From b37cc981f034efbc9a7191fed1bbfc2f5b7fe956 Mon Sep 17 00:00:00 2001 From: igerber Date: Fri, 7 Aug 2026 23:10:16 -0400 Subject: [PATCH] feat(v4): TWFE event-study mode absorbs MultiPeriodDiD - M-010/M-060/M-082 (phase 3(a)) TwoWayFixedEffects.fit(..., event_study=True, time=, 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. --- CHANGELOG.md | 58 + CONTRIBUTING.md | 6 +- DEFERRED.md | 4 +- README.md | 4 +- TODO.md | 6 + benchmarks/python/benchmark_twfe.py | 4 +- .../bench_brand_awareness_survey.py | 8 +- .../speed_review/bench_dose_response.py | 2 +- .../speed_review/bench_fe_absorption.py | 6 +- .../speed_review/bench_memory_scaling.py | 2 +- .../speed_review/bench_solve_ols_fastpath.py | 4 +- diff_diff/business_report.py | 22 + diff_diff/diagnostic_report.py | 22 + diff_diff/estimators.py | 652 ++++--- diff_diff/guides/llms-autonomous.txt | 4 +- diff_diff/guides/llms-full.txt | 55 +- diff_diff/guides/llms-practitioner.txt | 16 +- diff_diff/guides/llms.txt | 6 +- diff_diff/honest_did.py | 259 ++- diff_diff/power.py | 7 +- diff_diff/prep_dgp.py | 11 +- diff_diff/pretrends.py | 165 +- diff_diff/results_base.py | 75 + diff_diff/twfe.py | 368 +++- diff_diff/utils.py | 3 +- diff_diff/visualization/_diagnostic.py | 6 +- diff_diff/visualization/_event_study.py | 75 +- diff_diff/visualization/_power.py | 9 +- .../diff_diff.EventStudyResults.rst | 2 + docs/api/estimators.rst | 3 + docs/api/honest_did.rst | 21 +- docs/api/pretrends.rst | 20 +- docs/api/visualization.rst | 9 +- docs/choosing_estimator.rst | 27 +- docs/doc-deps.yaml | 15 + docs/index.rst | 2 +- docs/methodology/REGISTRY.md | 124 +- docs/practitioner_decision_tree.rst | 3 +- docs/quickstart.rst | 13 +- docs/troubleshooting.rst | 4 +- docs/v4-deprecations.yaml | 28 +- docs/v4-design.md | 49 +- pyproject.toml | 9 + tests/test_bacon.py | 2 +- tests/test_base_estimator.py | 13 + tests/test_conley_vcov.py | 28 +- tests/test_estimators.py | 37 +- tests/test_estimators_vcov_type.py | 60 +- tests/test_event_study_consumers.py | 9 +- tests/test_event_study_surface.py | 80 + tests/test_fixest_did_twfe_parity.py | 8 +- tests/test_linalg.py | 2 +- tests/test_methodology_changes_in_changes.py | 4 +- tests/test_methodology_lwdid.py | 4 +- tests/test_methodology_twfe.py | 60 +- tests/test_naming_guard.py | 22 +- tests/test_power.py | 6 +- tests/test_survey.py | 10 +- tests/test_survey_phase6.py | 2 +- ..._t24_staggered_vs_collapsed_power_drift.py | 2 +- tests/test_target_parameter.py | 2 +- tests/test_v4_inference_policy.py | 2 +- tests/test_v4_merge_mpd.py | 1521 +++++++++++++++++ tests/test_variance_conventions.py | 36 +- tests/test_wild_bootstrap.py | 4 +- 65 files changed, 3558 insertions(+), 544 deletions(-) create mode 100644 tests/test_v4_merge_mpd.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a913433b9..d0d15eaa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **TwoWayFixedEffects event-study mode** (v4 program Phase 3(a); ledger + row [M-010] shimmed): `TWFE().fit(..., event_study=True, time="period", + spec="within"|"pooled", reference_period=None, post_periods=[...])` + estimates per-period treatment effects and returns the unified + `EventStudyResults` surface natively (`source="TwoWayFixedEffects"`, + plus two new container provenance fields: the authoritative + `post_periods` calendar partition and `estimation_spec` - ledger row + [M-092] amendment). `spec="within"` (default) estimates the unit-FE + event study; `spec="pooled"` reproduces the MultiPeriodDiD design + exactly (the only spec valid for repeated cross-sections - it is the + migration target for 3.x MultiPeriodDiD fits, reproducing their + numbers under matched cluster settings and unconditionally without a + unit id). Point estimates coincide across the two specs only in the + restricted equivalence case (balanced panel, no covariates, + simultaneous adoption); with unbalanced panels or covariates the + unit-FE projection changes point estimates too. Event-study calls + pass `time=` (calendar) and `unit=` as keywords; `post_periods=` is + REQUIRED in event-study mode (the treatment boundary is not observable + from a time-invariant ever-treated indicator, so MultiPeriodDiD's + midpoint default - last half of the calendar - is a silent guess and + is deliberately not carried over; MultiPeriodDiD itself keeps it + through 3.9); the mode carries + TWFE's inference stack from day one - unit auto-cluster (with the + static carve-outs: dropped on Conley and for explicit one-way + analytical families, never injected as a survey PSU) - and + `inference="wild_bootstrap"` raises an explicit `ValueError` (the + wild cluster bootstrap covers the static ATT only; MultiPeriodDiD's + silent analytical fallback is deliberately not carried into the + merged mode). HonestDiD, PreTrendsPower, and `plot_event_study` + consume the new surface (dedicated calendar container routes with + native-route parity; HonestDiD scoped to chronologically-partitioned + surfaces per the Rambachan-Roth restriction geometry). + +### Changed +- **DiagnosticReport and BusinessReport explicitly reject + `EventStudyResults` inputs** (previously: DiagnosticReport silently + produced a zero-check report via an empty type-keyed applicability + set, and BusinessReport rendered an all-null scalar headline). Both + errors steer to the fitted estimator's scalar results; admission of + event-study surfaces is tracked in TODO.md. + ### Deprecated +- **MultiPeriodDiD + the EventStudy alias** (v4 program Phase 3(a); + ledger rows [M-010] shimmed, [M-060]): constructing `MultiPeriodDiD` + (or `EventStudy` - the same class object) emits a `FutureWarning` + naming the successor; both are removed in 4.0. Migration: + `TwoWayFixedEffects().fit(..., event_study=True, spec="pooled")` + reproduces the MultiPeriodDiD design; the default `spec="within"` + adds unit fixed effects (standard errors and, on unbalanced or + covariate designs, point estimates shift - the documented estimate + change). Behavior of fitted MultiPeriodDiD results is unchanged + through 3.9. +- **TwoWayFixedEffects static `fit(time=)` renamed to `post=`** (ledger + row [M-082] shimmed): the static 0/1 dummy parameter is `post=`; the + old keyword still works through 3.9 with a `FutureWarning` (from 4.0, + `time=` means the event-study calendar column only - the 4.0 semantic + enforcement is row [M-083]). Positional callers are unaffected + (`post` occupies the old slot). - **The 8 estimator convenience wrappers + the CDiD/Gardner/Stacked alias diet; new `SCM` alias** (v4 program 2(d) PR-A; ledger rows [M-070]..[M-077] shimmed, [M-062] + [M-135] done, notes amendments to diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39f642bce..5bb6dcbcf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -174,8 +174,10 @@ A new parameter is only complete when it is: - applied in **every** aggregation mode — `simple`, `event_study`, and `group` - applied in the **bootstrap/inference** paths, not just the analytical one - reflected on the result object, so `to_dict()`/`summary()` do not misreport it -- propagated to the estimators that inherit it: `TwoWayFixedEffects` and - `MultiPeriodDiD` define no `__init__` of their own, so they inherit a new +- propagated to the estimators that inherit it: `TwoWayFixedEffects` defines + no `__init__` of its own, and `MultiPeriodDiD`'s 3.9 deprecation shim + forwards via `super().__init__(*args, **kwargs)` with an import-time + `__signature__` mirror - so both inherit a new `DifferenceInDifferences` constructor parameter automatically; `SyntheticDiD` defines its OWN signature (it forwards only `robust`/`cluster`/`alpha` to `super().__init__`), so a new parent diff --git a/DEFERRED.md b/DEFERRED.md index bccde3990..316f4b1b9 100644 --- a/DEFERRED.md +++ b/DEFERRED.md @@ -20,6 +20,7 @@ provenance and AI-review deviation-documentation: a row here (or in | Issue | Location | PR | Priority | |-------|----------|----|----------| +| 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 | | `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 | | 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 | | 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 | `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 | | `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 | | `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 | -| 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 | +| 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 | | `predict()` raises `NotImplementedError` — rarely needed; user-facing limitation. | `estimators.py:890-911` | — | Low | ## Version-gated (v4) @@ -126,6 +127,7 @@ decisions (refactor waivers, perf trade-offs, test-infrastructure calls) are rec | Decision | Location | Verified | |----------|----------|----------| +| **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 | | **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 | | **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 | | **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 | diff --git a/README.md b/README.md index 13d54b33a..818f0875b 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; 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. +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. 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. @@ -100,7 +100,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`. - [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 +- [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`) - [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 diff --git a/TODO.md b/TODO.md index 4f86755cf..f7c9dd071 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,11 @@ Related tracking surfaces: | Issue | Location | Origin | Effort | Priority | |-------|----------|--------|--------|----------| +| diagnostic_report admission for `EventStudyResults` surfaces (the TWFE event-study mode + `aggregate('event_study')` containers): DiagnosticReport/BusinessReport now REJECT the surface explicitly (Phase 3(a); previously a silent zero-check report / all-null headline) and practitioner_next_steps serves the generic fall-through - admission needs source-aware routing (the type-name-keyed `_APPLICABILITY`/`_HANDLERS` registries cannot discriminate the unified container's producers) and a scalar-vs-per-period headline design; MPD-native results received {parallel_trends, pretrends_power, sensitivity, bacon, design_effect} | `diff_diff/diagnostic_report.py`, `diff_diff/business_report.py`, `diff_diff/practitioner.py` | 3(a) | Mid | Medium | +| Align the NATIVE pretrends pre_periods= contract with the container routes' fail-closed validation: `_extract_pre_period_params`'s MPD branch silently filters an explicit `pre_periods=` selection (unknown labels, the reference, unusable-inference rows dropped without error, caller order preserved) while both container routes validate every requested label and enforce calendar chronology (the relative route since M-024; the calendar route since 3(a) R8) - the relative route also silently collapses DUPLICATE requested labels (the calendar route rejects them), and both filter on SE only while the calendar route additionally requires a finite EFFECT (3(a) R9); one contract across all three routes, with pinned rejection messages | `diff_diff/pretrends.py` | 3(a) R8 | Quick | Low | +| `EventStudyResults` inference-provenance fields: the container records no `vcov_type`/`cluster_name`/`n_clusters`/`df_convention`/Conley metadata, so a serialized surface cannot distinguish unit auto-clustering from explicit clustering, survey, Conley, or the one-way carve-out (3(a) R9 review). Adding them is a cross-producer M-092 schema amendment (six builders, to_dict/summary rendering, surface-suite pins) - follow the pre-cut amendment convention (optional fields appended last, ledger note same-diff) rather than bolting onto one producer | `diff_diff/results_base.py` | 3(a) R9 | Mid | Low | +| Opt-in singleton-group pruning for TwoWayFixedEffects (static + event-study mode; reghdfe parity): singleton units/periods are currently RETAINED class-wide - the within-demeaned row is zero so points are unchanged, but N/G/residual-df count it and CR1/finite-sample SEs shift (~0.41019 -> 0.40962 measured; REGISTRY "Deviation from R" Note, R5 review) - reghdfe iteratively drops singletons by default while fixest retains them (diff-diff matches fixest); an opt-in knob needs iterative unit+period pruning with consistent cluster/survey/replicate/Conley array subsetting and a default-flip decision protocol (moves published SEs) | `diff_diff/twfe.py`, `diff_diff/estimators.py`, `diff_diff/utils.py` | 3(a) R5 | Mid | Low | +| Cohort-timing validation input for the simultaneous-adoption event-study family (TWFE `event_study=True` + MultiPeriodDiD through 3.9): an optional `first_treat=`/`cohort=` column so simultaneous adoption becomes checkable under the contract-valid time-invariant `D_i` indicator - today the staggered-adoption advisory derives timing from within-unit 0->1 transitions, so it can only fire on off-contract time-varying `D_it` input, and with valid `D_i` adoption timing is not observable in the inputs at all (REGISTRY "staggered-adoption detection limit" Notes, both sections); design questions: validate-only vs steering error, and interplay with the M-011 removal | `diff_diff/twfe.py`, `diff_diff/estimators.py` | 3(a) R2 | Mid | Medium | | EfficientDiD `aggregate()` recompute levels (event_study/group) on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence); wiring `BootstrapReplaySpec` (or retaining the n_bootstrap x n_gt draw matrix materialized at fit) would enable exact post-fit replay of percentile inference | `diff_diff/efficient_did_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3a | Mid | Low | | ImputationDiD/TwoStageDiD `aggregate()` recompute levels on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence; M-021/M-022); ImputationDiD's per-target psi machinery makes seeded replay tractable (the panel-backed kit retains everything the psi precompute reads), TwoStageDiD's per-level GMM scores are function-locals and would need retention | `diff_diff/imputation_results.py`, `diff_diff/two_stage_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3b | Mid | Low | | ContinuousDiD `aggregate('event_study')` on bootstrapped fits fails closed (M-025); a seeded post-fit bootstrap-ES replay is tractable - the multiplier draws are seeded (`np.random.default_rng(self.seed)`) - but needs the FULL per-cell `_bootstrap_info` (bread/ee_treated/Psi_eval/dPsi_*/beta_pred) the pruned kit deliberately drops, so shipping it means a kit-payload change with its own memory contract | `diff_diff/continuous_did_aggregation.py`, `diff_diff/continuous_did_results.py` | 2(b) PR-3c | Mid | Low | @@ -65,6 +70,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m | Issue | Location | Origin | Effort | Priority | |-------|----------|--------|--------|----------| +| Committed `fixest::feols` event-study golden for TWFE `event_study=True` (within + pooled specs, unbalanced + covariate panels, matched CR1 cluster convention, per-period effects + vcov block) - the in-suite gates are shared-core cross-checks (TWFE-within == MPD-absorb, pooled == MPD bit-exact), so a defect common to the shared core would pass; the live-R harness (`benchmarks/R/benchmark_multiperiod.R`, `feols(y ~ treated * time_f \| unit)`) validated the within design in `docs/benchmarks.rst` but is not a committed regression test - follow the `fixest_did_twfe_golden.json` committed-golden pattern (pytest.skip when absent) | `tests/test_fixest_did_twfe_parity.py`, `benchmarks/R/` | 3(a) R2 | Mid | Medium | | Type-blind `n_bootstrap` acceptance in already-validated estimators - HAD bool (`isinstance(..., int)` passes `True`, runs as 1 replicate), dCDH bool+float (its bare `< 0` check passes both `True` and `2.5`), TROP float (`2.5` passes the `>= 2` floor), SyntheticDiD float under all three variance methods + bool/negative under jackknife (its floor check is skipped there) - align these local checks with the `utils.validate_n_bootstrap` type guard (M-081 kept them out of the sweep: it scoped to previously-UNvalidated estimators only) | `diff_diff/had.py`, `diff_diff/chaisemartin_dhaultfoeuille.py`, `diff_diff/trop.py`, `diff_diff/synthetic_did.py` | 2(d) PR-B | Quick | Low | | M-020-era CS fit-time `aggregate=` teachings persist in troubleshooting.rst (:215/:241/:244) and choosing_estimator.rst (:243) - CS examples still fit with the deprecated kwarg; migrate to post-fit `results.aggregate('event_study')` (the two HAD examples in the same file were migrated with M-027) | `docs/troubleshooting.rst` | 2(b) PR-4 | Quick | Low | | Evaluate adding the `BaseEstimator` param surface (get_params/set_params) to the exported classes that never had it - `PowerAnalysis`, `LinearRegression`, `BusinessReport`, `DiagnosticReport`, `TWFEWeightsResult` (a NEW public surface, deliberately out of the 2(c)-i pure-refactor scope; `LinearRegression` is the one `fit`-bearing class excluded from the contract suite's roster-completeness test). | `diff_diff/linalg.py`, `diff_diff/power.py` | mixin PR | Mid | Low | diff --git a/benchmarks/python/benchmark_twfe.py b/benchmarks/python/benchmark_twfe.py index ce72266b2..575a92cfc 100644 --- a/benchmarks/python/benchmark_twfe.py +++ b/benchmarks/python/benchmark_twfe.py @@ -80,7 +80,7 @@ def main(): if args.warmup: print("Warm-up fit (untimed)...") TwoWayFixedEffects(robust=True).fit( - data, outcome="outcome", treatment="treated", time="post", unit="unit" + data, outcome="outcome", treatment="treated", post="post", unit="unit" ) twfe = TwoWayFixedEffects(robust=True) # auto-clusters at unit level @@ -90,7 +90,7 @@ def main(): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) diff --git a/benchmarks/speed_review/bench_brand_awareness_survey.py b/benchmarks/speed_review/bench_brand_awareness_survey.py index c41d66b9c..ccd7620cc 100644 --- a/benchmarks/speed_review/bench_brand_awareness_survey.py +++ b/benchmarks/speed_review/bench_brand_awareness_survey.py @@ -75,13 +75,13 @@ def naive_fit(): # practitioners actually start. did = DifferenceInDifferences(robust=True) results["naive"] = did.fit( - data, outcome="outcome", treatment="treat_unit", time="post", + data, outcome="outcome", treatment="treat_unit", post="post", ) def tsl_fit(): did = DifferenceInDifferences(robust=True) results["tsl"] = did.fit( - data, outcome="outcome", treatment="treat_unit", time="post", + data, outcome="outcome", treatment="treat_unit", post="post", survey_design=sd_tsl, ) @@ -94,7 +94,7 @@ def replicate_fit(): ) did = DifferenceInDifferences(robust=True) results["replicate"] = did.fit( - data, outcome="outcome", treatment="treat_unit", time="post", + data, outcome="outcome", treatment="treat_unit", post="post", survey_design=sd, ) @@ -103,7 +103,7 @@ def multi_outcome_loop(): for y in ("outcome", "consideration", "purchase_intent"): did = DifferenceInDifferences(robust=True) out[y] = did.fit( - data, outcome=y, treatment="treat_unit", time="post", + data, outcome=y, treatment="treat_unit", post="post", survey_design=sd_tsl, ) results["multi_outcome"] = out diff --git a/benchmarks/speed_review/bench_dose_response.py b/benchmarks/speed_review/bench_dose_response.py index 286c46e37..c30024d0a 100644 --- a/benchmarks/speed_review/bench_dose_response.py +++ b/benchmarks/speed_review/bench_dose_response.py @@ -86,7 +86,7 @@ def binarized_comparison(): data_bin["post"] = (data_bin["period"] >= treated_cohort).astype(int) did = DifferenceInDifferences(robust=True) results["binarized"] = did.fit( - data_bin, outcome="outcome", treatment="treated_any", time="post", + data_bin, outcome="outcome", treatment="treated_any", post="post", ) def spline_sensitivity_linear(): diff --git a/benchmarks/speed_review/bench_fe_absorption.py b/benchmarks/speed_review/bench_fe_absorption.py index f6867628e..2c11cf39f 100644 --- a/benchmarks/speed_review/bench_fe_absorption.py +++ b/benchmarks/speed_review/bench_fe_absorption.py @@ -89,13 +89,13 @@ def _fit(scenario, df): from diff_diff import TwoWayFixedEffects res = TwoWayFixedEffects().fit( - df, outcome="y", treatment="treated", time="post", unit="unit" + df, outcome="y", treatment="treated", post="post", unit="unit" ) elif scenario in ("geo_experiment", "tail_stress"): from diff_diff import DifferenceInDifferences res = DifferenceInDifferences().fit( - df, outcome="y", treatment="treated", time="post", absorb=["store", "week"] + df, outcome="y", treatment="treated", post="post", absorb=["store", "week"] ) elif scenario == "survey_absorb": from diff_diff import DifferenceInDifferences @@ -107,7 +107,7 @@ def _fit(scenario, df): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["state", "month"], survey_design=design, ) diff --git a/benchmarks/speed_review/bench_memory_scaling.py b/benchmarks/speed_review/bench_memory_scaling.py index f3f597f82..6bf721ae3 100644 --- a/benchmarks/speed_review/bench_memory_scaling.py +++ b/benchmarks/speed_review/bench_memory_scaling.py @@ -139,7 +139,7 @@ def _run_one(cfg): _twfe_panel(nu, ncov=ncov), outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=[f"x{j}" for j in range(ncov)], ) diff --git a/benchmarks/speed_review/bench_solve_ols_fastpath.py b/benchmarks/speed_review/bench_solve_ols_fastpath.py index cf4f3daa0..3c668b475 100644 --- a/benchmarks/speed_review/bench_solve_ols_fastpath.py +++ b/benchmarks/speed_review/bench_solve_ols_fastpath.py @@ -105,7 +105,7 @@ def fit(d): def fit(d): res = TwoWayFixedEffects().fit( - d, outcome="y", treatment="treated", time="post", unit="unit" + d, outcome="y", treatment="treated", post="post", unit="unit" ) return float(res.att), float(res.se) @@ -142,7 +142,7 @@ def fit(d): d, outcome="y", treatment="treated", - time="post", + post="post", absorb=["state", "month"], survey_design=design, ) diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index 09e30e25a..bb40e6024 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -177,6 +177,28 @@ def __init__( survey_design: Optional[Any] = None, precomputed: Optional[Dict[str, Any]] = None, ): + # The unified event-study container (row M-092) is rejected + # explicitly: BusinessReport's headline schema is SCALAR + # (effect/se/ci/p on one inference row), while EventStudyResults + # is per-period by construction and the TWFE event-study mode + # deliberately ships no overall ATT - so the report would render + # an all-null headline under the generic fall-through + # (no-silent-failures). BR/DR admission of EventStudyResults + # surfaces is tracked in TODO.md. + from diff_diff.results_base import EventStudyResults as _ESR + + if isinstance(results, _ESR): + raise TypeError( + "BusinessReport does not yet support EventStudyResults " + "surfaces (the TWFE event-study mode and " + "aggregate('event_study') containers): the narrative " + "headline is scalar and per-period surfaces carry no " + "overall ATT. Build the report from the fitted " + "estimator's scalar results (e.g. a static " + "TwoWayFixedEffects fit); use the event-study surface " + "with HonestDiD, PreTrendsPower, or plot_event_study. " + "EventStudyResults admission is tracked in TODO.md." + ) # Marked diagnostic results are rejected BY TYPE (spec section # 3.5, ledger row M-091): BusinessReport's primary input is a # fitted ESTIMATOR result carrying the canonical inference row. diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index 872038756..e757b5813 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -405,6 +405,28 @@ def __init__( outcome_label: Optional[str] = None, treatment_label: Optional[str] = None, ): + # The unified event-study container (row M-092) is rejected + # explicitly: _APPLICABILITY is keyed by scalar results-class + # names, so an EventStudyResults input would silently resolve to + # an EMPTY applicability set and produce a zero-check report + # (no-silent-failures). Admission of EventStudyResults surfaces + # (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). + from diff_diff.results_base import EventStudyResults as _ESR + + if isinstance(results, _ESR): + raise TypeError( + "DiagnosticReport does not yet support EventStudyResults " + "surfaces (the TWFE event-study mode and " + "aggregate('event_study') containers): its checks are " + "keyed to scalar estimator results. Run it on the fitted " + "estimator's scalar results instead (e.g. a static " + "TwoWayFixedEffects fit, or the native results object of " + "the producing estimator). EventStudyResults admission is " + "tracked in TODO.md." + ) # Marked diagnostic results (spec section 3.5, ledger row M-091) # are rejected BY TYPE — except Bacon, whose dedicated read-out # is retained. Before the marker, such inputs silently produced diff --git a/diff_diff/estimators.py b/diff_diff/estimators.py index 64d97ce06..521add248 100644 --- a/diff_diff/estimators.py +++ b/diff_diff/estimators.py @@ -59,6 +59,23 @@ # 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): """ @@ -1277,7 +1294,7 @@ def _normalize_set_params(cls, params: Dict[str, Any]) -> Dict[str, Any]: params["vcov_type"] = None return params - def _warn_replicate_vcov_ignored(self) -> bool: + def _warn_replicate_vcov_ignored(self, stacklevel: int = 3) -> bool: """Warn that an explicit ``vcov_type`` has no effect under a replicate-weight survey design, and tell the caller to remap the fit-time vcov to ``"hc1"``. @@ -1312,11 +1329,11 @@ def _warn_replicate_vcov_ignored(self) -> bool: "estimates only, identical across vcov families). Proceeding " "with replicate variance; the base fit uses 'hc1'.", UserWarning, - stacklevel=3, + stacklevel=stacklevel, ) return True - def _resolve_effective_vcov_type(self, effective_cluster_ids) -> str: + def _resolve_effective_vcov_type(self, effective_cluster_ids, stacklevel: int = 3) -> str: """Pick the ``vcov_type`` to use for a given fit given cluster context. Returns ``self.vcov_type`` unchanged in nearly every case. The one @@ -1350,7 +1367,7 @@ def _resolve_effective_vcov_type(self, effective_cluster_ids) -> str: "non-robust SEs, or vcov_type='hc1' to silence this " "warning.", UserWarning, - stacklevel=3, + stacklevel=stacklevel, ) return "hc1" return self.vcov_type @@ -1373,217 +1390,73 @@ def print_summary(self) -> None: """Print summary to stdout.""" print(self.summary()) - -class MultiPeriodDiD(DifferenceInDifferences): - """ - Multi-Period Difference-in-Differences estimator. - - 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 - ---------- - robust : bool, optional - DEPRECATED legacy alias for ``vcov_type`` (row M-045; warns with - ``FutureWarning``, removed in 4.0 - use ``vcov_type=``). - ``robust=True`` maps to ``vcov_type="hc1"``; ``robust=False`` maps - to ``vcov_type="classical"``. Explicit ``vcov_type`` overrides - ``robust`` unless the pair is contradictory (e.g. - ``robust=False, vcov_type="hc2"`` raises). - 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", "conley"}, optional - Variance-covariance family. Defaults to the ``robust`` alias. - - - ``"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. - - ``"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. - - ``"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 fit( # type: ignore[override] + def _fit_event_study_core( 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. + post_periods: Optional[List[Any]], + covariates: Optional[List[str]], + fixed_effects: Optional[List[str]], + absorb: Optional[List[str]], + reference_period: Any, + unit: Optional[str], + 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, + ) -> Tuple[MultiPeriodDiDResults, np.ndarray, np.ndarray]: + """Shared event-study estimation core (row M-010). + + 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) + --------------------------------------------- + include_treatment_main : bool + 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). + estimator_name : str + Producer name for warnings and validation messages, so TWFE + event-study fits never steer users toward the deprecated + MultiPeriodDiD class. + _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. """ - # 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" - + cluster_resolved: Optional[str] = ( + self.cluster if cluster_override is _USE_SELF_CLUSTER else 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'") @@ -1609,12 +1482,12 @@ def fit( # type: ignore[override] if not has_reversal and len(d_vals) > 1 and np.any(np.diff(d_vals) < 0): warnings.warn( f"Treatment reversal detected (unit '{u}' transitions from " - f"treated to untreated). MultiPeriodDiD assumes treatment is " + f"treated to untreated). {estimator_name} assumes treatment is " f"an absorbing state (once treated, always treated). " f"Treatment reversals violate this assumption and may " f"produce unreliable estimates.", UserWarning, - stacklevel=2, + stacklevel=2 + _frame_offset, ) has_reversal = True # Only use units with observed 0→1 transition for adoption timing @@ -1627,11 +1500,11 @@ def fit( # type: ignore[override] if unique_adoption > 1: warnings.warn( "Treatment timing varies across units (staggered adoption " - "detected). MultiPeriodDiD assumes simultaneous adoption " + f"detected). {estimator_name} assumes simultaneous adoption " "and may produce biased estimates with staggered treatment. " "Consider using CallawaySantAnna or SunAbraham instead.", UserWarning, - stacklevel=2, + stacklevel=2 + _frame_offset, ) # Check for time-varying treatment (D_it instead of D_i) @@ -1639,7 +1512,7 @@ def fit( # type: ignore[override] # MultiPeriodDiD expects a time-invariant ever-treated indicator. warnings.warn( "Treatment indicator varies within units (time-varying " - "treatment detected). MultiPeriodDiD's event-study " + f"treatment detected). {estimator_name}'s event-study " "specification expects a time-invariant ever-treated " "indicator (D_i = 1 for all periods of eventually-treated " "units). With time-varying treatment, pre-period " @@ -1647,7 +1520,7 @@ def fit( # type: ignore[override] f"df['ever_treated'] = df.groupby('{unit}')['{treatment}']" ".transform('max')", UserWarning, - stacklevel=2, + stacklevel=2 + _frame_offset, ) # Get all unique time periods @@ -1679,7 +1552,7 @@ def fit( # type: ignore[override] "is still valid, but pre-period coefficients for parallel trends " "testing are not available.", UserWarning, - stacklevel=2, + stacklevel=2 + _frame_offset, ) # Validate post_periods are in the data @@ -1689,8 +1562,11 @@ def fit( # type: ignore[override] # Determine reference period (omitted dummy) if reference_period is None: - # Default: last pre-period (e=-1 convention, matches fixest) - if len(pre_periods) > 1: + # 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 " @@ -1699,7 +1575,7 @@ def fit( # type: ignore[override] f"To silence this warning, pass " f"reference_period={pre_periods[-1]} explicitly.", FutureWarning, - stacklevel=2, + stacklevel=2 + _frame_offset, ) reference_period = pre_periods[-1] elif reference_period not in all_periods: @@ -1739,7 +1615,9 @@ def fit( # type: ignore[override] "survey designs. Replicate weights provide their own variance " "estimation." ) - _replicate_vcov_remap_mp = _uses_replicate_mp and self._warn_replicate_vcov_ignored() + _replicate_vcov_remap_mp = _uses_replicate_mp and self._warn_replicate_vcov_ignored( + stacklevel=3 + _frame_offset + ) # Handle absorbed fixed effects (within-transformation) working_data = data.copy() @@ -1809,7 +1687,7 @@ def fit( # type: ignore[override] from diff_diff.conley import _validate_conley_estimator_inputs _validate_conley_estimator_inputs( - estimator_name="MultiPeriodDiD", + estimator_name=estimator_name, data=data, unit=unit, conley_coords=self.conley_coords, @@ -1817,7 +1695,7 @@ def fit( # type: ignore[override] conley_lag_cutoff=self.conley_lag_cutoff, survey_design=survey_design, inference=self.inference, - cluster=self.cluster, + cluster=cluster_resolved, ) # Pre-compute non_ref_periods (needed for absorb demeaning) non_ref_periods = [p for p in all_periods if p != reference_period] @@ -1828,12 +1706,19 @@ def fit( # type: ignore[override] # absorbing unit FE) will zero out and be handled by rank-deficiency. d_raw = working_data[treatment].values.astype(float) t_raw = working_data[time].values - working_data["_did_treatment"] = d_raw + # include_treatment_main=False (the TWFE spec="within" design) + # omits the D main-effect working column entirely: D is absorbed + # by the unit FE, so demeaning it would only snap it to zero and + # emit a spurious collinearity warning. Interactions are built + # from the RAW indicator either way. + if include_treatment_main: + working_data["_did_treatment"] = d_raw for period in non_ref_periods: working_data[f"_did_period_{period}"] = (t_raw == period).astype(float) working_data[f"_did_interact_{period}"] = d_raw * (t_raw == period).astype(float) vars_to_demean = ( - [outcome, "_did_treatment"] + [outcome] + + (["_did_treatment"] if include_treatment_main else []) + [f"_did_period_{p}" for p in non_ref_periods] + [f"_did_interact_{p}" for p in non_ref_periods] + (covariates or []) @@ -1872,6 +1757,7 @@ def fit( # type: ignore[override] absorbed_desc=f"absorb={list(absorb)}", group_vars=list(absorb), rank_deficient_action=self.rank_deficient_action, + stacklevel=3 + _frame_offset, display_names={ "_did_treatment": treatment, **{f"_did_period_{p}": f"{time}=={p}" for p in non_ref_periods}, @@ -1883,9 +1769,12 @@ def fit( # type: ignore[override] # Extract outcome and treatment (may be demeaned if absorb was used) y = working_data[outcome].values.astype(float) - if absorb: + if absorb and include_treatment_main: d = working_data["_did_treatment"].values.astype(float) else: + # Raw indicator: the non-absorb design uses it for the main + # effect and interactions; with include_treatment_main=False it + # feeds interactions only (never enters X directly). d = working_data[treatment].values.astype(float) t = working_data[time].values @@ -1908,12 +1797,16 @@ def fit( # type: ignore[override] if fe == time: continue _reserved.update(fe_dummy_names(working_data[fe], fe)) - validate_covariate_names(covariates, _reserved, estimator="MultiPeriodDiD") + validate_covariate_names(covariates, _reserved, estimator=estimator_name) # Build design matrix - # Start with intercept and treatment main effect - X = np.column_stack([np.ones(len(y)), d]) - var_names = ["const", treatment] + # Start with intercept and (unless omitted) the treatment main effect + if include_treatment_main: + X = np.column_stack([np.ones(len(y)), d]) + var_names = ["const", treatment] + else: + X = np.ones((len(y), 1)) + var_names = ["const"] # Add period dummies (excluding reference period) period_dummy_indices = {} # Map period -> column index in X @@ -1970,16 +1863,16 @@ def fit( # type: ignore[override] # colliding with a structural period_{p} key) BEFORE the regression — so # the fit is not wasted and no misleading multicollinearity warning is # emitted ahead of the intended ValueError. - validate_design_term_names(var_names, estimator="MultiPeriodDiD") + validate_design_term_names(var_names, estimator=estimator_name) # Fit OLS using unified backend # Pass cluster_ids to solve_ols for proper vcov computation # This handles rank-deficient matrices by returning NaN for dropped columns - cluster_ids = data[self.cluster].values if self.cluster is not None else None + cluster_ids = data[cluster_resolved].values if cluster_resolved is not None else None # When survey PSU is present, it overrides cluster for variance estimation effective_cluster_ids = _resolve_effective_cluster( - resolved_survey, cluster_ids, self.cluster + resolved_survey, cluster_ids, cluster_resolved ) # Inject cluster as effective PSU for survey variance estimation @@ -2002,7 +1895,9 @@ def fit( # type: ignore[override] _fit_vcov_type = ( "hc1" if _replicate_vcov_remap_mp - else self._resolve_effective_vcov_type(effective_cluster_ids) + else self._resolve_effective_vcov_type( + effective_cluster_ids, stacklevel=3 + _frame_offset + ) ) # Cluster + CR2 Bell-McCaffrey (non-survey, unweighted) shares the SAME @@ -2132,12 +2027,14 @@ def _refit_mp_absorb(w_r): w_nz = w_r[nz] d_raw_ = wd[treatment].values.astype(float) t_raw_ = wd[time].values - wd["_did_treatment"] = d_raw_ + if include_treatment_main: + wd["_did_treatment"] = d_raw_ for period_ in non_ref_periods: wd[f"_did_period_{period_}"] = (t_raw_ == period_).astype(float) wd[f"_did_interact_{period_}"] = d_raw_ * (t_raw_ == period_).astype(float) vars_dm_ = ( - [outcome, "_did_treatment"] + [outcome] + + (["_did_treatment"] if include_treatment_main else []) + [f"_did_period_{p}" for p in non_ref_periods] + [f"_did_interact_{p}" for p in non_ref_periods] + (covariates or []) @@ -2155,8 +2052,11 @@ def _refit_mp_absorb(w_r): weights=w_nz, ) y_r = wd[outcome].values.astype(float) - d_r = wd["_did_treatment"].values.astype(float) - X_r = np.column_stack([np.ones(len(y_r)), d_r]) + if include_treatment_main: + d_r = wd["_did_treatment"].values.astype(float) + X_r = np.column_stack([np.ones(len(y_r)), d_r]) + else: + X_r = np.ones((len(y_r), 1)) for period_ in non_ref_periods: X_r = np.column_stack([X_r, wd[f"_did_period_{period_}"].values.astype(float)]) for period_ in non_ref_periods: @@ -2258,7 +2158,7 @@ def _refit_mp_absorb(w_r): "df_convention='cluster' requires at least 2 effective " f"clusters; got {_g_eff_mp}. Inference fields will be NaN.", UserWarning, - stacklevel=2, + stacklevel=2 + _frame_offset, ) _df_cluster_knob_invalid = True df = 0 @@ -2317,7 +2217,7 @@ def _refit_mp_absorb(w_r): f"Degrees of freedom is non-positive (df={df}). " "Using normal distribution instead of t-distribution for inference.", UserWarning, - stacklevel=2, + stacklevel=2 + _frame_offset, ) df = None @@ -2523,7 +2423,7 @@ def _refit_mp_absorb(w_r): coef_dict = {name: coef for name, coef in zip(var_names, coefficients)} # Store results - self.results_ = MultiPeriodDiDResults( + _core_results = MultiPeriodDiDResults( period_effects=period_effects, avg_att=avg_att, avg_se=avg_se, @@ -2547,7 +2447,7 @@ def _refit_mp_absorb(w_r): # Report the family that actually produced the SE; may be the # remapped hc1 under the legacy alias path, not self.vcov_type. vcov_type=_fit_vcov_type, - cluster_name=self.cluster, + cluster_name=cluster_resolved, n_clusters=( len(np.unique(effective_cluster_ids)) if effective_cluster_ids is not None else None ), @@ -2561,10 +2461,265 @@ def _refit_mp_absorb(w_r): event_study_df=es_df_used, ) + return _core_results, coefficients, vcov + + +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 + ---------- + robust : bool, optional + DEPRECATED legacy alias for ``vcov_type`` (row M-045; warns with + ``FutureWarning``, removed in 4.0 - use ``vcov_type=``). + ``robust=True`` maps to ``vcov_type="hc1"``; ``robust=False`` maps + to ``vcov_type="classical"``. Explicit ``vcov_type`` overrides + ``robust`` unless the pair is contradictory (e.g. + ``robust=False, vcov_type="hc2"`` raises). + 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", "conley"}, optional + Variance-covariance family. Defaults to the ``robust`` alias. + + - ``"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. + - ``"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. + - ``"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" + + results, coefficients, vcov = 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_ = results self._coefficients = coefficients self._vcov = vcov self.is_fitted_ = True - return self.results_ def summary(self) -> str: @@ -2582,6 +2737,19 @@ def summary(self) -> str: 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 diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 0aebda52a..1f0a366be 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -339,7 +339,7 @@ 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` | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | +| `MultiPeriodDiD` (deprecated 3.9 → `TwoWayFixedEffects` `event_study=True`) | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | | `TwoWayFixedEffects` | ✓ | warn | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | | `CallawaySantAnna` | ✓ | ✓ | ✗ | ✗ | partial | ✓ | ✗ | ✗ | ✓ | | `SunAbraham` | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ | @@ -459,7 +459,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: -- `MultiPeriodDiD` - per-period effect, standard event-study plot. +- `TwoWayFixedEffects(...).fit(..., event_study=True)` - per-period effects, standard event-study plot (`MultiPeriodDiD` is its deprecated predecessor; `spec='pooled'` reproduces it). - `TwoWayFixedEffects` with event-time dummies - similar output, no forbidden comparisons because there is only one cohort. diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index e92e5a9b4..57d5ff0fb 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -122,10 +122,15 @@ twfe.fit( data: pd.DataFrame, outcome: str, treatment: str, - time: str, + post: str, # 0/1 post dummy (renamed from time=, which warns through 3.9) unit: str, covariates: list[str] = None, -) -> DiDResults + event_study: bool = False, # per-period event study returning EventStudyResults + spec: str = "within", # "within" (unit-FE event study) | "pooled" (the MultiPeriodDiD 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 +) -> DiDResults | EventStudyResults ``` **Usage:** @@ -134,15 +139,26 @@ twfe.fit( from diff_diff import TwoWayFixedEffects twfe = TwoWayFixedEffects() -results = twfe.fit(data, outcome='y', treatment='treated', time='post', unit='unit_id') +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) +es = twfe.fit(data, outcome='y', treatment='treated', unit='unit_id', + event_study=True, time='period', post_periods=[4, 5, 6, 7]) +es.print_summary() ``` **Note:** TWFE can be biased with staggered treatment timing and heterogeneous effects. Consider CallawaySantAnna, SunAbraham, or ImputationDiD for staggered designs. ### MultiPeriodDiD -Event-study style DiD with period-specific treatment effects. Inherits from DifferenceInDifferences. +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. ```python MultiPeriodDiD( @@ -2539,7 +2555,7 @@ mirroring `MultiPeriodDiD.fit(unit=...)` / `TwoWayFixedEffects.fit(unit=...)`. ```python import numpy as np from diff_diff.linalg import LinearRegression -from diff_diff import DifferenceInDifferences, MultiPeriodDiD, TwoWayFixedEffects +from diff_diff import DifferenceInDifferences, TwoWayFixedEffects # Cross-sectional design: 1 row per unit, n × 2 lat/lon coords. reg = LinearRegression( @@ -2553,34 +2569,37 @@ reg = LinearRegression( se = np.sqrt(np.diag(reg.vcov_)) # Panel design: TWFE with within-unit Bartlett serial HAC. -# TWFE's `time` column is intrinsically a binary post indicator -# (treatment * time interaction); 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 MultiPeriodDiD. +# 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). res = TwoWayFixedEffects( vcov_type="conley", conley_coords=("lat", "lon"), # column names on `data` conley_cutoff_km=500.0, conley_lag_cutoff=1, # within-unit Bartlett, lag 1 panel period -).fit(data, outcome="y", treatment="treated", time="post", unit="unit_id") +).fit(data, outcome="y", treatment="treated", post="post", unit="unit_id") -# Panel design: MultiPeriodDiD with multi-period time. -# MultiPeriodDiD builds period dummies (NOT a treated * time product), so +# Panel design: multi-period event study (TWFE event-study mode; the +# deprecated MultiPeriodDiD behaves identically via the shared core). +# 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. # `_compute_conley_vcov` normalizes time to dense codes 0..T-1 internally, # so `conley_lag_cutoff` always counts panel periods regardless of label. -mp_res = MultiPeriodDiD( +mp_res = TwoWayFixedEffects( vcov_type="conley", conley_coords=("lat", "lon"), conley_cutoff_km=200.0, conley_lag_cutoff=2, # within-unit Bartlett up to 2 panel periods -).fit(data, outcome="y", treatment="treated", time="period", - post_periods=[2, 3], unit="unit_id") +).fit(data, outcome="y", treatment="treated", event_study=True, + spec="pooled", time="period", post_periods=[2, 3], unit="unit_id") # 2-period DiD on a panel: DiD.fit(unit="") opts into the Conley # panel block-decomposed sandwich; on a 2-period design the ATT/SE match -# MultiPeriodDiD(...).fit(..., post_periods=[1], reference_period=0) bit-exactly. +# the pooled event-study fit with post_periods=[1], reference_period=0 bit-exactly. did_res = DifferenceInDifferences( vcov_type="conley", conley_coords=("lat", "lon"), @@ -2600,7 +2619,7 @@ combined_res = TwoWayFixedEffects( conley_coords=("lat", "lon"), conley_cutoff_km=500.0, conley_lag_cutoff=1, -).fit(data, outcome="y", treatment="treated", time="post", unit="unit_id") +).fit(data, outcome="y", treatment="treated", post="post", unit="unit_id") ``` **Note on `conley_lag_cutoff` semantics:** the lag is counted in **panel @@ -2688,7 +2707,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 | `MultiPeriodDiD` | +| Event study with multiple periods (simultaneous adoption) | `TwoWayFixedEffects` with `event_study=True` (`spec="pooled"` reproduces the deprecated `MultiPeriodDiD`) | | Staggered treatment timing | `CallawaySantAnna`, `ImputationDiD`, or `SunAbraham` | | Few treated units / synthetic control | `SyntheticDiD` | | Interactive fixed effects / factor confounding | `TROP` | diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index 3ee8cea94..9e100f39b 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -311,11 +311,15 @@ print(results.summary()) ### Event study ```python -from diff_diff import MultiPeriodDiD - -es = MultiPeriodDiD(cluster='unit_id') -results = es.fit(data, outcome='y', unit='unit_id', time='period', - treatment='treated') +from diff_diff import TwoWayFixedEffects + +# TwoWayFixedEffects event-study mode (MultiPeriodDiD is deprecated in +# 3.9; spec='pooled' reproduces its design). post_periods= is REQUIRED: +# the treatment boundary cannot be inferred from a time-invariant +# ever-treated indicator. +es = TwoWayFixedEffects(cluster='unit_id') +results = es.fit(data, outcome='y', unit='unit_id', treatment='treated', + event_study=True, time='period', post_periods=[3, 4, 5]) print(results.summary()) ``` @@ -348,7 +352,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, CallawaySantAnna, and ChaisemartinDHaultfoeuille +Works with MultiPeriodDiD (deprecated - the TWFE event-study surface also admits), 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 502a20e98..c41b3d7b6 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; a StackedDiD `results.aggregate('event_study')` container also admits - 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 (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. 7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (fit-time `aggregate=`/`balance_e=` are deprecated since 3.9, removed in 4.0; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; EXCEPTION: on a BOOTSTRAPPED CS fit the recompute levels `'event_study'`/`'group'` raise while `.aggregate('simple')` relays the stored bootstrap inference (NaN df column); use the fit-time aggregation for a bootstrapped event-study surface); 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; fit-time `aggregate=`/`balance_e=` deprecated; on bootstrapped EDiD fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the fit-time aggregation for a bootstrapped ES/group surface; 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 recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the deprecated fit-time aggregation for a bootstrapped ES/group surface; 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 and raises on bootstrapped fits (use the deprecated fit-time `aggregate='eventstudy'` there until 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. @@ -58,8 +58,8 @@ The site is organized into 5 sections, each with a landing page: ## 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 +- [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 diff --git a/diff_diff/honest_did.py b/diff_diff/honest_did.py index 8e746c80d..d44ba115d 100644 --- a/diff_diff/honest_did.py +++ b/diff_diff/honest_did.py @@ -577,6 +577,237 @@ def plot( # ============================================================================= +def _extract_calendar_container_params( + surface: Any, +) -> 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 + 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 + consecutive-grid validation, so the calendar route can never create a + third behavior. Admission is provenance-gated and GEOMETRY-SCOPED: + + - ``post_periods`` provenance must be present (a hand-built calendar + surface without the partition is rejected - the partition is not + recoverable from ``event_time``/``is_reference``). + - Exactly ONE reference row (the native branch never needed this + guard - a fitted producer always has one - but containers are + publicly constructible, and with several references the scalar + ``reference_period`` is None and the boundary is undefined). + - Registry geometry only: the Rambachan-Roth restriction matrices are + built positionally around a single chronological pre/post boundary + (Delta^SD over consecutive second differences with delta_0 = 0 at + the boundary), so a non-suffix ``post_periods`` or a reference that + is not the last pre-period is REJECTED rather than silently fed to + the restriction builders. The native route does not validate + declared partitions (a pre-existing limitation - see the REGISTRY + HonestDiD Note and the DEFERRED.md row); this new route fails + closed instead of reproducing that geometry. + + No unknown-provenance/base-period warning fires here: a first-party + calendar surface with the partition provenance and a marked reference + IS verified provenance (the native branch these surfaces replicate + has no such warning). + """ + if surface.source != "TwoWayFixedEffects": + 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." + ) + if surface.post_periods is None: + raise TypeError( + "HonestDiD requires the calendar container's post_periods " + "partition provenance (present on producer-built " + "TwoWayFixedEffects event-study surfaces); a calendar surface " + "without it cannot be partitioned into pre/post periods - the " + "partition is not recoverable from event_time positions." + ) + + _all_labels = surface.event_time.tolist() + if len(set(_all_labels)) != len(_all_labels): + raise ValueError( + "The event-study container carries duplicate event_time " + f"labels ({_all_labels}); each horizon must appear exactly " + "once." + ) + + ref_rows = surface.event_time[surface.is_reference].tolist() + if len(ref_rows) != 1: + raise ValueError( + "HonestDiD requires exactly one reference row on a " + f"calendar-scale container (got {sorted(ref_rows)}): the " + "Rambachan-Roth boundary (delta_0 = 0) is defined at the " + "single omitted reference period." + ) + ref_period = ref_rows[0] + + # Registry geometry scoping: chronological boundary only. + post_set = set(surface.post_periods) + sorted_labels = sorted(_all_labels) + n_post = len(post_set) + is_suffix = set(sorted_labels[-n_post:]) == post_set + pre_labels = [p for p in sorted_labels if p not in post_set] + ref_is_last_pre = bool(pre_labels) and pre_labels[-1] == ref_period + if not (is_suffix and ref_is_last_pre): + raise ValueError( + "HonestDiD requires Registry-valid chronological geometry on " + "a calendar-scale container: post_periods must be the suffix " + "of the sorted period grid and the reference must be the last " + f"pre-period (got post_periods={sorted(post_set)}, " + f"reference={ref_period!r}, periods={sorted_labels}). The " + "Rambachan-Roth restrictions are built positionally over " + "consecutive differences around a single pre/post boundary; " + "a non-chronological declared partition is not expressible in " + "that system (see the REGISTRY HonestDiD Note and the " + "DEFERRED.md transform-or-reject row)." + ) + + # String calendar labels: chronology is unverifiable, and every + # ordering step here (and in the FIT that produced a first-party + # surface - the estimator sorts its calendar the same way) assumes + # sorted() order. The Rambachan-Roth restrictions are positional over + # consecutive periods, so a true chronology that differs from lexical + # sorting (unpadded numeric suffixes: 'c10' sorts before 'c2') would + # silently shift the l_vec sensitivity target - warn loudly (the + # pretrends string-label degradation convention; stacklevel=5 as for + # the vcov-less warning below). + if any(isinstance(t, str) for t in _all_labels): + warnings.warn( + "The event-study container carries STRING calendar labels; " + "chronological order cannot be verified and is assumed to be " + "sorted() order (the same assumption the estimator made at " + "fit time). The Rambachan-Roth restrictions are positional " + "over consecutive periods, so a chronology that differs from " + "lexical sorting (e.g. unpadded numeric suffixes, where " + "'c10' sorts before 'c2') silently shifts the sensitivity " + "target. Use numeric, Period, or Timestamp calendar labels " + "to make the order verifiable.", + UserWarning, + stacklevel=5, + ) + + # Native-branch reconstruction. Row filter mirrors the MPD 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). + _by_label = {t: i for i, t in enumerate(_all_labels)} + keep_mask = ( + (~surface.is_reference) + & np.isfinite(surface.att) + & np.isfinite(surface.se) + & (surface.se > 0) + ) + finite_labels = {t for t, k in zip(_all_labels, keep_mask) if k} + + declared_pre = [p for p in sorted_labels if p not in post_set] # incl. reference + declared_post = [p for p in sorted_labels if p in post_set] + pre_estimated = [p for p in declared_pre if p in finite_labels] + post_estimated = [p for p in declared_post if p in finite_labels] + + # Consecutive estimated horizons around the reference (the native + # branch's positional-geometry guard, verbatim semantics: the + # estimable grid is every non-reference row). + _pre_grid = [p for p in declared_pre if p != ref_period] + _post_grid = declared_post + _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_labels] + _dropped_post = [p for p in _post_grid if p not in finite_labels] + 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." + ) + if len(pre_estimated) == 0: + raise ValueError( + "No pre-period effects with finite estimates found. " + "HonestDiD requires at least one identified pre-period " + "coefficient." + ) + if len(post_estimated) == 0: + raise ValueError( + "No post-period effects with finite estimates found. " + "HonestDiD requires at least one identified post-treatment " + "coefficient (the sensitivity target)." + ) + + beta_hat = np.array([float(surface.att[_by_label[t]]) for t in all_estimated]) + ses = [float(surface.se[_by_label[t]]) for t in all_estimated] + + # Event-study vcov sub-block via the container's explicit index + # (mirrors the native interaction_indices lookup), hardened to the + # relative container path's convention above: duplicate or incomplete + # vcov_index fails loud, the extracted block passes + # _validate_vcov_subblock with allow_singular=False (Rambachan-Roth + # inference assumes eigenvalues bounded away from zero), and the + # diagonal approximation is reserved for a vcov-less container, with + # the same warning. + ses_arr = np.asarray(ses, dtype=float) + if surface.vcov is not None and surface.vcov_index is not None: + vcov_labels = list(surface.vcov_index.tolist()) + if len(set(vcov_labels)) != len(vcov_labels): + raise ValueError( + "The event-study container's vcov_index carries duplicate " + f"labels ({vcov_labels}); the covariance sub-block is " + "ambiguous." + ) + missing = [t for t in all_estimated if t not in vcov_labels] + if missing: + raise ValueError( + f"The event-study container's vcov_index is missing " + f"retained horizon(s) {missing}; cannot extract the " + f"covariance sub-block. Available index: {vcov_labels}." + ) + idx = [vcov_labels.index(t) for t in all_estimated] + sigma = _validate_vcov_subblock( + np.asarray(surface.vcov, dtype=float)[np.ix_(idx, idx)], + ses_arr, + "HonestDiD", + allow_singular=False, + ) + else: + # stacklevel=5: one frame deeper than the relative container + # path's identical warning (this helper is dispatched from + # _extract_container_params). + warnings.warn( + "Event-study container carries no full covariance matrix; " + "using a diagonal approximation from the stored standard " + "errors. Cross-event-time covariance is unavailable on this " + "surface.", + UserWarning, + stacklevel=5, + ) + sigma = np.diag(ses_arr**2) + + return ( + beta_hat, + sigma, + len(pre_estimated), + len(post_estimated), + pre_estimated, + post_estimated, + surface.df_survey, + ) + + def _extract_container_params( surface: Any, ) -> Tuple[np.ndarray, np.ndarray, int, int, List[Any], List[Any], Optional[float]]: @@ -600,11 +831,20 @@ def _extract_container_params( """ import warnings + # Calendar-scale surfaces (the TWFE event-study mode, row M-010) route + # into the native-branch reconstruction - the relative-scale arithmetic + # below (anticipation cutoffs, ref-gap literals) never applies to + # calendar labels. + if surface.time_scale == "calendar": + return _extract_calendar_container_params(surface) + if surface.source not in ("CallawaySantAnnaResults", "StackedDiDResults"): raise TypeError( "HonestDiD accepts EventStudyResults containers produced by " - "CallawaySantAnnaResults.aggregate('event_study') or " - "StackedDiDResults.aggregate('event_study') only " + "CallawaySantAnnaResults.aggregate('event_study'), " + "StackedDiDResults.aggregate('event_study'), or the " + "TwoWayFixedEffects event-study mode (calendar-scale " + "surfaces) only " f"(got source={surface.source!r}). For other estimators pass " "the native results object where supported " "(MultiPeriodDiDResults, CallawaySantAnnaResults, or " @@ -1002,6 +1242,12 @@ def _extract_event_study_params( "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 ( @@ -2838,13 +3084,14 @@ class HonestDiD(BaseEstimator): Examples -------- - >>> from diff_diff import MultiPeriodDiD + >>> from diff_diff import TwoWayFixedEffects >>> from diff_diff.honest_did import HonestDiD >>> >>> # Fit event study - >>> mp_did = MultiPeriodDiD() - >>> results = mp_did.fit(data, outcome='y', treatment='treated', - ... time='period', post_periods=[4,5,6,7]) + >>> twfe = TwoWayFixedEffects() + >>> results = twfe.fit(data, outcome='y', treatment='treated', + ... unit='unit', event_study=True, + ... time='period', post_periods=[4,5,6,7]) >>> >>> # Sensitivity analysis with relative magnitudes >>> honest = HonestDiD(method='relative_magnitude', M=1.0) diff --git a/diff_diff/power.py b/diff_diff/power.py index 90655889f..69e1fda28 100644 --- a/diff_diff/power.py +++ b/diff_diff/power.py @@ -308,7 +308,9 @@ def _twfe_fit_kwargs( n_periods: int, treatment_period: int, ) -> Dict[str, Any]: - return dict(outcome="outcome", treatment="treated", time="post", unit="unit") + # post= is the renamed static dummy parameter (row M-082); the DGP's + # "post" column is the 0/1 indicator. + return dict(outcome="outcome", treatment="treated", post="post", unit="unit") def _multiperiod_fit_kwargs( @@ -452,10 +454,11 @@ def _survey_twfe_fit_kwargs( survey_config: SurveyPowerConfig, ) -> Dict[str, Any]: """Fit kwargs for TwoWayFixedEffects with survey design.""" + # post= is the renamed static dummy parameter (row M-082). return dict( outcome="outcome", treatment="ever_treated", - time="post", + post="post", unit="unit", survey_design=survey_config._build_survey_design(), ) diff --git a/diff_diff/prep_dgp.py b/diff_diff/prep_dgp.py index 7dd199f7c..319ae734f 100644 --- a/diff_diff/prep_dgp.py +++ b/diff_diff/prep_dgp.py @@ -998,12 +998,13 @@ def generate_event_study_data( >>> data['event_time'].unique() array([-5, -4, -3, -2, -1, 0, 1, 2, 3, 4]) - Use with MultiPeriodDiD: + Use with the TwoWayFixedEffects event-study mode: - >>> from diff_diff import MultiPeriodDiD - >>> mp_did = MultiPeriodDiD() - >>> results = mp_did.fit(data, outcome='outcome', treatment='treated', - ... time='period', post_periods=[5, 6, 7, 8, 9]) + >>> from diff_diff import TwoWayFixedEffects + >>> twfe = TwoWayFixedEffects() + >>> results = twfe.fit(data, outcome='outcome', treatment='treated', + ... unit='unit', event_study=True, time='period', + ... post_periods=[5, 6, 7, 8, 9]) Notes ----- diff --git a/diff_diff/pretrends.py b/diff_diff/pretrends.py index 99b462e0a..6ad430351 100644 --- a/diff_diff/pretrends.py +++ b/diff_diff/pretrends.py @@ -844,13 +844,14 @@ class PreTrendsPower(BaseEstimator): -------- Basic usage with MultiPeriodDiD results: - >>> from diff_diff import MultiPeriodDiD + >>> from diff_diff import TwoWayFixedEffects >>> from diff_diff.pretrends import PreTrendsPower >>> >>> # Fit event study - >>> mp_did = MultiPeriodDiD() - >>> results = mp_did.fit(data, outcome='y', treatment='treated', - ... time='period', post_periods=[4, 5, 6, 7]) + >>> twfe = TwoWayFixedEffects() + >>> results = twfe.fit(data, outcome='y', treatment='treated', + ... unit='unit', event_study=True, + ... time='period', post_periods=[4, 5, 6, 7]) >>> >>> # Analyze pre-trends power >>> pt = PreTrendsPower(alpha=0.05, power=0.80) @@ -1416,6 +1417,146 @@ def _extract_pre_period_params( "StackedDiDResults.aggregate('event_study')." ) + def _extract_calendar_container_pre_period_params( + self, + surface: Any, + pre_periods: Optional[List[int]] = None, + ) -> 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 + 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 + ``vcov_index``, and - critically - the ``reference_period`` + plumbed through ``_coerce_relative_times_from_reference``, so + Roth's gamma-unit relative times, the string-label degradation + warning, and the REGISTRY gamma-unit contract are reproduced + exactly (the native route is NOT arithmetic-free: it subtracts + labels from the reference). + + Unlike HonestDiD's calendar route, this one is NOT + geometry-scoped: power/MDV consume pre-period coefficients and + ref-relative offsets, which are well-defined for arbitrary + (non-suffix) declared partitions and non-last references. + """ + if surface.source != "TwoWayFixedEffects": + 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." + ) + if surface.post_periods is None: + raise TypeError( + "PreTrendsPower requires the calendar container's " + "post_periods partition provenance (present on " + "producer-built TwoWayFixedEffects event-study surfaces); " + "a calendar surface without it cannot be partitioned into " + "pre/post periods - the partition is not recoverable from " + "event_time positions." + ) + + _all_labels = surface.event_time.tolist() + if len(set(_all_labels)) != len(_all_labels): + raise ValueError( + "The event-study container carries duplicate event_time " + f"labels ({_all_labels}); each horizon must appear " + "exactly once." + ) + + ref_rows = surface.event_time[surface.is_reference].tolist() + if len(ref_rows) != 1: + raise ValueError( + "PreTrendsPower requires exactly one reference row on a " + f"calendar-scale container (got {sorted(ref_rows)}): " + "relative times are computed against the single omitted " + "reference period." + ) + ref = ref_rows[0] + + 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 + # 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] + if len(declared_pre) == 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." + ) + # R9: the finite-EFFECT conjunct guards hand-built containers + # carrying a NaN/Inf coefficient beside a finite SE (producer + # surfaces NaN the whole inference row, so first-party behavior + # is unchanged; the native/relative routes' SE-only filters are + # the TODO.md one-contract alignment row). + eligible_pre = [ + p + for p in declared_pre + if not bool(surface.is_reference[_by_label[p]]) + and np.isfinite(surface.att[_by_label[p]]) + and np.isfinite(surface.se[_by_label[p]]) + and surface.se[_by_label[p]] > 0 + ] + + if pre_periods is not None: + # R8: an explicit selection is VALIDATED, never silently + # filtered or left in caller order (the relative container + # route's contract): unknown labels, the reference row, + # rows with unusable inference, and duplicates all fail + # loud, and the validated selection is arranged in calendar + # chronology before effects/weights/VCV construction - + # positional alternatives (last_period, custom weights) + # must target the chronological grid, not argument order. + requested = list(pre_periods) + if len(set(requested)) != len(requested): + raise ValueError( + f"Requested pre_periods contain duplicate labels " + f"({requested}); the pre-period selection is ambiguous." + ) + missing = [t for t in requested if t not in eligible_pre] + if missing: + raise ValueError( + f"Requested pre_periods {missing} are not eligible " + f"pre-treatment periods on this event-study surface " + f"(eligible: {eligible_pre})." + ) + _req = set(requested) + estimated_pre_periods = [p for p in eligible_pre if p in _req] + else: + estimated_pre_periods = eligible_pre + + 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([float(surface.att[_by_label[p]]) for p in estimated_pre_periods]) + ses = np.array([float(surface.se[_by_label[p]]) for p in estimated_pre_periods]) + + # Hardened extraction shared with the relative container route: + # duplicate or incomplete vcov_index fails loud, the sub-block + # passes _validate_vcov_subblock (PreTrendsPower keeps its + # documented singular handling), and the diagonal fallback is + # reserved for a vcov-less container. + vcov, covariance_source = _extract_container_vcov_subblock( + surface, estimated_pre_periods, ses + ) + + # Gamma-unit plumbing (the native branch's tail, verbatim + # semantics): numeric/Period/Timestamp labels subtract to Roth + # relative offsets; string labels degrade to None with the + # helper's pinned UserWarning (MDV then NOT in gamma units). + relative_times = _coerce_relative_times_from_reference(estimated_pre_periods, ref) + return effects, ses, vcov, n_pre, relative_times, covariance_source + def _extract_container_pre_period_params( self, surface: Any, @@ -1436,12 +1577,20 @@ def _extract_container_pre_period_params( from pre-trend coefficients, and PreTrendsPower has no native dCDH branch either. """ + # Calendar-scale surfaces (the TWFE event-study mode, row M-010) + # route into the native-branch reconstruction - the relative-scale + # arithmetic below (anticipation cutoffs, float(ref) coercion) + # never applies to calendar labels. + if surface.time_scale == "calendar": + return self._extract_calendar_container_pre_period_params(surface, pre_periods) + if surface.source not in ("CallawaySantAnnaResults", "StackedDiDResults"): raise TypeError( "PreTrendsPower accepts EventStudyResults containers " "produced by CallawaySantAnnaResults.aggregate(" - "'event_study') or StackedDiDResults.aggregate(" - "'event_study') only " + "'event_study'), StackedDiDResults.aggregate(" + "'event_study'), or the TwoWayFixedEffects event-study " + "mode (calendar-scale surfaces) only " f"(got source={surface.source!r}). For other estimators " "pass the native results object where supported " "(MultiPeriodDiDResults, CallawaySantAnnaResults, or " @@ -2252,10 +2401,10 @@ def compute_pretrends_power( Examples -------- - >>> from diff_diff import MultiPeriodDiD + >>> from diff_diff import TwoWayFixedEffects >>> from diff_diff.pretrends import compute_pretrends_power >>> - >>> results = MultiPeriodDiD().fit(data, ...) + >>> results = TwoWayFixedEffects().fit(data, ..., event_study=True) >>> power_results = compute_pretrends_power(results, pre_periods=[0, 1, 2, 3]) >>> print(f"MDV: {power_results.mdv:.3f}") >>> print(f"Power: {power_results.power:.1%}") diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index 48523ef32..e116b8589 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -327,6 +327,24 @@ class EventStudyResults(BaseResults): #: numbers never silently change meaning. Optional provenance appended #: last (the M-092 pre-cut amendment convention). 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 + #: 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 ``_from_mpd`` (and 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 + #: must be recoverable from the container. None from every builder + #: (only the TwoWayFixedEffects event-study producer sets it). + estimation_spec: Optional[str] = None _ARRAY_FIELDS = ( "att", @@ -442,6 +460,46 @@ def __post_init__(self) -> None: f"EventStudyResults n_kind {self.n_kind!r} is not in the shared " f"vocabulary {N_KIND_VOCABULARY}." ) + + # post_periods is the AUTHORITATIVE calendar partition consumers + # classify by, so malformed-but-present content is a contract break + # (the same fail-closed posture as n_kind / the vcov pairing): a + # hand-built surface must never silently mispartition a consumer. + if self.post_periods is not None: + pp = tuple(self.post_periods) + if len(pp) == 0: + raise ValueError( + "EventStudyResults post_periods must be a nonempty tuple " + "when provided (None means 'no partition recorded')." + ) + if len(set(pp)) != len(pp): + raise ValueError(f"EventStudyResults post_periods contains duplicates: {pp}.") + event_set = set(self.event_time.tolist()) + missing = [p for p in pp if p not in event_set] + if missing: + raise ValueError( + f"EventStudyResults post_periods entries {missing} are not " f"in event_time." + ) + ref_set = set(self.event_time[self.is_reference].tolist()) + overlap = [p for p in pp if p in ref_set] + if overlap: + raise ValueError( + f"EventStudyResults post_periods entries {overlap} are " + f"reference rows; the reference period is pre-treatment " + f"by construction." + ) + self.post_periods = pp + + # estimation_spec is a two-value design label (TWFE event-study + # producer only); off-vocabulary values are rejected like n_kind. + if self.estimation_spec is not None and self.estimation_spec not in ( + "within", + "pooled", + ): + raise ValueError( + f"EventStudyResults estimation_spec {self.estimation_spec!r} " + f"is not in ('within', 'pooled')." + ) self.df = df_arr if (self.vcov is None) != (self.vcov_index is None): @@ -557,6 +615,15 @@ def to_dict(self) -> Dict[str, Any]: "cband_crit_value": self.cband_crit_value, "alpha": self.alpha, "source": self.source, + # Calendar-partition + design provenance (M-092 amendment #5): + # post_periods labels JSON-safed like event_time (they carry the + # same Timestamp/Period label types on calendar surfaces). + "post_periods": ( + [_json_safe_label(p) for p in self.post_periods] + if self.post_periods is not None + else None + ), + "estimation_spec": self.estimation_spec, "df": cast(np.ndarray, self.df).tolist(), "base_period": self.base_period, "anticipation": self.anticipation, @@ -1039,6 +1106,13 @@ def _from_mpd(results: Any) -> EventStudyResults: if isinstance(df_map, dict): df_arg = np.array([float(df_map[p]) if p in df_map else np.nan for p in all_periods]) + # Calendar-partition provenance (M-092 amendment #5): the producer's + # AUTHORITATIVE post_periods list - an arbitrary subset is legal on + # MultiPeriodDiD, so consumers must never re-derive the partition + # positionally from the reference row. + _mpd_post = getattr(results, "post_periods", None) + post_periods_arg: Optional[Tuple[Any, ...]] = tuple(_mpd_post) if _mpd_post else None + return EventStudyResults( event_time=np.asarray(all_periods), att=att, @@ -1058,6 +1132,7 @@ def _from_mpd(results: Any) -> EventStudyResults: alpha=getattr(results, "alpha", 0.05), source=type(results).__name__, df=df_arg, + post_periods=post_periods_arg, **_provenance_kwargs(results), ) diff --git a/diff_diff/twfe.py b/diff_diff/twfe.py index 96be91c9d..c88632be7 100644 --- a/diff_diff/twfe.py +++ b/diff_diff/twfe.py @@ -2,8 +2,9 @@ Two-Way Fixed Effects estimator for panel Difference-in-Differences. """ +import dataclasses import warnings -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional, Union import numpy as np import pandas as pd @@ -12,9 +13,11 @@ 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.estimators import DifferenceInDifferences from diff_diff.linalg import LinearRegression from diff_diff.results import DiDResults +from diff_diff.results_base import EventStudyResults, _from_mpd from diff_diff.utils import ( absorbed_fe_cr1_k_increment, absorbed_fe_rank, @@ -80,6 +83,36 @@ class TwoWayFixedEffects(DifferenceInDifferences): where α_i are unit fixed effects and γ_t are time fixed effects. + **Event-study mode (3.9).** ``fit(..., event_study=True, + 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``). + ``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 + 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 + errors differ in general. Both specs assume SIMULTANEOUS adoption + (all treated units adopt at the same time). The staggered-adoption + advisory can only detect timing from within-unit 0-to-1 transitions + (a time-varying ``D_it`` column, which itself draws a warning): with + the documented time-invariant ever-treated ``D_i`` indicator, + adoption timing is not observable in the inputs at all, so the + 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 + 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 + static path - dropped on Conley, never injected as a survey PSU, + dropped for explicit one-way analytical families), and + ``inference="wild_bootstrap"`` raises (the wild cluster bootstrap + covers the static ATT only). See + ``docs/methodology/REGISTRY.md`` "Event-study mode". + **HC2 / Bell-McCaffrey are supported via an internal full-dummy build.** Because TWFE's within-transformation preserves coefficients but not the hat matrix, HC2 leverage and CR2 Bell-McCaffrey corrections on the @@ -137,13 +170,18 @@ def fit( # type: ignore[override] data: pd.DataFrame, outcome: str, treatment: str, - time: str, - unit: str, + post: Any = NOT_SUPPLIED, + unit: Any = NOT_SUPPLIED, covariates: Optional[List[str]] = None, survey_design: Optional["SurveyDesign"] = None, - ) -> DiDResults: + event_study: bool = False, + spec: str = "within", + reference_period: Any = None, + post_periods: Optional[List[Any]] = None, + time: Any = NOT_SUPPLIED, + ) -> Union[DiDResults, EventStudyResults]: """ - Fit Two-Way Fixed Effects model. + Fit Two-Way Fixed Effects model (static ATT or event-study mode). Parameters ---------- @@ -153,10 +191,15 @@ def fit( # type: ignore[override] Name of outcome variable column. treatment : str Name of treatment indicator column. - time : str - Name of time period 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). unit : str - Name of unit identifier column. + Name of unit identifier column. Required in static mode and for + ``spec="within"``; optional for ``spec="pooled"`` (the only + event-study spec that works without a unit id - repeated + cross-sections). covariates : list, optional List of covariate column names. Names must not collide with reserved structural terms (``const``, ``ATT``, unit/time fixed-effect dummy @@ -168,22 +211,113 @@ def fit( # type: ignore[override] Survey design specification for design-based inference. When provided, uses Taylor Series Linearization for variance estimation and 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 + :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 + 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 + case (balanced panel, no covariates, simultaneous adoption); + see the class Notes. + reference_period : any, optional + Event-study mode: the omitted (reference) period. Defaults to + the last pre-treatment period (e=-1 convention). + post_periods : list + Event-study mode: the post-treatment period values. REQUIRED + (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) + 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. Returns ------- - DiDResults - Estimation results. + DiDResults or EventStudyResults + Static estimation results, or the unified event-study surface + when ``event_study=True``. Raises ------ ValueError If a covariate name collides with a reserved structural term name - or duplicates another covariate. + or duplicates another covariate; if event-study-only parameters + are passed with ``event_study=False``; 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). """ # Per-fit bootstrap state: cleared up front so the result builder # labels inference from THIS fit only (see the matching reset in # DifferenceInDifferences.fit). self._bootstrap_results = None + + # --- Mode routing (rows M-010 / M-082; v4-design section 4.1) --- + # The spec value set is mode-independent: event_study=False with + # spec="bogus" must not pass silently while "pooled" is rejected. + if spec not in ("within", "pooled"): + raise ValueError(f"spec must be one of ('within', 'pooled'), got {spec!r}") + 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). + return self._fit_event_study( + data, + outcome, + treatment, + post=post, + unit=unit, + covariates=covariates, + survey_design=survey_design, + spec=spec, + reference_period=reference_period, + post_periods=post_periods, + time=time, + ) + # Static/ES param coherence: event-study-only parameters are + # rejected in static mode rather than silently ignored. The + # spec="within"-in-static corner is indistinguishable from the + # default and passes silently (documented). + _es_only = [] + if spec != "within": + _es_only.append("spec") + if reference_period is not None: + _es_only.append("reference_period") + if post_periods is not None: + _es_only.append("post_periods") + if _es_only: + raise ValueError( + f"{', '.join(_es_only)} require(s) event_study=True; static " + f"TwoWayFixedEffects estimates a single ATT from the 0/1 " + 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.", + ) + require_arg("TwoWayFixedEffects.fit", "post", post) + require_arg("TwoWayFixedEffects.fit", "unit", unit) + # Body-local name; the public static parameter is post (M-082). + time = post + # Validate unit column exists if unit not in data.columns: raise ValueError(f"Unit column '{unit}' not found in data") @@ -862,6 +996,218 @@ def _refit_twfe(w_r): self.is_fitted_ = True return self.results_ + def _fit_event_study( + self, + data: pd.DataFrame, + outcome: str, + treatment: str, + *, + post: Any, + unit: Any, + covariates: Optional[List[str]], + survey_design: Optional["SurveyDesign"], + spec: str, + reference_period: Any, + post_periods: Optional[List[Any]], + time: Any, + ) -> EventStudyResults: + """TWFE event-study mode (row M-010; docs/v4-design.md section 4.1). + + Runs the shared event-study core + (:meth:`DifferenceInDifferences._fit_event_study_core`) under one of + two designs - ``spec="pooled"`` is the MultiPeriodDiD 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. + + Inference carries TWFE's stack: the unit auto-cluster applies from + day one (user decision 2026-08-07) WITH static TWFE's carve-outs + mirrored lane-for-lane - the auto-cluster is dropped on the Conley + path (an implicit spatial x unit product kernel would zero every + 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 + cluster on every lane. Wild bootstrap raises: 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). + """ + # Step 0: sentinel normalization. The core's unit guard treats any + # non-None value as a column name, so NOT_SUPPLIED must never + # reach it. + unit_resolved: Optional[str] = None if unit is NOT_SUPPLIED else unit + + # 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). + if self.inference == "wild_bootstrap": + raise ValueError( + "inference='wild_bootstrap' is not supported in event-study " + "mode: the wild cluster bootstrap covers the static ATT " + "only. Use inference='analytical' for event-study fits." + ) + + # Step 4: within requires a unit id (the FE being absorbed). + if spec == "within" and unit_resolved is None: + raise ValueError( + "spec='within' requires unit=; the unit fixed effects are " + "absorbed at the unit level. spec='pooled' is the only " + "event-study spec that works without a unit id (repeated " + "cross-sections)." + ) + + # Step 5: the calendar column arrives as time= (keyword); the + # static 0/1 dummy post= is rejected rather than silently ignored. + # A positional 4th argument lands in post and is caught here. + if post is not NOT_SUPPLIED: + raise ValueError( + "event-study mode takes time= (calendar column) as a " + "keyword; post= is the static-mode 0/1 dummy. Pass " + "fit(..., event_study=True, time='')." + ) + require_arg("TwoWayFixedEffects.fit", "time", time) + + # Step 5.5: the calendar partition is REQUIRED. The treatment + # 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 + # silent guess and is deliberately not carried into the merged + # mode (mirrors the day-one wild raise: no legacy defaults on the + # new surface). + if post_periods is not None: + # materialize ONCE (R8): a one-shot iterable would otherwise be + # exhausted by the emptiness check before reaching the core + post_periods = list(post_periods) + if post_periods is None or len(post_periods) == 0: + raise ValueError( + "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." + ) + if len(set(post_periods)) != len(post_periods): + # R10: fail at the front door - the container's own + # duplicate validation would otherwise reject only AFTER the + # full regression has run. + raise ValueError( + f"post_periods contains duplicate labels ({post_periods}); " + "the calendar partition is ambiguous." + ) + + # Step 6: auto-cluster resolution (user decision 2026-08-07), with + # the three static-mirror carve-outs. Explicit cluster= wins and + # passes through on every lane; when survey_design carries its own + # PSU the shared resolver still gives the PSU precedence (identical + # on every class). + if self.cluster is not None: + cluster_override: Optional[str] = self.cluster + elif self.vcov_type == "conley": + # Static mirror: the implicit unit auto-cluster is silently + # dropped on the Conley path - combining Conley with unit-level + # clusters would zero out all between-unit pairs and defeat the + # spatial pooling. Only explicit cluster= combines. + cluster_override = None + elif survey_design is not None: + # Static mirror (the documented implicit-per-observation-PSU + # rule): the auto-cluster is never injected as a survey PSU; + # only user-explicit cluster= becomes one. + cluster_override = None + elif self.vcov_type in ("classical", "hc2") and self._vcov_type_explicit: + # The explicit one-way analytical exception (mirrors the static + # cluster_var block; inference is always analytical here - wild + # raised above). + cluster_override = None + else: + 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 + # 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. + if spec == "within": + # Step 4 guarantees a unit id on the within spec. + assert unit_resolved is not None + absorb_arg: Optional[List[str]] = [unit_resolved] + if ( + self.vcov_type in ("hc2", "hc2_bm") + and unit_resolved in data.columns + and time in data.columns + ): + # Memory guard, mirroring the static full-dummy preflight: + # the core's absorb->fixed_effects auto-route for HC2/HC2-BM + # materializes a dense design of intercept + period dummies + # + per-period interactions + covariates + unit dummies. + # Same ~50M float64 entry threshold (~400 MB) as static. + # Column-presence guard (R7): a missing unit/time column + # falls through to the core's own validation error instead + # of a raw KeyError from this size estimate. + _n_units = int(data[unit_resolved].nunique()) + _n_periods = int(data[time].nunique()) + _design_cols = ( + 1 + 2 * max(0, _n_periods - 1) + len(covariates or []) + max(0, _n_units - 1) + ) + _design_entries = len(data) * _design_cols + if _design_entries > 50_000_000: + warnings.warn( + f"TwoWayFixedEffects(vcov_type={self.vcov_type!r}) in " + f"event-study mode builds a dense {len(data)} x " + f"{_design_cols} full-dummy design " + f"(~{_design_entries / 1e6:.1f}M float64 entries, " + f"~{_design_entries * 8 / 1e9:.2f} GB) for the " + "leverage-corrected HC2/HC2-BM path. For panels with " + "many units, consider vcov_type='hc1' (absorbed " + "within-transform path; no leverage term, lower " + "memory) unless small-sample HC2/HC2-BM inference is " + "required.", + UserWarning, + stacklevel=3, + ) + else: + absorb_arg = None + results_internal, _, _ = self._fit_event_study_core( + data, + outcome, + treatment, + time, + post_periods, + covariates, + None, # fixed_effects: not offered in event-study mode + absorb_arg, + reference_period, + unit_resolved, + 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: convert to the unified surface. The internal + # MultiPeriodDiDResults is a construction detail (the 4.0 removal + # PR decouples it - ledger row M-011); the surface carries the + # TWFE provenance: source, the authoritative calendar partition + # (threaded by _from_mpd), and the design spec. The legacy + # writer-only _coefficients/_vcov attrs are deliberately left + # untouched (static TWFE has never set them; nothing reads them). + # dataclasses.replace re-runs __post_init__, so the final provenance + # (source + estimation_spec) passes the container's own validation + # atomically instead of being patched on after construction. + surface = dataclasses.replace( + _from_mpd(results_internal), + source="TwoWayFixedEffects", + estimation_spec=spec, + ) + self.results_ = surface + self.is_fitted_ = True + return surface + def _check_staggered_treatment( self, data: pd.DataFrame, diff --git a/diff_diff/utils.py b/diff_diff/utils.py index 60652494a..f1d5ce9d0 100644 --- a/diff_diff/utils.py +++ b/diff_diff/utils.py @@ -3671,6 +3671,7 @@ def snap_absorbed_regressors( rel_tol: float = 1e-10, weights: Optional[np.ndarray] = None, screen_tol: float = 1e-3, + stacklevel: int = 3, ) -> List[str]: """Zero out regressors that were absorbed (spanned) by the fixed effects. @@ -3780,7 +3781,7 @@ def snap_absorbed_regressors( "so their coefficients are not identified and will be reported " "as NaN.", UserWarning, - stacklevel=3, + stacklevel=stacklevel, ) return snapped diff --git a/diff_diff/visualization/_diagnostic.py b/diff_diff/visualization/_diagnostic.py index 953521793..ef2dd05b2 100644 --- a/diff_diff/visualization/_diagnostic.py +++ b/diff_diff/visualization/_diagnostic.py @@ -79,12 +79,14 @@ def plot_sensitivity( Examples -------- - >>> from diff_diff import MultiPeriodDiD + >>> from diff_diff import TwoWayFixedEffects >>> from diff_diff.honest_did import HonestDiD >>> from diff_diff.visualization import plot_sensitivity >>> >>> # Fit event study and run sensitivity analysis - >>> results = MultiPeriodDiD().fit(data, ...) + >>> results = TwoWayFixedEffects().fit( + ... data, ..., event_study=True, post_periods=[3, 4, 5] + ... ) >>> honest = HonestDiD(method='relative_magnitude') >>> sensitivity = honest.sensitivity_analysis(results) >>> diff --git a/diff_diff/visualization/_event_study.py b/diff_diff/visualization/_event_study.py index 9beec3fdf..0d605de19 100644 --- a/diff_diff/visualization/_event_study.py +++ b/diff_diff/visualization/_event_study.py @@ -145,12 +145,13 @@ def plot_event_study( Examples -------- - Using with MultiPeriodDiD results: + Using with TwoWayFixedEffects event-study results: - >>> from diff_diff import MultiPeriodDiD, plot_event_study - >>> did = MultiPeriodDiD() - >>> results = did.fit(data, outcome='y', treatment='treated', - ... time='period', post_periods=[3, 4, 5]) + >>> from diff_diff import TwoWayFixedEffects, plot_event_study + >>> twfe = TwoWayFixedEffects() + >>> results = twfe.fit(data, outcome='y', treatment='treated', + ... unit='unit', event_study=True, + ... time='period', post_periods=[3, 4, 5]) >>> plot_event_study(results) Using with a DataFrame: @@ -365,6 +366,25 @@ def plot_event_study( ) +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 + 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. + """ + runs: List[Tuple[int, int]] = [] + for x in sorted(pre_x): + if runs and x == runs[-1][1] + 1: + runs[-1] = (runs[-1][0], x) + else: + runs.append((x, x)) + return runs + + def _render_event_study_mpl( df, *, @@ -401,11 +421,13 @@ def _render_event_study_mpl( period_to_x = {p: i for i, p in enumerate(df["period"])} x_vals = [period_to_x[p] for p in df["period"]] - # Shade pre-treatment region + # Shade pre-treatment region - one span per CONTIGUOUS run of pre + # positions (a single min-to-max span would shade interleaved post + # periods as pre on non-suffix partitions). if shade_pre and pre_periods is not None: pre_x = [period_to_x[p] for p in pre_periods if p in period_to_x] - if pre_x: - ax.axvspan(min(pre_x) - 0.5, max(pre_x) + 0.5, color=shade_color, alpha=0.5, zorder=0) + for run_start, run_end in _pre_shading_runs(pre_x): + ax.axvspan(run_start - 0.5, run_end + 0.5, color=shade_color, alpha=0.5, zorder=0) # Draw horizontal zero line if show_zero_line: @@ -520,13 +542,16 @@ def _render_event_study_plotly( x_vals = list(range(len(periods))) tick_labels = [str(p) for p in periods] - # Shade pre-treatment region + # Shade pre-treatment region - one vrect per CONTIGUOUS run of pre + # positions (mirrors the matplotlib renderer; a single min-to-max + # vrect would shade interleaved post periods as pre on non-suffix + # partitions). if shade_pre and pre_periods is not None: pre_x = [period_to_x[p] for p in pre_periods if p in period_to_x] - if pre_x: + for run_start, run_end in _pre_shading_runs(pre_x): fig.add_vrect( - x0=min(pre_x) - 0.5, - x1=max(pre_x) + 0.5, + x0=run_start - 0.5, + x1=run_end + 0.5, fillcolor=_color_to_rgba(shade_color, 0.5), line_width=0, layer="below", @@ -762,11 +787,29 @@ def _extract_plot_data( _post_start = -int(surface.anticipation or 0) derived_pre = [p for p in periods if p < _post_start] derived_post = [p for p in periods if p >= _post_start] + elif surface.post_periods is not None: + # Calendar labels with the AUTHORITATIVE partition + # provenance (row M-092 amendment #5, threaded by + # _from_mpd / the TWFE event-study producer): the declared + # post set may be an arbitrary subset (non-suffix splits + # are legal on MultiPeriodDiD), so the positional + # fallback below would mislabel it - always prefer the + # provenance. Pre = non-reference complement. + _post_set = set(surface.post_periods) + derived_post = [p for p in periods if p in _post_set] + derived_pre = [ + p + for p in periods + if p not in _post_set + and p != reference_period + and (reference_marks is None or p not in reference_marks) + ] else: - # Calendar labels (possibly str/Timestamp): numeric `p < 0` - # is undefined - split POSITIONALLY around the reference - # row (rows before it in event_time order are pre); with - # no reference row, all rows are post. + # Calendar labels (possibly str/Timestamp) WITHOUT + # partition provenance: numeric `p < 0` is undefined - + # split POSITIONALLY around the reference row (rows before + # it in event_time order are pre); with no reference row, + # all rows are post. if reference_period is not None and reference_period in keys: ref_pos = keys.index(reference_period) derived_pre = [p for p in periods if p in keys and keys.index(p) < ref_pos] diff --git a/diff_diff/visualization/_power.py b/diff_diff/visualization/_power.py index 141352d96..0d24e0ea7 100644 --- a/diff_diff/visualization/_power.py +++ b/diff_diff/visualization/_power.py @@ -424,13 +424,14 @@ def plot_pretrends_power( -------- From PreTrendsPower results: - >>> from diff_diff import MultiPeriodDiD + >>> from diff_diff import TwoWayFixedEffects >>> from diff_diff.pretrends import PreTrendsPower >>> from diff_diff.visualization import plot_pretrends_power >>> - >>> mp_did = MultiPeriodDiD() - >>> event_results = mp_did.fit(data, outcome='y', treatment='treated', - ... time='period', post_periods=[4, 5, 6, 7]) + >>> twfe = TwoWayFixedEffects() + >>> event_results = twfe.fit(data, outcome='y', treatment='treated', + ... unit='unit', event_study=True, + ... time='period', post_periods=[4, 5, 6, 7]) >>> >>> pt = PreTrendsPower() >>> curve = pt.power_curve(event_results) diff --git a/docs/api/_autosummary/diff_diff.EventStudyResults.rst b/docs/api/_autosummary/diff_diff.EventStudyResults.rst index e0636f515..5390370cb 100644 --- a/docs/api/_autosummary/diff_diff.EventStudyResults.rst +++ b/docs/api/_autosummary/diff_diff.EventStudyResults.rst @@ -32,8 +32,10 @@ ~EventStudyResults.df ~EventStudyResults.df_survey ~EventStudyResults.estimand + ~EventStudyResults.estimation_spec ~EventStudyResults.event_time_convention ~EventStudyResults.n_kind + ~EventStudyResults.post_periods ~EventStudyResults.reference_event_times ~EventStudyResults.reference_period ~EventStudyResults.reference_periods diff --git a/docs/api/estimators.rst b/docs/api/estimators.rst index b90cda0c2..c8ee98d3c 100644 --- a/docs/api/estimators.rst +++ b/docs/api/estimators.rst @@ -59,6 +59,9 @@ 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: diff --git a/docs/api/honest_did.rst b/docs/api/honest_did.rst index a02b148f5..211c50b29 100644 --- a/docs/api/honest_did.rst +++ b/docs/api/honest_did.rst @@ -46,13 +46,14 @@ Example .. code-block:: python - from diff_diff import MultiPeriodDiD, HonestDiD + from diff_diff import TwoWayFixedEffects, HonestDiD - # First fit an event study - model = MultiPeriodDiD() + # First fit an event study (TwoWayFixedEffects event-study mode; + # HonestDiD also accepts the surface directly) + model = TwoWayFixedEffects() results = model.fit(data, outcome='y', treatment='treated', - time='period', unit='unit_id', - post_periods=[5, 6, 7], reference_period=4) + unit='unit_id', event_study=True, time='period', + post_periods=[5, 6, 7, 8, 9], reference_period=4) # Compute bounds under relative magnitudes restriction honest = HonestDiD(method='relative_magnitude', M=1.0) @@ -143,17 +144,17 @@ Complete Example import numpy as np from diff_diff import ( - MultiPeriodDiD, + TwoWayFixedEffects, HonestDiD, plot_sensitivity, plot_honest_event_study, ) - # Fit event study - model = MultiPeriodDiD() + # Fit event study (TwoWayFixedEffects event-study mode) + model = TwoWayFixedEffects() results = model.fit(data, outcome='y', treatment='treated', - time='period', unit='unit_id', - post_periods=[5, 6, 7], reference_period=4) + unit='unit_id', event_study=True, time='period', + post_periods=[5, 6, 7, 8, 9], reference_period=4) # Sensitivity analysis under relative magnitudes honest_rm = HonestDiD(method='relative_magnitude', M=1.0) diff --git a/docs/api/pretrends.rst b/docs/api/pretrends.rst index 0407e7b6a..ee2e0b8b6 100644 --- a/docs/api/pretrends.rst +++ b/docs/api/pretrends.rst @@ -47,13 +47,13 @@ Example .. code-block:: python - from diff_diff import MultiPeriodDiD, PreTrendsPower + from diff_diff import TwoWayFixedEffects, PreTrendsPower - # First fit an event study - model = MultiPeriodDiD() + # First fit an event study (TwoWayFixedEffects event-study mode) + model = TwoWayFixedEffects() results = model.fit(data, outcome='y', treatment='treated', - time='period', unit='unit_id', - post_periods=[5, 6, 7], reference_period=4) + unit='unit_id', event_study=True, time='period', + post_periods=[5, 6, 7, 8, 9], reference_period=4) # Compute pre-trends power for linear violations. # Default acceptance region is the Roth (2022) NIS box probability. @@ -150,17 +150,17 @@ Complete Example import numpy as np from diff_diff import ( - MultiPeriodDiD, + TwoWayFixedEffects, PreTrendsPower, compute_mdv, plot_pretrends_power, ) - # Fit event study - model = MultiPeriodDiD() + # Fit event study (TwoWayFixedEffects event-study mode) + model = TwoWayFixedEffects() results = model.fit(data, outcome='y', treatment='treated', - time='period', unit='unit_id', - post_periods=[5, 6, 7], reference_period=4) + unit='unit_id', event_study=True, time='period', + post_periods=[5, 6, 7, 8, 9], reference_period=4) # Compute MDV mdv = compute_mdv(results, alpha=0.05, target_power=0.80) diff --git a/docs/api/visualization.rst b/docs/api/visualization.rst index 09ede641f..b143d2d20 100644 --- a/docs/api/visualization.rst +++ b/docs/api/visualization.rst @@ -17,12 +17,13 @@ Example .. code-block:: python - from diff_diff import MultiPeriodDiD, plot_event_study + from diff_diff import TwoWayFixedEffects, plot_event_study - # Fit event study model - model = MultiPeriodDiD() + # Fit an event study (TwoWayFixedEffects event-study mode) + model = TwoWayFixedEffects() results = model.fit(data, outcome='y', treatment='treated', - time='period', unit='unit_id', reference_period=2) + unit='unit_id', event_study=True, time='period', + post_periods=[3, 4, 5], reference_period=2) # Create plot ax = plot_event_study(results) diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 51ffd18e1..10d9349ff 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -45,7 +45,9 @@ Start here and follow the questions: 5. **Do you need period-specific effects?** (Event study design) - **No** → Use :class:`~diff_diff.TwoWayFixedEffects` - - **Yes** → Use :class:`~diff_diff.MultiPeriodDiD` + - **Yes** → Use :class:`~diff_diff.TwoWayFixedEffects` with + ``event_study=True`` (``MultiPeriodDiD`` is deprecated in 3.9; + ``spec="pooled"`` reproduces its design) 6. **Is your treated group small?** (Few treated units, many controls) @@ -70,7 +72,7 @@ Quick Reference - Panel data, simultaneous treatment - Parallel trends (all periods) - Single ATT with unit/time FE - * - ``MultiPeriodDiD`` + * - ``MultiPeriodDiD`` (deprecated 3.9 → ``TwoWayFixedEffects`` ``event_study=True``) - Event studies, dynamic effects - Parallel trends (pre-periods) - Period-specific effects @@ -188,25 +190,32 @@ Use :class:`~diff_diff.TwoWayFixedEffects` when: twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome='y', treatment='treated', - unit='unit_id', time='period') + unit='unit_id', post='post') Multi-Period Event Study ~~~~~~~~~~~~~~~~~~~~~~~~ -Use :class:`~diff_diff.MultiPeriodDiD` when: +Use :class:`~diff_diff.TwoWayFixedEffects` with ``event_study=True`` when: - You want a full event-study with pre and post treatment effects - You need pre-period coefficients to assess parallel trends - You want to visualize treatment effect dynamics over time - 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. + .. code-block:: python - from diff_diff import MultiPeriodDiD, plot_event_study + from diff_diff import TwoWayFixedEffects, plot_event_study - event = MultiPeriodDiD() + event = TwoWayFixedEffects() results = event.fit(data, outcome='y', treatment='treated', - time='period', unit='unit_id', reference_period=2) + unit='unit_id', event_study=True, + time='period', post_periods=[3, 4, 5], + reference_period=2) # Visualize plot_event_study(results) @@ -687,8 +696,8 @@ differences helps interpret results and choose appropriate inference. - Uses White's robust SEs by default. Specify ``cluster`` for cluster-robust SEs. Use ``inference='wild_bootstrap'`` (with ``cluster=`` — required) for few clusters (<50). * - ``TwoWayFixedEffects`` - Cluster-robust (unit level) - - Always clusters at unit level after within-transformation. Specify ``cluster`` to override. Use ``inference='wild_bootstrap'`` for few clusters. - * - ``MultiPeriodDiD`` + - 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`` diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index 5cefa35e2..2165fda66 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -143,6 +143,21 @@ sources: type: methodology - path: docs/api/estimators.rst type: api_reference + - path: docs/quickstart.rst + type: user_guide + note: "TWFE static example (post= dummy) + the Event Study Design section (event_study=True); both track the fit signature (rows M-010/M-082)" + - path: docs/choosing_estimator.rst + type: user_guide + note: "Decision-tree question 5, Quick Reference + SE tables, and the Multi-Period Event Study section all teach the event-study mode (rows M-010/M-082)" + - path: docs/troubleshooting.rst + type: user_guide + note: "Static TWFE example uses post= (row M-082)" + - path: diff_diff/guides/llms.txt + type: user_guide + note: "TWFE catalog line documents event_study=True (row M-010)" + - path: diff_diff/guides/llms-full.txt + type: user_guide + note: "TWFE block documents the full fit signature incl. event-study params; the Conley section's panel examples use post=/event_study (rows M-010/M-082)" # ── CallawaySantAnna (staggered group) ─────��──────────────────────── diff --git a/docs/index.rst b/docs/index.rst index 2b19046c9..b7c81ffa5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -138,7 +138,7 @@ Supported Estimators * - :class:`~diff_diff.TwoWayFixedEffects` - Panel data with unit and time fixed effects * - :class:`~diff_diff.MultiPeriodDiD` - - Event study with period-specific treatment effects + - Event study with period-specific treatment effects (deprecated 3.9 — use :class:`~diff_diff.TwoWayFixedEffects` ``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 c516f54ee..d7d914be0 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -177,6 +177,16 @@ 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. + **Primary source:** Event study methodology - Freyaldenhoven, S., Hansen, C., Pérez, J.P., & Shapiro, J.M. (2021). Visualization, identification, and estimation in the linear panel event-study design. NBER Working Paper 29170. @@ -348,6 +358,15 @@ where V is the VCV sub-matrix for post-treatment δ_e coefficients. within-unit treatment variation is detected. Advises creating an ever-treated indicator. Without ever-treated D_i, pre-period interaction coefficients are unidentified. +- **Note (staggered detection requires D_it input):** the staggered-adoption + and treatment-reversal checks derive adoption timing from within-unit 0→1 + transitions, so they can only fire on time-varying `D_it` input (itself + off-contract per the bullet above). With the contract-valid time-invariant + `D_i`, adoption timing is not observable in the inputs and the + simultaneous-adoption scope is a user-asserted design assumption - the + estimator cannot verify it. See the TwoWayFixedEffects event-study-mode + Note (staggered-adoption detection limit); the cohort-timing validation + input that would close this is tracked in TODO.md. - Pre-test of parallel trends: joint F-test on pre-treatment δ_e coefficients. Low power in pre-test does not validate parallel trends (Roth 2022). @@ -375,6 +394,69 @@ where V is the VCV sub-matrix for post-treatment δ_e coefficients. **Primary source:** Panel data econometrics - Wooldridge, J.M. (2010). *Econometric Analysis of Cross Section and Panel Data*, 2nd ed. MIT Press, Chapter 10. +### Event-study mode (3.9, ledger row M-010) + +`fit(..., event_study=True, time=, spec="within"|"pooled", +reference_period=None, post_periods=)` estimates per-period +treatment effects and returns the unified `EventStudyResults` surface (spec section 5 +of `docs/v4-design.md`; `source="TwoWayFixedEffects"`, `estimation_spec` and +the authoritative `post_periods` partition recorded as provenance). The +static 0/1-dummy contract below is unchanged; in event-study mode `time=` is +the CALENDAR column (keyword), and the static dummy parameter is `post=` +(row M-082; `time=` remains its deprecated alias through 3.9). + +*Designs:* +``` +spec="within": Y_it = α_i + γ_t + Σ_{e≠ref} δ_e (D_i × 1[t=e]) + X'β + ε_it +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 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). + +- **Note (staggered-adoption detection limit):** both specs assume + simultaneous adoption (the MultiPeriodDiD 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 + warning already flags as off-contract. Under the documented time-invariant + ever-treated `D_i` contract, adoption timing is not observable in + `(y, D_i, unit, time)` at all, so simultaneous adoption cannot be verified + by the estimator and is asserted by the user (like parallel trends). An + optional cohort-timing validation input (`first_treat=`/`cohort=`) that + 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. + +*Inference:* +- **Note:** the event-study mode auto-clusters at the unit level from 3.9 + (decision 2026-08-07; new API adopts the section-7 end-state policy + immediately), mirroring the static carve-outs lane-for-lane: the + auto-cluster is silently dropped on the Conley path (only explicit + `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). +- **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). + **Key implementation requirements:** *Assumption checks / warnings:* @@ -529,7 +611,19 @@ This matches the behavior of R's `fixest::feols()` with absorbed FE. `docs/methodology/variance-conventions.md` (D1/D2, fixed 3.9). *Edge cases:* -- Singleton units/periods are automatically dropped +- Singleton units/periods are RETAINED, not dropped (both the static path and + the event-study mode, which shares the class inference stack). A singleton + unit's within-demeaned row is identically zero, so point estimates are + unchanged by its presence; it does count toward `N`, the cluster count `G`, + and residual df, so CR1/finite-sample SEs shift slightly (measured + `0.41019 -> 0.40962` on a 20-unit static fixture with one added + single-observation control). + - **Deviation from R:** Stata's `reghdfe` iteratively DROPS singleton + groups by default; R's `fixest::feols` retains them (a console notice + only, unless `fixef.rm` is escalated). diff-diff matches the fixest + default. An opt-in reghdfe-style pruning knob is tracked in TODO.md - + changing the default would move published SEs and needs its own + reviewed change. - Treatment perfectly collinear with FE raises error with informative message listing dropped columns - Covariate collinearity emits warning but estimation continues (ATT still identified) - Rank-deficient design matrix: warns and sets NA for dropped coefficients (R-style, matches `lm()`) @@ -4609,6 +4703,28 @@ Where `n_k` is the sample share of timing group `k`, `n_{kℓ} = n_k / (n_k + n_ - Requires event-study estimates with pre-treatment coefficients - Warns if pre-treatment coefficients suggest parallel trends violation - M=0 for Delta^SD: enforces linear trend extrapolation (not exact parallel trends) +- **Note (positional restriction geometry, 3.9):** the Delta^SD/Delta^RM + restriction matrices are built POSITIONALLY over the concatenated + declared pre/post coefficient lists, assuming one chronological pre/post + boundary. The native `MultiPeriodDiDResults` route does NOT validate a + declared partition against that assumption - a non-suffix + `post_periods` (e.g. `[2, 5]` on periods 0-5) or a non-last-pre + `reference_period` is accepted and produces bounds whose restriction + system does not match the equations above (a pre-existing limitation; + the transform-or-reject fix is the DEFERRED.md row). The 3.9 TWFE + event-study CALENDAR container route fails closed instead: it rejects + non-chronological declared partitions outright (row M-010). + - **Note (string calendar labels):** chronology on the calendar route is + `sorted()` order - verifiable for numeric/Period/Timestamp labels, but + for STRING labels lexical order is only ASSUMED chronology (unpadded + numeric suffixes sort `'c10'` before `'c2'`), and a mismatch would + silently shift the positional `l_vec` target. The calendar route emits + a `UserWarning` on string labels recommending orderable label types. + First-party surfaces are internally CONSISTENT either way - the + estimator sorted its calendar the same way at fit time, so the fit's + own ordering (reference selection, partition suffix semantics, plot + order) already reflects the same assumption; the warning marks the + input-type ambiguity, not a route divergence. *Restriction classes (Equations 8, Section 2.3):* @@ -4948,8 +5064,8 @@ should be a deliberate user choice. | Estimator | Default SE | Alternatives | |-----------|-----------|--------------| | DifferenceInDifferences | HC1 robust | Cluster-robust, wild bootstrap (with `cluster=`) | -| MultiPeriodDiD | HC1 robust | Cluster-robust (via `cluster` param); no wild path — `inference="wild_bootstrap"` warns and falls back to analytical | -| TwoWayFixedEffects | Cluster at unit | Wild bootstrap | +| MultiPeriodDiD (deprecated 3.9 → TWFE event-study mode) | HC1 robust | Cluster-robust (via `cluster` param); no wild path — `inference="wild_bootstrap"` warns and falls back to analytical | +| TwoWayFixedEffects | Cluster at unit (static AND event-study mode; ES carve-outs: dropped on Conley / never a survey PSU / explicit one-way) | Wild bootstrap (static only; event-study mode raises) | | CallawaySantAnna | Analytical (influence fn) | Multiplier bootstrap | | SunAbraham | Cluster-robust + delta method | Pairs bootstrap | | ImputationDiD | Conservative clustered (Thm 3) | Multiplier bootstrap (library extension; percentile CIs and empirical p-values, consistent with CS/SA) | @@ -5484,7 +5600,7 @@ metrics (`"haversine"`, `"euclidean"`) satisfy this by construction. - `MultiPeriodDiD` / `DifferenceInDifferences` `(vcov_type="conley")` without `unit=` at fit-time → `ValueError`. - `TwoWayFixedEffects(vcov_type="conley", cluster=)` is supported (Wave A #119): combined spatial + cluster product kernel applies. The cluster must be time-invariant within each unit on the panel path (validator-enforced). TWFE's default auto-cluster is silently dropped on the Conley path; explicit cluster is required to opt in. - `DifferenceInDifferences(vcov_type="conley", cluster=)`: combined kernel applies; same time-invariance contract on the panel path. DiD has no auto-cluster, so the cluster choice is fully explicit. -- `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` `(vcov_type="conley", inference="wild_bootstrap")` → `NotImplementedError`. (MPD's pre-Conley analytical-fallback `UserWarning` is suppressed when `vcov_type="conley"` so the user gets one consistent error message.) +- `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` `(vcov_type="conley", inference="wild_bootstrap")` → `NotImplementedError`. (MPD's pre-Conley analytical-fallback `UserWarning` is suppressed when `vcov_type="conley"` so the user gets one consistent error message.) **Note (mode qualifier, 3.9):** this is the STATIC contract; in the TWFE event-study mode (`event_study=True`, row M-010) the mode-level wild `ValueError` fires first - a mode-capability error precedes the Conley front door (see the TwoWayFixedEffects event-study section). - `DifferenceInDifferences` / `MultiPeriodDiD` / `TwoWayFixedEffects` `(vcov_type="conley")` + `survey_design=` → `NotImplementedError` at the estimator level. **Note (open methodological question):** weighted spatial-HAC under probability sampling is an open methodological question; no canonical extension of Conley (1999) exists for the combination. - `SyntheticDiD(vcov_type="conley")` → `TypeError` (SyntheticDiD uses bootstrap/jackknife/placebo variance, not the analytical sandwich; tracked in DEFERRED.md). - 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. **Note (open methodological question):** 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. (Estimator-specific shipped surfaces — SpilloverDiD via Wave E.1/E.2/E.3 and TwoStageDiD via Wave E.3 parity — are explicitly excepted; see "Note (deferral status, 2026-05-26)" below.) diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index 767fba30f..ca6171055 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -95,7 +95,8 @@ change in your test markets to the before/after change in your control markets. - If you have many time periods and want unit-level controls: :class:`~diff_diff.TwoWayFixedEffects` - If you want to see how the effect evolves over time (week by week): - :class:`~diff_diff.MultiPeriodDiD` + :class:`~diff_diff.TwoWayFixedEffects` with ``event_study=True`` + (``MultiPeriodDiD`` is deprecated in 3.9) .. _section-staggered: diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 0ce950895..0554c1e9e 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -119,23 +119,28 @@ For panel data with multiple periods: outcome='outcome', treatment='treated', unit='unit_id', - time='period' + post='post' ) Event Study Design ------------------ -Examine treatment effects over time: +Examine treatment effects over time with the TwoWayFixedEffects +event-study mode (``spec="pooled"`` reproduces the deprecated +``MultiPeriodDiD`` design; the default ``spec="within"`` adds unit fixed +effects): .. code-block:: python - from diff_diff import MultiPeriodDiD + from diff_diff import TwoWayFixedEffects - event = MultiPeriodDiD() + event = TwoWayFixedEffects() results = event.fit( data, outcome='outcome', treatment='treated', + unit='unit_id', + event_study=True, time='period', post_periods=[5, 6, 7, 8, 9], reference_period=4 diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 28dd5b03d..4db61373f 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -286,9 +286,11 @@ Performance Issues .. code-block:: python # TWFE already handles unit + time FE via within-transformation + # (post= is the 0/1 post-treatment dummy; the deprecated time= alias + # still works through 3.9) twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome='y', treatment='treated', - unit='unit_id', time='period') + unit='unit_id', post='post') # Reduce bootstrap iterations for initial exploration did = DifferenceInDifferences(inference='wild_bootstrap', cluster='unit_id', diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index e2b189668..bfc81f8fd 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -135,11 +135,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 3 + status: shimmed + phase: 5 warning: FutureWarning - code_refs: [diff_diff/estimators.py, diff_diff/twfe.py, diff_diff/__init__.py] - notes: "Unit-FE event study default; spec='within'|'pooled' opt-in (pooled = old MPD model, repeated cross-sections). Wild bootstrap in event-study mode = explicit error." + test_ref: tests/test_v4_merge_mpd.py + code_refs: [diff_diff/estimators.py, diff_diff/twfe.py, diff_diff/__init__.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/diagnostic_report.py, diff_diff/business_report.py, diff_diff/visualization/_event_study.py] + notes: "Unit-FE event study default; spec='within'|'pooled' opt-in (pooled = old MPD model, repeated cross-sections). Wild bootstrap in event-study mode = explicit error. SHIPPED 3.9 (Phase 3(a)): TWFE.fit(event_study=True, spec=, reference_period=, post_periods=) returns the unified EventStudyResults surface natively (source='TwoWayFixedEffects', estimation_spec + post_periods provenance); the shared core is DifferenceInDifferences._fit_event_study_core (the relocated MPD fit body, parameterized - MPD numerics/messages/warning-attribution bit-identical); ES calls pass time=/unit= as KEYWORDS (slot 4 stays post= through the M-082 window; section 4.1's snippet amended to keyword form). post_periods= is REQUIRED (non-empty) in ES mode (Phase 3(a) review R4, section 4.1 second amendment): the boundary is unobservable from time-invariant D_i, so MPD's midpoint default is a silent guess and is not carried over - MPD itself keeps it through 3.9. Day-one unit auto-cluster (user decision 2026-08-07) with static TWFE's carve-outs mirrored lane-for-lane: dropped on Conley (no implicit spatial x unit product kernel), never injected as a survey PSU (implicit-per-observation-PSU rule), dropped for explicit one-way analytical families; explicit cluster= passes through and matches MPD's explicit-cluster behavior (PSU injection included; a survey design carrying its own PSU keeps PSU precedence on every class). The wild ValueError and the no-legacy-reference-warning contract are live from the mode's 3.9 birth (the section 4.1 'at 4.0' clause describes when MPD's fallback dies with the class). MultiPeriodDiD.__init__ is the warning shim (forwarding *args/**kwargs + an import-time __signature__ mirror of DiD's constructor; set_params re-emits via its probe re-init; the static-type-checking loss for the deprecated class is the DEFERRED.md decision-record waiver). Consumer ports: HonestDiD + PreTrendsPower calendar container routes reconstruct the native-branch inputs from the post_periods provenance (HonestDiD geometry-scoped to chronological partitions - REGISTRY Note + DEFERRED row for the pre-existing native non-suffix geometry; PreTrendsPower plumbs reference_period through the gamma-unit helper incl. the string-label degradation); plot_event_study derives calendar splits from the provenance and shades per contiguous pre-run on both renderers; DiagnosticReport/BusinessReport explicitly reject the surface (TODO row for admission). 4.0 REMOVAL NOTE: the TWFE ES branch builds the surface via the internal MultiPeriodDiDResults container + _from_mpd - decouple when [M-011] removes the container." - id: M-011 kind: class group: merge-mpd @@ -150,7 +151,7 @@ rows: status: planned phase: 5 code_refs: [diff_diff/results.py, diff_diff/__init__.py] - notes: "Deprecation rides parent [M-010] (returned only by MPD). Successor = unified event-study surface on the merged TWFE results." + notes: "Deprecation rides parent [M-010] (returned only by MPD). Successor = unified event-study surface on the merged TWFE results. 4.0 REMOVAL NOTE: the TWFE event-study branch (twfe.py:_fit_event_study) constructs this container INTERNALLY and converts via results_base._from_mpd - the removal PR must decouple that construction (build the EventStudyResults directly from the core's payload) before deleting the class." - id: M-012 kind: class group: merge-mpd @@ -741,9 +742,9 @@ rows: deprecated_in: "3.9" removed_in: "4.0" status: planned - phase: 3 + phase: 5 code_refs: [diff_diff/__init__.py] - notes: "DROPPED, not retargeted: 'event study' is a design produced by CS/SA/BJS/LPDiD too, and retargeting to a class whose default is the static ATT would carry altered meaning. Deprecation warning rides parent [M-010] - the alias IS the same class object, so instantiating via EventStudy hits MPD's shim warning; no separate alias warning is enforceable." + notes: "DROPPED, not retargeted: 'event study' is a design produced by CS/SA/BJS/LPDiD too, and retargeting to a class whose default is the static ATT would carry altered meaning. Deprecation warning rides parent [M-010] - the alias IS the same class object, so instantiating via EventStudy hits MPD's shim warning; no separate alias warning is enforceable. 3.9 FutureWarning live via the parent [M-010] shim since Phase 3(a) (pinned by tests/test_v4_merge_mpd.py: EventStudy() emits MPD's message and identity holds); next transition = the 4.0 removal." - id: M-061 kind: alias group: alias-table @@ -999,7 +1000,7 @@ rows: phase: 2 test_ref: tests/test_event_study_surface.py code_refs: [diff_diff/results_base.py, diff_diff/__init__.py] - notes: "Phase 2 unified event-study representation (spec section 5): EventStudyResults container + builders for the 14 producers (CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, SpilloverDiD, ContinuousDiD, EfficientDiD, WooldridgeDiD, StaggeredTripleDifference, MultiPeriodDiD, LPDiD, ChaisemartinDHaultfoeuille, HeterogeneousAdoptionDiD). Canonical quintet columns, explicit is_reference marking (successor to the retiring sentinels [M-093]), vcov+vcov_index ordering, cband columns, event_time_convention metadata. Public exposure rides aggregate(type='event_study') in Phase 2 PR (b); merged TWFE returns it in Phase 3 [M-010]. introduced_in gates the 3.9 cut, mirroring [M-091]. Born done in this introducing diff (builder is package-internal; the class is exported). Amended pre-cut (same test_ref, introduced_in 3.9 not yet released): df became PER-ROW (one entry per event time, the df each stored p/CI actually used; joins the pinned to_dataframe schema) and StackedDiD/TwoStageDiD persist their internal full ES VCVs (event_study_vcov/_index/_df container fields; mode-gated for TwoStageDiD bootstrap/replicate). Completed pre-cut by the remaining producer channels: SunAbraham (per-event dict) and de Chaisemartin-D'Haultfoeuille (scalar) event_study_df, plus LPDiD pooled_df for the headline pre/post windows - every producer whose inference records a df now exposes it. Amended pre-cut a second time (2(b) PR-1, with M-026): three optional PROVENANCE fields appended last - base_period, anticipation, and df_survey (the fit's resolved SCALAR inference df beside the per-row channel: survey_metadata.df_survey with replicate-undefined mapping to the 0.0 fail-closed sentinel, else df_inference, else None - the per-row df column cannot encode that sentinel because __post_init__ NaNs it wherever p is non-finite) - threaded by the builders (the _empty_surface early return included) so the container consumers (HonestDiD's universal-base check, PreTrendsPower's anticipation cutoff, the honest df extraction) read fit-faithful values instead of dropping them. Amended pre-cut a third time (same PR): a fourth provenance field reference_event_times (also new on CallawaySantAnnaResults, computed at fit under base_period=universal) - the DISTINCT per-cohort positional-base event times, the common-reference signal is_reference cannot carry on gapped grids where a cohort's base overlaps another cohort's estimated horizon; HonestDiD and PreTrendsPower fail closed on more than one entry, on BOTH input routes (REGISTRY HonestDiD common-reference-guard Note). SCOPE QUALIFIER (2(b) PR-3a, with M-023): the 'every producer whose inference records a df now exposes it' sentence is satisfied on EfficientDiD through the SCALAR df_survey channel only - its newly public aggregate('event_study') container has no per-row df source (no event_study_df/df_inference field; the per-row column is all-NaN, contract-permitted for a producer that records none); threading the retained kit scalar into the per-row channel is the TODO.md M-092-completion row. The qualifier EXTENDS to ImputationDiD (2(b) PR-3b, with M-021): its newly public container likewise exposes only the scalar df_survey channel (no per-row df source; all-NaN per-row column, identical to its fit-time surface) - the same TODO.md completion row names it. The qualifier EXTENDS to ContinuousDiD (2(b) PR-3c, with M-025): its newly public container exposes only the scalar df_survey channel via the carrier's survey_metadata (no event_study_df/df_inference field, so _from_relative_dict publishes an all-NaN per-row df column on survey fits whose ES rows received a finite _survey_df - identical on the fit-time and post-fit routes); the same TODO.md completion row names it. Amended pre-cut a fourth time (2(b) PR-4, with M-027): an optional 'estimand' provenance field appended last plus a matching per-row 'estimand' column appended to the pinned to_dataframe schema - the per-row estimand discriminator ('att' for every ATT producer; the estimand label 'WAS'/'WAS_d_lower' where the att column is NOT an ATT, relayed by _from_had from target_parameter and honored by summary()'s column heading and to_dict()) - so neither the container nor a detached frame can silently relabel WAS-family numbers as ATT (the AggregationResult.target precedent). The qualifier EXTENDS to HeterogeneousAdoptionDiD (2(b) PR-4, with M-027): its newly public event-study container exposes only the scalar df_survey channel (the _from_had adapter passes no per-row df while survey ES fits pass a finite per-horizon df_infer into every safe_inference call); the same TODO.md completion row names it." + notes: "Phase 2 unified event-study representation (spec section 5): EventStudyResults container + builders for the 14 producers (CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, SpilloverDiD, ContinuousDiD, EfficientDiD, WooldridgeDiD, StaggeredTripleDifference, MultiPeriodDiD, LPDiD, ChaisemartinDHaultfoeuille, HeterogeneousAdoptionDiD). Canonical quintet columns, explicit is_reference marking (successor to the retiring sentinels [M-093]), vcov+vcov_index ordering, cband columns, event_time_convention metadata. Public exposure rides aggregate(type='event_study') in Phase 2 PR (b); merged TWFE returns it in Phase 3 [M-010]. introduced_in gates the 3.9 cut, mirroring [M-091]. Born done in this introducing diff (builder is package-internal; the class is exported). Amended pre-cut (same test_ref, introduced_in 3.9 not yet released): df became PER-ROW (one entry per event time, the df each stored p/CI actually used; joins the pinned to_dataframe schema) and StackedDiD/TwoStageDiD persist their internal full ES VCVs (event_study_vcov/_index/_df container fields; mode-gated for TwoStageDiD bootstrap/replicate). Completed pre-cut by the remaining producer channels: SunAbraham (per-event dict) and de Chaisemartin-D'Haultfoeuille (scalar) event_study_df, plus LPDiD pooled_df for the headline pre/post windows - every producer whose inference records a df now exposes it. Amended pre-cut a second time (2(b) PR-1, with M-026): three optional PROVENANCE fields appended last - base_period, anticipation, and df_survey (the fit's resolved SCALAR inference df beside the per-row channel: survey_metadata.df_survey with replicate-undefined mapping to the 0.0 fail-closed sentinel, else df_inference, else None - the per-row df column cannot encode that sentinel because __post_init__ NaNs it wherever p is non-finite) - threaded by the builders (the _empty_surface early return included) so the container consumers (HonestDiD's universal-base check, PreTrendsPower's anticipation cutoff, the honest df extraction) read fit-faithful values instead of dropping them. Amended pre-cut a third time (same PR): a fourth provenance field reference_event_times (also new on CallawaySantAnnaResults, computed at fit under base_period=universal) - the DISTINCT per-cohort positional-base event times, the common-reference signal is_reference cannot carry on gapped grids where a cohort's base overlaps another cohort's estimated horizon; HonestDiD and PreTrendsPower fail closed on more than one entry, on BOTH input routes (REGISTRY HonestDiD common-reference-guard Note). SCOPE QUALIFIER (2(b) PR-3a, with M-023): the 'every producer whose inference records a df now exposes it' sentence is satisfied on EfficientDiD through the SCALAR df_survey channel only - its newly public aggregate('event_study') container has no per-row df source (no event_study_df/df_inference field; the per-row column is all-NaN, contract-permitted for a producer that records none); threading the retained kit scalar into the per-row channel is the TODO.md M-092-completion row. The qualifier EXTENDS to ImputationDiD (2(b) PR-3b, with M-021): its newly public container likewise exposes only the scalar df_survey channel (no per-row df source; all-NaN per-row column, identical to its fit-time surface) - the same TODO.md completion row names it. The qualifier EXTENDS to ContinuousDiD (2(b) PR-3c, with M-025): its newly public container exposes only the scalar df_survey channel via the carrier's survey_metadata (no event_study_df/df_inference field, so _from_relative_dict publishes an all-NaN per-row df column on survey fits whose ES rows received a finite _survey_df - identical on the fit-time and post-fit routes); the same TODO.md completion row names it. Amended pre-cut a fourth time (2(b) PR-4, with M-027): an optional 'estimand' provenance field appended last plus a matching per-row 'estimand' column appended to the pinned to_dataframe schema - the per-row estimand discriminator ('att' for every ATT producer; the estimand label 'WAS'/'WAS_d_lower' where the att column is NOT an ATT, relayed by _from_had from target_parameter and honored by summary()'s column heading and to_dict()) - so neither the container nor a detached frame can silently relabel WAS-family numbers as ATT (the AggregationResult.target precedent). The qualifier EXTENDS to HeterogeneousAdoptionDiD (2(b) PR-4, with M-027): its newly public event-study container exposes only the scalar df_survey channel (the _from_had adapter passes no per-row df while survey ES fits pass a finite per-horizon df_infer into every safe_inference call); the same TODO.md completion row names it. Amended pre-cut a fifth time (Phase 3(a), with M-010): TWO optional provenance fields appended last - post_periods (the producer's AUTHORITATIVE calendar partition, an arbitrary subset being legal on MultiPeriodDiD, so consumers must never re-derive it positionally; threaded by _from_mpd and the TWFE event-study producer, None from every other builder; content-validated in __post_init__: nonempty, no duplicates, subset of event_time, disjoint from reference rows) and estimation_spec ('within'|'pooled', TWFE producer only, value-validated per the n_kind off-vocabulary precedent) - both routed through to_dict() with JSON-safe labels and consumed by the HonestDiD/PreTrendsPower calendar routes and the plotter's partition-aware split; same test_ref extended." - id: M-093 kind: behavior group: results-contract @@ -1010,7 +1011,7 @@ rows: status: planned phase: 5 code_refs: [diff_diff/staggered_results.py, diff_diff/sun_abraham.py, diff_diff/imputation_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/efficient_did_results.py, diff_diff/continuous_did_results.py, diff_diff/wooldridge_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/lpdid_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/results.py, diff_diff/had.py, diff_diff/visualization/_event_study.py, diff_diff/honest_did.py, diff_diff/pretrends.py] - notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None, all-NaN per-row df); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." + notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None, all-NaN per-row df); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." # ---- Behavior policies (schema-tracked, spec-governed; no reality probe) - - id: M-080 @@ -1023,7 +1024,7 @@ rows: status: planned phase: 5 code_refs: [diff_diff/estimators.py, diff_diff/twfe.py, diff_diff/stacked_did.py] - notes: "ONE auto-cluster policy at 4.0: panel estimators (required unit column) default to clustering at unit with cluster_name/n_clusters metadata; cluster=False disables; 2x2 cross-sectional estimators stay HC-robust. Changes some default SEs - migration guide with [M-004..M-006]." + notes: "ONE auto-cluster policy at 4.0: panel estimators (required unit column) default to clustering at unit with cluster_name/n_clusters metadata; cluster=False disables; 2x2 cross-sectional estimators stay HC-robust. Changes some default SEs - migration guide with [M-004..M-006]. SCOPE NOTE (Phase 3(a), user decision 2026-08-07): the TWFE EVENT-STUDY mode shipped already auto-clustered at 3.9 (new API adopts the end-state immediately, with the static carve-outs mirrored - see [M-010]), so this 4.0 flip does not re-touch it." - id: M-081 kind: behavior group: policy-n-bootstrap @@ -1058,11 +1059,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: null - status: planned + status: shimmed phase: 3 warning: FutureWarning - code_refs: [diff_diff/twfe.py] - notes: "Static TWFE's 'time' is a 0/1 post dummy today; static mode migrates to 'post'. The NAME 'time' persists (it becomes the event-study calendar column), so removed_in is null - the 4.0 semantic enforcement is [M-083]." + test_ref: tests/test_v4_merge_mpd.py + code_refs: [diff_diff/twfe.py, diff_diff/power.py] + notes: "Static TWFE's 'time' is a 0/1 post dummy today; static mode migrates to 'post'. The NAME 'time' persists (it becomes the event-study calendar column), so removed_in is null - the 4.0 semantic enforcement is [M-083]. SHIPPED 3.9 (Phase 3(a), the M-031 template): post takes the old time slot (positional static callers silently canonical), the deprecated time= tail kwarg resolves via resolve_renamed_kwarg with the calendar-column extra ('From 4.0, time= means the event-study calendar column only.'), require_arg restores the missing-argument TypeErrors (post AND unit, which gained the NOT_SUPPLIED sentinel so the unit-less pooled event-study spec is expressible); in event-study mode time= is the calendar KEYWORD with no warning and post= is rejected. Call-site migration: a receiver-resolving AST census found 103 keyword sites; 101 migrated across 11 test files + 4 benchmark scripts + power.py's two runtime TWFE fit-kwargs builders (simulate_power(TWFE) verified warning-free); the guide/rst example sites migrated with per-site VALUE judgment (docs surfaces list which column is the true 0/1 dummy). Deliberate time= usage survives only in the shim's own test pins." - id: M-083 kind: behavior group: merge-mpd diff --git a/docs/v4-design.md b/docs/v4-design.md index 784b99d7b..7010cee48 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -266,14 +266,31 @@ RegressionDiscontinuity's [M-040] [M-041]). ```python TWFE().fit(df, outcome, treatment, post, unit) # static ATT # (post = 0/1 dummy) -TWFE().fit(df, outcome, treatment, time, unit, - event_study=True) # dynamic mode +TWFE().fit(df, outcome, treatment, time="period", + unit="id", event_study=True, + post_periods=[3, 4, 5]) # dynamic mode # (time = calendar) -TWFE().fit(df, outcome, treatment, time, - event_study=True, spec="pooled") # old MPD model; +TWFE().fit(df, outcome, treatment, time="period", + event_study=True, spec="pooled", + post_periods=[3, 4, 5]) # old MPD model; # repeated cross-sections ``` +(Amended 2026-08-07, Phase 3(a) review: the event-study calls pass +`time=`/`unit=` as KEYWORDS. Signature slot 4 belongs to `post` for the +whole M-082 shim window and beyond - the same slot cannot carry the +static dummy and the calendar column - so the calendar `time=` lives at +the signature tail, exactly like the M-031 merged staggered interface. A +positional 4th argument under `event_study=True` lands in `post` and is +rejected with a message steering to `time=`. Second amendment, same +review cycle: `post_periods=` is REQUIRED (non-empty) in event-study +mode. The treatment boundary is not observable from the documented +time-invariant ever-treated indicator, so MultiPeriodDiD's midpoint +default - last half of the calendar - is a silent guess; the merged mode +fails loud instead, consistent with its day-one wild raise and +no-legacy-defaults posture. MPD itself keeps the midpoint default +through 3.9.) + **The static `time`/`post` contract [M-082] [M-083].** Today's static TWFE takes its 0/1 post dummy in a param NAMED `time` (the code and REGISTRY warn on >2 unique values) - the same overload section 8 rule 1 abolishes. The @@ -312,9 +329,22 @@ Migration guide gets a worked example of both specs. **Inference.** The merged class carries TWFE's inference stack: auto-cluster at unit (section 7), wild bootstrap for the static mode. Wild bootstrap in -event-study mode raises an explicit `ValueError` at 4.0 (MPD's current silent +event-study mode raises an explicit `ValueError` (MPD's current silent analytical fallback violates the no-silent-failures principle); porting it is -backlog, not scope. +backlog, not scope. (Amended 2026-08-07 with the Phase 3(a) ship, precision: +the raise is live from the mode's 3.9 BIRTH - a new surface needs no +deprecation window - and the original "at 4.0" phrasing described when MPD's +fallback dies with the class.) Auto-cluster decision (user-approved +2026-08-07): the event-study mode auto-clusters at unit FROM 3.9 - new API +adopts the section-7 end-state immediately, so [M-080]'s 4.0 flip never +re-touches it - WITH static TWFE's carve-outs mirrored lane-for-lane: the +auto-cluster is silently dropped on the Conley path (an implicit spatial x +unit product kernel would zero every 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 MPD's own explicit cluster on every lane. +The pooled-parity gate therefore pins bit-exactness under MATCHED cluster +settings and unconditionally in the no-unit repeated-cross-sections form. **Results.** 3.9's `TWFE(event_study=True)` returns the unified event-study surface (section 5) from day one - no intermediate container churn. @@ -326,7 +356,10 @@ unified surface in the same Phase 3 PR. **Deprecation choreography.** 3.9: `event_study=`/`spec=` ship on TWFE; `MultiPeriodDiD.__init__` emits FutureWarning; `EventStudy` alias warns [M-060]. 4.0: MultiPeriodDiD, MultiPeriodDiDResults, PeriodEffect, EventStudy -removed [M-010] [M-011] [M-012] [M-060]. +removed [M-010] [M-011] [M-012] [M-060]. (Shipped 3.9, Phase 3(a): the mode, +the shim, the alias warning riding it, the `time=`->`post=` rename [M-082], +and the consumer ports - see the [M-010] ledger notes for the shipped +mechanics and the test triple in tests/test_v4_merge_mpd.py.) ### 4.2 TripleDifference absorbs StaggeredTripleDifference [M-013] @@ -748,7 +781,7 @@ above; anything only one PR cares about stays in that PR's plan.** |---|---|---| | 1 (this PR) | - | Spec + matrix + enforcement test + support edits | | 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] [M-139] (M-020's shim already shipped; M-139 is the HAD workflow twin, a pre-cut amendment); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | -| 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple); (b) TripleDifference facade [M-013] + the SDDD alias [M-064]; (c) CiC method= [M-015] | +| 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple) (shipped: tests/test_v4_merge_mpd.py; consumer ports incl. HonestDiD/PreTrendsPower calendar routes); (b) TripleDifference facade [M-013] + the SDDD alias [M-064]; (c) CiC method= [M-015] | | 4: release + soak | 3.9 cut | Migration guide written (skeleton: section 10); maintainer cuts 3.9; maint/3.8 rule active | | 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; docs/llms.txt/README refresh | | 6: front door | 4.1 | `event_study(data, outcome, unit, time, first_treat, estimator=...)` comparison entry point over the staggered family (sketch only; specified in its own plan) | diff --git a/pyproject.toml b/pyproject.toml index 7377dd601..355dc174f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,15 @@ markers = [ "slow: marks tests as slow (run `pytest -m 'not slow'` to exclude, or `pytest -m slow` to run only slow tests)", "realdata: marks tests that validate against real survey datasets (API, NHANES, RECS)", ] +# The ~140 MPD behavior-suite constructions stay noise-free through the 3.9 +# deprecation window (row M-010; the suites pin MPD behavior until its 4.0 +# removal - REMOVE this filter with the class). pytest.warns overrides it, +# so the dedicated deprecation pins in tests/test_v4_merge_mpd.py still +# fire; note a test-local `warnings.simplefilter(...)` RESETS the filter +# list, so record-based tests must select warnings by message, never index. +filterwarnings = [ + "ignore:MultiPeriodDiD is deprecated:FutureWarning", +] [tool.black] line-length = 100 diff --git a/tests/test_bacon.py b/tests/test_bacon.py index 8de3554ad..6313e73c0 100644 --- a/tests/test_bacon.py +++ b/tests/test_bacon.py @@ -315,7 +315,7 @@ def test_twfe_staggered_warning(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - twfe.fit(data, outcome="outcome", treatment="treated", time="time", unit="unit") + twfe.fit(data, outcome="outcome", treatment="treated", post="time", unit="unit") # Should have emitted a warning about staggered treatment staggered_warnings = [x for x in w if "staggered" in str(x.message).lower()] diff --git a/tests/test_base_estimator.py b/tests/test_base_estimator.py index ed9657d51..064e10871 100644 --- a/tests/test_base_estimator.py +++ b/tests/test_base_estimator.py @@ -172,12 +172,25 @@ def test_init_signature_matches_get_params(cls): assert set(est.get_params()) == sig +# Classes whose CONSTRUCTION deliberately warns during a deprecation window +# (3.9 class merges, v4-design section 4.1). The round-trip test's +# warnings-as-errors filter exists to catch UNEXPECTED warnings from a +# re-init; these messages are expected by contract and are ignored inside +# the error filter. Forward home for the phase-3 siblings (SDDD, QDiD). +DEPRECATED_CLASS_WARNINGS = { + "MultiPeriodDiD": r"MultiPeriodDiD is deprecated", +} + + @estimators def test_reinstantiation_round_trip(cls): est = _make(cls) params = est.get_params() with warnings.catch_warnings(): warnings.simplefilter("error") + _expected = DEPRECATED_CLASS_WARNINGS.get(cls.__name__) + if _expected is not None: + warnings.filterwarnings("ignore", message=_expected, category=FutureWarning) clone = cls(**params) assert clone.get_params() == params diff --git a/tests/test_conley_vcov.py b/tests/test_conley_vcov.py index 1eac3fecb..b16fc602c 100644 --- a/tests/test_conley_vcov.py +++ b/tests/test_conley_vcov.py @@ -1202,7 +1202,7 @@ def test_twfe_conley_unknown_cluster_column_raises(self): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(df, outcome="y", treatment="treated", post="time", unit="unit") def test_mpd_conley_wild_bootstrap_raises_without_warning(self): """MPD + Conley + inference='wild_bootstrap' raises NotImplementedError @@ -1858,7 +1858,7 @@ def test_twfe_conley_panel_finite_se(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") assert np.isfinite(res.att), "ATT must be finite" assert np.isfinite(res.se) and res.se > 0, "SE must be positive and finite" @@ -1881,7 +1881,7 @@ def test_twfe_conley_with_explicit_cluster_combined_kernel(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") assert np.isfinite(res.att) assert np.isfinite(res.se) and res.se > 0 assert res.cluster_name == "region" @@ -1900,7 +1900,7 @@ def test_twfe_conley_with_wild_bootstrap_raises(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") def test_twfe_conley_repeated_coords_panel_finite_se(self, panel): """Phase 2 regression for the Phase-1 silent-bug case: each unit's @@ -1917,7 +1917,7 @@ def test_twfe_conley_repeated_coords_panel_finite_se(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") assert np.isfinite(res.se) and res.se > 0 def test_twfe_conley_missing_lag_cutoff_raises(self, panel): @@ -1929,7 +1929,7 @@ def test_twfe_conley_missing_lag_cutoff_raises(self, panel): vcov_type="conley", conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") def test_twfe_conley_binary_post_label_normalization(self, panel): """TWFE with binary `post` (values {0,1}) + `conley_lag_cutoff=1` @@ -1948,7 +1948,7 @@ def test_twfe_conley_binary_post_label_normalization(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(df_post, outcome="y", treatment="treated", time="post", unit="unit") + ).fit(df_post, outcome="y", treatment="treated", post="post", unit="unit") assert np.isfinite(res.se) and res.se > 0 def test_twfe_conley_summary_emits_conley_label(self, panel): @@ -1964,7 +1964,7 @@ def test_twfe_conley_summary_emits_conley_label(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") summary = res.summary() assert "Conley spatial HAC" in summary assert "lag_cutoff=1" in summary @@ -1989,7 +1989,7 @@ def test_twfe_conley_with_cluster_summary_label_names_kernel_and_cluster(self, p conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") summary = res.summary() assert "Conley spatial HAC" in summary assert "+ cluster product kernel at region" in summary @@ -2011,7 +2011,7 @@ def test_twfe_conley_to_dict_carries_lag_cutoff(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") d = res.to_dict() assert d["vcov_type"] == "conley" assert d["conley_lag_cutoff"] == 1 @@ -2031,7 +2031,7 @@ def test_twfe_conley_cluster_name_is_none(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") assert res.cluster_name is None d = res.to_dict() assert "cluster_name" not in d @@ -2057,7 +2057,7 @@ def test_twfe_conley_non_numeric_time_fails(self, panel): df_str, outcome="y", treatment="treated", - time="time_str", + post="time_str", unit="unit", ) @@ -2083,7 +2083,7 @@ def test_twfe_conley_within_vs_dummy_expansion_equivalence(self, panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(panel, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(panel, outcome="y", treatment="treated", post="time", unit="unit") # Manually demean using the same within-transform util TWFE uses from diff_diff.utils import within_transform as _within_transform_util @@ -2998,7 +2998,7 @@ def test_twfe_explicit_cluster_propagates_to_cluster_name(self): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(df, outcome="y", treatment="treated", post="time", unit="unit") assert res.cluster_name == "region" d = res.to_dict() assert d.get("cluster_name") == "region" diff --git a/tests/test_estimators.py b/tests/test_estimators.py index e87fbb0ae..048f00497 100644 --- a/tests/test_estimators.py +++ b/tests/test_estimators.py @@ -920,7 +920,7 @@ def test_twfe_basic_fit(self, twfe_panel_data): twfe = TwoWayFixedEffects() results = twfe.fit( - twfe_panel_data, outcome="outcome", treatment="treated", time="post", unit="unit" + twfe_panel_data, outcome="outcome", treatment="treated", post="post", unit="unit" ) assert results is not None @@ -943,7 +943,7 @@ def test_twfe_with_covariates(self, twfe_panel_data): twfe_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=["size"], ) @@ -961,7 +961,7 @@ def test_twfe_invalid_unit_column(self, twfe_panel_data): twfe_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="nonexistent_unit", ) @@ -971,7 +971,7 @@ def test_twfe_clusters_at_unit_level(self, twfe_panel_data): twfe = TwoWayFixedEffects() results = twfe.fit( - twfe_panel_data, outcome="outcome", treatment="treated", time="post", unit="unit" + twfe_panel_data, outcome="outcome", treatment="treated", post="post", unit="unit" ) # Cluster should NOT be mutated (remains None) - clustering is handled internally @@ -1013,7 +1013,7 @@ def test_twfe_treatment_collinearity_raises_error(self): # The key is that it should NOT silently produce misleading results try: results = twfe.fit( - df_collinear, outcome="outcome", treatment="treated", time="post", unit="unit" + df_collinear, outcome="outcome", treatment="treated", post="post", unit="unit" ) # If we get here without error, the ATT should still be computed # (this means only covariates were dropped, not the treatment) @@ -1038,7 +1038,7 @@ def test_rank_deficient_action_error_raises(self, twfe_panel_data): twfe_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=["collinear_cov"], ) @@ -1062,7 +1062,7 @@ def test_rank_deficient_action_silent_no_warning(self, twfe_panel_data): twfe_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=["size", "size_dup"], ) @@ -1974,9 +1974,16 @@ def test_reference_period_future_warning(self, panel_data): time="period", post_periods=[3, 4, 5], ) - future_warnings = [x for x in w if issubclass(x.category, FutureWarning)] - assert len(future_warnings) > 0, "Expected FutureWarning for reference_period default" - assert "reference_period" in str(future_warnings[0].message) + # Select BY MESSAGE, not index: MultiPeriodDiD's construction now + # emits its own deprecation FutureWarning first (row M-010), and + # simplefilter("always") resets the filter list, so the pyproject + # ignore cannot shield this record-based block. + ref_warnings = [ + x + for x in w + if issubclass(x.category, FutureWarning) and "reference_period" in str(x.message) + ] + assert len(ref_warnings) > 0, "Expected FutureWarning for reference_period default" def test_pre_period_effects_near_zero(self, panel_data): """Under parallel trends DGP, pre-period effects should be ~0.""" @@ -3277,7 +3284,7 @@ def test_twfe_with_unbalanced_panel(self): # FE-spanned junk column survived rank detection and produced a finite # garbage ATT. The v3.6.x span guard now routes that spec into TWFE's # collinearity error, so the test uses the well-specified form. - results = twfe.fit(df, outcome="outcome", treatment="treated", unit="unit", time="post") + results = twfe.fit(df, outcome="outcome", treatment="treated", unit="unit", post="post") # Should produce valid results assert np.isfinite(results.att) @@ -3288,7 +3295,7 @@ def test_twfe_with_unbalanced_panel(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") TwoWayFixedEffects().fit( - df, outcome="outcome", treatment="post", unit="unit", time="period" + df, outcome="outcome", treatment="post", unit="unit", post="period" ) def test_multiperiod_with_sparse_data(self): @@ -3574,7 +3581,7 @@ def test_twfe_with_absorbed_covariate(self): outcome="outcome", treatment="treated", unit="unit", - time="post", + post="post", covariates=["unit_covariate"], ) @@ -3838,14 +3845,14 @@ def test_twfe_unit_constant_covariate_nan_att_unaffected(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") base = TwoWayFixedEffects().fit( - df, outcome="y", treatment="treated", time="post", unit="unit" + df, outcome="y", treatment="treated", post="post", unit="unit" ) with pytest.warns(UserWarning, match="collinear with the absorbed"): res = TwoWayFixedEffects().fit( df, outcome="y", treatment="treated", - time="post", + post="post", unit="unit", covariates=["xc"], ) diff --git a/tests/test_estimators_vcov_type.py b/tests/test_estimators_vcov_type.py index f719a589a..b494a7658 100644 --- a/tests/test_estimators_vcov_type.py +++ b/tests/test_estimators_vcov_type.py @@ -243,7 +243,7 @@ def test_twfe_robust_false_preserves_cr1_via_autocluster(self): data = _make_did_panel(n_units=20) est = TwoWayFixedEffects(robust=False) with pytest.warns(UserWarning, match="robust=False with cluster"): - res = est.fit(data, outcome="y", treatment="treated", time="time", unit="unit") + res = est.fit(data, outcome="y", treatment="treated", post="time", unit="unit") assert np.isfinite(res.att) and np.isfinite(res.se) assert res.vcov_type == "hc1" assert "CR1 cluster-robust at unit" in res.summary() @@ -743,7 +743,7 @@ def test_twfe_hc2_and_hc2_bm_produce_finite_inference(self): data, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", ) assert np.isfinite(res.att), f"{vcov}: ATT not finite" @@ -764,7 +764,7 @@ def test_twfe_hc2_matches_did_fixed_effects_full_dummy(self): """ data = _make_did_panel(n_units=20) res_twfe = TwoWayFixedEffects(vcov_type="hc2").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) res_did = DifferenceInDifferences(vcov_type="hc2").fit( data, @@ -785,7 +785,7 @@ def test_twfe_hc2_bm_matches_did_fixed_effects_full_dummy(self): """ data = _make_did_panel(n_units=20) res_twfe = TwoWayFixedEffects(vcov_type="hc2_bm").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) res_did = DifferenceInDifferences(vcov_type="hc2_bm", cluster="unit").fit( data, @@ -832,7 +832,7 @@ def test_twfe_hc2_bm_auto_clusters_at_unit(self): data = pd.DataFrame(rows) res_twfe = TwoWayFixedEffects(vcov_type="hc2_bm").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) # Auto-cluster fires; result reports unit as the cluster name. assert res_twfe.cluster_name == "unit" @@ -891,7 +891,7 @@ def test_twfe_hc2_explicit_no_auto_cluster_analytical(self): """ data = _make_did_panel(n_units=20) res = TwoWayFixedEffects(vcov_type="hc2", inference="analytical").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) assert np.isfinite(res.att) assert np.isfinite(res.se) @@ -935,7 +935,7 @@ def test_twfe_hc2_wild_bootstrap_survives_rank_deficient_full_dummy(self): data, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", covariates=["x_invariant"], ) @@ -963,7 +963,7 @@ def test_twfe_hc2_wild_bootstrap_keeps_auto_cluster(self): inference="wild_bootstrap", n_bootstrap=50, seed=1, - ).fit(data, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(data, outcome="y", treatment="treated", post="time", unit="unit") assert np.isfinite(res.se) assert res.se > 0 # Bootstrap consumed unit-level clusters. @@ -1001,7 +1001,7 @@ def _fit(vc): data, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", survey_design=sd, ) @@ -1033,7 +1033,7 @@ def test_twfe_hc2_always_treated_unit_finite_att(self): # (TWFE.fit builds _treatment_post internally from data[treatment] * # data[time], so we just need data["treated"] and data["time"] right.) res = TwoWayFixedEffects(vcov_type="hc2_bm").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) assert np.isfinite(res.att) assert np.isfinite(res.se) @@ -1054,7 +1054,7 @@ def test_twfe_hc2_coefficients_align_with_vcov(self, vcov): data = _make_did_panel(n_units=20) res = TwoWayFixedEffects(vcov_type=vcov).fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) assert res.vcov is not None assert res.vcov.shape[0] == res.vcov.shape[1] @@ -1084,7 +1084,7 @@ def test_twfe_hc2_full_surface_matches_did_fixed_effects(self, vcov): """ data = _make_did_panel(n_units=20) res_twfe = TwoWayFixedEffects(vcov_type=vcov).fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) cluster_kwarg = "unit" if vcov == "hc2_bm" else None res_did = DifferenceInDifferences(vcov_type=vcov, cluster=cluster_kwarg).fit( @@ -1136,7 +1136,7 @@ def test_twfe_hc2_with_survey_weights_matches_did_fixed_effects(self, vcov): data, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", survey_design=sd, ) @@ -1181,7 +1181,7 @@ def test_twfe_hc2_with_survey_strata_psu_matches_did_fixed_effects(self, vcov): data, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", survey_design=sd, ) @@ -1210,7 +1210,7 @@ def test_twfe_results_record_cluster_name(self): data = pd.DataFrame(rows) res = TwoWayFixedEffects(vcov_type="hc1").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) summary = res.summary() # TWFE auto-clusters at the unit column when cluster=None. @@ -1226,7 +1226,7 @@ def test_twfe_honors_classical_without_autocluster(self): """ data = _make_did_panel(n_units=20) res = TwoWayFixedEffects(vcov_type="classical").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) assert np.isfinite(res.att) assert np.isfinite(res.se) @@ -1249,7 +1249,7 @@ def test_twfe_explicit_classical_without_autocluster(self): """ data = _make_did_panel(n_units=20) res = TwoWayFixedEffects(vcov_type="classical").fit( - data, outcome="y", treatment="treated", time="time", unit="unit" + data, outcome="y", treatment="treated", post="time", unit="unit" ) assert res.vcov_type == "classical" assert res.cluster_name is None @@ -1270,7 +1270,7 @@ def test_twfe_wild_bootstrap_preserves_auto_cluster(self): inference="wild_bootstrap", n_bootstrap=50, seed=1, - ).fit(data, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(data, outcome="y", treatment="treated", post="time", unit="unit") # Bootstrap must have succeeded with a finite SE. assert np.isfinite(res.se) assert res.se > 0 @@ -2375,7 +2375,7 @@ def test_collision_raises_on_all_paths(self, vcov_type, name): df, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", covariates=[name], ) @@ -2386,7 +2386,7 @@ def test_hc2_full_dummy_noncolliding_preserves_coefs(self): df, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", covariates=["x1"], ) @@ -2401,7 +2401,7 @@ def test_within_transform_noncolliding_returns_att_only(self): df, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", covariates=["x1"], ) @@ -2425,7 +2425,7 @@ def _boom(*args, **kwargs): df, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", covariates=["x1"], ) @@ -2622,7 +2622,7 @@ def test_twfe_classical_matches_full_dummy_oracle(self): DiD is the oracle. After the fix TWFE(classical) SE == that oracle.""" df = _make_absorb_panel() tw = TwoWayFixedEffects(vcov_type="classical").fit( - df, outcome="y", treatment="treated", time="post", unit="unit" + df, outcome="y", treatment="treated", post="post", unit="unit" ) fe = DifferenceInDifferences(vcov_type="classical").fit( df, outcome="y", treatment="treated", post="post", fixed_effects=["unit", "post"] @@ -2813,7 +2813,7 @@ def test_cluster_convention_matches_g_minus_1(self, estimator_cls): from scipy import stats data = self._clustered_panel() - kw = dict(outcome="y", treatment="group", time="post", unit="unit") + kw = dict(outcome="y", treatment="group", post="post", unit="unit") r0 = estimator_cls(cluster="unit").fit(data, **kw) r1 = estimator_cls(cluster="unit", df_convention="cluster").fit(data, **kw) assert r0.att == r1.att and r0.se == r1.se and r0.t_stat == r1.t_stat @@ -2832,7 +2832,7 @@ def test_twfe_inherits_knob(self): data = self._clustered_panel() data["treated"] = data["group"] * data["post"] - kw = dict(outcome="y", treatment="treated", time="post", unit="unit") + kw = dict(outcome="y", treatment="treated", post="post", unit="unit") r0 = TwoWayFixedEffects().fit(data, **kw) r1 = TwoWayFixedEffects(df_convention="cluster").fit(data, **kw) assert r0.se == r1.se and r0.t_stat == r1.t_stat @@ -2914,7 +2914,7 @@ def test_conley_fits_excluded_from_knob(self): lon = {u: -100 + rng.uniform(-2, 2) for u in units} data["lat"] = data["unit"].map(lat) data["lon"] = data["unit"].map(lon) - kw = dict(outcome="y", treatment="group", time="post", unit="unit") + kw = dict(outcome="y", treatment="group", post="post", unit="unit") common = dict( vcov_type="conley", cluster="unit", @@ -2931,7 +2931,7 @@ def test_bm_dof_precedence_over_knob(self): knob is fallback-level only, so hc2_bm inference is IDENTICAL with the knob on and off.""" data = self._clustered_panel(n_units=20) - kw = dict(outcome="y", treatment="group", time="post", unit="unit") + kw = dict(outcome="y", treatment="group", post="post", unit="unit") with warnings.catch_warnings(): warnings.simplefilter("ignore") r0 = DifferenceInDifferences(cluster="unit", vcov_type="hc2_bm").fit(data, **kw) @@ -3093,7 +3093,7 @@ def test_get_params_roundtrip(self): def test_unclustered_fit_knob_is_inert(self): """No cluster -> n_clusters_ is None -> knob has zero effect.""" data = self._clustered_panel() - kw = dict(outcome="y", treatment="group", time="post") + kw = dict(outcome="y", treatment="group", post="post") r0 = DifferenceInDifferences().fit(data, **kw) r1 = DifferenceInDifferences(df_convention="cluster").fit(data, **kw) assert (r0.p_value, r0.conf_int) == (r1.p_value, r1.conf_int) @@ -3128,7 +3128,7 @@ def _panel(seed=11): ) return pd.DataFrame(rows) - _kw = dict(outcome="y", treatment="group", time="post") + _kw = dict(outcome="y", treatment="group", post="post") def test_normal_is_z_on_every_fit(self): from scipy import stats @@ -3168,7 +3168,7 @@ def test_twfe_and_mpd_normal(self): data = self._panel() rt = TwoWayFixedEffects(cluster="unit", df_convention="normal").fit( - data, outcome="y", treatment="group", time="post", unit="unit" + data, outcome="y", treatment="group", post="post", unit="unit" ) assert rt.p_value == pytest.approx(2 * stats.norm.sf(abs(rt.t_stat)), rel=1e-14) rm = MultiPeriodDiD(cluster="unit", df_convention="normal").fit( diff --git a/tests/test_event_study_consumers.py b/tests/test_event_study_consumers.py index 4975cc321..70b656b44 100644 --- a/tests/test_event_study_consumers.py +++ b/tests/test_event_study_consumers.py @@ -927,14 +927,17 @@ def test_non_cs_e0_source_rejected(self): def test_calendar_scale_rejected(self): # Belt-and-suspenders: even a CS-sourced container is rejected on a - # non-relative time scale (CS never emits calendar). + # calendar time scale (CS never emits calendar). Since the M-010 + # merge, calendar surfaces route to the TWFE calendar branch, whose + # source gate rejects everything but the TWFE event-study producer - + # the rejection survives with the calendar-route message. surface = _tiny_container( event_time=np.array(["2018", "2019", "2020", "2021"], dtype=object), time_scale="calendar", ) - with pytest.raises(TypeError, match="relative"): + with pytest.raises(TypeError, match="TwoWayFixedEffects event-study mode"): compute_honest_did(surface, M=0.5) - with pytest.raises(TypeError, match="relative"): + with pytest.raises(TypeError, match="TwoWayFixedEffects event-study mode"): compute_pretrends_power(surface, M=0.1) def test_multiple_reference_rows_fail_closed_in_honest(self): diff --git a/tests/test_event_study_surface.py b/tests/test_event_study_surface.py index 05530d94d..009f2276c 100644 --- a/tests/test_event_study_surface.py +++ b/tests/test_event_study_surface.py @@ -1841,3 +1841,83 @@ def test_lpdid_pooled_df_threaded(): assert res.to_dict()["pooled_df"] == res.pooled_df # Native pooled frame schema is UNCHANGED. assert "df" not in res.pooled.columns + + +# --------------------------------------------------------------------------- +# M-092 amendment #5 (Phase 3(a), with M-010): the calendar-partition and +# design provenance fields +# --------------------------------------------------------------------------- + + +class TestPartitionProvenanceFields: + def test_post_periods_threaded_by_from_mpd(self): + from diff_diff import MultiPeriodDiD + from diff_diff.results_base import _from_mpd + + rng = np.random.default_rng(9) + rows = [] + for u in range(24): + ti = 1 if u < 12 else 0 + for t in range(6): + rows.append( + { + "unit": u, + "period": t, + "treated": ti, + "y": rng.normal() + 0.4 * ti * (t in (2, 5)), + } + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = MultiPeriodDiD().fit( + pd.DataFrame(rows), + "y", + "treated", + "period", + post_periods=[2, 5], + reference_period=4, + ) + surface = _from_mpd(res) + # the AUTHORITATIVE (possibly non-suffix) declared partition, verbatim + assert surface.post_periods == (2, 5) + assert surface.estimation_spec is None # TWFE producer only + + def test_absent_on_other_producers(self): + surface = _tiny_surface() + assert surface.post_periods is None + assert surface.estimation_spec is None + d = surface.to_dict() + assert d["post_periods"] is None + assert d["estimation_spec"] is None + + def test_post_periods_content_validated(self): + # Fail-closed on malformed-but-present content (the n_kind / vcov + # pairing convention): consumers classify by this field. + with pytest.raises(ValueError, match="nonempty"): + _tiny_surface(post_periods=()) + with pytest.raises(ValueError, match="duplicates"): + _tiny_surface(post_periods=(1, 1)) + with pytest.raises(ValueError, match="not\\s+in\\s+event_time|are not"): + _tiny_surface(post_periods=(9,)) + with pytest.raises(ValueError, match="reference rows"): + _tiny_surface(post_periods=(-1, 1)) + + def test_estimation_spec_vocabulary(self): + with pytest.raises(ValueError, match="'within', 'pooled'"): + _tiny_surface(estimation_spec="banana") + assert _tiny_surface(estimation_spec="within").estimation_spec == "within" + + def test_json_round_trip_carries_both_fields(self): + import json + + surface = _tiny_surface(post_periods=(0, 1), estimation_spec="pooled") + payload = json.loads(json.dumps(surface.to_dict())) + assert payload["post_periods"] == [0, 1] + assert payload["estimation_spec"] == "pooled" + + def test_copy_semantics(self): + # tuple coercion: a caller-owned list never aliases the container + pp = [0, 1] + surface = _tiny_surface(post_periods=pp) + pp.append(99) + assert surface.post_periods == (0, 1) diff --git a/tests/test_fixest_did_twfe_parity.py b/tests/test_fixest_did_twfe_parity.py index 7db3f5021..311ea43fc 100644 --- a/tests/test_fixest_did_twfe_parity.py +++ b/tests/test_fixest_did_twfe_parity.py @@ -70,7 +70,7 @@ def test_twfe_classical_se_matches_fixest_iid(self): golden = _load_golden() df = _build_df(golden["twfe"]) res = TwoWayFixedEffects(vcov_type="classical").fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) exp = golden["twfe"]["iid"] np.testing.assert_allclose(res.att, exp["att"], atol=1e-10, rtol=0) @@ -110,7 +110,7 @@ def test_twfe_cluster_att_matches_fixest(self): assert key in golden, f"required golden block {key!r} missing — regenerate the fixture" df = _build_df(golden[key]) res = TwoWayFixedEffects(vcov_type="hc1", cluster="unit").fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) exp = golden[key]["cluster_unit"] np.testing.assert_allclose(res.att, exp["att"], atol=1e-10, rtol=0) @@ -168,7 +168,7 @@ def test_twfe_hetero_iid_matches_fixest_machine_precision(self): ), "required golden block 'twfe_hetero' missing — regenerate the fixture" df = _build_df(golden["twfe_hetero"]) res = TwoWayFixedEffects(vcov_type="classical").fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) exp = golden["twfe_hetero"]["iid"] np.testing.assert_allclose(res.att, exp["att"], atol=1e-10, rtol=0) @@ -203,7 +203,7 @@ def _fit(self, key, golden, **est_kw): df = _build_df(golden[key]) cls = TwoWayFixedEffects if key.startswith("twfe") else DifferenceInDifferences return cls(**est_kw).fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) def test_iid_p_and_ci_match_fixest_under_residual_default(self): diff --git a/tests/test_linalg.py b/tests/test_linalg.py index 91bc34d7d..064404e59 100644 --- a/tests/test_linalg.py +++ b/tests/test_linalg.py @@ -1914,7 +1914,7 @@ def test_twfe_estimator_produces_valid_results(self): ) twfe = TwoWayFixedEffects() - result = twfe.fit(data, outcome="y", treatment="treated", time="post", unit="unit") + result = twfe.fit(data, outcome="y", treatment="treated", post="post", unit="unit") # Should produce valid results assert result.se > 0 diff --git a/tests/test_methodology_changes_in_changes.py b/tests/test_methodology_changes_in_changes.py index db759a7fb..cce04bcba 100644 --- a/tests/test_methodology_changes_in_changes.py +++ b/tests/test_methodology_changes_in_changes.py @@ -194,7 +194,7 @@ def test_qdid_att_matches_did_at_large_n(self): # comparison uses a large sample and a loose tolerance. df = make_additive_panel(20000, seed=42) qdid = fit_quiet(QDiD(n_bootstrap=0), df) - did = DifferenceInDifferences().fit(df, outcome="y", treatment="treated", time="post") + did = DifferenceInDifferences().fit(df, outcome="y", treatment="treated", post="post") assert qdid.att == pytest.approx(did.att, abs=0.05) def test_cic_matches_did_on_additive_dgp(self): @@ -202,7 +202,7 @@ def test_cic_matches_did_on_additive_dgp(self): # and DiD probability limits coincide. df = make_additive_panel(20000, seed=7) cic = fit_quiet(ChangesInChanges(n_bootstrap=0), df) - did = DifferenceInDifferences().fit(df, outcome="y", treatment="treated", time="post") + did = DifferenceInDifferences().fit(df, outcome="y", treatment="treated", post="post") assert cic.att == pytest.approx(did.att, abs=0.05) def test_cic_scale_invariance_nonlinear_dgp(self): diff --git a/tests/test_methodology_lwdid.py b/tests/test_methodology_lwdid.py index 3a324ff24..d5ed9d07c 100644 --- a/tests/test_methodology_lwdid.py +++ b/tests/test_methodology_lwdid.py @@ -507,7 +507,7 @@ def test_demean_ra_equals_plain_did(self): lw = LWDiD(rolling="demean", estimator="ra", vce="classical").fit( df, outcome="y", unit="unit", time="time", treatment="treat" ) - dd = DifferenceInDifferences().fit(df, outcome="y", treatment="treated_group", time="post") + dd = DifferenceInDifferences().fit(df, outcome="y", treatment="treated_group", post="post") np.testing.assert_allclose(lw.att, dd.att, rtol=0, atol=1e-10) @pytest.mark.parametrize("r", [5, 7]) @@ -520,7 +520,7 @@ def test_per_period_equals_subset_panel_did(self, r): df, outcome="y", unit="unit", time="time", treatment="treat" ) sub = df[(df["time"] < 5) | (df["time"] == r)] - dd = DifferenceInDifferences().fit(sub, outcome="y", treatment="treated_group", time="post") + dd = DifferenceInDifferences().fit(sub, outcome="y", treatment="treated_group", post="post") np.testing.assert_allclose(res.period_effects[r]["att"], dd.att, rtol=0, atol=1e-10) def test_detrend_t3_closed_form(self): diff --git a/tests/test_methodology_twfe.py b/tests/test_methodology_twfe.py index 5bef2e069..d3c7db00d 100644 --- a/tests/test_methodology_twfe.py +++ b/tests/test_methodology_twfe.py @@ -194,7 +194,7 @@ def test_twfe_att_matches_hand_calculated_demeaned_ols(self): # Run TWFE twfe = TwoWayFixedEffects() - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") # Manual demeaned OLS: demean both y and the interaction term data_with_tp = data.copy() @@ -217,7 +217,7 @@ def test_twfe_att_matches_basic_did_for_two_period_design(self): # TWFE twfe = TwoWayFixedEffects() twfe_results = twfe.fit( - data, outcome="outcome", treatment="treated", time="post", unit="unit" + data, outcome="outcome", treatment="treated", post="post", unit="unit" ) # Basic DiD @@ -444,7 +444,7 @@ def _run_python_twfe(self, data, covariates=None): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=covariates, ) @@ -679,7 +679,7 @@ def test_staggered_treatment_warning_multiperiod_time(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") # Use time="period" so staggered detection sees different first-treat times - twfe.fit(df, outcome="outcome", treatment="treated", time="period", unit="unit") + twfe.fit(df, outcome="outcome", treatment="treated", post="period", unit="unit") staggered_warnings = [x for x in w if "Staggered treatment" in str(x.message)] assert len(staggered_warnings) > 0, "Expected staggered treatment warning" @@ -729,7 +729,7 @@ def test_staggered_warning_not_fired_with_binary_time(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") # With binary time="post", staggering is undetectable - twfe.fit(df, outcome="outcome", treatment="treated", time="post", unit="unit") + twfe.fit(df, outcome="outcome", treatment="treated", post="post", unit="unit") staggered_warnings = [x for x in w if "Staggered treatment" in str(x.message)] assert ( @@ -743,7 +743,7 @@ def test_multiperiod_time_warning(self): twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - twfe.fit(data, outcome="outcome", treatment="treated", time="period", unit="unit") + twfe.fit(data, outcome="outcome", treatment="treated", post="period", unit="unit") multiperiod_warnings = [x for x in w if "unique values" in str(x.message)] assert ( @@ -760,7 +760,7 @@ def test_binary_time_no_multiperiod_warning(self): twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") multiperiod_warnings = [x for x in w if "unique values" in str(x.message)] assert ( @@ -776,7 +776,7 @@ def test_non_binary_time_values_warning(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") results = twfe.fit( - data, outcome="outcome", treatment="treated", time="year", unit="unit" + data, outcome="outcome", treatment="treated", post="year", unit="unit" ) non_binary_warnings = [x for x in w if "instead of {0, 1}" in str(x.message)] @@ -796,7 +796,7 @@ def test_boolean_time_no_warning(self): data, outcome="outcome", treatment="treated", - time="post_bool", + post="post_bool", unit="unit", ) @@ -812,7 +812,7 @@ def test_att_invariant_to_time_encoding(self): # Fit with binary {0,1} twfe = TwoWayFixedEffects() results_binary = twfe.fit( - data, outcome="outcome", treatment="treated", time="post", unit="unit" + data, outcome="outcome", treatment="treated", post="post", unit="unit" ) # Fit with year encoding {2020, 2021} @@ -820,7 +820,7 @@ def test_att_invariant_to_time_encoding(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") results_year = twfe.fit( - data, outcome="outcome", treatment="treated", time="year", unit="unit" + data, outcome="outcome", treatment="treated", post="year", unit="unit" ) np.testing.assert_allclose( @@ -849,13 +849,13 @@ def test_auto_clusters_at_unit_level(self): # Default (auto-clusters at unit) twfe_default = TwoWayFixedEffects() results_default = twfe_default.fit( - data, outcome="outcome", treatment="treated", time="post", unit="unit" + data, outcome="outcome", treatment="treated", post="post", unit="unit" ) # Explicit cluster at unit twfe_explicit = TwoWayFixedEffects(cluster="unit") results_explicit = twfe_explicit.fit( - data, outcome="outcome", treatment="treated", time="post", unit="unit" + data, outcome="outcome", treatment="treated", post="post", unit="unit" ) np.testing.assert_allclose( @@ -878,7 +878,7 @@ def test_df_adjustment_for_absorbed_fe(self): # Run TWFE twfe = TwoWayFixedEffects() - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") # Manual: demean both y and the interaction, then run LinearRegression data_with_tp = data.copy() @@ -936,7 +936,7 @@ def test_covariate_collinear_with_interaction_raises_error(self): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=["bad_cov"], ) @@ -954,7 +954,7 @@ def test_covariate_collinearity_warns_not_errors(self): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=["bad_cov"], ) @@ -976,7 +976,7 @@ def test_rank_deficient_action_error_raises(self): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=["bad_cov"], ) @@ -993,7 +993,7 @@ def test_rank_deficient_action_silent_no_warning(self): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", covariates=["bad_cov"], ) @@ -1011,7 +1011,7 @@ def test_unbalanced_panel_produces_valid_results(self): data = data.drop(index=drop_indices).reset_index(drop=True) twfe = TwoWayFixedEffects() - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") assert np.isfinite(results.att), "ATT should be finite for unbalanced panel" assert results.se > 0, "SE should be positive" @@ -1027,7 +1027,7 @@ def test_unit_column_missing_raises_error(self): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="nonexistent_unit", ) @@ -1089,7 +1089,7 @@ def test_cluster_se_differs_from_hc1_se(self): # TWFE: cluster-robust at unit (automatic) twfe = TwoWayFixedEffects() twfe_results = twfe.fit( - data, outcome="outcome", treatment="treated", time="post", unit="unit" + data, outcome="outcome", treatment="treated", post="post", unit="unit" ) # Manual HC1 SE on same demeaned regression (no clustering) @@ -1140,7 +1140,7 @@ def test_vcov_positive_semidefinite(self): data = generate_twfe_panel(n_units=20, n_periods=4, seed=42) twfe = TwoWayFixedEffects() - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") eigenvalues = np.linalg.eigvalsh(results.vcov) assert np.all( @@ -1162,7 +1162,7 @@ def test_wild_bootstrap_produces_valid_inference(self, ci_params): n_boot = ci_params.bootstrap(999, min_n=199) twfe = TwoWayFixedEffects(inference="wild_bootstrap", n_bootstrap=n_boot, seed=42) - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") assert np.isfinite(results.se) and results.se > 0 assert 0 <= results.p_value <= 1 @@ -1180,7 +1180,7 @@ def test_wild_bootstrap_weight_types(self, ci_params, weight_type): bootstrap_weights=weight_type, seed=42, ) - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") assert np.isfinite(results.se) and results.se > 0 assert 0 <= results.p_value <= 1 @@ -1190,7 +1190,7 @@ def test_inference_parameter_routing(self): data = generate_twfe_panel(n_units=20, n_periods=2, seed=42) twfe = TwoWayFixedEffects(inference="wild_bootstrap", n_bootstrap=99, seed=42) - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") assert results.inference_method == "wild_bootstrap" @@ -1234,7 +1234,7 @@ def test_summary_contains_key_info(self): """summary() output contains ATT.""" data = generate_hand_calculable_panel() twfe = TwoWayFixedEffects() - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") summary = results.summary() assert "ATT" in summary @@ -1243,7 +1243,7 @@ def test_to_dict_contains_all_fields(self): """to_dict() contains required fields.""" data = generate_hand_calculable_panel() twfe = TwoWayFixedEffects() - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") d = results.to_dict() for key in ["att", "se", "t_stat", "p_value", "n_obs"]: @@ -1258,7 +1258,7 @@ def test_residuals_plus_fitted_equals_demeaned_outcome(self): data = generate_twfe_panel(n_units=20, n_periods=4, seed=42) twfe = TwoWayFixedEffects() - results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") + results = twfe.fit(data, outcome="outcome", treatment="treated", post="post", unit="unit") # Within-transform by unit + post (same as TWFE internally does) demeaned = within_transform(data, ["outcome"], "unit", "post") @@ -1338,7 +1338,7 @@ def test_twfe_hc2_se_matches_r_lm_vcovHC(self): ) data = self._build_panel(scenario) res = TwoWayFixedEffects(vcov_type="hc2").fit( - data, outcome="y", treatment="treated", time="post", unit="unit" + data, outcome="y", treatment="treated", post="post", unit="unit" ) vcov_R = np.array(scenario["vcov_hc2"]).reshape(scenario["vcov_hc2_shape"], order="F") # ATT is the 2nd coef (index 1) in the R design @@ -1468,7 +1468,7 @@ def test_twfe_hc2_bm_se_matches_clubsandwich_cr2_unit(self): pytest.skip("twfe_two_period scenario does not include vcov_cr2_unit.") data = self._build_panel(scenario) res = TwoWayFixedEffects(vcov_type="hc2_bm").fit( - data, outcome="y", treatment="treated", time="post", unit="unit" + data, outcome="y", treatment="treated", post="post", unit="unit" ) n = len(scenario["coef_names"]) vcov_cr2 = np.array(scenario["vcov_cr2_unit"]).reshape((n, n), order="F") diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index 40099782d..a3d21f7b0 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -779,13 +779,26 @@ def _live_family_code_refs(tok): return refs +# Deprecation shims whose __init__ FORWARDS verbatim (*args/**kwargs + +# super().__init__) to the named base with a mirrored __signature__ (the +# 3.9 class merges, v4-design section 4.1). Their effective __init__ is no +# longer the base's function object, but calls under the shim's name still +# read every base constructor param - so they stay in the base's +# init-sharing group. Forward home for the phase-3 siblings (SDDD, QDiD). +_FORWARDING_INIT_SHIMS = { + "MultiPeriodDiD": "DifferenceInDifferences", +} + + def _init_sharing_class_names(_cache={}): """Exported class names grouped by the id() of their EFFECTIVE __init__. A subclass that inherits its constructor (``TwoWayFixedEffects`` from ``DifferenceInDifferences``) is callable under its own name with the base's params - ``TwoWayFixedEffects(robust=True)`` reads M-045's dying - param under a callee name the base-class form cannot see.""" + param under a callee name the base-class form cannot see. Forwarding + deprecation shims (``_FORWARDING_INIT_SHIMS``) join their base's group + by declaration - identity grouping cannot see through the wrapper.""" if not _cache: groups = {} for name in diff_diff.__all__: @@ -795,6 +808,13 @@ def _init_sharing_class_names(_cache={}): func = _unwrap_callable(inspect.getattr_static(obj, "__init__", None)) if func is not None: groups.setdefault(id(func), set()).add(name) + for shim_name, base_name in _FORWARDING_INIT_SHIMS.items(): + base = getattr(diff_diff, base_name, None) + if base is None or not inspect.isclass(base): + continue + base_func = _unwrap_callable(inspect.getattr_static(base, "__init__", None)) + if base_func is not None: + groups.setdefault(id(base_func), set()).add(shim_name) _cache["groups"] = groups return _cache["groups"] diff --git a/tests/test_power.py b/tests/test_power.py index e5a20967d..9fc85b439 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -1704,7 +1704,7 @@ def fit(self, data, **kwargs): result = simulate_power( _UnregisteredEstimator(), data_generator=generate_did_data, - estimator_kwargs=dict(outcome="outcome", treatment="treated", time="post"), + estimator_kwargs=dict(outcome="outcome", treatment="treated", post="post"), n_simulations=5, seed=42, progress=False, @@ -1748,7 +1748,7 @@ def _custom_extractor(result): result = simulate_power( _UnregisteredEstimator(), data_generator=generate_did_data, - estimator_kwargs=dict(outcome="outcome", treatment="treated", time="post"), + estimator_kwargs=dict(outcome="outcome", treatment="treated", post="post"), result_extractor=_custom_extractor, n_simulations=5, seed=42, @@ -1774,7 +1774,7 @@ def _custom_extractor(result): result = simulate_mde( _UnregisteredEstimator(), data_generator=generate_did_data, - estimator_kwargs=dict(outcome="outcome", treatment="treated", time="post"), + estimator_kwargs=dict(outcome="outcome", treatment="treated", post="post"), result_extractor=_custom_extractor, n_simulations=5, effect_range=(0.5, 5.0), diff --git a/tests/test_survey.py b/tests/test_survey.py index 8f973ba46..4d54cb1ee 100644 --- a/tests/test_survey.py +++ b/tests/test_survey.py @@ -903,7 +903,7 @@ def test_twfe_with_survey_design(self, twfe_panel_data): twfe_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", survey_design=sd, ) @@ -3518,7 +3518,7 @@ def test_twfe_weights_only_no_cluster_uses_no_psu_path(self, twfe_panel_data): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", survey_design=SurveyDesign(weights="weight"), ) @@ -3536,7 +3536,7 @@ def test_twfe_stratified_no_psu_no_cluster(self, twfe_panel_data): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", survey_design=SurveyDesign(weights="weight", strata="stratum"), ) @@ -3552,7 +3552,7 @@ def test_twfe_explicit_cluster_still_injects_psu(self, twfe_panel_data): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", survey_design=SurveyDesign(weights="weight"), ) @@ -3567,7 +3567,7 @@ def test_twfe_non_survey_default_clustering_unaffected(self, twfe_panel_data): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) assert result is not None diff --git a/tests/test_survey_phase6.py b/tests/test_survey_phase6.py index 2a58878ed..ca10cbc0e 100644 --- a/tests/test_survey_phase6.py +++ b/tests/test_survey_phase6.py @@ -1602,7 +1602,7 @@ def test_twfe_replicate_accepted(self): data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", survey_design=sd, ) diff --git a/tests/test_t24_staggered_vs_collapsed_power_drift.py b/tests/test_t24_staggered_vs_collapsed_power_drift.py index 53edb49de..d705705e2 100644 --- a/tests/test_t24_staggered_vs_collapsed_power_drift.py +++ b/tests/test_t24_staggered_vs_collapsed_power_drift.py @@ -97,7 +97,7 @@ def fit_2x2(panel, rollout_start, tail_start=None): collapse_2x2(panel, rollout_start, tail_start), outcome="outcome", treatment="treated", - time="post", + post="post", ) return r.att, r.se, r.conf_int, bool(r.p_value < 0.05) diff --git a/tests/test_target_parameter.py b/tests/test_target_parameter.py index 0bbad3d81..4b049f3a6 100644 --- a/tests/test_target_parameter.py +++ b/tests/test_target_parameter.py @@ -576,7 +576,7 @@ def test_twfe_fit_returns_did_results_branch(self): warnings.filterwarnings("ignore") df = generate_did_data(n_units=40, n_periods=4, seed=7) fit = TwoWayFixedEffects().fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) # Real TWFE fit returns DiDResults (no separate TWFE result class). assert type(fit).__name__ == "DiDResults" diff --git a/tests/test_v4_inference_policy.py b/tests/test_v4_inference_policy.py index e13d7915c..166002893 100644 --- a/tests/test_v4_inference_policy.py +++ b/tests/test_v4_inference_policy.py @@ -155,7 +155,7 @@ def _fit_did(est, df, **kw): def _fit_twfe(est, df, **kw): - return est.fit(df, outcome="y", treatment="treated", time="post", unit="unit", **kw) + return est.fit(df, outcome="y", treatment="treated", post="post", unit="unit", **kw) def _assert_finite_quintet(results): diff --git a/tests/test_v4_merge_mpd.py b/tests/test_v4_merge_mpd.py new file mode 100644 index 000000000..68559a085 --- /dev/null +++ b/tests/test_v4_merge_mpd.py @@ -0,0 +1,1521 @@ +"""Phase 3(a) merge tests: TWFE event-study mode + MPD deprecation + time->post. + +``test_ref`` for ledger rows M-010 (TwoWayFixedEffects absorbs +MultiPeriodDiD) and M-082 (static ``fit(time=)`` -> ``post=``), covering the +v4-design section 4.1 gate triple, the mode/rename validation surfaces, the +day-one auto-cluster carve-outs (user decision 2026-08-07), the deprecation +choreography (M-060's EventStudy warning rides the parent shim), the unified +surface contract, warning attribution, and the consumer ports. + +Conventions: message pins via ``re.escape`` of the exact text; parity +assertions are BIT-EXACT (``assert_array_equal``) where the two sides run +the SAME code path in the same process (pooled vs MultiPeriodDiD - the +shared core), and ``assert_allclose`` at tight tolerance where the designs +differ (within vs pooled equivalence). PreTrendsPower parity lanes pin +``pretest_form="wald"``: the default ``"nis"`` box probability uses scipy's +``multivariate_normal.cdf``, which is internally randomized at ~1e-9 even +for identical repeated calls. +""" + +import re +import warnings + +import numpy as np +import pandas as pd +import pytest + +from diff_diff import ( + DifferenceInDifferences, + EventStudy, + MultiPeriodDiD, + TwoWayFixedEffects, + compute_honest_did, +) +from diff_diff.pretrends import compute_pretrends_power +from diff_diff.results_base import EventStudyResults, _from_mpd + +# --------------------------------------------------------------------------- +# Pinned messages (the deprecation/validation contract) +# --------------------------------------------------------------------------- + +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." +) +RENAME_MSG = ( + "TwoWayFixedEffects.fit(time=) is deprecated and will be removed in " + "4.0; use post= instead. From 4.0, time= means the event-study " + "calendar column only." +) +ES_WILD_MSG = ( + "inference='wild_bootstrap' is not supported in event-study mode: the " + "wild cluster bootstrap covers the static ATT only. Use " + "inference='analytical' for event-study fits." +) +WITHIN_UNIT_MSG = ( + "spec='within' requires unit=; the unit fixed effects are absorbed at " + "the unit level. spec='pooled' is the only event-study spec that works " + "without a unit id (repeated cross-sections)." +) +ES_POST_MSG = ( + "event-study mode takes time= (calendar column) as a keyword; post= is " + "the static-mode 0/1 dummy. Pass " + "fit(..., event_study=True, time='')." +) +ES_POST_PERIODS_MSG = ( + "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." +) + + +# --------------------------------------------------------------------------- +# Seeded DGPs +# --------------------------------------------------------------------------- + + +def _panel(seed=42, n_units=40, n_periods=6, covariate=False, unbalanced=False, str_periods=False): + """Balanced simultaneous-adoption panel; knobs make it diverge. + + ``unbalanced=True`` drops the first two periods for a third of the + control units; ``covariate=True`` adds a treatment-correlated covariate + ``x`` entering the outcome. Both make the unit-FE projection change the + point estimates (the documented within-vs-pooled estimate shift). + """ + rng = np.random.default_rng(seed) + rows = [] + for u in range(n_units): + ti = 1 if u < n_units // 2 else 0 + a_u = rng.normal() * 2.0 + x_u = rng.normal() + 0.8 * ti + for p in range(n_periods): + if unbalanced and ti == 0 and u % 3 == 0 and p < 2: + continue + x = x_u + 0.5 * rng.normal() + y = ( + a_u + + 0.3 * p + + rng.normal() * 0.5 + + 0.8 * ti * (p >= 3) + + (0.7 * x if covariate else 0.0) + ) + rows.append(dict(unit=u, period=f"P{p}" if str_periods else p, treated=ti, x=x, y=y)) + return pd.DataFrame(rows) + + +def _post(n_periods=6, cut=3, str_periods=False): + ps = list(range(cut, n_periods)) + return [f"P{p}" for p in ps] if str_periods else ps + + +@pytest.fixture() +def panel(): + return _panel() + + +def _mpd(**kwargs): + """Construct MultiPeriodDiD with its deprecation warning suppressed.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + return MultiPeriodDiD(**kwargs) + + +def _static_df(panel): + return panel.assign(post=(panel["period"] >= 3).astype(int)) + + +def _eq_with_nans(a, b): + """Bit-exact parity that cannot mask NaN-vs-zero regressions: the NaN + masks must match exactly, then the finite entries compare bit-equal + (R4 - ``nan_to_num`` would silently equate a NaN on one side with a + 0.0 on the other, the exact partial-NaN shape the inference contract + forbids).""" + a = np.asarray(a, dtype=float) + b = np.asarray(b, dtype=float) + np.testing.assert_array_equal(np.isnan(a), np.isnan(b)) + np.testing.assert_array_equal(a[~np.isnan(a)], b[~np.isnan(b)]) + + +def _close_with_nans(a, b, **tol): + """allclose sibling of ``_eq_with_nans`` (same mask-first contract).""" + a = np.asarray(a, dtype=float) + b = np.asarray(b, dtype=float) + np.testing.assert_array_equal(np.isnan(a), np.isnan(b)) + np.testing.assert_allclose(a[~np.isnan(a)], b[~np.isnan(b)], **tol) + + +# --------------------------------------------------------------------------- +# The section 4.1 gate triple (+ the within numerical gate) +# --------------------------------------------------------------------------- + + +class TestGateTriple: + def test_a_equivalence_balanced_no_covariates(self, panel): + """(a) balanced / no covariates / simultaneous: within == pooled points.""" + w = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + p = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + _close_with_nans(w.att, p.att, atol=1e-10, rtol=1e-10) + + @pytest.mark.parametrize( + "dgp_kwargs, fit_kwargs, floor", + [ + # measured max|within - pooled| = 0.2092 on seed 42 -> floor an + # order of magnitude below, orders above numerical noise + (dict(unbalanced=True), {}, 0.02), + # measured 0.0214 on seed 42 + (dict(covariate=True), dict(covariates=["x"]), 0.002), + ], + ids=["unbalanced", "covariate"], + ) + def test_b_divergence_locks_estimate_shift(self, dgp_kwargs, fit_kwargs, floor): + """(b) unbalanced-or-covariate: within != pooled (the documented shift).""" + df = _panel(**dgp_kwargs) + w = TwoWayFixedEffects().fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + **fit_kwargs, + ) + p = TwoWayFixedEffects().fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + **fit_kwargs, + ) + assert np.nanmax(np.abs(w.att - p.att)) > floor + + def test_c_pooled_parity_unitless_repeated_cross_sections(self, panel): + """(c)(i) unit-less pooled == 3.x MPD default lane, BIT-EXACT. + + Same code path in the same process (the shared core), so exact + equality is the deliberate bar - the design's "reproduced exactly" + gate, not a cross-implementation comparison. + """ + s = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + m = _from_mpd(_mpd().fit(panel, "y", "treated", "period", post_periods=_post())) + for field in ("att", "se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper"): + _eq_with_nans(getattr(s, field), getattr(m, field)) + np.testing.assert_array_equal(s.event_time, m.event_time) + assert s.reference_period == m.reference_period + _eq_with_nans(s.vcov, m.vcov) + + def test_c_pooled_parity_explicit_cluster(self, panel): + """(c)(ii) pooled + matched explicit cluster= == MPD, BIT-EXACT.""" + s = TwoWayFixedEffects(cluster="unit").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + m = _from_mpd( + _mpd(cluster="unit").fit(panel, "y", "treated", "period", post_periods=_post()) + ) + for field in ("att", "se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper"): + _eq_with_nans(getattr(s, field), getattr(m, field)) + _eq_with_nans(s.vcov, m.vcov) + + def test_c_pooled_parity_hc2_bm_per_period_df(self, panel): + """(c)(iii) hc2_bm: the per-period BM-DOF df channel round-trips.""" + s = TwoWayFixedEffects(vcov_type="hc2_bm").fit( + panel, + "y", + "treated", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + m = _from_mpd( + _mpd(vcov_type="hc2_bm").fit(panel, "y", "treated", "period", post_periods=_post()) + ) + _eq_with_nans(s.df, m.df) + # the df column is the finite per-period BM-DOF channel (on this + # balanced DGP the per-period DOFs coincide numerically; the parity + # equality above is the load-bearing pin) + assert np.isfinite(np.asarray(s.df)[~s.is_reference]).all() + for field in ("att", "se", "p_value"): + _eq_with_nans(getattr(s, field), getattr(m, field)) + + @pytest.mark.parametrize( + "est_kwargs", + [dict(cluster="unit"), dict(vcov_type="hc2")], + ids=["explicit-cluster", "explicit-hc2-oneway"], + ) + def test_d_within_matches_mpd_absorb_unit(self, panel, est_kwargs): + """(d) within == MPD.fit(absorb=[unit]) under MATCHED inference. + + The designs coincide (MPD's absorb path snaps the unit-absorbed D + column and rank-drops it; on the hc2 lane the absorb -> + fixed_effects auto-route drops a redundant column instead - same + column space either way), so the interaction block's quintet, ES + vcov, and per-row df agree. Matched-inference lanes only: within + auto-clusters at unit while MPD never auto-clusters, so their + DEFAULTS diverge by construction (no "plain hc1" lane exists). + """ + w = TwoWayFixedEffects(**est_kwargs).fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + m = _from_mpd( + _mpd(**est_kwargs).fit( + panel, "y", "treated", "period", post_periods=_post(), absorb=["unit"] + ) + ) + for field in ("att", "se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper"): + _close_with_nans(getattr(w, field), getattr(m, field), atol=1e-12, rtol=1e-12) + _close_with_nans(w.vcov, m.vcov, atol=1e-12, rtol=1e-12) + _eq_with_nans(w.df, m.df) + + +# --------------------------------------------------------------------------- +# Mode validation + the rename shim +# --------------------------------------------------------------------------- + + +class TestModeValidation: + def test_bad_spec_rejected_any_mode(self, panel): + with pytest.raises( + ValueError, match=re.escape("spec must be one of ('within', 'pooled'), got 'bogus'") + ): + TwoWayFixedEffects().fit( + panel, "y", "treated", event_study=True, time="period", spec="bogus" + ) + df = _static_df(panel) + with pytest.raises(ValueError, match=re.escape("got 'bogus'")): + TwoWayFixedEffects().fit(df, "y", "treated", post="post", unit="unit", spec="bogus") + + @pytest.mark.parametrize("vcov_type", ["hc2", "hc2_bm"]) + @pytest.mark.parametrize("missing", ["unit", "period"]) + def test_hc2_preflight_missing_columns_get_estimator_errors(self, panel, vcov_type, missing): + """R7: the within HC2/HC2-BM memory preflight must not preempt + column validation - a missing unit/calendar column raises the + core's normal ValueError, never a raw pandas KeyError.""" + df = panel.drop(columns=[missing]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises(ValueError): + TwoWayFixedEffects(vcov_type=vcov_type).fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + + def test_post_periods_required_in_event_study(self, panel): + """R4: the calendar partition is explicit - omitting (or emptying) + post_periods= fails loud instead of inheriting the MPD midpoint + guess (the boundary is unobservable from time-invariant D_i).""" + for bad_kwargs in ({}, dict(post_periods=[])): + with pytest.raises(ValueError, match=re.escape(ES_POST_PERIODS_MSG)): + TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + **bad_kwargs, + ) + # MPD's own midpoint default is UNCHANGED (compatibility bar) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + m = _mpd().fit(panel, "y", "treated", "period") + assert np.isfinite(m.avg_att) + # duplicates fail at the front door, before the regression runs + # (R10 - the container's own validation would reject only at + # conversion time) + with pytest.raises(ValueError, match="duplicate labels"): + TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=[3, 3, 4], + ) + # a one-shot iterable is materialized once, not exhausted by the + # emptiness check (R8) + gen = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=(p for p in _post()), + ) + assert gen.post_periods == tuple(_post()) + + @pytest.mark.parametrize( + "kwargs, names", + [ + (dict(spec="pooled"), "spec"), + (dict(reference_period=2), "reference_period"), + (dict(post_periods=[3, 4, 5]), "post_periods"), + ], + ) + def test_static_rejects_event_study_params(self, panel, kwargs, names): + df = _static_df(panel) + with pytest.raises(ValueError, match=rf"{names}.*require\(s\) event_study=True"): + TwoWayFixedEffects().fit(df, "y", "treated", post="post", unit="unit", **kwargs) + + def test_within_requires_unit(self, panel): + with pytest.raises(ValueError, match=re.escape(WITHIN_UNIT_MSG)): + TwoWayFixedEffects().fit(panel, "y", "treated", event_study=True, time="period") + + def test_event_study_rejects_post(self, panel): + with pytest.raises(ValueError, match=re.escape(ES_POST_MSG)): + TwoWayFixedEffects().fit( + panel, "y", "treated", post="period", unit="unit", event_study=True, spec="pooled" + ) + + def test_event_study_positional_slot4_lands_in_post(self, panel): + # A positional 4th argument is the static post slot; under + # event_study=True it hits the post= rejection steering to time=. + with pytest.raises(ValueError, match=re.escape(ES_POST_MSG)): + TwoWayFixedEffects().fit( + panel, "y", "treated", "period", "unit", event_study=True, spec="pooled" + ) + + def test_event_study_missing_time_raises(self, panel): + with pytest.raises( + TypeError, match=re.escape("TwoWayFixedEffects.fit() missing required argument: 'time'") + ): + TwoWayFixedEffects().fit(panel, "y", "treated", unit="unit", event_study=True) + + def test_static_missing_post_raises(self, panel): + with pytest.raises( + TypeError, match=re.escape("TwoWayFixedEffects.fit() missing required argument: 'post'") + ): + TwoWayFixedEffects().fit(panel, "y", "treated") + + def test_static_missing_unit_raises(self, panel): + df = _static_df(panel) + with pytest.raises( + TypeError, match=re.escape("TwoWayFixedEffects.fit() missing required argument: 'unit'") + ): + TwoWayFixedEffects().fit(df, "y", "treated", post="post") + + +class TestRenameShim: + def test_canonical_post_silent(self, panel): + df = _static_df(panel) + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + r_kw = TwoWayFixedEffects().fit(df, "y", "treated", post="post", unit="unit") + r_pos = TwoWayFixedEffects().fit(df, "y", "treated", "post", "unit") + assert not [w for w in rec if issubclass(w.category, FutureWarning)] + assert r_kw.att == r_pos.att + + def test_time_warns_and_routes_identically(self, panel): + df = _static_df(panel) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = TwoWayFixedEffects().fit(df, "y", "treated", post="post", unit="unit") + with pytest.warns(FutureWarning, match=re.escape(RENAME_MSG)): + r_old = TwoWayFixedEffects().fit(df, "y", "treated", time="post", unit="unit") + assert r_old.att == r_new.att + assert r_old.se == r_new.se + assert r_old.t_stat == r_new.t_stat + assert r_old.p_value == r_new.p_value + assert r_old.conf_int == r_new.conf_int + + def test_both_supplied_raises(self, panel): + df = _static_df(panel) + with pytest.raises(ValueError, match=r"pass only post="): + TwoWayFixedEffects().fit(df, "y", "treated", post="post", time="post", unit="unit") + + def test_event_study_time_emits_no_rename_warning(self, panel): + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + assert not [w for w in rec if "time=" in str(w.message) and "deprecated" in str(w.message)] + + +# --------------------------------------------------------------------------- +# Wild raise + auto-cluster carve-outs +# --------------------------------------------------------------------------- + + +class TestWildRaise: + @pytest.mark.parametrize("n_bootstrap", [0, 1, 999]) + def test_wild_raises_at_any_n_bootstrap(self, panel, n_bootstrap): + est = TwoWayFixedEffects(inference="wild_bootstrap", n_bootstrap=n_bootstrap) + with pytest.raises(ValueError, match=re.escape(ES_WILD_MSG)): + est.fit(panel, "y", "treated", unit="unit", event_study=True, time="period") + + def test_wild_raise_precedes_survey_front_door(self, panel): + from diff_diff import SurveyDesign + + df = panel.assign(w=1.0) + est = TwoWayFixedEffects(inference="wild_bootstrap") + with pytest.raises(ValueError, match=re.escape(ES_WILD_MSG)): + est.fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + survey_design=SurveyDesign(weights="w"), + ) + + def test_wild_raise_precedes_conley_front_door(self, panel): + df = panel.assign(lat=40.0, lon=-100.0) + est = TwoWayFixedEffects( + inference="wild_bootstrap", + vcov_type="conley", + conley_coords=("lat", "lon"), + conley_cutoff_km=100.0, + conley_lag_cutoff=1, + ) + with pytest.raises(ValueError, match=re.escape(ES_WILD_MSG)): + est.fit(df, "y", "treated", unit="unit", event_study=True, time="period") + + +class TestAutoCluster: + def test_auto_cluster_equals_explicit_unit(self, panel): + r_auto = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + r_expl = TwoWayFixedEffects(cluster="unit").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + _eq_with_nans(r_auto.se, r_expl.se) + + def test_pooled_without_unit_stays_one_way(self, panel): + """No unit id -> no auto-cluster; == the MPD default (hc1) lane.""" + r = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + m = _from_mpd(_mpd().fit(panel, "y", "treated", "period", post_periods=_post())) + _eq_with_nans(r.se, m.se) + + def test_explicit_one_way_hc2_drops_auto_cluster(self, panel): + """The one-way exception mirror: hc2 + analytical == MPD hc2.""" + r = TwoWayFixedEffects(vcov_type="hc2").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + m = _from_mpd( + _mpd(vcov_type="hc2").fit(panel, "y", "treated", "period", post_periods=_post()) + ) + _eq_with_nans(r.se, m.se) + + def test_conley_carve_out_drops_auto_cluster(self): + """ES pooled + unit + conley + cluster=None == MPD conley cluster=None + (auto-cluster dropped - no implicit spatial x unit product kernel); + an explicit cluster= legitimately combines and differs.""" + rng = np.random.default_rng(3) + rows = [] + for u in range(30): + ti = 1 if u < 15 else 0 + lat, lon = rng.uniform(30, 45), rng.uniform(-100, -80) + for p in range(5): + rows.append( + dict( + unit=u, + period=p, + treated=ti, + lat=lat, + lon=lon, + y=rng.normal() + 0.6 * ti * (p >= 3), + ) + ) + df = pd.DataFrame(rows) + kw = dict( + vcov_type="conley", + conley_coords=("lat", "lon"), + conley_cutoff_km=500.0, + conley_lag_cutoff=1, + ) + r = TwoWayFixedEffects(**kw).fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=[3, 4], + ) + m = _from_mpd( + _mpd(**kw).fit(df, "y", "treated", "period", post_periods=[3, 4], unit="unit") + ) + _eq_with_nans(r.se, m.se) + _eq_with_nans(r.vcov, m.vcov) + r_ex = TwoWayFixedEffects(cluster="unit", **kw).fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=[3, 4], + ) + assert not np.allclose(np.nan_to_num(r_ex.se), np.nan_to_num(r.se)) + # within smoke: carve-out lane runs with finite inference + w = TwoWayFixedEffects(**kw).fit( + df, "y", "treated", unit="unit", event_study=True, time="period", post_periods=[3, 4] + ) + assert np.isfinite(np.asarray(w.se)[~w.is_reference]).all() + + def test_survey_carve_out_no_implicit_psu(self, panel): + """ES pooled + unit + no-PSU survey == MPD survey cluster=None + (implicit per-observation PSUs - the auto-cluster is never injected); + explicit cluster= injects the PSU and changes the SEs (matching + MPD's own explicit-cluster behavior). Both lanes use a survey design + WITHOUT its own PSU: when survey_design.psu is set, the shared + resolver gives the PSU precedence over any cluster on every class.""" + from diff_diff import SurveyDesign + + rng = np.random.default_rng(11) + df = panel.assign(w=rng.uniform(0.5, 2.0, len(panel))) + r = TwoWayFixedEffects().fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + survey_design=SurveyDesign(weights="w"), + ) + m = _from_mpd( + _mpd().fit( + df, + "y", + "treated", + "period", + post_periods=_post(), + survey_design=SurveyDesign(weights="w"), + ) + ) + _eq_with_nans(r.se, m.se) + r_ex = TwoWayFixedEffects(cluster="unit").fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + survey_design=SurveyDesign(weights="w"), + ) + assert not np.allclose(np.nan_to_num(r_ex.se), np.nan_to_num(r.se)) + + +# --------------------------------------------------------------------------- +# Deprecation choreography (M-010 warning; M-060 rides it) +# --------------------------------------------------------------------------- + + +class TestDeprecation: + def test_mpd_construction_warns_pinned_message(self): + with pytest.warns(FutureWarning, match=re.escape(MPD_DEPRECATION_MSG)): + MultiPeriodDiD() + + def test_event_study_alias_warns_and_is_same_class(self): + assert EventStudy is MultiPeriodDiD + with pytest.warns(FutureWarning, match=re.escape(MPD_DEPRECATION_MSG)): + EventStudy() + + def test_mpd_warns_and_still_works(self, panel): + with pytest.warns(FutureWarning, match=re.escape(MPD_DEPRECATION_MSG)): + est = MultiPeriodDiD() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r = est.fit(panel, "y", "treated", "period", post_periods=_post()) + assert np.isfinite(r.avg_att) + + def test_successors_do_not_warn(self): + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + TwoWayFixedEffects() + DifferenceInDifferences() + assert not [w for w in rec if issubclass(w.category, FutureWarning)] + + def test_mpd_introspection_intact(self): + """The __signature__ mirror keeps the BaseEstimator contract.""" + m = _mpd() + d = DifferenceInDifferences() + assert set(m.get_params()) == set(d.get_params()) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + m2 = m.set_params(alpha=0.10) + assert m2.get_params()["alpha"] == 0.10 + + +# --------------------------------------------------------------------------- +# The unified surface contract +# --------------------------------------------------------------------------- + + +class TestSurfaceContract: + def test_non_midpoint_partition_selects_correct_reference(self): + """R4: 8 periods with a declared boundary at period 2 - the + explicit partition [2..7] selects reference 1 (last pre). The + rejected midpoint guess would have partitioned at 4 and + misclassified periods 2-3 as pre-treatment.""" + df = _panel(n_periods=8) + s = TwoWayFixedEffects().fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=list(range(2, 8)), + ) + assert s.post_periods == tuple(range(2, 8)) + assert s.reference_period == 1 + ref_mask = np.asarray(s.is_reference) + assert s.event_time[ref_mask].tolist() == [1] + + def test_surface_provenance(self, panel): + s = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + assert isinstance(s, EventStudyResults) + assert s.time_scale == "calendar" + assert s.source == "TwoWayFixedEffects" + assert s.estimation_spec == "within" + assert s.post_periods == tuple(_post()) + assert s.reference_period == 2 # default: last pre-period (e=-1) + assert bool(s.is_reference[np.asarray(s.event_time) == 2][0]) + assert s.vcov is not None and s.vcov_index is not None + p = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + assert p.estimation_spec == "pooled" + + def test_explicit_reference_period_honored(self, panel): + s = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + reference_period=1, + ) + assert s.reference_period == 1 + + def test_no_legacy_reference_warning_in_event_study_mode(self, panel): + """The M-007 transition warning is MPD-only (v4-design section 4.1).""" + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + assert not [w for w in rec if "reference_period has changed" in str(w.message)] + with warnings.catch_warnings(record=True) as rec2: + warnings.simplefilter("always") + _mpd().fit(panel, "y", "treated", "period", post_periods=_post()) + assert [w for w in rec2 if "reference_period has changed" in str(w.message)] + + def test_renderers_run(self, panel): + s = TwoWayFixedEffects().fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + assert "TwoWayFixedEffects" in s.summary() or len(s.summary()) > 0 + df = s.to_dataframe() + assert len(df) == 6 + d = s.to_dict() + assert d["estimation_spec"] == "within" + assert d["post_periods"] == list(_post()) + + def test_refit_static_then_event_study_and_back(self, panel): + """Mode transitions on one estimator leave no stale state.""" + est = TwoWayFixedEffects() + df = _static_df(panel) + r1 = est.fit(df, "y", "treated", post="post", unit="unit") + assert hasattr(r1, "att") and np.isfinite(r1.att) + s = est.fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + assert isinstance(s, EventStudyResults) + assert est.results_ is s + r2 = est.fit(df, "y", "treated", post="post", unit="unit") + assert r2.att == r1.att + assert est.results_ is r2 + + def test_survey_replicate_within_matches_mpd_absorb(self, panel, ci_params): + """Numerical replicate-lane pin: the include_treatment_main threading + through the absorb replicate-refit closure produces the same numbers + as MPD.fit(absorb=[unit]) under matched explicit cluster.""" + from diff_diff import SurveyDesign + + rng = np.random.default_rng(5) + df = panel.assign(w=rng.uniform(0.5, 2.0, len(panel))) + n_rep = max(4, min(8, ci_params.bootstrap(8))) + rep_cols = {} + for j in range(n_rep): + rep_cols[f"rw{j}"] = df["w"] * rng.uniform(0.5, 1.5, len(df)) + df = df.assign(**rep_cols) + sd_kwargs = dict( + weights="w", + replicate_weights=[f"rw{j}" for j in range(n_rep)], + replicate_method="JK1", + ) + w = TwoWayFixedEffects(cluster="unit").fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + survey_design=SurveyDesign(**sd_kwargs), + ) + m = _from_mpd( + _mpd(cluster="unit").fit( + df, + "y", + "treated", + "period", + post_periods=_post(), + absorb=["unit"], + survey_design=SurveyDesign(**sd_kwargs), + ) + ) + for field in ("att", "se", "p_value"): + _close_with_nans(getattr(w, field), getattr(m, field), atol=1e-12, rtol=1e-12) + + +# --------------------------------------------------------------------------- +# Inference integrity: joint-NaN, estimator_name, warning attribution +# --------------------------------------------------------------------------- + + +class TestInferenceIntegrity: + def test_within_rank_deficient_period_jointly_nan(self): + """A period with no treated observations drops its interaction: that + row's FULL inference tuple is jointly NaN; identified rows finite.""" + rng = np.random.default_rng(7) + rows = [] + for u in range(30): + ti = 1 if u < 15 else 0 + for p in range(5): + if ti == 1 and p == 1: + continue # no treated obs in period 1 -> interaction drops + rows.append( + dict(unit=u, period=p, treated=ti, y=rng.normal() + 0.5 * ti * (p >= 3)) + ) + df = pd.DataFrame(rows) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + s = TwoWayFixedEffects(rank_deficient_action="silent").fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=[3, 4], + ) + et = np.asarray(s.event_time) + bad = (et == 1) & (~s.is_reference) + good = (~s.is_reference) & (et != 1) + assert bad.sum() == 1 + for field in ("se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper"): + arr = np.asarray(getattr(s, field)) + assert np.isnan(arr[bad]).all(), f"{field} not NaN on the dropped row" + assert np.isfinite(arr[good]).all(), f"{field} not finite on identified rows" + assert np.isnan(np.asarray(s.df)[bad]).all() + + def test_estimator_name_threading(self, panel): + """TWFE event-study messages name TwoWayFixedEffects, never the + deprecated class; MPD's own messages are bit-identical legacy.""" + stag = panel.copy() + # two adoption cohorts among former controls (0->1 transitions at + # periods 1 and 3) so the staggered-adoption advisory fires + stag.loc[(stag.unit >= 30) & (stag.unit < 33) & (stag.period >= 1), "treated"] = 1 + stag.loc[(stag.unit >= 33) & (stag.unit < 36) & (stag.period >= 3), "treated"] = 1 + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + TwoWayFixedEffects().fit( + stag, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + adv = [w for w in rec if "simultaneous adoption" in str(w.message)] + assert adv and "TwoWayFixedEffects" in str(adv[0].message) + assert "MultiPeriodDiD" not in str(adv[0].message) + with warnings.catch_warnings(record=True) as rec2: + warnings.simplefilter("always") + _mpd().fit(stag, "y", "treated", "period", post_periods=_post(), unit="unit") + adv2 = [w for w in rec2 if "simultaneous adoption" in str(w.message)] + assert adv2 and "MultiPeriodDiD" in str(adv2[0].message) + # validation errors carry the producer too + with pytest.raises(ValueError, match="TwoWayFixedEffects"): + TwoWayFixedEffects().fit( + panel.assign(const=1.0), + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + covariates=["const"], + ) + + def test_staggered_timing_undetectable_under_time_invariant_di(self, panel): + """Documented detection limit (REGISTRY 'staggered-adoption + detection limit' Notes; TODO.md 3(a) R2 cohort-timing row): with + the contract-valid time-invariant ever-treated D_i, adoption + timing is not observable in the inputs, so a genuinely staggered + two-cohort design fits with NO staggered advisory and NO D_it + warning. This pins the documented limitation so the behavior + change is visible when the cohort= validation input lands.""" + stag = panel.copy() + # two cohorts adopting at periods 1 and 3 - but encoded as the + # documented ever-treated D_i (1 in ALL periods for both cohorts), + # so the timing difference never appears in the treatment column + stag.loc[(stag.unit >= 30) & (stag.unit < 36), "treated"] = 1 + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + res = TwoWayFixedEffects().fit( + stag, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + assert not any("simultaneous adoption" in str(w.message) for w in rec) + assert not any("varies within units" in str(w.message) for w in rec) + assert np.all(np.isfinite(res.att)) + + def test_singleton_units_retained_class_consistently(self, panel): + """R5: singleton units are RETAINED, not dropped - the REGISTRY + 'Deviation from R' Note (reghdfe iteratively drops singletons, + fixest retains them; diff-diff matches the fixest default, + class-wide). The singleton's unit-demeaned row is zero, so + event-study points are unchanged while N/G/df count it (SEs + shift); the within spec inherits the class behavior EXACTLY - + bit-parity with the same-data MPD absorb fit holds ON the + singleton fixture, so the new mode introduces no divergence. + Opt-in pruning is the TODO.md 3(a) R5 row.""" + single = pd.DataFrame([dict(unit=999, period=0, treated=0, x=0.0, y=0.0)]) + aug = pd.concat([panel, single], ignore_index=True) + base = TwoWayFixedEffects(cluster="unit").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + with_s = TwoWayFixedEffects(cluster="unit").fit( + aug, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + ) + _close_with_nans(base.att, with_s.att, atol=1e-9, rtol=1e-9) + assert not np.allclose(np.nan_to_num(base.se), np.nan_to_num(with_s.se)) + m = _from_mpd( + _mpd(cluster="unit").fit( + aug, "y", "treated", "period", post_periods=_post(), absorb=["unit"] + ) + ) + for field in ("att", "se", "t_stat", "p_value"): + _close_with_nans(getattr(with_s, field), getattr(m, field), atol=1e-12, rtol=1e-12) + # static path: the same retained-singleton behavior (point pinned + # to ~ULP - the two-way alternating-projection demeaning remixes + # means - while the SE visibly shifts with N/G/df) + st = _static_df(panel) + st_aug = pd.concat([st, single.assign(post=0)], ignore_index=True) + s0 = TwoWayFixedEffects().fit(st, "y", "treated", post="post", unit="unit") + s1 = TwoWayFixedEffects().fit(st_aug, "y", "treated", post="post", unit="unit") + assert np.isclose(s0.att, s1.att) + assert s0.se != s1.se + + def test_warning_attribution_baselines(self, panel): + """The three A1 attribution pins: inline + snap -> USER file; + the solve_ols rank-deficiency chain -> estimators.py (preserved + library attribution; never a user-file pin on that class).""" + stag = panel.copy() + # two adoption cohorts among former controls (0->1 transitions at + # periods 1 and 3) so the staggered-adoption advisory fires + stag.loc[(stag.unit >= 30) & (stag.unit < 33) & (stag.period >= 1), "treated"] = 1 + stag.loc[(stag.unit >= 33) & (stag.unit < 36) & (stag.period >= 3), "treated"] = 1 + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + _mpd().fit(stag, "y", "treated", "period", post_periods=_post(), unit="unit") + inline = [w for w in rec if "simultaneous adoption" in str(w.message)] + assert inline and inline[0].filename.endswith("test_v4_merge_mpd.py") + # snap (class-(i)-via-helper): unit-constant covariate under absorb + df2 = panel.assign(ucov=(panel["unit"] % 3).astype(float)) + with warnings.catch_warnings(record=True) as rec2: + warnings.simplefilter("always") + _mpd().fit( + df2, + "y", + "treated", + "period", + post_periods=_post(), + absorb=["unit"], + covariates=["ucov"], + reference_period=2, + ) + snap = [w for w in rec2 if "collinear with the absorbed" in str(w.message)] + assert snap and snap[0].filename.endswith("test_v4_merge_mpd.py") + rank = [w for w in rec2 if "Rank-deficient design matrix" in str(w.message)] + assert rank and rank[0].filename.endswith("estimators.py") + # the TWFE event-study path preserves the same attribution classes + with warnings.catch_warnings(record=True) as rec3: + warnings.simplefilter("always") + TwoWayFixedEffects().fit( + df2, + "y", + "treated", + unit="unit", + event_study=True, + time="period", + post_periods=_post(), + covariates=["ucov"], + reference_period=2, + ) + snap3 = [w for w in rec3 if "collinear with the absorbed" in str(w.message)] + assert snap3 and snap3[0].filename.endswith("test_v4_merge_mpd.py") + + +# --------------------------------------------------------------------------- +# Consumers +# --------------------------------------------------------------------------- + + +class TestConsumers: + def _surfaces(self, panel, **mpd_kwargs): + s = TwoWayFixedEffects(cluster="unit").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + ) + m = _mpd(cluster="unit").fit(panel, "y", "treated", "period", post_periods=_post()) + return s, m + + def test_honest_did_parity_standard_geometry(self, panel): + s, m = self._surfaces(panel) + for M in (0.5, 1.0): + hs = compute_honest_did(s, M=M) + hn = compute_honest_did(m, M=M) + assert hs.ci_lb == hn.ci_lb and hs.ci_ub == hn.ci_ub + assert hs.pre_periods_used == hn.pre_periods_used + + def test_honest_did_survey_df_scalar_threads(self, panel): + """Survey-backed lane: the df_survey scalar that moves FLCI critical + values threads identically through the calendar route.""" + from diff_diff import SurveyDesign + + rng = np.random.default_rng(13) + df = panel.assign(w=rng.uniform(0.5, 2.0, len(panel))) + s = TwoWayFixedEffects().fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=_post(), + survey_design=SurveyDesign(weights="w"), + ) + m = _mpd().fit( + df, + "y", + "treated", + "period", + post_periods=_post(), + survey_design=SurveyDesign(weights="w"), + ) + hs = compute_honest_did(s, M=1.0) + hn = compute_honest_did(m, M=1.0) + assert hs.ci_lb == hn.ci_lb and hs.ci_ub == hn.ci_ub + + @pytest.mark.parametrize( + "fit_kwargs", + [ + dict(post_periods=[2, 5], reference_period=4), + dict(post_periods=[3, 4, 5], reference_period=1), + ], + ids=["non-suffix", "non-last-reference"], + ) + def test_honest_did_rejects_non_chronological_geometry(self, panel, fit_kwargs): + s = TwoWayFixedEffects(cluster="unit").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + **fit_kwargs, + ) + with pytest.raises(ValueError, match="Registry-valid chronological geometry"): + compute_honest_did(s, M=1.0) + + def test_honest_did_rejects_missing_provenance_and_multi_reference(self): + base = dict( + att=[0.1, 0.0, 0.5, 0.6], + se=[0.1, np.nan, 0.1, 0.1], + t_stat=[1.0, np.nan, 5.0, 6.0], + p_value=[0.3, np.nan, 0.01, 0.01], + conf_int_lower=[0.0] * 4, + conf_int_upper=[1.0] * 4, + n=[np.nan] * 4, + time_scale="calendar", + source="TwoWayFixedEffects", + ) + no_prov = EventStudyResults( + event_time=[0, 1, 2, 3], is_reference=[False, True, False, False], **base + ) + with pytest.raises(TypeError, match="post_periods partition provenance"): + compute_honest_did(no_prov, M=1.0) + with pytest.raises(TypeError, match="post_periods partition provenance"): + compute_pretrends_power(no_prov, M=0.3) + multi_base = dict(base) + multi_base.update( + att=[0.0, 0.0, 0.5, 0.6], + se=[np.nan, np.nan, 0.1, 0.1], + t_stat=[np.nan, np.nan, 5.0, 6.0], + p_value=[np.nan, np.nan, 0.01, 0.01], + ) + multi_ref = EventStudyResults( + event_time=[0, 1, 2, 3], + is_reference=[True, True, False, False], + post_periods=(2, 3), + **multi_base, + ) + with pytest.raises(ValueError, match="exactly one reference row"): + compute_honest_did(multi_ref, M=1.0) + with pytest.raises(ValueError, match="exactly one reference row"): + compute_pretrends_power(multi_ref, M=0.3) + + def test_calendar_route_rejects_foreign_source(self): + foreign = EventStudyResults( + event_time=[0, 1, 2], + att=[0.0, 0.5, 0.6], + se=[np.nan, 0.1, 0.1], + t_stat=[np.nan, 5.0, 6.0], + p_value=[np.nan, 0.01, 0.01], + conf_int_lower=[0.0] * 3, + conf_int_upper=[1.0] * 3, + is_reference=[True, False, False], + n=[np.nan] * 3, + time_scale="calendar", + source="SomethingElse", + post_periods=(1, 2), + ) + with pytest.raises(TypeError, match="TwoWayFixedEffects event-study mode"): + compute_honest_did(foreign, M=1.0) + with pytest.raises(TypeError, match="TwoWayFixedEffects event-study mode"): + compute_pretrends_power(foreign, M=0.3) + + def test_no_unknown_provenance_warning_on_first_party_surface(self, panel): + s, _ = self._surfaces(panel) + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + compute_honest_did(s, M=1.0) + assert not [w for w in rec if "provenance" in str(w.message)] + + @pytest.mark.parametrize( + "fit_kwargs", + [ + dict(post_periods=[3, 4, 5]), + dict(post_periods=[2, 5], reference_period=4), + dict(post_periods=[3, 4, 5], reference_period=1), + ], + ids=["suffix", "non-suffix", "non-last-reference"], + ) + def test_pretrends_parity_wald(self, panel, fit_kwargs): + """PreTrendsPower is NOT geometry-scoped: all three lanes are parity + lanes (wald form - the nis box probability is internally randomized).""" + s = TwoWayFixedEffects(cluster="unit").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + **fit_kwargs, + ) + m = _mpd(cluster="unit").fit(panel, "y", "treated", "period", **fit_kwargs) + a = compute_pretrends_power(s, M=0.3, pretest_form="wald") + b = compute_pretrends_power(m, M=0.3, pretest_form="wald") + assert a.power == b.power and a.mdv == b.mdv + assert a.covariance_source == b.covariance_source == "full_pre_period_vcov" + + def test_pretrends_string_labels_degrade_like_native(self): + """String calendar labels reproduce the native route's gamma-unit + degradation warning - never silence, never bypass.""" + df = _panel(str_periods=True) + posts = _post(str_periods=True) + s = TwoWayFixedEffects(cluster="unit").fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=posts, + ) + m = _mpd(cluster="unit").fit(df, "y", "treated", "period", post_periods=posts) + with warnings.catch_warnings(record=True) as r1: + warnings.simplefilter("always") + q1 = compute_pretrends_power(s, M=0.3, pretest_form="wald") + with warnings.catch_warnings(record=True) as r2: + warnings.simplefilter("always") + q2 = compute_pretrends_power(m, M=0.3, pretest_form="wald") + w1 = sorted(str(w.message) for w in r1 if "reference_period" in str(w.message)) + w2 = sorted(str(w.message) for w in r2 if "reference_period" in str(w.message)) + assert w1 == w2 and len(w1) == 1 + assert (q1.mdv == q2.mdv) or (np.isnan(q1.mdv) and np.isnan(q2.mdv)) + + def test_pretrends_explicit_pre_periods_validated(self, panel): + """R8: an explicit pre_periods= selection on the calendar route + is VALIDATED and chronologically ordered (the relative container + route's contract) - unknown labels, the reference row, + duplicates all fail loud, and a reversed-but-valid selection + gives the identical positional analysis (last_period targets the + chronological grid, not argument order).""" + s, _ = self._surfaces(panel) + with pytest.raises(ValueError, match="not eligible pre-treatment periods"): + compute_pretrends_power(s, M=0.3, pre_periods=[0, 999], pretest_form="wald") + with pytest.raises(ValueError, match="not eligible pre-treatment periods"): + # period 2 is the reference row + compute_pretrends_power(s, M=0.3, pre_periods=[0, 2], pretest_form="wald") + with pytest.raises(ValueError, match="duplicate labels"): + compute_pretrends_power(s, M=0.3, pre_periods=[0, 0], pretest_form="wald") + fwd = compute_pretrends_power( + s, M=0.3, pre_periods=[0, 1], violation_type="last_period", pretest_form="wald" + ) + rev = compute_pretrends_power( + s, M=0.3, pre_periods=[1, 0], violation_type="last_period", pretest_form="wald" + ) + assert fwd.power == rev.power and fwd.mdv == rev.mdv + # R9: a NaN coefficient beside a finite SE (hand-built shape - + # producer rows are NaN-consistent) is ineligible: dropped from + # automatic selection, rejected when explicitly requested + import dataclasses + + row_of = {t: i for i, t in enumerate(s.event_time.tolist())} + att_bad = np.asarray(s.att, dtype=float).copy() + att_bad[row_of[0]] = np.nan + bad = dataclasses.replace(s, att=att_bad) + auto = compute_pretrends_power(bad, M=0.3, pretest_form="wald") + assert np.isfinite(auto.power) # period 0 excluded, period 1 carries + with pytest.raises(ValueError, match="not eligible pre-treatment periods"): + compute_pretrends_power(bad, M=0.3, pre_periods=[0, 1], pretest_form="wald") + + def test_honest_string_labels_warn_ambiguous_chronology(self): + """R6: string calendar labels cannot prove chronology - the + HonestDiD calendar route warns loudly (sorted() order is only + ASSUMED; unpadded numeric suffixes would reorder, silently + shifting the positional l_vec target) while staying + native-route consistent: the fit itself applied the same + sorted() rule, so both routes share one ordering.""" + df = _panel(str_periods=True) + posts = _post(str_periods=True) + s = TwoWayFixedEffects(cluster="unit").fit( + df, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=posts, + ) + m = _mpd(cluster="unit").fit(df, "y", "treated", "period", post_periods=posts) + with pytest.warns(UserWarning, match="STRING calendar labels"): + hs = compute_honest_did(s, M=1.0) + hn = compute_honest_did(m, M=1.0) + assert hs.ci_lb == hn.ci_lb and hs.ci_ub == hn.ci_ub + + def test_calendar_vcov_integrity_rejections(self, panel): + """R3 hardening: the calendar routes share the relative container + path's vcov integrity contract instead of silently degrading to + diag(se^2) - duplicate/incomplete vcov_index and malformed + sub-blocks fail loud; HonestDiD additionally rejects singular + blocks (allow_singular=False) while PreTrendsPower keeps its + documented singular handling.""" + import dataclasses + + s, _ = self._surfaces(panel) + labels = list(s.vcov_index.tolist()) + row_of = {t: i for i, t in enumerate(s.event_time.tolist())} + + dup = dataclasses.replace(s, vcov_index=np.array([labels[0]] + labels[1:-1] + [labels[0]])) + for consumer in (compute_honest_did, compute_pretrends_power): + with pytest.raises(ValueError, match="carries duplicate"): + consumer(dup, M=0.5) + + # index/matrix shrunk together so the container validates, but the + # first pre-period label is gone from the covariance index + shrunk = dataclasses.replace( + s, + vcov=np.asarray(s.vcov)[1:, 1:], + vcov_index=np.array(labels[1:]), + ) + with pytest.raises(ValueError, match="retained horizon"): + compute_honest_did(shrunk, M=0.5) + with pytest.raises(ValueError, match="missing one of the pre-period labels"): + compute_pretrends_power(shrunk, M=0.5) + + asym = np.asarray(s.vcov, dtype=float).copy() + asym[0, 1] = asym[0, 1] + 0.01 # not mirrored + for consumer in (compute_honest_did, compute_pretrends_power): + with pytest.raises(ValueError, match="not symmetric"): + consumer(dataclasses.replace(s, vcov=asym), M=0.5) + + for consumer in (compute_honest_did, compute_pretrends_power): + with pytest.raises(ValueError, match="inconsistent with the stored standard"): + consumer(dataclasses.replace(s, vcov=np.asarray(s.vcov) * 4.0), M=0.5) + + # rank-1 PSD matrix with the exact se^2 diagonal: singular but + # otherwise well-formed + se_r = np.array([float(s.se[row_of[t]]) for t in labels]) + rank1 = dataclasses.replace(s, vcov=np.outer(se_r, se_r)) + with pytest.raises(ValueError, match="singular or near-singular"): + compute_honest_did(rank1, M=0.5) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + compute_pretrends_power(rank1, M=0.5) # documented singular handling + + def test_calendar_vcov_none_fallback_and_finite_effect_filter(self, panel): + """R3: a vcov-less container takes the WARNED diagonal fallback on + HonestDiD (the relative container path's message), and a non-finite + coefficient with a positive SE is filtered like the native MPD + branch - an interior NaN effect breaks the consecutive grid and + fails loud, a leading one is dropped safely.""" + import dataclasses + + s, _ = self._surfaces(panel) + bare = dataclasses.replace(s, vcov=None, vcov_index=None) + with pytest.warns(UserWarning, match="no full covariance matrix"): + hb = compute_honest_did(bare, M=0.5) + assert np.isfinite(hb.ci_lb) and np.isfinite(hb.ci_ub) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + compute_pretrends_power(bare, M=0.5) + + row_of = {t: i for i, t in enumerate(s.event_time.tolist())} + att_interior = np.asarray(s.att, dtype=float).copy() + att_interior[row_of[1]] = np.nan # interior pre-period (pre grid 0,1) + with pytest.raises(ValueError, match="consecutive estimated horizons"): + compute_honest_did(dataclasses.replace(s, att=att_interior), M=0.5) + + att_leading = np.asarray(s.att, dtype=float).copy() + att_leading[row_of[0]] = np.nan # leading pre-period: droppable + h_lead = compute_honest_did(dataclasses.replace(s, att=att_leading), M=0.5) + assert h_lead.pre_periods_used == [1] + + def test_honest_rejects_all_post_nan_surface(self, panel): + """R4: a surface whose every post-period row carries withheld + (NaN) inference passes the trailing-drop geometry but has no + sensitivity target - both the calendar route and the guard's + native sibling fail loud instead of handing num_post=0 to the + restriction system.""" + import dataclasses + + s, _ = self._surfaces(panel) + row_of = {t: i for i, t in enumerate(s.event_time.tolist())} + arrays = { + f: np.asarray(getattr(s, f), dtype=float).copy() + for f in ("att", "se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper") + } + for p_ in s.post_periods: + for f in arrays: + arrays[f][row_of[p_]] = np.nan + bad = dataclasses.replace(s, **arrays) + with pytest.raises(ValueError, match="No post-period effects with finite estimates"): + compute_honest_did(bad, M=0.5) + + def test_diagnostic_and_business_report_reject_surface(self, panel): + from diff_diff.business_report import BusinessReport + from diff_diff.diagnostic_report import DiagnosticReport + + s, _ = self._surfaces(panel) + with pytest.raises(TypeError, match="DiagnosticReport does not yet support"): + DiagnosticReport(s) + with pytest.raises(TypeError, match="BusinessReport does not yet support"): + BusinessReport(s) + + def test_plot_partition_aware_shading_both_renderers(self, panel): + matplotlib = pytest.importorskip("matplotlib") + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + from diff_diff.visualization import plot_event_study + from diff_diff.visualization._event_study import _pre_shading_runs + + assert _pre_shading_runs([0, 1, 3]) == [(0, 1), (3, 3)] + s = TwoWayFixedEffects(cluster="unit").fit( + panel, + "y", + "treated", + unit="unit", + event_study=True, + spec="pooled", + time="period", + post_periods=[2, 5], + reference_period=4, + ) + # pre = {0, 1, 3} (positions 0, 1, 3); post period 2 (position 2) + # must NOT be covered by any shaded span on either renderer. + plot_event_study(s) + ax = plt.gcf().axes[0] + spans = sorted((p.get_x(), p.get_x() + p.get_width()) for p in ax.patches) + assert spans == [(-0.5, 1.5), (2.5, 3.5)] + plt.close("all") + plotly = pytest.importorskip("plotly") + del plotly + fig = plot_event_study(s, backend="plotly") + vrects = sorted( + (sh.x0, sh.x1) for sh in fig.layout.shapes if getattr(sh, "type", "") == "rect" + ) + assert (-0.5, 1.5) in vrects and (2.5, 3.5) in vrects + assert not any(x0 < 2.0 < x1 for x0, x1 in vrects) diff --git a/tests/test_variance_conventions.py b/tests/test_variance_conventions.py index 5c9f9c119..9f86efb6d 100644 --- a/tests/test_variance_conventions.py +++ b/tests/test_variance_conventions.py @@ -189,7 +189,7 @@ def snapshot(self): dict( key="did_absorb_hc1_cluster_unit", fit=lambda df: diff_diff.DifferenceInDifferences(cluster="unit").fit( - df, outcome="y", treatment="grp", time="post", absorb=["unit", "time"] + df, outcome="y", treatment="grp", post="post", absorb=["unit", "time"] ), cr1_k=(7,), tail_df=(294.0,), @@ -202,7 +202,7 @@ def snapshot(self): dict( key="did_fixed_effects_hc1_cluster_unit", fit=lambda df: diff_diff.DifferenceInDifferences(cluster="unit").fit( - df, outcome="y", treatment="grp", time="post", fixed_effects=["unit", "time"] + df, outcome="y", treatment="grp", post="post", fixed_effects=["unit", "time"] ), cr1_k=(7,), tail_df=(294.0,), @@ -216,7 +216,7 @@ def snapshot(self): dict( key="did_plain_hc1_cluster_unit", fit=lambda df: diff_diff.DifferenceInDifferences(cluster="unit").fit( - df, outcome="y", treatment="grp", time="post" + df, outcome="y", treatment="grp", post="post" ), cr1_k=(4,), tail_df=(356.0,), @@ -226,7 +226,7 @@ def snapshot(self): dict( key="twfe_hc1_cluster_unit_time_post", fit=lambda df: diff_diff.TwoWayFixedEffects(vcov_type="hc1", cluster="unit").fit( - df, outcome="y", treatment="grp", time="post", unit="unit" + df, outcome="y", treatment="grp", post="post", unit="unit" ), cr1_k=(3,), tail_df=(298.0,), @@ -522,7 +522,7 @@ def test_capture_flags_non_hc1_clustered_family(monkeypatch): with warnings.catch_warnings(): warnings.simplefilter("ignore") diff_diff.DifferenceInDifferences(vcov_type="hc2_bm", cluster="unit").fit( - df, outcome="y", treatment="grp", time="post" + df, outcome="y", treatment="grp", post="post" ) assert cap.cr1_k == [] assert cap.unexpected_clustered, "non-hc1 clustered call was not flagged" @@ -541,10 +541,10 @@ def test_d1_convergence_is_pinned(): with warnings.catch_warnings(): warnings.simplefilter("ignore") a = diff_diff.DifferenceInDifferences(cluster="unit").fit( - df, outcome="y", treatment="grp", time="post", absorb=["unit", "time"] + df, outcome="y", treatment="grp", post="post", absorb=["unit", "time"] ) f = diff_diff.DifferenceInDifferences(cluster="unit").fit( - df, outcome="y", treatment="grp", time="post", fixed_effects=["unit", "time"] + df, outcome="y", treatment="grp", post="post", fixed_effects=["unit", "time"] ) np.testing.assert_allclose(a.att, f.att, rtol=0, atol=1e-10) np.testing.assert_allclose(f.se / a.se, 1.0, rtol=1e-9) @@ -842,10 +842,10 @@ def test_disconnected_end_to_end_did_absorb(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") r_un = diff_diff.DifferenceInDifferences(vcov_type="hc1").fit( - df, outcome="y", treatment="grp", time="post", absorb=["unit", "time"] + df, outcome="y", treatment="grp", post="post", absorb=["unit", "time"] ) r_cl = diff_diff.DifferenceInDifferences(cluster="unit").fit( - df, outcome="y", treatment="grp", time="post", absorb=["unit", "time"] + df, outcome="y", treatment="grp", post="post", absorb=["unit", "time"] ) # new count (adj=63): measured; old count (adj=64) would be ~0.44% larger np.testing.assert_allclose(r_un.se, 0.2811268249, rtol=1e-8) @@ -861,7 +861,7 @@ def test_disconnected_end_to_end_twfe_and_mpd(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") tw = diff_diff.TwoWayFixedEffects(vcov_type="hc1", cluster="unit").fit( - df, outcome="y", treatment="grp", time="time", unit="unit" + df, outcome="y", treatment="grp", post="time", unit="unit" ) mpd = diff_diff.MultiPeriodDiD(cluster="unit").fit( df, outcome="y", treatment="grp", time="time", absorb=["unit", "time"] @@ -904,7 +904,7 @@ def test_fail_closed_boundary_moves_both_directions(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") r = diff_diff.DifferenceInDifferences(vcov_type="hc1").fit( - tiny, outcome="y", treatment="grp", time="post", absorb=["unit", "time"] + tiny, outcome="y", treatment="grp", post="post", absorb=["unit", "time"] ) assert np.isfinite(r.se), "newly-finite direction: SE must be finite now" with warnings.catch_warnings(): @@ -913,7 +913,7 @@ def test_fail_closed_boundary_moves_both_directions(self): tiny.iloc[:-1], outcome="y", treatment="grp", - time="post", + post="post", absorb=["unit", "time"], ) assert not np.isfinite(r2.se), "saturated design must stay fail-closed NaN" @@ -1279,10 +1279,10 @@ def test_two_nested_dim_full_dummy_uses_rank_not_column_count(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") a = diff_diff.DifferenceInDifferences(cluster="state").fit( - df, outcome="y", treatment="grp", time="post", absorb=["unit", "state"] + df, outcome="y", treatment="grp", post="post", absorb=["unit", "state"] ) f = diff_diff.DifferenceInDifferences(cluster="state").fit( - df, outcome="y", treatment="grp", time="post", fixed_effects=["unit", "state"] + df, outcome="y", treatment="grp", post="post", fixed_effects=["unit", "state"] ) assert np.isfinite(a.se) and a.se > 0 np.testing.assert_allclose(f.se, a.se, rtol=1e-12) @@ -1311,7 +1311,7 @@ def _saturation_fit(self, df): with warnings.catch_warnings(): warnings.simplefilter("ignore") return diff_diff.DifferenceInDifferences(cluster="unit").fit( - df, outcome="y", treatment="grp", time="post", absorb=["unit", "time"] + df, outcome="y", treatment="grp", post="post", absorb=["unit", "time"] ) def test_clustered_fail_closed_below_the_boundary(self): @@ -1359,17 +1359,17 @@ def test_wcb_identity_and_p_invariance(self): warnings.simplefilter("ignore") r_ab = diff_diff.DifferenceInDifferences( cluster="unit", inference="wild_bootstrap", n_bootstrap=99, seed=42 - ).fit(df, outcome="y", treatment="grp", time="post", absorb=["unit", "time"]) + ).fit(df, outcome="y", treatment="grp", post="post", absorb=["unit", "time"]) r_fe = diff_diff.DifferenceInDifferences( cluster="unit", inference="wild_bootstrap", n_bootstrap=99, seed=42 - ).fit(df, outcome="y", treatment="grp", time="post", fixed_effects=["unit", "time"]) + ).fit(df, outcome="y", treatment="grp", post="post", fixed_effects=["unit", "time"]) r_tw = diff_diff.TwoWayFixedEffects( vcov_type="hc2", cluster="unit", inference="wild_bootstrap", n_bootstrap=99, seed=42, - ).fit(df, outcome="y", treatment="grp", time="post", unit="unit") + ).fit(df, outcome="y", treatment="grp", post="post", unit="unit") assert r_ab.se == np.sqrt(r_ab.vcov[3, 3]) assert r_fe.se == np.sqrt(r_fe.vcov[3, 3]) assert r_tw.se == np.sqrt(r_tw.vcov[1, 1]) diff --git a/tests/test_wild_bootstrap.py b/tests/test_wild_bootstrap.py index cf81c03d2..1071c081b 100644 --- a/tests/test_wild_bootstrap.py +++ b/tests/test_wild_bootstrap.py @@ -481,7 +481,7 @@ def test_twfe_with_wild_bootstrap(self, clustered_did_data, ci_params): ) results = twfe.fit( - clustered_did_data, outcome="outcome", treatment="treated", time="period", unit="unit" + clustered_did_data, outcome="outcome", treatment="treated", post="period", unit="unit" ) assert results.inference_method == "wild_bootstrap" @@ -1297,7 +1297,7 @@ def test_twfe_wild_bootstrap_p_val_type_propagates(): n_bootstrap=999, seed=7, p_val_type="equal-tailed", - ).fit(df, outcome="outcome", treatment="treated", time="post", unit="unit") + ).fit(df, outcome="outcome", treatment="treated", post="post", unit="unit") assert res.p_val_type == "equal-tailed" assert res.to_dict()["p_val_type"] == "equal-tailed" lower, upper = res.conf_int