Skip to content

Commit e92b6f7

Browse files
authored
Merge pull request #303 from igerber/feature/dcdh-honest-did-integration
Add HonestDiD integration for dCDH, summary() Phase 3 blocks
2 parents d801cc1 + b88a97e commit e92b6f7

8 files changed

Lines changed: 1039 additions & 25 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ The dynamic companion paper subsumes the AER 2020 paper: `DID_1 = DID_M`. The si
173173
| **3d.** Heterogeneity testing `beta^{het}_l` (Web Appendix Section 1.5) | LOW | Shipped (PR B) |
174174
| **3e.** Design-2 switch-in / switch-out separation (Web Appendix Section 1.6) | LOW | Shipped (PR B; convenience wrapper) |
175175
| **3f.** Non-binary treatment support (the formula already handles it; this row is documentation + tests) | MEDIUM | Shipped (PR #300; also ships placebo SE, L_max=1 per-group path, parity SE assertions) |
176-
| **3g.** HonestDiD (Rambachan-Roth) integration on `DID^{pl}_l` placebos | MEDIUM | Not started |
176+
| **3g.** HonestDiD (Rambachan-Roth) integration on `DID^{pl}_l` placebos | MEDIUM | Shipped (PR C) |
177177
| **3h.** **Single comprehensive tutorial notebook** covering all three phases — Favara-Imbs (2015) banking deregulation replication as the headline application, with comparison plots vs LP / TWFE | HIGH | Not started |
178178
| **3i.** Parity tests vs `did_multiplegt_dyn` for covariate and extension specifications | HIGH | Shipped (PR B; controls, trends_lin, combined) |
179179

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,15 @@ def fit(
538538
pool to groups in the same set (Web Appendix Section 1.4).
539539
Requires ``L_max >= 1`` and time-invariant values per group.
540540
honest_did : bool, default=False
541-
**Reserved for Phase 3** (HonestDiD integration on placebos).
541+
Run HonestDiD sensitivity analysis (Rambachan & Roth 2023) on
542+
the placebo + event study surface. Requires ``L_max >= 1``.
543+
Default: relative magnitudes (DeltaRM, Mbar=1.0), targeting
544+
the equal-weight average over all post-treatment horizons
545+
(``l_vec=None``). Results stored on
546+
``results.honest_did_results``; ``None`` with a warning if
547+
the solver fails. For custom parameters (e.g., targeting
548+
the on-impact effect only via ``l_vec``), call
549+
``compute_honest_did(results, ...)`` post-hoc instead.
542550
heterogeneity : str, optional
543551
Column name for a time-invariant covariate to test for
544552
heterogeneous effects (Web Appendix Section 1.5, Lemma 7).
@@ -946,6 +954,19 @@ def fit(
946954
f"is {n_post_baseline}."
947955
)
948956

957+
if honest_did and L_max is None:
958+
raise ValueError(
959+
"honest_did=True requires L_max >= 1 for multi-horizon placebos. "
960+
"Set L_max to compute DID^{pl}_l placebos that HonestDiD uses as "
961+
"pre-period coefficients."
962+
)
963+
if honest_did and not self.placebo:
964+
raise ValueError(
965+
"honest_did=True requires placebo computation. The estimator was "
966+
"constructed with placebo=False. Use "
967+
"ChaisemartinDHaultfoeuille(placebo=True) (the default)."
968+
)
969+
949970
# Pivot to (group x time) matrices for vectorized computations
950971
d_pivot = cell.pivot(index=group, columns=time, values="d_gt").reindex(
951972
index=all_groups, columns=all_periods
@@ -2394,6 +2415,28 @@ def fit(
23942415
_estimator_ref=self,
23952416
)
23962417

2418+
# ------------------------------------------------------------------
2419+
# HonestDiD integration (when honest_did=True)
2420+
# ------------------------------------------------------------------
2421+
if honest_did and results.placebo_event_study:
2422+
try:
2423+
from diff_diff.honest_did import compute_honest_did
2424+
2425+
results.honest_did_results = compute_honest_did(
2426+
results, method="relative_magnitude", M=1.0,
2427+
alpha=self.alpha,
2428+
)
2429+
except (ValueError, np.linalg.LinAlgError) as exc:
2430+
warnings.warn(
2431+
f"HonestDiD computation failed ({type(exc).__name__}): "
2432+
f"{exc}. results.honest_did_results will be None. "
2433+
f"You can retry with compute_honest_did(results, ...) "
2434+
f"using different parameters.",
2435+
UserWarning,
2436+
stacklevel=2,
2437+
)
2438+
results.honest_did_results = None
2439+
23972440
self.results_ = results
23982441
self.is_fitted_ = True
23992442
return results
@@ -2432,12 +2475,8 @@ def _check_forward_compat_gates(
24322475
# Validation (L_max >= 1, n_periods >= 3 required) is in fit().
24332476
# trends_nonparam gate lifted - state-set trends implemented.
24342477
# Validation (L_max >= 1, column exists, time-invariant) is in fit().
2435-
if honest_did:
2436-
raise NotImplementedError(
2437-
"HonestDiD integration for dCDH is reserved for Phase 3, applied to "
2438-
"the placebo DID^{pl}_l output. Phase 1 provides only the placebo "
2439-
"point estimate via results.placebo_effect. See ROADMAP.md Phase 3."
2440-
)
2478+
# honest_did gate lifted - integration implemented.
2479+
# Validation (L_max >= 1 required) is in fit() after L_max detection.
24412480

24422481

24432482
def _drop_crossing_cells(

diff_diff/chaisemartin_dhaultfoeuille_results.py

Lines changed: 174 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@
1818
NBER Working Paper 29873.
1919
"""
2020

21+
from __future__ import annotations
22+
2123
from dataclasses import dataclass, field
22-
from typing import Any, Dict, List, Optional, Tuple
24+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
25+
26+
if TYPE_CHECKING:
27+
from diff_diff.honest_did import HonestDiDResults
2328

2429
import numpy as np
2530
import pandas as pd
@@ -331,8 +336,11 @@ class ChaisemartinDHaultfoeuilleResults:
331336
design2_effects : dict, optional
332337
Design-2 switch-in/switch-out descriptive summary. Populated
333338
when ``design2=True``.
334-
honest_did_results : Any, optional
335-
Reserved for HonestDiD integration on placebos.
339+
honest_did_results : HonestDiDResults, optional
340+
HonestDiD sensitivity analysis bounds (Rambachan & Roth 2023).
341+
Populated when ``honest_did=True`` in ``fit()`` or by calling
342+
``compute_honest_did(results)`` post-hoc. Contains identified
343+
set bounds, robust confidence intervals, and breakdown analysis.
336344
survey_metadata : Any, optional
337345
Always ``None`` in Phase 1 — survey integration is deferred to a
338346
separate effort after all phases ship.
@@ -415,7 +423,7 @@ class ChaisemartinDHaultfoeuilleResults:
415423
linear_trends_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False)
416424
heterogeneity_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False)
417425
design2_effects: Optional[Dict[str, Any]] = field(default=None, repr=False)
418-
honest_did_results: Optional[Any] = field(default=None, repr=False)
426+
honest_did_results: Optional["HonestDiDResults"] = field(default=None, repr=False)
419427

420428
# --- Repr-suppressed metadata ---
421429
survey_metadata: Optional[Any] = field(default=None, repr=False)
@@ -798,6 +806,13 @@ def summary(self, alpha: Optional[float] = None) -> str:
798806

799807
lines.extend([""])
800808

809+
# --- Phase 3 extension blocks (factored into helpers) ---
810+
self._render_covariate_section(lines, width, thin)
811+
self._render_linear_trends_section(lines, width, thin, header_row)
812+
self._render_heterogeneity_section(lines, width, thin)
813+
self._render_design2_section(lines, width, thin)
814+
self._render_honest_did_section(lines, width, thin)
815+
801816
# --- TWFE diagnostic ---
802817
if self.twfe_beta_fe is not None:
803818
lines.extend(
@@ -842,6 +857,161 @@ def print_summary(self, alpha: Optional[float] = None) -> None:
842857
"""Print the formatted summary to stdout."""
843858
print(self.summary(alpha))
844859

860+
# ------------------------------------------------------------------
861+
# Summary section helpers (Phase 3 blocks)
862+
# ------------------------------------------------------------------
863+
864+
def _render_covariate_section(
865+
self, lines: List[str], width: int, thin: str
866+
) -> None:
867+
if self.covariate_residuals is None:
868+
return
869+
cov_df = self.covariate_residuals
870+
control_names = sorted(cov_df["covariate"].unique())
871+
n_baselines = cov_df["baseline_treatment"].nunique()
872+
failed = int(
873+
(cov_df.groupby("baseline_treatment")["theta_hat"].first().isna()).sum()
874+
)
875+
lines.extend(
876+
[
877+
thin,
878+
"Covariate Adjustment (DID^X) Diagnostics".center(width),
879+
thin,
880+
f"{'Controls:':<35} {', '.join(control_names):>10}",
881+
f"{'Baselines residualized:':<35} {n_baselines:>10}",
882+
f"{'Failed strata:':<35} {failed:>10}",
883+
thin,
884+
"",
885+
]
886+
)
887+
888+
def _render_linear_trends_section(
889+
self, lines: List[str], width: int, thin: str, header_row: str
890+
) -> None:
891+
if self.linear_trends_effects is None:
892+
return
893+
lines.extend(
894+
[
895+
thin,
896+
"Cumulated Level Effects (DID^{fd}, trends_linear)".center(width),
897+
thin,
898+
header_row,
899+
thin,
900+
]
901+
)
902+
for l_h in sorted(self.linear_trends_effects.keys()):
903+
entry = self.linear_trends_effects[l_h]
904+
lines.append(
905+
_format_inference_row(
906+
f"Level_{l_h}",
907+
entry["effect"],
908+
entry["se"],
909+
entry["t_stat"],
910+
entry["p_value"],
911+
)
912+
)
913+
lines.extend([thin, ""])
914+
915+
def _render_heterogeneity_section(
916+
self, lines: List[str], width: int, thin: str
917+
) -> None:
918+
if self.heterogeneity_effects is None:
919+
return
920+
lines.extend(
921+
[
922+
thin,
923+
"Heterogeneity Test (Section 1.5, partial)".center(width),
924+
thin,
925+
f"{'Horizon':<15} {'beta^het':>12} {'Std. Err.':>12} "
926+
f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
927+
thin,
928+
]
929+
)
930+
for l_h in sorted(self.heterogeneity_effects.keys()):
931+
entry = self.heterogeneity_effects[l_h]
932+
lines.append(
933+
_format_inference_row(
934+
f"l={l_h}",
935+
entry["beta"],
936+
entry["se"],
937+
entry["t_stat"],
938+
entry["p_value"],
939+
)
940+
)
941+
lines.extend(
942+
[
943+
thin,
944+
"Note: Post-treatment regressions only (no placebo/joint test).",
945+
"",
946+
]
947+
)
948+
949+
def _render_design2_section(
950+
self, lines: List[str], width: int, thin: str
951+
) -> None:
952+
if self.design2_effects is None:
953+
return
954+
d2 = self.design2_effects
955+
si = d2.get("switch_in", {})
956+
so = d2.get("switch_out", {})
957+
lines.extend(
958+
[
959+
thin,
960+
"Design-2: Switch-In / Switch-Out (Section 1.6)".center(width),
961+
thin,
962+
f"{'Join-then-leave groups:':<35} {d2.get('n_design2_groups', 0):>10}",
963+
f"{'Switch-in effect (mean):':<35} "
964+
f"{_fmt_float(si.get('mean_effect', float('nan'))):>10}"
965+
f" (N={si.get('n_groups', 0)})",
966+
f"{'Switch-out effect (mean):':<35} "
967+
f"{_fmt_float(so.get('mean_effect', float('nan'))):>10}"
968+
f" (N={so.get('n_groups', 0)})",
969+
thin,
970+
"",
971+
]
972+
)
973+
974+
def _render_honest_did_section(
975+
self, lines: List[str], width: int, thin: str
976+
) -> None:
977+
if self.honest_did_results is None:
978+
return
979+
hd = self.honest_did_results
980+
method_label = hd.method.replace("_", " ").title()
981+
m_val = hd.M
982+
sig_label = "Yes" if hd.is_significant else "No"
983+
conf_pct = int((1 - hd.alpha) * 100)
984+
lines.extend(
985+
[
986+
thin,
987+
"HonestDiD Sensitivity (Rambachan-Roth 2023)".center(width),
988+
thin,
989+
f"{'Method:':<35} {method_label} (M={_fmt_float(m_val)})",
990+
f"{'Target:':<35} {hd.target_label}",
991+
]
992+
)
993+
if hd.post_periods_used is not None:
994+
lines.append(
995+
f"{'Post horizons used:':<35} {hd.post_periods_used}"
996+
)
997+
if hd.pre_periods_used is not None:
998+
lines.append(
999+
f"{'Pre horizons used:':<35} {hd.pre_periods_used}"
1000+
)
1001+
lines.extend(
1002+
[
1003+
f"{'Original estimate:':<35} {_fmt_float(hd.original_estimate):>10}",
1004+
f"{'Identified set:':<35} "
1005+
f"[{_fmt_float(hd.lb)}, {_fmt_float(hd.ub)}]",
1006+
f"{'Robust ' + str(conf_pct) + '% CI:':<35} "
1007+
f"[{_fmt_float(hd.ci_lb)}, {_fmt_float(hd.ci_ub)}]",
1008+
f"{'Significant at ' + str(int(hd.alpha * 100)) + '%:':<35} "
1009+
f"{sig_label:>10}",
1010+
thin,
1011+
"",
1012+
]
1013+
)
1014+
8451015
# ------------------------------------------------------------------
8461016
# to_dataframe
8471017
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)