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]