Synthetic Control — 20 methods, one package¶
StatsPAI bundles a synthetic-control workflow with 20 estimator entry points, 6 inference strategies, automatic comparison, power analysis, sensitivity diagnostics, and report helpers. Validation strength varies by method and is reported through method-level metadata and the JSS evidence ledger. Validation status remains method-specific; broad API coverage is not a blanket parity claim.
Quick start¶
import statspai as sp
# Load the California Proposition 99 dataset (Abadie et al. 2010 style)
df = sp.synth.california_tobacco()
# Classic SCM (Abadie, Diamond & Hainmueller 2010)
res = sp.synth(
df, outcome='cigsale', unit='state', time='year',
treated_unit='California', treatment_time=1989,
method='classic',
)
print(res.summary())
sp.synth() is the unified dispatcher — switch method= to run any
of the 20 variants with the same API and return type.
The 20 methods¶
method= |
Paper | When to use |
|---|---|---|
classic |
Abadie, Diamond & Hainmueller (2010) | Baseline. One treated unit, few pre-periods, good donor fit. |
penalized / ridge |
Doudchenko & Imbens (2016) | When classic weights are unstable; ridge improves extrapolation. |
demeaned |
Ferman & Pinto (2021) | When unit fixed effects are large; demean before matching. |
unconstrained / elastic_net |
Doudchenko & Imbens (2016) | When negative/unbounded weights are theoretically justified. |
augmented / ascm |
Ben-Michael, Feller & Rothstein (2021) | When no exact pre-treatment fit exists; bias-corrects classic SCM. Several treated units with one date (averaged, as augsynth does), progfunc='ridge'/'none', scm=, fixedeff=, covariates=. |
sdid |
Arkhangelsky et al. (2021) | Combines DID & SCM; robust to effect heterogeneity. |
factor / gsynth |
Xu (2017) | Multiple treated units + interactive fixed effects. |
(sp.fect, not a method= value) |
Liu, Wang & Xu (2024) | Staggered / reversible treatment with many treated units: untreated-cell imputation under two-way FE, interactive FE (r factors) or matrix completion (lam); by-period ATT path, unit bootstrap / jackknife SEs. |
staggered |
Ben-Michael, Feller & Rothstein (2022) | Heterogeneous adoption times. |
mc |
Athey et al. (2021) | Many missing cells; matrix completion via nuclear norm. |
discos |
Gunsilius (2023) | When treatment affects the whole distribution, not just the mean. |
multi_outcome |
Sun (2023) | Multiple correlated outcomes; borrows strength across. |
scpi |
Cattaneo, Feng & Titiunik (2021) | Need honest prediction intervals, not placebo p-values. |
bayesian |
Vives & Martinez (2024) | Want full posterior; Dirichlet prior + MCMC credible intervals. |
bsts / causal_impact |
Brodersen et al. (2015) | Structural TS with level/trend; the Google CausalImpact model. |
penscm |
Abadie & L'Hour (2021) | Many similar donors; pairwise discrepancy penalty ensures interpretability. |
fdid |
Li (2024) | Want a subset of donors, not weights — simple to communicate. |
cluster |
Rho (2024) | Large donor pool; cluster first, then SCM within best cluster. |
sparse / lasso |
Amjad, Shah & Shen (2018) | High-dimensional donors; L1 picks a handful. |
kernel |
RKHS / MMD matching | Nonlinear donor relationships. |
kernel_ridge |
Kernel ridge regression | Nonlinear, unconstrained, regularised. |
Inference strategies¶
| Strategy | Call | When |
|---|---|---|
| Placebo (in-space) | default | Always available; exchangeable donor pool. |
| Conformal | method='conformal' or conformal_synth() |
Want finite-sample coverage guarantees (Chernozhukov et al. 2021). |
| Prediction intervals | scpi() |
Account for parameter + residual + design uncertainty. |
| Bootstrap / jackknife | SDID internal | Inference for SDID. |
| Bayesian posterior | bayesian_synth() |
Full credible intervals from MCMC. |
| BSTS posterior | bsts_synth() |
Kalman filter / smoother posterior draws. |
Placebo p-values and covariate specifications (classic SCM)¶
- p-value. The in-space placebo p-value is the treated unit's rank among
itself and its
Jplacebos, divided byJ + 1: a treated unit ranked 3rd of 39 gets3/39. - Confidence interval.
ciis the interval that p-value implies: the constant effectsCfor which the same rank test, applied after subtractingCfrom the treated unit's post-treatment outcomes, does not reject (Firpo and Possebom 2018). Zero is outside it exactly when the p-value is belowalpha. It is not symmetric around the estimate and is notestimate -/+ z * se. With fewer than1 / alpha - 1donors (19 at 5%) the test cannot reject anything and the interval is(-inf, inf); that is the information in the data, not a failure. The normal interval around the placebo spread thatciheld through 1.38.0 is inmodel_info['ci_normal']. - Speed. With
covariates=/special_predictors=every placebo re-solves the nested V-W problem. Passn_jobs=-1to fit placebos in parallel; results are bit-identical to the serial loop. - Convex hull.
model_info['in_predictor_hull']reports (by exact linear programming) whether the treated unit's predictors lie inside the donors' convex hull. If they do, infinitely many weight vectors fit the predictors exactly. The default ADH search (perfect_fit='legacy') then takes, for each V, the minimum-norm weights among those that fit equally well: the most evenly spread ones, and the same on every platform. Through 1.35 the choice was whatever SLSQP reached from a uniform start, often after minutes of search. perfect_fit='exact_balance'instead balances every predictor exactly and picks the best pre-treatment outcome fit among those weights (a certified convex QP, well under a second). It is a different estimator, not a faster ADH: the V search can drop predictors by giving them zero weight and fit the outcome better. On Prop 99 with four covariates, measured with the earlier inner solver, Montana's pre-period SSE was 12417 underexact_balanceagainst 2898 under the search, while Georgia's was 122 against 1022. Report which rule you used.
Staggered adoption and covariates with SDID¶
sp.sdid has two interfaces. treated_unit= / treatment_time= is the block
design of R synthdid. treat= takes a 0/1 indicator, as Stata's
sdid Y unit time W, and follows Stata sdid (Clarke, Pailañir, Athey and
Imbens 2024): units may adopt at different periods, each adoption cohort is
fitted against the never-treated units, and the cohort effects are averaged
by treated units × post periods.
import statspai as sp
res = sp.sdid(df, outcome="y", unit="country", time="year",
treat="w", se_method="jackknife")
res.model_info["tau_by_cohort"] # adoption, tau, se, n_treated, T_post, weight
res = sp.sdid(df, outcome="y", unit="country", time="year", treat="w",
covariates=["lngdp"], covariate_method="projected",
se_method="bootstrap", seed=1)
covariate_method="projected"removesX β, withβfrom a two-way fixed-effects regression on the never-treated units. Stata's default,optimized, is not implemented, so the method must be named.- The jackknife is deterministic and matches Stata to 1e-13; it needs two
treated units in every cohort. The bootstrap needs more than one treated
unit when there is a single cohort, and the placebo more never-treated than
treated units — the same refusals as Stata.
se_method="noinference"returns the point estimate only. sp.did(method="sdid")reads cohorts from itstreatcolumn (first treated period, 0 = never) and uses this path when there are several.
Research workflow¶
Run all methods at once¶
comp = sp.synth_compare(df, outcome='cigsale', unit='state', time='year',
treated_unit='California', treatment_time=1989)
comp.plot() # overlay all counterfactuals
comp.summary_table() # ATT, pre-RMSPE, p-value per method
best = sp.synth_recommend(comp) # auto-pick best by pre-fit + robustness
Power analysis before you commit¶
power = sp.synth_power(df, outcome='cigsale', unit='state', time='year',
treated_unit='California', treatment_time=1989,
effect_sizes=[2, 5, 10, 15, 20], n_sims=500)
sp.synth_power_plot(power)
# What's the minimum detectable effect at 80% power?
mde = sp.synth_mde(df, outcome='cigsale', ..., target_power=0.80)
Sensitivity suite¶
sens = sp.synth_sensitivity(res) # one-shot: LOO + time + donor + RMSPE
sp.synth_sensitivity_plot(sens)
# or individual components
sp.synth_loo(res) # leave-one-donor-out
sp.synth_time_placebo(res) # backdate the treatment time
sp.synth_donor_sensitivity(res) # random donor-pool subsampling
sp.synth_rmspe_filter(res) # drop high-residual placebos
Structured report helper¶
sp.synth_report(res, format='markdown') # 'text' | 'markdown' | 'latex'
sp.synth_report_to_file(res, 'analysis.md')
Produces: abstract, method, weights, placebo distribution, sensitivity, references — directly pasteable into a paper or appendix.
Canonical datasets¶
StatsPAI ships the three canonical SCM datasets as realistic simulations (same structure as the published data; use for teaching and unit tests):
sp.synth.california_tobacco() # Prop 99 (1989)
sp.synth.german_reunification() # Abadie, Diamond & Hainmueller (2015)
sp.synth.basque_terrorism() # Abadie & Gardeazabal (2003)
For real published data, use the causaldata R package or the authors'
replication files — StatsPAI does not redistribute copyrighted data.
Choosing a method¶
If you are unsure, run sp.synth_compare(...) and inspect:
- Pre-RMSPE — lower is better fit.
- Consistency across methods — if classic/ASCM/SDID/MC agree, the answer is robust. If they disagree, inspect why.
- Placebo distribution — is the treated-unit effect extreme relative to placebos?
Then run sp.synth_sensitivity(best) to stress-test the winner.
References¶
- Abadie, Diamond & Hainmueller (2010). JASA 105(490).
- Abadie & L'Hour (2021). JASA 116(536).
- Amjad, Shah & Shen (2018). JMLR 19(22).
- Arkhangelsky, Athey, Hirshberg, Imbens & Wager (2021). AER 111(12).
- Athey, Bayati, Doudchenko, Imbens & Khosravi (2021). JASA 116(536).
- Ben-Michael, Feller & Rothstein (2021). JASA 116(536).
- Brodersen, Gallusser, Koehler, Remy & Scott (2015). AOAS 9(1).
- Cattaneo, Feng & Titiunik (2021). JASA 116(536).
- Chernozhukov, Wüthrich & Zhu (2021). JASA 116(536).
- Doudchenko & Imbens (2016). NBER WP 22791.
- Ferman & Pinto (2021). Quantitative Economics 12(4).
- Gunsilius (2023). Econometrica 91(3).
- Li (2024). Forward Difference-in-Differences.
- Rho (2024). Cluster Synthetic Control Methods.
- Sun (2023). ReStat forthcoming.
- Vives & Martinez (2024). JCGS forthcoming.
- Xu (2017). Political Analysis 25(1).
For Agents¶
Pre-conditions - panel data in long form (unit × time × outcome) - single treated unit (classic) or a treatment-timing column (staggered) - ≥ 10 donor (untreated) units with similar pre-treatment trajectories - ≥ 10 pre-treatment periods (fewer → large weight on any one year)
Identifying assumptions - Treatment effect on the treated is identified by the counterfactual implicit in the donor weights - No spillover from treated unit to donors (SUTVA) - Donor pool contains units whose outcomes plausibly track the treated counterfactual - Pre-treatment fit (RMSPE) is small relative to post-treatment effect for placebo inference
Failure modes → recovery
| Symptom | Exception | Remedy | Try next |
|---|---|---|---|
| Pre-treatment RMSPE > post-treatment effect | AssumptionWarning |
Poor pre-fit — switch to method='demeaned'/'augmented' or enlarge donor pool. | sp.synth |
| Placebo p-value ≥ 0.1 despite visible gap | AssumptionWarning |
Use inference='conformal' (valid under weak assumptions) or report ranked placebo statistic. | sp.synth |
| All weight concentrated on one donor | AssumptionWarning |
Interpolation bias risk — check method='elastic_net' or augmented SCM. | sp.synth |
| Treated unit outside donor convex hull | IdentificationFailure |
Extrapolation needed — use method='unconstrained' or 'augmented'. | sp.synth |
Alternatives (ranked)
- sp.sdid
- sp.did
- sp.matrix_completion
- sp.causal_impact
Typical minimum N: 10