Choosing a DID estimator¶
StatsPAI ships 18 DID variants. Picking one is not the first question.
Baker, Callaway, Cunningham, Goodman-Bacon and Sant'Anna (2026, JEL 64(2), 498–557) call the estimator-first habit reverse engineering: starting from a familiar regression and asking what it identifies. Their alternative is forward engineering — fix the target parameter, state the assumption, and derive the estimator. This guide follows that order, because the estimator is the last decision, not the first.
0. Four questions before you pick anything¶
Q1 — What is the target parameter?¶
Not "the effect", but whose effect, averaged how.
| You want | Target | Call |
|---|---|---|
| One number for all treated units | overall ATT | sp.aggte(cs, type="simple") |
| Effects by time since treatment | ATT(e), event study | sp.aggte(cs, type="dynamic") |
| Effects by adoption cohort | θ(g) | sp.aggte(cs, type="group") |
| Effects by calendar period | θ(t) | sp.aggte(cs, type="calendar") |
Weights are part of this question, not a robustness check. The
unweighted ATT averages over treated units; weights="pop" averages
over the population those units represent. These are different
estimands and can differ in sign. Comparing them tells you nothing about
robustness — it tells you that you have not yet decided what you are
estimating (§3.1).
sp.callaway_santanna(df, y="y", g="first_treat", t="year", i="id",
weights="pop") # mirrors R did::att_gt(weightsname=)
sp.sun_abraham(df, y="y", g="first_treat", t="year", i="id",
weights="pop") # mirrors fixest sunab(..., weights=)
Estimators that do not implement ω (sdid, bjs, gardner_did,
stacked_did, lp_did) raise rather than ignoring the argument. That is
deliberate: silently dropping ω returns a different estimand under the
name of the one you asked for.
Q2 — Which parallel-trends assumption are you willing to impose?¶
In a staggered design the comparison-group option is an assumption
choice. The paper asks you to state which one (§5.2.2); StatsPAI records
it in result.model_info["parallel_trends"] and prints it in
.summary().
| Label | Comparison group | Restricts pre-trends? | How to get it |
|---|---|---|---|
PT-GT-NEV |
never-treated only | no | control_group="nevertreated" |
PT-GT-NYT |
all not-yet-treated | no | control_group="notyettreated" |
PT-GT-ALL |
all groups, all periods | yes | imputation/ETWFE estimators (sp.did_imputation, sp.etwfe, sp.gardner_did) |
PT-GT-ALL buys precision by assuming parallel pre-trends too — so if
pre-trends are not parallel, those estimators are biased where CS is
not. The paper's own application prefers PT-GT-NYT as the middle
ground.
Q3 — Do you need covariates, and how do they enter?¶
Check first, with balance in levels and in changes:
bal = sp.did_balance(df, ["poverty", "unemp", "median_income"],
g="first_treat", t="year", i="county", weights="pop")
print(bal.summary())
A covariate can look balanced in levels and still be moving differentially — and DiD identifies off trends. If anything is flagged, move to a conditional design.
⚠️ Do not add covariates to a TWFE regression. Under conditional parallel trends the interaction coefficient is a possibly non-convex weighted average of covariate-specific effects plus misspecification bias, and equals the ATT only if effects are constant across covariate strata (Caetano & Callaway 2024; §4.3). Use RA / IPW / DR instead — and prefer DR, which is consistent if either nuisance model is right (§4.4):
sp.callaway_santanna(df, ..., x=["poverty"], estimator="dr") # recommended
sp.callaway_santanna(df, ..., x=["poverty"], estimator="reg") # RA
sp.callaway_santanna(df, ..., x=["poverty"], estimator="ipw") # IPW
Q4 — What is random?¶
Sampling-based (units resampled from a population) vs design-based (treatment assignment is the only randomness). StatsPAI's CS path is sampling-based with a multiplier bootstrap. Say which you mean; it determines what the standard error covers (§3.3).
If adoption timing was actually randomised — a policy lottery, a phased launch assigned at random, an RCT rolled out in waves — this is not a footnote about standard errors. It changes which estimator is correct. Parallel trends is then neither assumed nor needed, and imposing it throws away the randomisation you paid for:
sp.staggered_rollout |
Roth & Sant'Anna's efficient estimator: uses the cohort's pre-treatment moments as optimal controls. estimand= picks simple / cohort / calendar / eventstudy |
sp.staggered_cs |
the Callaway–Sant'Anna estimand, design-based inference |
sp.staggered_sa |
the Sun–Abraham estimand, design-based inference |
fisher=True |
randomisation test: permutes adoption dates and needs no asymptotics — the natural inference for a randomised rollout |
Two traps worth naming. First, these answer a different question
from sp.callaway_santanna, so the numbers differ and that is not a
bug: on did::mpdta, where timing was not randomised, the
design-based estimate is −0.0471 against CS's −0.0400. Second, R
staggered reports two standard errors and so does StatsPAI —
se_type="neyman" (the default, conservative) and se_type="adjusted"
(what R prints, and never larger, because random timing identifies part
of the pre-period covariance). Report which one you used.
If timing was not randomised, stay on the parallel-trends estimators in §2 — a design-based SE does not become valid because the assignment looks haphazard.
Full treatment: Randomised rollouts (design-based DiD) — including which of the two standard errors to report, the randomisation test, the design-based event study, and the three diagnostics you should stop running once you are on this branch.
One call runs all of this, and reports the eight-step checklist:
rpt = sp.cs_report(df, y="y", g="first_treat", t="year", i="county",
x=["poverty"], weights="pop")
rpt.forward_engineering_checklist()
1. Two-period, two-group ("2x2 DID")¶
| Use case | Recommended call |
|---|---|
| Standard 2-period 2-group panel | sp.did(df, y='y', treat='treated', time='t') |
| With covariates, doubly-robust | sp.drdid(df, y='y', group='d', time='post', covariates=[...]) |
| Repeated cross-section (no panel match) | sp.did(..., panel=False) |
| Triple differences (DDD) | sp.ddd(df, y='y', treat='d', time='t', subgroup='eligible') |
Minimum viable robustness suite for 2x2 DID:
r = sp.did(df, y='y', treat='treated', time='t')
r.next_steps() # model-specific checklist
sp.honest_did(r, m_grid=[0.0, 0.1, 0.2]) # Rambachan-Roth sensitivity
sp.pretrends_test(r) # pre-treatment placebo
2. Staggered adoption¶
Staggered = units get treated at different calendar times. With staggered adoption, classic TWFE:
is biased whenever treatment effects are heterogeneous across cohorts (Goodman-Bacon 2021; de Chaisemartin & D'Haultfoeuille 2020). Diagnose first:bacon = sp.bacon_decomposition(df, y='y', treat='treat',
time='t', id='i')
# If most weight goes to "Earlier vs Later Treated" comparisons,
# TWFE is contaminated by already-treated units acting as controls.
2a. Staggered + homogeneous effects¶
TWFE is fine here. But CS / SA / Wooldridge are all also unbiased, and give you event-study flexibility for free. There's no reason to pick TWFE over them.
2b. Staggered + heterogeneous effects¶
| Scenario | Pick |
|---|---|
| You want group-time ATT(g,t) + event study | sp.callaway_santanna(df, y, g, t, i) |
| Heavy-weight covariates | sp.callaway_santanna(..., x=[...], estimator='dr') |
| Sun-Abraham interaction-weighted event | sp.sun_abraham(df, y, g, t, i) |
| Imputation-style (BJS untreated-only TWFE) | sp.did(df, y='y', treat='first_treat', time='t', id='i', method='bjs') |
| Counterfactual estimators with a factor / low-rank Y(0) model (Liu, Wang & Xu 2024): fe = imputation, ife = interactive FE, mc = matrix completion; reversals allowed | sp.fect(df, y='y', treat='d', unit='i', time='t', method='ife', r=2) (Track A module 86 vs R fect and Stata fect) |
| Two-stage regression (event study + covariate ix) | sp.gardner_did(df, y=..., group=..., time=..., first_treat=..., event_study=True) |
| One-call harvesting + precision-weighted | sp.harvest_did(df, outcome=..., unit=..., time=..., cohort=...) |
Two-way Mundlak / ETWFE — treated-obs-weighted simple ATT (R emfx(type='simple'), Stata jwdid, estat simple) |
sp.etwfe(df, y, group, time, first_treat) |
Two-way Mundlak / ETWFE — cohort-size-weighted mean of ATT(g), never-treated controls (R etwfe(cgroup='never') + emfx(type='group')) |
sp.wooldridge_did(df, y, group, time, first_treat) |
| Cohort sub-experiments w/ clean controls (CDLZ) | sp.stacked_did(df, y, group, time, first_treat, window=(-5, 5)) |
| Continuous / dose treatment | sp.continuous_did(df, y, d, t, i) |
| Changes-in-changes (CIC, not DID-in-mean) | sp.cic(df, y, g, t) |
| de Chaisemartin-D'Haultfoeuille | sp.did_multiplegt(df, y, group, time, treatment) |
sp.etwfevssp.wooldridge_did. One estimator, two headline aggregations of the same saturated cohort × period regression — not two methods, and not aliases.sp.etwfereports what Retwfe::emfx(type='simple')and Statajwdid, estat simpleprint;sp.wooldridge_didreports the cohort-size-weighted average ofATT(g)under never-treated controls. On the17_etwfeparity bytes they are 15.9% apart, so the choice is not cosmetic — state in your write-up which average you are reporting. Both are pinned against R and Stata in Track A module17_etwfe.
Default recommendation when in doubt: sp.callaway_santanna(..., estimator='dr').
Doubly-robust CS is the modern "no-regret" default — it's robust to both
outcome-model and propensity-score misspecification, and its aggregation
weights (sp.aggte) let you switch between simple, group-weighted,
calendar-weighted, and event-study ATT without refitting.
2c. Event study with TWFE (legacy)¶
If you must use TWFE event studies for a reviewer:
r = sp.event_study(df, y='y', treat_time='first_treat',
time='t', unit='i', window=(-4, 4))
sp.pretrends_test(r) # joint Wald test on the leads
sp.event_study is the pooled-TWFE event study — under staggered
adoption with heterogeneous effects its leads are contaminated by other
cohorts' treated periods. Run sp.sun_abraham instead when that applies.
3. Sensitivity and robustness¶
Always run the three-step robustness suite for a publication-quality DID result:
# 1. Pre-trend test (are pre-treatment coefficients near zero?)
sp.pretrends_test(r, alpha=0.05)
# 2. Honest DID (Rambachan-Roth): how much pre-trend violation can the
# causal conclusion survive?
hd = sp.honest_did(r, m_grid=[0.0, 0.25, 0.5], method='smoothness')
# 3. Full robustness report: combines pre-trend, placebo, leave-one-cohort-out
sp.robustness_report(r)
Optional but highly recommended for DID papers:
- sp.bjs_pretrend_joint(r): Borusyak-Jaravel-Spiess joint pre-trend
test (addresses multiple-testing issue in per-lag tests).
- sp.bacon_decomposition: shows which 2x2 comparisons drive your
TWFE estimate.
4. When to avoid DID entirely¶
DID is the wrong tool if:
- Treatment is confounded by pre-trends: use matched DID
(
sp.drdid) or synthetic control (sp.synth). - Only one treated unit: use
sp.synthorsp.causal_impact. - Treatment is continuous dose, not 0/1 onset: use
sp.continuous_didorsp.bunching(if at a threshold). - Anticipation effects exist: use
anticipation=hparameter in CS2021 to backdate the reference period.hshifts the base period for post-treatment cells only; underbase_period="varying"a pre-treatment placebo keeps the period immediately before it, so its value does not move withh(this matches Rdid).
4.1 Unbalanced panels¶
If some units are missing some periods, sp.callaway_santanna warns and
you have three genuinely different options — pick one deliberately
rather than letting the default decide:
| Option | What it estimates |
|---|---|
default (allow_unbalanced_panel=False) |
within-unit differences anyway; a unit missing the base or comparison period drops out of that cell, so the sample varies cell to cell |
allow_unbalanced_panel=True |
switches to the repeated-cross-section estimators, keeping every observed row, with influence functions folded back to units so the SEs keep within-unit correlation (R did::att_gt(allow_unbalanced_panel = TRUE)) |
sp.balance_panel(...) first |
one fixed sample throughout, at the cost of dropping incomplete units entirely |
The three give different numbers on the same data. Report which you used. The flag is inert when the panel turns out to be balanced.
4.5 Frontier estimators¶
Most of the post-2020 DiD advances are now shipped with cross-language parity evidence. The table says what backs each one, because "implemented" and "checked against the reference" are different claims.
| What you want | Use | Evidence |
|---|---|---|
| Continuous-dose DiD — CGS ATT(d) / ACRT(d) | sp.cgs_continuous_did |
curves and both overall quantities match R contdid 0.1.1 at 1e-12 (Track A 80) |
| Continuous-dose DiD — quick heuristic | sp.continuous_did(method='att_gt') |
dose-quantile 2×2 rollup; a look, not the CGS estimand |
| On/off switching — dCDH 2020 DID_M | sp.did_multiplegt |
pair rollup + joint placebo; the 2.x R package's mode="old" is broken upstream, so no parity |
| On/off switching — dCDH 2024 event study | sp.did_multiplegt_dyn |
effects, placebos and the switcher-weighted aggregate match DIDmultiplegtDYN 2.3.4 at 5e-15, switcher counts included (Track A 78) |
| Triple differences, heterogeneity-robust | sp.ddd_heterogeneous |
cells and analytic SEs match triplediff 0.2.4 at 1e-12 across dr / ipw / reg (Track A 77) |
| Stacked DiD (CDLZ) | sp.stacked_did |
matches a hand-written fixest stack at 1.3e-13 under both control-group conventions (Track A 75) |
| Is parallel trends scale-dependent? | sp.functional_form_test |
matches didFF 0.1.0 at 2e-15 on an accepting and a rejecting design, weighted and unweighted, across all four aggregations, the dynamic event-time window, and both the automatic and discrete binning rules (Track A 79) |
| Randomised adoption timing | sp.staggered_rollout, sp.staggered_cs, sp.staggered_sa |
matches R staggered 1.2.2 at 1e-9 on three panels — every estimand × beta × comparison group, both standard errors, every feasible event time and the joint event-study covariance; the randomisation test is pinned draw-for-draw on R-generated permutations |
| Pre-test power / detectable trend | sp.pretrends_power, sp.pretrends_slope_for_power |
match Roth's pretrends 0.1.0 within its own Monte-Carlo noise (Track A 76) |
| Spatial spillovers, heterogeneity-robust | sp.spillover_did |
no reference implementation exists; design-recovery evidence only |
| LP-DiD | sp.lp_did |
implemented, but not verified against the published paper and carries no parity test |
| Time-varying covariates DiD | sp.did_timevarying_covariates |
implemented; the attribution could not be verified, see the module docstring |
Read the third column. Everything above the spillover row is pinned
against an independent implementation; below it, correctness rests on
recovering a known design or on nothing external at all. That is a real
difference in how much weight a result can carry, and sp.describe_function
reports the same distinction as validation_status.
Why dCDH 2020 and dCDH 2024 are not the same estimator: the 2024
_dynversion is a long-difference event study with "not-yet-treated at horizonl" as the per-horizon control group.sp.did_multiplegt(dynamic=H)is the 2020 pair rollup extended to H horizons — numerically close on simple DGPs, different in identification, control construction and inference.
sp.did_multiplegt_dynis stillexperimentaldespite the parity. Switch-off events are dropped and the paper's own variance formula is not implemented;se_method='analytic'is available and agrees with the bootstrap, but runs about 1% below the R package's standard errors.
5. Reading the output¶
All DID estimators in StatsPAI return a CausalResult. The common
interface:
r.estimate # Point estimate of the main estimand (usually ATT)
r.se # Standard error (clustered at unit level by default)
r.ci # (lower, upper) tuple for 95% CI
r.tidy() # Long-format table (broom-compatible): main, event_study,
# group_time rows all in one DataFrame
r.glance() # One-row model-level summary (nobs, pretrend pvalue, etc.)
r.plot() # Auto-selects event-study / trajectory / coefplot
r.summary() # Human-readable summary
r.next_steps() # Prioritised robustness checklist
r.cite() # BibTeX for the underlying paper
For Agents¶
Pre-conditions - data is panel or repeated cross-section with a time column - treat column is binary (0/1) for 2x2, or first-treatment-period (int) for staggered - at least one pre-treatment period (≥ 2 periods for 2x2; ≥ 3 recommended for event study) - for staggered designs: id column identifying units across time
Identifying assumptions - Parallel trends: treated and control groups would have followed the same trajectory absent treatment - No anticipation: outcomes in pre-treatment periods are unaffected by future treatment - SUTVA: no spillovers between units - For staggered / heterogeneous effects: use CS or SA — TWFE can produce negative weights (Goodman-Bacon)
Failure modes → recovery
| Symptom | Exception | Remedy | Try next |
|---|---|---|---|
| Pre-trend joint test p < 0.05 (or underpowered at 0.10) | AssumptionViolation |
Use sp.sensitivity_rr (Rambachan & Roth honest CI) or switch to sp.callaway_santanna. | sp.sensitivity_rr |
| Staggered treatment timing with TWFE method | AssumptionWarning |
TWFE can give negative weights; use Callaway-Sant'Anna, Sun-Abraham, or BJS imputation. | sp.callaway_santanna |
| Pre-trend test underpowered (Roth 2022) | AssumptionWarning |
Check sp.pretrends_power — if low, report honest CI via sp.sensitivity_rr. | sp.sensitivity_rr |
| Few clusters at unit level | AssumptionWarning |
Use wild cluster bootstrap (sp.wild_cluster_bootstrap). | sp.wild_cluster_bootstrap |
Alternatives (ranked)
- sp.callaway_santanna
- sp.sun_abraham
- sp.did_imputation
- sp.sdid
- sp.synth
Typical minimum N: 50
For Agents¶
Pre-conditions - panel data with unit × time × outcome - g column is integer: first-treated period or 0 for never-treated - at least one never-treated or late-treated control group - ≥ 2 pre-treatment periods per cohort - data is panel or repeated cross-section with a time column - treat column is binary (0/1) for 2x2, or first-treatment-period (int) for staggered - at least one pre-treatment period (≥ 2 periods for 2x2; ≥ 3 recommended for event study) - for staggered designs: id column identifying units across time
Identifying assumptions - Parallel trends conditional on X (if covariates supplied) - No anticipation (or adjust via anticipation= parameter) - Overlap: positive propensity for each cohort - SUTVA - Parallel trends: treated and control groups would have followed the same trajectory absent treatment - No anticipation: outcomes in pre-treatment periods are unaffected by future treatment - SUTVA: no spillovers between units - For staggered / heterogeneous effects: use CS or SA — TWFE can produce negative weights (Goodman-Bacon)
Failure modes → recovery
| Symptom | Exception | Remedy | Try next |
|---|---|---|---|
| Pre-trend test on aggregated ATT(g,t) rejects | AssumptionViolation |
Use sp.sensitivity_rr for honest CI, or add covariates for conditional parallel trends. | sp.sensitivity_rr |
| Cohort with only one unit — insufficient variation | DataInsufficient |
Aggregate small cohorts or drop; check sp.diagnose_result. | |
| All units treated at the same time (no staggering) | MethodIncompatibility |
Fall back to 2x2 DID via sp.did(method='2x2'). | sp.did |
| Pre-trend joint test p < 0.05 (or underpowered at 0.10) | AssumptionViolation |
Use sp.sensitivity_rr (Rambachan & Roth honest CI) or switch to sp.callaway_santanna. | sp.sensitivity_rr |
| Staggered treatment timing with TWFE method | AssumptionWarning |
TWFE can give negative weights; use Callaway-Sant'Anna, Sun-Abraham, or BJS imputation. | sp.callaway_santanna |
| Pre-trend test underpowered (Roth 2022) | AssumptionWarning |
Check sp.pretrends_power — if low, report honest CI via sp.sensitivity_rr. | sp.sensitivity_rr |
| Few clusters at unit level | AssumptionWarning |
Use wild cluster bootstrap (sp.wild_cluster_bootstrap). | sp.wild_cluster_bootstrap |
Alternatives (ranked)
- sp.sun_abraham
- sp.did_imputation
- sp.sdid
- sp.did
- sp.callaway_santanna
- sp.synth
Typical minimum N: 50