DSC against the DiSCos R package (Dube minimum wage)#
- Estimator:
Distributional Synthetic Control (DSC) –
mlsynth.DSC- Source:
Gunsilius, F. (2023), “Distributional Synthetic Controls,” Econometrica 91(3):1105-1117, as implemented by the authors’ R package Davidvandijcke/DiSCos 0.1.4.
- Replication type:
cross-validation against the reference implementation.
- Status:
verified – mlsynth sits closer to the reference’s centre than the reference sits to itself, in both feasible sets. Maximum donor-weight gap 0.0079 under the simplex and 0.0103 without it, against the reference’s own across-seed standard deviations of 0.0160 and 0.0300.
- Benchmark:
benchmarks/cases/dsc_disco_xval.py(source).
Why this case took so long to exist#
DSC is mlsynth’s port of an estimator with a published reference
implementation, so a cross-validation against that implementation should have
been the first check written. It was not, and the reason is in the reference:
DiSCo_weights_reg draws its quadrature points with runif, so the fitted
weights are a Monte Carlo estimate. On this panel at the package’s default
settings they move by more than the quantity under test, and
Distributional SC — the disco Stata Journal published results put the problem plainly – the package “disagrees with
itself across seeds by up to 0.119, where mlsynth sits 0.044 from any one of
them”, so no tolerance against a single R run means anything.
That argument rules out one comparison, not all of them. What is unstable is an
individual run; the mean over runs is not. This case fixes M = 10,000, takes
40 seeded reference runs, and scores mlsynth against their mean, reading the
across-seed standard deviation as the yardstick. The question it asks is not
whether the two agree exactly, which they cannot, but whether mlsynth sits
nearer the reference’s centre than a single reference run does.
Result#
Mode |
max abs weight gap |
mean abs gap |
reference seed sd |
gap / sd |
|---|---|---|---|---|
simplex |
0.0079 |
0.0022 |
0.0160 |
0.50 |
sum-to-one |
0.0103 |
0.0042 |
0.0300 |
0.34 |
Correlation across the 33 donor weights is 0.9958 and 0.9983. Both ratios sit below one, which is the claim: the distance between the two implementations is smaller than the distance between the reference and itself.
Both feasible sets#
The two implementations do not default to the same one, which is why the table has two rows.
mlsynth constrains the weights to the simplex, the set \(\mathcal H\) of
Zhang, Zhang & Zhang (2026): non-negative and summing to one. DiSCos passes
lb = NULL unless simplex = TRUE, so its default is sum-to-one with an
upper bound of one and negatives allowed. mlsynth reaches that second set
through weight_constraint="sum_to_one", and the agreement there is the
closer of the two once the reference’s larger seed noise is accounted for.
The relaxed fit is genuinely a different answer, not a re-parameterisation of the same one: it puts weight down to \(-0.176\) on one donor, extrapolating outside the donors’ convex hull, and the reference does the same to \(-0.171\).
What this settles#
Issue #304 recorded mlsynth and DiSCos disagreeing on donor weights by up to
0.074 on this same panel, with the two reaching pre-period objective values 4
percent apart. dsc_dube treats that as the reason its rows cannot be
cross-validated: “pinning agreement before the disagreement is understood would
pin the wrong thing.”
The disagreement was a measurement artifact – one seed, at the package’s default draw count. Raise \(M\) and average the seed noise away, and it is 0.0079.
Two other results reach the same conclusion from different directions, and together they close the question:
Distributional SC — the disco Stata Journal published results reproduces the deterministic Stata implementation’s published weights bit-for-bit, so mlsynth’s fixed grid is not the outlier among the three implementations.
Handed bit-identical design matrices, mlsynth’s solver and the reference’s
pracma::lsqlinconreturn weights agreeing to 3e-9 (benchmarks/reference/dsc_mc/), so the weight solve was never a candidate explanation.
What remained was the quadrature rule, and that is what this case measures.
A caution on how to read a stochastic reference#
The natural way to state agreement with a noisy reference is in standard-error units, and here it does not work. The simplex solution is sparse: most donors carry exactly zero weight in all 40 seeds, so their across-seed standard deviation is zero and any floating-point difference divides by nothing. A per-donor standard-error statistic reports a worst case of 1,586 on a comparison that is, in weight units, agreement to 0.008. The case therefore states the gap in absolute weight units against the reference’s largest seed standard deviation, which is well defined whether or not a given donor is in the support.
Reproducing it#
The committed dump means the case itself needs no R:
python benchmarks/run_benchmarks.py --case dsc_disco_xval
Regenerating the reference does need it (bash benchmarks/R/install_discos.sh,
15-25 minutes cold), and takes about 130 seconds for the two modes:
python benchmarks/reference/dsc_disco_xval/export_panel.py
Rscript benchmarks/reference/dsc_disco_xval/reference.R
python benchmarks/reference/export_comparison.py dsc_disco_xval
python benchmarks/reference/build_validation.py
The panel is exported to CSV first because the reference cannot read parquet; that CSV is gitignored, being a 19 MB re-encoding of a 2 MB file the repository already carries.