fix(linalg): hc2 / unweighted hc2_bm fail closed at leverage-one observations (family-wide with hc3) - #824
fix(linalg): hc2 / unweighted hc2_bm fail closed at leverage-one observations (family-wide with hc3)#824igerber wants to merge 1 commit into
Conversation
…rvations (family-wide with hc3)
PR ReviewOverall assessment✅ Looks good — no unmitigated P0 or P1 findings. Executive summary
MethodologyM1 — Documented HC2-BM deviation
The HC2/HC3 denominator behavior agrees with MacKinnon–White at exact leverage one, and suppressing Bell–McCaffrey DOF when its covariance is undefined is internally consistent. Code QualityNo findings. The warning, NaN return shape, and affected-family labels are centralized in helpers at PerformanceNo findings. The guard occurs before meat construction, and Rust-to-NumPy redispatch is limited to the exceptional leverage-one path. MaintainabilityNo findings. Python and Rust use the same threshold and sentinel contract, with legacy-sentinel compatibility. Tech DebtNo findings. The resolved backlog row is removed from SecurityNo findings. No secrets, unsafe input handling, or security-sensitive changes were identified. Documentation/TestsNo findings. User-visible behavior has the required fragment at |
Summary
Resolves the
TODO.mdbacklog row "hc2/hc2_bm floor1 - h_iiat 1e-10 in the shared leverage meat" (origin #588). At a leverage-one observation,vcov_type="hc2"(every weight type) and unweighted, unclusteredvcov_type="hc2_bm"now return an all-NaN covariance and DOF vector with aUserWarning, the same contracthc3already had. Point estimates are unchanged; only the undefined inference is suppressed.Why. A perfectly fitted row (the single treated unit under
[1, D], a singleton cohort x period cell in a full-dummy design) has hat-matrix leverage 1 and residual exactly 0, so the leverage-corrected meat termu_i^2 / (1 - h_ii)is 0/0. The former floormax(1 - h, 1e-10)did not inflate that term; it silently dropped the row's outcome noise and reported an understated finite variance. On the probe design (X = [1, D], n = 6, one treated row,y = numpy.random.default_rng(0).normal(size=6)):Source evidence. R
sandwich::vcovHC(type = "HC2")and"HC3"return NaN for every coefficient at exact leverage one on this probe and warn that the covariances are undefined there. The new hc2 behavior agrees on this probe; the library retains its existing hc3 threshold ofh_ii >= 1 - 1e-8, rather than claiming identical behavior to R throughout the near-one region. RclubSandwich::vcovCR(cluster = 1:n, type = "CR2")returns a finite variance on the same design (SE 0.1915, Satterthwaite df 4) because its Moore-Penrose generalized inverse zeroes that row's adjustment; that is the same understated value the floor produced. Unweighted one-wayhc2_bmtherefore deviates fromclubSandwichand is recorded with the**Deviation from R:**label indocs/methodology/REGISTRY.md.Final behavior.
h_ii >= 1 - 1e-8. The whole vcov is NaN (as in R and inhc3), not per coefficient. The warning names the offending rows, states that point estimates are unaffected, and recommendsvcov_type="classical"(exact inference under homoskedastic normal errors, which needs positive residual df) or adding observations to the perfectly fitted cell.hc1is deliberately not offered as a remedy because at such a row it also omits the zero-residual observation and returns the understated number.fweight, a zero-count row's leverage (the unweighted quadratic form against the weighted bread) is unbounded and meaningless. Zero-count rows are excluded from the guard and from the meat, so compressed HC2/HC3 equal the literalnp.repeatexpansion at any leverage. This also fixes a pre-existing case where an inert zero-count row could NaN thehc3vcov or pushhc2into the HC1 fallback.hc2_bmroutes through the clubSandwich WLS-CR2 port and keeps its finite generalized-inverse result at leverage one, including under a no-opweights=np.ones(n); clustered CR2 keeps the Moore-Penrose absorbed-cluster-FE convention. Both are documented as unchanged, and the resultinghc2_bmversushc2_bm + weights=onesdiscontinuity is stated in the registry and changelog.h == 1exactly, so the extension must be rebuilt (maturin develop --release); a direct-kernel test fails loudly on a stale build.LWDiDno longer emits a second, differently worded warning per cell; it already failed closed on these designs and its behavior is otherwise unchanged.Affected estimator paths (verified by tracing the vcov call on fixtures with maximum leverage 1.0):
DifferenceInDifferences,MultiPeriodDiDandLinearRegressionon both families;TwoWayFixedEffectson explicithc2and onhc2_bmin event-studyspec="pooled"withoutunit=(no auto-cluster);SunAbrahamandWooldridgeDiD(OLS) on one-wayhc2whenever the full-dummy design has a singleton cohort x period cell. Such fits previously returned finite, understated SEs and now return NaN inference with preserved point estimates.StackedDiDcannot reach the branch (hc2rejected at__init__;hc2_bmalways clustered).Files.
diff_diff/linalg.py(guard, floor removal, fweight handling, DOF helper, Rust dispatch, docstrings),rust/src/linalg.rs,diff_diff/lwdid.py, class docstrings inestimators.py/twfe.py/sun_abraham.py/wooldridge.py,docs/api/lwdid.rst, tutorial 31 (markdown cell only),docs/methodology/variance-conventions.md,docs/methodology/REGISTRY.md(new family-wide Note, Deviation from R, weighted/clustered Note; amended one-way-scores, fweight, guard-ordering and LWDiD notes),TODO.mdrow removed,changelog.d/20260906-hc2-leverage-one-fail-closed.md.Methodology references (required if estimator / math changes)
sandwich::vcovHC(HC2/HC3 NaN at exact leverage one on the probe) and RclubSandwich::vcovCR(cluster = 1:n, type = "CR2")(finite generalized-inverse variance), both run locally on the probe design.hc2_bmfails closed whereclubSandwich's singleton CR2 returns a finite understated variance (the observation's outcome noise is dropped); recorded as**Deviation from R:**in REGISTRY.md. Weightedhc2_bmand clustered CR2 keep the clubSandwich convention (recorded as a**Note:**).Validation
tests/test_linalg_hc2_bm.py::TestLeverageOneFailsClosed: both entry points and both families with and without DOF; message count and five-row preview on a six-dummy design; aweight/pweight; fweight count-1 vs count-2 singleton; zero-count fweight expansion parity for hc2 and hc3 at leverage 1.0 and 2.7; mixed design proving the zero-count mask does not disable the guard; weightedhc2_bm+ ones stays at the clubSandwich value; over-one leverage; legacy Rust sentinel re-dispatch; no-floor denominator on a defined high-leverage design; DOF helper; warning attribution.tests/test_estimators_vcov_type.py::TestLeverageOneEstimators: DiD (hc2, hc2_bm), MultiPeriodDiD unclustered hc2_bm (per-period and post-average), TWFE pooled event study withoutunit=plus its clustered control, TWFE static explicit hc2, SunAbraham singleton cohort, WooldridgeDiD singleton cohort; each checks the warning, point estimates against a classical/hc1 fit of the same panel, and all-NaN inference.tests/test_rust_backend.py::TestRustHC2Vcov: fail-closed parity on an exact-unit-leverage design, direct-kernel sentinel test, legacy-sentinel re-dispatch, warning attribution; class docstring updated.tests/test_linalg.py::TestOneWayBMScoresDOF::test_leverage_one_nans_every_contrast(replaces the per-contrast pin),tests/test_lwdid.pyleverage-one hc2 test (kernel-shaped warnings only),tests/test_methodology_wls_cr2.pyequivalence test scoped to non-degenerate designs.maturin develop --release):pytest tests/test_linalg_hc2_bm.py tests/test_linalg.py tests/test_rust_backend.py tests/test_methodology_wls_cr2.py tests/test_estimators_vcov_type.py tests/test_lwdid.py: 881 passed, 28 deselected.DIFF_DIFF_BACKEND=python pytest tests/test_linalg_hc2_bm.py tests/test_rust_backend.py tests/test_linalg.py: 290 passed, 150 skipped.test_tracking_files.py,test_changelog_fragments.py,test_docs_ia.py,test_variance_conventions.py): 2602 passed, 45 skipped.cargo test --release: 57 passed;cargo fmt --check: clean.ruff check diff_diff tests,black --check diff_diff tests,.claude/scripts/changelog_compile.py check: clean.mypy diff_diffwith the project configuration (Python 3.10 target) fails on unchanged pinned source because the frozen environment's NumPy 2.5.2 stubs use Python 3.12typestatements (numpy/__init__.pyi:737); the changed modules type-check cleanly withmypy --python-version 3.14, and the environment and project configuration were left unchanged, so the CI Lint Gate with its NumPy 2.4.5 pin is the authoritative type check. The pre-merge scan reports one Check-D pattern hit atdiff_diff/linalg.py:5593(_compute_confidence_intervaldefinition); it is pre-existing and outside this diff. The full test suite and slow-marked tests were not run locally.Security / privacy
Changelog
changelog.d/20260906-hc2-leverage-one-fail-closed.md(Fixed + Behavioral Changes).