Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.d/20261026-v4-pr5b-storage-flip.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 37 additions & 1 deletion diff_diff/_deprecation.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"""

import warnings
from typing import Any
from typing import Any, Dict, Mapping

__all__: "list[str]" = []

Expand Down Expand Up @@ -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
66 changes: 33 additions & 33 deletions diff_diff/_reporting_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
"""
Expand Down Expand Up @@ -123,15 +123,15 @@ 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 "
"``event_study_effects`` / ``group_effects`` fields are None "
"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",
}

Expand Down Expand Up @@ -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,
}

Expand All @@ -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",
}

Expand All @@ -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",
}

Expand All @@ -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",
}

Expand Down Expand Up @@ -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-
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 "
Expand All @@ -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 {
Expand All @@ -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",
}

Expand All @@ -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",
}

Expand Down Expand Up @@ -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
Expand All @@ -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``
Expand All @@ -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
Expand All @@ -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 "
Expand All @@ -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 "
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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,
}

Expand Down
Loading
Loading