Accelerated Foundation in Applied Causal InferenceFor Economists · 15 modules · 45 lessons

5.1 DAGs and Structural Causal Models: Chains, Forks and Colliders

about 2 hoursSocial policy: job-training DAGs and collider-bias simulations
Lesson 5.1 · Module 5
DAGs and Structural Causal Models: Chains, Forks and Colliders
Video coming soon. The written lesson and the Colab notebook are ready below.
Colab notebook (.ipynb)
By the end of this lesson you can
  • ✓Draw a directed acyclic graph (DAG) for a policy question with networkx
  • ✓Write the structural causal model (SCM) that a DAG represents and simulate it
  • ✓Recognise the three building blocks (chains, forks, colliders) and what each implies for correlation
  • ✓Use d-separation to read off which variables should be independent
Lesson plan (120 minutes)
Written lesson

From equations to arrows

A structural causal model (SCM) lists, for every variable, the equation that produces it from its direct causes plus independent noise. A DAG draws those equations: an arrow X → Y means X appears in Y's equation. "Directed" because arrows have a direction; "acyclic" because nothing causes itself.

The DAG is where you write down your assumptions about the world before touching data. Fabozzi and Focardi stress the point: a causal model is a claim about how a system responds to manipulation, not a summary of correlations.

Python · Colab cell
!pip install -q numpy pandas statsmodels networkx matplotlib
import numpy as np, pandas as pd, networkx as nx
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
rng = np.random.default_rng(5)

Drawing DAGs in Python

We use networkx, a general graph library, so the code works in any Colab runtime. A small helper draws a DAG with readable labels.

Python · Colab cell
def draw_dag(edges, pos=None, title="", highlight=()):
    g = nx.DiGraph(edges)
    pos = pos or nx.spring_layout(g, seed=3)
    colors = ["#f59e0b" if n in highlight else "#bfdbfe" for n in g.nodes]
    plt.figure(figsize=(7, 4))
    nx.draw_networkx(g, pos, node_color=colors, node_size=2600, font_size=9,
                     arrowsize=20, edgecolors="#334155")
    plt.title(title); plt.axis("off"); plt.show()
    return g

job_training = draw_dag(
    [("Motivation", "Training"), ("Motivation", "Earnings"),
     ("PriorEducation", "Training"), ("PriorEducation", "Earnings"),
     ("Training", "Skills"), ("Skills", "Earnings")],
    pos={"Motivation": (0, 1), "PriorEducation": (2, 1), "Training": (0, 0),
         "Skills": (1, -0.6), "Earnings": (2, 0)},
    title="Job training: what causes what?", highlight=("Training", "Earnings"),
)

Chains, forks and colliders by simulation

Every DAG, however large, is built from three junctions. Each has a distinct signature in the data.

JunctionShapeX and Y are…Conditioning on M…
Chain (mediator)X → M → Ycorrelatedblocks the path
Fork (confounder)X ← M → Ycorrelatedblocks the path
ColliderX → M ← Yindependentopens the path
Python · Colab cell
N = 20_000
def slope(formula, df, var):
    return smf.ols(formula, data=df).fit().params[var]

# Chain: X -> M -> Y
x = rng.normal(size=N); m = 0.8 * x + rng.normal(size=N); y = 0.8 * m + rng.normal(size=N)
chain = pd.DataFrame(dict(x=x, m=m, y=y))

# Fork: X <- M -> Y
m = rng.normal(size=N); x = 0.8 * m + rng.normal(size=N); y = 0.8 * m + rng.normal(size=N)
fork = pd.DataFrame(dict(x=x, m=m, y=y))

# Collider: X -> M <- Y
x = rng.normal(size=N); y = rng.normal(size=N); m = x + y + rng.normal(size=N)
collider = pd.DataFrame(dict(x=x, m=m, y=y))

print(f"{'':10}{'y ~ x':>10}{'y ~ x + m':>12}")
for name, df in [("chain", chain), ("fork", fork), ("collider", collider)]:
    print(f"{name:10}{slope('y ~ x', df, 'x'):10.3f}{slope('y ~ x + m', df, 'x'):12.3f}")

Read the table row by row: conditioning on M kills the association in the chain and the fork, but creates one out of nothing in the collider.

The why

This table is the whole logic of covariate selection. Control for forks (confounders). Do not control for chains (mediators) if you want the total effect. Never control for colliders. Which is which cannot be learned from the data alone; it comes from the DAG you defend.

d-separation: reading independence off a graph

Two variables are d-separated given a set Z if every path between them is blocked: a chain or fork whose middle node is in Z, or a collider whose middle node (and all its descendants) is not in Z. d-separated variables must be independent in data generated by the DAG, a testable implication.

Python · Colab cell
d_sep = getattr(nx, "is_d_separator", None) or nx.d_separated   # name differs across networkx versions

g = nx.DiGraph([("X", "M"), ("Y", "M")])                          # the collider
print("X ⫫ Y          :", d_sep(g, {"X"}, {"Y"}, set()))
print("X ⫫ Y | M      :", d_sep(g, {"X"}, {"Y"}, {"M"}))

print("In the job-training DAG:")
print("  PriorEducation ⫫ Motivation        :", d_sep(job_training, {"PriorEducation"}, {"Motivation"}, set()))
print("  PriorEducation ⫫ Motivation | Training:", d_sep(job_training, {"PriorEducation"}, {"Motivation"}, {"Training"}))

Training is a collider between prior education and motivation. Among people who joined the programme, low prior education must be "compensated" by high motivation, so the two become correlated.

Exercise

Exercise

Write an SCM for the job-training DAG above (choose your own coefficients), simulate 20,000 people, and verify the two d-separation statements: prior education and motivation are uncorrelated overall, but correlated within each value of Training.

Show a solution
Python · Colab cell
motivation = rng.normal(size=N)
prior_educ = rng.normal(size=N)
training = (0.9 * motivation + 0.9 * prior_educ + rng.normal(size=N) > 0.5).astype(int)
sim = pd.DataFrame(dict(motivation=motivation, prior_educ=prior_educ, training=training))
print("Overall corr:          ", round(sim[["motivation", "prior_educ"]].corr().iloc[0, 1], 3))
for t in (0, 1):
    sub = sim[sim.training == t]
    print(f"Corr within training={t}:", round(sub[["motivation", "prior_educ"]].corr().iloc[0, 1], 3))

Checklist and recap

  • I can write an SCM and draw its DAG.
  • I know what conditioning does in chains, forks and colliders.
  • I can check d-separation in code and turn it into a testable prediction.