Synthetic Control Method (SCM)#
Classic synthetic control estimator for a single treated unit (Abadie, Diamond & Hainmueller 2010; originating in Abadie & Gardeazabal 2003).
The treated unit’s counterfactual is a convex combination of “donor” (never-treated)
units. Donor weights W*(V) solve a simplex-constrained, predictor-importance-weighted
least-squares fit of the treated unit’s pre-period predictors; the diagonal
predictor-importance matrix V is chosen data-driven (minimizing pre-period outcome
MSPE, v_method="nested"; out-of-sample cross-validation, v_method="cv"; or
closed-form inverse-variance, v_method="inverse_variance") or supplied by the user
(v_method="custom"). The
treatment-effect path is the gap \(\hat{\alpha}_{1t} = Y_{1t} - \sum_j w_j Y_{jt}\)
over the post periods.
When to use SCM:
Exactly one treated unit with a long, well-fit pre-treatment period.
A curated donor pool of comparable never-treated units.
Aggregate / few-unit comparative case studies (states, regions, countries).
Inference: classic SCM has no analytical standard error. se, t_stat,
p_value and conf_int are always NaN; att (the mean post-period gap) is the
reported estimate. Significance comes from in-space placebo permutation inference via
in_space_placebo() (post/pre RMSPE-ratio statistic,
placebo_p_value = rank/(n_placebos+1)). This permutation p-value is a separate field
from the (NaN) p_value; is_significant stays bound to p_value.
Robustness diagnostics (ADH 2015 §4, opt-in):
leave_one_out() drops each reportably-weighted (weight > 1e-6)
donor and re-fits (per-drop ATT / delta_att table — a large delta_att flags
single-donor dependence);
in_time_placebo() reassigns the intervention to an
earlier pre-date and checks for a spurious gap before the true treatment date (the
backdating placebo; placebo_att should be ~0);
regression_weights() computes the implied
regression-counterfactual donor weights W^reg (intercept-augmented) and flags those
outside [0, 1] — the extrapolation an OLS counterfactual would incur but the
simplex-constrained synthetic control cannot (pure linear algebra, no refit); and
sparse_synthetic_control() exhaustively searches
size-l donor subsets holding V fixed at the baseline, showing how fit / ATT degrade
as the synthetic is forced sparse. All leave the analytical inference fields NaN.
Confidence sets by test inversion (Firpo & Possebom 2018 §4, opt-in):
test_sharp_null() tests a sharp null
H_0: alpha_1t = f(t) (a scalar constant effect, or a post-period effect path) by
re-ranking the stored in-space placebo gaps — no refits, and test_sharp_null(0) is
identically placebo_p_value — and
confidence_set() (family="constant" or
"linear") inverts that test into a confidence set for the effect path: a
constant-effect interval (Eqs. 15–16) or a linear-slope set (Eqs. 17–18), with the
paper’s strict p > gamma membership (Eq. 14), computed by exact piecewise-constant
breakpoint inversion (or a fixed grid when bounds= is supplied). The set is
summarized on effect_confidence_set and returned by
get_confidence_set_df(); the analytical
conf_int stays NaN (this is a separate permutation set at level 1 - gamma,
possibly a set / unbounded / non-contiguous).
Conformal inference (Chernozhukov-Wüthrich-Zhu 2021, opt-in). Unlike the Firpo
path (which re-ranks the cross-unit placebo gaps), the conformal layer fits its own
time-permutation-invariant constrained-LS proxy (Eqs. 3–4, no V-matrix) under the
null on all periods and permutes residuals over time for the single treated
unit. conformal_test() gives a joint sharp-null
p-value for a hypothesized effect trajectory (Eqs. 1–2; statistic order q in {1, 2,
inf}); conformal_confidence_intervals() gives
pointwise per-period confidence intervals by test inversion (Algorithm 1 — each period
t uses Z = (pre-periods, t), the other post-periods dropped); and
conformal_average_effect() gives a confidence
interval for the average post-period effect by collapsing into non-overlapping
T*-blocks (Appendix A.1). scheme="moving_block" (default; valid under serial
dependence) or "iid" (finer p-values). The most recent run is summarized on
conformal_inference and the inversion grid is on
get_conformal_grid_df(); the analytical
conf_int stays NaN.
Distinct from SyntheticDiD (Arkhangelsky et al. 2021), which adds
time weights and ridge regularization; classic SCM uses donor weights only plus the
outer V search.
Reference: Abadie, A., Diamond, A., & Hainmueller, J. (2010). Synthetic Control Methods for Comparative Case Studies: Estimating the Effect of California’s Tobacco Control Program. Journal of the American Statistical Association, 105(490), 493–505. doi:10.1198/jasa.2009.ap08746
SyntheticControl#
Main estimator class for classic synthetic control estimation.
- class diff_diff.SyntheticControl[source]
Bases:
objectClassic Synthetic Control Method estimator (Abadie-Diamond-Hainmueller 2010).
- Parameters:
v_method ({"nested", "custom", "cv", "inverse_variance"}, default "nested") – How the predictor-importance matrix V is chosen.
"nested"selects V data-driven by minimizing the pre-period outcome MSPE ofW*(V)(ADH 2010 §2.3)."custom"uses the user-suppliedcustom_vand skips the outer search."cv"selects V by out-of-sample cross-validation (ADH 2015 §; Abadie 2021 Eq. 9): the pre-period is split atv_cv_t0into a training and a validation window; V is chosen to minimize the validation-window outcome MSPE of the training-fit weights, then the final weights are re-estimated on the validation-window predictors."inverse_variance"uses the closed-formv_h = 1/Var(X_{h·})(Abadie 2021 §3.2(a); variance over donors+treated), applied to the RAW predictors so the effective objective is the unit-variance-rescaledΣ_h diff_h²/Var_h— no search, deterministic. Note this rescaling is whatstandardize="std"does, so thestandardizesetting does not compose with it (equivalent to uniform V on standardized predictors); applying1/Varon already-standardized rows would double-rescale toΣ_h diff_h²/Var_h².custom_v (array-like, optional) – Diagonal of V (length = number of predictors). Required iff
v_method="custom"; must be None for every otherv_method(nested/cv/inverse_variance). Must be finite and non-negative; trace-normalized internally.optimizer_options (dict, optional) – Extra options merged into every
scipy.optimize.minimizecall in the outer V search (e.g.maxiter,xatol,fatol).n_starts (int, default 4) – Number of starting points for the multistart outer V search.
inner_max_iter (int, default 10000) – Max iterations for the inner Frank-Wolfe simplex solve.
inner_min_decrease (float, default 1e-5) – Inner-solve convergence scale (matches the SDID/Frank-Wolfe precedent in
prep.py). The Frank-Wolfe stop threshold is(inner_min_decrease * max(||b||, 1e-12))**2wherebis the V^½-scaled treated predictor vector — scale-aware so convergence is meaningful at any data magnitude. 1e-5 reproduces RSynth’s donor weights to ~1e-4 on the Basque benchmark while still signalling convergence; tighter values (e.g. 1e-6) can exhaustinner_max_iter.standardize ({"std", "none"}, default "std") – Predictor standardization.
"std"divides each predictor row by its standard deviation across donors+treated (ddof=1), matching RSynth."none"is a deviation from R (see REGISTRY).alpha (float, default 0.05) – Significance level recorded for downstream (placebo) inference.
seed (int, optional) – Seed for the multistart random (Dirichlet) starting points.
v_cv_t0 (int, optional) – Training/validation split index for
v_method="cv"only (positional into the pre-periods: training =pre[:v_cv_t0], validation =pre[v_cv_t0:]). Must leave at least 1 training and 1 validation pre-period. Default None →len(pre_periods) // 2(Abadie 2021’st0 = T0/2). Must be None unlessv_method="cv".
Methods
fit(data, outcome, treatment, unit, time, *)Fit the classic synthetic control model.
get_params()Get estimator parameters.
set_params(**params)Set estimator parameters.
- __init__(v_method='nested', custom_v=None, optimizer_options=None, n_starts=4, inner_max_iter=10000, inner_min_decrease=1e-05, standardize='std', alpha=0.05, seed=None, v_cv_t0=None)[source]
- set_params(**params)[source]
Set estimator parameters.
Applies updates transactionally: if
_validate_config()rejects the post-update state, the instance is rolled back to its pre-call values so a raisedValueErrorleaves the object consistent.- Return type:
- fit(data, outcome, treatment, unit, time, *, post_periods=None, treated_unit=None, predictors=None, predictors_op='mean', predictor_window=None, special_predictors=None, pre_period_outcomes=None, donor_pool=None, survey_design=None)[source]
Fit the classic synthetic control model.
- Parameters:
data (pandas.DataFrame) – Balanced panel.
outcome (str) – Column names.
treatmentis the ABSORBING treatment indicator (0/1): 1 for the treated unit in its treated periods, 0 otherwise.treatment (str) – Column names.
treatmentis the ABSORBING treatment indicator (0/1): 1 for the treated unit in its treated periods, 0 otherwise.unit (str) – Column names.
treatmentis the ABSORBING treatment indicator (0/1): 1 for the treated unit in its treated periods, 0 otherwise.time (str) – Column names.
treatmentis the ABSORBING treatment indicator (0/1): 1 for the treated unit in its treated periods, 0 otherwise.post_periods (list, optional) – Explicit post-treatment period values. If None, inferred from the treated unit’s treatment column (the D==1 periods).
treated_unit (Any, optional) – Identifier of the treated unit. If None, inferred as the single ever-treated unit.
predictors (list of str, optional) – Columns averaged over
predictor_window(usingpredictors_op) to form predictor rows.predictors_op ({"mean", "sum"}, default "mean") – Aggregation operator for
predictors(linear combinations only, per ADH 2010 §2.3).predictor_window (list, optional) – Pre-periods over which
predictorsare averaged. Defaults to all pre periods. Must be a non-empty subset of the pre periods.special_predictors (list of (var, periods, op), optional) – Per-variable special predictors, each averaged over its own periods with its own operator (mirrors R
Synthspecial.predictors).pre_period_outcomes ("all" or list, optional) – Use individual pre-period outcomes as predictor rows (“all” = every pre period). When no predictor arguments are given at all, defaults to all pre-period outcomes.
donor_pool (list, optional) – Explicit donor unit identifiers (must be never-treated). Defaults to all never-treated units.
survey_design (optional) – Not yet supported — raises
NotImplementedErrorif provided.
- Return type:
SyntheticControlResults#
Results container for synthetic control estimation.
- class diff_diff.SyntheticControlResults[source]
Bases:
objectResults from a classic Synthetic Control Method (SCM) estimation.
Implements Abadie, Diamond & Hainmueller (2010), “Synthetic Control Methods for Comparative Case Studies.” A single treated unit’s counterfactual is the convex combination
Σ_j w_j · Y_jtof donor units chosen to match the treated unit’s pre-period outcomes and predictors; the treatment effect path is the gapα̂_1t = Y_1t − Σ_j w_j · Y_jtover the post periods.- att
Average post-period gap (the reported point estimate). The per-period gaps are in
gap_path.- Type:
- se
Always NaN — classic SCM has no analytical standard error (inference is permutation/placebo based; see Abadie-Diamond-Hainmueller 2010 §2.4).
- Type:
- t_stat, p_value
Always NaN (no analytical SE).
- Type:
- n_obs
Number of observations (treated + donor rows over all periods) used.
- Type:
- n_donors
Number of donor units in the (post-filter) donor pool.
- Type:
- n_pre_periods
Number of pre-treatment periods.
- Type:
- n_post_periods
Number of post-treatment periods.
- Type:
- donor_weights
Mapping
{donor_unit_id: weight}on the unit simplex. Weights below the interpretability floor (1e-6) are dropped.- Type:
- v_weights
Mapping
{predictor_label: v}— the diagonal predictor-importance matrix V, trace-normalized to sum to 1. On the degenerate single-donor path (one donor forcesw=[1]) V is unidentified — every V yields the same synthetic — sov_weightsis uniform for everyv_method(includingcv/inverse_variance), with aUserWarningemitted at fit time.- Type:
- predictor_balance
Predictor-balance table: for each predictor, the treated value, the synthetic value (donor-weighted), and the donor-pool mean. Under
v_method="cv"the reporteddonor_weightscome from the ADH-2015 step-4 refit on the validation-window re-aggregated predictors, so thetreated/synthetic/donor_meanvalues are reported on that same validation-window basis (each spec re-aggregated overpre[v_cv_t0:]) — the row’spredictorlabel remains the full spec identity, so it stays aligned withv_weights. For every otherv_methodthe values are the full-pre-period predictor aggregates.- Type:
- gap_path
Mapping
{period: gap}for ALL periods (pre periods carry the fit residual used forpre_rmspe; post periods carry the effect path).- Type:
- pre_rmspe
Root mean squared prediction error over the pre-treatment periods (the primary fit diagnostic).
- Type:
- mspe_v
The outer-objective value of the selected
V: the pre-period outcome MSPE ofW*(V*)underv_method="nested", or the held-out validation-window outcome MSPE underv_method="cv"(the CV selection criterion). None when there is no outer search — thev_method="custom"and"inverse_variance"paths and the degenerate single-donor path. Not comparable acrossv_methodvalues (different objective windows).- Type:
float, optional
- treated_unit
The treated unit’s identifier.
- Type:
Any
- pre_periods, post_periods
Calendar-sorted pre / post period values.
- Type:
- v_method
"nested"(data-driven V),"custom"(user-supplied V),"cv"(out-of-sample cross-validation V), or"inverse_variance"(closed-form1/Var(X)V).- Type:
- v_cv_t0
The training/validation split index actually used under
v_method="cv"(the resolved value — equalsn_pre_periods // 2when the constructor’sv_cv_t0was None). None for every otherv_method. Survives pickling.- Type:
int, optional
- standardize
"std"(per-row SD scaling) or"none".- Type:
- alpha
Significance level recorded for downstream (placebo) inference.
- Type:
- rmspe_ratio
The treated unit’s post/pre RMSPE ratio =
sqrt(MSPE_post / MSPE_pre)— the in-space placebo test statistic (ADH 2010 §2.4), computed at fit time.- Type:
- placebo_p_value
In-space placebo permutation p-value (
rank / (n_placebos + 1)), NaN untilin_space_placebo()is run. SEPARATE from the (always-NaN) analyticalp_value;is_significantstays bound top_value.- Type:
- n_placebos, n_failed, n_infeasible
Donor placebos that entered the permutation reference set / were excluded for solver non-convergence / were excluded as structurally infeasible (under
v_method="cv", a re-aggregated window with no cross-donor variation once that donor is pseudo-treated). All 0 untilin_space_placebo()is run.n_infeasiblemirrors the splitin_time_placebo()already reports; the permutationplacebo_p_valueuses only then_placebosthat entered the rank, so it is unaffected by how the excluded remainder is attributed.- Type:
- survey_metadata
Reserved; always None in this release.
- Type:
Any, optional
- Significance for classic SCM comes from :meth:`in_space_placebo` (opt-in
- in-space placebo permutation inference); :meth:`get_placebo_df` returns the
- per-unit RMSPE-ratio table used for the rank.
Methods
in_space_placebo([n_starts])In-space placebo permutation inference (Abadie-Diamond-Hainmueller 2010, Section 2.4).
get_placebo_df()Get the in-space placebo distribution as a DataFrame (one row per unit).
leave_one_out([n_starts])Leave-one-out donor robustness (Abadie-Diamond-Hainmueller 2015, Section 4).
get_leave_one_out_df()Get the leave-one-out donor-robustness table (see
leave_one_out()).get_leave_one_out_gaps()Long-form leave-one-out gap paths, for the overlay ("spaghetti") plot.
in_time_placebo([placebo_periods, n_starts])In-time (backdating) placebo (Abadie-Diamond-Hainmueller 2015, Section 4).
get_in_time_placebo_df()Get the in-time placebo table (see
in_time_placebo()).get_in_time_placebo_gaps()Long-form in-time placebo gap paths, for the backdating overlay plot.
regression_weights()Regression-weight extrapolation diagnostic (ADH 2015 §4, journal pp.
get_regression_weights_df()Get the regression-weight extrapolation table (see
regression_weights()).sparse_synthetic_control([sizes, max_subsets])Sparse synthetic-control subset search (ADH 2015 §4, journal pp.
get_sparse_synthetic_control_df()Get the sparse synthetic-control table (see
sparse_synthetic_control()).get_sparse_synthetic_control_gaps()Long-form per-size sparse gap paths, for the overlay ("spaghetti") plot.
test_sharp_null(effect, *[, gamma, n_starts])Test a sharp null hypothesis on the treatment-effect path (Firpo & Possebom 2018, §4.1).
confidence_set(*[, family, gamma, bounds, ...])Confidence set for the treatment-effect path by test inversion (Firpo & Possebom 2018, §4.2).
get_confidence_set_df()Get the test-inversion confidence-set grid table (see
confidence_set()).conformal_test(effect, *[, q, scheme, ...])Joint sharp-null conformal test
H0: θ = effect(Chernozhukov-Wüthrich-Zhu 2021, §2.2).conformal_confidence_intervals(*[, alpha, ...])Pointwise per-period conformal confidence intervals (Chernozhukov-Wüthrich-Zhu 2021, Algorithm 1).
conformal_average_effect(*[, alpha, scheme, ...])Confidence interval for the AVERAGE post-period effect (Chernozhukov-Wüthrich-Zhu 2021, Appendix A.1).
get_conformal_grid_df()Get the conformal test-inversion grid table (see
conformal_average_effect()/conformal_confidence_intervals()).get_conformal_ci_df()Get the pointwise per-period conformal CI table (see
conformal_confidence_intervals()).summary([alpha])Generate a formatted summary of the estimation results.
print_summary([alpha])Print the summary to stdout.
to_dict()Convert scalar results to a dictionary.
to_dataframe()Convert scalar results to a single-row pandas DataFrame.
get_gap_df()Get the gap (effect) path as a DataFrame, in calendar order.
get_weights_df()Get donor weights as a DataFrame, sorted by weight descending.
- att: float
- se: float
- t_stat: float
- p_value: float
- n_obs: int
- n_donors: int
- n_pre_periods: int
- n_post_periods: int
- predictor_balance: DataFrame
- pre_rmspe: float
- treated_unit: Any
- v_method: str
- standardize: str
- alpha: float = 0.05
- placebo_p_value: float = nan
- rmspe_ratio: float = nan
- n_placebos: int = 0
- n_failed: int = 0
- n_infeasible: int = 0
- __getstate__()[source]
Exclude panel-derived internal state from pickling.
_fit_snapshotretains the full treated+donor panel and_placebo_gapsthe per-unit gap paths — both panel-derived, a privacy/size hazard if the pickle is sent elsewhere. The scalar placebo fields (placebo_p_value,rmspe_ratio,n_placebos,n_failed,n_infeasible) and the small_placebo_dfaggregate table survive. An unpickled result keeps all public fields; a diagnostic call that needs the snapshot (in_space_placebo) then raises a ValueError directing the user to re-fit. MirrorsSyntheticDiDResults.
- __setstate__(state)[source]
Restore pickled state, backfilling scalar diagnostic fields added later.
Unpickling bypasses
__init__/__post_init__, so a pickle written by an OLDER version (beforen_infeasible/_loo_n_infeasibleexisted) would otherwise leave those attributes unset and makesummary()/to_dict()/DiagnosticReportraiseAttributeError. Default any missing counter to 0 (the “no infeasible refits recorded” state) so a legacy result reports cleanly.
- property coef_var: float
SE / abs(ATT). NaN here (SE is always NaN).
- Type:
Coefficient of variation
- property is_significant: bool
Always False — classic SCM produces no analytical p-value.
- property significance_stars: str
Significance stars based on p-value (empty here — p_value is NaN).
- summary(alpha=None)[source]
Generate a formatted summary of the estimation results.
- print_summary(alpha=None)[source]
Print the summary to stdout.
- Parameters:
alpha (float | None)
- Return type:
None
- to_dict()[source]
Convert scalar results to a dictionary.
- Returns:
Dictionary of the scalar estimation results (weights/balance/gaps are available via the
get_*_dfaccessors).- Return type:
Dict[str, Any]
- to_dataframe()[source]
Convert scalar results to a single-row pandas DataFrame.
- Return type:
- get_gap_df()[source]
Get the gap (effect) path as a DataFrame, in calendar order.
Rebuilt period-keyed from
gap_pathusing the canonicalpre_periods + post_periodsorder so the row order is independent of any dict-insertion order. Columns:period,gap,phase.- Return type:
- get_weights_df()[source]
Get donor weights as a DataFrame, sorted by weight descending.
- Returns:
Columns:
unit,weight.- Return type:
- get_placebo_df()[source]
Get the in-space placebo distribution as a DataFrame (one row per unit).
This is a per-unit SUMMARY table (one row per unit), enough to reproduce the permutation rank and a ratio-distribution plot — NOT the per-period placebo gap paths needed for the classic “spaghetti” plot (those are retained internally on
_placebo_gapsfor the successful placebos). Columns:unit,pre_mspe,post_mspe,rmspe_ratio,is_treated,status("treated"/"placebo"/"failed"). The treated unit is always present as a singleis_treated=True, status="treated"row (its ratio is the original J-donor fit). After a placebo run that produced a reference set (>= 2donors AND a converged treated fit), the table hasn_donors + 1rows — every donor appears, including those whose refit did not converge (status="failed"with NaN metrics, excluded from the rank). In the degenerate / fail-closed cases (fewer than 2 donors, or a treated fit that did not converge) the placebo loop does not run, so only the treated row is returned.Populated by
in_space_placebo(); the summary table is retained on pickling, so it is still returned after a round-trip. Before any placebo run — including on an unpickled result that never ran one — only the treated row is returned.- Return type:
- in_space_placebo(n_starts=None)[source]
In-space placebo permutation inference (Abadie-Diamond-Hainmueller 2010, Section 2.4).
Reassigns the treatment to each donor in turn, re-estimates a synthetic control for that pseudo-treated donor against the OTHER donors, and ranks the real treated unit’s post/pre RMSPE ratio among all units. Populates
placebo_p_value,n_placebos,n_failedandn_infeasibleon this object (rmspe_ratio— the treated unit’s own ratio — is set at fit time) and returns the placebo distribution viaget_placebo_df().The real treated unit is excluded from every placebo’s donor pool: its post-period outcome is treatment-contaminated, so allowing a placebo to load weight on it would bias the placebo gap. The ranking set is therefore the
J+1units{treated} ∪ {J placebos}, with each placebo fit against the otherJ-1donors (this matches the standardSCtools::generate.placebosconstruction). The post/pre RMSPE ratio normalizes by pre-treatment fit, which obviates the pre-fit-cutoff filtering of ADH Figures 5-7 (journal p. 502), so no pre-fit filter is offered — every converged placebo enters the rank.The permutation
placebo_p_valueis intentionally distinct fromp_value(which stays NaN — classic SCM has no analytical SE) and fromis_significant(which also stays bound to the NaNp_value).A placebo is excluded from the reference set for one of two reasons, counted separately. A solver non-convergence (counted in
n_failed,status="failed") is EITHER an inner Frank-Wolfe weight solve that did not converge (a truncatedWis unusable) OR an outerVsearch that did not converge (an under-optimizedVfits the pre-period worse, shrinking its RMSPE ratio and biasing the permutation p-value anti-conservatively). A structural cv infeasibility (counted inn_infeasible,status="infeasible";v_method="cv"only) is a pseudo-treated donor pool that is indistinguishable in a re-aggregated CV window, so the weights are unidentified — remedied by adjusting the predictors /v_cv_t0/ donor pool, NOT the optimizer budget. Both are excluded from the rank identically, soplacebo_p_valueis unaffected by the attribution. Each placebo refit inherits the original fit’s ``optimizer_options`` / ``n_starts``, so valid inference requires settings adequate for the outerVsearch to converge: production defaults do; with cheap settings, raisen_startshere or re-fit with a largeroptimizer_options['maxiter'](otherwise placebos are dropped as failed). The treated unit’s own fit is held to the same standard — if its inner OR outer search did not converge, the whole run fails closed (see below).- Parameters:
n_starts (int, optional) – Override the multistart count for each placebo’s outer V search (nested/cv). Default None inherits the original fit’s
n_starts. The placebo loop is the cost driver (one outer V search per donor); lower it for a faster, coarser scan.- Returns:
The placebo distribution (see
get_placebo_df()).- Return type:
- Raises:
ValueError – If the fit snapshot is unavailable (e.g. this result was unpickled).
- leave_one_out(n_starts=None)[source]
Leave-one-out donor robustness (Abadie-Diamond-Hainmueller 2015, Section 4).
Drops each reportably-weighted donor, one at a time, and re-fits the treated unit’s synthetic control against the remaining donor pool. The per-drop ATTs reveal whether the estimated effect is driven by any single donor (ADH 2015 overlay the leave-one-out counterfactual trajectories for this purpose;
get_leave_one_out_gaps()returns those paths). This is a thin re-run of the validated SCM solver — it has no analytical standard error;se/t_stat/p_value/conf_intandis_significantare unaffected (still bound to the NaN analyticalp_value).The drop set is exactly the donors in
donor_weights— those above the1e-6interpretability floor (synthetic_control._MIN_REPORT_WEIGHT). A donor with negligible weight0 < w ≤ 1e-6is excluded (its removal moves the ATT by ~the weight, so itsdelta_attwould be ~0 — an uninformative row), keeping the LOO table aligned with the reported support; a zero-weight donor’s removal leaves the synthetic unchanged. (This 1e-6 approximation of “positive weight” is documented in REGISTRY §SyntheticControl.) A donor that carries ALL the weight is still dropped (the others absorb its mass on re-fit); its largedelta_attis exactly the single-donor-dependence signal this diagnostic exists to surface, NOT a failure.- Parameters:
n_starts (int, optional) – Override the multistart count for each leave-one-out refit’s outer V search (nested/cv). Default None inherits the original fit’s
n_starts.- Returns:
One
status="baseline"row (the full fit,delta_att=0) followed by one row per dropped donor:status="loo", or — with NaN metrics — an excluded drop that is"failed"(its refit did not converge) or"infeasible"(underv_method="cv"the reduced donor pool is indistinguishable in a re-aggregated CV window). Rows are sorted by|delta_att|descending, with the excluded ("failed"/"infeasible") rows last. Columns:dropped_unit,att,pre_rmspe,post_rmspe,rmspe_ratio,delta_att(att_loo - full_att),status.- Return type:
- Raises:
ValueError – If the fit snapshot is unavailable (e.g. this result was unpickled).
- get_leave_one_out_df()[source]
Get the leave-one-out donor-robustness table (see
leave_one_out()).Survives pickling. Raises if
leave_one_out()has not been run.- Return type:
- get_leave_one_out_gaps()[source]
Long-form leave-one-out gap paths, for the overlay (“spaghetti”) plot.
One row per (dropped donor, period) for every converged leave-one-out refit. Columns:
dropped_unit,period,gap,phase("pre"/"post") — mirroringget_gap_df(). These per-period paths are panel-derived and are NOT retained after pickling.- Return type:
- Raises:
ValueError – If
leave_one_out()has not been run, or if the gap paths were dropped on pickling (re-fit and re-run to recompute them).
- in_time_placebo(placebo_periods=None, n_starts=None)[source]
In-time (backdating) placebo (Abadie-Diamond-Hainmueller 2015, Section 4).
Reassigns the intervention to an earlier pre-treatment date
t_fand re-fits the synthetic control using ONLY pre-t_finformation, then measures the “effect” over the held-out window[t_f, T0). A credible synthetic control should show no spurious gap there (ADH 2015 Figure 4, German reunification backdated to 1975). This is a thin re-run of the validated SCM solver — it has no analytical standard error;se/t_stat/p_value/conf_intandis_significantare unaffected.Windowing convention (TRUNCATE). The placebo fit uses only periods strictly before
t_f: pre-period-outcome predictors become the pre-t_foutcomes, and covariate / special predictor windows are intersected with the pre-t_fwindow. A predictor window lying ENTIRELY in the held-out region[t_f, T0)is dropped (surfaced inn_dropped_specs+ an aggregated warning). For outcome-predictor fits this equals the literal “lag the predictors” re-run of a manualSynth::synth(R has no in-time-placebo function); seedocs/methodology/REGISTRY.mdfor the recognized deviation note.- Parameters:
placebo_periods (period value or list of period values, optional) – The pseudo-intervention date(s), each a member of
pre_periods. Default None sweeps every feasible interior pre-date (at least 2 pre-fake periods to fit + at least 1 post-fake period to measure the gap). A date that is a true post-treatment period, or not a pre-period at all, raisesValueError; a valid pre-date that is dimensionally infeasible (too few pre-fake periods, or all predictors dropped) yields astatus="infeasible"row (no raise).n_starts (int, optional) – Override the multistart count for each placebo refit’s outer V search (nested/cv). Default None inherits the original fit’s
n_starts.
- Returns:
One row per placebo date. Columns:
placebo_period,placebo_att(mean gap over the held-out window — should be ~0 if no real pre-period effect),pre_fit_rmspe,rmspe_ratio(post-fake/pre-fake),n_pre_fake,n_post_fake,n_dropped_specs,status("ran"/"infeasible"/"failed").- Return type:
- Raises:
ValueError – If the fit snapshot is unavailable (e.g. this result was unpickled), or an explicit
placebo_periodsentry is a post-treatment period / not a pre-period.
- get_in_time_placebo_df()[source]
Get the in-time placebo table (see
in_time_placebo()).Survives pickling. Raises if
in_time_placebo()has not been run.- Return type:
- get_in_time_placebo_gaps()[source]
Long-form in-time placebo gap paths, for the backdating overlay plot.
One row per (placebo date, period) for every converged in-time refit. Columns:
placebo_period,period,gap,phase("pre_fake"for periods before the placebo date,"post_fake"for the held-out window from it on). These per-period paths are panel-derived and are NOT retained after pickling.- Return type:
- Raises:
ValueError – If
in_time_placebo()has not been run, or if the gap paths were dropped on pickling (re-fit and re-run to recompute them).
- regression_weights()[source]
Regression-weight extrapolation diagnostic (ADH 2015 §4, journal pp. 498-499).
Computes the implied donor weights
W^reg = X0a'(X0a X0a')^{-1} X1aof the REGRESSION counterfactualB̂'X_1— the same predictor matrices the synthetic control matched on, augmented with an intercept row of ones. Because a constant is included,ι'W^reg = 1(under full row rank), so regression is ALSO a weighting estimator summing to one — but with UNRESTRICTED weights (can be negative or exceed 1), i.e. it extrapolates outside the donors’ convex hull. The simplex-constrained synthetic control cannot; comparing the two quantifies how much a regression counterfactual would have to extrapolate. (In ADH’s application regression assigned negative weights to Greece/Italy/Portugal/Spain.)Pure linear algebra — NO solver re-fit — leaving the analytical inference contract unchanged:
se/t_stat/p_value/conf_int/is_significantstay bound to the NaN analyticalp_value.- Returns:
One row per donor (all
Jdonors), sorted byabs_extrapolationdescending. Columns:donor_id,w_reg(implied regression weight),w_sc(the synthetic-control weight, 0 if below the reporting floor),extrapolates(bool:w_reg < 0orw_reg > 1),abs_extrapolation(max(0, -w_reg, w_reg - 1)— the distance outside[0, 1]).- Return type:
- Raises:
ValueError – If the fit snapshot is unavailable (e.g. this result was unpickled).
Notes
When the intercept-augmented predictor matrix is not full ROW rank (
k+1 > J— realistic with the default per-period outcome lags whenT0 > J— or collinear predictors), the reportedW^regis the MIN-NORM least-squares solution, aUserWarningis emitted, andself._regw_rank_deficientis set True; it is still an informative extrapolation witness, butΣ W^reg(self._regw_weight_sum) need not equal 1 in that case.
- get_regression_weights_df()[source]
Get the regression-weight extrapolation table (see
regression_weights()).Survives pickling. Raises if
regression_weights()has not been run.- Return type:
- sparse_synthetic_control(sizes=None, max_subsets=50000)[source]
Sparse synthetic-control subset search (ADH 2015 §4, journal pp. 506-507).
For each target size
l < J(the donor count), exhaustively searches ALLC(J, l)donor subsets — HOLDINGVFIXED at the baseline fit’s V (ADH hold V fixed to make the combinatorial search tractable, footnote 20) — refits the inner simplex weight solve on each subset, and reports the best-fitting size-lsynthetic (lowest pre-period outcome MSPE). This shows how the fit degrades and the ATT moves as the synthetic is forced to be sparse (ADH: reducing tol = 4, 3, 2degrades fit “moderately”,l = 1much worse — a single-match design close to DiD). A thin re-run of the validated inner solver: the analytical inference contract is unchanged (se/t_stat/p_value/conf_int/is_significantstay NaN).- Parameters:
sizes (int or sequence of int, optional) – Target sparsity size(s)
l. Default None sweeps[1, 2, 3](clipped tol < J). A DEFAULTED size whoseC(J, l)exceedsmax_subsetsis SKIPPED with a warning (a defaulted call never raises); an EXPLICITLY requestedlwithC(J, l) > max_subsetsraises ValueError instead. Each explicitlmust satisfy1 <= l <= J - 1.max_subsets (int, default 50000) – Guard on the exhaustive search. An explicitly requested size exceeding it raises ValueError with guidance (lower
l, curate the donor pool, or raise this cap).
- Returns:
A
status="baseline"row first (the full fit;size= the baseline support count,delta_att = 0), then onestatus="ran"row per searched size (or astatus="all_subsets_failed"row with NaN metrics if every subset of that size failed to converge). Columns:size,donor_ids(winning subset, a tuple),weights(dict),pre_rmspe,post_rmspe,rmspe_ratio,att,delta_att(att_sparse - full_att),n_subsets_evaluated,n_failed,status.- Return type:
- Raises:
ValueError – If the fit snapshot is unavailable (unpickled result); if
max_subsetsis not a positive integer; ifsizesis an empty sequence; or if an explicitly requested size is out of range or exceedsmax_subsets.
Notes
Pre-fit typically degrades as
lshrinks, but strict monotonicity is NOT guaranteed: subsets are ranked by the uniform-outcome pre-period MSPE while each subset’s weights are V-optimal on the predictor objective. The diagnostic’s signal is the degradation of fit and the movement of the ATT as you sparsify.
- get_sparse_synthetic_control_df()[source]
Get the sparse synthetic-control table (see
sparse_synthetic_control()).Survives pickling. Raises if
sparse_synthetic_control()has not been run.- Return type:
- get_sparse_synthetic_control_gaps()[source]
Long-form per-size sparse gap paths, for the overlay (“spaghetti”) plot.
One row per (size, period) for every searched size’s winning subset. Columns:
size,period,gap,phase("pre"/"post") — mirroringget_gap_df(). These per-period paths are panel-derived and are NOT retained after pickling.- Return type:
- Raises:
ValueError – If
sparse_synthetic_control()has not been run, or if the gap paths were dropped on pickling (re-fit and re-run to recompute them).
- test_sharp_null(effect, *, gamma=0.1, n_starts=None)[source]
Test a sharp null hypothesis on the treatment-effect path (Firpo & Possebom 2018, §4.1).
Tests
H_0^f: α_{1,t} = f(t)for every post period (Eq 11) by subtracting the hypothesized effect pathf(t)from the post-period gaps of EVERY unit and re-ranking the treated unit’s modified RMSPE ratio against the placebo distribution (Eqs 12–13 atφ = 0,v = (1,…,1)— the equal-weights benchmark). The synthetic controls are NOT refit: this reuses the gap paths and per-unit denominatorsin_space_placebo()already computed (run lazily here if needed). Ateffect = 0the p-value is identically the benchmarkplacebo_p_value(Eq 5 = Eq 13 withf ≡ 0).- Parameters:
effect (float or array-like) – The hypothesized post-period effect
f(t): a scalar (a constant-in-time effect, Eq 11), or a length-n_post_periodsarray aligned topost_periodsin calendar order (an arbitrary path — e.g. an intervention cost path or a theory-predicted shape).gamma (float, default 0.1) – Test level; the null is rejected when
p^f < gamma. The permutation p-value is granular in1/(J+1)(Firpo & Possebom fn 8), so not every nominal level is attainable.n_starts (int, optional) – Multistart count for the lazy
in_space_placebo()run; ignored (with a warning) if the reference set already exists.
- Returns:
p_value(p^f),reject(p^f < gamma),gamma,rmspe_f_treated(the treated unit’s modified RMSPE ratio),n_placebos(reference-set size),n_failed.- Return type:
- Raises:
ValueError – If
gammais not in(0, 1),effecthas the wrong shape / non-finite values, or no valid placebo reference set is available (seein_space_placebo()).
- confidence_set(*, family='constant', gamma=0.1, bounds=None, n_grid=200, n_starts=None)[source]
Confidence set for the treatment-effect path by test inversion (Firpo & Possebom 2018, §4.2).
Inverts the sharp-null test (
test_sharp_null()) over a one-parameter effect family: the confidence set is every parameter value whose sharp null is not rejected,{ param : p^param > gamma }(Eq 14, strict inequality). Two families are supported:family="constant"—f(t) = c(Eq 15); the set is a confidence interval for a constant-in-time effect (Eq 16). The parameter column isc.family="linear"—f(t) = c̃·(t − T0)with the 1-based post-period index(t − T0)(Eq 17); the set is a confidence set over the slopec̃(Eq 18). The parameter column isc_tilde.
The inversion is a pure re-ranking of the stored placebo gaps (no synthetic-control refits):
in_space_placebo()is run lazily if needed, then each value only recomputesp^param. Withbounds=Nonethe set is recovered exactly:p^paramis piecewise-constant (each placebo’s indicator flips only at the real roots of a quadratic inparam), so the placebo breakpoints partition the line,pis evaluated once per induced interval AND at each breakpoint (where a tie under≥can liftpabovegamma), and the union of accepted intervals/points is the set — with NO centering or monotonicity assumption (accepted tails and disjoint components are handled). With explicitboundsa fixedlinspace(*bounds, n_grid)grid is scanned instead (grid-limited membership).Boundary convention (paper-sourced, Eq 14): membership is the strict inequality
p^param > gamma. The permutation p-value is discrete (a multiple of1/(J+1)), sop = gammais reachable and is excluded from the set.The result is stored on the object: the summary on
effect_confidence_set({family, parameter, gamma, lower, upper, contiguous, boundary, point_estimate, n_grid, n_placebos, status}, surviving pickling) and the full grid onget_confidence_set_df(). The analyticalconf_int/sestay NaN — this is a separate permutation object.- Parameters:
family ({"constant", "linear"}, default "constant") – The one-parameter effect family to invert over.
gamma (float, default 0.1) – Confidence level is
1 − gamma;p^param > gammadefines membership.bounds ((float, float), optional) – Fixed
(lo, hi)grid for the parameter. Default None uses exact breakpoint inversion (a fixed grid is used only whenboundsis supplied).n_grid (int, default 200) – Number of grid points evaluated for the returned table (>= 2).
n_starts (int, optional) – Multistart count for the lazy
in_space_placebo()run; ignored (with a warning) if the reference set already exists.
- Returns:
Columns
param(cfor constant,c̃for linear),p_value(p^param),in_set(p^param > gamma). Empty for an"empty"set; an"unbounded"exact set with finite breakpoints still returns an inspection grid over a padded breakpoint range (seeeffect_confidence_setstatus).- Return type:
- Raises:
ValueError – If
familyis unknown,gammanot in(0, 1),n_grid < 2,boundsmalformed, or no valid placebo reference set is available.
- get_confidence_set_df()[source]
Get the test-inversion confidence-set grid table (see
confidence_set()).Columns:
param(cconstant /c̃linear),p_value(p^param),in_set(p^param > gamma). Survives pickling. Raises ifconfidence_set()has not been run.- Return type:
- conformal_test(effect, *, q=1, scheme='moving_block', n_iid=10000, seed=None)[source]
Joint sharp-null conformal test
H0: θ = effect(Chernozhukov-Wüthrich-Zhu 2021, §2.2).Imputes the counterfactual treated outcomes under the null (subtracts the hypothesized post-period effect path), fits the canonical CWZ constrained-LS synthetic-control proxy on all periods under that null (eqs 3-4 — simplex weights on raw outcomes, NO V-matrix; distinct from the headline ADH V-matrix weights, as the exactness theory requires a time-permutation-invariant proxy), and computes the permutation p-value (eq 2) of the statistic
S_q(û) = ((1/√T*)·Σ_{t>T0}|û_t|^q)^{1/q}by reshuffling residuals over time. The proxy is fit ONCE (footnote 7); only residuals are permuted.This is a SEPARATE permutation object from the analytical inference:
se/t_stat/p_value/conf_int/is_significantstay NaN.- Parameters:
effect (float or array-like) – The hypothesized post-period effect trajectory
θ0: a scalar (a constant-in-time effect) or a length-n_post_periodsarray aligned topost_periodsin calendar order.q ({1, 2, inf}, default 1) – The
S_qnorm order.1(robust to heavy tails — the paper’s application default),2(permanent effects),inf(=max|û_t|, large temporary effects).scheme ({"moving_block", "iid"}, default "moving_block") – The permutation set.
"moving_block"(Π_→,Tcyclic shifts) is valid under serially-dependent / stationary weakly-dependent errors (Assumption 2.2) — the robust default;"iid"(Π_all, sampled) is valid under i.i.d. errors (Assumption 2.1) and gives finer p-values.n_iid (int, default 10000) – Number of random permutations drawn for
scheme="iid"(ignored for moving-block, which is the exactT-element set). ExactT!enumeration is used whenT! <= n_iid.seed (int, optional) – RNG seed for
scheme="iid"sampling. Default uses the fit’s seed. Moving-block is deterministic.
- Returns:
p_value,S_observed,q,scheme,n_perms(|Π|),n_post,proxy_converged.- Return type:
- Raises:
ValueError – If
q/scheme/n_iidare invalid,effecthas the wrong shape / non-finite values, or the fit snapshot is unavailable.
- conformal_average_effect(*, alpha=0.1, scheme='moving_block', n_iid=10000, bounds=None, n_grid=200, seed=None)[source]
Confidence interval for the AVERAGE post-period effect (Chernozhukov-Wüthrich-Zhu 2021, Appendix A.1).
Tests
H0: T*^{-1}·Σ_{t>T0} θ_t = θ̄0by collapsing the panel into non-overlappingT*-blocks (each a per-unit block average), fitting the CWZ proxy on the collapsed series, and permuting the block residuals — theT/T*-block analog ofconformal_test()(a single post-block). The CI is everyθ̄0not rejected at levelalpha(test inversion). The earliestT0 mod T*pre-periods are dropped so the pre-block count is integral (the paper assumesT/T*integer).Because the effective sample is only
T/T*blocks, the moving-block permutation set has justT/T*elements (p-value granularityT*/T); passscheme="iid"for a finer set ((T/T*)!block permutations) when the block count is small. Analyticalse/conf_intstay NaN.- Parameters:
alpha (float, default 0.1) – The confidence level is
1 − alpha; membership isp^θ̄0 > alpha.scheme ({"moving_block", "iid"}, default "moving_block") – Permutation set over the collapsed blocks.
n_iid (int, default 10000) – Random block-permutation draws for
scheme="iid"(exact(T/T*)!enumeration when it fits).bounds ((float, float), optional) – Fixed
(lo, hi)grid forθ̄0. Default None auto-centres the grid on the average-effect point estimate (membership outside the grid is not certified — flagged viastatus="grid_limited").n_grid (int, default 200) – Number of grid points (>= 2).
seed (int, optional) – RNG seed for
scheme="iid". Default uses the fit’s seed.
- Returns:
lower,upper,point_estimate(the average-effect estimate),status("ran"/"grid_limited"/"empty"/"unbounded"),contiguous,n_perms,n_blocks,n_dropped_pre,n_grid_nonconverged.- Return type:
- Raises:
ValueError – If
alpha/scheme/n_iid/n_grid/boundsare invalid,T0 < T*(no full pre-block), or the fit snapshot is unavailable.
- get_conformal_grid_df()[source]
Get the conformal test-inversion grid table (see
conformal_average_effect()/conformal_confidence_intervals()).Columns:
param(the grid value),p_value(p^param),in_set(= not (converged and p_value <= alpha)— a non-converged grid point is indeterminate and stays in the set, soin_setcan beTrueeven when the displayedp_valueis not> alpha), andconverged(the proxy Frank-Wolfe convergence flag for that grid point). For pointwise CIs the table is the concatenation across post periods (with aperiodcolumn). A granularity-unboundedinterval (alpha < 1/|Π|) short-circuits and returns an EMPTY grid. Survives pickling. Raises if no conformal inversion has been run.- Return type:
- conformal_confidence_intervals(*, alpha=0.1, scheme='moving_block', n_iid=10000, bounds=None, n_grid=100, seed=None)[source]
Pointwise per-period conformal confidence intervals (Chernozhukov-Wüthrich-Zhu 2021, Algorithm 1).
For each post period
t, inverts a conformal test ofH0: θ_t = cover a grid ofc. Per the paper (§2.2), each per-period test uses the dataZ = (Z_1, …, Z_{T0}, Z_t)— theT0pre-periods PLUS only periodt, with the other post-periods dropped — so it is a clean single-post-period (T*=1) conformal test on the(T0+1)-length sub-series: imputeY_{1t} − c, refit the CWZ proxy on the sub-series, permute the residuals, and keepciffp^c > alpha. (BecauseT*=1here, theS_qorderqis inert —S_q = |û_t|for everyq— so it is not a parameter.) The analyticalconf_intstays(NaN, NaN)— this is a separate permutation object.- Parameters:
alpha (float, default 0.1) – The confidence level is
1 − alpha; membership isp^c > alpha.scheme ({"moving_block", "iid"}, default "moving_block") – Permutation set over the
(T0+1)-length sub-series.n_iid (int, default 10000) – Random permutation draws for
scheme="iid".bounds ((float, float), optional) – A single fixed
(lo, hi)grid applied to EVERY period. Default None auto-centres a per-period grid on that period’s point estimate (membership outside the grid is not certified — flaggedstatus="grid_limited").n_grid (int, default 100) – Grid points per period (>= 2).
seed (int, optional) – RNG seed for
scheme="iid". Default uses the fit’s seed.
- Returns:
One row per post period:
period,lower,upper,point_estimate,status("ran"/"grid_limited"/"empty"/"unbounded"),contiguous,n_grid_in_set,n_grid_nonconverged. The full per-period inversion grid is onget_conformal_grid_df().- Return type:
- Raises:
ValueError – If
alpha/scheme/n_iid/n_grid/boundsare invalid or the fit snapshot is unavailable.
- get_conformal_ci_df()[source]
Get the pointwise per-period conformal CI table (see
conformal_confidence_intervals()).One row per post period:
period,lower,upper,point_estimate,status,contiguous,n_grid_in_set,n_grid_nonconverged. Survives pickling. Raises ifconformal_confidence_intervals()has not been run.- Return type:
- __init__(att, se, t_stat, p_value, conf_int, n_obs, n_donors, n_pre_periods, n_post_periods, donor_weights, v_weights, predictor_balance, gap_path, pre_rmspe, treated_unit, pre_periods, post_periods, v_method, standardize, alpha=0.05, mspe_v=None, v_cv_t0=None, survey_metadata=None, placebo_p_value=nan, rmspe_ratio=nan, n_placebos=0, n_failed=0, n_infeasible=0, effect_confidence_set=None)
- Parameters:
att (float)
se (float)
t_stat (float)
p_value (float)
n_obs (int)
n_donors (int)
n_pre_periods (int)
n_post_periods (int)
predictor_balance (DataFrame)
pre_rmspe (float)
treated_unit (Any)
v_method (str)
standardize (str)
alpha (float)
mspe_v (float | None)
v_cv_t0 (int | None)
survey_metadata (Any | None)
placebo_p_value (float)
rmspe_ratio (float)
n_placebos (int)
n_failed (int)
n_infeasible (int)
- Return type:
None
Convenience Function#
- diff_diff.synthetic_control(data, outcome, treatment, unit, time, **kwargs)[source]#
Convenience function for classic synthetic control estimation.
Constructor-only keyword arguments (
v_method—"nested"/"custom"/"cv"/"inverse_variance"—custom_v,v_cv_t0,n_starts,standardize,alpha,seed,optimizer_options,inner_max_iter,inner_min_decrease) andfitkeyword arguments (post_periods,treated_unit,predictors,special_predictors, …) may both be passed via**kwargs.Examples
>>> from diff_diff import synthetic_control >>> res = synthetic_control(data, "y", "treated", "unit", "year", ... predictors=["x1", "x2"]) >>> print(f"ATT: {res.att:.3f}, pre-RMSPE: {res.pre_rmspe:.3f}")
Predictors and V selection#
Predictor rows of X1 (treated) / X0 (donors) are built, in this canonical row
order (the ordering matches R Synth::dataprep), from:
Argument |
Meaning |
|---|---|
|
Columns averaged over a pre-period window (default: all pre periods). |
|
|
|
Individual pre-period outcomes as predictor rows ( |
v_method="nested" selects the diagonal predictor-importance matrix V by minimizing
the pre-period outcome MSPE of W*(V) over a multistart Nelder-Mead search with a
derivative-free Powell polish. v_method="cv" selects V by out-of-sample
cross-validation (Abadie-Diamond-Hainmueller 2015; Abadie 2021): the pre-period is split
at v_cv_t0 (default len(pre)//2, i.e. t0 = T0/2) into a training and a validation
window; V is chosen to minimize the validation-window outcome MSPE of the training-fit
weights, then the final weights are re-estimated on the validation-window predictors. Each
predictor is re-aggregated over each window (a separate dataprep per window, as
ADH 2015’s CV does), so it must span both windows — the default per-period outcome lags
(single-period) are rejected; pass spanning covariate / multi-period special_predictors
(see docs/methodology/REGISTRY.md §SyntheticControl).
v_method="inverse_variance" uses the closed-form v_h = 1/Var(X_h) (variance over
donors+treated; no search), applied to the raw predictors — it intentionally bypasses
standardize (inverse-variance weighting is the unit-variance rescaling). v_method="custom" takes a user-supplied custom_v
(one entry per predictor row, trace-normalized) and skips the outer search. v_cv_t0
must be None unless v_method="cv".
Note
The predictor standardization (per-row SD over donors+treated, ddof=1) and the
optimizer are pinned from the R Synth source — they are not specified in
Abadie-Diamond-Hainmueller (2010). The outer objective uses all pre periods rather than
R’s time.optimize.ssr window, so the nested V differs from R by an
efficiency-only choice. Predictor/outcome aggregation also fails closed on any
non-finite cell, whereas R dataprep uses na.rm=TRUE — restrict
predictor_window / special_predictors periods to where a variable is observed.
Predictor rows support only equal-weight linear combinations (mean, sum,
per-period lags); ADH (2010) §2.3’s general weighted form Σ_s k_s Y_is with
arbitrary k_s (and non-linear ops such as median) is not accepted in this
release. See docs/methodology/REGISTRY.md §SyntheticControl for all deviation labels.
Example Usage#
Basic usage with covariate and special predictors:
from diff_diff import SyntheticControl
scm = SyntheticControl(v_method="nested", seed=0)
results = scm.fit(
data,
outcome="gdpcap",
treatment="treated", # absorbing 0/1 indicator
unit="region",
time="year",
predictors=["invest", "school.high"],
# Set predictor_window explicitly when a covariate is only observed on a
# subset of the pre periods — the default averages over ALL pre periods and
# fails closed if any selected cell is non-finite.
predictor_window=[1964, 1965, 1966, 1967, 1968, 1969],
special_predictors=[("gdpcap", [1960, 1965, 1969], "mean")],
)
results.print_summary()
# Effect path and donor weights
gap_df = results.get_gap_df() # period, gap, phase
weights_df = results.get_weights_df() # unit, weight (descending)
Quick estimation with the convenience function:
from diff_diff import synthetic_control
results = synthetic_control(
data, outcome="gdpcap", treatment="treated",
unit="region", time="year",
)
print(f"ATT: {results.att:.3f}, pre-RMSPE: {results.pre_rmspe:.3f}")
In-space placebo permutation inference (opt-in; refits one synthetic control per donor):
placebo_df = results.in_space_placebo() # reassigns treatment to each donor
print(f"placebo p-value: {results.placebo_p_value:.3f} "
f"(n_placebos={results.n_placebos})") # p = rank/(n_placebos+1)
print(placebo_df) # per-unit RMSPE-ratio table used for the permutation rank
Supplying a fixed predictor-importance matrix (skips the outer V search):
import numpy as np
scm = SyntheticControl(v_method="custom", custom_v=np.ones(n_predictors))
results = scm.fit(data, outcome="gdpcap", treatment="treated",
unit="region", time="year", predictors=["invest"])
Comparison with Synthetic DiD#
Feature |
SyntheticControl |
SyntheticDiD |
|---|---|---|
Unit (donor) weights |
Simplex, predictor-importance weighted |
Simplex, ridge-regularized |
Time weights |
None (level matching) |
Simplex (double difference) |
Predictor-importance |
Nested / cv / inverse-variance / custom diagonal |
No analog |
Inference |
Placebo permutation (no analytical SE) |
Bootstrap / jackknife / placebo |
Use SCM for a single treated unit with a long pre-period and a curated donor pool; use SDID when you have several treated units and parallel trends is plausible.