Skip to content

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.

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 in tests/test_ddd_covariates.py the true effect is 2.0, the regression returns 2.968 with or without the covariate, and method='dr' returns 2.054. Pass id= and method='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:

sp.panel(df, 'y ~ treat', entity='i', time='t', method='fe')
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.etwfe vs sp.wooldridge_did. One estimator, two headline aggregations of the same saturated cohort × period regression — not two methods, and not aliases. sp.etwfe reports what R etwfe::emfx(type='simple') and Stata jwdid, estat simple print; sp.wooldridge_did reports the cohort-size-weighted average of ATT(g) under never-treated controls. On the 17_etwfe parity 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 module 17_etwfe.

Poisson ETWFE: which number is "the ATT"? family='poisson' reports two scales. The default scale='response' is the average marginal effect in counts (R emfx() default). Papers that run Stata jwdid ..., method(ppmlhdfe) usually print estat simple, predict(xb): the treated-observation-weighted mean of the cohort × period coefficients, in log points. That is scale='link', and the two can differ by an order of magnitude. jwdid also absorbs unit fixed effects; R etwfe uses cohort dummies. They coincide on a balanced panel and not otherwise, so pass fe='unit' to reproduce jwdid when outcomes have missing values (the default warns in that case). Both scales live in res.model_info['aggregations'] and sp.etwfe_emfx(res, type=..., scale=...).

The rest of a jwdid table maps one to one. hettype='event' | 'cohort' | 'time' | 'twfe' is jwdid, hettype() (the restricted-heterogeneity robustness columns) -- on the linear model too, where hettype= or fe='unit' fits jwdid's reghdfe design. xvar='x' is jwdid y x; a categorical column (.astype('category')) is jwdid y i.x, and sp.etwfe_emfx(res, by_xvar=True) is estat simple, over(x). cgroup='nevertreated' is jwdid, never, and its leads feed sp.pretrends_test(res, window=(-5, -2)) (estat event, window(-5 5) pretrend) and sp.honest_did(res, l_vec='average', window=(-5, 5)) through the joint event-study covariance. Separated all-zero units are kept in N, the cluster count and the aggregation weights by default (separated='keep'); jwdid drops them whenever ppmlhdfe flags the separation, which separated='drop' reproduces — check jwdid's N to see which case your table is in.

Response-scale (count) standard errors under fe='unit' come in two conventions. The default response_se='profile' differentiates through the profiled unit effect. jwdid's estat runs margins, 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, use response_se='unconditional' (Stata margins, 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.jwdid takes these options under Stata's names and records the command it reproduces in res.model_info['stata_equivalent']; predict=None is estat simple (counts, with the margins SE by default), predict='xb' the log-point headline. Stata factor terms work in exovar= / x= (and in sp.etwfe's controls= / 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 in test_jwdid_Stata_parity.py). The fitted result draws its event study (res.plot(), sp.enhanced_event_study_plot(res)), runs res.pretrend_test(window=(-5, -2)), and fills the unit / year FE and cluster rows of sp.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:

  1. Treatment is confounded by pre-trends: use matched DID (sp.drdid) or synthetic control (sp.synth).
  2. Only one treated unit: use sp.synth or sp.causal_impact.
  3. Treatment is continuous dose, not 0/1 onset: use sp.continuous_did or sp.bunching (if at a threshold).
  4. Anticipation effects exist: use anticipation=h parameter in CS2021 to backdate the reference period. h shifts the base period for post-treatment cells only; under base_period="varying" a pre-treatment placebo keeps the period immediately before it, so its value does not move with h (this matches R did).

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 _dyn version is a long-difference event study with "not-yet-treated at horizon l" 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_dyn is still labelled experimental: trends_lin and predict_het are 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. With se_method='analytic' the variance is the authors' U_Gg_var and 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