Repeated cross-sections (panel=False)¶
Classic Callaway–Sant'Anna requires a balanced panel: every unit observed in every period. Many causal-inference datasets are repeated cross-sections (RCS) — pooled surveys (CPS, ACS), rolling polls, independent samples per period — where no within-unit first difference is available. StatsPAI's RCS path solves this with the unconditional 2×2 cell-mean DID.
Estimator¶
For each (g, t, base) triple with never-treated control c = 0:
and observation-level influence functions
where \(p_{\cdot,\cdot}\) is the empirical cell share. The influence
functions are stacked into the same inf_matrix contract used by the
panel estimator, so aggte, cs_report, ggdid, and honest_did
work downstream without modification.
Usage¶
sp.callaway_santanna(
df, y='y', g='first_treat', t='year', i='obs_id',
estimator='reg', # required for panel=False
panel=False,
)
Covariate adjustment¶
Add x=[...] for regression-adjusted RCS. Y is residualised on X
using OLS fit on the never-treated pool (with period fixed effects);
the cell-mean DID then runs on the residualised outcome:
sp.callaway_santanna(
survey_df, y='wage', g='g', t='year', i='obs',
estimator='reg', panel=False,
x=['age', 'education', 'female'],
)
Influence functions treat β̂ as known (plug-in IF), which is asymptotically valid at √n. A fully-coupled Sant'Anna–Zhao (2020) IF augmentation is planned.
Scope of the current implementation¶
estimatormust be'reg'control_groupmust be'nevertreated'- IPW / DR for RCS: planned for a future release
All other paths raise NotImplementedError with an actionable message.
Inference and weighting options (parity with R did / csdid)
Since 1.23.0 the repeated-cross-section route takes the same options as
the panel route, so a panel = FALSE script from R ports across without
dropping anything:
sp.callaway_santanna(
df, y="y", g="g", t="t", i="i",
panel=False,
x="~ lpop + I(lpop**2)", # R xformla; I(x^2) is rejected, use **
weights="pop", # part of the estimand, not a precision knob
clustervars=["i", "state"], # requires bstrap=True
bstrap=True, cband=True, biters=1000, random_state=0,
)
Two rules carry over from the panel path and are enforced, not assumed:
- Clustering beyond the unit requires
bstrap=True. The analytic per-cell standard errors cannot express within-cluster dependence, so reporting them underclustervarswould understate uncertainty. - Under
allow_unbalanced_panel=True, weights and cluster labels must be time-invariant within unit. That route folds a unit's influence contributions together, so a within-unit-varying weight would silently reweight that unit's own periods against each other.
Aggregation weights: R did and Stata csdid differ here¶
With repeated cross-sections there are no units to count, so the
aggregation of ATT(g, t) has to weight cells by observations — and the
two reference implementations pick different weights. The cells agree to
machine precision; the aggregates do not.
- StatsPAI and R
did: a cohort's weight is its share of treated observations, constant acrosstwithin the cohort. - Stata
csdid: each cell carries its own treated-observation count (the base period's observations plus the comparison period's), and the group average weights a cohort by its mean cell weight rather than its total — so a cohort observed for fewer post-treatment periods is not down-weighted for having fewer of them.
On the fixture in tests/reference_parity/test_rcs_aggregation_conventions.py
the simple ATT differs by 0.7 percent, the calendar average by 1.4 percent
and the group average by 0.02 percent. That test rebuilds each of Stata's
three aggregates from StatsPAI's own cells to 1e-12, so a reconciliation
against csdid on repeated cross-sections can price the difference instead
of recording it as unexplained. Neither convention is wrong; they are
different weighted averages of the same cells, and which one a paper wants
is a statement about its estimand.
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