Backdoor paths
A backdoor path from treatment D to outcome Y is any path that starts with an arrow into D (D ← …). Backdoor paths carry non-causal association, like the fork in Lesson 5.1. A set of variables Z satisfies the backdoor criterion if:
- no variable in Z is a descendant of D (no mediators, no consequences of treatment), and
- Z blocks every backdoor path from D to Y.
If such a Z exists and is observed, adjusting for it (by regression, matching, weighting or Double ML) identifies the causal effect.
!pip install -q numpy pandas statsmodels networkx
import itertools, numpy as np, pandas as pd, networkx as nx
import statsmodels.formula.api as smf
rng = np.random.default_rng(21)
d_sep = getattr(nx, "is_d_separator", None) or nx.d_separated
Finding adjustment sets in code
We implement the criterion directly: remove the arrows out of D (so only backdoor paths remain), then look for sets of observed non-descendants of D that d-separate D from Y.
def backdoor_sets(g, treatment, outcome, observed, max_size=4):
g_back = g.copy()
g_back.remove_edges_from(list(g.out_edges(treatment))) # keep only backdoor paths
forbidden = nx.descendants(g, treatment)
candidates = [v for v in observed if v not in forbidden and v not in (treatment, outcome)]
valid = []
for k in range(len(candidates) + 1):
for z in itertools.combinations(candidates, k):
if k <= max_size and d_sep(g_back, {treatment}, {outcome}, set(z)):
valid.append(set(z))
return valid
dag = nx.DiGraph([
("Motivation", "Training"), ("Motivation", "Earnings"),
("PriorEducation", "Training"), ("PriorEducation", "Earnings"),
("Training", "Skills"), ("Skills", "Earnings"),
])
print("If Motivation is observed:")
for s in backdoor_sets(dag, "Training", "Earnings", ["Motivation", "PriorEducation", "Skills"]):
print(" ", s)
print("If Motivation is NOT observed:",
backdoor_sets(dag, "Training", "Earnings", ["PriorEducation", "Skills"]) or "no valid set")
Skills never appears: it is a descendant of Training (a mediator). And when motivation is unobserved,
no adjustment set exists. Selection on observables fails, and you need a different design: the front
door below, an instrument (Module 9) or a natural experiment.
Good and bad controls by simulation
N = 20_000
motivation = rng.normal(size=N)
prior_educ = rng.normal(size=N)
training = rng.binomial(1, 1 / (1 + np.exp(-(0.8 * motivation + 0.8 * prior_educ))))
skills = 2.0 * training + rng.normal(size=N) # mediator
earnings = 1_000 * skills + 1_500 * motivation + 1_200 * prior_educ + rng.normal(0, 1_000, N)
df = pd.DataFrame(dict(training=training, skills=skills, motivation=motivation,
prior_educ=prior_educ, earnings=earnings))
TRUE_TOTAL = 2.0 * 1_000
specs = {
"no controls": "earnings ~ training",
"backdoor set {Motivation, PriorEducation}": "earnings ~ training + motivation + prior_educ",
"+ mediator Skills (bad control)": "earnings ~ training + motivation + prior_educ + skills",
}
print(f"True total effect: {TRUE_TOTAL:,.0f}")
for label, f in specs.items():
print(f"{label:45s} {smf.ols(f, data=df).fit().params['training']:8,.0f}")
A quick classification (Cinelli, Forney and Pearl's "crash course in good and bad controls"):
- Good: confounders, and proxies on backdoor paths.
- Neutral, often helpful: pure causes of the outcome (they improve precision).
- Bad: mediators (if you want the total effect), colliders, descendants of the treatment, and pure causes of the treatment (they cost precision and amplify any remaining bias).
The front-door criterion
When the confounder is unobserved but the effect runs entirely through an observed mediator that is not itself confounded with the outcome, the front-door formula still identifies the effect in two unconfounded steps:
- Effect of Training on Skills: no backdoor path, so a simple regression works.
- Effect of Skills on Earnings: the backdoor path Skills ← Training ← Motivation → Earnings is blocked by controlling for Training.
Multiply the two.
step1 = smf.ols("skills ~ training", data=df).fit().params["training"]
step2 = smf.ols("earnings ~ skills + training", data=df).fit().params["skills"]
print(f"Front-door estimate: {step1 * step2:,.0f} (true {TRUE_TOTAL:,.0f}), "
"without ever observing motivation in the formula")
The front door is rare in practice, because few mediators are both complete and unconfounded, but it shows the real lesson: identification is a property of the graph, not of the estimator.
Checklist and recap
- I can state the backdoor criterion and list valid adjustment sets for a DAG.
- I can explain, with a simulation, why mediators are bad controls.
- I know when the front-door criterion applies.