Tensors and graphs

Creative Commons License

aGrUM

interactive online version

This notebook covers two related but independent low-level tools in pyAgrum : named Tensors (multi-dimensional arrays used to represent probability tables) and Graphs (the topological objects underlying every graphical model).

(Named) tensors

In pyAgrum>=2.0.0, Tensors (previously Potentials) represent multi-dimensionnal arrays with (discrete) random variables attached to each dimension. This mathematical object have tensorial operators w.r.t. to the variables attached.

In [1]:
import pyagrum as gum
import pyagrum.lib.notebook as gnb

va, vb, vc = [gum.LabelizedVariable(s, s, 2) for s in "abc"]

Tensor algebra

In [2]:
p1 = gum.Tensor(va, vb).fillWith([1, 2, 3, 4]).normalize()
p2 = gum.Tensor(vb, vc).fillWith([4, 5, 2, 3]).normalize()
In [3]:
gnb.flow.row(p1, p2, p1 + p2, captions=["p1", "p2", "p1+p2"])
Out[3]:
a
b
0
1
0
0.10000.2000
1
0.30000.4000

p1
b
c
0
1
0
0.28570.3571
1
0.14290.2143

p2
b
a
c
0
1
0
0
0.38570.6571
1
0.24290.5143
1
0
0.48570.7571
1
0.34290.6143

p1+p2
In [4]:
p3 = p1 + p2
p3 / p3.sumOut(["b"])
Out[4]:
c
b
a
0
1
0
0
0.36990.3208
1
0.39080.3582
1
0
0.63010.6792
1
0.60920.6418
In [5]:
p4 = gum.Tensor() + p3
gnb.flow.row(p3, p4, captions=["p3", "p4"])
Out[5]:
b
a
c
0
1
0
0
0.38570.6571
1
0.24290.5143
1
0
0.48570.7571
1
0.34290.6143

p3
b
a
c
0
1
0
0
1.38571.6571
1
1.24291.5143
1
0
1.48571.7571
1
1.34291.6143

p4

Bayes’ theorem

In [6]:
bn = gum.fastBN("a->c;b->c", 3)
bn
Out[6]:
G a a c c a->c b b b->c

In such a small bayes net, we can directly manipulate \(P(a,b,c)\). For instance :

\[P(b|c)=\frac{\sum_{a} P(a,b,c)}{\sum_{a,b} P(a,b,c)}\]
In [7]:
pABC = bn.cpt("a") * bn.cpt("b") * bn.cpt("c")
pBgivenC = pABC.sumOut(["a"]) / pABC.sumOut(["a", "b"])

pBgivenC.putFirst("b")  # in order to have b horizontally in the table
Out[7]:
b
c
0
1
2
0
0.29680.22560.4776
1
0.12670.04790.8254
2
0.39200.06190.5461

Joint, marginal probability, likelihood

Let’s compute the joint probability \(P(A,B)\) from \(P(A,B,C)\)

In [8]:
pAC = pABC.sumOut(["b"])
print("pAC really is a probability : it sums to {}".format(pAC.sum()))
pAC
pAC really is a probability : it sums to 1.0
Out[8]:
a
c
0
1
2
0
0.18240.11980.0298
1
0.19980.10190.0366
2
0.25300.02310.0535

Computing \(p(A)\)

In [9]:
pAC.sumOut(["c"])
Out[9]:
a
0
1
2
0.63530.24480.1199

Computing \(p(A |C=1)\)

It is easy to compute \(p(A, C=1)\)

In [10]:
pAC.extract({"c": 1})
Out[10]:
a
0
1
2
0.19980.10190.0366

Moreover, we know that \(P(C=1)=\sum_A P(A,C=1)\)

In [11]:
pAC.extract({"c": 1}).sum()
Out[11]:
0.3383032235186551

Now we can compute \(p(A|C=1)=\frac{P(A,C=1)}{p(C=1)}\)

In [12]:
pAC.extract({"c": 1}).normalize()
Out[12]:
a
0
1
2
0.59070.30110.1082

Computing \(P(A|C)\)

\(P(A|C)\) is represented by a matrix that verifies \(p(A|C)=\frac{P(A,C)}{P(C}\)

In [13]:
pAgivenC = (pAC / pAC.sumIn("c")).putFirst("a")
# putFirst("a") : to correctly show a cpt, the first variable have to bethe conditionned one
gnb.flow.row(pAgivenC, pAgivenC.extract({"c": 1}), captions=["$P(A|C)$", "$P(A|C=1)$"])
Out[13]:
a
c
0
1
2
0
0.54940.36090.0897
1
0.59070.30110.1082
2
0.76760.07020.1622

$P(A|C)$
a
0
1
2
0.59070.30110.1082

$P(A|C=1)$

Likelihood \(P(A=2|C)\)

A likelihood can also be found in this matrix.

In [14]:
pAgivenC.extract({"a": 2})
Out[14]:
c
0
1
2
0.08970.10820.1622

A likelihood does not have to sum to 1. It is not relevant to normalize it.

In [15]:
pAgivenC.sumIn(["a"])
Out[15]:
a
0
1
2
1.90770.73210.3601

Numpy interoperability

A Tensor’s content can be read as a numpy.ndarray (in the order of its variables) with toarray(), and built back from one with fillWith().

In [16]:
arr = pAC.toarray()
arr
Out[16]:
array([[0.18243707, 0.11984106, 0.02980022],
       [0.19984102, 0.10185916, 0.03660304],
       [0.25302791, 0.02313105, 0.05345947]])
In [17]:
gum.Tensor(pAC).fillWith(arr)
Out[17]:
a
c
0
1
2
0
0.18240.11980.0298
1
0.19980.10190.0366
2
0.25300.02310.0535

tool on tensors : entropy of (probabilistic) tensor

In [18]:
%matplotlib inline
from pylab import *
import matplotlib.pyplot as plt
import numpy as np
In [19]:
p1 = gum.Tensor(va)
x = np.linspace(0, 1, 100)
plt.plot(x, [p1.fillWith([p, 1 - p]).entropy() for p in x])
plt.show()
../_images/notebooks_93-Tools_tensorsAndGraphs_35_0.svg
In [20]:
t = gum.LabelizedVariable("t", "t", 3)
p1 = gum.Tensor().add(t)


def entrop(bc):
  """
  bc is a list [a,b,c] close to a distribution
  (normalized just to be sure)
  """
  return p1.fillWith(bc).normalize().entropy()


import matplotlib.tri as tri

corners = np.array([[0, 0], [1, 0], [0.5, 0.75**0.5]])
triangle = tri.Triangulation(corners[:, 0], corners[:, 1])

# Mid-points of triangle sides opposite of each corner
midpoints = [(corners[(i + 1) % 3] + corners[(i + 2) % 3]) / 2.0 for i in range(3)]


def xy2bc(xy, tol=1.0e-3):
  """
  From 2D Cartesian coordinates to barycentric.
  """
  s = [(corners[i] - midpoints[i]).dot(xy - midpoints[i]) / 0.75 for i in range(3)]
  return np.clip(s, tol, 1.0 - tol)


def draw_entropy(nlevels=200, subdiv=6, **kwargs):
  refiner = tri.UniformTriRefiner(triangle)
  trimesh = refiner.refine_triangulation(subdiv=subdiv)
  pvals = [entrop(xy2bc(xy)) for xy in zip(trimesh.x, trimesh.y)]

  plt.tricontourf(trimesh, pvals, nlevels, **kwargs)
  plt.axis("equal")
  plt.ylim(0, 0.75**0.5)
  plt.axis("off")


draw_entropy()
plt.show()
../_images/notebooks_93-Tools_tensorsAndGraphs_36_0.svg
In [ ]:

(Named) Graphs

aGrUM’s graph classes represent the topology of a graphical model, independently of variables or probability tables: pyagrum.DiGraph (arcs), pyagrum.UndiGraph (edges), pyagrum.MixedGraph (both), pyagrum.DAG (acyclic) and pyagrum.PDAG (partially directed, acyclic). Nodes are plain integers (NodeId); a name can optionally be attached to a node with setName.

In [21]:
g = gum.MixedGraph()
a, b, c = g.addNode(), g.addNode(), g.addNode()
g.setName(a, "A")
g.setName(b, "B")
g.setName(c, "C")
g.addArc(a, b)
g.addEdge(b, c)
gnb.show(g)
../_images/notebooks_93-Tools_tensorsAndGraphs_39_0.svg

The fast syntax for graphs

Similarly to pyagrum.fastBN/pyagrum.fastMRF/pyagrum.fastID, graphs can be built from a compact dot-like description : '->' for a directed arc, '-' for an undirected edge, ';' to separate independent chains.

A single ‘-’ (not ‘–’) is used for edges on purpose : fastMRF already uses ‘–’ to list the variables of a single factor (a clique), a different construct from a chain of pairwise edges. Writing ‘A–B’ in the functions below raises an error rather than being silently misread.

In [22]:
g1 = gum.fastDiGraph("A->B->C;B->E")
g2 = gum.fastUndiGraph("A-B-C")
g3 = gum.fastMixedGraph("A->B-C")
gnb.flow.row(g1, g2, g3, captions=["fastDiGraph", "fastUndiGraph", "fastMixedGraph"])
Out[22]:
0 (0) A 1 (1) B 0->1 2 (2) C 1->2 3 (3) E 1->3
fastDiGraph
no_name 0 (0) A 1 (1) B 0->1 2 (2) C 1->2
fastUndiGraph
no_name 0 (0) A 1 (1) B 0->1 2 (2) C 1->2
fastMixedGraph

If every node token in the description is a plain (non-negative) integer, those integers are used directly as NodeIds instead of names :

In [23]:
sorted(gum.fastDiGraph("1->2->100").nodes())
Out[23]:
[1, 2, 100]

fastDAG and fastPDAG enforce their own structural invariant while parsing, here fastDAG rejecting a directed cycle :

In [24]:
try:
  gum.fastDAG("A->B->C->A")
except gum.InvalidDirectedCycle as e:
  print(e)
[pyAgrum] Directed cycle detected: Add a directed cycle in a dag !

The convenience fastGraph entry point

pyagrum.fastGraph picks a graph type for you from a quick read of the description : arc-only descriptions try fastDAG first, falling back to fastDiGraph if that would create a directed cycle ; descriptions mixing arcs and edges try fastPDAG first, falling back to fastMixedGraph on the same condition ; edge-only descriptions build a fastUndiGraph.

In [25]:
g1 = gum.fastGraph("A->B->C;B->E")  # arcs only, no cycle -> DAG
g2 = gum.fastGraph("A->B-C")  # arcs and edges, no cycle -> PDAG
g3 = gum.fastGraph("A-B-C")  # edges only -> UndiGraph
gnb.flow.row(
  g1, g2, g3, captions=[f"fastGraph → {type(g1).__name__}", f"fastGraph → {type(g2).__name__}", f"fastGraph → {type(g3).__name__}"]
)
Out[25]:
0 (0) A 1 (1) B 0->1 2 (2) C 1->2 3 (3) E 1->3
fastGraph → DAG
no_name cluster_0 0 (0) A 1 (1) B 0->1 2 (2) C 1->2
fastGraph → PDAG
no_name 0 (0) A 1 (1) B 0->1 2 (2) C 1->2
fastGraph → UndiGraph

When the arcs alone would create a directed cycle, fastGraph falls back to the corresponding non-acyclic type instead of raising :

In [26]:
g4 = gum.fastGraph("A->B->C->A")  # a directed cycle -> DiGraph (not DAG)
g5 = gum.fastGraph("A->B->C->A;B-D")  # cycle + an edge -> MixedGraph (not PDAG)
gnb.flow.row(g4, g5, captions=[f"fastGraph → {type(g4).__name__}", f"fastGraph → {type(g5).__name__}"])
Out[26]:
0 (0) A 1 (1) B 0->1 2 (2) C 1->2 2->0
fastGraph → DiGraph
no_name 0 (0) A 1 (1) B 0->1 2 (2) C 1->2 3 (3) D 1->3 2->0
fastGraph → MixedGraph

Graph algorithms

Once built, graphs expose several structural algorithms.

In [27]:
dag = gum.fastDAG("Rain->WetGrass<-Sprinkler")
moral = dag.moralGraph()
gnb.flow.row(dag, moral, captions=["DAG", "moral graph"])
Out[27]:
0 (0) Rain 1 (1) WetGrass 0->1 2 (2) Sprinkler 2->1
DAG
no_name 0 (0) Rain 1 (1) WetGrass 0->1 2 (2) Sprinkler 0->2 1->2
moral graph

DAG.dSeparation checks d-separation between (sets of) nodes given a conditioning set, without needing a full BayesNet :

In [28]:
rain, sprinkler, wetgrass = (dag.idFromName(n) for n in ["Rain", "Sprinkler", "WetGrass"])
print("Rain and Sprinkler d-separated (no conditioning) :", dag.dSeparation({rain}, {sprinkler}))
print("Rain and Sprinkler d-separated by WetGrass       :", dag.dSeparation({rain}, {sprinkler}, {wetgrass}))
Rain and Sprinkler d-separated (no conditioning) : True
Rain and Sprinkler d-separated by WetGrass       : False

Connected components and paths are available on undirected/mixed graphs :

In [29]:
g = gum.fastUndiGraph("A-B-C;D-E")
print("components  :", g.connectedComponentsList())
print("nb of comps :", g.connectedComponentsCount())
components  : {1: {3, 4}, 0: {0, 1, 2}}
nb of comps : 2
In [30]:
mg = gum.fastMixedGraph("A->B-C->D<-E<-F->B")
gnb.show(mg)
mg.mixedOrientedPath(mg.idFromName("A"), mg.idFromName("D"))
../_images/notebooks_93-Tools_tensorsAndGraphs_56_0.svg
Out[30]:
[0, 1, 2, 3]
In [31]:
mg.mixedOrientedPath(5,3) # from F to D
Out[31]:
[5, 4, 3]
In [ ]: