The Ladder and the Defaults

Climbing six synthetic control estimators in mlsynth — and finding the settings matter more

1.76 ppone default’s covariate spread
0.31 ppthe whole ladder’s spread
92estimators behind one config dict

Carlos Mendez

Nagoya University (GSID)

August 2, 2026

One Treated Unit

Act I

There is only one United Kingdom, and it voted to leave

On 23 June 2016, 51.9% voted to leave the European Union.

To say what that decision cost, you need the United Kingdom that stayed.

There is no such country. Every estimator in this deck is a different way of building one.

One line in a crowd

Log real GDP for 24 OECD economies, 1995-2020. The United Kingdom in orange; the dashed line marks the 2016 referendum.

24 countries · 23 donors · 86 pre-treatment quarters · treatment dated 2016Q3.

Ninety-two estimators, one configuration dictionary

One interface for the whole single-treated-unit literature: build a config, call .fit(), read a standardized result.

Six stages in this deck. Eighty-six more estimators behind the same door.

Pin the commit, not the release — __version__ says 1.0.0 either way.

Where we’re going

  • Read one config dict and one result object — the entire API surface.
  • Map each stage of the ladder onto a specific mlsynth class.
  • Identify the three defaults that change the answer materially.
  • Compare the stages on a placebo tournament, and read the ranking sceptically.
  • Report a cloud of estimates rather than a point.

One Config, Six Stages

Act II

Every stage is the same regression, weighted differently

\[(\hat\tau, \hat\mu, \hat\alpha, \hat\beta) = \arg\min \sum_{i,t} \omega_i \lambda_t \left( Y_{it} - \mu - \alpha_i - \beta_t - \tau D_{it} \right)^2\]

A two-way fixed-effects regression — but some countries and some quarters count more.

The stages differ only in how \(\omega\) and \(\lambda\) are chosen.

The ladder is two dials: who counts, and when

Stage \(\omega\) (who counts) \(\lambda\) (when) mlsynth call
DiD uniform uniform FDID(cfg).fit().did
SC fitted, simplex uniform VanillaSC(cfg)
DSC fitted, simplex + intercept uniform TSSC(cfg, method="MSCa")
SDID fitted, simplex + intercept fitted, simplex SDID(cfg, zeta=0.0)
MASC convex blend of matching and SC uniform MASC(cfg, set_f=...)
ASCM SC weights plus a ridge correction uniform VanillaSC(cfg, augment="ridge")

One dial for who counts, one for when. Only SDID turns both.

Five fields, one long panel — and one config dict

def cfg(post, pre=T0, **extra):
    return dict(df=window(post, pre), outcome="log_rgdp", treat="treat",
                unitid="country", time="tt", display_graphs=False, **extra)

res = VanillaSC(cfg(96, inference=False)).fit()

treat means under treatment right now, not ever treated.

Every later code block is one line, because cfg already did the work.

One result object, seven accessors that work everywhere

Flat accessor Resolves to
.att effects.att
.att_ci (inference.ci_lower, inference.ci_upper)
.counterfactual time_series.counterfactual_outcome
.gap time_series.estimated_gap
.donor_weights weights.donor_weights
.weight_vector dense array over the donor pool
.pre_rmse fit_diagnostics.rmse_pre

Learn these seven and you can read the output of any effect estimator in the library without opening its documentation.

Stage 0 — DiD weights every country and every quarter the same

did = FDID(cfg(96)).fit().did      # no standalone DiD class; it rides inside FDID

4.98% at end-2018 — nearly double every other stage.

23 donors, each weighted exactly \(1/23 = 0.0435\). Nothing was fitted.

Pre-treatment RMSE 0.0217 — four times SC’s 0.0056. That gap is why the ladder exists.

Stage 1 — Synthetic control fits who counts

\[\hat\omega = \arg\min_{\omega \in \mathbb{W}} \sum_{t=1}^{T_0} \Big( Y_{\text{UK},t} - \sum_j \omega_j Y_{j,t} \Big)^2, \qquad \mathbb{W} = \Big\{ \omega : \omega_j \ge 0, \ \sum_j \omega_j = 1 \Big\}\]

sc = VanillaSC(cfg(96, inference=False)).fit()      # 3.04%

Nine of 23 donors get non-zero weight; the simplex zeroes the rest. Pre-treatment \(R^2 = 0.998\).

Indistinguishable until 2016, then persistently below

UK log real GDP against its synthetic control (top) and the gap between them (bottom), shaded orange after the referendum.

The synthetic UK is roughly a fifth Hungary, a fifth the United States, a fifth Japan, a sixth Canada and an eighth Norway.

Stage 2 — Demeaned SC allows a level gap

dsc = TSSC(cfg(96, method="MSCa", inference=False)).fit()   # 2.99%

One extra free parameter — a constant offset. Match the UK’s shape, not its level.

The offset is \(+0.0024\) log points. Its smallness is the finding: SC was already level-balanced.

Stage 3 — SDID also fits which quarters count

\[\hat\tau_t = \Big( Y_{\text{UK},t} - \sum_j \hat\omega_j Y_{j,t} \Big) - \sum_{s \le T_0} \hat\lambda_s \Big( Y_{\text{UK},s} - \sum_j \hat\omega_j Y_{j,s} \Big)\]

sdid = SDID(cfg(96, zeta=0.0, vce="placebo", B=500)).fit()   # 2.80%

The gap at the evaluation quarter, minus the \(\lambda\)-weighted average gap before it.

And the time weights collapse onto one quarter

Stem plot of SDID’s 86 time weights. Almost all the mass sits on 2016Q2; the dashed line is the uniform weight DiD would use.

0.958 on 2016Q2 against DiD’s uniform \(1/86 \approx 0.012\).

Flat before, negative after

SDID event study by quarters since the referendum, with a 95% placebo confidence band.

Pre-treatment effects average \(+0.0021\), post-treatment \(-0.0281\) — a falsification test the single ATT number cannot give you, for the cost of one extra line.

Stages 4 and 5 — buy the trade-off, or drop the constraint

MASC — blend with matching

\[\hat{Y}^{\text{MASC}} = \phi\,\hat{Y}^{\text{match}}_m + (1-\phi)\,\hat{Y}^{\text{SC}}\]

MASC(cfg, m_grid=..., set_f=...)

CV picks \(m = 10\), \(\phi = 0.158\).

2.73% — the lowest stage.

ASCM — relax the simplex

Ridge-correct what the simplex cannot close. Negative weights allowed.

VanillaSC(cfg, augment="ridge")

Eight negative weights, largest \(-0.009\).

3.04% — same as plain SC.

The UK sits comfortably inside the hull, so there is almost nothing to correct.

Five estimators, essentially the same five countries

Donor weights under SC, DSC, SDID, MASC and augmented SC across the donor pool; the shaded region marks negative weights.

Hungary, the United States, Japan, Canada and Norway carry 87-91% of the counterfactual under every method. Only ASCM ever goes negative.

The paths agree until the referendum, then fan apart

Six counterfactual paths for the United Kingdom alongside the observed series, 2014-2020.

Difference-in-differences is the clear outlier. The other five are hard to tell apart by eye — which is the point.

The whole ladder, side by side

Stage mlsynth call end-2018 end-2019 published
DiD FDID(...).fit().did 4.98 6.18
SC VanillaSC(...) 3.04 4.17 3.06
DSC TSSC(..., method="MSCa") 2.99 4.12 2.98
SDID SDID(..., zeta=0.0) 2.80 3.94 2.79
MASC MASC(..., set_f=range(6, 87)) 2.73 3.83 2.73
ASCM VanillaSC(..., augment="ridge") 3.04 4.19 3.04

The width of the cluster — 2.73-3.04% — not any point inside it, is the honest answer.

Every stage exceeds the published 2.4%

Every stage’s estimated UK GDP shortfall at 2018Q4 and 2019Q4, with the three SDID flavours shown separately; the dashed line is the published 2.4%.

2.73-3.04% at end-2018 and 3.83-4.19% a year later — every stage above the 2.4% previously published for this same dataset.

The Defaults

Act III

Three defaults each move the answer more than the ladder does

Setting At its default Set deliberately Moves the estimate by
zeta — SDID’s unit-weight penalty 2.67 2.80 0.13 pp
set_f — MASC’s cross-validation folds 3.19 2.73 0.47 pp
the covariate method 3.61 1.85 1.76 pp
the whole ladder, DiD excluded 2.73 3.04 0.31 pp

You can pick the wrong default and land further from the truth than if you had picked the wrong estimator.

zeta penalises the unit weights unless you pass a literal zero

SDID(cfg(96)).fit()                   # zeta at its default  ->  2.67%
SDID(cfg(96, zeta=0.0)).fit()         # what the paper solves ->  2.80%

Every language penalises by default: R needs zeta.omega = 0, Stata needs zeta_omega(0).

0.13 pp — larger than the whole spread between SC, DSC and ASCM.

set_f decides which folds MASC learns from

The 2018Q4 estimate for each forced neighbour count from 1 to 10, each bar labelled with its phi; the dashed line marks the freely cross-validated 2.73%.

set_f=range(6, 87) gives 2.73%; the default min_preperiods resolves to 43 and gives 3.19%. One argument, 0.47 pp.

“Control for a covariate” means three different things

Key Method What it does
"adjust" Kranz (2022) residualise the outcome, then run SDID
"match" de Brabander et al. covariates inside the unit-weight problem
"optimized" Arkhangelsky et al. weights and coefficients jointly

covariates is a dict keyed by method. Pass a bare list and it raises.

Three defensible readings in the literature — so three different estimators.

And they disagree by 1.76 percentage points

SDID with no covariates and under the adjust, match and optimized methods, plus VanillaSC’s bilevel route; the dashed line is the published 2.4%.

3.61 / 1.85 / 3.11 against 2.80 with no covariates at all.

One estimator silently rounds the number you are most likely to quote

d18.att                    # -0.03                ->  3.0000%
np.asarray(d18.gap)[-1]    # -0.0298873295228968  ->  2.9887%

TSSC rounds every scalar. The series stay full precision.

When the scalar and the series disagree — trust the series.

A name is a mnemonic, not a definition

Class Is actually Not to be confused with
DSC Distributional SC (Gunsilius) demeaned SC = TSSC(method="MSCa")
DSCAR Dynamic SC for Auto-Regressive processes either of the above
SCD SC with Differencing DSC
DRSC Distribution-Regression SC DSC
MEDSC Mediation SC demeaned SC

mlsynth.DSC is not this deck’s DSC — and importing it raises no error.

A synthetic control estimate carries its solver’s fingerprint

Four mlsynth solver routes, all at 3.039%, against R’s Frank-Wolfe implementation at 3.060%.

Three languages, two camps — split by optimiser, not by author. Replicate a published number and land 0.02 away? Suspect the optimiser before the data.

Which Stage?

Act IV

Score the stages on a task whose true answer is zero

Move the treatment date to a quarter when nothing happened.

The true effect is zero, so every estimate is pure error.

def placebo_one(k, h):
    """Fit every stage on 1..k, predict k + h. Truth is zero."""
    ...

errors = [placebo_one(k, h) for k in FAKE_DATES for h in (1, 4)]   # 20 dates

inference=False and an explicit m_grid are what keep this to 13 seconds.

The time weights earn their keep

Twenty placebo errors for each of the seven estimators, graded one quarter ahead and four quarters ahead; the orange diamond marks the RMSE.

At \(h = 1\): SDID 0.0066 against 0.0086 for SC, DSC and ASCM — a 23% cut.

But the published ranking was graded on a different exam

Method RMSE, \(h = 1\) RMSE, \(h = 4\) Published
SC 0.0086 0.0145 0.0089
DSC 0.0086 0.0146 0.0087
SDID (i) 0.0066 0.0132 0.0067
SDID (ii) 0.0066 0.0133 0.0134
SDID (iii) 0.0066 0.0133 0.0134
MASC 0.0080 0.0140 0.0080
ASCM 0.0086 0.0146 0.0086

Two rows were graded four quarters ahead. Matched, the three SDID flavours are indistinguishable.

The UK’s gap is deeper than any placebo’s

Twenty-three grey placebo gap paths, one per donor given the treatment in turn, with the United Kingdom in orange.

RMSPE ratio 5.85, rank 1 of 24, giving a permutation p-value of \(1/24 = 0.042\).

The strongest objection — and the answer

Objection. \(p = 0.042\) is the smallest attainable with 24 units — the test is at its floor. And SDID’s own interval contains zero.

Response. Five inference routes span \(p = 0.006\) to \(0.042\) and all reject at 5%. But the floor is real, and no estimator changes it.

Which is why the deliverable is the cluster, not the point.

What the referendum cost

2.7 – 3.0%

UK GDP shortfall at end-2018, at every stage of the ladder · 3.8 – 4.2% a year later · against 2.4% previously published

What to take away

  1. Fit the ladder, not a stage. Publish the cloud — six estimators, one config, thirteen seconds.
  2. Say which defaults you set. Here that matters more than the estimator’s name.
  3. Pin the commit, not the release. A version number is not a version.
  4. A second-decimal disagreement is the optimiser, not the data.

Materials

  • Post, notebook, Colabcarlos-mendez.org/post/python_sc_dsc_sdid
  • Three cheat sheets — Python, R and Stata; each hard-codes the others’ column.
  • R editionr_sc_dsc_sdid hand-codes every estimator; go there for the derivations.
  • Data — Born, Müller, Schularick and Sedláček (2019), via de Brabander et al.
  • Librarymlsynth by Jared Greathouse, pinned at 15f168b.

Fit the ladder, not a stage — and publish the cloud, not the point.