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 4, 2026

One Treated Unit

Act I

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

On 23 June 2016, the United Kingdom 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.

Four moves: read the API, climb the ladder, find the defaults, publish the cloud

  • Read one config dict and one result object — the whole API.
  • Map each stage of the ladder onto one mlsynth class.
  • Identify the three defaults that change the answer.
  • Compare the stages on a placebo tournament — then report a cloud, not 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()

Estimand throughout: the ATT for the UK at one quarter, \(\tau_t = Y_{\text{UK},t}(1) - Y_{\text{UK},t}(0)\).

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 of donor_weights.values()
.pre_rmse fit_diagnostics.rmse_pre

Learn these seven and you can read any estimator in the library — no docs needed.

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: a fifth Hungary, a fifth the US, a fifth Japan — plus Canada and 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.

DSC treats all 86 pre-treatment quarters alike. SDID does not.

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.

Before: \(+0.0021\). After: \(-0.0281\). A falsification test for one extra line of code.

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.

Five countries carry 83–92% of the counterfactual under every method. Only ASCM ever goes negative.

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

Two of these three defaults outrun the whole ladder

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 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. Land 0.02 away? Suspect the solver first.

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. Four p-values span \(0.006\) to \(0.042\); scpi’s interval stops short of zero. Five routes, all rejecting 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 fitted 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.

Everything here ships with the post

  • 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.