Skip to content

Commit f81b7b6

Browse files
committed
wooldridge: CI R6 P1 fixes — plot cohort_share suppresses CI bands + Results metadata for cohort_trends/aggregation weights
1 parent 200975d commit f81b7b6

3 files changed

Lines changed: 123 additions & 2 deletions

File tree

diff_diff/wooldridge.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,7 @@ def _fit_ols(
13751375
_bm_artifacts=bm_artifacts,
13761376
_df_one_way=df_one_way,
13771377
cohort_trend_coefs=cohort_trend_coefs,
1378+
cohort_trends=self.cohort_trends,
13781379
)
13791380

13801381
# 9. Optional multiplier bootstrap (overrides analytic SE for overall ATT).

diff_diff/wooldridge_results.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ class WooldridgeDiDResults:
100100
# or wait for the deferred bootstrap-cohort-share follow-up.
101101
_bootstrap_used: bool = field(default=False, repr=False)
102102

103+
# Model-surface metadata for self-describing reporting.
104+
# ``cohort_trends`` records whether the fit was produced under the
105+
# Section 8 / Eq. 8.1 heterogeneous-cohort-trends design (paper
106+
# W2025 ``dg_i · t`` interactions on the OLS path). False on the
107+
# default ``cohort_trends=False`` fit and on logit/Poisson paths
108+
# (which reject ``cohort_trends=True`` at the constructor).
109+
# ``active_aggregation_weights`` records the most recent
110+
# ``aggregate(weights=...)`` scheme that wrote to the ``overall_*``
111+
# / ``event_study_effects`` / ``group_effects`` / ``calendar_effects``
112+
# fields ("cell" by default; flips to "cohort_share" after an opt-in
113+
# cohort-share aggregation). Surfaced in ``summary()`` so downstream
114+
# consumers can tell which estimand the printed rows represent
115+
# without inspecting ``cohort_trend_coefs`` (which is legitimately
116+
# empty on all-treated panels per the last-cohort drop rule).
117+
cohort_trends: bool = field(default=False, repr=False)
118+
active_aggregation_weights: str = field(default="cell", repr=False)
119+
103120
# ------------------------------------------------------------------ #
104121
# Internal — used by aggregate() for delta-method SEs #
105122
# ------------------------------------------------------------------ #
@@ -195,6 +212,14 @@ def aggregate(self, type: str, weights: str = "cell") -> "WooldridgeDiDResults":
195212
f"jwdid_estat-style cell-count weighting."
196213
)
197214

215+
# Record the active aggregation weighting scheme on the Results
216+
# object so downstream reporting (``summary()`` / ``to_dataframe()``
217+
# / ``__repr__``) can label the persisted ``overall_*`` /
218+
# ``event_study_effects`` / ``group_effects`` / ``calendar_effects``
219+
# fields with the right estimand. Stamped AFTER the raise-paths
220+
# above so invalid combinations don't pollute the metadata.
221+
self.active_aggregation_weights = weights
222+
198223
gt = self.group_time_effects
199224
cell_weights = self._gt_weights
200225
n_g_per_cohort = self._n_g_per_cohort
@@ -556,6 +581,8 @@ def summary(self, aggregation: str = "simple") -> str:
556581
f"Observations: {self.n_obs}",
557582
f"Treated units: {self.n_treated_units}",
558583
f"Control units: {self.n_control_units}",
584+
f"Cohort trends: {self.cohort_trends}",
585+
f"Aggregation w: {self.active_aggregation_weights}",
559586
"-" * 70,
560587
]
561588

@@ -714,7 +741,10 @@ def plot_event_study(self, weights: str = "cell", **kwargs) -> None:
714741
paper W2025 Eq. 7.6 cohort-share-by-exposure weights
715742
(post-treatment ``k >= 0`` only); inference fields are
716743
fail-closed to NaN per the Section 7.5 conditional-on-shares
717-
contract documented in REGISTRY.
744+
contract documented in REGISTRY, and the plot **suppresses
745+
error bars / CI bands** to honor the fail-closed contract
746+
(the conditional-on-shares SE would build a misleading
747+
normal-theory CI in the plotter).
718748
**kwargs
719749
Forwarded to ``diff_diff.visualization.plot_event_study``.
720750
@@ -738,7 +768,17 @@ def plot_event_study(self, weights: str = "cell", **kwargs) -> None:
738768
from diff_diff.visualization import plot_event_study # type: ignore
739769

740770
effects = {k: v["att"] for k, v in (self.event_study_effects or {}).items()}
741-
se = {k: v["se"] for k, v in (self.event_study_effects or {}).items()}
771+
if weights == "cohort_share":
772+
# Honor the fail-closed inference contract per paper Section
773+
# 7.5: the conditional-on-shares SE understates unconditional
774+
# uncertainty, so passing the finite SE into the plotter
775+
# would let it render a normal-theory CI that contradicts
776+
# the NaN inference fields the aggregate() helper produces.
777+
# Pass NaN SEs so the plotter suppresses error bars / CI
778+
# bands. Locked by ``test_plot_event_study_cohort_share_suppresses_error_bars``.
779+
se = {k: float("nan") for k in (self.event_study_effects or {})}
780+
else:
781+
se = {k: v["se"] for k, v in (self.event_study_effects or {}).items()}
742782
plot_event_study(effects=effects, se=se, alpha=self.alpha, **kwargs)
743783

744784
# --- Inference-field aliases (balance/external-adapter compatibility) ---

tests/test_methodology_wooldridge.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,6 +1546,86 @@ def test_cohort_trends_true_plus_aggregate_calendar(self) -> None:
15461546
finite_count += 1
15471547
assert finite_count >= 1
15481548

1549+
def test_plot_event_study_cohort_share_suppresses_error_bars(self) -> None:
1550+
"""CI R6 P1 fix: ``plot_event_study(weights="cohort_share")`` passes NaN SEs.
1551+
1552+
Honors the Section 7.5 fail-closed inference contract: the
1553+
conditional-on-shares SE that ``aggregate()`` returns is NOT a
1554+
valid input for a normal-theory CI band, so the plot helper
1555+
receives NaN SEs and therefore suppresses error bars. Locked
1556+
by inspecting the ``se`` kwarg the helper was called with.
1557+
"""
1558+
from unittest.mock import patch
1559+
1560+
rng = np.random.default_rng(_BASE_SEED_SECTION8 + 19)
1561+
panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=80, sigma=0.05)
1562+
res = WooldridgeDiD(method="ols").fit(
1563+
panel, outcome="y", unit="unit", time="time", cohort="cohort"
1564+
)
1565+
# cohort_share path: plot helper must receive NaN SEs
1566+
with warnings.catch_warnings():
1567+
warnings.filterwarnings("ignore", category=UserWarning)
1568+
with patch("diff_diff.visualization.plot_event_study") as mock_plot:
1569+
res.plot_event_study(weights="cohort_share")
1570+
assert mock_plot.call_count == 1
1571+
se_arg = mock_plot.call_args.kwargs["se"]
1572+
assert se_arg, "plot_event_study should be called with a non-empty se dict"
1573+
assert all(np.isnan(v) for v in se_arg.values()), (
1574+
f"weights='cohort_share' must pass NaN SEs to the plot helper "
1575+
f"to suppress error bars; got {se_arg}"
1576+
)
1577+
# cell path: plot helper must receive FINITE SEs (control)
1578+
with patch("diff_diff.visualization.plot_event_study") as mock_plot:
1579+
res.plot_event_study(weights="cell")
1580+
assert mock_plot.call_count == 1
1581+
se_arg_cell = mock_plot.call_args.kwargs["se"]
1582+
assert se_arg_cell, "cell path should also pass a non-empty se dict"
1583+
assert any(np.isfinite(v) and v > 0 for v in se_arg_cell.values()), (
1584+
f"weights='cell' must pass finite SEs to the plot helper for "
1585+
f"normal-theory CI bands; got {se_arg_cell}"
1586+
)
1587+
1588+
def test_results_metadata_records_cohort_trends_and_active_aggregation_weights(
1589+
self,
1590+
) -> None:
1591+
"""CI R6 P1 fix: Results object surfaces cohort_trends + active_aggregation_weights.
1592+
1593+
Downstream consumers need to know which model produced
1594+
``overall_*`` / ``event_study_effects`` / ``group_effects`` /
1595+
``calendar_effects`` (cohort_trends on/off) and which weighting
1596+
scheme is currently cached (``"cell"`` vs ``"cohort_share"``).
1597+
``cohort_trend_coefs`` is NOT a reliable proxy for cohort_trends
1598+
because the all-treated drop rule legitimately empties the dict
1599+
on G=1-after-drop panels.
1600+
"""
1601+
rng = np.random.default_rng(_BASE_SEED_SECTION8 + 20)
1602+
panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05)
1603+
# Default fit: cohort_trends=False, default aggregation weights "cell"
1604+
res_default = WooldridgeDiD(method="ols").fit(
1605+
panel, outcome="y", unit="unit", time="time", cohort="cohort"
1606+
)
1607+
assert res_default.cohort_trends is False
1608+
assert res_default.active_aggregation_weights == "cell"
1609+
# cohort_trends=True fit: metadata reflects it
1610+
res_trends = WooldridgeDiD(method="ols", cohort_trends=True).fit(
1611+
panel, outcome="y", unit="unit", time="time", cohort="cohort"
1612+
)
1613+
assert res_trends.cohort_trends is True
1614+
assert res_trends.active_aggregation_weights == "cell"
1615+
# Opt-in cohort_share aggregation flips active_aggregation_weights
1616+
with warnings.catch_warnings():
1617+
warnings.filterwarnings("ignore", category=UserWarning)
1618+
res_trends.aggregate("simple", weights="cohort_share")
1619+
assert res_trends.active_aggregation_weights == "cohort_share"
1620+
# Surface in summary()
1621+
summary_text = res_trends.summary("simple")
1622+
assert (
1623+
"Cohort trends: True" in summary_text
1624+
), f"summary() should surface cohort_trends; got:\n{summary_text}"
1625+
assert "Aggregation w: cohort_share" in summary_text, (
1626+
f"summary() should surface active_aggregation_weights; got:\n" f"{summary_text}"
1627+
)
1628+
15491629
def test_plot_event_study_propagates_weights_kwarg(self) -> None:
15501630
"""CI R1 P1 fix: ``plot_event_study(weights=...)`` propagates through aggregate().
15511631

0 commit comments

Comments
 (0)