Skip to content

Commit 1ceb63e

Browse files
igerberclaude
andcommitted
docs+test(imputation): CI-review P3s — LSMR fallback doc sync + weighted parity oracle
M1: REGISTRY ImputationDiD passages (v_it derivation note + sparse variance solver note) now describe the sparse LSMR fallback (certified istop set {0,1,2,4,5}, uncertified -> full NaN inference tuple, dense lstsq removed) instead of the pre-#657 dense-lstsq contract. C1: five stale 'dense lstsq' comments/docstrings in imputation.py updated to the LSMR contract. D1: weighted singular-system parity test added — null-space components of (A_0'[W]A_0) live in null(sqrt(W) A_0), so the WEIGHTED projection W_0 A_0 z (what the weighted estimator consumes) must match the dense oracle even where the unweighted projection need not; includes zero-weight (subpopulation) rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e1d704b commit 1ceb63e

3 files changed

Lines changed: 39 additions & 11 deletions

File tree

diff_diff/imputation.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ class _UntreatedProjection(NamedTuple):
6262
A_0: sparse.csr_matrix
6363
A_1: sparse.csr_matrix
6464
# solver(rhs) -> z; None when the factorization was exactly singular (the
65-
# solve path then routes to a dense lstsq fallback).
65+
# solve path then routes to the sparse LSMR least-squares fallback).
6666
solver: Optional[Callable[[np.ndarray], np.ndarray]]
67-
A0tA0_csc: sparse.csc_matrix # retained for the dense-lstsq fallback
67+
A0tA0_csc: sparse.csc_matrix # retained for the LSMR fallback
6868
survey_weights_0: Optional[np.ndarray]
6969
singular: bool
7070

@@ -1595,7 +1595,9 @@ def _build_untreated_projection(
15951595
Uses scipy.sparse for FE dummy columns to reduce memory from O(N*(U+T))
15961596
to O(N) for the FE portion. An exactly singular ``A_0'[W]A_0`` makes
15971597
``sparse_factorized`` raise ``RuntimeError``; we emit a UserWarning (once
1598-
per fit) and record ``singular=True`` so the solve routes to dense lstsq.
1598+
per fit) and record ``singular=True`` so the solve routes to the sparse
1599+
LSMR least-squares fallback (no dense materialization; see
1600+
:func:`_lsmr_minnorm_normal_solve`).
15991601
"""
16001602
# Exclude rank-deficient covariates from design matrices
16011603
if kept_cov_mask is not None and not np.all(kept_cov_mask):
@@ -1663,16 +1665,16 @@ def _build_A_sparse(df_sub, unit_vals, time_vals):
16631665
# Factorize once (factorize-once / solve-many). An exactly singular
16641666
# matrix makes sparse_factorized raise RuntimeError -- the same condition
16651667
# that previously surfaced as spsolve's MatrixRankWarning -> non-finite
1666-
# solution. Mirror the TwoStageDiD GMM-sandwich pattern: warn once and
1667-
# fall back to dense lstsq per target. (Bit-identical to the prior
1668-
# per-target spsolve for a single dense RHS -- both use the SuperLU
1669-
# simple driver with the same defaults.)
1668+
# solution. Warn once and fall back to the sparse LSMR least-squares
1669+
# solve per target (no dense materialization). (The factorized path is
1670+
# bit-identical to the prior per-target spsolve for a single dense
1671+
# RHS -- both use the SuperLU simple driver with the same defaults.)
16701672
try:
16711673
solver: Optional[Callable[[np.ndarray], np.ndarray]] = sparse_factorized(A0tA0_csc)
16721674
singular = False
16731675
except RuntimeError as exc:
16741676
# Silent-failure audit axis C: emit a UserWarning on fallback instead
1675-
# of swallowing the error. Keep the "dense lstsq" substring (asserted
1677+
# of swallowing the error. Keep the "sparse LSMR" substring (asserted
16761678
# by tests).
16771679
warnings.warn(
16781680
"ImputationDiD variance: sparse factorization of (A_0' [W] A_0) "
@@ -1711,7 +1713,7 @@ def _solve_untreated_v(self, ctx: _UntreatedProjection, weights: np.ndarray) ->
17111713
z = ctx.solver(A1_w)
17121714
if not np.all(np.isfinite(z)):
17131715
# Defensive, target-specific: a non-finite solve on an otherwise
1714-
# factorizable matrix routes this RHS to dense lstsq. Warn per
1716+
# factorizable matrix routes this RHS to the LSMR fallback. Warn per
17151717
# target (silent-failure audit axis C) -- distinct from the
17161718
# once-per-fit build-time singular warning.
17171719
warnings.warn(

docs/methodology/REGISTRY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,7 +1565,7 @@ Observation weights `v_it`:
15651565

15661566
**Note (pretrends lead FE-span guard, 2026-07):** the Test-1 lead model (`_compute_lead_coefficients`) routes its lead indicators + covariates through `snap_absorbed_regressors` after the within-transform (same two-stage snap + LSMR confirmation as the `absorb=` estimators; see "Absorbed Fixed Effects"). Lead indicators are the most plausible FE-spanned regressors: when a lead's calendar period contains only that cohort's rows on Omega_0 (e.g. never-treated units unobserved there), the lead collapses to a calendar-time dummy in the span of the absorbed time FE — it now snaps to exact zero and reports a deterministic NaN coefficient with a cause-specific warning (label `lead[h]`), instead of relying on the raw rank check alone (which the truncated-MAP-iterate exposure can defeat in slow-convergence regimes). Identified leads are unchanged (snap is a no-op; suite bit-stable).
15671567

1568-
**Note on v_it derivation:** The paper's Supplementary Proposition A3 gives the explicit `v_it^*` formula; it is not in the reviewed main-article PDF, so the projection is validated *empirically* against R `didimputation` (`tests/test_methodology_imputation.py::TestImputationDiDParityR`, SEs match to ~1e-10; the covariate branch — first stage `y ~ x | unit + time` on the untreated sample — is anchored separately by `TestImputationDiDCovariateParityR` on a time-varying-X panel, SEs ~2e-10). **Deviation note (superseded closed form):** the FE-only path previously used a closed form `-(w_i./n_{0,i} + w_.t/n_{0,t} - w../N_0)`, which is exact only for a *balanced* untreated set; because `Omega_0` is generically unbalanced in staggered designs (treated observations are removed), that form biased the SE (~27% on the parity panel) and was replaced by the exact projection above during the ImputationDiD methodology validation. A genuinely rank-deficient `A_0' A_0` (e.g. an unidentified period FE) routes to a dense least-squares fallback with a `UserWarning`.
1568+
**Note on v_it derivation:** The paper's Supplementary Proposition A3 gives the explicit `v_it^*` formula; it is not in the reviewed main-article PDF, so the projection is validated *empirically* against R `didimputation` (`tests/test_methodology_imputation.py::TestImputationDiDParityR`, SEs match to ~1e-10; the covariate branch — first stage `y ~ x | unit + time` on the untreated sample — is anchored separately by `TestImputationDiDCovariateParityR` on a time-varying-X panel, SEs ~2e-10). **Deviation note (superseded closed form):** the FE-only path previously used a closed form `-(w_i./n_{0,i} + w_.t/n_{0,t} - w../N_0)`, which is exact only for a *balanced* untreated set; because `Omega_0` is generically unbalanced in staggered designs (treated observations are removed), that form biased the SE (~27% on the parity panel) and was replaced by the exact projection above during the ImputationDiD methodology validation. A genuinely rank-deficient `A_0' A_0` (e.g. an unidentified period FE) routes to a sparse LSMR least-squares fallback with a `UserWarning` (no dense materialization; see the sparse-variance-solver Note below).
15691569

15701570
Auxiliary model residuals (Equation 8):
15711571
- Partition `Omega_1` into groups `G_g` (default: cohort × horizon)
@@ -1613,7 +1613,7 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only.
16131613
- **Non-constant `first_treat` within a unit:** Emits `UserWarning` identifying the count and example unit. The estimator proceeds using the first observed value per unit (via `.first()` aggregation), but results may be unreliable.
16141614
- **treatment_effects DataFrame weights:** `weight` column uses `1/n_valid` for finite tau_hat and 0 for NaN tau_hat, consistent with the ATT estimand (unweighted), or normalized survey weights `sw_i/sum(sw)` when `survey_design` is active.
16151615
- **Rank-deficient covariates in variance:** Covariates with NaN coefficients (dropped for rank deficiency in Step 1) are excluded from the variance design matrices `A_0`/`A_1`. Only covariates with finite coefficients participate in the `v_it` projection.
1616-
- **Sparse variance solver:** the untreated projection `v_untreated = -A_0 (A_0'[W]A_0)^{-1} A_1'w` factorizes the normal-equations matrix `(A_0'[W]A_0)` once per `fit()` via `scipy.sparse.linalg.factorized` and reuses the factorization across every estimand target (overall ATT, each event-study horizon, each group, and the bootstrap precompute), solving only the target-specific RHS `A_1'w` per target -- factorize-once / solve-many (the design is target-invariant; only `weights` vary). This is **bit-identical** to the prior per-target `scipy.sparse.linalg.spsolve` for a single dense RHS (both use the SuperLU simple driver with the same defaults), built once instead of `O(targets)` times. Mirrors the TwoStageDiD GMM-sandwich `factorized` pattern. An exactly singular `(A_0'[W]A_0)` makes `factorized` raise `RuntimeError`; the build falls back to dense `lstsq` and emits a `UserWarning` once per fit (silent-failure audit axis C). A defensive per-target non-finite solve likewise routes to dense `lstsq` with a per-target `UserWarning`, so callers always know variance estimates came from the degraded path. The design is built/cached in `_build_untreated_projection` and solved per target in `_solve_untreated_v`.
1616+
- **Sparse variance solver:** the untreated projection `v_untreated = -A_0 (A_0'[W]A_0)^{-1} A_1'w` factorizes the normal-equations matrix `(A_0'[W]A_0)` once per `fit()` via `scipy.sparse.linalg.factorized` and reuses the factorization across every estimand target (overall ATT, each event-study horizon, each group, and the bootstrap precompute), solving only the target-specific RHS `A_1'w` per target -- factorize-once / solve-many (the design is target-invariant; only `weights` vary). This is **bit-identical** to the prior per-target `scipy.sparse.linalg.spsolve` for a single dense RHS (both use the SuperLU simple driver with the same defaults), built once instead of `O(targets)` times. Mirrors the TwoStageDiD GMM-sandwich `factorized` pattern. An exactly singular `(A_0'[W]A_0)` makes `factorized` raise `RuntimeError`; the build emits a `UserWarning` once per fit (silent-failure audit axis C) and the solve routes to a **sparse LSMR least-squares fallback** (`scipy.sparse.linalg.lsmr`, `atol=btol=1e-14`) — the previous dense `lstsq(toarray())` fallback materialized the `O((U+T+K)^2)` normal matrix, an OOM risk on large panels (2026-07). Solver choice cannot change the estimator output: least-squares solutions of the singular system differ only by `null(sqrt(W) A_0)` components, which the downstream projection `v = -[W_0] A_0 z` annihilates (dense-oracle parity test). Convergence is validated fail-closed: `istop` in `{0, 1, 2, 4, 5}` counts as certified (4/5 are SciPy's machine-precision analogues of 1/2); an uncertified stop gets one retry with an uncapped condition limit, then raises internally and the variance boundary reports a **full NaN inference tuple** — raising rather than returning NaN matters because the missing-FE `nan_to_num` in the psi product would otherwise launder a NaN vector into zeros and a finite, wrong variance. A defensive per-target non-finite solve likewise routes to the LSMR fallback with a per-target `UserWarning`, so callers always know variance estimates came from the degraded path. The design is built/cached in `_build_untreated_projection` and solved per target in `_solve_untreated_v`.
16171617
- **Note:** Survey weights enter ImputationDiD via weighted iterative FE (Step 1), survey-weighted ATT aggregation (Step 3), and design-based variance via `compute_survey_if_variance()`. PSU clustering, stratification, and FPC are fully supported in the Theorem 3 variance path. When `resolved_survey` is present, the observation-level influence function (`v_it * epsilon_tilde_it`) is passed to `compute_survey_if_variance()` which applies the stratified PSU-level sandwich with FPC correction. Strata also enters survey df (n_PSU - n_strata) for t-distribution inference. Bootstrap + survey supported (Phase 6) via PSU-level multiplier weights.
16181618
- **Bootstrap inference:** Uses multiplier bootstrap on the Theorem 3 influence function: `psi_i = sum_t v_it * epsilon_tilde_it`. Cluster-level psi sums are pre-computed for each aggregation target (overall, per-horizon, per-group), then perturbed with multiplier weights (Rademacher by default; configurable via `bootstrap_weights` parameter to use Mammen or Webb weights, matching CallawaySantAnna). This is a library extension (not in the paper) consistent with CallawaySantAnna/SunAbraham bootstrap patterns.
16191619
- **Auxiliary residuals (Equation 8):** Implements the paper's *unit-clustered* Equation 8 aggregator, `tau_tilde_g = sum_i (sum_{t in G_g,i} v_it)(sum_{t in G_g,i} v_it * tau_hat_it) / sum_i (sum_{t in G_g,i} v_it)^2` (Borusyak-Jaravel-Spiess 2024, eq. 8, p. 3272; minimal-excess-variance derivation in Supplementary Appendix A.8): for each unit form the within-unit weight sum `a_{i,g}` and weighted-effect sum `b_{i,g}` over the unit's observations in group `g`, then combine across units. Groups partition `Omega_1` via `aux_partition` (default `"cohort_horizon"` = cohort × event-time; also `"cohort"` / `"horizon"`). Unimputable (NaN `tau_hat`) and off-target observations carry `v_it = 0` and are excluded from the aggregation — exact for finite `tau_hat` (a zero-weight row adds 0 to both `a` and `b`) and NaN-safe; a group with no contributing observations falls back to the unweighted group mean (a variance no-op, since `psi_g = sum_t v_it * eps_tilde_it = 0` there).

tests/test_imputation.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3172,6 +3172,32 @@ def test_singular_system_projection_matches_dense_oracle(self):
31723172
# (what the estimator consumes) must agree.
31733173
np.testing.assert_allclose(A_0 @ z_lsmr, A_0 @ z_dense, rtol=0, atol=1e-8)
31743174

3175+
def test_weighted_singular_system_projection_matches_dense_oracle(self):
3176+
"""Weighted variant (CI-review D1): the production path solves
3177+
(A_0'[W]A_0) z = rhs with survey weights W. Null-space components of
3178+
the weighted normal equations live in null(sqrt(W) A_0), so the
3179+
WEIGHTED projection W_0 A_0 z — what the weighted estimator
3180+
consumes — must agree across solvers even where the unweighted
3181+
projection A_0 z need not."""
3182+
import scipy.sparse as sp
3183+
3184+
from diff_diff.imputation import _lsmr_minnorm_normal_solve
3185+
3186+
rng = np.random.default_rng(9)
3187+
n, p = 180, 10
3188+
A0_dense = rng.normal(size=(n, p))
3189+
A0_dense[:, -1] = 2.0 * A0_dense[:, 1] # exact collinearity
3190+
w = rng.uniform(0.2, 3.0, size=n)
3191+
w[:12] = 0.0 # zero-weight rows (subpopulation) stay inert
3192+
A_0 = sp.csr_matrix(A0_dense)
3193+
A0tWA0 = sp.csc_matrix((A_0.T.multiply(w)) @ A_0)
3194+
rhs = rng.normal(size=p)
3195+
3196+
z_lsmr = _lsmr_minnorm_normal_solve(A0tWA0, rhs)
3197+
z_dense = np.linalg.lstsq(A0tWA0.toarray(), rhs, rcond=None)[0]
3198+
assert np.all(np.isfinite(z_lsmr))
3199+
np.testing.assert_allclose(w * (A_0 @ z_lsmr), w * (A_0 @ z_dense), rtol=0, atol=1e-8)
3200+
31753201
def test_no_dense_materialization_on_fallback(self, monkeypatch):
31763202
"""The singular-build fallback path must never call .toarray() on the
31773203
normal matrix (the O((U+T+K)^2) OOM risk this closes)."""

0 commit comments

Comments
 (0)