From e880325734017a230ab29b45cb53a5bd07168bda Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 26 Oct 2026 20:12:02 -0400 Subject: [PATCH] feat(v4): PR 5b of the 4.0 cut - flip the headline-quintet storage on the nine M-050..M-058 containers The canonical att / se / t_stat / p_value / conf_int become the native dataclass fields of CallawaySantAnnaResults (DMLDiDResults inherits), SunAbrahamResults, ImputationDiDResults, TwoStageDiDResults, StackedDiDResults, EfficientDiDResults, WooldridgeDiDResults, ChaisemartinDHaultfoeuilleResults and ContinuousDiDResults - renamed IN SLOT so the generated __init__ positional layout is unchanged - and the producers construct with the canonical keywords. The 3.x overall_* names (ContinuousDiD: all nine spellings, the overall_att_* fields and the former silent overall_se-style aliases) are read-only FutureWarning deprecated_field_property shims removed at 5.0; DMLDiDResults re-declares the five so the warning names the subclass. Pickle migration: diff_diff/_deprecation.py gains LEGACY_OVERALL_QUINTET and migrate_renamed_state_keys (an existing canonical key wins; the legacy key never lingers in __dict__); new __setstate__ on CS/SA/Imputation/ TwoStage/Efficient, the Stacked / dCDH / Wooldridge bodies extended (key renames before the default backfills). The two Wooldridge self-write sites (aggregate("simple"), the bootstrap override) assign the canonical fields. Report surface: the twelve non-SDDD headline_attribute values in _reporting_helpers read "att", diagnostic_report._extract_scalar_headline drops the legacy overall_*/overall_att_* alias row (headline_metric.name is "att"), DIAGNOSTIC_REPORT_SCHEMA_VERSION 2.0 -> 3.0 and BUSINESS_REPORT_SCHEMA_VERSION 3.0 -> 4.0 (REPORTING.md Note; the autonomous guide's schema headings follow). Ledger M-050..M-058 -> shimmed with tests/test_v4_storage_flip.py as test_ref (field shape, warning text + relay + read-only, legacy-state load, 4.0 pickle round-trip, canonical-only constructor keywords, every read surface under FutureWarning=error, the Wooldridge write paths, the helper contract). tests/test_result_aliases.py pins the post-flip direction and keeps StaggeredTripleDiffResults - unflipped until item 6 - in the 3.x direction. Test mocks, stubs and constructor kwargs follow; the naming-guard DMLDiDResults.overall_att allowlist reason is reworded. Docs: REGISTRY "Result-class field naming" rewritten for the 4.0 state plus a Note recording that the bootstrap draw containers keep their payload names (user decision 2026-10-26); container docstrings, the llms-full field tables, migration-4.0.md (constructor keywords, pickles, read-only names, report schemas), the spec's item-5 SHIPPED note and pickle-migration sentence, and the regenerated autosummary stubs for the ten classes. --- changelog.d/20261026-v4-pr5b-storage-flip.md | 8 + diff_diff/_deprecation.py | 38 +- diff_diff/_reporting_helpers.py | 66 +- diff_diff/business_report.py | 9 +- diff_diff/chaisemartin_dhaultfoeuille.py | 10 +- .../chaisemartin_dhaultfoeuille_results.py | 88 +-- diff_diff/continuous_did.py | 10 +- diff_diff/continuous_did_results.py | 109 ++-- diff_diff/diagnostic_report.py | 27 +- diff_diff/dml_did.py | 10 +- diff_diff/dml_did_results.py | 17 + diff_diff/efficient_did.py | 10 +- diff_diff/efficient_did_results.py | 72 ++- diff_diff/guides/llms-autonomous.txt | 8 +- diff_diff/guides/llms-full.txt | 68 +- diff_diff/imputation.py | 10 +- diff_diff/imputation_results.py | 76 ++- diff_diff/results_base.py | 12 +- diff_diff/stacked_did.py | 10 +- diff_diff/stacked_did_results.py | 76 ++- diff_diff/staggered.py | 10 +- diff_diff/staggered_results.py | 72 ++- diff_diff/sun_abraham.py | 82 ++- diff_diff/two_stage.py | 10 +- diff_diff/two_stage_results.py | 76 ++- diff_diff/wooldridge.py | 38 +- diff_diff/wooldridge_results.py | 74 ++- .../diff_diff.CallawaySantAnnaResults.rst | 20 +- ...diff.ChaisemartinDHaultfoeuilleResults.rst | 20 +- .../diff_diff.ContinuousDiDResults.rst | 20 +- .../diff_diff.EfficientDiDResults.rst | 20 +- .../diff_diff.ImputationDiDResults.rst | 20 +- .../diff_diff.StackedDiDResults.rst | 20 +- .../diff_diff.SunAbrahamResults.rst | 20 +- .../diff_diff.TwoStageDiDResults.rst | 20 +- ...iff_diff.dml_did_results.DMLDiDResults.rst | 20 +- ...ooldridge_results.WooldridgeDiDResults.rst | 20 +- docs/api/continuous_did.rst | 2 +- docs/methodology/REGISTRY.md | 48 +- docs/methodology/REPORTING.md | 37 +- docs/methodology/continuous-did.md | 2 +- docs/migration-4.0.md | 58 +- docs/performance-scenarios.md | 2 +- docs/v4-deprecations.yaml | 50 +- docs/v4-design.md | 22 +- tests/test_aggregate_contract.py | 8 +- tests/test_balance_interop_contract.py | 7 +- tests/test_business_report.py | 130 +--- tests/test_chaisemartin_dhaultfoeuille.py | 28 +- tests/test_diagnostic_report.py | 98 +-- tests/test_honest_did.py | 10 +- tests/test_naming_guard.py | 9 +- tests/test_practitioner.py | 39 +- tests/test_result_aliases.py | 399 +++++++----- tests/test_target_parameter.py | 86 ++- tests/test_v4_storage_flip.py | 599 ++++++++++++++++++ tests/test_wooldridge.py | 10 +- 57 files changed, 1848 insertions(+), 1092 deletions(-) create mode 100644 changelog.d/20261026-v4-pr5b-storage-flip.md create mode 100644 tests/test_v4_storage_flip.py diff --git a/changelog.d/20261026-v4-pr5b-storage-flip.md b/changelog.d/20261026-v4-pr5b-storage-flip.md new file mode 100644 index 000000000..eafbaa0b2 --- /dev/null +++ b/changelog.d/20261026-v4-pr5b-storage-flip.md @@ -0,0 +1,8 @@ +### Breaking Changes + +- **4.0 cut, storage flip of the headline quintet (`M-050`..`M-058`).** `CallawaySantAnnaResults` (and `DMLDiDResults`), `SunAbrahamResults`, `ImputationDiDResults`, `TwoStageDiDResults`, `StackedDiDResults`, `EfficientDiDResults`, `WooldridgeDiDResults`, `ChaisemartinDHaultfoeuilleResults` and `ContinuousDiDResults` now store the canonical `att` / `se` / `t_stat` / `p_value` / `conf_int` as their native dataclass fields (renamed in slot, so the positional constructor layout is unchanged) and take those names as constructor keywords (`overall_att=` raises `TypeError`). The 3.x `overall_att` / `overall_se` / `overall_t_stat` / `overall_p_value` / `overall_conf_int` names - and on `ContinuousDiDResults` all nine legacy spellings, the `overall_att_*` fields and the former silent `overall_se`-style aliases - are read-only properties that emit a `FutureWarning` and are removed at 5.0; they are absent from `dataclasses.fields()`, `asdict()` and `to_dict()`. 3.x pickles load unchanged (each container's `__setstate__` rewrites the legacy keys on load). The bootstrap draw containers (`bootstrap_results.overall_att_se` and siblings) are unchanged, and `StaggeredTripleDiffResults` keeps its `overall_*` storage until the DDD container unification. +- **Report schemas.** `DiagnosticReport.to_dict()` reports `headline_metric.name` and `target_parameter.headline_attribute` as `"att"` for the nine containers above (`"overall_att"` under 3.x); `DIAGNOSTIC_REPORT_SCHEMA_VERSION` 2.0 -> 3.0. `BusinessReport.to_dict()` reports `target_parameter.headline_attribute` likewise; `BUSINESS_REPORT_SCHEMA_VERSION` 3.0 -> 4.0 (its `headline` block carries no name key). `StaggeredTripleDiffResults` keeps `headline_attribute: "overall_att"` until item 6. + +### Internal + +- Ledger rows `M-050`..`M-058` flip to `shimmed` with `tests/test_v4_storage_flip.py` as their `test_ref`; `diff_diff._deprecation` gains `LEGACY_OVERALL_QUINTET` and `migrate_renamed_state_keys` (5.0-window infrastructure); `DMLDiDResults` re-declares the five shims so the warning names the subclass (naming-guard allowlist reason reworded); `_extract_scalar_headline`'s legacy alias row is deleted; `tests/test_result_aliases.py` pins the post-flip direction (SDDD kept in the 3.x direction); the API autosummary stubs are regenerated; the bootstrap draw containers' payload names are recorded as a REGISTRY `Note` (user decision 2026-10-26); spec item 5 carries its SHIPPED note. diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 8486daca0..16fcfc429 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -43,7 +43,7 @@ """ import warnings -from typing import Any +from typing import Any, Dict, Mapping __all__: "list[str]" = [] @@ -186,3 +186,39 @@ def _get(self: Any) -> Any: _get.__name__ = old _get.__doc__ = f"Deprecated alias for :attr:`{new}` (removed in {removed_in})." return property(_get) + + +#: The 3.x ``overall_*`` storage names of the nine results containers flipped +#: at the 4.0 cut (ledger rows M-050..M-058), keyed to the canonical quintet +#: field that now stores each value. Shared by the containers' ``__setstate__`` +#: pickle migration (spec section 5); ``ContinuousDiDResults`` keeps a +#: module-local map for its deviant ``overall_att_*`` spellings. +LEGACY_OVERALL_QUINTET: Dict[str, str] = { + "overall_att": "att", + "overall_se": "se", + "overall_t_stat": "t_stat", + "overall_p_value": "p_value", + "overall_conf_int": "conf_int", +} + + +def migrate_renamed_state_keys( + state: Mapping[str, Any], renames: Mapping[str, str] +) -> Dict[str, Any]: + """Rewrite renamed storage keys of a pickled ``__dict__`` on load. + + Returns a NEW dict (the input is never mutated). For each ``old -> new`` + pair the legacy key is popped when present and its value seeds the + canonical key only when that key is absent - an existing canonical + value wins, and the legacy key never lingers in ``__dict__`` (a + ``deprecated_field_property`` data descriptor would shadow it on read, + but it would survive a re-pickle). Used by the ``__setstate__`` of the + M-050..M-058 containers; the ``SyntheticDiDResults.__setstate__`` + precedent (``results.py``) is the hand-written form of the same rule. + """ + out = dict(state) + for old, new in renames.items(): + if old in out: + value = out.pop(old) + out.setdefault(new, value) + return out diff --git a/diff_diff/_reporting_helpers.py b/diff_diff/_reporting_helpers.py index c660252ca..de493df4e 100644 --- a/diff_diff/_reporting_helpers.py +++ b/diff_diff/_reporting_helpers.py @@ -51,11 +51,11 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: - ``ImputationDiD`` / ``TwoStageDiD``: horizon / group tables come from post-fit ``results.aggregate('event_study'/'group')`` since 3.9 (the fit-time ``aggregate`` kwarg was removed at 4.0) and never - change ``overall_att``. The headline is always the sample-mean + change ``att``. The headline is always the sample-mean overall ATT (per BJS 2024 Step 3 with ``w_it = 1/N_1``); disambiguate via the event-study or group aggregate if you need the horizon / group target. - - ``CallawaySantAnna``: ``overall_att`` is cohort-size-weighted + - ``CallawaySantAnna``: ``att`` is cohort-size-weighted across post-treatment ``ATT(g, t)`` cells regardless of any aggregation. The event-study / group tables are produced post-fit via ``results.aggregate('event_study'/'group')``; the legacy @@ -66,13 +66,13 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: both regime readings (``ATT^loc`` under PT, ``ATT^glob`` under SPT) so the user can pick the interpretation that matches their assumption. - - ``WooldridgeDiD``: ``overall_att`` depends on the fit-time + - ``WooldridgeDiD``: ``att`` depends on the fit-time ``method`` — for OLS ETWFE (``method="ols"``) it is the observation-count-weighted average of ``ATT(g, t)`` coefficients from the saturated regression; for nonlinear ETWFE (``method="logit"`` / ``"poisson"``) it is the average-structural-function (ASF) contrast across cohort x - time cells. Both paths preserve ``overall_att`` across + time cells. Both paths preserve ``att`` across ``.aggregate("event_study")`` calls (which only populate additional event-study tables). """ @@ -123,7 +123,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "definition": ( "A cohort-size-weighted average of group-time ATTs " "``ATT(g, t)`` across post-treatment cells (``t >= g``). " - "``overall_att`` is the simple-aggregation headline regardless " + "``att`` is the simple-aggregation headline regardless " "of aggregation choices; event-study and group tables are " "produced post-fit via " "``results.aggregate('event_study'/'group')`` (the legacy " @@ -131,7 +131,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "on every CallawaySantAnna fit since 4.0)." ), "aggregation": "simple", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Callaway & Sant'Anna (2021); REGISTRY.md Sec. CallawaySantAnna", } @@ -203,12 +203,12 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "anticipation=0) — " + weight_clause + " — " "where each cell is a conditional-on-covariates ATT " + score_clause - + ". ``overall_att`` is the simple-aggregation " + + ". ``att`` is the simple-aggregation " "headline; event-study and group tables are produced " "post-fit via ``results.aggregate('event_study'/'group')``." ), "aggregation": "simple", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": reference, } @@ -222,7 +222,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "each event time; the headline is the resulting overall ATT." ), "aggregation": "iw", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Sun & Abraham (2021); REGISTRY.md Sec. SunAbraham", } @@ -237,12 +237,12 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "untreated observations only (BJS 2024 Step 3). Post-fit " "``results.aggregate('event_study'/'group')`` (3.9; the " "fit-time ``aggregate`` kwarg was removed at 4.0) yields the " - "horizon / group tables and does NOT change ``overall_att`` " + "horizon / group tables and does NOT change ``att`` " "— for the horizon or group estimand, consult the " "event-study or group aggregate directly." ), "aggregation": "simple", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Borusyak-Jaravel-Spiess (2024); REGISTRY.md Sec. ImputationDiD", } @@ -258,10 +258,10 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "equivalent to Borusyak-Jaravel-Spiess imputation. As with " "ImputationDiD, post-fit ``results.aggregate(...)`` (3.9) " "yields the additional tables and does NOT change " - "``overall_att``." + "``att``." ), "aggregation": "simple", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Gardner (2022); REGISTRY.md Sec. TwoStageDiD", } @@ -295,19 +295,19 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "set over the event window ``[-kappa_pre, +kappa_post]``; " "each per-horizon ``delta_h`` is the paper's " "``theta_kappa^e`` treated-share-weighted cross-event " - "aggregate. The ``overall_att`` headline is the equally-" + "aggregate. The ``att`` headline is the equally-" "weighted average of these per-horizon coefficients, not a " "separate cross-event weighted aggregate at the ATT level. " + control_clause ), "aggregation": "stacked", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Wing-Freedman-Hollingsworth (2024); REGISTRY.md Sec. StackedDiD", } if name == "WooldridgeDiDResults": # PR #347 R4 P2: Wooldridge ETWFE has two identification paths # (REGISTRY.md splits them at Sec. WooldridgeDiD): the OLS - # path computes ``overall_att`` as an observation-count- + # path computes ``att`` as an observation-count- # weighted aggregation of ``ATT(g, t)`` coefficients from the # saturated regression, while the nonlinear (logit / Poisson) # paths produce an ASF-based ATT from the average-structural- @@ -324,14 +324,14 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "definition": ( "The overall ATT under OLS ETWFE (Wooldridge 2025): the " "saturated regression fits cohort x time ATT(g, t) " - "coefficients, and ``overall_att`` is their " + "coefficients, and ``att`` is their " "observation-count-weighted average across post-" 'treatment cells. Calling ``.aggregate("event_study")`` ' "populates additional event-study tables but does NOT " - "change the ``overall_att`` scalar." + "change the ``att`` scalar." ), "aggregation": "simple", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": ("Wooldridge (2025); REGISTRY.md Sec. WooldridgeDiD (OLS path)"), } return { @@ -348,17 +348,17 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: f"nonlinearity; OLS ETWFE uses the saturated-regression " f'coefficient path instead. Calling ``.aggregate("event_study")`` ' f"populates additional event-study tables but does NOT " - f"change the ``overall_att`` scalar." + f"change the ``att`` scalar." ), "aggregation": "simple", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": ( "Wooldridge (2023, 2025); REGISTRY.md Sec. WooldridgeDiD " "(nonlinear / ASF path)" ), } if name == "EfficientDiDResults": - # PR #347 R7 P1: the BR/DR headline ``overall_att`` is the + # PR #347 R7 P1: the BR/DR headline ``att`` is the # library's cohort-size-weighted average over post-treatment # ``(g, t)`` cells (see ``efficient_did.py`` around line 1274 # and REGISTRY.md Sec. EfficientDiD). This is distinct from @@ -369,7 +369,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: # the aggregation choice for the headline scalar. pt_assumption = getattr(results, "pt_assumption", "all") library_aggregation_note = ( - " The BR/DR headline ``overall_att`` is the library's " + " The BR/DR headline ``att`` is the library's " "cohort-size-weighted average of ATT(g, t) over post-" "treatment cells, NOT the paper's ``ES_avg`` uniform event-" "time average (see REGISTRY.md Sec. EfficientDiD for the " @@ -386,7 +386,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "single-baseline DiD (Corollary 3.2)." + library_aggregation_note ), "aggregation": "pt_post_single_baseline", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Chen-Sant'Anna-Xie (2025) Cor. 3.2; REGISTRY.md Sec. EfficientDiD", } return { @@ -399,7 +399,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "efficiency bound on the no-covariate path." + library_aggregation_note ), "aggregation": "pt_all_combined", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Chen-Sant'Anna-Xie (2025) Lemma 2.1; REGISTRY.md Sec. EfficientDiD", } @@ -418,7 +418,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "dose-specific inference." ), "aggregation": "dose_overall", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Callaway-Goodman-Bacon-Sant'Anna (2024); REGISTRY.md Sec. ContinuousDiD", } @@ -460,7 +460,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: # ``linear_trends_effects is not None``. The estimator can set # ``linear_trends_effects = None`` when the cumulated-horizon # dict is empty (no estimable horizons) while still - # unconditionally NaN-ing ``overall_att`` under + # unconditionally NaN-ing ``att`` under # ``trends_linear=True`` + ``L_max >= 2`` # (``chaisemartin_dhaultfoeuille.py:2828-2834``). The previous # inference missed that edge case; the new explicit flag @@ -476,7 +476,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "de Chaisemartin & D'Haultfoeuille (2020, 2024); " "REGISTRY.md Sec. ChaisemartinDHaultfoeuille" ) - # PR #347 R2 P1 review: mirror the exact ``overall_att`` + # PR #347 R2 P1 review: mirror the exact ``att`` # contract from ``chaisemartin_dhaultfoeuille.py`` lines # 2602-2634 (L_max>=2 overrides with cost-benefit ``delta``, # NaN if non-estimable) and lines 2828-2834 (``trends_linear`` @@ -485,7 +485,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: # ``chaisemartin_dhaultfoeuille_results.py:454-490`` is the # source-of-truth; this branch tracks that logic. - # Trends + L_max>=2: overall_att is NaN. No scalar aggregate; + # Trends + L_max>=2: ``att`` is NaN. No scalar aggregate; # per-horizon effects are on ``linear_trends_effects``. if has_trends and l_max is not None and l_max >= 2: # PR #347 R12 P1: distinguish the populated-surface case @@ -509,7 +509,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: definition_text = ( "Under ``trends_linear=True`` with ``L_max >= 2``, the " "estimator intentionally does NOT produce a scalar " - "aggregate in ``overall_att`` (it is NaN by design, " + "aggregate in ``att`` (it is NaN by design, " "matching R's ``did_multiplegt_dyn`` with " "``trends_lin=TRUE``). On this fit, no cumulated level " f"effects ``{estimand_label}`` survived estimation " @@ -525,7 +525,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: definition_text = ( "Under ``trends_linear=True`` with ``L_max >= 2``, the " "estimator intentionally does NOT produce a scalar " - "aggregate in ``overall_att`` (it is NaN by design, " + "aggregate in ``att`` (it is NaN by design, " "matching R's ``did_multiplegt_dyn`` with " "``trends_lin=TRUE``). Per-horizon cumulated level " "effects ``DID^{fd}_l`` (or ``DID^{X,fd}_l`` when " @@ -586,7 +586,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "``delta = sum_l w_l * DID_l`` (Lemma 4 of the dCDH " "dynamic companion paper), a weighted cross-horizon " "combination where ``w_l`` reflects the cumulative dose at " - "each horizon. ``overall_att`` holds this delta when " + "each horizon. ``att`` holds this delta when " "``L_max >= 2``; if delta is non-estimable (no eligible " "switchers at any horizon) it is NaN with the same " "``overall_se`` / CI surface." @@ -635,7 +635,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "name": f"{name_label} ({base_label})", "definition": definition_core + suffix_clause, "aggregation": agg_base + agg_suffix, - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": reference, } diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index 26682029a..05ccac0ab 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -53,7 +53,7 @@ from diff_diff.diagnostic_report import DiagnosticReport, DiagnosticReportResults from diff_diff.results_base import Diagnostic, _coverage_level, _coverage_pct -BUSINESS_REPORT_SCHEMA_VERSION = "3.0" +BUSINESS_REPORT_SCHEMA_VERSION = "4.0" __all__ = [ "BusinessReport", @@ -633,9 +633,8 @@ def _extract_headline(self, dr_schema: Optional[Dict[str, Any]]) -> Dict[str, An r = self._results # Delegate the attribute-alias lookup to the shared helper in the # diagnostic_report module so BR and DR agree on which fields a - # result class exposes for its headline (including - # ``ContinuousDiDResults`` which uses ``overall_att_se`` / - # ``overall_att_p_value`` / ``overall_att_conf_int``). + # result class exposes for its headline (the canonical quintet on + # every scalar-headline container since the 4.0 storage flip). from diff_diff.diagnostic_report import _extract_scalar_headline extracted = _extract_scalar_headline(r, fallback_alpha=self._context.alpha) @@ -2231,7 +2230,7 @@ def _render_headline_sentence(schema: Dict[str, Any]) -> str: h = schema.get("headline", {}) # PR #347 R4 P1: the dCDH ``trends_linear=True`` + ``L_max>=2`` # configuration does not produce a scalar headline by design — - # ``overall_att`` is intentionally NaN (per + # ``att`` is intentionally NaN (per # ``chaisemartin_dhaultfoeuille.py:2828-2834``). Render explicit # "no scalar headline by design" prose instead of routing through # the non-finite / estimation-failure path. diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py index 1d1f7bace..3436f392e 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille.py +++ b/diff_diff/chaisemartin_dhaultfoeuille.py @@ -4183,11 +4183,11 @@ def fit( _es_df_provenance = float(_final_inf_df) results = ChaisemartinDHaultfoeuilleResults( - overall_att=effective_overall_att, - overall_se=effective_overall_se, - overall_t_stat=effective_overall_t, - overall_p_value=effective_overall_p, - overall_conf_int=effective_overall_ci, + att=effective_overall_att, + se=effective_overall_se, + t_stat=effective_overall_t, + p_value=effective_overall_p, + conf_int=effective_overall_ci, joiners_att=joiners_att if effective_joiners_available else float("nan"), joiners_se=joiners_se if effective_joiners_available else float("nan"), joiners_t_stat=joiners_t if effective_joiners_available else float("nan"), diff --git a/diff_diff/chaisemartin_dhaultfoeuille_results.py b/diff_diff/chaisemartin_dhaultfoeuille_results.py index 2270b2a8e..855673f5d 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille_results.py +++ b/diff_diff/chaisemartin_dhaultfoeuille_results.py @@ -31,6 +31,11 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.aggregation import AggregationMixin, AggregationResult from diff_diff.results import _get_significance_stars from diff_diff.results_base import ( @@ -212,13 +217,13 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): exposed via ``n_groups_dropped_crossers``. **Inference-method switch when bootstrap is enabled.** The - ``overall_p_value`` / ``overall_conf_int`` (and joiners/leavers + ``p_value`` / ``conf_int`` (and joiners/leavers analogues) fields are populated by *normal-theory* inference from the cohort-recentered analytical SE when ``n_bootstrap=0`` (the default). When ``n_bootstrap > 0``, the same fields are populated by *percentile-based bootstrap inference* from the multiplier bootstrap distribution computed by ``_compute_dcdh_bootstrap()``. - The t-stat (``overall_t_stat``, etc.) is computed from the SE in + The t-stat (``t_stat``, etc.) is computed from the SE in both cases, since percentile bootstrap does not define an alternative t-stat semantic. ``event_study_effects[1]``, ``summary()``, ``to_dataframe()``, ``is_significant``, and @@ -233,13 +238,13 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): Attributes ---------- - overall_att : float + att : float ``DID_M = DID_1``: the contemporaneous-switch dCDH point estimate. - overall_se : float + se : float Standard error of ``DID_M``. - overall_t_stat : float - overall_p_value : float - overall_conf_int : tuple of float + t_stat : float + p_value : float + conf_int : tuple of float joiners_att : float ``DID_+``: the joiners-only contribution. ``NaN`` when ``joiners_available`` is False. @@ -304,7 +309,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): ``time``, ``weight``. Computed on the **FULL pre-filter cell sample** passed by the user (the same input the standalone :func:`twowayfeweights` function uses) — NOT the post-filter - estimation sample described by ``overall_att`` and + estimation sample described by ``att`` and ``units``. When ``fit()`` drops groups via the ragged-panel or ``drop_larger_lower`` filters, ``results.twfe_*`` and ``results.att`` describe different samples and a @@ -325,7 +330,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): sample**. twfe_beta_fe : float, optional The plain TWFE coefficient computed on the **FULL pre-filter - cell sample**, for comparison with ``overall_att``. Note that + cell sample**, for comparison with ``att``. Note that the two are computed on different samples when ``fit()`` filters drop groups — see the ``twfe_weights`` docstring above for the sample contract. @@ -526,11 +531,11 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): """ # --- Core: DID_M aggregate --- - overall_att: float - overall_se: float - overall_t_stat: float - overall_p_value: float - overall_conf_int: Tuple[float, float] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] # --- Joiners-only view (DID_+) --- joiners_att: float @@ -576,7 +581,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): # --- Event study (Phase 2: multi-horizon) --- # Populated with {l: {effect, se, t_stat, p_value, conf_int, n_obs}}. - # Phase 1 (L_max=None): single entry {1: {...}} mirroring overall_att. + # Phase 1 (L_max=None): single entry {1: {...}} mirroring ``att``. # Phase 2 (L_max>=2): entries for l = 1, ..., L_max. event_study_effects: Optional[Dict[int, Dict[str, Any]]] = None L_max: Optional[int] = None @@ -603,7 +608,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): # ``linear_trends_effects is not None`` broke on the empty- # horizon case — the estimator sets ``linear_trends_effects=None`` # when the cumulated dict is empty but still unconditionally - # NaN-s ``overall_att`` for ``trends_linear=True`` with + # NaN-s ``att`` for ``trends_linear=True`` with # ``L_max >= 2``. BR/DR dispatch on this flag (via # ``describe_target_parameter``) to route the no-scalar-by-design # headline correctly even when the horizon surface is empty. @@ -688,15 +693,18 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): event_study_df: Optional[float] = field(default=None, repr=False) def __setstate__(self, state: Dict[str, Any]) -> None: - """Migrate 3.x pickles created before the ``groups`` -> ``units`` rename. + """Migrate 3.x pickles: the ``groups`` -> ``units`` rename and the 4.0 + ``overall_*`` -> canonical-quintet storage flip (``docs/v4-design.md`` + section 5). - Results pickled under the old name stored the list under ``groups``; - rewriting the key on load populates ``units`` from old pickles. + Results pickled under the old names stored the list under ``groups`` + and the DID_M aggregate under ``overall_att``/``overall_se``/...; + rewriting the keys on load populates the canonical fields. """ if "groups" in state and "units" not in state: state = dict(state) state["units"] = state.pop("groups") - self.__dict__.update(state) + self.__dict__.update(migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET)) # ------------------------------------------------------------------ # Post-fit aggregation (row M-026, on the M-122 contract): dCDH's @@ -854,26 +862,26 @@ def _estimand_label(self) -> str: return f"{did_part}{suffix}_{sub_part}" if sub_part else f"{did_part}{suffix}" return base - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-057) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "ChaisemartinDHaultfoeuilleResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "ChaisemartinDHaultfoeuilleResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "ChaisemartinDHaultfoeuilleResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "ChaisemartinDHaultfoeuilleResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "ChaisemartinDHaultfoeuilleResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) def __repr__(self) -> str: """Concise string representation.""" diff --git a/diff_diff/continuous_did.py b/diff_diff/continuous_did.py index d07883327..639a308b3 100644 --- a/diff_diff/continuous_did.py +++ b/diff_diff/continuous_did.py @@ -1219,11 +1219,11 @@ def _run_stage(_es: Optional[Dict[int, Dict[str, Any]]]) -> Dict[str, Any]: results = ContinuousDiDResults( dose_response_att=dose_response_att, dose_response_acrt=dose_response_acrt, - overall_att=overall_att, - overall_att_se=overall_att_se, - overall_att_t_stat=overall_att_t, - overall_att_p_value=overall_att_p, - overall_att_conf_int=overall_att_ci, + att=overall_att, + se=overall_att_se, + t_stat=overall_att_t, + p_value=overall_att_p, + conf_int=overall_att_ci, overall_acrt=overall_acrt, overall_acrt_se=overall_acrt_se, overall_acrt_t_stat=overall_acrt_t, diff --git a/diff_diff/continuous_did_results.py b/diff_diff/continuous_did_results.py index 52ba4ee1c..b015abe5a 100644 --- a/diff_diff/continuous_did_results.py +++ b/diff_diff/continuous_did_results.py @@ -12,6 +12,7 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import deprecated_field_property, migrate_renamed_state_keys from diff_diff.aggregation import ( AggregationMixin, AggregationResult, @@ -31,6 +32,18 @@ __all__ = ["ContinuousDiDResults", "DoseResponseCurve"] +# The 3.x ``overall_att_*`` storage names of this container (its deviant +# spellings of the M-050..M-058 family) keyed to the canonical quintet +# field; the four silent ``overall_se``-style aliases were never stored. +_LEGACY_OVERALL_ATT_QUINTET: Dict[str, str] = { + "overall_att": "att", + "overall_att_se": "se", + "overall_att_t_stat": "t_stat", + "overall_att_p_value": "p_value", + "overall_att_conf_int": "conf_int", +} + + class _ContinuousKitAggregator(_ContinuousDiDAggregationMixin): """Throwaway host for the post-fit event-study recompute (row M-025). @@ -117,7 +130,7 @@ class ContinuousDiDResults(BaseResults, AggregationMixin): ATT(d) dose-response curve. dose_response_acrt : DoseResponseCurve ACRT(d) dose-response curve. - overall_att : float + att : float Binarized overall ATT (ATT^{loc} under PT, equals ATT^{glob} under SPT). overall_acrt : float Plug-in overall ACRT^{glob}. @@ -144,11 +157,11 @@ class ContinuousDiDResults(BaseResults, AggregationMixin): dose_response_att: DoseResponseCurve dose_response_acrt: DoseResponseCurve - overall_att: float - overall_att_se: float - overall_att_t_stat: float - overall_att_p_value: float - overall_att_conf_int: Tuple[float, float] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] overall_acrt: float overall_acrt_se: float overall_acrt_t_stat: float @@ -208,44 +221,52 @@ class ContinuousDiDResults(BaseResults, AggregationMixin): _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("simple", "event_study", "dose") _AGGREGATE_BALANCE_E_TYPES: ClassVar[Tuple[str, ...]] = () - # --- Inference-field aliases (balance/external-adapter compatibility) --- - # ATT-side is the headline contract; ACRT remains accessible via overall_acrt_*. - @property - def att(self) -> float: - return self.overall_att - - @property - def se(self) -> float: - return self.overall_att_se - - @property - def conf_int(self) -> Tuple[float, float]: - return self.overall_att_conf_int - - @property - def p_value(self) -> float: - return self.overall_att_p_value - - @property - def t_stat(self) -> float: - return self.overall_att_t_stat - - # `overall_*` aliases for naming consistency with the rest of the staggered family. - @property - def overall_se(self) -> float: - return self.overall_att_se - - @property - def overall_conf_int(self) -> Tuple[float, float]: - return self.overall_att_conf_int - - @property - def overall_p_value(self) -> float: - return self.overall_att_p_value - - @property - def overall_t_stat(self) -> float: - return self.overall_att_t_stat + # --- Deprecated 3.x storage names (ledger row M-058) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + # Nine spellings onto the five canonical fields: the five former + # ``overall_att_*`` fields AND the four former silent ``overall_*`` + # aliases (the M-058 family); ``overall_acrt*`` is a separate estimand. + overall_att = deprecated_field_property( + "ContinuousDiDResults", "overall_att", "att", removed_in="5.0" + ) + overall_att_se = deprecated_field_property( + "ContinuousDiDResults", "overall_att_se", "se", removed_in="5.0" + ) + overall_att_t_stat = deprecated_field_property( + "ContinuousDiDResults", "overall_att_t_stat", "t_stat", removed_in="5.0" + ) + overall_att_p_value = deprecated_field_property( + "ContinuousDiDResults", "overall_att_p_value", "p_value", removed_in="5.0" + ) + overall_att_conf_int = deprecated_field_property( + "ContinuousDiDResults", "overall_att_conf_int", "conf_int", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "ContinuousDiDResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "ContinuousDiDResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "ContinuousDiDResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "ContinuousDiDResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate 3.x pickles written under the ``overall_*`` storage names. + + The canonical quintet became the native fields at the 4.0 cut + (``docs/v4-design.md`` section 5; ``SyntheticDiDResults.__setstate__`` + precedent): legacy keys are rewritten on load so the fields populate + under their canonical names and ``overall_*`` never reappear in + ``__dict__``. + """ + self.__dict__.update(migrate_renamed_state_keys(state, _LEGACY_OVERALL_ATT_QUINTET)) def __repr__(self) -> str: sig_att = _get_significance_stars(self.p_value) diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index 28b4d6f8d..0bf040629 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -52,7 +52,7 @@ from diff_diff._reporting_helpers import describe_target_parameter # noqa: E402 (top-level import) from diff_diff.results_base import Diagnostic, _coverage_pct -DIAGNOSTIC_REPORT_SCHEMA_VERSION = "2.0" +DIAGNOSTIC_REPORT_SCHEMA_VERSION = "3.0" __all__ = [ "DiagnosticReport", @@ -1379,7 +1379,7 @@ def _execute(self) -> DiagnosticReportResults: # Headline metric — best-effort across estimator types. # PR #347 R4 P1: the dCDH ``trends_linear=True`` + ``L_max>=2`` # configuration does not produce a scalar headline by design - # (``overall_att`` is intentionally NaN per + # (``att`` is intentionally NaN per # ``chaisemartin_dhaultfoeuille.py:2828-2834``). Route the # headline through a dedicated no-scalar block when the # target-parameter helper flags this case so prose does not @@ -3838,10 +3838,16 @@ def _extract_scalar_headline( """Extract ``(name, value, se, p_value, conf_int, alpha)`` from a fitted result. Centralizes the scalar-headline mapping shared by both ``BusinessReport`` - and ``DiagnosticReport`` so schema drift (e.g. ``ContinuousDiDResults`` - using ``overall_att_se`` / ``overall_att_p_value`` / - ``overall_att_conf_int`` instead of the ``overall_att`` stem) is handled - in one place. + and ``DiagnosticReport`` so the two reports agree on which fields a + result class exposes for its headline. Since the 4.0 storage flip + (ledger rows M-050..M-058) every scalar-headline estimator container + stores the canonical quintet ``att`` / ``se`` / ``p_value`` / + ``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. 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 @@ -3850,14 +3856,7 @@ def _extract_scalar_headline( """ # (name, [se aliases], [p-value aliases], [ci aliases]) alias_table: List[Tuple[str, List[str], List[str], List[str]]] = [ - # Staggered / multi-period aggregations - ( - "overall_att", - ["overall_se", "overall_att_se"], - ["overall_p_value", "overall_att_p_value"], - ["overall_conf_int", "overall_att_conf_int"], - ), - # Simple DiDResults / SyntheticDiDResults / TROPResults / TripleDifferenceResults + # Every scalar-headline estimator container (the canonical quintet). ("att", ["se"], ["p_value"], ["conf_int"]), ] for name, se_aliases, p_aliases, ci_aliases in alias_table: diff --git a/diff_diff/dml_did.py b/diff_diff/dml_did.py index c0a7ee0f4..e9f2df734 100644 --- a/diff_diff/dml_did.py +++ b/diff_diff/dml_did.py @@ -3280,11 +3280,11 @@ def fit( results = DMLDiDResults( 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, + att=overall_att, + se=overall_se, + t_stat=overall_t_stat, + p_value=overall_p_value, + conf_int=overall_conf_int, groups=treatment_groups, time_periods=time_periods, n_obs=int(len(df)), diff --git a/diff_diff/dml_did_results.py b/diff_diff/dml_did_results.py index 206630c9a..ef6ec47bd 100644 --- a/diff_diff/dml_did_results.py +++ b/diff_diff/dml_did_results.py @@ -29,6 +29,7 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import deprecated_field_property from diff_diff.results_base import _json_safe_label from diff_diff.staggered_results import CallawaySantAnnaResults @@ -142,6 +143,22 @@ class DMLDiDResults(CallawaySantAnnaResults): bad_control_covariates: Optional[Tuple[str, ...]] = None bad_control_diagnostics: Optional[Dict[Any, Dict[str, Any]]] = field(default=None, repr=False) + # --- Deprecated 3.x storage names (ride ledger row M-050) --- + # The storage is inherited from CallawaySantAnnaResults; the five shims are + # RE-DECLARED here so the FutureWarning names this subclass (plain class + # attributes, never dataclass fields). Removed at 5.0 with the parent's. + overall_att = deprecated_field_property("DMLDiDResults", "overall_att", "att", removed_in="5.0") + overall_se = deprecated_field_property("DMLDiDResults", "overall_se", "se", removed_in="5.0") + overall_t_stat = deprecated_field_property( + "DMLDiDResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "DMLDiDResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "DMLDiDResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) + def __repr__(self) -> str: folds = f"n_folds={self.n_folds}" if self.effective_n_folds is not None: diff --git a/diff_diff/efficient_did.py b/diff_diff/efficient_did.py index 9f2c5d047..de28cfae7 100644 --- a/diff_diff/efficient_did.py +++ b/diff_diff/efficient_did.py @@ -1323,11 +1323,11 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any raw_unit_w_meta = raw_obs_w_meta[self._unit_first_panel_row] self.results_ = EfficientDiDResults( group_time_effects=group_time_effects, - overall_att=overall_att, - overall_se=overall_se, - overall_t_stat=overall_t, - overall_p_value=overall_p, - overall_conf_int=overall_ci, + att=overall_att, + se=overall_se, + t_stat=overall_t, + p_value=overall_p, + conf_int=overall_ci, groups=treatment_groups, time_periods=time_periods, n_obs=n_units * len(time_periods), diff --git a/diff_diff/efficient_did_results.py b/diff_diff/efficient_did_results.py index 548bce718..3c298fef7 100644 --- a/diff_diff/efficient_did_results.py +++ b/diff_diff/efficient_did_results.py @@ -12,6 +12,11 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.aggregation import AggregationMixin, AggregationResult, build_total_relay_row from diff_diff.bootstrap_chunking import effective_weight_backend from diff_diff.bootstrap_utils import ( @@ -138,16 +143,16 @@ class EfficientDiDResults(BaseResults, AggregationMixin): group_time_effects : dict ``{(g, t): {'effect', 'se', 't_stat', 'p_value', 'conf_int', 'n_treated', 'n_control'}}`` - overall_att : float + att : float Overall ATT (cohort-size weighted average of post-treatment group-time effects, matching CallawaySantAnna convention). - overall_se : float + se : float Standard error of overall ATT. - overall_t_stat : float + t_stat : float t-statistic for overall ATT. - overall_p_value : float + p_value : float p-value for overall ATT. - overall_conf_int : tuple + conf_int : tuple Confidence interval for overall ATT. groups : list Treatment cohort identifiers. @@ -222,11 +227,11 @@ class EfficientDiDResults(BaseResults, AggregationMixin): """ 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] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] groups: List[Any] time_periods: List[Any] n_obs: int @@ -288,26 +293,37 @@ class EfficientDiDResults(BaseResults, AggregationMixin): # the mixin default ("event_study",) - CS precedent, do not redeclare. _AGGREGATE_SUPPORTED = ("simple", "event_study", "group", "total") - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-055) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "EfficientDiDResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "EfficientDiDResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "EfficientDiDResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "EfficientDiDResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "EfficientDiDResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) - @property - def p_value(self) -> float: - return self.overall_p_value + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate 3.x pickles written under the ``overall_*`` storage names. - @property - def t_stat(self) -> float: - return self.overall_t_stat + The canonical quintet became the native fields at the 4.0 cut + (``docs/v4-design.md`` section 5; ``SyntheticDiDResults.__setstate__`` + precedent): legacy keys are rewritten on load so the fields populate + under their canonical names and ``overall_*`` never reappear in + ``__dict__``. + """ + self.__dict__.update(migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET)) # --- Post-fit aggregation (M-023) ----------------------------------- diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 986b008d8..4b72ecb74 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -1202,17 +1202,17 @@ close to zero support parallel trends. `BusinessReport(results)` and `DiagnosticReport(results)` are experimental in the 3.2 line. Their schema is versioned (`BUSINESS_REPORT_SCHEMA_VERSION` -and `DIAGNOSTIC_REPORT_SCHEMA_VERSION`, `"3.0"` and `"2.0"` respectively at time of writing) +and `DIAGNOSTIC_REPORT_SCHEMA_VERSION`, `"4.0"` and `"3.0"` respectively at time of writing) and expected to evolve. Treat `.to_dict()` output as the agent-legible contract; the prose renderers (`summary()`, `full_report()`) are derived from it. -### BusinessReport `to_dict()` schema (v3.0) +### BusinessReport `to_dict()` schema (v4.0) Top-level keys emitted by `BusinessReport.to_dict()` (source: `diff_diff/business_report.py`): -- `schema_version: str` - `BUSINESS_REPORT_SCHEMA_VERSION`, e.g. `"3.0"`. +- `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`. - `context: dict` - the `BusinessContext` bundle: `outcome_label`, @@ -1248,7 +1248,7 @@ Top-level keys emitted by `BusinessReport.to_dict()` checks. - `references: list[dict]` - citations relevant to the estimator. -### DiagnosticReport `to_dict()` schema (v2.0) +### DiagnosticReport `to_dict()` schema (v3.0) Top-level keys (source: `diff_diff/diagnostic_report.py`): diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 9daf2ada8..fef74b48a 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -1714,17 +1714,21 @@ results = etwfe_pois.fit(data, outcome='y_count', unit='id', time='t', first_tre ## Results Objects **Canonical quintet note.** Every staggered result class in this -section exposes the headline inference under the canonical flat names -`att` / `se` / `conf_int` / `p_value` / `t_stat` (4.0 design spec section -5) - read those. The `overall_*` / `overall_att_*` spellings in the tables -below are the 3.x STORED field names; they resolve to the same values -(the flat names are read-only `@property` aliases over them until the 4.0 -storage flip makes the flat names the native fields, after which the -`overall_*` spellings become `FutureWarning` aliases removed at 5.0). -External adapters that `getattr(res, "se", None)`-style query the -inference surface (e.g. `balance.interop.diff_diff.as_balance_diagnostic()`) -therefore work on every staggered class; assume the flat names are -present unless explicitly noted otherwise. +section stores the headline inference under the canonical names +`att` / `se` / `t_stat` / `p_value` / `conf_int` (4.0 design spec section +5; the storage flipped at the 4.0 cut, ledger rows M-050..M-058) - read +and construct with those. The 3.x `overall_att` / `overall_se` / +`overall_t_stat` / `overall_p_value` / `overall_conf_int` spellings (and +ContinuousDiD's `overall_att_se`-style spellings) are read-only +`FutureWarning` properties that relay the same values and are removed at +5.0; they are not constructor keywords and never appear in `to_dict()`. +3.x pickles load (the legacy keys are rewritten on load). The bootstrap +draw containers (`bootstrap_results.overall_att_se` etc.) keep their +payload names. External adapters that `getattr(res, "se", None)`-style +query the inference surface (e.g. +`balance.interop.diff_diff.as_balance_diagnostic()`) therefore work on +every staggered class; assume the canonical names are present unless +explicitly noted otherwise. **balance interop.** Meta's `balance` package (>= 0.21) ships the one-way adapter `balance.interop.diff_diff` (`pip install "balance[did]"`, @@ -1778,11 +1782,11 @@ Returned by `CallawaySantAnna.fit()`. | Attribute | Type | Description | |-----------|------|-------------| | `group_time_effects` | `dict[(g,t), GroupTimeEffect]` | ATT(g,t) for each (group, time) | -| `overall_att` | `float` | Overall ATT | -| `overall_se` | `float` | SE of overall ATT | -| `overall_t_stat` | `float` | T-statistic | -| `overall_p_value` | `float` | P-value | -| `overall_conf_int` | `tuple[float, float]` | CI for overall ATT | +| `att` | `float` | Overall ATT | +| `se` | `float` | SE of overall ATT | +| `t_stat` | `float` | T-statistic | +| `p_value` | `float` | P-value | +| `conf_int` | `tuple[float, float]` | CI for overall ATT | | `groups` | `list` | Treatment cohorts | | `time_periods` | `list` | All time periods | | `n_obs` | `int` | Number of observations | @@ -1816,11 +1820,11 @@ Returned by `SunAbraham.fit()`. | Attribute | Type | Description | |-----------|------|-------------| | `event_study_effects` | `dict[int, dict]` | Effects by relative time | -| `overall_att` | `float` | Overall ATT | -| `overall_se` | `float` | SE of overall ATT | -| `overall_t_stat` | `float` | T-statistic | -| `overall_p_value` | `float` | P-value | -| `overall_conf_int` | `tuple[float, float]` | CI | +| `att` | `float` | Overall ATT | +| `se` | `float` | SE of overall ATT | +| `t_stat` | `float` | T-statistic | +| `p_value` | `float` | P-value | +| `conf_int` | `tuple[float, float]` | CI | | `cohort_weights` | `dict[int, dict]` | Interaction weights per period | | `groups` | `list` | Treatment cohorts | | `n_obs` | `int` | Number of observations | @@ -1996,11 +2000,11 @@ ImputationDiDResults, TwoStageDiDResults, StackedDiDResults, and EfficientDiDRes | Attribute | Type | Description | |-----------|------|-------------| -| `overall_att` | `float` | Overall ATT | -| `overall_se` | `float` | SE of overall ATT | -| `overall_t_stat` | `float` | T-statistic | -| `overall_p_value` | `float` | P-value | -| `overall_conf_int` | `tuple[float, float]` | CI | +| `att` | `float` | Overall ATT | +| `se` | `float` | SE of overall ATT | +| `t_stat` | `float` | T-statistic | +| `p_value` | `float` | P-value | +| `conf_int` | `tuple[float, float]` | CI | | `event_study_effects` | `dict[int, dict] | None` | Legacy field. `None` on analytical fits and on every EfficientDiD fit; populated on bootstrapped ImputationDiD/TwoStageDiD fits (the percentile surface computed at fit, relayed by `aggregate('event_study')`); StackedDiD always populates it (row M-024). The canonical surface is the `aggregate('event_study')` CONTAINER | | `event_study_df` | `float | dict | None` | Producer-side per-row df channel for the unified event-study container. On `EfficientDiDResults`/`ImputationDiDResults` (3.12, M-092 completion): the scalar survey df governing the ES rows' t-inference (ImputationDiD threads the FINAL replicate-override value; leads included on survey fits); `None` on non-survey fits, on bootstrapped fits (percentile inference used no df), on analytical fits (no surface is stored - the kit recomputes), and for the replicate-undefined `0` sentinel | | `group_effects` | `dict | None` | Legacy field. `None` on analytical fits and on every EfficientDiD fit; populated on bootstrapped ImputationDiD/TwoStageDiD fits (relayed by `aggregate('group')`). The canonical surface is the `aggregate('group')` AggregationResult container | @@ -2029,11 +2033,11 @@ Each event study effect dict contains: `effect`, `se`, `t_stat`, `p_value`, `con |-----------|------|-------------| | `dose_response_att` | `DoseResponseCurve` | Dose-response curve for ATT | | `dose_response_acrt` | `DoseResponseCurve` | Dose-response curve for ACRT | -| `overall_att` | `float` | Overall ATT | -| `overall_att_se` | `float` | SE of overall ATT | -| `overall_att_t_stat` | `float` | T-statistic for ATT | -| `overall_att_p_value` | `float` | P-value for ATT | -| `overall_att_conf_int` | `tuple[float, float]` | CI for ATT | +| `att` | `float` | Overall ATT (the headline; ACRT is the separate `overall_acrt*` family) | +| `se` | `float` | SE of overall ATT | +| `t_stat` | `float` | T-statistic for ATT | +| `p_value` | `float` | P-value for ATT | +| `conf_int` | `tuple[float, float]` | CI for ATT | | `overall_acrt` | `float` | Overall ACRT | | `overall_acrt_se` | `float` | SE of overall ACRT | | `overall_acrt_t_stat` | `float` | T-statistic for ACRT | @@ -2139,7 +2143,7 @@ Results container for `ChangesInChanges` under both methods (the `method` field ### LWDiDResults -Results from `LWDiD.fit()` (both timing modes). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int` (when inference is unavailable - e.g. psm, degenerate designs - `se`/`t_stat`/`p_value` and both CI bounds are jointly NaN while `att` is RETAINED: the fail-closed contract), plus `n_obs`, `n_treated`, `n_control`, `df_inference`, `cluster_name`/`n_clusters`, and fit provenance (`rolling`, `estimation_method`, `vcov_type`, `alpha`, `control_group`, `n_bootstrap`, `seed`, `pscore_trim`, `psm_config`, `inference_basis`). Staggered fits also carry `cohort_effects` and `cohort_time_effects` dicts. Methods: `summary(alpha=None)` / `print_summary(alpha=None)` (alpha must equal the fit-time alpha - the family-wide guard), `to_dict()`, `to_dataframe(level='simple')` / `to_csv(path, level='simple')` (4.0: aggregate-backed - `aggregate(level).to_dataframe()` plus the `rolling`/`estimation_method`/`vcov_type` columns; `level` in `'simple'` (one headline row, default), `'group'` (per-cohort rows, staggered only), `'event_study'`; columns `conf_int_lower`/`conf_int_upper`, `n` = treated units - `n_obs`/`n_control` live in `to_dict()`), `aggregate(level)` with levels `'simple'` (view-relay of the fitted headline -> AggregationResult), `'event_study'` (per-event-time effects -> EventStudyResults), and `'group'` (per-cohort effects -> AggregationResult; staggered fits only - common-timing fits raise), and - common-timing `estimation_method='reg'` fits ONLY - `randomization_test()` and `wild_cluster_bootstrap()` (the latter ADDITIONALLY requires a `cluster=` fit - unclustered fits raise; both REPLAY the fitted collapsed design; no data arguments accepted; other fits reject informatively). NOTE: there is NO `overall_att` or `period_effects` attribute - those surfaces were retired pre-release (unlike the shared staggered pattern above); use `.att` for the overall effect and `aggregate('event_study')` for per-period effects. +Results from `LWDiD.fit()` (both timing modes). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int` (when inference is unavailable - e.g. psm, degenerate designs - `se`/`t_stat`/`p_value` and both CI bounds are jointly NaN while `att` is RETAINED: the fail-closed contract), plus `n_obs`, `n_treated`, `n_control`, `df_inference`, `cluster_name`/`n_clusters`, and fit provenance (`rolling`, `estimation_method`, `vcov_type`, `alpha`, `control_group`, `n_bootstrap`, `seed`, `pscore_trim`, `psm_config`, `inference_basis`). Staggered fits also carry `cohort_effects` and `cohort_time_effects` dicts. Methods: `summary(alpha=None)` / `print_summary(alpha=None)` (alpha must equal the fit-time alpha - the family-wide guard), `to_dict()`, `to_dataframe(level='simple')` / `to_csv(path, level='simple')` (4.0: aggregate-backed - `aggregate(level).to_dataframe()` plus the `rolling`/`estimation_method`/`vcov_type` columns; `level` in `'simple'` (one headline row, default), `'group'` (per-cohort rows, staggered only), `'event_study'`; columns `conf_int_lower`/`conf_int_upper`, `n` = treated units - `n_obs`/`n_control` live in `to_dict()`), `aggregate(level)` with levels `'simple'` (view-relay of the fitted headline -> AggregationResult), `'event_study'` (per-event-time effects -> EventStudyResults), and `'group'` (per-cohort effects -> AggregationResult; staggered fits only - common-timing fits raise), and - common-timing `estimation_method='reg'` fits ONLY - `randomization_test()` and `wild_cluster_bootstrap()` (the latter ADDITIONALLY requires a `cluster=` fit - unclustered fits raise; both REPLAY the fitted collapsed design; no data arguments accepted; other fits reject informatively). NOTE: there is NO `overall_att` or `period_effects` attribute, not even as a deprecated alias - those surfaces were retired pre-release (the staggered containers above carry `overall_att` only as a 5.0-removed FutureWarning property); use `.att` for the overall effect and `aggregate('event_study')` for per-period effects. ### TROPResults diff --git a/diff_diff/imputation.py b/diff_diff/imputation.py index 71be2ce79..07d4a42f3 100644 --- a/diff_diff/imputation.py +++ b/diff_diff/imputation.py @@ -1019,11 +1019,11 @@ def _precompute_psi( # Construct results self.results_ = ImputationDiDResults( treatment_effects=treated_df, - overall_att=overall_att, - overall_se=overall_se, - overall_t_stat=overall_t, - overall_p_value=overall_p, - overall_conf_int=overall_ci, + att=overall_att, + se=overall_se, + t_stat=overall_t, + p_value=overall_p, + conf_int=overall_ci, event_study_effects=event_study_effects, group_effects=group_effects, groups=treatment_groups, diff --git a/diff_diff/imputation_results.py b/diff_diff/imputation_results.py index 63b2d52a6..1f063fe8d 100644 --- a/diff_diff/imputation_results.py +++ b/diff_diff/imputation_results.py @@ -12,6 +12,11 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.aggregation import ( AggregationMixin, AggregationResult, @@ -130,15 +135,15 @@ class ImputationDiDResults(BaseResults, AggregationMixin): ---------- treatment_effects : pd.DataFrame Unit-level treatment effects with columns: unit, time, tau_hat, weight. - overall_att : float + att : float Overall average treatment effect on the treated. - overall_se : float + se : float Standard error of overall ATT. - overall_t_stat : float + t_stat : float T-statistic for overall ATT. - overall_p_value : float + p_value : float P-value for overall ATT. - overall_conf_int : tuple + conf_int : tuple Confidence interval for overall ATT. event_study_effects : dict, optional Dictionary mapping relative time h to effect dict with keys: @@ -174,11 +179,11 @@ class ImputationDiDResults(BaseResults, AggregationMixin): """ treatment_effects: pd.DataFrame - overall_att: float - overall_se: float - overall_t_stat: float - overall_p_value: float - overall_conf_int: Tuple[float, float] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] event_study_effects: Optional[Dict[int, Dict[str, Any]]] group_effects: Optional[Dict[Any, Dict[str, Any]]] groups: List[Any] @@ -229,26 +234,37 @@ class ImputationDiDResults(BaseResults, AggregationMixin): # default ("event_study",) - CS precedent, do not redeclare. _AGGREGATE_SUPPORTED = ("simple", "event_study", "group", "total") - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-052) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "ImputationDiDResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "ImputationDiDResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "ImputationDiDResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "ImputationDiDResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "ImputationDiDResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate 3.x pickles written under the ``overall_*`` storage names. + + The canonical quintet became the native fields at the 4.0 cut + (``docs/v4-design.md`` section 5; ``SyntheticDiDResults.__setstate__`` + precedent): legacy keys are rewritten on load so the fields populate + under their canonical names and ``overall_*`` never reappear in + ``__dict__``. + """ + self.__dict__.update(migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET)) # --- Post-fit aggregation (M-021/M-118) ------------------------------ diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index 31e2cd703..023e44d7b 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -65,11 +65,13 @@ class BaseResults: subclass converges to: - **Canonical quintet.** ``att``, ``se``, ``t_stat``, ``p_value``, - ``conf_int`` bound to ONE coherent inference row (native fields or - property aliases over legacy ``overall_*``/``avg_*`` storage; the - storage flips to native canonical fields at 4.0, ledger rows - M-050..M-058, with legacy names living on as FutureWarning - properties until 5.0). + ``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. - **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. diff --git a/diff_diff/stacked_did.py b/diff_diff/stacked_did.py index f5cdc454b..e6f406ca6 100644 --- a/diff_diff/stacked_did.py +++ b/diff_diff/stacked_did.py @@ -1045,11 +1045,11 @@ def _refit_stacked(w_r): # ---- Construct results ---- self.results_ = StackedDiDResults( - overall_att=overall_att, - overall_se=overall_se, - overall_t_stat=overall_t, - overall_p_value=overall_p, - overall_conf_int=overall_ci, + att=overall_att, + se=overall_se, + t_stat=overall_t, + p_value=overall_p, + conf_int=overall_ci, event_study_effects=event_study_effects, group_effects=None, stacked_data=stacked_df, diff --git a/diff_diff/stacked_did_results.py b/diff_diff/stacked_did_results.py index 252de501f..50fa31b83 100644 --- a/diff_diff/stacked_did_results.py +++ b/diff_diff/stacked_did_results.py @@ -11,6 +11,11 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.aggregation import AggregationMixin, AggregationResult from diff_diff.results import _format_survey_block, _get_significance_stars from diff_diff.results_base import ( @@ -32,16 +37,16 @@ class StackedDiDResults(BaseResults, AggregationMixin): Attributes ---------- - overall_att : float + att : float Overall average treatment effect on the treated (average of post-treatment event-study coefficients). - overall_se : float + se : float Standard error of overall ATT (delta method on VCV). - overall_t_stat : float + t_stat : float T-statistic for overall ATT. - overall_p_value : float + p_value : float P-value for overall ATT. - overall_conf_int : tuple + conf_int : tuple Confidence interval for overall ATT. event_study_effects : dict, optional Dictionary mapping event time h to effect dict with keys: @@ -118,11 +123,11 @@ class StackedDiDResults(BaseResults, AggregationMixin): overall inference used normal theory or failed closed. """ - overall_att: float - overall_se: float - overall_t_stat: float - overall_p_value: float - overall_conf_int: Tuple[float, float] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] event_study_effects: Optional[Dict[int, Dict[str, Any]]] group_effects: Optional[Dict[Any, Dict[str, Any]]] stacked_data: pd.DataFrame = field(repr=False) @@ -174,13 +179,14 @@ class StackedDiDResults(BaseResults, AggregationMixin): inference_df: Optional[float] = None def __setstate__(self, state: Dict[str, Any]) -> None: - """Migrate 3.x pickles created before the ``clean_control`` -> - ``control_group`` rename: rewrite the legacy key on load so the - field populates under its canonical name.""" + """Migrate 3.x pickles: the ``clean_control`` -> ``control_group`` + rename and the 4.0 ``overall_*`` -> canonical-quintet storage flip + (``docs/v4-design.md`` section 5). Legacy keys are rewritten on load + so each field populates under its canonical name.""" if "clean_control" in state and "control_group" not in state: state = dict(state) state["control_group"] = state.pop("clean_control") - self.__dict__.update(state) + self.__dict__.update(migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET)) # ------------------------------------------------------------------ # Container-consumer provenance (rows M-024 / M-093). Class-level @@ -237,7 +243,7 @@ def _aggregate_compute( # than CallawaySantAnna's treated+control "units" (REGISTRY # StackedDiD M-024 Note; cross-container ``n`` comparisons are out # of contract for this estimator). ``target`` is "att" per the CS - # precedent: ``overall_att`` is the equally-weighted average of + # precedent: ``att`` is the equally-weighted average of # post-treatment event-study coefficients, NOT the per-event-time # trimmed aggregate ATT, so weighting-specific target strings # would misstate the scalar (``describe_target_parameter`` is the @@ -264,26 +270,26 @@ def _aggregate_compute( estimator=type(self).__name__.replace("Results", ""), ) - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-054) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "StackedDiDResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "StackedDiDResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "StackedDiDResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "StackedDiDResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "StackedDiDResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) def __repr__(self) -> str: """Concise string representation.""" diff --git a/diff_diff/staggered.py b/diff_diff/staggered.py index b35b60530..e18c8ef69 100644 --- a/diff_diff/staggered.py +++ b/diff_diff/staggered.py @@ -2874,11 +2874,11 @@ def fit( self.results_ = CallawaySantAnnaResults( group_time_effects=group_time_effects, - overall_att=overall_att, - overall_se=overall_se, - overall_t_stat=overall_t, - overall_p_value=overall_p, - overall_conf_int=overall_ci, + att=overall_att, + se=overall_se, + t_stat=overall_t, + p_value=overall_p, + conf_int=overall_ci, groups=treatment_groups, time_periods=time_periods, n_obs=len(df), diff --git a/diff_diff/staggered_results.py b/diff_diff/staggered_results.py index c4140c025..a929945b6 100644 --- a/diff_diff/staggered_results.py +++ b/diff_diff/staggered_results.py @@ -11,6 +11,11 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.aggregation import ( AggregationMixin, AggregationResult, @@ -154,13 +159,13 @@ class CallawaySantAnnaResults(BaseResults, AggregationMixin): ---------- group_time_effects : dict Dictionary mapping (group, time) tuples to effect dictionaries. - overall_att : float + att : float Overall average treatment effect (weighted average of ATT(g,t)). - overall_se : float + se : float Standard error of overall ATT. - overall_p_value : float + p_value : float P-value for overall ATT. - overall_conf_int : tuple + conf_int : tuple Confidence interval for overall ATT. groups : list List of treatment cohorts (first treatment periods). @@ -210,11 +215,11 @@ class CallawaySantAnnaResults(BaseResults, AggregationMixin): """ 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] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] groups: List[Any] time_periods: List[Any] n_obs: int @@ -305,26 +310,37 @@ class CallawaySantAnnaResults(BaseResults, AggregationMixin): reference_event_times: Optional[Tuple[Any, ...]] = None _aggregation_kit: Optional[Any] = field(default=None, repr=False, compare=False) - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-050) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "CallawaySantAnnaResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "CallawaySantAnnaResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "CallawaySantAnnaResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "CallawaySantAnnaResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "CallawaySantAnnaResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) - @property - def p_value(self) -> float: - return self.overall_p_value + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate 3.x pickles written under the ``overall_*`` storage names. - @property - def t_stat(self) -> float: - return self.overall_t_stat + The canonical quintet became the native fields at the 4.0 cut + (``docs/v4-design.md`` section 5; ``SyntheticDiDResults.__setstate__`` + precedent): legacy keys are rewritten on load so the fields populate + under their canonical names and ``overall_*`` never reappear in + ``__dict__``. + """ + self.__dict__.update(migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET)) def __repr__(self) -> str: """Concise string representation.""" @@ -541,7 +557,7 @@ def _aggregate_simple_result(self, kit: Any) -> AggregationResult: n=np.array([float(self.n_treated_units + self.n_control_units)], dtype=float), # NOT ``df_inference``: that field is documented to stay None on # explicit ``survey_design=`` fits, where the df that actually - # governed ``overall_p_value`` lives on ``survey_metadata``. + # governed ``p_value`` lives on ``survey_metadata``. # Reading it directly reported df=NaN for survey fits whose CI # was built on a finite t-reference. Bootstrapped fits report a # NaN df column: no df governs percentile inference. diff --git a/diff_diff/sun_abraham.py b/diff_diff/sun_abraham.py index c45ee40f1..6f8e1fc95 100644 --- a/diff_diff/sun_abraham.py +++ b/diff_diff/sun_abraham.py @@ -28,6 +28,11 @@ import pandas as pd from diff_diff._base import BaseEstimator +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.bootstrap_utils import compute_effect_bootstrap_stats if TYPE_CHECKING: @@ -87,15 +92,15 @@ class SunAbrahamResults(BaseResults): percentile p-values/CIs never used a df - a narrower clearing rule than ``event_study_vcov``, which also clears under replicate refits (whose rows DID use a genuine df). - overall_att : float + att : float Overall average treatment effect (weighted average of post-treatment effects). - overall_se : float + se : float Standard error of overall ATT. - overall_t_stat : float + t_stat : float T-statistic for overall ATT. - overall_p_value : float + p_value : float P-value for overall ATT. - overall_conf_int : tuple + conf_int : tuple Confidence interval for overall ATT. cohort_weights : dict Dictionary mapping relative time to cohort weight dictionaries. @@ -129,11 +134,11 @@ class SunAbrahamResults(BaseResults): """ event_study_effects: Dict[int, Dict[str, Any]] - overall_att: float - overall_se: float - overall_t_stat: float - overall_p_value: float - overall_conf_int: Tuple[float, float] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] cohort_weights: Dict[int, Dict[Any, float]] groups: List[Any] time_periods: List[Any] @@ -215,26 +220,37 @@ class SunAbrahamResults(BaseResults): fallback (None under "normal" = normal theory). None on bootstrap fits, whose overall p/CI are percentile-based and never used a df.""" - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-051) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "SunAbrahamResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "SunAbrahamResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "SunAbrahamResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "SunAbrahamResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "SunAbrahamResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) - @property - def p_value(self) -> float: - return self.overall_p_value + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate 3.x pickles written under the ``overall_*`` storage names. - @property - def t_stat(self) -> float: - return self.overall_t_stat + The canonical quintet became the native fields at the 4.0 cut + (``docs/v4-design.md`` section 5; ``SyntheticDiDResults.__setstate__`` + precedent): legacy keys are rewritten on load so the fields populate + under their canonical names and ``overall_*`` never reappear in + ``__dict__``. + """ + self.__dict__.update(migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET)) def __repr__(self) -> str: """Concise string representation.""" @@ -1535,11 +1551,11 @@ def _refit_sa_cohort(w_r): # Store results self.results_ = SunAbrahamResults( event_study_effects=event_study_effects, - overall_att=overall_att, - overall_se=overall_se, - overall_t_stat=overall_t, - overall_p_value=overall_p, - overall_conf_int=overall_ci, + att=overall_att, + se=overall_se, + t_stat=overall_t, + p_value=overall_p, + conf_int=overall_ci, cohort_weights=cohort_weights, groups=treatment_groups, time_periods=time_periods, diff --git a/diff_diff/two_stage.py b/diff_diff/two_stage.py index 19f1fa560..e448a24b4 100644 --- a/diff_diff/two_stage.py +++ b/diff_diff/two_stage.py @@ -2083,11 +2083,11 @@ def _run_stage( # Construct results self.results_ = TwoStageDiDResults( treatment_effects=treated_df, - overall_att=overall_att, - overall_se=overall_se, - overall_t_stat=overall_t, - overall_p_value=overall_p, - overall_conf_int=overall_ci, + att=overall_att, + se=overall_se, + t_stat=overall_t, + p_value=overall_p, + conf_int=overall_ci, event_study_effects=event_study_effects, group_effects=group_effects, groups=treatment_groups, diff --git a/diff_diff/two_stage_results.py b/diff_diff/two_stage_results.py index 7d7b26ce1..5befa3f12 100644 --- a/diff_diff/two_stage_results.py +++ b/diff_diff/two_stage_results.py @@ -12,6 +12,11 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.aggregation import ( AggregationMixin, AggregationResult, @@ -126,15 +131,15 @@ class TwoStageDiDResults(BaseResults, AggregationMixin): Per-observation treatment effects with columns: unit, time, tau_hat, weight. tau_hat is the residualized outcome y_tilde for treated observations; weight is 1/n_treated. - overall_att : float + att : float Overall average treatment effect on the treated. - overall_se : float + se : float Standard error of overall ATT (GMM sandwich). - overall_t_stat : float + t_stat : float T-statistic for overall ATT. - overall_p_value : float + p_value : float P-value for overall ATT. - overall_conf_int : tuple + conf_int : tuple Confidence interval for overall ATT. event_study_effects : dict, optional Dictionary mapping relative time h to effect dict with keys: @@ -162,11 +167,11 @@ class TwoStageDiDResults(BaseResults, AggregationMixin): """ treatment_effects: pd.DataFrame - overall_att: float - overall_se: float - overall_t_stat: float - overall_p_value: float - overall_conf_int: Tuple[float, float] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] event_study_effects: Optional[Dict[int, Dict[str, Any]]] group_effects: Optional[Dict[Any, Dict[str, Any]]] groups: List[Any] @@ -218,26 +223,37 @@ class TwoStageDiDResults(BaseResults, AggregationMixin): # default ("event_study",) - CS precedent, do not redeclare. _AGGREGATE_SUPPORTED = ("simple", "event_study", "group", "total") - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-053) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "TwoStageDiDResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "TwoStageDiDResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "TwoStageDiDResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "TwoStageDiDResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "TwoStageDiDResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate 3.x pickles written under the ``overall_*`` storage names. + + The canonical quintet became the native fields at the 4.0 cut + (``docs/v4-design.md`` section 5; ``SyntheticDiDResults.__setstate__`` + precedent): legacy keys are rewritten on load so the fields populate + under their canonical names and ``overall_*`` never reappear in + ``__dict__``. + """ + self.__dict__.update(migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET)) # --- Post-fit aggregation (M-022/M-119) ------------------------------ diff --git a/diff_diff/wooldridge.py b/diff_diff/wooldridge.py index 83c88c5f8..43ea381d7 100644 --- a/diff_diff/wooldridge.py +++ b/diff_diff/wooldridge.py @@ -2649,11 +2649,11 @@ def _fit_ols( results = WooldridgeDiDResults( group_time_effects=gt_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"], + att=overall["att"], + se=overall["se"], + t_stat=overall["t_stat"], + p_value=overall["p_value"], + conf_int=overall["conf_int"], method=self.method, control_group=self.control_group, groups=result_groups, @@ -2752,10 +2752,10 @@ def _fit_ols( if boot_atts: boot_se = float(np.std(boot_atts, ddof=1)) t_stat_b, p_b, ci_b = safe_inference(results.att, boot_se, alpha=self.alpha) - results.overall_se = boot_se - results.overall_t_stat = t_stat_b - results.overall_p_value = p_b - results.overall_conf_int = ci_b + results.se = boot_se + results.t_stat = t_stat_b + results.p_value = p_b + results.conf_int = ci_b results._bootstrap_used = True return results @@ -2978,11 +2978,11 @@ def _avg_ax0(a, cell_mask): return WooldridgeDiDResults( group_time_effects=gt_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"], + att=overall["att"], + se=overall["se"], + t_stat=overall["t_stat"], + p_value=overall["p_value"], + conf_int=overall["conf_int"], method=self.method, control_group=self.control_group, groups=_cells_derived_groups(gt_effects), @@ -3242,11 +3242,11 @@ def _avg_ax0(a, cell_mask): return WooldridgeDiDResults( group_time_effects=gt_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"], + att=overall["att"], + se=overall["se"], + t_stat=overall["t_stat"], + p_value=overall["p_value"], + conf_int=overall["conf_int"], method=self.method, control_group=self.control_group, groups=_cells_derived_groups(gt_effects), diff --git a/diff_diff/wooldridge_results.py b/diff_diff/wooldridge_results.py index 6dbc2a1af..df10be0d2 100644 --- a/diff_diff/wooldridge_results.py +++ b/diff_diff/wooldridge_results.py @@ -9,6 +9,11 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import ( + LEGACY_OVERALL_QUINTET, + deprecated_field_property, + migrate_renamed_state_keys, +) from diff_diff.results_base import ( _SUMMARY_ALPHA_MESSAGE, BaseResults, @@ -48,11 +53,11 @@ class WooldridgeDiDResults(BaseResults): # ------------------------------------------------------------------ # # Simple (overall) aggregation — always populated at fit time # # ------------------------------------------------------------------ # - overall_att: float - overall_se: float - overall_t_stat: float - overall_p_value: float - overall_conf_int: Tuple[float, float] + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] # ------------------------------------------------------------------ # # Other aggregations — populated by .aggregate() # @@ -141,8 +146,8 @@ class WooldridgeDiDResults(BaseResults): # aggregation surface so ``summary()`` / ``to_dataframe()`` / # ``__repr__()`` can label each surface correctly under mixed-order # ``aggregate(weights=...)`` calls. Keys: ``"simple"`` (matches the - # ``overall_*`` fields), ``"group"``, ``"calendar"``, ``"event_study"``. - # The fit-time ``overall_*`` is cell-weighted, so ``"simple"`` is + # ``att``/``se``/... fields), ``"group"``, ``"calendar"``, ``"event_study"``. + # The fit-time ``att`` is cell-weighted, so ``"simple"`` is # initialized to ``"cell"`` and only flips after a successful # ``aggregate(type="simple", weights="cohort_share")`` call. The # other keys are populated lazily by ``aggregate()``. Mutation is @@ -240,7 +245,12 @@ def __setstate__(self, state: Dict[str, Any]) -> None: which restores the pre-3.9 normal-theory aggregation for them). ``df_convention`` (added 3.9) defaults to ``"residual"``. Missing ``unsupported_period_action`` defaults to ``"drop"``. + Pickles written before the 4.0 storage flip (``docs/v4-design.md`` + section 5) carry the simple aggregate under ``overall_att`` / + ``overall_se`` / ...; the keys are rewritten to the canonical quintet + BEFORE the update (key renames first, default backfills after). """ + state = migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET) self.__dict__.update(state) # NOTE: check __dict__ membership, not hasattr - dataclass field # defaults live as CLASS attributes, so hasattr is always True. @@ -592,11 +602,11 @@ def _build_effect( ) se = _agg_se(w_vec) eff = _build_effect(att, se, dofs.get("simple")) - self.overall_att = eff["att"] - self.overall_se = eff["se"] - self.overall_t_stat = eff["t_stat"] - self.overall_p_value = eff["p_value"] - self.overall_conf_int = eff["conf_int"] + self.att = eff["att"] + self.se = eff["se"] + self.t_stat = eff["t_stat"] + self.p_value = eff["p_value"] + self.conf_int = eff["conf_int"] # Atomic metadata mutation — only after successful write. self.aggregation_weights["simple"] = weights @@ -935,26 +945,26 @@ def plot_event_study(self, weights: str = "cell", **kwargs) -> None: se = {k: v["se"] for k, v in (self.event_study_effects or {}).items()} plot_event_study(effects=effects, se=se, alpha=self.alpha, **kwargs) - # --- 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 + # --- Deprecated 3.x storage names (ledger row M-056) --- + # FutureWarning from 4.0, removed at 5.0. Plain class attributes (no + # annotation) so they stay descriptors, never dataclass fields - the + # ``shimmed`` field-row shape tests/test_v4_matrix.py asserts + # (the LWDiDResults 3.10-alias precedent). + overall_att = deprecated_field_property( + "WooldridgeDiDResults", "overall_att", "att", removed_in="5.0" + ) + overall_se = deprecated_field_property( + "WooldridgeDiDResults", "overall_se", "se", removed_in="5.0" + ) + overall_t_stat = deprecated_field_property( + "WooldridgeDiDResults", "overall_t_stat", "t_stat", removed_in="5.0" + ) + overall_p_value = deprecated_field_property( + "WooldridgeDiDResults", "overall_p_value", "p_value", removed_in="5.0" + ) + overall_conf_int = deprecated_field_property( + "WooldridgeDiDResults", "overall_conf_int", "conf_int", removed_in="5.0" + ) def __repr__(self) -> str: n_gt = len(self.group_time_effects) diff --git a/docs/api/_autosummary/diff_diff.CallawaySantAnnaResults.rst b/docs/api/_autosummary/diff_diff.CallawaySantAnnaResults.rst index 223336b92..5d7919456 100644 --- a/docs/api/_autosummary/diff_diff.CallawaySantAnnaResults.rst +++ b/docs/api/_autosummary/diff_diff.CallawaySantAnnaResults.rst @@ -29,13 +29,11 @@ ~CallawaySantAnnaResults.allow_unbalanced_panel ~CallawaySantAnnaResults.alpha ~CallawaySantAnnaResults.anticipation - ~CallawaySantAnnaResults.att ~CallawaySantAnnaResults.base_period ~CallawaySantAnnaResults.bootstrap_results ~CallawaySantAnnaResults.cband_crit_value ~CallawaySantAnnaResults.cluster_name ~CallawaySantAnnaResults.coef_var - ~CallawaySantAnnaResults.conf_int ~CallawaySantAnnaResults.control_group ~CallawaySantAnnaResults.df_inference ~CallawaySantAnnaResults.epv_diagnostics @@ -48,23 +46,25 @@ ~CallawaySantAnnaResults.influence_functions ~CallawaySantAnnaResults.is_significant ~CallawaySantAnnaResults.n_clusters - ~CallawaySantAnnaResults.p_value + ~CallawaySantAnnaResults.overall_att + ~CallawaySantAnnaResults.overall_conf_int + ~CallawaySantAnnaResults.overall_p_value + ~CallawaySantAnnaResults.overall_se + ~CallawaySantAnnaResults.overall_t_stat ~CallawaySantAnnaResults.panel ~CallawaySantAnnaResults.pscore_fallback ~CallawaySantAnnaResults.pscore_trim ~CallawaySantAnnaResults.reference_event_times - ~CallawaySantAnnaResults.se ~CallawaySantAnnaResults.significance_stars ~CallawaySantAnnaResults.survey_metadata - ~CallawaySantAnnaResults.t_stat ~CallawaySantAnnaResults.used_rc_on_unbalanced_panel ~CallawaySantAnnaResults.vcov_type ~CallawaySantAnnaResults.group_time_effects - ~CallawaySantAnnaResults.overall_att - ~CallawaySantAnnaResults.overall_se - ~CallawaySantAnnaResults.overall_t_stat - ~CallawaySantAnnaResults.overall_p_value - ~CallawaySantAnnaResults.overall_conf_int + ~CallawaySantAnnaResults.att + ~CallawaySantAnnaResults.se + ~CallawaySantAnnaResults.t_stat + ~CallawaySantAnnaResults.p_value + ~CallawaySantAnnaResults.conf_int ~CallawaySantAnnaResults.groups ~CallawaySantAnnaResults.time_periods ~CallawaySantAnnaResults.n_obs diff --git a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst index 9c89a435c..d940c62bb 100644 --- a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst +++ b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst @@ -27,10 +27,8 @@ ~ChaisemartinDHaultfoeuilleResults.L_max ~ChaisemartinDHaultfoeuilleResults.alpha - ~ChaisemartinDHaultfoeuilleResults.att ~ChaisemartinDHaultfoeuilleResults.bootstrap_results ~ChaisemartinDHaultfoeuilleResults.coef_var - ~ChaisemartinDHaultfoeuilleResults.conf_int ~ChaisemartinDHaultfoeuilleResults.cost_benefit_delta ~ChaisemartinDHaultfoeuilleResults.covariate_residuals ~ChaisemartinDHaultfoeuilleResults.design2_effects @@ -41,28 +39,30 @@ ~ChaisemartinDHaultfoeuilleResults.is_significant ~ChaisemartinDHaultfoeuilleResults.linear_trends_effects ~ChaisemartinDHaultfoeuilleResults.normalized_effects - ~ChaisemartinDHaultfoeuilleResults.p_value + ~ChaisemartinDHaultfoeuilleResults.overall_att + ~ChaisemartinDHaultfoeuilleResults.overall_conf_int + ~ChaisemartinDHaultfoeuilleResults.overall_p_value + ~ChaisemartinDHaultfoeuilleResults.overall_se + ~ChaisemartinDHaultfoeuilleResults.overall_t_stat ~ChaisemartinDHaultfoeuilleResults.path_cumulated_event_study ~ChaisemartinDHaultfoeuilleResults.path_effects ~ChaisemartinDHaultfoeuilleResults.path_heterogeneity_effects ~ChaisemartinDHaultfoeuilleResults.path_placebo_event_study ~ChaisemartinDHaultfoeuilleResults.path_sup_t_bands ~ChaisemartinDHaultfoeuilleResults.placebo_event_study - ~ChaisemartinDHaultfoeuilleResults.se ~ChaisemartinDHaultfoeuilleResults.significance_stars ~ChaisemartinDHaultfoeuilleResults.sup_t_bands ~ChaisemartinDHaultfoeuilleResults.survey_metadata - ~ChaisemartinDHaultfoeuilleResults.t_stat ~ChaisemartinDHaultfoeuilleResults.trends_linear ~ChaisemartinDHaultfoeuilleResults.twfe_beta_fe ~ChaisemartinDHaultfoeuilleResults.twfe_fraction_negative ~ChaisemartinDHaultfoeuilleResults.twfe_sigma_fe ~ChaisemartinDHaultfoeuilleResults.twfe_weights - ~ChaisemartinDHaultfoeuilleResults.overall_att - ~ChaisemartinDHaultfoeuilleResults.overall_se - ~ChaisemartinDHaultfoeuilleResults.overall_t_stat - ~ChaisemartinDHaultfoeuilleResults.overall_p_value - ~ChaisemartinDHaultfoeuilleResults.overall_conf_int + ~ChaisemartinDHaultfoeuilleResults.att + ~ChaisemartinDHaultfoeuilleResults.se + ~ChaisemartinDHaultfoeuilleResults.t_stat + ~ChaisemartinDHaultfoeuilleResults.p_value + ~ChaisemartinDHaultfoeuilleResults.conf_int ~ChaisemartinDHaultfoeuilleResults.joiners_att ~ChaisemartinDHaultfoeuilleResults.joiners_se ~ChaisemartinDHaultfoeuilleResults.joiners_t_stat diff --git a/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst b/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst index de2a56c2a..c38bbeca6 100644 --- a/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.ContinuousDiDResults.rst @@ -27,11 +27,9 @@ ~ContinuousDiDResults.alpha ~ContinuousDiDResults.anticipation - ~ContinuousDiDResults.att ~ContinuousDiDResults.base_period ~ContinuousDiDResults.bootstrap_weights ~ContinuousDiDResults.coef_var - ~ContinuousDiDResults.conf_int ~ContinuousDiDResults.control_group ~ContinuousDiDResults.covariates ~ContinuousDiDResults.degree @@ -42,28 +40,30 @@ ~ContinuousDiDResults.is_significant ~ContinuousDiDResults.n_bootstrap ~ContinuousDiDResults.num_knots + ~ContinuousDiDResults.overall_att + ~ContinuousDiDResults.overall_att_conf_int + ~ContinuousDiDResults.overall_att_p_value + ~ContinuousDiDResults.overall_att_se + ~ContinuousDiDResults.overall_att_t_stat ~ContinuousDiDResults.overall_conf_int ~ContinuousDiDResults.overall_p_value ~ContinuousDiDResults.overall_se ~ContinuousDiDResults.overall_t_stat - ~ContinuousDiDResults.p_value ~ContinuousDiDResults.pscore_fallback ~ContinuousDiDResults.pscore_trim ~ContinuousDiDResults.rank_deficient_action ~ContinuousDiDResults.reference_dose - ~ContinuousDiDResults.se ~ContinuousDiDResults.seed ~ContinuousDiDResults.significance_stars ~ContinuousDiDResults.survey_metadata - ~ContinuousDiDResults.t_stat ~ContinuousDiDResults.treatment_type ~ContinuousDiDResults.dose_response_att ~ContinuousDiDResults.dose_response_acrt - ~ContinuousDiDResults.overall_att - ~ContinuousDiDResults.overall_att_se - ~ContinuousDiDResults.overall_att_t_stat - ~ContinuousDiDResults.overall_att_p_value - ~ContinuousDiDResults.overall_att_conf_int + ~ContinuousDiDResults.att + ~ContinuousDiDResults.se + ~ContinuousDiDResults.t_stat + ~ContinuousDiDResults.p_value + ~ContinuousDiDResults.conf_int ~ContinuousDiDResults.overall_acrt ~ContinuousDiDResults.overall_acrt_se ~ContinuousDiDResults.overall_acrt_t_stat diff --git a/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst b/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst index 9583ee34b..2a363136c 100644 --- a/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.EfficientDiDResults.rst @@ -27,12 +27,10 @@ ~EfficientDiDResults.alpha ~EfficientDiDResults.anticipation - ~EfficientDiDResults.att ~EfficientDiDResults.bootstrap_results ~EfficientDiDResults.bootstrap_weights ~EfficientDiDResults.cluster_name ~EfficientDiDResults.coef_var - ~EfficientDiDResults.conf_int ~EfficientDiDResults.control_group ~EfficientDiDResults.efficient_weights ~EfficientDiDResults.estimation_path @@ -46,24 +44,26 @@ ~EfficientDiDResults.n_clusters ~EfficientDiDResults.omega_condition_numbers ~EfficientDiDResults.omega_ridge - ~EfficientDiDResults.p_value + ~EfficientDiDResults.overall_att + ~EfficientDiDResults.overall_conf_int + ~EfficientDiDResults.overall_p_value + ~EfficientDiDResults.overall_se + ~EfficientDiDResults.overall_t_stat ~EfficientDiDResults.pt_assumption ~EfficientDiDResults.ratio_clip ~EfficientDiDResults.reference_period - ~EfficientDiDResults.se ~EfficientDiDResults.seed ~EfficientDiDResults.sieve_criterion ~EfficientDiDResults.sieve_k_max ~EfficientDiDResults.significance_stars ~EfficientDiDResults.survey_metadata - ~EfficientDiDResults.t_stat ~EfficientDiDResults.vcov_type ~EfficientDiDResults.group_time_effects - ~EfficientDiDResults.overall_att - ~EfficientDiDResults.overall_se - ~EfficientDiDResults.overall_t_stat - ~EfficientDiDResults.overall_p_value - ~EfficientDiDResults.overall_conf_int + ~EfficientDiDResults.att + ~EfficientDiDResults.se + ~EfficientDiDResults.t_stat + ~EfficientDiDResults.p_value + ~EfficientDiDResults.conf_int ~EfficientDiDResults.groups ~EfficientDiDResults.time_periods ~EfficientDiDResults.n_obs diff --git a/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst b/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst index c0a88fd15..3c2bd06db 100644 --- a/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst @@ -28,29 +28,29 @@ ~ImputationDiDResults.alpha ~ImputationDiDResults.anticipation - ~ImputationDiDResults.att ~ImputationDiDResults.bootstrap_results ~ImputationDiDResults.cluster_name ~ImputationDiDResults.coef_var - ~ImputationDiDResults.conf_int ~ImputationDiDResults.df_convention ~ImputationDiDResults.event_study_df ~ImputationDiDResults.is_significant ~ImputationDiDResults.leave_one_out ~ImputationDiDResults.n_clusters - ~ImputationDiDResults.p_value + ~ImputationDiDResults.overall_att + ~ImputationDiDResults.overall_conf_int + ~ImputationDiDResults.overall_p_value + ~ImputationDiDResults.overall_se + ~ImputationDiDResults.overall_t_stat ~ImputationDiDResults.pretrend_results - ~ImputationDiDResults.se ~ImputationDiDResults.significance_stars ~ImputationDiDResults.survey_metadata - ~ImputationDiDResults.t_stat ~ImputationDiDResults.vcov_type ~ImputationDiDResults.treatment_effects - ~ImputationDiDResults.overall_att - ~ImputationDiDResults.overall_se - ~ImputationDiDResults.overall_t_stat - ~ImputationDiDResults.overall_p_value - ~ImputationDiDResults.overall_conf_int + ~ImputationDiDResults.att + ~ImputationDiDResults.se + ~ImputationDiDResults.t_stat + ~ImputationDiDResults.p_value + ~ImputationDiDResults.conf_int ~ImputationDiDResults.event_study_effects ~ImputationDiDResults.group_effects ~ImputationDiDResults.groups diff --git a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst index 956982b57..d1733ae5c 100644 --- a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst @@ -27,13 +27,11 @@ ~StackedDiDResults.alpha ~StackedDiDResults.anticipation - ~StackedDiDResults.att ~StackedDiDResults.balance ~StackedDiDResults.balance_diagnostics ~StackedDiDResults.base_period ~StackedDiDResults.cluster_name ~StackedDiDResults.coef_var - ~StackedDiDResults.conf_int ~StackedDiDResults.control_group ~StackedDiDResults.covariates ~StackedDiDResults.df_convention @@ -50,19 +48,21 @@ ~StackedDiDResults.n_stacked_obs ~StackedDiDResults.n_sub_experiments ~StackedDiDResults.n_treated_units - ~StackedDiDResults.p_value + ~StackedDiDResults.overall_att + ~StackedDiDResults.overall_conf_int + ~StackedDiDResults.overall_p_value + ~StackedDiDResults.overall_se + ~StackedDiDResults.overall_t_stat ~StackedDiDResults.reference_event_times - ~StackedDiDResults.se ~StackedDiDResults.significance_stars ~StackedDiDResults.survey_metadata - ~StackedDiDResults.t_stat ~StackedDiDResults.vcov_type ~StackedDiDResults.weighting - ~StackedDiDResults.overall_att - ~StackedDiDResults.overall_se - ~StackedDiDResults.overall_t_stat - ~StackedDiDResults.overall_p_value - ~StackedDiDResults.overall_conf_int + ~StackedDiDResults.att + ~StackedDiDResults.se + ~StackedDiDResults.t_stat + ~StackedDiDResults.p_value + ~StackedDiDResults.conf_int ~StackedDiDResults.event_study_effects ~StackedDiDResults.group_effects ~StackedDiDResults.stacked_data diff --git a/docs/api/_autosummary/diff_diff.SunAbrahamResults.rst b/docs/api/_autosummary/diff_diff.SunAbrahamResults.rst index 66b12d8ef..dde7c1698 100644 --- a/docs/api/_autosummary/diff_diff.SunAbrahamResults.rst +++ b/docs/api/_autosummary/diff_diff.SunAbrahamResults.rst @@ -26,12 +26,10 @@ ~SunAbrahamResults.alpha ~SunAbrahamResults.anticipation - ~SunAbrahamResults.att ~SunAbrahamResults.bootstrap_results ~SunAbrahamResults.cluster_name ~SunAbrahamResults.coef_var ~SunAbrahamResults.cohort_effects - ~SunAbrahamResults.conf_int ~SunAbrahamResults.conley_lag_cutoff ~SunAbrahamResults.control_group ~SunAbrahamResults.df_convention @@ -40,20 +38,22 @@ ~SunAbrahamResults.event_study_vcov_index ~SunAbrahamResults.inference_df ~SunAbrahamResults.is_significant - ~SunAbrahamResults.p_value + ~SunAbrahamResults.overall_att + ~SunAbrahamResults.overall_conf_int + ~SunAbrahamResults.overall_p_value + ~SunAbrahamResults.overall_se + ~SunAbrahamResults.overall_t_stat ~SunAbrahamResults.reference_observed ~SunAbrahamResults.reference_period - ~SunAbrahamResults.se ~SunAbrahamResults.significance_stars ~SunAbrahamResults.survey_metadata - ~SunAbrahamResults.t_stat ~SunAbrahamResults.vcov_type ~SunAbrahamResults.event_study_effects - ~SunAbrahamResults.overall_att - ~SunAbrahamResults.overall_se - ~SunAbrahamResults.overall_t_stat - ~SunAbrahamResults.overall_p_value - ~SunAbrahamResults.overall_conf_int + ~SunAbrahamResults.att + ~SunAbrahamResults.se + ~SunAbrahamResults.t_stat + ~SunAbrahamResults.p_value + ~SunAbrahamResults.conf_int ~SunAbrahamResults.cohort_weights ~SunAbrahamResults.groups ~SunAbrahamResults.time_periods diff --git a/docs/api/_autosummary/diff_diff.TwoStageDiDResults.rst b/docs/api/_autosummary/diff_diff.TwoStageDiDResults.rst index 95b601710..ae5a74857 100644 --- a/docs/api/_autosummary/diff_diff.TwoStageDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.TwoStageDiDResults.rst @@ -27,28 +27,28 @@ ~TwoStageDiDResults.alpha ~TwoStageDiDResults.anticipation - ~TwoStageDiDResults.att ~TwoStageDiDResults.bootstrap_results ~TwoStageDiDResults.cluster_name ~TwoStageDiDResults.coef_var - ~TwoStageDiDResults.conf_int ~TwoStageDiDResults.event_study_df ~TwoStageDiDResults.event_study_vcov ~TwoStageDiDResults.event_study_vcov_index ~TwoStageDiDResults.is_significant ~TwoStageDiDResults.n_clusters - ~TwoStageDiDResults.p_value - ~TwoStageDiDResults.se + ~TwoStageDiDResults.overall_att + ~TwoStageDiDResults.overall_conf_int + ~TwoStageDiDResults.overall_p_value + ~TwoStageDiDResults.overall_se + ~TwoStageDiDResults.overall_t_stat ~TwoStageDiDResults.significance_stars ~TwoStageDiDResults.survey_metadata - ~TwoStageDiDResults.t_stat ~TwoStageDiDResults.vcov_type ~TwoStageDiDResults.treatment_effects - ~TwoStageDiDResults.overall_att - ~TwoStageDiDResults.overall_se - ~TwoStageDiDResults.overall_t_stat - ~TwoStageDiDResults.overall_p_value - ~TwoStageDiDResults.overall_conf_int + ~TwoStageDiDResults.att + ~TwoStageDiDResults.se + ~TwoStageDiDResults.t_stat + ~TwoStageDiDResults.p_value + ~TwoStageDiDResults.conf_int ~TwoStageDiDResults.event_study_effects ~TwoStageDiDResults.group_effects ~TwoStageDiDResults.groups diff --git a/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst b/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst index 434ec0025..899b96cd1 100644 --- a/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst @@ -30,7 +30,6 @@ ~DMLDiDResults.allow_unbalanced_panel ~DMLDiDResults.alpha ~DMLDiDResults.anticipation - ~DMLDiDResults.att ~DMLDiDResults.bad_control ~DMLDiDResults.bad_control_covariates ~DMLDiDResults.bad_control_diagnostics @@ -41,7 +40,6 @@ ~DMLDiDResults.cband_crit_value ~DMLDiDResults.cluster_name ~DMLDiDResults.coef_var - ~DMLDiDResults.conf_int ~DMLDiDResults.control_group ~DMLDiDResults.cross_fit_diagnostics ~DMLDiDResults.df_inference @@ -59,25 +57,27 @@ ~DMLDiDResults.n_clusters ~DMLDiDResults.n_folds ~DMLDiDResults.outcome_learner - ~DMLDiDResults.p_value + ~DMLDiDResults.overall_att + ~DMLDiDResults.overall_conf_int + ~DMLDiDResults.overall_p_value + ~DMLDiDResults.overall_se + ~DMLDiDResults.overall_t_stat ~DMLDiDResults.panel ~DMLDiDResults.propensity_learner ~DMLDiDResults.pscore_fallback ~DMLDiDResults.pscore_trim ~DMLDiDResults.reference_event_times - ~DMLDiDResults.se ~DMLDiDResults.seed ~DMLDiDResults.significance_stars ~DMLDiDResults.survey_metadata - ~DMLDiDResults.t_stat ~DMLDiDResults.used_rc_on_unbalanced_panel ~DMLDiDResults.vcov_type ~DMLDiDResults.group_time_effects - ~DMLDiDResults.overall_att - ~DMLDiDResults.overall_se - ~DMLDiDResults.overall_t_stat - ~DMLDiDResults.overall_p_value - ~DMLDiDResults.overall_conf_int + ~DMLDiDResults.att + ~DMLDiDResults.se + ~DMLDiDResults.t_stat + ~DMLDiDResults.p_value + ~DMLDiDResults.conf_int ~DMLDiDResults.groups ~DMLDiDResults.time_periods ~DMLDiDResults.n_obs diff --git a/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst b/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst index 246f38ac3..c1f799096 100644 --- a/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst @@ -27,11 +27,9 @@ ~WooldridgeDiDResults.alpha ~WooldridgeDiDResults.anticipation - ~WooldridgeDiDResults.att ~WooldridgeDiDResults.calendar_effects ~WooldridgeDiDResults.cluster_name ~WooldridgeDiDResults.cohort_trends - ~WooldridgeDiDResults.conf_int ~WooldridgeDiDResults.conley_lag_cutoff ~WooldridgeDiDResults.control_group ~WooldridgeDiDResults.df_convention @@ -42,18 +40,20 @@ ~WooldridgeDiDResults.n_control_units ~WooldridgeDiDResults.n_obs ~WooldridgeDiDResults.n_treated_units - ~WooldridgeDiDResults.p_value - ~WooldridgeDiDResults.se + ~WooldridgeDiDResults.overall_att + ~WooldridgeDiDResults.overall_conf_int + ~WooldridgeDiDResults.overall_p_value + ~WooldridgeDiDResults.overall_se + ~WooldridgeDiDResults.overall_t_stat ~WooldridgeDiDResults.survey_metadata - ~WooldridgeDiDResults.t_stat ~WooldridgeDiDResults.unsupported_period_action ~WooldridgeDiDResults.vcov_type ~WooldridgeDiDResults.group_time_effects - ~WooldridgeDiDResults.overall_att - ~WooldridgeDiDResults.overall_se - ~WooldridgeDiDResults.overall_t_stat - ~WooldridgeDiDResults.overall_p_value - ~WooldridgeDiDResults.overall_conf_int + ~WooldridgeDiDResults.att + ~WooldridgeDiDResults.se + ~WooldridgeDiDResults.t_stat + ~WooldridgeDiDResults.p_value + ~WooldridgeDiDResults.conf_int ~WooldridgeDiDResults.groups ~WooldridgeDiDResults.time_periods ~WooldridgeDiDResults.cohort_trend_coefs diff --git a/docs/api/continuous_did.rst b/docs/api/continuous_did.rst index 9a5f4a1d1..1447c738b 100644 --- a/docs/api/continuous_did.rst +++ b/docs/api/continuous_did.rst @@ -17,7 +17,7 @@ This module implements the methodology from Callaway, Goodman-Bacon & Sant'Anna as well as ATT\ :sup:`glob` and ACRT\ :sup:`glob`, require the **Strong Parallel Trends (SPT)** assumption — that there is no selection into dose groups based on treatment effects. Under the weaker standard Parallel Trends (PT) assumption, - only the binarized ATT\ :sup:`loc` (``overall_att``) is identified; it equals + only the binarized ATT\ :sup:`loc` (``att``) is identified; it equals ATT\ :sup:`glob` only when SPT holds. See Callaway, Goodman-Bacon & Sant'Anna (2024), Assumptions 1–2. diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 10aa23534..e6d8273ae 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -2,7 +2,9 @@ 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.** Headline scalar inference fields appear under one of three native naming patterns: flat `att` / `se` / `conf_int` / `p_value` / `t_stat` (`DiDResults`, `SyntheticDiDResults`, `TROPResults`, `TripleDifferenceResults`, `HeterogeneousAdoptionDiDResults`, `ChangesInChangesResults`); `overall_*` (`CallawaySantAnnaResults` and the rest of the staggered family); and `overall_att_*` (`ContinuousDiDResults`, where `att` and `acrt` are parallel response curves) - the fourth pattern, `avg_*`, left with the removed `MultiPeriodDiDResults` at 4.0 (the TWFE event-study surface carries no post-period-average scalar). Result classes in the prefixed `overall_*` / `overall_att_*` families additionally expose the flat `att` / `se` / `conf_int` / `p_value` / `t_stat` names as read-only `@property` aliases over their canonical fields, for adapter / external-consumer compatibility (see PR for v3.3.3, motivated by `balance.interop.diff_diff`). The flat-native classes (`DiDResults`, `SyntheticDiDResults`, `TROPResults`, `TripleDifferenceResults`, `HeterogeneousAdoptionDiDResults`, `ChangesInChangesResults`) already carry these names as native dataclass fields and are unchanged by this contract. `ContinuousDiDResults` further exposes `overall_*` aliases pointing at the ATT side (so `result.overall_se` reads `result.overall_att_se`, etc.). The native field is canonical for documentation, semantics, and computation — aliases are pure read-throughs and inherit the `safe_inference()` joint-NaN consistency contract automatically. Because aliases are `@property` descriptors (not dataclass fields), they do NOT appear in `dataclasses.fields()` or `dataclasses.asdict()` output, and assignment to an alias raises `AttributeError`; serializers and field-walkers continue to see only the canonical field set. +**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. + +- **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. ## Table of Contents @@ -1411,7 +1413,7 @@ The per-side terms are the code's `placebo_plus_t = stable0_avg - joiner_avg` an *Phase 2: Multi-horizon event study (Equation 3 and 5 of the dynamic companion paper):* -When `L_max >= 1`, the estimator computes the per-group building block `DID_{g,l}` and the aggregate `DID_l` for each horizon. When `L_max=1`, `overall_att` holds `DID_1` (the per-group estimand, not the per-period `DID_M`). When `L_max >= 2`, `overall_att` holds the cost-benefit delta. When `L_max=None`, the per-period `DID_M` path is used: +When `L_max >= 1`, the estimator computes the per-group building block `DID_{g,l}` and the aggregate `DID_l` for each horizon. When `L_max=1`, `att` holds `DID_1` (the per-group estimand, not the per-period `DID_M`). When `L_max >= 2`, `att` holds the cost-benefit delta. When `L_max=None`, the per-period `DID_M` path is used: ``` DID_{g,l} = Y_{g, F_g-1+l} - Y_{g, F_g-1} @@ -1423,7 +1425,7 @@ DID_l = (1/N_l) * sum_{g: F_g-1+l <= T_g} S_g * DID_{g,l} Normalized estimator `DID^n_l = DID_l / delta^D_l` where `delta^D_l = (1/N_l) * sum |delta^D_{g,l}|` and `delta^D_{g,l} = sum_{k=0}^{l-1} (D_{g,F_g+k} - D_{g,1})`. For binary treatment: `DID^n_l = DID_l / l`. -Cost-benefit aggregate `delta = sum_l w_l * DID_l` (Lemma 4) where `w_l` are non-negative weights reflecting the cumulative dose at each horizon. When `L_max > 1`, `overall_att` holds this delta. +Cost-benefit aggregate `delta = sum_l w_l * DID_l` (Lemma 4) where `w_l` are non-negative weights reflecting the cumulative dose at each horizon. When `L_max > 1`, `att` holds this delta. Dynamic placebos `DID^{pl}_l` look backward from each group's reference period, with a dual eligibility condition: `F_g - 1 - l >= 1` AND `F_g - 1 + l <= T_g`. @@ -1435,7 +1437,7 @@ Dynamic placebos `DID^{pl}_l` look backward from each group's reference period, - **Note (Phase 2 Assumption 7 and cost-benefit delta):** Assumption 7 (`D_{g,t} >= D_{g,1}`) is required for the single-sign cost-benefit interpretation. When leavers are present (binary: 1->0 groups violate Assumption 7), the estimator emits a `UserWarning` and provides `delta_joiners` / `delta_leavers` separately on `results.cost_benefit_delta`. -- **Note (Phase 2 cost-benefit delta SE):** When `L_max >= 2`, `overall_att` holds the cost-benefit `delta`. Its SE is computed via the delta method from per-horizon SEs: `SE(delta) = sqrt(sum w_l^2 * SE(DID_l)^2)`, treating horizons as independent (conservative under Assumption 8). When bootstrap is enabled, per-horizon bootstrap SEs flow through the delta-method formula, so `overall_se` reflects bootstrap-derived per-horizon uncertainty but the delta aggregation itself uses normal-theory (not bootstrap percentile). This is an intentional exception to the general bootstrap-inference-surface contract: `overall_p_value` and `overall_conf_int` for `delta` use `safe_inference(delta, delta_se)`, not percentile bootstrap, because the delta is a derived aggregate rather than a directly bootstrapped estimand. +- **Note (Phase 2 cost-benefit delta SE):** When `L_max >= 2`, `att` holds the cost-benefit `delta`. Its SE is computed via the delta method from per-horizon SEs: `SE(delta) = sqrt(sum w_l^2 * SE(DID_l)^2)`, treating horizons as independent (conservative under Assumption 8). When bootstrap is enabled, per-horizon bootstrap SEs flow through the delta-method formula, so `se` reflects bootstrap-derived per-horizon uncertainty but the delta aggregation itself uses normal-theory (not bootstrap percentile). This is an intentional exception to the general bootstrap-inference-surface contract: `p_value` and `conf_int` for `delta` use `safe_inference(delta, delta_se)`, not percentile bootstrap, because the delta is a derived aggregate rather than a directly bootstrapped estimand. - **Note (post-fit `aggregate()` is a view - row M-026):** `results.aggregate('event_study')` and `results.aggregate('simple')` are pure VIEWS over stored fields - nothing is recomputed. `'event_study'` returns the unified `EventStudyResults` container (Phase-1 `L_max=None` fits: the 2-row l=1 view; `L_max >= 1`: the multi-horizon `l1_first_switch` surface); `'simple'` a one-row `AggregationResult` relaying `overall_att/se/t/p/CI` bit-exactly with the estimand-aware `target` label (DID_M / DID_1 / delta, or the trends-linear first-difference label whose overall row is all-NaN by design). Because it is a view, BOOTSTRAP FITS ARE PERMITTED - the library-wide per-level relay rule (since M-027 the recompute adopters' 'simple' relays are permitted on bootstrap fits too; their kit-based recompute levels REPLAY the fit-time multiplier bootstrap on CallawaySantAnna, DMLDiD (via the inherited CS kit channel), and EfficientDiD, while the three hybrids ImputationDiD/TwoStageDiD/ContinuousDiD RELAY the percentile surface materialized at fit since the fit-time keyword was removed at 4.0 (PR 3c) - a recompute must never silently substitute analytical inference); here each row relays exactly the inference the fit stored, including the delta's analytical-with-df numbers under bootstrap per the `Note (Phase 2 cost-benefit delta SE)` above (the view's `df` column resolves from the actual inference path, not the bootstrap-cleared `event_study_df` channel). The dCDH event-study CONTAINER is deliberately rejected by `compute_honest_did`/`compute_pretrends_power` - its l1 placebo semantics need HonestDiD's native dCDH branch (mandatory reinterpretation warning + horizon trimming); pass the results object itself. @@ -1487,13 +1489,13 @@ Alternative: Multiplier bootstrap clustered at group via the `n_bootstrap` param - **Note (cluster contract):** `ChaisemartinDHaultfoeuille` clusters at the group level by default. The analytical SE plug-in operates on per-group influence-function values (one `U^G_g` per group) and, under the cell-period allocator, on their per-cell decomposition `U[g, t]` which telescopes back to `U^G_g` at the PSU-level sum. The multiplier bootstrap generates one weight per group. The user-facing `cluster=` kwarg is not supported: the constructor accepts `cluster=None` (the default and only supported value); passing any non-`None` value raises `NotImplementedError` at construction time (and the same gate fires from `set_params`) — custom user-specified clustering is reserved for a future phase. **Automatic PSU-level clustering under `survey_design`:** the analytical TSL path supports PSU labels that vary across cells of a group (within-cell constancy required); the multiplier bootstrap supports the same regime via the cell-level wild PSU bootstrap documented in the survey + bootstrap contract Note below. Under PSU-within-group-constant regimes (including the default auto-inject `psu=group` and strictly-coarser PSU with within-group constancy), the bootstrap dispatcher routes through the legacy group-level path so the SE is bit-identical to pre-cell-level releases via the identity-map fast path. The matching test for the `cluster=` gate is `test_cluster_parameter_raises_not_implemented` in `tests/test_chaisemartin_dhaultfoeuille.py::TestForwardCompatGates`. -- **Note (bootstrap inference surface):** When `n_bootstrap > 0`, the top-level `results.p_value` / `results.conf_int` (and joiners/leavers analogues) hold **percentile-based bootstrap inference** computed by the multiplier bootstrap, NOT normal-theory recomputations from the bootstrap SE. The t-stat (`overall_t_stat`, etc.) is computed from the SE via `safe_inference()[0]` to satisfy the project's anti-pattern rule (never compute `t = effect / se` inline) — bootstrap does not define an alternative t-stat semantic for percentile bootstrap, so the SE-based t-stat is the natural choice. `event_study_effects[1]`, `summary()`, `to_dataframe()`, `is_significant`, and `significance_stars` all read from these top-level fields and therefore reflect the bootstrap inference automatically. The library precedent for this propagation is `imputation.py:790-805`, `two_stage.py:778-787`, and `efficient_did.py:1009-1013`. The single-period placebo (`L_max=None`) still has NaN bootstrap fields; multi-horizon placebos (`L_max >= 1`) have valid bootstrap SE/CI/p via `placebo_horizon_ses/cis/p_values` on the bootstrap results object. The matching test is `test_bootstrap_p_value_and_ci_propagated_to_top_level` in `tests/test_chaisemartin_dhaultfoeuille.py::TestBootstrap`. +- **Note (bootstrap inference surface):** When `n_bootstrap > 0`, the top-level `results.p_value` / `results.conf_int` (and joiners/leavers analogues) hold **percentile-based bootstrap inference** computed by the multiplier bootstrap, NOT normal-theory recomputations from the bootstrap SE. The t-stat (`t_stat`, etc.) is computed from the SE via `safe_inference()[0]` to satisfy the project's anti-pattern rule (never compute `t = effect / se` inline) — bootstrap does not define an alternative t-stat semantic for percentile bootstrap, so the SE-based t-stat is the natural choice. `event_study_effects[1]`, `summary()`, `to_dataframe()`, `is_significant`, and `significance_stars` all read from these top-level fields and therefore reflect the bootstrap inference automatically. The library precedent for this propagation is `imputation.py:790-805`, `two_stage.py:778-787`, and `efficient_did.py:1009-1013`. The single-period placebo (`L_max=None`) still has NaN bootstrap fields; multi-horizon placebos (`L_max >= 1`) have valid bootstrap SE/CI/p via `placebo_horizon_ses/cis/p_values` on the bootstrap results object. The matching test is `test_bootstrap_p_value_and_ci_propagated_to_top_level` in `tests/test_chaisemartin_dhaultfoeuille.py::TestBootstrap`. - **Note:** Placebo Assumption 11 violations (placebo joiners exist but no 3-period stable_0 controls, or symmetric for leavers/stable_1) trigger zero-retention in the placebo numerator AND emit a consolidated `Placebo (DID_M^pl) Assumption 11 violations` warning from `fit()`, mirroring the main DID path's contract documented above. The zeroed placebo periods retain their switcher counts in the placebo `N_S^pl` denominator, biasing `DID_M^pl` toward zero in the offending direction (matching the placebo paper convention). - **Note:** The TWFE diagnostic (`twfe_diagnostic=True` in `fit()` and the standalone `twowayfeweights()`) requires binary `{0, 1}` treatment. On non-binary data, `fit()` emits a `UserWarning` and skips the diagnostic (all `twfe_*` fields are `None`), while `twowayfeweights()` raises `ValueError`. The diagnostic uses `d_gt == 1` as the treated-cell mask per Theorem 1 of AER 2020, which is undefined for non-binary treatment. -- **Note (TWFE diagnostic sample contract):** The fitted `results.twfe_weights` / `results.twfe_fraction_negative` / `results.twfe_sigma_fe` / `results.twfe_beta_fe` are computed on the **FULL pre-filter cell sample** — the data the user passed in, after `_validate_and_aggregate_to_cells()` runs but **before** the ragged-panel validation (Step 5b) and the multi-switch filter (`drop_larger_lower`, Step 6). They do NOT describe the post-filter estimation sample used by `overall_att`, `results.units`, and the inference fields. `fit()` has three sample-shaping filters in total: (1) interior-gap drops in Step 5b, (2) multi-switch drops in Step 6, and (3) the singleton-baseline filter in Step 7. Filters (1) and (2) actually shrink the point-estimate sample, so when either fires, the fitted TWFE diagnostic and `overall_att` describe **different samples** and the estimator emits a `UserWarning` explaining the divergence with explicit counts. Filter (3) is **variance-only** — singleton-baseline groups remain in the point-estimate sample as period-based stable controls (see the singleton-baseline Note above) — so it does NOT create a fitted-vs-`overall_att` mismatch and does NOT trigger the divergence warning. Rationale for the pre-filter design: the TWFE diagnostic answers "what would the plain TWFE estimator say on the data you passed in?" — not "what would TWFE say on the data dCDH actually used after filtering?" — so users comparing TWFE vs dCDH on a fixed input can do so without an interaction effect from the dCDH-specific filters. The standalone `twowayfeweights()` function uses the same pre-filter sample and accepts the same `survey_design` parameter as `fit()`, so the fitted and standalone APIs always produce identical numbers on the same input — including survey-weighted cell aggregation (`twowayfeweights(data, ..., survey_design=sd)` matches `fit(data, ..., survey_design=sd).twfe_*`). To reproduce the dCDH estimation sample for an external TWFE comparison, pre-process your data to drop the multi-switch and interior-gap groups before fitting (the warning lists offending IDs). The matching tests are `test_twfe_pre_filter_contract_with_interior_gap_drop` and `test_twfe_pre_filter_contract_with_multi_switch_drop` in `tests/test_chaisemartin_dhaultfoeuille.py`. +- **Note (TWFE diagnostic sample contract):** The fitted `results.twfe_weights` / `results.twfe_fraction_negative` / `results.twfe_sigma_fe` / `results.twfe_beta_fe` are computed on the **FULL pre-filter cell sample** — the data the user passed in, after `_validate_and_aggregate_to_cells()` runs but **before** the ragged-panel validation (Step 5b) and the multi-switch filter (`drop_larger_lower`, Step 6). They do NOT describe the post-filter estimation sample used by `att`, `results.units`, and the inference fields. `fit()` has three sample-shaping filters in total: (1) interior-gap drops in Step 5b, (2) multi-switch drops in Step 6, and (3) the singleton-baseline filter in Step 7. Filters (1) and (2) actually shrink the point-estimate sample, so when either fires, the fitted TWFE diagnostic and `att` describe **different samples** and the estimator emits a `UserWarning` explaining the divergence with explicit counts. Filter (3) is **variance-only** — singleton-baseline groups remain in the point-estimate sample as period-based stable controls (see the singleton-baseline Note above) — so it does NOT create a fitted-vs-`att` mismatch and does NOT trigger the divergence warning. Rationale for the pre-filter design: the TWFE diagnostic answers "what would the plain TWFE estimator say on the data you passed in?" — not "what would TWFE say on the data dCDH actually used after filtering?" — so users comparing TWFE vs dCDH on a fixed input can do so without an interaction effect from the dCDH-specific filters. The standalone `twowayfeweights()` function uses the same pre-filter sample and accepts the same `survey_design` parameter as `fit()`, so the fitted and standalone APIs always produce identical numbers on the same input — including survey-weighted cell aggregation (`twowayfeweights(data, ..., survey_design=sd)` matches `fit(data, ..., survey_design=sd).twfe_*`). To reproduce the dCDH estimation sample for an external TWFE comparison, pre-process your data to drop the multi-switch and interior-gap groups before fitting (the warning lists offending IDs). The matching tests are `test_twfe_pre_filter_contract_with_interior_gap_drop` and `test_twfe_pre_filter_contract_with_multi_switch_drop` in `tests/test_chaisemartin_dhaultfoeuille.py`. - **Note:** By default (`drop_larger_lower=True`), the estimator drops groups whose treatment switches more than once before estimation. This matches R `DIDmultiplegtDYN`'s default and is required for the analytical variance formula (Web Appendix Section 3.7.3 of the dynamic paper, which assumes Assumption 5 / no-crossing) to be consistent with the AER 2020 Theorem 3 point estimate. Both formulas operate on the same post-drop dataset. Setting `drop_larger_lower=False` is supported for diagnostic comparison but produces an inconsistent estimator-variance pairing for any multi-switch groups present, and emits an explicit warning. @@ -1510,7 +1512,7 @@ Alternative: Multiplier bootstrap clustered at group via the `n_bootstrap` param The guard is fired by `_survey_se_from_group_if` (analytical and replicate) and by `_unroll_target_to_cells` (bootstrap). **Unaffected paths**: Binder TSL under PSU-within-group-constant regimes (including PSU=group auto-inject) falls back to the legacy group-level allocator where the row-sum identity `sum_{c in g} U_centered_per_period[g, t] == U_centered[g]` makes the two statistically equivalent, and the bootstrap dispatcher routes the same regimes through the legacy group-level path. **Workaround:** pre-process the panel to remove terminal missingness (drop late-exit groups or trim to a balanced sub-panel). For Binder TSL, using an explicit `psu=` routes through the legacy group allocator. For replicate ATT and within-group-varying-PSU bootstrap, there is no allocator fallback — the panel itself must be pre-processed. The broader unbalanced-panel workaround (back-fill the baseline or drop late-entry groups before fitting, or use R `DIDmultiplegtDYN`) also applies. The Step 5b `ValueError` and `UserWarning` messages name the offending group IDs so you can locate them quickly. -- **Note (Phase 3 DID^X covariate adjustment):** When `covariates` is set, `per_period_effects` (the Phase 1 per-period DID_M decomposition) remains **unadjusted** (computed on raw outcomes). The covariate residualization applies only to the per-group `DID_{g,l}` path (`L_max >= 1`), which produces `event_study_effects` and `overall_att`. This means `per_period_effects` and `event_study_effects[1]` may diverge when covariates are active - by design (the per-period path uses binary joiner/leaver categorization and is not part of the DID^X contract). Implements the residualization-style covariate adjustment from Web Appendix Section 1.2 (Assumption 11). For each baseline treatment value `d`, estimates `theta_hat_d` via OLS of first-differenced outcomes on first-differenced covariates with time FEs, restricted to not-yet-treated observations. Residualizes at levels: `Y_tilde[g,t] = Y[g,t] - X[g,t] @ theta_hat_d`. All downstream DID computations use residualized outcomes. This is NOT doubly-robust, NOT IPW, NOT Callaway-Sant'Anna-style. Plug-in IF (treating `theta_hat` as fixed) is valid by FWL theorem. **Deviation from R `DIDmultiplegtDYN`:** The first-stage OLS uses equal cell weights (one observation per `(g,t)` cell), consistent with the library's cell-count weighting convention documented in Phase 1. R weights by `N_gt` (observation count per cell). On panels with 1 observation per cell (the common case), results are identical. When baseline-specific first stages fail (`n_obs = 0` or `n_obs < n_params`), the affected strata are excluded from the estimation (outcomes set to NaN) rather than retained unadjusted - matching R's "drop failed strata" behavior. Requires `L_max >= 1`. Activated via `covariates=["col1", "col2"]` in `fit()`. +- **Note (Phase 3 DID^X covariate adjustment):** When `covariates` is set, `per_period_effects` (the Phase 1 per-period DID_M decomposition) remains **unadjusted** (computed on raw outcomes). The covariate residualization applies only to the per-group `DID_{g,l}` path (`L_max >= 1`), which produces `event_study_effects` and `att`. This means `per_period_effects` and `event_study_effects[1]` may diverge when covariates are active - by design (the per-period path uses binary joiner/leaver categorization and is not part of the DID^X contract). Implements the residualization-style covariate adjustment from Web Appendix Section 1.2 (Assumption 11). For each baseline treatment value `d`, estimates `theta_hat_d` via OLS of first-differenced outcomes on first-differenced covariates with time FEs, restricted to not-yet-treated observations. Residualizes at levels: `Y_tilde[g,t] = Y[g,t] - X[g,t] @ theta_hat_d`. All downstream DID computations use residualized outcomes. This is NOT doubly-robust, NOT IPW, NOT Callaway-Sant'Anna-style. Plug-in IF (treating `theta_hat` as fixed) is valid by FWL theorem. **Deviation from R `DIDmultiplegtDYN`:** The first-stage OLS uses equal cell weights (one observation per `(g,t)` cell), consistent with the library's cell-count weighting convention documented in Phase 1. R weights by `N_gt` (observation count per cell). On panels with 1 observation per cell (the common case), results are identical. When baseline-specific first stages fail (`n_obs = 0` or `n_obs < n_params`), the affected strata are excluded from the estimation (outcomes set to NaN) rather than retained unadjusted - matching R's "drop failed strata" behavior. Requires `L_max >= 1`. Activated via `covariates=["col1", "col2"]` in `fit()`. - **Note (Phase 3 DID^{fd} linear trends):** Implements group-specific linear trends from Web Appendix Section 1.3 (Assumption 12, Lemma 6). Uses the Z_mat transformation: `Z[g,t] = Y[g,t] - Y[g,t-1]` (first-differenced outcomes). Since `DID_{g,l}(Z) = DID^{fd}_{g,l}` algebraically, the existing multi-horizon DID code produces trend-adjusted estimates when fed Z_mat. Requires F_g >= 3 (at least 2 pre-switch periods); groups with F_g < 3 are excluded with a `UserWarning`. Cumulated level effects `delta^{fd}_l = sum_{l'=1}^l DID^{fd}_{l'}` stored in `results.linear_trends_effects`. Cumulated SE uses conservative upper bound (sum of per-horizon SEs); cross-horizon covariance from IF vectors is a library extension (paper proves Theorem 1 per-horizon, not cross-horizon). When combined with DID^X, residualization is applied first, then first-differencing (per paper assumption ordering). **Suppressed surfaces under `trends_linear`:** `normalized_effects` (`DID^n_l`) and `cost_benefit_delta` are suppressed because they would operate on second-differences rather than level effects. Users should access cumulated level effects via `linear_trends_effects`. Activated via `trends_linear=True` in `fit()`. @@ -1683,13 +1685,13 @@ remain in place — this Deviations block is the canonical AI-review surface per CLAUDE.md "Documenting Deviations (AI Review Compatibility)" labels.* -1. **Deviation from R:** `range(dose)` vs `range(dvals)` boundary knots — the library uses `range(dose)` (training-dose range) for B-spline boundary knots; R's `contdid` v0.1.0 uses `range(dvals)` via `splines2::bSpline(dvals)`, which can produce extrapolation artifacts at dose-grid extremes. **Scope caveat:** R cross-language coverage therefore runs at **relative** tolerance bands across two surfaces: (a) **scalar parity with raw R `cont_did` / `pte_default`** at 1% relative on overall ATT for all 6 benchmarks and on overall ACRT for benchmarks 4-5; (b) **harmonized boundary-knot-normalized curve parity** with R-side ATT(d) / ACRT(d) reconstructed under `Boundary.knots = range(treated_doses)` (matching the library) on benchmarks 1-3 via the benchmark harness — `_run_r_contdid` does the R-side rebuild at `tests/test_methodology_continuous_did.py:333-367`, and `_compare_with_r` orchestrates the Python-vs-R comparison at `:395-459` — max ATT(d) at 1% and max ACRT(d) at 2%. Benchmark 6 is event-study, scalar `overall_att` only. NOT bit-exact (`atol=1e-8`) like HAD. Library extension toward methodological soundness (avoids extrapolation). Cross-references the § Edge Cases "Boundary knots" bullet above and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #1. +1. **Deviation from R:** `range(dose)` vs `range(dvals)` boundary knots — the library uses `range(dose)` (training-dose range) for B-spline boundary knots; R's `contdid` v0.1.0 uses `range(dvals)` via `splines2::bSpline(dvals)`, which can produce extrapolation artifacts at dose-grid extremes. **Scope caveat:** R cross-language coverage therefore runs at **relative** tolerance bands across two surfaces: (a) **scalar parity with raw R `cont_did` / `pte_default`** at 1% relative on overall ATT for all 6 benchmarks and on overall ACRT for benchmarks 4-5; (b) **harmonized boundary-knot-normalized curve parity** with R-side ATT(d) / ACRT(d) reconstructed under `Boundary.knots = range(treated_doses)` (matching the library) on benchmarks 1-3 via the benchmark harness — `_run_r_contdid` does the R-side rebuild at `tests/test_methodology_continuous_did.py:333-367`, and `_compare_with_r` orchestrates the Python-vs-R comparison at `:395-459` — max ATT(d) at 1% and max ACRT(d) at 2%. Benchmark 6 is event-study, scalar `att` only. NOT bit-exact (`atol=1e-8`) like HAD. Library extension toward methodological soundness (avoids extrapolation). Cross-references the § Edge Cases "Boundary knots" bullet above and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #1. 2. **Note:** `bspline_derivative_design_matrix` derivative-failure `UserWarning` — Phase 2 axis-C #12 silent-failures audit fix. No R correspondence; `contdid` v0.1.0 does not implement an equivalent warning. Cross-references the § Edge Cases `**Note:**` bullet above (`bspline_derivative_design_matrix` entry) and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #2. Locked in `tests/test_continuous_did.py::TestBSplineDerivativeDegenerateBasis` (3 tests); source-level aggregate-warning block at `diff_diff/continuous_did_bspline.py:150-187`. 3. **Note:** `+inf` → `0` never-treated recoding emits `UserWarning` reporting the affected row count; negative `first_treat` (including `-inf`) raises `ValueError`. Axis-E silent-coercion fix per Phase 2 audit. No R correspondence; `contdid` v0.1.0 silently absorbs `+inf` without a signal. Cross-references the § Implementation Checklist `**Note:**` below and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #3. 4. **Note:** Zero-`first_treat` rows with nonzero `dose` are force-zeroed with `UserWarning` reporting the affected row count (axis-E silent-coercion). No R correspondence; `contdid` v0.1.0 has the same `first_treat = 0` → `D = 0` invariant but silently coerces without a warning. Cross-references the § Implementation Checklist `**Note:**` below and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #4. -5. **Note (covariate support — library extension beyond `contdid` v0.1.0):** `covariates=` with `estimation_method ∈ {reg, dr}` adds conditional-parallel-trends adjustment. This is a **library extension**: `contdid` v0.1.0 hard-stops on any covariate (`stop("covariates not currently supported…")`), so there is **no external R anchor for the covariate-adjusted dose *curve***. Validation instead: (a) the **scalar `overall_att` + SE** map *exactly* onto `DRDID::reg_did_panel` (reg) / `DRDID::drdid_panel` (dr) — a tight (~1e-8) component anchor, skip-guarded since DRDID is not in CI (`tests/test_methodology_continuous_did.py::TestCovariateReg`); (b) an **R-free NumPy reconstruction** of the reg/dr `att`+SE runs *in CI* at p≥2 (`test_dr_reg_numpy_crosscheck_p2`) — the guard the p=1 reduction cannot provide (at p=1 the intercept-only propensity is constant, so `eta_cont ≡ 0` and dr collapses to reg); (c) DGP recovery + MC coverage (reg 96%, dr 95%). **`ipw` restricted:** `estimation_method="ipw"` with covariates raises `NotImplementedError` — pure IPW's covariate adjustment is a single scalar (a propensity-reweighted control mean) that shifts only the ATT(d) level and leaves `ACRT(d)` identical to the unconditional fit, so it cannot adjust the dose-response *shape*. **Deviations from DRDID:** unit weights are 1 (unweighted; `covariates=` + `survey_design=` raises `NotImplementedError`, deferred); propensity trimming uses clip semantics (`pscore_trim`) rather than DRDID's drop-trimming — identical on moderate-overlap data (the anchor regime), diverging only at extreme propensities. **Fail-closed policies (no-silent-failures):** (i) missing/non-finite covariate values raise `ValueError` up front — a per-cell fallback to unconditional estimation would silently mix conditional-PT and unconditional-PT cells in the aggregate; (ii) `dr` propensity-estimation failure raises by default (`pscore_fallback="error"`) so a `dr` fit never silently degrades to a non-DR estimate — `pscore_fallback="unconditional"` opts into the graceful (warned, reg-like) fallback. **`treatment_type="discrete"` composability:** on the intercept-free saturated basis the constant augmentation `eta_cont` shifts every `beta_j` equally (indicator partition of unity), so it cancels in the adjacent differences and reg/dr share `ACRT(d_j)` point AND SE for `j >= 2` — but the lowest-dose ACRT references the fixed baseline `ATT(0) = 0` (backward-to-zero), so reg/dr **differ at `ACRT(d_1)` by `eta_cont/d_1`** (the dr IF carries the augmentation variance there). See Note #6. Cross-references `docs/methodology/continuous-did.md` § Covariates. -6. **Note (discrete-treatment saturated regression — library extension beyond `contdid` v0.1.0):** `treatment_type="discrete"` estimates the dose-response by a **saturated regression** (CGBS 2024 Eq. 4.1) — one indicator per distinct dose level, so `beta_j = mean_{D=d_j}(ΔY − control) = ATT(d_j)` (a per-level 2×2 DiD) — instead of the B-spline sieve. `ACRT(d_j)` is the paper's **backward difference** on the grid `{d_0 = 0, d_1, …, d_J}` (Eq. 4.1 makes `d_0 = 0` the omitted category with `ATT(0) = 0`): `ACRT(d_j) = [ATT(d_j) − ATT(d_{j-1})]/(d_j − d_{j-1})` for `j ≥ 2`, and at the lowest positive level it references the zero-dose baseline, `ACRT(d_1) = [ATT(d_1) − 0]/(d_1 − 0) = ATT(d_1)/d_1`. So a single positive dose (`J = 1`, e.g. binary `D ∈ {0,1}`) yields `ACRT(d_1) = ATT(d_1)/d_1`, and for `d_1 = 1` the documented binary identity `ACRT = ATT` holds exactly. This is a **library extension**: `contdid` v0.1.0 accepts `treatment_type` in its signature but **does not implement the discrete path** (documented "Discrete treatment not yet implemented"), so there is **no external R anchor**. It is instead an *exact* basis swap of the B-spline design/evaluation/derivative trio for an indicator/identity/finite-difference trio; every downstream quantity is linear in `beta`, so the analytical-SE / multiplier-bootstrap / covariate (reg,dr) / survey machinery is reused unchanged and reduces *analytically* to the per-level 2×2 DiD (`bread @ psi_bar = ones(J)`; the common control mean cancels in the `j ≥ 2` adjacent differences whose `L`-rows sum to 0). **reg vs dr:** the constant DR augmentation `η̄_cont` cancels in the `j ≥ 2` differences, so `ACRT(d_j)` point AND SE are identical for `reg`/`dr` there; but `ACRT(d_1) = ATT(d_1)/d_1` references the fixed baseline `ATT(0) = 0` (not shifted by `η̄_cont`), so `reg` and `dr` genuinely **differ at `ACRT(d_1)` by `η̄_cont/d_1`** (and correspondingly in `ACRT^glob` via the `d_1` mass) — the dr influence function carries the augmentation variance at `d_1` (validated: analytical `ACRT(d_1)` SE matches the multiplier bootstrap). Validation (R-free, in CI): exact hand-calc of `ATT(d_j)`/`ACRT`/`overall_att` and the analytical SE against a direct per-level 2×2 reconstruction (`~1e-12`/`~1e-10`), DGP recovery, and MC coverage for analytical + bootstrap (`tests/test_methodology_continuous_did.py::TestDiscreteSaturated`, `tests/test_continuous_did.py::TestDiscreteSaturatedAPI`). **Fail-closed policies (no-silent-failures):** (i) multi-cohort fits with **heterogeneous dose support** across cohorts raise `NotImplementedError` — an absent global level yields a dropped zero column (`att_d[level]=0`) that the plain-sum dose aggregation would bias toward zero (support-aware aggregation is deferred; single-cohort, 2-period, and shared-support multi-cohort are supported); (ii) a requested `dvals` value that is not an observed dose level raises `ValueError` (a saturated model cannot be evaluated off-support); (iii) an over-parameterized fit (`< 2` treated units per level, or `J > n_treated/2`) warns (degenerate per-level SE); (iv) with `survey_design=`, any dose level with **zero effective treated mass in a `(g,t)` cell** raises `ValueError` — a per-cell check (not just the global positive-weight check), so a level that survey/subpopulation weights zero out for one cohort while another cohort keeps it cannot silently drop to a zero-coefficient saturated column. Cross-references `docs/methodology/continuous-did.md` § 5.1. -7. **Note (lowest-dose-as-control, Remark 3.1 — library extension beyond `contdid` v0.1.0):** `control_group="lowest_dose"` implements CGBS 2024 Remark 3.1 for settings with no untreated group (`P(D=0) = 0`): the lowest-dose group `d_L` becomes the comparison and the estimand is `ATT(d) − ATT(d_L)` (SPT), with `ATT(d_L) = 0` the omitted reference. Mechanically it is a **control-group swap** — the D=0 control pool is replaced by the `d_L` group; the entire linear influence-function / bootstrap / event-study / survey machinery is control-group-generic and reused unchanged (`ee_control` already carries the reference-group variance, so **no new SE plumbing**). On the discrete saturated basis the backward-difference operator's reference shifts from `0` to `d_L` (`ACRT(d_1) = ATT(d_1)/(d_1 − d_L)`); on the continuous B-spline path the reference shifts only `μ_0` (the level), leaving `ACRT = spline'` unchanged. `contdid` v0.1.0 does **not** implement Remark 3.1, so there is **no external R anchor**; validation (R-free, in CI): an **exact `d_L → 0` equivalence** anchor (relabelling a `never_treated` panel's D=0 group as a tiny common dose `d_L = ε` reproduces the `never_treated` ATT and SE exactly, for any ε), a discrete hand-calc of `ATT(d)−ATT(d_L)`/`ACRT`/`overall_att`/`overall_acrt` and the per-level 2×2 SE (`~1e-10`), continuous mass-point DGP recovery, analytical-vs-bootstrap SE agreement, a pre-period placebo, and MC coverage (`tests/test_methodology_continuous_did.py::TestLowestDose`, `tests/test_continuous_did.py::TestLowestDoseAPI`). The continuous path requires a genuine **mass point** at the minimum dose (`>= 2` units at `d_L`, i.e. `P(D=d_L) > 0`) — the Remark 3.1 identification condition; a singleton minimum fails closed. **Fail-closed policies (no-silent-failures):** (i) never-treated units present with `lowest_dose` → `ValueError` (they would be silently dropped); (ii) singleton `d_L` (no mass point) → `ValueError`; (iii) no treated dose above `d_L` → `ValueError`; (iv) user `dvals ≤ d_L` → `ValueError` (`d_L` is the omitted reference); (v) survey/subpopulation weighting that leaves the `d_L` group with `< 2` positive-weight units → `ValueError` (a single positive-weight reference unit gives `ee_control = 0`, i.e. zero control-side variance — the effective-`>= 2` analogue of the raw mass-point guard, applied after weighting); (vi) a boundary gap `d_1 − d_L` that is a tiny fraction of the dose range warns (huge boundary ACRT/SE). **Deferred (fail-closed `NotImplementedError` + TODO):** multi-cohort `lowest_dose` (needs a within-cohort reference + support-aware cross-cohort aggregation) and `covariates=` × `lowest_dose` (conditional-PT-relative-to-`d_L` estimand). Cross-references `docs/methodology/continuous-did.md` § 5.6. +5. **Note (covariate support — library extension beyond `contdid` v0.1.0):** `covariates=` with `estimation_method ∈ {reg, dr}` adds conditional-parallel-trends adjustment. This is a **library extension**: `contdid` v0.1.0 hard-stops on any covariate (`stop("covariates not currently supported…")`), so there is **no external R anchor for the covariate-adjusted dose *curve***. Validation instead: (a) the **scalar `att` + SE** map *exactly* onto `DRDID::reg_did_panel` (reg) / `DRDID::drdid_panel` (dr) — a tight (~1e-8) component anchor, skip-guarded since DRDID is not in CI (`tests/test_methodology_continuous_did.py::TestCovariateReg`); (b) an **R-free NumPy reconstruction** of the reg/dr `att`+SE runs *in CI* at p≥2 (`test_dr_reg_numpy_crosscheck_p2`) — the guard the p=1 reduction cannot provide (at p=1 the intercept-only propensity is constant, so `eta_cont ≡ 0` and dr collapses to reg); (c) DGP recovery + MC coverage (reg 96%, dr 95%). **`ipw` restricted:** `estimation_method="ipw"` with covariates raises `NotImplementedError` — pure IPW's covariate adjustment is a single scalar (a propensity-reweighted control mean) that shifts only the ATT(d) level and leaves `ACRT(d)` identical to the unconditional fit, so it cannot adjust the dose-response *shape*. **Deviations from DRDID:** unit weights are 1 (unweighted; `covariates=` + `survey_design=` raises `NotImplementedError`, deferred); propensity trimming uses clip semantics (`pscore_trim`) rather than DRDID's drop-trimming — identical on moderate-overlap data (the anchor regime), diverging only at extreme propensities. **Fail-closed policies (no-silent-failures):** (i) missing/non-finite covariate values raise `ValueError` up front — a per-cell fallback to unconditional estimation would silently mix conditional-PT and unconditional-PT cells in the aggregate; (ii) `dr` propensity-estimation failure raises by default (`pscore_fallback="error"`) so a `dr` fit never silently degrades to a non-DR estimate — `pscore_fallback="unconditional"` opts into the graceful (warned, reg-like) fallback. **`treatment_type="discrete"` composability:** on the intercept-free saturated basis the constant augmentation `eta_cont` shifts every `beta_j` equally (indicator partition of unity), so it cancels in the adjacent differences and reg/dr share `ACRT(d_j)` point AND SE for `j >= 2` — but the lowest-dose ACRT references the fixed baseline `ATT(0) = 0` (backward-to-zero), so reg/dr **differ at `ACRT(d_1)` by `eta_cont/d_1`** (the dr IF carries the augmentation variance there). See Note #6. Cross-references `docs/methodology/continuous-did.md` § Covariates. +6. **Note (discrete-treatment saturated regression — library extension beyond `contdid` v0.1.0):** `treatment_type="discrete"` estimates the dose-response by a **saturated regression** (CGBS 2024 Eq. 4.1) — one indicator per distinct dose level, so `beta_j = mean_{D=d_j}(ΔY − control) = ATT(d_j)` (a per-level 2×2 DiD) — instead of the B-spline sieve. `ACRT(d_j)` is the paper's **backward difference** on the grid `{d_0 = 0, d_1, …, d_J}` (Eq. 4.1 makes `d_0 = 0` the omitted category with `ATT(0) = 0`): `ACRT(d_j) = [ATT(d_j) − ATT(d_{j-1})]/(d_j − d_{j-1})` for `j ≥ 2`, and at the lowest positive level it references the zero-dose baseline, `ACRT(d_1) = [ATT(d_1) − 0]/(d_1 − 0) = ATT(d_1)/d_1`. So a single positive dose (`J = 1`, e.g. binary `D ∈ {0,1}`) yields `ACRT(d_1) = ATT(d_1)/d_1`, and for `d_1 = 1` the documented binary identity `ACRT = ATT` holds exactly. This is a **library extension**: `contdid` v0.1.0 accepts `treatment_type` in its signature but **does not implement the discrete path** (documented "Discrete treatment not yet implemented"), so there is **no external R anchor**. It is instead an *exact* basis swap of the B-spline design/evaluation/derivative trio for an indicator/identity/finite-difference trio; every downstream quantity is linear in `beta`, so the analytical-SE / multiplier-bootstrap / covariate (reg,dr) / survey machinery is reused unchanged and reduces *analytically* to the per-level 2×2 DiD (`bread @ psi_bar = ones(J)`; the common control mean cancels in the `j ≥ 2` adjacent differences whose `L`-rows sum to 0). **reg vs dr:** the constant DR augmentation `η̄_cont` cancels in the `j ≥ 2` differences, so `ACRT(d_j)` point AND SE are identical for `reg`/`dr` there; but `ACRT(d_1) = ATT(d_1)/d_1` references the fixed baseline `ATT(0) = 0` (not shifted by `η̄_cont`), so `reg` and `dr` genuinely **differ at `ACRT(d_1)` by `η̄_cont/d_1`** (and correspondingly in `ACRT^glob` via the `d_1` mass) — the dr influence function carries the augmentation variance at `d_1` (validated: analytical `ACRT(d_1)` SE matches the multiplier bootstrap). Validation (R-free, in CI): exact hand-calc of `ATT(d_j)`/`ACRT`/`att` and the analytical SE against a direct per-level 2×2 reconstruction (`~1e-12`/`~1e-10`), DGP recovery, and MC coverage for analytical + bootstrap (`tests/test_methodology_continuous_did.py::TestDiscreteSaturated`, `tests/test_continuous_did.py::TestDiscreteSaturatedAPI`). **Fail-closed policies (no-silent-failures):** (i) multi-cohort fits with **heterogeneous dose support** across cohorts raise `NotImplementedError` — an absent global level yields a dropped zero column (`att_d[level]=0`) that the plain-sum dose aggregation would bias toward zero (support-aware aggregation is deferred; single-cohort, 2-period, and shared-support multi-cohort are supported); (ii) a requested `dvals` value that is not an observed dose level raises `ValueError` (a saturated model cannot be evaluated off-support); (iii) an over-parameterized fit (`< 2` treated units per level, or `J > n_treated/2`) warns (degenerate per-level SE); (iv) with `survey_design=`, any dose level with **zero effective treated mass in a `(g,t)` cell** raises `ValueError` — a per-cell check (not just the global positive-weight check), so a level that survey/subpopulation weights zero out for one cohort while another cohort keeps it cannot silently drop to a zero-coefficient saturated column. Cross-references `docs/methodology/continuous-did.md` § 5.1. +7. **Note (lowest-dose-as-control, Remark 3.1 — library extension beyond `contdid` v0.1.0):** `control_group="lowest_dose"` implements CGBS 2024 Remark 3.1 for settings with no untreated group (`P(D=0) = 0`): the lowest-dose group `d_L` becomes the comparison and the estimand is `ATT(d) − ATT(d_L)` (SPT), with `ATT(d_L) = 0` the omitted reference. Mechanically it is a **control-group swap** — the D=0 control pool is replaced by the `d_L` group; the entire linear influence-function / bootstrap / event-study / survey machinery is control-group-generic and reused unchanged (`ee_control` already carries the reference-group variance, so **no new SE plumbing**). On the discrete saturated basis the backward-difference operator's reference shifts from `0` to `d_L` (`ACRT(d_1) = ATT(d_1)/(d_1 − d_L)`); on the continuous B-spline path the reference shifts only `μ_0` (the level), leaving `ACRT = spline'` unchanged. `contdid` v0.1.0 does **not** implement Remark 3.1, so there is **no external R anchor**; validation (R-free, in CI): an **exact `d_L → 0` equivalence** anchor (relabelling a `never_treated` panel's D=0 group as a tiny common dose `d_L = ε` reproduces the `never_treated` ATT and SE exactly, for any ε), a discrete hand-calc of `ATT(d)−ATT(d_L)`/`ACRT`/`att`/`overall_acrt` and the per-level 2×2 SE (`~1e-10`), continuous mass-point DGP recovery, analytical-vs-bootstrap SE agreement, a pre-period placebo, and MC coverage (`tests/test_methodology_continuous_did.py::TestLowestDose`, `tests/test_continuous_did.py::TestLowestDoseAPI`). The continuous path requires a genuine **mass point** at the minimum dose (`>= 2` units at `d_L`, i.e. `P(D=d_L) > 0`) — the Remark 3.1 identification condition; a singleton minimum fails closed. **Fail-closed policies (no-silent-failures):** (i) never-treated units present with `lowest_dose` → `ValueError` (they would be silently dropped); (ii) singleton `d_L` (no mass point) → `ValueError`; (iii) no treated dose above `d_L` → `ValueError`; (iv) user `dvals ≤ d_L` → `ValueError` (`d_L` is the omitted reference); (v) survey/subpopulation weighting that leaves the `d_L` group with `< 2` positive-weight units → `ValueError` (a single positive-weight reference unit gives `ee_control = 0`, i.e. zero control-side variance — the effective-`>= 2` analogue of the raw mass-point guard, applied after weighting); (vi) a boundary gap `d_1 − d_L` that is a tiny fraction of the dose range warns (huge boundary ACRT/SE). **Deferred (fail-closed `NotImplementedError` + TODO):** multi-cohort `lowest_dose` (needs a within-cohort reference + support-aware cross-cohort aggregation) and `covariates=` × `lowest_dose` (conditional-PT-relative-to-`d_L` estimand). Cross-references `docs/methodology/continuous-did.md` § 5.6. 8. **Note (post-fit aggregate() - rows M-025/M-122):** `fit(aggregate=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3c of the cut; the no-underscore `"eventstudy"` spelling and the fit-time value validation died with it; `survey_design`/`covariates` are positional slots 7/8) in favor of post-fit `results.aggregate(type)` on the unified vocabulary + `'dose'` as this estimator's documented extra level. (a) **MIXED view/recompute architecture** (unique among the aggregate-postfit adopters): the dose-response curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by `fit()` (`aggregate="dose"` was a fit-time no-op), so `aggregate('simple')` (2 rows, targets att/acrt - the dual-estimand case the `AggregationResult.target` column exists for; `n` = the DISJOINT treated+control unit total, `n_kind='units'`) and `aggregate('dose')` (2N target-discriminated rows; labels = the dose grid twice; no count/mass per row) are pure VIEWS over stored public fields, PERMITTED on bootstrap fits (the library-wide per-level relay rule, since M-027 converged CS/EDiD/Imputation/TwoStage onto it) - they relay stored inference verbatim, including the FINITE `safe_inference` t-stat fit stores beside the percentile p/CI on bootstrapped overall rows and the `DoseResponseCurve.to_dataframe`-exact NaN-t derivation on dose rows; only the df column is uniformly NaN under bootstrap. (b) **`aggregate('event_study')` recomputes** the binarized event study from a pruned per-cell IF payload retained on the fit-built kit: per-(g,t) treated/control positional indices, `delta_y_treated`, `ee_control`, masses and the covariate-path `if_att_glob` (O(n_treated+n_control) per cell), unit-level arrays, and - on survey fits - the PANEL-LEVEL `ResolvedSurveyDesign` ref (the recompute performs the unit collapse itself, keeping the moved body verbatim; on replicate designs the (n_obs x R) replicate matrix rides along - the documented memory cost). The K-dimensional spline machinery (bread, `ee_treated`, `Psi_eval`, `dPsi_*`) is NOT retained; no panel data columns and no raw unit identifiers are retained. Replicate-weight designs ARE supported post-fit (IF-based `compute_replicate_if_variance` - no refit replay). Bootstrap fits carry a SCALARS-ONLY kit; since 4.0 (PR 3c) they ALWAYS materialize the binarized event study at fit and `aggregate('event_study')` RELAYS it (see the bootstrapped-fit relay Note below) - a genuine seeded post-fit bootstrap replay is the TODO.md row. (c) **Fit-faithful quirk:** when no post-treatment (g,t) cells exist, event-study rows keep NaN inference on BOTH routes (the fit-time surface never fills them). (d) **df provenance:** the stored `dose_response_att.df_survey` channel (the value every fit-time `safe_inference` received) drives the views' df column (finite-and-positive else NaN - the replicate-undefined 0 sentinel reports NaN in the column but feeds the t/p derivation raw); the event-study container's PER-ROW df column now threads the same survey df via the results-level `event_study_df` channel (M-092 completion; finite on analytical survey fits, None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel), beside the scalar `df_survey` channel (the two-channel contract - see the CallawaySantAnna post-fit Note). (e) **Rendering:** this is the FIRST heterogeneous-`target` `AggregationResult`; `summary()`/`to_dataframe()` gained the target column / first-appearance target-block ordering amendment (normative rule in `docs/v4-design.md` section 6; uniform-target producers byte-stable). (f) **Consumer admission:** `compute_honest_did`/`compute_pretrends_power` reject ContinuousDiD containers BY DESIGN - no joint event-study covariance exists (per-bin IF SEs only) and the binarized bins carry no reference-period normalization at all (see M-093). Warning stacklevels in the moved bodies remain tuned for the fit-time frame depth, so post-fit-route warnings attribute to a library frame (the shipped EfficientDiD convention). (g) **Report-layer consumption:** `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent) for its `heterogeneity` check — the only ES-gated check in ContinuousDiD's applicability; captured recompute warnings are re-published on the section; bootstrapped fits are read from the fit-materialized raw field (raw-field precedence), and derivation failures (kit-less pickles, a fail-soft-cleared bootstrapped surface) surface as explicit skip context. - **Note (bootstrapped-fit relay, 4.0):** (i) RELAY: a fit with `n_bootstrap > 0` ALWAYS computes the binarized event study at fit (the only route - the kit stays SCALARS-ONLY there, so no recompute is ever possible on a bootstrapped fit and no new kit key is added: a 3.9-3.13 pickle must not `KeyError`), stores it in `event_study_effects` (percentile se/p/CI, `event_study_df=None`), and `aggregate('event_study')` RELAYS it (alpha/anticipation/`base_period`/survey metadata from the kit snapshot; per-row `df` column NaN; `df_survey` scalar from the carrier's survey metadata; `vcov`/`vcov_index` None; no reference row). The gate stays the kit scalar `n_bootstrap > 0` (not the mutable public field); `balance_e` applies to no level here (the mixin rejects it). Analytic fits are unchanged (kit recompute; legacy field None) - the dead analytic fit-time inference arm was deleted. (ii) FAIL-SOFT materialization + storage rule: the surface is built inside one guard - any exception leaves it None and `_run_bootstrap` runs for the overall/dose targets only (the pre-4.0 plain-fit path); a `_run_bootstrap` attempt that raises (anything but the replicate-design `NotImplementedError`/`ValueError`, which are re-raised - the check fires inside `_run_bootstrap` after the ES is computed) WITH the surface present clears it and RETRIES ONCE overall-only (`np.random.default_rng(self.seed)` is rebuilt on entry, so a seeded retry reproduces attempt 1's weight draws and the overall/dose quintets equal a plain fit's); a retry failure propagates out of `fit()` exactly as before 4.0, so a RETURNED bootstrapped fit always carries bootstrap inference. STORAGE RULE: the surface is stored IFF `_run_bootstrap` ran (post-treatment cells exist); a bootstrapped fit with no post cells clears it and `aggregate('event_study')` raises the shared absent-surface `ValueError` (before 4.0 that fit raised `NotImplementedError` from the bootstrap gate; its overall ATT is already NaN with a `UserWarning`). The fail-soft `UserWarning` fires only when the fit carries bootstrap inference and the surface was dropped: 'ContinuousDiD: the event-study/group surfaces could not be materialized on this bootstrapped fit ({e}); the bootstrap covered the overall ATT only, so aggregate('event_study') / aggregate('group') are unavailable here - re-fit with n_bootstrap=0 for the analytical post-fit recompute.'; the relay then raises 'This bootstrapped ContinuousDiDResults carries no stored event_study surface: either it was fitted before 4.0 (a bootstrapped plain fit built none) or the surface could not be materialized at fit (see the fit-time warning). Re-fit with diff-diff >= 4.0 for the relayed surface, or with n_bootstrap=0 for the analytical post-fit recompute.' Net invariant: a bootstrapped plain fit's overall/dose quintets and success/abort outcome are unchanged by the 4.0 removal. (iii) The ES aggregator's `UserWarning`s and the per-target bootstrap-stats `RuntimeWarning`s ('Dropping n/N non-finite bootstrap samples in ... event study (h=...)', 'Too few valid bootstrap samples', 'Bootstrap SE is non-finite or zero') now fire on bootstrapped plain fits that never emitted them. (iv) Pre-4.0 pickles: a 3.9-3.13 bootstrapped pickle fitted with `fit(aggregate='eventstudy')` relays its stored surface; one fitted plain stores none and the relay raises the refit `ValueError`. (v) The relay reads the MUTABLE public `event_study_effects` field (a view by definition - the StackedDiD precedent); only alpha/anticipation/base_period/survey metadata come from kit snapshots, so the recompute route's 'kit snapshots only' invariant does not hold for the relayed dict. (vi) Downstream: `summary()` prints the event-study table and `to_dataframe(level='event_study')` returns a frame on bootstrapped plain fits; `DiagnosticReport`/`BusinessReport` run `heterogeneity` (the only ES-gated check here; no sensitivity) on the relayed surface instead of skipping with `NotImplementedError`; `plot_event_study(results)` plots it natively. @@ -1845,7 +1847,7 @@ where `q_{g,e} = pi_g / sum_{g' in G_{trt,e}} pi_{g'}`. - **Note:** PT-All index set includes g'=∞ (never-treated) as a candidate comparison group and excludes period_1 for all g'. When g'=∞, the second and third Eq 3.9 terms telescope so all (∞, t_pre) moments produce the same 2x2 DiD value; these redundant moments are damped by the default Omega* ridge (see the Omega* ridge Note below; at `omega_ridge=0`, by the legacy pseudoinverse). When t_pre = period_1, the third term degenerates to E[Y_1 - Y_1 | G=g'] = 0 for any g', adding no information. Valid pairs require only t_pre < g' (pre-treatment for comparison group), not t_pre < g. Same-group pairs (g'=g) are valid and contribute overidentifying moments (Equation 3.9) — except the degenerate PRE-treatment self-pair (g'=g, t_pre=t), which the default ridge path excludes (see the ridge Note). - **Note:** Omega* ridge regularization (`omega_ridge`, default 1e-6; v3.7) — a documented refinement of the Omega* inversion in Eq 3.5/3.13/4.3, in the space the paper leaves open (it assumes Omega* is invertible and does not prescribe finite-sample handling of singularity). Under PT-All the overidentified moment set makes the SAMPLE Omega* numerically singular by construction (the telescoping (∞, t_pre) moments above plus near-duplicate cross-cohort moments): measured cond 1e17–1e22 for 100% of units on realistic panels, with a spectrum of one exact-null direction (the degenerate self-pair, below) plus a cluster of statistically-null directions at relative eigenvalue 1e-5–1e-8, far below the ~1e-2 sampling noise of the covariance entries — and no clean spectral gap. The prior pseudoinverse fallback therefore sat on the rcond-cutoff cliff: ANY floating-point-level change (BLAS reordering, platform change, a 1-ulp data perturbation) redrew per-cell weights and moved per-cell ATT(g,t) at up to ~1e-2 relative (measured 1.2e-4 per-cell rel for a 1-ulp outcome perturbation on the pre-v3.7 code; overall ATT stable at ~1e-9 because the redraw averages out across units and cells). The ridge solves `(Omega* + omega_ridge * max(trace/H, 0) * I) x = 1` (trace-scaled: scale-equivariant, O(H), no SVD) — a smooth regularization with sensitivity bounded by 1/omega_ridge, not a cutoff. **Why the deviation is safe:** every moment individually identifies ATT(g,t), so any fixed weights summing to 1 keep the estimator consistent; the ridge trades a numerically ill-defined efficiency optimum for a stable one, and the plug-in EIF treatment of estimated weights (Remark 4.2) is unaffected. **Calibration evidence (2026-07):** default 1e-6 chosen by 1-ulp stability (per-cell rel <= 3e-9 vs 1.2e-4 legacy; candidates 1e-4/1e-6/1e-8 all pass the <=1e-6 target) with the HRS Table 6 anchors as a hard gate (all anchors within the published-value tolerance at every candidate; worst deviation 0.0257 SE, unchanged from legacy; shift <= 0.0001 SE); Monte Carlo on covariate-confounded DGPs shows bias/RMSE/SE-calibration/coverage statistically identical to legacy (ridge marginally better point metrics). **One-time value shift:** per-cell and event-study values on the covariate path move within the pre-existing indeterminacy band when upgrading (worst observed post-treatment cell shift ~0.6 of its own SE at n=500, shrinking with n: overall-ATT shift 1.6e-2 → 4e-3 → 1.1e-3 rel at n=500/1k/2k); the no-covariates path is essentially unchanged (~1e-7). `omega_ridge=0` restores the ENTIRE legacy code path bit-for-bit (both the omega constructions and the inv/pinv weights, including per-cell condition-number warnings and the legacy O(n^2 H^2) runtime). **Degenerate self-pair:** for PRE-treatment cells (t < g), the pair (g'=g, t_pre=t) telescopes to the identically-zero moment 0=0 (the exact-null Omega* direction). The legacy pseudoinverse truncated it, spreading weight over noisy moments (spurious pre-treatment placebos of ~5% of the effect size, pure noise amplification); a naive ridge would instead load all weight on the zero-variance moment, making placebos deterministically zero and silently disabling the pre-trend diagnostic. The default ridge path therefore drops this zero-information pair (fit-level filter; post-treatment cells never contain it), restoring honest data-driven placebos; `omega_ridge=0` keeps the legacy pair set. **Warnings/diagnostics:** the no-covariates path still computes per-cell condition numbers (cheap at (H,H)) for `results.omega_condition_numbers` and consolidates cells with cond > 1e12 into ONE fit-level warning (count + max cond) instead of the legacy per-cell pseudoinverse warnings; the covariate path intentionally computes NO per-unit condition numbers — they would cost exactly the per-unit SVDs the v3.7 rewrite removes, near-singularity there is structural (~always true under PT-All, so a warning would be always-on noise rather than signal), and the ridge handles it by design (the legacy covariate path likewise had no per-unit diagnostics). The scalability warning threshold moved from n > 5000 (legacy per-cell warning) to n > 50,000 (one fit-level warning; the kernel stage is still intrinsically O(n^2) but with a ~100x lower constant and tile-bounded memory). **Cross-cell table hoisting (2026-07 follow-up):** the tiled implementation now builds the kernel-covariance tables once per comparison group per unit-tile instead of per (g, t) cell — every Omega* term is `s_group * KCov(Y_u1 - Y_v1, Y_u2 - Y_v2 | group)`, keyed only by wide-outcome columns, so the per-cell H(H+1)/2-pair tables dedup to distinct product columns per group (~26x fewer kernel GEMM columns on a PT-All fit), and the kernel weight matrices are built and freed one group at a time (the tile memory budget is governed by the largest single group rather than the sum, giving proportionally fatter tiles at large n). Each cell's Omega* is then gathered from the group tables in the same per-entry operation order as the per-cell construction (value-exact, locked by test); this is a pure implementation change — measured results move only at floating-point reassociation level (post-treatment cells ~1e-12 relative, overall ATT ~1e-13; the no-covariates path is byte-identical), and `omega_ridge=0` still routes the entire legacy path. **Rust-backend batched-Cholesky ridge solve (2026-07 follow-up):** on the Rust backend the batched ridge solve `(Omega* + lam * max(trace/H, 0) * I) x = 1` dispatches to a batched Cholesky kernel (the ridged Omega* is SPD by construction; a non-SPD row — measured zero on realistic panels — falls back to LU in-kernel, and any non-finite row is recomputed through the exact legacy numpy chain including its pseudoinverse backstop, so edge-case semantics are unchanged), parallelized over units. This too is a pure implementation change: Cholesky and LU solutions differ only at the condition-bounded floating-point level, and measured results move at reassociation level (post-treatment cells ~2e-12 relative, overall ATT ~1e-13); the pure-Python backend is byte-identical, and `omega_ridge=0` never reaches the kernel. - **Note:** Bootstrap aggregation uses fixed cohort-size weights for overall/event-study reaggregation, matching the CallawaySantAnna bootstrap pattern (`_run_multiplier_bootstrap`'s overall aggregation in `staggered_bootstrap.py` computes `bootstrap_overall = bootstrap_atts_gt[:, post_indices] @ weights`; its event-study block uses the same fixed-weight pattern). The analytical path includes a WIF correction; fixed-weight bootstrap captures the same sampling variability through per-cell EIF perturbation without re-estimating aggregation weights, consistent with both the library's CS implementation and the R `did` package. -- **Overall ATT convention**: The library's `overall_att` uses cohort-size-weighted averaging of post-treatment (g,t) cells, matching the CallawaySantAnna simple aggregation. This differs from the paper's ES_avg (Eq 2.3), which uniformly averages over event-time horizons. ES_avg can be computed from event study output as `mean(event_study_effects[e]["effect"] for e >= 0)` +- **Overall ATT convention**: The library's `att` uses cohort-size-weighted averaging of post-treatment (g,t) cells, matching the CallawaySantAnna simple aggregation. This differs from the paper's ES_avg (Eq 2.3), which uniformly averages over event-time horizons. ES_avg can be computed from event study output as `mean(event_study_effects[e]["effect"] for e >= 0)` *Algorithm (two-step semiparametric estimation, Section 4):* @@ -1977,7 +1979,7 @@ where weights ŵ_{g,e} = n_{g,e} / Σ_g n_{g,e} (sample share of cohort g at eve - **Note (Phase 1b aggregated BM contrast DOF):** Under `vcov_type="hc2_bm"`, the user-facing aggregated inference (`event_study_effects[e]['p_value']`/`['conf_int']`, - `overall_p_value`/`overall_conf_int`) uses CR2 Bell-McCaffrey + `p_value`/`conf_int`) uses CR2 Bell-McCaffrey Satterthwaite DOF per contrast — not the normal distribution. Per-event-time contrast `c_e[full_idx(g,e)] = w_{g,e}` (IW weight) and overall ATT contrast `c_overall[full_idx(g,e)] = w_e × w_{g,e}` @@ -2256,7 +2258,7 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only. - [x] Supports balanced and unbalanced panels (iterative Gauss-Seidel demeaning for exact FE) - [x] Event study and group aggregation -- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3c of the cut; `survey_design` is positional slot 7; the `imputation_did()` wrapper that used to forward the shared sentinel was removed per M-070 - PR 2) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `overall_att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column; since 4.0 (PR 3c) the ES/group levels RELAY the percentile surface materialized at fit (see the bootstrapped-fit relay Note below; `balance_e` fails closed there - the balanced-window replay via the retained psi machinery is the TODO row); the prior uniform fail-closed rule was superseded 2026-08-05 with the M-027 per-level convergence. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the PER-ROW df column now threads the FINAL survey df via the results-level `event_study_df` channel - M-092 completion; `survey_df_final` at fit, the level-matched replay value on replicate replays; leads included on survey fits; None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises NotImplementedError (per-replicate lead refits unimplemented; the unreachable fit-time twin of this gate was deleted at 4.0); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run on analytic plain fits (bootstrapped fits are read from the fit-materialized raw fields - raw-field precedence); the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (missing kit, the replicate gate above, a fail-soft-cleared bootstrapped surface) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3c of the cut; `survey_design` is positional slot 7; the `imputation_did()` wrapper that used to forward the shared sentinel was removed per M-070 - PR 2) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column; since 4.0 (PR 3c) the ES/group levels RELAY the percentile surface materialized at fit (see the bootstrapped-fit relay Note below; `balance_e` fails closed there - the balanced-window replay via the retained psi machinery is the TODO row); the prior uniform fail-closed rule was superseded 2026-08-05 with the M-027 per-level convergence. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the PER-ROW df column now threads the FINAL survey df via the results-level `event_study_df` channel - M-092 completion; `survey_df_final` at fit, the level-matched replay value on replicate replays; leads included on survey fits; None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises NotImplementedError (per-replicate lead refits unimplemented; the unreachable fit-time twin of this gate was deleted at 4.0); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run on analytic plain fits (bootstrapped fits are read from the fit-materialized raw fields - raw-field precedence); the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (missing kit, the replicate gate above, a fail-soft-cleared bootstrapped surface) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). - **Note (bootstrapped-fit relay, 4.0):** (i) RELAY: a fit with `n_bootstrap > 0` ALWAYS computes the event-study AND group percentile surfaces at fit (`balance_e=None` - exactly what the 3.x `fit(aggregate='all')` computed; percentile se/p/CI, recomputed t, `df_used`/`event_study_df` cleared), stores them in `event_study_effects`/`group_effects`, and `aggregate('event_study')`/`aggregate('group')` RELAY them (alpha/anticipation/survey metadata from the kit snapshot; per-row `df` column NaN - the `pretrends=True` lead rows show NaN df too, identical to the 3.x bootstrapped surface, by design; `df_survey` scalar from the carrier's survey metadata; `vcov`/`vcov_index` None; reference rows via the zero-count sentinel). `aggregate('event_study', balance_e=k)` on a bootstrapped fit FAILS CLOSED (`NotImplementedError`: the stored surface is unrestricted and the per-target draws are not retained; re-fit with `n_bootstrap=0`). Analytic fits are unchanged (kit recompute; legacy fields None). The gate is `bootstrap_results is not None`: `None` (n_bootstrap == 0, OR a bootstrapped fit that ended analytical) means the analytical kit RECOMPUTE, the pre-4.0 behaviour. (ii) FAIL-SOFT materialization + storage rule: the surfaces are built inside one guard - any exception (e.g. the `pretrends=True` + `rank_deficient_action='error'` lead regression raising `ValueError` on a lead-collinear covariate) leaves them None and the bootstrap runs overall-only (the pre-4.0 plain-fit path, which SUCCEEDS where the 3.x `fit(aggregate='event_study')` raised); a bootstrap attempt that raises (anything but `NotImplementedError`, re-raised) WITH surfaces present clears them and RETRIES ONCE overall-only - `_precompute_bootstrap_psi` + `_run_bootstrap` re-run with `event_study_effects=None, group_effects=None`; `np.random.default_rng(self.seed)` is rebuilt on entry, so a seeded retry reproduces attempt 1's weight draws and the overall quintet equals a plain fit's (an unseeded retry draws a fresh, equally valid realization). The retry keeps the pre-4.0 failure semantics: a psi-precompute failure -> the 'Bootstrap pre-computation failed ... Skipping bootstrap inference.' warning + analytical overall; a `_run_bootstrap` failure -> RE-RAISED (the fit aborts exactly as before). STORAGE RULE: the legacy fields hold a surface IFF `bootstrap_results is not None`; when the fit ends analytical (`n_valid == 0` skipped the psi precompute, total failure) they are cleared and `aggregate()` RECOMPUTES analytically - an analytical surface must never sit on a bootstrapped fit where `summary()`/`to_dataframe()`/the DiagnosticReport raw-field precedence would read it as the fit's inference. The fail-soft `UserWarning` fires only when the fit DOES carry bootstrap inference and a surface was dropped: 'ImputationDiD: the event-study/group surfaces could not be materialized on this bootstrapped fit ({e}); the bootstrap covered the overall ATT only, so aggregate('event_study') / aggregate('group') are unavailable here - re-fit with n_bootstrap=0 for the analytical post-fit recompute.'; the relay then raises the shared absent-surface `ValueError` ('This bootstrapped ImputationDiDResults carries no stored event_study surface: either it was fitted before 4.0 (a bootstrapped plain fit built none) or the surface could not be materialized at fit (see the fit-time warning). Re-fit with diff-diff >= 4.0 for the relayed surface, or with n_bootstrap=0 for the analytical post-fit recompute.'). Net invariant: a bootstrapped plain fit's overall quintet and success/abort outcome are unchanged by the 4.0 removal. (iii) Newly reachable warnings on bootstrapped plain fits: the aggregators' own `UserWarning`s (Prop-5 unidentified horizons, empty horizon after filters, LOO single-unit auxiliary groups) and the per-target bootstrap-stats `RuntimeWarning`s ('Dropping n/N non-finite bootstrap samples in ... event study (h=...)', 'Too few valid bootstrap samples', 'Bootstrap SE is non-finite or zero'). (iv) Pre-4.0 pickle limitation: a 3.9-3.13 bootstrapped pickle fitted with `fit(aggregate=..., balance_e=k)` stores a balance_e-RESTRICTED surface and records no fit-time `balance_e`, so the relay returns that surface as stored - re-fit under 4.0 for the unrestricted one; one fitted plain stores none and the relay raises the refit `ValueError`. (v) The relay reads the MUTABLE public `event_study_effects`/`group_effects` fields (a view by definition - the StackedDiD precedent); only alpha/anticipation/survey metadata come from kit snapshots, so the recompute route's 'kit snapshots only' invariant does not hold for the relayed dict. (vi) Downstream: `summary()` prints the ES/group tables and `to_dataframe(level='event_study'|'group')` returns frames on bootstrapped plain fits; `DiagnosticReport`/`BusinessReport` run `heterogeneity` (on the relayed GROUP percentile dict - `_collect_effect_scalars` reads `group_effects` first) and `parallel_trends` (`pretrends=True`) on the relayed surface instead of skipping with `NotImplementedError` (no sensitivity check applies); `plot_event_study(results)` plots them natively. Replicate-weight designs never meet the always-on surfaces (replicate + `n_bootstrap > 0` is rejected before them), so the fit-time replicate stack is always `[overall]`-only - the degenerate-design overall-row migration delta in (e) stands. - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. @@ -2332,7 +2334,7 @@ Our implementation uses multiplier bootstrap on the GMM influence function: clus - **Note:** Zero-total-weight groups (e.g. whole PSUs zeroed by JK1/BRR replicate weights, which reach Stage 1 unmasked — `keep_mask` only drops always-treated units): a unit/period whose observations ALL carry zero weight surfaces as `NaN` FE (key retained for the rank-condition membership check; matches the SpilloverDiD `_iterative_fe_subset` REGISTRY contract — never a silent finite `0.0`), and the shared demean engine's inert-row guard leaves those rows un-demeaned instead of NaN-poisoning the column. Before 3.7 the pandas loops divided 0/0 there: the covariate replicate path NaN-poisoned `y_dm`/`X_dm`, failed EVERY replicate refit inside `solve_ols(check_finite=True)`, and returned NaN SEs after a non-convergence warning storm. It now produces finite replicate SEs. `_mask_nan_ytilde`'s "non-finite imputed outcomes" `UserWarning` is suppressed (via `warn_nan=False`) ONLY inside the replicate-refit closures, where NaN FE for zeroed PSUs is expected mechanics — the main-fit warning is unchanged. - **Note:** When the Stage-2 bread `X'_2 W X_2` is singular, both the analytical TSL variance (`two_stage.py`) and the multiplier-bootstrap bread (`two_stage_bootstrap.py`) now emit a `UserWarning` before falling back to `np.linalg.lstsq`. Previously this fallback was silent. Sibling of axis-A finding #17 in the Phase 2 silent-failures audit; surfaced by the repo-wide lstsq-fallback pattern grep that accompanied the StaggeredTripleDifference fix. - **Note:** The GMM sandwich and bootstrap paths both use `scipy.sparse.linalg.factorized` for the Stage 1 normal-equations solve `(X'_{10} W X_{10}) gamma = X'_1 W X_2` and fall back to a **certified sparse LSMR** solve when the sparse factorization raises `RuntimeError` on a near-singular matrix (2026-07; previously dense `lstsq(toarray())`, an `O((U+T+K)²)` materialization and OOM risk on large panels — the pattern the ImputationDiD LSMR fix closed). Solver choice cannot change the output: least-squares solutions differ only by `null(X'X) = null(X_10)` components (weighted: `null(W^{1/2}X_10)`; zero-weight rows are inert in every weighted consumer), and every `gamma_hat` consumer is an `X_10`-range functional — `Psi = X_10 gamma`, the GMM score correction `c_g' gamma` with `c_g = X_{10,g}' eps_{10,g}` in `rowspace(X_10)` — so its null component annihilates. The bootstrap's `X_1_sparse @ theta_exact` consumer evaluates theta on treated rows where annihilation does not apply; parity there holds because both dense `lstsq` (SVD) and LSMR return the MIN-NORM least-squares solution, so the solvers agree on the whole vector to iterative tolerance (helper-level dense-lstsq-oracle parity test on a singular Gram + fit-level singular-design V/SE parity test). Convergence is validated fail-closed: `istop` in `{0,1,2,4,5}` certified, one uncapped-conlim retry, then `_LSMRUnconvergedError` — the analytical variance boundary reports NaN vcov/SE (raising matters: the GMM score `nan_to_num` would otherwise launder NaN scores into zeros and a finite, wrong variance) and the bootstrap boundary degenerates to the established `None` contract. All fallback sites (analytical unweighted + weighted, SpilloverDiD Wave-D meat, bootstrap) emit a `UserWarning` (silent-failure audit axis C). -- **Note:** The GMM sandwich re-solves the Stage-1 unit+time fixed effects **exactly** (sparse OLS reusing the `scipy.sparse.linalg.factorized` factorization of `(X'_{10} W X_{10})` already computed for `gamma_hat`), rather than reusing the iterative alternating-projection FE (`_iterative_fe`) that produces the point estimate. The iterative solver converges only to ~1e-7 on unbalanced *untreated* panels — negligible for the ATT, but enough to perturb the variance by ~1% relative to the analytical GMM sandwich. The exact re-solve makes the analytical GMM SE match R `did2s` to ~1e-7 (`tests/test_methodology_two_stage.py::TestTwoStageDiDParityR`), mirroring ImputationDiD's exact-sparse variance path (`imputation.py` `_build_A_sparse`). It also required adding an **intercept column** to `_build_fe_design` so the first-stage column space spans the constant (the grand mean): the prior intercept-free `[unit_1.., time_1..]` layout (drop first unit + first time, no intercept) silently omitted the grand mean, which the exact residual is first-order sensitive to (the iterative point-estimate solver absorbs the grand mean into its mean-based FE, so the point estimate was unaffected). Obs whose unit or time FE are unidentified (NaN; rank-deficient / Proposition-5) fall back to the iterative residual, so those edge cases are unchanged. The reported `overall_att` still uses the iterative FE (preserving point-estimate equivalence with ImputationDiD at 1e-10); only the variance uses the exact residuals. +- **Note:** The GMM sandwich re-solves the Stage-1 unit+time fixed effects **exactly** (sparse OLS reusing the `scipy.sparse.linalg.factorized` factorization of `(X'_{10} W X_{10})` already computed for `gamma_hat`), rather than reusing the iterative alternating-projection FE (`_iterative_fe`) that produces the point estimate. The iterative solver converges only to ~1e-7 on unbalanced *untreated* panels — negligible for the ATT, but enough to perturb the variance by ~1% relative to the analytical GMM sandwich. The exact re-solve makes the analytical GMM SE match R `did2s` to ~1e-7 (`tests/test_methodology_two_stage.py::TestTwoStageDiDParityR`), mirroring ImputationDiD's exact-sparse variance path (`imputation.py` `_build_A_sparse`). It also required adding an **intercept column** to `_build_fe_design` so the first-stage column space spans the constant (the grand mean): the prior intercept-free `[unit_1.., time_1..]` layout (drop first unit + first time, no intercept) silently omitted the grand mean, which the exact residual is first-order sensitive to (the iterative point-estimate solver absorbs the grand mean into its mean-based FE, so the point estimate was unaffected). Obs whose unit or time FE are unidentified (NaN; rank-deficient / Proposition-5) fall back to the iterative residual, so those edge cases are unchanged. The reported `att` still uses the iterative FE (preserving point-estimate equivalence with ImputationDiD at 1e-10); only the variance uses the exact residuals. - **Note:** `vcov_type` is permanently narrow to `{"hc1"}` (Phase 1b threading). TwoStageDiD's variance is the Gardner (2022) two-stage GMM cluster-sandwich `V = (X'_2 W X_2)^{-1} (S' S) (X'_2 W X_2)^{-1}` with the per-cluster GMM-corrected score `S_g = gamma_hat' c_g - X'_{2g} eps_{2g}`. Analytical-sandwich families `{classical, hc2, hc2_bm}` are rejected at `__init__`/`fit()`: the GMM-corrected meat folds first-stage FE estimation uncertainty into the score via the `gamma_hat' c_g` term, so there is no single hat matrix spanning both stages on which HC2 leverage or Bell-McCaffrey Satterthwaite DOF can be defined, and the Gardner first-stage correction has not been derived for the leverage-corrected or homoskedastic meat structures (no reference implementation — `clubSandwich` covers single-equation WLS/OLS CR2, not two-stage GMM; mirrors the SpilloverDiD `vcov_type="classical"` rejection). `cluster=` selects the cluster level; `cluster=None` (the default) still clusters at the `unit` column (`cluster_var = unit`), so the summary renders `"CR1 cluster-robust at , G="` rather than the generic `"HC1"` label. **Note (deviation from R):** the did2s GMM sandwich uses NO finite-sample multiplier (meat `= S' S`), so the rendered `CR1` family label carries no Stata-style `(n-1)/(n-p)` or `G/(G-1)` factor (matches R `did2s`; same FSA-free convention as ImputationDiD's Theorem 3 variance). Under bootstrap (`n_bootstrap > 0`) the analytical variance-family label is suppressed in `summary()` because `fit()` overwrites the reported SE/CI/p-value with `bootstrap_results` (mirrors `DiDResults` at `results.py:213-226`). `cluster=` combined with a replicate-weight survey design raises `NotImplementedError` (replicate-refit variance ignores `cluster=`). `vcov_type='conley'` is deferred to the TwoStageDiD Conley follow-up row in DEFERRED.md. - **Note:** Provenance exposure (4.0 program row M-092, no numeric change): the full Gardner-GMM event-study covariance `V` is now persisted as `TwoStageDiDResults.event_study_vcov` (+ `event_study_vcov_index` = the estimated horizons, in Stage-2 column order). The reference period and Proposition-5 horizons are never Stage-2 columns and are absent from the index; an estimated horizon whose observations are all filtered keeps its column with the rank-guard's NaN row/col (consistent with its NaN marginal SE). Persisted only on the analytical paths - `None` under bootstrap (percentile inference has no covariance) and replicate-weight survey designs (the replicate VCV's [overall, ES, groups] layout is not an ES matrix; CS precedent). `event_study_df` records the scalar survey df every estimated ES row's `safe_inference` received (None on non-survey fits: normal-theory GMM inference). @@ -2401,7 +2403,7 @@ Estimated via WLS with Q-weights. The delta_h coefficients identify theta_kappa^ *Variance families (`vcov_type`):* - `hc1` (default) — CR1 Liang-Zeger cluster-robust via `solve_ols(weights=composed_weights, vcov_type="hc1")` (Stata-style `G/(G-1) * (n-1)/(n-p)` finite-sample correction). Matches `clubSandwich::vcovCR(lm(weights=Q,...), cluster=~unit, type="CR1S")` at atol=1e-10 on the new `benchmarks/data/stacked_did_golden.json` fixture (R-side target is `CR1S` not plain `CR1` — the latter omits the `(n-1)/(n-p)` term and diverges by ~1.4% on a 325-row, 10-column stacked design). Bit-equal to the prior bake-Q-into-X output up to float64 multiplication ordering (atol=1e-13; WLS-CR1 score is algebraically invariant between the two forms). -- `hc2_bm` — CR2 Bell-McCaffrey via `solve_ols(weights=composed_weights, vcov_type="hc2_bm")` routed through the clubSandwich WLS-CR2 port (PR #475). The diff-diff implementation matches clubSandwich's specific algebra (W not √W in the hat matrix, W² in the bias-correction term, unweighted residuals in the score construction) — see Phase 1a `hc2_bm + weights` registry row for the full derivation. Matches `clubSandwich::vcovCR(lm(weights=Q,...), cluster=~unit, type="CR2") + coef_test()$df_Satt` at atol=1e-10 (pinned in `tests/test_methodology_stacked_did.py`). **Bell-McCaffrey Satterthwaite DOF is threaded into the user-facing aggregated inference** (`event_study_effects[h]['p_value']`/`['conf_int']` use the per-event-time contrast DOF from `_compute_cr2_bm_contrast_dof`; `overall_p_value`/`overall_conf_int` use the post-period-average contrast DOF, matching R `Wald_test(constraints=row, vcov=CR2, test="HTZ")$df_denom` at atol=1e-10). Mirrors the SunAbraham aggregated-inference pattern from PR #472. +- `hc2_bm` — CR2 Bell-McCaffrey via `solve_ols(weights=composed_weights, vcov_type="hc2_bm")` routed through the clubSandwich WLS-CR2 port (PR #475). The diff-diff implementation matches clubSandwich's specific algebra (W not √W in the hat matrix, W² in the bias-correction term, unweighted residuals in the score construction) — see Phase 1a `hc2_bm + weights` registry row for the full derivation. Matches `clubSandwich::vcovCR(lm(weights=Q,...), cluster=~unit, type="CR2") + coef_test()$df_Satt` at atol=1e-10 (pinned in `tests/test_methodology_stacked_did.py`). **Bell-McCaffrey Satterthwaite DOF is threaded into the user-facing aggregated inference** (`event_study_effects[h]['p_value']`/`['conf_int']` use the per-event-time contrast DOF from `_compute_cr2_bm_contrast_dof`; `p_value`/`conf_int` use the post-period-average contrast DOF, matching R `Wald_test(constraints=row, vcov=CR2, test="HTZ")$df_denom` at atol=1e-10). Mirrors the SunAbraham aggregated-inference pattern from PR #472. - `classical`, `hc2` — REJECTED at `__init__` with `ValueError`. StackedDiD clusters intrinsically at `'unit'` or `'unit_subexp'` (no `cluster=None` opt-out); the linalg validator rejects one-way families paired with `cluster_ids`. Use `vcov_type='hc1'` or `'hc2_bm'`. - `conley` — REJECTED at `__init__` with `ValueError`. Deferred for a **methodology** reason, NOT plumbing (unlike the SunAbraham / WooldridgeDiD-OLS conley threading): the stacked design replicates each control unit across every sub-experiment it qualifies for (`_build_sub_experiment`), so one geographic unit occupies many stacked rows. Conley's pairwise distance matrix would see those same-unit copies at distance 0 (`K(0)=1`), conflating the stacking-replication device with real spatial correlation; a correct treatment needs a per-stack spatial identifier and has no `conleyreg` analogue to anchor parity. Paper-gated; tracked in `DEFERRED.md`. - `survey_design=` + `vcov_type ∈ {classical, hc2, hc2_bm}` — REJECTED at `fit()` with `NotImplementedError`; the survey TSL or replicate-weight refit variance overrides the analytical sandwich. Use `vcov_type='hc1'` (default) for survey designs. Reject order: the existing fweight/aweight check at `stacked_did.py:309` fires first (Q-weight ratio semantics), then the survey + non-hc1 check at `stacked_did.py:~325` — locked by `test_aweight_plus_hc2_bm_rejected_by_stacked_did_level_guard`. @@ -2551,9 +2553,9 @@ The interaction coefficient `δ_{g,t}` identifies `ATT(g, t)` under parallel tre - **Note (comparison-support filtering is REFUSED under `survey_design=`):** a second survey boundary alongside the unidentified-cohort refusal below, and for the same reason — deleting rows removes their PSUs and strata from the Taylor-linearized meat and from `df_survey = n_PSU − n_strata`. Raised **before any row is removed**, and strictly conditional on the filter actually dropping rows (survey fits that lose nothing are unaffected). The message points at **explicit frame restriction**, not `SurveyDesign.subpopulation()`: subpopulation zero-pads the excluded rows, which `_reject_zero_weight_groups` then refuses on the OLS path (measured: `Survey weights sum to zero for time period(s) [8, 9]`). **That workaround is exact only when every PSU and stratum survives the restriction**, which holds on a BALANCED panel — measured there: no PSU and no stratum is removed, `df_survey` is unchanged, and ATTs/SEs agree to ≤5e-15. It does NOT hold in general: on an unbalanced panel a PSU or stratum observed only at unsupported periods disappears with them (measured: restricting a 6-period frame to its 5 supported periods dropped one PSU and one stratum entirely), which changes the Taylor-linearized meat and can change `df_survey`. Before relying on the restriction, confirm the restricted frame retains every PSU and stratum of the design you specified and is the survey universe you intend; if it does not, there is no supported path until domain estimation lands. - **Note (control-pool asymmetry on `never_treated`):** `control_group="never_treated"` restricts the comparison pool to never-treated units **on the OLS path only**, where `include_pre` emits every `(g, t)` except each cohort's reference and treated units' pre-treatment rows therefore sit in their own indicators. On `method="logit"` / `"poisson"` only post-treatment cells are emitted — including all cells would make each cohort dummy collinear with the sum of its own indicators — so treated units' pre-treatment rows ARE the identifying comparison there, exactly as under `not_yet_treated`. Measured at HEAD on `never_treated` + Poisson with never-treated units observed only at `t=1..3` and cohorts 4, 7 at `t=1..5`: the fit succeeds and perturbing only cohort-7 rows at `t∈{4,5}` moves `ATT(4,4)` and `ATT(4,5)` materially, while the OLS counterpart on a panel where that fit is defined is invariant to ~1e-15. This asymmetry is **pre-existing and structural**, not introduced by the support filter. Related: `n_control_units` counts never-treated UNITS on this setting regardless of method, so on the nonlinear paths it under-reports the rows actually doing the comparison — widening it is tracked in `TODO.md`. - **Note:** unobserved `(g, t)` pairs are skipped rather than emitted as identically-zero columns, and the skipped pairs are named in a `UserWarning` (gated on `rank_deficient_action`, so `"silent"` remains silent). -- **Note (within-cohort support connectivity — OLS/unit-FE path only):** per-period support is **necessary but not sufficient** for identification. Identification runs through the unit fixed effects, so each emitted `(g, t)` cell must be connected to an **unemitted** period in the bipartite graph whose nodes are that cohort's units and periods (edge = a supported observation). Within any connected component in which *every* period is a treatment cell, those columns sum to the component's unit indicators — absorbed by the unit FE — so the block is rank-deficient and QR drops one, potentially a genuine post-treatment effect, leaving the overall ATT an average over an incomplete cell set. This is issue #724's failure mode reached through **unit** support rather than through the reference period, and per-period observation counts cannot detect it. Measured on a panel where cohort 4's units split into `{2, 4}` and `{1, 5}` groups: `g4_t5` was silently dropped and `overall_att` computed from the single surviving post cell. `fit()` now **fails closed** naming the unidentified cells (not gated on `rank_deficient_action` — that setting governs how rank warnings surface, not whether an unidentified estimand may be returned as a number). On `not_yet_treated` and the nonlinear paths pre-treatment periods are not emitted, so a component holding any pre-period is safe — which is why the condition is "contains an unemitted period", not "contains the reference". The condition applies **only to the OLS path**, whose within-transformation absorbs the unit fixed effects: `logit`/`poisson` use explicit cohort + time dummies, nothing absorbs a component's cell block, and such designs are full rank — so the check is gated on `method="ols"` and those paths estimate the same panel normally. Component-aware estimation (rather than refusal) is tracked in `TODO.md`. +- **Note (within-cohort support connectivity — OLS/unit-FE path only):** per-period support is **necessary but not sufficient** for identification. Identification runs through the unit fixed effects, so each emitted `(g, t)` cell must be connected to an **unemitted** period in the bipartite graph whose nodes are that cohort's units and periods (edge = a supported observation). Within any connected component in which *every* period is a treatment cell, those columns sum to the component's unit indicators — absorbed by the unit FE — so the block is rank-deficient and QR drops one, potentially a genuine post-treatment effect, leaving the overall ATT an average over an incomplete cell set. This is issue #724's failure mode reached through **unit** support rather than through the reference period, and per-period observation counts cannot detect it. Measured on a panel where cohort 4's units split into `{2, 4}` and `{1, 5}` groups: `g4_t5` was silently dropped and `att` computed from the single surviving post cell. `fit()` now **fails closed** naming the unidentified cells (not gated on `rank_deficient_action` — that setting governs how rank warnings surface, not whether an unidentified estimand may be returned as a number). On `not_yet_treated` and the nonlinear paths pre-treatment periods are not emitted, so a component holding any pre-period is safe — which is why the condition is "contains an unemitted period", not "contains the reference". The condition applies **only to the OLS path**, whose within-transformation absorbs the unit fixed effects: `logit`/`poisson` use explicit cohort + time dummies, nothing absorbs a component's cell block, and such designs are full rank — so the check is gated on `method="ols"` and those paths estimate the same panel normally. Component-aware estimation (rather than refusal) is tracked in `TODO.md`. - **Note (unidentified-cohort exclusion is REFUSED under `survey_design=`):** the row exclusion above is naive subsetting, which under a complex survey design is not domain estimation — it removes the excluded rows' PSUs and strata from the Taylor-linearized meat and from `df_survey = n_PSU − n_strata`, so surviving ATTs would be reported with variance computed on a design the user never specified (measured on a two-stratum panel: `df_survey` 22 → 14, finite SE, no diagnostic). *Subpopulation Analysis (Phase 6)* below and Lumley (2004) §3.4 require the opposite — zero the excluded rows' weights and RETAIN the strata/PSU layout — and `SurveyDesign.subpopulation()` implements that contract, but composing it here additionally requires the weighted within-transformation to tolerate zero-weight units (shared machinery). Until that lands, `fit()` raises `NotImplementedError` when `survey_design=` is combined with an unidentified cohort, on all three methods, rather than returning a confident number from the wrong design (tracked in `TODO.md`). Relatedly, the **full** `SurveyDesign` is resolved against the **pre-exclusion** sample for validation only (weights, `weight_type`, strata, PSU, FPC, nesting): every check that ran after exclusion was blind to whatever the deleted rows contained, so invalid metadata confined to an excluded cohort used to vanish silently. -- **Note (finite-overall-ATT invariant, ledger `M-124`):** a successful `fit()` reports a **finite** `overall_att` or raises explaining why it cannot, so a result object never presents a NaN point estimate as a completed fit. The invariant covers the **point estimate only**. `overall_se` and the inference fields may legitimately be NaN on a successful fit where the *variance* is unidentifiable while the ATT is not — Bell-McCaffrey DOF unavailable on the `hc2_bm` path, and `aggregate(weights="cohort_share")`, both of which fail closed to NaN through `safe_inference()` by design (see those entries). `_require_estimable_overall_att` accordingly checks `overall_att` and nothing else. Enforced at two levels because neither subsumes the other: at BUILD time (is any post-treatment cell estimable, `t >= g − anticipation`) and again POST-SOLVE in `_require_estimable_overall_att`, before every results construction. The build-time notion of a treatment cell is deliberately **wider** than the aggregation's — cells in the anticipation window (`g − anticipation <= t < g`) are estimated and reported per-cell, but excluded from the overall average per W2025 — so a cohort never observed once actually treated produces cells yet no overall estimand. Three causes are distinguished by message: no cell survived the solve (collinear design); cells survived but none has `t >= g`; post-treatment cells survived with NaN coefficients (rank-dropped). **Cohort count is not a proxy for comparison support** — a design with two surviving treated cohorts can still be fully degenerate when neither post cell has a same-period comparison unit, so the check is on surviving finite coefficients, not on how many cohorts remain. +- **Note (finite-overall-ATT invariant, ledger `M-124`):** a successful `fit()` reports a **finite** `att` or raises explaining why it cannot, so a result object never presents a NaN point estimate as a completed fit. The invariant covers the **point estimate only**. `se` and the inference fields may legitimately be NaN on a successful fit where the *variance* is unidentifiable while the ATT is not — Bell-McCaffrey DOF unavailable on the `hc2_bm` path, and `aggregate(weights="cohort_share")`, both of which fail closed to NaN through `safe_inference()` by design (see those entries). `_require_estimable_overall_att` accordingly checks `att` and nothing else. Enforced at two levels because neither subsumes the other: at BUILD time (is any post-treatment cell estimable, `t >= g − anticipation`) and again POST-SOLVE in `_require_estimable_overall_att`, before every results construction. The build-time notion of a treatment cell is deliberately **wider** than the aggregation's — cells in the anticipation window (`g − anticipation <= t < g`) are estimated and reported per-cell, but excluded from the overall average per W2025 — so a cohort never observed once actually treated produces cells yet no overall estimand. Three causes are distinguished by message: no cell survived the solve (collinear design); cells survived but none has `t >= g`; post-treatment cells survived with NaN coefficients (rank-dropped). **Cohort count is not a proxy for comparison support** — a design with two surviving treated cohorts can still be fully degenerate when neither post cell has a same-period comparison unit, so the check is on surviving finite coefficients, not on how many cohorts remain. - **Note:** OLS path uses iterative alternating-projection within-transformation (uniform weights) for exact FE absorption on both balanced and unbalanced panels. One-pass demeaning (`y - ȳ_i - ȳ_t + ȳ`) is only exact for balanced panels. - **Note:** The weighted within-transformation (`utils.within_transform` with `weights`) is invoked on every WooldridgeDiD fit (survey weights when provided, `np.ones` otherwise) and emits a `UserWarning` on non-convergence per the shared convention documented under *Absorbed Fixed Effects with Survey Weights*. - **Note:** NaN values in the `cohort` column are filled with 0 (treated as never-treated), both in `_filter_sample` and in `fit()`. This recategorization now emits a `UserWarning` reporting the affected row count so it is no longer silent (axis-E silent coercion under the Phase 2 audit). Pass `0` directly for never-treated units to avoid the warning. @@ -2683,7 +2685,7 @@ where `g(·)` is the link inverse (logistic or exp), `η_i` is the individual li - **Note:** Polynomial-trend extensions (`"quadratic"`, `"cubic"` per paper p. 2572 footnote) are NOT yet exposed — `cohort_trends` is a binary `True/False` flag for linear `dg_i · t` only. - **Note:** `cohort_trends=True` + `survey_design` is **NOT yet supported** (raises `NotImplementedError` at `fit()`). The full-dummy auto-route composed with the survey TSL variance has not been validated against R-parity goldens. Tracked in DEFERRED.md follow-up. - **Note:** `cohort_trends=True` + `control_group="never_treated"` is **NOT yet supported** (raises `NotImplementedError` at `fit()`). The OLS + never_treated branch emits the `(g, t)` placebo cells (paper W2025 Section 4.4 placebo coverage) MINUS each cohort's `ref(g)` reference cell (W2025 Eq. 6.1/6.4; see the reference-period Note below). The appended `dg_i · t` trend columns are still spanned — jointly by the emitted cells and the unit fixed effects, which absorb `1{cohort=g}` and thereby recover the omitted reference — so the Section 8 trend specification remains unidentified on this branch. Use `control_group="not_yet_treated"` (the default) for `cohort_trends=True`. Tracked in DEFERRED.md follow-up. -- **Note:** Identification + baseline normalization for `cohort_trend_coefs` on all-eventually-treated panels: when a never-treated cohort (`g = 0`) is present, **all** `G` treated cohorts get a `dg_i · t` interaction column and `cohort_trend_coefs[g]` reports each cohort's linear slope relative to the never-treated baseline (absorbed by time FE). When **no** never-treated cohort exists, the **last cohort's** trend column is dropped deterministically per paper W2025 Section 5.4 ("all variables in regression (5.3) involving `dT_i` get dropped"); that cohort serves as the trend baseline, and `cohort_trend_coefs` surfaces `G - 1` entries (the last cohort is absent — its slope is the baseline in deviation form). **Note (deviation from W2025 Section 5.4 — narrowed to the covariate block):** Section 5.4's "all variables in regression (5.3) involving `dT_i` get dropped" governs four regressor families: the cells `dT·f_t`, the cell x covariate block `dT·f_t·ẋ`, the cohort trend `dT·t`, and the cohort x covariate block `D_T × X`. The library implements the first three — comparison-support filtering (below) removes the periods at which no unit is untreated, so cohort `G_max` receives no cells and no trend column, and the trend-drop path described here is reachable end-to-end. **The `D_{G_max} × X` normalization is NOT applied:** when covariates are supplied, `D_g × X` is still built for every cohort including `G_max`, so `Σ_g (D_g · x) = x` and the design is rank-deficient. Coefficients are unaffected (QR's arbitrary pick spans the same column space, `max |Δ| = 1.35e-14`), but `rank_deficient_action="error"` raises and `"warn"` drops an arbitrarily-chosen cohort's column rather than deterministically the last. Tracked in `TODO.md`; note for whoever takes it that `xtvar` under the default `demean_covariates=True` is FULL RANK (the raw block carries demeaned values while `D_g × X` carries raw ones, so the dependency does not arise) and forcing the drop there moves `overall_att` 1.11903 → 1.46269. +- **Note:** Identification + baseline normalization for `cohort_trend_coefs` on all-eventually-treated panels: when a never-treated cohort (`g = 0`) is present, **all** `G` treated cohorts get a `dg_i · t` interaction column and `cohort_trend_coefs[g]` reports each cohort's linear slope relative to the never-treated baseline (absorbed by time FE). When **no** never-treated cohort exists, the **last cohort's** trend column is dropped deterministically per paper W2025 Section 5.4 ("all variables in regression (5.3) involving `dT_i` get dropped"); that cohort serves as the trend baseline, and `cohort_trend_coefs` surfaces `G - 1` entries (the last cohort is absent — its slope is the baseline in deviation form). **Note (deviation from W2025 Section 5.4 — narrowed to the covariate block):** Section 5.4's "all variables in regression (5.3) involving `dT_i` get dropped" governs four regressor families: the cells `dT·f_t`, the cell x covariate block `dT·f_t·ẋ`, the cohort trend `dT·t`, and the cohort x covariate block `D_T × X`. The library implements the first three — comparison-support filtering (below) removes the periods at which no unit is untreated, so cohort `G_max` receives no cells and no trend column, and the trend-drop path described here is reachable end-to-end. **The `D_{G_max} × X` normalization is NOT applied:** when covariates are supplied, `D_g × X` is still built for every cohort including `G_max`, so `Σ_g (D_g · x) = x` and the design is rank-deficient. Coefficients are unaffected (QR's arbitrary pick spans the same column space, `max |Δ| = 1.35e-14`), but `rank_deficient_action="error"` raises and `"warn"` drops an arbitrarily-chosen cohort's column rather than deterministically the last. Tracked in `TODO.md`; note for whoever takes it that `xtvar` under the default `demean_covariates=True` is FULL RANK (the raw block carries demeaned values while `D_g × X` carries raw ones, so the dependency does not arise) and forcing the drop there moves `att` 1.11903 → 1.46269. ### Deviations from the paper / from R / library extensions @@ -2694,7 +2696,7 @@ Consolidated list of substantive deviations from the W2025 paper and from R `etw 3. **QMLE sandwich `(G/(G-1)) · ((n-1)/(n-k))`** (vs Stata `jwdid` `G/(G-1)` only). Conservative; for typical panels n >> k the difference is negligible. Tracked in DEFERRED.md (WooldridgeDiD follow-up cluster row). See § Method Note. 4. **Nonlinear methods via direct QMLE** (vs R `etwfe` fixest backend). Avoids statsmodels/fixest dependency. See § Method Deviation from R. 5. **Logit cohort+time additive dummies** (not unit FE) to avoid incidental-parameters bias in short panels. Matches Stata `jwdid method(logit)`. See § Edge cases Note. -6. **Anticipation + aggregation**: `aggregate(type="simple", weights="cell")` uses `t >= g` as the post-treatment threshold regardless of `anticipation`. Anticipation-window leads are estimated as placebos but excluded from `overall_att`. See § Edge cases Note. +6. **Anticipation + aggregation**: `aggregate(type="simple", weights="cell")` uses `t >= g` as the post-treatment threshold regardless of `anticipation`. Anticipation-window leads are estimated as placebos but excluded from `att`. See § Edge cases Note. 7. **Response-scale ATT vs R `etwfe` log-link coefficients** (Poisson + logit): diff-diff's `WooldridgeDiD(method="poisson" | "logit")` returns ATT on the response scale (counterfactual mean difference per paper W2023 ASF / APE framework); R `etwfe(family="poisson" | "logit")` returns the cell-level log-link / log-odds coefficient. Numerical cell-level R-parity for nonlinear paths requires either `emfx()`-based APE extraction on the R side or link-function inversion with baseline-mean adjustment; deferred (DEFERRED.md row, added in PR-B). See `tests/test_methodology_wooldridge.py::TestWooldridgeParityRPoisson` / `TestWooldridgeParityRLogit` for the current surface-test scope. - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense); this superseded the local `>= 0` check, CHANGING the error message text (now the shared `anticipation must be a non-negative integer ...` wording) and turning the `None`/str raw `TypeError` into a `ValueError` — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. @@ -4232,7 +4234,7 @@ shared verbatim. `g > 0` and the never-enabled set from `g == 0`, so a negatively-encoded unit belonged to NEITHER — still counted toward `n_obs` while contributing to no ATT comparison — and the fit returned a plausible finite estimate for a - different population (measured on the 3(b) fixture: `overall_att` + different population (measured on the 3(b) fixture: `att` 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). @@ -4347,7 +4349,7 @@ functions across comparison groups. Minimizes asymptotic variance subject to `su Event study (Equation 4.13): cohort-share-weighted average across cohorts for each relative time `e = t - g`. Reuses `CallawaySantAnnaAggregationMixin._aggregate_event_study()`. -Overall ATT: two summaries are available. (1) The default `overall_att` is the +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")`. @@ -4373,7 +4375,7 @@ uncertainty in cohort-share weights, inherited from the CallawaySantAnna mixin. R's `agg_ddd(type="group")` uses `wif=NULL` for per-cohort aggregation since within-cohort weights are fixed. This makes our per-cohort group-effect SEs slightly conservative relative to R. -- **Note:** The default overall ATT (`overall_att`) is the Callaway-Sant'Anna simple +- **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` diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index b72b83878..2e589695e 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -101,7 +101,7 @@ The BusinessReport and DiagnosticReport schemas both carry a top-level `target_parameter` block that names what scalar the headline number actually represents. The dispatched result classes have meaningfully different estimands — a stakeholder reading -`overall_att = -0.0214` on a Callaway-Sant'Anna fit cannot tell +`att = -0.0214` on a Callaway-Sant'Anna fit cannot tell whether that is the simple-weighted average across `ATT(g,t)` cells, an event-study-weighted aggregate, or a group-weighted aggregate. Baker et al. (2025) Step 2 is "Define the target @@ -114,7 +114,7 @@ Schema shape: "name": "overall ATT (cohort-size-weighted average of ATT(g,t))", "definition": "A cohort-size-weighted average of group-time ATTs ...", "aggregation": "simple", - "headline_attribute": "overall_att", + "headline_attribute": "att", "reference": "Callaway & Sant'Anna (2021); REGISTRY.md Sec. CallawaySantAnna" } ``` @@ -143,7 +143,7 @@ Field semantics: - `"dose_overall"` (ContinuousDiD) - `"ddd"` / `"staggered_ddd"` (TripleDifference / StaggeredTripleDiff) - `"spillover"` (SpilloverDiD — Butts 2021 total effect on the treated `tau_total`, identified off far-away controls; headline `att`) - - dCDH dynamic branches follow the exact `overall_att` + - dCDH dynamic branches follow the exact `att` contract: `"M"` / `"M_x"` / `"M_fd"` / `"M_x_fd"` for `L_max=None`; `"DID_1"` / `"DID_1_x"` / `"DID_1_fd"` / `"DID_1_x_fd"` for `L_max=1`; `"delta"` / `"delta_x"` for @@ -154,10 +154,13 @@ Field semantics: `"twfe"` (BaconDecomposition read-out) / `"unknown"` (default fallback). - `headline_attribute` — the raw result attribute the scalar - comes from (`"overall_att"` / `"att"` / `"twfe_estimate"`; + 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; `"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 `overall_att` is intentionally NaN by + L_max>=2` branch where `att` is intentionally NaN by design). Agents dispatching on this field must handle `None` by inspecting `headline.reason` (BR) / `headline_metric.reason` (DR), which distinguishes two subcases: @@ -200,7 +203,7 @@ A few branches read fit-time config from the result object: describing which units qualify as controls. - `ChaisemartinDHaultfoeuilleResults.L_max` + `covariate_residuals` + `linear_trends_effects`: branches the - dCDH estimand tag per the exact `overall_att` contract in + dCDH estimand tag per the exact `att` contract in `chaisemartin_dhaultfoeuille.py:2602-2634` and `chaisemartin_dhaultfoeuille.py:2828-2834`: - `L_max=None` → `DID_M` (Phase 1 per-period aggregate; @@ -210,7 +213,7 @@ A few branches read fit-time config from the result object: `aggregation="DID_1"`). - `L_max>=2` → cost-benefit `delta` (Lemma 4 cross-horizon aggregate; `aggregation="delta"`). - - `trends_linear=True` AND `L_max>=2` → `overall_att` is + - `trends_linear=True` AND `L_max>=2` → `att` is intentionally NaN (no scalar aggregate; per-horizon level effects live on `results.linear_trends_effects[l]`). `aggregation="no_scalar_headline"` and @@ -226,8 +229,8 @@ A few branches read fit-time config from the result object: A few branches emit a fixed tag regardless of fit-time config — notably `CallawaySantAnna`, `ImputationDiD`, `TwoStageDiD`, and -`WooldridgeDiD`. For these estimators the `overall_att` -(or `att`) scalar is ALWAYS the simple weighted +`WooldridgeDiD`. For these estimators the `att` +scalar is ALWAYS the simple weighted aggregation; post-fit `results.aggregate()` (the successor to the deprecated fit-time `aggregate` kwarg, rows M-020..M-027) returns the horizon / group tables without changing the headline scalar — @@ -580,6 +583,22 @@ a library setting. serialized output from 4.0). A key removal is breaking under the policy above, hence the major bump; `DIAGNOSTIC_REPORT_SCHEMA_VERSION` stays 2.0. +- **Note:** `DIAGNOSTIC_REPORT_SCHEMA_VERSION` 2.0 -> 3.0 and + `BUSINESS_REPORT_SCHEMA_VERSION` 3.0 -> 4.0 at the 4.0 cut (PR 5b, + 2026-10-26, the M-050..M-058 storage flip): the nine flipped containers + store the canonical quintet natively, so `_extract_scalar_headline`'s + legacy `overall_*` / `overall_att_*` alias row was deleted and + `_reporting_helpers.describe_target_parameter` reports the raw attribute + the scalar comes from as `att`. Observable in the schemas: DR's + `headline_metric.name` and both reports' `target_parameter.headline_attribute` + 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). + - **Note:** Schema version 2.0 (both BR and DR). The BR/DR gap #6 target-parameter PR adds the `headline.status` / `headline_metric.status` value `"no_scalar_by_design"` (used for diff --git a/docs/methodology/continuous-did.md b/docs/methodology/continuous-did.md index aac1fefda..1d2c2e4f8 100644 --- a/docs/methodology/continuous-did.md +++ b/docs/methodology/continuous-did.md @@ -473,7 +473,7 @@ can be meaningfully aggregated. under conditional parallel trends (`covariates=`, `estimation_method=`). Each `(g,t)` cell replaces the unconditional control mean with a covariate-adjusted counterfactual (`reg`: `ΔY_i − X_i'γ̂`; `dr`: additionally minus the DRDID augmentation `η̄_cont`); the B-spline dose - layer is unchanged. Scalar `overall_att` + SE match `DRDID::reg_did_panel` / `drdid_panel`. `ipw` + layer is unchanged. Scalar `att` + SE match `DRDID::reg_did_panel` / `drdid_panel`. `ipw` is not offered on the dose curve (its covariate adjustment is a scalar level shift → `ACRT(d)` unchanged), and `covariates=` + `survey_design=` is deferred. See REGISTRY § ContinuousDiD Note #5. diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index 170cce54d..2c4f0b651 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -228,13 +228,14 @@ replicate-weight `ImputationDiD`/`TwoStageDiD` designs: the plain fit's replicat ## Results fields -Nine containers rename `overall_att` to the canonical `att`. **The new accessor already works -today**, so this migration can be done immediately; the old name becomes a warning-emitting -property at 4.0 and is removed at 5.0. +Nine containers rename `overall_att` to the canonical `att`. Since 4.0 the canonical name +is the stored dataclass field and the old name is a read-only warning-emitting property +(removed at 5.0); the new accessor already worked under 3.x, so a codebase that migrated +early sees no change. ```python -att = results.att # canonical, works now -att = results.overall_att # warns from 4.0, removed at 5.0 +att = results.att # canonical: the stored field +att = results.overall_att # FutureWarning since 4.0, removed at 5.0 ``` **The whole inference quintet moves, not just the point estimate.** Wherever a container @@ -250,12 +251,35 @@ names in the same step: | `overall_conf_int` | `conf_int` | ```{note} -`ContinuousDiDResults` is the outlier: its sibling fields are spelled +`ContinuousDiDResults` is the outlier: its sibling fields were spelled `overall_att_se`, `overall_att_t_stat`, `overall_att_p_value` and -`overall_att_conf_int` — with the `att` infix — and they flip to the same canonical -names. If you grep for `overall_se` you will miss them. +`overall_att_conf_int` — with the `att` infix — and they flipped to the same canonical +names. If you grep for `overall_se` you will miss them. Since 4.0 all nine of its legacy +spellings warn: the five former fields AND the four former silent `overall_se` / +`overall_t_stat` / `overall_p_value` / `overall_conf_int` aliases. ``` +**Constructing a results object by hand** (test fixtures, adapters) takes the canonical +keywords: `CallawaySantAnnaResults(att=..., se=..., t_stat=..., p_value=..., conf_int=..., ...)`. +Passing a legacy keyword such as `overall_att=` raises `TypeError`. The fields were renamed in +their original slots, so positional construction order is unchanged. + +**Pickles written under 3.x load unchanged.** Each flipped container's `__setstate__` rewrites +the legacy `overall_*` keys to the canonical names on load (an existing canonical key wins), +so the loaded object stores the quintet natively and `overall_*` never reappear in its +`__dict__` or in a re-pickle. + +**The legacy names are read-only.** Assigning `results.overall_se = x` raises +`AttributeError`; assign the canonical field (`results.se = x`) instead. The bootstrap draw +containers (`results.bootstrap_results.overall_att_se` and friends) are NOT part of this +flip: they hold no point estimate, keep their payload names, and emit no warning. + +**Report schemas.** `DiagnosticReport.to_dict()` reports `headline_metric.name` and +`target_parameter.headline_attribute` as `"att"` for the nine containers (`"overall_att"` +under 3.x), and `DIAGNOSTIC_REPORT_SCHEMA_VERSION` is `"3.0"`; +`BusinessReport.to_dict()` reports `target_parameter.headline_attribute` as `"att"` likewise +and `BUSINESS_REPORT_SCHEMA_VERSION` is `"4.0"` (its `headline` block carries no name key). + ## Inference defaults that move numbers Two changes alter results without changing any call: @@ -459,15 +483,15 @@ does not mean no action is required, so read the `Fix` cell. | M-130 | df-convention-flip | `diff_diff:StackedDiD[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | | M-131 | df-convention-flip | `diff_diff:ImputationDiD[df_convention]` | — | The `df_convention` default flips to `"cluster"`; pass `df_convention="residual"` to keep 3.x numbers. | | M-090 | diagnostic-family | `diff_diff:BaconDecomposition` | — | Bacon moves out of the estimator roster into the diagnostics family - update docs references and imports of the roster, not call sites. | -| M-050 | field-flip | `diff_diff:CallawaySantAnnaResults.overall_att` | `diff_diff:CallawaySantAnnaResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-051 | field-flip | `diff_diff:SunAbrahamResults.overall_att` | `diff_diff:SunAbrahamResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-052 | field-flip | `diff_diff:ImputationDiDResults.overall_att` | `diff_diff:ImputationDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-053 | field-flip | `diff_diff:TwoStageDiDResults.overall_att` | `diff_diff:TwoStageDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-054 | field-flip | `diff_diff:StackedDiDResults.overall_att` | `diff_diff:StackedDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-055 | field-flip | `diff_diff:EfficientDiDResults.overall_att` | `diff_diff:EfficientDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-056 | field-flip | `diff_diff:WooldridgeDiDResults.overall_att` | `diff_diff:WooldridgeDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-057 | field-flip | `diff_diff:ChaisemartinDHaultfoeuilleResults.overall_att` | `diff_diff:ChaisemartinDHaultfoeuilleResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | -| M-058 | field-flip | `diff_diff:ContinuousDiDResults.overall_att` | `diff_diff:ContinuousDiDResults.att` | Read `att` instead of `overall_att`; the canonical accessor already works today. | +| M-050 | field-flip | `diff_diff:CallawaySantAnnaResults.overall_att` | `diff_diff:CallawaySantAnnaResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-051 | field-flip | `diff_diff:SunAbrahamResults.overall_att` | `diff_diff:SunAbrahamResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-052 | field-flip | `diff_diff:ImputationDiDResults.overall_att` | `diff_diff:ImputationDiDResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-053 | field-flip | `diff_diff:TwoStageDiDResults.overall_att` | `diff_diff:TwoStageDiDResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-054 | field-flip | `diff_diff:StackedDiDResults.overall_att` | `diff_diff:StackedDiDResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-055 | field-flip | `diff_diff:EfficientDiDResults.overall_att` | `diff_diff:EfficientDiDResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-056 | field-flip | `diff_diff:WooldridgeDiDResults.overall_att` | `diff_diff:WooldridgeDiDResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-057 | field-flip | `diff_diff:ChaisemartinDHaultfoeuilleResults.overall_att` | `diff_diff:ChaisemartinDHaultfoeuilleResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | +| M-058 | field-flip | `diff_diff:ContinuousDiDResults.overall_att` | `diff_diff:ContinuousDiDResults.att` | Read and construct with `att`; `overall_att` is a read-only FutureWarning property since 4.0; 3.x pickles load unchanged. | | M-070 | function-wrappers | `diff_diff:imputation_did` | — | Call the estimator class directly instead of `imputation_did`. | | M-071 | function-wrappers | `diff_diff:two_stage_did` | — | Call the estimator class directly instead of `two_stage_did`. | | M-072 | function-wrappers | `diff_diff:stacked_did` | — | Call the estimator class directly instead of `stacked_did`. | diff --git a/docs/performance-scenarios.md b/docs/performance-scenarios.md index 90399120e..86e4c71b9 100644 --- a/docs/performance-scenarios.md +++ b/docs/performance-scenarios.md @@ -278,7 +278,7 @@ serves a different purpose: R-parity accuracy). They complement it. ``` - **Operation chain.** (1) dCDH fit with `L_max=3` (computes `DID_l` for l=1..3, dynamic placebos, sup-t bands, TWFE diagnostic); (2) snapshot - `placebo_effect`, `overall_att`, `joiners_att`, `leavers_att` from the + `placebo_effect`, `att`, `joiners_att`, `leavers_att` from the result object for pre-trend evidence and joiner/leaver inspection; (3) `compute_honest_did()` M-grid on the placebo event study; (4) heterogeneity refit with `heterogeneity="group"`. The TSL path for diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index f34cd6333..8704b0d69 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -638,12 +638,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning - test_ref: null + 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] - notes: "diff_diff/_deprecation.py is 5.0-WINDOW INFRASTRUCTURE (added 2026-10-18): this row's FutureWarning properties ride its deprecated_field_property helper through the 4.0 -> 5.0 window ([M-016]'s view is a leaf-local computed property in results_base.py instead - the leaf module cannot import the helper and the helper is a rename-forwarder, not a computed view), so the 4.0 removal PRs delete the shims that USE it but never the module. Pickle migration via __setstate__ per spec section 5. 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." + 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 group: field-flip @@ -652,11 +652,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning + test_ref: tests/test_v4_storage_flip.py code_refs: [diff_diff/sun_abraham.py] - notes: "" + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): SunAbrahamResults stores the canonical quintet natively (fields renamed in slot); overall_att/overall_se/overall_t_stat/overall_p_value/overall_conf_int are read-only FutureWarning deprecated_field_property shims (removed 5.0); __setstate__ migrates 3.x pickles via _deprecation.migrate_renamed_state_keys. Mechanics and the cross-cutting inventory on [M-050]." - id: M-052 kind: field group: field-flip @@ -665,11 +666,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning + test_ref: tests/test_v4_storage_flip.py code_refs: [diff_diff/imputation_results.py, diff_diff/imputation.py] - notes: "" + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): ImputationDiDResults stores the canonical quintet natively (fields renamed in slot); overall_att/overall_se/overall_t_stat/overall_p_value/overall_conf_int are read-only FutureWarning deprecated_field_property shims (removed 5.0); __setstate__ migrates 3.x pickles via _deprecation.migrate_renamed_state_keys. Mechanics and the cross-cutting inventory on [M-050]." - id: M-053 kind: field group: field-flip @@ -678,11 +680,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning + test_ref: tests/test_v4_storage_flip.py code_refs: [diff_diff/two_stage_results.py, diff_diff/two_stage.py] - notes: "" + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): TwoStageDiDResults stores the canonical quintet natively (fields renamed in slot); overall_att/overall_se/overall_t_stat/overall_p_value/overall_conf_int are read-only FutureWarning deprecated_field_property shims (removed 5.0); __setstate__ migrates 3.x pickles via _deprecation.migrate_renamed_state_keys. Mechanics and the cross-cutting inventory on [M-050]." - id: M-054 kind: field group: field-flip @@ -691,11 +694,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning + test_ref: tests/test_v4_storage_flip.py code_refs: [diff_diff/stacked_did_results.py, diff_diff/stacked_did.py] - notes: "" + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): StackedDiDResults stores the canonical quintet natively (fields renamed in slot); overall_att/overall_se/overall_t_stat/overall_p_value/overall_conf_int are read-only FutureWarning deprecated_field_property shims (removed 5.0); __setstate__ migrates 3.x pickles via _deprecation.migrate_renamed_state_keys (extends the existing clean_control -> control_group migration). Mechanics and the cross-cutting inventory on [M-050]." - id: M-055 kind: field group: field-flip @@ -704,11 +708,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning - code_refs: [diff_diff/efficient_did_results.py, diff_diff/efficient_did_aggregation.py] - notes: "" + test_ref: tests/test_v4_storage_flip.py + code_refs: [diff_diff/efficient_did_results.py, diff_diff/efficient_did_aggregation.py, diff_diff/efficient_did.py] + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): EfficientDiDResults stores the canonical quintet natively (fields renamed in slot); overall_att/overall_se/overall_t_stat/overall_p_value/overall_conf_int are read-only FutureWarning deprecated_field_property shims (removed 5.0); __setstate__ migrates 3.x pickles via _deprecation.migrate_renamed_state_keys. Mechanics and the cross-cutting inventory on [M-050]." - id: M-056 kind: field group: field-flip @@ -717,11 +722,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning - code_refs: [diff_diff/wooldridge_results.py] - notes: "" + test_ref: tests/test_v4_storage_flip.py + code_refs: [diff_diff/wooldridge_results.py, diff_diff/wooldridge.py] + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): WooldridgeDiDResults stores the canonical quintet natively (fields renamed in slot); overall_att/overall_se/overall_t_stat/overall_p_value/overall_conf_int are read-only FutureWarning deprecated_field_property shims (removed 5.0); __setstate__ migrates 3.x pickles via _deprecation.migrate_renamed_state_keys (key renames run BEFORE the existing default backfills); the two self-WRITE sites - aggregate('simple') in wooldridge_results.py and the bootstrap override in wooldridge.py - assign the canonical fields. Mechanics and the cross-cutting inventory on [M-050]." - id: M-057 kind: field group: field-flip @@ -730,11 +736,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning + test_ref: tests/test_v4_storage_flip.py code_refs: [diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/chaisemartin_dhaultfoeuille.py] - notes: "" + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): ChaisemartinDHaultfoeuilleResults stores the canonical quintet natively (fields renamed in slot); overall_att/overall_se/overall_t_stat/overall_p_value/overall_conf_int are read-only FutureWarning deprecated_field_property shims (removed 5.0); __setstate__ migrates 3.x pickles via _deprecation.migrate_renamed_state_keys (extends the existing groups -> units migration). Mechanics and the cross-cutting inventory on [M-050]." - id: M-058 kind: field group: field-flip @@ -743,11 +750,12 @@ rows: introduced_in: "4.0" deprecated_in: "4.0" removed_in: "5.0" - status: planned + status: shimmed phase: 5 warning: FutureWarning + test_ref: tests/test_v4_storage_flip.py code_refs: [diff_diff/continuous_did_results.py, diff_diff/continuous_did.py, docs/methodology/continuous-did.md] - notes: "Also flips the deviant overall_att_se/overall_att_p_value/overall_att_conf_int/overall_att_t_stat sibling fields (the trap-catalog outlier)." + notes: "SHIMMED at the 4.0 cut (PR 5b, 2026-10-26): ContinuousDiDResults stores the ATT-side headline under the canonical quintet (fields renamed in slot; overall_acrt* untouched - a separate estimand). NINE read-only FutureWarning deprecated_field_property shims onto the five canonical fields: the five former fields overall_att/overall_att_se/overall_att_t_stat/overall_att_p_value/overall_att_conf_int AND the four former silent aliases overall_se/overall_t_stat/overall_p_value/overall_conf_int (removed 5.0). __setstate__ migrates 3.x pickles via a module-local overall_att_* -> canonical map (the silent aliases were never stored). Mechanics on [M-050]. Original: Also flips the deviant overall_att_se/overall_att_p_value/overall_att_conf_int/overall_att_t_stat sibling fields (the trap-catalog outlier)." # ---- Alias table --------------------------------------------------------- - id: M-060 diff --git a/docs/v4-design.md b/docs/v4-design.md index 549adf159..47f2e08eb 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -534,7 +534,10 @@ dtype, int labels never become floats). **Pickle migration.** Renamed-field classes ship `__setstate__` migration following the existing `SyntheticDiDResults.__setstate__` precedent -(diff_diff/results.py) so 3.x pickles load under 4.0. +(diff_diff/results.py) so 3.x pickles load under 4.0; the nine M-050..M-058 +containers do so through `_deprecation.migrate_renamed_state_keys` with the +shared `LEGACY_OVERALL_QUINTET` key map (an existing canonical key wins; the +legacy key never lingers in `__dict__`). ## 6. Aggregation contract: `results.aggregate(type=...)` @@ -1153,6 +1156,23 @@ tests-first PR that flips no rows). 5. Storage flip [M-050]..[M-058] + pickle migration + the `.att` sweep over docs/notebooks/guides/benchmarks (incl. a disposition for the un-rowed `CSBootstrapResults.overall_att_se`). + (SHIPPED as three PRs: 5a (#873) and 5a-ii (#874) migrated every + in-repo reader - source, tests, docs, notebooks, guides, benchmarks - to + the canonical quintet ahead of the flip; 5b, 2026-10-26, flipped the + storage: the quintet are the native fields, renamed in slot, with the + `overall_*` names as read-only `deprecated_field_property` FutureWarning + shims (removed 5.0), `DMLDiDResults` re-declaring the five shims so the + warning names the subclass, `__setstate__` migration through the new + `_deprecation.migrate_renamed_state_keys` / `LEGACY_OVERALL_QUINTET`, + ContinuousDiD's nine spellings shimmed, the report surface reading the + raw attribute as `att` (DR schema 3.0, BR 4.0), and + `tests/test_v4_storage_flip.py` as the nine rows' `test_ref`. + Disposition of the bootstrap draw containers + (`CSBootstrapResults.overall_att_se` and siblings, `DCDHBootstrapResults. + overall_se` and siblings): LEFT AS-IS, user decision 2026-10-26, recorded + as the REGISTRY.md "Result-class field naming" Note - they hold no point + estimate, so the quintet contract does not apply. `StaggeredTripleDiffResults` + is unflipped and dies at item 6.) 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. diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py index fc3e47673..b3de4d4da 100644 --- a/tests/test_aggregate_contract.py +++ b/tests/test_aggregate_contract.py @@ -2091,7 +2091,7 @@ def test_simple_bit_exact_relay_hc1(self, stacked_fitted): assert isinstance(agg, AggregationResult) assert agg.level == "simple" assert list(agg.label) == ["overall"] - # target is "att": overall_att is the equally-weighted post-period + # target is "att": att is the equally-weighted post-period # average, NOT the per-event trimmed aggregate ATT (M-024 Note). assert list(agg.target) == ["att"] assert float(agg.att[0]) == stacked_fitted.att @@ -3697,8 +3697,8 @@ def test_replicate_overall_row_migration_delta(self): # cohort-zero design (one replicate NaNs one cohort's group target # while overall stays finite) the joint [overall, groups] stack # dropped a replicate the [overall]-only stack keeps -> the plain - # fit's overall_se differs from the pre-removal fit(aggregate='group') - # overall_se (oracle); post-fit aggregate('group') level-matches the + # fit's se differs from the pre-removal fit(aggregate='group') + # se (oracle); post-fit aggregate('group') level-matches the # oracle rows exactly. d, rep_cols = _imputation_survey_panel(replicate=True, degenerate="cohort_zero") sd = _imputation_survey_design(rep_cols) @@ -6088,7 +6088,7 @@ def test_true_overflow_blanks_whole_row(self, panel): """A FINITE overall value that becomes non-finite BY the x C scaling blanks the row entirely - the one path allowed to blank a finite att, pinned on both the att and se halves of the guard.""" - for field_name in ("overall_att", "overall_se"): + for field_name in ("att", "se"): res = CallawaySantAnna().fit(panel, **FIT_KW) setattr(res, field_name, 1e308) tot = res.aggregate("total") diff --git a/tests/test_balance_interop_contract.py b/tests/test_balance_interop_contract.py index 004f5bc05..c7e11258a 100644 --- a/tests/test_balance_interop_contract.py +++ b/tests/test_balance_interop_contract.py @@ -278,10 +278,11 @@ def test_result_accepts_provenance_attribute(fitted_cs): fitted_cs._balance_adjustment = sentinel assert fitted_cs._balance_adjustment is sentinel del fitted_cs._balance_adjustment - # ...while the flat aliases stay read-only properties (full alias - # coverage lives in tests/test_result_aliases.py - not duplicated here). + # ...while the legacy ``overall_*`` names are read-only FutureWarning + # properties since the 4.0 storage flip (the canonical ``att`` is the + # stored field; full coverage lives in tests/test_result_aliases.py). with pytest.raises(AttributeError): - fitted_cs.att = 0.0 + fitted_cs.overall_att = 0.0 # --------------------------------------------------------------------------- diff --git a/tests/test_business_report.py b/tests/test_business_report.py index 947dc520b..3dbbb3533 100644 --- a/tests/test_business_report.py +++ b/tests/test_business_report.py @@ -871,13 +871,9 @@ class StaggeredTripleDiffResults: pass obj = StaggeredTripleDiffResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 100 @@ -910,13 +906,9 @@ class ImputationDiDResults: pass obj = ImputationDiDResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 100 @@ -960,13 +952,9 @@ class TwoStageDiDResults: pass obj = TwoStageDiDResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 100 @@ -1018,13 +1006,9 @@ class EfficientDiDResults: pass stub = EfficientDiDResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -1552,13 +1536,9 @@ class StackedDiDResults: pass obj = StackedDiDResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.2 obj.se = 0.2 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 400 @@ -1604,13 +1584,9 @@ class StaggeredTripleDiffResults: pass stub = StaggeredTripleDiffResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 800 @@ -1840,13 +1816,9 @@ class StackedDiDResults: pass stub = StackedDiDResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 400 @@ -2022,13 +1994,9 @@ class TestAnticipationStripsStrictNoAnticipationClause: def _stub(class_name: str, **extras): stub_cls = type(class_name, (), {}) stub = stub_cls() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 400 @@ -2112,13 +2080,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2142,13 +2106,9 @@ class EfficientDiDResults: pass stub = EfficientDiDResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2173,13 +2133,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2210,13 +2166,9 @@ class ContinuousDiDResults: pass stub = ContinuousDiDResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_att_se = 0.2 stub.se = 0.2 - stub.overall_att_p_value = 0.001 stub.p_value = 0.001 - stub.overall_att_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 120 @@ -2253,13 +2205,9 @@ class StaggeredTripleDiffResults: pass stub = StaggeredTripleDiffResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 200 @@ -2305,13 +2253,9 @@ class WooldridgeDiDResults: pass stub = WooldridgeDiDResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2360,13 +2304,9 @@ class WooldridgeDiDResults: pass stub = WooldridgeDiDResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2447,13 +2387,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2528,7 +2464,7 @@ def test_br_schema_tier_is_downgraded(self): schema = { "schema_version": "2.0", "estimator": "CallawaySantAnnaResults", - "headline_metric": {"name": "overall_att", "value": 1.0}, + "headline_metric": {"name": "att", "value": 1.0}, "parallel_trends": { "status": "ran", "method": "joint_wald_event_study", @@ -2561,13 +2497,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2944,13 +2876,9 @@ class EfficientDiDResults: pass stub = EfficientDiDResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.3 stub.se = 0.3 - stub.overall_p_value = 0.01 stub.p_value = 0.01 - stub.overall_conf_int = (0.4, 1.6) stub.conf_int = (0.4, 1.6) stub.alpha = 0.05 stub.n_obs = 500 @@ -2998,13 +2926,9 @@ class of mistag as the round-20 Hausman finding (which was about def _build_stub(class_name: str, **extras): stub_cls = type(class_name, (), {}) stub = stub_cls() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 400 @@ -3081,13 +3005,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 1000 @@ -3139,9 +3059,7 @@ class TROPResults: pass stub = TROPResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 stub.alpha = 0.05 stub.n_obs = 100 @@ -3316,13 +3234,9 @@ class TROPResults: pass stub = TROPResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 400 @@ -3386,10 +3300,6 @@ def _stub(class_name: str, **extras): # precomputed key without touching result internals. stub_cls = type(class_name, (), {}) stub = stub_cls() - stub.overall_att = 1.0 - stub.overall_se = 0.2 - stub.overall_p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.att = 1.0 stub.se = 0.2 stub.p_value = 0.001 @@ -3558,9 +3468,7 @@ class TROPResults: pass stub = TROPResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 stub.alpha = 0.05 stub.n_obs = 100 @@ -3626,13 +3534,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 500 @@ -3693,13 +3597,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 500 @@ -3792,9 +3692,7 @@ class TROPResults: pass stub = TROPResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 stub.alpha = 0.05 stub.n_obs = 100 @@ -3808,9 +3706,7 @@ class TROPResults: pass stub = TROPResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 stub.alpha = 0.05 stub.n_obs = 100 @@ -4174,13 +4070,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.3 stub.se = 0.3 - stub.overall_p_value = 0.01 stub.p_value = 0.01 - stub.overall_conf_int = (0.4, 1.6) stub.conf_int = (0.4, 1.6) stub.alpha = 0.05 stub.n_obs = 100 @@ -4227,13 +4119,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.3 stub.se = 0.3 - stub.overall_p_value = 0.01 stub.p_value = 0.01 - stub.overall_conf_int = (0.4, 1.6) stub.conf_int = (0.4, 1.6) stub.alpha = 0.05 stub.n_obs = 100 @@ -4329,13 +4217,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 0.5608 stub.att = 0.5608 - stub.overall_se = 0.1216 stub.se = 0.1216 - stub.overall_p_value = 0.0 stub.p_value = 0.0 - stub.overall_conf_int = (0.323, 0.799) stub.conf_int = (0.323, 0.799) stub.alpha = 0.05 stub.n_obs = 539 @@ -4597,10 +4481,10 @@ def _bacon_schema_with_high_forbidden_weight(): def _make_cs_like_stub(class_name: str): cls = type(class_name, (), {}) obj = cls() - obj.overall_att = 1.0 - obj.overall_se = 0.2 - obj.overall_p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) + obj.att = 1.0 + obj.se = 0.2 + obj.p_value = 0.001 + obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 500 obj.n_treated = 100 @@ -4743,13 +4627,9 @@ class CallawaySantAnnaResults: pass obj = CallawaySantAnnaResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.2 obj.se = 0.2 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 600 diff --git a/tests/test_chaisemartin_dhaultfoeuille.py b/tests/test_chaisemartin_dhaultfoeuille.py index 62d782094..c24b6e70a 100644 --- a/tests/test_chaisemartin_dhaultfoeuille.py +++ b/tests/test_chaisemartin_dhaultfoeuille.py @@ -1323,11 +1323,11 @@ def test_coef_var_nan_safe_on_non_finite_se(self): ) r_nan = ChaisemartinDHaultfoeuilleResults( - overall_att=2.0, - overall_se=float("nan"), - overall_t_stat=float("nan"), - overall_p_value=float("nan"), - overall_conf_int=(float("nan"), float("nan")), + att=2.0, + se=float("nan"), + t_stat=float("nan"), + p_value=float("nan"), + conf_int=(float("nan"), float("nan")), joiners_att=float("nan"), joiners_se=float("nan"), joiners_t_stat=float("nan"), @@ -1365,11 +1365,11 @@ def test_coef_var_nan_safe_on_non_finite_se(self): # Independently verify: with finite SE > 0, coef_var equals SE/|ATT| r_finite = ChaisemartinDHaultfoeuilleResults( - overall_att=2.0, - overall_se=0.5, - overall_t_stat=4.0, - overall_p_value=0.01, - overall_conf_int=(1.0, 3.0), + att=2.0, + se=0.5, + t_stat=4.0, + p_value=0.01, + conf_int=(1.0, 3.0), joiners_att=float("nan"), joiners_se=float("nan"), joiners_t_stat=float("nan"), @@ -5166,10 +5166,10 @@ def test_summary_footer_mixed_validity_surfaces_live_targets(self): # untouched — simulates the reversible-panel scenario where the # overall IF is identically zero but the by_path subset IF is # not. - res.overall_se = float("nan") - res.overall_t_stat = float("nan") - res.overall_p_value = float("nan") - res.overall_conf_int = (float("nan"), float("nan")) + res.se = float("nan") + res.t_stat = float("nan") + res.p_value = float("nan") + res.conf_int = (float("nan"), float("nan")) if res.event_study_effects is not None: for entry in res.event_study_effects.values(): entry["se"] = float("nan") diff --git a/tests/test_diagnostic_report.py b/tests/test_diagnostic_report.py index 79ddcf6be..a159dc210 100644 --- a/tests/test_diagnostic_report.py +++ b/tests/test_diagnostic_report.py @@ -216,7 +216,7 @@ def test_schema_version_constant(self, twfe_fit): fit, _ = twfe_fit schema = DiagnosticReport(fit).to_dict() assert schema["schema_version"] == DIAGNOSTIC_REPORT_SCHEMA_VERSION - assert DIAGNOSTIC_REPORT_SCHEMA_VERSION == "2.0" + assert DIAGNOSTIC_REPORT_SCHEMA_VERSION == "3.0" def test_all_statuses_use_closed_enum(self, cs_fit): fit, sdf = cs_fit @@ -431,11 +431,11 @@ def test_precomputed_pretrends_power_persisted_full_vcov_no_downgrade(self): from diff_diff.pretrends import PreTrendsPowerResults class _CSStub: - overall_att = 1.0 - overall_se = 0.25 - overall_t_stat = 4.0 - overall_p_value = 0.001 - overall_conf_int = (0.5, 1.5) + att = 1.0 + se = 0.25 + t_stat = 4.0 + p_value = 0.001 + conf_int = (0.5, 1.5) alpha = 0.05 n_obs = 400 n_treated = 80 @@ -489,11 +489,11 @@ def test_precomputed_pretrends_power_legacy_missing_field_still_downgraded(self) # Minimal CS-shaped stub with full vcov populated. class _CSStub: - overall_att = 1.0 - overall_se = 0.25 - overall_t_stat = 4.0 - overall_p_value = 0.001 - overall_conf_int = (0.5, 1.5) + att = 1.0 + se = 0.25 + t_stat = 4.0 + p_value = 0.001 + conf_int = (0.5, 1.5) alpha = 0.05 n_obs = 400 n_treated = 80 @@ -542,11 +542,11 @@ def test_precomputed_pretrends_power_consumes_persisted_cov_source(self): from diff_diff.pretrends import PreTrendsPowerResults class _CSStub: - overall_att = 1.0 - overall_se = 0.25 - overall_t_stat = 4.0 - overall_p_value = 0.001 - overall_conf_int = (0.5, 1.5) + att = 1.0 + se = 0.25 + t_stat = 4.0 + p_value = 0.001 + conf_int = (0.5, 1.5) alpha = 0.05 n_obs = 400 n_treated = 80 @@ -903,13 +903,9 @@ class CallawaySantAnnaResults: # noqa: D401 — test stub that mimics the real obj.event_study_vcov = np.asarray(vcov, dtype=float) if vcov is not None else None obj.vcov = None obj.interaction_indices = None - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 100 @@ -1198,13 +1194,9 @@ class CallawaySantAnnaResults: pass obj = CallawaySantAnnaResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 200 @@ -1255,13 +1247,9 @@ class CallawaySantAnnaResults: pass obj = CallawaySantAnnaResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 200 @@ -1317,13 +1305,9 @@ class CallawaySantAnnaResults: obj.interaction_indices = None obj.event_study_vcov = None obj.event_study_vcov_index = None - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 100 @@ -1405,13 +1389,9 @@ class StackedDiDResults: pass obj = StackedDiDResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.2 obj.se = 0.2 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 400 @@ -1450,13 +1430,9 @@ class CallawaySantAnnaResults: pass obj = CallawaySantAnnaResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.2 obj.se = 0.2 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 500 @@ -1503,13 +1479,9 @@ class StackedDiDResults: pass obj = StackedDiDResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.2 obj.se = 0.2 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 400 @@ -1561,13 +1533,9 @@ class CallawaySantAnnaResults: obj.interaction_indices = None obj.event_study_vcov = None obj.event_study_vcov_index = None - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 100 @@ -1601,13 +1569,9 @@ class StackedDiDResults: pass obj = StackedDiDResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.2 obj.se = 0.2 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 400 @@ -1692,7 +1656,7 @@ def test_pretrends_power_adapter_filters_zero_se_cs(self): -1: {"effect": 0.0, "se": 0.2, "p_value": 0.99, "n_groups": 1}, 0: {"effect": 1.0, "se": 0.2, "p_value": 0.0, "n_groups": 1}, } - obj.overall_att = 1.0 + obj.att = 1.0 obj.alpha = 0.05 pp = compute_pretrends_power(obj, alpha=0.05, target_power=0.80, violation_type="linear") @@ -1785,13 +1749,9 @@ class CallawaySantAnnaResults: pass obj = CallawaySantAnnaResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 200 @@ -1887,13 +1847,9 @@ class CallawaySantAnnaResults: pass obj = CallawaySantAnnaResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.1 obj.se = 0.1 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.8, 1.2) obj.conf_int = (0.8, 1.2) obj.alpha = 0.05 obj.n_obs = 200 @@ -1928,7 +1884,7 @@ def test_cs_pt_uses_event_study_vcov_wald(self): class TestContinuousDiDHeadline: - """ContinuousDiDResults exposes overall_att_se/p_value/conf_int, not overall_se/… + """ContinuousDiDResults exposes the canonical quintet like every other container… Regression for the P1 CI-review finding that both report classes missed ContinuousDiDResults inference fields. @@ -1941,20 +1897,16 @@ class ContinuousDiDResults: pass obj = ContinuousDiDResults() - obj.overall_att = 2.5 obj.att = 2.5 - obj.overall_att_se = 0.4 obj.se = 0.4 - obj.overall_att_p_value = 0.00001 obj.p_value = 0.00001 - obj.overall_att_conf_int = (1.7, 3.3) obj.conf_int = (1.7, 3.3) obj.alpha = 0.05 result = _extract_scalar_headline(obj) assert result is not None name, value, se, p, ci, alpha = result - assert name == "overall_att" + assert name == "att" assert value == pytest.approx(2.5) assert se == pytest.approx(0.4) assert p == pytest.approx(0.00001) @@ -2493,13 +2445,9 @@ class ChaisemartinDHaultfoeuilleResults: stub = ChaisemartinDHaultfoeuilleResults() stub.att = 1.0 - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 100 @@ -2579,13 +2527,9 @@ class CallawaySantAnnaResults: 1: {"effect": 2.0, "se": 0.2, "n_groups": 12}, 2: {"effect": float("nan"), "se": 0.2, "n_groups": 5}, # non-finite } - stub.overall_att = 1.5 stub.att = 1.5 - stub.overall_se = 0.1 stub.se = 0.1 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (1.3, 1.7) stub.conf_int = (1.3, 1.7) stub.alpha = 0.05 stub.n_obs = 100 @@ -2665,13 +2609,9 @@ class CallawaySantAnnaResults: pass obj = CallawaySantAnnaResults() - obj.overall_att = 1.0 obj.att = 1.0 - obj.overall_se = 0.2 obj.se = 0.2 - obj.overall_p_value = 0.001 obj.p_value = 0.001 - obj.overall_conf_int = (0.6, 1.4) obj.conf_int = (0.6, 1.4) obj.alpha = 0.05 obj.n_obs = 600 diff --git a/tests/test_honest_did.py b/tests/test_honest_did.py index 94ac39182..f1df5885c 100644 --- a/tests/test_honest_did.py +++ b/tests/test_honest_did.py @@ -812,11 +812,11 @@ def test_honest_did_cs_all_post_nan_raises(self): # Create CS results with valid pre-periods but NaN SEs in post-periods cs_results = CallawaySantAnnaResults( group_time_effects={}, - overall_att=np.nan, - overall_se=np.nan, - overall_t_stat=np.nan, - overall_p_value=np.nan, - overall_conf_int=(np.nan, np.nan), + att=np.nan, + se=np.nan, + t_stat=np.nan, + p_value=np.nan, + conf_int=(np.nan, np.nan), groups=[2004], time_periods=[2000, 2001, 2002, 2003], n_obs=400, diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index 4856d6952..fa4af417f 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -581,11 +581,10 @@ def _build_rowed_index(): ), "plot_group_effects[groups]": _CS_COHORT + " - cohort selector on the plotting surface", "DMLDiDResults.overall_att": ( - "INHERITED CallawaySantAnnaResults dataclass field, not an " - "independent surface: the M-050 storage flip applies through " - "subclassing automatically, and the DMLDiDResults construction " - "site is in M-050's code_refs (diff_diff/dml_did.py) so the flip " - "PR rewrites it" + "re-declared deprecated_field_property shim so the FutureWarning " + "names the subclass; the storage is INHERITED from " + "CallawaySantAnnaResults and rides M-050 (dml_did_results.py / " + "dml_did.py are in its code_refs) - no independent row" ), "TripleDifference.fit[group]": ( "rule-3 reserved treated-group 0/1 indicator (v4-design section 8 rule 3)" diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index 96d1fbfe7..88abadd3d 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -122,19 +122,16 @@ def mock_scm_results(): @pytest.fixture def mock_efficient_results(): r = EfficientDiDResults.__new__(EfficientDiDResults) - r.overall_att = 0.6 - r.overall_se = 0.15 + r.att = 0.6 + r.se = 0.15 return r @pytest.fixture def mock_continuous_results(): r = ContinuousDiDResults.__new__(ContinuousDiDResults) - r.overall_att = 0.4 - # Canonical SE field on ContinuousDiDResults is overall_att_se (the ATT side). - # `overall_se` is a read-only property alias since the v3.3.3 inference-field - # alias surface; assigning to it raises AttributeError. - r.overall_att_se = 0.1 + r.att = 0.4 + r.se = 0.1 # the ATT-side headline; ACRT lives on overall_acrt* return r @@ -149,32 +146,32 @@ def mock_triple_results(): @pytest.fixture def mock_sa_results(): r = SunAbrahamResults.__new__(SunAbrahamResults) - r.overall_att = 0.5 - r.overall_se = 0.1 + r.att = 0.5 + r.se = 0.1 return r @pytest.fixture def mock_imputation_results(): r = ImputationDiDResults.__new__(ImputationDiDResults) - r.overall_att = 0.5 - r.overall_se = 0.1 + r.att = 0.5 + r.se = 0.1 return r @pytest.fixture def mock_two_stage_results(): r = TwoStageDiDResults.__new__(TwoStageDiDResults) - r.overall_att = 0.5 - r.overall_se = 0.1 + r.att = 0.5 + r.se = 0.1 return r @pytest.fixture def mock_stacked_results(): r = StackedDiDResults.__new__(StackedDiDResults) - r.overall_att = 0.5 - r.overall_se = 0.1 + r.att = 0.5 + r.se = 0.1 return r @@ -642,8 +639,8 @@ class TestImputationAggregationStep(_AggregationStepMixin): def _results(self): r = ImputationDiDResults.__new__(ImputationDiDResults) - r.overall_att = 0.6 - r.overall_se = 0.15 + r.att = 0.6 + r.se = 0.15 return r @@ -652,8 +649,8 @@ class TestTwoStageAggregationStep(_AggregationStepMixin): def _results(self): r = TwoStageDiDResults.__new__(TwoStageDiDResults) - r.overall_att = 0.6 - r.overall_se = 0.15 + r.att = 0.6 + r.se = 0.15 return r @@ -674,8 +671,8 @@ def _agg_step(output): def _results(self): r = ContinuousDiDResults.__new__(ContinuousDiDResults) - r.overall_att = 0.6 - r.overall_att_se = 0.15 + r.att = 0.6 + r.se = 0.15 return r def test_aggregation_step_present_with_all_routes(self): diff --git a/tests/test_result_aliases.py b/tests/test_result_aliases.py index 409d00ecb..bc445f5df 100644 --- a/tests/test_result_aliases.py +++ b/tests/test_result_aliases.py @@ -1,8 +1,14 @@ -"""Inference-field aliases on result classes (balance / external-adapter compatibility). - -Each in-scope result class exposes flat aliases (``att`` / ``se`` / ``conf_int`` / -``p_value`` / ``t_stat``) that map to the canonical native fields (``overall_*``, -``overall_att_*``, or ``avg_*``). This file pins the alias-canonical contract. +"""Inference-field names on result classes (balance / external-adapter compatibility). + +Since the 4.0 storage flip (ledger rows M-050..M-058, PR 5b) the canonical +quintet ``att`` / ``se`` / ``t_stat`` / ``p_value`` / ``conf_int`` IS the +native storage on every estimator container in this file, and the 3.x +``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. Motivating bug: ``balance.interop.diff_diff.as_balance_diagnostic`` reads ``getattr(res, "se", None)`` and ``getattr(res, "conf_int", None)`` without @@ -14,6 +20,7 @@ from __future__ import annotations import math +import warnings from dataclasses import asdict, fields import numpy as np @@ -139,13 +146,20 @@ class _Probe: assert inst.x == 1.5 -def _assert_pattern_b_aliases(res, *, att, se, t_stat, p_value, conf_int): - """Pattern B: 5 flat aliases mapping to the overall_* canonical fields.""" - assert _alias_equal(res.att, att), f"att alias != overall_att ({res.att} vs {att})" - assert _alias_equal(res.se, se) - assert _alias_equal(res.conf_int, conf_int) - assert _alias_equal(res.p_value, p_value) - assert _alias_equal(res.t_stat, t_stat) +def _assert_legacy_aliases_relay(res, *, att, se, t_stat, p_value, conf_int): + """Pattern B post-flip: the five legacy ``overall_*`` properties warn and relay + the canonical fields.""" + cls = type(res).__name__ + for old, expected in ( + ("overall_att", att), + ("overall_se", se), + ("overall_t_stat", t_stat), + ("overall_p_value", p_value), + ("overall_conf_int", conf_int), + ): + with pytest.warns(FutureWarning, match=rf"{cls}\.{old} is deprecated"): + got = getattr(res, old) + assert _alias_equal(got, expected), f"{cls}.{old} relays {got!r}, expected {expected!r}" # Sentinel inference values exercised across direct-construction tests. @@ -156,71 +170,118 @@ def _assert_pattern_b_aliases(res, *, att, se, t_stat, p_value, conf_int): _CI = (1.0, 2.0) +_CANONICAL = {"att": _ATT, "se": _SE, "t_stat": _T, "p_value": _P, "conf_int": _CI} +_CANONICAL_NAN = { + "att": np.nan, + "se": np.nan, + "t_stat": np.nan, + "p_value": np.nan, + "conf_int": (np.nan, np.nan), +} + +# The nine flipped containers (M-050..M-058) plus DMLDiDResults, which inherits +# CallawaySantAnnaResults' storage and re-declares the shims under its own name. +FLIPPED_PATTERN_B = [ + CallawaySantAnnaResults, + DMLDiDResults, + StackedDiDResults, + EfficientDiDResults, + ChaisemartinDHaultfoeuilleResults, + WooldridgeDiDResults, + SunAbrahamResults, + ImputationDiDResults, + TwoStageDiDResults, +] + + # ============================================================================ -# Pattern B (10 classes) — direct-construction alias mechanics +# Pattern B (9 flipped classes) — direct-construction mechanics # ============================================================================ -@pytest.mark.parametrize( - "cls", - [ - CallawaySantAnnaResults, - DMLDiDResults, - StackedDiDResults, - EfficientDiDResults, - ChaisemartinDHaultfoeuilleResults, - StaggeredTripleDiffResults, - WooldridgeDiDResults, - SunAbrahamResults, - ImputationDiDResults, - TwoStageDiDResults, - ], - ids=lambda c: c.__name__, -) -def test_pattern_b_aliases_match_overall(cls): - """Each Pattern B class's flat aliases equal the canonical overall_* fields.""" - overrides = { - "overall_att": _ATT, - "overall_se": _SE, - "overall_t_stat": _T, - "overall_p_value": _P, - "overall_conf_int": _CI, - } - res = cls(**_required_init_kwargs(cls, overrides)) - _assert_pattern_b_aliases(res, att=_ATT, se=_SE, t_stat=_T, p_value=_P, conf_int=_CI) +@pytest.mark.parametrize("cls", FLIPPED_PATTERN_B, ids=lambda c: c.__name__) +def test_pattern_b_legacy_aliases_relay_canonical(cls): + """Each flipped class constructs with canonical keywords; the legacy + ``overall_*`` properties warn (FutureWarning) and relay the stored value.""" + res = cls(**_required_init_kwargs(cls, dict(_CANONICAL))) + assert res.att == _ATT + assert res.se == _SE + assert res.t_stat == _T + assert res.p_value == _P + assert res.conf_int == _CI + _assert_legacy_aliases_relay(res, att=_ATT, se=_SE, t_stat=_T, p_value=_P, conf_int=_CI) -@pytest.mark.parametrize( - "cls", - [ - CallawaySantAnnaResults, - DMLDiDResults, - StackedDiDResults, - EfficientDiDResults, - ChaisemartinDHaultfoeuilleResults, - StaggeredTripleDiffResults, - WooldridgeDiDResults, - SunAbrahamResults, - ImputationDiDResults, - TwoStageDiDResults, - ], - ids=lambda c: c.__name__, -) -def test_pattern_b_aliases_propagate_nan(cls): - """When canonical overall_* fields are NaN (degenerate fit), aliases are NaN. +@pytest.mark.parametrize("cls", FLIPPED_PATTERN_B, ids=lambda c: c.__name__) +def test_pattern_b_legacy_aliases_propagate_nan(cls): + """When the canonical fields are NaN (degenerate fit), the legacy aliases are NaN. Pins the safe_inference() joint-NaN contract (per CLAUDE.md: ALL inference fields are computed together and stay NaN-consistent). Aliases are pure read-throughs, so the contract holds without re-computation. """ - overrides = { - "overall_att": np.nan, - "overall_se": np.nan, - "overall_t_stat": np.nan, - "overall_p_value": np.nan, - "overall_conf_int": (np.nan, np.nan), + res = cls(**_required_init_kwargs(cls, dict(_CANONICAL_NAN))) + with pytest.warns(FutureWarning): + assert math.isnan(res.overall_att) + assert math.isnan(res.overall_se) + assert math.isnan(res.overall_t_stat) + assert math.isnan(res.overall_p_value) + assert math.isnan(res.overall_conf_int[0]) + assert math.isnan(res.overall_conf_int[1]) + + +@pytest.mark.parametrize("cls", FLIPPED_PATTERN_B, ids=lambda c: c.__name__) +def test_pattern_b_legacy_constructor_keywords_rejected(cls): + """The 3.x ``overall_*`` names are no longer constructor keywords.""" + kwargs = _required_init_kwargs(cls, dict(_CANONICAL)) + kwargs["overall_att"] = _ATT + with pytest.raises(TypeError, match="unexpected keyword argument 'overall_att'"): + 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, } - res = cls(**_required_init_kwargs(cls, overrides)) + + +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) @@ -230,17 +291,17 @@ def test_pattern_b_aliases_propagate_nan(cls): # ============================================================================ -# Pattern C — ContinuousDiDResults: flat AND overall_* aliases +# Pattern C — ContinuousDiDResults: nine legacy spellings onto the canonical five # ============================================================================ def _continuous_did_overrides(att=_ATT, se=_SE, t=_T, p=_P, ci=_CI): return { - "overall_att": att, - "overall_att_se": se, - "overall_att_t_stat": t, - "overall_att_p_value": p, - "overall_att_conf_int": ci, + "att": att, + "se": se, + "t_stat": t, + "p_value": p, + "conf_int": ci, "overall_acrt": 0.0, "overall_acrt_se": 0.0, "overall_acrt_t_stat": 0.0, @@ -249,8 +310,8 @@ def _continuous_did_overrides(att=_ATT, se=_SE, t=_T, p=_P, ci=_CI): } -def test_continuous_did_flat_aliases(): - """ContinuousDiD flat aliases map to the ATT-side overall_att_* fields.""" +def test_continuous_did_canonical_fields_native(): + """ContinuousDiD stores the ATT-side headline under the canonical quintet.""" res = ContinuousDiDResults( **_required_init_kwargs(ContinuousDiDResults, _continuous_did_overrides()) ) @@ -259,29 +320,32 @@ def test_continuous_did_flat_aliases(): assert res.conf_int == _CI assert res.p_value == _P assert res.t_stat == _T + assert res.overall_acrt == 0.0 # the separate ACRT estimand is untouched -def test_continuous_did_overall_aliases(): - """ContinuousDiD overall_* aliases also map to the ATT-side fields - (consistency with Pattern B family naming).""" - res = ContinuousDiDResults( - **_required_init_kwargs(ContinuousDiDResults, _continuous_did_overrides()) - ) - assert res.overall_se == _SE - assert res.overall_conf_int == _CI - assert res.overall_p_value == _P - assert res.overall_t_stat == _T - - -def test_continuous_did_double_alias_resolves_same_value(): - """``res.se`` and ``res.overall_se`` MUST point at the same value.""" +@pytest.mark.parametrize( + ("old", "expected"), + [ + ("overall_att", _ATT), + ("overall_att_se", _SE), + ("overall_att_t_stat", _T), + ("overall_att_p_value", _P), + ("overall_att_conf_int", _CI), + ("overall_se", _SE), + ("overall_t_stat", _T), + ("overall_p_value", _P), + ("overall_conf_int", _CI), + ], +) +def test_continuous_did_legacy_spellings_warn_and_relay(old, expected): + """All nine 3.x spellings - the five former ``overall_att_*`` fields and the + four former silent ``overall_*`` aliases - warn and relay the canonical + field (the M-058 family).""" res = ContinuousDiDResults( **_required_init_kwargs(ContinuousDiDResults, _continuous_did_overrides()) ) - assert res.se == res.overall_se - assert res.conf_int == res.overall_conf_int - assert res.p_value == res.overall_p_value - assert res.t_stat == res.overall_t_stat + with pytest.warns(FutureWarning, match=rf"ContinuousDiDResults\.{old} is deprecated"): + assert getattr(res, old) == expected # ============================================================================ @@ -290,45 +354,60 @@ def test_continuous_did_double_alias_resolves_same_value(): @pytest.mark.parametrize( - ("cls", "ovr"), + ("cls", "ovr", "legacy_names"), [ ( CallawaySantAnnaResults, - { - "overall_att": _ATT, - "overall_se": _SE, - "overall_t_stat": _T, - "overall_p_value": _P, - "overall_conf_int": _CI, - }, + dict(_CANONICAL), + ("overall_att", "overall_se", "overall_t_stat", "overall_p_value", "overall_conf_int"), + ), + ( + ContinuousDiDResults, + _continuous_did_overrides(), + ( + "overall_att", + "overall_att_se", + "overall_att_t_stat", + "overall_att_p_value", + "overall_att_conf_int", + "overall_se", + "overall_t_stat", + "overall_p_value", + "overall_conf_int", + ), ), - (ContinuousDiDResults, _continuous_did_overrides()), ], - ids=lambda v: v.__name__ if hasattr(v, "__name__") else "ovr", + ids=["CallawaySantAnnaResults", "ContinuousDiDResults"], ) -def test_aliases_are_read_only(cls, ovr): - """Assigning to an alias must raise AttributeError (no setter installed). - - Regression: a downstream test in tests/test_practitioner.py used - `r.overall_se = X` on a `ContinuousDiDResults.__new__()` mock — pre-alias - that silently created a junk attribute; post-alias the property correctly - rejects the assignment. Locking read-only here means future contributors - who write similar fixtures fail loudly via this test rather than via a - surprise `AttributeError: can't set attribute` deep in another suite. +def test_legacy_aliases_are_read_only_canonical_fields_writable(cls, ovr, legacy_names): + """Post-flip read-only semantics are INVERTED from 3.x: assigning to a legacy + ``overall_*`` property raises AttributeError (no setter installed), while the + canonical quintet are plain dataclass fields and accept assignment (the + ``WooldridgeDiDResults.aggregate("simple")`` write path relies on this). + + Regression lineage: a downstream test in tests/test_practitioner.py once + used `r.overall_se = X` on a `ContinuousDiDResults.__new__()` mock. Locking + the direction here means fixtures that assign the wrong name fail loudly + via this test rather than deep in another suite. """ res = cls(**_required_init_kwargs(cls, ovr)) + for name in legacy_names: + with pytest.raises(AttributeError): + setattr(res, name, object()) + for name in ("att", "se", "conf_int", "p_value", "t_stat"): + setattr(res, name, 7.0) + 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()) - # ContinuousDiDResults also exposes overall_se / overall_conf_int / - # overall_p_value / overall_t_stat as read-only aliases over the - # ATT-side canonical fields (no parallel `overall_att` alias is needed - # because `overall_att_att` would be confusing; the flat `att` covers - # that one). These must also reject assignment. - if cls.__name__ == "ContinuousDiDResults": - for name in ("overall_se", "overall_conf_int", "overall_p_value", "overall_t_stat"): - with pytest.raises(AttributeError): - setattr(res, name, object()) @pytest.mark.parametrize( @@ -336,52 +415,57 @@ def test_aliases_are_read_only(cls, ovr): [CallawaySantAnnaResults, ContinuousDiDResults], ids=lambda v: v.__name__, ) -def test_aliases_excluded_from_dataclass_fields_and_asdict(cls): - """Aliases must not appear in ``dataclasses.fields()`` or - ``dataclasses.asdict()`` output. This is the contract the registry - documents (PR for v3.3.3 + REGISTRY note), but the existing alias - suite only locks read-through and read-only semantics — a future - refactor that converted an `@property` to a real dataclass field - would silently surface aliases to serializers and field-walkers - without this regression catching it. - - Two representative classes cover the surviving alias patterns: - Pattern B (`CallawaySantAnnaResults`) and the double-alias case - (`ContinuousDiDResults`); the `avg_*` mapping left with its - calendar-keyed event-study container at the 4.0 cut. +def test_legacy_aliases_excluded_from_dataclass_fields_and_asdict(cls): + """The legacy ``overall_*`` properties must not appear in + ``dataclasses.fields()`` or ``dataclasses.asdict()`` output, and the + canonical quintet MUST (it is the storage). A future refactor that + annotated a shim (turning it into a real field) would silently surface + the deprecated names to serializers and field-walkers without this + regression catching it. + + Two representative classes cover the flipped patterns: Pattern B + (`CallawaySantAnnaResults`) and the nine-spelling case + (`ContinuousDiDResults`). """ ovr = { - CallawaySantAnnaResults: { - "overall_att": _ATT, - "overall_se": _SE, - "overall_t_stat": _T, - "overall_p_value": _P, - "overall_conf_int": _CI, - }, + CallawaySantAnnaResults: dict(_CANONICAL), ContinuousDiDResults: _continuous_did_overrides(), }[cls] res = cls(**_required_init_kwargs(cls, ovr)) field_names = {f.name for f in fields(res)} asdict_keys = set(asdict(res).keys()) - alias_names = ["att", "se", "conf_int", "p_value", "t_stat"] - # ContinuousDiDResults is the documented double-alias case: it - # also exposes `overall_se` / `overall_conf_int` / `overall_p_value` - # / `overall_t_stat` aliases pointing at the ATT side (the native - # field is `overall_att_*`). Lock those out of fields()/asdict() too - # so a future refactor cannot silently surface them as dataclass - # fields (which would duplicate the native `overall_att_*` keys in - # serializer output). + canonical = ["att", "se", "conf_int", "p_value", "t_stat"] + legacy = ["overall_att", "overall_se", "overall_conf_int", "overall_p_value", "overall_t_stat"] if cls is ContinuousDiDResults: - alias_names += ["overall_se", "overall_conf_int", "overall_p_value", "overall_t_stat"] - for alias in alias_names: + legacy += [ + "overall_att_se", + "overall_att_conf_int", + "overall_att_p_value", + "overall_att_t_stat", + ] + for name in canonical: + assert name in field_names, f"{cls.__name__}: canonical {name!r} is not a dataclass field" + assert name in asdict_keys + for alias in legacy: assert alias not in field_names, ( - f"{cls.__name__}: alias {alias!r} surfaced in " - f"dataclasses.fields() output - aliases must remain " - f"@property descriptors, never real fields." + f"{cls.__name__}: legacy {alias!r} surfaced in " + f"dataclasses.fields() output - shims must remain " + f"descriptors, never real fields." ) assert ( alias not in asdict_keys - ), f"{cls.__name__}: alias {alias!r} surfaced in dataclasses.asdict() output." + ), 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) # ============================================================================ @@ -421,7 +505,8 @@ def test_balance_adapter_pattern_returns_populated_se(): conf_int is not None and len(conf_int) == 2 and all(np.isfinite(x) for x in conf_int) ), f"balance adapter would see conf_int={conf_int!r}; pre-alias bug returned None" assert p_value is not None and np.isfinite(p_value) - # Aliases must equal the canonical overall_* fields. - assert se == res.overall_se - assert conf_int == res.overall_conf_int - assert p_value == res.overall_p_value + # The legacy overall_* properties relay the same stored values (and warn). + with pytest.warns(FutureWarning): + assert se == res.overall_se + assert conf_int == res.overall_conf_int + assert p_value == res.overall_p_value diff --git a/tests/test_target_parameter.py b/tests/test_target_parameter.py index 0760287dc..3c048a3d6 100644 --- a/tests/test_target_parameter.py +++ b/tests/test_target_parameter.py @@ -61,7 +61,7 @@ def test_did_results_mentions_twfe(self): def test_callaway_santanna(self): tp = describe_target_parameter(_minimal_result("CallawaySantAnnaResults")) assert tp["aggregation"] == "simple" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" assert "ATT(g,t)" in tp["name"] or "ATT(g, t)" in tp["definition"] def test_sun_abraham(self): @@ -72,7 +72,7 @@ def test_sun_abraham(self): def test_imputation(self): tp = describe_target_parameter(_minimal_result("ImputationDiDResults")) assert tp["aggregation"] == "simple" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" assert ( "imputation" in tp["name"].lower() or "BJS" in tp["reference"] @@ -82,7 +82,7 @@ def test_imputation(self): def test_two_stage(self): tp = describe_target_parameter(_minimal_result("TwoStageDiDResults")) assert tp["aggregation"] == "simple" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" assert "Gardner" in tp["reference"] or "two-stage" in tp["name"].lower() def test_stacked(self): @@ -90,7 +90,7 @@ def test_stacked(self): _minimal_result("StackedDiDResults", control_group="not_yet_treated") ) assert tp["aggregation"] == "stacked" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" assert "sub-experiment" in tp["definition"].lower() def test_wooldridge_ols(self): @@ -100,7 +100,7 @@ def test_wooldridge_ols(self): """ tp = describe_target_parameter(_minimal_result("WooldridgeDiDResults", method="ols")) assert tp["aggregation"] == "simple" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" # OLS wording: mentions ATT(g,t) aggregation, not ASF. assert ( "ATT(g,t)" in tp["name"] or "ATT(g,t)" in tp["definition"] or "OLS ETWFE" in tp["name"] @@ -114,7 +114,7 @@ def test_wooldridge_nonlinear(self): for method in ("logit", "poisson"): tp = describe_target_parameter(_minimal_result("WooldridgeDiDResults", method=method)) assert tp["aggregation"] == "simple" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" assert "ASF" in tp["name"] assert method in tp["name"] or method in tp["definition"] @@ -123,7 +123,7 @@ def test_efficient_did_pt_all(self): assert tp["aggregation"] == "pt_all_combined" assert "PT-All" in tp["name"] # PR #347 R7 P1 regression: the definition must disambiguate - # the library's cohort-size-weighted ``overall_att`` from the + # the library's cohort-size-weighted ``att`` from the # paper's uniform-event-time ``ES_avg``. defn = tp["definition"] assert "cohort-size-weighted" in defn @@ -161,10 +161,10 @@ def test_dcdh_m(self): ) assert tp["aggregation"] == "M" assert "DID_M" in tp["name"] - # R1 PR #347 review: headline lives in ``overall_att``, not + # R1 PR #347 review: headline lives in ``att``, not # ``att``. Machine-readable field must match the raw attribute # downstream consumers should read. - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" def test_dcdh_did_1(self): """``L_max = 1`` → headline is ``DID_1`` (per-group single- @@ -182,7 +182,7 @@ def test_dcdh_did_1(self): ) assert tp["aggregation"] == "DID_1" assert "DID_1" in tp["name"] - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" def test_dcdh_delta(self): """``L_max >= 2`` (no trends) → headline is the cost-benefit @@ -199,7 +199,7 @@ def test_dcdh_delta(self): ) assert tp["aggregation"] == "delta" assert "delta" in tp["name"] - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" def test_dcdh_did_1_with_controls(self): tp = describe_target_parameter( @@ -227,7 +227,7 @@ def test_dcdh_delta_with_controls(self): def test_dcdh_trends_linear_with_l_max_geq_2_emits_no_scalar_headline(self): """``trends_linear=True`` with ``L_max >= 2`` intentionally - suppresses the scalar aggregate (``overall_att`` is NaN by + suppresses the scalar aggregate (``att`` is NaN by design per ``chaisemartin_dhaultfoeuille.py:2828-2834``). The target-parameter block must reflect that — ``aggregation = "no_scalar_headline"`` and ``headline_attribute = None``. @@ -253,7 +253,7 @@ def test_dcdh_trends_linear_with_l_max_geq_2_emits_no_scalar_headline(self): def test_dcdh_trends_linear_empty_surface_still_no_scalar(self): """PR #347 R9 P1 regression: the estimator sets ``linear_trends_effects=None`` when the cumulated-horizon dict - is empty, but still unconditionally NaN-s ``overall_att`` for + is empty, but still unconditionally NaN-s ``att`` for ``trends_linear=True`` with ``L_max >= 2`` (see ``chaisemartin_dhaultfoeuille.py:2828-2834``). The previous inference on ``linear_trends_effects is not None`` would have @@ -306,7 +306,7 @@ def test_dcdh_trends_linear_with_l_max_1_still_has_scalar(self): ) assert tp["aggregation"] == "DID_1_fd" assert "DID^{fd}_1" in tp["name"] - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" def test_sdid(self): tp = describe_target_parameter(_minimal_result("SyntheticDiDResults")) @@ -427,13 +427,13 @@ def test_dcdh_config_branches_tag(self, L_max, controls, trends, expected_tag): ) ) assert tp["aggregation"] == expected_tag - # Contract: ``headline_attribute`` is "overall_att" whenever + # Contract: ``headline_attribute`` is "att" whenever # there IS a scalar aggregate, and None when the # ``trends_linear + L_max>=2`` no-scalar rule fires. if expected_tag == "no_scalar_headline": assert tp["headline_attribute"] is None else: - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" class TestTargetParameterCoversEveryResultClass: @@ -473,13 +473,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 500 @@ -504,13 +500,9 @@ class CallawaySantAnnaResults: pass stub = CallawaySantAnnaResults() - stub.overall_att = 1.0 stub.att = 1.0 - stub.overall_se = 0.2 stub.se = 0.2 - stub.overall_p_value = 0.001 stub.p_value = 0.001 - stub.overall_conf_int = (0.6, 1.4) stub.conf_int = (0.6, 1.4) stub.alpha = 0.05 stub.n_obs = 500 @@ -609,7 +601,7 @@ def test_stacked_did_fit_headline_attribute_matches_real_estimand(self): ) assert type(fit).__name__ == "StackedDiDResults" tp = describe_target_parameter(fit) - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" # R1 P1 fix: the definition must describe the actual estimand — # the average of post-treatment delta_h event-study coefficients. assert ( @@ -637,7 +629,7 @@ def _dcdh_reversible_panel(self, seed): rows.append({"unit": u, "period": t, "treated": d, "outcome": y}) return pd.DataFrame(rows) - def test_dcdh_did_m_fit_headline_attribute_is_overall_att(self): + def test_dcdh_did_m_fit_headline_attribute_is_att(self): """``ChaisemartinDHaultfoeuilleResults.att`` holds the DID_M headline scalar (``chaisemartin_dhaultfoeuille_results.py`` line ~357). R1 P1: previously ``headline_attribute="att"`` @@ -660,7 +652,7 @@ def test_dcdh_did_m_fit_headline_attribute_is_overall_att(self): assert type(fit).__name__ == "ChaisemartinDHaultfoeuilleResults" tp = describe_target_parameter(fit) assert tp["aggregation"] == "M" - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" # Sanity: the attribute BR/DR points at actually exists on the # real fit object. assert hasattr(fit, tp["headline_attribute"]), ( @@ -690,7 +682,7 @@ def test_dcdh_did_1_fit_overall_att_real(self): tp = describe_target_parameter(fit) assert tp["aggregation"] == "DID_1" assert "DID_1" in tp["name"] - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" assert hasattr(fit, "att") def test_dcdh_delta_fit_real(self): @@ -714,7 +706,7 @@ def test_dcdh_delta_fit_real(self): tp = describe_target_parameter(fit) assert tp["aggregation"] == "delta" assert "delta" in tp["name"] - assert tp["headline_attribute"] == "overall_att" + assert tp["headline_attribute"] == "att" assert hasattr(fit, "att") def test_dcdh_trends_linear_no_scalar_propagates_through_br(self): @@ -833,11 +825,11 @@ def _empty_surface_stub(self): ) return ChaisemartinDHaultfoeuilleResults( - overall_att=float("nan"), - overall_se=float("nan"), - overall_t_stat=float("nan"), - overall_p_value=float("nan"), - overall_conf_int=(float("nan"), float("nan")), + att=float("nan"), + se=float("nan"), + t_stat=float("nan"), + p_value=float("nan"), + conf_int=(float("nan"), float("nan")), joiners_att=float("nan"), joiners_se=float("nan"), joiners_t_stat=float("nan"), @@ -971,11 +963,11 @@ def _empty_surface_stub_with_controls(self): ) return ChaisemartinDHaultfoeuilleResults( - overall_att=float("nan"), - overall_se=float("nan"), - overall_t_stat=float("nan"), - overall_p_value=float("nan"), - overall_conf_int=(float("nan"), float("nan")), + att=float("nan"), + se=float("nan"), + t_stat=float("nan"), + p_value=float("nan"), + conf_int=(float("nan"), float("nan")), joiners_att=float("nan"), joiners_se=float("nan"), joiners_t_stat=float("nan"), @@ -1141,11 +1133,11 @@ def test_dcdh_empty_surface_propagates_to_assumption_and_native_label(self): ) stub = ChaisemartinDHaultfoeuilleResults( - overall_att=float("nan"), - overall_se=float("nan"), - overall_t_stat=float("nan"), - overall_p_value=float("nan"), - overall_conf_int=(float("nan"), float("nan")), + att=float("nan"), + se=float("nan"), + t_stat=float("nan"), + p_value=float("nan"), + conf_int=(float("nan"), float("nan")), joiners_att=float("nan"), joiners_se=float("nan"), joiners_t_stat=float("nan"), @@ -1215,7 +1207,7 @@ def test_dcdh_empty_surface_propagates_to_assumption_and_native_label(self): def test_dcdh_trends_linear_with_l_max_geq_2_fit_real(self): """Real ``trends_linear=True`` + ``L_max>=2`` fit: the library - intentionally sets ``overall_att=NaN`` and populates the + intentionally sets ``att=NaN`` and populates the per-horizon effects on ``linear_trends_effects`` instead (``chaisemartin_dhaultfoeuille.py:2828-2834``). The target- parameter block must reflect that via @@ -1239,9 +1231,9 @@ def test_dcdh_trends_linear_with_l_max_geq_2_fit_real(self): L_max=2, trends_linear=True, ) - # Real fit must intentionally produce NaN overall_att. + # Real fit must intentionally produce NaN att. assert math.isnan(fit.att), ( - "trends_linear + L_max>=2 must suppress overall_att (NaN by " + "trends_linear + L_max>=2 must suppress att (NaN by " "design). If this test fails, the library contract changed — " "update the ``no_scalar_headline`` branch of " "describe_target_parameter accordingly." diff --git a/tests/test_v4_storage_flip.py b/tests/test_v4_storage_flip.py new file mode 100644 index 000000000..f3a876379 --- /dev/null +++ b/tests/test_v4_storage_flip.py @@ -0,0 +1,599 @@ +"""Storage flip of the headline quintet at the 4.0 cut (ledger rows M-050..M-058). + +``test_ref`` for M-050 (CallawaySantAnnaResults; DMLDiDResults rides it), +M-051 (SunAbrahamResults), M-052 (ImputationDiDResults), M-053 +(TwoStageDiDResults), M-054 (StackedDiDResults), M-055 (EfficientDiDResults), +M-056 (WooldridgeDiDResults), M-057 (ChaisemartinDHaultfoeuilleResults) and +M-058 (ContinuousDiDResults). Spec: ``docs/v4-design.md`` section 5. + +Contract pinned here, per class: + +1. the canonical quintet ``att`` / ``se`` / ``t_stat`` / ``p_value`` / + ``conf_int`` are dataclass fields; every legacy name is a plain + descriptor (``deprecated_field_property``), not a field; +2. reading a legacy name emits the pinned ``FutureWarning`` text naming the + class (the subclass for ``DMLDiDResults``) and relays the canonical value; + assigning to it raises ``AttributeError``; +3. a 3.x pickle state (legacy keys) loads through ``__setstate__`` into the + canonical fields and no ``overall_*`` key lingers in ``__dict__``; +4. a 4.0 pickle round-trips with every field present and ``to_dict()`` emits + the canonical key; +5. constructors take canonical keywords only (``overall_att=`` raises); +6. every public read surface (``to_dict`` / ``summary`` / ``repr`` / + ``to_dataframe`` at every level / ``coef_var`` / ``is_significant`` / + ``significance_stars`` / ``aggregate``) runs under + ``simplefilter("error", FutureWarning)`` - no internal ``self.overall_*`` + read survived the flip; +7. the Wooldridge self-WRITE sites assign the canonical fields; +8. the ``migrate_renamed_state_keys`` helper contract. + +Fixtures are module-scoped fits built locally (fixtures do not cross +modules); every ``aggregate()`` call runs on a FRESH fit because +``WooldridgeDiDResults.aggregate()`` mutates ``self``. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import math +import pickle +import re +import warnings + +import numpy as np +import pandas as pd +import pytest + +from diff_diff import ( + CallawaySantAnna, + ChaisemartinDHaultfoeuille, + ContinuousDiD, + DMLDiD, + EfficientDiD, + ImputationDiD, + StackedDiD, + SunAbraham, + TwoStageDiD, + WooldridgeDiD, + generate_continuous_did_data, + generate_reversible_did_data, + generate_staggered_data, + load_mpdta, +) +from diff_diff._deprecation import LEGACY_OVERALL_QUINTET, migrate_renamed_state_keys +from diff_diff.chaisemartin_dhaultfoeuille_results import ChaisemartinDHaultfoeuilleResults +from diff_diff.continuous_did_results import ( + _LEGACY_OVERALL_ATT_QUINTET, + ContinuousDiDResults, +) +from diff_diff.dml_did_results import DMLDiDResults +from diff_diff.efficient_did_results import EfficientDiDResults +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.sun_abraham import SunAbrahamResults +from diff_diff.two_stage_results import TwoStageDiDResults +from diff_diff.wooldridge_results import WooldridgeDiDResults + +CANONICAL = ("att", "se", "t_stat", "p_value", "conf_int") + +# Legacy property name -> canonical field, per class (what WARNS). +PROPERTY_MAP = { + CallawaySantAnnaResults: dict(LEGACY_OVERALL_QUINTET), + DMLDiDResults: dict(LEGACY_OVERALL_QUINTET), + SunAbrahamResults: dict(LEGACY_OVERALL_QUINTET), + ImputationDiDResults: dict(LEGACY_OVERALL_QUINTET), + TwoStageDiDResults: dict(LEGACY_OVERALL_QUINTET), + StackedDiDResults: dict(LEGACY_OVERALL_QUINTET), + EfficientDiDResults: dict(LEGACY_OVERALL_QUINTET), + WooldridgeDiDResults: dict(LEGACY_OVERALL_QUINTET), + ChaisemartinDHaultfoeuilleResults: dict(LEGACY_OVERALL_QUINTET), + ContinuousDiDResults: {**_LEGACY_OVERALL_ATT_QUINTET, **LEGACY_OVERALL_QUINTET}, +} +# Legacy STORED key -> canonical field, per class (what a 3.x pickle carries). +STATE_MAP = { + cls: ( + dict(_LEGACY_OVERALL_ATT_QUINTET) + if cls is ContinuousDiDResults + else dict(LEGACY_OVERALL_QUINTET) + ) + for cls in PROPERTY_MAP +} +CLASSES = list(PROPERTY_MAP) +IDS = [c.__name__ for c in CLASSES] + +# ``to_dataframe(level=...)`` vocabulary per class (read from each container). +LEVELS = { + CallawaySantAnnaResults: ("group_time", "event_study", "group"), + DMLDiDResults: ("group_time", "event_study", "group"), + SunAbrahamResults: ("event_study", "cohort"), + ImputationDiDResults: ("observation", "event_study", "group"), + TwoStageDiDResults: ("observation", "event_study", "group"), + StackedDiDResults: ("event_study", "group"), + EfficientDiDResults: ("group_time", "event_study", "group"), + WooldridgeDiDResults: ("simple", "group", "calendar", "event_study", "gt"), + ChaisemartinDHaultfoeuilleResults: ("overall",), + ContinuousDiDResults: ("dose_response", "group_time", "event_study"), +} +# Classes whose fits may legitimately refuse a level because the surface +# was not stored on a 4.0 fit (the documented ValueError). +NOT_STORED_RE = re.compile( + r"not stored|aggregate\(|not available|not supported|no .* surface|None", re.I +) + + +def _nan_eq(a, b) -> bool: + if isinstance(a, tuple) and isinstance(b, tuple): + return len(a) == len(b) and all(_nan_eq(x, y) for x, y in zip(a, b)) + if isinstance(a, float) and isinstance(b, float) and math.isnan(a) and math.isnan(b): + return True + return a == b + + +# --------------------------------------------------------------------------- +# Fixtures (module-scoped fits; FRESH fits via the builders below) +# --------------------------------------------------------------------------- + +_STAGGERED_FIT_KW = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") + + +def _staggered_panel(): + return generate_staggered_data( + n_units=80, n_periods=8, cohort_periods=[4], treatment_effect=2.0, seed=42 + ) + + +def _sa_panel(n_units=80, n_periods=8, n_cohorts=3, seed=42): + rng = np.random.RandomState(seed) + units = np.repeat(np.arange(n_units), n_periods) + times = np.tile(np.arange(n_periods), n_units) + n_never = int(n_units * 0.3) + n_treated = n_units - n_never + cohort_periods = np.linspace(3, n_periods - 2, n_cohorts).astype(int) + first_treat = np.zeros(n_units) + if n_treated > 0: + first_treat[n_never:] = cohort_periods[rng.choice(len(cohort_periods), size=n_treated)] + fte = np.repeat(first_treat, n_periods) + unit_fe = np.repeat(rng.randn(n_units) * 2, n_periods) + time_fe = np.tile(np.linspace(0, 1, n_periods), n_units) + post = (times >= fte) & (fte > 0) + y = unit_fe + time_fe + 2.0 * post + rng.randn(len(units)) * 0.5 + return pd.DataFrame( + {"unit": units, "time": times, "outcome": y, "first_treat": fte.astype(int)} + ) + + +def _dml_panel(n_units=120, n_periods=5, seed=0): + rng = np.random.default_rng(seed) + cohort = rng.choice([0, 3, 4], size=n_units, p=[0.4, 0.3, 0.3]) + rows = [] + for i in range(n_units): + x1, x2 = rng.normal(), rng.normal() + u = rng.normal() + for t in range(1, n_periods + 1): + y = u + 0.5 * t + 0.3 * x1 - 0.2 * x2 + rng.normal(scale=0.5) + if cohort[i] > 0 and t >= cohort[i]: + y += 1.5 + rows.append((i, t, cohort[i], y, x1, x2)) + return pd.DataFrame(rows, columns=["unit", "time", "first_treat", "y", "x1", "x2"]) + + +def _fit(cls): + """Build a FRESH fit of ``cls`` (never cached - aggregate() may mutate).""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if cls is CallawaySantAnnaResults: + return CallawaySantAnna(n_bootstrap=0).fit(_staggered_panel(), **_STAGGERED_FIT_KW) + if cls is DMLDiDResults: + return DMLDiD(seed=0).fit( + _dml_panel(), + outcome="y", + unit="unit", + time="time", + first_treat="first_treat", + covariates=["x1", "x2"], + ) + if cls is SunAbrahamResults: + return SunAbraham().fit( + _sa_panel(), outcome="outcome", unit="unit", time="time", first_treat="first_treat" + ) + if cls is ImputationDiDResults: + return ImputationDiD().fit(_staggered_panel(), **_STAGGERED_FIT_KW) + if cls is TwoStageDiDResults: + return TwoStageDiD().fit(_staggered_panel(), **_STAGGERED_FIT_KW) + if cls is StackedDiDResults: + data = generate_staggered_data( + n_units=120, + n_periods=12, + cohort_periods=[4, 6, 8], + never_treated_frac=0.3, + treatment_effect=5.0, + dynamic_effects=True, + seed=42, + ) + return StackedDiD(kappa_pre=2, kappa_post=2).fit(data, **_STAGGERED_FIT_KW) + if cls is EfficientDiDResults: + return EfficientDiD().fit(_staggered_panel(), **_STAGGERED_FIT_KW) + if cls is WooldridgeDiDResults: + return WooldridgeDiD().fit( + load_mpdta(), + outcome="lemp", + unit="countyreal", + time="year", + first_treat="first_treat", + ) + if cls is ChaisemartinDHaultfoeuilleResults: + data = generate_reversible_did_data( + n_groups=50, n_periods=8, pattern="joiners_only", seed=42 + ) + return ChaisemartinDHaultfoeuille(twfe_diagnostic=False).fit( + data, + outcome="outcome", + unit="group", + time="period", + treatment="treatment", + L_max=1, + ) + if cls is ContinuousDiDResults: + data = generate_continuous_did_data( + n_units=200, n_periods=5, cohort_periods=[2, 4], seed=42, noise_sd=0.5 + ) + return ContinuousDiD(control_group="not_yet_treated", n_bootstrap=0).fit( + data, "outcome", "unit", "period", "first_treat", "dose" + ) + raise AssertionError(cls) + + +@pytest.fixture(scope="module") +def fits(): + return {cls: _fit(cls) for cls in CLASSES} + + +# --------------------------------------------------------------------------- +# 1. Field shape +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cls", CLASSES, ids=IDS) +def test_canonical_quintet_are_fields_legacy_names_are_descriptors(cls): + field_names = set(cls.__dataclass_fields__) + for name in CANONICAL: + assert name in field_names, f"{cls.__name__}.{name} is not a dataclass field" + for old in PROPERTY_MAP[cls]: + assert old not in field_names, f"{cls.__name__}.{old} is still a dataclass field" + assert isinstance( + inspect.getattr_static(cls, old), property + ), f"{cls.__name__}.{old} does not resolve as a property descriptor" + # The ACRT family on ContinuousDiD is a separate estimand and stays stored. + if cls is ContinuousDiDResults: + assert {"overall_acrt", "overall_acrt_se", "overall_acrt_conf_int"} <= field_names + + +# --------------------------------------------------------------------------- +# 2. Warning + relay + read-only +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cls", CLASSES, ids=IDS) +def test_legacy_properties_warn_relay_and_reject_assignment(fits, cls): + res = fits[cls] + assert type(res) is cls + for old, new in PROPERTY_MAP[cls].items(): + pattern = ( + rf"^{cls.__name__}\.{old} is deprecated; use {new} instead\. Will be removed in 5\.0\.$" + ) + with pytest.warns(FutureWarning, match=pattern): + got = getattr(res, old) + assert _nan_eq(got, getattr(res, new)), (cls.__name__, old) + with pytest.raises(AttributeError): + setattr(res, old, 1.0) + doc = inspect.getattr_static(cls, old).__doc__ or "" + assert "removed in 5.0" in doc, (cls.__name__, old, doc) + + +def test_dml_warning_names_the_subclass(fits): + res = fits[DMLDiDResults] + with pytest.warns(FutureWarning, match=r"^DMLDiDResults\.overall_att is deprecated"): + res.overall_att + # The storage is the inherited field set - no DML-specific field appeared. + assert set(DMLDiDResults.__dataclass_fields__) >= set( + CallawaySantAnnaResults.__dataclass_fields__ + ) + assert "overall_att" not in DMLDiDResults.__dataclass_fields__ + + +# --------------------------------------------------------------------------- +# 3. Legacy pickle state migrates +# --------------------------------------------------------------------------- + + +def _assert_no_legacy_keys(obj, cls): + allowed_prefix = "overall_acrt" if cls is ContinuousDiDResults else None + leaked = [ + k + for k in obj.__dict__ + if k.startswith("overall_") and not (allowed_prefix and k.startswith(allowed_prefix)) + ] + assert not leaked, f"{cls.__name__}: legacy keys linger in __dict__: {leaked}" + + +@pytest.mark.parametrize("cls", CLASSES, ids=IDS) +def test_legacy_state_loads_into_canonical_fields(fits, cls): + res = fits[cls] + state = dict(res.__dict__) + for old, new in STATE_MAP[cls].items(): + state[old] = state.pop(new) + if cls is StackedDiDResults: + # The pre-existing clean_control -> control_group migration still runs. + state["clean_control"] = state.pop("control_group") + restored = object.__new__(cls) + restored.__setstate__(state) + for name in CANONICAL: + assert _nan_eq(getattr(restored, name), getattr(res, name)), (cls.__name__, name) + _assert_no_legacy_keys(restored, cls) + if cls is StackedDiDResults: + assert restored.control_group == res.control_group + with pytest.warns(FutureWarning): + assert _nan_eq(restored.overall_att, res.att) + + +# --------------------------------------------------------------------------- +# 4. 4.0 pickle round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("cls", CLASSES, ids=IDS) +def test_pickle_round_trip_keeps_every_field_and_canonical_to_dict(fits, cls): + res = fits[cls] + loaded = pickle.loads(pickle.dumps(res)) + for f in dataclasses.fields(cls): + assert f.name in loaded.__dict__, (cls.__name__, f.name) + _assert_no_legacy_keys(loaded, cls) + for name in CANONICAL: + assert _nan_eq(getattr(loaded, name), getattr(res, name)), (cls.__name__, name) + d = loaded.to_dict() + assert _nan_eq(d["att"], res.att) + assert not [k for k in d if k.startswith("overall_") and not k.startswith("overall_acrt")] + + +# --------------------------------------------------------------------------- +# 5. Constructor keywords +# --------------------------------------------------------------------------- + + +def _required_init_kwargs(cls, overrides): + kwargs = {} + for f in dataclasses.fields(cls): + if f.name in overrides: + continue + if f.default is not dataclasses.MISSING or f.default_factory is not dataclasses.MISSING: + continue + ann = str(f.type) + if "Tuple" in ann or "tuple" in ann: + kwargs[f.name] = (0.0, 0.0) + elif "List" in ann or "list" in ann: + kwargs[f.name] = [] + elif "Dict" in ann or "dict" in ann: + kwargs[f.name] = {} + elif "DataFrame" in ann: + kwargs[f.name] = pd.DataFrame() + elif "ndarray" in ann: + kwargs[f.name] = np.array([]) + elif "float" in ann: + kwargs[f.name] = 0.0 + elif "int" in ann: + kwargs[f.name] = 0 + else: + kwargs[f.name] = None + kwargs.update(overrides) + return kwargs + + +@pytest.mark.parametrize("cls", CLASSES, ids=IDS) +def test_constructor_takes_canonical_keywords_only(cls): + canonical = {"att": 1.5, "se": 0.3, "t_stat": 5.0, "p_value": 0.001, "conf_int": (1.0, 2.0)} + res = cls(**_required_init_kwargs(cls, canonical)) + for name, value in canonical.items(): + assert getattr(res, name) == value + for old in STATE_MAP[cls]: + kwargs = _required_init_kwargs(cls, canonical) + kwargs[old] = 1.5 + with pytest.raises(TypeError, match=rf"unexpected keyword argument '{old}'"): + cls(**kwargs) + + +def test_positional_layout_unchanged_canonical_in_legacy_slots(): + """The fields were renamed IN SLOT: the generated ``__init__`` positional + layout (public API on StackedDiDResults / dCDH) puts the quintet exactly + where ``overall_*`` sat under 3.x.""" + stacked = [f.name for f in dataclasses.fields(StackedDiDResults)] + assert stacked[:5] == list(CANONICAL) + dcdh = [f.name for f in dataclasses.fields(ChaisemartinDHaultfoeuilleResults)] + assert dcdh[:5] == list(CANONICAL) + cs = [f.name for f in dataclasses.fields(CallawaySantAnnaResults)] + assert cs[:6] == ["group_time_effects", *CANONICAL] + cont = [f.name for f in dataclasses.fields(ContinuousDiDResults)] + assert cont[:7] == ["dose_response_att", "dose_response_acrt", *CANONICAL] + assert cont[7:12] == [ + "overall_acrt", + "overall_acrt_se", + "overall_acrt_t_stat", + "overall_acrt_p_value", + "overall_acrt_conf_int", + ] + + +# --------------------------------------------------------------------------- +# 6. No internal legacy read survives: every surface under FutureWarning=error +# --------------------------------------------------------------------------- + + +def _assert_documented_value_error(exc: ValueError, cls, level): + assert NOT_STORED_RE.search( + str(exc) + ), f"{cls.__name__}.to_dataframe({level!r}) raised an undocumented ValueError: {exc}" + + +@pytest.mark.parametrize("cls", CLASSES, ids=IDS) +def test_read_surfaces_emit_no_future_warning(fits, cls): + res = fits[cls] + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + res.to_dict() + res.summary() + repr(res) + for level in LEVELS[cls]: + try: + res.to_dataframe(level=level) + except ValueError as exc: + _assert_documented_value_error(exc, cls, level) + if cls is not WooldridgeDiDResults: + res.coef_var + res.is_significant + res.significance_stars + + +@pytest.mark.parametrize( + "cls", + [CallawaySantAnnaResults, ImputationDiDResults, TwoStageDiDResults, EfficientDiDResults], + ids=lambda c: c.__name__, +) +def test_aggregate_simple_and_total_emit_no_future_warning(cls): + res = _fit(cls) + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + simple = res.aggregate("simple") + assert np.isfinite(simple.to_dict()["att"]) if hasattr(simple, "to_dict") else True + try: + res.aggregate("total") + except ValueError as exc: + assert "total" in str(exc), exc + + +@pytest.mark.parametrize( + "cls", + [StackedDiDResults, ChaisemartinDHaultfoeuilleResults, ContinuousDiDResults, SunAbrahamResults], + ids=lambda c: c.__name__, +) +def test_aggregate_simple_emits_no_future_warning_other_containers(cls): + res = _fit(cls) + if not hasattr(res, "aggregate"): + pytest.skip(f"{cls.__name__} has no aggregate()") + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + try: + res.aggregate("simple") + except ValueError as exc: + assert "simple" in str(exc), exc + + +# --------------------------------------------------------------------------- +# 7. Wooldridge self-writes assign the canonical fields +# --------------------------------------------------------------------------- + + +def _three_cohort_panel(rng, *, tau_by_gt, sigma, cohort_unit_counts): + """Balanced 4-period panel, cohorts g=0 (never), 2, 3; the fixture of + tests/test_methodology_wooldridge.py's cohort-share test.""" + rows = [] + unit_id = 0 + for g in (0, 2, 3): + for _ in range(cohort_unit_counts[g]): + c_i = rng.standard_normal() + for t in (1, 2, 3, 4): + w = 1 if (g > 0 and t >= g) else 0 + tau = tau_by_gt.get((g, t), 0.0) if w else 0.0 + y = c_i + 0.5 * t + w * tau + sigma * rng.standard_normal() + rows.append({"unit": unit_id, "time": t, "cohort": g, "y": y}) + unit_id += 1 + return pd.DataFrame(rows) + + +def test_wooldridge_aggregate_simple_writes_canonical_fields(): + rng = np.random.default_rng(20261026) + panel = _three_cohort_panel( + rng, + tau_by_gt={(2, 2): 1.0, (2, 3): 1.0, (2, 4): 1.0, (3, 3): 2.0, (3, 4): 2.0}, + sigma=0.05, + cohort_unit_counts={0: 100, 2: 50, 3: 200}, + ) + res = WooldridgeDiD(method="ols").fit( + panel, outcome="y", unit="unit", time="time", first_treat="cohort" + ) + n_g = res._n_g_per_cohort + assert n_g == {2: 50, 3: 200} + gt = res.group_time_effects + # Paper Eq. 7.4: ATT_simple = sum_{(g,t): t>=g} N_g tau_gt / sum N_g. + expected = ( + n_g[2] * (gt[(2, 2)]["att"] + gt[(2, 3)]["att"] + gt[(2, 4)]["att"]) + + n_g[3] * (gt[(3, 3)]["att"] + gt[(3, 4)]["att"]) + ) / (n_g[2] * 3 + n_g[3] * 2) + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + # cohort_share inference fails closed with a documented UserWarning + # (paper W2025 Section 7.5); only FutureWarning is under test here. + warnings.simplefilter("ignore", UserWarning) + res.aggregate("simple", weights="cohort_share") + # On a balanced panel cell and cohort-share weights coincide, so the value + # need not CHANGE - the point is that the canonical-field WRITE path ran + # and landed Eq. 7.4 under the error filter. + assert res.att == pytest.approx(expected, abs=1e-12) + assert np.isfinite(res.se) + assert res.aggregation_weights["simple"] == "cohort_share" + _assert_no_legacy_keys(res, WooldridgeDiDResults) + + +def test_wooldridge_bootstrap_override_writes_canonical_fields(): + res = WooldridgeDiD(n_bootstrap=20, seed=0).fit( + load_mpdta(), outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) + assert res._bootstrap_used is True + assert np.isfinite(res.se) + assert np.isfinite(res.t_stat) and np.isfinite(res.p_value) + assert all(np.isfinite(res.conf_int)) + _assert_no_legacy_keys(res, WooldridgeDiDResults) + + +# --------------------------------------------------------------------------- +# 8. migrate_renamed_state_keys helper contract +# --------------------------------------------------------------------------- + + +class TestMigrateRenamedStateKeys: + def test_canonical_value_wins_and_legacy_key_is_dropped(self): + out = migrate_renamed_state_keys({"overall_att": 1.0, "att": 2.0}, LEGACY_OVERALL_QUINTET) + assert out == {"att": 2.0} + + def test_legacy_only_state_seeds_canonical(self): + out = migrate_renamed_state_keys( + {"overall_att": 1.0, "overall_se": 0.5, "n_obs": 3}, LEGACY_OVERALL_QUINTET + ) + assert out == {"att": 1.0, "se": 0.5, "n_obs": 3} + + def test_canonical_only_state_returned_equal_but_distinct(self): + state = {"att": 1.0, "se": 0.5} + out = migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET) + assert out == state and out is not state + + def test_input_not_mutated(self): + state = {"overall_att": 1.0, "att": 2.0} + snapshot = dict(state) + migrate_renamed_state_keys(state, LEGACY_OVERALL_QUINTET) + assert state == snapshot + + def test_quintet_map_is_the_five_names(self): + assert LEGACY_OVERALL_QUINTET == { + "overall_att": "att", + "overall_se": "se", + "overall_t_stat": "t_stat", + "overall_p_value": "p_value", + "overall_conf_int": "conf_int", + } + assert _LEGACY_OVERALL_ATT_QUINTET == { + "overall_att": "att", + "overall_att_se": "se", + "overall_att_t_stat": "t_stat", + "overall_att_p_value": "p_value", + "overall_att_conf_int": "conf_int", + } diff --git a/tests/test_wooldridge.py b/tests/test_wooldridge.py index 950c913fc..9814f8372 100644 --- a/tests/test_wooldridge.py +++ b/tests/test_wooldridge.py @@ -43,11 +43,11 @@ def _make_minimal_results(**kwargs): "conf_int": (0.02, 1.58), }, }, - overall_att=1.1, - overall_se=0.35, - overall_t_stat=3.14, - overall_p_value=0.002, - overall_conf_int=(0.41, 1.79), + att=1.1, + se=0.35, + t_stat=3.14, + p_value=0.002, + conf_int=(0.41, 1.79), group_effects=None, calendar_effects=None, event_study_effects=None,