Description
SyntheticControl solves for its donor weights, min_w ||x1 - X0 w||^2 over the simplex (with V folded in), by Frank-Wolfe. At the default inner_max_iter=10000, that solve often stops on its budget rather than its min-decrease rule on ordinary factor-model panels. In the reproducer's three designs, the placebo test failed closed or lost placebos on 40 of 40, 40 of 40 and 33 of 40 panels. Three things follow:
- the treated fit warns "did not converge", and
in_space_placebo() fails closed (placebo_p_value NaN);
- otherwise, placebos whose refit hit the budget are dropped from the reference set. Which units drop depends on how hard they are to fit, so the rank is taken against a selected subset rather than all units;
- in the default nested V search, evaluations that hit the budget are excluded from V ranking.
The budget flag does not separate good fits from bad ones. The inner problem has an exact finite solution (below). Measured against it, fits flagged at 10 000 steps were about as close to the optimum as unflagged ones. Twenty times the budget clears every flag, yet it barely moves the flagged fits and does not reach the optimum.
Minimal reproducer
V is held uniform (v_method="custom", standardize="none"), so only the inner solve is exercised. There are 40 panels per design.
"""diff-diff 3.12.0: SyntheticControl's Frank-Wolfe weight solve, at the default inner_max_iter,
exhausts its budget on a sizeable share of ordinary panels; the treated fit then fails closed
(placebo p-value NaN) or placebos are dropped as failed.
V is held uniform (v_method="custom", standardize="none") so only the inner simplex solve
min_w ||x1 - X0 w||^2 is exercised. Its exact optimum comes from one NNLS:
u = argmin_{u >= 0} || [X0 - x1 1'; 1'] u - [0; 1] ||, w = u / sum(u).
For u = s w the objective is s^2 a + (s - 1)^2 with a = ||X0 w - x1||^2; it is least at
s = 1 / (1 + a), where it equals a / (1 + a), increasing in a.
"""
import warnings
import numpy as np
import pandas as pd
from diff_diff import SyntheticControl
from scipy.optimize import nnls
def panel(rng, units, pre, post):
"""Unit level + three AR(1) factors with uniform loadings + AR(1) errors."""
periods = pre + post
factors = np.empty((periods, 3))
errors = np.empty((units, periods))
factors[0] = rng.normal(size=3) / np.sqrt(0.75)
errors[:, 0] = rng.normal(size=units) / np.sqrt(0.75)
for t in range(1, periods):
factors[t] = 0.5 * factors[t - 1] + rng.normal(size=3)
errors[:, t] = 0.5 * errors[:, t - 1] + rng.normal(size=units)
return rng.normal(size=(units, 1)) + rng.uniform(0, 1, (units, 3)) @ factors.T + errors
def long_frame(y, pre):
n, periods = y.shape
d = np.zeros((n, periods), dtype=int)
d[0, pre:] = 1
return pd.DataFrame({"unit": np.repeat(np.arange(n), periods), "time": np.tile(np.arange(periods), n),
"y": y.ravel(), "d": d.ravel()})
def exact_pre_rmspe(y, pre):
x1, x0 = y[0, :pre], y[1:, :pre].T
z = x0 - x1[:, None]
scale = np.sqrt(np.mean(z**2)) # keeps the two blocks comparable
u, _ = nnls(np.vstack([z / scale, np.ones(z.shape[1])]), np.r_[np.zeros(pre), 1.0], maxiter=5000)
w = u / u.sum()
return float(np.sqrt(np.mean((x1 - x0 @ w) ** 2)))
for inner_max_iter in (10_000, 200_000):
for units, pre, post in ((20, 20, 5), (40, 20, 5), (20, 40, 5)):
warned = nan_p = dropped = either = 0
excess = []
for seed in range(40):
y = panel(np.random.default_rng([units, pre, seed, 7]), units, pre, post)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
fit = SyntheticControl(v_method="custom", custom_v=np.ones(pre), standardize="none",
inner_max_iter=inner_max_iter, seed=0).fit(
long_frame(y, pre), outcome="y", treatment="d", unit="unit", time="time",
pre_period_outcomes="all")
fit.in_space_placebo()
warned += any("did not converge" in str(w.message) for w in caught)
nan_p += not np.isfinite(fit.placebo_p_value)
dropped += int(fit.n_failed or 0) > 0
either += (not np.isfinite(fit.placebo_p_value)) or int(fit.n_failed or 0) > 0
excess.append(fit.pre_rmspe / exact_pre_rmspe(y, pre) - 1.0)
print(f"inner_max_iter={inner_max_iter}, {units - 1} donors, {pre} pre-periods, 40 panels: "
f"'did not converge' warning on {warned}, placebo_p_value NaN on {nan_p}, "
f"placebos dropped as failed on {dropped}, either on {either}; treated pre-RMSPE above the exact optimum "
f"by median {np.median(excess):.1e}, max {np.max(excess):.1e} (relative)", flush=True)
Expected behavior
On ordinary panels, the weight solve should reach its optimum at the default settings, so that the placebo test keeps its full reference set.
Actual behavior
inner_max_iter=10000, 19 donors, 20 pre-periods, 40 panels: 'did not converge' warning on 10, placebo_p_value NaN on 10, placebos dropped as failed on 30, either on 40; treated pre-RMSPE above the exact optimum by median 1.2e-04, max 5.7e-04 (relative)
inner_max_iter=10000, 39 donors, 20 pre-periods, 40 panels: 'did not converge' warning on 15, placebo_p_value NaN on 15, placebos dropped as failed on 25, either on 40; treated pre-RMSPE above the exact optimum by median 1.9e-04, max 8.4e-04 (relative)
inner_max_iter=10000, 19 donors, 40 pre-periods, 40 panels: 'did not converge' warning on 3, placebo_p_value NaN on 3, placebos dropped as failed on 30, either on 33; treated pre-RMSPE above the exact optimum by median 1.2e-04, max 2.2e-04 (relative)
inner_max_iter=200000, 19 donors, 20 pre-periods, 40 panels: 'did not converge' warning on 0, placebo_p_value NaN on 0, placebos dropped as failed on 0, either on 0; treated pre-RMSPE above the exact optimum by median 1.1e-04, max 4.6e-04 (relative)
inner_max_iter=200000, 39 donors, 20 pre-periods, 40 panels: 'did not converge' warning on 0, placebo_p_value NaN on 0, placebos dropped as failed on 0, either on 0; treated pre-RMSPE above the exact optimum by median 1.6e-04, max 8.1e-04 (relative)
inner_max_iter=200000, 19 donors, 40 pre-periods, 40 panels: 'did not converge' warning on 0, placebo_p_value NaN on 0, placebos dropped as failed on 0, either on 0; treated pre-RMSPE above the exact optimum by median 1.2e-04, max 2.2e-04 (relative)
Split by the flag, for the treated fit at 10 000 steps:
- 19 donors / 20 pre-periods: the flagged fits were a median 1.2e-4 above the exact optimum, the rest 1.2e-4. At 200 000 steps the flagged fits were 8.2e-5 above it.
- 39 donors: flagged 2.5e-4, the rest 1.6e-4; flagged at 200 000 steps, 1.7e-4.
- 19 donors / 40 pre-periods: flagged 1.4e-4, the rest 1.2e-4; flagged at 200 000 steps, 1.2e-4.
At the full defaults (v_method="nested", standardize="std"), the same inner solve runs at every V the search tries. Evaluations that hit the budget are excluded from V ranking, with the warning "Inner Frank-Wolfe did not converge on X of Y weight solves during nested V selection". I have not yet measured how often that happens on these panels, because each nested fit is slow. I will add the numbers here once the run finishes.
Proposed fix
With intercept=False, zeta=0, which is SyntheticControl's inner problem, the simplex-constrained least squares reduces exactly to one non-negative least squares:
Z = V^½ (X0 - x1 1') # columns: donor minus treated
u = argmin_{u >= 0} || [Z / s; 1'] u - [0; 1] ||^2 # s > 0 any scale, e.g. RMS of Z
w = u / sum(u)
The reduction works as follows. Write u = c w with w on the simplex and c = 1'u. The objective is c^2 a / s^2 + (c - 1)^2 with a = ||Z w||^2 = ||V^½ (X0 w - x1)||^2. Its minimum over c is a / (a + s^2), which increases with a. So the minimiser's w is the simplex least-squares solution, whatever s is. s only conditions the problem.
Lawson-Hanson's active set (scipy.optimize.nnls) terminates in finitely many steps. So there is no budget to tune, no convergence flag, and no residual gap. It is what the reproducer uses as exact_pre_rmspe.
SyntheticDiD's regularised and intercepted Frank-Wolfe, which matches R's synthdid, would be untouched. If Frank-Wolfe is preferred for SyntheticControl too, away-step or pairwise Frank-Wolfe (Lacoste-Julien & Jaggi 2015) converge linearly over a polytope. Classic Frank-Wolfe converges sublinearly when the optimum lies on a face of the simplex, which is the usual case for synthetic control weights.
Version
diff-diff 3.12.0 (PyPI wheel, Rust backend), Python 3.14.7, Linux.
Description
SyntheticControlsolves for its donor weights,min_w ||x1 - X0 w||^2over the simplex (with V folded in), by Frank-Wolfe. At the defaultinner_max_iter=10000, that solve often stops on its budget rather than its min-decrease rule on ordinary factor-model panels. In the reproducer's three designs, the placebo test failed closed or lost placebos on 40 of 40, 40 of 40 and 33 of 40 panels. Three things follow:in_space_placebo()fails closed (placebo_p_valueNaN);The budget flag does not separate good fits from bad ones. The inner problem has an exact finite solution (below). Measured against it, fits flagged at 10 000 steps were about as close to the optimum as unflagged ones. Twenty times the budget clears every flag, yet it barely moves the flagged fits and does not reach the optimum.
Minimal reproducer
V is held uniform (
v_method="custom",standardize="none"), so only the inner solve is exercised. There are 40 panels per design.Expected behavior
On ordinary panels, the weight solve should reach its optimum at the default settings, so that the placebo test keeps its full reference set.
Actual behavior
Split by the flag, for the treated fit at 10 000 steps:
At the full defaults (
v_method="nested",standardize="std"), the same inner solve runs at every V the search tries. Evaluations that hit the budget are excluded from V ranking, with the warning "Inner Frank-Wolfe did not converge on X of Y weight solves during nested V selection". I have not yet measured how often that happens on these panels, because each nested fit is slow. I will add the numbers here once the run finishes.Proposed fix
With
intercept=False, zeta=0, which is SyntheticControl's inner problem, the simplex-constrained least squares reduces exactly to one non-negative least squares:The reduction works as follows. Write
u = c wwithwon the simplex andc = 1'u. The objective isc^2 a / s^2 + (c - 1)^2witha = ||Z w||^2 = ||V^½ (X0 w - x1)||^2. Its minimum overcisa / (a + s^2), which increases witha. So the minimiser'swis the simplex least-squares solution, whateversis.sonly conditions the problem.Lawson-Hanson's active set (
scipy.optimize.nnls) terminates in finitely many steps. So there is no budget to tune, no convergence flag, and no residual gap. It is what the reproducer uses asexact_pre_rmspe.SyntheticDiD's regularised and intercepted Frank-Wolfe, which matches R's
synthdid, would be untouched. If Frank-Wolfe is preferred for SyntheticControl too, away-step or pairwise Frank-Wolfe (Lacoste-Julien & Jaggi 2015) converge linearly over a polytope. Classic Frank-Wolfe converges sublinearly when the optimum lies on a face of the simplex, which is the usual case for synthetic control weights.Version
diff-diff 3.12.0 (PyPI wheel, Rust backend), Python 3.14.7, Linux.