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