Skip to content

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 J placebos, divided by J + 1: a treated unit ranked 3rd of 39 gets 3/39.
  • Confidence interval. ci is the interval that p-value implies: the constant effects C for which the same rank test, applied after subtracting C from the treated unit's post-treatment outcomes, does not reject (Firpo and Possebom 2018). Zero is outside it exactly when the p-value is below alpha. It is not symmetric around the estimate and is not estimate -/+ z * se. With fewer than 1 / alpha - 1 donors (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 that ci held through 1.38.0 is in model_info['ci_normal'].
  • Speed. With covariates= / special_predictors= every placebo re-solves the nested V-W problem. Pass n_jobs=-1 to 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 under exact_balance against 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" removes X β, 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 its treat column (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:

  1. Pre-RMSPE — lower is better fit.
  2. Consistency across methods — if classic/ASCM/SDID/MC agree, the answer is robust. If they disagree, inspect why.
  3. 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