Skip to content

Commit 08957d6

Browse files
igerberclaude
andcommitted
synthetic-control: address CI codex R6 — exclude truncated inner solves from V argmin (P1)
Strengthen the prior fix: a non-converged inner Frank-Wolfe solve during the nested V search is now EXCLUDED from V ranking (not merely warned above a 5% rate) — in an argmin search even one truncated W*(V) could win and silently flip the selected V. The objective returns a large FINITE penalty (10×(max single-donor vertex MSPE)+1, which dominates any feasible objective value since the objective is convex in w → max at a simplex vertex) so that candidate can never be chosen; a non-converged univariate-start solve gets inf MSPE (→ zero heuristic weight). A finite penalty is used instead of np.inf because inf floods scipy's Nelder-Mead/Powell simplex arithmetic with RuntimeWarnings (and makes it churn). Warn on ANY non-zero non-convergence count (no rate threshold). Healthy fits unaffected (Basque Tier-2 unchanged; warning count back to baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7c361ee commit 08957d6

1 file changed

Lines changed: 25 additions & 8 deletions

File tree

diff_diff/synthetic_control.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -990,8 +990,12 @@ def _add_unique(t: Optional[np.ndarray], pool: List[np.ndarray]) -> None:
990990
w_i, conv_i = _inner_solve_W(X1s, X0s, e, inner_max_iter, inner_min_decrease)
991991
inner_total += 1
992992
if not conv_i:
993+
# Don't trust a truncated solve: inf -> 0 inverse-MSPE weight, so this
994+
# predictor doesn't shape the heuristic start.
993995
inner_nonconv += 1
994-
uni_mspe[i] = float(np.mean((Z1 - Z0 @ w_i) ** 2))
996+
uni_mspe[i] = np.inf
997+
else:
998+
uni_mspe[i] = float(np.mean((Z1 - Z0 @ w_i) ** 2))
995999
inv_mspe = np.where(uni_mspe > 0, 1.0 / np.maximum(uni_mspe, 1e-12), 0.0)
9961000
if np.sum(inv_mspe) > 0:
9971001
_add_unique(_to_theta(inv_mspe / np.sum(inv_mspe)), candidates)
@@ -1035,12 +1039,24 @@ def _outer_solve_V(
10351039
# suppresses its own per-call warning during the search; we aggregate here.
10361040
_st = {"total": 0, "nonconv": 0}
10371041

1042+
# Finite penalty for a non-converged evaluation: the objective is convex in w, so its
1043+
# maximum over the simplex is attained at a single-donor vertex. Penalizing above that
1044+
# bound guarantees a truncated W*(V) can never win the argmin, while staying FINITE
1045+
# (np.inf would flood scipy's simplex arithmetic with RuntimeWarnings).
1046+
_vertex_mspe = [float(np.mean((Z1 - Z0[:, j]) ** 2)) for j in range(Z0.shape[1])]
1047+
_penalty = 10.0 * (max(_vertex_mspe) + 1.0) if _vertex_mspe else 1.0
1048+
10381049
def objective(theta: np.ndarray) -> float:
10391050
v = _softmax(theta)
10401051
w, conv = _inner_solve_W(X1s, X0s, v, inner_max_iter, inner_min_decrease)
10411052
_st["total"] += 1
10421053
if not conv:
1054+
# A truncated W*(V) is unusable for V ranking: in an argmin search even a
1055+
# single non-converged evaluation could win and silently flip the selected V.
1056+
# Penalize above the feasible objective bound so it can never be chosen (and
1057+
# is tallied for the aggregated warning below).
10431058
_st["nonconv"] += 1
1059+
return _penalty
10441060
return float(np.mean((Z1 - Z0 @ w) ** 2))
10451061

10461062
nm_options = {"maxiter": 1000, "xatol": 1e-8, "fatol": 1e-8}
@@ -1091,16 +1107,17 @@ def objective(theta: np.ndarray) -> float:
10911107
)
10921108

10931109
# Aggregate intermediate inner Frank-Wolfe non-convergence across the whole nested
1094-
# search (univariate starts + every objective evaluation). Per-call FW warnings are
1095-
# suppressed during the search, so without this the outer optimizer could silently
1096-
# rank truncated W*(V) solves. Threshold mirrors synthetic_did.py's 5% rule.
1097-
if _st["nonconv"] > 0.05 * max(_st["total"], 1):
1110+
# search (univariate starts + every objective evaluation). Non-converged objective
1111+
# evaluations were excluded from V ranking (returned as +inf); warn on ANY such
1112+
# occurrence — unlike a bootstrap summary, an argmin search is sensitive to even one
1113+
# truncated solve, so no rate threshold is appropriate here.
1114+
if _st["nonconv"] > 0:
10981115
warnings.warn(
10991116
f"Inner Frank-Wolfe did not converge on {_st['nonconv']} of {_st['total']} "
11001117
f"weight solves during nested V selection (inner_max_iter={inner_max_iter}); "
1101-
"the outer search may have ranked truncated W*(V) solutions, so the selected "
1102-
"V / donor weights / ATT may be sub-optimal. Increase inner_max_iter or relax "
1103-
"inner_min_decrease.",
1118+
"those evaluations were excluded from V ranking, but the search space was "
1119+
"effectively restricted, so the selected V / donor weights / ATT may be "
1120+
"sub-optimal. Increase inner_max_iter or relax inner_min_decrease.",
11041121
UserWarning,
11051122
stacklevel=3,
11061123
)

0 commit comments

Comments
 (0)