Skip to content

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:

\[ \widehat{\text{ATT}}(g, t) = (\bar{Y}_{g, t} - \bar{Y}_{g, b}) - (\bar{Y}_{c, t} - \bar{Y}_{c, b}) \]

and observation-level influence functions

\[ \psi_i = \frac{\mathbf{1}\{G_i=g,\,T_i=t\}(Y_i - \bar{Y}_{g,t})}{p_{g,t}} - \frac{\mathbf{1}\{G_i=g,\,T_i=b\}(Y_i - \bar{Y}_{g,b})}{p_{g,b}} - \frac{\mathbf{1}\{G_i=c,\,T_i=t\}(Y_i - \bar{Y}_{c,t})}{p_{c,t}} + \frac{\mathbf{1}\{G_i=c,\,T_i=b\}(Y_i - \bar{Y}_{c,b})}{p_{c,b}} \]

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

  • estimator must be 'reg'
  • control_group must 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 under clustervars would 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 across t within 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