Skip to content

Commit 8bd7171

Browse files
igerberclaude
andcommitted
fix(linalg): cluster-count check precedes saturated-design NaN guard (CI review P1)
The saturated-design guard added earlier (n_eff <= k -> NaN vcov) was placed before the cluster-robust "need >= 2 clusters" validation, so a 1-cluster *saturated* cluster-robust request returned all-NaN instead of raising the documented ValueError (verified: compute_robust_vcov(eye(2), 0, zeros(2)) now raises; a non-saturated 1-cluster fit always raised). The cluster-count check (with the zero-total-weight effective-cluster exclusion) now runs first on the clustered path; the saturated NaN return only applies once that assumption holds. Multi-cluster saturated designs still return NaN (no ZeroDivisionError), and full-rank fits are unchanged. Tests: 1-cluster saturated -> ValueError; 2-cluster saturated -> NaN vcov (tests/test_linalg.py::TestComputeRobustVcov). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 91a458d commit 8bd7171

2 files changed

Lines changed: 33 additions & 3 deletions

File tree

diff_diff/linalg.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2623,6 +2623,22 @@ def _compute_robust_vcov_numpy(
26232623
# ------------------------------------------------------------------
26242624
assert vcov_type == "hc1"
26252625

2626+
# Cluster-robust validity check FIRST: a cluster-robust request with fewer
2627+
# than 2 clusters is invalid and must raise the documented error — this has
2628+
# to precede the saturated-design guard below so a 1-cluster *saturated* fit
2629+
# still raises rather than being masked by the NaN return. (Mirrors the
2630+
# effective-cluster count computed in the cluster branch, including the
2631+
# zero-total-weight exclusion.)
2632+
if cluster_ids is not None:
2633+
n_clusters_check = len(np.unique(cluster_ids))
2634+
if weights is not None and weight_type != "fweight" and np.any(weights == 0):
2635+
cluster_weight_sums = pd.Series(weights).groupby(cluster_ids).sum()
2636+
n_clusters_check = int((cluster_weight_sums > 0).sum())
2637+
if n_clusters_check < 2:
2638+
raise ValueError(
2639+
f"Need at least 2 clusters for cluster-robust SEs, got {n_clusters_check}"
2640+
)
2641+
26262642
# Saturated design (no residual degrees of freedom): both the HC1 adjustment
26272643
# n_eff/(n_eff-k) and the CR1 adjustment (n_eff-1)/(n_eff-k) divide by
26282644
# (n_eff - k), which is zero when the design exactly determines y. Return a

tests/test_linalg.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,22 @@ def test_cluster_robust_symmetric(self, ols_data):
531531

532532
np.testing.assert_array_almost_equal(vcov, vcov.T)
533533

534+
def test_cluster_count_check_precedes_saturated_guard(self):
535+
"""A 1-cluster cluster-robust request raises 'need >= 2 clusters' even on
536+
a saturated design — the cluster-count validation must precede the
537+
saturated (no residual DOF) NaN guard, not be masked by it."""
538+
# 1 cluster AND saturated (n == k): must still raise, not return NaN.
539+
with pytest.raises(ValueError, match="at least 2 clusters"):
540+
compute_robust_vcov(np.eye(2), np.zeros(2), np.zeros(2))
541+
542+
def test_saturated_multi_cluster_returns_nan(self):
543+
"""A saturated design (no residual DOF) with >= 2 clusters returns a NaN
544+
vcov rather than raising ZeroDivisionError from the CR1 adjustment."""
545+
# 2 clusters, n == k == 4 (saturated 2x2 with one obs per cluster-period).
546+
X = np.column_stack([np.ones(4), [0, 0, 1, 1], [0.0, 1.0, 0.0, 1.0], [0, 0, 0, 1.0]])
547+
vcov = compute_robust_vcov(X, np.zeros(4), np.array([0, 0, 1, 1]))
548+
assert np.all(np.isnan(vcov))
549+
534550
def test_numerical_instability_fallback_warns(self, ols_data):
535551
"""Test that numerical instability in Rust backend triggers warning and fallback."""
536552
from unittest.mock import patch
@@ -2140,9 +2156,7 @@ def test_tracker_appended_once_on_truncation_only(self):
21402156
rng = np.random.RandomState(3)
21412157
n = 50
21422158
# Deficient: constant column collinear with intercept.
2143-
A_def = self._gram(
2144-
np.column_stack([np.ones(n), np.full(n, 2.0), rng.standard_normal(n)])
2145-
)
2159+
A_def = self._gram(np.column_stack([np.ones(n), np.full(n, 2.0), rng.standard_normal(n)]))
21462160
tracker = []
21472161
_rank_guarded_inv(A_def, tracker=tracker)
21482162
assert len(tracker) == 1 # exactly one condition-number sample

0 commit comments

Comments
 (0)