From 568f8d264fd55263f52447b0f53fd3a9d069bdfe Mon Sep 17 00:00:00 2001 From: igerber Date: Wed, 21 Oct 2026 19:30:49 -0400 Subject: [PATCH] feat(v4): PR 4a of the 4.0 cut - remove StaggeredTripleDifference/SDDD and QDiD/QDiDResults, flip M-085 Cut-sequence item 4 is split three ways (4a/4b/4c, user decision): CI executes the tutorials and five of them construct MultiPeriodDiD, so the MultiPeriodDiD family waits for the notebook rebuild (4b) and lands in 4c. This PR takes the two notebook-safe removals and the M-085 raise. Removed: the StaggeredTripleDifference class and its module (diff_diff/staggered_triple_diff.py), the SDDD alias, the QDiD class and the QDiDResults alias. The staggered DDD estimator is TripleDifference().fit(..., unit=, time=, first_treat=, partition=) (same engine, pinned against the committed oracle); the quantile-DiD comparison estimator is ChangesInChanges(method="qdid"). _validate_all_params reads params["method"] directly now that the only method-less constructor is gone; the engine's dead cluster= accepted-then-warned branch goes with the class. M-085: in the 2x2x2 design TripleDifference.fit(time=) raises ValueError naming post= instead of the 3.x M-031 FutureWarning reinterpretation; both-supplied keeps its "pass only post=" error. resolve_renamed_kwarg is no longer imported there. Ledger: M-013/M-015 -> removed, M-061/M-064/M-085 -> done (test_refs added), M-031 keeps shimmed with warning null, M-080 drops the deleted module. Spec: item 4 split with 4a marked shipped, 4a-before-4b-before-4c ordering, the cross-row rule's alias clause. doc-deps: the staggered_triple_diff group is re-primed on the results module. Removed_in-4.0 non-terminal rows 11 -> 7; the full 4.0.0 due gate 34 -> 29 rows. Tests: the three StaggeredTripleDifference suites are ported to TripleDifference's staggered mode (keyword fits, R-golden compact control_group values mapped); the DDD/CiC merge gates drop their old-vs-new parity lanes for removal pins; the two capture scripts are marked historical; mixed suites (aggregate contract, staggered aggregation, survey metadata, event-study surface, anticipation/inference rosters, balance interop, profile panel, base estimator, practitioner, CiC suites, rename semantic) move to the surviving surfaces. Docs: API index/homepage rows and the two autosummary stubs removed, staggered.rst and changes_in_changes.rst sections dropped, REGISTRY TripleDifference notes and the StaggeredTripleDifference/QDiD section banners updated (archive framing stays with M-090), guides (llms*.txt) fold the staggered signature and the qdid method into the surviving entries, catalog citations moved onto TripleDifference, paper review/survey docs/tracking files reworded, migration guide and memory notes updated. Benchmark benchmark_triple_diff.py passes post= in 2x2x2 mode. --- .claude/commands/docs-check.md | 1 - .claude/memory.md | 6 +- DEFERRED.md | 4 +- METHODOLOGY_REVIEW.md | 6 +- README.md | 3 +- TODO.md | 2 +- benchmarks/python/benchmark_triple_diff.py | 2 +- .../20261021-v4-pr4a-std-qdid-removals.md | 42 ++ diff_diff/__init__.py | 10 - diff_diff/_deprecation.py | 4 +- diff_diff/_staggered_triple_diff_engine.py | 66 +-- diff_diff/bootstrap_utils.py | 4 +- diff_diff/business_report.py | 3 +- diff_diff/changes_in_changes.py | 140 +---- diff_diff/changes_in_changes_results.py | 10 +- diff_diff/guides/llms-autonomous.txt | 20 +- diff_diff/guides/llms-full.txt | 110 ++-- diff_diff/guides/llms-practitioner.txt | 1 - diff_diff/guides/llms.txt | 6 +- diff_diff/power.py | 2 +- diff_diff/practitioner.py | 17 +- diff_diff/results_base.py | 2 +- diff_diff/staggered_aggregation.py | 6 +- diff_diff/staggered_results.py | 2 +- diff_diff/staggered_triple_diff.py | 269 ---------- diff_diff/triple_diff.py | 65 +-- diff_diff/utils.py | 10 +- docs/api/_autosummary/diff_diff.QDiD.rst | 21 - .../diff_diff.StaggeredTripleDifference.rst | 41 -- docs/api/changes_in_changes.rst | 35 +- docs/api/index.rst | 2 - docs/api/staggered.rst | 25 +- docs/api/triple_diff.rst | 10 +- docs/choosing_estimator.rst | 10 +- docs/dev-status.md | 1 - docs/doc-deps.yaml | 18 +- docs/index.rst | 6 +- docs/methodology/REGISTRY.md | 100 ++-- ...rtiz-villavicencio-santanna-2025-review.md | 10 +- docs/methodology/survey-theory.md | 12 +- docs/migration-4.0.md | 6 +- docs/references.rst | 2 +- docs/survey-roadmap.md | 4 +- docs/v4-deprecations.yaml | 29 +- docs/v4-design.md | 41 +- pyproject.toml | 21 +- tests/_capture_v4_merge_cic_oracles.py | 12 +- tests/_capture_v4_merge_ddd_oracles.py | 6 + tests/test_aggregate_contract.py | 48 +- tests/test_anticipation_policy.py | 16 +- tests/test_balance_interop_contract.py | 8 +- tests/test_base_estimator.py | 13 +- tests/test_business_report.py | 13 +- tests/test_changes_in_changes.py | 20 +- tests/test_changes_in_changes_parity.py | 6 +- tests/test_event_study_surface.py | 20 +- tests/test_linalg.py | 2 +- tests/test_methodology_changes_in_changes.py | 9 +- .../test_methodology_staggered_triple_diff.py | 238 +++++---- tests/test_naming_guard.py | 13 +- tests/test_practitioner.py | 5 +- tests/test_profile_panel.py | 25 +- tests/test_staggered.py | 2 +- tests/test_staggered_aggregation.py | 34 +- tests/test_staggered_triple_diff.py | 495 ++++++++++++------ tests/test_survey_metadata_raw_scale.py | 40 +- tests/test_survey_staggered_ddd.py | 403 +++++++------- tests/test_v4_inference_policy.py | 7 +- tests/test_v4_matrix.py | 6 +- tests/test_v4_merge_cic.py | 245 +++------ tests/test_v4_merge_ddd.py | 475 ++++++----------- tests/test_v4_rename_semantic.py | 21 +- 72 files changed, 1421 insertions(+), 1968 deletions(-) create mode 100644 changelog.d/20261021-v4-pr4a-std-qdid-removals.md delete mode 100644 diff_diff/staggered_triple_diff.py delete mode 100644 docs/api/_autosummary/diff_diff.QDiD.rst delete mode 100644 docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst diff --git a/.claude/commands/docs-check.md b/.claude/commands/docs-check.md index 2e517c30e..fe8f38426 100644 --- a/.claude/commands/docs-check.md +++ b/.claude/commands/docs-check.md @@ -50,7 +50,6 @@ The following estimators/features MUST have documentation: | ContinuousDiD | "ContinuousDiD" | "ContinuousDiD" | continuous_did.rst | "Multi-Period and Staggered" | | HeterogeneousAdoptionDiD | "HeterogeneousAdoptionDiD" | "HeterogeneousAdoptionDiD" | had.rst | "Heterogeneous Adoption (No-Untreated Designs)" | | TripleDifference | "TripleDifference" | "TripleDifference" | triple_diff.rst | "Triple Difference" | -| StaggeredTripleDifference | "StaggeredTripleDifference" | "StaggeredTripleDifference" | staggered.rst | "Triple Difference" | | WooldridgeDiD | "WooldridgeDiD" | "WooldridgeDiD" | wooldridge_etwfe.rst | "Multi-Period and Staggered" | | TROP | "TROP" | "TROP" | trop.rst | "Triply Robust Panel" | | HonestDiD | n/a (in `## Diagnostics`) | n/a (in `## Diagnostics`) | honest_did.rst | "Honest DiD" | diff --git a/.claude/memory.md b/.claude/memory.md index 1e43186a0..31bd9b265 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -98,9 +98,9 @@ Target contract: `docs/v4-design.md` section 5; per-surface lifecycle: were removed at 4.0) - `time` calendar period everywhere; DifferenceInDifferences / TripleDifference take the 0/1 post dummy as `post` (the 3.x `time=` - spelling on DiD was removed at 4.0, M-030; on TripleDifference it is - still a warning shim, M-031, because `time` persists as the merged - class's staggered calendar column - its 4.0 enforcement is M-085); + spelling on DiD was removed at 4.0, M-030; on TripleDifference the name + `time` persists as the staggered calendar column, M-031, and in 2x2x2 + mode `time=` raises naming `post=` since 4.0 PR 4a, M-085); static TwoWayFixedEffects takes `post` and its `time` is the event-study calendar column (the static `time=` alias on TWFE is the same kind of surviving shim, M-082, enforced by M-083) diff --git a/DEFERRED.md b/DEFERRED.md index 98ac11f5c..285c92b99 100644 --- a/DEFERRED.md +++ b/DEFERRED.md @@ -57,7 +57,7 @@ exists but parity can't be verified without a local toolchain. | Issue | Location | PR | Priority | |-------|----------|----|----------| -| `StaggeredTripleDifference` R cross-validation (the engine is shared with `TripleDifference`'s staggered mode since 3(b), so this covers both surfaces): CSV fixtures not committed (gitignored); tests skip without local R + `triplediff`. Commit fixtures or generate deterministically. | `tests/test_methodology_staggered_triple_diff.py` | #245 | Medium | +| Staggered-DDD R cross-validation (`TripleDifference`'s staggered mode; the engine formerly behind `StaggeredTripleDifference`): CSV fixtures not committed (gitignored); tests skip without local R + `triplediff`. Commit fixtures or generate deterministically. | `tests/test_methodology_staggered_triple_diff.py` | #245 | Medium | | Staggered DDD R parity (both surfaces - one shared engine since 3(b)): benchmark only tests the no-covariate path (`xformla=~1`). Add covariate-adjusted scenarios + aggregation-SE parity assertions. | `benchmarks/R/benchmark_staggered_triplediff.R` | #245 | Medium | | Staggered DDD per-cohort group-effect SEs include WIF (both surfaces - one shared engine since 3(b)) (conservative vs R's `wif=NULL`); documented in REGISTRY. Could override the mixin for an exact R match (verification needs R `triplediff`). | `_staggered_triple_diff_engine.py` | #245 | Low | | **WooldridgeDiD follow-up cluster** (PR-B Stage D/E fail-closed surfaces; re-enable after R/Stata validation):
• QMLE sandwich uses `aweight` cluster adjustment `(G/(G-1))·(n-1)/(n-k)` vs Stata's `G/(G-1)` (conservative); add a `qmle` weight type if Stata goldens confirm a material difference (`wooldridge.py`, `linalg.py`).
• response-scale APE / log-link coefficient bridge for R `etwfe(family=poisson\|logit)` cell-level parity — needs `emfx()` APE extraction or link-inversion with baseline-mean adjustment (`generate_wooldridge_golden.R`, `test_methodology_wooldridge.py`).
• `aggregate(weights="cohort_share")` on survey-weighted fits: `_n_g_per_cohort` uses raw `unit.nunique()`; implement design-weighted unit totals per cohort (paper W2025 §7) and lift the `ValueError` gate (`wooldridge.py`, `wooldridge_results.py`).
• unconditional inference for `cohort_share` accounting for ω̂_g sampling uncertainty (W2025 §7.5); currently NaN-closed (`wooldridge_results.py`).
• `cohort_trends=True × survey_design` and `× control_group="never_treated"` raise `NotImplementedError` (unvalidated TSL variance / trend columns spanned jointly by the placebo cells and the unit FE, which absorb the cohort indicator and recover the omitted reference) (`wooldridge.py`).
• ~~Stata `jwdid` golden-value `TestReferenceValues`~~ RESOLVED: the golden ships four arms pinned by `tests/test_etwfe_cs_stata_parity.py` (no `TestReferenceValues` symbol was ever added). The QMLE bullet above REMAINS OPEN -- every golden arm is linear `jwdid`, so no QMLE cluster-SE reference exists, and SEs are pinned only as a ratio. | `wooldridge.py`, `wooldridge_results.py`, `linalg.py`, benchmarks | #216 · PR-B | Med-Low | @@ -143,7 +143,7 @@ decisions (refactor waivers, perf trade-offs, test-infrastructure calls) are rec | Decision | Location | Verified | |----------|----------|----------| -| **`utils.validate_pscore_trim` ships without the `allow_zero` flag the retired TODO row proposed.** The row (retired in the M-145 PR) suggested `validate_pscore_trim(value, *, allow_zero)`; after ContinuousDiD's alignment to `0 < x < 0.5`, no consumer needs `allow_zero=True` — every migrated copy (TripleDifference, DMLDiD, ContinuousDiD, LWDiD, CallawaySantAnna) wants the strict interval, and the deliberately-unmigrated `StaggeredTripleDifference` keeps its own bare check rather than a flagged call. A dead-on-arrival parameter on a shared validator is a drift magnet; re-add the flag only when a real caller needs a `[0, 0.5)` domain, with a distinct range message | `diff_diff/utils.py` | M-145 / 2026-08-27 | +| **`utils.validate_pscore_trim` ships without the `allow_zero` flag the retired TODO row proposed.** The row (retired in the M-145 PR) suggested `validate_pscore_trim(value, *, allow_zero)`; after ContinuousDiD's alignment to `0 < x < 0.5`, no consumer needs `allow_zero=True` — every migrated copy (TripleDifference, DMLDiD, ContinuousDiD, LWDiD, CallawaySantAnna) wants the strict interval, and the former `StaggeredTripleDifference` class's bare check went with the class (PR 4a) rather than becoming a flagged call. A dead-on-arrival parameter on a shared validator is a drift magnet; re-add the flag only when a real caller needs a `[0, 0.5)` domain, with a distinct range message | `diff_diff/utils.py` | M-145 / 2026-08-27 | | **Fixed-B Stata SE-golden comparisons in the LWDiD suite run unmarked in default CI** (no `ci_params.bootstrap()` scaling, no `slow` marker): the SE tolerance derives from BOTH fixed rep counts (ours B=999, Stata's R from the golden meta) so scaling either side would invalidate the committed-golden comparison — the ci_params convention governs convergence-style tests, not fixed-B golden comparisons — and these tests are the PR #588 acceptance bar, which must run in default CI; module-scoped fit memoization bounds the cost (~1-2 s per fit locally) | `tests/test_methodology_lwdid.py` | 2026-08-16 | | **The 4.0 migration guide's code blocks are not snippet-executed.** `tests/test_doc_snippets.py` discovers a hardcoded list of `.rst` files and only `.. code-block:: python` / RST `::` bodies, so `docs/migration-4.0.md` gets no coverage. Deliberate: the guide is a MIXED document - most "after" examples (the renames, `results.att`) run on the current release, but the `field-flip` and `df-convention-flip` examples describe 4.0 behaviour that cannot run until 4.0, so a blanket execution lane would fail by construction. Closing the gap fully means a markdown-fence extractor plus a skip-marker convention for the future-API blocks - a harness change, out of scope for a docs PR. What IS gated: the appendix's ledger parity (`test_migration_guide_*`), which pins the row set and every mechanically checkable cell; and, since the first local review found all three merge examples carrying invalid keywords, `test_migration_guide_examples_bind_to_real_signatures`, which ast-parses the guide's python blocks and asserts every constructor/`fit()` keyword exists on the target signature. That is signature binding, NOT execution - it deliberately skips calls whose owner it cannot resolve (e.g. `results.aggregate(...)`), and it cannot catch a wrong VALUE or a wrong sequence of calls. The hand-written `Fix` prose remains unverifiable by any available means. | `docs/migration-4.0.md`, `tests/test_doc_snippets.py` | Phase 4 / 2026-08-09 | | **MMM launch carousel carries scoped marketing claims, not exporter-contract documentation.** The deck (a LinkedIn marketing artifact, not a docs surface) states capabilities in scoped-but-punchy form: guardrail copy says "the easy mistakes fail loudly" / "you own the design, it owns the math" (the caller-owned estimand/population/window/outcome-scale alignment lives in REGISTRY.md's MMM section and the exporter docstrings, not on slides), and slide 8 shows tutorial 29's worked staggered-boost lift row WITHOUT an on-slide linearity qualifier - the compression's linear-channel scoping is the tutorial's job, which the CTA points to. Absolutes ("no silent mis-calibration", "any estimate exports", "anything with an estimate + SE") are ban-listed by `tests/test_mmm_carousel_claims.py`; local review R1/R2 pressed for compliance-style scoping language on the slides and the marketing-appropriate scoped copy was chosen instead. | `carousel/generate_mmm_carousel.py`, `tests/test_mmm_carousel_claims.py` | mmm-carousel / 2026-08-20 | diff --git a/METHODOLOGY_REVIEW.md b/METHODOLOGY_REVIEW.md index a4baf8cce..25cf891cf 100644 --- a/METHODOLOGY_REVIEW.md +++ b/METHODOLOGY_REVIEW.md @@ -66,7 +66,7 @@ The catalog grew incrementally over several quarters, so formats vary across the | Estimator | Module | R Reference | Status | Last Review | |-----------|--------|-------------|--------|-------------| | TripleDifference | `triple_diff.py` | `triplediff::ddd()` | **Complete** | 2026-02-18 | -| StaggeredTripleDifference | `staggered_triple_diff.py` | `triplediff::ddd(panel=TRUE)` + `agg_ddd()` | **Complete** | 2026-05-30 | +| TripleDifference (staggered mode; the former StaggeredTripleDifference class) | `_staggered_triple_diff_engine.py` | `triplediff::ddd(panel=TRUE)` + `agg_ddd()` | **Complete** | 2026-05-30 | ### Counterfactual / Synthetic Estimators @@ -988,7 +988,7 @@ These three are feature deferrals (paper-supported extensions that the library h | Field | Value | |-------|-------| -| Module | `_staggered_triple_diff_engine.py` (the shared engine), `staggered_triple_diff.py` (deprecated class surface), `staggered_triple_diff_results.py` | +| Module | `_staggered_triple_diff_engine.py` (the engine behind `TripleDifference`'s staggered mode; the `staggered_triple_diff.py` class surface was removed at 4.0), `staggered_triple_diff_results.py` | | Primary Reference | Ortiz-Villavicencio & Sant'Anna (2025) — same paper as TripleDifference, staggered case | | R Reference | `triplediff::ddd(panel=TRUE)` + `agg_ddd()` (per `benchmarks/R/benchmark_staggered_triplediff.R`) | | Status | **Complete** | @@ -1464,7 +1464,7 @@ Goldens at `benchmarks/data/r_conleyreg_conley_golden.json`; generator at `bench **Outstanding Concerns — cross-estimator survey coverage boundary** (intentional, fail-closed `NotImplementedError` deferrals, not bugs; line refs current as of this review — re-grep before relying on them): - **Conley + survey_design** (open methodological question — no canonical weighted spatial-HAC under probability sampling): `conley.py:298`, `linalg.py:1367` / `linalg.py:3501`, `spillover.py:3246`. -- **Replicate-weight designs** (use TSL strata/PSU/FPC, or `n_bootstrap=0`): `synthetic_did.py:437`, `continuous_did.py:2181`, `staggered_triple_diff.py:701`, `spillover.py:2400`, `bacon.py:526`, `had.py:1751` (+ HAD pretests), `wooldridge.py:89`, `trop.py:444`, `staggered.py:2228` (CS bootstrap), `efficient_did.py:1175`, `chaisemartin_dhaultfoeuille.py:2810`. +- **Replicate-weight designs** (use TSL strata/PSU/FPC, or `n_bootstrap=0`): `synthetic_did.py:437`, `continuous_did.py:2181`, `_staggered_triple_diff_engine.py` (replicate + bootstrap guard), `spillover.py:2400`, `bacon.py:526`, `had.py:1751` (+ HAD pretests), `wooldridge.py:89`, `trop.py:444`, `staggered.py:2228` (CS bootstrap), `efficient_did.py:1175`, `chaisemartin_dhaultfoeuille.py:2810`. - **Survey + non-HC1 vcov** — HC2 / HC2-BM / classical **explicitly raise `NotImplementedError`** under `survey_design=` (a fail-closed guard: the survey TSL / replicate-refit variance would otherwise silently discard the requested sandwich family): `stacked_did.py:428`, `sun_abraham.py:751`, `wooldridge.py:702`, `twfe.py:252`. - **Survey + user `cluster=`** — **explicitly raises** (a fail-closed guard, not a silent drop): the survey TSL / replicate-refit variance would otherwise ignore `cluster=`, so the combination is rejected at construction: `efficient_did.py:518`, `staggered.py:1719`, `imputation.py:314`, `two_stage.py:1439`, `triple_diff.py:674`. - **SyntheticControl** — no survey support yet (`synthetic_control.py:335`). diff --git a/README.md b/README.md index cd318cb61..bb0eddcaa 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,13 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`. - [SpilloverDiD](https://diff-diff.readthedocs.io/en/stable/api/spillover.html) - Butts (2021) ring-indicator spillover-aware DiD identifying direct effect on treated + per-ring spillover on near-control units; handles non-staggered and staggered timing; supports survey-design variance under `survey_design=` for HC1 / CR1 (Wave E.1 Binder TSL) and Conley (Wave E.2 panel-aware stratified-Conley sandwich on per-period PSU totals; extended in Wave E.2 follow-up to `conley_lag_cutoff > 0` via panel-block composition with within-PSU serial Bartlett HAC — `lag>0` requires an effective PSU via explicit `survey_design.psu` or injected `cluster=`); `SurveyDesign.subpopulation()` preserves full-design `n_psu` / `df_survey` via zero-padded scores (Wave E.3, R `svyrecvar(subset())` form) - [SyntheticDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - Synthetic DiD combining standard DiD and synthetic control for few treated units - [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html) - Abadie, Diamond & Hainmueller (2010) classic synthetic control for a single treated unit (donor-weight counterfactual, nested/cv/inverse-variance/custom V; in-space placebo permutation inference via `in_space_placebo()`, plus ADH-2015 `leave_one_out()` + `in_time_placebo()` robustness, Firpo-Possebom (2018) test-inversion confidence sets, and Chernozhukov-Wüthrich-Zhu (2021) conformal inference) -- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility; serves both the 2x2x2 and the staggered-adoption design from one signature (`fit(..., first_treat=)` selects the staggered engine) +- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility; serves both the 2x2x2 and the staggered-adoption design (Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT) from one signature (`fit(..., first_treat=)` selects the staggered engine) - [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html) - Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves - [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html) - de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲ = 0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲ > 0`, continuous-near-d̲ or mass-point), with a multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release - repeated cross-sections rejected by the validator. Alias `HAD`. - [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2014) sharp, fuzzy, AND covariate-adjusted regression discontinuity with robust bias-corrected inference and rdrobust-parity bandwidth selection (all 10 selectors, mass-point handling; fuzzy via `takeup=` with a first-stage block and weak-identification warning; covariates via `covariates=` - CCFT 2019, same estimand, covariate-aware bandwidths). Canonical `att` is the bias-corrected estimate with a coherent robust CI (rdrobust's printed headline is `att_conventional`). Alias `RDD`. - [StackedDiD](https://diff-diff.readthedocs.io/en/stable/api/stacked_did.html) - Wing, Freedman & Hollingsworth (2024) stacked DiD with Q-weights and sub-experiments; optional covariate balancing (Ustyuzhanin 2026) - [EfficientDiD](https://diff-diff.readthedocs.io/en/stable/api/efficient_did.html) - Chen, Sant'Anna & Xie (2025) efficient DiD with optimal weighting for tighter SEs - [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html) - Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment -- [StaggeredTripleDifference](https://diff-diff.readthedocs.io/en/stable/api/staggered.html#staggeredtripledifference) - Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT (deprecated 3.9 - use `TripleDifference` with `first_treat=`) - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html) - Wooldridge (2023, 2025) ETWFE: saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias `ETWFE`. - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment - [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC` diff --git a/TODO.md b/TODO.md index 1af3e9689..7915ddda6 100644 --- a/TODO.md +++ b/TODO.md @@ -29,7 +29,7 @@ Related tracking surfaces: | Post-fit `aggregate()` for the staggered DDD container: `StaggeredTripleDiffResults` carries no `AggregationMixin`, which is why the phase-3(b) merge had to carry fit-time `aggregate=`/`balance_e=` onto the surviving `TripleDifference` (rows M-140/M-141) as the ONE documented exception to the section-6 aggregate-postfit program. Porting the container onto the M-122 aggregation contract retires both rows; note the bootstrapped-fit recompute levels will need replay or a fail-closed relay — solved for CS, DMLDiD, and EfficientDiD via the BootstrapReplaySpec state replay (the container port can adopt the same mechanism); ImputationDiD/TwoStageDiD/ContinuousDiD still track theirs. Until it lands, the DDD docs deliberately keep teaching the fit-time kwarg (the canonical route there) | `diff_diff/staggered_triple_diff_results.py`, `diff_diff/aggregation.py`, `docs/api/triple_diff.rst`, `docs/tutorials/08_triple_diff.ipynb` | 3(b) | Heavy | Medium | | Staggered-DDD power support: `simulate_power`/`simulate_mde`/`simulate_sample_size` now REJECT a staggered-configured `TripleDifference` (both registered DDD generators emit 2x2x2 data and fit with `(group, partition, post)`, so a staggered config would be simulated under the wrong design). Support needs a staggered DDD DGP profile plus fit-kwargs builder, and a decision on whether the mode is selected by profile or by the estimator's own config | `diff_diff/power.py` | 3(b) | Mid | Low | | Bootstrap-`seed` provenance on multiplier-bootstrap results containers: neither `StaggeredTripleDiffResults` nor `CallawaySantAnnaResults` carries the `seed` that generated its bootstrap SEs / p-values / sup-t bands, so a serialized result cannot report the random configuration behind its inference. NOT a 3(b) regression - `seed` reaches the engine and `get_params()` correctly (same seed reproduces the SE bit-exactly, a different seed moves it), the gap is results-object observability only, it predates the merge, and both containers inherit it from the shared `CallawaySantAnnaBootstrapMixin`. Add `seed` (and consider `n_bootstrap`/`bootstrap_weights`/`cband`) to BOTH containers plus `to_dict()`, with seeded and unseeded pins; sequence it with the M-014 container unification rather than schema-changing one container mid-merge. Precedent for exposing it: `ContinuousDiDResults`, `EfficientDiDResults`, `SyntheticDiDResults` already do | `diff_diff/staggered_triple_diff_results.py`, `diff_diff/staggered_results.py` | 3(b) | Quick | Low | -| Staggered-mode cluster-robust ANALYTICAL SEs: `cluster=` raises in `TripleDifference`'s staggered mode (and is accepted-then-ignored on the deprecated class), so clustered inference there is bootstrap-only. Implementing a clustered analytical path for the GMM-combined influence function would let the raise become a real lane | `diff_diff/_staggered_triple_diff_engine.py` | 3(b) | Heavy | Low | +| Staggered-mode cluster-robust ANALYTICAL SEs: `cluster=` raises in `TripleDifference`'s staggered mode, so clustered inference there is bootstrap-only. Implementing a clustered analytical path for the GMM-combined influence function would let the raise become a real lane | `diff_diff/_staggered_triple_diff_engine.py` | 3(b) | Heavy | Low | | 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 | | DiagnosticReport public skip bookkeeping omits RUNNER-level skips: `applicable_checks` reflects only gate outcomes, so a check whose runner returns `status="skipped"` (heterogeneity's empty/non-finite branches, `_pt_event_study`'s empty-coefs branch - a pre-existing pattern, now also reachable via a failed post-fit event-study derivation on bootstrapped / kit-less ImputationDiD/TwoStageDiD/ContinuousDiD fits) stays listed as applicable while `skipped_checks`/`schema["skipped"]` omit it, so automation reading the public fields can misclassify the check as completed; reconcile runner-returned skipped sections into the public bookkeeping (all checks, one convention) or resolve those availabilities at the gate | `diff_diff/diagnostic_report.py` | derived-ES review | Mid | Low | | 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 | diff --git a/benchmarks/python/benchmark_triple_diff.py b/benchmarks/python/benchmark_triple_diff.py index 478037d19..8bf562243 100644 --- a/benchmarks/python/benchmark_triple_diff.py +++ b/benchmarks/python/benchmark_triple_diff.py @@ -83,7 +83,7 @@ def main(): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=covariates, ) diff --git a/changelog.d/20261021-v4-pr4a-std-qdid-removals.md b/changelog.d/20261021-v4-pr4a-std-qdid-removals.md new file mode 100644 index 000000000..04c6b2677 --- /dev/null +++ b/changelog.d/20261021-v4-pr4a-std-qdid-removals.md @@ -0,0 +1,42 @@ +### Removed +- **The `StaggeredTripleDifference` class and its `SDDD` alias are gone** (`M-013`, `M-064`; 4.0 + cut, PR 4a). `from diff_diff import StaggeredTripleDifference` and `SDDD` raise `ImportError`, + and the `diff_diff.staggered_triple_diff` module no longer exists. The staggered DDD estimator + is `TripleDifference().fit(..., unit=, time=, first_treat=, partition=)` - the same engine + (`diff_diff/_staggered_triple_diff_engine.py`), pinned against the committed oracle in + `tests/test_v4_merge_ddd.py`. R's compact `control_group` spellings + (`"notyettreated"`/`"nevertreated"`) died with the class; the merged surface takes + `"not_yet_treated"`/`"never_treated"`. +- **The `QDiD` class and the `QDiDResults` alias are gone** (`M-015`, `M-061`). The quantile-DiD + comparison estimator is `ChangesInChanges(method="qdid")` (same engine, identical numbers); + the results container is `ChangesInChangesResults` for both methods. +- The two `pyproject.toml` pytest `filterwarnings` entries that silenced the removed classes' + construction `FutureWarning`s. + +### Breaking Changes +- **`TripleDifference.fit(time=)` in the 2x2x2 design now raises `ValueError`** naming `post=` + (`M-085`). Through 3.x `time=` was the deprecated alias for the 0/1 post indicator and emitted + a `FutureWarning` (`M-031`); since 4.0 `time` means the staggered calendar column only. + Supplying both `post=` and `time=` keeps raising the "pass only post=" error. +- Keyword mapping for callers of the removed class: `eligibility=` is `partition=` on + `TripleDifference`; the staggered fit parameters are keyword-only; `cluster=` raises in + staggered mode (use `n_bootstrap > 0` for unit-level clustered inference). Users of + `balance[did]` stay on 3.x through that extra's `diff-diff<4` pin, where the old name still + resolves. + +### Internal +- The three StaggeredTripleDifference suites (`tests/test_staggered_triple_diff.py`, + `tests/test_methodology_staggered_triple_diff.py`, `tests/test_survey_staggered_ddd.py`) are + ported to `TripleDifference`'s staggered mode (keyword fits; the R golden's compact + `control_group` values are mapped), so the engine keeps its behavior, methodology and survey + coverage. `tests/test_v4_merge_ddd.py` / `tests/test_v4_merge_cic.py` drop their old-vs-new + parity lanes for `Test*Removal` pins; the two capture scripts are marked historical and + `make_2x2` lives in the CiC merge test. +- Ledger: `M-013`/`M-015` -> `removed`, `M-061`/`M-064`/`M-085` -> `done`; `M-031` keeps + `shimmed` with `warning: null`; `M-080` drops the deleted module from its `code_refs`. + Spec: cut-sequence item 4 split into 4a/4b/4c, the cross-row rule gains its alias clause. + `docs/doc-deps.yaml` re-primes the `staggered_triple_diff` group on the results module. +- `tests/test_v4_matrix.py` no longer requires `StaggeredTripleDifference`/`QDiD` worked-example + owners in the migration guide; the two autosummary stubs are deleted; REGISTRY keeps the + `## StaggeredTripleDifference` and `### QDiD` sections as the engine's methodology record + (archive framing lands with `M-090`). diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index 8136d2a46..be8935a3c 100644 --- a/diff_diff/__init__.py +++ b/diff_diff/__init__.py @@ -55,11 +55,9 @@ ) from diff_diff.changes_in_changes import ( ChangesInChanges, - QDiD, ) from diff_diff.changes_in_changes_results import ( ChangesInChangesResults, - QDiDResults, ) from diff_diff.continuous_did import ( ContinuousDiD, @@ -258,9 +256,6 @@ CSBootstrapResults, GroupTimeEffect, ) -from diff_diff.staggered_triple_diff import ( - StaggeredTripleDifference, -) from diff_diff.staggered_triple_diff_results import ( StaggeredTripleDiffResults, ) @@ -332,7 +327,6 @@ SA = SunAbraham BJS = ImputationDiD DDD = TripleDifference -SDDD = StaggeredTripleDifference Bacon = BaconDecomposition EDiD = EfficientDiD ETWFE = WooldridgeDiD @@ -352,7 +346,6 @@ "CallawaySantAnna", "ChaisemartinDHaultfoeuille", "ChangesInChanges", - "QDiD", "ContinuousDiD", "SunAbraham", "ImputationDiD", @@ -373,7 +366,6 @@ "SA", "BJS", "DDD", - "SDDD", "SCM", "Bacon", # Bacon Decomposition @@ -389,7 +381,6 @@ "CSBootstrapResults", "GroupTimeEffect", "ChangesInChangesResults", - "QDiDResults", "ContinuousDiDResults", "DoseResponseCurve", "SunAbrahamResults", @@ -400,7 +391,6 @@ "TwoStageBootstrapResults", "SpilloverDiDResults", "TripleDifferenceResults", - "StaggeredTripleDifference", "StaggeredTripleDiffResults", "TROPResults", "SyntheticControlResults", diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 2dc700ff3..7ce2cda17 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -120,8 +120,8 @@ def resolve_renamed_kwarg( Returned when neither name was supplied. Required parameters pass ``NOT_SUPPLIED`` here and re-validate with :func:`require_arg`. extra : str - Appended to the warning message (e.g. M-031's note that ``time=`` - survives as the calendar column). + Appended to the warning message (e.g. M-082's note that ``time=`` + survives as TwoWayFixedEffects' event-study calendar column). Returns ------- diff --git a/diff_diff/_staggered_triple_diff_engine.py b/diff_diff/_staggered_triple_diff_engine.py index 1f24ebe42..27e07af58 100644 --- a/diff_diff/_staggered_triple_diff_engine.py +++ b/diff_diff/_staggered_triple_diff_engine.py @@ -1,21 +1,20 @@ -"""Shared staggered-DDD engine (private). +"""Staggered-DDD engine (private). -Holds the estimation core both DDD surfaces run: `TripleDifference` in its -staggered mode (ledger row M-013) and the deprecated `StaggeredTripleDifference` -until its 4.0 removal. Relocated VERBATIM in phase 3(b) - the only changes are -the three parameterization knobs described below, so the merge is an API merge -and the numbers cannot move. +Holds the estimation core behind `TripleDifference`'s staggered mode (ledger +row M-013). Through 3.x it was shared with the `StaggeredTripleDifference` +class, removed at the 4.0 cut; the body was relocated VERBATIM in phase 3(b) +- the only changes were the three parameterization knobs described below, so +the merge was an API merge and the numbers never moved. -Knobs, all passed explicitly by the two callers: +Knobs, passed explicitly by the caller: - estimator_name interpolated into every message that names a class, so the - merged surface never steers users at the deprecated one - partition_label "partition" on the merged surface, "eligibility" on the - dying one - the third-dimension vocabulary - _frame_offset added to every user-attributed `stacklevel`, so warning - attribution is identical through both call depths - (user -> fit -> core on the dying class = 1; - user -> fit -> _fit_staggered -> core on the merged one = 2) + estimator_name interpolated into every message that names a class + ("TripleDifference") + partition_label "partition" - the third-dimension vocabulary (the removed + class said "eligibility") + _frame_offset added to every user-attributed `stacklevel`, so warnings + land on the caller through the call depth + user -> fit -> _fit_staggered -> core (= 2) The offset is ALSO mirrored onto `self._warn_frame_offset` for the duration of a fit, because four helper methods and the shared bootstrap mixin warn from depths @@ -46,9 +45,10 @@ # Type alias for pre-computed structures PrecomputedData = Dict[str, Any] -# The dying class accepts R's compact spellings; the merged class uses the -# underscored library vocabulary from birth (M-013). The engine reads both so a -# single core can serve both surfaces without a value shim on either. +# The engine reads both spellings: the underscored library vocabulary +# TripleDifference validates at construction (M-013), and R's compact spellings +# the removed StaggeredTripleDifference class accepted through 3.x (kept so the +# core stays byte-identical to the relocated body). _NEVER_TREATED = frozenset({"nevertreated", "never_treated"}) @@ -60,8 +60,9 @@ def _is_never_treated(control_group: str) -> bool: class _StaggeredTripleDiffEngineMixin: """Estimation core for staggered DDD. Not usable standalone. - Mixed into both `TripleDifference` and `StaggeredTripleDifference`, which - supply the constructor attributes and the CS aggregation/bootstrap mixins + Mixed into `TripleDifference` (and, through 3.x, the removed + `StaggeredTripleDifference` class), which supplies the constructor + attributes and the CS aggregation/bootstrap mixins this core calls. The annotations below exist because mypy type-checks this class independently of its hosts (`attr-defined` is not disabled) - they are declarations, never assignments — with ONE exception: `anticipation` is @@ -168,7 +169,8 @@ def _fit_staggered_core( ) -> StaggeredTripleDiffResults: """Fit the staggered triple-difference estimator. - The estimation body is the 3.x `StaggeredTripleDifference.fit` verbatim; + The estimation body is the 3.x `StaggeredTripleDifference.fit` verbatim + (that class was removed at 4.0; the body lives on here); see the module docstring for the three knobs. """ # Body-local alias so the relocated body stays byte-identical (the @@ -177,13 +179,12 @@ def _fit_staggered_core( eligibility = partition self._warn_frame_offset = _frame_offset try: - # Identification-assumption guard, enforced in the ENGINE so BOTH - # public surfaces fail closed on the same input. The merged - # constructor validates this eagerly; this call additionally covers - # the deprecated StaggeredTripleDifference (whose 3.x API SHAPE is - # frozen through removal - that freeze was never a licence to emit - # silently-biased numbers) and direct attribute mutation on either - # class, which bypasses __init__ and set_params alike. The + # Identification-assumption guard, enforced in the ENGINE so the + # surface fails closed on the same input however it was reached. + # The constructor validates this eagerly; this call additionally + # covers direct attribute mutation, which bypasses __init__ and + # set_params alike (and, through 3.x, covered the removed + # construction-permissive StaggeredTripleDifference class). The # assignment form also normalizes a numpy scalar to a Python int # before any `g - 1 - anticipation` arithmetic can overflow. self.anticipation = validate_anticipation(self.anticipation) @@ -228,15 +229,6 @@ def _fit_staggered_core( partition_label=partition_label, ) - if self.cluster is not None: - warnings.warn( - "cluster parameter is accepted but cluster-robust analytical SEs " - "are not yet implemented for staggered DDD. Use n_bootstrap > 0 " - "for unit-level clustered inference via multiplier bootstrap.", - UserWarning, - stacklevel=2 + _frame_offset, - ) - if first_treat != "first_treat": df["first_treat"] = df[first_treat] # Surface the inf → 0 recategorization the same way StaggeredDiD does diff --git a/diff_diff/bootstrap_utils.py b/diff_diff/bootstrap_utils.py index dd08d30ac..9036e5a95 100644 --- a/diff_diff/bootstrap_utils.py +++ b/diff_diff/bootstrap_utils.py @@ -970,8 +970,8 @@ def generate_rao_wu_weights_batch( # Extracted verbatim from CallawaySantAnna.fit()'s inline blocks (and adopted # by EfficientDiD's fit/replay) so the post-fit aggregate() replay applies # EXACTLY the same percentile overrides the fit-time path applies — one -# implementation, no twin drift. (The deprecated StaggeredTripleDifference -# keeps its OWN copy of the group replacement loop; unifying it is sequenced +# implementation, no twin drift. (The staggered DDD engine behind +# TripleDifference keeps its OWN copy of the group replacement loop; unifying it is sequenced # with the M-014 container port.) Note on warning attribution: when the # engines run under the post-fit replay their fit-tuned stacklevels resolve # into library frames rather than the user's aggregate() call — accepted as diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index 51f3a1386..c2cb58e26 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -834,7 +834,8 @@ def _extract_sample(self) -> Dict[str, Any]: # ``n_control_units`` is a composite total that also includes # the eligibility-denied / larger-cohort cells. Regardless of # the ``control_group`` mode the valid fixed comparison is the - # never-enabled cohort (``staggered_triple_diff.py:384``, + # never-enabled cohort (the ``has_never_enabled`` / ``valid_gc = [0]`` + # comparison selection in ``_staggered_triple_diff_engine.py``, # REGISTRY.md §StaggeredTripleDifference line 1730). Round-37 # P1 CI review on PR #318: under ``control_group="never_treated"`` # (i.e., ``_canonical_control == "nevertreated"``) the composite diff --git a/diff_diff/changes_in_changes.py b/diff_diff/changes_in_changes.py index 91d5a30aa..1ed1680ad 100644 --- a/diff_diff/changes_in_changes.py +++ b/diff_diff/changes_in_changes.py @@ -1,4 +1,5 @@ -"""Changes-in-Changes (CiC) and Quantile Difference-in-Differences (QDiD) estimators. +"""Changes-in-Changes (CiC) and Quantile Difference-in-Differences (QDiD) estimators, +both served by :class:`ChangesInChanges` (``method="cic"`` | ``"qdid"``). Implements the nonlinear difference-in-differences estimators of Athey & Imbens (2006), "Identification and Inference in Nonlinear Difference-in-Differences @@ -9,8 +10,10 @@ The counterfactual second-period outcome distribution of the treated group is ``F_{Y^N,11}(y) = F_10(F_00^{-1}(F_01(y)))`` (Theorem 3.1, eq. 9); the ATT is the plug-in eq. (36) and quantile treatment effects follow eqs. (17)-(18). -- :class:`QDiD`: the quantile DiD comparison estimator (Section 3.3). The - authors recommend CiC over QDiD (p. 447): QDiD's justifying model is not +- ``ChangesInChanges(method="qdid")``: the quantile DiD comparison estimator + (Section 3.3); the standalone ``QDiD`` class was removed at the 4.0 cut + (ledger row M-015). The authors recommend CiC over QDiD (p. 447): QDiD's + justifying model is not invariant to monotone rescaling of the outcome and places testable restrictions on the data. @@ -99,19 +102,6 @@ # and is echoed back on the results container as ``method``. _VALID_METHODS = ("cic", "qdid") -# Row M-015: the QDiD CLASS is deprecated, the QDiD METHOD is not. Emitted once per -# construction from QDiD.__init__ (set_params re-emits via BaseEstimator's transactional -# probe re-init - the documented side effect MultiPeriodDiD's and SDDD's shims also have). -# Pinned verbatim by tests/test_v4_merge_cic.py and the targeted pytest filter in -# pyproject.toml. REMOVE WITH THE CLASS at 4.0. -_QDID_DEPRECATION_MSG = ( - "QDiD is deprecated and will be removed in 4.0; use " - "ChangesInChanges(method='qdid') instead - the same engine, so the numbers " - "are unchanged. Only the class spelling is deprecated: method='qdid' is a " - "fully supported comparison mode and emits no warning of its own. The " - "QDiDResults alias is deprecated with it." -) - # Covariate-path quantile-regression tau grid: qte hardcodes ``seq(0.01, 0.99, 0.01)`` # inside compute.CiC/compute.QDiD (99 taus, not user-configurable). Pinned to R's EXACT # seq() doubles for the same reason as _DEFAULT_QUANTILES: natural numpy constructions @@ -840,17 +830,15 @@ def _fit_distributional( unit: Optional[str], kind: str, ) -> ChangesInChangesResults: - """Shared fit pipeline for ChangesInChanges and QDiD (``kind`` in {"cic", "qdid"}).""" + """Shared fit pipeline for ChangesInChanges (``kind`` = ``method`` in {"cic", "qdid"}).""" # Re-validate hyperparameters (set_params may have mutated them since __init__). _validate_all_params(est.get_params()) quantiles = np.sort( np.asarray(_DEFAULT_QUANTILES if est.quantiles is None else est.quantiles, dtype=float) ) - # Derived from the INSTANCE, not from ``kind``: with method= on the merged class, - # a kind-based map would name "QDiD" in errors raised by a fit the user made - # through ChangesInChanges(method="qdid") - a class they never constructed. This - # is byte-identical to the old mapping on both 3.x surfaces (QDiD -> "QDiD", - # ChangesInChanges -> "ChangesInChanges"). + # Derived from the INSTANCE, not from ``kind``: errors raised by a fit made + # through ChangesInChanges(method="qdid") must name the class the user + # constructed, never a kind-derived "QDiD" (the standalone class is gone). estimator_name = type(est).__name__ # ---- column resolution ------------------------------------------------- @@ -1195,11 +1183,7 @@ def _validate_all_params(params: Dict[str, Any]) -> None: _validate_alpha(params["alpha"]) _validate_panel(params["panel"]) _validate_seed(params["seed"]) - # ``.get``, not ``[...]``: QDiD keeps its frozen five-param __init__ (row - # M-015 deprecates the class, not the method), so its get_params() carries - # no "method" key and _fit_distributional re-validates through this same - # function on every QDiD fit. A bare params["method"] would KeyError there. - _validate_method(params.get("method", "cic")) + _validate_method(params["method"]) class ChangesInChanges(BaseEstimator): @@ -1296,10 +1280,10 @@ def __init__( # Stored verbatim (sklearn-clone contract): quantiles=None resolves to the # default grid at fit time, the raw None round-trips get_params(). # - # ``method`` is in this hand-built dict, not just in _validate_all_params' - # ``.get`` default: __init__ does NOT forward get_params(), so omitting it - # here would let ChangesInChanges(method="bogus") - and the set_params probe - # re-init that reconstructs through it - succeed silently. + # ``method`` is in this hand-built dict: __init__ does NOT forward + # get_params(), so omitting it here would let ChangesInChanges(method="bogus") + # - and the set_params probe re-init that reconstructs through it - succeed + # silently. _validate_all_params( { "quantiles": quantiles, @@ -1365,97 +1349,3 @@ def fit( return _fit_distributional( self, data, outcome, treatment, time, formula, covariates, unit, self.method ) - - -class QDiD(BaseEstimator): - """Quantile Difference-in-Differences comparison estimator (2x2 design). - - .. deprecated:: 3.9 - Use ``ChangesInChanges(method="qdid")`` instead; this class is removed - in 4.0 (ledger row M-015). The ESTIMATOR is not deprecated - only this - class spelling. The merged surface runs the same engine, so the numbers - are unchanged, and ``method="qdid"`` emits no warning of its own. - - Applies DiD quantile-by-quantile: ``qte(tau) = Q(y11, tau) - [Q(y10, tau) - + Q(y01, tau) - Q(y00, tau)]`` with R type-7 (linear-interpolation) - quantiles, matching ``qte::QDiD()`` (v1.3.1) exactly - including its ATT - formula, which evaluates the control-group quantile functions at the - treated pre-period's own-sample ranks. This finite-sample form deviates - from the Athey-Imbens k^QDID transformation mean (they are - population-equivalent; see the labeled Note in the methodology registry). - - Athey & Imbens recommend :class:`ChangesInChanges` over QDiD (2006, - p. 447): QDiD's justifying model is not invariant to monotone - transformations of the outcome, forces the unobservable distribution to be - identical across all four cells, and places testable restrictions on the - data (in unconditional fits, a warning fires when the implied - counterfactual quantile function is non-monotone; with covariates the - check is moot - the imputed counterfactual's quantile curve is monotone by - construction). QDiD's mean effect matches standard DiD's ATT in - population; the paper provides no asymptotic theory for QDiD, so inference - is a bootstrap convention shared with the qte package. - - Covariates port qte's ``xformla`` branch exactly: quantile regressions in - THREE cells (both control cells plus treated-pre), conditional ranks from - the treated pre-period cell's own conditional distribution, and an - additive imputation. qte's covariate QDiD mixes quantile types - type-7 - for the treated post-period quantiles, type-1 for the imputed - counterfactual - and that asymmetry is ported verbatim (REGISTRY Note). - - Fit signature, bootstrap behavior, and the results container are identical - to :class:`ChangesInChanges` (no interior-range guard: eq. 17 has no QDiD - analogue). The constructor parameters are identical EXCEPT that the merged - class additionally carries ``method=``, which is what selects this - estimator there; this class deliberately keeps its frozen five-parameter - signature through removal, so ``QDiD(method=...)`` is not accepted. - """ - - def __init__( - self, - quantiles: Optional[Any] = None, - n_bootstrap: int = 200, - alpha: float = 0.05, - panel: bool = False, - seed: Optional[int] = None, - ): - # Row M-015. Per construction, so a caller sees it at the site that names the - # dying class. NOT added to _DEPRECATED_ALIASES in __init__.py - QDiD is a real - # class there, not an alias, and routing it through both would double-warn. - warnings.warn(_QDID_DEPRECATION_MSG, FutureWarning, stacklevel=2) - # Stored verbatim (sklearn-clone contract): quantiles=None resolves to the - # default grid at fit time, the raw None round-trips get_params(). - # No "method" key: this class's five-param contract is frozen through removal, - # which is why _validate_all_params reads it with .get rather than [...]. - _validate_all_params( - { - "quantiles": quantiles, - "n_bootstrap": n_bootstrap, - "alpha": alpha, - "panel": panel, - "seed": seed, - } - ) - self.quantiles = quantiles - self.n_bootstrap = n_bootstrap - self.alpha = alpha - self.panel = panel - self.seed = seed - self.is_fitted_ = False - self.results_: Optional[ChangesInChangesResults] = None - - # get_params/set_params come from BaseEstimator. - - def fit( - self, - data: pd.DataFrame, - outcome: Optional[str] = None, - treatment: Optional[str] = None, - time: Optional[str] = None, - formula: Optional[str] = None, - covariates: Optional[List[str]] = None, - unit: Optional[str] = None, - ) -> ChangesInChangesResults: - """Fit the QDiD estimator on a 2x2 dataset (see ChangesInChanges.fit).""" - return _fit_distributional( - self, data, outcome, treatment, time, formula, covariates, unit, "qdid" - ) diff --git a/diff_diff/changes_in_changes_results.py b/diff_diff/changes_in_changes_results.py index f40a291c8..103a85893 100644 --- a/diff_diff/changes_in_changes_results.py +++ b/diff_diff/changes_in_changes_results.py @@ -1,4 +1,4 @@ -"""Results container for the ChangesInChanges (CiC) and QDiD estimators.""" +"""Results container for ChangesInChanges (the CiC and QDiD methods).""" from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple @@ -16,8 +16,8 @@ @dataclass class ChangesInChangesResults(BaseResults): - """Results for :class:`~diff_diff.changes_in_changes.ChangesInChanges` and - :class:`~diff_diff.changes_in_changes.QDiD`. + """Results for :class:`~diff_diff.changes_in_changes.ChangesInChanges` + (both ``method`` values, ``"cic"`` and ``"qdid"``). The headline ``att``/``se``/``t_stat``/``p_value``/``conf_int`` fields carry the mean effect; ``quantile_effects`` is a DataFrame with one row per @@ -251,7 +251,3 @@ def __repr__(self) -> str: f"n_quantiles={len(self.quantile_effects)}, " f"panel={self.panel}, n_bootstrap={self.n_bootstrap})" ) - - -# QDiD shares the container; the ``method`` field distinguishes the two. -QDiDResults = ChangesInChangesResults diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index d27d3bdbb..c1e0645fd 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -355,7 +355,7 @@ supported / out of scope; `warn` supported but with documented caveats; | `SyntheticDiD` | ✓ | ✗ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ | partial | | `TROP` | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | partial | | `TripleDifference` | ✓ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | -| `StaggeredTripleDifference` | ✓ | ✓ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | +| `TripleDifference` (staggered mode, `first_treat=`) | ✓ | ✓ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | | `ContinuousDiD` | ✗ | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ | | `HeterogeneousAdoptionDiD` | ✗ | partial | partial | ✗ | ✗ | ✗ | ✗ | ✓ | warn | | `RegressionDiscontinuity` | ✗ (cross-sectional; sharp: treatment = running >= cutoff; fuzzy: observed take-up via `takeup=`) | ✗ | ✗ | ✗ | ✗ | ✓ (precision only - estimand unchanged, unlike the DiD conditional-PT role; `covariates=`) | ✗ | ✗ | ✗ (follow-up) | @@ -426,7 +426,7 @@ supported / out of scope; `warn` supported but with documented caveats; exactly one observation per `(unit, time)` cell with every unit observed in every period: `ContinuousDiD`, `EfficientDiD`, `SyntheticDiD`, `HeterogeneousAdoptionDiD`, -`StaggeredTripleDifference`. Gate these on BOTH +TripleDifference staggered mode (`first_treat=`). Gate these on BOTH `PanelProfile.is_balanced == True` AND the absence of the `duplicate_unit_time_rows` alert (`is_balanced` is computed from the unique-key support and stays `True` when duplicates exist; the @@ -635,10 +635,9 @@ When a second cross-cutting comparison axis exists (e.g., policy hits some states and some demographic subgroups within states): - `TripleDifference` - classic two-period DDD. -- `StaggeredTripleDifference` - staggered DDD, robust to cohort-mixing. - DEPRECATED 3.9 (removed 4.0): use - `TripleDifference().fit(..., unit=, time=, first_treat=, partition=)`, - the same engine. `control_group` takes `not_yet_treated`/`never_treated`. +- `TripleDifference().fit(..., unit=, time=, first_treat=, partition=)` - + staggered DDD, robust to cohort-mixing (the former `StaggeredTripleDifference` + class, removed at 4.0). `control_group` takes `not_yet_treated`/`never_treated`. Triple-difference is not automatically detected by `profile_panel`; it requires the caller to identify the third comparison axis. If a @@ -818,10 +817,11 @@ Explicitly rejected for RCS (panel-only): - `SyntheticDiD` - requires balanced panel with per-unit donor matching. - `ContinuousDiD` - requires balanced panel with per-unit constant dose. -- `StaggeredTripleDifference` - panel-only; `fit()` has no - `panel=False` mode and rejects duplicate / unbalanced - `(unit, time)` structure. For cross-sectional DDD data use - `TripleDifference` instead. +- TripleDifference staggered mode (`fit(..., first_treat=)`) - panel-only; + it has no `panel=False` mode and rejects duplicate / unbalanced + `(unit, time)` structure (the former `StaggeredTripleDifference` class, + removed at 4.0). For cross-sectional DDD data use the two-period + `TripleDifference` design above. Treat other estimators in this guide as panel-only unless their own docs explicitly say otherwise. When routing, also: diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index af6e4be6b..9be0850bf 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -734,6 +734,34 @@ results = ddd.fit(data, outcome='outcome', group='group', results.print_summary() ``` +**Staggered mode** (Ortiz-Villavicencio & Sant'Anna 2025 staggered DDD with group-time ATT; the former `StaggeredTripleDifference` class, removed at 4.0). `first_treat=` selects it; the staggered fit parameters are keyword-only. Panel-only (balanced `(unit, time)` structure); `control_group` takes `'not_yet_treated'`/`'never_treated'`; `cluster=` raises in this mode (use `n_bootstrap > 0` for unit-level clustered inference); mixing the two designs' parameters raises. + +```python +ddd.fit( + data: pd.DataFrame, + outcome: str, + *, + unit: str, # Unit identifier + time: str, # Calendar period column + first_treat: str, # Enabling cohort (0 = never enabled) + partition: str, # Binary eligibility partition (0/1) + covariates: list[str] | None = None, + aggregate: str | None = None, # None, "simple", "group", "event_study", "all" + balance_e: int | None = None, # Max event time for balanced event study + survey_design=None, +) -> StaggeredTripleDiffResults +``` + +```python +from diff_diff import TripleDifference + +sddd = TripleDifference(estimation_method='dr', n_bootstrap=999) +results = sddd.fit(data, outcome='y', unit='id', time='t', + first_treat='ft', partition='eligible', + aggregate='event_study') +results.print_summary() +``` + ### ContinuousDiD Continuous Difference-in-Differences estimator (Callaway, Goodman-Bacon & Sant'Anna 2024). Estimates dose-response curves ATT(d) and ACRT(d). @@ -1274,21 +1302,11 @@ print(results.quantile_effects) # per-quantile QTE table print(results.uniform_bands()) # sup-t simultaneous bands (fixed 95%) ``` -### QDiD +### ChangesInChanges(method="qdid") - the QDiD comparison estimator -Quantile Difference-in-Differences comparison estimator (Athey & Imbens 2006, Section 3.3) for the 2x2 design: `QTE(tau) = Q(y11,tau) - [Q(y10,tau) + Q(y01,tau) - Q(y00,tau)]` with R type-7 linear-interpolation quantiles, matching `qte::QDiD()` (v1.3.1) exactly - including its ATT formula (control-group quantile functions evaluated at treated pre-period own-sample ranks; population-equivalent to the paper's k^QDID transformation but a different finite-sample estimator, see the REGISTRY.md Note). The paper recommends ChangesInChanges over QDiD: QDiD's justifying model is not scale-invariant, forces identical unobservable distributions in all four cells, and places testable restrictions on the data (a warning fires when the implied counterfactual quantile function is non-monotone; unconditional fits only - the covariate-path counterfactual quantile curve is monotone by construction). QDiD's mean effect equals standard DiD's ATT in population. **Deprecated in 3.9, removed in 4.0 - use `ChangesInChanges(method="qdid")` instead** (same engine, identical numbers; only the class spelling is deprecated, the estimator is not). Fit signature (including `covariates=`), bootstrap machinery, and results container are identical to ChangesInChanges (no interior-range guard; `q_lower`/`q_upper` are NaN); the constructor is identical EXCEPT that the merged class additionally carries `method=`, which is what selects this estimator there. Covariate fits use quantile regressions in THREE cells with own-cell conditional ranks and qte's verbatim-ported asymmetric quantile types (type-7 treated-post, type-1 imputed counterfactual). +Quantile Difference-in-Differences comparison estimator (Athey & Imbens 2006, Section 3.3) for the 2x2 design, selected with `method="qdid"` on `ChangesInChanges`: `QTE(tau) = Q(y11,tau) - [Q(y10,tau) + Q(y01,tau) - Q(y00,tau)]` with R type-7 linear-interpolation quantiles, matching `qte::QDiD()` (v1.3.1) exactly - including its ATT formula (control-group quantile functions evaluated at treated pre-period own-sample ranks; population-equivalent to the paper's k^QDID transformation but a different finite-sample estimator, see the REGISTRY.md Note). The paper recommends ChangesInChanges over QDiD: QDiD's justifying model is not scale-invariant, forces identical unobservable distributions in all four cells, and places testable restrictions on the data (a warning fires when the implied counterfactual quantile function is non-monotone; unconditional fits only - the covariate-path counterfactual quantile curve is monotone by construction). QDiD's mean effect equals standard DiD's ATT in population. The standalone `QDiD` class (and the `QDiDResults` alias) was removed at the 4.0 cut; the estimator itself is fully supported through `method="qdid"` (same engine, identical numbers). Fit signature (including `covariates=`), bootstrap machinery, and results container are the ChangesInChanges ones (no interior-range guard on this method; `q_lower`/`q_upper` are NaN). Covariate fits use quantile regressions in THREE cells with own-cell conditional ranks and qte's verbatim-ported asymmetric quantile types (type-7 treated-post, type-1 imputed counterfactual). -```python -QDiD( - quantiles: array-like | None = None, # Same defaults as ChangesInChanges - n_bootstrap: int = 200, - alpha: float = 0.05, - panel: bool = False, - seed: int | None = None, -) -``` - -**Usage:** +**Usage** (constructor params as for ChangesInChanges, plus `method='qdid'`): ```python from diff_diff import ChangesInChanges @@ -1580,68 +1598,6 @@ zero over the full grid. because it re-estimates. It is tied to `attgt_weights` by an identity: `attgt_weights(cs, type="twfe").implied_att == decompose_twfe_weights(panel, ...).estimate`. -### StaggeredTripleDifference - -DEPRECATED in 3.9, removed in 4.0 (ledger row M-013). Use -`TripleDifference().fit(..., unit=, time=, first_treat=, partition=)` - the same -engine, so the numbers are identical. Vocabulary on the merged surface: -`eligibility=` is `partition=`, and `control_group` takes the underscored values -`not_yet_treated`/`never_treated` (this class keeps R's compact `notyettreated`/ -`nevertreated` until removal). `cluster=` raises on the merged surface instead of -being accepted-and-ignored. Alias `SDDD` is deprecated with the class. - -Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD estimator for designs with two eligibility criteria and staggered treatment timing. - -```python -StaggeredTripleDifference( - estimation_method: str = "dr", # "dr", "ipw", or "reg" - control_group: str = "notyettreated", # "nevertreated" or "notyettreated" - alpha: float = 0.05, - anticipation: int = 0, # validated at fit(); construction stays permissive (deprecated class, frozen shape) - base_period: str = "varying", # "varying" or "universal" - n_bootstrap: int = 0, - bootstrap_weights: str = "rademacher", - seed: int | None = None, - cband: bool = True, - pscore_trim: float = 0.01, - cluster: str | None = None, - rank_deficient_action: str = "warn", - epv_threshold: float = 10, # Min events-per-variable for propensity score - pscore_fallback: str = "error", # "error" or "unconditional" -) -``` - -**Alias:** `SDDD` - -**fit() parameters:** - -```python -sddd.fit( - data: pd.DataFrame, - outcome: str, - unit: str, - time: str, - first_treat: str, - eligibility: str, # Binary eligibility column (0/1) - covariates: list[str] | None = None, - aggregate: str | None = None, # None, "simple", "group", "event_study", "all" - balance_e: int | None = None, # Max event time for balanced event study - survey_design=None, -) -> StaggeredTripleDiffResults -``` - -**Usage:** - -```python -from diff_diff import StaggeredTripleDifference - -sddd = StaggeredTripleDifference(estimation_method='dr', n_bootstrap=999) -results = sddd.fit(data, outcome='y', unit='id', time='t', - first_treat='ft', eligibility='eligible', - aggregate='event_study') -results.print_summary() -``` - ### WooldridgeDiD Wooldridge (2023, 2025) Extended Two-Way Fixed Effects (ETWFE) estimator. OLS path uses direct saturated-regression coefficients for ATT(g,t). Logit and Poisson QMLE paths use Average Structural Function (ASF) based ATT with delta-method standard errors. @@ -2237,7 +2193,7 @@ Per-horizon event-study results container for `HeterogeneousAdoptionDiD`'s event ### ChangesInChangesResults -Results container shared by `ChangesInChanges` and `QDiD` (the `method` field is `"cic"` or `"qdid"`; the pre-3.9 name `estimator` was removed in 4.0; `QDiDResults` is an alias of this class). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int`; `quantile_effects` is a DataFrame with columns `quantile`, `qte`, `se`, `t_stat`, `p_value`, `conf_low`, `conf_high`. `q_lower`/`q_upper` bound the point-identified interior quantile range for unconditional CiC fits (NaN for QDiD and for covariate fits); `sup_t_crit` is the qte sup-t critical value backing `uniform_bands()` (fixed 95% level). Also carries `n_obs`, `cell_sizes`, `n_bootstrap`, `n_bootstrap_valid`, `panel`, `quantiles`, `alpha`, and `covariates` (the covariate columns of a conditional fit, else None). Methods: `summary()`, `print_summary()`, `to_dict()`, `to_dataframe(level="quantiles"|"att")`, `uniform_bands()`. All inference flows through `safe_inference`/`safe_inference_batch` (joint-NaN contract; `n_bootstrap=0` yields NaN inference everywhere). +Results container for `ChangesInChanges` under both methods (the `method` field is `"cic"` or `"qdid"`; the pre-3.9 name `estimator` and the `QDiDResults` alias were removed in 4.0). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int`; `quantile_effects` is a DataFrame with columns `quantile`, `qte`, `se`, `t_stat`, `p_value`, `conf_low`, `conf_high`. `q_lower`/`q_upper` bound the point-identified interior quantile range for unconditional CiC fits (NaN for QDiD and for covariate fits); `sup_t_crit` is the qte sup-t critical value backing `uniform_bands()` (fixed 95% level). Also carries `n_obs`, `cell_sizes`, `n_bootstrap`, `n_bootstrap_valid`, `panel`, `quantiles`, `alpha`, and `covariates` (the covariate columns of a conditional fit, else None). Methods: `summary()`, `print_summary()`, `to_dict()`, `to_dataframe(level="quantiles"|"att")`, `uniform_bands()`. All inference flows through `safe_inference`/`safe_inference_batch` (joint-NaN contract; `n_bootstrap=0` yields NaN inference everywhere). ### LWDiDResults @@ -3043,7 +2999,7 @@ DIFF_DIFF_BACKEND=rust pytest # Force Rust (fail if unavailable) | Continuous treatment intensity, per-dose ATT(d) / ACRT(d) (needs zero-dose controls, or `control_group="lowest_dose"` for Remark 3.1 when P(D=0)=0) | `ContinuousDiD` | | Continuous treatment intensity, WAS at dose support boundary (compatible with universal rollout or small never-treated share) | `HeterogeneousAdoptionDiD` | | Two-criterion treatment, simultaneous (2x2x2 DDD) | `TripleDifference` | -| Two-criterion treatment, staggered timing + eligibility | `StaggeredTripleDifference` | +| Two-criterion treatment, staggered timing + eligibility | `TripleDifference` with `first_treat=` | | Nonlinear outcome (binary/count) with staggered timing | `WooldridgeDiD` | | Diagnosing TWFE bias | `BaconDecomposition` | | Efficiency-optimal estimation | `EfficientDiD` | diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index c026aaa07..f54ef49ce 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -179,7 +179,6 @@ Use this decision tree to select the appropriate estimator: Is this a triple-difference (DDD) design? (Two criteria: e.g., policy + eligibility) |-- YES, simultaneous (2x2x2): TripleDifference (DDD) |-- YES, staggered timing: TripleDifference(...).fit(..., first_treat=) -| (StaggeredTripleDifference/SDDD deprecated 3.9) | Is treatment continuous (doses/intensities)? Route by ESTIMAND first, not by whether never-treated units happen diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index 345431037..8dc21607f 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -68,18 +68,16 @@ The site is organized into 5 sections, each with a landing page: - [SpilloverDiD](https://diff-diff.readthedocs.io/en/stable/api/spillover.html): Butts (2021) ring-indicator spillover-aware DiD identifying direct effect on treated + per-ring spillover-on-control; reuses `conley_coords` for ring construction; handles non-staggered and staggered timing; supports `SurveyDesign(weights, strata, psu, fpc)` under `vcov_type="hc1"` with optional `cluster=` for CR1 via Gerber (2026) Binder TSL (Wave E.1) and under `vcov_type="conley"` via a panel-aware stratified-Conley sandwich on per-period PSU totals (Wave E.2 cross-sectional `conley_lag_cutoff=0`) extended in Wave E.2 follow-up to `conley_lag_cutoff > 0` via panel-block composition with within-PSU serial Bartlett HAC (Newey-West 1987 separable form; `lag>0` requires an effective PSU via explicit `survey_design.psu` or injected `cluster=`), both composed with the Wave D Gardner GMM correction; `SurveyDesign.subpopulation()` preserves full-design `n_psu` / `df_survey` via zero-padded scores at the meat-helper boundary (Wave E.3, R `svyrecvar(subset())` form) (replicate weights queued as follow-up) - [SyntheticDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Synthetic DiD combining standard DiD and synthetic control methods for few treated units - [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html): Abadie, Diamond & Hainmueller (2010) classic synthetic control for ONE treated unit — donor-weight counterfactual, predictor-importance V via nested / cv (out-of-sample, ADH 2015; needs predictors spanning both train/val windows, so default single-period lags are rejected) / inverse-variance (1/Var on raw predictors, bypasses standardize) / custom, gap path + pre-RMSPE; no analytical SE (inference fields NaN), significance via in-space placebo permutation inference (`in_space_placebo()`, post/pre RMSPE-ratio, p = rank/(n_placebos+1)); ADH-2015 §4 robustness: `leave_one_out()` donor-robustness + `in_time_placebo()` backdating placebo; confidence sets by test inversion (Firpo-Possebom 2018 §4): `test_sharp_null()` + `confidence_set(family="constant"|"linear")` re-rank the placebo gaps into a confidence set for the effect path (the analytical `conf_int` stays NaN); conformal inference (Chernozhukov-Wüthrich-Zhu 2021): `conformal_test()` (joint sharp-null p-value), `conformal_confidence_intervals()` (pointwise per-period CIs), `conformal_average_effect()` (average-effect CI) — fit their OWN constrained-LS proxy under the null on all periods and permute residuals over time (moving-block / iid schemes; `conf_int` still NaN). Alias `SCM`. -- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html): Triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility. Since 3.9 it serves BOTH DDD designs from one signature: the 2x2x2 design as before, and staggered adoption when `first_treat=` is supplied (with the unit id, the calendar period column and `partition=`; the staggered params are keyword-only, and mixing the two designs' params raises). `control_group` takes 'not_yet_treated'/'never_treated' +- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html): Triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility. Since 3.9 it serves BOTH DDD designs from one signature: the 2x2x2 design as before, and staggered adoption (Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT) when `first_treat=` is supplied (with the unit id, the calendar period column and `partition=`; the staggered params are keyword-only, and mixing the two designs' params raises). `control_group` takes 'not_yet_treated'/'never_treated' - [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html): Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves - [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html): de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲=0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲>0`, continuous-near-d̲ or mass-point), with multi-period event-study extension (last-treatment cohort, pointwise CIs; the mode is panel-inferred since 3.9 - two periods -> overall WAS, more -> event-study - and post-fit `results.aggregate('simple')`/`.aggregate('event_study')` are pure views, rows M-027/M-139). **Panel-only** in this release (repeated cross-sections rejected by the validator). Alias `HAD`. - [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html): Calonico, Cattaneo & Titiunik (2014) sharp AND fuzzy regression discontinuity with robust bias-corrected inference, parity-targeting R rdrobust 4.0.0 (all 10 data-driven bandwidth selectors, mass-point handling, three-row conventional/bias-corrected/robust output; canonical `att` = the bias-corrected estimate with a coherent robust CI - rdrobust's printed headline is `att_conventional`). Fuzzy via `fit(..., takeup=...)`: local Wald ratio (complier LATE for binary take-up under monotonicity; ratio-of-jumps otherwise - the `estimand` field says which), first-stage `first_stage*` block, weak-first-stage warning. Covariate adjustment via `fit(..., covariates=[...])` (CCFT 2019 additive common-coefficient, R's `covs=`): SAME estimand, precision only; requires covariate balance at the cutoff (testable: fit each covariate as the outcome); covariate-aware bandwidths; collinear columns dropped with a warning (`covs_drop`). Cluster-robust variance is a documented follow-up. Alias `RDD`. - [StackedDiD](https://diff-diff.readthedocs.io/en/stable/api/stacked_did.html): Wing, Freedman & Hollingsworth (2024) stacked DiD with Q-weights and sub-experiments; optional covariate balancing (`balance="entropy"`, Ustyuzhanin 2026) - [EfficientDiD](https://diff-diff.readthedocs.io/en/stable/api/efficient_did.html): Chen, Sant'Anna & Xie (2025) efficient DiD with optimal weighting for tighter SEs - [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html): Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment (absorbing by default; `non_absorbing=True` for on/off treatment, method='local') -- [StaggeredTripleDifference](https://diff-diff.readthedocs.io/en/stable/api/staggered.html#staggeredtripledifference): Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT. DEPRECATED in 3.9, removed in 4.0 - use `TripleDifference` with `first_treat=` (supplying the unit id, the calendar period column and `partition=`) - the same engine (`eligibility=` is `partition=` there; `control_group` takes the underscored values). Alias `SDDD` deprecated with it - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html): Wooldridge (2023, 2025) ETWFE — saturated OLS, logit/Poisson QMLE (ASF-based ATT); `unsupported_period_action="error"` refuses comparison-support period filtering (default `"drop"`). Alias: ETWFE - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html): Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting); variance- or equally-weighted ATT, premean differencing, pooled pre/post, fast. Absorbing by default; non-absorbing (reversible) treatment via `non_absorbing="first_entry"` (Eq. 12) or `"effect_stabilization"` (Eq. 13, window `L`). Complex-survey designs (pweight + stratified-PSU TSL SEs) on the default path via `fit(survey_design=...)`. -- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: recovers the treated group's full counterfactual outcome distribution and quantile treatment effects (ATT + QTE grid) via the CDF transformation `F_10(F_00^{-1}(F_01(y)))`; invariant to monotone outcome transformations (unconditional fits; the covariate QR branch is not); bootstrap inference (panel or repeated cross-section resampling); point parity with R `qte::CiC()`, including its covariate branch (`covariates=` -> per-cell linear quantile regression, Melly-Santangelo-style conditional CiC). Continuous outcomes, numeric covariates. Alias `CiC`. -- [QDiD](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): **Deprecated 3.9, removed 4.0 - use `ChangesInChanges(method="qdid")`.** Athey & Imbens (2006) quantile DiD comparison estimator (additive quantile-by-quantile DiD, matching R `qte::QDiD()` including its covariate branch via `covariates=`); same bootstrap machinery as ChangesInChanges. The paper recommends CiC over QDiD (scale-dependent model with testable restrictions; a non-monotonicity warning fires when violated - unconditional fits only, the covariate-path counterfactual quantile curve is monotone by construction). +- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: recovers the treated group's full counterfactual outcome distribution and quantile treatment effects (ATT + QTE grid) via the CDF transformation `F_10(F_00^{-1}(F_01(y)))`; invariant to monotone outcome transformations (unconditional fits; the covariate QR branch is not); bootstrap inference (panel or repeated cross-section resampling); point parity with R `qte::CiC()`, including its covariate branch (`covariates=` -> per-cell linear quantile regression, Melly-Santangelo-style conditional CiC). Continuous outcomes, numeric covariates. `method="qdid"` selects the QDiD comparison estimator the same paper formalizes (additive quantile-by-quantile DiD matching R `qte::QDiD()` incl. its covariate branch; the paper recommends CiC over it; a non-monotonicity warning fires when QDiD's testable restriction is violated in unconditional fits; the standalone QDiD class was removed in 4.0). Alias `CiC`. - [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html): Lee & Wooldridge (2025, 2026) rolling-transformation DiD — unit-specific demean/detrend converts panel to cross-section, supports staggered adoption with flexible control groups. Signature: `LWDiD(rolling='demean', estimation_method='reg', vcov_type='hc1', cluster=None, control_group='not_yet_treated', alpha=0.05, n_bootstrap=0, seed=None, pscore_trim=0.01, n_neighbors=1, caliper=None, with_replacement=True, n_jobs=1).fit(data, outcome, unit, time, treatment, first_treat=None, covariates=None)`. `estimation_method` values: `reg` (papers' RA), `ipw`, `dr` (papers' IPWRA, doubly robust), `psm`; `vcov_type` values: `classical`/`hc1`/`hc2`/`hc3` for `reg`; `ipw`/`dr` accept `hc1` only (influence-function variance); `psm` accepts `hc1` as configuration only - PSM inference is unavailable (NaN) pending an Abadie-Imbens matching variance; cluster-robust inference via the constructor's `cluster=` column (hc1/CR1 only, not a `vcov_type` value; rejected for `psm`). Per-period effects: post-fit `results.aggregate('event_study')`. - [DMLDiD](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html): Chang (2020) double/debiased machine learning DiD — staggered ATT(g,t) with cross-fitted ML nuisances (DML2) and Neyman-orthogonal scores; covariates REQUIRED (conditional parallel trends). Signature: `DMLDiD(propensity_learner='logit', outcome_learner='linear', n_folds=5, control_group='never_treated', anticipation=0, alpha=0.05, n_bootstrap=0, bootstrap_weights=None, seed=None, base_period='varying', cband=True, pscore_trim=0.01, panel=True, cluster=None).fit(data, outcome, unit, time, first_treat, covariates, survey_design=None, bad_control=None, bad_control_covariates=None)`. `panel=False` = declared repeated cross sections (Chang Case 2: level outcomes, row-unique unit IDs, lambda-corrected variance). Survey/cluster support on BOTH designs (bad-control lane: panel only, `cluster=` only): `survey_design=` (pweight full-design TSL — weighted moments, PSU-cohesive folds, design-based variance with t-inference; a library extension of Chang's i.i.d. theory), replicate-weight designs (BRR/Fay/JK1/JKn/SDR — per-cell AND aggregate IF-reweighting variance, df = rank-1; cluster= and bootstrap combinations rejected), and coarser-than-unit `cluster=` (variance/folds only, kernels stay unweighted). Bad controls (Caetano, Callaway, Payne & Sant'Anna 2026): `fit(..., bad_control='x', bad_control_covariates=[outcome])` runs the paper's Neyman-orthogonal doubly-robust score (Eq. 10) - parallel trends conditional on the bad control's untreated path plus covariate unconfoundedness given its base-period value, W and Z - and stores the per-cell `ATT_X(g,t)` pre-test (`results.bad_control_summary()`); a bad control must NOT be in `covariates`. Learners: string names (`linear`/`ridge`/`sieve` regressors, `logit` classifier) or any object with fit/predict(_proba) (sklearn-compatible); `SieveLearner(k_max, criterion)` is exported for adaptive polynomial nuisances. Aggregation is POST-FIT: `results.aggregate('event_study'/'group'/'simple')`, plus `'total'` on panel non-survey fits (RCS and declared-survey fits fail 'total' closed); sup-t bands via bootstrap replay. With seed=None point estimates vary across fits (random folds); set seed for reproducibility. - [DurationDiD](https://diff-diff.readthedocs.io/en/stable/api/duration_did.html): Deaner & Ku (2026) causal duration DiD — two-group, common-timing design for a binary ABSORBING outcome (spell ended; once 1, always 1). Identifies off a restriction on the groups' UNTREATED HAZARDS, never outcome levels: `method='cd'` (constant additive hazard gap) or `method='ph'` (constant hazard ratio, mean-of-ratios estimator), fitted on the pre-treatment cumulative hazards (default equal weights over every eligible pre-treatment date; a window via `pre_periods=`/`pre_period_weights=`). Reports the absorption ATT at every post-treatment date and its uniform average as `att`, with the paper's whole-individual pooled bootstrap (centered pointwise intervals + simultaneous max-|t| band) and a fixed-anchor pre-treatment specification test in `results.pretest`. Signature: `DurationDiD(method='cd', n_bootstrap=1000, alpha=0.05, seed=None).fit(data, outcome, unit, time, treatment, last_pre_period=..., pre_periods=None, pre_period_weights=None)`; `last_pre_period` (the last untreated date) is REQUIRED and never inferred. Balanced equally spaced numeric grid, fixed 0/1 group indicator; no covariates, staggering, censoring, survey or cluster support. Every inference family is fully available or fully withheld (`inference_status`). Not admitted by DiagnosticReport/BusinessReport; `results.aggregate('event_study')` returns the unified container. diff --git a/diff_diff/power.py b/diff_diff/power.py index bba947fe0..fd2ffb564 100644 --- a/diff_diff/power.py +++ b/diff_diff/power.py @@ -355,7 +355,7 @@ def _ddd_panel_fit_kwargs( n_periods: int, treatment_period: int, ) -> Dict[str, Any]: - # Panel DDD: time="post" is generate_ddd_panel_data's derived binary + # Panel DDD: post="post" is generate_ddd_panel_data's derived binary # pre/post indicator (vs the cross-sectional "time"). Clustering is NOT a # fit kwarg — it resolves from the estimator's cluster="unit" attribute # against the DGP's "unit" column. diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index 48326c45b..153a9074a 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -65,8 +65,8 @@ def _distributional_kind(results: Any) -> Any: def _estimator_display(type_name: str, results: Any) -> str: """Per-instance display name. - ``ChangesInChangesResults`` is shared by CiC and QDiD (``QDiDResults`` - is an alias), so the static per-type map cannot distinguish them; the + ``ChangesInChangesResults`` is shared by the CiC and QDiD methods, so + the static per-type map cannot distinguish them; the ``method`` field ("cic"/"qdid") does. ``DMLDiDResults`` likewise serves two lanes: a fit with ``bad_control`` set runs the Caetano, Callaway, Payne & Sant'Anna (2026) bad-control score and is named for @@ -1739,8 +1739,9 @@ def _cic_fit_snippet( ) -> str: """Render a ChangesInChanges constructor+fit snippet preserving the fit's design. - Always emits ``ChangesInChanges(...)``: row M-015 deprecates the QDiD - CLASS, so guidance must never hand the reader a ``QDiD(...)`` call. Pass + Always emits ``ChangesInChanges(...)``: the standalone QDiD class was + removed at 4.0 (row M-015), so guidance never hands the reader a + ``QDiD(...)`` call. Pass ``method="qdid"`` to select that estimator on the merged surface; any other value (including the default ``None``) emits no ``method=`` argument at all, keeping CiC snippets byte-identical to the pre-merge text. @@ -1811,8 +1812,8 @@ def _did_anchor_snippet(results: Any) -> str: def _handle_cic(results: Any): """ChangesInChanges / QDiD guidance (shared results class). - CiC and QDiD share ``ChangesInChangesResults`` (``QDiDResults`` is an - alias), so this single handler branches on the ``method`` field + The CiC and QDiD methods share ``ChangesInChangesResults``, so this + single handler branches on the ``method`` field ("cic"/"qdid"; unknown or missing kinds fall to the CiC branch - the paper-primary, safe-voiced default) and on covariate status (truthiness, not ``is not None``: fit() normalizes ``covariates=[]`` @@ -1824,8 +1825,8 @@ def _handle_cic(results: Any): """ is_qdid = _distributional_kind(results) == "qdid" has_cov = bool(getattr(results, "covariates", None)) - # Display name for step LABELS (still the estimator's own name - the METHOD - # "QDiD" is not deprecated, only the class spelling is). + # Display name for step LABELS (the estimator's own name - the METHOD + # "QDiD" is fully supported; only the class spelling was removed at 4.0). est_name = "QDiD" if is_qdid else "ChangesInChanges" # Constructor argument for emitted SNIPPETS: every snippet now builds # ChangesInChanges, selecting the estimator via method= (row M-015). diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index 5f524393c..8b3e66147 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -1432,7 +1432,7 @@ def build_event_study_surface(results: Any) -> EventStudyResults: f"{type(results).__name__} does not expose an event-study surface. " "Supported producers: CallawaySantAnna, DMLDiD, SunAbraham, ImputationDiD, " "TwoStageDiD, StackedDiD, SpilloverDiD, ContinuousDiD, EfficientDiD, " - "WooldridgeDiD, StaggeredTripleDifference, MultiPeriodDiD, LPDiD, " + "WooldridgeDiD, TripleDifference (staggered mode), MultiPeriodDiD, LPDiD, " "ChaisemartinDHaultfoeuille, and HeterogeneousAdoptionDiD " "event-study results." ) diff --git a/diff_diff/staggered_aggregation.py b/diff_diff/staggered_aggregation.py index 687169da1..2992d0594 100644 --- a/diff_diff/staggered_aggregation.py +++ b/diff_diff/staggered_aggregation.py @@ -33,7 +33,7 @@ class EventStudyAggregation: overall : dict or None Eq. (4.14) overall ATT (``att`` / ``se`` / ``effective_df``) - the unweighted mean of post-treatment ES(e). Consumed by - ``StaggeredTripleDifference`` as ``overall_att_es``; CallawaySantAnna + TripleDifference's staggered mode as ``overall_att_es``; CallawaySantAnna leaves it unread. ``None`` when no post-treatment horizon qualifies. df_used : float or None The ONE df every ES row's inference actually used, recorded iff it @@ -221,7 +221,7 @@ def _get_agg_cache(precomputed: "PrecomputedData") -> Dict[str, Any]: on the precomputed dict. The cache is validated by ARRAY IDENTITY, not dict residency: - StaggeredTripleDifference aggregates through a shallow copy of + TripleDifference's staggered mode aggregates through a shallow copy of precomputed with a replaced (eligibility-zeroed) ``unit_cohorts``, so a cache keyed to the dict could serve stale tables across the copy. ``cohorts_ref``/``sw_ref`` pin the exact arrays the tables @@ -1186,7 +1186,7 @@ def _aggregate_event_study( # Eq. (4.14) overall ATT: the unweighted mean of the post-treatment # event-study effects ES(e). Stashed on self (mirroring _event_study_vcov) - # so the StaggeredTripleDifference estimator can expose it as overall_att_es; + # so TripleDifference's staggered mode can expose it as overall_att_es; # CallawaySantAnna leaves it unread. Post-treatment is the library predicate # e >= -anticipation (matching _aggregate_simple and the default overall_att), # NOT a hardcoded e >= 0. diff --git a/diff_diff/staggered_results.py b/diff_diff/staggered_results.py index 05167cb7e..88575e889 100644 --- a/diff_diff/staggered_results.py +++ b/diff_diff/staggered_results.py @@ -380,7 +380,7 @@ def _aggregate_compute( # Shallow copy: shares every array (no data is duplicated) but gives the # aggregators a scratch dict to memoize `_agg_cache` into, so the kit - # itself is never written to. Mirrors what StaggeredTripleDifference + # itself is never written to. Mirrors what the staggered DDD engine # already does when it aggregates through a modified copy. Shared by # the bootstrap replay and the analytical aggregation below, exactly # as fit shares one `precomputed` between them. diff --git a/diff_diff/staggered_triple_diff.py b/diff_diff/staggered_triple_diff.py deleted file mode 100644 index 34638e9d2..000000000 --- a/diff_diff/staggered_triple_diff.py +++ /dev/null @@ -1,269 +0,0 @@ -""" -Staggered Triple Difference (DDD) estimator. - -Implements Ortiz-Villavicencio & Sant'Anna (2025) for staggered adoption -settings with an eligibility dimension, combining group-time DDD effects -via GMM-optimal weighting. - -Core pairwise DiD computation matches R's triplediff::compute_did() exactly -(Riesz/Hajek normalization, separate M1/M3 OR corrections, hessian = (X'WX)^{-1}*n). - -The estimation engine itself lives in `_staggered_triple_diff_engine.py`, shared -verbatim with `TripleDifference`'s staggered mode (ledger row M-013). This module -keeps the deprecated class's 3.x surface: its own constructor (including R's -compact `control_group` spellings) and its own `fit` signature (including the -`eligibility` parameter name), both frozen until the 4.0 removal. -""" - -import warnings -from typing import TYPE_CHECKING, List, Optional - -from diff_diff._base import BaseEstimator -from diff_diff._staggered_triple_diff_engine import _StaggeredTripleDiffEngineMixin -from diff_diff.staggered_aggregation import ( - CallawaySantAnnaAggregationMixin, -) -from diff_diff.staggered_bootstrap import ( - CallawaySantAnnaBootstrapMixin, -) -from diff_diff.staggered_triple_diff_results import StaggeredTripleDiffResults -from diff_diff.utils import validate_n_bootstrap - -if TYPE_CHECKING: - import pandas as pd - - from diff_diff.survey import SurveyDesign - -__all__ = [ - "StaggeredTripleDifference", - "StaggeredTripleDiffResults", -] - -# StaggeredTripleDifference 3.9 deprecation message (row M-013; the SDDD alias -# is the same class object, so constructing via the alias emits this too - -# row M-064). Pinned verbatim by tests/test_v4_merge_ddd.py and the targeted -# pytest filter in pyproject.toml. -_SDDD_DEPRECATION_MSG = ( - "StaggeredTripleDifference is deprecated and will be removed in 4.0; use " - "TripleDifference().fit(..., unit=, time=, first_treat=, partition=) " - "instead - the same engine, so the numbers are unchanged. Two vocabulary " - "changes on the merged surface: the eligibility= parameter is named " - "partition=, and control_group takes the underscored values " - "'not_yet_treated'/'never_treated'. The SDDD alias is deprecated with it." -) - - -class StaggeredTripleDifference( - _StaggeredTripleDiffEngineMixin, - CallawaySantAnnaBootstrapMixin, - CallawaySantAnnaAggregationMixin, - BaseEstimator, -): - """ - Staggered Triple Difference (DDD) estimator. - - .. deprecated:: 3.9 - Deprecated in 3.9 and removed in 4.0 (ledger row M-013). Use - ``TripleDifference().fit(..., unit=, time=, first_treat=, partition=)``, - which runs this exact engine. The ``eligibility=`` parameter is named - ``partition=`` there, and ``control_group`` takes the underscored - values ``"not_yet_treated"``/``"never_treated"``. The ``SDDD`` alias is - deprecated with the class (row M-064). - - Computes group-time average treatment effects ATT(g,t) for settings - with staggered adoption and a binary eligibility dimension, using the - three-DiD decomposition of Ortiz-Villavicencio & Sant'Anna (2025). - - Multiple comparison groups are combined via GMM-optimal (inverse-variance) - weighting. Event study, group, and overall aggregations are supported. - - Parameters - ---------- - estimation_method : str, default="dr" - Estimation method: "dr" (doubly robust), "ipw" (inverse probability - weighting), or "reg" (regression adjustment). - alpha : float, default=0.05 - Significance level. - anticipation : int, default=0 - Number of anticipation periods. Must be a non-negative integer; - ``bool`` is rejected - validated at ``fit()`` (construction stays - permissive on this deprecated class). - base_period : str, default="varying" - Base period selection: "varying" (consecutive comparisons) or - "universal" (always vs g-1-anticipation). - n_bootstrap : int, default=0 - Number of multiplier bootstrap repetitions. 0 disables bootstrap. - bootstrap_weights : str, default="rademacher" - Bootstrap weight distribution: "rademacher", "mammen", or "webb". - seed : int or None, default=None - Random seed for reproducibility. - cband : bool, default=True - Whether to compute simultaneous confidence bands. - pscore_trim : float, default=0.01 - Propensity score trimming bound. - cluster : str or None, default=None - Column name for cluster-robust standard errors. - rank_deficient_action : str, default="warn" - Action for rank-deficient design matrices: "warn", "error", "silent". - epv_threshold : float, default=10 - Minimum events per variable for propensity score logistic regression. - A warning is emitted when EPV falls below this threshold. - pscore_fallback : str, default="error" - Action when propensity score estimation fails: "error" (raise) or - "unconditional" (fall back to unconditional propensity). - - References - ---------- - Ortiz-Villavicencio, M. & Sant'Anna, P.H.C. (2025). "Better Understanding - Triple Differences Estimators." arXiv:2505.09942v3. - """ - - # Names this estimator in the shared bootstrap mixin's user-facing - # warnings. The mixin is shared with the other hosts, so a hard-coded - # literal there would misname whichever surface was actually fit. - _BOOTSTRAP_LABEL: str = "StaggeredTripleDifference" - - def __init__( - self, - estimation_method: str = "dr", - control_group: str = "notyettreated", - alpha: float = 0.05, - anticipation: int = 0, - base_period: str = "varying", - n_bootstrap: int = 0, - bootstrap_weights: str = "rademacher", - seed: Optional[int] = None, - cband: bool = True, - pscore_trim: float = 0.01, - cluster: Optional[str] = None, - rank_deficient_action: str = "warn", - epv_threshold: float = 10, - pscore_fallback: str = "error", - ): - # Row M-013. Emitted per construction (not once): a fitted instance - # never re-warns, but each new one does, and set_params re-emits via - # BaseEstimator's transactional probe re-init - the documented side - # effect MultiPeriodDiD's shim also has. - warnings.warn(_SDDD_DEPRECATION_MSG, FutureWarning, stacklevel=2) - if estimation_method not in ["dr", "ipw", "reg"]: - raise ValueError( - f"estimation_method must be 'dr', 'ipw', or 'reg', " f"got '{estimation_method}'" - ) - if control_group not in ["nevertreated", "notyettreated"]: - raise ValueError( - f"control_group must be 'nevertreated' or 'notyettreated', " - f"got '{control_group}'" - ) - if not (0 < pscore_trim < 0.5): - raise ValueError(f"pscore_trim must be in (0, 0.5), got {pscore_trim}") - if bootstrap_weights not in ["rademacher", "mammen", "webb"]: - raise ValueError( - f"bootstrap_weights must be 'rademacher', 'mammen', or 'webb', " - f"got '{bootstrap_weights}'" - ) - if rank_deficient_action not in ["warn", "error", "silent"]: - raise ValueError( - f"rank_deficient_action must be 'warn', 'error', or 'silent', " - f"got '{rank_deficient_action}'" - ) - if base_period not in ["varying", "universal"]: - raise ValueError( - f"base_period must be 'varying' or 'universal', " f"got '{base_period}'" - ) - if epv_threshold <= 0: - raise ValueError(f"epv_threshold must be > 0, got {epv_threshold}") - if pscore_fallback not in ["error", "unconditional"]: - raise ValueError( - f"pscore_fallback must be 'error' or 'unconditional', " f"got '{pscore_fallback}'" - ) - - self.estimation_method = estimation_method - self.control_group = control_group - self.alpha = alpha - self.anticipation = anticipation - self.base_period = base_period - validate_n_bootstrap(n_bootstrap) - self.n_bootstrap = n_bootstrap - self.bootstrap_weights = bootstrap_weights - self.seed = seed - self.cband = cband - self.pscore_trim = pscore_trim - self.cluster = cluster - self.rank_deficient_action = rank_deficient_action - self.epv_threshold = epv_threshold - self.pscore_fallback = pscore_fallback - - self.is_fitted_ = False - self.results_: Optional[StaggeredTripleDiffResults] = None - - # ------------------------------------------------------------------ - # fit() - # ------------------------------------------------------------------ - - def fit( - self, - data: "pd.DataFrame", - outcome: str, - unit: str, - time: str, - first_treat: str, - eligibility: str, - covariates: Optional[List[str]] = None, - aggregate: Optional[str] = None, - balance_e: Optional[int] = None, - survey_design: Optional["SurveyDesign"] = None, - ) -> StaggeredTripleDiffResults: - """ - Fit the staggered triple difference estimator. - - Parameters - ---------- - data : pd.DataFrame - Panel data. - outcome : str - Outcome variable column name. - unit : str - Unit identifier column name. - time : str - Time period column name. - first_treat : str - Column with the enabling period for each unit's group. - Use 0 or np.inf for never-enabled units. - eligibility : str - Binary eligibility indicator column (0/1, time-invariant). - covariates : list of str, optional - Covariate column names. - aggregate : str, optional - Aggregation method: "event_study", "group", "simple", or "all". - balance_e : int, optional - Event time to balance on for event study. - survey_design : SurveyDesign, optional - Survey design specification for complex survey data. When - provided, uses survey weights for estimation (weighted Riesz - representers, weighted logit, weighted OLS) and design-based - variance for aggregated SEs (overall, event study, group) via - Taylor Series Linearization or replicate weights. Requires - ``weight_type='pweight'``. - - Returns - ------- - StaggeredTripleDiffResults - """ - # The engine is shared with TripleDifference's staggered mode; this - # class supplies its own vocabulary and its own frame depth - # (user -> fit -> core), so warnings attribute exactly as in 3.x. - return self._fit_staggered_core( - data, - outcome, - unit, - time, - first_treat, - eligibility, - covariates=covariates, - aggregate=aggregate, - balance_e=balance_e, - survey_design=survey_design, - estimator_name="StaggeredTripleDifference", - partition_label="eligibility", - _frame_offset=1, - ) diff --git a/diff_diff/triple_diff.py b/diff_diff/triple_diff.py index 4d72a3051..53b7d93aa 100644 --- a/diff_diff/triple_diff.py +++ b/diff_diff/triple_diff.py @@ -38,7 +38,6 @@ from diff_diff._deprecation import ( NOT_SUPPLIED, require_arg, - resolve_renamed_kwarg, ) from diff_diff._staggered_triple_diff_engine import _StaggeredTripleDiffEngineMixin from diff_diff.linalg import _rank_guarded_inv, solve_logit, solve_ols @@ -473,8 +472,8 @@ class TripleDifference( Comparison cohort for staggered mode: ``"not_yet_treated"`` (units whose enabling period is still in the future) or ``"never_treated"`` (the never-enabled cohort only). The compact R spellings - ``"notyettreated"``/``"nevertreated"`` are accepted only by the - deprecated ``StaggeredTripleDifference`` and die with it at 4.0. + ``"notyettreated"``/``"nevertreated"`` were accepted only by the + removed ``StaggeredTripleDifference`` class (4.0, row M-013). anticipation : int, default=0 Number of periods before the enabling period in which units may already respond. Must be a non-negative integer; ``bool`` is @@ -619,8 +618,8 @@ def __init__( ) # Staggered-mode value contracts (M-013). The merged class uses the # UNDERSCORED library vocabulary from birth; R's compact spellings - # ("notyettreated"/"nevertreated") die with StaggeredTripleDifference - # at 4.0 and are deliberately NOT accepted here. + # ("notyettreated"/"nevertreated") died with the StaggeredTripleDifference + # class at 4.0 and are deliberately NOT accepted here. if control_group not in ("never_treated", "not_yet_treated"): raise ValueError( f"control_group must be 'never_treated' or 'not_yet_treated', " @@ -751,9 +750,10 @@ def fit( all estimation methods ("reg", "ipw", "dr"). time : str In STAGGERED mode, the calendar period column (keyword). In 2x2x2 - mode it is the deprecated alias for ``post`` (row M-031) and warns - with ``FutureWarning``. From 4.0 it means the calendar column only - (row M-085). + mode ``time=`` is not accepted: since 4.0 it means the calendar + column only, and passing it raises ``ValueError`` naming ``post=`` + (row M-085; through 3.x it was the deprecated alias for ``post``, + row M-031). unit : str, keyword-only Staggered mode. Unit identifier column. first_treat : str, keyword-only @@ -784,9 +784,9 @@ def fit( # A bad `aggregate` value must not be masked by a mode error (3(a)'s # `spec` precedent). Consequence, deliberate: fit(aggregate="bogus") # WITHOUT first_treat reports the invalid value, not the mode error. - # `_validate_vcov_type` is deliberately NOT hoisted here - moving it - # ahead of the M-031 shim would make a direct-attribute-mutated - # vcov_type raise BEFORE the rename FutureWarning that fires today. + # `_validate_vcov_type` is deliberately NOT hoisted here - it stays + # after the 2x2x2 keyword checks so a direct-attribute-mutated vcov_type + # is reported after, not instead of, a wrong-keyword error. if aggregate is not None and aggregate not in ("event_study", "group", "simple", "all"): raise ValueError( f"aggregate must be 'event_study', 'group', 'simple', or 'all', " @@ -794,8 +794,8 @@ def fit( ) # ---- Step 1: staggered mode --------------------------------------- - # Raw sentinels forwarded: the 2x2x2 rename shim must not run, because - # `time=` is the calendar column on this path, not a deprecated alias. + # Raw sentinels forwarded: the 2x2x2 `time=` check must not run, because + # `time=` is the calendar column on this path. if first_treat is not NOT_SUPPLIED: return self._fit_staggered( data, @@ -853,24 +853,28 @@ def fit( f"parameter(s), or pass first_treat= to fit the staggered design." ) - # ---- Step 4: 2x2x2 mode, the M-031 rename shim -------------------- + # ---- Step 4: 2x2x2 mode, the time=/post= contract (M-085) -------- # group/partition are sentinel-defaulted now (they must be omissible in # staggered mode), so their missing-argument TypeErrors are restored - # here. They run BEFORE the rename shim: after it, fit(df, "y", - # time="t") would newly emit the M-031 FutureWarning before raising the - # missing-group TypeError (today it raises immediately, with no - # warning, and under -W error the surfaced exception type would flip). + # here. They run BEFORE the time= check so a missing group is reported + # as the TypeError, not as the M-085 ValueError. require_arg("TripleDifference.fit", "group", group) require_arg("TripleDifference.fit", "partition", partition) - post = resolve_renamed_kwarg( - "TripleDifference.fit", - "time", - time, - "post", - post, - default=NOT_SUPPLIED, - extra="From 4.0, time= means the calendar column only.", - ) + # Since 4.0 `time` means the staggered calendar column ONLY (row M-085; + # through 3.x it was the deprecated alias for `post`, row M-031): both + # supplied is the old both-supplied error, time= alone raises rather + # than being reinterpreted as the post dummy. + if post is not NOT_SUPPLIED and time is not NOT_SUPPLIED: + raise ValueError( + "TripleDifference.fit: got both post= and time=; in the 2x2x2 design " + "pass only post= (time= is the staggered calendar column, row M-085)." + ) + if time is not NOT_SUPPLIED: + raise ValueError( + "TripleDifference.fit: in the 2x2x2 design time= is the staggered " + "calendar column only (4.0, row M-085); pass the 0/1 post-treatment " + "indicator as post=." + ) require_arg("TripleDifference.fit", "post", post) # Body-local name; the public parameter is post (M-031). time = post @@ -1102,7 +1106,8 @@ def _fit_staggered( Reached only when ``first_treat=`` was supplied. Rejects the other design's parameters rather than ignoring them, then hands off to the - engine shared with the deprecated ``StaggeredTripleDifference``. + staggered engine (``_staggered_triple_diff_engine.py``, formerly the + ``StaggeredTripleDifference`` class, removed at 4.0). """ # Step 1: the 2x2x2 params are rejected, not ignored. A positional 3rd # or 5th argument lands in group/post and is caught here. @@ -1143,8 +1148,8 @@ def _fit_staggered( require_arg("TripleDifference.fit", "partition", partition) # Step 4: cluster= is a CONSTRUCTOR param and the staggered engine has - # no clustered analytical path. The deprecated class accepts-then-warns - # -then-ignores it; this surface is new, so it raises from birth rather + # no clustered analytical path. The removed StaggeredTripleDifference + # class accepted-then-warned-then-ignored it; this surface raises rather # than shipping an accepted-but-unhonored parameter (3(a)'s precedent). if self.cluster is not None: raise ValueError( diff --git a/diff_diff/utils.py b/diff_diff/utils.py index 8f7bc26da..032b18e81 100644 --- a/diff_diff/utils.py +++ b/diff_diff/utils.py @@ -492,8 +492,7 @@ def validate_n_bootstrap(n_bootstrap: Any) -> None: accepts numpy integers, rejects bool/None/float/negative). ``0`` means bootstrap off wherever a ``> 0`` gate exists — the zero-default estimators (CallawaySantAnna, SunAbraham, EfficientDiD, ImputationDiD, - TwoStageDiD, WooldridgeDiD, ContinuousDiD, StaggeredTripleDifference, - TripleDifference, DMLDiD) + TwoStageDiD, WooldridgeDiD, ContinuousDiD, TripleDifference, DMLDiD) and every analytical lane. On the DiD/TWFE wild-bootstrap lane 0 never meant off (the routing consults only ``inference=``); their fit-level floor rejects ``n_bootstrap < 2`` under ``inference="wild_bootstrap"``. @@ -518,8 +517,8 @@ def validate_pscore_trim(value: Any) -> float: 1-element array. Accepted values must additionally satisfy ``1 - trim < 1`` in binary64: a sub-ulp positive trim would round the upper clip bound to exactly 1.0, disabling the guard like ``trim=0``. - StaggeredTripleDifference deliberately keeps its bare range check - (construction-permissive dying class, ledger M-013/M-144). + (The removed StaggeredTripleDifference class kept its bare range check + through 3.x - construction-permissive, ledger M-013/M-144.) """ if isinstance(value, bool) or not isinstance(value, (int, float, np.integer, np.floating)): raise ValueError( @@ -622,8 +621,7 @@ def validate_anticipation(anticipation: Any) -> int: ``TripleDifference``, and RE-CHECKED on the fit path on all of them — the uniform direct-mutation defense: manual re-assignments on eight, EfficientDiD via its ``_validate_params`` re-run, SpilloverDiD via its - in-fit check, TripleDifference and the deprecated - ``StaggeredTripleDifference`` via the shared staggered engine. + in-fit check, TripleDifference (staggered mode) via the staggered engine. """ if isinstance(anticipation, bool) or not isinstance(anticipation, (int, np.integer)): raise ValueError( diff --git a/docs/api/_autosummary/diff_diff.QDiD.rst b/docs/api/_autosummary/diff_diff.QDiD.rst deleted file mode 100644 index b28a178df..000000000 --- a/docs/api/_autosummary/diff_diff.QDiD.rst +++ /dev/null @@ -1,21 +0,0 @@ -diff\_diff.QDiD -=============== - -.. currentmodule:: diff_diff - -.. autoclass:: QDiD - :no-members: - - - .. rubric:: Methods - - .. autosummary:: - - ~QDiD.__init__ - ~QDiD.fit - ~QDiD.get_params - ~QDiD.set_params - - - - diff --git a/docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst b/docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst deleted file mode 100644 index ba4ccdc6d..000000000 --- a/docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst +++ /dev/null @@ -1,41 +0,0 @@ -diff\_diff.StaggeredTripleDifference -==================================== - -.. currentmodule:: diff_diff - -.. autoclass:: StaggeredTripleDifference - :no-members: - - - .. rubric:: Methods - - .. autosummary:: - - ~StaggeredTripleDifference.__init__ - ~StaggeredTripleDifference.fit - ~StaggeredTripleDifference.get_params - ~StaggeredTripleDifference.set_params - - - - - .. rubric:: Attributes - - .. autosummary:: - - ~StaggeredTripleDifference.estimation_method - ~StaggeredTripleDifference.control_group - ~StaggeredTripleDifference.alpha - ~StaggeredTripleDifference.anticipation - ~StaggeredTripleDifference.base_period - ~StaggeredTripleDifference.n_bootstrap - ~StaggeredTripleDifference.cband - ~StaggeredTripleDifference.pscore_trim - ~StaggeredTripleDifference.cluster - ~StaggeredTripleDifference.rank_deficient_action - ~StaggeredTripleDifference.epv_threshold - ~StaggeredTripleDifference.pscore_fallback - ~StaggeredTripleDifference.is_fitted_ - ~StaggeredTripleDifference.bootstrap_weights - ~StaggeredTripleDifference.seed - diff --git a/docs/api/changes_in_changes.rst b/docs/api/changes_in_changes.rst index eb33fbd1d..b0b391925 100644 --- a/docs/api/changes_in_changes.rst +++ b/docs/api/changes_in_changes.rst @@ -16,8 +16,9 @@ inherits exactly in unconditional fits; the covariate branch's linear quantile regressions are not equivariant to nonlinear monotone transforms) and places no testable restrictions on continuous data. -``QDiD`` is the quantile-by-quantile DiD comparison estimator the same paper -formalizes: it adds the control group's over-time quantile change to the +``QDiD`` (``ChangesInChanges(method="qdid")``) is the quantile-by-quantile DiD +comparison estimator the same paper formalizes: it adds the control group's +over-time quantile change to the treated pre-period quantile. Athey & Imbens recommend CiC over QDiD - QDiD's justifying model is not scale-invariant and imposes testable restrictions (in unconditional fits, a warning fires when they appear violated). @@ -87,36 +88,12 @@ Main changes-in-changes estimator class. ~ChangesInChanges.get_params ~ChangesInChanges.set_params -QDiD ----- - -Quantile difference-in-differences comparison estimator. - -*Deprecated in 3.9, removed in 4.0* - use ``ChangesInChanges(method="qdid")`` -instead. The same engine runs either way, so the numbers are unchanged; only -the class spelling is deprecated, not the estimator. - -.. autoclass:: diff_diff.QDiD - :no-index: - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - - .. rubric:: Methods - - .. autosummary:: - - ~QDiD.fit - ~QDiD.get_params - ~QDiD.set_params - ChangesInChangesResults ----------------------- -Results container shared by both estimators (the ``method`` field records -which produced it; the pre-3.9 name ``estimator`` was removed in 4.0; -``QDiDResults`` is an alias of this class). +Results container shared by both methods (the ``method`` field records which +produced it; the pre-3.9 name ``estimator`` and the ``QDiDResults`` alias were +removed in 4.0). .. autoclass:: diff_diff.changes_in_changes_results.ChangesInChangesResults :no-index: diff --git a/docs/api/index.rst b/docs/api/index.rst index 5c02bb7ec..da05badb1 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -33,12 +33,10 @@ regression discontinuity, and the Goodman-Bacon decomposition diagnostic: diff_diff.WooldridgeDiD diff_diff.LPDiD diff_diff.ChangesInChanges - diff_diff.QDiD diff_diff.LWDiD diff_diff.DMLDiD diff_diff.DurationDiD diff_diff.BaconDecomposition - diff_diff.StaggeredTripleDifference diff_diff.RegressionDiscontinuity Results Classes diff --git a/docs/api/staggered.rst b/docs/api/staggered.rst index 45e13db1a..70cf2d00f 100644 --- a/docs/api/staggered.rst +++ b/docs/api/staggered.rst @@ -151,31 +151,12 @@ Bootstrap inference results for Sun-Abraham estimation. :undoc-members: :show-inheritance: -StaggeredTripleDifference -------------------------- - -Ortiz-Villavicencio & Sant'Anna (2025) staggered triple-difference (DDD) estimator -with group-time ATT identification under heterogeneous treatment timing. - -.. deprecated:: 3.9 - Removed in 4.0 (ledger row M-013). Use - :class:`~diff_diff.TripleDifference` with - ``fit(..., unit=, time=, first_treat=, partition=)``, which runs the same - engine. ``eligibility=`` is named ``partition=`` there, and ``control_group`` - takes the underscored values ``"not_yet_treated"``/``"never_treated"``. The - ``SDDD`` alias is deprecated with the class. - -.. autoclass:: diff_diff.StaggeredTripleDifference - :no-index: - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - StaggeredTripleDiffResults -------------------------- -Results container for ``StaggeredTripleDifference`` estimation. +Results container for :class:`~diff_diff.TripleDifference`'s staggered mode +(``fit(..., first_treat=, partition=)``; through 3.x also returned by the removed +``StaggeredTripleDifference`` class). .. autoclass:: diff_diff.StaggeredTripleDiffResults :no-index: diff --git a/docs/api/triple_diff.rst b/docs/api/triple_diff.rst index 06d4a6a51..41ecc3b20 100644 --- a/docs/api/triple_diff.rst +++ b/docs/api/triple_diff.rst @@ -30,11 +30,11 @@ TripleDifference Main estimator class for Triple Difference designs. Since 3.9 this class serves BOTH triple-difference designs (ledger row M-013): -the 2x2x2 design, and the staggered-adoption design that -:class:`~diff_diff.StaggeredTripleDifference` used to own. ``first_treat=`` -selects the staggered engine; mixing the two designs' parameters raises rather -than guessing. The estimation cores are unchanged - both surfaces share one -engine, so the staggered numbers are identical to the deprecated class's. +the 2x2x2 design, and the staggered-adoption design that the +``StaggeredTripleDifference`` class owned through 3.x (removed at 4.0). +``first_treat=`` selects the staggered engine; mixing the two designs' parameters +raises rather than guessing. The estimation core is unchanged - the staggered +numbers are identical to the removed class's. .. code-block:: python diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index ff5cab0e8..f0fd66d28 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -16,7 +16,7 @@ Start here and follow the questions: - **No** → Go to question 1 - **Yes, simultaneous treatment (2×2×2)** → Use :class:`~diff_diff.TripleDifference` - - **Yes, with staggered timing** → Use :class:`~diff_diff.TripleDifference` with ``first_treat=`` (:class:`~diff_diff.StaggeredTripleDifference` is deprecated in 3.9 and runs the same engine until its 4.0 removal) + - **Yes, with staggered timing** → Use :class:`~diff_diff.TripleDifference` with ``first_treat=`` 1. **Is treatment continuous?** (Units receive different doses or intensities) @@ -39,7 +39,7 @@ Start here and follow the questions: 4. **Do you have panel data?** (Multiple observations per unit over time) - **No** → Use :class:`~diff_diff.DifferenceInDifferences` (basic 2x2) - - **No, and you care about effect heterogeneity across the outcome distribution** → Use :class:`~diff_diff.ChangesInChanges` (2x2 quantile treatment effects, invariant to monotone outcome rescaling in unconditional fits; optional numeric covariates via quantile-regression conditioning - the covariate branch's linear quantile regressions are not equivariant to nonlinear monotone transforms; works with panel data too - ``panel=True`` changes only the bootstrap). :class:`~diff_diff.ChangesInChanges` with ``method="qdid"`` is the quantile-DiD comparison estimator (the standalone ``QDiD`` class is deprecated in 3.9); Athey & Imbens (2006) recommend CiC over it + - **No, and you care about effect heterogeneity across the outcome distribution** → Use :class:`~diff_diff.ChangesInChanges` (2x2 quantile treatment effects, invariant to monotone outcome rescaling in unconditional fits; optional numeric covariates via quantile-regression conditioning - the covariate branch's linear quantile regressions are not equivariant to nonlinear monotone transforms; works with panel data too - ``panel=True`` changes only the bootstrap). :class:`~diff_diff.ChangesInChanges` with ``method="qdid"`` is the quantile-DiD comparison estimator (the standalone ``QDiD`` class was removed in 4.0); Athey & Imbens (2006) recommend CiC over it - **Yes** → Go to question 5 5. **Do you need period-specific effects?** (Event study design) @@ -124,7 +124,7 @@ Quick Reference - Two eligibility criteria (DDD) - Parallel trends for both dimensions - DDD ATT (regression, IPW, or DR) - * - ``StaggeredTripleDifference`` + * - ``TripleDifference`` (``fit(..., first_treat=)``) - Staggered DDD with treatment timing - Conditional parallel trends (DDD) - Group-time ATT(g,t), aggregations @@ -144,7 +144,7 @@ Quick Reference - Binary absorbing outcome (spell ended), two groups, common timing - Constant gap (CD) or ratio (PH) between the groups' untreated hazards - Per-date absorption ATT + uniform average (whole-individual bootstrap bands, pretest) - * - ``QDiD`` (deprecated 3.9; use ``ChangesInChanges(method="qdid")``) + * - ``ChangesInChanges(method="qdid")`` - 2x2 quantile-DiD comparison alongside ChangesInChanges - Additive quantile model (scale-dependent, testable restrictions) - ATT + quantile treatment effects (bootstrap inference) @@ -1092,7 +1092,7 @@ estimation. The depth of support varies by estimator and variance method: - Full (TSL; df = ``n_PSU - n_strata``) - Full (IF-reweighting; per-cell + aggregate) - Multiplier (PSU) - * - ``ChangesInChanges`` / ``QDiD`` + * - ``ChangesInChanges`` (both methods) - -- - -- - -- diff --git a/docs/dev-status.md b/docs/dev-status.md index f1797453b..c6eb44074 100644 --- a/docs/dev-status.md +++ b/docs/dev-status.md @@ -47,7 +47,6 @@ Target: ideally < 1000 lines per module; modules ≥3000 lines are candidates fo | `prep.py` | 1878 | Acceptable | | `efficient_did_covariates.py` | 1818 | Acceptable | | `_staggered_triple_diff_engine.py` | 1635 | Acceptable — the shared staggered DDD engine, relocated here in phase 3(b) (row M-013) | -| `staggered_triple_diff.py` | 262 | Acceptable — shrank from 1680 when the engine moved out; now the deprecated class's frozen 3.x surface | | `trop_local.py` | 1662 | Acceptable | | `lpdid.py` | 1607 | Acceptable | | `stacked_did.py` | 1589 | Acceptable | diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index 16ae58353..03b05fb4a 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -30,7 +30,6 @@ groups: - diff_diff/staggered_bootstrap.py - diff_diff/staggered_results.py staggered_triple_diff: - - diff_diff/staggered_triple_diff.py - diff_diff/staggered_triple_diff_results.py # The estimation engine relocated here in phase 3(b) (row M-013) and is # shared with TripleDifference's staggered mode. Private (leading @@ -236,9 +235,10 @@ sources: - path: docs/migration-4.0.md type: user_guide - # ── StaggeredTripleDifference (staggered_triple_diff group) ───────── + # ── staggered DDD engine (staggered_triple_diff group; the + # StaggeredTripleDifference class was removed at the 4.0 cut) ───── - diff_diff/staggered_triple_diff.py: + diff_diff/staggered_triple_diff_results.py: drift_risk: low docs: - path: docs/methodology/REGISTRY.md @@ -248,17 +248,11 @@ sources: type: api_reference - path: docs/tutorials/16_survey_did.ipynb type: tutorial - note: "StaggeredTripleDifference appears in survey-DiD examples" - - path: README.md - section: "Estimators (one-line catalog entry)" - type: user_guide + note: "staggered DDD (TripleDifference first_treat= mode) appears in survey-DiD examples" - path: docs/references.rst type: user_guide - path: diff_diff/guides/llms-full.txt - section: "StaggeredTripleDifference" - type: user_guide - - path: diff_diff/guides/llms.txt - section: "Estimators" + section: "TripleDifference" type: user_guide # Hand-edit-only surfaces before phase 3(b): neither was mapped, so # /docs-impact could not surface them when this estimator changed. @@ -899,7 +893,7 @@ sources: section: "Module (DurationDiDResults rejected by type)" type: methodology - # ── ChangesInChanges + QDiD (changes_in_changes group) ──────────── + # ── ChangesInChanges (method='cic'|'qdid'; changes_in_changes group) ── diff_diff/changes_in_changes.py: drift_risk: medium diff --git a/docs/index.rst b/docs/index.rst index 08f7380af..2daa59bfa 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -170,9 +170,7 @@ Supported Estimators * - :class:`~diff_diff.EfficientDiD` - Chen, Sant'Anna & Xie (2025) efficient DiD * - :class:`~diff_diff.TripleDifference` - - Triple difference (DDD) estimator - * - :class:`~diff_diff.StaggeredTripleDifference` - - Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT (deprecated 3.9 - use :class:`~diff_diff.TripleDifference` with ``first_treat=``) + - Triple difference (DDD) estimator; staggered adoption per Ortiz-Villavicencio & Sant'Anna (2025) via ``first_treat=`` * - :class:`~diff_diff.ContinuousDiD` - Callaway, Goodman-Bacon & Sant'Anna (2024) continuous-treatment dose-response DiD * - :class:`~diff_diff.HeterogeneousAdoptionDiD` @@ -189,8 +187,6 @@ Supported Estimators - Chang (2020) double/debiased ML DiD; staggered ATT(g,t) with cross-fitted nuisance learners (panel or declared repeated cross sections; survey/cluster support); Caetano et al. (2026) bad-control score via ``fit(bad_control=)`` (bad-control lane: panel only, ``cluster=`` only) * - :class:`~diff_diff.DurationDiD` - Deaner & Ku (2026) causal duration DiD for binary absorbing outcomes; untreated-hazard restriction (common dynamics or proportional hazards), whole-individual bootstrap bands - * - :class:`~diff_diff.QDiD` - - Quantile DiD comparison estimator applying DiD quantile-by-quantile (deprecated 3.9 - use :class:`~diff_diff.ChangesInChanges` with ``method="qdid"``) * - :class:`~diff_diff.RegressionDiscontinuity` - Calonico, Cattaneo & Titiunik (2014) sharp/fuzzy RD with robust bias-corrected inference * - :class:`~diff_diff.TROP` diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 57d7c4304..74f99a827 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -95,7 +95,7 @@ where τ is the ATT. - Rank-deficient design matrix (collinearity): warns and sets NA for dropped coefficients (R-style, matches `lm()`) - Tolerance: `1e-07` (matches R's `qr()` default), relative to largest diagonal element of R in QR decomposition - Controllable via `rank_deficient_action` parameter: "warn" (default), "error", or "silent" - - **Note (scale invariance):** the shared `diff_diff/linalg.py` rank check re-detects on column-equilibrated (unit 2-norm) columns when the raw pass reports a deficiency (adopting the higher equilibrated rank and its scale-corrected pivot selection only when a large-scale column inflated the threshold; genuine collinearity keeps the raw selection), and the least-squares solve equilibrates columns then unscales the coefficients — so rank detection and the fit are invariant to per-column scaling; for a well-scaled collinear design the dropped column is unchanged, while a scale-induced under-count adopts the scale-corrected equilibrated selection (which may differ from the raw choice but is guaranteed to retain an identified subset). This covers every covariate outcome-regression fit routed through `solve_ols` — DiD, TwoWayFixedEffects, MultiPeriodDiD, ImputationDiD, TwoStageDiD, TripleDifference, and (as of the OR scale-equilibration change) CallawaySantAnna's `_compute_all_att_gt_covariate_reg` / `_doubly_robust` and StaggeredTripleDifference's `_compute_or` — see the CallawaySantAnna rank-deficiency Note's scope statement. **Certification fast path (2026-07):** the common full-rank case is short-circuited by a Gram/eigenvalue certification on the equilibrated Gram at the documented `_rank_guarded_inv` 1e-10 Gram threshold — two orders stricter than the QR full-rank boundary, so it never reports full rank where the pivoted-QR path would report a deficiency; deficient or uncertifiable designs (including all n < k and non-finite inputs) fall through to the pivoted-QR path verbatim, leaving every drop decision, pivot selection, and rank count unchanged. Certification never SELECTS columns (it only confirms none need dropping), so the `_rank_guarded_inv` note about equilibrated-Gram column selection being reserved for the IF inverse still holds. + - **Note (scale invariance):** the shared `diff_diff/linalg.py` rank check re-detects on column-equilibrated (unit 2-norm) columns when the raw pass reports a deficiency (adopting the higher equilibrated rank and its scale-corrected pivot selection only when a large-scale column inflated the threshold; genuine collinearity keeps the raw selection), and the least-squares solve equilibrates columns then unscales the coefficients — so rank detection and the fit are invariant to per-column scaling; for a well-scaled collinear design the dropped column is unchanged, while a scale-induced under-count adopts the scale-corrected equilibrated selection (which may differ from the raw choice but is guaranteed to retain an identified subset). This covers every covariate outcome-regression fit routed through `solve_ols` — DiD, TwoWayFixedEffects, MultiPeriodDiD, ImputationDiD, TwoStageDiD, TripleDifference, and (as of the OR scale-equilibration change) CallawaySantAnna's `_compute_all_att_gt_covariate_reg` / `_doubly_robust` and the staggered DDD engine's `_compute_or` (`_staggered_triple_diff_engine.py`, TripleDifference staggered mode) — see the CallawaySantAnna rank-deficiency Note's scope statement. **Certification fast path (2026-07):** the common full-rank case is short-circuited by a Gram/eigenvalue certification on the equilibrated Gram at the documented `_rank_guarded_inv` 1e-10 Gram threshold — two orders stricter than the QR full-rank boundary, so it never reports full rank where the pivoted-QR path would report a deficiency; deficient or uncertifiable designs (including all n < k and non-finite inputs) fall through to the pivoted-QR path verbatim, leaving every drop decision, pivot selection, and rank count unchanged. Certification never SELECTS columns (it only confirms none need dropping), so the `_rank_guarded_inv` note about equilibrated-Gram column selection being reserved for the IF inverse still holds. - **Note (run-to-run determinism of the clustered vcov, 2026-07):** the Rust backend's clustered `compute_robust_vcov` / `solve_ols(return_vcov=True)` previously aggregated cluster scores in `HashMap` iteration order — SipHash-randomized per call — so identical inputs produced ~1e-14-level distinct vcov values across calls (the Python backend was always bit-stable). Fixed in #653: rows accumulate in first-appearance order (ascending for the factorized 0..G-1 ids the dispatcher passes, matching NumPy's groupby order); repeated identical calls are bit-identical (a 50-call probe during the fix; the shipped regression asserts 20 repeats per id-layout), NumPy parity unchanged (~1e-14 cross-backend GEMM tolerance). Regression: `tests/test_rust_backend.py::TestClusterVcovDeterminism` (contiguous + non-contiguous unsorted ids, plus a raw-kernel path). - **Note (covariate-name collision guard):** `DifferenceInDifferences`, `MultiPeriodDiD`, and `TwoWayFixedEffects` build their `coefficients` dict by zipping a variable-name list (structural terms + user covariate column names, appended verbatim) with the coefficient vector. A covariate whose name equals a reserved structural name — `const`, the treatment/time column names, the `{treatment}:{time}` interaction (DiD), the `period_{p}` dummies / `{treatment}:period_{p}` interactions (MultiPeriodDiD), `ATT` (TwoWayFixedEffects), any fixed-effect / unit / time dummy name, or an internal `_`-prefixed working column (`_treat_time`, `_did_treatment`, `_treatment_post`) — would silently overwrite that structural coefficient (dict last-write-wins) with no error. `fit()` now calls `utils.validate_covariate_names()` before building the design, raising `ValueError` on such a collision (case-sensitive, so `Const` is allowed) and on duplicate covariate names. Reserved fixed-effect/unit/time dummy names are taken from the same `pd.get_dummies(..., drop_first=True)` call used to build them (exact match, including for `Categorical` columns). The TwoWayFixedEffects guard fires on both variance paths — the within-transform path exposes only `{"ATT": att}`, but a `_treatment_post`-named covariate would still clobber the internal interaction column. The influence-function estimators (CallawaySantAnna, etc.) key results by `(g, t)` and are unaffected. @@ -934,7 +934,7 @@ double-weight). - Requires never-treated units as comparison group (identified by `first_treat=0` or `never_treated=True`) - Warns if no never-treated units exist (suggests alternative comparison strategies) - Limited pre-treatment periods reduce ability to test parallel trends -- **Note (rank-guarded IF standard errors):** The analytical SE paths invert the propensity-score Hessian (`H_psi`) and outcome-regression bread (`X'WX`) across every `(g, t)` cell via `_safe_inv()`. `np.linalg.solve`/`inv` raise `LinAlgError` only on *exactly* singular matrices; a **near**-singular Gram (a constant or collinear covariate) does **not** raise, so the prior code returned a garbage inverse (entries ~1e13) that flowed straight into the SE (`overall_se` ~1e13 was reproduced for `estimation_method="dr"`). `_safe_inv()` now delegates to the shared `_rank_guarded_inv()` (`diff_diff/linalg.py`): it symmetrically equilibrates `A → D^{-1/2} A D^{-1/2}` (`D = diag(A)`), then, when rank-deficient, inverts a **column-dropped** principal submatrix — keeping the most-independent columns via pivoted QR on the **equilibrated** Gram. This is a column-drop generalized inverse in the **same family** as the point estimate / R's `lm()` (drop redundant columns, *not* a minimum-norm pseudo-inverse), but the column *selection* is computed on the equilibrated Gram and is therefore **scale-invariant** by construction — so it need not pick the *same member* of a collinear set as the point estimate's raw-pivot `_detect_rank_deficiency` under mixed-scale *exact* collinearity (see the selection caveat below); the resulting SE is well-defined and order-invariant regardless of which redundant member is dropped. The `rcond = 1e-10` relative-eigenvalue threshold on the equilibrated Gram sets the rank (chosen because a Gram squares the condition number of `X`; matches EfficientDiD's `tol/max_eigval`). The result is a **finite SE on the identified covariate subset**; an all-NaN inverse (and NaN SE) is returned **only** on true rank-0. **Column-drop equals the full-rank limit:** using a column-drop generalized inverse (rather than a minimum-norm pseudo-inverse) makes the analytical SE equal the **well-conditioned near-collinear limit** — replacing an exactly-collinear covariate with a near-collinear (full-rank) one yields the same SE to working precision (verified `se_ratio ≈ 1` across `reg`/`ipw`/`dr`), for every per-cell bread (the PS Hessian — including unweighted ipw's since v3.7 — and the control- *and* treated-side OR breads, including reg's centered estimation-effect Gram `G` since v3.7). A minimum-norm pseudo-inverse would instead diverge sharply when the IF multiplier leaves `range(A)` — e.g. a control (or treated-sub-cell) bread multiplied by a mean from a cell where the covariate is *not* collinear — so it is rejected. With column-drop there is no such divergence; a covariate that is rank-deficient only within one cell still legitimately enters the other cells' full-rank fits, so the ATT and SE reflect that (poor) covariate specification, surfaced by the aggregate warning. **Selection caveat (equilibrated vs raw pivot):** because `_rank_guarded_inv` pivots the *equilibrated* Gram, the *member* it drops from a collinear set can differ from the point estimate's raw-pivot `_detect_rank_deficiency` choice **only** under mixed-scale *exact* collinearity (e.g. a covariate and its `1e8×` duplicate). This selection difference does **not** change the SE: dropping either member of an exactly-collinear pair leaves the identified subspace — hence the variance — unchanged, and the SE is order-invariant (verified for both column orders and under survey weighting). It is a documented, intentional deviation in the *generalized-inverse column choice* (equilibrated, scale-invariant) from the point-estimate's raw pivot; it is not a parity claim with the raw selection. **Caveat (point-estimate side, NOT the rank-guard):** CS/SDDD's reg/DR *point-estimate* OR fit now routes through the scale-equilibrated `solve_ols` (SVD/gelsd), so a covariate correlated with another regressor at a very large scale no longer perturbs the point-estimate ATT — or the IF SE that consistently follows it — under `reg`/`dr`; scale-invariance is pinned by tests (covariate + large constant offset → ATT(g,t) unchanged to ~1e-11). This is independent of the rank-guarded IF inverse, whose SE is invariant to which collinear member is dropped (see the selection caveat above). The well-conditioned fast path returns `np.linalg.solve(A, I)` unchanged (R-parity preserved). `fit()` emits ONE aggregate `UserWarning` (count + max condition number) reporting the dropped redundant direction(s); suppressed under `rank_deficient_action="silent"`. **`rank_deficient_action` enforcement:** `"error"` is enforced *upstream* at the point-estimate solve (`solve_ols` / `solve_logit` / the covariate-regression fit), which raises `ValueError` when the covariate **design** is rank-deficient at its `1e-7` threshold (`_detect_rank_deficiency`) **before** any influence-function SE is computed — so for a genuinely (design-)rank-deficient covariate the rank-guarded IF inverse is reached only under `"warn"`/`"silent"`. The IF guard truncates on a *stricter* relative threshold, though: `1e-10` on the **equilibrated Gram** vs `1e-7` on the design, and a Gram squares `X`'s condition number, so the IF guard drops a direction once `X`'s singular-value ratio falls below ~`1e-5` (= √`1e-10`), well above the design's `1e-7`. A cell that is *near-singular yet still full-rank by the upstream design check* (singular-value ratio between ~`1e-7` and ~`1e-5`) therefore passes the `"error"` gate without an exception and is **still** column-dropped by the IF guard — the guard does not re-raise, and (under `"error"`/`"warn"`, not `"silent"`) the aggregate warning still fires. So `"error"` blocks design-rank-deficient *covariates*; it does not promise that every near-singular IF bread/Hessian raises. Sibling of axis-A finding #17 in the Phase 2 silent-failures audit. +- **Note (rank-guarded IF standard errors):** The analytical SE paths invert the propensity-score Hessian (`H_psi`) and outcome-regression bread (`X'WX`) across every `(g, t)` cell via `_safe_inv()`. `np.linalg.solve`/`inv` raise `LinAlgError` only on *exactly* singular matrices; a **near**-singular Gram (a constant or collinear covariate) does **not** raise, so the prior code returned a garbage inverse (entries ~1e13) that flowed straight into the SE (`overall_se` ~1e13 was reproduced for `estimation_method="dr"`). `_safe_inv()` now delegates to the shared `_rank_guarded_inv()` (`diff_diff/linalg.py`): it symmetrically equilibrates `A → D^{-1/2} A D^{-1/2}` (`D = diag(A)`), then, when rank-deficient, inverts a **column-dropped** principal submatrix — keeping the most-independent columns via pivoted QR on the **equilibrated** Gram. This is a column-drop generalized inverse in the **same family** as the point estimate / R's `lm()` (drop redundant columns, *not* a minimum-norm pseudo-inverse), but the column *selection* is computed on the equilibrated Gram and is therefore **scale-invariant** by construction — so it need not pick the *same member* of a collinear set as the point estimate's raw-pivot `_detect_rank_deficiency` under mixed-scale *exact* collinearity (see the selection caveat below); the resulting SE is well-defined and order-invariant regardless of which redundant member is dropped. The `rcond = 1e-10` relative-eigenvalue threshold on the equilibrated Gram sets the rank (chosen because a Gram squares the condition number of `X`; matches EfficientDiD's `tol/max_eigval`). The result is a **finite SE on the identified covariate subset**; an all-NaN inverse (and NaN SE) is returned **only** on true rank-0. **Column-drop equals the full-rank limit:** using a column-drop generalized inverse (rather than a minimum-norm pseudo-inverse) makes the analytical SE equal the **well-conditioned near-collinear limit** — replacing an exactly-collinear covariate with a near-collinear (full-rank) one yields the same SE to working precision (verified `se_ratio ≈ 1` across `reg`/`ipw`/`dr`), for every per-cell bread (the PS Hessian — including unweighted ipw's since v3.7 — and the control- *and* treated-side OR breads, including reg's centered estimation-effect Gram `G` since v3.7). A minimum-norm pseudo-inverse would instead diverge sharply when the IF multiplier leaves `range(A)` — e.g. a control (or treated-sub-cell) bread multiplied by a mean from a cell where the covariate is *not* collinear — so it is rejected. With column-drop there is no such divergence; a covariate that is rank-deficient only within one cell still legitimately enters the other cells' full-rank fits, so the ATT and SE reflect that (poor) covariate specification, surfaced by the aggregate warning. **Selection caveat (equilibrated vs raw pivot):** because `_rank_guarded_inv` pivots the *equilibrated* Gram, the *member* it drops from a collinear set can differ from the point estimate's raw-pivot `_detect_rank_deficiency` choice **only** under mixed-scale *exact* collinearity (e.g. a covariate and its `1e8×` duplicate). This selection difference does **not** change the SE: dropping either member of an exactly-collinear pair leaves the identified subspace — hence the variance — unchanged, and the SE is order-invariant (verified for both column orders and under survey weighting). It is a documented, intentional deviation in the *generalized-inverse column choice* (equilibrated, scale-invariant) from the point-estimate's raw pivot; it is not a parity claim with the raw selection. **Caveat (point-estimate side, NOT the rank-guard):** the CS and staggered-DDD engines' reg/DR *point-estimate* OR fit now routes through the scale-equilibrated `solve_ols` (SVD/gelsd), so a covariate correlated with another regressor at a very large scale no longer perturbs the point-estimate ATT — or the IF SE that consistently follows it — under `reg`/`dr`; scale-invariance is pinned by tests (covariate + large constant offset → ATT(g,t) unchanged to ~1e-11). This is independent of the rank-guarded IF inverse, whose SE is invariant to which collinear member is dropped (see the selection caveat above). The well-conditioned fast path returns `np.linalg.solve(A, I)` unchanged (R-parity preserved). `fit()` emits ONE aggregate `UserWarning` (count + max condition number) reporting the dropped redundant direction(s); suppressed under `rank_deficient_action="silent"`. **`rank_deficient_action` enforcement:** `"error"` is enforced *upstream* at the point-estimate solve (`solve_ols` / `solve_logit` / the covariate-regression fit), which raises `ValueError` when the covariate **design** is rank-deficient at its `1e-7` threshold (`_detect_rank_deficiency`) **before** any influence-function SE is computed — so for a genuinely (design-)rank-deficient covariate the rank-guarded IF inverse is reached only under `"warn"`/`"silent"`. The IF guard truncates on a *stricter* relative threshold, though: `1e-10` on the **equilibrated Gram** vs `1e-7` on the design, and a Gram squares `X`'s condition number, so the IF guard drops a direction once `X`'s singular-value ratio falls below ~`1e-5` (= √`1e-10`), well above the design's `1e-7`. A cell that is *near-singular yet still full-rank by the upstream design check* (singular-value ratio between ~`1e-7` and ~`1e-5`) therefore passes the `"error"` gate without an exception and is **still** column-dropped by the IF guard — the guard does not re-raise, and (under `"error"`/`"warn"`, not `"silent"`) the aggregate warning still fires. So `"error"` blocks design-rank-deficient *covariates*; it does not promise that every near-singular IF bread/Hessian raises. Sibling of axis-A finding #17 in the Phase 2 silent-failures audit. *Variance families (`vcov_type`, IF-based):* - `hc1` (default, only accepted value) — per-unit IF variance per Callaway & Sant'Anna (2021) when `cluster=None`; cluster-robust CR1 Liang-Zeger on the IF when `cluster=X` is set (synthesizes `SurveyDesign(psu=X)` internally, threading through the same PSU machinery as explicit survey designs). When `survey_design=SurveyDesign(psu=Y)` is provided, the explicit PSU takes precedence; if `cluster=X` is also set with a different partition, emits a `UserWarning` (PSU wins). @@ -1158,7 +1158,7 @@ The multiplier bootstrap uses random weights w_i with E[w]=0 and Var(w)=1: - Detection: Pivoted QR decomposition with tolerance `1e-07` (R's `qr()` default), with a **column-equilibration re-check** (unit 2-norm) that makes the rank *count* invariant to per-column scaling; the dropped-column selection is unchanged for well-scaled collinear designs (a scale-induced under-count instead adopts the scale-corrected equilibrated selection) - Handling: Warns and drops linearly dependent columns, sets NA for dropped coefficients (R-style, matches `lm()`) - Parameter: `rank_deficient_action` controls behavior: "warn" (default), "error", or "silent" - - **Note:** Rank detection and the least-squares solve are invariant to per-column scaling in BOTH the Python and Rust backends. The rank threshold is anchored to the largest pivot/singular value, so a column on a large raw scale (e.g. an unstandardized covariate in raw population or currency units) previously inflated the threshold and false-dropped the intercept/treatment/interaction to NaN on an otherwise full-rank design — or truncated the small-scale direction in the solve, returning finite-but-wrong coefficients. Detection now runs a raw pivoted QR first and only re-checks on column-equilibrated (unit 2-norm) columns when the raw pass reports a deficiency. If the equilibrated rank is higher (a scale-induced false-drop), the equilibrated rank AND its pivot selection are adopted — its first `rank` columns are independent under the scale-corrected criterion, so the retained design is guaranteed identified; otherwise (genuine collinearity, no scale disparity) the raw rank and pivot selection are kept unchanged. The solve equilibrates columns and unscales the coefficients. The Python (LAPACK `gelsd`) and Rust (faer thin-SVD) backends implement this same contract and agree at the parity-suite tolerances (coefficients ~1e-8, vcov ~1e-5), never bit-identically. This repairs the scale bug while leaving everything else unchanged: it is a no-op for full-rank well-conditioned designs (R-parity unaffected) and does **not** change which column is dropped in a *well-scaled* collinear design (the established raw pivot selection is preserved). A scale-induced under-count instead adopts the scale-corrected equilibrated selection — which may differ from the raw choice in a mixed scale+collinearity design but is guaranteed to retain an identified (full-rank) subset. This shared `diff_diff/linalg.py` behavior covers every covariate outcome-regression fit routed through `solve_ols` — DiD, TwoWayFixedEffects, MultiPeriodDiD, ImputationDiD, TwoStageDiD, and TripleDifference. **Scope (now scale-equilibrated):** CallawaySantAnna's covariate outcome-regression (`_compute_all_att_gt_covariate_reg`) and doubly-robust (`_doubly_robust`) nuisance solves — and StaggeredTripleDifference's per-cohort OR solve (`_compute_or`) — now route through `solve_ols` (column-equilibrated SVD/gelsd), matching TripleDifference's already-`solve_ols`-routed OR fit (`triple_diff.py:1438/1444`) and R's `lm()`/QR. The prior estimator-local `cho_solve(X'X)` / `scipy.linalg.lstsq(cond=1e-7)` fast paths were not scale-equilibrated: a covariate **correlated with another regressor at a very large scale** (e.g. a large constant offset, near-collinear with the intercept) could perturb the point-estimate ATT — and the IF SE that follows it — because the normal-equations Cholesky squares the condition number (pure orthogonal ill-scaling was already safe). The equilibrated SVD is offset-invariant to ~1e-11 where the prior solve drifted; scale-invariance is now pinned by tests (covariate + 1e6 offset → ATT(g,t) unchanged). The change is not bit-identical (cho/normal-equations → SVD) but well-scaled designs move only ~1e-12. The separate DR/OR influence-function SE rank-guard — which previously returned garbage SEs (~1e13) when these local Gram matrices were near-singular — is also **implemented** for CS / TripleDifference / StaggeredTripleDifference via `_rank_guarded_inv` (see the "rank-guarded IF standard errors" Note above). + - **Note:** Rank detection and the least-squares solve are invariant to per-column scaling in BOTH the Python and Rust backends. The rank threshold is anchored to the largest pivot/singular value, so a column on a large raw scale (e.g. an unstandardized covariate in raw population or currency units) previously inflated the threshold and false-dropped the intercept/treatment/interaction to NaN on an otherwise full-rank design — or truncated the small-scale direction in the solve, returning finite-but-wrong coefficients. Detection now runs a raw pivoted QR first and only re-checks on column-equilibrated (unit 2-norm) columns when the raw pass reports a deficiency. If the equilibrated rank is higher (a scale-induced false-drop), the equilibrated rank AND its pivot selection are adopted — its first `rank` columns are independent under the scale-corrected criterion, so the retained design is guaranteed identified; otherwise (genuine collinearity, no scale disparity) the raw rank and pivot selection are kept unchanged. The solve equilibrates columns and unscales the coefficients. The Python (LAPACK `gelsd`) and Rust (faer thin-SVD) backends implement this same contract and agree at the parity-suite tolerances (coefficients ~1e-8, vcov ~1e-5), never bit-identically. This repairs the scale bug while leaving everything else unchanged: it is a no-op for full-rank well-conditioned designs (R-parity unaffected) and does **not** change which column is dropped in a *well-scaled* collinear design (the established raw pivot selection is preserved). A scale-induced under-count instead adopts the scale-corrected equilibrated selection — which may differ from the raw choice in a mixed scale+collinearity design but is guaranteed to retain an identified (full-rank) subset. This shared `diff_diff/linalg.py` behavior covers every covariate outcome-regression fit routed through `solve_ols` — DiD, TwoWayFixedEffects, MultiPeriodDiD, ImputationDiD, TwoStageDiD, and TripleDifference. **Scope (now scale-equilibrated):** CallawaySantAnna's covariate outcome-regression (`_compute_all_att_gt_covariate_reg`) and doubly-robust (`_doubly_robust`) nuisance solves — and the staggered DDD engine's per-cohort OR solve (`_compute_or`, TripleDifference staggered mode) — now route through `solve_ols` (column-equilibrated SVD/gelsd), matching TripleDifference's already-`solve_ols`-routed OR fit (`triple_diff.py:1438/1444`) and R's `lm()`/QR. The prior estimator-local `cho_solve(X'X)` / `scipy.linalg.lstsq(cond=1e-7)` fast paths were not scale-equilibrated: a covariate **correlated with another regressor at a very large scale** (e.g. a large constant offset, near-collinear with the intercept) could perturb the point-estimate ATT — and the IF SE that follows it — because the normal-equations Cholesky squares the condition number (pure orthogonal ill-scaling was already safe). The equilibrated SVD is offset-invariant to ~1e-11 where the prior solve drifted; scale-invariance is now pinned by tests (covariate + 1e6 offset → ATT(g,t) unchanged). The change is not bit-identical (cho/normal-equations → SVD) but well-scaled designs move only ~1e-12. The separate DR/OR influence-function SE rank-guard — which previously returned garbage SEs (~1e13) when these local Gram matrices were near-singular — is also **implemented** for CS / TripleDifference (both modes) via `_rank_guarded_inv` (see the "rank-guarded IF standard errors" Note above). - **Note (opt-in solve_ols Cholesky fast path, 2026-07):** setting `DIFF_DIFF_SOLVE_OLS_FASTPATH=1` (positive integer; read per call) routes full-rank `solve_ols` fits through an equilibrated normal-equations Cholesky solve, certification-gated at reciprocal condition 1e-6 on the equilibrated Gram (Python: LAPACK `dpocon`, the same guard shape as the IRLS inner-solver Note under Propensity score estimation; Rust: the exact 1-norm rcond, whose inverse byproduct is reused as the sandwich bread). Any certification decline falls back VERBATIM to the SVD/gelsd solve, so the knob-off default path - and every rank-deficiency decision - stays byte-identical to the legacy behavior on both backends. The fast path never runs on a design the QR detector would call deficient: the 1e-6 solve guard is strictly inside the 1e-10 stage-0 rank certification, which is itself two orders stricter than the 1e-7 QR boundary. Within the opt-in path, parity vs the default solve is tol-bounded, not bit-level (fitted values ~1e-8 abs, SEs ~1e-6 rel; certified forward-error budget ~eps*cond(G_eq) <= 2e-10 in the equilibrated basis). The default path keeps the SVD's independent singular-value truncation as defense-in-depth; the knob is opt-in per the 2026-07 correctness-first decision. - Non-finite inference values: - Analytic SE: Returns NaN to signal invalid inference (not biased via zeroing) @@ -1872,7 +1872,7 @@ where `q_{g,e} = pi_g / sum_{g' in G_{trt,e}} pi_{g'}`. - **Note:** Last-cohort-as-control (`control_group="last_cohort"`) reclassifies the latest treatment cohort as pseudo-never-treated and drops time periods at `t >= last_g - anticipation`, excluding anticipation-contaminated periods from the pseudo-control's pre-treatment window. This is distinct from CallawaySantAnna's `not_yet_treated` option which dynamically selects not-yet-treated units per (g,t) pair. - **Note:** `vcov_type` is permanently narrow to `{"hc1"}` per the Chen-Sant'Anna-Xie (2025) EIF-based variance achieving the semiparametric efficiency bound. Analytical-sandwich families `{classical, hc2, hc2_bm}` are rejected at `__init__` — the per-unit EIF aggregation has no equivalent single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined. `cluster=` invokes Liang-Zeger CR1 on cluster-aggregated EIF (`_compute_se_from_eif` with `cluster_indices`); `survey_design=` invokes TSL on the combined IF (`_compute_survey_eif_se`); both live in `diff_diff/efficient_did_aggregation.py` since the M-023 post-fit aggregate() extraction. `vcov_type='conley'` deferred to the EfficientDiD Conley follow-up row in DEFERRED.md. - **Note:** Default `cluster=None` (no survey design) renders summary label "HC1 heteroskedasticity-robust" because the per-unit EIF SE `sqrt(mean(EIF²)/n)` is methodologically HC1-style (no Liang-Zeger G/(G-1) finite-sample correction). `EfficientDiDResults.cluster_name` and `n_clusters` stay None under unclustered fits. This diverges from `ImputationDiD` which auto-clusters at unit per Borusyak-Jaravel-Spiess (2024) Theorem 3 — there the default summary renders the CR1 unit-clustered label. -- **Note:** `set_params(vcov_type=bad)` raises immediately on EVERY estimator: since the shared `BaseEstimator` mixin (`diff_diff/_base.py`, v4 2(c)-i), `set_params` validates transactionally by constructor probe re-init, so it enforces exactly `__init__`'s validation, eagerly, library-wide. The former split — EfficientDiD eager vs `ImputationDiD`/`TripleDifference`/`CallawaySantAnna` (and six more: SunAbraham, StackedDiD, StaggeredTripleDifference, SpilloverDiD, TROP, PreTrendsPower) accepting constructor-rejected values until `fit()` — is retired; the fit-time re-validation layers remain as a second check against DIRECT attribute mutation (`est.vcov_type = ...`), which no setter can see. +- **Note:** `set_params(vcov_type=bad)` raises immediately on EVERY estimator: since the shared `BaseEstimator` mixin (`diff_diff/_base.py`, v4 2(c)-i), `set_params` validates transactionally by constructor probe re-init, so it enforces exactly `__init__`'s validation, eagerly, library-wide. The former split — EfficientDiD eager vs `ImputationDiD`/`TripleDifference`/`CallawaySantAnna` (and six more: SunAbraham, StackedDiD, the since-removed StaggeredTripleDifference, SpilloverDiD, TROP, PreTrendsPower) accepting constructor-rejected values until `fit()` — is retired; the fit-time re-validation layers remain as a second check against DIRECT attribute mutation (`est.vcov_type = ...`), which no setter can see. - **Note (post-fit aggregate() - rows M-023/M-120):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3b of the cut: `fit()` computes no event-study/group surface, the legacy `event_study_effects`/`group_effects`/`event_study_df` fields are None after EVERY fit, `to_dataframe(level='event_study'|'group')` raises on fresh fits until the M-093 schema rewire, and `survey_design`/`store_eif` are positional slots 7/8) in favor of post-fit `EfficientDiDResults.aggregate(type, balance_e=)` - a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a StackedDiD/dCDH view relay): `fit()` computes nothing extra, the results object retains an `AggregationKit`, and `aggregate('event_study'/'group', balance_e=)` re-runs the extracted `_EfficientAggregationMixin` aggregators on a throwaway host while `aggregate('simple')` relays the stored overall row bit-exact. (a) RETAINED BUFFERS (memory contract; phrased as maxima - optional design fields stay None when unsupplied): the per-(g,t) EIF dict, O(n_units x n_gt), the dominant payload - retained on EVERY fit regardless of `store_eif`, which since 3.9 governs only the public `influence_functions` field; `unit_cohorts` (cohort labels), `unit_level_weights`, factorized cluster codes - O(n_units) each; on ordinary (TSL) survey fits the unit-level `ResolvedSurveyDesign` adds `weights` plus, where supplied, `strata`/`psu`/`fpc` (factorized int codes / float values, never raw labels) - up to four O(n_units) arrays; on replicate designs it adds the O(n_units x n_replicates) replicate matrix plus, where supplied, `replicate_strata`/`replicate_rscales` (O(n_replicates)); per-row dict SNAPSHOTS of `group_time_effects` plus copies of the `groups`/`time_periods` lists and the scalar `pt_assumption`/`n_treated+n_control` provenance (aggregate() recomputes exclusively from these private snapshots, never from the mutable public result fields - a user edit of the public rows cannot mix altered point estimates with the retained EIF variance); scalars `n_units`, `cohort_fractions`, and the POST-OVERALL `df_survey` snapshot (captured after the overall inference and before the ES/group gates: the group pass can degenerate the working df to None on replicate designs with `n_valid <= 1`, and every fit-time aggregation seeds from the post-overall value, so recompute replays the exact seed). The data-minimization guarantee is scoped to unit identifiers - no unit-label container is retained. (b) `balance_e` uses the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon, then retain all their horizons) - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference (EDiD anchors on the `int(t - g)` bucket, CS on raw `t - g` - identical on integer-period panels; see the truncation Note below), divergent from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' (and 'total') RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the RECOMPUTE levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the fit-captured RNG state + run params BY VALUE - seed=None fits replay; post-fit `set_params`/attribute mutation cannot alter it; pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose` - never bit-identity), the percentile p-value is a count statistic (compared at 2/n_bootstrap), and the replay publishes no analytical provenance (percentile overrides via the shared `bootstrap_utils` appliers; group rows' `df_used` cleared exactly as at fit time; EDiD has no sup-t cband). The spec stamps the weight backend at capture ('rust'/'numpy', or 'portable' for backend-independent branches - stratified survey generation, census-FPC zero weights, the single-PSU degenerate early return); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. Replays RE-EMIT the fit-time bootstrap warnings for the replayed configuration (low n_bootstrap, single-PSU, empty balance_e anchor, non-finite-draw RuntimeWarnings); the relay levels stay silent. Per-call cost O(n_bootstrap x n_units x n_gt), no memoization. The M-027 rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the percentile overrides and NaN df channels. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the primary ground is the absent joint event-study covariance (container `vcov=None`; the PER-ROW df column now threads the post-overall `df_survey` snapshot via the results-level `event_study_df` channel on analytical survey fits - M-092 completion; None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). Reference semantics are regime-dependent: under `pt_assumption="all"` there is NO reference row (universal first-period baseline; e=-1 is a genuine estimate); under `"post"` the per-cohort baseline cell is materialized as a mechanical zero anchor at `e = -1 - anticipation` whenever it is not the panel's first period, and the MEMBERSHIP-GATED `reference_period` property (the SunAbraham rule - never synthesized when the anchor cell was not estimated) marks it `is_reference` in the container and corrects `plot_event_study`'s inferred reference (previously the `-1` fallback) on PT-Post `anticipation>0` fits. (e) 'simple' relay conventions: `target="att"`, `n = n_treated_units + n_control_units` with `n_kind="units"` (DISJOINT by construction - `last_cohort` trimming reassigns before the counts, so a true total exists, unlike StackedDiD's overlapping sets), `df` = the post-overall snapshot (provenance-exact where `survey_metadata.df_survey` can diverge in the degenerate replicate state); 'group' relay: `n_kind="cells"`, `weight=None` (equal within-cohort weights, no cross-cohort mass), per-row `df_used` array captured at each row's `safe_inference` call (exact by construction; a stated divergence from CS's conservative-min scalar broadcast); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose `survey_metadata` copy carries the snapshot `df_survey` and whose `event_study_df` threads the same snapshot into the per-row df column (cleared on bootstrap replays and for the 0 sentinel). (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C = sum(n_treated)` over the kept post-anticipation FINITE-effect cells of the kit's deep-copied `group_time_effects` snapshot (an exact integer sum; NEVER the routinely-non-integral `n_units x sum(cohort_fractions)` float product). Unweighted fits only: ANY declared `survey_design=` fails closed via the kit's `unit_level_weights`/`resolved_survey_unit` markers (weight-type-agnostic - unweighted psu-only and analytic fweight designs, whose resolved weights stay RAW, gate identically; EDiD never synthesizes an internal design, so the markers are exact declaration provenance and post-fit mutation of the public `survey_metadata` cannot bypass them). No keepers -> NaN mass -> all-NaN row (no re-warn); with finite `C` the relay is verbatim (inherited NaNs pass through; att/n never blanked). Bootstrap fits relay with a NaN df column (M-027); df carrier = the post-overall `df_survey` snapshot; container: single `target='total'` row, `n_kind='obs'`, the per-level n-semantics note in the CS total Note applies. MMM: the container is admitted with NO scale (see the MMM section); the survey/RC totals remainder and its att*dC variance term are the DEFERRED row. - **Note (fractional-period truncation bucketing):** the event-study equation defines `ES(e)` at an exact relative time, but the implementation buckets horizons by `int(t - g)` - truncation TOWARD ZERO - on the analytical aggregator, the bootstrap ES prep (all three keying sites, the `balance_e` anchor filter included; aligned 2026-08), and `hausman_pretest`'s internal aggregation. On integer-period panels `int()` is the identity and nothing below applies. On fractional-period panels (accepted by `fit()` without an integrality check): (i) bucket 0 is DOUBLE-WIDTH, spanning `(-1, 1)`, so the pre-treatment horizon e=-0.5 pools into the post-treatment e=0 bucket; e=1.0/1.5 pool into bucket 1 and e=-1.5/-1.0 into bucket -1; (ii) under `pt_assumption="post"` the bucket at `-1 - anticipation` pools genuine estimated fractional pre-treatment horizons (e.g. raw e=-1.5) with the mechanical zero anchor, and the container's reference normalization then publishes the WHOLE pooled row as `att=0.0` with NaN inference (`is_reference=True`) - genuine fractional-horizon estimates are subsumed into the reference row (the fit-time `event_study_effects` dict keeps the pooled effect + percentile inference unrewritten; the rewrite is container-only); (iii) WEIGHTING is CELL-MASS within the bucket - the aggregator appends one `(effect, pi_g)` term per CELL and normalizes, so a cohort contributing k cells to a bucket carries k*pi_g mass, whereas the exact-relative-time equation has one term per cohort - while `n_groups` counts DISTINCT cohorts in the bucket (identity with the cell count on integer panels). Every aggregation that truncation-buckets a fractional horizon emits a UserWarning naming this Note. @@ -4068,19 +4068,20 @@ shared verbatim. keyword-only, so a positionally-written staggered call cannot bind to the 2x2x2 slots; positional slots 1-8 are unchanged. - **Note (`time=` semantics):** the dual role is resolved by dispatch ORDER, not - value inspection. In staggered mode `time=` is the calendar column and emits NO - rename warning; in 2x2x2 mode it stays the deprecated alias for `post=` (row - M-031) and warns. At 4.0 it means the calendar column only (row M-085). + value inspection. In staggered mode `time=` is the calendar column; in 2x2x2 + mode `time=` raises a `ValueError` naming `post=` (row M-085, 4.0; through 3.x + it was the deprecated alias for `post=`, row M-031, and warned). - **Note (vocabulary):** the third dimension is `partition` in both modes, and `control_group` takes the underscored values `"not_yet_treated"` / - `"never_treated"`. R's compact spellings are accepted only by the deprecated - `StaggeredTripleDifference` and die with it. + `"never_treated"`. R's compact spellings were accepted only by the removed + `StaggeredTripleDifference` class (4.0, row M-013). - **Note (`cluster=` raises in staggered mode):** cluster-robust ANALYTICAL SEs are not implemented for the staggered engine, so on this surface `cluster=` raises a `ValueError` steering to `n_bootstrap > 0` (unit-level clustering via - the multiplier bootstrap). The deprecated class keeps its 3.x - accepted-then-warned-then-ignored `UserWarning`. **Deviation from the dying - class, deliberate:** the raise is live from the mode's 3.9 birth, because a new + the multiplier bootstrap). The removed `StaggeredTripleDifference` class kept + its 3.x accepted-then-warned-then-ignored `UserWarning` through 3.x. + **Deviation from the removed class, deliberate:** the raise is live from the + mode's 3.9 birth, because a new surface needs no deprecation window and should not accept a parameter it does not honor. In 2x2x2 mode `cluster=` continues to give Liang-Zeger CR1. - **Note (unreachable-by-construction params):** `bootstrap_weights`, `seed` and @@ -4111,8 +4112,8 @@ shared verbatim. originally recorded (`ContinuousDiD` still validated `0.0 <= pscore_trim < 0.5`, admitting `0`) was closed by ledger row M-145: every consumer now validates via the shared `utils.validate_pscore_trim` (`0 < x < 0.5`, type guard, `float` - coercion), with the deprecated `StaggeredTripleDifference` deliberately keeping - its permissive construction shape (M-013/M-144 posture). + coercion); the `StaggeredTripleDifference` class kept its permissive + construction shape through 3.x (M-013/M-144 posture) until its 4.0 removal. - **Note (the `triple_difference()` wrapper was removed at 4.0, PR 2 of the cut):** the functional wrapper (deprecated 3.9, row M-075) reached only the 2x2x2 design and forwarded its own `time=` as `post=`; it was deliberately never @@ -4129,7 +4130,8 @@ shared verbatim. same seed reproduces an SE bit-exactly and a different seed moves it - and it appears in `get_params()`/`set_params()`. What is absent is a results-object FIELD. This is deliberate and pre-dates the 3.9 merge: `seed` was already a - `StaggeredTripleDifference` constructor param that its container never + constructor param of the (since-removed) `StaggeredTripleDifference` class + whose container never recorded, and `CallawaySantAnnaResults` has exactly the same gap through the shared `CallawaySantAnnaBootstrapMixin`. Adding the field to one container alone would leave the two siblings inconsistent, and @@ -4152,14 +4154,11 @@ shared verbatim. window is a silent estimand change (see the no-silent-failures policy). `bool` is rejected because `True` would otherwise coerce to a silent one-period window. The check is the shared `utils.validate_anticipation`, and - it is ALSO called from the staggered engine, so the deprecated - `StaggeredTripleDifference` fails closed too — its frozen 3.x API shape was - never a licence to emit silently-biased numbers from the same engine. The - split is deliberate: on the dying class CONSTRUCTION still succeeds (its - signature contract is untouched) and FIT raises, because this is an - identification guard rather than an API change. The engine-level call also - covers direct attribute mutation, which bypasses `__init__` and `set_params` - alike. **Family-wide adoption (ledger row [M-144]):** all ten + it is ALSO called from the staggered engine (through 3.x that made the + construction-permissive `StaggeredTripleDifference` class fail closed at fit + too — its frozen API shape was never a licence to emit silently-biased + numbers from the same engine). The engine-level call covers direct attribute + mutation, which bypasses `__init__` and `set_params` alike. **Family-wide adoption (ledger row [M-144]):** all ten anticipation-taking estimators (CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, ContinuousDiD, EfficientDiD, WooldridgeDiD, SpilloverDiD, DMLDiD) now call the shared `utils.validate_anticipation` at `__init__` @@ -4171,12 +4170,9 @@ shared verbatim. SpilloverDiD's re-check is the in-fit check, kept ordered before the ref-period arithmetic (the PR #456 R2 guarantee); EfficientDiD's rides its `_validate_params` fit-time re-run; the staggered engine covers - TripleDifference and the deprecated sibling. WooldridgeDiD's weaker local + TripleDifference's staggered mode. WooldridgeDiD's weaker local `>= 0` check is superseded (its message text changed, and `None`/str now - raise `ValueError` instead of a raw `TypeError`). The deprecated - `StaggeredTripleDifference` stays construction-permissive by design (pin: - test_v4_merge_ddd.py::test_deprecated_sibling_also_fails_closed_on_anticipation) - — a recorded choice, not a gap. Policy suite: + raise `ValueError` instead of a raw `TypeError`). Policy suite: `tests/test_anticipation_policy.py` (roster-guarded). - **Note (degenerate enabling cohorts are reported, not hidden):** a positive `first_treat` cohort whose units are ALL `partition == 0` cannot identify @@ -4192,9 +4188,9 @@ shared verbatim. the panel), not just this one. **The estimate is deliberately unchanged:** it remains valid for the cohorts that do identify, and this engine already warns-and-skips rather than raising for unidentified CELLS, so raising for a - cohort would have been the odd one out. Pre-existing behavior on - `StaggeredTripleDifference`; the fix is in the shared engine, so both - surfaces report identically. + cohort would have been the odd one out. Pre-existing behavior of the + staggered engine (then also surfaced by the `StaggeredTripleDifference` + class); the fix is in the engine. - **Note (`first_treat` cohort encoding is fail-closed on negatives):** the contract is `0` for never-treated (or `+inf`, recoded to `0` with a `UserWarning`) and positive period labels for treated cohorts. Negative values @@ -4208,8 +4204,8 @@ shared verbatim. ATT comparison — and the fit returned a plausible finite estimate for a different population (measured on the 3(b) fixture: `overall_att` 3.29438582 → 2.99470938 with `n_never_enabled` 24 → 0, no error and no - warning). The guard lives in the shared engine, so BOTH surfaces fail closed; - the behavior it replaces was pre-existing on `StaggeredTripleDifference`. + warning). The guard lives in the staggered engine; the behavior it replaces + was pre-existing (then also surfaced by the `StaggeredTripleDifference` class). - **Note (results containers):** 2x2x2 mode returns `TripleDifferenceResults` and staggered mode returns `StaggeredTripleDiffResults` through 3.9; `fit`'s return type is a `Union`. The one-shape unification lands at 4.0 with row M-014, so no @@ -4217,18 +4213,17 @@ shared verbatim. ## StaggeredTripleDifference -- **Note:** Deprecated in 3.9, removed in 4.0 (ledger row M-013). The successor is - `TripleDifference().fit(..., unit=, time=, first_treat=, partition=)`, which runs - this EXACT engine - the estimation core moved verbatim to - `diff_diff/_staggered_triple_diff_engine.py` and both classes mix it in, so the - numbers are identical by construction (pinned bit-exactly in - `tests/test_v4_merge_ddd.py`). Two vocabulary differences on the merged surface: - `eligibility=` is named `partition=`, and `control_group` takes the underscored - values `"not_yet_treated"`/`"never_treated"` (R's compact spellings stay on this - class and die with it). One behavior difference: `cluster=` RAISES on the merged - surface instead of being accepted-then-warned-then-ignored, because a new surface - should not ship a parameter it does not honor. Everything below remains the 3.x - contract for the deprecated class through its removal. +- **Note:** REMOVED at the 4.0 cut (PR 4a, ledger row M-013; deprecated in 3.9). + This section is retained as the methodology record of the staggered DDD engine, + which `TripleDifference().fit(..., unit=, time=, first_treat=, partition=)` runs + unchanged - the estimation core moved verbatim to + `diff_diff/_staggered_triple_diff_engine.py` in 3.9 and is pinned against the + committed oracle in `tests/test_v4_merge_ddd.py`. Vocabulary on the surviving + surface: `eligibility=` is `partition=`, `control_group` takes `"not_yet_treated"` + / `"never_treated"` (R's compact spellings died with the class), and `cluster=` + raises in staggered mode (see the TripleDifference section). The methodology + below is stated in the engine's own vocabulary; archive framing lands with + [M-090]. **Primary source:** [Ortiz-Villavicencio, M., & Sant'Anna, P.H.C. (2025). Better Understanding Triple Differences Estimators. arXiv:2505.09942v3.](https://arxiv.org/abs/2505.09942v3). Paper review on file: `docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md`. @@ -4391,8 +4386,9 @@ confidence bands (sup-t) for event study. with `pscore >= 0.995` but does not apply a lower bound. The soft-clipping approach retains all observations with bounded weights, which is more conservative under moderate overlap violations. -- **Note:** The `cluster` parameter is accepted but not currently wired to the - analytical SE computation. The multiplier bootstrap provides unit-level +- **Note:** On the surviving staggered surface `cluster=` raises (see the + TripleDifference section); the removed class accepted it without wiring it to + the analytical SE computation. The multiplier bootstrap provides unit-level clustering. Full cluster-robust analytical SEs are deferred. - **Note:** Full survey design support (pweight only). Survey weights enter propensity score estimation (weighted IRLS), outcome regression (WLS), and @@ -4430,7 +4426,7 @@ confidence bands (sup-t) for event study. - [x] Pre-treatment event-study coefficients constructable - [x] Influence-function-based SEs - [x] Multiplier bootstrap for simultaneous confidence bands (via CS mixin) -- [ ] Cluster-robust analytical SEs (accepted but not wired — deferred) +- [ ] Cluster-robust analytical SEs (`cluster=` raises on the surviving surface — deferred) - [x] Survey design support (pweight, strata/PSU/FPC, replicate weights) - [x] Validation against R `triplediff` package: group-time ATT and SE match within 0.001% across 10 scenarios (3 seeds, 3 methods, both control group modes). @@ -5851,7 +5847,7 @@ where `Q1` is the R type-1 quantile - the paper's eq. (35)/(A.1) inf-based ceili ### QDiD (quantile DiD comparison estimator) -- **Note:** As of 3.9 this estimator is selected with `ChangesInChanges(method="qdid")`; the standalone `QDiD` class is deprecated (ledger row M-015) and removed in 4.0. Only the CLASS SPELLING is deprecated - the estimator is fully supported and selecting it emits no advisory warning. The existing footnote-21 non-monotonicity `UserWarning` and `practitioner.py`'s "Prefer ChangesInChanges over QDiD" step already carry Athey-Imbens' p. 447 recommendation at the moments a user can act on it; a third warning on every `method="qdid"` fit would blur which of the class/method is dying. Both engines and every number are unchanged by the merge. +- **Note:** As of 3.9 this estimator is selected with `ChangesInChanges(method="qdid")`; the standalone `QDiD` class spelling (and the `QDiDResults` alias) was removed at the 4.0 cut (PR 4a, ledger row M-015). Only the class spelling went - the estimator is fully supported and selecting it emits no advisory warning. The existing footnote-21 non-monotonicity `UserWarning` and `practitioner.py`'s "Prefer ChangesInChanges over QDiD" step already carry Athey-Imbens' p. 447 recommendation at the moments a user can act on it; a third warning on every `method="qdid"` fit would blur which of the class/method is dying. Both engines and every number are unchanged by the merge. **Primary source:** Athey & Imbens (2006), Section 3.3 (pp. 446-447) - the paper formalizes QDiD as the natural comparison estimator and recommends CiC over it: QDiD's justifying model (eq. 22) is not invariant to monotone rescaling of the outcome, forces `U ⊥ (G, T)` (identical unobservable distributions in all four cells), and places testable restrictions on the data (footnote 21 - the implementation warns when the implied counterfactual quantile function is non-monotone on the requested grid; unconditional fits only, see the covariates block above). @@ -7757,11 +7753,11 @@ variance from the distribution of replicate estimates. design structure is fixed and dropped replicates contribute zero to the sum without changing the scale. Survey df uses `n_valid - 1` for t-based inference. -- **Note:** Replicate-weight support matrix (14 of 21 public estimators): +- **Note:** Replicate-weight support matrix (13 of 20 public estimators): - **Supported**: CallawaySantAnna (reg/ipw/dr with or without covariates, no bootstrap; IF-based replicate variance is covariate-agnostic), ContinuousDiD (no bootstrap), EfficientDiD (no bootstrap), - TripleDifference (all methods), StaggeredTripleDifference (IF-based), + TripleDifference (all methods; staggered mode IF-based), DifferenceInDifferences (no-absorb via LinearRegression dispatch, absorb via estimator-level refit), MultiPeriodDiD (no-absorb via `compute_replicate_vcov`, absorb via estimator-level refit), @@ -7781,7 +7777,7 @@ variance from the distribution of replicate estimates. HeterogeneousAdoptionDiD (TSL-only survey paths; replicate designs rejected at `fit()`), SyntheticControl (rejects `survey_design` entirely) - - **BaconDecomposition** is diagnostic-only — outside the 21-estimator + - **BaconDecomposition** is diagnostic-only — outside the 20-estimator count — and likewise rejects replicate designs - Estimators with replicate support reject replicate + bootstrap (replicate weights provide analytical variance) diff --git a/docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md b/docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md index 155860fdd..d883c935c 100644 --- a/docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md +++ b/docs/methodology/papers/ortiz-villavicencio-santanna-2025-review.md @@ -9,11 +9,11 @@ ## Methodology Registry Entry -*Formatted to match `docs/methodology/REGISTRY.md` structure. This file is the canonical **scholarly paper review** for arXiv:2505.09942 and is the **Primary source** for **two** library estimators that share this paper: `TripleDifference` (the two-period DDD **estimand and three-DiD decomposition**, paper §3.1 / §4 specialized to a single treatment date — the library's repeated-cross-section data path follows the companion `triplediff` package; see Relation to diff-diff) and `StaggeredTripleDifference` (the multi-period / staggered case, paper §3.2 / §4). It **supplements — it does not supersede** — the existing REGISTRY sections `## TripleDifference` and `## StaggeredTripleDifference`, which remain the implementation contract. The equation anchors those REGISTRY sections already cite were verified accurate against v3 (see Gaps and Uncertainties).* +*Formatted to match `docs/methodology/REGISTRY.md` structure. This file is the canonical **scholarly paper review** for arXiv:2505.09942 and is the **Primary source** for the `TripleDifference` estimator's two modes: the two-period DDD (**estimand and three-DiD decomposition**, paper §3.1 / §4 specialized to a single treatment date — the library's repeated-cross-section data path follows the companion `triplediff` package; see Relation to diff-diff) and the staggered mode (`fit(..., first_treat=)`, the multi-period / staggered case, paper §3.2 / §4; through 3.x the separate `StaggeredTripleDifference` class, removed at 4.0). It **supplements — it does not supersede** — the existing REGISTRY sections `## TripleDifference` and `## StaggeredTripleDifference`, which remain the implementation contract. The equation anchors those REGISTRY sections already cite were verified accurate against v3 (see Gaps and Uncertainties).* **Note (paper-vs-package scope):** This paper is the primary source for the **DDD estimand and three-DiD decomposition** that both library estimators implement. `TripleDifference` runs the **repeated-cross-section** path (`triplediff::ddd(panel=FALSE)`); its repeated-cross-section data handling and the associated inference follow the companion `triplediff` package, because the paper itself assumes a balanced panel and defers repeated-cross-section / unbalanced data to future work (§7). Read this review as authoritative for the DDD *estimand, identification, and balanced-panel inference*, not for the `panel=FALSE` data path. -## TripleDifference & StaggeredTripleDifference (DDD) +## TripleDifference (2x2x2 and staggered DDD) **Primary source:** Ortiz-Villavicencio & Sant'Anna (2025), *Better Understanding Triple Differences Estimators*, arXiv:2505.09942v3. @@ -201,7 +201,7 @@ The paper recommends practitioners **favor the optimal GMM DR DDD estimator** th ## Implementation Notes ### Data Structure Requirements -- Paper framework (§2): balanced panel with outcome `Y`, unit id, period, **enabling group `S_i`** (`first_treat`; `0`/`∞` for never-enabled), **binary time-invariant eligibility `Q_i`** (`eligibility`), and optional time-invariant covariates `X_i`. (The library's `StaggeredTripleDifference` uses a balanced panel; `TripleDifference` is repeated cross-section — see Relation to diff-diff below.) +- Paper framework (§2): balanced panel with outcome `Y`, unit id, period, **enabling group `S_i`** (`first_treat`; `0`/`∞` for never-enabled), **binary time-invariant eligibility `Q_i`** (`eligibility`), and optional time-invariant covariates `X_i`. (The library's `TripleDifference` fits the staggered design on a balanced panel via `fit(..., first_treat=, partition=)` and the two-period 2x2x2 design on repeated cross-sections — see Relation to diff-diff below.) - Both eligible (`Q=1`) and ineligible (`Q=0`) units are always required. A **never-enabled** cohort (`S=∞`) is required for `control_group="nevertreated"` and to identify the last-enabling cohort (whose only admissible comparison is never-enabled); otherwise later **not-yet-enabled** cohorts (`control_group="notyettreated"`, `g_c > t`) can serve as comparisons without a never-enabled cohort. The paper also accommodates the all-eventually-enabled case by trimming observations after `max S_i` and treating the last-enabling group as the effective never-eligible comparison (§2). ### Computational Considerations @@ -220,14 +220,14 @@ The paper recommends practitioners **favor the optimal GMM DR DDD estimator** th ### Relation to Existing diff-diff Estimators - **`TripleDifference`** (library, **Complete**) implements the **two-period DDD estimand and three-DR-DiD-component decomposition** of Eq. (3.5)/(4.1) with CS-2021-style influence-function SEs — this estimand and decomposition are what the paper is the primary source for. The library's path is **repeated cross-section** (`triplediff::ddd(panel=FALSE)`); the paper assumes a balanced panel and leaves repeated-cross-section / unbalanced data handling to future work (§7), so that data-path detail follows the companion `triplediff` package rather than the paper directly. -- **`StaggeredTripleDifference`** (library, **Complete**; DEPRECATED in 3.9, removed in 4.0 - the staggered case is served by `TripleDifference().fit(..., first_treat=)` since the phase-3(b) merge, ledger row M-013, running this same engine) implements the staggered / multi-period case — group-time `ATT(g,t)` via Eq. (4.1), the optimal-GMM combination across comparison cohorts (Eqs. 4.11–4.12), event-study via the CS aggregation mixin (Eq. 4.13), IF-based SEs, and a multiplier bootstrap for simultaneous bands. R reference `triplediff::ddd(panel = TRUE)` + `agg_ddd()`. +- **`TripleDifference` staggered mode** (library, **Complete**; `TripleDifference().fit(..., first_treat=)` since the phase-3(b) merge, ledger row M-013 - the standalone `StaggeredTripleDifference` class was removed at the 4.0 cut, PR 4a, same engine) implements the staggered / multi-period case — group-time `ATT(g,t)` via Eq. (4.1), the optimal-GMM combination across comparison cohorts (Eqs. 4.11–4.12), event-study via the CS aggregation mixin (Eq. 4.13), IF-based SEs, and a multiplier bootstrap for simultaneous bands. R reference `triplediff::ddd(panel = TRUE)` + `agg_ddd()`. - Both reuse Callaway–Sant'Anna machinery (the DR-DiD building block, the cohort-share event-study aggregation, and the multiplier-bootstrap inference of Remark 4.7). *The following are points where the **library's implementation choices** differ from the **paper and/or the R `triplediff` package** — for example, the comparison-cohort admissibility rule (a) differs from the paper and (b) matches R. They are recorded here for the implementation record and are formalized as REGISTRY deviations separately; the methodology summary above sources only from the paper.* - **Comparison-cohort admissibility.** The paper's Theorem 4.1 / Corollary 4.1 define admissible comparison cohorts as `g_c > max(g, t)`. The library follows the R `triplediff` convention `g_c > max(t, base_period) + anticipation`; the two can differ for pre-treatment cells when a later cohort lies in `(t, g)`. - **Aggregation weights.** The paper's `ES(e)` (Eqs. 2.3 / 4.13) weights cohorts by `P(G=g | …)`, where `G_i` is finite only for eligible-treated (`Q=1`) units — i.e. weights use `P(S=g, Q=1)`. R's `agg_ddd()` uses `P(S=g)` (all enabling-group units, including ineligible). This is the source of the larger tolerance on aggregated quantities in the library's existing R cross-validation. - **Per-cohort group-effect WIF.** The library propagates the weight-influence-function (WIF) term through per-cohort group aggregations (conservative), where R's `agg_ddd(type="group")` uses `wif=NULL`. -- **Cluster-robust analytical SEs** are contemplated by Remark 4.7 (via CS-2021 Remark 10) but are accepted-but-not-wired in the library (deferred). +- **Cluster-robust analytical SEs** are contemplated by Remark 4.7 (via CS-2021 Remark 10) but are not implemented: on the staggered surface `cluster=` raises a `ValueError` steering to `n_bootstrap > 0` (deferred). --- diff --git a/docs/methodology/survey-theory.md b/docs/methodology/survey-theory.md index bfe174d0d..37172c150 100644 --- a/docs/methodology/survey-theory.md +++ b/docs/methodology/survey-theory.md @@ -256,7 +256,7 @@ The implementation passes sqrt(w_i)-transformed data to `solve_ols()` in ### Influence-function-based estimators For IF-based estimators (CallawaySantAnna, ImputationDiD, TwoStageDiD, -EfficientDiD, TripleDifference, StaggeredTripleDifference), point estimates +EfficientDiD, TripleDifference in both modes), point estimates are constructed from survey-weighted sample moments. For example, CallawaySantAnna with `estimation_method='reg'` computes: @@ -334,7 +334,7 @@ estimators and their smoothness arguments: - **TripleDifference:** T(F) extends the two-group DiD sandwich to a triple contrast. The IF follows by the same arguments as DifferenceInDifferences. -- **StaggeredTripleDifference:** Staggered DDD with IF-based aggregation +- **TripleDifference (staggered mode):** Staggered DDD with IF-based aggregation across group-time-subgroup cells. Smooth by the same logic as CallawaySantAnna. @@ -481,7 +481,7 @@ The total meat is sum_h V_h, computed by `_compute_stratified_psu_meat()` in ### IF-based TSL variance For scalar IF-based estimators (CallawaySantAnna, ImputationDiD, TwoStageDiD, -TripleDifference, StaggeredTripleDifference, EfficientDiD, DMLDiD (bad-control lane: `cluster=` only; `survey_design` raises)), the variance is +TripleDifference (both modes), EfficientDiD, DMLDiD (bad-control lane: `cluster=` only; `survey_design` raises)), the variance is computed directly from per-unit influence function values without the bread matrix: @@ -592,7 +592,7 @@ would require R complete re-fits of the estimator, which is computationally expensive. diff-diff avoids this for most IF-based estimators (CallawaySantAnna, -EfficientDiD, ContinuousDiD, TripleDifference, StaggeredTripleDifference, +EfficientDiD, ContinuousDiD, TripleDifference (both modes), DMLDiD (bad-control lane: `cluster=` only; `survey_design` raises)) using weight-ratio rescaling: the replicate estimate is computed by reweighting the per-unit IF values rather than re-running the estimator. The `SurveyDesign` parameter `combined_weights` controls the interpretation: @@ -695,7 +695,7 @@ Each estimator uses one of three variance strategies under survey designs: | DMLDiD | TSL on IFs | pweight only; cross-fitted augmented scores; replicate designs: IF-reweighting on augmented scores, per-cell + aggregate; library extension of Chang (2020) i.i.d. theory (bad-control lane: `cluster=` only; `survey_design` raises) | | SunAbraham | TSL sandwich | OLS-based, all weight types | | TripleDifference | TSL on IFs | pweight only | -| StaggeredTripleDifference | TSL on IFs | pweight only | +| TripleDifference (staggered mode) | TSL on IFs | pweight only | | ImputationDiD | TSL on IFs | pweight only | | TwoStageDiD | TSL on IFs | pweight only | | EfficientDiD | TSL on EIFs | all weight types | @@ -731,7 +731,7 @@ Two bootstrap strategies interact with survey designs: - **Multiplier bootstrap at PSU level** (CallawaySantAnna, DMLDiD (bad-control lane: `cluster=` only; `survey_design` raises), ImputationDiD, TwoStageDiD, ContinuousDiD, EfficientDiD, - StaggeredTripleDifference): + TripleDifference staggered mode): Generates multiplier weights at the PSU level within strata, with FPC scaling. Each bootstrap draw reweights the IF values. diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index d443869d3..f72f070ba 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -100,7 +100,7 @@ results = ChangesInChanges(method="qdid", n_bootstrap=200, seed=42).fit( data, outcome="y", treatment="treat", time="post") ``` -**Only the class spelling is deprecated, not the estimator.** `method="qdid"` is a fully +**Only the class spelling was removed, not the estimator.** `method="qdid"` is a fully supported comparison mode and emits no warning of its own — the numbers are unchanged, because it is the same engine. `method="cic"` is the default and encodes Athey–Imbens' recommendation. @@ -284,8 +284,8 @@ removal/deprecation fields and appear here only. inputs (`None`, strings, `Decimal`/`Fraction`, 1-element arrays) raise `ValueError` instead of `TypeError` (or being silently accepted, for the 1-element array). `CallawaySantAnna` gains the same type guard; `TripleDifference`, `CallawaySantAnna`, and `ContinuousDiD` now store the value - coerced to built-in `float`; `LWDiD`'s message wording changed. The deprecated - `StaggeredTripleDifference` keeps its permissive construction shape. + coerced to built-in `float`; `LWDiD`'s message wording changed. (The removed + `StaggeredTripleDifference` class kept its permissive construction shape through 3.x.) - `summary(alpha=...)` / `print_summary(alpha=...)` on the results classes family-wide ([M-146], landing at 4.0; the staggered family first, then the non-staggered summaries - diff --git a/docs/references.rst b/docs/references.rst index e9f76999d..d2918f9fb 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -348,7 +348,7 @@ Changes-in-Changes / Distributional DiD - **Athey, S., & Imbens, G. W. (2006).** "Identification and Inference in Nonlinear Difference-in-Differences Models." *Econometrica*, 74(2), 431-497. https://doi.org/10.1111/j.1468-0262.2006.00668.x - Primary source for the ``ChangesInChanges`` (alias ``CiC``) and ``QDiD`` estimators: nonlinear DiD recovering the treated group's full counterfactual outcome distribution and quantile treatment effects in the 2x2 design, with the quantile-DiD comparison estimator the paper formalizes alongside it. Point estimation matches the R ``qte`` package (v1.3.1, Callaway) exactly; bootstrap inference follows the same package's conventions. Paper review on file at ``docs/methodology/papers/athey-imbens-2006-review.md``; companion reviews: ``melly-santangelo-2015-review.md`` (covariates - implemented in qte's simplified form, see below), ``callaway-li-oka-2018-review.md`` (panel QTT, deferred), ``ciaccio-2024-review.md`` (staggered, deferred). + Primary source for the ``ChangesInChanges`` (alias ``CiC``) estimator and its ``method="qdid"`` QDiD comparison estimator: nonlinear DiD recovering the treated group's full counterfactual outcome distribution and quantile treatment effects in the 2x2 design, with the quantile-DiD comparison estimator the paper formalizes alongside it. Point estimation matches the R ``qte`` package (v1.3.1, Callaway) exactly; bootstrap inference follows the same package's conventions. Paper review on file at ``docs/methodology/papers/athey-imbens-2006-review.md``; companion reviews: ``melly-santangelo-2015-review.md`` (covariates - implemented in qte's simplified form, see below), ``callaway-li-oka-2018-review.md`` (panel QTT, deferred), ``ciaccio-2024-review.md`` (staggered, deferred). - **Melly, B., & Santangelo, G. (2015).** "The Changes-in-Changes Model with Covariates." *Working paper*, Bern University and European Commission - JRC. https://sites.google.com/site/blaisemelly/home/computer-programs/cic_stata diff --git a/docs/survey-roadmap.md b/docs/survey-roadmap.md index 4af07e847..e54b802f5 100644 --- a/docs/survey-roadmap.md +++ b/docs/survey-roadmap.md @@ -81,8 +81,8 @@ Weighted `solve_logit()` in `linalg.py` — survey weights enter IRLS as to sensitivity analysis with t-distribution critical values - **7e.** Staggered DDD survey support (only implementation in R or Python with design-based DDD variance). Reached via `TripleDifference` with - `first_treat=` since 3.9; `StaggeredTripleDifference` is deprecated - (row M-013) but runs the same engine until its 4.0 removal. + `first_treat=` since 3.9 (the `StaggeredTripleDifference` class, row M-013, + was removed at 4.0; same engine). ### Phase 8: Survey Maturity (v2.8.3-v2.8.4) diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 39dda237c..8fab79980 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -184,12 +184,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning code_refs: [diff_diff/triple_diff.py, diff_diff/staggered_triple_diff.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/staggered_bootstrap.py, diff_diff/power.py, diff_diff/__init__.py] test_ref: tests/test_v4_merge_ddd.py - notes: "Facade dispatch: 2x2x2 engine vs staggered engine, both internally unchanged (R-parity preserved). Partition param unified as 'partition'. The compact 'notyettreated' control_group spelling rides this class removal - no value shim on the dying class; the unified class uses underscored values from birth. SHIPPED 3.9 (Phase 3(b)). Mechanics: (1) the estimation core moved VERBATIM to the private diff_diff/_staggered_triple_diff_engine.py, mixed into both classes, so neither surface forks the math - the pre-merge oracles reproduce bit-identically on both engines (tests/_capture_v4_merge_ddd_oracles.py). (2) The core takes three knobs: estimator_name (messages never name the deprecated class on the merged surface), partition_label (applied to BOTH sentences of the time-invariance error - the tail 'varying eligibility' would otherwise leak onto the partition= interface), and _frame_offset, mirrored onto self._warn_frame_offset for the four helper depths plus the shared bootstrap mixin, so warning attribution is unchanged on both paths. (3) fit dispatches on first_treat=; the staggered-only fit params are KEYWORD-ONLY (a positionally-written staggered call cannot bind to the 2x2x2 slots), while positional slots 1-8 including the deprecated time= are preserved byte-for-byte, so nothing narrows. (4) Mixing the two designs' params raises in BOTH directions, constructor params included (control_group/anticipation/base_period/n_bootstrap in 2x2x2; robust/vcov_type in staggered). bootstrap_weights/seed/cband pass silently in 2x2x2 because they are unreachable without n_bootstrap > 0, which is itself rejected - recorded here and in REGISTRY rather than left to discovery. The power front door (simulate_power/simulate_mde/simulate_sample_size) applies the SAME boundary, rejecting only control_group/anticipation/base_period/n_bootstrap: an earlier revision had it reject all seven, which made TripleDifference(seed=7) legal to fit and illegal to simulate. The phase also added TWO fail-closed identification guards in the SHARED engine, so both surfaces (the deprecated class included) reject them: negative/-inf first_treat cohort values, which previously fell into neither the treated nor the comparison population and silently estimated on a different sample (overall_att 3.29438582 -> 2.99470938, n_never_enabled 24 -> 0 on the 3(b) fixture); and a non-negative-integer anticipation window via the new shared utils.validate_anticipation. The dying class's API SHAPE stays frozen - construction still succeeds, fit raises - because these are identification guards, not signature changes. (5) cluster= RAISES in staggered mode (the deprecated class keeps its accepted-then-warned-then-ignored UserWarning): a new surface should not ship an accepted-but-unhonored param, the 3(a) precedent. (6) Returns StaggeredTripleDiffResults through 3.9; the container unification is M-014's job at 4.0. (7) pscore_trim validation tightened, rowed separately as M-142. Fit-time aggregate=/balance_e= are carried on the surviving class and rowed as M-140/M-141." + notes: "REMOVED at the 4.0 cut (PR 4a, 2026-10-21). The class and its module diff_diff/staggered_triple_diff.py are gone; TripleDifference().fit(..., unit=, time=, first_treat=, partition=) runs the same engine (_staggered_triple_diff_engine.py), pinned against the committed oracle in tests/test_v4_merge_ddd.py; the three StaggeredTripleDifference test suites were ported to the merged surface. Shimmed in 3.9: Facade dispatch: 2x2x2 engine vs staggered engine, both internally unchanged (R-parity preserved). Partition param unified as 'partition'. The compact 'notyettreated' control_group spelling rides this class removal - no value shim on the dying class; the unified class uses underscored values from birth. SHIPPED 3.9 (Phase 3(b)). Mechanics: (1) the estimation core moved VERBATIM to the private diff_diff/_staggered_triple_diff_engine.py, mixed into both classes, so neither surface forks the math - the pre-merge oracles reproduce bit-identically on both engines (tests/_capture_v4_merge_ddd_oracles.py). (2) The core takes three knobs: estimator_name (messages never name the deprecated class on the merged surface), partition_label (applied to BOTH sentences of the time-invariance error - the tail 'varying eligibility' would otherwise leak onto the partition= interface), and _frame_offset, mirrored onto self._warn_frame_offset for the four helper depths plus the shared bootstrap mixin, so warning attribution is unchanged on both paths. (3) fit dispatches on first_treat=; the staggered-only fit params are KEYWORD-ONLY (a positionally-written staggered call cannot bind to the 2x2x2 slots), while positional slots 1-8 including the deprecated time= are preserved byte-for-byte, so nothing narrows. (4) Mixing the two designs' params raises in BOTH directions, constructor params included (control_group/anticipation/base_period/n_bootstrap in 2x2x2; robust/vcov_type in staggered). bootstrap_weights/seed/cband pass silently in 2x2x2 because they are unreachable without n_bootstrap > 0, which is itself rejected - recorded here and in REGISTRY rather than left to discovery. The power front door (simulate_power/simulate_mde/simulate_sample_size) applies the SAME boundary, rejecting only control_group/anticipation/base_period/n_bootstrap: an earlier revision had it reject all seven, which made TripleDifference(seed=7) legal to fit and illegal to simulate. The phase also added TWO fail-closed identification guards in the SHARED engine, so both surfaces (the deprecated class included) reject them: negative/-inf first_treat cohort values, which previously fell into neither the treated nor the comparison population and silently estimated on a different sample (overall_att 3.29438582 -> 2.99470938, n_never_enabled 24 -> 0 on the 3(b) fixture); and a non-negative-integer anticipation window via the new shared utils.validate_anticipation. The dying class's API SHAPE stays frozen - construction still succeeds, fit raises - because these are identification guards, not signature changes. (5) cluster= RAISES in staggered mode (the deprecated class keeps its accepted-then-warned-then-ignored UserWarning): a new surface should not ship an accepted-but-unhonored param, the 3(a) precedent. (6) Returns StaggeredTripleDiffResults through 3.9; the container unification is M-014's job at 4.0. (7) pscore_trim validation tightened, rowed separately as M-142. Fit-time aggregate=/balance_e= are carried on the surviving class and rowed as M-140/M-141." - id: M-014 kind: class group: merge-ddd @@ -209,12 +209,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_v4_merge_cic.py code_refs: [diff_diff/changes_in_changes.py, diff_diff/changes_in_changes_results.py, diff_diff/practitioner.py, diff_diff/__init__.py] - notes: "method='cic' (default) | 'qdid'; default encodes Athey-Imbens' recommendation of CiC over QDiD. Already one dispatcher/results class internally. SHIPPED 3.9 (Phase 3(c)). method= is KEYWORD-ONLY (every pre-existing positional slot is preserved byte-for-byte; BaseEstimator._param_names accepts KEYWORD_ONLY so get_params/set_params pick it up with no new hooks) and lowercase-only - 'CiC'/'QDiD' casing raises, matching the underscored-only control_group ruling in [M-013]. The METHOD is not deprecated, only the CLASS spelling: method='qdid' emits no advisory warning, because the fit-time footnote-21 non-monotonicity UserWarning and practitioner.py's 'Prefer ChangesInChanges over QDiD' step already carry Athey-Imbens' caution, and a third warning would blur which of class/method is dying. VALIDATION is in BOTH places by necessity: __init__ passes a hand-built dict (it does not forward get_params()), so 'method' is added there AND read as params.get('method', 'cic') inside _validate_all_params - the .get default exists because QDiD keeps its frozen five-param __init__, so its get_params() carries no 'method' key and _fit_distributional re-validates through the same function on every QDiD fit. _fit_distributional's estimator_name now derives from type(est).__name__ instead of the kind literal, so a ChangesInChanges(method='qdid') fit no longer raises errors naming a class the user never constructed (byte-identical on both 3.x surfaces). QDiD stays a STANDALONE sibling, not a subclass: inheriting the merged __init__ would put method into its get_params() and make QDiD(method='cic') constructible. Results-field mirror: [M-143]." + notes: "REMOVED at the 4.0 cut (PR 4a, 2026-10-21). The class and the QDiDResults alias are gone; ChangesInChanges(method='qdid') is the estimator, and _validate_all_params now reads params['method'] directly (the .get default existed only for the removed class). Shimmed in 3.9: method='cic' (default) | 'qdid'; default encodes Athey-Imbens' recommendation of CiC over QDiD. Already one dispatcher/results class internally. SHIPPED 3.9 (Phase 3(c)). method= is KEYWORD-ONLY (every pre-existing positional slot is preserved byte-for-byte; BaseEstimator._param_names accepts KEYWORD_ONLY so get_params/set_params pick it up with no new hooks) and lowercase-only - 'CiC'/'QDiD' casing raises, matching the underscored-only control_group ruling in [M-013]. The METHOD is not deprecated, only the CLASS spelling: method='qdid' emits no advisory warning, because the fit-time footnote-21 non-monotonicity UserWarning and practitioner.py's 'Prefer ChangesInChanges over QDiD' step already carry Athey-Imbens' caution, and a third warning would blur which of class/method is dying. VALIDATION is in BOTH places by necessity: __init__ passes a hand-built dict (it does not forward get_params()), so 'method' is added there AND read as params.get('method', 'cic') inside _validate_all_params - the .get default exists because QDiD keeps its frozen five-param __init__, so its get_params() carries no 'method' key and _fit_distributional re-validates through the same function on every QDiD fit. _fit_distributional's estimator_name now derives from type(est).__name__ instead of the kind literal, so a ChangesInChanges(method='qdid') fit no longer raises errors naming a class the user never constructed (byte-identical on both 3.x surfaces). QDiD stays a STANDALONE sibling, not a subclass: inheriting the merged __init__ would put method into its get_params() and make QDiD(method='cic') constructible. Results-field mirror: [M-143]." - id: M-016 kind: field group: merge-mpd @@ -367,10 +367,10 @@ rows: removed_in: null status: shimmed phase: 2 - warning: FutureWarning + warning: null test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/triple_diff.py, diff_diff/power.py, diff_diff/prep_dgp.py] - notes: "Same post-dummy overload as [M-030], but the NAME 'time' persists as the merged class's staggered calendar column, so removed_in is null - the 4.0 semantic enforcement is [M-085] (mirrors the TWFE pair [M-082]/[M-083])." + notes: "Same post-dummy overload as [M-030], but the NAME 'time' persists as the merged class's staggered calendar column, so removed_in is null - the 4.0 semantic enforcement is [M-085] (mirrors the TWFE pair [M-082]/[M-083]). AT 4.0 (PR 4a, 2026-10-21): the 2x2x2 FutureWarning path is gone - [M-085] now raises, so warning is null; 'time' survives only as the staggered calendar column (both names still bind on TripleDifference.fit, which is what 'shimmed' asserts here)." - id: M-032 kind: param group: renames-cohort @@ -769,10 +769,11 @@ rows: new_target: null deprecated_in: "3.9" removed_in: "4.0" - status: planned + status: done phase: 5 code_refs: [diff_diff/__init__.py] - notes: "Already a pure export alias of ChangesInChangesResults; dies with QDiD [M-015]. Status stays 'planned' through 3.9 (the M-060/M-064 precedent): the alias is a plain module global, deliberately NOT routed through _DEPRECATED_ALIASES, so it emits no warning of its own - constructing QDiD is what warns, and the alias identity QDiDResults is ChangesInChangesResults is pinned by tests/test_v4_merge_cic.py." + test_ref: tests/test_v4_merge_cic.py + notes: "RETIRED at the 4.0 cut (PR 4a, 2026-10-21): the name is absent from diff_diff and __all__; tests/test_v4_merge_cic.py pins the absence. old_target kept as the historical record (spec section 11 cross-row rule, alias clause). Through 3.9: Already a pure export alias of ChangesInChangesResults; dies with QDiD [M-015]. Status stays 'planned' through 3.9 (the M-060/M-064 precedent): the alias is a plain module global, deliberately NOT routed through _DEPRECATED_ALIASES, so it emits no warning of its own - constructing QDiD is what warns, and the alias identity QDiDResults is ChangesInChangesResults is pinned by tests/test_v4_merge_cic.py." - id: M-064 kind: alias group: alias-table @@ -782,10 +783,11 @@ rows: new_target: null deprecated_in: "3.9" removed_in: "4.0" - status: planned + status: done phase: 5 code_refs: [diff_diff/__init__.py] - notes: "Dies with its class [M-013]; deprecation warning rides the parent class shim (same object). Migrate the old_target locator when [M-013] flips to removed (cross-row rule). 3.9 FutureWarning live via the parent [M-013] shim since Phase 3(b) - SDDD stays a plain module global (NOT an entry in __init__'s _DEPRECATED_ALIASES: that table is read only by the module __getattr__, which never fires for a real global, so an entry would be dead code). Status stays 'planned' because this row's own transition is the 4.0 removal; the parent's message names the alias explicitly. Pinned by tests/test_v4_merge_ddd.py." + test_ref: tests/test_v4_merge_ddd.py + notes: "RETIRED at the 4.0 cut (PR 4a, 2026-10-21): the name is absent from diff_diff and __all__; tests/test_v4_merge_ddd.py pins the absence. old_target kept as the historical record (spec section 11 cross-row rule, alias clause). Through 3.9: Dies with its class [M-013]; deprecation warning rides the parent class shim (same object). 3.9 FutureWarning live via the parent [M-013] shim since Phase 3(b) - SDDD stays a plain module global (NOT an entry in __init__'s _DEPRECATED_ALIASES: that table is read only by the module __getattr__, which never fires for a real global, so an entry would be dead code). Status stays 'planned' because this row's own transition is the 4.0 removal; the parent's message names the alias explicitly. Pinned by tests/test_v4_merge_ddd.py." - id: M-062 kind: alias group: alias-table @@ -1038,7 +1040,7 @@ rows: removed_in: null status: planned phase: 5 - code_refs: [diff_diff/spillover.py, diff_diff/twfe.py, diff_diff/estimators.py, diff_diff/sun_abraham.py, diff_diff/lpdid.py, diff_diff/stacked_did.py, diff_diff/wooldridge.py, diff_diff/imputation.py, diff_diff/two_stage.py, diff_diff/staggered.py, diff_diff/efficient_did.py, diff_diff/dml_did.py, diff_diff/lwdid.py, diff_diff/had.py, diff_diff/triple_diff.py, diff_diff/staggered_triple_diff.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/chaisemartin_dhaultfoeuille.py, docs/methodology/variance-conventions.md] + code_refs: [diff_diff/spillover.py, diff_diff/twfe.py, diff_diff/estimators.py, diff_diff/sun_abraham.py, diff_diff/lpdid.py, diff_diff/stacked_did.py, diff_diff/wooldridge.py, diff_diff/imputation.py, diff_diff/two_stage.py, diff_diff/staggered.py, diff_diff/efficient_did.py, diff_diff/dml_did.py, diff_diff/lwdid.py, diff_diff/had.py, diff_diff/triple_diff.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/chaisemartin_dhaultfoeuille.py, docs/methodology/variance-conventions.md] notes: "RE-SCOPED 2026-10-18 (4.0-cut framing, user decision; measured roster in docs/methodology/variance-conventions.md and spec section 7). The original premise - static TwoWayFixedEffects runs HC-robust by default - was already false: static TWFE auto-clusters at unit (twfe.py, since 2026-01). Measured categories: (a) SpilloverDiD is the ONE required-unit estimator whose default SE moves (observation-level HC -> unit clustering, mirroring TWFE's carve-outs: explicit cluster= wins, Conley drops it, never injected as survey PSU); (c1) TWFE/SunAbraham/WooldridgeDiD/LPDiD already cluster at unit by regression-level default - cluster=False yields the plain HC sandwich; (c2) ImputationDiD/TwoStageDiD default cluster_var=unit INSIDE unit-level influence-function machinery - no unclustered variance exists, cluster=False is an accepted explicit no-op; (c3) StackedDiD clusters by construction and its validator accepts only unit/unit_subexp - cluster=False is REJECTED (REGISTRY Note); (b) CS/EfficientDiD/DMLDiD/dCDH/staggered-DDD (per-unit IF) and LWDiD/HAD (regression on a unit-level cross-section) are unit-level by construction - cluster=False is an accepted explicit no-op; (d) ContinuousDiD/DurationDiD/SDiD/SC/TROP/CiC have no cluster param; (n/a) cross-sectional DiD/2x2x2 DDD/RDD stay HC-robust or unclustered. Categories b and d carry NO auto-cluster metadata by decision (cluster_name stays None where the field exists; no field is added where it does not). The 4.0 flip therefore = the SpilloverDiD default + the uniform cluster=False off-switch on a/b/c1/c2 (today accepted nowhere: LPDiD's `self.cluster or unit` treats False as auto-cluster, most others look up a column named False, DMLDiD/StackedDiD reject on type, dCDH raises on any non-None) + metadata gaps (TWFE/DiD n_clusters on the analytical path; SunAbraham cluster_name + n_clusters). ORIGINAL NOTE: 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 @@ -1098,10 +1100,11 @@ rows: new: null deprecated_in: "4.0" removed_in: null - status: planned + status: done phase: 5 code_refs: [diff_diff/triple_diff.py] - notes: "At 4.0, 'time' means the staggered calendar column ONLY: 2x2x2 mode passing time= raises a ValueError pointing at post= - never silently reinterpreted. Companion to [M-031]; mirrors [M-083]." + test_ref: tests/test_v4_merge_ddd.py + notes: "DONE at the 4.0 cut (PR 4a, 2026-10-21): 2x2x2 time= raises ValueError naming post= (both-supplied keeps its own 'pass only post=' error); the M-031 FutureWarning path is gone. Spec: At 4.0, 'time' means the staggered calendar column ONLY: 2x2x2 mode passing time= raises a ValueError pointing at post= - never silently reinterpreted. Companion to [M-031]; mirrors [M-083]." - id: M-084 kind: param group: constructor-hygiene diff --git a/docs/v4-design.md b/docs/v4-design.md index 72a4439f5..a21d6f9c0 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -406,8 +406,8 @@ positionally would otherwise bind `group="unit"`, `partition="period"`, `post="first_treat"` and land in 2x2x2 mode with a confusing downstream error instead of a mode error. `time=` keeps its dual role, resolved by dispatch ORDER rather than by value inspection: the staggered branch is taken before the -M-031 rename shim runs, so `time=` is the calendar column there and emits no -rename warning. +2x2x2 `time=` check, so `time=` is the calendar column there (2x2x2 mode raises +on it since 4.0 [M-085]; through 3.x the M-031 rename shim warned). **Routing semantics.** One class, two engines, both internally UNCHANGED in this program (R-parity preserved; engine unification is a possible 4.x @@ -426,7 +426,7 @@ GMM weighting on the staggered engine). `cluster=` analytical SEs remain staggered-mode-unsupported (bootstrap required) - documented in the class docstring, tracked as post-4.0 backlog. **(Amended 2026-08-08, Phase 3(b) ship, user-approved.)** On the MERGED surface `cluster=` now RAISES a `ValueError` -steering to `n_bootstrap > 0`, rather than the deprecated class's +steering to `n_bootstrap > 0`, rather than the removed class's accepted-then-warned-then-ignored `UserWarning`, which is preserved unchanged on that class. Same reasoning 3(a) ratified for MPD's wild-bootstrap fallback: the raise is live from the mode's 3.9 BIRTH, because a new surface needs no @@ -1074,13 +1074,23 @@ tests-first PR that flips no rows). `PeriodEffect` and `EventStudy`; the QDiD class and `QDiDResults`; the `StaggeredTripleDifference` CLASS and `SDDD` - but NOT `StaggeredTripleDiffResults` [M-014], which the surviving facade's - staggered mode still constructs and which dies in PR 6 (the DDD container unification). Rides along: - [M-007], the [M-016] property view on `EventStudyResults`, the [M-083] / - [M-085] raises, the `pyproject.toml` `filterwarnings` entries, the - tutorials built on MultiPeriodDiD (01/02/04/05/07/23, rebuilt on - `TWFE(event_study=True)` - user decision), the MultiPeriodDiD benchmark arm - (relabelled `spec="pooled"`), the power registry, and the - `doc-deps.yaml` STD group/key. + staggered mode still constructs and which dies in PR 6 (the DDD container + unification). Split three ways (2026-10-21, user decision) because CI + executes the tutorials and five of them construct MultiPeriodDiD: + - 4a - the `StaggeredTripleDifference` class + `SDDD` and the QDiD class + + `QDiDResults`, with the [M-085] raise, the two `pyproject.toml` + `filterwarnings` entries, the `doc-deps.yaml` STD group/key, and the + three StaggeredTripleDifference suites ported to the merged surface + (SHIPPED, PR 4a). + - 4b - the tutorials built on MultiPeriodDiD (02/04/05/07/23; 01 no longer + uses it) rebuilt on `TWFE(event_study=True)` - user decision - plus the + StaggeredTripleDifference markdown mentions in 08/16; lands BEFORE 4c + because CI executes the notebooks. + - 4c - the MultiPeriodDiD class, `MultiPeriodDiDResults`, `PeriodEffect` + and `EventStudy`, with [M-007], the [M-016] property view on + `EventStudyResults`, the [M-083] raise, the remaining `filterwarnings` + entry, the MultiPeriodDiD benchmark arm (relabelled `spec="pooled"`) and + the power registry. 5. Storage flip [M-050]..[M-058] + pickle migration + the `.att` sweep over docs/notebooks/guides/benchmarks (incl. a disposition for the un-rowed `CSBootstrapResults.overall_att_se`). @@ -1095,8 +1105,9 @@ tests-first PR that flips no rows). 10. Release: bump to 4.0.0 (closes the cut window), changelog compile, migration-guide retitle, due gate green. -Ordering constraints: 4 before 5 and 6; 5 before 6 (the unified DDD -container is born canonical); 7 and 8 independent; 9 after 4/6; 10 last. +Ordering constraints: 4a before 4b before 4c; 4 before 5 and 6; 5 before 6 +(the unified DDD container is born canonical); 7 and 8 independent; 9 after +4/6; 10 last. Waves 2 and 3 are independent of each other and of 4. **3.9-cut checklist (un-rowed obligations).** `test_due_rows_are_terminal` @@ -1357,7 +1368,11 @@ only while the window is open). same diff, every other row whose locators or `code_refs` reference it (e.g. [M-016]'s locator moves to the successor container when [M-011] flips). The enforcement test's hard-fail on unresolvable module parts makes forgetting -this loud, not silent. +this loud, not silent. Alias clause (PR 4a, 2026-10-21): an alias row +reaching `done` with `new_target: null` keeps its `old_target` as the +historical record - the probe does not resolve `old_target` at `done` +(`tests/test_v4_matrix.py`, alias branch), and there is no successor to +migrate to; [M-061] and [M-064] follow this reading, [M-060] will. **`phase` semantics.** `phase` names the program phase whose PR performs the row's NEXT status transition; each flip updates it (terminal rows keep their diff --git a/pyproject.toml b/pyproject.toml index 056c39b33..74b917ce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,21 +125,16 @@ 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 and ~103 StaggeredTripleDifference behavior-suite constructions -# stay noise-free through the 3.9 deprecation window (rows M-010 / M-013; the -# suites pin each class's 3.x behavior until its 4.0 removal - REMOVE each -# filter with its class). pytest.warns overrides them, so the dedicated -# deprecation pins in tests/test_v4_merge_mpd.py and tests/test_v4_merge_ddd.py -# still fire; note a test-local `warnings.simplefilter(...)` RESETS the filter -# list, so record-based tests must select warnings by message, never index. -# The SDDD suites already do: several DO count and index (e.g. -# test_staggered_triple_diff.py's rank-guard assertions), but every one of them -# substring-filters the record list first, so the new FutureWarning never -# enters a counted list. +# The ~140 MPD behavior-suite constructions stay noise-free through the +# deprecation window (row M-010; the suite pins the class's 3.x behavior until +# its 4.0 removal in PR 4c - REMOVE the filter with the class; the +# StaggeredTripleDifference and QDiD filters went with their classes in PR 4a). +# 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", - "ignore:StaggeredTripleDifference is deprecated:FutureWarning", - "ignore:QDiD is deprecated:FutureWarning", ] [tool.black] diff --git a/tests/_capture_v4_merge_cic_oracles.py b/tests/_capture_v4_merge_cic_oracles.py index ac21c6978..f6b468804 100644 --- a/tests/_capture_v4_merge_cic_oracles.py +++ b/tests/_capture_v4_merge_cic_oracles.py @@ -1,5 +1,11 @@ """Step 0: capture the pre-merge oracles for tests/test_v4_merge_cic.py. +HISTORICAL since the 4.0 cut (PR 4a): this script imports and drives the REMOVED +``QDiD`` class, so it is runnable only at the recorded provenance commit below +(``c2941caa``). It is kept as the oracle literals' provenance record; the test +module now carries its own copy of ``make_2x2``. Re-capturing means checking out +that commit. + MUST run on the UNMODIFIED tree (before ``method=`` lands on ChangesInChanges), under DIFF_DIFF_BACKEND=python: @@ -26,9 +32,9 @@ golden at atol=1e-10, rtol=0 for cic/qdid x panel/rcs. That is a stronger absolute cross-tree pin than anything this file would add. -The DGP lives HERE, not in the test module: this script runs at step 1 while -the test module is written at step 7, so the dependency must point this way. -tests/test_v4_merge_cic.py imports ``make_2x2`` from this module. +The DGP lived HERE, not in the test module, because this script ran at step 1 +while the test module was written at step 7; since PR 4a the test module keeps +its own copy of ``make_2x2`` (this module no longer imports on the 4.0 tree). PROVENANCE of the literals committed in tests/test_v4_merge_cic.py: commit c2941caa2a7865c9458c6092359e75238fdbabb1 (origin/main, pre-3(c)) diff --git a/tests/_capture_v4_merge_ddd_oracles.py b/tests/_capture_v4_merge_ddd_oracles.py index 6ee490a74..847301020 100644 --- a/tests/_capture_v4_merge_ddd_oracles.py +++ b/tests/_capture_v4_merge_ddd_oracles.py @@ -1,5 +1,11 @@ """Step 0: capture the pre-merge oracles for tests/test_v4_merge_ddd.py. +HISTORICAL since the 4.0 cut (PR 4a): this script imports and drives the REMOVED +``StaggeredTripleDifference`` class, so it is runnable only at the recorded +provenance commit (``be694f49``, see the test module's ORACLE PROVENANCE). It is +kept as the oracle literals' provenance record; re-capturing means checking out +that commit. + MUST run on the UNMODIFIED tree, under DIFF_DIFF_BACKEND=python. Two oracles: diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py index fb3bd4f61..2cb42973c 100644 --- a/tests/test_aggregate_contract.py +++ b/tests/test_aggregate_contract.py @@ -42,7 +42,7 @@ import pytest from legacy_surface import boot_oracles, load_oracles, oracle_value -from diff_diff import CallawaySantAnna, StaggeredTripleDifference +from diff_diff import CallawaySantAnna, TripleDifference from diff_diff.aggregation import AGGREGATION_SCHEMA, AggregationResult from diff_diff.results_base import EventStudyResults, build_event_study_surface from diff_diff.utils import safe_inference @@ -1477,18 +1477,20 @@ def test_bootstrap_clears_group_df_provenance(self, survey): @pytest.mark.parametrize("survey", [False, True]) def test_sddd_bootstrap_clears_group_df_provenance(self, survey): - """StaggeredTripleDifference carries its OWN copy of the bootstrap - group-replacement loop, so the clearing rule has to hold there too - + """The staggered DDD engine (TripleDifference's staggered mode) carries + its OWN copy of the bootstrap group-replacement loop, so the clearing + rule has to hold there too - fixing one twin and testing only the other is how this class of bug survived in the first place. Coverage is ASYMMETRIC and deliberately so: only ``survey=True`` exercises the clearing (verified by reverting the fix - just that case - fails). SDDD's plain-``cluster`` path never resolves a finite analytical - df, so ``df_used`` is already None there and nothing needs clearing. The + fails). The engine's plain path never resolves a finite analytical df, so + ``df_used`` is already None there and nothing needs clearing. The non-survey case is kept as a cheap guard in case that ever changes, not - because it currently reproduces the bug. The CallawaySantAnna - equivalent above DOES fail in both configurations. + because it currently reproduces the bug (TripleDifference rejects + ``cluster=`` in staggered mode, so neither arm clusters). The + CallawaySantAnna equivalent above DOES fail in both configurations. """ from diff_diff import SurveyDesign @@ -1511,7 +1513,7 @@ def test_sddd_bootstrap_clears_group_df_provenance(self, survey): "outcome": 0.3 * t + (1.2 if treated else 0.0) + rng.normal(0, 0.2), } ) - ctor = {} if survey else {"cluster": "psu"} + ctor = {} fit_extra = ( {"survey_design": SurveyDesign(weights="w", strata="stratum", psu="psu")} if survey @@ -1519,19 +1521,19 @@ def test_sddd_bootstrap_clears_group_df_provenance(self, survey): ) with warnings.catch_warnings(): warnings.simplefilter("ignore") - res = StaggeredTripleDifference(n_bootstrap=40, seed=3, **ctor).fit( + res = TripleDifference(n_bootstrap=40, seed=3, **ctor).fit( pd.DataFrame(rows), - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="all", **fit_extra, ) - assert res.group_effects, "fixture produced no SDDD group rows" + assert res.group_effects, "fixture produced no staggered-DDD group rows" for g, eff in res.group_effects.items(): - assert eff.get("df_used") is None, f"SDDD group {g} kept analytical df on bootstrap" + assert eff.get("df_used") is None, f"DDD group {g} kept analytical df on bootstrap" def test_group_df_is_not_the_event_study_df(self, fitted): """``group`` must not borrow ``event_study_df``: on a plain fit that @@ -1663,7 +1665,7 @@ def test_summary_refuses_to_relabel_a_stored_interval(self, fitted): def test_staggered_triple_diff_overall_att_es_still_works(): - """StaggeredTripleDifference inherits the CS aggregation mixin and is the + """TripleDifference (staggered mode) inherits the CS aggregation mixin and is the ONLY reader of the Eq. 4.14 overall, which the purity refactor converted from a self-attribute to a returned value.""" rng = np.random.default_rng(5) @@ -1685,13 +1687,13 @@ def test_staggered_triple_diff_overall_att_es_still_works(): data = pd.DataFrame(rows) with warnings.catch_warnings(): warnings.simplefilter("ignore") - res = StaggeredTripleDifference().fit( + res = TripleDifference().fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) assert res.overall_att_es is not None diff --git a/tests/test_anticipation_policy.py b/tests/test_anticipation_policy.py index b21608a93..ae6262085 100644 --- a/tests/test_anticipation_policy.py +++ b/tests/test_anticipation_policy.py @@ -15,11 +15,9 @@ keyword-only XOR ``treatment``/``first_treat`` interface doesn't fit this suite's shared invocation); the other adopters are pinned HERE in the mutation lanes. -- The deprecated ``StaggeredTripleDifference`` stays - construction-permissive BY DESIGN (frozen 3.x API shape; the shared - staggered engine validates at fit) — pinned by - test_v4_merge_ddd.py::test_deprecated_sibling_also_fails_closed_on_anticipation, - and deliberately NOT in this roster. +- The ``StaggeredTripleDifference`` class (removed at the 4.0 cut, PR 4a) was + the one construction-permissive exception through 3.x; the roster is now + exactly the validated classes. Message pins match the FULL text via ``re.escape`` (repo convention), except numpy-scalar values whose repr is NEP-51-dependent — those use the @@ -43,7 +41,6 @@ ImputationDiD, SpilloverDiD, StackedDiD, - StaggeredTripleDifference, SunAbraham, TripleDifference, TwoStageDiD, @@ -127,14 +124,11 @@ def test_anticipation_exposed_by_exactly_the_validated_roster(self): params = inspect.signature(obj.__init__).parameters if "anticipation" in params: discovered.append(obj) - assert set(discovered) == set(VALIDATED_CLASSES) | {StaggeredTripleDifference}, ( + assert set(discovered) == set(VALIDATED_CLASSES), ( "The `anticipation` roster changed. A future estimator exposing " "`anticipation` must join the family-wide validation policy " "(ledger row M-144): validate via utils.validate_anticipation at " - "__init__ AND re-check on the fit path via the assignment form. " - "The deprecated StaggeredTripleDifference is the one documented " - "construction-permissive exception (fit-validated via the shared " - "engine)." + "__init__ AND re-check on the fit path via the assignment form." ) diff --git a/tests/test_balance_interop_contract.py b/tests/test_balance_interop_contract.py index bc872b769..4869fe620 100644 --- a/tests/test_balance_interop_contract.py +++ b/tests/test_balance_interop_contract.py @@ -208,8 +208,11 @@ def test_survey_design_replicate_construction(): # 4. Estimator resolution by name and alias # --------------------------------------------------------------------------- -# The 17 estimator names promised in balance/interop/diff_diff.py's module -# docstring ("weight_type='pweight' is ... compatible with ..."). +# 16 of the 17 estimator names promised in balance/interop/diff_diff.py's module +# docstring ("weight_type='pweight' is ... compatible with ..."). The 17th, +# StaggeredTripleDifference, was removed at the 4.0 cut (PR 4a; its staggered DDD +# is TripleDifference's first_treat= mode). balance's [did] extra pins +# diff-diff>=3.3.0,<4, so 3.x users keep the name until the adapter follows. ADAPTER_DOCSTRING_ESTIMATORS = [ "CallawaySantAnna", "StackedDiD", @@ -218,7 +221,6 @@ def test_survey_design_replicate_construction(): "TwoStageDiD", "WooldridgeDiD", "TROP", - "StaggeredTripleDifference", "ChaisemartinDHaultfoeuille", "TripleDifference", "SyntheticDiD", diff --git a/tests/test_base_estimator.py b/tests/test_base_estimator.py index 814c7ff64..7136143b8 100644 --- a/tests/test_base_estimator.py +++ b/tests/test_base_estimator.py @@ -94,7 +94,6 @@ def _discover(): "LPDiD": {"alpha": 5.0}, "ChangesInChanges": {"alpha": 5.0}, "DurationDiD": {"method": "not_a_method"}, - "QDiD": {"alpha": 5.0}, "HeterogeneousAdoptionDiD": {"design": "not_a_design"}, "RegressionDiscontinuity": {"kernel": "not_a_kernel"}, "RDDensityTest": {"kernel": "not_a_kernel"}, @@ -103,7 +102,6 @@ def _discover(): "PreTrendsPower": {"alpha": 5.0}, "WooldridgeDiD": {"method": "not_a_method"}, "SpilloverDiD": {"rank_deficient_action": "not_an_action"}, - "StaggeredTripleDifference": {"estimation_method": "not_a_method"}, "BaconDecomposition": {"weights": "not_a_weighting"}, } @@ -144,8 +142,8 @@ def _params_id(cls): def test_roster_discovered_nontrivially(): - # 28 defining classes (DMLDiD included) + TwoWayFixedEffects/ - # MultiPeriodDiD inheritors. + # 26 defining classes (DMLDiD included; StaggeredTripleDifference and QDiD + # removed at the 4.0 cut) + TwoWayFixedEffects/MultiPeriodDiD inheritors. assert len(MIXIN_CLASSES) >= 27 @@ -186,12 +184,11 @@ def test_init_signature_matches_get_params(cls): # (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. Complete as of phase 3(c): all three merges have shipped, -# so any future entry belongs to a NEW deprecation, not a pending sibling. +# the error filter. Only MultiPeriodDiD remains (StaggeredTripleDifference and +# QDiD were removed at the 4.0 cut, PR 4a; MultiPeriodDiD follows in PR 4c), so +# any future entry belongs to a NEW deprecation, not a pending sibling. DEPRECATED_CLASS_WARNINGS = { "MultiPeriodDiD": r"MultiPeriodDiD is deprecated", - "StaggeredTripleDifference": r"StaggeredTripleDifference is deprecated", - "QDiD": r"QDiD is deprecated", } diff --git a/tests/test_business_report.py b/tests/test_business_report.py index 6119125bf..e17732e65 100644 --- a/tests/test_business_report.py +++ b/tests/test_business_report.py @@ -737,8 +737,8 @@ def __init__(self): class TestAssumptionBlockSourceFaithful: """Regression for the round-4 P1 finding that ``_describe_assumption`` - was producing generic DiD PT text for ContinuousDiD, TripleDifference, - and StaggeredTripleDifference — all of which have different identifying + was producing generic DiD PT text for ContinuousDiD and TripleDifference + (both modes) — all of which have different identifying logic per the Methodology Registry.""" def _stub(self, class_name): @@ -1572,7 +1572,8 @@ class TestStaggeredTripleDiffNeverTreatedFixedComparison: stores ``n_control_units`` as a composite total that also includes the eligibility-denied cohorts. The valid fixed comparison under ``control_group="never_treated"`` is the never-enabled cohort - (``staggered_triple_diff.py:384``, REGISTRY.md §StaggeredTripleDifference + (the ``has_never_enabled`` / ``valid_gc = [0]`` comparison selection in + ``_staggered_triple_diff_engine.py``, REGISTRY.md §StaggeredTripleDifference line 1730). BR was previously narrating the composite total as "control" on the ``nevertreated`` mode; the fix surfaces ``n_never_enabled`` as the fixed comparison count on that path @@ -2194,9 +2195,9 @@ class ContinuousDiDResults: class TestStaggeredTripleDiffDynamicControlSample: - """Round-18 P1 regression: StaggeredTripleDifference with - ``control_group="notyettreated"`` (no underscore per the estimator - contract) must also take the dynamic-control path. Its fixed + """Round-18 P1 regression: a staggered-DDD results stub carrying the 3.x + compact ``control_group="notyettreated"`` spelling (as a pre-4.0 pickle + would) must also take the dynamic-control path. Its fixed subset is ``n_never_enabled`` (separate field) rather than a never-treated count. """ diff --git a/tests/test_changes_in_changes.py b/tests/test_changes_in_changes.py index 6c0938799..264b5acf5 100644 --- a/tests/test_changes_in_changes.py +++ b/tests/test_changes_in_changes.py @@ -14,9 +14,18 @@ import pandas as pd import pytest -from diff_diff import ChangesInChanges, ChangesInChangesResults, QDiD, QDiDResults +from diff_diff import ChangesInChanges, ChangesInChangesResults from tests.conftest import assert_nan_inference + +def QDiD(**kw): + """The qdid method on the merged surface (the standalone QDiD class was + removed at the 4.0 cut, PR 4a); keeps the suite's two-arm parametrization + readable.""" + kw.setdefault("method", "qdid") + return ChangesInChanges(**kw) + + BOTH = pytest.mark.parametrize("cls", [ChangesInChanges, QDiD], ids=["cic", "qdid"]) @@ -910,5 +919,10 @@ def test_custom_quantile_grid(self, cls): ) -def test_qdid_results_alias(): - assert QDiDResults is ChangesInChangesResults +def test_qdid_results_alias_is_gone(): + """The QDiDResults alias (M-061) was retired at the 4.0 cut; the shared + container is ChangesInChangesResults for both methods.""" + import diff_diff + + assert not hasattr(diff_diff, "QDiDResults") + assert type(fit_quiet(QDiD(n_bootstrap=0), make_2x2())) is ChangesInChangesResults diff --git a/tests/test_changes_in_changes_parity.py b/tests/test_changes_in_changes_parity.py index fac86fbd3..ff62e5d92 100644 --- a/tests/test_changes_in_changes_parity.py +++ b/tests/test_changes_in_changes_parity.py @@ -50,7 +50,7 @@ import pandas as pd import pytest -from diff_diff import ChangesInChanges, QDiD +from diff_diff import ChangesInChanges from diff_diff.changes_in_changes import ( _QR_TAU_GRID, _design_matrix, @@ -97,8 +97,8 @@ def _scenario_df(scenario): def _fit(df, method, mode, probs, n_bootstrap=0, seed=None, covariates=None): - cls = ChangesInChanges if method == "cic" else QDiD - est = cls( + est = ChangesInChanges( + method=method, quantiles=probs, n_bootstrap=n_bootstrap, panel=(mode == "panel"), diff --git a/tests/test_event_study_surface.py b/tests/test_event_study_surface.py index cb5688561..a2a2deebc 100644 --- a/tests/test_event_study_surface.py +++ b/tests/test_event_study_surface.py @@ -283,7 +283,7 @@ def surfaces(): LPDiD, MultiPeriodDiD, StackedDiD, - StaggeredTripleDifference, + TripleDifference, WooldridgeDiD, generate_staggered_ddd_data, ) @@ -444,18 +444,18 @@ def surfaces(): wd.aggregate("event_study") out["WooldridgeDiD"] = (wd, build_event_study_surface(wd)) - # 10. StaggeredTripleDifference + # 10. TripleDifference, staggered mode (the staggered-DDD engine) sddd_data = generate_staggered_ddd_data(n_units=300, treatment_effect=3.0, seed=42) - sddd = StaggeredTripleDifference().fit( + sddd = TripleDifference().fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) - out["StaggeredTripleDifference"] = (sddd, build_event_study_surface(sddd)) + out["TripleDifference_staggered"] = (sddd, build_event_study_surface(sddd)) # 11. MultiPeriodDiD mpd_data = _mpd_panel() @@ -510,7 +510,7 @@ def surfaces(): "ContinuousDiD_bootstrap", "EfficientDiD", "WooldridgeDiD", - "StaggeredTripleDifference", + "TripleDifference_staggered", "MultiPeriodDiD", "LPDiD", "ChaisemartinDHaultfoeuille", diff --git a/tests/test_linalg.py b/tests/test_linalg.py index c7d2a119e..ec129e803 100644 --- a/tests/test_linalg.py +++ b/tests/test_linalg.py @@ -2581,7 +2581,7 @@ class TestRankGuardedInv: """Contract tests for _rank_guarded_inv (rank-guarded generalized inverse). Used by the influence-function SE path of CallawaySantAnna / - TripleDifference / StaggeredTripleDifference. A near-singular covariate Gram + TripleDifference (both modes). A near-singular covariate Gram matrix must yield a finite generalized inverse on the identified subspace (NaN only on true rank-0), and well-conditioned matrices must be unchanged. """ diff --git a/tests/test_methodology_changes_in_changes.py b/tests/test_methodology_changes_in_changes.py index cce04bcba..ce6d0b1c9 100644 --- a/tests/test_methodology_changes_in_changes.py +++ b/tests/test_methodology_changes_in_changes.py @@ -19,7 +19,7 @@ import pandas as pd import pytest -from diff_diff import ChangesInChanges, DifferenceInDifferences, QDiD +from diff_diff import ChangesInChanges, DifferenceInDifferences from diff_diff.changes_in_changes import ( _cic_point, _ecdf, @@ -30,6 +30,13 @@ ) +def QDiD(**kw): + """The qdid method on the merged surface (the standalone QDiD class was + removed at the 4.0 cut, PR 4a); keeps the two-arm parametrizations readable.""" + kw.setdefault("method", "qdid") + return ChangesInChanges(**kw) + + def fit_quiet(est, df, **kwargs): with warnings.catch_warnings(): warnings.simplefilter("ignore") diff --git a/tests/test_methodology_staggered_triple_diff.py b/tests/test_methodology_staggered_triple_diff.py index 4a3455893..6d62ebb1e 100644 --- a/tests/test_methodology_staggered_triple_diff.py +++ b/tests/test_methodology_staggered_triple_diff.py @@ -1,4 +1,8 @@ -"""Cross-validation tests: StaggeredTripleDifference vs R triplediff package. +"""Cross-validation tests: TripleDifference (staggered mode) vs R triplediff package. + +The staggered-DDD engine was the ``StaggeredTripleDifference`` class through 3.x +(removed at the 4.0 cut, PR 4a); it now runs behind +``TripleDifference().fit(..., first_treat=, partition=)``. Compares group-time ATT(g,t) and SE values against pre-computed R golden values generated by benchmarks/R/benchmark_staggered_triplediff.R. @@ -16,7 +20,7 @@ import pandas as pd import pytest -from diff_diff import StaggeredTripleDifference, generate_staggered_ddd_data +from diff_diff import TripleDifference, generate_staggered_ddd_data from diff_diff.survey import SurveyDesign BENCHMARK_DIR = Path(__file__).parent.parent / "benchmarks" / "data" / "synthetic" @@ -107,20 +111,26 @@ def _load_r_data(seed: int, dgp: int) -> pd.DataFrame: return data.rename(columns=R_TO_PY_COLS) +# The R golden JSON records triplediff's compact control_group spellings; the +# merged TripleDifference surface speaks the underscored library vocabulary only +# (the compact spellings died with the StaggeredTripleDifference class at 4.0). +_R_CONTROL_GROUP = {"nevertreated": "never_treated", "notyettreated": "not_yet_treated"} + + def _run_python(data, method, control_group): """Run Python estimator and return results.""" - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method=method, - control_group=control_group, + control_group=_R_CONTROL_GROUP.get(control_group, control_group), base_period="varying", ) return est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) @@ -311,18 +321,18 @@ def test_overall_att_close_to_r(self, r_results, key): if np.isnan(r.get("overall_att_simple", float("nan"))): pytest.skip("No simple aggregation in R") data = _load_r_data(r["seed"], r["dgp_type"]) - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method=r["est_method"], - control_group=r["control_group"], + control_group=_R_CONTROL_GROUP.get(r["control_group"], r["control_group"]), base_period="varying", ) res = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", ) _assert_close( res.overall_att, @@ -380,7 +390,15 @@ def test_overall_att_es_close_to_r(self, r_results, key): # (conditional parallel trends). # --------------------------------------------------------------------------- -_FIT_COLS = ("outcome", "unit", "period", "first_treat", "eligibility") +# Keyword form: the staggered-mode fit parameters are keyword-only on +# TripleDifference (a positional splat would bind to the 2x2x2 slots). +_FIT_KW = dict( + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", +) class TestTheorem41Identification: @@ -394,8 +412,8 @@ class TestTheorem41Identification: def test_ra_ipw_dr_agree_without_covariates(self): data = generate_staggered_ddd_data(n_units=600, treatment_effect=3.0, seed=11) fits = { - m: StaggeredTripleDifference(estimation_method=m, control_group="nevertreated").fit( - data, *_FIT_COLS + m: TripleDifference(estimation_method=m, control_group="never_treated").fit( + data, **_FIT_KW ) for m in ("dr", "ipw", "reg") } @@ -421,8 +439,8 @@ class TestEquation41Decomposition: def test_recovers_constant_effect(self): tau = 2.5 data = generate_staggered_ddd_data(n_units=800, treatment_effect=tau, noise_sd=0.3, seed=21) - res = StaggeredTripleDifference(estimation_method="dr", control_group="notyettreated").fit( - data, *_FIT_COLS, aggregate="event_study" + res = TripleDifference(estimation_method="dr", control_group="not_yet_treated").fit( + data, **_FIT_KW, aggregate="event_study" ) post = [ v["effect"] @@ -450,8 +468,8 @@ def test_weights_sum_to_one_multi_group(self): data = generate_staggered_ddd_data( n_units=600, cohort_periods=[3, 5, 7], treatment_effect=3.0, seed=31 ) - res = StaggeredTripleDifference(estimation_method="dr", control_group="notyettreated").fit( - data, *_FIT_COLS + res = TripleDifference(estimation_method="dr", control_group="not_yet_treated").fit( + data, **_FIT_KW ) assert res.gmm_weights is not None multi = {k: w for k, w in res.gmm_weights.items() if len(w) > 1} @@ -463,8 +481,8 @@ def test_weights_sum_to_one_multi_group(self): def test_single_group_reduces_to_unit_weight(self): data = generate_staggered_ddd_data(n_units=400, treatment_effect=3.0, seed=32) - res = StaggeredTripleDifference(estimation_method="dr", control_group="nevertreated").fit( - data, *_FIT_COLS + res = TripleDifference(estimation_method="dr", control_group="never_treated").fit( + data, **_FIT_KW ) assert res.gmm_weights is not None for k, w in res.gmm_weights.items(): @@ -486,8 +504,8 @@ def test_es_is_cohort_share_weighted_average(self): effect_growth=0.4, seed=41, ) - res = StaggeredTripleDifference(estimation_method="dr", control_group="nevertreated").fit( - data, *_FIT_COLS, aggregate="event_study" + res = TripleDifference(estimation_method="dr", control_group="never_treated").fit( + data, **_FIT_KW, aggregate="event_study" ) assert res.event_study_effects is not None gt = res.group_time_effects @@ -523,10 +541,10 @@ def _fit(self, aggregate, anticipation=0, seed=51): effect_growth=0.3, seed=seed, ) - est = StaggeredTripleDifference( - estimation_method="dr", control_group="nevertreated", anticipation=anticipation + est = TripleDifference( + estimation_method="dr", control_group="never_treated", anticipation=anticipation ) - return est.fit(data, *_FIT_COLS, aggregate=aggregate) + return est.fit(data, **_FIT_KW, aggregate=aggregate) def test_overall_es_is_unweighted_mean_of_post_es(self): res = self._fit("event_study") @@ -573,12 +591,12 @@ def test_bootstrap_overall_es_finite(self): data = generate_staggered_ddd_data( n_units=500, cohort_periods=[3, 5], treatment_effect=3.0, seed=61 ) - res = StaggeredTripleDifference( + res = TripleDifference( estimation_method="dr", - control_group="nevertreated", + control_group="never_treated", n_bootstrap=199, seed=7, - ).fit(data, *_FIT_COLS, aggregate="event_study") + ).fit(data, **_FIT_KW, aggregate="event_study") assert res.overall_att_es is not None assert np.isfinite(res.overall_att_es) assert res.overall_se_es is not None @@ -598,11 +616,11 @@ def test_balance_e_empties_event_study_fails_soft(self): n_units=400, cohort_periods=[3, 5], treatment_effect=3.0, seed=71 ) for nb in (0, 10): - est = StaggeredTripleDifference( - estimation_method="dr", control_group="nevertreated", n_bootstrap=nb, seed=3 + est = TripleDifference( + estimation_method="dr", control_group="never_treated", n_bootstrap=nb, seed=3 ) with pytest.warns(UserWarning): - res = est.fit(data, *_FIT_COLS, aggregate="event_study", balance_e=100) + res = est.fit(data, **_FIT_KW, aggregate="event_study", balance_e=100) # requested-but-undefined -> NaN (not a stale value, not a crash) assert res.overall_att_es is not None and np.isnan(res.overall_att_es) assert res.overall_se_es is None or np.isnan(res.overall_se_es) @@ -623,7 +641,7 @@ class TestAggregationReturnContract: """ def test_degenerate_if_branch_returns_consistent_arity(self): - est = StaggeredTripleDifference() + est = TripleDifference() est.anticipation = 0 # mixin reads self.anticipation empty = np.array([]) df0 = pd.DataFrame({"unit": []}) # empty design -> empty combined IF @@ -662,26 +680,26 @@ def test_constant_covariate_finite_se_matches_drop_one(self, method): with warnings.catch_warnings(): warnings.simplefilter("ignore") - drop_one = StaggeredTripleDifference( - estimation_method=method, control_group="nevertreated" + drop_one = TripleDifference( + estimation_method=method, control_group="never_treated" ).fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1"], ) - with_const = StaggeredTripleDifference( - estimation_method=method, control_group="nevertreated" + with_const = TripleDifference( + estimation_method=method, control_group="never_treated" ).fit( data_const, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "xc"], ) @@ -695,13 +713,13 @@ def test_constant_covariate_emits_rank_guard_warning(self): data["xc"] = 5.0 with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - StaggeredTripleDifference(estimation_method="reg", control_group="nevertreated").fit( + TripleDifference(estimation_method="reg", control_group="never_treated").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "xc"], ) rank_guard = [w for w in caught if "rank-guarded inverse" in str(w.message)] @@ -711,15 +729,13 @@ def test_well_conditioned_no_rank_guard_warning(self): data = self._data() with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - res = StaggeredTripleDifference( - estimation_method="dr", control_group="nevertreated" - ).fit( + res = TripleDifference(estimation_method="dr", control_group="never_treated").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x2"], ) assert not any("rank-guarded inverse" in str(w.message) for w in caught) @@ -741,27 +757,27 @@ def test_survey_weighted_constant_covariate_finite_se(self, method): sd = SurveyDesign(weights="weight") with warnings.catch_warnings(): warnings.simplefilter("ignore") - drop_one = StaggeredTripleDifference( - estimation_method=method, control_group="nevertreated" + drop_one = TripleDifference( + estimation_method=method, control_group="never_treated" ).fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1"], survey_design=sd, ) - with_const = StaggeredTripleDifference( - estimation_method=method, control_group="nevertreated" + with_const = TripleDifference( + estimation_method=method, control_group="never_treated" ).fit( data_const, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "xc"], survey_design=sd, ) @@ -779,17 +795,17 @@ def test_notyettreated_silent_constant_covariate(self, method): data["xc"] = 5.0 with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - res = StaggeredTripleDifference( + res = TripleDifference( estimation_method=method, - control_group="notyettreated", + control_group="not_yet_treated", rank_deficient_action="silent", ).fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "xc"], ) assert np.isfinite(res.overall_se) @@ -808,17 +824,17 @@ def test_error_mode_raises_before_rank_guard(self, method): data = self._data() data["x1c"] = 2.0 * data["x1"] # exactly collinear with x1 with pytest.raises(ValueError, match="(?i)rank-deficient"): - StaggeredTripleDifference( + TripleDifference( estimation_method=method, - control_group="nevertreated", + control_group="never_treated", rank_deficient_action="error", ).fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x1c"], ) @@ -839,26 +855,26 @@ def test_control_cell_aliasing_close_to_drop_one(self, method): data["x2d"] = pd.Series(x2d, index=data.index).groupby(data["unit"]).transform("first") with warnings.catch_warnings(): warnings.simplefilter("ignore") - drop_one = StaggeredTripleDifference( - estimation_method=method, control_group="nevertreated" + drop_one = TripleDifference( + estimation_method=method, control_group="never_treated" ).fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1"], ) - with_deg = StaggeredTripleDifference( - estimation_method=method, control_group="nevertreated" + with_deg = TripleDifference( + estimation_method=method, control_group="never_treated" ).fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x2d"], ) assert np.isfinite(with_deg.overall_se) and with_deg.overall_se > 0 @@ -866,7 +882,7 @@ def test_control_cell_aliasing_close_to_drop_one(self, method): class TestStaggeredTripleDiffCovariateScaleEquilibration: - """StaggeredTripleDifference covariate outcome-regression is scale-robust. + """The staggered-DDD covariate outcome-regression is scale-robust. The OR nuisance (``_compute_or``) is fit through the shared scale-equilibrated ``solve_ols`` (column-equilibrated SVD/gelsd), matching ``TripleDifference`` @@ -884,9 +900,9 @@ def _fit(self, offset): data["x1"] = data["x1"] + offset with warnings.catch_warnings(): warnings.simplefilter("ignore") - return StaggeredTripleDifference( - estimation_method="reg", control_group="nevertreated" - ).fit(data, *_FIT_COLS, covariates=["x1"]) + return TripleDifference(estimation_method="reg", control_group="never_treated").fit( + data, **_FIT_KW, covariates=["x1"] + ) def test_or_fit_offset_invariant(self): """A 1e6 constant offset on the covariate (absorbed by the intercept) diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index 5df6ab089..122255f28 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -870,14 +870,11 @@ def _live_family_code_refs(tok): # 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 remaining phase-3 sibling (QDiD). -# -# StaggeredTripleDifference was EVALUATED for this table in phase 3(b) and is -# deliberately absent: it is not a forwarding shim. It keeps its own __init__ -# (R's compact control_group spellings, frozen until the 4.0 removal) and only -# SHARES an engine mixin with TripleDifference, so identity grouping already -# sees it correctly. Adding it would wrongly inject it into TripleDifference's -# init-sharing group and widen that class's param-row consumer set. +# init-sharing group. MultiPeriodDiD is the last such shim (removed in PR 4c); +# the other two phase-3 siblings are gone: QDiD was a standalone class (never a +# forwarding shim) and StaggeredTripleDifference kept its own __init__ and only +# SHARED an engine mixin with TripleDifference - both removed at the 4.0 cut +# (PR 4a). _FORWARDING_INIT_SHIMS = { "MultiPeriodDiD": "DifferenceInDifferences", } diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index 538059376..2ea2ba375 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -11,7 +11,6 @@ HeterogeneousAdoptionDiDEventStudyResults, HeterogeneousAdoptionDiDResults, MultiPeriodDiD, - QDiD, generate_did_data, generate_staggered_data, ) @@ -1413,7 +1412,7 @@ def cic_fit_results(cic_2x2_data): @pytest.fixture(scope="module") def qdid_fit_results(cic_2x2_data): - est = QDiD(n_bootstrap=20, panel=True, seed=42) + est = ChangesInChanges(method="qdid", n_bootstrap=20, panel=True, seed=42) return est.fit(cic_2x2_data, outcome="y", treatment="treated", time="post", unit="unit") @@ -1428,7 +1427,7 @@ def cic_cov_fit_results(cic_2x2_data): @pytest.fixture(scope="module") def qdid_cov_fit_results(cic_2x2_data): - est = QDiD(n_bootstrap=0) + est = ChangesInChanges(method="qdid", n_bootstrap=0) return est.fit(cic_2x2_data, outcome="y", treatment="treated", time="post", covariates=["x1"]) diff --git a/tests/test_profile_panel.py b/tests/test_profile_panel.py index 6c76a9402..16802a0f2 100644 --- a/tests/test_profile_panel.py +++ b/tests/test_profile_panel.py @@ -534,7 +534,6 @@ def test_guide_api_strings_resolve_against_public_api(): "SyntheticDiD", "TROP", "TripleDifference", - "StaggeredTripleDifference", "ContinuousDiD", "HeterogeneousAdoptionDiD", ): @@ -719,7 +718,7 @@ def test_guide_api_strings_resolve_against_public_api(): "EfficientDiD", "SyntheticDiD", "HeterogeneousAdoptionDiD", - "StaggeredTripleDifference", + "TripleDifference staggered mode", ): idx = 0 found = False @@ -791,10 +790,9 @@ def test_guide_api_strings_resolve_against_public_api(): # Repeated-cross-section (§4.10) must not claim broad # applicability. The documented RCS-capable estimators are - # CallawaySantAnna(panel=False), DMLDiD(panel=False), - # TripleDifference, and - # StaggeredTripleDifference; EfficientDiD and - # HeterogeneousAdoptionDiD explicitly reject RCS per REGISTRY.md. + # CallawaySantAnna(panel=False), DMLDiD(panel=False) and the two-period + # TripleDifference; EfficientDiD, HeterogeneousAdoptionDiD and + # TripleDifference's staggered mode explicitly reject RCS per REGISTRY.md. assert "most estimators remain applicable" not in text, ( "§4.10 must not claim broad RCS applicability; only the " "explicitly documented RCS-capable subset is applicable." @@ -811,26 +809,25 @@ def test_guide_api_strings_resolve_against_public_api(): for panel_only in ( "EfficientDiD", "HeterogeneousAdoptionDiD", - "StaggeredTripleDifference", + "TripleDifference staggered mode", ): assert panel_only in rcs_section, ( f"§4.10 must explicitly name {panel_only!r} as panel-only " "so RCS data is not routed to it" ) - # The explicit RCS-capable bullet list must NOT put - # StaggeredTripleDifference next to the RCS-support language. - # The estimator has no panel=False mode and fit() rejects - # unbalanced input; only TripleDifference (non-staggered) is - # cross-sectional-DDD-capable. + # The explicit RCS-capable bullet list must NOT put the staggered + # TripleDifference mode next to the RCS-support language. That mode has + # no panel=False and fit() rejects unbalanced input; only the two-period + # TripleDifference design is cross-sectional-DDD-capable. explicit_support_block = text.find("Explicit RCS support", rcs_section_start) rejected_block = text.find("Explicitly rejected for RCS", rcs_section_start) assert 0 <= explicit_support_block < rejected_block, ( "§4.10 must separate an Explicit RCS support list from the " "Explicitly rejected list" ) explicit_segment = text[explicit_support_block:rejected_block] - assert "StaggeredTripleDifference" not in explicit_segment, ( - "StaggeredTripleDifference must NOT appear in the Explicit RCS " + assert "staggered mode" not in explicit_segment, ( + "TripleDifference staggered mode must NOT appear in the Explicit RCS " "support list — it is panel-only and balance-enforced." ) diff --git a/tests/test_staggered.py b/tests/test_staggered.py index 9a88f8171..2cc61734c 100644 --- a/tests/test_staggered.py +++ b/tests/test_staggered.py @@ -1757,7 +1757,7 @@ def test_control_cell_aliasing_close_to_drop_one(self, method): @pytest.mark.parametrize("method", ["reg", "ipw", "dr"]) def test_survey_weighted_constant_covariate_finite_se(self, method): # Exercises the *survey-weighted* CS bread / PS-Hessian branches - # (W includes survey weights), mirroring the TD/SDDD weighted tests. + # (W includes survey weights), mirroring the TripleDifference weighted tests. # Panel estimator -> weights constant within unit. from diff_diff.survey import SurveyDesign diff --git a/tests/test_staggered_aggregation.py b/tests/test_staggered_aggregation.py index 97377d366..25d7e997f 100644 --- a/tests/test_staggered_aggregation.py +++ b/tests/test_staggered_aggregation.py @@ -23,7 +23,7 @@ import pandas as pd import pytest -from diff_diff import CallawaySantAnna, StaggeredTripleDifference, SurveyDesign +from diff_diff import CallawaySantAnna, SurveyDesign, TripleDifference from diff_diff.staggered_aggregation import CallawaySantAnnaAggregationMixin @@ -408,18 +408,18 @@ def test_bootstrap_prep_path(self, monkeypatch): assert rec.n_calls > 0 def test_triple_diff_zeroed_cohorts(self, monkeypatch): - """StaggeredTripleDifference aggregates through a shallow copy of + """TripleDifference's staggered mode aggregates through a shallow copy of precomputed with an eligibility-zeroed unit_cohorts - the fourth consumer of the mixin.""" rec = _ParityRecorder(monkeypatch) - std = StaggeredTripleDifference() + std = TripleDifference() std.fit( _make_ddd(), - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="all", ) assert rec.n_calls > 0 @@ -470,7 +470,7 @@ def test_non_keeper_cohort_exact_zero(self): class TestAggCache: """The per-fit cohort-table cache is validated by array identity and - rebuilt into a fresh dict - the StaggeredTripleDifference shallow-copy + rebuilt into a fresh dict - the staggered-DDD shallow-copy pattern must never see stale tables.""" def _precomputed(self, cohorts, sw=None): @@ -491,7 +491,7 @@ def test_cache_hit_on_same_arrays(self): def test_shallow_copy_with_swapped_cohorts_rebuilds(self): p = self._precomputed(np.array([0, 3, 3, 5])) c1 = CallawaySantAnnaAggregationMixin._get_agg_cache(p) - # StaggeredTripleDifference pattern: dict() copy + replaced cohorts + # staggered-DDD pattern: dict() copy + replaced cohorts p2 = dict(p) p2["unit_cohorts"] = np.array([0, 0, 3, 5]) # eligibility-zeroed c2 = CallawaySantAnnaAggregationMixin._get_agg_cache(p2) @@ -637,18 +637,18 @@ def test_bootstrap_prep_fast_path_taken(self, monkeypatch): assert calls["fallback"] == 0 def test_triple_diff_fast_path_taken(self, monkeypatch): - """StaggeredTripleDifference's shallow-copied precomputed (zeroed + """The staggered-DDD engine's shallow-copied precomputed (zeroed cohorts, no canonical_size key) must still resolve the fast path via the .get defaults.""" calls = self._spy(monkeypatch) - std = StaggeredTripleDifference() + std = TripleDifference() std.fit( _make_ddd(), - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="all", ) assert calls["resolved"] > 0 diff --git a/tests/test_staggered_triple_diff.py b/tests/test_staggered_triple_diff.py index c95173d2a..0b4f59037 100644 --- a/tests/test_staggered_triple_diff.py +++ b/tests/test_staggered_triple_diff.py @@ -1,12 +1,13 @@ -"""Tests for StaggeredTripleDifference estimator.""" +"""Tests for the staggered-DDD engine behind TripleDifference's staggered mode +(``fit(..., first_treat=, partition=)``; formerly the ``StaggeredTripleDifference`` +class, removed at the 4.0 cut, PR 4a).""" import numpy as np import pytest from diff_diff import ( - SDDD, - StaggeredTripleDifference, StaggeredTripleDiffResults, + TripleDifference, generate_staggered_ddd_data, ) @@ -54,7 +55,7 @@ def dynamic_data(): class TestStaggeredTripleDiffInit: def test_default_params(self): - est = StaggeredTripleDifference() + est = TripleDifference() params = est.get_params() assert params["estimation_method"] == "dr" assert params["alpha"] == 0.05 @@ -63,34 +64,31 @@ def test_default_params(self): assert params["n_bootstrap"] == 0 assert params["pscore_trim"] == 0.01 - def test_alias(self): - assert SDDD is StaggeredTripleDifference - def test_set_params(self): - est = StaggeredTripleDifference() + est = TripleDifference() est.set_params(estimation_method="ipw", alpha=0.10) assert est.estimation_method == "ipw" assert est.alpha == 0.10 def test_set_params_updates_bootstrap_weights(self): - est = StaggeredTripleDifference() + est = TripleDifference() est.set_params(bootstrap_weights="mammen") assert est.bootstrap_weights == "mammen" def test_invalid_estimation_method(self): with pytest.raises(ValueError, match="estimation_method"): - StaggeredTripleDifference(estimation_method="ols") + TripleDifference(estimation_method="ols") def test_invalid_pscore_trim(self): with pytest.raises(ValueError, match="pscore_trim"): - StaggeredTripleDifference(pscore_trim=0.6) + TripleDifference(pscore_trim=0.6) def test_invalid_base_period(self): with pytest.raises(ValueError, match="base_period"): - StaggeredTripleDifference(base_period="fixed") + TripleDifference(base_period="fixed") def test_set_params_unknown(self): - est = StaggeredTripleDifference() + est = TripleDifference() with pytest.raises(ValueError, match="Unknown parameter"): est.set_params(nonexistent_param=42) @@ -102,14 +100,28 @@ def test_set_params_unknown(self): class TestStaggeredTripleDiffBasic: def test_fit_returns_results(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert isinstance(res, StaggeredTripleDiffResults) assert est.is_fitted_ def test_results_structure(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert isinstance(res.overall_att, float) assert isinstance(res.overall_se, float) assert res.overall_se > 0 @@ -124,8 +136,15 @@ def test_results_structure(self, simple_data): assert res.n_ineligible > 0 def test_group_time_effects_populated(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert len(res.group_time_effects) > 0 for (g, t), eff in res.group_time_effects.items(): assert "effect" in eff @@ -137,8 +156,15 @@ def test_group_time_effects_populated(self, simple_data): assert "n_control" in eff def test_summary_runs(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) summary = res.summary() assert "Staggered Triple Difference" in summary assert "ATT" in summary @@ -147,13 +173,13 @@ def test_overall_att_es_result_surface(self, simple_data): """Opt-in Eq. 4.14 overall (overall_att_es) is exposed on the result surface (attribute, summary(), to_dict()) under aggregate='event_study', and is None for the default (no event-study) fit.""" - res = StaggeredTripleDifference().fit( + res = TripleDifference().fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) es_fields = ( @@ -171,8 +197,13 @@ def test_overall_att_es_result_surface(self, simple_data): for f in es_fields: assert f in d # Default fit (no event-study aggregation): fields are None and absent from to_dict. - res_default = StaggeredTripleDifference().fit( - simple_data, "outcome", "unit", "period", "first_treat", "eligibility" + res_default = TripleDifference().fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", ) assert res_default.overall_att_es is None assert "overall_att_es" not in res_default.to_dict() @@ -185,16 +216,22 @@ def test_overall_att_es_aggregate_all_matches_event_study(self, simple_data): overall_att_es: aggregate='all' additionally computes the per-cohort group aggregation, and must neither drop nor perturb the event-study-average overall. """ - res_all = StaggeredTripleDifference().fit( - simple_data, "outcome", "unit", "period", "first_treat", "eligibility", aggregate="all" + res_all = TripleDifference().fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + aggregate="all", ) - res_es = StaggeredTripleDifference().fit( + res_es = TripleDifference().fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) # Populated (not None / finite) on the 'all' surface. @@ -212,22 +249,43 @@ def test_overall_att_es_aggregate_all_matches_event_study(self, simple_data): assert ci_all[1] == pytest.approx(ci_es[1], abs=1e-10) def test_to_dataframe_group_time(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) df = res.to_dataframe("group_time") assert "group" in df.columns assert "effect" in df.columns assert len(df) == len(res.group_time_effects) def test_repr(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) r = repr(res) assert "StaggeredTripleDiffResults" in r def test_significance_properties(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert isinstance(res.is_significant, bool) assert isinstance(res.significance_stars, str) @@ -240,26 +298,40 @@ def test_significance_properties(self, simple_data): class TestStaggeredTripleDiffRecovery: def test_att_recovery(self, simple_data): """ATT should be within 2 SE of true effect.""" - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert abs(res.overall_att - 3.0) < 2 * res.overall_se def test_null_effect(self, null_effect_data): """ATT should not be significant when true effect is 0.""" - est = StaggeredTripleDifference() - res = est.fit(null_effect_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + null_effect_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert abs(res.overall_att) < 2 * res.overall_se def test_att_with_covariates(self, data_with_covariates): """ATT recovery with covariates.""" - est = StaggeredTripleDifference(estimation_method="dr") + est = TripleDifference(estimation_method="dr") res = est.fit( data_with_covariates, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x2"], ) assert abs(res.overall_att - 3.0) < 2 * res.overall_se @@ -273,8 +345,15 @@ def test_att_with_covariates(self, data_with_covariates): class TestStaggeredTripleDiffMethods: @pytest.mark.parametrize("method", ["dr", "ipw", "reg"]) def test_method_produces_finite_results(self, simple_data, method): - est = StaggeredTripleDifference(estimation_method=method) - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference(estimation_method=method) + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert np.isfinite(res.overall_att) assert np.isfinite(res.overall_se) assert res.overall_se > 0 @@ -287,14 +366,14 @@ def test_method_produces_finite_results(self, simple_data, method): class TestStaggeredTripleDiffEventStudy: def test_event_study_aggregation(self, simple_data): - est = StaggeredTripleDifference() + est = TripleDifference() res = est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) assert res.event_study_effects is not None @@ -302,14 +381,14 @@ def test_event_study_aggregation(self, simple_data): def test_pretreatment_near_zero(self, simple_data): """Pre-treatment event study effects should be near zero.""" - est = StaggeredTripleDifference() + est = TripleDifference() res = est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) for e, eff in res.event_study_effects.items(): @@ -321,14 +400,14 @@ def test_pretreatment_near_zero(self, simple_data): def test_posttreatment_positive(self, simple_data): """Post-treatment event study effects should be positive.""" - est = StaggeredTripleDifference() + est = TripleDifference() res = est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) for e, eff in res.event_study_effects.items(): @@ -336,14 +415,14 @@ def test_posttreatment_positive(self, simple_data): assert eff["effect"] > 0 def test_event_study_dataframe(self, simple_data): - est = StaggeredTripleDifference() + est = TripleDifference() res = est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) df = res.to_dataframe("event_study") @@ -351,22 +430,28 @@ def test_event_study_dataframe(self, simple_data): assert "effect" in df.columns def test_aggregate_all(self, simple_data): - est = StaggeredTripleDifference() + est = TripleDifference() res = est.fit( - simple_data, "outcome", "unit", "period", "first_treat", "eligibility", aggregate="all" + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + aggregate="all", ) assert res.event_study_effects is not None assert res.group_effects is not None def test_aggregate_group(self, simple_data): - est = StaggeredTripleDifference() + est = TripleDifference() res = est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="group", ) assert res.group_effects is not None @@ -380,15 +465,29 @@ def test_aggregate_group(self, simple_data): class TestStaggeredTripleDiffGMM: def test_gmm_weights_sum_to_one(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) for (g, t), weights in res.gmm_weights.items(): w_sum = sum(weights.values()) assert abs(w_sum - 1.0) < 1e-10, f"GMM weights for (g={g}, t={t}) sum to {w_sum}" def test_comparison_group_counts(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) for (g, t), k in res.comparison_group_counts.items(): assert k >= 1 @@ -400,8 +499,15 @@ def test_single_comparison_group_weight_is_one(self): never_enabled_frac=0.3, seed=77, ) - est = StaggeredTripleDifference() - res = est.fit(data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) for (g, t), weights in res.gmm_weights.items(): if len(weights) == 1: w = list(weights.values())[0] @@ -416,21 +522,28 @@ def test_single_comparison_group_weight_is_one(self): class TestStaggeredTripleDiffBootstrap: def test_bootstrap_runs(self, simple_data, ci_params): n_boot = ci_params.bootstrap(199) - est = StaggeredTripleDifference(n_bootstrap=n_boot, seed=42) - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference(n_bootstrap=n_boot, seed=42) + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert res.bootstrap_results is not None assert res.bootstrap_results.n_bootstrap == n_boot def test_bootstrap_with_event_study(self, simple_data, ci_params): n_boot = ci_params.bootstrap(199) - est = StaggeredTripleDifference(n_bootstrap=n_boot, seed=42) + est = TripleDifference(n_bootstrap=n_boot, seed=42) res = est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) assert res.bootstrap_results is not None @@ -447,22 +560,43 @@ class TestStaggeredTripleDiffEdgeCases: def test_nonbinary_eligibility_raises(self, simple_data): bad_data = simple_data.copy() bad_data.loc[0, "eligibility"] = 2 - est = StaggeredTripleDifference() + est = TripleDifference() with pytest.raises(ValueError, match="binary"): - est.fit(bad_data, "outcome", "unit", "period", "first_treat", "eligibility") + est.fit( + bad_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) def test_varying_eligibility_raises(self): data = generate_staggered_ddd_data(n_units=50, seed=1) # Make eligibility vary within a unit data.loc[data["unit"] == 0, "eligibility"] = [0, 1, 0, 1, 0, 1, 0, 1] - est = StaggeredTripleDifference() + est = TripleDifference() with pytest.raises(ValueError, match="time-invariant"): - est.fit(data, "outcome", "unit", "period", "first_treat", "eligibility") + est.fit( + data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) def test_missing_column_raises(self, simple_data): - est = StaggeredTripleDifference() + est = TripleDifference() with pytest.raises(ValueError, match="Missing columns"): - est.fit(simple_data, "outcome", "unit", "period", "nonexistent", "eligibility") + est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="nonexistent", + partition="eligibility", + ) def test_inf_first_treat_works(self): """Never-enabled units encoded as inf should be recoded to 0, and the @@ -473,50 +607,71 @@ def test_inf_first_treat_works(self): inf_mask = data["first_treat"] == 0 n_inf_rows = int(inf_mask.sum()) data.loc[inf_mask, "first_treat"] = np.inf - est = StaggeredTripleDifference() + est = TripleDifference() with pytest.warns( UserWarning, match=rf"{n_inf_rows} row\(s\) have first_treat=inf; recoding to 0", ): - res = est.fit(data, "outcome", "unit", "period", "first_treat", "eligibility") + res = est.fit( + data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert np.isfinite(res.overall_att) def test_survey_design_invalid_type_raises(self, simple_data): - est = StaggeredTripleDifference() + est = TripleDifference() with pytest.raises(TypeError, match="SurveyDesign"): est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design="something", ) def test_invalid_aggregate_raises(self, simple_data): - est = StaggeredTripleDifference() + est = TripleDifference() with pytest.raises(ValueError, match="aggregate"): est.fit( simple_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="invalid", ) def test_base_period_universal(self, simple_data): - est = StaggeredTripleDifference(base_period="universal") - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference(base_period="universal") + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) assert np.isfinite(res.overall_att) assert abs(res.overall_att - 3.0) < 2 * res.overall_se def test_to_dict(self, simple_data): - est = StaggeredTripleDifference() - res = est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est = TripleDifference() + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) d = res.to_dict() assert "overall_att" in d assert "n_obs" in d @@ -539,12 +694,19 @@ def test_base_period_outside_panel_warns(self): cohort_periods=[2, 4], seed=77, ) - est = StaggeredTripleDifference(anticipation=1) + est = TripleDifference(anticipation=1) import warnings as _w with _w.catch_warnings(record=True) as caught: _w.simplefilter("always") - res = est.fit(data, "outcome", "unit", "period", "first_treat", "eligibility") + res = est.fit( + data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) base_period_warnings = [w for w in caught if "outside the observed panel" in str(w.message)] assert len(base_period_warnings) > 0, "Expected warning about base period" assert np.isfinite(res.overall_att) @@ -559,12 +721,19 @@ def test_empty_subgroup_warns(self): # Remove all ineligible units from cohort 6 to make (S=6,Q=0) empty mask = ~((data["first_treat"] == 6) & (data["eligibility"] == 0)) data = data[mask].reset_index(drop=True) - est = StaggeredTripleDifference() + est = TripleDifference() import warnings as _w with _w.catch_warnings(record=True) as caught: _w.simplefilter("always") - res = est.fit(data, "outcome", "unit", "period", "first_treat", "eligibility") + res = est.fit( + data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) subgroup_warnings = [w for w in caught if "Empty subgroup" in str(w.message)] assert len(subgroup_warnings) > 0, "Expected warning about empty subgroup" assert np.isfinite(res.overall_att) @@ -579,7 +748,7 @@ def test_collinear_covariates_cached_ps_finite(self): ) # Add a perfectly collinear covariate (x3 = 2*x1) data["x3"] = 2.0 * data["x1"] - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method="dr", rank_deficient_action="warn", ) @@ -589,11 +758,11 @@ def test_collinear_covariates_cached_ps_finite(self): _w.simplefilter("always") res = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x2", "x3"], ) # All group-time effects should be finite despite collinearity @@ -623,18 +792,18 @@ def test_collinear_covariates_emit_lstsq_fallback_warning(self): # x3 ≡ 2·x1 makes covX = [intercept, x1, x2, x3] rank-deficient # per pair, so XpX in _compute_did_panel() is near-singular. data["x3"] = 2.0 * data["x1"] - est = StaggeredTripleDifference(estimation_method="dr") # default "warn" + est = TripleDifference(estimation_method="dr") # default "warn" import warnings as _w with _w.catch_warnings(record=True) as caught: _w.simplefilter("always") res = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x2", "x3"], ) or_warnings = [ @@ -661,7 +830,7 @@ def test_collinear_covariates_emit_ps_hessian_warning(self): seed=55, ) data["x3"] = 2.0 * data["x1"] - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method="ipw", # exercises PS path, skips OR projection ) import warnings as _w @@ -670,11 +839,11 @@ def test_collinear_covariates_emit_ps_hessian_warning(self): _w.simplefilter("always") est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x2", "x3"], ) ps_warnings = [w for w in caught if "propensity-score Hessian" in str(w.message)] @@ -698,18 +867,18 @@ def test_well_conditioned_covariates_emit_no_lstsq_warning(self): add_covariates=True, seed=42, ) - est = StaggeredTripleDifference(estimation_method="dr") + est = TripleDifference(estimation_method="dr") import warnings as _w with _w.catch_warnings(record=True) as caught: _w.simplefilter("always") est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1", "x2"], ) lstsq_warnings = [w for w in caught if "Rank-deficient X'WX" in str(w.message)] @@ -721,12 +890,19 @@ def test_well_conditioned_covariates_emit_no_lstsq_warning(self): def test_no_covariates_no_warning(self, simple_data): """Without covariates both OR and PS paths are skipped, so no aggregate warning should be emitted.""" - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") import warnings as _w with _w.catch_warnings(record=True) as caught: _w.simplefilter("always") - est.fit(simple_data, "outcome", "unit", "period", "first_treat", "eligibility") + est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) lstsq_warnings = [w for w in caught if "Rank-deficient X'WX" in str(w.message)] assert lstsq_warnings == [] @@ -734,8 +910,13 @@ def test_no_covariates_no_warning(self, simple_data): @pytest.fixture(scope="module") def alpha_fitted(): data = generate_staggered_ddd_data(n_units=300, treatment_effect=3.0, seed=42) - return StaggeredTripleDifference().fit( - data, "outcome", "unit", "period", "first_treat", "eligibility" + return TripleDifference().fit( + data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", ) diff --git a/tests/test_survey_metadata_raw_scale.py b/tests/test_survey_metadata_raw_scale.py index 751e3d064..63e15486f 100644 --- a/tests/test_survey_metadata_raw_scale.py +++ b/tests/test_survey_metadata_raw_scale.py @@ -25,7 +25,7 @@ CallawaySantAnna, ContinuousDiD, EfficientDiD, - StaggeredTripleDifference, + TripleDifference, ) from diff_diff.survey import SurveyDesign @@ -397,13 +397,13 @@ def sddd_df(self): def _fit(self, df, design): return _fit_quiet( - StaggeredTripleDifference(), + TripleDifference(), df, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=design, ) @@ -419,20 +419,20 @@ def test_weights_none_ones_fallback(self, sddd_df): assert md.weight_range == (1.0, 1.0) assert md.effective_n == n_units - def test_bare_cluster_never_reaches_metadata_recompute(self, sddd_df): - # Same unreachability pin as the CS variant, on the DDD engine. + def test_bare_cluster_is_rejected_in_staggered_mode(self, sddd_df): + # The CS variant pins that a bare cluster= never reaches the metadata + # recompute; on the DDD engine the merged surface rejects cluster= in + # staggered mode outright, so the recompute is unreachable by construction. df = sddd_df.assign(cluster_col=sddd_df["unit"] // 10) - res = _fit_quiet( - StaggeredTripleDifference(cluster="cluster_col"), - df, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", - ) - assert np.isfinite(res.overall_att) - assert res.survey_metadata is None + with pytest.raises(ValueError, match="cluster= is not supported in staggered DDD mode"): + TripleDifference(cluster="cluster_col").fit( + df, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) # --------------------------------------------------------------------------- diff --git a/tests/test_survey_staggered_ddd.py b/tests/test_survey_staggered_ddd.py index dfccde404..48c38a2db 100644 --- a/tests/test_survey_staggered_ddd.py +++ b/tests/test_survey_staggered_ddd.py @@ -1,4 +1,5 @@ -"""Survey support tests for StaggeredTripleDifference estimator. +"""Survey support tests for TripleDifference's staggered mode (the staggered-DDD +engine; formerly the ``StaggeredTripleDifference`` class, removed at the 4.0 cut, PR 4a). Tests follow the patterns from test_survey_phase4.py and test_survey_phase7a.py: uniform weight equivalence, scale invariance, nontrivial weight effects, @@ -11,7 +12,7 @@ import pandas as pd import pytest -from diff_diff import StaggeredTripleDifference +from diff_diff import TripleDifference from diff_diff.survey import SurveyDesign # --------------------------------------------------------------------------- @@ -94,14 +95,14 @@ class TestSmokeSDDDSurvey: @pytest.mark.parametrize("method", ["reg", "ipw", "dr"]) def test_finite_results(self, sddd_data, method): sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference(estimation_method=method) + est = TripleDifference(estimation_method=method) res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) assert np.isfinite(res.overall_att) @@ -111,14 +112,14 @@ def test_finite_results(self, sddd_data, method): @pytest.mark.parametrize("agg", ["simple", "event_study", "group", "all"]) def test_aggregation(self, sddd_data, agg): sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate=agg, survey_design=sd, ) @@ -142,27 +143,27 @@ def test_uniform_weights_match_unweighted(self, sddd_data, method): data = sddd_data.copy() data["uniform_w"] = 1.0 - est = StaggeredTripleDifference(estimation_method=method) + est = TripleDifference(estimation_method=method) # Unweighted res_uw = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", ) # Uniform weights sd = SurveyDesign(weights="uniform_w") res_w = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) @@ -192,27 +193,27 @@ def test_scale_invariance(self, sddd_data): data = sddd_data.copy() data["weight_2x"] = data["weight"] * 2.0 - est = StaggeredTripleDifference(estimation_method="dr") + est = TripleDifference(estimation_method="dr") sd1 = SurveyDesign(weights="weight") res1 = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd1, ) sd2 = SurveyDesign(weights="weight_2x") res2 = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd2, ) @@ -237,25 +238,25 @@ class TestNontrivialWeightsChangeSE: """Variable survey weights should produce different SEs than unweighted.""" def test_weighted_se_differs(self, sddd_data): - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") res_uw = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", ) sd = SurveyDesign(weights="weight") res_w = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) @@ -279,14 +280,14 @@ def test_strata_psu(self, sddd_data): strata="stratum", psu="psu", ) - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", survey_design=sd, ) @@ -298,14 +299,14 @@ def test_strata_psu(self, sddd_data): def test_metadata_populated(self, sddd_data): sd = SurveyDesign(weights="weight", strata="stratum", psu="psu") - est = StaggeredTripleDifference(estimation_method="dr") + est = TripleDifference(estimation_method="dr") res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) sm = res.survey_metadata @@ -324,17 +325,17 @@ class TestDesignBasedAggSE: """Aggregated SEs with strata/PSU differ from pweight-only SEs.""" def test_design_se_differs_from_pweight_only(self, sddd_data): - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") # pweight only sd_pw = SurveyDesign(weights="weight") res_pw = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", survey_design=sd_pw, ) @@ -347,11 +348,11 @@ def test_design_se_differs_from_pweight_only(self, sddd_data): ) res_full = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", survey_design=sd_full, ) @@ -398,14 +399,14 @@ def test_brr_replicate(self, sddd_data): replicate_weights=rep_cols, replicate_method="BRR", ) - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") res = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", survey_design=sd, ) @@ -440,18 +441,18 @@ def test_brr_with_bootstrap_rejected(self, sddd_data): replicate_weights=rep_cols, replicate_method="BRR", ) - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method="reg", n_bootstrap=49, ) with pytest.raises(NotImplementedError, match="replicate"): est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) @@ -489,14 +490,14 @@ def test_scale_invariance(self, sddd_data, agg): replicate_weights=rep_cols, replicate_method="BRR", ) - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") res1 = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate=agg, survey_design=sd1, ) @@ -514,11 +515,11 @@ def test_scale_invariance(self, sddd_data, agg): ) res2 = est.fit( data_k, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate=agg, survey_design=sd2, ) @@ -593,16 +594,16 @@ def test_unequal_cohort_weights_change_aggregate(self): w_map[uid] = 3.0 + rng.uniform(0, 0.5) data["skewed_w"] = data["unit"].map(w_map) - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") # Unweighted res_uw = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", ) @@ -610,11 +611,11 @@ def test_unequal_cohort_weights_change_aggregate(self): sd = SurveyDesign(weights="skewed_w") res_w = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", survey_design=sd, ) @@ -642,15 +643,15 @@ def test_fweight_rejected(self, sddd_data): data = sddd_data.copy() data["fweight"] = 1 sd = SurveyDesign(weights="fweight", weight_type="fweight") - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") with pytest.raises(ValueError, match="pweight"): est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) @@ -658,15 +659,15 @@ def test_aweight_rejected(self, sddd_data): data = sddd_data.copy() data["aweight"] = 1.0 sd = SurveyDesign(weights="aweight", weight_type="aweight") - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") with pytest.raises(ValueError, match="pweight"): est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) @@ -681,18 +682,18 @@ class TestBootstrapSurvey: def test_bootstrap_with_survey(self, sddd_data): sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method="reg", n_bootstrap=49, seed=42, ) res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", survey_design=sd, ) @@ -709,20 +710,20 @@ def test_bootstrap_with_survey(self, sddd_data): class TestControlGroupSurvey: """Both control_group settings work with survey.""" - @pytest.mark.parametrize("cg", ["nevertreated", "notyettreated"]) + @pytest.mark.parametrize("cg", ["never_treated", "not_yet_treated"]) def test_control_group(self, sddd_data, cg): sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method="reg", control_group=cg, ) res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) assert np.isfinite(res.overall_att) @@ -740,17 +741,17 @@ class TestBasePeriodSurvey: @pytest.mark.parametrize("bp", ["varying", "universal"]) def test_base_period(self, sddd_data, bp): sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference( + est = TripleDifference( estimation_method="reg", base_period=bp, ) res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) assert np.isfinite(res.overall_att) @@ -768,14 +769,14 @@ class TestCovariateAdjustedSurvey: @pytest.mark.parametrize("method", ["reg", "ipw", "dr"]) def test_covariate_survey(self, sddd_data, method): sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference(estimation_method=method) + est = TripleDifference(estimation_method=method) res = est.fit( sddd_data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["x1"], survey_design=sd, ) @@ -811,14 +812,14 @@ def test_non_combined_replicate(self, sddd_data): replicate_method="BRR", combined_weights=False, ) - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") res = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="simple", survey_design=sd, ) @@ -845,18 +846,18 @@ def test_fallback_uses_weighted_mean(self, sddd_data): # logit failure in at least some subgroups data["collinear_x"] = data["eligibility"].astype(float) * 100.0 sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference(estimation_method="ipw", pscore_fallback="unconditional") + est = TripleDifference(estimation_method="ipw", pscore_fallback="unconditional") import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") res = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", covariates=["collinear_x"], survey_design=sd, ) @@ -881,18 +882,18 @@ def test_zero_mass_subgroup_warns(self): data.loc[mask, "weight"] = 0.0 sd = SurveyDesign(weights="weight") - est = StaggeredTripleDifference(estimation_method="reg") + est = TripleDifference(estimation_method="reg") import warnings with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") res = est.fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", survey_design=sd, ) # Should have produced at least one empty/zero-mass subgroup warning @@ -918,23 +919,23 @@ def test_uniform_weight_equivalence(self): data = _make_staggered_ddd_data(n_units=200, seed=42) data["weight"] = 1.0 sd = SurveyDesign(weights="weight") - res_w = StaggeredTripleDifference(estimation_method="reg").fit( + res_w = TripleDifference(estimation_method="reg").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", survey_design=sd, ) - res_u = StaggeredTripleDifference(estimation_method="reg").fit( + res_u = TripleDifference(estimation_method="reg").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) assert res_w.overall_att_es is not None and res_u.overall_att_es is not None @@ -945,23 +946,23 @@ def test_uniform_weight_equivalence(self): def test_nontrivial_weights_change_se(self): data = _make_staggered_ddd_data(n_units=200, seed=42) # variable weights sd = SurveyDesign(weights="weight") - res_w = StaggeredTripleDifference(estimation_method="reg").fit( + res_w = TripleDifference(estimation_method="reg").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", survey_design=sd, ) - res_u = StaggeredTripleDifference(estimation_method="reg").fit( + res_u = TripleDifference(estimation_method="reg").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", ) assert res_w.overall_se_es is not None and res_u.overall_se_es is not None @@ -971,13 +972,13 @@ def test_nontrivial_weights_change_se(self): def test_full_design_overall_es_finite(self): data = _make_staggered_ddd_data(n_units=200, seed=42) sd = SurveyDesign(weights="weight", strata="stratum", psu="psu") - res = StaggeredTripleDifference(estimation_method="reg").fit( + res = TripleDifference(estimation_method="reg").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", survey_design=sd, ) @@ -1005,13 +1006,13 @@ def test_replicate_weight_overall_es_finite(self): replicate_weights=[f"rep_{r}" for r in range(n_rep)], replicate_method="BRR", ) - res = StaggeredTripleDifference(estimation_method="reg").fit( + res = TripleDifference(estimation_method="reg").fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", survey_design=sd, ) @@ -1038,13 +1039,13 @@ def test_single_psu_bootstrap_cluster_unidentified(self): sd = SurveyDesign(weights="weight", psu="psu", fpc="fpc_col") with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - res = StaggeredTripleDifference(estimation_method="reg", n_bootstrap=50, seed=7).fit( + res = TripleDifference(estimation_method="reg", n_bootstrap=50, seed=7).fit( data, - "outcome", - "unit", - "period", - "first_treat", - "eligibility", + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", aggregate="event_study", survey_design=sd, ) diff --git a/tests/test_v4_inference_policy.py b/tests/test_v4_inference_policy.py index e587676a1..8fd89c881 100644 --- a/tests/test_v4_inference_policy.py +++ b/tests/test_v4_inference_policy.py @@ -46,8 +46,6 @@ HeterogeneousAdoptionDiD, ImputationDiD, MultiPeriodDiD, - QDiD, - StaggeredTripleDifference, SunAbraham, SyntheticDiD, TripleDifference, @@ -88,7 +86,8 @@ def _floor_msg(n: int) -> str: "Using analytical inference instead." ) -# The M-081 sweep roster (9 previously-unvalidated classes) plus CiC/QDiD, +# The M-081 sweep roster (9 previously-unvalidated classes) plus ChangesInChanges +# (both methods), # whose local validator was the promotion source and now routes through the # shared helper. VALIDATED_CLASSES = [ @@ -103,10 +102,8 @@ def _floor_msg(n: int) -> str: TwoStageDiD, WooldridgeDiD, ContinuousDiD, - StaggeredTripleDifference, TripleDifference, ChangesInChanges, - QDiD, # Bootstrap-only whole-individual resampling: 0 is legal (point # estimates, NaN inference), 1 is rejected by a local floor, >= 2 runs. DurationDiD, diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index b9127e7d7..c2b443403 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -1618,12 +1618,14 @@ def test_migration_guide_examples_bind_to_real_signatures(): len(blocks) >= 4 ), f"expected at least 4 ```python blocks in the guide, found {len(blocks)}" assert len(pairs) >= 20, f"expected at least 20 resolvable keywords, found {len(pairs)}" + # Owners whose worked example must still resolve. The 3.x-side owners of the + # merge examples drop out as their classes are removed (StaggeredTripleDifference + # and QDiD at PR 4a; MultiPeriodDiD follows in PR 4c): the collector skips names + # diff_diff no longer exports, so only the surviving side is asserted. for owner in ( "MultiPeriodDiD", "TwoWayFixedEffects", - "StaggeredTripleDifference", "TripleDifference", - "QDiD", "ChangesInChanges", ): assert any( diff --git a/tests/test_v4_merge_cic.py b/tests/test_v4_merge_cic.py index 692b542d0..edbea35d6 100644 --- a/tests/test_v4_merge_cic.py +++ b/tests/test_v4_merge_cic.py @@ -1,11 +1,12 @@ -"""Phase 3(c): ChangesInChanges absorbs QDiD - rows M-015 / M-143. +"""Merge gates: ChangesInChanges absorbed QDiD (phase 3(c), rows M-015 / M-143); +the QDiD class and the ``QDiDResults`` alias (M-061) were removed at the 4.0 cut +(PR 4a). ``ChangesInChanges(method="qdid")`` is the one surviving surface. -TOLERANCE DOCTRINE. Every merge-parity gate is BIT-EXACT -(``assert_array_equal``): both surfaces call the same untouched -``_fit_distributional`` in the same process, so a needed tolerance IS the -finding. The single exception is Gate B's committed oracle, captured in a -different process (and possibly a different backend), asserted with -``assert_allclose(rtol=1e-9, atol=1e-12)``. +TOLERANCE DOCTRINE. Through 3.x the merge-parity gates compared the two +surfaces BIT-EXACTLY (``assert_array_equal``) because both called the same +``_fit_distributional`` in one process. With the class gone, the absolute pin +is Gate B's committed oracle, captured in a different process (and possibly a +different backend), asserted with ``assert_allclose(rtol=1e-9, atol=1e-12)``. ORACLE PROVENANCE (Gate B). Literals captured by ``tests/_capture_v4_merge_cic_oracles.py`` on the UNMODIFIED pre-merge tree: @@ -13,17 +14,20 @@ commit c2941caa2a7865c9458c6092359e75238fdbabb1 (origin/main, pre-3(c)) command DIFF_DIFF_BACKEND=python python3 tests/_capture_v4_merge_cic_oracles.py +That script drives the removed class and is HISTORICAL (runnable only at that +commit); it is kept as the oracle's provenance record. + The oracle covers the UNCONDITIONAL arm only, and omits ``quantile_effects``. Both are deliberate: the covariate quantile-regression path is tie-selection-bounded with BLAS-dependent tie flips (see ``tests/test_changes_in_changes_parity.py``: COV_ATT_ATOL = 0.04, COV_QTE_ATOL = 0.25) and CI runs ubuntu/macos/windows/arm, so tight committed covariate literals would be platform-fragile; and ``benchmarks/data/qte_golden.json`` -is git-tracked, so ``test_point_parity`` never skips and already pins -``quantile_effects["qte"]`` against the R ``qte`` 1.3.1 golden at -``atol=1e-10, rtol=0`` for cic/qdid x panel/rcs - a stronger absolute pin than -anything this file would add. Covariate coverage here comes from Gate A's -in-process bit-exact parity, which cannot be platform-dependent. +is git-tracked, so ``tests/test_changes_in_changes_parity.py::test_point_parity`` +never skips and already pins ``quantile_effects["qte"]`` against the R ``qte`` +1.3.1 golden at ``atol=1e-10, rtol=0`` for cic/qdid x panel/rcs - a stronger +absolute pin than anything this file would add. Covariate coverage lives in +that parity suite's covariate lanes (platform-tolerant by design). """ import pickle @@ -31,34 +35,47 @@ import warnings import numpy as np +import pandas as pd import pytest from numpy.testing import assert_allclose, assert_array_equal from diff_diff import ( ChangesInChanges, ChangesInChangesResults, - QDiD, - QDiDResults, practitioner_next_steps, ) -from diff_diff.changes_in_changes import _validate_all_params - -from ._capture_v4_merge_cic_oracles import make_2x2 # -------------------------------------------------------------------------- # Pinned messages # -------------------------------------------------------------------------- -QDID_DEPRECATION_RE = re.escape("QDiD is deprecated and will be removed in 4.0") METHOD_VALUE_RE = re.escape("method must be 'cic' or 'qdid', got ") FIT_KW = dict(outcome="y", treatment="treated", time="post") -def _qdid(**kw): - """Construct the dying class without its (expected) FutureWarning.""" - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", message=QDID_DEPRECATION_RE, category=FutureWarning) - return QDiD(**kw) +def make_2x2(n_treated=60, n_control=80, seed=0, effect=1.0): + """Full-overlap continuous 2x2 panel (long format, one row per unit-period). + + The oracle DGP: a copy of the helper the HISTORICAL capture script + ``tests/_capture_v4_merge_cic_oracles.py`` carries (that script imports the + removed QDiD class and no longer runs on this tree). The ``id`` column + repeats across periods, so the same frame serves both ``panel=False`` + (pooled row resample) and ``panel=True`` (unit-block resample, ``unit="id"``). + """ + rng = np.random.default_rng(seed) + n = n_treated + n_control + treat = np.repeat([1, 0], [n_treated, n_control]) + u = rng.normal(0, 1, n) + y_pre = u + rng.normal(0, 0.3, n) + y_post = u + 0.5 + rng.normal(0, 0.3, n) + treat * effect + return pd.DataFrame( + { + "id": np.tile(np.arange(n), 2), + "post": np.repeat([0, 1], n), + "treated": np.tile(treat, 2), + "y": np.concatenate([y_pre, y_post]), + } + ) def _fit_quiet(est, df, **kw): @@ -85,47 +102,8 @@ def cov_df(): # ========================================================================== # Gate A - merged mode is bit-exact against the dying class # ========================================================================== -class TestGateAParity: - _FIELDS = ("att", "se", "t_stat", "p_value", "q_lower", "q_upper", "sup_t_crit") - - def _assert_same(self, merged, dying): - for f in self._FIELDS: - assert_array_equal( - np.asarray(getattr(merged, f), dtype=float), - np.asarray(getattr(dying, f), dtype=float), - err_msg=f"field {f!r} diverged between the merged and dying surfaces", - ) - assert_array_equal(np.asarray(merged.conf_int, float), np.asarray(dying.conf_int, float)) - assert_array_equal( - merged.quantile_effects.to_numpy(dtype=float), - dying.quantile_effects.to_numpy(dtype=float), - ) - assert list(merged.quantile_effects.columns) == list(dying.quantile_effects.columns) - assert merged.cell_sizes == dying.cell_sizes - assert merged.n_obs == dying.n_obs - assert merged.n_bootstrap_valid == dying.n_bootstrap_valid - assert merged.method == dying.method == "qdid" - assert type(merged).__name__ == type(dying).__name__ - - @pytest.mark.parametrize("panel", [False, True], ids=["rcs", "panel"]) - def test_point_parity(self, df, panel): - kw = {"unit": "id"} if panel else {} - merged = _fit_quiet(ChangesInChanges(n_bootstrap=0, panel=panel, method="qdid"), df, **kw) - dying = _fit_quiet(_qdid(n_bootstrap=0, panel=panel), df, **kw) - self._assert_same(merged, dying) - - def test_seeded_bootstrap_parity(self, df): - merged = _fit_quiet(ChangesInChanges(n_bootstrap=49, seed=7, method="qdid"), df) - dying = _fit_quiet(_qdid(n_bootstrap=49, seed=7), df) - self._assert_same(merged, dying) - - def test_covariate_parity(self, cov_df): - """The conditional QR path - in-process, so bit-exact is legitimate here.""" - merged = _fit_quiet( - ChangesInChanges(n_bootstrap=0, method="qdid"), cov_df, covariates=["x1"] - ) - dying = _fit_quiet(_qdid(n_bootstrap=0), cov_df, covariates=["x1"]) - self._assert_same(merged, dying) +class TestGateAMergedSurface: + """Merged-surface pins that the 3.x old-vs-new parity lanes carried.""" def test_cic_default_unchanged(self, df): """method='cic' is the default, and must match a bare ChangesInChanges.""" @@ -135,29 +113,17 @@ def test_cic_default_unchanged(self, df): assert_array_equal(np.asarray(a.se), np.asarray(b.se)) assert a.method == b.method == "cic" - def test_fit_time_userwarning_sets_match(self, df): - """Scoped to fit-time UserWarnings ONLY. - - The whole warning sets differ BY CONSTRUCTION - Gate D requires QDiD() - to emit a FutureWarning the merged surface must not - so comparing them - wholesale would contradict this file's own deprecation gate. + def test_qdid_fit_emits_the_footnote_21_warning(self, df): + """The Athey-Imbens footnote-21 non-monotonicity UserWarning fires on this + DGP through the merged surface (through 3.x this was asserted as a + warning-set parity against the removed class; it is now a direct pin). """ - - def _user_warnings(make): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - make() - return sorted(str(x.message) for x in w if issubclass(x.category, UserWarning)) - - merged = _user_warnings( - lambda: ChangesInChanges(n_bootstrap=0, method="qdid").fit(df, **FIT_KW) - ) - dying = _user_warnings(lambda: _qdid(n_bootstrap=0).fit(df, **FIT_KW)) - assert merged == dying - assert any("non-monotone" in m for m in merged), ( - "the DGP is expected to trip QDiD's footnote-21 non-monotonicity warning; " - "without it this parity assertion would be vacuous" - ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + ChangesInChanges(n_bootstrap=0, method="qdid").fit(df, **FIT_KW) + msgs = [str(x.message) for x in w if issubclass(x.category, UserWarning)] + assert any("non-monotone" in m for m in msgs), msgs + assert not [x for x in w if issubclass(x.category, FutureWarning)] # ========================================================================== @@ -295,8 +261,8 @@ class TestGateCValidation: @pytest.mark.parametrize("bad", ["bogus", None, "CiC", "QDiD", "", 1, 0.5]) def test_rejects_at_construction(self, bad): """Construction-time, not just fit-time: the regression test for - ``method`` being in __init__'s hand-built validation dict. With only - _validate_all_params' ``.get`` default this would construct silently.""" + ``method`` being in __init__'s hand-built validation dict (__init__ does + not forward get_params(), so a missing key would construct silently).""" with pytest.raises(ValueError, match=METHOD_VALUE_RE): ChangesInChanges(method=bad) @@ -345,11 +311,9 @@ def test_set_params_failure_is_transactional(self): est.set_params(method="bogus") assert est.method == "cic" - def test_get_params_key_sets(self): + def test_get_params_key_set(self): merged = set(ChangesInChanges().get_params()) assert merged == {"quantiles", "n_bootstrap", "alpha", "panel", "seed", "method"} - # The dying class's five-param contract is FROZEN through removal. - assert set(_qdid().get_params()) == merged - {"method"} @pytest.mark.parametrize("method", ["cic", "qdid"]) def test_reinstantiation_round_trip(self, method): @@ -359,55 +323,38 @@ def test_reinstantiation_round_trip(self, method): clone = ChangesInChanges(**est.get_params()) assert clone.get_params() == est.get_params() - def test_dying_class_round_trip_needs_targeted_ignore(self): - """QDiD construction MUST warn, so the error filter needs the per-class - ignore (the tests/test_base_estimator.py:188-193 idiom).""" - est = _qdid(n_bootstrap=3) - with warnings.catch_warnings(): - warnings.simplefilter("error") - warnings.filterwarnings("ignore", message=QDID_DEPRECATION_RE, category=FutureWarning) - clone = QDiD(**est.get_params()) - assert clone.get_params() == est.get_params() - # ========================================================================== -# Gate D - deprecation choreography +# Gate D - removal pins (M-015 / M-061; 4.0 cut, PR 4a) # ========================================================================== -class TestGateDDeprecation: - def test_construction_warns_once(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - QDiD(n_bootstrap=0) - fw = [x for x in w if issubclass(x.category, FutureWarning)] - assert len(fw) == 1 - assert re.search(QDID_DEPRECATION_RE, str(fw[0].message)) - assert "ChangesInChanges(method='qdid')" in str(fw[0].message) +class TestGateDRemoval: + """The QDiD class and the QDiDResults alias are gone; the merged surface + constructs silently with either method.""" - def test_warns_but_still_works(self, df): - res = _fit_quiet(_qdid(n_bootstrap=0), df) - assert np.isfinite(res.att) + @pytest.mark.parametrize("name", ["QDiD", "QDiDResults"]) + def test_name_is_absent(self, name): + import diff_diff - def test_stacklevel_attributes_to_caller(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - QDiD(n_bootstrap=0) - assert w[0].filename == __file__, "stacklevel=2 must blame the user frame" + assert not hasattr(diff_diff, name) + assert name not in diff_diff.__all__ + assert name not in dir(diff_diff) + with pytest.raises(ImportError): + exec(f"from diff_diff import {name}") - def test_merged_surface_is_silent(self): - """The METHOD is not deprecated - only the class spelling.""" + def test_results_module_has_no_alias(self): + import diff_diff.changes_in_changes_results as mod + + assert not hasattr(mod, "QDiDResults") + assert mod.ChangesInChangesResults is ChangesInChangesResults + + @pytest.mark.parametrize("method", ["cic", "qdid"]) + def test_merged_surface_is_silent(self, method): + """The METHOD was never deprecated - only the removed class spelling.""" with warnings.catch_warnings(): warnings.simplefilter("error", FutureWarning) - ChangesInChanges(n_bootstrap=0, method="qdid") - ChangesInChanges(n_bootstrap=0, method="cic") - - def test_set_params_re_emits(self): - """Documented side effect of BaseEstimator's transactional probe re-init.""" - est = _qdid(n_bootstrap=0) - with pytest.warns(FutureWarning, match=QDID_DEPRECATION_RE): + est = ChangesInChanges(n_bootstrap=0, method=method) est.set_params(n_bootstrap=5) - - def test_results_alias_identity(self): - assert QDiDResults is ChangesInChangesResults + assert est.method == method # ========================================================================== @@ -519,22 +466,6 @@ def test_canonical_surface_emits_no_deprecation(self, df, method): # Gate F - dispatch guards inside the shared engine # ========================================================================== class TestGateFEngineGuards: - def test_qdid_fit_survives_revalidation(self, df): - """QDiD's get_params() has no 'method' key and _fit_distributional - re-validates through _validate_all_params on every fit.""" - assert np.isfinite(_fit_quiet(_qdid(n_bootstrap=0), df).att) - - def test_validate_all_params_tolerates_missing_method(self): - """The behavioural pin for ``params.get("method", "cic")``. - - A ``params["method"]`` read would KeyError here - which is exactly what - every QDiD fit would hit. Asserted directly rather than by mutating - source, so nothing dirties the worktree. - """ - _validate_all_params( - {"quantiles": None, "n_bootstrap": 0, "alpha": 0.05, "panel": False, "seed": None} - ) - def test_direct_attribute_mutation_revalidated_at_fit(self, df): """The one route that bypasses BOTH __init__ and the set_params probe.""" est = ChangesInChanges(n_bootstrap=0) @@ -542,22 +473,16 @@ def test_direct_attribute_mutation_revalidated_at_fit(self, df): with pytest.raises(ValueError, match=METHOD_VALUE_RE): est.fit(df, **FIT_KW) - @pytest.mark.parametrize( - "factory,expected", - [ - (lambda: ChangesInChanges(n_bootstrap=0, method="qdid"), "ChangesInChanges"), - (lambda: _qdid(n_bootstrap=0), "QDiD"), - ], - ids=["merged", "dying"], - ) - def test_errors_name_the_class_the_user_built(self, cov_df, factory, expected): - """Derived from type(est).__name__, so a method='qdid' fit never names - a class the caller never constructed.""" + @pytest.mark.parametrize("method", ["cic", "qdid"]) + def test_errors_name_the_class_the_user_built(self, cov_df, method): + """Derived from type(est).__name__, so a method='qdid' fit names + ChangesInChanges, never the removed QDiD class.""" bad = cov_df.copy() bad["x_str"] = "a" with pytest.raises(ValueError) as exc: - factory().fit(bad, **FIT_KW, covariates=["x_str"]) - assert expected in str(exc.value) + ChangesInChanges(n_bootstrap=0, method=method).fit(bad, **FIT_KW, covariates=["x_str"]) + assert "ChangesInChanges" in str(exc.value) + assert "QDiD(" not in str(exc.value) # ========================================================================== diff --git a/tests/test_v4_merge_ddd.py b/tests/test_v4_merge_ddd.py index a51e1ceb3..b28b40ce0 100644 --- a/tests/test_v4_merge_ddd.py +++ b/tests/test_v4_merge_ddd.py @@ -1,13 +1,18 @@ -"""Phase 3(b) merge gates: TripleDifference absorbs StaggeredTripleDifference. +"""Merge gates: TripleDifference absorbed StaggeredTripleDifference (phase 3(b)); +the class and its ``SDDD`` alias were removed at the 4.0 cut (PR 4a). -Ledger rows under test: M-013 (the class merge), M-064 (the SDDD alias warning -riding the class), M-140/M-141 (fit-time aggregate=/balance_e= carried on the -surviving class), M-142 (the pscore_trim validation tightening). +Ledger rows under test: M-013 (the class merge, now removed), M-064 (the retired +``SDDD`` alias), M-085 (2x2x2 ``time=`` raises), M-140/M-141 (fit-time +aggregate=/balance_e= carried on the surviving class), M-142 (the pscore_trim +validation tightening). TOLERANCE DOCTRINE ------------------ -Every merge-PARITY gate is BIT-EXACT (``assert_array_equal``): both sides run -the same engine in the same process, so a needed tolerance IS the finding. +Through 3.x the merge-PARITY gates compared the two surfaces BIT-EXACTLY +(``assert_array_equal``). With the class gone, the merged surface's absolute +pins are the committed oracles below; any in-process comparison that remains +(e.g. the cross-mode refit) stays bit-exact, so a needed tolerance IS the +finding. Two deliberate exceptions, both comparing against literals captured in a DIFFERENT process: @@ -29,16 +34,18 @@ DIFF_DIFF_BACKEND=python python3 capture_oracles.py -commit be694f493ac4e1640cdcccb5bdf7997a5b1f24dd. +commit be694f493ac4e1640cdcccb5bdf7997a5b1f24dd. The capture script +(``tests/_capture_v4_merge_ddd_oracles.py``) drives the removed class and is +HISTORICAL: runnable only at that commit. Why two oracles, not one. ORACLE_2X2X2 guards the rewritten fit prologue, signature and dispatch. ORACLE_STAGGERED guards the ~1700-line relocation of the staggered engine into ``diff_diff/_staggered_triple_diff_engine.py``: -nothing else in CI pins that engine in ABSOLUTE terms, because the parity gates -compare two callers of the same relocated core, the four SDDD suites carry no -committed numeric pins, and the R-golden lane skips whenever the gitignored -CSVs are absent (``.gitignore``). Without it a transcription slip in the move -would be invisible. +nothing else in CI pins that engine in ABSOLUTE terms - the three staggered-DDD +suites (ported to the merged surface in PR 4a) carry no committed numeric +pins, and the R-golden lane skips whenever the gitignored CSVs are absent +(``.gitignore``). Without it a transcription slip in the move would have been +invisible, and a regression in the engine would be now. Two capture-config notes worth keeping: @@ -70,20 +77,18 @@ """ import os -import re import warnings import numpy as np import pytest from numpy.testing import assert_allclose, assert_array_equal -from diff_diff import SDDD, StaggeredTripleDifference, TripleDifference +from diff_diff import TripleDifference from diff_diff.prep_dgp import ( generate_ddd_data, generate_ddd_panel_data, generate_staggered_ddd_data, ) -from diff_diff.staggered_triple_diff import _SDDD_DEPRECATION_MSG from diff_diff.staggered_triple_diff_results import StaggeredTripleDiffResults from diff_diff.survey import SurveyDesign from diff_diff.triple_diff import TripleDifferenceResults @@ -378,15 +383,6 @@ STAG_COVS = ["x1", "x2"] -# The dying class emits a FutureWarning per construction; the pyproject filter -# keeps the legacy suites quiet, but a test-local simplefilter RESETS the filter -# list, so build it through this helper inside any recording block. -def _sddd(**kwargs): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - return StaggeredTripleDifference(**kwargs) - - @pytest.fixture(scope="module") def cross(): return generate_ddd_data(**CROSS_KW) @@ -421,7 +417,6 @@ def stag_survey(stag): C_COLS = dict(outcome="outcome", group="group", partition="partition", post="time") S_NEW = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") -S_OLD = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") def _fit_new(df, **kwargs): @@ -431,11 +426,6 @@ def _fit_new(df, **kwargs): ) -def _fit_old(df, **kwargs): - """Staggered fit through the DEPRECATED surface.""" - return _sddd(**kwargs.pop("ctor", {})).fit(df, eligibility="eligibility", **S_OLD, **kwargs) - - def _eq_with_nans(a, b): """NaN-mask equality first, then the finite subset - so a NaN-vs-0.0 regression cannot be masked by nan_to_num-style comparison.""" @@ -538,12 +528,20 @@ def test_post_still_lands_in_slot_5(self, cross): keyword = TripleDifference().fit(cross, **C_COLS) _eq_with_nans(_quintet(positional), _quintet(keyword)) - def test_time_alias_still_warns_and_routes_identically(self, cross): - with pytest.warns(FutureWarning, match="calendar column only"): - aliased = TripleDifference().fit( + def test_time_in_2x2x2_raises_naming_post(self, cross): + """M-085 (4.0): in the 2x2x2 design `time=` is the staggered calendar + column only; passing it raises and points at `post=` instead of being + reinterpreted as the post dummy (the 3.x M-031 FutureWarning shim).""" + with pytest.raises(ValueError, match="staggered calendar column only") as exc: + TripleDifference().fit( cross, outcome="outcome", group="group", partition="partition", time="time" ) - _eq_with_nans(_quintet(aliased), _quintet(TripleDifference().fit(cross, **C_COLS))) + assert "post=" in str(exc.value) + # The canonical keyword still fits, warning-free. + with warnings.catch_warnings(): + warnings.simplefilter("error") + r = TripleDifference().fit(cross, **C_COLS) + assert np.isfinite(r.att) # --------------------------------------------------------------------------- @@ -555,10 +553,12 @@ def test_time_alias_still_warns_and_routes_identically(self, cross): class TestGateBPrimeStaggeredOracle: """The engine that actually moved, pinned in ABSOLUTE terms. - Gate B below compares two callers of the same relocated core, so it cannot - see a transcription slip in the move itself; the SDDD suites carry no + Through 3.x Gate B compared the two callers of the relocated core (removed + with the StaggeredTripleDifference class), which could not see a + transcription slip in the move itself; the staggered-DDD suites carry no committed numeric pins; and the R-golden lane skips whenever the gitignored - CSVs are absent. This gate is the one that would catch it. + CSVs are absent. This gate is the current numerical regression check on the + engine. """ def test_base_config_matches_oracle(self, stag): @@ -587,12 +587,13 @@ def test_bootstrap_config_matches_oracle(self, stag): # -- branch coverage for the rest of the relocated engine ----------------- # The two lanes above ride DR-without-covariates. _compute_pscore, # _compute_or, the never-treated comparison fork and the survey path all - # moved too, and Gate B cannot see a slip in any of them (both of its sides - # run the moved code). These are absolute pins on each. + # moved too, and nothing else pins them in absolute terms (the 3.x + # old-vs-new parity lanes ran the moved code on both sides). These are + # absolute pins on each. def test_never_treated_branch_matches_oracle(self, stag): """The _is_never_treated fork. The merged surface passes the UNDERSCORED - spelling; the oracle was captured through the dying class's compact one, + spelling; the oracle was captured through the removed class's compact one, so this also pins the vocabulary bridge onto real numbers.""" exp = ORACLE_STAGGERED["stag_dr_nevertreated"] r = _fit_new(stag, aggregate="all", ctor=dict(control_group="never_treated")) @@ -655,33 +656,6 @@ def _assert_against(r, exp): assert_allclose(got[k]["se"], v["se"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) -# --------------------------------------------------------------------------- -# Gate B - merged staggered mode == deprecated class, bit-exact -# --------------------------------------------------------------------------- - -# ONE AXIS AT A TIME against a fixed base, not a Cartesian product: the full -# cross-product is ~5.8k configs x two fits x bootstrap draws, which is not -# runnable. Named interaction cells cover the couplings that matter. -_BASE = dict(estimation_method="dr") -_AXES = [ - ("method_ipw", dict(estimation_method="ipw"), {}), - ("method_reg", dict(estimation_method="reg"), {}), - ("base_period_universal", dict(base_period="universal"), {}), - ("anticipation_1", dict(anticipation=1), {}), - ("cband_off", dict(n_bootstrap=49, seed=7, cband=False), {}), - ("weights_mammen", dict(n_bootstrap=49, seed=7, bootstrap_weights="mammen"), {}), - ("weights_webb", dict(n_bootstrap=49, seed=7, bootstrap_weights="webb"), {}), - ("aggregate_none", {}, dict(aggregate=None)), - ("aggregate_event_study", {}, dict(aggregate="event_study")), - ("aggregate_group", {}, dict(aggregate="group")), - ("aggregate_simple", {}, dict(aggregate="simple")), - ("balance_e", {}, dict(aggregate="event_study", balance_e=1)), - ("covariates", {}, dict(covariates=["x1"])), - # named interaction cells - ("boot_x_cband", dict(n_bootstrap=49, seed=7, cband=True), dict(aggregate="all")), - ("universal_x_anticipation", dict(base_period="universal", anticipation=1), {}), -] - _STAG_FIELDS = [ "overall_att", "overall_se", @@ -720,45 +694,24 @@ def _assert_staggered_parity(old, new): _eq_dicts_with_nans(a, b, keys=("effect", "se")) -class TestGateBStaggeredParity: - """Every lane: merged staggered mode is BIT-EXACT vs the dying class.""" +# --------------------------------------------------------------------------- +# Gate B - merged-surface contracts (the 3.x old-vs-new parity lanes went with the class) +# --------------------------------------------------------------------------- - @pytest.mark.parametrize("label,ctor,fit_kw", _AXES, ids=[a[0] for a in _AXES]) - def test_axis_parity(self, stag, label, ctor, fit_kw): - full_ctor = {**_BASE, **ctor} - base_fit = dict(aggregate="all") - base_fit.update(fit_kw) - if "covariates" in fit_kw: - df = generate_staggered_ddd_data(**STAG_KW, add_covariates=True) - base_fit["covariates"] = ["x1"] - else: - df = stag - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - old = _fit_old(df, ctor=dict(full_ctor), **base_fit) - new = _fit_new(df, ctor=dict(full_ctor), **base_fit) - _assert_staggered_parity(old, new) - @pytest.mark.parametrize( - "new_value,old_value", - [("not_yet_treated", "notyettreated"), ("never_treated", "nevertreated")], - ) - def test_control_group_vocabulary_parity(self, stag, new_value, old_value): - """Each class takes only ITS OWN spelling, so a parity pair is the - underscored value on the new surface vs the compact one on the old.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - old = _fit_old(stag, ctor=dict(control_group=old_value), aggregate="all") - new = _fit_new(stag, ctor=dict(control_group=new_value), aggregate="all") - _assert_staggered_parity(old, new) +class TestGateBMergedSurfaceContracts: + """Merged-surface contracts that the 3.x old-vs-new parity lanes carried. + + The parity lanes themselves (axis-by-axis and vocabulary pairs against the + removed class) are gone with the class; the absolute pins live in + ``TestGateBPrimeStaggeredOracle``.""" def test_control_group_values_are_SEMANTICALLY_different(self, stag): """Guards a _is_never_treated that collapses both spellings. - Parity alone cannot catch it: both surfaces route through the same - helper, so a collapsing helper keeps every parity gate green while - silently switching the comparison-group semantics. Nothing else in the - repo differentiates the two values (the R-golden lane skips in CI). + The oracle alone cannot catch it on every design: a collapsing helper + would silently switch the comparison-group semantics. Nothing else in + the repo differentiates the two values (the R-golden lane skips in CI). """ with warnings.catch_warnings(): warnings.simplefilter("ignore") @@ -769,21 +722,19 @@ def test_control_group_values_are_SEMANTICALLY_different(self, stag): "comparison-group branch is not reading control_group" ) - def test_first_treat_inf_recode_warning_parity(self, stag): + def test_first_treat_inf_recode_warns(self, stag): df = stag.copy() df["first_treat"] = df["first_treat"].astype(float) df.loc[df["first_treat"] == 0, "first_treat"] = np.inf - msgs = [] - for fit in (_fit_old, _fit_new): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - fit(df, aggregate="simple") - msgs.append([str(x.message) for x in w if "first_treat=inf" in str(x.message)]) - assert msgs[0] == msgs[1] and msgs[0], msgs - - def test_bootstrap_x_replicate_survey_raises_on_BOTH_surfaces(self, stag): - """Excluded from the parity matrix by construction: this combination - raises before any parity field exists, so it is exception-parity.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _fit_new(df, aggregate="simple") + msgs = [str(x.message) for x in w if "first_treat=inf" in str(x.message)] + assert msgs, [str(x.message) for x in w] + + def test_bootstrap_x_replicate_survey_raises_naming_the_surface(self, stag): + """This combination raises before any result field exists; the message + must name the class the user fit.""" df = stag.copy() rng = np.random.default_rng(3) df["w"] = 1.0 @@ -798,10 +749,9 @@ def test_bootstrap_x_replicate_survey_raises_on_BOTH_surfaces(self, stag): replicate_weights=[f"rw{i}" for i in range(1, 4)], replicate_method="JK1", ) - for fit, name in ((_fit_old, "StaggeredTripleDifference"), (_fit_new, "TripleDifference")): - with pytest.raises(NotImplementedError) as exc: - fit(df, ctor=dict(n_bootstrap=49, seed=7), survey_design=design) - assert name in str(exc.value), f"{name} not named: {exc.value}" + with pytest.raises(NotImplementedError) as exc: + _fit_new(df, ctor=dict(n_bootstrap=49, seed=7), survey_design=design) + assert "TripleDifference" in str(exc.value), exc.value # --------------------------------------------------------------------------- @@ -880,9 +830,9 @@ def test_2x2x2_required_args_named_individually(self, cross, missing): TripleDifference().fit(cross, **kw) def test_missing_group_raises_without_the_rename_warning(self, cross): - """require_arg for group/partition runs BEFORE the M-031 shim: after - it, this call would newly emit the rename FutureWarning before raising, - and under -W error the surfaced exception type would flip.""" + """require_arg for group/partition runs BEFORE the 2x2x2 `time=` check, so + a missing group is reported as the TypeError, not as the M-085 + ValueError (and nothing warns on the way).""" with warnings.catch_warnings(): warnings.simplefilter("error") with pytest.raises(TypeError, match="missing required argument: 'group'"): @@ -910,18 +860,13 @@ def test_staggered_params_are_keyword_only(self, stag): class TestGateDTimeSemantics: - def test_staggered_time_emits_no_rename_warning(self, stag): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _fit_new(stag, aggregate="simple") - assert not [x for x in w if "calendar column only" in str(x.message)] - - def test_staggered_time_is_bit_exact_vs_the_dying_surface(self, stag): + def test_staggered_time_is_the_calendar_column(self, stag): + """`time=` is accepted (and required) in staggered mode: the M-085 2x2x2 + check must never fire on this path.""" with warnings.catch_warnings(): warnings.simplefilter("ignore") - _assert_staggered_parity( - _fit_old(stag, aggregate="all"), _fit_new(stag, aggregate="all") - ) + r = _fit_new(stag, aggregate="simple") + assert np.isfinite(r.overall_att) def test_2x2x2_both_post_and_time_raises(self, cross): with pytest.raises(ValueError, match="pass only post="): @@ -934,64 +879,46 @@ def test_2x2x2_both_post_and_time_raises(self, cross): time="time", ) - def test_m085_is_not_live_in_3_9(self, cross): - """2x2x2 time= WARNS; it does not raise. The 4.0 flip is M-085's job.""" - with pytest.warns(FutureWarning): - r = TripleDifference().fit( - cross, outcome="outcome", group="group", partition="partition", time="time" - ) - assert np.isfinite(r.att) - # --------------------------------------------------------------------------- -# Gate E - deprecation choreography (M-013 / M-064) +# Gate E - removal pins (M-013 / M-064; 4.0 cut, PR 4a) # --------------------------------------------------------------------------- -class TestGateEDeprecation: - def test_construction_warns_with_the_pinned_message(self): - with pytest.warns(FutureWarning, match=re.escape(_SDDD_DEPRECATION_MSG)): - StaggeredTripleDifference() +class TestGateERemoval: + """The class, its module and the ``SDDD`` alias are gone; the merged surface + constructs silently; the results container (M-014) is still exported.""" - def test_sddd_alias_is_the_same_class_and_warns_once(self): - assert SDDD is StaggeredTripleDifference - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - SDDD() - fired = [ - x - for x in w - if x.category is FutureWarning - and "StaggeredTripleDifference is deprecated" in str(x.message) - ] - assert len(fired) == 1, [str(x.message) for x in w] - assert "SDDD alias is deprecated with it" in str(fired[0].message) + @pytest.mark.parametrize("name", ["StaggeredTripleDifference", "SDDD"]) + def test_name_is_absent(self, name): + import diff_diff - def test_warns_and_still_works(self, stag): - with pytest.warns(FutureWarning): - est = StaggeredTripleDifference() - r = est.fit(stag, eligibility="eligibility", **S_OLD, aggregate="simple") - assert np.isfinite(r.overall_att) + assert not hasattr(diff_diff, name) + assert name not in diff_diff.__all__ + assert name not in dir(diff_diff) + with pytest.raises(ImportError): + exec(f"from diff_diff import {name}") + + def test_module_is_gone(self): + import importlib + + with pytest.raises(ModuleNotFoundError): + importlib.import_module("diff_diff.staggered_triple_diff") + + def test_results_container_still_exported(self): + """M-014 dies in PR 6, not here.""" + import diff_diff + + assert diff_diff.StaggeredTripleDiffResults is StaggeredTripleDiffResults + assert "StaggeredTripleDiffResults" in diff_diff.__all__ def test_successor_does_not_warn(self, stag): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") TripleDifference() + TripleDifference().set_params(alpha=0.10) assert not [x for x in w if x.category is FutureWarning] - def test_deprecation_attributes_to_the_caller(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - StaggeredTripleDifference() - assert w[0].filename.endswith("test_v4_merge_ddd.py") - - def test_set_params_re_emits(self): - """Documented side effect of BaseEstimator's transactional probe - re-init, shared with MultiPeriodDiD's shim.""" - est = _sddd() - with pytest.warns(FutureWarning, match="StaggeredTripleDifference is deprecated"): - est.set_params(alpha=0.10) - # --------------------------------------------------------------------------- # Gate F - introspection, the new validation contracts, cross-mode refit @@ -1020,31 +947,9 @@ class TestGateFIntrospectionAndValidation: def test_merged_param_set_is_pinned(self): assert set(TripleDifference().get_params()) == _MERGED_PARAMS - def test_dying_class_param_set_is_frozen(self): - assert set(_sddd().get_params()) == { - "estimation_method", - "control_group", - "alpha", - "anticipation", - "base_period", - "n_bootstrap", - "bootstrap_weights", - "seed", - "cband", - "pscore_trim", - "cluster", - "rank_deficient_action", - "epv_threshold", - "pscore_fallback", - } - - def test_round_trip_both_classes(self): + def test_round_trip(self): est = TripleDifference(control_group="never_treated", n_bootstrap=9) assert TripleDifference(**est.get_params()).get_params() == est.get_params() - old = _sddd(control_group="nevertreated") - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - assert StaggeredTripleDifference(**old.get_params()).get_params() == old.get_params() def test_set_params_round_trip(self): est = TripleDifference() @@ -1118,28 +1023,10 @@ def test_negative_anticipation_cannot_reach_the_engine(self, stag): stag, partition="eligibility", **S_NEW, aggregate="simple" ) - @pytest.mark.parametrize("bad", [-1, 1.5, True]) - def test_deprecated_sibling_also_fails_closed_on_anticipation(self, stag, bad): - """The dying class's frozen 3.x API SHAPE (param names, `eligibility=`, - the compact control_group vocabulary, accepted-then-ignored `cluster=`) - was never a licence to emit silently-biased numbers: it runs the SAME - engine, where a negative window selects an already-treated base period - and admits contaminated controls. So the guard lives in the engine and - BOTH surfaces fail closed. - - The split is deliberate and pinned below: CONSTRUCTION still succeeds on - the deprecated class (its signature contract is untouched), while FIT - raises - the check is an identification guard, not an API change.""" - est = _sddd(anticipation=bad) - assert est.anticipation == bad, "the dying class's constructor contract is unchanged" - with pytest.raises(ValueError, match="anticipation must be a non-negative integer"): - est.fit(stag, eligibility="eligibility", **S_OLD, aggregate="simple") - # -- first_treat cohort encoding (silent-population guard) ---------------- @pytest.mark.parametrize("sentinel", [-1, -np.inf, -0.5]) - @pytest.mark.parametrize("surface", ["merged", "deprecated"]) - def test_negative_cohorts_fail_closed_on_both_surfaces(self, stag, sentinel, surface): + def test_negative_cohorts_fail_closed(self, stag, sentinel): """_precompute_structures builds the treated set from `g > 0` and the never-enabled set from `g == 0`, so a unit encoded with the common `-1` never-treated convention belonged to NEITHER: still counted in n_obs, @@ -1155,9 +1042,8 @@ def test_negative_cohorts_fail_closed_on_both_surfaces(self, stag, sentinel, sur df = stag.copy() df["first_treat"] = df["first_treat"].astype(float) df.loc[df["first_treat"] == 0, "first_treat"] = sentinel - fit = _fit_new if surface == "merged" else _fit_old with pytest.raises(ValueError, match="negative cohort value"): - fit(df, aggregate="simple") + _fit_new(df, aggregate="simple") # -- degenerate enabling cohort (honest-reporting guard) ------------------ @@ -1168,31 +1054,19 @@ def _strip_eligibility_from_cohort(df, cohort): out.loc[out["unit"].isin(units), "eligibility"] = 0 return out - @pytest.mark.parametrize("surface", ["merged", "deprecated"]) - def test_degenerate_cohort_is_named_and_dropped_from_groups(self, stag, surface): + def test_degenerate_cohort_is_named_and_dropped_from_groups(self, stag): """A positive cohort whose units are ALL partition==0 cannot identify ATT(g,t), so it contributes to no aggregate - but it was still advertised in `groups`/`n_groups`, and no warning named it (the ones mentioning it describe its role as a COMPARISON cohort for other g). The estimate is deliberately unchanged: it is valid for the cohorts that do identify.""" df = self._strip_eligibility_from_cohort(stag, 4) - fit = _fit_new if surface == "merged" else _fit_old with pytest.warns(UserWarning, match=r"cohort g=4\.0 has no eligible treated units"): - r = fit(df, aggregate="simple") + r = _fit_new(df, aggregate="simple") assert list(r.groups) == [3.0], "groups must not advertise a non-contributing cohort" assert r.to_dict()["n_groups"] == 1 assert {g for g, _ in r.group_time_effects} == {3.0} - def test_degenerate_cohort_does_not_move_the_estimate(self, stag): - """The fix is reporting-only - pinned so a later 'improvement' cannot - quietly turn it into an estimand change.""" - df = self._strip_eligibility_from_cohort(stag, 4) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - new, old = _fit_new(df, aggregate="simple"), _fit_old(df, aggregate="simple") - _eq_with_nans([new.overall_att, new.overall_se], [old.overall_att, old.overall_se]) - assert list(new.groups) == list(old.groups) - def test_healthy_cohorts_are_untouched(self, stag): """Negative pin: no warning and no groups change on a well-posed panel.""" with warnings.catch_warnings(record=True) as w: @@ -1235,17 +1109,11 @@ def test_anticipation_guard_survives_attribute_mutation(self, stag): @pytest.mark.parametrize("compact", ["notyettreated", "nevertreated"]) def test_merged_class_rejects_the_compact_vocabulary(self, compact): - """The boundary Design 3's bridge and Gate B's pairing rest on.""" + """The merged class speaks the underscored vocabulary only; R's compact + spellings died with the StaggeredTripleDifference class.""" with pytest.raises(ValueError, match="control_group must be"): TripleDifference(control_group=compact) - @pytest.mark.parametrize("underscored", ["not_yet_treated", "never_treated"]) - def test_dying_class_rejects_the_underscored_vocabulary(self, underscored): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - with pytest.raises(ValueError, match="control_group must be"): - StaggeredTripleDifference(control_group=underscored) - def test_base_period_and_bootstrap_weights_contracts(self): with pytest.raises(ValueError, match="base_period must be"): TripleDifference(base_period="bogus") @@ -1292,31 +1160,29 @@ def test_frame_offset_is_restored_after_a_RAISING_staggered_fit(self, cross, sta class TestGateGAttributionAndThreading: - def test_user_attributed_warnings_land_on_the_CALLER_on_both_surfaces(self, stag): + def test_user_attributed_warnings_land_on_the_CALLER(self, stag): """The facade adds a frame; _frame_offset must absorb it. Uses the base-period-outside-panel warning, which fires from the fit body.""" df = generate_staggered_ddd_data(**{**STAG_KW, "cohort_periods": [1, 3]}) - for fit in (_fit_old, _fit_new): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - fit(df, aggregate="simple") - fired = [x for x in w if "outside the observed panel" in str(x.message)] - assert fired, "expected the base-period warning" - assert all(x.filename.endswith("test_v4_merge_ddd.py") for x in fired), [ - x.filename for x in fired - ] - - def test_low_bootstrap_warning_attributes_to_the_caller_on_both_surfaces(self, stag): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _fit_new(df, aggregate="simple") + fired = [x for x in w if "outside the observed panel" in str(x.message)] + assert fired, "expected the base-period warning" + assert all(x.filename.endswith("test_v4_merge_ddd.py") for x in fired), [ + x.filename for x in fired + ] + + def test_low_bootstrap_warning_attributes_to_the_caller(self, stag): """Shared with CallawaySantAnna via the bootstrap mixin, so it reads the offset through getattr rather than taking it as an argument.""" - for fit in (_fit_old, _fit_new): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - fit(stag, ctor=dict(n_bootstrap=9, seed=1), aggregate="simple") - fired = [x for x in w if "is low" in str(x.message)] - assert fired and all(x.filename.endswith("test_v4_merge_ddd.py") for x in fired), [ - (x.filename, str(x.message)[:40]) for x in fired - ] + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _fit_new(stag, ctor=dict(n_bootstrap=9, seed=1), aggregate="simple") + fired = [x for x in w if "is low" in str(x.message)] + assert fired and all(x.filename.endswith("test_v4_merge_ddd.py") for x in fired), [ + (x.filename, str(x.message)[:40]) for x in fired + ] def test_callawaysantanna_attribution_is_unchanged(self): """CS never sets _warn_frame_offset, so the shared site's getattr @@ -1337,10 +1203,9 @@ def test_survey_pweight_error_names_the_surface_that_was_fit(self, stag): df = stag.copy() df["w"] = 1.0 design = SurveyDesign(weights="w", weight_type="fweight") - for fit, name in ((_fit_old, "StaggeredTripleDifference"), (_fit_new, "TripleDifference")): - with pytest.raises(ValueError) as exc: - fit(df, survey_design=design) - assert str(exc.value).startswith(f"{name} survey support requires") + with pytest.raises(ValueError) as exc: + _fit_new(df, survey_design=design) + assert str(exc.value).startswith("TripleDifference survey support requires") def test_partition_vocabulary_never_leaks_on_the_merged_surface(self, stag): """BOTH sentences of the time-invariance error are parameterized - the @@ -1352,26 +1217,18 @@ def test_partition_vocabulary_never_leaks_on_the_merged_surface(self, stag): msg = str(exc.value) assert "Partition must be time-invariant" in msg assert "eligibility" not in msg, msg - with pytest.raises(ValueError) as exc_old: - _fit_old(df) - assert "Eligibility must be time-invariant" in str(exc_old.value) - assert "varying eligibility" in str(exc_old.value) def test_no_valid_group_time_message_uses_the_right_vocabulary(self, stag): """An anticipation window wider than the panel skips every cell, which is the cheapest route to this message. (Filtering the partition instead would trip the Q-notation validator first - that message is paper vocabulary and is deliberately NOT parameterized.)""" - for fit, word, wrong in ( - (_fit_new, "partition", "eligibility"), - (_fit_old, "eligibility", "partition"), - ): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - with pytest.raises(ValueError, match="No valid group-time effects") as exc: - fit(stag, ctor=dict(anticipation=10)) - assert word in str(exc.value) - assert wrong not in str(exc.value) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises(ValueError, match="No valid group-time effects") as exc: + _fit_new(stag, ctor=dict(anticipation=10)) + assert "partition" in str(exc.value) + assert "eligibility" not in str(exc.value) # --------------------------------------------------------------------------- @@ -1380,29 +1237,20 @@ def test_no_valid_group_time_message_uses_the_right_vocabulary(self, stag): class TestGateHConsumers: - def test_event_study_surface_identical_across_surfaces(self, stag): - from diff_diff.results_base import build_event_study_surface - - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - old = build_event_study_surface(_fit_old(stag, aggregate="event_study")) - new = build_event_study_surface(_fit_new(stag, aggregate="event_study")) - _eq_with_nans(old.att, new.att) - _eq_with_nans(old.se, new.se) - assert old.source == new.source - - def test_business_report_handles_both_control_group_vocabularies(self, stag): + def test_business_report_handles_both_control_groups(self, stag): + """BusinessReport renders the staggered container under either + control_group and reports the never-enabled count for both.""" from diff_diff.business_report import BusinessReport with warnings.catch_warnings(): warnings.simplefilter("ignore") - new = BusinessReport( + nyt = BusinessReport( _fit_new(stag, ctor=dict(control_group="not_yet_treated"), aggregate="simple") ).to_dict() - old = BusinessReport( - _fit_old(stag, ctor=dict(control_group="notyettreated"), aggregate="simple") + nev = BusinessReport( + _fit_new(stag, ctor=dict(control_group="never_treated"), aggregate="simple") ).to_dict() - assert new["sample"]["n_never_enabled"] == old["sample"]["n_never_enabled"] + assert nyt["sample"]["n_never_enabled"] == nev["sample"]["n_never_enabled"] > 0 def test_summary_renders_in_both_modes(self, cross, stag): """summary()/print_summary() delegate to results_, so the merged class @@ -1417,15 +1265,6 @@ def test_summary_renders_in_both_modes(self, cross, stag): text = est.summary() assert "ATT" in text and len(text.splitlines()) > 10 - def test_describe_target_parameter_unchanged(self, stag): - from diff_diff._reporting_helpers import describe_target_parameter - - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - assert describe_target_parameter( - _fit_new(stag, aggregate="simple") - ) == describe_target_parameter(_fit_old(stag, aggregate="simple")) - def test_power_rejects_staggered_config_at_all_three_entry_points(self): from diff_diff.power import simulate_mde, simulate_power, simulate_sample_size @@ -1519,10 +1358,9 @@ def test_power_rejects_mode_selectors_by_presence(self, key, value): TripleDifference(), n_units=48, n_simulations=1, estimator_kwargs={key: value} ) - @pytest.mark.parametrize("surface", ["merged", "deprecated"]) - def test_bootstrap_warning_names_the_estimator_that_was_fit(self, stag, surface): + def test_bootstrap_warning_names_the_estimator_that_was_fit(self, stag): """The single-PSU/degenerate-design warning lives in the mixin shared by - CallawaySantAnna and BOTH DDD classes, so the hard-coded literal named + CallawaySantAnna and TripleDifference, so a hard-coded literal would name CallawaySantAnna no matter which surface was fit - a misleading diagnosis on a fit that is failing closed. @@ -1535,12 +1373,11 @@ def test_bootstrap_warning_names_the_estimator_that_was_fit(self, stag, surface) df["fpc_col"] = 10**6 # design-based variance so the PSU is retained design = SurveyDesign(weights="w", psu="psu", fpc="fpc_col") ctor = dict(estimation_method="reg", n_bootstrap=20, seed=7) - fit = _fit_new if surface == "merged" else _fit_old - expected = "TripleDifference" if surface == "merged" else "StaggeredTripleDifference" + expected = "TripleDifference" with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - fit(df, aggregate="event_study", survey_design=design, ctor=ctor) + _fit_new(df, aggregate="event_study", survey_design=design, ctor=ctor) hits = [ str(w.message) for w in caught @@ -1557,7 +1394,6 @@ def test_bootstrap_label_is_declared_on_every_host(self): assert CallawaySantAnna._BOOTSTRAP_LABEL == "CallawaySantAnna" assert TripleDifference._BOOTSTRAP_LABEL == "TripleDifference" - assert StaggeredTripleDifference._BOOTSTRAP_LABEL == "StaggeredTripleDifference" @pytest.mark.parametrize( "staggered", ["control_group", "anticipation", "base_period", "n_bootstrap"] @@ -1632,8 +1468,8 @@ def test_fit_and_power_share_one_staggered_boundary(self): class TestGateGRParity: """The R-golden lane SKIPS in CI: the generated CSVs are gitignored (.gitignore benchmarks/data/synthetic/*.csv), so `_load_r_data` calls - pytest.skip. It is therefore paired with a substitute that always runs and - proves the routing is identity-preserving on the same config. + pytest.skip; locally, with the CSVs present, it pins the merged surface + against R's triplediff on the golden's config. """ def test_r_golden_through_the_merged_surface(self): @@ -1688,20 +1524,3 @@ def test_r_golden_through_the_merged_surface(self): assert_allclose( py_gt[key_gt]["effect"], scenario["gt_att"][i], rtol=ATT_RTOL, atol=ATT_ATOL ) - - def test_always_running_substitute_on_the_same_config(self, stag): - """What the merge actually needs to establish: the routing is - identity-preserving on the golden's config, even when R data is absent.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - old = _fit_old( - stag, - ctor=dict(estimation_method="dr", control_group="notyettreated"), - aggregate="simple", - ) - new = _fit_new( - stag, - ctor=dict(estimation_method="dr", control_group="not_yet_treated"), - aggregate="simple", - ) - _assert_staggered_parity(old, new) diff --git a/tests/test_v4_rename_semantic.py b/tests/test_v4_rename_semantic.py index b2b353b35..c29cb6b3d 100644 --- a/tests/test_v4_rename_semantic.py +++ b/tests/test_v4_rename_semantic.py @@ -11,9 +11,9 @@ canonical-only serialized keys. ``__setstate__`` pickle migrations are KEPT (spec section 5: 3.x pickles load under 4.0) and stay pinned. -M-031 (``TripleDifference.fit(time=)``) stays shimmed: ``time`` keeps a -calendar meaning there and its 4.0 enforcement is M-083 (PR 4), so the -``test_ddd_*`` pins keep the 3.9 shape. +M-031 (``TripleDifference.fit(time=)``) stays shimmed: ``time`` keeps its +calendar meaning in staggered mode. Its 4.0 enforcement, M-085, landed in +PR 4a, so the 2x2x2 ``time=`` pin below is a raise pin. """ import pickle @@ -104,8 +104,8 @@ def etwfe_result(): # --------------------------------------------------------------------------- -# M-030 (removed): DiD.fit time= -> post=; M-031 (still shimmed): -# TripleDifference.fit time= -> post= +# M-030 (removed): DiD.fit time= -> post=; M-031 (shimmed, name kept for the +# staggered calendar column) + M-085 (done): TripleDifference.fit time= raises in 2x2x2 # --------------------------------------------------------------------------- @@ -130,16 +130,17 @@ def test_did_time_raises_typeerror(self, did_2x2): with pytest.raises(TypeError, match=_unexpected("time")): DiD().fit(did_2x2, outcome="y", treatment="d", post="p", time="p") - def test_ddd_time_warns_with_calendar_note(self, ddd_2x2x2): + def test_ddd_time_raises_naming_post(self, ddd_2x2x2): + """M-085: in 2x2x2 mode ``time=`` is the staggered calendar column only.""" from diff_diff import TripleDifference with warnings.catch_warnings(): - warnings.simplefilter("ignore") + warnings.simplefilter("error") r_new = TripleDifference().fit(ddd_2x2x2, "y", "g", "q", post="t") r_pos = TripleDifference().fit(ddd_2x2x2, "y", "g", "q", "t") - with pytest.warns(FutureWarning, match=r"calendar column only"): - r_old = TripleDifference().fit(ddd_2x2x2, "y", "g", "q", time="t") - assert r_old.att == r_new.att == r_pos.att + assert r_new.att == r_pos.att + with pytest.raises(ValueError, match=r"staggered calendar column only"): + TripleDifference().fit(ddd_2x2x2, "y", "g", "q", time="t") def test_ddd_missing_post_raises(self, ddd_2x2x2): from diff_diff import TripleDifference