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', treat='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') |
| DDD with covariates | sp.ddd(..., covariates=[...], id='unit', method='dr') |
DDD covariates do not go in the regression.
method='3wfe'(the default) puts them in additively, which does not identify the covariate-adjusted ATT — and when the covariate is time-invariant it changes the triple-interaction coefficient by nothing at all, since a variable with no time variation has no triple difference. On the design intests/test_ddd_covariates.pythe true effect is 2.0, the regression returns 2.968 with or without the covariate, andmethod='dr'returns 2.054. Passid=andmethod='dr'(or'reg'/'ipw') to get the conditional estimators of Ortiz-Villavicencio & Sant'Anna (2025).
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')
# Weight on "Later vs Earlier Treated" (and "Later vs Always Treated")
# comparisons is TWFE using already-treated units as controls:
bacon['already_treated_control_weight_share']
sp.feols, sp.fepois, sp.hdfe_ols, sp.regress (with C(unit) +
C(time)) and sp.panel(method='twoway') report the same share themselves
when they see a 0/1 treatment that switches on at different dates under
unit and time effects: result.violations() lists staggered_twfe, a
warning is issued, and result.next_steps() starts with the sp.did call
that estimates the same design with Callaway-Sant'Anna. The share comes
from the cohort sizes and dates alone, so the check costs no regression.
sp.bacon_decomposition needs a binary treatment that turns on once.
sp.twowayfeweights does not. It returns the weights the regression puts
on the effect of each treated cell (de Chaisemartin & D'Haultfoeuille
2020), for any treatment, with controls, with other treatments in the
regression, and for the first-difference regression too:
w = sp.twowayfeweights(df, y='y', group='i', time='t', treat='d',
test_random_weights=['t'])
w.model_info['n_negative'], w.model_info['sum_negative']
w.model_info['sigma_fe'] # heterogeneity at which beta and ATT = 0 coexist
w.model_info['random_weights'] # are the weights related to calendar time?
Negative weights that add up to a few percent, with a large sigma_fe,
say the coefficient is safe to read. Weights correlated with a variable
that plausibly moves the effect say it is not.
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) |
Poisson ETWFE for counts, as Stata jwdid ..., method(ppmlhdfe) + estat simple, predict(xb) (log points, unit FE) |
sp.etwfe(df, y, group, time, first_treat, family='poisson', fe='unit', scale='link') |
The same with Stata's option names (ivar() tvar() gvar() method() never hettype() exovar()) |
sp.jwdid(df, y, ivar=..., tvar=..., gvar=..., method='ppmlhdfe', predict='xb') |
| Cohort sub-experiments w/ clean controls (CDLZ) | sp.stacked_did(df, y, group, time, first_treat, window=(-5, 5)) |
| Repeated events per unit (e.g. several minimum-wage increases), one sub-experiment per event with clean controls | sp.stacked_did(df, y, group, time, events='event', window=(-3, 4)) |
Policy that is continuous or changes several times (Stata xtevent) |
sp.xtevent(df, y, policy='z', panel='id', time='t', window=3) |
| 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.Poisson ETWFE: which number is "the ATT"?
family='poisson'reports two scales. The defaultscale='response'is the average marginal effect in counts (Remfx()default). Papers that run Statajwdid ..., method(ppmlhdfe)usually printestat simple, predict(xb): the treated-observation-weighted mean of the cohort × period coefficients, in log points. That isscale='link', and the two can differ by an order of magnitude. jwdid also absorbs unit fixed effects; Retwfeuses cohort dummies. They coincide on a balanced panel and not otherwise, so passfe='unit'to reproduce jwdid when outcomes have missing values (the default warns in that case). Both scales live inres.model_info['aggregations']andsp.etwfe_emfx(res, type=..., scale=...).The rest of a jwdid table maps one to one.
hettype='event' | 'cohort' | 'time' | 'twfe'isjwdid, hettype()(the restricted-heterogeneity robustness columns) -- on the linear model too, wherehettype=orfe='unit'fits jwdid'sreghdfedesign.xvar='x'isjwdid y x; a categorical column (.astype('category')) isjwdid y i.x, andsp.etwfe_emfx(res, by_xvar=True)isestat simple, over(x).cgroup='nevertreated'isjwdid, never, and its leads feedsp.pretrends_test(res, window=(-5, -2))(estat event, window(-5 5) pretrend) andsp.honest_did(res, l_vec='average', window=(-5, 5))through the joint event-study covariance. Separated all-zero units are kept inN, the cluster count and the aggregation weights by default (separated='keep'); jwdid drops them wheneverppmlhdfeflags the separation, whichseparated='drop'reproduces — check jwdid'sNto see which case your table is in.Response-scale (count) standard errors under
fe='unit'come in two conventions. The defaultresponse_se='profile'differentiates through the profiled unit effect. jwdid'sestatrunsmargins, which holds the absorbed unit and period effects fixed;response_se='margins'reproduces it, and its SEs are 14-15% larger. In a Monte Carlo study (benchmarks/etwfe_poisson_response_se_coverage.py) the profiled interval covers the sample's ATT at 94-95% with 400 units but 91-93% with 100 units or strong unit heterogeneity. The margins interval over-covers in the first case and is close to 95% in the second. Use'margins'to match a Stata table or to stay conservative in a small panel. Both condition on the sample's units. When the paper claims a population ATT, useresponse_se='unconditional'(Statamargins, vce(unconditional)), which adds the variability of the units averaged over. It agrees with a delete-one-unit jackknife and covers the population ATT at 94-96% in the same study, where the profiled interval reaches 91-95%. Point estimates and log-point SEs are the same under all three.
sp.jwdidtakes these options under Stata's names and records the command it reproduces inres.model_info['stata_equivalent'];predict=Noneisestat simple(counts, with themarginsSE by default),predict='xb'the log-point headline. Stata factor terms work inexovar=/x=(and insp.etwfe'scontrols=/xvar=):exovar='i.year#i.nodecity'builds the dummies itself, keeping every cell of a#product and omitting the collinear ones as Stata does (checked against Stata intest_jwdid_Stata_parity.py). The fitted result draws its event study (res.plot(),sp.enhanced_event_study_plot(res)), runsres.pretrend_test(window=(-5, -2)), and fills the unit / year FE and cluster rows ofsp.regtable.
Or settle it on your own panel¶
A default is a prior, not evidence. sp.did_calibrated_simulation turns the
choice into a measurement: it removes the estimated effect from your outcome,
redraws the adoption pattern, injects an effect you specify, and refits every
candidate.
study = sp.did_calibrated_simulation(
df, y='y', id='i', time='t', cohort='first_treat',
estimators=['twfe', 'callaway_santanna', 'sun_abraham',
'did_imputation', 'gardner_did', 'etwfe'],
effect=0.05, # or 0.0 for a pure size / coverage study
n_sims=200, seed=1, n_jobs=-1,
)
print(study.summary())
study.best('rmse')
The redrawn assignment is random, so parallel trends holds by construction:
what the table measures is the estimators on your cohort structure, your
serial correlation and your number of treated units — not whether your design
is credible. Run it before fixing the specification, and report the table
alongside the estimate. effect=0.0 doubles as a size check: a rejection rate
far above the nominal level on your own panel is a warning about the standard
errors, not about the point estimates.
One caveat worth stating in the write-up: the calibration subtracts an
estimated effect, so the null it creates is zero up to an error of order
n^{-1/2} that every replication shares. That shifts all estimators the same
way, so the table compares estimators more precisely than it measures any one
estimator's absolute bias.
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 F(K, G-1) test on the leads, as Stata `test`
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(imp, df, y=, group=<unit id>, time=, first_treat=):
Borusyak-Jaravel-Spiess joint pre-trend test (addresses the
multiple-testing issue in per-lag tests). imp must be a
sp.did_imputation(..., horizon=[-3, ..., 3]) fit with the same
arguments; a Callaway-Sant'Anna result is refused.
- 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 (balance='pair') |
within-unit differences anyway; a unit missing the base or comparison period drops out of that cell, so the sample varies cell to cell |
balance='none' (the same as 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)) |
balance='full' |
one fixed sample throughout, at the cost of dropping incomplete units entirely, with a warning that counts them. This is the default of R did and of Stata csdid 2.0.0, so it is the setting that reproduces their numbers |
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.
They also assume different things about the missing cells. The default
compares each unit with itself, so it tolerates cells missing at random
and units that leave according to their level. allow_unbalanced_panel=True
compares group means, so it also needs each group's composition to be
stable over time. In tests/reliability/unbalanced_panel.py, when treated
units with a high level left the panel, the default still covered 94% and
the group-mean route was biased by 40% of the effect (coverage 50% with
100 units, 3% with 400). See
When the interval can be trusted.
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, binary or count treatment — dCDH 2020 DID_M | sp.did_multiplegt |
effect and placebos match Stata did_multiplegt_old at 1e-8 on a binary and on a count treatment; standard errors are bootstrap on both sides |
| On/off switching, binary or count treatment — 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); on a count treatment effects, analytic SEs, joint tests, normalized effects and path-by-path effects match Stata did_multiplegt_dyn at 1e-6 |
| On/off switching, binary treatment — match on treatment history (Imai-Kim-Wang) | sp.panel_match |
matched sets, refinement weights, estimates at every lead, conditional / unconditional SEs, balance and placebo estimates match R PanelMatch 3.1.5 at 1e-10 (frozen fixture); bootstrap SEs screened; five documented departures (R output that is wrong, e.g. ATC propensity weights) in docs/dev/modules/did.md; clean-room implementation |
| What does the TWFE coefficient weight? | sp.twowayfeweights |
counts, sums, sensitivity measures and the random-weights regression match Stata twowayfeweights for feTR and fdTR, with controls, other treatments and weights |
| 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 / bad controls | sp.did_timevarying_covariates |
approach 1 of Caetano, Callaway, Payne & Sant'Anna (2026); the regression cell matches R ptetools / did, and the DR, IPW, not-yet-treated and analytic-SE paths are checked on a design where the treatment moves the covariate by a known amount |
Covariates that the treatment itself moves are the case to be careful with.
Controlling for them contemporaneously is the classic bad-control mistake —
on the test panel in tests/test_did_bad_controls.py a two-way
fixed-effects regression with the covariate on the right-hand side returns
-0.18 for a true effect of 1.0, because the covariate carries part of the
effect. sp.did_timevarying_covariates freezes the covariate at g-1
instead, and covariate_pretest=True says whether that was necessary (how
much the treatment moves the covariate) and whether it was enough (a placebo
run a period earlier).
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 still labelledexperimental:trends_linandpredict_hetare not implemented. Everything else the authors' textbook uses is there and checked against their Stata command: both switch directions, non-binary treatments,normalized,controls,trends_nonparam,continuous,same_switchers,design,by_path, and the joint tests. Withse_method='analytic'the variance is the authors'U_Gg_varand nothing is bootstrapped. On an unbalanced panel with a non-binary treatment a handful of switchers can differ from Stata's sample; the function's module docstring says which and why.
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 |
| Few treated clusters (one or a handful) | AssumptionWarning |
Cluster-robust SEs over-reject whatever the total cluster count; use sp.did_few_treated (Conley-Taber / Ferman-Pinto) or sp.cs_jackknife (CV3). | sp.did_few_treated |
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 - Parallel trends: treated and control groups would have followed the same trajectory absent treatment - SUTVA: no spillovers between units
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 |
| 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 |
| Few treated clusters (one or a handful) | AssumptionWarning |
Cluster-robust SEs over-reject whatever the total cluster count; use sp.did_few_treated (Conley-Taber / Ferman-Pinto) or sp.cs_jackknife (CV3). | sp.did_few_treated |
Alternatives (ranked)
- sp.sun_abraham
- sp.did_imputation
- sp.sdid
- sp.did
- sp.synth
Typical minimum N: 50