Skip to content

Commit b37cc98

Browse files
committed
feat(v4): TWFE event-study mode absorbs MultiPeriodDiD - M-010/M-060/M-082 (phase 3(a))
TwoWayFixedEffects.fit(..., event_study=True, time=<calendar>, spec="within"|"pooled", reference_period=None, post_periods=[...]) estimates per-period treatment effects and returns the unified EventStudyResults surface natively. spec="pooled" is the MultiPeriodDiD design verbatim (shared estimation core - bit-exact under matched cluster settings, unconditional in the unit-less repeated-cross-sections form); spec="within" (default) absorbs the unit FE and omits the spanned treatment main effect. MultiPeriodDiD (+ the EventStudy alias) is deprecated with a construction FutureWarning naming the successor; static fit(time=) is renamed to post= behind the M-030-style shim (time= survives as the ES calendar column; 4.0 enforcement is M-083). Core mechanics: - DifferenceInDifferences._fit_event_study_core is the relocated MPD.fit body, parameterized (include_treatment_main, warn_legacy_reference_default, cluster_override, estimator_name, _frame_offset) - MPD numerics, messages, and warning attribution are bit-identical pre/post extraction (attribution pins included). - The mode carries TWFE's inference stack from day one: unit auto-cluster with the static carve-outs mirrored lane-for-lane (dropped on Conley - no implicit spatial x unit product kernel; never injected as a survey PSU; dropped for explicit one-way analytical families), a day-one wild-bootstrap ValueError, and no legacy reference-period warning. - post_periods= is REQUIRED (non-empty, duplicate-free) in ES mode: the treatment boundary is not observable from the documented time-invariant ever-treated indicator, so the MPD midpoint default would silently guess the reference/partition; MPD itself keeps its documented default through 3.9. ES calls pass time=/unit= as keywords (slot 4 stays post= through the M-082 window). - EventStudyResults gains two provenance fields (M-092 amendment #5): the authoritative post_periods calendar partition (content-validated, threaded by _from_mpd) and estimation_spec ({within,pooled}, TWFE producer only); both serialize through to_dict(). Consumer ports: - HonestDiD and PreTrendsPower gain calendar container routes with native-route parity: the partition provenance reconstructs exactly the inputs the native MPD branches read, including the reference-anchored Roth gamma-unit plumbing (string-label degradation reproduced, not bypassed). HonestDiD is geometry-scoped to chronological partitions (suffix post set, last-pre reference) per the Rambachan-Roth positional restriction system; the pre-existing native-route permissiveness is documented (REGISTRY Note) and deferred (DEFERRED.md row). Both calendar routes share the hardened container-vcov contract (duplicate/incomplete vcov_index fail loud, _validate_vcov_subblock, HonestDiD with allow_singular=False, warned diagonal fallback only when no matrix is stored), require finite effects alongside finite positive SEs where their native conventions do, guard empty pre AND post retained sets (the post guard added to the native HonestDiD sibling too), fail closed on invalid explicit pre_periods= selections in calendar chronology, and warn on string calendar labels (chronology unverifiable; sorted() order assumed, matching the fit's own rule). - plot_event_study derives the pre/post split from the partition provenance with per-contiguous-run pre-shading on both renderers. - DiagnosticReport and BusinessReport explicitly reject EventStudyResults (previously a silent empty-applicability report / all-null headline); admission is a tracked backlog row. Sweeps and hygiene: - ~140 static TWFE/DiD time= keyword callers migrated to post= across tests, docs, guides, and benchmarks (receiver-resolved per site; legitimate time= params on CiC/QDiD/TripleDiff/MPD/placebo surfaces untouched; deliberate shim-test usage retained). - MPD deprecation shim: forwarding __init__ + import-time __signature__ mirror keeps BaseEstimator introspection working; pyproject filterwarnings ignore for the suite-noise window; naming-guard _FORWARDING_INIT_SHIMS registry keeps MPD in DiD's init-sharing group. - ES within + hc2/hc2_bm gains the static path's dense full-dummy memory preflight (column-presence guarded). - REGISTRY: TWFE "Event-study mode (3.9)" subsection (designs, estimate-shift, auto-cluster + carve-outs, wild raise, explicit partition, staggered-adoption detection limit, string-label chronology); the pre-existing singleton edge-case overclaim corrected to the RETAINED behavior with a Deviation-from-R Note (reghdfe drops, fixest retains; execution-verified on both paths); MPD deprecation Note; HonestDiD positional-geometry Notes. - v4-deprecations ledger: M-010 shimmed/phase 5, M-082 shimmed, M-060 planned/phase 5 (warning rides the parent), M-092 amendment #5, M-093 admission amendment, M-011/M-080 cross-notes; v4-design section 4.1 keyword + required-partition amendments (dated, same-diff). - tests/test_v4_merge_mpd.py (72 tests): the section 4.1 gate triple + the within numerical gate, mode/rename validation, wild-raise precedence, auto-cluster carve-out behavioral pins, deprecation choreography, surface contract (incl. replicate-survey numerical lane), inference integrity (rank-deficiency NaN tuple, attribution baselines, singleton class-consistency), and consumer lanes (parity, geometry/provenance/vcov-integrity rejections, string-label warning, explicit-selection validation, plot geometry on both renderers). Parity assertions are mask-first (_eq_with_nans / _close_with_nans) so NaN-vs-zero regressions cannot be equated. - Teaching surfaces migrated off MultiPeriodDiD / static time=: quickstart, choosing_estimator, troubleshooting, homepage estimator table, api pages (executed examples), practitioner decision tree, README catalog line, and all four bundled guides; docs build -W green with all post-build HTML guards.
1 parent e891f25 commit b37cc98

65 files changed

Lines changed: 3558 additions & 544 deletions

Some content is hidden

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

CHANGELOG.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **TwoWayFixedEffects event-study mode** (v4 program Phase 3(a); ledger
12+
row [M-010] shimmed): `TWFE().fit(..., event_study=True, time="period",
13+
spec="within"|"pooled", reference_period=None, post_periods=[...])`
14+
estimates per-period treatment effects and returns the unified
15+
`EventStudyResults` surface natively (`source="TwoWayFixedEffects"`,
16+
plus two new container provenance fields: the authoritative
17+
`post_periods` calendar partition and `estimation_spec` - ledger row
18+
[M-092] amendment). `spec="within"` (default) estimates the unit-FE
19+
event study; `spec="pooled"` reproduces the MultiPeriodDiD design
20+
exactly (the only spec valid for repeated cross-sections - it is the
21+
migration target for 3.x MultiPeriodDiD fits, reproducing their
22+
numbers under matched cluster settings and unconditionally without a
23+
unit id). Point estimates coincide across the two specs only in the
24+
restricted equivalence case (balanced panel, no covariates,
25+
simultaneous adoption); with unbalanced panels or covariates the
26+
unit-FE projection changes point estimates too. Event-study calls
27+
pass `time=` (calendar) and `unit=` as keywords; `post_periods=` is
28+
REQUIRED in event-study mode (the treatment boundary is not observable
29+
from a time-invariant ever-treated indicator, so MultiPeriodDiD's
30+
midpoint default - last half of the calendar - is a silent guess and
31+
is deliberately not carried over; MultiPeriodDiD itself keeps it
32+
through 3.9); the mode carries
33+
TWFE's inference stack from day one - unit auto-cluster (with the
34+
static carve-outs: dropped on Conley and for explicit one-way
35+
analytical families, never injected as a survey PSU) - and
36+
`inference="wild_bootstrap"` raises an explicit `ValueError` (the
37+
wild cluster bootstrap covers the static ATT only; MultiPeriodDiD's
38+
silent analytical fallback is deliberately not carried into the
39+
merged mode). HonestDiD, PreTrendsPower, and `plot_event_study`
40+
consume the new surface (dedicated calendar container routes with
41+
native-route parity; HonestDiD scoped to chronologically-partitioned
42+
surfaces per the Rambachan-Roth restriction geometry).
43+
44+
### Changed
45+
- **DiagnosticReport and BusinessReport explicitly reject
46+
`EventStudyResults` inputs** (previously: DiagnosticReport silently
47+
produced a zero-check report via an empty type-keyed applicability
48+
set, and BusinessReport rendered an all-null scalar headline). Both
49+
errors steer to the fitted estimator's scalar results; admission of
50+
event-study surfaces is tracked in TODO.md.
51+
1052
### Deprecated
53+
- **MultiPeriodDiD + the EventStudy alias** (v4 program Phase 3(a);
54+
ledger rows [M-010] shimmed, [M-060]): constructing `MultiPeriodDiD`
55+
(or `EventStudy` - the same class object) emits a `FutureWarning`
56+
naming the successor; both are removed in 4.0. Migration:
57+
`TwoWayFixedEffects().fit(..., event_study=True, spec="pooled")`
58+
reproduces the MultiPeriodDiD design; the default `spec="within"`
59+
adds unit fixed effects (standard errors and, on unbalanced or
60+
covariate designs, point estimates shift - the documented estimate
61+
change). Behavior of fitted MultiPeriodDiD results is unchanged
62+
through 3.9.
63+
- **TwoWayFixedEffects static `fit(time=)` renamed to `post=`** (ledger
64+
row [M-082] shimmed): the static 0/1 dummy parameter is `post=`; the
65+
old keyword still works through 3.9 with a `FutureWarning` (from 4.0,
66+
`time=` means the event-study calendar column only - the 4.0 semantic
67+
enforcement is row [M-083]). Positional callers are unaffected
68+
(`post` occupies the old slot).
1169
- **The 8 estimator convenience wrappers + the CDiD/Gardner/Stacked alias
1270
diet; new `SCM` alias** (v4 program 2(d) PR-A; ledger rows
1371
[M-070]..[M-077] shimmed, [M-062] + [M-135] done, notes amendments to

CONTRIBUTING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ A new parameter is only complete when it is:
174174
- applied in **every** aggregation mode — `simple`, `event_study`, and `group`
175175
- applied in the **bootstrap/inference** paths, not just the analytical one
176176
- reflected on the result object, so `to_dict()`/`summary()` do not misreport it
177-
- propagated to the estimators that inherit it: `TwoWayFixedEffects` and
178-
`MultiPeriodDiD` define no `__init__` of their own, so they inherit a new
177+
- propagated to the estimators that inherit it: `TwoWayFixedEffects` defines
178+
no `__init__` of its own, and `MultiPeriodDiD`'s 3.9 deprecation shim
179+
forwards via `super().__init__(*args, **kwargs)` with an import-time
180+
`__signature__` mirror - so both inherit a new
179181
`DifferenceInDifferences` constructor parameter automatically;
180182
`SyntheticDiD` defines its OWN signature (it forwards only
181183
`robust`/`cluster`/`alpha` to `super().__init__`), so a new parent

DEFERRED.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ provenance and AI-review deviation-documentation: a row here (or in
2020

2121
| Issue | Location | PR | Priority |
2222
|-------|----------|----|----------|
23+
| HonestDiD non-chronological declared partitions (native `MultiPeriodDiDResults` route): the Rambachan-Roth restriction matrices are built POSITIONALLY over the concatenated declared pre/post lists assuming one chronological boundary, but the native route accepts non-suffix `post_periods` / non-last-pre references and returns bounds whose restriction system does not match the Registry equations (pre-existing; surfaced by the Phase 3(a) calendar-route review, which fails closed instead). Fix = transform the declared partition into boundary form where a valid mapping exists, else reject on the native route too - needs the restriction-geometry derivation. REGISTRY HonestDiD Note records the limitation. | `diff_diff/honest_did.py` | 3(a) | Medium |
2324
| `PlaceboTests` `boundary_gap` — a permutation randomization-inference margin (SE-audit item (b)); NOT computed anywhere in code today, so this is a new feature + result field, not a coverage lock. **User-locked 2026-07-09: defer until a derivation/paper source exists** — do not design or implement from scratch. | `tests/test_methodology_placebo.py`, `diff_diff/diagnostics.py` | SE-audit | Low |
2425
| TwoStageDiD honest/pretrends container admission DEFERRED (decision revised from "widen" during the 2(b) PR-3b plan review): analytical fits carry the joint Gardner-GMM event-study covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS — the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row appended mechanically — not contrasts against a reference period, while HonestDiD's Δ^RM/Δ^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints. Admission needs either a Stage-2 re-estimation with the reference horizon in the design or a derived residual-to-reference normalization mapping (+ its variance transform). Both consumers' TypeErrors state the deferral; see the REGISTRY TwoStageDiD Note (d). | `diff_diff/honest_did.py`, `diff_diff/pretrends.py`, `diff_diff/two_stage_aggregation.py` | 2(b) PR-3b | Low |
2526
| CBWSDID covariate balancing (`StackedDiD(balance="entropy")`) v1 supports only balanced event windows + `weighting="aggregate"`; unbalanced/ragged panels fail closed (unit-count vs observation-count corrector convention unresolved off balanced panels). Matching-based balancing and the repeated `0→1`/`1→0` episode extension are also deferred. Documented in REGISTRY StackedDiD "Covariate balancing (CBWSDID)" Notes. | `stacked_did.py`, `balancing.py`, REGISTRY | follow-up | Low |
@@ -104,7 +105,7 @@ For survey-specific limitations (`NotImplementedError` paths), see the
104105
| `SpilloverDiD` estimator-level end-to-end vcov reconstruction tests (`bread @ meat @ bread` against `res.vcov`): requires exposing the estimator's internal `X_2_kept` design arrays; the surface is currently pinned from different angles (uniform-weight bit-identity, drift goldens, manual lincom reconstruction at rtol=1e-6). | `spillover.py`, `tests/test_spillover.py` | follow-up | Low |
105106
| `SpilloverDiD` Wave E.3 `finite_mask + design-subset` hygiene not yet adopted for TwoStageDiD's analogous pattern (`two_stage.py:567-601`) — separate parity follow-up noted in the `docs/api/spillover.rst` Restrictions block. | `two_stage.py` | Wave E.3 | Low |
106107
| `HeterogeneousAdoptionDiD` `covariates=` (Theorem 6 multivariate-covariate extension) not implemented — `fit(covariates=...)` raises `NotImplementedError` via the shipped future-work trap (locked by the `test_had.py` / `test_methodology_had.py` L73 tests); the deferred work is the Theorem 6 extension itself. | `had.py` | Phase 2a | Low |
107-
| MultiPeriodDiD wild bootstrap not supported (falls back to analytical, n_bootstrap-independent) — user-facing edge-case limitation; the 4.0 removal replaces the fallback with a raise (v4-design §4.1). | `estimators.py:1574` || Low |
108+
| MultiPeriodDiD wild bootstrap not supported (falls back to analytical, n_bootstrap-independent) — user-facing edge-case limitation; the 4.0 removal replaces the fallback with a raise (v4-design §4.1), and the merged TWFE event-study mode already RAISES since 3.9 (Phase 3(a)) — this row now governs only the deprecated class itself. | `estimators.py` (MultiPeriodDiD.fit wild-fallback block) || Low |
108109
| `predict()` raises `NotImplementedError` — rarely needed; user-facing limitation. | `estimators.py:890-911` || Low |
109110

110111
## Version-gated (v4)
@@ -126,6 +127,7 @@ decisions (refactor waivers, perf trade-offs, test-infrastructure calls) are rec
126127

127128
| Decision | Location | Verified |
128129
|----------|----------|----------|
130+
| **MultiPeriodDiD deprecation shim loses static constructor-arg checking (3.9 window).** The M-010 shim is `__init__(*args, **kwargs)` + an import-time `__signature__` mirror of DiD's constructor: runtime introspection (get_params/set_params, `inspect.signature`) and eager validation are fully preserved, but static type checkers / IDEs cannot check constructor arguments for the deprecated class until its 4.0 removal. Accepted: the alternative (hand-mirroring ~20 parameters) is a drift magnet on a class with one minor version of remaining life. | `diff_diff/estimators.py` | 3(a) / 2026-08-07 |
129131
| **DCDH `sklearn.base.clone` param-identity failure won't-fix.** `ChaisemartinDHaultfoeuille._validate_paths_of_interest` unconditionally canonicalizes `paths_of_interest` into a fresh `List[Tuple[int, ...]]`, so sklearn `clone()`'s post-construction `param1 is param2` identity check fails for configured instances - a pre-existing normalization the BaseEstimator mixin PR documented rather than changed (get_params/set_params signatures are clone-compatible; the dependency-free `cls(**est.get_params())` config-equality contract is the enforced one, `tests/test_base_estimator.py`). Fixing would mean returning the caller's raw object from a validator whose job is canonicalization. | `chaisemartin_dhaultfoeuille.py` | mixin PR / 2026-08-01 |
130132
| **scikit-learn stays out of dev deps; clone-identity tests remain importorskip-only.** The sklearn-`clone()` round-trip tests (`test_base_estimator.py`, had/rdd/cic suites) run only where scikit-learn happens to be installed - deliberate, matching the numpy/pandas/scipy-only dependency posture; the always-running contract is the dependency-free re-instantiation config-equality test. | `tests/test_base_estimator.py` | mixin PR / 2026-08-01 |
131133
| **Plan-review hash gate threat model: accident prevention, NOT adversarial defense.** The ExitPlanMode content-hash gate (hook + `plan_snapshot.py`) exists to stop accidents — stale approvals, concurrent-worktree cross-talk, plans edited mid-review — all of which it closes by construction (snapshot identity + invocation-unique state tokens, 30+ behavioral tests). It does NOT and cannot defend against a malicious local process: nothing verifies review AUTHORSHIP, and such an actor can simply write a matching review file directly — no userland hook can prevent that short of signed reviews, which is out of scope for a personal workflow aid. Review findings that presuppose a hostile local actor against this gate are waived by this decision (2026-07-20, after 7 local AI-review rounds converged on ever-deeper adversarial-model refinements with no reachable fixpoint). Genuine accident vectors remain in scope and are fixed as found. | `.claude/hooks/check-plan-review.py`, `.claude/scripts/plan_snapshot.py` | 2026-07-20 |

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ For rigorous DiD analysis, follow these 8 steps. Skipping diagnostic steps produ
9090
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.
9191
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.
9292
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).
93-
6. **Sensitivity analysis** - `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; a StackedDiD `results.aggregate('event_study')` container also admits - needs `kappa_pre >= 2`), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs.
93+
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.
9494
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.
9595
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.
9696

@@ -100,7 +100,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
100100

101101
- [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
102102
- [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
103-
- [MultiPeriodDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - event study design with period-specific treatment effects for dynamic analysis
103+
- [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`)
104104
- [CallawaySantAnna](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Callaway & Sant'Anna (2021) group-time ATT estimator for staggered adoption
105105
- [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`.
106106
- [SunAbraham](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Sun & Abraham (2021) interaction-weighted estimator for heterogeneity-robust event studies

0 commit comments

Comments
 (0)