Skip to content

Commit ad8f6be

Browse files
igerberclaude
andcommitted
Address PR #393 R4 P1+P3: bootstrap+placebo regressions + Results docstring
P1 fixes — coverage gaps for newly reachable bootstrap+placebo paths: - test_per_path_placebos_with_trends_linear_bootstrap_inference: asserts negative-horizon SE differs between analytical and bootstrap fits, proving the placebo bootstrap propagation block runs through the first-differenced path. - test_per_path_placebos_with_trends_linear_bootstrap_nan_consistent: n_bootstrap=1 case asserting NaN-consistent inference on negative horizons (locks the library-wide NaN-on-invalid contract on this newly reachable surface). - test_per_path_placebos_with_trends_nonparam_bootstrap_inference: comparison fit with vs without trends_nonparam under bootstrap + placebo; asserts negative-horizon SE differs, proving set_ids reaches _collect_path_placebo_bootstrap_inputs. P3 fix — Results dataclass attribute documentation: - Added a path_cumulated_event_study attribute entry to ChaisemartinDHaultfoeuilleResults attributes docstring (was added as a field but missing from the public attribute table). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b3f5243 commit ad8f6be

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

diff_diff/chaisemartin_dhaultfoeuille_results.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,26 @@ class ChaisemartinDHaultfoeuilleResults:
418418
cohort-sharing SE deviation from R documented for
419419
``path_effects``. See REGISTRY.md
420420
``Note (Phase 3 by_path ...)`` → "Per-path placebos".
421+
path_cumulated_event_study : dict, optional
422+
Per-path cumulated level effects ``delta_{path, l} =
423+
sum_{l'=1..l} DID^{fd}_{path, l'}`` for ``l = 1..L_max``,
424+
keyed by observed treatment trajectory (tuple of int). Inner
425+
dict is keyed by horizon directly (no ``"horizons"`` wrapper);
426+
each entry holds ``{"effect", "se", "t_stat", "p_value",
427+
"conf_int", "n_obs"}``. Populated when ``by_path`` is a
428+
positive int AND ``trends_linear=True`` AND ``L_max >= 1``;
429+
``None`` otherwise. Mirrors the global ``linear_trends_effects``
430+
cumulation: SE on the cumulated layer is the conservative
431+
upper bound (sum of per-horizon component SEs from
432+
``path_effects[path]["horizons"][l]["se"]``, NaN-consistent).
433+
Built AFTER bootstrap propagation so the cumulated SE / t / p
434+
/ CI are derived from the FINAL post-bootstrap per-horizon
435+
SEs when ``n_bootstrap > 0``. Surfaced as ``cumulated_effect``
436+
/ ``cumulated_se`` columns on
437+
``to_dataframe(level="by_path")`` (always-present, NaN-when-
438+
None) and as a per-path "Cumulated Level Effects" sub-section
439+
in ``summary()``. See REGISTRY.md ``Note (Phase 3 by_path
440+
...)`` → "Per-path linear-trends DID^{fd}".
421441
path_sup_t_bands : dict, optional
422442
Per-path joint sup-t simultaneous-band metadata, keyed by
423443
observed treatment trajectory (tuple of int). Each entry holds

tests/test_chaisemartin_dhaultfoeuille.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7299,6 +7299,115 @@ def test_per_path_placebos_with_trends_linear_present(self):
72997299
"placebo path may have regressed."
73007300
)
73017301

7302+
@pytest.mark.slow
7303+
def test_per_path_placebos_with_trends_linear_bootstrap_inference(self):
7304+
"""Bootstrap-derived inference fields populated on negative-
7305+
horizon ``path_placebo_event_study`` rows under ``by_path +
7306+
trends_linear + placebo + n_bootstrap > 0``. Pins the placebo
7307+
bootstrap collector path that consumes the first-differenced
7308+
``Y_mat`` AND the bootstrap propagation block at
7309+
``chaisemartin_dhaultfoeuille.py:3097-`` for negative horizons.
7310+
Without this, a silent regression in the placebo bootstrap
7311+
propagation would surface analytical SEs on a bootstrap fit.
7312+
"""
7313+
data = _by_path_data_with_trends_linear()
7314+
with warnings.catch_warnings():
7315+
warnings.simplefilter("ignore", UserWarning)
7316+
est_a = ChaisemartinDHaultfoeuille(
7317+
drop_larger_lower=False, by_path=3, placebo=True
7318+
)
7319+
res_a = est_a.fit(
7320+
data,
7321+
outcome="outcome",
7322+
group="group",
7323+
time="period",
7324+
treatment="treatment",
7325+
trends_linear=True,
7326+
L_max=3,
7327+
)
7328+
est_b = ChaisemartinDHaultfoeuille(
7329+
drop_larger_lower=False,
7330+
by_path=3,
7331+
placebo=True,
7332+
n_bootstrap=200,
7333+
seed=42,
7334+
)
7335+
res_b = est_b.fit(
7336+
data,
7337+
outcome="outcome",
7338+
group="group",
7339+
time="period",
7340+
treatment="treatment",
7341+
trends_linear=True,
7342+
L_max=3,
7343+
)
7344+
# Negative-horizon placebo rows must exist and carry bootstrap-
7345+
# derived inference. Verify by comparing analytical-only fit's
7346+
# SEs to bootstrap-fit's SEs on the same negative-horizon
7347+
# entries: bootstrap should differ (non-bit-identical) since
7348+
# the propagation block overwrites SE / p_value / conf_int.
7349+
assert res_b.path_placebo_event_study is not None
7350+
any_se_diff = False
7351+
any_finite = False
7352+
for path, lag_dict in res_b.path_placebo_event_study.items():
7353+
for lag_k, vals_b in lag_dict.items():
7354+
if not np.isfinite(vals_b["se"]):
7355+
continue
7356+
any_finite = True
7357+
vals_a = res_a.path_placebo_event_study.get(path, {}).get(lag_k)
7358+
if vals_a is None or not np.isfinite(vals_a["se"]):
7359+
continue
7360+
if abs(vals_b["se"] - vals_a["se"]) > 1e-10:
7361+
any_se_diff = True
7362+
break
7363+
if any_se_diff:
7364+
break
7365+
assert any_finite, "No finite negative-horizon bootstrap SEs surfaced"
7366+
assert any_se_diff, (
7367+
"Bootstrap fit produced bit-identical SEs to analytical fit on "
7368+
"every negative-horizon placebo cell; the placebo bootstrap "
7369+
"propagation block under trends_linear may not be running."
7370+
)
7371+
7372+
@pytest.mark.slow
7373+
def test_per_path_placebos_with_trends_linear_bootstrap_nan_consistent(self):
7374+
"""``n_bootstrap=1`` produces NaN-consistent inference on
7375+
negative-horizon ``path_placebo_event_study`` rows under
7376+
``by_path + trends_linear + placebo``. Pins the library-wide
7377+
NaN-on-invalid bootstrap contract on the new placebo path.
7378+
"""
7379+
data = _by_path_data_with_trends_linear()
7380+
with warnings.catch_warnings():
7381+
warnings.simplefilter("ignore", (UserWarning, RuntimeWarning))
7382+
est = ChaisemartinDHaultfoeuille(
7383+
drop_larger_lower=False,
7384+
by_path=3,
7385+
placebo=True,
7386+
n_bootstrap=1,
7387+
seed=42,
7388+
)
7389+
res = est.fit(
7390+
data,
7391+
outcome="outcome",
7392+
group="group",
7393+
time="period",
7394+
treatment="treatment",
7395+
trends_linear=True,
7396+
L_max=3,
7397+
)
7398+
assert res.path_placebo_event_study is not None
7399+
# n_bootstrap=1 → degenerate bootstrap distribution → NaN SE /
7400+
# p_value / conf_int on every negative-horizon entry.
7401+
for path, lag_dict in res.path_placebo_event_study.items():
7402+
for lag_k, vals in lag_dict.items():
7403+
assert not np.isfinite(vals["se"]), (
7404+
f"path={path} lag={lag_k}: SE finite ({vals['se']}) "
7405+
"under n_bootstrap=1; expected NaN"
7406+
)
7407+
assert not np.isfinite(vals["p_value"]), (
7408+
f"path={path} lag={lag_k}: p_value finite under n_bootstrap=1"
7409+
)
7410+
73027411
@pytest.mark.slow
73037412
def test_sup_t_bands_with_trends_linear_finite_crit(self):
73047413
"""Per-path joint sup-t bands populated under ``by_path +
@@ -7851,3 +7960,84 @@ def test_sup_t_bands_with_trends_nonparam_finite_crit(self):
78517960
"No positive-horizon cband rows populated under "
78527961
"trends_nonparam + bootstrap"
78537962
)
7963+
7964+
@pytest.mark.slow
7965+
def test_per_path_placebos_with_trends_nonparam_bootstrap_inference(self):
7966+
"""Bootstrap-derived inference fields populated on negative-
7967+
horizon ``path_placebo_event_study`` rows under ``by_path +
7968+
trends_nonparam + placebo + n_bootstrap > 0``.
7969+
7970+
Pins the ``set_ids`` threading into
7971+
``_collect_path_placebo_bootstrap_inputs`` (line 5963 in the
7972+
diff): without that threading, the placebo bootstrap collector
7973+
would re-compute the per-group placebo IF with set_ids=None,
7974+
bypassing the set-restricted control pool. We verify by
7975+
comparing two bootstrap fits — one with trends_nonparam, one
7976+
without — and asserting at least one negative-horizon SE
7977+
differs (the set restriction must propagate through the
7978+
placebo bootstrap path) AND remains finite.
7979+
"""
7980+
data = _by_path_data_with_trends_nonparam()
7981+
with warnings.catch_warnings():
7982+
warnings.simplefilter("ignore", UserWarning)
7983+
est_no_set = ChaisemartinDHaultfoeuille(
7984+
drop_larger_lower=False,
7985+
by_path=3,
7986+
placebo=True,
7987+
n_bootstrap=200,
7988+
seed=42,
7989+
)
7990+
res_no = est_no_set.fit(
7991+
data,
7992+
outcome="outcome",
7993+
group="group",
7994+
time="period",
7995+
treatment="treatment",
7996+
L_max=3,
7997+
)
7998+
est_set = ChaisemartinDHaultfoeuille(
7999+
drop_larger_lower=False,
8000+
by_path=3,
8001+
placebo=True,
8002+
n_bootstrap=200,
8003+
seed=42,
8004+
)
8005+
res_set = est_set.fit(
8006+
data,
8007+
outcome="outcome",
8008+
group="group",
8009+
time="period",
8010+
treatment="treatment",
8011+
trends_nonparam="state",
8012+
L_max=3,
8013+
)
8014+
assert res_set.path_placebo_event_study is not None
8015+
assert res_no.path_placebo_event_study is not None
8016+
any_diff = False
8017+
any_finite = False
8018+
for path, lag_dict in res_set.path_placebo_event_study.items():
8019+
for lag_k, vals_set in lag_dict.items():
8020+
if not np.isfinite(vals_set["se"]):
8021+
continue
8022+
any_finite = True
8023+
vals_no = res_no.path_placebo_event_study.get(path, {}).get(
8024+
lag_k
8025+
)
8026+
if vals_no is None or not np.isfinite(vals_no["se"]):
8027+
continue
8028+
# Set restriction shrinks the control pool; with the
8029+
# same seed, the bootstrap distribution should differ.
8030+
if abs(vals_set["se"] - vals_no["se"]) > 1e-10:
8031+
any_diff = True
8032+
break
8033+
if any_diff:
8034+
break
8035+
assert any_finite, (
8036+
"No finite negative-horizon bootstrap SEs surfaced under "
8037+
"trends_nonparam + placebo + bootstrap"
8038+
)
8039+
assert any_diff, (
8040+
"Bootstrap placebo SEs are bit-identical with vs without "
8041+
"trends_nonparam restriction; set_ids may not be reaching "
8042+
"the per-path placebo bootstrap collector."
8043+
)

0 commit comments

Comments
 (0)