DTWSC – Cao and Chadefaux (2025) on the Basque panel#
DTWSC: Dynamic Synthetic Control is cross-validated against the authors’ own R package,
conflictlab/dsc (MIT), pinned at commit
b1cd241. The reference is not vendored; the generator that produces the
comparison dump lives in benchmarks/reference/dtwsc_basque/ and the
dependency chain it needs is scripted in benchmarks/R/install_dtwsc.sh.
The setup is the reference package’s own README example: Synth’s basque
panel, 1955–1997, treatment in 1970, the national aggregate dropped so 16
donors remain, the full 14-predictor Abadie specification, k = 4,
filter.width = 5, buffer = 0, n.burn = 3, symmetricP1 for the
first alignment and asymmetricP2 for the second.
Headline numbers#
method |
pre-RMSE |
ATT |
|---|---|---|
standard synthetic control |
0.0886 |
-0.6027 |
DSC, R reference |
0.0705 |
-0.5579 |
DTWSC, mlsynth’s warp through R’s |
0.0705 |
-0.5592 |
DTWSC, mlsynth end to end |
0.0663 |
-0.5310 |
standard SC, mlsynth end to end |
0.0845 |
-0.6232 |
The paper’s claim reproduces: warping tightens the pre-treatment fit by about 20 percent, and moves the ATT modestly toward zero.
Read the units before comparing any of these. dsc(rescale = TRUE) is the
reference’s default, and it maps every unit onto a common pre-treatment range
before fitting anything, so every number R reports is in rescaled units rather
than in GDP per capita. The mlsynth rows above are run with
rescale_units=True so the whole table sits on one scale; without it mlsynth
reports in the outcome’s own units, where R’s standard-SC ATT is -0.6405 and
not -0.6027.
That distinction is easy to lose, and this page previously lost it: an mlsynth
ATT of -0.6026 in GDP per capita was compared against R’s -0.6027 in rescaled
units and reported as a four-decimal reproduction. It was a coincidence of two
different quantities landing on the same digits. On one scale the two arms
agree to about 0.004 on pre-RMSE and 0.03 on the ATT, which is the honest
figure and is what benchmarks/cases/dtwsc_basque.py now pins.
Do not read that ATT agreement as agreement between the counterfactual paths, either. Comparing the two pointwise rather than in the mean, the pre-treatment halves sit almost on top of each other – worst single-period gap 0.027 warped and 0.036 unwarped, against a series spanning 2.18 rescaled units – while the post-treatment halves reach 0.19 and 0.16. The paths cross rather than running parallel, so most of that error cancels in the average and a 0.19 divergence presents as a 0.03 ATT gap.
That is the expected signature of the Savitzky–Golay difference described below: it is held down where the pre-treatment fit constrains it and free to grow where nothing does. The pointwise maximum is pinned in the benchmark alongside the ATT, because a regression that tilted the counterfactual while leaving its mean intact would pass an ATT check untouched.
What matches, seam by seam#
The comparison holds the input fixed at R’s own filtered panel, so only the warping is being tested.
quantity |
agreement |
|---|---|
|
16/16 donors exact |
|
16/16 bit-identical |
|
16/16, worst 2.2e-16 (one ULP) |
warped donor series |
16/16, worst 7.1e-15 |
second-phase window search ( |
28/28 windows on the worst-fitting donor |
outlier-filter decisions |
13888/13888 cells |
These rows are pinned in benchmarks/cases/dtwsc_basque.py rather than left
as a one-off measurement, so a regression in the warping engine fails the
benchmark.
Two findings worth keeping#
The alignment kernel is exact, across every step pattern the method uses.
mlsynth implements them directly rather than taking a DTW dependency, so a
mistyped recursion coefficient is the kind of error that would produce a
plausible warp and a wrong answer. Six are supported – symmetricP1,
symmetricP2, asymmetricP1, asymmetricP2, typeIc and
mori2006 – and each is checked against R dtw 1.23-3 on committed gold
in benchmarks/reference/dtwsc_dtw/: 214 admissible alignments agree on the
accumulated cost, the normalised cost, and the full index1/index2
warping paths exactly, while 138 inadmissible cases raise where R errors.
warp and ref_too_short, which consume those alignments, are checked
under all six as well.
Six rather than two because of how the method is meant to be used. The authors do not fix the step pattern; they select it per run by grid search over seven, and the choices shipped in their replication archive span six. An implementation carrying only the two their published example happens to use could reproduce 39 percent of their Basque design, 31 percent of the California one and 50 percent of the German one – so the omission was not a matter of completeness but of whether the paper’s own procedure is reachable at all.
One detail was worth getting right rather than approximating. Normalisation is
not uniform across the patterns: the symmetric ones divide the accumulated cost
by the query plus reference length, the asymmetric ones by the query’s, and
mori2006 by the reference’s. Treating that as a two-way choice leaves every
path correct and every normalised distance wrong, which changes nothing
visible until an open-ended alignment picks its endpoint – and that endpoint
decides which windows ref_too_short rules out of the second-phase search.
One bit was load-bearing. An earlier version of this page recorded a residual disagreement in 40 of the 13888 outlier-filter decisions and attributed it to floating-point ties that no implementation could be expected to reproduce. That was wrong, and the way it was wrong is worth keeping.
second_dtw discards outlying speeds with a type-7 \(Q_3 + 3\,
\mathrm{IQR}\) fence. The speeds are small-denominator rationals, so a column
routinely looks like \((1, 1, 7/6, 1)\) – and on such a column the fence
evaluates to exactly \(7/6\), landing on the very value it is judging.
Which side of that comparison the value falls on is then decided by the last
bit of arithmetic, and dropping the cell rather than keeping it moves the
column mean by \(1/24\).
The last bit was ours to get right. R’s stats::filter smooths the speeds by
accumulating \(x_k \cdot (1/w)\) across the window; taking the window’s
mean instead computes \((\sum x_k)/w\), which rounds 7/6 to
1.1666666666666667 where R gets 1.1666666666666665. Everything
downstream inherited that one bit. Matching R’s summation order makes the
first-phase speeds bit-identical and the entire warping engine exact, which is
what the table above now records. A tolerance on the fence does not work, and
was tried: because the disagreements run in both directions, widening the fence
fixes three donors and breaks four.
The general lesson is about the method rather than the port. A statistic whose value turns on which side of an exactly-coincident bound a number falls is fragile by construction, and DSC has one in its inner loop. mlsynth reproduces the reference’s choice here, but a cross-language check of a DSC ATT should not be read as validating the last digit.
A second artefact is inherited from the method rather than the port. Three
donors’ warped series end one period short of 1997, so the reference’s own
counterfactual is NA there and its ATT is really taken over 1971–1996.
mlsynth reproduces that rather than extrapolating over it, and reports the
number of dropped periods in res.metadata["n_post_periods_undefined"].
The one remaining difference#
Every seam above is measured with R’s own filtered panel substituted for mlsynth’s, which is the honest way to test the warping engine on its own. Run end to end on mlsynth’s own preprocessing instead, 5 of the 16 donors pick up a different alignment and the warped series diverge. That difference is the Savitzky–Golay stage, and it is the only one left.
Before differentiating, the reference extends each series at both ends with a
two-period forecast::auto.arima forecast, filters the extended series, and
trims back. mlsynth edge-pads instead. The filters agree to 1e-16 across the
interior of every series and disagree only over the two points at each end –
but a second derivative is small, and at those four points the disagreement
reaches 0.083 against a series whose own scale is 0.090. Two of them sit at the
start of the pre-treatment window the first alignment learns from.
This is recorded rather than fixed, and deliberately. Closing it would take a
bit-exact auto.arima, not a good one. Because the outlier fence described
above lands exactly on the values it is testing, an approximate forecast would
fall on an arbitrary side of it and would not track the reference any more
reliably than edge-padding does – while adding a heavyweight dependency and a
stepwise order search to a preprocessing step. The gap is bounded, measured,
and pinned in the benchmark case as dtwsc_speed_max_abs_diff_vs_r.
Placebo inference#
inference="placebo" implements the paper’s two inferential procedures. Run
on the Basque panel through the public API – outcome-only synthetic control,
fixed warping hyperparameters, 17 pools and 256 placebo runs:
quantity |
mlsynth |
paper |
|---|---|---|
efficiency test |
-6.79 |
-7.91 |
efficiency test |
9.0e-11 |
< 0.0001 |
mean |
-0.226 |
-0.18 (from the reported log MSEs) |
implied MSE reduction |
20 percent |
16 percent |
The direction and rough magnitude reproduce, but read the table as a
comparison of two different procedures rather than a reproduction attempt. The
authors’ replication archive (Dataverse 10.7910/DVN/DIUPUA) shows their
placebo design differs from mlsynth’s built-in one in five ways: they keep
Spain in the donor pool where the estimator page’s example drops it; they use
118 perturbed datasets and 1789 runs against mlsynth’s 17 pools and 256; they
give every run its own grid-optimal filter.width / k / step pattern
where mlsynth holds one configuration fixed; they fit the full 14-predictor
specification on every placebo where mlsynth’s example is outcome-only; and
they map each gap back to the outcome’s own units before squaring it, so their
reported log MSEs are not on the scale mlsynth reports.
An earlier version of this page said the rule that picked those per-run
hyperparameters was absent from the replication package. That was wrong, and
in the useful direction: the archive ships the resulting choices themselves,
per run and per panel, in Figure_5_*_gridOpt.Rds. The selection rule is
indeed not shown, but it is not needed – the choices can be read directly.
So the table above is a comparison of two procedures that happen to measure
the same thing, not a replication of the reported statistic. Reproducing the
published t means adopting their design wholesale – pool, datasets,
per-run hyperparameters, predictors, and units – rather than tightening
anything in mlsynth’s own.
The band does not reproduce, and that is worth stating plainly. On this specification the warped band comes out marginally wider than the unwarped one – 2.436 against 2.296, or 0.3 percent – where the paper reports it narrower.
The two results are not in conflict. The efficiency test is a within-run paired comparison of MSE; the band is the cross-run spread of gaps. Lowering each run’s error need not shrink the dispersion across runs, because the band is a tail quantity.
Two candidate explanations have been tested and ruled out. The first was the
synthetic-control specification: the paper fits Synth over 14 predictors
where the default here matches on the outcome alone, so sc_backend="malo"
with the paper’s predictors ought to have tightened the band. It does the
opposite – the band widens to 2.9 percent and the efficiency test collapses
(\(t = +0.56\), MSE reduction \(-2\) percent). The second was fidelity
of the warp itself, which is now bit-exact against the reference; fixing it
moved the band from 1.3 percent wider to 0.3 percent wider, so it was a real
contributor but not the explanation.
What remains untested is the per-run hyperparameter optimisation, and the pool construction: the paper draws its quantiles over roughly 2000 runs where the figures here use 256, and a 2.5 percent tail quantile is not well determined at that count. Neither has been measured, so neither is claimed.
Treat the efficiency test as the reproducible claim and the band as descriptive.
Cost. The placebo procedure runs one full two-phase warp per donor per pool.
Basque with placebo_pairs=0 is 256 warped fits and takes about 12 minutes;
the paper’s placebo_pairs=100 construction is roughly 2000 fits.