Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ Related tracking surfaces:
| Issue | Location | Origin | Effort | Priority |
|-------|----------|--------|--------|----------|
| Consolidate the remaining estimator-entangled DR/logit score variants (`staggered.py::_doubly_robust` + RC twins, `triple_diff.py`, `lwdid.py`, `wooldridge.py`) onto the shared `_dr_scores.py` module, each migration with its own committed oracle capture (the ContinuousDiD lift's two-tier pattern in `tests/test_dr_scores.py`); and add a ridge vcov path to `solve_ridge` if an estimator ever needs analytical ridge inference | `diff_diff/_dr_scores.py` | dml-b0 | Mid | Low |
| hc2/hc2_bm floor `1 - h_ii` at 1e-10 in the shared leverage meat, fabricating finite (if inflated) variances for leverage-one observations - hc3 now fails closed there (LWDiD fix wave) but the pre-existing hc2 family behavior is released surface; decide fail-closed vs keep-floor for hc2/hc2_bm | `diff_diff/linalg.py` | #588 | Quick | Low |
| Numeric between-period cohorts (e.g. `first_treat=4.5` with integer times) are rejected by LWDiD while CallawaySantAnna estimates them and LWDiD's own datetime/Period cohorts map to the next observed period — close the dtype asymmetry by adopting the next-observed-period mapping for numeric cohorts too (contract documented in REGISTRY cohort-encodings Note + `docs/api/lwdid.rst` Input Contract). Lands only after PR #588 merges | `diff_diff/lwdid.py` | #588 | Quick | Low |
| Implement the LW 2026 eq. 7.9/7.10 unit-average cohort estimand (regress per-unit post-average transformed outcomes on `[1, D_g]` vs never-treated) as an alternative to the documented cell-mass `cohort_effects` convention (REGISTRY within-cohort aggregation Note; the two differ on unbalanced panels, where cell-mass weights units by observed post periods). Needs the 7.10 regression + its covariance on the NT path. Lands only after PR #588 merges | `diff_diff/lwdid_staggered.py` | #588 | Quick | Low |
| Expose cell-mass overall ATT (Stata `Post_avg` convention; = CS-simple on balanced panels) as an aggregate extra on LWDiD results — the fit's `.att` is the paper's `tau_omega` (cohort-mean-then-treated-weight, eq. 7.18); the authors' large-N display uses cell-mass weighting instead, and both are legitimate estimands (see the REGISTRY LWDiD Aggregation note). Lands only after PR #588 merges | `diff_diff/lwdid_results.py` | #588 | Quick | Low |
Expand Down
51 changes: 51 additions & 0 deletions changelog.d/20260906-hc2-leverage-one-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
### Fixed
- **`hc2` / `hc2_bm` no longer report an understated finite variance at a
leverage-one observation**: the shared one-way leverage meat floored
`1 - h_ii` at 1e-10, which did not inflate the perfectly-fitted row's
term but silently dropped its outcome noise (probe `[1, D]` with a single
treated unit: HC2 SE 0.19 against the exact classical 0.47), and
`hc2_bm` paired that finite SE with a NaN Satterthwaite DOF. Both now
fail closed exactly like `hc3` (see Behavioral Changes). The Rust HC2
kernel mirrors the change (no floor; it signals and the Python dispatcher
re-dispatches to the NumPy branch, which emits the single warning). The
sibling floor in the one-way Bell-McCaffrey DOF helper is removed: at
leverage one every contrast's DOF is NaN because the HC2 variance it
belongs to is undefined; below leverage one nothing changes.
- **Zero-count `fweight` rows no longer trip the HC3 leverage guard or the
HC1 fallback**: under frequency weights the leverage of a zero-count row
is the unweighted quadratic form against the weighted bread and is
unbounded, so an inert row could NaN the `hc3` vcov or push `hc2` into an
HC1 fallback that broke expansion parity. Zero-count rows are excluded
from the guard and from the meat, so compressed HC2/HC3 equal the literal
`np.repeat` expansion at any leverage.
- `LWDiD` no longer emits a second, differently worded "HC2 variance is
undefined" warning per cell on leverage-one designs; the shared kernel's
warning is the only one (behavior otherwise unchanged: LWDiD already
failed closed there).

### Behavioral Changes
- **Leverage-one designs under `vcov_type="hc2"` (every weight type) and
under unweighted, unclustered `vcov_type="hc2_bm"` now return an all-NaN
covariance and DOF vector with a `UserWarning`** ("HC2 variance is
undefined: N observation(s) have hat-matrix leverage ~1 ...") whenever a
positive-weight row has `h_ii >= 1 - 1e-8`, matching the released `hc3`
contract and R `sandwich::vcovHC` (NaN at hat values ~1). Point
estimates are unchanged; `se`, `t_stat`, `p_value` and confidence
intervals are NaN. The former warn-and-fall-back-to-HC1 branch for
over-one leverage is retired (that case is inside the new guard).
Affected surfaces: `DifferenceInDifferences`, `MultiPeriodDiD` and
`LinearRegression` on both families; `TwoWayFixedEffects` on explicit
`hc2` and on `hc2_bm` in event-study `spec="pooled"` without `unit=`;
`SunAbraham` and `WooldridgeDiD` (OLS) full-dummy fits under one-way
`hc2` whenever the design has a singleton cohort x period cell. Weighted
(pweight) `hc2_bm` — including a no-op `weights=np.ones(n)` — keeps the
clubSandwich singleton-CR2 generalized-inverse result and stays finite,
as does clustered CR2; this asymmetry is a documented deviation from
`clubSandwich` for the unweighted case (maintainer decision, 2026-09).
Remedy: `vcov_type="classical"` (exact inference under homoskedastic
normal errors; needs positive residual df) or add observations to the
perfectly-fitted cell. `hc1` is deliberately not offered as a remedy: it
also omits the zero-residual row and would return the understated number.
- The shared leverage-one warning (also used by `hc3`) now names the
offending row indices and recommends classical exact inference or more
observations in the cell instead of "add treated units".
14 changes: 12 additions & 2 deletions diff_diff/estimators.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ class DifferenceInDifferences(BaseEstimator):
(library default). With ``cluster=``, uses CR1 (Liang-Zeger).
- ``"hc2"``: leverage-corrected meat (one-way only). Errors with
``cluster=``; use ``"hc2_bm"`` for clustered Bell-McCaffrey.
- ``"hc2_bm"``: one-way HC2 + Imbens-Kolesar (2016) Satterthwaite DOF;
A leverage-one observation has no defined HC2 variance and the
vcov fails closed (warning + NaN inference, point estimate
preserved) rather than flooring ``1 - h_ii``.
- ``"hc2_bm"``: one-way HC2 + Imbens-Kolesar (2016) Satterthwaite DOF
(unweighted and unclustered, it shares the ``hc2`` leverage-one
fail-closed contract);
with ``cluster=``, Pustejovsky-Tipton (2018) CR2 cluster-robust.
``MultiPeriodDiD(cluster=..., vcov_type="hc2_bm")`` is supported and
uses a cluster-aware Bell-McCaffrey contrast DOF for the
Expand Down Expand Up @@ -2513,9 +2518,14 @@ class MultiPeriodDiD(DifferenceInDifferences):
(library default). With ``cluster=``, uses CR1 (Liang-Zeger).
- ``"hc2"``: leverage-corrected meat (one-way only). Errors with
``cluster=``; use ``"hc2_bm"`` without cluster for Bell-McCaffrey.
A leverage-one observation has no defined HC2 variance and the
vcov fails closed (warning + NaN inference, point estimate
preserved) rather than flooring ``1 - h_ii``.
- ``"hc2_bm"``: one-way HC2 + Imbens-Kolesar (2016) Satterthwaite DOF
per coefficient plus a contrast-aware DOF for the post-period-average
ATT. With ``cluster=``, dispatches to Pustejovsky-Tipton (2018)
ATT (unclustered, it shares the ``hc2`` leverage-one fail-closed
contract: all-NaN vcov and DOF, point estimates preserved). With
``cluster=``, dispatches to Pustejovsky-Tipton (2018)
CR2 cluster-robust with a Bell-McCaffrey Satterthwaite contrast DOF
on the post-period average (see ``cluster`` above for parity
details). Weighted CR2-BM (``survey_design=``) is still gated.
Expand Down
Loading
Loading