Skip to content

Commit 6b45c59

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 88cb83a commit 6b45c59

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
@@ -56,9 +56,9 @@ class _UntreatedProjection(NamedTuple):
5656
A_0: sparse.csr_matrix
5757
A_1: sparse.csr_matrix
5858
# solver(rhs) -> z; None when the factorization was exactly singular (the
59-
# solve path then routes to a dense lstsq fallback).
59+
# solve path then routes to the sparse LSMR least-squares fallback).
6060
solver: Optional[Callable[[np.ndarray], np.ndarray]]
61-
A0tA0_csc: sparse.csc_matrix # retained for the dense-lstsq fallback
61+
A0tA0_csc: sparse.csc_matrix # retained for the LSMR fallback
6262
survey_weights_0: Optional[np.ndarray]
6363
singular: bool
6464

@@ -1589,7 +1589,9 @@ def _build_untreated_projection(
15891589
Uses scipy.sparse for FE dummy columns to reduce memory from O(N*(U+T))
15901590
to O(N) for the FE portion. An exactly singular ``A_0'[W]A_0`` makes
15911591
``sparse_factorized`` raise ``RuntimeError``; we emit a UserWarning (once
1592-
per fit) and record ``singular=True`` so the solve routes to dense lstsq.
1592+
per fit) and record ``singular=True`` so the solve routes to the sparse
1593+
LSMR least-squares fallback (no dense materialization; see
1594+
:func:`_lsmr_minnorm_normal_solve`).
15931595
"""
15941596
# Exclude rank-deficient covariates from design matrices
15951597
if kept_cov_mask is not None and not np.all(kept_cov_mask):
@@ -1657,16 +1659,16 @@ def _build_A_sparse(df_sub, unit_vals, time_vals):
16571659
# Factorize once (factorize-once / solve-many). An exactly singular
16581660
# matrix makes sparse_factorized raise RuntimeError -- the same condition
16591661
# that previously surfaced as spsolve's MatrixRankWarning -> non-finite
1660-
# solution. Mirror the TwoStageDiD GMM-sandwich pattern: warn once and
1661-
# fall back to dense lstsq per target. (Bit-identical to the prior
1662-
# per-target spsolve for a single dense RHS -- both use the SuperLU
1663-
# simple driver with the same defaults.)
1662+
# solution. Warn once and fall back to the sparse LSMR least-squares
1663+
# solve per target (no dense materialization). (The factorized path is
1664+
# bit-identical to the prior per-target spsolve for a single dense
1665+
# RHS -- both use the SuperLU simple driver with the same defaults.)
16641666
try:
16651667
solver: Optional[Callable[[np.ndarray], np.ndarray]] = sparse_factorized(A0tA0_csc)
16661668
singular = False
16671669
except RuntimeError as exc:
16681670
# Silent-failure audit axis C: emit a UserWarning on fallback instead
1669-
# of swallowing the error. Keep the "dense lstsq" substring (asserted
1671+
# of swallowing the error. Keep the "sparse LSMR" substring (asserted
16701672
# by tests).
16711673
warnings.warn(
16721674
"ImputationDiD variance: sparse factorization of (A_0' [W] A_0) "
@@ -1705,7 +1707,7 @@ def _solve_untreated_v(self, ctx: _UntreatedProjection, weights: np.ndarray) ->
17051707
z = ctx.solver(A1_w)
17061708
if not np.all(np.isfinite(z)):
17071709
# Defensive, target-specific: a non-finite solve on an otherwise
1708-
# factorizable matrix routes this RHS to dense lstsq. Warn per
1710+
# factorizable matrix routes this RHS to the LSMR fallback. Warn per
17091711
# target (silent-failure audit axis C) -- distinct from the
17101712
# once-per-fit build-time singular warning.
17111713
warnings.warn(

docs/methodology/REGISTRY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1563,7 +1563,7 @@ Observation weights `v_it`:
15631563
- For treated `(i,t) in Omega_1`: `v_it = w_it` (the aggregation weight)
15641564
- For untreated `(i,t) in Omega_0` (FE-only **and** covariate cases): the exact imputation projection `v_untreated = -A_0 (A_0' A_0)^{-1} A_1' w_treated` (survey-weighted, with the left WLS weight factor `W_0`: `-W_0 A_0 (A_0' W_0 A_0)^{-1} A_1' w_treated`), where `A_0`, `A_1` are the two-way-FE (all unit dummies + time dummies dropping the first; plus any covariates) design matrices for untreated/treated observations.
15651565

1566-
**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`.
1566+
**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).
15671567

15681568
Auxiliary model residuals (Equation 8):
15691569
- Partition `Omega_1` into groups `G_g` (default: cohort × horizon)
@@ -1611,7 +1611,7 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only.
16111611
- **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.
16121612
- **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.
16131613
- **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.
1614-
- **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`.
1614+
- **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`.
16151615
- **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.
16161616
- **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.
16171617
- **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
@@ -3086,6 +3086,32 @@ def test_singular_system_projection_matches_dense_oracle(self):
30863086
# (what the estimator consumes) must agree.
30873087
np.testing.assert_allclose(A_0 @ z_lsmr, A_0 @ z_dense, rtol=0, atol=1e-8)
30883088

3089+
def test_weighted_singular_system_projection_matches_dense_oracle(self):
3090+
"""Weighted variant (CI-review D1): the production path solves
3091+
(A_0'[W]A_0) z = rhs with survey weights W. Null-space components of
3092+
the weighted normal equations live in null(sqrt(W) A_0), so the
3093+
WEIGHTED projection W_0 A_0 z — what the weighted estimator
3094+
consumes — must agree across solvers even where the unweighted
3095+
projection A_0 z need not."""
3096+
import scipy.sparse as sp
3097+
3098+
from diff_diff.imputation import _lsmr_minnorm_normal_solve
3099+
3100+
rng = np.random.default_rng(9)
3101+
n, p = 180, 10
3102+
A0_dense = rng.normal(size=(n, p))
3103+
A0_dense[:, -1] = 2.0 * A0_dense[:, 1] # exact collinearity
3104+
w = rng.uniform(0.2, 3.0, size=n)
3105+
w[:12] = 0.0 # zero-weight rows (subpopulation) stay inert
3106+
A_0 = sp.csr_matrix(A0_dense)
3107+
A0tWA0 = sp.csc_matrix((A_0.T.multiply(w)) @ A_0)
3108+
rhs = rng.normal(size=p)
3109+
3110+
z_lsmr = _lsmr_minnorm_normal_solve(A0tWA0, rhs)
3111+
z_dense = np.linalg.lstsq(A0tWA0.toarray(), rhs, rcond=None)[0]
3112+
assert np.all(np.isfinite(z_lsmr))
3113+
np.testing.assert_allclose(w * (A_0 @ z_lsmr), w * (A_0 @ z_dense), rtol=0, atol=1e-8)
3114+
30893115
def test_no_dense_materialization_on_fallback(self, monkeypatch):
30903116
"""The singular-build fallback path must never call .toarray() on the
30913117
normal matrix (the O((U+T+K)^2) OOM risk this closes)."""

0 commit comments

Comments
 (0)