From 8f26a921fc6ecb0eda58deea996eef78e8abd965 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 25 Oct 2026 19:16:25 -0400 Subject: [PATCH 1/2] feat(v4): PR 4c-ii-a of the 4.0 cut - remove MultiPeriodDiD, MultiPeriodDiDResults, PeriodEffect and EventStudy Cut-sequence item 4c-ii (docs/v4-design.md section 9), split into 4c-ii-a (this commit: everything a CI gate or the REGISTRY deviation rule needs in the same diff as the source) and 4c-ii-b (methodology/benchmark prose, tracking-file rewordings and the oracle fixture's mpd_twin prune) because the single diff exceeded the local reviewer's input cap. Source: the class, its container and per-period record, the EventStudy alias, results_base._from_mpd, estimators._mpd_results_from_payload, the core's post-average (avg_*) block and the payload fields only the wrapper read are gone; the shared event-study core is TWFE-only (post_periods required by the core, include_treatment_main/cluster_override keyword-required). The hc2_bm contrast batch KEEPS the former post-average column as the batch-relative Bell-McCaffrey DOF guard's anchor (dropping it flips near-collinear per-period DOFs from NaN to finite; REGISTRY Note + TODO row). M-016: period_effects is a FutureWarning property view on calendar EventStudyResults (private frozen record, is_significant at the surface alpha, duplicate labels rejected, AttributeError on relative-time surfaces); build_event_study_surface opens with an isinstance passthrough. M-083: static fit(time=) raises naming post=. M-007 warning retired. Consumer MPD branches deleted (honest_did, pretrends, visualization, diagnostic_report, business_report, _reporting_helpers, practitioner, power - the power generic fallback reads the flat att/se/p_value/conf_int names). pyproject filterwarnings entry removed. Oracle: two clustered hc2_bm lanes captured at d45d5316 BEFORE the pruning (the CR2 bypass had no committed lane; rust agreed exactly); every TWFE lane of tests/fixtures/v4_twfe_event_study_oracles.json passes unchanged. Ledger: M-010/M-011/M-012 -> removed, M-060/M-007/M-083 -> done, M-016 -> shimmed on EventStudyResults, M-082 shimmed with warning: null, M-050 note corrected. Spec item 4c-ii SHIPPED note with the recorded deviations. Migration guide: unit= caveat, period_effects view, appendix row. REGISTRY: archive note, TWFE event-study Notes (alpha, anchor column), auto-route verification narrowed to the live pins. API rst / autosummary stubs / homepage table / README catalog / llms guides / user docs / two notebook markdown cells / doc-deps drop the removed names. Tests: MPD-only tests deleted or re-fixtured onto cs_fit / the TWFE surface; the clustered per-period CR2 DOF R pin and the event-study covariate-collision guard are migrated to TWFE; tests/test_v4_merge_mpd.py carries the removal, M-016, M-007 and anchor pins; variance-conventions twfe_es rows re-pinned (tail_df 5) and the table regenerated. --- .claude/commands/docs-check.md | 1 - CLAUDE.md | 3 +- README.md | 7 +- TODO.md | 4 +- changelog.d/20261025-v4-pr4c-ii-remove-mpd.md | 56 + diff_diff/__init__.py | 8 - diff_diff/_deprecation.py | 5 +- diff_diff/_reporting_helpers.py | 14 - diff_diff/business_report.py | 17 +- diff_diff/conley.py | 4 +- diff_diff/diagnostic_report.py | 191 +-- diff_diff/estimators.py | 665 ++------ diff_diff/guides/llms-autonomous.txt | 10 +- diff_diff/guides/llms-full.txt | 156 +- diff_diff/guides/llms-practitioner.txt | 8 +- diff_diff/guides/llms.txt | 5 +- diff_diff/honest_did.py | 914 +++++----- diff_diff/linalg.py | 24 +- diff_diff/power.py | 74 +- diff_diff/practitioner.py | 20 +- diff_diff/prep.py | 2 +- diff_diff/prep_dgp.py | 2 +- diff_diff/pretrends.py | 134 +- diff_diff/results.py | 471 +----- diff_diff/results_base.py | 246 +-- diff_diff/stacked_did.py | 4 +- diff_diff/sun_abraham.py | 2 +- diff_diff/twfe.py | 88 +- diff_diff/utils.py | 14 +- diff_diff/visualization/_event_study.py | 81 +- diff_diff/wooldridge.py | 2 +- .../diff_diff.EventStudyResults.rst | 1 + .../_autosummary/diff_diff.MultiPeriodDiD.rst | 24 - .../diff_diff.MultiPeriodDiDResults.rst | 67 - .../_autosummary/diff_diff.PeriodEffect.rst | 31 - docs/api/estimators.rst | 31 +- docs/api/index.rst | 3 - docs/api/results.rst | 41 +- docs/choosing_estimator.rst | 22 +- docs/doc-deps.yaml | 6 +- docs/index.rst | 4 +- docs/methodology/REGISTRY.md | 84 +- docs/methodology/variance-conventions.md | 9 +- docs/migration-4.0.md | 41 +- docs/practitioner_decision_tree.rst | 1 - docs/quickstart.rst | 6 +- docs/tutorials/06_power_analysis.ipynb | 6 +- docs/tutorials/16_survey_did.ipynb | 2 +- docs/v4-deprecations.yaml | 43 +- docs/v4-design.md | 32 +- pyproject.toml | 12 - tests/_capture_v4_merge_mpd_oracles.py | 121 +- tests/fixtures/README.md | 14 +- .../fixtures/v4_twfe_event_study_oracles.json | 358 +++- tests/helpers/event_study_average.py | 21 +- tests/helpers/event_study_surface_builders.py | 6 +- tests/helpers/twfe_es_oracle_lanes.py | 5 + tests/test_aliases.py | 2 - tests/test_balance_interop_contract.py | 13 +- tests/test_base_estimator.py | 17 +- tests/test_business_report.py | 121 +- tests/test_conley_vcov.py | 149 +- tests/test_diagnostic_report.py | 311 +--- tests/test_estimators.py | 1466 ++--------------- tests/test_estimators_vcov_type.py | 699 +------- tests/test_event_study_consumers.py | 73 +- tests/test_event_study_surface.py | 125 +- tests/test_honest_did.py | 368 +---- tests/test_linalg_hc2_bm.py | 4 +- tests/test_methodology_did.py | 109 +- tests/test_methodology_pretrends.py | 171 +- tests/test_naming_guard.py | 23 +- tests/test_power.py | 131 +- tests/test_practitioner.py | 21 - tests/test_pretrends.py | 130 -- tests/test_profile_panel.py | 1 - tests/test_result_aliases.py | 50 +- tests/test_results_serialization.py | 1 - tests/test_survey.py | 202 --- tests/test_target_parameter.py | 6 - tests/test_twfe_event_study_decoupling.py | 253 +-- tests/test_utils.py | 8 +- tests/test_v4_inference_policy.py | 51 +- tests/test_v4_matrix.py | 8 +- tests/test_v4_merge_mpd.py | 797 ++++----- tests/test_variance_conventions.py | 99 +- tests/test_visualization.py | 26 +- 87 files changed, 2577 insertions(+), 7081 deletions(-) create mode 100644 changelog.d/20261025-v4-pr4c-ii-remove-mpd.md delete mode 100644 docs/api/_autosummary/diff_diff.MultiPeriodDiD.rst delete mode 100644 docs/api/_autosummary/diff_diff.MultiPeriodDiDResults.rst delete mode 100644 docs/api/_autosummary/diff_diff.PeriodEffect.rst diff --git a/.claude/commands/docs-check.md b/.claude/commands/docs-check.md index fe8f38426..2162c1e67 100644 --- a/.claude/commands/docs-check.md +++ b/.claude/commands/docs-check.md @@ -38,7 +38,6 @@ The following estimators/features MUST have documentation: |-----------|---------------|----------------|---------|-------------------| | DifferenceInDifferences | "DifferenceInDifferences" | "DifferenceInDifferences" | estimators.rst | "Difference-in-Differences" | | TwoWayFixedEffects | "TwoWayFixedEffects" | "TwoWayFixedEffects" | estimators.rst | "Two-Way Fixed Effects" | -| MultiPeriodDiD | "MultiPeriodDiD" | "MultiPeriodDiD" | estimators.rst | "Multi-Period and Staggered" | | SyntheticDiD | "SyntheticDiD" | "SyntheticDiD" | estimators.rst | "Synthetic Difference-in-Differences" | | CallawaySantAnna | "CallawaySantAnna" | "CallawaySantAnna" | staggered.rst | "Multi-Period and Staggered" | | SunAbraham | "SunAbraham" | "SunAbraham" | staggered.rst | "Multi-Period and Staggered" | diff --git a/CLAUDE.md b/CLAUDE.md index df7fef7a2..0f5781932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,11 +86,10 @@ pytest tests/test_rust_backend.py -v BaseEstimator (diff_diff/_base.py — shared get_params/set_params mixin) ├── DifferenceInDifferences │ ├── TwoWayFixedEffects - │ ├── MultiPeriodDiD │ └── SyntheticDiD └── every other estimator class (CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, TripleDifference, TROP, StackedDiD, - BaconDecomposition, ... — 25 classes total) + BaconDecomposition, ... — 28 classes total) ``` `get_params` introspects the `__init__` signature and `set_params` is TRANSACTIONAL via probe re-init (`type(self)(**merged)` validates before diff --git a/README.md b/README.md index bb0eddcaa..11b5b5020 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ For rigorous DiD analysis, follow these 8 steps. Skipping diagnostic steps produ 3. **Test parallel trends** - simple 2x2: `check_parallel_trends()`, `equivalence_test_trends()`; staggered: inspect CS event-study pre-period coefficients (generic PT tests are invalid for staggered designs). Insignificant pre-trends do NOT prove PT holds. 4. **Choose estimator** - staggered adoption -> CS/SA/BJS (NOT plain TWFE); few treated units -> SDiD; factor confounding -> TROP; simple 2x2 -> DiD. Run `BaconDecomposition` to diagnose TWFE bias. 5. **Estimate** - `estimator.fit(data, ...)`. Always print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer - for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). -6. **Sensitivity analysis** - `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; the TwoWayFixedEffects `event_study=True` surface and a StackedDiD `results.aggregate('event_study')` container also admit - Stacked needs `kappa_pre >= 2`), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs. +6. **Sensitivity analysis** - `compute_honest_did(results)` for bounds under PT violations (the TwoWayFixedEffects `event_study=True` surface, CS, or dCDH natively; a StackedDiD `results.aggregate('event_study')` container also admits - Stacked needs `kappa_pre >= 2`), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs. 7. **Heterogeneity** - CS: `results.aggregate('group')`/`'event_study'` (post-fit, no refit); SA: `results.event_study_effects` / `to_dataframe(level='cohort')`; Stacked: `results.aggregate('event_study')`/`'simple'` post-fit views (surface always computed since 3.9); EDiD: `results.aggregate(...)` post-fit from retained EIFs (3.9); ImputationDiD/TwoStageDiD: `results.aggregate(...)` post-fit from panel-backed kits (3.9); ContinuousDiD: `results.aggregate('dose'/'simple'/'event_study')` post-fit (3.9; dose/simple are views, event_study recomputes); subgroup re-estimation. 8. **Robustness** - compare 2-3 estimators (CS vs SA vs BJS), report with and without covariates (shows whether conditioning drives identification), present pre-trends and sensitivity bounds. @@ -99,8 +99,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`. ## Estimators - [DifferenceInDifferences](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - basic 2x2 DiD with robust/cluster-robust SEs, wild bootstrap, formula interface, and fixed effects -- [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - panel data DiD with unit and time fixed effects via within-transformation or dummies -- [MultiPeriodDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - event study design with period-specific treatment effects for dynamic analysis (deprecated 3.9 - use TwoWayFixedEffects `event_study=True`) +- [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - panel data DiD with unit and time fixed effects via within-transformation or dummies; event-study mode (`event_study=True`) for period-specific treatment effects - [CallawaySantAnna](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Callaway & Sant'Anna (2021) group-time ATT estimator for staggered adoption - [ChaisemartinDHaultfoeuille](https://diff-diff.readthedocs.io/en/stable/api/chaisemartin_dhaultfoeuille.html) - de Chaisemartin & D'Haultfœuille (2020/2022) for **reversible (non-absorbing) treatments** with multi-horizon event study, normalized effects, cost-benefit delta, sup-t bands, and dynamic placebos. The most general option for treatments that switch on AND off (see also `LPDiD`/`TROP` `non_absorbing`). Alias `DCDH`. - [SunAbraham](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Sun & Abraham (2021) interaction-weighted estimator for heterogeneity-robust event studies @@ -136,7 +135,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`. - [Pre-Trends Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/pretrends.html) - Roth (2022) minimum detectable violation and power curves - [Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/power.html) - analytical and simulation-based MDE, sample size, power curves for study design - [MMM Calibration Export](https://diff-diff.readthedocs.io/en/stable/api/mmm.html) - convert experiment results into MMM calibration inputs: PyMC-Marketing lift-test frames and Google Meridian lognormal ROI priors -- Conley spatial HAC SE (`vcov_type="conley"`) on cross-sectional `LinearRegression` / `compute_robust_vcov` plus panel `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` (with `conley_lag_cutoff` for within-unit Bartlett temporal HAC) - Conley (1999) spatial-correlation-aware SEs with parity vs R `conleyreg` on cross-sectional + panel fixtures, optional combined spatial + cluster product kernel via explicit `cluster=`, auto-activating sparse k-d-tree fast path for `n > 5_000` +- Conley spatial HAC SE (`vcov_type="conley"`) on cross-sectional `LinearRegression` / `compute_robust_vcov` plus panel `DifferenceInDifferences` / `TwoWayFixedEffects` (with `conley_lag_cutoff` for within-unit Bartlett temporal HAC) - Conley (1999) spatial-correlation-aware SEs with parity vs R `conleyreg` on cross-sectional + panel fixtures, optional combined spatial + cluster product kernel via explicit `cluster=`, auto-activating sparse k-d-tree fast path for `n > 5_000` ## Survey Support diff --git a/TODO.md b/TODO.md index dd14937a2..bc71854f0 100644 --- a/TODO.md +++ b/TODO.md @@ -86,7 +86,9 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m | Issue | Location | Origin | Effort | Priority | |-------|----------|--------|--------|----------| -| 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")`): the within spec with hc2_bm auto-clusters at unit, so the one-way lane needs an explicit singleton-cluster column (`cluster=` + hc2_bm = CR2 with G=n, PT2018 §3.3) once the G=n CR2 path is verified; the hc2 lane is already pinned by `TestTWFEEventStudyAbsorbedFERParity`, and the MultiPeriodDiD lanes die at the 4.0 cut (PR 4c-ii) | `tests/test_estimators_vcov_type.py` | #v4-4c-i | Mid | Medium | +| 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")`): the within spec with hc2_bm auto-clusters at unit, so the one-way lane needs an explicit singleton-cluster column (`cluster=` + hc2_bm = CR2 with G=n, PT2018 §3.3) once the G=n CR2 path is verified; 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 | +| 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 | +| 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 | | Re-validate the plan-review dual engine on `gpt-6.1-sol` with `tools/plan-review-eval`: re-pin every arm's composite `cli_version` to the live Claude Code + codex CLI pair, add a gpt-6.1-sol arm, run the harness against the gpt-5.6-sol baseline (the 2026-10 swap carried the model over from the code-review campaign 2 verdict without a plan-review campaign). | `tools/plan-review-eval/config/configs.json` | reviewer-eval gpt61sol PR-2 | Heavy | Low | | TWFE-weights tutorial section: `attgt_weights` (all three `type=` values side by side) + `decompose_twfe_weights` + `plot_twfe_weights` on `mpdta`, either as a section of `02_staggered_did.ipynb` or a new notebook registered in `docs/tutorials/index.rst`; follow-on PR by decision, the feature ships without it | `docs/tutorials/02_staggered_did.ipynb` | #812 | Mid | Medium | | Geo-experiments tutorial: the SDiD fits on the `generate_factor_data(...)` panel (conversions ~1,500, sigma-hat ~310) hit the Frank-Wolfe iteration cap - the placebo fit warns once and the bootstrap cross-check warns on 100/100 draws - yet the narrative presents the bootstrap SE as a paper-faithful robustness check. Pre-existing on `main` under BOTH backends (surfaced when the notebook outputs were refreshed for the shape-only fit diagnostic); the committed April outputs predate the aggregated non-convergence warning. Either rescale / re-tune the DGP (or raise `max_iter` / loosen `min_decrease`) so the refits converge and re-execute, or label the bootstrap result unreliable and drop it from the "Validity evidence" claim. | `docs/tutorials/18_geo_experiments.ipynb` | local codex review of the SDiD shape-only fit-RMSE PR | Mid | Low | diff --git a/changelog.d/20261025-v4-pr4c-ii-remove-mpd.md b/changelog.d/20261025-v4-pr4c-ii-remove-mpd.md new file mode 100644 index 000000000..009ea6d2d --- /dev/null +++ b/changelog.d/20261025-v4-pr4c-ii-remove-mpd.md @@ -0,0 +1,56 @@ +### Removed +- **The `MultiPeriodDiD` class, its `MultiPeriodDiDResults` container, the `PeriodEffect` + record and the `EventStudy` alias are gone** (`M-010`, `M-011`, `M-012`, `M-060`; 4.0 cut, + PR 4c-ii). `from diff_diff import MultiPeriodDiD` (and the other three names) raises + `ImportError`. The estimator is `TwoWayFixedEffects().fit(..., event_study=True, spec=, + post_periods=, reference_period=)`, which returns the unified `EventStudyResults` surface + natively: `spec="pooled"` is the removed class's design (bit-exact without `unit=`; with + `unit=` the pooled spec auto-clusters at unit), `spec="within"` is its `absorb=["unit"]` + design; `post_periods=` is required (the midpoint default died with the class) and the + post-period average `avg_att` has no successor field. Pinned field for field against the + pre-refactor capture `tests/fixtures/v4_twfe_event_study_oracles.json`. +- The `pyproject.toml` pytest `filterwarnings` entry that silenced the removed class's + construction `FutureWarning`, the `MultiPeriodDiD` power-simulation profile + (`simulate_power` has no event-study profile yet; TODO row), and the `MultiPeriodDiDResults` + branches of DiagnosticReport / BusinessReport / `practitioner_next_steps` (both reports still + reject `EventStudyResults` by design; the admission TODO row records where the logic lived). + +### Breaking Changes +- **`TwoWayFixedEffects.fit(time=)` in static mode now raises `ValueError`** naming `post=` + (`M-083`). Through 3.x `time=` was the deprecated alias for the 0/1 post indicator and + emitted a `FutureWarning` (`M-082`); since 4.0 `time` means the event-study calendar column + only. Supplying both `post=` and `time=` keeps raising the "pass only post=" error. +- **`EventStudyResults.period_effects` is a `FutureWarning` compatibility view** (`M-016`; + removed in 5.0): on a calendar surface it returns the former calendar-keyed dict of + per-period records (`period, effect, se, t_stat, p_value, conf_int, is_significant, + significance_stars`, reference period omitted; `is_significant` is evaluated at the fit's + `alpha`, where the removed record hard-coded 0.05; duplicate labels raise). Relative-time + `aggregate("event_study")` surfaces do not carry it (`AttributeError`, no warning). The + successor is `to_dataframe()` and the columnar arrays. +- The 3.x "default reference_period has changed" `FutureWarning` is no longer emitted + (`M-007`): the event-study mode defaults to the last pre-period silently. +- `simulate_power` and friends read the flat `att`/`se`/`p_value`/`conf_int` names from + unregistered custom estimators' results and raise `TypeError` naming `_EstimatorProfile` + otherwise (the `avg_*` fallback left with the removed container; pass `result_extractor=` + for other shapes). + +### Internal +- `DifferenceInDifferences._fit_event_study_core` serves only the TWFE event-study mode + (`post_periods` required by the core; `include_treatment_main` / `cluster_override` + keyword-required); the private payload is pruned to the fields the surface builder reads. + The clustered `hc2_bm` contrast batch keeps the former post-period-average column as the + batch-relative Bell-McCaffrey DOF guard's anchor so per-period NaN masks stay bit-identical + (REGISTRY Note; TODO row for the guard redesign). `build_event_study_surface` opens with an + `isinstance(EventStudyResults)` passthrough. +- Ledger: `M-010`/`M-011`/`M-012` -> `removed`, `M-060`/`M-007`/`M-083` -> `done`, `M-016` + -> `shimmed` on `diff_diff:EventStudyResults.period_effects`, `M-082` keeps `shimmed` with + `warning: null`. Spec item 4c-ii carries the SHIPPED note incl. the three recorded + deviations (anchor column, new live benchmark script, DR/BR branch deletion). +- Tests: the MPD-only tests are deleted or re-fixtured onto `cs_fit` / the TWFE surface + (`tests/test_v4_merge_mpd.py` carries the removal, `M-016` and `M-007` pins and the anchor + pin); the oracle fixture gains two clustered `hc2_bm` lanes captured at `d45d5316` before + the core pruning (its `mpd_twin` records are pruned in PR 4c-ii-b); the clustered + per-period CR2 DOF R/clubSandwich pin and the event-study covariate-collision guard are + migrated to TWFE; variance-conventions `twfe_es_*` rows re-pinned (`tail_df` 5) and the table + regenerated. The methodology/benchmark prose half and the oracle fixture's + `mpd_twin` prune follow in PR 4c-ii-b. diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index be8935a3c..ef2e36776 100644 --- a/diff_diff/__init__.py +++ b/diff_diff/__init__.py @@ -100,7 +100,6 @@ ) from diff_diff.estimators import ( DifferenceInDifferences, - MultiPeriodDiD, SyntheticDiD, TwoWayFixedEffects, ) @@ -233,8 +232,6 @@ ) from diff_diff.results import ( DiDResults, - MultiPeriodDiDResults, - PeriodEffect, SpilloverDiDResults, # re-export SyntheticDiDResults, ) @@ -321,7 +318,6 @@ # Estimator aliases — short names for convenience DiD = DifferenceInDifferences TWFE = TwoWayFixedEffects -EventStudy = MultiPeriodDiD SDiD = SyntheticDiD CS = CallawaySantAnna SA = SunAbraham @@ -341,7 +337,6 @@ # Estimators "DifferenceInDifferences", "TwoWayFixedEffects", - "MultiPeriodDiD", "SyntheticDiD", "CallawaySantAnna", "ChaisemartinDHaultfoeuille", @@ -358,7 +353,6 @@ # Estimator aliases (short names). "DiD", "TWFE", - "EventStudy", "SDiD", "CS", "DCDH", @@ -374,9 +368,7 @@ "Comparison2x2", # Results "DiDResults", - "MultiPeriodDiDResults", "SyntheticDiDResults", - "PeriodEffect", "CallawaySantAnnaResults", "CSBootstrapResults", "GroupTimeEffect", diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 7ce2cda17..8486daca0 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -120,8 +120,9 @@ def resolve_renamed_kwarg( Returned when neither name was supplied. Required parameters pass ``NOT_SUPPLIED`` here and re-validate with :func:`require_arg`. extra : str - Appended to the warning message (e.g. M-082's note that ``time=`` - survives as TwoWayFixedEffects' event-study calendar column). + Appended to the warning message (e.g. a note that the old name + survives with a different meaning on the same surface, as M-082's + ``time=`` did through 3.x). Returns ------- diff --git a/diff_diff/_reporting_helpers.py b/diff_diff/_reporting_helpers.py index 58b160b59..c660252ca 100644 --- a/diff_diff/_reporting_helpers.py +++ b/diff_diff/_reporting_helpers.py @@ -117,20 +117,6 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "reference": ("REGISTRY.md Sec. DifferenceInDifferences / TwoWayFixedEffects"), } - if name == "MultiPeriodDiDResults": - return { - "name": "average ATT over post-treatment periods (event-study average)", - "definition": ( - "The equally-weighted average of the post-treatment event-study " - "coefficients. Each coefficient is an ATT at a single event time " - "relative to treatment onset; the headline averages across " - "post-treatment horizons." - ), - "aggregation": "event_study", - "headline_attribute": "avg_att", - "reference": "REGISTRY.md Sec. MultiPeriodDiD", - } - if name == "CallawaySantAnnaResults": return { "name": "overall ATT (cohort-size-weighted average of ATT(g,t))", diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index c2cb58e26..26682029a 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -695,8 +695,8 @@ def _extract_headline(self, dr_schema: Optional[Dict[str, Any]]) -> Dict[str, An inference_label = "undefined-df (replicate-weight)" else: # Ordinary analytical fit with a finite but unexposed - # ``df`` (``DifferenceInDifferences`` / ``MultiPeriodDiD`` - # / most staggered estimators / TROP). We cannot + # ``df`` (``DifferenceInDifferences`` / most staggered + # estimators / TROP). We cannot # reproduce the t-quantile without the fit's ``df``. inference_label = "analytical (native degrees of freedom)" @@ -1127,7 +1127,7 @@ def _anticipation_periods(results: Any) -> int: """Return the non-negative anticipation-period count from a result, or 0. Helper for ``_describe_assumption``. Anticipation-capable estimators - (MultiPeriodDiD, CS, SA, ImputationDiD, TwoStageDiD, Stacked, EfficientDiD, + (CS, SA, ImputationDiD, TwoStageDiD, Stacked, EfficientDiD, StaggeredTripleDiff, ContinuousDiD, Wooldridge) expose ``anticipation`` as an int defaulting to ``0``. """ @@ -1202,7 +1202,7 @@ def _apply_anticipation_to_assumption(block: Dict[str, Any], results: Any) -> Di Round-17 CI review flagged the strict "plus no anticipation" language on anticipation-enabled fits. Per REGISTRY.md §CallawaySantAnna lines - 355-395 and the matching sections for SA / MultiPeriod / Wooldridge / + 355-395 and the matching sections for SA / Wooldridge / EfficientDiD, a fit with ``anticipation=k`` shifts the effective treatment boundary by ``k`` pre-periods; the identifying assumption becomes "no treatment effects earlier than ``k`` periods before the @@ -1800,11 +1800,10 @@ def _build_caveats( # use" prose; BR must carry the same distinction through to the # caveat. The TWFE-style estimators whose results route through # Bacon and for which the "switch to a robust estimator" - # recommendation is load-bearing are the DiDResults-type fits; all - # other result classes are already robust. - _TWFE_STYLE_RESULTS: FrozenSet[str] = frozenset( - {"DiDResults", "MultiPeriodDiDResults", "TwoWayFixedEffectsResults"} - ) + # recommendation is load-bearing are the DiDResults-type fits (static + # TwoWayFixedEffects returns DiDResults too); all other result classes + # are already robust. + _TWFE_STYLE_RESULTS: FrozenSet[str] = frozenset({"DiDResults"}) if dr_schema: bacon = dr_schema.get("bacon") or {} if bacon.get("status") == "ran": diff --git a/diff_diff/conley.py b/diff_diff/conley.py index 7bde531ab..6825cec01 100644 --- a/diff_diff/conley.py +++ b/diff_diff/conley.py @@ -211,8 +211,8 @@ def _validate_conley_estimator_inputs( cluster: Optional[str] = None, ) -> None: """Shared front-door validation for ``vcov_type='conley'`` on the - estimator entry points (``DifferenceInDifferences``, ``MultiPeriodDiD``, - ``TwoWayFixedEffects``, ``SunAbraham``, and ``WooldridgeDiD``-OLS). + estimator entry points (``DifferenceInDifferences``, ``TwoWayFixedEffects``, + ``SunAbraham``, and ``WooldridgeDiD``-OLS). Each estimator's ``fit()`` calls this BEFORE building Conley arrays or threading them into the variance computation. The eight checks below diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index a35a1fbb7..28b4d6f8d 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -99,14 +99,11 @@ # as ``error``, so the narrower set is the right contract. # # ``sensitivity`` is restricted to families with a ``HonestDiD`` -# adapter: MultiPeriod, CS, dCDH (via ``placebo_event_study``). SDiD +# adapter: CS, dCDH (via ``placebo_event_study``). SDiD # and TROP use their own native paths (``estimator_native``) instead # of HonestDiD. _APPLICABILITY: Dict[str, FrozenSet[str]] = { "DiDResults": frozenset({"parallel_trends", "design_effect"}), - "MultiPeriodDiDResults": frozenset( - {"parallel_trends", "pretrends_power", "sensitivity", "bacon", "design_effect"} - ), "CallawaySantAnnaResults": frozenset( { "parallel_trends", @@ -267,7 +264,6 @@ # "scm_fit" — classic SCM donor-weighted pre-treatment fit (surfaces pre_rmspe) _PT_METHOD: Dict[str, str] = { "DiDResults": "two_x_two", - "MultiPeriodDiDResults": "event_study", "CallawaySantAnnaResults": "event_study", "DMLDiDResults": "event_study", "SunAbrahamResults": "event_study", @@ -462,7 +458,7 @@ def __init__( # (the TWFE event-study mode and aggregate('event_study') # containers) is tracked in TODO.md - until then, run diagnostics # on the fitted estimator's scalar results (e.g. the static TWFE - # fit or the MultiPeriodDiD results object). + # fit). from diff_diff.results_base import EventStudyResults as _ESR if isinstance(results, _ESR): @@ -1022,19 +1018,6 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: "pre-periods. Re-fit with kappa_pre >= 2 so " "pre-treatment event-study coefficients exist." ) - # ``pre_period_effects`` producers (MultiPeriodDiD): a - # valid one-pre-period fit has an EMPTY dict here because - # its sole pre-period is the omitted reference — that is - # not a missing post-fit aggregation route, so it must - # not fall through to the resolver's taxonomy. - if getattr(r, "pre_period_effects", None) is not None: - return ( - "No estimated pre-period coefficients exist on " - "this fit: every pre-treatment period is the " - "omitted reference. Re-fit with more " - "pre-treatment periods to enable pre-trend " - "checks." - ) self._derived_es_consulted.add("parallel_trends") surface, surface_dict, why = self._resolve_event_study_surface() if surface is not None: @@ -1116,7 +1099,7 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: if surface is not None and surface.source not in _DERIVED_SURFACE_CONSUMER_SOURCES: # Fail closed on a non-admitted producer (M-093). # Unreachable under the current applicability set - # (MPD/CS/DMLDiD/SA); locals only — never mutate the cache. + # (CS/DMLDiD/SA); locals only — never mutate the cache. surface = None surface_dict = None if not (has_vcov or has_event_vcov or has_event_es or surface is not None): @@ -1195,11 +1178,10 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: "(re-fit with a placebo-producing configuration)." ) return None - # MultiPeriod / CS path: ``HonestDiD.sensitivity_analysis`` - # consumes ``event_study_effects`` plus either ``vcov`` + - # ``interaction_indices`` (MultiPeriod) or ``event_study_vcov`` - # + ``event_study_vcov_index`` (CS), with a per-SE diagonal - # fallback otherwise. + # CS path: ``HonestDiD.sensitivity_analysis`` consumes + # ``event_study_effects`` plus ``event_study_vcov`` + + # ``event_study_vcov_index``, with a per-SE diagonal fallback + # otherwise. has_vcov = getattr(r, "vcov", None) is not None has_event_vcov = getattr(r, "event_study_vcov", None) is not None has_event_es = getattr(r, "event_study_effects", None) is not None @@ -1313,7 +1295,6 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: "event_study_effects", "treatment_effects", # TROP per-(unit, time) "group_time_effects", # CS default aggregation - "period_effects", # MultiPeriod ): val = getattr(r, attr, None) if val is None: @@ -1691,9 +1672,9 @@ def _pt_two_x_two(self) -> Dict[str, Any]: def _pt_event_study(self) -> Dict[str, Any]: """Event-study joint Wald (or Bonferroni fallback) on pre-period coefficients. - Works with either ``pre_period_effects`` (``MultiPeriodDiDResults`` style, - dict of ``PeriodEffect`` objects) or ``event_study_effects`` (CS / SA / - ImputationDiD style, dict of dicts with ``effect``/``se``/``p_value`` keys). + Works with ``event_study_effects`` (CS / SA / ImputationDiD style, dict + of dicts with ``effect``/``se``/``p_value`` keys) or dCDH's + ``placebo_event_study``. """ r = self._results surface, surface_dict, _why = self._resolve_event_study_surface() @@ -1760,9 +1741,6 @@ def _mark_derived(section: Dict[str, Any]) -> Dict[str, Any]: "reason": "No pre-period event-study coefficients available.", } ) - interaction_indices = getattr(r, "interaction_indices", None) - vcov = getattr(r, "vcov", None) - # pre_coefs is a sorted list of (key, effect, se, p_value) tuples. per_period = [ { @@ -1786,13 +1764,10 @@ def _mark_derived(section: Dict[str, Any]) -> Dict[str, Any]: # The schema's ``method`` field exposes which path ran so agents and # tests can distinguish the two unambiguously. # - # Two covariance sources are supported: - # 1. ``interaction_indices`` + ``vcov`` — the MultiPeriodDiDResults - # convention, where ``vcov`` is the full regression covariance - # matrix and ``interaction_indices`` maps period labels to rows. - # 2. ``event_study_vcov_index`` + ``event_study_vcov`` — the - # CallawaySantAnnaResults convention, where the event-study - # covariance is stored separately from the full regression vcov. + # Covariance source: ``event_study_vcov_index`` + ``event_study_vcov`` + # (the CallawaySantAnnaResults convention, where the event-study + # covariance is stored separately from the full regression vcov; the + # derived post-fit surface carries the same pair). # Fail-closed inference guard for the JOINT-WALD path. The Wald # statistic consumes only effects + covariance, so with a persisted # vcov it would silently bypass the estimator's own fail-closed @@ -1845,36 +1820,32 @@ def _mark_derived(section: Dict[str, Any]) -> Dict[str, Any]: # BM-adjusted per-row p-values keeps the small-sample correction. # See REGISTRY.md StackedDiD / REPORTING.md PT notes. if getattr(r, "vcov_type", None) != "hc2_bm": - if vcov is not None and interaction_indices is not None: - vcov_for_wald = vcov - idx_map_for_wald = interaction_indices - else: - es_vcov = getattr(r, "event_study_vcov", None) - es_vcov_index = getattr(r, "event_study_vcov_index", None) - if es_vcov is None or es_vcov_index is None: - # Derived-route covariance: the post-fit container - # carries the same event-study vcov + ordered index - # (np.int64 labels hash-equal to the adapter's int - # keys; extra vcov rows are tolerated by the sub-block - # indexing below). - if ( - derived - and surface is not None - and getattr(surface, "vcov", None) is not None - and getattr(surface, "vcov_index", None) is not None - ): - es_vcov = surface.vcov - es_vcov_index = list(surface.vcov_index) - if es_vcov is not None and es_vcov_index is not None: - vcov_for_wald = es_vcov - # ``event_study_vcov_index`` is an ordered list of - # relative-time keys; convert it into a dict mapping - # key -> position. - try: - idx_map_for_wald = {k: i for i, k in enumerate(es_vcov_index)} - vcov_method_tag = "joint_wald_event_study" - except TypeError: - idx_map_for_wald = None + es_vcov = getattr(r, "event_study_vcov", None) + es_vcov_index = getattr(r, "event_study_vcov_index", None) + if es_vcov is None or es_vcov_index is None: + # Derived-route covariance: the post-fit container + # carries the same event-study vcov + ordered index + # (np.int64 labels hash-equal to the adapter's int + # keys; extra vcov rows are tolerated by the sub-block + # indexing below). + if ( + derived + and surface is not None + and getattr(surface, "vcov", None) is not None + and getattr(surface, "vcov_index", None) is not None + ): + es_vcov = surface.vcov + es_vcov_index = list(surface.vcov_index) + if es_vcov is not None and es_vcov_index is not None: + vcov_for_wald = es_vcov + # ``event_study_vcov_index`` is an ordered list of + # relative-time keys; convert it into a dict mapping + # key -> position. + try: + idx_map_for_wald = {k: i for i, k in enumerate(es_vcov_index)} + vcov_method_tag = "joint_wald_event_study" + except TypeError: + idx_map_for_wald = None df_denom: Optional[float] = None if vcov_for_wald is not None and idx_map_for_wald is not None and df > 0: try: @@ -2192,12 +2163,8 @@ def _infer_cov_source(source_fit: Any) -> str: Classification rules: - ``"full_pre_period_vcov"`` — basic ``DiDResults`` and other - non-event-study, non-MPD result types that historically expose - the full pre-period covariance. ``MultiPeriodDiDResults`` is - handled by an explicit branch below because its - ``pretrends.py`` MPD branch only takes the full sub-block path - when ``interaction_indices`` is populated, otherwise falling - through to ``diag(ses**2)``. + non-event-study result types that historically expose the full + pre-period covariance. - ``"diag_fallback_available_full_vcov_unused"`` — event-study result types with populated ``event_study_vcov``. Under PR-B, new fits route through the full sub-block, but a legacy @@ -2217,10 +2184,7 @@ def _infer_cov_source(source_fit: Any) -> str: persists its VCV in every inference mode and, since 3.9 (row M-024), always materializes the event-study surface, so post-3.9 StackedDiD fits never reach this fallback - only - pre-3.9 pickles can); - OR ``MultiPeriodDiDResults`` without ``interaction_indices`` - (genuine diag-only path inside ``pretrends.py:_extract_pre_period_params``, - no "available but unused" concern, so no downgrade applies). + pre-3.9 pickles can). """ is_event_study_type = type(source_fit).__name__ in { "CallawaySantAnnaResults", @@ -2246,22 +2210,8 @@ def _infer_cov_source(source_fit: Any) -> str: return "diag_fallback_available_full_vcov_unused" if is_event_study_type: return "diag_fallback" - # Non-event-study path. MultiPeriodDiDResults takes the full - # ``vcov[ix_]`` sub-block only when ``interaction_indices`` is - # populated (pretrends.py MPD branch); otherwise it falls - # through to ``diag(ses**2)`` and ships the diag-fallback path - # — which is a normal (not "available but unused") fallback, - # so no conservative downgrade applies. Legacy MPD result - # objects without ``interaction_indices`` should be reported as - # ``diag_fallback`` rather than overclaiming full-Σ_22. - if type(source_fit).__name__ == "MultiPeriodDiDResults": - mpd_has_full_vcov = ( - getattr(source_fit, "vcov", None) is not None - and getattr(source_fit, "interaction_indices", None) is not None - ) - return "full_pre_period_vcov" if mpd_has_full_vcov else "diag_fallback" - # Other non-event-study types (basic DiDResults, TWFE, etc.) - # historically expose the full covariance. + # Non-event-study types (basic DiDResults, TWFE, etc.) historically + # expose the full covariance. return "full_pre_period_vcov" def _check_sensitivity(self) -> Dict[str, Any]: @@ -3377,13 +3327,7 @@ def _collect_effect_scalars(self) -> List[float]: ge = getattr(r, "group_effects", None) if ge is not None: return self._scalars_from_mapping(ge) - # 2. MultiPeriodDiDResults: use the ``post_period_effects`` property - # (post-treatment only) instead of ``period_effects`` (which mixes - # pre- and post-treatment coefficients). - ppe = getattr(r, "post_period_effects", None) - if ppe is not None: - return self._scalars_from_mapping(ppe) - # 3. event_study_effects: dict keyed by relative time -> dict with + # 2. event_study_effects: dict keyed by relative time -> dict with # 'effect'. Filter to **post-treatment** horizons (rel_time >= 0), # exclude reference markers (``n_groups == 0`` on CS/SA; # ``n_obs == 0`` on Stacked/TwoStage/Imputation/EfficientDiD), and @@ -3513,16 +3457,13 @@ def _heterogeneity_source(self) -> str: """Name the attribute that produced the scalars (for the schema). Mirrors the dispatch order in ``_collect_effect_scalars`` and - reports the actual post-treatment surface consumed (e.g., - ``post_period_effects`` rather than ``period_effects`` on - ``MultiPeriodDiDResults``, and ``event_study_effects_post`` to - make it clear pre-period / reference-marker rows were filtered). + reports the actual post-treatment surface consumed + (``event_study_effects_post`` makes it clear pre-period / + reference-marker rows were filtered). """ r = self._results if getattr(r, "group_effects", None) is not None: return "group_effects" - if getattr(r, "post_period_effects", None) is not None: - return "post_period_effects" if getattr(r, "event_study_effects", None) is not None: return "event_study_effects_post" if getattr(r, "treatment_effects", None) is not None: @@ -3916,8 +3857,6 @@ def _extract_scalar_headline( ["overall_p_value", "overall_att_p_value"], ["overall_conf_int", "overall_att_conf_int"], ), - # MultiPeriodDiDResults - ("avg_att", ["avg_se"], ["avg_p_value"], ["avg_conf_int"]), # Simple DiDResults / SyntheticDiDResults / TROPResults / TripleDifferenceResults ("att", ["se"], ["p_value"], ["conf_int"]), ] @@ -3957,8 +3896,8 @@ def _extract_scalar_headline( def _extract_scalar_effect(val: Any) -> Optional[float]: """Pull a scalar effect out of the many shapes results expose. - Handles: ``PeriodEffect`` / ``GroupTimeEffect`` objects (``.effect`` - or ``.att`` attr), dicts with an ``"effect"`` or ``"att"`` key, and + Handles: ``GroupTimeEffect``-style objects (``.effect`` or ``.att`` + attr), dicts with an ``"effect"`` or ``"att"`` key, and bare scalars. Wooldridge stores ``att`` in its ``group_time_effects`` / ``group_effects`` / ``event_study_effects`` payloads rather than ``effect`` (round-16 CI review on PR #318). @@ -4135,8 +4074,7 @@ def _collect_pre_period_coefs( """Return ``(sorted list of (key, effect, se, p_value), n_dropped_undefined)`` for pre-period coefficients. - Handles three shapes: - * ``pre_period_effects``: dict-of-``PeriodEffect`` on ``MultiPeriodDiDResults``. + Handles two shapes: * ``event_study_effects``: dict-of-dict (with ``effect`` / ``se`` / ``p_value`` keys) on the staggered estimators (CS / SA / ImputationDiD / Stacked / EDiD / etc.). Pre-period entries are those with negative relative-time keys. @@ -4166,36 +4104,17 @@ def _collect_pre_period_coefs( ``n_dropped_undefined`` to force an inconclusive verdict rather than silently shrinking. - Returns ``([], 0)`` when none of the three sources provides valid - pre-period entries. + Returns ``([], 0)`` when neither source provides valid pre-period + entries. """ results_list: List[Tuple[Any, float, float, Optional[float]]] = [] n_dropped_undefined = 0 - pre = getattr(results, "pre_period_effects", None) # dCDH exposes pre-period placebos via ``placebo_event_study``; the # round-6 CI review flagged that routing dCDH through the generic # ``event_study_effects`` path produced empty pre-coef lists and # silently skipped the PT check. dcdh_placebo = getattr(results, "placebo_event_study", None) - if pre: - for k, pe in pre.items(): - eff = getattr(pe, "effect", None) - se = getattr(pe, "se", None) - p = getattr(pe, "p_value", None) - if eff is None or se is None: - n_dropped_undefined += 1 - continue - try: - eff_f = float(eff) - se_f = float(se) - except (TypeError, ValueError): - n_dropped_undefined += 1 - continue - if not (np.isfinite(eff_f) and np.isfinite(se_f) and se_f > 0): - n_dropped_undefined += 1 - continue - results_list.append((k, eff_f, se_f, _to_python_float(p))) - elif dcdh_placebo: + if dcdh_placebo: # dCDH placebo horizons are the pre-period surface. for k, entry in dcdh_placebo.items(): if not isinstance(entry, dict): diff --git a/diff_diff/estimators.py b/diff_diff/estimators.py index 912eb226e..8fc23cb01 100644 --- a/diff_diff/estimators.py +++ b/diff_diff/estimators.py @@ -2,8 +2,8 @@ Difference-in-Differences estimators with sklearn-like API. This module contains the core DiD estimators: -- DifferenceInDifferences: Basic 2x2 DiD estimator -- MultiPeriodDiD: Event-study style DiD with period-specific treatment effects +- DifferenceInDifferences: Basic 2x2 DiD estimator (and the shared + event-study core behind TwoWayFixedEffects.fit(event_study=True)) Additional estimators are in separate modules: - TwoWayFixedEffects: See diff_diff.twfe @@ -27,7 +27,7 @@ compute_r_squared, solve_ols, ) -from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect +from diff_diff.results import DiDResults from diff_diff.results_base import _EventStudyCorePayload from diff_diff.utils import ( WildBootstrapResults, @@ -49,29 +49,11 @@ ) # Accepted values for the `inference` selector (M-096). Exposed by exactly -# DifferenceInDifferences, MultiPeriodDiD and TwoWayFixedEffects (the two -# subclasses inherit this __init__); the fail-closed check lives in -# DifferenceInDifferences.__init__ and set_params inherits it via the -# BaseEstimator probe re-init. +# DifferenceInDifferences and TwoWayFixedEffects (the subclass inherits this +# __init__); the fail-closed check lives in DifferenceInDifferences.__init__ +# and set_params inherits it via the BaseEstimator probe re-init. _INFERENCE_METHODS = ("analytical", "wild_bootstrap") -# Sentinel for _fit_event_study_core's cluster_override: "use self.cluster". -# A plain None default cannot express that, because None is itself a legal -# override value ("no clustering" - the resolved TWFE event-study carve-outs). -_USE_SELF_CLUSTER: Any = object() - -# MultiPeriodDiD 3.9 deprecation message (row M-010; the EventStudy alias is -# the same class object, so constructing via the alias emits this too - -# row M-060). Pinned verbatim by tests/test_v4_merge_mpd.py and the targeted -# pytest filter in pyproject.toml. -_MPD_DEPRECATION_MSG = ( - "MultiPeriodDiD is deprecated and will be removed in 4.0; use " - "TwoWayFixedEffects().fit(..., event_study=True) instead - " - "spec='pooled' reproduces the MultiPeriodDiD design; the default " - "spec='within' adds unit fixed effects. The EventStudy alias is " - "deprecated with it." -) - class DifferenceInDifferences(BaseEstimator): """ @@ -102,9 +84,9 @@ class DifferenceInDifferences(BaseEstimator): entirely NaN covariance/inference, retaining point estimates. - ``"hc2_bm"``: one-way HC2 + Imbens-Kolesar (2016) Satterthwaite DOF; with ``cluster=``, Pustejovsky-Tipton (2018) CR2 cluster-robust. - ``MultiPeriodDiD(cluster=..., vcov_type="hc2_bm")`` is supported and - uses a cluster-aware Bell-McCaffrey contrast DOF for the - post-period-average ATT (see ``_compute_cr2_bm_contrast_dof`` in + ``TwoWayFixedEffects(cluster=..., vcov_type="hc2_bm")`` in event-study + mode is supported and uses cluster-aware Bell-McCaffrey per-period + contrast DOF (see ``_compute_cr2_bm_contrast_dof`` in ``linalg.py`` and the REGISTRY.md note). Weighted CR2-BM (``survey_design=`` paths) is a separate gate. Unweighted, unclustered fits share HC2's leverage-one NaN guard; @@ -367,8 +349,7 @@ def fit( ``vcov_type="conley"`` — the panel block-decomposed Conley sandwich (matching R ``conleyreg`` with ``lag_cutoff > 0``) needs the unit identifier to compute the per-unit serial sum. - Mirrors :meth:`MultiPeriodDiD.fit(unit=...)` and - :meth:`TwoWayFixedEffects.fit(unit=...)`. Fit-time only — NOT + Mirrors :meth:`TwoWayFixedEffects.fit(unit=...)`. Fit-time only — NOT a constructor kwarg, so it is not part of ``get_params()`` / ``set_params()`` (which return constructor-arg dicts). Ignored when ``vcov_type`` is not ``"conley"``. @@ -513,14 +494,14 @@ def fit( # Validate vcov_type="conley" wire-up. DiD.fit() accepts `unit` # as a fit-time arg (NOT on __init__) because cluster/unit # semantics on DiD are opt-in rather than auto-derived (unlike - # MultiPeriodDiD / TwoWayFixedEffects which have a unit declaration - # at fit-time anyway). The panel block-decomposed Conley sandwich + # TwoWayFixedEffects, which has a unit declaration at fit-time + # anyway). The panel block-decomposed Conley sandwich # (matching R conleyreg with lag_cutoff > 0) needs unit/time/coords # to assemble the within-period spatial and within-unit serial - # sums; we mirror MultiPeriodDiD's reject pattern for missing args - # and the survey/wild-bootstrap incompatibilities. + # sums; we mirror the event-study core's reject pattern for missing + # args and the survey/wild-bootstrap incompatibilities. if self.vcov_type == "conley": - # Shared front-door validation across DiD / MPD / TWFE entry + # Shared front-door validation across DiD / TWFE entry # points (Wave A holistic fix: replaces the inline drift that # accumulated across CI R1/R2/R6 — same-class validation gaps # mirrored across estimator surfaces). @@ -713,7 +694,7 @@ def fit( # but the kernel grid uses the original space (coords) and time/unit # indexing. `_compute_conley_vcov` normalizes time labels to dense # codes 0..T-1 internally, so non-numeric `time` labels (datetime64, - # pd.Period, strings) still work on the MultiPeriodDiD path; DiD's + # pd.Period, strings) still work on the event-study path; DiD's # binary `time` column is integer 0/1 by convention and is unaffected # by the normalization. if _fit_vcov_type == "conley": @@ -1332,65 +1313,50 @@ def _fit_event_study_core( survey_design: Any, effective_inference: str, *, - include_treatment_main: bool = True, - warn_legacy_reference_default: bool = True, - cluster_override: Any = _USE_SELF_CLUSTER, - estimator_name: str = "MultiPeriodDiD", - _frame_offset: int = 0, + include_treatment_main: bool, + cluster_override: Optional[str], + estimator_name: str = "TwoWayFixedEffects", + _frame_offset: int = 2, ) -> _EventStudyCorePayload: """Shared event-study estimation core (row M-010). Returns a private :class:`~diff_diff.results_base._EventStudyCorePayload` - (PR 4c-i decoupling): ``MultiPeriodDiD.fit`` wraps it into the legacy - ``MultiPeriodDiDResults`` container via ``_mpd_results_from_payload``; - the TWFE event-study mode builds the unified ``EventStudyResults`` - surface from it directly, so no MultiPeriodDiD container is - constructed on that path. - - The relocated body of ``MultiPeriodDiD.fit`` (verbatim through the - 3.9 merge; docs/v4-design.md section 4.1): validation, the - pooled event-study design build, OLS via ``solve_ols``, survey / - Conley / hc2_bm variance lanes, per-period inference, and the - ``MultiPeriodDiDResults`` construction. Called by - ``MultiPeriodDiD.fit`` (all knob defaults) and by - ``TwoWayFixedEffects`` in event-study mode. - - Parameters (knobs beyond the MPD fit surface) - --------------------------------------------- + from which the TWFE event-study mode builds the unified + ``EventStudyResults`` surface (``results_base._from_event_study_payload``). + Through 3.x this was the body of the removed class's ``fit`` (row M-010) + (docs/v4-design.md section 4.1): validation, the pooled event-study + design build, OLS via ``solve_ols``, survey / Conley / hc2_bm variance + lanes and per-period inference. Its numbers are pinned against the + pre-refactor capture by ``tests/test_twfe_event_study_decoupling.py``. + + Parameters (knobs beyond the fit surface) + ----------------------------------------- include_treatment_main : bool + Keyword-required. True builds the pooled design (treatment main + effect + period dummies + interactions, ``spec="pooled"``); False omits the treatment main-effect column - the ``spec="within"`` design, where D is absorbed by the unit FE (omitting it avoids a spurious snap/collinearity warning). Interactions are unaffected (built from the raw indicator). - warn_legacy_reference_default : bool - False suppresses the M-007 legacy reference-period default - FutureWarning (the merged event-study mode has no legacy - default to warn about; MPD keeps warning until 4.0). - cluster_override : Any - The RESOLVED cluster column for this fit. The default - sentinel means "use ``self.cluster``" (MPD behavior). The - TWFE event-study branch pre-resolves its auto-cluster with - the Conley / survey-PSU / one-way carve-outs and passes the - final value; an explicit column here behaves exactly like - MPD's own explicit ``cluster=`` on every lane (survey PSU - injection included). + cluster_override : Optional[str] + Keyword-required. The RESOLVED cluster column for this fit + (None = no clustering). The TWFE event-study branch pre-resolves + its auto-cluster with the Conley / survey-PSU / one-way + carve-outs and passes the final value; an explicit column + behaves exactly like an explicit ``cluster=`` on every lane + (survey PSU injection included). estimator_name : str - Producer name for warnings and validation messages, so TWFE - event-study fits never steer users toward the deprecated - MultiPeriodDiD class. + Producer name for warnings and validation messages. _frame_offset : int Extra call frames between the user's fit call and this body - (1 via ``MultiPeriodDiD.fit``, 2 via the TWFE event-study - branch). Added to every warning stacklevel that attributed - to USER code before the extraction, preserving attribution - bit-identically; library-attributed warnings (e.g. the - ``solve_ols`` rank-deficiency chain) are deliberately not - offset - they attributed to this module before the move and - still do. + (2 via the TWFE event-study branch). Added to every warning + stacklevel that attributed to USER code before the extraction, + preserving attribution bit-identically; library-attributed + warnings (e.g. the ``solve_ols`` rank-deficiency chain) are + deliberately not offset - they attributed to this module before + the move and still do. """ - cluster_resolved: Optional[str] = ( - self.cluster if cluster_override is _USE_SELF_CLUSTER else cluster_override - ) + cluster_resolved: Optional[str] = cluster_override # Validate basic inputs if outcome is None or treatment is None or time is None: raise ValueError("Must provide 'outcome', 'treatment', and 'time'") @@ -1443,7 +1409,7 @@ def _fit_event_study_core( # Check for time-varying treatment (D_it instead of D_i) # If any unit has a 0→1 transition, the treatment column is D_it. - # MultiPeriodDiD expects a time-invariant ever-treated indicator. + # The event-study design expects a time-invariant ever-treated indicator. warnings.warn( "Treatment indicator varies within units (time-varying " f"treatment detected). {estimator_name}'s event-study " @@ -1463,18 +1429,17 @@ def _fit_event_study_core( if len(all_periods) < 2: raise ValueError("Time variable must have at least 2 unique periods") - # Determine pre and post periods - if post_periods is None: - # Default: last half of periods are post-treatment - mid_point = len(all_periods) // 2 - post_periods = all_periods[mid_point:] - pre_periods = all_periods[:mid_point] - else: - post_periods = list(post_periods) - pre_periods = [p for p in all_periods if p not in post_periods] - - if len(post_periods) == 0: - raise ValueError("Must have at least one post-treatment period") + # Determine pre and post periods. post_periods is REQUIRED: the + # treatment boundary is not observable from a time-invariant + # ever-treated indicator, so there is no default (the 3.x midpoint + # default died with the removed class at the 4.0 cut). + if post_periods is None or len(post_periods) == 0: + raise ValueError( + f"{estimator_name} event-study mode requires an explicit, non-empty " + "post_periods= (the post-treatment calendar periods)." + ) + post_periods = list(post_periods) + pre_periods = [p for p in all_periods if p not in post_periods] if len(pre_periods) == 0: raise ValueError("Must have at least one pre-treatment period") @@ -1496,21 +1461,9 @@ def _fit_event_study_core( # Determine reference period (omitted dummy) if reference_period is None: - # Default: last pre-period (e=-1 convention, matches fixest). - # The M-007 transition warning is MPD-only: the merged TWFE - # event-study mode was born on the e=-1 convention and has no - # legacy default to warn about (docs/v4-design.md section 4.1). - if len(pre_periods) > 1 and warn_legacy_reference_default: - warnings.warn( - f"The default reference_period has changed from the first " - f"pre-period ({pre_periods[0]}) to the last pre-period " - f"({pre_periods[-1]}) to match the standard e=-1 convention " - f"(as used by fixest, did, etc.). " - f"To silence this warning, pass " - f"reference_period={pre_periods[-1]} explicitly.", - FutureWarning, - stacklevel=2 + _frame_offset, - ) + # Default: last pre-period (e=-1 convention, matches fixest). The + # 3.x transition FutureWarning (row M-007) retired with the removed + # class at the 4.0 cut; this mode was born on e=-1. reference_period = pre_periods[-1] elif reference_period not in all_periods: raise ValueError(f"Reference period '{reference_period}' not found in time column") @@ -1522,8 +1475,8 @@ def _fit_event_study_core( f"The reference period must be a pre-treatment period " f"(e.g., the last pre-period {pre_periods[-1]}). " f"Post-period references are not supported because the reference " - f"period is excluded from estimation, which would bias avg_att " - f"and break downstream inference." + f"period is excluded from estimation, which would bias the " + f"post-period estimates and break downstream inference." ) # Validate fixed effects and absorb columns @@ -1557,10 +1510,6 @@ def _fit_event_study_core( working_data = data.copy() n_absorbed_effects = 0 - # Save raw treatment counts before absorb demeaning - n_treated_raw = int(np.sum(data[treatment].values.astype(float))) - n_control_raw = len(data) - n_treated_raw - # Mutual-exclusion check runs ABOVE the auto-route below so that the # `absorb=..., fixed_effects=...` combination still rejects rather # than being silently merged. @@ -1612,7 +1561,7 @@ def _fit_event_study_core( # iterative alternating projections (demean_by_groups), the exact weighted # FWL projection for N > 1 dimensions on both balanced and unbalanced panels. - # MultiPeriodDiD is intrinsically a multi-period panel estimator; + # The event-study core is intrinsically a multi-period panel estimator; # Phase 2 panel block-decomposed Conley (matches R conleyreg) needs # `unit`, `conley_lag_cutoff`, and `conley_coords` at fit-time. The # validation is shared with DiD / TWFE to avoid the validation-class @@ -1776,7 +1725,7 @@ def _fit_event_study_core( # Add fixed effects as dummy variables. # - # MPD's design already absorbs the time dimension via non-reference + # The event-study design already absorbs the time dimension via non-reference # period dummies (the `period_` columns above) and the treatment- # period interactions. If the caller passes the same column as a # fixed effect (either explicitly or via the absorb -> fixed_effects @@ -1844,7 +1793,7 @@ def _fit_event_study_core( # designs route through the TSL/replicate paths). If a future weighted # entry point set survey_weights here, the flag is False and the code # falls back to the original two-call behavior. - _is_mpd_cr2_path = ( + _cr2_bypass_path = ( _fit_vcov_type == "hc2_bm" and effective_cluster_ids is not None and not _use_survey_vcov @@ -1885,11 +1834,12 @@ def _fit_event_study_core( # Clustered-CR1 K_reference adjustment (variance-conventions.md D1/D2). # Absorb lane: the absorbed increment (non-nested rank + no intercept - # term — MPD's X carries an intercept). Non-absorb lanes: the built-in - # period dummies ARE MPD's time-FE block (a supplied time FE is - # skipped as redundant above), so they and any placed `_mp_fes` - # dummies SUBTRACT their joint rank when nested in the cluster — - # preserving MPD's absorb/fixed_effects equivalence. Survey lanes + # term — the event-study X carries an intercept). Non-absorb lanes: + # the built-in period dummies ARE the design's time-FE block (a + # supplied time FE is skipped as redundant above), so they and any + # placed `_mp_fes` dummies SUBTRACT their joint rank when nested in + # the cluster — preserving the absorb/fixed_effects equivalence. + # Survey lanes # pass 0 (survey vcov replaces CR1 wholesale). _cr1_k_adj_mp = 0 if _fit_vcov_type == "hc1" and effective_cluster_ids is not None and not _use_survey_vcov: @@ -1923,7 +1873,7 @@ def _fit_event_study_core( X, y, return_fitted=True, - return_vcov=(not _use_survey_vcov) and not _is_mpd_cr2_path, + return_vcov=(not _use_survey_vcov) and not _cr2_bypass_path, cluster_ids=effective_cluster_ids, column_names=var_names, rank_deficient_action=self.rank_deficient_action, @@ -2050,7 +2000,6 @@ def _refit_mp_absorb(w_r): vcov = np.full((X.shape[1], X.shape[1]), np.nan) else: vcov = compute_survey_vcov(X, residuals, resolved_survey) - r_squared = compute_r_squared(y, residuals) # Degrees of freedom: survey df overrides standard df k_effective = int(np.sum(~np.isnan(coefficients))) @@ -2156,22 +2105,20 @@ def _refit_mp_absorb(w_r): # through compute_robust_vcov's classical branch (identical math), so # vcov above already matches the requested variance family. - # For hc2_bm with a non-survey fit, compute per-coefficient and - # per-contrast Bell-McCaffrey Satterthwaite DOF so period-specific - # effects and the post-period average use correct small-sample DOF - # rather than the shared n-k fallback. + # For hc2_bm with a non-survey fit, compute per-coefficient + # Bell-McCaffrey Satterthwaite DOF so period-specific effects use + # correct small-sample DOF rather than the shared n-k fallback. _bm_dof_per_coef: Optional[np.ndarray] = None - _bm_dof_avg: Optional[float] = None - # On the `_is_mpd_cr2_path` bypass, solve_ols did not compute vcov + # On the `_cr2_bypass_path` bypass, solve_ols did not compute vcov # (return_vcov=False). If every coefficient was dropped, synthesize the # all-NaN vcov solve_ols would have returned (linalg.py:1230/1019); the # BM DOF block below is skipped (no identified coefficients). - if _is_mpd_cr2_path and vcov is None and np.all(np.isnan(coefficients)): + if _cr2_bypass_path and vcov is None and np.all(np.isnan(coefficients)): vcov = np.full((X.shape[1], X.shape[1]), np.nan) if ( self.vcov_type == "hc2_bm" and not _use_survey_vcov - and (vcov is not None or _is_mpd_cr2_path) + and (vcov is not None or _cr2_bypass_path) and not np.all(np.isnan(coefficients)) ): from diff_diff.linalg import ( @@ -2189,8 +2136,16 @@ def _refit_mp_absorb(w_r): X_kept * survey_weights[:, np.newaxis] if survey_weights is not None else X_kept ) # Build the contrast matrix: one column per identified coefficient - # plus one column for the post-period average contrast (1/n_post - # on each post-period interaction column, 0 elsewhere). + # plus the post-period average contrast (1/n_post on each + # post-period interaction column, 0 elsewhere). The average's + # own DOF is unused since the 4.0 cut (the post-period average + # left with the removed container), but the column is KEPT + # as the anchor of the batch-relative noise-floor guard in + # linalg._cr2_bm_dof_inner (`scaled_max_B < 1e-10 * max`): the + # average column often sets that max, and dropping it changes + # which near-collinear per-period DOFs are NaN'd (reproduced + # on 20/299 near-collinear draws). See the REGISTRY Note and + # the TODO row on the batch-relative guard. n_kept = len(_kept) # Post-period contrast in full-width k dims, then subset to kept post_contrast_full = np.zeros(X.shape[1]) @@ -2213,7 +2168,7 @@ def _refit_mp_absorb(w_r): contrasts, weights=survey_weights, ) - elif _is_mpd_cr2_path: + elif _cr2_bypass_path: # Cluster-aware CR2 BM: vcov AND the per-coefficient + # post-period-average compound contrast (Gate 6 lift) DOF # from a SINGLE precompute build — the perf dedup. solve_ols @@ -2221,7 +2176,7 @@ def _refit_mp_absorb(w_r): # same (X_kept, residuals, cluster_ids, bread_kept) it would # have used internally (→ byte-identical vcov), then expand # with NaN for dropped columns. weights=None per the - # _is_mpd_cr2_path guard (survey_weights is None here; survey + # _cr2_bypass_path guard (survey_weights is None here; survey # designs route through the TSL path). _vcov_reduced, _dof_all = _compute_cr2_bm_vcov_and_dof( X_kept, @@ -2238,7 +2193,7 @@ def _refit_mp_absorb(w_r): # non-None `survey_weights` comes from a SurveyDesign whose # `needs_survey_vcov` is True — so `_use_survey_vcov` would be # True and the whole hc2_bm block is skipped. Hence - # `_is_mpd_cr2_path` is always satisfied here in practice (it + # `_cr2_bypass_path` is always satisfied here in practice (it # already requires `survey_weights is None`), and this branch # only fails safe for a future weighted-cluster entry point. # It mirrors the pre-refactor call EXACTLY (solve_ols's vcov is @@ -2251,31 +2206,22 @@ def _refit_mp_absorb(w_r): contrasts, ) # Expand per-coefficient DOF back to full width (NaN for dropped). + # `_dof_all[-1]` (the anchor column's DOF) is not read. _bm_dof_per_coef = np.full(X.shape[1], np.nan) _bm_dof_per_coef[_kept] = _dof_all[:n_kept] - # Post-period average: last contrast column. - # Only meaningful if all post-period coefs are identified. - if np.all(_identified[[interaction_indices[p] for p in post_periods]]): - _bm_dof_avg = float(_dof_all[-1]) # Extract period-specific treatment effects for ALL non-reference periods - # (index-aligned lists over non_ref_periods; the MultiPeriodDiD - # wrapper zips them into PeriodEffect records, the TWFE surface - # builder reads them as arrays). + # (index-aligned lists over non_ref_periods; the TWFE surface builder + # reads them as arrays). pe_att: List[float] = [] pe_se: List[float] = [] pe_t: List[float] = [] pe_p: List[float] = [] pe_lo: List[float] = [] pe_hi: List[float] = [] - post_effect_values = [] - post_effect_indices = [] # Per-period df PROVENANCE for the unified event-study surface # (row M-092): the df actually handed to each period's - # safe_inference below. Distinct from `inference_df`, which stores - # the POST-AVERAGE contrast df - under hc2_bm the per-period BM - # DOFs differ from it, so broadcasting inference_df would - # misrepresent the per-period rows. + # safe_inference below. es_df_used: Dict[Any, float] = {} assert vcov is not None @@ -2307,53 +2253,7 @@ def _refit_mp_absorb(w_r): pe_lo.append(conf_int[0]) pe_hi.append(conf_int[1]) - if period in post_periods: - post_effect_values.append(effect) - post_effect_indices.append(idx) - - # Compute average treatment effect (post-periods only) - # R-style NA propagation: if ANY post-period effect is NaN, average is undefined - effect_arr = np.array(post_effect_values) - - _avg_df = None - if np.any(np.isnan(effect_arr)): - # Some period effects are NaN (unidentified) - cannot compute valid average - # This follows R's default behavior where mean(c(1, 2, NA)) returns NA - avg_att = np.nan - avg_se = np.nan - avg_t_stat = np.nan - avg_p_value = np.nan - avg_conf_int = (np.nan, np.nan) - else: - # All effects identified - compute average normally - avg_att = float(np.mean(effect_arr)) - - # Standard error of average: need to account for covariance - n_post = len(post_periods) - sub_vcov = vcov[np.ix_(post_effect_indices, post_effect_indices)] - avg_var = np.sum(sub_vcov) / (n_post**2) - - if np.isnan(avg_var) or avg_var < 0: - # Vcov has NaN (dropped columns) - propagate NaN - avg_se = np.nan - avg_t_stat = np.nan - avg_p_value = np.nan - avg_conf_int = (np.nan, np.nan) - else: - avg_se = float(np.sqrt(avg_var)) - # Prefer the contrast-specific BM DOF for the post-period average - # when hc2_bm is in use; otherwise fall back to the shared df. - _avg_df = _bm_dof_avg if _bm_dof_avg is not None else df - avg_t_stat, avg_p_value, avg_conf_int = safe_inference( - avg_att, avg_se, alpha=self.alpha, df=_avg_df - ) - - # Count observations (use raw counts to avoid demeaned values from absorb) - n_treated = n_treated_raw - n_control = n_control_raw - - # Hand off to the builders (PR 4c-i): MultiPeriodDiD wraps this into - # its container; the TWFE event-study mode builds the surface from it. + # Hand off to the surface builder (results_base._from_event_study_payload). return _EventStudyCorePayload( periods=list(non_ref_periods), att=np.asarray(pe_att, dtype=float), @@ -2364,382 +2264,14 @@ def _refit_mp_absorb(w_r): ci_upper=np.asarray(pe_hi, dtype=float), df_by_period=es_df_used, reference_period=reference_period, - pre_periods=pre_periods, post_periods=post_periods, alpha=self.alpha, - coefficients=coefficients, - coef_names=list(var_names), vcov=vcov, interaction_indices=interaction_indices, - residuals=residuals, - fitted_values=fitted, - r_squared=r_squared, - avg_att=avg_att, - avg_se=avg_se, - avg_t_stat=avg_t_stat, - avg_p_value=avg_p_value, - avg_conf_int=avg_conf_int, - inference_df=( - float(_avg_df) - if _avg_df is not None and np.isfinite(_avg_df) and _avg_df > 0 - else None - ), - n_obs=len(y), - n_treated=n_treated, - n_control=n_control, - # Report the family that actually produced the SE; may be the - # remapped hc1 under a replicate-weight survey design. - vcov_type=_fit_vcov_type, - cluster_name=cluster_resolved, - n_clusters=( - len(np.unique(effective_cluster_ids)) if effective_cluster_ids is not None else None - ), - conley_lag_cutoff=(self.conley_lag_cutoff if _fit_vcov_type == "conley" else None), - df_convention=self.df_convention, survey_metadata=survey_metadata, ) -def _mpd_results_from_payload(p: _EventStudyCorePayload) -> MultiPeriodDiDResults: - """Wrap the event-study core's payload into the legacy MultiPeriodDiD - container (deleted with the class at the 4.0 cut, PR 4c-ii). - - ``period_effects`` is built in ``p.periods`` order (the core's sorted - non-reference order, the same insertion order the core used before the - PR 4c-i decoupling); per-period scalars round-trip through float64 - arrays (value-identical; ``np.float64`` is a ``float`` subclass). - """ - period_effects = { - per: PeriodEffect( - period=per, - effect=p.att[i], - se=p.se[i], - t_stat=p.t_stat[i], - p_value=p.p_value[i], - conf_int=(p.ci_lower[i], p.ci_upper[i]), - ) - for i, per in enumerate(p.periods) - } - return MultiPeriodDiDResults( - period_effects=period_effects, - avg_att=p.avg_att, - avg_se=p.avg_se, - avg_t_stat=p.avg_t_stat, - avg_p_value=p.avg_p_value, - avg_conf_int=p.avg_conf_int, - n_obs=p.n_obs, - n_treated=p.n_treated, - n_control=p.n_control, - pre_periods=p.pre_periods, - post_periods=p.post_periods, - alpha=p.alpha, - coefficients=dict(zip(p.coef_names, p.coefficients)), - vcov=p.vcov, - residuals=p.residuals, - fitted_values=p.fitted_values, - r_squared=p.r_squared, - reference_period=p.reference_period, - interaction_indices=p.interaction_indices, - survey_metadata=p.survey_metadata, - vcov_type=p.vcov_type, - cluster_name=p.cluster_name, - n_clusters=p.n_clusters, - conley_lag_cutoff=p.conley_lag_cutoff, - df_convention=p.df_convention, - inference_df=p.inference_df, - event_study_df=p.df_by_period, - ) - - -class MultiPeriodDiD(DifferenceInDifferences): - """ - Multi-Period Difference-in-Differences estimator. - - .. deprecated:: 3.9 - MultiPeriodDiD is deprecated and will be removed in 4.0 (ledger - row M-010): use ``TwoWayFixedEffects().fit(..., event_study=True)`` - instead. ``spec="pooled"`` reproduces this estimator's design - exactly (treatment-group dummy + period dummies, no unit fixed - effects - the only spec valid for repeated cross-sections); the - default ``spec="within"`` estimates the unit-FE event study. The - ``EventStudy`` alias is deprecated with this class. - - Extends the standard DiD to handle multiple pre-treatment and - post-treatment time periods, providing period-specific treatment - effects as well as an aggregate average treatment effect. - - Parameters - ---------- - cluster : str, optional - Column name for cluster-robust standard errors. With ``vcov_type="hc1"`` - dispatches to CR1 (Liang-Zeger). With ``vcov_type="hc2_bm"`` dispatches - to CR2 cluster-robust SEs with Bell-McCaffrey Satterthwaite DOF on both - per-period coefficients and the post-period-average ATT contrast (the - latter via the new ``_compute_cr2_bm_contrast_dof`` helper in - ``linalg.py``; matches clubSandwich's - ``Wald_test(test="HTZ")$df_denom`` at atol=1e-10). Weighted CR2-BM - (``survey_design=``) is a separate, still-gated path. - vcov_type : {"classical", "hc1", "hc2", "hc2_bm", "hc3", "conley"}, optional - Variance-covariance family. Defaults to ``"hc1"``. - - - ``"classical"``: non-robust OLS SEs, ``sigma_hat^2 * (X'X)^{-1}``. - - ``"hc1"``: heteroskedasticity-robust HC1 with ``n/(n-k)`` adjustment - (library default). With ``cluster=``, uses CR1 (Liang-Zeger). - - ``"hc2"``: leverage-corrected meat (one-way only). Errors with - ``cluster=``; use ``"hc2_bm"`` without cluster for Bell-McCaffrey. - Effective leverage ``h_ii >= 1 - 1e-8`` produces a warning and - entirely NaN covariance/inference, retaining point estimates. - - ``"hc2_bm"``: one-way HC2 + Imbens-Kolesar (2016) Satterthwaite DOF - per coefficient plus a contrast-aware DOF for the post-period-average - ATT. With ``cluster=``, dispatches to Pustejovsky-Tipton (2018) - CR2 cluster-robust with a Bell-McCaffrey Satterthwaite contrast DOF - on the post-period average (see ``cluster`` above for parity - details). Weighted CR2-BM (``survey_design=``) is still gated. - Unweighted, unclustered fits share HC2's leverage-one NaN guard - for covariance and all contrast DOFs, including the average ATT. - - ``"hc3"``: jackknife-style leverage correction, meat - ``e_i^2 / (1 - h_ii)^2`` (one-way only; errors with ``cluster=``). - A leverage-one observation has no defined HC3 variance and the - vcov fails closed (warning + NaN inference) rather than flooring - ``1 - h_ii``. With ``absorb=``, routes through the full-dummy - design like hc2. - - ``"conley"``: Conley 1999 spatial-HAC sandwich via the panel - block-decomposed form (matches R ``conleyreg`` with - ``lag_cutoff > 0``). Pass ``conley_coords=(lat_col, lon_col)``, - ``conley_cutoff_km=``, and ``conley_lag_cutoff=`` on - the constructor; ``unit=`` must be supplied at fit-time. The - sandwich sums within-period spatial pairs plus within-unit - Bartlett serial pairs (lag=0 excluded to avoid double-counting); - this is NOT a multiplicative product kernel. ``conley_time`` is - auto-derived from the ``time`` column at fit-time and normalized - to dense panel-period codes ``0..T-1`` so ``conley_lag_cutoff`` - always counts panel periods (works for int / datetime64 / - ``pd.Period`` / string encodings). Explicit ``cluster=`` - enables the combined spatial + cluster product kernel - (Wave A #119; cluster must be constant within each unit across - periods). Restrictions: ``survey_design=`` and - ``inference="wild_bootstrap"`` raise on this path - (Phase 5 / follow-up). - alpha : float, default=0.05 - Significance level for confidence intervals. - conley_coords, conley_cutoff_km, conley_metric, conley_kernel, conley_lag_cutoff - Constructor kwargs that take effect when ``vcov_type="conley"``. - ``conley_coords`` is a ``(lat_col, lon_col)`` tuple of column names - on ``data``. ``conley_lag_cutoff`` is the within-unit Bartlett lag - (non-negative int; 0 means within-period spatial only, no serial - component). - - Attributes - ---------- - results_ : MultiPeriodDiDResults - Estimation results after calling fit(). - is_fitted_ : bool - Whether the model has been fitted. - - Examples - -------- - Basic usage with multiple time periods: - - >>> import pandas as pd - >>> from diff_diff import MultiPeriodDiD - >>> - >>> # Create sample panel data with 6 time periods - >>> # Periods 0-2 are pre-treatment, periods 3-5 are post-treatment - >>> data = create_panel_data() # Your data - >>> - >>> # Fit the model - >>> did = MultiPeriodDiD() - >>> results = did.fit( - ... data, - ... outcome='sales', - ... treatment='treated', - ... time='period', - ... post_periods=[3, 4, 5] # Specify which periods are post-treatment - ... ) - >>> - >>> # View period-specific effects - >>> for period, effect in results.period_effects.items(): - ... print(f"Period {period}: {effect.effect:.3f} (SE: {effect.se:.3f})") - >>> - >>> # View average treatment effect - >>> print(f"Average ATT: {results.avg_att:.3f}") - - Notes - ----- - The model estimates: - - Y_it = α + β*D_i + Σ_t γ_t*Period_t + Σ_{t≠ref} δ_t*(D_i × 1{t}) + ε_it - - Where: - - D_i is the treatment indicator - - Period_t are time period dummies (all non-reference periods) - - D_i × 1{t} are treatment-by-period interactions (all non-reference) - - δ_t are the period-specific treatment effects - - The reference period (default: last pre-period) has δ_ref = 0 by construction - - Pre-treatment δ_t test the parallel trends assumption (should be ≈ 0). - Post-treatment δ_t estimate dynamic treatment effects. - The average ATT is computed from post-treatment δ_t only. - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Deprecation shim (row M-010): warn, then defer to DiD's __init__. - - The forwarding ``*args/**kwargs`` keeps the constructor surface - identical to :class:`DifferenceInDifferences` with zero drift risk; - the ``__signature__`` mirror below restores introspection for - ``get_params``/``set_params`` (``BaseEstimator._param_names`` - rejects VAR_* parameter kinds) and ``inspect.signature`` callers. - Known, accepted side effects: ``set_params`` re-emits the warning - (its transactional probe re-instantiates), and static type - checkers lose constructor-argument checking for this class during - the 3.9 window (waiver recorded in DEFERRED.md's decision record; - runtime validation is unchanged - ``super().__init__`` validates - eagerly). - """ - warnings.warn(_MPD_DEPRECATION_MSG, FutureWarning, stacklevel=2) - super().__init__(*args, **kwargs) - - def fit( # type: ignore[override] - self, - data: pd.DataFrame, - outcome: str, - treatment: str, - time: str, - post_periods: Optional[List[Any]] = None, - covariates: Optional[List[str]] = None, - fixed_effects: Optional[List[str]] = None, - absorb: Optional[List[str]] = None, - reference_period: Any = None, - unit: Optional[str] = None, - survey_design=None, - ) -> MultiPeriodDiDResults: - """ - Fit the Multi-Period Difference-in-Differences model. - - Parameters - ---------- - data : pd.DataFrame - DataFrame containing the outcome, treatment, and time variables. - outcome : str - Name of the outcome variable column. - treatment : str - Name of the treatment group indicator column (0/1). Should be a - time-invariant ever-treated indicator (D_i = 1 for all periods of - treated units). If treatment is time-varying (D_it), pre-period - interaction coefficients will be unidentified. - time : str - Name of the time period column (can have multiple values). - post_periods : list - List of time period values that are post-treatment. - All other periods are treated as pre-treatment. - covariates : list, optional - List of covariate column names to include as linear controls. - Names must not collide with reserved structural terms (``const``, - the treatment column name, ``period_{p}`` dummies, the - ``{treatment}:period_{p}`` interactions, fixed-effect dummy names, or - internal working columns) and must be unique; a collision or - duplicate raises ``ValueError`` (it would otherwise silently - overwrite a structural coefficient). - fixed_effects : list, optional - List of categorical column names to include as fixed effects. - absorb : list, optional - List of categorical column names for high-dimensional fixed effects. - reference_period : any, optional - The reference (omitted) time period for the period dummies. - Defaults to the last pre-treatment period (e=-1 convention). - unit : str, optional - Name of the unit identifier column. When provided, checks whether - treatment timing varies across units and warns if staggered adoption - is detected (suggests CallawaySantAnna instead). Required when - ``vcov_type="conley"`` (the panel block-decomposed sandwich computes - a per-unit serial sum). For other ``vcov_type`` values, use the - ``cluster`` parameter for cluster-robust SEs. - survey_design : SurveyDesign, optional - Survey design specification for design-based inference. When provided, - uses Taylor Series Linearization for variance estimation and - applies sampling weights to the regression. - - Returns - ------- - MultiPeriodDiDResults - Object containing period-specific and average treatment effects. - - Raises - ------ - ValueError - If required parameters are missing or data validation fails, or if - a covariate name collides with a reserved structural term name or - duplicates another covariate. - """ - # Fall back to analytical inference if wild bootstrap requested - # (must happen before _resolve_survey_for_fit which rejects bootstrap+survey). - # SKIP the warning on the Conley path — the Conley validator below - # raises NotImplementedError for wild_bootstrap + Conley, so emitting - # the analytical-fallback warning first would produce contradictory - # guidance on the same call (warn "falling back" + raise "not - # supported"). The Conley raise takes precedence. Codex CI R11 P3. - # NOTE: ``p_val_type`` is inherited from DifferenceInDifferences but is - # inert here — MultiPeriodDiD has no wild-bootstrap path (it falls back - # to analytical inference below), so the parameter has no effect. - effective_inference = self.inference - if self.inference == "wild_bootstrap" and self.vcov_type != "conley": - warnings.warn( - "Wild bootstrap inference is not yet supported for MultiPeriodDiD. " - "Using analytical inference instead.", - UserWarning, - ) - effective_inference = "analytical" - - payload = self._fit_event_study_core( - data, - outcome, - treatment, - time, - post_periods, - covariates, - fixed_effects, - absorb, - reference_period, - unit, - survey_design, - effective_inference, - _frame_offset=1, - ) - self.results_ = _mpd_results_from_payload(payload) - self._coefficients = payload.coefficients - self._vcov = payload.vcov - self.is_fitted_ = True - return self.results_ - - def summary(self) -> str: - """ - Get summary of estimation results. - - Returns - ------- - str - Formatted summary. - """ - if not self.is_fitted_: - raise RuntimeError("Model must be fitted before calling summary()") - assert self.results_ is not None - return self.results_.summary() - - -# Mirror DiD's constructor signature onto the deprecation shim so -# introspection keeps working: BaseEstimator._param_names raises on -# *args/**kwargs parameter kinds, and inspect.signature honors an explicit -# __signature__ - so get_params/set_params (and the roster contract in -# tests/test_base_estimator.py) see exactly DiD's parameter surface, with -# zero drift risk when DifferenceInDifferences gains a constructor param. -import inspect as _inspect # noqa: E402 - -MultiPeriodDiD.__init__.__signature__ = _inspect.signature( # type: ignore[attr-defined] - DifferenceInDifferences.__init__ -) - - # Re-export estimators from submodules for backward compatibility # These can also be imported directly from their respective modules: # - from diff_diff.twfe import TwoWayFixedEffects @@ -2751,7 +2283,6 @@ def summary(self) -> str: __all__ = [ "DifferenceInDifferences", - "MultiPeriodDiD", "TwoWayFixedEffects", "SyntheticDiD", "SyntheticControl", diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index c1e0645fd..5c15d0c37 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -339,7 +339,6 @@ supported / out of scope; `warn` supported but with documented caveats; | Estimator | binary absorbing | staggered | continuous | triple-diff | never-treated required | covariate adjustment | few-treated (synthetic) | heterogeneous adoption | clustered SE | |---|---|---|---|---|---|---|---|---|---| | `DifferenceInDifferences` | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | -| `MultiPeriodDiD` (deprecated 3.9 → `TwoWayFixedEffects` `event_study=True`) | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | | `TwoWayFixedEffects` | ✓ | warn | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | | `CallawaySantAnna` | ✓ | ✓ | ✗ | ✗ | partial | ✓ | ✗ | ✗ | ✓ | | `SunAbraham` | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | @@ -474,7 +473,7 @@ case; the choice between them is mostly about output shape: When `is_staggered == False` and `n_periods > 2`, event-study dynamics can be estimated but cohort-mixing bias is moot: -- `TwoWayFixedEffects(...).fit(..., event_study=True)` - per-period effects, standard event-study plot (`MultiPeriodDiD` is its deprecated predecessor; `spec='pooled'` reproduces it). +- `TwoWayFixedEffects(...).fit(..., event_study=True)` - per-period effects, standard event-study plot (`spec='pooled'` runs the design of the `MultiPeriodDiD` class removed at the 4.0 cut). - `TwoWayFixedEffects` with event-time dummies - similar output, no forbidden comparisons because there is only one cohort. @@ -572,7 +571,7 @@ When `has_never_treated == False`: not-yet-treated units as the control pool. - `ChaisemartinDHaultfoeuille` - constructs switchers vs. non-switchers directly; no never-treated requirement. -- TWFE / `MultiPeriodDiD` / `ImputationDiD` / `TwoStageDiD` / +- TWFE (static or `event_study=True`) / `ImputationDiD` / `TwoStageDiD` / `StackedDiD` - use the last-treated or untreated- until-late units as implicit controls; estimators do not error, but consider whether the implicit control structure is what you want. @@ -1131,8 +1130,9 @@ section is the API-reference index. - `equivalence_test_trends(df, ...)` - Bilinski-Hatfield-style equivalence test (alternative framing of the PT test). - `compute_pretrends_power(results, ...)` - standalone power analysis - for the PT test; takes a fitted `MultiPeriodDiDResults` (or - compatible event-study results object), not raw DataFrame. Useful + for the PT test; takes a fitted event-study results object (CS / SA + results or an `EventStudyResults` container, e.g. the TWFE + `event_study=True` surface), not raw DataFrame. Useful when `min_pre_periods` is small. ### Sensitivity / robustness diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 429c1775a..ff4ad9172 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -121,14 +121,14 @@ twfe.fit( data: pd.DataFrame, outcome: str, treatment: str, - post: str, # 0/1 post dummy (renamed from time=, which warns through 3.9) + post: str, # the static 0/1 post dummy; time= in static mode raises since 4.0 (M-083) unit: str, covariates: list[str] = None, event_study: bool = False, # per-period event study returning EventStudyResults - spec: str = "within", # "within" (unit-FE event study) | "pooled" (the MultiPeriodDiD design) + spec: str = "within", # "within" (unit-FE event study) | "pooled" (the pre-4.0 pooled event-study design) reference_period: Any = None, # ES mode: omitted period (default: last pre-period, e=-1) post_periods: list = None, # ES mode: post-treatment period values - time: str = ..., # ES mode: the CALENDAR column (keyword); static: deprecated alias for post + time: str = ..., # ES mode: the CALENDAR column (keyword); static mode: raises - pass post= ) -> DiDResults | EventStudyResults ``` @@ -141,9 +141,9 @@ twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome='y', treatment='treated', post='post', unit='unit_id') results.print_summary() -# Event-study mode (absorbs the deprecated MultiPeriodDiD; spec="pooled" -# reproduces its design; wild bootstrap raises in this mode; the unit -# auto-cluster applies with the static carve-outs) +# Event-study mode (the MultiPeriodDiD class was removed at the 4.0 cut; +# spec="pooled" runs its design; wild bootstrap raises in this mode; the +# unit auto-cluster applies with the static carve-outs) es = twfe.fit(data, outcome='y', treatment='treated', unit='unit_id', event_study=True, time='period', post_periods=[4, 5, 6, 7]) print(es.summary()) @@ -151,50 +151,32 @@ print(es.summary()) **Note:** TWFE can be biased with staggered treatment timing and heterogeneous effects. Consider CallawaySantAnna, SunAbraham, or ImputationDiD for staggered designs. -### MultiPeriodDiD +### EventStudyResults.period_effects (compatibility view) -DEPRECATED (3.9, removed in 4.0; ledger row M-010): use -`TwoWayFixedEffects(...).fit(..., event_study=True)` - `spec="pooled"` -reproduces this design exactly. Constructing MultiPeriodDiD (or its -EventStudy alias) emits a FutureWarning. Event-study style DiD with -period-specific treatment effects. Inherits from DifferenceInDifferences. +The `MultiPeriodDiD` class, `MultiPeriodDiDResults`, `PeriodEffect` and the +`EventStudy` alias were removed at the 4.0 cut (ledger rows M-010/M-011/M-012/ +M-060); `TwoWayFixedEffects(...).fit(..., event_study=True)` is the estimator. +For code that still reads the old per-period dict, the `EventStudyResults` +returned by the TWFE event-study mode exposes `period_effects` as a deprecated +compatibility VIEW (ledger row M-016, removed in 5.0): -```python -MultiPeriodDiD( - cluster: str | None = None, - alpha: float = 0.05, - df_convention: str = "residual", # Analytical t/p/CI df: "residual" (default), "cluster" (G-1), or "normal" (z); flips at v4 -) -``` - -**Alias:** `EventStudy` - -**fit() parameters:** - -```python -mp_did.fit( - data: pd.DataFrame, - outcome: str, - treatment: str, - time: str, - post_periods: list = None, # Which periods are post-treatment - covariates: list[str] = None, - fixed_effects: list[str] = None, - absorb: list[str] = None, - reference_period: Any = None, # Reference period (default: last pre-period) -) -> MultiPeriodDiDResults -``` - -**Usage:** +- Reading it emits a `FutureWarning`; `to_dataframe()` (one row per + `event_time`, reference row included) is the successor. +- Calendar-time surfaces only (`time_scale="calendar"`, i.e. the TWFE + event-study mode); on a relative-time surface the attribute raises + `AttributeError`, so `hasattr(es, "period_effects")` is False there. +- A dict keyed by calendar period with the reference period OMITTED (as the + old dict was). Each value is a read-only record with `period`, `effect`, + `se`, `t_stat`, `p_value`, `conf_int`, `is_significant` and + `significance_stars`; `is_significant` is evaluated at the fit's `alpha` + (NaN p-value -> False), not the removed record's fixed 0.05. ```python -from diff_diff import MultiPeriodDiD, plot_event_study - -did = MultiPeriodDiD() -results = did.fit(data, outcome='sales', treatment='treated', - time='period', post_periods=[4, 5, 6, 7]) -results.print_summary() -plot_event_study(results) +es = twfe.fit(data, outcome='y', treatment='treated', unit='unit_id', + event_study=True, time='period', post_periods=[4, 5, 6, 7]) +table = es.to_dataframe() # successor: columnar per-event-time table +pe = es.period_effects # FutureWarning; {period: record}, reference omitted +pe[5].effect, pe[5].conf_int ``` ### CallawaySantAnna @@ -1788,47 +1770,6 @@ Returned by `DifferenceInDifferences.fit()` and `TwoWayFixedEffects.fit()`. **Properties:** `is_significant`, `significance_stars` -### MultiPeriodDiDResults - -Returned by `MultiPeriodDiD.fit()`. - -| Attribute | Type | Description | -|-----------|------|-------------| -| `period_effects` | `dict[Any, PeriodEffect]` | Period-specific effects (pre and post) | -| `avg_att` | `float` | Average ATT across post-periods | -| `avg_se` | `float` | SE of average ATT | -| `avg_t_stat` | `float` | T-statistic for average ATT | -| `avg_p_value` | `float` | P-value for average ATT | -| `avg_conf_int` | `tuple[float, float]` | CI for average ATT | -| `n_obs` | `int` | Number of observations | -| `n_treated` | `int` | Number of treated observations | -| `n_control` | `int` | Number of control observations | -| `pre_periods` | `list` | Pre-treatment period identifiers | -| `post_periods` | `list` | Post-treatment period identifiers | -| `reference_period` | `Any` | Reference (omitted) period | -| `r_squared` | `float` | R-squared | -| `vcov` | `np.ndarray` | Variance-covariance matrix | -| `interaction_indices` | `dict` | Period to VCV column index mapping | - -**Methods:** `summary()`, `print_summary()`, `get_effect(period)`, `to_dict()`, `to_dataframe()` - -**Properties:** `pre_period_effects`, `post_period_effects`, `is_significant`, `significance_stars` - -### PeriodEffect - -Individual period treatment effect (used in MultiPeriodDiDResults). - -| Attribute | Type | Description | -|-----------|------|-------------| -| `period` | `Any` | Time period identifier | -| `effect` | `float` | Treatment effect estimate | -| `se` | `float` | Standard error | -| `t_stat` | `float` | T-statistic | -| `p_value` | `float` | P-value | -| `conf_int` | `tuple[float, float]` | Confidence interval | - -**Properties:** `is_significant`, `significance_stars` - ### CallawaySantAnnaResults Returned by `CallawaySantAnna.fit()`. @@ -2461,7 +2402,7 @@ All plotting functions return a matplotlib `Figure` object. from diff_diff import plot_event_study plot_event_study( - results, # MultiPeriodDiDResults, CS, SA, BJS, TwoStageDiD, StackedDiD, or DataFrame + results, # EventStudyResults (TWFE event-study surface), CS, SA, BJS, TwoStageDiD, StackedDiD, or DataFrame effects=None, # Manual dict of effects (alternative to results) se=None, # Manual dict of SEs periods=None, @@ -2825,8 +2766,8 @@ modes: - **Cross-sectional:** Direct `compute_robust_vcov` / `LinearRegression` on a single-period design. - **Panel block-decomposed** (matches R `conleyreg` with `lag_cutoff > 0`): - `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` with - `vcov_type="conley"` and `conley_lag_cutoff=`. The sandwich sums + `DifferenceInDifferences` / `TwoWayFixedEffects` (static or event-study + mode) with `vcov_type="conley"` and `conley_lag_cutoff=`. The sandwich sums within-period spatial pairs plus within-unit Bartlett serial pairs (excluding lag=0 to avoid double-counting); NOT a multiplicative product kernel. @@ -2834,7 +2775,7 @@ modes: `DifferenceInDifferences(vcov_type="conley").fit(..., unit="")` is supported (Wave A #118). `unit` is a fit-time kwarg (NOT on `__init__`; unused unless Conley is set; not part of `get_params()` / `set_params()`) -mirroring `MultiPeriodDiD.fit(unit=...)` / `TwoWayFixedEffects.fit(unit=...)`. +mirroring `TwoWayFixedEffects.fit(unit=...)`. ```python import numpy as np @@ -2854,11 +2795,11 @@ se = np.sqrt(np.diag(reg.vcov_)) # Panel design: TWFE with within-unit Bartlett serial HAC. # Static TWFE's `post` column is a binary post indicator (treatment * post -# interaction; `time=` is its deprecated alias through 3.9); only numeric -# encodings are supported on this surface. `conley_lag_cutoff=1` includes -# the cross-period pair under the Bartlett taper. For multi-period panels, -# use the TWFE event-study mode (`event_study=True, time=`; -# MultiPeriodDiD is deprecated). +# interaction; passing `time=` in static mode raises since 4.0 - M-083); only +# numeric encodings are supported on this surface. `conley_lag_cutoff=1` +# includes the cross-period pair under the Bartlett taper. For multi-period +# panels, use the TWFE event-study mode (`event_study=True, time=`; the MultiPeriodDiD class was removed at the 4.0 cut). res = TwoWayFixedEffects( vcov_type="conley", conley_coords=("lat", "lon"), # column names on `data` @@ -2866,8 +2807,8 @@ res = TwoWayFixedEffects( conley_lag_cutoff=1, # within-unit Bartlett, lag 1 panel period ).fit(data, outcome="y", treatment="treated", post="post", unit="unit_id") -# Panel design: multi-period event study (TWFE event-study mode; the -# deprecated MultiPeriodDiD behaves identically via the shared core). +# Panel design: multi-period event study (TWFE event-study mode; the same +# shared core that ran the removed MultiPeriodDiD class). # The design builds period dummies (NOT a treated * time product), so # `time` can be any orderable encoding — int years (2020, 2021, ...), # YYYYMM (202012, 202101, ...), datetime64, pd.Period, strings. @@ -2909,14 +2850,15 @@ combined_res = TwoWayFixedEffects( **Note on `conley_lag_cutoff` semantics:** the lag is counted in **panel periods** (number of distinct sorted values in the `time` column), NOT in raw-label differences. Internally, time labels are normalized to dense codes -`0..T-1` via `np.unique(return_inverse=True)`. For example, MultiPeriodDiD -with `time` values `(2020, 2021, 2022)` and `(202012, 202101, 202102)` both +`0..T-1` via `np.unique(return_inverse=True)`. For example, the TWFE +event-study mode with `time` values `(2020, 2021, 2022)` and `(202012, 202101, +202102)` both produce the same codes `(0, 1, 2)` and the same lag matrix. **TWFE caveat:** the time-label normalization runs inside `_compute_conley_vcov`, but TWFE's own design step `_treatment_post = treated * time` requires numeric `time`; non-numeric labels (datetime64, pd.Period, strings) are TWFE-incompatible -end-to-end. Use MultiPeriodDiD if you need datetime/Period/string time -labels. **Deviation from R `conleyreg`:** R uses raw `time` values directly +end-to-end. Use the TWFE event-study mode (`event_study=True, +time=`) if you need datetime/Period/string time labels. **Deviation from R `conleyreg`:** R uses raw `time` values directly in the lag computation, which silently mishandles non-dense encodings. diff-diff is the more robust default; for bit-exact R parity, pass `time` as a dense integer index. @@ -2950,11 +2892,11 @@ recommends a sensitivity grid (e.g., 50, 100, 200, 500 km) and reporting the SE range. **Restrictions in this release:** -- `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` `+ vcov_type="conley"` without `conley_lag_cutoff` → `ValueError` (no defensible default; explicit user choice required). -- `DifferenceInDifferences` / `MultiPeriodDiD` `+ vcov_type="conley"` without `unit=` at fit-time → `ValueError`. +- `DifferenceInDifferences` / `TwoWayFixedEffects` `+ vcov_type="conley"` without `conley_lag_cutoff` → `ValueError` (no defensible default; explicit user choice required). +- `DifferenceInDifferences` `+ vcov_type="conley"` without `unit=` at fit-time → `ValueError`. - Combining `vcov_type="conley"` with explicit `cluster=` applies the combined spatial + cluster product kernel (Wave A #119). On the panel path the cluster must be constant within each unit across periods (validator raises `ValueError` otherwise). TWFE's default auto-cluster on the Conley path is silently dropped; users opt into the combined kernel explicitly. -- `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` `(vcov_type="conley", inference="wild_bootstrap")` → `NotImplementedError` (wild bootstrap does not consume the analytical sandwich). -- `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` `(vcov_type="conley")` + `survey_design=` → `NotImplementedError` (estimator-level gate; weighted spatial-HAC under probability sampling is an open methodological question). +- `DifferenceInDifferences` / `TwoWayFixedEffects` `(vcov_type="conley", inference="wild_bootstrap")` → `NotImplementedError` (wild bootstrap does not consume the analytical sandwich). +- `DifferenceInDifferences` / `TwoWayFixedEffects` `(vcov_type="conley")` + `survey_design=` → `NotImplementedError` (estimator-level gate; weighted spatial-HAC under probability sampling is an open methodological question). - `SyntheticDiD(vcov_type="conley")` → `TypeError` (uses bootstrap, not analytical sandwich). - Generic `LinearRegression(vcov_type="conley", survey_design=...)` → `NotImplementedError`. Generic `LinearRegression` / `compute_robust_vcov` Conley rejects `weights=` for any `weight_type` (`pweight` / `aweight` / `fweight`) → `NotImplementedError` (weighted Conley is not implemented on the generic linalg surface; `compute_robust_vcov` does not accept `survey_design=`, the survey-design surface is `LinearRegression` only). The `pweight` / `survey_design` subset additionally reflects an open methodological question — no canonical extension of Conley (1999) exists for weighted spatial-HAC under probability sampling. - A sparse k-d-tree fast path auto-activates for `n > 5_000` with `conley_kernel="bartlett"` AND `conley_metric` in `{"haversine", "euclidean"}` (Wave A #120); callable metrics and uniform kernel fall back to the dense path. `n > 20_000` with the dense fallback still emits a memory-OOM `UserWarning`. @@ -2991,7 +2933,7 @@ DIFF_DIFF_BACKEND=rust pytest # Force Rust (fail if unavailable) |----------|----------------------| | Classic 2x2 design (one treated group, one time split) | `DifferenceInDifferences` | | Panel data with unit + time FE | `TwoWayFixedEffects` | -| Event study with multiple periods (simultaneous adoption) | `TwoWayFixedEffects` with `event_study=True` (`spec="pooled"` reproduces the deprecated `MultiPeriodDiD`) | +| Event study with multiple periods (simultaneous adoption) | `TwoWayFixedEffects` with `event_study=True` (`spec="pooled"` runs the design of the `MultiPeriodDiD` class removed at the 4.0 cut) | | Staggered treatment timing | `CallawaySantAnna`, `ImputationDiD`, or `SunAbraham` | | Few treated units / synthetic control | `SyntheticDiD` | | Very few treated units, exact small-sample inference (valid down to one treated unit) | `LWDiD` | diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index f54ef49ce..f70b18a81 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -87,7 +87,7 @@ results.overall_se # Standard error # The canonical prefixed names remain the documented and computed # surface; the flat aliases are pure read-throughs for compatibility # with adapters that `getattr(res, "se", None)`-style query inference. -results.att # alias of overall_att / avg_att (or overall_att for ContinuousDiD ATT side) +results.att # alias of overall_att (or overall_att for ContinuousDiD ATT side) results.se # alias of overall_se / avg_se (or overall_att_se for ContinuousDiD ATT side) ``` @@ -359,8 +359,8 @@ print(results.summary()) ```python from diff_diff import TwoWayFixedEffects -# TwoWayFixedEffects event-study mode (MultiPeriodDiD is deprecated in -# 3.9; spec='pooled' reproduces its design). post_periods= is REQUIRED: +# TwoWayFixedEffects event-study mode (the MultiPeriodDiD class was removed +# at the 4.0 cut; spec='pooled' runs its design). post_periods= is REQUIRED: # the treatment boundary cannot be inferred from a time-invariant # ever-treated indicator. es = TwoWayFixedEffects(cluster='unit_id') @@ -398,7 +398,7 @@ This step is CRITICAL and most often skipped. Run at least one of: ### HonestDiD (Rambachan & Roth 2023) - recommended Bounds on the treatment effect under violations of parallel trends. -Works with MultiPeriodDiD (deprecated - the TWFE event-study surface also admits), CallawaySantAnna, and ChaisemartinDHaultfoeuille +Works with the TwoWayFixedEffects event-study surface (`event_study=True`), CallawaySantAnna, and ChaisemartinDHaultfoeuille (dCDH) results, plus StackedDiD via the post-fit `results.aggregate('event_study')` container (needs `kappa_pre >= 2` so estimated pre-periods exist). diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index 8dc21607f..4ac55f68c 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -20,7 +20,7 @@ diagnostic steps produces unreliable results. 3. **Test parallel trends** — simple 2x2: `check_parallel_trends()`, `equivalence_test_trends()`; staggered: inspect CS event-study pre-period coefficients (generic PT tests are invalid for staggered designs). Insignificant pre-trends do NOT prove PT holds. 4. **Choose estimator** — staggered adoption → CS/SA/BJS (NOT plain TWFE); few treated units → SDiD; factor confounding → TROP; simple 2x2 → DiD. Run `BaconDecomposition` to diagnose TWFE bias. 5. **Estimate** — `estimator.fit(data, ...)`. Always print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer — for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). -6. **Sensitivity analysis** — `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; the TwoWayFixedEffects `event_study=True` surface and a StackedDiD `results.aggregate('event_study')` container also admit - Stacked needs `kappa_pre >= 2` so estimated pre-periods exist), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs. +6. **Sensitivity analysis** — `compute_honest_did(results)` for bounds under PT violations (the TwoWayFixedEffects `event_study=True` surface, CS, or dCDH natively; a StackedDiD `results.aggregate('event_study')` container also admits - Stacked needs `kappa_pre >= 2` so estimated pre-periods exist), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs. 7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (the fit-time `aggregate=`/`balance_e=` keywords were removed at 4.0 and `results.event_study_effects`/`group_effects` are None on every CS fit; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; BOOTSTRAPPED CS fits included: the recompute levels `'event_study'`/`'group'` REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; the container's vcov is the covariance of the bootstrap draws, so its diagonal is the bootstrap se**2), while `.aggregate('simple')` and, where supported, `.aggregate('total')` relay the stored bootstrap inference (NaN df column); only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message). NEW in 3.10: `.aggregate('total')` on CS/EfficientDiD/ImputationDiD/TwoStageDiD - the estimator-owned total incremental outcome (exact relay C x overall over the finite-masked complete-case support; single target='total' row; bootstrap-safe RELAY; panel non-survey fits only - repeated-cross-section and declared survey_design fits raise NotImplementedError with the reason); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; StackedDiD: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; the fit-time keywords were removed at 4.0; on bootstrapped EDiD fits the recompute levels REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message) while `.aggregate('simple')` relays the stored bootstrap inference; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/TwoStageDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the percentile ES/group surfaces are computed at fit and `.aggregate('event_study')`/`.aggregate('group')` RELAY them (4.0; `balance_e=` unavailable there) while `.aggregate('simple')` relays the stored bootstrap inference; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit on analytical fits and relays the percentile surface computed at fit on bootstrapped fits (4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation. 8. **Robustness** — compare 2-3 estimators (CS vs SA vs BJS), MUST report with and without covariates (shows whether conditioning drives identification), present pre-trends and sensitivity bounds. @@ -59,7 +59,6 @@ The site is organized into 5 sections, each with a landing page: - [DifferenceInDifferences](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Basic 2x2 DiD with robust/cluster-robust SEs, wild bootstrap, formula interface, and fixed effects - [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Panel data DiD with unit and time fixed effects via within-transformation or dummies; `event_study=True` estimates per-period effects (spec='within'|'pooled') returning the unified EventStudyResults surface -- [MultiPeriodDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Event study design with period-specific treatment effects for dynamic analysis (deprecated 3.9 - use TwoWayFixedEffects event_study=True; spec='pooled' reproduces this design) - [CallawaySantAnna](https://diff-diff.readthedocs.io/en/stable/api/staggered.html): Callaway & Sant'Anna (2021) group-time ATT estimator for staggered adoption with aggregation - [ChaisemartinDHaultfoeuille](https://diff-diff.readthedocs.io/en/stable/api/chaisemartin_dhaultfoeuille.html): de Chaisemartin & D'Haultfœuille (2020/2022) estimator for **reversible (non-absorbing) treatments** with multi-horizon event study (`L_max`), normalized effects, cost-benefit delta, sup-t bands, and dynamic placebos. The most general option for treatments that switch on AND off (LPDiD/TROP `non_absorbing` also handle non-absorbing treatment under stronger assumptions). Alias `DCDH`. - [SunAbraham](https://diff-diff.readthedocs.io/en/stable/api/staggered.html): Sun & Abraham (2021) interaction-weighted estimator for heterogeneity-robust event studies @@ -95,7 +94,7 @@ The site is organized into 5 sections, each with a landing page: - [Pre-Trends Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/pretrends.html): Roth (2022) Section II.A-B no-individually-significant (NIS) box-probability pretest power + minimum detectable violation; `pretest_form='nis'` (default) implements the paper's primary form, `pretest_form='wald'` retained as paper-supported alternative (Propositions 1+3+4 all apply); linear-violation MDV in Roth's γ units when relative-time labels are threaded through `fit()`; full Σ_22 routing on non-bootstrap CallawaySantAnna and SunAbraham adapters and on admitted CS-/StackedDiD-sourced `aggregate('event_study')` containers (StackedDiD persists its ES VCV in every inference mode) - [Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/power.html): Analytical and simulation-based power analysis — MDE, sample size, power curves for study design - [MMM Calibration Export](https://diff-diff.readthedocs.io/en/stable/api/mmm.html): Assemble Marketing Mix Model calibration inputs from experiment results. Two input routes: explicit already-scoped numbers, or the pinned `AggregationResult` container from post-fit `results.aggregate('simple'|'group')` via `aggregation_result=` + `scale=`, or a `results.aggregate('total')` container ALONE (its single row is the estimator-owned total; scale is rejected - for overall totals this supersedes scale="auto") (effect = att x scale per row, in `to_dataframe()` order; `scale="auto"` reads the container's treated-obs count and is honored ONLY for ImputationDiD/TwoStageDiD fits - it acknowledges an additive-level outcome, an unweighted fit, and fully identified effects; other estimators and raw results objects fail closed). `to_pymc_marketing_lift_test(channel, x, delta_x, delta_y=, sigma=, aggregation_result=, scale=, dims=, on_wrong_sign=)` builds the PyMC-Marketing/prophetverse lift-test DataFrame with sign/zero/positivity guards. `to_meridian_roi_prior(incremental_outcome=, incremental_outcome_se=, aggregation_result=, scale=, spend, parameter="roi_m"|"mroi_m", se_widening=)` builds Google Meridian lognormal ROI priors (spend-weighted pooling, lognormal parity with `lognormal_dist_from_mean_std`, channel- and time-scoped `.to_code()` snippet setting `media_prior_type` - time scope via `meridian_calibration_mask(media_times=, media_channels=, channel=, window=)`, which builds the boolean (n_media_times, n_media_channels) mask (window = inclusive (start, end) tuple or explicit-labels list; experiment channel True on the window, every other channel ALL-TRUE per Meridian's documented convention; roi_m priors only - Meridian rejects the mask for mroi_m) and whose array `.to_code()` serializes into the snippet, or via a mask expression string, or full_model_window=True; widen `se_widening` when pooling a group-level container's same-fit cohorts). Pure numpy/pandas; imports no MMM package; never calls `aggregate()` itself. -- Conley spatial HAC SE (`vcov_type="conley"`) on cross-sectional `LinearRegression` / `compute_robust_vcov` PLUS panel `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` (with `conley_lag_cutoff=` for within-unit Bartlett temporal HAC) — Conley (1999) spatial-correlation-aware SEs with haversine/euclidean/callable distance metric and Bartlett/uniform spatial kernel; panel path uses the R `conleyreg`-form block-decomposed sandwich (within-period spatial + within-unit Bartlett serial, same-time excluded); parity vs R `conleyreg` (Düsterhöft 2021) on cross-sectional AND panel `lag_cutoff > 0` fixtures. Combining with explicit `cluster=` applies the combined spatial + cluster product kernel `K_total[i,j] = K_space · 1{c_i = c_j}` (cluster must be constant within each unit across periods on the panel path; validator-enforced). DiD takes `unit=` as a fit-time kwarg when `vcov_type="conley"` (not on `__init__`). Sparse k-d-tree fast path auto-activates for `n > 5_000` with bartlett kernel + haversine/euclidean metric +- Conley spatial HAC SE (`vcov_type="conley"`) on cross-sectional `LinearRegression` / `compute_robust_vcov` PLUS panel `DifferenceInDifferences` / `TwoWayFixedEffects` (with `conley_lag_cutoff=` for within-unit Bartlett temporal HAC) — Conley (1999) spatial-correlation-aware SEs with haversine/euclidean/callable distance metric and Bartlett/uniform spatial kernel; panel path uses the R `conleyreg`-form block-decomposed sandwich (within-period spatial + within-unit Bartlett serial, same-time excluded); parity vs R `conleyreg` (Düsterhöft 2021) on cross-sectional AND panel `lag_cutoff > 0` fixtures. Combining with explicit `cluster=` applies the combined spatial + cluster product kernel `K_total[i,j] = K_space · 1{c_i = c_j}` (cluster must be constant within each unit across periods on the panel path; validator-enforced). DiD takes `unit=` as a fit-time kwarg when `vcov_type="conley"` (not on `__init__`). Sparse k-d-tree fast path auto-activates for `n > 5_000` with bartlett kernel + haversine/euclidean metric ## Tutorials diff --git a/diff_diff/honest_did.py b/diff_diff/honest_did.py index 0273fa1b5..70d99e12a 100644 --- a/diff_diff/honest_did.py +++ b/diff_diff/honest_did.py @@ -27,9 +27,6 @@ from diff_diff._base import BaseEstimator from diff_diff.aggregation import resolve_inference_df -from diff_diff.results import ( - MultiPeriodDiDResults, -) from diff_diff.results_base import Diagnostic, _coverage_pct, _validate_vcov_subblock from diff_diff.utils import _get_critical_value @@ -583,8 +580,8 @@ def _extract_calendar_container_params( ) -> Tuple[np.ndarray, np.ndarray, int, int, List[Any], List[Any], Optional[float]]: """Calendar-scale container branch (TWFE event-study mode, row M-010). - Reconstructs EXACTLY the inputs the native ``MultiPeriodDiDResults`` - branch reads - the authoritative pre/post partition (from the + Reconstructs EXACTLY the inputs the former native per-period-dict + branch read - the authoritative pre/post partition (from the container's ``post_periods`` provenance, never derived positionally), the per-period estimates, the event-study vcov sub-block, and the scalar ``df_survey`` - and applies the same row filter and @@ -617,8 +614,7 @@ def _extract_calendar_container_params( raise TypeError( "HonestDiD accepts calendar-scale EventStudyResults containers " "from the TwoWayFixedEffects event-study mode only (got " - f"source={surface.source!r}). For MultiPeriodDiD pass the " - "native MultiPeriodDiDResults object." + f"source={surface.source!r})." ) if surface.post_periods is None: raise TypeError( @@ -692,7 +688,7 @@ def _extract_calendar_container_params( stacklevel=5, ) - # Native-branch reconstruction. Row filter mirrors the MPD branch: + # Native-branch reconstruction. Row filter mirrors the former native branch: # non-reference rows with finite EFFECT and finite, positive SE (the # native branch requires both - a NaN/Inf coefficient with a positive # SE must not reach the LP/optimizer). @@ -855,7 +851,7 @@ def _extract_container_params( "surfaces) only " f"(got source={surface.source!r}). For other estimators pass " "the native results object where supported " - "(MultiPeriodDiDResults, CallawaySantAnnaResults, or " + "(CallawaySantAnnaResults or " "ChaisemartinDHaultfoeuilleResults); " "EfficientDiDResults, ImputationDiDResults and " "ContinuousDiDResults containers are rejected BY DESIGN " @@ -1144,14 +1140,14 @@ def _extract_container_params( def _extract_event_study_params( - results: Union[MultiPeriodDiDResults, Any], + results: Any, ) -> Tuple[np.ndarray, np.ndarray, int, int, List[Any], List[Any], Optional[float]]: """ Extract event study parameters from results objects. Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults + results : CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults Estimation results with event study structure, or the unified event-study container produced by ``CallawaySantAnnaResults.aggregate('event_study')``, @@ -1184,531 +1180,411 @@ def _extract_event_study_params( if isinstance(results, EventStudyResults): return _extract_container_params(results) - if isinstance(results, MultiPeriodDiDResults): - # Extract from MultiPeriodDiD - pre_periods = results.pre_periods - post_periods = results.post_periods - - # Filter periods with finite effects/SEs, maintaining pre-then-post - # order. se <= 0 drops too: safe_inference treats it as undefined - # inference, and admitting such a row would launder NaN source - # inference into finite sensitivity bounds (mirrors the CS and - # container branches and pretrends). - finite_periods = { - p - for p in results.period_effects.keys() - if np.isfinite(results.period_effects[p].effect) - and np.isfinite(results.period_effects[p].se) - and results.period_effects[p].se > 0 - } - - pre_estimated = [p for p in pre_periods if p in finite_periods] - post_estimated = [p for p in post_periods if p in finite_periods] - - # The RR constraint builders treat retained coefficients as - # CONSECUTIVE around the reference (they index by position, not - # label), so a dropped interior or reference-adjacent horizon - # would silently change the smoothness / first-difference - # geometry and return wrong bounds. Require the retained - # pre-periods to be a contiguous SUFFIX of the estimable pre grid - # (ending adjacent to the reference) and the retained - # post-periods a contiguous PREFIX (starting immediately after - # it). Leading-pre / trailing-post drops keep valid geometry; - # anything else fails closed (mirrors the CS branch's - # consecutive-grid validation). - _pre_grid = [p for p in pre_periods if p in results.period_effects] - _post_grid = [p for p in post_periods if p in results.period_effects] - _pre_ok = pre_estimated == _pre_grid[len(_pre_grid) - len(pre_estimated) :] - _post_ok = post_estimated == _post_grid[: len(post_estimated)] - if not (_pre_ok and _post_ok): - _dropped_pre = [p for p in _pre_grid if p not in finite_periods] - _dropped_post = [p for p in _post_grid if p not in finite_periods] - raise ValueError( - "HonestDiD requires consecutive estimated horizons around " - "the reference period: retained pre-periods must end " - "immediately before it and retained post-periods must " - "start immediately after it, with no interior gaps (the " - "Rambachan-Roth restrictions are built positionally). " - "Horizons with undefined inference (non-finite or zero " - f"SE) break that grid here: dropped pre {_dropped_pre}, " - f"dropped post {_dropped_post}. Only leading pre-periods " - "and trailing post-periods can be dropped safely." - ) - - all_estimated = pre_estimated + post_estimated - - if not all_estimated: - raise ValueError( - "No period effects with finite estimates found. " "Cannot compute HonestDiD bounds." - ) - - effects = [results.period_effects[p].effect for p in all_estimated] - ses = [results.period_effects[p].se for p in all_estimated] - - beta_hat = np.array(effects) - num_pre_periods = sum(1 for p in all_estimated if p in pre_periods) - num_post_periods = sum(1 for p in all_estimated if p in post_periods) - - if num_pre_periods == 0: - raise ValueError( - "No pre-period effects with finite estimates found. " - "HonestDiD requires at least one identified pre-period " - "coefficient." - ) - if num_post_periods == 0: - raise ValueError( - "No post-period effects with finite estimates found. " - "HonestDiD requires at least one identified post-treatment " - "coefficient (the sensitivity target)." - ) - - # Extract proper sub-VCV for interaction terms - if ( - results.vcov is not None - and hasattr(results, "interaction_indices") - and results.interaction_indices is not None - ): - indices = [results.interaction_indices[p] for p in all_estimated] - sigma = results.vcov[np.ix_(indices, indices)] - else: - # Fallback: diagonal from SEs - sigma = np.diag(np.array(ses) ** 2) - - # Extract inference df via the shared precedence helper: prefer - # ``survey_metadata.df_survey`` (the actual internal df, which may - # have been tightened post-resolve for replicate designs; undefined - # replicate df -> the 0.0 sentinel) over the ``df_inference`` - # FALLBACK carrier (bare-``cluster=`` fits where - # ``survey_metadata`` is intentionally None). Reading - # ``df_inference`` first would silently overstate the denominator - # df on panel survey fits whose df was tightened during - # aggregation. - df_survey = resolve_inference_df(results) - - # Return the ESTIMATED label lists - the ones beta_hat/sigma were - # built from - not the declared results.pre_periods/post_periods - # (which include the reference period and any dropped - # zero/non-finite-SE horizons). HonestDiDResults.pre_periods_used / - # post_periods_used relay these verbatim, so misaligned labels - # would claim excluded horizons entered the estimand. - return ( - beta_hat, - sigma, - num_pre_periods, - num_post_periods, - pre_estimated, - post_estimated, - df_survey, - ) - - else: - # Try CallawaySantAnnaResults - try: - from diff_diff.staggered import CallawaySantAnnaResults - - if isinstance(results, CallawaySantAnnaResults): - if results.event_study_effects is None: - raise ValueError( - f"{type(results).__name__} must have event_study_effects " - "for HonestDiD. Pass the post-fit container " - "(compute_honest_did(results.aggregate('event_study')))" - " instead." - ) - - # Common-reference guard, mirroring the container branch: - # multiple distinct cohort base event times = coefficients - # normalized against different bases (gapped universal - # grid, including the overlapped-base layout no - # reference-only row marks). Provenance-less universal - # results (pre-3.9 pickles, replace()-stripped copies) - # must not FAIL OPEN: derive the bases from the - # materialized reference cells; warn fail-safe when even - # those are absent. - _ref_e = getattr(results, "reference_event_times", None) - if _ref_e is None and getattr(results, "base_period", None) == "universal": - _gte = getattr(results, "group_time_effects", None) or {} - _derived = {t - g for (g, t), _d in _gte.items() if _d.get("is_reference")} - if _derived: - _ref_e = tuple(sorted(_derived)) - else: - import warnings - - warnings.warn( - "This CallawaySantAnna base_period='universal' " - "result carries no reference_event_times " - "provenance and no materialized reference " - "cells, so a common reference period cannot " - "be verified (a gapped time grid may mix " - "cohort-specific bases). Re-fit with the " - "current version to record provenance.", - UserWarning, - stacklevel=4, - ) - if _ref_e is not None and len(set(_ref_e)) > 1: - raise ValueError( - "HonestDiD requires event-study coefficients " - "normalized against one common reference period, " - "but this CallawaySantAnna base_period='universal' " - "fit selected cohort-specific positional bases at " - f"event times {sorted(set(_ref_e))} (gapped time " - "grid). On such grids a cohort's base can overlap " - "another cohort's estimated horizon, so the " - "coefficients are normalized against different " - "bases and are not jointly interpretable under " - "Rambachan-Roth's delta_0 = 0 normalization. " - "Re-estimate on a consecutive (ungapped) time grid " - "so every cohort's base falls at the same event " - "time." - ) + # Try CallawaySantAnnaResults + try: + from diff_diff.staggered import CallawaySantAnnaResults + + if isinstance(results, CallawaySantAnnaResults): + if results.event_study_effects is None: + raise ValueError( + f"{type(results).__name__} must have event_study_effects " + "for HonestDiD. Pass the post-fit container " + "(compute_honest_did(results.aggregate('event_study')))" + " instead." + ) - # Warn if not using universal base period (R's HonestDiD requires it) - if getattr(results, "base_period", "universal") != "universal": + # Common-reference guard, mirroring the container branch: + # multiple distinct cohort base event times = coefficients + # normalized against different bases (gapped universal + # grid, including the overlapped-base layout no + # reference-only row marks). Provenance-less universal + # results (pre-3.9 pickles, replace()-stripped copies) + # must not FAIL OPEN: derive the bases from the + # materialized reference cells; warn fail-safe when even + # those are absent. + _ref_e = getattr(results, "reference_event_times", None) + if _ref_e is None and getattr(results, "base_period", None) == "universal": + _gte = getattr(results, "group_time_effects", None) or {} + _derived = {t - g for (g, t), _d in _gte.items() if _d.get("is_reference")} + if _derived: + _ref_e = tuple(sorted(_derived)) + else: import warnings warnings.warn( - "HonestDiD sensitivity analysis on CallawaySantAnna results " - "requires base_period='universal' for valid interpretation. " - "With base_period='varying', pre-treatment coefficients use " - "consecutive comparisons (not a common reference period), " - "which changes the meaning of the parallel trends restriction. " - "Re-run with CallawaySantAnna(base_period='universal') for " - "methodologically valid HonestDiD bounds.", + "This CallawaySantAnna base_period='universal' " + "result carries no reference_event_times " + "provenance and no materialized reference " + "cells, so a common reference period cannot " + "be verified (a gapped time grid may mix " + "cohort-specific bases). Re-fit with the " + "current version to record provenance.", UserWarning, - stacklevel=3, + stacklevel=4, ) + if _ref_e is not None and len(set(_ref_e)) > 1: + raise ValueError( + "HonestDiD requires event-study coefficients " + "normalized against one common reference period, " + "but this CallawaySantAnna base_period='universal' " + "fit selected cohort-specific positional bases at " + f"event times {sorted(set(_ref_e))} (gapped time " + "grid). On such grids a cohort's base can overlap " + "another cohort's estimated horizon, so the " + "coefficients are normalized against different " + "bases and are not jointly interpretable under " + "Rambachan-Roth's delta_0 = 0 normalization. " + "Re-estimate on a consecutive (ungapped) time grid " + "so every cohort's base falls at the same event " + "time." + ) - # Extract event study effects by relative time - # Filter out normalization constraints (n_groups=0), non-finite - # SEs, and zero SEs (safe_inference treats se <= 0 as undefined - # inference - admitting such a row would launder NaN source - # inference into finite sensitivity bounds; mirrors pretrends). - event_effects = { - t: data - for t, data in results.event_study_effects.items() - if data.get("n_groups", 1) > 0 - and np.isfinite(data.get("se", np.nan)) - and float(data.get("se", 0.0)) > 0 - } - rel_times = sorted(event_effects.keys()) - - # Infer the omitted reference period from the normalization - # marker injected by _aggregate_event_study for universal base. - # The reference has the exact signature: effect=0.0, se=NaN, n_groups=0. - # Other empty bins may also have n_groups=0 but with NaN effect. - ref_period = None - for t, data in results.event_study_effects.items(): - if ( - data.get("n_groups", 1) == 0 - and data.get("effect", None) == 0.0 - and not np.isfinite(data.get("se", 0.0)) - ): - ref_period = t - break + # Warn if not using universal base period (R's HonestDiD requires it) + if getattr(results, "base_period", "universal") != "universal": + import warnings - if ref_period is not None: - # Universal base: split relative to the reference period - pre_times = [t for t in rel_times if t < ref_period] - post_times = [t for t in rel_times if t > ref_period] - else: - # Varying base or no reference marker: the anticipation - # window [e = -k, -1] carries anticipated TREATMENT - # effects (REGISTRY anticipation contract; pretrends - # applies the same cutoff), so the clean pre-period set - # is e < -k and beta_post starts at -k. Splitting at 0 - # would misclassify anticipated effects as pre-trend - # coefficients. Mirrors the container branch. - _post_start = -int(getattr(results, "anticipation", 0) or 0) - pre_times = [t for t in rel_times if t < _post_start] - post_times = [t for t in rel_times if t >= _post_start] - - if len(pre_times) == 0: - raise ValueError( - "No pre-period effects with finite estimates found in " - "CallawaySantAnna event study. HonestDiD requires at " - "least one identified pre-period coefficient." - ) + warnings.warn( + "HonestDiD sensitivity analysis on CallawaySantAnna results " + "requires base_period='universal' for valid interpretation. " + "With base_period='varying', pre-treatment coefficients use " + "consecutive comparisons (not a common reference period), " + "which changes the meaning of the parallel trends restriction. " + "Re-run with CallawaySantAnna(base_period='universal') for " + "methodologically valid HonestDiD bounds.", + UserWarning, + stacklevel=3, + ) + + # Extract event study effects by relative time + # Filter out normalization constraints (n_groups=0), non-finite + # SEs, and zero SEs (safe_inference treats se <= 0 as undefined + # inference - admitting such a row would launder NaN source + # inference into finite sensitivity bounds; mirrors pretrends). + event_effects = { + t: data + for t, data in results.event_study_effects.items() + if data.get("n_groups", 1) > 0 + and np.isfinite(data.get("se", np.nan)) + and float(data.get("se", 0.0)) > 0 + } + rel_times = sorted(event_effects.keys()) + + # Infer the omitted reference period from the normalization + # marker injected by _aggregate_event_study for universal base. + # The reference has the exact signature: effect=0.0, se=NaN, n_groups=0. + # Other empty bins may also have n_groups=0 but with NaN effect. + ref_period = None + for t, data in results.event_study_effects.items(): + if ( + data.get("n_groups", 1) == 0 + and data.get("effect", None) == 0.0 + and not np.isfinite(data.get("se", 0.0)) + ): + ref_period = t + break - effects = [] - ses = [] - for t in rel_times: - effects.append(event_effects[t]["effect"]) - ses.append(event_effects[t]["se"]) - - beta_hat = np.array(effects) - - # Use full event-study VCV if available (Phase 7d), - # otherwise fall back to diagonal from SEs - if hasattr(results, "event_study_vcov") and results.event_study_vcov is not None: - vcov = results.event_study_vcov - # VCV is indexed by the aggregated event times (stored in - # event_study_vcov_index), NOT by event_study_effects keys - # (which may include an injected reference period). - # Subset to match the surviving rel_times. - vcov_index = getattr(results, "event_study_vcov_index", None) - if vcov_index is not None and len(rel_times) < len(vcov_index): - idx = [vcov_index.index(t) for t in rel_times if t in vcov_index] - if len(idx) == len(rel_times): - sigma = vcov[np.ix_(idx, idx)] - else: - sigma = np.diag(np.array(ses) ** 2) - elif vcov.shape[0] == len(rel_times): - sigma = vcov + if ref_period is not None: + # Universal base: split relative to the reference period + pre_times = [t for t in rel_times if t < ref_period] + post_times = [t for t in rel_times if t > ref_period] + else: + # Varying base or no reference marker: the anticipation + # window [e = -k, -1] carries anticipated TREATMENT + # effects (REGISTRY anticipation contract; pretrends + # applies the same cutoff), so the clean pre-period set + # is e < -k and beta_post starts at -k. Splitting at 0 + # would misclassify anticipated effects as pre-trend + # coefficients. Mirrors the container branch. + _post_start = -int(getattr(results, "anticipation", 0) or 0) + pre_times = [t for t in rel_times if t < _post_start] + post_times = [t for t in rel_times if t >= _post_start] + + if len(pre_times) == 0: + raise ValueError( + "No pre-period effects with finite estimates found in " + "CallawaySantAnna event study. HonestDiD requires at " + "least one identified pre-period coefficient." + ) + + effects = [] + ses = [] + for t in rel_times: + effects.append(event_effects[t]["effect"]) + ses.append(event_effects[t]["se"]) + + beta_hat = np.array(effects) + + # Use full event-study VCV if available (Phase 7d), + # otherwise fall back to diagonal from SEs + if hasattr(results, "event_study_vcov") and results.event_study_vcov is not None: + vcov = results.event_study_vcov + # VCV is indexed by the aggregated event times (stored in + # event_study_vcov_index), NOT by event_study_effects keys + # (which may include an injected reference period). + # Subset to match the surviving rel_times. + vcov_index = getattr(results, "event_study_vcov_index", None) + if vcov_index is not None and len(rel_times) < len(vcov_index): + idx = [vcov_index.index(t) for t in rel_times if t in vcov_index] + if len(idx) == len(rel_times): + sigma = vcov[np.ix_(idx, idx)] else: sigma = np.diag(np.array(ses) ** 2) - # Same integrity contract as the container route - # (_extract_container_params): finite, symmetric, - # diagonal == se**2, and allow_singular=False because - # Rambachan-Roth inference assumes covariance eigenvalues - # bounded away from zero. A few-PSU fit (fewer clusters - # than horizons) stores a singular matrix on both the - # analytical and the bootstrap path, so it fails loud - # here exactly as results.aggregate('event_study') does. - sigma = _validate_vcov_subblock( - sigma, np.asarray(ses, dtype=float), "HonestDiD", allow_singular=False - ) + elif vcov.shape[0] == len(rel_times): + sigma = vcov else: - # No full VCV available. A bootstrap fit carries the - # covariance of its event-study draws instead of the - # analytical VCV, except when n_bootstrap does not exceed - # the number of event times (singular by construction) or - # a draw is non-finite. - if ( - hasattr(results, "bootstrap_results") - and results.bootstrap_results is not None - ): - import warnings - - warnings.warn( - "HonestDiD on bootstrap-fitted CallawaySantAnna results " - "uses a diagonal covariance matrix (no covariance of the " - "event-study bootstrap draws is available: n_bootstrap " - "does not exceed the number of event times, or a draw is " - "non-finite). For full covariance structure, increase " - "n_bootstrap or use analytical SEs (n_bootstrap=0).", - UserWarning, - stacklevel=4, - ) sigma = np.diag(np.array(ses) ** 2) + # Same integrity contract as the container route + # (_extract_container_params): finite, symmetric, + # diagonal == se**2, and allow_singular=False because + # Rambachan-Roth inference assumes covariance eigenvalues + # bounded away from zero. A few-PSU fit (fewer clusters + # than horizons) stores a singular matrix on both the + # analytical and the bootstrap path, so it fails loud + # here exactly as results.aggregate('event_study') does. + sigma = _validate_vcov_subblock( + sigma, np.asarray(ses, dtype=float), "HonestDiD", allow_singular=False + ) + else: + # No full VCV available. A bootstrap fit carries the + # covariance of its event-study draws instead of the + # analytical VCV, except when n_bootstrap does not exceed + # the number of event times (singular by construction) or + # a draw is non-finite. + if hasattr(results, "bootstrap_results") and results.bootstrap_results is not None: + import warnings - # Validate the full event-time grid is consecutive. - # For universal base: exactly one gap for the omitted reference. - # For varying base: no gap expected (pre ends at -1, post starts at 0). - if pre_times and post_times: - if ref_period is not None: - # Universal: pre[-1]+1 = ref, ref+1 = post[0] → gap of 2 - ref_gap = post_times[0] - pre_times[-1] - has_gap = ref_gap != 2 - else: - # Varying: pre ends at -1, post starts at 0 → gap of 1 - ref_gap = post_times[0] - pre_times[-1] - has_gap = ref_gap != 1 - elif pre_times: - has_gap = False # only pre, no ref gap to check - elif post_times: - has_gap = False # only post, no ref gap to check - else: - has_gap = False - # Also check within-block consecutiveness - for block in [pre_times, post_times]: - if len(block) >= 2: - for i in range(len(block) - 1): - if block[i + 1] - block[i] != 1: - has_gap = True - break - if has_gap: - raise ValueError( - "HonestDiD requires a consecutive event-time grid " - "around the omitted reference period. Retained " - f"pre-periods {pre_times} and post-periods " - f"{post_times} have gaps. This can happen when " - "some event-study horizons have non-finite SEs. " - "Ensure all event-study periods have valid estimates, " - "or use balance_e to restrict to a balanced subset." + warnings.warn( + "HonestDiD on bootstrap-fitted CallawaySantAnna results " + "uses a diagonal covariance matrix (no covariance of the " + "event-study bootstrap draws is available: n_bootstrap " + "does not exceed the number of event times, or a draw is " + "non-finite). For full covariance structure, increase " + "n_bootstrap or use analytical SEs (n_bootstrap=0).", + UserWarning, + stacklevel=4, ) + sigma = np.diag(np.array(ses) ** 2) - # Extract inference df via the shared precedence helper - # (``survey_metadata.df_survey`` -> replicate 0.0 sentinel - # -> ``df_inference`` fallback carrier) - the same order the - # MPD branch uses, so all branches agree. - df_survey = resolve_inference_df(results) - - return ( - beta_hat, - sigma, - len(pre_times), - len(post_times), - pre_times, - post_times, - df_survey, + # Validate the full event-time grid is consecutive. + # For universal base: exactly one gap for the omitted reference. + # For varying base: no gap expected (pre ends at -1, post starts at 0). + if pre_times and post_times: + if ref_period is not None: + # Universal: pre[-1]+1 = ref, ref+1 = post[0] → gap of 2 + ref_gap = post_times[0] - pre_times[-1] + has_gap = ref_gap != 2 + else: + # Varying: pre ends at -1, post starts at 0 → gap of 1 + ref_gap = post_times[0] - pre_times[-1] + has_gap = ref_gap != 1 + elif pre_times: + has_gap = False # only pre, no ref gap to check + elif post_times: + has_gap = False # only post, no ref gap to check + else: + has_gap = False + # Also check within-block consecutiveness + for block in [pre_times, post_times]: + if len(block) >= 2: + for i in range(len(block) - 1): + if block[i + 1] - block[i] != 1: + has_gap = True + break + if has_gap: + raise ValueError( + "HonestDiD requires a consecutive event-time grid " + "around the omitted reference period. Retained " + f"pre-periods {pre_times} and post-periods " + f"{post_times} have gaps. This can happen when " + "some event-study horizons have non-finite SEs. " + "Ensure all event-study periods have valid estimates, " + "or use balance_e to restrict to a balanced subset." ) - except ImportError: - pass - # Try ChaisemartinDHaultfoeuilleResults (dCDH estimator) - try: - from diff_diff.chaisemartin_dhaultfoeuille_results import ( - ChaisemartinDHaultfoeuilleResults, + # Extract inference df via the shared precedence helper + # (``survey_metadata.df_survey`` -> replicate 0.0 sentinel + # -> ``df_inference`` fallback carrier) - the same order the + # calendar branch uses, so all branches agree. + df_survey = resolve_inference_df(results) + + return ( + beta_hat, + sigma, + len(pre_times), + len(post_times), + pre_times, + post_times, + df_survey, ) + except ImportError: + pass - if isinstance(results, ChaisemartinDHaultfoeuilleResults): - import warnings + # Try ChaisemartinDHaultfoeuilleResults (dCDH estimator) + try: + from diff_diff.chaisemartin_dhaultfoeuille_results import ( + ChaisemartinDHaultfoeuilleResults, + ) - warnings.warn( - "HonestDiD on dCDH results uses DID^{pl}_l placebo " - "estimates as pre-period coefficients, not standard " - "event-study pre-treatment coefficients. The Rambachan-" - "Roth restrictions bound violations of the parallel " - "trends assumption underlying the dCDH placebo " - "estimand. This is a library extension; interpretation " - "differs from canonical event-study HonestDiD.", - UserWarning, - stacklevel=3, - ) + if isinstance(results, ChaisemartinDHaultfoeuilleResults): + import warnings - if results.placebo_event_study is None: - raise ValueError( - "ChaisemartinDHaultfoeuilleResults must have placebo_event_study " - "for HonestDiD. Re-run ChaisemartinDHaultfoeuille.fit() with " - "L_max >= 1 to compute multi-horizon placebos." - ) - if results.event_study_effects is None: - raise ValueError( - "ChaisemartinDHaultfoeuilleResults must have event_study_effects " - "for HonestDiD." - ) + warnings.warn( + "HonestDiD on dCDH results uses DID^{pl}_l placebo " + "estimates as pre-period coefficients, not standard " + "event-study pre-treatment coefficients. The Rambachan-" + "Roth restrictions bound violations of the parallel " + "trends assumption underlying the dCDH placebo " + "estimand. This is a library extension; interpretation " + "differs from canonical event-study HonestDiD.", + UserWarning, + stacklevel=3, + ) - # Filter for finite, strictly positive SEs in both surfaces - # (se <= 0 = undefined inference per safe_inference; a zero - # row would enter Sigma with zero variance and launder NaN - # source inference into finite bounds - mirrors the MPD, CS - # and container branches and pretrends). - placebo_finite = { - h: data - for h, data in results.placebo_event_study.items() - if np.isfinite(data.get("se", np.nan)) and float(data.get("se", 0.0)) > 0 - } - effects_finite = { - h: data - for h, data in results.event_study_effects.items() - if np.isfinite(data.get("se", np.nan)) and float(data.get("se", 0.0)) > 0 - } + if results.placebo_event_study is None: + raise ValueError( + "ChaisemartinDHaultfoeuilleResults must have placebo_event_study " + "for HonestDiD. Re-run ChaisemartinDHaultfoeuille.fit() with " + "L_max >= 1 to compute multi-horizon placebos." + ) + if results.event_study_effects is None: + raise ValueError( + "ChaisemartinDHaultfoeuilleResults must have event_study_effects " + "for HonestDiD." + ) - pre_times = sorted(placebo_finite.keys()) # -P, ..., -1 - post_times = sorted(effects_finite.keys()) # 1, ..., L_max + # Filter for finite, strictly positive SEs in both surfaces + # (se <= 0 = undefined inference per safe_inference; a zero + # row would enter Sigma with zero variance and launder NaN + # source inference into finite bounds - mirrors the calendar, CS + # and container branches and pretrends). + placebo_finite = { + h: data + for h, data in results.placebo_event_study.items() + if np.isfinite(data.get("se", np.nan)) and float(data.get("se", 0.0)) > 0 + } + effects_finite = { + h: data + for h, data in results.event_study_effects.items() + if np.isfinite(data.get("se", np.nan)) and float(data.get("se", 0.0)) > 0 + } + + pre_times = sorted(placebo_finite.keys()) # -P, ..., -1 + post_times = sorted(effects_finite.keys()) # 1, ..., L_max + + if len(pre_times) == 0: + raise ValueError( + "No placebo horizons with finite SEs found in dCDH results. " + "HonestDiD requires at least one identified pre-period " + "coefficient." + ) + if len(post_times) == 0: + raise ValueError( + "No event study horizons with finite SEs found in dCDH results. " + "HonestDiD requires at least one post-period coefficient." + ) - if len(pre_times) == 0: - raise ValueError( - "No placebo horizons with finite SEs found in dCDH results. " - "HonestDiD requires at least one identified pre-period " - "coefficient." - ) - if len(post_times) == 0: + # Consecutiveness check: more permissive than CS because + # trends_nonparam support-trimming can create legitimate gaps. + # Filter to the largest consecutive block spanning the -1/+1 + # boundary; warn about dropped horizons. + def _largest_consecutive_block(times, boundary_val): + """Find largest consecutive block containing boundary_val.""" + if not times: + return [] + if boundary_val not in times: raise ValueError( - "No event study horizons with finite SEs found in dCDH results. " - "HonestDiD requires at least one post-period coefficient." + f"HonestDiD requires horizon {boundary_val} in " + f"the dCDH " + f"{'placebo' if boundary_val < 0 else 'event study'}" + f" surface, but it was removed by finite-SE " + f"filtering. Retained horizons: {times}. Ensure " + f"horizon {boundary_val} has a finite SE." ) + # Expand outward from boundary_val + block = [boundary_val] + idx = times.index(boundary_val) + # Expand left + for i in range(idx - 1, -1, -1): + if times[i] == block[0] - 1: + block.insert(0, times[i]) + else: + break + # Expand right + for i in range(idx + 1, len(times)): + if times[i] == block[-1] + 1: + block.append(times[i]) + else: + break + return block - # Consecutiveness check: more permissive than CS because - # trends_nonparam support-trimming can create legitimate gaps. - # Filter to the largest consecutive block spanning the -1/+1 - # boundary; warn about dropped horizons. - def _largest_consecutive_block(times, boundary_val): - """Find largest consecutive block containing boundary_val.""" - if not times: - return [] - if boundary_val not in times: - raise ValueError( - f"HonestDiD requires horizon {boundary_val} in " - f"the dCDH " - f"{'placebo' if boundary_val < 0 else 'event study'}" - f" surface, but it was removed by finite-SE " - f"filtering. Retained horizons: {times}. Ensure " - f"horizon {boundary_val} has a finite SE." - ) - # Expand outward from boundary_val - block = [boundary_val] - idx = times.index(boundary_val) - # Expand left - for i in range(idx - 1, -1, -1): - if times[i] == block[0] - 1: - block.insert(0, times[i]) - else: - break - # Expand right - for i in range(idx + 1, len(times)): - if times[i] == block[-1] + 1: - block.append(times[i]) - else: - break - return block - - pre_consec = _largest_consecutive_block(pre_times, -1) - post_consec = _largest_consecutive_block(post_times, 1) + pre_consec = _largest_consecutive_block(pre_times, -1) + post_consec = _largest_consecutive_block(post_times, 1) - dropped_pre = set(pre_times) - set(pre_consec) - dropped_post = set(post_times) - set(post_consec) + dropped_pre = set(pre_times) - set(pre_consec) + dropped_post = set(post_times) - set(post_consec) - if dropped_pre or dropped_post: - import warnings + if dropped_pre or dropped_post: + import warnings - dropped = sorted(dropped_pre | dropped_post) - warnings.warn( - f"HonestDiD requires a consecutive event-time grid. " - f"Dropping non-consecutive horizons {dropped} from dCDH " - f"results. This can happen when trends_nonparam " - f"support-trimming removes horizons. Retained: " - f"pre={pre_consec}, post={post_consec}.", - UserWarning, - stacklevel=3, - ) - pre_times = pre_consec - post_times = post_consec + dropped = sorted(dropped_pre | dropped_post) + warnings.warn( + f"HonestDiD requires a consecutive event-time grid. " + f"Dropping non-consecutive horizons {dropped} from dCDH " + f"results. This can happen when trends_nonparam " + f"support-trimming removes horizons. Retained: " + f"pre={pre_consec}, post={post_consec}.", + UserWarning, + stacklevel=3, + ) + pre_times = pre_consec + post_times = post_consec - if len(pre_times) == 0 or len(post_times) == 0: - raise ValueError( - "After filtering for consecutive horizons, no pre- or " - "post-periods remain. Cannot compute HonestDiD bounds." - ) + if len(pre_times) == 0 or len(post_times) == 0: + raise ValueError( + "After filtering for consecutive horizons, no pre- or " + "post-periods remain. Cannot compute HonestDiD bounds." + ) - # Build beta_hat and sigma (diagonal - no full VCV for dCDH) - effects = [] - ses = [] - for h in pre_times: - effects.append(placebo_finite[h]["effect"]) - ses.append(placebo_finite[h]["se"]) - for h in post_times: - effects.append(effects_finite[h]["effect"]) - ses.append(effects_finite[h]["se"]) - - beta_hat = np.array(effects) - sigma = np.diag(np.array(ses) ** 2) + # Build beta_hat and sigma (diagonal - no full VCV for dCDH) + effects = [] + ses = [] + for h in pre_times: + effects.append(placebo_finite[h]["effect"]) + ses.append(placebo_finite[h]["se"]) + for h in post_times: + effects.append(effects_finite[h]["effect"]) + ses.append(effects_finite[h]["se"]) + + beta_hat = np.array(effects) + sigma = np.diag(np.array(ses) ** 2) - # Extract inference df via the shared precedence helper - # (``survey_metadata.df_survey`` -> replicate 0.0 sentinel - # -> ``df_inference`` fallback carrier) - the same order the - # MPD branch uses, so all branches agree. - df_survey = resolve_inference_df(results) - - return ( - beta_hat, - sigma, - len(pre_times), - len(post_times), - pre_times, - post_times, - df_survey, - ) - except ImportError: - pass + # Extract inference df via the shared precedence helper + # (``survey_metadata.df_survey`` -> replicate 0.0 sentinel + # -> ``df_inference`` fallback carrier) - the same order the + # calendar branch uses, so all branches agree. + df_survey = resolve_inference_df(results) - raise TypeError( - f"Unsupported results type: {type(results)}. " - "Expected MultiPeriodDiDResults, CallawaySantAnnaResults, " - "ChaisemartinDHaultfoeuilleResults, or an EventStudyResults " - "container from CallawaySantAnnaResults.aggregate('event_study'), " - "DMLDiDResults.aggregate('event_study'), " - "or StackedDiDResults.aggregate('event_study')." - ) + return ( + beta_hat, + sigma, + len(pre_times), + len(post_times), + pre_times, + post_times, + df_survey, + ) + except ImportError: + pass + + raise TypeError( + f"Unsupported results type: {type(results)}. " + "Expected CallawaySantAnnaResults, " + "ChaisemartinDHaultfoeuilleResults, or an EventStudyResults " + "container (the TwoWayFixedEffects event-study mode, " + "CallawaySantAnnaResults.aggregate('event_study'), " + "DMLDiDResults.aggregate('event_study'), " + "or StackedDiDResults.aggregate('event_study'))." + ) def _construct_A_sd(num_pre_periods: int, num_post_periods: int) -> np.ndarray: @@ -3127,7 +3003,7 @@ def _validate_params(self): def fit( self, - results: Union[MultiPeriodDiDResults, Any], + results: Any, M: Optional[float] = None, ) -> HonestDiDResults: """ @@ -3135,7 +3011,7 @@ def fit( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults + results : CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults Results from event study estimation, or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, @@ -3476,20 +3352,6 @@ def _estimate_max_pre_violation(self, results: Any, pre_periods: List) -> float: Uses pre-period coefficients if available, otherwise returns a default based on the overall SE. """ - if isinstance(results, MultiPeriodDiDResults): - # Pre-period effects are now in period_effects directly - # Filter out non-finite effects (e.g. from rank-deficient designs) - pre_effects = [ - abs(results.period_effects[p].effect) - for p in pre_periods - if p in results.period_effects and np.isfinite(results.period_effects[p].effect) - ] - if pre_effects: - return max(pre_effects) - - # Fallback: use avg_se as a scale - return results.avg_se - # For CallawaySantAnna, use pre-period event study effects try: from diff_diff.staggered import CallawaySantAnnaResults @@ -3516,7 +3378,7 @@ def _estimate_max_pre_violation(self, results: Any, pre_periods: List) -> float: def sensitivity_analysis( self, - results: Union[MultiPeriodDiDResults, Any], + results: Any, M_grid: Optional[List[float]] = None, ) -> SensitivityResults: """ @@ -3524,7 +3386,7 @@ def sensitivity_analysis( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults + results : CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults Results from event study estimation, or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, @@ -3620,9 +3482,7 @@ def _ci_includes_zero(ci_lb, ci_ub): return None - def breakdown_value( - self, results: Union[MultiPeriodDiDResults, Any], tol: float = 0.01 - ) -> Optional[float]: + def breakdown_value(self, results: Any, tol: float = 0.01) -> Optional[float]: """ Find the breakdown value directly using binary search. @@ -3631,7 +3491,7 @@ def breakdown_value( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults + results : CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults Results from event study estimation, or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, @@ -3682,7 +3542,7 @@ def _ci_covers_zero(r): def compute_honest_did( - results: Union[MultiPeriodDiDResults, Any], + results: Any, method: str = "relative_magnitude", M: float = 1.0, alpha: float = 0.05, @@ -3693,7 +3553,7 @@ def compute_honest_did( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults + results : CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults Results from event study estimation, or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, ``DMLDiDResults.aggregate('event_study')``, @@ -3727,7 +3587,7 @@ def compute_honest_did( def sensitivity_plot( - results: Union[MultiPeriodDiDResults, Any], + results: Any, method: str = "relative_magnitude", M_grid: Optional[List[float]] = None, alpha: float = 0.05, @@ -3739,7 +3599,7 @@ def sensitivity_plot( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults + results : CallawaySantAnnaResults, ChaisemartinDHaultfoeuilleResults, or EventStudyResults Results from event study estimation, or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, ``DMLDiDResults.aggregate('event_study')``, diff --git a/diff_diff/linalg.py b/diff_diff/linalg.py index 72177d713..107ca1d51 100644 --- a/diff_diff/linalg.py +++ b/diff_diff/linalg.py @@ -1326,7 +1326,7 @@ def solve_ols( # Front-door Conley-specific validation. Runs BEFORE the routing/backend # branching so `return_vcov=False` cannot bypass the conley+weights / # conley+cluster_ids guards. Conley needs this because survey-replicate - # paths in MultiPeriodDiD pass `return_vcov=False` + `weights=survey_w` + # paths in the event-study core pass `return_vcov=False` + `weights=survey_w` # and would otherwise silently return survey SEs under a Conley request. # The full `_validate_vcov_args` is NOT called here because other vcov # types (e.g. `hc2_bm` + replicate weights) intentionally fall through @@ -2492,7 +2492,7 @@ def _compute_cr2_bm_vcov_and_dof( eigendecompositions on the weighted path) are defined in exactly one place. Consolidating the two formerly-duplicated precompute blocks lets a caller - that needs both vcov and contrast DOF (e.g. :class:`MultiPeriodDiD` under + that needs both vcov and contrast DOF (e.g. the TWFE event-study mode under ``cluster + hc2_bm``) build them once instead of twice. Parameters @@ -3064,7 +3064,7 @@ def _cr2_bm_dof_inner_weighted( # by enough to materially shift Satterthwaite DOF on high-leverage # coefficients). Fall back to a symmetric-eigendecomposition pseudo- # square-root only on rank-deficient designs (where chol raises), so the - # MultiPeriodDiD-style full-dummy designs that exposed the original + # event-study full-dummy designs that exposed the original # singular-bread case still proceed. _TOL = 1e-10 @@ -3160,7 +3160,7 @@ def _factor_psd(M): # 2. Absolute (single-contrast safe): max(|P|) < eps_floor based on the # bread matrix scale. Necessary because batch-relative reduces to # max|P| < 1e-10 × max|P| (i.e. False) for a single contrast, leaving - # direct `_compute_cr2_bm_contrast_dof(...)` callers (e.g., MPD avg_att) + # direct `_compute_cr2_bm_contrast_dof(...)` callers (single contrasts) # unprotected. Threshold: `(EPS × n × k × bread_inv_scale)²` covers the # worst-case dgemm accumulation roundoff floor for `H1/H2/H3 @ contrast` # products. CI codex flagged this as P2 (R8 round); regression test in @@ -3208,8 +3208,8 @@ def _compute_cr2_bm_contrast_dof( Generalizes the per-coefficient DOF from :func:`_compute_cr2_bm` to arbitrary linear combinations ``c = sum_j a_j * beta_j``. Used by - :class:`MultiPeriodDiD` to compute the Satterthwaite DOF for the - post-period-average ATT contrast under cluster-robust CR2 inference. + the event-study core to compute Satterthwaite DOF for its contrast + batch under cluster-robust CR2 inference. Parameters ---------- @@ -3331,7 +3331,7 @@ def _compute_bm_dof_from_contrasts( if np.any(h_diag >= _HC_LEVERAGE_THRESHOLD): # Covariance already warns on this design. Remain silent for callers - # computing additional contrasts, e.g. MultiPeriodDiD's average ATT. + # computing additional contrasts, e.g. the event-study contrast batch. return np.full(contrasts.shape[1], np.nan) # Unweighted: keep the simple (tr B)² / tr(B²) formula — algebraically @@ -3442,7 +3442,7 @@ def _compute_robust_vcov_numpy( # Bread: (X'WX) or (X'X) depending on whether weights present. # Suppress spurious BLAS-level subnormal warnings on macOS Accelerate - # for sparse-X designs (e.g., MultiPeriodDiD's event-study dummies). + # for sparse-X designs (e.g., the event-study period dummies). # Non-finite bread is caught at the downstream np.linalg.solve. with np.errstate(divide="ignore", over="ignore", invalid="ignore"): if weights is not None: @@ -4393,9 +4393,9 @@ class LinearRegression: (``pweight`` / ``survey_design``) the deferral additionally reflects an open methodological question, with no canonical extension of Conley (1999) for weighted spatial-HAC under - probability sampling. The DiD / MPD / TWFE - estimators all support panel Conley by passing ``unit`` at fit-time - (DiD as a fit-time kwarg; MPD/TWFE via the existing ``unit`` + probability sampling. The DiD / TWFE + estimators both support panel Conley by passing ``unit`` at fit-time + (DiD as a fit-time kwarg; TWFE via the existing ``unit`` argument), threading ``conley_time`` / ``conley_unit`` into the block-decomposed sandwich. conley_coords : ndarray of shape (n, 2), optional @@ -5257,7 +5257,7 @@ def get_inference( # every fit (clustered, unclustered, and conley alike). Survey df # and per-coefficient Bell-McCaffrey DOF above still win — this # branch replaces only the residual fallback. Keep textually - # parallel with MultiPeriodDiD's inline ladder (estimators.py, + # parallel with the event-study core's inline ladder (estimators.py, # "normal" branch). effective_df = None else: diff --git a/diff_diff/power.py b/diff_diff/power.py index fd2ffb564..fd4f48c22 100644 --- a/diff_diff/power.py +++ b/diff_diff/power.py @@ -317,20 +317,6 @@ def _twfe_fit_kwargs( return dict(outcome="outcome", treatment="treated", post="post", unit="unit") -def _multiperiod_fit_kwargs( - data: pd.DataFrame, - n_units: int, - n_periods: int, - treatment_period: int, -) -> Dict[str, Any]: - return dict( - outcome="outcome", - treatment="treated", - time="period", - post_periods=list(range(treatment_period, n_periods)), - ) - - def _staggered_fit_kwargs( data: pd.DataFrame, n_units: int, @@ -468,25 +454,6 @@ def _survey_twfe_fit_kwargs( ) -def _survey_multiperiod_fit_kwargs( - data: pd.DataFrame, - n_units: int, - n_periods: int, - treatment_period: int, - survey_config: SurveyPowerConfig, -) -> Dict[str, Any]: - """Fit kwargs for MultiPeriodDiD with survey design (1-indexed periods).""" - return dict( - outcome="outcome", - treatment="ever_treated", - unit="unit", - time="period", - # 1-indexed: post periods run from treatment_period+1 to n_periods - post_periods=list(range(treatment_period + 1, n_periods + 1)), - survey_design=survey_config._build_survey_design(), - ) - - def _survey_staggered_fit_kwargs( data: pd.DataFrame, n_units: int, @@ -511,12 +478,6 @@ def _extract_simple(result: Any) -> Tuple[float, float, float, Tuple[float, floa return (result.att, result.se, result.p_value, result.conf_int) -def _extract_multiperiod( - result: Any, -) -> Tuple[float, float, float, Tuple[float, float]]: - return (result.avg_att, result.avg_se, result.avg_p_value, result.avg_conf_int) - - def _extract_staggered( result: Any, ) -> Tuple[float, float, float, Tuple[float, float]]: @@ -597,7 +558,6 @@ def _first(r: Any, *attrs: str, default: Any = _nan) -> Any: _SURVEY_FIT_BUILDERS: Dict[str, Callable] = { "DifferenceInDifferences": _survey_basic_fit_kwargs, "TwoWayFixedEffects": _survey_twfe_fit_kwargs, - "MultiPeriodDiD": _survey_multiperiod_fit_kwargs, **{name: _survey_staggered_fit_kwargs for name in _STAGGERED_ESTIMATORS}, } @@ -952,13 +912,6 @@ def _get_registry() -> Dict[str, _EstimatorProfile]: result_extractor=_extract_simple, min_n=20, ), - "MultiPeriodDiD": _EstimatorProfile( - default_dgp=generate_did_data, - dgp_kwargs_builder=_basic_dgp_kwargs, - fit_kwargs_builder=_multiperiod_fit_kwargs, - result_extractor=_extract_multiperiod, - min_n=20, - ), # --- Staggered group --- "CallawaySantAnna": _EstimatorProfile( default_dgp=generate_staggered_data, @@ -2149,7 +2102,7 @@ def simulate_power( When provided, generates survey-structured data via ``generate_survey_did_data`` and injects ``SurveyDesign`` into estimator ``fit()``. Mutually exclusive with ``data_generator``. - Supported estimators: DiD, TWFE, MultiPeriod, CS, SA, ImputationDiD, + Supported estimators: DiD, TWFE, CS, SA, ImputationDiD, TwoStageDiD, StackedDiD, EfficientDiD. Unsupported: TROP, SyntheticDiD, TripleDifference. ``heterogeneous_te_by_strata`` must be False. @@ -2496,8 +2449,8 @@ def simulate_power( data = _generate_survey_did_data(seed=sim_seed, **dgp_kwargs) # Derive columns for non-staggered estimators. - # Survey DGP's `treated` is time-varying (1{g>0, t>=g}); basic/TWFE/ - # MultiPeriod need a time-invariant group indicator (`ever_treated`). + # Survey DGP's `treated` is time-varying (1{g>0, t>=g}); basic/TWFE + # need a time-invariant group indicator (`ever_treated`). if estimator_name not in _STAGGERED_ESTIMATORS: data["ever_treated"] = (data["first_treat"] > 0).astype(int) # Basic/TWFE also need a `post` period indicator. @@ -2577,10 +2530,23 @@ def simulate_power( elif result_extractor is not None: att, se, p_val, ci = result_extractor(result) else: - att = result.att if hasattr(result, "att") else result.avg_att - se = result.se if hasattr(result, "se") else result.avg_se - p_val = result.p_value if hasattr(result, "p_value") else result.avg_p_value - ci = result.conf_int if hasattr(result, "conf_int") else result.avg_conf_int + # Unregistered / custom estimators: the canonical flat + # results names (the avg_* fallback left with the removed + # event-study container at the 4.0 cut). + missing = [ + n for n in ("att", "se", "p_value", "conf_int") if not hasattr(result, n) + ] + if missing: + raise TypeError( + f"{type(result).__name__} exposes no {', '.join(missing)}; " + "simulate_power reads the flat att/se/p_value/conf_int names " + "from unregistered estimators' results - register an " + "_EstimatorProfile (or pass result_extractor=) for other shapes." + ) + att = result.att + se = result.se + p_val = result.p_value + ci = result.conf_int # NaN p-value → treat as non-rejection rejected = bool(p_val < alpha) if not np.isnan(p_val) else False diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index 153a9074a..129f6685e 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -32,7 +32,6 @@ # --------------------------------------------------------------------------- _ESTIMATOR_NAMES: Dict[str, str] = { "DiDResults": "DifferenceInDifferences", - "MultiPeriodDiDResults": "MultiPeriodDiD (Event Study)", "CallawaySantAnnaResults": "CallawaySantAnna", "DMLDiDResults": "DMLDiD (Chang 2020 double/debiased ML)", "SunAbrahamResults": "SunAbraham", @@ -261,7 +260,7 @@ def _parallel_trends_step(staggered: bool = False) -> Dict[str, Any]: why=( "Parallel trends is the core identifying assumption. " "Insignificant pre-trends do NOT prove it holds. For " - "MultiPeriodDiD or CS results, use HonestDiD to bound " + "event-study or CS results, use HonestDiD to bound " "the impact of violations." ), code=( @@ -389,18 +388,6 @@ def _handle_did(results: Any): return steps, warnings -def _handle_multi_period(results: Any): - steps = [ - _parallel_trends_step(), - _honest_did_step(), - # Note: run_all_placebo_tests() requires binary time indicator, - # which MultiPeriodDiD does not use. Omit placebo for this type. - _robustness_compare_step("CallawaySantAnna, SunAbraham, or ImputationDiD"), - ] - warnings = _check_nan_att(results) - return steps, warnings - - def _handle_cs(results: Any): # The post-fit RECOMPUTE levels work on bootstrapped fits too: they # REPLAY the fit-time multiplier bootstrap from the kit-retained RNG @@ -2488,7 +2475,6 @@ def _handle_duration_did(results: Any): # --------------------------------------------------------------------------- _HANDLERS = { "DiDResults": _handle_did, - "MultiPeriodDiDResults": _handle_multi_period, "CallawaySantAnnaResults": _handle_cs, "DMLDiDResults": _handle_dml_did, "SunAbrahamResults": _handle_sa, @@ -2525,12 +2511,10 @@ def _check_nan_att(results: Any) -> List[str]: too: any future estimator returning ndarray ``att`` without a dedicated handler gets the same all-NaN warning shape. """ - # Check .att (DiDResults), .overall_att (staggered), .avg_att (MultiPeriod) + # Check .att (DiDResults), .overall_att (staggered) att = getattr(results, "att", None) if att is None: att = getattr(results, "overall_att", None) - if att is None: - att = getattr(results, "avg_att", None) if att is None: return [] try: diff --git a/diff_diff/prep.py b/diff_diff/prep.py index f50b30665..8080f9c07 100644 --- a/diff_diff/prep.py +++ b/diff_diff/prep.py @@ -1585,7 +1585,7 @@ def aggregate_survey( - ``"aweight"``: Precision weights - inverse variance (``1 / V(y_bar)``). Targets precision-weighted second-stage estimation via WLS. Compatible with estimators that accept ``aweight`` - (DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD, + (DifferenceInDifferences, TwoWayFixedEffects, SunAbraham, ContinuousDiD, EfficientDiD); rejected by ``pweight``-only estimators. diff --git a/diff_diff/prep_dgp.py b/diff_diff/prep_dgp.py index 319ae734f..388246125 100644 --- a/diff_diff/prep_dgp.py +++ b/diff_diff/prep_dgp.py @@ -956,7 +956,7 @@ def generate_event_study_data( Generate synthetic data for event study analysis. Creates panel data with simultaneous treatment at period n_pre. - Useful for testing MultiPeriodDiD, pre-trends power analysis, + Useful for testing the TwoWayFixedEffects event-study mode, pre-trends power analysis, and HonestDiD sensitivity analysis. Parameters diff --git a/diff_diff/pretrends.py b/diff_diff/pretrends.py index ad8e8a6eb..cd136123d 100644 --- a/diff_diff/pretrends.py +++ b/diff_diff/pretrends.py @@ -27,14 +27,13 @@ import warnings from dataclasses import dataclass, field -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, Optional, Tuple import numpy as np import pandas as pd from scipy import optimize, stats from diff_diff._base import BaseEstimator -from diff_diff.results import MultiPeriodDiDResults from diff_diff.results_base import Diagnostic, _validate_vcov_subblock @@ -846,7 +845,7 @@ class PreTrendsPower(BaseEstimator): Examples -------- - Basic usage with MultiPeriodDiD results: + Basic usage with TwoWayFixedEffects event-study results: >>> from diff_diff import TwoWayFixedEffects >>> from diff_diff.pretrends import PreTrendsPower @@ -1041,7 +1040,7 @@ def _get_violation_weights( def _extract_pre_period_params( self, - results: Union[MultiPeriodDiDResults, Any], + results: Any, pre_periods: Optional[List[int]] = None, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, int, Optional[np.ndarray], str]: """ @@ -1049,7 +1048,7 @@ def _extract_pre_period_params( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, SunAbrahamResults, or EventStudyResults + results : CallawaySantAnnaResults, SunAbrahamResults, or EventStudyResults Results object from event study estimation, or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, @@ -1078,15 +1077,15 @@ def _extract_pre_period_params( extraction actually took: - ``"full_pre_period_vcov"`` when a full pre-period - covariance sub-block was used (MPD with - ``interaction_indices``, CS/SA with populated - ``event_study_vcov``, or an admitted CS-/Stacked-sourced - container carrying ``vcov`` - StackedDiD persists its + covariance sub-block was used (CS/SA with populated + ``event_study_vcov``, or an admitted container carrying + ``vcov`` - the TWFE event-study surface always does; + StackedDiD persists its event-study VCV in every inference mode, so Stacked containers always take this tier). - ``"diag_fallback"`` when only the per-period standard errors were available (bootstrap / replicate-weight CS or - SA fits, MPD without ``interaction_indices``). + SA fits). ``DiagnosticReport`` consumes this label downstream to decide whether the power-tier should be conservatively @@ -1100,78 +1099,6 @@ def _extract_pre_period_params( if isinstance(results, EventStudyResults): return self._extract_container_pre_period_params(results, pre_periods) - if isinstance(results, MultiPeriodDiDResults): - # Get pre-period information - use explicit pre_periods if provided - if pre_periods is not None: - all_pre_periods = list(pre_periods) - else: - all_pre_periods = results.pre_periods - - if len(all_pre_periods) == 0: - raise ValueError( - "No pre-treatment periods found in results. " - "Pre-trends power analysis requires pre-period coefficients. " - "If you estimated all periods as post_periods, use the pre_periods " - "parameter to specify which are actually pre-treatment." - ) - - # Pre-period effects are in period_effects (excluding reference period) - estimated_pre_periods = [ - p - for p in all_pre_periods - if p in results.period_effects and results.period_effects[p].se > 0 - ] - - if len(estimated_pre_periods) == 0: - raise ValueError( - "No estimated pre-period coefficients found. " - "The pre-trends test requires at least one estimated " - "pre-period coefficient (excluding the reference period)." - ) - - n_pre = len(estimated_pre_periods) - effects = np.array([results.period_effects[p].effect for p in estimated_pre_periods]) - ses = np.array([results.period_effects[p].se for p in estimated_pre_periods]) - - # Extract vcov using stored interaction indices for robust extraction - if ( - results.vcov is not None - and hasattr(results, "interaction_indices") - and results.interaction_indices is not None - ): - indices = [results.interaction_indices[p] for p in estimated_pre_periods] - vcov = results.vcov[np.ix_(indices, indices)] - covariance_source = "full_pre_period_vcov" - else: - vcov = np.diag(ses**2) - covariance_source = "diag_fallback" - - # For MultiPeriodDiDResults, period identifiers are generic - # (often calendar years, sometimes pre-shifted relative times). - # Roth's δ_t = γ·t convention needs RELATIVE offsets from the - # treatment / reference period. Three label-type regimes: - # - # 1. Numeric (int / float / np.int64) — direct float() coercion - # gives the correct relative offset. - # 2. pandas.Period — period arithmetic works on the Period - # object directly (``p - ref`` returns ordinal-difference); - # we cast via the `n` attribute on the resulting offset for - # sub-period frequencies. Datetime-like labels (Timestamp, - # np.datetime64) are caught the same way and converted to - # days via numpy timedelta semantics. - # 3. Genuinely non-numeric / unordered labels (string period - # IDs, categoricals without a ranking) — emit an explicit - # UserWarning and fall back to the legacy count-based - # [n_pre-1, ..., 0] / ||·||_2 normalized direction. The - # reported MDV under this fallback is NOT in Roth's γ - # units; users on non-numeric labels who need γ-unit MDV - # should re-fit with numeric period labels. - ref = getattr(results, "reference_period", None) - relative_times: Optional[np.ndarray] = None - if ref is not None: - relative_times = _coerce_relative_times_from_reference(estimated_pre_periods, ref) - return effects, ses, vcov, n_pre, relative_times, covariance_source - # Try CallawaySantAnnaResults try: from diff_diff.staggered import CallawaySantAnnaResults @@ -1286,8 +1213,7 @@ def _extract_pre_period_params( raise ValueError("No pre-treatment periods found in event study.") # An explicitly requested pre-period subset is honored, - # never silently ignored (mirrors the MPD branch and the - # container branch). + # never silently ignored (mirrors the container branch). if pre_periods is not None: _missing = [t for t in sorted(pre_periods) if t not in pre_effects] if _missing: @@ -1322,8 +1248,8 @@ def _extract_pre_period_params( # NORMALIZED ZERO (his omitted period is labeled t=0): # delta must vanish at the omitted reference, so relative # times are measured FROM it - |t - t_ref| - not from - # treatment (the MPD branch's - # _coerce_relative_times_from_reference precedent). + # treatment (the _coerce_relative_times_from_reference + # precedent). # Universal fits carry the anchor as the singleton # reference_event_times; varying fits have no common # reference (raw labels retained under the varying-base @@ -1414,11 +1340,12 @@ def _extract_pre_period_params( raise TypeError( f"Unsupported results type: {type(results)}. " - "Expected MultiPeriodDiDResults, CallawaySantAnnaResults, " - "SunAbrahamResults, or an EventStudyResults container from " + "Expected CallawaySantAnnaResults, " + "SunAbrahamResults, or an EventStudyResults container (the " + "TwoWayFixedEffects event-study mode, " "CallawaySantAnnaResults.aggregate('event_study'), " "DMLDiDResults.aggregate('event_study'), or " - "StackedDiDResults.aggregate('event_study')." + "StackedDiDResults.aggregate('event_study'))." ) def _extract_calendar_container_pre_period_params( @@ -1428,8 +1355,8 @@ def _extract_calendar_container_pre_period_params( ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, int, Optional[np.ndarray], str]: """Calendar-scale container branch (TWFE event-study mode, M-010). - Reconstructs EXACTLY the inputs the native - ``MultiPeriodDiDResults`` branch reads: the pre-period set from + Reconstructs EXACTLY the inputs the former native per-period-dict + branch read: the pre-period set from the container's authoritative ``post_periods`` partition provenance (never a positional split), per-period effects/SEs, the pre-period vcov sub-block via the container's explicit @@ -1449,8 +1376,7 @@ def _extract_calendar_container_pre_period_params( raise TypeError( "PreTrendsPower accepts calendar-scale EventStudyResults " "containers from the TwoWayFixedEffects event-study mode " - f"only (got source={surface.source!r}). For MultiPeriodDiD " - "pass the native MultiPeriodDiDResults object." + f"only (got source={surface.source!r})." ) if surface.post_periods is None: raise TypeError( @@ -1482,7 +1408,7 @@ def _extract_calendar_container_pre_period_params( post_set = set(surface.post_periods) _by_label = {t: i for i, t in enumerate(_all_labels)} - # The native MPD complement rule for the declared pre grid; the + # The calendar complement rule for the declared pre grid; the # eligible set then drops the reference and unusable-inference rows # in CHRONOLOGICAL (sorted) order. declared_pre = [p for p in sorted(_all_labels) if p not in post_set] @@ -1604,7 +1530,7 @@ def _extract_container_pre_period_params( "mode (calendar-scale surfaces) only " f"(got source={surface.source!r}). For other estimators " "pass the native results object where supported " - "(MultiPeriodDiDResults, CallawaySantAnnaResults, or " + "(CallawaySantAnnaResults or " "SunAbrahamResults); EfficientDiDResults, " "ImputationDiDResults and ContinuousDiDResults containers " "are rejected BY DESIGN " @@ -2113,7 +2039,7 @@ def power_minus_target(M: float) -> float: def fit( self, - results: Union[MultiPeriodDiDResults, Any], + results: Any, M: Optional[float] = None, pre_periods: Optional[List[int]] = None, ) -> PreTrendsPowerResults: @@ -2122,7 +2048,7 @@ def fit( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, SunAbrahamResults, or EventStudyResults + results : CallawaySantAnnaResults, SunAbrahamResults, or EventStudyResults Results from an event study estimation, or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, @@ -2198,7 +2124,7 @@ def fit( def power_at( self, - results: Union[MultiPeriodDiDResults, Any], + results: Any, M: float, pre_periods: Optional[List[int]] = None, ) -> float: @@ -2224,7 +2150,7 @@ def power_at( def power_curve( self, - results: Union[MultiPeriodDiDResults, Any], + results: Any, M_grid: Optional[List[float]] = None, n_points: int = 50, pre_periods: Optional[List[int]] = None, @@ -2283,7 +2209,7 @@ def power_curve( def sensitivity_to_honest_did( self, - results: Union[MultiPeriodDiDResults, Any], + results: Any, pre_periods: Optional[List[int]] = None, ) -> Dict[str, Any]: """ @@ -2368,7 +2294,7 @@ def sensitivity_to_honest_did( def compute_pretrends_power( - results: Union[MultiPeriodDiDResults, Any], + results: Any, M: Optional[float] = None, alpha: float = 0.05, target_power: float = 0.80, @@ -2383,7 +2309,7 @@ def compute_pretrends_power( Parameters ---------- results : results object - Event study results (MPD / CS / SA), or the unified event-study + Event study results (CS / SA), or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, ``DMLDiDResults.aggregate('event_study')``, or ``StackedDiDResults.aggregate('event_study')`` (Stacked @@ -2437,7 +2363,7 @@ def compute_pretrends_power( def compute_mdv( - results: Union[MultiPeriodDiDResults, Any], + results: Any, alpha: float = 0.05, target_power: float = 0.80, violation_type: str = "linear", @@ -2451,7 +2377,7 @@ def compute_mdv( Parameters ---------- results : results object - Event study results (MPD / CS / SA), or the unified event-study + Event study results (CS / SA), or the unified event-study container from ``CallawaySantAnnaResults.aggregate('event_study')``, ``DMLDiDResults.aggregate('event_study')``, or ``StackedDiDResults.aggregate('event_study')`` (Stacked diff --git a/diff_diff/results.py b/diff_diff/results.py index fb5efed46..970a514ba 100644 --- a/diff_diff/results.py +++ b/diff_diff/results.py @@ -86,7 +86,7 @@ def _format_vcov_label( if vcov_type == "conley": # Cross-sectional Conley on direct LinearRegression / compute_robust_vcov, # or panel block-decomposed Conley (within-period spatial + within-unit - # Bartlett serial) on DifferenceInDifferences / MultiPeriodDiD / + # Bartlett serial) on DifferenceInDifferences / # TwoWayFixedEffects. With an explicit cluster_name, the combined # spatial + cluster product kernel applies (Wave A #119). lag_suffix = f", lag_cutoff={conley_lag_cutoff}" if conley_lag_cutoff is not None else "" @@ -151,7 +151,7 @@ class DiDResults(BaseResults): # Variance-covariance family: "classical" | "hc1" | "hc2" | "hc2_bm" | # "conley". Plus cluster_name when cluster-robust. Used by summary() to # label the SE family in the output. For vcov_type='conley' on the panel - # estimators (DifferenceInDifferences / MultiPeriodDiD / TwoWayFixedEffects), + # estimators (DifferenceInDifferences / TwoWayFixedEffects), # `conley_lag_cutoff` carries the within-unit Bartlett max lag (matches # the constructor arg). DiD requires `unit=` as a fit-time kwarg # when vcov_type='conley' (Wave A #118). @@ -242,8 +242,7 @@ def summary(self, alpha: Optional[float] = None) -> str: # replicate-weight variance instead of the analytical HC/CR sandwich; # _format_survey_block above already surfaces the survey inference # details (weight type, strata/PSU counts, replicate method), so a - # parallel "Variance: HC1/..." line would be misleading. The survey - # suppression also covers MultiPeriodDiDResults. + # parallel "Variance: HC1/..." line would be misleading. if ( self.vcov_type is not None and self.inference_method == "analytical" @@ -594,470 +593,6 @@ def to_dict(self) -> Dict[str, Any]: return base -@dataclass -class PeriodEffect: - """ - Treatment effect for a single time period. - - Attributes - ---------- - period : any - The time period identifier. - effect : float - The treatment effect estimate for this period. - se : float - Standard error of the effect estimate. - t_stat : float - T-statistic for the effect estimate. - p_value : float - P-value for the null hypothesis that effect = 0. - conf_int : tuple[float, float] - Confidence interval for the effect. - """ - - period: Any - effect: float - se: float - t_stat: float - p_value: float - conf_int: Tuple[float, float] - - def __repr__(self) -> str: - """Concise string representation.""" - sig = _get_significance_stars(self.p_value) - return ( - f"PeriodEffect(period={self.period}, effect={self.effect:.4f}{sig}, " - f"SE={self.se:.4f}, p={self.p_value:.4f})" - ) - - @property - def is_significant(self) -> bool: - """Check if the effect is statistically significant at 0.05 level.""" - return bool(self.p_value < 0.05) - - @property - def significance_stars(self) -> str: - """Return significance stars based on p-value.""" - return _get_significance_stars(self.p_value) - - -@dataclass -class MultiPeriodDiDResults(BaseResults): - """ - Results from a Multi-Period Difference-in-Differences estimation. - - Provides access to period-specific treatment effects as well as - an aggregate average treatment effect. - - Attributes - ---------- - period_effects : dict[any, PeriodEffect] - Dictionary mapping period identifiers to their PeriodEffect objects. - Contains all estimated period effects (pre and post, excluding - the reference period which is normalized to zero). - avg_att : float - Average Treatment effect on the Treated across post-periods only. - avg_se : float - Standard error of the average ATT. - avg_t_stat : float - T-statistic for the average ATT. - avg_p_value : float - P-value for the null hypothesis that average ATT = 0. - avg_conf_int : tuple[float, float] - Confidence interval for the average ATT. - n_obs : int - Number of observations used in estimation. - n_treated : int - Number of treated units/observations. - n_control : int - Number of control units/observations. - pre_periods : list - List of pre-treatment period identifiers. - post_periods : list - List of post-treatment period identifiers. - reference_period : any, optional - The reference (omitted) period. Its coefficient is zero by - construction and it is excluded from ``period_effects``. - interaction_indices : dict, optional - Mapping from period identifier to column index in the full - variance-covariance matrix. Used internally for sub-VCV - extraction (e.g., by HonestDiD and PreTrendsPower). - """ - - period_effects: Dict[Any, PeriodEffect] - avg_att: float - avg_se: float - avg_t_stat: float - avg_p_value: float - avg_conf_int: Tuple[float, float] - n_obs: int - n_treated: int - n_control: int - pre_periods: List[Any] - post_periods: List[Any] - alpha: float = 0.05 - coefficients: Optional[Dict[str, float]] = field(default=None) - vcov: Optional[np.ndarray] = field(default=None) - residuals: Optional[np.ndarray] = field(default=None) - fitted_values: Optional[np.ndarray] = field(default=None) - r_squared: Optional[float] = field(default=None) - reference_period: Optional[Any] = field(default=None) - interaction_indices: Optional[Dict[Any, int]] = field(default=None, repr=False) - # Survey design metadata (SurveyMetadata instance from diff_diff.survey) - survey_metadata: Optional[Any] = field(default=None) - # Inference method (always "analytical" today for MultiPeriodDiD; included for - # symmetry with DiDResults and so summary() can gate the Variance label). - inference_method: str = field(default="analytical") - n_bootstrap: Optional[int] = field(default=None) - n_clusters: Optional[int] = field(default=None) - # Variance-covariance family and cluster column for summary() labeling. - # vcov_type='conley' on MultiPeriodDiD uses the panel block-decomposed - # form; `conley_lag_cutoff` carries the within-unit Bartlett max lag - # (matches the constructor arg). See _format_vcov_label. - vcov_type: Optional[str] = field(default=None) - cluster_name: Optional[str] = field(default=None) - conley_lag_cutoff: Optional[int] = field(default=None) - # Inference df metadata: the df convention the estimator was configured - # with ("residual" | "cluster") and the effective df the reported t/p/CI - # actually used. Set only when finite and strictly positive — None under - # wild bootstrap (test-inversion), normal-distribution fallback, and NaN - # inference (incl. the df=0 replicate / invalid-cluster-df sentinels). - # Lets consumers audit which reference distribution produced the - # p-value/CI without refitting. - df_convention: Optional[str] = field(default=None) - inference_df: Optional[float] = field(default=None) - # event_study_df (spec section 5, row M-092): PER-PERIOD df provenance - - # maps each estimated period to the df actually passed to that period's - # safe_inference (the period's OWN Bell-McCaffrey DOF under hc2_bm, the - # shared analytical df otherwise; NaN when inference used normal theory - # or an undefined df). Distinct from `inference_df` above, which stores - # the POST-AVERAGE contrast df - under hc2_bm the two genuinely differ, - # so per-period consumers must read this dict, never broadcast - # inference_df. - event_study_df: Optional[Dict[Any, float]] = field(default=None, repr=False) - - # --- Inference-field aliases (balance/external-adapter compatibility) --- - @property - def att(self) -> float: - return self.avg_att - - @property - def se(self) -> float: - return self.avg_se - - @property - def conf_int(self) -> Tuple[float, float]: - return self.avg_conf_int - - @property - def p_value(self) -> float: - return self.avg_p_value - - @property - def t_stat(self) -> float: - return self.avg_t_stat - - def __repr__(self) -> str: - """Concise string representation.""" - sig = _get_significance_stars(self.avg_p_value) - return ( - f"MultiPeriodDiDResults(avg_ATT={self.avg_att:.4f}{sig}, " - f"SE={self.avg_se:.4f}, " - f"n_post_periods={len(self.post_periods)})" - ) - - @property - def pre_period_effects(self) -> Dict[Any, PeriodEffect]: - """Pre-period effects only (for parallel trends assessment).""" - return {p: pe for p, pe in self.period_effects.items() if p in self.pre_periods} - - @property - def post_period_effects(self) -> Dict[Any, PeriodEffect]: - """Post-period effects only.""" - return {p: pe for p, pe in self.period_effects.items() if p in self.post_periods} - - @property - def coef_var(self) -> float: - """Coefficient of variation: SE / abs(overall ATT). NaN when ATT is 0 or SE non-finite.""" - if not (np.isfinite(self.avg_se) and self.avg_se >= 0): - return np.nan - if not np.isfinite(self.avg_att) or self.avg_att == 0: - return np.nan - return self.avg_se / abs(self.avg_att) - - def summary(self, alpha: Optional[float] = None) -> str: - """ - Generate a formatted summary of the estimation results. - - Parameters - ---------- - alpha : float, optional - Accepted for signature uniformity. The stored intervals were - computed at fit time; a value different from the stored - ``alpha`` raises ValueError rather than silently recomputing - or relabeling. Re-fit at the desired alpha instead. - - Returns - ------- - str - Formatted summary table. - """ - alpha = _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) - conf_level = _coverage_pct(alpha) - - lines = [ - "=" * 80, - "Multi-Period Difference-in-Differences Estimation Results".center(80), - "=" * 80, - "", - f"{'Observations:':<25} {self.n_obs:>10}", - f"{'Treated observations:':<25} {self.n_treated:>10}", - f"{'Control observations:':<25} {self.n_control:>10}", - f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}", - f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}", - ] - - if self.r_squared is not None: - lines.append(f"{'R-squared:':<25} {self.r_squared:>10.4f}") - - # Add survey design info - if self.survey_metadata is not None: - sm = self.survey_metadata - lines.extend(_format_survey_block(sm, 80)) - - # Variance family label (only when inference was analytical AND not survey). - # Survey fits use Taylor linearization or replicate-weight variance, which - # _format_survey_block already surfaces above; a parallel analytical label - # would mislabel the actual inference source. - if ( - self.vcov_type is not None - and self.inference_method == "analytical" - and self.survey_metadata is None - ): - label = _format_vcov_label( - self.vcov_type, - cluster_name=self.cluster_name, - n_clusters=self.n_clusters, - n_obs=self.n_obs, - conley_lag_cutoff=self.conley_lag_cutoff, - ) - if label is not None: - lines.append(f"{'Variance:':<25} {label:>50}") - - # Pre-period effects (parallel trends test) - pre_effects = {p: pe for p, pe in self.period_effects.items() if p in self.pre_periods} - if pre_effects: - lines.extend( - [ - "", - "-" * 80, - "Pre-Period Effects (Parallel Trends Test)".center(80), - "-" * 80, - f"{'Period':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}", - "-" * 80, - ] - ) - - for period in self.pre_periods: - if period in self.period_effects: - pe = self.period_effects[period] - stars = pe.significance_stars - lines.append( - f"{str(period):<15} {pe.effect:>12.4f} {pe.se:>12.4f} " - f"{pe.t_stat:>10.3f} {pe.p_value:>10.4f} {stars:>6}" - ) - - # Show reference period - if self.reference_period is not None: - lines.append( - f"[ref: {self.reference_period}]" - f"{'0.0000':>21} {'---':>12} {'---':>10} {'---':>10} {'':>6}" - ) - - lines.append("-" * 80) - - # Post-period treatment effects - lines.extend( - [ - "", - "-" * 80, - "Post-Period Treatment Effects".center(80), - "-" * 80, - f"{'Period':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}", - "-" * 80, - ] - ) - - for period in self.post_periods: - pe = self.period_effects[period] - stars = pe.significance_stars - lines.append( - f"{str(period):<15} {pe.effect:>12.4f} {pe.se:>12.4f} " - f"{pe.t_stat:>10.3f} {pe.p_value:>10.4f} {stars:>6}" - ) - - # Average effect - lines.extend( - [ - "-" * 80, - "", - "-" * 80, - "Average Treatment Effect (across post-periods)".center(80), - "-" * 80, - f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}", - "-" * 80, - f"{'Avg ATT':<15} {self.avg_att:>12.4f} {self.avg_se:>12.4f} " - f"{self.avg_t_stat:>10.3f} {self.avg_p_value:>10.4f} {self.significance_stars:>6}", - "-" * 80, - "", - f"{conf_level}% Confidence Interval: [{self.avg_conf_int[0]:.4f}, {self.avg_conf_int[1]:.4f}]", - ] - ) - - cv = self.coef_var - if np.isfinite(cv): - lines.append(f"{'CV (SE/abs(ATT)):':<25} {cv:>10.4f}") - - # Add significance codes - lines.extend( - [ - "", - "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1", - "=" * 80, - ] - ) - - return "\n".join(lines) - - def print_summary(self, alpha: Optional[float] = None) -> None: - """Print the summary to stdout.""" - print(self.summary(alpha)) - - def get_effect(self, period) -> PeriodEffect: - """ - Get the treatment effect for a specific period. - - Parameters - ---------- - period : any - The period identifier. - - Returns - ------- - PeriodEffect - The treatment effect for the specified period. - - Raises - ------ - KeyError - If the period is not found in post-treatment periods. - """ - if period not in self.period_effects: - if hasattr(self, "reference_period") and period == self.reference_period: - raise KeyError( - f"Period '{period}' is the reference period (coefficient " - f"normalized to zero by construction). Its effect is 0.0 with " - f"no associated uncertainty." - ) - raise KeyError( - f"Period '{period}' not found. " - f"Available periods: {list(self.period_effects.keys())}" - ) - return self.period_effects[period] - - def to_dict(self) -> Dict[str, Any]: - """ - Convert results to a dictionary. - - Returns - ------- - Dict[str, Any] - Dictionary containing all estimation results. - """ - result: Dict[str, Any] = { - "avg_att": self.avg_att, - "avg_se": self.avg_se, - "avg_t_stat": self.avg_t_stat, - "avg_p_value": self.avg_p_value, - "avg_conf_int_lower": self.avg_conf_int[0], - "avg_conf_int_upper": self.avg_conf_int[1], - "n_obs": self.n_obs, - "n_treated": self.n_treated, - "n_control": self.n_control, - "n_pre_periods": len(self.pre_periods), - "n_post_periods": len(self.post_periods), - "r_squared": self.r_squared, - "reference_period": self.reference_period, - } - # Variance-family metadata: included only when set, so existing - # dict consumers see no new keys for non-conley / non-cluster fits. - if self.vcov_type is not None: - result["vcov_type"] = self.vcov_type - if self.cluster_name is not None: - result["cluster_name"] = self.cluster_name - if self.conley_lag_cutoff is not None: - result["conley_lag_cutoff"] = self.conley_lag_cutoff - if self.df_convention is not None: - result["df_convention"] = self.df_convention - if self.inference_df is not None: - result["inference_df"] = self.inference_df - - # Add period-specific effects - for period, pe in self.period_effects.items(): - result[f"effect_period_{period}"] = pe.effect - result[f"se_period_{period}"] = pe.se - result[f"pval_period_{period}"] = pe.p_value - - # Add survey metadata if present - if self.survey_metadata is not None: - sm = self.survey_metadata - result["weight_type"] = sm.weight_type - result["effective_n"] = sm.effective_n - result["design_effect"] = sm.design_effect - result["sum_weights"] = sm.sum_weights - result["n_strata"] = sm.n_strata - result["n_psu"] = sm.n_psu - result["df_survey"] = sm.df_survey - - return result - - def to_dataframe(self) -> pd.DataFrame: - """ - Convert period-specific effects to a pandas DataFrame. - - Returns - ------- - pd.DataFrame - DataFrame with one row per estimated period (pre and post). - """ - rows = [] - for period, pe in self.period_effects.items(): - rows.append( - { - "period": period, - "effect": pe.effect, - "se": pe.se, - "t_stat": pe.t_stat, - "p_value": pe.p_value, - "conf_int_lower": pe.conf_int[0], - "conf_int_upper": pe.conf_int[1], - "is_significant": pe.is_significant, - "is_post": period in self.post_periods, - } - ) - return pd.DataFrame(rows) - - @property - def is_significant(self) -> bool: - """Check if the average ATT is statistically significant at the alpha level.""" - return bool(self.avg_p_value < self.alpha) - - @property - def significance_stars(self) -> str: - """Return significance stars for the average ATT based on p-value.""" - return _get_significance_stars(self.avg_p_value) - - @dataclass class _SyntheticDiDFitSnapshot: """Internal snapshot of fit() state retained on SyntheticDiDResults for diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index 474c8aa05..31e2cd703 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -20,6 +20,7 @@ from __future__ import annotations +import warnings from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple, Union, cast @@ -212,6 +213,59 @@ def _json_safe_label(value: Any) -> Any: ) +def _significance_stars(p_value: float) -> str: + """Leaf-local twin of ``results._get_significance_stars`` (NaN -> ""). + + ``results_base`` imports no other ``diff_diff`` module, so the M-016 view + carries its own copy of the convention. + """ + if np.isnan(p_value): + return "" + if p_value < 0.001: + return "***" + if p_value < 0.01: + return "**" + if p_value < 0.05: + return "*" + if p_value < 0.1: + return "." + return "" + + +_PERIOD_EFFECTS_REMOVED_MSG = ( + "EventStudyResults.period_effects is deprecated and will be removed in " + "5.0; use to_dataframe() (one row per event_time, reference row included) " + "or the columnar att/se/t_stat/p_value/conf_int_lower/conf_int_upper " + "arrays instead." +) + +_PERIOD_EFFECTS_SCALE_MSG = ( + "period_effects is a compatibility view of the former calendar-keyed " + "per-period dict (row M-016) and exists only on time_scale='calendar' " + "surfaces; relative-time surfaces never had it - use to_dataframe()." +) + + +@dataclass(frozen=True) +class _PeriodEffectView: + """One row of the deprecated ``EventStudyResults.period_effects`` view (row M-016). + + Private, read-only snapshot of a non-reference row of a calendar surface; + mirrors the per-period record the 4.0 cut removed (row M-012). + ``is_significant`` is evaluated at the surface's ``alpha`` (NaN p-value -> + False); ``significance_stars`` follows the package convention. + """ + + period: Any + effect: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] + is_significant: bool + significance_stars: str + + @dataclass class EventStudyResults(BaseResults): """Unified event-study representation (spec section 5, row M-092). @@ -228,9 +282,9 @@ class EventStudyResults(BaseResults): event_time : np.ndarray Sorted estimator-native event-time labels, NEVER renumbered. Relative producers use their own origin (see - ``event_time_convention``); the pre-4.0 MultiPeriodDiD surface is - calendar-keyed (``time_scale="calendar"``) and may carry object - dtype (str/datetime period labels). + ``event_time_convention``); the TwoWayFixedEffects event-study + surface is calendar-keyed (``time_scale="calendar"``) and may carry + object dtype (str/datetime period labels). att, se, t_stat, p_value : np.ndarray Canonical per-event-time inference columns. On the reference row (if any): ``att == 0.0`` and ``se``/``t_stat``/``p_value`` are NaN. @@ -276,8 +330,9 @@ class EventStudyResults(BaseResults): ``int(t - g)`` — see its REGISTRY truncation Note.) vcov : np.ndarray or None Full event-study variance-covariance matrix where the RESULT - CONTAINER exposes one (e.g. CallawaySantAnna, SunAbraham, - MultiPeriodDiD, StackedDiD, and TwoStageDiD's analytical modes), + CONTAINER exposes one (e.g. CallawaySantAnna, SunAbraham, the + TwoWayFixedEffects event-study mode, StackedDiD, and TwoStageDiD's + analytical modes), ordered by ``vcov_index``. None when the producer records no matrix or when the stored SEs are no longer its diagonal (bootstrap and replicate-weight overrides clear it rather than ship an @@ -307,7 +362,7 @@ class EventStudyResults(BaseResults): Chaisemartin-D'Haultfoeuille, whose effect and placebo rows share one design df), or a length-n array (per-row producers: StackedDiD and SunAbraham ``hc2_bm`` per-event Bell-McCaffrey df, LPDiD per-horizon - cluster df, MultiPeriodDiD ``hc2_bm`` per-period df). + cluster df, TwoWayFixedEffects event-study ``hc2_bm`` per-period df). NaN on any row means normal-theory inference, an undefined df, bootstrap-overridden inference, or a producer that records none; reference rows and rows with NaN p-values are always NaN. @@ -395,20 +450,19 @@ class EventStudyResults(BaseResults): estimand: Optional[str] = None #: Calendar-partition provenance (M-092 pre-cut amendment #5, with row #: M-010): the producer's AUTHORITATIVE post-treatment period labels. - #: MultiPeriodDiD accepts an ARBITRARY ``post_periods`` subset (a - #: non-suffix split is legal), and the partition is not recoverable + #: The TWFE event-study mode accepts an ARBITRARY ``post_periods`` subset + #: (a non-suffix split is legal), and the partition is not recoverable #: from ``event_time``/``is_reference`` positionally - consumers that #: need pre/post classification on a calendar surface (HonestDiD, #: PreTrendsPower, the plotter) read THIS field; pre-periods derive as #: the complement: non-reference rows whose ``event_time`` is not in - #: ``post_periods``. Threaded by the calendar-surface builder (the - #: MultiPeriodDiD adapter and the TWFE event-study producer); None - #: from every other builder. + #: ``post_periods``. Threaded by the calendar-surface builder behind the + #: TWFE event-study producer; None from every other builder. post_periods: Optional[Tuple[Any, ...]] = None #: TWFE event-study design provenance (M-092 amendment #5, with row - #: M-010): ``"within"`` (unit + time FE) or ``"pooled"`` (the - #: MultiPeriodDiD design - treatment-group dummy + period dummies, no - #: unit FE). The two specs differ materially in SEs, so the design + #: M-010): ``"within"`` (unit + time FE) or ``"pooled"`` (the pre-4.0 + #: pooled event-study design - treatment-group dummy + period dummies, + #: no unit FE). The two specs differ materially in SEs, so the design #: must be recoverable from the container. None from every builder #: (only the TwoWayFixedEffects event-study producer sets it). estimation_spec: Optional[str] = None @@ -612,6 +666,52 @@ def reference_periods(self) -> List[Any]: """ return [_json_safe_label(v) for v in self.event_time[self.is_reference].tolist()] + @property + def period_effects(self) -> Dict[Any, _PeriodEffectView]: + """Deprecated calendar-keyed per-period dict (row M-016; removed in 5.0). + + Compatibility view of the former ``period_effects`` dict: one + :class:`_PeriodEffectView` per NON-reference row, keyed by the row's + calendar label (the reference row is omitted, as the old dict did). + Only ``time_scale="calendar"`` surfaces carry it; reading it on a + relative-time surface raises ``AttributeError`` (so ``hasattr`` is + False there and nothing warns). Reading it emits ``FutureWarning``. + Use :meth:`to_dataframe` (one row per ``event_time``, reference row + included) or the columnar arrays instead. + """ + if self.time_scale != "calendar": + raise AttributeError(_PERIOD_EFFECTS_SCALE_MSG) + # Iterate the array itself (not .tolist(), which turns datetime64[ns] + # labels into integer nanoseconds); the elements hash/compare like the + # calendar labels the old dict was keyed by. + # Uniqueness is checked over EVERY row (reference included): a label + # shared by a reference row and an estimated row would otherwise + # surface the estimate under the reference's key. + labels = list(self.event_time) + if len(set(labels)) != len(labels): + raise ValueError( + "The event-study surface carries duplicate event_time labels " + f"({self.event_time.tolist()}); each label must appear exactly once " + "for a label-keyed period_effects view." + ) + warnings.warn(_PERIOD_EFFECTS_REMOVED_MSG, FutureWarning, stacklevel=2) + out: Dict[Any, _PeriodEffectView] = {} + for i, label in enumerate(self.event_time): + if self.is_reference[i]: + continue + p = float(self.p_value[i]) + out[label] = _PeriodEffectView( + period=label, + effect=float(self.att[i]), + se=float(self.se[i]), + t_stat=float(self.t_stat[i]), + p_value=p, + conf_int=(float(self.conf_int_lower[i]), float(self.conf_int_upper[i])), + is_significant=bool(p < self.alpha), + significance_stars=_significance_stars(p), + ) + return out + # ------------------------------------------------------------------ # Serialization contract # ------------------------------------------------------------------ @@ -1139,18 +1239,15 @@ def _from_relative_dict(results: Any) -> EventStudyResults: class _EventStudyCorePayload: """Producer-neutral output of ``DifferenceInDifferences._fit_event_study_core``. - Private hand-off between the shared event-study core and its two - builders: ``MultiPeriodDiD.fit`` wraps it into the legacy - ``MultiPeriodDiDResults`` container, and the TWFE event-study mode - builds the unified :class:`EventStudyResults` surface from it directly - (:func:`_from_event_study_payload`) - so the TWFE path no longer - constructs a MultiPeriodDiD container (ledger rows M-010 / M-011). - - Per-period arrays are index-aligned to ``periods``, the core's SORTED - non-reference iteration order. The ``avg_*`` block, ``inference_df`` and - the count / label fields exist only for the MultiPeriodDiD container and - are deleted with it at the 4.0 cut (PR 4c-ii). ``eq=False``: the - generated ``__eq__``/``__hash__`` would raise on the ndarray fields. + Private hand-off between the shared event-study core and the TWFE + event-study mode, which builds the unified :class:`EventStudyResults` + surface from it (:func:`_from_event_study_payload`). Carries exactly what + the surface builder reads: per-period arrays index-aligned to ``periods`` + (the core's SORTED non-reference iteration order), the partition and + alpha, the full-design vcov with the interaction column map (for the + event-study vcov sub-block) and the survey metadata (for the scalar + ``df_survey``). ``eq=False``: the generated ``__eq__``/``__hash__`` would + raise on the ndarray fields. """ # Per-period inference (one entry per non-reference period, sorted). @@ -1164,35 +1261,12 @@ class _EventStudyCorePayload: df_by_period: Dict[Any, float] # Partition / normalisation. reference_period: Any - pre_periods: List[Any] post_periods: List[Any] alpha: float - # Full-design inference (the MultiPeriodDiD container + the surface's + # Full-design vcov + the interaction column map (the surface's # event-study vcov sub-block). - coefficients: np.ndarray - coef_names: List[str] vcov: np.ndarray interaction_indices: Dict[Any, int] - residuals: np.ndarray - fitted_values: np.ndarray - r_squared: float - # MultiPeriodDiD-only: the post-period average and its contrast df - # (die with the class at 4c-ii). - avg_att: float - avg_se: float - avg_t_stat: float - avg_p_value: float - avg_conf_int: Tuple[float, float] - inference_df: Optional[float] - # MultiPeriodDiD-only counts and labels. - n_obs: int - n_treated: int - n_control: int - vcov_type: Optional[str] - cluster_name: Optional[str] - n_clusters: Optional[int] - conley_lag_cutoff: Optional[int] - df_convention: Optional[str] # Shared provenance (the survey df is resolved from it). survey_metadata: Optional[Any] @@ -1221,10 +1295,10 @@ def _calendar_surface_from_arrays( ) -> EventStudyResults: """Build the calendar-keyed surface from index-aligned per-period arrays. - The one builder behind both :func:`_from_mpd` (which extracts the arrays - from a ``MultiPeriodDiDResults``) and :func:`_from_event_study_payload` - (which reads them off the core's payload), so the two paths cannot - drift. ``periods`` must already be SORTED (the caller supplies the + The builder behind :func:`_from_event_study_payload` (which reads the + arrays off the core's payload; through 3.x the removed class's adapter + shared it, so the two paths could not drift). ``periods`` must + already be SORTED (the caller supplies the non-reference labels in sorted order and the arrays aligned to them); an explicit raise guards that contract - a silent re-sort of the labels alone would pair labels with the wrong values. The reference row is @@ -1309,44 +1383,6 @@ def _calendar_surface_from_arrays( ) -def _from_mpd(results: Any) -> EventStudyResults: - """Adapter for MultiPeriodDiD's calendar-keyed ``period_effects``. - - The reference period is omitted from the native dict and recorded on - ``results.reference_period``; the marked row is synthesized by the - shared builder (``att=0.0``, NaN inference). The dict's insertion order - is irrelevant: the labels are sorted here and the value lists built in - that order before the hand-off. - """ - period_effects = getattr(results, "period_effects", None) - if not period_effects: - raise _absent(results) - - periods = sorted(period_effects.keys()) - pes = [period_effects[p] for p in periods] - provenance = _provenance_kwargs(results) - return _calendar_surface_from_arrays( - periods=periods, - ref=getattr(results, "reference_period", None), - att=[pe.effect for pe in pes], - se=[pe.se for pe in pes], - t_stat=[pe.t_stat for pe in pes], - p_value=[pe.p_value for pe in pes], - ci_lo=[pe.conf_int[0] for pe in pes], - ci_hi=[pe.conf_int[1] for pe in pes], - full_vcov=getattr(results, "vcov", None), - interaction_indices=getattr(results, "interaction_indices", None), - df_map=getattr(results, "event_study_df", None), - post_periods=getattr(results, "post_periods", None), - alpha=getattr(results, "alpha", 0.05), - source=type(results).__name__, - df_survey=provenance["df_survey"], - base_period=provenance["base_period"], - anticipation=provenance["anticipation"], - reference_event_times=provenance["reference_event_times"], - ) - - def _from_event_study_payload( payload: _EventStudyCorePayload, *, @@ -1355,10 +1391,9 @@ def _from_event_study_payload( ) -> EventStudyResults: """Build the unified surface directly from the event-study core's payload. - The TWFE event-study producer's path: no MultiPeriodDiD container is - constructed. Field for field this is what ``_from_mpd`` yields for the - MultiPeriodDiD twin of the same fit (pinned by - ``tests/test_twfe_event_study_decoupling.py``). + The TWFE event-study producer's path, pinned field for field against the + committed pre-refactor capture by + ``tests/test_twfe_event_study_decoupling.py``. """ return _calendar_surface_from_arrays( periods=payload.periods, @@ -1584,17 +1619,19 @@ def build_event_study_surface(results: Any) -> EventStudyResults: an absent surface raises ``ValueError`` naming the call that produces it (never a silent empty container). """ + if isinstance(results, EventStudyResults): + # A surface is already the surface: never duck-sniff it (the M-016 + # period_effects view is a warning property - hasattr would fire it). + return results # Order matters: dCDH carries both event_study_effects and the split - # placebo dict; HAD's columnar container and MPD's period_effects have - # unique attributes; LPDiD's surface is a DataFrame field. (The TWFE - # event-study producer never arrives here: it builds the surface - # natively from the core's payload via _from_event_study_payload.) + # placebo dict; HAD's columnar container has unique attributes; LPDiD's + # surface is a DataFrame field. (The TWFE event-study producer returns + # the surface natively, built from the core's payload via + # _from_event_study_payload, so it takes the passthrough above.) if hasattr(results, "placebo_event_study") and hasattr(results, "event_study_effects"): return _from_dcdh(results) if hasattr(results, "event_times") and hasattr(results, "n_obs_per_horizon"): return _from_had(results) - if hasattr(results, "period_effects") and hasattr(results, "interaction_indices"): - return _from_mpd(results) if hasattr(results, "pooled") and hasattr(results, "event_study"): return _from_lpdid(results) if hasattr(results, "event_study_effects"): @@ -1605,7 +1642,8 @@ def build_event_study_surface(results: Any) -> EventStudyResults: f"{type(results).__name__} does not expose an event-study surface. " "Supported producers: CallawaySantAnna, DMLDiD, SunAbraham, ImputationDiD, " "TwoStageDiD, StackedDiD, SpilloverDiD, ContinuousDiD, EfficientDiD, " - "WooldridgeDiD, TripleDifference (staggered mode), MultiPeriodDiD, LPDiD, " + "WooldridgeDiD, TripleDifference (staggered mode), LPDiD, " "ChaisemartinDHaultfoeuille, and HeterogeneousAdoptionDiD " - "event-study results." + "event-study results; the TwoWayFixedEffects event-study mode returns " + "the surface natively." ) diff --git a/diff_diff/stacked_did.py b/diff_diff/stacked_did.py index f86ecb63e..f5cdc454b 100644 --- a/diff_diff/stacked_did.py +++ b/diff_diff/stacked_did.py @@ -707,7 +707,7 @@ def fit( # Satterthwaite DOF for each contrast — not the normal distribution # that safe_inference(df=None) would otherwise default to. Mirrors # the SunAbraham aggregated-inference pattern from PR #472 - # (sun_abraham.py:997-1097) and the MPD avg_att pattern from PR #465. + # (sun_abraham.py:997-1097) and the event-study core's contrast pattern from PR #465. # Computed BEFORE constructing event_study_effects / overall_* # inference so the DOFs can be threaded into the safe_inference calls. _bm_contrast_dof_per_event: Dict[int, float] = {} @@ -715,7 +715,7 @@ def fit( if self.vcov_type == "hc2_bm" and not _uses_replicate_sd and not np.all(np.isnan(coef)): from diff_diff.linalg import _compute_cr2_bm_contrast_dof - # Mirror the MultiPeriodDiD rank-deficient pattern (PR #465, + # Mirror the event-study core's rank-deficient pattern (PR #465, # estimators.py:1860-1913): solve_ols emits NaN for dropped # coefficients under R-style rank handling. Subset X, bread, # and contrast vectors to the identified-column block BEFORE diff --git a/diff_diff/sun_abraham.py b/diff_diff/sun_abraham.py index 43c5692be..33d78e6c0 100644 --- a/diff_diff/sun_abraham.py +++ b/diff_diff/sun_abraham.py @@ -1272,7 +1272,7 @@ def _refit_sa(w_r): # aggregated inference must use CR2 Bell-McCaffrey Satterthwaite # DOF for each contrast — not the normal distribution that # `safe_inference(..., df=None)` would otherwise default to. - # Mirrors the MultiPeriodDiD post-period-average contrast pattern + # Mirrors the event-study core's per-contrast BM DOF pattern # added in PR #465 (`_compute_cr2_bm_contrast_dof`). _es_contrast_dofs: Dict[int, float] = {} _overall_att_contrast_dof: Optional[float] = None diff --git a/diff_diff/twfe.py b/diff_diff/twfe.py index 4059b5967..f546da935 100644 --- a/diff_diff/twfe.py +++ b/diff_diff/twfe.py @@ -12,7 +12,7 @@ from diff_diff.bacon import BaconDecompositionResults from diff_diff.survey import SurveyDesign -from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg +from diff_diff._deprecation import NOT_SUPPLIED, require_arg from diff_diff.estimators import DifferenceInDifferences from diff_diff.linalg import LinearRegression from diff_diff.results import DiDResults @@ -82,10 +82,10 @@ class TwoWayFixedEffects(DifferenceInDifferences): time=)`` estimates per-period treatment effects and returns the unified :class:`~diff_diff.results_base.EventStudyResults` surface instead of - the single static ATT (absorbing the deprecated ``MultiPeriodDiD``). + the single static ATT (absorbing the class removed at the 4.0 cut, row M-010). ``spec="within"`` (default) estimates the unit-FE event study ``α_i + γ_t + Σ_e δ_e (D_i × 1[t=e])``; ``spec="pooled"`` reproduces - the MultiPeriodDiD design exactly (treatment-group dummy + period + the pre-4.0 pooled event-study design exactly (treatment-group dummy + period dummies, no unit FE - the only spec valid for repeated cross-sections). Point estimates coincide across the two specs only on balanced no-covariate simultaneous-adoption panels; standard @@ -98,7 +98,7 @@ class TwoWayFixedEffects(DifferenceInDifferences): assumption cannot be verified by the estimator - it is asserted by the user. For the same reason the calendar partition is explicit: ``post_periods=`` is REQUIRED in event-study mode (the deprecated - MultiPeriodDiD midpoint default is not carried over). For staggered + pre-4.0 midpoint default is not carried over). For staggered designs use ``CallawaySantAnna`` or ``SunAbraham``. The mode carries TWFE's inference stack from day one: unit auto-cluster (with the same carve-outs as the @@ -188,8 +188,10 @@ def fit( # type: ignore[override] Name of treatment indicator column. post : str Static mode: name of the 0/1 post-treatment indicator column - (renamed from ``time``, row M-082 - see Notes). Not accepted in - event-study mode (which takes the calendar ``time=`` instead). + (the static-mode name since 3.9, row M-082; since 4.0 passing + ``time=`` in static mode raises, row M-083 - see Notes). Not + accepted in event-study mode (which takes the calendar ``time=`` + instead). unit : str Name of unit identifier column. Required in static mode and for ``spec="within"``; optional for ``spec="pooled"`` (the only @@ -208,12 +210,12 @@ def fit( # type: ignore[override] applies sampling weights to the regression. event_study : bool, default False Estimate a per-period event study instead of the single static - ATT (row M-010, absorbing MultiPeriodDiD). Returns the unified + ATT (row M-010, the merged event-study mode). Returns the unified :class:`~diff_diff.results_base.EventStudyResults` surface. spec : {"within", "pooled"}, default "within" Event-study design. ``"within"`` estimates the unit-FE event study (unit + time fixed effects + per-period treatment - interactions). ``"pooled"`` reproduces the MultiPeriodDiD + interactions). ``"pooled"`` reproduces the pre-4.0 pooled design exactly (treatment-group dummy + period dummies, no unit FE; the only spec valid for repeated cross-sections). Point estimates coincide only in the restricted equivalence @@ -227,14 +229,15 @@ def fit( # type: ignore[override] (non-empty) when ``event_study=True``: the treatment boundary is not observable from a time-invariant ever-treated indicator, so it cannot be inferred - the deprecated - MultiPeriodDiD midpoint default (last half of the calendar) + pre-4.0 midpoint default (last half of the calendar) is deliberately not carried into the merged mode. time : str Event-study mode: the calendar time column (keyword-only in practice - positional slot 4 belongs to ``post``). In static - mode, ``time=`` is a DEPRECATED alias for ``post`` (row - M-082); it warns with ``FutureWarning``. From 4.0, ``time=`` - means the event-study calendar column only. + mode ``time=`` is not accepted: since 4.0 it means the calendar + column only, and passing it raises ``ValueError`` naming + ``post=`` (row M-083; through 3.x it was the deprecated alias + for ``post``, row M-082). Returns ------- @@ -247,7 +250,8 @@ def fit( # type: ignore[override] ValueError If a covariate name collides with a reserved structural term name or duplicates another covariate; if event-study-only parameters - are passed with ``event_study=False``; if ``post=`` is passed in + are passed with ``event_study=False``; if ``time=`` is passed in + static mode (row M-083); if ``post=`` is passed in event-study mode; if ``spec="within"`` lacks ``unit=``; or if ``inference="wild_bootstrap"`` is combined with event-study mode (the wild cluster bootstrap covers the static ATT only). @@ -265,8 +269,7 @@ def fit( # type: ignore[override] if event_study: # The event-study branch owns its own post/time/unit resolution # (the raw sentinels are passed through; running the static - # rename shim first would fold the calendar time= into post - # with a spurious M-082 warning). + # time=/post= contract first would reject the calendar time=). return self._fit_event_study( data, outcome, @@ -298,16 +301,22 @@ def fit( # type: ignore[override] f"post= dummy." ) - # --- Static mode: time= -> post= rename shim (row M-082) --- - post = resolve_renamed_kwarg( - "TwoWayFixedEffects.fit", - "time", - time, - "post", - post, - default=NOT_SUPPLIED, - extra="From 4.0, time= means the event-study calendar column only.", - ) + # --- Static mode: the time=/post= contract (row M-083) --- + # Since 4.0 `time` means the event-study calendar column ONLY (through + # 3.x it was the deprecated alias for `post`, row M-082): both supplied + # keeps the both-supplied error; time= alone raises rather than being + # reinterpreted as the post dummy (the TripleDifference M-085 pattern). + if post is not NOT_SUPPLIED and time is not NOT_SUPPLIED: + raise ValueError( + "TwoWayFixedEffects.fit: got both post= and time=; in static mode " + "pass only post= (time= is the event-study calendar column, row M-083)." + ) + if time is not NOT_SUPPLIED: + raise ValueError( + "TwoWayFixedEffects.fit: in static mode (event_study=False) time= is " + "the event-study calendar column only (4.0, row M-083); pass the 0/1 " + "post-treatment indicator as post=." + ) require_arg("TwoWayFixedEffects.fit", "post", post) require_arg("TwoWayFixedEffects.fit", "unit", unit) # Body-local name; the public static parameter is post (M-082). @@ -401,7 +410,7 @@ def fit( # type: ignore[override] # HC2/HC2-BM auto-route, which does not compose with the # per-replicate re-demeaning. Previously hc2/hc2_bm raised # NotImplementedError here; the warn-and-proceed contract matches - # DifferenceInDifferences/MultiPeriodDiD (shared helper). + # DifferenceInDifferences (shared helper). _replicate_vcov_remap_twfe = _uses_replicate_twfe and self._warn_replicate_vcov_ignored() if _replicate_vcov_remap_twfe: use_full_dummy = False @@ -508,7 +517,7 @@ def fit( # type: ignore[override] y = data[outcome].values.astype(np.float64) cov_arrs = [data[c].values.astype(np.float64) for c in (covariates or [])] # Shared drop-first dummy build (single implementation with the - # DiD/MPD fixed_effects= paths; names match fe_dummy_names). + # DiD / event-study fixed_effects= paths; names match fe_dummy_names). _fe_blocks, _fe_dummy_names = build_fe_dummy_blocks( data, [unit, time], prefixes=[f"_fe_{unit}", f"_fe_{time}"] ) @@ -522,7 +531,7 @@ def fit( # type: ignore[override] df_adjustment = 0 # var_names parallels the X columns so the downstream # `coefficients` dict can mirror the full-dummy vcov shape - # (matching the MPD invariant + # (matching the event-study core invariant # ``len(result.coefficients) == result.vcov.shape[0]``). _twfe_var_names: Optional[List[str]] = ( ["const", "ATT"] + list(covariates or []) + _fe_dummy_names @@ -943,7 +952,7 @@ def _refit_twfe(w_r): # preserved as the ATT key on both paths, so existing # `result.coefficients["ATT"]` consumers continue to work. The # invariant ``len(coefficients) == vcov.shape[0]`` is now upheld on the - # full-dummy path (matches the MPD absorb auto-route invariant + # full-dummy path (matches the event-study absorb auto-route invariant # checked at tests/test_estimators_vcov_type.py:1611). coef_array = np.asarray(reg.coefficients_) _coefficients_dict: dict = ( @@ -1002,7 +1011,7 @@ def _fit_event_study( Runs the shared event-study core (:meth:`DifferenceInDifferences._fit_event_study_core`) under one of - two designs - ``spec="pooled"`` is the MultiPeriodDiD design + two designs - ``spec="pooled"`` is the pre-4.0 pooled design verbatim; ``spec="within"`` absorbs the unit fixed effects and omits the (absorbed) treatment main effect - and returns the unified :class:`~diff_diff.results_base.EventStudyResults` surface. @@ -1014,9 +1023,9 @@ def _fit_event_study( between-unit pair), never injected as a survey PSU (the documented implicit-per-observation-PSU rule), and dropped for explicit one-way analytical families. Explicit ``cluster=`` always passes - through and behaves exactly like MultiPeriodDiD's own explicit + through and behaves exactly like an explicit cluster on every lane. Wild bootstrap raises: the WCR - implementation covers the static ATT only, and MultiPeriodDiD's + implementation covers the static ATT only, and the removed class's silent analytical fallback is deliberately not carried into the merged mode (no-silent-failures). """ @@ -1027,7 +1036,7 @@ def _fit_event_study( # Step 3: wild raise, first in the branch - a mode-capability error # precedes every lane front door (survey/Conley resolution happens - # inside the core, and MPD's warn-and-fallback is not inherited). + # inside the core; the removed class's warn-and-fallback is gone). if self.inference == "wild_bootstrap": raise ValueError( "inference='wild_bootstrap' is not supported in event-study " @@ -1059,7 +1068,7 @@ def _fit_event_study( # boundary is not observable from the documented time-invariant # ever-treated D_i indicator (the REGISTRY staggered-detection-limit # Note), so it cannot be inferred from the data; the deprecated - # MultiPeriodDiD midpoint default (last half of the calendar) is a + # pre-4.0 midpoint default (last half of the calendar) is a # silent guess and is deliberately not carried into the merged # mode (mirrors the day-one wild raise: no legacy defaults on the # new surface). @@ -1072,9 +1081,9 @@ def _fit_event_study( "event-study mode requires an explicit post_periods= (the " "post-treatment calendar periods): the treatment boundary " "is not observable from a time-invariant ever-treated " - "treatment indicator, and the deprecated MultiPeriodDiD " - "midpoint default (last half of the calendar) is " - "deliberately not carried into the merged mode." + "treatment indicator, and the pre-4.0 midpoint default " + "(last half of the calendar) is deliberately not carried " + "into the merged mode." ) if len(set(post_periods)) != len(post_periods): # R10: fail at the front door - the container's own @@ -1112,7 +1121,7 @@ def _fit_event_study( cluster_override = unit_resolved # None when no unit was passed # Step 7: run the shared core. spec="pooled" is the 3.x - # MultiPeriodDiD design verbatim; spec="within" absorbs the unit FE + # pre-4.0 pooled design verbatim; spec="within" absorbs the unit FE # and omits the (absorbed) treatment main-effect column. The core # has no wild path, so effective_inference is always analytical # here. _frame_offset=2: user -> fit -> _fit_event_study -> core. @@ -1170,14 +1179,13 @@ def _fit_event_study( survey_design, "analytical", include_treatment_main=(spec == "pooled"), - warn_legacy_reference_default=False, cluster_override=cluster_override, estimator_name="TwoWayFixedEffects", _frame_offset=2, ) # Step 8: build the unified surface directly from the core's payload - # (PR 4c-i decoupling, ledger rows M-010 / M-011: no MultiPeriodDiD + # (PR 4c-i decoupling, ledger rows M-010 / M-011: no legacy # container is constructed on this path). The surface carries the # TWFE provenance - source, the authoritative calendar partition and # the design spec - at construction, so it passes the container's diff --git a/diff_diff/utils.py b/diff_diff/utils.py index 032b18e81..3e9a49b37 100644 --- a/diff_diff/utils.py +++ b/diff_diff/utils.py @@ -147,8 +147,8 @@ def validate_design_term_names( Backstop for :func:`validate_covariate_names`: even after the user covariates are cleared, a fixed-effect dummy name (``{fe}_{value}``) can - still collide with a structural term — most notably a ``MultiPeriodDiD`` - ``period_{p}`` event-study key when a non-time fixed effect produces matching + still collide with a structural term — most notably an event-study + ``period_{p}`` key when a non-time fixed effect produces matching dummy names — or with another dummy. Such a duplicate would silently overwrite a coefficient when ``var_names`` is zipped into the result's ``coefficients`` dict (Python dict last-write-wins). This checks the FINAL @@ -228,8 +228,8 @@ def build_fe_dummy_blocks( """Materialize drop-first fixed-effect dummy blocks and their column names. Single shared implementation of the ``pd.get_dummies(col, prefix=..., - drop_first=True)`` design-build used by ``DifferenceInDifferences`` / - ``MultiPeriodDiD`` (``fixed_effects=``) and the ``TwoWayFixedEffects`` + drop_first=True)`` design-build used by ``DifferenceInDifferences`` and + the event-study core (``fixed_effects=``) and the ``TwoWayFixedEffects`` HC2/HC2-BM full-dummy path — previously three inline copies whose FE naming / dtype / column-order conventions could drift independently. Names match :func:`fe_dummy_names` (the reserved-name collision guard) @@ -647,7 +647,7 @@ def resolve_tail_df( and per-coefficient Bell-McCaffrey DOF keep precedence at the CALL SITE under every value — callers consult this helper only when no higher-precedence df applies. ``LinearRegression.get_inference`` and - MultiPeriodDiD keep their own equivalent inline ladders. + the event-study core keep their own equivalent inline ladders. Branch table: @@ -655,8 +655,8 @@ def resolve_tail_df( - ``"cluster"`` — ``n_clusters - 1`` when ``n_clusters >= 2``. When ``n_clusters`` is not None but ``<= 1`` the cluster df is undefined: warn and return ``0``, the fail-closed sentinel ``safe_inference`` - turns into all-NaN inference (the ``df = 0`` twin of MultiPeriodDiD's - inline ``_df_cluster_knob_invalid`` path; ``LinearRegression. + turns into all-NaN inference (the ``df = 0`` twin of the event-study + core's inline ``_df_cluster_knob_invalid`` path; ``LinearRegression. get_inference`` reaches the same observable warn+NaN outcome via an early return). Defense-in-depth only: every in-scope clustered solve raises "Need at least 2 clusters" at the vcov layer before df diff --git a/diff_diff/visualization/_event_study.py b/diff_diff/visualization/_event_study.py index 8f3a4dd95..6a5aab631 100644 --- a/diff_diff/visualization/_event_study.py +++ b/diff_diff/visualization/_event_study.py @@ -12,7 +12,6 @@ ) from diff_diff.honest_did import HonestDiDResults from diff_diff.imputation import ImputationDiDResults - from diff_diff.results import MultiPeriodDiDResults from diff_diff.results_base import EventStudyResults from diff_diff.stacked_did import StackedDiDResults from diff_diff.staggered import CallawaySantAnnaResults @@ -21,7 +20,6 @@ # Type alias for results that can be plotted PlottableResults = Union[ - "MultiPeriodDiDResults", "CallawaySantAnnaResults", "SunAbrahamResults", "ImputationDiDResults", @@ -70,8 +68,8 @@ def plot_event_study( Parameters ---------- - results : MultiPeriodDiDResults, CallawaySantAnnaResults, EventStudyResults, or DataFrame, optional - Results object from MultiPeriodDiD, CallawaySantAnna, a unified + results : CallawaySantAnnaResults, EventStudyResults, or DataFrame, optional + Results object from CallawaySantAnna, a unified :class:`~diff_diff.results_base.EventStudyResults` container (from any producer's ``aggregate('event_study')`` or ``build_event_study_surface``), or a DataFrame with columns @@ -381,8 +379,8 @@ def _pre_shading_runs(pre_x: List[int]) -> List[Tuple[int, int]]: """Contiguous runs of pre-period x-positions, as (start, end) pairs. The pre-period set need not be contiguous: a calendar surface with - non-suffix ``post_periods`` provenance (legal on MultiPeriodDiD / - the TWFE event-study mode) interleaves post periods between pre + non-suffix ``post_periods`` provenance (legal on the TWFE event-study + mode) interleaves post periods between pre periods, and a single min-to-max span would shade those post periods as pre-treatment. One span per contiguous run keeps the shading truthful on every partition shape. @@ -801,10 +799,9 @@ def _extract_plot_data( elif surface.post_periods is not None: # Calendar labels with the AUTHORITATIVE partition # provenance (row M-092 amendment #5, threaded by the - # calendar-surface builder behind the MultiPeriodDiD adapter - # and the TWFE event-study producer): the declared - # post set may be an arbitrary subset (non-suffix splits - # are legal on MultiPeriodDiD), so the positional + # calendar-surface builder behind the TWFE event-study + # producer): the declared post set may be an arbitrary + # subset (non-suffix splits are legal), so the positional # fallback below would mislabel it - always prefer the # provenance. Pre = non-reference complement. _post_set = set(surface.post_periods) @@ -890,49 +887,6 @@ def _extract_plot_data( None, ) - # Handle MultiPeriodDiDResults - if hasattr(results, "period_effects"): - effects = {} - se = {} - - for period, pe in results.period_effects.items(): - effects[period] = pe.effect - se[period] = pe.se - - if pre_periods is None and hasattr(results, "pre_periods"): - pre_periods = results.pre_periods - - if post_periods is None and hasattr(results, "post_periods"): - post_periods = results.post_periods - - if periods is None: - periods = sorted(results.period_effects.keys()) - - # Auto-detect reference period from results if not explicitly provided - ref_inferred = False - if ( - reference_period is None - and hasattr(results, "reference_period") - and results.reference_period is not None - ): - reference_period = results.reference_period - ref_inferred = True - - return ( - effects, - se, - periods, - pre_periods, - post_periods, - reference_period, - ref_inferred, - None, - None, - None, - None, - None, - ) - # Handle ChaisemartinDHaultfoeuilleResults (dCDH event study) # Must come before the generic event_study_effects branch because # dCDH results also have event_study_effects but additionally have @@ -1064,7 +1018,7 @@ def _extract_plot_data( raise TypeError( f"Cannot extract plot data from {type(results).__name__}. " - "Expected MultiPeriodDiDResults, CallawaySantAnnaResults, " + "Expected CallawaySantAnnaResults, " "SunAbrahamResults, ImputationDiDResults, " "ChaisemartinDHaultfoeuilleResults, EventStudyResults, or DataFrame. " "For a result that stores no event-study surface (DMLDiD, " @@ -1260,23 +1214,6 @@ def plot_honest_event_study( "excluded from the sensitivity analysis and carry no " "honest interval." ) - elif hasattr(original_results, "period_effects"): - # MultiPeriodDiDResults - effects_dict = {p: pe.effect for p, pe in original_results.period_effects.items()} - se_dict = {p: pe.se for p, pe in original_results.period_effects.items()} - if reference_period is None: - reference_period = getattr(original_results, "reference_period", None) - # Verified anchor: only the result's own reference_period metadata - # - a caller label that does not match it is never an anchor on - # this route (period_effects rows carry no constraint signature to - # verify against). - _anchor_period = ( - reference_period - if reference_period is not None - and reference_period == getattr(original_results, "reference_period", None) - else None - ) - periods = _honest_raw_route_periods(periods, effects_dict, se_dict, _anchor_period) elif hasattr(original_results, "event_study_effects"): # CallawaySantAnnaResults (fit-time dict surface) effects_dict = { @@ -1320,7 +1257,7 @@ def plot_honest_event_study( # Original CIs: the container's STORED intervals where available # (NaN preserved); z-reconstruction from the SE only for input routes - # that carry no stored intervals (MPD / fit-time CS dict surfaces). + # that carry no stored intervals (fit-time CS dict surfaces). alpha_val = honest_results.alpha if stored_ci_lower is not None: container_alpha = getattr(original_results, "alpha", None) diff --git a/diff_diff/wooldridge.py b/diff_diff/wooldridge.py index 3d2b0c7e8..6deeb854d 100644 --- a/diff_diff/wooldridge.py +++ b/diff_diff/wooldridge.py @@ -2486,7 +2486,7 @@ def _fit_ols( # dropped columns, so _compute_cr2_bm_contrast_dof would # LinAlgError on it. Reduce X / bread / contrasts to the kept- # column subspace before computing BM DOF (matches the existing - # MPD pattern at estimators.py:1860-1913 and SA's full-dummy + # event-study core pattern in estimators.py and SA's full-dummy # behavior). Identified (g, t) cells survive; cells whose # treatment-cell coefficient was dropped get per_cell_bm_dof=NaN # and the gt_effects loop fail-closes their inference. diff --git a/docs/api/_autosummary/diff_diff.EventStudyResults.rst b/docs/api/_autosummary/diff_diff.EventStudyResults.rst index 5390370cb..230ee595a 100644 --- a/docs/api/_autosummary/diff_diff.EventStudyResults.rst +++ b/docs/api/_autosummary/diff_diff.EventStudyResults.rst @@ -35,6 +35,7 @@ ~EventStudyResults.estimation_spec ~EventStudyResults.event_time_convention ~EventStudyResults.n_kind + ~EventStudyResults.period_effects ~EventStudyResults.post_periods ~EventStudyResults.reference_event_times ~EventStudyResults.reference_period diff --git a/docs/api/_autosummary/diff_diff.MultiPeriodDiD.rst b/docs/api/_autosummary/diff_diff.MultiPeriodDiD.rst deleted file mode 100644 index f0c3e56c8..000000000 --- a/docs/api/_autosummary/diff_diff.MultiPeriodDiD.rst +++ /dev/null @@ -1,24 +0,0 @@ -diff\_diff.MultiPeriodDiD -========================= - -.. currentmodule:: diff_diff - -.. autoclass:: MultiPeriodDiD - :no-members: - - - .. rubric:: Methods - - .. autosummary:: - - ~MultiPeriodDiD.__init__ - ~MultiPeriodDiD.fit - ~MultiPeriodDiD.get_params - ~MultiPeriodDiD.predict - ~MultiPeriodDiD.print_summary - ~MultiPeriodDiD.set_params - ~MultiPeriodDiD.summary - - - - diff --git a/docs/api/_autosummary/diff_diff.MultiPeriodDiDResults.rst b/docs/api/_autosummary/diff_diff.MultiPeriodDiDResults.rst deleted file mode 100644 index b3c2b8c43..000000000 --- a/docs/api/_autosummary/diff_diff.MultiPeriodDiDResults.rst +++ /dev/null @@ -1,67 +0,0 @@ -diff\_diff.MultiPeriodDiDResults -================================ - -.. currentmodule:: diff_diff - -.. autoclass:: MultiPeriodDiDResults - :no-members: - - - .. rubric:: Methods - - .. autosummary:: - - ~MultiPeriodDiDResults.__init__ - ~MultiPeriodDiDResults.get_effect - ~MultiPeriodDiDResults.print_summary - ~MultiPeriodDiDResults.summary - ~MultiPeriodDiDResults.to_dataframe - ~MultiPeriodDiDResults.to_dict - - - - - .. rubric:: Attributes - - .. autosummary:: - - ~MultiPeriodDiDResults.alpha - ~MultiPeriodDiDResults.att - ~MultiPeriodDiDResults.cluster_name - ~MultiPeriodDiDResults.coef_var - ~MultiPeriodDiDResults.coefficients - ~MultiPeriodDiDResults.conf_int - ~MultiPeriodDiDResults.conley_lag_cutoff - ~MultiPeriodDiDResults.df_convention - ~MultiPeriodDiDResults.event_study_df - ~MultiPeriodDiDResults.fitted_values - ~MultiPeriodDiDResults.inference_df - ~MultiPeriodDiDResults.inference_method - ~MultiPeriodDiDResults.interaction_indices - ~MultiPeriodDiDResults.is_significant - ~MultiPeriodDiDResults.n_bootstrap - ~MultiPeriodDiDResults.n_clusters - ~MultiPeriodDiDResults.p_value - ~MultiPeriodDiDResults.post_period_effects - ~MultiPeriodDiDResults.pre_period_effects - ~MultiPeriodDiDResults.r_squared - ~MultiPeriodDiDResults.reference_period - ~MultiPeriodDiDResults.residuals - ~MultiPeriodDiDResults.se - ~MultiPeriodDiDResults.significance_stars - ~MultiPeriodDiDResults.survey_metadata - ~MultiPeriodDiDResults.t_stat - ~MultiPeriodDiDResults.vcov - ~MultiPeriodDiDResults.vcov_type - ~MultiPeriodDiDResults.period_effects - ~MultiPeriodDiDResults.avg_att - ~MultiPeriodDiDResults.avg_se - ~MultiPeriodDiDResults.avg_t_stat - ~MultiPeriodDiDResults.avg_p_value - ~MultiPeriodDiDResults.avg_conf_int - ~MultiPeriodDiDResults.n_obs - ~MultiPeriodDiDResults.n_treated - ~MultiPeriodDiDResults.n_control - ~MultiPeriodDiDResults.pre_periods - ~MultiPeriodDiDResults.post_periods - diff --git a/docs/api/_autosummary/diff_diff.PeriodEffect.rst b/docs/api/_autosummary/diff_diff.PeriodEffect.rst deleted file mode 100644 index 303501b66..000000000 --- a/docs/api/_autosummary/diff_diff.PeriodEffect.rst +++ /dev/null @@ -1,31 +0,0 @@ -diff\_diff.PeriodEffect -======================= - -.. currentmodule:: diff_diff - -.. autoclass:: PeriodEffect - :no-members: - - - .. rubric:: Methods - - .. autosummary:: - - ~PeriodEffect.__init__ - - - - - .. rubric:: Attributes - - .. autosummary:: - - ~PeriodEffect.is_significant - ~PeriodEffect.significance_stars - ~PeriodEffect.period - ~PeriodEffect.effect - ~PeriodEffect.se - ~PeriodEffect.t_stat - ~PeriodEffect.p_value - ~PeriodEffect.conf_int - diff --git a/docs/api/estimators.rst b/docs/api/estimators.rst index 752979f10..04ed16d53 100644 --- a/docs/api/estimators.rst +++ b/docs/api/estimators.rst @@ -3,9 +3,9 @@ Estimators Core estimator classes for Difference-in-Differences analysis. -The main estimators module (``diff_diff.estimators``) contains the base classes -``DifferenceInDifferences`` and ``MultiPeriodDiD``. Additional estimators are -organized in separate modules for maintainability: +The main estimators module (``diff_diff.estimators``) contains the base class +``DifferenceInDifferences``. Additional estimators are organized in separate +modules for maintainability: - ``diff_diff.twfe`` - ``TwoWayFixedEffects`` estimator - ``diff_diff.synthetic_did`` - ``SyntheticDiD`` estimator @@ -15,13 +15,13 @@ for backward compatibility, so you can import any of them using: .. code-block:: python - from diff_diff import DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD, SyntheticDiD + from diff_diff import DifferenceInDifferences, TwoWayFixedEffects, SyntheticDiD Most estimators have short aliases (``TROP`` already uses its short canonical name): .. code-block:: python - from diff_diff import DiD, TWFE, EventStudy, SDiD, CS, SA, BJS, DDD, SCM, Bacon + from diff_diff import DiD, TWFE, SDiD, CS, SA, BJS, DDD, SCM, Bacon .. module:: diff_diff.estimators @@ -51,25 +51,14 @@ post-estimation result-object contract is designed. ~DifferenceInDifferences.get_params ~DifferenceInDifferences.set_params -MultiPeriodDiD (alias: ``EventStudy``) --------------------------------------- - -Event study estimator with period-specific treatment effects. -*Deprecated in 3.9, removed in 4.0*: use -:class:`~diff_diff.TwoWayFixedEffects` with ``event_study=True`` -(``spec="pooled"`` reproduces this design exactly). - -.. autoclass:: diff_diff.MultiPeriodDiD - :no-index: - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - TwoWayFixedEffects (alias: ``TWFE``) ------------------------------------- -Panel DiD with unit and time fixed effects. +Panel DiD with unit and time fixed effects. ``fit(..., event_study=True, +spec="pooled" | "within", post_periods=, reference_period=)`` is the +event-study mode with period-specific treatment effects, returning an +:class:`~diff_diff.EventStudyResults` (``spec="pooled"`` runs the pooled +design of the ``MultiPeriodDiD`` class removed at the 4.0 cut). .. module:: diff_diff.twfe diff --git a/docs/api/index.rst b/docs/api/index.rst index da05badb1..f74caa4ce 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -15,7 +15,6 @@ regression discontinuity, and the Goodman-Bacon decomposition diagnostic: diff_diff.DifferenceInDifferences diff_diff.TwoWayFixedEffects - diff_diff.MultiPeriodDiD diff_diff.SyntheticDiD diff_diff.CallawaySantAnna diff_diff.ChaisemartinDHaultfoeuille @@ -49,9 +48,7 @@ Result containers returned by estimators: :nosignatures: diff_diff.DiDResults - diff_diff.MultiPeriodDiDResults diff_diff.SyntheticDiDResults - diff_diff.PeriodEffect diff_diff.CallawaySantAnnaResults diff_diff.CSBootstrapResults diff_diff.GroupTimeEffect diff --git a/docs/api/results.rst b/docs/api/results.rst index 593372a8f..65ec0b2ff 100644 --- a/docs/api/results.rst +++ b/docs/api/results.rst @@ -37,41 +37,6 @@ Results from basic DifferenceInDifferences estimation. ~DiDResults.to_dict ~DiDResults.to_dataframe -MultiPeriodDiDResults ---------------------- - -Results from MultiPeriodDiD event study estimation. - -.. autoclass:: diff_diff.MultiPeriodDiDResults - :no-index: - :members: - :undoc-members: - :show-inheritance: - - .. rubric:: Attributes - - .. autosummary:: - - ~MultiPeriodDiDResults.period_effects - ~MultiPeriodDiDResults.att - ~MultiPeriodDiDResults.pre_periods - ~MultiPeriodDiDResults.post_periods - ~MultiPeriodDiDResults.reference_period - ~MultiPeriodDiDResults.interaction_indices - ~MultiPeriodDiDResults.pre_period_effects - ~MultiPeriodDiDResults.post_period_effects - -PeriodEffect ------------- - -Container for a single period's treatment effect in event studies. - -.. autoclass:: diff_diff.PeriodEffect - :no-index: - :members: - :undoc-members: - :show-inheritance: - SyntheticDiDResults ------------------- @@ -98,7 +63,11 @@ Shared bases introduced by the 4.0 API-unification program. ``BaseResults`` is the estimator-results base; ``Diagnostic`` marks diagnostic result containers (which carry no inference row); ``EventStudyResults`` is the unified per-event-time -representation. +representation. On calendar-time surfaces (``TwoWayFixedEffects`` event-study +mode) ``EventStudyResults.period_effects`` is a deprecated compatibility view +of the removed ``MultiPeriodDiDResults.period_effects`` (FutureWarning, ledger +M-016, removed in 5.0): a dict keyed by calendar period with the reference +period omitted; ``to_dataframe()`` is the successor. .. autoclass:: diff_diff.BaseResults :no-index: diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index f0fd66d28..65a1d06f6 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -46,8 +46,8 @@ Start here and follow the questions: - **No** → Use :class:`~diff_diff.TwoWayFixedEffects` - **Yes** → Use :class:`~diff_diff.TwoWayFixedEffects` with - ``event_study=True`` (``MultiPeriodDiD`` is deprecated in 3.9; - ``spec="pooled"`` reproduces its design) + ``event_study=True`` (``spec="pooled"`` runs the design of the + ``MultiPeriodDiD`` class removed at the 4.0 cut) 6. **Is your treated group small?** (Few treated units, many controls) @@ -72,7 +72,7 @@ Quick Reference - Panel data, simultaneous treatment - Parallel trends (all periods) - Single ATT with unit/time FE - * - ``MultiPeriodDiD`` (deprecated 3.9 → ``TwoWayFixedEffects`` ``event_study=True``) + * - ``TwoWayFixedEffects`` (``event_study=True``) - Event studies, dynamic effects - Parallel trends (pre-periods) - Period-specific effects @@ -207,9 +207,9 @@ Use :class:`~diff_diff.TwoWayFixedEffects` with ``event_study=True`` when: - All treated units receive treatment at the same time (simultaneous adoption) The default ``spec="within"`` estimates the unit-FE event study; -``spec="pooled"`` reproduces the design of the deprecated -:class:`~diff_diff.MultiPeriodDiD` (removed in 4.0) and is the only spec -valid for repeated cross-sections. +``spec="pooled"`` runs the pooled design of the ``MultiPeriodDiD`` class +removed at the 4.0 cut and is the only spec valid for repeated +cross-sections. .. code-block:: python @@ -855,9 +855,6 @@ differences helps interpret results and choose appropriate inference. * - ``TwoWayFixedEffects`` - Cluster-robust (unit level) - Always clusters at unit level after within-transformation (static AND event-study mode). Specify ``cluster`` to override. Use ``inference='wild_bootstrap'`` for few clusters (static mode only; event-study mode raises). - * - ``MultiPeriodDiD`` (deprecated 3.9) - - HC1 (heteroskedasticity-robust) - - Same as basic DiD. Cluster-robust available via ``cluster``. Wild bootstrap not yet supported for multi-coefficient inference. * - ``CallawaySantAnna`` - Analytical (influence function) - Uses influence-function SEs with WIF adjustment by default. Set ``n_bootstrap=999`` for multiplier bootstrap inference (weight types: ``rademacher``, ``mammen``, ``webb``). @@ -987,12 +984,7 @@ estimation. The depth of support varies by estimator and variance method: - Full - Full - -- - * - ``TwoWayFixedEffects`` - - Full - - Full - - Full - - -- - * - ``MultiPeriodDiD`` + * - ``TwoWayFixedEffects`` (static and ``event_study=True``) - Full - Full - Full diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index 908bfd0e2..c6521781b 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -120,7 +120,7 @@ sources: type: methodology note: "Clustered-variance conventions inventory (CR1 k + tail-df map, D/L classification); table generated by tests/test_variance_conventions.py" - path: docs/methodology/REGISTRY.md - section: "DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD" + section: "DifferenceInDifferences, TwoWayFixedEffects" type: methodology - path: docs/api/estimators.rst type: api_reference @@ -1554,6 +1554,10 @@ sources: note: "BaseResults / Diagnostic marker / EventStudyResults contract (4.0 program Phase 2; ledger rows M-091/M-092/M-093)" - path: docs/migration-4.0.md type: user_guide + note: "EventStudyResults.period_effects compatibility view (ledger row M-016) - worked example in the MultiPeriodDiD merge section" + - path: diff_diff/guides/llms-full.txt + type: user_guide + note: "EventStudyResults.period_effects compatibility view block (M-016 reader)" diff_diff/bootstrap_utils.py: drift_risk: medium diff --git a/docs/index.rst b/docs/index.rst index 2daa59bfa..898d9ee04 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -146,9 +146,7 @@ Supported Estimators * - :class:`~diff_diff.DifferenceInDifferences` - Basic 2x2 DiD with robust/clustered standard errors * - :class:`~diff_diff.TwoWayFixedEffects` - - Panel data with unit and time fixed effects - * - :class:`~diff_diff.MultiPeriodDiD` - - Event study with period-specific treatment effects (deprecated 3.9 — use :class:`~diff_diff.TwoWayFixedEffects` ``event_study=True``) + - Panel data with unit and time fixed effects; event study with period-specific treatment effects via ``event_study=True`` * - :class:`~diff_diff.CallawaySantAnna` - Callaway & Sant'Anna (2021) group-time ATT for staggered adoption * - :class:`~diff_diff.ChaisemartinDHaultfoeuille` diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 74f99a827..3f6eeb0a2 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -183,15 +183,24 @@ independent full-refit enumeration in `tests/test_wild_bootstrap.py::test_wcr_ma ## MultiPeriodDiD -- **Note:** Deprecated in 3.9, removed in 4.0 (ledger row M-010): merged into - the `TwoWayFixedEffects` event-study mode. `TWFE().fit(..., - event_study=True, spec="pooled", time=)` reproduces this - estimator's design EXACTLY (same shared estimation core; bit-exact under - matched cluster settings and unconditionally without a unit id); the - default `spec="within"` adds unit fixed effects (standard errors shift in - general, and point estimates too on unbalanced/covariate designs - the - documented estimate change). Everything below remains the 3.x contract - for the deprecated class through its removal. +- **Note:** REMOVED at the 4.0 cut (PR 4c-ii, ledger row M-010; deprecated in 3.9). + This section is retained as the methodology record of the pooled + event-study design, which `TwoWayFixedEffects().fit(..., event_study=True, + spec="pooled", post_periods=, reference_period=)` runs unchanged - the + estimation core moved verbatim into + `DifferenceInDifferences._fit_event_study_core` in 3.9 and is pinned field + for field against the committed pre-refactor capture + `tests/fixtures/v4_twfe_event_study_oracles.json` by + `tests/test_twfe_event_study_decoupling.py`. Vocabulary on the surviving + surface: `absorb=["unit"]` is `spec="within"` (unit FE only; multi-column + `absorb=` and `fixed_effects=` are not expressible), `post_periods=` is + required (the midpoint default died with the class), the post-period + average `avg_att` has no successor field (compute it from the surface: the + `tests/helpers/event_study_average.py` arithmetic), `period_effects` + survives as a FutureWarning view on `EventStudyResults` (row M-016, + removed 5.0), and `unit=` auto-clusters the pooled spec at unit. The + methodology below is stated in the removed class's vocabulary; archive + framing lands with [M-090]. **Primary source:** Event study methodology - Freyaldenhoven, S., Hansen, C., Pérez, J.P., & Shapiro, J.M. (2021). Visualization, @@ -302,7 +311,7 @@ where V is the VCV sub-matrix for post-treatment δ_e coefficients. `_compute_cr2_bm_contrast_dof` (DOF-only for arbitrary contrasts) are thin wrappers over that shared core, so every CR2 caller routes through one implementation. The consolidation is bit-identical to the prior two-call path (proven at atol=0). -- **Note (unweighted per-coef DOF guard):** the unweighted clustered CR2-BM per-coefficient DOF (`_cr2_bm_dof_inner`, the simple `(tr B)²/tr(B²)` form) carries the same two-part reliability guard as the weighted P-array path. (1) **Noise floor:** for a high-leverage FE-dummy / collinear nuisance column `trace_B2 = Σ B_{g,h}²` collapses to float64 accumulation noise while `trace_B` stays O(1), inflating the ratio to a non-physical DOF (observed ~1e61 on the absorbed-FE golden); a contrast whose `max|B_{g,h}|` sits below the batch-relative (`1e-10×max`, computed on the scale-normalized `max|B|/‖c‖²` since `B ∝ ‖c‖²` while the DOF is scale-invariant) or absolute (`(EPS·n·k·bread_scale)²`) floor is NaN'd. (2) **Cluster-count bound:** the Bell-McCaffrey Satterthwaite DOF is `(tr B)²/tr(B²)` with `B` PSD and cluster-structured, so it is bounded by `rank(B) ≤ G` (number of clusters); the simple unweighted form is numerically less faithful than clubSandwich's P-array form on high-leverage columns and can return a finite-but-inflated DOF above `G` (observed ~32.7 and ~16.3 vs R's 6 and 3, `G=8`), which is NaN'd as non-physical. The well-conditioned contrasts estimators consume — the treatment effect, event-study coefficients, and the compound post-period-average — are unaffected and match R clubSandwich; only the non-user-facing high-leverage nuisance DOFs are suppressed (exact P-array reproduction of those is deferred). A `UserWarning` fires per fit. Regression: `tests/test_estimators_vcov_type.py::TestDiDAbsorbedFERParity::test_unweighted_cr2_bm_per_coef_dof_no_nonphysical`. **Evaluation (2026-07):** the pairwise `B` matrix is computed via the algebraic identity `B = Ω'MΩ = diag(‖ω_g‖²) − P' M_U P` with `P = X'Ω` (the Satterthwaite DOF itself is Pustejovsky-Tipton 2018 §3.1 Eq. 11; equivalently the `q=1` reduction of the AHT Eq. 13) — instead of contracting against an explicit dense `n×n` residual-maker (`O(n k + G² k)` per contrast; peak memory = two `O(n k)` input-scale score precomputes plus working buffers capped by a byte cap subject to a one-contrast lower bound (a single contrast intrinsically needs `O(n)` + `O(G k)` buffers) — q vectors, per-cluster omegas, and product buffers contrast-chunked with every width-scaled buffer counted in the chunk denominator, and the `(G, G)` pairwise matrix row-chunked (Frobenius sum and max are row-separable), so none of the `n×n` residual-maker, `O(n m)` score arrays, or `O(G²)` pairwise entries is ever held at once; chunk-count invariant to ~1 ULP — BLAS width-dependent column accumulation; algebraically identical, ~1e-12 float agreement locked by a frozen-oracle parity test). **One-way scores evaluation (2026-07):** the non-clustered unweighted BM DOF denominator `a'(M∘M)a` (`_compute_bm_dof_from_contrasts`) is likewise evaluated without the dense `n×n` residual-maker, via the Schur-product expansion `Σ a_i²(1−2h_ii) + tr((B S_a)²)` with `S_a = X'diag(a)X` — exact algebra, `O(n k² + k³)` per contrast (was `O(n²k)`), frozen-dense-oracle parity ~1e-12, with a noise-floor cancellation guard NaN-ing contrasts whose expanded denominator collapses below float precision. A separate design-wide guard now returns all-NaN unweighted contrast DOFs silently at any `h_ii >= 1 - 1e-8`, matching the shared covariance failure (`TestOneWayBMScoresDOF`; healthy dense-oracle expectations retained). **Low-rank A_g factorization (2026-07):** the unweighted per-cluster adjustment operator `A_g = (I − H_gg)^{−1/2}` is evaluated from the k×k eigenproblem of `U_g'U_g` (`U_g = X_g M_U^{1/2}`; `A_g = I + (U_g Q) diag(γ) (U_g Q)'`, `γ = ((1−λ)^{−1/2}−1)/λ` via expm1/log1p, Moore-Penrose zeroing at `1−λ ≤ 1e-10` matching `_cr2_adjustment_matrix`'s convention) and only ever applied to skinny matrices — the dense `(n_g, n_g)` `A_g` (an `O(n_g³)` eigh per cluster) is never materialized for `n_g > k`; clusters with `n_g ≤ k` keep the (tiny) dense construction since it is the smaller Gram side. Algebraically identical; vcov/DOF match a frozen dense-eigh oracle at rtol 1e-12/1e-10 incl. the leverage-1 absorbed-cluster-FE and singleton-cluster regimes (`TestCR2BMLowRankAdjustment`). The weighted clubSandwich path keeps the dense construction (its `G_g` carries the `S_W` bias term). +- **Note (unweighted per-coef DOF guard):** the unweighted clustered CR2-BM per-coefficient DOF (`_cr2_bm_dof_inner`, the simple `(tr B)²/tr(B²)` form) carries the same two-part reliability guard as the weighted P-array path. (1) **Noise floor:** for a high-leverage FE-dummy / collinear nuisance column `trace_B2 = Σ B_{g,h}²` collapses to float64 accumulation noise while `trace_B` stays O(1), inflating the ratio to a non-physical DOF (observed ~1e61 on the absorbed-FE golden); a contrast whose `max|B_{g,h}|` sits below the batch-relative (`1e-10×max`, computed on the scale-normalized `max|B|/‖c‖²` since `B ∝ ‖c‖²` while the DOF is scale-invariant) or absolute (`(EPS·n·k·bread_scale)²`) floor is NaN'd. (2) **Cluster-count bound:** the Bell-McCaffrey Satterthwaite DOF is `(tr B)²/tr(B²)` with `B` PSD and cluster-structured, so it is bounded by `rank(B) ≤ G` (number of clusters); the simple unweighted form is numerically less faithful than clubSandwich's P-array form on high-leverage columns and can return a finite-but-inflated DOF above `G` (observed ~32.7 and ~16.3 vs R's 6 and 3, `G=8`), which is NaN'd as non-physical. The well-conditioned contrasts estimators consume — the treatment effect and the event-study coefficients — are unaffected and match R clubSandwich (the compound post-period-average column, whose DOF the removed MultiPeriodDiD consumed, is since 4.0 only the batch-relative floor's anchor in the TWFE event-study `hc2_bm` batch - see the TwoWayFixedEffects event-study Note); only the non-user-facing high-leverage nuisance DOFs are suppressed (exact P-array reproduction of those is deferred). A `UserWarning` fires per fit. Regression: `tests/test_estimators_vcov_type.py::TestDiDAbsorbedFERParity::test_unweighted_cr2_bm_per_coef_dof_no_nonphysical`. **Evaluation (2026-07):** the pairwise `B` matrix is computed via the algebraic identity `B = Ω'MΩ = diag(‖ω_g‖²) − P' M_U P` with `P = X'Ω` (the Satterthwaite DOF itself is Pustejovsky-Tipton 2018 §3.1 Eq. 11; equivalently the `q=1` reduction of the AHT Eq. 13) — instead of contracting against an explicit dense `n×n` residual-maker (`O(n k + G² k)` per contrast; peak memory = two `O(n k)` input-scale score precomputes plus working buffers capped by a byte cap subject to a one-contrast lower bound (a single contrast intrinsically needs `O(n)` + `O(G k)` buffers) — q vectors, per-cluster omegas, and product buffers contrast-chunked with every width-scaled buffer counted in the chunk denominator, and the `(G, G)` pairwise matrix row-chunked (Frobenius sum and max are row-separable), so none of the `n×n` residual-maker, `O(n m)` score arrays, or `O(G²)` pairwise entries is ever held at once; chunk-count invariant to ~1 ULP — BLAS width-dependent column accumulation; algebraically identical, ~1e-12 float agreement locked by a frozen-oracle parity test). **One-way scores evaluation (2026-07):** the non-clustered unweighted BM DOF denominator `a'(M∘M)a` (`_compute_bm_dof_from_contrasts`) is likewise evaluated without the dense `n×n` residual-maker, via the Schur-product expansion `Σ a_i²(1−2h_ii) + tr((B S_a)²)` with `S_a = X'diag(a)X` — exact algebra, `O(n k² + k³)` per contrast (was `O(n²k)`), frozen-dense-oracle parity ~1e-12, with a noise-floor cancellation guard NaN-ing contrasts whose expanded denominator collapses below float precision. A separate design-wide guard now returns all-NaN unweighted contrast DOFs silently at any `h_ii >= 1 - 1e-8`, matching the shared covariance failure (`TestOneWayBMScoresDOF`; healthy dense-oracle expectations retained). **Low-rank A_g factorization (2026-07):** the unweighted per-cluster adjustment operator `A_g = (I − H_gg)^{−1/2}` is evaluated from the k×k eigenproblem of `U_g'U_g` (`U_g = X_g M_U^{1/2}`; `A_g = I + (U_g Q) diag(γ) (U_g Q)'`, `γ = ((1−λ)^{−1/2}−1)/λ` via expm1/log1p, Moore-Penrose zeroing at `1−λ ≤ 1e-10` matching `_cr2_adjustment_matrix`'s convention) and only ever applied to skinny matrices — the dense `(n_g, n_g)` `A_g` (an `O(n_g³)` eigh per cluster) is never materialized for `n_g > k`; clusters with `n_g ≤ k` keep the (tiny) dense construction since it is the smaller Gram side. Algebraically identical; vcov/DOF match a frozen dense-eigh oracle at rtol 1e-12/1e-10 incl. the leverage-1 absorbed-cluster-FE and singleton-cluster regimes (`TestCR2BMLowRankAdjustment`). The weighted clubSandwich path keeps the dense construction (its `G_g` carries the `S_W` bias term). - **Note:** `LinearRegression.get_se()` / `get_inference()` clamp the vcov diagonal at 0 before `sqrt`. A high-leverage / degenerate coefficient (an absorbed-FE dummy near-collinear with the treatment, whose Satterthwaite DOF already hits the noise-floor @@ -417,19 +426,20 @@ spec="within": Y_it = α_i + γ_t + Σ_{e≠ref} δ_e (D_i × 1[t=e]) + X'β + spec="pooled": Y_it = α + βD_i + Σ_{t≠ref} γ_t Period_t + Σ_{e≠ref} δ_e (D_i × 1[t=e]) + X'β + ε_it ``` -`spec="pooled"` is the MultiPeriodDiD design verbatim (shared estimation -core - bit-exact reproduction under matched cluster settings) and the only +`spec="pooled"` is the pre-4.0 pooled design verbatim (the removed +MultiPeriodDiD; shared estimation core, pinned against the committed +pre-refactor oracle - see the MultiPeriodDiD archive section) and the only spec valid for repeated cross-sections; `spec="within"` absorbs the unit fixed effects (the treatment main effect is omitted - it is spanned by the unit FE). Point estimates coincide across specs only in the restricted equivalence case (balanced panel, no covariates, simultaneous adoption); otherwise the unit-FE projection changes point estimates too. The reference period defaults to the last pre-treatment period (e=-1 convention) with no -transition warning (the legacy-default FutureWarning is MultiPeriodDiD-only, -row M-007). +transition warning (the removed class's legacy-default FutureWarning was +retired at 4.0 with it, row M-007 done). - **Note (staggered-adoption detection limit):** both specs assume - simultaneous adoption (the MultiPeriodDiD scope, inherited verbatim). The + simultaneous adoption (the removed MultiPeriodDiD's scope, inherited verbatim). The staggered-adoption advisory infers adoption timing from within-unit 0→1 transitions of the treatment column - i.e. it can only fire on a time-varying `D_it` input, which the companion time-varying-treatment @@ -441,10 +451,10 @@ row M-007). would make the assumption checkable under `D_i` is tracked in TODO.md. - **Note (explicit calendar partition):** for the same reason, `post_periods=` is REQUIRED (non-empty) in event-study mode: the boundary cannot be - inferred from the data, so MultiPeriodDiD's midpoint default (last half of - the calendar) is a silent guess and is deliberately not carried into the - merged mode - omission raises with a message naming the rejected default. - MultiPeriodDiD itself keeps the documented midpoint default through 3.9. + inferred from the data, so the removed MultiPeriodDiD's midpoint default + (last half of the calendar) was a silent guess and is deliberately not + carried into the merged mode - omission raises with a message naming the + rejected default. The midpoint default is gone at 4.0 with the class. *Inference:* - **Note:** the event-study mode auto-clusters at the unit level from 3.9 @@ -454,14 +464,30 @@ row M-007). `cluster=` combines), never injected as a survey PSU (the implicit-per-observation-PSU rule), and dropped for explicit one-way analytical families (`classical`/`hc2`). Explicit `cluster=` passes - through and behaves exactly like MultiPeriodDiD's own explicit cluster on - every lane (survey PSU injection included). + through and behaves exactly like the removed MultiPeriodDiD's explicit + cluster did on every lane (survey PSU injection included). - **Note:** `inference="wild_bootstrap"` in event-study mode raises `ValueError` from the mode's 3.9 birth - the WCR implementation covers - the static ATT only, and MultiPeriodDiD's silent analytical fallback is - deliberately not carried into the merged mode (no-silent-failures). This - mode-level error fires BEFORE the survey/Conley front doors (contrast the - static precedence contract in the Conley section). + the static ATT only, and the removed MultiPeriodDiD's silent analytical + fallback was deliberately not carried into the merged mode + (no-silent-failures). This mode-level error fires BEFORE the survey/Conley + front doors (contrast the static precedence contract in the Conley section). +- **Note:** `EventStudyResults.period_effects` (the M-016 compatibility view, + calendar surfaces only, removed 5.0) evaluates `is_significant` at the + surface's `alpha`, not the removed per-period record's fixed 0.05 (user + decision 2026-10-25); duplicate calendar labels raise rather than silently + collapse. +- **Note:** The clustered `hc2_bm` contrast batch keeps the former + post-period-average column as the batch-relative noise-floor guard's anchor + (`_cr2_bm_dof_inner`: a contrast is NaN'd when its scaled max|B| falls + below 1e-10 of the batch maximum, and the average column often sets that + maximum); its own DOF is unused since 4.0. Dropping the column would flip + near-collinear per-period DOFs from NaN to finite (reproduced on 20/299 + near-collinear draws; the mask flips at noise ~2e-6..4e-6 on + `x_nc = treated*1[period>=4] + s*N(0,1)`, cond(X)~9e5), so it stays until + the guard is given a batch-independent anchor (TODO.md row; structural pin + `test_hc2_bm_contrast_batch_keeps_post_average_anchor` in + `tests/test_v4_merge_mpd.py`). **Key implementation requirements:** @@ -5033,11 +5059,11 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - [x] Phase 1a: HC2 + Bell-McCaffrey DOF correction in `diff_diff/linalg.py` via `vcov_type="hc2_bm"` enum (both one-way and CR2 cluster-robust with Imbens-Kolesar / Pustejovsky-Tipton Satterthwaite DOF). **Weighted Bell-McCaffrey (`hc2_bm + weights`, both one-way and cluster) is now supported on the analytical linalg surface (`compute_robust_vcov` / `solve_ols` / `LinearRegression`) via the clubSandwich WLS-CR2 port.** The port matches clubSandwich's `pweight` (sampling-weight) convention only — `weight_type="aweight"` and `weight_type="fweight"` raise `NotImplementedError` on `hc2_bm + weights` (separate methodology task; CR1 / `vcov_type="hc1"` continues to support all three weight types). Estimator-level `survey_design=` paths continue to use the Taylor-series linearization (TSL) survey variance, which takes precedence over the analytical sandwich (no change in this PR). **Known precision limit on high-leverage FE-dummy coefficients**: the Satterthwaite DOF formula `(tr P)² / sum(P²)` is at the float64 noise floor for contrasts whose projection onto the design is essentially zero (typically FE-dummy coefficients where the dummy fires in a single cluster). In this regime, BLAS reduction-order differences between NumPy and R's BLAS produce 15-30% DOF discrepancies despite vcov matching at machine precision. `_cr2_bm_dof_inner_weighted` detects this regime via two criteria applied union-wise — **(a) batch-relative**: per-contrast max|P| below `1e-10 ×` the largest contrast's max|P| (catches noise-floor coefficients in a per-coefficient sweep where a non-noise reference is available); **(b) absolute single-contrast safe**: per-contrast max|P| below `(EPS × n × k × max(bread_inv_scale, 1))²` (catches single-contrast calls like MPD avg_att where no batch reference exists) — and returns NaN with a `UserWarning` rather than ship arbitrarily-different DOF on the user-facing surface. The coefficient SEs remain valid; only the DOF (and any t-test / CI that depends on it) is suppressed. The diff-diff implementation matches clubSandwich's specific algebra (R source: `CR-adjustments.R::CR2`, `clubSandwich.R::vcov_CR`, `coef_test.R::Satterthwaite_df`), **not** a textbook Pustejovsky-Tipton (2018) §3.3 transform-once derivation. clubSandwich uses W (not √W) in the hat matrix `H_gg = X_g M_U X_g' W_g`, W² in the bias-correction term `S_W = Σ_g X_g' W_g² X_g`, and unweighted residuals in the score construction `s_g = X_g' W_g A_g e_g`. The Satterthwaite DOF uses the full H1/H2/H3 array construction (`get_arrays.R::get_GH`), not the simplified `(tr B)² / tr(B²)` form (which works for the unweighted case but diverges from clubSandwich by 0.5-30% on weighted designs — see `feedback_wls_cr2_clubsandwich_parity`). Pinned at atol=1e-10 against `clubSandwich::vcovCR(..., type="CR2") + coef_test(test="Satterthwaite")$df_Satt` and `Wald_test(test="HTZ")$df_denom` on six weighted scenarios in `benchmarks/data/clubsandwich_cr2_golden.json` (vcov + non-noise-floor per-coefficient DOF + compound-contrast DOF; high-leverage FE-dummy coefficients are suppressed to NaN per the precision-limit note below) (`tests/test_methodology_wls_cr2.py`). Unweighted CR2-BM behavior is algebraically identical to prior (parity within floating-point tolerance after the 2026-07 scores-based evaluation change; regression-safe via `TestUnweightedRegressionStillBitEqual` + `TestDOFFormulaDualPathEquivalence`). The one-way weighted HC2-BM path uses clubSandwich's singleton-cluster CR2 reduction (each obs is its own cluster), routed via `_compute_bm_dof_from_contrasts` when `weights is not None`. clubSandwich pin: `≥ 0.7.0`. - **Note (scope limitation on absorbed FE):** HC2 and HC2 + Bell-McCaffrey on within-transformed designs still depend on the FULL FE hat matrix because FWL preserves coefficients and residuals but NOT the hat matrix: `h_ii = x_i' (X'X)^{-1} x_i` on the reduced design is not the diagonal of the full FE projection, and CR2's block adjustment `A_g = (I - H_gg)^{-1/2}` likewise depends on the full cluster-block hat matrix. The status across the three estimators that previously rejected this combination: - **`DifferenceInDifferences(absorb=..., vcov_type in {"hc2","hc2_bm"})` — SUPPORTED (auto-route).** When the user pairs `absorb=` with HC2 / HC2-BM, `DiD.fit()` internally promotes the absorb columns to `fixed_effects=` so the existing full-dummy code path computes the algebraically correct vcov from the full FE projection. Verified at ~1e-10 vs `lm() + sandwich::vcovHC(type="HC2")` and `lm() + clubSandwich::vcovCR(cluster=..., type="CR2")` (singleton-cluster CR2 trick for one-way HC2-BM Satterthwaite DOF; PT2018 §3.3 unweighted CR2 algebra). **User-visible surface change**: under the auto-route, the entire `DiDResults` (coefficients, vcov, residuals, fitted_values, r_squared) reflect the full-dummy fit rather than the within-transformed fit — the FE-dummy entries are included in `result.coefficients` / `result.vcov`, `r_squared` is computed on the un-demeaned outcome, and `residuals` / `fitted_values` are on the original scale. `result.att` is unaffected (FWL-equivalent). HC1/CR1 paths on `absorb=` are unchanged (no leverage term). **Survey-design scope**: when `survey_design=` is supplied, the existing survey variance path (Taylor-series linearization / replicate weights) takes precedence over the analytical HC2/HC2-BM sandwich; the auto-route only changes the FE handling (removing the prior reject) and does not redirect to the analytical small-sample sandwich on survey fits. - - **`TwoWayFixedEffects(vcov_type in {"hc2","hc2_bm"})` — SUPPORTED (inline full-dummy build).** TWFE has no `absorb=` / `fixed_effects=` parameter (the unit + time FE are baked into the estimator's identity), so the same parameter-swap auto-route used for DiD-absorb / MPD-absorb is not directly applicable. Instead, `TwoWayFixedEffects.fit()` bypasses the within-transform when `vcov_type in {"hc2","hc2_bm"}` and builds the full-dummy design `[intercept, treated×post, covariates, factor(unit), factor(time)]` explicitly, then runs OLS through the standard `solve_ols` path so the leverage correction and BM DOF compute on the full FE projection. Verified at atol=1e-10 vs `lm(y ~ treat_post + factor(unit) + factor(post)) + sandwich::vcovHC(type="HC2")` for HC2 and vs `clubSandwich::vcovCR(cluster=seq_len(n), type="CR2")` for the singleton-cluster one-way HC2-BM Satterthwaite DOF; vs `vcovCR(cluster=unit, type="CR2")` for the auto-cluster CR2-BM path. **Auto-cluster default (non-survey analytical path):** TWFE's unit auto-cluster is preserved on `hc2_bm` (routes to CR2-BM at unit) and on `hc2 + wild_bootstrap` (the bootstrap consumes the cluster structure for resampling regardless of the analytical sandwich choice); dropped on explicit `hc2 + analytical` to match the one-way contract (the linalg validator rejects `hc2 + cluster_ids`). `hc2_bm + analytical` with no explicit cluster yields the auto-cluster CR2-BM path. **Survey-design exception:** under `survey_design=` with no explicit `cluster=`, TWFE intentionally keeps the documented implicit-PSU path (the auto-cluster is NOT injected into the survey PSU structure); users who want unit-level PSU injection under a survey design must pass explicit `cluster="unit"` or set `survey_design.psu` directly. **User-visible surface change** (matches the DiD-absorb / MPD-absorb disclosure above): under HC2 / HC2-BM, `result.coefficients`, `result.vcov`, `result.residuals`, `result.fitted_values`, and `result.r_squared` reflect the full-dummy fit rather than the within-transformed reduced fit (FE-dummy entries are included, `r_squared` is computed on the un-demeaned outcome, residuals/fitted are on the original scale). `result.att`, its SE, and analytical inference are unchanged (FWL-equivalent). HC1 / CR1 / Conley / classical paths remain on the within-transform (no leverage term in those vcov families). **Survey-design scope** (mirrors the DiD-absorb auto-route contract above): when `survey_design=` is supplied, the existing survey variance path (Taylor-series linearization for analytical-weight designs, or replicate-weight variance for BRR/Fay/JK1/JKn/SDR) takes precedence over the analytical HC2/HC2-BM sandwich; the full-dummy build only changes the FE handling (removing the prior reject) and does not redirect to the analytical small-sample sandwich on survey fits. **Replicate-weight survey designs** are blocked at the estimator level: `vcov_type in {"hc2","hc2_bm"}` + replicate weights raises `NotImplementedError` because the replicate refit path re-demeans per replicate, which doesn't compose with the full-dummy build (would require per-replicate full-dummy refit); workaround: use `vcov_type="hc1"` for replicate-weight CR1. `hc2_bm + weights` (Gates 4-5) is now lifted via the clubSandwich WLS-CR2 port; the survey-design rejection here is a separate estimator-level gate (analytical sandwich vs survey TSL precedence), independent of the linalg validator. - - **`MultiPeriodDiD(absorb=..., vcov_type in {"hc2","hc2_bm"})` — SUPPORTED (auto-route).** Same auto-route pattern as `DifferenceInDifferences`: `MultiPeriodDiD.fit()` internally promotes the absorb columns to `fixed_effects=` for HC2 / HC2-BM callers, so the existing full-dummy code path computes the algebraically correct vcov from the full FE projection on the event-study design (`treated + period_X dummies + treated:period_X interactions + factor(unit)`). Verified at ~1e-10 vs `lm() + sandwich::vcovHC(type="HC2")` and `lm() + clubSandwich::vcovCR(cluster=1:n, type="CR2")` on a 5-cohort × 5-period event-study fixture; the parity target is a per-period interaction `treated:period_X` because MPD requires the `treated` column to be a time-invariant ever-treated indicator, which lies in the span of the intercept and the post-auto-route unit FE dummies (under `pd.get_dummies(..., drop_first=True)` the dropped reference unit is implicit in the intercept, so the exact alias relation depends on the omitted FE category — it is NOT simply "the sum of treated-cohort unit dummies"). `solve_ols` drops one column from the collinear set under R-style rank-deficiency handling; in the shipped parity fixture (4 ever-treated cohorts of 5 units + 1 never-treated cohort of 5 units) it drops a unit dummy from the never-treated cohort (`unit_25`) and the `treated` main effect remains finite, but the specific column that gets NaN'd is pivot-order and dummy-coding dependent. Either way, the slope coefficients (`treated:period_X`) and the post-period-average `avg_att` are identified and invariant to which column was dropped. Same `MultiPeriodDiDResults` surface change as DiD: `vcov`, `residuals`, `fitted_values`, `r_squared`, and `coefficients` reflect the full-dummy fit, with `period_effects[t].effect` / `.se` / `.p_value` / `.conf_int` invariant by FWL. HC1/CR1 paths on `absorb=` are unchanged (no leverage term). Same survey-design scope as DiD: replicate-weight variance routes through the standard `compute_replicate_vcov` path on the fixed full-dummy design rather than the per-replicate refit branch (which targets the demeaning path); since the auto-routed design does not depend on replicate weights, no refit is needed. **Redundant time-FE skip:** when the routed (or directly-supplied) `fixed_effects` list contains the `time` column, MPD silently skips emitting `