flowchart LR
Q{"Where does X enter?"}
Q -->|level / effect| I["inert · ~3,621"]
Q -->|trend / propensity| C["corrected · ~1,794"]
C -.recovers.-> B["RCT benchmark 1,794"]
The LaLonde test in Python — when do covariates rescue a DiD?
Nagoya University (GSID)
August 4, 2026
Act I
A randomized trial says job training raised earnings by $1,794.
Now throw the control group away. Use a survey instead.
Do you still find $1,794?
Following Scott Cunningham’s Mixtape Substack, we reproduce the test in Python.
The treated group never changes. So the ATT target stays $1,794, whatever control group we use.
Act II — diagnose the bias, then climb the spec ladder
Standardized mean differences: trainees versus each control group
CPS controls differ from trainees by +2.3 SD on race and −1.6 SD on prior earnings. The randomized controls do not.
Raw earnings trends by group, 1974 to 1978
The CPS control sits on a different level and a different slope. Assuming the trainees would have followed it is not credible.
The \(2 \times 2\) DiD ATT is computed identically by:
We use the saturated form. Only it lets us move covariates around and watch the estimate.
Same number, four ways — so the specification choice is about covariates, not about the DiD itself.
A covariate \(X\) can enter a DiD in three fundamentally different places:
| Placement | Formula | What it changes |
|---|---|---|
| Level | \(+ X\) | the intercept (additive control) |
| Effect | \(X \times D\) | the treatment effect (heterogeneity) |
| Trend | \(X \times \text{post}\) | the counterfactual trend |
Only the last placement addresses why the naive estimate is wrong.
Same covariates, same data, same estimator. One extra term — and the answer halves.
Time-invariant covariates in the level or the effect never touch the control’s trend. Nothing moves.
The instant covariates bend the counterfactual trend, the estimate snaps to the benchmark.
These do not model the trend. They model treatment — how the probability of being a trainee depends on \(X\) — and reweight the controls.
Outcome regression specifies the trend; propensity weighting specifies selection. Two philosophies, one answer near $1,794.
The ATT across the ordered specification sequence
That cliff — between \(X \times\) treatment and \(X \times\) post — is the single most important feature of the analysis.
By-hand estimators versus the diff-diff package
The transparent code and the battle-tested package tell the same story — within $14.
flowchart LR
Q{"Where does X enter?"}
Q -->|level / effect| I["inert · ~3,621"]
Q -->|trend / propensity| C["corrected · ~1,794"]
C -.recovers.-> B["RCT benchmark 1,794"]
Covariates in DiD are not a robustness knob.
They have a job: make parallel trends hold, once you condition on X.
Act III
All eight estimators with 95% intervals against the RCT benchmark
Three inert specifications cluster at $3,621; every trend and propensity estimator clusters on the $1,794 line.
Trust the pattern, not the decimal
Every corrected estimate’s 95% CI spans roughly $400 to $3,100; the benchmark itself has SE \(\approx\) $671.
The $59 gap between Spec B and Spec C is noise.
Trust the split, not the ranking: ignore the trend and you are wrong; model it and you are right.
Packages: pyfixest, diff-diff, causaldata.