From cca0d46d18afb580c65e7ba8e463a515166ced2a Mon Sep 17 00:00:00 2001 From: igerber Date: Tue, 27 Oct 2026 10:48:01 -0400 Subject: [PATCH 1/2] feat(v4): item 6 of the 4.0 cut - unify the DDD results container (M-014, M-140, M-141) TripleDifference.fit returns ONE TripleDifferenceResults for both designs. The surviving 2x2x2 class IS the unified container: its positional layout is byte-stable (the four cell counts and estimation_method gained defaults), the staggered structure is appended with defaults, and a design discriminator ("2x2x2" / "staggered") says which engine fit it. The canonical quintet is native in both designs; the group-time table is empty in 2x2x2; event_study_effects / group_effects / cband_crit_value are carrier fields that are None on every 4.0 fit; the five fit-time overall_*_es fields and the never-populated influence_functions are gone; df_inference records the 2x2x2 engine's t df (the LWDiDResults contract). The module moved from staggered_triple_diff_results.py to triple_diff_results.py (re-exported from triple_diff.py, the 3.x pickle class path) so the shared staggered engine imports it without a cycle. StaggeredTripleDiffResults is removed. Post-fit aggregate() (rows M-140/M-141, the fit-time aggregate=/balance_e= keywords are removed - TypeError): the container is an AggregationMixin adopter. 'simple' relays the stored headline in both designs; 'event_study' and 'group' recompute from the retained CallawaySantAnna kit in the staggered design (built on the eligibility-zeroed precomputed copy so the eligible-treated weighting is preserved) and REPLAY the fit-time multiplier bootstrap on bootstrapped fits; the 2x2x2 design fails those levels closed naming first_treat=. The paper's Eq. 4.14 overall (the 3.x overall_att_es family) is aggregate('simple', weights='event_time') - a weights selector, not a vocabulary word (user decision 2026-10-27) - reproducing the engine's df rule (per-statistic effective df, else the fit's sentinelled survey df carried as the ddd_df_survey_original bookkeeping key) and both fit-time warnings. power.py's staggered-boundary guard drops its by-value pair. Consumers dispatch on the design through _reporting_helpers.results_design / is_staggered_ddd (DR's _DESIGN_APPLICABILITY / _DESIGN_PT_METHOD tables beside the class-name tables; BR's never-enabled narration; a staggered practitioner handler); headline_attribute reads att in both designs (the last overall_att); DR gains a top-level design key and BR estimator.design (additive; no version bump - DR 3.0 / BR 4.0 have not shipped, folded into PR 5b's REPORTING.md Note). plot_group_effects and its siblings raise on the empty 2x2x2 table. Ledger M-014 / M-140 / M-141 -> removed with tests/test_v4_unify_ddd.py as test_ref (removal pins, shape and positional layout, the aggregate vocabulary and guards, python-backend-guarded literal oracles for the analytical, balance_e, bootstrap-replay and survey lanes captured on the unmodified tree, both Eq. 4.14 warnings, read surfaces under FutureWarning=error, the duck-typed consumers, pickles incl. the 3.x class path, the report surface and practitioner routing). The DDD suites port to the post-fit route; the aggregate-contract, event-study-surface, staggered-aggregation, alias, serialization, target-parameter, report and naming-guard suites follow. Spec section 4.2 / section 6 / item 6 SHIPPED notes, the REGISTRY post-fit Note, REPORTING.md, the migration guide, guides, API rst, autosummary stubs, tutorial 08 (re-executed) and the tracking rows follow. --- METHODOLOGY_REVIEW.md | 8 +- TODO.md | 3 +- ...261027-v4-pr6-ddd-container-unification.md | 8 + diff_diff/__init__.py | 4 - diff_diff/_reporting_helpers.py | 51 +- diff_diff/_staggered_triple_diff_engine.py | 279 +---- diff_diff/business_report.py | 37 +- diff_diff/diagnostic_report.py | 88 +- diff_diff/guides/llms-autonomous.txt | 9 +- diff_diff/guides/llms-full.txt | 29 +- diff_diff/guides/llms.txt | 2 +- diff_diff/power.py | 21 +- diff_diff/practitioner.py | 61 + diff_diff/results_base.py | 18 +- diff_diff/staggered_aggregation.py | 14 +- diff_diff/staggered_bootstrap.py | 3 +- diff_diff/staggered_triple_diff_results.py | 495 -------- diff_diff/triple_diff.py | 414 +------ diff_diff/triple_diff_results.py | 1073 +++++++++++++++++ diff_diff/visualization/_staggered.py | 14 + .../diff_diff.StaggeredTripleDiffResults.rst | 72 -- .../diff_diff.TripleDifferenceResults.rst | 36 +- docs/api/index.rst | 1 - docs/api/staggered.rst | 12 - docs/api/triple_diff.rst | 26 +- docs/doc-deps.yaml | 15 +- docs/methodology/REGISTRY.md | 97 +- docs/methodology/REPORTING.md | 21 +- docs/migration-4.0.md | 27 +- docs/tutorials/08_triple_diff.ipynb | 179 ++- docs/v4-deprecations.yaml | 27 +- docs/v4-design.md | 26 +- tests/test_aggregate_contract.py | 18 +- tests/test_business_report.py | 20 +- tests/test_diagnostic_report.py | 18 +- tests/test_event_study_surface.py | 5 +- tests/test_guides.py | 24 + .../test_methodology_staggered_triple_diff.py | 230 ++-- tests/test_naming_guard.py | 17 +- tests/test_result_aliases.py | 81 +- tests/test_results_serialization.py | 1 - tests/test_staggered_aggregation.py | 14 +- tests/test_staggered_triple_diff.py | 323 +++-- tests/test_survey_staggered_ddd.py | 299 ++--- tests/test_target_parameter.py | 20 +- tests/test_v4_merge_ddd.py | 177 +-- tests/test_v4_unify_ddd.py | 653 ++++++++++ 47 files changed, 3089 insertions(+), 1981 deletions(-) create mode 100644 changelog.d/20261027-v4-pr6-ddd-container-unification.md delete mode 100644 diff_diff/staggered_triple_diff_results.py create mode 100644 diff_diff/triple_diff_results.py delete mode 100644 docs/api/_autosummary/diff_diff.StaggeredTripleDiffResults.rst create mode 100644 tests/test_v4_unify_ddd.py diff --git a/METHODOLOGY_REVIEW.md b/METHODOLOGY_REVIEW.md index 4acd1ade0..7a672f217 100644 --- a/METHODOLOGY_REVIEW.md +++ b/METHODOLOGY_REVIEW.md @@ -987,7 +987,7 @@ These three are feature deferrals (paper-supported extensions that the library h | Field | Value | |-------|-------| -| 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` | +| 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), `triple_diff_results.py` (the unified `TripleDifferenceResults`, both designs, since item 6) | | 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** | @@ -1005,7 +1005,7 @@ These three are feature deferrals (paper-supported extensions that the library h - **Three-term DDD decomposition (Eq. 4.1):** post-treatment ATT(g,t) recover a known constant effect. - **GMM combination (Eqs. 4.11-4.12):** optimal weights sum to one; a single comparison group reduces to `w=[1]`. - **Event study (Eq. 4.13):** ES(e) equals the eligible-treated cohort-share-weighted average of ATT(g, g+e). -- **Overall (Eq. 4.14 / Cor. 4.2):** opt-in `overall_att_es` = unweighted mean of post-treatment ES(e), cross-validated against R `agg_ddd(type="eventstudy")$overall.att`/`overall.se`. +- **Overall (Eq. 4.14 / Cor. 4.2):** post-fit `aggregate("simple", weights="event_time")` (the 3.x fit-time `overall_att_es`) = unweighted mean of post-treatment ES(e), cross-validated against R `agg_ddd(type="eventstudy")$overall.att`/`overall.se`. **R Comparison Results** (`benchmarks/R/benchmark_staggered_triplediff.R`; `triplediff::ddd(panel=TRUE)` + `agg_ddd()`; CSV fixtures gitignored / regenerated on-the-fly, JSON golden committed): @@ -1014,8 +1014,8 @@ These three are feature deferrals (paper-supported extensions that the library h | Group-time ATT(g,t) | rtol 0.1% | exact | | Group-time SE(g,t) | rtol 1% | matches | | Event-study ES(e) | rtol 25% | within (per-e eligible-treated weighting deviation) | -| Overall ATT, Eq. 4.14 (`overall_att_es`) | rtol 10% | ≤5% (weighting deviation averages out in the mean) | -| Overall SE, Eq. 4.14 (`overall_se_es`) | rtol 3% | ≤0.5% | +| Overall ATT, Eq. 4.14 (`aggregate("simple", weights="event_time")`) | rtol 10% | ≤5% (weighting deviation averages out in the mean) | +| Overall SE, Eq. 4.14 (same call, `se`) | rtol 3% | ≤0.5% | The paper-equation-anchored Verified Components above are deterministic and run without R. The R cross-validation in this table runs only when local `R` + `triplediff` are available diff --git a/TODO.md b/TODO.md index b88812607..edd9e74da 100644 --- a/TODO.md +++ b/TODO.md @@ -26,9 +26,8 @@ Related tracking surfaces: | Numeric between-period cohorts (e.g. `first_treat=4.5` with integer times) are rejected by LWDiD while CallawaySantAnna estimates them and LWDiD's own datetime/Period cohorts map to the next observed period — close the dtype asymmetry by adopting the next-observed-period mapping for numeric cohorts too (contract documented in REGISTRY cohort-encodings Note + `docs/api/lwdid.rst` Input Contract). Lands only after PR #588 merges | `diff_diff/lwdid.py` | #588 | Quick | Low | | Implement the LW 2026 eq. 7.9/7.10 unit-average cohort estimand (regress per-unit post-average transformed outcomes on `[1, D_g]` vs never-treated) as an alternative to the documented cell-mass `cohort_effects` convention (REGISTRY within-cohort aggregation Note; the two differ on unbalanced panels, where cell-mass weights units by observed post periods). Needs the 7.10 regression + its covariance on the NT path. Lands only after PR #588 merges | `diff_diff/lwdid_staggered.py` | #588 | Quick | Low | | Expose cell-mass overall ATT (Stata `Post_avg` convention; = CS-simple on balanced panels) as an aggregate extra on LWDiD results — the fit's `.att` is the paper's `tau_omega` (cohort-mean-then-treated-weight, eq. 7.18); the authors' large-N display uses cell-mass weighting instead, and both are legitimate estimands (see the REGISTRY LWDiD Aggregation note). Lands only after PR #588 merges | `diff_diff/lwdid_results.py` | #588 | Quick | Low | -| 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 | +| Bootstrap-`seed` provenance on multiplier-bootstrap results containers: neither `TripleDifferenceResults` (staggered design) 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; the M-014 container unification (4.0, item 6) landed without it - the unified container records `n_bootstrap` / `inference_method` but not `seed` - so add it to BOTH containers in one PR. Precedent for exposing it: `ContinuousDiDResults`, `EfficientDiDResults`, `SyntheticDiDResults` already do | `diff_diff/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, 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}. PR 4c-ii (2026-10-25) DELETED the MPD-native branches that logic lived in (DiagnosticReport's `interaction_indices`+`vcov` joint-Wald covariance source, the `post_period_effects` heterogeneity source, the `pre_period_effects` Bonferroni/NaN-filter collector branch, the `avg_att` alias row, practitioner's `_handle_multi_period`) - recover them from git history (`git show 5a10b847^:diff_diff/diagnostic_report.py`) when admission lands; event-study results reach `_handle_generic` meanwhile | `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 | diff --git a/changelog.d/20261027-v4-pr6-ddd-container-unification.md b/changelog.d/20261027-v4-pr6-ddd-container-unification.md new file mode 100644 index 000000000..0509506c7 --- /dev/null +++ b/changelog.d/20261027-v4-pr6-ddd-container-unification.md @@ -0,0 +1,8 @@ +### Breaking Changes + +- **4.0 cut, DDD container unification (`M-014`, `M-140`, `M-141`).** `TripleDifference.fit()` returns ONE `TripleDifferenceResults` for both designs: `results.design` is `"2x2x2"` or `"staggered"`, the canonical `att` / `se` / `t_stat` / `p_value` / `conf_int` are native in both, the 2x2x2 positional layout is unchanged, and the staggered structure (`group_time_effects`, `groups`, `time_periods`, unit counts, `control_group`, `base_period`, `anticipation`, `bootstrap_results`, ...) is appended with defaults (`group_time_effects` is `{}` in the 2x2x2 design). The 3.x `StaggeredTripleDiffResults` class, its module, its `overall_*` fields and `to_dict()` keys, and its fit-time `overall_att_es` / `overall_se_es` / `overall_t_stat_es` / `overall_p_value_es` / `overall_conf_int_es` fields are REMOVED (3.x staggered pickles cannot load; 3.x 2x2x2 pickles load unchanged). The fit-time `aggregate=` / `balance_e=` keywords of `TripleDifference.fit()` are REMOVED (`TypeError`): aggregation is post-fit - `results.aggregate('event_study')` (`EventStudyResults`, `balance_e=`), `results.aggregate('group')` (`AggregationResult`) and `results.aggregate('simple')` (a one-row relay of the stored headline, both designs); bootstrapped fits REPLAY the fit-time multiplier bootstrap so sup-t bands and percentile inference match a 3.x fit-time aggregation; the 2x2x2 design fails the staggered levels closed. The paper's Eq. 4.14 overall is `results.aggregate('simple', weights='event_time')` (accepts `balance_e=`; both 3.x warnings preserved). `summary()` no longer prints the event-study / cohort / Eq. 4.14 blocks; `to_dataframe('event_study' | 'group')` raise with the post-fit pointer; `to_dataframe(level=None)` defaults to `'group_time'` in the staggered design and `'overall'` in 2x2x2 (whose one-row frame gains `design`, `pscore_trim`, `alpha`, `df_inference`). New field `df_inference` records the 2x2x2 engine's t df. `plot_group_effects` and its siblings raise `ValueError` on a 2x2x2 fit (no cells). +- **Report schemas.** For staggered DDD fits `DiagnosticReport.to_dict()['estimator']` and `BusinessReport.to_dict()['estimator']['class_name']` read `TripleDifferenceResults`, and `target_parameter.headline_attribute` reads `"att"` (the last `"overall_att"`). Both schemas gain an additive key - DR a top-level `design`, BR `estimator.design` - `"2x2x2"` / `"staggered"` for `TripleDifferenceResults`, `None` otherwise. No version bump: `DIAGNOSTIC_REPORT_SCHEMA_VERSION` 3.0 and `BUSINESS_REPORT_SCHEMA_VERSION` 4.0 had not shipped (folded into the PR 5b bump). `power.py`'s staggered-boundary guard no longer keys on `aggregate` / `balance_e` (an `estimator_kwargs` carrying them surfaces as `fit()`'s `TypeError`). + +### Internal + +- Ledger rows `M-014`, `M-140`, `M-141` flip to `removed` with `tests/test_v4_unify_ddd.py` as their `test_ref`; `diff_diff/staggered_triple_diff_results.py` is renamed to `diff_diff/triple_diff_results.py` (hosting the unified container, re-exported from `triple_diff.py`); the staggered engine attaches the CallawaySantAnna aggregation kit (`staggered.py`'s `_build_aggregation_kit` on the eligibility-zeroed precomputed copy, plus the `ddd_df_survey_original` bookkeeping key for the Eq. 4.14 df rule); report consumers dispatch on the design through `_reporting_helpers.results_design` / `is_staggered_ddd` (DR's `_DESIGN_APPLICABILITY` / `_DESIGN_PT_METHOD`); the practitioner handler gains a staggered branch; `docs/api/_autosummary` regenerated; the spec's section 4.2 / section 6 / item-6 SHIPPED notes and the REGISTRY `## TripleDifference` post-fit Note record the mechanics and the `weights='event_time'` decision. diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index ef2e36776..4d9fdb300 100644 --- a/diff_diff/__init__.py +++ b/diff_diff/__init__.py @@ -253,9 +253,6 @@ CSBootstrapResults, GroupTimeEffect, ) -from diff_diff.staggered_triple_diff_results import ( - StaggeredTripleDiffResults, -) from diff_diff.sun_abraham import ( SABootstrapResults, SunAbraham, @@ -383,7 +380,6 @@ "TwoStageBootstrapResults", "SpilloverDiDResults", "TripleDifferenceResults", - "StaggeredTripleDiffResults", "TROPResults", "SyntheticControlResults", "StackedDiDResults", diff --git a/diff_diff/_reporting_helpers.py b/diff_diff/_reporting_helpers.py index de493df4e..67250a3cb 100644 --- a/diff_diff/_reporting_helpers.py +++ b/diff_diff/_reporting_helpers.py @@ -19,11 +19,29 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Optional from diff_diff.results_base import Diagnostic +def results_design(results: Any) -> Optional[str]: + """The fitted DESIGN of a results container that serves two designs from one class. + + Scoped to ``TripleDifferenceResults`` (4.0, row M-014: ``"2x2x2"`` or + ``"staggered"``); ``None`` for every other class. Scoped on purpose - the + HAD containers carry an unrelated ``design`` field (their dose-support + mode), which must not leak into the report schemas' ``design`` key. + """ + if type(results).__name__ != "TripleDifferenceResults": + return None + return getattr(results, "design", None) + + +def is_staggered_ddd(results: Any) -> bool: + """True for a ``TripleDifferenceResults`` fitted in the staggered design.""" + return results_design(results) == "staggered" + + def describe_target_parameter(results: Any) -> Dict[str, Any]: """Return the target-parameter block for a fitted result. @@ -423,6 +441,22 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: } if name == "TripleDifferenceResults": + # One container, two designs since 4.0 (row M-014): dispatch on the + # ``design`` discriminator, never on a class name. + if results_design(results) == "staggered": + return { + "name": "overall ATT (cohort-weighted staggered triple-difference)", + "definition": ( + "A cohort-weighted aggregate of per-(g, g_c, t) triple-" + "difference ATTs ``DDD(g, g_c, t)`` via the GMM optimal-" + "combination weighting. Extends the DDD cancellation to " + "staggered adoption timing with a third (eligibility) " + "dimension." + ), + "aggregation": "staggered_ddd", + "headline_attribute": "att", + "reference": "Ortiz-Villavicencio & Sant'Anna (2025); REGISTRY.md Sec. StaggeredTripleDifference", + } return { "name": "ATT (triple-difference)", "definition": ( @@ -437,21 +471,6 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "reference": "Ortiz-Villavicencio & Sant'Anna (2025); REGISTRY.md Sec. TripleDifference", } - if name == "StaggeredTripleDiffResults": - return { - "name": "overall ATT (cohort-weighted staggered triple-difference)", - "definition": ( - "A cohort-weighted aggregate of per-(g, g_c, t) triple-" - "difference ATTs ``DDD(g, g_c, t)`` via the GMM optimal-" - "combination weighting. Extends the DDD cancellation to " - "staggered adoption timing with a third (eligibility) " - "dimension." - ), - "aggregation": "staggered_ddd", - "headline_attribute": "overall_att", - "reference": "Ortiz-Villavicencio & Sant'Anna (2025); REGISTRY.md Sec. StaggeredTripleDifference", - } - if name == "ChaisemartinDHaultfoeuilleResults": l_max = getattr(results, "L_max", None) has_controls = getattr(results, "covariate_residuals", None) is not None diff --git a/diff_diff/_staggered_triple_diff_engine.py b/diff_diff/_staggered_triple_diff_engine.py index 27e07af58..3123ea578 100644 --- a/diff_diff/_staggered_triple_diff_engine.py +++ b/diff_diff/_staggered_triple_diff_engine.py @@ -26,7 +26,7 @@ class said "eligibility") """ import warnings -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast import numpy as np import pandas as pd @@ -36,7 +36,8 @@ class said "eligibility") _rank_guarded_inv, solve_logit, ) -from diff_diff.staggered_triple_diff_results import StaggeredTripleDiffResults +from diff_diff.staggered import _build_aggregation_kit +from diff_diff.triple_diff_results import TripleDifferenceResults from diff_diff.utils import safe_inference, validate_anticipation if TYPE_CHECKING: @@ -159,14 +160,12 @@ def _fit_staggered_core( first_treat: str, partition: str, covariates: Optional[List[str]] = None, - aggregate: Optional[str] = None, - balance_e: Optional[int] = None, survey_design: Optional["SurveyDesign"] = None, *, estimator_name: str, partition_label: str, _frame_offset: int = 0, - ) -> StaggeredTripleDiffResults: + ) -> TripleDifferenceResults: """Fit the staggered triple-difference estimator. The estimation body is the 3.x `StaggeredTripleDifference.fit` verbatim @@ -206,16 +205,6 @@ def _fit_staggered_core( f"weight_type='pweight', got '{resolved_survey.weight_type}'. " f"The survey variance math assumes probability weights." ) - 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', " - f"got '{aggregate}'" - ) df = data.copy() self._validate_inputs( @@ -606,10 +595,12 @@ def _fit_staggered_core( group_time_effects, influence_func_info, df_agg, unit, precomputed_agg ) # Preserve the ORIGINAL survey df before the simple-overall statistic mutates - # it below. The Eq. 4.14 overall (overall_att_es) must fall back to this - # original df, never to the simple overall's per-statistic replicate df — the - # two statistics can drop different replicate subsets, so reusing the simple - # overall's df would silently give overall_att_es the wrong p-value/CI. + # it below. The post-fit Eq. 4.14 overall (aggregate('simple', + # weights='event_time')) must fall back to this original df, never to the + # simple overall's per-statistic replicate df — the two statistics can drop + # different replicate subsets, so reusing the simple overall's df would + # silently give the Eq. 4.14 overall the wrong p-value/CI. Carried to the + # aggregation kit below. df_survey_original = df_survey # Use per-statistic effective df from replicate aggregation if available; # otherwise fall back to the original df from the survey design. @@ -621,81 +612,10 @@ def _fit_staggered_core( overall_att, overall_se, alpha=self.alpha, df=df_survey ) - # Aggregations - event_study_effects = None - group_effects = None - es_aggregation = None - if aggregate in ("event_study", "all"): - es_aggregation = self._aggregate_event_study( - group_time_effects, - influence_func_info, - treatment_groups, - time_periods, - balance_e, - df_agg, - unit, - precomputed_agg, - ) - event_study_effects = es_aggregation.effects - if aggregate in ("group", "all"): - group_effects = self._aggregate_by_group( - group_time_effects, - influence_func_info, - treatment_groups, - precomputed_agg, - df_agg, - unit, - ) - - # Paper Eq. (4.14) overall ATT (event-study average): an opt-in summary - # alongside the default CS-simple ``overall_att``. ``_aggregate_event_study`` - # RETURNS it on its aggregation object (it used to stash it on - # ``self._event_study_overall``); populated only when the event-study - # aggregation ran. Analytical inference here; the bootstrap block below - # overrides the SE when ``n_bootstrap > 0`` (mirroring ``overall_se``). - overall_att_es = None - overall_se_es = None - overall_t_stat_es = None - overall_p_value_es = None - overall_conf_int_es = None - # Whether the ANALYTICAL Eq. 4.14 SE was non-finite while its point estimate - # was finite (i.e. a contributing horizon's influence function was non-finite). - # Captured before any bootstrap override so the terminal warning below does not - # misdiagnose a bootstrap-side NaN SE (e.g. cluster-unidentified) as an - # analytical-IF failure (the bootstrap path emits its own warning). - analytical_overall_es_se_nonfinite = False - if aggregate in ("event_study", "all"): - es_overall = es_aggregation.overall if es_aggregation is not None else None - if es_overall is not None: - overall_att_es = es_overall["att"] - overall_se_es = es_overall["se"] - analytical_overall_es_se_nonfinite = bool( - np.isfinite(overall_att_es) and not np.isfinite(overall_se_es) - ) - es_eff_df = es_overall.get("effective_df") - # Fall back to the ORIGINAL survey df, not the simple-overall's mutated - # per-statistic df (P1 fix): overall_att_es has its own replicate df. - df_for_es = es_eff_df if es_eff_df is not None else df_survey_original - overall_t_stat_es, overall_p_value_es, overall_conf_int_es = safe_inference( - overall_att_es, overall_se_es, alpha=self.alpha, df=df_for_es - ) - else: - # Event-study aggregation was requested but yielded no post-treatment - # horizon: this is "requested but undefined" -> NaN + warning (the - # library's overall-aggregation contract, matching _aggregate_simple), - # distinct from "not requested" which leaves the fields None. - warnings.warn( - "Event-study aggregation was requested but no post-treatment " - "horizons are available for the Eq. 4.14 overall (overall_att_es); " - "returning NaN.", - UserWarning, - stacklevel=2 + _frame_offset, - ) - overall_att_es = np.nan - overall_se_es = np.nan - overall_t_stat_es, overall_p_value_es, overall_conf_int_es = safe_inference( - np.nan, np.nan, alpha=self.alpha, df=df_survey - ) + # Event-study / cohort aggregations and the paper's Eq. 4.14 overall are + # POST-FIT since 4.0 (rows M-014 / M-140 / M-141): results.aggregate() + # recomputes them from the retained kit (bootstrapped fits replay the + # multiplier bootstrap from the kit's RNG state). # Reject replicate-weight designs for bootstrap — replicate variance # is an analytical alternative, not compatible with bootstrap @@ -718,8 +638,8 @@ def _fit_staggered_core( bootstrap_results = self._run_multiplier_bootstrap( group_time_effects, influence_func_info, - aggregate, - balance_e, + None, + None, treatment_groups, time_periods, df_agg, @@ -735,22 +655,6 @@ def _fit_staggered_core( overall_conf_int = bootstrap_results.overall_att_ci overall_p_value = bootstrap_results.overall_att_p_value - # Mirror the override for the Eq. (4.14) event-study-average overall - # (only when event-study aggregation produced it). A NaN bootstrap SE - # (e.g. cluster-unidentified) correctly NaNs the inference. - if ( - overall_att_es is not None - and bootstrap_results.overall_att_es_se is not None - ): - overall_se_es = bootstrap_results.overall_att_es_se - overall_t_stat_es, overall_p_value_es, overall_conf_int_es = safe_inference( - overall_att_es, overall_se_es, alpha=self.alpha, df=df_survey - ) - overall_conf_int_es = bootstrap_results.overall_att_es_ci - overall_p_value_es = bootstrap_results.overall_att_es_p_value - if bootstrap_results.cband_crit_value is not None: - cband_crit_value = bootstrap_results.cband_crit_value - # Update group-time effects with bootstrap SEs if bootstrap_results.group_time_ses: for gt_key in group_time_effects: @@ -772,104 +676,30 @@ def _fit_staggered_core( ) group_time_effects[gt_key]["t_stat"] = t_val - if event_study_effects and bootstrap_results.event_study_ses: - for e_key in event_study_effects: - if e_key in bootstrap_results.event_study_ses: - # ses/cis/p_values are populated together. - assert ( - bootstrap_results.event_study_cis is not None - and bootstrap_results.event_study_p_values is not None - ) - event_study_effects[e_key]["se"] = ( - bootstrap_results.event_study_ses[e_key] - ) - event_study_effects[e_key]["conf_int"] = ( - bootstrap_results.event_study_cis[e_key] - ) - event_study_effects[e_key]["p_value"] = ( - bootstrap_results.event_study_p_values[e_key] - ) - t_val, _, _ = safe_inference( - event_study_effects[e_key]["effect"], - bootstrap_results.event_study_ses[e_key], - alpha=self.alpha, - df=df_survey, - ) - event_study_effects[e_key]["t_stat"] = t_val - if cband_crit_value is not None: - bs_se = bootstrap_results.event_study_ses[e_key] - eff = event_study_effects[e_key]["effect"] - event_study_effects[e_key]["cband_conf_int"] = ( - eff - cband_crit_value * bs_se, - eff + cband_crit_value * bs_se, - ) - - # Update group effects with bootstrap SEs - if ( - group_effects - and bootstrap_results.group_effect_ses is not None - and bootstrap_results.group_effect_cis is not None - and bootstrap_results.group_effect_p_values is not None - ): - grp_keys = [ - g for g in group_effects if g in bootstrap_results.group_effect_ses - ] - for g_key in grp_keys: - group_effects[g_key]["se"] = bootstrap_results.group_effect_ses[g_key] - group_effects[g_key]["conf_int"] = bootstrap_results.group_effect_cis[ - g_key - ] - group_effects[g_key]["p_value"] = ( - bootstrap_results.group_effect_p_values[g_key] - ) - t_val, _, _ = safe_inference( - group_effects[g_key]["effect"], - bootstrap_results.group_effect_ses[g_key], - alpha=self.alpha, - df=df_survey, - ) - group_effects[g_key]["t_stat"] = t_val - # Bootstrap se/p/CI replaced the analytical ones, which - # is what the retained df described - keeping it would - # claim a t-reference that governed nothing. - group_effects[g_key]["df_used"] = None - - # Eq. 4.14 overall: an ANALYTICAL non-finite SE under a finite point estimate - # (a contributing horizon's influence function is non-finite, or the variance is - # unidentified — e.g. a single-PSU/cluster design). Surface it — never NaN the SE - # silently. Gated on the analytical-origin flag (captured before the bootstrap - # override) and the final state still being non-finite: a bootstrap that supplies a - # finite SE rescues it (no warning), and a bootstrap that NaNs the SE for unrelated - # reasons (e.g. cluster-unidentified) is reported by the bootstrap's own warning, - # not misdiagnosed here as an analytical-IF failure. - if ( - analytical_overall_es_se_nonfinite - and overall_se_es is not None - and not np.isfinite(overall_se_es) - ): - warnings.warn( - "Eq. 4.14 overall (overall_att_es) point estimate is defined but its " - "standard error is undefined (NaN): either a contributing post-treatment " - "event-study horizon has a non-finite influence function, or the variance " - "is unidentified (e.g. a single-PSU/cluster survey design). overall_se_es " - "and its inference fields are NaN.", - UserWarning, - stacklevel=2 + _frame_offset, - ) - n_treated_units = int(np.sum((unit_cohorts > 0) & (eligibility_per_unit == 1))) n_control_units = n_units - n_treated_units n_never_enabled = int(np.sum(unit_cohorts == 0)) n_eligible = int(np.sum(eligibility_per_unit == 1)) n_ineligible = int(np.sum(eligibility_per_unit == 0)) - self.results_ = StaggeredTripleDiffResults( + results = TripleDifferenceResults( + att=overall_att, + se=overall_se, + t_stat=overall_t_stat, + p_value=overall_p_value, + conf_int=overall_conf_int, + n_obs=len(df), + estimation_method=self.estimation_method, + alpha=self.alpha, + inference_method="bootstrap" if bootstrap_results is not None else "analytical", + vcov_type=None, + n_bootstrap=self.n_bootstrap if bootstrap_results is not None else None, + survey_metadata=survey_metadata, + epv_diagnostics=epv_diagnostics if epv_diagnostics else None, + epv_threshold=self.epv_threshold, + pscore_fallback=self.pscore_fallback, + design="staggered", group_time_effects=group_time_effects, - overall_att=overall_att, - overall_se=overall_se, - overall_t_stat=overall_t_stat, - overall_p_value=overall_p_value, - overall_conf_int=overall_conf_int, # Cohorts that actually produced at least one (g, t) cell. A # cohort can drop out for several reasons (no eligible treated # units, no valid comparison cohort, every base period outside @@ -879,34 +709,47 @@ def _fit_staggered_core( # order-preserving against the sorted input roster. groups=[g for g in treatment_groups if g in {gt[0] for gt in group_time_effects}], time_periods=time_periods, - n_obs=len(df), n_treated_units=n_treated_units, n_control_units=n_control_units, n_never_enabled=n_never_enabled, n_eligible=n_eligible, n_ineligible=n_ineligible, - alpha=self.alpha, control_group=self.control_group, base_period=self.base_period, anticipation=self.anticipation, - estimation_method=self.estimation_method, - event_study_effects=event_study_effects, - group_effects=group_effects, + pscore_trim=self.pscore_trim, bootstrap_results=bootstrap_results, cband_crit_value=cband_crit_value, - pscore_trim=self.pscore_trim, - survey_metadata=survey_metadata, comparison_group_counts=comparison_group_counts, gmm_weights=gmm_weights_store, - epv_diagnostics=epv_diagnostics if epv_diagnostics else None, - epv_threshold=self.epv_threshold, - pscore_fallback=self.pscore_fallback, - overall_att_es=overall_att_es, - overall_se_es=overall_se_es, - overall_t_stat_es=overall_t_stat_es, - overall_p_value_es=overall_p_value_es, - overall_conf_int_es=overall_conf_int_es, ) + # Post-fit aggregation kit (spec section 6): built HERE because + # neither the eligibility-zeroed `precomputed_agg` nor + # `influence_func_info` survives fit(). The kit retains the ZEROED + # copy - the aggregation weights use the eligible-treated + # population P(S=g, Q=1), the REGISTRY "Deviation from R" - so a + # post-fit aggregate() reproduces the fit-time numbers. The builder + # is annotated for CallawaySantAnna but reads only alpha / + # anticipation / cband / _BOOTSTRAP_LABEL (the DMLDiD precedent). + kit = _build_aggregation_kit( + cast(Any, self), + precomputed_agg, + influence_func_info, + group_time_effects, + is_survey_fit=survey_metadata is not None, + bootstrap_results=bootstrap_results, + covariates=covariates, + ) + if kit is not None: + # The Eq. 4.14 overall's df rule needs the fit's sentinelled, + # PRE-mutation survey df (None on plain fits -> normal + # reference; the survey df on survey fits; 0 on replicate + # designs with undefined df). The whitelisted `df_survey` + # bookkeeping key is the RAW precomputed value, so carry the + # engine's resolved one as a fit-time derived key. + kit.bookkeeping["ddd_df_survey_original"] = df_survey_original + results._aggregation_kit = kit + self.results_ = results self.is_fitted_ = True return self.results_ finally: diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index 05ccac0ab..5a6ca7087 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -49,7 +49,11 @@ import numpy as np -from diff_diff._reporting_helpers import describe_target_parameter +from diff_diff._reporting_helpers import ( + describe_target_parameter, + is_staggered_ddd, + results_design, +) from diff_diff.diagnostic_report import DiagnosticReport, DiagnosticReportResults from diff_diff.results_base import Diagnostic, _coverage_level, _coverage_pct @@ -605,6 +609,10 @@ def _build_schema(self) -> Dict[str, Any]: "estimator": { "class_name": estimator_name, "display_name": estimator_name, + # Additive key (4.0, item 6): the fitted DESIGN of a container + # serving two designs from one class name ("2x2x2" / + # "staggered" for TripleDifferenceResults); None otherwise. + "design": results_design(self._results), }, "context": { "outcome_label": self._context.outcome_label, @@ -841,11 +849,11 @@ def _extract_sample(self) -> Dict[str, Any]: # total was being narrated as "control". Surface # ``n_never_enabled`` instead on both the ``nevertreated`` and # the dynamic ``notyettreated`` modes. - if name == "StaggeredTripleDiffResults" and _canonical_control == "nevertreated": + if is_staggered_ddd(r) and _canonical_control == "nevertreated": n_never_enabled = _safe_int(getattr(r, "n_never_enabled", None)) n_control = None if is_dynamic_control: - if name == "StaggeredTripleDiffResults": + if is_staggered_ddd(r): n_never_enabled = _safe_int(getattr(r, "n_never_enabled", None)) n_control = None elif name == "StackedDiDResults": @@ -1287,7 +1295,7 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str, "marginal effect ACRT(d), and cross-dose contrasts." ), } - if estimator_name in {"TripleDifferenceResults", "StaggeredTripleDiffResults"}: + if estimator_name == "TripleDifferenceResults": # Ortiz-Villavicencio & Sant'Anna (2025) — identification is the # triple-difference cancellation across the 2x2x2 cells, not # ordinary DiD parallel trends; see REGISTRY.md §TripleDifference @@ -2525,6 +2533,13 @@ def _render_summary(schema: Dict[str, Any]) -> str: # for the per-estimator dynamic-control phrasing branch below. estimator_block = schema.get("estimator") or {} estimator = estimator_block.get("class_name") if isinstance(estimator_block, dict) else None + # The staggered DDD narration keys on the DESIGN (4.0, row M-014): one + # class name serves both DDD designs. + staggered_ddd = ( + estimator == "TripleDifferenceResults" + and isinstance(estimator_block, dict) + and estimator_block.get("design") == "staggered" + ) n_obs = sample.get("n_obs") n_t = sample.get("n_treated") n_c = sample.get("n_control") @@ -2576,12 +2591,7 @@ def _render_summary(schema: Dict[str, Any]) -> str: "dynamic not-yet-treated comparison group (the control set " f"varies by cohort and period){subset_clause}." ) - elif ( - estimator == "StaggeredTripleDiffResults" - and isinstance(n_t, int) - and isinstance(n_ne, int) - and n_ne > 0 - ): + elif staggered_ddd and isinstance(n_t, int) and isinstance(n_ne, int) and n_ne > 0: # Round-38 P2 CI review on PR #318: StaggeredTripleDiff # under fixed ``control_group="never_treated"`` had the # schema moved to ``n_never_enabled`` (round-37) but the @@ -2778,6 +2788,11 @@ def _render_full_report(schema: Dict[str, Any]) -> str: estimator_name = ( estimator_block.get("class_name") if isinstance(estimator_block, dict) else None ) + md_staggered_ddd = ( + estimator_name == "TripleDifferenceResults" + and isinstance(estimator_block, dict) + and estimator_block.get("design") == "staggered" + ) cg = sample.get("control_group") # Panel-vs-RCS count-unit label for the full report. Mirrors the # summary path: CallawaySantAnna's ``panel=False`` mode stores @@ -2792,7 +2807,7 @@ def _render_full_report(schema: Dict[str, Any]) -> str: if isinstance(sample.get("n_control"), int): lines.append(f"- Control: {sample['n_control']:,}") elif ( - estimator_name == "StaggeredTripleDiffResults" + md_staggered_ddd and isinstance(sample.get("n_never_enabled"), int) and sample["n_never_enabled"] > 0 and not sample.get("dynamic_control") diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index 0bf040629..4eb1161f1 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -49,7 +49,11 @@ import numpy as np import pandas as pd -from diff_diff._reporting_helpers import describe_target_parameter # noqa: E402 (top-level import) +from diff_diff._reporting_helpers import ( # noqa: E402 (top-level import) + describe_target_parameter, + is_staggered_ddd, + results_design, +) from diff_diff.results_base import Diagnostic, _coverage_pct DIAGNOSTIC_REPORT_SCHEMA_VERSION = "3.0" @@ -89,7 +93,12 @@ # imports across the result modules. Renaming or aliasing any result class # requires updating both this table and ``_PT_METHOD`` below; the # applicability-matrix test parametrized over all result types serves as the -# regression guard. +# regression guard. A class that serves two DESIGNS from one name +# (``TripleDifferenceResults`` since 4.0, row M-014) keeps ONE class-name key +# here - the exhaustiveness guard in tests/test_target_parameter.py builds a +# stub from every key - and declares its design-specific sets in the +# ``_DESIGN_*`` tables below, read through ``_applicability_for`` / +# ``_pt_method_for``. # ``pretrends_power`` is restricted to the result families for which # ``compute_pretrends_power`` has an explicit adapter — see # ``diff_diff/pretrends.py`` around the result-type dispatch. Expanding @@ -234,7 +243,6 @@ ), "ContinuousDiDResults": frozenset({"design_effect", "heterogeneity"}), "TripleDifferenceResults": frozenset({"design_effect", "epv"}), - "StaggeredTripleDiffResults": frozenset({"parallel_trends", "design_effect"}), "WooldridgeDiDResults": frozenset( { "parallel_trends", @@ -254,6 +262,42 @@ "BaconDecompositionResults": frozenset({"bacon"}), } +# Design-specific applicability for the containers that serve two designs +# from one class name, keyed ``(class_name, design)``. ``TripleDifferenceResults`` +# (4.0, row M-014): the 2x2x2 design keeps the class-name row above (no +# event-study surface, so no parallel-trends check); the staggered design +# carries the former ``StaggeredTripleDiffResults`` sets. +_DESIGN_APPLICABILITY: Dict[Tuple[str, str], FrozenSet[str]] = { + ("TripleDifferenceResults", "staggered"): frozenset({"parallel_trends", "design_effect"}), +} + + +def _applicability_for(results: Any) -> FrozenSet[str]: + """Type- and design-level applicability set for ``results``.""" + name = type(results).__name__ + design = results_design(results) + if design is not None and (name, design) in _DESIGN_APPLICABILITY: + return _DESIGN_APPLICABILITY[(name, design)] + return _APPLICABILITY.get(name, frozenset()) + + +# Design-specific parallel-trends method (see ``_DESIGN_APPLICABILITY``): the +# staggered DDD design derives its surface post-fit through +# ``results.aggregate('event_study')``; the 2x2x2 design has no PT row. +_DESIGN_PT_METHOD: Dict[Tuple[str, str], str] = { + ("TripleDifferenceResults", "staggered"): "event_study", +} + + +def _pt_method_for(results: Any) -> Optional[str]: + """Type- and design-level parallel-trends method for ``results``.""" + name = type(results).__name__ + design = results_design(results) + if design is not None and (name, design) in _DESIGN_PT_METHOD: + return _DESIGN_PT_METHOD[(name, design)] + return _PT_METHOD.get(name) + + # Per-type parallel-trends method. The PT check dispatches internally on this. # Values: # "two_x_two" — uses utils.check_parallel_trends (requires ``data``) @@ -273,7 +317,6 @@ "StackedDiDResults": "event_study", "EfficientDiDResults": "hausman", "ContinuousDiDResults": "event_study", - "StaggeredTripleDiffResults": "event_study", "WooldridgeDiDResults": "event_study", "ChaisemartinDHaultfoeuilleResults": "event_study", "SyntheticDiDResults": "synthetic_fit", @@ -721,8 +764,7 @@ def _compute_applicable_checks(self) -> Tuple[set, Dict[str, str]]: opt-out. Checks that are not type-applicable for this estimator are omitted from both sets (not surfaced as "skipped"). """ - type_name = type(self._results).__name__ - type_level = set(_APPLICABILITY.get(type_name, frozenset())) + type_level = set(_applicability_for(self._results)) # A precomputed passthrough is a caller-supplied override, not # a claim about estimator-native applicability. Round-31 P1 CI # review on PR #318: when a caller passes @@ -839,17 +881,6 @@ def _resolve_event_study_surface( "place; call it on the results object, then re-run the " "report." ) - elif name == "StaggeredTripleDiffResults": - # Fit-time aggregate= is the CANONICAL route here (ledger row - # M-140, not deprecated); the container gains post-fit - # aggregate() with the M-014 unification at 4.0. - why = ( - f"{prefix}. Re-fit with fit(..., aggregate='event_study') on " - "TripleDifference's staggered mode to populate the " - "per-event-time output - the fit-time kwarg is the canonical " - "event-study route for staggered triple-difference (its " - "results container gains post-fit aggregate() at 4.0)." - ) elif "event_study" not in getattr(type(r), "_AGGREGATE_SUPPORTED", ()): why = ( f"{prefix}, and {name} does not support post-fit " @@ -934,7 +965,7 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: # advertised but skipped before use. if "parallel_trends" in self._precomputed: return None - method = _PT_METHOD.get(name) + method = _pt_method_for(r) if method == "two_x_two": # Mirror the full argument contract of ``_pt_two_x_two``: # the runner needs ``data`` AND all three column names to @@ -1477,6 +1508,10 @@ def _execute(self) -> DiagnosticReportResults: schema: Dict[str, Any] = { "schema_version": DIAGNOSTIC_REPORT_SCHEMA_VERSION, "estimator": type(self._results).__name__, + # Additive key (4.0, item 6): the fitted DESIGN of a container that + # serves two designs from one class name ("2x2x2" / "staggered" + # for TripleDifferenceResults); None for every other estimator. + "design": results_design(self._results), "headline_metric": headline, "target_parameter": describe_target_parameter(self._results), "parallel_trends": sections["parallel_trends"], @@ -1588,7 +1623,7 @@ def _check_parallel_trends(self) -> Dict[str, Any]: if "parallel_trends" in self._precomputed: return self._format_precomputed_pt(self._precomputed["parallel_trends"]) - method = _PT_METHOD.get(type(self._results).__name__) + method = _pt_method_for(self._results) if method == "two_x_two": return self._pt_two_x_two() if method == "event_study": @@ -2192,12 +2227,11 @@ def _infer_cov_source(source_fit: Any) -> str: "SunAbrahamResults", "ImputationDiDResults", "StackedDiDResults", - "StaggeredTripleDiffResults", "WooldridgeDiDResults", "ChaisemartinDHaultfoeuilleResults", "EfficientDiDResults", "TwoStageDiDResults", - } + } or is_staggered_ddd(source_fit) has_full_es_vcov = ( getattr(source_fit, "event_study_vcov", None) is not None and getattr(source_fit, "event_study_vcov_index", None) is not None @@ -3374,8 +3408,10 @@ def _collect_effect_scalars(self) -> List[float]: # (g, t) -> dict. Filter to t >= g (post-treatment cells); the # pre-treatment cells (t < g) are identification-deviation # placebos, not effect heterogeneity. + # Truthiness, not ``is not None``: the unified TripleDifferenceResults + # carries an EMPTY dict in its 2x2x2 design (no cells to read). gte = getattr(r, "group_time_effects", None) - if gte is not None: + if gte: post_cells: List[float] = [] try: items = list(gte.items()) @@ -3468,7 +3504,7 @@ def _heterogeneity_source(self) -> str: return "event_study_effects_post" if getattr(r, "treatment_effects", None) is not None: return "treatment_effects" - if getattr(r, "group_time_effects", None) is not None: + if getattr(r, "group_time_effects", None): return "group_time_effects_post" return "unknown" @@ -3845,9 +3881,9 @@ def _extract_scalar_headline( ``conf_int`` natively, so the table has a single row; the 3.x ``overall_*`` / ``overall_att_*`` row was deleted at the cut (reading it would now emit the containers' FutureWarning shims). The returned - ``name`` is therefore ``"att"`` for every scalar-headline estimator, - including ``StaggeredTripleDiffResults``, whose canonical names are - silent forwarding properties over its unflipped ``overall_*`` storage. + ``name`` is therefore ``"att"`` for every scalar-headline estimator (the + last ``overall_*`` container, ``StaggeredTripleDiffResults``, was retired + by the 4.0 DDD container unification, row M-014). Each row in the attribute-alias table below is tried in priority order. The first point-estimate attribute that resolves to a non-None value diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 4b72ecb74..a98ca29ce 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -1213,8 +1213,10 @@ Top-level keys emitted by `BusinessReport.to_dict()` (source: `diff_diff/business_report.py`): - `schema_version: str` - `BUSINESS_REPORT_SCHEMA_VERSION`, e.g. `"4.0"`. -- `estimator: dict` - `class_name` (the fitted result class) and a - human-friendly `display_name`. +- `estimator: dict` - `class_name` (the fitted result class), a + human-friendly `display_name`, and `design` (`"2x2x2"` / `"staggered"` + for `TripleDifferenceResults`, which serves both DDD designs since 4.0; + `None` for every other estimator). - `context: dict` - the `BusinessContext` bundle: `outcome_label`, `outcome_unit`, `outcome_direction`, `business_question`, `treatment_label`, `alpha`. @@ -1254,6 +1256,9 @@ Top-level keys (source: `diff_diff/diagnostic_report.py`): - `schema_version: str` - `DIAGNOSTIC_REPORT_SCHEMA_VERSION`. - `estimator: str` - the fitted result class name. +- `design: str | None` - `"2x2x2"` / `"staggered"` for + `TripleDifferenceResults` (both DDD designs share the class since 4.0); + `None` for every other estimator. - `headline_metric: dict` - the main scalar the report headlines. - `target_parameter: dict` - same shape as the BR field above. - `parallel_trends: dict` - PT test result. diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index fef74b48a..9b85c24c6 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -728,10 +728,8 @@ ddd.fit( 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 +) -> TripleDifferenceResults # design == "staggered"; aggregation is post-fit ``` ```python @@ -739,9 +737,11 @@ 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') + first_treat='ft', partition='eligible') results.print_summary() +es = results.aggregate('event_study') # post-fit; replays the bootstrap +cohorts = results.aggregate('group') +eq414 = results.aggregate('simple', weights='event_time') # paper Eq. 4.14 overall ``` ### ContinuousDiD @@ -1905,11 +1905,14 @@ Returned by `SyntheticControl.fit()`. ### TripleDifferenceResults -Returned by `TripleDifference.fit()`. +Returned by `TripleDifference.fit()` in BOTH designs (4.0, row M-014); `design` says which. +The 2x2x2 design is the degenerate single-ATT view (empty `group_time_effects`); the +staggered design's event-study / cohort surfaces are post-fit (`aggregate()`). | Attribute | Type | Description | |-----------|------|-------------| -| `att` | `float` | ATT estimate | +| `design` | `str` | `"2x2x2"` or `"staggered"` | +| `att` | `float` | ATT estimate (staggered: the cohort-size-weighted post-treatment average, the CS "simple" convention) | | `se` | `float` | Standard error | | `t_stat` | `float` | T-statistic | | `p_value` | `float` | P-value | @@ -1923,8 +1926,16 @@ Returned by `TripleDifference.fit()`. | `group_means` | `dict` | Cell means | | `pscore_stats` | `dict` | Propensity score diagnostics | | `r_squared` | `float` | R-squared (for "reg") | +| `df_inference` | `float \| None` | t df that governed `att`'s inference when no survey design carries it (2x2x2: `n_obs - 8 - len(covariates)`; staggered plain fits: None, normal reference) | +| `inference_method` | `str` | `"analytical"` or `"bootstrap"` (staggered, `n_bootstrap > 0`; `n_bootstrap` records the draws) | +| `group_time_effects` | `dict` | Staggered: `(group, time) -> {effect, se, t_stat, p_value, conf_int, ...}`; `{}` in 2x2x2 | +| `groups`, `time_periods` | `list` | Staggered: enabling cohorts that produced cells; calendar periods | +| `n_treated_units`, `n_control_units`, `n_never_enabled`, `n_eligible`, `n_ineligible` | `int \| None` | Staggered unit counts (None in 2x2x2) | +| `control_group`, `base_period`, `anticipation`, `pscore_trim` | | Staggered engine configuration (`pscore_trim` in both designs) | +| `bootstrap_results` | `CSBootstrapResults \| None` | Staggered multiplier-bootstrap payload | +| `epv_diagnostics` | `dict \| None` | Keyed by subgroup (2x2x2) or `(g, t)` cell (staggered) | -**Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()` +**Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe(level=None)` (`'overall'` one-row scalars, both designs; `'group_time'` cells, staggered), `epv_summary()`, `aggregate(type, weights=None, *, balance_e=None)` - `'simple'` relays the stored headline in both designs (one-row `AggregationResult`; `weights='event_time'` gives the paper's Eq. 4.14 overall, the unweighted mean of post-treatment ES(e), accepting `balance_e=`); `'event_study'` (`EventStudyResults`, `balance_e=`) and `'group'` (`AggregationResult`) are recomputed from the retained kit in the staggered design - bootstrapped fits REPLAY the fit-time multiplier bootstrap - and raise in the 2x2x2 design. No `'total'`. The 3.x `StaggeredTripleDiffResults`, its `overall_*` fields and the fit-time `aggregate=` / `balance_e=` keywords were removed at 4.0 (rows M-014 / M-140 / M-141). ### BaconDecompositionResults @@ -3055,7 +3066,7 @@ dr.applicable_checks # tuple of checks that will run for this estimator dr.skipped_checks # dict of {check: plain-English reason} ``` -Schema top-level keys: `schema_version, estimator, headline_metric, +Schema top-level keys: `schema_version, estimator, design, headline_metric, target_parameter, parallel_trends, pretrends_power, sensitivity, placebo, bacon, design_effect, heterogeneity, epv, estimator_native_diagnostics, skipped, warnings, diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index 4ac55f68c..3f435fd6a 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -67,7 +67,7 @@ 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 (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' +- [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'. Both designs return ONE `TripleDifferenceResults` (`design` says which; 4.0); in the staggered design the event-study / cohort surfaces are post-fit `results.aggregate('event_study')` / `.aggregate('group')` (bootstrapped fits replay the fit-time bootstrap) and the paper's Eq. 4.14 overall is `.aggregate('simple', weights='event_time')` - [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`. diff --git a/diff_diff/power.py b/diff_diff/power.py index 48cbd1af9..22817ce05 100644 --- a/diff_diff/power.py +++ b/diff_diff/power.py @@ -567,18 +567,16 @@ def _extract_simple(result: Any) -> Tuple[float, float, float, Tuple[float, floa # channel, so these can genuinely flip the merged class into staggered mode # against 2x2x2 data - the one path that reaches the trigger at all. # -# The two tuples encode DIFFERENT tests, mirroring `TripleDifference.fit()` -# exactly. `first_treat` and `unit` are sentinel-defaulted there -# (`x is not NOT_SUPPLIED`), so SUPPLYING them at all is the signal - an -# explicit `first_treat=None` still selects staggered mode and then fails on a -# missing column. `aggregate`/`balance_e` default to None and fit only rejects a -# NON-None value, so keying on presence here would reject -# `estimator_kwargs={"aggregate": None}` - a config `fit()` accepts - and break -# the documented "legal to fit implies legal to simulate" boundary. +# The test mirrors `TripleDifference.fit()` exactly: `first_treat` and `unit` +# are sentinel-defaulted there (`x is not NOT_SUPPLIED`), so SUPPLYING them at +# all is the signal - an explicit `first_treat=None` still selects staggered +# mode and then fails on a missing column. (Through 3.x the fit-time +# `aggregate`/`balance_e` keywords were a second, by-VALUE pair; they were +# removed at 4.0 with rows M-140/M-141 - aggregation is post-fit - so an +# `estimator_kwargs={"aggregate": ...}` now surfaces as fit()'s own TypeError.) _DDD_STAGGERED_FIT_KEYS_BY_PRESENCE = ("first_treat", "unit") -_DDD_STAGGERED_FIT_KEYS_BY_VALUE = ("aggregate", "balance_e") -# Retained as the union for callers/tests that want the whole roster. -_DDD_STAGGERED_FIT_KEYS = _DDD_STAGGERED_FIT_KEYS_BY_PRESENCE + _DDD_STAGGERED_FIT_KEYS_BY_VALUE +# Retained as the whole roster for callers/tests. +_DDD_STAGGERED_FIT_KEYS = _DDD_STAGGERED_FIT_KEYS_BY_PRESENCE def _reject_staggered_ddd_config(estimator: Any, est_kwargs: Dict[str, Any]) -> None: @@ -595,7 +593,6 @@ def _reject_staggered_ddd_config(estimator: Any, est_kwargs: Dict[str, Any]) -> return offenders = staggered_ddd_ctor_offenders(estimator) offenders += [k for k in _DDD_STAGGERED_FIT_KEYS_BY_PRESENCE if k in est_kwargs] - offenders += [k for k in _DDD_STAGGERED_FIT_KEYS_BY_VALUE if est_kwargs.get(k) is not None] if not offenders: return raise ValueError( diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index 8585c58e4..1dd29bc57 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -1228,6 +1228,10 @@ def _handle_continuous(results: Any): def _handle_triple(results: Any): + # One container serves both DDD designs since 4.0 (row M-014); the + # staggered design has post-fit event-study / cohort surfaces to point at. + if getattr(results, "design", None) == "staggered": + return _handle_triple_staggered(results) steps = [ _step( baker_step=3, @@ -1264,6 +1268,63 @@ def _handle_triple(results: Any): return steps, warnings +def _handle_triple_staggered(results: Any): + """Staggered DDD design (``TripleDifference.fit(..., first_treat=)``).""" + is_bootstrap = getattr(results, "bootstrap_results", None) is not None + es_note = ( + " This fit is BOOTSTRAPPED: aggregate() replays the fit-time multiplier " + "bootstrap post-fit (percentile inference; no refit needed)." + if is_bootstrap + else "" + ) + steps = [ + _step( + baker_step=3, + label="Inspect pre-treatment event-study horizons", + why=( + "The staggered DDD identifying assumption is the triple-difference " + "cancellation across cohorts; the pre-treatment ATT(g, t) horizons are " + "its placebo check. Aggregate the event study post-fit - the fit-time " + "aggregate= keyword was removed at 4.0." + es_note + ), + code=( + "es = results.aggregate('event_study')\n" + "print(es.to_dataframe()) # pre-treatment rows should be ~0" + ), + step_name="parallel_trends", + ), + _step( + baker_step=5, + label="Report cohort heterogeneity", + why=( + "Cohort-specific effects reveal whether the overall ATT masks " + "heterogeneity across enabling cohorts. Post-fit, no refit needed." + es_note + ), + code=( + "print(results.aggregate('group').to_dataframe()) # Per-cohort ATTs\n" + "print(results.aggregate('event_study').to_dataframe()) # Dynamic effects\n" + "# The paper's Eq. 4.14 overall (unweighted mean of post-treatment ES(e)):\n" + "print(results.aggregate('simple', weights='event_time').to_dataframe())" + ), + step_name="heterogeneity", + ), + _step( + baker_step=7, + label="Test placebo partition", + why=( + "Re-estimate using a placebo eligibility partition to check " + "whether the DDD result could be an artifact of the " + "partition structure rather than the treatment." + ), + code="# Re-estimate with a placebo eligibility partition", + step_name="heterogeneity", + ), + _covariates_step(), + ] + warnings = _check_nan_att(results) + return steps, warnings + + def _handle_bacon(results: Any): steps = [ _step( diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index 023e44d7b..fcc92a4ee 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -68,10 +68,10 @@ class BaseResults: ``conf_int`` bound to ONE coherent inference row. Native fields on every estimator container since the 4.0 storage flip (ledger rows M-050..M-058; the legacy ``overall_*`` names live on as read-only - FutureWarning properties until 5.0) EXCEPT - :class:`StaggeredTripleDiffResults`, which keeps ``overall_*`` - storage behind property aliases until the item-6 container - unification retires it. + FutureWarning properties until 5.0); the last ``overall_*`` container, + ``StaggeredTripleDiffResults``, was retired by the item-6 DDD + container unification (row M-014) in favour of the canonical + :class:`~diff_diff.TripleDifferenceResults`. - **Serialization.** ``summary(alpha=None)``, ``to_dict()`` (canonical key names only - deprecated names never leak into serialized output), and ``to_dataframe(level=...)`` where multiple views exist. @@ -889,7 +889,12 @@ def summary(self, alpha: Optional[float] = None) -> str: # Absence only possible on pre-3.9 pickles: 3.9+ fits always # materialize the surface (row M-024). "StackedDiDResults": "re-fit with diff-diff >= 3.9, which always computes the surface", - "StaggeredTripleDiffResults": "refit with aggregate='event_study' (or 'all')", + # Post-fit since 4.0 (rows M-014/M-140/M-141); the 2x2x2 design has no + # event-study surface at all. + "TripleDifferenceResults": ( + "call results.aggregate('event_study') (staggered design only - the 2x2x2 " + "design has a single ATT)" + ), "EfficientDiDResults": "call results.aggregate('event_study')", "ContinuousDiDResults": ( "call results.aggregate('event_study') (a bootstrapped fit from before " @@ -1073,7 +1078,8 @@ def _from_relative_dict(results: Any) -> EventStudyResults: Covers CallawaySantAnna, DMLDiD (via its inherited CS container channel), SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, SpilloverDiD, ContinuousDiD, EfficientDiD, Wooldridge - (``att`` inner key normalized here), and StaggeredTripleDiff. + (``att`` inner key normalized here), and TripleDifference's staggered + design (through the carrier its ``aggregate('event_study')`` builds). Reference resolution is PRODUCER-AWARE, because a zero count is not a universal reference marker: diff --git a/diff_diff/staggered_aggregation.py b/diff_diff/staggered_aggregation.py index 2992d0594..cfa47b796 100644 --- a/diff_diff/staggered_aggregation.py +++ b/diff_diff/staggered_aggregation.py @@ -33,8 +33,9 @@ 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 - TripleDifference's staggered mode as ``overall_att_es``; CallawaySantAnna - leaves it unread. ``None`` when no post-treatment horizon qualifies. + TripleDifferenceResults.aggregate('simple', weights='event_time'); + 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 governs a t-reference (finite and > 0). Provenance for @@ -1185,10 +1186,11 @@ def _aggregate_event_study( event_study_vcov_index = valid_event_times if event_study_vcov is not None else None # 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 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), + # event-study effects ES(e). Returned on the aggregation object so the + # unified TripleDifferenceResults can expose it post-fit as + # aggregate('simple', weights='event_time'); CallawaySantAnna leaves it + # unread. Post-treatment is the library predicate + # e >= -anticipation (matching _aggregate_simple and the default att), # NOT a hardcoded e >= 0. # # The POINT ESTIMATE averages EVERY finite post-treatment ES(e) effect (read diff --git a/diff_diff/staggered_bootstrap.py b/diff_diff/staggered_bootstrap.py index 661dd6eb8..97445dfe7 100644 --- a/diff_diff/staggered_bootstrap.py +++ b/diff_diff/staggered_bootstrap.py @@ -679,7 +679,8 @@ def _make_weight_iter( event_study_vcov = None event_study_vcov_index = None # Paper Eq. (4.14) overall (event-study average) bootstrap inference; stays - # NaN unless event-study draws exist. Mirrors the analytical overall_att_es. + # NaN unless event-study draws exist. Mirrors the analytical Eq. 4.14 + # overall (TripleDifferenceResults.aggregate('simple', weights='event_time')). overall_att_es_se = np.nan overall_att_es_ci = (np.nan, np.nan) overall_att_es_p_value = np.nan diff --git a/diff_diff/staggered_triple_diff_results.py b/diff_diff/staggered_triple_diff_results.py deleted file mode 100644 index 0f0c109f4..000000000 --- a/diff_diff/staggered_triple_diff_results.py +++ /dev/null @@ -1,495 +0,0 @@ -""" -Result container classes for Staggered Triple Difference estimator. - -This module provides dataclass containers for storing and presenting -group-time DDD effects and their aggregations. -""" - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple - -import numpy as np -import pandas as pd - -from diff_diff.results import _format_survey_block, _get_significance_stars -from diff_diff.results_base import BaseResults, _coverage_pct, _require_fit_alpha - -if TYPE_CHECKING: - from diff_diff.staggered_bootstrap import CSBootstrapResults - - -@dataclass -class StaggeredTripleDiffResults(BaseResults): - """ - Results from Staggered Triple Difference (DDD) estimation. - - Implements the Ortiz-Villavicencio & Sant'Anna (2025) estimator for - staggered adoption settings with an eligibility dimension. - - Attributes - ---------- - group_time_effects : dict - Dictionary mapping (group, time) tuples to effect dictionaries. - overall_att : float - Overall average treatment effect (weighted average of ATT(g,t)). - overall_se : float - Standard error of overall ATT. - overall_t_stat : float - T-statistic for overall ATT. - overall_p_value : float - P-value for overall ATT. - overall_conf_int : tuple - Confidence interval for overall ATT. - groups : list - List of enabling cohorts (first treatment periods). - time_periods : list - List of all time periods. - n_obs : int - Total number of observations. - n_treated_units : int - Number of treated units (S < inf AND Q = 1). - n_control_units : int - Number of units not in treated group. - n_never_enabled : int - Number of never-enabled units (S = inf or 0). - n_eligible : int - Number of eligible units (Q = 1). - n_ineligible : int - Number of ineligible units (Q = 0). - overall_att_es : float, optional - Paper Eq. (4.14) "overall" ATT: the unweighted mean of the post-treatment - event-study effects ES(e). Populated only when ``aggregate`` is - ``"event_study"`` or ``"all"``; ``None`` otherwise. Distinct from - ``overall_att`` (the Callaway-Sant'Anna simple post-treatment (g,t) average, - which is the default headline ATT). - overall_se_es, overall_t_stat_es, overall_p_value_es, overall_conf_int_es : optional - Standard error, t-statistic, p-value, and confidence interval for - ``overall_att_es``; ``None`` when ``overall_att_es`` is ``None``. - """ - - group_time_effects: Dict[Tuple[Any, Any], Dict[str, Any]] - overall_att: float - overall_se: float - overall_t_stat: float - overall_p_value: float - overall_conf_int: Tuple[float, float] - groups: List[Any] - time_periods: List[Any] - n_obs: int - n_treated_units: int - n_control_units: int - n_never_enabled: int - n_eligible: int - n_ineligible: int - alpha: float = 0.05 - control_group: str = "notyettreated" - base_period: str = "varying" - # Anticipation periods (``k``) used at fit time. Persisted so - # downstream diagnostics in ``BusinessReport`` / ``DiagnosticReport`` - # can render the anticipation-aware assumption block and - # horizon-classification cutoffs accurately on real fits. - anticipation: int = 0 - estimation_method: str = "dr" - event_study_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None) - group_effects: Optional[Dict[Any, Dict[str, Any]]] = field(default=None) - influence_functions: Optional["np.ndarray"] = field(default=None, repr=False) - bootstrap_results: Optional["CSBootstrapResults"] = field(default=None, repr=False) - cband_crit_value: Optional[float] = None - pscore_trim: float = 0.01 - survey_metadata: Optional[Any] = field(default=None, repr=False) - comparison_group_counts: Optional[Dict[Tuple, int]] = field(default=None, repr=False) - gmm_weights: Optional[Dict[Tuple, Dict]] = field(default=None, repr=False) - epv_diagnostics: Optional[Dict[Tuple[Any, Any], Dict[str, Any]]] = field( - default=None, repr=False - ) - epv_threshold: float = 10 - pscore_fallback: str = "error" - # Paper Eq. (4.14) "overall" ATT: the unweighted mean of the post-treatment - # event-study effects ES(e). Populated only when aggregate in {"event_study", - # "all"}; None otherwise. Distinct from the default ``overall_att`` (the - # Callaway-Sant'Anna simple post-treatment (g,t) average). See REGISTRY - # ## StaggeredTripleDifference "Aggregation". - overall_att_es: Optional[float] = None - overall_se_es: Optional[float] = None - overall_t_stat_es: Optional[float] = None - overall_p_value_es: Optional[float] = None - overall_conf_int_es: Optional[Tuple[float, float]] = None - - # --- Inference-field aliases (balance/external-adapter compatibility) --- - @property - def att(self) -> float: - return self.overall_att - - @property - def se(self) -> float: - return self.overall_se - - @property - def conf_int(self) -> Tuple[float, float]: - return self.overall_conf_int - - @property - def p_value(self) -> float: - return self.overall_p_value - - @property - def t_stat(self) -> float: - return self.overall_t_stat - - def __repr__(self) -> str: - """Concise string representation.""" - sig = _get_significance_stars(self.overall_p_value) - return ( - f"StaggeredTripleDiffResults(ATT={self.overall_att:.4f}{sig}, " - f"SE={self.overall_se:.4f}, " - f"n_groups={len(self.groups)}, " - f"n_periods={len(self.time_periods)})" - ) - - @property - def coef_var(self) -> float: - """Coefficient of variation: SE / abs(overall ATT). NaN when ATT is 0 or SE non-finite.""" - if not (np.isfinite(self.overall_se) and self.overall_se >= 0): - return np.nan - if not np.isfinite(self.overall_att) or self.overall_att == 0: - return np.nan - return self.overall_se / abs(self.overall_att) - - def summary(self, alpha: Optional[float] = None) -> str: - """ - Generate formatted summary of estimation results. - - Parameters - ---------- - alpha : float, optional - Accepted for signature uniformity. The stored intervals were - computed at fit time; a value different from the stored - ``alpha`` raises ValueError rather than silently recomputing - or relabeling (bootstrap percentile intervals cannot be - reconstructed from the reported SE). Re-fit at the desired - alpha instead. - - Returns - ------- - str - Formatted summary. - """ - alpha = _require_fit_alpha(alpha, self.alpha) - conf_level = _coverage_pct(alpha) - - lines = [ - "=" * 85, - "Staggered Triple Difference (DDD) Results".center(85), - "=" * 85, - "", - f"{'Total observations:':<30} {self.n_obs:>10}", - f"{'Treated units (S10}", - f"{'Control units:':<30} {self.n_control_units:>10}", - f"{'Never-enabled units:':<30} {self.n_never_enabled:>10}", - f"{'Eligible units (Q=1):':<30} {self.n_eligible:>10}", - f"{'Ineligible units (Q=0):':<30} {self.n_ineligible:>10}", - f"{'Enabling cohorts:':<30} {len(self.groups):>10}", - f"{'Time periods:':<30} {len(self.time_periods):>10}", - f"{'Estimation method:':<30} {self.estimation_method:>10}", - f"{'Control group:':<30} {self.control_group:>10}", - f"{'Base period:':<30} {self.base_period:>10}", - "", - ] - - if self.survey_metadata is not None: - sm = self.survey_metadata - lines.extend(_format_survey_block(sm, 85)) - - # Overall ATT - lines.extend( - [ - "-" * 85, - "Overall Average Treatment Effect on the Treated".center(85), - "-" * 85, - f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} " - f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}", - "-" * 85, - f"{'ATT':<15} {self.overall_att:>12.4f} {self.overall_se:>12.4f} " - f"{self.overall_t_stat:>10.3f} {self.overall_p_value:>10.4f} " - f"{_get_significance_stars(self.overall_p_value):>6}", - "-" * 85, - "", - f"{conf_level}% Confidence Interval: " - f"[{self.overall_conf_int[0]:.4f}, {self.overall_conf_int[1]:.4f}]", - ] - ) - - # Paper Eq. (4.14) overall (event-study average), when computed - # (aggregate in {"event_study", "all"}). The headline ATT above remains the - # Callaway-Sant'Anna simple post-treatment (g,t) average. - if ( - self.overall_att_es is not None - and self.overall_se_es is not None - and self.overall_conf_int_es is not None - ): - p_es = self.overall_p_value_es if self.overall_p_value_es is not None else float("nan") - t_es = self.overall_t_stat_es if self.overall_t_stat_es is not None else float("nan") - sig_es = _get_significance_stars(p_es) - lines.extend( - [ - "", - "Overall ATT (event-study average, paper Eq. 4.14):", - f"{'ATT':<15} {self.overall_att_es:>12.4f} {self.overall_se_es:>12.4f} " - f"{t_es:>10.3f} {p_es:>10.4f} {sig_es:>6}", - f"{conf_level}% Confidence Interval: " - f"[{self.overall_conf_int_es[0]:.4f}, {self.overall_conf_int_es[1]:.4f}]", - ] - ) - - cv = self.coef_var - if np.isfinite(cv): - lines.append(f"{'CV (SE/abs(ATT)):':<25} {cv:>10.4f}") - - lines.append("") - - # EPV diagnostics block (if any cohort has low EPV) - if self.epv_diagnostics: - low_epv = {k: v for k, v in self.epv_diagnostics.items() if v.get("is_low")} - if low_epv: - n_affected = len(low_epv) - n_total = len(self.epv_diagnostics) - min_entry = min(low_epv.values(), key=lambda v: v["epv"]) - min_g = min(low_epv.keys(), key=lambda k: low_epv[k]["epv"]) - lines.extend( - [ - "-" * 85, - "Propensity Score Diagnostics".center(85), - "-" * 85, - f"WARNING: Low Events Per Variable (EPV) in " - f"{n_affected} of {n_total} cohort-time cell(s).", - f"Minimum EPV: {min_entry['epv']:.1f} " - f"(cohort g={min_g[0]}). " - f"Threshold: {self.epv_threshold:.0f}.", - "Consider: estimation_method='reg' or fewer covariates.", - "Call results.epv_summary() for per-cohort details.", - "-" * 85, - "", - ] - ) - - # Event study effects - if self.event_study_effects: - ci_label = "Simult. CI" if self.cband_crit_value is not None else "Pointwise CI" - lines.extend( - [ - "-" * 85, - "Event Study (Dynamic) Effects".center(85), - "-" * 85, - f"{'Rel. Period':<15} {'Estimate':>12} {'Std. Err.':>12} " - f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}", - "-" * 85, - ] - ) - - for rel_t in sorted(self.event_study_effects.keys()): - eff = self.event_study_effects[rel_t] - sig = _get_significance_stars(eff["p_value"]) - lines.append( - f"{rel_t:<15} {eff['effect']:>12.4f} {eff['se']:>12.4f} " - f"{eff['t_stat']:>10.3f} {eff['p_value']:>10.4f} {sig:>6}" - ) - - lines.extend(["-" * 85]) - if self.cband_crit_value is not None: - lines.append( - f"{ci_label}: critical value = {self.cband_crit_value:.4f} " - f"(sup-t bootstrap, {conf_level}% family-wise)" - ) - lines.append("") - - # Group effects - if self.group_effects: - lines.extend( - [ - "-" * 85, - "Effects by Enabling Cohort".center(85), - "-" * 85, - f"{'Cohort':<15} {'Estimate':>12} {'Std. Err.':>12} " - f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}", - "-" * 85, - ] - ) - - for group in sorted(self.group_effects.keys()): - eff = self.group_effects[group] - sig = _get_significance_stars(eff["p_value"]) - lines.append( - f"{group:<15} {eff['effect']:>12.4f} {eff['se']:>12.4f} " - f"{eff['t_stat']:>10.3f} {eff['p_value']:>10.4f} {sig:>6}" - ) - - lines.extend(["-" * 85, ""]) - - lines.extend( - [ - "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1", - "=" * 85, - ] - ) - - return "\n".join(lines) - - def print_summary(self, alpha: Optional[float] = None) -> None: - """Print summary to stdout.""" - print(self.summary(alpha)) - - def epv_summary(self, show_all: bool = False) -> pd.DataFrame: - """ - Return per-cohort EPV diagnostics as a DataFrame. - - Parameters - ---------- - show_all : bool, default False - If False, only show cells with low EPV. If True, show all cells. - - Returns - ------- - pd.DataFrame - Columns: group, time, epv, n_events, n_params, is_low. - """ - if not self.epv_diagnostics: - return pd.DataFrame(columns=["group", "time", "epv", "n_events", "n_params", "is_low"]) - rows = [] - for (g, t), diag in sorted(self.epv_diagnostics.items()): - if show_all or diag.get("is_low", False): - rows.append( - { - "group": g, - "time": t, - "epv": diag.get("epv"), - "n_events": diag.get("n_events"), - "n_params": diag.get("k"), - "is_low": diag.get("is_low", False), - } - ) - cols = ["group", "time", "epv", "n_events", "n_params", "is_low"] - return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) - - def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: - """ - Convert results to DataFrame. - - Parameters - ---------- - level : str, default="group_time" - Level of aggregation: "group_time", "event_study", or "group". - - Returns - ------- - pd.DataFrame - Results as DataFrame. - """ - if level == "group_time": - rows = [] - for (g, t), data in self.group_time_effects.items(): - row = { - "group": g, - "time": t, - "effect": data["effect"], - "se": data["se"], - "t_stat": data["t_stat"], - "p_value": data["p_value"], - "conf_int_lower": data["conf_int"][0], - "conf_int_upper": data["conf_int"][1], - } - if self.epv_diagnostics and (g, t) in self.epv_diagnostics: - row["epv"] = self.epv_diagnostics[(g, t)].get("epv") - rows.append(row) - return pd.DataFrame(rows) - - elif level == "event_study": - if self.event_study_effects is None: - raise ValueError("Event study effects not computed. Use aggregate='event_study'.") - rows = [] - for rel_t, data in sorted(self.event_study_effects.items()): - cband_ci = data.get("cband_conf_int", (np.nan, np.nan)) - rows.append( - { - "relative_period": rel_t, - "effect": data["effect"], - "se": data["se"], - "t_stat": data["t_stat"], - "p_value": data["p_value"], - "conf_int_lower": data["conf_int"][0], - "conf_int_upper": data["conf_int"][1], - "cband_lower": cband_ci[0], - "cband_upper": cband_ci[1], - } - ) - return pd.DataFrame(rows) - - elif level == "group": - if self.group_effects is None: - raise ValueError("Group effects not computed. Use aggregate='group'.") - rows = [] - for group, data in sorted(self.group_effects.items()): - rows.append( - { - "group": group, - "effect": data["effect"], - "se": data["se"], - "t_stat": data["t_stat"], - "p_value": data["p_value"], - "conf_int_lower": data["conf_int"][0], - "conf_int_upper": data["conf_int"][1], - } - ) - return pd.DataFrame(rows) - - else: - raise ValueError( - f"Unknown level: {level}. " "Use 'group_time', 'event_study', or 'group'." - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert results to dictionary.""" - d = { - "overall_att": self.overall_att, - "overall_se": self.overall_se, - "overall_t_stat": self.overall_t_stat, - "overall_p_value": self.overall_p_value, - "overall_conf_int": self.overall_conf_int, - "n_obs": self.n_obs, - "n_treated_units": self.n_treated_units, - "n_control_units": self.n_control_units, - "n_never_enabled": self.n_never_enabled, - "n_eligible": self.n_eligible, - "n_ineligible": self.n_ineligible, - "n_groups": len(self.groups), - "n_periods": len(self.time_periods), - "groups": self.groups, - "time_periods": self.time_periods, - "estimation_method": self.estimation_method, - "control_group": self.control_group, - "base_period": self.base_period, - "alpha": self.alpha, - "pscore_trim": self.pscore_trim, - } - if self.event_study_effects is not None: - d["event_study_effects"] = self.event_study_effects - if self.group_effects is not None: - d["group_effects"] = self.group_effects - if self.comparison_group_counts is not None: - d["comparison_group_counts"] = self.comparison_group_counts - if self.overall_att_es is not None: - d["overall_att_es"] = self.overall_att_es - d["overall_se_es"] = self.overall_se_es - d["overall_t_stat_es"] = self.overall_t_stat_es - d["overall_p_value_es"] = self.overall_p_value_es - d["overall_conf_int_es"] = self.overall_conf_int_es - return d - - @property - def is_significant(self) -> bool: - """Check if overall ATT is significant.""" - return bool(self.overall_p_value < self.alpha) - - @property - def significance_stars(self) -> str: - """Significance stars for overall ATT.""" - return _get_significance_stars(self.overall_p_value) diff --git a/diff_diff/triple_diff.py b/diff_diff/triple_diff.py index 53b7d93aa..511055bf1 100644 --- a/diff_diff/triple_diff.py +++ b/diff_diff/triple_diff.py @@ -28,8 +28,7 @@ """ import warnings -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd @@ -41,16 +40,14 @@ ) from diff_diff._staggered_triple_diff_engine import _StaggeredTripleDiffEngineMixin from diff_diff.linalg import _rank_guarded_inv, solve_logit, solve_ols -from diff_diff.results import _format_survey_block, _get_significance_stars -from diff_diff.results_base import ( - _SUMMARY_ALPHA_MESSAGE, - BaseResults, - _coverage_pct, - _require_fit_alpha, -) from diff_diff.staggered_aggregation import CallawaySantAnnaAggregationMixin from diff_diff.staggered_bootstrap import CallawaySantAnnaBootstrapMixin -from diff_diff.staggered_triple_diff_results import StaggeredTripleDiffResults + +# The results container lives in its own module since the 4.0 unification +# (item 6, row M-014) so the shared staggered engine can import it without a +# cycle; re-exported here because `diff_diff.triple_diff.TripleDifferenceResults` +# is the class path 3.x pickles and existing readers use. +from diff_diff.triple_diff_results import TripleDifferenceResults # noqa: F401 from diff_diff.utils import ( safe_inference, staggered_ddd_ctor_offenders, @@ -61,329 +58,6 @@ _MIN_CELL_SIZE = 10 -# ============================================================================= -# Results Classes -# ============================================================================= - - -@dataclass -class TripleDifferenceResults(BaseResults): - """ - Results from Triple Difference (DDD) estimation. - - Provides access to the estimated average treatment effect on the treated - (ATT), standard errors, confidence intervals, and diagnostic information. - - Attributes - ---------- - att : float - Average Treatment effect on the Treated (ATT). - This is the effect on units in the treated group (G=1) and eligible - partition (P=1) after treatment (T=1). - se : float - Standard error of the ATT estimate. - t_stat : float - T-statistic for the ATT estimate. - p_value : float - P-value for the null hypothesis that ATT = 0. - conf_int : tuple[float, float] - Confidence interval for the ATT. - n_obs : int - Total number of observations used in estimation. - n_treated_eligible : int - Number of observations in treated group and eligible partition. - n_treated_ineligible : int - Number of observations in treated group and ineligible partition. - n_control_eligible : int - Number of observations in control group and eligible partition. - n_control_ineligible : int - Number of observations in control group and ineligible partition. - estimation_method : str - Estimation method used: "dr" (doubly robust), "reg" (regression - adjustment), or "ipw" (inverse probability weighting). - alpha : float - Significance level used for confidence intervals. - """ - - att: float - se: float - t_stat: float - p_value: float - conf_int: Tuple[float, float] - n_obs: int - n_treated_eligible: int - n_treated_ineligible: int - n_control_eligible: int - n_control_ineligible: int - estimation_method: str - alpha: float = 0.05 - # Group means for diagnostics - group_means: Optional[Dict[str, float]] = field(default=None) - # Propensity score diagnostics (for IPW/DR) - pscore_stats: Optional[Dict[str, float]] = field(default=None) - # Regression diagnostics - r_squared: Optional[float] = field(default=None) - # Covariate balance statistics - covariate_balance: Optional[pd.DataFrame] = field(default=None, repr=False) - # Inference details - inference_method: str = field(default="analytical") - vcov_type: str = field(default="hc1") - cluster_name: Optional[str] = field(default=None) - n_bootstrap: Optional[int] = field(default=None) - n_clusters: Optional[int] = field(default=None) - # Survey design metadata (SurveyMetadata instance from diff_diff.survey) - survey_metadata: Optional[Any] = field(default=None) - # EPV diagnostics per subgroup comparison - epv_diagnostics: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False) - epv_threshold: float = 10 - pscore_fallback: str = "error" - - def __repr__(self) -> str: - """Concise string representation.""" - return ( - f"TripleDifferenceResults(ATT={self.att:.4f}{self.significance_stars}, " - f"SE={self.se:.4f}, p={self.p_value:.4f}, method={self.estimation_method})" - ) - - def summary(self, alpha: Optional[float] = None) -> str: - """ - Generate a formatted summary of the estimation results. - - Parameters - ---------- - alpha : float, optional - Accepted for signature uniformity. The stored intervals were - computed at fit time; a value different from the stored - ``alpha`` raises ValueError rather than silently recomputing - or relabeling. Re-fit at the desired alpha instead. - - Returns - ------- - str - Formatted summary table. - """ - alpha = _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) - conf_level = _coverage_pct(alpha) - - lines = [ - "=" * 75, - "Triple Difference (DDD) Estimation Results".center(75), - "=" * 75, - "", - f"{'Estimation method:':<30} {self.estimation_method:>15}", - f"{'Total observations:':<30} {self.n_obs:>15}", - "", - "Sample Composition by Cell:", - f" {'Treated group, Eligible:':<28} {self.n_treated_eligible:>15}", - f" {'Treated group, Ineligible:':<28} {self.n_treated_ineligible:>15}", - f" {'Control group, Eligible:':<28} {self.n_control_eligible:>15}", - f" {'Control group, Ineligible:':<28} {self.n_control_ineligible:>15}", - ] - - if self.r_squared is not None: - lines.append(f"{'R-squared:':<30} {self.r_squared:>15.4f}") - - # Add survey design info - if self.survey_metadata is not None: - sm = self.survey_metadata - lines.extend(_format_survey_block(sm, 75)) - - if self.inference_method != "analytical": - lines.append(f"{'Inference method:':<30} {self.inference_method:>15}") - if self.n_bootstrap is not None: - lines.append(f"{'Bootstrap replications:':<30} {self.n_bootstrap:>15}") - # Variance-estimator line. Suppressed under survey designs (the survey - # block above already names the design + n_psu + df; the analytical - # SE is TSL on the combined IF, not the raw hc1 sandwich). For bare - # cluster= fits the actual algebra is CR1 Liang-Zeger on the combined - # IF, so route through the shared _format_vcov_label to render a - # cluster-aware label rather than raw "hc1". - if self.survey_metadata is None: - from diff_diff.results import _format_vcov_label - - vcov_label = _format_vcov_label( - self.vcov_type, - cluster_name=self.cluster_name, - n_clusters=self.n_clusters, - n_obs=self.n_obs, - ) - if vcov_label: - lines.append(f"{'Variance estimator:':<30} {vcov_label:>15}") - if self.n_clusters is not None: - lines.append(f"{'Number of clusters:':<30} {self.n_clusters:>15}") - - lines.extend( - [ - "", - "-" * 75, - f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'':>5}", - "-" * 75, - f"{'ATT':<15} {self.att:>12.4f} {self.se:>12.4f} {self.t_stat:>10.3f} {self.p_value:>10.4f} {self.significance_stars:>5}", - "-" * 75, - "", - f"{conf_level}% Confidence Interval: [{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]", - ] - ) - - # EPV diagnostics block (if any subgroup has low EPV) - if self.epv_diagnostics: - low_epv = {k: v for k, v in self.epv_diagnostics.items() if v.get("is_low")} - if low_epv: - n_affected = len(low_epv) - n_total = len(self.epv_diagnostics) - min_entry = min(low_epv.values(), key=lambda v: v["epv"]) - lines.extend( - [ - "", - "-" * 75, - "EPV Diagnostics".center(75), - "-" * 75, - f"WARNING: Low Events Per Variable (EPV) in " - f"{n_affected} of {n_total} subgroup comparison(s).", - f"Minimum EPV: {min_entry['epv']:.1f}. " - f"Threshold: {self.epv_threshold:.0f}.", - "Consider: estimation_method='reg' or fewer covariates.", - "Call results.epv_summary() for details.", - "-" * 75, - ] - ) - - # Show group means if available - if self.group_means: - lines.extend( - [ - "", - "-" * 75, - "Cell Means (Y):", - "-" * 75, - ] - ) - for cell, mean in self.group_means.items(): - lines.append(f" {cell:<35} {mean:>12.4f}") - - # Show propensity score diagnostics if available - if self.pscore_stats: - lines.extend( - [ - "", - "-" * 75, - "Propensity Score Diagnostics:", - "-" * 75, - ] - ) - for stat, value in self.pscore_stats.items(): - lines.append(f" {stat:<35} {value:>12.4f}") - - lines.extend( - [ - "", - "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1", - "=" * 75, - ] - ) - - return "\n".join(lines) - - def print_summary(self, alpha: Optional[float] = None) -> None: - """Print the summary to stdout.""" - print(self.summary(alpha)) - - def to_dict(self) -> Dict[str, Any]: - """ - Convert results to a dictionary. - - Returns - ------- - Dict[str, Any] - Dictionary containing all estimation results. - """ - result = { - "att": self.att, - "se": self.se, - "t_stat": self.t_stat, - "p_value": self.p_value, - "conf_int_lower": self.conf_int[0], - "conf_int_upper": self.conf_int[1], - "n_obs": self.n_obs, - "n_treated_eligible": self.n_treated_eligible, - "n_treated_ineligible": self.n_treated_ineligible, - "n_control_eligible": self.n_control_eligible, - "n_control_ineligible": self.n_control_ineligible, - "estimation_method": self.estimation_method, - "inference_method": self.inference_method, - "vcov_type": self.vcov_type, - } - if self.r_squared is not None: - result["r_squared"] = self.r_squared - if self.n_bootstrap is not None: - result["n_bootstrap"] = self.n_bootstrap - if self.n_clusters is not None: - result["n_clusters"] = self.n_clusters - if self.cluster_name is not None: - result["cluster_name"] = self.cluster_name - if self.survey_metadata is not None: - sm = self.survey_metadata - result["weight_type"] = sm.weight_type - result["effective_n"] = sm.effective_n - result["design_effect"] = sm.design_effect - result["sum_weights"] = sm.sum_weights - result["n_strata"] = sm.n_strata - result["n_psu"] = sm.n_psu - result["df_survey"] = sm.df_survey - return result - - def to_dataframe(self) -> pd.DataFrame: - """ - Convert results to a pandas DataFrame. - - Returns - ------- - pd.DataFrame - DataFrame with estimation results. - """ - return pd.DataFrame([self.to_dict()]) - - @property - def is_significant(self) -> bool: - """Check if the ATT is statistically significant at the alpha level.""" - return bool(self.p_value < self.alpha) - - @property - def significance_stars(self) -> str: - """Return significance stars based on p-value.""" - return _get_significance_stars(self.p_value) - - def epv_summary(self, show_all: bool = False) -> pd.DataFrame: - """ - Return per-subgroup EPV diagnostics as a DataFrame. - - Parameters - ---------- - show_all : bool, default False - If False, only show subgroups with low EPV. If True, show all. - - Returns - ------- - pd.DataFrame - Columns: subgroup, epv, n_events, n_params, is_low. - """ - if not self.epv_diagnostics: - return pd.DataFrame(columns=["subgroup", "epv", "n_events", "n_params", "is_low"]) - rows = [] - for sg, diag in sorted(self.epv_diagnostics.items()): - if show_all or diag.get("is_low", False): - rows.append( - { - "subgroup": sg, - "epv": diag.get("epv"), - "n_events": diag.get("n_events"), - "n_params": diag.get("k"), - "is_low": diag.get("is_low", False), - } - ) - cols = ["subgroup", "epv", "n_events", "n_params", "is_low"] - return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) - # ============================================================================= # Helper Functions @@ -502,11 +176,13 @@ class TripleDifference( Attributes ---------- - results_ : TripleDifferenceResults or StaggeredTripleDiffResults - Estimation results after calling fit(). The 2x2x2 mode returns - ``TripleDifferenceResults``; the staggered mode returns - ``StaggeredTripleDiffResults`` (the two containers unify at 4.0, row - M-014). + results_ : TripleDifferenceResults + Estimation results after calling fit(). ONE container serves both + designs since 4.0 (row M-014): ``results_.design`` is ``"2x2x2"`` or + ``"staggered"``; the canonical ``att`` / ``se`` / ``t_stat`` / + ``p_value`` / ``conf_int`` are populated in both, and the staggered + design's event-study / cohort surfaces come from the post-fit + ``results_.aggregate('event_study')`` / ``aggregate('group')``. is_fitted_ : bool Whether the model has been fitted. @@ -690,7 +366,7 @@ def __init__( self.cband = cband self.is_fitted_ = False - self.results_: Optional[Union[TripleDifferenceResults, StaggeredTripleDiffResults]] = None + self.results_: Optional[TripleDifferenceResults] = None def fit( self, @@ -705,9 +381,7 @@ def fit( *, unit: Any = NOT_SUPPLIED, first_treat: Any = NOT_SUPPLIED, - aggregate: Optional[str] = None, - balance_e: Optional[int] = None, - ) -> Union[TripleDifferenceResults, StaggeredTripleDiffResults]: + ) -> TripleDifferenceResults: """ Fit the Triple Difference model, in either of its two designs. @@ -759,18 +433,18 @@ def fit( first_treat : str, keyword-only Staggered mode, and the MODE TRIGGER. Column giving each unit's enabling period; 0 or ``np.inf`` marks never-enabled units. - aggregate : str, optional, keyword-only - Staggered mode. ``"event_study"``, ``"group"``, ``"simple"`` or - ``"all"``. - balance_e : int, optional, keyword-only - Staggered mode. Event time to balance the event study on. Returns ------- - TripleDifferenceResults or StaggeredTripleDiffResults - 2x2x2 mode returns ``TripleDifferenceResults``; staggered mode - returns ``StaggeredTripleDiffResults``. The two containers unify at - 4.0 (row M-014). + TripleDifferenceResults + One container for both designs (``results.design`` says which). + In the staggered design the event-study and cohort surfaces are + post-fit: ``results.aggregate('event_study')`` / + ``results.aggregate('group')`` (the 3.x fit-time ``aggregate=`` / + ``balance_e=`` keywords were removed at 4.0, rows M-140 / M-141; + ``balance_e`` is now a keyword of ``aggregate()``), and the paper's + Eq. 4.14 overall is ``results.aggregate('simple', + weights='event_time')``. Raises ------ @@ -780,19 +454,6 @@ def fit( NotImplementedError If survey_design is used with wild_bootstrap inference. """ - # ---- Step 0: mode-INDEPENDENT value validation ------------------- - # 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 - 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', " - f"got '{aggregate}'" - ) - # ---- Step 1: staggered mode --------------------------------------- # Raw sentinels forwarded: the 2x2x2 `time=` check must not run, because # `time=` is the calendar column on this path. @@ -807,27 +468,19 @@ def fit( group=group, post=post, covariates=covariates, - aggregate=aggregate, - balance_e=balance_e, survey_design=survey_design, ) # ---- Step 2: 2x2x2 mode, staggered-only FIT params ---------------- _staggered_only = [ - name - for name, supplied in ( - ("unit", unit is not NOT_SUPPLIED), - ("aggregate", aggregate is not None), - ("balance_e", balance_e is not None), - ) - if supplied + name for name, supplied in (("unit", unit is not NOT_SUPPLIED),) if supplied ] if _staggered_only: raise ValueError( f"{', '.join(_staggered_only)} require(s) first_treat= (the staggered DDD " f"mode); the 2x2x2 TripleDifference design estimates a single ATT from the " - f"(group=, partition=, post=) triple and has no adoption cohorts to " - f"aggregate over. For staggered adoption pass fit(..., unit=, " + f"(group=, partition=, post=) triple and has no unit dimension. For " + f"staggered adoption pass fit(..., unit=, " f"time=, first_treat=, " f"partition=)." ) @@ -1081,6 +734,11 @@ def fit( epv_diagnostics=epv_diag if epv_diag else None, epv_threshold=self.epv_threshold, pscore_fallback=self.pscore_fallback, + pscore_trim=self.pscore_trim, + # The t df that governed p_value / conf_int above when no survey + # design carries it (on survey fits the governing df lives on + # survey_metadata.df_survey, which resolve_inference_df reads first). + df_inference=None if resolved_survey is not None else float(df), ) self.is_fitted_ = True @@ -1098,10 +756,8 @@ def _fit_staggered( group: Any, post: Any, covariates: Optional[List[str]], - aggregate: Optional[str], - balance_e: Optional[int], survey_design, - ) -> StaggeredTripleDiffResults: + ) -> TripleDifferenceResults: """Staggered branch of the merged fit (row M-013). Reached only when ``first_treat=`` was supplied. Rejects the other @@ -1167,8 +823,6 @@ def _fit_staggered( first_treat, partition, covariates=covariates, - aggregate=aggregate, - balance_e=balance_e, survey_design=survey_design, estimator_name="TripleDifference", partition_label="partition", diff --git a/diff_diff/triple_diff_results.py b/diff_diff/triple_diff_results.py new file mode 100644 index 000000000..457aadb35 --- /dev/null +++ b/diff_diff/triple_diff_results.py @@ -0,0 +1,1073 @@ +""" +Results container for the Triple Difference (DDD) estimator - both designs. + +Since the 4.0 cut (item 6 of the cut sequence, ledger rows M-014 / M-140 / +M-141) ``TripleDifference.fit`` returns ONE results shape for both of its +designs: the 2x2x2 cross-sectional design (``group=``, ``partition=``, +``post=``) and the staggered-adoption panel design (``first_treat=``). The +container is the staggered one's structure with the 2x2x2 case as the +degenerate single-ATT view: the canonical quintet ``att`` / ``se`` / +``t_stat`` / ``p_value`` / ``conf_int`` is always populated, the group-time +table is empty in the 2x2x2 design, and ``design`` says which engine fit it. + +Post-fit aggregation (spec section 6) lives here too: ``aggregate('simple')`` +relays the stored headline in both designs; ``aggregate('event_study')`` and +``aggregate('group')`` recompute from the retained aggregation kit in the +staggered design (bootstrapped fits REPLAY the fit-time multiplier bootstrap); +``aggregate('simple', weights='event_time')`` is the paper's Equation 4.14 +overall - the unweighted mean of the post-treatment event-study effects - +which through 3.x was the fit-time ``overall_att_es`` field. + +The 3.x ``StaggeredTripleDiffResults`` class (this module's former content) +was removed at 4.0 with row M-014; this module was renamed from +``staggered_triple_diff_results.py`` so the shared staggered engine can import +the container without a cycle through ``triple_diff.py``. +""" + +import warnings +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +from diff_diff.aggregation import ( + AggregationMixin, + AggregationResult, + resolve_inference_df, +) +from diff_diff.bootstrap_chunking import effective_weight_backend +from diff_diff.bootstrap_utils import ( + apply_bootstrap_event_study_overrides, + apply_bootstrap_group_overrides, +) +from diff_diff.results import _format_survey_block, _get_significance_stars +from diff_diff.results_base import ( + _SUMMARY_ALPHA_MESSAGE, + BaseResults, + _coverage_pct, + _require_fit_alpha, + build_event_study_surface, +) +from diff_diff.staggered_bootstrap import apply_cband_conf_ints +from diff_diff.staggered_results import _KitAggregator, _KitBootstrapAggregator +from diff_diff.utils import safe_inference + +if TYPE_CHECKING: + from diff_diff.staggered_bootstrap import CSBootstrapResults + +_DESIGNS = ("2x2x2", "staggered") + +#: The four 2x2x2 cell counts. Required in that design (the summary renderer +#: formats them), meaningless in the staggered design (``None`` there). +_CELL_COUNT_FIELDS = ( + "n_treated_eligible", + "n_treated_ineligible", + "n_control_eligible", + "n_control_ineligible", +) + +_TWO_BY_TWO_ONLY = ( + "the 2x2x2 TripleDifference design estimates a single ATT from the " + "(group=, partition=, post=) triple and has no ATT(g, t) cells, event-study " + "horizons or adoption cohorts to aggregate over; fit the staggered design " + "(fit(..., unit=, time=, first_treat=, partition=)) for those surfaces." +) + + +@dataclass +class TripleDifferenceResults(BaseResults, AggregationMixin): + """ + Results from Triple Difference (DDD) estimation, in either design. + + One container serves both designs of :class:`~diff_diff.TripleDifference` + (Ortiz-Villavicencio & Sant'Anna 2025). ``design`` records which engine + produced the fit; the canonical headline quintet is populated in both. + + Attributes + ---------- + att : float + Average treatment effect on the treated. 2x2x2: the effect on units in + the treated group (G=1) and eligible partition (P=1) after treatment + (T=1). Staggered: the cohort-size-weighted average of the + post-treatment ``ATT(g, t)`` cells (the library-wide Callaway-Sant'Anna + "simple" convention; the paper's Eq. 4.14 event-study average is + ``aggregate('simple', weights='event_time')``). + se : float + Standard error of ``att``. + t_stat : float + T-statistic for ``att``. + p_value : float + P-value for the null hypothesis that ``att`` is zero. + conf_int : tuple[float, float] + Confidence interval for ``att`` at ``alpha``. + n_obs : int + Total number of observations used in estimation. + n_treated_eligible, n_treated_ineligible, n_control_eligible, n_control_ineligible : int or None + 2x2x2 cell counts (treated/control group x eligible/ineligible + partition). ``None`` in the staggered design. + estimation_method : str + ``"dr"`` (doubly robust), ``"reg"`` (regression adjustment) or + ``"ipw"`` (inverse probability weighting). + alpha : float + Significance level used for confidence intervals. + group_means, pscore_stats, r_squared, covariate_balance + 2x2x2 diagnostics (``None`` in the staggered design). + inference_method : str + ``"analytical"`` or ``"bootstrap"`` (staggered design with + ``n_bootstrap > 0``). + vcov_type : str or None + 2x2x2 sandwich family (``"hc1"`` ...). ``None`` in the staggered + design, whose SEs come from the GMM-combined influence function or + the multiplier bootstrap. + cluster_name, n_clusters : optional + 2x2x2 ``cluster=`` metadata. + n_bootstrap : int or None + Bootstrap replications when ``inference_method == "bootstrap"``. + survey_metadata : SurveyMetadata or None + Survey design metadata when fitted with ``survey_design=``. + epv_diagnostics : dict or None + Events-per-variable diagnostics: keyed by subgroup comparison in the + 2x2x2 design, by ``(g, t)`` cell in the staggered design. + design : str + ``"2x2x2"`` or ``"staggered"``. + df_inference : float or None + Degrees of freedom used for t-distribution inference on ``att`` when + a t-reference governed it and no survey design carries it: the 2x2x2 + engine's ``n_obs - 8 - len(covariates)`` on non-survey fits. ``None`` + when inference used the normal reference (the staggered design's + non-survey fits) or when the governing df lives on + ``survey_metadata.df_survey``. + group_time_effects : dict + Staggered design: ``(group, time)`` -> effect record. Empty in the + 2x2x2 design. + groups : list + Staggered design: enabling cohorts that produced at least one cell. + time_periods : list + Staggered design: calendar periods. + n_treated_units, n_control_units, n_never_enabled, n_eligible, n_ineligible : int or None + Staggered unit counts (``None`` in the 2x2x2 design). + control_group, base_period, anticipation : optional + Staggered engine configuration (``None`` in the 2x2x2 design). + pscore_trim : float + Propensity-score trimming bound (both engines). + event_study_effects, group_effects : dict or None + ``None`` on every 4.0 fit - the event-study and cohort surfaces are + produced post-fit by :meth:`aggregate`. Retained as carrier fields for + the aggregation machinery and for 3.x pickles. + bootstrap_results : CSBootstrapResults or None + Staggered multiplier-bootstrap payload when ``n_bootstrap > 0``. + cband_crit_value : float or None + ``None`` on 4.0 fits (simultaneous bands are produced by + ``aggregate('event_study')``). + comparison_group_counts, gmm_weights : dict or None + Staggered per-cell comparison-cohort counts and GMM weights. + """ + + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] + n_obs: int + n_treated_eligible: Optional[int] = None + n_treated_ineligible: Optional[int] = None + n_control_eligible: Optional[int] = None + n_control_ineligible: Optional[int] = None + estimation_method: str = "dr" + alpha: float = 0.05 + # Group means for diagnostics (2x2x2) + group_means: Optional[Dict[str, float]] = field(default=None) + # Propensity score diagnostics (2x2x2 IPW/DR) + pscore_stats: Optional[Dict[str, float]] = field(default=None) + # Regression diagnostics (2x2x2) + r_squared: Optional[float] = field(default=None) + # Covariate balance statistics (2x2x2) + covariate_balance: Optional[pd.DataFrame] = field(default=None, repr=False) + # Inference details + inference_method: str = field(default="analytical") + vcov_type: Optional[str] = field(default="hc1") + cluster_name: Optional[str] = field(default=None) + n_bootstrap: Optional[int] = field(default=None) + n_clusters: Optional[int] = field(default=None) + # Survey design metadata (SurveyMetadata instance from diff_diff.survey) + survey_metadata: Optional[Any] = field(default=None) + # EPV diagnostics: per subgroup comparison (2x2x2) or per (g, t) cell (staggered) + epv_diagnostics: Optional[Dict[Any, Dict[str, Any]]] = field(default=None, repr=False) + epv_threshold: float = 10 + pscore_fallback: str = "error" + # ---- 4.0 unified-container fields (item 6; rows M-014 / M-140 / M-141). ---- + # Everything below is appended with a default so the 2x2x2 positional + # layout above is byte-stable. + design: str = "2x2x2" + df_inference: Optional[float] = None + group_time_effects: Dict[Tuple[Any, Any], Dict[str, Any]] = field(default_factory=dict) + groups: List[Any] = field(default_factory=list) + time_periods: List[Any] = field(default_factory=list) + n_treated_units: Optional[int] = None + n_control_units: Optional[int] = None + n_never_enabled: Optional[int] = None + n_eligible: Optional[int] = None + n_ineligible: Optional[int] = None + control_group: Optional[str] = None + base_period: Optional[str] = None + anticipation: Optional[int] = None + pscore_trim: float = 0.01 + # Carrier fields for the post-fit aggregation machinery (the CS pattern): + # None on every 4.0 fit, populated only on the throwaway copy that + # ``build_event_study_surface`` reads, and on 3.x pickles. + event_study_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None) + group_effects: Optional[Dict[Any, Dict[str, Any]]] = field(default=None) + bootstrap_results: Optional["CSBootstrapResults"] = field(default=None, repr=False) + cband_crit_value: Optional[float] = None + comparison_group_counts: Optional[Dict[Tuple, int]] = field(default=None, repr=False) + gmm_weights: Optional[Dict[Tuple, Dict]] = field(default=None, repr=False) + event_study_vcov: Optional["np.ndarray"] = field(default=None, repr=False) + event_study_vcov_index: Optional[list] = field(default=None, repr=False) + event_study_df: Optional[float] = field(default=None, repr=False) + reference_event_times: Optional[Tuple[Any, ...]] = None + # Post-fit re-aggregation payload (spec section 6), attached by the + # staggered engine because nothing it needs survives fit(). Excluded from + # repr and equality: internal bookkeeping whose arrays would make `==` raise. + _aggregation_kit: Optional[Any] = field(default=None, repr=False, compare=False) + + #: ``simple`` relays the stored headline in both designs; ``event_study`` + #: and ``group`` recompute from the kit in the staggered design. No + #: ``total`` - the DDD estimators never offered one. + _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("simple", "event_study", "group") + #: ``balance_e`` is meaningful on ``event_study`` and on + #: ``simple`` ONLY together with ``weights="event_time"`` (the Eq. 4.14 + #: overall averages the balanced event-study horizons); plain ``simple`` + #: rejects it in ``_aggregate_compute``. + _AGGREGATE_BALANCE_E_TYPES: ClassVar[Tuple[str, ...]] = ("event_study", "simple") + + def __post_init__(self) -> None: + if self.design not in _DESIGNS: + raise ValueError(f"design must be one of {_DESIGNS}, got {self.design!r}") + if self.design == "staggered": + if not self.group_time_effects or not self.groups or not self.time_periods: + raise ValueError( + "design='staggered' requires non-empty group_time_effects, groups and " + "time_periods (the staggered engine never constructs without them)" + ) + else: + missing = [name for name in _CELL_COUNT_FIELDS if getattr(self, name) is None] + if missing: + raise ValueError( + f"design='2x2x2' requires the four cell counts; missing: {missing}" + ) + + # ------------------------------------------------------------------ # + # Pickle migration + # ------------------------------------------------------------------ # + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Load 3.x ``TripleDifferenceResults`` pickles (2x2x2 only). + + The 3.x 2x2x2 container already stored the canonical quintet, so no + key is renamed; the 4.0 unified-container fields are backfilled with + their defaults when absent (spec section 5 pickle-migration rule, the + ``SyntheticDiDResults.__setstate__`` precedent). 3.x + ``StaggeredTripleDiffResults`` pickles cannot load: the class was + removed at 4.0 (row M-014). + """ + self.__dict__.update(state) + defaults: Dict[str, Any] = { + "design": "2x2x2", + "df_inference": None, + "n_treated_units": None, + "n_control_units": None, + "n_never_enabled": None, + "n_eligible": None, + "n_ineligible": None, + "control_group": None, + "base_period": None, + "anticipation": None, + "pscore_trim": 0.01, + "event_study_effects": None, + "group_effects": None, + "bootstrap_results": None, + "cband_crit_value": None, + "comparison_group_counts": None, + "gmm_weights": None, + "event_study_vcov": None, + "event_study_vcov_index": None, + "event_study_df": None, + "reference_event_times": None, + "_aggregation_kit": None, + } + for key, value in defaults.items(): + if key not in self.__dict__: + self.__dict__[key] = value + for key, factory in ( + ("group_time_effects", dict), + ("groups", list), + ("time_periods", list), + ): + if key not in self.__dict__: + self.__dict__[key] = factory() + + # ------------------------------------------------------------------ # + # Scalar views + # ------------------------------------------------------------------ # + + def __repr__(self) -> str: + """Concise string representation.""" + if self.design == "staggered": + return ( + f"TripleDifferenceResults(design='staggered', " + f"ATT={self.att:.4f}{self.significance_stars}, SE={self.se:.4f}, " + f"n_groups={len(self.groups)}, n_periods={len(self.time_periods)})" + ) + return ( + f"TripleDifferenceResults(ATT={self.att:.4f}{self.significance_stars}, " + f"SE={self.se:.4f}, p={self.p_value:.4f}, method={self.estimation_method})" + ) + + @property + def is_significant(self) -> bool: + """Check if the ATT is statistically significant at the alpha level.""" + return bool(self.p_value < self.alpha) + + @property + def significance_stars(self) -> str: + """Return significance stars based on p-value.""" + return _get_significance_stars(self.p_value) + + @property + def coef_var(self) -> float: + """Coefficient of variation: SE / abs(ATT). NaN when ATT is 0 or SE non-finite.""" + if not (np.isfinite(self.se) and self.se >= 0): + return np.nan + if not np.isfinite(self.att) or self.att == 0: + return np.nan + return self.se / abs(self.att) + + # ------------------------------------------------------------------ # + # summary() + # ------------------------------------------------------------------ # + + def summary(self, alpha: Optional[float] = None) -> str: + """ + Generate a formatted summary of the estimation results. + + Parameters + ---------- + alpha : float, optional + Accepted for signature uniformity. The stored intervals were + computed at fit time; a value different from the stored + ``alpha`` raises ValueError rather than silently recomputing + or relabeling (bootstrap percentile intervals cannot be + reconstructed from the reported SE). Re-fit at the desired + alpha instead. + + Returns + ------- + str + Formatted summary table. + """ + if self.design == "staggered": + return self._summary_staggered(alpha) + return self._summary_2x2x2(alpha) + + def _summary_2x2x2(self, alpha: Optional[float]) -> str: + alpha = _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) + conf_level = _coverage_pct(alpha) + + lines = [ + "=" * 75, + "Triple Difference (DDD) Estimation Results".center(75), + "=" * 75, + "", + f"{'Estimation method:':<30} {self.estimation_method:>15}", + f"{'Total observations:':<30} {self.n_obs:>15}", + "", + "Sample Composition by Cell:", + f" {'Treated group, Eligible:':<28} {self.n_treated_eligible:>15}", + f" {'Treated group, Ineligible:':<28} {self.n_treated_ineligible:>15}", + f" {'Control group, Eligible:':<28} {self.n_control_eligible:>15}", + f" {'Control group, Ineligible:':<28} {self.n_control_ineligible:>15}", + ] + + if self.r_squared is not None: + lines.append(f"{'R-squared:':<30} {self.r_squared:>15.4f}") + + # Add survey design info + if self.survey_metadata is not None: + sm = self.survey_metadata + lines.extend(_format_survey_block(sm, 75)) + + if self.inference_method != "analytical": + lines.append(f"{'Inference method:':<30} {self.inference_method:>15}") + if self.n_bootstrap is not None: + lines.append(f"{'Bootstrap replications:':<30} {self.n_bootstrap:>15}") + # Variance-estimator line. Suppressed under survey designs (the survey + # block above already names the design + n_psu + df; the analytical + # SE is TSL on the combined IF, not the raw hc1 sandwich). For bare + # cluster= fits the actual algebra is CR1 Liang-Zeger on the combined + # IF, so route through the shared _format_vcov_label to render a + # cluster-aware label rather than raw "hc1". + if self.survey_metadata is None and self.vcov_type is not None: + from diff_diff.results import _format_vcov_label + + vcov_label = _format_vcov_label( + self.vcov_type, + cluster_name=self.cluster_name, + n_clusters=self.n_clusters, + n_obs=self.n_obs, + ) + if vcov_label: + lines.append(f"{'Variance estimator:':<30} {vcov_label:>15}") + if self.n_clusters is not None: + lines.append(f"{'Number of clusters:':<30} {self.n_clusters:>15}") + + lines.extend( + [ + "", + "-" * 75, + f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'':>5}", + "-" * 75, + f"{'ATT':<15} {self.att:>12.4f} {self.se:>12.4f} {self.t_stat:>10.3f} {self.p_value:>10.4f} {self.significance_stars:>5}", + "-" * 75, + "", + f"{conf_level}% Confidence Interval: [{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]", + ] + ) + + # EPV diagnostics block (if any subgroup has low EPV) + if self.epv_diagnostics: + low_epv = {k: v for k, v in self.epv_diagnostics.items() if v.get("is_low")} + if low_epv: + n_affected = len(low_epv) + n_total = len(self.epv_diagnostics) + min_entry = min(low_epv.values(), key=lambda v: v["epv"]) + lines.extend( + [ + "", + "-" * 75, + "EPV Diagnostics".center(75), + "-" * 75, + f"WARNING: Low Events Per Variable (EPV) in " + f"{n_affected} of {n_total} subgroup comparison(s).", + f"Minimum EPV: {min_entry['epv']:.1f}. " + f"Threshold: {self.epv_threshold:.0f}.", + "Consider: estimation_method='reg' or fewer covariates.", + "Call results.epv_summary() for details.", + "-" * 75, + ] + ) + + # Show group means if available + if self.group_means: + lines.extend( + [ + "", + "-" * 75, + "Cell Means (Y):", + "-" * 75, + ] + ) + for cell, mean in self.group_means.items(): + lines.append(f" {cell:<35} {mean:>12.4f}") + + # Show propensity score diagnostics if available + if self.pscore_stats: + lines.extend( + [ + "", + "-" * 75, + "Propensity Score Diagnostics:", + "-" * 75, + ] + ) + for stat, value in self.pscore_stats.items(): + lines.append(f" {stat:<35} {value:>12.4f}") + + lines.extend( + [ + "", + "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1", + "=" * 75, + ] + ) + + return "\n".join(lines) + + def _summary_staggered(self, alpha: Optional[float]) -> str: + alpha = _require_fit_alpha(alpha, self.alpha) + conf_level = _coverage_pct(alpha) + + lines = [ + "=" * 85, + "Staggered Triple Difference (DDD) Results".center(85), + "=" * 85, + "", + f"{'Total observations:':<30} {self.n_obs:>10}", + f"{'Treated units (S10}", + f"{'Control units:':<30} {self.n_control_units:>10}", + f"{'Never-enabled units:':<30} {self.n_never_enabled:>10}", + f"{'Eligible units (Q=1):':<30} {self.n_eligible:>10}", + f"{'Ineligible units (Q=0):':<30} {self.n_ineligible:>10}", + f"{'Enabling cohorts:':<30} {len(self.groups):>10}", + f"{'Time periods:':<30} {len(self.time_periods):>10}", + f"{'Estimation method:':<30} {self.estimation_method:>10}", + f"{'Control group:':<30} {str(self.control_group):>10}", + f"{'Base period:':<30} {str(self.base_period):>10}", + "", + ] + + if self.survey_metadata is not None: + sm = self.survey_metadata + lines.extend(_format_survey_block(sm, 85)) + + if self.inference_method != "analytical": + lines.append(f"{'Inference method:':<30} {self.inference_method:>10}") + if self.n_bootstrap is not None: + lines.append(f"{'Bootstrap replications:':<30} {self.n_bootstrap:>10}") + lines.append("") + + # Overall ATT + lines.extend( + [ + "-" * 85, + "Overall Average Treatment Effect on the Treated".center(85), + "-" * 85, + f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} " + f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}", + "-" * 85, + f"{'ATT':<15} {self.att:>12.4f} {self.se:>12.4f} " + f"{self.t_stat:>10.3f} {self.p_value:>10.4f} " + f"{_get_significance_stars(self.p_value):>6}", + "-" * 85, + "", + f"{conf_level}% Confidence Interval: " + f"[{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]", + ] + ) + + cv = self.coef_var + if np.isfinite(cv): + lines.append(f"{'CV (SE/abs(ATT)):':<25} {cv:>10.4f}") + + lines.append("") + + # EPV diagnostics block (if any cohort has low EPV) + if self.epv_diagnostics: + low_epv = {k: v for k, v in self.epv_diagnostics.items() if v.get("is_low")} + if low_epv: + n_affected = len(low_epv) + n_total = len(self.epv_diagnostics) + min_entry = min(low_epv.values(), key=lambda v: v["epv"]) + min_g = min(low_epv.keys(), key=lambda k: low_epv[k]["epv"]) + lines.extend( + [ + "-" * 85, + "Propensity Score Diagnostics".center(85), + "-" * 85, + f"WARNING: Low Events Per Variable (EPV) in " + f"{n_affected} of {n_total} cohort-time cell(s).", + f"Minimum EPV: {min_entry['epv']:.1f} " + f"(cohort g={min_g[0]}). " + f"Threshold: {self.epv_threshold:.0f}.", + "Consider: estimation_method='reg' or fewer covariates.", + "Call results.epv_summary() for per-cohort details.", + "-" * 85, + "", + ] + ) + + lines.extend( + [ + "Event-study and cohort surfaces: results.aggregate('event_study') / " + "results.aggregate('group').", + "", + "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1", + "=" * 85, + ] + ) + + return "\n".join(lines) + + def print_summary(self, alpha: Optional[float] = None) -> None: + """Print the summary to stdout.""" + print(self.summary(alpha)) + + # ------------------------------------------------------------------ # + # Serialization + # ------------------------------------------------------------------ # + + def to_dict(self) -> Dict[str, Any]: + """ + Convert results to a dictionary (canonical key names only). + + Returns + ------- + Dict[str, Any] + The headline quintet (``conf_int`` split into + ``conf_int_lower`` / ``conf_int_upper``), the shared scalars, and + the design-specific block. + """ + result: Dict[str, Any] = { + "att": self.att, + "se": self.se, + "t_stat": self.t_stat, + "p_value": self.p_value, + "conf_int_lower": self.conf_int[0], + "conf_int_upper": self.conf_int[1], + "n_obs": self.n_obs, + "estimation_method": self.estimation_method, + "alpha": self.alpha, + "design": self.design, + "pscore_trim": self.pscore_trim, + "inference_method": self.inference_method, + "df_inference": self.df_inference, + } + if self.design == "staggered": + result.update( + { + "n_treated_units": self.n_treated_units, + "n_control_units": self.n_control_units, + "n_never_enabled": self.n_never_enabled, + "n_eligible": self.n_eligible, + "n_ineligible": self.n_ineligible, + "n_groups": len(self.groups), + "n_periods": len(self.time_periods), + "groups": self.groups, + "time_periods": self.time_periods, + "control_group": self.control_group, + "base_period": self.base_period, + "anticipation": self.anticipation, + } + ) + if self.n_bootstrap is not None: + result["n_bootstrap"] = self.n_bootstrap + if self.comparison_group_counts is not None: + result["comparison_group_counts"] = self.comparison_group_counts + else: + result.update( + { + "n_treated_eligible": self.n_treated_eligible, + "n_treated_ineligible": self.n_treated_ineligible, + "n_control_eligible": self.n_control_eligible, + "n_control_ineligible": self.n_control_ineligible, + "vcov_type": self.vcov_type, + } + ) + if self.r_squared is not None: + result["r_squared"] = self.r_squared + if self.n_bootstrap is not None: + result["n_bootstrap"] = self.n_bootstrap + if self.n_clusters is not None: + result["n_clusters"] = self.n_clusters + if self.cluster_name is not None: + result["cluster_name"] = self.cluster_name + if self.survey_metadata is not None: + sm = self.survey_metadata + result["weight_type"] = sm.weight_type + result["effective_n"] = sm.effective_n + result["design_effect"] = sm.design_effect + result["sum_weights"] = sm.sum_weights + result["n_strata"] = sm.n_strata + result["n_psu"] = sm.n_psu + result["df_survey"] = sm.df_survey + return result + + def to_dataframe(self, level: Optional[str] = None) -> pd.DataFrame: + """ + Convert results to a pandas DataFrame. + + Parameters + ---------- + level : str, optional + ``"overall"`` - one row of the scalar ``to_dict()`` entries (both + designs); ``"group_time"`` - the ``ATT(g, t)`` cells (staggered + design only). ``None`` selects ``"overall"`` in the 2x2x2 design + and ``"group_time"`` in the staggered design. ``"event_study"`` and + ``"group"`` are not stored on 4.0 fits - use + ``results.aggregate('event_study').to_dataframe()`` / + ``results.aggregate('group').to_dataframe()``. + + Returns + ------- + pd.DataFrame + """ + if level is None: + level = "group_time" if self.design == "staggered" else "overall" + if level == "overall": + scalars = { + k: v + for k, v in self.to_dict().items() + if not isinstance(v, (dict, list, tuple, np.ndarray)) + } + return pd.DataFrame([scalars]) + if level == "group_time": + if self.design != "staggered": + raise ValueError(f"to_dataframe(level='group_time'): {_TWO_BY_TWO_ONLY}") + rows = [] + for (g, t), data in self.group_time_effects.items(): + row = { + "group": g, + "time": t, + "effect": data["effect"], + "se": data["se"], + "t_stat": data["t_stat"], + "p_value": data["p_value"], + "conf_int_lower": data["conf_int"][0], + "conf_int_upper": data["conf_int"][1], + } + if self.epv_diagnostics and (g, t) in self.epv_diagnostics: + row["epv"] = self.epv_diagnostics[(g, t)].get("epv") + rows.append(row) + return pd.DataFrame(rows) + if level in ("event_study", "group"): + raise ValueError( + f"{'Event study' if level == 'event_study' else 'Group'} effects are not " + f"stored on 4.0 fits; use results.aggregate({level!r}).to_dataframe() " + "(no refit required; staggered design only)." + ) + raise ValueError( + f"Unknown level: {level}. Use 'overall' or 'group_time' (or None for the " + "design's default)." + ) + + def epv_summary(self, show_all: bool = False) -> pd.DataFrame: + """ + Return the EPV diagnostics as a DataFrame. + + Parameters + ---------- + show_all : bool, default False + If False, only show cells with low EPV. If True, show all. + + Returns + ------- + pd.DataFrame + 2x2x2: columns ``subgroup, epv, n_events, n_params, is_low``. + Staggered: columns ``group, time, epv, n_events, n_params, is_low``. + """ + staggered = self.design == "staggered" + cols = ( + ["group", "time", "epv", "n_events", "n_params", "is_low"] + if staggered + else ["subgroup", "epv", "n_events", "n_params", "is_low"] + ) + if not self.epv_diagnostics: + return pd.DataFrame(columns=cols) + rows = [] + for key, diag in sorted(self.epv_diagnostics.items()): + if show_all or diag.get("is_low", False): + row: Dict[str, Any] = {} + if staggered: + row["group"], row["time"] = key + else: + row["subgroup"] = key + row.update( + { + "epv": diag.get("epv"), + "n_events": diag.get("n_events"), + "n_params": diag.get("k"), + "is_low": diag.get("is_low", False), + } + ) + rows.append(row) + return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) + + # ------------------------------------------------------------------ # + # Post-fit aggregation (spec section 6; rows M-014 / M-140 / M-141) + # ------------------------------------------------------------------ # + + def _aggregate_validate_weights(self, weights: Optional[str]) -> None: + if weights is not None and weights != "event_time": + raise ValueError( + f"TripleDifferenceResults.aggregate() accepts weights=None (the " + f"cohort-size-weighted overall) or weights='event_time' (the paper's " + f"Eq. 4.14 overall, the unweighted mean of the post-treatment " + f"event-study effects); got {weights!r}." + ) + + def _aggregate_compute( + self, level: str, *, weights: Optional[str], balance_e: Optional[int] + ) -> Any: + # Every guard runs before any relay returns (nothing may be dropped). + if weights == "event_time" and level != "simple": + raise ValueError( + "weights='event_time' selects the Eq. 4.14 overall and applies to " + "aggregate('simple') only." + ) + if self.design != "staggered": + if level in ("event_study", "group") or weights is not None or balance_e is not None: + raise ValueError(f"aggregate({level!r}): {_TWO_BY_TWO_ONLY}") + if level == "simple" and balance_e is not None and weights != "event_time": + raise ValueError( + "balance_e applies to aggregate('simple', weights='event_time') and " + "aggregate('event_study'); the default aggregate('simple') relays the " + "stored overall ATT, which balance_e does not enter." + ) + + if level == "simple" and weights is None: + return self._aggregate_simple_result() + + kit = getattr(self, "_aggregation_kit", None) + if kit is None: + raise ValueError( + "This result carries no aggregation kit, so it cannot be " + "re-aggregated. Kits are attached at fit time by the " + "estimator; a result unpickled from an older release will " + "not have one." + ) + + # Shallow copy: shares every array but gives the aggregators a scratch + # dict to memoize into, so the kit itself is never written to (the CS + # pattern - and exactly what the engine did at fit through its + # eligibility-zeroed copy, which is what the kit retains). + precomputed = dict(kit.bookkeeping) + + # Bootstrap replay (CS pattern): re-run the fit-time multiplier + # bootstrap from the kit-retained RNG state so the recompute levels + # carry percentile inference matching a fit-time aggregation. + boot_replay: Optional["CSBootstrapResults"] = None + if self.bootstrap_results is not None: + spec = getattr(kit, "bootstrap", None) + if spec is None or getattr(spec, "bitgen_state", None) is None: + raise NotImplementedError( + f"aggregate({level!r}) on this bootstrapped fit needs the " + "fit-time bootstrap replay state, which this result " + "predates - refit to enable the percentile-bootstrap " + "replay. aggregate('simple') relays the stored bootstrap " + "inference and remains available." + ) + current_backend = effective_weight_backend() + if spec.backend not in ("portable", current_backend): + raise NotImplementedError( + f"aggregate({level!r}) cannot replay this bootstrapped " + f"fit here: its multiplier bootstrap was generated under " + f"the {spec.backend!r} weight backend, but the current " + f"backend is {current_backend!r}, and the two produce " + "different draws from the same RNG state. Re-fit under " + "the current backend, or restore the original one (the " + "DIFF_DIFF_BACKEND environment variable / the Rust " + "extension install)." + ) + host = _KitBootstrapAggregator( + kit.alpha, + kit.anticipation, + spec.n_bootstrap, + spec.weight_type, + bootstrap_label=kit.bookkeeping.get("bootstrap_label", "TripleDifference"), + ) + replay_level = "event_study" if level == "simple" else level + boot_replay = host._run_multiplier_bootstrap( + group_time_effects=self.group_time_effects, + influence_func_info=kit.influence, + aggregate=replay_level, + balance_e=balance_e if replay_level == "event_study" else None, + treatment_groups=self.groups, + time_periods=self.time_periods, + df=None, + unit=None, + precomputed=precomputed, + cband=kit.cband, + _replay_bitgen_state=spec.bitgen_state, + ) + + agg = _KitAggregator(kit.alpha, kit.anticipation) + + if level == "group": + effects = agg._aggregate_by_group( + self.group_time_effects, + kit.influence, + self.groups, + precomputed=precomputed, + df=None, + unit=None, + ) + if boot_replay is not None: + apply_bootstrap_group_overrides(effects, boot_replay, kit.alpha) + return self._group_effects_to_aggregation(effects, kit) + + es = agg._aggregate_event_study( + self.group_time_effects, + kit.influence, + self.groups, + self.time_periods, + balance_e, + None, + None, + precomputed, + ) + + if level == "simple": # weights == "event_time": the Eq. 4.14 overall + return self._event_time_overall_result(es, boot_replay, kit) + + # event_study -> the unified EventStudyResults container (row M-092) + if boot_replay is not None: + apply_bootstrap_event_study_overrides(es.effects, boot_replay, kit.alpha) + apply_cband_conf_ints(es.effects, boot_replay.cband_crit_value) + carrier = replace( + self, + event_study_effects=es.effects, + event_study_vcov=boot_replay.event_study_vcov if boot_replay is not None else es.vcov, + event_study_vcov_index=( + boot_replay.event_study_vcov_index if boot_replay is not None else es.vcov_index + ), + event_study_df=None if boot_replay is not None else es.df_used, + cband_crit_value=( + boot_replay.cband_crit_value if boot_replay is not None else self.cband_crit_value + ), + reference_event_times=es.reference_event_times, + ) + return build_event_study_surface(carrier) + + def _aggregate_simple_result(self) -> AggregationResult: + """The stored headline ATT as a one-row table (a view, both designs). + + Bit-identical to the fit by construction, hence permitted on + bootstrapped fits: the stored quintet relays verbatim and only the df + column is NaN'd there (no df governs percentile inference). + """ + ci = self.conf_int or (np.nan, np.nan) + if self.design == "staggered": + n_units = float((self.n_treated_units or 0) + (self.n_control_units or 0)) + n, n_kind = n_units, "units" + else: + n, n_kind = float(self.n_obs), "obs" + return AggregationResult( + level="simple", + label=np.array(["overall"], dtype=object), + target=np.array(["att"], dtype=object), + att=np.array([self.att], dtype=float), + se=np.array([self.se], dtype=float), + t_stat=np.array([self.t_stat], dtype=float), + p_value=np.array([self.p_value], dtype=float), + conf_int_lower=np.array([ci[0]], dtype=float), + conf_int_upper=np.array([ci[1]], dtype=float), + n=np.array([n], dtype=float), + df=np.array( + [np.nan if self.bootstrap_results is not None else _df_or_nan(self)], + dtype=float, + ), + alpha=self.alpha, + n_kind=n_kind, + weight=np.array([1.0], dtype=float), + estimator="TripleDifference", + ) + + def _event_time_overall_result( + self, es: Any, boot_replay: Optional["CSBootstrapResults"], kit: Any + ) -> AggregationResult: + """The paper's Eq. 4.14 overall: the unweighted mean of post-treatment ES(e). + + Reproduces the 3.x fit-time ``overall_att_es`` rule exactly: the point + estimate averages every finite post-treatment horizon; the analytical + SE is the influence function of that mean, with the df the engine + used (the per-statistic effective df when the survey design yields + one, else the fit's sentinelled survey df); on bootstrapped fits the + replayed percentile SE / CI / p override it. Both fit-time warnings + are preserved. + """ + df_fit = kit.bookkeeping.get("ddd_df_survey_original") + df_es = df_fit + overall = es.overall + if overall is None: + warnings.warn( + "Event-study aggregation was requested but no post-treatment " + "horizons are available for the Eq. 4.14 overall " + "(aggregate('simple', weights='event_time')); returning NaN.", + UserWarning, + stacklevel=4, + ) + att_es: float = np.nan + se_es: float = np.nan + t_es, p_es, ci_es = safe_inference(np.nan, np.nan, alpha=kit.alpha, df=df_fit) + analytical_se_nonfinite = False + else: + att_es = float(overall["att"]) + se_es = float(overall["se"]) + analytical_se_nonfinite = bool(np.isfinite(att_es) and not np.isfinite(se_es)) + eff_df = overall.get("effective_df") + df_es = eff_df if eff_df is not None else df_fit + t_es, p_es, ci_es = safe_inference(att_es, se_es, alpha=kit.alpha, df=df_es) + if boot_replay is not None and boot_replay.overall_att_es_se is not None: + se_es = boot_replay.overall_att_es_se + # The engine passes its (possibly survey-mutated) running df here; + # the two coincide on every bootstrapped fit because bootstrap x + # replicate-weight designs are rejected and the mutation only + # happens on replicate designs. + t_es, _, _ = safe_inference(att_es, se_es, alpha=kit.alpha, df=df_fit) + ci_es = boot_replay.overall_att_es_ci + p_es = boot_replay.overall_att_es_p_value + if analytical_se_nonfinite and not np.isfinite(se_es): + warnings.warn( + "Eq. 4.14 overall (aggregate('simple', weights='event_time')) point " + "estimate is defined but its standard error is undefined (NaN): either a " + "contributing post-treatment event-study horizon has a non-finite " + "influence function, or the variance is unidentified (e.g. a " + "single-PSU/cluster survey design). The SE and its inference fields " + "are NaN.", + UserWarning, + stacklevel=4, + ) + ci_es = ci_es if ci_es is not None else (np.nan, np.nan) + n_units = float((self.n_treated_units or 0) + (self.n_control_units or 0)) + return AggregationResult( + level="simple", + label=np.array(["overall"], dtype=object), + target=np.array(["att"], dtype=object), + att=np.array([att_es], dtype=float), + se=np.array([se_es], dtype=float), + t_stat=np.array([t_es], dtype=float), + p_value=np.array([p_es], dtype=float), + conf_int_lower=np.array([ci_es[0]], dtype=float), + conf_int_upper=np.array([ci_es[1]], dtype=float), + n=np.array([n_units], dtype=float), + df=np.array( + [ + ( + np.nan + if boot_replay is not None + else (float(df_es) if overall is not None and df_es is not None else np.nan) + ) + ], + dtype=float, + ), + alpha=kit.alpha, + n_kind="units", + weight=np.array([1.0], dtype=float), + estimator="TripleDifference", + ) + + def _group_effects_to_aggregation( + self, effects: Dict[Any, Dict[str, Any]], kit: Any + ) -> AggregationResult: + """Per-cohort aggregation as a table (the CallawaySantAnnaResults shape). + + ``weight`` is left unset: ``_aggregate_by_group`` weights ``(g, t)`` + cells equally WITHIN each cohort and never forms a cross-cohort mass. + ``n`` is the cohort's finite-contributing cell count + (``n_kind="cells"``). + """ + labels = list(effects.keys()) + rows = [effects[g] for g in labels] + cis = [r.get("conf_int") or (np.nan, np.nan) for r in rows] + return AggregationResult( + level="group", + label=np.array(labels, dtype=object), + target=np.array(["att"] * len(labels), dtype=object), + att=np.array([r["effect"] for r in rows], dtype=float), + se=np.array([r["se"] for r in rows], dtype=float), + t_stat=np.array([r["t_stat"] for r in rows], dtype=float), + p_value=np.array([r["p_value"] for r in rows], dtype=float), + conf_int_lower=np.array([c[0] for c in cis], dtype=float), + conf_int_upper=np.array([c[1] for c in cis], dtype=float), + n=np.array([float(r.get("n_periods", np.nan)) for r in rows], dtype=float), + df=rows[0].get("df_used") if rows else None, + alpha=kit.alpha, + n_kind="cells", + weight=None, + estimator="TripleDifference", + ) + + +def _df_or_nan(results: Any) -> float: + df = resolve_inference_df(results) + return np.nan if df is None else float(df) diff --git a/diff_diff/visualization/_staggered.py b/diff_diff/visualization/_staggered.py index c18c46521..9f2774f26 100644 --- a/diff_diff/visualization/_staggered.py +++ b/diff_diff/visualization/_staggered.py @@ -62,6 +62,14 @@ def plot_group_effects( raise TypeError( "results must be a CallawaySantAnnaResults (or subclass, e.g. " "DMLDiDResults) object" ) + if not results.group_time_effects: + # The unified TripleDifferenceResults carries an EMPTY table in its + # 2x2x2 design (a single ATT, no cells) - nothing to plot. + raise ValueError( + f"{type(results).__name__} has no group-time cells to plot (a 2x2x2 " + "TripleDifference fit has a single ATT; the staggered design " + "(first_treat=) produces ATT(g, t) cells)." + ) # Get groups to plot if groups is None: @@ -364,6 +372,12 @@ def _extract_staircase_data(results, data, unit, time, first_treat): "results must be a CallawaySantAnnaResults (or subclass, e.g. " "DMLDiDResults) object" ) + if not results.group_time_effects: + raise ValueError( + f"{type(results).__name__} has no group-time cells to plot (a 2x2x2 " + "TripleDifference fit has a single ATT; the staggered design " + "(first_treat=) produces ATT(g, t) cells)." + ) groups = sorted(results.groups) cohort_counts = [] diff --git a/docs/api/_autosummary/diff_diff.StaggeredTripleDiffResults.rst b/docs/api/_autosummary/diff_diff.StaggeredTripleDiffResults.rst deleted file mode 100644 index c57948e32..000000000 --- a/docs/api/_autosummary/diff_diff.StaggeredTripleDiffResults.rst +++ /dev/null @@ -1,72 +0,0 @@ -diff\_diff.StaggeredTripleDiffResults -===================================== - -.. currentmodule:: diff_diff - -.. autoclass:: StaggeredTripleDiffResults - :no-members: - - - .. rubric:: Methods - - .. autosummary:: - - ~StaggeredTripleDiffResults.__init__ - ~StaggeredTripleDiffResults.epv_summary - ~StaggeredTripleDiffResults.print_summary - ~StaggeredTripleDiffResults.summary - ~StaggeredTripleDiffResults.to_dataframe - ~StaggeredTripleDiffResults.to_dict - - - - - .. rubric:: Attributes - - .. autosummary:: - - ~StaggeredTripleDiffResults.alpha - ~StaggeredTripleDiffResults.anticipation - ~StaggeredTripleDiffResults.att - ~StaggeredTripleDiffResults.base_period - ~StaggeredTripleDiffResults.bootstrap_results - ~StaggeredTripleDiffResults.cband_crit_value - ~StaggeredTripleDiffResults.coef_var - ~StaggeredTripleDiffResults.comparison_group_counts - ~StaggeredTripleDiffResults.conf_int - ~StaggeredTripleDiffResults.control_group - ~StaggeredTripleDiffResults.epv_diagnostics - ~StaggeredTripleDiffResults.epv_threshold - ~StaggeredTripleDiffResults.estimation_method - ~StaggeredTripleDiffResults.event_study_effects - ~StaggeredTripleDiffResults.gmm_weights - ~StaggeredTripleDiffResults.group_effects - ~StaggeredTripleDiffResults.influence_functions - ~StaggeredTripleDiffResults.is_significant - ~StaggeredTripleDiffResults.overall_att_es - ~StaggeredTripleDiffResults.overall_conf_int_es - ~StaggeredTripleDiffResults.overall_p_value_es - ~StaggeredTripleDiffResults.overall_se_es - ~StaggeredTripleDiffResults.overall_t_stat_es - ~StaggeredTripleDiffResults.p_value - ~StaggeredTripleDiffResults.pscore_fallback - ~StaggeredTripleDiffResults.pscore_trim - ~StaggeredTripleDiffResults.se - ~StaggeredTripleDiffResults.significance_stars - ~StaggeredTripleDiffResults.survey_metadata - ~StaggeredTripleDiffResults.t_stat - ~StaggeredTripleDiffResults.group_time_effects - ~StaggeredTripleDiffResults.overall_att - ~StaggeredTripleDiffResults.overall_se - ~StaggeredTripleDiffResults.overall_t_stat - ~StaggeredTripleDiffResults.overall_p_value - ~StaggeredTripleDiffResults.overall_conf_int - ~StaggeredTripleDiffResults.groups - ~StaggeredTripleDiffResults.time_periods - ~StaggeredTripleDiffResults.n_obs - ~StaggeredTripleDiffResults.n_treated_units - ~StaggeredTripleDiffResults.n_control_units - ~StaggeredTripleDiffResults.n_never_enabled - ~StaggeredTripleDiffResults.n_eligible - ~StaggeredTripleDiffResults.n_ineligible - diff --git a/docs/api/_autosummary/diff_diff.TripleDifferenceResults.rst b/docs/api/_autosummary/diff_diff.TripleDifferenceResults.rst index d57a7d52a..b44375ae1 100644 --- a/docs/api/_autosummary/diff_diff.TripleDifferenceResults.rst +++ b/docs/api/_autosummary/diff_diff.TripleDifferenceResults.rst @@ -12,6 +12,7 @@ .. autosummary:: ~TripleDifferenceResults.__init__ + ~TripleDifferenceResults.aggregate ~TripleDifferenceResults.epv_summary ~TripleDifferenceResults.print_summary ~TripleDifferenceResults.summary @@ -26,18 +27,45 @@ .. autosummary:: ~TripleDifferenceResults.alpha + ~TripleDifferenceResults.anticipation + ~TripleDifferenceResults.base_period + ~TripleDifferenceResults.bootstrap_results + ~TripleDifferenceResults.cband_crit_value ~TripleDifferenceResults.cluster_name + ~TripleDifferenceResults.coef_var + ~TripleDifferenceResults.comparison_group_counts + ~TripleDifferenceResults.control_group ~TripleDifferenceResults.covariate_balance + ~TripleDifferenceResults.design + ~TripleDifferenceResults.df_inference ~TripleDifferenceResults.epv_diagnostics ~TripleDifferenceResults.epv_threshold + ~TripleDifferenceResults.estimation_method + ~TripleDifferenceResults.event_study_df + ~TripleDifferenceResults.event_study_effects + ~TripleDifferenceResults.event_study_vcov + ~TripleDifferenceResults.event_study_vcov_index + ~TripleDifferenceResults.gmm_weights + ~TripleDifferenceResults.group_effects ~TripleDifferenceResults.group_means ~TripleDifferenceResults.inference_method ~TripleDifferenceResults.is_significant ~TripleDifferenceResults.n_bootstrap ~TripleDifferenceResults.n_clusters + ~TripleDifferenceResults.n_control_eligible + ~TripleDifferenceResults.n_control_ineligible + ~TripleDifferenceResults.n_control_units + ~TripleDifferenceResults.n_eligible + ~TripleDifferenceResults.n_ineligible + ~TripleDifferenceResults.n_never_enabled + ~TripleDifferenceResults.n_treated_eligible + ~TripleDifferenceResults.n_treated_ineligible + ~TripleDifferenceResults.n_treated_units ~TripleDifferenceResults.pscore_fallback ~TripleDifferenceResults.pscore_stats + ~TripleDifferenceResults.pscore_trim ~TripleDifferenceResults.r_squared + ~TripleDifferenceResults.reference_event_times ~TripleDifferenceResults.significance_stars ~TripleDifferenceResults.survey_metadata ~TripleDifferenceResults.vcov_type @@ -47,9 +75,7 @@ ~TripleDifferenceResults.p_value ~TripleDifferenceResults.conf_int ~TripleDifferenceResults.n_obs - ~TripleDifferenceResults.n_treated_eligible - ~TripleDifferenceResults.n_treated_ineligible - ~TripleDifferenceResults.n_control_eligible - ~TripleDifferenceResults.n_control_ineligible - ~TripleDifferenceResults.estimation_method + ~TripleDifferenceResults.group_time_effects + ~TripleDifferenceResults.groups + ~TripleDifferenceResults.time_periods diff --git a/docs/api/index.rst b/docs/api/index.rst index f74caa4ce..6fed84022 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -82,7 +82,6 @@ Result containers returned by estimators: diff_diff.duration_did_results.DurationDiDResults diff_diff.duration_did_results.DurationDiDPretestResults diff_diff.Comparison2x2 - diff_diff.StaggeredTripleDiffResults diff_diff.TWFEWeightsResult diff_diff.RegressionDiscontinuityResults diff_diff.RDPlotResult diff --git a/docs/api/staggered.rst b/docs/api/staggered.rst index 70cf2d00f..b56abf518 100644 --- a/docs/api/staggered.rst +++ b/docs/api/staggered.rst @@ -151,15 +151,3 @@ Bootstrap inference results for Sun-Abraham estimation. :undoc-members: :show-inheritance: -StaggeredTripleDiffResults --------------------------- - -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: - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/api/triple_diff.rst b/docs/api/triple_diff.rst index 41ecc3b20..797a20ea1 100644 --- a/docs/api/triple_diff.rst +++ b/docs/api/triple_diff.rst @@ -55,14 +55,19 @@ numbers are identical to the removed class's. unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - -2x2x2 mode returns :class:`~diff_diff.TripleDifferenceResults`; staggered mode -returns :class:`~diff_diff.StaggeredTripleDiffResults` (the containers unify at -4.0, row M-014). ``cluster=`` gives Liang-Zeger CR1 in 2x2x2 mode and raises in -staggered mode, where unit-level clustering is available through -``n_bootstrap > 0``. + es = res.aggregate("event_study") # post-fit, no refit + cohorts = res.aggregate("group") + eq414 = res.aggregate("simple", weights="event_time") # paper Eq. 4.14 overall + +Both designs return ONE :class:`~diff_diff.TripleDifferenceResults` (4.0, row M-014): +``res.design`` is ``"2x2x2"`` or ``"staggered"``, the canonical ``att`` / ``se`` / +``t_stat`` / ``p_value`` / ``conf_int`` are populated in both, and the staggered +design's event-study and cohort surfaces are post-fit through ``aggregate()`` (the +3.x fit-time ``aggregate=`` / ``balance_e=`` keywords were removed at 4.0, rows +M-140 / M-141; ``balance_e`` is a keyword of ``aggregate()``). ``cluster=`` gives +Liang-Zeger CR1 in 2x2x2 mode and raises in staggered mode, where unit-level +clustering is available through ``n_bootstrap > 0``. .. autoclass:: diff_diff.TripleDifference :no-index: @@ -82,7 +87,10 @@ staggered mode, where unit-level clustering is available through TripleDifferenceResults ----------------------- -Results container for Triple Difference estimation. +Results container for Triple Difference estimation - one class for both designs +(``design`` says which). ``aggregate()`` levels: ``'simple'`` (both designs; with +``weights='event_time'`` the paper's Eq. 4.14 overall), ``'event_study'`` and +``'group'`` (staggered design; bootstrapped fits replay the fit-time bootstrap). .. autoclass:: diff_diff.TripleDifferenceResults :no-index: @@ -98,6 +106,8 @@ Results container for Triple Difference estimation. ~TripleDifferenceResults.print_summary ~TripleDifferenceResults.to_dict ~TripleDifferenceResults.to_dataframe + ~TripleDifferenceResults.aggregate + ~TripleDifferenceResults.epv_summary Estimation Methods ------------------ diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index c6521781b..6c3d4570d 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -30,7 +30,7 @@ groups: - diff_diff/staggered_bootstrap.py - diff_diff/staggered_results.py staggered_triple_diff: - - diff_diff/staggered_triple_diff_results.py + - diff_diff/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 # underscore), so tests/test_doc_deps_integrity.py exempts it from needing @@ -257,15 +257,20 @@ sources: type: user_guide # ── staggered DDD engine (staggered_triple_diff group; the - # StaggeredTripleDifference class was removed at the 4.0 cut) ───── + # StaggeredTripleDifference class was removed at the 4.0 cut, and the + # unified TripleDifferenceResults container - both DDD designs - lives in + # triple_diff_results.py since item 6, row M-014) ───── - diff_diff/staggered_triple_diff_results.py: - drift_risk: low + diff_diff/triple_diff_results.py: + drift_risk: medium docs: - path: docs/methodology/REGISTRY.md section: "StaggeredTripleDifference" type: methodology - - path: docs/api/staggered.rst + - path: docs/methodology/REGISTRY.md + section: "TripleDifference" + type: methodology + - path: docs/api/triple_diff.rst type: api_reference - path: docs/tutorials/16_survey_did.ipynb type: tutorial diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index e6d8273ae..1ba85ebbf 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -2,7 +2,7 @@ This document provides the academic foundations and key implementation requirements for each estimator in diff-diff. It serves as a reference for contributors and users who want to understand the theoretical basis of the methods. -**Result-class field naming.** Since the 4.0 storage flip (ledger rows M-050..M-058, PR 5b) the headline scalar inference fields are stored under ONE native naming pattern on every estimator results container: the canonical quintet `att` / `se` / `t_stat` / `p_value` / `conf_int` (`DiDResults`, `SyntheticDiDResults`, `TROPResults`, `TripleDifferenceResults`, `HeterogeneousAdoptionDiDResults`, `ChangesInChangesResults` always stored them this way; `CallawaySantAnnaResults`, `DMLDiDResults`, `SunAbrahamResults`, `ImputationDiDResults`, `TwoStageDiDResults`, `StackedDiDResults`, `EfficientDiDResults`, `WooldridgeDiDResults`, `ChaisemartinDHaultfoeuilleResults` and `ContinuousDiDResults` flipped from their 3.x `overall_*` / `overall_att_*` storage at the cut, with the fields renamed in slot so the generated `__init__` positional layout is unchanged). The one exception is `StaggeredTripleDiffResults`, which keeps `overall_*` storage behind silent read-only `att` / `se` / ... forwarding properties until the item-6 container unification retires it (its successor is born canonical). The 3.x names (`overall_att`, `overall_se`, `overall_t_stat`, `overall_p_value`, `overall_conf_int`; on `ContinuousDiDResults` also the `overall_att_se` / `overall_att_t_stat` / `overall_att_p_value` / `overall_att_conf_int` spellings and the former silent `overall_se`-style aliases - nine spellings in all) live on as read-only `FutureWarning` properties built by `diff_diff._deprecation.deprecated_field_property`, removed at 5.0; `DMLDiDResults` re-declares the five shims so the warning names the subclass. The deprecated names are plain descriptors, not dataclass fields: they do NOT appear in `dataclasses.fields()` / `dataclasses.asdict()` / `to_dict()` output (spec section 5 forbids deprecated names in serialized output), they are not constructor keywords (`CallawaySantAnnaResults(overall_att=...)` raises `TypeError`), and assignment to one raises `AttributeError`. 3.x pickles load: every flipped container's `__setstate__` rewrites the legacy keys through `diff_diff._deprecation.migrate_renamed_state_keys` (an existing canonical key wins; the legacy key never lingers in `__dict__`). The canonical field is authoritative for documentation, semantics, and computation; the deprecated properties are pure read-throughs and inherit the `safe_inference()` joint-NaN consistency contract automatically. `ContinuousDiDResults` keeps its separate `overall_acrt` / `overall_acrt_se` / ... fields: ACRT is a distinct estimand, not a spelling of the headline. +**Result-class field naming.** Since the 4.0 storage flip (ledger rows M-050..M-058, PR 5b) the headline scalar inference fields are stored under ONE native naming pattern on every estimator results container: the canonical quintet `att` / `se` / `t_stat` / `p_value` / `conf_int` (`DiDResults`, `SyntheticDiDResults`, `TROPResults`, `TripleDifferenceResults`, `HeterogeneousAdoptionDiDResults`, `ChangesInChangesResults` always stored them this way; `CallawaySantAnnaResults`, `DMLDiDResults`, `SunAbrahamResults`, `ImputationDiDResults`, `TwoStageDiDResults`, `StackedDiDResults`, `EfficientDiDResults`, `WooldridgeDiDResults`, `ChaisemartinDHaultfoeuilleResults` and `ContinuousDiDResults` flipped from their 3.x `overall_*` / `overall_att_*` storage at the cut, with the fields renamed in slot so the generated `__init__` positional layout is unchanged). The last `overall_*` container, `StaggeredTripleDiffResults`, was retired by the item-6 DDD container unification (row M-014): `TripleDifferenceResults` serves both DDD designs with native canonical storage. The 3.x names (`overall_att`, `overall_se`, `overall_t_stat`, `overall_p_value`, `overall_conf_int`; on `ContinuousDiDResults` also the `overall_att_se` / `overall_att_t_stat` / `overall_att_p_value` / `overall_att_conf_int` spellings and the former silent `overall_se`-style aliases - nine spellings in all) live on as read-only `FutureWarning` properties built by `diff_diff._deprecation.deprecated_field_property`, removed at 5.0; `DMLDiDResults` re-declares the five shims so the warning names the subclass. The deprecated names are plain descriptors, not dataclass fields: they do NOT appear in `dataclasses.fields()` / `dataclasses.asdict()` / `to_dict()` output (spec section 5 forbids deprecated names in serialized output), they are not constructor keywords (`CallawaySantAnnaResults(overall_att=...)` raises `TypeError`), and assignment to one raises `AttributeError`. 3.x pickles load: every flipped container's `__setstate__` rewrites the legacy keys through `diff_diff._deprecation.migrate_renamed_state_keys` (an existing canonical key wins; the legacy key never lingers in `__dict__`). The canonical field is authoritative for documentation, semantics, and computation; the deprecated properties are pure read-throughs and inherit the `safe_inference()` joint-NaN consistency contract automatically. `ContinuousDiDResults` keeps its separate `overall_acrt` / `overall_acrt_se` / ... fields: ACRT is a distinct estimand, not a spelling of the headline. - **Note:** Bootstrap draw containers (`CSBootstrapResults`, `SABootstrapResults`, `ImputationBootstrapResults`, `TwoStageBootstrapResults`, `EDiDBootstrapResults`: `overall_att_se` / `overall_att_ci` / `overall_att_p_value`; `DCDHBootstrapResults`: `overall_se` / `overall_ci` / `overall_p_value`) keep their estimator-local payload names through the 4.0 storage flip (user decision 2026-10-26): they hold no point estimate, so the headline-quintet contract does not apply, `overall_att_se` literally names "the SE of the overall ATT", and `ci` is not `conf_int`. `results.bootstrap_results.overall_att_se` therefore stays valid and warning-free. @@ -4096,8 +4096,8 @@ shared verbatim. - **Dispatch.** `first_treat=` selects the staggered engine; the 2x2x2 engine serves `(group, partition, post)`. Mixing the two parameter sets is an ERROR in both directions, constructor params included, never a silent guess. The - staggered-only fit params (`unit`, `first_treat`, `aggregate`, `balance_e`) are - keyword-only, so a positionally-written staggered call cannot bind to the 2x2x2 + staggered-only fit params (`unit`, `first_treat`) are 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; in 2x2x2 @@ -4124,13 +4124,12 @@ shared verbatim. the SAME boundary:** `simulate_power` / `simulate_mde` / `simulate_sample_size` reject only those four and accept the three inert ones, so an estimator that is legal to `fit()` is legal to simulate. That equivalence extends to the FIT - kwargs, and the test differs per param because `fit()`'s does: `first_treat` - and `unit` are sentinel-defaulted, so SUPPLYING them at all selects staggered - mode (an explicit `first_treat=None` still does, then fails on a missing - column), while `aggregate`/`balance_e` default to `None` and only a NON-`None` - value is staggered-only. Power therefore keys on presence for the first pair - and on value for the second — keying on presence throughout would reject - `estimator_kwargs={"aggregate": None}`, a configuration `fit()` accepts. An earlier 3(b) revision rejected all + kwargs: `first_treat` and `unit` are sentinel-defaulted, so SUPPLYING them at + all selects staggered mode (an explicit `first_treat=None` still does, then + fails on a missing column), and power keys on their presence. (Through 3.x the + fit-time `aggregate`/`balance_e` were a second, by-VALUE pair; they were removed + at 4.0 with rows M-140/M-141, so `estimator_kwargs={"aggregate": ...}` now + surfaces as `fit()`'s own `TypeError`.) An earlier 3(b) revision rejected all seven at the power front door, which made `TripleDifference(seed=7)` fittable but not simulatable - `seed` is set habitually for reproducibility and the `simulate_*` helpers take their own separate `seed=`. @@ -4167,9 +4166,10 @@ shared verbatim. 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 - `StaggeredTripleDiffResults` is already slated for the 4.0 container port - (row M-014), so the fix is sequenced there and tracked as a `TODO.md` row - covering BOTH containers. Estimators that do expose it + the 4.0 container unification (row M-014, item 6) landed WITHOUT it - the + unified `TripleDifferenceResults` records `n_bootstrap` and `inference_method` + but not `seed` - so the gap stays tracked as a `TODO.md` row covering BOTH + containers. Estimators that do expose it (`ContinuousDiDResults`, `EfficientDiDResults`, `SyntheticDiDResults`) are the precedent for the eventual shape. - **Note (`anticipation` domain validated from birth):** `anticipation` is one of @@ -4238,10 +4238,54 @@ shared verbatim. 3.29438582 → 2.99470938 with `n_never_enabled` 24 → 0, no error and no 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 - downstream consumer changes in 3.9. +- **Note (results containers, row M-014):** since 4.0 (item 6, 2026-10-27) BOTH + designs return ONE `TripleDifferenceResults` (`diff_diff/triple_diff_results.py`, + re-exported from `triple_diff.py`) with a `design` discriminator (`"2x2x2"` / + `"staggered"`): the 2x2x2 positional layout is byte-stable, the staggered + structure (`group_time_effects`, `groups`, `time_periods`, the unit counts, + `control_group`, `base_period`, `anticipation`, `bootstrap_results`, ...) is + appended with defaults, the canonical quintet is native in both designs, the + group-time table is empty in 2x2x2, and `df_inference` records the 2x2x2 engine's + t df (`n_obs - 8 - len(covariates)` on non-survey fits; the LWDiDResults contract). + `StaggeredTripleDiffResults` (storage `overall_*`, `to_dict` keys `overall_*`, the + five fit-time `overall_*_es` fields) is removed; 3.x 2x2x2 pickles load via + `__setstate__`, 3.x staggered pickles do not. Through 3.9 the facade returned + `TripleDifferenceResults` (2x2x2) or `StaggeredTripleDiffResults` (staggered). +- **Note (post-fit aggregate() - rows M-014/M-140/M-141):** the fit-time + `aggregate=`/`balance_e=` keywords - the one documented exception to the section-6 + aggregate-postfit program through 3.x - were REMOVED at 4.0 (item 6; `fit()` + raises `TypeError`). `TripleDifferenceResults` is an `AggregationMixin` adopter + (`_AGGREGATE_SUPPORTED = ('simple', 'event_study', 'group')`; no `'total'`, which + the DDD estimators never offered): `aggregate('simple')` relays the stored + headline in BOTH designs (one row; `n_kind` `units` in the staggered design, `obs` + in 2x2x2; the df column is `resolve_inference_df` provenance - the 2x2x2 t df, the + survey df on survey fits, NaN where the normal reference or percentile inference + governed); `aggregate('event_study')` / `aggregate('group')` RECOMPUTE from the + retained CS aggregation kit (built by `staggered.py`'s `_build_aggregation_kit` on + the eligibility-zeroed `precomputed` copy, so the eligible-treated weighting below + is preserved exactly; `balance_e=` on `'event_study'`), and on bootstrapped fits + REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the + CS/DMLDiD/EfficientDiD mechanism: same weight stream, so sup-t bands and percentile + inference match a 3.x fit-time aggregation to BLAS reassociation; pre-replay + pickles and cross-backend artifacts fail closed with a refit message). The 2x2x2 + design fails `'event_study'` / `'group'` / `weights=` / `balance_e=` closed with a + message naming `first_treat=` (a single ATT has no cells to aggregate). The + paper's Eq. 4.14 overall (the 3.x fit-time `overall_att_es` family) is + `aggregate('simple', weights='event_time')` - a `weights=` selector on the existing + type, not a vocabulary word (user decision 2026-10-27); it accepts `balance_e=` + (the balanced horizons are averaged, as the 3.x field did), reproduces the engine's + df rule (the per-statistic effective df when the survey design yields one, else the + fit's sentinelled survey df carried as the `ddd_df_survey_original` bookkeeping key - + None on plain fits, the survey df on survey fits, 0 on replicate designs with + undefined df, so inference the fit reported as NaN stays NaN post-fit) and both + 3.x warnings (no post-treatment horizon -> NaN row; finite point estimate with an + undefined SE -> the cause-accurate NaN-SE warning); `weights='event_time'` on any + other type, and `balance_e=` on the plain `'simple'` relay, are rejected rather than + silently ignored (the mixin's shared `balance_e` rejection text lists `'simple'` + because of this one selector). `summary()` no longer prints the event-study / + cohort / Eq. 4.14 blocks (it points at the post-fit calls); `to_dataframe()` keeps + `'group_time'` (default in the staggered design) and `'overall'` (default in 2x2x2), + while `'event_study'` / `'group'` raise with the post-fit pointer. ## StaggeredTripleDifference @@ -4353,9 +4397,10 @@ Overall ATT: two summaries are available. (1) The default `att` is the cohort-size-weighted average across post-treatment (g,t) pairs (reuses `CallawaySantAnnaAggregationMixin._aggregate_simple()`) — the library-wide Callaway-Sant'Anna "simple" convention, matching R `agg_ddd(type="simple")`. -(2) The opt-in `overall_att_es` is the paper's Equation 4.14 overall — the unweighted -mean of the post-treatment event-study effects ES(e) — populated when -`aggregate="event_study"` or `"all"` (see the labeled Note below). +(2) `results.aggregate("simple", weights="event_time")` is the paper's Equation 4.14 +overall — the unweighted mean of the post-treatment event-study effects ES(e) — +computed post-fit (through 3.x the fit-time `overall_att_es` field; see the labeled +Note below). Group effects: average across post-treatment time periods for each cohort. Reuses `CallawaySantAnnaAggregationMixin._aggregate_by_group()`. @@ -4378,14 +4423,15 @@ uncertainty in cohort-share weights, inherited from the CallawaySantAnna mixin. - **Note:** The default overall ATT (`att`) is the Callaway-Sant'Anna simple post-treatment (g,t) average — the library-wide convention across staggered estimators — and is NOT the paper's Equation 4.14 overall (which averages the - event-study effects). The paper's Eq 4.14 form is exposed opt-in as `overall_att_es` - (populated only when `aggregate` is `"event_study"`/`"all"`), computed as the + event-study effects). The paper's Eq 4.14 form is exposed post-fit as + `results.aggregate("simple", weights="event_time")` (through 3.x the fit-time + `overall_att_es` field), computed as the unweighted mean of the post-treatment ES(e) over `e >= -anticipation`. Its analytical SE is the influence function of that mean (the average of the per-event-time combined IFs, routed through the same survey-aware variance estimator as the per-e effects); a multiplier-bootstrap SE replaces it when `n_bootstrap > 0`. The two summaries answer - different questions and generally differ; `overall_att_es` is cross-validated against - R `agg_ddd(type="eventstudy")$overall.att`/`overall.se`. + different questions and generally differ; the Eq. 4.14 overall is cross-validated + against R `agg_ddd(type="eventstudy")$overall.att`/`overall.se`. *Standard errors:* @@ -4442,7 +4488,8 @@ confidence bands (sup-t) for event study. **Reference implementation(s):** - R `triplediff` (companion package by paper authors) — cross-validated in `tests/test_methodology_staggered_triple_diff.py` (group-time ATT/SE for both control - groups, plus the Eq. 4.14 overall `overall_att_es` against `agg_ddd(type="eventstudy")`). + groups, plus the Eq. 4.14 overall `aggregate("simple", weights="event_time")` against + `agg_ddd(type="eventstudy")`). CSV fixtures are gitignored and regenerated on-the-fly from `benchmarks/R/benchmark_staggered_triplediff.R`; the JSON golden is committed. diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index 2e589695e..19e1b0f08 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -155,9 +155,9 @@ Field semantics: fallback). - `headline_attribute` — the raw result attribute the scalar comes from (`"att"` for every estimator container since the 4.0 - storage flip, rows M-050..M-058; `"overall_att"` only for the - unflipped `StaggeredTripleDiffResults` until the item-6 container - unification; `"twfe_estimate"` for the BaconDecomposition read-out; + storage flip, rows M-050..M-058, and - since the item-6 container + unification retired `StaggeredTripleDiffResults` - for both + `TripleDifferenceResults` designs; `"twfe_estimate"` for the BaconDecomposition read-out; `"avg_att"` left with the removed MultiPeriodDiDResults at 4.0), OR `None` when `aggregation == "no_scalar_headline"` (the dCDH `trends_linear=True, L_max>=2` branch where `att` is intentionally NaN by @@ -402,7 +402,7 @@ a library setting. container (M-093 seventh amendment). Other staggered families with event-study output (`ImputationDiDResults`, `TwoStageDiDResults`, `SpilloverDiDResults`, `StackedDiDResults`, `EfficientDiDResults`, - `StaggeredTripleDiffResults`, `WooldridgeDiDResults`, + `TripleDifferenceResults` (staggered design), `WooldridgeDiDResults`, `ChaisemartinDHaultfoeuilleResults`) do not yet have a power adapter and therefore render the `no_detected_violation` tier as `underpowered` with the fallback reason recorded in @@ -594,10 +594,15 @@ a library setting. read `"att"` where they read `"overall_att"` under 3.x (BR's `headline` block carries no name key, so BR's bump covers `headline_attribute` only). Value changes in an existing string field are renames under the policy - above, hence the major bumps. `StaggeredTripleDiffResults` is unflipped - until item 6: its `headline_attribute` stays `"overall_att"`, while its - `headline_metric.name` is `"att"` like every other scalar headline (the - single alias row reads the canonical forwarding properties). + above, hence the major bumps. Item 6 (2026-10-27, the DDD container + unification, row M-014) folds into the SAME bump - DR 3.0 / BR 4.0 had not + shipped: `StaggeredTripleDiffResults` is retired, so the DR class-name + string and BR's `class_name` read `TripleDifferenceResults` for staggered DDD + fits, their `target_parameter.headline_attribute` reads `"att"` (the last + `"overall_att"`), and both schemas gain an additive `design` key - top-level + in DR, inside BR's class-name block - holding `"2x2x2"` / `"staggered"` for + `TripleDifferenceResults` and `None` for every other estimator (additive + keys need no bump under the policy above). No further version change. - **Note:** Schema version 2.0 (both BR and DR). The BR/DR gap #6 target-parameter PR adds the `headline.status` / diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index 2c4f0b651..f7aaf11e0 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -119,8 +119,25 @@ results = TripleDifference().fit(data, outcome="y", unit="id", time="period", first_treat="g", partition="p") ``` -The staggered fit parameters are keyword-only on the merged class. The results container is the -unified `TripleDifference` shape; the 2x2x2 design reads as a degenerate single-ATT view of it. +The staggered fit parameters are keyword-only on the merged class. Both designs return ONE +`TripleDifferenceResults` (row M-014; `results.design` is `"2x2x2"` or `"staggered"`); the +2x2x2 design reads as a degenerate single-ATT view of it (empty group-time table). The 3.x +`StaggeredTripleDiffResults` container is gone: read `results.att` / `results.se` / ... where you +read `overall_att` / `overall_se` / ..., and its `to_dict()` emits the canonical keys. The +fit-time `aggregate=` / `balance_e=` keywords are gone too (rows M-140 / M-141) - aggregation is +post-fit, and the paper's Eq. 4.14 overall (the 3.x `overall_att_es` field) is a `weights=` +selector on `aggregate('simple')`: + +```python +# 4.0: post-fit aggregation on the staggered design (bootstrapped fits replay the +# fit-time multiplier bootstrap, so sup-t bands and percentile inference match) +es = results.aggregate("event_study", balance_e=None) # EventStudyResults +cohorts = results.aggregate("group") # AggregationResult +eq414 = results.aggregate("simple", weights="event_time") # the Eq. 4.14 overall, one row +``` + +3.x pickles of the 2x2x2 `TripleDifferenceResults` load unchanged (the new fields are +backfilled); 3.x `StaggeredTripleDiffResults` pickles cannot load, like every removed class. ### QDiD → ChangesInChanges(method="qdid") @@ -503,10 +520,10 @@ does not mean no action is required, so read the `Fix` cell. | M-151 | lwdid-results-contract | `diff_diff:LWDiDResults.pvalue` | `diff_diff:LWDiDResults.p_value` | Read `.p_value`; the alias warns from 4.0 and is removed at 5.0. | | M-152 | lwdid-results-contract | `diff_diff:LWDiDResults.ci` | `diff_diff:LWDiDResults.conf_int` | Read `.conf_int`; the alias warns from 4.0 and is removed at 5.0. | | M-013 | merge-ddd | `diff_diff:StaggeredTripleDifference` | `diff_diff:TripleDifference.fit[first_treat]` | See the merges section for the worked before/after. | -| M-014 | merge-ddd | `diff_diff:StaggeredTripleDiffResults` | — | Read the unified `TripleDifference` results shape (a degenerate single-ATT view for 2x2x2) instead of this container. | +| M-014 | merge-ddd | `diff_diff:StaggeredTripleDiffResults` | — | Both designs return `TripleDifferenceResults` (`results.design` says which); read `att` / `se` / ... where you read `overall_*`; event-study and cohort surfaces are `results.aggregate(...)`. 3.x staggered pickles cannot load. | | M-085 | merge-ddd | `diff_diff:TripleDifference.fit[time]` | — | In 2x2x2 mode, `time=` raises - pass `post=` for the 0/1 post dummy. `time=` now means the staggered calendar column only. | -| M-140 | merge-ddd | `diff_diff:TripleDifference.fit[aggregate]` | `diff_diff:TripleDifferenceResults.aggregate` *(not yet implemented)* | No successor yet - post-fit `aggregate()` is not implemented on the DDD container (open `TODO.md` row). Keep the fit-time call until it lands. | -| M-141 | merge-ddd | `diff_diff:TripleDifference.fit[balance_e]` | `diff_diff:TripleDifferenceResults.aggregate[balance_e]` *(not yet implemented)* | No successor yet - post-fit `aggregate()` is not implemented on the DDD container (open `TODO.md` row). Keep the fit-time call until it lands. | +| M-140 | merge-ddd | `diff_diff:TripleDifference.fit[aggregate]` | `diff_diff:TripleDifferenceResults.aggregate` | Drop the keyword and call `results.aggregate('event_study')` / `aggregate('group')` / `aggregate('simple')` post-fit; the Eq. 4.14 overall (3.x `overall_att_es`) is `aggregate('simple', weights='event_time')`. | +| M-141 | merge-ddd | `diff_diff:TripleDifference.fit[balance_e]` | `diff_diff:TripleDifferenceResults.aggregate[balance_e]` | Pass `balance_e=` to `results.aggregate('event_study', balance_e=...)` or to `aggregate('simple', weights='event_time', balance_e=...)`. | | M-010 | merge-mpd | `diff_diff:MultiPeriodDiD` | `diff_diff:TwoWayFixedEffects.fit[event_study]` | See the merges section for the worked before/after. | | M-011 | merge-mpd | `diff_diff:MultiPeriodDiDResults` | — | Read the unified event-study surface on the merged `TwoWayFixedEffects` results instead of this container. | | M-012 | merge-mpd | `diff_diff:PeriodEffect` | — | Superseded by the unified event-study representation - read the event-study surface rather than per-period `PeriodEffect` objects. | diff --git a/docs/tutorials/08_triple_diff.ipynb b/docs/tutorials/08_triple_diff.ipynb index 3dcb6b082..6a7d3f7a7 100644 --- a/docs/tutorials/08_triple_diff.ipynb +++ b/docs/tutorials/08_triple_diff.ipynb @@ -33,10 +33,10 @@ "execution_count": 1, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:31.277134Z", - "iopub.status.busy": "2026-08-10T22:40:31.276851Z", - "iopub.status.idle": "2026-08-10T22:40:31.990810Z", - "shell.execute_reply": "2026-08-10T22:40:31.990320Z" + "iopub.execute_input": "2026-10-27T14:33:26.033599Z", + "iopub.status.busy": "2026-10-27T14:33:26.033357Z", + "iopub.status.idle": "2026-10-27T14:33:26.748922Z", + "shell.execute_reply": "2026-10-27T14:33:26.748424Z" } }, "outputs": [], @@ -65,10 +65,10 @@ "execution_count": 2, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:31.992000Z", - "iopub.status.busy": "2026-08-10T22:40:31.991905Z", - "iopub.status.idle": "2026-08-10T22:40:32.013375Z", - "shell.execute_reply": "2026-08-10T22:40:32.013034Z" + "iopub.execute_input": "2026-10-27T14:33:26.750328Z", + "iopub.status.busy": "2026-10-27T14:33:26.750236Z", + "iopub.status.idle": "2026-10-27T14:33:26.768097Z", + "shell.execute_reply": "2026-10-27T14:33:26.767696Z" } }, "outputs": [ @@ -126,10 +126,10 @@ "execution_count": 3, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.014409Z", - "iopub.status.busy": "2026-08-10T22:40:32.014343Z", - "iopub.status.idle": "2026-08-10T22:40:32.018809Z", - "shell.execute_reply": "2026-08-10T22:40:32.018452Z" + "iopub.execute_input": "2026-10-27T14:33:26.783151Z", + "iopub.status.busy": "2026-10-27T14:33:26.783068Z", + "iopub.status.idle": "2026-10-27T14:33:26.787481Z", + "shell.execute_reply": "2026-10-27T14:33:26.787156Z" } }, "outputs": [ @@ -215,10 +215,10 @@ "execution_count": 4, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.019713Z", - "iopub.status.busy": "2026-08-10T22:40:32.019655Z", - "iopub.status.idle": "2026-08-10T22:40:32.024900Z", - "shell.execute_reply": "2026-08-10T22:40:32.024600Z" + "iopub.execute_input": "2026-10-27T14:33:26.788633Z", + "iopub.status.busy": "2026-10-27T14:33:26.788565Z", + "iopub.status.idle": "2026-10-27T14:33:26.793791Z", + "shell.execute_reply": "2026-10-27T14:33:26.793462Z" } }, "outputs": [ @@ -281,10 +281,10 @@ "execution_count": 5, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.025736Z", - "iopub.status.busy": "2026-08-10T22:40:32.025674Z", - "iopub.status.idle": "2026-08-10T22:40:32.031643Z", - "shell.execute_reply": "2026-08-10T22:40:32.031276Z" + "iopub.execute_input": "2026-10-27T14:33:26.794785Z", + "iopub.status.busy": "2026-10-27T14:33:26.794729Z", + "iopub.status.idle": "2026-10-27T14:33:26.799844Z", + "shell.execute_reply": "2026-10-27T14:33:26.799415Z" } }, "outputs": [ @@ -330,10 +330,10 @@ "execution_count": 6, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.032445Z", - "iopub.status.busy": "2026-08-10T22:40:32.032376Z", - "iopub.status.idle": "2026-08-10T22:40:32.038752Z", - "shell.execute_reply": "2026-08-10T22:40:32.038506Z" + "iopub.execute_input": "2026-10-27T14:33:26.800726Z", + "iopub.status.busy": "2026-10-27T14:33:26.800666Z", + "iopub.status.idle": "2026-10-27T14:33:26.806354Z", + "shell.execute_reply": "2026-10-27T14:33:26.805973Z" } }, "outputs": [ @@ -419,10 +419,10 @@ "execution_count": 7, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.039712Z", - "iopub.status.busy": "2026-08-10T22:40:32.039651Z", - "iopub.status.idle": "2026-08-10T22:40:32.045795Z", - "shell.execute_reply": "2026-08-10T22:40:32.045473Z" + "iopub.execute_input": "2026-10-27T14:33:26.807284Z", + "iopub.status.busy": "2026-10-27T14:33:26.807223Z", + "iopub.status.idle": "2026-10-27T14:33:26.812655Z", + "shell.execute_reply": "2026-10-27T14:33:26.812288Z" } }, "outputs": [ @@ -462,16 +462,16 @@ "execution_count": 8, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.046691Z", - "iopub.status.busy": "2026-08-10T22:40:32.046625Z", - "iopub.status.idle": "2026-08-10T22:40:32.146203Z", - "shell.execute_reply": "2026-08-10T22:40:32.145818Z" + "iopub.execute_input": "2026-10-27T14:33:26.813569Z", + "iopub.status.busy": "2026-10-27T14:33:26.813511Z", + "iopub.status.idle": "2026-10-27T14:33:26.905146Z", + "shell.execute_reply": "2026-10-27T14:33:26.904720Z" } }, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABKYAAAIDCAYAAAA+HxaBAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAA7Z9JREFUeJzs3Qd4VFX6BvA3vVBC6L333iwgSAcbIirS7boq6tq7/9VV17r2XtdCEQUBFaUqCqiA9N57b6EkIW3+z3suN0y5SSYhyUyS9/fsLGbumTtn7rQz3/3Od0JcLpcLIiIiIiIiIiIihSy0sO9QRERERERERESEFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCDCA3O3IiLZW7FiBRISEjL/joqKQlxcHGrVqoWYmJigvG1O0tLSsGHDBpw4cQLVq1c3l5CQkLPap5y9bdu24cCBAyhdujRq1qxp/hXLsWPHsHz5ctStW9ccm0BITU3FX3/95Xf7KlWqoFGjRgg2a9aswaFDh9ClS5cCuw8eJx6vFi1aID4+vsDupyjZsWMH9u/fbz7Lq1WrhvLly+tzN8AWLVqEsLAwtGvXLse269evN8+fLTQ01HwnN2zY0DyngbB582bs3r0b55xzjl99yG17EZESySUiEoQuuOACFz+ivC9hYWGudu3auV577TVXUlJSUN02K2z/4IMPusqWLeuxz7i4ONdNN93kWrZsmUf7RYsWmUswCcY+nY2EhATX448/7qpevbrPc92kSRPXM888Y9qUdL///rs5Js8//3zA+rBnzx7H92RWl+uuu84VjAYMGGD6l5qaWiD7X7t2beYxePTRR10l2eHDh12PPPKI4/ubn7sjR450zZkzJ9DdLLHq1KnjatGihV9thw8f7vg+j46Odv3jH/9wHT9+vED6uGHDBvP5d+rUKZ9to0aNMn3YsmVLntuLiIgnZUyJSFDjGcbIyEikp6ebTKZNmzZhyZIl5vLhhx/i559/Ru3atYPqtt4GDx6MKVOmmP+uWrUqatSogYMHD5oz+Z988gnKlSuHV155xaM9bdy4EcEiGPuUVzzuffv2xdq1a83fFStWNBlBiYmJ2Llz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4iy7Gk4iIiLFnWpMiYiIiIiIiIhIQHguSSIiIiIiIiIiIlJIFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCAUmBIRERERERERkYBQYEpERERERERERAJCgSkREREREREREQkIBaZERERERERERCQgFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCAUmBIRERERERERkYBQYEpERERERERERAJCgSkREREREREREQkIBaZERERERERERCQgFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCAUmBIRERERERERkYBQYEpERERERERERAJCgSkREREREREREQkIBaZERERERERERCQgFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJiPDA3K2IBCOXy4Uff/wRP/30EzZt2oRTp06hevXqqFu3LgYOHIiOHTuiuNmwYQNGjhyJG264Af/4xz/8us2MGTPw7bffmtuGhYWZ43P++efj6quvRlxcXIH3WURERALjgQcewNy5c/1uP2/ePDNWKK74+O6//348/PDDZqzoj99++w1ff/011q1bZ/6uU6cOzjnnHFxzzTUoX758AfdYRIKSS0TE5XJt3LjR1a5dOxc/FrK63HLLLYV2rK6//npXv379Cvx+lixZYh7b448/nmPb9PR014gRI7I8PtHR0a61a9dmtl+5cqXrvPPOc3300Udn3c/83JeIiIjkzaWXXprtWMn7kpqaGpBDvXXrVjNueO211wr0fr7//nvzON977z2/2t9xxx1ZHquIiAjXwoULC+QxFNbxEJG8UcaUiGDPnj3o2rWr+bdGjRq45ZZbzJmrUqVKmeu2bt2K7777DkeOHCm0o7VixQpzv8Hk888/x1dffYWoqCjccccd6N69O8qU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8oV74+abb7Zrr722ynLHer6GWu5YXe1Z7hgAgMbT0NdfVXIDAACgBSemhgwZYkuXLg3oIzVs2DA38qlk1d4mpoJL1vV8snXr1gYJXBUY67nbtGlDxRQAAI2koa+/em7/OKK18T631xKhIc6fqtK8xuwAAKDhNfT114sbahM/hURiStP3tCLfwoULXaWUvP/++xYfH2/9+/ev91J8VU0BAADUNY5ITk5udQeN+AkAADRk/BQSialRo0bZnXfeaePGjbNjjz3W9ZiaPn26W60vISHB7aMm5pMnT3Z/X7FihRu9vOKKK6xz5852++231+p1OnXqZGvWrLHExERX8l/fvKmCeo2GmCoIAAAa//qrkT4FVYojWiPiJwAAWp78EIqfwipDqC79xx9/tBkzZrhs2pgxYwKm9pWVldlTTz1V5We05PHEiRMtVE6s3rsasJKYAgCA6y+InwAACEX5IZS/CImKKY+m7dU0dS8yMtJVSAEAAAAAAKBloMMkAAAAAAAAmgSJqXqkFQDVK8t/JUAAANCwuP42b5w/AABa9/U3pHpMAQAAAAAAoPWgYgoAAAAAAABNgsQUAAAAAAAAmgSJKQAAAAAAADQJElO1NHToUOvSpYu79e/f30488UT75JNPGvbsAADQyrz++uu+6223bt3sgAMOsGuvvdYyMzPr5fkHDx5s7777br08F/bs2WefDTifBx54oN18882Wl5fH4QMAoB4dcsghvmtuv3797Nhjj7W33367Xp776aeftiOPPNIaCompWlq/fr1deOGFNmPGDHvjjTesT58+dswxx9j06dMb7OQAANDaFBYW2rp162zatGnu9uCDD9o777xjp5xyitXHei167qKionp5r9izrVu3Wk5OjoufvvrqK7v77rvt3//+t5111lkcPgAA6tGGDRvs9NNPd9fct956y0aNGmUnnXSSffDBB/v83AUFBbZx40ZrKCSm6iA5OdllHwcOHGiPPvqodezY0f7zn//Y559/bj169LCPPvrIDj30UPf37777zv3MZ599Zscdd5xLZI0fP96NHAIAgN3r3Lmzde3a1Q4//HC799573UDQypUrXWCkCipVPqmC+YorrrAtW7YE/Owrr7zirrkaLTz55JNt5syZvpHE/Px8u/zyy931XNXQaHhhYWG+iimN3t5222323nvvuYSVzs/vf/97+/Wv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Wze77LLLXMDZFBYvXmx33323DRo0yL2nM888s16ff968eQGfOTw83NLS0uzoo4+2zz77zBpCjx49qv0cem191j/+8Y+2evXqBnltAEDdnHvuubWKnwYMGBCSh/axxx4LeJ9K9HTq1Ml9ruXLl1tLcuutt9rAgQPtpJNOqvLY1KlTbdiwYRYbG2sdOnSwK6+80nJzc+vldYuLi6t8H5QgGzt2rL344ovWFLZt22b//e9/beLEiRYfH+/e0w8//FCvr/Gzn/0s4DPHxMRY37597eabb671YGxdPPvsszV+Dn1Oxax33nlnvb8usCckpoBa6tOnj1VWVgbcxo8fb8nJyVW26xdm/akqn1BzxBFH+N7njz/+aCNHjnQVTvX5Xv/2t7816WjLww8/bF27drXjjz8+YHtBQYEdeuih7mL89ddf2+bNm+3kk0+2U045xf7v//6vXt/DTz/95I6xArY//elP9vLLL9shhxzigpzGNmnSJPdeNBLWkJQI1OuUlpbaF198YYWFhXbUUUfZ559/bo3p1FNPdYmxRx99tFFfFwBQveeffz4gTvIGLfT/tP92XZ9VMaW/K/kTar788kv33pQw0C/4n3zyiY0ZM6beYh4le/T8v/rVr6wprFu3zp577jm7+uqrq43tfvGLX9jll19uOTk59uGHH9qnn35qxx57rJWXl9fbezjnnHPcMVAF9HfffecSgGeffbY99dRT1tj++te/usHmM844w33uhhIREeH7N7Bp0yZX9f+HP/zBJa0akxJWv/zlL92xVowMNCYSU0Ar1rlzZ3fRVRJHo4EtgYLFF154wY1iBvvLX/7iqqn+9a9/udEoVYxpypcSRr/5zW9cQFDfNMKmRMm1117rXrs+q7NqS4GdAn2NgDYGBVj77bef/fOf/3TBqo57Y1LV1FlnneXOs0ZgAQCoT6oYUlWwKnSVSGiqip769o9//MNV7Jx22mkB24uKilxFuCpqVCXVpk0bGzp0qEtWffPNNy7x2BBJkp49e7prueK1phhsUuymiiklplRl31izGzRgrEFFJT6///57a0w6x4qHFcMBjYnEFNBIPaZUOaNtDzzwgL355puuD44ucqq6WrJkidvnjTfecL/QK+BRJdPMmTOrPK9GkP785z+7UmoFBrqAqeJH07X2NomgarCsrCwXeMjs2bPdc6rqRAGK3mvwSJWq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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABKYAAAIDCAYAAAA+HxaBAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjIsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvgI3uAAAAAAlwSFlzAAAPYQAAD2EBqD+naQAA7Z9JREFUeJzs3Qd4VFX6BvA3vVBC6L333iwgSAcbIirS7boq6tq7/9VV17r2XtdCEQUBFaUqCqiA9N57b6EkIW3+z3suN0y5SSYhyUyS9/fsLGbumTtn7rQz3/3Od0JcLpcLIiIiIiIiIiIihSy0sO9QRERERERERESEFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCDCA3O3IiLZW7FiBRISEjL/joqKQlxcHGrVqoWYmJigvG1O0tLSsGHDBpw4cQLVq1c3l5CQkLPap5y9bdu24cCBAyhdujRq1qxp/hXLsWPHsHz5ctStW9ccm0BITU3FX3/95Xf7KlWqoFGjRgg2a9aswaFDh9ClS5cCuw8eJx6vFi1aID4+vsDupyjZsWMH9u/fbz7Lq1WrhvLly+tzN8AWLVqEsLAwtGvXLse269evN8+fLTQ01HwnN2zY0DyngbB582bs3r0b55xzjl99yG17EZESySUiEoQuuOACFz+ivC9hYWGudu3auV577TVXUlJSUN02K2z/4IMPusqWLeuxz7i4ONdNN93kWrZsmUf7RYsWmUswCcY+nY2EhATX448/7qpevbrPc92kSRPXM888Y9qUdL///rs5Js8//3zA+rBnzx7H92RWl+uuu84VjAYMGGD6l5qaWiD7X7t2beYxePTRR10l2eHDh12PPPKI4/ubn7sjR450zZkzJ9DdLLHq1KnjatGihV9thw8f7vg+j46Odv3jH/9wHT9+vED6uGHDBvP5d+rUKZ9to0aNMn3YsmVLntuLiIgnZUyJSFDjGcbIyEikp6ebTKZNmzZhyZIl5vLhhx/i559/Ru3atYPqtt4GDx6MKVOmmP+uWrUqatSogYMHD5oz+Z988gnKlSuHV155xaM9bdy4EcEiGPuUVzzuffv2xdq1a83fFStWNBlBiYmJ2Llz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4iy7Gk4iIiLFnWpMiYiIiIiIiIhIQHguSSIiIiIiIiIiIlJIFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCAUmBIRERERERERkYBQYEpERERERERERAJCgSkREREREREREQkIBaZERERERERERCQgFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCAUmBIRERERERERkYBQYEpERERERERERAJCgSkREREREREREQkIBaZERERERERERCQgFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJCAWmREREREREREQkIBSYEhERERERERGRgFBgSkREREREREREAkKBKRERERERERERCQgFpkREREREREREJCAUmBIRERERERERkYBQYEpERERERERERAJCgSkREREREREREQkIBaZERERERERERCQgFJgSEREREREREZGAUGBKREREREREREQCQoEpEREREREREREJiPDA3K2IBCOXy4Uff/wRP/30EzZt2oRTp06hevXqqFu3LgYOHIiOHTuiuNmwYQNGjhyJG264Af/4xz/8us2MGTPw7bffmtuGhYWZ43P++efj6quvRlxcXIH3WURERALjgQcewNy5c/1uP2/ePDNWKK74+O6//348/PDDZqzoj99++w1ff/011q1bZ/6uU6cOzjnnHFxzzTUoX758AfdYRIKSS0TE5XJt3LjR1a5dOxc/FrK63HLLLYV2rK6//npXv379Cvx+lixZYh7b448/nmPb9PR014gRI7I8PtHR0a61a9dmtl+5cqXrvPPOc3300Udn3c/83JeIiIjkzaWXXprtWMn7kpqaGpBDvXXrVjNueO211wr0fr7//nvzON977z2/2t9xxx1ZHquIiAjXwoULC+QxFNbxEJG8UcaUiGDPnj3o2rWr+bdGjRq45ZZbzJmrUqVKmeu2bt2K7777DkeOHCm0o7VixQpzv8Hk888/x1dffYWoqCjccccd6N69O8qU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8oV74+abb7Zrr722ynLHer6GWu5YXe1Z7hgAgMbT0NdfVXIDAACgBSemhgwZYkuXLg3oIzVs2DA38qlk1d4mpoJL1vV8snXr1gYJXBUY67nbtGlDxRQAAI2koa+/em7/OKK18T631xKhIc6fqtK8xuwAAKDhNfT114sbahM/hURiStP3tCLfwoULXaWUvP/++xYfH2/9+/ev91J8VU0BAADUNY5ITk5udQeN+AkAADRk/BQSialRo0bZnXfeaePGjbNjjz3W9ZiaPn26W60vISHB7aMm5pMnT3Z/X7FihRu9vOKKK6xz5852++231+p1OnXqZGvWrLHExERX8l/fvKmCeo2GmCoIAAAa//qrkT4FVYojWiPiJwAAWp78EIqfwipDqC79xx9/tBkzZrhs2pgxYwKm9pWVldlTTz1V5We05PHEiRMtVE6s3rsasJKYAgCA6y+InwAACEX5IZS/CImKKY+m7dU0dS8yMtJVSAEAAAAAAKBloMMkAAAAAAAAmgSJqXqkFQDVK8t/JUAAANCwuP42b5w/AABa9/U3pHpMAQAAAAAAoPWgYgoAAAAAAABNgsQUAAAAAAAAmgSJKQAAAAAAADQJElO1NHToUOvSpYu79e/f30488UT75JNPGvbsAADQyrz++uu+6223bt3sgAMOsGuvvdYyMzPr5fkHDx5s7777br08F/bs2WefDTifBx54oN18882Wl5fH4QMAoB4dcsghvmtuv3797Nhjj7W33367Xp776aeftiOPPNIaCompWlq/fr1deOGFNmPGDHvjjTesT58+dswxx9j06dMb7OQAANDaFBYW2rp162zatGnu9uCDD9o777xjp5xyitXHei167qKionp5r9izrVu3Wk5OjoufvvrqK7v77rvt3//+t5111lkcPgAA6tGGDRvs9NNPd9fct956y0aNGmUnnXSSffDBB/v83AUFBbZx40ZrKCSm6iA5OdllHwcOHGiPPvqodezY0f7zn//Y559/bj169LCPPvrIDj30UPf37777zv3MZ599Zscdd5xLZI0fP96NHAIAgN3r3Lmzde3a1Q4//HC799573UDQypUrXWCkCipVPqmC+YorrrAtW7YE/Owrr7zirrkaLTz55JNt5syZvpHE/Px8u/zyy931XNXQaHhhYWG+iimN3t5222323nvvuYSVzs/vf/97+/Wv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Wze77LLLXMDZFBYvXmx33323DRo0yL2nM888s16ff968eQGfOTw83NLS0uzoo4+2zz77zBpCjx49qv0cem191j/+8Y+2evXqBnltAEDdnHvuubWKnwYMGBCSh/axxx4LeJ9K9HTq1Ml9ruXLl1tLcuutt9rAgQPtpJNOqvLY1KlTbdiwYRYbG2sdOnSwK6+80nJzc+vldYuLi6t8H5QgGzt2rL344ovWFLZt22b//e9/beLEiRYfH+/e0w8//FCvr/Gzn/0s4DPHxMRY37597eabb671YGxdPPvsszV+Dn1Oxax33nlnvb8usCckpoBa6tOnj1VWVgbcxo8fb8nJyVW26xdm/akqn1BzxBFH+N7njz/+aCNHjnQVTvX5Xv/2t7816WjLww8/bF27drXjjz8+YHtBQYEdeuih7mL89ddf2+bNm+3kk0+2U045xf7v//6vXt/DTz/95I6xArY//elP9vLLL9shhxzigpzGNmnSJPdeNBLWkJQI1OuUlpbaF198YYWFhXbUUUfZ559/bo3p1FNPdYmxRx99tFFfFwBQveeffz4gTvIGLfT/tP92XZ9VMaW/K/kTar788kv33pQw0C/4n3zyiY0ZM6beYh4le/T8v/rVr6wprFu3zp577jm7+uqrq43tfvGLX9jll19uOTk59uGHH9qnn35qxx57rJWXl9fbezjnnHPcMVAF9HfffecSgGeffbY99dRT1tj++te/usHmM844w33uhhIREeH7N7Bp0yZX9f+HP/zBJa0akxJWv/zlL92xVowMNCYSU0Ar1rlzZ3fRVRJHo4EtgYLFF154wY1iBvvLX/7iqqn+9a9/udEoVYxpypcSRr/5zW9cQFDfNMKmRMm1117rXrs+q7NqS4GdAn2NgDYGBVj77bef/fOf/3TBqo57Y1LV1FlnneXOs0ZgAQCoT6oYUlWwKnSVSGiqip769o9//MNV7Jx22mkB24uKilxFuCpqVCXVpk0bGzp0qEtWffPNNy7x2BBJkp49e7prueK1phhsUuymiiklplRl31izGzRgrEFFJT6///57a0w6x4qHFcMBjYnEFNBIPaZUOaNtDzzwgL355puuD44ucqq6WrJkidvnjTfecL/QK+BRJdPMmTOrPK9GkP785z+7UmoFBrqAqeJH07X2NomgarCsrCwXeMjs2bPdc6rqRAGK3mvwSJWq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", "text/plain": [ "
" ] @@ -546,10 +546,10 @@ "execution_count": 9, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.147197Z", - "iopub.status.busy": "2026-08-10T22:40:32.147123Z", - "iopub.status.idle": "2026-08-10T22:40:32.149171Z", - "shell.execute_reply": "2026-08-10T22:40:32.148899Z" + "iopub.execute_input": "2026-10-27T14:33:26.906149Z", + "iopub.status.busy": "2026-10-27T14:33:26.906073Z", + "iopub.status.idle": "2026-10-27T14:33:26.908202Z", + "shell.execute_reply": "2026-10-27T14:33:26.907822Z" } }, "outputs": [ @@ -584,10 +584,10 @@ "execution_count": 10, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.150069Z", - "iopub.status.busy": "2026-08-10T22:40:32.150012Z", - "iopub.status.idle": "2026-08-10T22:40:32.152406Z", - "shell.execute_reply": "2026-08-10T22:40:32.152081Z" + "iopub.execute_input": "2026-10-27T14:33:26.909049Z", + "iopub.status.busy": "2026-10-27T14:33:26.908992Z", + "iopub.status.idle": "2026-10-27T14:33:26.911550Z", + "shell.execute_reply": "2026-10-27T14:33:26.911210Z" } }, "outputs": [ @@ -603,12 +603,16 @@ "conf_int_lower 0.701351\n", "conf_int_upper 3.27632\n", "n_obs 1600\n", + "estimation_method dr\n", + "alpha 0.05\n", + "design 2x2x2\n", + "pscore_trim 0.01\n", + "inference_method analytical\n", + "df_inference 1592.0\n", "n_treated_eligible 400\n", "n_treated_ineligible 400\n", "n_control_eligible 400\n", "n_control_ineligible 400\n", - "estimation_method dr\n", - "inference_method analytical\n", "vcov_type hc1\n" ] } @@ -624,10 +628,10 @@ "execution_count": 11, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.153218Z", - "iopub.status.busy": "2026-08-10T22:40:32.153165Z", - "iopub.status.idle": "2026-08-10T22:40:32.154857Z", - "shell.execute_reply": "2026-08-10T22:40:32.154591Z" + "iopub.execute_input": "2026-10-27T14:33:26.912510Z", + "iopub.status.busy": "2026-10-27T14:33:26.912453Z", + "iopub.status.idle": "2026-10-27T14:33:26.914235Z", + "shell.execute_reply": "2026-10-27T14:33:26.913893Z" } }, "outputs": [ @@ -684,10 +688,10 @@ "execution_count": 12, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.155677Z", - "iopub.status.busy": "2026-08-10T22:40:32.155624Z", - "iopub.status.idle": "2026-08-10T22:40:32.169995Z", - "shell.execute_reply": "2026-08-10T22:40:32.169638Z" + "iopub.execute_input": "2026-10-27T14:33:26.915055Z", + "iopub.status.busy": "2026-10-27T14:33:26.915000Z", + "iopub.status.idle": "2026-10-27T14:33:26.928062Z", + "shell.execute_reply": "2026-10-27T14:33:26.927730Z" } }, "outputs": [ @@ -726,24 +730,9 @@ "-------------------------------------------------------------------------------------\n", "\n", "95% Confidence Interval: [3.0953, 3.8306]\n", - "\n", - "Overall ATT (event-study average, paper Eq. 4.14):\n", - "ATT 3.5111 0.1903 18.448 0.0000 ***\n", - "95% Confidence Interval: [3.1381, 3.8841]\n", "CV (SE/abs(ATT)): 0.0542\n", "\n", - "-------------------------------------------------------------------------------------\n", - " Event Study (Dynamic) Effects \n", - "-------------------------------------------------------------------------------------\n", - "Rel. Period Estimate Std. Err. t-stat P>|t| Sig.\n", - "-------------------------------------------------------------------------------------\n", - "-2.0 -0.0356 0.2408 -0.148 0.8824 \n", - "-1.0 -0.1455 0.2024 -0.719 0.4723 \n", - "0.0 3.2638 0.1866 17.489 0.0000 ***\n", - "1.0 3.3555 0.2458 13.651 0.0000 ***\n", - "2.0 3.5769 0.2370 15.093 0.0000 ***\n", - "3.0 3.8483 0.3333 11.545 0.0000 ***\n", - "-------------------------------------------------------------------------------------\n", + "Event-study and cohort surfaces: results.aggregate('event_study') / results.aggregate('group').\n", "\n", "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1\n", "=====================================================================================\n" @@ -753,9 +742,9 @@ "name": "stderr", "output_type": "stream", "text": [ - ".py:11: UserWarning: Base period 0 for (g=3.0, t=1) is outside the observed panel. Skipping this cell.\n", + "/var/folders/bh/mzf05nq92hs6t7vn2ssvfhpr0000gn/T/ipykernel_86473/676150762.py:11: UserWarning: Base period 0 for (g=3.0, t=1) is outside the observed panel. Skipping this cell.\n", " staggered_results = staggered.fit(\n", - ".py:11: UserWarning: Base period 0 for (g=4.0, t=1) is outside the observed panel. Skipping this cell.\n", + "/var/folders/bh/mzf05nq92hs6t7vn2ssvfhpr0000gn/T/ipykernel_86473/676150762.py:11: UserWarning: Base period 0 for (g=4.0, t=1) is outside the observed panel. Skipping this cell.\n", " staggered_results = staggered.fit(\n" ] } @@ -778,9 +767,6 @@ " unit=\"unit\",\n", " time=\"period\",\n", " first_treat=\"first_treat\",\n", - " # Staggered DDD is the ONE surface where fit-time aggregate= is canonical\n", - " # (not deprecated): its results container has no post-fit aggregate() yet.\n", - " aggregate=\"event_study\",\n", ")\n", "print(staggered_results.summary())" ] @@ -790,10 +776,10 @@ "execution_count": 13, "metadata": { "execution": { - "iopub.execute_input": "2026-08-10T22:40:32.170871Z", - "iopub.status.busy": "2026-08-10T22:40:32.170813Z", - "iopub.status.idle": "2026-08-10T22:40:32.173568Z", - "shell.execute_reply": "2026-08-10T22:40:32.173264Z" + "iopub.execute_input": "2026-10-27T14:33:26.929138Z", + "iopub.status.busy": "2026-10-27T14:33:26.929081Z", + "iopub.status.idle": "2026-10-27T14:33:26.933607Z", + "shell.execute_reply": "2026-10-27T14:33:26.933260Z" } }, "outputs": [ @@ -801,30 +787,37 @@ "name": "stdout", "output_type": "stream", "text": [ - " relative_period effect se t_stat p_value \\\n", - "0 -2.0 -0.035627 0.240806 -0.147949 8.823831e-01 \n", - "1 -1.0 -0.145458 0.202400 -0.718664 4.723479e-01 \n", - "2 0.0 3.263767 0.186616 17.489216 1.731226e-68 \n", - "3 1.0 3.355549 0.245818 13.650524 2.004319e-42 \n", - "4 2.0 3.576874 0.236984 15.093316 1.792062e-51 \n", - "5 3.0 3.848277 0.333338 11.544666 7.854633e-31 \n", + " event_time att se t_stat p_value conf_int_lower \\\n", + "0 -2.0 -0.035627 0.240806 -0.147949 8.823831e-01 -0.507598 \n", + "1 -1.0 -0.145458 0.202400 -0.718664 4.723479e-01 -0.542155 \n", + "2 0.0 3.263767 0.186616 17.489216 1.731226e-68 2.898007 \n", + "3 1.0 3.355549 0.245818 13.650524 2.004319e-42 2.873754 \n", + "4 2.0 3.576874 0.236984 15.093316 1.792062e-51 3.112394 \n", + "5 3.0 3.848277 0.333338 11.544666 7.854633e-31 3.194947 \n", + "\n", + " conf_int_upper cband_lower cband_upper n df is_reference estimand \n", + "0 0.436344 NaN NaN 1.0 NaN False att \n", + "1 0.251239 NaN NaN 2.0 NaN False att \n", + "2 3.629528 NaN NaN 2.0 NaN False att \n", + "3 3.837344 NaN NaN 2.0 NaN False att \n", + "4 4.041354 NaN NaN 2.0 NaN False att \n", + "5 4.501608 NaN NaN 1.0 NaN False att \n", + " level label target att se t_stat p_value \\\n", + "0 simple overall att 3.511117 0.190325 18.448022 5.408851e-76 \n", "\n", - " conf_int_lower conf_int_upper cband_lower cband_upper \n", - "0 -0.507598 0.436344 NaN NaN \n", - "1 -0.542155 0.251239 NaN NaN \n", - "2 2.898007 3.629528 NaN NaN \n", - "3 2.873754 3.837344 NaN NaN \n", - "4 3.112394 4.041354 NaN NaN \n", - "5 3.194947 4.501608 NaN NaN \n", + " conf_int_lower conf_int_upper n weight df \n", + "0 3.138087 3.884147 120.0 1.0 NaN \n", "\n", "ValueError: post belong(s) to the 2x2x2 mode and cannot be combined with first_treat=: staggered DDD reads the treated cohort from first_treat= (0 or np.inf = never enabled) and the pre/post contrast from time= (the calendar column), so there is no 0/1 group dummy and no 0/1 post dummy. Positional arguments 3-5 are the 2x2x2 (group, partition, post) triple - in staggered mode pass unit=, time=, first_treat= and partition= as keywords.\n" ] } ], "source": [ - "# Group-time effects, and the event-study aggregation\n", - "es = staggered_results.to_dataframe(level=\"event_study\")\n", - "print(es)\n", + "# The event-study aggregation is POST-FIT since 4.0 (no refit; bootstrapped fits\n", + "# replay the fit-time bootstrap). The paper's Eq. 4.14 overall is a weights= selector.\n", + "es = staggered_results.aggregate(\"event_study\")\n", + "print(es.to_dataframe())\n", + "print(staggered_results.aggregate(\"simple\", weights=\"event_time\").to_dataframe())\n", "\n", "# The 2x2x2 parameters are rejected in staggered mode, not silently ignored\n", "try:\n", diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 8704b0d69..490a49647 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -199,10 +199,11 @@ rows: new: null deprecated_in: "3.9" removed_in: "4.0" - status: planned + status: removed phase: 5 - code_refs: [diff_diff/staggered_triple_diff_results.py, diff_diff/__init__.py] - notes: "Deprecation rides parent [M-013]. Successor = unified TripleDifference results shape (degenerate single-ATT view for 2x2x2). 4.0 REMOVAL NOTE: since Phase 3(b) this container has a SECOND construction site - TripleDifference's staggered mode returns it too (the facade is API-only in 3.9; user-approved 2026-08-08). The 4.0 PR that unifies the shape must therefore decouple _fit_staggered_core's construction site as well as the deprecated class's, and it needs a disposition for TripleDifferenceResults, which has no row of its own. M-140/M-141's removed_in: 4.0 machine-checks that the unified container gains the post-fit aggregate() this release, so the two obligations land together." + code_refs: [diff_diff/triple_diff_results.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/triple_diff.py, diff_diff/__init__.py, diff_diff/_reporting_helpers.py, diff_diff/diagnostic_report.py, diff_diff/business_report.py, diff_diff/practitioner.py, diff_diff/results_base.py] + notes: "REMOVED at the 4.0 cut (item 6, 2026-10-27): the class and its module diff_diff/staggered_triple_diff_results.py are gone (the module was renamed to diff_diff/triple_diff_results.py and now hosts the unified container). TripleDifference.fit returns ONE TripleDifferenceResults for both designs - the disposition this row asked for: the surviving 2x2x2 class IS the unified container, its positional layout byte-stable (the four 2x2x2 cell counts and estimation_method gained defaults), with the staggered structure appended after it and a design discriminator ('2x2x2' / 'staggered'; __post_init__ validates the design-specific invariants). The canonical quintet is native in both designs (born canonical, after item 5); the group-time table is empty in the 2x2x2 design; event_study_effects / group_effects / cband_crit_value are carrier fields that are None on every 4.0 fit (the surfaces are post-fit, rows M-140/M-141); the five fit-time overall_*_es fields are gone (their successor is aggregate('simple', weights='event_time')); influence_functions (never populated) is dropped; df_inference records the 2x2x2 engine's t df (the LWDiDResults.df_inference contract). Both construction sites were decoupled: _fit_staggered_core constructs the unified container and attaches the CS aggregation kit (staggered.py _build_aggregation_kit on the eligibility-zeroed precomputed copy, plus the fit-time ddd_df_survey_original bookkeeping key for the Eq. 4.14 df rule); the 2x2x2 site adds pscore_trim and df_inference. Pickles: 3.x 2x2x2 pickles load (TripleDifferenceResults.__setstate__ backfills the new fields; the diff_diff.triple_diff.TripleDifferenceResults class path is re-exported); 3.x StaggeredTripleDiffResults pickles cannot load, like every removed class. Consumers dispatch on the design via _reporting_helpers.results_design / is_staggered_ddd (DR's _DESIGN_APPLICABILITY / _DESIGN_PT_METHOD tables; BR's never-enabled narration; the practitioner handler), headline_attribute reads att for both designs (the last overall_att died here), and both report schemas gained an additive design key (no version bump: DR 3.0 / BR 4.0 have not shipped; folded into PR 5b's REPORTING.md Note). Pinned by tests/test_v4_unify_ddd.py. Through 3.9: Deprecation rides parent [M-013]. Successor = unified TripleDifference results shape (degenerate single-ATT view for 2x2x2). Since Phase 3(b) this container had a SECOND construction site - TripleDifference's staggered mode returned it too (the facade was API-only in 3.9; user-approved 2026-08-08)." + test_ref: tests/test_v4_unify_ddd.py - id: M-015 kind: class group: merge-qdid @@ -642,7 +643,7 @@ rows: phase: 5 warning: FutureWarning test_ref: tests/test_v4_storage_flip.py - code_refs: [diff_diff/staggered_results.py, diff_diff/staggered.py, diff_diff/staggered_aggregation.py, diff_diff/power.py, diff_diff/practitioner.py, diff_diff/efficient_did.py, diff_diff/wooldridge.py, diff_diff/_reporting_helpers.py, diff_diff/diagnostic_report.py, diff_diff/prep_dgp.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/staggered_triple_diff_results.py, diff_diff/dml_did.py, diff_diff/dml_did_results.py, diff_diff/_deprecation.py, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, docs/methodology/REGISTRY.md, docs/methodology/REPORTING.md] + code_refs: [diff_diff/staggered_results.py, diff_diff/staggered.py, diff_diff/staggered_aggregation.py, diff_diff/power.py, diff_diff/practitioner.py, diff_diff/efficient_did.py, diff_diff/wooldridge.py, diff_diff/_reporting_helpers.py, diff_diff/diagnostic_report.py, diff_diff/prep_dgp.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/triple_diff_results.py, diff_diff/dml_did.py, diff_diff/dml_did_results.py, diff_diff/_deprecation.py, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, docs/methodology/REGISTRY.md, docs/methodology/REPORTING.md] notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): the canonical quintet att/se/t_stat/p_value/conf_int are the native dataclass fields, renamed IN SLOT (the generated __init__ positional layout is public API and unchanged; constructors take canonical keywords and overall_att= raises TypeError). The five overall_* names are read-only deprecated_field_property shims (FutureWarning, stacklevel 2, 'Will be removed in 5.0'; plain class attributes, never dataclass fields, absent from fields()/asdict()/to_dict()). DMLDiDResults inherits the storage and RE-DECLARES the five shims so the warning names the subclass (tests/test_naming_guard.py SURFACE_ALLOWLIST['DMLDiDResults.overall_att'] records it - no independent row). Pickle migration: __setstate__ on every flipped container rewrites the legacy keys through _deprecation.migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET) (an existing canonical key wins; the legacy key never lingers in __dict__; spec section 5). Report surface: _reporting_helpers headline_attribute is 'att' for the nine containers and diagnostic_report._extract_scalar_headline's legacy overall_*/overall_att_* alias row was deleted (DR schema 2.0 -> 3.0, BR 3.0 -> 4.0; REPORTING.md Note). StaggeredTripleDiffResults is UNFLIPPED (storage, to_dict keys and headline_attribute stay overall_*; it dies with the item-6 container unification, whose successor is born canonical). The bootstrap draw containers (CSBootstrapResults.overall_att_se etc., DCDHBootstrapResults.overall_se etc.) keep their payload names (user decision 2026-10-26; REGISTRY.md Result-class field naming Note). Reader inventory after PR 5a/5a-ii/5b: no in-repo reader of the overall_* names remains on the nine containers (source, tests, docs, notebooks, guides, benchmarks all read the canonical quintet); the only quoted literals are the shim declarations, the LEGACY_OVERALL_QUINTET state map and the staggered-DDD branch of _reporting_helpers. diff_diff/_deprecation.py is 5.0-WINDOW INFRASTRUCTURE (added 2026-10-18): these rows' FutureWarning properties ride its deprecated_field_property helper and their __setstate__ bodies ride migrate_renamed_state_keys through the 4.0 -> 5.0 window ([M-016]'s view is a leaf-local computed property in results_base.py instead), so the 5.0 removal PR deletes these shims and the LEGACY_OVERALL_QUINTET state map but never the module - and must add CONSUMER_ALLOWLIST[('overall_att', 'diff_diff/_deprecation.py')] if it keeps the migration literal for still-older pickles. This row carries the token family's CROSS-CUTTING consumer inventory (reports, guides, methodology docs, DGP helpers, the un-rowed staggered-DDD engine/container) alongside its own producers; per-class producer files live on their own rows. Inventory completed at the 3.9 bump, when the rule-11 Duty C window armed for 4.0-scheduled rows." - id: M-051 kind: field @@ -1039,7 +1040,7 @@ rows: removed_in: null status: planned phase: 5 - code_refs: [diff_diff/staggered_results.py, diff_diff/sun_abraham.py, diff_diff/imputation_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/efficient_did_results.py, diff_diff/continuous_did_results.py, diff_diff/wooldridge_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/lpdid_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/results.py, diff_diff/had.py, diff_diff/visualization/_event_study.py, diff_diff/honest_did.py, diff_diff/pretrends.py] + code_refs: [diff_diff/staggered_results.py, diff_diff/sun_abraham.py, diff_diff/imputation_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/efficient_did_results.py, diff_diff/continuous_did_results.py, diff_diff/wooldridge_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/lpdid_results.py, diff_diff/triple_diff_results.py, diff_diff/results.py, diff_diff/had.py, diff_diff/visualization/_event_study.py, diff_diff/honest_did.py, diff_diff/pretrends.py] notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None; per-row df was all-NaN at that amendment and is finite on analytical survey fits since the 3.12 M-092 completion - the rejection ground is the missing joint covariance, unchanged); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. SEVENTH PRE-CUT AMENDMENT (DML PR-B1): admission widened to DMLDiD-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults, DMLDiDResults}): DMLDiDResults subclasses CallawaySantAnnaResults and its aggregate('event_study') container is built by the INHERITED CS kit channel, so it carries the same staggered ATT(g,t) payload, reference semantics, base_period provenance, and (bootstrapped) replay behavior as a CS container - the same-semantics ground the original CS admission rests on; the producer-derived guard messages treat DMLDiDResults like CS (both expose base_period). DMLDiD adds NO builder (M-092 untouched) and never populates fit-time event_study_effects (its sentinel-retirement obligation is discharged by the shared CS code). Source scoping continues to reject all other producers. EIGHTH PRE-CUT AMENDMENT (4.0 cut PRs 3b/3c, with M-020..M-027/M-117..M-120/M-139): the fit-time aggregate=/balance_e= keywords are gone, which fixes WHICH producers populate the legacy event_study_effects/group_effects fields - CallawaySantAnna and EfficientDiD: None on EVERY fit (analytic and bootstrapped; the post-fit replay is the only bootstrap route); ImputationDiD and TwoStageDiD: None on analytic fits, ES + group dicts (percentile se/p/CI, df cleared) on bootstrapped fits IFF bootstrap_results is not None, relayed by aggregate(); ContinuousDiD: None on analytic fits, the ES dict on bootstrapped fits IFF _run_bootstrap ran, relayed by aggregate(); StackedDiD and SunAbraham: always populated (unchanged). CS/EDiD to_dataframe(level='event_study'|'group') therefore raises on every fresh fit until this row's schema rewire lands; admission is unchanged by the removals. This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." # ---- Behavior policies (schema-tracked, spec-governed; no reality probe) - @@ -1722,10 +1723,11 @@ rows: introduced_in: null deprecated_in: null removed_in: "4.0" - status: planned + status: removed phase: 5 - code_refs: [diff_diff/triple_diff.py] - notes: "The fit-time aggregate= carried onto the SURVIVING class by the M-013 merge, and the one documented exception to section 6's aggregate-postfit program (M-020..M-027): the staggered DDD container has no post-fit aggregate() to steer users to (no AggregationMixin on StaggeredTripleDiffResults), so fit-time routing ships here as the only route rather than as a deprecated one. Hence deprecated_in and warning are null - nothing is being steered away from yet. introduced_in is DELIBERATELY null even though the param ships in 3.9: collect_due_problems fails any row past its introduced_in still at 'planned', 'shimmed' would require the successor locator to resolve (it cannot until the 4.0 container port), and 'removed' trips the early-removal guard - the ledger's status vocabulary cannot express 'introduced now, successor not built yet', so the row is modelled purely as a scheduled REMOVAL. removed_in: 4.0 is load-bearing twice: it satisfies the phase-table direction-2 citation predicate at phase 5, and it machine-checks the container port at the 4.0 bump, which is M-014's job in the same release. Section 6 carries the matching dated amendment." + code_refs: [diff_diff/triple_diff.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/triple_diff_results.py, diff_diff/power.py] + notes: "REMOVED at the 4.0 cut (item 6, 2026-10-27): fit(aggregate=) raises TypeError in both designs; the successor TripleDifferenceResults.aggregate(type, weights=None, *, balance_e=None) is the M-122 contract on the unified container (AggregationMixin): 'simple' relays the stored headline in both designs (n_kind units / obs; the df column from resolve_inference_df via the new df_inference field), 'event_study' and 'group' recompute from the retained CS kit in the staggered design (bootstrapped fits REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec - the CS/DMLDiD/EfficientDiD mechanism - so sup-t bands and percentile inference match a 3.x fit-time aggregation), and the 2x2x2 design fails those levels closed naming first_treat=. The paper's Eq. 4.14 overall (the former fit-time overall_att_es family) is aggregate('simple', weights='event_time') - a weights= SELECTOR (the LWDiD/Wooldridge hook), not a vocabulary word (user decision 2026-10-27) - reproducing the engine's df rule (per-statistic effective df, else the fit's sentinelled survey df carried as the ddd_df_survey_original bookkeeping key) and both fit-time warnings. No 'total'. power.py's staggered-boundary guard lost its by-value pair (estimator_kwargs={'aggregate': ...} now surfaces as fit()'s TypeError). Through 3.x: The fit-time aggregate= carried onto the SURVIVING class by the M-013 merge, and the one documented exception to section 6's aggregate-postfit program (M-020..M-027): the staggered DDD container had no post-fit aggregate() to steer users to (no AggregationMixin on StaggeredTripleDiffResults), so fit-time routing shipped as the only route rather than as a deprecated one; introduced_in / deprecated_in DELIBERATELY null (the ledger's status vocabulary cannot express 'introduced now, successor not built yet', so the row was modelled purely as a scheduled REMOVAL whose removed_in: 4.0 machine-checked the container port). Section 6 carries the matching dated amendments." + test_ref: tests/test_v4_unify_ddd.py - id: M-141 kind: param group: merge-ddd @@ -1734,10 +1736,11 @@ rows: introduced_in: null deprecated_in: null removed_in: "4.0" - status: planned + status: removed phase: 5 - code_refs: [diff_diff/triple_diff.py] - notes: "Twin of [M-140] for the balance_e knob - see its notes for the null-version rationale. Rowed separately rather than riding M-140's notes, per the M-117..M-120 precedent and M-020's own note that prose-only balance_e tracking was exactly the un-rowed-obligation class the gating-completeness amendment closed." + code_refs: [diff_diff/triple_diff.py, diff_diff/_staggered_triple_diff_engine.py, diff_diff/triple_diff_results.py] + notes: "REMOVED at the 4.0 cut (item 6, 2026-10-27): fit(balance_e=) raises TypeError; balance_e is a keyword of TripleDifferenceResults.aggregate(), accepted on 'event_study' and on 'simple' together with weights='event_time' (the Eq. 4.14 overall averages the balanced horizons, as the 3.x fit-time field did); the plain 'simple' relay rejects it rather than silently dropping it, and the 2x2x2 design rejects it with the design message. Twin of [M-140] - see its notes. Rowed separately rather than riding M-140's notes, per the M-117..M-120 precedent and M-020's own note that prose-only balance_e tracking was exactly the un-rowed-obligation class the gating-completeness amendment closed." + test_ref: tests/test_v4_unify_ddd.py - id: M-142 kind: behavior group: merge-ddd @@ -1802,7 +1805,7 @@ rows: status: done phase: 5 test_ref: tests/test_staggered.py - code_refs: [diff_diff/results_base.py, diff_diff/staggered_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/imputation_results.py, diff_diff/efficient_did_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/sun_abraham.py, diff_diff/results.py, diff_diff/triple_diff.py, diff_diff/trop_results.py, diff_diff/continuous_did_results.py, diff_diff/synthetic_control_results.py, docs/methodology/REGISTRY.md, diff_diff/lwdid_results.py] + code_refs: [diff_diff/results_base.py, diff_diff/staggered_results.py, diff_diff/triple_diff_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/imputation_results.py, diff_diff/efficient_did_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/sun_abraham.py, diff_diff/results.py, diff_diff/triple_diff.py, diff_diff/trop_results.py, diff_diff/continuous_did_results.py, diff_diff/synthetic_control_results.py, docs/methodology/REGISTRY.md, diff_diff/lwdid_results.py] notes: "LWDiD joined the guard at the 4.0-cut PR 1 (2026-10-18): LWDiDResults.summary()/print_summary() previously took no alpha at all - they now take alpha=None under the same _require_fit_alpha contract. Staggered-family summary(alpha=)/print_summary(alpha=) tightening via the shared results_base._require_fit_alpha guard (the DMLDiDResults/EventStudyResults precedent): eight results classes (CallawaySantAnnaResults, StaggeredTripleDiffResults, ChaisemartinDHaultfoeuilleResults, ImputationDiDResults, EfficientDiDResults, TwoStageDiDResults, StackedDiDResults, SunAbrahamResults) previously did alpha = alpha or self.alpha and relabeled the confidence-interval header at the REQUESTED alpha while always printing the FIT-TIME stored intervals - silent coverage mislabeling on any non-fit alpha (bootstrap percentile intervals cannot be reconstructed from the SE at all). Behavior delta: accepted-and-mislabeled -> loud ValueError; alpha=0.0, previously swallowed by the falsy `or` idiom, now raises too. StaggeredTripleDiffResults is INCLUDED although its parent estimator is removed at 4.0 ([M-013]/[M-014]) - the mislabel is a live 3.x rendering bug, distinct from the estimator's frozen construction shape. Resolved - the guard is applied family-wide: the non-staggered summaries (DiDResults incl. SpilloverDiDResults by inheritance, MultiPeriodDiDResults, SyntheticDiDResults, TripleDifferenceResults, TROPResults, ContinuousDiDResults, SyntheticControlResults - the last previously a dead no-op alpha, now an honest raise) adopted it via the same helper (an optional message= override carries class-accurate wording where the staggered default's bootstrap-percentile rationale would be false; the staggered default message is byte-unchanged). Sibling behavioral evidence lives in tests/test_estimators.py, tests/test_triple_diff.py, tests/test_trop.py, tests/test_continuous_did.py, tests/test_methodology_synthetic_control.py, and tests/test_spillover.py (test_ref stays the staggered anchor: the schema's test_ref is a single path and this row remains the one logical contract). introduced_in 4.0 per the [M-144] rationale; status done is terminal." - id: M-147 kind: behavior diff --git a/docs/v4-design.md b/docs/v4-design.md index 47f2e08eb..36ac4aaa0 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -452,7 +452,15 @@ INVENT one - the two containers disagree on storage (native quintet vs counterpart, `to_dataframe`/`epv_summary`/`summary` diverge, and changing the 2x2x2 return type is an unrowed breaking change to a surface with no deprecation row. [M-014] keeps the obligation and now records the second -construction site. +construction site. **(SHIPPED at 4.0, item 6, 2026-10-27.)** `TripleDifferenceResults` +IS the unified container (module `diff_diff/triple_diff_results.py`, re-exported +from `triple_diff.py`; the staggered engine imports it without a cycle): the 2x2x2 +positional layout is byte-stable, the staggered structure is appended with +defaults, `design` (`"2x2x2"` / `"staggered"`) discriminates, the canonical quintet +is native in both designs, the group-time table is empty in 2x2x2, the fit-time +`overall_*_es` fields are gone (successor `aggregate('simple', weights='event_time')`), +and `df_inference` records the 2x2x2 engine's t df. `StaggeredTripleDiffResults` is +removed; its 3.x pickles cannot load (3.x 2x2x2 pickles do, via `__setstate__`). **Deprecation choreography.** 3.9: staggered params ship on TripleDifference; `time` -> `post` in 2x2x2 mode [M-031] (the `time` NAME persists as the @@ -557,10 +565,18 @@ the surviving `TripleDifference`, rowed as [M-140] [M-141] with which machine-checks that the 4.0 container unification ([M-014]) delivers the post-fit `aggregate()` in the same release the params die. The rule above is otherwise unchanged: no OTHER estimator may add a fit-time `aggregate=`. +**(SHIPPED at 4.0, item 6, 2026-10-27.)** Both keywords are gone (`TypeError`); +`TripleDifferenceResults` is an `AggregationMixin` adopter (`'simple'` relay in both +designs, `'event_study'` / `'group'` recomputed from the retained CS kit in the +staggered design with the CS-style bootstrap replay; the 2x2x2 design fails those +levels closed). No fit-time `aggregate=` survives anywhere in the library. **Vocabulary.** Closed set: `"simple"`, `"event_study"`, `"group"`, `"calendar"`, `"total"`, plus per-estimator documented extras where the estimand demands -them (ContinuousDiD adds `"dose"` [M-025]; HAD's `"overall"` maps to +them (ContinuousDiD adds `"dose"` [M-025]; TripleDifference's staggered design +exposes the paper's Eq. 4.14 overall as `aggregate('simple', weights='event_time')` - +a `weights=` SELECTOR on the existing type (the Wooldridge / LWDiD weights hook), not +a vocabulary word (user decision 2026-10-27, item 6); HAD's `"overall"` maps to `"simple"` [M-027] - its fit-time mode selector, and the workflow twin `did_had_pretest_workflow(aggregate=)` [M-139], both resolve by panel-shape inference; Wooldridge's `"gt"` group-time table stays as a @@ -1176,6 +1192,12 @@ tests-first PR that flips no rows). 6. DDD container unification [M-014] [M-140] [M-141] (user decision: lands at 4.0, not re-scheduled), born with canonical fields - after 5, so the unified container never carries `overall_*` storage. + (SHIPPED, item 6, 2026-10-27: `TripleDifferenceResults` serves both designs + with a `design` discriminator; `StaggeredTripleDiffResults` and the fit-time + `aggregate=`/`balance_e=` are gone; post-fit `aggregate()` with bootstrap + replay; the Eq. 4.14 overall is `aggregate('simple', weights='event_time')`; + report schemas gain an additive `design` key under the unshipped DR 3.0 / + BR 4.0 numbers. Pinned by `tests/test_v4_unify_ddd.py`.) 7. Inference defaults: the df_convention flips + [M-080] as re-scoped + the uniform `cluster=False` off-switch. 8. [M-093] sentinel retirement + event-study schema enforcement. diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py index b3de4d4da..929637959 100644 --- a/tests/test_aggregate_contract.py +++ b/tests/test_aggregate_contract.py @@ -1528,12 +1528,13 @@ def test_sddd_bootstrap_clears_group_df_provenance(self, survey): time="period", first_treat="first_treat", partition="eligibility", - aggregate="all", **fit_extra, ) - 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"DDD group {g} kept analytical df on bootstrap" + # 4.0 (rows M-014/M-140): the cohort surface is post-fit and REPLAYS + # the fit-time bootstrap; the df column is the provenance carrier. + grp = res.aggregate("group") + assert len(grp.label) > 0, "fixture produced no staggered-DDD group rows" + assert np.isnan(grp.df).all(), "DDD group rows 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 @@ -1664,7 +1665,7 @@ def test_summary_refuses_to_relabel_a_stored_interval(self, fitted): # --------------------------------------------------------------------------- # -def test_staggered_triple_diff_overall_att_es_still_works(): +def test_staggered_triple_diff_event_time_overall_still_works(): """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.""" @@ -1694,10 +1695,11 @@ def test_staggered_triple_diff_overall_att_es_still_works(): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) - assert res.overall_att_es is not None - assert np.isfinite(res.overall_att_es) + # 4.0: the Eq. 4.14 overall is the post-fit weights='event_time' row. + es_overall = res.aggregate("simple", weights="event_time") + assert np.isfinite(es_overall.att[0]) + assert np.isfinite(es_overall.se[0]) and es_overall.se[0] > 0 # --------------------------------------------------------------------------- # diff --git a/tests/test_business_report.py b/tests/test_business_report.py index 3dbbb3533..d423ed1ea 100644 --- a/tests/test_business_report.py +++ b/tests/test_business_report.py @@ -767,7 +767,7 @@ def test_continuous_did_assumption_uses_two_level_pt(self): def test_triple_difference_assumption_uses_ddd_decomposition(self): class TripleDifferenceResults: - pass + design = "2x2x2" obj = TripleDifferenceResults() obj.att = 1.0 @@ -867,10 +867,10 @@ def test_scm_robustness_block_surfaces_adh2015_diagnostics(self, scm_fit): assert native["in_time_placebo"]["status"] == "ran" def test_staggered_triple_diff_assumption_uses_ddd_not_generic_pt(self): - class StaggeredTripleDiffResults: - pass + class TripleDifferenceResults: + design = "staggered" - obj = StaggeredTripleDiffResults() + obj = TripleDifferenceResults() obj.att = 1.0 obj.se = 0.1 obj.p_value = 0.001 @@ -1580,10 +1580,10 @@ class TestStaggeredTripleDiffNeverTreatedFixedComparison: @staticmethod def _stub(control_group: str): - class StaggeredTripleDiffResults: - pass + class TripleDifferenceResults: + design = "staggered" - stub = StaggeredTripleDiffResults() + stub = TripleDifferenceResults() stub.att = 1.0 stub.se = 0.2 stub.p_value = 0.001 @@ -2201,10 +2201,10 @@ class TestStaggeredTripleDiffDynamicControlSample: """ def test_notyettreated_surfaces_n_never_enabled(self): - class StaggeredTripleDiffResults: - pass + class TripleDifferenceResults: + design = "staggered" - stub = StaggeredTripleDiffResults() + stub = TripleDifferenceResults() stub.att = 1.0 stub.se = 0.2 stub.p_value = 0.001 diff --git a/tests/test_diagnostic_report.py b/tests/test_diagnostic_report.py index a159dc210..aaec63b22 100644 --- a/tests/test_diagnostic_report.py +++ b/tests/test_diagnostic_report.py @@ -50,6 +50,7 @@ _TOP_LEVEL_KEYS = { "schema_version", "estimator", + "design", "headline_metric", "target_parameter", "parallel_trends", @@ -3608,7 +3609,11 @@ def test_wooldridge_reason_points_at_inplace_aggregate(self): # DR must never auto-call Wooldridge's MUTATING aggregate. assert w.event_study_effects is None - def test_staggered_triple_diff_reason_names_canonical_kwarg(self): + def test_staggered_triple_diff_derives_pt_post_fit(self): + """Item 6 (rows M-014/M-140): the staggered DDD container has post-fit + ``aggregate('event_study')``, so DR derives the pre-period surface itself + - no "re-fit with aggregate=" advice survives, and the schema records the + fitted design.""" from diff_diff import TripleDifference sdf = generate_staggered_data(n_units=100, n_periods=6, treatment_effect=1.5, seed=7) @@ -3621,10 +3626,13 @@ def test_staggered_triple_diff_reason_names_canonical_kwarg(self): time="period", first_treat="first_treat", ) - skipped = DiagnosticReport(t).run_all().skipped_checks - assert "aggregate='event_study'" in skipped["parallel_trends"] - assert "canonical" in skipped["parallel_trends"] - assert "deprecated" not in skipped["parallel_trends"] + dr = DiagnosticReport(t).run_all() + assert "parallel_trends" in dr.applicable_checks + reason = dr.skipped_checks.get("parallel_trends", "") + assert "Re-fit" not in reason and "fit-time" not in reason + assert "aggregate='event_study'" not in reason + assert dr.to_dict()["design"] == "staggered" + assert dr.to_dict()["estimator"] == "TripleDifferenceResults" def test_plain_imputation_twostage_continuous_heterogeneity( self, imp_pretrends_fits, ts_pretrends_fits diff --git a/tests/test_event_study_surface.py b/tests/test_event_study_surface.py index c0506498e..ccfd3cd9c 100644 --- a/tests/test_event_study_surface.py +++ b/tests/test_event_study_surface.py @@ -452,8 +452,11 @@ def surfaces(): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) + # 4.0 (row M-014): the surface is post-fit; the native side is the same + # legacy stand-in the CallawaySantAnna entry uses. + assert sddd.event_study_effects is None + sddd = legacy_standin(sddd) out["TripleDifference_staggered"] = (sddd, build_event_study_surface(sddd)) # 11. TWFE event-study mode (4.0 cut, PR 4c-i), pooled and within: the diff --git a/tests/test_guides.py b/tests/test_guides.py index fd85bb884..d577d75a8 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -309,6 +309,30 @@ def _assert_no_fit_time_aggregation_lines(fit_block: str, label: str) -> None: ), f"{label}: fit-time aggregation keyword(s) still documented: {offending}" +def test_llms_full_triple_difference_fit_blocks_have_no_fit_time_aggregate(): + # M-140 / M-141 (removed at 4.0, item 6): BOTH fit() signature blocks of the + # TripleDifference section (2x2x2 and staggered) are keyword-free of + # aggregate / balance_e - the last fit-time aggregation went post-fit. + text = get_llm_guide("full") + start = text.index("### TripleDifference\n") + section = text[start : text.index("\n### ", start + 1)] + blocks = [] + pos = 0 + while True: + # Signature blocks only (one parameter per line, closed by "\n)"); the + # inline usage examples are `.fit(data, ...)` one-liners. + fit_start = section.find(".fit(\n", pos) + if fit_start < 0: + break + end = section.index("\n)", fit_start) + blocks.append(section[fit_start:end]) + pos = end + assert len(blocks) >= 2, "expected the 2x2x2 and staggered fit blocks" + for block in blocks: + _assert_no_fit_time_aggregation_lines(block, "TripleDifference") + assert "StaggeredTripleDiffResults" not in section + + def test_llms_full_callaway_fit_block_has_no_fit_time_aggregate(): # M-020 / M-117 (removed at 4.0, PR 3b): the CallawaySantAnna signature block # lost its `aggregate: str = None` / `balance_e: int = None` lines. diff --git a/tests/test_methodology_staggered_triple_diff.py b/tests/test_methodology_staggered_triple_diff.py index 6d62ebb1e..38d22114a 100644 --- a/tests/test_methodology_staggered_triple_diff.py +++ b/tests/test_methodology_staggered_triple_diff.py @@ -131,7 +131,6 @@ def _run_python(data, method, control_group): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) @@ -281,15 +280,16 @@ class TestStaggeredDDDAggregation: The eligible-treated aggregation weighting (REGISTRY deviation) propagates into the individual per-relative-time ES(e) values and the simple overall, so those are checked at a loose 25% envelope (the per-(g,t) ATT(g,t) themselves match R - exactly, 0.00%). The Eq. 4.14 overall (``overall_att_es``) is the unweighted MEAN - of the post-treatment ES(e), where the weighting deviation largely averages out - -- empirically <=5% on ATT and <=0.5% on SE across all benchmark scenarios -- so - it is checked at a much tighter, dedicated envelope. + exactly, 0.00%). The Eq. 4.14 overall (``aggregate("simple", + weights="event_time")``) is the unweighted MEAN of the post-treatment ES(e), + where the weighting deviation largely averages out -- empirically <=5% on ATT + and <=0.5% on SE across all benchmark scenarios -- so it is checked at a much + tighter, dedicated envelope. """ AGG_RTOL = 0.25 # per-e ES + simple overall: weighting deviation on individual values AGG_ATOL = 3.0 - # Eq. 4.14 overall_att_es: the weighting deviation averages out in the mean + # Eq. 4.14 overall (weights="event_time"): the weighting deviation averages out in the mean # (max observed across all scenarios: ATT 5.0%, SE 0.4%); tight-but-safe envelope. OVERALL_ES_ATT_RTOL = 0.10 OVERALL_ES_SE_RTOL = 0.03 @@ -303,17 +303,22 @@ def test_event_study_att_close_to_r(self, r_results, key): data = _load_r_data(r["seed"], r["dgp_type"]) res = _run_python(data, r["est_method"], r["control_group"]) - assert res.event_study_effects is not None + assert res.event_study_effects is None # post-fit surface on 4.0 fits + es = res.aggregate("event_study", balance_e=None) r_es = dict(zip(r["es_event_times"], r["es_att"])) - for e, eff in res.event_study_effects.items(): - if e in r_es: - _assert_close( - eff["effect"], - r_es[e], - self.AGG_RTOL, - self.AGG_ATOL, - f"{key} ES ATT(e={e})", - ) + checked = 0 + for e, a, is_ref in zip(es.event_time, es.att, es.is_reference): + if is_ref or e not in r_es: + continue + checked += 1 + _assert_close( + a, + r_es[e], + self.AGG_RTOL, + self.AGG_ATOL, + f"{key} ES ATT(e={e})", + ) + assert checked > 0, f"{key}: no Python ES horizon matched an R horizon" @pytest.mark.parametrize("key", ALL_SCENARIOS) def test_overall_att_close_to_r(self, r_results, key): @@ -335,7 +340,7 @@ def test_overall_att_close_to_r(self, r_results, key): partition="eligibility", ) _assert_close( - res.overall_att, + res.att, r["overall_att_simple"], self.AGG_RTOL, self.AGG_ATOL, @@ -348,7 +353,9 @@ def test_overall_att_es_close_to_r(self, r_results, key): R's ``agg_ddd(type="eventstudy")$overall.att`` / ``overall.se`` ARE the paper's Eq. 4.14 overall and its analytical SE; the library exposes them - opt-in as ``overall_att_es`` / ``overall_se_es``. The AGG_RTOL tolerance + post-fit as ``res.aggregate("simple", weights="event_time")`` (the R golden + JSON keeps its ``overall_att_es`` / ``overall_se_es`` keys - an output-file + interface). The AGG_RTOL tolerance reflects the documented eligible-treated weighting (REGISTRY deviation) propagating from ES(e) into the overall -- NOT a new deviation; per-(g,t) ATT(g,t) match R exactly, and empirically the overall agreement is far @@ -360,9 +367,11 @@ def test_overall_att_es_close_to_r(self, r_results, key): pytest.skip("No event-study overall in R") data = _load_r_data(r["seed"], r["dgp_type"]) res = _run_python(data, r["est_method"], r["control_group"]) - assert res.overall_att_es is not None + o = res.aggregate("simple", weights="event_time") + assert o.att.shape == (1,) + assert np.isfinite(o.att[0]) _assert_close( - res.overall_att_es, + o.att[0], r_att, self.OVERALL_ES_ATT_RTOL, self.OVERALL_ES_ATOL, @@ -371,7 +380,7 @@ def test_overall_att_es_close_to_r(self, r_results, key): r_se = r.get("overall_se_es") if r_se is not None and not (isinstance(r_se, float) and np.isnan(r_se)): _assert_close( - res.overall_se_es, + o.se[0], r_se, self.OVERALL_ES_SE_RTOL, self.OVERALL_ES_ATOL, @@ -440,7 +449,7 @@ 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 = TripleDifference(estimation_method="dr", control_group="not_yet_treated").fit( - data, **_FIT_KW, aggregate="event_study" + data, **_FIT_KW ) post = [ v["effect"] @@ -505,12 +514,15 @@ def test_es_is_cohort_share_weighted_average(self): seed=41, ) res = TripleDifference(estimation_method="dr", control_group="never_treated").fit( - data, **_FIT_KW, aggregate="event_study" + data, **_FIT_KW ) - assert res.event_study_effects is not None + assert res.event_study_effects is None # post-fit surface on 4.0 fits + es = res.aggregate("event_study", balance_e=None) gt = res.group_time_effects checked_multi = False - for e, es in res.event_study_effects.items(): + for e, es_att, is_ref in zip(es.event_time, es.att, es.is_reference): + if is_ref: + continue contrib = [ (g, t) for (g, t) in gt if (t - g) == e and np.isfinite(gt[(g, t)]["effect"]) ] @@ -521,18 +533,36 @@ def test_es_is_cohort_share_weighted_average(self): eff = np.array([gt[(g, t)]["effect"] for (g, t) in contrib]) expected = float(np.sum(w / w.sum() * eff)) assert ( - abs(es["effect"] - expected) < 1e-9 - ), f"ES(e={e})={es['effect']:.6f} != cohort-share-weighted avg {expected:.6f}" + abs(es_att - expected) < 1e-9 + ), f"ES(e={e})={es_att:.6f} != cohort-share-weighted avg {expected:.6f}" assert checked_multi, "DGP did not produce a multi-cohort relative time to check" +def _post_es_mean(res, anticipation): + """Unweighted mean of the estimated post-treatment ES(e) from the post-fit surface. + + Mirrors the 3.x fit-time rule: the old ``event_study_effects`` dict held only + the ESTIMATED horizons, so the reference horizon (att 0 / se NaN) is excluded + explicitly alongside non-finite horizons. + """ + es = res.aggregate("event_study", balance_e=None) + event_time = np.asarray(es.event_time, dtype=float) + att = np.asarray(es.att, dtype=float) + is_ref = np.asarray(es.is_reference, dtype=bool) + mask = (event_time >= -anticipation) & ~is_ref & np.isfinite(att) + assert mask.any(), "no estimated post-treatment horizon on the fixture" + return float(np.mean(att[mask])) + + class TestEquation414Overall: - """Eq. (4.14) / Corollary 4.2: ``overall_att_es`` is the UNWEIGHTED mean of the - post-treatment event-study effects ES(e); opt-in via ``aggregate='event_study'``; - ``None`` otherwise; the default ``overall_att`` is undisturbed. + """Eq. (4.14) / Corollary 4.2: the Eq. 4.14 overall is the UNWEIGHTED mean of the + post-treatment event-study effects ES(e). Since 4.0 it is the post-fit call + ``res.aggregate("simple", weights="event_time")`` (a one-row AggregationResult); + the fit-time ``overall_att_es`` field is gone and the default ``res.att`` (the + cohort-size-weighted CS-simple overall) is undisturbed. """ - def _fit(self, aggregate, anticipation=0, seed=51): + def _fit(self, anticipation=0, seed=51): data = generate_staggered_ddd_data( n_units=600, cohort_periods=[3, 5], @@ -544,47 +574,56 @@ def _fit(self, aggregate, anticipation=0, seed=51): est = TripleDifference( estimation_method="dr", control_group="never_treated", anticipation=anticipation ) - return est.fit(data, **_FIT_KW, aggregate=aggregate) + return est.fit(data, **_FIT_KW) def test_overall_es_is_unweighted_mean_of_post_es(self): - res = self._fit("event_study") - assert res.overall_att_es is not None - assert res.event_study_effects is not None - post = [ - es["effect"] - for e, es in res.event_study_effects.items() - if e >= 0 and np.isfinite(es["effect"]) - ] - assert abs(res.overall_att_es - float(np.mean(post))) < 1e-10 - - def test_overall_es_none_without_event_study(self): - assert self._fit("simple").overall_att_es is None - assert self._fit(None).overall_att_es is None + res = self._fit() + o = res.aggregate("simple", weights="event_time") + assert o.att.shape == (1,) and o.se.shape == (1,) + assert abs(o.att[0] - _post_es_mean(res, anticipation=0)) < 1e-10 + + def test_overall_es_field_gone_and_event_time_overall_always_available(self): + # Replaces the 3.x "None without aggregate='event_study'" test: there is no + # fit-time opt-in any more. The field does not exist on the container and + # the Eq. 4.14 overall is available post-fit on EVERY staggered fit. + res = self._fit() + assert res.design == "staggered" + assert not hasattr(res, "overall_att_es") + assert not hasattr(res, "overall_se_es") + assert res.event_study_effects is None + assert res.group_effects is None + assert res.cband_crit_value is None + o = res.aggregate("simple", weights="event_time") + assert np.isfinite(o.att[0]) + assert np.isfinite(o.se[0]) and o.se[0] > 0 + assert np.isfinite(o.t_stat[0]) and np.isfinite(o.p_value[0]) + assert o.conf_int_lower[0] < o.att[0] < o.conf_int_upper[0] def test_overall_es_respects_anticipation_window(self): - # anticipation=1 => post-treatment is e >= -1 (matches the default overall_att set) - res = self._fit("event_study", anticipation=1) - assert res.overall_att_es is not None - assert res.event_study_effects is not None - post = [ - es["effect"] - for e, es in res.event_study_effects.items() - if e >= -1 and np.isfinite(es["effect"]) - ] - assert abs(res.overall_att_es - float(np.mean(post))) < 1e-10 + # anticipation=1 => post-treatment is e >= -1 (matches the default res.att set) + res = self._fit(anticipation=1) + assert res.anticipation == 1 + o = res.aggregate("simple", weights="event_time") + assert abs(o.att[0] - _post_es_mean(res, anticipation=1)) < 1e-10 + # The window matters: averaging only e >= 0 would give a different number. + assert abs(o.att[0] - _post_es_mean(res, anticipation=0)) > 1e-6 def test_overall_es_does_not_disturb_default_overall(self): - res_es = self._fit("event_study") - res_simple = self._fit("simple") - # Default overall_att stays the CS-simple aggregation regardless of ES opt-in - assert abs(res_es.overall_att - res_simple.overall_att) < 1e-9 - assert res_es.overall_se_es is not None - assert np.isfinite(res_es.overall_se_es) and res_es.overall_se_es > 0 + res = self._fit() + o = res.aggregate("simple", weights="event_time") + simple = res.aggregate("simple") + # Default res.att stays the CS-simple aggregation; aggregate('simple') relays + # it verbatim, and the Eq. 4.14 overall is a distinct, finite estimate. + assert simple.att[0] == res.att + assert simple.se[0] == res.se + assert np.isfinite(o.se[0]) and o.se[0] > 0 + assert abs(o.att[0] - res.att) > 1e-6 class TestEquation414OverallBootstrap: """Cross-surface: the Eq. 4.14 overall is finite under the multiplier bootstrap - (its SE is overridden by the per-draw mean of post-treatment ES(e) draws). + (``aggregate("simple", weights="event_time")`` REPLAYS the fit-time bootstrap; + its SE is the per-draw mean of post-treatment ES(e) draws, percentile inference). """ def test_bootstrap_overall_es_finite(self): @@ -596,11 +635,15 @@ def test_bootstrap_overall_es_finite(self): control_group="never_treated", n_bootstrap=199, seed=7, - ).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 - assert np.isfinite(res.overall_se_es) and res.overall_se_es > 0 + ).fit(data, **_FIT_KW) + assert res.inference_method == "bootstrap" + o = res.aggregate("simple", weights="event_time") + assert np.isfinite(o.att[0]) + assert np.isfinite(o.se[0]) and o.se[0] > 0 + assert np.isfinite(o.p_value[0]) + assert o.conf_int_lower[0] < o.att[0] < o.conf_int_upper[0] + # Percentile inference: no df governs it. + assert np.isnan(o.df[0]) def test_balance_e_empties_event_study_fails_soft(self): """A balance_e that removes every event-study cohort must fail SOFT (NaN + @@ -609,8 +652,10 @@ def test_balance_e_empties_event_study_fails_soft(self): Regression for the empty-rel_periods ``np.column_stack`` crash in the shared bootstrap mixin: ``balance_e=100`` (no cohort reaches relative time 100) empties the event study, so the Eq. 4.14 overall is "requested but - undefined" -> NaN. The default ``overall_att`` (CS-simple, balance_e- - independent) stays finite. + undefined" -> NaN. Since 4.0 ``balance_e`` is an argument of the post-fit + ``aggregate("simple", weights="event_time", balance_e=...)`` call, so the + warning is emitted BY THAT CALL, not by fit. The default ``res.att`` + (CS-simple, balance_e-independent) stays finite. """ data = generate_staggered_ddd_data( n_units=400, cohort_periods=[3, 5], treatment_effect=3.0, seed=71 @@ -619,13 +664,20 @@ def test_balance_e_empties_event_study_fails_soft(self): est = TripleDifference( estimation_method="dr", control_group="never_treated", n_bootstrap=nb, seed=3 ) - with pytest.warns(UserWarning): - res = est.fit(data, **_FIT_KW, aggregate="event_study", balance_e=100) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # low-n_bootstrap advisory on nb=10 + res = est.fit(data, **_FIT_KW) + with pytest.warns( + UserWarning, + match="no post-treatment horizons are available for the Eq. 4.14 overall", + ): + o = res.aggregate("simple", weights="event_time", 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) - # default overall_att is balance_e-independent and stays finite - assert np.isfinite(res.overall_att) + assert o.att.shape == (1,) + assert np.isnan(o.att[0]) + assert np.isnan(o.se[0]) + # default res.att is balance_e-independent and stays finite + assert np.isfinite(res.att) class TestAggregationReturnContract: @@ -664,8 +716,8 @@ class TestRankGuardedAnalyticalSE: The per-pair outcome-regression bread and propensity-score Hessian are near-singular when a covariate is constant/collinear. Previously - np.linalg.solve / np.linalg.inv returned a garbage inverse (overall_se - inflated 30-100x); the rank-guarded inverse drops the redundant direction, + np.linalg.solve / np.linalg.inv returned a garbage inverse (the headline + ``se`` inflated 30-100x); the rank-guarded inverse drops the redundant direction, giving a finite SE equal to fitting without the redundant covariate. """ @@ -703,9 +755,9 @@ def test_constant_covariate_finite_se_matches_drop_one(self, method): covariates=["x1", "xc"], ) - assert np.isfinite(with_const.overall_se) - assert with_const.overall_se < 1.0 # was 30-100x inflated - np.testing.assert_allclose(with_const.overall_se, drop_one.overall_se, rtol=1e-9) + assert np.isfinite(with_const.se) + assert with_const.se < 1.0 # was 30-100x inflated + np.testing.assert_allclose(with_const.se, drop_one.se, rtol=1e-9) def test_constant_covariate_emits_rank_guard_warning(self): # reg uses the OR bread only -> exactly one OR-path rank-guard warning. @@ -739,7 +791,7 @@ def test_well_conditioned_no_rank_guard_warning(self): covariates=["x1", "x2"], ) assert not any("rank-guarded inverse" in str(w.message) for w in caught) - assert np.isfinite(res.overall_se) + assert np.isfinite(res.se) @pytest.mark.parametrize("method", ["reg", "ipw", "dr"]) def test_survey_weighted_constant_covariate_finite_se(self, method): @@ -781,9 +833,9 @@ def test_survey_weighted_constant_covariate_finite_se(self, method): covariates=["x1", "xc"], survey_design=sd, ) - assert np.isfinite(with_const.overall_se) - assert with_const.overall_se < 1.0 - np.testing.assert_allclose(with_const.overall_se, drop_one.overall_se, rtol=1e-9) + assert np.isfinite(with_const.se) + assert with_const.se < 1.0 + np.testing.assert_allclose(with_const.se, drop_one.se, rtol=1e-9) @pytest.mark.parametrize("method", ["reg", "ipw", "dr"]) def test_notyettreated_silent_constant_covariate(self, method): @@ -808,8 +860,8 @@ def test_notyettreated_silent_constant_covariate(self, method): partition="eligibility", covariates=["x1", "xc"], ) - assert np.isfinite(res.overall_se) - assert res.overall_se < 1.0 + assert np.isfinite(res.se) + assert res.se < 1.0 assert not any("rank-guarded inverse" in str(w.message) for w in caught) @pytest.mark.parametrize("method", ["reg", "ipw", "dr"]) @@ -877,8 +929,8 @@ def test_control_cell_aliasing_close_to_drop_one(self, method): partition="eligibility", covariates=["x1", "x2d"], ) - assert np.isfinite(with_deg.overall_se) and with_deg.overall_se > 0 - np.testing.assert_allclose(with_deg.overall_se, drop_one.overall_se, rtol=5e-2) + assert np.isfinite(with_deg.se) and with_deg.se > 0 + np.testing.assert_allclose(with_deg.se, drop_one.se, rtol=5e-2) class TestStaggeredTripleDiffCovariateScaleEquilibration: @@ -906,7 +958,7 @@ def _fit(self, offset): def test_or_fit_offset_invariant(self): """A 1e6 constant offset on the covariate (absorbed by the intercept) - leaves ATT(g,t) unchanged — the OR fit is scale-equilibrated.""" + leaves ATT(g,t) unchanged - the OR fit is scale-equilibrated.""" base = self._fit(0.0) shifted = self._fit(1e6) common = set(base.group_time_effects) & set(shifted.group_time_effects) diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index fa4af417f..4543da706 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -486,8 +486,10 @@ def _build_rowed_index(): _CS_COHORT = "ATT(g,t) cohort in Callaway-Sant'Anna's own notation (v4-design section 8 carve-out)" _RULE1_TIME = "rule-1 canonical calendar column (v4-design section 8 rule 1)" -# The 8 surviving staggered-family results containers whose `groups` field +# The surviving staggered-family results containers whose `groups` field # names the ATT(g,t) cohort (dCDH's `groups` means unit ids and IS rowed, M-114). +# TripleDifferenceResults joined at 4.0 (row M-014): its staggered design +# carries the former StaggeredTripleDiffResults' `groups`. _CS_GROUPS_CLASSES = ( "CallawaySantAnnaResults", "ContinuousDiDResults", @@ -496,6 +498,7 @@ def _build_rowed_index(): "ImputationDiDResults", "StackedDiDResults", "SunAbrahamResults", + "TripleDifferenceResults", "TwoStageDiDResults", "WooldridgeDiDResults", ) @@ -1270,16 +1273,16 @@ def _token_family_code_refs(tok): ("aggregate", "diff_diff/guides/llms.txt"): ( "canonical post-fit results.aggregate() vocabulary; the fit-time " "aggregate= teaching for the eight adopters was removed with the " - "4.0 PR 3b/3c sweep (M-020..M-027, M-117..M-120, M-139); the only " - "surviving fit-time aggregate= is TripleDifference's canonical " - "staggered-mode keyword (M-140/M-141)" + "4.0 PR 3b/3c sweep (M-020..M-027, M-117..M-120, M-139); the last " + "fit-time aggregate=, TripleDifference's staggered-design keyword, went " + "post-fit with the item-6 container unification (M-014/M-140/M-141)" ), ("aggregate", "diff_diff/guides/llms-full.txt"): ( "canonical post-fit results.aggregate() vocabulary and the " "signature blocks WITHOUT the removed fit-time lines (4.0 PR 3b/3c " "sweep; pinned by tests/test_guides.py's " - "*_fit_block_has_no_fit_time_aggregate tests); the only surviving " - "fit-time aggregate= is TripleDifference's (M-140/M-141)" + "*_fit_block_has_no_fit_time_aggregate tests); no fit-time aggregate= " + "survives - TripleDifference's went post-fit at item 6 (M-014/M-140/M-141)" ), ("aggregate", "diff_diff/guides/llms-practitioner.txt"): ( "canonical post-fit results.aggregate() vocabulary; the bootstrapped " @@ -1430,7 +1433,7 @@ def _token_family_code_refs(tok): "diff_diff/results_base.py", "diff_diff/stacked_did_results.py", "diff_diff/staggered_results.py", - "diff_diff/staggered_triple_diff_results.py", + "diff_diff/triple_diff_results.py", "diff_diff/sun_abraham.py", "diff_diff/two_stage_results.py", "diff_diff/visualization/_staggered.py", diff --git a/tests/test_result_aliases.py b/tests/test_result_aliases.py index bc445f5df..594c948eb 100644 --- a/tests/test_result_aliases.py +++ b/tests/test_result_aliases.py @@ -6,9 +6,9 @@ ``overall_*`` / ``overall_att_*`` names are read-only ``FutureWarning`` properties (removed at 5.0). This file pins that direction: canonical names are constructor keywords and dataclass fields, legacy names warn and relay. -The one exception is ``StaggeredTripleDiffResults``, which keeps the 3.x -direction (``overall_*`` storage, canonical names as silent forwarding -properties) until the item-6 container unification retires it. +The last ``overall_*`` container, ``StaggeredTripleDiffResults``, was retired by +the item-6 DDD container unification (row M-014): ``TripleDifferenceResults`` +now serves both DDD designs with native canonical storage. Motivating bug: ``balance.interop.diff_diff.as_balance_diagnostic`` reads ``getattr(res, "se", None)`` and ``getattr(res, "conf_int", None)`` without @@ -20,7 +20,6 @@ from __future__ import annotations import math -import warnings from dataclasses import asdict, fields import numpy as np @@ -40,7 +39,6 @@ from diff_diff.imputation_results import ImputationDiDResults from diff_diff.stacked_did_results import StackedDiDResults from diff_diff.staggered_results import CallawaySantAnnaResults -from diff_diff.staggered_triple_diff_results import StaggeredTripleDiffResults from diff_diff.sun_abraham import SunAbrahamResults from diff_diff.two_stage_results import TwoStageDiDResults from diff_diff.wooldridge_results import WooldridgeDiDResults @@ -239,57 +237,6 @@ def test_pattern_b_legacy_constructor_keywords_rejected(cls): cls(**kwargs) -# ============================================================================ -# StaggeredTripleDiffResults — UNFLIPPED (dies with the item-6 container -# unification; keeps the 3.x direction until then) -# ============================================================================ - - -def _sddd_legacy_overrides(att=_ATT, se=_SE, t=_T, p=_P, ci=_CI): - return { - "overall_att": att, - "overall_se": se, - "overall_t_stat": t, - "overall_p_value": p, - "overall_conf_int": ci, - } - - -def test_staggered_triple_diff_keeps_legacy_storage_direction(): - """``StaggeredTripleDiffResults`` is NOT in M-050..M-058: it still stores - ``overall_*`` and exposes the canonical names as silent forwarding - properties (no warning). Unflipped on purpose - the class is retired by - the item-6 unified DDD container, which is born canonical.""" - res = StaggeredTripleDiffResults( - **_required_init_kwargs(StaggeredTripleDiffResults, _sddd_legacy_overrides()) - ) - with warnings.catch_warnings(): - warnings.simplefilter("error") - assert res.att == _ATT == res.overall_att - assert res.se == _SE == res.overall_se - assert res.t_stat == _T == res.overall_t_stat - assert res.p_value == _P == res.overall_p_value - assert res.conf_int == _CI == res.overall_conf_int - field_names = {f.name for f in fields(res)} - assert {"overall_att", "overall_se"} <= field_names - assert not ({"att", "se", "t_stat", "p_value", "conf_int"} & field_names) - - -def test_staggered_triple_diff_aliases_propagate_nan(): - res = StaggeredTripleDiffResults( - **_required_init_kwargs( - StaggeredTripleDiffResults, - _sddd_legacy_overrides(np.nan, np.nan, np.nan, np.nan, (np.nan, np.nan)), - ) - ) - assert math.isnan(res.att) - assert math.isnan(res.se) - assert math.isnan(res.t_stat) - assert math.isnan(res.p_value) - assert math.isnan(res.conf_int[0]) - assert math.isnan(res.conf_int[1]) - - # ============================================================================ # Pattern C — ContinuousDiDResults: nine legacy spellings onto the canonical five # ============================================================================ @@ -399,17 +346,6 @@ def test_legacy_aliases_are_read_only_canonical_fields_writable(cls, ovr, legacy assert getattr(res, name) == 7.0 -def test_staggered_triple_diff_canonical_aliases_are_read_only(): - """Unflipped SDDD keeps the 3.x semantics: the canonical names are read-only - forwarding properties over the ``overall_*`` fields.""" - res = StaggeredTripleDiffResults( - **_required_init_kwargs(StaggeredTripleDiffResults, _sddd_legacy_overrides()) - ) - for name in ("att", "se", "conf_int", "p_value", "t_stat"): - with pytest.raises(AttributeError): - setattr(res, name, object()) - - @pytest.mark.parametrize( "cls", [CallawaySantAnnaResults, ContinuousDiDResults], @@ -457,17 +393,6 @@ def test_legacy_aliases_excluded_from_dataclass_fields_and_asdict(cls): ), f"{cls.__name__}: legacy {alias!r} surfaced in dataclasses.asdict() output." -def test_staggered_triple_diff_canonical_aliases_excluded_from_fields(): - """Unflipped SDDD: the canonical names stay out of fields()/asdict().""" - res = StaggeredTripleDiffResults( - **_required_init_kwargs(StaggeredTripleDiffResults, _sddd_legacy_overrides()) - ) - field_names = {f.name for f in fields(res)} - for alias in ("att", "se", "conf_int", "p_value", "t_stat"): - assert alias not in field_names - assert alias not in asdict(res) - - # ============================================================================ # Cross-cutting regression — balance.interop.diff_diff adapter pattern # ============================================================================ diff --git a/tests/test_results_serialization.py b/tests/test_results_serialization.py index ba41bf524..6a81fe746 100644 --- a/tests/test_results_serialization.py +++ b/tests/test_results_serialization.py @@ -43,7 +43,6 @@ "WooldridgeDiDResults", "ChaisemartinDHaultfoeuilleResults", "LPDiDResults", - "StaggeredTripleDiffResults", "TripleDifferenceResults", "TROPResults", "SyntheticControlResults", diff --git a/tests/test_staggered_aggregation.py b/tests/test_staggered_aggregation.py index 25d7e997f..c1b3458dd 100644 --- a/tests/test_staggered_aggregation.py +++ b/tests/test_staggered_aggregation.py @@ -413,15 +413,18 @@ def test_triple_diff_zeroed_cohorts(self, monkeypatch): consumer of the mixin.""" rec = _ParityRecorder(monkeypatch) std = TripleDifference() - std.fit( + res = std.fit( _make_ddd(), outcome="outcome", unit="unit", time="period", first_treat="first_treat", partition="eligibility", - aggregate="all", ) + # 4.0 (rows M-014/M-140): the event-study / cohort aggregations run + # post-fit off the retained (eligibility-zeroed) kit. + res.aggregate("event_study") + res.aggregate("group") assert rec.n_calls > 0 @@ -642,14 +645,17 @@ def test_triple_diff_fast_path_taken(self, monkeypatch): the .get defaults.""" calls = self._spy(monkeypatch) std = TripleDifference() - std.fit( + res = std.fit( _make_ddd(), outcome="outcome", unit="unit", time="period", first_treat="first_treat", partition="eligibility", - aggregate="all", ) + # 4.0 (rows M-014/M-140): the event-study / cohort aggregations run + # post-fit off the retained (eligibility-zeroed) kit. + res.aggregate("event_study") + res.aggregate("group") assert calls["resolved"] > 0 assert calls["fallback"] == 0 diff --git a/tests/test_staggered_triple_diff.py b/tests/test_staggered_triple_diff.py index 0b4f59037..442930a4e 100644 --- a/tests/test_staggered_triple_diff.py +++ b/tests/test_staggered_triple_diff.py @@ -1,13 +1,20 @@ """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).""" +class, removed at the 4.0 cut, PR 4a). + +Since PR 6 of the 4.0 cut the staggered fit returns the unified +``TripleDifferenceResults`` (``design == "staggered"``); the event-study, cohort and +Eq. 4.14 surfaces are post-fit ``results.aggregate(...)`` calls, not fit-time fields. +""" import numpy as np import pytest from diff_diff import ( - StaggeredTripleDiffResults, + AggregationResult, + EventStudyResults, TripleDifference, + TripleDifferenceResults, generate_staggered_ddd_data, ) @@ -109,7 +116,8 @@ def test_fit_returns_results(self, simple_data): first_treat="first_treat", partition="eligibility", ) - assert isinstance(res, StaggeredTripleDiffResults) + assert isinstance(res, TripleDifferenceResults) + assert res.design == "staggered" assert est.is_fitted_ def test_results_structure(self, simple_data): @@ -122,11 +130,17 @@ def test_results_structure(self, simple_data): first_treat="first_treat", partition="eligibility", ) - assert isinstance(res.overall_att, float) - assert isinstance(res.overall_se, float) - assert res.overall_se > 0 - assert isinstance(res.overall_conf_int, tuple) - assert len(res.overall_conf_int) == 2 + assert isinstance(res.att, float) + assert isinstance(res.se, float) + assert res.se > 0 + assert isinstance(res.conf_int, tuple) + assert len(res.conf_int) == 2 + assert res.conf_int[0] < res.att < res.conf_int[1] + assert res.t_stat == pytest.approx(res.att / res.se) + # The 3.x fit-time surfaces are not stored on 4.0 fits (post-fit aggregate()). + assert res.event_study_effects is None + assert res.group_effects is None + assert res.cband_crit_value is None assert len(res.groups) > 0 assert len(res.time_periods) > 0 assert res.n_obs > 0 @@ -168,11 +182,17 @@ def test_summary_runs(self, simple_data): summary = res.summary() assert "Staggered Triple Difference" in summary assert "ATT" in summary - - 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.""" + # 4.0: no fit-time event-study / cohort / Eq. 4.14 blocks; a pointer line instead. + assert "results.aggregate('event_study')" in summary + assert "results.aggregate('group')" in summary + assert "Eq. 4.14" not in summary + + def test_event_time_overall_aggregate_surface(self, simple_data): + """The paper's Eq. 4.14 overall (the 3.x fit-time ``overall_att_es`` field) is + a post-fit surface: ``aggregate("simple", weights="event_time")`` returns a + one-row AggregationResult that is the unweighted mean of the post-treatment + event-study effects. The fit-time fields are gone from the attribute, summary() + and to_dict() surfaces.""" res = TripleDifference().fit( simple_data, outcome="outcome", @@ -180,73 +200,66 @@ def test_overall_att_es_result_surface(self, simple_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) - es_fields = ( + o = res.aggregate("simple", weights="event_time") + assert isinstance(o, AggregationResult) + assert len(o.att) == 1 + assert np.isfinite(o.att[0]) and np.isfinite(o.se[0]) and o.se[0] > 0 + assert o.t_stat[0] == pytest.approx(o.att[0] / o.se[0]) + assert 0.0 <= o.p_value[0] <= 1.0 + assert o.conf_int_lower[0] < o.att[0] < o.conf_int_upper[0] + # Eq. 4.14: the unweighted mean of the finite post-treatment ES(e) effects. + es = res.aggregate("event_study") + post = es.att[(es.event_time >= 0) & ~es.is_reference & np.isfinite(es.att)] + assert len(post) > 0 + assert o.att[0] == pytest.approx(post.mean(), abs=1e-10) + # The Eq. 4.14 overall differs from the stored cohort-size-weighted headline. + assert o.att[0] != res.att + # The 3.x fit-time fields and their summary()/to_dict() entries are gone. + for f in ( "overall_att_es", "overall_se_es", "overall_t_stat_es", "overall_p_value_es", "overall_conf_int_es", - ) - assert res.overall_att_es is not None and np.isfinite(res.overall_att_es) - for f in es_fields: - assert getattr(res, f) is not None - assert "Eq. 4.14" in res.summary() - d = res.to_dict() - 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 = 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() + ): + assert not hasattr(res, f) + assert not any(k.startswith("overall_") for k in res.to_dict()) + assert "Eq. 4.14" not in res.summary() - def test_overall_att_es_aggregate_all_matches_event_study(self, simple_data): - """Eq. 4.14 overall (overall_att_es) is populated under aggregate='all' (not only - 'event_study') and matches the 'event_study' path bit-for-bit on the same data. + def test_event_time_overall_unperturbed_by_other_aggregations(self, simple_data): + """The Eq. 4.14 overall is deterministic and independent of which other + post-fit aggregations ran on the same result (the 4.0 form of the 3.x + guard that aggregate='all' matched aggregate='event_study' bit-for-bit). - Regression guard for the documented public contract that BOTH surfaces expose - overall_att_es: aggregate='all' additionally computes the per-cohort group - aggregation, and must neither drop nor perturb the event-study-average overall. + aggregate('event_study') / aggregate('group') memoize into a scratch copy of + the kit and must neither drop nor perturb the event-study-average overall. """ - res_all = TripleDifference().fit( + res = TripleDifference().fit( simple_data, outcome="outcome", unit="unit", time="period", first_treat="first_treat", partition="eligibility", - aggregate="all", ) - res_es = TripleDifference().fit( - simple_data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - partition="eligibility", - aggregate="event_study", - ) - # Populated (not None / finite) on the 'all' surface. - assert res_all.overall_att_es is not None and np.isfinite(res_all.overall_att_es) - assert res_all.overall_se_es is not None and np.isfinite(res_all.overall_se_es) - # Identical point estimate AND inference vs the event_study path: same data, same - # analytical influence-function SE (no bootstrap), so bit-for-bit close. - assert res_all.overall_att_es == pytest.approx(res_es.overall_att_es, abs=1e-10) - assert res_all.overall_se_es == pytest.approx(res_es.overall_se_es, abs=1e-10) - assert res_all.overall_t_stat_es == pytest.approx(res_es.overall_t_stat_es, abs=1e-10) - assert res_all.overall_p_value_es == pytest.approx(res_es.overall_p_value_es, abs=1e-10) - ci_all, ci_es = res_all.overall_conf_int_es, res_es.overall_conf_int_es - assert ci_all is not None and ci_es is not None - assert ci_all[0] == pytest.approx(ci_es[0], abs=1e-10) - assert ci_all[1] == pytest.approx(ci_es[1], abs=1e-10) + o_first = res.aggregate("simple", weights="event_time") + # Run the other recompute surfaces in between (the former aggregate='all'). + res.aggregate("group") + res.aggregate("event_study") + o_again = res.aggregate("simple", weights="event_time") + assert np.isfinite(o_first.att[0]) and np.isfinite(o_first.se[0]) + # Identical point estimate AND inference: same data, same analytical + # influence-function SE (no bootstrap), so bit-for-bit close. + assert o_again.att[0] == pytest.approx(o_first.att[0], abs=1e-10) + assert o_again.se[0] == pytest.approx(o_first.se[0], abs=1e-10) + assert o_again.t_stat[0] == pytest.approx(o_first.t_stat[0], abs=1e-10) + assert o_again.p_value[0] == pytest.approx(o_first.p_value[0], abs=1e-10) + assert o_again.conf_int_lower[0] == pytest.approx(o_first.conf_int_lower[0], abs=1e-10) + assert o_again.conf_int_upper[0] == pytest.approx(o_first.conf_int_upper[0], abs=1e-10) + # The stored headline is a relay, untouched by the recomputes. + assert res.aggregate("simple").att[0] == res.att + assert res.aggregate("simple").se[0] == res.se def test_to_dataframe_group_time(self, simple_data): est = TripleDifference() @@ -274,7 +287,8 @@ def test_repr(self, simple_data): partition="eligibility", ) r = repr(res) - assert "StaggeredTripleDiffResults" in r + assert "TripleDifferenceResults" in r + assert "design='staggered'" in r def test_significance_properties(self, simple_data): est = TripleDifference() @@ -307,7 +321,7 @@ def test_att_recovery(self, simple_data): first_treat="first_treat", partition="eligibility", ) - assert abs(res.overall_att - 3.0) < 2 * res.overall_se + assert abs(res.att - 3.0) < 2 * res.se def test_null_effect(self, null_effect_data): """ATT should not be significant when true effect is 0.""" @@ -320,7 +334,7 @@ def test_null_effect(self, null_effect_data): first_treat="first_treat", partition="eligibility", ) - assert abs(res.overall_att) < 2 * res.overall_se + assert abs(res.att) < 2 * res.se def test_att_with_covariates(self, data_with_covariates): """ATT recovery with covariates.""" @@ -334,7 +348,7 @@ def test_att_with_covariates(self, data_with_covariates): partition="eligibility", covariates=["x1", "x2"], ) - assert abs(res.overall_att - 3.0) < 2 * res.overall_se + assert abs(res.att - 3.0) < 2 * res.se # --------------------------------------------------------------------------- @@ -354,9 +368,9 @@ def test_method_produces_finite_results(self, simple_data, method): first_treat="first_treat", partition="eligibility", ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) - assert res.overall_se > 0 + assert np.isfinite(res.att) + assert np.isfinite(res.se) + assert res.se > 0 # --------------------------------------------------------------------------- @@ -374,10 +388,19 @@ def test_event_study_aggregation(self, simple_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) - assert res.event_study_effects is not None - assert len(res.event_study_effects) > 0 + # Not stored at fit time; produced post-fit by aggregate(). + assert res.event_study_effects is None + es = res.aggregate("event_study") + assert isinstance(es, EventStudyResults) + n_horizons = len(es.event_time) + assert n_horizons > 0 + for arr in (es.att, es.se, es.t_stat, es.p_value, es.conf_int_lower, es.conf_int_upper): + assert len(arr) == n_horizons + estimated = ~es.is_reference + assert np.all(np.isfinite(es.att[estimated])) + assert np.all(es.se[estimated] > 0) + assert es.cband_crit_value is None # analytical fit: no simultaneous band def test_pretreatment_near_zero(self, simple_data): """Pre-treatment event study effects should be near zero.""" @@ -389,14 +412,13 @@ def test_pretreatment_near_zero(self, simple_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) - for e, eff in res.event_study_effects.items(): - if e < 0: - # Pre-treatment effects within 3 SE of zero - assert abs(eff["effect"]) < 3 * eff["se"], ( - f"Pre-treatment effect at e={e} is {eff['effect']:.3f} " f"(SE={eff['se']:.3f})" - ) + es = res.aggregate("event_study") + pre = (es.event_time < 0) & ~es.is_reference + assert pre.any() + for e, att, se in zip(es.event_time[pre], es.att[pre], es.se[pre]): + # Pre-treatment effects within 3 SE of zero + assert abs(att) < 3 * se, f"Pre-treatment effect at e={e} is {att:.3f} (SE={se:.3f})" def test_posttreatment_positive(self, simple_data): """Post-treatment event study effects should be positive.""" @@ -408,11 +430,11 @@ def test_posttreatment_positive(self, simple_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) - for e, eff in res.event_study_effects.items(): - if e >= 0: - assert eff["effect"] > 0 + es = res.aggregate("event_study") + post = (es.event_time >= 0) & ~es.is_reference + assert post.any() + assert np.all(es.att[post] > 0) def test_event_study_dataframe(self, simple_data): est = TripleDifference() @@ -423,13 +445,19 @@ def test_event_study_dataframe(self, simple_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) - df = res.to_dataframe("event_study") - assert "relative_period" in df.columns - assert "effect" in df.columns - - def test_aggregate_all(self, simple_data): + es = res.aggregate("event_study") + df = es.to_dataframe() + for col in ("event_time", "att", "se", "conf_int_lower", "conf_int_upper", "is_reference"): + assert col in df.columns + assert len(df) == len(es.event_time) + np.testing.assert_allclose(df["att"].to_numpy(), es.att, rtol=0, atol=0) + # The fit-time event-study table is gone from the results container. + with pytest.raises(ValueError, match="not stored on 4.0 fits"): + res.to_dataframe("event_study") + + def test_event_study_and_group_both_available(self, simple_data): + # The former aggregate='all' fit: both surfaces come from the same result. est = TripleDifference() res = est.fit( simple_data, @@ -438,10 +466,14 @@ def test_aggregate_all(self, simple_data): 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 + es = res.aggregate("event_study") + g = res.aggregate("group") + assert len(es.event_time) > 0 + assert len(g.att) == len(res.groups) + # Neither recompute populates the (None) fit-time carrier fields. + assert res.event_study_effects is None + assert res.group_effects is None def test_aggregate_group(self, simple_data): est = TripleDifference() @@ -452,10 +484,20 @@ def test_aggregate_group(self, simple_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="group", ) - assert res.group_effects is not None - assert len(res.group_effects) > 0 + assert res.group_effects is None # not stored at fit time + g = res.aggregate("group") + assert isinstance(g, AggregationResult) + assert len(g.att) > 0 + assert set(g.label) == set(res.groups) + assert np.all(np.isfinite(g.att)) + assert np.all(g.se > 0) + assert np.all(g.conf_int_lower < g.att) and np.all(g.att < g.conf_int_upper) + df = g.to_dataframe() + assert "label" in df.columns and "att" in df.columns + assert len(df) == len(res.groups) + with pytest.raises(ValueError, match="not stored on 4.0 fits"): + res.to_dataframe("group") # --------------------------------------------------------------------------- @@ -533,6 +575,9 @@ def test_bootstrap_runs(self, simple_data, ci_params): ) assert res.bootstrap_results is not None assert res.bootstrap_results.n_bootstrap == n_boot + assert res.inference_method == "bootstrap" + assert res.n_bootstrap == n_boot + assert res.to_dict()["n_bootstrap"] == n_boot def test_bootstrap_with_event_study(self, simple_data, ci_params): n_boot = ci_params.bootstrap(199) @@ -544,11 +589,21 @@ def test_bootstrap_with_event_study(self, simple_data, ci_params): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", ) assert res.bootstrap_results is not None - if res.cband_crit_value is not None: - assert res.cband_crit_value > 0 + assert res.cband_crit_value is None # no fit-time event-study surface + # aggregate('event_study') replays the fit-time multiplier bootstrap. + es = res.aggregate("event_study") + assert es.cband_crit_value is not None + assert es.cband_crit_value > 0 + assert es.cband_lower is not None and es.cband_upper is not None + estimated = ~es.is_reference + assert np.all(np.isfinite(es.cband_lower[estimated])) + assert np.all(es.cband_lower[estimated] <= es.cband_upper[estimated]) + # The replay is deterministic from the retained RNG state. + es_again = res.aggregate("event_study") + assert es_again.cband_crit_value == es.cband_crit_value + np.testing.assert_allclose(es_again.se, es.se, rtol=0, atol=0) # --------------------------------------------------------------------------- @@ -621,7 +676,7 @@ def test_inf_first_treat_works(self): first_treat="first_treat", partition="eligibility", ) - assert np.isfinite(res.overall_att) + assert np.isfinite(res.att) def test_survey_design_invalid_type_raises(self, simple_data): est = TripleDifference() @@ -638,7 +693,8 @@ def test_survey_design_invalid_type_raises(self, simple_data): def test_invalid_aggregate_raises(self, simple_data): est = TripleDifference() - with pytest.raises(ValueError, match="aggregate"): + # 4.0: fit() no longer takes aggregate= (or balance_e=) at all. + with pytest.raises(TypeError, match="aggregate"): est.fit( simple_data, outcome="outcome", @@ -648,6 +704,26 @@ def test_invalid_aggregate_raises(self, simple_data): partition="eligibility", aggregate="invalid", ) + with pytest.raises(TypeError, match="balance_e"): + est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + balance_e=1, + ) + res = est.fit( + simple_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + partition="eligibility", + ) + with pytest.raises(ValueError, match="Unsupported aggregation type"): + res.aggregate("invalid") def test_base_period_universal(self, simple_data): est = TripleDifference(base_period="universal") @@ -659,8 +735,8 @@ def test_base_period_universal(self, simple_data): first_treat="first_treat", partition="eligibility", ) - assert np.isfinite(res.overall_att) - assert abs(res.overall_att - 3.0) < 2 * res.overall_se + assert np.isfinite(res.att) + assert abs(res.att - 3.0) < 2 * res.se def test_to_dict(self, simple_data): est = TripleDifference() @@ -673,9 +749,31 @@ def test_to_dict(self, simple_data): partition="eligibility", ) d = res.to_dict() - assert "overall_att" in d - assert "n_obs" in d - assert "estimation_method" in d + for key in ( + "att", + "se", + "t_stat", + "p_value", + "conf_int_lower", + "conf_int_upper", + "n_obs", + "estimation_method", + "design", + "n_groups", + "n_periods", + "groups", + "time_periods", + ): + assert key in d + assert d["att"] == res.att + assert d["se"] == res.se + assert d["design"] == "staggered" + assert d["n_groups"] == len(res.groups) + # Canonical names only: no 3.x overall_* keys, no fit-time surface keys. + assert not any(k.startswith("overall_") for k in d) + assert "event_study_effects" not in d + assert "group_effects" not in d + assert "n_bootstrap" not in d # analytical fit # --------------------------------------------------------------------------- @@ -709,7 +807,7 @@ def test_base_period_outside_panel_warns(self): ) 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) + assert np.isfinite(res.att) def test_empty_subgroup_warns(self): """Data where one (S,Q) cell is empty should warn, not crash.""" @@ -736,7 +834,7 @@ def test_empty_subgroup_warns(self): ) 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) + assert np.isfinite(res.att) def test_collinear_covariates_cached_ps_finite(self): """Collinear covariates with PS cache reuse should produce finite results.""" @@ -816,7 +914,7 @@ def test_collinear_covariates_emit_lstsq_fallback_warning(self): assert "(g, g_c, t) pair(s)" in msg assert "rank-guarded inverse" in msg # Point estimates should still be finite (rank-guard truncation). - assert np.isfinite(res.overall_att) + assert np.isfinite(res.att) def test_collinear_covariates_emit_ps_hessian_warning(self): """Collinear propensity-score covariates should trigger the aggregate @@ -923,9 +1021,8 @@ def alpha_fitted(): class TestSummaryAlphaContract: """summary(alpha=...) never recomputes stored inference. - Family-wide guard (results_base._require_fit_alpha) - applies to this - results class even though the parent estimator is deprecated: the - summary surface is live through 3.x. + Family-wide guard (results_base._require_fit_alpha) on the unified + TripleDifferenceResults container's staggered summary. """ @pytest.mark.parametrize("bad_alpha", [0.10, 0.0]) diff --git a/tests/test_survey_staggered_ddd.py b/tests/test_survey_staggered_ddd.py index 48c38a2db..0a58ca951 100644 --- a/tests/test_survey_staggered_ddd.py +++ b/tests/test_survey_staggered_ddd.py @@ -105,11 +105,13 @@ def test_finite_results(self, sddd_data, method): partition="eligibility", survey_design=sd, ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) - assert res.overall_se > 0 + assert np.isfinite(res.att) + assert np.isfinite(res.se) + assert res.se > 0 - @pytest.mark.parametrize("agg", ["simple", "event_study", "group", "all"]) + # The fit-time ``aggregate="all"`` option is gone at 4.0: both of its surfaces + # are the post-fit ``event_study`` / ``group`` levels parametrized here. + @pytest.mark.parametrize("agg", ["simple", "event_study", "group"]) def test_aggregation(self, sddd_data, agg): sd = SurveyDesign(weights="weight") est = TripleDifference(estimation_method="reg") @@ -120,14 +122,29 @@ def test_aggregation(self, sddd_data, agg): time="period", first_treat="first_treat", partition="eligibility", - aggregate=agg, survey_design=sd, ) - assert np.isfinite(res.overall_att) - if agg in ("event_study", "all"): - assert res.event_study_effects is not None - if agg in ("group", "all"): - assert res.group_effects is not None + assert res.design == "staggered" + assert np.isfinite(res.att) + # Surfaces are post-fit: nothing is stored on the fit itself. + assert res.event_study_effects is None + assert res.group_effects is None + assert res.cband_crit_value is None + if agg == "simple": + simple = res.aggregate("simple") + assert len(simple.att) == 1 + np.testing.assert_allclose(simple.att[0], res.att, rtol=0, atol=0) + np.testing.assert_allclose(simple.se[0], res.se, rtol=0, atol=0) + elif agg == "event_study": + es = res.aggregate("event_study") + assert len(es.event_time) > 0 + assert len(es.att) == len(es.event_time) + assert np.all(np.isfinite(es.att)) + else: + g = res.aggregate("group") + assert len(g.label) > 0 + assert len(g.att) == len(g.label) + assert np.all(np.isfinite(g.att)) # --------------------------------------------------------------------------- @@ -168,14 +185,14 @@ def test_uniform_weights_match_unweighted(self, sddd_data, method): ) np.testing.assert_allclose( - res_w.overall_att, - res_uw.overall_att, + res_w.att, + res_uw.att, atol=1e-10, err_msg=f"ATT mismatch for {method}", ) np.testing.assert_allclose( - res_w.overall_se, - res_uw.overall_se, + res_w.se, + res_uw.se, rtol=1e-6, err_msg=f"SE mismatch for {method}", ) @@ -218,13 +235,13 @@ def test_scale_invariance(self, sddd_data): ) np.testing.assert_allclose( - res2.overall_att, - res1.overall_att, + res2.att, + res1.att, atol=1e-12, ) np.testing.assert_allclose( - res2.overall_se, - res1.overall_se, + res2.se, + res1.se, rtol=1e-6, ) @@ -260,10 +277,9 @@ def test_weighted_se_differs(self, sddd_data): survey_design=sd, ) - assert abs(res_w.overall_se - res_uw.overall_se) > 1e-6, ( - f"Expected SEs to differ: weighted={res_w.overall_se}, " - f"unweighted={res_uw.overall_se}" - ) + assert ( + abs(res_w.se - res_uw.se) > 1e-6 + ), f"Expected SEs to differ: weighted={res_w.se}, unweighted={res_uw.se}" # --------------------------------------------------------------------------- @@ -288,11 +304,10 @@ def test_strata_psu(self, sddd_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", survey_design=sd, ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) + assert np.isfinite(res.att) + assert np.isfinite(res.se) assert res.survey_metadata is not None assert res.survey_metadata.df_survey is not None assert res.survey_metadata.df_survey > 0 @@ -336,7 +351,6 @@ def test_design_se_differs_from_pweight_only(self, sddd_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", survey_design=sd_pw, ) @@ -353,22 +367,20 @@ def test_design_se_differs_from_pweight_only(self, sddd_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", survey_design=sd_full, ) # Point estimates should be identical (same weights) np.testing.assert_allclose( - res_full.overall_att, - res_pw.overall_att, + res_full.att, + res_pw.att, atol=1e-10, ) # Aggregated SEs should differ (design-based variance) - assert abs(res_full.overall_se - res_pw.overall_se) > 1e-6, ( - f"Expected aggregated SEs to differ: full={res_full.overall_se}, " - f"pweight-only={res_pw.overall_se}" - ) + assert ( + abs(res_full.se - res_pw.se) > 1e-6 + ), f"Expected aggregated SEs to differ: full={res_full.se}, pweight-only={res_pw.se}" # --------------------------------------------------------------------------- @@ -407,12 +419,11 @@ def test_brr_replicate(self, sddd_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", survey_design=sd, ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) - assert res.overall_se > 0 + assert np.isfinite(res.att) + assert np.isfinite(res.se) + assert res.se > 0 # --------------------------------------------------------------------------- @@ -498,7 +509,6 @@ def test_scale_invariance(self, sddd_data, agg): time="period", first_treat="first_treat", partition="eligibility", - aggregate=agg, survey_design=sd1, ) @@ -520,52 +530,62 @@ def test_scale_invariance(self, sddd_data, agg): time="period", first_treat="first_treat", partition="eligibility", - aggregate=agg, survey_design=sd2, ) np.testing.assert_allclose( - res2.overall_att, - res1.overall_att, + res2.att, + res1.att, atol=1e-12, err_msg=f"ATT changed with weight rescaling (agg={agg})", ) np.testing.assert_allclose( - res2.overall_se, - res1.overall_se, + res2.se, + res1.se, rtol=1e-6, err_msg=f"SE changed with weight rescaling (agg={agg})", ) - # Assert sub-aggregation outputs are also scale-invariant - if agg == "event_study" and res1.event_study_effects: - for e_key in res1.event_study_effects: - np.testing.assert_allclose( - res2.event_study_effects[e_key]["effect"], - res1.event_study_effects[e_key]["effect"], - atol=1e-12, - err_msg=f"Event study e={e_key} effect changed", - ) - np.testing.assert_allclose( - res2.event_study_effects[e_key]["se"], - res1.event_study_effects[e_key]["se"], - rtol=1e-6, - err_msg=f"Event study e={e_key} SE changed", - ) - if agg == "group" and res1.group_effects: - for g_key in res1.group_effects: - np.testing.assert_allclose( - res2.group_effects[g_key]["effect"], - res1.group_effects[g_key]["effect"], - atol=1e-12, - err_msg=f"Group g={g_key} effect changed", - ) - np.testing.assert_allclose( - res2.group_effects[g_key]["se"], - res1.group_effects[g_key]["se"], - rtol=1e-6, - err_msg=f"Group g={g_key} SE changed", - ) + # Assert the post-fit aggregation surfaces are also scale-invariant + if agg == "simple": + s1 = res1.aggregate("simple") + s2 = res2.aggregate("simple") + np.testing.assert_allclose(s2.att, s1.att, atol=1e-12) + np.testing.assert_allclose(s2.se, s1.se, rtol=1e-6) + if agg == "event_study": + es1 = res1.aggregate("event_study") + es2 = res2.aggregate("event_study") + assert len(es1.event_time) > 0 + np.testing.assert_array_equal(es2.event_time, es1.event_time) + np.testing.assert_allclose( + es2.att, + es1.att, + atol=1e-12, + err_msg="Event study effects changed", + ) + np.testing.assert_allclose( + es2.se, + es1.se, + rtol=1e-6, + err_msg="Event study SEs changed", + ) + if agg == "group": + g1 = res1.aggregate("group") + g2 = res2.aggregate("group") + assert len(g1.label) > 0 + np.testing.assert_array_equal(g2.label, g1.label) + np.testing.assert_allclose( + g2.att, + g1.att, + atol=1e-12, + err_msg="Group effects changed", + ) + np.testing.assert_allclose( + g2.se, + g1.se, + rtol=1e-6, + err_msg="Group SEs changed", + ) # --------------------------------------------------------------------------- @@ -604,7 +624,6 @@ def test_unequal_cohort_weights_change_aggregate(self): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", ) # Skewed survey weights @@ -616,18 +635,17 @@ def test_unequal_cohort_weights_change_aggregate(self): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", survey_design=sd, ) # Both should be finite - assert np.isfinite(res_uw.overall_att) - assert np.isfinite(res_w.overall_att) + assert np.isfinite(res_uw.att) + assert np.isfinite(res_w.att) # Aggregated ATT should differ due to different cohort weighting - assert abs(res_w.overall_att - res_uw.overall_att) > 1e-6, ( + assert abs(res_w.att - res_uw.att) > 1e-6, ( f"Expected aggregate ATTs to differ with skewed weights: " - f"weighted={res_w.overall_att}, unweighted={res_uw.overall_att}" + f"weighted={res_w.att}, unweighted={res_uw.att}" ) @@ -694,12 +712,11 @@ def test_bootstrap_with_survey(self, sddd_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", survey_design=sd, ) assert res.bootstrap_results is not None - assert np.isfinite(res.overall_se) - assert res.overall_se > 0 + assert np.isfinite(res.se) + assert res.se > 0 # --------------------------------------------------------------------------- @@ -726,8 +743,8 @@ def test_control_group(self, sddd_data, cg): partition="eligibility", survey_design=sd, ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) + assert np.isfinite(res.att) + assert np.isfinite(res.se) # --------------------------------------------------------------------------- @@ -754,8 +771,8 @@ def test_base_period(self, sddd_data, bp): partition="eligibility", survey_design=sd, ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) + assert np.isfinite(res.att) + assert np.isfinite(res.se) # --------------------------------------------------------------------------- @@ -780,9 +797,9 @@ def test_covariate_survey(self, sddd_data, method): covariates=["x1"], survey_design=sd, ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) - assert res.overall_se > 0 + assert np.isfinite(res.att) + assert np.isfinite(res.se) + assert res.se > 0 # --------------------------------------------------------------------------- @@ -820,12 +837,11 @@ def test_non_combined_replicate(self, sddd_data): time="period", first_treat="first_treat", partition="eligibility", - aggregate="simple", survey_design=sd, ) - assert np.isfinite(res.overall_att) - assert np.isfinite(res.overall_se) - assert res.overall_se > 0 + assert np.isfinite(res.att) + assert np.isfinite(res.se) + assert res.se > 0 # --------------------------------------------------------------------------- @@ -861,7 +877,7 @@ def test_fallback_uses_weighted_mean(self, sddd_data): covariates=["collinear_x"], survey_design=sd, ) - assert np.isfinite(res.overall_att) + assert np.isfinite(res.att) # --------------------------------------------------------------------------- @@ -900,7 +916,7 @@ def test_zero_mass_subgroup_warns(self): mass_warnings = [x for x in w if "mass=0" in str(x.message)] assert len(mass_warnings) > 0, "Expected zero-mass subgroup warning" # Result should still be finite (other cohorts contribute) - assert np.isfinite(res.overall_att) + assert np.isfinite(res.att) # --------------------------------------------------------------------------- @@ -908,11 +924,18 @@ def test_zero_mass_subgroup_warns(self): # --------------------------------------------------------------------------- +def _eq414_overall(res): + """The paper's Eq. 4.14 overall (the 3.x fit-time ``overall_att_es``), now the + post-fit ``aggregate("simple", weights="event_time")`` one-row table.""" + return res.aggregate("simple", weights="event_time") + + class TestSurveyOverallAttEs: - """The opt-in ``overall_att_es`` (paper Eq. 4.14) honours survey weights: its - analytical SE routes through the same survey-aware variance estimator as the - per-event-time effects, so uniform weights reproduce the unweighted result, - nontrivial weights move the SE, and a full strata/PSU design stays finite. + """The Eq. 4.14 overall (``aggregate("simple", weights="event_time")``, the + former fit-time ``overall_att_es``) honours survey weights: its analytical SE + routes through the same survey-aware variance estimator as the per-event-time + effects, so uniform weights reproduce the unweighted result, nontrivial + weights move the SE, and a full strata/PSU design stays finite. """ def test_uniform_weight_equivalence(self): @@ -926,7 +949,6 @@ def test_uniform_weight_equivalence(self): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", survey_design=sd, ) res_u = TripleDifference(estimation_method="reg").fit( @@ -936,12 +958,14 @@ def test_uniform_weight_equivalence(self): 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 - assert np.isclose(res_w.overall_att_es, res_u.overall_att_es, atol=1e-8) - assert res_w.overall_se_es is not None and res_u.overall_se_es is not None - assert np.isclose(res_w.overall_se_es, res_u.overall_se_es, atol=1e-8) + o_w = _eq414_overall(res_w) + o_u = _eq414_overall(res_u) + assert len(o_w.att) == 1 and len(o_u.att) == 1 + assert np.isfinite(o_w.att[0]) and np.isfinite(o_u.att[0]) + assert np.isclose(o_w.att[0], o_u.att[0], atol=1e-8) + assert np.isfinite(o_w.se[0]) and np.isfinite(o_u.se[0]) + assert np.isclose(o_w.se[0], o_u.se[0], atol=1e-8) def test_nontrivial_weights_change_se(self): data = _make_staggered_ddd_data(n_units=200, seed=42) # variable weights @@ -953,7 +977,6 @@ def test_nontrivial_weights_change_se(self): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", survey_design=sd, ) res_u = TripleDifference(estimation_method="reg").fit( @@ -963,11 +986,12 @@ def test_nontrivial_weights_change_se(self): 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 + o_w = _eq414_overall(res_w) + o_u = _eq414_overall(res_u) + assert np.isfinite(o_w.se[0]) and np.isfinite(o_u.se[0]) # Variable survey weights change the Eq-4.14 overall SE - assert not np.isclose(res_w.overall_se_es, res_u.overall_se_es, atol=1e-6) + assert not np.isclose(o_w.se[0], o_u.se[0], atol=1e-6) def test_full_design_overall_es_finite(self): data = _make_staggered_ddd_data(n_units=200, seed=42) @@ -979,19 +1003,19 @@ def test_full_design_overall_es_finite(self): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", survey_design=sd, ) - assert res.overall_att_es is not None and np.isfinite(res.overall_att_es) - assert res.overall_se_es is not None and np.isfinite(res.overall_se_es) - assert res.overall_se_es > 0 + o = _eq414_overall(res) + assert np.isfinite(o.att[0]) + assert np.isfinite(o.se[0]) + assert o.se[0] > 0 def test_replicate_weight_overall_es_finite(self): - """The Eq. 4.14 overall (overall_att_es) is computed and finite under a - replicate-weight (BRR) survey design, routing its SE through the replicate - variance path. Regression for the P1 fix that overall_att_es uses its OWN - per-statistic effective df (compute_replicate_if_variance), not the simple - overall's mutated df. + """The Eq. 4.14 overall (``aggregate("simple", weights="event_time")``) is + computed and finite under a replicate-weight (BRR) survey design, routing + its SE through the replicate variance path. Regression for the P1 fix that + the Eq. 4.14 overall uses its OWN per-statistic effective df + (compute_replicate_if_variance), not the simple overall's mutated df. """ data = _make_staggered_ddd_data(n_units=200, seed=42) rng = np.random.default_rng(99) @@ -1013,25 +1037,30 @@ def test_replicate_weight_overall_es_finite(self): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", survey_design=sd, ) - assert res.overall_att_es is not None and np.isfinite(res.overall_att_es) - assert res.overall_se_es is not None and np.isfinite(res.overall_se_es) - assert res.overall_se_es > 0 - # default overall_att (simple) also computed under the replicate design - assert np.isfinite(res.overall_att) and np.isfinite(res.overall_se) + o = _eq414_overall(res) + assert np.isfinite(o.att[0]) + assert np.isfinite(o.se[0]) + assert o.se[0] > 0 + # the per-statistic replicate df governs the Eq. 4.14 inference row + assert np.isfinite(o.df[0]) and o.df[0] > 0 + # default headline ATT (simple) also computed under the replicate design + assert np.isfinite(res.att) and np.isfinite(res.se) def test_single_psu_bootstrap_cluster_unidentified(self): """Under a single-PSU survey design with n_bootstrap>0, the Eq. 4.14 overall keeps its (analytical) point estimate but its SE is unidentified -> NaN, and the inference fields are NaN-consistent. - Regression for the P2 warning-misdiagnosis fix: when overall_se_es is NaN the - terminal warning must not attribute it *solely* to a non-finite influence + Regression for the P2 warning-misdiagnosis fix: when the Eq. 4.14 SE is NaN + the terminal warning must not attribute it *solely* to a non-finite influence function -- a single-PSU/cluster design is the actual cause here, and the - authoritative "variance unidentified" warning is raised by the bootstrap. The - Eq. 4.14 warning, when emitted, must name the unidentified-variance cause. + authoritative "variance unidentified" warning is raised by the bootstrap at + fit. The Eq. 4.14 warning is emitted by the + ``aggregate("simple", weights="event_time")`` call (which replays the + fit-time bootstrap) and, when emitted, must name the unidentified-variance + cause - so the recording block spans both the fit and the aggregate call. """ data = _make_staggered_ddd_data(n_units=120, seed=42).copy() data["psu"] = 0 # collapse to a single PSU -> variance/cluster unidentified @@ -1046,21 +1075,21 @@ def test_single_psu_bootstrap_cluster_unidentified(self): time="period", first_treat="first_treat", partition="eligibility", - aggregate="event_study", survey_design=sd, ) + o = _eq414_overall(res) # Point estimate survives; SE + inference are NaN-consistent (never a stale value). - assert res.overall_att_es is not None and np.isfinite(res.overall_att_es) - assert res.overall_se_es is not None and np.isnan(res.overall_se_es) - assert res.overall_t_stat_es is not None and np.isnan(res.overall_t_stat_es) - assert res.overall_p_value_es is not None and np.isnan(res.overall_p_value_es) - assert res.overall_conf_int_es is not None - assert np.isnan(res.overall_conf_int_es[0]) and np.isnan(res.overall_conf_int_es[1]) + assert len(o.att) == 1 + assert np.isfinite(o.att[0]) + assert np.isnan(o.se[0]) + assert np.isnan(o.t_stat[0]) + assert np.isnan(o.p_value[0]) + assert np.isnan(o.conf_int_lower[0]) and np.isnan(o.conf_int_upper[0]) msgs = [str(w.message) for w in caught] # The bootstrap raises the authoritative "variance unidentified" warning. assert any("unidentified" in m for m in msgs) # The Eq. 4.14 overall warning is cause-accurate (would fail on the old wording # that attributed the NaN solely to a non-finite influence function). - es_warns = [m for m in msgs if "overall_att_es)" in m] + es_warns = [m for m in msgs if "weights='event_time'))" in m] assert es_warns, "expected the Eq. 4.14 overall warning under single-PSU bootstrap" assert all("unidentified" in m for m in es_warns) diff --git a/tests/test_target_parameter.py b/tests/test_target_parameter.py index 3c048a3d6..fe20625ac 100644 --- a/tests/test_target_parameter.py +++ b/tests/test_target_parameter.py @@ -27,7 +27,7 @@ import pytest from diff_diff._reporting_helpers import describe_target_parameter -from diff_diff.diagnostic_report import _APPLICABILITY +from diff_diff.diagnostic_report import _APPLICABILITY, _DESIGN_APPLICABILITY def _minimal_result(class_name: str, **attrs): @@ -151,9 +151,14 @@ def test_triple_difference(self): assert tp["headline_attribute"] == "att" def test_staggered_triple_difference(self): - tp = describe_target_parameter(_minimal_result("StaggeredTripleDiffResults")) + # One container, two designs since 4.0 (row M-014): the staggered DDD + # entry is selected by the ``design`` discriminator, and its headline + # attribute is the canonical ``att`` (the last ``overall_att`` died here). + tp = describe_target_parameter( + _minimal_result("TripleDifferenceResults", design="staggered") + ) assert tp["aggregation"] == "staggered_ddd" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" def test_dcdh_m(self): tp = describe_target_parameter( @@ -452,6 +457,15 @@ def test_every_applicability_class_has_explicit_branch(self): f"describe_target_parameter branch. Missing: {missing}" ) + def test_every_design_applicability_entry_has_explicit_branch(self): + """Mirror guard for the design-keyed table (4.0, row M-014): a class that + serves two designs from one name dispatches on ``design``, so every + ``(class_name, design)`` key must reach a non-default branch too.""" + for class_name, design in _DESIGN_APPLICABILITY: + tp = describe_target_parameter(_minimal_result(class_name, design=design)) + assert tp["aggregation"] != "unknown", (class_name, design) + assert tp["headline_attribute"] == "att", (class_name, design) + def test_every_applicability_class_has_nonempty_fields(self): for class_name in _APPLICABILITY: tp = describe_target_parameter(_minimal_result(class_name)) diff --git a/tests/test_v4_merge_ddd.py b/tests/test_v4_merge_ddd.py index 77e5dafed..efda118a3 100644 --- a/tests/test_v4_merge_ddd.py +++ b/tests/test_v4_merge_ddd.py @@ -3,7 +3,8 @@ 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 +aggregate=/balance_e= carried on the surviving class through 3.x, removed at +4.0 by item 6), M-142 (the pscore_trim validation tightening). TOLERANCE DOCTRINE @@ -89,7 +90,6 @@ generate_ddd_panel_data, generate_staggered_ddd_data, ) -from diff_diff.staggered_triple_diff_results import StaggeredTripleDiffResults from diff_diff.survey import SurveyDesign from diff_diff.triple_diff import TripleDifferenceResults @@ -563,10 +563,14 @@ class TestGateBPrimeStaggeredOracle: def test_base_config_matches_oracle(self, stag): exp = ORACLE_STAGGERED["stag_dr_all"] - r = _fit_new(stag, aggregate="all") + r = _fit_new(stag) assert_allclose(r.att, exp["overall"]["att"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) assert_allclose(r.se, exp["overall"]["se"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) - assert_allclose(r.overall_att_es, exp["overall_att_es"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + es_overall = r.aggregate("simple", weights="event_time") + assert_allclose( + es_overall.att[0], exp["overall_att_es"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL + ) + assert_allclose(es_overall.se[0], exp["overall_se_es"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) assert r.n_obs == exp["n_obs"] assert r.n_never_enabled == exp["n_never_enabled"] got = {f"{g}|{t}": v for (g, t), v in sorted(r.group_time_effects.items())} @@ -577,11 +581,15 @@ def test_base_config_matches_oracle(self, stag): def test_bootstrap_config_matches_oracle(self, stag): exp = ORACLE_STAGGERED["stag_dr_all_boot49"] - r = _fit_new(stag, aggregate="all", ctor=dict(n_bootstrap=49, seed=7, cband=True)) + r = _fit_new(stag, ctor=dict(n_bootstrap=49, seed=7, cband=True)) assert_allclose(r.att, exp["overall"]["att"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) assert_allclose(r.se, exp["overall"]["se"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + # The sup-t critical value now comes from the post-fit event-study + # aggregation, which REPLAYS the fit-time multiplier bootstrap from the + # kit's retained RNG state - same weight stream, same statistic. + es = r.aggregate("event_study") assert_allclose( - r.cband_crit_value, exp["cband_crit_value"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL + es.cband_crit_value, exp["cband_crit_value"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL ) # -- branch coverage for the rest of the relocated engine ----------------- @@ -596,7 +604,7 @@ def test_never_treated_branch_matches_oracle(self, stag): 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")) + r = _fit_new(stag, ctor=dict(control_group="never_treated")) self._assert_against(r, exp) # ...and it must actually be a DIFFERENT comparison group, else a bridge # that collapsed every spelling to one branch would still pass above. @@ -610,7 +618,6 @@ def test_covariate_lanes_match_oracle(self, stag_cov, method): exp = ORACLE_STAGGERED[f"stag_{method}_cov"] r = _fit_new( stag_cov, - aggregate="all", covariates=STAG_COVS, ctor=dict(estimation_method=method), ) @@ -629,17 +636,14 @@ def test_methods_converge_without_covariates(self, stag): so dr/ipw/reg collapse to the same estimator. Asserted live rather than committed as three identical literal blocks that would LOOK like nuisance-model coverage while providing none.""" - fits = [ - _fit_new(stag, aggregate="all", ctor=dict(estimation_method=m)) - for m in ("dr", "ipw", "reg") - ] + fits = [_fit_new(stag, ctor=dict(estimation_method=m)) for m in ("dr", "ipw", "reg")] for r in fits[1:]: assert_allclose(r.att, fits[0].att, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) assert_allclose(r.se, fits[0].se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) def test_survey_pweight_matches_oracle(self, stag_survey): exp = ORACLE_STAGGERED["stag_dr_survey_pweight"] - r = _fit_new(stag_survey, aggregate="all", survey_design=SurveyDesign(weights="w")) + r = _fit_new(stag_survey, survey_design=SurveyDesign(weights="w")) self._assert_against(r, exp) # the weights must actually bite assert exp["overall"]["att"] != ORACLE_STAGGERED["stag_dr_all"]["overall"]["att"] @@ -648,7 +652,11 @@ def test_survey_pweight_matches_oracle(self, stag_survey): def _assert_against(r, exp): assert_allclose(r.att, exp["overall"]["att"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) assert_allclose(r.se, exp["overall"]["se"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) - assert_allclose(r.overall_att_es, exp["overall_att_es"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + es_overall = r.aggregate("simple", weights="event_time") + assert_allclose( + es_overall.att[0], exp["overall_att_es"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL + ) + assert_allclose(es_overall.se[0], exp["overall_se_es"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) got = {f"{g}|{t}": v for (g, t), v in sorted(r.group_time_effects.items())} assert set(got) == set(exp["group_time"]) for k, v in exp["group_time"].items(): @@ -657,14 +665,10 @@ def _assert_against(r, exp): _STAG_FIELDS = [ - "overall_att", - "overall_se", - "overall_t_stat", - "overall_p_value", - "overall_att_es", - "overall_se_es", - "overall_t_stat_es", - "overall_p_value_es", + "att", + "se", + "t_stat", + "p_value", "n_obs", "n_treated_units", "n_control_units", @@ -676,7 +680,8 @@ def _assert_against(r, exp): def _assert_staggered_parity(old, new): - assert type(new) is type(old) is StaggeredTripleDiffResults + assert type(new) is type(old) is TripleDifferenceResults + assert old.design == new.design == "staggered" for f in _STAG_FIELDS: a, b = getattr(old, f), getattr(new, f) if a is None or b is None: @@ -685,11 +690,16 @@ def _assert_staggered_parity(old, new): _eq_with_nans(a, b) _eq_with_nans(np.asarray(old.conf_int, dtype=float), np.asarray(new.conf_int, dtype=float)) _eq_dicts_with_nans(old.group_time_effects, new.group_time_effects) - for attr in ("event_study_effects", "group_effects"): - a, b = getattr(old, attr), getattr(new, attr) - assert (a is None) == (b is None) - if a is not None: - _eq_dicts_with_nans(a, b, keys=("effect", "se")) + # The event-study / cohort surfaces are post-fit since 4.0 (rows M-014/M-140): + # compare the recomputed containers, not stored fields (which are None). + es_a, es_b = old.aggregate("event_study"), new.aggregate("event_study") + _eq_with_nans(es_a.event_time, es_b.event_time) + _eq_with_nans(es_a.att, es_b.att) + _eq_with_nans(es_a.se, es_b.se) + g_a, g_b = old.aggregate("group"), new.aggregate("group") + assert list(g_a.label) == list(g_b.label) + _eq_with_nans(g_a.att, g_b.att) + _eq_with_nans(g_a.se, g_b.se) # --------------------------------------------------------------------------- @@ -713,8 +723,8 @@ def test_control_group_values_are_SEMANTICALLY_different(self, stag): """ with warnings.catch_warnings(): warnings.simplefilter("ignore") - nyt = _fit_new(stag, ctor=dict(control_group="not_yet_treated"), aggregate="all") - nev = _fit_new(stag, ctor=dict(control_group="never_treated"), aggregate="all") + nyt = _fit_new(stag, ctor=dict(control_group="not_yet_treated")) + nev = _fit_new(stag, ctor=dict(control_group="never_treated")) assert nyt.att != nev.att, ( "not_yet_treated and never_treated produced identical ATTs - the " "comparison-group branch is not reading control_group" @@ -726,7 +736,7 @@ def test_first_treat_inf_recode_warns(self, stag): df.loc[df["first_treat"] == 0, "first_treat"] = np.inf with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - _fit_new(df, aggregate="simple") + _fit_new(df) 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] @@ -763,7 +773,7 @@ def test_staggered_rejects_2x2x2_fit_params(self, stag): with pytest.raises(ValueError, match=r"belong\(s\) to the 2x2x2 mode"): _fit_new(stag, **kw) - @pytest.mark.parametrize("kw", [{"unit": "unit"}, {"aggregate": "all"}, {"balance_e": 1}]) + @pytest.mark.parametrize("kw", [{"unit": "unit"}]) def test_2x2x2_rejects_staggered_fit_params(self, cross, kw): with pytest.raises(ValueError, match=r"require\(s\) first_treat="): TripleDifference().fit(cross, **C_COLS, **kw) @@ -814,11 +824,14 @@ def test_shared_and_unreachable_params_pass_silently_in_2x2x2(self, cross, param r = TripleDifference(**{param: value}).fit(cross, **C_COLS) assert np.isfinite(r.att) - def test_bad_aggregate_VALUE_precedes_the_mode_error(self, cross): - """Deliberate ordering (3(a)'s `spec` precedent): a bad value is - reported as a bad value even in the wrong mode.""" - with pytest.raises(ValueError, match="aggregate must be"): - TripleDifference().fit(cross, aggregate="bogus", **C_COLS) + @pytest.mark.parametrize("kw", [{"aggregate": "all"}, {"balance_e": 1}]) + def test_fit_time_aggregate_keywords_are_gone(self, cross, stag, kw): + """Rows M-140/M-141, removed at 4.0: the keywords are unknown to fit() + in BOTH designs (TypeError, never a mode error); aggregation is post-fit.""" + with pytest.raises(TypeError, match="unexpected keyword argument"): + TripleDifference().fit(cross, **C_COLS, **kw) + with pytest.raises(TypeError, match="unexpected keyword argument"): + _fit_new(stag, **kw) @pytest.mark.parametrize("missing", ["group", "partition", "post"]) def test_2x2x2_required_args_named_individually(self, cross, missing): @@ -863,7 +876,7 @@ def test_staggered_time_is_the_calendar_column(self, stag): check must never fire on this path.""" with warnings.catch_warnings(): warnings.simplefilter("ignore") - r = _fit_new(stag, aggregate="simple") + r = _fit_new(stag) assert np.isfinite(r.att) def test_2x2x2_both_post_and_time_raises(self, cross): @@ -884,8 +897,9 @@ def test_2x2x2_both_post_and_time_raises(self, cross): 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.""" + """The class, its module, the ``SDDD`` alias and - since item 6 (row M-014) - + the ``StaggeredTripleDiffResults`` container are gone; the merged surface + constructs silently and returns the unified ``TripleDifferenceResults``.""" @pytest.mark.parametrize("name", ["StaggeredTripleDifference", "SDDD"]) def test_name_is_absent(self, name): @@ -903,12 +917,19 @@ def test_module_is_gone(self): 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.""" + def test_results_container_is_gone(self): + """M-014 (item 6): the staggered container and its module are removed; + the unified TripleDifferenceResults is the one export.""" + import importlib + import diff_diff - assert diff_diff.StaggeredTripleDiffResults is StaggeredTripleDiffResults - assert "StaggeredTripleDiffResults" in diff_diff.__all__ + assert not hasattr(diff_diff, "StaggeredTripleDiffResults") + assert "StaggeredTripleDiffResults" not in diff_diff.__all__ + with pytest.raises(ModuleNotFoundError): + importlib.import_module("diff_diff.staggered_triple_diff_results") + assert diff_diff.TripleDifferenceResults is TripleDifferenceResults + assert "TripleDifferenceResults" in diff_diff.__all__ def test_successor_does_not_warn(self, stag): with warnings.catch_warnings(record=True) as w: @@ -1017,9 +1038,7 @@ def test_negative_anticipation_cannot_reach_the_engine(self, stag): # the guard is EAGER: it fires at construction, so no fit can be reached # with an out-of-domain window even via the staggered branch. with pytest.raises(ValueError, match="anticipation must be a non-negative integer"): - TripleDifference(anticipation=-1).fit( - stag, partition="eligibility", **S_NEW, aggregate="simple" - ) + TripleDifference(anticipation=-1).fit(stag, partition="eligibility", **S_NEW) # -- first_treat cohort encoding (silent-population guard) ---------------- @@ -1041,7 +1060,7 @@ def test_negative_cohorts_fail_closed(self, stag, sentinel): df["first_treat"] = df["first_treat"].astype(float) df.loc[df["first_treat"] == 0, "first_treat"] = sentinel with pytest.raises(ValueError, match="negative cohort value"): - _fit_new(df, aggregate="simple") + _fit_new(df) # -- degenerate enabling cohort (honest-reporting guard) ------------------ @@ -1060,7 +1079,7 @@ def test_degenerate_cohort_is_named_and_dropped_from_groups(self, stag): deliberately unchanged: it is valid for the cohorts that do identify.""" df = self._strip_eligibility_from_cohort(stag, 4) with pytest.warns(UserWarning, match=r"cohort g=4\.0 has no eligible treated units"): - r = _fit_new(df, aggregate="simple") + r = _fit_new(df) 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} @@ -1069,7 +1088,7 @@ 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: warnings.simplefilter("always") - r = _fit_new(stag, aggregate="simple") + r = _fit_new(stag) assert not [x for x in w if "no eligible treated units" in str(x.message)] assert list(r.groups) == [3.0, 4.0] @@ -1078,7 +1097,7 @@ def test_degenerate_cohort_under_survey_weights(self, stag_survey): treated mass.""" df = self._strip_eligibility_from_cohort(stag_survey, 4) with pytest.warns(UserWarning, match=r"cohort g=4\.0 has no eligible treated units"): - r = _fit_new(df, aggregate="simple", survey_design=SurveyDesign(weights="w")) + r = _fit_new(df, survey_design=SurveyDesign(weights="w")) assert list(r.groups) == [3.0] def test_positive_inf_still_recodes_with_a_warning(self, stag): @@ -1087,14 +1106,14 @@ def test_positive_inf_still_recodes_with_a_warning(self, stag): df["first_treat"] = df["first_treat"].astype(float) df.loc[df["first_treat"] == 0, "first_treat"] = np.inf with pytest.warns(UserWarning, match="recoding to 0"): - r = _fit_new(df, aggregate="simple") - plain = _fit_new(stag, aggregate="simple") + r = _fit_new(df) + plain = _fit_new(stag) _eq_with_nans([r.att, r.se], [plain.att, plain.se]) assert r.n_never_enabled == plain.n_never_enabled def test_valid_cohorts_are_untouched_by_the_guard(self, stag): """Regression pin: the guard changes no accepted fit.""" - r = _fit_new(stag, aggregate="all") + r = _fit_new(stag) exp = ORACLE_STAGGERED["stag_dr_all"] assert_allclose(r.att, exp["overall"]["att"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) @@ -1103,7 +1122,7 @@ def test_anticipation_guard_survives_attribute_mutation(self, stag): est = TripleDifference() est.anticipation = -1 # neither validator sees this with pytest.raises(ValueError, match="anticipation must be a non-negative integer"): - est.fit(stag, partition="eligibility", **S_NEW, aggregate="simple") + est.fit(stag, partition="eligibility", **S_NEW) @pytest.mark.parametrize("compact", ["notyettreated", "nevertreated"]) def test_merged_class_rejects_the_compact_vocabulary(self, compact): @@ -1130,14 +1149,16 @@ def test_cross_mode_refit_on_one_instance(self, cross, stag): est = TripleDifference() with warnings.catch_warnings(): warnings.simplefilter("ignore") - stag_first = est.fit(stag, partition="eligibility", **S_NEW, aggregate="all") - assert isinstance(stag_first, StaggeredTripleDiffResults) + stag_first = est.fit(stag, partition="eligibility", **S_NEW) + assert isinstance(stag_first, TripleDifferenceResults) + assert stag_first.design == "staggered" then_cross = est.fit(cross, **C_COLS) assert isinstance(then_cross, TripleDifferenceResults) + assert then_cross.design == "2x2x2" fresh_cross = TripleDifference().fit(cross, **C_COLS) _eq_with_nans(_quintet(then_cross), _quintet(fresh_cross)) - back_to_stag = est.fit(stag, partition="eligibility", **S_NEW, aggregate="all") + back_to_stag = est.fit(stag, partition="eligibility", **S_NEW) _assert_staggered_parity(stag_first, back_to_stag) assert est.is_fitted_ @@ -1164,7 +1185,7 @@ def test_user_attributed_warnings_land_on_the_CALLER(self, stag): df = generate_staggered_ddd_data(**{**STAG_KW, "cohort_periods": [1, 3]}) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - _fit_new(df, aggregate="simple") + _fit_new(df) 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), [ @@ -1176,7 +1197,7 @@ def test_low_bootstrap_warning_attributes_to_the_caller(self, stag): offset through getattr rather than taking it as an argument.""" with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - _fit_new(stag, ctor=dict(n_bootstrap=9, seed=1), aggregate="simple") + _fit_new(stag, ctor=dict(n_bootstrap=9, seed=1)) 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 @@ -1243,11 +1264,9 @@ def test_business_report_handles_both_control_groups(self, stag): with warnings.catch_warnings(): warnings.simplefilter("ignore") nyt = BusinessReport( - _fit_new(stag, ctor=dict(control_group="not_yet_treated"), aggregate="simple") - ).to_dict() - nev = BusinessReport( - _fit_new(stag, ctor=dict(control_group="never_treated"), aggregate="simple") + _fit_new(stag, ctor=dict(control_group="not_yet_treated")) ).to_dict() + nev = BusinessReport(_fit_new(stag, ctor=dict(control_group="never_treated"))).to_dict() assert nyt["sample"]["n_never_enabled"] == nev["sample"]["n_never_enabled"] > 0 def test_summary_renders_in_both_modes(self, cross, stag): @@ -1259,7 +1278,7 @@ def test_summary_renders_in_both_modes(self, cross, stag): assert "Triple Difference" in est.summary() with warnings.catch_warnings(): warnings.simplefilter("ignore") - est.fit(stag, partition="eligibility", **S_NEW, aggregate="all") + est.fit(stag, partition="eligibility", **S_NEW) text = est.summary() assert "ATT" in text and len(text.splitlines()) > 10 @@ -1315,29 +1334,15 @@ def test_power_accepts_the_inert_bootstrap_satellites(self, inert): progress=False, ) - @pytest.mark.parametrize("fn_name", ["simulate_power", "simulate_mde", "simulate_sample_size"]) - def test_power_accepts_explicit_none_aggregate_and_balance_e(self, cross, fn_name): - """`aggregate`/`balance_e` default to None and fit() rejects only a - NON-None value, so keying the power guard on KEY PRESENCE rejected - `estimator_kwargs={"aggregate": None}` - a config fit() accepts. That - broke the boundary the guard exists to uphold: legal to fit implies - legal to simulate.""" - import diff_diff.power as power_mod - - kw = {"aggregate": None, "balance_e": None} - assert TripleDifference().fit(cross, **C_COLS, **kw) is not None - - fn = getattr(power_mod, fn_name) - extra = {"n_units": 48} if fn_name != "simulate_sample_size" else {} - if fn_name != "simulate_power": - extra.update(max_steps=2, progress=False) - assert fn(TripleDifference(), n_simulations=2, seed=1, estimator_kwargs=kw, **extra) - @pytest.mark.parametrize("key,value", [("aggregate", "simple"), ("balance_e", 1)]) - def test_power_still_rejects_non_none_aggregate_and_balance_e(self, key, value): + def test_power_forwards_removed_fit_time_keywords_to_fit(self, key, value): + """Rows M-140/M-141: `aggregate`/`balance_e` are no longer fit() keywords, + so power's staggered-boundary guard no longer keys on them (there is no + by-value pair any more); `estimator_kwargs` is forwarded to fit(), whose + own TypeError is the observable - never the "staggered DDD mode" ValueError.""" from diff_diff.power import simulate_power - with pytest.raises(ValueError, match="staggered DDD mode"): + with pytest.raises(TypeError, match="unexpected keyword argument"): simulate_power( TripleDifference(), n_units=48, n_simulations=1, estimator_kwargs={key: value} ) @@ -1375,7 +1380,7 @@ def test_bootstrap_warning_names_the_estimator_that_was_fit(self, stag): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - _fit_new(df, aggregate="event_study", survey_design=design, ctor=ctor) + _fit_new(df, survey_design=design, ctor=ctor) hits = [ str(w.message) for w in caught diff --git a/tests/test_v4_unify_ddd.py b/tests/test_v4_unify_ddd.py new file mode 100644 index 000000000..9d058de06 --- /dev/null +++ b/tests/test_v4_unify_ddd.py @@ -0,0 +1,653 @@ +"""Item 6 of the 4.0 cut - the DDD container unification (rows M-014 / M-140 / M-141). + +``TripleDifference.fit`` returns ONE results shape for both of its designs: +``TripleDifferenceResults`` with a ``design`` discriminator (``"2x2x2"`` / +``"staggered"``), the canonical quintet stored natively, the group-time table +empty in the 2x2x2 design, and the staggered design's event-study / cohort +surfaces produced POST-FIT by ``aggregate()`` (bootstrapped fits replay the +fit-time multiplier bootstrap from the retained kit). The 3.x +``StaggeredTripleDiffResults`` container and the fit-time ``aggregate=`` / +``balance_e=`` keywords are gone; the paper's Eq. 4.14 overall (the former +``overall_att_es`` field) is ``aggregate('simple', weights='event_time')``. + +Numeric pins are literals captured on the UNMODIFIED tree under +``DIFF_DIFF_BACKEND=python`` (the pre-change fit-time ``aggregate="all"`` / +``balance_e=1`` surfaces on the ``generate_staggered_ddd_data(n_units=96, +n_periods=6, cohort_periods=[3, 4], seed=42)`` panel, the ``seed=11`` per-unit +pweight lane of tests/test_v4_merge_ddd.py, and the ``n_bootstrap=49, seed=7, +cband=True`` lane), so they are tolerance-based AND python-backend-guarded like +the merge gates. +""" + +from __future__ import annotations + +import inspect +import os +import pickle +import warnings +from dataclasses import fields + +import numpy as np +import pandas as pd +import pytest +from numpy.testing import assert_allclose + +import diff_diff +from diff_diff import SurveyDesign, TripleDifference, TripleDifferenceResults +from diff_diff.aggregation import AggregationResult +from diff_diff.prep_dgp import generate_ddd_data, generate_staggered_ddd_data +from diff_diff.results_base import EventStudyResults, build_event_study_surface + +ORACLE_RTOL = 1e-9 +ORACLE_ATOL = 1e-12 +_BACKEND_IS_PYTHON = os.environ.get("DIFF_DIFF_BACKEND", "").lower() == "python" +requires_python_backend = pytest.mark.skipif( + not _BACKEND_IS_PYTHON, + reason="literal oracles were captured under DIFF_DIFF_BACKEND=python", +) + +STAG_KW = dict(n_units=96, n_periods=6, cohort_periods=[3, 4], seed=42) +CROSS_KW = dict( + n_per_cell=200, + treatment_effect=2.0, + group_effect=1.0, + partition_effect=0.5, + time_effect=0.7, + noise_sd=1.0, + add_covariates=True, + seed=42, +) +S = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") +C = dict(outcome="outcome", group="group", partition="partition", post="time") + +# --------------------------------------------------------------------------- +# Oracles (pre-change fit-time surfaces; see the module docstring) +# --------------------------------------------------------------------------- + +# aggregate="all" on the plain analytical fit +ES_ORACLE = { # event time -> (effect, se) + -2.0: (-0.0027171020705352477, 0.32095504907992906), + -1.0: (-0.2859337822535215, 0.21658182913951848), + 0.0: (3.160855577888972, 0.24096993187145188), + 1.0: (3.2718809379369556, 0.26314749853218433), + 2.0: (3.3843545714882985, 0.2749885435697253), + 3.0: (3.426518594109598, 0.3635995672681256), +} +GROUP_ORACLE = { # cohort -> (effect, se) + 3.0: (3.0395757421191214, 0.26041596101583653), + 4.0: (3.634132600087188, 0.3032102909862472), +} +ES_OVERALL_ORACLE = dict( # overall_att_es and siblings, analytical + att=3.3109024203559563, + se=0.23391014483341283, + t_stat=14.15459095506067, + p_value=1.7495276788677547e-45, + ci=(2.8524469608639196, 3.769357879847993), +) +# aggregate="event_study", balance_e=1 (both cohorts are observed through e=1, +# so the balanced surface coincides with the unbalanced one on this panel - the +# pin is that balance_e threads through without disturbing anything) +ES_OVERALL_BALANCE1_ORACLE = ES_OVERALL_ORACLE +# n_bootstrap=49, seed=7, cband=True, aggregate="all" +BOOT_CBAND_ORACLE = 2.7121511569356116 +BOOT_ES_SE_ORACLE = { + -2.0: 0.31692583310553385, + -1.0: 0.22336582093958765, + 0.0: 0.22103913589369725, + 1.0: 0.28671846315219596, + 2.0: 0.3034921174110529, + 3.0: 0.3383619126874234, +} +BOOT_GROUP_SE_ORACLE = {3.0: 0.2531410081915656, 4.0: 0.3379035199522487} +BOOT_ES_OVERALL_ORACLE = dict( + att=3.3109024203559563, + se=0.23970805380084317, + p_value=0.02, + ci=(3.00823738849901, 3.8702221169961026), +) +# survey_design=SurveyDesign(weights="w"), aggregate="all" +SURVEY_ES_OVERALL_ORACLE = dict( + att=3.28298050259862, + se=0.25007641854869433, + t_stat=13.127909147336757, + p_value=4.581613702172688e-23, + ci=(2.7865160417217636, 3.779444963475476), + df=95.0, +) +SURVEY_GROUP_ORACLE = { + 3.0: (2.9067119210278998, 0.28522911091462966), + 4.0: (3.6833713597964355, 0.3124802376382699), +} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def stag(): + return generate_staggered_ddd_data(**STAG_KW) + + +@pytest.fixture(scope="module") +def cross(): + return generate_ddd_data(**CROSS_KW) + + +@pytest.fixture(scope="module") +def stag_survey(stag): + df = stag.copy() + rng = np.random.default_rng(11) + units = sorted(df["unit"].unique()) + df["w"] = df["unit"].map(dict(zip(units, rng.uniform(0.5, 2.0, size=len(units))))) + return df + + +def _fit_stag(df, ctor=None, **kw): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return TripleDifference(**(ctor or {})).fit(df, partition="eligibility", **S, **kw) + + +@pytest.fixture(scope="module") +def stag_fit(stag): + return _fit_stag(stag) + + +@pytest.fixture(scope="module") +def boot_fit(stag): + return _fit_stag(stag, ctor=dict(n_bootstrap=49, seed=7, cband=True)) + + +@pytest.fixture(scope="module") +def survey_fit(stag_survey): + return _fit_stag(stag_survey, survey_design=SurveyDesign(weights="w")) + + +@pytest.fixture(scope="module") +def cross_fit(cross): + return TripleDifference().fit(cross, **C) + + +def _row(es, e): + idx = [i for i, t in enumerate(es.event_time) if float(t) == e] + assert len(idx) == 1, (e, es.event_time) + return idx[0] + + +# --------------------------------------------------------------------------- +# (1) Removal pins - the class, its module, the fit-time keywords +# --------------------------------------------------------------------------- + + +class TestRemoval: + def test_staggered_container_is_gone(self): + import importlib + + assert not hasattr(diff_diff, "StaggeredTripleDiffResults") + assert "StaggeredTripleDiffResults" not in diff_diff.__all__ + with pytest.raises(ModuleNotFoundError): + importlib.import_module("diff_diff.staggered_triple_diff_results") + assert "TripleDifferenceResults" in diff_diff.__all__ + + def test_results_module_moved_and_re_exported(self): + from diff_diff import triple_diff, triple_diff_results + + assert TripleDifferenceResults is triple_diff_results.TripleDifferenceResults + # The 3.x class path still resolves (pickles and existing readers). + assert triple_diff.TripleDifferenceResults is TripleDifferenceResults + + @pytest.mark.parametrize("kw", [{"aggregate": "event_study"}, {"balance_e": 1}]) + def test_fit_time_keywords_raise_type_error_in_both_designs(self, stag, cross, kw): + with pytest.raises(TypeError, match="unexpected keyword argument"): + TripleDifference().fit(cross, **C, **kw) + with pytest.raises(TypeError, match="unexpected keyword argument"): + TripleDifference().fit(stag, partition="eligibility", **S, **kw) + + def test_fit_signature_lacks_the_keywords(self): + params = inspect.signature(TripleDifference.fit).parameters + assert "aggregate" not in params and "balance_e" not in params + assert "balance_e" in inspect.signature(TripleDifferenceResults.aggregate).parameters + + +# --------------------------------------------------------------------------- +# (2) Shape - one class, two designs, canonical storage +# --------------------------------------------------------------------------- + + +class TestShape: + def test_both_designs_return_the_unified_class(self, stag_fit, cross_fit): + assert type(stag_fit) is type(cross_fit) is TripleDifferenceResults + assert stag_fit.design == "staggered" + assert cross_fit.design == "2x2x2" + + def test_degenerate_2x2x2_view(self, cross_fit): + assert cross_fit.group_time_effects == {} + assert cross_fit.groups == [] and cross_fit.time_periods == [] + assert cross_fit.n_treated_units is None and cross_fit.control_group is None + assert cross_fit.n_treated_eligible is not None + assert cross_fit.vcov_type == "hc1" + + def test_staggered_fields(self, stag_fit): + assert set(stag_fit.groups) == {3.0, 4.0} + assert len(stag_fit.time_periods) == 6 + assert stag_fit.n_treated_eligible is None and stag_fit.vcov_type is None + assert stag_fit.control_group == "not_yet_treated" + assert stag_fit.anticipation == 0 and stag_fit.pscore_trim == 0.01 + assert stag_fit.inference_method == "analytical" and stag_fit.n_bootstrap is None + # The former fit-time surfaces are carrier fields, None on every fit. + assert stag_fit.event_study_effects is None + assert stag_fit.group_effects is None + assert stag_fit.cband_crit_value is None + + def test_canonical_quintet_native_and_no_overall_storage(self): + names = {f.name for f in fields(TripleDifferenceResults)} + assert {"att", "se", "t_stat", "p_value", "conf_int", "design", "df_inference"} <= names + assert not [n for n in names if n.startswith("overall_")] + for legacy in ("overall_att", "overall_se", "overall_att_es", "overall_se_es"): + assert not hasattr(TripleDifferenceResults, legacy) + + def test_2x2x2_positional_layout_is_byte_stable(self): + res = TripleDifferenceResults(1.0, 0.5, 2.0, 0.05, (0.0, 2.0), 100, 25, 25, 25, 25, "dr") + assert res.design == "2x2x2" and res.estimation_method == "dr" + assert res.n_treated_eligible == 25 and res.alpha == 0.05 + + def test_post_init_guards(self): + with pytest.raises(ValueError, match="design must be one of"): + TripleDifferenceResults( + 1.0, 0.5, 2.0, 0.05, (0.0, 2.0), 100, 25, 25, 25, 25, design="x" + ) + with pytest.raises(ValueError, match="requires non-empty group_time_effects"): + TripleDifferenceResults(1.0, 0.5, 2.0, 0.05, (0.0, 2.0), 100, design="staggered") + with pytest.raises(ValueError, match="requires the four cell counts"): + TripleDifferenceResults(1.0, 0.5, 2.0, 0.05, (0.0, 2.0), 100) + + def test_df_inference_provenance(self, stag_fit, cross_fit, boot_fit, survey_fit): + # 2x2x2 non-survey: the engine's t df (n_obs - 8 - len(covariates)) + assert cross_fit.df_inference == cross_fit.n_obs - 8 + # staggered: the overall uses the normal reference on plain fits + assert stag_fit.df_inference is None and boot_fit.df_inference is None + assert survey_fit.df_inference is None and survey_fit.survey_metadata.df_survey == 95 + + +# --------------------------------------------------------------------------- +# (3) Post-fit aggregate() +# --------------------------------------------------------------------------- + + +class TestAggregateVocabulary: + def test_simple_relays_the_stored_quintet(self, stag_fit, cross_fit): + for res, n_kind, n in ( + (stag_fit, "units", 96.0), + (cross_fit, "obs", float(cross_fit.n_obs)), + ): + simple = res.aggregate("simple") + assert isinstance(simple, AggregationResult) + assert simple.level == "simple" and list(simple.label) == ["overall"] + assert simple.att[0] == res.att and simple.se[0] == res.se + assert simple.t_stat[0] == res.t_stat and simple.p_value[0] == res.p_value + assert (simple.conf_int_lower[0], simple.conf_int_upper[0]) == tuple(res.conf_int) + assert simple.n_kind == n_kind and simple.n[0] == n + assert simple.estimator == "TripleDifference" + assert np.isnan(stag_fit.aggregate("simple").df[0]) # normal reference at fit + assert cross_fit.aggregate("simple").df[0] == cross_fit.n_obs - 8 + + @pytest.mark.parametrize("level", ["event_study", "group"]) + def test_2x2x2_recompute_levels_name_the_staggered_design(self, cross_fit, level): + with pytest.raises(ValueError, match="fit the staggered design"): + cross_fit.aggregate(level) + + def test_2x2x2_rejects_weights_and_balance_e(self, cross_fit): + with pytest.raises(ValueError, match="fit the staggered design"): + cross_fit.aggregate("simple", weights="event_time") + with pytest.raises(ValueError, match="fit the staggered design"): + cross_fit.aggregate("simple", balance_e=1) + + def test_total_is_not_offered(self, stag_fit): + with pytest.raises(ValueError, match="Unsupported aggregation type 'total'"): + stag_fit.aggregate("total") + + def test_weights_selector_contract(self, stag_fit): + with pytest.raises(ValueError, match="weights='event_time'"): + stag_fit.aggregate("simple", weights="bogus") + for level in ("event_study", "group"): + with pytest.raises(ValueError, match="applies to aggregate\\('simple'\\) only"): + stag_fit.aggregate(level, weights="event_time") + + def test_balance_e_on_plain_simple_is_rejected(self, stag_fit): + with pytest.raises(ValueError, match="balance_e applies to"): + stag_fit.aggregate("simple", balance_e=1) + + +@requires_python_backend +class TestAggregateReproducesFitTimeSurfaces: + def test_event_study(self, stag_fit): + es = stag_fit.aggregate("event_study") + assert isinstance(es, EventStudyResults) + assert sorted(float(t) for t in es.event_time) == sorted(ES_ORACLE) + for e, (eff, se) in ES_ORACLE.items(): + i = _row(es, e) + assert_allclose(es.att[i], eff, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert_allclose(es.se[i], se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert es.cband_crit_value is None + assert es.vcov is not None and es.vcov.shape == (len(ES_ORACLE), len(ES_ORACLE)) + + def test_group(self, stag_fit): + grp = stag_fit.aggregate("group") + assert isinstance(grp, AggregationResult) and grp.level == "group" + got = dict(zip((float(g) for g in grp.label), zip(grp.att, grp.se))) + assert set(got) == set(GROUP_ORACLE) + for g, (eff, se) in GROUP_ORACLE.items(): + assert_allclose(got[g][0], eff, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert_allclose(got[g][1], se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + + def test_event_time_overall_is_the_eq_414_field(self, stag_fit): + o = stag_fit.aggregate("simple", weights="event_time") + assert o.level == "simple" and list(o.label) == ["overall"] and list(o.target) == ["att"] + assert_allclose(o.att[0], ES_OVERALL_ORACLE["att"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert_allclose(o.se[0], ES_OVERALL_ORACLE["se"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert_allclose( + o.t_stat[0], ES_OVERALL_ORACLE["t_stat"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL + ) + assert_allclose( + o.p_value[0], ES_OVERALL_ORACLE["p_value"], rtol=ORACLE_RTOL, atol=ORACLE_ATOL + ) + assert_allclose(o.conf_int_lower[0], ES_OVERALL_ORACLE["ci"][0], rtol=ORACLE_RTOL) + assert_allclose(o.conf_int_upper[0], ES_OVERALL_ORACLE["ci"][1], rtol=ORACLE_RTOL) + assert np.isnan(o.df[0]) # plain fit: normal reference, no df governed it + assert o.n_kind == "units" and o.n[0] == 96.0 and o.weight[0] == 1.0 + # distinct from the cohort-weighted headline + assert o.att[0] != stag_fit.att + + def test_balance_e_threads_into_the_eq_414_overall(self, stag_fit): + o = stag_fit.aggregate("simple", weights="event_time", balance_e=1) + assert_allclose(o.att[0], ES_OVERALL_BALANCE1_ORACLE["att"], rtol=ORACLE_RTOL) + assert_allclose(o.se[0], ES_OVERALL_BALANCE1_ORACLE["se"], rtol=ORACLE_RTOL) + + def test_bootstrap_replay(self, boot_fit): + assert boot_fit.inference_method == "bootstrap" and boot_fit.n_bootstrap == 49 + es = boot_fit.aggregate("event_study") + assert_allclose(es.cband_crit_value, BOOT_CBAND_ORACLE, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + for e, se in BOOT_ES_SE_ORACLE.items(): + assert_allclose(es.se[_row(es, e)], se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert es.cband_lower is not None and np.all(es.cband_lower <= es.att) + grp = boot_fit.aggregate("group") + got = dict(zip((float(g) for g in grp.label), grp.se)) + for g, se in BOOT_GROUP_SE_ORACLE.items(): + assert_allclose(got[g], se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert np.isnan(grp.df).all() # percentile inference: no df governs it + o = boot_fit.aggregate("simple", weights="event_time") + assert_allclose(o.att[0], BOOT_ES_OVERALL_ORACLE["att"], rtol=ORACLE_RTOL) + assert_allclose(o.se[0], BOOT_ES_OVERALL_ORACLE["se"], rtol=ORACLE_RTOL) + assert_allclose(o.p_value[0], BOOT_ES_OVERALL_ORACLE["p_value"], rtol=ORACLE_RTOL) + assert_allclose(o.conf_int_lower[0], BOOT_ES_OVERALL_ORACLE["ci"][0], rtol=ORACLE_RTOL) + assert np.isnan(o.df[0]) + simple = boot_fit.aggregate("simple") + assert simple.se[0] == boot_fit.se and np.isnan(simple.df[0]) + + def test_survey_pweight(self, survey_fit): + grp = survey_fit.aggregate("group") + got = dict(zip((float(g) for g in grp.label), zip(grp.att, grp.se))) + for g, (eff, se) in SURVEY_GROUP_ORACLE.items(): + assert_allclose(got[g][0], eff, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert_allclose(got[g][1], se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + o = survey_fit.aggregate("simple", weights="event_time") + assert_allclose(o.att[0], SURVEY_ES_OVERALL_ORACLE["att"], rtol=ORACLE_RTOL) + assert_allclose(o.se[0], SURVEY_ES_OVERALL_ORACLE["se"], rtol=ORACLE_RTOL) + assert_allclose(o.p_value[0], SURVEY_ES_OVERALL_ORACLE["p_value"], rtol=ORACLE_RTOL) + # the survey df governed the t-reference, and the relay reports it too + assert o.df[0] == SURVEY_ES_OVERALL_ORACLE["df"] + assert survey_fit.aggregate("simple").df[0] == SURVEY_ES_OVERALL_ORACLE["df"] + + +class TestEq414Warnings: + def test_no_post_horizon_fails_soft(self, stag_fit): + with pytest.warns(UserWarning, match="no post-treatment horizons are available"): + o = stag_fit.aggregate("simple", weights="event_time", balance_e=100) + assert np.isnan(o.att[0]) and np.isnan(o.se[0]) and np.isnan(o.p_value[0]) + assert np.isfinite(stag_fit.att) # the headline is untouched + + def test_single_psu_bootstrap_se_is_nan_with_cause_accurate_warning(self, stag): + df = stag.copy() + df["w"] = 1.0 + df["psu"] = 0 # one PSU -> the bootstrap variance is unidentified + df["fpc_col"] = 10**6 + design = SurveyDesign(weights="w", psu="psu", fpc="fpc_col") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = TripleDifference(estimation_method="reg", n_bootstrap=20, seed=7).fit( + df, partition="eligibility", survey_design=design, **S + ) + o = res.aggregate("simple", weights="event_time") + assert np.isfinite(o.att[0]) and np.isnan(o.se[0]) and np.isnan(o.p_value[0]) + msgs = [str(w.message) for w in caught if "weights='event_time'))" in str(w.message)] + assert msgs and all("unidentified" in m for m in msgs), msgs + + +# --------------------------------------------------------------------------- +# (4) Read surfaces (no FutureWarning anywhere; the duck-typed consumers) +# --------------------------------------------------------------------------- + + +class TestReadSurfaces: + @pytest.fixture(params=["stag_fit", "cross_fit", "boot_fit"]) + def res(self, request): + return request.getfixturevalue(request.param) + + def test_surfaces_are_warning_free(self, res): + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + d = res.to_dict() + assert d["att"] == res.att and d["design"] == res.design + assert "df_inference" in d and not [k for k in d if k.startswith("overall_")] + assert "ATT" in res.summary() and "TripleDifferenceResults" in repr(res) + frame = res.to_dataframe("overall") + assert len(frame) == 1 and frame["att"].iloc[0] == res.att + assert isinstance(res.coef_var, float) and isinstance(res.is_significant, bool) + assert isinstance(res.epv_summary(), pd.DataFrame) + + def test_to_dataframe_levels(self, stag_fit, cross_fit): + assert len(stag_fit.to_dataframe()) == len(stag_fit.group_time_effects) + assert list(stag_fit.to_dataframe().columns[:2]) == ["group", "time"] + assert len(cross_fit.to_dataframe()) == 1 + for cols_expected in ("design", "pscore_trim", "alpha", "df_inference", "att"): + assert cols_expected in cross_fit.to_dataframe().columns + with pytest.raises(ValueError, match="fit the staggered design"): + cross_fit.to_dataframe("group_time") + for level in ("event_study", "group"): + with pytest.raises(ValueError, match="not stored on 4.0 fits"): + stag_fit.to_dataframe(level) + with pytest.raises(ValueError, match="Unknown level"): + stag_fit.to_dataframe("bogus") + + def test_to_dict_blocks(self, stag_fit, cross_fit, boot_fit): + d = stag_fit.to_dict() + assert d["n_groups"] == 2 and d["control_group"] == "not_yet_treated" + assert "n_treated_eligible" not in d and "n_bootstrap" not in d + assert boot_fit.to_dict()["n_bootstrap"] == 49 + c = cross_fit.to_dict() + assert c["n_treated_eligible"] is not None and "n_groups" not in c + assert c["vcov_type"] == "hc1" + + def test_summary_text_by_design(self, stag_fit, cross_fit): + s = stag_fit.summary() + assert "Staggered Triple Difference" in s and "results.aggregate('event_study')" in s + assert "Eq. 4.14" not in s + assert "Sample Composition by Cell" in cross_fit.summary() + assert "design='staggered'" in repr(stag_fit) and "design=" not in repr(cross_fit) + + def test_epv_summary_columns(self, stag_fit, cross_fit): + assert list(stag_fit.epv_summary().columns[:2]) == ["group", "time"] + assert list(cross_fit.epv_summary().columns[:1]) == ["subgroup"] + + def test_duck_typed_consumers_on_2x2x2(self, cross_fit): + from diff_diff import plot_event_study, plot_group_effects + + with pytest.raises(ValueError, match="staggered design only"): + build_event_study_surface(cross_fit) + with pytest.raises(ValueError, match="no group-time cells to plot"): + plot_group_effects(cross_fit, show=False) + with pytest.raises(TypeError): + plot_event_study(cross_fit, show=False) + + +# --------------------------------------------------------------------------- +# (5) Pickles +# --------------------------------------------------------------------------- + + +class TestPickle: + def test_round_trip_both_designs(self, stag_fit, cross_fit): + back = pickle.loads(pickle.dumps(stag_fit)) + assert back.design == "staggered" and back.att == stag_fit.att + es = back.aggregate("event_study") # the kit survives + assert len(es.event_time) == len(stag_fit.aggregate("event_study").event_time) + back_c = pickle.loads(pickle.dumps(cross_fit)) + assert back_c.design == "2x2x2" and back_c.to_dict() == cross_fit.to_dict() + + def test_simulated_3x_2x2x2_state_loads_with_defaults(self, cross_fit): + state = dict(cross_fit.__dict__) + new_keys = {f.name for f in fields(TripleDifferenceResults)} - { + "att", + "se", + "t_stat", + "p_value", + "conf_int", + "n_obs", + "n_treated_eligible", + "n_treated_ineligible", + "n_control_eligible", + "n_control_ineligible", + "estimation_method", + "alpha", + "group_means", + "pscore_stats", + "r_squared", + "covariate_balance", + "inference_method", + "vcov_type", + "cluster_name", + "n_bootstrap", + "n_clusters", + "survey_metadata", + "epv_diagnostics", + "epv_threshold", + "pscore_fallback", + } + for key in new_keys | {"_aggregation_kit"}: + state.pop(key, None) + obj = TripleDifferenceResults.__new__(TripleDifferenceResults) + obj.__setstate__(state) + assert obj.design == "2x2x2" and obj.group_time_effects == {} and obj.groups == [] + assert obj.df_inference is None and obj.pscore_trim == 0.01 + assert obj.att == cross_fit.att and obj.aggregate("simple").att[0] == cross_fit.att + + def test_old_class_path_unpickles(self, cross_fit): + # Protocol 0 spells the class path in clear text, so a 3.x pickle (class + # path diff_diff.triple_diff.TripleDifferenceResults) is simulated by + # rewriting the module name; the re-export is what makes it resolve. + raw = pickle.dumps(cross_fit, protocol=0) + assert b"diff_diff.triple_diff_results\nTripleDifferenceResults" in raw + legacy = raw.replace( + b"diff_diff.triple_diff_results\nTripleDifferenceResults", + b"diff_diff.triple_diff\nTripleDifferenceResults", + ) + back = pickle.loads(legacy) + assert type(back) is TripleDifferenceResults and back.att == cross_fit.att + + +# --------------------------------------------------------------------------- +# (6) Report surface +# --------------------------------------------------------------------------- + + +class TestReportSurface: + def test_target_parameter_headline_is_att_in_both_designs(self, stag_fit, cross_fit): + from diff_diff._reporting_helpers import describe_target_parameter + + tp_s = describe_target_parameter(stag_fit) + tp_c = describe_target_parameter(cross_fit) + assert tp_s["aggregation"] == "staggered_ddd" and tp_s["headline_attribute"] == "att" + assert tp_c["aggregation"] == "ddd" and tp_c["headline_attribute"] == "att" + + def test_diagnostic_report_staggered_derives_pt_post_fit(self, stag_fit): + from diff_diff.diagnostic_report import DIAGNOSTIC_REPORT_SCHEMA_VERSION, DiagnosticReport + + assert DIAGNOSTIC_REPORT_SCHEMA_VERSION == "3.0" + dr = DiagnosticReport(stag_fit).run_all() + assert "parallel_trends" in dr.applicable_checks + reason = dr.skipped_checks.get("parallel_trends", "") + assert "Re-fit" not in reason and "aggregate='event_study'" not in reason + d = dr.to_dict() + assert d["design"] == "staggered" and d["estimator"] == "TripleDifferenceResults" + assert d["headline_metric"]["name"] == "att" + assert d["target_parameter"]["headline_attribute"] == "att" + + def test_diagnostic_report_2x2x2_has_no_pt_row(self, cross_fit): + from diff_diff.diagnostic_report import DiagnosticReport + + dr = DiagnosticReport(cross_fit).run_all() + assert "parallel_trends" not in dr.applicable_checks + assert "parallel_trends" not in dr.skipped_checks + assert dr.to_dict()["design"] == "2x2x2" + + def test_diagnostic_report_design_is_none_elsewhere(self): + from diff_diff import CallawaySantAnna + from diff_diff.diagnostic_report import DiagnosticReport + from diff_diff.prep_dgp import generate_staggered_data + + cs = CallawaySantAnna().fit( + generate_staggered_data(n_units=60, n_periods=5, cohort_periods=[3], seed=1), + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) + assert DiagnosticReport(cs).run_all().to_dict()["design"] is None + + @pytest.mark.parametrize("control_group", ["not_yet_treated", "never_treated"]) + def test_business_report_staggered(self, stag, control_group): + from diff_diff.business_report import BUSINESS_REPORT_SCHEMA_VERSION, BusinessReport + + assert BUSINESS_REPORT_SCHEMA_VERSION == "4.0" + res = _fit_stag(stag, ctor=dict(control_group=control_group)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + d = BusinessReport(res, auto_diagnostics=False).to_dict() + assert d["estimator"]["class_name"] == "TripleDifferenceResults" + assert d["estimator"]["design"] == "staggered" + assert d["sample"]["n_never_enabled"] > 0 and d["sample"]["n_control"] is None + assert d["target_parameter"]["headline_attribute"] == "att" + assert d["assumption"]["parallel_trends_variant"] == "triple_difference_cancellation" + + def test_business_report_2x2x2(self, cross_fit): + from diff_diff.business_report import BusinessReport + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + d = BusinessReport(cross_fit, auto_diagnostics=False).to_dict() + assert d["estimator"]["design"] == "2x2x2" + assert d["assumption"]["parallel_trends_variant"] == "triple_difference_cancellation" + + +# --------------------------------------------------------------------------- +# (7) Practitioner routing +# --------------------------------------------------------------------------- + + +class TestPractitioner: + def test_staggered_fit_gets_the_post_fit_steps(self, stag_fit, cross_fit): + from diff_diff.practitioner import practitioner_next_steps + + staggered = practitioner_next_steps(stag_fit, verbose=False) + code = "\n".join(step["code"] for step in staggered["next_steps"]) + assert "results.aggregate('event_study')" in code + assert "weights='event_time'" in code + assert "aggregate=" not in code # no fit-time keyword is taught anywhere + two_by_two = practitioner_next_steps(cross_fit, verbose=False) + labels = [step["label"] for step in two_by_two["next_steps"]] + assert "Assess DDD identifying assumption" in labels + assert "aggregate('event_study')" not in "\n".join( + s["code"] for s in two_by_two["next_steps"] + ) From 085f3b9c401b0508c4a6699cc5d8ab7613243a17 Mon Sep 17 00:00:00 2001 From: igerber Date: Tue, 27 Oct 2026 12:08:53 -0400 Subject: [PATCH 2/2] fix(diagnostic_report): keep fractional event-time horizons in the PT and heterogeneity collectors CI review on PR #878 (P0): the DR adapter keyed the derived aggregate('event_study') surface through int(k) and the pre-period / heterogeneity collectors compared int(k) against the anticipation-aware boundary. On a half-step calendar (e = t - g in -1.5, -1.0, -0.5, ...) -1.5 merged into -1 and -0.5 into the treatment horizon 0, so pre-periods silently left the PT family (a staggered DDD fit on a halved calendar reported "no pre-periods"; a CS fit on the same calendar kept one of its three). Pre-existing for every post-fit event-study producer; this PR's staggered DDD route is the newest adopter. _event_time_value / _event_time_key replace the four int() sites: integer-valued horizons keep Python int keys (the ImputationDiD/TwoStageDiD raw dtype), fractional horizons stay float, pre/post comparisons use the numeric value, and the labels stay hash-equal to the surface's vcov_index so the joint Wald still aligns. Tests: adapter unit pins (horizons [-1.5, -1.0, -0.5] with p-values [1e-20, 0.92, 1e-30] all survive, covariance index alignment, raw-field route, helper edge cases), the heterogeneity collectors' numeric split, and a staggered DDD half-step-calendar fit whose DiagnosticReport PT runs the covariance-backed joint Wald over every negative horizon. Also the review's P2: balance_e=3 oracles (cohort 4 unobserved at e=3 -> dropped, e=-2 gone) for the analytical event study / Eq. 4.14 overall and the bootstrap replay (per-horizon se, cband, percentile overall), captured on the unmodified tree. REPORTING.md derived-route Note and the changelog fragment record the fix. --- ...261027-v4-pr6-ddd-container-unification.md | 4 + diff_diff/diagnostic_report.py | 58 ++++++++-- docs/methodology/REPORTING.md | 9 +- tests/test_diagnostic_report.py | 97 ++++++++++++++++ tests/test_v4_unify_ddd.py | 107 ++++++++++++++++++ 5 files changed, 263 insertions(+), 12 deletions(-) diff --git a/changelog.d/20261027-v4-pr6-ddd-container-unification.md b/changelog.d/20261027-v4-pr6-ddd-container-unification.md index 0509506c7..721546e99 100644 --- a/changelog.d/20261027-v4-pr6-ddd-container-unification.md +++ b/changelog.d/20261027-v4-pr6-ddd-container-unification.md @@ -3,6 +3,10 @@ - **4.0 cut, DDD container unification (`M-014`, `M-140`, `M-141`).** `TripleDifference.fit()` returns ONE `TripleDifferenceResults` for both designs: `results.design` is `"2x2x2"` or `"staggered"`, the canonical `att` / `se` / `t_stat` / `p_value` / `conf_int` are native in both, the 2x2x2 positional layout is unchanged, and the staggered structure (`group_time_effects`, `groups`, `time_periods`, unit counts, `control_group`, `base_period`, `anticipation`, `bootstrap_results`, ...) is appended with defaults (`group_time_effects` is `{}` in the 2x2x2 design). The 3.x `StaggeredTripleDiffResults` class, its module, its `overall_*` fields and `to_dict()` keys, and its fit-time `overall_att_es` / `overall_se_es` / `overall_t_stat_es` / `overall_p_value_es` / `overall_conf_int_es` fields are REMOVED (3.x staggered pickles cannot load; 3.x 2x2x2 pickles load unchanged). The fit-time `aggregate=` / `balance_e=` keywords of `TripleDifference.fit()` are REMOVED (`TypeError`): aggregation is post-fit - `results.aggregate('event_study')` (`EventStudyResults`, `balance_e=`), `results.aggregate('group')` (`AggregationResult`) and `results.aggregate('simple')` (a one-row relay of the stored headline, both designs); bootstrapped fits REPLAY the fit-time multiplier bootstrap so sup-t bands and percentile inference match a 3.x fit-time aggregation; the 2x2x2 design fails the staggered levels closed. The paper's Eq. 4.14 overall is `results.aggregate('simple', weights='event_time')` (accepts `balance_e=`; both 3.x warnings preserved). `summary()` no longer prints the event-study / cohort / Eq. 4.14 blocks; `to_dataframe('event_study' | 'group')` raise with the post-fit pointer; `to_dataframe(level=None)` defaults to `'group_time'` in the staggered design and `'overall'` in 2x2x2 (whose one-row frame gains `design`, `pscore_trim`, `alpha`, `df_inference`). New field `df_inference` records the 2x2x2 engine's t df. `plot_group_effects` and its siblings raise `ValueError` on a 2x2x2 fit (no cells). - **Report schemas.** For staggered DDD fits `DiagnosticReport.to_dict()['estimator']` and `BusinessReport.to_dict()['estimator']['class_name']` read `TripleDifferenceResults`, and `target_parameter.headline_attribute` reads `"att"` (the last `"overall_att"`). Both schemas gain an additive key - DR a top-level `design`, BR `estimator.design` - `"2x2x2"` / `"staggered"` for `TripleDifferenceResults`, `None` otherwise. No version bump: `DIAGNOSTIC_REPORT_SCHEMA_VERSION` 3.0 and `BUSINESS_REPORT_SCHEMA_VERSION` 4.0 had not shipped (folded into the PR 5b bump). `power.py`'s staggered-boundary guard no longer keys on `aggregate` / `balance_e` (an `estimator_kwargs` carrying them surfaces as `fit()`'s `TypeError`). +### Fixed + +- **`DiagnosticReport` kept every event-study horizon on fractional calendars.** The parallel-trends and heterogeneity collectors keyed the derived `aggregate('event_study')` surface (and compared raw `event_study_effects` keys) through `int(k)`, so on a half-step calendar (`e = t - g` in `-1.5, -1.0, -0.5, ...`) `-1.5` merged into `-1` and `-0.5` into the treatment horizon `0`: pre-periods silently left the PT family and a violated horizon could read as a clean verdict. Integer-valued horizons keep their `int` keys; fractional horizons stay `float`, compare at their real value against the anticipation-aware boundary, and align with the covariance index for the joint Wald. Affected every post-fit event-study producer routed through the report (CallawaySantAnna included), not only the staggered DDD route this release enables. + ### Internal - Ledger rows `M-014`, `M-140`, `M-141` flip to `removed` with `tests/test_v4_unify_ddd.py` as their `test_ref`; `diff_diff/staggered_triple_diff_results.py` is renamed to `diff_diff/triple_diff_results.py` (hosting the unified container, re-exported from `triple_diff.py`); the staggered engine attaches the CallawaySantAnna aggregation kit (`staggered.py`'s `_build_aggregation_kit` on the eligibility-zeroed precomputed copy, plus the `ddd_df_survey_original` bookkeeping key for the Eq. 4.14 df rule); report consumers dispatch on the design through `_reporting_helpers.results_design` / `is_staggered_ddd` (DR's `_DESIGN_APPLICABILITY` / `_DESIGN_PT_METHOD`); the practitioner handler gains a staggered branch; `docs/api/_autosummary` regenerated; the spec's section 4.2 / section 6 / item-6 SHIPPED notes and the REGISTRY `## TripleDifference` post-fit Note record the mechanics and the `weights='event_time'` decision. diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index 4eb1161f1..80bf591bc 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -44,7 +44,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, FrozenSet, List, Optional, Tuple +from typing import Any, Dict, FrozenSet, List, Optional, Tuple, Union import numpy as np import pandas as pd @@ -2663,7 +2663,7 @@ def _surface_post_effect_scalars(self, surface: Any) -> List[float]: if bool(is_ref): continue try: - rel = int(k) + rel = _event_time_value(k) except (TypeError, ValueError): continue if rel < boundary: @@ -3382,9 +3382,9 @@ def _collect_effect_scalars(self) -> List[float]: items = [] for key, entry in items: try: - rel = int(key) + rel = _event_time_value(key) except (TypeError, ValueError): - # Non-integer keys — unknown shape; skip conservatively + # Non-numeric keys — unknown shape; skip conservatively # rather than mixing into the dispersion summary. continue if rel < post_cutoff: @@ -4049,6 +4049,32 @@ def _pre_post_boundary(results: Any) -> int: return -k +def _event_time_value(k: Any) -> float: + """Numeric value of an event-time label for pre/post comparisons. + + Raises ``TypeError`` / ``ValueError`` for non-numeric or non-finite + labels (callers skip those rows). Never truncates: a fractional + horizon from a half-step calendar compares at its real value. + """ + v = float(k) + if not np.isfinite(v): + raise ValueError(f"non-finite event-time label {k!r}") + return v + + +def _event_time_key(k: Any) -> Union[int, float]: + """Hashable dict key for an event-time label, without truncation. + + Integer-valued labels (``-2``, ``np.int64(-2)``, ``-2.0``) become Python + ``int``; fractional labels (``-0.5``) stay ``float``. Both hash-equal + their ``np.float64`` / ``np.int64`` counterparts, so a key built here + aligns with the surface's ``vcov_index`` labels under plain ``in`` / + dict lookups. + """ + v = _event_time_value(k) + return int(v) if v.is_integer() else v + + def _surface_to_event_study_dict(surface: Any) -> Dict[Any, Dict[str, float]]: """Adapt an ``EventStudyResults`` container to the legacy ``event_study_effects`` dict shape consumed by this module's readers. @@ -4070,10 +4096,15 @@ def _surface_to_event_study_dict(surface: Any) -> Dict[Any, Dict[str, float]]: PT to "inconclusive". NaN se/p on surviving rows pass through so the undefined-inference guards behave identically to the raw route. - Keys are Python ``int`` (matching the ImputationDiD/TwoStageDiD raw - key dtype exactly; the CS derived route orders numerically where its - raw ``np.int64`` keys str-sort — presentational only, the two routes - never coexist). + Keys go through ``_event_time_key``: integer-valued horizons become + Python ``int`` (matching the ImputationDiD/TwoStageDiD raw key dtype + exactly; the CS derived route orders numerically where its raw + ``np.int64`` keys str-sort — presentational only, the two routes never + coexist) and fractional horizons stay ``float``. A half-step calendar + (``e = t - g`` in ``-1.5, -1.0, -0.5, ...``) therefore keeps every + horizon distinct: the former ``int(k)`` truncation merged ``-1.5`` into + ``-1`` and ``-0.5`` into ``0``, silently dropping pre-periods from the + PT family (CI review on PR #878). """ if getattr(surface, "time_scale", "relative") != "relative": raise ValueError( @@ -4094,7 +4125,7 @@ def _surface_to_event_study_dict(surface: Any) -> Dict[Any, Dict[str, float]]: continue if np.isfinite(n) and float(n) == 0.0: continue - out[int(k)] = { + out[_event_time_key(k)] = { "effect": float(att), "se": float(se), "p_value": float(p), @@ -4181,9 +4212,14 @@ def _collect_pre_period_coefs( es = surface_dict es = es or {} for k, entry in es.items(): - # Pre-period relative-time keys are negative (convention: e=-1, -2, ...). + # Pre-period relative-time keys are negative (convention: e=-1, + # -2, ...). Compare the NUMERIC value - never ``int(k)``, which + # truncates a fractional horizon toward zero (``-0.5`` -> ``0``) + # and reclassifies a pre-period as the treatment horizon. The + # key itself is kept verbatim as the row label so it stays + # hash-equal to the covariance index. try: - rel = int(k) + rel = _event_time_value(k) except (TypeError, ValueError): continue if rel >= pre_cutoff: diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index 19e1b0f08..7331c95f3 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -278,7 +278,14 @@ a library setting. the key, mirroring the `df_denom` additive-key convention), and any warnings the kit recompute re-emits are captured and re-published on the consuming section's `warnings` list (record-and-republish; never - swallowed). This + swallowed). Event-time labels are never truncated: integer-valued + horizons key the adapted surface as `int`, fractional horizons (a + half-step calendar's `e = t - g` in `-1.5, -1.0, -0.5, ...`) stay + `float`, and the pre/post split compares the numeric value against + the anticipation-aware boundary - so every pre-period reaches the PT + family and stays hash-aligned with the covariance index (an earlier + `int(k)` conversion merged `-0.5` into the treatment horizon and + silently dropped it; CI review on PR #878). This mirrors the guidance in `practitioner._parallel_trends_step(staggered=True)`. - **Note:** Survey-design threading for fit-faithful Bacon replay. diff --git a/tests/test_diagnostic_report.py b/tests/test_diagnostic_report.py index aaec63b22..805f48a2c 100644 --- a/tests/test_diagnostic_report.py +++ b/tests/test_diagnostic_report.py @@ -3723,6 +3723,103 @@ class _Shell: assert [k for (k, _, _, _) in pre_coefs] == [-3] assert n_dropped == 0 + def test_adapter_preserves_fractional_horizons(self): + # CI review on PR #878 (P0): ``int(k)`` keys merged a half-step + # calendar's ``-1.5`` into ``-1`` and ``-0.5`` into ``0``. With pre + # p-values ``[1e-20, 0.92, 1e-30]`` only the middle row survived and + # the PT verdict read ``p=0.92`` / no violation. Every horizon must + # survive, labelled at its real value, hash-aligned with the + # covariance index, and the integer-valued keys stay ``int``. + from diff_diff.diagnostic_report import ( + _collect_pre_period_coefs, + _event_time_key, + _event_time_value, + _surface_to_event_study_dict, + ) + from diff_diff.results_base import EventStudyResults + + horizons = np.array([-1.5, -1.0, -0.5, 0.0, 0.5]) + surface = EventStudyResults( + event_time=horizons, + att=np.array([0.9, 0.01, 1.1, 0.5, 0.6]), + se=np.array([0.1, 0.1, 0.1, 0.1, 0.1]), + t_stat=np.array([9.0, 0.1, 11.0, 5.0, 6.0]), + p_value=np.array([1e-20, 0.92, 1e-30, 0.001, 0.001]), + conf_int_lower=np.array([0.7, -0.19, 0.9, 0.3, 0.4]), + conf_int_upper=np.array([1.1, 0.21, 1.3, 0.7, 0.8]), + is_reference=np.array([False] * 5), + n=np.array([40.0] * 5), + vcov=np.eye(5) * 0.01, + vcov_index=list(horizons), + ) + adapted = _surface_to_event_study_dict(surface) + assert list(adapted) == [-1.5, -1, -0.5, 0, 0.5] + key_types = {k: type(k) for k in adapted} + assert key_types[-1] is int and key_types[0] is int + assert key_types[-1.5] is float and key_types[-0.5] is float + assert adapted[-0.5]["p_value"] == 1e-30 and adapted[-1.5]["p_value"] == 1e-20 + + class _Shell: + event_study_effects = None + anticipation = 0 + + pre_coefs, n_dropped = _collect_pre_period_coefs(_Shell(), surface_dict=adapted) + assert n_dropped == 0 + assert [k for (k, _, _, _) in pre_coefs] == [-1.5, -1, -0.5] + # the family's smallest p-value is the one the truncation used to drop + assert min(p for (_, _, _, p) in pre_coefs) == 1e-30 + # covariance alignment: every retained key resolves in the vcov index + idx_map = {k: i for i, k in enumerate(surface.vcov_index)} + assert [idx_map[k] for (k, _, _, _) in pre_coefs] == [0, 1, 2] + # the raw-field route (dict keyed by the surface's own float labels) + # compares numerically too: -0.5 is a pre-period, 0.5 is not + shell = _Shell() + shell.event_study_effects = { + -0.5: {"effect": 1.1, "se": 0.1, "p_value": 1e-30}, + 0.5: {"effect": 0.6, "se": 0.1, "p_value": 0.001}, + } + raw_pre, _ = _collect_pre_period_coefs(shell) + assert [k for (k, _, _, _) in raw_pre] == [-0.5] + # the helpers themselves + assert _event_time_key(np.int64(-2)) == -2 and isinstance(_event_time_key(-2.0), int) + assert _event_time_key(np.float64(-0.5)) == -0.5 + assert _event_time_value("-1.5") == -1.5 + with pytest.raises(ValueError): + _event_time_value(float("nan")) + with pytest.raises((TypeError, ValueError)): + _event_time_key("2001Q1") + + def test_effect_collectors_compare_fractional_horizons_numerically(self): + # The heterogeneity collectors share the pre/post boundary: ``-0.5`` + # is NOT a post-treatment horizon, ``0.5`` is. + from diff_diff.diagnostic_report import DiagnosticReport + from diff_diff.results_base import EventStudyResults + + class _Shell: + group_effects = None + anticipation = 0 + event_study_effects = { + -0.5: {"effect": -7.0, "se": 0.1, "p_value": 0.5}, + 0.5: {"effect": 2.0, "se": 0.1, "p_value": 0.01}, + 1.0: {"effect": 3.0, "se": 0.1, "p_value": 0.01}, + } + + dr = object.__new__(DiagnosticReport) + dr._results = _Shell() + assert dr._collect_effect_scalars() == [2.0, 3.0] + surface = EventStudyResults( + event_time=np.array([-0.5, 0.5, 1.0]), + att=np.array([-7.0, 2.0, 3.0]), + se=np.array([0.1, 0.1, 0.1]), + t_stat=np.array([-70.0, 20.0, 30.0]), + p_value=np.array([0.5, 0.01, 0.01]), + conf_int_lower=np.array([-7.2, 1.8, 2.8]), + conf_int_upper=np.array([-6.8, 2.2, 3.2]), + is_reference=np.array([False, False, False]), + n=np.array([40.0, 40.0, 40.0]), + ) + assert dr._surface_post_effect_scalars(surface) == [2.0, 3.0] + def test_adapter_rejects_calendar_scale(self): from diff_diff.diagnostic_report import _surface_to_event_study_dict from diff_diff.results_base import EventStudyResults diff --git a/tests/test_v4_unify_ddd.py b/tests/test_v4_unify_ddd.py index 9d058de06..755a270f6 100644 --- a/tests/test_v4_unify_ddd.py +++ b/tests/test_v4_unify_ddd.py @@ -88,6 +88,39 @@ # so the balanced surface coincides with the unbalanced one on this panel - the # pin is that balance_e threads through without disturbing anything) ES_OVERALL_BALANCE1_ORACLE = ES_OVERALL_ORACLE +# aggregate="event_study", balance_e=3: cohort 3 is observed through e=3 (t=6) +# and cohort 4 only through e=2, so balancing on e=3 DROPS cohort 4 (and with +# it the e=-2 horizon) - a genuinely different surface from the unbalanced one +# (CI review on PR #878 asked for a partial-cohort-removal oracle). (effect, se) +BALANCE3_ES_ORACLE = { + -1.0: (-0.027719840746928036, 0.27963313073357926), + 0.0: (2.762999666832223, 0.28233821941873094), + 1.0: (2.946059062614397, 0.39516152783198905), + 2.0: (3.0227256449202673, 0.3155992489776067), + 3.0: (3.426518594109598, 0.3635995672681256), +} +BALANCE3_ES_OVERALL_ORACLE = dict( + att=3.0395757421191214, + se=0.26041596101583653, + t_stat=11.672002477353058, + p_value=1.7720240558757052e-31, + ci=(2.529169837528695, 3.549981646709548), +) +# n_bootstrap=49, seed=7, cband=True, aggregate="event_study", balance_e=3 +BOOT_BALANCE3_CBAND_ORACLE = 2.49333299518289 +BOOT_BALANCE3_ES_SE_ORACLE = { + -1.0: 0.2722181669173392, + 0.0: 0.28424292019694336, + 1.0: 0.3731732550535591, + 2.0: 0.31109084929419584, + 3.0: 0.3383619126874234, +} +BOOT_BALANCE3_ES_OVERALL_ORACLE = dict( + att=3.0395757421191214, + se=0.25314100819156565, + p_value=0.02, + ci=(2.5589358849283936, 3.5484478904824353), +) # n_bootstrap=49, seed=7, cband=True, aggregate="all" BOOT_CBAND_ORACLE = 2.7121511569356116 BOOT_ES_SE_ORACLE = { @@ -170,6 +203,16 @@ def cross_fit(cross): return TripleDifference().fit(cross, **C) +@pytest.fixture(scope="module") +def half_step_fit(): + # A half-step calendar: periods 0.5..4.0 and cohorts 2.0 / 2.5, so the + # event-study horizons e = t - g are fractional (-1.0, -0.5, 0.0, 0.5, ...). + df = generate_staggered_ddd_data(n_units=96, n_periods=8, cohort_periods=[4, 5], seed=42) + df["period"] = df["period"] / 2.0 + df["first_treat"] = df["first_treat"] / 2.0 + return _fit_stag(df) + + def _row(es, e): idx = [i for i, t in enumerate(es.event_time) if float(t) == e] assert len(idx) == 1, (e, es.event_time) @@ -365,6 +408,50 @@ def test_balance_e_threads_into_the_eq_414_overall(self, stag_fit): assert_allclose(o.att[0], ES_OVERALL_BALANCE1_ORACLE["att"], rtol=ORACLE_RTOL) assert_allclose(o.se[0], ES_OVERALL_BALANCE1_ORACLE["se"], rtol=ORACLE_RTOL) + def test_balance_e_3_drops_cohort_4_analytical(self, stag_fit): + # Partial cohort removal: cohort 4 is unobserved at e=3, so the balanced + # surface loses it (and the e=-2 horizon it alone contributed). + es = stag_fit.aggregate("event_study", balance_e=3) + assert [float(t) for t in es.event_time] == sorted(BALANCE3_ES_ORACLE) + for e, (eff, se) in BALANCE3_ES_ORACLE.items(): + i = _row(es, e) + assert_allclose(es.att[i], eff, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert_allclose(es.se[i], se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + unbalanced = stag_fit.aggregate("event_study") + assert len(unbalanced.event_time) == len(es.event_time) + 1 + assert es.att[_row(es, 0.0)] != unbalanced.att[_row(unbalanced, 0.0)] + o = stag_fit.aggregate("simple", weights="event_time", balance_e=3) + assert_allclose(o.att[0], BALANCE3_ES_OVERALL_ORACLE["att"], rtol=ORACLE_RTOL) + assert_allclose(o.se[0], BALANCE3_ES_OVERALL_ORACLE["se"], rtol=ORACLE_RTOL) + assert_allclose(o.t_stat[0], BALANCE3_ES_OVERALL_ORACLE["t_stat"], rtol=ORACLE_RTOL) + assert_allclose(o.p_value[0], BALANCE3_ES_OVERALL_ORACLE["p_value"], rtol=ORACLE_RTOL) + assert_allclose(o.conf_int_lower[0], BALANCE3_ES_OVERALL_ORACLE["ci"][0], rtol=ORACLE_RTOL) + assert_allclose(o.conf_int_upper[0], BALANCE3_ES_OVERALL_ORACLE["ci"][1], rtol=ORACLE_RTOL) + assert o.att[0] != ES_OVERALL_ORACLE["att"] + + def test_balance_e_3_bootstrap_replay(self, boot_fit): + es = boot_fit.aggregate("event_study", balance_e=3) + assert [float(t) for t in es.event_time] == sorted(BOOT_BALANCE3_ES_SE_ORACLE) + assert_allclose( + es.cband_crit_value, BOOT_BALANCE3_CBAND_ORACLE, rtol=ORACLE_RTOL, atol=ORACLE_ATOL + ) + assert es.cband_crit_value != BOOT_CBAND_ORACLE # the replay re-ran on the balanced set + for e, se in BOOT_BALANCE3_ES_SE_ORACLE.items(): + i = _row(es, e) + assert_allclose(es.se[i], se, rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + assert_allclose(es.att[i], BALANCE3_ES_ORACLE[e][0], rtol=ORACLE_RTOL, atol=ORACLE_ATOL) + o = boot_fit.aggregate("simple", weights="event_time", balance_e=3) + assert_allclose(o.att[0], BOOT_BALANCE3_ES_OVERALL_ORACLE["att"], rtol=ORACLE_RTOL) + assert_allclose(o.se[0], BOOT_BALANCE3_ES_OVERALL_ORACLE["se"], rtol=ORACLE_RTOL) + assert_allclose(o.p_value[0], BOOT_BALANCE3_ES_OVERALL_ORACLE["p_value"], rtol=ORACLE_RTOL) + assert_allclose( + o.conf_int_lower[0], BOOT_BALANCE3_ES_OVERALL_ORACLE["ci"][0], rtol=ORACLE_RTOL + ) + assert_allclose( + o.conf_int_upper[0], BOOT_BALANCE3_ES_OVERALL_ORACLE["ci"][1], rtol=ORACLE_RTOL + ) + assert np.isnan(o.df[0]) + def test_bootstrap_replay(self, boot_fit): assert boot_fit.inference_method == "bootstrap" and boot_fit.n_bootstrap == 49 es = boot_fit.aggregate("event_study") @@ -584,6 +671,26 @@ def test_diagnostic_report_staggered_derives_pt_post_fit(self, stag_fit): assert d["headline_metric"]["name"] == "att" assert d["target_parameter"]["headline_attribute"] == "att" + def test_diagnostic_report_keeps_fractional_pre_horizons(self, half_step_fit): + # CI review on PR #878 (P0): the DR adapter used to key the derived + # surface by ``int(k)``, so on a half-step calendar ``-0.5`` collapsed + # into the treatment horizon ``0`` and the pre-period vanished from + # the PT family without a warning. Every negative horizon on the + # surface must reach the PT test, labelled at its real value and + # aligned with the covariance index (joint Wald, not Bonferroni). + from diff_diff.diagnostic_report import DiagnosticReport + + es = half_step_fit.aggregate("event_study") + horizons = [float(t) for t in es.event_time] + assert any(not float(t).is_integer() for t in horizons) + pre = [t for t, ref in zip(horizons, es.is_reference) if t < 0 and not ref] + assert -0.5 in pre and len(pre) >= 2 + pt = DiagnosticReport(half_step_fit).to_dict()["parallel_trends"] + assert pt["status"] == "ran" and pt["method"] == "joint_wald_event_study" + assert pt["n_pre_periods"] == len(pre) + assert [row["period"] for row in pt["per_period"]] == sorted(pre) + assert np.isfinite(pt["joint_p_value"]) + def test_diagnostic_report_2x2x2_has_no_pt_row(self, cross_fit): from diff_diff.diagnostic_report import DiagnosticReport