Skip to content

Commit 8a9bea6

Browse files
igerberclaude
andcommitted
Add methodology verification tests for HonestDiD (17 tests)
Tests verify corrected implementation against Rambachan & Roth (2023): - TestDeltaSDConstraintMatrix: delta_0=0 boundary, bridge constraint, row count (T+Tbar-1), hand-computed 2+2 case - TestIdentifiedSetLP: delta_pre=beta_pre pinning, M=0 linear extrapolation, three-period analytical case from Section 2.3 - TestDeltaRMFirstDifferences: first-difference constraints (not levels), boundary term, Mbar=0 point identification, monotonicity - TestOptimalFLCI: cv_alpha folded normal, optimal narrower than naive, M=0 short-circuit instant - TestBreakdownValueMethodology: significance monotonicity in M Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2f28b52 commit 8a9bea6

1 file changed

Lines changed: 300 additions & 0 deletions

File tree

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""
2+
Methodology verification tests for Honest DiD (Rambachan & Roth, 2023).
3+
4+
These tests verify the corrected implementation against the paper's
5+
equations, known analytical cases, and expected mathematical properties.
6+
"""
7+
8+
import numpy as np
9+
import pytest
10+
11+
from diff_diff.honest_did import (
12+
HonestDiD,
13+
_compute_flci,
14+
_compute_optimal_flci,
15+
_compute_pre_first_differences,
16+
_construct_A_sd,
17+
_construct_constraints_rm_component,
18+
_construct_constraints_sd,
19+
_cv_alpha,
20+
_solve_bounds_lp,
21+
_solve_rm_bounds_union,
22+
)
23+
24+
25+
# =============================================================================
26+
# TestDeltaSDConstraintMatrix
27+
# =============================================================================
28+
29+
30+
class TestDeltaSDConstraintMatrix:
31+
"""Verify DeltaSD constraint matrix accounts for delta_0 = 0 boundary."""
32+
33+
def test_row_count(self):
34+
"""T+Tbar-1 rows, not T+Tbar-2 (accounts for delta_0 = 0)."""
35+
for T, Tbar in [(2, 2), (3, 3), (4, 2), (1, 1), (3, 1), (1, 3)]:
36+
A = _construct_A_sd(T, Tbar)
37+
expected_rows = T + Tbar - 1
38+
assert A.shape == (expected_rows, T + Tbar), (
39+
f"T={T}, Tbar={Tbar}: expected {expected_rows} rows, got {A.shape[0]}"
40+
)
41+
42+
def test_2pre_2post_hand_computed(self):
43+
"""Hand-computed matrix for 2 pre + 2 post periods."""
44+
# delta = [d_{-2}, d_{-1}, d_1, d_2]
45+
A = _construct_A_sd(2, 2)
46+
expected = np.array([
47+
[1, -2, 0, 0], # t=-1: d_{-2} - 2*d_{-1} + 0
48+
[0, 1, 1, 0], # t= 0: d_{-1} + d_1 (bridge)
49+
[0, 0, -2, 1], # t= 1: 0 - 2*d_1 + d_2
50+
])
51+
np.testing.assert_array_equal(A, expected)
52+
53+
def test_bridge_constraint_present(self):
54+
"""The bridge constraint delta_{-1} + delta_1 is always present."""
55+
for T, Tbar in [(1, 1), (2, 2), (4, 3)]:
56+
A = _construct_A_sd(T, Tbar)
57+
# Find the bridge row: non-zero only at positions T-1 and T
58+
bridge_found = False
59+
for row in A:
60+
if row[T - 1] != 0 and row[T] != 0:
61+
# This should be [0, ..., 1, 1, ..., 0]
62+
assert row[T - 1] == 1, f"Bridge row should have 1 at delta_{{-1}}"
63+
assert row[T] == 1, f"Bridge row should have 1 at delta_1"
64+
bridge_found = True
65+
assert bridge_found, f"Bridge constraint not found for T={T}, Tbar={Tbar}"
66+
67+
def test_constraints_span_all_periods(self):
68+
"""Constraints involve both pre and post periods (not pre-only)."""
69+
A = _construct_A_sd(3, 3)
70+
# Some rows should have non-zero entries in post-period columns
71+
post_cols = A[:, 3:] # columns for delta_1, delta_2, delta_3
72+
assert np.any(post_cols != 0), "No constraints involve post-period deltas"
73+
74+
75+
# =============================================================================
76+
# TestIdentifiedSetLP
77+
# =============================================================================
78+
79+
80+
class TestIdentifiedSetLP:
81+
"""Verify identified set LP pins delta_pre = beta_pre."""
82+
83+
def test_m0_linear_extrapolation(self):
84+
"""M=0 with linear pre-trends gives finite point-identified bounds."""
85+
# Pre-trends: linear decline with slope -0.1
86+
beta_pre = np.array([0.3, 0.2, 0.1])
87+
beta_post = np.array([2.0])
88+
l_vec = np.array([1.0])
89+
90+
A, b = _construct_constraints_sd(3, 1, M=0.0)
91+
lb, ub = _solve_bounds_lp(beta_pre, beta_post, l_vec, A, b, 3)
92+
93+
# Linear extrapolation: slope = -0.1, so delta_1 = 0 - 0.1 = -0.1
94+
# theta = beta_post - delta_post = 2.0 - (-0.1) = 2.1
95+
assert np.isfinite(lb), "M=0 should give finite lower bound"
96+
assert np.isfinite(ub), "M=0 should give finite upper bound"
97+
np.testing.assert_allclose(lb, 2.1, atol=1e-6)
98+
np.testing.assert_allclose(ub, 2.1, atol=1e-6)
99+
100+
def test_bounds_widen_with_m(self):
101+
"""Identified set widens monotonically with M."""
102+
beta_pre = np.array([0.3, 0.2, 0.1])
103+
beta_post = np.array([2.0])
104+
l_vec = np.array([1.0])
105+
106+
prev_width = 0
107+
for M in [0.0, 0.1, 0.5, 1.0]:
108+
A, b = _construct_constraints_sd(3, 1, M=M)
109+
lb, ub = _solve_bounds_lp(beta_pre, beta_post, l_vec, A, b, 3)
110+
width = ub - lb
111+
assert width >= prev_width - 1e-10, (
112+
f"Width should increase: M={M}, width={width}, prev={prev_width}"
113+
)
114+
prev_width = width
115+
116+
def test_three_period_analytical(self):
117+
"""Paper Section 2.3: three-period example (T=1, Tbar=1)."""
118+
# delta = [d_{-1}, d_1], with delta_0 = 0
119+
# DeltaSD(M): |d_1 + d_{-1}| <= M (bridge constraint only)
120+
# With d_{-1} = beta_{-1} pinned:
121+
# d_1 in [-(beta_{-1} + M), -(beta_{-1} - M)] = [-beta_{-1} - M, -beta_{-1} + M]
122+
# theta = beta_1 - d_1
123+
# lb = beta_1 - (-beta_{-1} + M) = beta_1 + beta_{-1} - M
124+
# ub = beta_1 - (-beta_{-1} - M) = beta_1 + beta_{-1} + M
125+
beta_pre = np.array([0.5])
126+
beta_post = np.array([3.0])
127+
128+
for M in [0.0, 0.2, 1.0]:
129+
A, b = _construct_constraints_sd(1, 1, M=M)
130+
lb, ub = _solve_bounds_lp(beta_pre, beta_post, np.array([1.0]), A, b, 1)
131+
expected_lb = 3.0 + 0.5 - M
132+
expected_ub = 3.0 + 0.5 + M
133+
np.testing.assert_allclose(lb, expected_lb, atol=1e-6,
134+
err_msg=f"M={M}: lb mismatch")
135+
np.testing.assert_allclose(ub, expected_ub, atol=1e-6,
136+
err_msg=f"M={M}: ub mismatch")
137+
138+
139+
# =============================================================================
140+
# TestDeltaRMFirstDifferences
141+
# =============================================================================
142+
143+
144+
class TestDeltaRMFirstDifferences:
145+
"""Verify DeltaRM constrains first differences, not levels."""
146+
147+
def test_pre_first_differences_computation(self):
148+
"""Pre-period first differences include delta_0=0 boundary."""
149+
beta_pre = np.array([0.3, 0.2, 0.1])
150+
diffs = _compute_pre_first_differences(beta_pre)
151+
152+
# Interior: |0.2-0.3|=0.1, |0.1-0.2|=0.1
153+
# Boundary: |0 - 0.1| = 0.1
154+
np.testing.assert_allclose(diffs, [0.1, 0.1, 0.1], atol=1e-10)
155+
156+
def test_pre_first_differences_boundary(self):
157+
"""The boundary term |0 - beta_{-1}| is included."""
158+
beta_pre = np.array([0.0, 0.0, 0.5])
159+
diffs = _compute_pre_first_differences(beta_pre)
160+
161+
# Interior: |0-0|=0, |0.5-0|=0.5
162+
# Boundary: |0 - 0.5| = 0.5
163+
np.testing.assert_allclose(diffs, [0.0, 0.5, 0.5], atol=1e-10)
164+
165+
def test_rm_constraints_are_first_differences(self):
166+
"""RM constraint matrix constrains consecutive differences, not levels."""
167+
A, b = _construct_constraints_rm_component(2, 3, Mbar=1.0, max_pre_first_diff=0.1)
168+
169+
# 3 post-period first diffs: |d_1|, |d_2-d_1|, |d_3-d_2|
170+
# Each needs pos/neg constraint = 6 rows total
171+
assert A.shape[0] == 6
172+
assert A.shape[1] == 5 # 2 pre + 3 post
173+
174+
# First pair: d_1 <= 0.1 and -d_1 <= 0.1
175+
assert A[0, 2] == 1 # d_1
176+
assert A[1, 2] == -1 # -d_1
177+
178+
# Second pair: d_2 - d_1 <= 0.1
179+
assert A[2, 3] == 1 and A[2, 2] == -1 # d_2 - d_1
180+
assert A[3, 3] == -1 and A[3, 2] == 1 # -(d_2 - d_1)
181+
182+
def test_mbar0_gives_point_estimate(self):
183+
"""Mbar=0: all post first diffs = 0, theta = l'beta_post."""
184+
beta_pre = np.array([0.3, 0.2, 0.1])
185+
beta_post = np.array([2.0, 2.5])
186+
l_vec = np.array([0.5, 0.5])
187+
188+
lb, ub = _solve_rm_bounds_union(beta_pre, beta_post, l_vec, 3, Mbar=0.0)
189+
190+
theta = np.dot(l_vec, beta_post)
191+
np.testing.assert_allclose(lb, theta, atol=1e-6)
192+
np.testing.assert_allclose(ub, theta, atol=1e-6)
193+
194+
def test_rm_bounds_widen_with_mbar(self):
195+
"""Identified set widens monotonically with Mbar."""
196+
beta_pre = np.array([0.3, 0.2, 0.1])
197+
beta_post = np.array([2.0, 2.5])
198+
l_vec = np.array([0.5, 0.5])
199+
200+
prev_width = 0
201+
for Mbar in [0.0, 0.5, 1.0, 2.0]:
202+
lb, ub = _solve_rm_bounds_union(beta_pre, beta_post, l_vec, 3, Mbar)
203+
width = ub - lb
204+
assert width >= prev_width - 1e-10, f"Mbar={Mbar}: width decreased"
205+
prev_width = width
206+
207+
208+
# =============================================================================
209+
# TestOptimalFLCI
210+
# =============================================================================
211+
212+
213+
class TestOptimalFLCI:
214+
"""Verify optimal FLCI properties."""
215+
216+
def test_cv_alpha_at_zero(self):
217+
"""cv_alpha(0, alpha) = z_{alpha/2} (standard normal quantile)."""
218+
from scipy.stats import norm
219+
np.testing.assert_allclose(_cv_alpha(0, 0.05), norm.ppf(0.975), atol=1e-4)
220+
np.testing.assert_allclose(_cv_alpha(0, 0.01), norm.ppf(0.995), atol=1e-4)
221+
222+
def test_cv_alpha_monotonic(self):
223+
"""cv_alpha(t) increases with |t| (more bias -> wider CI)."""
224+
cvs = [_cv_alpha(t, 0.05) for t in [0, 0.5, 1.0, 2.0, 5.0]]
225+
assert all(cvs[i] <= cvs[i + 1] + 1e-10 for i in range(len(cvs) - 1))
226+
227+
def test_optimal_flci_narrower_than_naive(self):
228+
"""Optimal FLCI should be no wider than naive FLCI."""
229+
beta_pre = np.array([0.3, 0.2, 0.1])
230+
beta_post = np.array([2.0])
231+
sigma = np.eye(4) * 0.01
232+
l_vec = np.array([1.0])
233+
234+
ci_lb_opt, ci_ub_opt = _compute_optimal_flci(
235+
beta_pre, beta_post, sigma, l_vec, 3, 1, M=0.5, alpha=0.05
236+
)
237+
238+
# Naive FLCI
239+
A, b = _construct_constraints_sd(3, 1, 0.5)
240+
lb, ub = _solve_bounds_lp(beta_pre, beta_post, l_vec, A, b, 3)
241+
se = np.sqrt(l_vec @ sigma[3:, 3:] @ l_vec)
242+
ci_lb_naive, ci_ub_naive = _compute_flci(lb, ub, se, 0.05)
243+
244+
opt_width = ci_ub_opt - ci_lb_opt
245+
naive_width = ci_ub_naive - ci_lb_naive
246+
assert opt_width <= naive_width + 1e-6, (
247+
f"Optimal ({opt_width:.4f}) wider than naive ({naive_width:.4f})"
248+
)
249+
250+
def test_m0_short_circuit(self):
251+
"""M=0 should use standard CI without optimization."""
252+
beta_pre = np.array([0.3, 0.2, 0.1])
253+
beta_post = np.array([2.0])
254+
sigma = np.eye(4) * 0.01
255+
l_vec = np.array([1.0])
256+
257+
import time
258+
t0 = time.time()
259+
_compute_optimal_flci(beta_pre, beta_post, sigma, l_vec, 3, 1, M=0.0)
260+
elapsed = time.time() - t0
261+
262+
assert elapsed < 0.1, f"M=0 should be instant, took {elapsed:.2f}s"
263+
264+
265+
# =============================================================================
266+
# TestBreakdownValueMethodology
267+
# =============================================================================
268+
269+
270+
class TestBreakdownValueMethodology:
271+
"""Verify breakdown value properties."""
272+
273+
def test_breakdown_monotonicity(self):
274+
"""If significant at M=k, should be significant at all M < k."""
275+
from diff_diff.results import MultiPeriodDiDResults, PeriodEffect
276+
277+
period_effects = {
278+
1: PeriodEffect(period=1, effect=0.1, se=0.05, t_stat=2.0,
279+
p_value=0.05, conf_int=(0.0, 0.2)),
280+
2: PeriodEffect(period=2, effect=0.05, se=0.05, t_stat=1.0,
281+
p_value=0.32, conf_int=(-0.05, 0.15)),
282+
4: PeriodEffect(period=4, effect=2.0, se=0.1, t_stat=20.0,
283+
p_value=0.0, conf_int=(1.8, 2.2)),
284+
}
285+
results = MultiPeriodDiDResults(
286+
avg_att=2.0, avg_se=0.1, avg_t_stat=20.0, avg_p_value=0.0,
287+
avg_conf_int=(1.8, 2.2), n_obs=500, n_treated=250, n_control=250,
288+
period_effects=period_effects, pre_periods=[1, 2], post_periods=[4],
289+
vcov=np.eye(3) * 0.0025,
290+
interaction_indices={1: 0, 2: 1, 4: 2},
291+
)
292+
293+
honest = HonestDiD(method="smoothness")
294+
# Check that CI at M=0 does not include zero (strong effect)
295+
r0 = honest.fit(results, M=0.0)
296+
assert r0.ci_lb > 0, "Should be significant at M=0"
297+
298+
# At sufficiently large M, CI should include zero
299+
r_large = honest.fit(results, M=5.0)
300+
assert r_large.ci_lb <= 0 or r_large.ci_ub >= 0, "Should lose significance at large M"

0 commit comments

Comments
 (0)