4. Backdoor Criterion

A main interest is in causal analysis is to estimate the effect of one variable \(X\) (cause) on another \(Y\) (effect); in general, \(X\) and \(Y\) could be mutually exclusive sets of variables. Motivating examples are the following.

  • What’s the effect of smoking on lung cancer?

  • What’s the effect of studying on grades?

  • What’s the effect of carbon emission on climate change?

The problem with estimating “causal” effect is with confounders. Confounders are variables that influence both the cause and effect. Confounders lead to spurios associations and add noise to the estimation of causal effects. If we can identify confounders, then we can control, block or freeze their influence and isolate and estimate the causal effects of \(X\) on \(Y\).

In a directed acyclic graph (DAG) partially representing a causal model, we can use the Backdoor criterion to identify confounders. A set of variables \(Z\) satisifies the backdoor criterion relative to \(X\) and \(Y\) if the following are true:

  • \(Z\) blocks all backdoor paths from \(X\) to \(Y\)

  • \(Z\) does not contain any descendants of \(X\)

Backdoor paths have arrows leading into \(X\) (meaning, backdoor paths start from \(X\) and then to its parents and ultimately \(Y\)). Backdoor paths are commonly referred to as non-causal pathways. Frontdoor paths have arrows leading away from \(X\) (meaning, frontdoor paths start from \(X\) and then to its children and ultimately \(Y\)). Frontdoor paths are commonly referred to as causal pathways. For the two conditions above, correspondingly,

  • we find all active backdoor paths and identify confounders (the magic is to find variables of active backdoor paths that are not in a collider configuration), and

  • we leave all frontdoor paths alone (e.g. avoid post-treatment bias)

Finding confounders is much easier than the ideas above. According to the confounder selection criterion, given the DAG, simply use the parents of \(X\), \(Y\) or both as confounders. This approach is easy, but a bit too crude and brute force; it might be that during real-world validation, some parents of \(X\) or \(Y\) are not practical/ethical to control for, and we need to find other confounders.

Once we have identify counfounders, then we can use the backdoor adjustment formula to estimate the causal effect.

\(P(y|do(x)) = \sum_z P(y|x,z) P(z)\)

Let’s have fun and see how we can programmatically find confounders using the backdoor criterion. We will take some networks from the following videos (which explains the backdoor criterion very well).

4.1. Build graphs

Let’s build the graphs.

[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 = '''
W2 -> W1
W2 -> W3
W1 -> T
C -> T
T -> M
W3 -> Y
C -> Y
M -> Y
'''.strip()

g1 = get_graph(s)
[3]:
s = '''
W -> Z
U -> Z
U -> T
Z -> X
X -> T
X -> Y
T -> Y
'''.strip()

g2 = get_graph(s)

Now let’s visualize the graphs.

[4]:
import matplotlib.pyplot as plt

def plot_graph(g, ax):
    pos = nx.nx_agraph.graphviz_layout(g.d, prog='dot')

    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, ax = plt.subplots(1, 2, figsize=(10, 3.5))

plot_graph(g1, ax[0])
plot_graph(g2, ax[1])

ax[0].set_title('Graph 1')
ax[1].set_title('Graph 2')

fig.tight_layout()
_images/backdoor-criterion_6_0.png

4.2. Confounder identification

The algorithm to find confounders is as follows.

  • Input

    • \(G\): DAG

    • \(X\): cause

    • \(Y\): effect

  • Output: \(Z\) confounders

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

  • For all active backdoor path \(p\) in \(P\), find the colliders \(C\)

  • Find all the descendants of \(X\), \(D\)

  • Denote \(A\) as all the nodes in the active backdoor paths, then \(Z = A \setminus C \cup D \cup X \cup Y\)

  • return \(Z\)

[5]:
import itertools

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 get_colliders(g, path):
    if len(path) < 3:
        return []

    path_triplets = get_path_triplets(path)
    return [z for x, z, y in path_triplets if get_triplet_type(g, x, z, y) == 'converging']

def get_paths(g, x, y):
    def is_backdoor_path(p):
        if g.d.has_edge(p[1], p[0]):
            return True
        return False

    def is_frontdoor_path(p):
        if g.d.has_edge(p[0], p[1]):
            return True
        return False

    def get_path_metadata(p):
        xy = {x, y}
        colliders = get_colliders(g, p)
        confounders = list(set(p) - xy - set(colliders))

        return {
            'x': x,
            'y': y,
            'z': confounders,
            'path': p,
            'is_active': is_active_path(g, p),
            'colliders': colliders,
            'backdoor': is_backdoor_path(p),
            'frontdoor': is_frontdoor_path(p)
        }
    paths = get_all_paths(g, x, y)
    return [get_path_metadata(p) for p in paths]

def get_backdoors(g, x, y):
    paths = get_paths(g, x, y)

    colliders = [p['colliders'] for p in paths if p['is_active'] == False and p['backdoor'] == True]
    colliders = itertools.chain(*colliders)
    colliders = set(colliders)
    # print(f'{colliders=}')

    descendants = nx.descendants(g.d, x)
    # print(f'{descendants=}')

    exclude = colliders | descendants | {x, y}
    # print(f'{exclude=}')

    candidates = [[n for n in p['path'] if n not in exclude] for p in paths if p['is_active'] == True and p['backdoor'] == True]
    # print(f'{candidates=}')
    candidates = itertools.chain(*candidates)
    candidates = set(candidates)

    return candidates

The first graph actually has backdoor paths. Thus, the candidate confounders are \(C, W_1, W_2, W_3\). Note that the weakness of this algorithm, as it is, is that it does not identify the minimal set of confounders to condition on. We only need to include \(W_1\), \(W_2\) or \(W_3\) and not all of them.

[6]:
get_paths(g1, 'T', 'Y')
[6]:
[{'x': 'T',
  'y': 'Y',
  'z': ['W2', 'W3', 'W1'],
  'path': ['T', 'W1', 'W2', 'W3', 'Y'],
  'is_active': True,
  'colliders': [],
  'backdoor': True,
  'frontdoor': False},
 {'x': 'T',
  'y': 'Y',
  'z': ['C'],
  'path': ['T', 'C', 'Y'],
  'is_active': True,
  'colliders': [],
  'backdoor': True,
  'frontdoor': False},
 {'x': 'T',
  'y': 'Y',
  'z': ['M'],
  'path': ['T', 'M', 'Y'],
  'is_active': True,
  'colliders': [],
  'backdoor': False,
  'frontdoor': True}]
[7]:
get_backdoors(g1, 'T', 'Y')
[7]:
{'C', 'W1', 'W2', 'W3'}

The second graph has no backdoor paths. Thus, there are no confounders identified!

[8]:
get_paths(g2, 'W', 'Y')
[8]:
[{'x': 'W',
  'y': 'Y',
  'z': ['X', 'U'],
  'path': ['W', 'Z', 'U', 'T', 'X', 'Y'],
  'is_active': False,
  'colliders': ['Z', 'T'],
  'backdoor': False,
  'frontdoor': True},
 {'x': 'W',
  'y': 'Y',
  'z': ['T', 'U'],
  'path': ['W', 'Z', 'U', 'T', 'Y'],
  'is_active': False,
  'colliders': ['Z'],
  'backdoor': False,
  'frontdoor': True},
 {'x': 'W',
  'y': 'Y',
  'z': ['T', 'X', 'Z'],
  'path': ['W', 'Z', 'X', 'T', 'Y'],
  'is_active': True,
  'colliders': [],
  'backdoor': False,
  'frontdoor': True},
 {'x': 'W',
  'y': 'Y',
  'z': ['X', 'Z'],
  'path': ['W', 'Z', 'X', 'Y'],
  'is_active': True,
  'colliders': [],
  'backdoor': False,
  'frontdoor': True}]
[9]:
get_backdoors(g2, 'W', 'Y')
[9]:
set()

4.3. Minimal confounders

Here’s how to identify the minimal set of confounders.

[10]:
def find_minimal_confounders(g, path, x, y, z):
    keep = []
    for _i, _c in enumerate(z):
        _z = keep + [_c]

        if not is_active_path(g, path, _z):
            if len(keep) < 1:
                keep.append(_c)
                break

    return keep

def get_minimal_confounders(g, x, y):
    paths = get_paths(g, x, y)
    paths = filter(lambda p: p['is_active'] == True and p['backdoor'] == True, paths)

    confounders = [find_minimal_confounders(g, p['path'], x, y, p['z']) for p in paths]
    confounders = itertools.chain(*confounders)
    confounders = set(confounders)
    return list(confounders)

For the first graph, \(I(T,Y|W_2,C)\).

[11]:
get_minimal_confounders(g1, 'T', 'Y')
[11]:
['W2', 'C']

For the second graph, \(I(W,Y|\{\})\).

[12]:
get_minimal_confounders(g2, 'W', 'Y')
[12]:
[]

4.4. Minimal mediators

While we are at it, we can also identify the minimal set of mediators.

[13]:
def find_minimal_mediator(g, path, x, y, z):
    colliders = get_colliders(g, path)
    exclude = {x, y} | set(colliders)
    candidates = set(path) - exclude

    keep = []
    for _i, _c in enumerate(candidates):
        _z = keep + [_c]

        if not is_active_path(g, path, _z):
            if len(keep) < 1:
                keep.append(_c)
                break

    return keep

def get_minimal_mediators(g, x, y):
    all_paths = get_paths(g, x, y)
    fontdoor_paths = filter(lambda p: p['is_active'] == True and p['frontdoor'] == True, all_paths)

    mediators = [find_minimal_mediator(g, p['path'], x, y, p['z']) for p in fontdoor_paths]
    mediators = itertools.chain(*mediators)
    mediators = set(mediators)

    colliders = [p['colliders'] for p in all_paths]
    colliders = itertools.chain(*colliders)
    colliders = set(colliders)

    return list(mediators - colliders)

For the first graph, \(I(T,Y|M)\).

[14]:
get_minimal_mediators(g1, 'T', 'Y')
[14]:
['M']

For the second graph, \(I(W,Y|X)\).

[15]:
get_minimal_mediators(g2, 'W', 'Y')
[15]:
['X']
[ ]: