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..721546e99
--- /dev/null
+++ b/changelog.d/20261027-v4-pr6-ddd-container-unification.md
@@ -0,0 +1,12 @@
+### 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`).
+
+### 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/__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..80bf591bc 100644
--- a/diff_diff/diagnostic_report.py
+++ b/diff_diff/diagnostic_report.py
@@ -44,12 +44,16 @@
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
-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
@@ -2629,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:
@@ -3348,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:
@@ -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
@@ -4013,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.
@@ -4034,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(
@@ -4058,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),
@@ -4145,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/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..7331c95f3 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
@@ -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.
@@ -402,7 +409,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 +601,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..805f48a2c 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
@@ -3715,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_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..755a270f6
--- /dev/null
+++ b/tests/test_v4_unify_ddd.py
@@ -0,0 +1,760 @@
+"""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
+# 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 = {
+ -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)
+
+
+@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)
+ 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_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")
+ 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_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
+
+ 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"]
+ )