2. d-separation, Active Paths

Direction-dependent separation, or d-separation, is a rule for reading conditional independencies off a directed acyclic graph, DAG. The utility of d-separation is that it can determine if two mutually exclusive sets of variables, \(X\) and \(Y\), are independent give a third \(Z\). The output of d-separation is true when \(X\) and \(Y\) are independent given \(Z\), denoted as \(I(X,Y|Z)\), and false when \(X\) and \(Y\) are dependent given \(Z\), denoted as \(D(X,Y|Z)\). If the output of d-separation is true, then \(X\) and \(Y\) are said to be d-separated given \(Z\); likewise, if the output of d-separation is false, then \(X\) and \(Y\) are said to be d-connected given \(Z\).

Let’s motivate the importance of d-separation. Let’s say we want to know if the treatment of an illness, \(T\), and the outcome of the illness, \(E\), are independent given race, \(R\). We want to know which of the following are true.

  • \(I(T,E|R)\)

  • \(D(T,E|R)\)

If \(T\) and \(E\) given \(R\) are d-separated, then once we know \(R\), \(T\) and \(E\) are no longer related. If \(T\) and \(E\) given \(R\) are d-connected, then when we know \(R\), \(T\) and \(E\) become related.

2.1. Graph example

Here is a graph taken from this most excellent video tutorial on d-separation exercises. We will be using this graph to show some d-separation queries.

[1]:
import networkx as nx
from collections import namedtuple

Graph = namedtuple('Graph', 'u, d')

def get_edges(s):
    edges = s.split('\n')
    edges = map(lambda line: line.split('->'), edges)
    edges = map(lambda tokens: tuple([t.strip() for t in tokens]), edges)
    edges = list(edges)

    return edges

def get_directed_graph(edges):
    g = nx.DiGraph()
    g.add_edges_from(edges)

    return g

def get_undirected_graph(edges):
    g = nx.Graph()
    g.add_edges_from(edges)

    return g

def get_graph(s):
    e = get_edges(s)

    d = get_directed_graph(e)
    u = get_undirected_graph(e)
    g = Graph(u, d)

    return g
[2]:
s = '''
U -> W
V -> W
V -> X
V -> T
W -> Y
X -> Y
T -> Z
'''.strip()

g = get_graph(s)
[3]:
import matplotlib.pyplot as plt

pos = nx.nx_agraph.graphviz_layout(g.d, prog='dot')

fig, ax = plt.subplots(figsize=(5, 5))

nx.draw_networkx_nodes(
    G=g.d,
    pos=pos,
    ax=ax,
    node_size=100,
    alpha=0.5,
    node_color='red'
)

nx.draw_networkx_labels(
    G=g.d,
    pos=pos,
    ax=ax,
    font_size=15,
    font_color='black',
    alpha=1.0
)

nx.draw_networkx_edges(
    G=g.d,
    pos=pos,
    alpha=0.25,
    arrowsize=30,
    ax=ax
)

fig.tight_layout()
_images/d-separation_4_0.png

2.2. Algorithm

Here’s an algorithm to determine if \(X\) and \(Y\) are d-separated given \(Z\).

  • Input:

    • \(G\): DAG

    • \(X\): start node

    • \(Y\): end node

    • \(Z\): evidence nodes

  • Output: True or False

  • Find all paths, \(P\), between \(X\) and \(Y\)

  • For each path \(p\) in \(P\)

    • if \(p\) is active, return False

  • return True

In the algorithm above, if we find any path in \(G\) between \(X\) and \(Y\) (given \(Z\)) that is active, then \(X\) and \(Y\) are d-connected; if we cannot find any active path between \(X\) and \(Y\) (given \(Z\)) in \(G\) then they are d-separated. The idea of an active and inactive path comes through understanding the configuration of triplets in a DAG. In general, there are 3 types of triplet configurations.

  • serial: \(X \rightarrow Z \rightarrow Y\) (causal chain)

  • diverging: \(X \leftarrow Z \rightarrow Y\) (common cause)

  • converging: \(X \rightarrow Z \leftarrow Y\) (common effect, also called a v-structure)

For the serial and diverging configurations, without evidence of \(Z\), the configurations are active; if we do have evidence of \(Z\), then the serial and diverging configurations are inactive. The converging configuration is unique; without evidence of \(Z\), the configuration is inactive, and with evidence, the configuration is active. Active and inactive is about opening or blocking information, respectively.

A path between \(X\) and \(Y\) (given \(Z\)) is said to be active if all contiguous triplets are active (like a conjunctive boolean AND). If at least one of the contiguous triplets in the path is inactive, then the path is inactive (like a disjunctive boolean OR).

[4]:
def get_all_paths(g, start, stop):
    return nx.all_simple_paths(g.u, start, stop)

def get_path_triplets(path):
    if len(path) < 3:
        return [path]
    else:
        return [path[i:i+3] for i in range(0, len(path) - 2)]

def get_triplet_type(g, x, z, y):
    x_children = set(g.d.successors(x))
    z_children = set(g.d.successors(z))
    y_children = set(g.d.successors(y))

    if z in x_children and y in z_children:
        return 'serial'
    if z in y_children and x in z_children:
        return 'serial'
    if x in z_children and y in z_children:
        return 'diverging'
    if z in x_children and z in y_children:
        return 'converging'
    raise Exception(f'cannot determine triplet configuration: x={x}, z={z}, y={y}')

def is_active_triplet(g, x, z, y, evidence=set()):
    triplet_type = get_triplet_type(g, x, z, y)

    if triplet_type in {'serial', 'diverging'}:
        if z in evidence:
            return False
        else:
            return True
    else:
        z_set = nx.descendants(g.d, z)
        z_set.add(z)

        if len(z_set & evidence) > 0:
            return True
        else:
            return False

def is_active_path(g, path, evidence=set()):
    if len(path) < 3:
        if path[0] in evidence or path[-1] in evidence:
            return False
        else:
            return True

    path_triplets = get_path_triplets(path)

    for x, z, y in path_triplets:
        if not is_active_triplet(g, x, z, y, evidence):
            return False
    return True

def is_d_separated(g, x, y, evidence=set()):
    paths = list(get_all_paths(g, x, y))

    if len(paths) < 1:
        return True

    for path in get_all_paths(g, x, y):
        if is_active_path(g, path, evidence):
            return False
    return True

2.3. Examples

Below are some exercise examples to query for d-separation based on the algorithm and code above.

2.3.1. Example 1

[5]:
is_d_separated(g, 'V', 'Z')
[5]:
False

2.3.2. Example 2

[6]:
is_d_separated(g, 'V', 'Z', {'T'})
[6]:
True

2.3.3. Example 3

[7]:
is_d_separated(g, 'U', 'V')
[7]:
True

2.3.4. Example 4

[8]:
is_d_separated(g, 'U', 'V', {'W'})
[8]:
False

2.3.5. Example 5

[9]:
is_d_separated(g, 'U', 'V', {'X'})
[9]:
True

2.3.6. Example 6

[10]:
is_d_separated(g, 'U', 'V', {'Y'})
[10]:
False

2.3.7. Example 7

[11]:
is_d_separated(g, 'U', 'V', {'Z'})
[11]:
True

2.3.8. Example 8

[12]:
is_d_separated(g, 'W', 'X')
[12]:
False

2.3.9. Example 9

[13]:
is_d_separated(g, 'X', 'T', {'V'})
[13]:
True

2.3.10. Example 10

[14]:
is_d_separated(g, 'X', 'W', {'U'})
[14]:
False

2.3.11. Example 11

[15]:
is_d_separated(g, 'Y', 'Z')
[15]:
False

2.3.12. Example 12

[16]:
is_d_separated(g, 'Y', 'Z', 'T')
[16]:
True

2.3.13. Example 13

[17]:
is_d_separated(g, 'Y', 'Z', {'X'})
[17]:
False

2.3.14. Example 14

[18]:
is_d_separated(g, 'Y', 'Z', {'V'})
[18]:
True

2.3.15. Example 15

[19]:
is_d_separated(g, 'W', 'Z', {'V'})
[19]:
True

2.3.16. Example 16

[20]:
is_d_separated(g, 'U', 'Z')
[20]:
True

2.3.17. Example 17

[21]:
is_d_separated(g, 'U', 'Z', {'Y'})
[21]:
False

2.3.18. Additional examples

[22]:
is_d_separated(g, 'U', 'W')
[22]:
False
[23]:
is_d_separated(g, 'U', 'W', {'U'})
[23]:
True
[24]:
is_d_separated(g, 'U', 'W', {'W'})
[24]:
True
[ ]: