Skip to content

Commit 3d41bb0

Browse files
authored
feat(v4): item 8b of the 4.0 cut - unified to_dataframe(level='event_study') schema on every results class (M-093 done) (#883)
1 parent c32d20b commit 3d41bb0

45 files changed

Lines changed: 1275 additions & 533 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎TODO.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
8686

8787
| Issue | Location | Origin | Effort | Priority |
8888
|-------|----------|--------|--------|----------|
89-
| Re-execute the nine tutorials whose committed outputs still show the 3.x residual-df p-values/CIs after the `df_convention` default moved to `"cluster"` (t(G-1)): `01_basic_did`, `02_staggered_did`, `04_parallel_trends`, `05_honest_did`, `07_pretrends_power`, `09_real_world_examples`, `11_imputation_did`, `13_stacked_did`, `16_wooldridge_etwfe` (not `16_survey_did`: the survey df wins). Run under `DIFF_DIFF_BACKEND=rust` with the per-worktree kernel, reconcile any prose that restates a moved number, and `--nbmake` each; scheduled for the 4.0 release PR (`docs/v4-design.md` section 9 item 10) together with any item-8/9 movers (24 was refreshed in #879; 23 in 7b, re-validated in 7c - it prints no p-values or CIs). 7b's label changes also drift any committed analytical DiD/TWFE summary (", G=" on the variance line) and every SunAbraham summary (the new "Std. errors:" line) - covered by the same re-execution. | `docs/tutorials/*.ipynb` | #879 | Mid | Medium |
89+
| Relay `to_dataframe(level='group')` to `aggregate('group').to_dataframe()` on CallawaySantAnna/DMLDiD, EfficientDiD and TripleDifference (staggered) - the level still raises with the post-fit pointer while `level='event_study'` was rewired to the aggregate route at the 4.0 cut (item 8b, [M-093]); ImputationDiD/TwoStageDiD keep their native `group` frames and need the same decision | `diff_diff/staggered_results.py`, `diff_diff/efficient_did_results.py`, `diff_diff/triple_diff_results.py` | #883 | Quick | Low |
90+
| Re-execute the eight tutorials whose committed outputs still show the 3.x residual-df p-values/CIs after the `df_convention` default moved to `"cluster"` (t(G-1)): `01_basic_did`, `02_staggered_did`, `04_parallel_trends`, `05_honest_did`, `07_pretrends_power`, `09_real_world_examples`, `11_imputation_did`, `13_stacked_did` (not `16_survey_did`: the survey df wins; `16_wooldridge_etwfe` was re-executed and reconciled in the 8b PR). Run under `DIFF_DIFF_BACKEND=rust` with the per-worktree kernel, reconcile any prose that restates a moved number, and `--nbmake` each; scheduled for the 4.0 release PR (`docs/v4-design.md` section 9 item 10) together with any item-8/9 movers (24 was refreshed in #879; 23 in 7b, re-validated in 7c - it prints no p-values or CIs). 7b's label changes also drift any committed analytical DiD/TWFE summary (", G=" on the variance line) and every SunAbraham summary (the new "Std. errors:" line) - covered by the same re-execution. | `docs/tutorials/*.ipynb` | #879 | Mid | Medium |
9091
| TWFE event-study one-way HC2-BM absorbed-FE R parity against the `mpd_absorbed_fe_did` golden (`clubSandwich::vcovCR(cluster=1:n, type="CR2")`): since 4.0 (item 7b) `cluster=False` + hc2_bm reaches the one-way lane directly (no singleton-cluster column needed; `tests/test_v4_cluster_policy.py` pins finiteness and the static-surface LinearRegression parity only), so the open item is the R pin itself; the hc2 lane is pinned by `TestTWFEEventStudyAbsorbedFERParity` and the clustered hc2_bm per-period lane by `test_twfe_event_study_cluster_hc2_bm_per_period_df_matches_clubsandwich`; the MultiPeriodDiD lanes died at the 4.0 cut (PR 4c-ii) | `tests/test_estimators_vcov_type.py` | #v4-4c-i | Mid | Medium |
9192
| TWFE event-study power profile: `simulate_power`/`simulate_mde`/`simulate_sample_size` have no `_EstimatorProfile` for `TwoWayFixedEffects.fit(event_study=True)` since the MultiPeriodDiD registry entry went with the class (PR 4c-ii); `EventStudyResults` carries no scalar headline, so the profile needs a design (post-period average via the `tests/helpers/event_study_average.py` arithmetic, or a named period) plus its `_SURVEY_FIT_BUILDERS` twin | `diff_diff/power.py` | #v4-4c-ii | Mid | Low |
9293
| Clustered hc2_bm Bell-McCaffrey DOF guard is batch-relative (`linalg._cr2_bm_dof_inner`: a contrast is NaN'd when its scaled max\|B\| falls below 1e-10 of the batch maximum), so the event-study core keeps the otherwise-unused post-period-average contrast column as the anchor to hold the 3.x per-period NaN masks bit-identical (PR 4c-ii, user decision 2026-10-25). Reproduction: `x_nc = treated * 1[period >= 4] + 3e-6 * default_rng(seed).normal(size=n)` as a covariate on the oracle panel flips the mask between `[eye(k), avg]` and `eye(k)` on 20/20 seeds (cond(X) ~ 9e5; 1e-7 never flips, <= 3e-8 rank-drops) - too BLAS-sensitive for a committed oracle, hence the structural pin `test_hc2_bm_contrast_batch_keeps_post_average_anchor`. Decide whether the guard should use a batch-independent anchor (the treatment contrast, or the absolute floor only) and then drop the column | `diff_diff/estimators.py`, `diff_diff/linalg.py` | #v4-4c-ii | Mid | Medium |
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### Breaking Changes
2+
3+
- Every results class's `to_dataframe(level="event_study")` now emits the unified `EVENT_STUDY_SCHEMA` ([M-093], item 8b of the 4.0 cut): `event_time, att, se, t_stat, p_value, conf_int_lower, conf_int_upper, cband_lower, cband_upper, n, df, is_reference, estimand` - the frame `results.aggregate("event_study").to_dataframe()` returns (`CallawaySantAnna`/`DMLDiD`, `EfficientDiD`, `ImputationDiD`, `TwoStageDiD`, `StackedDiD`, `ContinuousDiD`, `TripleDifference` staggered, `ChaisemartinDHaultfoeuille`, HAD event-study results, `DurationDiD`, `LWDiD`) or the builder frame (`SunAbraham`, `LPDiD`, `WooldridgeDiD`, `SpilloverDiD`). The 3.x bespoke frames are gone: `relative_period` -> `event_time`; `effect` / `att_glob` / LPDiD's `coefficient` -> `att`; `conf_int_lo`/`hi`, `conf_low`/`high`, HAD's `conf_int_low`/`high` -> `conf_int_lower`/`conf_int_upper`; `n_obs` -> `n` (NaN on the `is_reference` row); dCDH's `horizon` -> `event_time` on the paper's `l1_first_switch` convention, its per-horizon `estimand` labels (`DID^pl_l` / `ref` / `DID_l`) are not carried (`estimand` is `"att"`) and its reference row is marked `is_reference` instead of `n_obs 0`. `WooldridgeDiD` and `LWDiD` append their fit-configuration columns after the schema (`cohort_trends`, `aggregation_weights`; `rolling`, `estimation_method`, `vcov_type`), and `WooldridgeDiD.to_dataframe(level="event_study")` (its default level) now RAISES before `aggregate("event_study", weights=)` instead of returning an empty frame. `CallawaySantAnna`, `EfficientDiD`, `TripleDifference` and analytical `ImputationDiD` / `TwoStageDiD` / `ContinuousDiD` no longer raise "not stored on 4.0 fits" - the level recomputes the surface from the retained kit (bootstrapped `CallawaySantAnna` / `EfficientDiD` / `TripleDifference` fits replay the fit-time bootstrap on every call; an `ImputationDiD` replicate-weight survey fit with `pretrends=True` raises `NotImplementedError` as `aggregate()` does); a kit-less pre-3.9 pickle, or a bootstrapped pickle predating the replay state, that still carries a stored dict now raises where it returned a frame. `level="group"` on those three classes still raises with the post-fit pointer. New levels: `SpilloverDiDResults.to_dataframe(level="simple" | "event_study")` (default `"simple"`, the former one-row frame; aggregate-mode fits raise on `"event_study"`), `HeterogeneousAdoptionDiDEventStudyResults.to_dataframe(level="event_study")`, `DurationDiDResults.to_dataframe(level="event_study")`. An `LPDiD` fit whose stored event-study frame is empty raises instead of returning the empty frame.
4+
5+
### Internal
6+
7+
- The routing rule is the M-153 LWDiD precedent (`aggregate(level).to_dataframe()` on the `AggregationMixin` classes, `build_event_study_surface(self).to_dataframe()` elsewhere); `results_base._ABSENT_SURFACE_HINTS` gains a `SunAbrahamResults` entry. Ledger: M-093 flips to `done` with `test_ref` `tests/test_v4_event_study_schema.py` (the cross-producer schema-prefix / route-equality / error-contract pins; `tests/test_v4_sentinel_retirement.py` keeps the 8a pins) and a tenth amendment note; `docs/v4-design.md` section 5 now states the PREFIX rule and item 8 is complete. REGISTRY (CS, EfficientDiD, Imputation/TwoStage/Stacked relay and reference-row Notes, DDD, HAD, plus new to_dataframe-schema Notes for dCDH, SunAbraham, WooldridgeDiD, LPDiD, DurationDiD and SpilloverDiD), the migration guide, `llms-full.txt`, the practitioner guide, `docs/api/wooldridge_etwfe.rst` and `docs/api/spillover.rst` describe the frame; notebooks 16 (re-executed, outputs refreshed and prose reconciled - it leaves the TODO.md tutorial re-execution row), 19 (reads `event_time`/`att`/`is_reference`) and 14 (export prose) updated. TODO.md gains the `level='group'` relay row.

‎diff_diff/chaisemartin_dhaultfoeuille_results.py‎

Lines changed: 14 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,9 +1693,19 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
16931693
- ``"per_period"``: one row per time period with
16941694
``did_plus_t``, ``did_minus_t``, switching cell counts, and
16951695
the A11-zeroed flags.
1696-
- ``"event_study"``: one row per horizon (positive and
1697-
negative/placebo), including a reference period at
1698-
horizon 0. Available when ``L_max >= 1``.
1696+
- ``"event_study"``: the unified event-study table -
1697+
``self.aggregate("event_study").to_dataframe()`` - with the
1698+
pinned ``EVENT_STUDY_SCHEMA`` columns (``event_time, att, se,
1699+
t_stat, p_value, conf_int_lower, conf_int_upper, cband_lower,
1700+
cband_upper, n, df, is_reference, estimand``), identical across
1701+
estimators (4.0, ledger row M-093). One row per horizon on the
1702+
paper's ``l1_first_switch`` convention: placebos at negative
1703+
``event_time``, the reference marked ``is_reference`` at 0,
1704+
``DID_l`` at ``l >= 1``. The per-horizon estimand labels
1705+
(``DID^pl_l`` / ``ref`` / ``DID_l``) are not carried - the
1706+
``estimand`` column is the ATT contract's ``"att"``;
1707+
``summary()`` keeps the paper's labels. Available when
1708+
``L_max >= 1``.
16991709
- ``"normalized"``: one row per horizon for the normalized
17001710
effects ``DID^n_l``. Available when ``L_max >= 1``.
17011711
- ``"twfe_weights"``: per-(group, time) TWFE decomposition
@@ -1847,64 +1857,7 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
18471857
return pd.DataFrame(rows)
18481858

18491859
elif level == "event_study":
1850-
rows = []
1851-
# Placebo horizons (negative keys)
1852-
if self.placebo_event_study:
1853-
for h in sorted(self.placebo_event_study.keys()):
1854-
entry = self.placebo_event_study[h]
1855-
cband = entry.get("cband_conf_int", (np.nan, np.nan))
1856-
rows.append(
1857-
{
1858-
"horizon": h,
1859-
"estimand": f"DID^pl_{abs(h)}",
1860-
"effect": entry["effect"],
1861-
"se": entry["se"],
1862-
"t_stat": entry["t_stat"],
1863-
"p_value": entry["p_value"],
1864-
"conf_int_lower": entry["conf_int"][0],
1865-
"conf_int_upper": entry["conf_int"][1],
1866-
"n_obs": entry["n_obs"],
1867-
"cband_lower": cband[0] if cband else np.nan,
1868-
"cband_upper": cband[1] if cband else np.nan,
1869-
}
1870-
)
1871-
# Reference period (horizon 0)
1872-
rows.append(
1873-
{
1874-
"horizon": 0,
1875-
"estimand": "ref",
1876-
"effect": 0.0,
1877-
"se": np.nan,
1878-
"t_stat": np.nan,
1879-
"p_value": np.nan,
1880-
"conf_int_lower": np.nan,
1881-
"conf_int_upper": np.nan,
1882-
"n_obs": 0,
1883-
"cband_lower": np.nan,
1884-
"cband_upper": np.nan,
1885-
}
1886-
)
1887-
# Positive horizons
1888-
if self.event_study_effects:
1889-
for h in sorted(self.event_study_effects.keys()):
1890-
entry = self.event_study_effects[h]
1891-
cband = entry.get("cband_conf_int", (np.nan, np.nan))
1892-
rows.append(
1893-
{
1894-
"horizon": h,
1895-
"estimand": self._horizon_label(h),
1896-
"effect": entry["effect"],
1897-
"se": entry["se"],
1898-
"t_stat": entry["t_stat"],
1899-
"p_value": entry["p_value"],
1900-
"conf_int_lower": entry["conf_int"][0],
1901-
"conf_int_upper": entry["conf_int"][1],
1902-
"n_obs": entry["n_obs"],
1903-
"cband_lower": cband[0] if cband else np.nan,
1904-
"cband_upper": cband[1] if cband else np.nan,
1905-
}
1906-
)
1907-
return pd.DataFrame(rows)
1860+
return self.aggregate("event_study").to_dataframe()
19081861

19091862
elif level == "normalized":
19101863
if not self.normalized_effects:

‎diff_diff/continuous_did_results.py‎

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,15 @@ def to_dataframe(self, level: str = "dose_response") -> pd.DataFrame:
494494
----------
495495
level : str, default="dose_response"
496496
``"dose_response"``, ``"group_time"``, or ``"event_study"``.
497+
``level="event_study"`` returns the unified event-study table -
498+
``self.aggregate("event_study").to_dataframe()`` - with the pinned
499+
``EVENT_STUDY_SCHEMA`` columns (``event_time, att, se, t_stat, p_value,
500+
conf_int_lower, conf_int_upper, cband_lower, cband_upper, n, df,
501+
is_reference, estimand``), identical across estimators (4.0, ledger
502+
row M-093).
503+
Analytical fits recompute the binarized event study from the
504+
retained kit; bootstrapped fits relay the fit-time percentile
505+
surface.
497506
"""
498507
if level == "dose_response":
499508
att_df = self.dose_response_att.to_dataframe()
@@ -526,29 +535,7 @@ def to_dataframe(self, level: str = "dose_response") -> pd.DataFrame:
526535
)
527536
return pd.DataFrame(rows)
528537
elif level == "event_study":
529-
if self.event_study_effects is None:
530-
raise ValueError(
531-
"Event study effects not stored (only bootstrapped fits "
532-
"materialize the surface at fit). Call "
533-
"results.aggregate('event_study') for the unified "
534-
"post-fit container - analytic fits recompute it from "
535-
"the retained kit; a result unpickled from an older "
536-
"release must be re-fit with diff-diff >= 3.9."
537-
)
538-
rows = []
539-
for rel_t, data in sorted(self.event_study_effects.items()):
540-
rows.append(
541-
{
542-
"relative_period": rel_t,
543-
"att_glob": data["effect"],
544-
"se": data["se"],
545-
"t_stat": data["t_stat"],
546-
"p_value": data["p_value"],
547-
"conf_int_lower": data["conf_int"][0],
548-
"conf_int_upper": data["conf_int"][1],
549-
}
550-
)
551-
return pd.DataFrame(rows)
538+
return self.aggregate("event_study").to_dataframe()
552539
else:
553540
raise ValueError(
554541
f"Unknown level: {level}. Use 'dose_response', 'group_time', or 'event_study'."

‎diff_diff/duration_did_results.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,11 +445,21 @@ def to_dataframe(self, level: str = "periods") -> pd.DataFrame:
445445
446446
Parameters
447447
----------
448-
level : {"periods", "att"}
448+
level : {"periods", "att", "event_study"}
449449
``"periods"``: one row per post-treatment date with the effect,
450450
pointwise inference, simultaneous band and validity status.
451451
``"att"``: the single headline row.
452+
``level="event_study"`` returns the unified event-study table -
453+
``self.aggregate("event_study").to_dataframe()`` - with the pinned
454+
``EVENT_STUDY_SCHEMA`` columns (``event_time, att, se, t_stat, p_value,
455+
conf_int_lower, conf_int_upper, cband_lower, cband_upper, n, df,
456+
is_reference, estimand``), identical across estimators (4.0, ledger
457+
row M-093).
458+
Relative event time (``-1`` is the reference row, ``0..`` the
459+
post-treatment dates in order).
452460
"""
461+
if level == "event_study":
462+
return self.aggregate("event_study").to_dataframe()
453463
if level == "att":
454464
return pd.DataFrame(
455465
[
@@ -482,7 +492,7 @@ def to_dataframe(self, level: str = "periods") -> pd.DataFrame:
482492
"status": list(self.period_status),
483493
}
484494
)
485-
raise ValueError(f"level must be 'periods' or 'att', got {level!r}")
495+
raise ValueError(f"level must be 'periods', 'att' or 'event_study', got {level!r}")
486496

487497
# ------------------------------------------------------------------ #
488498
# Summary #

‎diff_diff/efficient_did_results.py‎

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,15 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame:
810810
----------
811811
level : str
812812
``"group_time"``, ``"event_study"``, or ``"group"``.
813+
``level="event_study"`` returns the unified event-study table -
814+
``self.aggregate("event_study").to_dataframe()`` - with the pinned
815+
``EVENT_STUDY_SCHEMA`` columns (``event_time, att, se, t_stat, p_value,
816+
conf_int_lower, conf_int_upper, cband_lower, cband_upper, n, df,
817+
is_reference, estimand``), identical across estimators (4.0, ledger
818+
row M-093).
819+
Bootstrapped fits replay the fit-time multiplier bootstrap on
820+
every call; ``"group"`` raises on 4.0 fits with a pointer to
821+
``aggregate('group')``.
813822
"""
814823
if level == "group_time":
815824
rows = []
@@ -829,29 +838,7 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame:
829838
return pd.DataFrame(rows)
830839

831840
elif level == "event_study":
832-
if self.event_study_effects is None:
833-
raise ValueError(
834-
"Event study effects are not stored on 4.0 fits; use "
835-
"results.aggregate('event_study').to_dataframe() "
836-
"(bootstrapped fits replay the fit-time multiplier "
837-
"bootstrap - no refit needed); a result unpickled from "
838-
"a pre-3.9 release carries no aggregation kit and must "
839-
"be refit."
840-
)
841-
rows = []
842-
for rel_t, data in sorted(self.event_study_effects.items()):
843-
rows.append(
844-
{
845-
"relative_period": rel_t,
846-
"effect": data["effect"],
847-
"se": data["se"],
848-
"t_stat": data["t_stat"],
849-
"p_value": data["p_value"],
850-
"conf_int_lower": data["conf_int"][0],
851-
"conf_int_upper": data["conf_int"][1],
852-
}
853-
)
854-
return pd.DataFrame(rows)
841+
return self.aggregate("event_study").to_dataframe()
855842

856843
elif level == "group":
857844
if self.group_effects is None:

0 commit comments

Comments
 (0)