Skip to content

Commit b732007

Browse files
igerberclaude
andcommitted
Address AI review: runtime warning, docstring, summary refactor, edge-case test
- Add UserWarning in dCDH HonestDiD extraction about placebo-based pre-periods - Update REGISTRY.md to explicitly document library extension semantics - Update fit() docstring for honest_did (was "Reserved for Phase 3") - Include exception class name in HonestDiD failure warning - Factor summary() Phase 3 blocks into 5 private helper methods - Add test_dcdh_emits_placebo_warning and test_dcdh_empty_consecutive_block_raises Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cb58478 commit b732007

File tree

5 files changed

+202
-124
lines changed

5 files changed

+202
-124
lines changed

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,12 @@ 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). Results
544+
stored on ``results.honest_did_results``; ``None`` with a
545+
warning if the solver fails. For custom parameters, call
546+
``compute_honest_did(results, ...)`` post-hoc instead.
542547
heterogeneity : str, optional
543548
Column name for a time-invariant covariate to test for
544549
heterogeneous effects (Web Appendix Section 1.5, Lemma 7).
@@ -2413,8 +2418,8 @@ def fit(
24132418
)
24142419
except (ValueError, np.linalg.LinAlgError) as exc:
24152420
warnings.warn(
2416-
f"HonestDiD computation failed: {exc}. "
2417-
f"results.honest_did_results will be None. "
2421+
f"HonestDiD computation failed ({type(exc).__name__}): "
2422+
f"{exc}. results.honest_did_results will be None. "
24182423
f"You can retry with compute_honest_did(results, ...) "
24192424
f"using different parameters.",
24202425
UserWarning,

diff_diff/chaisemartin_dhaultfoeuille_results.py

Lines changed: 148 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -806,126 +806,12 @@ def summary(self, alpha: Optional[float] = None) -> str:
806806

807807
lines.extend([""])
808808

809-
# --- Covariate adjustment diagnostics (DID^X) ---
810-
if self.covariate_residuals is not None:
811-
cov_df = self.covariate_residuals
812-
control_names = sorted(cov_df["covariate"].unique())
813-
n_baselines = cov_df["baseline_treatment"].nunique()
814-
failed = int((cov_df.groupby("baseline_treatment")["theta_hat"].first().isna()).sum())
815-
lines.extend(
816-
[
817-
thin,
818-
"Covariate Adjustment (DID^X) Diagnostics".center(width),
819-
thin,
820-
f"{'Controls:':<35} {', '.join(control_names):>10}",
821-
f"{'Baselines residualized:':<35} {n_baselines:>10}",
822-
f"{'Failed strata:':<35} {failed:>10}",
823-
thin,
824-
"",
825-
]
826-
)
827-
828-
# --- Linear trends cumulated level effects ---
829-
if self.linear_trends_effects is not None:
830-
lines.extend(
831-
[
832-
thin,
833-
"Cumulated Level Effects (DID^{fd}, trends_linear)".center(width),
834-
thin,
835-
header_row,
836-
thin,
837-
]
838-
)
839-
for l_h in sorted(self.linear_trends_effects.keys()):
840-
entry = self.linear_trends_effects[l_h]
841-
lines.append(
842-
_format_inference_row(
843-
f"Level_{l_h}",
844-
entry["effect"],
845-
entry["se"],
846-
entry["t_stat"],
847-
entry["p_value"],
848-
)
849-
)
850-
lines.extend([thin, ""])
851-
852-
# --- Heterogeneity test ---
853-
if self.heterogeneity_effects is not None:
854-
lines.extend(
855-
[
856-
thin,
857-
"Heterogeneity Test (Section 1.5, partial)".center(width),
858-
thin,
859-
f"{'Horizon':<15} {'beta^het':>12} {'Std. Err.':>12} "
860-
f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
861-
thin,
862-
]
863-
)
864-
for l_h in sorted(self.heterogeneity_effects.keys()):
865-
entry = self.heterogeneity_effects[l_h]
866-
lines.append(
867-
_format_inference_row(
868-
f"l={l_h}",
869-
entry["beta"],
870-
entry["se"],
871-
entry["t_stat"],
872-
entry["p_value"],
873-
)
874-
)
875-
lines.extend(
876-
[
877-
thin,
878-
"Note: Post-treatment regressions only (no placebo/joint test).",
879-
"",
880-
]
881-
)
882-
883-
# --- Design-2 switch-in / switch-out ---
884-
if self.design2_effects is not None:
885-
d2 = self.design2_effects
886-
si = d2.get("switch_in", {})
887-
so = d2.get("switch_out", {})
888-
lines.extend(
889-
[
890-
thin,
891-
"Design-2: Switch-In / Switch-Out (Section 1.6)".center(width),
892-
thin,
893-
f"{'Join-then-leave groups:':<35} {d2.get('n_design2_groups', 0):>10}",
894-
f"{'Switch-in effect (mean):':<35} "
895-
f"{_fmt_float(si.get('mean_effect', float('nan'))):>10}"
896-
f" (N={si.get('n_groups', 0)})",
897-
f"{'Switch-out effect (mean):':<35} "
898-
f"{_fmt_float(so.get('mean_effect', float('nan'))):>10}"
899-
f" (N={so.get('n_groups', 0)})",
900-
thin,
901-
"",
902-
]
903-
)
904-
905-
# --- HonestDiD sensitivity ---
906-
if self.honest_did_results is not None:
907-
hd = self.honest_did_results
908-
method_label = hd.method.replace("_", " ").title()
909-
m_val = hd.M
910-
sig_label = "Yes" if hd.is_significant else "No"
911-
conf_pct = int((1 - hd.alpha) * 100)
912-
lines.extend(
913-
[
914-
thin,
915-
"HonestDiD Sensitivity (Rambachan-Roth 2023)".center(width),
916-
thin,
917-
f"{'Method:':<35} {method_label} (M={_fmt_float(m_val)})",
918-
f"{'Original estimate:':<35} {_fmt_float(hd.original_estimate):>10}",
919-
f"{'Identified set:':<35} "
920-
f"[{_fmt_float(hd.lb)}, {_fmt_float(hd.ub)}]",
921-
f"{'Robust ' + str(conf_pct) + '% CI:':<35} "
922-
f"[{_fmt_float(hd.ci_lb)}, {_fmt_float(hd.ci_ub)}]",
923-
f"{'Significant at ' + str(int(hd.alpha * 100)) + '%:':<35} "
924-
f"{sig_label:>10}",
925-
thin,
926-
"",
927-
]
928-
)
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)
929815

930816
# --- TWFE diagnostic ---
931817
if self.twfe_beta_fe is not None:
@@ -971,6 +857,148 @@ def print_summary(self, alpha: Optional[float] = None) -> None:
971857
"""Print the formatted summary to stdout."""
972858
print(self.summary(alpha))
973859

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"{'Original estimate:':<35} {_fmt_float(hd.original_estimate):>10}",
991+
f"{'Identified set:':<35} "
992+
f"[{_fmt_float(hd.lb)}, {_fmt_float(hd.ub)}]",
993+
f"{'Robust ' + str(conf_pct) + '% CI:':<35} "
994+
f"[{_fmt_float(hd.ci_lb)}, {_fmt_float(hd.ci_ub)}]",
995+
f"{'Significant at ' + str(int(hd.alpha * 100)) + '%:':<35} "
996+
f"{sig_label:>10}",
997+
thin,
998+
"",
999+
]
1000+
)
1001+
9741002
# ------------------------------------------------------------------
9751003
# to_dataframe
9761004
# ------------------------------------------------------------------

diff_diff/honest_did.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,20 @@ def _extract_event_study_params(
824824
)
825825

826826
if isinstance(results, ChaisemartinDHaultfoeuilleResults):
827+
import warnings
828+
829+
warnings.warn(
830+
"HonestDiD on dCDH results uses DID^{pl}_l placebo "
831+
"estimates as pre-period coefficients, not standard "
832+
"event-study pre-treatment coefficients. The Rambachan-"
833+
"Roth restrictions bound violations of the parallel "
834+
"trends assumption underlying the dCDH placebo "
835+
"estimand. This is a library extension; interpretation "
836+
"differs from canonical event-study HonestDiD.",
837+
UserWarning,
838+
stacklevel=3,
839+
)
840+
827841
if results.placebo_event_study is None:
828842
raise ValueError(
829843
"ChaisemartinDHaultfoeuilleResults must have placebo_event_study "

docs/methodology/REGISTRY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ Alternative: Multiplier bootstrap clustered at group via the `n_bootstrap` param
617617

618618
- **Note (Phase 3 heterogeneity testing - partial implementation):** Partial implementation of the heterogeneity test from Web Appendix Section 1.5 (Assumption 15, Lemma 7). Computes post-treatment saturated OLS regressions of `S_g * (Y_{g, F_g-1+l} - Y_{g, F_g-1})` on a time-invariant covariate `X_g` plus cohort indicator dummies. Standard OLS inference is valid (paper shows no DID error correction needed). **Deviation from R `predict_het`:** R's full `predict_het` option additionally computes placebo regressions and a joint null test, and disallows combination with `controls`. This implementation provides only post-treatment regressions. **Rejected combinations:** `controls` (matching R), `trends_linear` (heterogeneity test uses raw level changes, incompatible with second-differenced outcomes), and `trends_nonparam` (heterogeneity test does not thread state-set control-pool restrictions). Results stored in `results.heterogeneity_effects`. Activated via `heterogeneity="covariate_column"` in `fit()`.
619619

620-
- **Note (HonestDiD integration):** HonestDiD sensitivity analysis (Rambachan & Roth 2023) is available on the placebo + event study surface via `honest_did=True` in `fit()` or `compute_honest_did(results)` post-hoc. Uses diagonal variance (no full VCV available for dCDH). Relative magnitudes (DeltaRM) with Mbar=1.0 is the default when called from `fit()`. When `trends_linear=True`, bounds apply to the second-differenced estimand (parallel trends in first differences). Requires `L_max >= 1` for multi-horizon placebos. Gaps in the horizon grid from `trends_nonparam` support-trimming are handled by filtering to the largest consecutive block and warning.
620+
- **Note (HonestDiD integration):** HonestDiD sensitivity analysis (Rambachan & Roth 2023) is available on the placebo + event study surface via `honest_did=True` in `fit()` or `compute_honest_did(results)` post-hoc. **Library extension:** dCDH HonestDiD uses `DID^{pl}_l` placebo estimates as pre-period coefficients rather than standard event-study pre-treatment coefficients. The Rambachan-Roth restrictions bound violations of the parallel trends assumption underlying the dCDH placebo estimand; interpretation differs from canonical event-study HonestDiD. A `UserWarning` is emitted at runtime. Uses diagonal variance (no full VCV available for dCDH). Relative magnitudes (DeltaRM) with Mbar=1.0 is the default when called from `fit()`. When `trends_linear=True`, bounds apply to the second-differenced estimand (parallel trends in first differences). Requires `L_max >= 1` for multi-horizon placebos. Gaps in the horizon grid from `trends_nonparam` support-trimming are handled by filtering to the largest consecutive block and warning.
621621

622622
- **Note (Phase 3 Design-2 switch-in/switch-out):** Convenience wrapper for Web Appendix Section 1.6 (Assumption 16). Identifies groups with exactly 2 treatment changes (join then leave), reports switch-in and switch-out mean effects. This is a descriptive summary, not a full re-estimation with specialized control pools as described in the paper. **Always uses raw (unadjusted) outcomes** regardless of active `controls`, `trends_linear`, or `trends_nonparam` options - those adjustments apply to the main estimator surface but not to the Design-2 descriptive block. For full adjusted Design-2 estimation with proper control pools, the paper recommends "running the command on a restricted subsample and using `trends_nonparam` for the entry-timing grouping." Activated via `design2=True` in `fit()`, requires `drop_larger_lower=False` to retain 2-switch groups.
623623

tests/test_honest_did.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,3 +1398,34 @@ def test_dcdh_no_placebos_raises(self):
13981398
)
13991399
with pytest.raises(ValueError, match="placebo_event_study"):
14001400
compute_honest_did(r)
1401+
1402+
def test_dcdh_emits_placebo_warning(self):
1403+
"""compute_honest_did on dCDH emits warning about placebo-based pre-periods."""
1404+
import warnings
1405+
1406+
results = self._fit_dcdh()
1407+
with warnings.catch_warnings(record=True) as w:
1408+
warnings.simplefilter("always")
1409+
compute_honest_did(results)
1410+
placebo_warnings = [
1411+
x for x in w
1412+
if "placebo" in str(x.message).lower()
1413+
and "pre-period" in str(x.message).lower()
1414+
]
1415+
assert len(placebo_warnings) >= 1, (
1416+
"Expected a UserWarning about placebo-based pre-period inputs"
1417+
)
1418+
1419+
def test_dcdh_empty_consecutive_block_raises(self):
1420+
"""ValueError when all placebos have NaN SE (no valid pre-periods)."""
1421+
import warnings
1422+
1423+
# Fit real results, then corrupt placebo SEs to NaN
1424+
results = self._fit_dcdh()
1425+
for h in results.placebo_event_study:
1426+
results.placebo_event_study[h]["se"] = float("nan")
1427+
1428+
with warnings.catch_warnings():
1429+
warnings.simplefilter("ignore")
1430+
with pytest.raises(ValueError, match="No placebo horizons with finite SEs"):
1431+
compute_honest_did(results)

0 commit comments

Comments
 (0)