Skip to content

Commit 4be416a

Browse files
igerberclaude
andcommitted
Wave D fail-closed HC1/CR1 multiplier on saturated stage-2 designs
When the kept stage-2 design saturates the sample (n_obs == effective_rank after rank-deficient drops), the HC1 multiplier n/(n-p) and the CR1 multiplier (n-1)/(n-p) are mathematically undefined. The original Wave D helper used `max(n - p_2, 1)` to clamp the denominator, which silently fabricated finite multipliers on underdetermined fits — `result.se` and per-coefficient SEs could stay finite even when only `t_stat`/`p_value`/CI were NaN-gated via `df_resid=0`. That violates the no-silent-failures contract. Fix: when n - p_2 <= 0, return NaN meat with an explicit UserWarning so the SE surface NaN-propagates consistently with the inference fields. The Conley path is unaffected (no finite-sample multiplier on that branch by convention). Tests: new `test_saturated_design_yields_nan_se_not_finite` in TestSpilloverDiDWaveDPublicVarianceContract exercises both the HC1 and CR1 paths on a synthetic n=p_2=4 Psi fixture; asserts NaN meat AND the saturation warning fires. Docs: replaced "Wave B MVP limitations" section heading at docs/api/spillover.rst with "Restrictions and follow-ups" (the section now describes the shipped Wave D variance + remaining limitations); updated the SpilloverDiD vs TwoStageDiD comparison table to label the Conley and cluster rows "(Wave D GMM-corrected sandwich)" instead of "(via solve_ols at stage 2)". All 224 spillover tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cb1ad09 commit 4be416a

3 files changed

Lines changed: 85 additions & 9 deletions

File tree

diff_diff/two_stage.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,23 @@ def _compute_gmm_corrected_meat(
208208
# 3. Kernel dispatch.
209209
if vcov_type == "hc1":
210210
# K = I_n: meat = Psi' Psi with HC1 finite-sample multiplier.
211+
# Fail closed when n - p_2 <= 0 (saturated design — every degree
212+
# of freedom consumed by the stage-2 design): the multiplier
213+
# n / (n - p_2) is undefined, so NaN-propagate per
214+
# `feedback_no_silent_failures` rather than clamping the
215+
# denominator and emitting finite SE on an underdetermined fit.
216+
if n - p_2 <= 0:
217+
warnings.warn(
218+
"SpilloverDiD Wave D HC1 sandwich: saturated stage-2 design "
219+
f"(n_obs={n}, effective_rank={p_2}, n-p_2={n - p_2} <= 0). "
220+
"The HC1 finite-sample multiplier n/(n-p) is undefined. "
221+
"Returning NaN meat so downstream inference NaN-propagates.",
222+
UserWarning,
223+
stacklevel=2,
224+
)
225+
return np.full((p_2, p_2), np.nan)
211226
meat_unscaled = Psi.T @ Psi
212-
multiplier = n / max(n - p_2, 1)
227+
multiplier = n / (n - p_2)
213228
meat = multiplier * meat_unscaled
214229
elif vcov_type == "cluster":
215230
if cluster_ids is None:
@@ -223,16 +238,29 @@ def _compute_gmm_corrected_meat(
223238
G = len(unique_clusters)
224239
# Mirror linalg.py:1942 — reject G<2 so the CR1 finite-sample
225240
# multiplier G/(G-1) doesn't fabricate finite output on a degenerate
226-
# one-cluster sample. (Round 1 codex P0 fix.)
241+
# one-cluster sample.
227242
if G < 2:
228243
raise ValueError(f"Need at least 2 clusters for cluster-robust SEs, got {G}")
244+
# Fail closed on saturated design (n - p_2 <= 0). The CR1
245+
# multiplier (n-1)/(n-p) is undefined; emitting finite SE here
246+
# would be silently wrong.
247+
if n - p_2 <= 0:
248+
warnings.warn(
249+
"SpilloverDiD Wave D CR1 sandwich: saturated stage-2 design "
250+
f"(n_obs={n}, effective_rank={p_2}, n-p_2={n - p_2} <= 0). "
251+
"The CR1 finite-sample multiplier (n-1)/(n-p) is undefined. "
252+
"Returning NaN meat so downstream inference NaN-propagates.",
253+
UserWarning,
254+
stacklevel=2,
255+
)
256+
return np.full((p_2, p_2), np.nan)
229257
S_cluster = np.zeros((G, p_2))
230258
for j in range(p_2):
231259
np.add.at(S_cluster[:, j], cluster_indices, Psi[:, j])
232260
meat_unscaled = S_cluster.T @ S_cluster
233261
# CR1 finite-sample multiplier: G/(G-1) * (n-1)/(n-p_2). Standard
234262
# cluster-robust convention (Stata, R `sandwich::vcovCL(type='CR1')`).
235-
multiplier = (G / (G - 1)) * ((n - 1) / max(n - p_2, 1))
263+
multiplier = (G / (G - 1)) * ((n - 1) / (n - p_2))
236264
meat = multiplier * meat_unscaled
237265
elif vcov_type == "conley":
238266
if conley_coords is None or conley_cutoff_km is None or conley_metric is None:

docs/api/spillover.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,19 @@ Estimator Comparison
160160
- ``D=0`` (untreated)
161161
- N/A (single stage)
162162
* - Conley spatial-HAC SE
163-
- Yes (via solve_ols at stage 2)
163+
- Yes (Wave D GMM-corrected sandwich)
164164
- Not yet supported
165165
- Yes
166166
* - Cluster-robust SE
167-
- Yes (HC1 + CR1 via solve_ols)
167+
- Yes (HC1 + CR1, Wave D GMM-corrected sandwich)
168168
- Yes (GMM sandwich + clusters)
169169
- Yes
170170

171-
Wave B MVP limitations
172-
----------------------
171+
Restrictions and follow-ups
172+
---------------------------
173173

174-
The current implementation has the following documented limitations,
175-
planned as follow-up enhancements:
174+
The current implementation has the following documented restrictions
175+
and planned follow-up enhancements:
176176

177177
- **Gardner GMM first-stage correction at stage 2** — SHIPPED in Wave D.
178178
Stage-2 variance now applies the influence-function-based correction

tests/test_spillover.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4458,6 +4458,54 @@ def test_single_cluster_sample_raises(self):
44584458
with pytest.raises(ValueError, match="at least 2 clusters"):
44594459
est.fit(df, outcome="y", unit="unit", time="time", treatment="D")
44604460

4461+
def test_saturated_design_yields_nan_se_not_finite(self):
4462+
"""`n_obs == p_2` saturated stage-2 design: HC1 multiplier
4463+
``n/(n-p)`` is undefined. Wave D fails closed by returning NaN
4464+
meat → NaN SE downstream, rather than clamping the denominator
4465+
to 1 and emitting a finite SE on an underdetermined fit.
4466+
"""
4467+
from scipy import sparse
4468+
4469+
from diff_diff.two_stage import _compute_gmm_corrected_meat
4470+
4471+
# Construct a saturated synthetic Psi fixture directly through the
4472+
# helper (avoids manufacturing a real saturated SpilloverDiD panel,
4473+
# which is constrained by the validator). n_obs == p_2 == 4.
4474+
n, p_1, p_2 = 4, 3, 4
4475+
rng = np.random.default_rng(0)
4476+
X_1 = sparse.csr_matrix(rng.standard_normal((n, p_1)))
4477+
X_10 = sparse.csr_matrix(rng.standard_normal((n, p_1)))
4478+
eps_10 = rng.standard_normal(n)
4479+
X_2 = rng.standard_normal((n, p_2))
4480+
eps_2 = rng.standard_normal(n)
4481+
4482+
import warnings as _w
4483+
4484+
for vmode, kwargs in [
4485+
("hc1", {}),
4486+
("cluster", {"cluster_ids": np.array([0, 0, 1, 1])}),
4487+
]:
4488+
with _w.catch_warnings(record=True) as caught:
4489+
_w.simplefilter("always")
4490+
meat = _compute_gmm_corrected_meat(
4491+
X_1_sparse=X_1,
4492+
X_10_sparse=X_10,
4493+
eps_10=eps_10,
4494+
X_2=X_2,
4495+
eps_2=eps_2,
4496+
vcov_type=vmode,
4497+
**kwargs,
4498+
)
4499+
assert np.all(np.isnan(meat)), (
4500+
f"vcov_type={vmode!r} saturated design (n=p_2={n}) returned "
4501+
f"finite meat instead of NaN: {meat!r}"
4502+
)
4503+
saturation_warning_fired = any("saturated" in str(w.message) for w in caught)
4504+
assert saturation_warning_fired, (
4505+
f"vcov_type={vmode!r} saturated design did not emit the "
4506+
f"expected saturation warning"
4507+
)
4508+
44614509
def test_classical_vcov_raises_with_clear_message(self):
44624510
"""`vcov_type="classical"` raises NotImplementedError upfront with a
44634511
clear remediation message rather than failing deep inside the GMM

0 commit comments

Comments
 (0)