statspai.synth¶
synth ¶
Synthetic Control module for StatsPAI.
Unified entry point: synth(method=...) dispatches to all variants.
Variants (20 methods)
- classic — Abadie, Diamond & Hainmueller (2010)
- penalized / ridge — Ridge-penalised SCM
- demeaned / detrended — Ferman & Pinto (2021)
- unconstrained / elastic_net — Doudchenko & Imbens (2016)
- augmented / ascm — Ben-Michael, Feller & Rothstein (2021)
- sdid — Arkhangelsky, Athey, Hirshberg, Imbens & Wager (2021)
- factor / gsynth — Xu (2017)
- fect — Liu, Wang & Xu (2024) counterfactual estimators (fe / ife / mc) for staggered, multi-unit panels
- staggered — Ben-Michael, Feller & Rothstein (2022)
- mc / matrix_completion — Athey, Bayati et al. (2021)
- discos / distributional — Gunsilius (2023)
- multi_outcome — Sun (2023)
- scpi / prediction_interval — Cattaneo, Feng & Titiunik (2021)
- bayesian — Bayesian SCM with MCMC posterior (Vives & Martinez 2024)
- bsts / causal_impact — Bayesian Structural Time Series (Brodersen et al. 2015)
- penscm / abadie_lhour — Penalized SCM with pairwise discrepancy (Abadie & L'Hour 2021)
- fdid / forward_did — Forward DID with optimal donor selection (Li 2024)
- rcm / hcw — regression control method (Hsiao, Ching & Wan 2012)
- cluster — Cluster SCM with donor grouping (Rho et al. 2025, arXiv:2503.21629) [@rho2025clustersc]
- sparse / lasso — Sparse SCM with L1 penalties (Amjad, Shah & Shen 2018)
- kernel / kernel_ridge — Kernel-based nonlinear SCM
Inference
- placebo — in-space permutation (default)
- conformal — Chernozhukov, Wüthrich & Zhu (2021)
- bootstrap / jackknife — for SDID
- prediction intervals — Cattaneo et al. (2021)
- bayesian posterior — full posterior credible intervals (Bayesian SCM)
- bsts posterior — Bayesian structural time series uncertainty
Diagnostics
- synth_sensitivity() — comprehensive robustness suite
- synth_loo() — leave-one-out donor analysis
- synth_time_placebo() — backdating tests
- synth_donor_sensitivity() — donor pool variation
- synth_rmspe_filter() — pre-RMSPE robustness
SynthComparison ¶
Structured container for multi-method SCM comparison results.
Attributes:
| Name | Type | Description |
|---|---|---|
results |
dict
|
Mapping of method name to |
comparison_table |
DataFrame
|
Side-by-side metrics for every successful method, sorted by
|
recommended |
str
|
Name of the recommended method. |
recommendation_reason |
str
|
Human-readable justification. |
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> comp = sp.synth_compare(
... df, outcome='packspercapita', unit='state', time='year',
... treated_unit='California', treatment_time=1989,
... methods=['classic', 'demeaned'], placebo=False,
... )
>>> isinstance(comp, sp.SynthComparison)
True
>>> bool(comp.recommended in comp.results)
True
>>> sorted(comp.comparison_table['method'])
['classic', 'demeaned']
summary ¶
Return a formatted multi-line summary string.
Returns:
| Type | Description |
|---|---|
str
|
|
plot ¶
Overlay all results using synthplot(type='compare').
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
**kwargs
|
Any
|
Forwarded to |
{}
|
Returns:
| Type | Description |
|---|---|
matplotlib Figure or Axes
|
|
to_latex ¶
Render the comparison as a LaTeX table.
Forwards to :func:statspai.synth.exports.synth_to_latex with
the side-by-side multi-method layout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
**kwargs
|
Any
|
See :func: |
{}
|
Returns:
| Type | Description |
|---|---|
str
|
|
to_markdown ¶
Render the comparison as a Markdown table.
Forwards to :func:statspai.synth.exports.synth_to_markdown.
to_excel ¶
Write a multi-sheet Excel workbook covering all methods.
Forwards to :func:statspai.synth.exports.synth_to_excel.
Returns the absolute path of the file written.
SynthExperimentalDesignResult
dataclass
¶
Bases: ResultProtocolMixin
Structured output of :func:synth_experimental_design.
Attributes:
| Name | Type | Description |
|---|---|---|
selected |
list of unit ids
|
The |
ranking |
DataFrame
|
All candidates with columns
|
weights |
dict[unit_id, ndarray]
|
Leave-one-out SC weight vectors (aligned to |
donor_units |
list
|
The donor pool that each candidate was matched against (candidates excluded from each other's donor pool by default). |
expected_variance |
float
|
Sum of pre-period MSPEs over |
baseline_variance |
float
|
Same quantity for a random- |
method |
str
|
|
diagnostics |
dict
|
Extra metadata (n_units, pre_periods, solver, etc.). |
Notes
Under criterion='population' the fields read differently:
ranking has one row per unit with its role and weight,
weights holds the two vectors 'treated' and 'control'
(aligned to donor_units, here all units), expected_variance is
the sum of the two pre-period MSPEs of the chosen set and
baseline_variance its mean over the sets searched.
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> df = sp.utils.dgp_synth(n_units=40, n_periods=20, seed=0)
>>> res = sp.synth_experimental_design(
... df, unit="unit", time="time", outcome="y",
... k=5, pre_period=(0, 19), random_state=0,
... )
>>> len(res.selected)
5
SyntheticControl ¶
Canonical Synthetic Control estimator (Abadie, Diamond & Hainmueller 2010) with nested V-W optimization.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel |
required |
outcome
|
str
|
Column names. |
required |
unit
|
str
|
Column names. |
required |
time
|
str
|
Column names. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
covariates
|
list of str
|
Column names whose pre-treatment means are used as predictors for the V-weighted matching problem. |
None
|
special_predictors
|
list of tuple
|
R/Stata |
None
|
v_method
|
(auto, nested, equal, regression)
|
|
'auto'
|
standardize_predictors
|
bool
|
Rescale each predictor to unit range before the V optimization.
.. versionchanged:: 1.39.0
⚠️ The default used to be |
None
|
n_random_starts
|
int
|
Additional random Dirichlet starts for the outer V optimiser. |
4
|
penalization
|
float
|
Ridge penalty on donor weights. |
0.0
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
n_jobs
|
int
|
Worker processes for the in-space placebo loop; |
1
|
perfect_fit
|
(legacy, exact_balance)
|
Rule for the nested V-W fit when the treated unit's predictors lie
inside the convex hull of the donors' predictors, which is checked
exactly by linear programming and reported as
|
'legacy'
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> sc = sp.SyntheticControl(
... df, outcome='packspercapita', unit='state', time='year',
... treated_unit='California', treatment_time=1989,
... )
>>> res = sc.fit(placebo=False)
>>> res.method
'Synthetic Control Method'
>>> bool(res.estimate < 0) # cigarette sales fell after Prop 99
True
fit ¶
Fit the Synthetic Control model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
placebo
|
bool
|
Run in-space placebo tests across all donor units. |
True
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
SequentialSDIDResult
dataclass
¶
Bases: ResultProtocolMixin
Per-cohort and aggregated output of :func:sequential_sdid.
Dataclass container bundling the aggregate ATT / SE / CI with a
per_cohort table of cohort-specific ATT(g). Exposes a formatted
:meth:summary.
Examples:
>>> import pandas as pd
>>> import statspai as sp
>>> per_cohort = pd.DataFrame(
... {"cohort": [5, 8], "att": [2.10, 1.90], "se": [0.30, 0.40]}
... )
>>> res = sp.SequentialSDIDResult(
... aggregate_att=2.0, aggregate_se=0.25,
... aggregate_ci=(1.51, 2.49), per_cohort=per_cohort,
... )
>>> len(res.per_cohort)
2
>>> summary_text = res.summary()
SyntheticSurvivalResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:synth_survival.
Exposes the fitted counterfactual survival curve (s_synth), the
observed treated curve (s_treated), their gap trajectory (gap),
the donor weights, and a pointwise placebo band.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> months = np.arange(0, 13)
>>> rows = []
>>> for arm in ['treated_arm', 'ctrl_1', 'ctrl_2', 'ctrl_3']:
... bump = 0.03 if arm == 'treated_arm' else 0.0
... for m in months:
... haz = 0.06 - bump * (m >= 6)
... rows.append({'arm': arm, 'month': m,
... 'km': float(np.exp(-haz * m)),
... 'is_treated': arm == 'treated_arm'})
>>> panel = pd.DataFrame(rows)
>>> r = sp.synth_survival(
... panel, unit='arm', time='month', survival='km',
... treated='is_treated', treat_time=6, n_placebos=20, seed=0,
... )
>>> isinstance(r, sp.SyntheticSurvivalResult)
True
>>> r.treated_unit
'treated_arm'
>>> int(len(r.time_grid))
13
bayesian_synth ¶
bayesian_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, covariates: Optional[List[str]] = None, n_iter: int = 2000, n_warmup: int = 1000, n_chains: int = 2, dirichlet_alpha: float = 1.0, seed: Optional[int] = None, alpha: float = 0.05) -> CausalResult
Bayesian Synthetic Control Method.
Estimates the ATT by placing a Dirichlet prior on donor weights and sampling from the posterior via Metropolis-Hastings MCMC. Returns full posterior credible intervals for the treatment effect.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel data in long format with columns for unit, time, and outcome. |
required |
outcome
|
str
|
Name of the outcome variable column. |
required |
unit
|
str
|
Name of the unit identifier column. |
required |
time
|
str
|
Name of the time period column. |
required |
treated_unit
|
scalar
|
Value in unit that identifies the treated unit. |
required |
treatment_time
|
scalar
|
First period of treatment (inclusive). |
required |
covariates
|
list of str
|
Additional pre-treatment predictors to include in the matching objective. Covariates are appended to the pre-treatment outcome series for each unit before fitting. |
None
|
n_iter
|
int
|
Total MCMC iterations per chain (including warmup). |
2000
|
n_warmup
|
int
|
Number of warmup (burn-in) iterations for adaptation. Must be strictly less than n_iter. |
1000
|
n_chains
|
int
|
Number of independent MCMC chains. Multiple chains enable the R-hat convergence diagnostic. |
2
|
dirichlet_alpha
|
float
|
Concentration parameter for the symmetric Dirichlet prior on
donor weights. |
1.0
|
seed
|
int
|
Random seed for reproducibility. |
None
|
alpha
|
float
|
Significance level for credible intervals. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
With |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the panel has fewer than 2 pre-treatment periods, no post-treatment periods, or no valid donor units. |
Examples:
Called through the sp.synth dispatcher; extra keyword arguments
are forwarded to this estimator:
>>> import statspai as sp
>>> df = sp.dgp_synth(n_units=10, n_periods=20, treatment_time=15,
... effect=2.0, seed=0)
>>> result = sp.synth(
... df, outcome='y', unit='unit', time='time',
... treated_unit=0, treatment_time=15, method='bayesian',
... n_iter=500, n_warmup=250, n_chains=2, seed=42,
... )
>>> result.method, result.estimand
('Bayesian Synthetic Control', 'ATT')
>>> 'att_draws' in result.model_info
True
Notes
The sampler uses a Dirichlet proposal on the simplex (re-normalised perturbation) with adaptive step-size tuning during warmup targeting an acceptance rate of ~0.35. Samples are thinned by a factor of 2 to reduce autocorrelation.
References
Vives, J. and Martinez, A. (2024). "Bayesian Synthetic Control Methods." Journal of Computational and Graphical Statistics.
bsts_synth ¶
bsts_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, covariates: Optional[List[str]] = None, model: str = 'local_level', n_simulations: int = 1000, alpha: float = 0.05, seed: Optional[int] = None) -> CausalResult
BSTS synthetic control with a panel-data interface.
Converts long-format panel data into the wide format expected by
:func:causal_impact, using control-unit outcome series as
covariates/regressors. This provides a CausalImpact-style analysis
that integrates seamlessly with StatsPAI's other synthetic-control
methods.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data with columns for unit, time, and outcome. |
required |
outcome
|
str
|
Outcome variable column name. |
required |
unit
|
str
|
Unit identifier column name. |
required |
time
|
str
|
Time period column name. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
covariates
|
list of str
|
Additional time-varying covariates to include alongside control unit series. Each covariate is averaged across control units per time period and appended as an extra regressor. |
None
|
model
|
``'local_level'`` or ``'local_linear_trend'``
|
State-space model type. |
'local_level'
|
n_simulations
|
int
|
Number of posterior draws. |
1000
|
alpha
|
float
|
Significance level. |
0.05
|
seed
|
int
|
Random seed. |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Unified result object. |
Examples:
Called through the sp.synth dispatcher:
>>> import statspai as sp
>>> df = sp.dgp_synth(n_units=10, n_periods=20, treatment_time=15,
... effect=2.0, seed=0)
>>> result = sp.synth(
... df, outcome='y', unit='unit', time='time',
... treated_unit=0, treatment_time=15, method='bsts',
... n_simulations=200, seed=0,
... )
>>> result.method
'Bayesian Structural Time Series (CausalImpact)'
See Also
causal_impact : Wide-format CausalImpact interface.
causal_impact ¶
causal_impact(data: DataFrame, pre_period: Tuple[Any, Any], post_period: Tuple[Any, Any], outcome: Optional[str] = None, covariates: Optional[List[str]] = None, model: str = 'local_level', n_simulations: int = 1000, alpha: float = 0.05, seed: Optional[int] = None) -> CausalResult
Google CausalImpact-style causal inference for time series.
Fits a Bayesian structural time series model on the pre-intervention period and produces a counterfactual prediction for the post-period. The treatment effect is the difference between observed and counterfactual, with full posterior uncertainty.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Time-indexed DataFrame. If outcome is |
required |
pre_period
|
tuple of (start, end)
|
Pre-intervention period boundaries (inclusive). Values are matched against the DataFrame index. |
required |
post_period
|
tuple of (start, end)
|
Post-intervention period boundaries (inclusive). |
required |
outcome
|
str
|
Column name of the outcome variable. If |
None
|
covariates
|
list of str
|
Column names to use as controls. If |
None
|
model
|
``'local_level'`` or ``'local_linear_trend'``
|
State-space model type. |
'local_level'
|
n_simulations
|
int
|
Number of posterior draws for uncertainty quantification. |
1000
|
alpha
|
float
|
Significance level for confidence/credible intervals. |
0.05
|
seed
|
int
|
Random seed for reproducibility. |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Unified result object with:
- |
Notes
The implementation follows Brodersen et al. (2015). Regression coefficients are estimated via ridge regression with GCV-selected penalty (an empirical-Bayes analogue of the spike-and-slab prior). Hyperparameters (observation/level/slope noise) are estimated by maximum likelihood through the Kalman filter.
Examples:
This wide-format interface is not re-exported as sp.causal_impact
(that name is the long-format estimator); import it from its module:
>>> import numpy as np
>>> import pandas as pd
>>> from statspai.synth.bsts import causal_impact
>>> rng = np.random.default_rng(0)
>>> x1 = 100 + np.cumsum(rng.normal(0, 1, 100))
>>> x2 = 50 + np.cumsum(rng.normal(0, 1, 100))
>>> y = 1.2 * x1 + 0.8 * x2 + rng.normal(0, 1, 100)
>>> y[70:] += 10 # intervention at t = 70
>>> # Wide-format: columns = [outcome, control1, control2, ...]
>>> data = pd.DataFrame({"y": y, "x1": x1, "x2": x2})
>>> result = causal_impact(
... data, pre_period=(0, 69), post_period=(70, 99),
... n_simulations=200, seed=0,
... )
>>> result.method
'Bayesian Structural Time Series (CausalImpact)'
>>> bool(result.estimate > 0)
True
References
Brodersen, K.H., Gallusser, F., Koehler, J., Remy, N. and Scott, S.L. (2015). "Inferring causal impact using Bayesian structural time series models." Annals of Applied Statistics, 9(1), 247-274. [@brodersen2015inferring]
cluster_synth ¶
cluster_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, n_clusters: Optional[int] = None, cluster_method: str = 'kmeans', augment: bool = False, max_augment: int = 3, covariates: Optional[List[str]] = None, placebo: bool = True, alpha: float = 0.05, seed: Optional[int] = None) -> CausalResult
Cluster Synthetic Control estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Name of the outcome column. |
required |
unit
|
str
|
Name of the unit-identifier column. |
required |
time
|
str
|
Name of the time-period column. |
required |
treated_unit
|
any
|
Identifier of the single treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
n_clusters
|
int or None
|
Number of clusters. |
None
|
cluster_method
|
('kmeans', 'spectral', 'hierarchical')
|
Clustering algorithm. |
'kmeans'
|
augment
|
bool
|
If |
False
|
max_augment
|
int
|
Maximum number of additional donors when augment is |
3
|
covariates
|
list of str or None
|
Additional columns to include in the clustering feature matrix. |
None
|
placebo
|
bool
|
Run in-space placebo permutation inference. |
True
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
seed
|
int or None
|
Random seed for reproducibility. |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
synth_compare ¶
synth_compare(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any = None, treatment_time: Any = None, methods: Optional[List[str]] = None, placebo: bool = True, alpha: float = 0.05, seed: Optional[int] = None, **kwargs: Any) -> SynthComparison
Run multiple SCM variants and compare them side by side.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable column name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
None
|
treatment_time
|
any
|
First treatment period (inclusive). |
None
|
methods
|
list of str
|
SCM variants to compare. If |
None
|
placebo
|
bool
|
Whether to run placebo inference for each method. |
True
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
seed
|
int
|
Random seed for the methods whose inference resamples (synthetic
DID's placebo or bootstrap standard error, for one). It is passed
only to the methods that take a |
None
|
**kwargs
|
Any
|
Additional keyword arguments forwarded to |
{}
|
Returns:
| Type | Description |
|---|---|
SynthComparison
|
Structured comparison object with |
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> comp = sp.synth_compare(
... df, outcome="packspercapita", unit="state", time="year",
... treated_unit="California", treatment_time=1989,
... methods=["classic", "demeaned"],
... )
>>> comp.comparison_table.shape # one row per method
(2, 11)
>>> comp.recommended # method with the best pre-period fit
'classic'
See Also
synth_recommend : Quick one-liner returning only the method name. synth : Unified SCM dispatcher.
synth_recommend ¶
synth_recommend(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any = None, treatment_time: Any = None, **kwargs: Any) -> str
Quickly recommend the best SCM method for the given data.
Runs synth_compare internally with placebo=False for speed,
then returns just the recommended method name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable column name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
None
|
treatment_time
|
any
|
First treatment period (inclusive). |
None
|
**kwargs
|
Any
|
Additional keyword arguments forwarded to |
{}
|
Returns:
| Type | Description |
|---|---|
str
|
Name of the recommended SCM method (e.g., |
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> best = sp.synth_recommend(
... df, outcome="packspercapita", unit="state", time="year",
... treated_unit="California", treatment_time=1989,
... methods=["classic", "demeaned", "augmented"], # omit to try all
... )
>>> best
'classic'
>>> result = sp.synth(
... df, outcome="packspercapita", unit="state", time="year",
... treated_unit="California", treatment_time=1989,
... method=best,
... )
See Also
synth_compare : Full comparison with all metrics and plots.
conformal_synth ¶
conformal_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, scm_method: str = 'classic', grid_size: int = 101, grid_range: Optional[Tuple[float, float]] = None, alpha: float = 0.05, penalization: float = 0.0) -> CausalResult
Conformal inference for synthetic control (Chernozhukov, Wüthrich & Zhu).
Tests sharp null hypotheses about the treated unit's post-period
effects by re-estimating the synthetic control under each null on all
periods and permuting the resulting residuals over time, and builds
confidence sets by inverting those tests on a grid. This is the
procedure of the authors' R package scinference
(estimation_method = "sc", moving-block permutations, q = 1).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data (one row per unit and period). |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period. |
required |
scm_method
|
('classic', 'ridge')
|
Label of the counterfactual model. Both fit simplex-constrained
least squares of the treated outcome on the donor outcomes (no
intercept, |
'classic'
|
grid_size
|
int
|
Number of points in the hypothesis grid for CI inversion. |
101
|
grid_range
|
tuple of (float, float)
|
(min, max) of the hypothesis grid. Default: the point estimate
+/- max(5 x SD of the pre-period SC residuals, 3 x the largest
absolute post-period gap). A confidence set reaching an end of the
grid is truncated there (flagged in |
None
|
alpha
|
float
|
Significance level ( |
0.05
|
penalization
|
float
|
Ridge penalty on the weights. |
0.0
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99() # state, year, packspercapita, treated
>>> result = sp.conformal_synth(df, outcome='packspercapita',
... unit='state', time='year', treated_unit='California',
... treatment_time=1989)
>>> print(result.summary())
References
[@chernozhukov2021exact]
basque_terrorism ¶
Basque Country terrorism dataset (simulated).
Returns a balanced panel of GDP per capita (thousands of 1986 USD) for 17 Spanish regions, 1955--1997. The Basque Country is the treated unit; treatment begins in 1970 (onset of ETA terrorism).
The simulated data reproduce the gradual widening of an approximately 10 % GDP gap between the Basque Country and its synthetic counterfactual after 1970.
References
Abadie, A. & Gardeazabal, J. (2003). "The Economic Costs of Conflict: A Case Study of the Basque Country." American Economic Review, 93(1), 113--132. [@abadie2003economic]
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Examples:
california_tobacco ¶
California Proposition 99 tobacco dataset (simulated, extended).
Returns a balanced panel of per-capita cigarette sales and covariates for 39 US states, 1970--2000. California is the treated unit; treatment begins in 1989 (Proposition 99).
This dataset extends the simpler california_prop99() panel with
additional covariates (retail price, log income, youth population
share, beer consumption), enabling covariate-matching SCM analyses.
References
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. [@abadie2010synthetic]
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Notes
cigsale: per-capita cigarette sales (packs).retprice: average retail price per pack (cents, real).lnincome: log of real per-capita personal income.age15to24: share of population aged 15--24 (percent).beer: per-capita beer consumption (gallons).
Examples:
german_reunification ¶
German reunification dataset (simulated).
Returns a balanced panel of GDP per capita for 17 OECD countries, 1960--2003. West Germany is the treated unit; treatment begins in 1990 (reunification).
The simulated trajectories reproduce the key stylised facts: Luxembourg has the highest GDP per capita (~40 000), Portugal the lowest (~10 000), and all countries share a common upward growth trend. Post-1990, West Germany exhibits an approximately 1 500 GDP-per-capita decline relative to its synthetic counterfactual.
References
Abadie, A., Diamond, A. & Hainmueller, J. (2015). "Comparative Politics and the Synthetic Control Method." American Journal of Political Science, 59(2), 495--510. [@abadie2015comparative]
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Examples:
demeaned_synth ¶
demeaned_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, covariates: Optional[List[str]] = None, variant: Literal['demeaned', 'detrended'] = 'demeaned', penalization: float = 0.0, placebo: bool = True, alpha: float = 0.05) -> CausalResult
De-meaned / De-trended Synthetic Control Method.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
covariates
|
list of str
|
Not supported: the weights are fitted on (de-meaned / de-trended)
pre-treatment outcomes only. Passing covariates raises
|
None
|
variant
|
('demeaned', 'detrended')
|
|
'demeaned'
|
penalization
|
float
|
Ridge penalty on weights. |
0.0
|
placebo
|
bool
|
Run in-space placebo inference. |
True
|
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
De-meaned synthetic control on the Proposition 99 tobacco panel, with California treated from 1989:
>>> import statspai as sp
>>> import numpy as np
>>> df = sp.california_prop99()
>>> result = sp.demeaned_synth(
... df, outcome='packspercapita', unit='state', time='year',
... treated_unit='California', treatment_time=1989,
... )
>>> bool(np.isfinite(result.estimate))
True
References
ferman2021synthetic, doudchenko2016balancing
discos_plot ¶
discos_plot(result: CausalResult, type: str = 'quantile_effect', ax: Any = None, figsize: Tuple[int, int] = (10, 6), color: str = '#2C3E50', ci_alpha: float = 0.2, title: Optional[str] = None) -> Any
Visualise distributional synthetic control results.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output from |
required |
type
|
('quantile_effect', 'quantile_comparison', 'gap', 'weights')
|
default 'quantile_effect'
|
'quantile_effect'
|
ax
|
Axes
|
Pre-existing axes for the plot. |
None
|
figsize
|
tuple
|
Figure size. |
(10, 6)
|
color
|
str
|
Primary plot colour. |
'#2C3E50'
|
ci_alpha
|
float
|
Transparency for CI band. |
0.2
|
title
|
str
|
Plot title override. |
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> result = sp.discos(df, outcome='packspercapita', unit='state',
... time='year', treated_unit='California',
... treatment_time=1989)
>>> fig, ax = sp.discos_plot(result, type='quantile_effect')
>>> fig2, ax2 = sp.discos_plot(result, type='quantile_comparison')
discos_test ¶
Test for distributional treatment effects.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output from |
required |
test
|
('ks', 'cvm', 'stochastic_dominance')
|
|
'ks'
|
Returns:
| Type | Description |
|---|---|
dict
|
Keys: |
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> result = sp.discos(df, outcome='packspercapita', unit='state',
... time='year', treated_unit='California',
... treatment_time=1989)
>>> out = sp.discos_test(result, test='ks')
>>> out['test']
'Kolmogorov-Smirnov'
>>> bool('pvalue' in out)
True
qqsynth ¶
qqsynth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, n_quantiles: int = 100, placebo: bool = True, alpha: float = 0.05, seed: Optional[int] = None) -> CausalResult
Quantile Synthetic Control: exactly sp.discos(..., method='quantile').
On individual-level data this is Gunsilius' quantile-function DiSCo
(R DiSCos::DiSCo(mixture = FALSE, simplex = FALSE)): weights sum to
one but may be negative. discos' keyword-only arguments (M,
simplex, q_nodes, cdf_grid) are not exposed here, so R's
random quantile nodes cannot be replayed through this alias; call
sp.discos for that. On aggregate panels it is discos' labelled
fallback heuristic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel data in long format. |
required |
outcome
|
str
|
Outcome variable column. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
n_quantiles
|
int
|
Number of quantile grid points. |
100
|
placebo
|
bool
|
Run placebo permutation inference. |
True
|
alpha
|
float
|
Significance level. |
0.05
|
seed
|
int
|
Random seed. |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> result = sp.qqsynth(df, outcome='packspercapita', unit='state',
... time='year', treated_unit='California',
... treatment_time=1989)
>>> bool(result.estimate is not None)
True
See Also
discos : Full distributional synthetic controls with method selection.
References
gunsilius2023distributional
stochastic_dominance ¶
Test for stochastic dominance of the treated distribution over the counterfactual distribution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output from |
required |
order
|
(1, 2)
|
Order of stochastic dominance. 1 = first-order (CDF dominance). 2 = second-order (integrated CDF dominance). |
1
|
Returns:
| Type | Description |
|---|---|
dict
|
Keys: |
Examples:
synth_experimental_design ¶
synth_experimental_design(data: DataFrame, *, unit: str, time: str, outcome: str, k: int, candidates: Optional[Sequence[Any]] = None, donors: Optional[Sequence[Any]] = None, pre_period: Optional[Tuple[Any, Any]] = None, risk: str = 'mspe', concentration_weight: float = 0.0, penalization: float = 0.0, n_random: int = 500, random_state: Optional[int] = None, criterion: str = 'loo_fit', population_weights: Optional[str] = None, n_search: int = 500) -> SynthExperimentalDesignResult
Pick the k candidates with the best leave-one-out SC pre-fit.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame (long format)
|
Must contain columns |
required |
unit
|
str
|
Column names for the panel. |
required |
time
|
str
|
Column names for the panel. |
required |
outcome
|
str
|
Column names for the panel. |
required |
k
|
int
|
Number of units to select for treatment. Must satisfy
|
required |
candidates
|
sequence
|
Units eligible for treatment. Defaults to all units. |
None
|
donors
|
sequence
|
Units available as donors. Defaults to "all units NOT in
|
None
|
pre_period
|
(start, end)
|
Closed interval of pre-treatment periods. Defaults to all
timestamps in |
None
|
risk
|
('mspe', 'rmse')
|
Loss functional for ranking candidates. |
'mspe'
|
concentration_weight
|
float
|
Penalty on donor-weight concentration (Herfindahl):
|
0.0
|
penalization
|
float
|
Ridge penalty passed to the simplex solver (Doudchenko & Imbens 2016 style). |
0.0
|
n_random
|
int
|
Monte-Carlo draws used to estimate |
500
|
random_state
|
int
|
|
None
|
criterion
|
('loo_fit', 'population')
|
|
'loo_fit'
|
population_weights
|
str
|
For |
None
|
n_search
|
int
|
For |
500
|
Returns:
| Type | Description |
|---|---|
SynthExperimentalDesignResult
|
|
Notes
The recipe (a heuristic; see the module docstring) is:
- For each candidate unit
i, solve the simplex SC problem against the donor pool restricted to non-candidates (to avoid coupling risk scores across candidates). - Record the pre-period MSPE as the plug-in estimate of
sigma^2_i. - Pick the
kcandidates with the smallestrisk_score:loss_i + lambda * H(w_i).
The implementation degrades gracefully when candidates covers all
units: we then use per-candidate leave-one-out donor pools.
Examples:
synth_to_excel ¶
synth_to_excel(obj: Union[CausalResult, 'SynthComparison', List[CausalResult]], path: str, *, method_names: Optional[Sequence[str]] = None, digits: int = 6) -> str
Multi-sheet Excel workbook for synthetic-control results.
Sheets
"Summary"— one row per method (ATT, SE, CI, pre-RMSPE, fit quality, donor counts)."Weights"— donor weights per method (one column per method; missing donors are NaN)."Gap_<method>"— per-period treated / synthetic / gap for each method."Diagnostics"— scalar diagnostics (pre-RMSPE, post/pre RMSPE ratio, fit quality, n_donors, etc.).
Requires openpyxl (already a soft dependency of pandas
Excel I/O). Will raise ModuleNotFoundError with an actionable
hint if it is not installed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
CausalResult, SynthComparison, or list of CausalResult
|
Object to export. |
required |
path
|
str
|
Destination |
required |
method_names
|
list of str
|
Override sheet / column labels. |
None
|
digits
|
int
|
Rounding for floating-point values. |
6
|
Returns:
| Type | Description |
|---|---|
str
|
Absolute path of the file that was written. |
Examples:
>>> import os
>>> import tempfile
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.synth(
... df, outcome="y", unit="unit", time="time",
... treated_unit=0, treatment_time=7, method="classic",
... placebo=False,
... )
>>> path = sp.synth_to_excel(
... res, os.path.join(tempfile.mkdtemp(), "synth.xlsx")
... )
>>> os.path.exists(path)
True
synth_to_latex ¶
synth_to_latex(obj: Union[CausalResult, 'SynthComparison', List[CausalResult]], *, caption: Optional[str] = None, label: Optional[str] = None, booktabs: bool = True, show_ci: bool = True, show_weights: bool = False, top_n_weights: int = 5, digits: Optional[int] = None, method_names: Optional[Sequence[str]] = None) -> str
Formatted LaTeX table for synthetic-control results.
Single-result mode produces a vertical table with ATT, SE,
confidence interval, pre-RMSPE, fit quality, and (optionally) the
top-N donor weights. Comparison mode (SynthComparison or list
of results) produces a wide table with one column per method, the
standard textbook layout for empirical applied work.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
CausalResult, SynthComparison, or list of CausalResult
|
Object to render. |
required |
caption
|
str
|
Table caption. Defaults to a sensible auto-generated string. |
None
|
label
|
str
|
LaTeX label for cross-referencing. Defaults to
|
None
|
booktabs
|
bool
|
If True, use |
True
|
show_ci
|
bool
|
Include the confidence-interval row. |
True
|
show_weights
|
bool
|
Append a panel listing the top-N donor weights. |
False
|
top_n_weights
|
int
|
How many donors to show per method when |
5
|
digits
|
int
|
Number of decimal places. |
4
|
method_names
|
list of str
|
Override column labels in comparison mode. |
None
|
Returns:
| Type | Description |
|---|---|
str
|
LaTeX source ready to drop into a paper. Stars use the
standard |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.synth(
... df, outcome="y", unit="unit", time="time",
... treated_unit=0, treatment_time=7, method="classic",
... placebo=False,
... )
>>> tex = sp.synth_to_latex(res, show_weights=True)
>>> isinstance(tex, str)
True
Multi-method comparison:
synth_to_markdown ¶
synth_to_markdown(obj: Union[CausalResult, 'SynthComparison', List[CausalResult]], *, title: Optional[str] = None, show_ci: bool = True, show_weights: bool = False, top_n_weights: int = 5, digits: Optional[int] = None, method_names: Optional[Sequence[str]] = None) -> str
GitHub-flavoured Markdown table for synthetic-control results.
Mirrors :func:synth_to_latex in scope but emits a pipe-delimited
Markdown table that renders cleanly on GitHub, in pandoc, and in
most static-site generators.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obj
|
Union[CausalResult, 'SynthComparison', List[CausalResult]]
|
See :func: |
required |
title
|
Union[CausalResult, 'SynthComparison', List[CausalResult]]
|
See :func: |
required |
show_ci
|
Union[CausalResult, 'SynthComparison', List[CausalResult]]
|
See :func: |
required |
show_weights
|
Union[CausalResult, 'SynthComparison', List[CausalResult]]
|
See :func: |
required |
top_n_weights
|
Union[CausalResult, 'SynthComparison', List[CausalResult]]
|
See :func: |
required |
digits
|
Union[CausalResult, 'SynthComparison', List[CausalResult]]
|
See :func: |
required |
method_names
|
Union[CausalResult, 'SynthComparison', List[CausalResult]]
|
See :func: |
required |
Returns:
| Type | Description |
|---|---|
str
|
Markdown source. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.synth(
... df, outcome="y", unit="unit", time="time",
... treated_unit=0, treatment_time=7, method="classic",
... placebo=False,
... )
>>> md = sp.synth_to_markdown(res)
>>> isinstance(md, str)
True
>>> "**ATT**" in md
True
kernel_ridge_synth ¶
kernel_ridge_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, kernel: str = 'rbf', sigma: Optional[float] = None, degree: int = 2, ridge_lambda: float = 0.01, covariates: Optional[List[str]] = None, placebo: bool = True, alpha: float = 0.05) -> CausalResult
Kernel Ridge Regression Synthetic Control.
Instead of constrained simplex weights, this estimator uses kernel
ridge regression to learn the mapping from donors to the treated unit.
The ridge penalty lambda prevents overfitting when the number of
donors is small relative to pre-treatment periods.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel data in long format. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
Any
|
Identifier of the treated unit. |
required |
treatment_time
|
Any
|
First treatment period (inclusive). |
required |
kernel
|
``{'rbf', 'polynomial', 'laplacian'}``
|
Kernel function. |
``'rbf'``
|
sigma
|
float or None
|
Bandwidth (None = median heuristic). |
None
|
degree
|
int
|
Polynomial kernel degree. |
2
|
ridge_lambda
|
float
|
Regularisation parameter. Larger values shrink the coefficient vector toward zero. |
0.01
|
covariates
|
list of str or None
|
Additional pre-treatment covariates. |
None
|
placebo
|
bool
|
Run placebo permutation inference. |
True
|
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Notes
The solution is:
.. math::
\beta = (K + \lambda I)^{-1}\, k(Y_1)
and the counterfactual is :math:\hat{Y}_{1,\text{post}} =
Y_{0,\text{post}}^\top \beta.
No non-negativity or sum-to-one constraints are imposed, which gives the estimator more flexibility but may produce extrapolation.
kernel_synth ¶
kernel_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, kernel: str = 'rbf', sigma: Optional[float] = None, degree: int = 2, covariates: Optional[List[str]] = None, placebo: bool = True, alpha: float = 0.05) -> CausalResult
Kernel-based Nonlinear Synthetic Control Method.
Standard SCM assumes the counterfactual is a linear combination of donors. This estimator lifts the donor panel into a reproducing kernel Hilbert space (RKHS) and solves for synthetic control weights in that feature space, capturing nonlinear donor relationships.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel data in long format with columns for unit, time, and outcome. |
required |
outcome
|
str
|
Name of the outcome variable. |
required |
unit
|
str
|
Column identifying panel units. |
required |
time
|
str
|
Column identifying time periods. |
required |
treated_unit
|
Any
|
Identifier of the treated unit. |
required |
treatment_time
|
Any
|
First treatment period (inclusive). |
required |
kernel
|
``{'rbf', 'polynomial', 'laplacian'}``
|
Kernel function to use. |
``'rbf'``
|
sigma
|
float or None
|
Bandwidth for RBF / Laplacian kernels. If None, the median heuristic is used (recommended). |
None
|
degree
|
int
|
Degree for the polynomial kernel (ignored otherwise). |
2
|
covariates
|
list of str or None
|
Additional pre-treatment covariates to include in the feature
vector. If provided, each donor row is |
None
|
placebo
|
bool
|
Whether to run in-space placebo permutation for inference. |
True
|
alpha
|
float
|
Significance level for the confidence interval. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Unified result with ATT estimate, SE, p-value, CI, and
period-level effects in |
Notes
The optimisation solved is:
.. math::
\min_{w \ge 0,\, \sum w = 1}
\bigl[K(Y_1, Y_1) - 2\,w^\top k(Y_1) + w^\top K\,w\bigr]
where :math:K_{ij} = k(Y_{0,i},\, Y_{0,j}) is the donor kernel
matrix and :math:k(Y_1)_j = k(Y_1,\, Y_{0,j}).
References
Trajectory balancing, including kernel balancing of pre-treatment outcomes. [@hazlett2018trajectory]
Kernels and reproducing kernel Hilbert spaces. [@scholkopf2002learning]
mc_synth ¶
mc_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, covariates: Optional[List[str]] = None, lambda_reg: Optional[float] = None, max_iter: int = 5000, tol: float = 1e-10, cv_folds: int = 5, alpha: float = 0.05, placebo: bool = True, seed: Optional[int] = None, fixed_effects: str = 'two-way') -> CausalResult
Matrix Completion Synthetic Control Method.
Imputes the treated unit's post-treatment counterfactual by solving a nuclear-norm-penalised matrix completion problem on the full panel, following Athey et al. (2021).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
covariates
|
list of str
|
Time-varying covariates to partial out before matrix completion. |
None
|
lambda_reg
|
float
|
Singular-value threshold on the |
None
|
max_iter
|
int
|
Maximum Soft-Impute iterations ( |
5000
|
tol
|
float
|
Convergence tolerance (relative change in Frobenius norm of the fitted matrix). |
1e-10
|
cv_folds
|
int
|
Number of CV folds for automatic lambda selection. |
5
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
placebo
|
bool
|
Run placebo (permutation) inference by treating each control unit as if it were treated. |
True
|
seed
|
int
|
Random seed for the CV fold assignment. With |
None
|
fixed_effects
|
('two-way', 'unit', 'time', 'none')
|
Unpenalised additive effects fitted with the low-rank part.
|
"two-way"
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
With |
Notes
The algorithm uses the Soft-Impute / Singular Value Thresholding (SVT) procedure. At each iteration the current completion is projected onto observed entries, combined with the previous imputation at missing entries, then rank-reduced by soft-thresholding the singular values.
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> result = sp.mc_synth(df, outcome='packspercapita', unit='state',
... time='year', treated_unit='California',
... treatment_time=1989, placebo=False, seed=0)
>>> result.method, result.estimand
('Matrix Completion SCM (Athey et al. 2021)', 'ATT')
>>> bool(result.estimate < 0) # Prop 99 reduced cigarette sales
True
multi_outcome_synth ¶
multi_outcome_synth(data: DataFrame, outcomes: List[str], unit: str, time: str, treated_unit: Any, treatment_time: Any, method: str = 'concatenated', standardize: bool = True, penalization: float = 0.0, placebo: bool = True, alpha: float = 0.05) -> CausalResult
Multiple Outcomes Synthetic Control Method (Sun, Ben-Michael & Feller).
The shared weights reproduce R augsynth::augsynth_multiout(progfunc =
"None", scm = TRUE, fixedeff = FALSE) with combine_method =
"concat" (method='concatenated') or "avg"
(method='averaged') when standardize=True: each outcome's
pre-period panel is scaled by its standard deviation over the donors'
pre-period cells (the centring cancels under the adding-up
constraint). The placebo SE / p-values and the Fisher joint p-value are
StatsPAI's own; augsynth offers only conformal inference for multiple
outcomes.
Finds a single set of donor weights that simultaneously matches the treated unit across all K outcomes in the pre-treatment period.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data containing all outcome columns. |
required |
outcomes
|
list of str
|
Column names for the K outcome variables. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Value identifying the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
method
|
('concatenated', 'averaged')
|
Weight-estimation strategy.
|
'concatenated'
|
standardize
|
bool
|
Standardise each outcome to zero mean / unit variance before stacking or averaging (strongly recommended when outcome scales differ). |
True
|
penalization
|
float
|
Ridge-type penalty added to the diagonal of the donor
cross-product matrix ( |
0.0
|
placebo
|
bool
|
Run in-space placebo permutations for inference (each donor is pretended to be treated in turn). |
True
|
alpha
|
float
|
Significance level for confidence intervals and joint test. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Unified result object with:
|
Examples:
>>> import numpy as np, pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(123)
>>> n_units, n_periods, t0 = 11, 20, 11
>>> alphas = rng.normal(10, 2, (n_units, 3))
>>> betas = rng.normal(0.5, 0.1, (n_units, 3))
>>> records = []
>>> for i in range(n_units):
... for t in range(1, n_periods + 1):
... row = {'unit': f'unit_{i}', 'time': t}
... for k, name in enumerate(['gdp', 'employment', 'investment']):
... y = alphas[i, k] + betas[i, k] * t + rng.normal(0, 0.3)
... if i == 0 and t >= t0: # unit_0 treated from t0
... y += [5.0, 3.0, 0.0][k]
... row[name] = y
... records.append(row)
>>> df = pd.DataFrame(records)
>>> result = sp.multi_outcome_synth(
... df,
... outcomes=['gdp', 'employment', 'investment'],
... unit='unit', time='time',
... treated_unit='unit_0', treatment_time=11,
... placebo=False,
... )
>>> summary_text = result.summary()
>>> effects = result.model_info['per_outcome_effects']
Notes
Sun, Ben-Michael & Feller show that under a low-rank factor model the bias of the concatenated estimator shrinks as O(1/sqrt(K)), where K is the number of outcomes. The key requirement is that the outcomes share a common latent-factor structure.
penalized_synth ¶
penalized_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, covariates: Optional[List[str]] = None, lambda_pen: Optional[float] = None, penalty_type: str = 'pairwise', predictors: Optional[List[str]] = None, placebo: bool = True, alpha: float = 0.05) -> CausalResult
Penalized Synthetic Control estimator (Abadie & L'Hour 2021).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data with columns for unit, time, outcome, and optionally covariates / predictors. |
required |
outcome
|
str
|
Name of the outcome column. |
required |
unit
|
str
|
Name of the unit identifier column. |
required |
time
|
str
|
Name of the time period column. |
required |
treated_unit
|
Any
|
Identifier of the treated unit. |
required |
treatment_time
|
Any
|
First treatment period (inclusive). |
required |
covariates
|
list of str
|
Covariate columns used only for the pairwise distance penalty.
When |
None
|
lambda_pen
|
float
|
Penalty parameter. |
None
|
penalty_type
|
('pairwise', 'max_dev', 'l1_pairwise')
|
Penalty functional form.
|
'pairwise'
|
predictors
|
list of str
|
Columns whose pre-treatment averages are appended to the covariate vector for distance computation. |
None
|
placebo
|
bool
|
Run in-space placebo permutation tests. |
True
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
With |
References
Abadie, A. and L'Hour, J. (2021). "A Penalized Synthetic Control Estimator for Disaggregated Data." Journal of the American Statistical Association, 116(536), 1817-1834. [@abadie2021penalized]
synthplot ¶
synthplot(result: Union[CausalResult, List[CausalResult]], type: str = 'trajectory', ax: Any = None, figsize: Optional[tuple[float, float]] = None, title: Optional[str] = None, top_n: int = 15, labels: Optional[List[str]] = None, **kwargs: Any) -> tuple[Any, Any]
Unified plot function for all Synthetic Control variants.
Automatically detects the SCM variant and renders the appropriate
visualisation. Works with results from synth(method=...),
sdid(), augsynth(), gsynth(), staggered_synth(),
conformal_synth(), and all other variants.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult or list of CausalResult
|
Output of any |
required |
type
|
str
|
Plot type:
|
'trajectory'
|
ax
|
matplotlib Axes
|
Pre-existing axes for single-panel plots. |
None
|
figsize
|
tuple
|
Figure size. Auto-selected if None. |
None
|
title
|
str
|
Override the auto-generated title. |
None
|
top_n
|
int
|
Number of donors to show in weight plots. |
15
|
labels
|
list of str
|
Labels for |
None
|
**kwargs
|
Any
|
Additional arguments passed to individual plotters. Notable:
|
{}
|
Returns:
| Type | Description |
|---|---|
(fig, ax) or (fig, axes)
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> result = sp.synth(
... df, outcome="y", unit="unit", time="time",
... treated_unit=0, treatment_time=7, method="classic",
... placebo=True,
... )
>>> fig, ax = sp.synthplot(result) # trajectory
>>> fig, ax = sp.synthplot(result, type='gap') # gap plot
>>> fig, axes = sp.synthplot(result, type='both') # two-panel
>>> fig, ax = sp.synthplot(result, type='weights') # donor weights
>>> fig, ax = sp.synthplot(result, type='placebo') # placebo dist
Compare methods:
synth_mde ¶
synth_mde(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, power_target: float = 0.8, alpha: float = 0.05, n_simulations: int = 200, seed: Optional[int] = None) -> float
Minimum Detectable Effect for a Synthetic Control design.
Convenience wrapper around :func:synth_power that returns only
the MDE (the smallest effect size achieving the target power).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period. |
required |
power_target
|
float
|
Desired power level. |
0.80
|
alpha
|
float
|
Significance level for the placebo test. |
0.05
|
n_simulations
|
int
|
Number of simulations per effect size. |
200
|
seed
|
int
|
Random seed for reproducibility. |
None
|
Returns:
| Type | Description |
|---|---|
float
|
Minimum detectable effect size. Returns |
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> mde = sp.synth_mde(
... df, outcome='packspercapita', unit='state', time='year',
... treated_unit='California', treatment_time=1989,
... seed=42,
... )
>>> bool(mde >= 0)
True
See Also
synth_power : Full power curve with details.
synth_power ¶
synth_power(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, effect_sizes: Optional[Sequence[float]] = None, n_simulations: int = 200, alpha: float = 0.05, seed: Optional[int] = None) -> DataFrame
Power analysis for Synthetic Control designs.
Estimates statistical power across a grid of hypothetical effect sizes using placebo-based inference. Identifies the Minimum Detectable Effect (MDE) — the smallest effect where power >= 0.80.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
effect_sizes
|
array-like of float
|
Grid of hypothetical additive effect sizes to evaluate.
If |
None
|
n_simulations
|
int
|
Number of Monte-Carlo simulations per effect size. |
200
|
alpha
|
float
|
Significance level for the placebo test. |
0.05
|
seed
|
int
|
Random seed for reproducibility. |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: The |
Notes
The null distribution is the set of RMSPE ratios from in-space
placebos (computed once on the original data). For each effect
size, the simulation adds delta to the treated unit's
post-treatment outcomes and re-computes the RMSPE ratio; H0 is
rejected when the rank p-value
(1 + #{placebo ratio >= treated ratio}) / (J + 1) is <= alpha.
With J placebos no effect can be detected when alpha < 1/(J+1). A small
noise perturbation (10 % of pre-treatment residual SD) is added so
that each simulation draw is unique.
This is a novel diagnostic — no existing SCM package provides an equivalent power-planning tool.
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> power_df = sp.synth_power(
... df, outcome='packspercapita', unit='state', time='year',
... treated_unit='California', treatment_time=1989,
... n_simulations=200, seed=42,
... )
>>> list(power_df.columns)
['effect_size', 'power', 'n_rejections', 'n_simulations', 'mde_flag']
>>> bool((power_df['power'] >= 0).all())
True
>>> mde_row = power_df[power_df['mde_flag']]
See Also
synth_mde : Quick MDE extraction. synth_power_plot : Visualise the power curve.
synth_power_plot ¶
synth_power_plot(power_result: DataFrame, ax: Any = None, figsize: tuple = (9, 6), title: Optional[str] = None) -> Any
Plot the power curve from :func:synth_power.
Displays power (y-axis) against effect size (x-axis) with reference lines at power = 0.80 and the MDE.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
power_result
|
DataFrame
|
Output of :func: |
required |
ax
|
Axes
|
Axes to plot on. If |
None
|
figsize
|
tuple
|
Figure size (width, height) in inches. |
(9, 6)
|
title
|
str
|
Custom plot title. Defaults to
|
None
|
Returns:
| Type | Description |
|---|---|
Axes
|
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> power_df = sp.synth_power(
... df, outcome='packspercapita', unit='state', time='year',
... treated_unit='California', treatment_time=1989,
... n_simulations=50, seed=42,
... )
>>> ax = sp.synth_power_plot(power_df)
>>> bool(ax is not None)
True
See Also
synth_power : Compute the power curve.
synth_report ¶
synth_report(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any = None, treatment_time: Any = None, method: str = 'classic', output: str = 'text', sensitivity: bool = True, alpha: float = 0.05, **kwargs: Any) -> str
Generate a comprehensive Synthetic Control analysis report.
Runs synth() for the main estimation and optionally
synth_sensitivity() for robustness diagnostics, then formats
everything into a structured report.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
None
|
treatment_time
|
any
|
First treatment period (inclusive). |
None
|
method
|
str
|
SCM variant passed to |
'classic'
|
output
|
str
|
Output format: |
'text'
|
sensitivity
|
bool
|
Whether to include the sensitivity analysis section. |
True
|
alpha
|
float
|
Significance level for CIs and hypothesis tests. |
0.05
|
**kwargs
|
Any
|
Additional keyword arguments forwarded to |
{}
|
Returns:
| Type | Description |
|---|---|
str
|
Formatted analysis report. |
Examples:
synth_report_to_file ¶
synth_report_to_file(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any = None, treatment_time: Any = None, method: str = 'classic', output: str = 'markdown', sensitivity: bool = True, alpha: float = 0.05, filename: str = 'report.md', **kwargs: Any) -> str
Generate an SCM report and write it directly to a file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
None
|
treatment_time
|
any
|
First treatment period (inclusive). |
None
|
method
|
str
|
SCM variant passed to |
'classic'
|
output
|
str
|
Output format: |
'markdown'
|
sensitivity
|
bool
|
Whether to include the sensitivity analysis section. |
True
|
alpha
|
float
|
Significance level. |
0.05
|
filename
|
str
|
Output file path. |
'report.md'
|
**kwargs
|
Any
|
Additional keyword arguments forwarded to |
{}
|
Returns:
| Type | Description |
|---|---|
str
|
The generated report string (also written to filename). |
Examples:
>>> import os, tempfile
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> path = os.path.join(tempfile.gettempdir(), 'california_scm.md')
>>> report = sp.synth_report_to_file(
... df, outcome='packspercapita', unit='state', time='year',
... treated_unit='California', treatment_time=1989,
... filename=path, sensitivity=False,
... )
>>> bool(os.path.exists(path))
True
robust_synth ¶
robust_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, covariates: Optional[List[str]] = None, variant: Literal['unconstrained', 'elastic_net', 'penalized'] = 'unconstrained', l1_penalty: float = 0.0, l2_penalty: float = 0.01, intercept: bool = True, placebo: bool = True, alpha: float = 0.05) -> CausalResult
Robust / unconstrained Synthetic Control.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period. |
required |
covariates
|
list of str
|
Not supported: the weights are fitted on pre-treatment outcomes
only. Passing covariates raises |
None
|
variant
|
('unconstrained', 'elastic_net', 'penalized')
|
|
'unconstrained'
|
l1_penalty
|
float
|
L1 penalty |
0.0
|
l2_penalty
|
float
|
Ridge penalty |
0.01
|
intercept
|
bool
|
Fit an unpenalised intercept (level shift). Only for unconstrained / elastic_net. |
True
|
placebo
|
bool
|
Run in-space placebo inference. |
True
|
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
synth ¶
synth(data: DataFrame, outcome: Optional[str] = None, unit: Optional[str] = None, time: Optional[str] = None, treated_unit: Any = None, treatment_time: Any = None, method: str = 'classic', covariates: Optional[List[str]] = None, penalization: float = 0.0, placebo: bool = True, alpha: float = 0.05, inference: Optional[str] = None, treatment: Optional[str] = None, y: Optional[str] = None, **kwargs: Any) -> CausalResult
Public sp.synth entry point — see _dispatch_synth_impl for
the full docstring on methods and parameters.
Thin wrapper around the multi-branch dispatcher that attaches a
:class:Provenance record to the returned result so downstream
replication_pack / Quarto appendix / table footers can pick up
the call (function name, args, data hash) without each individual
SCM backend having to opt in. The 20-method dispatcher itself
lives in :func:_dispatch_synth_impl.
References
Abadie, A., Diamond, A. and Hainmueller, J. (2010). Synthetic control methods for comparative case studies. Journal of the American Statistical Association. [@abadie2010synthetic]
Examples:
Classic Abadie-Diamond-Hainmueller SCM on the bundled Proposition 99 panel (39 states x 31 years):
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> result = sp.synth(df, outcome='packspercapita', unit='state',
... time='year', treated_unit='California',
... treatment_time=1989, method='classic')
>>> round(result.estimate, 1) # post-1989 ATT, packs per capita
-18.2
Switch estimator via method= (same call signature):
>>> result = sp.synth(df, outcome='packspercapita', unit='state',
... time='year', treated_unit='California',
... treatment_time=1989, method='sdid')
To fit several SCM variants at once and get a recommendation, use
:func:sp.synth_compare with the same arguments.
scdata ¶
scdata(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any) -> Dict[str, Any]
Prepare data matrices for synthetic control estimation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data (one row per unit-period). |
required |
outcome
|
str
|
Outcome variable column name. |
required |
unit
|
str
|
Unit identifier column name. |
required |
time
|
str
|
Time period column name. |
required |
treated_unit
|
Any
|
Identifier of the treated unit. |
required |
treatment_time
|
Any
|
First treatment period (periods |
required |
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
Duplicate (unit, time) rows and missing outcomes of the treated unit are refused. Donors with a missing pre-treatment outcome are dropped with a warning; a donor missing a post-treatment outcome is refused, because the synthetic control would be undefined in that period.
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> d = sp.scdata(df, outcome="packspercapita", unit="state",
... time="year", treated_unit="California",
... treatment_time=1989)
>>> d["B"].shape == (d["T0"], d["J"])
True
References
[@cattaneo2025scpi]
scest ¶
scest(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any = None, treatment_time: Any = None, w_constr: str = 'simplex', lasso_lambda: Optional[float] = None, ridge_lambda: Optional[float] = None, Q: Optional[float] = None, Q2: Optional[float] = None, treat: Optional[str] = None, effect: str = 'unit-time', anticipation: int = 0) -> Dict[str, Any]
Estimate synthetic control weights.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable column name. |
required |
unit
|
str
|
Unit identifier column name. |
required |
time
|
str
|
Time period column name. |
required |
treated_unit
|
Any
|
Identifier of the treated unit. With |
None
|
treatment_time
|
Any
|
First treatment period of the single treated unit. Not used with
|
None
|
w_constr
|
str
|
Constraint on the weights:
|
'simplex'
|
lasso_lambda
|
float
|
Deprecated and ignored (a |
None
|
ridge_lambda
|
float
|
Deprecated and ignored (a |
None
|
Q
|
float
|
Norm bound for |
None
|
Q2
|
float
|
L2 bound for |
None
|
treat
|
str
|
0/1 treatment column for several treated units with staggered adoption; each treated unit gets its own fit with the never-treated units as donors. |
None
|
effect
|
('unit-time', 'unit', 'time')
|
With |
'unit-time'
|
anticipation
|
int
|
With |
0
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> est = sp.scest(df, outcome="packspercapita", unit="state",
... time="year", treated_unit="California",
... treatment_time=1989)
>>> round(float(est["weights"].sum()), 6)
1.0
References
[@cattaneo2025scpi]
california_prop99 ¶
Simulated panel with the shape of the California Proposition 99 data.
A balanced panel of per-capita cigarette sales for 39 US states,
1970-2000, with California treated from 1989. The numbers are
simulated (fixed seed), laid out like the synthdid example
dataset with the same states, years and column meaning. They are not
the Abadie, Diamond and Hainmueller data: estimates on this panel do
not reproduce published ones (sp.sdid gives -17.9 here, and -15.60
on the real panel, where it agrees with R synthdid to 1e-9).
For the real panel use sp.datasets.california_prop99(), whose
outcome column is cigsale.
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Examples:
did_estimate ¶
did_estimate(data: DataFrame, y: str, unit: str, time: str, treat_unit: Any, treat_time: Any, **kw: Any) -> CausalResult
R-style alias: synthdid::did_estimate.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.did_estimate(
... df, y="y", unit="unit", time="time",
... treat_unit=0, treat_time=7, seed=42,
... )
>>> round(res.estimate, 2) # true effect = 3.0
3.01
sc_estimate ¶
sc_estimate(data: DataFrame, y: str, unit: str, time: str, treat_unit: Any, treat_time: Any, **kw: Any) -> CausalResult
R-style alias: synthdid::sc_estimate.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.sc_estimate(
... df, y="y", unit="unit", time="time",
... treat_unit=0, treat_time=7, seed=42,
... )
>>> round(res.estimate, 1) # synthetic-control variant
2.5
synthdid_estimate ¶
synthdid_estimate(data: DataFrame, y: str, unit: str, time: str, treat_unit: Any, treat_time: Any, **kw: Any) -> CausalResult
R-style alias: synthdid::synthdid_estimate.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.synthdid_estimate(
... df, y="y", unit="unit", time="time",
... treat_unit=0, treat_time=7, seed=42,
... )
>>> round(res.estimate, 2) # true effect = 3.0
3.09
synthdid_placebo ¶
synthdid_placebo(data: DataFrame, y: str, unit: str, time: str, treat_unit: Any, treat_time: Any, method: Literal['sdid', 'sc', 'did'] = 'sdid', kind: Literal['unit', 'time'] = 'unit', **kw: Any) -> DataFrame
Placebo estimates for a synthetic difference-in-differences fit.
kind='unit' (default) is the in-space placebo table: the treated
units are set aside and :func:sdid is refitted once per control unit
as if that unit had been treated at treat_time.
kind='time' is R's synthdid::synthdid_placebo: the estimator
is run on the pre-treatment periods only, with the treatment date
moved earlier so that the placebo post-period is the same fraction of
the panel as the real one, and with the regularisation constants of
the original fit. On the real Proposition 99 panel it returns
-1.6674164, equal to synthdid 0.0.9 to 1e-8.
Accepts the same arguments as :func:sdid, plus any extra keyword
arguments (used by kind='unit' only).
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> pl = sp.synthdid_placebo(
... df, y="y", unit="unit", time="time",
... treat_unit=0, treat_time=7, seed=42,
... )
>>> list(pl.columns)
['unit', 'estimate', 'se', 'pvalue']
>>> len(pl) # one row per control unit
11
synthdid_plot ¶
synthdid_plot(result: CausalResult, ax: Any = None, figsize: Tuple[float, float] = (10, 6), treated_color: str = '#2C3E50', synth_color: str = '#E74C3C', ci_alpha: float = 0.15, title: Optional[str] = None) -> Tuple[Any, Any]
Plot observed vs synthetic trajectory.
Replicates synthdid::plot.synthdid_estimate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(10, 6)
|
treated_color
|
str
|
|
'#2C3E50'
|
synth_color
|
str
|
|
'#2C3E50'
|
ci_alpha
|
float
|
|
0.15
|
title
|
str
|
|
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.synthdid_estimate(
... df, y="y", unit="unit", time="time",
... treat_unit=0, treat_time=7, seed=42,
... )
>>> fig, ax = sp.synthdid_plot(res)
>>> type(ax).__name__
'Axes'
synthdid_rmse_plot ¶
synthdid_rmse_plot(result: CausalResult, ax: Any = None, figsize: Tuple[float, float] = (8, 5)) -> Tuple[Any, Any]
Pre-treatment RMSE of treated vs synthetic trajectory.
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.synthdid_estimate(
... df, y="y", unit="unit", time="time",
... treat_unit=0, treat_time=7, seed=42,
... )
>>> fig, ax = sp.synthdid_rmse_plot(res)
>>> type(ax).__name__
'Axes'
synthdid_units_plot ¶
synthdid_units_plot(result: CausalResult, top_n: int = 10, ax: Any = None, figsize: Tuple[float, float] = (8, 5)) -> Tuple[Any, Any]
Horizontal bar chart of unit weight contributions.
Replicates synthdid::synthdid_units_plot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
|
required |
top_n
|
int
|
Show the top-N donors by weight. |
10
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> units, periods = 12, 10
>>> unit = np.repeat(np.arange(units), periods)
>>> time = np.tile(np.arange(1, periods + 1), units)
>>> d = (unit == 0) & (time >= 7)
>>> y = (unit * 0.4 + time * 0.2 + 3.0 * d
... + rng.normal(0, 0.3, unit.size))
>>> df = pd.DataFrame({"y": y, "unit": unit, "time": time})
>>> res = sp.synthdid_estimate(
... df, y="y", unit="unit", time="time",
... treat_unit=0, treat_time=7, seed=42,
... )
>>> fig, ax = sp.synthdid_units_plot(res)
>>> type(ax).__name__
'Axes'
synth_donor_sensitivity ¶
synth_donor_sensitivity(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, k: Optional[int] = None, n_samples: int = 100, penalization: float = 0.0, seed: Optional[int] = None, *, covariates: Optional[List[str]] = None, special_predictors: Optional[List[Any]] = None, v_method: Optional[str] = None, standardize_predictors: Optional[bool] = None) -> DataFrame
Donor-pool bootstrap sensitivity for Synthetic Control.
Draws n_samples random subsets of size k from the donor
pool and re-fits SCM for each, producing a distribution of ATT
estimates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
outcome
|
str
|
Outcome variable. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period. |
required |
k
|
int
|
Donor subset size. Default is |
None
|
n_samples
|
int
|
Number of random donor subsets to draw. |
100
|
penalization
|
float
|
Ridge penalty forwarded to SCM. |
0.0
|
seed
|
int
|
Random seed for reproducibility. |
None
|
covariates
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
special_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
v_method
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
standardize_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Examples:
synth_loo ¶
synth_loo(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, penalization: float = 0.0, alpha: float = 0.05, *, covariates: Optional[List[str]] = None, special_predictors: Optional[List[Any]] = None, v_method: Optional[str] = None, standardize_predictors: Optional[bool] = None) -> DataFrame
Leave-one-out donor sensitivity for Synthetic Control.
Re-fits SCM dropping each donor in turn. Identifies influential donors whose removal shifts the ATT substantially.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
outcome
|
str
|
Outcome variable. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period. |
required |
penalization
|
float
|
Ridge penalty forwarded to SCM. |
0.0
|
alpha
|
float
|
Accepted for API symmetry with :func: |
0.05
|
covariates
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
special_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
v_method
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
standardize_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Examples:
synth_rmspe_filter ¶
synth_rmspe_filter(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, thresholds: Optional[List[float]] = None, penalization: float = 0.0, *, metric: str = 'rmspe', placebo_pool: str = 'include_treated', covariates: Optional[List[str]] = None, special_predictors: Optional[List[Any]] = None, v_method: Optional[str] = None, standardize_predictors: Optional[bool] = None) -> DataFrame
Pre-fit-filtered placebo p-values (Abadie et al. 2010).
Runs the in-space placebo SCM on every donor unit, then recomputes the rank p-value of the post/pre RMSPE ratio after discarding placebos whose pre-treatment fit is worse than a multiple of the treated unit's.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
outcome
|
str
|
Outcome variable. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period. |
required |
thresholds
|
list of float
|
Cut-off multiples of the treated unit's pre-treatment fit; a placebo
is kept when its fit is |
None
|
penalization
|
float
|
Ridge penalty. |
0.0
|
metric
|
('rmspe', 'mspe')
|
Scale on which |
'rmspe'
|
placebo_pool
|
('include_treated', 'exclude_treated')
|
Donor pool of each placebo fit. |
'include_treated'
|
covariates
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
special_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
v_method
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
standardize_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Examples:
synth_sensitivity ¶
synth_sensitivity(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, penalization: float = 0.0, n_donor_samples: int = 100, seed: Optional[int] = None, alpha: float = 0.05, standardize_predictors: Optional[bool] = None) -> Dict[str, Any]
Run all SCM sensitivity diagnostics in a single call.
Combines leave-one-out, time placebos, donor pool bootstrap, and pre-RMSPE filtering into one bundled report.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
outcome
|
str
|
Outcome variable. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period. |
required |
penalization
|
float
|
Ridge penalty. |
0.0
|
n_donor_samples
|
int
|
Number of random donor subsets for donor sensitivity. |
100
|
seed
|
int
|
Random seed. |
None
|
alpha
|
float
|
Significance level. |
0.05
|
standardize_predictors
|
bool
|
As in :func: |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
Keys:
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> sens = sp.synth_sensitivity(df, outcome='packspercapita',
... unit='state', time='year', treated_unit='California',
... treatment_time=1989, n_donor_samples=50, seed=42)
>>> sorted(k for k in sens if k != 'summary')
['donor_sensitivity', 'loo', 'original_att', 'rmspe_filter', 'time_placebo']
>>> print(sens['summary'])
synth_sensitivity_plot ¶
synth_sensitivity_plot(sensitivity_result: Dict[str, Any], figsize: Tuple[float, float] = (14, 10), title: Optional[str] = None) -> Any
Multi-panel sensitivity diagnostic plot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sensitivity_result
|
dict
|
Output from :func: |
required |
figsize
|
tuple
|
Figure size in inches. |
(14, 10)
|
title
|
str
|
Super-title for the figure. |
None
|
Returns:
| Type | Description |
|---|---|
Figure
|
|
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> sens = sp.synth_sensitivity(df, outcome='packspercapita',
... unit='state', time='year', treated_unit='California',
... treatment_time=1989, n_donor_samples=50, seed=42)
>>> fig = sp.synth_sensitivity_plot(sens)
>>> fig.savefig('synth_sensitivity.png', dpi=150)
synth_time_placebo ¶
synth_time_placebo(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, penalization: float = 0.0, n_placebo_times: Optional[int] = None, alpha: float = 0.05, *, covariates: Optional[List[str]] = None, special_predictors: Optional[List[Any]] = None, v_method: Optional[str] = None, standardize_predictors: Optional[bool] = None) -> DataFrame
Time-placebo ("backdating") test for Synthetic Control.
Re-fits SCM using fake treatment times drawn from the pre-treatment period. If the method finds large "effects" where none should exist, the original estimate is suspect.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
outcome
|
str
|
Outcome variable. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
Real first treatment period. |
required |
penalization
|
float
|
Ridge penalty forwarded to SCM. |
0.0
|
n_placebo_times
|
int
|
Max number of placebo treatment times to try (a subset drawn with a fixed seed). Default is all feasible pre-treatment times: every pre-period from the third on, so each placebo fit keeps >= 2 pre-periods. Real post-treatment periods are discarded before the placebo fits. |
None
|
alpha
|
float
|
Accepted for API symmetry with :func: |
0.05
|
covariates
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
special_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
v_method
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
standardize_predictors
|
optional
|
The predictor specification of the estimate being checked, as in
:func: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: |
Examples:
sparse_synth ¶
sparse_synth(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, mode: str = 'lasso', lambda_w: Optional[float] = None, lambda_v: Optional[float] = None, covariates: Optional[List[str]] = None, placebo: bool = True, alpha: float = 0.05) -> CausalResult
Sparse Synthetic Control estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel data. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treatment period (inclusive). |
required |
mode
|
('lasso', 'constrained_lasso', 'joint')
|
|
'lasso'
|
lambda_w
|
float or None
|
L1 penalty on donor weights. |
None
|
lambda_v
|
float or None
|
L1 penalty on feature weights ( |
None
|
covariates
|
list of str
|
Additional covariates to append to the pre-treatment outcome matrix before weight estimation. |
None
|
placebo
|
bool
|
Run in-space placebo permutation for inference. |
True
|
alpha
|
float
|
Significance level for confidence interval. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
Called through the sp.synth dispatcher:
>>> import statspai as sp
>>> df = sp.dgp_synth(n_units=10, n_periods=20, treatment_time=15,
... effect=2.0, seed=0)
>>> result = sp.synth(
... df, outcome='y', unit='unit', time='time',
... treated_unit=0, treatment_time=15,
... method='sparse', mode='lasso',
... )
>>> result.method
'Sparse SCM (LASSO)'
staggered_synth ¶
staggered_synth(data: DataFrame, outcome: str, unit: str, time: str, treatment: str, method: Literal['separate', 'pooled'] = 'separate', penalization: float = 0.0, placebo: bool = True, alpha: float = 0.05, *, nu: Union[float, Literal['auto']] = 0.0, fixedeff: bool = False, n_leads: Optional[int] = None, n_lags: Optional[int] = None, se_method: Literal['placebo', 'jackknife'] = 'placebo') -> CausalResult
Staggered Adoption Synthetic Control (Ben-Michael, Feller & Rothstein).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Balanced long-format panel. |
required |
outcome
|
str
|
Outcome variable name. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treatment
|
str
|
Binary, absorbing treatment indicator (0/1). A unit's adoption
period is the first period with |
required |
method
|
('separate', 'pooled')
|
|
'separate'
|
penalization
|
float
|
Ridge penalty |
0.0
|
placebo
|
bool
|
Compute inference (see |
True
|
alpha
|
float
|
Significance level. |
0.05
|
nu
|
float in [0, 1] or 'auto'
|
Pooling parameter. |
0.0
|
fixedeff
|
bool
|
De-mean outcomes by unit before fitting (multisynth |
False
|
n_leads
|
int
|
Number of post-treatment event times in each effect window.
|
None
|
n_lags
|
int
|
Number of pre-treatment periods to balance ( |
None
|
se_method
|
('placebo', 'jackknife')
|
|
'placebo'
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=30, n_periods=10, staggered=True, seed=0)
>>> result = sp.staggered_synth(
... df, outcome='y', unit='unit', time='time',
... treatment='treated', placebo=False,
... )
>>> bool(result.estimate is not None)
True
>>> _ = result.summary()
References
benmichael2022synthetic
synth_survival ¶
synth_survival(data: DataFrame, unit: str, time: str, survival: str, treated: str, treat_time: float, alpha: float = 0.05, n_placebos: int = 100, seed: int = 0) -> SyntheticSurvivalResult
Synthetic Survival Control estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long panel: one row per (unit, time) with a precomputed Kaplan-Meier
survival probability in column |
required |
unit
|
str
|
Unit (panel-id) column. |
required |
time
|
str
|
Time grid column. |
required |
survival
|
str
|
Column containing the survival probability :math: |
required |
treated
|
str
|
Column containing the name of the single treated unit. Accepts either a boolean column or a dedicated string/int identifier. |
required |
treat_time
|
float
|
Time at which treatment starts (times >= |
required |
alpha
|
float
|
Level of the pointwise placebo band. |
0.05
|
n_placebos
|
int
|
Number of donors used as in-space placebos (a random subset when
smaller than the donor pool; all donors otherwise). The band is
pointwise: at each time the effect interval is
|
100
|
seed
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
SyntheticSurvivalResult
|
Fitted counterfactual survival curve, gap trajectory, donor weights, and a pointwise placebo band. |
Examples:
>>> import statspai as sp, numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = [("treated_arm", m, float(np.exp(-0.04 * m)))
... for m in range(12)]
>>> for c in range(6): # six donor arms
... rows += [(f"control_{c}", m, float(np.exp(-(0.05 + 0.01 * c) * m)))
... for m in range(12)]
>>> df = pd.DataFrame(rows, columns=["trial_arm", "month", "km_est"])
>>> r = sp.synth_survival(
... df, unit="trial_arm", time="month",
... survival="km_est", treated="treated_arm", treat_time=6,
... )
>>> _ = r.summary()
synth_ttest ¶
synth_ttest(data: DataFrame, outcome: str, unit: str, time: str, treated_unit: Any, treatment_time: Any, n_folds: int = 3, alpha: float = 0.05, penalization: float = 0.0) -> CausalResult
Debiased synthetic control ATT with a t-test (Chernozhukov, Wüthrich & Zhu).
Let r = min(floor(T0 / K), T1) and take the last K r pre-periods
as K consecutive blocks H_1, ..., H_K of r periods. For each
block the simplex-constrained synthetic control weights w_k are
fitted on the pre-periods outside H_k and
tau_k = mean_post(Y1 - Y0 w_k) - mean_{H_k}(Y1 - Y0 w_k).
The estimate is the mean of the tau_k; its standard error is
sqrt(1 + K r / T1) * sd(tau_k) / sqrt(K) and the reference
distribution is Student's t with K - 1 degrees of freedom. This is
the inference_method = "ttest" procedure of the authors' R package
scinference.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel, one row per unit and period. |
required |
outcome
|
str
|
Outcome column. |
required |
unit
|
str
|
Unit identifier column. |
required |
time
|
str
|
Time period column. |
required |
treated_unit
|
any
|
Identifier of the treated unit. |
required |
treatment_time
|
any
|
First treated period. |
required |
n_folds
|
int
|
Number of blocks |
3
|
alpha
|
float
|
Level of the confidence interval. |
0.05
|
penalization
|
float
|
Ridge penalty on the weights. |
0.0
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Notes
The target is the average effect over the post-period, and the method
needs both the pre-period and the post-period to be long: each block
and the post-period supply an average whose error has to be small. With
stationary errors the K estimates are approximately independent; the
factor 1 + K r / T1 accounts for the post-period average they share.
Examples:
>>> import statspai as sp
>>> df = sp.california_prop99()
>>> res = sp.synth_ttest(df, outcome='packspercapita', unit='state',
... time='year', treated_unit='California',
... treatment_time=1989)
>>> int(res.model_info['df'])
2
References
chernozhukov2026debiasing