Skip to content

Commit a5fbf5a

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/dcdh-survey-variance-extensions
2 parents 0e537b0 + ba790b0 commit a5fbf5a

10 files changed

Lines changed: 75 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [3.1.2] - 2026-04-18
11+
12+
### Fixed
13+
- **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.
14+
- **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`.
15+
- **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.
16+
1017
### Changed
11-
- 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.
18+
- 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.
19+
- 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.
1220

1321
## [3.1.1] - 2026-04-16
1422

@@ -1298,6 +1306,7 @@ for the full feature history leading to this release.
12981306
[2.1.2]: https://github.com/igerber/diff-diff/compare/v2.1.1...v2.1.2
12991307
[2.1.1]: https://github.com/igerber/diff-diff/compare/v2.1.0...v2.1.1
13001308
[2.1.0]: https://github.com/igerber/diff-diff/compare/v2.0.3...v2.1.0
1309+
[3.1.2]: https://github.com/igerber/diff-diff/compare/v3.1.1...v3.1.2
13011310
[3.1.1]: https://github.com/igerber/diff-diff/compare/v3.1.0...v3.1.1
13021311
[3.1.0]: https://github.com/igerber/diff-diff/compare/v3.0.2...v3.1.0
13031312
[3.0.2]: https://github.com/igerber/diff-diff/compare/v3.0.1...v3.0.2

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Deprecated parameters still present for backward compatibility:
124124

125125
- `lambda_reg` and `zeta` in `SyntheticDiD` (`synthetic_did.py`)
126126
- Deprecated in favor of `zeta_omega`/`zeta_lambda` parameters
127-
- Remove in v3.1
127+
- Remove in v4.0.0 (SemVer-safe: public kwarg removal requires a major bump)
128128

129129
---
130130

diff_diff/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@
231231
ETWFE = WooldridgeDiD
232232
DCDH = ChaisemartinDHaultfoeuille
233233

234-
__version__ = "3.1.1"
234+
__version__ = "3.1.2"
235235
__all__ = [
236236
# Estimators
237237
"DifferenceInDifferences",

diff_diff/guides/llms-full.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> A Python library for Difference-in-Differences causal inference analysis. Provides sklearn-like estimators with statsmodels-style output for econometric analysis.
44

5-
- Version: 3.1.1
5+
- Version: 3.1.2
66
- Repository: https://github.com/igerber/diff-diff
77
- License: MIT
88
- Dependencies: numpy, pandas, scipy (no statsmodels dependency)

diff_diff/synthetic_did.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,14 @@ def __init__(
149149
warnings.warn(
150150
"lambda_reg is deprecated and ignored. Regularization is now "
151151
"auto-computed from data. Use zeta_omega to override unit weight "
152-
"regularization. Will be removed in v3.1.",
152+
"regularization. Will be removed in v4.0.0.",
153153
DeprecationWarning,
154154
stacklevel=2,
155155
)
156156
if zeta is not None:
157157
warnings.warn(
158158
"zeta is deprecated and ignored. Use zeta_lambda to override "
159-
"time weight regularization. Will be removed in v3.1.",
159+
"time weight regularization. Will be removed in v4.0.0.",
160160
DeprecationWarning,
161161
stacklevel=2,
162162
)
@@ -1471,7 +1471,7 @@ def set_params(self, **params) -> "SyntheticDiD":
14711471
if key in _deprecated:
14721472
warnings.warn(
14731473
f"{key} is deprecated and ignored. Use zeta_omega/zeta_lambda "
1474-
f"instead. Will be removed in v3.1.",
1474+
f"instead. Will be removed in v4.0.0.",
14751475
DeprecationWarning,
14761476
stacklevel=2,
14771477
)

diff_diff/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,12 +1460,15 @@ def _sc_weight_fw_numpy(
14601460
lam = np.ones(T0) / T0
14611461

14621462
vals = np.full(max_iter, np.nan)
1463+
converged = False
14631464
for t in range(max_iter):
14641465
lam = _fw_step(A, lam, b, eta)
14651466
err = Y @ np.append(lam, -1.0)
14661467
vals[t] = zeta**2 * np.sum(lam**2) + np.sum(err**2) / N
14671468
if t >= 1 and vals[t - 1] - vals[t] < min_decrease**2:
1469+
converged = True
14681470
break
1471+
warn_if_not_converged(converged, "Frank-Wolfe SC weight solver", max_iter, min_decrease)
14691472

14701473
return lam
14711474

docs/methodology/REGISTRY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1496,7 +1496,7 @@ Convergence criterion: stop when objective decrease < min_decrease² (default mi
14961496
P-value: analytical (normal distribution), not empirical.
14971497

14981498
*Edge cases:*
1499-
- **Frank-Wolfe non-convergence**: Returns current weights after max_iter iterations. No warning emitted; the convergence check `vals[t-1] - vals[t] < min_decrease²` simply does not trigger early exit, and the final iterate is returned.
1499+
- **Frank-Wolfe non-convergence**: Returns current weights after max_iter iterations when the convergence check `vals[t-1] - vals[t] < min_decrease²` never triggers early exit. The numpy-backend path (`_sc_weight_fw_numpy`) emits a `UserWarning` via `diff_diff.utils.warn_if_not_converged` in that case; the Rust-backend path silently returns the final iterate (Rust-side signature change required to thread convergence status — tracked as an axis-G backend-parity follow-up).
15001500
- **`_sparsify` all-zero input**: If `max(v) <= 0`, returns uniform weights `ones(len(v)) / len(v)`.
15011501
- **Single control unit**: `compute_sdid_unit_weights` returns `[1.0]` immediately (short-circuit before Frank-Wolfe).
15021502
- **Zero control units**: `compute_sdid_unit_weights` returns empty array `[]`.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "diff-diff"
7-
version = "3.1.1"
7+
version = "3.1.2"
88
description = "Difference-in-Differences causal inference with sklearn-like API. Callaway-Sant'Anna, Synthetic DiD, Honest DiD, event studies, parallel trends."
99
readme = "README.md"
1010
license = "MIT"

rust/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "diff_diff_rust"
3-
version = "3.1.1"
3+
version = "3.1.2"
44
edition = "2021"
55
rust-version = "1.84"
66
description = "Rust backend for diff-diff DiD library"

tests/test_methodology_sdid.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
_compute_regularization,
2222
_fw_step,
2323
_sc_weight_fw,
24+
_sc_weight_fw_numpy,
2425
_sparsify,
2526
_sum_normalize,
2627
compute_sdid_estimator,
@@ -207,6 +208,58 @@ def test_intercept_centering(self):
207208
# They should be different because centering matters
208209
assert not np.allclose(lam_intercept, lam_no_intercept, atol=1e-3)
209210

211+
def test_fw_warns_on_nonconvergence(self):
212+
"""Silent-failure audit axis B: _sc_weight_fw_numpy must warn when max_iter exhausts."""
213+
rng = np.random.default_rng(42)
214+
Y = rng.standard_normal((15, 7)) # (N, T0+1) with T0=6
215+
216+
with pytest.warns(UserWarning, match="did not converge"):
217+
_sc_weight_fw_numpy(Y, zeta=0.1, max_iter=1, min_decrease=1e-12)
218+
219+
def test_fw_no_warning_on_convergence(self):
220+
"""Silent-failure audit axis B: no warning on well-conditioned convergent input."""
221+
rng = np.random.default_rng(42)
222+
Y = rng.standard_normal((15, 7))
223+
224+
with warnings.catch_warnings(record=True) as w:
225+
warnings.simplefilter("always")
226+
_sc_weight_fw_numpy(Y, zeta=0.1, max_iter=10000, min_decrease=1e-3)
227+
assert not any("did not converge" in str(x.message) for x in w)
228+
229+
def test_fw_wrapper_warns_on_nonconvergence_without_rust(self):
230+
"""Silent-failure audit axis B: public _sc_weight_fw wrapper must route
231+
warnings through even when called via the dispatcher with the Rust
232+
backend disabled. Pins the contract against refactors that would
233+
bypass the numpy path."""
234+
rng = np.random.default_rng(42)
235+
Y = rng.standard_normal((15, 7))
236+
237+
with patch("diff_diff.utils.HAS_RUST_BACKEND", False):
238+
with pytest.warns(UserWarning, match="did not converge"):
239+
_sc_weight_fw(Y, zeta=0.1, max_iter=1, min_decrease=1e-12)
240+
241+
def test_fw_wrapper_no_warning_on_convergence_without_rust(self):
242+
"""Silent-failure audit axis B: wrapper-level negative control."""
243+
rng = np.random.default_rng(42)
244+
Y = rng.standard_normal((15, 7))
245+
246+
with patch("diff_diff.utils.HAS_RUST_BACKEND", False):
247+
with warnings.catch_warnings(record=True) as w:
248+
warnings.simplefilter("always")
249+
_sc_weight_fw(Y, zeta=0.1, max_iter=10000, min_decrease=1e-3)
250+
assert not any("did not converge" in str(x.message) for x in w)
251+
252+
def test_fw_max_iter_zero_warns(self):
253+
"""Silent-failure audit axis B: max_iter=0 produces the uniform init
254+
without iterating, which cannot converge by construction. The warning
255+
must fire (consistent with the convention: if we exited the loop
256+
without hitting the tolerance gate, we signal). Pins this contract."""
257+
Y = np.random.default_rng(0).standard_normal((5, 4))
258+
259+
with patch("diff_diff.utils.HAS_RUST_BACKEND", False):
260+
with pytest.warns(UserWarning, match="did not converge"):
261+
_sc_weight_fw(Y, zeta=0.1, max_iter=0)
262+
210263

211264
class TestSparsify:
212265
"""Verify sparsification behavior."""

0 commit comments

Comments
 (0)