Skip to content

Commit 0028102

Browse files
authored
Merge pull request #300 from igerber/dcdh-phase3a-cleanup-nonbinary
dCDH Phase 3a: placebo SE, non-binary treatment, parity SE assertions
2 parents 993e43a + 7bfa4c6 commit 0028102

7 files changed

Lines changed: 1202 additions & 275 deletions

TODO.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Deferred items from PR reviews that were not addressed before merge.
5656

5757
| Issue | Location | PR | Priority |
5858
|-------|----------|----|----------|
59+
| dCDH: Phase 1 per-period placebo DID_M^pl has NaN SE (no IF derivation for the per-period aggregation path). Multi-horizon placebos (L_max >= 1) have valid SE. | `chaisemartin_dhaultfoeuille.py` | #294 | Low |
60+
| dCDH: Parity test SE/CI assertions only cover pure-direction scenarios; mixed-direction SE comparison is structurally apples-to-oranges (cell-count vs obs-count weighting). | `test_chaisemartin_dhaultfoeuille_parity.py` | #294 | Low |
5961
| CallawaySantAnna: consider materializing NaN entries for non-estimable (g,t) cells in group_time_effects dict (currently omitted with consolidated warning); would require updating downstream consumers (event study, balance_e, aggregation) | `staggered.py` | #256 | Low |
6062
| ImputationDiD dense `(A0'A0).toarray()` scales O((U+T+K)^2), OOM risk on large panels | `imputation.py` | #141 | Medium (deferred — only triggers when sparse solver fails) |
6163
| Multi-absorb weighted demeaning needs iterative alternating projections for N > 1 absorbed FE with survey weights; unweighted multi-absorb also uses single-pass (pre-existing, exact only for balanced panels) | `estimators.py` | #218 | Medium |

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 561 additions & 158 deletions
Large diffs are not rendered by default.

diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
produce a bootstrap distribution per target.
2020
"""
2121

22-
import warnings
2322
from typing import TYPE_CHECKING, Dict, Optional, Tuple
2423

2524
import numpy as np
@@ -135,9 +134,8 @@ def _compute_dcdh_bootstrap(
135134
``divisor`` is the leaver switching-cell total
136135
``sum_t N_{0,1,t}``.
137136
placebo_inputs : tuple, optional
138-
Same triple for the placebo ``DID_M^pl`` target. Always
139-
``None`` in Phase 1 — see REGISTRY.md placebo-bootstrap-
140-
deferred Note.
137+
Same triple for the Phase 1 per-period placebo ``DID_M^pl``.
138+
``None`` when ``L_max=None`` (per-period placebo has no IF).
141139
142140
Returns
143141
-------
@@ -160,29 +158,29 @@ def _compute_dcdh_bootstrap(
160158
f"u_centered_overall length ({u_centered_overall.shape[0]}) does not "
161159
f"match n_groups_for_overall ({n_groups_for_overall})"
162160
)
163-
if divisor_overall <= 0:
164-
warnings.warn(
165-
f"_compute_dcdh_bootstrap: divisor_overall={divisor_overall} <= 0; "
166-
"returning all-NaN bootstrap results.",
167-
RuntimeWarning,
168-
stacklevel=2,
169-
)
170-
return _empty_bootstrap_results(self.n_bootstrap, self.bootstrap_weights, self.alpha)
171-
172161
rng = np.random.default_rng(self.seed)
173162

174163
# --- Overall DID_M ---
175-
overall_se, overall_ci, overall_p, overall_dist = _bootstrap_one_target(
176-
u_centered=u_centered_overall,
177-
divisor=divisor_overall,
178-
original=original_overall,
179-
n_bootstrap=self.n_bootstrap,
180-
weight_type=self.bootstrap_weights,
181-
alpha=self.alpha,
182-
rng=rng,
183-
context="dCDH overall DID_M bootstrap",
184-
return_distribution=True,
185-
)
164+
# Skip the scalar DID_M bootstrap when divisor_overall <= 0
165+
# (e.g., pure non-binary panels where N_S=0), but continue
166+
# to process multi_horizon_inputs and placebo_horizon_inputs.
167+
if divisor_overall > 0:
168+
overall_se, overall_ci, overall_p, overall_dist = _bootstrap_one_target(
169+
u_centered=u_centered_overall,
170+
divisor=divisor_overall,
171+
original=original_overall,
172+
n_bootstrap=self.n_bootstrap,
173+
weight_type=self.bootstrap_weights,
174+
alpha=self.alpha,
175+
rng=rng,
176+
context="dCDH overall DID_M bootstrap",
177+
return_distribution=True,
178+
)
179+
else:
180+
overall_se = np.nan
181+
overall_ci = (np.nan, np.nan)
182+
overall_p = np.nan
183+
overall_dist = None
186184

187185
results = DCDHBootstrapResults(
188186
n_bootstrap=self.n_bootstrap,
@@ -399,15 +397,3 @@ def _bootstrap_one_target(
399397
return se, ci, p_value, (boot_dist if return_distribution else None)
400398

401399

402-
def _empty_bootstrap_results(
403-
n_bootstrap: int, weight_type: str, alpha: float
404-
) -> DCDHBootstrapResults:
405-
"""Return an all-NaN bootstrap results object as a graceful fallback."""
406-
return DCDHBootstrapResults(
407-
n_bootstrap=n_bootstrap,
408-
weight_type=weight_type,
409-
alpha=alpha,
410-
overall_se=np.nan,
411-
overall_ci=(np.nan, np.nan),
412-
overall_p_value=np.nan,
413-
)

diff_diff/chaisemartin_dhaultfoeuille_results.py

Lines changed: 75 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,14 @@ class DCDHBootstrapResults:
4848
in the underlying data (e.g., no leavers), the matching fields are
4949
``None``.
5050
51-
**Phase 1 placebo bootstrap is intentionally NOT computed.** The
52-
dynamic companion paper Section 3.7.3 derives the cohort-recentered
53-
analytical variance for ``DID_l`` only, not for the placebo
51+
**Phase 1 per-period placebo (L_max=None) bootstrap is NOT computed.**
52+
The dynamic companion paper Section 3.7.3 derives the cohort-recentered
53+
analytical variance for ``DID_l`` only, not for the per-period
5454
``DID_M^pl``. The ``placebo_se`` / ``placebo_ci`` / ``placebo_p_value``
55-
fields below ALWAYS remain ``None`` in Phase 1, even when
56-
``n_bootstrap > 0``. Phase 2 will add multiplier-bootstrap support
57-
for the placebo via the dynamic paper's machinery.
55+
fields below remain ``None`` for Phase 1. Multi-horizon placebos
56+
(``L_max >= 1``) have valid SE via ``placebo_horizon_ses`` - this is
57+
a library extension applying the same IF/variance structure to the
58+
placebo estimand (see REGISTRY.md dynamic placebo SE Note).
5859
5960
Attributes
6061
----------
@@ -84,12 +85,14 @@ class DCDHBootstrapResults:
8485
leavers_p_value : float, optional
8586
Bootstrap p-value for leavers-only ``DID_-``.
8687
placebo_se : float, optional
87-
**Always ``None`` in Phase 1** — placebo bootstrap is deferred
88-
to Phase 2 (see class docstring above).
88+
``None`` for the Phase 1 single-period placebo (``L_max=None``).
89+
Multi-horizon placebo bootstrap SE is on
90+
``placebo_horizon_ses``.
8991
placebo_ci : tuple of float, optional
90-
**Always ``None`` in Phase 1** (see class docstring above).
92+
``None`` for single-period placebo. See ``placebo_horizon_cis``.
9193
placebo_p_value : float, optional
92-
**Always ``None`` in Phase 1** (see class docstring above).
94+
``None`` for single-period placebo. See
95+
``placebo_horizon_p_values``.
9396
bootstrap_distribution : np.ndarray, optional
9497
Full bootstrap distribution of the overall ``DID_M`` estimator
9598
(shape: ``(n_bootstrap,)``). Stored for advanced diagnostics;
@@ -160,9 +163,10 @@ class ChaisemartinDHaultfoeuilleResults:
160163
``summary()``, ``to_dataframe()``, ``is_significant``, and
161164
``significance_stars`` all read from these top-level fields and
162165
therefore reflect the bootstrap inference automatically. The
163-
placebo path is unchanged: placebo bootstrap is deferred to Phase
164-
2, so ``placebo_p_value`` and ``placebo_conf_int`` stay NaN even
165-
when ``n_bootstrap > 0``. See the methodology registry
166+
single-period placebo (``L_max=None``) still has NaN bootstrap
167+
fields; multi-horizon placebos (``L_max >= 1``) have valid
168+
bootstrap SE/CI/p via ``placebo_horizon_ses/cis/p_values``.
169+
See the methodology registry
166170
``Note (bootstrap inference surface)`` for the full contract and
167171
library precedent.
168172
@@ -273,8 +277,12 @@ class ChaisemartinDHaultfoeuilleResults:
273277
n_treated_obs : int
274278
Treated observations in the post-filter sample.
275279
n_switcher_cells : int
276-
Number of switching ``(g, t)`` cells across periods. Equals
277-
``sum_t (n_10_t + n_01_t)`` where each transition cell counts
280+
When ``L_max=None``: number of switching ``(g, t)`` cells
281+
(``N_S = sum_t (n_10_t + n_01_t)``). When ``L_max >= 1``:
282+
number of eligible switcher groups at horizon 1 (``N_1``).
283+
Previously this field always held the cell count; for
284+
``L_max >= 1`` it was repurposed to hold the per-group count
285+
that matches the ``DID_1`` estimand. Originally equals
278286
once regardless of how many original observations fed into it.
279287
This is the ``N_S`` denominator of ``DID_M`` per AER 2020
280288
Theorem 3 — cell counts, not within-cell observation counts.
@@ -301,14 +309,14 @@ class ChaisemartinDHaultfoeuilleResults:
301309
alpha : float
302310
Significance level used for confidence intervals.
303311
event_study_effects : dict, optional
304-
In Phase 1 this is populated with a single entry for horizon
305-
``1``, mirroring ``overall_att``. Keeping the field shape stable
306-
avoids API churn when Phase 2 adds entries for ``l = 2, ..., L``.
312+
Populated with horizon ``1`` when ``L_max=None``, or horizons
313+
``1..L_max`` when ``L_max >= 1``. When ``L_max >= 1``, uses the
314+
per-group ``DID_{g,l}`` path; when ``L_max=None``, uses the
315+
per-period ``DID_M`` path.
307316
normalized_effects : dict, optional
308-
Phase 2 placeholder (``DID^n_l``). Always ``None`` in Phase 1.
317+
Normalized estimator ``DID^n_l``. Populated when ``L_max >= 1``.
309318
cost_benefit_delta : dict, optional
310-
Phase 2 placeholder (cost-benefit aggregate ``delta``). Always
311-
``None`` in Phase 1.
319+
Cost-benefit aggregate ``delta``. Populated when ``L_max >= 2``.
312320
sup_t_bands : dict, optional
313321
Phase 2 placeholder (sup-t simultaneous confidence bands).
314322
covariate_residuals : pd.DataFrame, optional
@@ -411,7 +419,12 @@ class ChaisemartinDHaultfoeuilleResults:
411419
def __repr__(self) -> str:
412420
"""Concise string representation."""
413421
sig = _get_significance_stars(self.overall_p_value)
414-
label = "delta" if self.L_max is not None and self.L_max >= 2 else "DID_M"
422+
if self.L_max is not None and self.L_max >= 2:
423+
label = "delta"
424+
elif self.L_max is not None and self.L_max == 1:
425+
label = "DID_1"
426+
else:
427+
label = "DID_M"
415428
return (
416429
f"ChaisemartinDHaultfoeuilleResults("
417430
f"{label}={self.overall_att:.4f}{sig}, "
@@ -477,7 +490,9 @@ def summary(self, alpha: Optional[float] = None) -> str:
477490
"",
478491
f"{'Total observations:':<35} {self.n_obs:>10}",
479492
f"{'Treated observations:':<35} {self.n_treated_obs:>10}",
480-
f"{'Switcher cells (N_S):':<35} {self.n_switcher_cells:>10}",
493+
f"{'Eligible switchers (N_1):':<35} {self.n_switcher_cells:>10}"
494+
if self.L_max is not None and self.L_max >= 1
495+
else f"{'Switcher cells (N_S):':<35} {self.n_switcher_cells:>10}",
481496
f"{'Groups (post-filter):':<35} {len(self.groups):>10}",
482497
f"{'Cohorts:':<35} {self.n_cohorts:>10}",
483498
f"{'Time periods:':<35} {len(self.time_periods):>10}",
@@ -507,12 +522,15 @@ def summary(self, alpha: Optional[float] = None) -> str:
507522
)
508523

509524
# --- Overall ---
510-
overall_label = (
511-
"Cost-Benefit Delta"
512-
if self.L_max is not None and self.L_max >= 2
513-
else "DID_M (Contemporaneous-Switch ATT)"
514-
)
515-
overall_row_label = "delta" if self.L_max is not None and self.L_max >= 2 else "DID_M"
525+
if self.L_max is not None and self.L_max >= 2:
526+
overall_label = "Cost-Benefit Delta"
527+
overall_row_label = "delta"
528+
elif self.L_max is not None and self.L_max == 1:
529+
overall_label = "DID_1 (Per-Group ATT at Horizon 1)"
530+
overall_row_label = "DID_1"
531+
else:
532+
overall_label = "DID_M (Contemporaneous-Switch ATT)"
533+
overall_row_label = "DID_M"
516534
lines.extend(
517535
[
518536
thin,
@@ -537,7 +555,8 @@ def summary(self, alpha: Optional[float] = None) -> str:
537555

538556
cv = self.coef_var
539557
if np.isfinite(cv):
540-
lines.append(f"{'CV (SE/|DID_M|):':<25} {cv:>10.4f}")
558+
cv_label = f"CV (SE/|{overall_row_label}|):"
559+
lines.append(f"{cv_label:<25} {cv:>10.4f}")
541560

542561
lines.append("")
543562
is_delta = (
@@ -647,8 +666,8 @@ def summary(self, alpha: Optional[float] = None) -> str:
647666
]
648667
)
649668

650-
# --- Phase 2: Event study table ---
651-
if self.L_max is not None and self.L_max >= 2 and self.event_study_effects:
669+
# --- Event study table (L_max >= 1) ---
670+
if self.L_max is not None and self.L_max >= 1 and self.event_study_effects:
652671
lines.extend(
653672
[
654673
thin,
@@ -768,18 +787,19 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
768787
level : str, default="overall"
769788
One of:
770789
771-
- ``"overall"``: single-row table with the overall ``DID_M``
772-
point estimate, SE, t-stat, p-value, CI bounds.
773-
- ``"joiners_leavers"``: three rows for ``DID_M``, ``DID_+``,
774-
and ``DID_-``.
790+
- ``"overall"``: single-row table with the overall estimand
791+
(``DID_M`` when ``L_max=None``, ``DID_1`` when ``L_max=1``,
792+
``delta`` when ``L_max >= 2``).
793+
- ``"joiners_leavers"``: up to three rows for the overall,
794+
``DID_+``, and ``DID_-`` (binary panels only).
775795
- ``"per_period"``: one row per time period with
776796
``did_plus_t``, ``did_minus_t``, switching cell counts, and
777797
the A11-zeroed flags.
778798
- ``"event_study"``: one row per horizon (positive and
779799
negative/placebo), including a reference period at
780-
horizon 0. Available when ``L_max >= 2``.
800+
horizon 0. Available when ``L_max >= 1``.
781801
- ``"normalized"``: one row per horizon for the normalized
782-
effects ``DID^n_l``. Available when ``L_max >= 2``.
802+
effects ``DID^n_l``. Available when ``L_max >= 1``.
783803
- ``"twfe_weights"``: per-(group, time) TWFE decomposition
784804
weights table. Only available when ``twfe_diagnostic=True``
785805
was passed to ``fit()``.
@@ -793,7 +813,11 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
793813
[
794814
{
795815
"estimand": (
796-
"delta" if self.L_max is not None and self.L_max >= 2 else "DID_M"
816+
"delta"
817+
if self.L_max is not None and self.L_max >= 2
818+
else "DID_1"
819+
if self.L_max is not None and self.L_max == 1
820+
else "DID_M"
797821
),
798822
"effect": self.overall_att,
799823
"se": self.overall_se,
@@ -816,7 +840,12 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
816840
# For the DID_M row, both quantities use the overall switching
817841
# cell set: n_cells = sum of joiner + leaver cells, and n_obs
818842
# is the same sum of raw observation counts.
819-
overall_est_label = "delta" if self.L_max is not None and self.L_max >= 2 else "DID_M"
843+
if self.L_max is not None and self.L_max >= 2:
844+
overall_est_label = "delta"
845+
elif self.L_max is not None and self.L_max == 1:
846+
overall_est_label = "DID_1"
847+
else:
848+
overall_est_label = "DID_M"
820849
rows = [
821850
{
822851
"estimand": overall_est_label,
@@ -827,7 +856,11 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
827856
"conf_int_lower": self.overall_conf_int[0],
828857
"conf_int_upper": self.overall_conf_int[1],
829858
"n_cells": self.n_switcher_cells,
830-
"n_obs": self.n_joiner_obs + self.n_leaver_obs,
859+
"n_obs": (
860+
self.n_treated_obs
861+
if not self.joiners_available and not self.leavers_available
862+
else self.n_joiner_obs + self.n_leaver_obs
863+
),
831864
"available": True,
832865
},
833866
{
@@ -942,7 +975,7 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
942975

943976
elif level == "normalized":
944977
if not self.normalized_effects:
945-
raise ValueError("Normalized effects not computed. Pass L_max >= 2 to fit().")
978+
raise ValueError("Normalized effects not computed. Pass L_max >= 1 to fit().")
946979
rows = []
947980
for h in sorted(self.normalized_effects.keys()):
948981
entry = self.normalized_effects[h]

0 commit comments

Comments
 (0)