A t-test for synthetic controls (Chernozhukov, Wüthrich & Zhu)#
- Estimator:
Vanilla Synthetic Control (VanillaSC) —
mlsynth.VanillaSC,inference="ttest"- Source:
Chernozhukov, V., Wüthrich, K., & Zhu, Y., “A t-test for synthetic controls” (arXiv:1812.10820), Tables 1, 3 and 5.
- Replication type:
Path A on the authors’ data and Path B on their simulation design, both cross-validated against their own R.
- Status:
verified — the empirical estimate, the Table 3 Monte Carlo and the Table 1 efficiency formula all reproduced against live runs.
- Durable cases:
What the method does#
A synthetic control gives an estimate but not a standard error, and the usual routes to one are awkward here: there is a single treated unit, the post-treatment window is short, and the weights are estimated from the same pre-period the errors are judged against. The t-test replaces all of that with cross-fitting.
Split the pre-period into \(K\) blocks. For each block, refit the weights on its complement and form the difference between the mean post-treatment gap and the mean gap on the held-out block. That gives \(K\) estimates of the same effect, each computed from weights that never saw its own held-out block, so the bias the fit induces is removed by the subtraction. Their mean is the debiased ATT and their sample standard deviation supplies the standard error, giving a statistic that is asymptotically \(t_{K-1}\) — a familiar \(\widehat{\tau} \pm t_{K-1}(1-\alpha/2)\,\mathrm{se}\) with no long-run variance to estimate and no requirement that the synthetic control be correctly specified.
The price is that \(K\) is a choice, and it is a real trade-off: more folds
shorten the interval and shorter blocks make each fold’s estimate noisier. Table
1 quantifies the first half of that trade, and mlsynth’s ttest_K="auto"
implements a rule on top of it.
Path A — the Swedish carbon tax#
Table 5(a) applies the test to Andersson’s (2019) study of Sweden’s 1990 carbon tax on transport CO2 per capita: 15 countries, 1960–2005, \(T_0 = 30\), \(T_1 = 16\), \(K = 3\), outcome-only weights. The paper reports an ATT of −0.27 with a 90% interval of [−0.41, −0.14].
import pandas as pd
from mlsynth import VanillaSC
ct = pd.read_stata("basedata/carbontax_data.dta")
ct["treated"] = ((ct.country == "Sweden") & (ct.year >= 1990)).astype(int)
res = VanillaSC({
"df": ct, "outcome": "CO2_transport_capita", "treat": "treated",
"unitid": "country", "time": "year", "backend": "outcome-only",
"inference": "ttest", "ttest_K": 3, "alpha": 0.1, "display_graphs": False,
}).fit()
res.inference.details["att_debiased"] # -0.273903
res.inference.ci_lower, res.inference.ci_upper # -0.406425, -0.141380
Those are the authors’ scinference outputs, captured live in
benchmarks/reference/cwz_ttest/, not the paper’s rounded cells. The same
case also pins the debiased ATT on the Basque Country (−0.657511) and California
Proposition 99 (−17.991121).
Path B — Table 3, live#
Table 3 calibrates a simulation to the same carbon tax panel: a four-factor model fitted to the detrended controls, an AR(1) per control unit, and an AR(1) for the SC prediction errors. Nine DGPs vary whether a simplex synthetic control can span the treated unit and how the panel departs from stationarity. The treatment effect is zero, so what is measured is the coverage of a nominal 90% interval.
cwz_ttest_mc runs the authors’ own calibration_dgps.R and
common_functions.R, and separates two claims that a single end-to-end rate
cannot tell apart. Ten seed-matched panels per DGP are dumped with the ATT and
standard error R computed on each, and mlsynth is handed those exact panels: the
debiased t-test is a deterministic function of a panel, so those must agree
exactly, and they do to 1e−11. Then mlsynth draws its own panels from its own
port of the design and reproduces the reference’s coverage, length and bias
across all nine DGPs.
The geometry the paper reports comes through. Coverage is near nominal where the theory covers the design (DGP 1: 0.92 against 0.88; DGP 2: 0.92 against 0.91), mildly short under a common trend with one deviating donor (DGP 6: 0.82 against 0.82), and short by a wide margin under heterogeneous trends (DGP 8: 0.56 against 0.63), which lies outside the theory and is the paper’s stress case. The recovered \(\rho_u = 0.3125\) matches the paper’s reported ~0.31.
cwz_mc remains alongside it, tracking the paper’s printed cells from a
Python reimplementation of the design. The two fail differently: one would catch
a drift away from what the paper reports, the other a drift away from what the
authors’ code computes.
Path B — Table 1, the efficiency formula#
Table 1 is the relative asymptotic efficiency of the \(K\)-fold interval:
the ratio of its limiting expected length as \(K \to \infty\) to its length
at finite \(K\), a closed form in \(K\), the level and
\(c_0 = T_0/T_1\). cwz_rae runs the authors’ RAE.R and matches
mlsynth.utils.inferutils.rae() to 1e−9 across \(K = 2, \dots, 10\) at
their \(c_0 = 30/16\):
\(K=2\) |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
|---|---|---|---|---|---|---|---|---|
0.327 |
0.636 |
0.759 |
0.821 |
0.858 |
0.882 |
0.900 |
0.913 |
0.923 |
Two folds cost most of the efficiency, three recovers two thirds of it, and the
returns flatten past six. That shape is why the paper’s guidance settles on a
small \(K\) and why select_K treats three as the floor. The rule was
exercised at every level before this except against the table it comes from.
What the reproduction found#
scinference solves the synthetic control through limSolve::lsei, whose
lsei_type argument selects between two solvers, and the authors’ simulation
scripts pass type = 1. On the real carbon tax panel that is fine — Path A
above cross-validates it to 4e−7. On the simulated panels it is not: type 1
warns “inequalities contradictory” on the fold refits and returns a solution
that ignores the non-negativity constraint. Over the ten dumped DGP 8 draws it
does so on five of thirty fold refits, with one weight reaching −32.4, and those
are exactly the draws where the R and Python answers separate. The consequence
is visible in the aggregate: under type 1 the misspecified DGPs report a mean
interval length of 103 and a mean bias of −8.5 on an outcome of order one, where
type 2 gives 0.66 and 0.006.
Weights off the simplex are not a synthetic control, so what type 1 returns
there is an answer to a different problem. The reference run uses type = 2,
which solves the stated program on every draw, and records the off-simplex count
in its output so the choice is a measurement and not a claim. The Path A case is
unaffected and keeps type = 1.
Reproducing it#
bash benchmarks/R/install_scinference.sh
python benchmarks/reference/generate.py cwz_ttest
python benchmarks/reference/generate.py cwz_ttest_mc
python benchmarks/reference/generate.py cwz_rae
python benchmarks/run_benchmarks.py --case cwz_ttest
python benchmarks/run_benchmarks.py --case cwz_ttest_mc
python benchmarks/run_benchmarks.py --case cwz_rae