diff --git a/CHANGELOG.md b/CHANGELOG.md index a913433b..d0d15eaa 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 39f642bc..5bb6dcbc 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 bccde399..316f4b1b 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 13d54b33..818f0875 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 4f86755c..f7c9dd07 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 ce72266b..575a92cf 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 c41d66b9..ccd7620c 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 286c46e3..c30024d0 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 f6867628..2c11cf39 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 f3f597f8..6bf721ae 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 cf4f3daa..3c668b47 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 09e30e25..bb40e602 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 87203875..e757b581 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 64d97ce0..521add24 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 0aebda52..1f0a366b 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 e92e5a9b..57d5ff0f 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 3ee8cea9..9e100f39 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 502a20e9..c41b3d7b 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 8e746c80..d44ba115 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 90655889..69e1fda2 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 7dd199f7..319ae734 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 99b462e0..6ad43035 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 48523ef3..e116b858 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 96be91c9..c88632be 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 60652494..f1d5ce9d 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 95352179..ef2dd05b 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 9beec3fd..0d605de1 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 141352d9..0d24e0ea 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 e0636f51..5390370c 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 b90cda0c..c8ee98d3 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 a02b148f..211c50b2 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 0407e7b6..ee2e0b8b 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 09ede641..b143d2d2 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 51ffd18e..10d9349f 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 5cefa35e..2165fda6 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 2b19046c..b7c81ffa 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 c516f54e..d7d914be 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 767fba30..ca617105 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 0ce95089..0554c1e9 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 28dd5b03..4db61373 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 e2b18966..bfc81f8f 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 784b99d7..7010cee4 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 7377dd60..355dc174 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 8de3554a..6313e73c 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 ed9657d5..064e1087 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 1eac3fec..b16fc602 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 e87fbb0a..048f0049 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 f719a589..b494a765 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 4975cc32..70b656b4 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 05530d94..009f2276 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 7db3f502..311ea43f 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 91bc34d7..064404e5 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 db759a7f..cce04bcb 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 3a324ff2..d5ed9d07 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 5bef2e06..d3c7db00 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 40099782..a3d21f7b 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 e5a20967..9fc85b43 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 8f973ba4..4d54cb1e 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 2a58878e..ca10cbc0 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 53edb49d..d705705e 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 0bbad3d8..4b049f3a 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 e13d7915..16600289 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 00000000..68559a08 --- /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 5c9f9c11..9f86efb6 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 cf81c03d..1071c081 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