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.
!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.
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.
| Junction | Shape | X and Y are… | Conditioning on M… |
|---|---|---|---|
| Chain (mediator) | X → M → Y | correlated | blocks the path |
| Fork (confounder) | X ← M → Y | correlated | blocks the path |
| Collider | X → M ← Y | independent | opens the path |
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.
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.
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
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
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.