perf(linalg): compute hc2_bm CR2 sandwich once, not twice (#475)#565
Conversation
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>
Overall Assessment✅ Looks good. No unmitigated P0/P1 findings. Executive Summary
MethodologyNo findings. The changed path in No undocumented formula, weighting, variance, SE, or assumption deviation found. Code QualityNo findings. The implementation keeps the change localized to Performance
MaintainabilityNo findings. The new tests in Tech Debt
SecurityNo findings. No secret-handling or external-input security surface is changed. Documentation/TestsNo PR defect found. The changelog documents the performance change and the warning-count side effect. Test coverage was added for the changed HC2-BM path. Verification note: |
MultiPeriodDiD(cluster=..., vcov_type="hc2_bm") built the expensive CR2 Bell-McCaffrey per-cluster precomputes (A_g eigendecompositions, S_W, the residual-maker M = I - H) twice per fit: once in solve_ols's vcov path (whose per-coefficient DOF was then discarded) and again in the separate _compute_cr2_bm_contrast_dof call for the per-coef + post-period-average ATT Satterthwaite DOF. Extract a shared core `_compute_cr2_bm_vcov_and_dof` (vcov + DOF for arbitrary contrasts from one precompute build); `_compute_cr2_bm` and _compute_cr2_bm_contrast_dof` become thin bit-identical wrappers over it (removing the formerly-duplicated ~50-line precompute block, so every CR2 caller routes through one implementation). The shared filter guards the residuals subscript with `residuals is not None` so DOF-only callers that pass weights with zero rows do not crash. At the MPD fit level, bypass solve_ols's vcov on the cluster+hc2_bm (non-survey, unweighted) path and compute vcov + the combined per-coef/avg-ATT DOF from one call, then expand with NaN for dropped columns; the `survey_weights is None` clause keeps the bypass byte-identical to solve_ols (else it falls back to the prior two-call behavior). Bit-identical vcov_, per-period DOF, avg-ATT DOF, p-values, and CIs (proven at atol=0 across balanced, unbalanced, and rank-deficient designs); a mechanism test asserts the per-cluster adjustment matrix is built exactly once per cluster (down from twice). No methodology, numerical, or public-API change. Updates the stale REGISTRY note that described the prior twice-built precompute. Resolves the MPD cluster+hc2_bm Performance row in TODO.md and completes the MultiPeriodDiD follow-up noted in the #565 LinearRegression entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MultiPeriodDiD(cluster=..., vcov_type="hc2_bm") built the expensive CR2 Bell-McCaffrey per-cluster precomputes (A_g eigendecompositions, S_W, the residual-maker M = I - H) twice per fit: once in solve_ols's vcov path (whose per-coefficient DOF was then discarded) and again in the separate _compute_cr2_bm_contrast_dof call for the per-coef + post-period-average ATT Satterthwaite DOF. Extract a shared core `_compute_cr2_bm_vcov_and_dof` (vcov + DOF for arbitrary contrasts from one precompute build); `_compute_cr2_bm` and _compute_cr2_bm_contrast_dof` become thin bit-identical wrappers over it (removing the formerly-duplicated ~50-line precompute block, so every CR2 caller routes through one implementation). The shared filter guards the residuals subscript with `residuals is not None` so DOF-only callers that pass weights with zero rows do not crash. At the MPD fit level, bypass solve_ols's vcov on the cluster+hc2_bm (non-survey, unweighted) path and compute vcov + the combined per-coef/avg-ATT DOF from one call, then expand with NaN for dropped columns; the `survey_weights is None` clause keeps the bypass byte-identical to solve_ols (else it falls back to the prior two-call behavior). Bit-identical vcov_, per-period DOF, avg-ATT DOF, p-values, and CIs (proven at atol=0 across balanced, unbalanced, and rank-deficient designs); a mechanism test asserts the per-cluster adjustment matrix is built exactly once per cluster (down from twice). No methodology, numerical, or public-API change. Updates the stale REGISTRY note that described the prior twice-built precompute. Resolves the MPD cluster+hc2_bm Performance row in TODO.md and completes the MultiPeriodDiD follow-up noted in the #565 LinearRegression entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
LinearRegression(vcov_type="hc2_bm"): compute the CR2 sandwich once, not twice. The fit path computed the CR2/HC2-BM vcov insidesolve_ols, then recomputed the entire Bell-McCaffrey sandwich (per-clusterA_g, theO(n²k)Satterthwaite loop) a second time viacompute_robust_vcov(return_dof=True)just to extract the per-coefficient DOF._compute_cr2_bm/_compute_bm_dof_onewayalready return(vcov, dof)together, so the second sandwich was pure waste.hc2_bm-and-not-survey path, skipsolve_ols's vcov (return_vcov=Falsevia a new_is_bm_pathflag) and source BOTHself.vcov_andself._bm_doffrom a singlecompute_robust_vcov(..., return_dof=True)call, reusing the existing_expand_vcov_with_nanrank-deficient expansion.solve_ols(and its ~50 callers) is left untouched. Affects everyhc2_bmfit — weighted one-way WLS-CR2, weighted/unweighted clustered CR2-BM (DiD/TWFE/MPDwithvcov_type="hc2_bm").#475Performance backlog row (CI-codex P3 from PR Lift hc2_bm + weights gates via clubSandwich WLS-CR2 port #475).Methodology references
LinearRegressionHC2-BM / CR2 variance — Imbens & Kolesár (2016) one-way Bell-McCaffrey; Pustejovsky & Tipton (2018) /clubSandwichCR2 (incl. thepweightWLS-CR2 port). Seedocs/methodology/REGISTRY.md"Variance Estimation, Cluster-Robust SE".solve_olsvcov and the combined call route through the same_compute_robust_vcov_numpy). No methodology / numerical / public-API change → no REGISTRY edit.Validation
tests/test_methodology_wls_cr2.py): (a)test_lr_hc2_bm_vcov_value_matches_helper— directly pinsself.vcov_against an independently-computed CR2 vcov + asserts each SE is its sqrt-diagonal (weighted cluster / weighted one-way / unweighted); (b)test_lr_hc2_bm_cr2_sandwich_computed_once— mechanism guard: patches_compute_robust_vcov_numpyand asserts the sandwich runs once, not twice across weighted/unweighted × one-way/clustered (covers both the_compute_cr2_bmand unweighted-one-way_compute_bm_dof_onewaysub-paths).atol=0(dev capture) across weighted/unweighted × one-way/clustered + rank-deficient:vcov,_bm_dof, and per-coefficient SE/DOF/p all exact-match pre/post.test_methodology_wls_cr2.py(vcov + dofatol=1e-10),test_linalg_hc2_bm.py(clubSandwichatol=1e-6). Targeted suite:test_methodology_wls_cr2.py+test_linalg_hc2_bm.py(64) andtest_linalg.py+test_estimators.py(269 passed / 1 skipped) all green. black / ruff / mypy clean (0 new).UserWarningnow fires once per fit instead of twice (no test asserts a count).Security / privacy
🤖 Generated with Claude Code