|
| 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