
In Causal Inference I we saw that the naive difference in means equals the true effect plus selection bias, and that randomization removes that bias. But we often cannot run an experiment:
This lecture is about doing causal inference without an experiment — which forces us to be explicit about our assumptions using causal graphs.
A causal graph is a directed acyclic graph (DAG): nodes are variables, and an arrow \(X \rightarrow Y\) means “\(X\) is a direct cause of \(Y\)” (Pearl et al. 2016).
The graph encodes assumptions we bring to the data (from domain knowledge). The data alone cannot draw it for us — but once drawn, the graph tells us exactly which variables to control for.

Causal effects flow along directed paths. Spurious associations flow along the other paths — so we need to know which paths carry association and which do not. Remarkably, every path is built from just three elementary structures.

Any path is a sequence of these: each interior node is a mediator, a confounder, or a collider on that path.
A path carries association between its endpoints only if it is open. Two rules decide:
A path is blocked if at least one of its interior nodes is blocked; otherwise it is open. Two variables are (conditionally) independent when every path between them is blocked.
Sanity check on the example DAG: \(X \leftarrow A \to Y\) is open (fork, nothing conditioned) — that is the ice-cream/temperature confounding of Lecture I. Condition on \(A\) and it closes.
The three structures behave oppositely when you condition (adjust/stratify) on the middle node:
| Structure | Middle node | Adjust for it? | Why |
|---|---|---|---|
| Fork \(X \leftarrow Z \to Y\) | confounder | Yes | blocks the spurious backdoor path |
| Chain \(X \to M \to Y\) | mediator | No* | it’s part of the effect you want |
| Collider \(X \to C \leftarrow Y\) | collider | No! | conditioning creates spurious association |
* Adjust for a mediator only if you specifically want the direct (not total) effect.
“Just throw every variable into the regression” is wrong — controlling for a collider or a mediator introduces bias. Choosing controls is a causal decision, not a statistical one (Cinelli et al. 2024).
A backdoor path is a non-causal path from \(X\) to \(Y\) that starts with an arrow into \(X\) (e.g. \(X \leftarrow Z \to Y\)). Backdoor paths are the graph-language for confounding.
Backdoor criterion: a set of variables \(S\) suffices to identify the causal effect of \(X\) on \(Y\) if \(S\) blocks every backdoor path and contains no descendants of \(X\) (Pearl 2009).
When such an \(S\) exists and we adjust for it, we get the adjustment formula: \[ P(Y \mid do(X{=}x)) = \sum_{s} P(Y \mid X{=}x, S{=}s)\, P(S{=}s). \] This is the formal justification for “controlling for confounders.”
Given a DAG, a treatment \(X\) and an outcome \(Y\):
Then adjust for \(S\) by whichever estimator you like — regression, stratification, matching, propensity scores (next).
Back to the exercise DAG (\(X\) = exercise, \(Y\) = heart health):
\[ A \to X,\; A \to Y,\; M \to X,\; M \to D,\; D \to Y,\; X \to W,\; W \to Y \]
In regression terms: Y ~ X + A + M recovers the total effect of exercise; Y ~ X + A + M + W does not.
Tutoring (\(X\)) and final grade (\(Y\)), with: \(\text{GPA}_{\text{prior}} \to X\), \(\text{GPA}_{\text{prior}} \to Y\), \(X \to \text{Hours} \to Y\), and \(X \to \text{HonorRoll} \leftarrow Y\).
Which of these adjustment sets are valid for the total effect of \(X\) on \(Y\)?
Answer: only (a). (b) blocks the causal path through the mediator; (c) opens the collider \(X \to \text{HonorRoll} \leftarrow Y\); (d) leaves the backdoor through prior GPA open. Run the five steps to convince yourself.
Conditioning on a collider (or its descendant) opens a spurious path. Classic example: suppose talent and looks are independent in the general population, but both help an actor get famous.
import numpy as np
rng = np.random.default_rng(701)
n = 10_000
talent = rng.normal(0, 1, n)
looks = rng.normal(0, 1, n) # independent of talent by construction
famous = (talent + looks + rng.normal(0, 0.5, n)) > 1.5 # collider: common effect
corr_all = np.corrcoef(talent, looks)[0, 1]
corr_famous = np.corrcoef(talent[famous], looks[famous])[0, 1]
print(f"corr(talent, looks) overall = {corr_all:+.2f}")
print(f"corr(talent, looks) among famous = {corr_famous:+.2f}")corr(talent, looks) overall = +0.00
corr(talent, looks) among famous = -0.55
Among the famous, talent and looks are negatively correlated — “why are talented celebrities so often unattractive?” — even though they are unrelated in general. We manufactured the correlation by conditioning on fame. This is collider / selection bias (a.k.a. Berkson’s paradox).
If a job-training program raises earnings by teaching skills:
\[ \text{Training} \rightarrow \text{Skills} \rightarrow \text{Earnings} \]
Controlling for skills would block the very pathway the program works through, making the program look useless. This is overcontrol bias.
Rule of thumb for a total effect: adjust for common causes, never for anything on the causal path or for common effects (Cinelli et al. 2024).
Suppose we’ve drawn the DAG and found an adjustment set \(S\) that satisfies the backdoor criterion (the ignorability / unconfoundedness assumption). Several estimators then recover the effect:
import numpy as np, pandas as pd, statsmodels.formula.api as smf
rng = np.random.default_rng(0)
n = 4000
# Confounder Z drives BOTH treatment and outcome
Z = rng.normal(0, 1, n)
T = (rng.uniform(size=n) < 1 / (1 + np.exp(-Z))).astype(int) # sicker -> treated
Y = 3.0 * T + 2.0 * Z + rng.normal(0, 1, n) # TRUE effect = 3.0
df = pd.DataFrame({"Y": Y, "T": T, "Z": Z})
naive = smf.ols("Y ~ T", data=df).fit().params["T"]
adj = smf.ols("Y ~ T + Z", data=df).fit().params["T"]
print(f"True effect = 3.00")
print(f"Naive (ignore Z) = {naive:.2f} <- biased")
print(f"Adjusted (+ Z) = {adj:.2f} <- recovers truth")True effect = 3.00
Naive (ignore Z) = 4.65 <- biased
Adjusted (+ Z) = 3.00 <- recovers truth
Adjusting for the confounder \(Z\) recovers the true effect of \(3.0\); ignoring it does not. Same idea as stratifying the kidney-stone table — just done with a regression.
When \(S\) is high-dimensional, matching directly is hard. Rosenbaum & Rubin showed it is enough to match/weight on the scalar propensity score \(e(S) = P(T{=}1\mid S)\).
from sklearn.linear_model import LogisticRegression
# Estimate propensity, then inverse-propensity weighting (IPW) for the ATE
ps = LogisticRegression().fit(df[["Z"]], df["T"]).predict_proba(df[["Z"]])[:, 1]
w = np.where(df["T"] == 1, 1 / ps, 1 / (1 - ps))
ate_ipw = (np.sum(w * df["T"] * df["Y"]) / np.sum(w * df["T"])
- np.sum(w * (1 - df["T"]) * df["Y"]) / np.sum(w * (1 - df["T"])))
print(f"IPW estimate of ATE = {ate_ipw:.2f} (true = 3.0)")IPW estimate of ATE = 3.03 (true = 3.0)
Inverse-propensity weighting builds a “pseudo-population” in which treatment is unrelated to \(S\) — mimicking a randomized experiment from observational data.
Adjustment only works for confounders we can measure. When we can’t, we look for “nature’s randomization” — sources of variation in treatment that are as good as random (Angrist and Pischke 2014).
Three workhorse designs:
Compare the change in a treated group to the change in a control group over time. This differences out any fixed differences between the groups.

Card & Krueger’s famous study used DiD to show a New Jersey minimum-wage increase did not reduce fast-food employment relative to neighboring Pennsylvania (Card and Krueger 1994). Key assumption: parallel trends (absent treatment, both groups would have moved together).
Instrumental variable (IV)
An instrument \(Z\) affects the outcome only through the treatment: \[ Z \rightarrow T \rightarrow Y, \quad Z \not\rightarrow Y \text{ directly}. \]
\(Z\) acts like the coin flip we wish we had run.
Regression discontinuity (RD)
Treatment switches on at a cutoff of a running variable. Units just above vs. just below are comparable.
Compare outcomes in a narrow window around the cutoff.
All three designs trade the “no unmeasured confounders” assumption for a different, often more credible, assumption. There is no free lunch — every causal estimate rests on assumptions that data cannot fully verify.
Every observational causal estimate relies on assumptions (Hernán and Robins 2020):
Since unconfoundedness is untestable, good practice includes a sensitivity analysis: how strong would an unmeasured confounder have to be to overturn the conclusion?
The workflow is always: draw the graph → identify an estimand → estimate → challenge it with robustness checks. The graph comes first.
Format: groups of 3–4. Time: ~20 min of group work + ~10 min report-out.
You will get a scenario. For it, your group must produce four deliverables:
Pick one (or your instructor will assign one):
Watch for the trap in each: the variable that is tempting to “control for” but is actually a mediator or collider. Find it and justify your choice.
Each group takes ~2 minutes to share:
Discussion prompts for the whole class: