Skip to content

Commit 41bc1e8

Browse files
igerberclaude
andcommitted
Fix Round 4: delta SE on analytical path, shared bootstrap, equal-cell Note
- Compute delta-method SE regardless of bootstrap (was gated on bootstrap_results != None, leaving analytical path with NaN) - Generate one shared bootstrap weight matrix for all horizons so sup-t bands are a valid joint multiplier-bootstrap band - Add REGISTRY Note for Phase 2 equal-cell weighting deviation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 00efe07 commit 41bc1e8

3 files changed

Lines changed: 36 additions & 28 deletions

File tree

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,20 +1506,18 @@ def fit(
15061506
# Cost-benefit delta SE: compute from per-horizon bootstrap
15071507
# distributions if available (delta = sum w_l * DID_l, so
15081508
# delta_b = sum w_l * DID_l_b for each bootstrap rep).
1509-
delta_se = float("nan")
1510-
if bootstrap_results is not None and bootstrap_results.event_study_ses is not None:
1511-
# The mixin stores overall_dist for l=1; we need
1512-
# per-horizon distributions which were computed but
1513-
# not all stored. Use the delta-method SE as fallback:
1514-
# Var(delta) = sum_l w_l^2 * Var(DID_l) for indep.
1515-
weights = cost_benefit_result.get("weights", {})
1516-
var_delta = 0.0
1517-
for l_w, w_l in weights.items():
1518-
se_l = event_study_effects.get(l_w, {}).get("se", float("nan"))
1519-
if np.isfinite(se_l):
1520-
var_delta += (w_l * se_l) ** 2
1521-
if var_delta > 0:
1522-
delta_se = float(np.sqrt(var_delta))
1509+
# Delta-method SE: Var(delta) = sum w_l^2 * Var(DID_l)
1510+
# (treating horizons as independent, conservative under
1511+
# Assumption 8). Works on both analytical and bootstrap
1512+
# SEs since event_study_effects[l]["se"] holds whichever
1513+
# was propagated.
1514+
weights = cost_benefit_result.get("weights", {})
1515+
var_delta = 0.0
1516+
for l_w, w_l in weights.items():
1517+
se_l = event_study_effects.get(l_w, {}).get("se", float("nan"))
1518+
if np.isfinite(se_l):
1519+
var_delta += (w_l * se_l) ** 2
1520+
delta_se = float(np.sqrt(var_delta)) if var_delta > 0 else float("nan")
15231521

15241522
if np.isfinite(delta_se):
15251523
effective_overall_se = delta_se

diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -251,25 +251,36 @@ def _compute_dcdh_bootstrap(
251251
results.placebo_ci = ci_pl
252252
results.placebo_p_value = p_pl
253253

254-
# --- Phase 2: Multi-horizon bootstrap ---
254+
# --- Phase 2: Multi-horizon bootstrap with shared weight matrix ---
255+
# Generate ONE shared (n_bootstrap, n_groups) weight matrix so all
256+
# horizons use the same bootstrap draw, making the sup-t statistic
257+
# a valid joint multiplier-bootstrap band.
255258
if multi_horizon_inputs is not None:
256259
es_ses: Dict[int, float] = {}
257260
es_cis: Dict[int, Tuple[float, float]] = {}
258261
es_pvals: Dict[int, float] = {}
259262
es_dists: Dict[int, np.ndarray] = {}
260263

264+
# Shared weight matrix sized for the group set
265+
n_groups_mh = n_groups_for_overall
266+
shared_weights = _generate_bootstrap_weights_batch(
267+
n_bootstrap=self.n_bootstrap,
268+
n_units=n_groups_mh,
269+
weight_type=self.bootstrap_weights,
270+
rng=rng,
271+
)
272+
261273
for l_h, (u_h, n_h, eff_h) in sorted(multi_horizon_inputs.items()):
262274
if u_h.size > 0 and n_h > 0:
263-
se_h, ci_h, p_h, dist_h = _bootstrap_one_target(
264-
u_centered=u_h,
265-
divisor=n_h,
266-
original=eff_h,
267-
n_bootstrap=self.n_bootstrap,
268-
weight_type=self.bootstrap_weights,
275+
# Use the shared weight matrix truncated to u_h length
276+
w_h = shared_weights[:, : u_h.size]
277+
deviations = (w_h @ u_h) / n_h
278+
dist_h = deviations + eff_h
279+
280+
se_h, ci_h, p_h = _compute_effect_bootstrap_stats(
281+
original_effect=eff_h,
282+
boot_dist=dist_h,
269283
alpha=self.alpha,
270-
rng=rng,
271-
context=f"dCDH horizon l={l_h} bootstrap",
272-
return_distribution=True,
273284
)
274285
es_ses[l_h] = se_h
275286
es_cis[l_h] = ci_h
@@ -280,9 +291,7 @@ def _compute_dcdh_bootstrap(
280291
results.event_study_cis = es_cis
281292
results.event_study_p_values = es_pvals
282293

283-
# Sup-t simultaneous confidence bands (CallawaySantAnna pattern
284-
# from staggered_bootstrap.py:497-533): for each bootstrap rep,
285-
# compute the max absolute t-stat across horizons.
294+
# Sup-t simultaneous confidence bands using the shared draws.
286295
valid_horizons = [
287296
l_h
288297
for l_h in es_dists
@@ -292,7 +301,6 @@ def _compute_dcdh_bootstrap(
292301
boot_matrix = np.array([es_dists[l_h] for l_h in valid_horizons])
293302
effects_vec = np.array([multi_horizon_inputs[l_h][2] for l_h in valid_horizons])
294303
ses_vec = np.array([es_ses[l_h] for l_h in valid_horizons])
295-
# sup_t_dist[b] = max_l |(boot_l[b] - DID_l) / SE_l|
296304
t_stats = np.abs((boot_matrix - effects_vec[:, None]) / ses_vec[:, None])
297305
sup_t_dist = np.max(t_stats, axis=0)
298306
finite_mask = np.isfinite(sup_t_dist)

docs/methodology/REGISTRY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,8 @@ Cost-benefit aggregate `delta = sum_l w_l * DID_l` (Lemma 4) where `w_l` are non
533533

534534
Dynamic placebos `DID^{pl}_l` look backward from each group's reference period, with a dual eligibility condition: `F_g - 1 - l >= 1` AND `F_g - 1 + l <= T_g`.
535535

536+
- **Note (Phase 2 equal-cell weighting, deviation from R `DIDmultiplegtDYN`):** The Phase 1 equal-cell weighting contract carries forward to all Phase 2 estimands (`DID_l`, `DID^{pl}_l`, `DID^n_l`, `delta`). Each `(g, t)` cell contributes equally regardless of within-cell observation count. On individual-level inputs with uneven cell sizes, this produces a different estimand than R `DIDmultiplegtDYN` which weights by cell size. The parity tests use one-observation-per-cell generators so parity holds. See the Phase 1 weighting Note above for the full rationale.
537+
536538
- **Note (Phase 2 `<50%` switcher warning):** When fewer than 50% of the l=1 switchers contribute at a far horizon l, `fit()` emits a `UserWarning`. The paper recommends not reporting such horizons (Favara-Imbs application, footnote 14).
537539

538540
- **Note (Phase 2 Assumption 7 and cost-benefit delta):** Assumption 7 (`D_{g,t} >= D_{g,1}`) is required for the single-sign cost-benefit interpretation. When leavers are present (binary: 1->0 groups violate Assumption 7), the estimator emits a `UserWarning` and provides `delta_joiners` / `delta_leavers` separately on `results.cost_benefit_delta`.

0 commit comments

Comments
 (0)