<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>brexit | Carlos Mendez</title><link>https://carlos-mendez.org/tag/brexit/</link><atom:link href="https://carlos-mendez.org/tag/brexit/index.xml" rel="self" type="application/rss+xml"/><description>brexit</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>© 2018–2026 Carlos Mendez. All rights reserved.</copyright><lastBuildDate>Sun, 02 Aug 2026 00:00:00 +0000</lastBuildDate><image><url>https://carlos-mendez.org/media/icon_huedfae549300b4ca5d201a9bd09a3ecd5_79625_512x512_fill_lanczos_center_3.png</url><title>brexit</title><link>https://carlos-mendez.org/tag/brexit/</link></image><item><title>The Synthetic Control Ladder in Python: A Guided Tour of mlsynth on the Brexit Referendum</title><link>https://carlos-mendez.org/post/python_sc_dsc_sdid/</link><pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_sc_dsc_sdid/</guid><description>&lt;div style="background:#0e1545; border-radius:12px; padding:8px;">
&lt;iframe style="border-radius:8px" src="https://open.spotify.com/embed/episode/7wmH9iF0ITNStTeBk47zb1?utm_source=generator&amp;theme=0" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy">&lt;/iframe>
&lt;/div>
&lt;h2 id="abstract">Abstract&lt;/h2>
&lt;p>Estimating what a policy cost requires building a version of the world in which it never happened, and the software for doing that is scattered across a dozen packages with a dozen different interfaces. This tutorial is a guided tour of &lt;code>mlsynth&lt;/code>, a Python library that puts ninety-two panel-data causal estimators behind a single configuration dictionary and a single result object, and it uses that library to climb the whole ladder of single-treated-unit estimators — difference-in-differences, synthetic control, demeaned synthetic control, synthetic difference-in-differences in three flavours, matching-and-synthetic-control and augmented synthetic control — one class per stage. The data are quarterly log real GDP for 24 OECD economies from 1995Q1 to 2020Q4, leaving the United Kingdom as the treated unit, 23 donors and 86 pre-treatment quarters. Dating the referendum at 2016Q3 and matching on outcomes alone, the estimated shortfall in UK GDP at the end of 2018 is 3.04% under synthetic control, 2.99% under demeaned SC, 2.80% under SDID, 2.73% under MASC and 3.04% under augmented SC, widening to between 3.83% and 4.19% a year later — every one above the 2.4% previously published for this dataset. An in-sample placebo tournament over twenty artificial treatment dates ranks the SDID family first at 0.0066 log points of root mean squared error against 0.0086 for plain synthetic control. Three findings emerge that only a package-level reading produces: three of the library&amp;rsquo;s defaults each change the answer by more than the spread across the entire ladder, one estimator silently rounds the number you are most likely to quote, and the three routes &lt;code>mlsynth&lt;/code> offers for &amp;ldquo;controlling for a covariate&amp;rdquo; disagree by 1.8 percentage points.&lt;/p>
&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>On 23 June 2016 the United Kingdom voted to leave the European Union. Three and a half years later UK GDP was some number of percentage points below where it would otherwise have been. The trouble is &amp;ldquo;otherwise have been&amp;rdquo;: there is one United Kingdom, it took the treatment, and the version that stayed in the EU exists nowhere in the data.&lt;/p>
&lt;p>The standard move is to build that missing country out of the countries we do observe — weight the other OECD economies, add them up, and require the blend to track the real UK quarter by quarter through the two decades before the referendum. That is the synthetic control method, and it is one stage of a ladder that starts at difference-in-differences and climbs through several increasingly flexible estimators.&lt;/p>
&lt;p>&lt;a href="https://carlos-mendez.org/post/r_sc_dsc_sdid/">The R edition of this post&lt;/a> climbs that ladder the hard way: every estimator is hand-coded in twenty lines before its package is called, and four separate R packages are needed to cover the six stages. &lt;strong>This post makes a different argument.&lt;/strong> Every stage here is a single class from one library, &lt;code>mlsynth&lt;/code>, and the interesting work is not in deriving the estimators but in &lt;em>driving the package&lt;/em>: which configuration field selects which estimator, what the result object actually contains, and — the part that turns out to matter most — where a default will quietly hand you a different estimator than the one you meant to fit.&lt;/p>
&lt;p>That last point is not a minor caveat. By the end of this post you will have seen three separate defaults that change the headline estimate materially, two of them by more than the spread across the entire six-stage ladder. Knowing the econometrics is not enough. You have to know the software.&lt;/p>
&lt;h3 id="11-learning-objectives">1.1 Learning objectives&lt;/h3>
&lt;p>By the end of this tutorial you will be able to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Install&lt;/strong> &lt;code>mlsynth&lt;/code> and read its one-config-dict, one-result-object interface, including the Pydantic validation that turns a typo into an exception instead of a silently wrong answer.&lt;/li>
&lt;li>&lt;strong>Map&lt;/strong> each stage of the synthetic-control ladder onto a specific &lt;code>mlsynth&lt;/code> class and configuration field, and explain why &lt;code>mlsynth.DSC&lt;/code> is not the DSC on this ladder.&lt;/li>
&lt;li>&lt;strong>Extract&lt;/strong> the average treatment effect on the treated, the donor weights, the time weights, the counterfactual path and the event study from a fitted result.&lt;/li>
&lt;li>&lt;strong>Identify&lt;/strong> the three defaults — &lt;code>zeta&lt;/code>, &lt;code>set_f&lt;/code> and the covariate method — that change the answer materially, and set each one deliberately.&lt;/li>
&lt;li>&lt;strong>Compare&lt;/strong> estimators on a common in-sample placebo tournament and read the resulting ranking with appropriate scepticism.&lt;/li>
&lt;li>&lt;strong>Choose&lt;/strong> among the wider &lt;code>mlsynth&lt;/code> catalogue when your design is not the canonical one-treated-unit case.&lt;/li>
&lt;/ul>
&lt;h3 id="12-the-road-ahead">1.2 The road ahead&lt;/h3>
&lt;p>Each stage of this ladder exists because the stage below it gets something wrong. The diagram traces that sequence of complaints, and names the &lt;code>mlsynth&lt;/code> class that answers each one.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">flowchart TD
D[&amp;quot;&amp;lt;b&amp;gt;Panel data&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;24 countries, 104 quarters&amp;lt;br/&amp;gt;one treated unit&amp;quot;] --&amp;gt; Q0{&amp;quot;Which donors&amp;lt;br/&amp;gt;count, and by&amp;lt;br/&amp;gt;how much?&amp;quot;}
Q0 --&amp;gt;|&amp;quot;all of them, equally&amp;quot;| DID[&amp;quot;&amp;lt;b&amp;gt;Stage 0 — DiD&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;FDID(...).fit().did&amp;quot;]
DID --&amp;gt; Q1{&amp;quot;But the donors do&amp;lt;br/&amp;gt;not look like the UK&amp;quot;}
Q1 --&amp;gt; SC[&amp;quot;&amp;lt;b&amp;gt;Stage 1 — SC&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;VanillaSC&amp;quot;]
SC --&amp;gt; Q2{&amp;quot;But the blend must&amp;lt;br/&amp;gt;match the LEVEL, not&amp;lt;br/&amp;gt;just the shape&amp;quot;}
Q2 --&amp;gt; DSC[&amp;quot;&amp;lt;b&amp;gt;Stage 2 — DSC&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;TSSC(method='MSCa')&amp;quot;]
DSC --&amp;gt; Q3{&amp;quot;But every pre-period&amp;lt;br/&amp;gt;counts the same&amp;quot;}
Q3 --&amp;gt; SDID[&amp;quot;&amp;lt;b&amp;gt;Stage 3 — SDID&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;SDID(zeta=0.0)&amp;quot;]
SDID --&amp;gt; PIVOT[&amp;quot;&amp;lt;b&amp;gt;The pivot&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;extrapolation bias vs&amp;lt;br/&amp;gt;interpolation bias&amp;quot;]
PIVOT --&amp;gt; MASC[&amp;quot;&amp;lt;b&amp;gt;Stage 4 — MASC&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;MASC(set_f=...)&amp;quot;]
PIVOT --&amp;gt; ASCM[&amp;quot;&amp;lt;b&amp;gt;Stage 5 — ASCM&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;VanillaSC(augment='ridge')&amp;quot;]
MASC --&amp;gt; SEL[&amp;quot;&amp;lt;b&amp;gt;Which stage?&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;in-sample placebo&amp;lt;br/&amp;gt;tournament&amp;quot;]
ASCM --&amp;gt; SEL
SEL --&amp;gt; INF[&amp;quot;&amp;lt;b&amp;gt;Inference&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;six methods, one flag&amp;quot;]
style D fill:#141413,stroke:#6a9bcc,color:#fff
style DID fill:#8b9dc3,stroke:#141413,color:#fff
style SC fill:#6a9bcc,stroke:#141413,color:#fff
style DSC fill:#00d4c8,stroke:#141413,color:#141413
style SDID fill:#d97757,stroke:#141413,color:#fff
style MASC fill:#6a9bcc,stroke:#141413,color:#fff
style ASCM fill:#6a9bcc,stroke:#141413,color:#fff
style PIVOT fill:#141413,stroke:#d97757,color:#fff
style SEL fill:#1a3a8a,stroke:#141413,color:#fff
style INF fill:#1a3a8a,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Read the diagram top to bottom as a conversation. Every arrow labelled &amp;ldquo;but&amp;rdquo; is an objection to the stage above it, and every box below an objection is the estimator that answers it. The two boxes hanging off the pivot are not a further step up but two different reactions to the same discovery, which is why the ladder branches there rather than continuing.&lt;/p>
&lt;h2 id="2-key-concepts">2. Key concepts&lt;/h2>
&lt;p>Eight ideas carry the whole post. Two repay slow reading: the distinction between unit weights and time weights, and the difference between an estimator and the &lt;em>solver&lt;/em> that fits it.&lt;/p>
&lt;p>&lt;strong>The missing counterfactual and the donor pool.&lt;/strong>
There is one United Kingdom and it took the treatment. The path it would have followed without Brexit is in no dataset. Synthetic control builds that path from countries that were not treated, and those countries are the donor pool.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>After dropping the twelve OECD countries with incomplete records, 24 remain. The UK is the treated unit; the other 23 — from Australia to the United States — are the donor pool. In &lt;code>mlsynth&lt;/code> you never name them: the library reads the donor pool off the &lt;code>treat&lt;/code> column, which is 1 for the treated unit in post-treatment periods and 0 everywhere else.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>The master tape of a song is lost and the band has broken up. You hire session musicians and rehearse them against a bootleg until they are indistinguishable from the original, then have them play a song the original band never recorded. The donor pool is the pool of session musicians; the pre-treatment period is the rehearsal.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The simplex.&lt;/strong>
Synthetic control weights must be non-negative and sum to one. That set of allowed weight vectors is the simplex, and the blends it can reach form the convex hull of the donors.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>&lt;code>mlsynth&lt;/code> reports the constraint it imposed. &lt;code>VanillaSC&lt;/code> returns &lt;code>weights.summary_stats[&amp;quot;constraint&amp;quot;]&lt;/code> as &lt;code>&amp;quot;simplex (non-negative, sum to 1)&amp;quot;&lt;/code>, and only nine of the 23 donors come back with any weight at all: Hungary 0.2231, the United States 0.1926, Japan 0.1826, Canada 0.1751, Norway 0.1350, and four smaller ones.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Hammer a pin into a corkboard for every donor country, stretch a rubber band around all the pins and let it snap tight. Everything inside is reachable by some blend; nothing outside is. Reaching outside would need a negative amount of some country, like a recipe calling for minus two eggs.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>Unit weights and time weights.&lt;/strong>
Unit weights say how much each donor country counts. Time weights say how much each pre-treatment quarter counts. Both are chosen by the same kind of optimisation, run in two different directions.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>&lt;code>mlsynth&lt;/code> keeps them in separate places, and finding the second one is the single most common stumbling block. Unit weights are &lt;code>result.donor_weights&lt;/code>; SDID&amp;rsquo;s time weights are &lt;code>result.cohorts[a].time_weights&lt;/code>. Ours put 0.9585 on 2016Q2 and roughly nothing on the other 85 quarters.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A mixing desk has two banks of faders. The first sets how loud each instrument is; the second sets which seconds of the rehearsal tape you play back when you check the mix. Difference-in-differences leaves both banks flat. Synthetic control moves the first. Synthetic difference-in-differences moves both.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The intercept, which is a unit fixed effect in disguise.&lt;/strong>
Sometimes the blend moves in near-perfect parallel with the treated unit but sits at a slightly different level. The intercept is the average pre-treatment gap, subtracted off, and adding it is exactly the same as putting a unit fixed effect in the regression.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>For the UK, &lt;code>TSSC&lt;/code>&amp;rsquo;s &lt;code>MSCa&lt;/code> variant estimates an intercept of $+0.00241$ log points, about a quarter of one per cent of GDP. That is why demeaned SC lands at 2.99% and plain SC at 3.04%: a small intercept is evidence that the SC fit was already level-balanced.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Your bathroom scale reads two kilograms heavy. You do not throw it out; you subtract two. Plain synthetic control insists on a scale that is already exactly right and will reject a perfectly consistent one. Demeaned synthetic control just calibrates the offset.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>Extrapolation bias and interpolation bias.&lt;/strong>
Two different ways a weighted counterfactual goes wrong. Extrapolation bias: the blend&amp;rsquo;s characteristics do not match the treated unit&amp;rsquo;s. Interpolation bias: the characteristics match, but the outcome is a curved function of them, so averaging outcomes is not the same as the outcome at the average. Neither name means quite what you would guess.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>The unit weights $\omega$ attack extrapolation bias; matching attacks interpolation bias; SDID&amp;rsquo;s time weights $\lambda$ are what let one estimator attack both. That claim is the theoretical contribution of the paper this post replicates, and section 16 is where we test it.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Extrapolation bias is guessing a stranger&amp;rsquo;s weight from a photograph of someone else. Interpolation bias is averaging the heights of a five-year-old and a fifty-year-old and calling the result the height of a typical twenty-seven-year-old — both inputs are real people, but growth is not linear in age.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The configuration object.&lt;/strong>
Every &lt;code>mlsynth&lt;/code> estimator takes one dictionary, validated by Pydantic. Five fields are always the same: &lt;code>df&lt;/code>, &lt;code>outcome&lt;/code>, &lt;code>treat&lt;/code>, &lt;code>unitid&lt;/code>, &lt;code>time&lt;/code>. Everything else is estimator-specific.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>Swapping &lt;code>VanillaSC&lt;/code> for &lt;code>SDID&lt;/code> in a script means changing one word. The five data fields, the DataFrame, the treatment indicator and the call pattern &lt;code>Estimator(config).fit()&lt;/code> are all identical. The configs set &lt;code>extra=&amp;quot;forbid&amp;quot;&lt;/code>, so writing &lt;code>backendd=&lt;/code> instead of &lt;code>backend=&lt;/code> raises &lt;code>MlsynthConfigError&lt;/code> rather than being ignored.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A camera body with interchangeable lenses. The grip, the shutter button and the memory card do not change when you swap a wide angle for a macro; only the glass does, and only the glass has its own settings.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The solver&amp;rsquo;s fingerprint.&lt;/strong>
An estimator is a mathematical object. Fitting it requires an optimiser, and on a badly conditioned problem two correct optimisers stop in different places.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>&lt;code>mlsynth&lt;/code> hands the synthetic-control problem to a convex solver and returns 3.039%. R&amp;rsquo;s &lt;code>synthdid&lt;/code> walks the same objective with Frank-Wolfe on a capped iteration budget and stops at 3.06%. Neither is buggy. Section 15 shows why the difference is the interesting part.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Two hikers are told to find the lowest point of a wide, almost flat valley in fog. They both walk downhill and they both stop when the ground stops obviously falling away. They end up two hundred metres apart, and both are following the instructions correctly.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>A three-letter trap: DSC.&lt;/strong>
&lt;code>mlsynth&lt;/code> ships a class named &lt;code>DSC&lt;/code>. It is not the estimator on this ladder, and importing the wrong one raises no error at all.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>&lt;code>mlsynth.DSC&lt;/code> is &lt;em>Distributional&lt;/em> Synthetic Control (Gunsilius 2023): it matches whole outcome distributions in Wasserstein space and needs micro-level data with many observations per unit-period. This post&amp;rsquo;s DSC is &lt;em>Demeaned&lt;/em> Synthetic Control, which in &lt;code>mlsynth&lt;/code> is &lt;code>TSSC(method=&amp;quot;MSCa&amp;quot;)&lt;/code>. Section 6 is devoted to this.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Two colleagues in the same building are both called J. Smith. Sending the quarterly report to the wrong one does not bounce. It just arrives somewhere useless, and you find out weeks later.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>With the vocabulary in place, we can install the library.&lt;/p>
&lt;h2 id="3-setup-installing-mlsynth">3. Setup: installing mlsynth&lt;/h2>
&lt;h3 id="31-install-and-version-pinning">3.1 Install and version pinning&lt;/h3>
&lt;p>&lt;code>mlsynth&lt;/code> is on PyPI, but both the README and the documentation still recommend installing from GitHub — and there is a concrete reason to follow that advice rather than reach for &lt;code>pip install mlsynth&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The PyPI release numbered 1.0.0 is behind git &lt;code>main&lt;/code> at the same version number.&lt;/strong> Installing from PyPI gives you a package that reports &lt;code>mlsynth.__version__ == &amp;quot;1.0.0&amp;quot;&lt;/code> but is missing &lt;code>VanillaSCConfig.w_constr&lt;/code>, which sections 9.2 and 19 use. Nothing in the version string warns you. So install from git, and pin the commit:&lt;/p>
&lt;pre>&lt;code class="language-python"># The moving target:
# pip install -U &amp;quot;git+https://github.com/jgreathouse9/mlsynth.git&amp;quot;
#
# The exact commit this post was verified against:
# pip install -U &amp;quot;git+https://github.com/jgreathouse9/mlsynth.git@15f168b&amp;quot;
#
# Optional extras:
# &amp;quot;mlsynth[design] @ git+...&amp;quot; SCIP solver, for SYNDES / MAREX designs
# &amp;quot;mlsynth[bayes] @ git+...&amp;quot; NumPyro, for SPOTSYNTH's Bayesian mode
# &amp;quot;mlsynth[all] @ git+...&amp;quot; everything
import warnings
warnings.filterwarnings(&amp;quot;ignore&amp;quot;)
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import mlsynth
from mlsynth import FDID, MASC, SDID, TSSC, VanillaSC
from mlsynth.exceptions import MlsynthConfigError, MlsynthDataError
print(&amp;quot;mlsynth&amp;quot;, mlsynth.__version__)
print(&amp;quot;estimators exported:&amp;quot;, len(mlsynth.__all__))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">mlsynth 1.0.0
estimators exported: 92
&lt;/code>&lt;/pre>
&lt;p>Everything below was produced with &lt;strong>&lt;code>mlsynth&lt;/code> 1.0.0 at commit &lt;code>15f168b&lt;/code>&lt;/strong>, Python 3.13.11, NumPy 2.3.5, pandas 3.0.1, Matplotlib 3.10.8 and CVXPY 1.8.1. &lt;code>mlsynth&lt;/code> requires Python 3.10 or later — the README&amp;rsquo;s claim of 3.9 is out of date, and &lt;code>pyproject.toml&lt;/code> is the authority. The core dependencies are pandas, NumPy, Matplotlib, SciPy, scikit-learn, statsmodels, CVXPY, ECOS, Pydantic and PyArrow; both optional solver backends are lazily imported, so a base install can always &lt;code>import mlsynth&lt;/code> and construct any estimator class.&lt;/p>
&lt;h3 id="32-the-library-at-a-glance">3.2 The library at a glance&lt;/h3>
&lt;p>Ninety-two exported names is a lot, and the temptation is to reach for whichever class name looks closest to what you want. Resist it: several of the names are near-homonyms of each other. The library ships a machine-readable index of the whole catalogue, which is the fastest way to find out what a class actually does.&lt;/p>
&lt;pre>&lt;code class="language-python">from mlsynth._guides_api import get_llm_guide
guide = get_llm_guide() # &amp;quot;concise&amp;quot; | &amp;quot;full&amp;quot; | &amp;quot;practitioner&amp;quot;
print(guide[:420])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"># mlsynth
&amp;gt; mlsynth is a strongly-typed Python library of synthetic-control and
&amp;gt; difference-in-differences estimators for causal inference with panel data.
&amp;gt; Every estimator exposes a Pydantic config and a `.fit()` that returns a
&amp;gt; standardized results object. Most are validated against the source paper's
&amp;gt; empirical result, Monte Carlo, or an authoritative reference implementation
&amp;gt; (see the Replications page).
&lt;/code>&lt;/pre>
&lt;p>That guide is worth reading in full before you pick an estimator, and section 20 tabulates the part of it relevant to this ladder. For now the important sentence is the second one: &lt;em>every&lt;/em> estimator exposes a Pydantic config and a &lt;code>.fit()&lt;/code> that returns a standardized result. That uniformity is the whole reason this post can cover six estimators without six separate interfaces to learn.&lt;/p>
&lt;h2 id="4-the-mlsynth-data-contract">4. The mlsynth data contract&lt;/h2>
&lt;h3 id="41-five-fields-one-long-panel">4.1 Five fields, one long panel&lt;/h3>
&lt;p>Every estimator in the library wants the same five things: a long-format DataFrame with one row per unit-period, and the names of the outcome, treatment, unit and time columns. The &lt;code>treat&lt;/code> column is a 0/1 indicator that is 1 for the treated unit in post-treatment periods and 0 everywhere else — including for the treated unit &lt;em>before&lt;/em> treatment.&lt;/p>
&lt;p>That convention is worth dwelling on, because it is where most first attempts go wrong. &lt;code>treat&lt;/code> is not &amp;ldquo;this unit is ever treated&amp;rdquo;; it is &amp;ldquo;this unit is under treatment right now&amp;rdquo;. &lt;code>mlsynth&lt;/code> reads both the donor pool and the treatment date off that single column.&lt;/p>
&lt;pre>&lt;code class="language-python">panel = pd.read_csv(&amp;quot;brexit_analysis.csv&amp;quot;)
print(f&amp;quot;{panel.shape[0]} rows x {panel.shape[1]} columns&amp;quot;)
print(f&amp;quot;{panel.country.nunique()} countries, quarters t = {panel.t.min()}..{panel.t.max()}&amp;quot;)
print(panel[[&amp;quot;country&amp;quot;, &amp;quot;quarter_label&amp;quot;, &amp;quot;t&amp;quot;, &amp;quot;log_rgdp&amp;quot;, &amp;quot;treated&amp;quot;]].head(3).to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">2496 rows x 16 columns
24 countries, quarters t = 1..104
country quarter_label t log_rgdp treated
Australia 1995Q1 1 -0.351514 0
Australia 1995Q2 2 -0.345316 0
Australia 1995Q3 3 -0.339262 0
&lt;/code>&lt;/pre>
&lt;p>The panel is quarterly log real GDP for 24 OECD economies over 1995Q1–2020Q4, assembled by Born, Müller, Schularick and Sedláček [2] and redistributed in the replication package of de Brabander, Juodis and Miyazato Szini [1]. There are no missing values, 23 donors, and 86 pre-treatment quarters if we date the treatment at 2016Q3.&lt;/p>
&lt;h3 id="42-pydantic-configs-and-what-happens-when-you-get-it-wrong">4.2 Pydantic configs, and what happens when you get it wrong&lt;/h3>
&lt;p>The configs are Pydantic v2 models with &lt;code>extra = &amp;quot;forbid&amp;quot;&lt;/code>. In practice this means the library refuses to accept a field it does not recognise, which is a much better failure mode than silently ignoring it.&lt;/p>
&lt;pre>&lt;code class="language-python">base = dict(df=window(T_2018Q4), outcome=&amp;quot;log_rgdp&amp;quot;, treat=&amp;quot;treat&amp;quot;,
unitid=&amp;quot;country&amp;quot;, time=&amp;quot;tt&amp;quot;, display_graphs=False)
for bad, why in [
(dict(base, backendd=&amp;quot;outcome-only&amp;quot;), &amp;quot;misspelled keyword&amp;quot;),
(dict(base, outcome=&amp;quot;gdp_log&amp;quot;), &amp;quot;column not in the DataFrame&amp;quot;),
]:
try:
VanillaSC(bad).fit()
except (MlsynthConfigError, MlsynthDataError) as exc:
print(f&amp;quot;{why:&amp;lt;28s} -&amp;gt; {type(exc).__name__}: {str(exc).splitlines()[0][:70]}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">misspelled keyword -&amp;gt; MlsynthConfigError: 1 validation error for VanillaSCConfig
column not in the DataFrame -&amp;gt; MlsynthDataError: Missing required columns in DataFrame 'df': gdp_log
&lt;/code>&lt;/pre>
&lt;p>There are four exception types — &lt;code>MlsynthConfigError&lt;/code>, &lt;code>MlsynthDataError&lt;/code>, &lt;code>MlsynthEstimationError&lt;/code> and &lt;code>MlsynthPlottingError&lt;/code> — and they tell you which phase failed. A &lt;code>MlsynthConfigError&lt;/code> means you wrote the config wrong; a &lt;code>MlsynthDataError&lt;/code> means the DataFrame does not satisfy the contract (empty, missing columns, duplicate unit-period pairs); a &lt;code>MlsynthEstimationError&lt;/code> means the optimiser gave up. Catching the first two separately from the third is a good habit in any loop over specifications.&lt;/p>
&lt;h3 id="43-what-the-data-look-like-before-we-assume-anything">4.3 What the data look like before we assume anything&lt;/h3>
&lt;p>Before fitting anything, look at the twenty-four series.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(9.5, 6))
for c in DONORS:
ax.plot(QDEC, Y[c], color=GREY_DONOR, lw=0.8, alpha=0.75)
ax.plot(QDEC, Y[TREATED], color=ORANGE, lw=2.4, label=&amp;quot;United Kingdom&amp;quot;, zorder=5)
ax.axvline(QDEC[T0], color=LIGHT_TEXT, ls=&amp;quot;--&amp;quot;, lw=1.0)
ax.set_xlabel(&amp;quot;year&amp;quot;); ax.set_ylabel(&amp;quot;log real GDP&amp;quot;)
plt.savefig(&amp;quot;python_sc_dsc_sdid_01_gdp_paths.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_01_gdp_paths.png" alt="Log real GDP for twenty-four OECD countries from 1995 to 2020, with the United Kingdom highlighted in orange among twenty-three grey donor series and a dashed vertical line at the 2016 referendum.">&lt;/p>
&lt;p>The UK is one line among twenty-four, and nothing in the picture tells you what would have happened without the referendum. Notice also that the series are strongly trending and highly correlated: this is close to a set of random walks with drift, which will matter enormously in section 11 when we look at what the time weights do.&lt;/p>
&lt;h3 id="44-the-one-trick-that-makes-everything-else-simple">4.4 The one trick that makes everything else simple&lt;/h3>
&lt;p>Every &lt;code>mlsynth&lt;/code> estimator reports an ATT averaged over &lt;strong>all&lt;/strong> post-treatment periods. But the question here — and the question in the published tables — is the shortfall at two specific quarters, 2018Q4 and 2019Q4.&lt;/p>
&lt;p>There is a neat way to get exactly that without any post-estimation arithmetic. Keep the 86 pre-treatment quarters plus the single quarter of interest, renumber time so it runs 1 to 87, and &amp;ldquo;the average over all post periods&amp;rdquo; becomes an average over one period. A bare &lt;code>.fit()&lt;/code> then returns precisely the number you want.&lt;/p>
&lt;pre>&lt;code class="language-python">T0 = 86 # pre-treatment quarters, through 2016Q2
EVAL = {&amp;quot;2018Q4&amp;quot;: 96, &amp;quot;2019Q4&amp;quot;: 100} # evaluation quarters, as values of `t`
TREATED = &amp;quot;United Kingdom&amp;quot;
def window(post, pre=T0):
&amp;quot;&amp;quot;&amp;quot;`pre` pre-treatment quarters + the given post quarter(s), renumbered 1..pre+1.&amp;quot;&amp;quot;&amp;quot;
post = [post] if np.isscalar(post) else list(post)
sub = panel[(panel.t &amp;lt;= pre) | (panel.t.isin(post))].copy()
sub[&amp;quot;tt&amp;quot;] = sub.groupby(&amp;quot;country&amp;quot;)[&amp;quot;t&amp;quot;].rank(method=&amp;quot;dense&amp;quot;).astype(int)
sub[&amp;quot;treat&amp;quot;] = ((sub.country == TREATED) &amp;amp; (sub.tt &amp;gt; pre)).astype(int)
return sub
def cfg(post, pre=T0, **extra):
&amp;quot;&amp;quot;&amp;quot;The five fields every mlsynth estimator wants, plus estimator-specific ones.&amp;quot;&amp;quot;&amp;quot;
return dict(df=window(post, pre), outcome=&amp;quot;log_rgdp&amp;quot;, treat=&amp;quot;treat&amp;quot;,
unitid=&amp;quot;country&amp;quot;, time=&amp;quot;tt&amp;quot;, display_graphs=False, **extra)
def pct(att):
&amp;quot;&amp;quot;&amp;quot;mlsynth reports treated minus counterfactual. Flip into a % GDP shortfall.&amp;quot;&amp;quot;&amp;quot;
return -100.0 * att
&lt;/code>&lt;/pre>
&lt;p>Those three helpers are the entire scaffolding of this post. &lt;code>cfg&lt;/code> is the reason every subsequent code block is one line long, and &lt;code>pct&lt;/code> is the reason every number is positive: &lt;code>mlsynth&lt;/code> reports the ATT as treated minus counterfactual, which is negative when the treatment hurt, and the literature reports Brexit as a positive percentage &lt;em>loss&lt;/em>.&lt;/p>
&lt;p>In formal terms, the estimand throughout is the average treatment effect on the treated at a single period,&lt;/p>
&lt;p>$$\tau_t = Y_{\text{UK},t}(1) - Y_{\text{UK},t}(0),$$&lt;/p>
&lt;p>where $Y_{\text{UK},t}(0)$ is the unobserved no-Brexit path. Every stage is a different estimator of that same quantity, and we report $-100 \times \tau_t$ so that a larger number means a larger loss.&lt;/p>
&lt;h2 id="5-anatomy-of-a-fit">5. Anatomy of a fit&lt;/h2>
&lt;h3 id="51-config-in-standardized-result-out">5.1 Config in, standardized result out&lt;/h3>
&lt;p>The call pattern never changes: build a config, construct the estimator, call &lt;code>.fit()&lt;/code>, read the result.&lt;/p>
&lt;pre>&lt;code class="language-python">res = VanillaSC(cfg(96, inference=False)).fit()
print(f&amp;quot;type(result) {type(res).__name__}&amp;quot;)
print(f&amp;quot;result.att {res.att:+.6f}&amp;quot;)
print(f&amp;quot;result.pre_rmse {res.pre_rmse:.6f}&amp;quot;)
print(f&amp;quot;result.fit_diagnostics.r_squared_pre {res.fit_diagnostics.r_squared_pre:.6f}&amp;quot;)
print(f&amp;quot;result.method_details.method_name {res.method_details.method_name}&amp;quot;)
print(f&amp;quot;len(result.donor_weights) {len(res.donor_weights)}&amp;quot;)
print(f&amp;quot;result.counterfactual.shape {np.shape(res.counterfactual)}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">type(result) BaseEstimatorResults
result.att -0.030388
result.pre_rmse 0.005589
result.fit_diagnostics.r_squared_pre 0.998060
result.method_details.method_name VanillaSC[outcome-only]
len(result.donor_weights) 9
result.counterfactual.shape (87,)
&lt;/code>&lt;/pre>
&lt;p>Two things are worth noticing. &lt;code>len(result.donor_weights)&lt;/code> is 9, not 23: &lt;code>mlsynth&lt;/code> returns only the donors with non-zero weight, so do not treat that dictionary as a dense vector over the donor pool. And &lt;code>method_details.method_name&lt;/code> reports &lt;code>VanillaSC[outcome-only]&lt;/code>, which is the backend the &lt;code>&amp;quot;auto&amp;quot;&lt;/code> setting resolved to — the library tells you which algorithm it actually ran, which is exactly the information you need when comparing against another implementation.&lt;/p>
&lt;p>The result is a &lt;code>BaseEstimatorResults&lt;/code> (aliased &lt;code>EffectResult&lt;/code>), organised into six sub-models plus a set of flat convenience accessors:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Sub-model&lt;/th>
&lt;th>Contains&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>effects&lt;/code>&lt;/td>
&lt;td>&lt;code>att&lt;/code>, &lt;code>att_percent&lt;/code>, &lt;code>att_std_err&lt;/code>, &lt;code>additional_effects&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>fit_diagnostics&lt;/code>&lt;/td>
&lt;td>&lt;code>rmse_pre&lt;/code>, &lt;code>r_squared_pre&lt;/code>, &lt;code>rmse_post&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>time_series&lt;/code>&lt;/td>
&lt;td>&lt;code>observed_outcome&lt;/code>, &lt;code>counterfactual_outcome&lt;/code>, &lt;code>estimated_gap&lt;/code>, &lt;code>time_periods&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>weights&lt;/code>&lt;/td>
&lt;td>&lt;code>donor_weights&lt;/code>, &lt;code>time_weights&lt;/code>, &lt;code>unit_weights&lt;/code>, &lt;code>summary_stats&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>inference&lt;/code>&lt;/td>
&lt;td>&lt;code>p_value&lt;/code>, &lt;code>ci_lower&lt;/code>, &lt;code>ci_upper&lt;/code>, &lt;code>standard_error&lt;/code>, &lt;code>method&lt;/code>, &lt;code>details&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>method_details&lt;/code>&lt;/td>
&lt;td>&lt;code>method_name&lt;/code>, &lt;code>is_recommended&lt;/code>, &lt;code>parameters_used&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Flat accessor&lt;/th>
&lt;th>Resolves to&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>.att&lt;/code>&lt;/td>
&lt;td>&lt;code>effects.att&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>.att_ci&lt;/code>&lt;/td>
&lt;td>&lt;code>(inference.ci_lower, inference.ci_upper)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>.counterfactual&lt;/code>&lt;/td>
&lt;td>&lt;code>time_series.counterfactual_outcome&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>.gap&lt;/code>&lt;/td>
&lt;td>&lt;code>time_series.estimated_gap&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>.donor_weights&lt;/code>&lt;/td>
&lt;td>&lt;code>weights.donor_weights&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>.weight_vector&lt;/code>&lt;/td>
&lt;td>dense array of &lt;code>donor_weights.values()&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>.pre_rmse&lt;/code>&lt;/td>
&lt;td>&lt;code>fit_diagnostics.rmse_pre&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Learn the seven flat accessors and you can read the output of any effect estimator in the library without opening its documentation.&lt;/p>
&lt;h3 id="52-plotting-what-the-package-gives-you-for-free">5.2 Plotting: what the package gives you for free&lt;/h3>
&lt;p>Every effect result carries a &lt;code>.plot()&lt;/code> method driven by a &lt;code>PlotConfig&lt;/code>. Set &lt;code>display_graphs=True&lt;/code> and you get a figure without writing any Matplotlib at all.&lt;/p>
&lt;pre>&lt;code class="language-python">res = VanillaSC(cfg(96, inference=False)).fit()
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))
# display=False on every call. Without it .plot() ends in plt.show(), which in
# a notebook flushes the figure after the FIRST panel and leaves the second empty.
res.plot(kind=&amp;quot;counterfactual&amp;quot;, ax=axes[0], display=False)
res.plot(kind=&amp;quot;gap&amp;quot;, ax=axes[1], display=False)
plt.tight_layout()
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_02_mlsynth_native_plot.png" alt="Two side-by-side panels produced entirely by mlsynth&amp;amp;rsquo;s own plotting code: on the left the observed treated series against its synthetic counterfactual, on the right the estimated gap between them, both on the package&amp;amp;rsquo;s default light background with red and black lines.">&lt;/p>
&lt;p>That figure is deliberately unstyled — it is what the library draws with no help from us, and it is already publishable. &lt;code>kind&lt;/code> takes &lt;code>&amp;quot;auto&amp;quot;&lt;/code>, &lt;code>&amp;quot;counterfactual&amp;quot;&lt;/code> or &lt;code>&amp;quot;gap&amp;quot;&lt;/code>; passing &lt;code>ax=&lt;/code> lets you compose several results into one panel.&lt;/p>
&lt;p>The &lt;code>display=False&lt;/code> above is not cosmetic, and it is worth a paragraph because it is a fourth silent default. &lt;code>.plot()&lt;/code> ends with &lt;code>if pc.display: plt.show()&lt;/code>, and the &lt;code>PlotConfig&lt;/code> it consults is &lt;code>self.plot_config or PlotConfig()&lt;/code> — but the fitted result&amp;rsquo;s &lt;code>plot_config&lt;/code> is &lt;code>None&lt;/code>, so the &lt;code>display_graphs=False&lt;/code> you set on the config never reaches it and the fallback &lt;code>PlotConfig()&lt;/code> has &lt;code>display=True&lt;/code>. In a script the stray &lt;code>plt.show()&lt;/code> is a harmless no-op under the Agg backend. In a notebook it flushes and closes the figure after the first panel, so the second panel renders blank and a stray &lt;code>&amp;lt;Figure size 640x480 with 0 Axes&amp;gt;&lt;/code> appears underneath. The per-call &lt;code>display=False&lt;/code> override is the fix; the same applies any time you compose two &lt;code>.plot()&lt;/code> calls into one figure.&lt;/p>
&lt;p>Cosmetics are configured through the nested &lt;code>plot&lt;/code> field rather than through Matplotlib, so a house style travels with the config:&lt;/p>
&lt;pre>&lt;code class="language-python">from mlsynth.config_models import PlotConfig
styled = VanillaSC(cfg(96, inference=False, plot=PlotConfig(
observed_color=&amp;quot;#d97757&amp;quot;,
counterfactual_colors=[&amp;quot;#6a9bcc&amp;quot;],
counterfactual_linestyle=&amp;quot;--&amp;quot;,
xlabel=&amp;quot;quarter index (renumbered 1..87)&amp;quot;,
ylabel=&amp;quot;log real GDP&amp;quot;,
title=&amp;quot;VanillaSC via PlotConfig&amp;quot;,
display=False,
))).fit()
&lt;/code>&lt;/pre>
&lt;p>The legacy flat fields &lt;code>treated_color&lt;/code> and &lt;code>counterfactual_color&lt;/code> still work — note that the latter takes a &lt;em>list&lt;/em>, not a string — but &lt;code>PlotConfig&lt;/code> is the maintained route and supports themes, save targets and per-call overrides.&lt;/p>
&lt;p>Every remaining figure in this post is drawn by hand in the site&amp;rsquo;s dark palette, because a tutorial benefits from consistent colours. In your own work, &lt;code>display_graphs=True&lt;/code> is usually enough.&lt;/p>
&lt;h3 id="53-where-estimators-deviate-from-the-standard-result">5.3 Where estimators deviate from the standard result&lt;/h3>
&lt;p>Three of the six estimators return a richer object than &lt;code>BaseEstimatorResults&lt;/code>, and each adds exactly what its method needs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Estimator&lt;/th>
&lt;th>Returns&lt;/th>
&lt;th>The extra you need&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>FDID&lt;/code>&lt;/td>
&lt;td>&lt;code>FDIDResults&lt;/code>&lt;/td>
&lt;td>&lt;code>.fdid&lt;/code> and &lt;code>.did&lt;/code>, two &lt;code>FDIDMethodFit&lt;/code> objects — the forward-selected fit and the plain two-way DiD benchmark&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>TSSC&lt;/code>&lt;/td>
&lt;td>&lt;code>TSSCResults&lt;/code>&lt;/td>
&lt;td>&lt;code>.variants&lt;/code> (a dict of the four MSC variants), &lt;code>.selection&lt;/code> (the Step-1 tests), &lt;code>.recommended_method&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>SDID&lt;/code>&lt;/td>
&lt;td>&lt;code>SDIDResults&lt;/code>&lt;/td>
&lt;td>&lt;code>.inference_detail&lt;/code>, &lt;code>.event_study&lt;/code>, &lt;code>.cohorts[a]&lt;/code> (which is where the time weights live)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>All three still populate the standard sub-models and flat accessors, so &lt;code>.att&lt;/code> works everywhere. The extras are additive, not alternative.&lt;/p>
&lt;h2 id="6-a-naming-hazard-before-we-import-anything">6. A naming hazard, before we import anything&lt;/h2>
&lt;p>This section exists because getting it wrong costs nothing at runtime and everything in interpretation.&lt;/p>
&lt;p>&lt;strong>&lt;code>mlsynth.DSC&lt;/code> is not the DSC on this ladder.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>mlsynth.DSC&lt;/code> is &lt;strong>Distributional&lt;/strong> Synthetic Control (Gunsilius 2023, &lt;em>Econometrica&lt;/em>). It reconstructs the treated unit&amp;rsquo;s whole outcome &lt;em>distribution&lt;/em> as a Wasserstein-space average of donor distributions and returns quantile treatment effects. It requires &lt;strong>micro-level&lt;/strong> data: many individual observations per unit-period. Handed a panel like ours, with one observation per country-quarter, it is answering a question the data cannot support.&lt;/li>
&lt;li>The DSC on this ladder is &lt;strong>Demeaned&lt;/strong> Synthetic Control (Doudchenko and Imbens [8]; Ferman and Pinto [9]) — synthetic control plus an intercept. In &lt;code>mlsynth&lt;/code> it is &lt;code>TSSC(method=&amp;quot;MSCa&amp;quot;)&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>Same three letters, different estimators, and no error is raised. The mapping used throughout this post follows &lt;a href="https://github.com/jgreathouse9/mlsynth/issues/312" target="_blank" rel="noopener">mlsynth issue #312&lt;/a>, which is itself a reading of the paper we replicate.&lt;/p>
&lt;p>The library has several more acronym neighbours worth knowing about before you reach for one by name:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Class&lt;/th>
&lt;th>Is actually&lt;/th>
&lt;th>Not to be confused with&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>DSC&lt;/code>&lt;/td>
&lt;td>&lt;strong>D&lt;/strong>istributional SC (Gunsilius)&lt;/td>
&lt;td>demeaned SC = &lt;code>TSSC(method=&amp;quot;MSCa&amp;quot;)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DSCAR&lt;/code>&lt;/td>
&lt;td>&lt;strong>D&lt;/strong>ynamic SC for &lt;strong>A&lt;/strong>uto-&lt;strong>R&lt;/strong>egressive processes&lt;/td>
&lt;td>either of the above&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>SCD&lt;/code>&lt;/td>
&lt;td>SC with &lt;strong>D&lt;/strong>ifferencing&lt;/td>
&lt;td>&lt;code>DSC&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DRSC&lt;/code>&lt;/td>
&lt;td>&lt;strong>D&lt;/strong>istribution-&lt;strong>R&lt;/strong>egression SC&lt;/td>
&lt;td>&lt;code>DSC&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>MEDSC&lt;/code>&lt;/td>
&lt;td>&lt;strong>Med&lt;/strong>iation SC&lt;/td>
&lt;td>demeaned SC&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The general lesson is that in a ninety-two-class library, the class name is a mnemonic and not a definition. Check the docstring before you fit.&lt;/p>
&lt;h2 id="7-one-regression-six-sets-of-weights">7. One regression, six sets of weights&lt;/h2>
&lt;p>Before the code, one unifying idea. Every stage on this ladder is the same weighted two-way regression,&lt;/p>
&lt;p>$$(\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,$$&lt;/p>
&lt;p>with a different choice of the unit weights $\omega_i$ and the time weights $\lambda_t$. In words: fit a two-way fixed-effects regression, but let some units and some periods count more than others. The stages differ only in how those two weight vectors are chosen.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
R[&amp;quot;&amp;lt;b&amp;gt;One weighted two-way regression&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;min Σ ω&amp;lt;sub&amp;gt;i&amp;lt;/sub&amp;gt; λ&amp;lt;sub&amp;gt;t&amp;lt;/sub&amp;gt; (Y&amp;lt;sub&amp;gt;it&amp;lt;/sub&amp;gt; − μ − α&amp;lt;sub&amp;gt;i&amp;lt;/sub&amp;gt; − β&amp;lt;sub&amp;gt;t&amp;lt;/sub&amp;gt; − τD&amp;lt;sub&amp;gt;it&amp;lt;/sub&amp;gt;)²&amp;quot;]
R --&amp;gt; A[&amp;quot;ω uniform, λ uniform&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;DiD&amp;lt;/b&amp;gt;&amp;quot;]
R --&amp;gt; B[&amp;quot;ω fitted, λ uniform, no intercept&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;SC&amp;lt;/b&amp;gt;&amp;quot;]
R --&amp;gt; C[&amp;quot;ω fitted, λ uniform, intercept&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;DSC&amp;lt;/b&amp;gt;&amp;quot;]
R --&amp;gt; D[&amp;quot;ω fitted, λ fitted, intercept&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;SDID&amp;lt;/b&amp;gt;&amp;quot;]
R --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Change the feasible set instead&amp;lt;/b&amp;gt;&amp;quot;]
E --&amp;gt; F[&amp;quot;blend with m-nearest-neighbour matching&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;MASC&amp;lt;/b&amp;gt;&amp;quot;]
E --&amp;gt; G[&amp;quot;allow negative weights, penalise them&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;ASCM&amp;lt;/b&amp;gt;&amp;quot;]
style R fill:#141413,stroke:#6a9bcc,color:#fff
style A fill:#8b9dc3,stroke:#141413,color:#fff
style B fill:#6a9bcc,stroke:#141413,color:#fff
style C fill:#00d4c8,stroke:#141413,color:#141413
style D fill:#d97757,stroke:#141413,color:#fff
style E fill:#141413,stroke:#d97757,color:#fff
style F fill:#6a9bcc,stroke:#141413,color:#fff
style G fill:#6a9bcc,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The diagram splits the ladder into two families. The first four stages change &lt;em>which weights&lt;/em> the same regression uses. The last two change &lt;em>what weights are allowed at all&lt;/em> — MASC by mixing in a different estimator, ASCM by relaxing the simplex. That distinction is why the ladder branches rather than continuing upward, and it is the reason section 16&amp;rsquo;s tournament cannot simply declare the top stage the winner.&lt;/p>
&lt;p>Here is the whole ladder as &lt;code>mlsynth&lt;/code> code, which is the table to bookmark:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>$\omega$&lt;/th>
&lt;th>$\lambda$&lt;/th>
&lt;th>mlsynth call&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DiD&lt;/td>
&lt;td>uniform&lt;/td>
&lt;td>uniform&lt;/td>
&lt;td>&lt;code>FDID(cfg).fit().did&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>fitted, simplex&lt;/td>
&lt;td>uniform&lt;/td>
&lt;td>&lt;code>VanillaSC(cfg)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>fitted, simplex + intercept&lt;/td>
&lt;td>uniform&lt;/td>
&lt;td>&lt;code>TSSC(cfg, method=&amp;quot;MSCa&amp;quot;)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID&lt;/td>
&lt;td>fitted, simplex + intercept&lt;/td>
&lt;td>fitted, simplex&lt;/td>
&lt;td>&lt;code>SDID(cfg, zeta=0.0)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>convex blend of matching and SC&lt;/td>
&lt;td>uniform&lt;/td>
&lt;td>&lt;code>MASC(cfg, set_f=...)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>SC weights plus a ridge correction&lt;/td>
&lt;td>uniform&lt;/td>
&lt;td>&lt;code>VanillaSC(cfg, augment=&amp;quot;ridge&amp;quot;)&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Now we climb it.&lt;/p>
&lt;h2 id="8-stage-0--difference-in-differences">8. Stage 0 — Difference-in-differences&lt;/h2>
&lt;p>&lt;code>mlsynth&lt;/code> has no standalone DiD class, and this trips people up. The plain two-way estimator comes free inside &lt;code>FDID&lt;/code> (Forward DiD, Li 2024), which fits its own estimator and the textbook benchmark side by side and exposes them as &lt;code>.fdid&lt;/code> and &lt;code>.did&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-python">fdid_res = {k: FDID(cfg(e)).fit() for k, e in EVAL.items()}
did = {k: r.did for k, r in fdid_res.items()}
print(f&amp;quot;DiD 2018Q4 {pct(did['2018Q4'].att):.2f}% 2019Q4 {pct(did['2019Q4'].att):.2f}%&amp;quot;)
print(f&amp;quot;SE (analytic) {100 * did['2018Q4'].att_se:.2f}&amp;quot;)
wd = did[&amp;quot;2018Q4&amp;quot;].donor_weights
print(f&amp;quot;donor weights: {len(wd)} donors, all equal to {next(iter(wd.values())):.6f} = 1/{len(wd)}&amp;quot;)
print(f&amp;quot;pre-treatment RMSE {did['2018Q4'].pre_rmse:.5f}, R^2 {did['2018Q4'].r_squared:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">DiD 2018Q4 4.98% 2019Q4 6.18%
SE (analytic) 2.19
donor weights: 23 donors, all equal to 0.043478 = 1/23
pre-treatment RMSE 0.02170, R^2 0.9706
&lt;/code>&lt;/pre>
&lt;p>Difference-in-differences puts the Brexit cost at 4.98% of GDP by the end of 2018 — far above every other stage, and far above the 2.4% previously published. The uniform $1/23 = 0.043478$ weights are the giveaway that nothing was fitted: DiD assumes the average of all twenty-three OECD economies would have moved in parallel with the UK, and the pre-treatment RMSE of 0.0217 says it did not. That number is roughly four times the 0.0056 that synthetic control achieves, and it is the entire reason the rest of the ladder exists.&lt;/p>
&lt;p>&lt;strong>A free seventh stage.&lt;/strong> The same call also gives you Forward DiD, which selects a subset of donors greedily and then runs DiD on them. It is not on the paper&amp;rsquo;s ladder, but it costs nothing:&lt;/p>
&lt;pre>&lt;code class="language-python">f18 = fdid_res[&amp;quot;2018Q4&amp;quot;].fdid
print(f&amp;quot;Forward DiD selects {len(f18.selected_names)} donors: {', '.join(f18.selected_names)}&amp;quot;)
print(f&amp;quot;Forward DiD 2018Q4 {pct(f18.att):.2f}% pre-RMSE {f18.pre_rmse:.5f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Forward DiD selects 4 donors: Norway, Hungary, Austria, United States
Forward DiD 2018Q4 2.42% pre-RMSE 0.00880
&lt;/code>&lt;/pre>
&lt;p>Forward DiD picks four donors, cuts the pre-treatment RMSE from 0.0217 to 0.0088, and lands at 2.42% — remarkably close to Born et al.&amp;rsquo;s published 2.4%, and the lowest estimate anywhere in this post. Three of its four donors (Norway, Hungary, the United States) are also among synthetic control&amp;rsquo;s five largest weights, which is reassuring: two quite different selection procedures are finding the same countries.&lt;/p>
&lt;h2 id="9-stage-1--synthetic-control">9. Stage 1 — Synthetic control&lt;/h2>
&lt;p>DiD&amp;rsquo;s complaint is that the donor average does not look like the UK. Synthetic control fixes that by fitting the unit weights, subject to the simplex constraint&lt;/p>
&lt;p>$$\hat\omega = \arg\min_{\omega \in \mathbb{W}} \sum_{t=1}^{T_0} \left( Y_{\text{UK},t} - \sum_j \omega_j Y_{j,t} \right)^2, \qquad \mathbb{W} = \left\{ \omega : \omega_j \ge 0, \sum_j \omega_j = 1 \right\}.$$&lt;/p>
&lt;p>In words, pick the non-negative weights summing to one that make the blend track the UK as closely as possible over the 86 pre-treatment quarters. In code, &lt;code>Y&lt;/code> is the &lt;code>log_rgdp&lt;/code> column, the sum over $j$ runs over &lt;code>DONORS&lt;/code>, and $T_0$ is &lt;code>T0 = 86&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-python">sc = {k: VanillaSC(cfg(e, inference=False)).fit() for k, e in EVAL.items()}
s18 = sc[&amp;quot;2018Q4&amp;quot;]
print(f&amp;quot;SC 2018Q4 {pct(s18.att):.2f}% 2019Q4 {pct(sc['2019Q4'].att):.2f}%&amp;quot;)
print(f&amp;quot;backend chosen by 'auto': {s18.method_details.method_name}&amp;quot;)
print(f&amp;quot;pre-RMSE {s18.pre_rmse:.6f} R^2 {s18.fit_diagnostics.r_squared_pre:.5f}&amp;quot;)
ss = s18.weights.summary_stats
print(f&amp;quot;weights: {ss['n_nonzero']} nonzero, sum {ss['sum_of_weights']:.6f}, {ss['constraint']}&amp;quot;)
for c, w in sorted(s18.donor_weights.items(), key=lambda kv: -kv[1]):
if w &amp;gt; 0.01:
print(f&amp;quot; {c:&amp;lt;16s} {w:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">SC 2018Q4 3.04% 2019Q4 4.17%
backend chosen by 'auto': VanillaSC[outcome-only]
pre-RMSE 0.005589 R^2 0.99806
weights: 9 nonzero, sum 1.000000, simplex (non-negative, sum to 1)
Hungary 0.2231
United States 0.1926
Japan 0.1826
Canada 0.1751
Norway 0.1350
Ireland 0.0523
Italy 0.0196
Portugal 0.0124
&lt;/code>&lt;/pre>
&lt;p>Synthetic control puts the shortfall at 3.04% by the end of 2018 and 4.17% a year later, with a pre-treatment $R^2$ of 0.998. The synthetic UK is roughly a fifth Hungary, a fifth the United States, a fifth Japan, a sixth Canada and an eighth Norway. Fourteen of the twenty-three donors get nothing at all — this sparsity is a feature of the simplex constraint, not an accident, and it is why synthetic control estimates are usually easy to describe in a sentence.&lt;/p>
&lt;p>Note that the United States carries about a fifth of the counterfactual. That will matter in section 19, when we ask whether the answer survives dropping it.&lt;/p>
&lt;h3 id="91-the-five-backends">9.1 The five backends&lt;/h3>
&lt;p>&lt;code>VanillaSC&lt;/code> has a &lt;code>backend&lt;/code> field with five settings, and understanding when each applies saves a lot of confusion:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>backend&lt;/code>&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>When it applies&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>&amp;quot;auto&amp;quot;&lt;/code> (default)&lt;/td>
&lt;td>&lt;code>&amp;quot;outcome-only&amp;quot;&lt;/code> without covariates, &lt;code>&amp;quot;mscmt&amp;quot;&lt;/code> with them&lt;/td>
&lt;td>always a safe default&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;outcome-only&amp;quot;&lt;/code>&lt;/td>
&lt;td>convex simplex fit on pre-treatment outcomes&lt;/td>
&lt;td>no covariates; unique solution&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;mscmt&amp;quot;&lt;/code>&lt;/td>
&lt;td>global differential-evolution search over predictor weights $V$&lt;/td>
&lt;td>covariates (Becker-Klössner)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;malo&amp;quot;&lt;/code>&lt;/td>
&lt;td>corner search over $V$ (Malo et al. 2024)&lt;/td>
&lt;td>covariates&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;penalized&amp;quot;&lt;/code>&lt;/td>
&lt;td>Abadie-L&amp;rsquo;Hour unique/sparse estimator&lt;/td>
&lt;td>covariates, when uniqueness matters&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The distinction that matters: &lt;strong>with no covariates the problem is convex and has a unique solution; with covariates it becomes a bilevel program whose predictor weights are generically not identified.&lt;/strong> That is why &lt;code>VanillaSC&lt;/code> reports a &lt;code>v_agreement&lt;/code> diagnostic alongside covariate fits — small means the predictor weights are well identified, large means they are fragile. We come back to this in section 17.&lt;/p>
&lt;h3 id="92-one-estimator-four-solvers">9.2 One estimator, four solvers&lt;/h3>
&lt;p>Here is where the package-level reading earns its keep. Plain synthetic control is a single mathematical object, but &lt;code>mlsynth&lt;/code> can reach it four different ways, and R&amp;rsquo;s &lt;code>synthdid&lt;/code> reaches it a fifth.&lt;/p>
&lt;p>(&lt;code>tssc_att&lt;/code> below is a three-line helper that reads &lt;code>TSSC&lt;/code>&amp;rsquo;s estimate off its
gap series rather than its rounded &lt;code>.att&lt;/code> field. Section 10.1 explains why it is
needed; for now take it as &amp;ldquo;the unrounded ATT&amp;rdquo;.)&lt;/p>
&lt;pre>&lt;code class="language-python">for label, klass, kw in [
(&amp;quot;VanillaSC, backend='auto'&amp;quot;, VanillaSC, dict(inference=False)),
(&amp;quot;VanillaSC, backend='outcome-only'&amp;quot;, VanillaSC, dict(backend=&amp;quot;outcome-only&amp;quot;, inference=False)),
(&amp;quot;VanillaSC, w_constr='simplex'&amp;quot;, VanillaSC, dict(w_constr=&amp;quot;simplex&amp;quot;, inference=False)),
(&amp;quot;TSSC, method='SC'&amp;quot;, TSSC, dict(method=&amp;quot;SC&amp;quot;, inference=False)),
]:
r = klass(cfg(96, **kw)).fit()
att = tssc_att(r, &amp;quot;SC&amp;quot;) if klass is TSSC else r.effects.att
print(f&amp;quot;{label:&amp;lt;36s} 2018Q4 {pct(att):.3f}&amp;quot;)
# Reference value from the R edition, not refitted here (see section 15).
print(f&amp;quot;{'R synthdid (Frank-Wolfe, published)':&amp;lt;36s} 2018Q4 3.060&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">VanillaSC, backend='auto' 2018Q4 3.039
VanillaSC, backend='outcome-only' 2018Q4 3.039
VanillaSC, w_constr='simplex' 2018Q4 3.039
TSSC, method='SC' 2018Q4 3.039
R synthdid (Frank-Wolfe, published) 2018Q4 3.060
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_03_solver_comparison.png" alt="Horizontal bar chart comparing the 2018Q4 estimate from four mlsynth solver routes, all at 3.039 percent in steel blue, against R&amp;amp;rsquo;s Frank-Wolfe implementation at 3.060 percent in orange.">&lt;/p>
&lt;p>Four independent code paths inside &lt;code>mlsynth&lt;/code> — two backends, an explicit constraint family, and a completely different estimator class — agree to three decimals at 3.039%. R&amp;rsquo;s &lt;code>synthdid&lt;/code> stops at 3.06%. Section 15 explains why, and argues that this is the most interesting number in the post.&lt;/p>
&lt;p>The &lt;code>w_constr&lt;/code> field, incidentally, is a general escape hatch. It accepts &lt;code>&amp;quot;simplex&amp;quot;&lt;/code>, &lt;code>&amp;quot;ols&amp;quot;&lt;/code>, &lt;code>&amp;quot;lasso&amp;quot;&lt;/code>, &lt;code>&amp;quot;ridge&amp;quot;&lt;/code> and &lt;code>&amp;quot;L1-L2&amp;quot;&lt;/code>, which lets you ask what the estimate would be under a different feasible set without changing estimator classes. Section 19 uses it.&lt;/p>
&lt;h3 id="93-the-counterfactual-path">9.3 The counterfactual path&lt;/h3>
&lt;p>The windowed fits above answer &amp;ldquo;how large is the shortfall at 2018Q4&amp;rdquo;. To draw the whole counterfactual path, fit once on the untruncated panel.&lt;/p>
&lt;pre>&lt;code class="language-python">full = cfg(list(range(T0 + 1, 105))) # all 18 post-treatment quarters
sc_full = VanillaSC(dict(full, inference=False)).fit()
cf_sc = np.asarray(sc_full.counterfactual, float).ravel()
gap_sc = np.asarray(sc_full.gap, float).ravel()
print(f&amp;quot;pre-RMSE {sc_full.pre_rmse:.6f}, mean post gap {gap_sc[T0:].mean():+.5f} log points&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">pre-RMSE 0.005589, mean post gap -0.02882 log points
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_04_sc_fit_gap.png" alt="Two stacked panels: on top the UK log real GDP against its synthetic control, indistinguishable until 2016 and then diverging; below, the gap between them, hovering around zero for two decades before turning persistently negative and shaded orange after the referendum.">&lt;/p>
&lt;p>The average post-treatment gap is $-0.0288$ log points, or about 2.9% of GDP averaged across all eighteen quarters after the referendum. The bottom panel is the one to look at: the gap oscillates around zero for eighty-six quarters and then goes negative and stays there. That persistence, rather than the size of any single quarter&amp;rsquo;s gap, is what makes the result credible.&lt;/p>
&lt;h2 id="10-stage-2--demeaned-synthetic-control">10. Stage 2 — Demeaned synthetic control&lt;/h2>
&lt;p>Synthetic control insists the blend match the UK&amp;rsquo;s &lt;em>level&lt;/em>. Sometimes a blend tracks the shape perfectly while sitting slightly above or below, and plain SC will reject it in favour of a worse-shaped blend at the right level. Demeaned SC adds one free parameter — a constant offset — so the blend has to match the shape but not the level.&lt;/p>
&lt;p>In &lt;code>mlsynth&lt;/code> this is the &lt;code>MSCa&lt;/code> variant of the two-step estimator of Li and Shankar (2023).&lt;/p>
&lt;pre>&lt;code class="language-python">dsc = {k: TSSC(cfg(e, method=&amp;quot;MSCa&amp;quot;, inference=False)).fit() for k, e in EVAL.items()}
d18 = dsc[&amp;quot;2018Q4&amp;quot;].variants[&amp;quot;MSCa&amp;quot;]
print(f&amp;quot;DSC 2018Q4 {pct(tssc_att(dsc['2018Q4'])):.2f}% 2019Q4 {pct(tssc_att(dsc['2019Q4'])):.2f}%&amp;quot;)
print(f&amp;quot;MSCa intercept: {d18.intercept:+.6f} log points = {100 * d18.intercept:+.3f}% of GDP&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">DSC 2018Q4 2.99% 2019Q4 4.12%
MSCa intercept: +0.002410 log points = +0.241% of GDP
&lt;/code>&lt;/pre>
&lt;p>The estimated offset is $+0.0024$ log points, about a quarter of one per cent of GDP. That is small, and its smallness is informative: it says the SC fit was already close to level-balanced, which is why DSC&amp;rsquo;s 2.99% sits so near SC&amp;rsquo;s 3.04%.&lt;/p>
&lt;p>&lt;img src="python_sc_dsc_sdid_05_dsc_offset.png" alt="The UK, plain synthetic control and demeaned synthetic control from 2010 to 2020, with the demeaned series shifted by a small constant offset annotated at plus 0.0024 log points.">&lt;/p>
&lt;h3 id="101-a-precision-trap-worth-knowing-about">10.1 A precision trap worth knowing about&lt;/h3>
&lt;p>&lt;code>TSSC&lt;/code> &lt;strong>rounds every scalar it reports.&lt;/strong> The series it returns are full precision, but &lt;code>.att&lt;/code>, &lt;code>.rmse_pre&lt;/code> and the donor weights are rounded before they reach you.&lt;/p>
&lt;pre>&lt;code class="language-python">print(f&amp;quot;variants['MSCa'].att {d18.att!r} -&amp;gt; {pct(d18.att):.4f}%&amp;quot;)
print(f&amp;quot;gap[-1] (full precision) {np.asarray(d18.gap, float)[-1]!r}&amp;quot;)
print(f&amp;quot; -&amp;gt; {pct(tssc_att(dsc['2018Q4'])):.4f}%&amp;quot;)
print(f&amp;quot;variants['MSCa'].rmse_pre {d18.rmse_pre!r}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">variants['MSCa'].att -0.03 -&amp;gt; 3.0000%
gap[-1] (full precision) np.float64(-0.0298873295228968)
-&amp;gt; 2.9887%
variants['MSCa'].rmse_pre 0.006
&lt;/code>&lt;/pre>
&lt;p>Take the headline off &lt;code>.att&lt;/code> and DSC reads exactly 3.00%. Take it off the gap series and it reads 2.99%, which is what &lt;code>mlsynth&lt;/code>&amp;rsquo;s own replication table reports and what the published paper reports. A hundredth of a percentage point is not going to change anyone&amp;rsquo;s policy view, but it will make you think you have failed to replicate a table when you have not. The fix is three lines:&lt;/p>
&lt;pre>&lt;code class="language-python">def tssc_att(res, variant=&amp;quot;MSCa&amp;quot;, n_post=1):
&amp;quot;&amp;quot;&amp;quot;Full-precision ATT for a TSSC variant, read off the unrounded gap series.&amp;quot;&amp;quot;&amp;quot;
gap = np.asarray(res.variants[variant].gap, float).ravel()
return float(gap[-n_post:].mean())
&lt;/code>&lt;/pre>
&lt;p>The general lesson generalises past &lt;code>TSSC&lt;/code>: when a package hands you both a scalar summary and the series it was computed from, and the two disagree, trust the series.&lt;/p>
&lt;h3 id="102-the-four-variants-and-the-step-1-selection">10.2 The four variants and the Step-1 selection&lt;/h3>
&lt;p>Left to itself, &lt;code>TSSC&lt;/code> fits all four variants of the Li-Shankar estimator and runs a subsampling procedure to pick one. &lt;code>method=&lt;/code> forces a single variant and skips the selection entirely.&lt;/p>
&lt;pre>&lt;code class="language-python">t_all = TSSC(cfg(96, draws=500, seed=SEED)).fit() # no method= : fit all four
print(f&amp;quot;TSSC recommends: {t_all.recommended_method}&amp;quot;)
for m in (&amp;quot;SC&amp;quot;, &amp;quot;MSCa&amp;quot;, &amp;quot;MSCb&amp;quot;, &amp;quot;MSCc&amp;quot;):
v = t_all.variants[m]
ic = &amp;quot;none&amp;quot; if v.intercept is None else f&amp;quot;{v.intercept:+.5f}&amp;quot;
print(f&amp;quot; {m:&amp;lt;5s} loss {pct(tssc_att(t_all, m)):5.3f}% &amp;quot;
f&amp;quot;(.att reports {v.att:+.5f}) intercept {ic}&amp;quot;)
for name, test in t_all.selection.tests.items():
print(f&amp;quot; test '{name}': stat {test.statistic:+.5f} &amp;quot;
f&amp;quot;CI [{test.ci_lower:+.5f}, {test.ci_upper:+.5f}] rejected {test.rejected}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">TSSC recommends: SC
SC loss 3.039% (.att reports -0.03000) intercept none
MSCa loss 2.989% (.att reports -0.03000) intercept +0.00241
MSCb loss 3.041% (.att reports -0.03000) intercept none
MSCc loss 3.021% (.att reports -0.03000) intercept +0.00282
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> test 'joint': stat +2.21193 CI [+0.02894, +9.10199] rejected False
&lt;/code>&lt;/pre>
&lt;p>All four variants land between 2.99% and 3.04%, and all four report &lt;code>.att&lt;/code> as exactly $-0.03$ — a compact demonstration of the rounding trap. The four differ in which constraints they impose: &lt;code>SC&lt;/code> is the simplex with no intercept, &lt;code>MSCa&lt;/code> adds an intercept, &lt;code>MSCb&lt;/code> relaxes the sum-to-one constraint, and &lt;code>MSCc&lt;/code> relaxes both. The Step-1 test does not reject the restriction, so &lt;code>TSSC&lt;/code> recommends plain &lt;code>SC&lt;/code>.&lt;/p>
&lt;p>The cost of that convenience is real. Fitting four variants with 500 subsampling draws takes about 17 seconds; forcing one variant with &lt;code>inference=False&lt;/code> takes 0.01 seconds — a factor of roughly 1,700. If you are running an estimator inside a loop, as we do in section 16, set &lt;code>method=&lt;/code> and &lt;code>inference=False&lt;/code>. Note that &lt;code>inference=False&lt;/code> &lt;em>requires&lt;/em> &lt;code>method&lt;/code> to be set; asking for no inference without naming a variant raises, because there would be nothing to select with.&lt;/p>
&lt;h2 id="11-stage-3--synthetic-difference-in-differences">11. Stage 3 — Synthetic difference-in-differences&lt;/h2>
&lt;p>DSC still treats all eighty-six pre-treatment quarters as equally informative. SDID fits the time weights too, solving the transposed version of the same problem: which blend of quarters, judged across all donors, best predicts the treatment quarter.&lt;/p>
&lt;pre>&lt;code class="language-python">sdid = {k: SDID(cfg(e, zeta=0.0, vce=&amp;quot;placebo&amp;quot;, B=500, seed=SEED)).fit()
for k, e in EVAL.items()}
s = sdid[&amp;quot;2018Q4&amp;quot;]
print(f&amp;quot;SDID 2018Q4 {pct(s.effects.att):.2f}% 2019Q4 {pct(sdid['2019Q4'].effects.att):.2f}%&amp;quot;)
inf = s.inference_detail
print(f&amp;quot;ATT {inf.att:+.5f}, SE {inf.se:.5f}, CI [{inf.ci[0]:+.5f}, {inf.ci[1]:+.5f}]&amp;quot;)
print(f&amp;quot;p = {inf.p_value:.4f}, method '{inf.method}', n_placebo {inf.n_placebo}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">SDID 2018Q4 2.80% 2019Q4 3.94%
ATT -0.02801, SE 0.02636, CI [-0.07968, +0.02365]
p = 0.2016, method 'placebo', n_placebo 500
&lt;/code>&lt;/pre>
&lt;p>SDID puts the shortfall at 2.80% and 3.94%. Note the standard error: 0.0264 against a point estimate of 0.0280, giving a confidence interval that comfortably contains zero and a placebo p-value of 0.20. With one treated unit and twenty-three donors, that is the honest state of the evidence — section 18 returns to it.&lt;/p>
&lt;h3 id="111-the-one-setting-that-carries-the-result">11.1 The one setting that carries the result&lt;/h3>
&lt;pre>&lt;code class="language-python">default = SDID(cfg(96, vce=&amp;quot;noinference&amp;quot;)).fit()
print(f&amp;quot;zeta left at its default: {pct(default.effects.att):.2f}%&amp;quot;)
print(f&amp;quot;zeta = 0.0: {pct(sdid['2018Q4'].effects.att):.2f}%&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">zeta left at its default: 2.67%
zeta = 0.0: 2.80%
&lt;/code>&lt;/pre>
&lt;p>&lt;code>zeta&lt;/code> is a ridge penalty on the unit weights, and it is on by default. Leave it alone and SDID reports 2.67%; set it to zero, which is what the paper solves, and it reports 2.80%. That 0.13-percentage-point gap is larger than the entire spread between SC, DSC and ASCM.&lt;/p>
&lt;p>This is not an &lt;code>mlsynth&lt;/code> quirk. &lt;strong>Every implementation in every language penalises by default&lt;/strong>: R&amp;rsquo;s &lt;code>synthdid&lt;/code> needs &lt;code>zeta.omega = 0&lt;/code>, Stata&amp;rsquo;s &lt;code>sdid&lt;/code> needs &lt;code>zeta_omega(0)&lt;/code>, and &lt;code>mlsynth&lt;/code> needs &lt;code>zeta=0.0&lt;/code>. Stata&amp;rsquo;s version is the nastiest, because its documented default of &lt;code>1e-6&lt;/code> is a magic sentinel that requests the &lt;em>full&lt;/em> penalty. The lesson survives translation: if you are replicating a published synthetic-DiD number, find out what the authors did with the penalty before you conclude anything.&lt;/p>
&lt;p>One companion flag deserves a note because the documentation emphasises it:&lt;/p>
&lt;pre>&lt;code class="language-python">ia = SDID(cfg(96, zeta=0.0, intercept_adjust=True, vce=&amp;quot;noinference&amp;quot;)).fit()
print(f&amp;quot;intercept_adjust=True: {pct(ia.effects.att):.4f}%&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">intercept_adjust=True: 2.8012%
&lt;/code>&lt;/pre>
&lt;p>Identical to four decimals. &lt;code>intercept_adjust&lt;/code> matters when there are several post-treatment periods to average over; under the truncate-and-renumber trick there is exactly one, so there is nothing to adjust. Worth setting anyway if you fit on an untruncated panel.&lt;/p>
&lt;h3 id="112-time-weights-cohorts-and-the-event-study">11.2 Time weights, cohorts and the event study&lt;/h3>
&lt;p>SDID&amp;rsquo;s time weights are the one output that is not where you would first look. They live on the cohort object, not on &lt;code>weights&lt;/code>:&lt;/p>
&lt;pre>&lt;code class="language-python">coh = list(sdid[&amp;quot;2018Q4&amp;quot;].cohorts.values())[0]
lam = np.asarray(coh.time_weights, float)
print(f&amp;quot;cohorts: {list(sdid['2018Q4'].cohorts)} (n_treated={coh.n_treated}, n_post={coh.n_post})&amp;quot;)
print(f&amp;quot;lambda: {len(lam)} weights summing to {lam.sum():.6f}&amp;quot;)
for i in np.where(lam &amp;gt; 1e-4)[0]:
print(f&amp;quot; {QLAB[i]:&amp;lt;8s} {lam[i]:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">cohorts: [87] (n_treated=1, n_post=1)
lambda: 86 weights summing to 1.000000
2008Q4 0.0386
2014Q3 0.0029
2016Q2 0.9585
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_06_sdid_time_weights.png" alt="Stem plot of SDID&amp;amp;rsquo;s eighty-six time weights against year, with almost all the mass on a single orange point at 2016Q2 labelled 0.958, and a dashed gold line marking the uniform weight that difference-in-differences would use.">&lt;/p>
&lt;p>The time weights put 95.85% of their mass on 2016Q2, the last pre-treatment quarter, and essentially nothing on the other eighty-five. Difference-in-differences would place the dashed uniform weight, $1/86 \approx 0.0116$, on all of them.&lt;/p>
&lt;p>Why? Because log real GDP behaves close to a random walk. If the outcome is a random walk, the best predictor of next quarter is &lt;em>this&lt;/em> quarter, and the eighty-five quarters before it add noise rather than information. The source paper reports exactly this collapse and attributes it to the same cause. Do not read it as a bug; read it as SDID correctly discovering that most of the pre-treatment history is not informative about the level at the treatment date.&lt;/p>
&lt;p>&lt;code>mlsynth&lt;/code> also aggregates an event-study estimator alongside the headline ATT, which the R packages on this ladder do not:&lt;/p>
&lt;pre>&lt;code class="language-python">full_sdid = SDID(dict(full, zeta=0.0, vce=&amp;quot;placebo&amp;quot;, B=200, seed=SEED)).fit()
es = full_sdid.event_study
et, tau = np.asarray(es.event_times, float), np.asarray(es.tau, float)
print(f&amp;quot;event times {et.min():.0f}..{et.max():.0f}&amp;quot;)
print(f&amp;quot;post-treatment tau mean {tau[et &amp;gt; 0].mean():+.5f}&amp;quot;)
print(f&amp;quot;pre-treatment tau mean {tau[(et &amp;lt; 0) &amp;amp; (et &amp;gt;= -20)].mean():+.5f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">event times -86..17
post-treatment tau mean -0.02809
pre-treatment tau mean +0.00208
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_07_sdid_event_study.png" alt="Event-study plot of the SDID effect by quarters since the referendum, with a shaded ninety-five percent placebo confidence band, a flat pre-treatment path near zero and a clearly negative post-treatment path.">&lt;/p>
&lt;p>The pre-treatment effects average $+0.0021$ — essentially zero — while the post-treatment effects average $-0.0281$. That flat pre-treatment path is a falsification test the single ATT number cannot give you, and it is available from &lt;code>result.event_study&lt;/code> for the cost of one extra line.&lt;/p>
&lt;h3 id="113-three-flavours-of-sdid">11.3 Three flavours of SDID&lt;/h3>
&lt;p>The time weights have to be fitted against &lt;em>something&lt;/em> in the post-treatment period, and there are three natural choices. The published paper reports all three, and only the last falls out of a bare &lt;code>.fit()&lt;/code>.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Variant&lt;/th>
&lt;th>$\lambda$ is fitted to predict&lt;/th>
&lt;th>Evaluated at&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>(i)&lt;/td>
&lt;td>the first treated quarter, 2016Q3&lt;/td>
&lt;td>any horizon&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>(ii)&lt;/td>
&lt;td>the average of 2016Q3 through the evaluation date&lt;/td>
&lt;td>that date&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>(iii)&lt;/td>
&lt;td>the evaluation quarter alone&lt;/td>
&lt;td>that date&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Variants (i) and (ii) need the weights the result already hands you, applied at a different quarter. This is a good demonstration of reading $\omega$ and $\lambda$ back out of a fitted result and using them yourself:&lt;/p>
&lt;p>$$\hat\tau_t = \left( Y_{\text{UK},t} - \sum_j \hat\omega_j Y_{j,t} \right) - \sum_{s \le T_0} \hat\lambda_s \left( Y_{\text{UK},s} - \sum_j \hat\omega_j Y_{j,s} \right).$$&lt;/p>
&lt;p>In words: take the gap at the evaluation quarter, then subtract the $\lambda$-weighted average gap over the pre-treatment period. The second term is the bias adjustment, and it is the only thing that differs between the three flavours.&lt;/p>
&lt;pre>&lt;code class="language-python">def sdid_weights(post, pre=T0):
&amp;quot;&amp;quot;&amp;quot;Fit SDID on a given post window; return (omega dict, lambda array).&amp;quot;&amp;quot;&amp;quot;
res = SDID(cfg(post, pre, zeta=0.0, vce=&amp;quot;noinference&amp;quot;)).fit()
return res.donor_weights, np.asarray(list(res.cohorts.values())[0].time_weights, float)
def sdid_loss(w, lam, t, pre=T0):
&amp;quot;&amp;quot;&amp;quot;Apply an (omega, lambda) pair at an arbitrary quarter.&amp;quot;&amp;quot;&amp;quot;
wv = np.array([w[c] for c in DONORS])
gap = lambda s: float(Y.loc[s, TREATED] - Y.loc[s, DONORS].to_numpy() @ wv)
bias = float(sum(lam[s - 1] * gap(s) for s in range(1, pre + 1)))
return -100.0 * (gap(t) - bias)
w_i, lam_i = sdid_weights(T0 + 1) # (i)
w_ii, lam_ii = sdid_weights(range(T0 + 1, 97)) # (ii)
w_iii, lam_iii = sdid_weights(96) # (iii)
print(f&amp;quot;SDID (i) 2018Q4 {sdid_loss(w_i, lam_i, 96):.3f}&amp;quot;)
print(f&amp;quot;SDID (ii) 2018Q4 {sdid_loss(w_ii, lam_ii, 96):.3f}&amp;quot;)
print(f&amp;quot;SDID (iii) 2018Q4 {sdid_loss(w_iii, lam_iii, 96):.3f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">SDID (i) 2018Q4 2.771
SDID (ii) 2018Q4 2.801
SDID (iii) 2018Q4 2.801
&lt;/code>&lt;/pre>
&lt;p>The three variants land within &lt;strong>0.03 percentage points&lt;/strong> of each other, because all three put essentially all their time weight on the same last pre-treatment quarter. Whatever else is uncertain here, the choice among SDID flavours is not where the uncertainty lives — a conclusion the published paper&amp;rsquo;s placebo table appears to contradict, and section 16 shows why that appearance is an artefact.&lt;/p>
&lt;h3 id="114-choosing-an-inference-method">11.4 Choosing an inference method&lt;/h3>
&lt;p>&lt;code>SDID&lt;/code>&amp;rsquo;s &lt;code>vce&lt;/code> field takes four values, and the right choice depends on what you are doing:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>vce&lt;/code>&lt;/th>
&lt;th>Method&lt;/th>
&lt;th>Use when&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>&amp;quot;placebo&amp;quot;&lt;/code> (default)&lt;/td>
&lt;td>refit treating each donor as pseudo-treated&lt;/td>
&lt;td>one treated unit — the only valid choice here&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;jackknife&amp;quot;&lt;/code>&lt;/td>
&lt;td>leave-one-treated-unit-out&lt;/td>
&lt;td>several treated units&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;bootstrap&amp;quot;&lt;/code>&lt;/td>
&lt;td>resample units with replacement&lt;/td>
&lt;td>many treated units&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;noinference&amp;quot;&lt;/code>&lt;/td>
&lt;td>skip it&lt;/td>
&lt;td>inside a loop, where you only want the point estimate&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With a single treated unit, jackknife and bootstrap have nothing to resample, so placebo is the only defensible option. &lt;code>&amp;quot;noinference&amp;quot;&lt;/code> is what section 16&amp;rsquo;s tournament uses, and it is the difference between a two-minute loop and a two-hour one.&lt;/p>
&lt;h2 id="12-stage-4--masc">12. Stage 4 — MASC&lt;/h2>
&lt;p>Sections 9 to 11 improve the counterfactual by choosing better weights within the simplex. MASC (Kellogg, Mogstad, Pouliot and Torgovitsky [11]) does something different: it forms a convex combination of synthetic control and $m$-nearest-neighbour matching,&lt;/p>
&lt;p>$$\hat{Y}^{\text{MASC}} = \phi \cdot \hat{Y}^{\text{match}}_m + (1 - \phi) \cdot \hat{Y}^{\text{SC}},$$&lt;/p>
&lt;p>and chooses $m$ and $\phi$ jointly by rolling-origin cross-validation. The motivation is the bias decomposition: synthetic control attacks extrapolation bias, matching attacks interpolation bias, and the cross-validation buys whichever trade-off the data prefer.&lt;/p>
&lt;pre>&lt;code class="language-python">M_GRID = list(range(1, 11))
SET_F = list(range(6, T0 + 1))
masc = {k: MASC(cfg(e, m_grid=M_GRID, set_f=SET_F)).fit() for k, e in EVAL.items()}
m18 = masc[&amp;quot;2018Q4&amp;quot;]
print(f&amp;quot;MASC 2018Q4 {pct(m18.att):.2f}% 2019Q4 {pct(masc['2019Q4'].att):.2f}%&amp;quot;)
print(f&amp;quot;weights.summary_stats: {m18.weights.summary_stats}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">MASC 2018Q4 2.73% 2019Q4 3.83%
weights.summary_stats: {'constraint': 'simplex (matching+SC blend)', 'phi_hat': 0.1576922233857826, 'm_hat': 10}
&lt;/code>&lt;/pre>
&lt;p>MASC gives 2.73%, the lowest estimate on the ladder. The two tuned dials are in &lt;code>weights.summary_stats&lt;/code>, not in &lt;code>method_details.parameters_used&lt;/code> — which is &lt;code>None&lt;/code> for this estimator, so looking there first will send you away empty-handed. The cross-validation picked $m = 10$ neighbours and $\phi = 0.158$, so the estimate is roughly one-sixth matching and five-sixths synthetic control.&lt;/p>
&lt;h3 id="121-the-argument-that-decides-the-answer">12.1 The argument that decides the answer&lt;/h3>
&lt;p>&lt;code>set_f&lt;/code> and &lt;code>min_preperiods&lt;/code> both control the cross-validation fold set, and they are mutually exclusive. The default is not the fold set the paper uses, and the difference is not subtle.&lt;/p>
&lt;pre>&lt;code class="language-python">for label, kw in [
(&amp;quot;set_f=range(6, 87) [the paper]&amp;quot;, dict(m_grid=M_GRID, set_f=SET_F)),
(&amp;quot;min_preperiods=None [default]&amp;quot;, dict(m_grid=M_GRID)),
(&amp;quot;min_preperiods=43 [ceil(T0/2)]&amp;quot;, dict(m_grid=M_GRID, min_preperiods=43)),
]:
vals = [pct(MASC(cfg(e, **kw)).fit().att) for e in EVAL.values()]
print(f&amp;quot;{label:&amp;lt;34s} 2018Q4 {vals[0]:.3f} 2019Q4 {vals[1]:.3f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">set_f=range(6, 87) [the paper] 2018Q4 2.726 2019Q4 3.828
min_preperiods=None [default] 2018Q4 3.191 2019Q4 4.325
min_preperiods=43 [ceil(T0/2)] 2018Q4 3.191 2019Q4 4.325
&lt;/code>&lt;/pre>
&lt;p>One argument moves the estimate by &lt;strong>0.47 percentage points&lt;/strong>, from 2.73% to 3.19%. That is bigger than the gap between the highest and lowest stages of the entire ladder excluding DiD. The default &lt;code>min_preperiods&lt;/code> resolves to $\lceil T_0/2 \rceil = 43$, which uses only the second half of the pre-treatment period for cross-validation; passing &lt;code>set_f=range(6, 87)&lt;/code> uses folds starting at quarter 6, which is what the paper does and what R&amp;rsquo;s &lt;code>masc&lt;/code> reproduces to three decimals.&lt;/p>
&lt;p>This is the second of the three defaults promised in the overview. Like &lt;code>zeta&lt;/code>, it is not wrong — it is a defensible choice that happens not to be the one your reference used.&lt;/p>
&lt;p>Tracing the cross-validation makes the trade-off visible. Refitting with a single-element &lt;code>m_grid&lt;/code> forces each neighbour count in turn:&lt;/p>
&lt;pre>&lt;code class="language-python">rows = []
for m in M_GRID:
r = MASC(cfg(96, m_grid=[m], set_f=SET_F)).fit()
st = r.weights.summary_stats
rows.append(dict(m=m, loss=pct(r.att), phi_hat=st[&amp;quot;phi_hat&amp;quot;]))
print(pd.DataFrame(rows).to_string(index=False, float_format=lambda v: f&amp;quot;{v:.5f}&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> m loss phi_hat
1 2.89028 0.03808
2 3.01677 0.00531
3 2.97685 0.06197
4 3.09494 0.08790
5 3.18100 0.09211
6 3.23477 0.08848
7 3.07342 0.04640
8 2.96191 0.11266
9 2.95586 0.12382
10 2.72551 0.15769
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_08_masc_cv.png" alt="Bar chart of the 2018Q4 estimate for each forced number of matched neighbours from one to ten, each bar labelled with the corresponding phi, with the winning m equal to ten highlighted in orange and a dashed teal line at the freely cross-validated answer of 2.73 percent.">&lt;/p>
&lt;p>Two patterns. First, $\phi$ rises with $m$ — the more neighbours you average over, the more weight the cross-validation is willing to put on matching, because averaging more neighbours reduces the variance that makes matching unattractive. Second, the estimate is not monotone in $m$: it wanders between 2.73% and 3.23% with no obvious structure. That non-monotonicity is worth remembering when someone reports a single MASC number without saying what grid produced it.&lt;/p>
&lt;h2 id="13-stage-5--augmented-synthetic-control">13. Stage 5 — Augmented synthetic control&lt;/h2>
&lt;p>Every stage so far assumes the treated unit lies inside the convex hull of the donors, because the simplex cannot reach outside it. If the fit is imperfect — if there is pre-treatment imbalance that no non-negative blend can close — augmented SC (Ben-Michael, Feller and Rothstein [12]) fits a ridge regression to whatever imbalance is left and corrects for it. The correction is allowed to use negative weights.&lt;/p>
&lt;pre>&lt;code class="language-python">ascm = {k: VanillaSC(cfg(e, augment=&amp;quot;ridge&amp;quot;, inference=False)).fit() for k, e in EVAL.items()}
a18 = ascm[&amp;quot;2018Q4&amp;quot;]
aw = np.array(list(a18.donor_weights.values()))
print(f&amp;quot;ASCM 2018Q4 {pct(a18.att):.2f}% 2019Q4 {pct(ascm['2019Q4'].att):.2f}%&amp;quot;)
print(f&amp;quot;weights: sum {aw.sum():.5f}, {int((aw &amp;lt; -1e-6).sum())} negative, &amp;quot;
f&amp;quot;min {aw.min():+.4f}, max {aw.max():+.4f}&amp;quot;)
print(f&amp;quot;pre-RMSE {a18.pre_rmse:.6f} vs SC's {s18.pre_rmse:.6f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">ASCM 2018Q4 3.04% 2019Q4 4.19%
weights: sum 1.00000, 8 negative, min -0.0090, max +0.2262
pre-RMSE 0.005428 vs SC's 0.005589
&lt;/code>&lt;/pre>
&lt;p>Augmented SC lands at 3.04%, indistinguishable from plain SC. The reason is visible in the weights: eight are negative, but the largest in magnitude is $-0.0090$, and the pre-treatment RMSE improves only from 0.005589 to 0.005428. There was very little imbalance left for the ridge correction to fix, because the UK sits comfortably inside the convex hull of twenty-three OECD economies. Augmentation earns its keep when the treated unit is extreme; here it has almost nothing to do.&lt;/p>
&lt;p>The &lt;code>augment&lt;/code> field is the whole interface — one keyword on the class you already used for plain SC. Two companions are worth knowing:&lt;/p>
&lt;pre>&lt;code class="language-python">res_ascm = {k: VanillaSC(cfg(e, augment=&amp;quot;ridge&amp;quot;, residualize=True, inference=False)).fit()
for k, e in EVAL.items()}
print(f&amp;quot;residualize=True: 2018Q4 {pct(res_ascm['2018Q4'].att):.2f}% &amp;quot;
f&amp;quot;2019Q4 {pct(res_ascm['2019Q4'].att):.2f}%&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">residualize=True: 2018Q4 3.04% 2019Q4 4.19%
&lt;/code>&lt;/pre>
&lt;p>&lt;code>residualize=True&lt;/code> is the paper&amp;rsquo;s &amp;ldquo;ASCM res.&amp;rdquo; column — it residualises the outcome on covariates before augmenting. With no covariates supplied there is nothing to residualise on, so it returns the same numbers, which is the correct behaviour rather than a silent failure. &lt;code>ridge_lambda&lt;/code> lets you fix the penalty by hand instead of letting the cross-validation choose it.&lt;/p>
&lt;h2 id="14-the-whole-ladder-side-by-side">14. The whole ladder, side by side&lt;/h2>
&lt;p>Every section above appended one row to a running ledger; &lt;code>analysis.py&lt;/code> writes it
out as &lt;code>att_headline.csv&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-python">ladder = pd.read_csv(&amp;quot;att_headline.csv&amp;quot;)
print(ladder[[&amp;quot;method&amp;quot;, &amp;quot;command&amp;quot;, &amp;quot;loss_2018Q4&amp;quot;, &amp;quot;loss_2019Q4&amp;quot;,
&amp;quot;r_post_2018Q4&amp;quot;, &amp;quot;published_2018Q4&amp;quot;]].to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">method command loss_2018Q4 loss_2019Q4 r_post_2018Q4 published_2018Q4
DiD FDID(...).fit().did 4.98 6.18 4.98 NaN
SC VanillaSC(...) 3.04 4.17 3.06 3.06
DSC TSSC(..., method=&amp;quot;MSCa&amp;quot;) 2.99 4.12 2.98 2.98
SDID SDID(..., zeta=0.0) 2.80 3.94 2.79 2.79
MASC MASC(..., set_f=range(6, 87)) 2.73 3.83 2.73 2.73
ASCM VanillaSC(..., augment=&amp;quot;ridge&amp;quot;) 3.04 4.19 3.04 3.04
&lt;/code>&lt;/pre>
&lt;p>Reading across the columns: &lt;code>r_post_2018Q4&lt;/code> is what the R edition of this post reports using &lt;code>synthdid&lt;/code>, &lt;code>Synth&lt;/code>, &lt;code>masc&lt;/code> and &lt;code>augsynth&lt;/code>; &lt;code>published_2018Q4&lt;/code> is de Brabander, Juodis and Miyazato Szini [1]. &lt;strong>Three of the six stages — DiD, MASC and ASCM — agree with both to two decimals. DSC lands a hundredth above the published 2.98 only because 2.9887 sits on a rounding boundary; the R edition reports the same estimate as 2.99. The two real disagreements are SC and SDID, and both differ in the same direction and for the same reason.&lt;/strong>&lt;/p>
&lt;p>Every stage puts the cost of the referendum above the 2.4% that Born, Müller, Schularick and Sedláček [2] published for this same dataset, and the excluding-DiD range is a fairly tight 2.73% to 3.04% at the end of 2018, widening to 3.83% to 4.19% a year later.&lt;/p>
&lt;p>&lt;img src="python_sc_dsc_sdid_09_donor_weights.png" alt="Grouped horizontal bar chart of donor weights for synthetic control, demeaned SC, SDID, MASC and augmented SC across the donor countries, with a shaded region marking negative weights that only augmented SC enters.">&lt;/p>
&lt;p>The weights tell a consistent story. Hungary, Canada, the United States, Japan and Norway carry the counterfactual under every method, and only ASCM ever goes negative — eight times, all of them tiny. Five estimators built on quite different principles are picking essentially the same five countries.&lt;/p>
&lt;p>&lt;img src="python_sc_dsc_sdid_10_all_counterfactuals.png" alt="Six counterfactual paths for the United Kingdom from 2014 to 2020 alongside the observed series, agreeing closely until the 2016 referendum and then fanning apart, with difference-in-differences the clear outlier.">&lt;/p>
&lt;p>&lt;img src="python_sc_dsc_sdid_11_att_dotplot.png" alt="Dot plot of every stage&amp;amp;rsquo;s estimated UK GDP shortfall at 2018Q4 and 2019Q4, with the three SDID flavours shown separately and a dashed gold line at Born et al.&amp;amp;rsquo;s published 2.4 percent, which every stage exceeds.">&lt;/p>
&lt;p>The dot plot makes the shape of the disagreement clear: DiD is off on its own, and the other seven estimates cluster within about a third of a percentage point of each other at 2018Q4. The width of that cluster, not any single point in it, is the honest answer.&lt;/p>
&lt;h3 id="141-comparing-counterfactuals-with-one-call">14.1 Comparing counterfactuals with one call&lt;/h3>
&lt;p>Building that comparison by hand is instructive, but &lt;code>mlsynth&lt;/code> ships utilities for it:&lt;/p>
&lt;pre>&lt;code class="language-python">from mlsynth import compare_estimators, plot_counterfactual_comparison
comparison = compare_estimators(
{&amp;quot;SC&amp;quot;: VanillaSC(cfg(96, inference=False)),
&amp;quot;SDID&amp;quot;: SDID(cfg(96, zeta=0.0, vce=&amp;quot;noinference&amp;quot;)),
&amp;quot;MASC&amp;quot;: MASC(cfg(96, m_grid=M_GRID, set_f=SET_F))},
show_bands=False,
)
ax = plot_counterfactual_comparison(comparison)
&lt;/code>&lt;/pre>
&lt;p>&lt;code>compare_estimators&lt;/code> fits several estimators on one panel and lines their counterfactuals up on a common time axis; &lt;code>compare_counterfactuals&lt;/code> does the same for already-fitted results; &lt;code>plot_counterfactual_comparison&lt;/code> draws them with their prediction intervals. For an exploratory comparison this is one call instead of thirty lines, and it is the right starting point before you invest in custom figures.&lt;/p>
&lt;h2 id="15-the-disagreement-is-the-finding">15. The disagreement is the finding&lt;/h2>
&lt;p>Two cells in section 14&amp;rsquo;s table disagree with R: SC (3.04 against 3.06) and SDID (2.80 against 2.79). Neither is a bug in either library, and the explanation is the most useful thing in this post.&lt;/p>
&lt;p>The synthetic-control objective on this panel has a condition number of roughly $7.5 \times 10^5$. In geometric terms that means a long, narrow, nearly flat valley of near-optimal weight vectors: many quite different $\omega$ give almost the same pre-treatment fit. Any optimiser has to decide when to stop walking down it.&lt;/p>
&lt;ul>
&lt;li>R&amp;rsquo;s &lt;code>synthdid&lt;/code> walks the valley with &lt;strong>Frank-Wolfe on a capped iteration budget&lt;/strong> and stops at 3.06%.&lt;/li>
&lt;li>&lt;code>mlsynth&lt;/code> hands the identical problem to a &lt;strong>convex solver&lt;/strong> which runs it to optimality and returns 3.039%.&lt;/li>
&lt;li>Stata&amp;rsquo;s &lt;code>sdid&lt;/code> inherits &lt;code>synthdid&lt;/code>&amp;rsquo;s Frank-Wolfe and stops in the same place; tighten its convergence with &lt;code>max_iter(100000) min_dec(1e-9)&lt;/code> and its SDID estimate drifts from 2.79% to 2.80%, which is where &lt;code>mlsynth&lt;/code> already is.&lt;/li>
&lt;/ul>
&lt;p>All three legs ship with this post, so you can run the comparison yourself rather than take it on trust: &lt;a href="cheatsheet_python.py">&lt;code>cheatsheet_python.py&lt;/code>&lt;/a>, &lt;a href="cheatsheet_R.R">&lt;code>cheatsheet_R.R&lt;/code>&lt;/a> and &lt;a href="cheatsheet_stata.do">&lt;code>cheatsheet_stata.do&lt;/code>&lt;/a>. Same data, same treatment date, same two evaluation quarters, same comparative table at the end — and each file hard-codes the others&amp;rsquo; column, so a disagreement shows up the moment you run any one of them.&lt;/p>
&lt;p>The Stata file is the one worth reading even if you never open Stata. Two things in it. Its ASCM row is a &lt;em>different estimator&lt;/em> — &lt;code>allsynth&lt;/code> implements the bias-corrected synthetic control of Abadie and L&amp;rsquo;Hour rather than the ridge-augmented version &lt;code>VanillaSC(augment=&amp;quot;ridge&amp;quot;)&lt;/code> gives you — and its MASC row is empty, because MASC has no Stata implementation. Reporting those honestly rather than approximating them is the point.&lt;/p>
&lt;p>Stata&amp;rsquo;s version of the penalty trap is also the nastiest of the three. Its documented default is &lt;code>zeta_omega(1e-6)&lt;/code>, which looks like a value but is a magic sentinel: &lt;code>sdid.ado&lt;/code> reads &lt;code>if (EOmega==1e-6) EtaOmega = (yNtr*yTpost)^(1/4)&lt;/code>, so passing the documented default &lt;em>explicitly&lt;/em> still requests the full penalty. Only a literal &lt;code>0&lt;/code> switches it off.&lt;/p>
&lt;p>So three implementations, written independently in three languages, sort themselves into exactly two camps — and the split is by &lt;strong>solver&lt;/strong>, not by language or by author. The R edition of this post reached the same conclusion from a completely different direction, by running the Frank-Wolfe iteration ladder by hand and watching the estimate converge to 3.039 as the budget grew. Section 9.2&amp;rsquo;s figure is the same finding a third time: four independent code paths inside &lt;code>mlsynth&lt;/code>, all convex, all landing on 3.039.&lt;/p>
&lt;p>The practical lesson is not that one library is right. It is that &lt;strong>a synthetic control estimate carries its solver&amp;rsquo;s fingerprint&lt;/strong>, and a second-decimal disagreement between implementations is the normal state of affairs rather than a cause for alarm. When you replicate a published synthetic-control number and land 0.02 away, the first hypothesis should be the optimiser, not the data.&lt;/p>
&lt;h2 id="16-which-stage-should-you-choose">16. Which stage should you choose?&lt;/h2>
&lt;p>The estimates cluster, but they do not coincide, and the ladder gives no reason to prefer the top stage. The source paper&amp;rsquo;s answer is an in-sample placebo tournament: advance the treatment date to a quarter when nothing happened, build the counterfactual on data up to that point only, and compare with what actually occurred. The true effect is zero, so every estimate is pure error.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">flowchart LR
A[&amp;quot;Pick a fake&amp;lt;br/&amp;gt;treatment date k&amp;lt;br/&amp;gt;(2010Q1 … 2014Q4)&amp;quot;] --&amp;gt; B[&amp;quot;Fit every stage&amp;lt;br/&amp;gt;on quarters 1..k&amp;quot;]
B --&amp;gt; C[&amp;quot;Predict quarter&amp;lt;br/&amp;gt;k + h&amp;quot;]
C --&amp;gt; D[&amp;quot;Compare with&amp;lt;br/&amp;gt;what happened.&amp;lt;br/&amp;gt;True effect = 0&amp;quot;]
D --&amp;gt; E[&amp;quot;Score:&amp;lt;br/&amp;gt;RMSE, MAB&amp;quot;]
E --&amp;gt; A
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#141413,stroke:#6a9bcc,color:#fff
style C fill:#141413,stroke:#6a9bcc,color:#fff
style D fill:#d97757,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>The loop runs twenty times, once for each last-pre-treatment quarter from 2010Q1 to 2014Q4, and each pass refits all seven estimators from scratch. Everything the earlier sections taught about defaults now pays off: &lt;code>vce=&amp;quot;noinference&amp;quot;&lt;/code>, &lt;code>method=&amp;quot;MSCa&amp;quot;&lt;/code>, &lt;code>inference=False&lt;/code> and an explicit &lt;code>m_grid&lt;/code> are what keep this to thirteen seconds rather than several hours.&lt;/p>
&lt;pre>&lt;code class="language-python">def placebo_one(k, h):
&amp;quot;&amp;quot;&amp;quot;Every stage refit as if the treatment had happened at quarter k+1.&amp;quot;&amp;quot;&amp;quot;
e = k + h
row = {&amp;quot;k&amp;quot;: k, &amp;quot;last_pre&amp;quot;: QLAB[k - 1], &amp;quot;horizon&amp;quot;: h}
row[&amp;quot;SC&amp;quot;] = VanillaSC(cfg(e, pre=k, inference=False)).fit().effects.att
row[&amp;quot;DSC&amp;quot;] = tssc_att(TSSC(cfg(e, pre=k, method=&amp;quot;MSCa&amp;quot;, inference=False)).fit())
w1, l1 = sdid_weights(k + 1, pre=k) # variant (i)
w2, l2 = sdid_weights(range(k + 1, k + 5), pre=k) # variant (ii)
w3, l3 = sdid_weights(k + 4, pre=k) # variant (iii)
row[&amp;quot;SDID (i)&amp;quot;] = -sdid_loss(w1, l1, e, pre=k) / 100.0
row[&amp;quot;SDID (ii)&amp;quot;] = -sdid_loss(w2, l2, e, pre=k) / 100.0
row[&amp;quot;SDID (iii)&amp;quot;] = -sdid_loss(w3, l3, e, pre=k) / 100.0
row[&amp;quot;MASC&amp;quot;] = MASC(cfg(e, pre=k, m_grid=M_GRID, set_f=list(range(6, k + 1)))).fit().effects.att
row[&amp;quot;ASCM&amp;quot;] = VanillaSC(cfg(e, pre=k, augment=&amp;quot;ridge&amp;quot;, inference=False)).fit().effects.att
return row
placebo = pd.DataFrame([placebo_one(k, h) for h in (1, 4) for k in range(61, 81)])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Horizon h = 1 quarter
method RMSE MAB MedAB
SC 0.0086 0.0068 0.0051
DSC 0.0086 0.0068 0.0051
SDID (i) 0.0066 0.0037 0.0016
SDID (ii) 0.0066 0.0038 0.0017
SDID (iii) 0.0066 0.0039 0.0020
MASC 0.0080 0.0062 0.0045
ASCM 0.0086 0.0068 0.0051
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_12_placebo_tournament.png" alt="Two panels of strip plots showing twenty placebo errors for each of the seven estimators, graded one quarter ahead and four quarters ahead, with the root mean squared error marked as an orange diamond and the SDID family visibly tighter around zero.">&lt;/p>
&lt;p>At a one-quarter horizon the whole SDID family scores 0.0066 root mean squared error, against 0.0080 for MASC and 0.0086 for SC, DSC and ASCM alike. The gap is even larger in mean absolute bias: 0.0037 for SDID (i) against 0.0068 for SC, close to a factor of two. &lt;strong>The time weights are doing real work&lt;/strong>, and this is the paper&amp;rsquo;s central theoretical claim surviving an empirical test.&lt;/p>
&lt;h3 id="161-the-published-table-is-not-comparing-like-with-like">16.1 The published table is not comparing like with like&lt;/h3>
&lt;p>Now look at how the published version of that table is produced. In the replication code, SC, DSC, SDID (i), MASC and ASCM are all graded &lt;strong>one quarter ahead&lt;/strong>; SDID (ii) and (iii) are graded &lt;strong>four quarters ahead&lt;/strong>. Forecasting a year out is a strictly harder task, so part of the reported gap is the exam, not the student.&lt;/p>
&lt;p>Running every estimator at both horizons settles it:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>RMSE, $h = 1$&lt;/th>
&lt;th>RMSE, $h = 4$&lt;/th>
&lt;th>Published&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;td>0.0145&lt;/td>
&lt;td>0.0089&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;td>0.0146&lt;/td>
&lt;td>0.0087&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (i)&lt;/td>
&lt;td>0.0066&lt;/td>
&lt;td>0.0132&lt;/td>
&lt;td>0.0067&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (ii)&lt;/td>
&lt;td>0.0066&lt;/td>
&lt;td>0.0133&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (iii)&lt;/td>
&lt;td>0.0066&lt;/td>
&lt;td>0.0133&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>0.0080&lt;/td>
&lt;td>0.0140&lt;/td>
&lt;td>0.0080&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;td>0.0146&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Every cell reproduces the published value to within 0.0003 — except the two that were graded on a different exam. Graded on the same task, the three SDID variants are &lt;strong>indistinguishable&lt;/strong>: 0.0066 at one quarter, 0.0132–0.0133 at four. The published conclusion that variants (ii) and (iii) &amp;ldquo;perform the worst&amp;rdquo; is an artefact of the horizon, not a property of the estimators. This is the same finding the R edition reports, arrived at with a different library, which is about as much corroboration as a result of this kind can get.&lt;/p>
&lt;p>What survives is the finding that matters more: &lt;strong>at either horizon the whole SDID family beats every other stage&lt;/strong>, and the ordering below it is stable — SDID, then MASC, then SC, ASCM and DSC, and those last three are indistinguishable at one quarter and separated by less than 0.0001 at four.&lt;/p>
&lt;h2 id="17-do-covariates-help-three-meanings-of-control-for">17. Do covariates help? Three meanings of &amp;ldquo;control for&amp;rdquo;&lt;/h2>
&lt;p>So far everything has matched on outcomes alone. The obvious next question is whether adding the covariates — consumption, investment, export and import shares, labour productivity growth and the employment-population ratio — improves the counterfactual.&lt;/p>
&lt;p>The answer &lt;code>mlsynth&lt;/code> gives is more interesting than yes or no: &lt;strong>it asks which of three different things you mean.&lt;/strong> &lt;code>SDIDConfig.covariates&lt;/code> is not a list. It is a dictionary keyed by method, and passing a bare list raises.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Key&lt;/th>
&lt;th>Method&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>&amp;quot;adjust&amp;quot;&lt;/code>&lt;/td>
&lt;td>Kranz (2022) two-step&lt;/td>
&lt;td>residualise the &lt;em>outcome&lt;/em> on covariates first, then run SDID on the residuals&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;match&amp;quot;&lt;/code>&lt;/td>
&lt;td>de Brabander et al. [1], eqs. 11–12&lt;/td>
&lt;td>put the covariates &lt;em>inside the unit-weight problem&lt;/em>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>&amp;quot;optimized&amp;quot;&lt;/code>&lt;/td>
&lt;td>Arkhangelsky et al. [10], fn. 4&lt;/td>
&lt;td>estimate weights and covariate coefficients &lt;em>jointly&lt;/em>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These are three different estimators, and they are all defensible readings of &amp;ldquo;SDID with covariates&amp;rdquo; in the literature.&lt;/p>
&lt;pre>&lt;code class="language-python">COVARIATES = [&amp;quot;cons_share&amp;quot;, &amp;quot;inv_share&amp;quot;, &amp;quot;exp_share&amp;quot;, &amp;quot;imp_share&amp;quot;,
&amp;quot;labprod_growth&amp;quot;, &amp;quot;emp_pop&amp;quot;]
for meth in (&amp;quot;adjust&amp;quot;, &amp;quot;match&amp;quot;, &amp;quot;optimized&amp;quot;):
kw = dict(zeta=0.0, vce=&amp;quot;noinference&amp;quot;, covariates={meth: COVARIATES})
if meth == &amp;quot;match&amp;quot;:
kw[&amp;quot;match_pre_periods&amp;quot;] = &amp;quot;last&amp;quot;
vals = [pct(SDID(cfg(e, **kw)).fit().effects.att) for e in EVAL.values()]
print(f&amp;quot;covariates={{'{meth}': ...}} 2018Q4 {vals[0]:5.2f} 2019Q4 {vals[1]:5.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">covariates={'adjust': ...} 2018Q4 3.61 2019Q4 4.85
covariates={'match': ...} 2018Q4 1.85 2019Q4 2.93
covariates={'optimized': ...} 2018Q4 3.11 2019Q4 4.56
&lt;/code>&lt;/pre>
&lt;p>The three routes disagree by &lt;strong>1.76 percentage points&lt;/strong> at 2018Q4 — from 1.85% to 3.61%, against 2.80% with no covariates at all. That spread is more than five times the spread across the entire outcomes-only ladder. Adding covariates does not refine the answer here; it replaces one well-identified number with three poorly-identified ones.&lt;/p>
&lt;p>&lt;code>match_pre_periods&lt;/code> is the companion setting for the &lt;code>&amp;quot;match&amp;quot;&lt;/code> route, taking &lt;code>&amp;quot;all&amp;quot;&lt;/code>, &lt;code>&amp;quot;half&amp;quot;&lt;/code>, &lt;code>&amp;quot;last&amp;quot;&lt;/code> or an integer. It controls which pre-treatment periods the covariate means are computed over, and &lt;code>mlsynth&lt;/code>&amp;rsquo;s own benchmarks report that its agreement with R&amp;rsquo;s &lt;code>Synth&lt;/code> degrades from a weight-vector correlation of 0.998 under &lt;code>&amp;quot;last&amp;quot;&lt;/code> to 0.636 under &lt;code>&amp;quot;all&amp;quot;&lt;/code> — precisely because the predictor weights become less identified as more periods enter.&lt;/p>
&lt;h3 id="171-the-same-problem-in-vanillasc-and-a-clean-demonstration-of-why">17.1 The same problem in VanillaSC, and a clean demonstration of why&lt;/h3>
&lt;p>&lt;code>VanillaSC&lt;/code> has its own covariate route: the Abadie-Diamond-Hainmueller bilevel program, where an outer loop searches over predictor weights $V$ and an inner loop solves for donor weights $\omega$. Section 9.1 noted that those predictor weights are &lt;em>generically not identified&lt;/em>. That claim sounds abstract until you test it, and the test costs one keyword.&lt;/p>
&lt;p>Fit the identical model twice, with the same seed and the same data, changing only how long the differential-evolution search is allowed to run. If $V$ were well identified, the budget would not matter.&lt;/p>
&lt;pre>&lt;code class="language-python">for label, budget in [(&amp;quot;default (maxiter=300, popsize=15)&amp;quot;, {}),
(&amp;quot;reduced (maxiter=120, popsize=12)&amp;quot;,
dict(mscmt_maxiter=120, mscmt_popsize=12))]:
r = VanillaSC(cfg(96, covariates=COVARIATES, backend=&amp;quot;mscmt&amp;quot;,
canonical_v=&amp;quot;min.loss.w&amp;quot;, seed=SEED,
inference=False, **budget)).fit()
ss = r.weights.summary_stats
print(f&amp;quot;{label:&amp;lt;36s} {pct(r.effects.att):5.2f}% &amp;quot;
f&amp;quot;pre-RMSE {r.fit_diagnostics.rmse_pre:.6f} &amp;quot;
f&amp;quot;v_agreement {ss['v_agreement']:.5f}&amp;quot;)
top = sorted(ss[&amp;quot;predictor_weights&amp;quot;].items(), key=lambda kv: -abs(kv[1]))[:4]
print(&amp;quot; &amp;quot; + &amp;quot;, &amp;quot;.join(f&amp;quot;{k} {v:.3f}&amp;quot; for k, v in top))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">default (maxiter=300, popsize=15) 1.32% pre-RMSE 0.009662 v_agreement 0.05530
imp_share 1.000, exp_share 0.000, labprod_growth 0.000, emp_pop 0.000
reduced (maxiter=120, popsize=12) 1.11% pre-RMSE 0.009928 v_agreement 0.08281
labprod_growth 0.419, emp_pop 0.396, cons_share 0.141, inv_share 0.045
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_13_covariate_methods.png" alt="Grouped horizontal bar chart comparing the SDID estimate under no covariates and under the adjust, match and optimized covariate methods, plus VanillaSC&amp;amp;rsquo;s bilevel covariate route, with a dashed gold reference line at Born et al.&amp;amp;rsquo;s 2.4 percent.">&lt;/p>
&lt;p>Same estimator, same seed, same data. Shortening the search moves the estimate from 1.32% to 1.11% — and, far more strikingly, moves the predictor weights from a &lt;strong>corner solution&lt;/strong> that puts all the weight on the import share to a &lt;strong>spread&lt;/strong> across labour productivity, employment, and the consumption and investment shares. Those are not slightly different answers to the same question. They are different economic stories about what makes a country comparable to the UK.&lt;/p>
&lt;p>Three diagnostics all point the same way. The pre-treatment RMSE &lt;strong>rises&lt;/strong> from 0.005589 to about 0.0097 — adding six covariates makes the pre-treatment fit nearly twice as bad, because the optimiser now spends its effort matching predictor means instead of the outcome path. &lt;code>v_agreement&lt;/code>, the gap between the two canonical choices of $V$, is 0.055 to 0.083 rather than near zero. And the answer moves with the optimiser budget, which is the definition of a non-identified problem.&lt;/p>
&lt;p>This is why the R edition&amp;rsquo;s placebo tournament found covariates make the counterfactual &lt;em>worse&lt;/em> rather than better, and why the headline specification here matches on outcomes alone. &lt;strong>When you have eighty-six pre-treatment quarters of the outcome itself, six covariate means are not adding information — they are adding a poorly identified optimisation problem.&lt;/strong> The fact that &lt;code>mlsynth&lt;/code> reports &lt;code>v_agreement&lt;/code> at all is what let us see it.&lt;/p>
&lt;h2 id="18-inference">18. Inference&lt;/h2>
&lt;p>&lt;code>VanillaSC&lt;/code> exposes nine inference methods behind a single &lt;code>inference=&lt;/code> field, which is unusually generous. Here is what each returns on this panel:&lt;/p>
&lt;pre>&lt;code class="language-python">rows = []
for meth in (&amp;quot;placebo&amp;quot;, &amp;quot;scpi&amp;quot;, &amp;quot;lto&amp;quot;, &amp;quot;conformal&amp;quot;, &amp;quot;ttest&amp;quot;, &amp;quot;jackknife_plus&amp;quot;):
try:
r = VanillaSC(cfg(96, inference=meth, alpha=0.05)).fit()
inf = r.inference
rows.append(dict(method=meth, att=r.att, p_value=inf.p_value,
ci_lower=inf.ci_lower, ci_upper=inf.ci_upper,
reported_as=inf.method))
except Exception as exc:
rows.append(dict(method=meth, att=np.nan,
reported_as=f&amp;quot;FAILED: {type(exc).__name__}&amp;quot;))
print(pd.DataFrame(rows).to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> method att p_value ci_lower ci_upper reported_as
placebo -0.030388 0.041667 NaN NaN in-space placebo (RMSPE ratio)
scpi -0.030388 NaN -0.055333 -0.009374 scpi prediction intervals (Cattaneo-Feng-Titiunik 2021)
lto -0.030388 0.008333 NaN NaN leave-two-out refined placebo (Lei-Sudijono 2025)
conformal -0.030388 0.020000 -0.046603 -0.014292 conformal prediction intervals (Chernozhukov-Wuthrich-Zhu 2021)
ttest -0.030388 0.005788 -0.040042 -0.020228 debiased SC t-test (Chernozhukov-Wuthrich-Zhu 2025)
jackknife_plus NaN NaN NaN NaN FAILED: MlsynthEstimationError
&lt;/code>&lt;/pre>
&lt;p>Reported honestly: &lt;code>jackknife_plus&lt;/code> raises &lt;code>MlsynthEstimationError&lt;/code> on this configuration and is excluded. The other five all reject at the 5% level: four report p-values between 0.006 and 0.042, and &lt;code>scpi&lt;/code> reports no p-value but an interval that stops short of zero.&lt;/p>
&lt;p>That looks decisive, and it should be read with more caution than it invites. The five methods are not five independent tests — they all use the same point estimate and the same donor pool, and they differ in how they build a reference distribution from twenty-three donors. Note also that SDID&amp;rsquo;s own placebo inference in section 11 gave p = 0.20 with an interval containing zero. Different estimator, different variance estimator, very different verdict. Read these as orders of magnitude rather than as digits.&lt;/p>
&lt;h3 id="181-placebo-in-space">18.1 Placebo in space&lt;/h3>
&lt;p>The most interpretable of the five is worth doing explicitly: give every donor the treatment in turn and see where the UK ranks.&lt;/p>
&lt;pre>&lt;code class="language-python">rows = []
for country in [TREATED] + DONORS:
sub = panel.copy()
sub[&amp;quot;tt&amp;quot;] = sub.groupby(&amp;quot;country&amp;quot;)[&amp;quot;t&amp;quot;].rank(method=&amp;quot;dense&amp;quot;).astype(int)
sub[&amp;quot;treat&amp;quot;] = ((sub.country == country) &amp;amp; (sub.tt &amp;gt; T0)).astype(int)
r = VanillaSC(dict(df=sub, outcome=&amp;quot;log_rgdp&amp;quot;, treat=&amp;quot;treat&amp;quot;, unitid=&amp;quot;country&amp;quot;,
time=&amp;quot;tt&amp;quot;, display_graphs=False, inference=False)).fit()
gap = np.asarray(r.gap, float).ravel()
pre, post = np.sqrt(np.mean(gap[:T0] ** 2)), np.sqrt(np.mean(gap[T0:] ** 2))
rows.append(dict(country=country, rmspe_pre=pre, rmspe_post=post, ratio=post / pre))
placebo_space = (pd.DataFrame(rows)
.assign(rank=lambda d: d[&amp;quot;ratio&amp;quot;].rank(ascending=False).astype(int))
.sort_values(&amp;quot;ratio&amp;quot;, ascending=False))
print(placebo_space.head(6).round(4).to_string(index=False))
&lt;/code>&lt;/pre>
&lt;p>The statistic is the ratio of post-treatment to pre-treatment root mean squared prediction error,&lt;/p>
&lt;p>$$R_j = \frac{\text{RMSPE}_j^{\text{post}}}{\text{RMSPE}_j^{\text{pre}}},$$&lt;/p>
&lt;p>which asks whether unit $j$&amp;rsquo;s gap grew after the treatment date &lt;em>relative to how well it was fitted beforehand&lt;/em>. Dividing by the pre-treatment fit is what stops a badly fitted donor from looking treated.&lt;/p>
&lt;pre>&lt;code class="language-text"> country rmspe_pre rmspe_post ratio rank
United Kingdom 0.0056 0.0327 5.8488 1
Belgium 0.0043 0.0151 3.5231 2
Finland 0.0202 0.0666 3.2905 3
New Zealand 0.0134 0.0397 2.9693 4
Hungary 0.0210 0.0593 2.8193 5
Austria 0.0042 0.0111 2.6324 6
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_sc_dsc_sdid_14_placebo_in_space.png" alt="Placebo-in-space plot showing twenty-three grey gap paths, one for each donor country given the treatment in turn, with the United Kingdom&amp;amp;rsquo;s gap in orange diverging further below zero than any placebo after 2016.">&lt;/p>
&lt;p>The UK&amp;rsquo;s ratio of 5.85 is the largest of all twenty-four units, giving a permutation p-value of $1/24 = 0.042$. That is as small as this design can produce: with twenty-four units the smallest attainable p-value is 0.042, so the test is at its floor and could not have been more favourable.&lt;/p>
&lt;h3 id="182-what-this-can-and-cannot-tell-you">18.2 What this can and cannot tell you&lt;/h3>
&lt;p>It can tell you that the UK&amp;rsquo;s post-2016 divergence is unusual relative to what these twenty-three donors do. It cannot tell you the effect is 3.04% rather than 2.73%, it cannot separate Brexit from anything else distinctive that happened to the UK after mid-2016, and with twenty-four units it has essentially no power to detect anything subtler. Every interval in this section is wide, and the SDID interval contains zero.&lt;/p>
&lt;h2 id="19-robustness-the-specification-zoo">19. Robustness: the specification zoo&lt;/h2>
&lt;p>Four departures from the headline specification, each one line of config. The SDID column reports variant (i) for the date and donor-pool rows and variant (ii) — the post&amp;rsquo;s headline — for the &lt;code>zeta&lt;/code> rows, because those are the variants each check was run on.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Departure&lt;/th>
&lt;th>SC&lt;/th>
&lt;th>DSC&lt;/th>
&lt;th>SDID&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>treatment date 2016Q3 (headline)&lt;/td>
&lt;td>3.04&lt;/td>
&lt;td>2.99&lt;/td>
&lt;td>2.77 &lt;em>(i)&lt;/em>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>treatment date 2016Q2&lt;/td>
&lt;td>3.09&lt;/td>
&lt;td>3.05&lt;/td>
&lt;td>3.18 &lt;em>(i)&lt;/em>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>drop the United States&lt;/td>
&lt;td>3.06&lt;/td>
&lt;td>3.04&lt;/td>
&lt;td>2.83 &lt;em>(i)&lt;/em>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>zeta = 0&lt;/code> (the paper)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>2.80 &lt;em>(ii)&lt;/em>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>zeta&lt;/code> at its default&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>2.67 &lt;em>(ii)&lt;/em>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>w_constr='simplex'&lt;/code>&lt;/td>
&lt;td>3.04&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>w_constr='ols'&lt;/code>&lt;/td>
&lt;td>3.37&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>w_constr='ridge'&lt;/code>&lt;/td>
&lt;td>3.37&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>w_constr='lasso'&lt;/code>&lt;/td>
&lt;td>2.97&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Four observations.&lt;/p>
&lt;p>&lt;strong>The treatment date matters most for SDID.&lt;/strong> Moving from 2016Q3 to 2016Q2 barely touches SC (3.04 to 3.09) but moves SDID from 2.77% to 3.18%. That is exactly what section 11.2 predicts: SDID puts 96% of its time weight on the last pre-treatment quarter, so changing which quarter that is changes the bias adjustment directly. The estimator that is best on the placebo tournament is also the one most sensitive to the treatment date — a trade-off worth stating out loud.&lt;/p>
&lt;p>&lt;strong>Dropping the United States barely moves anything.&lt;/strong> The US carries about a fifth of the counterfactual, and removing it entirely shifts SC from 3.04% to 3.06% and SDID from 2.77% to 2.83%. The result does not hinge on one donor.&lt;/p>
&lt;p>&lt;strong>The constraint set is worth more than the estimator choice.&lt;/strong> Relaxing the simplex to OLS or ridge moves SC from 3.04% to 3.37%, a third of a percentage point — larger than the gap between SC and MASC. &lt;code>w_constr&lt;/code> is a research decision, not a tuning knob.&lt;/p>
&lt;p>&lt;strong>And &lt;code>zeta&lt;/code> again.&lt;/strong> 2.80% against 2.67%, for a setting most users will never see.&lt;/p>
&lt;h2 id="20-beyond-the-ladder-the-mlsynth-catalogue">20. Beyond the ladder: the mlsynth catalogue&lt;/h2>
&lt;p>Six of ninety-two classes have appeared in this post. The rest are worth knowing about, because the reason to invest in this library rather than four single-purpose ones is that the config you have already written works for all of them. Here is the map, organised by what makes your design non-canonical:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your design has…&lt;/th>
&lt;th>Reach for&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>The canonical one treated unit&lt;/td>
&lt;td>&lt;code>VanillaSC&lt;/code>, &lt;code>TSSC&lt;/code>, &lt;code>FDID&lt;/code>&lt;/td>
&lt;td>the workhorses; everything in this post&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Staggered adoption, several cohorts&lt;/td>
&lt;td>&lt;code>SDID&lt;/code>, &lt;code>SequentialSDID&lt;/code>, &lt;code>SSC&lt;/code>, &lt;code>PPSCM&lt;/code>, &lt;code>CSCM&lt;/code>&lt;/td>
&lt;td>&lt;code>SDID&lt;/code> handles both cases; &lt;code>dataprep&lt;/code> detects which&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Many donors relative to periods&lt;/td>
&lt;td>&lt;code>CLUSTERSC&lt;/code>, &lt;code>MLSC&lt;/code>, &lt;code>FSCM&lt;/code>, &lt;code>SparseSC&lt;/code>, &lt;code>MSQRT&lt;/code>, &lt;code>PDA&lt;/code>, &lt;code>SCUL&lt;/code>, &lt;code>RESCM&lt;/code>&lt;/td>
&lt;td>regularisation or donor selection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bayesian uncertainty&lt;/td>
&lt;td>&lt;code>BVSS&lt;/code>, &lt;code>BSCM&lt;/code>, &lt;code>BFSC&lt;/code>, &lt;code>MVBBSC&lt;/code>, &lt;code>MTGP&lt;/code>, &lt;code>BPSCS&lt;/code>&lt;/td>
&lt;td>posterior rather than placebo intervals&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A treated unit outside the convex hull&lt;/td>
&lt;td>&lt;code>ISCM&lt;/code>, &lt;code>NSC&lt;/code>&lt;/td>
&lt;td>relax the hull rather than augment the fit&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Spillovers onto donors&lt;/td>
&lt;td>&lt;code>SpSyDiD&lt;/code>, &lt;code>SPILLSYNTH&lt;/code>, &lt;code>SPOTSYNTH&lt;/code>, &lt;code>RRSC&lt;/code>&lt;/td>
&lt;td>&lt;code>SPOTSYNTH&lt;/code> also &lt;em>detects&lt;/em> which donors are contaminated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Missing cells in the panel&lt;/td>
&lt;td>&lt;code>MCNNM&lt;/code>, &lt;code>SNN&lt;/code>, &lt;code>RMSI&lt;/code>&lt;/td>
&lt;td>matrix completion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multiple outcomes&lt;/td>
&lt;td>&lt;code>SCMO&lt;/code>&lt;/td>
&lt;td>joint rather than one-at-a-time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Micro-level distributions&lt;/td>
&lt;td>&lt;code>DSC&lt;/code>, &lt;code>MicroSynth&lt;/code>&lt;/td>
&lt;td>the &lt;em>distributional&lt;/em> DSC, not this post&amp;rsquo;s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Endogenous treatment&lt;/td>
&lt;td>&lt;code>SIV&lt;/code>, &lt;code>ORTHSC&lt;/code>, &lt;code>PROXIMAL&lt;/code>&lt;/td>
&lt;td>instruments and proximal inference&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No untreated donors at all&lt;/td>
&lt;td>&lt;code>SHC&lt;/code>&lt;/td>
&lt;td>synthetic &lt;em>historical&lt;/em> control&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A design to run before treating&lt;/td>
&lt;td>&lt;code>MAREX&lt;/code>, &lt;code>SYNDES&lt;/code>, &lt;code>PANGEO&lt;/code>, &lt;code>SPCD&lt;/code>, &lt;code>MUSC&lt;/code>&lt;/td>
&lt;td>these return a &lt;code>DesignResult&lt;/code>, not an &lt;code>EffectResult&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>A continuous treatment&lt;/td>
&lt;td>&lt;code>CTSC&lt;/code>&lt;/td>
&lt;td>dose rather than on/off&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Factor structure in the outcome&lt;/td>
&lt;td>&lt;code>FMA&lt;/code>, &lt;code>CFM&lt;/code>, &lt;code>CSCIPCA&lt;/code>, &lt;code>TASC&lt;/code>, &lt;code>DSCAR&lt;/code>&lt;/td>
&lt;td>interactive fixed effects&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The library sorts its outputs into exactly two families, which is worth internalising because it determines what &lt;code>.fit()&lt;/code> gives you back. &lt;code>EffectResult&lt;/code> is an &lt;em>observational report&lt;/em> — measure an effect on data you already have. &lt;code>DesignResult&lt;/code> is a &lt;em>research design&lt;/em> — choose which units to treat before any intervention, and it resolves into an &lt;code>EffectResult&lt;/code> once outcomes exist. Everything in this post is the first kind.&lt;/p>
&lt;p>For the current list on your installed version, &lt;code>get_llm_guide()&lt;/code> is authoritative; the counts quoted in the README and the documentation prose disagree with each other and with &lt;code>__all__&lt;/code>.&lt;/p>
&lt;h2 id="21-discussion">21. Discussion&lt;/h2>
&lt;p>&lt;strong>What Brexit cost.&lt;/strong> Taking the ladder as a whole, the referendum had cost the UK between &lt;strong>2.7% and 3.0% of GDP by the end of 2018&lt;/strong>, and between &lt;strong>3.8% and 4.2% by the end of 2019&lt;/strong>. That is above the 2.4% previously published for this dataset, and the reason is not exotic: the earlier figure came from a specification that matched on covariates, and section 17 shows covariates make the counterfactual worse here rather than better.&lt;/p>
&lt;p>Three caveats belong with the number. It is a &lt;em>net&lt;/em> gap between the UK and a blend of OECD economies, so anything else distinctive that happened to the UK after mid-2016 is inside it. The no-interference assumption is strong over four years when the United States carries a fifth of the weight. And the estimate is a point on a specification cloud rather than a parameter that has been pinned down — every interval in section 18 is wide, and SDID&amp;rsquo;s contains zero.&lt;/p>
&lt;p>&lt;strong>What the software taught.&lt;/strong> This is where a package-first reading pays off, and the findings are not econometric.&lt;/p>
&lt;p>&lt;em>Three defaults change the answer materially, and two of them by more than the spread across the ladder.&lt;/em> &lt;code>zeta&lt;/code> moves SDID by 0.13 percentage points, &lt;code>set_f&lt;/code> moves MASC by 0.47, and the choice among the three covariate methods moves SDID by 1.76. The ladder&amp;rsquo;s own spread, excluding DiD, is 0.31, so &lt;code>set_f&lt;/code> and the covariate method each clear it on their own while &lt;code>zeta&lt;/code> stays just inside. &lt;strong>You can pick the wrong default and be further from the truth than if you had picked the wrong estimator.&lt;/strong>&lt;/p>
&lt;p>&lt;em>One estimator silently rounds the number you are most likely to quote.&lt;/em> &lt;code>TSSC&lt;/code> returns &lt;code>.att&lt;/code> as exactly $-0.03$ while its gap series carries the full $-0.029887$. Nothing warns you.&lt;/p>
&lt;p>&lt;em>Names are mnemonics, not definitions.&lt;/em> &lt;code>mlsynth.DSC&lt;/code> is not this post&amp;rsquo;s DSC, and importing it raises no error.&lt;/p>
&lt;p>&lt;em>A version number is not a version.&lt;/em> The PyPI release numbered 1.0.0 is behind git &lt;code>main&lt;/code> at the same version number, and is missing a config field this post uses. &lt;code>mlsynth.__version__&lt;/code> will tell you &lt;code>&amp;quot;1.0.0&amp;quot;&lt;/code> in both cases. Pin the commit, not the release.&lt;/p>
&lt;p>&lt;em>A solver leaves a fingerprint.&lt;/em> Four convex code paths inside &lt;code>mlsynth&lt;/code> agree at 3.039% while R&amp;rsquo;s Frank-Wolfe stops at 3.06%. Three languages, two camps, split by optimiser rather than by author.&lt;/p>
&lt;p>&lt;strong>So what should you actually do?&lt;/strong> Fit the ladder, not a stage, and publish the cloud rather than a point. &lt;code>mlsynth&lt;/code> makes that cheap — six estimators, one config, thirteen seconds for a twenty-date placebo tournament — which removes the main practical excuse for reporting a single specification. And when you report, say which defaults you set. On this dataset that sentence carries more information than the choice of estimator.&lt;/p>
&lt;h2 id="22-summary-and-next-steps">22. Summary and next steps&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The Brexit referendum cost the UK 2.7–3.0% of GDP by end-2018 and 3.8–4.2% by end-2019&lt;/strong>, at every stage of the ladder, against 2.4% previously published for the same data.&lt;/li>
&lt;li>&lt;strong>&lt;code>mlsynth&lt;/code> puts all six stages behind one interface&lt;/strong>: &lt;code>Estimator({&amp;quot;df&amp;quot;: ..., &amp;quot;outcome&amp;quot;: ..., &amp;quot;treat&amp;quot;: ..., &amp;quot;unitid&amp;quot;: ..., &amp;quot;time&amp;quot;: ...}).fit()&lt;/code>, returning a result with seven flat accessors that work everywhere.&lt;/li>
&lt;li>&lt;strong>Three defaults matter, and two of them more than the estimator choice.&lt;/strong> &lt;code>SDID&lt;/code> penalises unit weights unless you set &lt;code>zeta=0.0&lt;/code> (2.80% vs 2.67%); &lt;code>MASC&lt;/code> cross-validates on a different fold set unless you set &lt;code>set_f&lt;/code> (2.73% vs 3.19%); and &lt;code>SDID&lt;/code>&amp;rsquo;s three covariate methods disagree by 1.76 percentage points. Against a ladder that spans 0.31, the last two each clear it alone.&lt;/li>
&lt;li>&lt;strong>The time weights earn their keep.&lt;/strong> SDID&amp;rsquo;s placebo RMSE is 0.0066 against 0.0086 for plain SC, a 23% reduction, and the advantage holds at both forecast horizons.&lt;/li>
&lt;li>&lt;strong>But the published ranking among SDID variants does not survive a matched horizon.&lt;/strong> Graded on the same task, the three flavours score 0.0066, 0.0066 and 0.0066.&lt;/li>
&lt;li>&lt;strong>Covariates hurt here.&lt;/strong> They raise the pre-treatment RMSE from 0.0056 to 0.0099 and produce a &lt;code>v_agreement&lt;/code> of 0.083, both signs of a poorly identified predictor-weight problem.&lt;/li>
&lt;li>&lt;strong>A limitation to carry forward:&lt;/strong> with one treated unit and 23 donors, the smallest attainable permutation p-value is 0.042. The design is at its inferential floor, and no estimator choice changes that.&lt;/li>
&lt;li>&lt;strong>Next:&lt;/strong> the same panel with &lt;code>CLUSTERSC&lt;/code> or &lt;code>PDA&lt;/code> if you have many more donors; &lt;code>SequentialSDID&lt;/code> if adoption is staggered; &lt;code>SPOTSYNTH&lt;/code> if you suspect spillovers onto the donor pool. All three take the config you already wrote.&lt;/li>
&lt;/ul>
&lt;p>All three cheat sheets ship with this post, so the cross-language comparison in section 15 is reproducible without leaving the bundle: &lt;a href="cheatsheet_python.py">&lt;code>cheatsheet_python.py&lt;/code>&lt;/a> (about half a minute), &lt;a href="cheatsheet_stata.do">&lt;code>cheatsheet_stata.do&lt;/code>&lt;/a> (twenty seconds with standard errors off, three minutes with them on) and &lt;a href="cheatsheet_R.R">&lt;code>cheatsheet_R.R&lt;/code>&lt;/a> (thirty seconds with &lt;code>SE &amp;lt;- FALSE&lt;/code>, four minutes otherwise). Each prints the same ladder and the same comparative table on the same data, and each hard-codes the other languages&amp;rsquo; column so you can check one against another directly. &lt;a href="https://carlos-mendez.org/post/r_sc_dsc_sdid/">The R edition of this post&lt;/a> hand-codes every estimator before calling its package and is the place to go for the derivations. On the wider site, &lt;a href="https://carlos-mendez.org/post/r_basic_synthetic_control/">the classic synthetic control on the Basque Country&lt;/a>, &lt;a href="https://carlos-mendez.org/post/r_augsynth/">augmented synthetic control on the Kansas tax cuts&lt;/a> and &lt;a href="https://carlos-mendez.org/post/stata_sdid/">synthetic difference-in-differences on Proposition 99 in Stata&lt;/a> cover single stages in isolation.&lt;/p>
&lt;h2 id="23-exercises">23. Exercises&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Move the treatment date.&lt;/strong> Re-run the headline table with &lt;code>pre=T0-1&lt;/code> (treatment materialising 2016Q2). Which stage moves most, and can you explain it from the time weights?&lt;/li>
&lt;li>&lt;strong>Break the rounding.&lt;/strong> Compute DSC&amp;rsquo;s estimate from &lt;code>variants[&amp;quot;MSCa&amp;quot;].att&lt;/code> and from the gap series across all twenty placebo windows in section 16. How large does the discrepancy get?&lt;/li>
&lt;li>&lt;strong>Time the inference default.&lt;/strong> &lt;code>VanillaSC&lt;/code>&amp;rsquo;s &lt;code>inference&lt;/code> defaults to &lt;code>True&lt;/code>, which runs in-space placebo. Time a fit with &lt;code>inference=False&lt;/code> against the default, and decide when the difference matters.&lt;/li>
&lt;li>&lt;strong>Read the simplex.&lt;/strong> Print &lt;code>weights.summary_stats&lt;/code> for all six stages. Which report &lt;code>n_negative &amp;gt; 0&lt;/code>, and which report a &lt;code>constraint&lt;/code> other than the plain simplex?&lt;/li>
&lt;li>&lt;strong>Grade the horizon.&lt;/strong> Extend section 16&amp;rsquo;s tournament to $h = 8$. Does the SDID family&amp;rsquo;s advantage survive a two-year forecast?&lt;/li>
&lt;li>&lt;strong>Separate MASC&amp;rsquo;s two dials.&lt;/strong> Fix &lt;code>m=10&lt;/code> and vary &lt;code>set_f&lt;/code>; then fix &lt;code>set_f&lt;/code> and vary &lt;code>m_grid&lt;/code>. Which of the two drives the 0.47-point swing?&lt;/li>
&lt;li>&lt;strong>Try a fourth covariate route.&lt;/strong> Fit &lt;code>SDID&lt;/code> with &lt;code>covariates={&amp;quot;match&amp;quot;: [...]}&lt;/code> under each of &lt;code>match_pre_periods&lt;/code> in &lt;code>{&amp;quot;all&amp;quot;, &amp;quot;half&amp;quot;, &amp;quot;last&amp;quot;, 20}&lt;/code>. How wide is the resulting range, and how does it compare with the range across methods?&lt;/li>
&lt;li>&lt;strong>Pick a different estimator entirely.&lt;/strong> Fit &lt;code>CLUSTERSC&lt;/code> and &lt;code>PDA&lt;/code> on this panel with the config you already have. Do they land inside the ladder&amp;rsquo;s range, and what does that tell you about the range?&lt;/li>
&lt;/ol>
&lt;h2 id="24-references">24. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1080/07474938.2025.2530649" target="_blank" rel="noopener">de Brabander, E., Juodis, A., &amp;amp; Miyazato Szini, G. (2025). On the use of synthetic difference-in-differences approach with (-out) covariates: The case study of Brexit referendum. &lt;em>Econometric Reviews&lt;/em> 44(10), 1617–1646.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1093/ej/uez020" target="_blank" rel="noopener">Born, B., Müller, G. J., Schularick, M., &amp;amp; Sedláček, P. (2019). The costs of economic nationalism: Evidence from the Brexit experiment. &lt;em>The Economic Journal&lt;/em> 129(623), 2722–2744.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1257/000282803321455188" target="_blank" rel="noopener">Abadie, A., &amp;amp; Gardeazabal, J. (2003). The economic costs of conflict: A case study of the Basque Country. &lt;em>American Economic Review&lt;/em> 93(1), 113–132.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1198/jasa.2009.ap08746" target="_blank" rel="noopener">Abadie, A., Diamond, A., &amp;amp; Hainmueller, J. (2010). Synthetic control methods for comparative case studies. &lt;em>Journal of the American Statistical Association&lt;/em> 105(490), 493–505.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1111/ajps.12116" target="_blank" rel="noopener">Abadie, A., Diamond, A., &amp;amp; Hainmueller, J. (2015). Comparative politics and the synthetic control method. &lt;em>American Journal of Political Science&lt;/em> 59(2), 495–510.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1257/jel.20191450" target="_blank" rel="noopener">Abadie, A. (2021). Using synthetic controls: Feasibility, data requirements, and methodological aspects. &lt;em>Journal of Economic Literature&lt;/em> 59(2), 391–425.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1287/mnsc.2023.4878" target="_blank" rel="noopener">Li, K. T., &amp;amp; Shankar, V. (2023). A two-step synthetic control approach for estimating causal effects of marketing events. &lt;em>Management Science&lt;/em> 70(6), 3734–3747.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.3386/w22791" target="_blank" rel="noopener">Doudchenko, N., &amp;amp; Imbens, G. W. (2016). Balancing, regression, difference-in-differences and synthetic control methods: A synthesis. NBER Working Paper 22791.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.3982/QE1596" target="_blank" rel="noopener">Ferman, B., &amp;amp; Pinto, C. (2021). Synthetic controls with imperfect pretreatment fit. &lt;em>Quantitative Economics&lt;/em> 12(4), 1197–1221.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1257/aer.20190159" target="_blank" rel="noopener">Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., &amp;amp; Wager, S. (2021). Synthetic difference-in-differences. &lt;em>American Economic Review&lt;/em> 111(12), 4088–4118.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.2021.1979562" target="_blank" rel="noopener">Kellogg, M., Mogstad, M., Pouliot, G. A., &amp;amp; Torgovitsky, A. (2021). Combining matching and synthetic control to trade off biases from extrapolation and interpolation. &lt;em>Journal of the American Statistical Association&lt;/em> 116(536), 1804–1816.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.2021.1929245" target="_blank" rel="noopener">Ben-Michael, E., Feller, A., &amp;amp; Rothstein, J. (2021). The augmented synthetic control method. &lt;em>Journal of the American Statistical Association&lt;/em> 116(536), 1789–1803.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.3982/ECTA18260" target="_blank" rel="noopener">Gunsilius, F. F. (2023). Distributional synthetic controls. &lt;em>Econometrica&lt;/em> 91(3), 1105–1117.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.2021.1979561" target="_blank" rel="noopener">Cattaneo, M. D., Feng, Y., &amp;amp; Titiunik, R. (2021). Prediction intervals for synthetic control methods. &lt;em>Journal of the American Statistical Association&lt;/em> 116(536), 1865–1880.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.2021.1920957" target="_blank" rel="noopener">Chernozhukov, V., Wüthrich, K., &amp;amp; Zhu, Y. (2021). An exact and robust conformal inference method for counterfactual and synthetic controls. &lt;em>Journal of the American Statistical Association&lt;/em> 116(536), 1849–1864.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2407.09565" target="_blank" rel="noopener">Ciccia, D. (2024). A short note on event-study synthetic difference-in-differences estimators. arXiv:2407.09565.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/skranz/xsynthdid" target="_blank" rel="noopener">Kranz, S. (2022). Synthetic difference-in-differences with time-varying covariates. Working paper.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/jgreathouse9/mlsynth" target="_blank" rel="noopener">Greathouse, J. mlsynth: A Python library of synthetic control and difference-in-differences estimators.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mlsynth.readthedocs.io/" target="_blank" rel="noopener">mlsynth documentation.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/jgreathouse9/mlsynth/issues/312" target="_blank" rel="noopener">mlsynth issue #312 — Check de Brabander, Juodis &amp;amp; Miyazato Szini (2025) against mlsynth.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.python.org/" target="_blank" rel="noopener">Van Rossum, G., &amp;amp; Drake, F. L. (2009). &lt;em>Python 3 Reference Manual&lt;/em>. CreateSpace.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://carlos-mendez.org/post/r_sc_dsc_sdid/">Companion tutorials on this site: the R edition of this ladder, the Basque Country, Kansas and Proposition 99.&lt;/a>&lt;/li>
&lt;/ol>
&lt;h4 id="acknowledgements">Acknowledgements&lt;/h4>
&lt;p>AI tools (Claude Code, Gemini, NotebookLM) were used to make the contents of this post more accessible to students. Nevertheless, the content in this post may still have errors. Caution is needed when applying the contents of this post to true research projects.&lt;/p></description></item><item><title>From DiD to SDID: A Ladder of Synthetic Control Estimators, and What Brexit Cost the UK</title><link>https://carlos-mendez.org/post/r_sc_dsc_sdid/</link><pubDate>Fri, 31 Jul 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/r_sc_dsc_sdid/</guid><description>&lt;div style="background:#0e1545; border-radius:12px; padding:8px;">
&lt;iframe style="border-radius:8px" src="https://open.spotify.com/embed/episode/7wmH9iF0ITNStTeBk47zb1?utm_source=generator&amp;theme=0" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy">&lt;/iframe>
&lt;/div>
&lt;h2 id="abstract">Abstract&lt;/h2>
&lt;p>The United Kingdom voted to leave the European Union on 23 June 2016, and because there is only one United Kingdom, that decision can only be costed against a country that never existed. This tutorial builds that country seven times over, hand-coding every stage of the single-treated-unit ladder before running it with its R package: difference-in-differences, synthetic control, demeaned synthetic control, synthetic difference-in-differences in three flavours, matching-and-synthetic-control and augmented synthetic control. The data are quarterly log real GDP for 24 OECD economies from 1995Q1 to 2020Q4: the UK treated, 23 donors, 86 pre-treatment quarters. Dating the treatment at 2016Q3 and matching on outcomes alone, the estimated shortfall in UK GDP at the end of 2018 is 3.06% under synthetic control, 2.99% under demeaned SC, 2.76% under SDID, 2.73% under MASC and 3.04% under augmented SC, widening to between 3.83% and 4.20% a year later — every one larger than the 2.4% previously reported for this dataset. A placebo tournament over twenty artificial treatment dates ranks the SDID family first, at 0.0067 log points of root mean squared error against 0.0089 for plain SC, and shows covariates make the counterfactual worse rather than better. Two things the published tables hide: the headline synthetic-control number is partly an artefact of where the optimiser stopped, and the ranking among the three SDID variants dissolves once they are graded on the same forecast horizon. Porting the ladder to Stata and Python sharpens the first: three independent implementations split by solver, not by language.&lt;/p>
&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>On 23 June 2016 the United Kingdom voted to leave the European Union. Three and a half years later, at the end of 2019, UK GDP was some number of percentage points below where it would otherwise have been. The trouble is the phrase &amp;ldquo;otherwise have been.&amp;rdquo; There is one United Kingdom, it took the treatment, and the version of it that stayed in the EU does not exist anywhere in the data.&lt;/p>
&lt;p>The standard move is to build that missing country out of the countries we do observe. Take the other OECD economies, give each one a weight, add them up, and require that the resulting blend tracks the real UK quarter by quarter through the two decades &lt;em>before&lt;/em> the referendum. If the blend and the UK were indistinguishable for eighty-six quarters, the argument goes, the blend is a credible stand-in for the UK afterwards. Whatever gap opens up after 2016 is the estimated effect.&lt;/p>
&lt;p>That is the synthetic control method, and it is the second stage of a ladder. This tutorial climbs the whole ladder. We start at the bottom, with difference-in-differences, which is the same construction with the weights frozen at one twenty-third. Then we let the data choose the weights (synthetic control). Then we allow the blend to sit at a different &lt;em>level&lt;/em> from the UK, provided it moves in parallel (demeaned synthetic control). Then we let the data also choose &lt;em>which pre-treatment quarters&lt;/em> to hold the blend accountable for (synthetic difference-in-differences). Then two more recent estimators that attack the problem from different directions: a cross-validated blend of matching and synthetic control (MASC), and a ridge-augmented synthetic control that is allowed to use negative weights (ASCM).&lt;/p>
&lt;p>Every stage opens with the same question: &lt;strong>what does the previous stage get wrong?&lt;/strong> That question has a precise answer, and it is the intellectual centre of this post. Following de Brabander, Juodis and Miyazato Szini [1], we will decompose the error of any weighted counterfactual into two pieces: an &lt;em>extrapolation&lt;/em> bias and an &lt;em>interpolation&lt;/em> bias. Synthetic control&amp;rsquo;s unit weights attack only the first. Nearest-neighbour matching attacks only the second. SDID&amp;rsquo;s time weights are what let a single estimator attack both. Once you have that picture, the ladder stops being a list of acronyms and becomes a sequence of answers to one question.&lt;/p>
&lt;p>We do everything twice. Each estimator is first written from scratch, in ten or twenty lines of R, so you can see exactly which optimisation problem is being solved and what is being held fixed. Only then do we call the package — &lt;code>synthdid&lt;/code>, &lt;code>Synth&lt;/code>, &lt;code>masc&lt;/code>, &lt;code>augsynth&lt;/code> — and check that the two agree. When they do not agree, we find out why, and in one case the answer turns out to be more interesting than the agreement would have been.&lt;/p>
&lt;p>The empirical stakes are real. Born, Müller, Schularick and Sedláček [2] estimated, using this same dataset, that the referendum had cost the UK about 2.4% of GDP by the end of 2018. Every stage of the ladder we build puts the number higher.&lt;/p>
&lt;blockquote>
&lt;p>Three companion posts on this site cover pieces of this ground in isolation: &lt;a href="https://carlos-mendez.org/post/r_basic_synthetic_control/">the classic synthetic control on the Basque Country&lt;/a>, &lt;a href="https://carlos-mendez.org/post/r_augsynth/">the augmented synthetic control on the Kansas tax cuts&lt;/a>, and &lt;a href="https://carlos-mendez.org/post/stata_sdid/">synthetic difference-in-differences on Proposition 99 in Stata&lt;/a>. This post is the one that puts them on a single ladder and asks which stage to choose.&lt;/p>
&lt;/blockquote>
&lt;h3 id="11-learning-objectives">1.1 Learning objectives&lt;/h3>
&lt;p>By the end of this tutorial you will be able to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Derive&lt;/strong> each estimator as the same weighted two-way fixed-effects regression with a different choice of unit weights $\omega$ and time weights $\lambda$.&lt;/li>
&lt;li>&lt;strong>Implement&lt;/strong> DiD, SC, DSC, SDID, MASC and ASCM from scratch in base R, and reproduce each one with &lt;code>synthdid&lt;/code>, &lt;code>Synth&lt;/code>, &lt;code>masc&lt;/code> and &lt;code>augsynth&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Decompose&lt;/strong> the bias of any weighted counterfactual into extrapolation and interpolation components, and say which weights target which.&lt;/li>
&lt;li>&lt;strong>Estimate&lt;/strong> the effect of the Brexit referendum on UK GDP under two treatment dates, with and without covariates.&lt;/li>
&lt;li>&lt;strong>Select&lt;/strong> among estimators using an in-sample placebo tournament — and recognise when such a tournament is not comparing like with like.&lt;/li>
&lt;/ul>
&lt;h3 id="12-the-road-ahead">1.2 The road ahead&lt;/h3>
&lt;p>The roadmap below is the shape of the post. Read it as a sequence of complaints: each estimator exists because of something the one before it could not do. The three diamonds are the whole story; everything else is bookkeeping.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">flowchart TD
D[&amp;quot;&amp;lt;b&amp;gt;Data&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;24 OECD economies&amp;lt;br/&amp;gt;1995Q1-2020Q4&amp;lt;br/&amp;gt;log real GDP&amp;quot;] --&amp;gt; Q0{&amp;quot;How much should&amp;lt;br/&amp;gt;each donor country&amp;lt;br/&amp;gt;count?&amp;quot;}
Q0 --&amp;gt;|&amp;quot;all the same&amp;quot;| DID[&amp;quot;&amp;lt;b&amp;gt;1. DiD&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;omega = 1/J&amp;lt;br/&amp;gt;parallel trends&amp;quot;]
Q0 --&amp;gt;|&amp;quot;let the data decide&amp;quot;| SC[&amp;quot;&amp;lt;b&amp;gt;2. SC&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;omega on the simplex&amp;lt;br/&amp;gt;match level AND trend&amp;quot;]
SC --&amp;gt; Q1{&amp;quot;Must the blend sit at&amp;lt;br/&amp;gt;the same LEVEL&amp;lt;br/&amp;gt;as the UK?&amp;quot;}
Q1 --&amp;gt;|&amp;quot;no, absorb the gap&amp;quot;| DSC[&amp;quot;&amp;lt;b&amp;gt;3. DSC&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;demeaned omega&amp;lt;br/&amp;gt;+ constant adjustment&amp;quot;]
DSC --&amp;gt; Q2{&amp;quot;Should every&amp;lt;br/&amp;gt;pre-treatment quarter&amp;lt;br/&amp;gt;count the same?&amp;quot;}
Q2 --&amp;gt;|&amp;quot;no, weight them too&amp;quot;| SDID[&amp;quot;&amp;lt;b&amp;gt;4. SDID&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;omega AND lambda&amp;lt;br/&amp;gt;three variants&amp;quot;]
SDID --&amp;gt; BIAS[&amp;quot;&amp;lt;b&amp;gt;The pivot&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;extrapolation bias&amp;lt;br/&amp;gt;vs interpolation bias&amp;quot;]
BIAS --&amp;gt; MASC[&amp;quot;&amp;lt;b&amp;gt;5. MASC&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;trade the two off&amp;lt;br/&amp;gt;by cross-validation&amp;quot;]
BIAS --&amp;gt; ASCM[&amp;quot;&amp;lt;b&amp;gt;6. ASCM&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;de-bias imperfect fit&amp;lt;br/&amp;gt;with a ridge leash&amp;quot;]
MASC --&amp;gt; R[&amp;quot;&amp;lt;b&amp;gt;Results&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;2.7 to 3.1 per cent&amp;lt;br/&amp;gt;at 2018Q4&amp;quot;]
ASCM --&amp;gt; R
R --&amp;gt; SEL[&amp;quot;&amp;lt;b&amp;gt;Which one?&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;in-sample placebo&amp;lt;br/&amp;gt;over 20 fake dates&amp;quot;]
SEL --&amp;gt; INF[&amp;quot;&amp;lt;b&amp;gt;Inference&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;beyond the paper&amp;quot;]
style D fill:#6a9bcc,stroke:#141413,color:#fff
style Q0 fill:#f5f5f5,stroke:#141413,color:#141413
style Q1 fill:#f5f5f5,stroke:#141413,color:#141413
style Q2 fill:#f5f5f5,stroke:#141413,color:#141413
style DID fill:#d97757,stroke:#141413,color:#fff
style SC fill:#6a9bcc,stroke:#141413,color:#fff
style DSC fill:#6a9bcc,stroke:#141413,color:#fff
style SDID fill:#00d4c8,stroke:#141413,color:#141413
style BIAS fill:#141413,stroke:#00d4c8,color:#fff
style MASC fill:#d97757,stroke:#141413,color:#fff
style ASCM fill:#d97757,stroke:#141413,color:#fff
style R fill:#6a9bcc,stroke:#141413,color:#fff
style SEL fill:#00d4c8,stroke:#141413,color:#141413
style INF fill:#141413,stroke:#6a9bcc,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Notice that the dark node in the middle is not an estimator. It is the section that explains why the last two branches exist at all, and it is the part of this material that transfers to problems that have nothing to do with Brexit.&lt;/p>
&lt;h2 id="2-key-concepts">2. Key concepts&lt;/h2>
&lt;p>Eight ideas carry the whole post. Two of them repay slow reading: the distinction between unit weights and time weights, and the distinction between extrapolation bias and interpolation bias. Neither of those bias names means what you would guess, which is exactly why they need a card.&lt;/p>
&lt;p>&lt;strong>The missing counterfactual and the donor pool.&lt;/strong>
There is one United Kingdom and it took the treatment. The path it would have followed without Brexit is not in any dataset. Synthetic control builds that path from countries that were not treated. Those countries are the donor pool.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>After dropping the twelve OECD countries with incomplete records, 24 remain. The UK is the treated unit. The other 23 — from Australia to the United States — are the donor pool. None of them held a referendum on EU membership in 2016.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>The master tape of a song is lost and the original band has broken up. You hire session musicians and rehearse them against a bootleg recording until they are indistinguishable from the original. Then you have them play a song the original band never recorded. The donor pool is the pool of session musicians; the pre-treatment period is the rehearsal.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The simplex and the convex hull.&lt;/strong>
Synthetic control weights must be non-negative and must sum to one. That set of allowed weight vectors is called the simplex. The blends it can produce form a region called the convex hull of the donors.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>With 23 donors, the simplex is the set of 23 non-negative numbers adding to one. In our fit only about eight donors get a weight above 0.01: Hungary near 0.22, the United States near 0.20, Japan near 0.18, Canada near 0.16, Norway near 0.13. The other fifteen get essentially nothing.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Hammer a pin into a corkboard for every donor country, positioned by its economic characteristics. Stretch a rubber band around all the pins and let it snap tight. Everything inside the band is reachable by some blend; nothing outside it is. If the UK&amp;rsquo;s pin lands outside the band, no recipe of non-negative parts can reach it — you would need a &lt;em>negative&lt;/em> amount of some country, like a recipe calling for minus two eggs.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>Unit weights and time weights.&lt;/strong>
Unit weights say how much each donor country counts. Time weights say how much each pre-treatment quarter counts. The two are chosen by the same kind of optimisation, run in two different directions.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>Our unit weights put about 0.22 on Hungary and 0.00 on France. Our time weights put 0.96 on 2016Q2 and roughly zero on the other 85 quarters. Both vectors are non-negative and both sum to one.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A mixing desk has two banks of faders. The first bank sets how loud each instrument is; the second sets which seconds of the rehearsal tape you play back when you check the mix. Difference-in-differences leaves both banks flat. Synthetic control moves the first. Synthetic difference-in-differences moves both.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The bias-adjustment term, which is a unit fixed effect in disguise.&lt;/strong>
Sometimes the blend moves in near-perfect parallel with the treated unit but sits at a slightly different level. The bias-adjustment term is the average pre-treatment gap, subtracted off. Adding it is exactly the same as putting a unit fixed effect in the regression.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>For the UK the demeaned synthetic control&amp;rsquo;s adjustment is $+0.0024$ log points, about a quarter of one per cent of GDP. That is why the DSC estimate of 2.99% at 2018Q4 sits so close to the SC estimate of 3.06%. A small adjustment is evidence that the SC fit was already level-balanced.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Your bathroom scale reads two kilograms heavy. You do not throw it out; you subtract two. Synthetic control insists on a scale that is already exactly right and will reject a perfectly consistent one. Demeaned synthetic control just calibrates the offset.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>Extrapolation bias and interpolation bias.&lt;/strong>
Two different ways a weighted counterfactual can be wrong. Extrapolation bias: the blend&amp;rsquo;s characteristics do not match the treated unit&amp;rsquo;s. Interpolation bias: the characteristics match, but the outcome is a curved function of them, so averaging outcomes is not the same as the outcome at the average.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>Synthetic control chooses its weights to minimise pre-treatment prediction error, which is exactly minimising the first kind of error. It does nothing about the second unless log GDP happens to be a linear function of the underlying drivers. SDID&amp;rsquo;s time weights are what attack the second.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>You are roasting a 3.4 kilogram turkey and the chart lists only 3 kg and 4 kg. Extrapolation bias is misreading the scale and looking up 5 kg — right chart, wrong row. Interpolation bias is reading both rows correctly and averaging their times — right rows, but roasting time bends with weight, so the average of two times is not the time for the average bird. The two errors are independent, and fixing one does nothing for the other.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>Rolling-origin cross-validation.&lt;/strong>
A way to tune a parameter using only pre-treatment data. Walk forward through the pre-period; at each stopping point, fit on everything before it, forecast the next step, and score the forecast.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>MASC uses it to pick the number of matched neighbours $m$ and the blend weight $\phi$. With 86 pre-treatment quarters it produces eighty rolling origins, each giving a one-quarter-ahead forecast error for every candidate pair. Here it lands on $m = 10$ and $\phi = 0.158$.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>You do not grade a weather forecaster on how vividly they describe yesterday. You replay the archive, ask them to predict tomorrow from each day&amp;rsquo;s vantage point, and total up the misses.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The in-sample placebo.&lt;/strong>
Pretend the treatment happened earlier, when nothing did. Estimate the counterfactual anyway and compare it to what actually occurred. The error you get is pure false alarm, and it measures the estimator&amp;rsquo;s precision.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>We repeat the whole exercise for twenty artificial treatment dates, with last pre-treatment quarters running from 2010Q1 to 2014Q4. SDID&amp;rsquo;s root mean squared error over those twenty is 0.0067 log points; plain synthetic control&amp;rsquo;s is 0.0089.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A fire drill. There is no fire, so any alarm is a false one, and the detector that stays quietest is the one you install in the server room.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>The ridge leash.&lt;/strong>
Augmented synthetic control drops the non-negativity constraint so the weights can leave the convex hull. A quadratic penalty pulls them back toward the ordinary SC weights, and the penalty parameter sets how far they may roam.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>The ASCM weights in this application include Switzerland at $-0.0090$ and Slovak Republic at $-0.0085$ — impossible under the simplex — and the resulting estimate is 3.04% at 2018Q4, very close to the 3.06% from plain SC.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>An elastic leash on a dog. Slack leash: the dog goes wherever the scent leads, including off the lawn. Taut leash: it stays where you started. The stiffness of the leash is the ridge parameter.&lt;/p>
&lt;/details>
&lt;/div>
&lt;h2 id="3-setup">3. Setup&lt;/h2>
&lt;p>Five packages do the estimation and four more do the plotting. Three of the five are not on CRAN, which is worth knowing before you start.&lt;/p>
&lt;pre>&lt;code class="language-r">library(quadprog) # exact simplex least squares -- our hand-coded solver
library(synthdid) # SC, DSC and SDID all come out of one function (GitHub)
library(Synth) # only needed for the nested V-optimisation with covariates
library(masc) # matching and synthetic control (GitHub)
library(augsynth) # ridge-augmented synthetic control (GitHub)
library(ggplot2); library(tidyr); library(readr)
library(patchwork); library(scales); library(jsonlite)
# The three GitHub packages:
# remotes::install_github(&amp;quot;synth-inference/synthdid&amp;quot;)
# remotes::install_github(&amp;quot;ebenmichael/augsynth&amp;quot;)
#
# masc declares a hard dependency on Gurobi, a commercial solver it never
# actually needs on the code path we use. Drop the dependency first:
# git clone --depth 1 https://github.com/maxkllgg/masc /tmp/masc_src
# sed -i '' 's/^ gurobi,$//' /tmp/masc_src/DESCRIPTION
# R CMD INSTALL /tmp/masc_src
set.seed(20260801)
&lt;/code>&lt;/pre>
&lt;p>Everything in this post runs on R 4.5.2 with &lt;code>synthdid&lt;/code> 0.0.9, &lt;code>augsynth&lt;/code> 0.2.0, &lt;code>masc&lt;/code> 0.1.1 and &lt;code>Synth&lt;/code> 1.1-10. The full script is &lt;a href="analysis.R">&lt;code>analysis.R&lt;/code>&lt;/a>, which also needs &lt;code>patchwork&lt;/code>, &lt;code>scales&lt;/code> and &lt;code>jsonlite&lt;/code> for its figures and exports.&lt;/p>
&lt;p>If you want the estimates without the derivations, there are three cheat sheets — one per language — each of which calls the packages directly and ends with the same comparative table: &lt;a href="cheatsheet_R.R">&lt;code>cheatsheet_R.R&lt;/code>&lt;/a>, &lt;a href="cheatsheet_stata.do">&lt;code>cheatsheet_stata.do&lt;/code>&lt;/a> and &lt;a href="cheatsheet_python.py">&lt;code>cheatsheet_python.py&lt;/code>&lt;/a>. &lt;a href="#19-the-same-ladder-in-stata-and-python">Section 19&lt;/a> compares what the three languages produce, and why two of them disagree in the second decimal.&lt;/p>
&lt;h2 id="4-the-data">4. The data&lt;/h2>
&lt;p>The dataset is the one assembled by Born and coauthors [2] from the OECD Economic Outlook: quarterly national accounts for 36 OECD economies. Twelve are dropped for incomplete records, leaving 24 countries observed over 104 quarters with no missing values at all.&lt;/p>
&lt;pre>&lt;code class="language-r">url &amp;lt;- paste0(&amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/&amp;quot;,
&amp;quot;master/content/post/r_sc_dsc_sdid/brexit_analysis.csv&amp;quot;)
panel &amp;lt;- if (file.exists(&amp;quot;brexit_analysis.csv&amp;quot;)) read.csv(&amp;quot;brexit_analysis.csv&amp;quot;) else read.csv(url)
COUNTRIES &amp;lt;- unique(panel$country[order(panel$unit_id)])
UK &amp;lt;- which(COUNTRIES == &amp;quot;United Kingdom&amp;quot;)
DONORS &amp;lt;- setdiff(seq_along(COUNTRIES), UK)
# Y is [time x country]: 104 quarters by 24 countries.
Y &amp;lt;- matrix(panel$log_rgdp[order(panel$unit_id, panel$t)],
nrow = 104, dimnames = list(NULL, COUNTRIES))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> panel : 24 countries x 104 quarters (1995Q1 to 2020Q4)
treated : United Kingdom (unit_id 23)
donors (23) : Australia, Austria, Belgium, Canada, Finland, France, Germany,
Hungary, Iceland, Ireland, Italy, Japan, Korea, Luxembourg,
Netherlands, New Zealand, Norway, Portugal, Slovak Republic,
Spain, Sweden, Switzerland, United States
headline spec: treatment materialises 2016Q3, T0 = 86 pre-periods
evaluated at : 2018Q4 (t=96) and 2019Q4 (t=100)
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The donor pool has &lt;strong>23 countries&lt;/strong> and the pre-treatment window has &lt;strong>86 quarters&lt;/strong> — a long panel by synthetic-control standards, which matters because the bias of these estimators shrinks with the number of pre-treatment periods. The outcome is the natural log of real GDP indexed so that each country&amp;rsquo;s 1995 average equals one, so all effects are in &lt;strong>log points&lt;/strong> and a difference of 0.03 is about a 3% shortfall.&lt;/p>
&lt;h3 id="41-two-definitions-of-t_0-pinned-down-once">4.1 Two definitions of $T_0$, pinned down once&lt;/h3>
&lt;p>This is the single most common place to go wrong, so we fix it before writing any code. In the papers, $T_0$ is the treatment &lt;em>period&lt;/em>. In the &lt;code>synthdid&lt;/code> package, the &lt;code>T0&lt;/code> argument is the &lt;em>number&lt;/em> of pre-treatment periods. Here they are 87 and 86.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Concept&lt;/th>
&lt;th>In the papers&lt;/th>
&lt;th>In &lt;code>synthdid&lt;/code>&lt;/th>
&lt;th>Quarter&lt;/th>
&lt;th>&lt;code>t&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>First pre-treatment quarter&lt;/td>
&lt;td>$t = 1$&lt;/td>
&lt;td>—&lt;/td>
&lt;td>1995Q1&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Last pre-treatment quarter&lt;/td>
&lt;td>$t = T_0 - 1$&lt;/td>
&lt;td>period &lt;code>T0&lt;/code>&lt;/td>
&lt;td>2016Q2&lt;/td>
&lt;td>86&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Number of pre-treatment quarters&lt;/td>
&lt;td>$T_0 - 1 = 86$&lt;/td>
&lt;td>&lt;code>T0 = 86&lt;/code>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Treatment quarter&lt;/td>
&lt;td>$t = T_0$&lt;/td>
&lt;td>period &lt;code>T0 + 1&lt;/code>&lt;/td>
&lt;td>2016Q3&lt;/td>
&lt;td>87&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>First evaluation quarter&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>2018Q4&lt;/td>
&lt;td>96&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Second evaluation quarter&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>2019Q4&lt;/td>
&lt;td>100&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The referendum was held on 23 June 2016, right at the end of 2016Q2. Following the source paper we date the treatment by the quarter in which the effect &lt;em>materialises&lt;/em>, which makes 2016Q3 the headline choice and 2016Q2 a robustness check. Section 17 shows the choice is not innocuous.&lt;/p>
&lt;h2 id="5-what-the-data-look-like-before-we-assume-anything">5. What the data look like before we assume anything&lt;/h2>
&lt;pre>&lt;code class="language-r">ggplot(donors, aes(date, y, group = country)) +
geom_line(colour = GREY_DONOR, linewidth = 0.35, alpha = 0.75) +
geom_line(data = uk, aes(date, y), colour = ORANGE, linewidth = 1.1) +
geom_vline(xintercept = 2016.50, linetype = &amp;quot;dashed&amp;quot;, colour = TEAL)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_sc_dsc_sdid_01_gdp_paths.png" alt="Log real GDP for 24 OECD countries from 1995 to 2020, with the United Kingdom highlighted in orange among 23 grey donor paths and a dashed vertical line at the 2016 referendum">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The UK is one line in a crowd. No single donor tracks it, which is the whole reason we will be building a &lt;em>blend&lt;/em>. But look at 2008–09: every line collapses at once. That synchronised movement is a common factor, and the estimators from stage three onward are built precisely to exploit it.&lt;/p>
&lt;p>Now the same picture with the crowd averaged into a single line. This &lt;em>is&lt;/em> the difference-in-differences counterfactual, drawn before we name it.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_02_did_counterfactual.png" alt="The UK&amp;amp;rsquo;s log real GDP against the level-aligned equal-weighted average of the 23 donors, with the growing gap between them shaded in orange">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The two lines were already drifting apart from about 2013, three years before the referendum. That is parallel trends failing in plain sight. Difference-in-differences will happily produce a number anyway — we compute it in the next section — and the number will be nearly twice what every other method reports. This is not a subtle failure.&lt;/p>
&lt;p>The six covariates that Born and coauthors match on tell their own story:&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_03_covariates.png" alt="Six small-multiple panels showing consumption, investment, exports and imports as shares of GDP, labour productivity growth and the employment share, with the UK in orange over the donor interquartile band">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The six predictors live on scales that differ by orders of magnitude — a consumption share near 0.65, a quarterly productivity growth rate near 0.1. That is why Abadie&amp;rsquo;s method needs a predictor-importance matrix at all, and it is the first hint of why adding covariates might cost more than it buys.&lt;/p>
&lt;h2 id="6-one-regression-seven-sets-of-weights">6. One regression, seven sets of weights&lt;/h2>
&lt;p>Before the ladder, the frame. Everything below is the &lt;em>same&lt;/em> regression run with different weights.&lt;/p>
&lt;p>Write the observed outcome in potential-outcome form. Let $y_{j,t}$ be log real GDP in country $j$ and quarter $t$, and let $w_{j,t}$ be one when country $j$ is treated in quarter $t$:&lt;/p>
&lt;p>$$y_{j,t} = w_{j,t}\, y^{1}_{j,t} + (1 - w_{j,t})\, y^{0}_{j,t}$$&lt;/p>
&lt;p>In words, this says that for every country-quarter we observe exactly one of two numbers — the treated outcome if the referendum applies, the untreated outcome otherwise — and the other is permanently missing. In code, $y_{j,t}$ is &lt;code>Y[t, j]&lt;/code> and $w_{j,t}$ is one only for the UK from &lt;code>t = 87&lt;/code> onward.&lt;/p>
&lt;p>What we want is the gap between what the UK did and what it would have done:&lt;/p>
&lt;p>$$\hat{\tau}_t = y_{1,t} - \hat{y}^{0}_{1,t}$$&lt;/p>
&lt;p>In words, the effect in any post-referendum quarter is the observed UK outcome minus the estimated counterfactual. In code, $y_{1,t}$ is &lt;code>Y[96, UK]&lt;/code> at 2018Q4 and $\hat{y}^{0}_{1,t}$ is &lt;code>Y[96, DONORS] %*% omega&lt;/code>.&lt;/p>
&lt;p>Now the frame. Every estimator in this post is a weighted two-way fixed-effects regression:&lt;/p>
&lt;p>$$\left(\hat{\tau}, \hat{\alpha}, \hat{\beta}\right) = \underset{\tau, \alpha, \beta}{\arg\min} \sum_{j=1}^{J+1} \sum_{t=1}^{T_0} \left( y_{j,t} - \alpha_j - \beta_t - w_{j,t}\, \tau \right)^{2} \hat{\omega}_j\, \hat{\lambda}_t$$&lt;/p>
&lt;p>In words, this says: regress the outcome on a country effect, a quarter effect and a treatment dummy, weighting each country by $\hat{\omega}_j$ and each quarter by $\hat{\lambda}_t$. In code, $\hat{\omega}_j$ is &lt;code>omega[j]&lt;/code>, $\hat{\lambda}_t$ is &lt;code>lambda[t]&lt;/code>, and $\alpha_j$ is the unit fixed effect that &lt;code>omega.intercept = TRUE&lt;/code> switches on.&lt;/p>
&lt;p>The entire ladder is a table of settings for that one expression:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Unit weights $\omega$&lt;/th>
&lt;th>Time weights $\lambda$&lt;/th>
&lt;th>Unit effect $\alpha$&lt;/th>
&lt;th>Feasible set for $\omega$&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DiD&lt;/td>
&lt;td>fixed at $1/J$&lt;/td>
&lt;td>fixed at $1/(T_0-1)$&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>optimised&lt;/td>
&lt;td>none&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;td>simplex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SC(B)&lt;/td>
&lt;td>optimised&lt;/td>
&lt;td>none&lt;/td>
&lt;td>no&lt;/td>
&lt;td>simplex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>optimised on demeaned data&lt;/td>
&lt;td>fixed at $1/(T_0-1)$&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>simplex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID&lt;/td>
&lt;td>optimised on demeaned data&lt;/td>
&lt;td>&lt;strong>optimised&lt;/strong>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>simplex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>$\phi \cdot$ matching $+\, (1-\phi) \cdot$ SC&lt;/td>
&lt;td>none&lt;/td>
&lt;td>no&lt;/td>
&lt;td>simplex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>SC weights + ridge correction&lt;/td>
&lt;td>none&lt;/td>
&lt;td>no&lt;/td>
&lt;td>&lt;strong>sums to one, sign free&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;pre>&lt;code class="language-mermaid">graph TD
OBJ[&amp;quot;&amp;lt;b&amp;gt;One weighted two-way regression&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;minimise the sum of&amp;lt;br/&amp;gt;(y - alpha - beta - w*tau)^2 * omega * lambda&amp;quot;]
OBJ --&amp;gt; A[&amp;quot;&amp;lt;b&amp;gt;omega uniform&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;lambda uniform&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;alpha included&amp;quot;]
OBJ --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;omega optimised&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;no lambda&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;alpha SUPPRESSED&amp;quot;]
OBJ --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;omega optimised&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;lambda uniform&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;alpha included&amp;quot;]
OBJ --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;omega optimised&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;lambda optimised&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;alpha included&amp;quot;]
A --&amp;gt; A1[&amp;quot;DiD&amp;quot;]
B --&amp;gt; B1[&amp;quot;SC and SC(B)&amp;quot;]
C --&amp;gt; C1[&amp;quot;DSC&amp;quot;]
E --&amp;gt; E1[&amp;quot;SDID&amp;quot;]
B1 --&amp;gt; F[&amp;quot;&amp;lt;b&amp;gt;Change the feasible set&amp;lt;br/&amp;gt;instead of the weights&amp;lt;/b&amp;gt;&amp;quot;]
F --&amp;gt; F1[&amp;quot;MASC&amp;lt;br/&amp;gt;cap omega at 1/m,&amp;lt;br/&amp;gt;then blend&amp;quot;]
F --&amp;gt; F2[&amp;quot;ASCM&amp;lt;br/&amp;gt;drop non-negativity,&amp;lt;br/&amp;gt;add a ridge pull&amp;quot;]
style OBJ fill:#141413,stroke:#00d4c8,color:#fff
style A fill:#d97757,stroke:#141413,color:#fff
style B fill:#6a9bcc,stroke:#141413,color:#fff
style C fill:#6a9bcc,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#141413
style A1 fill:#f5f5f5,stroke:#141413,color:#141413
style B1 fill:#f5f5f5,stroke:#141413,color:#141413
style C1 fill:#f5f5f5,stroke:#141413,color:#141413
style E1 fill:#f5f5f5,stroke:#141413,color:#141413
style F fill:#141413,stroke:#d97757,color:#fff
style F1 fill:#d97757,stroke:#141413,color:#fff
style F2 fill:#d97757,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Two things are worth pausing on. First, synthetic control is the only stage that switches the unit fixed effect &lt;em>off&lt;/em>, and that single omission is what forces it to match the UK&amp;rsquo;s level as well as its shape. Second, MASC and ASCM hang off a different branch: they do not re-weight the regression, they change what counts as an admissible weight vector.&lt;/p>
&lt;h3 id="61-one-solver-used-five-times">6.1 One solver, used five times&lt;/h3>
&lt;p>Every stage reduces to the same problem — minimise a sum of squares over the simplex — with different inputs. So we write the solver once:&lt;/p>
&lt;p>$$\underset{w}{\min} \, \lVert b - A w \rVert^{2} \quad \text{subject to} \quad w_k \geq 0 \, \text{for all } k, \quad \sum_k w_k = 1$$&lt;/p>
&lt;p>In words, find the non-negative shares of the columns of $A$ that come closest to reproducing the target vector $b$. In code, $A$ is &lt;code>Z0&lt;/code> (the donors&amp;rsquo; pre-treatment paths) when we want unit weights, and its transpose when we want time weights.&lt;/p>
&lt;pre>&lt;code class="language-r">simplex_ls &amp;lt;- function(A, b, ridge = 1e-10) {
k &amp;lt;- ncol(A)
w &amp;lt;- solve.QP(Dmat = crossprod(A) + ridge * diag(k), # the quadratic term
dvec = crossprod(A, b), # the linear term
Amat = cbind(rep(1, k), diag(k)), # col 1: sum(w)=1; rest: w&amp;gt;=0
bvec = c(1, rep(0, k)),
meq = 1)$solution # meq=1: first constraint is =
w[w &amp;lt; 1e-10] &amp;lt;- 0
w / sum(w)
}
&lt;/code>&lt;/pre>
&lt;p>The &lt;code>1e-10&lt;/code> on the diagonal is numerical hygiene so the Cholesky factorisation never fails. It is &lt;em>not&lt;/em> the regularisation parameter of Arkhangelsky and coauthors, which is a modelling choice we look at in section 17.&lt;/p>
&lt;h2 id="7-stage-1--difference-in-differences">7. Stage 1 — Difference-in-differences&lt;/h2>
&lt;p>DiD is the ladder&amp;rsquo;s ground floor: every donor counts the same, and a unit fixed effect absorbs whatever constant level gap remains.&lt;/p>
&lt;p>$$\hat{\tau}^{did}_{t} = \left( y_{1,t} - \bar{y}_{1} \right) - \frac{1}{J} \sum_{j=2}^{J+1} \left( y_{j,t} - \bar{y}_{j} \right)$$&lt;/p>
&lt;p>In words, the change for the UK from its own pre-referendum average, minus the average change across all 23 donors from theirs. In code, $\bar{y}_j$ is &lt;code>colMeans(Z0)&lt;/code> and $1/J$ is &lt;code>rep(1/23, 23)&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-r">w_did &amp;lt;- rep(1 / 23, 23)
b_did &amp;lt;- mean(Z1 - Z0 %*% w_did) # the unit fixed effect
loss &amp;lt;- function(w, e, b = 0) -100 * (Y[e, UK] - drop(Y[e, DONORS] %*% w) - b)
c(loss(w_did, 96, b_did), loss(w_did, 100, b_did))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> DiD 2018Q4 4.981 2019Q4 6.182 uniform weights
pre-treatment RMSE vs the donor average : 0.02175
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> DiD says Brexit cost the UK &lt;strong>4.98% of GDP&lt;/strong> by the end of 2018 and &lt;strong>6.18%&lt;/strong> by the end of 2019 — roughly double what every other stage will report. It is not in the source paper&amp;rsquo;s tables, and it should not be trusted: its pre-treatment fit error is &lt;strong>0.0218 log points&lt;/strong>, four times what synthetic control will achieve. The estimator is fitting a trend divergence that began years before the referendum and calling it Brexit.&lt;/p>
&lt;p>DiD&amp;rsquo;s error is that it gave Luxembourg and the United States the same vote. The next stage lets the data vote.&lt;/p>
&lt;h2 id="8-stage-2--synthetic-control">8. Stage 2 — Synthetic control&lt;/h2>
&lt;h3 id="81-the-optimisation-problem">8.1 The optimisation problem&lt;/h3>
&lt;p>Instead of fixing the weights, choose them to make the blend track the treated unit as closely as possible over the pre-treatment window:&lt;/p>
&lt;p>$$\hat{\boldsymbol{\omega}}^{sc} = \underset{\boldsymbol{\omega} \in \mathbb{W}}{\arg\min} \sum_{t=1}^{T_0-1} \left( y_{1,t} - \sum_{j=2}^{J+1} \omega_j\, y_{j,t} \right)^{2}$$&lt;/p>
&lt;p>In words, pick the blend of donors whose path over the 86 pre-referendum quarters is as close as possible, in squared error, to the UK&amp;rsquo;s own. In code, the inner sum is &lt;code>Z0 %*% omega&lt;/code> and the objective is &lt;code>sum((Z1 - Z0 %*% omega)^2)&lt;/code>.&lt;/p>
&lt;p>The feasible set is the simplex:&lt;/p>
&lt;p>$$\mathbb{W} = \left\{ \boldsymbol{\omega} \in \mathbb{R}^{J} : \omega_j \geq 0 \, \text{for all } j, \, \sum_{j=2}^{J+1} \omega_j = 1 \right\}$$&lt;/p>
&lt;p>In words, the weights are shares — never negative, always adding to one. In code, that is the &lt;code>Amat&lt;/code>/&lt;code>bvec&lt;/code>/&lt;code>meq&lt;/code> block of &lt;code>simplex_ls&lt;/code>.&lt;/p>
&lt;p>Once the weights are fixed, the counterfactual in &lt;em>any&lt;/em> quarter is just the weighted sum of what the donors actually did:&lt;/p>
&lt;p>$$\hat{y}^{0,sc}_{1,t} = \sum_{j=2}^{J+1} \hat{\omega}^{sc}_{j}\, y_{j,t}$$&lt;/p>
&lt;h3 id="82-what-the-simplex-actually-is">8.2 What the simplex actually is&lt;/h3>
&lt;p>Two constraints, non-negativity and summing to one, sound innocuous. Geometrically they are not. They confine the synthetic UK to the &lt;em>convex hull&lt;/em> of the donors — the region you can reach by averaging.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_04_convex_hull.png" alt="Donor countries plotted by their average log GDP early and late in the pre-treatment period, with the convex hull shaded and the UK marked inside it">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The shaded region is everything a non-negative blend can reach. The UK sits comfortably inside it, which is why synthetic control works well here. Had the orange point fallen outside the band — a very rich or very poor treated unit — no recipe of non-negative shares could have reached it, and we would need stage six.&lt;/p>
&lt;p>That picture is in &lt;em>outcome&lt;/em> space. The optimisation happens in &lt;em>weight&lt;/em> space, which is a different object. Restrict attention to the three donors that end up carrying the most weight and you can draw the entire search:&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_05_simplex_surface.png" alt="The pre-treatment mean squared prediction error over the two-simplex of blends of the United States, Hungary and Japan, drawn as a filled triangle with the minimum marked">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Every point in the triangle is one set of weights summing to one; the corners are &amp;ldquo;put everything on one donor&amp;rdquo;. The minimum sits in the interior, meaning all three donors earn a positive share. The real problem is this same picture in 22 dimensions, and a weight of exactly zero means the optimum sat on an edge.&lt;/p>
&lt;h3 id="83-from-scratch-then-the-package">8.3 From scratch, then the package&lt;/h3>
&lt;pre>&lt;code class="language-r">Z1 &amp;lt;- Y[1:86, UK] # the UK's pre-treatment path
Z0 &amp;lt;- Y[1:86, DONORS] # the donors' pre-treatment paths
w_sc_qp &amp;lt;- simplex_ls(Z0, Z1) # exact, via quadprog
# The package. Note the layout: units x time, treated unit LAST, and T0 is the
# NUMBER of pre-treatment periods.
Y_sd &amp;lt;- t(cbind(Y[, DONORS], Y[, UK]))
sd_sc &amp;lt;- synthdid_estimate(Y_sd[, 1:87], N0 = 23, T0 = 86,
zeta.omega = 0, zeta.lambda = 0,
omega.intercept = FALSE, lambda.intercept = FALSE)
w_sc_pkg &amp;lt;- as.numeric(attr(sd_sc, &amp;quot;weights&amp;quot;)$omega)
&lt;/code>&lt;/pre>
&lt;p>Why call &lt;code>synthdid&lt;/code> rather than &lt;code>Synth&lt;/code> here? Because with no covariates, the classic synthetic-control problem of the equation above &lt;em>is&lt;/em> &lt;code>synthdid_estimate&lt;/code> with both intercepts off and both penalties set to zero. Using one optimiser for the whole ladder means every difference we report between stages is the method, not the solver. &lt;code>Synth&lt;/code> reappears in section 16, where its nested optimisation is genuinely needed.&lt;/p>
&lt;p>There is a third way to solve the same problem, and we need it in a moment. &lt;code>synthdid&lt;/code> does not call a quadratic programming solver at all — internally it runs &lt;strong>Frank–Wolfe&lt;/strong>, an iterative method that walks toward the optimum one simplex vertex at a time. Porting it is twenty lines, and it is the only way to see what the package is actually doing:&lt;/p>
&lt;pre>&lt;code class="language-r"># A line-for-line port of synthdid's internal optimiser (sc.weight.fw).
fw_step &amp;lt;- function(A, x, b, eta) {
Ax &amp;lt;- A %*% x
half &amp;lt;- t(Ax - b) %*% A + eta * x
i &amp;lt;- which.min(half) # the steepest simplex vertex
dx &amp;lt;- -x; dx[i] &amp;lt;- 1 - x[i] # the direction to move in
if (all(dx == 0)) return(x)
derr &amp;lt;- A[, i] - Ax
s &amp;lt;- -drop(half %*% dx) / (sum(derr^2) + eta * sum(dx^2))
x + min(1, max(0, s)) * dx # the optimal step length, clipped
}
simplex_fw &amp;lt;- function(A, b, intercept = FALSE, min.decrease = 1e-5,
max.iter = 10000) {
if (intercept) { A &amp;lt;- sweep(A, 2, colMeans(A)); b &amp;lt;- b - mean(b) }
run &amp;lt;- function(x, mi) {
vals &amp;lt;- rep(NA_real_, mi); it &amp;lt;- 0
# Stop on a small enough improvement -- OR on the iteration cap.
while (it &amp;lt; mi &amp;amp;&amp;amp; (it &amp;lt; 2 || vals[it - 1] - vals[it] &amp;gt; min.decrease^2)) {
it &amp;lt;- it + 1
x &amp;lt;- fw_step(A, x, b, 0)
vals[it] &amp;lt;- sum((A %*% x - b)^2) / nrow(A)
}
x
}
x &amp;lt;- run(rep(1 / ncol(A), ncol(A)), 100) # short pre-round, then sparsify
x[x &amp;lt;= max(x) / 4] &amp;lt;- 0; x &amp;lt;- x / sum(x)
run(x, max.iter)
}
w_sc_fw &amp;lt;- simplex_fw(Z0, Z1, min.decrease = 1e-5 * sd(apply(t(Z0), 1, diff)))
&lt;/code>&lt;/pre>
&lt;p>Watch the &lt;code>while&lt;/code> condition. It exits on &lt;em>either&lt;/em> a small enough improvement &lt;em>or&lt;/em> the iteration cap — and which of those fires turns out to matter.&lt;/p>
&lt;pre>&lt;code class="language-text"> exact QP : SSR 2.686818e-03 nonzero 9 loss(2018Q4) 3.039
hand-coded FW: SSR 2.751662e-03 nonzero 13 loss(2018Q4) 3.056
synthdid : SSR 2.751662e-03 nonzero 13 loss(2018Q4) 3.056
|FW - package| max abs weight difference : 0.000e+00 &amp;lt;-- these agree
|QP - package| max abs weight difference : 1.564e-02 &amp;lt;-- these do NOT
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Something has gone wrong — or rather, something instructive has gone right. Our hand-coded Frank–Wolfe loop matches the package &lt;em>exactly&lt;/em>, to the last bit. But the exact quadratic-programming solution disagrees, giving &lt;strong>3.04%&lt;/strong> where the package gives &lt;strong>3.06%&lt;/strong>, and achieving a &lt;strong>lower&lt;/strong> sum of squared residuals while doing it. The package is not finding the optimum.&lt;/p>
&lt;h3 id="84-why-the-two-solvers-disagree">8.4 Why the two solvers disagree&lt;/h3>
&lt;p>The reason is the shape of the objective. With 23 donors and 86 pre-treatment quarters and no regularisation, the problem is nearly degenerate:&lt;/p>
&lt;pre>&lt;code class="language-text"> noise.level (sd of donor quarterly changes) : 0.012425
synthdid min.decrease : 1.242e-07
donor Gram: smallest eigenvalue 3.192e-04, condition number 7.487e+05
&lt;/code>&lt;/pre>
&lt;p>A condition number near a million means the objective has directions along which it is almost perfectly flat. Frank–Wolfe crawls along those directions and never triggers its stopping rule, so it halts on its iteration cap instead. Let it run longer and the answer keeps moving:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Frank–Wolfe iterations&lt;/th>
&lt;th>Sum of squares&lt;/th>
&lt;th>Loss at 2018Q4&lt;/th>
&lt;th>Loss at 2019Q4&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>100&lt;/td>
&lt;td>0.0034364&lt;/td>
&lt;td>3.177&lt;/td>
&lt;td>4.289&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>300&lt;/td>
&lt;td>0.0031180&lt;/td>
&lt;td>3.038&lt;/td>
&lt;td>4.183&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1,000&lt;/td>
&lt;td>0.0029646&lt;/td>
&lt;td>3.056&lt;/td>
&lt;td>4.203&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3,000&lt;/td>
&lt;td>0.0028366&lt;/td>
&lt;td>3.061&lt;/td>
&lt;td>4.210&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>10,000 (the package default)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0.0027517&lt;/strong>&lt;/td>
&lt;td>&lt;strong>3.056&lt;/strong>&lt;/td>
&lt;td>&lt;strong>4.204&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>30,000&lt;/td>
&lt;td>0.0027141&lt;/td>
&lt;td>3.047&lt;/td>
&lt;td>4.187&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>100,000&lt;/td>
&lt;td>0.0026963&lt;/td>
&lt;td>3.042&lt;/td>
&lt;td>4.178&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>exact QP&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0.0026868&lt;/strong>&lt;/td>
&lt;td>&lt;strong>3.039&lt;/strong>&lt;/td>
&lt;td>&lt;strong>4.172&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;img src="r_sc_dsc_sdid_07_solver_ladder.png" alt="Estimated 2018Q4 GDP loss plotted against the number of Frank-Wolfe iterations on a log scale, converging toward the exact optimum with the package default marked in orange">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The published synthetic-control estimate of 3.06% is the value Frank–Wolfe happens to be passing through at ten thousand iterations. The true minimiser of the stated objective gives &lt;strong>3.04%&lt;/strong>. The difference is 0.02 percentage points — economically nothing, and it does not change a single conclusion in this post. But it is worth knowing that it is there, because it tells you something general: &lt;strong>when a synthetic-control objective is this flat, the donor weights are not identified to more than a couple of decimal places, even though the estimate they produce is stable.&lt;/strong> Two solvers disagreeing by 0.016 on individual weights agree to within 0.02 percentage points on the effect.&lt;/p>
&lt;p>For the rest of the post we report the package answer, so the tables line up with the published ones.&lt;/p>
&lt;h3 id="85-the-counterfactual-and-the-gap">8.5 The counterfactual and the gap&lt;/h3>
&lt;pre>&lt;code class="language-text"> country omega_fw omega_qp
Hungary 0.2186 0.2231
United States 0.1994 0.1926
Japan 0.1773 0.1826
Canada 0.1612 0.1751
Norway 0.1256 0.1350
Ireland 0.0543 0.0523
Italy 0.0353 0.0196
Portugal 0.0123 0.0124
pre-treatment RMSPE : 0.00566 (DiD: 0.02175)
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Synthetic Britain is roughly one-fifth Hungary, one-fifth the United States, one-fifth Japan, one-sixth Canada and one-eighth Norway. That combination has no economic interpretation and is not supposed to have one — it is whatever reproduces the UK&amp;rsquo;s growth path. The pre-treatment fit error of &lt;strong>0.0057 log points&lt;/strong> is a quarter of what DiD managed, which is the entire argument for the method.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_06_sc_fit_gap.png" alt="Two panels: the UK and its synthetic control tracking each other from 1995 to 2016 and diverging afterwards, and the gap between them turning persistently negative after the referendum">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Twenty-one years of near-perfect tracking, then a gap that opens right at the referendum and keeps widening: &lt;strong>3.06%&lt;/strong> by 2018Q4 and &lt;strong>4.20%&lt;/strong> by 2019Q4. The flatness of the gap before 2016 is what licenses reading the gap after 2016 as an effect.&lt;/p>
&lt;h2 id="9-stage-3--demeaned-synthetic-control">9. Stage 3 — Demeaned synthetic control&lt;/h2>
&lt;h3 id="91-what-sc-gets-wrong">9.1 What SC gets wrong&lt;/h3>
&lt;p>Synthetic control has no intercept. Look again at what that means. Suppose some blend of donors moved in &lt;em>perfect&lt;/em> parallel with the UK for twenty-one years but sat consistently 0.5% below it. SC&amp;rsquo;s objective would score that blend badly and reject it, even though it is exactly what we want for forecasting a counterfactual — a series with the right dynamics and a known, constant offset.&lt;/p>
&lt;p>Ferman and Pinto [9] and Doudchenko and Imbens [8] both proposed the same fix: demean first, then add the offset back.&lt;/p>
&lt;p>$$\hat{\boldsymbol{\omega}}^{dsc} = \underset{\boldsymbol{\omega} \in \mathbb{W}}{\arg\min} \sum_{t=1}^{T_0-1} \left( (y_{1,t} - \bar{y}_{1}) - \sum_{j=2}^{J+1} \omega_j\, (y_{j,t} - \bar{y}_{j}) \right)^{2}$$&lt;/p>
&lt;p>In words, the same problem as before, but every country&amp;rsquo;s own pre-treatment average is stripped out first, so the fit is judged on shape rather than on level. In code, &lt;code>Z0_dm &amp;lt;- sweep(Z0, 2, colMeans(Z0))&lt;/code> and &lt;code>Z1_dm &amp;lt;- Z1 - mean(Z1)&lt;/code>.&lt;/p>
&lt;p>The offset comes back as a constant:&lt;/p>
&lt;p>$$b^{dsc} = \frac{1}{T_0-1} \sum_{t=1}^{T_0-1} \left( y_{1,t} - \sum_{j=2}^{J+1} \hat{\omega}^{dsc}_{j}\, y_{j,t} \right)$$&lt;/p>
&lt;p>In words, the average distance over the 86 pre-referendum quarters between the UK and its blend. In code, &lt;code>b_dsc &amp;lt;- mean(Z1 - Z0 %*% w_dsc)&lt;/code>.&lt;/p>
&lt;p>$$\hat{\tau}^{dsc}_{t} = y_{1,t} - \sum_{j=2}^{J+1} \hat{\omega}^{dsc}_{j}\, y_{j,t} - b^{dsc}$$&lt;/p>
&lt;p>In words, the raw gap minus the offset we already knew about from before the referendum.&lt;/p>
&lt;p>Adding $b^{dsc}$ is algebraically identical to putting the unit fixed effect $\alpha_j$ back into the master regression of section 6. And that gives us a tidy result: &lt;strong>DiD is DSC with the weights frozen at $1/J$.&lt;/strong> The ground floor and the third stage are the same estimator with different $\omega$.&lt;/p>
&lt;h3 id="92-two-changed-lines">9.2 Two changed lines&lt;/h3>
&lt;pre>&lt;code class="language-r">Z0_dm &amp;lt;- sweep(Z0, 2, colMeans(Z0)) # &amp;lt;-- the only change, part 1
Z1_dm &amp;lt;- Z1 - mean(Z1) # &amp;lt;-- the only change, part 2
w_dsc_qp &amp;lt;- simplex_ls(Z0_dm, Z1_dm) # identical call to stage 2
# The package: one argument flips.
sd_dsc &amp;lt;- synthdid_estimate(Y_sd[, 1:87], N0 = 23, T0 = 86,
zeta.omega = 0, zeta.lambda = 0,
omega.intercept = TRUE, lambda.intercept = TRUE)
w_dsc &amp;lt;- as.numeric(attr(sd_dsc, &amp;quot;weights&amp;quot;)$omega)
b_dsc &amp;lt;- mean(Z1 - Z0 %*% w_dsc)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> bias adjustment b_dsc : +0.00242 log points (0.242% of GDP)
|QP - package| max weight diff : 7.523e-03
correlation of SC and DSC weights: 0.9928
DSC 2018Q4 2.985 2019Q4 4.121
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_sc_dsc_sdid_08_dsc_offset.png" alt="The UK, its synthetic control and its demeaned synthetic control from 2010 onward, with the constant bias adjustment annotated">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> DSC is SC&amp;rsquo;s curve slid up by a single number, and here that number is &lt;strong>+0.0024 log points&lt;/strong> — about a quarter of one percent. The estimate moves from 3.06% to &lt;strong>2.99%&lt;/strong>. This is an anticlimax, and the anticlimax is the finding: a small bias adjustment means the synthetic control was &lt;em>already&lt;/em> level-balanced, so SC was not sacrificing shape to chase level. On a dataset where the treated unit sits awkwardly relative to the donor pool, this term would be doing real work. Notice also that the two weight vectors correlate at &lt;strong>0.993&lt;/strong> — demeaning barely changed who gets picked, only how the result is read off.&lt;/p>
&lt;h2 id="10-stage-4--synthetic-difference-in-differences">10. Stage 4 — Synthetic difference-in-differences&lt;/h2>
&lt;h3 id="101-what-dsc-gets-wrong">10.1 What DSC gets wrong&lt;/h3>
&lt;p>DSC&amp;rsquo;s bias adjustment is a &lt;em>flat&lt;/em> average over all 86 pre-treatment quarters. It gives 1995Q1 exactly as much say as 2016Q2 in deciding how far apart the UK and its blend sit. But 1995 resembles 2016 hardly at all, and if the gap between the UK and its blend has been drifting, a flat average is the wrong correction.&lt;/p>
&lt;p>Arkhangelsky and coauthors [10] let the data choose which quarters to trust.&lt;/p>
&lt;h3 id="102-the-time-weight-problem-is-the-unit-weight-problem-transposed">10.2 The time-weight problem is the unit-weight problem, transposed&lt;/h3>
&lt;p>This is the key structural insight of the whole post, so it gets its own sentence: &lt;strong>the time-weight problem is the unit-weight problem run on the transpose.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>$\omega$ asks: which &lt;em>countries&lt;/em>, blended, reproduce the UK&amp;rsquo;s pre-treatment path?&lt;/li>
&lt;li>$\lambda$ asks: which &lt;em>quarters&lt;/em>, blended, reproduce the treatment quarter, judged across all the donors?&lt;/li>
&lt;/ul>
&lt;p>$$\hat{\boldsymbol{\lambda}}^{sdid} = \underset{\boldsymbol{\lambda} \in \mathbb{L}}{\arg\min} \sum_{j=2}^{J+1} \left( (y_{j,T_0} - \bar{y}_{T_0}) - \sum_{t=1}^{T_0-1} \lambda_t\, (y_{j,t} - \bar{y}_{t}) \right)^{2}$$&lt;/p>
&lt;p>In words, find the blend of pre-referendum quarters that best predicts the treatment quarter, scored across all 23 donors, after removing the cross-country average at each date. In code, this is &lt;code>simplex_ls(t(Z0_centred), y_treatment_quarter_centred)&lt;/code> — the same function, with a transposed argument.&lt;/p>
&lt;p>$$\mathbb{L} = \left\{ \boldsymbol{\lambda} \in \mathbb{R}^{T_0-1} : \lambda_t \geq 0 \, \text{for all } t, \, \sum_{t=1}^{T_0-1} \lambda_t = 1 \right\}$$&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>A trap worth naming.&lt;/strong> The word &amp;ldquo;demeaned&amp;rdquo; means two &lt;em>different&lt;/em> things in stage three and stage four. DSC removes each &lt;strong>country&amp;rsquo;s&lt;/strong> own time-series mean: &lt;code>sweep(Z0, 2, colMeans(Z0))&lt;/code>. The SDID time-weight problem removes each &lt;strong>quarter&amp;rsquo;s&lt;/strong> cross-sectional mean: &lt;code>sweep(Z0, 1, rowMeans(Z0))&lt;/code>. Same verb, orthogonal operations. If you take one thing away from this section, take that.&lt;/p>
&lt;/blockquote>
&lt;p>The bias adjustment then becomes a weighted average instead of a flat one:&lt;/p>
&lt;p>$$b^{sdid} = \sum_{t=1}^{T_0-1} \hat{\lambda}^{sdid}_{t} \left( y_{1,t} - \sum_{j=2}^{J+1} \hat{\omega}^{dsc}_{j}\, y_{j,t} \right)$$&lt;/p>
&lt;p>Compare that with $b^{dsc}$ two sections above. They are the same expression with $1/(T_0-1)$ replaced by $\hat{\lambda}_t$. &lt;strong>That is the entire difference between stages three and four.&lt;/strong> SDID does not even estimate its own unit weights — it reuses DSC&amp;rsquo;s.&lt;/p>
&lt;pre>&lt;code class="language-r"># A has donors as ROWS and quarters as COLUMNS -- the transpose of the omega
# problem. `intercept = TRUE` demeans each quarter across donors, rather than
# each country across quarters as stage 3 did.
#
# We use the Frank-Wolfe port here rather than simplex_ls, so that the result is
# comparable with the package to the last bit. The exact QP gives the same
# answer to six decimals and an identical treatment effect.
lambda &amp;lt;- simplex_fw(t(Z0), Y[87, DONORS], intercept = TRUE,
min.decrease = 1e-5 * sd(apply(t(Z0), 1, diff)))
b_dsc &amp;lt;- mean(Z1 - Z0 %*% w_dsc) # stage 3: flat average
b_sdid &amp;lt;- drop(lambda %*% (Z1 - Z0 %*% w_dsc)) # stage 4: weighted average
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> |hand-coded lambda - synthdid lambda| : 0.000e+00
lambda(i): 3 nonzero weights; 0.958 on the last pre-period (2016Q2)
2016Q2 0.9585
2008Q4 0.0386
2014Q3 0.0029
DSC bias adjustment +0.00242 vs SDID(i) bias adjustment +0.00015
SDID (i) 2018Q4 2.758 2019Q4 3.894
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Our hand-coded time weights match &lt;code>synthdid&lt;/code>&amp;rsquo;s to zero — not to within a tolerance, exactly. And the answer is startling: &lt;strong>96% of the weight lands on a single quarter, 2016Q2&lt;/strong>, with a small 3.9% on 2008Q4, the trough of the financial crisis. Because the weighted average of pre-treatment gaps is so different from the flat one ($+0.00015$ against $+0.00242$), the estimate drops from 2.99% to &lt;strong>2.76%&lt;/strong>.&lt;/p>
&lt;h3 id="103-where-did-and-two-way-fixed-effects-live-inside-sdid">10.3 Where DiD and two-way fixed effects live inside SDID&lt;/h3>
&lt;p>$$\lambda^{did}_t = \mathbf{1}\{ t = T_0 - 1 \}, \qquad \lambda^{twfe}_t = \frac{1}{T_0 - 1}$$&lt;/p>
&lt;p>In words, if all the time weight lands on the last pre-treatment quarter you have a difference-in-differences correction; if it is spread evenly you have the two-way fixed-effects correction that DSC uses. SDID is supposed to let the data pick a point &lt;em>between&lt;/em> those two extremes.&lt;/p>
&lt;p>Here it picks one of the extremes. That deserves an explanation, not a shrug.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_09_lambda_weights.png" alt="Two panels: the estimated time weights as a stem plot with almost all mass on the final quarter, and a scatter of donor log GDP at quarter t against quarter t minus one lying almost exactly on the 45-degree line">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Log GDP is very close to a random walk — the scatter of quarter $t$ against quarter $t-1$ sits almost exactly on the 45-degree line. If the best predictor of next quarter is simply this quarter, then the best &lt;em>weighted blend&lt;/em> of past quarters for predicting the treatment quarter is the most recent quarter alone. The collapse is a property of the data, not a bug in the code. The source paper notes this too, and declines to switch to differenced data on the grounds that matching levels and trends is the entire point of a synthetic control. Exercise 4 asks you to try it anyway.&lt;/p>
&lt;h3 id="104-three-flavours-of-sdid">10.4 Three flavours of SDID&lt;/h3>
&lt;p>The time weights have to be fitted against &lt;em>something&lt;/em> in the post-treatment period, and there are three natural choices:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Variant&lt;/th>
&lt;th>$\lambda$ is fitted to predict&lt;/th>
&lt;th>Evaluated at&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>(i)&lt;/td>
&lt;td>the first treated quarter, 2016Q3&lt;/td>
&lt;td>any horizon&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>(ii)&lt;/td>
&lt;td>the average of all quarters from 2016Q3 to the evaluation date&lt;/td>
&lt;td>that date&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>(iii)&lt;/td>
&lt;td>the evaluation quarter alone&lt;/td>
&lt;td>that date&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;pre>&lt;code class="language-text"> SDID (i) 2018Q4 2.758 2019Q4 3.894
SDID (ii) 2018Q4 2.787 2019Q4 3.923
SDID (iii) 2018Q4 2.787 2019Q4 3.923
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The three variants land within &lt;strong>0.03 percentage points&lt;/strong> of each other, because all three put essentially all their time weight on the same last pre-treatment quarter. Section 15 asks whether the placebo evidence can tell them apart — and finds that the published answer to that question does not survive scrutiny.&lt;/p>
&lt;h2 id="11-the-pivot-extrapolation-bias-and-interpolation-bias">11. The pivot: extrapolation bias and interpolation bias&lt;/h2>
&lt;p>We now have four stages and no principled reason to prefer any of them. This section supplies one, and it is the theoretical contribution of the source paper.&lt;/p>
&lt;h3 id="111-a-warning-about-the-names">11.1 A warning about the names&lt;/h3>
&lt;p>Neither term means what you would guess.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Extrapolation bias&lt;/strong> is &lt;em>not&lt;/em> about predicting outside the range of the data in time. It is about the blend having the wrong &lt;em>characteristics&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Interpolation bias&lt;/strong> is &lt;em>not&lt;/em> about filling in missing quarters. It is about the response function being &lt;em>curved&lt;/em>.&lt;/li>
&lt;/ul>
&lt;p>Hold those corrected definitions in mind, because the formal decomposition is short and it will go past quickly.&lt;/p>
&lt;h3 id="112-the-decomposition">11.2 The decomposition&lt;/h3>
&lt;p>Write each country&amp;rsquo;s untreated outcome as a function of its characteristics, and write a generic weighted counterfactual:&lt;/p>
&lt;p>$$\hat{y}^{0}_{1,T_0} = \sum_{j=2}^{J+1} w_j\, y^{0}_{j,T_0}\left[ \boldsymbol{x}_{j,T_0} \right]$$&lt;/p>
&lt;p>In words, every estimator in this post is a choice of the weights $w_j$ in this one expression; the square brackets are a device for tracking &lt;em>where&lt;/em> the response function is being evaluated.&lt;/p>
&lt;p>The total bias is what we want and what we get:&lt;/p>
&lt;p>$$\text{Bias}_{1,T_0} = y^{0}_{1,T_0}\left[ \boldsymbol{x}_{1,T_0} \right] - \sum_{j=2}^{J+1} w_j\, y^{0}_{j,T_0}\left[ \boldsymbol{x}_{j,T_0} \right]$$&lt;/p>
&lt;p>Insert a middle term — the response function evaluated at the &lt;em>blend&amp;rsquo;s&lt;/em> characteristics — and it splits in two. First piece:&lt;/p>
&lt;p>$$B^{ext} = y^{0}_{1,T_0}\left[ \boldsymbol{x}_{1,T_0} \right] - y^{0}_{1,T_0}\left[ \sum_{j=2}^{J+1} w_j\, \boldsymbol{x}_{j,T_0} \right]$$&lt;/p>
&lt;p>In words, the same function evaluated at two different places: the UK&amp;rsquo;s true characteristics, and the blend&amp;rsquo;s characteristics. If the blend matches the UK exactly, this term is zero.&lt;/p>
&lt;p>Second piece:&lt;/p>
&lt;p>$$B^{int} = y^{0}_{1,T_0}\left[ \sum_{j=2}^{J+1} w_j\, \boldsymbol{x}_{j,T_0} \right] - \sum_{j=2}^{J+1} w_j\, y^{0}_{j,T_0}\left[ \boldsymbol{x}_{j,T_0} \right]$$&lt;/p>
&lt;p>In words, the outcome &lt;em>at&lt;/em> the averaged characteristics minus the average &lt;em>of&lt;/em> the outcomes. These coincide only if the function is a straight line, so this term is pure curvature.&lt;/p>
&lt;p>$$\text{Bias}_{1,T_0} = B^{ext} + B^{int}$$&lt;/p>
&lt;p>The middle term cancels, so the two pieces add up exactly. That is what makes it legitimate to ask, of any estimator, which of the two it is attacking.&lt;/p>
&lt;h3 id="113-a-two-donor-picture">11.3 A two-donor picture&lt;/h3>
&lt;p>With two donors and one characteristic, there are two obvious weighting rules and each kills exactly one term. Linear-interpolation weights place the blend&amp;rsquo;s characteristic exactly on the treated unit&amp;rsquo;s:&lt;/p>
&lt;p>$$w^{li}_{3} = \frac{x_{1} - x_{2}}{x_{3} - x_{2}}, \qquad w^{li}_{2} = 1 - w^{li}_{3}, \qquad w^{nn}_{2} = 1, \qquad w^{nn}_{3} = 0$$&lt;/p>
&lt;p>In words, one recipe puts the blend at the right place on the horizontal axis and reads off the chord; the other copies the nearest donor outright. Neither can do both.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_10_bias_toy.png" alt="Two panels showing a curved response function with two donors and one treated unit; the left panel annotates the interpolation bias as the gap between the chord and the curve, the right panel annotates the extrapolation bias as the gap from copying the nearest donor">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> On the left, the blend&amp;rsquo;s characteristic is exactly right, so there is no extrapolation bias — but the chord between the two donor outcomes sits below the curve, and that vertical distance is the interpolation bias. On the right, we copy the nearest donor, so there is no interpolation bias — but we are reading the curve at the wrong place. &lt;strong>Two errors, two weighting rules, one killed each time.&lt;/strong> The question the ladder has been building toward is whether anything can kill both.&lt;/p>
&lt;h3 id="114-where-each-estimator-sits">11.4 Where each estimator sits&lt;/h3>
&lt;p>&lt;img src="r_sc_dsc_sdid_11_bias_targets.png" alt="A tile chart with estimators as rows and bias types as columns, showing which component each method targets; only the SDID row is dark in both the extrapolation and interpolation columns">&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
T[&amp;quot;&amp;lt;b&amp;gt;Total bias&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;true UK outcome minus&amp;lt;br/&amp;gt;weighted donor outcome&amp;quot;] --&amp;gt; X[&amp;quot;&amp;lt;b&amp;gt;Extrapolation bias&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;same function,&amp;lt;br/&amp;gt;WRONG PLACE&amp;quot;]
T --&amp;gt; I[&amp;quot;&amp;lt;b&amp;gt;Interpolation bias&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;right place,&amp;lt;br/&amp;gt;CURVED FUNCTION&amp;quot;]
X --&amp;gt; XA[&amp;quot;&amp;lt;b&amp;gt;omega weights&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;minimise pre-treatment&amp;lt;br/&amp;gt;prediction error&amp;quot;]
I --&amp;gt; IA[&amp;quot;&amp;lt;b&amp;gt;lambda weights&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;find pre-periods that&amp;lt;br/&amp;gt;resemble the treatment period&amp;quot;]
XA --&amp;gt; SC2[&amp;quot;SC: yes&amp;quot;]
XA --&amp;gt; NN2[&amp;quot;Matching: no&amp;quot;]
IA --&amp;gt; SC3[&amp;quot;SC: only if y is linear in x&amp;quot;]
IA --&amp;gt; NN3[&amp;quot;Matching: yes, by construction&amp;quot;]
XA --&amp;gt; SD[&amp;quot;&amp;lt;b&amp;gt;SDID: yes&amp;lt;/b&amp;gt;&amp;quot;]
IA --&amp;gt; SD
style T fill:#141413,stroke:#00d4c8,color:#fff
style X fill:#6a9bcc,stroke:#141413,color:#fff
style I fill:#d97757,stroke:#141413,color:#fff
style XA fill:#6a9bcc,stroke:#141413,color:#fff
style IA fill:#d97757,stroke:#141413,color:#fff
style SC2 fill:#f5f5f5,stroke:#141413,color:#141413
style NN2 fill:#f5f5f5,stroke:#141413,color:#141413
style SC3 fill:#f5f5f5,stroke:#141413,color:#141413
style NN3 fill:#f5f5f5,stroke:#141413,color:#141413
style SD fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>Only one node has two arrows pointing into it. Synthetic control minimises extrapolation bias by construction, because it is the argmin of the pre-treatment fit. Matching minimises interpolation bias by construction, because it never blends. &lt;strong>SDID&amp;rsquo;s unit weights do the first job and its time weights do the second&lt;/strong>, which is the source paper&amp;rsquo;s headline claim and the reason it recommends the method.&lt;/p>
&lt;h3 id="115-the-honest-caveat">11.5 The honest caveat&lt;/h3>
&lt;p>Do not over-learn this. The decomposition assumes a common response function across countries and sets the idiosyncratic error aside entirely. SDID &amp;ldquo;targets&amp;rdquo; both biases, but it pays for the privilege by estimating 85 extra parameters, and the source paper&amp;rsquo;s own conclusion is that the gain over DSC is &amp;ldquo;marginal at best&amp;rdquo; for the kind of trend specification we have here. Section 20 returns to this.&lt;/p>
&lt;h2 id="12-stage-5--masc">12. Stage 5 — MASC&lt;/h2>
&lt;h3 id="121-buying-the-trade-off-explicitly">12.1 Buying the trade-off explicitly&lt;/h3>
&lt;p>If SC kills one bias and matching kills the other, why not buy some of each? Kellogg, Mogstad, Pouliot and Torgovitsky [11] do exactly that:&lt;/p>
&lt;p>$$\hat{\boldsymbol{\omega}}^{masc}(m, \phi) = \phi\, \hat{\boldsymbol{\omega}}^{ma}(m) + (1 - \phi)\, \hat{\boldsymbol{\omega}}^{sc}$$&lt;/p>
&lt;p>In words, a dial between pure matching and pure synthetic control, with the dial position chosen by out-of-sample forecast error rather than by taste. In code, &lt;code>phi * nn_weights(Z0, Z1, m) + (1 - phi) * w_sc&lt;/code>.&lt;/p>
&lt;p>The matching weights themselves are the solution to a linear program:&lt;/p>
&lt;p>$$\hat{\boldsymbol{\omega}}^{ma}(m) = \underset{\boldsymbol{\omega} \in \mathbb{S}}{\arg\min} \sum_{j=2}^{J+1} \omega_j \lVert \boldsymbol{y}_j - \boldsymbol{y}_1 \rVert, \qquad \mathbb{S} = \left\{ \boldsymbol{\omega} : 0 \leq \omega_j \leq \tfrac{1}{m}, \, \sum_j \omega_j = 1 \right\}$$&lt;/p>
&lt;p>In words, capping every weight at $1/m$ and minimising total distance forces the solution to spread $1/m$ across exactly the $m$ closest donors. Matching, written as an optimisation problem.&lt;/p>
&lt;h3 id="122-the-cross-validation-written-out">12.2 The cross-validation, written out&lt;/h3>
&lt;pre>&lt;code class="language-r">nn_weights &amp;lt;- function(Z0, Z1, m) {
d &amp;lt;- colSums((Z1 - Z0)^2)
sel &amp;lt;- d %in% sort(d)[1:m]
as.numeric(sel) / sum(sel)
}
# Rolling origin: for each stopping point k, refit BOTH estimators on quarters
# 1..k, forecast quarter k+1, and score. phi then has a closed form.
set_f &amp;lt;- 6:85
wt &amp;lt;- rep(1 / length(set_f), length(set_f))
ysc &amp;lt;- vapply(set_f, function(k) drop(Z0[k+1, ] %*% simplex_ls(Z0[1:k, ], Z1[1:k])), 0)
ytr &amp;lt;- Z1[set_f + 1]
cv &amp;lt;- do.call(rbind, lapply(1:10, function(m) {
ymt &amp;lt;- vapply(set_f, function(k) drop(Z0[k+1, ] %*% nn_weights(Z0[1:k, ], Z1[1:k], m)), 0)
phi &amp;lt;- min(1, max(0, drop((wt*(ytr-ysc)) %*% (ymt-ysc) / ((wt*(ymt-ysc)) %*% (ymt-ysc)))))
data.frame(m = m, phi = phi, cv = sum(wt * (ytr - phi*ymt - (1-phi)*ysc)^2))
}))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> hand-coded : m = 10, phi = 0.1577
masc package: m = 10, phi = 0.1577 |hand - package| = 0.000e+00
MASC 2018Q4 2.726 2019Q4 3.828 m = 10, phi = 0.158
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_sc_dsc_sdid_12_masc_cv.png" alt="Rolling-origin cross-validation error by number of matched neighbours, with the winning value of m highlighted and the selected phi printed above each bar">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The data buy &lt;strong>15.8% matching and 84.2% synthetic control&lt;/strong>, using the ten nearest donors. The estimate, &lt;strong>2.73%&lt;/strong>, is the lowest on the ladder. Note that $\phi$ is not a taste parameter: it is the minimiser of a forecast error computed entirely from pre-treatment data, and at $\phi = 0$ MASC collapses to plain SC, so on this criterion it can never do worse.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>A trap in the package.&lt;/strong> The published replication code passes &lt;code>masc&lt;/code>&amp;rsquo;s &lt;code>min_preperiods&lt;/code> argument, but current package master reads that value as the fold &lt;em>start&lt;/em>, producing only five folds. Five folds select $\phi = 1$ — pure matching — and an estimate of 2.36%, which does not match the published result. The authors&amp;rsquo; numbers correspond to folds running from 6 to $T_0 - 1$, so we set &lt;code>set_f&lt;/code> explicitly. Silent, plausible-looking, and wrong by a third of a percentage point.&lt;/p>
&lt;/blockquote>
&lt;h2 id="13-stage-6--augmented-synthetic-control">13. Stage 6 — Augmented synthetic control&lt;/h2>
&lt;h3 id="131-what-every-stage-so-far-assumes">13.1 What every stage so far assumes&lt;/h3>
&lt;p>All six previous stages require the UK to lie inside the donors&amp;rsquo; convex hull — inside the rubber band of section 8.2. When it does not, the pre-treatment fit is imperfect and Abadie&amp;rsquo;s own advice is to stop. Ben-Michael, Feller and Rothstein [12] instead de-bias:&lt;/p>
&lt;p>$$\hat{\boldsymbol{\omega}}^{ascm} = \underset{\boldsymbol{\omega} : \sum_j \omega_j = 1}{\arg\min} \, \frac{1}{2 \lambda^{ridge}} \sum_{t=1}^{T_0-1} \left( y_{1,t} - \sum_{j=2}^{J+1} \omega_j\, y_{j,t} \right)^{2} + \frac{1}{2} \sum_{j=2}^{J+1} \left( \omega_j - \hat{\omega}^{sc}_{j} \right)^{2}$$&lt;/p>
&lt;p>In words, keep improving the pre-treatment fit, but pay a quadratic price for every step away from the ordinary synthetic-control weights. Non-negativity is gone; only the sum-to-one constraint survives. In code, $\lambda^{ridge}$ is chosen by leave-one-period-out cross-validation inside &lt;code>augsynth&lt;/code>.&lt;/p>
&lt;p>The estimator has a closed form, so we can write it out: take the SC weights and add a ridge-predicted correction for whatever pre-treatment imbalance they left behind.&lt;/p>
&lt;pre>&lt;code class="language-r">ascm_hand &amp;lt;- function(lambda_ridge) {
Xc &amp;lt;- sweep(t(Z0), 2, colMeans(t(Z0))) # donors x quarters, period-centred
x1c &amp;lt;- Z1 - colMeans(t(Z0)) # the treated path, same centring
# SC weights, plus a ridge regression of the residual imbalance on the donors
as.numeric(w_sc_qp + solve(tcrossprod(Xc) + lambda_ridge * diag(23),
Xc %*% (x1c - crossprod(Xc, w_sc_qp))))
}
&lt;/code>&lt;/pre>
&lt;p>One thing we cannot supply by hand is $\lambda^{ridge}$ itself — it is chosen by cross-validation, and inventing a value would be cheating. Fit the package, read its choice back out, and feed it in:&lt;/p>
&lt;pre>&lt;code class="language-r">ad &amp;lt;- data.frame(unitnum = rep(1:24, each = 87), t = rep(1:87, 24),
value = as.vector(Y[1:87, c(UK, DONORS)]))
ad$treatment &amp;lt;- as.integer(ad$unitnum == 1 &amp;amp; ad$t == 87)
ascm &amp;lt;- augsynth(value ~ treatment, unitnum, t, ad, progfunc = &amp;quot;Ridge&amp;quot;, scm = TRUE)
w_ascm_hand &amp;lt;- ascm_hand(ascm$lambda) # ascm$lambda is augsynth's CV choice
max(abs(w_ascm_hand - as.numeric(ascm$weights)))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> package : sum(w) = 1.0000, min(w) = -0.0090, 8 negative weights
hand : ridge lambda = 0.13858 (chosen by augsynth's own CV)
loss(2018Q4) = 3.045 |hand - package| max weight diff = 3.881e-06
negative weights (impossible under the simplex):
country omega
Switzerland -0.0090
Slovak Republic -0.0085
Belgium -0.0066
Spain -0.0056
Sweden -0.0030
Korea -0.0027
Austria -0.0013
Netherlands -0.0012
ASCM 2018Q4 3.045 2019Q4 4.187
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Eight donors get negative weight — the synthetic UK now &lt;em>subtracts&lt;/em> a little Switzerland and a little Belgium, which is flatly impossible under the simplex. And the estimate, &lt;strong>3.04%&lt;/strong>, is almost exactly plain SC&amp;rsquo;s 3.06%. That is the expected result when the pre-treatment fit was already excellent: the ridge correction has almost nothing to fix, so it barely moves. ASCM earns its keep on datasets where SC visibly fails, which this is not. For a case where it does matter, see &lt;a href="https://carlos-mendez.org/post/r_augsynth/">the Kansas tax-cut tutorial&lt;/a>.&lt;/p>
&lt;h2 id="14-the-whole-ladder-side-by-side">14. The whole ladder, side by side&lt;/h2>
&lt;p>Everything is now in place. First the units. Because the outcome is in logs, the counterfactual-minus-actual difference is in log points:&lt;/p>
&lt;p>$$L_t = 100 \times \left( \hat{y}^{0}_{1,t} - y_{1,t} \right)$$&lt;/p>
&lt;p>In words, multiply the log gap by 100 to get an approximate percentage shortfall, positive when the UK underperformed its counterfactual. In code, the script stores &lt;code>tau = y1 - yhat0&lt;/code>, so the reported loss is &lt;code>-100 * tau&lt;/code>. At these magnitudes the log approximation is good to two decimal places.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>2018Q4&lt;/th>
&lt;th>2019Q4&lt;/th>
&lt;th>Published&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DiD&lt;/td>
&lt;td>4.98&lt;/td>
&lt;td>6.18&lt;/td>
&lt;td>—&lt;/td>
&lt;td>not in the paper; pre-trends fail&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>3.06&lt;/td>
&lt;td>4.20&lt;/td>
&lt;td>3.06 / 4.20&lt;/td>
&lt;td>Frank–Wolfe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SC (exact QP)&lt;/td>
&lt;td>3.04&lt;/td>
&lt;td>4.17&lt;/td>
&lt;td>—&lt;/td>
&lt;td>the true optimum&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>2.99&lt;/td>
&lt;td>4.12&lt;/td>
&lt;td>2.98 / 4.12&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (i)&lt;/td>
&lt;td>2.76&lt;/td>
&lt;td>3.89&lt;/td>
&lt;td>2.76 / 3.89&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (ii)&lt;/td>
&lt;td>2.79&lt;/td>
&lt;td>3.92&lt;/td>
&lt;td>2.79 / 3.92&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (iii)&lt;/td>
&lt;td>2.79&lt;/td>
&lt;td>3.92&lt;/td>
&lt;td>2.79 / 3.92&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>2.73&lt;/td>
&lt;td>3.83&lt;/td>
&lt;td>2.73 / 3.83&lt;/td>
&lt;td>$m = 10$, $\phi = 0.158$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>3.04&lt;/td>
&lt;td>4.19&lt;/td>
&lt;td>3.04 / 4.19&lt;/td>
&lt;td>8 negative weights&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Born et al. (2019)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>2.40&lt;/strong>&lt;/td>
&lt;td>&lt;strong>3.60&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>with covariates&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;img src="r_sc_dsc_sdid_15_att_dotplot.png" alt="Dot plot of every estimator&amp;amp;rsquo;s 2018Q4 and 2019Q4 estimate with Born et al.&amp;amp;rsquo;s 2.4 per cent marked as a dashed reference line, all estimates lying to the right of it">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Every cell of that table reproduces the published one to within 0.01 percentage points. Three things stand out. First, the spread across methods at 2018Q4 is &lt;strong>2.73% to 3.06%&lt;/strong> — a range of a third of a percentage point, which is small next to the gap to Born et al.&amp;rsquo;s 2.40%. Second, the estimated damage &lt;strong>grows over time&lt;/strong>, from roughly 2.9% to roughly 4.1%, which looks like a change in the growth rate rather than a one-off level shift. Third, and this is the paper&amp;rsquo;s empirical punchline: &lt;strong>every stage of the ladder puts the cost of the referendum above the previously published figure&lt;/strong>, and the methods that adjust for level and timing put it lowest, not highest.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_14_all_counterfactuals.png" alt="Six counterfactual paths for the UK zoomed to 2014 through 2020, indistinguishable before the referendum and fanning apart afterwards">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Before the referendum the six lines are on top of each other — they are all fitting the same 86 quarters, and all fitting them well. The disagreement is entirely a post-treatment phenomenon, which is a useful reminder that pre-treatment fit cannot arbitrate between methods that all achieve it.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_13_donor_weights.png" alt="Grouped horizontal bar chart of donor weights for SC, DSC, SDID, MASC and ASCM, with negative ASCM weights shown in orange">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Four of the five recipes are nearly the same blend, dominated by Hungary, the United States, Japan, Canada and Norway. The SDID and DSC panels are &lt;em>identical&lt;/em> by construction, since SDID reuses DSC&amp;rsquo;s unit weights and changes only the bias adjustment. Only ASCM crosses zero.&lt;/p>
&lt;h2 id="15-which-stage-should-you-choose">15. Which stage should you choose?&lt;/h2>
&lt;h3 id="151-the-in-sample-placebo-tournament">15.1 The in-sample placebo tournament&lt;/h3>
&lt;p>Pre-treatment fit cannot choose between these methods, because they all fit. So the source paper does something better: it moves the treatment date back to a quarter when nothing happened, builds the counterfactual using only data up to that point, and compares it to what actually occurred. The true effect is zero, so every estimate is pure error.&lt;/p>
&lt;p>$$\text{RMSE} = \sqrt{ \frac{1}{20} \sum_{k=1}^{20} \left( y_{1, T&amp;rsquo;_k + h} - \hat{y}^{0}_{1, T&amp;rsquo;_k + h} \right)^{2} }$$&lt;/p>
&lt;p>In words, across twenty artificial treatment dates, how far off is the counterfactual from an outcome we can actually check. In code, the loop runs &lt;code>k&lt;/code> over the last pre-treatment quarters 2010Q1 to 2014Q4, always starting the window at 1995Q1, with &lt;code>h&lt;/code> the forecast horizon.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">flowchart LR
A[&amp;quot;&amp;lt;b&amp;gt;Pick a fake&amp;lt;br/&amp;gt;treatment date&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;2010Q1 ... 2014Q4&amp;lt;br/&amp;gt;20 of them&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;Fit on 1995Q1&amp;lt;br/&amp;gt;up to that date&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;all seven estimators&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;Predict h quarters&amp;lt;br/&amp;gt;ahead&amp;lt;/b&amp;gt;&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;Compare to what&amp;lt;br/&amp;gt;actually happened&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;true effect is zero&amp;quot;]
D --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Score&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;RMSE, mean and median&amp;lt;br/&amp;gt;absolute error&amp;quot;]
E --&amp;gt; A
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#6a9bcc,stroke:#141413,color:#fff
style C fill:#d97757,stroke:#141413,color:#fff
style D fill:#d97757,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;h3 id="152-the-published-table-reproduced">15.2 The published table, reproduced&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>RMSE&lt;/th>
&lt;th>MAB&lt;/th>
&lt;th>MedAB&lt;/th>
&lt;th>Published RMSE&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>0.0089&lt;/td>
&lt;td>0.0072&lt;/td>
&lt;td>0.0055&lt;/td>
&lt;td>0.0089&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>0.0087&lt;/td>
&lt;td>0.0070&lt;/td>
&lt;td>0.0052&lt;/td>
&lt;td>0.0087&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SDID (i)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0.0067&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0.0037&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0.0016&lt;/strong>&lt;/td>
&lt;td>0.0067&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>0.0080&lt;/td>
&lt;td>0.0062&lt;/td>
&lt;td>0.0045&lt;/td>
&lt;td>0.0080&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;td>0.0068&lt;/td>
&lt;td>0.0051&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (ii)&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;td>0.0111&lt;/td>
&lt;td>0.0103&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (iii)&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;td>0.0111&lt;/td>
&lt;td>0.0107&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Every number reproduces exactly. Read as published, the table says SDID (i) wins comfortably — its median absolute error of &lt;strong>0.0016&lt;/strong> is a third of plain synthetic control&amp;rsquo;s &lt;strong>0.0055&lt;/strong> — and that variants (ii) and (iii) are the worst of the lot, twice as bad as plain SC. That is the basis for the paper&amp;rsquo;s recommendation to use variant (i) in practice.&lt;/p>
&lt;h3 id="153-the-table-is-not-comparing-like-with-like">15.3 The table is not comparing like with like&lt;/h3>
&lt;p>Look closely at how those numbers are produced. In the replication code, SC, DSC, SDID (i), MASC and ASCM are all graded &lt;strong>one quarter ahead&lt;/strong>. SDID (ii) and (iii) are graded &lt;strong>four quarters ahead&lt;/strong>. Forecasting a year out is a strictly harder task than forecasting a quarter out, so part of the gap is the exam, not the student.&lt;/p>
&lt;p>Running every estimator at both horizons settles it:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>RMSE, $h = 1$&lt;/th>
&lt;th>RMSE, $h = 4$&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>0.0089&lt;/td>
&lt;td>0.0150&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>0.0087&lt;/td>
&lt;td>0.0149&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (i)&lt;/td>
&lt;td>0.0067&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (ii)&lt;/td>
&lt;td>&lt;strong>0.0066&lt;/strong>&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (iii)&lt;/td>
&lt;td>&lt;strong>0.0066&lt;/strong>&lt;/td>
&lt;td>0.0134&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>0.0080&lt;/td>
&lt;td>0.0140&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;td>0.0146&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;img src="r_sc_dsc_sdid_16_placebo_tournament.png" alt="Two panels of strip plots showing the twenty placebo errors for each estimator, graded one quarter ahead and four quarters ahead, with the root mean squared error marked as an orange diamond">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Graded on the same task, the three SDID variants are &lt;strong>indistinguishable&lt;/strong> — 0.0067, 0.0066, 0.0066 at one quarter, and 0.0134 for all three at four quarters. The published conclusion that variants (ii) and (iii) &amp;ldquo;perform the worst&amp;rdquo; is an artefact of the horizon, not a property of the estimators.&lt;/p>
&lt;p>What survives is the finding that matters more: &lt;strong>at either horizon, the whole SDID family beats every other stage&lt;/strong>, and the ordering below it is stable — SDID, then MASC, then ASCM, then DSC, then SC. The time weights are doing real work. Which variant supplies them is not settled by this evidence, and variant (i) remains the sensible default simply because it is the cheapest and requires no choice of post-treatment window.&lt;/p>
&lt;h2 id="16-do-covariates-help">16. Do covariates help?&lt;/h2>
&lt;p>The Brexit dataset ships with six covariates, and Born and coauthors matched on them. The source paper&amp;rsquo;s most pointed conclusion is that you should not.&lt;/p>
&lt;p>Matching on covariates requires Abadie&amp;rsquo;s nested optimisation: an inner problem that picks weights given a predictor-importance matrix $\boldsymbol{V}$, and an outer problem that picks $\boldsymbol{V}$.&lt;/p>
&lt;p>$$\hat{\boldsymbol{\omega}}(\boldsymbol{V}) = \underset{\boldsymbol{\omega} \in \mathbb{W}}{\arg\min} \, (\boldsymbol{z}_1 - \boldsymbol{Z}\boldsymbol{\omega})&amp;rsquo; \boldsymbol{V} (\boldsymbol{z}_1 - \boldsymbol{Z}\boldsymbol{\omega})$$&lt;/p>
&lt;p>In words, for a fixed opinion about how important each predictor is, pick the blend that best balances the predictors. In code, &lt;code>Synth::synth(X1 = ..., X0 = ...)&lt;/code>.&lt;/p>
&lt;p>$$\hat{\boldsymbol{V}}^{sc} = \underset{\boldsymbol{V} \in \mathbb{V}}{\arg\min} \sum_{t=1}^{T_0-1} \left( y_{1,t} - \sum_{j=2}^{J+1} \hat{\omega}_j(\boldsymbol{V})\, y_{j,t} \right)^{2}$$&lt;/p>
&lt;p>In words, choose the predictor importances that make the resulting blend track the UK&amp;rsquo;s &lt;em>outcome&lt;/em> best. The variant used by Born and coauthors — call it SC(B) — replaces the outcome in this outer objective with the full stacked predictor vector, so covariates are scored in &lt;strong>both&lt;/strong> loops:&lt;/p>
&lt;pre>&lt;code class="language-r"># SC with covariates: outer loop scores the OUTCOME
synth(X1 = X1, X0 = X0, Z1 = as.matrix(Z1), Z0 = Z0)
# SC(B): outer loop scores the stacked PREDICTORS
synth(X1 = X1, X0 = X0, Z1 = X1, Z0 = X0)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-r">X1 &amp;lt;- as.matrix(c(Z1, cov_means[UK, ])) # 86 outcomes + 6 covariate means
X0 &amp;lt;- rbind(Z0, t(cov_means[DONORS, ]))
s_born &amp;lt;- synth(X1 = X1, X0 = X0, Z1 = X1, Z0 = X0) # SC(B)
s_sc &amp;lt;- synth(X1 = X1, X0 = X0, Z1 = as.matrix(Z1), Z0 = Z0) # SC cov.
s_dsc &amp;lt;- synth(X1 = X1d, X0 = X0d, Z1 = as.matrix(Z1_dm), Z0 = Z0_dm) # DSC cov.
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> method loss_2018Q4 loss_2019Q4 published
SC(B) 2.428 3.606 2.43
SC cov. 3.028 4.170 3.11
DSC cov. 2.942 4.050 2.90
SDID cov. (i) 2.731 3.839 2.75
SC no cov. 3.056 4.204 3.06
DSC no cov. 2.985 4.121 2.98
SDID no cov. (i) 2.758 3.894 2.76
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The first row is the headline. &lt;strong>SC(B) with mean covariates gives 2.43% at 2018Q4 and 3.61% at 2019Q4&lt;/strong> — that is Born et al.&amp;rsquo;s 2.4% and 3.6%, reproduced to the second decimal. So the gap between the earlier published figure and everything else in this post is not a data difference or a coding difference. It is entirely the choice of estimator, and specifically the choice to score covariates in both optimisation loops.&lt;/p>
&lt;p>Note also that the four covariate rows sit &lt;em>below&lt;/em> their no-covariate counterparts in every case, and that our &lt;code>Synth&lt;/code>-based numbers drift from the published ones by up to 0.08 percentage points. That drift is honest and expected: the outer optimisation over the predictor-importance matrix is not convex, &lt;code>Synth&lt;/code> runs a derivative-free search over 92 dimensions, and different starting values land in different local optima. When a specification&amp;rsquo;s answer depends on where the optimiser started, that is information about the specification.&lt;/p>
&lt;p>But the decisive evidence is not in this table at all — it is in the placebo tournament. Rerunning section 15 with covariates in the matching set makes almost every estimator &lt;em>worse&lt;/em>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>RMSE without covariates&lt;/th>
&lt;th>RMSE with mean covariates&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>0.0089&lt;/td>
&lt;td>0.0092&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>0.0087&lt;/td>
&lt;td>0.0106&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (i)&lt;/td>
&lt;td>0.0067&lt;/td>
&lt;td>0.0063&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>0.0080&lt;/td>
&lt;td>0.0048&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>0.0086&lt;/td>
&lt;td>0.0083&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;em>(the covariate column is Table 8 of the source paper; our no-covariate column reproduces its Table 7 exactly)&lt;/em>&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Adding six covariates degrades SC and DSC and barely helps SDID and ASCM. Only MASC clearly benefits. The source paper&amp;rsquo;s conclusion is blunt, and our replication supports it: if the object of interest is the GDP series, do not add covariates. The reason is not mysterious. Kaul and coauthors [13] showed that once &lt;em>all&lt;/em> pre-treatment outcomes are in the matching set, covariates are redundant — the outcomes already encode whatever the covariates would have told you. With 86 pre-treatment outcomes in play, the six extra predictors add estimation noise and nothing else.&lt;/p>
&lt;h2 id="17-robustness-the-specification-zoo">17. Robustness: the specification zoo&lt;/h2>
&lt;p>Four departures from the headline specification, each a short table and a single lesson.&lt;/p>
&lt;p>&lt;strong>(a) The other treatment date.&lt;/strong> The referendum fell at the very end of 2016Q2, so dating the treatment there rather than at 2016Q3 is entirely defensible.&lt;/p>
&lt;pre>&lt;code class="language-text"> method 2016Q2_2018Q4 2016Q3_2018Q4
SC 3.123 3.056
DSC 3.036 2.985
SDID (i) 3.170 2.758
MASC 2.769 2.726
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> SC, DSC and MASC barely notice — they move by less than 0.07 percentage points. &lt;strong>SDID moves by 0.41&lt;/strong>, from 2.76% to 3.17%, which is more than the entire spread across methods at a fixed date. The reason is visible in the time-weight figure: SDID fits its time weights against the treatment quarter, so changing which quarter that is changes the target of the fit. At 2016Q2 the weight splits 0.84/0.16 across the last two quarters instead of collapsing onto one. &lt;strong>The choice of treatment date is not innocuous, and it bites hardest on precisely the estimator the tournament recommends.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>(b) Dropping the United States.&lt;/strong> The US carries about a fifth of the weight, and if spillovers exist they should be concentrated in the highest-weighted donors [17].&lt;/p>
&lt;pre>&lt;code class="language-text"> method with_US without_US
SC 3.056 3.067
DSC 2.985 3.032
SDID (i) 2.758 2.818
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Every estimate moves by at most 0.06 percentage points and all three move &lt;em>up&lt;/em>. Whatever the no-interference assumption is doing here, it is not driving the result.&lt;/p>
&lt;p>&lt;strong>(c) The ridge penalty.&lt;/strong> Arkhangelsky and coauthors propose an automatic regularisation of the unit weights, whose main theoretical benefit is that it makes the solution unique — which, given section 8.4, is not a trivial gain.&lt;/p>
&lt;p>$$\zeta = \left( T_{post} \right)^{1/4} \sqrt{ \frac{1}{J (T_0 - 2)} \sum_{j=2}^{J+1} \sum_{t=1}^{T_0-2} \left( \Delta_{j,t} - \bar{\Delta} \right)^{2} }$$&lt;/p>
&lt;p>In words, the penalty scale is the standard deviation of donors&amp;rsquo; quarter-to-quarter GDP changes, inflated by the fourth root of the number of post-treatment quarters. In code, this is what &lt;code>synthdid&lt;/code> uses when you &lt;em>omit&lt;/em> &lt;code>zeta.omega = 0&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-text"> method no_penalty with_penalty
SC 3.056 3.093
DSC 2.985 3.090
SDID (i) 2.758 2.642
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Penalising moves SC up by 0.04, DSC up by 0.11 and SDID down by 0.12. All within the spread we have already seen. The penalty is worth switching on for the uniqueness it buys, not because it changes any conclusion.&lt;/p>
&lt;p>&lt;strong>(d) Mean covariates.&lt;/strong> Section 16 asks whether covariates help as a matter of estimator design; the same question belongs here as a specification cell, because it is the one departure that moves every stage in the same direction.&lt;/p>
&lt;pre>&lt;code class="language-text"> method no_covariates with_covariates
SC 3.056 3.028
DSC 2.985 2.942
SDID (i) 2.758 2.731
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Interpretation.&lt;/strong> All three fall, and none by more than 0.05 percentage points. The covariate route that genuinely moves the answer is SC(B) at 2.43%, and that is a different &lt;em>estimator&lt;/em> — &lt;code>Synth&lt;/code>&amp;rsquo;s nested optimisation over 92 predictors — rather than a different specification of the ones on this ladder. Section 16 has the detail.&lt;/p>
&lt;p>&lt;img src="r_sc_dsc_sdid_17_robustness_grid.png" alt="Specification zoo: estimated 2018Q4 GDP loss under five departures from the headline specification, coloured by method, against Born et al.&amp;amp;rsquo;s reference line">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> Across every specification in this section, the estimated loss sits between roughly &lt;strong>2.6% and 3.2%&lt;/strong>. Nothing plotted here reaches Born et al.&amp;rsquo;s 2.4% — the closest is SDID under the ridge penalty, at 2.64. Only SC(B) gets there, and it sits in section 16 rather than in this figure. That is the whole robustness story in one picture, and it is the reason the source paper insists on reporting a cloud rather than a point.&lt;/p>
&lt;h2 id="18-inference-what-the-paper-does-not-do">18. Inference: what the paper does not do&lt;/h2>
&lt;blockquote>
&lt;p>&lt;strong>This section goes beyond the paper.&lt;/strong> De Brabander, Juodis and Miyazato Szini state in their Remark 1 that they consider point estimates only and set inference aside entirely, on the grounds that inference for this class of problems is genuinely hard. That is a defensible position for a methods comparison, and a poor place for a first-time learner to stop. Everything that follows is our addition.&lt;/p>
&lt;/blockquote>
&lt;h3 id="181-placebo-in-space">18.1 Placebo in space&lt;/h3>
&lt;p>The classic device, due to Abadie and coauthors: pretend each donor in turn was the treated country, run the whole procedure, and see whether the UK&amp;rsquo;s post-treatment gap is unusual against that reference distribution. The statistic is the ratio of post-treatment to pre-treatment fit error, which corrects for the fact that a country the method fits badly will show a large gap for uninteresting reasons.&lt;/p>
&lt;p>$$R_j = \frac{ \sqrt{ \frac{1}{T - T_0 + 1} \sum_{t \geq T_0} \hat{\tau}_{j,t}^{2} } }{ \sqrt{ \frac{1}{T_0 - 1} \sum_{t &amp;lt; T_0} \hat{\tau}_{j,t}^{2} } }, \qquad p = \frac{1}{J+1} \sum_{j=1}^{J+1} \mathbf{1}\{ R_j \geq R_1 \}$$&lt;/p>
&lt;p>In words, form the post-over-pre error ratio for every country and ask what fraction look at least as extreme as the UK. In code, &lt;code>ratio_of()&lt;/code> applied to each placebo run, then the rank of the UK.&lt;/p>
&lt;pre>&lt;code class="language-r">placebo_space &amp;lt;- lapply(DONORS, function(j) {
pool &amp;lt;- setdiff(DONORS, j) # the UK is excluded throughout
w &amp;lt;- simplex_fw(Y[1:86, pool], Y[1:86, j])
Y[, j] - as.vector(Y[, pool] %*% w) # this donor's placebo gap path
})
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> UK post/pre RMSPE ratio : 5.82
rank among 24 countries : 1
permutation p-value : 0.042 (finest attainable: 0.042)
synthdid placebo standard error (SDID): 0.00948 log points
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_sc_dsc_sdid_18_placebo_in_space.png" alt="Twenty-three grey placebo gap paths with the United Kingdom&amp;amp;rsquo;s gap in orange, showing the UK&amp;amp;rsquo;s post-referendum divergence as the most extreme in the sample">&lt;/p>
&lt;p>&lt;strong>Interpretation.&lt;/strong> The UK&amp;rsquo;s post-treatment fit error is &lt;strong>5.8 times&lt;/strong> its pre-treatment fit error, and that ratio is the &lt;strong>largest of all 24 countries&lt;/strong>. The permutation p-value is therefore &lt;strong>0.042&lt;/strong>, the smallest value this design can produce. Visually, the orange line leaves the grey band shortly after the referendum and never returns.&lt;/p>
&lt;p>The &lt;code>synthdid&lt;/code> placebo standard error tells a more sobering story: &lt;strong>0.0095 log points&lt;/strong>, which puts a conventional 95% interval around the SDID estimate at roughly &lt;strong>0.9% to 4.6%&lt;/strong>. The point estimate is much better determined than the interval, which is the normal state of affairs with one treated unit and 23 donors. Anyone quoting &amp;ldquo;Brexit cost 2.8% of GDP&amp;rdquo; without that interval is overstating what this design can deliver.&lt;/p>
&lt;h3 id="182-what-this-can-and-cannot-tell-you">18.2 What this can and cannot tell you&lt;/h3>
&lt;p>Three caveats, all of which matter.&lt;/p>
&lt;p>First, with 23 donors the finest attainable p-value is $1/24 \approx 0.042$. The test simply cannot reject at the 1% level no matter how extreme the UK looks. This is a property of the design, not of Brexit.&lt;/p>
&lt;p>Second, this is randomisation inference: it asks &lt;em>how unusual is the United Kingdom among OECD countries&lt;/em>, not &lt;em>what is the sampling error of this estimate&lt;/em>. Those are different questions, and only the first one has a well-defined answer here.&lt;/p>
&lt;p>Third, for a genuinely model-based alternative, the source paper itself points to the conformal-inference approach of Chernozhukov, Wüthrich and Zhu [16], which &lt;code>augsynth&lt;/code> implements and which &lt;a href="https://carlos-mendez.org/post/r_augsynth/">the Kansas tutorial&lt;/a> works through in detail.&lt;/p>
&lt;h2 id="19-the-same-ladder-in-stata-and-python">19. The same ladder in Stata and Python&lt;/h2>
&lt;p>Everything above is R. The ladder is not, and a reader who works in Stata or Python should not have to take the results on trust. This section ports the whole thing twice — &lt;a href="cheatsheet_stata.do">&lt;code>cheatsheet_stata.do&lt;/code>&lt;/a> and &lt;a href="cheatsheet_python.py">&lt;code>cheatsheet_python.py&lt;/code>&lt;/a>, alongside &lt;a href="cheatsheet_R.R">&lt;code>cheatsheet_R.R&lt;/code>&lt;/a> — and the disagreements between the three turn out to be the most instructive part.&lt;/p>
&lt;p>All three sheets share one device. Each of these packages reports an ATT averaged over &lt;em>all&lt;/em> post-treatment periods, but we want the shortfall at two specific quarters. So keep the 86 pre-treatment quarters plus the single quarter of interest, renumber time, and the average over &amp;ldquo;all post periods&amp;rdquo; becomes an average over one period. The bare package call then returns exactly the number we want, and none of the three files contains any post-estimation arithmetic.&lt;/p>
&lt;h3 id="191-what-maps-onto-what">19.1 What maps onto what&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>R&lt;/th>
&lt;th>Stata&lt;/th>
&lt;th>Python (&lt;code>mlsynth&lt;/code>)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DiD&lt;/td>
&lt;td>&lt;code>did_estimate()&lt;/code>&lt;/td>
&lt;td>&lt;code>sdid …, method(did)&lt;/code>&lt;/td>
&lt;td>&lt;code>FDID(…).fit().did&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>&lt;code>sc_estimate()&lt;/code>&lt;/td>
&lt;td>&lt;code>sdid …, method(sc)&lt;/code>&lt;/td>
&lt;td>&lt;code>VanillaSC(…)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>&lt;code>synthdid_estimate(lambda = uniform)&lt;/code>&lt;/td>
&lt;td>&lt;code>sdid …, method(sc)&lt;/code> on demeaned $y$&lt;/td>
&lt;td>&lt;code>TSSC(…, method = &amp;quot;MSCa&amp;quot;)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID&lt;/td>
&lt;td>&lt;code>synthdid_estimate()&lt;/code>&lt;/td>
&lt;td>&lt;code>sdid …, zeta_omega(0) zeta_lambda(0)&lt;/code>&lt;/td>
&lt;td>&lt;code>SDID(…, zeta = 0)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>&lt;code>masc()&lt;/code>&lt;/td>
&lt;td>— none —&lt;/td>
&lt;td>&lt;code>MASC(…, set_f = range(6, 87))&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>&lt;code>augsynth(progfunc = &amp;quot;Ridge&amp;quot;)&lt;/code>&lt;/td>
&lt;td>&lt;code>allsynth …, bcorrect(merge)&lt;/code>&lt;/td>
&lt;td>&lt;code>VanillaSC(…, augment = &amp;quot;ridge&amp;quot;)&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two entries need explaining before the numbers make sense.&lt;/p>
&lt;p>&lt;strong>DSC in Stata.&lt;/strong> There is no &lt;code>dsc&lt;/code> command and none is needed. Demeaned SC &lt;em>is&lt;/em> SC run on outcomes from which each country&amp;rsquo;s own pre-treatment mean has been subtracted: after demeaning, the pre-treatment gap averages to zero by construction, so the double difference collapses to the single one. Three lines of &lt;code>bysort&lt;/code> and a &lt;code>method(sc)&lt;/code> call reproduce it exactly.&lt;/p>
&lt;p>&lt;strong>ASCM in Stata is a different estimator.&lt;/strong> &lt;code>allsynth&lt;/code> implements the &lt;em>bias-corrected&lt;/em> synthetic control of Abadie and L&amp;rsquo;Hour and of Ben-Michael, Feller and Rothstein: fit SC, then regress the outcome on the predictors across the donor pool and subtract the predicted discrepancy. &lt;code>augsynth&lt;/code> uses &lt;em>ridge-augmented&lt;/em> SC. They are cousins, not the same estimator. Worse, the bias correction is an OLS fit across donors, so it needs more control units than predictors — with 23 donors we cannot hand it all 86 pre-treatment quarters the way a ridge penalty can. The path has to be summarised, and the summary matters enormously: with sparse individual lags the bias-corrected estimate swings between $-0.8$ and $5.1$ depending on which quarters you pick. Block means are far better conditioned, and the do-file fits a small grid of them and keeps the one with the lowest pre-treatment RMSPE — a rule fixed in advance that never looks at the post-treatment answer.&lt;/p>
&lt;h3 id="192-the-three-ports-side-by-side">19.2 The three ports, side by side&lt;/h3>
&lt;p>Shortfall in UK real GDP (%), treatment dated 2016Q3, outcomes only.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>R 2018Q4&lt;/th>
&lt;th>Stata 2018Q4&lt;/th>
&lt;th>Python 2018Q4&lt;/th>
&lt;th>R 2019Q4&lt;/th>
&lt;th>Stata 2019Q4&lt;/th>
&lt;th>Python 2019Q4&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DiD&lt;/td>
&lt;td>4.98&lt;/td>
&lt;td>4.98&lt;/td>
&lt;td>4.98&lt;/td>
&lt;td>6.18&lt;/td>
&lt;td>6.18&lt;/td>
&lt;td>6.18&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SC&lt;/td>
&lt;td>3.06&lt;/td>
&lt;td>3.06&lt;/td>
&lt;td>&lt;strong>3.04&lt;/strong>&lt;/td>
&lt;td>4.20&lt;/td>
&lt;td>4.20&lt;/td>
&lt;td>&lt;strong>4.17&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DSC&lt;/td>
&lt;td>2.99&lt;/td>
&lt;td>2.99&lt;/td>
&lt;td>2.99&lt;/td>
&lt;td>4.12&lt;/td>
&lt;td>4.12&lt;/td>
&lt;td>4.12&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDID (ii)&lt;/td>
&lt;td>2.79&lt;/td>
&lt;td>2.79&lt;/td>
&lt;td>&lt;strong>2.80&lt;/strong>&lt;/td>
&lt;td>3.92&lt;/td>
&lt;td>3.92&lt;/td>
&lt;td>&lt;strong>3.94&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MASC&lt;/td>
&lt;td>2.73&lt;/td>
&lt;td>—&lt;/td>
&lt;td>2.73&lt;/td>
&lt;td>3.83&lt;/td>
&lt;td>—&lt;/td>
&lt;td>3.83&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ASCM&lt;/td>
&lt;td>3.04&lt;/td>
&lt;td>3.10&lt;/td>
&lt;td>3.04&lt;/td>
&lt;td>4.19&lt;/td>
&lt;td>4.22&lt;/td>
&lt;td>4.19&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The SDID row reports variant (ii) in all three languages, so that the comparison is like with like; section 14&amp;rsquo;s headline SDID is variant (i), at 2.76. Stata reproduces R to five decimal places at every stage it can fit. Python agrees on DiD, DSC, MASC and ASCM, and differs in the second decimal on SC and SDID.&lt;/p>
&lt;h3 id="193-the-disagreement-is-the-finding">19.3 The disagreement is the finding&lt;/h3>
&lt;p>The bolded cells are not a bug in any of the three libraries. They are &lt;a href="#84-why-the-two-solvers-disagree">section 8.4&lt;/a>&amp;rsquo;s solver story arriving from a completely independent direction.&lt;/p>
&lt;p>Recall the problem: the SC objective on this panel has a condition number around $7.5 \times 10^{5}$, which means a wide, nearly flat valley of near-optimal weight vectors. &lt;code>synthdid&lt;/code> walks that valley with Frank–Wolfe on a capped iteration budget and stops at &lt;strong>3.06&lt;/strong>. &lt;code>mlsynth&lt;/code> hands the identical problem to a convex solver, which runs it to optimality and returns &lt;strong>3.04&lt;/strong> — which is precisely the &amp;ldquo;SC (exact QP)&amp;rdquo; row computed by hand in section 8.4, on both quarters. Stata&amp;rsquo;s &lt;code>sdid&lt;/code> inherits &lt;code>synthdid&lt;/code>&amp;rsquo;s Frank–Wolfe and stops in the same place; tighten its convergence with &lt;code>max_iter(100000) min_dec(1e-9)&lt;/code> and the SDID estimate drifts from 2.79 to 2.80, which is where Python already is.&lt;/p>
&lt;p>So three implementations, written independently in three languages, sort themselves into exactly two camps — and the split is by &lt;em>solver&lt;/em>, not by language or by author. That is a much stronger piece of evidence for the section 8.4 claim than the iteration ladder in the original analysis, because nobody was trying to make this point when they wrote &lt;code>mlsynth&lt;/code>.&lt;/p>
&lt;p>The practical lesson is not that one library is right. It is that a synthetic control estimate carries its solver&amp;rsquo;s fingerprint, and that a second-decimal disagreement between implementations is the normal state of affairs rather than a cause for alarm.&lt;/p>
&lt;h3 id="194-three-traps-the-ports-exposed">19.4 Three traps the ports exposed&lt;/h3>
&lt;p>Each language has a default that quietly gives you the wrong estimator, and in all three cases it is the same default.&lt;/p>
&lt;p>&lt;strong>Every package penalises by default; the paper does not.&lt;/strong> R needs &lt;code>zeta.omega = 0, zeta.lambda = 0&lt;/code>, Python needs &lt;code>zeta = 0&lt;/code>, and Stata needs &lt;code>zeta_omega(0) zeta_lambda(0)&lt;/code>. Leave any of them alone and SDID reports 2.66–2.67 instead of 2.79. Stata&amp;rsquo;s version of this trap is the nastiest: the documented default is &lt;code>zeta_omega(1e-6)&lt;/code>, which looks like a value but is a magic sentinel — &lt;code>sdid.ado&lt;/code> reads &lt;code>if (EOmega==1e-6) EtaOmega = (yNtr*yTpost)^(1/4)&lt;/code>, so passing the documented default explicitly still requests the full penalty. Only &lt;code>0&lt;/code> switches it off.&lt;/p>
&lt;p>&lt;strong>MASC&amp;rsquo;s fold set has to be given explicitly in both languages that have MASC.&lt;/strong> R&amp;rsquo;s &lt;code>masc&lt;/code> and Python&amp;rsquo;s &lt;code>mlsynth.MASC&lt;/code> both cross-validate over a fold set that, left to its default, is not the one the paper uses. Pass &lt;code>set_f = 6:T0&lt;/code> in R and &lt;code>set_f=range(6, 87)&lt;/code> in Python and the two agree to three decimals at 2.726. This is the same trap flagged in section 12, and it survives translation.&lt;/p>
&lt;p>&lt;strong>&lt;code>mlsynth.DSC&lt;/code> is not this post&amp;rsquo;s DSC.&lt;/strong> mlsynth ships a class named &lt;code>DSC&lt;/code> which implements &lt;em>Distributional&lt;/em> Synthetic Control (Gunsilius) — matching whole outcome distributions. The DSC on this ladder is &lt;em>Demeaned&lt;/em> Synthetic Control, which in mlsynth is &lt;code>TSSC(method = &amp;quot;MSCa&amp;quot;)&lt;/code>. Same three letters, different estimators, and importing the wrong one raises no error at all. It simply answers a different question. The mapping used here follows &lt;a href="https://github.com/jgreathouse9/mlsynth/issues/312" target="_blank" rel="noopener">mlsynth issue #312&lt;/a>, which is itself a reading of the paper this post replicates.&lt;/p>
&lt;blockquote>
&lt;p>The Python column of these tables is only a cheat sheet. &lt;strong>&lt;a href="https://carlos-mendez.org/post/python_sc_dsc_sdid/">The Python edition of this post&lt;/a>&lt;/strong> climbs the same ladder at full length with &lt;code>mlsynth&lt;/code> alone — every config option, every result field, the three SDID flavours, the covariate routes this cheat sheet skips, and the wider catalogue of estimators the library ships. Read it if you work in Python; read this one for the derivations.&lt;/p>
&lt;/blockquote>
&lt;h3 id="195-what-each-language-cannot-do">19.5 What each language cannot do&lt;/h3>
&lt;p>Reported plainly rather than papered over. MASC has no Stata implementation, so that row is empty rather than approximated. SC(B) and the other covariate specifications are in none of the three sheets, because they need &lt;code>Synth&lt;/code>&amp;rsquo;s nested optimisation over 92 predictors and turn a thirty-second script into a coffee break — section 16 and &lt;code>analysis.R&lt;/code> §14d cover them. And the standard errors each package reports are its own recommended method, not a common yardstick: R&amp;rsquo;s placebo SEs, Stata&amp;rsquo;s placebo SEs at a different replication count, &lt;code>augsynth&lt;/code>&amp;rsquo;s jackknife and &lt;code>mlsynth&lt;/code>&amp;rsquo;s analytic FDID error are not comparable digit for digit. Read them as orders of magnitude, and note that every one of them is wide enough to contain zero.&lt;/p>
&lt;h2 id="20-discussion">20. Discussion&lt;/h2>
&lt;p>&lt;strong>What Brexit cost.&lt;/strong> Taking the ladder as a whole, the referendum had cost the UK somewhere between &lt;strong>2.7% and 3.1% of GDP by the end of 2018&lt;/strong>, and between &lt;strong>3.8% and 4.2% by the end of 2019&lt;/strong>. That is above the 2.4% previously published for this dataset, and the reason is not exotic: the earlier figure came from a specification that matched on covariates, and covariates make the counterfactual worse here rather than better.&lt;/p>
&lt;p>Three caveats belong with that number. It is a &lt;em>net&lt;/em> gap between the UK and a blend of OECD economies, not a Brexit-only effect — anything else distinctive that happened to the UK after mid-2016 is inside it. The no-interference assumption is strong over a four-year horizon when the United States carries a fifth of the weight in the counterfactual. And the estimate is a point on a specification cloud, not a parameter that has been pinned down.&lt;/p>
&lt;p>&lt;strong>What the ladder taught.&lt;/strong> The durable idea is the bias decomposition, not the leaderboard. Extrapolation bias and interpolation bias are separate failures with separate fixes, unit weights address the first, time weights address the second, and any weighted counterfactual you ever build can be interrogated on both counts. The ranking of estimators on this dataset is far more perishable — it depends on the outcome behaving like a random walk, on a long pre-period and on a treated unit that sits inside the convex hull.&lt;/p>
&lt;p>It also cuts the other way, and the source paper says so plainly: SDID&amp;rsquo;s advantage over DSC is marginal once you count the 85 extra parameters it estimates, and neither MASC nor ASCM justifies its computational cost here. Our own placebo results agree — the whole SDID family clusters together, and the gap down to DSC is smaller than the gap between covariates and no covariates.&lt;/p>
&lt;p>&lt;strong>So what should you actually do?&lt;/strong> Fit the ladder, not a stage. Run an in-sample placebo tournament and check that the horizons are matched. Report the range. If one specification is going to be the headline, choose it before you see the estimates, and show the others anyway. Ferman, Pinto and Possebom [15] have documented how much room for cherry-picking this literature leaves; the honest response is to publish the cloud.&lt;/p>
&lt;h2 id="21-summary-and-next-steps">21. Summary and next steps&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Every estimator here is one weighted two-way regression&lt;/strong> with a different choice of unit weights $\omega$, time weights $\lambda$, and whether the unit fixed effect is switched on. DiD, SC, DSC and SDID are four settings of the same expression; MASC and ASCM change the feasible set instead.&lt;/li>
&lt;li>&lt;strong>One solver does five jobs.&lt;/strong> The simplex least-squares problem solves the unit weights, the demeaned unit weights, the time weights (on the transpose) and both components of MASC&amp;rsquo;s cross-validation.&lt;/li>
&lt;li>&lt;strong>The Brexit cost is 2.7–3.1% at end-2018 and 3.8–4.2% at end-2019&lt;/strong>, above the previously published 2.4%, and the difference is driven mostly by the covariate specification.&lt;/li>
&lt;li>&lt;strong>The SDID family wins the placebo tournament&lt;/strong> at either forecast horizon, but the published ranking &lt;em>within&lt;/em> that family does not survive matching the horizons.&lt;/li>
&lt;li>&lt;strong>Covariates hurt here.&lt;/strong> With 86 pre-treatment outcomes already in the matching set, six extra badly-scaled predictors add estimation noise without adding identification.&lt;/li>
&lt;li>&lt;strong>Two practical traps&lt;/strong> cost real accuracy: &lt;code>masc&lt;/code>&amp;rsquo;s fold argument silently produces five folds instead of eighty, and a flat objective means &lt;code>synthdid&lt;/code>&amp;rsquo;s optimiser stops on its iteration cap rather than at the optimum.&lt;/li>
&lt;li>&lt;strong>Both traps survive translation.&lt;/strong> Porting the ladder to Stata and Python (section 19) reproduces every estimate, and the places where it does not are the solver, not the language: &lt;code>mlsynth&lt;/code>&amp;rsquo;s convex solver lands on the exact-QP answer while &lt;code>synthdid&lt;/code> and Stata&amp;rsquo;s &lt;code>sdid&lt;/code> stop where Frank–Wolfe stops. Three implementations, two camps, split by solver.&lt;/li>
&lt;/ul>
&lt;p>Where to go next: the three cheat sheets if you just want working code — &lt;a href="cheatsheet_R.R">&lt;code>cheatsheet_R.R&lt;/code>&lt;/a>, &lt;a href="cheatsheet_stata.do">&lt;code>cheatsheet_stata.do&lt;/code>&lt;/a>, &lt;a href="cheatsheet_python.py">&lt;code>cheatsheet_python.py&lt;/code>&lt;/a> — then &lt;a href="https://carlos-mendez.org/post/r_sc_multi_country/">multi-country and staggered adoption with &lt;code>multisynth&lt;/code>&lt;/a>, &lt;a href="https://carlos-mendez.org/post/stata_sdid/">the same SDID estimator in Stata on Proposition 99&lt;/a>, or &lt;a href="https://carlos-mendez.org/post/r_demeaning_twfe/">manual demeaning and the FWL theorem&lt;/a> if the unit-fixed-effect story in stage three felt too quick. The Monte Carlo study in the source paper, which stress-tests this ranking on simulated data, is the subject of a future post.&lt;/p>
&lt;h2 id="22-exercises">22. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Move the treatment date.&lt;/strong> Re-run SC and SDID (i) dating the treatment at 2016Q1 rather than 2016Q2 or 2016Q3. Which estimator moves more, and can you explain why using the time-weight figure?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Read the simplex.&lt;/strong> Using &lt;code>simplex_ls&lt;/code>, solve the SC problem restricted to just the United States, Hungary and Canada, and plot the objective over the triangle. Now add Japan as a fourth donor. By how much does the pre-treatment MSPE fall?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Break DSC on purpose.&lt;/strong> Add a constant of 0.05 log points to &lt;em>every&lt;/em> UK observation, before and after the referendum. Which of SC, DSC and SDID change their estimated effect, and which do not? Explain the result using the unit fixed effect $\alpha_j$ in the master regression.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Force the time weights to spread out.&lt;/strong> Re-run SDID on first-differenced log GDP instead of levels. Does the spike on the final quarter survive? Then argue, using the source paper&amp;rsquo;s own reasoning, why the authors declined to make this switch in their headline results.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Finish the horizon audit.&lt;/strong> Section 15.3 matched the horizons for the placebo tournament. Extend it to $h = 2$ and $h = 8$. Is the SDID family&amp;rsquo;s advantage over SC stable in the horizon, or does it shrink?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Separate MASC&amp;rsquo;s two dials.&lt;/strong> Compute the 2018Q4 estimate for $\phi$ on a grid from 0 to 1 in steps of 0.05, holding $m = 10$. How much of the difference between MASC&amp;rsquo;s 2.73% and SC&amp;rsquo;s 3.06% is due to the chosen $\phi$ rather than to the choice of $m$?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Drop the biggest donor.&lt;/strong> The United States carries about a fifth of the weight in most specifications, and spillover risk is concentrated in the highest-weighted donors. Re-run the entire ladder without the United States. Does your conclusion about Brexit change? Then ask the harder question: does your conclusion about &lt;em>which estimator to use&lt;/em> change?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Build your own stage.&lt;/strong> DSC and SDID differ only in how the bias adjustment is weighted across pre-periods — flat in one, optimised in the other. Propose a third weighting, for example exponentially decaying weights with a half-life you choose, implement it, and enter it in the placebo tournament. Does it beat SDID?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="23-references">23. References&lt;/h2>
&lt;ol>
&lt;li>de Brabander, E., Juodis, A., &amp;amp; Miyazato Szini, G. (2025). &lt;a href="https://doi.org/10.1080/07474938.2025.2530649" target="_blank" rel="noopener">On the use of synthetic difference-in-differences approach with (-out) covariates: The case study of Brexit referendum&lt;/a>. &lt;em>Econometric Reviews&lt;/em>, 44(10), 1617–1646.&lt;/li>
&lt;li>Born, B., Müller, G. J., Schularick, M., &amp;amp; Sedláček, P. (2019). &lt;a href="https://doi.org/10.1093/ej/uez020" target="_blank" rel="noopener">The costs of economic nationalism: Evidence from the Brexit experiment&lt;/a>. &lt;em>The Economic Journal&lt;/em>, 129(623), 2722–2744.&lt;/li>
&lt;li>Abadie, A., &amp;amp; Gardeazabal, J. (2003). &lt;a href="https://doi.org/10.1257/000282803321455188" target="_blank" rel="noopener">The economic costs of conflict: A case study of the Basque Country&lt;/a>. &lt;em>American Economic Review&lt;/em>, 93(1), 113–132.&lt;/li>
&lt;li>Abadie, A., Diamond, A., &amp;amp; Hainmueller, J. (2010). &lt;a href="https://doi.org/10.1198/jasa.2009.ap08746" target="_blank" rel="noopener">Synthetic control methods for comparative case studies&lt;/a>. &lt;em>Journal of the American Statistical Association&lt;/em>, 105(490), 493–505.&lt;/li>
&lt;li>Abadie, A., Diamond, A., &amp;amp; Hainmueller, J. (2015). &lt;a href="https://doi.org/10.1111/ajps.12116" target="_blank" rel="noopener">Comparative politics and the synthetic control method&lt;/a>. &lt;em>American Journal of Political Science&lt;/em>, 59(2), 495–510.&lt;/li>
&lt;li>Abadie, A. (2021). &lt;a href="https://doi.org/10.1257/jel.20191450" target="_blank" rel="noopener">Using synthetic controls: Feasibility, data requirements, and methodological aspects&lt;/a>. &lt;em>Journal of Economic Literature&lt;/em>, 59(2), 391–425.&lt;/li>
&lt;li>Rubin, D. B. (1974). &lt;a href="https://doi.org/10.1037/h0037350" target="_blank" rel="noopener">Estimating causal effects of treatments in randomized and nonrandomized studies&lt;/a>. &lt;em>Journal of Educational Psychology&lt;/em>, 66(5), 688–701.&lt;/li>
&lt;li>Doudchenko, N., &amp;amp; Imbens, G. W. (2016). &lt;a href="https://doi.org/10.3386/w22791" target="_blank" rel="noopener">Balancing, regression, difference-in-differences and synthetic control methods: A synthesis&lt;/a>. NBER Working Paper 22791.&lt;/li>
&lt;li>Ferman, B., &amp;amp; Pinto, C. (2021). &lt;a href="https://doi.org/10.3982/QE1596" target="_blank" rel="noopener">Synthetic controls with imperfect pretreatment fit&lt;/a>. &lt;em>Quantitative Economics&lt;/em>, 12(4), 1197–1221.&lt;/li>
&lt;li>Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., &amp;amp; Wager, S. (2021). &lt;a href="https://doi.org/10.1257/aer.20190159" target="_blank" rel="noopener">Synthetic difference-in-differences&lt;/a>. &lt;em>American Economic Review&lt;/em>, 111(12), 4088–4118.&lt;/li>
&lt;li>Kellogg, M., Mogstad, M., Pouliot, G. A., &amp;amp; Torgovitsky, A. (2021). &lt;a href="https://doi.org/10.1080/01621459.2021.1979562" target="_blank" rel="noopener">Combining matching and synthetic control to trade off biases from extrapolation and interpolation&lt;/a>. &lt;em>Journal of the American Statistical Association&lt;/em>, 116(536), 1804–1816.&lt;/li>
&lt;li>Ben-Michael, E., Feller, A., &amp;amp; Rothstein, J. (2021). &lt;a href="https://doi.org/10.1080/01621459.2021.1929245" target="_blank" rel="noopener">The augmented synthetic control method&lt;/a>. &lt;em>Journal of the American Statistical Association&lt;/em>, 116(536), 1789–1803.&lt;/li>
&lt;li>Kaul, A., Klößner, S., Pfeifer, G., &amp;amp; Schieler, M. (2022). &lt;a href="https://doi.org/10.1080/07350015.2021.1930012" target="_blank" rel="noopener">Standard synthetic control methods: The case of using all preintervention outcomes together with covariates&lt;/a>. &lt;em>Journal of Business &amp;amp; Economic Statistics&lt;/em>, 40(3), 1362–1376.&lt;/li>
&lt;li>Botosaru, I., &amp;amp; Ferman, B. (2019). &lt;a href="https://doi.org/10.1093/ectj/utz001" target="_blank" rel="noopener">On the role of covariates in the synthetic control method&lt;/a>. &lt;em>The Econometrics Journal&lt;/em>, 22(2), 117–130.&lt;/li>
&lt;li>Ferman, B., Pinto, C., &amp;amp; Possebom, V. (2020). &lt;a href="https://doi.org/10.1002/pam.22206" target="_blank" rel="noopener">Cherry picking with synthetic controls&lt;/a>. &lt;em>Journal of Policy Analysis and Management&lt;/em>, 39(2), 510–532.&lt;/li>
&lt;li>Chernozhukov, V., Wüthrich, K., &amp;amp; Zhu, Y. (2021). &lt;a href="https://doi.org/10.1080/01621459.2021.1920957" target="_blank" rel="noopener">An exact and robust conformal inference method for counterfactual and synthetic controls&lt;/a>. &lt;em>Journal of the American Statistical Association&lt;/em>, 116(536), 1849–1864.&lt;/li>
&lt;li>Di Stefano, R., &amp;amp; Mellace, G. (2024). &lt;a href="https://arxiv.org/abs/2403.17624" target="_blank" rel="noopener">The inclusive synthetic control method&lt;/a>. arXiv:2403.17624.&lt;/li>
&lt;li>Tashman, L. J. (2000). &lt;a href="https://doi.org/10.1016/S0169-2070%2800%2900065-0" target="_blank" rel="noopener">Out-of-sample tests of forecasting accuracy: An analysis and review&lt;/a>. &lt;em>International Journal of Forecasting&lt;/em>, 16(4), 437–450.&lt;/li>
&lt;li>Software, R: &lt;a href="https://github.com/synth-inference/synthdid" target="_blank" rel="noopener">&lt;code>synthdid&lt;/code>&lt;/a> · &lt;a href="https://CRAN.R-project.org/package=Synth" target="_blank" rel="noopener">&lt;code>Synth&lt;/code>&lt;/a> · &lt;a href="https://github.com/maxkllgg/masc" target="_blank" rel="noopener">&lt;code>masc&lt;/code>&lt;/a> · &lt;a href="https://github.com/ebenmichael/augsynth" target="_blank" rel="noopener">&lt;code>augsynth&lt;/code>&lt;/a> · &lt;a href="https://CRAN.R-project.org/package=quadprog" target="_blank" rel="noopener">&lt;code>quadprog&lt;/code>&lt;/a>&lt;/li>
&lt;li>Software, Stata: &lt;a href="https://doi.org/10.1177/1536867X241297914" target="_blank" rel="noopener">&lt;code>sdid&lt;/code>&lt;/a> (Clarke, Pailañir, Athey &amp;amp; Imbens) · &lt;a href="http://fmwww.bc.edu/repec/bocode/s/synth.ado" target="_blank" rel="noopener">&lt;code>synth&lt;/code>&lt;/a> (Abadie, Diamond &amp;amp; Hainmueller) · &lt;a href="http://fmwww.bc.edu/repec/bocode/a/allsynth.ado" target="_blank" rel="noopener">&lt;code>allsynth&lt;/code>&lt;/a> (Wiltshire)&lt;/li>
&lt;li>Software, Python: &lt;a href="https://github.com/jgreathouse9/mlsynth" target="_blank" rel="noopener">&lt;code>mlsynth&lt;/code>&lt;/a> (Greathouse). The estimator mapping used in section 19 follows &lt;a href="https://github.com/jgreathouse9/mlsynth/issues/312" target="_blank" rel="noopener">issue #312&lt;/a>, which reads the same source paper this post replicates.&lt;/li>
&lt;li>Companion tutorials on this site: &lt;a href="https://carlos-mendez.org/post/r_basic_synthetic_control/">Synthetic control on the Basque Country&lt;/a> · &lt;a href="https://carlos-mendez.org/post/r_augsynth/">Augmented synthetic control and the Kansas tax cuts&lt;/a> · &lt;a href="https://carlos-mendez.org/post/stata_sdid/">Synthetic difference-in-differences on Proposition 99&lt;/a> · &lt;a href="https://carlos-mendez.org/post/r_demeaning_twfe/">Manual demeaning and two-way fixed effects&lt;/a> · &lt;a href="https://carlos-mendez.org/post/r_sc_multi_country/">Multi-country augmented synthetic control&lt;/a>&lt;/li>
&lt;/ol>
&lt;h4 id="acknowledgements">Acknowledgements&lt;/h4>
&lt;p>AI tools (Claude Code, Gemini, NotebookLM) were used to make the contents of this post more accessible to students. Nevertheless, the content in this post may still have errors. Caution is needed when applying the contents of this post to true research projects.&lt;/p></description></item></channel></rss>