diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0351f64c..401b92b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Deprecated
+- **The 8 estimator convenience wrappers + the CDiD/Gardner/Stacked alias
+ diet; new `SCM` alias** (v4 program 2(d) PR-A; ledger rows
+ [M-070]..[M-077] shimmed, [M-062] + [M-135] done, notes amendments to
+ [M-021]/[M-022] and [M-132]..[M-134]). The module-level wrapper
+ functions `imputation_did`, `two_stage_did`, `stacked_did`, `trop`,
+ `synthetic_control`, `triple_difference`, `bacon_decompose`, and
+ `chaisemartin_dhaultfoeuille` are deprecated (3.9, removed in 4.0):
+ every call now emits a `FutureWarning` naming the class successor -
+ classes are the single canonical construction surface (only 8 of 24
+ estimators ever had wrappers, so the surface retires rather than
+ completes; `twowayfeweights` stays - a diagnostic function, not a
+ class duplicate). The wrappers remain pure construct+fit relays -
+ results are bit-identical to the class path (pinned across the full
+ inference quintet in `tests/test_v4_wrapper_shims.py`), and the
+ `aggregate`/`balance_e` sentinel forwarding is unchanged (a plain
+ wrapper call fires exactly ONE warning - the wrapper deprecation;
+ four `test_plain_wrapper_call_does_not_warn`-style pins flipped BY
+ DESIGN to expect it). The `DiagnosticReport` Bacon runner migrated to
+ `BaconDecomposition` internally (numbers identical). ALIAS DIET: the
+ `CDiD`/`Gardner`/`Stacked` aliases are deprecated (3.9, removed 4.0;
+ rows M-132..M-134) - they leave module globals (gone from `dir()`
+ and static autocomplete) but stay importable and in `__all__`,
+ served by a new PEP 562 module `__getattr__` ([M-135]) that warns and
+ returns `ContinuousDiD`/`TwoStageDiD`/`StackedDiD`; a star-import
+ records the three alias warnings twice each (the package import
+ protocol resolves each missing `__all__` name twice - the tests
+ assert the message set, not the count). NEW: `SCM =
+ SyntheticControl` ([M-062], introduce-only). Emitted-guidance
+ register: `practitioner_next_steps()` recommendation strings now use
+ full class names (e.g. "CallawaySantAnna, SunAbraham, or
+ TwoStageDiD" instead of "CS, SA, or Gardner") and its
+ `output["estimator"]` display label for TwoStageDiD results is now
+ "TwoStageDiD" (previously "TwoStageDiD (Gardner)"); the
+ BusinessReport/DiagnosticReport Bacon caveats name classes likewise.
+
### Added
- **HeterogeneousAdoptionDiD post-fit `aggregate()` + panel-shape mode
inference, and the per-level bootstrap-gate convergence** (v4 program 2(b)
@@ -122,7 +158,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`fit()` never warns; supplying ANY value (None included) warns once;
the deprecated path still returns exactly the numbers it always did.
The `imputation_did`/`two_stage_did` wrappers forward the sentinel,
- so plain wrapper calls never warn. Unknown `aggregate` strings keep
+ so plain wrapper calls never fire the aggregate warning (since 3.9
+ every wrapper call fires its own M-070/M-071 wrapper-deprecation
+ warning instead). Unknown `aggregate` strings keep
silently acting like None on the deprecated path; the post-fit
successor fails closed on unknown types - a behavior improvement.
- MEMORY CONTRACT: ImputationDiD's kit references the SAME per-fit
@@ -245,7 +283,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
inert (CS-style warn-and-still-work - results are identical to a
plain fit's), and the `group`/`all`/unknown-value `ValueError`s
persist after the warning. The `stacked_did()` convenience wrapper
- forwards the shim. `StackedDiDResults.aggregate()` ships as a pure
+ forwards the shim (a plain wrapper call fires only the M-072
+ wrapper-deprecation warning since 3.9). `StackedDiDResults.aggregate()` ships as a pure
VIEW: `'event_study'` returns the unified container (with
`base_period='universal'` and singleton `reference_event_times`
provenance - one omitted reference per fit by construction);
@@ -519,7 +558,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`guides/*.txt`, and `docs/methodology/`. The guard's first run completed 26
rows' `code_refs` with newly confirmed readers (e.g. `diagnostics.py`'s
`fit(time=...)` call sites on [M-030], `diagnostic_report.py`'s bare
- `bacon_decompose(...)` call on [M-076], the packaged-guide readers of
+ `bacon_decompose(...)` call on [M-076] (that internal call migrated to
+ the class form in the 2(d) PR-A), the packaged-guide readers of
`cohort`/`aggregation`/the wrapper functions), rule 11 gained the
token-family `code_refs` union clause, and the `plot_group_effects` cohort
selector joined the section-8 domain-vocabulary carve-outs. Wired into the
@@ -741,7 +781,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
ImputationDiD↔TwoStageDiD merge, and a moderate **alias diet** — `CDiD`,
`Stacked` and `Gardner` are deprecated at 3.9 and removed at 4.0
(ledger rows M-132..M-134), with the 3.9 `FutureWarning` carried by a
- module-level `__getattr__` (M-135, ships in phase 2d) because the
+ module-level `__getattr__` (M-135, shipped by the 2(d) PR-A) because the
target classes survive; `EDiD` was initially slated but retained (review
evidence: it is the Chen-Sant'Anna-Xie paper's own estimator label); the
never-shipped `Spillover` alias introduction is
diff --git a/benchmarks/speed_review/bench_dose_response.py b/benchmarks/speed_review/bench_dose_response.py
index bbfbecb1..286c46e3 100644
--- a/benchmarks/speed_review/bench_dose_response.py
+++ b/benchmarks/speed_review/bench_dose_response.py
@@ -1,7 +1,7 @@
"""
Scenario 6: Pricing dose-response with ContinuousDiD cubic spline.
-Chains: CDiD fit (overall ATT + ACRT + dose-response curves are always
+Chains: ContinuousDiD fit (overall ATT + ACRT + dose-response curves are always
computed; bootstrap 199) -> dataframe extraction -> post-fit event-study
pre-trend (results.aggregate('event_study'), row M-025) -> binarized-DiD
comparison -> spline sensitivity (degree=1, num_knots=2).
@@ -76,7 +76,7 @@ def cdid_event_study():
def binarized_comparison():
# Derive post from the actual first_treat cohort in the data so
- # this phase is aligned with the CDiD fits above. A hardcoded
+ # this phase is aligned with the ContinuousDiD fits above. A hardcoded
# period cutoff would silently desync if the DGP cohort moves.
treated_cohort = int(
sorted(v for v in data["first_treat"].unique() if v > 0)[0]
diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py
index 4ed94a20..78318ac7 100644
--- a/diff_diff/__init__.py
+++ b/diff_diff/__init__.py
@@ -20,6 +20,9 @@
the difference-in-differences methodology.
"""
+import warnings as _warnings
+from typing import Any as _Any
+
# Import backend detection from dedicated module (avoids circular imports)
from diff_diff._backend import (
HAS_RUST_BACKEND,
@@ -318,13 +321,10 @@
EventStudy = MultiPeriodDiD
SDiD = SyntheticDiD
CS = CallawaySantAnna
-CDiD = ContinuousDiD
SA = SunAbraham
BJS = ImputationDiD
-Gardner = TwoStageDiD
DDD = TripleDifference
SDDD = StaggeredTripleDifference
-Stacked = StackedDiD
Bacon = BaconDecomposition
EDiD = EfficientDiD
ETWFE = WooldridgeDiD
@@ -332,6 +332,34 @@
HAD = HeterogeneousAdoptionDiD
CiC = ChangesInChanges
RDD = RegressionDiscontinuity
+SCM = SyntheticControl
+
+# Alias diet (rows M-132..M-134, mechanism M-135): CDiD / Gardner /
+# Stacked are deprecated in 3.9 and removed in 4.0. They deliberately
+# do NOT live in module globals — dir()/vars() no longer list them —
+# but stay importable (and in __all__) through 3.9, served by the PEP
+# 562 module __getattr__ below, which emits the FutureWarning naming
+# the surviving class.
+_DEPRECATED_ALIASES = {
+ "CDiD": "ContinuousDiD",
+ "Gardner": "TwoStageDiD",
+ "Stacked": "StackedDiD",
+}
+
+
+def __getattr__(name: str) -> _Any:
+ """PEP 562 warning shim for the dieted aliases (row M-135)."""
+ target = _DEPRECATED_ALIASES.get(name)
+ if target is not None:
+ _warnings.warn(
+ f"diff_diff.{name} is deprecated and will be removed in 4.0; "
+ f"use diff_diff.{target}.",
+ FutureWarning,
+ stacklevel=2,
+ )
+ return globals()[target]
+ raise AttributeError(f"module 'diff_diff' has no attribute {name!r}")
+
__version__ = "3.8.0"
__all__ = [
@@ -353,7 +381,9 @@
"TROP",
"SyntheticControl",
"StackedDiD",
- # Estimator aliases (short names)
+ # Estimator aliases (short names). CDiD / Gardner / Stacked are
+ # deprecated (M-132..M-134): still importable through 3.9 via the
+ # module __getattr__ (M-135), gone from module globals/dir().
"DiD",
"TWFE",
"EventStudy",
@@ -367,6 +397,7 @@
"Gardner",
"DDD",
"SDDD",
+ "SCM",
"Stacked",
"Bacon",
# Bacon Decomposition
diff --git a/diff_diff/bacon.py b/diff_diff/bacon.py
index 6777f503..d80949b6 100644
--- a/diff_diff/bacon.py
+++ b/diff_diff/bacon.py
@@ -1281,6 +1281,11 @@ def bacon_decompose(
"""
Convenience function for Goodman-Bacon decomposition.
+ .. deprecated:: 3.9
+ ``bacon_decompose()`` is deprecated and will be removed in 4.0
+ (row M-076). Construct the diagnostic instead:
+ ``BaconDecomposition(...).fit(data, ...)``.
+
Decomposes a TWFE estimate into weighted 2x2 DiD comparisons,
showing which comparisons drive the estimate and whether
problematic "forbidden comparisons" are involved.
@@ -1392,5 +1397,11 @@ def bacon_decompose(
plot_bacon : Visualize the decomposition
CallawaySantAnna : Robust estimator that avoids forbidden comparisons
"""
+ warnings.warn(
+ "bacon_decompose() is deprecated and will be removed in 4.0; "
+ "construct the diagnostic instead: BaconDecomposition(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
decomp = BaconDecomposition(weights=weights)
return decomp.fit(data, outcome, unit, time, first_treat, survey_design=survey_design)
diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py
index 8f11cdfd..09e30e25 100644
--- a/diff_diff/business_report.py
+++ b/diff_diff/business_report.py
@@ -1652,8 +1652,8 @@ def _build_caveats(
# Round-45 P1 CI review on PR #318: Goodman-Bacon is a
# decomposition of TWFE weights (see ``bacon.py`` header and
# Goodman-Bacon 2021). On fits already produced by a
- # heterogeneity-robust estimator (CS / SA / BJS / Gardner /
- # Wooldridge / EfficientDiD / Stacked / dCDH / TripleDifference /
+ # heterogeneity-robust estimator (CS / SA / BJS / TwoStageDiD /
+ # Wooldridge / EfficientDiD / StackedDiD / dCDH / TripleDifference /
# StaggeredTripleDiff / SDiD / TROP), a high forbidden-weight share
# says "TWFE would have been materially biased on this rollout",
# not "the displayed estimator needs to be replaced" — the
@@ -1680,7 +1680,8 @@ def _build_caveats(
"later-vs-earlier comparisons. TWFE may be "
"materially biased under heterogeneous effects. "
"Re-estimate with a heterogeneity-robust "
- "estimator (CS / SA / BJS / Gardner)."
+ "estimator (CallawaySantAnna, SunAbraham, "
+ "ImputationDiD, or TwoStageDiD)."
)
else:
bacon_message = (
diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py
index 6d119c7e..1a24a200 100644
--- a/diff_diff/chaisemartin_dhaultfoeuille.py
+++ b/diff_diff/chaisemartin_dhaultfoeuille.py
@@ -8737,6 +8737,11 @@ def chaisemartin_dhaultfoeuille(
One-shot convenience wrapper around
:class:`ChaisemartinDHaultfoeuille`.
+ .. deprecated:: 3.9
+ ``chaisemartin_dhaultfoeuille()`` is deprecated and will be
+ removed in 4.0 (row M-077). Construct the estimator instead:
+ ``ChaisemartinDHaultfoeuille(...).fit(data, ...)``.
+
Equivalent to::
ChaisemartinDHaultfoeuille(**init_kwargs).fit(
@@ -8761,6 +8766,13 @@ def chaisemartin_dhaultfoeuille(
"""
import inspect
+ warnings.warn(
+ "chaisemartin_dhaultfoeuille() is deprecated and will be removed in 4.0; "
+ "construct the estimator instead: "
+ "ChaisemartinDHaultfoeuille(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
init_keys = {
name
for name, p in inspect.signature(ChaisemartinDHaultfoeuille.__init__).parameters.items()
diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py
index 33c35b75..87203875 100644
--- a/diff_diff/diagnostic_report.py
+++ b/diff_diff/diagnostic_report.py
@@ -13,7 +13,7 @@
- No estimator fitting and no variance re-derivation from raw data. Every
effect, SE, p-value, CI, and sensitivity bound is either read from
``results`` or produced by an existing diff-diff utility. May call
- ``check_parallel_trends`` / ``bacon_decompose`` /
+ ``check_parallel_trends`` / ``BaconDecomposition`` /
``EfficientDiD.hausman_pretest`` when the caller supplies the panel +
column kwargs. Report-layer cross-period aggregations (joint-Wald /
Bonferroni pre-trends p-value, heterogeneity dispersion over
@@ -138,7 +138,7 @@
# ``bacon``. ``bacon`` is excluded because SpilloverDiD identifies
# the direct effect off FAR-AWAY units (Butts Assumption 5), not
# off the TWFE 2x2 comparisons a Goodman-Bacon decomposition
- # enumerates: ``bacon_decompose`` on the raw binary treatment
+ # enumerates: a Bacon decomposition on the raw binary treatment
# ignores the ring/distance structure and would pool spillover-
# contaminated in-ring units into the control group — the exact
# SUTVA violation the estimator exists to handle (same rationale
@@ -344,7 +344,7 @@ class DiagnosticReport:
survey_design : SurveyDesign, optional
The ``SurveyDesign`` object used to fit a survey-weighted
estimator. Required for fit-faithful replay of Goodman-Bacon on a
- survey-backed fit; threaded to ``bacon_decompose(survey_design=...)``.
+ survey-backed fit; threaded to ``BaconDecomposition.fit(survey_design=...)``.
When the fit carries ``survey_metadata`` but ``survey_design`` is
not supplied, Bacon is skipped with an explicit reason rather than
replaying an unweighted decomposition for a design that does not
@@ -445,7 +445,7 @@ def __init__(
self._sensitivity_method = sensitivity_method
self._alpha = float(alpha)
# Round-40 P1 CI review on PR #318: survey-backed fits need the
- # ``SurveyDesign`` object threaded through to ``bacon_decompose``
+ # ``SurveyDesign`` object threaded through to ``BaconDecomposition.fit``
# for a fit-faithful Goodman-Bacon replay, and the unweighted
# 2x2 parallel-trends helper (``utils.check_parallel_trends``)
# cannot be called on a survey-weighted DiDResults without
@@ -944,7 +944,7 @@ def _instance_skip_reason(self, check: str) -> Optional[str]:
if name == "BaconDecompositionResults":
return None
# Otherwise mirror the full argument contract of
- # ``_check_bacon`` / ``bacon_decompose``: the runner needs
+ # ``_check_bacon`` / ``BaconDecomposition.fit``: the runner needs
# ``data``, ``first_treat``, and the ``outcome`` / ``time`` /
# ``unit`` column names. Gating on only ``data`` +
# ``first_treat`` (as before) left ``applicable_checks``
@@ -966,7 +966,7 @@ def _instance_skip_reason(self, check: str) -> Optional[str]:
"Bacon decomposition needs panel data + outcome / time "
"/ unit / first_treat column names. Missing: " + ", ".join(bacon_missing) + "."
)
- # Round-40 P1 CI review on PR #318: ``bacon_decompose``
+ # Round-40 P1 CI review on PR #318: ``BaconDecomposition.fit``
# supports a ``survey_design`` kwarg for survey-weighted
# decomposition. When the fitted result carries
# ``survey_metadata`` but the caller did not supply a
@@ -2004,7 +2004,7 @@ def _check_bacon(self) -> Dict[str, Any]:
"""Surface Bacon decomposition: read-out when applicable, else skip.
If ``results`` is itself a ``BaconDecompositionResults``, read fields.
- If ``data`` + ``first_treat`` are supplied, call ``bacon_decompose``.
+ If ``data`` + ``first_treat`` are supplied, run ``BaconDecomposition``.
Otherwise, skip with a helpful reason.
"""
if "bacon" in self._precomputed:
@@ -2046,15 +2046,16 @@ def _check_bacon(self) -> Dict[str, Any]:
}
try:
- from diff_diff.bacon import bacon_decompose
+ from diff_diff.bacon import BaconDecomposition
- bacon = bacon_decompose(
+ bacon = BaconDecomposition(
+ weights="exact", # paper-faithful Eqs. 7-9 / 10e-g
+ ).fit(
data,
outcome=outcome,
unit=unit,
time=time,
first_treat=first_treat,
- weights="exact", # paper-faithful Eqs. 7-9 / 10e-g
survey_design=self._survey_design,
)
except ValueError as exc:
@@ -2063,7 +2064,8 @@ def _check_bacon(self) -> Dict[str, Any]:
# ``_validate_unit_constant_survey``. Survey-backed reports on
# panels with time-varying within-unit weights / strata / PSU
# / FPC fail that check. The library still supports those
- # panels via explicit ``bacon_decompose(weights="approximate", ...)``,
+ # panels via explicit
+ # ``BaconDecomposition(weights="approximate").fit(...)``,
# so emit a structured skip pointing users at the
# ``precomputed={'bacon': ...}`` escape hatch rather than an
# opaque ``error`` block.
@@ -2074,8 +2076,9 @@ def _check_bacon(self) -> Dict[str, Any]:
"Survey design has within-unit-varying columns "
"(weights / strata / PSU / FPC), which the "
'paper-faithful ``weights="exact"`` Bacon path '
- "rejects. Run ``bacon_decompose(data, ..., "
- 'weights="approximate", survey_design=design)`` '
+ "rejects. Run "
+ '``BaconDecomposition(weights="approximate")'
+ ".fit(data, ..., survey_design=design)`` "
"yourself and pass via "
"``DiagnosticReport(..., precomputed={'bacon': result})`` "
f"to populate this section. Validator detail: {exc}"
@@ -2083,7 +2086,7 @@ def _check_bacon(self) -> Dict[str, Any]:
}
return {
"status": "error",
- "reason": f"bacon_decompose raised {type(exc).__name__}: {exc}",
+ "reason": f"the Bacon decomposition raised {type(exc).__name__}: {exc}",
}
except NotImplementedError as exc:
# PR #454 R4 P3: ``BaconDecomposition.fit()`` raises
@@ -2102,7 +2105,7 @@ def _check_bacon(self) -> Dict[str, Any]:
"Bacon decomposition does not support (bacon is a "
"diagnostic and does not compute replicate-based "
"variance). To populate this section, run "
- "``bacon_decompose(data, ..., "
+ "``BaconDecomposition().fit(data, ..., "
"survey_design=SurveyDesign(weights=..., strata=..., "
"psu=..., fpc=...))`` with a TSL-based design and "
"pass via "
@@ -2113,7 +2116,7 @@ def _check_bacon(self) -> Dict[str, Any]:
except Exception as exc: # noqa: BLE001
return {
"status": "error",
- "reason": f"bacon_decompose raised {type(exc).__name__}: {exc}",
+ "reason": f"the Bacon decomposition raised {type(exc).__name__}: {exc}",
}
return self._format_bacon(bacon)
@@ -4148,7 +4151,8 @@ def _render_overall_interpretation(schema: Dict[str, Any], labels: Dict[str, str
sentences.append(
f"Goodman-Bacon decomposition flags {fw:.0%} of TWFE weight on "
f"'forbidden' later-vs-earlier comparisons — consider a "
- f"heterogeneity-robust estimator (CS / SA / BJS / Gardner) if "
+ f"heterogeneity-robust estimator (CallawaySantAnna, SunAbraham, "
+ f"ImputationDiD, or TwoStageDiD) if "
f"not already in use."
)
deff = schema.get("design_effect") or {}
diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt
index 5bc6030d..0aebda52 100644
--- a/diff_diff/guides/llms-autonomous.txt
+++ b/diff_diff/guides/llms-autonomous.txt
@@ -485,7 +485,7 @@ estimators:
- `ImputationDiD` (Borusyak, Jaravel, Spiess) - imputation-based,
efficient under homoskedasticity, produces an imputation-based
residual at the observation level.
-- `TwoStageDiD` (Gardner) - two-stage residualize-then-regress.
+- `TwoStageDiD` - two-stage residualize-then-regress.
- `StackedDiD` - stacked event-study regressions, one subpanel per
cohort. Conservative interpretation.
- `WooldridgeDiD` (ETWFE) - extended-TWFE with cohort-by-time-by-
@@ -495,7 +495,7 @@ estimators:
to pick. Requires a balanced panel (`PanelProfile.is_balanced ==
True`); `fit()` raises `ValueError` on unbalanced input.
-Diagnostic: `bacon_decompose(df, ...)` shows the weight allocation of a
+Diagnostic: `BaconDecomposition().fit(df, ...)` shows the weight allocation of a
TWFE fit to 2×2 comparison types. Forbidden-comparison weight > 10% is a
strong signal that the TWFE estimate is biased.
@@ -1080,7 +1080,7 @@ Some estimators expose diagnostics as methods on the result object:
### Decomposition and weight auditing
-- `bacon_decompose(df, ...)` - Goodman-Bacon (2021) TWFE weight
+- `BaconDecomposition().fit(df, ...)` - Goodman-Bacon (2021) TWFE weight
decomposition. Returns a `BaconDecompositionResults` with the weight
on forbidden (later-vs-earlier) comparisons. Run before interpreting
any TWFE staggered fit.
@@ -1251,7 +1251,7 @@ or the outcome model is correctly specified.
- **Goodman-Bacon, Andrew (2021).** "Difference-in-Differences with
Variation in Treatment Timing." Journal of Econometrics
225(2): 254-277. TWFE weight decomposition;
- `bacon_decompose` implements this.
+ `BaconDecomposition` implements this.
- **Callaway, Brantly, and Pedro H. C. Sant'Anna (2021).**
"Difference-in-Differences with Multiple Time Periods." Journal of
Econometrics 225(2): 200-230. Group-time ATT.
@@ -1312,7 +1312,7 @@ This guide does **not**:
- Promise forward-compatibility of the BR / DR schema or the alert
catalogue. Treat these as experimental until the 12-item foundation-
gap list closes.
-- Replace `bacon_decompose()`, `compute_honest_did()`, or any of the
+- Replace `BaconDecomposition`, `compute_honest_did()`, or any of the
estimator-native diagnostics. Post-fit validation is mandatory, not
optional, and belongs in the final write-up.
- Cover methods outside diff-diff's estimator suite (e.g., instrumental
diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt
index bc12c234..5a907c5b 100644
--- a/diff_diff/guides/llms-full.txt
+++ b/diff_diff/guides/llms-full.txt
@@ -29,7 +29,7 @@ print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})")
- **sklearn-like API**: All estimators use `fit()` method, `get_params()`/`set_params()` for configuration.
- **Formula interface**: Supports R-style formulas like `"outcome ~ treated * post"`.
- **Results objects**: Rich dataclass containers with `summary()`, `to_dict()`, `to_dataframe()`.
-- **Estimator aliases**: Short names available (e.g., `DiD`, `CS`, `SA`, `BJS`, `Gardner`, `SDiD`, `TWFE`, `DDD`, `CDiD`, `EDiD`, `Stacked`, `Bacon`).
+- **Estimator aliases**: Short names available (e.g., `DiD`, `CS`, `SA`, `BJS`, `SDiD`, `TWFE`, `DDD`, `EDiD`, `SCM`, `Bacon`). The former `CDiD`/`Gardner`/`Stacked` aliases are deprecated (3.9, removed in 4.0) - use `ContinuousDiD`/`TwoStageDiD`/`StackedDiD`.
## Practitioner Workflow (based on Baker et al. 2025)
@@ -480,7 +480,7 @@ TwoStageDiD(
)
```
-**Alias:** `Gardner`
+**Alias:** `Gardner` (deprecated 3.9, removed 4.0 - emits FutureWarning; use `TwoStageDiD`)
**fit() parameters:**
@@ -618,6 +618,8 @@ weights_df = results.get_unit_weights_df()
Classic Synthetic Control Method (Abadie, Diamond & Hainmueller 2010; Abadie & Gardeazabal 2003). Builds a single treated unit's counterfactual as a convex combination of never-treated "donor" units. **Donor weights only** (no time weights, no ridge) — distinct from `SyntheticDiD`.
+**Alias:** `SCM`
+
```python
SyntheticControl(
v_method: str = "nested", # "nested" (pre-MSPE) | "cv" (out-of-sample, ADH 2015; needs predictors spanning BOTH train/val windows — default single-period lags are REJECTED) | "inverse_variance" (1/Var(X) on RAW predictors, bypasses standardize) | "custom"
@@ -756,7 +758,7 @@ is the paper's backward finite difference on `{0, d_1, …, d_J}` (`ACRT(d_1) =
covariate / survey) and reduces to the per-level 2×2 DiD SE. Multi-cohort fits need a shared dose
support across cohorts (else `NotImplementedError`); an off-support `dvals` value raises `ValueError`.
-**Alias:** `CDiD`
+**Alias:** `CDiD` (deprecated 3.9, removed 4.0 - emits FutureWarning; use `ContinuousDiD`)
**fit() parameters:**
@@ -1037,7 +1039,7 @@ StackedDiD(
)
```
-**Alias:** `Stacked`
+**Alias:** `Stacked` (deprecated 3.9, removed 4.0 - emits FutureWarning; use `StackedDiD`)
**fit() parameters:**
@@ -1482,19 +1484,21 @@ etwfe_pois = WooldridgeDiD(method='poisson')
results = etwfe_pois.fit(data, outcome='y_count', unit='id', time='t', first_treat='first_treat')
```
-### Convenience Functions
+### Convenience Functions (deprecated 3.9, removed 4.0)
+
+All 8 wrapper functions (`imputation_did`, `two_stage_did`, `stacked_did`, `trop`, `synthetic_control`, `triple_difference`, `bacon_decompose`, `chaisemartin_dhaultfoeuille`) emit a `FutureWarning` since 3.9 and are removed in 4.0 (rows M-070..M-077) - construct the class instead:
```python
-# Functional interfaces (create estimator + call fit in one step)
-from diff_diff import imputation_did, two_stage_did, triple_difference, stacked_did, trop, bacon_decompose
+# Canonical class form (what the wrappers did internally)
+from diff_diff import ImputationDiD, TwoStageDiD, TripleDifference, StackedDiD, TROP, BaconDecomposition
-results = imputation_did(data, outcome='y', unit='id', time='t', first_treat='ft')
-results = two_stage_did(data, outcome='y', unit='id', time='t', first_treat='ft')
-results = triple_difference(data, outcome='y', group='g', partition='p', time='t')
-results = stacked_did(data, outcome='y', unit='id', time='t', first_treat='ft',
- kappa_pre=2, kappa_post=2)
-results = trop(data, outcome='y', treatment='d', unit='id', time='t')
-results = bacon_decompose(data, outcome='y', unit='id', time='t', first_treat='ft')
+results = ImputationDiD().fit(data, outcome='y', unit='id', time='t', first_treat='ft')
+results = TwoStageDiD().fit(data, outcome='y', unit='id', time='t', first_treat='ft')
+results = TripleDifference().fit(data, outcome='y', group='g', partition='p', post='t')
+results = StackedDiD(kappa_pre=2, kappa_post=2).fit(data, outcome='y', unit='id', time='t',
+ first_treat='ft')
+results = TROP().fit(data, 'y', 'd', 'id', 't')
+results = BaconDecomposition().fit(data, outcome='y', unit='id', time='t', first_treat='ft')
```
## Results Objects
@@ -1738,7 +1742,7 @@ Returned by `TripleDifference.fit()`.
### BaconDecompositionResults
-Returned by `BaconDecomposition.fit()` and `bacon_decompose()`.
+Returned by `BaconDecomposition.fit()` (and the deprecated `bacon_decompose()` wrapper, removed in 4.0).
| Attribute | Type | Description |
|-----------|------|-------------|
@@ -2179,7 +2183,7 @@ All plotting functions return a matplotlib `Figure` object.
from diff_diff import plot_event_study
plot_event_study(
- results, # MultiPeriodDiDResults, CS, SA, BJS, Gardner, Stacked, or DataFrame
+ results, # MultiPeriodDiDResults, CS, SA, BJS, TwoStageDiD, StackedDiD, or DataFrame
effects=None, # Manual dict of effects (alternative to results)
se=None, # Manual dict of SEs
periods=None,
@@ -2753,7 +2757,7 @@ no-scalar branch.
## DiagnosticReport
Unified diagnostic runner orchestrating `check_parallel_trends`,
-`compute_pretrends_power`, `HonestDiD.sensitivity`, `bacon_decompose`,
+`compute_pretrends_power`, `HonestDiD.sensitivity`, `BaconDecomposition`,
plus estimator-native surfaces for SyntheticDiD (`pre_treatment_fit`,
`get_weight_concentration`, `in_time_placebo`, `sensitivity_to_zeta_omega`)
and TROP (factor-model metrics). EfficientDiD PT uses the native
@@ -2827,7 +2831,7 @@ Power tier (drives BR phrasing for the `no_detected_violation` verdict):
BR and DR do no estimator fitting and do not re-derive variance from
raw data — every effect, SE, p-value, CI, and sensitivity bound is
read from the fitted result or produced by an existing diff-diff
-utility (may call `check_parallel_trends`, `bacon_decompose`, or
+utility (may call `check_parallel_trends`, `BaconDecomposition.fit`, or
`EfficientDiD.hausman_pretest` when the panel + column kwargs are
supplied). The `design_effect` section is read-only: it echoes
`survey_metadata.design_effect` / `effective_n` from the fitted
diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt
index 90fecf49..d432d6ee 100644
--- a/diff_diff/guides/llms-practitioner.txt
+++ b/diff_diff/guides/llms-practitioner.txt
@@ -178,7 +178,7 @@ Route by ESTIMAND first, not by whether never-treated units happen
to be present (HAD remains valid with a small never-treated share;
see REGISTRY HeterogeneousAdoptionDiD edge cases):
|-- Target estimand is per-dose ATT(d) / ACRT(d) curves (you want
-| the dose-response function): ContinuousDiD (CDiD). Requires
+| the dose-response function): ContinuousDiD. Requires
| a non-empty untreated comparison group — i.e. some units
| with dose == 0 throughout, encoded as either first_treat == 0
| or first_treat == inf (ContinuousDiD normalizes inf -> 0).
@@ -210,8 +210,8 @@ Is treatment adoption staggered (multiple cohorts, different timing)?
| |-- CallawaySantAnna (CS) -- most general, doubly robust, recommended default
| |-- SunAbraham (SA) -- interaction-weighted, good for event studies
| |-- ImputationDiD (BJS) -- most efficient under homogeneous effects
-| |-- TwoStageDiD (Gardner) -- two-stage with GMM variance
-| |-- StackedDiD (Stacked) -- sub-experiment approach
+| |-- TwoStageDiD -- two-stage with GMM variance
+| |-- StackedDiD -- sub-experiment approach
| |-- EfficientDiD (EDiD) -- optimal weighting for tighter SEs
| \-- WooldridgeDiD (ETWFE) -- nonlinear outcomes (logit/Poisson) or saturated OLS
|
diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt
index b1d76469..0dbe5ab9 100644
--- a/diff_diff/guides/llms.txt
+++ b/diff_diff/guides/llms.txt
@@ -21,7 +21,7 @@ diagnostic steps produces unreliable results.
4. **Choose estimator** — staggered adoption → CS/SA/BJS (NOT plain TWFE); few treated units → SDiD; factor confounding → TROP; simple 2x2 → DiD. Run `BaconDecomposition` to diagnose TWFE bias.
5. **Estimate** — `estimator.fit(data, ...)`. Always print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer).
6. **Sensitivity analysis** — `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; a StackedDiD `results.aggregate('event_study')` container also admits - needs `kappa_pre >= 2` so estimated pre-periods exist), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs.
-7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (fit-time `aggregate=`/`balance_e=` are deprecated since 3.9, removed in 4.0; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; EXCEPTION: on a BOOTSTRAPPED CS fit the recompute levels `'event_study'`/`'group'` raise while `.aggregate('simple')` relays the stored bootstrap inference (NaN df column); use the fit-time aggregation for a bootstrapped event-study surface); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; Stacked: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; fit-time `aggregate=`/`balance_e=` deprecated; on bootstrapped EDiD fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the fit-time aggregation for a bootstrapped ES/group surface; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/Gardner: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the deprecated fit-time aggregation for a bootstrapped ES/group surface; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit and raises on bootstrapped fits (use the deprecated fit-time `aggregate='eventstudy'` there until 4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation.
+7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (fit-time `aggregate=`/`balance_e=` are deprecated since 3.9, removed in 4.0; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; EXCEPTION: on a BOOTSTRAPPED CS fit the recompute levels `'event_study'`/`'group'` raise while `.aggregate('simple')` relays the stored bootstrap inference (NaN df column); use the fit-time aggregation for a bootstrapped event-study surface); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; StackedDiD: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; fit-time `aggregate=`/`balance_e=` deprecated; on bootstrapped EDiD fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the fit-time aggregation for a bootstrapped ES/group surface; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/TwoStageDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the deprecated fit-time aggregation for a bootstrapped ES/group surface; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit and raises on bootstrapped fits (use the deprecated fit-time `aggregate='eventstudy'` there until 4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation.
8. **Robustness** — compare 2-3 estimators (CS vs SA vs BJS), MUST report with and without covariates (shows whether conditioning drives identification), present pre-trends and sensitivity bounds.
After estimation, call `practitioner_next_steps(results)` for context-aware
@@ -67,7 +67,7 @@ The site is organized into 5 sections, each with a landing page:
- [TwoStageDiD](https://diff-diff.readthedocs.io/en/stable/api/two_stage.html): Gardner (2022) two-stage estimator with GMM sandwich variance
- [SpilloverDiD](https://diff-diff.readthedocs.io/en/stable/api/spillover.html): Butts (2021) ring-indicator spillover-aware DiD identifying direct effect on treated + per-ring spillover-on-control; reuses `conley_coords` for ring construction; handles non-staggered and staggered timing; supports `SurveyDesign(weights, strata, psu, fpc)` under `vcov_type="hc1"` with optional `cluster=
` for CR1 via Gerber (2026) Binder TSL (Wave E.1) and under `vcov_type="conley"` via a panel-aware stratified-Conley sandwich on per-period PSU totals (Wave E.2 cross-sectional `conley_lag_cutoff=0`) extended in Wave E.2 follow-up to `conley_lag_cutoff > 0` via panel-block composition with within-PSU serial Bartlett HAC (Newey-West 1987 separable form; `lag>0` requires an effective PSU via explicit `survey_design.psu` or injected `cluster=`), both composed with the Wave D Gardner GMM correction; `SurveyDesign.subpopulation()` preserves full-design `n_psu` / `df_survey` via zero-padded scores at the meat-helper boundary (Wave E.3, R `svyrecvar(subset())` form) (replicate weights queued as follow-up)
- [SyntheticDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Synthetic DiD combining standard DiD and synthetic control methods for few treated units
-- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html): Abadie, Diamond & Hainmueller (2010) classic synthetic control for ONE treated unit — donor-weight counterfactual, predictor-importance V via nested / cv (out-of-sample, ADH 2015; needs predictors spanning both train/val windows, so default single-period lags are rejected) / inverse-variance (1/Var on raw predictors, bypasses standardize) / custom, gap path + pre-RMSPE; no analytical SE (inference fields NaN), significance via in-space placebo permutation inference (`in_space_placebo()`, post/pre RMSPE-ratio, p = rank/(n_placebos+1)); ADH-2015 §4 robustness: `leave_one_out()` donor-robustness + `in_time_placebo()` backdating placebo; confidence sets by test inversion (Firpo-Possebom 2018 §4): `test_sharp_null()` + `confidence_set(family="constant"|"linear")` re-rank the placebo gaps into a confidence set for the effect path (the analytical `conf_int` stays NaN); conformal inference (Chernozhukov-Wüthrich-Zhu 2021): `conformal_test()` (joint sharp-null p-value), `conformal_confidence_intervals()` (pointwise per-period CIs), `conformal_average_effect()` (average-effect CI) — fit their OWN constrained-LS proxy under the null on all periods and permute residuals over time (moving-block / iid schemes; `conf_int` still NaN)
+- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html): Abadie, Diamond & Hainmueller (2010) classic synthetic control for ONE treated unit — donor-weight counterfactual, predictor-importance V via nested / cv (out-of-sample, ADH 2015; needs predictors spanning both train/val windows, so default single-period lags are rejected) / inverse-variance (1/Var on raw predictors, bypasses standardize) / custom, gap path + pre-RMSPE; no analytical SE (inference fields NaN), significance via in-space placebo permutation inference (`in_space_placebo()`, post/pre RMSPE-ratio, p = rank/(n_placebos+1)); ADH-2015 §4 robustness: `leave_one_out()` donor-robustness + `in_time_placebo()` backdating placebo; confidence sets by test inversion (Firpo-Possebom 2018 §4): `test_sharp_null()` + `confidence_set(family="constant"|"linear")` re-rank the placebo gaps into a confidence set for the effect path (the analytical `conf_int` stays NaN); conformal inference (Chernozhukov-Wüthrich-Zhu 2021): `conformal_test()` (joint sharp-null p-value), `conformal_confidence_intervals()` (pointwise per-period CIs), `conformal_average_effect()` (average-effect CI) — fit their OWN constrained-LS proxy under the null on all periods and permute residuals over time (moving-block / iid schemes; `conf_int` still NaN). Alias `SCM`.
- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html): Triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility
- [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html): Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves
- [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html): de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲=0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲>0`, continuous-near-d̲ or mass-point), with multi-period event-study extension (last-treatment cohort, pointwise CIs; the mode is panel-inferred since 3.9 - two periods -> overall WAS, more -> event-study - and post-fit `results.aggregate('simple')`/`.aggregate('event_study')` are pure views, rows M-027/M-139). **Panel-only** in this release (repeated cross-sections rejected by the validator). Alias `HAD`.
diff --git a/diff_diff/imputation.py b/diff_diff/imputation.py
index 28ac432c..2b44f7dd 100644
--- a/diff_diff/imputation.py
+++ b/diff_diff/imputation.py
@@ -1392,6 +1392,11 @@ def imputation_did(
"""
Convenience function for imputation DiD estimation.
+ .. deprecated:: 3.9
+ ``imputation_did()`` is deprecated and will be removed in 4.0
+ (row M-070). Construct the estimator instead:
+ ``ImputationDiD(...).fit(data, ...)``.
+
This is a shortcut for creating an ImputationDiD estimator and calling fit().
Parameters
@@ -1412,7 +1417,8 @@ def imputation_did(
DEPRECATED (3.9, removed in 4.0; row M-021): forwarded to ``fit()``,
which warns — aggregate post-fit via
``results.aggregate('event_study')`` instead. A plain wrapper call
- (kwarg not supplied) never warns.
+ (kwarg not supplied) never fires the aggregate warning; since 3.9
+ every wrapper call fires the M-070 wrapper-deprecation warning.
balance_e : int, optional
DEPRECATED (3.9, removed in 4.0; row M-118): forwarded to ``fit()``,
which warns — moves onto ``results.aggregate('event_study',
@@ -1447,6 +1453,12 @@ def imputation_did(
>>> results.print_summary()
>>> results.aggregate('event_study').summary() # post-fit aggregation
"""
+ warnings.warn(
+ "imputation_did() is deprecated and will be removed in 4.0; "
+ "construct the estimator instead: ImputationDiD(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
est = ImputationDiD(vcov_type=vcov_type, **kwargs)
return est.fit(
data,
diff --git a/diff_diff/power.py b/diff_diff/power.py
index efd01ab9..90655889 100644
--- a/diff_diff/power.py
+++ b/diff_diff/power.py
@@ -2079,9 +2079,9 @@ def simulate_power(
When provided, generates survey-structured data via
``generate_survey_did_data`` and injects ``SurveyDesign`` into
estimator ``fit()``. Mutually exclusive with ``data_generator``.
- Supported estimators: DiD, TWFE, MultiPeriod, CS, SA, Imputation,
- TwoStage, Stacked, Efficient. Unsupported: TROP, SyntheticDiD,
- TripleDifference. ``heterogeneous_te_by_strata`` must be False.
+ Supported estimators: DiD, TWFE, MultiPeriod, CS, SA, ImputationDiD,
+ TwoStageDiD, StackedDiD, EfficientDiD. Unsupported: TROP,
+ SyntheticDiD, TripleDifference. ``heterogeneous_te_by_strata`` must be False.
Returns
-------
diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py
index 5fefc207..c55250a3 100644
--- a/diff_diff/practitioner.py
+++ b/diff_diff/practitioner.py
@@ -36,7 +36,7 @@
"CallawaySantAnnaResults": "CallawaySantAnna",
"SunAbrahamResults": "SunAbraham",
"ImputationDiDResults": "ImputationDiD (Borusyak-Jaravel-Spiess)",
- "TwoStageDiDResults": "TwoStageDiD (Gardner)",
+ "TwoStageDiDResults": "TwoStageDiD",
"StackedDiDResults": "StackedDiD",
"SyntheticDiDResults": "SyntheticDiD",
"TROPResults": "TROP",
@@ -371,7 +371,7 @@ def _handle_multi_period(results: Any):
_honest_did_step(),
# Note: run_all_placebo_tests() requires binary time indicator,
# which MultiPeriodDiD does not use. Omit placebo for this type.
- _robustness_compare_step("CS, SA, or BJS"),
+ _robustness_compare_step("CallawaySantAnna, SunAbraham, or ImputationDiD"),
]
warnings = _check_nan_att(results)
return steps, warnings
@@ -445,7 +445,7 @@ def _handle_cs(results: Any):
code=heterogeneity_code,
step_name="heterogeneity",
),
- _robustness_compare_step("SA, BJS, or Gardner"),
+ _robustness_compare_step("SunAbraham, ImputationDiD, or TwoStageDiD"),
_covariates_step(),
]
warnings = _check_nan_att(results)
@@ -492,7 +492,7 @@ def _handle_sa(results: Any):
),
step_name="heterogeneity",
),
- _robustness_compare_step("CS, BJS, or Gardner"),
+ _robustness_compare_step("CallawaySantAnna, ImputationDiD, or TwoStageDiD"),
_covariates_step(),
]
warnings = _check_nan_att(results)
@@ -538,8 +538,8 @@ def _handle_imputation(results: Any):
why=(
"ImputationDiD does not have a control_group parameter. "
"Compare results with and without covariates, vary the "
- "sample (drop cohorts), and compare with CS/SA as "
- "falsification checks."
+ "sample (drop cohorts), and compare with CallawaySantAnna/"
+ "SunAbraham as falsification checks."
),
code=(
"# Compare with alternative estimators as robustness:\n"
@@ -551,7 +551,7 @@ def _handle_imputation(results: Any):
# variation recommendation. Tag separately.
step_name="specification_comparison",
),
- _robustness_compare_step("CS, SA, or Gardner"),
+ _robustness_compare_step("CallawaySantAnna, SunAbraham, or TwoStageDiD"),
_covariates_step(),
]
warnings = _check_nan_att(results)
@@ -597,8 +597,8 @@ def _handle_two_stage(results: Any):
why=(
"TwoStageDiD does not have a control_group parameter. "
"Compare results with and without covariates, vary the "
- "sample (drop cohorts), and compare with CS/SA as "
- "falsification checks."
+ "sample (drop cohorts), and compare with CallawaySantAnna/"
+ "SunAbraham as falsification checks."
),
code=(
"# Compare with alternative estimators as robustness:\n"
@@ -610,7 +610,7 @@ def _handle_two_stage(results: Any):
# variation recommendation. Tag separately.
step_name="specification_comparison",
),
- _robustness_compare_step("CS, BJS, or SA"),
+ _robustness_compare_step("CallawaySantAnna, ImputationDiD, or SunAbraham"),
_covariates_step(),
]
warnings = _check_nan_att(results)
@@ -659,7 +659,7 @@ def _handle_stacked(results: Any):
# diagnostic ever completes it, so the advice always survives.
step_name="sub_experiment_balance",
),
- _robustness_compare_step("CS, SA, or BJS"),
+ _robustness_compare_step("CallawaySantAnna, SunAbraham, or ImputationDiD"),
]
warnings = _check_nan_att(results)
return steps, warnings
@@ -742,7 +742,7 @@ def _handle_synthetic(results: Any):
),
_step(
baker_step=8,
- label="Compare with staggered estimators (CS, SA)",
+ label="Compare with staggered estimators (CallawaySantAnna, SunAbraham)",
why=(
"SyntheticDiD is for few treated units; compare with "
"staggered estimators if applicable. Use TROP only if "
@@ -798,7 +798,7 @@ def _handle_trop(results: Any):
# battery complete.
step_name="placebo",
),
- _robustness_compare_step("SyntheticDiD or CS"),
+ _robustness_compare_step("SyntheticDiD or CallawaySantAnna"),
]
warnings = _check_nan_att(results)
return steps, warnings
@@ -859,7 +859,7 @@ def _handle_synthetic_control(results: Any):
),
code=(
"# Exclude contaminated donors explicitly:\n"
- "# synthetic_control(..., donor_pool=[clean, comparable, units])"
+ "# SyntheticControl().fit(..., donor_pool=[clean, comparable, units])"
),
priority="medium",
step_name="estimator_selection",
@@ -900,7 +900,7 @@ def _handle_synthetic_control(results: Any):
# NOT "sensitivity" (a caller could mark that done and drop this step).
step_name="in_time_placebo",
),
- _robustness_compare_step("SyntheticDiD or CS"),
+ _robustness_compare_step("SyntheticDiD or CallawaySantAnna"),
]
warnings = _check_nan_att(results)
return steps, warnings
@@ -982,7 +982,7 @@ def _handle_efficient(results: Any):
# completes, which never runs this aggregation.
step_name="aggregation",
),
- _robustness_compare_step("CS, SA, or BJS"),
+ _robustness_compare_step("CallawaySantAnna, SunAbraham, or ImputationDiD"),
_covariates_step(),
]
warnings = _check_nan_att(results)
@@ -1155,8 +1155,9 @@ def _handle_bacon(results: Any):
why=(
"Bacon decomposition is diagnostic, not an estimator. "
"If substantial weight falls on 'later vs earlier' "
- "comparisons, TWFE is biased. Use CS, SA, BJS, or another "
- "heterogeneity-robust estimator for causal estimates."
+ "comparisons, TWFE is biased. Use CallawaySantAnna, SunAbraham, "
+ "ImputationDiD, or another heterogeneity-robust estimator "
+ "for causal estimates."
),
code=(
"from diff_diff import CallawaySantAnna\n"
diff --git a/diff_diff/stacked_did.py b/diff_diff/stacked_did.py
index 4d627478..afd897a2 100644
--- a/diff_diff/stacked_did.py
+++ b/diff_diff/stacked_did.py
@@ -1708,6 +1708,11 @@ def stacked_did(
"""
Convenience function for stacked DiD estimation.
+ .. deprecated:: 3.9
+ ``stacked_did()`` is deprecated and will be removed in 4.0
+ (row M-072). Construct the estimator instead:
+ ``StackedDiD(...).fit(data, ...)``.
+
This is a shortcut for creating a StackedDiD estimator and calling fit().
Parameters
@@ -1757,6 +1762,12 @@ def stacked_did(
>>> results.print_summary()
>>> es = results.aggregate('event_study') # post-fit view (M-024)
"""
+ warnings.warn(
+ "stacked_did() is deprecated and will be removed in 4.0; "
+ "construct the estimator instead: StackedDiD(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
est = StackedDiD(kappa_pre=kappa_pre, kappa_post=kappa_post, **kwargs)
return est.fit(
data,
diff --git a/diff_diff/synthetic_control.py b/diff_diff/synthetic_control.py
index 7bf7374a..8f856671 100644
--- a/diff_diff/synthetic_control.py
+++ b/diff_diff/synthetic_control.py
@@ -721,6 +721,11 @@ def synthetic_control(
"""
Convenience function for classic synthetic control estimation.
+ .. deprecated:: 3.9
+ ``synthetic_control()`` is deprecated and will be removed in 4.0
+ (row M-074). Construct the estimator instead:
+ ``SyntheticControl(...).fit(data, ...)``.
+
Constructor-only keyword arguments (``v_method`` — ``"nested"`` / ``"custom"`` /
``"cv"`` / ``"inverse_variance"`` — ``custom_v``, ``v_cv_t0``, ``n_starts``,
``standardize``, ``alpha``, ``seed``, ``optimizer_options``,
@@ -735,6 +740,12 @@ def synthetic_control(
... predictors=["x1", "x2"])
>>> print(f"ATT: {res.att:.3f}, pre-RMSPE: {res.pre_rmspe:.3f}")
"""
+ warnings.warn(
+ "synthetic_control() is deprecated and will be removed in 4.0; "
+ "construct the estimator instead: SyntheticControl(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
ctor_keys = set(SyntheticControl().get_params().keys())
ctor_kwargs = {k: v for k, v in kwargs.items() if k in ctor_keys}
fit_kwargs = {k: v for k, v in kwargs.items() if k not in ctor_keys}
diff --git a/diff_diff/triple_diff.py b/diff_diff/triple_diff.py
index 37404a4b..6a28b802 100644
--- a/diff_diff/triple_diff.py
+++ b/diff_diff/triple_diff.py
@@ -2139,6 +2139,11 @@ def triple_difference(
"""
Estimate Triple Difference (DDD) treatment effect.
+ .. deprecated:: 3.9
+ ``triple_difference()`` is deprecated and will be removed in 4.0
+ (row M-075). Construct the estimator instead:
+ ``TripleDifference(...).fit(data, ...)``.
+
Convenience function that creates a TripleDifference estimator and
fits it to the data in one step.
@@ -2206,6 +2211,12 @@ def triple_difference(
... )
>>> print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})")
"""
+ warnings.warn(
+ "triple_difference() is deprecated and will be removed in 4.0; "
+ "construct the estimator instead: TripleDifference(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
estimator = TripleDifference(
estimation_method=estimation_method,
robust=robust,
diff --git a/diff_diff/trop.py b/diff_diff/trop.py
index 1e80b3b1..d085baf2 100644
--- a/diff_diff/trop.py
+++ b/diff_diff/trop.py
@@ -962,6 +962,10 @@ def trop(
"""
Convenience function for TROP estimation.
+ .. deprecated:: 3.9
+ ``trop()`` is deprecated and will be removed in 4.0 (row M-073).
+ Construct the estimator instead: ``TROP(...).fit(data, ...)``.
+
Parameters
----------
data : pd.DataFrame
@@ -997,5 +1001,11 @@ def trop(
>>> results = trop(data, 'y', 'treated', 'unit', 'time')
>>> print(f"ATT: {results.att:.3f}")
"""
+ warnings.warn(
+ "trop() is deprecated and will be removed in 4.0; "
+ "construct the estimator instead: TROP(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
estimator = TROP(**kwargs)
return estimator.fit(data, outcome, treatment, unit, time, survey_design=survey_design)
diff --git a/diff_diff/twfe.py b/diff_diff/twfe.py
index 8a6eb5f0..1963bbd0 100644
--- a/diff_diff/twfe.py
+++ b/diff_diff/twfe.py
@@ -880,7 +880,7 @@ def _check_staggered_treatment(
f"effects are heterogeneous across time. Consider using:\n"
f" - CallawaySantAnna estimator for robust estimates\n"
f" - TwoWayFixedEffects.decompose() to diagnose the decomposition\n"
- f" - bacon_decompose() to see weight on 'forbidden' comparisons",
+ f" - BaconDecomposition().fit(...) to see weight on 'forbidden' comparisons",
UserWarning,
stacklevel=3,
)
@@ -972,7 +972,6 @@ def decompose(
See Also
--------
- bacon_decompose : Standalone decomposition function
BaconDecomposition : Class-based decomposition interface
CallawaySantAnna : Robust estimator that avoids forbidden comparisons
"""
diff --git a/diff_diff/two_stage.py b/diff_diff/two_stage.py
index 610ec3ad..fb92557f 100644
--- a/diff_diff/two_stage.py
+++ b/diff_diff/two_stage.py
@@ -2347,6 +2347,11 @@ def two_stage_did(
"""
Convenience function for two-stage DiD estimation.
+ .. deprecated:: 3.9
+ ``two_stage_did()`` is deprecated and will be removed in 4.0
+ (row M-071). Construct the estimator instead:
+ ``TwoStageDiD(...).fit(data, ...)``.
+
This is a shortcut for creating a TwoStageDiD estimator and calling fit().
Parameters
@@ -2367,7 +2372,8 @@ def two_stage_did(
DEPRECATED (3.9, removed in 4.0; row M-022): forwarded to ``fit()``,
which warns — aggregate post-fit via
``results.aggregate('event_study')`` instead. A plain wrapper call
- (kwarg not supplied) never warns.
+ (kwarg not supplied) never fires the aggregate warning; since 3.9
+ every wrapper call fires the M-071 wrapper-deprecation warning.
balance_e : int, optional
DEPRECATED (3.9, removed in 4.0; row M-119): forwarded to ``fit()``,
which warns — moves onto ``results.aggregate('event_study',
@@ -2400,6 +2406,12 @@ def two_stage_did(
>>> results.print_summary()
>>> results.aggregate('event_study').summary() # post-fit aggregation
"""
+ warnings.warn(
+ "two_stage_did() is deprecated and will be removed in 4.0; "
+ "construct the estimator instead: TwoStageDiD(...).fit(data, ...).",
+ FutureWarning,
+ stacklevel=2,
+ )
est = TwoStageDiD(vcov_type=vcov_type, **kwargs)
return est.fit(
data,
diff --git a/diff_diff/visualization/_diagnostic.py b/diff_diff/visualization/_diagnostic.py
index 66320ce5..95352179 100644
--- a/diff_diff/visualization/_diagnostic.py
+++ b/diff_diff/visualization/_diagnostic.py
@@ -353,7 +353,8 @@ def plot_bacon(
Parameters
----------
results : BaconDecompositionResults
- Results from BaconDecomposition.fit() or bacon_decompose().
+ Results from BaconDecomposition.fit() (or the deprecated
+ bacon_decompose() wrapper, removed in 4.0).
plot_type : str, default="scatter"
Type of plot to create:
- "scatter": Scatter plot with estimates on x-axis, weights on y-axis
@@ -397,9 +398,9 @@ def plot_bacon(
--------
Scatter plot (default):
- >>> from diff_diff import bacon_decompose, plot_bacon
- >>> results = bacon_decompose(data, outcome='y', unit='id',
- ... time='t', first_treat='first_treat')
+ >>> from diff_diff import BaconDecomposition, plot_bacon
+ >>> results = BaconDecomposition().fit(data, outcome='y', unit='id',
+ ... time='t', first_treat='first_treat')
>>> plot_bacon(results)
Bar chart of weights by type:
@@ -422,8 +423,7 @@ def plot_bacon(
See Also
--------
- bacon_decompose : Perform the decomposition
- BaconDecomposition : Class-based interface
+ BaconDecomposition : Perform the decomposition
"""
# Default colors
if colors is None:
diff --git a/docs/api/_autosummary/diff_diff.AggregationResult.rst b/docs/api/_autosummary/diff_diff.AggregationResult.rst
index 5cf24414..eea2db70 100644
--- a/docs/api/_autosummary/diff_diff.AggregationResult.rst
+++ b/docs/api/_autosummary/diff_diff.AggregationResult.rst
@@ -38,3 +38,4 @@
~AggregationResult.conf_int_upper
~AggregationResult.n
~AggregationResult.df
+
diff --git a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst
index 9c89a435..265e7bfb 100644
--- a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst
+++ b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst
@@ -36,6 +36,7 @@
~ChaisemartinDHaultfoeuilleResults.design2_effects
~ChaisemartinDHaultfoeuilleResults.event_study_df
~ChaisemartinDHaultfoeuilleResults.event_study_effects
+ ~ChaisemartinDHaultfoeuilleResults.groups
~ChaisemartinDHaultfoeuilleResults.heterogeneity_effects
~ChaisemartinDHaultfoeuilleResults.honest_did_results
~ChaisemartinDHaultfoeuilleResults.is_significant
diff --git a/docs/api/_autosummary/diff_diff.ContinuousDiD.rst b/docs/api/_autosummary/diff_diff.ContinuousDiD.rst
index 14764cb1..b4d01c5e 100644
--- a/docs/api/_autosummary/diff_diff.ContinuousDiD.rst
+++ b/docs/api/_autosummary/diff_diff.ContinuousDiD.rst
@@ -19,3 +19,9 @@
+ .. rubric:: Attributes
+
+ .. autosummary::
+
+ ~ContinuousDiD.alpha
+
diff --git a/docs/api/_autosummary/diff_diff.EventStudyResults.rst b/docs/api/_autosummary/diff_diff.EventStudyResults.rst
index 0e64dfcf..e0636f51 100644
--- a/docs/api/_autosummary/diff_diff.EventStudyResults.rst
+++ b/docs/api/_autosummary/diff_diff.EventStudyResults.rst
@@ -31,6 +31,7 @@
~EventStudyResults.cband_upper
~EventStudyResults.df
~EventStudyResults.df_survey
+ ~EventStudyResults.estimand
~EventStudyResults.event_time_convention
~EventStudyResults.n_kind
~EventStudyResults.reference_event_times
diff --git a/docs/api/_autosummary/diff_diff.ImputationDiD.rst b/docs/api/_autosummary/diff_diff.ImputationDiD.rst
index 6794ab8f..504845e0 100644
--- a/docs/api/_autosummary/diff_diff.ImputationDiD.rst
+++ b/docs/api/_autosummary/diff_diff.ImputationDiD.rst
@@ -31,4 +31,9 @@
~ImputationDiD.seed
~ImputationDiD.anticipation
~ImputationDiD.horizon_max
+ ~ImputationDiD.pretrends
+ ~ImputationDiD.aux_partition
+ ~ImputationDiD.leave_one_out
+ ~ImputationDiD.rank_deficient_action
+ ~ImputationDiD.df_convention
diff --git a/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst b/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst
index 66dfdf39..d0414a3d 100644
--- a/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst
+++ b/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst
@@ -45,6 +45,7 @@
~RegressionDiscontinuityResults.first_stage_t_stat
~RegressionDiscontinuityResults.first_stage_t_stat_bias_corrected
~RegressionDiscontinuityResults.first_stage_t_stat_conventional
+ ~RegressionDiscontinuityResults.treatment_col
~RegressionDiscontinuityResults.att
~RegressionDiscontinuityResults.se
~RegressionDiscontinuityResults.t_stat
diff --git a/docs/api/_autosummary/diff_diff.StackedDiD.rst b/docs/api/_autosummary/diff_diff.StackedDiD.rst
index 681a8bb3..a0ac5abb 100644
--- a/docs/api/_autosummary/diff_diff.StackedDiD.rst
+++ b/docs/api/_autosummary/diff_diff.StackedDiD.rst
@@ -21,3 +21,9 @@
+ .. rubric:: Attributes
+
+ .. autosummary::
+
+ ~StackedDiD.clean_control
+
diff --git a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst
index 618b7775..1ae35dd1 100644
--- a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst
+++ b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst
@@ -29,8 +29,9 @@
~StackedDiDResults.anticipation
~StackedDiDResults.att
~StackedDiDResults.balance
- ~StackedDiDResults.base_period
~StackedDiDResults.balance_diagnostics
+ ~StackedDiDResults.base_period
+ ~StackedDiDResults.clean_control
~StackedDiDResults.cluster_name
~StackedDiDResults.coef_var
~StackedDiDResults.conf_int
diff --git a/docs/api/_autosummary/diff_diff.TwoStageDiD.rst b/docs/api/_autosummary/diff_diff.TwoStageDiD.rst
index a89f28fd..116ab473 100644
--- a/docs/api/_autosummary/diff_diff.TwoStageDiD.rst
+++ b/docs/api/_autosummary/diff_diff.TwoStageDiD.rst
@@ -31,4 +31,5 @@
~TwoStageDiD.seed
~TwoStageDiD.horizon_max
~TwoStageDiD.pretrends
+ ~TwoStageDiD.rank_deficient_action
diff --git a/docs/api/business_report.rst b/docs/api/business_report.rst
index 7e9c9022..ae17bffa 100644
--- a/docs/api/business_report.rst
+++ b/docs/api/business_report.rst
@@ -35,7 +35,7 @@ rest of the report still renders.
For survey-weighted fits (any result carrying
``survey_metadata``) pass the original ``SurveyDesign`` via
``survey_design=``. It is threaded through to
-``bacon_decompose`` for a fit-faithful Goodman-Bacon replay. When
+``BaconDecomposition.fit`` for a fit-faithful Goodman-Bacon replay. When
``survey_metadata`` is set but ``survey_design`` is not supplied,
Bacon is skipped with an explicit reason so the report never emits
an unweighted decomposition for a design that differs from the
diff --git a/docs/api/diagnostic_report.rst b/docs/api/diagnostic_report.rst
index 4dc1c8b7..fb691dc9 100644
--- a/docs/api/diagnostic_report.rst
+++ b/docs/api/diagnostic_report.rst
@@ -32,7 +32,7 @@ while the rest of the battery still runs.
For survey-weighted fits (any result carrying
``survey_metadata``) pass the original ``SurveyDesign`` via
``survey_design=``. It is threaded through to
-``bacon_decompose`` for a fit-faithful Goodman-Bacon replay. When
+``BaconDecomposition.fit`` for a fit-faithful Goodman-Bacon replay. When
``survey_metadata`` is set but ``survey_design`` is not supplied,
Bacon is skipped with an explicit reason so the report never emits
an unweighted decomposition for a design that differs from the
diff --git a/docs/api/estimators.rst b/docs/api/estimators.rst
index 5ba2e632..b90cda0c 100644
--- a/docs/api/estimators.rst
+++ b/docs/api/estimators.rst
@@ -21,7 +21,11 @@ Most estimators have short aliases (``TROP`` already uses its short canonical na
.. code-block:: python
- from diff_diff import DiD, TWFE, EventStudy, SDiD, CS, CDiD, SA, BJS, Gardner, DDD, Stacked, Bacon
+ from diff_diff import DiD, TWFE, EventStudy, SDiD, CS, SA, BJS, DDD, SCM, Bacon
+
+``CDiD``, ``Gardner`` and ``Stacked`` are deprecated since 3.9 (they emit a
+``FutureWarning`` and are removed in 4.0) — use ``ContinuousDiD``,
+``TwoStageDiD`` and ``StackedDiD``.
.. module:: diff_diff.estimators
diff --git a/docs/api/synthetic_control.rst b/docs/api/synthetic_control.rst
index 420b3fa0..4d17f14a 100644
--- a/docs/api/synthetic_control.rst
+++ b/docs/api/synthetic_control.rst
@@ -229,13 +229,13 @@ Basic usage with covariate and special predictors::
gap_df = results.get_gap_df() # period, gap, phase
weights_df = results.get_weights_df() # unit, weight (descending)
-Quick estimation with the convenience function::
+Quick one-call estimation (the ``synthetic_control()`` wrapper is
+deprecated since 3.9 and removed in 4.0)::
- from diff_diff import synthetic_control
+ from diff_diff import SyntheticControl
- results = synthetic_control(
- data, outcome="gdpcap", treatment="treated",
- unit="region", time="year",
+ results = SyntheticControl().fit(
+ data, "gdpcap", "treated", "region", "year",
)
print(f"ATT: {results.att:.3f}, pre-RMSPE: {results.pre_rmspe:.3f}")
diff --git a/docs/api/triple_diff.rst b/docs/api/triple_diff.rst
index 9618941a..46e19731 100644
--- a/docs/api/triple_diff.rst
+++ b/docs/api/triple_diff.rst
@@ -119,15 +119,15 @@ With covariates::
covariates=['age', 'education', 'experience']
)
-Using the convenience function::
+Quick one-call estimation (the ``triple_difference()`` wrapper is
+deprecated since 3.9 and removed in 4.0)::
- from diff_diff import triple_difference
+ from diff_diff import TripleDifference
- results = triple_difference(
+ results = TripleDifference(estimation_method='dr').fit(
data,
outcome='wages',
group='policy_state',
partition='female',
- time='post',
- estimation_method='dr'
+ post='post',
)
diff --git a/docs/api/trop.rst b/docs/api/trop.rst
index 0d66e37e..1015fc7b 100644
--- a/docs/api/trop.rst
+++ b/docs/api/trop.rst
@@ -228,17 +228,17 @@ Basic usage::
)
results.print_summary()
-Quick estimation with convenience function::
+Quick one-call estimation (the ``trop()`` wrapper is deprecated since
+3.9 and removed in 4.0)::
- from diff_diff import trop
+ from diff_diff import TROP
- results = trop(
+ results = TROP(n_bootstrap=200).fit(
data,
- outcome='y',
- treatment='treated',
- unit='unit_id',
- time='period',
- n_bootstrap=200
+ 'y',
+ 'treated',
+ 'unit_id',
+ 'period',
)
Using the global method for faster estimation::
diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml
index 67ce44ba..5a860fde 100644
--- a/docs/doc-deps.yaml
+++ b/docs/doc-deps.yaml
@@ -397,7 +397,7 @@ sources:
type: methodology
- path: docs/methodology/continuous-did.md
type: methodology
- note: "Detailed CDiD implementation docs"
+ note: "Detailed ContinuousDiD implementation docs"
- path: docs/api/continuous_did.rst
type: api_reference
- path: docs/tutorials/14_continuous_did.ipynb
diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md
index 080e9e92..8025f06d 100644
--- a/docs/methodology/REGISTRY.md
+++ b/docs/methodology/REGISTRY.md
@@ -1869,7 +1869,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=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `imputation_did` wrapper forwards the shared sentinel so plain wrapper calls never warn) 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, while the RECOMPUTE levels fail closed (the per-target psi machinery makes exact replay tractable - a 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 scalar `df_survey` channel is its only df provenance, the per-row hole being the tracked M-092-completion TODO row). (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) 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 the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); '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.
+- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `imputation_did` wrapper forwards the shared sentinel so plain wrapper calls never fire the aggregate warning; since 3.9 the wrapper itself warns per M-070) 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, while the RECOMPUTE levels fail closed (the per-target psi machinery makes exact replay tractable - a 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 scalar `df_survey` channel is its only df provenance, the per-row hole being the tracked M-092-completion TODO row). (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) 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 the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); '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.
---
@@ -1958,7 +1958,7 @@ Our implementation uses multiplier bootstrap on the GMM influence function: clus
- [x] Multiplier bootstrap on GMM influence function
- [x] Event study and overall ATT aggregation
-- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `two_stage_did` wrapper forwards the shared sentinel so plain wrapper calls never warn) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform rule superseded 2026-08-05 with the M-027 per-level convergence; a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row is appended mechanically - not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints; admission awaits a normalization derivation (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off.
+- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `two_stage_did` wrapper forwards the shared sentinel so plain wrapper calls never fire the aggregate warning; since 3.9 the wrapper itself warns per M-071) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform rule superseded 2026-08-05 with the M-027 per-level convergence; a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row is appended mechanically - not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints; admission awaits a normalization derivation (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off.
---
@@ -2846,7 +2846,7 @@ Classic synthetic control (donor/unit weights only) for a single treated unit, d
- **Note (in-time placebo windowing — TRUNCATE):** ADH 2015 §4 says to re-estimate the in-time placebo "with the same predictors lagged accordingly." Because `diff_diff`'s predictor specs reference **absolute** periods, the in-time placebo re-cuts them by TRUNCATION: pre-period-outcome predictors become the pre-`t_f` outcomes, and covariate / special-predictor windows are intersected with the pre-`t_f` window; a window lying ENTIRELY in the held-out region `[t_f, T0)` is **dropped** (surfaced in the `n_dropped_specs` column + an aggregated warning), and `custom_v` is subset in lockstep with the surviving specs. For an outcome-predictor fit (the R-anchorable case) TRUNCATE is identical to ADH's "lag" — both equal a manual `Synth::synth` re-run with `time.optimize.ssr` cut at `t_f`. The held-out window never enters the fit (the placebo's `all_periods` is the pre-fake + post-fake span; the true post-treatment periods are excluded entirely), so there is no "peeking." This concrete convention is NOT spelled out in ADH 2015 (which gives only the qualitative "lag accordingly").
- **Note (in-time placebo requires ≥2 pre-fake periods):** the in-time placebo treats a date with fewer than 2 pre-fake periods as `status="infeasible"` (the default sweep starts at the 3rd pre-period). This is DELIBERATELY stricter than the base estimator's `T0 ≥ 1` allowance (which permits a single-pre-period fit but warns that nested-`V` selection is unreliable): an auto-swept placebo date with a single pre-fake period is a trivially-matchable, non-credible pre-fit, so it is dropped rather than surfaced as a `ran` placebo (mirrors `SyntheticDiD.in_time_placebo`'s `i ≥ 2` rule). A date whose surviving `custom_v` has zero mass after truncation is likewise infeasible (not a convergence failure).
- **Note (leave-one-out weight floor):** ADH 2015 §4 leave-one-out omits "each donor that received positive weight." This implementation drops each donor with **reportable** weight — above the `1e-6` interpretability floor (`synthetic_control._MIN_REPORT_WEIGHT`), i.e. exactly the donors in `donor_weights` — rather than every strictly-positive weight. A donor with `0 < w ≤ 1e-6` is numerical dust whose removal moves the ATT by ~its weight (its `delta_att` would be ~0, an uninformative row), and the floor keeps the LOO table aligned with the reported donor support. The drop-set is **frozen at fit time** on the fit snapshot (`weighted_donor_ids`), so `leave_one_out()` is immune to post-fit mutation of the presentation-level `donor_weights` dict.
-- **Note (ADH-2015 diagnostics validation):** R `Synth` has **no** in-time-placebo or leave-one-out function (verified against its full CRAN function index; `SCtools` adds only the *in-space* placebo battery, `scpi` only prediction-interval uncertainty), so there is no canonical R *output* to match for these diagnostics — in R they are hand-rolled by re-running `dataprep()`+`synth()`. They are validated instead by (a) the solver's existing Basque R parity (above), and (b) deterministic **self-consistency** tests proving each diagnostic equals a from-scratch `synthetic_control()` fit on the equivalent sub-problem — `leave_one_out()` drop-`d` == a fit on the donor pool minus `d`; `in_time_placebo([t_f])` == a fit on the backdated/truncated panel — both via a fixed `custom_v` (match to 1e-7). The two §4-tail diagnostics are likewise R-anchor-free (R `Synth` has neither): `regression_weights()` is validated by a **numpy oracle** re-implementing `W^reg = X0a'(X0a X0a')^{-1}X1a` on hand-built matrices (incl. the full-rank sum-to-one property, the rank-deficient min-norm branch, and row-scaling invariance across `v_method` spaces); `sparse_synthetic_control()` by **self-consistency** — its exhaustive size-`l` winner (and the winner's weights) match an independent brute-force enumeration using the SAME fixed baseline `V`, which also confirms `V` is held fixed rather than re-searched.
+- **Note (ADH-2015 diagnostics validation):** R `Synth` has **no** in-time-placebo or leave-one-out function (verified against its full CRAN function index; `SCtools` adds only the *in-space* placebo battery, `scpi` only prediction-interval uncertainty), so there is no canonical R *output* to match for these diagnostics — in R they are hand-rolled by re-running `dataprep()`+`synth()`. They are validated instead by (a) the solver's existing Basque R parity (above), and (b) deterministic **self-consistency** tests proving each diagnostic equals a from-scratch `SyntheticControl` fit on the equivalent sub-problem — `leave_one_out()` drop-`d` == a fit on the donor pool minus `d`; `in_time_placebo([t_f])` == a fit on the backdated/truncated panel — both via a fixed `custom_v` (match to 1e-7). The two §4-tail diagnostics are likewise R-anchor-free (R `Synth` has neither): `regression_weights()` is validated by a **numpy oracle** re-implementing `W^reg = X0a'(X0a X0a')^{-1}X1a` on hand-built matrices (incl. the full-rank sum-to-one property, the rank-deficient min-norm branch, and row-scaling invariance across `v_method` spaces); `sparse_synthetic_control()` by **self-consistency** — its exhaustive size-`l` winner (and the winner's weights) match an independent brute-force enumeration using the SAME fixed baseline `V`, which also confirms `V` is held fixed rather than re-searched.
- **Note (conformal proxy ≠ ADH V-matrix — deliberate):** the CWZ conformal layer fits its OWN counterfactual proxy — the canonical constrained-LS synthetic control on **raw outcomes over all periods** (eqs 3–4, no V-matrix) — NOT the headline ADH V-matrix weights (which match on pre-period predictors only). This is required, not incidental: CWZ's exactness theory (Lemma 1; Appendix D exchangeability under the null) holds for a **time-permutation-invariant** estimator, which the ADH pre-period V-fit is not (it treats pre and post asymmetrically). So the conformal counterfactual / `point_estimate` can differ from the headline `att`, and is reported as a separate object.
- **Note (conformal permutation floor — `1/|Π|`, distinct from Firpo's `1/(J+1)`):** the conformal p-value is `(1/|Π|)·#{π : S(û_π) ≥ S(û)}` (eq 2). The permutation set `Π` **includes the identity** (`S(û_id)=S(û)`, counted under `≥`), so `p̂ ≥ 1/|Π|` automatically — there is **no extra `+1`** (unlike the cross-unit placebo / Firpo `(1+n)/(n+1)`, where the treated unit is not a member of the placebo reference set). `|Π| = T` (moving-block, joint), `T0+1` (pointwise sub-series), or `T/T*` (average-effect blocks), capped at `n_iid` for the i.i.d. scheme.
- **Note (conformal per-period CI drops the other post-periods — paper-sourced):** for a pointwise CI of period `t`, CWZ §2.2 defines the data under the null as `Z = (Z_1,…,Z_{T0}, Z_t)` — the pre-periods plus ONLY period `t`. The other post-periods are dropped (not plugged in, not zeroed), making each per-period CI a clean `T*=1` conformal test on the `(T0+1)`-length sub-series. (Confirmed against arXiv:1712.09089v10 §2.2 + Algorithm 1.) The grid is centred on a **pre-only** proxy fit (predict the post slot from the pre slots) — the unconditional all-slot fit would soak the effect into the weights and bias the naïve residual toward 0, so it is not used as the centre.
@@ -5024,7 +5024,7 @@ where `D_it` is the treatment indicator (1 if unit `i` is treated by time `t`) a
Replaces the aggregate spec with the per-event-time × ring decomposition from Butts Section 5 / Table 2. Direct effects `tau_k` and per-(ring, event-time) spillover effects `delta_jk` are emitted in `att_dynamic` and a MultiIndex `spillover_effects`. A TwoStageDiD-compatible `event_study_effects: Dict[int, Dict]` alias (mirroring `two_stage_aggregation.py::_stage2_event_study` row schema with `conf_int = (low, high)` tuple) is also emitted for `plot_event_study` consumption — `_extract_plot_data` prefers the new `reference_period` attribute over the legacy `n_obs==0` heuristic. `DiagnosticReport` routing is now wired (see the DiagnosticReport applicability Note below).
-- **Note (DiagnosticReport applicability):** `SpilloverDiDResults` is registered in `DiagnosticReport`'s `_APPLICABILITY` / `_PT_METHOD` tables with applicable checks {`parallel_trends` (method `event_study`, on the per-event-time DIRECT-effect dynamics — populated when `event_study=True`; Bonferroni fallback across pre-period coefs since there is no `event_study_vcov`), `design_effect` (instance-gated on `survey_metadata`), `heterogeneity` (reads `event_study_effects`)}. This mirrors the `TwoStageDiD` set **minus `bacon`**. `bacon` is **deliberately excluded**: SpilloverDiD identifies the direct effect off FAR-AWAY control observations (`d_it > d_bar`, Assumption 5), not off the TWFE 2×2 comparisons a Goodman-Bacon decomposition enumerates; running `bacon_decompose` on the raw binary treatment would ignore the ring/distance structure and pool spillover-contaminated in-ring units into the control group — the exact SUTVA violation this estimator handles (same rationale that excludes `bacon` for SyntheticControl / TROP / ContinuousDiD). `pretrends_power` / `sensitivity` / `epv` / `estimator_native` are not applicable (no `compute_pretrends_power` adapter, no HonestDiD adapter, no `epv_diagnostics`, not an SDiD/TROP/SCM native path).
+- **Note (DiagnosticReport applicability):** `SpilloverDiDResults` is registered in `DiagnosticReport`'s `_APPLICABILITY` / `_PT_METHOD` tables with applicable checks {`parallel_trends` (method `event_study`, on the per-event-time DIRECT-effect dynamics — populated when `event_study=True`; Bonferroni fallback across pre-period coefs since there is no `event_study_vcov`), `design_effect` (instance-gated on `survey_metadata`), `heterogeneity` (reads `event_study_effects`)}. This mirrors the `TwoStageDiD` set **minus `bacon`**. `bacon` is **deliberately excluded**: SpilloverDiD identifies the direct effect off FAR-AWAY control observations (`d_it > d_bar`, Assumption 5), not off the TWFE 2×2 comparisons a Goodman-Bacon decomposition enumerates; running the Bacon decomposition on the raw binary treatment would ignore the ring/distance structure and pool spillover-contaminated in-ring units into the control group — the exact SUTVA violation this estimator handles (same rationale that excludes `bacon` for SyntheticControl / TROP / ContinuousDiD). `pretrends_power` / `sensitivity` / `epv` / `estimator_native` are not applicable (no `compute_pretrends_power` adapter, no HonestDiD adapter, no `epv_diagnostics`, not an SDiD/TROP/SCM native path).
**Note (two-clock K_it):** Butts Section 5 uses one symbol `K_it`, but operationally there are TWO event-time clocks. The **direct-effect clock** is `K_direct_{it} = t - effective_first_treat(i)` for ever-treated unit rows (NaN for never-treated). The **spillover-exposure clock** is `K_spill_{it} = t - min{ effective_first_treat(j) : d(i, j) ≤ d_bar AND effective_first_treat(j) ≤ t }` — time since `i` first became exposed to ANY treated neighbor (running min across activated cohorts). `K_spill` is structurally non-negative (pre-trigger rows are NaN and contribute to stage 1 only); spillover placebos at `k < 0` are therefore NOT meaningful and rectangular emission of those cells uses NaN coef + `n_obs = 0`. The trigger cohort for unit `i` is the earliest activated cohort whose treated members fall within `d_bar` of `i` — NOT necessarily the geographically nearest cohort.
diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md
index 3be109ed..8e5795b9 100644
--- a/docs/methodology/REPORTING.md
+++ b/docs/methodology/REPORTING.md
@@ -19,12 +19,12 @@ circular imports across the 16 result classes. They do no estimator
fitting and do not re-derive any variance from raw data; every effect,
SE, p-value, CI, and sensitivity bound is either read from the fitted
result or produced by an existing diff-diff utility
-(`compute_honest_did`, `HonestDiD.sensitivity`, `bacon_decompose`,
+(`compute_honest_did`, `HonestDiD.sensitivity`, `BaconDecomposition`,
`check_parallel_trends`, `compute_pretrends_power`). When the caller
passes the raw panel + column kwargs, `DiagnosticReport` may call
those utilities on the supplied data (2x2 PT via
`check_parallel_trends`, Goodman-Bacon decomposition via
-`bacon_decompose`, and the EfficientDiD Hausman PT-All vs PT-Post
+`BaconDecomposition`, and the EfficientDiD Hausman PT-All vs PT-Post
pretest via `EfficientDiD.hausman_pretest`).
The `design_effect` section of `DiagnosticReport.to_dict()` is a
@@ -225,7 +225,7 @@ a library setting.
`DiagnosticReport(survey_design=...)` and
`BusinessReport(survey_design=...)` accept the original
`SurveyDesign` object and forward it to
- `bacon_decompose(survey_design=...)` so the Goodman-Bacon
+ `BaconDecomposition.fit(survey_design=...)` so the Goodman-Bacon
decomposition is computed under the same design as the weighted
estimate. When `survey_metadata` is set but `survey_design` is not
supplied, Bacon skips with an explicit reason rather than replaying
diff --git a/docs/performance-plan.md b/docs/performance-plan.md
index c09e7e65..9cbf202b 100644
--- a/docs/performance-plan.md
+++ b/docs/performance-plan.md
@@ -732,7 +732,7 @@ any rerun):
| 2 | `diff_diff/imputation.py` ImputationDiD fit (+ `diff_diff/sun_abraham.py` SunAbraham fit) | Staggered CS @ 1,500 units | together consistently ~70-80% of the chain at every scale; either can be the top phase at a given (scale, backend) cell | **Investigate only after BRFSS fix lands.** Total chain is well under practitioner-perceptible threshold; candidate follow-up. Either phase is a legitimate target. |
| 3 | `diff_diff/utils.py:1434` `_sc_weight_fw_numpy` | SDiD python @ any scale | dominates Python SDiD at all scales | **Already ported to Rust.** Python fallback acceptable as a teaching/safety path; non-production for n > 100. Python skipped at n=500 (jackknife cost would exceed 4 minutes per run). |
| 4 | `diff_diff/chaisemartin_dhaultfoeuille.py` dCDH fit + heterogeneity | Reversible (single scale) | main fit and survey-aware heterogeneity refit each rebuild TSL scaffolding; heterogeneity phase is as expensive as the main fit | **Cache/precompute** - heterogeneity refit duplicates the main fit's TSL setup under the same `SurveyDesign`. Not P0; newer code path (v3.1) never optimization-reviewed. |
-| 5 | `diff_diff/continuous_did.py` CDiD spline bootstrap | Dose-response (single scale) | four spline fits ~equal, linear in variant count | **Leave alone** - well under perceptible threshold. |
+| 5 | `diff_diff/continuous_did.py` ContinuousDiD spline bootstrap | Dose-response (single scale) | four spline fits ~equal, linear in variant count | **Leave alone** - well under perceptible threshold. |
### Memory analysis
diff --git a/docs/performance-scenarios.md b/docs/performance-scenarios.md
index 69f7e444..3189675e 100644
--- a/docs/performance-scenarios.md
+++ b/docs/performance-scenarios.md
@@ -303,12 +303,12 @@ serves a different purpose: R-parity accuracy). They complement it.
dose="dose",
)
```
-- **Operation chain.** (1) CDiD fit - produces
+- **Operation chain.** (1) ContinuousDiD fit - produces
overall ATT, overall ACRT, and the dose-response curves
unconditionally (the fit-time `aggregate=` kwarg is deprecated, row
M-025); (2) extract
`results.to_dataframe(level="dose_response")` and
- `level="group_time"`; (3) a second analytical CDiD fit followed by
+ `level="group_time"`; (3) a second analytical ContinuousDiD fit followed by
post-fit `results.aggregate("event_study")` for pre-trend
diagnostics (the unified underscored spelling - the fit-time
no-underscore `"eventstudy"` value dies with the deprecated kwarg in
diff --git a/docs/tutorials/02_staggered_did.ipynb b/docs/tutorials/02_staggered_did.ipynb
index 7421fa98..0f43633a 100644
--- a/docs/tutorials/02_staggered_did.ipynb
+++ b/docs/tutorials/02_staggered_did.ipynb
@@ -161,10 +161,10 @@
"metadata": {},
"outputs": [],
"source": [
- "from diff_diff import bacon_decompose, plot_bacon\n",
+ "from diff_diff import BaconDecomposition, plot_bacon\n",
"\n",
"# Perform the Goodman-Bacon decomposition\n",
- "bacon_results = bacon_decompose(\n",
+ "bacon_results = BaconDecomposition().fit(\n",
" df,\n",
" outcome='outcome',\n",
" unit='unit',\n",
diff --git a/docs/tutorials/08_triple_diff.ipynb b/docs/tutorials/08_triple_diff.ipynb
index 841cee33..73dec636 100644
--- a/docs/tutorials/08_triple_diff.ipynb
+++ b/docs/tutorials/08_triple_diff.ipynb
@@ -38,7 +38,7 @@
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
- "from diff_diff import TripleDifference, triple_difference"
+ "from diff_diff import TripleDifference"
]
},
{
@@ -229,9 +229,9 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Convenience Function\n",
+ "## Quick One-Call Estimation\n",
"\n",
- "For quick estimation, you can use the `triple_difference()` convenience function:"
+ "Constructor and `fit()` chain into a single expression (the old `triple_difference()` convenience wrapper is deprecated since 3.9 and removed in 4.0):"
]
},
{
@@ -240,15 +240,14 @@
"metadata": {},
"outputs": [],
"source": [
- "# One-liner estimation\n",
- "quick_results = triple_difference(\n",
+ "# One-liner estimation via the class API\n",
+ "quick_results = TripleDifference(estimation_method='dr').fit(\n",
" data,\n",
" outcome='outcome',\n",
" group='group',\n",
" partition='partition',\n",
- " time='time',\n",
+ " post='time',\n",
" covariates=['age', 'education'],\n",
- " estimation_method='dr'\n",
")\n",
"\n",
"print(f\"ATT: {quick_results.att:.4f} (95% CI: [{quick_results.conf_int[0]:.4f}, {quick_results.conf_int[1]:.4f}])\")"
diff --git a/docs/tutorials/09_real_world_examples.ipynb b/docs/tutorials/09_real_world_examples.ipynb
index 3ac5e878..7790000b 100644
--- a/docs/tutorials/09_real_world_examples.ipynb
+++ b/docs/tutorials/09_real_world_examples.ipynb
@@ -21,7 +21,7 @@
" TwoWayFixedEffects,\n",
" CallawaySantAnna,\n",
" SunAbraham,\n",
- " bacon_decompose,\n",
+ " BaconDecomposition,\n",
")\n",
"from diff_diff.datasets import (\n",
" load_card_krueger,\n",
@@ -363,7 +363,7 @@
"outputs": [],
"source": [
"# Goodman-Bacon decomposition reveals the problem\n",
- "bacon_results = bacon_decompose(\n",
+ "bacon_results = BaconDecomposition().fit(\n",
" castle,\n",
" outcome='homicide_rate',\n",
" unit='state',\n",
diff --git a/docs/tutorials/10_trop.ipynb b/docs/tutorials/10_trop.ipynb
index d7e62f17..547c4f7d 100644
--- a/docs/tutorials/10_trop.ipynb
+++ b/docs/tutorials/10_trop.ipynb
@@ -13,7 +13,7 @@
"source": [
"import numpy as np\n",
"import pandas as pd\n",
- "from diff_diff import TROP, trop, SyntheticDiD\n",
+ "from diff_diff import TROP, SyntheticDiD\n",
"\n",
"# For nicer plots (optional)\n",
"try:\n",
@@ -552,9 +552,9 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Using the Convenience Function\n",
+ "## Quick One-Call Estimation\n",
"\n",
- "For quick estimation, you can use the `trop()` convenience function."
+ "Constructor and `fit()` chain into a single expression (the old `trop()` convenience wrapper is deprecated since 3.9 and removed in 4.0)."
]
},
{
@@ -563,16 +563,17 @@
"metadata": {},
"outputs": [],
"source": [
- "# One-liner estimation with default tuning grid\n",
+ "# One-liner estimation with default tuning grid via the class API\n",
"# Note: TROP infers treatment periods from the treatment indicator\n",
- "quick_results = trop(\n",
- " df,\n",
- " outcome='outcome',\n",
- " treatment='treated',\n",
- " unit='unit',\n",
- " time='period',\n",
+ "quick_results = TROP(\n",
" n_bootstrap=20, # Reduced for faster execution\n",
- " seed=42\n",
+ " seed=42,\n",
+ ").fit(\n",
+ " df,\n",
+ " 'outcome',\n",
+ " 'treated',\n",
+ " 'unit',\n",
+ " 'period',\n",
")\n",
"\n",
"print(f\"Quick estimation:\")\n",
diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml
index fa9311d5..3a678c6f 100644
--- a/docs/v4-deprecations.yaml
+++ b/docs/v4-deprecations.yaml
@@ -240,7 +240,7 @@ rows:
warning: FutureWarning
test_ref: tests/test_aggregate_contract.py
code_refs: [diff_diff/imputation.py, diff_diff/imputation_aggregation.py, diff_diff/imputation_results.py, diff_diff/imputation_bootstrap.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt]
- notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-118], warn-and-still-work; the imputation_did wrapper forwards the sentinel so a plain wrapper call never warns). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary - a behavior improvement). The successor is a PANEL-BACKED lazy recompute kit: ES/group aggregation is a target-specific Theorem-3 recompute from the working panel + untreated FE model (no compact influence payload can honor a different balance_e), so the kit's bookkeeping holds REFERENCES to the SAME per-fit objects self._fit_data already retains for pretrend_test() - ZERO marginal memory and unchanged pickles via memoization (the _estimator_ref field already ships the panel; enumeration in the REGISTRY ImputationDiD Note). Value snapshots (treatment_groups copy, config scalars, a dataclasses.replace copy of survey_metadata, and the survey_df_seed/survey_df_final df channels) isolate recompute and the ES carrier from public-field mutation; aggregate() reads NOTHING mutable off the results object except the deliberate overall-quintet relay and the bootstrap_results gate. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' RELAYS the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (the per-target psi machinery makes replay tractable - a TODO row). The prior fail-closed-for-ALL-levels uniform-parity decision was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale - never publish analytical provenance beside percentile inference - is honored by the NaN df column. Replicate-weight fits REPLAY the extracted _replicate_override_aggregates with a LEVEL-MATCHED stack ([overall, ES] or [overall, groups]): compute_replicate_refit_variance validates replicates jointly, so aggregate(L) reproduces fit(aggregate=L) exactly and a fit(aggregate='all') surface is NOT the equivalence target when a replicate NaNs on one family's targets; the same joint-stack coupling means migrating a replicate fit from fit(aggregate=) to plain fit changes the OVERALL row's se/CI/df on degenerate designs (documented migration delta, CHANGELOG + REGISTRY note (e) + a contract-test pin). pretrends=True + replicate: post-fit aggregate('event_study') raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); group/simple still work. Simple relay: n = n_treated_obs with n_kind='obs' (the treated/control unit sets OVERLAP, so the CS/EDiD disjoint-units convention cannot apply - the StackedDiD carve-out class); df = the survey_df_final snapshot (what the stored overall inference received). Group rows record per-row df_used at each safe_inference call (additive row-dict key; the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via .get). The M-127 df_convention inert-config warning predicate is REVISED to reachability (pretrends AND not-replicate AND (deprecated fit-time ES/all supplied OR n_bootstrap <= 0)) because post-fit aggregate() made the old aggregate-keyed claim false; reachability-BASED, not exact - a fit whose bootstrap later fails (bootstrap_results=None) can still aggregate post-fit, so that corner warns spuriously (recorded on M-127 too). Container admission NOT widened: ImputationDiD is rejected BY DESIGN (no joint ES covariance - per-horizon conservative SEs only; see M-093). balance_e moves as its own row [M-118]."
+ notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-118], warn-and-still-work; the imputation_did wrapper forwards the sentinel so a plain wrapper call never fires the aggregate warning; since 3.9 the wrapper itself warns per [M-070]). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary - a behavior improvement). The successor is a PANEL-BACKED lazy recompute kit: ES/group aggregation is a target-specific Theorem-3 recompute from the working panel + untreated FE model (no compact influence payload can honor a different balance_e), so the kit's bookkeeping holds REFERENCES to the SAME per-fit objects self._fit_data already retains for pretrend_test() - ZERO marginal memory and unchanged pickles via memoization (the _estimator_ref field already ships the panel; enumeration in the REGISTRY ImputationDiD Note). Value snapshots (treatment_groups copy, config scalars, a dataclasses.replace copy of survey_metadata, and the survey_df_seed/survey_df_final df channels) isolate recompute and the ES carrier from public-field mutation; aggregate() reads NOTHING mutable off the results object except the deliberate overall-quintet relay and the bootstrap_results gate. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' RELAYS the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (the per-target psi machinery makes replay tractable - a TODO row). The prior fail-closed-for-ALL-levels uniform-parity decision was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale - never publish analytical provenance beside percentile inference - is honored by the NaN df column. Replicate-weight fits REPLAY the extracted _replicate_override_aggregates with a LEVEL-MATCHED stack ([overall, ES] or [overall, groups]): compute_replicate_refit_variance validates replicates jointly, so aggregate(L) reproduces fit(aggregate=L) exactly and a fit(aggregate='all') surface is NOT the equivalence target when a replicate NaNs on one family's targets; the same joint-stack coupling means migrating a replicate fit from fit(aggregate=) to plain fit changes the OVERALL row's se/CI/df on degenerate designs (documented migration delta, CHANGELOG + REGISTRY note (e) + a contract-test pin). pretrends=True + replicate: post-fit aggregate('event_study') raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); group/simple still work. Simple relay: n = n_treated_obs with n_kind='obs' (the treated/control unit sets OVERLAP, so the CS/EDiD disjoint-units convention cannot apply - the StackedDiD carve-out class); df = the survey_df_final snapshot (what the stored overall inference received). Group rows record per-row df_used at each safe_inference call (additive row-dict key; the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via .get). The M-127 df_convention inert-config warning predicate is REVISED to reachability (pretrends AND not-replicate AND (deprecated fit-time ES/all supplied OR n_bootstrap <= 0)) because post-fit aggregate() made the old aggregate-keyed claim false; reachability-BASED, not exact - a fit whose bootstrap later fails (bootstrap_results=None) can still aggregate post-fit, so that corner warns spuriously (recorded on M-127 too). Container admission NOT widened: ImputationDiD is rejected BY DESIGN (no joint ES covariance - per-horizon conservative SEs only; see M-093). balance_e moves as its own row [M-118]."
- id: M-022
kind: param
group: aggregate-postfit
@@ -254,7 +254,7 @@ rows:
warning: FutureWarning
test_ref: tests/test_aggregate_contract.py
code_refs: [diff_diff/two_stage.py, diff_diff/two_stage_aggregation.py, diff_diff/two_stage_results.py, diff_diff/two_stage_bootstrap.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt]
- notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-119], warn-and-still-work; the two_stage_did wrapper forwards the sentinel so a plain wrapper call never warns). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary). The successor is a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich, so the kit retains a COLUMN-SUBSET COPY of the working frame (only the columns the moved methods read by name, deduplicated - cluster= may legally name the unit/time/first_treat column) plus the Stage-1 FE model, masks, and survey objects. MEMORY CONTRACT: this is the FIRST panel retention on TwoStageDiD results - O(n_obs) incl. unit/time/cluster identifier columns on every results object and pickle, and replicate designs additionally retain the (n_obs x R) replicate matrix via resolved_survey; the CS/EDiD identifier-minimization guarantee deliberately does NOT hold (a store_kit opt-out is a DEFERRED row). score_pad_mask/cluster_ids_full are stored as the Wave-E.3-GATED values fit actually passed. Value snapshots (treatment_groups copy, overall_att, survey_df_stage2/survey_df_final, a dataclasses.replace copy of survey_metadata) isolate recompute and the ES carrier from public-field mutation. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed; weights= rejected. Bootstrap fits: 'simple' RELAYS the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform fail-closed decision superseded 2026-08-05 with the [M-027] per-level convergence; a fit whose bootstrap FAILED (bootstrap_results=None, analytical inference retained) aggregates normally. Replicate-weight fits REPLAY the extracted _replay_replicate_inference with a LEVEL-MATCHED stack - aggregate(L) reproduces fit(aggregate=L) exactly; fit(aggregate='all') is NOT the equivalence target on degenerate designs, and the same joint-stack coupling makes the migration to plain fit change the OVERALL row's se/CI/df there (documented migration delta). Post-fit aggregate('event_study') reproduces the M-092 container contract exactly: analytical fits thread the recomputed joint vcov + vcov_index + the finite-and->0 df scalar through the carrier; replicate fits thread vcov=None/index=None with the REPLAYED level-matched df. Simple relay: n = n_treated_obs with n_kind='obs' (overlapping unit sets - StackedDiD carve-out class); df = the survey_df_final snapshot (on replicate fits that value came from the [overall]-only stack - snapshotted, never re-derived). Group relay df is a SCALAR broadcast (deliberate divergence from ImputationDiD's per-row df_used: _stage2_group passes one immutable survey_df to every row, so the scalar is provenance-exact by construction and the moved method stays verbatim). Container admission NOT widened: DEFERRED pending a normalization derivation - analytical surfaces carry the real joint Gardner-GMM covariance (M-092), but pre-period coefficients are stage-1 residual means, not reference-normalized contrasts, while HonestDiD's Delta arithmetic hard-codes delta_0=0 (see M-093 + the DEFERRED.md paper-gated row). balance_e moves as its own row [M-119]."
+ notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-119], warn-and-still-work; the two_stage_did wrapper forwards the sentinel so a plain wrapper call never fires the aggregate warning; since 3.9 the wrapper itself warns per [M-071]). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary). The successor is a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich, so the kit retains a COLUMN-SUBSET COPY of the working frame (only the columns the moved methods read by name, deduplicated - cluster= may legally name the unit/time/first_treat column) plus the Stage-1 FE model, masks, and survey objects. MEMORY CONTRACT: this is the FIRST panel retention on TwoStageDiD results - O(n_obs) incl. unit/time/cluster identifier columns on every results object and pickle, and replicate designs additionally retain the (n_obs x R) replicate matrix via resolved_survey; the CS/EDiD identifier-minimization guarantee deliberately does NOT hold (a store_kit opt-out is a DEFERRED row). score_pad_mask/cluster_ids_full are stored as the Wave-E.3-GATED values fit actually passed. Value snapshots (treatment_groups copy, overall_att, survey_df_stage2/survey_df_final, a dataclasses.replace copy of survey_metadata) isolate recompute and the ES carrier from public-field mutation. SUPPORTED SUBSET simple|event_study|group; calendar/'all' fail closed; weights= rejected. Bootstrap fits: 'simple' RELAYS the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform fail-closed decision superseded 2026-08-05 with the [M-027] per-level convergence; a fit whose bootstrap FAILED (bootstrap_results=None, analytical inference retained) aggregates normally. Replicate-weight fits REPLAY the extracted _replay_replicate_inference with a LEVEL-MATCHED stack - aggregate(L) reproduces fit(aggregate=L) exactly; fit(aggregate='all') is NOT the equivalence target on degenerate designs, and the same joint-stack coupling makes the migration to plain fit change the OVERALL row's se/CI/df there (documented migration delta). Post-fit aggregate('event_study') reproduces the M-092 container contract exactly: analytical fits thread the recomputed joint vcov + vcov_index + the finite-and->0 df scalar through the carrier; replicate fits thread vcov=None/index=None with the REPLAYED level-matched df. Simple relay: n = n_treated_obs with n_kind='obs' (overlapping unit sets - StackedDiD carve-out class); df = the survey_df_final snapshot (on replicate fits that value came from the [overall]-only stack - snapshotted, never re-derived). Group relay df is a SCALAR broadcast (deliberate divergence from ImputationDiD's per-row df_used: _stage2_group passes one immutable survey_df to every row, so the scalar is provenance-exact by construction and the moved method stays verbatim). Container admission NOT widened: DEFERRED pending a normalization derivation - analytical surfaces carry the real joint Gardner-GMM covariance (M-092), but pre-period coefficients are stage-1 residual means, not reference-normalized contrasts, while HonestDiD's Delta arithmetic hard-codes delta_0=0 (see M-093 + the DEFERRED.md paper-gated row). balance_e moves as its own row [M-119]."
- id: M-023
kind: param
group: aggregate-postfit
@@ -780,10 +780,11 @@ rows:
introduced_in: "3.9"
deprecated_in: null
removed_in: null
- status: planned
+ status: done
phase: 2
- code_refs: [diff_diff/__init__.py]
- notes: "Introduce-only. 'SC' rejected: one transposition from the existing CS alias. TROP/LPDiD are self-aliased acronyms; no rows."
+ test_ref: tests/test_aliases.py
+ code_refs: [diff_diff/__init__.py, docs/api/estimators.rst, diff_diff/guides/llms-full.txt, diff_diff/guides/llms.txt]
+ notes: "Introduce-only. 'SC' rejected: one transposition from the existing CS alias. TROP/LPDiD are self-aliased acronyms; no rows. Shipped in 2(d) PR-A: SCM = SyntheticControl in module globals + __all__ + the guide/API alias rosters."
- id: M-063
kind: alias
group: alias-table
@@ -807,11 +808,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/imputation.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/methodology/REGISTRY.md]
- notes: "Use ImputationDiD. Only 8 of 24 estimators ever had wrappers; the surface is retired rather than completed."
+ code_refs: [diff_diff/imputation.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/methodology/REGISTRY.md, docs/api/imputation.rst]
+ notes: "Use ImputationDiD. Only 8 of 24 estimators ever had wrappers; the surface is retired rather than completed. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9."
- id: M-071
kind: function
group: function-wrappers
@@ -819,11 +821,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/two_stage.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/methodology/REGISTRY.md]
- notes: "Use TwoStageDiD."
+ code_refs: [diff_diff/two_stage.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/methodology/REGISTRY.md, docs/api/two_stage.rst]
+ notes: "Use TwoStageDiD. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9."
- id: M-072
kind: function
group: function-wrappers
@@ -831,11 +834,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/stacked_did.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt]
- notes: "Use StackedDiD."
+ code_refs: [diff_diff/stacked_did.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/api/stacked_did.rst]
+ notes: "Use StackedDiD. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9."
- id: M-073
kind: function
group: function-wrappers
@@ -843,11 +847,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/trop.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt]
- notes: "Use TROP."
+ code_refs: [diff_diff/trop.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/api/trop.rst, docs/tutorials/10_trop.ipynb]
+ notes: "Use TROP. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9."
- id: M-074
kind: function
group: function-wrappers
@@ -855,11 +860,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/synthetic_control.py, diff_diff/__init__.py, diff_diff/practitioner.py, docs/methodology/REGISTRY.md]
- notes: "Use SyntheticControl."
+ code_refs: [diff_diff/synthetic_control.py, diff_diff/__init__.py, diff_diff/practitioner.py, docs/methodology/REGISTRY.md, diff_diff/guides/llms-full.txt, docs/api/synthetic_control.rst, tests/test_methodology_synthetic_control.py]
+ notes: "Use SyntheticControl. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9."
- id: M-075
kind: function
group: function-wrappers
@@ -867,11 +873,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/triple_diff.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt]
- notes: "Use TripleDifference."
+ code_refs: [diff_diff/triple_diff.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/api/triple_diff.rst, docs/tutorials/08_triple_diff.ipynb]
+ notes: "Use TripleDifference. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9."
- id: M-076
kind: function
group: function-wrappers
@@ -879,11 +886,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/bacon.py, diff_diff/__init__.py, diff_diff/diagnostic_report.py, docs/methodology/REPORTING.md, docs/methodology/REGISTRY.md, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt, diff_diff/visualization/_diagnostic.py, diff_diff/twfe.py]
- notes: "Use BaconDecomposition. twowayfeweights stays - it is a diagnostic function, not a class duplicate."
+ code_refs: [diff_diff/bacon.py, diff_diff/__init__.py, diff_diff/diagnostic_report.py, docs/methodology/REPORTING.md, docs/methodology/REGISTRY.md, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt, diff_diff/visualization/_diagnostic.py, diff_diff/twfe.py, docs/api/bacon.rst, docs/tutorials/02_staggered_did.ipynb, docs/tutorials/09_real_world_examples.ipynb]
+ notes: "Use BaconDecomposition. twowayfeweights stays - it is a diagnostic function, not a class duplicate. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9. Internal callers migrated to the class (diagnostic_report runner + emitted guidance in twfe/visualization); message uses diagnostic wording (BaconDecompositionResults subclasses the Diagnostic marker)."
- id: M-077
kind: function
group: function-wrappers
@@ -891,11 +899,12 @@ rows:
new: null
deprecated_in: "3.9"
removed_in: "4.0"
- status: planned
- phase: 2
+ status: shimmed
+ phase: 5
+ test_ref: tests/test_v4_wrapper_shims.py
warning: FutureWarning
- code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/__init__.py]
- notes: "Use ChaisemartinDHaultfoeuille."
+ code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/__init__.py, diff_diff/guides/llms-full.txt, docs/api/chaisemartin_dhaultfoeuille.rst]
+ notes: "Use ChaisemartinDHaultfoeuille. 3.9 shim shipped (2(d) PR-A): FutureWarning at call + docstring deprecation note; the autofunction API page stays through 3.9."
- id: M-086
kind: param-value
@@ -1569,7 +1578,7 @@ rows:
status: planned
phase: 5
code_refs: [diff_diff/__init__.py, docs/api/estimators.rst, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, tests/test_aliases.py]
- notes: "Alias diet 2026-07-31: not an established literature name and one edit from CiC/DiD/DCDH. Target class survives, so no parent-class shim can carry the warning - the 3.9 FutureWarning rides the module __getattr__ shim ([M-135]); removal at 4.0. code_refs list the known reader surfaces (section 8 rule 11); the 2d/removal PRs also grep repo-wide."
+ notes: "Alias diet 2026-07-31: not an established literature name and one edit from CiC/DiD/DCDH. Target class survives, so no parent-class shim can carry the warning - the 3.9 FutureWarning rides the module __getattr__ shim ([M-135]); removal at 4.0. code_refs list the known reader surfaces (section 8 rule 11); the 2d/removal PRs also grep repo-wide; 3.9 FutureWarning live via [M-135] since 2(d) PR-A."
- id: M-133
kind: alias
group: alias-table
@@ -1582,7 +1591,7 @@ rows:
status: planned
phase: 5
code_refs: [diff_diff/__init__.py, docs/api/estimators.rst, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, diff_diff/guides/llms-autonomous.txt, tests/test_aliases.py, diff_diff/diagnostic_report.py, diff_diff/business_report.py, diff_diff/power.py]
- notes: "Alias diet 2026-07-31: a vague English adjective saving three characters over StackedDiD, with zero lookup value. Target class survives - the 3.9 FutureWarning rides the module __getattr__ shim ([M-135]); removal at 4.0. code_refs list the known reader surfaces; the 2d/removal PRs also grep repo-wide."
+ notes: "Alias diet 2026-07-31: a vague English adjective saving three characters over StackedDiD, with zero lookup value. Target class survives - the 3.9 FutureWarning rides the module __getattr__ shim ([M-135]); removal at 4.0. code_refs list the known reader surfaces; the 2d/removal PRs also grep repo-wide; 3.9 FutureWarning live via [M-135] since 2(d) PR-A."
- id: M-134
kind: alias
group: alias-table
@@ -1595,7 +1604,7 @@ rows:
status: planned
phase: 5
code_refs: [diff_diff/__init__.py, docs/api/estimators.rst, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, diff_diff/guides/llms-autonomous.txt, tests/test_aliases.py, diff_diff/diagnostic_report.py, diff_diff/practitioner.py, diff_diff/business_report.py]
- notes: "Alias diet 2026-07-31: author surname as a class name; the ecosystem name for the method is did2s and 'Gardner (2022)' prose citations are unaffected. Target class survives - the 3.9 FutureWarning rides the module __getattr__ shim ([M-135]); removal at 4.0. code_refs list the known reader surfaces; the 2d/removal PRs also grep repo-wide."
+ notes: "Alias diet 2026-07-31: author surname as a class name; the ecosystem name for the method is did2s and 'Gardner (2022)' prose citations are unaffected. Target class survives - the 3.9 FutureWarning rides the module __getattr__ shim ([M-135]); removal at 4.0. code_refs list the known reader surfaces; the 2d/removal PRs also grep repo-wide; 3.9 FutureWarning live via [M-135] since 2(d) PR-A."
- id: M-135
kind: behavior
group: alias-table
@@ -1604,10 +1613,11 @@ rows:
introduced_in: "3.9"
deprecated_in: null
removed_in: null
- status: planned
+ status: done
phase: 2
+ test_ref: tests/test_aliases.py
code_refs: [diff_diff/__init__.py]
- notes: "Alias-diet warning mechanism: CDiD/Stacked/Gardner served via a PEP 562 module __getattr__ emitting FutureWarning naming the surviving class. Names stay in __all__ through 3.9 but leave module globals: dir()/vars() shrink symmetrically (test_dir_returns_full_module_namespace unaffected) and star-import fires the three warnings - accepted diet behavior, documented in v4-design section 3.2. Ships in phase 2d; test_due_rows_are_terminal forces it terminal before any 3.9 bump. Removals are [M-132]..[M-134]."
+ notes: "Alias-diet warning mechanism: CDiD/Stacked/Gardner served via a PEP 562 module __getattr__ emitting FutureWarning naming the surviving class. Names stay in __all__ through 3.9 but leave module globals: dir()/vars() shrink symmetrically (test_dir_returns_full_module_namespace unaffected) and star-import fires the alias warnings - SIX recorded under simplefilter('always'), not three: the package import protocol resolves each missing __all__ name twice (_handle_fromlist hasattr-probe, then the IMPORT_STAR getattr), so the test asserts the message SET, not a count - accepted diet behavior, documented in v4-design section 3.2. Shipped in 2(d) PR-A. Removals are [M-132]..[M-134]."
- id: M-136
kind: param-value
group: renames-level
diff --git a/docs/v4-design.md b/docs/v4-design.md
index 833a1f66..d7b98f16 100644
--- a/docs/v4-design.md
+++ b/docs/v4-design.md
@@ -107,22 +107,26 @@ paper's own label for its estimator, the same criterion that keeps SDiD). Change
that CS/SA/BJS/LPDiD also produce, and retargeting it to a class whose default
mode is the static ATT would make the name carry altered meaning. `SDDD` dies
with its class [M-013] [M-064]. `QDiDResults` dies with QDiD [M-061].
-**Alias diet (2026-07-31):** `CDiD`, `Stacked` and `Gardner` are
+**Alias diet (2026-07-31; shipped in 3.9 by 2(d) PR-A):** `CDiD`,
+`Stacked` and `Gardner` are
retired — [M-132]..[M-134] — deprecated 3.9, removed 4.0. Their target classes
survive, so no parent-class shim can carry the warning; the 3.9 FutureWarning
rides a module-level `__getattr__` [M-135]. The 3.9 consequence: the three
names leave module globals (gone from `dir()` and static autocomplete) while
staying importable and in `__all__`, and `from diff_diff import *` fires their
-FutureWarnings — accepted diet behavior, owned by the phase-2(d) PR. New:
-`SCM` for SyntheticControl [M-062] ("SC" rejected - one transposition from
-CS). The planned `Spillover` alias for SpilloverDiD is CANCELLED [M-063]
+FutureWarnings — accepted diet behavior (the package import protocol
+resolves each missing `__all__` name twice, so the star-import records
+six warnings for the three aliases; the test asserts the message set).
+New: `SCM` for SyntheticControl [M-062] ("SC" rejected - one
+transposition from CS). The planned `Spillover` alias for SpilloverDiD is CANCELLED [M-063]
(never shipped). TROP and LPDiD are self-aliased acronyms.
After 4.0: every estimator has exactly one class name and at most one alias —
15 aliases total.
### 3.3 Module-level function wrappers
-The 8 estimator wrapper functions are deprecated in 3.9 and removed in 4.0
+The 8 estimator wrapper functions are deprecated in 3.9 (shipped by 2(d)
+PR-A: FutureWarning shims + docstring notes) and removed in 4.0
([M-070]..[M-077]): classes are the single canonical construction surface.
`twowayfeweights` stays (diagnostic function, not a class duplicate);
`compute_honest_did`, placebo/power helpers, and dataset loaders are
@@ -534,6 +538,22 @@ domain vocabulary, not drift.
unsupported - and a warning still leaves a procedure running that the caller
did not ask for.
+ **Locked implementation decisions (2026-08-06, for the 2(d) PR-B).**
+ The accepted value set `{"analytical", "wild_bootstrap"}` is validated
+ at `__init__` (transactional `set_params` inherits it via the
+ BaseEstimator probe re-init); the COHERENCE checks run at fit - DiD
+ with `inference="wild_bootstrap"` and no `cluster=` raises
+ `ValueError`, and DiD/TWFE with `wild_bootstrap` and
+ `n_bootstrap < 1` raise `ValueError`; TWFE's unit auto-cluster stays;
+ MultiPeriodDiD's warn-and-fallback stays until its 4.0 removal
+ (DEFERRED.md documents the limitation). The pinned fallback test
+ `test_did_wild_bootstrap_requires_cluster` flips BY DESIGN (its
+ CHANGELOG entry must say so). The roster guard is a dynamic sweep:
+ the set of estimators exposing `inference` in `get_params()` is
+ exactly {DifferenceInDifferences, MultiPeriodDiD,
+ TwoWayFixedEffects} - a future estimator gaining WCR must adopt the
+ selector or land its own row.
+
The apparent `inference=` vs `n_bootstrap>0` split is NOT drift: an estimator
whose bootstrap IS its inference method runs a different procedure -
CallawaySantAnna an influence-function multiplier bootstrap, SunAbraham a
@@ -550,7 +570,14 @@ domain vocabulary, not drift.
- `n_bootstrap` semantic unification [M-081]: `0` = bootstrap off wherever an
analytical path exists; bootstrap-only estimators document their positive
defaults. Counts stay tuned per estimator (999 light / 200 compute-heavy) -
- NO numeric default changes.
+ NO numeric default changes. **Locked implementation decision
+ (2026-08-06, for the 2(d) PR-B)**: a shared `validate_n_bootstrap`
+ helper (non-negative int; rejects bool/None/negative; 0 stays legal
+ wherever it means off) is promoted to utils and applied to EVERY
+ estimator with a currently-unvalidated `n_bootstrap` - the roster is
+ GREP-DERIVED at PR-B implementation (known so far: CallawaySantAnna,
+ SunAbraham, EfficientDiD, ImputationDiD, TwoStageDiD, WooldridgeDiD,
+ ContinuousDiD, the DiD family, and StaggeredTripleDifference).
- **Auto-cluster policy** [M-080], flips at 4.0: every panel estimator
(required `unit` column) defaults to clustering at unit
(Bertrand-Duflo-Mullainathan practice), setting `cluster_name` /
@@ -740,12 +767,18 @@ re-enumerate the cells' M-id lists:
`EventStudyResults` downstream-consumability work (TODO.md row: the three
consumers currently reject the unified container) lands before or inside
this wave so the shims do not steer users into a dead end.
-6. 2(d): wrapper deprecations, the SCM introduction, the alias-diet
- `__getattr__` shim + dieted-alias surface sweep (reader surfaces are
- recorded in M-132..M-135's `code_refs`, the ledger-native home - this doc
- carries no file inventory; the 2(d) PR starts from those `code_refs` and
- additionally greps each dieted alias repo-wide), `n_bootstrap`, and the
- wild-cluster-bootstrap roster guard.
+6. 2(d), split into TWO PRs (2026-08-06): PR-A - wrapper deprecations
+ [M-070..M-077], the SCM introduction [M-062], and the alias-diet
+ `__getattr__` shim [M-135] + dieted-alias surface sweep (reader
+ surfaces are recorded in M-132..M-135's `code_refs`, the
+ ledger-native home - this doc carries no file inventory; the PR
+ starts from those `code_refs` and additionally greps each dieted
+ alias repo-wide) (shipped: the eight wrapper shims +
+ `tests/test_v4_wrapper_shims.py`, the module `__getattr__` +
+ `tests/test_aliases.py`, SCM); then PR-B - the two inference-surface
+ policies, `n_bootstrap` semantic unification [M-081] and the
+ wild-cluster-bootstrap roster guard [M-096] (locked implementation
+ decisions live in section 7, per this section's boundary rule).
7. Phase 3 merges (a)/(b)/(c) per the phase-3 cell.
8. Phase 4: migration guide, the 3.9-cut checklist below, cut.
diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py
index 96679e98..f435f2be 100644
--- a/tests/test_aggregate_contract.py
+++ b/tests/test_aggregate_contract.py
@@ -18,6 +18,7 @@
import copy
import pickle
+import re
import warnings
import numpy as np
@@ -977,7 +978,9 @@ def test_wrapper_forwarded_aggregate_warns(self, dcdh_panel):
# through the wrapper too.
from diff_diff.chaisemartin_dhaultfoeuille import chaisemartin_dhaultfoeuille
- with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"):
+ # Both warnings fire since 2(d) PR-A: the wrapper deprecation
+ # (M-077) plus the forwarded fit(aggregate=) shim.
+ with pytest.warns(FutureWarning) as record:
chaisemartin_dhaultfoeuille(
dcdh_panel,
outcome="outcome",
@@ -986,6 +989,9 @@ def test_wrapper_forwarded_aggregate_warns(self, dcdh_panel):
treatment="treat",
aggregate=None,
)
+ msgs = [str(w.message) for w in record]
+ assert any("chaisemartin_dhaultfoeuille() is deprecated" in m for m in msgs), msgs
+ assert any(re.search(r"fit\(aggregate=\) is deprecated", m) for m in msgs), msgs
class TestDcdhAggregate:
@@ -1249,7 +1255,8 @@ def test_wrapper_forwarded_aggregate_warns(self, stacked_panel):
# default and forwards verbatim into fit().
from diff_diff.stacked_did import stacked_did
- with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"):
+ # Both warnings fire since 2(d) PR-A (M-072 + the forwarded shim).
+ with pytest.warns(FutureWarning) as record:
stacked_did(
stacked_panel,
"outcome",
@@ -1260,8 +1267,15 @@ def test_wrapper_forwarded_aggregate_warns(self, stacked_panel):
kappa_post=2,
aggregate="simple",
)
-
- def test_plain_wrapper_call_does_not_warn(self, stacked_panel):
+ msgs = [str(w.message) for w in record]
+ assert any("stacked_did() is deprecated" in m for m in msgs), msgs
+ assert any(re.search(r"fit\(aggregate=\) is deprecated", m) for m in msgs), msgs
+
+ def test_plain_wrapper_call_fires_only_wrapper_warning(self, stacked_panel):
+ # Flipped BY DESIGN in the 2(d) PR-A (M-072): the wrapper itself
+ # now emits its deprecation FutureWarning, but the sentinel
+ # forwarding is unchanged - a plain wrapper call must never fire
+ # the fit-time aggregate warning, so EXACTLY ONE FutureWarning.
from diff_diff.stacked_did import stacked_did
with warnings.catch_warnings(record=True) as caught:
@@ -1275,7 +1289,10 @@ def test_plain_wrapper_call_does_not_warn(self, stacked_panel):
kappa_pre=2,
kappa_post=2,
)
- assert [w for w in caught if issubclass(w.category, FutureWarning)] == []
+ fw = [w for w in caught if issubclass(w.category, FutureWarning)]
+ assert len(fw) == 1, [str(w.message) for w in fw]
+ assert "stacked_did() is deprecated" in str(fw[0].message)
+ assert "StackedDiD.fit" not in str(fw[0].message)
assert res.event_study_effects is not None
def test_group_warns_then_raises_educational_error(self, stacked_panel):
@@ -2215,7 +2232,8 @@ def test_warn_and_still_work(self, imputation_fitted, imputation_fit_time):
def test_wrapper_forwarded_aggregate_warns(self, imputation_panel):
from diff_diff import imputation_did
- with pytest.warns(FutureWarning, match=r"ImputationDiD\.fit\(aggregate=\)"):
+ # Both warnings fire since 2(d) PR-A (M-070 + the forwarded shim).
+ with pytest.warns(FutureWarning) as record:
imputation_did(
imputation_panel,
"outcome",
@@ -2224,22 +2242,34 @@ def test_wrapper_forwarded_aggregate_warns(self, imputation_panel):
"first_treat",
aggregate="event_study",
)
+ msgs = [str(w.message) for w in record]
+ assert any("imputation_did() is deprecated" in m for m in msgs), msgs
+ assert any(re.search(r"ImputationDiD\.fit\(aggregate=\)", m) for m in msgs), msgs
def test_wrapper_forwarded_balance_e_warns(self, imputation_panel):
from diff_diff import imputation_did
- with pytest.warns(FutureWarning, match=r"ImputationDiD\.fit\(balance_e=\)"):
+ with pytest.warns(FutureWarning) as record:
imputation_did(
imputation_panel, "outcome", "unit", "period", "first_treat", balance_e=1
)
-
- def test_plain_wrapper_call_does_not_warn(self, imputation_panel):
+ msgs = [str(w.message) for w in record]
+ assert any("imputation_did() is deprecated" in m for m in msgs), msgs
+ assert any(re.search(r"ImputationDiD\.fit\(balance_e=\)", m) for m in msgs), msgs
+
+ def test_plain_wrapper_call_fires_only_wrapper_warning(self, imputation_panel):
+ # Flipped BY DESIGN in the 2(d) PR-A (M-070): the wrapper's own
+ # deprecation warning fires, the forwarded aggregate sentinel
+ # still never does - EXACTLY ONE FutureWarning.
from diff_diff import imputation_did
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
imputation_did(imputation_panel, "outcome", "unit", "period", "first_treat")
- assert [w for w in caught if issubclass(w.category, FutureWarning)] == []
+ fw = [w for w in caught if issubclass(w.category, FutureWarning)]
+ assert len(fw) == 1, [str(w.message) for w in fw]
+ assert "imputation_did() is deprecated" in str(fw[0].message)
+ assert "ImputationDiD.fit" not in str(fw[0].message)
class TestImputationAggregate:
@@ -2637,7 +2667,8 @@ def test_warn_and_still_work(self, twostage_fitted, twostage_fit_time):
def test_wrapper_forwarded_aggregate_warns(self, twostage_panel):
from diff_diff import two_stage_did
- with pytest.warns(FutureWarning, match=r"TwoStageDiD\.fit\(aggregate=\)"):
+ # Both warnings fire since 2(d) PR-A (M-071 + the forwarded shim).
+ with pytest.warns(FutureWarning) as record:
two_stage_did(
twostage_panel,
"outcome",
@@ -2646,20 +2677,32 @@ def test_wrapper_forwarded_aggregate_warns(self, twostage_panel):
"first_treat",
aggregate="event_study",
)
+ msgs = [str(w.message) for w in record]
+ assert any("two_stage_did() is deprecated" in m for m in msgs), msgs
+ assert any(re.search(r"TwoStageDiD\.fit\(aggregate=\)", m) for m in msgs), msgs
def test_wrapper_forwarded_balance_e_warns(self, twostage_panel):
from diff_diff import two_stage_did
- with pytest.warns(FutureWarning, match=r"TwoStageDiD\.fit\(balance_e=\)"):
+ with pytest.warns(FutureWarning) as record:
two_stage_did(twostage_panel, "outcome", "unit", "period", "first_treat", balance_e=1)
-
- def test_plain_wrapper_call_does_not_warn(self, twostage_panel):
+ msgs = [str(w.message) for w in record]
+ assert any("two_stage_did() is deprecated" in m for m in msgs), msgs
+ assert any(re.search(r"TwoStageDiD\.fit\(balance_e=\)", m) for m in msgs), msgs
+
+ def test_plain_wrapper_call_fires_only_wrapper_warning(self, twostage_panel):
+ # Flipped BY DESIGN in the 2(d) PR-A (M-071): the wrapper's own
+ # deprecation warning fires, the forwarded aggregate sentinel
+ # still never does - EXACTLY ONE FutureWarning.
from diff_diff import two_stage_did
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
two_stage_did(twostage_panel, "outcome", "unit", "period", "first_treat")
- assert [w for w in caught if issubclass(w.category, FutureWarning)] == []
+ fw = [w for w in caught if issubclass(w.category, FutureWarning)]
+ assert len(fw) == 1, [str(w.message) for w in fw]
+ assert "two_stage_did() is deprecated" in str(fw[0].message)
+ assert "TwoStageDiD.fit" not in str(fw[0].message)
class TestTwoStageAggregate:
diff --git a/tests/test_aliases.py b/tests/test_aliases.py
index 0f0560bf..c93de42f 100644
--- a/tests/test_aliases.py
+++ b/tests/test_aliases.py
@@ -1,27 +1,51 @@
-"""Tests for estimator short aliases."""
+"""Tests for estimator short aliases.
+
+Covers the kept aliases (silent identity), the M-062 SCM introduction,
+and the M-132..M-134 alias diet served by the M-135 module
+``__getattr__`` (FutureWarning naming the surviving class; names stay
+in ``__all__`` through 3.9 but leave module globals / ``dir()``).
+"""
+
+import re
+import warnings
+
+import pytest
import diff_diff
+# The three dieted aliases (M-132..M-134): removed from module globals in
+# 3.9, served by the M-135 module __getattr__ with a FutureWarning until
+# the 4.0 removal.
+DIETED_ALIASES = {
+ "CDiD": "ContinuousDiD",
+ "Gardner": "TwoStageDiD",
+ "Stacked": "StackedDiD",
+}
+
+
+def _alias_message(name: str, target: str) -> str:
+ return f"diff_diff.{name} is deprecated and will be removed in 4.0; use diff_diff.{target}."
+
def test_alias_identity():
- """Each alias is the same class object as the full name."""
- assert diff_diff.DiD is diff_diff.DifferenceInDifferences
- assert diff_diff.TWFE is diff_diff.TwoWayFixedEffects
- assert diff_diff.EventStudy is diff_diff.MultiPeriodDiD
- assert diff_diff.SDiD is diff_diff.SyntheticDiD
- assert diff_diff.CS is diff_diff.CallawaySantAnna
- assert diff_diff.CDiD is diff_diff.ContinuousDiD
- assert diff_diff.SA is diff_diff.SunAbraham
- assert diff_diff.BJS is diff_diff.ImputationDiD
- assert diff_diff.Gardner is diff_diff.TwoStageDiD
- assert diff_diff.DDD is diff_diff.TripleDifference
- assert diff_diff.Stacked is diff_diff.StackedDiD
- assert diff_diff.Bacon is diff_diff.BaconDecomposition
- assert diff_diff.CiC is diff_diff.ChangesInChanges
+ """Each kept alias is the same class object as the full name (silent)."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", FutureWarning)
+ assert diff_diff.DiD is diff_diff.DifferenceInDifferences
+ assert diff_diff.TWFE is diff_diff.TwoWayFixedEffects
+ assert diff_diff.EventStudy is diff_diff.MultiPeriodDiD
+ assert diff_diff.SDiD is diff_diff.SyntheticDiD
+ assert diff_diff.CS is diff_diff.CallawaySantAnna
+ assert diff_diff.SA is diff_diff.SunAbraham
+ assert diff_diff.BJS is diff_diff.ImputationDiD
+ assert diff_diff.DDD is diff_diff.TripleDifference
+ assert diff_diff.Bacon is diff_diff.BaconDecomposition
+ assert diff_diff.CiC is diff_diff.ChangesInChanges
+ assert diff_diff.SCM is diff_diff.SyntheticControl
def test_aliases_in_all():
- """All aliases are listed in __all__."""
+ """All aliases are listed in __all__ (the dieted three stay through 3.9)."""
aliases = [
"DiD",
"TWFE",
@@ -33,6 +57,7 @@ def test_aliases_in_all():
"BJS",
"Gardner",
"DDD",
+ "SCM",
"Stacked",
"Bacon",
"CiC",
@@ -45,3 +70,88 @@ def test_alias_instantiation():
"""Instantiating via alias produces the correct type."""
model = diff_diff.DiD()
assert isinstance(model, diff_diff.DifferenceInDifferences)
+
+
+class TestAliasDiet:
+ """M-132..M-134 dieted aliases + the M-135 __getattr__ mechanism."""
+
+ @pytest.mark.parametrize("name,target", sorted(DIETED_ALIASES.items()))
+ def test_getattr_route_warns_once_and_resolves(self, name, target):
+ """Plain attribute access: ONE __getattr__ hit -> one warning."""
+ with pytest.warns(FutureWarning, match=re.escape(_alias_message(name, target))):
+ obj = getattr(diff_diff, name)
+ assert obj is getattr(diff_diff, target)
+
+ @pytest.mark.parametrize("name,target", sorted(DIETED_ALIASES.items()))
+ def test_from_import_route_warns_and_resolves(self, name, target):
+ """``from diff_diff import `` still works and warns.
+
+ Asserted SET-WISE, not by count: for a PACKAGE, the import
+ protocol resolves each fromlist name twice (``_handle_fromlist``
+ hasattr-probes the name before the IMPORT_FROM opcode getattrs
+ it), so a single from-import records TWO copies of the same
+ FutureWarning under ``simplefilter("always")``.
+ """
+ ns: dict = {}
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ exec(f"from diff_diff import {name}", ns) # noqa: S102
+ fw = [w for w in caught if issubclass(w.category, FutureWarning)]
+ assert fw, "from-import fired no FutureWarning"
+ assert {str(w.message) for w in fw} == {_alias_message(name, target)}
+ assert ns[name] is getattr(diff_diff, target)
+
+ def test_dieted_aliases_stay_in_all(self):
+ for name in DIETED_ALIASES:
+ assert name in diff_diff.__all__, f"{name} must stay in __all__ through 3.9"
+
+ def test_dieted_aliases_left_module_globals(self):
+ """The diet removes the names from dir()/vars(); SCM is present."""
+ dir_names = {str(n) for n in dir(diff_diff)}
+ vars_names = set(vars(diff_diff))
+ for name in DIETED_ALIASES:
+ assert name not in dir_names, f"{name} still in dir(diff_diff)"
+ assert name not in vars_names, f"{name} still in vars(diff_diff)"
+ assert "SCM" in dir_names and "SCM" in vars_names
+
+ def test_unknown_attribute_raises_attribute_error(self):
+ with pytest.raises(
+ AttributeError, match=re.escape("module 'diff_diff' has no attribute 'NotAnAlias'")
+ ):
+ diff_diff.NotAnAlias
+
+ def test_star_import_fires_alias_warnings_setwise(self):
+ """Star-import serves the three dieted aliases via __getattr__.
+
+ Asserted SET-WISE, not by count: diff_diff is a PACKAGE, so
+ ``_handle_fromlist`` hasattr-probes every missing ``__all__``
+ name (deciding whether it names a submodule) before
+ ``import_all_from`` getattrs it — two ``__getattr__`` hits per
+ alias, i.e. 6 recorded warnings for 3 aliases under
+ ``simplefilter("always")``. The exact count is an importlib
+ implementation detail; the contract is the message SET.
+ """
+ expected = {_alias_message(n, t) for n, t in DIETED_ALIASES.items()}
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ exec("from diff_diff import *", {}) # noqa: S102
+ fw = [w for w in caught if issubclass(w.category, FutureWarning)]
+ got = {str(w.message) for w in fw}
+ assert got == expected, f"unexpected FutureWarning set: {got ^ expected}"
+ # Every recorded FutureWarning is one of the three alias messages.
+ assert all(str(w.message) in expected for w in fw)
+
+ def test_star_import_binds_dieted_aliases(self):
+ """The names remain star-importable and identity holds."""
+ ns: dict = {}
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", FutureWarning)
+ exec("from diff_diff import *", ns) # noqa: S102
+ for name, target in DIETED_ALIASES.items():
+ assert ns[name] is getattr(diff_diff, target), name
+
+ def test_scm_alias_is_silent(self):
+ """M-062 introduce-only alias: no warning on access."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", FutureWarning)
+ assert diff_diff.SCM is diff_diff.SyntheticControl
diff --git a/tests/test_bacon.py b/tests/test_bacon.py
index 7761a345..8de3554a 100644
--- a/tests/test_bacon.py
+++ b/tests/test_bacon.py
@@ -17,6 +17,33 @@
)
+def _bacon_fit(
+ data,
+ outcome=None,
+ unit=None,
+ time=None,
+ first_treat=None,
+ *,
+ weights="exact",
+ survey_design=None,
+ **kw,
+):
+ """Construct-and-fit via the canonical class API (2(d) PR-A, M-076).
+
+ Migrated from the deprecated ``bacon_decompose()`` wrapper: ``weights``
+ is a constructor kwarg, everything else goes to ``fit()``.
+ """
+ return BaconDecomposition(weights=weights).fit(
+ data,
+ outcome=outcome,
+ unit=unit,
+ time=time,
+ first_treat=first_treat,
+ survey_design=survey_design,
+ **kw,
+ )
+
+
def generate_staggered_data(
n_units: int = 100,
n_periods: int = 10,
@@ -102,7 +129,7 @@ def test_weights_sum_to_one(self):
"""Test that decomposition weights sum to approximately 1."""
data = generate_staggered_data(seed=123)
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -117,7 +144,7 @@ def test_weighted_sum_equals_twfe(self):
"""
data = generate_staggered_data(seed=456)
- results = bacon_decompose(
+ results = _bacon_fit(
data,
outcome="outcome",
unit="unit",
@@ -137,7 +164,7 @@ def test_comparison_types(self):
"""Test that all three comparison types are identified."""
data = generate_staggered_data(n_cohorts=3, never_treated_frac=0.3)
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -152,7 +179,7 @@ def test_no_never_treated(self):
"""Test decomposition with no never-treated units."""
data = generate_staggered_data(never_treated_frac=0.0)
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -164,7 +191,7 @@ def test_single_cohort(self):
"""Test with single treatment cohort."""
data = generate_staggered_data(n_cohorts=1, never_treated_frac=0.3)
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -177,7 +204,7 @@ def test_weight_by_type(self):
"""Test weight_by_type method."""
data = generate_staggered_data()
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -192,7 +219,7 @@ def test_effect_by_type(self):
"""Test effect_by_type method."""
data = generate_staggered_data()
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -206,7 +233,7 @@ def test_to_dataframe(self):
"""Test conversion to DataFrame."""
data = generate_staggered_data()
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -224,7 +251,7 @@ def test_summary(self):
"""Test summary generation."""
data = generate_staggered_data()
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -239,7 +266,7 @@ def test_missing_column_error(self):
data = generate_staggered_data()
with pytest.raises(ValueError, match="Missing columns"):
- bacon_decompose(
+ _bacon_fit(
data, outcome="nonexistent", unit="unit", time="time", first_treat="first_treat"
)
@@ -296,15 +323,21 @@ def test_twfe_staggered_warning(self):
class TestBaconDecomposeFunction:
- """Tests for bacon_decompose convenience function."""
+ """Tests for the DEPRECATED bacon_decompose convenience wrapper.
+
+ KEEP (2(d) PR-A, M-076): these stay on the wrapper path - the
+ wrapper survives until 4.0 and only these tests exercise it here -
+ updated for its new deprecation FutureWarning.
+ """
def test_convenience_function(self):
- """Test that convenience function works."""
+ """The deprecated wrapper still works, and warns."""
data = generate_staggered_data()
- results = bacon_decompose(
- data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
- )
+ with pytest.warns(FutureWarning, match="bacon_decompose\\(\\) is deprecated"):
+ results = bacon_decompose(
+ data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
+ )
assert isinstance(results, BaconDecompositionResults)
@@ -318,7 +351,7 @@ def test_plot_bacon_scatter(self):
from diff_diff import plot_bacon
data = generate_staggered_data()
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -332,7 +365,7 @@ def test_plot_bacon_bar(self):
from diff_diff import plot_bacon
data = generate_staggered_data()
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -346,7 +379,7 @@ def test_plot_bacon_invalid_type(self):
from diff_diff import plot_bacon
data = generate_staggered_data()
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -420,7 +453,7 @@ def test_exact_vs_approximate_different(self):
"""Test that exact and approximate weights can differ."""
data = generate_staggered_data(seed=123, n_cohorts=3)
- results_approx = bacon_decompose(
+ results_approx = _bacon_fit(
data,
outcome="outcome",
unit="unit",
@@ -429,7 +462,7 @@ def test_exact_vs_approximate_different(self):
weights="approximate",
)
- results_exact = bacon_decompose(
+ results_exact = _bacon_fit(
data,
outcome="outcome",
unit="unit",
@@ -448,7 +481,7 @@ def test_exact_weights_lower_decomposition_error(self):
"""Test that exact weights generally have lower decomposition error."""
data = generate_staggered_data(seed=456, n_cohorts=3)
- results_approx = bacon_decompose(
+ results_approx = _bacon_fit(
data,
outcome="outcome",
unit="unit",
@@ -457,7 +490,7 @@ def test_exact_weights_lower_decomposition_error(self):
weights="approximate",
)
- results_exact = bacon_decompose(
+ results_exact = _bacon_fit(
data,
outcome="outcome",
unit="unit",
@@ -476,17 +509,19 @@ def test_invalid_weights_raises(self):
BaconDecomposition(weights="invalid")
def test_convenience_function_weights_param(self):
- """Test that convenience function accepts weights parameter."""
+ """KEEP (M-076): the deprecated wrapper's weights= routing to
+ the constructor - a property only the wrapper path exercises."""
data = generate_staggered_data()
- results = bacon_decompose(
- data,
- outcome="outcome",
- unit="unit",
- time="time",
- first_treat="first_treat",
- weights="exact",
- )
+ with pytest.warns(FutureWarning, match="bacon_decompose\\(\\) is deprecated"):
+ results = bacon_decompose(
+ data,
+ outcome="outcome",
+ unit="unit",
+ time="time",
+ first_treat="first_treat",
+ weights="exact",
+ )
assert isinstance(results, BaconDecompositionResults)
@@ -545,9 +580,7 @@ def test_unbalanced_panel_warning(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
- bacon_decompose(
- data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
- )
+ _bacon_fit(data, outcome="outcome", unit="unit", time="time", first_treat="first_treat")
# Should have warning about unbalanced panel
unbalanced_warnings = [x for x in w if "unbalanced" in str(x.message).lower()]
@@ -559,9 +592,7 @@ def test_balanced_panel_no_warning(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
- bacon_decompose(
- data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
- )
+ _bacon_fit(data, outcome="outcome", unit="unit", time="time", first_treat="first_treat")
# Should NOT have warning about unbalanced panel
unbalanced_warnings = [x for x in w if "unbalanced" in str(x.message).lower()]
@@ -575,7 +606,7 @@ def test_small_sample(self):
"""Test with small sample size."""
data = generate_staggered_data(n_units=20, n_periods=5, n_cohorts=2)
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -587,7 +618,7 @@ def test_many_cohorts(self):
n_units=200, n_periods=15, n_cohorts=5, never_treated_frac=0.2
)
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
@@ -601,7 +632,7 @@ def test_inf_for_never_treated(self):
# Replace 0 with inf for never-treated
data["first_treat"] = data["first_treat"].replace(0, np.inf)
- results = bacon_decompose(
+ results = _bacon_fit(
data, outcome="outcome", unit="unit", time="time", first_treat="first_treat"
)
diff --git a/tests/test_business_report.py b/tests/test_business_report.py
index 1d1a4b81..6965ef89 100644
--- a/tests/test_business_report.py
+++ b/tests/test_business_report.py
@@ -29,18 +29,18 @@
import diff_diff as dd
from diff_diff import (
+ BaconDecomposition,
BusinessContext,
BusinessReport,
CallawaySantAnna,
DiagnosticReport,
DifferenceInDifferences,
MultiPeriodDiD,
+ SyntheticControl,
SyntheticDiD,
- bacon_decompose,
generate_did_data,
generate_factor_data,
generate_staggered_data,
- synthetic_control,
)
from diff_diff.business_report import BUSINESS_REPORT_SCHEMA_VERSION
@@ -131,17 +131,12 @@ def scm_fit():
for t in range(T):
rows.append({"unit": "treated", "year": years[t], "y": treated[t], "treated": int(t >= T0)})
df = pd.DataFrame(rows)
- res = synthetic_control(
- df,
- "y",
- "treated",
- "unit",
- "year",
+ res = SyntheticControl(
seed=0,
n_starts=1,
optimizer_options={"maxiter": 50},
inner_min_decrease=1e-3,
- )
+ ).fit(df, "y", "treated", "unit", "year")
return res, df
@@ -521,7 +516,7 @@ def __str__(self):
class TestBaconTypeError:
def test_br_on_bacon_raises(self):
sdf = generate_staggered_data(n_units=30, n_periods=6, treatment_effect=1.5, seed=7)
- bacon = bacon_decompose(
+ bacon = BaconDecomposition().fit(
sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat"
)
with pytest.raises(TypeError, match="BaconDecompositionResults is a diagnostic"):
@@ -4713,7 +4708,7 @@ def test_survey_backed_did_br_rolls_up_pt_skip(self):
def test_survey_backed_staggered_br_forwards_survey_design_to_bacon(self):
"""BR must forward ``survey_design`` to the auto-constructed
- DR, which in turn threads it to ``bacon_decompose``. Verify via
+ DR, which in turn threads it to ``BaconDecomposition.fit``. Verify via
``unittest.mock.patch`` that the kwarg reaches the decomposer.
"""
from unittest.mock import MagicMock, patch
@@ -4736,7 +4731,11 @@ def test_survey_backed_staggered_br_forwards_survey_design_to_bacon(self):
fake_decomp.total_weight_later_vs_earlier = 0.05
fake_decomp.twfe_estimate = 1.1
fake_decomp.n_timing_groups = 2
- with patch("diff_diff.bacon.bacon_decompose", return_value=fake_decomp) as m:
+ # 2(d) PR-A (M-076): the DR runner constructs BaconDecomposition
+ # directly - `weights` is a ctor kwarg, `survey_design` a fit()
+ # kwarg - so the patch target and assertions follow the split.
+ with patch("diff_diff.bacon.BaconDecomposition") as m:
+ m.return_value.fit.return_value = fake_decomp
br = BusinessReport(
obj,
data=panel,
@@ -4747,9 +4746,12 @@ def test_survey_backed_staggered_br_forwards_survey_design_to_bacon(self):
survey_design=sentinel_design,
)
br.to_dict() # trigger DR build
- assert m.called, "bacon_decompose was not called"
- _, kwargs = m.call_args
- assert kwargs.get("survey_design") is sentinel_design
+ assert m.called, "BaconDecomposition was not constructed"
+ _, ctor_kwargs = m.call_args
+ assert ctor_kwargs.get("weights") == "exact"
+ assert m.return_value.fit.called, "BaconDecomposition.fit was not called"
+ _, fit_kwargs = m.return_value.fit.call_args
+ assert fit_kwargs.get("survey_design") is sentinel_design
def test_survey_backed_staggered_br_skips_bacon_without_survey_design(self):
"""Without ``survey_design``, BR's DR must skip Bacon with the
@@ -5005,3 +5007,22 @@ def test_plain_fit_br_runs_checks_and_keeps_balance_step(self):
assert schema["heterogeneity"]["status"] == "ran"
labels = [s["label"] for s in d["next_steps"]]
assert "Check sub-experiment balance" in labels
+
+
+class TestEmittedGuidanceCanonicalNames:
+ """2(d) PR-A per-site pins (user decision 2026-08-06): the emitted
+ Bacon recommendation uses full class names; preserved citation prose
+ ("Two-Stage DiD (Gardner 2022)", "Stacked DiD (Wing...)") is
+ deliberately untouched, so no module-wide alias ban is asserted.
+ """
+
+ def test_bacon_recommendation_uses_class_names(self):
+ import inspect
+
+ import diff_diff.business_report as br_mod
+
+ source = inspect.getsource(br_mod)
+ assert (
+ "estimator (CS / SA / BJS / Gardner)" not in source
+ ), "the Bacon recommendation still uses alias shorthand"
+ assert "ImputationDiD, or TwoStageDiD)." in source
diff --git a/tests/test_chaisemartin_dhaultfoeuille.py b/tests/test_chaisemartin_dhaultfoeuille.py
index 5892e01a..f0812928 100644
--- a/tests/test_chaisemartin_dhaultfoeuille.py
+++ b/tests/test_chaisemartin_dhaultfoeuille.py
@@ -210,14 +210,15 @@ def test_convenience_function_matches_class(self):
time="period",
treatment="treatment",
)
- results_fn = chaisemartin_dhaultfoeuille(
- data,
- outcome="outcome",
- group="group",
- time="period",
- treatment="treatment",
- seed=1,
- )
+ with pytest.warns(FutureWarning, match=r"chaisemartin_dhaultfoeuille\(\) is deprecated"):
+ results_fn = chaisemartin_dhaultfoeuille(
+ data,
+ outcome="outcome",
+ group="group",
+ time="period",
+ treatment="treatment",
+ seed=1,
+ )
# Same point estimate
assert results_class.overall_att == pytest.approx(results_fn.overall_att)
assert results_class.overall_se == pytest.approx(results_fn.overall_se)
@@ -244,18 +245,21 @@ def test_convenience_function_routes_paths_of_interest_to_init(self):
treatment="treatment",
L_max=3,
)
- r_fn = chaisemartin_dhaultfoeuille(
- df,
- outcome="outcome",
- group="group",
- time="period",
- treatment="treatment",
- drop_larger_lower=False,
- paths_of_interest=[(0, 1, 1, 1), (0, 1, 0, 0)],
- twfe_diagnostic=False,
- seed=42,
- L_max=3,
- )
+ with pytest.warns(
+ FutureWarning, match=r"chaisemartin_dhaultfoeuille\(\) is deprecated"
+ ):
+ r_fn = chaisemartin_dhaultfoeuille(
+ df,
+ outcome="outcome",
+ group="group",
+ time="period",
+ treatment="treatment",
+ drop_larger_lower=False,
+ paths_of_interest=[(0, 1, 1, 1), (0, 1, 0, 0)],
+ twfe_diagnostic=False,
+ seed=42,
+ L_max=3,
+ )
# Both surfaces produce identical per-path effects.
assert list(r_fn.path_effects.keys()) == list(r_class.path_effects.keys())
for path in r_fn.path_effects:
@@ -489,16 +493,18 @@ def test_cluster_parameter_raises_not_implemented(self, data):
assert est_default.get_params()["cluster"] is None
# The convenience function also rejects (forward-compat gate
- # propagates through the wrapper at __init__ time)
- with pytest.raises(NotImplementedError, match=pattern):
- chaisemartin_dhaultfoeuille(
- data,
- outcome="outcome",
- group="group",
- time="period",
- treatment="treatment",
- cluster="state",
- )
+ # propagates through the wrapper at __init__ time; the wrapper
+ # deprecation warning fires first)
+ with pytest.warns(FutureWarning, match=r"chaisemartin_dhaultfoeuille\(\) is deprecated"):
+ with pytest.raises(NotImplementedError, match=pattern):
+ chaisemartin_dhaultfoeuille(
+ data,
+ outcome="outcome",
+ group="group",
+ time="period",
+ treatment="treatment",
+ cluster="state",
+ )
def test_rank_deficient_action_error_raises_on_fitted_twfe(self):
"""
diff --git a/tests/test_diagnostic_report.py b/tests/test_diagnostic_report.py
index 1748c372..14ee3708 100644
--- a/tests/test_diagnostic_report.py
+++ b/tests/test_diagnostic_report.py
@@ -31,11 +31,11 @@
DifferenceInDifferences,
EfficientDiD,
MultiPeriodDiD,
+ SyntheticControl,
SyntheticDiD,
generate_did_data,
generate_factor_data,
generate_staggered_data,
- synthetic_control,
)
from diff_diff.diagnostic_report import (
DIAGNOSTIC_REPORT_SCHEMA_VERSION,
@@ -165,17 +165,12 @@ def scm_fit():
# global filter state for later tests in the same worker.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
- df,
- "y",
- "treated",
- "unit",
- "year",
+ res = SyntheticControl(
seed=0,
n_starts=1,
optimizer_options={"maxiter": 50},
inner_min_decrease=1e-3,
- )
+ ).fit(df, "y", "treated", "unit", "year")
return res, df
@@ -293,7 +288,7 @@ def test_did_with_data_but_no_column_kwargs_skips_pt(self, did_fit):
def test_bacon_applicability_requires_all_column_kwargs(self, cs_fit):
"""Round-11 regression: Bacon needs the full ``outcome`` / ``time``
- / ``unit`` / ``first_treat`` contract from ``bacon_decompose``."""
+ / ``unit`` / ``first_treat`` contract from ``BaconDecomposition.fit``."""
fit, sdf = cs_fit
dr = DiagnosticReport(
fit,
@@ -2246,8 +2241,8 @@ def test_scm_native_marks_infeasible_placebo(self):
df = pd.DataFrame(rows)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
- df, "y", "treated", "unit", "year", seed=0, n_starts=1, inner_min_decrease=1e-3
+ res = SyntheticControl(seed=0, n_starts=1, inner_min_decrease=1e-3).fit(
+ df, "y", "treated", "unit", "year"
)
res.in_space_placebo() # only 1 donor -> infeasible
native = DiagnosticReport(res).to_dict()["estimator_native_diagnostics"]
@@ -2619,7 +2614,7 @@ class TestSurveyDesignThreading:
DR must:
* accept a ``survey_design`` kwarg;
- * thread it to ``bacon_decompose(survey_design=...)`` when the
+ * thread it to ``BaconDecomposition.fit(survey_design=...)`` when the
user supplies it;
* skip Bacon with an explicit reason when ``survey_metadata`` is
set but ``survey_design`` is not supplied;
@@ -2804,7 +2799,7 @@ def test_survey_backed_staggered_skips_bacon_without_survey_design(self):
def test_survey_backed_staggered_threads_survey_design_to_bacon(self):
"""When ``survey_design`` is supplied, Bacon applicability flips
- back to runnable and ``bacon_decompose`` is invoked with the
+ back to runnable and ``BaconDecomposition.fit`` is invoked with the
survey design. Assert via ``unittest.mock.patch`` that the
kwarg is forwarded.
"""
@@ -2830,7 +2825,12 @@ def test_survey_backed_staggered_threads_survey_design_to_bacon(self):
fake_decomp.twfe_estimate = 1.1
fake_decomp.n_timing_groups = 2
- with patch("diff_diff.bacon.bacon_decompose", return_value=fake_decomp) as m:
+ # 2(d) PR-A (M-076): the runner constructs BaconDecomposition
+ # directly, so the patch target and the assertion shape follow
+ # the class split - `weights` is a CONSTRUCTOR kwarg while
+ # `survey_design` stays a fit() kwarg.
+ with patch("diff_diff.bacon.BaconDecomposition") as m:
+ m.return_value.fit.return_value = fake_decomp
dr = DiagnosticReport(
obj,
data=panel,
@@ -2845,11 +2845,14 @@ def test_survey_backed_staggered_threads_survey_design_to_bacon(self):
bacon = dr.to_dict()["bacon"]
assert bacon["status"] == "ran"
# The survey_design must be threaded through to
- # bacon_decompose as a kwarg so the replayed decomposition
- # matches the fitted design.
- assert m.called, "bacon_decompose was not called"
- _, kwargs = m.call_args
- assert kwargs.get("survey_design") is sentinel_design
+ # BaconDecomposition.fit as a kwarg so the replayed
+ # decomposition matches the fitted design.
+ assert m.called, "BaconDecomposition was not constructed"
+ _, ctor_kwargs = m.call_args
+ assert ctor_kwargs.get("weights") == "exact"
+ assert m.return_value.fit.called, "BaconDecomposition.fit was not called"
+ _, fit_kwargs = m.return_value.fit.call_args
+ assert fit_kwargs.get("survey_design") is sentinel_design
# ---------------------------------------------------------------------------
@@ -3395,3 +3398,18 @@ def test_kappa_pre_1_default_heterogeneity_only(self):
reason = pt.get("reason", "") + " ".join(str(v) for v in pt.values() if isinstance(v, str))
assert "kappa_pre >= 2" in reason
assert "aggregate='event_study'" not in reason
+
+
+class TestEmittedGuidanceCanonicalNames:
+ """2(d) PR-A per-site pin (user decision 2026-08-06): the executive
+ summary's Bacon caveat names estimators by full class name.
+ """
+
+ def test_bacon_caveat_uses_class_names(self):
+ import inspect
+
+ import diff_diff.diagnostic_report as dr_mod
+
+ source = inspect.getsource(dr_mod)
+ assert "(CS / SA / BJS / Gardner)" not in source
+ assert "heterogeneity-robust estimator (CallawaySantAnna, SunAbraham, " in source
diff --git a/tests/test_imputation.py b/tests/test_imputation.py
index 74357579..dfbd1b8b 100644
--- a/tests/test_imputation.py
+++ b/tests/test_imputation.py
@@ -515,31 +515,34 @@ def test_repr(self):
assert "ATT=" in r
def test_convenience_function(self):
- """Test imputation_did convenience function."""
+ """KEEP (2(d) PR-A, M-070): the deprecated wrapper still works, and
+ warns - here BOTH warnings fire (wrapper + forwarded aggregate=)."""
data = generate_test_data()
- results = imputation_did(
- data,
- "outcome",
- "unit",
- "time",
- "first_treat",
- aggregate="event_study",
- )
+ with pytest.warns(FutureWarning, match=r"imputation_did\(\) is deprecated"):
+ results = imputation_did(
+ data,
+ "outcome",
+ "unit",
+ "time",
+ "first_treat",
+ aggregate="event_study",
+ )
assert isinstance(results, ImputationDiDResults)
assert results.event_study_effects is not None
def test_convenience_function_kwargs(self):
- """Test imputation_did passes kwargs to constructor."""
+ """KEEP (M-070): wrapper ctor-kwarg forwarding."""
data = generate_test_data()
- results = imputation_did(
- data,
- "outcome",
- "unit",
- "time",
- "first_treat",
- alpha=0.10,
- )
+ with pytest.warns(FutureWarning, match=r"imputation_did\(\) is deprecated"):
+ results = imputation_did(
+ data,
+ "outcome",
+ "unit",
+ "time",
+ "first_treat",
+ alpha=0.10,
+ )
assert results.alpha == 0.10
@@ -2902,26 +2905,28 @@ def test_fit_clone_idempotent_on_vcov_type(self):
def test_imputation_did_convenience_func_rejects_bad_vcov(self):
data = generate_test_data(seed=11)
- with pytest.raises(ValueError, match="influence-function"):
- imputation_did(
+ with pytest.warns(FutureWarning, match=r"imputation_did\(\) is deprecated"):
+ with pytest.raises(ValueError, match="influence-function"):
+ imputation_did(
+ data,
+ outcome="outcome",
+ unit="unit",
+ time="time",
+ first_treat="first_treat",
+ vcov_type="classical",
+ )
+
+ def test_imputation_did_convenience_func_threads_vcov_type(self):
+ data = generate_test_data(seed=11)
+ with pytest.warns(FutureWarning, match=r"imputation_did\(\) is deprecated"):
+ r = imputation_did(
data,
outcome="outcome",
unit="unit",
time="time",
first_treat="first_treat",
- vcov_type="classical",
+ vcov_type="hc1",
)
-
- def test_imputation_did_convenience_func_threads_vcov_type(self):
- data = generate_test_data(seed=11)
- r = imputation_did(
- data,
- outcome="outcome",
- unit="unit",
- time="time",
- first_treat="first_treat",
- vcov_type="hc1",
- )
assert r.vcov_type == "hc1"
diff --git a/tests/test_methodology_bacon.py b/tests/test_methodology_bacon.py
index 1a2622e5..3e89d5fa 100644
--- a/tests/test_methodology_bacon.py
+++ b/tests/test_methodology_bacon.py
@@ -43,6 +43,34 @@
bacon_decompose,
)
+
+def _bacon_fit(
+ data,
+ outcome=None,
+ unit=None,
+ time=None,
+ first_treat=None,
+ *,
+ weights="exact",
+ survey_design=None,
+ **kw,
+):
+ """Construct-and-fit via the canonical class API (2(d) PR-A, M-076).
+
+ Migrated from the deprecated ``bacon_decompose()`` wrapper: ``weights``
+ is a constructor kwarg, everything else goes to ``fit()``.
+ """
+ return BaconDecomposition(weights=weights).fit(
+ data,
+ outcome=outcome,
+ unit=unit,
+ time=time,
+ first_treat=first_treat,
+ survey_design=survey_design,
+ **kw,
+ )
+
+
# ---------------------------------------------------------------------------
# Hand-calculable DGP
# ---------------------------------------------------------------------------
@@ -148,7 +176,7 @@ class TestBaconHandCalculation:
def test_weights_sum_to_one(self) -> None:
df = _hand_calc_panel()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -162,7 +190,7 @@ def test_weights_sum_to_one(self) -> None:
def test_twfe_equals_weighted_sum(self) -> None:
"""Theorem 1's algebraic identity at machine precision."""
df = _hand_calc_panel()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -177,7 +205,7 @@ def test_twfe_equals_weighted_sum(self) -> None:
def test_three_comparison_types_present(self) -> None:
df = _hand_calc_panel()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -193,7 +221,7 @@ def test_three_comparison_types_present(self) -> None:
def test_eq_10b_treated_vs_never_value(self) -> None:
"""β̂_{2U}^{2x2} = 5 on the hand-calc panel (constant ATT, no noise)."""
df = _hand_calc_panel()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -221,7 +249,7 @@ def test_eq_7_treated_untreated_variance(self) -> None:
So s_{2U} / s_{3U} = 0.75 (= 0.3 / 0.4).
"""
df = _hand_calc_panel()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -248,7 +276,7 @@ def test_eq_8_earlier_vs_later_variance(self) -> None:
Per the hand calc: s_{23}^k = 0.006944... / 0.069444... = 0.1
"""
df = _hand_calc_panel()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -271,7 +299,7 @@ def test_eq_9_later_vs_earlier_variance(self) -> None:
Per the hand calc: s_{23}^ℓ = 0.013889... / 0.069444... = 0.2
"""
df = _hand_calc_panel()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -323,7 +351,7 @@ def test_twfe_coef_matches_r(self, golden) -> None:
if fixture_name == "meta":
continue
panel = pd.DataFrame(fix["panel"])
- results = bacon_decompose(
+ results = _bacon_fit(
panel,
outcome="y",
unit="unit",
@@ -340,7 +368,7 @@ def test_weights_sum_matches_r(self, golden) -> None:
if fixture_name == "meta":
continue
panel = pd.DataFrame(fix["panel"])
- results = bacon_decompose(
+ results = _bacon_fit(
panel,
outcome="y",
unit="unit",
@@ -409,7 +437,7 @@ def _classify_r_type(c: dict, fixture_name: str) -> str:
panel = pd.DataFrame(fix["panel"])
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
panel,
outcome="y",
unit="unit",
@@ -503,7 +531,7 @@ def test_always_treated_remapped_fold_back_matches_r(self, golden) -> None:
panel = pd.DataFrame(fix["panel"])
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
panel,
outcome="y",
unit="unit",
@@ -615,7 +643,7 @@ class TestBaconAlwaysTreatedRemap:
def test_warn_emitted_on_remap(self) -> None:
df = _panel_with_always_treated()
with pytest.warns(UserWarning, match="Remapping to U bucket"):
- bacon_decompose(
+ _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -630,7 +658,7 @@ def test_user_first_treat_column_unchanged(self) -> None:
original = df["first_treat"].copy()
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- bacon_decompose(
+ _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -644,7 +672,7 @@ def test_n_always_treated_remapped_reported(self) -> None:
df = _panel_with_always_treated()
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -690,7 +718,7 @@ def test_treated_vs_never_emitted_when_U_is_only_remapped_always_treated(
df = pd.DataFrame(rows, columns=["unit", "time", "y", "first_treat"])
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -754,7 +782,7 @@ def test_negative_first_treat_as_valid_timing_group(self) -> None:
df = pd.DataFrame(rows, columns=["unit", "time", "y", "first_treat"])
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -804,7 +832,7 @@ def test_negative_first_treat_below_min_time_remapped(self) -> None:
df = pd.DataFrame(rows, columns=["unit", "time", "y", "first_treat"])
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -833,7 +861,7 @@ def test_no_warning_on_sentinel_only_inputs(self) -> None:
with warnings.catch_warnings():
warnings.simplefilter("error", category=UserWarning)
try:
- bacon_decompose(
+ _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -859,7 +887,7 @@ def test_no_untreated_group(self) -> None:
df = _staggered_data(seed=22)
# Drop never-treated
df = df[df["first_treat"] > 0].copy()
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -890,7 +918,7 @@ def test_single_timing_group_with_never_treated(self) -> None:
rows.append((uid, t, y, 0))
uid += 1
df = pd.DataFrame(rows, columns=["unit", "time", "y", "first_treat"])
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -908,7 +936,7 @@ def test_unbalanced_panel_warns(self) -> None:
# Drop a few rows to unbalance
df = df.drop(df.sample(n=10, random_state=44).index).copy()
with pytest.warns(UserWarning, match="Unbalanced panel"):
- bacon_decompose(
+ _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -929,7 +957,7 @@ def test_unbalanced_panel_finite_but_not_machine_precision(self) -> None:
df = df.drop(df.sample(n=10, random_state=44).index).copy()
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -952,7 +980,7 @@ def test_unbalanced_panel_finite_but_not_machine_precision(self) -> None:
def test_constant_att_recovers_effect(self) -> None:
"""ΔATT=0 + VWCT=0 → β̂^DD ≈ true ATT (sample noise only)."""
df = _staggered_data(seed=55)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -967,7 +995,7 @@ def test_constant_att_recovers_effect(self) -> None:
def test_weighted_sum_machine_precision(self) -> None:
"""The TWFE-vs-weighted-sum identity holds on noisy data too."""
df = _staggered_data(seed=66)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -1010,7 +1038,7 @@ def test_exact_vs_approximate_differ_meaningfully(self) -> None:
"""The two modes produce different relative weights (the approximate
path uses a simplified variance not matching Eqs. 7-9)."""
df = _staggered_data(seed=88)
- r_exact = bacon_decompose(
+ r_exact = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -1018,7 +1046,7 @@ def test_exact_vs_approximate_differ_meaningfully(self) -> None:
first_treat="first_treat",
weights="exact",
)
- r_approx = bacon_decompose(
+ r_approx = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -1061,7 +1089,7 @@ def test_survey_design_compatible_with_exact_mode(self) -> None:
unit_w = df.groupby("unit").ngroup() * 0.1 + 1.0
df = df.assign(w=unit_w.values)
sd = SurveyDesign(weights="w")
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -1085,7 +1113,7 @@ def test_survey_design_propagates_through_remap(self) -> None:
sd = SurveyDesign(weights="w")
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=UserWarning)
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
@@ -1136,19 +1164,22 @@ def test_default_bacon_decomposition_class_rejects_time_varying_weights(
def test_default_bacon_decompose_function_rejects_time_varying_weights(
self,
) -> None:
- """The new ``weights="exact"`` default flows through
- ``bacon_decompose(...)`` (no explicit weights= kwarg). Same
- rejection contract."""
+ """KEEP (2(d) PR-A, M-076): the ``weights="exact"`` default flows
+ through the DEPRECATED ``bacon_decompose(...)`` wrapper (no
+ explicit weights= kwarg) - only the wrapper path exercises that
+ default routing. Same rejection contract, now behind the
+ wrapper-deprecation FutureWarning."""
df, sd = self._time_varying_survey_panel()
- with pytest.raises(ValueError, match="varies within units"):
- bacon_decompose(
- df,
- outcome="y",
- unit="unit",
- time="time",
- first_treat="first_treat",
- survey_design=sd,
- )
+ with pytest.warns(FutureWarning, match="bacon_decompose\\(\\) is deprecated"):
+ with pytest.raises(ValueError, match="varies within units"):
+ bacon_decompose(
+ df,
+ outcome="y",
+ unit="unit",
+ time="time",
+ first_treat="first_treat",
+ survey_design=sd,
+ )
def test_explicit_approximate_accepts_time_varying_weights(self) -> None:
"""Users can opt back into the obs-level weighted-means path via
@@ -1158,7 +1189,7 @@ def test_explicit_approximate_accepts_time_varying_weights(self) -> None:
df, sd = self._time_varying_survey_panel()
# Should not raise; produces a valid decomposition via the legacy
# approximate path that tolerates obs-level weighted means.
- results = bacon_decompose(
+ results = _bacon_fit(
df,
outcome="y",
unit="unit",
diff --git a/tests/test_methodology_synthetic_control.py b/tests/test_methodology_synthetic_control.py
index 8024ec9e..093eae3f 100644
--- a/tests/test_methodology_synthetic_control.py
+++ b/tests/test_methodology_synthetic_control.py
@@ -36,7 +36,6 @@
DiagnosticReport,
SyntheticControl,
SyntheticControlResults,
- synthetic_control,
)
from diff_diff.conformal import (
_block_collapse,
@@ -96,6 +95,20 @@
_FAST_CHURN = dict(n_starts=1, optimizer_options={"maxiter": 5})
+def _synth_fit(data, outcome, treatment, unit, time, **kwargs):
+ """Construct-and-fit via the canonical class API (2(d) PR-A, M-074).
+
+ Migrated from the deprecated ``synthetic_control()`` wrapper: kwargs
+ are routed to ``SyntheticControl.__init__`` or ``fit()`` by
+ signature, exactly the class-API construction every call site now
+ exercises.
+ """
+ ctor_keys = set(SyntheticControl().get_params().keys())
+ ctor_kwargs = {k: v for k, v in kwargs.items() if k in ctor_keys}
+ fit_kwargs = {k: v for k, v in kwargs.items() if k not in ctor_keys}
+ return SyntheticControl(**ctor_kwargs).fit(data, outcome, treatment, unit, time, **fit_kwargs)
+
+
# ---------------------------------------------------------------------------
# Synthetic panel builders (fast; no R needed)
# ---------------------------------------------------------------------------
@@ -217,7 +230,7 @@ def test_non_binary_treatment_rejected():
df = df.copy()
df.loc[(df["unit"] == "d0") & (df["year"] == years[0]), "treated"] = 2
with pytest.raises(ValueError, match="binary"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0)
def test_missing_treatment_value_rejected():
@@ -227,7 +240,7 @@ def test_missing_treatment_value_rejected():
df = df.copy()
df.loc[(df["unit"] == "d0") & (df["year"] == years[0]), "treated"] = np.nan
with pytest.raises(ValueError, match="missing"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0)
def test_estimators_module_reexport():
@@ -246,12 +259,8 @@ def test_post_periods_canonicalized_and_gap_order_independent():
df, years, T0 = _make_panel()
ordered = years[T0:]
scrambled = list(reversed(ordered)) + [ordered[-1]] # unsorted + duplicate
- r1 = synthetic_control(
- df, "y", "treated", "unit", "year", post_periods=ordered, seed=0, **_FAST
- )
- r2 = synthetic_control(
- df, "y", "treated", "unit", "year", post_periods=scrambled, seed=0, **_FAST
- )
+ r1 = _synth_fit(df, "y", "treated", "unit", "year", post_periods=ordered, seed=0, **_FAST)
+ r2 = _synth_fit(df, "y", "treated", "unit", "year", post_periods=scrambled, seed=0, **_FAST)
assert r1.post_periods == r2.post_periods == ordered
assert abs(r1.att - r2.att) < 1e-12
gdf = r2.get_gap_df()
@@ -267,9 +276,7 @@ def test_post_periods_canonicalized_and_gap_order_independent():
def test_donor_pool_restricts_donors():
df, years, T0 = _make_panel(n_donors=4)
- res = synthetic_control(
- df, "y", "treated", "unit", "year", donor_pool=["d0", "d1"], seed=0, **_FAST
- )
+ res = _synth_fit(df, "y", "treated", "unit", "year", donor_pool=["d0", "d1"], seed=0, **_FAST)
assert res.n_donors == 2
assert set(res.get_weights_df()["unit"]) <= {"d0", "d1"}
@@ -278,7 +285,7 @@ def test_contaminated_donor_pool_rejected():
df, years, T0 = _make_panel()
# The treated unit itself must never appear in the donor pool.
with pytest.raises(ValueError, match="treated unit|ever-treated|never-treated"):
- synthetic_control(df, "y", "treated", "unit", "year", donor_pool=["d0", "treated"], seed=0)
+ _synth_fit(df, "y", "treated", "unit", "year", donor_pool=["d0", "treated"], seed=0)
def test_ever_treated_donor_rejected():
@@ -287,7 +294,7 @@ def test_ever_treated_donor_rejected():
df = df.copy()
df.loc[(df["unit"] == "d0") & (df["year"] >= years[T0]), "treated"] = 1
with pytest.raises(ValueError, match="ever-treated|never-treated"):
- synthetic_control(
+ _synth_fit(
df,
"y",
"treated",
@@ -367,7 +374,7 @@ def test_outer_v_nonconvergence_warning():
# maxiter=1 forces the OUTER non-convergence; n_starts=1 + a loose inner tolerance
# keep the (still-real) inner solves cheap. Loosening inner_min_decrease does not
# affect whether the outer optimizer hits its 1-iteration cap.
- synthetic_control(
+ _synth_fit(
df,
"y",
"treated",
@@ -385,9 +392,7 @@ def test_inner_v_search_nonconvergence_warning():
# inner_max_iter=1 makes them truncate, and the estimator emits an aggregated warning.
df, _, _ = _make_panel()
with pytest.warns(UserWarning, match="during nested V selection"):
- synthetic_control(
- df, "y", "treated", "unit", "year", seed=0, inner_max_iter=1, **_FAST_CHURN
- )
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0, inner_max_iter=1, **_FAST_CHURN)
def test_single_inner_nonconvergence_excluded_from_v_ranking(monkeypatch):
@@ -416,7 +421,7 @@ def patched(X1s, X0s, v, max_iter, min_decrease):
monkeypatch.setattr(sc, "_inner_solve_W", patched)
with pytest.warns(UserWarning, match="during nested V selection"):
- res = synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ res = _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
assert state["failed"] # the patch actually fired on an objective evaluation
assert np.isfinite(res.att)
@@ -429,7 +434,7 @@ def test_n_starts_one_runs():
# n_starts=1 uses only the uniform start (short-circuits the heuristic candidates)
# and still produces a valid nested fit.
df, _, _ = _make_panel()
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -453,7 +458,7 @@ def test_nested_production_defaults_smoke():
# this stays non-slow. The @slow Tier-2 Basque test covers the defaults only in the Rust
# matrix; this is the pure-Python complement.
df, _, _ = _make_panel(n_donors=2)
- res = synthetic_control(df, "y", "treated", "unit", "year", seed=0) # production defaults
+ res = _synth_fit(df, "y", "treated", "unit", "year", seed=0) # production defaults
assert np.isfinite(res.att)
assert abs(sum(res.donor_weights.values()) - 1.0) < 1e-6
assert res.n_donors == 2
@@ -465,7 +470,7 @@ def test_non_finite_outcome_rejected():
df = df.copy()
df.loc[(df["unit"] == "d1") & (df["year"] == years[2]), "y"] = np.nan
with pytest.raises(ValueError, match="non-finite"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0)
def test_distinct_special_period_sets_not_duplicate():
@@ -508,7 +513,7 @@ def test_duplicate_predictor_window_periods_deduped():
# A repeated period in predictor_window must not re-weight the mean: the
# deduped window [y0,y0,y1] matches the explicit [y0,y1].
df, years, T0 = _make_panel()
- r_dup = synthetic_control(
+ r_dup = _synth_fit(
df,
"y",
"treated",
@@ -519,7 +524,7 @@ def test_duplicate_predictor_window_periods_deduped():
seed=0,
**_FAST,
)
- r_uniq = synthetic_control(
+ r_uniq = _synth_fit(
df,
"y",
"treated",
@@ -561,7 +566,7 @@ def test_poor_fit_warning():
rows.append({"unit": "treated", "year": yr, "y": 50 + 2.0 * t, "treated": int(t >= T0)})
df = pd.DataFrame(rows)
with pytest.warns(UserWarning, match="Pre-treatment fit is poor"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
def test_poor_fit_warning_flat_treated_pre_path():
@@ -580,7 +585,7 @@ def test_poor_fit_warning_flat_treated_pre_path():
)
df = pd.DataFrame(rows)
with pytest.warns(UserWarning, match="Pre-treatment fit is poor"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
# ---------------------------------------------------------------------------
@@ -628,7 +633,7 @@ def test_inner_nonconvergence_warning():
def test_standardize_none_runs():
df, _, _ = _make_panel()
- res = synthetic_control(df, "y", "treated", "unit", "year", standardize="none", seed=0, **_FAST)
+ res = _synth_fit(df, "y", "treated", "unit", "year", standardize="none", seed=0, **_FAST)
assert res.standardize == "none"
assert np.isfinite(res.att)
@@ -670,7 +675,7 @@ def test_custom_v_wrong_length_rejected():
def test_single_donor_degenerate_warns():
df, _, _ = _make_panel(n_donors=1)
with pytest.warns(UserWarning, match="single donor"):
- res = synthetic_control(df, "y", "treated", "unit", "year", seed=0)
+ res = _synth_fit(df, "y", "treated", "unit", "year", seed=0)
assert res.n_donors == 1
assert abs(sum(res.donor_weights.values()) - 1.0) < 1e-9
@@ -686,7 +691,7 @@ def test_no_pre_period_rejected():
rows.append({"unit": "treated", "year": yr, "y": 12.0 + yr, "treated": 1})
df = pd.DataFrame(rows)
with pytest.raises(ValueError, match="No pre-treatment periods|Cannot infer"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0)
def test_single_pre_period_nested_warns():
@@ -700,7 +705,7 @@ def test_single_pre_period_nested_warns():
rows.append({"unit": "treated", "year": yr, "y": 11.0 + i, "treated": int(i >= 1)})
df = pd.DataFrame(rows)
with pytest.warns(UserWarning, match="single pre period"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0)
def test_multiple_treated_units_rejected():
@@ -708,7 +713,7 @@ def test_multiple_treated_units_rejected():
df = df.copy()
df.loc[(df["unit"] == "d0") & (df["year"] >= 2006), "treated"] = 1
with pytest.raises(ValueError, match="exactly one"):
- synthetic_control(df, "y", "treated", "unit", "year", seed=0)
+ _synth_fit(df, "y", "treated", "unit", "year", seed=0)
# ---------------------------------------------------------------------------
@@ -749,7 +754,7 @@ def test_set_params_rolls_back_on_invalid():
def test_nan_inference_contract():
df, _, _ = _make_panel()
- res = synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ res = _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
assert_nan_inference(
{"se": res.se, "t_stat": res.t_stat, "p_value": res.p_value, "conf_int": res.conf_int}
)
@@ -758,7 +763,7 @@ def test_nan_inference_contract():
def test_result_accessors_render():
df, _, _ = _make_panel()
- res = synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ res = _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
assert isinstance(res, SyntheticControlResults)
assert isinstance(res.summary(), str) and "Synthetic Control" in res.summary()
assert "att" in res.to_dict()
@@ -778,10 +783,8 @@ def test_result_accessors_render():
def test_inferred_post_matches_explicit():
df, years, T0 = _make_panel()
- r_inf = synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
- r_exp = synthetic_control(
- df, "y", "treated", "unit", "year", post_periods=years[T0:], seed=0, **_FAST
- )
+ r_inf = _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ r_exp = _synth_fit(df, "y", "treated", "unit", "year", post_periods=years[T0:], seed=0, **_FAST)
assert r_inf.post_periods == r_exp.post_periods == years[T0:]
assert abs(r_inf.att - r_exp.att) < 1e-12
@@ -909,7 +912,7 @@ def _fit_for_placebo(n_donors=4, effect=3.0, **kw):
opts.update(kw)
with warnings.catch_warnings(): # single-donor / poor-fit fit warnings are not under test
warnings.simplefilter("ignore")
- return synthetic_control(df, "y", "treated", "unit", "year", seed=0, **opts)
+ return _synth_fit(df, "y", "treated", "unit", "year", seed=0, **opts)
def test_in_space_placebo_strong_effect_ranks_treated_first():
@@ -1002,7 +1005,7 @@ def test_in_space_placebo_fails_closed_on_nonconverged_treated_fit():
df, _, _ = _make_panel(n_donors=4, effect=3.0)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1070,7 +1073,7 @@ def test_in_space_placebo_custom_v_path():
k = 6
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1131,7 +1134,7 @@ def test_in_space_placebo_perfect_treated_fit_finite_ratio():
df = pd.DataFrame(rows)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1154,7 +1157,7 @@ def test_in_space_placebo_immune_to_post_fit_mutation():
opts = {"maxiter": 50}
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1220,7 +1223,7 @@ def test_in_space_placebo_fails_closed_on_underoptimized_outer_v():
df, _, _ = _make_panel(n_donors=4, effect=3.0)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1265,7 +1268,7 @@ def fake_minimize(fun, x0, **kwargs):
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df, "y", "treated", "unit", "year", seed=0, n_starts=2, inner_min_decrease=1e-3
)
# The winning incumbent came from a success=False run -> selected V is not a
@@ -1304,7 +1307,7 @@ def fake_minimize(fun, x0, **kwargs):
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df, "y", "treated", "unit", "year", seed=0, n_starts=1, inner_min_decrease=1e-3
)
# Powell's success at a worse point must NOT flip the selected incumbent to converged.
@@ -1336,7 +1339,7 @@ def test_summary_distinguishes_infeasible_placebo_from_not_run():
df, _, _ = _make_panel(n_donors=1)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ res = _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
before = res.summary()
res.in_space_placebo() # infeasible: single donor -> no placebo distribution
after = res.summary()
@@ -1356,7 +1359,7 @@ def test_summary_treated_fit_failure_names_specific_reason():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1458,7 +1461,7 @@ def _single_donor_panel(n_donors=4, T=8, T0=6, effect=3.0, seed=2):
def _fit_cheap(df):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- return synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ return _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
_LOO_COLS = ["dropped_unit", "att", "pre_rmspe", "post_rmspe", "rmspe_ratio", "delta_att", "status"]
@@ -1544,7 +1547,7 @@ def test_leave_one_out_fails_closed_on_nonconverged_treated_fit():
df, _, _ = _make_panel(n_donors=4, effect=3.0)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df, "y", "treated", "unit", "year", seed=0, inner_max_iter=1, **_FAST_CHURN
)
assert res._fit_converged is False
@@ -1674,7 +1677,7 @@ def test_truncate_snapshot_custom_v_lockstep():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1698,7 +1701,7 @@ def test_truncate_snapshot_straddling_window_partial_keep():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1731,7 +1734,7 @@ def test_truncate_snapshot_infeasible_all_specs_dropped():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1888,7 +1891,7 @@ def test_in_time_placebo_custom_v_zero_mass_is_infeasible_not_failed():
v = np.array([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]) # all mass on the 2003/2004/2005 lags
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1938,7 +1941,7 @@ def test_in_time_placebo_windowed_covariate_dropped_and_warns():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1959,7 +1962,7 @@ def test_in_time_placebo_all_specs_dropped_infeasible():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -1981,7 +1984,7 @@ def test_in_time_placebo_custom_v_runs_without_shape_error():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -2004,7 +2007,7 @@ def test_in_time_placebo_accepts_2d_custom_v():
v2d = v1d.reshape(1, 6) # row-vector form accepted at fit time
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res1 = synthetic_control(
+ res1 = _synth_fit(
df,
"y",
"treated",
@@ -2014,7 +2017,7 @@ def test_in_time_placebo_accepts_2d_custom_v():
custom_v=v1d,
inner_min_decrease=1e-3,
)
- res2 = synthetic_control(
+ res2 = _synth_fit(
df,
"y",
"treated",
@@ -2042,7 +2045,7 @@ def test_in_time_placebo_fails_closed_on_nonconverged_treated_fit():
df, _, _ = _make_panel(n_donors=4, effect=3.0)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df, "y", "treated", "unit", "year", seed=0, inner_max_iter=1, **_FAST_CHURN
)
assert res._fit_converged is False
@@ -2117,7 +2120,7 @@ def test_leave_one_out_matches_fresh_reduced_pool_fit():
v = np.arange(1.0, 7.0) # k = 6 default lag predictors; fixed V -> deterministic
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -2132,7 +2135,7 @@ def test_leave_one_out_matches_fresh_reduced_pool_fit():
d = [x for x in donor_ids if x in res.donor_weights][0] # a positively-weighted donor
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- fresh = synthetic_control(
+ fresh = _synth_fit(
df,
"y",
"treated",
@@ -2152,7 +2155,7 @@ def test_in_time_placebo_matches_fresh_backdated_fit():
v = np.arange(1.0, 7.0)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -2170,7 +2173,7 @@ def test_in_time_placebo_matches_fresh_backdated_fit():
back["treated"] = ((back["unit"] == "treated") & (back["year"] >= 2003)).astype(int)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- fresh = synthetic_control(
+ fresh = _synth_fit(
back,
"y",
"treated",
@@ -2463,8 +2466,8 @@ def test_inverse_variance_fit_is_deterministic_and_searchless():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- r1 = synthetic_control(df, "y", "treated", "unit", "year", v_method="inverse_variance")
- r2 = synthetic_control(df, "y", "treated", "unit", "year", v_method="inverse_variance")
+ r1 = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance")
+ r2 = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance")
assert r1.mspe_v is None # no outer search ran
assert r1.att == r2.att # fully deterministic (no rng)
assert r1.donor_weights == r2.donor_weights
@@ -2479,7 +2482,7 @@ def test_inverse_variance_weights_equal_inverse_row_variance():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(df, "y", "treated", "unit", "year", v_method="inverse_variance")
+ res = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance")
snap = res._fit_snapshot
X1, X0, labels = sc._build_predictor_matrix(
snap.pivots, snap.specs, snap.treated_id, snap.donor_ids
@@ -2537,14 +2540,14 @@ def test_inverse_variance_matches_paper_objective():
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(df, "y", "treated", "unit", "year", v_method="inverse_variance")
+ res = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance")
snap = res._fit_snapshot
X1, X0, labels = sc._build_predictor_matrix(
snap.pivots, snap.specs, snap.treated_id, snap.donor_ids
)
k = X1.shape[0]
v_iv = sc._inverse_variance_v(X1, X0)
- res_uniform_std = synthetic_control(
+ res_uniform_std = _synth_fit(
df,
"y",
"treated",
@@ -2554,7 +2557,7 @@ def test_inverse_variance_matches_paper_objective():
custom_v=np.ones(k),
standardize="std",
)
- res_invvar_none = synthetic_control(
+ res_invvar_none = _synth_fit(
df,
"y",
"treated",
@@ -2573,7 +2576,7 @@ def test_inverse_variance_matches_paper_objective():
# a DIFFERENT result, so this test actually discriminates the fix.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res_double = synthetic_control(
+ res_double = _synth_fit(
df,
"y",
"treated",
@@ -2621,7 +2624,7 @@ def test_inverse_variance_zero_variance_row_gets_zero_weight():
df, years = _panel_with_constant_lag(constant_years={2001})
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(df, "y", "treated", "unit", "year", v_method="inverse_variance")
+ res = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance")
assert res.v_weights["y_2001"] == pytest.approx(0.0, abs=1e-12)
assert sum(res.v_weights.values()) == pytest.approx(1.0, abs=1e-9)
assert any(v > 0 for k, v in res.v_weights.items() if k != "y_2001")
@@ -2635,7 +2638,7 @@ def test_inverse_variance_all_zero_variance_falls_back_to_uniform():
warnings.simplefilter("ignore")
warnings.simplefilter("always", UserWarning)
with pytest.warns(UserWarning, match="no usable predictor variance"):
- res = synthetic_control(df, "y", "treated", "unit", "year", v_method="inverse_variance")
+ res = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance")
vals = list(res.v_weights.values())
assert np.allclose(vals, 1.0 / len(vals))
@@ -2647,7 +2650,7 @@ def test_inverse_variance_single_donor_returns_uniform_v():
# inert here). The fit warns rather than silently relabeling.
df, _, _ = _make_panel(n_donors=1)
with pytest.warns(UserWarning, match="uniform regardless of v_method"):
- res = synthetic_control(df, "y", "treated", "unit", "year", v_method="inverse_variance")
+ res = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance")
assert res.n_donors == 1
assert abs(sum(res.donor_weights.values()) - 1.0) < 1e-9
vw = list(res.v_weights.values())
@@ -2697,16 +2700,14 @@ def test_inverse_variance_in_time_placebo_matches_fresh_backdated_fit():
df, _, _ = _make_panel(n_donors=4) # pre = 2000..2005 (default per-period lags)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
- df, "y", "treated", "unit", "year", v_method="inverse_variance", **_FAST
- )
+ res = _synth_fit(df, "y", "treated", "unit", "year", v_method="inverse_variance", **_FAST)
itp = res.in_time_placebo([2004]) # pre-fake = {2000..2003}
placebo_att = itp.loc[itp["placebo_period"] == 2004, "placebo_att"].iloc[0]
back = df[df["year"] <= 2005].copy()
back["treated"] = ((back["unit"] == "treated") & (back["year"] >= 2004)).astype(int)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- fresh = synthetic_control(
+ fresh = _synth_fit(
back, "y", "treated", "unit", "year", v_method="inverse_variance", **_FAST
)
assert placebo_att == pytest.approx(fresh.att, abs=1e-7)
@@ -2728,7 +2729,7 @@ def _fit_cv(df, *, specs=_CV_SPANNING, **kw):
opts.update(kw)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- return synthetic_control(
+ return _synth_fit(
df, "y", "treated", "unit", "year", v_method="cv", special_predictors=specs, **opts
)
@@ -2740,7 +2741,7 @@ def test_cv_rejects_non_spanning_predictors():
# with guidance to pass spanning predictors.
df, _, _ = _make_panel(n_donors=4)
with pytest.raises(ValueError, match="span BOTH the training"):
- synthetic_control(df, "y", "treated", "unit", "year", v_method="cv", seed=0, **_FAST)
+ _synth_fit(df, "y", "treated", "unit", "year", v_method="cv", seed=0, **_FAST)
def test_cv_runs_and_reports_validation_mspe():
@@ -2766,7 +2767,7 @@ def test_cv_t0_out_of_range_raises():
# The t0-range check fires before the predictor-precondition check.
df, _, _ = _make_panel(n_donors=4) # 6 pre periods -> valid 1..5
with pytest.raises(ValueError, match="out of range"):
- synthetic_control(
+ _synth_fit(
df,
"y",
"treated",
@@ -2792,7 +2793,7 @@ def test_cv_requires_two_pre_periods():
rows.append({"unit": "treated", "year": yr, "y": 11.0 + i, "treated": int(i >= 1)})
df = pd.DataFrame(rows)
with pytest.raises(ValueError, match="requires at least 2 pre-treatment periods"):
- synthetic_control(df, "y", "treated", "unit", "year", v_method="cv", **_FAST)
+ _synth_fit(df, "y", "treated", "unit", "year", v_method="cv", **_FAST)
def test_cv_single_donor_validates_and_surfaces_v_cv_t0():
@@ -2802,7 +2803,7 @@ def test_cv_single_donor_validates_and_surfaces_v_cv_t0():
# predictors keep the cv precondition satisfied so we exercise the J==1 path itself.
df, _, _ = _make_panel(n_donors=1) # 6 pre periods 2000-2005
with pytest.raises(ValueError, match="out of range"):
- synthetic_control(
+ _synth_fit(
df,
"y",
"treated",
@@ -2815,7 +2816,7 @@ def test_cv_single_donor_validates_and_surfaces_v_cv_t0():
)
with warnings.catch_warnings():
warnings.simplefilter("ignore") # degenerate single-donor warning
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -2874,7 +2875,7 @@ def test_cv_reaggregation_matches_custom_v_per_window_steps():
assert all(tp != vp for (_, tp, _), (_, vp, _) in zip(train_reagg, val_reagg))
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- fin = synthetic_control(
+ fin = _synth_fit(
df,
"y",
"treated",
@@ -2885,7 +2886,7 @@ def test_cv_reaggregation_matches_custom_v_per_window_steps():
custom_v=v_star,
inner_min_decrease=1e-3,
)
- tr = synthetic_control(
+ tr = _synth_fit(
df,
"y",
"treated",
@@ -2965,7 +2966,7 @@ def test_cv_in_time_placebo_matches_fresh_backdated_fit():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
# backdated pre-fake = {2000..2003}; the spanning specs truncated to that window.
- fresh = synthetic_control(
+ fresh = _synth_fit(
back,
"y",
"treated",
@@ -2990,7 +2991,7 @@ def test_cv_v_cv_t0_surfaced_on_results_and_serialized():
res_explicit = _fit_cv(df, v_cv_t0=2, seed=0)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res_nested = synthetic_control(df, "y", "treated", "unit", "year", seed=0, **_FAST)
+ res_nested = _synth_fit(df, "y", "treated", "unit", "year", seed=0, **_FAST)
# Resolved value: None constructor -> len(pre)//2 = 3; explicit -> 2; non-cv -> None.
assert res_default.v_cv_t0 == 3
assert res_explicit.v_cv_t0 == 2
@@ -3030,7 +3031,7 @@ def test_cv_in_time_placebo_empty_window_is_infeasible_not_failed():
df = pd.DataFrame(rows)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -3159,7 +3160,7 @@ def test_cv_fails_closed_when_training_solve_truncates(specs):
df, _, _ = _make_panel(n_donors=4)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- res = synthetic_control(
+ res = _synth_fit(
df,
"y",
"treated",
@@ -3208,7 +3209,7 @@ def spy(*a, **k):
reduced = df[df["unit"] != dropped].copy()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- fresh = synthetic_control(
+ fresh = _synth_fit(
reduced,
"y",
"treated",
diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py
index 717a1d75..73ea2901 100644
--- a/tests/test_practitioner.py
+++ b/tests/test_practitioner.py
@@ -1728,3 +1728,90 @@ def test_bootstrap_warnings_accept_numpy_scalars(self):
r0 = _mock_cic(att=0.5, estimator="cic", covariates=None, n_bootstrap=np.int64(0))
output0 = practitioner_next_steps(r0, verbose=False)
assert any("n_bootstrap=0" in w for w in output0["warnings"])
+
+
+class TestEmittedGuidanceCanonicalNames:
+ """2(d) PR-A per-site pins: emitted recommendation strings use full
+ class names (user decision 2026-08-06); the ``_ESTIMATOR_NAMES``
+ display map keeps kept-alias parentheticals but drops the DYING
+ Gardner one. Per-site assertions only - method/citation prose
+ (e.g. "Gardner 2022", "Stacked DiD") is deliberately untouched, so
+ no module-wide alias ban is asserted.
+ """
+
+ def test_compare_step_alternatives_use_class_names(self):
+ # Every _robustness_compare_step call site was migrated to full
+ # class names; the constructor embeds `alternatives` verbatim in
+ # the emitted label and code payloads.
+ import inspect
+
+ import diff_diff.practitioner as practitioner_mod
+
+ source = inspect.getsource(practitioner_mod)
+ migrated = [
+ '_robustness_compare_step("CallawaySantAnna, SunAbraham, or ImputationDiD")',
+ '_robustness_compare_step("SunAbraham, ImputationDiD, or TwoStageDiD")',
+ '_robustness_compare_step("CallawaySantAnna, ImputationDiD, or TwoStageDiD")',
+ '_robustness_compare_step("CallawaySantAnna, SunAbraham, or TwoStageDiD")',
+ '_robustness_compare_step("CallawaySantAnna, ImputationDiD, or SunAbraham")',
+ '_robustness_compare_step("SyntheticDiD or CallawaySantAnna")',
+ ]
+ for call in migrated:
+ assert call in source, f"missing migrated compare-step call: {call}"
+ # The old shorthand rosters are gone from compare-step calls.
+ import re
+
+ for stale in re.findall(r"_robustness_compare_step\(\"([^\"]+)\"\)", source):
+ for tok in ("CS", "SA", "BJS", "Gardner"):
+ assert not re.search(
+ rf"\b{tok}\b", stale
+ ), f"compare-step roster still uses alias shorthand: {stale!r}"
+
+ def test_emitted_compare_step_label_uses_class_names(self):
+ # Behavioral arm: the constructed step embeds the roster in the
+ # emitted label/code strings.
+ from diff_diff.practitioner import _robustness_compare_step
+
+ step = _robustness_compare_step("CallawaySantAnna, SunAbraham, or ImputationDiD")
+ assert "CallawaySantAnna, SunAbraham, or ImputationDiD" in step["label"]
+ assert "CallawaySantAnna, SunAbraham, or ImputationDiD" in step["code"]
+
+ def test_falsification_why_payloads_use_class_names(self):
+ import inspect
+
+ import diff_diff.practitioner as practitioner_mod
+
+ source = inspect.getsource(practitioner_mod)
+ assert "compare with CS/SA as" not in source
+ assert source.count("compare with CallawaySantAnna/") == 2 # BJS + Gardner handler twins
+ assert "Use CS, SA, BJS, or another" not in source
+ assert "Use CallawaySantAnna, SunAbraham, " in source
+
+ def test_staggered_comparison_label_uses_class_names(self):
+ import inspect
+
+ import diff_diff.practitioner as practitioner_mod
+
+ source = inspect.getsource(practitioner_mod)
+ assert 'label="Compare with staggered estimators (CallawaySantAnna, SunAbraham)"' in source
+ assert "(CS, SA)" not in source
+
+ def test_estimator_display_map_drops_dying_gardner_parenthetical(self):
+ # Tier (ii): the display map keeps KEPT-alias parentheticals
+ # (DDD/HAD/CiC - pinned verbatim elsewhere) but carries no DYING
+ # alias token.
+ import re
+
+ from diff_diff.practitioner import _ESTIMATOR_NAMES
+
+ assert _ESTIMATOR_NAMES["TwoStageDiDResults"] == "TwoStageDiD"
+ for value in _ESTIMATOR_NAMES.values():
+ assert "Gardner" not in value, value
+ assert "CDiD" not in value, value
+ # `Stacked` only as part of StackedDiD (no "(Stacked)" parenthetical).
+ assert not re.search(r"\bStacked\b(?! ?DiD)", value.replace("StackedDiD", "")), value
+ # Kept parentheticals stay (deliberate kept-alias documentation).
+ assert _ESTIMATOR_NAMES["TripleDifferenceResults"] == "TripleDifference (DDD)"
+ assert _ESTIMATOR_NAMES["HeterogeneousAdoptionDiDResults"] == (
+ "HeterogeneousAdoptionDiD (HAD)"
+ )
diff --git a/tests/test_stacked_did.py b/tests/test_stacked_did.py
index 25ac9eb8..b74cbfdd 100644
--- a/tests/test_stacked_did.py
+++ b/tests/test_stacked_did.py
@@ -729,16 +729,17 @@ def test_set_params_unknown_raises(self):
est.set_params(nonexistent_param=42)
def test_convenience_function(self, staggered_data):
- """stacked_did() convenience function works."""
- results = stacked_did(
- staggered_data,
- outcome="outcome",
- unit="unit",
- time="period",
- first_treat="first_treat",
- kappa_pre=2,
- kappa_post=2,
- )
+ """KEEP (2(d) PR-A, M-072): the deprecated wrapper still works, and warns."""
+ with pytest.warns(FutureWarning, match=r"stacked_did\(\) is deprecated"):
+ results = stacked_did(
+ staggered_data,
+ outcome="outcome",
+ unit="unit",
+ time="period",
+ first_treat="first_treat",
+ kappa_pre=2,
+ kappa_post=2,
+ )
assert isinstance(results, StackedDiDResults)
assert np.isfinite(results.overall_att)
@@ -1903,19 +1904,21 @@ def test_get_params_includes_balance_and_clone(self):
assert clone.balance == "entropy"
def test_convenience_function_threads_covariates(self):
+ """KEEP (M-072): wrapper ctor/fit kwarg threading (balance + covariates)."""
df = _balance_panel(seed=9)
- res = stacked_did(
- df,
- "y",
- "unit",
- "time",
- "first_treat",
- kappa_pre=2,
- kappa_post=2,
- control_group="never_treated",
- balance="entropy",
- covariates=["x"],
- )
+ with pytest.warns(FutureWarning, match=r"stacked_did\(\) is deprecated"):
+ res = stacked_did(
+ df,
+ "y",
+ "unit",
+ "time",
+ "first_treat",
+ kappa_pre=2,
+ kappa_post=2,
+ control_group="never_treated",
+ balance="entropy",
+ covariates=["x"],
+ )
assert res.balance == "entropy"
assert res.covariates == ["x"]
diff --git a/tests/test_survey_phase3.py b/tests/test_survey_phase3.py
index eaa7f370..a3b1c8ce 100644
--- a/tests/test_survey_phase3.py
+++ b/tests/test_survey_phase3.py
@@ -329,14 +329,15 @@ def test_convenience_function(self, staggered_survey_data):
from diff_diff.stacked_did import stacked_did
sd = SurveyDesign(weights="weight")
- result = stacked_did(
- staggered_survey_data,
- "outcome",
- "unit",
- "time",
- "first_treat",
- survey_design=sd,
- )
+ with pytest.warns(FutureWarning, match=r"stacked_did\(\) is deprecated"):
+ result = stacked_did(
+ staggered_survey_data,
+ "outcome",
+ "unit",
+ "time",
+ "first_treat",
+ survey_design=sd,
+ )
assert result.survey_metadata is not None
def test_summary_includes_survey(self, staggered_survey_data):
@@ -486,14 +487,15 @@ def test_convenience_function(self, staggered_survey_data):
from diff_diff.bacon import bacon_decompose
sd = SurveyDesign(weights="weight")
- result = bacon_decompose(
- staggered_survey_data,
- "outcome",
- "unit",
- "time",
- "first_treat",
- survey_design=sd,
- )
+ with pytest.warns(FutureWarning, match=r"bacon_decompose\(\) is deprecated"):
+ result = bacon_decompose(
+ staggered_survey_data,
+ "outcome",
+ "unit",
+ "time",
+ "first_treat",
+ survey_design=sd,
+ )
assert result.survey_metadata is not None
@@ -633,15 +635,16 @@ def test_convenience_function(self, ddd_survey_data):
from diff_diff.triple_diff import triple_difference
sd = SurveyDesign(weights="weight")
- result = triple_difference(
- ddd_survey_data,
- "outcome",
- "group",
- "partition",
- "time",
- estimation_method="reg",
- survey_design=sd,
- )
+ with pytest.warns(FutureWarning, match=r"triple_difference\(\) is deprecated"):
+ result = triple_difference(
+ ddd_survey_data,
+ "outcome",
+ "group",
+ "partition",
+ "time",
+ estimation_method="reg",
+ survey_design=sd,
+ )
assert result.survey_metadata is not None
diff --git a/tests/test_survey_phase4.py b/tests/test_survey_phase4.py
index 715e0e59..67755b3c 100644
--- a/tests/test_survey_phase4.py
+++ b/tests/test_survey_phase4.py
@@ -457,14 +457,15 @@ def test_wrapper_imputation_did_with_survey(self, staggered_survey_data):
from diff_diff import imputation_did
sd = SurveyDesign(weights="weight")
- r_wrapper = imputation_did(
- staggered_survey_data,
- "outcome",
- "unit",
- "period",
- "first_treat",
- survey_design=sd,
- )
+ with pytest.warns(FutureWarning, match=r"imputation_did\(\) is deprecated"):
+ r_wrapper = imputation_did(
+ staggered_survey_data,
+ "outcome",
+ "unit",
+ "period",
+ "first_treat",
+ survey_design=sd,
+ )
r_direct = ImputationDiD().fit(
staggered_survey_data,
"outcome",
@@ -671,14 +672,15 @@ def test_wrapper_two_stage_did_with_survey(self, staggered_survey_data):
from diff_diff import two_stage_did
sd = SurveyDesign(weights="weight")
- r_wrapper = two_stage_did(
- staggered_survey_data,
- "outcome",
- "unit",
- "period",
- "first_treat",
- survey_design=sd,
- )
+ with pytest.warns(FutureWarning, match=r"two_stage_did\(\) is deprecated"):
+ r_wrapper = two_stage_did(
+ staggered_survey_data,
+ "outcome",
+ "unit",
+ "period",
+ "first_treat",
+ survey_design=sd,
+ )
r_direct = TwoStageDiD().fit(
staggered_survey_data,
"outcome",
diff --git a/tests/test_survey_phase5.py b/tests/test_survey_phase5.py
index afdccf99..be1beb0a 100644
--- a/tests/test_survey_phase5.py
+++ b/tests/test_survey_phase5.py
@@ -2077,18 +2077,19 @@ def test_unit_varying_survey_raises(self, trop_survey_data):
)
def test_convenience_function_with_survey(self, trop_survey_data, survey_design_weights):
- """trop() convenience function accepts survey_design."""
- result = trop(
- trop_survey_data,
- outcome="outcome",
- treatment="D",
- unit="unit",
- time="time",
- survey_design=survey_design_weights,
- n_bootstrap=10,
- seed=42,
- max_iter=5,
- )
+ """KEEP (2(d) PR-A, M-073): the deprecated wrapper threads survey_design."""
+ with pytest.warns(FutureWarning, match=r"trop\(\) is deprecated"):
+ result = trop(
+ trop_survey_data,
+ outcome="outcome",
+ treatment="D",
+ unit="unit",
+ time="time",
+ survey_design=survey_design_weights,
+ n_bootstrap=10,
+ seed=42,
+ max_iter=5,
+ )
assert result.survey_metadata is not None
def test_to_dict_includes_survey(self, trop_survey_data, survey_design_weights):
diff --git a/tests/test_target_parameter.py b/tests/test_target_parameter.py
index 6670ffc1..0bbad3d8 100644
--- a/tests/test_target_parameter.py
+++ b/tests/test_target_parameter.py
@@ -357,11 +357,11 @@ class TestTargetParameterBaconDRIntegration:
def test_dr_with_bacon_result_emits_target_parameter(self):
import warnings
- from diff_diff import DiagnosticReport, bacon_decompose, generate_staggered_data
+ from diff_diff import BaconDecomposition, DiagnosticReport, generate_staggered_data
warnings.filterwarnings("ignore")
df = generate_staggered_data(n_units=40, n_periods=5, seed=21)
- bacon = bacon_decompose(
+ bacon = BaconDecomposition().fit(
df,
outcome="outcome",
unit="unit",
diff --git a/tests/test_triple_diff.py b/tests/test_triple_diff.py
index c763a0fd..331c79ab 100644
--- a/tests/test_triple_diff.py
+++ b/tests/test_triple_diff.py
@@ -679,41 +679,44 @@ class TestConvenienceFunction:
"""Test triple_difference convenience function."""
def test_basic_usage(self, simple_ddd_data):
- """Test basic usage of convenience function."""
- results = triple_difference(
- simple_ddd_data,
- outcome="outcome",
- group="group",
- partition="partition",
- time="time",
- )
+ """KEEP (2(d) PR-A, M-075): the deprecated wrapper still works, and warns."""
+ with pytest.warns(FutureWarning, match=r"triple_difference\(\) is deprecated"):
+ results = triple_difference(
+ simple_ddd_data,
+ outcome="outcome",
+ group="group",
+ partition="partition",
+ time="time",
+ )
assert isinstance(results, TripleDifferenceResults)
assert abs(results.att - 2.0) < 0.5
def test_with_method_specification(self, simple_ddd_data):
- """Test convenience function with method specification."""
- results = triple_difference(
- simple_ddd_data,
- outcome="outcome",
- group="group",
- partition="partition",
- time="time",
- estimation_method="reg",
- )
+ """KEEP (M-075): wrapper kwarg forwarding (estimation_method)."""
+ with pytest.warns(FutureWarning, match=r"triple_difference\(\) is deprecated"):
+ results = triple_difference(
+ simple_ddd_data,
+ outcome="outcome",
+ group="group",
+ partition="partition",
+ time="time",
+ estimation_method="reg",
+ )
assert results.estimation_method == "reg"
def test_with_covariates(self, ddd_data_with_covariates):
- """Test convenience function with covariates."""
- results = triple_difference(
- ddd_data_with_covariates,
- outcome="outcome",
- group="group",
- partition="partition",
- time="time",
- covariates=["x1", "x2"],
- )
+ """KEEP (M-075): wrapper kwarg forwarding (covariates)."""
+ with pytest.warns(FutureWarning, match=r"triple_difference\(\) is deprecated"):
+ results = triple_difference(
+ ddd_data_with_covariates,
+ outcome="outcome",
+ group="group",
+ partition="partition",
+ time="time",
+ covariates=["x1", "x2"],
+ )
assert results is not None
@@ -968,18 +971,19 @@ def test_convenience_function_passes_rank_deficient_action(self, ddd_data_with_c
# Add a covariate that is perfectly collinear with x1
ddd_data_with_covariates["x1_dup"] = ddd_data_with_covariates["x1"].copy()
- # Should raise with "error" action
- with pytest.raises(ValueError, match="[Rr]ank-deficient"):
- triple_difference(
- ddd_data_with_covariates,
- outcome="outcome",
- group="group",
- partition="partition",
- time="time",
- estimation_method="reg",
- covariates=["x1", "x1_dup"],
- rank_deficient_action="error",
- )
+ # Should raise with "error" action (the wrapper warns first)
+ with pytest.warns(FutureWarning, match=r"triple_difference\(\) is deprecated"):
+ with pytest.raises(ValueError, match="[Rr]ank-deficient"):
+ triple_difference(
+ ddd_data_with_covariates,
+ outcome="outcome",
+ group="group",
+ partition="partition",
+ time="time",
+ estimation_method="reg",
+ covariates=["x1", "x1_dup"],
+ rank_deficient_action="error",
+ )
class TestTripleDifferenceTStatNaN:
@@ -1572,27 +1576,29 @@ def test_fit_clone_idempotent_on_vcov_type(self):
def test_triple_difference_convenience_func_rejects_invalid_vcov_type(self):
"""Invalid vcov_type rejected at the function entry point too."""
data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=59)
- with pytest.raises(ValueError, match="influence-function"):
- triple_difference(
+ with pytest.warns(FutureWarning, match=r"triple_difference\(\) is deprecated"):
+ with pytest.raises(ValueError, match="influence-function"):
+ triple_difference(
+ data,
+ outcome="outcome",
+ group="group",
+ partition="partition",
+ time="time",
+ vcov_type="classical",
+ )
+
+ def test_triple_difference_convenience_func_threads_valid_vcov_type(self):
+ """Valid vcov_type='hc1' fits successfully AND lands on Results."""
+ data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=61)
+ with pytest.warns(FutureWarning, match=r"triple_difference\(\) is deprecated"):
+ res = triple_difference(
data,
outcome="outcome",
group="group",
partition="partition",
time="time",
- vcov_type="classical",
+ vcov_type="hc1",
)
-
- def test_triple_difference_convenience_func_threads_valid_vcov_type(self):
- """Valid vcov_type='hc1' fits successfully AND lands on Results."""
- data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=61)
- res = triple_difference(
- data,
- outcome="outcome",
- group="group",
- partition="partition",
- time="time",
- vcov_type="hc1",
- )
assert res.vcov_type == "hc1"
assert np.isfinite(res.att)
assert np.isfinite(res.se)
diff --git a/tests/test_trop.py b/tests/test_trop.py
index a7bc3cfc..3c2d8371 100644
--- a/tests/test_trop.py
+++ b/tests/test_trop.py
@@ -14,6 +14,13 @@
from diff_diff.trop_local import _run_trop_bootstrap_loop
+def _trop_fit(data, *, outcome, treatment, unit, time, survey_design=None, **ctor_kwargs):
+ """Construct-and-fit via the canonical class API (2(d) PR-A, M-073)."""
+ return TROP(**ctor_kwargs).fit(
+ data, outcome, treatment, unit, time, survey_design=survey_design
+ )
+
+
def generate_factor_dgp(
n_units: int = 50,
n_pre: int = 10,
@@ -474,38 +481,40 @@ class TestConvenienceFunction:
"""Tests for trop() convenience function."""
def test_convenience_function(self, simple_panel_data):
- """Test that convenience function works."""
- results = trop(
- simple_panel_data,
- outcome="outcome",
- treatment="treated",
- unit="unit",
- time="period",
- lambda_time_grid=[0.0, 1.0],
- lambda_unit_grid=[0.0, 1.0],
- lambda_nn_grid=[0.0, 0.1],
- n_bootstrap=10,
- seed=42,
- )
+ """KEEP (2(d) PR-A, M-073): the deprecated wrapper still works, and warns."""
+ with pytest.warns(FutureWarning, match=r"trop\(\) is deprecated"):
+ results = trop(
+ simple_panel_data,
+ outcome="outcome",
+ treatment="treated",
+ unit="unit",
+ time="period",
+ lambda_time_grid=[0.0, 1.0],
+ lambda_unit_grid=[0.0, 1.0],
+ lambda_nn_grid=[0.0, 0.1],
+ n_bootstrap=10,
+ seed=42,
+ )
assert isinstance(results, TROPResults)
assert results.n_obs == len(simple_panel_data)
def test_convenience_with_kwargs(self, simple_panel_data):
- """Test convenience function with additional kwargs."""
- results = trop(
- simple_panel_data,
- outcome="outcome",
- treatment="treated",
- unit="unit",
- time="period",
- lambda_time_grid=[0.0, 0.5, 1.0],
- lambda_unit_grid=[0.0, 0.5],
- lambda_nn_grid=[0.0, 0.1],
- max_iter=50,
- n_bootstrap=10,
- seed=42,
- )
+ """KEEP (M-073): wrapper kwarg forwarding into the constructor."""
+ with pytest.warns(FutureWarning, match=r"trop\(\) is deprecated"):
+ results = trop(
+ simple_panel_data,
+ outcome="outcome",
+ treatment="treated",
+ unit="unit",
+ time="period",
+ lambda_time_grid=[0.0, 0.5, 1.0],
+ lambda_unit_grid=[0.0, 0.5],
+ lambda_nn_grid=[0.0, 0.1],
+ max_iter=50,
+ n_bootstrap=10,
+ seed=42,
+ )
assert isinstance(results, TROPResults)
@@ -741,7 +750,7 @@ def test_reproducibility_with_seed(self, simple_panel_data, ci_params):
Running TROP twice with the same seed should produce identical results.
"""
n_boot = ci_params.bootstrap(20)
- results1 = trop(
+ results1 = _trop_fit(
simple_panel_data,
outcome="outcome",
treatment="treated",
@@ -754,7 +763,7 @@ def test_reproducibility_with_seed(self, simple_panel_data, ci_params):
seed=42,
)
- results2 = trop(
+ results2 = _trop_fit(
simple_panel_data,
outcome="outcome",
treatment="treated",
@@ -1207,7 +1216,7 @@ def test_cycling_search_converges(self, simple_panel_data):
def test_cycling_search_reproducible(self, simple_panel_data):
"""Test that cycling search produces reproducible results."""
- results1 = trop(
+ results1 = _trop_fit(
simple_panel_data,
outcome="outcome",
treatment="treated",
@@ -1220,7 +1229,7 @@ def test_cycling_search_reproducible(self, simple_panel_data):
seed=42,
)
- results2 = trop(
+ results2 = _trop_fit(
simple_panel_data,
outcome="outcome",
treatment="treated",
@@ -1308,35 +1317,37 @@ def test_fit_no_post_periods_parameter(self, simple_panel_data):
def test_convenience_function_no_post_periods(self, simple_panel_data):
"""Test that trop() convenience function no longer accepts post_periods."""
# This should work
- results = trop(
- simple_panel_data,
- outcome="outcome",
- treatment="treated",
- unit="unit",
- time="period",
- lambda_time_grid=[0.0],
- lambda_unit_grid=[0.0],
- lambda_nn_grid=[0.0],
- n_bootstrap=5,
- seed=42,
- )
- assert results is not None
-
- # This should fail
- with pytest.raises(TypeError, match="unexpected keyword argument"):
- trop(
+ with pytest.warns(FutureWarning, match=r"trop\(\) is deprecated"):
+ results = trop(
simple_panel_data,
outcome="outcome",
treatment="treated",
unit="unit",
time="period",
- post_periods=[5, 6, 7], # Should fail
lambda_time_grid=[0.0],
lambda_unit_grid=[0.0],
lambda_nn_grid=[0.0],
n_bootstrap=5,
seed=42,
)
+ assert results is not None
+
+ # This should fail (the wrapper warns first, then the ctor rejects)
+ with pytest.warns(FutureWarning, match=r"trop\(\) is deprecated"):
+ with pytest.raises(TypeError, match="unexpected keyword argument"):
+ trop(
+ simple_panel_data,
+ outcome="outcome",
+ treatment="treated",
+ unit="unit",
+ time="period",
+ post_periods=[5, 6, 7], # Should fail
+ lambda_time_grid=[0.0],
+ lambda_unit_grid=[0.0],
+ lambda_nn_grid=[0.0],
+ n_bootstrap=5,
+ seed=42,
+ )
def test_results_has_period_counts_not_lists(self, simple_panel_data):
"""Test that TROPResults has n_pre_periods/n_post_periods, not lists."""
diff --git a/tests/test_two_stage.py b/tests/test_two_stage.py
index 4f674c18..88b4634c 100644
--- a/tests/test_two_stage.py
+++ b/tests/test_two_stage.py
@@ -1392,46 +1392,49 @@ class TestTwoStageDiDConvenience:
"""Tests for convenience function."""
def test_convenience_function_returns_results(self):
- """Convenience function should return TwoStageDiDResults."""
+ """KEEP (2(d) PR-A, M-071): the deprecated wrapper still works, and warns."""
data = generate_test_data()
- results = two_stage_did(
- data,
- outcome="outcome",
- unit="unit",
- time="time",
- first_treat="first_treat",
- )
+ with pytest.warns(FutureWarning, match=r"two_stage_did\(\) is deprecated"):
+ results = two_stage_did(
+ data,
+ outcome="outcome",
+ unit="unit",
+ time="time",
+ first_treat="first_treat",
+ )
assert isinstance(results, TwoStageDiDResults)
assert results.overall_att > 0
def test_convenience_function_kwargs(self):
- """Constructor kwargs should be forwarded."""
+ """KEEP (M-071): wrapper ctor-kwarg forwarding."""
data = generate_test_data()
- results = two_stage_did(
- data,
- outcome="outcome",
- unit="unit",
- time="time",
- first_treat="first_treat",
- anticipation=1,
- alpha=0.1,
- )
+ with pytest.warns(FutureWarning, match=r"two_stage_did\(\) is deprecated"):
+ results = two_stage_did(
+ data,
+ outcome="outcome",
+ unit="unit",
+ time="time",
+ first_treat="first_treat",
+ anticipation=1,
+ alpha=0.1,
+ )
assert isinstance(results, TwoStageDiDResults)
assert results.alpha == 0.1
def test_convenience_function_aggregate(self):
- """Convenience function should support aggregate parameter."""
+ """KEEP (M-071): the wrapper forwards the deprecated aggregate= (both warnings fire)."""
data = generate_test_data()
- results = two_stage_did(
- data,
- outcome="outcome",
- unit="unit",
- time="time",
- first_treat="first_treat",
- aggregate="event_study",
- )
+ with pytest.warns(FutureWarning, match=r"two_stage_did\(\) is deprecated"):
+ results = two_stage_did(
+ data,
+ outcome="outcome",
+ unit="unit",
+ time="time",
+ first_treat="first_treat",
+ aggregate="event_study",
+ )
assert results.event_study_effects is not None
@@ -2344,8 +2347,9 @@ def test_convenience_function_threads_vcov_type(self):
warnings.simplefilter("ignore")
r = two_stage_did(data, "outcome", "unit", "time", "first_treat", vcov_type="hc1")
assert r.vcov_type == "hc1"
- with pytest.raises(ValueError):
- two_stage_did(data, "outcome", "unit", "time", "first_treat", vcov_type="classical")
+ with pytest.warns(FutureWarning, match=r"two_stage_did\(\) is deprecated"):
+ with pytest.raises(ValueError):
+ two_stage_did(data, "outcome", "unit", "time", "first_treat", vcov_type="classical")
def test_fit_clone_idempotence(self):
data = generate_test_data(n_units=60, seed=3)
diff --git a/tests/test_v4_rename_semantic.py b/tests/test_v4_rename_semantic.py
index 7a8d7917..934a07ab 100644
--- a/tests/test_v4_rename_semantic.py
+++ b/tests/test_v4_rename_semantic.py
@@ -151,13 +151,23 @@ def test_ddd_missing_post_raises(self, ddd_2x2x2):
with pytest.raises(TypeError, match=r"missing required argument: 'post'"):
TripleDifference().fit(ddd_2x2x2, "y", "g", "q")
- def test_wrapper_forwards_silently(self, ddd_2x2x2):
+ def test_wrapper_forwards_time_without_rename_warning(self, ddd_2x2x2):
+ # Flipped BY DESIGN in the 2(d) PR-A (M-075): the wrapper now
+ # fires its OWN deprecation FutureWarning, but the contract this
+ # test exists for is unchanged and only the wrapper path can
+ # express it - the wrapper's legacy positional `time` argument
+ # maps onto the renamed `post=` fit kwarg WITHOUT tripping
+ # M-031's inner rename warning. So: exactly ONE FutureWarning,
+ # the wrapper's, never the rename shim's.
from diff_diff.triple_diff import triple_difference
with warnings.catch_warnings(record=True) as record:
warnings.simplefilter("always")
r = triple_difference(ddd_2x2x2, "y", "g", "q", "t")
- _assert_no_future_warning(record)
+ fw = [w for w in record if issubclass(w.category, FutureWarning)]
+ assert len(fw) == 1, [str(w.message) for w in fw]
+ assert "triple_difference() is deprecated" in str(fw[0].message)
+ assert "time=" not in str(fw[0].message)
assert np.isfinite(r.att)
diff --git a/tests/test_v4_wrapper_shims.py b/tests/test_v4_wrapper_shims.py
new file mode 100644
index 00000000..c45f00ac
--- /dev/null
+++ b/tests/test_v4_wrapper_shims.py
@@ -0,0 +1,394 @@
+"""Wrapper-deprecation shims (rows M-070..M-077, 2(d) PR-A).
+
+The eight module-level convenience wrappers are deprecated in 3.9 and
+removed in 4.0; classes are the single canonical construction surface.
+This suite is the rows' shared ``test_ref``. Per wrapper it pins:
+
+1. WARNING: one legacy call emits the uniform FutureWarning, matched on
+ the FULL message via ``re.escape`` (never a bare template - the
+ message is dense in regex metacharacters). ``bacon_decompose`` uses
+ diagnostic wording (``BaconDecompositionResults`` subclasses the
+ ``Diagnostic`` marker).
+2. EQUIVALENCE (THE GATE): the wrapper is a pure construct+fit relay,
+ so its result equals the explicit class path BIT-EXACTLY -
+ ``assert_allclose(rtol=0, atol=0, equal_nan=True)`` over the full
+ inference quintet (att/se/t_stat/p_value/conf_int). NaN-by-design
+ fields compare NaN==NaN (SyntheticControl's quintet is all-NaN
+ beside a finite att; additionally validated via
+ ``assert_nan_inference``). ``BaconDecompositionResults`` has no
+ att/se - its gate compares the enumerated diagnostic field list
+ plus the ``comparisons`` payload via ``to_dataframe()``.
+ Determinism: only TROP and SyntheticControl draw entropy - both
+ sides share an explicit ``seed=`` (TROP additionally runs a
+ CI-scaled ``n_bootstrap`` and a narrowed regularization grid);
+ the other six are deterministic at their defaults.
+3. SENTINEL FORWARDING (imputation_did / two_stage_did / stacked_did):
+ a plain wrapper call fires EXACTLY ONE FutureWarning - the wrapper
+ deprecation - never the fit-time aggregate warning; an explicit
+ ``aggregate=`` fires both (wrapper first). dCDH is safe by
+ construction (``**fit_kwargs`` - a plain call never passes
+ aggregate).
+"""
+
+import re
+import warnings
+
+import numpy as np
+import pandas as pd
+import pytest
+from numpy.testing import assert_allclose
+
+from diff_diff import (
+ TROP,
+ BaconDecomposition,
+ ChaisemartinDHaultfoeuille,
+ ImputationDiD,
+ StackedDiD,
+ SyntheticControl,
+ TripleDifference,
+ TwoStageDiD,
+ bacon_decompose,
+ chaisemartin_dhaultfoeuille,
+ imputation_did,
+ stacked_did,
+ synthetic_control,
+ triple_difference,
+ trop,
+ two_stage_did,
+)
+
+from .conftest import assert_nan_inference
+
+QUINTET = ("att", "se", "t_stat", "p_value", "conf_int")
+
+
+def _wrapper_message(name: str, cls: str, kind: str = "estimator") -> str:
+ return (
+ f"{name}() is deprecated and will be removed in 4.0; "
+ f"construct the {kind} instead: {cls}(...).fit(data, ...)."
+ )
+
+
+def _assert_quintet_bit_equal(wrapped, direct):
+ """THE GATE: full inference quintet, zero tolerance, NaN==NaN."""
+ for field in QUINTET:
+ w, d = getattr(wrapped, field), getattr(direct, field)
+ assert_allclose(
+ np.asarray(w, dtype=float),
+ np.asarray(d, dtype=float),
+ rtol=0,
+ atol=0,
+ equal_nan=True,
+ err_msg=f"wrapper/class divergence on {field}",
+ )
+
+
+@pytest.fixture(scope="module")
+def staggered_panel():
+ rng = np.random.default_rng(42)
+ n_units, periods = 40, [1, 2, 3, 4, 5]
+ rows = []
+ for u in range(n_units):
+ first = 0 if u >= 24 else (3 if u < 12 else 4)
+ for t in periods:
+ treated = int(first > 0 and t >= first)
+ y = 1.0 + 0.3 * u / n_units + 0.2 * t + 0.8 * treated + rng.normal(0, 0.4)
+ rows.append((u, t, first, treated, y))
+ return pd.DataFrame(rows, columns=["unit", "time", "first_treat", "treated", "y"])
+
+
+@pytest.fixture(scope="module")
+def ddd_panel():
+ rng = np.random.default_rng(7)
+ n = 60
+ df = pd.DataFrame(
+ {
+ "group": np.repeat([0, 1], n // 2 * 4)[: n * 4],
+ "partition": np.tile(np.repeat([0, 1], 2), n),
+ "post": np.tile([0, 1], n * 2),
+ }
+ )
+ df["y"] = (
+ 0.5
+ + 0.2 * df.group
+ + 0.1 * df.partition
+ + 0.15 * df.post
+ + 0.9 * df.group * df.partition * df.post
+ + rng.normal(0, 0.3, len(df))
+ )
+ return df
+
+
+@pytest.fixture(scope="module")
+def scm_panel():
+ rng = np.random.default_rng(3)
+ units, periods = list(range(8)), list(range(1, 9))
+ rows = []
+ for u in units:
+ base = 1.0 + 0.2 * u
+ for t in periods:
+ treated = int(u == 0 and t >= 6)
+ y = base + 0.1 * t + 1.5 * treated + rng.normal(0, 0.1)
+ rows.append((u, t, treated, y))
+ return pd.DataFrame(rows, columns=["unit", "time", "treated", "y"])
+
+
+@pytest.fixture(scope="module")
+def absorbing_panel():
+ rng = np.random.default_rng(11)
+ n_units, periods = 12, list(range(1, 7))
+ rows = []
+ for u in range(n_units):
+ first = 4 if u < 6 else 0
+ for t in periods:
+ d = int(first > 0 and t >= first)
+ y = 0.5 + 0.1 * u + 0.2 * t + 1.0 * d + rng.normal(0, 0.3)
+ rows.append((u, t, d, y))
+ return pd.DataFrame(rows, columns=["unit", "time", "d", "y"])
+
+
+class TestWrapperWarnings:
+ """Pin 1: the uniform full-message FutureWarning, per wrapper."""
+
+ def test_imputation_did_warns(self, staggered_panel):
+ with pytest.warns(
+ FutureWarning, match=re.escape(_wrapper_message("imputation_did", "ImputationDiD"))
+ ):
+ imputation_did(staggered_panel, "y", "unit", "time", "first_treat")
+
+ def test_two_stage_did_warns(self, staggered_panel):
+ with pytest.warns(
+ FutureWarning, match=re.escape(_wrapper_message("two_stage_did", "TwoStageDiD"))
+ ):
+ two_stage_did(staggered_panel, "y", "unit", "time", "first_treat")
+
+ def test_stacked_did_warns(self, staggered_panel):
+ with pytest.warns(
+ FutureWarning, match=re.escape(_wrapper_message("stacked_did", "StackedDiD"))
+ ):
+ stacked_did(staggered_panel, "y", "unit", "time", "first_treat")
+
+ def test_trop_warns(self, absorbing_panel, ci_params):
+ with pytest.warns(FutureWarning, match=re.escape(_wrapper_message("trop", "TROP"))):
+ trop(
+ absorbing_panel,
+ "y",
+ "d",
+ "unit",
+ "time",
+ n_bootstrap=ci_params.bootstrap(19),
+ lambda_time_grid=[0.1],
+ lambda_unit_grid=[0.1],
+ lambda_nn_grid=[0.1],
+ seed=42,
+ )
+
+ def test_synthetic_control_warns(self, scm_panel):
+ with pytest.warns(
+ FutureWarning,
+ match=re.escape(_wrapper_message("synthetic_control", "SyntheticControl")),
+ ):
+ synthetic_control(scm_panel, "y", "treated", "unit", "time", seed=1)
+
+ def test_triple_difference_warns(self, ddd_panel):
+ with pytest.warns(
+ FutureWarning,
+ match=re.escape(_wrapper_message("triple_difference", "TripleDifference")),
+ ):
+ triple_difference(
+ ddd_panel, outcome="y", group="group", partition="partition", time="post"
+ )
+
+ def test_bacon_decompose_warns_diagnostic_wording(self, staggered_panel):
+ # The Bacon carve-out: BaconDecompositionResults subclasses the
+ # Diagnostic marker, so the message says "diagnostic", not
+ # "estimator".
+ with pytest.warns(
+ FutureWarning,
+ match=re.escape(
+ _wrapper_message("bacon_decompose", "BaconDecomposition", kind="diagnostic")
+ ),
+ ):
+ bacon_decompose(
+ staggered_panel, outcome="y", unit="unit", time="time", first_treat="first_treat"
+ )
+
+ def test_chaisemartin_dhaultfoeuille_warns(self, staggered_panel):
+ with pytest.warns(
+ FutureWarning,
+ match=re.escape(
+ _wrapper_message("chaisemartin_dhaultfoeuille", "ChaisemartinDHaultfoeuille")
+ ),
+ ):
+ chaisemartin_dhaultfoeuille(
+ staggered_panel, outcome="y", group="unit", time="time", treatment="treated"
+ )
+
+
+class TestWrapperEquivalence:
+ """Pin 2 (THE GATE): wrapper == class path, zero tolerance."""
+
+ def _wrapped(self, fn, *args, **kwargs):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", FutureWarning)
+ return fn(*args, **kwargs)
+
+ def test_imputation_did_equivalent(self, staggered_panel):
+ wrapped = self._wrapped(imputation_did, staggered_panel, "y", "unit", "time", "first_treat")
+ direct = ImputationDiD().fit(
+ staggered_panel, outcome="y", unit="unit", time="time", first_treat="first_treat"
+ )
+ _assert_quintet_bit_equal(wrapped, direct)
+
+ def test_two_stage_did_equivalent(self, staggered_panel):
+ wrapped = self._wrapped(two_stage_did, staggered_panel, "y", "unit", "time", "first_treat")
+ direct = TwoStageDiD().fit(
+ staggered_panel, outcome="y", unit="unit", time="time", first_treat="first_treat"
+ )
+ _assert_quintet_bit_equal(wrapped, direct)
+
+ def test_stacked_did_equivalent(self, staggered_panel):
+ wrapped = self._wrapped(stacked_did, staggered_panel, "y", "unit", "time", "first_treat")
+ direct = StackedDiD().fit(
+ staggered_panel, outcome="y", unit="unit", time="time", first_treat="first_treat"
+ )
+ _assert_quintet_bit_equal(wrapped, direct)
+
+ @pytest.mark.slow
+ def test_trop_equivalent(self, absorbing_panel, ci_params):
+ # TROP draws bootstrap entropy: identical explicit seed on both
+ # sides, CI-scaled replicate count, and a narrowed
+ # regularization grid (the 200-replicate default over full
+ # grids, run twice, is the trop-heavy-tests hazard).
+ kw = dict(
+ n_bootstrap=ci_params.bootstrap(19),
+ lambda_time_grid=[0.1],
+ lambda_unit_grid=[0.1],
+ lambda_nn_grid=[0.1],
+ seed=42,
+ )
+ wrapped = self._wrapped(trop, absorbing_panel, "y", "d", "unit", "time", **kw)
+ direct = TROP(**kw).fit(absorbing_panel, "y", "d", "unit", "time")
+ _assert_quintet_bit_equal(wrapped, direct)
+
+ def test_synthetic_control_equivalent(self, scm_panel):
+ # SyntheticControl draws optimizer-start entropy under
+ # seed=None: identical explicit seed on both sides. Its
+ # analytical quintet is all-NaN beside a finite att by design.
+ wrapped = self._wrapped(
+ synthetic_control, scm_panel, "y", "treated", "unit", "time", seed=1
+ )
+ direct = SyntheticControl(seed=1).fit(scm_panel, "y", "treated", "unit", "time")
+ _assert_quintet_bit_equal(wrapped, direct)
+ assert np.isfinite(wrapped.att)
+ assert_nan_inference(
+ {
+ "se": wrapped.se,
+ "t_stat": wrapped.t_stat,
+ "p_value": wrapped.p_value,
+ "conf_int": wrapped.conf_int,
+ }
+ )
+
+ def test_triple_difference_equivalent(self, ddd_panel):
+ wrapped = self._wrapped(
+ triple_difference,
+ ddd_panel,
+ outcome="y",
+ group="group",
+ partition="partition",
+ time="post",
+ )
+ direct = TripleDifference().fit(
+ data=ddd_panel, outcome="y", group="group", partition="partition", post="post"
+ )
+ _assert_quintet_bit_equal(wrapped, direct)
+
+ def test_bacon_decompose_equivalent(self, staggered_panel):
+ # BaconDecompositionResults is a Diagnostic with NO att/se: the
+ # gate compares the enumerated diagnostic field list, zero
+ # tolerance, plus the comparisons payload via to_dataframe().
+ wrapped = self._wrapped(
+ bacon_decompose,
+ staggered_panel,
+ outcome="y",
+ unit="unit",
+ time="time",
+ first_treat="first_treat",
+ )
+ direct = BaconDecomposition().fit(
+ staggered_panel, outcome="y", unit="unit", time="time", first_treat="first_treat"
+ )
+ for field in (
+ "twfe_estimate",
+ "total_weight_treated_vs_never",
+ "total_weight_earlier_vs_later",
+ "total_weight_later_vs_earlier",
+ "weighted_avg_treated_vs_never",
+ "weighted_avg_earlier_vs_later",
+ "weighted_avg_later_vs_earlier",
+ "decomposition_error",
+ ):
+ assert_allclose(
+ getattr(wrapped, field),
+ getattr(direct, field),
+ rtol=0,
+ atol=0,
+ equal_nan=True,
+ err_msg=f"wrapper/class divergence on {field}",
+ )
+ for field in ("n_timing_groups", "n_never_treated", "n_obs", "n_always_treated_remapped"):
+ assert getattr(wrapped, field) == getattr(direct, field), field
+ pd.testing.assert_frame_equal(wrapped.to_dataframe(), direct.to_dataframe())
+
+ def test_chaisemartin_dhaultfoeuille_equivalent(self, staggered_panel):
+ wrapped = self._wrapped(
+ chaisemartin_dhaultfoeuille,
+ staggered_panel,
+ outcome="y",
+ group="unit",
+ time="time",
+ treatment="treated",
+ )
+ direct = ChaisemartinDHaultfoeuille().fit(
+ staggered_panel, outcome="y", unit="unit", time="time", treatment="treated"
+ )
+ _assert_quintet_bit_equal(wrapped, direct)
+
+
+class TestSentinelForwarding:
+ """Pin 3: the NOT_SUPPLIED forwarding contract on the three wrappers
+ that carry a deprecated ``aggregate`` sentinel (M-021/M-022/M-024).
+ """
+
+ @pytest.mark.parametrize(
+ "fn",
+ [imputation_did, two_stage_did, stacked_did],
+ ids=["imputation_did", "two_stage_did", "stacked_did"],
+ )
+ def test_plain_wrapper_call_fires_exactly_one_warning(self, fn, staggered_panel):
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ fn(staggered_panel, "y", "unit", "time", "first_treat")
+ fw = [w for w in caught if issubclass(w.category, FutureWarning)]
+ assert len(fw) == 1, [str(w.message) for w in fw]
+ assert "is deprecated and will be removed in 4.0; construct the" in str(fw[0].message)
+
+ @pytest.mark.parametrize(
+ "fn,inner_match",
+ [
+ (imputation_did, "ImputationDiD.fit"),
+ (two_stage_did, "TwoStageDiD.fit"),
+ (stacked_did, "StackedDiD.fit"),
+ ],
+ ids=["imputation_did", "two_stage_did", "stacked_did"],
+ )
+ def test_explicit_aggregate_fires_both_warnings(self, fn, inner_match, staggered_panel):
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ fn(staggered_panel, "y", "unit", "time", "first_treat", aggregate="event_study")
+ fw = [str(w.message) for w in caught if issubclass(w.category, FutureWarning)]
+ assert len(fw) == 2, fw
+ # Wrapper deprecation first, then the fit-time aggregate shim.
+ assert "construct the" in fw[0]
+ assert inner_match in fw[1]