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

5.2 The Backdoor Criterion, Adjustment Sets and the Front Door

about 2 hoursSocial policy: job-training DAGs and collider-bias simulations
Lesson 5.2 · Module 5
The Backdoor Criterion, Adjustment Sets and the Front Door
Video coming soon. The written lesson and the Colab notebook are ready below.
Colab notebook (.ipynb)
By the end of this lesson you can
  • ✓State the backdoor criterion and find valid adjustment sets programmatically
  • ✓Show by simulation that controlling for a mediator removes the effect you want to measure
  • ✓Estimate an effect with the front-door formula when the confounder is unobserved
  • ✓Classify candidate controls as good, neutral or bad
Lesson plan (120 minutes)
Written lesson

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:

  1. no variable in Z is a descendant of D (no mediators, no consequences of treatment), and
  2. 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.

Python · Colab cell
!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.

Python · Colab cell
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

Python · Colab cell
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}")
Key takeaway

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:

  1. Effect of Training on Skills: no backdoor path, so a simple regression works.
  2. Effect of Skills on Earnings: the backdoor path Skills ← Training ← Motivation → Earnings is blocked by controlling for Training.

Multiply the two.

Python · Colab cell
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.