Skip to content

Commit 196f910

Browse files
igerberclaude
andcommitted
Reject negative-dose samples in mse_optimal_bandwidth (P1)
CI AI review P1: mse_optimal_bandwidth did not enforce the HAD support requirement D_{g,2} >= 0. Negative-dose samples could silently pass through both boundary branches: boundary=0 (accepted via _at_zero even with d_min<0) and boundary=float(d.min()) (accepts any lower edge). The symmetric nprobust kernel would happily calibrate a two-sided interior bandwidth while the downstream one-sided fitter runs on [boundary, boundary+h] -- silent assumption violation. Front-door check added: np.any(d < 0) raises ValueError with a message citing the paper's support assumption. Two new regression tests: - test_negative_dose_rejected_boundary_zero: d ~ U(-0.5, 0.5) with boundary=0 raises. - test_negative_dose_rejected_boundary_at_d_min: d ~ U(-1, -0.1) with boundary=d.min() raises. Deferred (P3 same as last round): tri/uni/shifted-boundary golden parity extension. All three kernels share lprobust_bw, so epa parity transitively covers kernel dispatch. 177 tests pass (up from 175). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 044baed commit 196f910

2 files changed

Lines changed: 37 additions & 0 deletions

File tree

diff_diff/local_linear.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,25 @@ def mse_optimal_bandwidth(
622622
if not np.isfinite(boundary):
623623
raise ValueError(f"boundary must be finite; got {boundary}")
624624

625+
# HAD support restriction: de Chaisemartin et al. (2026) Assumption
626+
# (dose definition in Section 2) treats ``D_{g,2}`` as the period-2
627+
# treatment dose with ``D_{g,2} >= 0``. Negative dose values are
628+
# outside the HAD design and would silently calibrate the selector
629+
# against a symmetric-kernel two-sided problem while the downstream
630+
# fitter remains one-sided. Reject front-door rather than produce a
631+
# plausible bandwidth on a malformed input.
632+
d_neg = d < 0.0
633+
if np.any(d_neg):
634+
n_neg = int(d_neg.sum())
635+
min_neg = float(d[d_neg].min())
636+
raise ValueError(
637+
f"Negative dose values detected in d (n_neg={n_neg}, "
638+
f"min={min_neg!r}). The HAD estimator (de Chaisemartin et "
639+
f"al. 2026) requires the period-2 dose D_{{g,2}} >= 0. "
640+
f"Nonnegative-dose data is required for both Design 1' "
641+
f"(d_lower = 0) and Design 1 (d_lower > 0)."
642+
)
643+
625644
# Boundary-applicability check (Phase 1b scope).
626645
# The exported wrapper is scoped to the two documented HAD
627646
# nonparametric estimands:

tests/test_bandwidth_selector.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,24 @@ def test_negative_boundary_rejected(self):
376376
with pytest.raises(ValueError, match="not at a supported HAD estimand"):
377377
mse_optimal_bandwidth(d, y, boundary=-0.1)
378378

379+
def test_negative_dose_rejected_boundary_zero(self):
380+
"""HAD requires D_{g,2} >= 0. Negative-dose samples must be
381+
rejected at the wrapper level, even under boundary=0."""
382+
rng = np.random.default_rng(2026)
383+
d = rng.uniform(-0.5, 0.5, size=1500) # mix of negative and positive
384+
y = d + rng.normal(0, 0.3, size=1500)
385+
with pytest.raises(ValueError, match="Negative dose values"):
386+
mse_optimal_bandwidth(d, y, boundary=0.0)
387+
388+
def test_negative_dose_rejected_boundary_at_d_min(self):
389+
"""Negative-dose samples must also be rejected when the caller
390+
tries to pass boundary=d.min() < 0."""
391+
rng = np.random.default_rng(2026)
392+
d = rng.uniform(-1.0, -0.1, size=1500) # entirely negative
393+
y = d + rng.normal(0, 0.3, size=1500)
394+
with pytest.raises(ValueError, match="Negative dose values"):
395+
mse_optimal_bandwidth(d, y, boundary=float(d.min()))
396+
379397
def test_mass_point_design_rejected(self):
380398
"""Design 1 mass-point case (boundary > 0, modal fraction > 2%)
381399
must be rejected with NotImplementedError pointing to 2SLS."""

0 commit comments

Comments
 (0)