Distributionally Robust Synthetic Control (DROSC)#

Overview#

Classical synthetic control estimates a treatment effect by matching the treated unit’s pre-treatment outcomes with a weighted average of control units, and then assumes that the same weighting carries over to the post-treatment period. Two things break that assumption in practice. When the control units are highly correlated, many different weight vectors fit the pre-period almost equally well, so the chosen weights – and the effect read off them – are unstable. And when the relationship between the treated unit and the controls shifts across the intervention, the pre-period weighting no longer identifies the counterfactual at all.

DROSC, the method of Koo & Guo (2026), replaces the single fitted weight vector with a worst-case over an entire set of weights: every weight vector compatible with the pre-treatment moments up to a radius \(\lambda\). At \(\lambda = 0\) the set is tightest and DROSC agrees with classical synthetic control; as \(\lambda\) grows the set widens and the estimate becomes a deliberately conservative proxy for the effect that the data can no longer pin down. Sweeping \(\lambda\) is an honest sensitivity display: it shows how much robustness the conclusion can absorb before the effect is no longer distinguishable from zero.

When to use this estimator#

Reach for DROSC when the donor pool is highly collinear – many similar regions, stores, or products, so the classical weights are non-unique and fragile – or when you suspect the treated-control relationship shifts across the intervention and want an effect and interval that stay valid when the standard identification does not. The robustness radius is the dial: report the effect as a function of \(\lambda\), not a single number.

If your donors are well separated and the standard assumptions are credible, the plain estimators (Vanilla Synthetic Control (VanillaSC)) are more efficient – DROSC’s conservatism is wasted. If the fragility is specifically pre-fit non-uniqueness and you only need a stable tie-break rather than a robust interval, Lexicographic Synthetic Control (LEXSCM) resolves the non-uniqueness lexicographically instead. DROSC targets a different, robust estimand; do not read its number as an estimate of the same quantity classical synthetic control targets when the two disagree.

Notation#

The treated unit’s outcome is \(y_t\); the \(N\) donors’ outcomes are the rows of \(\mathbf{X}_t\). Time splits at \(T^\star = T_0 + 1\) into the pre-period \(t \le T_0\) and the post-period. Write the pre-treatment donor second-moment matrix and the treated-donor cross-moment as

\[\widehat{\boldsymbol{\Sigma}} = \frac{1}{T_0}\sum_{t \le T_0} \mathbf{X}_t \mathbf{X}_t^\top, \qquad \widehat{\boldsymbol{\gamma}} = \frac{1}{T_0}\sum_{t \le T_0} y_t \mathbf{X}_t,\]

and the post-treatment means \(\bar y_1 = \operatorname{mean}(y_t)\) and \(\bar{\mathbf{x}}_1 = \operatorname{mean}(\mathbf{X}_t)\) over \(t > T_0\). The compatible-weight set at radius \(\lambda\) is the simplex intersected with a moment band,

\[\mathcal{B}(\lambda) = \Big\{ \boldsymbol{\beta} \ge 0,\ \textstyle\sum_j \beta_j = 1 \ :\ \big\lVert \widehat{\boldsymbol{\Sigma}}\boldsymbol{\beta} - \widehat{\boldsymbol{\gamma}} \big\rVert_\infty \le \lambda + c_{T_0} \Big\},\]

where \(c_{T_0} \propto \log(\max(T_0, N))^{1/2}/\sqrt{T_0}\) is a data-driven slack that vanishes as \(T_0\) grows. DROSC reports the effect at the weight in \(\mathcal{B}(\lambda)\) that best matches the post-treatment mean,

\[\widehat{\boldsymbol{\beta}}(\lambda) = \operatorname*{arg\,min}_{ \boldsymbol{\beta} \in \mathcal{B}(\lambda)} \big( \bar{\mathbf{x}}_1^\top \boldsymbol{\beta} - \bar y_1 \big)^2, \qquad \widehat{\tau}(\lambda) = \bar y_1 - \bar{\mathbf{x}}_1^\top \widehat{\boldsymbol{\beta}}(\lambda).\]

The objective is rank one in \(\boldsymbol{\beta}\), so \(\widehat{\tau}(\lambda)\) is identified even when \(\widehat{\boldsymbol{\beta}}(\lambda)\) is not – which is exactly the non-uniqueness DROSC is built to tolerate.

Assumptions#

  1. Single treated unit, complete panel. Ingestion is the standard mlsynth.utils.datautils.dataprep() contract: one treated unit with a pre/post split and a fully observed outcome matrix.

    Remark. There is no covariate cube or spatial input; DROSC uses only the outcome panel. A dependent flag switches the moment covariances to a Newey-West HAC estimator when the outcomes are autocorrelated.

  2. The compatible set contains the truth. At the chosen radius, \(\mathcal{B}(\lambda)\) includes a weighting that reproduces the treated counterfactual. Under the classical identification this holds at \(\lambda = 0\); when it fails, \(\widehat{\tau}(\lambda)\) is a conservative proxy, not the classical effect.

    Remark. This is why the radius is reported as a sweep. The value of \(\lambda\) at which \(\widehat{\tau}\) first reaches zero is the amount of robustness the finding can absorb.

  3. Simplex weights. Donor weights are non-negative and sum to one, so the counterfactual is an interpolation of the donors (no extrapolation).

    Remark. The moment band is layered on top of the simplex, so DROSC never leaves the convex hull; it only restricts which hull points are admissible.

Inference#

Set inference=True for the perturbation-based confidence interval. Because \(\widehat{\tau}(\lambda)\) is the value of an inequality-constrained optimisation, its limiting law is non-normal, so a normal interval is invalid. DROSC instead perturbs the pre-treatment moments and the post-treatment means from their (HAC-)sampling distributions, re-solves the moment-band problem for each of n_perturbations draws, forms a normal interval around each draw’s effect, and returns the union of those intervals – a confidence set that may be a single interval or a disjoint union. The enveloping hull is exposed as res.inference.ci_lower / ci_upper (and res.att_ci); the full list of pieces is in res.inference.details["ci_intervals"].

Two practical notes. The interval is a genuine confidence set, so read it as “the effects the data cannot reject”, not as a point estimate plus symmetric error. And it is computationally heavy and mildly seed-sensitive: the moment band starts far tighter than the perturbation scale, so an internal slack is inflated until enough draws are feasible (dozens of rounds of \(\texttt{n\_perturbations}\) solves), and the endpoints move with the seed. Fix seed for reproducibility and keep the default off unless you need the interval.

Example#

import pandas as pd
from mlsynth import DROSC

basque = pd.read_csv("basedata/basque_jasa.csv")
basque = basque[basque.regionname != "Spain (Espana)"].copy()
treat_year = sorted(basque.year.unique())[15]          # T0 = 15
basque["treat"] = ((basque.regionname == "Basque Country (Pais Vasco)")
                   & (basque.year >= treat_year)).astype(int)

base = dict(df=basque, outcome="gdpcap", treat="treat",
            unitid="regionname", time="year")

# a robustness sweep: how fast does the effect shrink toward zero?
for lam in (0.0, 0.03, 0.06):
    res = DROSC({**base, "robustness_lambda": lam}).fit()
    print(lam, round(res.effects.att, 3))     # -0.742, -0.256, 0.000

# perturbation union confidence interval at a fixed radius (slower)
res = DROSC({**base, "robustness_lambda": 0.0, "inference": True,
             "seed": 1}).fit()
print(res.att_ci)                             # enveloping hull, contains 0

Verification#

DROSC is cross-validated against the authors’ own R implementation (helpers.R, limSolve::lsei) – sourced from their repository and run live via Rscript – on the Basque study: the worst-case estimand \(\widehat{\tau}(\lambda)\) and the \(\lambda = 0\) donor weights match value-for-value (to \(\sim 10^{-7}\)) across the robustness sweep. See the durable case benchmarks/cases/drosc_basque.py and the replication page DROSC — Basque robustness sweep vs the authors’ R.

Core API#

class mlsynth.DROSC(config: DROSCConfig | dict)#

Bases: object

Distributionally Robust Synthetic Control estimator.

Parameters:

config (DROSCConfig or dict) – Configuration object. See mlsynth.config_models.DROSCConfig.

fit() BaseEstimatorResults#

Estimate the DROSC effect (and, if requested, its union CI).

class mlsynth.config_models.DROSCConfig(*, 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>, robustness_lambda: ~typing.Annotated[float, ~annotated_types.Ge(ge=0)] = 0.0, inference: bool = False, conditional: bool = False, dependent: bool = False, alpha: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.05, n_perturbations: ~typing.Annotated[int, ~annotated_types.Ge(ge=1)] = 500, seed: ~typing.Annotated[int, ~annotated_types.Ge(ge=0)] = 0, nu: ~typing.Annotated[float, ~annotated_types.Ge(ge=0)] = 0.01, eta: ~typing.Annotated[float, ~annotated_types.Ge(ge=0)] = 0.01, slack_rate: ~typing.Annotated[float, ~annotated_types.Gt(gt=0)] = 0.5, gamma: ~typing.Annotated[float, ~annotated_types.Ge(ge=0)] = 1.0, alpha0: ~typing.Annotated[float, ~annotated_types.Gt(gt=0.0), ~annotated_types.Lt(lt=1.0)] = 0.01, c_sample: ~typing.Annotated[float, ~annotated_types.Gt(gt=0)] = 0.01)#

Bases: BaseEstimatorConfig

Distributionally Robust Synthetic Control.

DROSC (Koo & Guo 2026) targets a worst-case treatment effect over the set of donor weights compatible with the pre-treatment moments up to a robustness radius robustness_lambda. At robustness_lambda = 0 the compatible set is tightest (classical-SC-like); as it grows the estimate becomes a conservative proxy that is robust to highly correlated controls and to shifts in the treated-control relationship. Optional perturbation-based inference returns a (possibly disjoint) union confidence interval whose limiting law is non-normal.

References

Koo, T. & Guo, Z. (2026). “Distributionally Robust Synthetic Control: Ensuring Robustness Against Highly Correlated Controls and Weight Shifts.” arXiv:2511.02632.

alpha: float#
alpha0: float#
c_sample: float#
conditional: bool#
dependent: bool#
eta: float#
gamma: float#
inference: bool#
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_perturbations: int#
nu: float#
robustness_lambda: float#
seed: int#
slack_rate: float#