Modified Unbiased Synthetic Control

Contents

Modified Unbiased Synthetic Control#

When to Use This Estimator#

Classical synthetic control fits donor weights by minimising the pre-period prediction error of the treated unit’s outcome (Abadie, Diamond and Hainmueller 2010). The resulting estimator’s theoretical guarantees rest on an outcome model — usually a linear factor model — that is assumed to govern the data-generating process. In an applied study you have to defend that model, which is uncomfortable when the panel really is “the 50 U.S. states” or “the OECD economies” and there is no defensible random-sampling story.

Use MUSC, due to Bottmer, Imbens, Spiess and Warnick (2024) [MUSC], when

  • you have a single treated unit and a small donor pool (the paper’s simulations include \(N \in \{5, 10, 50\}\));

  • you are willing to commit to a design-based view in which which unit ends up treated is treated as random, even when the treatment is observational;

  • you want a finite-sample unbiased estimator and an unbiased variance estimator, not just an asymptotic bias bound conditional on an assumed factor model;

  • you want randomization-based confidence intervals that are exact in finite samples under the design-based assumption.

MUSC modifies the synthetic-control quadratic programme with a single additional linear restriction — the column-sums-to-zero condition on the weight matrix — and that one change is enough to make the resulting average-treatment-effect estimator exactly unbiased under random assignment of which unit is treated (Lemma 1).

Note

MUSC is the only estimator in mlsynth that returns an unbiased finite-sample variance estimator: a closed-form, four- term formula (Proposition 1) computed from the weight matrix and the observed outcomes at the treated period. No Monte Carlo, no placebo loop, no asymptotic approximation.

Notation#

We index units by \(i = 1, \dots, N\) (the paper labels the treated unit’s identity as a random variable; we condition on it being unit \(i_*\) when reporting the realised ATT). Time runs over \(t = 1, \dots, T\), with the pre-treatment window \(\mathcal{T}_1 = \{t < t_*\}\) and the post-treatment window \(\mathcal{T}_2 = \{t \ge t_*\}\). The observed outcome panel is \(Y \in \mathbb{R}^{T \times N}\) with element \(Y_{t, j}\).

The Class of Generalised Synthetic-Control Estimators#

Bottmer et al. write every member of the Generalised Synthetic Control (GSC) class as a single linear functional of the outcome matrix parametrised by an \(N \times (N+1)\) weight matrix \(M\):

\[\hat\tau(U, V, Y, M) \;=\; \sum_{i = 1}^{N} \sum_{t = 1}^{T} U_i V_t \left( M_{i, 0} + \sum_{j = 1}^{N} M_{i, j+1} Y_{j, t} \right) \;+\; \sum_{i, t} U_i V_t Y_{i, t},\]

where \(U_i \in \{0, 1\}\) is the treated-unit indicator (\(\sum_i U_i = 1\)), \(V_t \in \{0, 1\}\) is the treated- period indicator (\(\sum_t V_t = 1\)), \(M_{i, 0}\) is a per-row intercept and \(M_{i, j+1}\) is the weight that candidate-treated unit \(i\) places on unit \(j\) when used as a control. The standard SC, DiM, DiD and MSC estimators are all recovered by fixing different subsets of the weight matrix; the distinguishing feature of MUSC is which restriction set \(\mathcal{M}\) it searches over (Table 2 of the paper).

The MUSC Weight-Matrix Constraints#

Four linear restrictions define the MUSC class:

  1. \(M_{i, i+1} = 1\) for all \(i\) (the treated-self loading).

  2. \(M_{i, j+1} \in [-1, 0]\) for all \(i \neq j\) (non-positive control weights, bounded below).

  3. \(\sum_{j = 1}^{N} M_{i, j+1} = 0\) for every \(i\) (the SC adding-up restriction; equivalent to the canonical “weights sum to one” once the sign is flipped).

  4. \(\sum_{i = 1}^{N} M_{i, j+1} = 0\) for every \(j\) (the MUSC unbiasedness restriction — this is the one constraint that distinguishes MUSC from the modified SC of Doudchenko & Imbens (2016)).

The fourth constraint is the only thing that differs from MSC. It costs almost nothing computationally: MUSC and MSC are the same QP with one extra linear equality.

The Quadratic Programme#

Given the pre-treatment outcome matrix \(Y_{\text{pre}} \in \mathbb{R}^{T_0 \times N}\), MUSC solves

\[\min_M \sum_{i = 1}^{N} \sum_{s \in \mathcal{T}_1} \left( M_{i, 0} + \sum_{j = 1}^{N} M_{i, j+1} Y_{j, s} \right)^2\]

subject to restrictions 1-4. The optimisation is convex (quadratic objective, linear constraints) and is implemented through cvxpy with CLARABEL as the default solver. The same QP with restriction 4 dropped is the standard SC estimator (under MSC’s intercept-free modification); mlsynth.MUSC returns both fits so the effect of restriction 4 is visible on the result object.

Assumptions#

Assumption 1 (Random Assignment of Units). Conditional on the potential outcomes \(Y(0)\) and the treated period, the treated unit is drawn uniformly at random from the panel.

Remark. This is the design-based view of Bottmer et al.: even when the empirical setting (50 U.S. states) is not literally a random sample, the analyst commits to analysing the data as if the identity of the treated unit had been randomised — a posture consistent with the placebo-based inference already used throughout the SC literature (Abadie, Diamond & Hainmueller 2010; Firpo & Possebom 2018). MUSC’s main results require this assumption; the randomization CIs become exact under it.

Assumption 2 (Random Assignment of Treated Period; optional). The treated period is drawn uniformly at random from the candidate intervention periods.

Remark. Strengthens unbiasedness from being conditional on \(V\) (the treated period) to being unconditional on \((U, V)\). Useful when the treated period was itself chosen for exchangeability reasons rather than by deliberate selection.

The MUSC Bias Theorem#

Lemma 1. Under Assumption 1, if one of (a) the intercept \(M_{i, 0}\) is zero, or (b) the intercept is unconstrained and fitted from the data, and the weight set \(\mathcal{M}\) imposes \(\sum_i M_{i, j+1} = 0\) for every \(j\), then the GSC estimator is exactly unbiased:

\[\mathbb{E}_{U} [ \hat\tau \mid V ] - \tau \;=\; 0.\]

Remark. This is the theoretical justification for MUSC. Adding the single column-sums-to-zero restriction to the otherwise standard SC weight matrix removes the entire bias term identified in equation 3.2. In mlsynth.MUSC we confirm this empirically: across 50 panel draws the MUSC bias is 0.000000 to machine precision on every draw, while the same QP without the column-sum restriction shows visible per-panel bias (see the Verification section below).

The Unbiased Variance Estimator (Proposition 1)#

Under Assumption 1, the exact conditional variance of any GSC estimator with a time-invariant constraint set is

\[\mathbb{V}(V, M) \;=\; \mathbb{E}_U\!\left[ (\hat\tau(U, V, Y, M) - \tau)^2 \mid V \right] \;=\; \frac{1}{N} \sum_{i = 1}^{N} V_t \left( M_{i, 0} + \sum_{j = 1}^{N} M_{i, j+1} Y_{j, t} \right)^2,\]

and Proposition 1 of the paper gives a closed-form unbiased estimator \(\hat{\mathbb{V}}\) of this variance that depends only on the realised outcomes at the treated period and the weight matrix. The expression has four terms (eq. 3.3 of the paper); the implementation in mlsynth.utils.musc_helpers.unbiased_variance() is a direct port of the paper’s MATLAB reference var_gsc_intercept.m.

For comparison the placebo-based variance estimator commonly used in the SC literature is biased — its sign depends on the data (Section 3.4 of the paper) — while Proposition 1 is unconditionally unbiased under Assumption 1.

Randomization-Based Confidence Intervals (Section 3.5)#

The unbiased variance gives a natural Normal-approximation CI:

\[\hat\tau \;\pm\; z_{1-\alpha/2} \sqrt{\,\hat{\mathbb{V}}\,}.\]

For finite-sample exactness, however, Bottmer et al. propose inverting a permutation test on the placebo distribution. For each non-treated unit \(j\), the leave-one-out estimator is refit pretending \(j\) is treated, giving a placebo ATT \(\hat\tau_j\). Under random assignment the placebo ATTs are draws from the null distribution; the inverted (1 - \alpha) interval based on their order statistics is

\[\tau \in \big[\hat\tau - \hat\beta_{(N(1 - \alpha / 2))},\; \hat\tau - \hat\beta_{(N \alpha / 2)}\big],\]

where \(\hat\beta_{(k)}\) is the \(k\)-th order statistic of the centred placebo distribution. mlsynth.MUSC reports both the Normal CI (inference.ci_normal) and the randomization CI (inference.ci_randomization); the latter is the default surfaced as results.att_ci. Table 6 of the paper shows the randomization CIs attain nominal coverage in their CPS simulation while Normal- approximation CIs may mildly under- or over-cover.

Multiple Treated Units (Appendix D.1)#

The main paper develops MUSC for the single-treated-unit case. Appendix D.1 extends the formulation to \(N_T \ge 2\) treated units by introducing a \(K \times (N+1) \times T\) weight tensor with one row for each of the \(K = \binom{N}{N_T}\) possible treated subsets. The constraint set generalises to

\[\mathcal{M}^{\text{MUSC}} \;=\; \Big\{\,M \;\Big|\; \sum_{j = 1}^N M_{k, j, t} = 0 \;\;\forall\, k, t, \quad \sum_{k = 1}^{K} M_{k, j, t} = 0 \;\;\forall\, j \ge 1, t \,\Big\},\]

and the per-row treated loading becomes \(M_{k, j, t} = 1/N_T\) for every \(j\) in the treated subset (the uniform-treated- weight assumption that the appendix singles out as the natural default).

Under the uniform-weight assumption, the per-row objective collapses to a single-row objective on a synthetic unit whose outcome at every period is the within-period mean of the treated units’ outcomes. This is the reduction mlsynth.MUSC implements: when the panel contains a cohort of \(N_T \ge 2\) treated units sharing the same first treated period, the constituent treated rows are collapsed to that mean and single-unit MUSC is fitted on the resulting panel. The result is theoretically equivalent to the appendix’s K-row formulation under uniform treated weights, and avoids the combinatorial blow-up (\(K = \binom{50}{3} = 19{,}600\) for a typical state-level panel) that makes the exact K-row formulation intractable.

When the treated units have different first treated periods (staggered adoption), mlsynth.MUSC partitions them into cohorts by intervention period and fits MUSC independently on each cohort, drawing donors from the panel-wide pool of never-treated units. The aggregate ATT reported on the result object is the equal- weighted average across cohorts. This is the standard staggered- adoption convention used by mlsynth’s mlsynth.utils.datautils.dataprep and matches the way Forward Difference-in-Differences (FDID), Sequential Synthetic Difference-in-Differences (Sequential SDiD) and Spatial Synthetic Difference-in-Differences (SpSyDiD) aggregate per-cohort estimates.

Note

The exact K-row formulation from Appendix D.1 – without the uniform-treated-weight restriction – is not provided in mlsynth. For typical synthetic-control panels \(K\) grows combinatorially with \(N_T\) and the resulting QP is not tractable. Practitioners who require an unrestricted multi- treated MUSC fit should re-formulate the problem with a smaller \(N\) (e.g. by clustering donors) and the standalone solver.

Routing#

How mlsynth.MUSC.fit() dispatches based on treatment structure.#

Treatment structure

Dispatch

Returned object

one treated unit

single-unit MUSC

MUSCResults

\(N_T \ge 2\) units, all treated at the same period

cohort collapse (uniform-weight)

MUSCResults whose inputs.treated_label is the synthetic cohort label

staggered adoption (multiple intervention periods)

per-cohort MUSC against shared donors

MUSCMultiCohortResults

Example#

A self-contained Monte Carlo: simulate a small linear-factor panel under \(H_0\) (no treatment effect anywhere), fit MUSC, and inspect the column-sum diagnostic that confirms the unbiasedness constraint binds.

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

# ---- one-draw factor panel (15 units x 25 periods, T0 = 20).
rng = np.random.default_rng(0)
N, T_pre, T_post = 15, 20, 5
T = T_pre + T_post
mu = rng.normal(0.0, 0.5, size=N)
eta = rng.normal(0.0, 1.0, size=T); f = np.zeros(T)
for t in range(1, T):
    f[t] = 0.7 * f[t - 1] + eta[t]
lam = rng.normal(1.0, 0.3, size=N)
eps = rng.normal(0.0, 1.0, size=(T, N))
Y = mu[None, :] + f[:, None] * lam[None, :] + eps

# ---- pivot into mlsynth's expected long form.
rows = []
for j in range(N):
    for t in range(T):
        rows.append({
            "unit": f"u{j:02d}", "time": t, "y": float(Y[t, j]),
            "treat": int(j == 0 and t >= T_pre),
        })
df = pd.DataFrame(rows)

res = MUSC({
    "df": df, "outcome": "y", "treat": "treat",
    "unitid": "unit", "time": "time",
    "display_graphs": False, "run_inference": True, "seed": 0,
}).fit()

print(f"SC   col-sum |max abs|: {res.fits['SC'].column_sum_residual:.4f}")
print(f"MUSC col-sum |max abs|: {res.fits['MUSC'].column_sum_residual:.2e}")
print(f"ATT       SC={res.fits['SC'].att:+.3f}  MUSC={res.fits['MUSC'].att:+.3f}")
print(f"V̂ (Prop 1)               = {res.inference.variance:.3f}")
print(f"95% randomization CI = ({res.att_ci[0]:+.3f}, {res.att_ci[1]:+.3f})")

The MUSC column-sum is at machine precision (~1e-14) while the SC column-sum is materially non-zero — the unbiasedness constraint is binding exactly. The Proposition 1 variance estimator returns a finite, non-negative number, and the randomization CI is built from the leave-one-out placebos.

Verification#

Empirical replication against the authors’ Lemma 1 (Path B). The paper’s headline theoretical claim is that the MUSC ATT estimator is exactly unbiased under random unit assignment (Lemma 1), while the standard SC estimator is biased. The architectural test in mlsynth/tests/test_musc.py::TestLemma1Replication reproduces this on the paper’s linear-factor data-generating process and confirms that the empirical bias of MUSC is at machine precision on every Monte Carlo draw, while SC’s bias is bounded away from zero.

import numpy as np
from mlsynth.utils.musc_helpers import (
    att_for_unit, solve_musc_qp,
)

def factor_panel(rng, N=10, T_pre=20, T_post=3,
                   rho=0.7, sigma=1.0, mu_std=0.5):
    T = T_pre + T_post
    mu = rng.normal(0.0, mu_std, size=N)
    eta = rng.normal(0.0, 1.0, size=T); f = np.zeros(T)
    for t in range(1, T): f[t] = rho * f[t - 1] + eta[t]
    lam = rng.normal(1.0, 0.3, size=N)
    eps = rng.normal(0.0, sigma, size=(T, N))
    return mu[None, :] + f[:, None] * lam[None, :] + eps

biases = {"SC": [], "MUSC": []}
for rep in range(50):
    rng = np.random.default_rng(rep)
    Y = factor_panel(rng); T0 = 20
    M_sc, _   = solve_musc_qp(Y[:T0], column_balance=False)
    M_musc, _ = solve_musc_qp(Y[:T0], column_balance=True)
    # Exact expectation under random unit assignment.
    sc_atts   = np.array([att_for_unit(M_sc,   Y, i, T0)[2]
                           for i in range(Y.shape[1])])
    musc_atts = np.array([att_for_unit(M_musc, Y, i, T0)[2]
                           for i in range(Y.shape[1])])
    biases["SC"].append(sc_atts.mean())
    biases["MUSC"].append(musc_atts.mean())

print(f"SC   bias: max|E_U[τ̂]| over 50 panels = "
       f"{np.abs(biases['SC']).max():.3e}")
print(f"MUSC bias: max|E_U[τ̂]| over 50 panels = "
       f"{np.abs(biases['MUSC']).max():.3e}")

prints:

SC   bias: max|E_U[τ̂]| over 50 panels = 3.5e-01
MUSC bias: max|E_U[τ̂]| over 50 panels = 1.7e-15

MUSC’s bias is at machine precision on every one of the 50 panel draws — not just small on average — because the column-sum restriction analytically annihilates the bias formula 3.2, irrespective of the panel. SC’s bias varies by panel and reaches a maximum of ~0.35 in magnitude.

Unbiased variance estimator validation. Proposition 1 says \(\mathbb{E}_Y[\hat{\mathbb{V}}] = \mathbb{E}_Y[\mathrm{Var}_U[\hat\tau]]\) across DGPs. The test in mlsynth/tests/test_musc.py::TestProposition1Replication runs 50 panels and checks that mean(V̂) / mean(Var_U) lies in [0.85, 1.15]; empirically the ratio sits around 0.97-1.00.

Core API#

Modified Unbiased Synthetic Control (MUSC).

A thin, NumPy-first orchestration over mlsynth.utils.musc_helpers. MUSC is the Bottmer, Imbens, Spiess & Warnick (2024 JBES) modification of the Synthetic Control estimator. It adds a single linear restriction to the canonical SC quadratic programme – the column-sums-to-zero condition on the weight matrix – and that single change makes the resulting ATT estimator exactly unbiased under random assignment of which unit is treated (Lemma 1).

In addition to the unbiased point estimator the package ships:

  • mlsynth.utils.musc_helpers.unbiased_variance() – the closed-form Proposition 1 variance estimator (eq. 3.3 of the paper);

  • mlsynth.utils.musc_helpers.randomization_ci() – the exact randomization-based confidence interval of Section 3.5;

  • an SC comparator under the same matrix-form parametrisation, so the effect of adding the column-balance restriction is directly visible on the result object.

class mlsynth.estimators.musc.MUSC(config: MUSCConfig | dict)#

Bases: object

Modified Unbiased Synthetic Control estimator.

Parameters:

config (MUSCConfig or dict) – Validated configuration. Beyond the common fields (df, outcome, treat, unitid, time, display_graphs, save, colours), MUSC reads alpha (significance level for the CIs), run_inference (toggle the Prop 1 variance + randomization CI), and solver (cvxpy solver).

References

Bottmer, L., Imbens, G. W., Spiess, J., & Warnick, M. (2024). A Design-Based Perspective on Synthetic Control Methods. Journal of Business & Economic Statistics, 42(2), 762-773. DOI: 10.1080/07350015.2023.2238788.

fit() MUSCResults | MUSCMultiCohortResults#

Run the MUSC pipeline end to end.

Detects treated cohorts via the treatment indicator, then dispatches:

  • one treated unit -> single-unit MUSC, returns MUSCResults;

  • multiple treated units sharing the same first treated period -> single-cohort MUSC with the constituent units collapsed to their within-period mean (Bottmer et al. 2024 Appendix D.1 uniform-weight version), returns MUSCResults;

  • multiple cohorts with distinct intervention times (staggered adoption) -> per-cohort MUSC fits against a shared never-treated donor pool, returns MUSCMultiCohortResults whose att is the equal-weighted average across cohorts.

Configuration#

class mlsynth.config_models.MUSCConfig(*, 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', alpha: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.05, run_inference: bool = True, solver: str | None = None)#

Configuration for the Modified Unbiased Synthetic Control (MUSC) estimator.

MUSC is the Bottmer, Imbens, Spiess & Warnick (2024 JBES) modification of the synthetic control method: a single column- sums-to-zero linear restriction is added to the standard SC quadratic programme so that the resulting estimator is exactly unbiased under random assignment of which unit is treated (the paper’s Lemma 1).

alpha: 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].

run_inference: bool#
solver: str | None#

Result Containers#

mlsynth.MUSC.fit() returns a MUSCResults whose fits map holds one MUSCVariantFit per restriction-set ("SC" and "MUSC"), and whose inference attribute is a MUSCInference containing the Proposition 1 variance, the Normal-approximation CI, the randomization-based CI, and the leave-one-out placebo ATTs.

Frozen, NumPy-first containers for the Modified Unbiased Synthetic Control (MUSC) estimator.

MUSC is the Bottmer, Imbens, Spiess & Warnick (2024 JBES) modification of the Synthetic Control estimator that adds a column-sums-to-zero restriction on the weight matrix, making the resulting average treatment effect estimator unbiased under random assignment of which unit is treated (Lemma 1 of the paper). Everything below is pure NumPy; the only DataFrame touchpoint is mlsynth.utils.musc_helpers.setup.prepare_musc_inputs().

Units and time are addressed through IndexSet (immutable label-to-integer maps) so downstream code never reaches back into pandas.

class mlsynth.utils.musc_helpers.structures.MUSCCohortFit(intervention_time: Any, treated_units: Tuple[Any, ...], results: MUSCResults)#

Bases: object

A single-cohort MUSC fit inside a staggered design.

Each cohort is the group of units sharing a common intervention period. We collapse the cohort’s treated units to their within- period mean (the uniform-treated-weight version of Bottmer et al. 2024 Appendix D.1, equation D.1, with M_{k,j,t} = 1/N_T for j in the treated subset) and run single-unit MUSC against the panel-wide donor pool (i.e. units that are never treated in any cohort).

intervention_time#

First treated period for the cohort.

Type:

Any

treated_units#

Original treated-unit labels (length N_T).

Type:

Tuple[Any, …]

results#

MUSC fit on the cohort-collapsed panel. results.att is the cohort’s ATT; results.inputs.treated_label is the synthetic cohort label that replaces the constituent treated units.

Type:

MUSCResults

intervention_time: Any#
results: MUSCResults#
treated_units: Tuple[Any, ...]#
class mlsynth.utils.musc_helpers.structures.MUSCInference(variance: float, se: float, ci_normal: Tuple[float, float], ci_randomization: Tuple[float, float], placebo_atts: ndarray, alpha: float)#

Bases: object

Inference outputs for the recommended (MUSC) variant.

Parameters:
  • variance (float) – Proposition-1 unbiased estimate of Var[τ̂] at the first post-treatment period, computed under the random-unit- assignment design (Bottmer et al. 2024, equation 3.3).

  • se (float) – Square root of variance (nan if variance is negative due to finite-sample noise).

  • ci_normal ((float, float)) – Normal-approximation (1 alpha) CI for the ATT.

  • ci_randomization ((float, float)) – Exact randomization-based (1 alpha) CI built by inverting a placebo permutation test (Section 3.5 of the paper). nan entries when the design is too small or the QP failed on every placebo.

  • placebo_atts (np.ndarray) – Placebo ATTs from the leave-one-out estimator applied to each non-treated unit; populates ci_randomization.

  • alpha (float) – Two-sided significance level.

alpha: float#
ci_normal: Tuple[float, float]#
ci_randomization: Tuple[float, float]#
placebo_atts: ndarray#
se: float#
variance: float#
class mlsynth.utils.musc_helpers.structures.MUSCInputs(unit_index: ~mlsynth.utils.fast_scm_helpers.structure.IndexSet, time_index: ~mlsynth.utils.fast_scm_helpers.structure.IndexSet, treated_idx: int, donor_idx: ~numpy.ndarray, Y: ~numpy.ndarray, T0: int, metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

Preprocessed, NumPy-only panel for the MUSC engine.

Parameters:
  • unit_index (IndexSet) – All N units; row order of Y.

  • time_index (IndexSet) – All T periods; column order of Y.

  • treated_idx (int) – Row index (into unit_index) of the treated unit.

  • donor_idx (np.ndarray) – Row indices of the donor pool.

  • Y (np.ndarray) – Outcome panel of shape (N, T) (rows = units).

  • T0 (int) – Number of pre-treatment periods. The first treated period is the first column of Y_post.

  • metadata (dict) – Free-form provenance.

property N: int#
property T: int#
T0: int#
Y: ndarray#
property Y_post: ndarray#

Post-treatment outcomes, shape (T - T0, N) (time-major).

property Y_pre: ndarray#

Pre-treatment outcomes, shape (T0, N) (time-major).

donor_idx: ndarray#
property donor_labels: ndarray#
metadata: Dict[str, Any]#
property n_donors: int#
time_index: IndexSet#
treated_idx: int#
property treated_label: Any#
unit_index: IndexSet#
property y_treated: ndarray#
class mlsynth.utils.musc_helpers.structures.MUSCMultiCohortResults(cohort_fits: ~typing.Dict[~typing.Any, ~mlsynth.utils.musc_helpers.structures.MUSCCohortFit], metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

Top-level container for a multi-cohort (staggered) MUSC fit.

Returned by mlsynth.MUSC.fit() when the panel contains multiple treated units that share more than one distinct intervention period. The per-cohort MUSC fits are computed independently against a shared, never-treated donor pool; the aggregate ATT is the equal-weighted average across cohorts of their cohort-level ATTs (matching the standard staggered-adoption aggregation in mlsynth).

cohort_fits#

Per-cohort fits keyed by intervention_time.

Type:

Dict[Any, MUSCCohortFit]

metadata#

Free-form provenance.

Type:

dict

property att: float#

Equal-weighted average ATT across cohorts.

att_by_cohort() Dict[Any, float]#
cohort_fits: Dict[Any, MUSCCohortFit]#
metadata: Dict[str, Any]#
property n_cohorts: int#
class mlsynth.utils.musc_helpers.structures.MUSCResults(inputs: ~mlsynth.utils.musc_helpers.structures.MUSCInputs, fits: ~typing.Dict[str, ~mlsynth.utils.musc_helpers.structures.MUSCVariantFit], inference: ~mlsynth.utils.musc_helpers.structures.MUSCInference, selected_variant: str = 'MUSC', metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

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

property att: float#
att_by_variant() Dict[str, float]#
property att_ci: Tuple[float, float]#

Randomization-based (1 alpha) CI for the ATT.

property counterfactual: ndarray#
property donor_weights: Dict[Any, float]#
fits: Dict[str, MUSCVariantFit]#
property gap: ndarray#
inference: MUSCInference#
inputs: MUSCInputs#
metadata: Dict[str, Any]#
property pre_rmse: float#
selected_variant: str = 'MUSC'#
class mlsynth.utils.musc_helpers.structures.MUSCVariantFit(name: str, M: ~numpy.ndarray, weights_on_treated: ~numpy.ndarray, intercept: float, counterfactual: ~numpy.ndarray, gap: ~numpy.ndarray, att: float, pre_rmse: float, column_sum_residual: float, donor_weights: ~typing.Dict[~typing.Any, float], metadata: ~typing.Dict[str, ~typing.Any] = <factory>)#

Bases: object

A single estimator variant fit (SC or MUSC).

Parameters:
  • name (str) – Variant label, "SC" or "MUSC".

  • M (np.ndarray) – Full (N, N+1) weight matrix as defined in the paper: the first column is the per-unit intercept; the remaining N columns are the within-row weights, with M[i, i+1] = 1 and off-diagonals in [-1, 0].

  • weights_on_treated (np.ndarray) – Donor weights from the treated unit’s row, length n_donors, in the canonical SC sign (non-negative, summing to one).

  • intercept (float) – The treated unit’s row-intercept M[treated_idx, 0].

  • counterfactual (np.ndarray) – Length-T synthetic counterfactual for the treated unit: -intercept - Σ_j M[treated, j+1] Y_{j, t}.

  • gap (np.ndarray) – Length-T treated-minus-counterfactual gap.

  • att (float) – Mean of gap over the post-treatment window.

  • pre_rmse (float) – Root mean squared error of gap over the pre-treatment window. Used as a simple measure of pre-period fit quality.

  • column_sum_residual (float) – max_j |Σ_i M[i, j+1]| — the binding diagnostic for the MUSC unbiasedness constraint; should be ~0 for MUSC and bounded away from 0 for SC.

  • donor_weights (dict) – Donor-label-keyed dict of the treated unit’s row weights, in canonical SC sign (non-negative).

M: ndarray#
att: float#
column_sum_residual: float#
counterfactual: ndarray#
donor_weights: Dict[Any, float]#
gap: ndarray#
intercept: float#
metadata: Dict[str, Any]#
name: str#
pre_rmse: float#
weights_on_treated: ndarray#

Helper Modules#

The long-DataFrame -> NumPy boundary, the cvxpy quadratic programme, the Proposition 1 variance estimator, the randomization-based CI, the result-assembly orchestration, and the treated-vs-counterfactual plotter — one module each.

Long-DataFrame → NumPy boundary for MUSC (the only pandas touchpoint).

mlsynth.utils.musc_helpers.setup.prepare_musc_inputs(df: DataFrame, *, unitid: str, time: str, outcome: str, treated_unit: Any, intervention_time: Any) MUSCInputs#

Pivot the long panel to NumPy and build the MUSCInputs.

Parameters:
  • df (pd.DataFrame) – Long balanced panel (one row per unit-period).

  • unitid, time, outcome (str) – Column names for the unit id, period index, and outcome.

  • treated_unit (Any) – Label of the treated unit.

  • intervention_time (Any) – First treated period; pre-period is time < intervention_time.

Returns:

MUSCInputs – Pure-NumPy container for the MUSC engine.

MUSC quadratic-program solver.

The estimator implemented here is the Modified Unbiased Synthetic Control of Bottmer, Imbens, Spiess & Warnick (2024), JBES 42(2), 762–773. We use the paper’s matrix-form parametrisation (M R^{N × (N+1)}: a free intercept column plus an N × N weight block) and toggle the column-sums-to-zero constraint to switch between the MUSC variant and the standard SC comparator.

Formally, let Y_pre R^{T_pre × N} be the time-major pre-period outcome matrix. We solve

\[\min_{M} \sum_{i = 1}^{N} \sum_{s \in \mathcal{T}_1} \left( M_{i, 0} + \sum_{j = 1}^{N} M_{i, j} Y_{j, s} \right)^2\]

subject to

  • \(M_{i, i+1} = 1\) for all \(i\) (the treated-self loading);

  • \(M_{i, j+1} \in [-1, 0]\) for all \(i \neq j\) (non-positive control weights, bounded below by -1);

  • \(\sum_{j = 1}^{N} M_{i, j+1} = 0\) for every \(i\) (the SC adding-up restriction, i.e. donor weights sum to 1 in the canonical sign);

  • \(\sum_{i = 1}^{N} M_{i, j+1} = 0\) for every \(j\), only when ``column_balance=True`` (the MUSC unbiasedness restriction; see Lemma 1).

The intercept column is free for both variants — this is the MSC modification of Doudchenko & Imbens (2016), which the paper treats as implicit (see Table 2). The reduction to the standard SC weights when column_balance=False matches the sc_estimator.m baseline in the paper’s MATLAB replication archive.

mlsynth.utils.musc_helpers.estimation.att_for_unit(M: ndarray, Y_full: ndarray, treated_idx: int, T0: int) Tuple[ndarray, ndarray, float, float]#

Convenience: gap, counterfactual, ATT, and pre-RMSE for one unit.

Returns:

  • counterfactual (np.ndarray, length T)

  • gap (np.ndarray, length T (treated minus counterfactual))

  • att (float (mean of gap[T0:]))

  • pre_rmse (float (RMSE of gap[:T0]))

mlsynth.utils.musc_helpers.estimation.predict_counterfactual(M: ndarray, Y_full: ndarray, treated_idx: int) ndarray#

Synthetic counterfactual for the treated unit, length T.

Bottmer et al. parametrise the residual so that the treated-self loading is +1; the synthetic prediction is therefore Y_{treat,t} + Σ_j M[treat, j+1] Y_{j, t}), i.e. the treated outcome minus the row-residual. Equivalently:

synth_t = − M[treat, 0] − Σ_{j ≠ treat} M[treat, j+1] Y_{j, t}.

We return the equivalent Y_{treat, t} residual form because it is numerically the cleanest expression.

mlsynth.utils.musc_helpers.estimation.solve_musc_qp(Y_pre: ndarray, *, column_balance: bool, solver: str | None = None, verbose: bool = False) Tuple[ndarray, str]#

Solve the MUSC (or standard-SC) quadratic programme.

Parameters:
  • Y_pre (np.ndarray) – Pre-treatment outcome matrix of shape (T_pre, N) – time-major: one row per pre-period, one column per unit.

  • column_balance (bool) – When True, impose sum_i M[i, j+1] = 0 for every j 1 – the MUSC unbiasedness restriction. When False the QP reduces to the standard SC estimator (one fit per treated row, the rest of the panel as donors).

  • solver (str, optional) – cvxpy solver name; defaults to "CLARABEL" which ships with cvxpy ≥ 1.4. Override only if benchmarking.

  • verbose (bool) – Forwarded to cvxpy.

Returns:

(M, status) ((np.ndarray, str)) – M is the (N, N+1) weight matrix and status is the cvxpy solver status string. The first column of M is the per-row intercept; columns 1..N are the within-row weights, with M[i, i+1] = 1 and off-diagonals in [-1, 0].

Raises:

MlsynthEstimationError – If the cvxpy solver returns a non-optimal status.

Design-based inference for MUSC.

Two inference outputs from Bottmer, Imbens, Spiess & Warnick (2024):

  1. Proposition 1 unbiased variance estimator (equation 3.3). Under random unit assignment, the conditional variance of any Generalised-Synthetic-Control estimator has a closed-form unbiased estimator that depends only on the weight matrix and the realised outcomes at the treated period. The formula has four terms; we implement them as a single nested loop over candidate treated units, matching the paper’s var_gsc_intercept.m reference.

  2. Randomization-based confidence interval (Section 3.5). For each non-treated unit i, refit MUSC pretending i is treated (leave-one-out), giving placebo ATTs that, under random assignment, are draws from the null distribution of the test statistic. Inverting the resulting permutation test gives an exact (1 alpha) confidence interval for the treated unit’s ATT.

We also expose a Normal-approximation CI keyed off the unbiased variance, which Table 6 of the paper shows can mildly under- or over-cover relative to the randomization-based interval.

mlsynth.utils.musc_helpers.inference.normal_ci_from_variance(att: float, variance: float, alpha: float = 0.05) Tuple[float, float]#

Normal-approximation (1 - alpha) CI for the ATT.

Falls back to (nan, nan) when variance < 0 (which Prop 1 permits in finite samples).

mlsynth.utils.musc_helpers.inference.randomization_ci(Y: ndarray, *, treated_idx: int, T0: int, column_balance: bool, att_observed: float, alpha: float = 0.05, solver: str | None = None) Tuple[Tuple[float, float], ndarray]#

Exact randomization CI for the treated unit’s ATT.

Implements Bottmer et al. (2024) Section 3.5: for each non-treated unit j, refit the estimator pretending j is treated (leave-one-out), giving a placebo ATT τ̂_j. Under the random- unit-assignment design these are draws from the null distribution. The resulting (1 alpha) CI inverts a two-sided permutation test on the order statistics of the placebo ATTs centred on the observed ATT.

Parameters:
  • Y (np.ndarray) – Full (T, N) outcome panel in time-major layout (rows = periods).

  • treated_idx, T0 (int) – Index of the treated unit and the pre-period length.

  • column_balance (bool) – Whether to use the MUSC unbiasedness restriction during refits. Should match the variant of interest.

  • att_observed (float) – The realised ATT of the treated unit.

  • alpha (float, default 0.05) – Two-sided significance level.

  • solver (str, optional) – cvxpy solver name; forwarded to the QP.

Returns:

(ci, placebo_atts) (((float, float), np.ndarray)) – ci is the randomization-based (1 alpha) interval. placebo_atts is the length-(N 1) array of leave-one- out placebo ATTs, sorted ascending. ci is (nan, nan) when too few placebos solved successfully.

mlsynth.utils.musc_helpers.inference.unbiased_variance(M_t: ndarray, y_t: ndarray) float#

Unbiased estimate of Var[τ̂] under random unit assignment.

Direct port of Bottmer et al. (2024) equation 3.3, originally var_gsc_intercept.m in the authors’ archive. The estimator is the mean across candidate treated units of a four-term per-unit expression:

\[\hat V \;=\; \frac{1}{N}\sum_i \Big[ \frac{1}{N-3}\sum_{k \neq i} \big(\sum_{j \neq i, j \neq k} M_{k,j}(Y_k - Y_j)\big)^2 - \frac{1}{(N-2)(N-3)}\sum_{k \neq i}\sum_{j \neq i, j \neq k} M_{k,j}^2 (Y_k - Y_j)^2 + \frac{2}{N-2}\sum_{k \neq i} \alpha_k \sum_{j \neq i, j \neq k} M_{k,j}(Y_j - Y_k) + \frac{1}{N}\sum_{k} \alpha_k^2 \Big],\]

where α_k = M_t[k, 0] is the intercept column and M_{k, j} = M_t[k, j+1] is the weight block.

Parameters:
  • M_t (np.ndarray) – (N, N+1) weight matrix at the treated period. For time- invariant constraint sets (as in MUSC) this is the same M used for prediction.

  • y_t (np.ndarray) – Outcomes at the treated period, length N.

Returns:

float – Unbiased estimate of Var[τ̂]. May be negative in finite samples due to Monte Carlo noise; callers should fall back to nan for the standard error in that case.

High-level fitters that glue setup + estimation + inference together.

mlsynth.utils.musc_helpers.orchestration.collapse_cohort(df: DataFrame, *, unitid: str, time: str, outcome: str, treat: str, treated_units: Sequence[Any], intervention_time: Any, synthetic_label: Any, other_treated_units: Sequence[Any] = ()) DataFrame#

Collapse a cohort of treated units into a single synthetic row.

Implements the uniform-treated-weight version of Bottmer et al. (2024) Appendix D.1: with the constraint M_{k, j, t} = 1 / N_T for j in the treated set, the K-row formulation’s per-row objective reduces to a single-row objective on a synthetic unit whose outcome is the within-period mean of the treated units’ outcomes. We construct that synthetic unit here and drop the constituent treated rows from the panel; the resulting DataFrame is then passed to prepare_musc_inputs() unchanged.

Parameters:
  • df (pd.DataFrame) – Original long panel.

  • unitid, time, outcome, treat (str) – Column names for the unit id, period, outcome, and 0/1 treatment indicator.

  • treated_units (sequence of unit labels) – Units belonging to this cohort.

  • intervention_time (Any) – First treated period for this cohort.

  • synthetic_label (Any) – Label assigned to the synthetic cohort row in the returned DataFrame (must not collide with any existing unit label).

  • other_treated_units (sequence of unit labels, default ()) – Treated units belonging to other cohorts that should be excluded from the donor pool. Mirrors dataprep’s cohort donor-pool convention – only never-treated units serve as donors for any cohort.

Returns:

pd.DataFrame – Modified long panel containing the synthetic cohort row plus all non-treated units. The synthetic row’s treat column is 1 on/after intervention_time and 0 otherwise.

mlsynth.utils.musc_helpers.orchestration.derive_treatment(df: DataFrame, unitid: str, time: str, treat: str) Tuple[Any, Any]#

Identify a single treated unit and its first treated period.

Kept for back-compatibility with single-unit callers. Returns a plain (treated_unit, intervention_time) tuple. Raises if more than one treated unit is found; use derive_treatment_cohorts() for the multi-treated / staggered case.

mlsynth.utils.musc_helpers.orchestration.derive_treatment_cohorts(df: DataFrame, unitid: str, time: str, treat: str) List[Tuple[Tuple[Any, ...], Any]]#

Group treated units into cohorts by intervention period.

A cohort is the set of units that share a common first treated period. The result is a list of (treated_units, intervention_time) tuples sorted by intervention time, where treated_units is a tuple of one or more unit labels.

Parameters:
  • df (pd.DataFrame) – Long balanced panel with a 0/1 treatment indicator.

  • unitid, time, treat (str) – Column names.

Returns:

list of (tuple of unit labels, intervention time) – One entry per cohort; the cohort with the earliest intervention time comes first.

Raises:

MlsynthDataError – If no treated unit is found.

mlsynth.utils.musc_helpers.orchestration.run_musc(inputs: MUSCInputs, *, alpha: float = 0.05, run_inference: bool = True, solver: str | None = None, verbose: bool = False) MUSCResults#

Fit MUSC + SC, compute Prop 1 variance and the randomization CI.

Parameters:
  • inputs (MUSCInputs) – Preprocessed panel.

  • alpha (float, default 0.05) – Two-sided significance level for the CIs.

  • run_inference (bool, default True) – When False, skip both the unbiased-variance computation and the placebo refits (useful for quick exploratory fits and large-N panels where the placebo loop is the bottleneck).

  • solver (str, optional) – Forwarded to cvxpy.

  • verbose (bool) – Forwarded to cvxpy.

Returns:

MUSCResults

mlsynth.utils.musc_helpers.orchestration.run_musc_cohorts(df: DataFrame, *, unitid: str, time: str, outcome: str, treat: str, cohorts: List[Tuple[Tuple[Any, ...], Any]], alpha: float = 0.05, run_inference: bool = True, solver: str | None = None, verbose: bool = False) MUSCMultiCohortResults#

Fit per-cohort MUSC on a multi-treated / staggered panel.

For each cohort the constituent treated units are collapsed to their within-period mean (uniform-treated-weight version of Bottmer et al. 2024 Appendix D.1), and single-unit MUSC is fitted on the resulting panel against a shared pool of never-treated donors – the same convention used by dataprep and the staggered-adoption estimators elsewhere in mlsynth.

Parameters:
  • df (pd.DataFrame) – Long panel.

  • unitid, time, outcome, treat (str) – Column names.

  • cohorts (list of (tuple of unit labels, intervention time)) – As returned by derive_treatment_cohorts().

  • alpha, run_inference, solver, verbose – Forwarded to run_musc() for each cohort.

Returns:

MUSCMultiCohortResults

Treated-vs-counterfactual plot for MUSC.

Renders the treated unit’s observed outcome alongside the MUSC (and optionally SC) counterfactual, with a vertical line at the intervention period – the same shape Bottmer et al. (2024) show as their Figure 3 California-smoking comparison.

mlsynth.utils.musc_helpers.plotter.plot_musc(results: MUSCResults, *, outcome: str = 'outcome', time: str = 'time', treated_color: str = 'black', counterfactual_color: str = 'tab:red', show_sc_baseline: bool = True, save: bool | str | dict = False) None#

Plot the treated outcome against the MUSC counterfactual.

Parameters:
  • results (MUSCResults) – Fitted MUSC results.

  • outcome, time (str) – Axis labels.

  • treated_color (str) – Colour of the treated outcome line.

  • counterfactual_color (str) – Colour of the MUSC counterfactual; the SC baseline is drawn with the same hue at reduced opacity when show_sc_baseline is True.

  • show_sc_baseline (bool, default True) – Overlay the standard-SC counterfactual for comparison.

  • save (bool, str, or dict) – Falsy disables saving. A truthy value saves to save (if a path) or to a default path; dict is forwarded as Figure.savefig kwargs.

References#

[MUSC]

Bottmer, L., Imbens, G. W., Spiess, J., and Warnick, M. (2024). “A Design-Based Perspective on Synthetic Control Methods.” Journal of Business & Economic Statistics 42(2), 762-773. DOI: 10.1080/07350015.2023.2238788.