Skip to content

Commit e5e7362

Browse files
igerberclaude
andcommitted
perf(linalg): compute hc2_bm CR2 sandwich once, not twice
LinearRegression(vcov_type="hc2_bm").fit computed the CR2/HC2-BM sandwich twice: once inside solve_ols for the vcov, then again via compute_robust_vcov(return_dof=True) just to extract the per-coefficient Bell-McCaffrey DOF. The CR2 helper returns (vcov, dof) together, so the second sandwich (per-cluster A_g + the O(n^2 k) Satterthwaite loop) was pure waste (#475). Skip solve_ols's vcov on the hc2_bm-not-survey path (return_vcov=False via a new _is_bm_path flag) and source BOTH vcov and dof from one compute_robust_vcov(return_dof=True) call, reusing the existing _expand_vcov_with_nan rank-deficient expansion. solve_ols (and its ~50 callers) is left untouched. Affects every hc2_bm fit: weighted one-way WLS-CR2, weighted/unweighted clustered CR2-BM (DiD/TWFE/MPD with vcov_type="hc2_bm"). Bit-identical SEs + per-coef DOF (proven at atol=0 across weighted/ unweighted x one-way/clustered + rank-deficient; both vcov computations route through the same _compute_robust_vcov_numpy). No methodology, numerical, or public-API change. Minor: the HC2->HC1 high-leverage fallback UserWarning now fires once per fit instead of twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8733faa commit e5e7362

4 files changed

Lines changed: 148 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6868
#groups)` factorizations to one. **Bit-identical** to the prior per-target `spsolve` (proven
6969
at `atol=0` across FE-only, covariate, survey-weighted, and bootstrap paths); no methodology,
7070
numerical, or public-API change.
71+
- **`LinearRegression(vcov_type="hc2_bm")`: compute the CR2 sandwich once, not twice.** The fit
72+
path previously computed the CR2/HC2-BM vcov inside `solve_ols`, then recomputed the entire
73+
Bell-McCaffrey sandwich (per-cluster `A_g`, the `O(n²k)` Satterthwaite loop) a second time via
74+
`compute_robust_vcov(return_dof=True)` just to extract the per-coefficient DOF. The vcov **and**
75+
DOF now come from a single combined call (the CR2 helper returns both together), halving the
76+
per-coefficient CR2 sandwich cost on every `hc2_bm` fit (weighted one-way WLS-CR2,
77+
weighted/unweighted clustered CR2-BM — i.e. `DiD`/`TWFE`/`MPD` with `vcov_type="hc2_bm"`).
78+
(`MultiPeriodDiD`'s separate post-period-average contrast DOF still has its own recomputation,
79+
tracked as an open follow-up in `TODO.md`.) **Bit-identical** SEs and DOF
80+
(proven at `atol=0`); no methodology, numerical, or public-API change. Minor side effect: the
81+
HC2→HC1 high-leverage-fallback `UserWarning` now fires once per fit instead of twice.
7182

7283
### Fixed
7384
- **Corrected the Korn & Graubard (1990) citation venue** in `docs/methodology/REGISTRY.md`

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
5656
| Issue | Location | Origin | Effort | Priority |
5757
|-------|----------|--------|--------|----------|
5858
| `ImputationDiD` dense `(A0'A0).toarray()` scales `O((U+T+K)^2)` — OOM risk on large panels (only triggers when the sparse solver fails). Needs an alternative dense fallback or richer sparse strategy. | `imputation.py` | #141 | Heavy | Medium |
59-
| `LinearRegression.fit()` pays the CR2 cost twice on the weighted `hc2_bm` path: once in `solve_ols(..., return_vcov=True)` and again via `compute_robust_vcov(..., return_dof=True)` for `_bm_dof`. Fix: thread `return_dof` through `solve_ols`, or cache the per-cluster `A_g` / `MUWTWUM` precomputes. (CI codex P3 on #475.) | `linalg.py` | #475 | Mid | Low |
6059
| MPD `cluster+hc2_bm` computes CR2 precomputes twice — `solve_ols → _compute_cr2_bm` for vcov+DOF, then `_compute_cr2_bm_contrast_dof` for the post-period-average contrast DOF. Both rebuild `H`, `M`, per-cluster `A_g`. Plumb the contrast DOF through the vcov path or share via a cached helper. | `linalg.py`, `estimators.py::MultiPeriodDiD.fit` | follow-up | Mid | Low |
6160
| CR2 Bell-McCaffrey DOF uses a naive `O(n²k)` per-coefficient loop over cluster pairs; Pustejovsky-Tipton (2018) Appendix B has a scores-based formulation avoiding the full `n×n` `M`. Switch when a user hits a large-`n` cluster-robust design. | `linalg.py::_compute_cr2_bm` | Phase 1a | Heavy | Low |
6261
| Rust-backend HC2: the Rust path only supports HC1; HC2 and CR2 Bell-McCaffrey fall through to NumPy. Noticeable for large-`n` fits. | `rust/src/linalg.rs` | Phase 1a | Mid | Low |

diff_diff/linalg.py

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3542,14 +3542,20 @@ def fit(
35423542
)
35433543

35443544
if _fit_vcov_type != "classical" or effective_cluster_ids is not None:
3545-
# Use solve_ols with robust/cluster SEs
3546-
# When survey vcov will be used, skip standard vcov computation
3545+
# Use solve_ols with robust/cluster SEs.
3546+
# When survey vcov will be used, skip standard vcov computation.
3547+
# For hc2_bm (non-survey), ALSO skip solve_ols's vcov: the CR2
3548+
# sandwich produces (vcov, dof) together, so the block below gets
3549+
# BOTH from a single compute_robust_vcov(return_dof=True) call
3550+
# instead of computing the vcov here and recomputing the whole
3551+
# sandwich a second time just for the dof (#475).
3552+
_is_bm_path = _fit_vcov_type == "hc2_bm" and not _use_survey_vcov
35473553
coefficients, residuals, fitted, vcov = solve_ols(
35483554
X,
35493555
y,
35503556
cluster_ids=effective_cluster_ids,
35513557
return_fitted=True,
3552-
return_vcov=not _use_survey_vcov,
3558+
return_vcov=(not _use_survey_vcov) and not _is_bm_path,
35533559
rank_deficient_action=self.rank_deficient_action,
35543560
weights=_fit_weights,
35553561
weight_type=_fit_weight_type,
@@ -3562,22 +3568,24 @@ def fit(
35623568
conley_unit=self.conley_unit,
35633569
conley_lag_cutoff=self.conley_lag_cutoff,
35643570
)
3565-
# For hc2_bm, compute per-coefficient Bell-McCaffrey DOF. Both
3566-
# the one-way HC2+BM case and the cluster CR2 case are supported,
3567-
# including the weighted clustered CR2 path via the clubSandwich
3568-
# WLS-CR2 port. The dispatcher already rejects non-pweight weight
3569-
# types for hc2_bm + weights, so reaching this block guarantees
3570-
# `_compute_cr2_bm` returns a finite per-coefficient DOF.
3571-
if (
3572-
_fit_vcov_type == "hc2_bm"
3573-
and not _use_survey_vcov
3574-
and vcov is not None
3575-
and not np.all(np.isnan(coefficients))
3576-
):
3577-
# Identified columns for DOF (rank-deficient case sets NaN coefs).
3571+
# For hc2_bm (non-survey), compute the CR2 vcov AND per-coefficient
3572+
# Bell-McCaffrey DOF together in a SINGLE compute_robust_vcov call
3573+
# (solve_ols skipped its vcov above via `_is_bm_path`). Both the
3574+
# one-way HC2+BM case and the (weighted) clustered CR2 case route
3575+
# through the same `_compute_robust_vcov_numpy`/`_compute_cr2_bm`, so
3576+
# this is bit-identical to the prior two-call form while computing the
3577+
# O(n^2 k) sandwich once instead of twice (#475). The dispatcher
3578+
# already rejects non-pweight weight types for hc2_bm + weights.
3579+
if _is_bm_path:
3580+
# Rank-deficient solves set NaN coefficients for dropped columns.
35783581
nan_mask = np.isnan(coefficients)
3579-
if not np.any(nan_mask):
3580-
_, self._bm_dof = compute_robust_vcov(
3582+
if np.all(nan_mask):
3583+
# All columns dropped: NaN vcov (matches solve_ols's
3584+
# rank-deficient all-drop) and no BM DOF (n-k fallback).
3585+
vcov = np.full((X.shape[1], X.shape[1]), np.nan)
3586+
self._bm_dof = None
3587+
elif not np.any(nan_mask):
3588+
vcov, self._bm_dof = compute_robust_vcov(
35813589
X,
35823590
residuals,
35833591
cluster_ids=effective_cluster_ids,
@@ -3587,23 +3595,23 @@ def fit(
35873595
return_dof=True,
35883596
)
35893597
else:
3590-
# Per-coef DOF only for identified coefficients; set NaN for dropped.
3598+
# Rank-deficient: compute on identified columns only, then
3599+
# expand BOTH vcov and per-coef DOF with NaN for dropped
3600+
# columns (mirrors solve_ols's `_expand_vcov_with_nan` path).
35913601
kept = np.where(~nan_mask)[0]
3592-
if len(kept) > 0:
3593-
_, dof_kept = compute_robust_vcov(
3594-
X[:, kept],
3595-
residuals,
3596-
cluster_ids=effective_cluster_ids,
3597-
weights=_fit_weights,
3598-
weight_type=_fit_weight_type,
3599-
vcov_type="hc2_bm",
3600-
return_dof=True,
3601-
)
3602-
full = np.full(X.shape[1], np.nan)
3603-
full[kept] = dof_kept
3604-
self._bm_dof = full
3605-
else:
3606-
self._bm_dof = np.full(X.shape[1], np.nan)
3602+
vcov_reduced, dof_kept = compute_robust_vcov(
3603+
X[:, kept],
3604+
residuals,
3605+
cluster_ids=effective_cluster_ids,
3606+
weights=_fit_weights,
3607+
weight_type=_fit_weight_type,
3608+
vcov_type="hc2_bm",
3609+
return_dof=True,
3610+
)
3611+
vcov = _expand_vcov_with_nan(vcov_reduced, X.shape[1], kept)
3612+
full = np.full(X.shape[1], np.nan)
3613+
full[kept] = dof_kept
3614+
self._bm_dof = full
36073615
else:
36083616
self._bm_dof = None
36093617
else:

tests/test_methodology_wls_cr2.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,101 @@ def test_lr_weighted_oneway_hc2_bm_df_matches_helper(self):
461461
err_msg="One-way LinearRegression _bm_dof must match helper Satterthwaite DOF",
462462
)
463463

464+
def test_lr_hc2_bm_vcov_value_matches_helper(self):
465+
"""#475 dedup: the fit-level ``self.vcov_`` is now sourced from the same
466+
combined ``compute_robust_vcov(return_dof=True)`` call as ``_bm_dof``. It
467+
must match an independently-computed CR2 vcov, and each coefficient's SE
468+
must equal its sqrt-diagonal. Pins the vcov VALUE directly (the other LR
469+
tests only pin ``_bm_dof`` end-to-end)."""
470+
from diff_diff.linalg import LinearRegression, compute_robust_vcov
471+
472+
rng = np.random.default_rng(4751)
473+
n = 36
474+
X = np.column_stack([np.ones(n), rng.normal(size=n), rng.normal(size=n)])
475+
y = X @ np.array([1.0, 0.5, -0.3]) + rng.normal(0, 0.5, n)
476+
w = rng.uniform(0.5, 2.0, n)
477+
cl = np.arange(n) % 6
478+
479+
for kw in (
480+
{"weights": w, "weight_type": "pweight", "cluster_ids": cl},
481+
{"weights": w, "weight_type": "pweight"},
482+
{}, # unweighted one-way
483+
):
484+
if "weights" in kw:
485+
coef = np.linalg.solve(X.T @ (X * w[:, None]), X.T @ (w * y))
486+
else:
487+
coef = np.linalg.solve(X.T @ X, X.T @ y)
488+
resid = y - X @ coef
489+
helper_vcov, _ = compute_robust_vcov(
490+
X,
491+
resid,
492+
cluster_ids=kw.get("cluster_ids"),
493+
weights=kw.get("weights"),
494+
weight_type=kw.get("weight_type", "pweight"),
495+
vcov_type="hc2_bm",
496+
return_dof=True,
497+
)
498+
499+
lr = LinearRegression(include_intercept=False, vcov_type="hc2_bm", **kw)
500+
lr.fit(X, y)
501+
np.testing.assert_allclose(
502+
lr.vcov_,
503+
helper_vcov,
504+
atol=1e-9,
505+
rtol=1e-9,
506+
err_msg=f"LinearRegression.vcov_ must match the CR2 helper vcov (kw={kw})",
507+
)
508+
for i in range(X.shape[1]):
509+
inf_i = lr.get_inference(index=i)
510+
np.testing.assert_allclose(
511+
inf_i.se,
512+
np.sqrt(lr.vcov_[i, i]),
513+
rtol=1e-12,
514+
err_msg=f"SE must be sqrt(vcov_[{i},{i}]) (kw={kw})",
515+
)
516+
517+
def test_lr_hc2_bm_cr2_sandwich_computed_once(self):
518+
"""#475 dedup mechanism proof: the CR2 sandwich
519+
(``_compute_robust_vcov_numpy``) is computed ONCE per hc2_bm fit, not
520+
twice (previously once inside ``solve_ols`` for the vcov + once via
521+
``compute_robust_vcov`` for the dof). Patching ``_compute_robust_vcov_numpy``
522+
covers BOTH the ``_compute_cr2_bm`` (weighted / clustered) and the
523+
unweighted-one-way ``_compute_bm_dof_oneway`` sub-paths."""
524+
import diff_diff.linalg as _linalg
525+
from diff_diff.linalg import LinearRegression
526+
527+
rng = np.random.default_rng(4752)
528+
n = 36
529+
X = np.column_stack([np.ones(n), rng.normal(size=n), rng.normal(size=n)])
530+
y = X @ np.array([1.0, 0.5, -0.3]) + rng.normal(0, 0.5, n)
531+
w = rng.uniform(0.5, 2.0, n)
532+
cl = np.arange(n) % 6
533+
534+
orig = _linalg._compute_robust_vcov_numpy
535+
for kw in (
536+
{"weights": w, "weight_type": "pweight"}, # weighted one-way
537+
{"weights": w, "weight_type": "pweight", "cluster_ids": cl}, # weighted cluster
538+
{}, # unweighted one-way (-> _compute_bm_dof_oneway)
539+
{"cluster_ids": cl}, # unweighted cluster
540+
):
541+
calls = {"n": 0}
542+
543+
def _counting(*a, _orig=orig, _calls=calls, **k):
544+
_calls["n"] += 1
545+
return _orig(*a, **k)
546+
547+
_linalg._compute_robust_vcov_numpy = _counting
548+
try:
549+
lr = LinearRegression(include_intercept=False, vcov_type="hc2_bm", **kw)
550+
lr.fit(X, y)
551+
finally:
552+
_linalg._compute_robust_vcov_numpy = orig
553+
assert lr._bm_dof is not None, f"_bm_dof must be set (kw={kw})"
554+
assert calls["n"] == 1, (
555+
f"CR2 sandwich must run once, not {calls['n']}x (kw={kw}); the #475 "
556+
"dedup collapses solve_ols's vcov + the separate dof recompute."
557+
)
558+
464559

465560
class TestWLSCR2FEDoFNoiseGuard:
466561
"""Regression for R6 P1: weighted CR2-BM Satterthwaite DOF for high-

0 commit comments

Comments
 (0)