Skip to content

Commit e9bda71

Browse files
igerberclaude
andcommitted
Address PR #374 R1 P1 + P3: empty-state contract + bootstrap_weights wording
P1: `path_sup_t_bands` now returns `{}` (not `None`) when `by_path + n_bootstrap > 0` is requested but `path_effects == {}` (no observed path has a complete window). Mirrors the documented empty-state contract used by `path_effects` and `path_placebo_event_study`. The bug: `_collect_path_bootstrap_inputs` is gated on `len(path_effects) > 0`, so when `path_effects == {}` the bootstrap collector is skipped, `bootstrap_results.path_cband_crit_values` stays `None`, and the result-class kwarg builder mapped `None` to `path_sup_t_bands = None` — losing the requested-vs-empty distinction. Fix: the kwarg builder now keys off `self.by_path is not None and self.n_bootstrap > 0`. When that condition holds we always materialize a dict (empty when no path passed both gates), reserving `None` only for "feature not requested." When `path_cband_crit_values is None` we treat it as the empty case for the dict comprehension. P3: replaced "Hall-Mammen multiplier weight matrix" with neutral "multiplier weight matrix (using the estimator's configured `bootstrap_weights` — Rademacher / Mammen / Webb)" in CHANGELOG, REGISTRY, helper docstring, and TestByPathSupTBands docstring. The implementation honors `self.bootstrap_weights`, so the prose shouldn't fix the family. Regression test added: `test_path_sup_t_bands_empty_dict_when_no_complete_window` on the same empty-window panel as the analytical sibling at `test_empty_path_surface_when_no_complete_window` (`:4015+`), asserting `path_effects == {}`, `path_sup_t_bands == {}`, and no horizon writes a `cband_conf_int` key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 14891c9 commit e9bda71

5 files changed

Lines changed: 100 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11-
- **`ChaisemartinDHaultfoeuille.by_path` + `n_bootstrap > 0` joint sup-t bands** — per-path joint sup-t simultaneous confidence intervals across horizons `1..L_max` within each path. A single shared `(n_bootstrap, n_eligible)` Hall-Mammen multiplier weight matrix is drawn per path and broadcast across all horizons of that path, producing correlated bootstrap distributions across horizons. The path-specific critical value `c_p = quantile(max_l |t_l|, 1 - α)` is used to construct symmetric joint bands `effect_l ± c_p · se_l` per horizon. Surfaced on `results.path_sup_t_bands` (dict keyed by path tuple, each entry with `crit_value / alpha / n_bootstrap / method / n_valid_horizons`) and as `cband_conf_int` per horizon entry on `path_effects[path]["horizons"][l]`. Gates: a path needs `>= 2` valid horizons (finite bootstrap SE > 0) AND `>= 50%` finite sup-t draws to receive a band. Empty-state contract: `path_sup_t_bands is None` when not requested; `{}` when requested but no path passes both gates. **Methodology asymmetry vs OVERALL `event_study_sup_t_bands`:** the per-path sup-t draws a fresh shared weight matrix per path AFTER the per-path SE bootstrap block has already populated `results.path_ses` via independent per-(path, horizon) draws — asymptotically equivalent to OVERALL's self-consistent reuse but NOT bit-identical. Documented intentional choice to preserve RNG-state isolation for existing per-path SE seed-reproducibility tests. Inherits the cross-path cohort-sharing SE deviation from R documented for `path_effects`. **Deviation from R:** `did_multiplegt_dyn` does not provide joint / sup-t bands at any surface — this is a Python-only methodology extension consistent with the existing OVERALL sup-t bands (also Python-only). Bands cover joint inference WITHIN a single path across horizons; they do NOT provide simultaneous coverage across paths. Pre-audit fix bundled: stale "Phase 2 placeholder" docstring on the existing `sup_t_bands` field updated to the actual contract description. Tests at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSupTBands` (`@pytest.mark.slow`). See `docs/methodology/REGISTRY.md` §ChaisemartinDHaultfoeuille `Note (Phase 3 by_path per-path joint sup-t bands)` for the full contract.
11+
- **`ChaisemartinDHaultfoeuille.by_path` + `n_bootstrap > 0` joint sup-t bands** — per-path joint sup-t simultaneous confidence intervals across horizons `1..L_max` within each path. A single shared `(n_bootstrap, n_eligible)` multiplier weight matrix (using the estimator's configured `bootstrap_weights` — Rademacher / Mammen / Webb) is drawn per path and broadcast across all horizons of that path, producing correlated bootstrap distributions across horizons. The path-specific critical value `c_p = quantile(max_l |t_l|, 1 - α)` is used to construct symmetric joint bands `effect_l ± c_p · se_l` per horizon. Surfaced on `results.path_sup_t_bands` (dict keyed by path tuple, each entry with `crit_value / alpha / n_bootstrap / method / n_valid_horizons`) and as `cband_conf_int` per horizon entry on `path_effects[path]["horizons"][l]`. Gates: a path needs `>= 2` valid horizons (finite bootstrap SE > 0) AND `>= 50%` finite sup-t draws to receive a band. Empty-state contract: `path_sup_t_bands is None` when not requested; `{}` when requested but no path passes both gates. **Methodology asymmetry vs OVERALL `event_study_sup_t_bands`:** the per-path sup-t draws a fresh shared weight matrix per path AFTER the per-path SE bootstrap block has already populated `results.path_ses` via independent per-(path, horizon) draws — asymptotically equivalent to OVERALL's self-consistent reuse but NOT bit-identical. Documented intentional choice to preserve RNG-state isolation for existing per-path SE seed-reproducibility tests. Inherits the cross-path cohort-sharing SE deviation from R documented for `path_effects`. **Deviation from R:** `did_multiplegt_dyn` does not provide joint / sup-t bands at any surface — this is a Python-only methodology extension consistent with the existing OVERALL sup-t bands (also Python-only). Bands cover joint inference WITHIN a single path across horizons; they do NOT provide simultaneous coverage across paths. Pre-audit fix bundled: stale "Phase 2 placeholder" docstring on the existing `sup_t_bands` field updated to the actual contract description. Tests at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSupTBands` (`@pytest.mark.slow`). See `docs/methodology/REGISTRY.md` §ChaisemartinDHaultfoeuille `Note (Phase 3 by_path per-path joint sup-t bands)` for the full contract.
1212
- **`ChaisemartinDHaultfoeuille.by_path` + `placebo=True`** — per-path backward-horizon placebos `DID^{pl}_{path, l}` for `l = 1..L_max`. The same per-path SE convention used for the event-study (joiners/leavers IF precedent: switcher-side contributions zeroed for non-path groups; cohort structure and control pool unchanged; plug-in SE with path-specific divisor `N^{pl}_{l, path}`) is applied to backward horizons via the new `switcher_subset_mask` parameter on `_compute_per_group_if_placebo_horizon`. Surfaced on `results.path_placebo_event_study[path][-l]` (negative-int inner keys mirroring `placebo_event_study`); `summary()` renders the rows alongside per-path event-study horizons; `to_dataframe(level="by_path")` emits negative-horizon rows alongside the existing positive-horizon rows. **Bootstrap** (when `n_bootstrap > 0`) propagates per-`(path, lag)` percentile CI / p-value through the same `_bootstrap_one_target` dispatch as the per-path event-study, with the canonical NaN-on-invalid contract enforced on the new surface (PR #364 library-wide invariant). **SE inherits the cross-path cohort-sharing deviation from R** documented for `path_effects` (full-panel cohort-centered plug-in vs R's per-path re-run): tracks R within tolerance on single-path-cohort panels, diverges materially on cohort-mixed panels — the bootstrap SE is a Monte Carlo analog of the analytical SE and inherits the same deviation. R-parity confirmed at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathPlacebo` on the new `multi_path_reversible_by_path_placebo` scenario (point estimates exact match; SE within Phase-2 envelope rtol ≤ 5%); positive analytical + bootstrap invariants at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathPlacebo` (and the gated `::TestBootstrap` subclass). See `docs/methodology/REGISTRY.md` §ChaisemartinDHaultfoeuille `Note (Phase 3 by_path ...)` → "Per-path placebos" for the full contract.
1313
- **Tutorial 19: dCDH for Marketing Pulse Campaigns** (`docs/tutorials/19_dcdh_marketing_pulse.ipynb`) — end-to-end practitioner walkthrough on a 60-market reversible-treatment panel covering the TWFE decomposition diagnostic (`twowayfeweights`), `DCDH` Phase 1 (DID_M, joiners-vs-leavers, single-lag placebo), the `L_max` multi-horizon event study with multiplier bootstrap, a stakeholder communication template, and drift guards. README listing for Tutorial 17 (Brand Awareness Survey) backfilled in the same edit. Cross-link from `docs/practitioner_decision_tree.rst` § "Reversible Treatment" added.
1414

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3661,6 +3661,19 @@ def fit(
36613661
path_effects=path_effects,
36623662
path_placebo_event_study=path_placebos,
36633663
path_sup_t_bands=(
3664+
# When by_path + n_bootstrap > 0 is active, surface a
3665+
# dict (possibly empty) — preserving the documented
3666+
# `None` (not requested) vs `{}` (requested but empty)
3667+
# contract that mirrors `path_effects` / `path_placebo_
3668+
# event_study` empty-state behavior. The empty case
3669+
# arises in two ways:
3670+
# 1. `path_effects == {}` — no observed path has a
3671+
# complete window; the per-path bootstrap collector
3672+
# is skipped upstream and `path_cband_crit_values`
3673+
# stays `None`. We materialize `{}` here.
3674+
# 2. Bootstrap ran but no path passed both gates
3675+
# (>=2 valid horizons AND >=50% finite sup-t draws);
3676+
# `path_cband_crit_values == {}` — passes through.
36643677
{
36653678
path_key: {
36663679
"crit_value": crit,
@@ -3669,17 +3682,20 @@ def fit(
36693682
"method": "multiplier_bootstrap",
36703683
"n_valid_horizons": (
36713684
bootstrap_results.path_cband_n_valid_horizons.get(path_key, 0)
3672-
if bootstrap_results.path_cband_n_valid_horizons is not None
3685+
if bootstrap_results is not None
3686+
and bootstrap_results.path_cband_n_valid_horizons is not None
36733687
else 0
36743688
),
36753689
}
3676-
for path_key, crit in bootstrap_results.path_cband_crit_values.items()
3690+
for path_key, crit in (
3691+
bootstrap_results.path_cband_crit_values
3692+
if bootstrap_results is not None
3693+
and bootstrap_results.path_cband_crit_values is not None
3694+
else {}
3695+
).items()
36773696
if np.isfinite(crit)
36783697
}
3679-
if (
3680-
bootstrap_results is not None
3681-
and bootstrap_results.path_cband_crit_values is not None
3682-
)
3698+
if (self.by_path is not None and self.n_bootstrap > 0)
36833699
else None
36843700
),
36853701
survey_metadata=survey_metadata,

diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,8 @@ def _compute_dcdh_bootstrap(
782782
# Sibling of the OVERALL event-study sup-t at the multi-horizon
783783
# block above (`:599-614`). Per-path joint simultaneous
784784
# confidence bands across horizons 1..L_max within each path:
785-
# one shared (n_bootstrap, n_eligible) Hall-Mammen weight matrix
785+
# one shared (n_bootstrap, n_eligible) multiplier weight matrix
786+
# (using `self.bootstrap_weights` — Rademacher / Mammen / Webb)
786787
# per path is broadcast across all valid horizons of that path,
787788
# producing correlated bootstrap distributions across horizons.
788789
# The path-specific critical value

0 commit comments

Comments
 (0)