Add conservative dependency lower bounds and drop now-dead sklearn compat branches - #1049
Add conservative dependency lower bounds and drop now-dead sklearn compat branches#1049kbattocchi wants to merge 3 commits into
Conversation
Nightly runs installing floating dependencies began failing on Python 3.12
on 2026-07-29, the day after uv 0.12.0 was released (2026-07-28; the prior
nightly used uv 0.11.32 and passed). Under uv 0.12.0's default prerelease
handling the resolver selects the prerelease numba 0.67.0rc1 - currently the
only numba permitting numpy >= 2.5 - which pulls in numpy 2.5.1 and then
back-tracks the rest of the graph. Because "scipy > 1.4.0" placed almost no
floor on scipy, the resolver was free to settle on scipy 1.6.1, which has no
wheels for Python 3.12 and fails to build from source since distutils was
removed from the standard library:
ModuleNotFoundError: No module named 'distutils'
Passing --prerelease disallow avoids it, but that would only protect CI;
any user installing econml with uv 0.12+ can hit the same resolution. The
declarative fix is to give every dependency a real lower bound.
Bounds are set to roughly the newest release available at the end of 2024,
capped so they never exceed the version actually exercised on our oldest
supported interpreter (Python 3.9) in lkg.txt. numpy (2.0.2), scipy (1.13.1)
and matplotlib (3.9.4) are capped by that rule; the rest take their 2024
value. Several dependencies (numpy, sparse, lightgbm, packaging, graphviz,
matplotlib) previously had no lower bound at all, as did the build-system
requirements for scipy, cython, setuptools and wheel.
Also raises scikit-learn from >= 1.0 to >= 1.6.0. We have never tested below
1.6.1 (the lowest pin in lkg.txt), so the old floor claimed support we do not
verify. This covers the pyproject half of the pending "raise sklearn floor"
work; removing the now-dead SKLEARN_GE_12/14/15 branches remains outstanding.
Verified with uv 0.12.0 using default settings (no --prerelease flag) that
all 18 combinations of Python 3.9-3.14 x {[plt], [plt,dowhy], [plt,dowhy,ray]}
resolve successfully, and that the resolved versions still match lkg.txt.
Python 3.9 continues to resolve numpy 2.0.2 / scipy 1.13.1 / scikit-learn
1.6.1. Also confirmed the package builds, installs and imports.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Keith Battocchi <kebatt@microsoft.com>
Raising the declared scikit-learn floor to >= 1.6.0 in the previous commit makes several version gates unconditional, so the branches they guard are now dead code. SKLEARN_GE_12, SKLEARN_GE_14, SKLEARN_GE_15 and SKLEARN_GE_16 are always true at the new floor and are removed; only SKLEARN_GE_17 and SKLEARN_GE_18 still distinguish supported versions. Note this includes SKLEARN_GE_16, which is dead precisely because the new floor is 1.6. Collapsed accordingly: * _sklearn_compat.py: the two symbol re-exports (_get_column_indices, _print_elapsed_time, both relocated in 1.5) become plain imports, hoisted into the top import block so they no longer trip E402; one_hot_encoder always passes sparse_output (renamed in 1.2); ensure_finite_kwargs always returns ensure_all_finite (renamed in 1.6). * model_selection.py: _cross_val_predict drops the pre-1.4 call that still passed `verbose` to sklearn's non-public _fit_and_predict. * tests/test_sklearn_compat.py: the helper assertions were branching on the same flags, so they are now unconditional. The README compatibility table keeps documenting these change kinds, since they remain useful worked examples of renamed kwargs and relocated symbols. Rather than delete or reword them, the citations now point at commit-SHA permalinks of the pre-cleanup code, with a note that each branch was removed once the floor reached 1.6. Permalinks are pinned to a SHA rather than a release tag because _sklearn_compat.py postdates v0.16.0 and so does not exist in any tag yet. Verified with ruff over the whole package, the compat and compat-helper test modules, the linear_model clone/set_params/n_alphas/future-warning tests, and the full test_model_selection module (which exercises the changed _cross_val_predict path) against scikit-learn 1.9. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Keith Battocchi <kebatt@microsoft.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens EconML’s dependency resolution (notably under uv on Python 3.12+) by introducing conservative lower bounds for runtime/build dependencies, and simplifies scikit-learn compatibility code by removing now-unreachable version branches after raising the sklearn floor.
Changes:
- Add explicit conservative lower bounds for core and optional dependencies (plus build-system requirements) in
pyproject.toml. - Remove dead scikit-learn compat branches/flags and simplify call sites/tests accordingly.
- Update README scikit-learn compatibility documentation to reflect the new flags and link to historical examples via commit permalinks.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates sklearn-compat documentation/examples to match the new supported range and removed branches. |
| pyproject.toml | Adds conservative lower bounds for runtime/optional deps and for build-system requirements to prevent resolver backtracking. |
| econml/tests/test_sklearn_compat.py | Removes branching assertions now that sklearn <1.6 is unsupported. |
| econml/sklearn_extensions/model_selection.py | Drops the pre-1.4 _fit_and_predict call signature branch. |
| econml/_sklearn_compat.py | Removes obsolete version flags and conditional imports; makes shims unconditional given the new sklearn floor. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The ForestDRIV example was passing or failing depending on which machine
in the GitHub Actions fleet ran the docs job. Two runs of the identical
commit, with byte-identical resolved package sets (218 packages, zero
version differences) and the same runner image, produced:
hi = array([ 1.36811, 10.23568, -0.16722]) # passes
hi = array([ 1.37008, 10.23568, -0.17805]) # fails
Both values are legitimate. The difference is reproducible locally by
changing only the OpenBLAS kernel: OPENBLAS_CORETYPE=Nehalem yields
-0.1672216 (matching the recorded expectation) while the default AVX2
path yields -0.17805291. Runner images are identical across the fleet,
but the underlying CPUs are not, so OpenBLAS picks different kernels.
Two things stack to make this example uniquely fragile:
- DRIV pseudo-outcomes divide by an estimated compliance rate, and this
DGP uses a deliberately weak instrument, so the final forest sees noisy
targets with many near-tied candidate splits. Comparing the fitted
forest across the two kernels, 9 of 58704 nodes differ in split feature
and threshold; those flipped splits change honest leaf assignments and
move predictions by ~0.1%.
- The recorded upper bound sits near zero, so scipy-doctest's rtol=0.01
allows only +/-0.0017 on it -- roughly 40x tighter in absolute terms
than any other value in the doctest suite. The actual movement is 0.011,
i.e. 0.16% of the interval's width.
Nothing is wrong with the estimator. A census of all 101 numeric doctest
statements under both kernels found 78 bit-identical, 21 drifting under
0.01%, and only this example exceeding rtol -- CausalForestDML and
grf.RegressionForest are bit-identical, so this is not a general property
of the forest estimators.
Shift the example's ground-truth effect by a constant baseline so no
printed value sits near zero. Reducing n_estimators would also stabilize
it, but doctests need to stay fast and the default is what we want to
document. With the offset, the smallest printed value is 3.03, so rtol
allows +/-0.03 against observed drift of ~0.01, and the raw outputs are
bit-identical across both kernels.
Verified with scipy-doctest's DTChecker under both OPENBLAS_CORETYPE
settings: 2 examples attempted, 0 failed.
Signed-off-by: Keith Battocchi <kebatt@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f11774e2-02be-42fa-a2b9-429871322083
|
Pushed 92dec46 to fix the intermittent The change here is not the cause. Three independent checks:
Root cause: OpenBLAS kernel selection. Reproducible locally by changing nothing but
Runner images are identical across the fleet; the CPUs behind them are not. Why only this doctest. Two amplifications stack. DRIV pseudo-outcomes divide by an estimated compliance rate and this DGP uses a deliberately weak instrument, so the final forest sees noisy targets with many near-tied splits — comparing the fitted forests across the two kernels, 9 of 58704 nodes differ in split feature and threshold, and those flipped splits change honest leaf assignments. On top of that the recorded upper bound sits near zero, so Nothing systemic is wrong. I ran a census of all 101 numeric doctest statements under both kernels:
The fix shifts the example's ground-truth effect by a constant baseline so no printed value sits near zero. The smallest printed value is now 3.03, so |
Fixes the nightly (floating-dependency) failures on Python 3.12, and hardens dependency resolution generally by giving every dependency a real lower bound.
What broke
Nightly runs started failing on Python 3.12 on 2026-07-29 — the day after uv 0.12.0 was released (2026-07-28). The 2026-07-28 nightly used uv 0.11.32 and passed:
Run tests (ubuntu-latest, 3.12, main)Building scipy==1.6.1→ build failureThe error:
Root cause
Under uv 0.12.0's default prerelease handling the resolver selects the prerelease
numba 0.67.0rc1— currently the only numba permittingnumpy >= 2.5— which pulls in numpy 2.5.1 and then back-tracks the rest of the graph. Becausescipy > 1.4.0placed essentially no floor on scipy, the resolver was free to settle on scipy 1.6.1 (Feb 2021), which has no wheels for Python 3.12 and fails to build from source. Reproduced locally with uv 0.12.0 (my system uv 0.10.0 could not reproduce it, which is why the uv version turned out to be the key variable):--prereleaseif-necessarydisallowNotably
--prerelease if-necessarydoes not help.Why not just pass
--prerelease disallowin CIThat would only protect our own CI. Any user installing econml with uv 0.12+ can hit the same resolution. The declarative fix — which is also what uv's own hint suggests — is to give the dependencies real lower bounds.
The change
Bounds are set to roughly the newest release available at the end of 2024, capped so they never exceed the version actually exercised on our oldest supported interpreter (Python 3.9) in
lkg.txt. That cap is what binds numpy (2.0.2), scipy (1.13.1) and matplotlib (3.9.4); the rest take their 2024 value. Six runtime dependencies previously had no lower bound at all:>= 2.0.2> 1.0>= 2.2.3> 0.53.1>= 0.60.0>= 0.38.1>= 0.46.0> 1.4.0>= 1.13.1>= 4.5.0>= 1.0>= 1.6.0>= 24.2>= 0.15.4>= 0.20.3>= 0.13.0>= 1.4.2>= 3.9.4>= 0.10>= 0.14.4> 2.2.0>= 2.40.0[build-system].requireshad the same gap and is fixed too:setuptools >= 75.6.0,wheel >= 0.45.1,scipy >= 1.13.1,cython >= 3.0.11.Verification
With uv 0.12.0 and no flags, all 18 combinations of Python 3.9-3.14 x
{[plt], [plt,dowhy], [plt,dowhy,ray]}resolve successfully, and the resolved versions still matchlkg.txt:Python 3.9 continues to resolve exactly what LKG pins. Also confirmed the package builds, installs and imports cleanly.
Update: dead-branch cleanup folded in
Now that the scikit-learn floor moves to
>= 1.6.0by the same end-of-2024 rule used for every other dependency, a second commit removes the compatibility branches that become unreachable at that floor.SKLEARN_GE_12,SKLEARN_GE_14,SKLEARN_GE_15andSKLEARN_GE_16are all unconditionally true at the new floor and are deleted; onlySKLEARN_GE_17andSKLEARN_GE_18still distinguish supported versions. (SKLEARN_GE_16is dead precisely because the floor is now 1.6.)_sklearn_compat.py— the two 1.5 symbol re-exports become plain imports (hoisted to the top import block so they don't tripE402);one_hot_encoderalways passessparse_output;ensure_finite_kwargsalways returnsensure_all_finitemodel_selection.py—_cross_val_predictdrops the pre-1.4 call that still passedverboseto sklearn's non-public_fit_and_predicttests/test_sklearn_compat.py— assertions that branched on the same flags become unconditionalDocs
The README compatibility table still documents these change kinds, since they remain good worked examples. Instead of deleting or rewording them, the citations now point at commit-SHA permalinks of the pre-cleanup code plus a note that each branch was removed at the 1.6 floor. They are pinned to a SHA rather than a release tag because
_sklearn_compat.pypostdatesv0.16.0and is not in any tag yet, so a tag link would 404.