From 231dca936ad91d47a1f12ffd2a9c6cb3b9ed4d68 Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 18 Apr 2026 13:37:25 -0400 Subject: [PATCH 1/2] Release 3.1.2: SDID scale fix, convergence warnings, roadmap refresh; remove deprecated SyntheticDiD params Package four merged PRs (#312 SDID catastrophic cancellation at extreme Y scale, #313 roadmap refresh, #314 FE imputation non-convergence signaling, #315 Frank-Wolfe SC weight solver non-convergence signaling) as 3.1.2. Also remove the SyntheticDiD(lambda_reg=...) and SyntheticDiD(zeta=...) kwargs, which have been deprecated with DeprecationWarning since v2.3.1 (2026-02-10) in favor of zeta_omega / zeta_lambda; their warning messages announced removal in v3.1. Passing the old kwargs now raises TypeError at __init__ and ValueError: Unknown parameter at set_params. Internal ridge-regression helpers that accept a lambda_reg parameter (compute_synthetic_weights, rank_control_units, Rust FFI bindings) are unaffected. Version strings bumped in diff_diff/__init__.py, pyproject.toml, rust/Cargo.toml, and diff_diff/guides/llms-full.txt. CHANGELOG populated with Fixed / Changed / Removed sections and comparison-link footer. TODO.md's "Deprecated Code" entry removed now that the task is done. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 +++++++++- TODO.md | 10 -------- diff_diff/__init__.py | 2 +- diff_diff/guides/llms-full.txt | 2 +- diff_diff/synthetic_did.py | 30 +---------------------- pyproject.toml | 2 +- rust/Cargo.toml | 2 +- tests/test_estimators.py | 21 ---------------- tests/test_methodology_sdid.py | 44 ++-------------------------------- 9 files changed, 19 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c1a0c1..0b3314c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.2] - 2026-04-18 + +### Fixed +- **SyntheticDiD catastrophic cancellation at extreme Y scale** (PR #312) - the Frank-Wolfe weight solver lost precision when outcome magnitudes were very large or very small; results are now numerically stable across scales. +- **Non-convergence signaling in FE imputation alternating-projection solvers** (PR #314) - `ImputationDiD`, `TwoStageDiD`, and shared `within_transform` now emit a `ConvergenceWarning` when the alternating-projection / weighted-demean loop exits without meeting the tolerance. `max_iter` and `tol` are documented on `within_transform`. +- **Non-convergence signaling in SyntheticDiD Frank-Wolfe solver** (PR #315) - the numpy-path Frank-Wolfe SC weight solver now emits a `ConvergenceWarning` when the loop exits without meeting `min_decrease`. Wrapper-level and `max_iter=0` regression tests added. + ### Changed -- Refresh `ROADMAP.md` to drop top-level phase numbering and reflect shipped state through v3.1.1. Absorbs dCDH into the Current State estimator list; adds Recently Shipped summary; reorganizes open work as Shipping Next / Under Consideration / AI-Agent Track / Long-term. Updates `docs/business-strategy.md`, `docs/survey-roadmap.md`, `docs/practitioner_decision_tree.rst`, `docs/choosing_estimator.rst`, `docs/api/chaisemartin_dhaultfoeuille.rst`, `README.md`, and `diff_diff/guides/llms-full.txt` to remove stale phase-deferral language now that the deferred items have shipped. +- Refresh `ROADMAP.md` to drop top-level phase numbering and reflect shipped state through v3.1.1 (PR #313). Absorbs dCDH into the Current State estimator list; adds Recently Shipped summary; reorganizes open work as Shipping Next / Under Consideration / AI-Agent Track / Long-term. Updates `docs/business-strategy.md`, `docs/survey-roadmap.md`, `docs/practitioner_decision_tree.rst`, `docs/choosing_estimator.rst`, `docs/api/chaisemartin_dhaultfoeuille.rst`, `README.md`, and `diff_diff/guides/llms-full.txt` to remove stale phase-deferral language now that the deferred items have shipped. + +### Removed +- **`SyntheticDiD(lambda_reg=...)` and `SyntheticDiD(zeta=...)`** - deprecated since v2.3.1 (2026-02-10) in favor of `zeta_omega` / `zeta_lambda`, which match R `synthdid`'s unit-weight / time-weight split. Passing the old kwargs now raises `TypeError` at `__init__` and `ValueError: Unknown parameter` at `set_params`. Internal helpers that take a `lambda_reg` ridge parameter (`compute_synthetic_weights`, `rank_control_units`, Rust FFI bindings) are unaffected - they remain supported. ## [3.1.1] - 2026-04-16 @@ -1298,6 +1308,7 @@ for the full feature history leading to this release. [2.1.2]: https://github.com/igerber/diff-diff/compare/v2.1.1...v2.1.2 [2.1.1]: https://github.com/igerber/diff-diff/compare/v2.1.0...v2.1.1 [2.1.0]: https://github.com/igerber/diff-diff/compare/v2.0.3...v2.1.0 +[3.1.2]: https://github.com/igerber/diff-diff/compare/v3.1.1...v3.1.2 [3.1.1]: https://github.com/igerber/diff-diff/compare/v3.1.0...v3.1.1 [3.1.0]: https://github.com/igerber/diff-diff/compare/v3.0.2...v3.1.0 [3.0.2]: https://github.com/igerber/diff-diff/compare/v3.0.1...v3.0.2 diff --git a/TODO.md b/TODO.md index f5917ebc..aa16400f 100644 --- a/TODO.md +++ b/TODO.md @@ -118,16 +118,6 @@ Different estimators compute SEs differently. Consider unified interface. Mypy reports 0 errors. All mixin `attr-defined` errors resolved via `TYPE_CHECKING`-guarded method stubs in bootstrap mixin classes. -## Deprecated Code - -Deprecated parameters still present for backward compatibility: - -- `lambda_reg` and `zeta` in `SyntheticDiD` (`synthetic_did.py`) - - Deprecated in favor of `zeta_omega`/`zeta_lambda` parameters - - Remove in v3.1 - ---- - ## Test Coverage **Note**: 21 visualization tests are skipped when matplotlib unavailable—this is expected. diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index bc1b88de..3ae225b0 100644 --- a/diff_diff/__init__.py +++ b/diff_diff/__init__.py @@ -231,7 +231,7 @@ ETWFE = WooldridgeDiD DCDH = ChaisemartinDHaultfoeuille -__version__ = "3.1.1" +__version__ = "3.1.2" __all__ = [ # Estimators "DifferenceInDifferences", diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 95734a57..7bbd950c 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -2,7 +2,7 @@ > A Python library for Difference-in-Differences causal inference analysis. Provides sklearn-like estimators with statsmodels-style output for econometric analysis. -- Version: 3.1.1 +- Version: 3.1.2 - Repository: https://github.com/igerber/diff-diff - License: MIT - Dependencies: numpy, pandas, scipy (no statsmodels dependency) diff --git a/diff_diff/synthetic_did.py b/diff_diff/synthetic_did.py index 97976f7c..a993d18d 100644 --- a/diff_diff/synthetic_did.py +++ b/diff_diff/synthetic_did.py @@ -141,26 +141,7 @@ def __init__( variance_method: str = "placebo", n_bootstrap: int = 200, seed: Optional[int] = None, - # Deprecated — accepted for backward compat, ignored with warning - lambda_reg: Optional[float] = None, - zeta: Optional[float] = None, ): - if lambda_reg is not None: - warnings.warn( - "lambda_reg is deprecated and ignored. Regularization is now " - "auto-computed from data. Use zeta_omega to override unit weight " - "regularization. Will be removed in v3.1.", - DeprecationWarning, - stacklevel=2, - ) - if zeta is not None: - warnings.warn( - "zeta is deprecated and ignored. Use zeta_lambda to override " - "time weight regularization. Will be removed in v3.1.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(robust=True, cluster=None, alpha=alpha) self.zeta_omega = zeta_omega self.zeta_lambda = zeta_lambda @@ -1465,17 +1446,8 @@ def get_params(self) -> Dict[str, Any]: def set_params(self, **params) -> "SyntheticDiD": """Set estimator parameters.""" - # Deprecated parameter names — emit warning and ignore - _deprecated = {"lambda_reg", "zeta"} for key, value in params.items(): - if key in _deprecated: - warnings.warn( - f"{key} is deprecated and ignored. Use zeta_omega/zeta_lambda " - f"instead. Will be removed in v3.1.", - DeprecationWarning, - stacklevel=2, - ) - elif hasattr(self, key): + if hasattr(self, key): setattr(self, key, value) else: raise ValueError(f"Unknown parameter: {key}") diff --git a/pyproject.toml b/pyproject.toml index 395b7ef7..f3749252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "diff-diff" -version = "3.1.1" +version = "3.1.2" description = "Difference-in-Differences causal inference with sklearn-like API. Callaway-Sant'Anna, Synthetic DiD, Honest DiD, event studies, parallel trends." readme = "README.md" license = "MIT" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 8a54e8e9..4fe96ea7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diff_diff_rust" -version = "3.1.1" +version = "3.1.2" edition = "2021" rust-version = "1.84" description = "Rust backend for diff-diff DiD library" diff --git a/tests/test_estimators.py b/tests/test_estimators.py index 62440f7d..8c6363f9 100644 --- a/tests/test_estimators.py +++ b/tests/test_estimators.py @@ -2758,27 +2758,6 @@ def test_get_set_params(self): sdid.set_params(variance_method="bootstrap") assert sdid.variance_method == "bootstrap" - def test_deprecated_params(self): - """Test that old parameter names emit DeprecationWarning.""" - import warnings as _warnings - - with _warnings.catch_warnings(record=True) as w: - _warnings.simplefilter("always") - sdid = SyntheticDiD(lambda_reg=1.0, zeta=0.5) - dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] - assert len(dep_warnings) == 2 - - # Deprecated params are ignored — auto-computed regularization is used - assert sdid.zeta_omega is None - assert sdid.zeta_lambda is None - - # set_params with deprecated names also warns - with _warnings.catch_warnings(record=True) as w: - _warnings.simplefilter("always") - sdid.set_params(lambda_reg=2.0) - dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] - assert len(dep_warnings) == 1 - def test_missing_unit_column(self, sdid_panel_data): """Test error when unit column is missing.""" sdid = SyntheticDiD() diff --git a/tests/test_methodology_sdid.py b/tests/test_methodology_sdid.py index c7a77545..2d654036 100644 --- a/tests/test_methodology_sdid.py +++ b/tests/test_methodology_sdid.py @@ -1064,15 +1064,6 @@ def test_set_params_new_names(self): assert sdid.zeta_omega == 2.0 assert sdid.zeta_lambda == 0.1 - def test_set_params_deprecated_names_warn(self): - """set_params with old names should emit DeprecationWarning.""" - sdid = SyntheticDiD() - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - sdid.set_params(lambda_reg=1.0) - dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] - assert len(dep_warnings) == 1 - def test_set_params_unknown_raises(self): """set_params with unknown name should raise ValueError.""" sdid = SyntheticDiD() @@ -1080,39 +1071,8 @@ def test_set_params_unknown_raises(self): sdid.set_params(nonexistent_param=1.0) -class TestDeprecatedParams: - """Test deprecated parameter handling in __init__.""" - - def test_lambda_reg_warns(self): - """SyntheticDiD(lambda_reg=...) emits DeprecationWarning.""" - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - sdid = SyntheticDiD(lambda_reg=0.1) - dep = [x for x in w if issubclass(x.category, DeprecationWarning)] - assert len(dep) == 1 - assert "lambda_reg" in str(dep[0].message) - - # Deprecated param is ignored — auto-computed used - assert sdid.zeta_omega is None - - def test_zeta_warns(self): - """SyntheticDiD(zeta=...) emits DeprecationWarning.""" - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - sdid = SyntheticDiD(zeta=2.0) - dep = [x for x in w if issubclass(x.category, DeprecationWarning)] - assert len(dep) == 1 - assert "zeta" in str(dep[0].message) - - assert sdid.zeta_lambda is None - - def test_both_deprecated_params(self): - """Both deprecated params at once should emit two warnings.""" - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - SyntheticDiD(lambda_reg=0.5, zeta=1.5) - dep = [x for x in w if issubclass(x.category, DeprecationWarning)] - assert len(dep) == 2 +class TestDefaultVarianceMethod: + """Default variance_method sanity.""" def test_default_variance_method_is_placebo(self): """Default variance_method should be 'placebo' (matching R).""" From 9230c8f483827996f8cdabe8fced17a054a1a768 Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 18 Apr 2026 13:51:43 -0400 Subject: [PATCH 2/2] Revert SyntheticDiD deprecation removal; keep warning-only through 3.x Per CI review feedback (#316): removing public kwargs under a patch version violates Semantic Versioning, which CHANGELOG.md explicitly claims to adhere to. Restore lambda_reg and zeta handling in SyntheticDiD.__init__ and set_params as warning-only, and bump the removal target in the DeprecationWarning text from "v3.1" to "v4.0.0". The 3.1.2 release now carries only the four fix/doc PRs (#312 SDID scale, #313 roadmap, #314 FE imputation convergence, #315 Frank-Wolfe convergence) with no breaking changes. - diff_diff/synthetic_did.py: restore deprecated kwargs + warnings (v4.0.0 text) - tests/test_methodology_sdid.py: restore TestDeprecatedParams class + set_params deprecation test - tests/test_estimators.py: restore test_deprecated_params - CHANGELOG.md: drop Removed section; add Changed entry documenting the v3.1 -> v4.0.0 bump in the removal target - TODO.md: restore Deprecated Code section with v4.0.0 removal target and SemVer rationale Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 +--- TODO.md | 10 ++++++++ diff_diff/synthetic_did.py | 30 ++++++++++++++++++++++- tests/test_estimators.py | 21 ++++++++++++++++ tests/test_methodology_sdid.py | 44 ++++++++++++++++++++++++++++++++-- 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3314c2..c24e1b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Refresh `ROADMAP.md` to drop top-level phase numbering and reflect shipped state through v3.1.1 (PR #313). Absorbs dCDH into the Current State estimator list; adds Recently Shipped summary; reorganizes open work as Shipping Next / Under Consideration / AI-Agent Track / Long-term. Updates `docs/business-strategy.md`, `docs/survey-roadmap.md`, `docs/practitioner_decision_tree.rst`, `docs/choosing_estimator.rst`, `docs/api/chaisemartin_dhaultfoeuille.rst`, `README.md`, and `diff_diff/guides/llms-full.txt` to remove stale phase-deferral language now that the deferred items have shipped. - -### Removed -- **`SyntheticDiD(lambda_reg=...)` and `SyntheticDiD(zeta=...)`** - deprecated since v2.3.1 (2026-02-10) in favor of `zeta_omega` / `zeta_lambda`, which match R `synthdid`'s unit-weight / time-weight split. Passing the old kwargs now raises `TypeError` at `__init__` and `ValueError: Unknown parameter` at `set_params`. Internal helpers that take a `lambda_reg` ridge parameter (`compute_synthetic_weights`, `rank_control_units`, Rust FFI bindings) are unaffected - they remain supported. +- Bump the `SyntheticDiD(lambda_reg=...)` and `SyntheticDiD(zeta=...)` deprecation warnings' removal target from `v3.1` to `v4.0.0`. Removing public kwargs in a patch / minor release would violate Semantic Versioning; the deprecation stays warning-only throughout the `3.x` line and will be removed in the next major release. Use `zeta_omega` / `zeta_lambda` instead. ## [3.1.1] - 2026-04-16 diff --git a/TODO.md b/TODO.md index aa16400f..2ef24f36 100644 --- a/TODO.md +++ b/TODO.md @@ -118,6 +118,16 @@ Different estimators compute SEs differently. Consider unified interface. Mypy reports 0 errors. All mixin `attr-defined` errors resolved via `TYPE_CHECKING`-guarded method stubs in bootstrap mixin classes. +## Deprecated Code + +Deprecated parameters still present for backward compatibility: + +- `lambda_reg` and `zeta` in `SyntheticDiD` (`synthetic_did.py`) + - Deprecated in favor of `zeta_omega`/`zeta_lambda` parameters + - Remove in v4.0.0 (SemVer-safe: public kwarg removal requires a major bump) + +--- + ## Test Coverage **Note**: 21 visualization tests are skipped when matplotlib unavailable—this is expected. diff --git a/diff_diff/synthetic_did.py b/diff_diff/synthetic_did.py index a993d18d..e3f43740 100644 --- a/diff_diff/synthetic_did.py +++ b/diff_diff/synthetic_did.py @@ -141,7 +141,26 @@ def __init__( variance_method: str = "placebo", n_bootstrap: int = 200, seed: Optional[int] = None, + # Deprecated — accepted for backward compat, ignored with warning + lambda_reg: Optional[float] = None, + zeta: Optional[float] = None, ): + if lambda_reg is not None: + warnings.warn( + "lambda_reg is deprecated and ignored. Regularization is now " + "auto-computed from data. Use zeta_omega to override unit weight " + "regularization. Will be removed in v4.0.0.", + DeprecationWarning, + stacklevel=2, + ) + if zeta is not None: + warnings.warn( + "zeta is deprecated and ignored. Use zeta_lambda to override " + "time weight regularization. Will be removed in v4.0.0.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(robust=True, cluster=None, alpha=alpha) self.zeta_omega = zeta_omega self.zeta_lambda = zeta_lambda @@ -1446,8 +1465,17 @@ def get_params(self) -> Dict[str, Any]: def set_params(self, **params) -> "SyntheticDiD": """Set estimator parameters.""" + # Deprecated parameter names — emit warning and ignore + _deprecated = {"lambda_reg", "zeta"} for key, value in params.items(): - if hasattr(self, key): + if key in _deprecated: + warnings.warn( + f"{key} is deprecated and ignored. Use zeta_omega/zeta_lambda " + f"instead. Will be removed in v4.0.0.", + DeprecationWarning, + stacklevel=2, + ) + elif hasattr(self, key): setattr(self, key, value) else: raise ValueError(f"Unknown parameter: {key}") diff --git a/tests/test_estimators.py b/tests/test_estimators.py index 8c6363f9..62440f7d 100644 --- a/tests/test_estimators.py +++ b/tests/test_estimators.py @@ -2758,6 +2758,27 @@ def test_get_set_params(self): sdid.set_params(variance_method="bootstrap") assert sdid.variance_method == "bootstrap" + def test_deprecated_params(self): + """Test that old parameter names emit DeprecationWarning.""" + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as w: + _warnings.simplefilter("always") + sdid = SyntheticDiD(lambda_reg=1.0, zeta=0.5) + dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(dep_warnings) == 2 + + # Deprecated params are ignored — auto-computed regularization is used + assert sdid.zeta_omega is None + assert sdid.zeta_lambda is None + + # set_params with deprecated names also warns + with _warnings.catch_warnings(record=True) as w: + _warnings.simplefilter("always") + sdid.set_params(lambda_reg=2.0) + dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(dep_warnings) == 1 + def test_missing_unit_column(self, sdid_panel_data): """Test error when unit column is missing.""" sdid = SyntheticDiD() diff --git a/tests/test_methodology_sdid.py b/tests/test_methodology_sdid.py index 2d654036..c7a77545 100644 --- a/tests/test_methodology_sdid.py +++ b/tests/test_methodology_sdid.py @@ -1064,6 +1064,15 @@ def test_set_params_new_names(self): assert sdid.zeta_omega == 2.0 assert sdid.zeta_lambda == 0.1 + def test_set_params_deprecated_names_warn(self): + """set_params with old names should emit DeprecationWarning.""" + sdid = SyntheticDiD() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + sdid.set_params(lambda_reg=1.0) + dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(dep_warnings) == 1 + def test_set_params_unknown_raises(self): """set_params with unknown name should raise ValueError.""" sdid = SyntheticDiD() @@ -1071,8 +1080,39 @@ def test_set_params_unknown_raises(self): sdid.set_params(nonexistent_param=1.0) -class TestDefaultVarianceMethod: - """Default variance_method sanity.""" +class TestDeprecatedParams: + """Test deprecated parameter handling in __init__.""" + + def test_lambda_reg_warns(self): + """SyntheticDiD(lambda_reg=...) emits DeprecationWarning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + sdid = SyntheticDiD(lambda_reg=0.1) + dep = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(dep) == 1 + assert "lambda_reg" in str(dep[0].message) + + # Deprecated param is ignored — auto-computed used + assert sdid.zeta_omega is None + + def test_zeta_warns(self): + """SyntheticDiD(zeta=...) emits DeprecationWarning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + sdid = SyntheticDiD(zeta=2.0) + dep = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(dep) == 1 + assert "zeta" in str(dep[0].message) + + assert sdid.zeta_lambda is None + + def test_both_deprecated_params(self): + """Both deprecated params at once should emit two warnings.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + SyntheticDiD(lambda_reg=0.5, zeta=1.5) + dep = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(dep) == 2 def test_default_variance_method_is_placebo(self): """Default variance_method should be 'placebo' (matching R)."""