Ridge lambda cross-validation – against augsynth on two panels#
The ridge penalty in Vanilla Synthetic Control (VanillaSC)’s Augmented SCM (augment="ridge") is
selected by leave-one-pre-period-out cross-validation and a 1-SE rule. This page
records that selection being cross-validated against the authors’ own R package,
ebenmichael/augsynth at commit
7a90ea48 (0.2.0), and two defects it uncovered.
A wrong penalty does not announce itself. It is not an error or an implausible number – it is a well-behaved fit at the wrong amount of shrinkage, which then propagates into every effect and every interval built on top of it.
Two panels, one of which cannot see the problem#
panel |
shape |
role |
|---|---|---|
augsynth’s |
89 pre-periods, 49 donors |
negative control – the selected penalty is identical whether the code is right or wrong |
Song et al. PM2.5, “2+26 cities” 2015 |
25 pre-periods, 37 donors |
diagnostic – the final pre-period is a ~9x outlier fold, so the defects move the answer |
The Kansas panel agreed on the selected penalty to fifteen significant figures both before and after the fixes below. Any suite testing only that panel would report the cross-validation as correct while it was not, which is exactly what happened. The second panel was added because its final pre-treatment period sits on the seasonal ramp into the heating season and carries about nine times the held-out error of the other twenty-four, so whether it counts as a fold at all moves the whole curve.
What agreed already#
The lambda grid and the selection rule were faithful and are now pinned.
create_lambda_list builds lambda_max * scaler^k for k in
0..n_lambda, which is \(n_{\lambda}+1\) points rather than
\(n_{\lambda}\) – an easy off-by-one to inherit from R’s
seq(0:n_lambda) - 1. lambda_max is the squared largest singular value of
the centered control matrix, and matches to 1e-13 on the outcome-only
specification. choose_lambda takes the largest lambda whose mean error is
within one standard error of the minimum, with the comparison inclusive.
Two defects, and why one hid the other#
The fold count was one too many. augsynth’s get_lambda_errors loops
i in 1:(ncol(X_c) - holdout_length) and sizes its error matrix to match, so
with a holdout length of one the final pre-period is never held out. mlsynth
iterated one extra fold. On Kansas that is a 1-in-89 perturbation of an average
and changed nothing detectable. On the Song cell the extra fold contributes about
nine times the mean error, which moved the curve by up to 22 and the selected
penalty from augsynth’s 28.5157 to 11.3523 – a factor of 2.5 in shrinkage.
The standard error used the wrong denominator. augsynth reports
sd(x) / sqrt(length(x)) and R’s sd is the sample standard deviation,
while numpy’s .std defaults to the population one. The reported error was
therefore too small by \(\sqrt{(n-1)/n}\). This is not cosmetic: it feeds the
1-SE threshold directly, so it can change which penalty is chosen.
After both fixes, on the outcome-only specification:
quantity |
kansas |
song |
|---|---|---|
lambda grid |
1e-13 |
1e-13 |
CV error curve |
1e-10 (was 5.1e-6) |
1e-6 (was 22.2) |
CV standard error |
1e-10 |
2.5e-7 |
selected penalty |
exact |
exact (was 11.35 vs 28.52) |
What the fix exposed#
Fixing the cross-validation made the covariate cells of Ridge ASCM — Augmented Synthetic Control (Ben-Michael, Feller & Rothstein 2021) disagree more, from about 0.002 to 0.005 on the ATT. That is worth stating plainly, because the surface reading is the opposite: it was a cancellation being removed, not a regression.
Two independent errors had been partly offsetting each other. The fold off-by-one
was one; the other was in how the benchmark aggregated auxiliary covariates to one
value per unit. augsynth omits missing values column-wise
(mean(x, na.rm = TRUE) per covariate, with na.action = NULL passed to
model.frame); the harness was omitting them row-wise, discarding every quarter
in which either of the two annually reported revenue series was missing. On the
Kansas panel that is 56 of 89 pre-treatment quarters, thrown away from all six
covariates rather than from the two that are sparse, which put lambda_max at
128.4583 against augsynth’s 128.6077. The wrong fold count happened to select a
point on that already-wrong grid that landed nearer augsynth’s answer.
With both corrected, the covariate specification is exact: ATT -0.060937 and
pre-fit L2 0.053855 against augsynth’s own values, matching the two outcome-only
specifications. The aggregation rule now lives in aggregate_covariates in
benchmarks/cases/ascm_kansas.py, and TestCovariateAggregation in
mlsynth/tests/test_bilevel_ridge.py pins it – including how far the row-wise
rule would move the four fully observed covariates, so the two rules cannot be
confused again silently.
The residualized specification still differs, by about 0.004. That one is deliberate and documented on Ridge ASCM — Augmented Synthetic Control (Ben-Michael, Feller & Rothstein 2021): augsynth’s residual lambda-CV is ill-posed once K covariates are projected out, so mlsynth tunes that penalty on the outcome scale instead.
Verification#
Pinned in mlsynth/tests/test_ascm_ridge_cv.py (16 tests), which asserts the
grid, the curve, the standard error and the selected penalty on both panels, and
separately pins the fold count and the fact that the final pre-period is never
held out so the off-by-one cannot silently return. Gold is generated by
benchmarks/reference/ascm_ridge_cv/reference.R
and committed, so the tests need no R toolchain.