Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ The dynamic companion paper subsumes the AER 2020 paper: `DID_1 = DID_M`. The si
| **3d.** Heterogeneity testing `beta^{het}_l` (Web Appendix Section 1.5) | LOW | Shipped (PR B) |
| **3e.** Design-2 switch-in / switch-out separation (Web Appendix Section 1.6) | LOW | Shipped (PR B; convenience wrapper) |
| **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) |
| **3g.** HonestDiD (Rambachan-Roth) integration on `DID^{pl}_l` placebos | MEDIUM | Not started |
| **3g.** HonestDiD (Rambachan-Roth) integration on `DID^{pl}_l` placebos | MEDIUM | Shipped (PR C) |
| **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 |
| **3i.** Parity tests vs `did_multiplegt_dyn` for covariate and extension specifications | HIGH | Shipped (PR B; controls, trends_lin, combined) |

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

if honest_did and L_max is None:
raise ValueError(
"honest_did=True requires L_max >= 1 for multi-horizon placebos. "
"Set L_max to compute DID^{pl}_l placebos that HonestDiD uses as "
"pre-period coefficients."
)
if honest_did and not self.placebo:
raise ValueError(
"honest_did=True requires placebo computation. The estimator was "
"constructed with placebo=False. Use "
"ChaisemartinDHaultfoeuille(placebo=True) (the default)."
)

# Pivot to (group x time) matrices for vectorized computations
d_pivot = cell.pivot(index=group, columns=time, values="d_gt").reindex(
index=all_groups, columns=all_periods
Expand Down Expand Up @@ -2394,6 +2415,28 @@ def fit(
_estimator_ref=self,
)

# ------------------------------------------------------------------
# HonestDiD integration (when honest_did=True)
# ------------------------------------------------------------------
if honest_did and results.placebo_event_study:
try:
from diff_diff.honest_did import compute_honest_did

results.honest_did_results = compute_honest_did(
results, method="relative_magnitude", M=1.0,
alpha=self.alpha,
)
except (ValueError, np.linalg.LinAlgError) as exc:
warnings.warn(
f"HonestDiD computation failed ({type(exc).__name__}): "
f"{exc}. results.honest_did_results will be None. "
f"You can retry with compute_honest_did(results, ...) "
f"using different parameters.",
UserWarning,
stacklevel=2,
)
results.honest_did_results = None

self.results_ = results
self.is_fitted_ = True
return results
Expand Down Expand Up @@ -2432,12 +2475,8 @@ def _check_forward_compat_gates(
# Validation (L_max >= 1, n_periods >= 3 required) is in fit().
# trends_nonparam gate lifted - state-set trends implemented.
# Validation (L_max >= 1, column exists, time-invariant) is in fit().
if honest_did:
raise NotImplementedError(
"HonestDiD integration for dCDH is reserved for Phase 3, applied to "
"the placebo DID^{pl}_l output. Phase 1 provides only the placebo "
"point estimate via results.placebo_effect. See ROADMAP.md Phase 3."
)
# honest_did gate lifted - integration implemented.
# Validation (L_max >= 1 required) is in fit() after L_max detection.


def _drop_crossing_cells(
Expand Down
178 changes: 174 additions & 4 deletions diff_diff/chaisemartin_dhaultfoeuille_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@
NBER Working Paper 29873.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

if TYPE_CHECKING:
from diff_diff.honest_did import HonestDiDResults

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

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

lines.extend([""])

# --- Phase 3 extension blocks (factored into helpers) ---
self._render_covariate_section(lines, width, thin)
self._render_linear_trends_section(lines, width, thin, header_row)
self._render_heterogeneity_section(lines, width, thin)
self._render_design2_section(lines, width, thin)
self._render_honest_did_section(lines, width, thin)

# --- TWFE diagnostic ---
if self.twfe_beta_fe is not None:
lines.extend(
Expand Down Expand Up @@ -842,6 +857,161 @@ def print_summary(self, alpha: Optional[float] = None) -> None:
"""Print the formatted summary to stdout."""
print(self.summary(alpha))

# ------------------------------------------------------------------
# Summary section helpers (Phase 3 blocks)
# ------------------------------------------------------------------

def _render_covariate_section(
self, lines: List[str], width: int, thin: str
) -> None:
if self.covariate_residuals is None:
return
cov_df = self.covariate_residuals
control_names = sorted(cov_df["covariate"].unique())
n_baselines = cov_df["baseline_treatment"].nunique()
failed = int(
(cov_df.groupby("baseline_treatment")["theta_hat"].first().isna()).sum()
)
lines.extend(
[
thin,
"Covariate Adjustment (DID^X) Diagnostics".center(width),
thin,
f"{'Controls:':<35} {', '.join(control_names):>10}",
f"{'Baselines residualized:':<35} {n_baselines:>10}",
f"{'Failed strata:':<35} {failed:>10}",
thin,
"",
]
)

def _render_linear_trends_section(
self, lines: List[str], width: int, thin: str, header_row: str
) -> None:
if self.linear_trends_effects is None:
return
lines.extend(
[
thin,
"Cumulated Level Effects (DID^{fd}, trends_linear)".center(width),
thin,
header_row,
thin,
]
)
for l_h in sorted(self.linear_trends_effects.keys()):
entry = self.linear_trends_effects[l_h]
lines.append(
_format_inference_row(
f"Level_{l_h}",
entry["effect"],
entry["se"],
entry["t_stat"],
entry["p_value"],
)
)
lines.extend([thin, ""])

def _render_heterogeneity_section(
self, lines: List[str], width: int, thin: str
) -> None:
if self.heterogeneity_effects is None:
return
lines.extend(
[
thin,
"Heterogeneity Test (Section 1.5, partial)".center(width),
thin,
f"{'Horizon':<15} {'beta^het':>12} {'Std. Err.':>12} "
f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
thin,
]
)
for l_h in sorted(self.heterogeneity_effects.keys()):
entry = self.heterogeneity_effects[l_h]
lines.append(
_format_inference_row(
f"l={l_h}",
entry["beta"],
entry["se"],
entry["t_stat"],
entry["p_value"],
)
)
lines.extend(
[
thin,
"Note: Post-treatment regressions only (no placebo/joint test).",
"",
]
)

def _render_design2_section(
self, lines: List[str], width: int, thin: str
) -> None:
if self.design2_effects is None:
return
d2 = self.design2_effects
si = d2.get("switch_in", {})
so = d2.get("switch_out", {})
lines.extend(
[
thin,
"Design-2: Switch-In / Switch-Out (Section 1.6)".center(width),
thin,
f"{'Join-then-leave groups:':<35} {d2.get('n_design2_groups', 0):>10}",
f"{'Switch-in effect (mean):':<35} "
f"{_fmt_float(si.get('mean_effect', float('nan'))):>10}"
f" (N={si.get('n_groups', 0)})",
f"{'Switch-out effect (mean):':<35} "
f"{_fmt_float(so.get('mean_effect', float('nan'))):>10}"
f" (N={so.get('n_groups', 0)})",
thin,
"",
]
)

def _render_honest_did_section(
self, lines: List[str], width: int, thin: str
) -> None:
if self.honest_did_results is None:
return
hd = self.honest_did_results
method_label = hd.method.replace("_", " ").title()
m_val = hd.M
sig_label = "Yes" if hd.is_significant else "No"
conf_pct = int((1 - hd.alpha) * 100)
lines.extend(
[
thin,
"HonestDiD Sensitivity (Rambachan-Roth 2023)".center(width),
thin,
f"{'Method:':<35} {method_label} (M={_fmt_float(m_val)})",
f"{'Target:':<35} {hd.target_label}",
]
)
if hd.post_periods_used is not None:
lines.append(
f"{'Post horizons used:':<35} {hd.post_periods_used}"
)
if hd.pre_periods_used is not None:
lines.append(
f"{'Pre horizons used:':<35} {hd.pre_periods_used}"
)
lines.extend(
[
f"{'Original estimate:':<35} {_fmt_float(hd.original_estimate):>10}",
f"{'Identified set:':<35} "
f"[{_fmt_float(hd.lb)}, {_fmt_float(hd.ub)}]",
f"{'Robust ' + str(conf_pct) + '% CI:':<35} "
f"[{_fmt_float(hd.ci_lb)}, {_fmt_float(hd.ci_ub)}]",
f"{'Significant at ' + str(int(hd.alpha * 100)) + '%:':<35} "
f"{sig_label:>10}",
thin,
"",
]
)

# ------------------------------------------------------------------
# to_dataframe
# ------------------------------------------------------------------
Expand Down
Loading
Loading