Skip to content

Commit 7b028fb

Browse files
igerberclaude
andcommitted
HonestDiD: surface ARP vertex-enumeration pathologies via RuntimeWarning
`_enumerate_vertices` was swallowing `np.linalg.LinAlgError` on every basis with `try / except / continue`, and `_compute_arp_test` returned False (conservative non-rejection) when the returned list was empty. Users had no way to tell whether the test "did not reject" because the data didn't support rejection or because the vertex search was numerically pathological (singular A_sys on every basis, degenerate moment-inequality system). Instrument `_enumerate_vertices` with three counters (`n_total`, `n_linalg_error`, `n_infeasible`) and emit a `RuntimeWarning` at function exit when: 1. `vertices == []` after `n_total > 0` bases tried — enumeration exhausted; caller will fall back to conservative non-rejection. 2. `vertices != []` but `n_linalg_error / n_total >= 0.5` — enumeration heavily constrained; recovered vertices may be numerically fragile. `RuntimeWarning` (not `UserWarning`) marks this as a numerical / algorithmic signal rather than a user-input issue. `stacklevel=3` so the warning surfaces at `_compute_arp_test`'s caller, matching the codebase convention for one-level-deep helper warnings. No changes to the return type, the caller (`_compute_arp_test`), or the algorithm semantics — the previous silent-skip behavior is fully preserved, the diagnostic is purely additive. Tier-A row in the post-Wave-2 backlog (TODO.md, item 6, PR #334 reference). Adds new `TestARPVertexEnumeration` class in test_methodology_honest_did.py with three cases: exhausted enumeration, heavy rejection, healthy enumeration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a7a1df2 commit 7b028fb

2 files changed

Lines changed: 108 additions & 1 deletion

File tree

diff_diff/honest_did.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"""
1919

2020
from dataclasses import dataclass, field
21+
import warnings
2122
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
2223

2324
import numpy as np
@@ -1890,11 +1891,15 @@ def _enumerate_vertices(
18901891
if n_eq > n_moments:
18911892
return []
18921893

1893-
vertices = []
1894+
vertices: List[np.ndarray] = []
1895+
n_total = 0
1896+
n_linalg_error = 0
1897+
n_infeasible = 0
18941898

18951899
# Each vertex has exactly n_eq non-zero (basic) variables
18961900
for basis_idx in itertools.combinations(range(n_moments), n_eq):
18971901
basis_idx = list(basis_idx)
1902+
n_total += 1
18981903

18991904
# Build the system for basic variables
19001905
# gamma[basis_idx]' @ X_tilde[basis_idx, :] = 0
@@ -1913,13 +1918,43 @@ def _enumerate_vertices(
19131918
try:
19141919
gamma_basic = np.linalg.solve(A_sys, b_sys)
19151920
except np.linalg.LinAlgError:
1921+
n_linalg_error += 1
19161922
continue
19171923

19181924
# Check feasibility: gamma >= 0
19191925
if np.all(gamma_basic >= -1e-10):
19201926
gamma = np.zeros(n_moments)
19211927
gamma[basis_idx] = np.maximum(gamma_basic, 0)
19221928
vertices.append(gamma)
1929+
else:
1930+
n_infeasible += 1
1931+
1932+
# Diagnostic warnings — surface vertex-search pathologies that would
1933+
# otherwise hide behind `_compute_arp_test` returning False
1934+
# (conservative non-rejection).
1935+
if n_total > 0 and len(vertices) == 0:
1936+
warnings.warn(
1937+
f"ARP vertex enumeration exhausted without feasible vertices: "
1938+
f"tried {n_total} bases, {n_linalg_error} rejected for "
1939+
f"LinAlgError, {n_infeasible} infeasible (negative basic "
1940+
f"variables). The caller (_compute_arp_test) will return False "
1941+
f"(conservative non-rejection). This may indicate near-singular "
1942+
f"nuisance constraints (X_tilde) or a degenerate "
1943+
f"moment-inequality system.",
1944+
RuntimeWarning,
1945+
stacklevel=3,
1946+
)
1947+
elif n_total > 0 and n_linalg_error / n_total >= 0.5:
1948+
warnings.warn(
1949+
f"ARP vertex enumeration heavily constrained: "
1950+
f"{n_linalg_error} of {n_total} bases ({100 * n_linalg_error / n_total:.0f}%) "
1951+
f"rejected for LinAlgError, {n_infeasible} infeasible. "
1952+
f"{len(vertices)} feasible vertex(es) recovered. Results may be "
1953+
f"numerically fragile; consider regularizing the moment-inequality "
1954+
f"system or reviewing the nuisance constraints (X_tilde).",
1955+
RuntimeWarning,
1956+
stacklevel=3,
1957+
)
19231958

19241959
return vertices
19251960

tests/test_methodology_honest_did.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"""
77

88

9+
import warnings
10+
911
import numpy as np
1012
import pytest
1113

@@ -495,3 +497,73 @@ def test_breakdown_monotonicity(self):
495497
# The optimal FLCI is efficient, so need large M for a weak effect.
496498
r_large = honest.fit(results, M=20.0)
497499
assert r_large.ci_lb <= 0 <= r_large.ci_ub, "Should lose significance at large M"
500+
501+
502+
class TestARPVertexEnumeration:
503+
"""Diagnostic warnings on `_enumerate_vertices` vertex-search pathologies."""
504+
505+
def test_enumerate_vertices_warns_on_exhausted_search(self):
506+
"""All-LinAlgError path: fully-zero nuisance column makes A_sys
507+
singular on every basis, so the enumeration exhausts without
508+
feasible vertices and the user should see a RuntimeWarning rather
509+
than a silent empty-list return."""
510+
from diff_diff.honest_did import _enumerate_vertices
511+
512+
# 4 moments, 1 nuisance column (all zeros) → A_sys singular on every basis
513+
X_tilde = np.zeros((4, 1))
514+
sigma_tilde_diag = np.array([1.0, 1.0, 1.0, 1.0])
515+
with pytest.warns(RuntimeWarning, match="exhausted"):
516+
vertices = _enumerate_vertices(X_tilde, sigma_tilde_diag, n_moments=4)
517+
assert vertices == []
518+
519+
def test_enumerate_vertices_warns_on_heavy_rejection(self):
520+
"""Mixed-basis path: a partially-singular X_tilde produces some
521+
feasible vertices but rejects >= 50% of bases for LinAlgError.
522+
The warning helps users see that the recovered vertices came from
523+
a numerically fragile enumeration."""
524+
from diff_diff.honest_did import _enumerate_vertices
525+
526+
# 5 moments, 2 nuisance columns. C(5, 3) = 10 bases. Construct
527+
# X_tilde so that ~6 of 10 bases have rank-deficient A_sys.
528+
X_tilde = np.array([
529+
[1.0, 0.0],
530+
[1.0, 0.0],
531+
[1.0, 0.0],
532+
[0.0, 1.0],
533+
[0.0, 1.0],
534+
])
535+
sigma_tilde_diag = np.array([1.0, 1.0, 1.0, 1.0, 1.0])
536+
with warnings.catch_warnings(record=True) as caught:
537+
warnings.simplefilter("always", RuntimeWarning)
538+
vertices = _enumerate_vertices(X_tilde, sigma_tilde_diag, n_moments=5)
539+
heavy = [w for w in caught if "heavily constrained" in str(w.message)]
540+
# If exhaustion fired instead, that's also a valid outcome — but the
541+
# construction is calibrated for the heavy-rejection branch
542+
exhausted = [w for w in caught if "exhausted" in str(w.message)]
543+
assert heavy or exhausted, (
544+
f"Expected heavily-constrained or exhausted warning; got "
545+
f"{[str(w.message) for w in caught]}, vertices={len(vertices)}"
546+
)
547+
548+
def test_enumerate_vertices_quiet_on_healthy_enumeration(self):
549+
"""Well-conditioned X_tilde: most bases solve cleanly and feasible
550+
vertices are recovered. No RuntimeWarning should fire."""
551+
from diff_diff.honest_did import _enumerate_vertices
552+
553+
rng = np.random.default_rng(0)
554+
# 4 moments, 1 nuisance — small and well-conditioned
555+
X_tilde = rng.normal(size=(4, 1))
556+
sigma_tilde_diag = np.array([1.0, 1.0, 1.0, 1.0])
557+
with warnings.catch_warnings(record=True) as caught:
558+
warnings.simplefilter("always", RuntimeWarning)
559+
vertices = _enumerate_vertices(X_tilde, sigma_tilde_diag, n_moments=4)
560+
diag_warnings = [
561+
w for w in caught
562+
if "exhausted" in str(w.message) or "heavily constrained" in str(w.message)
563+
]
564+
assert not diag_warnings, (
565+
f"Healthy enumeration must not emit ARP diagnostics; got "
566+
f"{[str(w.message) for w in diag_warnings]}"
567+
)
568+
# Sanity: we expect some feasible vertices on a well-conditioned input
569+
assert isinstance(vertices, list)

0 commit comments

Comments
 (0)