Skip to content

fix(synthetic_control): put the treated unit in every in-space placebo's donor pool - #841

Merged
igerber merged 5 commits into
igerber:mainfrom
ilgrad:fix/in-space-placebo-donor-pool
Oct 15, 2026
Merged

igerber merged 5 commits into
igerber:mainfrom
ilgrad:fix/in-space-placebo-donor-pool

Conversation

@ilgrad

@ilgrad ilgrad commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #837.

Problem

SyntheticControlResults.in_space_placebo() fits each placebo against the other J − 1 donors and never puts the real treated unit in a placebo's donor pool, while the treated unit's own synthetic control uses all J donors. Unit j's RMSPE ratio then depends on which unit carries the treatment label, so the J + 1 ratios are not one fixed set that random assignment permutes, and the permutation p-value is not exact. On the #837 panel (10 units, no effect) 2 of the 10 assignments give p = 0.1 and 3 give p ≤ 0.2. ADH 2010 build every placebo from all the other units (§3.4, journal p. 501): "In each iteration we reassign in our data the tobacco control intervention to one of the 38 control states, shifting California to the donor pool." Firpo & Possebom (2018, fn 10) keep the treated unit in the placebo pools too. test_sharp_null() and confidence_set() re-rank the same placebo gaps, so they inherit the construction.

Reproducer

main 6874b5d, Python 3.14.7, numpy 2.5.3, pandas 3.0.6, scipy 1.18.1, pure-Python backend (DIFF_DIFF_BACKEND=python). The script is the one in #837, with two lines added at the end to print the per-unit p-values.

Script
import warnings

import numpy as np
import pandas as pd
from diff_diff import SyntheticControl

N_PRE, N_POST, UNITS = 24, 6, 10


def panel(seed):
    rng = np.random.default_rng(seed)
    periods = N_PRE + N_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)
    loadings = rng.uniform(0.0, 1.0, (UNITS, 3))
    return loadings @ factors.T + 0.5 * errors + rng.normal(size=(UNITS, 1))


def fit(y, treated):
    d = np.zeros(y.shape, dtype=int)
    d[treated, N_PRE:] = 1
    frame = pd.DataFrame({"unit": np.repeat(np.arange(UNITS), y.shape[1]),
                          "time": np.tile(np.arange(y.shape[1]), UNITS), "y": y.ravel(), "d": d.ravel()})
    # Uniform V and a generous Frank-Wolfe budget, so every fit converges and only the
    # placebos' donor pools differ between the two constructions.
    return SyntheticControl(v_method="custom", custom_v=np.ones(N_PRE), standardize="none",
                            inner_max_iter=200_000, seed=0).fit(
        frame, outcome="y", treatment="d", unit="unit", time="time", pre_period_outcomes="all")


y = panel(0)
with warnings.catch_warnings():
    warnings.simplefilter("ignore")  # poor-pre-fit notices on some units; not the point here
    fits = [fit(y, unit) for unit in range(UNITS)]
    for f in fits:
        f.in_space_placebo()
assert all(f.n_failed == 0 for f in fits)

p_in_space = np.array([f.placebo_p_value for f in fits])
# ADH's construction from diff-diff's own fits: fits[u] is unit u's synthetic control from all the others.
ratio = np.array([f.rmspe_ratio for f in fits])
p_all_others = np.array([np.mean(ratio >= ratio[unit]) for unit in range(UNITS)])
print("in_space_placebo():         ", np.round(np.sort(p_in_space), 2))
print("each unit from all others:  ", np.round(np.sort(p_all_others), 2))
for k in range(1, UNITS + 1):
    count = int(np.sum(p_in_space <= k / UNITS + 1e-12))
    if count > k:
        print(f"in_space_placebo(): {count} of the {UNITS} assignments give p <= {k}/{UNITS}")
print("per-unit in_space_placebo p:", p_in_space.tolist())
print("per-unit all-others p:      ", p_all_others.tolist())

On main:

in_space_placebo():          [0.1 0.1 0.2 0.5 0.5 0.6 0.7 0.8 0.9 1. ]
each unit from all others:   [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]
in_space_placebo(): 2 of the 10 assignments give p <= 1/10
in_space_placebo(): 3 of the 10 assignments give p <= 2/10
per-unit in_space_placebo p: [0.2, 0.6, 0.1, 0.5, 0.5, 1.0, 0.8, 0.9, 0.7, 0.1]
per-unit all-others p:       [0.3, 0.7, 0.1, 0.5, 0.4, 1.0, 0.8, 0.9, 0.6, 0.2]

On this branch:

in_space_placebo():          [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]
each unit from all others:   [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]
per-unit in_space_placebo p: [0.3, 0.7, 0.1, 0.5, 0.4, 1.0, 0.8, 0.9, 0.6, 0.2]
per-unit all-others p:       [0.3, 0.7, 0.1, 0.5, 0.4, 1.0, 0.8, 0.9, 0.6, 0.2]

Each placebo refit is now the fit that treats that unit, so in_space_placebo() reproduces the all-others p-values exactly. Five of the ten assignments move by one rank (three up, two down).

Cause

diff_diff/synthetic_control_results.py:1030 on main, in in_space_placebo(): pool = [d for d in donors if d != j], where donors = snap.donor_ids never contains the treated unit. The docstring and the REGISTRY Note gave two reasons: the treated unit's post period is treatment-contaminated, and the construction matches SCtools::generate.placebos. The first holds only under the alternative. Under the null the p-value is calibrated for, the treated unit's outcome is an untreated outcome like any other. Exactness needs each unit's statistic to be the same function of the data whichever unit is labelled treated, and leaving the label's unit out of every other pool breaks exactly that.

Fix

  • in_space_placebo(): placebo j's pool is every unit except j, the treated unit included, in the outcome pivot's sorted unit order. A unit's refit therefore sees the same pool in the same order whichever unit is treated, and with the default donor pool it is the same computation as a fresh fit() that treats that unit (the regression test checks the ratios agree to rtol=1e-12).
  • _placebo_fit_unit() (private) also returns the donor weights. leave_one_out() and in_time_placebo() ignore them.
  • test_sharp_null() / confidence_set(): under H_0^f the treated unit's untreated outcome is Y_1 − f, so placebo j's synthetic control moves by −w_j1·f, where w_j1 is its weight on the treated unit (Firpo & Possebom fn 10: "under sharp null hypotheses, the treatment effect is known"). in_space_placebo() keeps each placebo's w_j1; _sharp_null_pvalue() and _invert_sharp_null() take them as a keyword-only treated_weights and use the placebo residual g_j − (1 − w_j1)·f. The breakpoint quadratics get the matching (1 − w_j1)²·S2 curvature. With w_j1 = 0 this is the existing Eq 12 shift, so the existing helper tests are unchanged. The weights depend on pre-period data only, so this needs no refits; a brute-force test refits every placebo on the null-imputed panel and compares.
  • Messages that described the old pool: the J == 2 warning (each placebo is now fit against 2 units, with a weight solve and a V search), and the J < 2 reasons in the warning, summary(), _require_placebo_reference() and DiagnosticReport (with one donor, the treated unit and the donor are each other's synthetic control; this case still returns NaN).
  • Docs: the in_space_placebo() / test_sharp_null() / helper docstrings; REGISTRY §SyntheticControl (the donor-pool Note now cites ADH §3.4 p. 501, a new **Deviation from R (SCtools::generate.placebos):** entry, the test-inversion paragraph, the reference-implementation line, the checklist, and the validation Note); guides/llms-full.txt; one sentence in the Firpo-Possebom paper review; a changelog fragment.

Compatibility

  • Results. placebo_p_value, the placebo rows of get_placebo_df(), test_sharp_null() and confidence_set() change on every fit where some placebo's synthetic control puts weight on the treated unit. att, the donor weights, the treated unit's rmspe_ratio, leave_one_out() and in_time_placebo() do not change.
  • Cost. Each placebo's weight solve has J donors instead of J − 1.
  • J == 2. Each placebo is fit against 2 units, with a weight solve and a V search, instead of a forced w = [1]. With fewer than 2 donors the p-value is still NaN.
  • API. No public signature changes. Private helpers only: _placebo_fit_unit() also returns the weights, and _sharp_null_pvalue() / _invert_sharp_null() take a keyword-only treated_weights whose default None keeps the old shift.
  • R parity. The placebo pools no longer match SCtools::generate.placebos; REGISTRY records it as a **Deviation from R** entry.

Methodology references (required if estimator / math changes)

  • Method name(s): Abadie, Diamond & Hainmueller (2010) in-space placebo permutation inference; Firpo & Possebom (2018) sharp-null test inversion.
  • Paper / source link(s): https://doi.org/10.1198/jasa.2009.ap08746 (§3.4, journal p. 501), https://doi.org/10.1515/jci-2016-0026 (fn 10, Eqs 11–14)
  • Any intentional deviations from the source (and why): none from the papers; this removes one. The deviation is now from SCtools::generate.placebos, which drops the treated unit from the placebo pools; its reference set changes with the treated unit, so its p-value is not exact. Documented as a REGISTRY **Deviation from R** entry.

Validation

  • Tests added/updated, in tests/test_methodology_synthetic_control.py:
    • test_in_space_placebo_puts_real_treated_in_every_donor_pool: each placebo's pool is every unit but itself.
    • test_in_space_placebo_is_exact_permutation: on a 10-unit no-effect panel the ten assignments give p = 0.1, 0.2, ..., 1.0 once each, and each placebo's ratio equals that unit's own fit() to rtol=1e-12.
    • test_sharp_null_pvalue_imputes_treated_in_placebo_pools and test_invert_with_treated_weights_matches_direct_pvalues: the (1 − w_j1) shift in the p-value and in the inversion's breakpoints, against direct p-values.
    • test_test_sharp_null_matches_refits_on_null_imputed_panel: brute force. It refits every placebo on the panel whose treated post outcomes are replaced by Y_1 − f, and compares with test_sharp_null().
    • Updated: test_leave_one_out_uniform_shift_surfaced_by_delta_not_range (its stub of _placebo_fit_unit returns the weights too) and test_cv_in_space_placebo_excludes_donor_flat_refits (the treated unit now takes d1–d3's values in the validation window, so d0's placebo pool, which now holds the treated unit, stays donor-indistinguishable as the test intends).
  • Fail before, pass after. On main 6874b5d the two behavioural tests fail on their assertions: the pools lack treated, and the p-values are [0.2, 0.2, 0.2, 0.5, 0.5, 0.6, 0.7, 0.8, 1, 1]. The three sharp-null tests and the leave-one-out stub fail on the new private interface. The cv fixture test passes on both. All seven pass on this branch.
  • Mutations. Each of six fails at least one test: the p-value ignoring w_j1 (3 tests fail), the inversion ignoring it (1), the p-value's curvature without (1 − w_j1)² (4), the breakpoints' curvature without it (1), the old pool (40), the treated unit first in the pool's order (1).
  • Simulation evidence: three AR(1) factors with U(0, 1) loadings, AR(1) noise, unit levels, 20 pre and 5 post periods, uniform custom V.
    • Size, 300 no-effect panels of 10 units, each unit treated in turn, so the treated draw is integrated out exactly:

      p ≤ 0.1 p ≤ 0.2
      main 11.70% (MC SE 0.25 points); 2 of 10 units on 57 panels, none on 6 21.73% (SE 0.31); 3 or 4 of 10 on 71 panels, 1 on 20
      this branch 10% on every panel 20% on every panel
    • Placebo refits against fresh fits: on 40 panels, and on every panel whose drawn treated unit the branch rejects at 10% (342 panels of 10 units, 182 of 20), each placebo's ratio equals that unit's own fit() exactly (relative difference 0.0), and the stored p-value equals the p-value computed from the fresh fits.

    • The default nested V search: a placebo refit calls the same _outer_solve_V with the same seed and n_starts as a fresh fit, over the same units in the same order. On a panel of 5 units, 8 pre and 2 post periods (seed=3), each placebo's ratio equals that unit's own fit() exactly (relative difference 0.0), no placebo failed, and the p-value, 0.8, equals the share of the five fresh fits' ratios at or above the treated unit's.

  • Commands: Python 3.14.7, numpy 2.5.3, pandas 3.0.6, scipy 1.18.1, DIFF_DIFF_BACKEND=python.
    • pytest -n 2 on test_methodology_synthetic_control.py, test_t25_synthetic_control_policy_drift.py, test_diagnostic_report.py, test_business_report.py, test_practitioner.py, test_changelog_fragments.py, test_guides.py and test_doc_deps_integrity.py: 1158 passed, 1 skipped.
    • The slow test_confidence_set_coverage_simulation: passed. test_naming_guard.py, test_docs_ia.py, test_dml_carousel_claims.py and the cv fixture test: 59 passed.
    • ruff check diff_diff tests (0.16.7) and black --check diff_diff tests (26.5.1): clean. mypy diff_diff (2.3.1, with numpy 2.4.5, pandas 3.0.3, scipy 1.17.1, as in lint.yml): no issues.
    • Not run: the rest of the suite, the Rust backend, the notebooks.

Security / privacy

  • Confirm no secrets/PII in this PR: confirmed. Code, tests and docs only; the simulations use synthetic data.

Changelog

ilgrad and others added 5 commits October 14, 2026 17:37
…o's donor pool

in_space_placebo() fit each placebo against the other J - 1 donors and never
put the real treated unit in a placebo's pool, while the treated unit's own
synthetic control uses all J donors. Unit j's RMSPE ratio then depended on
which unit carried the treatment label, so the J + 1 ratios were not one set
that random assignment permutes, and the permutation p-value was not exact.
Abadie, Diamond & Hainmueller (2010, section 3.4) shift the treated unit into
the donor pool. Each placebo now fits against every other unit, and
test_sharp_null() / confidence_set() use the treated unit's null-imputed
outcome Y_1 - f inside each placebo's synthetic control (Firpo & Possebom
2018, fn 10), weighted by the placebo's weight on it.

Fixes igerber#837
…DH and qualify placebo exactness

Firpo & Possebom (2018) footnote 10 characterizes ADH 2010/2015 as excluding the
treated unit from the placebo pools, which is at odds with the ADH 2010 sentence
the donor-pool Note quotes; record both readings and which one the implementation
follows. State that the permutation p-value is exact only when every placebo refit
enters the reference set (failed / infeasible refits are excluded and shrink the
effective denominator), in the REGISTRY Note, the in_space_placebo() docstring
and the changelog fragment.
… treated fit matches its placebo refits

fit() now orders an explicit donor_pool by the pivot's sorted unit order, the same
assignment-independent order every in-space placebo refit uses. Frank-Wolfe breaks
exact gradient ties by position, so a caller-ordered list could give the treated fit
a different tie-break than its placebos and make the permutation rank depend on list
presentation. Regression test with two pre-period-identical donors and three list
orders (same weights, ATT, rmspe_ratio, placebo p-value and table). Also qualify the
exactness claim in llms-full.txt the same way as the docstring, REGISTRY and the
changelog fragment.
…that differs from the fit under a V search

Under v_method="nested" / "cv" the multistart count is part of the fitting procedure:
more starts can select a different V optimum even when every fit converges. The treated
unit's RMSPE ratio was produced at fit time with the fit's n_starts, so letting the
placebos use another budget ranks one statistic against references computed by a
different rule and voids the exactness argument. in_space_placebo(n_starts=k) with
k != the fit's n_starts now raises ValueError pointing at a re-fit; the lazy reference
build behind test_sharp_null / confidence_set goes through the same gate. The value
stays inert under custom / inverse_variance (no V search). Remedy texts that said
"raise n_starts on in_space_placebo()" now say to re-fit. Tests for the gate, for the
inert case, and the cache-invalidation test rebuilds with the fit's own budget.
…_placebo n_starts override is rejected

The confidence-set cache was cleared before the n_starts validation ran, so
a caller whose override was rejected (mismatch under a nested / cv V search,
or a non-positive value) lost a confidence set that was still valid: nothing
had been rebuilt, so nothing was stale. Clear the cache only after every
argument check has passed and the reference set is certain to be rebuilt.
Regression test computes a set, attempts both rejected overrides, and checks
the set and its frame are untouched (and that an accepted call still clears).

Docs: state the random-assignment condition on the exactness claim in
llms-full.txt and the changelog fragment, as the docstring and REGISTRY
already do; the changelog no longer says get_weights_df() rows follow panel
order (it stays weight-sorted; donor_weights keys follow panel order) and
qualifies the two-donor V search to nested / cv fits.
@igerber
igerber force-pushed the fix/in-space-placebo-donor-pool branch from 66b4b22 to 8b88e0c Compare October 15, 2026 12:30
@igerber igerber added the ready-for-ci Triggers CI test workflows label Oct 15, 2026
@igerber

igerber commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Thank you for the contribution!

@igerber
igerber merged commit f84c49b into igerber:main Oct 15, 2026
51 of 53 checks passed
igerber added a commit to ilgrad/diff-diff that referenced this pull request Oct 16, 2026
…-execute Tutorial 25 on top of igerber#841

Two cv-infeasibility warnings (in-space placebo and leave-one-out) still
named inner_max_iter as the budget not to raise; with the exact inner solve
the only budget left is n_starts. The donor-order canonicalization comment
and its test described the tie-break as Frank-Wolfe's; the active set also
breaks an exact gradient tie by position (the first tied column enters), so
the canonicalization is still needed and the comments now say so.

Tutorial 25 is re-executed on top of igerber#841 (treated unit in every placebo
pool): the constant-effect confidence set prints [3.47, 20.45] and the
nested V search keeps its uniform start exactly. The prose quotes neither
number; the drift bands hold.
igerber added a commit that referenced this pull request Oct 17, 2026
…ve least squares (#842)

* fix(synthetic_control): solve the donor weights exactly by non-negative least squares

Frank-Wolfe with a budget of inner_max_iter steps stopped short of the simplex
least-squares optimum on ordinary factor-model panels, so the treated fit failed
closed, placebos were dropped as failed, and the nested V search ranked V by where
truncated solves stopped. The problem reduces exactly to one non-negative least
squares, solved here by Lawson-Hanson's active set, which terminates finitely and,
being diff-diff's own, returns the same vertex at a tie under every SciPy release.
The CWZ conformal proxy and rank_control_units' synthetic_weight column solve the
same problem and use the same solve.

Fixes #838

* docs(synthetic_control): drop the last inner_max_iter remedies and re-execute Tutorial 25 on top of #841

Two cv-infeasibility warnings (in-space placebo and leave-one-out) still
named inner_max_iter as the budget not to raise; with the exact inner solve
the only budget left is n_starts. The donor-order canonicalization comment
and its test described the tie-break as Frank-Wolfe's; the active set also
breaks an exact gradient tie by position (the first tied column enters), so
the canonicalization is still needed and the comments now say so.

Tutorial 25 is re-executed on top of #841 (treated unit in every placebo
pool): the constant-effect confidence set prints [3.47, 20.45] and the
nested V search keeps its uniform start exactly. The prose quotes neither
number; the drift bands hold.

* feat(synthetic_control): deprecate the inert inner-solve budget params and flag a step-guard exhaustion instead of raising

inner_max_iter and inner_min_decrease have had no effect since the donor
weights moved to an exact non-negative least squares. Keeping them as
silent no-ops hides that from a caller who tunes them; the repo precedent
for an inert constructor parameter (SyntheticDiD lambda_reg, ledger M-001)
is a warn-and-discard shim. Both now default to None, emit a
DeprecationWarning when passed, and are stored as None (get_params and
set_params see None, so a probe re-init warns once). Ledger rows M-148 and
M-149 (deprecated 4.0, removed 5.0; 4.0 is the locked ladder's next
release), migration-guide rows and prose, the design spec's phase-5 cell,
and the naming guard's token predicate are updated together. The fit
snapshot, _inner_solve_W, _v_starts, _outer_solve_V, _outer_solve_V_cv,
_cwz_proxy_fit and the conformal grid helpers drop the dead budget
plumbing (including the proxy's unused warm start), and the convenience
function's docstring no longer lists the two names.

_nnls_lawson_hanson no longer raises RuntimeError when it exhausts its step
guard (50 steps per donor, not reached in practice). It returns the current
feasible iterate flagged converged=False, _sc_weight_nnls threads the flag
through (return_convergence=True), and _inner_solve_W and the conformal
proxy report it, so the fail-closed paths that were kept (non-converged
treated fit, dropped placebo, indeterminate grid point) handle it instead
of a bare exception escaping from the middle of a nested V search or a
confidence-interval grid. The post-fit inner-solve warning names the step
guard instead of an iteration count.

Tests: the budget-exhaustion test asserts a feasible flagged iterate rather
than a raise; a new test monkeypatches the solve to exhaust and checks the
flag reaches the fit warning, the fail-closed placebo and the conformal
proxy; a new test pins the deprecation (warning text, discard to None,
set_params, identical fit to the default, no warning on a default
construction). Every inner_min_decrease=1e-3 in the suites, the tutorial-25
kwarg lock and the notebook's fit call drop the inert argument (the
notebook's outputs are unchanged by it).

Docs: REGISTRY inner-solve line and the three placebo failure notes, the
llms-full signature, the changelog fragment (Deprecated section; the
step-guard contract), and the resolved TODO row.

* fix(utils): make the NNLS conditioning scale overflow-safe, fail closed on a non-finite difference, and apply the ridge per passive set

_sc_weight_nnls conditioned the problem by sqrt(mean(Z**2)) on the raw
donor-minus-treated block. For finite inputs beyond about 1e154 (below
about 1e-154) that overflowed to inf (underflowed to 0), the division
erased the fitting block, and the active set returned an arbitrary vertex
flagged converged: Y = [[0, 1e200, 9e199]] gave [1, 0] instead of
[0.1, 0.9], and an exact-match panel through
SyntheticControl(standardize="none") put weight 1 on the wrong donor with
_fit_converged still true. The scale is now computed from the
max-normalized block (an RMS of O(1) numbers), so any finite magnitude
gives the same weights; a non-finite Z (a difference that itself
overflows) fails closed as uniform weights flagged converged=False, and a
non-finite result does the same.

zeta > 0 stacked a dense J x J identity under Z and carried it through
every passive-set least squares (800 MB at 10,000 controls in
rank_control_units). _nnls_lawson_hanson takes the ridge as a scalar and
applies it inside each passive-set solve (an identity sized to the passive
set) and in the gradient; the result equals the stacked form to 1e-12.

The treated-fit non-convergence messages (in-space / in-time placebo,
leave-one-out, the diagnostic-report reason text and the in_space_placebo
docstring) attributed every non-converged fit to the outer V search and
prescribed its knobs; they now name the inner step guard as the other
cause and scope the remedy to the outer search. The conformal grid
docstring no longer describes a warm-started Frank-Wolfe refit, and the
REGISTRY donor-order Note names the active set's positional tie-break.

Tests: scale invariance at 1e-300 .. 1e300 (same weights, KKT, converged);
fail-closed on an overflowing difference; ridge equals the stacked
identity; an exact-match fit at a 1e150 outcome scale keeps its weight,
zero ATT and converged flag.

* fix(utils): solve a ridge-dominated passive set by its regularized normal equations

When the ridge dwarfs the fitting block (rank_control_units with a large
lambda_reg, or a tiny outcome scale), the NNLS coefficients are
O(1/ridge^2) and an SVD of the stacked [M; ridge I] system loses them: the
information sits in O(1/ridge) entries of U computed to O(eps) absolute.
Y = [[0, 2e-14, 1.8e-14]] with zeta = 1 returned [0.4986, 0.5014] as
converged where the optimum is [0.5, 0.5] to 1e-28. With the ridge at or
above the block's Frobenius norm the Gram matrix is well conditioned
(cond <= 2), so the passive-set solve now uses (M'M + ridge^2 I) z = M'd
there and keeps the stacked least squares otherwise; at the branch point
the two agree to 1e-12. SyntheticControl and the conformal proxy run with
zeta = 0 and are unaffected.

The comment above test_outer_v_nonconvergence_warning no longer describes
a loose inner tolerance that no longer exists.

* fix(utils): equilibrate the donor columns inside the NNLS reduction so heterogeneous donor scales are solved exactly

A single global conditioning scale hid small donors behind a large one:
with A = [[1e16, 1, 2]] and b = 1.5 the two O(1) donors fell below the
active set's tolerance after the block was normalized by the 1e16 column,
and the solve returned w = (0, 1, 0) (residual -0.5) as converged. Each
donor column of Z is now scaled to unit norm first (B = Z D, w = D v, the
sum constraint becoming (D1)'v = 1, that row itself scaled to a unit
maximum), which keeps the reduction exact for any scale while giving every
column its own rounding level; the active set's entering test is per
column. The entering column is still chosen on the gradient in the
original units (pivot_scale), so the vertex an exact-fit tie returns is
the one the un-scaled reduction returned: Tutorial 25 and its drift bands
are unchanged. The ridge for zeta > 0 becomes a per-column vector. A donor
identical to the target gets weight 1 directly (zeta = 0).

Tests: the heterogeneous-scale problem is solved to a zero residual at
scales 1 and 1e+-200, as is the CWZ proxy on the same donors; an
end-to-end standardize="none" fit with a 1e12 donor beside two O(1)
donors reproduces the treated pre-path exactly. The tie-vertex,
scale-invariance, ridge and KKT tests pass unchanged.

* fix(utils): fail the NNLS solve closed on any non-finite intermediate and scope its magnitude claim to the float64 range

Subnormal data (entries near 1e-320) makes a reciprocal column norm
overflow, and entries near 1e308 make a column norm itself overflow; a
NaN or inf then reaches the active set, whose comparisons are all False,
so it returned the zero iterate as converged and the wrapper substituted
uniform weights under that flag. Every intermediate (column norms, their
reciprocals, the equilibrated block, the scale, the ridge vector and the
constraint row) is now checked finite before the active set runs, and the
active set itself refuses a non-finite problem; both paths return uniform
weights flagged converged=False so the estimator's fail-closed paths
(non-converged fit warning, skipped diagnostics) fire. The docstring and
REGISTRY no longer claim finite inputs of any magnitude: the guarantee is
the normal float64 range (column norms and reciprocals between about
1e-300 and 1e300), with the regimes outside it named as fail-closed.

Tests: a subnormal panel and an overflowing column norm both fail closed;
a NaN problem fails closed inside the active set; an end-to-end fit on
subnormal outcomes reports a non-converged fit with the warning.

* test(synthetic_control): pin objective parity for opposing large donors beside a small one

Two nearly opposing 1e8 donors next to an O(1) donor, with a 1e-8 wrinkle
in a second predictor: the candidate optima differ in objective by about
1e-17, below the rounding of the 1e8 entries (ulp 1.5e-8), so a float64
solver can only choose among them by rounding. The test pins the contract
that matters: the returned simplex vector is flagged converged, its
objective is no worse than the alternative vertex evaluated in the same
arithmetic, and its residual is within one ulp of the data.

* docs(prep): describe synthetic_weight as the thresholded, renormalized exact solve

rank_control_units() solves its simplex least squares exactly and then
zeroes weights below 1e-6 and renormalizes for readability, so the column
is the optimum up to that cleanup, not to the last digit (a donor whose
optimal weight is below the threshold is dropped). The docstring, the
solver comment and the test's name and docstring now say so; the test's
assertion is unchanged (no optimal weight on its panel falls below the
threshold).

* fix(prep): surface a non-converged synthetic_weight solve instead of presenting its fallback as the solution

rank_control_units() called the simplex solve without its convergence
flag, so a fail-closed return (a non-finite intermediate or an exhausted
step guard, neither reached in practice) came back as uniform weights that
were then thresholded and reported as the informational synthetic_weight
column. The call now requests the flag; a non-converged solve leaves the
column NaN and emits a UserWarning saying so, while quality_score and the
ranking (which never used the column) are unaffected. Regression test
monkeypatches the solve to fail closed.

Tutorial 25 said synthetic control identifies the counterfactual path even
where the weights are not identified. At an exact pre-period fit it does
not: donors on the same optimal face can differ post-period, so the
solver's tie-break can move the synthetic path and the ATT. The caveat and
the closing checklist now say that identification rests on the
factor-model assumptions and a long pre-period, and point at the
robustness diagnostics for tie-break sensitivity. Markdown only; the
notebook's outputs are unchanged.

* fix(prep): reject infinite pre-period outcomes in rank_control_units instead of routing them to the missing-data fallback

The uniform synthetic_weight column for an unbalanced panel is keyed on a
non-finite pre-period block, so an infinite outcome took the same branch
and was presented as a valid diagnostic (and could leave the ranking
scores non-finite). The pre-period pivot is now checked for infinities
right after it is built and raises ValueError naming the outcome column;
NaN keeps its documented meaning of a missing cell. Parametrized test for
an infinite treated and an infinite control outcome.

Tutorial 25's tie-sensitivity caveat pointed at "sparse-subset
diagnostics below" that the notebook does not run; it now points at the
leave-one-out diagnostic below and names sparse_synthetic_control().

* fix(prep): check for infinite outcomes only on the units that enter the ranking

The guard scanned the whole pre-period pivot, so an infinite outcome in a
unit listed in exclude_units raised although that unit cannot reach any
output. The check now runs after the treated list and the non-excluded
control candidates are resolved and covers only those columns, on the
valid pre-periods. Test: an excluded control with an infinite outcome
neither raises nor appears, and the ranking columns stay finite.

Two prep tests still passed the deprecated inner_min_decrease to
SyntheticControl and emitted DeprecationWarnings; the kwarg is dropped
(the prep suite now runs clean with DeprecationWarning as an error).

* docs(synthetic_control): name both causes of an inner-solve non-convergence in the warning

_sc_weight_nnls reports converged=False either when the active set
exhausts its step guard or when the conditioned problem leaves the
float64 range (an overflowing column norm or reciprocal, a non-finite
intermediate), but the fit-time warning named only the step guard. The
warning and the _inner_solve_W, conformal proxy and in_space_placebo
docstrings now name both; the fail-closed behavior is unchanged.

* test(synthetic_control): pin the rounding floor, not the vertex ordering, in the opposing-large-donors case

test_sc_weight_nnls_opposing_large_donors_beside_a_small_one asserted that
the returned weights' objective was no worse than the vertex
(0, 1e-8, 1 - 1e-8). Both candidates sit at the rounding floor of the 1e8
entries (objectives 8.1e-17 vs 1.0e-16 on Accelerate, 1.5e-16 vs 1.0e-16
on OpenBLAS), so the ordering is decided by BLAS rounding and the test
failed on every x86 CI leg while passing on macOS and arm. The test now
asserts what the case was pinned for: a converged simplex vector whose
residual is within the rounding of the 1e8 entries (two ulps, since
1e8 * (w1 - w0) carries the rounding of two O(0.5) weights).

---------

Co-authored-by: igerber <isaac.gerber@gmail.com>
@igerber igerber mentioned this pull request Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: in_space_placebo() leaves the treated unit out of every placebo's donor pool, so the placebo test is not exact

2 participants