Skip to content

Commit 187cd21

Browse files
igerberclaude
andcommitted
fix(lpdid): non-absorbing pooled-pre uses deepest reach-back horizon
Codex P1: `_build_pooled_sample(kind="pre")` passed horizon=0 to the non-absorbing masks, so the effect_stabilization clean window only covered [t-L, t] instead of the pooled-pre reach-back to the most-negative horizon ([t - max(L, -h), t-1]). A unit with a prior treated spell at t-3 (clean at t-1) leaked into a [-3, -2] pooled-pre placebo and biased it. Pre windows now use min(horizons); the absorbing branch keeps horizon=0 (not-yet-treated at t already implies a clean pre-span, so its R-parity goldens are unchanged). Adds a deterministic regression test (spell entrants excluded from the pooled-pre sample; verified to fail at 0.286 before the fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 90af194 commit 187cd21

2 files changed

Lines changed: 29 additions & 1 deletion

File tree

diff_diff/lpdid.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,13 @@ def _build_pooled_sample(
962962
control_horizon = max(horizons) if kind == "post" else 0
963963
control_mask = self._clean_control_mask(sample, time=time, horizon=control_horizon)
964964
else:
965-
control_horizon = max(horizons) if kind == "post" else 0
965+
# Pre pooling reaches back to the MOST NEGATIVE horizon, so the
966+
# non-absorbing clean window must cover it (the effect_stabilization
967+
# placebo window widens to [t - max(L, -h), t-1]); using horizon=0 would
968+
# only clean [t-L, t] and leak prior treatment changes inside the pooled
969+
# pre reach-back. (Absorbing pre uses horizon=0 above: not-yet-treated at t
970+
# implies clean across the whole pre-span, so it is unaffected.)
971+
control_horizon = max(horizons) if kind == "post" else min(horizons)
966972
treated_mask, control_mask = self._nonabsorbing_masks(
967973
sample, panel, unit=unit, time=time, horizon=control_horizon
968974
)

tests/test_lpdid.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,28 @@ def test_dgp_recovery_both_modes(self):
16211621
for _, row in post.iterrows():
16221622
assert row["coefficient"] == pytest.approx(2.0, abs=0.2), (mode_kw, row.to_dict())
16231623

1624+
def test_effect_stabilization_pooled_pre_window_clean(self):
1625+
# Pooled-pre placebo must be clean over the DEEPEST reach-back, not just
1626+
# [t-L, t-1] (codex P1). With L=1 and pre_window=3 the pooled pre window is
1627+
# [-3, -2], so a unit with a treated spell at t-3 contaminates the placebo even
1628+
# though [t-1] is clean; the pooled mask must use the most-negative horizon. The
1629+
# "spell" entrants (treated at t=3, re-enter at t=5) must be excluded from the
1630+
# pooled-pre sample, leaving the clean entrants' ~0 placebo. A horizon=0 pooled
1631+
# mask would leak the spell rows and bias the estimate to ~tau/2.
1632+
clean_entrant = [0, 0, 0, 0, 0, 1, 1, 1] # enter t=5, no prior spell
1633+
spell_entrant = [0, 0, 0, 1, 0, 1, 1, 1] # treated spell at t=3, re-enter t=5
1634+
specs = [clean_entrant] * 5 + [spell_entrant] * 5 + [[0] * 8] * 6 + [[1] * 8] * 4
1635+
df = _deterministic_panel(specs, tau=2.0)
1636+
r = LPDiD(
1637+
pre_window=3,
1638+
post_window=1,
1639+
cluster="unit",
1640+
non_absorbing="effect_stabilization",
1641+
stabilization_window=1,
1642+
).fit(df, **_FIT_KW)
1643+
pre = r.pooled.loc[r.pooled["window"] == "pre", "coefficient"].iloc[0]
1644+
assert abs(pre) < 1e-6, f"pooled pre placebo contaminated by a prior spell: {pre}"
1645+
16241646
def test_interior_gap_raises(self):
16251647
df = pd.DataFrame(
16261648
{

0 commit comments

Comments
 (0)