The Mixtape replication suite¶
Four worked examples from Cunningham's Causal Inference: The Mixtape
ship with StatsPAI as real data plus checked numbers — not simulated
stand-ins. Every value is pinned in CI against Stata 18 MP and, where
a reference exists, R did.
import statspai as sp
sp.list_replications() # what is available
data, guide = sp.replicate('castle_2013')
print(guide) # code + expected numbers + caveats
What is in the suite¶
| key | chapter | dataset | what it teaches |
|---|---|---|---|
castle_2013 |
9 — DiD | castle_doctrine() |
staggered adoption, Bacon decomposition, Callaway–Sant'Anna |
texas_1993 |
10 — synthetic control | texas_prison() |
classic SCM's non-convex V–W problem, and what survives it |
sasp_within |
8 — panel | sasp_panel() |
the within transformation, three equivalent ways |
thornton_2008 |
4 — potential outcomes | thornton_hiv() |
randomization inference on a real experiment |
Alongside them the older entries — card_1995, abadie_2010,
lalonde_1986, lee_2008 — cover IV, synthetic control, matching and RD.
Verification status¶
Read this column before quoting anything.
| replication | agreement with reference software |
|---|---|
castle_2013 |
bit-parity. Four TWFE specs ≤1e-6 vs Stata (including aweight and 19 collinear drops); bacon_decomposition matches bacondecomp across all 25 cells; callaway_santanna matches R did and csdid to 1e-9 on estimates and standard errors. |
sasp_within |
bit-parity. Pooled OLS, within, and manual demeaning all ~5e-10 vs Stata. |
thornton_2008 |
bit-parity on the estimate: group means, the simple difference and the HC1 standard error match Stata exactly. The RI p-value is Monte Carlo and is not pinned to a draw. |
texas_1993 |
deliberately not parity. See below. |
Why texas_1993 is shipped as a non-parity case¶
The book's SCM recipe puts four lagged outcomes among the predictors,
which leaves the predictor-weight matrix V weakly identified (Kaul et al.
2015) and makes the nested V–W problem non-convex. Stata synth and
sp.synth converge to different local optima:
| donors | mean 1994–2000 gap | pre-RMSE | |
|---|---|---|---|
Stata synth |
CA .408 IL .360 LA .122 FL .109 | 23,074 | 1227 |
sp.synth |
FL .436 NY .311 IL .253 | 23,779 | 865 |
StatsPAI reaches the lower pre-treatment RMSE and returns the identical optimum at 4 and 40 random starts, so neither is wrong on its own objective. Across five routes — including outcome-only classic SCM (21,482) and synthdid (19,479) — every estimate is a large positive effect within a ~30% band, on entirely disjoint donor sets.
The effect is identified far more robustly than the weights are. Report the effect; do not interpret the donor weights, and do not tune the recipe until the weights look familiar. If a number has to reproduce across software, use synthdid or the outcome-only recipe, where V is fixed to the identity and the donor-weight problem is convex with a unique solution.
What the suite caught¶
Replications earn their keep by finding things unit tests cannot, because a package can be perfectly consistent with itself and still wrong.
sp.aggte standard errors were up to 8% too small. The
Callaway–Sant'Anna aggregation weights are estimated cohort shares, and
the variance treated them as fixed — dropping R's did:::wif term. Point
estimates were always correct, which is exactly why no internal check
caught it: only a cross-implementation comparison could. Single-cohort
aggregates were unaffected (the shares cancel), so the defect hid in
cross-cohort aggregates. Fixed; see
MIGRATION.md.
sp.ri_test dropped missing rows silently. Passing cluster= where
the cluster id is itself missing shrank the sample without saying so. On
Thornton, four of 2834 rows have no village id, moving the statistic from
0.450552 to 0.451982 — which reads as a discrepancy rather than a smaller
sample. It now warns with the counts.
Four shipped replication guides contained code that could not run.
They called sp.regtable(..., column_labels=...), which is not a
parameter. Every guide's code block is now executed in CI.
Two traps the data itself sets¶
Castle doctrine: post is not 1{year >= effyear}. Cheng & Hoekstra
code post = 1{year > effyear} because the law was in force for only
part of the adoption year; that year's fractional exposure lives in
cdl. The obvious reconstruction silently changes 21 of 550
observations, and it moves the Callaway–Sant'Anna ATT from 0.110 to
0.019 depending on how you then code the cohort.
SASP: the within transformation destroys twelve of the controls.
Provider age, race, schooling, BMI and marital status do not vary across
a provider's four sessions. Stata's xtreg, fe omits them silently;
StatsPAI raises NumericalInstability naming the column, because a
regressor with no identifying variation should be named rather than
quietly discarded.
Running them¶
# Every guide's code is executable as printed.
data, guide = sp.replicate('sasp_within')
print(guide)
# The datasets stand alone too.
df = sp.datasets.sasp_panel(analytic_sample=True) # book's 1028-row extract
df = sp.datasets.castle_doctrine(event_time=True) # adds time_til / gvar
df = sp.datasets.thornton_hiv(complete_case=True) # n = 2834
df = sp.datasets.texas_prison()
The parity tests live in tests/reference_parity/ — test_castle_stata_parity.py,
test_sasp_within_parity.py, test_thornton_ri_parity.py,
test_texas_synth_parity.py, and test_aggte_r_did_parity.py. Each records
the exact Stata or R command that produced its reference values, so they can
be regenerated rather than trusted.
Data redistributed from the MIT-licensed mixtape repository.