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\):
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:
\(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).
\(\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).
\(\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
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:
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
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:
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
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
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#
Treatment structure |
Dispatch |
Returned object |
|---|---|---|
one treated unit |
single-unit MUSC |
|
\(N_T \ge 2\) units, all treated at the same period |
cohort collapse (uniform-weight) |
|
staggered adoption (multiple intervention periods) |
per-cohort MUSC against shared donors |
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:
objectModified 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 readsalpha(significance level for the CIs),run_inference(toggle the Prop 1 variance + randomization CI), andsolver(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
MUSCMultiCohortResultswhoseattis 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).
- model_config: ClassVar[ConfigDict] = {'arbitrary_types_allowed': True, 'extra': 'forbid'}#
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
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:
objectA 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_Tforjin 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.attis the cohort’s ATT;results.inputs.treated_labelis the synthetic cohort label that replaces the constituent treated units.- Type:
- results: MUSCResults#
- 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:
objectInference 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(nanifvarianceis 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).nanentries 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.
- placebo_atts: ndarray#
- 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:
objectPreprocessed, NumPy-only panel for the MUSC engine.
- Parameters:
unit_index (IndexSet) – All
Nunits; row order ofY.time_index (IndexSet) – All
Tperiods; column order ofY.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.
- 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#
- time_index: IndexSet#
- 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:
objectTop-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 inmlsynth).- cohort_fits#
Per-cohort fits keyed by
intervention_time.- Type:
Dict[Any, MUSCCohortFit]
- cohort_fits: Dict[Any, MUSCCohortFit]#
- 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:
objectTop-level container returned by
mlsynth.MUSC.fit().- property counterfactual: ndarray#
- fits: Dict[str, MUSCVariantFit]#
- property gap: ndarray#
- inference: MUSCInference#
- inputs: MUSCInputs#
- 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:
objectA 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 remainingNcolumns are the within-row weights, withM[i, i+1] = 1and 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-
Tsynthetic counterfactual for the treated unit:-intercept - Σ_j M[treated, j+1] Y_{j, t}.gap (np.ndarray) – Length-
Ttreated-minus-counterfactual gap.att (float) – Mean of
gapover the post-treatment window.pre_rmse (float) – Root mean squared error of
gapover 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 forMUSCand bounded away from 0 forSC.donor_weights (dict) – Donor-label-keyed dict of the treated unit’s row weights, in canonical SC sign (non-negative).
- M: ndarray#
- counterfactual: ndarray#
- gap: ndarray#
- 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
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} − residualform 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, imposesum_i M[i, j+1] = 0for everyj ≥ 1– the MUSC unbiasedness restriction. WhenFalsethe 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)) –
Mis the(N, N+1)weight matrix andstatusis the cvxpy solver status string. The first column ofMis the per-row intercept; columns1..Nare the within-row weights, withM[i, i+1] = 1and 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):
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.mreference.Randomization-based confidence interval (Section 3.5). For each non-treated unit
i, refit MUSC pretendingiis 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)whenvariance < 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 pretendingjis 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)) –
ciis the randomization-based(1 − alpha)interval.placebo_attsis the length-(N − 1)array of leave-one- out placebo ATTs, sorted ascending.ciis(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.min 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 andM_{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 sameMused 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 tonanfor 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_Tforjin 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 toprepare_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
treatcolumn is 1 on/afterintervention_timeand 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; usederive_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, wheretreated_unitsis 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-
Npanels 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
dataprepand the staggered-adoption estimators elsewhere inmlsynth.- 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_baselineis 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;dictis forwarded asFigure.savefigkwargs.
References#
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.