Castle doctrine — a real-data staggered DiD replication¶
This guide replicates Cheng & Hoekstra (2013) — the castle-doctrine dataset behind Chapter 9 of Cunningham's Causal Inference: The Mixtape — on the real panel, not a simulated stand-in.
Every number below is checked in CI against Stata 18 MP and R
did. The checks live in
tests/reference_parity/test_castle_stata_parity.py.
Why this dataset? It is the cleanest teaching case of staggered adoption in economics: 50 states, 11 years, 21 staggered adopters, and a genuine never-treated group. It is also the case where the naive answer and the modern answer differ enough to matter.
0. The data¶
50 US states × 11 years (2000–2010). Between 2005 and 2009, 21 states expanded "castle doctrine" self-defence law; 29 never did.
| column | meaning |
|---|---|
l_homicide |
log homicide rate per 100,000 — the outcome |
post |
the paper's treatment dummy (read §1) |
cdl |
fractional castle-doctrine exposure within the year |
effyear |
year the law took effect (NaN = never treated) |
popwt |
state population weight (Stata aweight) |
sid, year |
panel identifiers |
The published extract also carries 44 region × year dummies and 51 state linear trends. Those are pure design-matrix columns, so StatsPAI regenerates them on request rather than shipping 95 columns of zeros and ones:
df = sp.datasets.castle_doctrine(
region_year_fe=True, # r20001 … r20104 (44)
state_trends=True, # trend_1 … trend_51 (51)
event_time=True, # adds time_til and gvar
)
1. The trap: post is not year >= effyear¶
Cheng & Hoekstra code
The adoption year itself is coded untreated, because the law was in
force for only part of it. That year's fractional exposure lives in
cdl — Alabama 2006 is 0.5808, meaning the law was live for about 58%
of the year.
eff = df["effyear"].fillna(9999)
naive = (df["year"] >= eff).astype(float)
(naive != df["post"]).sum() # 21 observations flip
Rebuilding the treatment dummy the "obvious" way silently changes 21 of 550 observations. This is the single most common way to fail to reproduce the paper.
2. The classic answer: two-way fixed effects¶
The paper's ladder, weighted by state population and clustered on state:
xvar = ['l_police', 'unemployrt', 'poverty', 'l_income',
'l_prisoner', 'l_lagprisoner', 'blackm_15_24',
'whitem_15_24', 'blackm_25_44', 'whitem_25_44',
'l_exp_subsidy', 'l_exp_pubwelfare']
region = [c for c in df.columns if c.startswith('r20')]
trends = [c for c in df.columns if c.startswith('trend_')]
bare = sp.feols('l_homicide ~ post | sid + year',
data=df, vcov={'CRV1': 'sid'})
wtd = sp.feols('l_homicide ~ post | sid + year', data=df,
weights='popwt', vcov={'CRV1': 'sid'})
full = sp.feols(
'l_homicide ~ post + ' + ' + '.join(xvar + region + trends)
+ ' | sid + year',
data=df, weights='popwt', vcov={'CRV1': 'sid'})
sp.regtable([bare, wtd, full],
model_labels=['TWFE', '+ weights', '+ full controls'])
| specification | β(post) | cluster SE | Stata 18 MP |
|---|---|---|---|
| TWFE, unweighted | 0.069398 | 0.055860 | ✅ identical |
TWFE, aweight=popwt |
0.075533 | 0.033194 | ✅ identical |
| + time-varying controls | 0.079635 | 0.030876 | ✅ identical |
| + region × year + state trends | 0.076949 | 0.033938 | ✅ identical |
The last row is the strongest check in the suite: 19 of the 95 extra regressors are collinear, and it only reproduces if StatsPAI drops exactly the columns Stata drops.
Read plainly: strengthening self-defence law is associated with roughly an 8 log-point increase in homicide — the opposite of deterrence.
3. Why is TWFE that number? Decompose it¶
Under staggered adoption, TWFE is a weighted average of every possible 2×2 DiD — including "forbidden" comparisons that use already-treated states as controls (Goodman-Bacon 2021).
bacon = sp.bacon_decomposition(
df, y='l_homicide', treat='post', time='year', id='sid')
dec = bacon['decomposition']
clean = dec[dec['type'] == 'Treated vs Untreated']['weight'].sum()
print(f"TWFE = {bacon['beta_twfe']:.4f}, never-treated weight = {clean:.1%}")
# TWFE = 0.0694, never-treated weight = 89.9%
All 25 comparisons — estimates and weights — match Stata's
bacondecomp cell by cell.
89.9% of the weight sits on clean never-treated comparisons. That is unusually benign; many staggered designs are far worse. But the residual 10.1% still bites: several early-vs-late cells are strongly negative (−0.218, −0.154), dragging the average down.
4. The modern answer: Callaway–Sant'Anna¶
df = sp.datasets.castle_doctrine(event_time=True) # adds `gvar`
cs = sp.callaway_santanna(
df, y='l_homicide', g='gvar', t='year', i='sid',
control_group='nevertreated')
sp.aggte(cs, type='simple').estimate # 0.110383
sp.aggte(cs, type='dynamic').plot()
ATT = 0.1104, against TWFE's 0.0694 — the heterogeneity-robust
estimate is roughly 60% larger. This matches R did::aggte and Stata
csdid to 1e-9.
4.1 The cohort-coding decision changes the answer¶
Because the adoption year is only partially treated, there is no unambiguous cohort variable:
| cohort coding | simple ATT | what it does |
|---|---|---|
gvar = effyear |
0.1104 | clean pre-period base; adoption year counted as fully treated |
gvar = effyear + 1 |
0.0194 | consistent with post; but the partially treated year becomes the base period |
A factor of 5.7, and the second is indistinguishable from zero. Neither is simply "right":
effyearkeeps the base period clean but overstates exposure at event time 0.effyear + 1matchespostbut contaminates the baseline with a partially treated year, which biases toward zero.
The defensible options are to report both, or to drop the adoption year entirely and estimate on unambiguous periods. What you should not do is pick one silently.
5. What this replication buys you¶
| claim | evidence |
|---|---|
StatsPAI's feols matches Stata xtreg, fe including aweight and collinear-drop behaviour |
4 specifications, ≤1e-6 |
bacon_decomposition matches Stata bacondecomp |
all 25 cells, estimates and weights |
callaway_santanna matches R did and Stata csdid |
point estimates and SEs to 1e-9 |
A bug this replication caught¶
Building this page surfaced a real defect. StatsPAI's Callaway–Sant'Anna
standard errors were 0.3–8% smaller than R did and Stata csdid
(which agreed with each other) — point estimates were always correct.
The aggregation weights in Callaway–Sant'Anna are estimated cohort
shares \(\hat p_g\), not constants, and sp.aggte was treating them as
fixed. That drops a term from the variance (R's did:::wif) and makes
the reported SE anti-conservative. It is fixed as of the current
release; see MIGRATION.md. Cells drawn from a single cohort were never
affected, which is why the discrepancy hid in cross-cohort aggregates
only.
This is the argument for replication suites over unit tests: no self-consistent test would have caught it, because StatsPAI was consistent with itself.
References¶
- Cheng, C. & Hoekstra, M. (2013). Does Strengthening Self-Defense Law Deter Crime or Escalate Violence?: Evidence from Expansions to Castle Doctrine. Journal of Human Resources 48(3), 821–853. doi:10.1353/jhr.2013.0023
- Goodman-Bacon, A. (2021). Difference-in-differences with variation in treatment timing. Journal of Econometrics 225(2), 254–277.
- Callaway, B. & Sant'Anna, P. H. C. (2021). Difference-in-differences with multiple time periods. Journal of Econometrics 225(2), 200–230.
- Cunningham, S. (2021). Causal Inference: The Mixtape. Yale University Press.
Data redistributed from the MIT-licensed mixtape repository.