Skip to content

Commit 6f1c99f

Browse files
igerberclaude
andcommitted
Fix CI review Round 2: NaN overall for trends, reject controls+heterogeneity
P1: trends_linear + L_max>=2 overall_* is now NaN (R does not compute an aggregate in trends_lin mode). Cumulated effects available via results.linear_trends_effects[l]. P1: heterogeneity + controls now raises ValueError (matching R's predict_het which disallows controls). REGISTRY documents heterogeneity as partial implementation (post-treatment only, no placebo regressions or joint null test). P3: Added fit() docstrings for heterogeneity and design2 parameters. P3: Updated to_dataframe() error text with new level names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2c6cabf commit 6f1c99f

4 files changed

Lines changed: 56 additions & 22 deletions

File tree

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,17 @@ def fit(
539539
Requires ``L_max >= 1`` and time-invariant values per group.
540540
honest_did : bool, default=False
541541
**Reserved for Phase 3** (HonestDiD integration on placebos).
542+
heterogeneity : str, optional
543+
Column name for a time-invariant covariate to test for
544+
heterogeneous effects (Web Appendix Section 1.5, Lemma 7).
545+
Partial implementation: post-treatment regressions only
546+
(no placebo regressions or joint null test). Cannot be
547+
combined with ``controls``. Requires ``L_max >= 1``.
548+
design2 : bool, default=False
549+
If ``True``, identify and report switch-in/switch-out
550+
(Design-2) groups. Convenience wrapper (descriptive summary,
551+
not full paper re-estimation). Requires
552+
``drop_larger_lower=False`` to retain 2-switch groups.
542553
survey_design : Any, optional
543554
**Not supported in any phase.** Survey design integration is
544555
handled as a separate effort after all three phases ship.
@@ -2105,20 +2116,17 @@ def fit(
21052116
linear_trends_effects = cumulated if cumulated else None
21062117

21072118
# When trends_linear=True and L_max>=2, suppress cost_benefit_delta
2108-
# (which is computed on second-differences) and set overall_* from
2109-
# the cumulated level effects instead. This prevents the results
2110-
# surface from labeling a second-difference aggregate as delta^{fd}
2111-
# (a level-effect estimand).
2119+
# and NaN out the overall_* surface. R's did_multiplegt_dyn with
2120+
# trends_lin=TRUE does not compute an aggregate "average total
2121+
# effect" - users should access cumulated level effects via
2122+
# results.linear_trends_effects[l] instead.
21122123
if _is_trends_linear and L_max is not None and L_max >= 2:
21132124
cost_benefit_result = None
2114-
if linear_trends_effects:
2115-
max_h = max(linear_trends_effects.keys())
2116-
lt = linear_trends_effects[max_h]
2117-
effective_overall_att = lt["effect"]
2118-
effective_overall_se = lt["se"]
2119-
effective_overall_t = lt["t_stat"]
2120-
effective_overall_p = lt["p_value"]
2121-
effective_overall_ci = lt["conf_int"]
2125+
effective_overall_att = float("nan")
2126+
effective_overall_se = float("nan")
2127+
effective_overall_t = float("nan")
2128+
effective_overall_p = float("nan")
2129+
effective_overall_ci = (float("nan"), float("nan"))
21222130

21232131
# ------------------------------------------------------------------
21242132
# Heterogeneity testing (Web Appendix Section 1.5, Lemma 7)
@@ -2130,6 +2138,14 @@ def fit(
21302138
raise ValueError(
21312139
f"heterogeneity column {het_col!r} not found in data."
21322140
)
2141+
# R's predict_het disallows controls; our partial implementation
2142+
# follows this restriction to avoid inconsistent behavior.
2143+
if controls is not None:
2144+
raise ValueError(
2145+
"heterogeneity cannot be combined with controls. "
2146+
"R's did_multiplegt_dyn disallows predict_het with "
2147+
"controls; remove one of the two options."
2148+
)
21332149
# Extract per-group covariate (must be time-invariant)
21342150
het_per_group = data.groupby(group)[het_col].nunique()
21352151
het_varying = het_per_group[het_per_group > 1]

diff_diff/chaisemartin_dhaultfoeuille_results.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1054,7 +1054,8 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
10541054
else:
10551055
raise ValueError(
10561056
f"Unknown level: {level!r}. Use 'overall', 'joiners_leavers', "
1057-
f"'per_period', 'event_study', 'normalized', or 'twfe_weights'."
1057+
f"'per_period', 'event_study', 'normalized', 'twfe_weights', "
1058+
f"'heterogeneity', or 'linear_trends'."
10581059
)
10591060

10601061

docs/methodology/REGISTRY.md

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

616616
- **Note (Phase 3 state-set trends):** Implements state-set-specific trends from Web Appendix Section 1.4 (Assumptions 13-14). Restricts the control pool for each switcher to groups in the same set (e.g., same state in county-level data). The restriction applies in BOTH `_compute_multi_horizon_dids()` (point estimates) and `_compute_per_group_if_multi_horizon()` (influence functions) to ensure IF consistency. Cohort structure stays as `(D_{g,1}, F_g, S_g)` triples (does not incorporate set membership). Set membership must be time-invariant per group. Activated via `trends_nonparam="state_column"` in `fit()`.
617617

618-
- **Note (Phase 3 heterogeneity testing):** Implements the heterogeneity test from Web Appendix Section 1.5 (Assumption 15, Lemma 7). Saturated OLS regression 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 - the paper shows no need to account for DID estimation error. Results stored in `results.heterogeneity_effects`. Activated via `heterogeneity="covariate_column"` in `fit()`.
618+
- **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. Combination with `controls` is rejected (matching R). Results stored in `results.heterogeneity_effects`. Activated via `heterogeneity="covariate_column"` in `fit()`.
619619

620620
- **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. The paper notes Design-2 can be implemented by "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.
621621

tests/test_chaisemartin_dhaultfoeuille.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2546,24 +2546,31 @@ def test_trends_with_covariates(self):
25462546
df, "outcome", "group", "period", "treatment",
25472547
controls=["X1"], L_max=2, trends_linear=True,
25482548
)
2549-
assert np.isfinite(r.overall_att)
2549+
# overall_att is NaN for trends + L_max>=2 (no aggregate)
2550+
assert np.isnan(r.overall_att)
25502551
assert r.covariate_residuals is not None
25512552
assert r.linear_trends_effects is not None
25522553

25532554
def test_trends_linear_lmax2_overall_surface(self):
2554-
"""Overall surface under trends_linear + L_max>=2 uses cumulated level effects."""
2555+
"""Under trends_linear + L_max>=2, overall_* is NaN (no aggregate).
2556+
2557+
R's did_multiplegt_dyn with trends_lin=TRUE does not compute an
2558+
aggregate average total effect. Cumulated level effects are
2559+
available via results.linear_trends_effects[l].
2560+
"""
25552561
df = self._make_panel_with_trends()
25562562
r = ChaisemartinDHaultfoeuille(seed=1).fit(
25572563
df, "outcome", "group", "period", "treatment",
25582564
L_max=3, trends_linear=True,
25592565
)
2560-
# overall_att should equal the cumulated level effect at max horizon
2561-
assert r.linear_trends_effects is not None
2562-
max_h = max(r.linear_trends_effects.keys())
2563-
cum_effect = r.linear_trends_effects[max_h]["effect"]
2564-
assert r.overall_att == pytest.approx(cum_effect, abs=1e-10)
2565-
# cost_benefit_delta should be suppressed (not computed on second-diffs)
2566+
# overall_* should be NaN (not computed in trends mode)
2567+
assert np.isnan(r.overall_att)
2568+
assert np.isnan(r.overall_se)
2569+
# cost_benefit_delta suppressed
25662570
assert r.cost_benefit_delta is None
2571+
# Cumulated effects still available
2572+
assert r.linear_trends_effects is not None
2573+
assert len(r.linear_trends_effects) >= 1
25672574

25682575
def test_cumulated_se_nan_propagation(self):
25692576
"""Cumulated SE is NaN when a component horizon has NaN SE."""
@@ -2755,6 +2762,16 @@ def test_heterogeneity_missing_column(self):
27552762
L_max=1, heterogeneity="nonexistent",
27562763
)
27572764

2765+
def test_heterogeneity_rejects_controls(self):
2766+
"""heterogeneity + controls raises ValueError (matching R predict_het)."""
2767+
df = self._make_panel_with_het()
2768+
df["X1"] = np.random.RandomState(42).normal(0, 1, len(df))
2769+
with pytest.raises(ValueError, match="cannot be combined with controls"):
2770+
ChaisemartinDHaultfoeuille(seed=1).fit(
2771+
df, "outcome", "group", "period", "treatment",
2772+
L_max=1, heterogeneity="het_x", controls=["X1"],
2773+
)
2774+
27582775

27592776
class TestDesign2:
27602777
"""Design-2 switch-in/switch-out separation (ROADMAP item 3e)."""

0 commit comments

Comments
 (0)