Partially Pooled SCM (PPSCM)

Contents

Partially Pooled SCM (PPSCM)#

When to Use This Estimator#

PPSCM is a faithful port of augsynth::multisynth – the partially pooled synthetic control of Ben-Michael, Feller and Rothstein [PPSCM] for staggered adoption. Use it when several units are treated but at different times, with a pool of never-treated (or late-treated) comparison units, and you want a single estimate of the average treatment effect on the treated (ATT) over relative time (time-since-treatment), pooling information across cohorts.

The central idea is a pooling dial \(\nu\). Fitting a separate synthetic control for each treated unit gives the best per-unit pre-treatment fit but high variance; a fully pooled control (one synthetic match for the average treated unit) is stable but may fit any individual unit poorly. PPSCM interpolates between the two, choosing \(\nu\) to balance overall and unit-level imbalance. time_cohort=True collapses units sharing an adoption time into a single fully-pooled cohort (one synthetic control per cohort).

The problem PPSCM solves is that the two reflexive extensions of SCM to staggered adoption are each flawed. Separate SCM (fit a synthetic control per treated unit, then average – common practice) requires a good synthetic control for every treated unit, which often fails, and its strong per-unit fits can still leave the average poorly matched, biasing the ATT. Pooled SCM (match the average treated unit) nails the average fit but can fit individual units badly, biasing unit-level effects and the average when the data-generating process drifts over time. Ben-Michael, Feller and Rothstein bound the estimation error by both the average imbalance and the per-unit imbalances, and partially pooled SCM minimises a weighted combination of the two – the regime where neither extreme is trustworthy.

Reach for PPSCM when#

  • Several units are treated at different adoption times, with a pool of never-treated (or not-yet-treated) comparison units.

  • You want an ATT over relative time (an event-study path), pooling information across cohorts rather than trusting each cohort’s own fit.

  • No single donor mix matches every treated unit, so separate SCM leaves you with unreliable per-unit fits – the partial-pooling dial lets the average fit borrow strength without abandoning unit-level fit.

  • You want an estimator that nests the familiar special cases (separate and fully pooled SCM) and a principled way to choose between them.

Do not use PPSCM when#

Notation#

All units \(\mathcal{N} \coloneqq \{1, \ldots, N\}\) are observed over periods \(t \in \mathcal{T} \coloneqq \{1, \ldots, T\}\). A treated unit (or cohort) \(j\) adopts at period \(T_j\); never-treated units have \(T_j = \infty\) and form the donor pool \(\mathcal{N}_0 \coloneqq \{j \in \mathcal{N} : T_j = \infty\}\) of cardinality \(N_0\). The panel is split at the last adoption time, the canonical point \(T_0\), into a pre-period \(\mathcal{T}_1 \coloneqq \{t \in \mathcal{T} : t \le T_0\}\) of length \(T_0\) and a post-period \(\mathcal{T}_2 \coloneqq \{t \in \mathcal{T} : t > T_0\}\). For cohort \(j\), donor weights \(\mathbf{w}_j\) live on the simplex \(\Delta^{N_0} \coloneqq \{\mathbf{w} \in \mathbb{R}_{\ge 0}^{N_0} : \|\mathbf{w}\|_1 = 1\}\); the synthetic control matches the cohort’s pre-treatment residuals. The per-period effect is \(\tau_t\) and the average treatment effect on the treated is \(\widehat{\tau}\).

Method#

PPSCM follows multisynth in three stages.

1. Two-way fixed effects (fixedeff=True, the default). A time effect is the never-treated units’ per-period mean; a unit effect is each unit’s mean over its own pre-adoption window. Both are removed and the synthetic control balances the residuals – the “intercept-shifted” estimator of the paper.

2. Partially pooled QP. With per-cohort pre-treatment imbalance \(\mathbf{q}_j \coloneqq \mathbf{x}_j - \mathbf{X}_{0,j}\mathbf{w}_j\) (residuals; the pooled imbalance aligned by relative time), the weights solve

\[\min_{\{\mathbf{w}_j \in \Delta^{N_0}\}} \; \frac{\nu}{\text{norm}_{\text{pool}}\,J^2} \Bigl\|\textstyle\sum_j \mathbf{q}_j\Bigr\|^2 + \frac{1-\nu}{\text{norm}_{\text{sep}}\,J} \sum_j \frac{\|\mathbf{q}_j\|^2}{\text{ndim}_j} + \lambda \sum_j \|\mathbf{w}_j\|^2 ,\]

where \(\text{norm}_{\text{pool}}\) and \(\text{norm}_{\text{sep}}\) are the separate-fit (nu=0) global and individual imbalance norms. Small \(\nu\) approaches a separate SCM per cohort; large \(\nu\) a fully pooled SCM.

3. Choosing \(\nu\). With nu="auto" (default) PPSCM uses augsynth’s triangle-inequality ratio \(\nu = \text{global\_l2}\cdot\sqrt{T_0}/\text{avg\_l2}\) from the separate fit; a float fixes it.

Assumptions / Remarks.

Assumption 1 (no anticipation, parallel residual trends). After removing the two-way fixed effects, the treated cohorts’ residual paths would have matched a convex combination of donor residual paths absent treatment. Remark. This is the staggered-adoption analogue of the SCM identifying assumption; the fixed effects absorb level and common-time shifts so the weights only need to match the residual dynamics.

Assumption 2 (overlap / donor availability). Each cohort has eligible donors – never-treated units, or units treated more than n_leads periods later. Remark. Late-treated units can serve as “clean” controls for earlier cohorts until they themselves are treated, which the donor-eligibility rule enforces.

Remark (pooling). \(\nu\) is a bias–variance dial, not an identification parameter: the estimand (the wATET over the treated cohorts) is the same; \(\nu\) only trades per-cohort fit against stability of the pooled average.

Auxiliary covariates#

By default PPSCM matches on the pre-treatment outcome path alone. Passing covariates=[...] also balances a set of auxiliary covariates, following the paper’s Section 5.2. Each covariate is z-scored against the never-treated controls and rescaled to the outcome scale, so covariate and outcome imbalance share a footing; the covariate imbalance is then stacked into both the pooled and the separate terms of the partially-pooled objective. Time-varying covariates are aggregated to their mean over the periods before the first adoption. Balancing covariates typically improves covariate balance at a small cost to the pre-treatment outcome fit – the usual bias/variance trade of matching on more.

res = PPSCM({"df": df, "outcome": "y", "treat": "d",
             "unitid": "unit", "time": "period",
             "covariates": ["income_1959", "student_teacher_ratio_1959"]}).fit()

This reproduces augsynth::multisynth’s covariate mode (y ~ d | income + ratio); see PPSCM — augsynth multisynth (Paglayan collective bargaining) and the ppscm_paglayan_covs benchmark for the cell-by-cell cross-check against a live augsynth 0.2.0 run.

Inference#

PPSCM reports the paper’s delete-one jackknife: drop each unit, refit the full estimator (holding \(\nu\) fixed), and form \(\widehat{\text{se}}^2 = \tfrac{N-1}{N}\sum_{j \in \mathcal{N}}(\widehat{\tau}_j - \bar{\tau})^2\) for the overall ATT and each relative-time horizon, with Wald intervals.

Per-unit fits alongside the pooled report#

Because partially pooled SCM fits a separate synthetic control per treated unit (or per cohort with time_cohort=True) and averages them into the ATT, the unit-level estimates are the components of the pooled one – so both are read off a single fit. results.per_unit is a dict keyed the same as donor_weights_by_cohort; each value is a PPSCMUnitFit carrying the unit’s att, its relative-time tau path, its donor_weights, its adoption time and member units, and its in-sample fit prefit_rmspe – the root-mean-square pre-treatment imbalance \(q_j\) of that unit’s synthetic control.

Each PPSCMUnitFit additionally carries a per-unit prediction interval on its time-averaged effect – ci_lower / ci_upper with a band-implied p_value – populated when run_inference is on. It is built by the CFPT/SCPI out-of-sample interval engine (mlsynth.utils.scpi_helpers, the same machinery behind MSQRT’s bands), applied to each unit’s own pre-period residuals and post-period gap with the synthetic-control weights held fixed. This is the per-unit analogue of the pooled inference above: the delete-one jackknife (or bootstrap) quantifies uncertainty across units for the aggregate, whereas the per-unit SCPI band quantifies each unit’s own effect. A naive permutation over the QP-optimised pre-period residuals would over-reject – the fit makes those residuals small, so they are not exchangeable with the post-period gaps – which is why the per-unit band uses the SCPI construction rather than a residual permutation.

The two levels reconcile exactly, so the unit-level and pooled reports never disagree: the reported separate imbalance design.ind_l2 equals \(\sqrt{\tfrac1J\sum_j q_j^2}\), and the n_units-weighted per-horizon average of the unit tau paths reproduces event_study.tau and hence the aggregate effects.att. This makes it a one-line switch to serve either request – pooled error via design.ind_l2 / global_l2 and the aggregate ATT, or per-unit estimates and their in-sample error via results.per_unit.

A caveat worth surfacing to whoever reads the unit-level numbers: at a high \(\nu\) (heavily pooled), the per-unit synthetic controls fit poorly, so a unit’s att is only as trustworthy as its prefit_rmspe – read the two together, and prefer a lower \(\nu\) (toward separate SCM) when unit-level estimates are the deliverable.

res = PPSCM(config).fit()
res.design.ind_l2                       # pooled/separate in-sample error
res.effects.att                         # aggregate ATT
for label, uf in res.per_unit.items():  # per-unit estimates + in-sample error
    print(label, uf.att, uf.prefit_rmspe)

Empirical Illustration: mandatory collective bargaining#

The multisynth vignette studies the effect of state mandatory collective-bargaining laws on log per-pupil education expenditure (Paglayan 2018), a staggered design. basedata/Teachingaugsynth.scv ships the panel; the analysis restricts to 1959–1997, drops DC and Wisconsin, and treats a state from the year it required bargaining.

import numpy as np
import pandas as pd
from mlsynth import PPSCM

url = "https://raw.githubusercontent.com/jgreathouse9/mlsynth/refs/heads/main/basedata/Teachingaugsynth.scv"
df = pd.read_csv(url)
df = df[~df["State"].isin(["DC", "WI"])]
df = df[(df["year"] >= 1959) & (df["year"] <= 1997)].copy()
df["cbr"] = (df["year"] >= df["YearCBrequired"].fillna(np.inf)).astype(int)

res = PPSCM({"df": df, "outcome": "lnppexpend", "treat": "cbr",
             "unitid": "State", "time": "year", "display_graphs": True}).fit()

print(f"nu (auto)   : {res.design.nu_used:.4f}")
print(f"Average ATT : {res.att:.3f}  (SE {res.inference.se:.3f})")

This prints:

nu (auto)   : 0.2607
Average ATT : -0.011  (SE 0.020)

reproducing the augsynth vignette (nu = 0.2607, Average ATT -0.011). Setting time_cohort=True collapses to adoption-time cohorts and gives nu = 0.3939, Average ATT -0.017 (augsynth: -0.018).

Verification#

Note

Exact replication of augsynth. On the Paglayan data PPSCM matches augsynth::multisynth to high precision: the auto-\(\nu\) agrees to four decimals (0.2607 default, 0.3939 time-cohort), the Average ATT matches (\(-0.011\) default; \(-0.017\) vs \(-0.018\) time-cohort), and the raw global/individual L2 imbalances agree (0.003 / 0.028). The full relative-time event study matches the vignette’s per-horizon averages to 3–4 decimals. The decisive fidelity detail is aligning the pooled imbalance by relative time on top of two-way fixed effects. The jackknife SE (0.020) is close to augsynth’s default wild-bootstrap SE (0.022); they differ only by inference procedure. This is locked in by test_matches_augsynth_vignette in mlsynth/tests/test_ppscm.py.

Core API#

Partially Pooled Synthetic Control (PPSCM) estimator.

A thin orchestration over mlsynth.utils.ppscm_helpers, faithfully porting augsynth::multisynth:

Ben-Michael, E., Feller, A., & Rothstein, J. (2022). “Synthetic Controls with Staggered Adoption.” JRSS-B 84(2):351-381.

PPSCM removes two-way fixed effects, balances the residuals with a partially-pooled QP (nu interpolating between separate and fully pooled SCM), and reports a relative-time event study and overall ATT with the paper’s delete-one jackknife. time_cohort=True collapses units sharing an adoption time into one fully-pooled cohort.

class mlsynth.estimators.ppscm.PPSCM(config: PPSCMConfig | dict)#

Bases: object

Partially Pooled SCM estimator (augsynth::multisynth port).

Parameters:

config (PPSCMConfig or dict) – Validated configuration. Reads nu (pooling, or "auto"), fixedeff, n_leads, n_lags, time_cohort, lam, run_inference and alpha beyond the common panel fields.

Returns:

PPSCMResults – Design (pooling level + balance diagnostics), relative-time event study, overall ATT with jackknife inference, donor weights, and per_unit – the per-treated-unit (or per-cohort) fits (att, in-sample prefit_rmspe, tau path, donor weights) that are the components of the pooled estimate and reconstruct design.ind_l2 and the aggregate ATT.

fit() PPSCMResults#

Fit PPSCM and return the typed result container.

Configuration#

class mlsynth.config_models.PPSCMConfig(*, df: ~pandas.DataFrame, outcome: str, treat: str, unitid: str, time: str, display_graphs: bool = True, save: bool | str = False, counterfactual_color: ~typing.List[str] = <factory>, treated_color: str = 'black', plot: ~mlsynth.config_models.PlotConfig = <factory>, nu: float | ~typing.Literal['auto'] = 'auto', fixedeff: bool = True, n_leads: ~typing.Annotated[int | None, ~annotated_types.Ge(ge=1)] = None, n_lags: ~typing.Annotated[int | None, ~annotated_types.Ge(ge=1)] = None, time_cohort: bool = False, lam: ~typing.Annotated[float, ~annotated_types.Ge(ge=0)] = 0.0, solver: ~typing.Any = None, run_inference: bool = True, inference_method: str = 'jackknife', n_boot: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 1000, seed: int = 0, alpha: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.05, covariates: ~typing.List[str] | None = None)#

Configuration for the Partially Pooled SCM (PPSCM) estimator.

Implements Ben-Michael, Feller & Rothstein (2022, JRSS-B 84(2):351-381). Targets staggered-adoption designs by minimizing a weighted average of the per-treated-unit imbalance q_sep and the average-treated imbalance q_pool, with weighting hyper- parameter nu.

alpha: float#
covariates: List[str] | None#
fixedeff: bool#
inference_method: str#
lam: float#
model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

n_boot: int#
n_lags: int | None#
n_leads: int | None#
nu: float | Literal['auto']#
run_inference: bool#
seed: int#
solver: Any#
time_cohort: bool#

Result Containers#

PPSCM.fit() returns a PPSCMResults: the PPSCMDesign (pooling level and balance diagnostics), the relative-time PPSCMEventStudy, the overall PPSCMInference, and the per-cohort donor weights.

Typed, NumPy-first result containers for Partially Pooled SCM (staggered).

PPSCM ports augsynth::multisynth (Ben-Michael, Feller & Rothstein 2022): a partially-pooled synthetic control for staggered adoption that interpolates, via nu, between a separate SCM per treated unit (nu small) and a fully pooled SCM (nu large), on top of two-way fixed effects.

class mlsynth.utils.ppscm_helpers.structures.PPSCMDesign(nu_used: float, lam: float, fixedeff: bool, time_cohort: bool, n_leads: int, n_lags: int, global_l2: float, ind_l2: float, scaled_global_l2: float, scaled_ind_l2: float)#

Bases: object

The fitted design: pooling level and balance diagnostics.

fixedeff: bool#
global_l2: float#
ind_l2: float#
lam: float#
n_lags: int#
n_leads: int#
nu_used: float#
property pct_improve_global: float#
property pct_improve_ind: float#
scaled_global_l2: float#
scaled_ind_l2: float#
time_cohort: bool#
class mlsynth.utils.ppscm_helpers.structures.PPSCMEventStudy(horizons: ndarray, tau: ndarray, se: ndarray, ci: ndarray)#

Bases: object

Relative-time (time-since-treatment) average ATT path.

ci: ndarray#
horizons: ndarray#
se: ndarray#
tau: ndarray#
class mlsynth.utils.ppscm_helpers.structures.PPSCMInference(att: float, se: float, ci: Tuple[float, float], method: str)#

Bases: object

Overall (post-period average) ATT and its inference.

att: float#
ci: Tuple[float, float]#
method: str#
se: float#
class mlsynth.utils.ppscm_helpers.structures.PPSCMInputs(Xy: ndarray, trt: ndarray, n_pre: int, time_labels: ndarray, units: ndarray, outcome: str, intervention_time: Any, Z: ndarray | None = None, cov_names: tuple | None = None)#

Bases: object

Preprocessed staggered panel (the only pandas touchpoint is setup).

Parameters:
  • Xy (np.ndarray) – Full outcome matrix, shape (n, T) (units x all periods).

  • trt (np.ndarray) – Adoption index per unit (position in time_labels); inf for never-treated controls.

  • n_pre (int) – Number of pre-treatment periods (columns before the last adoption).

  • time_labels (np.ndarray) – Sorted time labels, length T.

  • units (np.ndarray) – Unit labels, length n.

  • outcome (str) – Outcome column name.

  • intervention_time (Any) – The last adoption time (pre/post split point).

  • Z (np.ndarray or None) – Per-unit auxiliary-covariate matrix, shape (n, d_cov), aggregated to the pre-first-adoption mean. None when no covariates are given.

  • cov_names (tuple of str or None) – Names of the covariate columns, length d_cov.

Xy: ndarray#
Z: ndarray | None = None#
property control_units: ndarray#
cov_names: tuple | None = None#
intervention_time: Any#
property n: int#
n_pre: int#
outcome: str#
time_labels: ndarray#
property treated_units: ndarray#
trt: ndarray#
units: ndarray#
class mlsynth.utils.ppscm_helpers.structures.PPSCMResults(*, effects: ~mlsynth.config_models.EffectsResults | None = None, fit_diagnostics: ~mlsynth.config_models.FitDiagnosticsResults | None = None, time_series: ~mlsynth.config_models.TimeSeriesResults | None = None, weights: ~mlsynth.config_models.WeightsResults | None = None, inference: ~mlsynth.config_models.InferenceResults | None = None, method_details: ~mlsynth.config_models.MethodDetailsResults | None = None, sub_method_results: ~typing.Dict[str, ~typing.Any] | None = None, additional_outputs: ~typing.Dict[str, ~typing.Any] | None = None, raw_results: ~typing.Dict[str, ~typing.Any] | None = None, execution_summary: ~typing.Dict[str, ~typing.Any] | None = None, plot_config: ~mlsynth.config_models.PlotConfig | None = None, inputs: ~mlsynth.utils.ppscm_helpers.structures.PPSCMInputs, design: ~mlsynth.utils.ppscm_helpers.structures.PPSCMDesign, event_study: ~mlsynth.utils.ppscm_helpers.structures.PPSCMEventStudy, inference_detail: ~mlsynth.utils.ppscm_helpers.structures.PPSCMInference, donor_weights_by_cohort: ~typing.Dict[~typing.Any, ~typing.Dict[~typing.Any, float]], per_unit: ~typing.Dict[~typing.Any, ~mlsynth.utils.ppscm_helpers.structures.PPSCMUnitFit] = <factory>, metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: BaseEstimatorResults

Top-level container returned by mlsynth.PPSCM.fit().

An EffectResult. PPSCM is a staggered / partially-pooled estimator, so the standardized time_series carries the pooled event-time effect path (gap = horizon effect, counterfactual = no-effect baseline), and effects.att is the aggregate ATT – mirroring the SequentialSDID convention. The native objects are preserved: inference_detail (the PPSCMInference, formerly inference) and donor_weights_by_cohort (the nested per-cohort weights, formerly donor_weights); the contract names inference / donor_weights are taken by the base contract.

design: PPSCMDesign#
donor_weights_by_cohort: Dict[Any, Dict[Any, float]]#
event_study: PPSCMEventStudy#
inference_detail: PPSCMInference#
inputs: PPSCMInputs#
metadata: Dict[str, Any]#
model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid', 'frozen': True, 'json_encoders': {<class 'numpy.ndarray'>: <function BaseEstimatorResults.Config.<lambda>>}}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

property nu: float#
per_unit: Dict[Any, PPSCMUnitFit]#
class mlsynth.utils.ppscm_helpers.structures.PPSCMUnitFit(label: str, adoption_time: Any, member_units: List[str], n_units: int, att: float, prefit_rmspe: float, tau: ndarray, pre_imbalance: ndarray, donor_weights: Dict[Any, float], ci_lower: float | None = None, ci_upper: float | None = None, p_value: float | None = None)#

Bases: object

The synthetic-control fit for one treated unit (or cohort) in the pool.

Partially-pooled SCM fits a separate synthetic control per treated unit (or per adoption cohort with time_cohort=True) and averages them into the ATT, so these are the components of the pooled estimate at the chosen nu – not a separate re-run. The two aggregates reconstruct exactly: the reported design.ind_l2 equals sqrt(mean_j prefit_rmspe_j**2), and the n1-weighted per-horizon average of the tau paths reproduces the pooled event study.

label#

The unit label (time_cohort=False) or adoption-time label (time_cohort=True); matches the key in donor_weights_by_cohort.

Type:

str

adoption_time#

The (public) time label at which this unit / cohort adopts treatment.

Type:

Any

member_units#

Treated unit label(s) in this group (one unless a cohort pools several).

Type:

list of str

n_units#

Cohort size (len(member_units)); the aggregation weight n1.

Type:

int

att#

This unit’s/cohort’s average post-treatment effect (mean of tau).

Type:

float

prefit_rmspe#

Pre-treatment in-sample fit error q_j – the root-mean-square pre-period imbalance of this synthetic control (residual, fixed-effect- removed space, matching the estimator’s balance objective). A large value flags a poorly fit unit whose att should not be over-trusted (the nu-pooling caveat). Aggregates to design.ind_l2.

Type:

float

tau#

Relative-time effect path (length n_leads); NaN past this unit’s observed horizon.

Type:

np.ndarray

pre_imbalance#

The pre-treatment imbalance vector (front-padded to the balance window) whose weighted RMS is prefit_rmspe; the per-period in-sample residual.

Type:

np.ndarray

donor_weights#

{donor_label: weight} for this unit’s synthetic control (nonneg, sums to 1).

Type:

dict

adoption_time: Any#
att: float#
ci_lower: float | None = None#
ci_upper: float | None = None#
donor_weights: Dict[Any, float]#
label: str#
member_units: List[str]#
n_units: int#
p_value: float | None = None#
pre_imbalance: ndarray#
prefit_rmspe: float#
tau: ndarray#

Helper Modules#

Staggered long-to-wide formatting (the only DataFrame touchpoint): derive adoption times, split pre/post at the last adoption.

Long-DataFrame -> NumPy boundary for PPSCM (staggered adoption).

Mirrors augsynth::format_data_stag: derive each unit’s first treated period, split the panel at the last adoption time into pre (X) and post (y), and index adoption by position in the sorted time vector (Inf for never-treated).

mlsynth.utils.ppscm_helpers.setup.prepare_ppscm_inputs(df: DataFrame, *, outcome: str, treat: str, unitid: str, time: str, covariates: List[str] | None = None) PPSCMInputs#

The engine: two-way fixed effects (fit_feff), the partially-pooled QP, auto-\(\nu\), and the relative-time event study / ATT.

Core staggered-adoption engine for PPSCM, ported faithfully from augsynth::multisynth (Ben-Michael, Feller & Rothstein 2022).

Pipeline (one call = one fit):
  1. fit_feff removes fixed effects (force=3 two-way: time effect from never-treated column means + per-cohort unit pre-mean) and balances the residuals.

  2. solve_cohort_qp solves the partially-pooled QP over donor weights, with the pooled imbalance aligned by relative time (front-padded) and the pooled/separate terms normalized by the separate fit’s norms.

  3. run_multisynth chooses nu (triangle-inequality ratio when “auto”), refits, and produces the relative-time event study and ATT.

Validated to reproduce the multisynth vignette exactly (default nu=0.2607, ATT=-0.011; time_cohort nu=0.3939, ATT=-0.017).

mlsynth.utils.ppscm_helpers.engine.fit_feff(Xy: ndarray, trt: ndarray, adopt_indices, fixedeff: bool) Dict[int, ndarray]#

Residualize Xy per cohort.

Returns {adoption_index: residual_matrix (n, T)}. With fixedeff the time effect is the never-treated column mean and the unit effect is each unit’s mean residual over its pre-adoption window [:tj]; without it, only the time effect (control averages) is removed.

mlsynth.utils.ppscm_helpers.engine.predict_tau(res, groups, adopt_of, members, donors, W, n1, H, n, bs_weight=None)#

Relative-time tau per cohort, plus the n1-weighted event study and ATT.

With bs_weight (per-unit multipliers, default all ones) this is augsynth’s predict.multisynth(bs_weight=...) written in residual space: the fixed-effect terms cancel between the treated mean and the synthetic, leaving the treated residuals (scaled by bs_weight, averaged over the cohort) minus the donor residuals weighted by W[g] * bs_weight. bs_weight = ones reproduces the point estimate exactly.

mlsynth.utils.ppscm_helpers.engine.run_multisynth(Xy: ndarray, trt: ndarray, d: int, n_leads: int, n_lags: int, *, fixedeff: bool = True, time_cohort: bool = False, nu: float | None = None, lam: float = 0.0, solver: Any = None, Z: ndarray | None = None) Dict[str, Any]#

Run one multisynth fit; returns weights, event study, ATT, diagnostics.

mlsynth.utils.ppscm_helpers.engine.solve_cohort_qp(res, groups, adopt_of, members, donors, n1, d, n, n_lags, nu, norm_pool, norm_sep, lam, solver, zt=None, Zc=None) Dict[Any, ndarray]#

Partially-pooled QP: per-cohort simplex weights (summing to cohort size).

When zt/Zc (per-cohort scaled auxiliary-covariate target sums and donor blocks) are supplied, the covariate imbalance is stacked into the pooled and separate terms (normalized by the number of covariates), following augsynth::multisynth Sec 5.2.

The paper’s delete-one jackknife inference.

Delete-one jackknife inference for PPSCM (Ben-Michael et al. 2022).

The paper’s jackknife drops each unit i (treated or control), refits the full staggered estimator on the remaining n - 1 units (holding nu fixed), and forms

se^2 = (n - 1) / n * sum_i (theta_i - mean_i theta_i)^2

separately for the overall ATT and each relative-time horizon. Wald intervals are built from these SEs around the full-sample point estimates.

mlsynth.utils.ppscm_helpers.inference.bootstrap_inference(fit: dict, *, alpha: float, n_boot: int, seed: int, per_time_full: ndarray, att_full: float)#

augsynth’s default Mammen wild/multiplier bootstrap (weighted_bootstrap_multi).

Reweights the single fit by per-unit multipliers Z (no refit): for each draw, predict_tau(bs_weight=Z) - (sum(Z)/n_treated) * point_estimate; the bootstrap SE is the root-mean-square of the centered draws. Returns (att, se, ci, per_time_se, per_time_ci) matching jackknife_inference.

mlsynth.utils.ppscm_helpers.inference.jackknife_inference(Xy: ndarray, trt: ndarray, d: int, n_leads: int, n_lags: int, *, fixedeff: bool, time_cohort: bool, nu_used: float, lam: float, solver: Any, alpha: float, per_time_full: ndarray, att_full: float) Tuple[float, float, Tuple[float, float], ndarray, ndarray]#

Return (att, se, ci, per_time_se, per_time_ci).

mlsynth.utils.ppscm_helpers.inference.per_unit_intervals(M: ndarray, tau_rel: ndarray, *, alpha: float, time_dependence: str = 'iid') Tuple[ndarray, ndarray, ndarray]#

Per-unit CFPT/SCPI prediction intervals for each unit’s time-averaged ATT.

The pooled bootstrap / jackknife measures variability across units and so cannot give one treated unit its own interval. This builds a per-unit band from that unit’s own fit and reuses mlsynth’s out-of-sample interval engine (the same CFPT/SCPI machinery MSQRT uses), so PPSCM’s per-unit bands are methodologically consistent with MSQRT’s.

For unit k the band comes from its post-period effect path tau_rel[k, :] (the CFPT effects) and its pre-period residuals M[:, k] (the CFPT pre_residuals): the residual moments set the sub-Gaussian scale of the counterfactual prediction error, which correctly accounts for the in-sample fit – unlike a naive permutation over the QP-optimised pre-residuals, which are not exchangeable with the post gaps and over-reject. The engine is called per unit (one column at a time), so units with different post horizons (ragged NaN) are handled by trimming.

Parameters:
  • M (numpy.ndarray) – Pre-period residual columns, shape (d, J) (a 1-D array is a single unit). NaN entries are dropped per unit.

  • tau_rel (numpy.ndarray) – Post-period relative-time effect paths, shape (J, H) (a 1-D array is a single unit). Trailing NaN (past a unit’s horizon) is dropped.

  • alpha (float) – Total miscoverage level; the interval is 100 * (1 - alpha) percent. Keyword-only.

  • time_dependence ({“iid”, “general”}, default “iid”) – Time-averaging bound passed through to the CFPT engine. Keyword-only.

Returns:

tuple of numpy.ndarray(ci_lower, ci_upper, p_value), each shape (J,): the per-unit band bounds on the time-averaged ATT and a band-implied two-sided p-value (the house convention 2 * (alpha/2) ** ((point/half_width) ** 2), clamped to [0, 1]). A unit with no usable residuals yields NaN.