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: object

Classic 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 of W*(V) (ADH 2010 §2.3). "custom" uses the user-supplied custom_v and skips the outer search. "cv" selects V by out-of-sample cross-validation (ADH 2015 §; Abadie 2021 Eq. 9): the pre-period is split at v_cv_t0 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. "inverse_variance" uses the closed-form v_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 what standardize="std" does, so the standardize setting does not compose with it (equivalent to uniform V on standardized predictors); applying 1/Var on 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 other v_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.minimize call 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))**2 where b is the V^½-scaled treated predictor vector — scale-aware so convergence is meaningful at any data magnitude. 1e-5 reproduces R Synth’s donor weights to ~1e-4 on the Basque benchmark while still signalling convergence; tighter values (e.g. 1e-6) can exhaust inner_max_iter.

  • standardize ({"std", "none"}, default "std") – Predictor standardization. "std" divides each predictor row by its standard deviation across donors+treated (ddof=1), matching R Synth. "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’s t0 = T0/2). Must be None unless v_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]
Parameters:
  • v_method (str)

  • custom_v (Any | None)

  • optimizer_options (Dict[str, Any] | None)

  • n_starts (int)

  • inner_max_iter (int)

  • inner_min_decrease (float)

  • standardize (str)

  • alpha (float)

  • seed (int | None)

  • v_cv_t0 (int | None)

get_params()[source]

Get estimator parameters.

Return type:

Dict[str, Any]

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 raised ValueError leaves the object consistent.

Return type:

SyntheticControl

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. treatment is the ABSORBING treatment indicator (0/1): 1 for the treated unit in its treated periods, 0 otherwise.

  • treatment (str) – Column names. treatment is the ABSORBING treatment indicator (0/1): 1 for the treated unit in its treated periods, 0 otherwise.

  • unit (str) – Column names. treatment is the ABSORBING treatment indicator (0/1): 1 for the treated unit in its treated periods, 0 otherwise.

  • time (str) – Column names. treatment is 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 (using predictors_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 predictors are 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 Synth special.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 NotImplementedError if provided.

Return type:

SyntheticControlResults

SyntheticControlResults#

Results container for synthetic control estimation.

class diff_diff.SyntheticControlResults[source]

Bases: object

Results 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_jt of 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_jt over the post periods.

att

Average post-period gap (the reported point estimate). The per-period gaps are in gap_path.

Type:

float

se

Always NaN — classic SCM has no analytical standard error (inference is permutation/placebo based; see Abadie-Diamond-Hainmueller 2010 §2.4).

Type:

float

t_stat, p_value

Always NaN (no analytical SE).

Type:

float

conf_int

Always (NaN, NaN) (no analytical SE).

Type:

tuple[float, float]

n_obs

Number of observations (treated + donor rows over all periods) used.

Type:

int

n_donors

Number of donor units in the (post-filter) donor pool.

Type:

int

n_pre_periods

Number of pre-treatment periods.

Type:

int

n_post_periods

Number of post-treatment periods.

Type:

int

donor_weights

Mapping {donor_unit_id: weight} on the unit simplex. Weights below the interpretability floor (1e-6) are dropped.

Type:

dict

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 forces w=[1]) V is unidentified — every V yields the same synthetic — so v_weights is uniform for every v_method (including cv / inverse_variance), with a UserWarning emitted at fit time.

Type:

dict

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 reported donor_weights come from the ADH-2015 step-4 refit on the validation-window re-aggregated predictors, so the treated / synthetic / donor_mean values are reported on that same validation-window basis (each spec re-aggregated over pre[v_cv_t0:]) — the row’s predictor label remains the full spec identity, so it stays aligned with v_weights. For every other v_method the values are the full-pre-period predictor aggregates.

Type:

pandas.DataFrame

gap_path

Mapping {period: gap} for ALL periods (pre periods carry the fit residual used for pre_rmspe; post periods carry the effect path).

Type:

dict

pre_rmspe

Root mean squared prediction error over the pre-treatment periods (the primary fit diagnostic).

Type:

float

mspe_v

The outer-objective value of the selected V: the pre-period outcome MSPE of W*(V*) under v_method="nested", or the held-out validation-window outcome MSPE under v_method="cv" (the CV selection criterion). None when there is no outer search — the v_method="custom" and "inverse_variance" paths and the degenerate single-donor path. Not comparable across v_method values (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:

list

v_method

"nested" (data-driven V), "custom" (user-supplied V), "cv" (out-of-sample cross-validation V), or "inverse_variance" (closed-form 1/Var(X) V).

Type:

str

v_cv_t0

The training/validation split index actually used under v_method="cv" (the resolved value — equals n_pre_periods // 2 when the constructor’s v_cv_t0 was None). None for every other v_method. Survives pickling.

Type:

int, optional

standardize

"std" (per-row SD scaling) or "none".

Type:

str

alpha

Significance level recorded for downstream (placebo) inference.

Type:

float

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:

float

placebo_p_value

In-space placebo permutation p-value (rank / (n_placebos + 1)), NaN until in_space_placebo() is run. SEPARATE from the (always-NaN) analytical p_value; is_significant stays bound to p_value.

Type:

float

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 until in_space_placebo() is run. n_infeasible mirrors the split in_time_placebo() already reports; the permutation placebo_p_value uses only the n_placebos that entered the rank, so it is unaffected by how the excluded remainder is attributed.

Type:

int

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
conf_int: Tuple[float, float]
n_obs: int
n_donors: int
n_pre_periods: int
n_post_periods: int
donor_weights: Dict[Any, float]
v_weights: Dict[str, float]
predictor_balance: DataFrame
gap_path: Dict[Any, float]
pre_rmspe: float
treated_unit: Any
pre_periods: List[Any]
post_periods: List[Any]
v_method: str
standardize: str
alpha: float = 0.05
mspe_v: float | None = None
v_cv_t0: int | None = None
survey_metadata: Any | None = None
placebo_p_value: float = nan
rmspe_ratio: float = nan
n_placebos: int = 0
n_failed: int = 0
n_infeasible: int = 0
effect_confidence_set: Dict[str, Any] | None = None
__getstate__()[source]

Exclude panel-derived internal state from pickling.

_fit_snapshot retains the full treated+donor panel and _placebo_gaps the 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_df aggregate 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. Mirrors SyntheticDiDResults.

Return type:

Dict[str, Any]

__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 (before n_infeasible / _loo_n_infeasible existed) would otherwise leave those attributes unset and make summary() / to_dict() / DiagnosticReport raise AttributeError. Default any missing counter to 0 (the “no infeasible refits recorded” state) so a legacy result reports cleanly.

Parameters:

state (Dict[str, Any])

Return type:

None

__repr__()[source]

Concise string representation.

Return type:

str

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.

Parameters:

alpha (float, optional) – Significance level; defaults to the alpha used during estimation.

Returns:

Formatted summary table.

Return type:

str

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_*_df accessors).

Return type:

Dict[str, Any]

to_dataframe()[source]

Convert scalar results to a single-row pandas DataFrame.

Return type:

DataFrame

get_gap_df()[source]

Get the gap (effect) path as a DataFrame, in calendar order.

Rebuilt period-keyed from gap_path using the canonical pre_periods + post_periods order so the row order is independent of any dict-insertion order. Columns: period, gap, phase.

Return type:

pandas.DataFrame

get_weights_df()[source]

Get donor weights as a DataFrame, sorted by weight descending.

Returns:

Columns: unit, weight.

Return type:

pandas.DataFrame

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_gaps for 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 single is_treated=True, status="treated" row (its ratio is the original J-donor fit). After a placebo run that produced a reference set (>= 2 donors AND a converged treated fit), the table has n_donors + 1 rows — 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:

pandas.DataFrame

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_failed and n_infeasible on this object (rmspe_ratio — the treated unit’s own ratio — is set at fit time) and returns the placebo distribution via get_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+1 units {treated} {J placebos}, with each placebo fit against the other J-1 donors (this matches the standard SCtools::generate.placebos construction). 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_value is intentionally distinct from p_value (which stays NaN — classic SCM has no analytical SE) and from is_significant (which also stays bound to the NaN p_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 truncated W is unusable) OR an outer V search that did not converge (an under-optimized V fits the pre-period worse, shrinking its RMSPE ratio and biasing the permutation p-value anti-conservatively). A structural cv infeasibility (counted in n_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, so placebo_p_value is unaffected by the attribution. Each placebo refit inherits the original fit’s ``optimizer_options`` / ``n_starts``, so valid inference requires settings adequate for the outer V search to converge: production defaults do; with cheap settings, raise n_starts here or re-fit with a larger optimizer_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:

pandas.DataFrame

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_int and is_significant are unaffected (still bound to the NaN analytical p_value).

The drop set is exactly the donors in donor_weights — those above the 1e-6 interpretability floor (synthetic_control._MIN_REPORT_WEIGHT). A donor with negligible weight 0 < w 1e-6 is excluded (its removal moves the ATT by ~the weight, so its delta_att would 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 large delta_att is 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" (under v_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:

pandas.DataFrame

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:

pandas.DataFrame

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") — mirroring get_gap_df(). These per-period paths are panel-derived and are NOT retained after pickling.

Return type:

pandas.DataFrame

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_f and re-fits the synthetic control using ONLY pre-t_f information, 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_int and is_significant are unaffected.

Windowing convention (TRUNCATE). The placebo fit uses only periods strictly before t_f: pre-period-outcome predictors become the pre-t_f outcomes, and covariate / special predictor windows are intersected with the pre-t_f window. A predictor window lying ENTIRELY in the held-out region [t_f, T0) is dropped (surfaced in n_dropped_specs + an aggregated warning). For outcome-predictor fits this equals the literal “lag the predictors” re-run of a manual Synth::synth (R has no in-time-placebo function); see docs/methodology/REGISTRY.md for 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, raises ValueError; a valid pre-date that is dimensionally infeasible (too few pre-fake periods, or all predictors dropped) yields a status="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:

pandas.DataFrame

Raises:

ValueError – If the fit snapshot is unavailable (e.g. this result was unpickled), or an explicit placebo_periods entry 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:

pandas.DataFrame

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:

pandas.DataFrame

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} X1a of the REGRESSION counterfactual B̂'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_significant stay bound to the NaN analytical p_value.

Returns:

One row per donor (all J donors), sorted by abs_extrapolation descending. Columns: donor_id, w_reg (implied regression weight), w_sc (the synthetic-control weight, 0 if below the reporting floor), extrapolates (bool: w_reg < 0 or w_reg > 1), abs_extrapolation (max(0, -w_reg, w_reg - 1) — the distance outside [0, 1]).

Return type:

pandas.DataFrame

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 when T0 > J — or collinear predictors), the reported W^reg is the MIN-NORM least-squares solution, a UserWarning is emitted, and self._regw_rank_deficient is 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:

pandas.DataFrame

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 ALL C(J, l) donor subsets — HOLDING V FIXED 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-l synthetic (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 to l = 4, 3, 2 degrades fit “moderately”, l = 1 much 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_significant stay NaN).

Parameters:
  • sizes (int or sequence of int, optional) – Target sparsity size(s) l. Default None sweeps [1, 2, 3] (clipped to l < J). A DEFAULTED size whose C(J, l) exceeds max_subsets is SKIPPED with a warning (a defaulted call never raises); an EXPLICITLY requested l with C(J, l) > max_subsets raises ValueError instead. Each explicit l must satisfy 1 <= 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 one status="ran" row per searched size (or a status="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:

pandas.DataFrame

Raises:

ValueError – If the fit snapshot is unavailable (unpickled result); if max_subsets is not a positive integer; if sizes is an empty sequence; or if an explicitly requested size is out of range or exceeds max_subsets.

Notes

Pre-fit typically degrades as l shrinks, 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:

pandas.DataFrame

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") — mirroring get_gap_df(). These per-period paths are panel-derived and are NOT retained after pickling.

Return type:

pandas.DataFrame

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 path f(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 denominators in_space_placebo() already computed (run lazily here if needed). At effect = 0 the p-value is identically the benchmark placebo_p_value (Eq 5 = Eq 13 with f 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_periods array aligned to post_periods in 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 in 1/(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:

pandas.Series

Raises:

ValueError – If gamma is not in (0, 1), effect has the wrong shape / non-finite values, or no valid placebo reference set is available (see in_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 is c.

  • 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 slope (Eq 18). The parameter column is c_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 recomputes p^param. With bounds=None the set is recovered exactly: p^param is piecewise-constant (each placebo’s indicator flips only at the real roots of a quadratic in param), so the placebo breakpoints partition the line, p is evaluated once per induced interval AND at each breakpoint (where a tie under can lift p above gamma), and the union of accepted intervals/points is the set — with NO centering or monotonicity assumption (accepted tails and disjoint components are handled). With explicit bounds a fixed linspace(*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 of 1/(J+1)), so p = gamma is 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 on get_confidence_set_df(). The analytical conf_int / se stay 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 > gamma defines membership.

  • bounds ((float, float), optional) – Fixed (lo, hi) grid for the parameter. Default None uses exact breakpoint inversion (a fixed grid is used only when bounds is 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 (c for constant, 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 (see effect_confidence_set status).

Return type:

pandas.DataFrame

Raises:

ValueError – If family is unknown, gamma not in (0, 1), n_grid < 2, bounds malformed, 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 (c constant / linear), p_value (p^param), in_set (p^param > gamma). Survives pickling. Raises if confidence_set() has not been run.

Return type:

pandas.DataFrame

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_significant stay 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_periods array aligned to post_periods in calendar order.

  • q ({1, 2, inf}, default 1) – The S_q norm 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" (Π_→, T cyclic 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 exact T-element set). Exact T! enumeration is used when T! <= 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:

pandas.Series

Raises:

ValueError – If q / scheme / n_iid are invalid, effect has 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 = θ̄0 by collapsing the panel into non-overlapping T*-blocks (each a per-unit block average), fitting the CWZ proxy on the collapsed series, and permuting the block residuals — the T/T*-block analog of conformal_test() (a single post-block). The CI is every θ̄0 not rejected at level alpha (test inversion). The earliest T0 mod T* pre-periods are dropped so the pre-block count is integral (the paper assumes T/T* integer).

Because the effective sample is only T/T* blocks, the moving-block permutation set has just T/T* elements (p-value granularity T*/T); pass scheme="iid" for a finer set ((T/T*)! block permutations) when the block count is small. Analytical se / conf_int stay NaN.

Parameters:
  • alpha (float, default 0.1) – The confidence level is 1 alpha; membership is p^θ̄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 via status="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:

pandas.Series

Raises:

ValueError – If alpha / scheme / n_iid / n_grid / bounds are 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, so in_set can be True even when the displayed p_value is not > alpha), and converged (the proxy Frank-Wolfe convergence flag for that grid point). For pointwise CIs the table is the concatenation across post periods (with a period column). A granularity-unbounded interval (alpha < 1/|Π|) short-circuits and returns an EMPTY grid. Survives pickling. Raises if no conformal inversion has been run.

Return type:

pandas.DataFrame

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 of H0: θ_t = c over a grid of c. Per the paper (§2.2), each per-period test uses the data Z = (Z_1, …, Z_{T0}, Z_t) — the T0 pre-periods PLUS only period t, 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: impute Y_{1t} c, refit the CWZ proxy on the sub-series, permute the residuals, and keep c iff p^c > alpha. (Because T*=1 here, the S_q order q is inert — S_q = |û_t| for every q — so it is not a parameter.) The analytical conf_int stays (NaN, NaN) — this is a separate permutation object.

Parameters:
  • alpha (float, default 0.1) – The confidence level is 1 alpha; membership is p^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 — flagged status="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 on get_conformal_grid_df().

Return type:

pandas.DataFrame

Raises:

ValueError – If alpha / scheme / n_iid / n_grid / bounds are 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 if conformal_confidence_intervals() has not been run.

Return type:

pandas.DataFrame

__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:
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) and fit keyword 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}")
Parameters:
Return type:

SyntheticControlResults

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

predictors + predictor_window + predictors_op

Columns averaged over a pre-period window (default: all pre periods).

special_predictors

(var, periods, op) triples, each averaged over its own periods/operator.

pre_period_outcomes

Individual pre-period outcomes as predictor rows ("all" or a list). When no predictor arguments are given, defaults to all pre-period outcomes.

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 V

Nested / cv / inverse-variance / custom diagonal V

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.