FIX: fix boundary 0-weight edge-case in `_weighted_percentile`

#### [NAME_REDACTED]/PRs

Found this bug while working on #32193 (maybe the 3rd or 4th bug found while working on this test, really this is a good test :joy:)

Let me know if you want me to open an issue describing the bug, but for now it's described it in the next section.

#### The bug

```python
from sklearn.utils.stats import _weighted_percentile

_weighted_percentile([1, 3], [1, 1], average=True)  # returns 2 as expected
_weighted_percentile([1, 2, 3], [1, 0, 1], average=True)  # ! returns 1
_weighted_percentile([1, 2, 3, 4], [1, 0, 1, 0], average=True)  # returns 2 as expected

```
#### What does this implement/fix? Explain your changes.

[Codex wrote:]

I traced it to the zero‑weight “plateau” handling in `_weighted_percentile`. In your second case, the CDF has a flat section (`[1, 1, 2]`). The code correctly finds the next index where the CDF increases (the last element), but then treats `next_index == max_idx` as “out of range” and snaps back to the current index, which forces the average to use the same value twice and returns `1.0`.

Changes:
- Allow `next_index == max_idx` so the “plus one” value can be the last element.
- Added regression test in `sklearn/utils/tests/test_stats.py` to cover the plateau case.

#### AI usage disclosure

I used AI assistance for:
- [ ] Initially finding the bug
- [x] Code generation (e.g., when writing an implementation or fixing a bug)
- [x] Test/benchmark generation
- [X] Documentation (including examples)
- [x] Research and understanding

@@ -0,0 +1,8 @@
+- Fixed ``_weighted_percentile`` with ``average=True`` so zero-weight samples
+   just before the end of the array are handled correctly. This
+  can change results when using ``sample_weight`` with
+  :class:`preprocessing.KBinsDiscretizer` (``strategy="quantile"``,
+  ``quantile_method="averaged_inverted_cdf"``) and in
+  :func:`metrics.median_absolute_error`, :func:`metrics.d2_pinball_score`, and
+  :func:`metrics.d2_absolute_error_score`.
+  By :user:`[NAME_REDACTED] <cakedev0>`.

@@ -192,7 +192,7 @@ def _weighted_percentile(
                 )
                 # Handle case where there are trailing 0 sample weight samples
                 # and `percentile_indices` is already max index
-                if next_index >= max_idx:
+                if next_index > max_idx:
                     # use original `percentile_indices` again
                     next_index = percentile_indices[col_idx]

@@ -130,6 +130,13 @@ def test_weighted_percentile_ignores_zero_weight(
         assert approx(value[idx]) == expected_value

+def test_weighted_percentile_average_zero_weight_plateau():
+    """Check zero weights just before `max_index` handled correctly."""
+    score_without_zeros = _weighted_percentile([1, 3], [3, 3], average=True)
+    score_with_zeros = _weighted_percentile([1, 2, 3], [3, 0, 3], average=True)
+    assert approx(score_without_zeros) == score_with_zeros
+
+
 [USER_REDACTED].mark.parametrize("average", [True, False])
 [USER_REDACTED].mark.parametrize("percentile_rank", [20, 35, 50, 61])
 def test_weighted_percentile_frequency_weight_semantics(

The fix looks good.

I checked that it matches the results of scipy 1.17.0:

```python
from scipy.stats import quantile

quantile([1, 2, 3], 0.5, weights=[3, 0, 3], method="averaged_inverted_cdf")
```

LGTM, just a nit.

(Thanks for fixing btw!)

Nit, but this case is specifically when the next non-zero weight is the last index right? We were already skipping zero-weights, maybe something like:

```suggestion
- Fixed ``_weighted_percentile`` with ``average=True`` so zero-weight samples
   just before the end of the array are handled correctly. This
```

This seems more accurate?
```suggestion
    """Check zero weights just before `max_index` handled correctly."""
```

Yes, definitely more accurate thx!


ENH update code to check response values of an estimator

#### [NAME_REDACTED]/PRs
Fixes #33093. See also #33084.

#### What does this implement/fix? Explain your changes.

It implements suggestions made here https://github.com/[USER_REDACTED]n/scikit-learn/issues/33093#issuecomment-3759684206

#### AI usage disclosure
I did **not** use AI assistance.

#### Any other comments?

@@ -0,0 +1,3 @@
+- ``sklearn.utils._response._get_response_values`` now provides a clearer error message
+  when estimator does not implement the given ``response_method``.
+  By :user:`Quentin Barthélemy <qbarthelemy>`.

@@ -383,20 +383,6 @@ def test_multioutput_regressor_error(pyplot):
         DecisionBoundaryDisplay.from_estimator(tree, X, response_method="predict")

-[USER_REDACTED].mark.parametrize(
-    "response_method",
-    ["predict_proba", "decision_function", ["predict_proba", "predict"]],
-)
-def test_regressor_unsupported_response(pyplot, response_method):
-    """Check that we can display the decision boundary for a regressor."""
-    X, y = load_diabetes(return_X_y=True)
-    X = X[:, :2]
-    tree = DecisionTreeRegressor().fit(X, y)
-    err_msg = "should either be a classifier to be used with response_method"
-    with pytest.raises(ValueError, match=err_msg):
-        DecisionBoundaryDisplay.from_estimator(tree, X, response_method=response_method)
-
-
 [USER_REDACTED].mark.filterwarnings(
     # We expect to raise the following warning because the classifier is fit on a
     # NumPy array

@@ -120,7 +120,8 @@ def _get_response_values(
     pos_label=None,
     return_response_method_used=False,
 ):
-    """Compute the response values of a classifier, an outlier detector, or a regressor.
+    """Compute the response values of a classifier, an outlier detector, a regressor
+    or a clusterer.

     The response values are predictions such that it follows the following shape:

@@ -129,8 +130,8 @@ def _get_response_values(
         - with response_method="predict", it is a 1d array of shape `(n_samples,)`;
         - otherwise, it is a 2d array of shape `(n_samples, n_classes)`;
     - for multilabel classification, it is a 2d array of shape `(n_samples, n_outputs)`;
-    - for outlier detection, it is a 1d array of shape `(n_samples,)`;
-    - for regression, it is a 1d array of shape `(n_samples,)`.
+    - for outlier detection, a regressor or a clusterer, it is a 1d array of shape
+      `(n_samples,)`.

     If `estimator` is a binary classifier, also return the label for the
     effective positive class.
@@ -142,9 +143,9 @@ def _get_response_values(
     Parameters
     ----------
     estimator : estimator instance
-        Fitted classifier, outlier detector, or regressor or a
+        Fitted classifier, outlier detector, regressor, clusterer or a
         fitted :class:`~sklearn.pipeline.Pipeline` in which the last estimator is a
-        classifier, an outlier detector, or a regressor.
+        classifier, an outlier detector, a regressor or a clusterer.

     X : {array-like, sparse matrix} of shape (n_samples, n_features)
         Input values.
@@ -180,8 +181,8 @@ def _get_response_values(

     pos_label : int, float, bool, str or None
         The class considered as the positive class when computing
-        the metrics. Returns `None` if `estimator` is a regressor or an outlier
-        detector.
+        the metrics. Returns `None` if `estimator` is a regressor, an outlier
+        detector or a clusterer.

     response_method_used : str
         The response method used to compute the response values. Only returned
@@ -194,13 +195,10 @@ def _get_response_values(
     ValueError
         If `pos_label` is not a valid label.
         If the shape of `y_pred` is not consistent for binary classifier.
-        If the response method can be applied to a classifier only and
-        `estimator` is a regressor.
     """
-    from sklearn.base import is_classifier, is_outlier_detector
+    prediction_method = _check_response_method(estimator, response_method)

     if is_classifier(estimator):
-        prediction_method = _check_response_method(estimator, response_method)
         classes = estimator.classes_
         target_type = type_of_target(classes)

@@ -229,18 +227,7 @@ def _get_response_values(
                 classes=classes,
                 pos_label=pos_label,
             )
-    elif is_outlier_detector(estimator):
-        prediction_method = _check_response_method(estimator, response_method)
-        y_pred, pos_label = prediction_method(X), None
-    else:  # estimator is a regressor
-        if response_method != "predict":
-            raise ValueError(
-                f"{estimator.__class__.__name__} should either be a classifier to be "
-                f"used with response_method={response_method} or the response_method "
-                "should be 'predict'. Got a regressor with response_method="
-                f"{response_method} instead."
-            )
-        prediction_method = estimator.predict
+    else:
         y_pred, pos_label = prediction_method(X), None

     if return_response_method_used:

@@ -4,21 +4,17 @@
 import pytest

 from sklearn.base import clone
+from sklearn.cluster import DBSCAN, KMeans
 from sklearn.datasets import (
     load_iris,
     make_classification,
     make_multilabel_classification,
-    make_regression,
 )
 from sklearn.ensemble import IsolationForest
-from sklearn.linear_model import (
-    LinearRegression,
-    LogisticRegression,
-)
+from sklearn.linear_model import LinearRegression, LogisticRegression
 from sklearn.multioutput import ClassifierChain
 from sklearn.preprocessing import scale
 from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
-from sklearn.utils._mocking import _MockEstimatorOnOffPrediction
 from sklearn.utils._response import _get_response_values, _get_response_values_binary
 from sklearn.utils._testing import assert_allclose, assert_array_equal

@@ -29,56 +25,59 @@

 [USER_REDACTED].mark.parametrize(
-    "response_method", ["decision_function", "predict_proba", "predict_log_proba"]
+    "estimator, response_method",
+    [
+        (DecisionTreeRegressor(), "predict_proba"),
+        (DecisionTreeRegressor(), ["predict_proba", "decision_function"]),
+        (KMeans(n_clusters=2, n_init=1), "predict_proba"),
+        (KMeans(n_clusters=2, n_init=1), ["predict_proba", "decision_function"]),
+        (DBSCAN(), "predict"),
+        (IsolationForest(random_state=0), "predict_proba"),
+        (IsolationForest(random_state=0), ["predict_proba", "score"]),
+    ],
 )
-def test_get_response_values_regressor_error(response_method):
-    """Check the error message with regressor an not supported response
-    method."""
-    my_estimator = _MockEstimatorOnOffPrediction(response_methods=[response_method])
-    X = "mocking_data", "mocking_target"
-    err_msg = f"{my_estimator.__class__.__name__} should either be a classifier"
-    with pytest.raises(ValueError, match=err_msg):
-        _get_response_values(my_estimator, X, response_method=response_method)
-
-
-[USER_REDACTED].mark.parametrize("return_response_method_used", [True, False])
-def test_get_response_values_regressor(return_response_method_used):
-    """Check the behaviour of `_get_response_values` with regressor."""
-    X, y = make_regression(n_samples=10, random_state=0)
-    regressor = LinearRegression().fit(X, y)
-    results = _get_response_values(
-        regressor,
-        X,
-        response_method="predict",
-        return_response_method_used=return_response_method_used,
-    )
-    assert_array_equal(results[0], regressor.predict(X))
-    assert results[1] is None
-    if return_response_method_used:
-        assert results[2] == "predict"
+def test_estimator_unsupported_response(pyplot, estimator, response_method):
+    """Check the error message with not supported response method."""
+    X, y = np.random.RandomState(0).randn(10, 2), np.array([0, 1] * 5)
+    estimator.fit(X, y)
+    err_msg = "has none of the following attributes:"
+    with pytest.raises(AttributeError, match=err_msg):
+        _get_response_values(
+            estimator,
+            X,
+            response_method=response_method,
+        )

 [USER_REDACTED].mark.parametrize(
-    "response_method",
-    ["predict", "decision_function", ["decision_function", "predict"]],
+    "estimator, response_method",
+    [
+        (LinearRegression(), "predict"),
+        (KMeans(n_clusters=2, n_init=1), "predict"),
+        (KMeans(n_clusters=2, n_init=1), "score"),
+        (KMeans(n_clusters=2, n_init=1), ["predict", "score"]),
+        (IsolationForest(random_state=0), "predict"),
+        (IsolationForest(random_state=0), "decision_function"),
+        (IsolationForest(random_state=0), ["decision_function", "predict"]),
+    ],
 )
 [USER_REDACTED].mark.parametrize("return_response_method_used", [True, False])
-def test_get_response_values_outlier_detection(
-    response_method, return_response_method_used
+def test_estimator_get_response_values(
+    estimator, response_method, return_response_method_used
 ):
-    """Check the behaviour of `_get_response_values` with outlier detector."""
-    X, y = make_classification(n_samples=50, random_state=0)
-    outlier_detector = IsolationForest(random_state=0).fit(X, y)
+    """Check the behaviour of `_get_response_values`."""
+    X, y = np.random.RandomState(0).randn(10, 2), np.array([0, 1] * 5)
+    estimator.fit(X, y)
     results = _get_response_values(
-        outlier_detector,
+        estimator,
         X,
         response_method=response_method,
         return_response_method_used=return_response_method_used,
     )
     chosen_response_method = (
         response_method[0] if isinstance(response_method, list) else response_method
     )
-    prediction_method = getattr(outlier_detector, chosen_response_method)
+    prediction_method = getattr(estimator, chosen_response_method)
     assert_array_equal(results[0], prediction_method(X))
     assert results[1] is None
     if return_response_method_used:
@@ -417,6 +416,8 @@ def test_response_values_type_of_target_on_classes_no_warning():
         (IsolationForest(), "predict", "multiclass", (10,)),
         (DecisionTreeRegressor(), "predict", "binary", (10,)),
         (DecisionTreeRegressor(), "predict", "multiclass", (10,)),
+        (KMeans(n_clusters=2, n_init=1), "predict", "binary", (10,)),
+        (KMeans(n_clusters=2, n_init=1), "predict", "multiclass", (10,)),
     ],
 )
 def test_response_values_output_shape_(
@@ -430,8 +431,8 @@ def test_response_values_output_shape_(
         - with response_method="predict", it is a 1d array of shape `(n_samples,)`;
         - otherwise, it is a 2d array of shape `(n_samples, n_classes)`;
     - for multilabel classification, it is a 2d array of shape `(n_samples, n_outputs)`;
-    - for outlier detection, it is a 1d array of shape `(n_samples,)`;
-    - for regression, it is a 1d array of shape `(n_samples,)`.
+    - for outlier detection, regression and clustering,
+      it is a 1d array of shape `(n_samples,)`.
     """
     X = np.random.RandomState(0).randn(10, 2)
     if target_type == "binary":

Thank you for the PR [USER_REDACTED]!

A few remarks:
1. To avoid confusion, please change the title to start with "ENH" or "FIX" instead of "DOC" (as this PR does not change the documentation).
1. Please add a changelog entry [like this](https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md).
1. Your tests are currently failing. In general, we need two new tests here:
    * one that checks that no error is thrown for all types of estimators with their supported `response_methods`, and
    * one that checks that the expected error is thrown for unsupported `response_methods`.
Note that `response_method` can be a string or a list of strings, both cases should be part of the tests.
All existing tests should not be changed (or only adapted according to the new error message), to make sure nothing else is affected by [NAME_REDACTED] know if you need more help with designing the tests.

Thank you for addressing the comments so fast! Here is another round of feedback:

A few more small things:

LGTM now, thank you!

Thank you for the PR [USER_REDACTED] ! LGTM

```suggestion
```
These lines should be removed, as this case is no longer handled by [NAME_REDACTED] `AttributeError` will be raised instead by `_check_response_method` in `sklearn/utils/validation.py`

Add "clusterer" here and below (in response shape and in parameter decription for `estimator`).

Small typo:
```suggestion
        detector or a clusterer
```

```suggestion
  when estimator does not implement the given ``response_method``.
```

Could you please also add corresponding test cases (for binary and multiclass) for a clusterer here:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/3e51c6b0a5cbeaabae1d4c1fb8cd171c20f2635b/sklearn/utils/tests/test_response.py#L422

Could you please extend this test (and rename it to  `test_estimator_unsupported_response`) by adding a parametrization with regressor, outlier detector and clusterer?

Could you please extend the list with `["not-a-valid-method", "predict"]` (which should also result in `predict` being selected)? The logic below would also have to be adapted slightly, or you can change the parametrization into a tuple to explicitly specify the expected method as well.

Same comment as [below](https://github.com/[USER_REDACTED]n/scikit-learn/pull/33126/changes#r2731684885)

I would also be in favor of moving this test to sklearn/utils/tests/test_response.py, to have everything related to testing response values in the same file.

Small nit: this can be condensed into one bullet point as in _response.py below

Wouldn't they all work with n_samples=50? Then `Xy` could be taken out of the parametrization, because this does not directly impact the test.

Can you please also add a test case for `DBSCAN()` with "predict"? (encountered here https://github.com/[USER_REDACTED]n/scikit-learn/issues/33119#issuecomment-3811432790)

Note for reviewers: This is now tested in sklearn/utils/tests/test_response.py in
`test_estimator_unsupported_response`

There is a lot of duplicate code in `test_get_response_values_regressor/outlier_detection/clusterer`. Can you try to parametrize the estimators here as well
(similar to `test_response_values_output_shape_`)?

What I meant was `["not-a-valid-method", "predict"]`, i.e., a case where actually only the second item is supported. But since this is also already tested here:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/b0bf5d7280e8768e3add0d9c83ae950436a1b878/sklearn/utils/tests/test_validation.py#L2100
I think it is ok to remove this again here. Sorry, I didn't see that before.


CI Migrate Linux_Nightly build to GHA

<!--
🙌 Thanks for contributing a pull request!

👀 Please ensure you have taken a look at the contribution guidelines:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/CONTRIBUTING.md

✅ In particular following the pull request checklist will increase the likelihood
of having maintainers review your PR:
https://scikit-learn.org/dev/developers/contributing.html#pull-request-checklist

📋 If your PR is likely to affect users, you will need to add a changelog entry
describing your PR changes, see:
https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md
-->

#### [NAME_REDACTED]/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Towards #32434

<!--
Thank you for your patience. Changes to scikit-learn require careful
attention, but with limited maintainer time, not every contribution can be reviewed
quickly.
For more information and tips on improving your pull request, see:
https://scikit-learn.org/dev/faq.html#why-is-my-pull-request-not-getting-any-attention.

Thanks for contributing!
-->

@@ -289,8 +289,20 @@ jobs:
     if: contains(needs.retrieve-commit-message.outputs.message, '[free-threaded]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
     env:
       DISTRIB: conda-free-threaded
-      LOCK_FILE: ./build_tools/azure/pylatest_free_threaded_linux-64_conda.lock
+      LOCK_FILE: build_tools/azure/pylatest_free_threaded_linux-64_conda.lock
       COVERAGE: false
       # Disable pytest-xdist to use multiple cores for stress-testing with pytest-run-parallel
       PYTEST_XDIST_VERSION: none
     steps: *unit-tests-steps
+
+  scipy-dev:
+    name: Linux x86-64 pylatest_pip_scipy_dev
+    runs-on: ubuntu-22.04
+    needs: [lint, retrieve-commit-message, retrieve-selected-tests]
+    if: contains(needs.retrieve-commit-message.outputs.message, '[scipy-dev]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+    env:
+      DISTRIB: conda-pip-scipy-dev
+      LOCK_FILE: build_tools/azure/pylatest_pip_scipy_dev_linux-64_conda.lock
+      SKLEARN_WARNINGS_AS_ERRORS: 1
+      CHECK_PYTEST_SOFT_DEPENDENCY: true
+    steps: *unit-tests-steps

@@ -45,28 +45,6 @@ jobs:
         python build_tools/check-meson-openmp-dependencies.py
       displayName: [NAME_REDACTED] checks

-
-- template: build_tools/azure/posix.yml
-  parameters:
-    name: Linux_Nightly
-    vmImage: ubuntu-22.04
-    dependsOn: [git_commit, linting]
-    condition: |
-      and(
-        succeeded(),
-        not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')),
-        or(eq(variables['Build.Reason'], 'Schedule'),
-           contains(dependencies['git_commit']['outputs']['commit.message'], '[scipy-dev]'
-          )
-        )
-      )
-    matrix:
-      pylatest_pip_scipy_dev:
-        DISTRIB: 'conda-pip-scipy-dev'
-        LOCK_FILE: './build_tools/azure/pylatest_pip_scipy_dev_linux-64_conda.lock'
-        SKLEARN_WARNINGS_AS_ERRORS: '1'
-        CHECK_PYTEST_SOFT_DEPENDENCY: 'true'
-
 # Will run all the time regardless of linting outcome.
 - template: build_tools/azure/posix.yml
   parameters:

I think we should not automatically run this build without the condition on `[scipy-dev]` appearing in the commit message:

By design, we cannot pin dev dependencies and therefore this build is more likely to fail randomly. We don't want this happening in unrelated PRs.

LGTM, thanks!

I pushed an unrelated minor tweak + `[scipy-dev]` commit to make 100% sure this works as expected (edit: it does run) and enabled auto-merge.

And I agree yaml anchor seems like the simplest solution for now.

For the sake of naming consistency:

```suggestion
          - name: Linux pylatest_pip_scipy_dev
```

```suggestion
    runs-on: ubuntu-22.04
```


Re-enable compressed cache for fetch_kddcup99

Fixes #33117
## Re-enable compressed cache for `fetch_kddcup99
### [NAME_REDACTED] KDDCup99 cache is currently forced to be written uncompressed due to a legacy
decompression workaround. This significantly inflates disk usage for a very large
dataset. Re-enabling compression reduces storage requirements and disk I/O without
changing the public API or user-facing behavior.

### What changed
- Write cached `samples` and `targets` using `joblib.dump(..., compress=3)` in
  `_kddcup99.py`.
- Add a regression test in `test_kddcup99.py` that builds a compressed cache and
  verifies that `fetch_kddcup99` can load it correctly without network access.

### Review focus
- Confirm that the chosen compression level (`compress=3`) is a reasonable
  trade-off between cache size reduction and CPU overhead.
- Validate that the new test reliably covers loading from a compressed cache and
  does not require network access.

### AI usage disclosure
This pull request includes code checks and PR enhancement  using  AI.

@@ -0,0 +1,3 @@
+- Re-enabled compressed caching for :func:`datasets.fetch_kddcup99`, reducing
+  on-disk cache size without changing the public API.
+  By :user:`[NAME_REDACTED] <un1u3>`.

@@ -402,12 +402,8 @@ def _fetch_brute_kddcup99(

         X = Xy[:, :-1]
         y = Xy[:, -1]
-        # XXX bug when compress!=0:
-        # (error: 'Incorrect data length while decompressing[...] the file
-        #  could be corrupted.')
-
-        joblib.dump(X, samples_path, compress=0)
-        joblib.dump(y, targets_path, compress=0)
+        joblib.dump(X, samples_path, compress=3)
+        joblib.dump(y, targets_path, compress=3)
     else:
         raise OSError("Data not found and `download_if_missing` is False")

According to `sklearn/_min_dependencies.py`, the oldest supported version of joblib is 1.3.0 (and the current version is 1.5.3).

I installed this version locally, deleted `~/scikit_learn_data/kddcup99_10-py3` and then I tried to run:

```
SKLEARN_SKIP_NETWORK_TESTS=0 pytest -vl sklearn/datasets/tests/test_kddcup99.py
```

All tests pass.

So this confirms that we don't observe the old joblib compression bug anymore.

The resulting local folder is much smaller, as expected:

```
$ du -hs ~/scikit_learn_data/kddcup99_10-py3
 18M    /Users/ogrisel/scikit_learn_data/kddcup99_10-py3
```

It used to be on the order of 100 MB.

Could you please add a changelog entry for this fix? See: https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md

Besides the following, LGTM.

Why change the source URL? We use the `figshare.com` mirror on purpose because personal pages at university websites tend to disappear over time.

Also, I'd rather stick to https if we can.

I don't think we need this new indirect test. The existing tests should be enough.

Ok I wil remove test

> Why change the source URL? We use the `figshare.com` mirror on purpose because personal pages at university websites tend to disappear over time.
>
> Also, I'd rather stick to https if we can.

Ohh sorry
it was of another issue
I accidently added this
I will remove it asap and push

Please credit yourself using the formatting described in the instructions:

See: https://github.com/[USER_REDACTED]n/scikit-learn/blob/main/doc/whats_new/upcoming_changes/README.md

You could also rename this filename to `33118.efficiency.rst` since this is a disk space optimization.


CI Port `free-threaded` job from Azure to GHA

Part of #32434

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [x] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [ ] Research and understanding

@@ -212,7 +212,7 @@ jobs:

     env: ${{ matrix }}

-    steps:
+    steps: &unit-tests-steps
       - name: Checkout
         uses: actions/checkout@v6

@@ -281,3 +281,16 @@ jobs:
             --junit-file $TEST_DIR/$JUNITXML \
             --auto-close false \
             --job-name "${{ matrix.name }}"
+
+  free-threaded:
+    name: Linux x86-64 pylatest_free_threaded
+    runs-on: ubuntu-latest
+    needs: [lint, retrieve-commit-message, retrieve-selected-tests]
+    if: ${{ contains(needs.retrieve-commit-message.outputs.message, '[free-threaded]') }}
+    env:
+      DISTRIB: conda-free-threaded
+      LOCK_FILE: ./build_tools/azure/pylatest_free_threaded_linux-64_conda.lock
+      COVERAGE: false
+      # Disable pytest-xdist to use multiple cores for stress-testing with pytest-run-parallel
+      PYTEST_XDIST_VERSION: none
+    steps: *unit-tests-steps

@@ -67,29 +67,6 @@ jobs:
         SKLEARN_WARNINGS_AS_ERRORS: '1'
         CHECK_PYTEST_SOFT_DEPENDENCY: 'true'

-- template: build_tools/azure/posix.yml
-  # CPython free-threaded build
-  parameters:
-    name: Linux_free_threaded
-    vmImage: ubuntu-22.04
-    dependsOn: [git_commit, linting]
-    condition: |
-      and(
-        succeeded(),
-        not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]')),
-        or(eq(variables['Build.Reason'], 'Schedule'),
-           contains(dependencies['git_commit']['outputs']['commit.message'], '[free-threaded]'
-          )
-        )
-      )
-    matrix:
-      pylatest_free_threaded:
-        DISTRIB: 'conda-free-threaded'
-        LOCK_FILE: './build_tools/azure/pylatest_free_threaded_linux-64_conda.lock'
-        COVERAGE: 'false'
-        # Disable pytest-xdist to use multiple cores for stress-testing with pytest-run-parallel
-        PYTEST_XDIST_VERSION: 'none'
-
 # Will run all the time regardless of linting outcome.
 - template: build_tools/azure/posix.yml
   parameters:

@@ -463,6 +463,9 @@ def test_error():
     assert not hasattr(BaggingClassifier(base).fit(X, y), "decision_function")

+# TODO: remove mark once loky bug is fixed:
+# https://github.com/joblib/loky/issues/458
+[USER_REDACTED].mark.thread_unsafe
 def test_parallel_classification():
     # Check parallel classification.
     X_train, X_test, y_train, y_test = train_test_split(

LGTM thanks!

[USER_REDACTED] I don't understand what you are trying to achieve with this? Is the purpose to check what happens if the condition is never met? Is this causing the jobs to be stuck in the "pending" state?

So this is causing:

```
Invalid workflow file: .github/workflows/unit-tests.yml#L1
(Line: 138, Col: 9): Unrecognized named-value: 'matrix'. Located at position 53 within expression: github.repository == 'scikit-learn/scikit-learn' && matrix.RUN_CONDITION == 'bla'
```

https://github.com/[USER_REDACTED]n/scikit-learn/actions/runs/21293108036

This was a test commit to check what part of the condition triggered the syntax error. Indeed `matrix` cannot be used in the context of an `if`, hence my comment above saying we might need some extra setup.


DOC/MNT Little clean up around the splitting docs and error message

Noticed while working on #33089

#### What does this implement/fix? Explain your changes.
A few clean ups around the splitting docs and error message.

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [ ] Research and understanding

@@ -142,9 +142,9 @@ class CalibratedClassifierCV(ClassifierMixin, MetaEstimatorMixin, BaseEstimator)
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if ``y`` is binary or multiclass,
         :class:`~sklearn.model_selection.StratifiedKFold` is used. If ``y`` is

@@ -747,9 +747,9 @@ class GraphicalLassoCV(BaseGraphicalLasso):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs :class:`~sklearn.model_selection.KFold` is used.

@@ -24,7 +24,7 @@

 class SelectorMixin(TransformerMixin, metaclass=ABCMeta):
     """
-    Transformer mixin that performs feature selection given a support mask
+    Transformer mixin that performs feature selection given a support mask.

     This mixin provides a feature selector implementation with `transform` and
     `inverse_transform` functionality given an implementation of

@@ -597,9 +597,9 @@ class RFECV(RFE):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if ``y`` is binary or multiclass,
         :class:`~sklearn.model_selection.StratifiedKFold` is used. If the

@@ -99,7 +99,7 @@ class SequentialFeatureSelector(SelectorMixin, MetaEstimatorMixin, BaseEstimator
         - None, to use the default 5-fold cross validation,
         - integer, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass,

@@ -2046,9 +2046,9 @@ class LassoCV(RegressorMixin, LinearModelCV):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - int, to specify the number of folds.
+        - int, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For int/None inputs, :class:`~sklearn.model_selection.KFold` is used.

@@ -2316,9 +2316,9 @@ class ElasticNetCV(RegressorMixin, LinearModelCV):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - int, to specify the number of folds.
+        - int, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For int/None inputs, :class:`~sklearn.model_selection.KFold` is used.

@@ -3014,9 +3014,9 @@ class MultiTaskElasticNetCV(RegressorMixin, LinearModelCV):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - int, to specify the number of folds.
+        - int, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For int/None inputs, :class:`~sklearn.model_selection.KFold` is used.

@@ -3272,9 +3272,9 @@ class MultiTaskLassoCV(RegressorMixin, LinearModelCV):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - int, to specify the number of folds.
+        - int, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For int/None inputs, :class:`~sklearn.model_selection.KFold` is used.

@@ -1542,9 +1542,9 @@ class LarsCV(Lars):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, :class:`~sklearn.model_selection.KFold` is used.

@@ -1862,9 +1862,9 @@ class LassoLarsCV(LarsCV):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, :class:`~sklearn.model_selection.KFold` is used.

@@ -926,9 +926,9 @@ class OrthogonalMatchingPursuitCV(RegressorMixin, LinearModel):
         Possible inputs for cv are:

         - None, to use the default 5-fold cross-validation,
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, :class:`~sklearn.model_selection.KFold` is used.

@@ -2654,9 +2654,9 @@ class RidgeCV(MultiOutputMixin, RegressorMixin, _BaseRidgeCV):
         Possible inputs for cv are:

         - None, to use the efficient Leave-One-Out cross-validation
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if ``y`` is binary or multiclass,
         :class:`~sklearn.model_selection.StratifiedKFold` is used, else,
@@ -2847,9 +2847,9 @@ class RidgeClassifierCV(_RidgeClassifierMixin, _BaseRidgeCV):
         Possible inputs for cv are:

         - None, to use the efficient Leave-One-Out cross-validation
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         Refer :ref:`[NAME_REDACTED] <cross_validation>` for the various
         cross-validation strategies that can be used here.

@@ -354,7 +354,7 @@ def from_estimator(
             - None, to use the default 5-fold cross validation,
             - int, to specify the number of folds in a `(Stratified)KFold`,
             - :term:`CV splitter`,
-            - An iterable yielding (train, test) splits as arrays of indices.
+            - an iterable yielding (train, test) splits as arrays of indices.

             For int/None inputs, if the estimator is a classifier and `y` is
             either binary or multiclass,
@@ -741,7 +741,7 @@ def from_estimator(
             - None, to use the default 5-fold cross validation,
             - int, to specify the number of folds in a `(Stratified)KFold`,
             - :term:`CV splitter`,
-            - An iterable yielding (train, test) splits as arrays of indices.
+            - an iterable yielding (train, test) splits as arrays of indices.

             For int/None inputs, if the estimator is a classifier and `y` is
             either binary or multiclass,

@@ -1347,7 +1347,7 @@ class GridSearchCV(BaseSearchCV):
         - None, to use the default 5-fold cross validation,
         - integer, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all
@@ -1732,7 +1732,7 @@ class RandomizedSearchCV(BaseSearchCV):
         - None, to use the default 5-fold cross validation,
         - integer, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all

@@ -461,7 +461,7 @@ class HalvingGridSearchCV(BaseSuccessiveHalving):

         - integer, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all
@@ -820,7 +820,7 @@ class HalvingRandomSearchCV(BaseSuccessiveHalving):

         - integer, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For integer/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all

@@ -2696,9 +2696,9 @@ def check_cv(cv=5, y=None, *, classifier=False):
         Determines the cross-validation splitting strategy.
         Possible inputs for cv are:
         - None, to use the default 5-fold cross validation,
-        - integer, to specify the number of folds.
+        - integer, to specify the number of folds,
         - :term:`CV splitter`,
-        - An iterable that generates (train, test) splits as arrays of indices.
+        - an iterable that generates (train, test) splits as arrays of indices.

         For integer/None inputs, if classifier is True and ``y`` is either
         binary or multiclass, :class:`StratifiedKFold` is used. In all other
@@ -2714,8 +2714,10 @@ def check_cv(cv=5, y=None, *, classifier=False):
         The target variable for supervised learning problems.

     classifier : bool, default=False
-        Whether the task is a classification task, in which case
-        stratified KFold will be used.
+        Whether the task is a classification task. When ``True`` and `cv` is an
+        integer or ``None``, :class:`StratifiedKFold` is used if ``y`` is binary
+        or multiclass; otherwise :class:`KFold` is used. Ignored if `cv` is a
+        cross-validator instance or iterable.

     Returns
     -------
@@ -2745,9 +2747,9 @@ def check_cv(cv=5, y=None, *, classifier=False):
     if not hasattr(cv, "split") or isinstance(cv, str):
         if not isinstance(cv, Iterable) or isinstance(cv, str):
             raise ValueError(
-                "Expected cv as an integer, cross-validation "
-                "object (from sklearn.model_selection) "
-                "or an iterable. Got %s." % cv
+                "Expected `cv` as an integer, a cross-validation object "
+                "(from sklearn.model_selection), or an iterable yielding (train, test) "
+                f"splits as arrays of indices. Got {cv}."
             )
         return _CVIterableWrapper(cv)

@@ -170,7 +170,7 @@ def cross_validate(
         - None, to use the default 5-fold cross validation,
         - int, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For int/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all
@@ -1504,7 +1504,7 @@ def permutation_test_score(
         - `None`, to use the default 5-fold cross validation,
         - int, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For `int`/`None` inputs, if the estimator is a classifier and `y` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all
@@ -1810,7 +1810,7 @@ def learning_curve(
         - None, to use the default 5-fold cross validation,
         - int, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For int/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all
@@ -2296,7 +2296,7 @@ def validation_curve(
         - None, to use the default 5-fold cross validation,
         - int, to specify the number of folds in a `(Stratified)KFold`,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

         For int/None inputs, if the estimator is a classifier and ``y`` is
         either binary or multiclass, :class:`StratifiedKFold` is used. In all

@@ -1621,7 +1621,8 @@ def test_check_cv():
     cv = check_cv(3, y_multioutput, classifier=True)
     np.testing.assert_equal(list(KFold(3).split(X)), list(cv.split(X)))

-    with pytest.raises(ValueError):
+    msg = "Expected `cv` as an integer, a cross-validation object"
+    with pytest.raises(ValueError, match=msg):
         check_cv(cv="lolo")

@@ -920,7 +920,7 @@ class ClassifierChain(MetaEstimatorMixin, ClassifierMixin, _BaseChain):
         - None, to use true labels when fitting,
         - integer, to specify the number of folds in a (Stratified)KFold,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

     chain_method : {'predict', 'predict_proba', 'predict_log_proba', \
             'decision_function'} or list of such str's, default='predict'
@@ -1205,7 +1205,7 @@ class RegressorChain(MetaEstimatorMixin, RegressorMixin, _BaseChain):
         - None, to use true labels when fitting,
         - integer, to specify the number of folds in a (Stratified)KFold,
         - :term:`CV splitter`,
-        - An iterable yielding (train, test) splits as arrays of indices.
+        - an iterable yielding (train, test) splits as arrays of indices.

     random_state : int, RandomState instance or None, optional (default=None)
         If ``order='random'``, determines random number generation for the

Thank you [USER_REDACTED]! I have one nit and one clarification request (that actually goes beyond your changes).

Thank you [USER_REDACTED]! I agree with your points and have pushed the changes.

One more small nit:
There are three different variations being used:
`- integer, to specify the number of folds,`
`- int, to specify the number of folds,`
`- integer, to specify the number of folds in a `(Stratified)KFold`,`
`- int, to specify the number of folds in a `(Stratified)KFold`,`

Should we make them all the same?

Alright, then LGTM.

I made the error message more specific, since strings are also iterables.

If we're being picky, this (and all other occurrences below) also has to start with a lowercase `an`

According to lines 2703-2705 above, there are more dependencies to this selection. This could need some more clarification.

Oh yea, let's be picky. It looks more trustable if there are not these kind of mistakes.

True, I have added the additional information, do explain this better.


ENH Turn `TargetEncoder` into a metadata router and route `groups` to `cv` object

#### [NAME_REDACTED]/PRs
closes #32076
supersedes #32239 and #32843

#### What does this implement/fix? Explain your changes.
- turns `TargetEncoder` into a metadata router that routes `groups` to the internal splitter
- adds more input options for `cv` init param (as discussed here https://github.com/[USER_REDACTED]n/scikit-learn/issues/32076#issuecomment-3307697377)
- exposes cv_ attribute
- add tests

#### AI usage disclosure
<!--
If AI tools were involved in creating this PR, please check all boxes that apply
below and make sure that you adhere to our [NAME_REDACTED]:
https://scikit-learn.org/dev/developers/contributing.html#automated-contributions-policy
-->
I used AI assistance for:
- [ ] Code generation (e.g., when writing an implementation or fixing a bug)
- [ ] Test/benchmark generation
- [ ] Documentation (including examples)
- [x] Research and understanding

@@ -317,6 +317,7 @@ Meta-estimators and functions supporting metadata routing:
 - :class:`sklearn.multioutput.MultiOutputClassifier`
 - :class:`sklearn.multioutput.MultiOutputRegressor`
 - :class:`sklearn.multioutput.RegressorChain`
+- :class:`sklearn.preprocessing.TargetEncoder`
 - :class:`sklearn.pipeline.FeatureUnion`
 - :class:`sklearn.pipeline.Pipeline`
 - :class:`sklearn.semi_supervised.SelfTrainingClassifier`

@@ -0,0 +1,5 @@
+- :class:`~preprocessing.TargetEncoder` now routes `groups` to the :term:`CV splitter`
+  internally used for :term:`cross fitting` in its
+  :meth:`~preprocessing.TargetEncoder.fit_transform`.
+  By :user:`[NAME_REDACTED] <samruddhibaviskar11>` and
+  :user:`[NAME_REDACTED] <StefanieSenger>`.

@@ -2687,7 +2687,7 @@ def split(self, X=None, y=None, groups=None):
             yield train, test

-def check_cv(cv=5, y=None, *, classifier=False):
+def check_cv(cv=5, y=None, *, classifier=False, shuffle=False, random_state=None):
     """Input checker utility for building a cross-validator.

     Parameters
@@ -2719,6 +2719,19 @@ def check_cv(cv=5, y=None, *, classifier=False):
         or multiclass; otherwise :class:`KFold` is used. Ignored if `cv` is a
         cross-validator instance or iterable.

+    shuffle : bool, default=False
+        Whether to shuffle the data before splitting into batches. Note that the samples
+        within each split will not be shuffled. Only applies if `cv` is an int or
+        `None`. If `cv` is a cross-validation generator or an iterable, `shuffle` is
+        ignored.
+
+    random_state : int, RandomState instance or None, default=None
+        When `shuffle` is True and `cv` is an integer or `None`, `random_state` affects
+        the ordering of the indices, which controls the randomness of each fold.
+        Otherwise, this parameter has no effect.
+        Pass an int for reproducible output across multiple function calls.
+        See :term:`Glossary <random_state>`.
+
     Returns
     -------
     checked_cv : a cross-validator instance.
@@ -2740,9 +2753,9 @@ def check_cv(cv=5, y=None, *, classifier=False):
             and (y is not None)
             and (type_of_target(y, input_name="y") in ("binary", "multiclass"))
         ):
-            return StratifiedKFold(cv)
+            return StratifiedKFold(cv, shuffle=shuffle, random_state=random_state)
         else:
-            return KFold(cv)
+            return KFold(cv, shuffle=shuffle, random_state=random_state)

     if not hasattr(cv, "split") or isinstance(cv, str):
         if not isinstance(cv, Iterable) or isinstance(cv, str):

@@ -1,7 +1,7 @@
 # Authors: The scikit-learn developers
 # SPDX-License-Identifier: BSD-3-Clause

-from numbers import Integral, Real
+from numbers import Real

 import numpy as np

@@ -11,6 +11,14 @@
     _fit_encoding_fast,
     _fit_encoding_fast_auto_smooth,
 )
+from sklearn.utils import Bunch, indexable
+from sklearn.utils._metadata_requests import (
+    MetadataRouter,
+    MethodMapping,
+    _raise_for_params,
+    _routing_enabled,
+    process_routing,
+)
 from sklearn.utils._param_validation import Interval, StrOptions
 from sklearn.utils.multiclass import type_of_target
 from sklearn.utils.validation import (
@@ -41,7 +49,7 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
     that are not seen during :meth:`fit` are encoded with the target mean, i.e.
     `target_mean_`.

-    For a demo on the importance of the `TargetEncoder` internal cross-fitting,
+    For a demo on the importance of the `TargetEncoder` internal :term:`cross fitting`,
     see
     :ref:`sphx_glr_auto_examples_preprocessing_plot_target_encoder_cross_val.py`.
     For a comparison of different encoders, refer to
@@ -94,14 +102,33 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         more weight on the global target mean.
         If `"auto"`, then `smooth` is set to an empirical Bayes estimate.

-    cv : int, default=5
-        Determines the number of folds in the :term:`cross fitting` strategy used in
-        :meth:`fit_transform`. For classification targets, `StratifiedKFold` is used
-        and for continuous targets, `KFold` is used.
+    cv : int, cross-validation generator or an iterable, default=None
+        Determines the splitting strategy used in the internal :term:`cross fitting`
+        during :meth:`fit_transform`. Splitters where each sample index doesn't appear
+        in the validation fold exactly once, raise a `ValueError`.
+        Possible inputs for cv are:
+
+        - `None`, to use a 5-fold cross-validation chosen internally based on
+            `target_type`,
+        - integer, to specify the number of folds for the cross-validation chosen
+            internally based on `target_type`,
+        - :term:`CV splitter` that does not repeat samples across validation folds,
+        - an iterable yielding (train, test) splits as arrays of indices.
+
+        For integer/None inputs, if `target_type` is `"continuous"`, :class:`KFold` is
+        used, otherwise :class:`StratifiedKFold` is used.
+
+        Refer :ref:`[NAME_REDACTED] <cross_validation>` for more information on
+        cross-validation strategies.
+
+        .. versionchanged:: 1.9
+            Cross-validation generators and iterables can also be passed as `cv`.

     shuffle : bool, default=True
         Whether to shuffle the data in :meth:`fit_transform` before splitting into
-        folds. Note that the samples within each split will not be shuffled.
+        folds. Note that the samples within each split will not be shuffled. Only
+        applies if `cv` is an int or `None`. If `cv` is a cross-validation generator or
+        an iterable, `shuffle` is ignored.

     random_state : int, RandomState instance or None, default=None
         When `shuffle` is True, `random_state` affects the ordering of the
@@ -193,7 +220,7 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         "categories": [StrOptions({"auto"}), list],
         "target_type": [StrOptions({"auto", "continuous", "binary", "multiclass"})],
         "smooth": [StrOptions({"auto"}), Interval(Real, 0, None, closed="left")],
-        "cv": [Interval(Integral, 2, None, closed="left")],
+        "cv": ["cv_object"],
         "shuffle": ["boolean"],
         "random_state": ["random_state"],
     }
@@ -243,7 +270,7 @@ def fit(self, X, y):
         return self

     @_fit_context(prefer_skip_nested_validation=True)
-    def fit_transform(self, X, y):
+    def fit_transform(self, X, y, **params):
         """Fit :class:`TargetEncoder` and transform `X` with the target encoding.

         This method uses a :term:`cross fitting` scheme to prevent target leakage
@@ -263,28 +290,71 @@ def fit_transform(self, X, y):
         y : array-like of shape (n_samples,)
             The target data used to encode the categories.

+        **params : dict
+            Parameters to route to the internal CV object.
+
+            Can only be used in conjunction with a cross-validation generator as CV
+            object.
+
+            For instance, `groups` (array-like of shape `(n_samples,)`) can be routed to
+            a CV splitter that accepts `groups`, such as :class:`GroupKFold` or
+            :class:`StratifiedGroupKFold`.
+
+            .. versionadded:: 1.9
+                Only available if `enable_metadata_routing=True`, which can be
+                set by using ``sklearn.set_config(enable_metadata_routing=True)``.
+                See :ref:`[NAME_REDACTED] Guide <metadata_routing>` for
+                more details.
+
         Returns
         -------
         X_trans : ndarray of shape (n_samples, n_features) or \
                     (n_samples, (n_features * n_classes))
             Transformed input.
         """
-        from sklearn.model_selection import (  # avoid circular import
+        # avoid circular imports
+        from sklearn.model_selection import (
+            GroupKFold,
             KFold,
+            StratifiedGroupKFold,
             StratifiedKFold,
         )
+        from sklearn.model_selection._split import check_cv
+
+        _raise_for_params(params, self, "fit_transform")

         X_ordinal, X_known_mask, y_encoded, n_categories = self._fit_encodings_all(X, y)

-        # The cv splitter is voluntarily restricted to *KFold to enforce non
-        # overlapping validation folds, otherwise the fit_transform output will
-        # not be well-specified.
-        if self.target_type_ == "continuous":
-            cv = KFold(self.cv, shuffle=self.shuffle, random_state=self.random_state)
+        cv = check_cv(
+            self.cv,
+            y,
+            classifier=self.target_type_ != "continuous",
+            shuffle=self.shuffle,
+            random_state=self.random_state,
+        )
+
+        if _routing_enabled():
+            if params["groups"] is not None:
+                X, y, params["groups"] = indexable(X, y, params["groups"])
+            routed_params = process_routing(self, "fit_transform", **params)
         else:
-            cv = StratifiedKFold(
-                self.cv, shuffle=self.shuffle, random_state=self.random_state
-            )
+            routed_params = Bunch(splitter=Bunch(split={}))
+
+        # The internal cross-fitting is only well-defined when each sample index
+        # appears in exactly one validation fold. Skip the validation check for
+        # known non-overlapping splitters in scikit-learn:
+        if not isinstance(
+            cv, (GroupKFold, KFold, StratifiedKFold, StratifiedGroupKFold)
+        ):
+            seen_count = np.zeros(X.shape[0])
+            for _, test_idx in cv.split(X, y, **routed_params.splitter.split):
+                seen_count[test_idx] += 1
+            if not np.all(seen_count == 1):
+                raise ValueError(
+                    "Validation indices from `cv` must cover each sample index exactly "
+                    "once with no overlap. Pass a splitter with non-overlapping "
+                    "validation folds as `cv` or refer to the docs for other options."
+                )

         # If 'multiclass' multiply axis=1 by [NAME_REDACTED] shape the same
         if self.target_type_ == "multiclass":
@@ -295,7 +365,7 @@ def fit_transform(self, X, y):
         else:
             X_out = np.empty_like(X_ordinal, dtype=np.float64)

-        for train_idx, test_idx in cv.split(X, y):
+        for train_idx, test_idx in cv.split(X, y, **routed_params.splitter.split):
             X_train, y_train = X_ordinal[train_idx, :], y_encoded[train_idx]
             y_train_mean = np.mean(y_train, axis=0)

@@ -546,6 +616,33 @@ def get_feature_names_out(self, input_features=None):
         else:
             return feature_names

+    def get_metadata_routing(self):
+        """Get metadata routing of this object.
+
+        Please check :ref:`[NAME_REDACTED] <metadata_routing>` on how the routing
+        mechanism works.
+
+        .. versionadded:: 1.9
+
+        Returns
+        -------
+        routing : MetadataRouter
+            A :class:`~sklearn.utils.metadata_routing.MetadataRouter` encapsulating
+            routing information.
+        """
+
+        router = MetadataRouter(owner=self)
+
+        router.add(
+            # This works, since none of {None, int, iterable} request any metadata
+            # and the machinery here would assign an empty MetadataRequest
+            # to it.
+            splitter=self.cv,
+            method_mapping=MethodMapping().add(caller="fit_transform", callee="split"),
+        )
+
+        return router
+
     def __sklearn_tags__(self):
         tags = super().__sklearn_tags__()
         tags.target_tags.required = True

@@ -5,6 +5,7 @@
 import pytest
 from numpy.testing import assert_allclose, assert_array_equal

+from sklearn.datasets import make_regression
 from sklearn.ensemble import RandomForestRegressor
 from sklearn.linear_model import Ridge
 from sklearn.model_selection import (
@@ -732,3 +733,22 @@ def test_pandas_copy_on_write():
             with pd.option_context("mode.copy_on_write", True):
                 df = pd.DataFrame({"x": ["a", "b", "b"], "y": [4.0, 5.0, 6.0]})
                 TargetEncoder(target_type="continuous").fit(df[["x"]], df["y"])
+
+
+def test_target_encoder_raises_cv_overlap(global_random_seed):
+    """
+    Test that `TargetEncoder` raises if `cv` has overlapping splits.
+    """
+    X, y = make_regression(n_samples=100, n_features=3, random_state=0)
+
+    non_overlapping_iterable = KFold().split(X, y)
+    encoder = TargetEncoder(cv=non_overlapping_iterable)
+    encoder.fit_transform(X, y)
+
+    overlapping_iterable = ShuffleSplit(
+        n_splits=5, random_state=global_random_seed
+    ).split(X, y)
+    encoder = TargetEncoder(cv=overlapping_iterable)
+    msg = "Validation indices from `cv` must cover each sample index exactly once"
+    with pytest.raises(ValueError, match=msg):
+        encoder.fit_transform(X, y)

@@ -15,7 +15,7 @@
 )
 from sklearn.metrics._scorer import _Scorer, mean_squared_error
 from sklearn.model_selection import BaseCrossValidator
-from sklearn.model_selection._split import GroupsConsumerMixin
+from sklearn.model_selection._split import GroupKFold, GroupsConsumerMixin
 from sklearn.utils._metadata_requests import (
     SIMPLE_METHODS,
 )
@@ -480,6 +480,11 @@ def _iter_test_indices(self, X=None, y=None, groups=None):
         yield train_indices

+class ConsumingSplitterInheritingFromGroupKFold(ConsumingSplitter, GroupKFold):
+    """Helper class that can be used to test TargetEncoder, that only takes specific
+    splitters."""
+
+
 class MetaRegressor(MetaEstimatorMixin, RegressorMixin, BaseEstimator):
     """A meta-regressor which is only a router."""

@@ -63,12 +63,14 @@
     MultiOutputRegressor,
     RegressorChain,
 )
+from sklearn.preprocessing import TargetEncoder
 from sklearn.semi_supervised import SelfTrainingClassifier
 from sklearn.tests.metadata_routing_common import (
     ConsumingClassifier,
     ConsumingRegressor,
     ConsumingScorer,
     ConsumingSplitter,
+    ConsumingSplitterInheritingFromGroupKFold,
     NonConsumingClassifier,
     NonConsumingRegressor,
     _Registry,
@@ -448,6 +450,13 @@
         "X": X,
         "y": y,
     },
+    {
+        "metaestimator": TargetEncoder,
+        "X": X,
+        "y": y,
+        "cv_name": "cv",
+        "cv_routing_methods": ["fit_transform"],
+    },
 ]
 """List containing all metaestimators to be tested and their settings

@@ -560,7 +569,10 @@ def get_init_args(metaestimator_info, sub_estimator_consumes):
     if "cv_name" in metaestimator_info:
         cv_name = metaestimator_info["cv_name"]
         cv_registry = _Registry()
-        cv = ConsumingSplitter(registry=cv_registry)
+        if metaestimator_info["metaestimator"] is TargetEncoder:
+            cv = ConsumingSplitterInheritingFromGroupKFold(registry=cv_registry)
+        else:
+            cv = ConsumingSplitter(registry=cv_registry)
         kwargs[cv_name] = cv

     return (

Thanks, [USER_REDACTED]!

a few minor comments, otherwise LGTM.

Thanks for the review, [USER_REDACTED]! I have applied your suggestions.

Otherwise LGTM.

[USER_REDACTED] wanna have a final look?

LGTM

Passing `**routed_params.splitter.split` into `cv.split` is needed for the test. It doesn't route here since nothing is done with the splits, but the routed metadata gets recorded in the `_records` attribute of the splitter.

We need to add a shuffle option to check_cv, since by default, data in TargetEncoder get shuffled before it gets split via the splitter.

Can this be an `isinstance` check?

What is `self.cv_` used for?

using `np.concaetenate` allocates new arrays during each cycle. As an alternative can we create an int array of length `X.shape[0]`, add 1 to each position we seen, and then check that all the values are 1?

High level:

```python
seen_count= np.zeros(X.shape[0])
for _, test_idx in cv.split(...):
    seen_count[test_idx] += 1

# check that all seen_count is equal to one
```

Yes, I actually wanted to change that and then forgot about this. Now it is an `isinstance` check.

Just as a check for the user which splitting strategy had been internally determined, to reduce the magic.
(It was more necessary in #32843 than here, since this version doesn't introduce an automatism for internally determining a grouped splitter, but might still be useful here?)
What do you think about this?

Oh yes, of cause!

I do not recall having a precedence of storing an object's name as an attribute, but I think it's okay here because it's a short string. Is there a test to check that `cv_` is set correctly?

The alternative is to store the `cv` object itself, but one can have an custom cv object that is large. (Although, I think it'll be unlikely for custom cv objects to get big.)

> Is there a test to check that `cv_` is set correctly?

I have added `TargetEncoder` to `test_fit_docstring_attributes` in `sklearn/tests/test_docstring_parameters.py` to check for this attribute. The test is a simple test if the attribute is exposed after `fit_transform`. It does not test for correctness. I will add a test for this.

add a note that these are used only when cv is an integer

```suggestion
            `target_type`,
```

we don't really do this in other places, and thinking of our conventions, if I see `obj.cv_` I'd expect the actual instance, not the name. So I'd remove this attribute.

now that users can pass an actual `cv`, this is redundant, so ideally I'd remove it, but I don't think we have to go through the deprecation and leave it here.

same applies here.

to be removed

we don't need this, unless, something breaks in a pipeline or a metaestimator calling `fit.transform` instead of `fit_transform`, but I think that already breaks since the two are not equivalent anyway.

all of this can become:

```py
          cv = check_cv(
              self.cv,
              y,
              classifier=self.target_type_ != "continuous",
              shuffle=self.shuffle,
              random_state=self.random_state,
          )
```

Yes, I have removed it.

I agree. I can later open a follow up-PR on this to deprecate `shuffle` and `random_state`.

I have now removed the attribute and the test case. See https://github.com/[USER_REDACTED]n/scikit-learn/pull/33089/changes#r2704508846

Yes, true, thanks.

Note to reviewers / readers from the very distant future:

This calls `split` here, and then the same method is called again the for loop bellow. Which means we're running split twice.

However, compared to what happens in the for loop bellow, this is really fast, and it's a better user experience if we raise here quickly, rather than potentially raising in the expensive for loop bellow after a few iterations.

```suggestion
        router.add(
            # This works, since none of {None, int, iterable} request any metadata
            # and the machinery here would assign an empty MetadataRequest
            # to it.
            splitter=self.cv,
```


CI [NAME_REDACTED] min_dependencies test job from Azure to GHA

Addresses part of #32434

@@ -127,6 +127,19 @@ jobs:
             DISTRIB: conda
             LOCK_FILE: build_tools/github/pymin_conda_forge_arm_linux-aarch64_conda.lock

+          # Linux build with minimum supported version of dependencies
+          - name: Linux x86-64 pymin_conda_forge_openblas_min_dependencies
+            os: ubuntu-22.04
+            DISTRIB: conda
+            LOCK_FILE: build_tools/azure/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock
+            # Enable debug Cython directives to capture IndexError exceptions in
+            # combination with the -Werror::pytest.PytestUnraisableExceptionWarning
+            # flag for pytest.
+            # https://github.com/[USER_REDACTED]n/scikit-learn/pull/24438
+            SKLEARN_ENABLE_DEBUG_CYTHON_DIRECTIVES: 1
+            SKLEARN_RUN_FLOAT32_TESTS: 1
+            SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 2  # non-default seed
+
           # Linux environment to test the latest available dependencies.
           # It runs tests requiring lightgbm, pandas and PyAMG.
           - name: Linux pylatest_pip_openblas_pandas

@@ -138,30 +138,6 @@ jobs:
         COVERAGE: 'false'
         SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '0'  # non-default seed

-- template: build_tools/azure/posix.yml
-  parameters:
-    name: Linux
-    vmImage: ubuntu-22.04
-    dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish]
-    # Runs when dependencies succeeded or skipped
-    condition: |
-      and(
-        not(or(failed(), canceled())),
-        not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]'))
-      )
-    matrix:
-      # Linux build with minimum supported version of dependencies
-      pymin_conda_forge_openblas_min_dependencies:
-        DISTRIB: 'conda'
-        LOCK_FILE: './build_tools/azure/pymin_conda_forge_openblas_min_dependencies_linux-64_conda.lock'
-        # Enable debug Cython directives to capture IndexError exceptions in
-        # combination with the -Werror::pytest.PytestUnraisableExceptionWarning
-        # flag for pytest.
-        # https://github.com/[USER_REDACTED]n/scikit-learn/pull/24438
-        SKLEARN_ENABLE_DEBUG_CYTHON_DIRECTIVES: '1'
-        SKLEARN_RUN_FLOAT32_TESTS: '1'
-        SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '2'  # non-default seed
-
 - template: build_tools/azure/posix-docker.yml
   parameters:
     name: Linux_Docker

```suggestion
            os: ubuntu-24.04
```
maybe?

This one I would leave it like this.

- it may be done on purpose to use the oldest not EOLed Ubuntu version to check against old compiler versions that everything works fine. Maybe if you are curious some code archeology could enlighten us, but I don't think this is worth doing right now.
- not changing too many unrelated things at the same time unless there is a strong need.


CI [NAME_REDACTED] no_openmp test job from Azure to GHA

Addresses part of #32434

@@ -167,14 +167,22 @@ jobs:
             SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 1  # non-default seed

           - name: macOS pylatest_conda_forge_arm
-            os: macOS-15
+            os: macos-15
             DISTRIB: conda
             LOCK_FILE: build_tools/azure/pylatest_conda_forge_osx-arm64_conda.lock
             SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 5  # non-default seed
             SCIPY_ARRAY_API: 1
             PYTORCH_ENABLE_MPS_FALLBACK: 1
             CHECK_PYTEST_SOFT_DEPENDENCY: true

+          - name: macOS x86-64 pylatest_conda_forge_mkl_no_openmp
+            os: macos-15-intel
+            DISTRIB: conda
+            LOCK_FILE: build_tools/azure/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock
+            SKLEARN_TEST_NO_OPENMP: true
+            SKLEARN_SKIP_OPENMP_TEST: true
+            SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 6  # non-default seed
+
           - name: Windows x64 pymin_conda_forge_openblas
             os: windows-latest
             DISTRIB: conda

@@ -156,22 +156,3 @@ jobs:
         COVERAGE: "true"
         LOCK_FILE: './build_tools/azure/debian_32bit_lock.txt'
         SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '4'  # non-default seed
-
-- template: build_tools/azure/posix.yml
-  parameters:
-    name: macOS
-    vmImage: macOS-15
-    dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish]
-    # Runs when dependencies succeeded or skipped
-    condition: |
-      and(
-        not(or(failed(), canceled())),
-        not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]'))
-      )
-    matrix:
-      pylatest_conda_forge_mkl_no_openmp:
-        DISTRIB: 'conda'
-        LOCK_FILE: './build_tools/azure/pylatest_conda_forge_mkl_no_openmp_osx-64_conda.lock'
-        SKLEARN_TEST_NO_OPENMP: 'true'
-        SKLEARN_SKIP_OPENMP_TEST: 'true'
-        SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '6'  # non-default seed

Thanks for the PR, a few comments below

So there is a joke here, I think you need to use macos-15-intel on GHA to have a macOS Intel VM. macOS-15 on Azure is a macOS Intel VM, macos-15 on GHA is a macOS arm VM. The lock-file has been created for a macOS Intel OS (`osx-64` int he lock file name)

Do double-check this, I am not 100% confident but it does ring a bell.

i see that mac-15 is indeed expanded to mac-15-arm64

seems to work!

Even if arguably a bit more ambiguous, I would stick to the values documented in the [doc](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#choosing-github-hosted-runners)
```suggestion
            os: macos-15
```

I would stick to the lower case to follow the [documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#choosing-github-hosted-runners)
```suggestion
            os: macos-15-intel
```

I am wondering if the build name should not include the architecture as well?Sometimes it's mentioned in the lock-file name sometimes not but putting it at the beginning may help anyway.

```suggestion
          - name: macOS Intel pylatest_conda_forge_mkl_no_openmp
```

Changing my mind about naming for consistency

```suggestion
          - name: macOS x86-64 pylatest_conda_forge_mkl_no_openmp
```

Let revert this change since `arm` is already in the build name and it is already a required check. Changing the name of a required check would cause minor annoyances, see https://github.com/[USER_REDACTED]n/scikit-learn/pull/31832#issuecomment-3116678817 for a similar case where it happens.

```suggestion
          - name: macOS pylatest_conda_forge_arm
```


CI [NAME_REDACTED] ubuntu_atlas test job from Azure to GHA

Addresses part of #32434

@@ -143,6 +143,16 @@ jobs:
             PYTEST_XDIST_VERSION: none
             PIP_BUILD_ISOLATION: true

+          # Linux environment to test that scikit-learn can be built against
+          # versions of numpy, scipy with ATLAS that comes with Ubuntu 24.04
+          # [NAME_REDACTED] i.e. numpy 1.26.4 and scipy 1.11.4
+          - name: Linux x86-64 ubuntu_atlas
+            os: ubuntu-24.04
+            DISTRIB: ubuntu
+            LOCK_FILE: build_tools/azure/ubuntu_atlas_lock.txt
+            COVERAGE: false
+            SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 1  # non-default seed
+
           - name: macOS pylatest_conda_forge_arm
             os: macOS-15
             DISTRIB: conda
@@ -167,6 +177,7 @@ jobs:

       - name: Set up conda
         uses: conda-incubator/setup-miniconda@v3
+        if: ${{ startsWith(env.DISTRIB, 'conda') }}
         with:
           miniforge-version: latest
           auto-activate-base: true

@@ -138,27 +138,6 @@ jobs:
         COVERAGE: 'false'
         SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '0'  # non-default seed

-- template: build_tools/azure/posix.yml
-  parameters:
-    name: Ubuntu_Atlas
-    vmImage: ubuntu-24.04
-    dependsOn: [linting, git_commit, Ubuntu_Jammy_Jellyfish]
-    # Runs when dependencies succeeded or skipped
-    condition: |
-      and(
-        not(or(failed(), canceled())),
-        not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]'))
-      )
-    matrix:
-      # Linux environment to test that scikit-learn can be built against
-      # versions of numpy, scipy with ATLAS that comes with Ubuntu 24.04 [NAME_REDACTED]
-      # i.e. numpy 1.26.4 and scipy 1.11.4
-      ubuntu_atlas:
-        DISTRIB: 'ubuntu'
-        LOCK_FILE: './build_tools/azure/ubuntu_atlas_lock.txt'
-        COVERAGE: 'false'
-        SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '1'  # non-default seed
-
 - template: build_tools/azure/posix.yml
   parameters:
     name: Linux

LGTM, thanks!

Let's put the architecture to be explicit in the build name

```suggestion
          - name: Linux x86-64 ubuntu_atlas
```


CI Move debian-32 build from Azure to GHA

Addresses part of #32434

Ping [USER_REDACTED]

@@ -127,7 +127,7 @@ jobs:
             DISTRIB: conda
             LOCK_FILE: build_tools/github/pymin_conda_forge_arm_linux-aarch64_conda.lock

-          - name: Linuw x86-64 pylatest_conda_forge_mkl
+          - name: Linux x86-64 pylatest_conda_forge_mkl
             os: ubuntu-22.04
             DISTRIB: conda
             LOCK_FILE: build_tools/azure/pylatest_conda_forge_mkl_linux-64_conda.lock
@@ -303,7 +303,8 @@ jobs:
             --job-name "$JOB_NAME"

   free-threaded:
-    name: &free-threaded-job-name Linux x86-64 pylatest_free_threaded
+    name: &free-threaded-job-name
+      Linux x86-64 pylatest_free_threaded
     runs-on: ubuntu-latest
     needs: [lint, retrieve-commit-message, retrieve-selected-tests]
     if: contains(needs.retrieve-commit-message.outputs.message, '[free-threaded]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
@@ -318,7 +319,8 @@ jobs:
     steps: *unit-tests-steps

   scipy-dev:
-    name: &scipy-dev-job-name Linux x86-64 pylatest_pip_scipy_dev
+    name: &scipy-dev-job-name
+      Linux x86-64 pylatest_pip_scipy_dev
     runs-on: ubuntu-22.04
     needs: [lint, retrieve-commit-message, retrieve-selected-tests]
     if: contains(needs.retrieve-commit-message.outputs.message, '[scipy-dev]') || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
@@ -330,3 +332,111 @@ jobs:
       # To be able to access the job name in the steps, it must be set as an env variable.
       JOB_NAME: *scipy-dev-job-name
     steps: *unit-tests-steps
+
+  debian-32bit:
+    name: &debian-32bit-job-name
+      Linux i386 debian_32bit
+    runs-on: ubuntu-24.04
+    needs: [lint, retrieve-commit-message, retrieve-selected-tests]
+    env:
+      DISTRIB: debian-32
+      LOCK_FILE: build_tools/azure/debian_32bit_lock.txt
+      SKLEARN_TESTS_GLOBAL_RANDOM_SEED: 4  # non-default seed
+      DOCKER_CONTAINER: i386/debian:trixie
+      # To be able to access the job name in the steps, it must be set as an env variable.
+      JOB_NAME: *debian-32bit-job-name
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+
+      - name: Create cache for ccache
+        uses: actions/cache@v5
+        with:
+          path: ${{ env.CCACHE_DIR }}
+          key: ccache-v1-${{ env.JOB_NAME }}-${{ hashFiles('**/*.pyx*', '**/*.pxd*', '**/*.pxi*', '**/*.h', '**/*.c', '**/*.cpp', format('{0}', env.LOCK_FILE)) }}
+          restore-keys: ccache-${{ env.JOB_NAME }}
+
+      - name: Set up conda
+        uses: conda-incubator/setup-miniconda@v3
+        if: ${{ startsWith(env.DISTRIB, 'conda') }}
+        with:
+          miniforge-version: latest
+          auto-activate-base: true
+          activate-environment: ""
+
+      - name: Set random seed for nightly/manual runs
+        if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+        run: echo "SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$((RANDOM % 100))" >> $GITHUB_ENV
+
+      - name: Start container
+        # Environment variable are passed when starting the container rather
+        # than in "Run tests step" for more standard jobs
+        env:
+          COMMIT_MESSAGE: ${{ needs.retrieve-commit-message.outputs.message }}
+          SELECTED_TESTS: ${{ needs.retrieve-selected-tests.outputs.tests }}
+          COVERAGE: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}}
+        run: >
+          docker container run --rm
+          --volume $TEST_DIR:/temp_dir
+          --volume $PWD:/scikit-learn
+          --volume $CCACHE_DIR:/ccache
+          -w /scikit-learn
+          --detach
+          --name skcontainer
+          -e TEST_DIR=/temp_dir
+          -e CCACHE_DIR=/ccache
+          -e COVERAGE
+          -e DISTRIB
+          -e LOCK_FILE
+          -e JUNITXML
+          -e VIRTUALENV
+          -e PYTEST_XDIST_VERSION
+          -e SKLEARN_SKIP_NETWORK_TESTS
+          -e SELECTED_TESTS
+          -e CCACHE_COMPRESS
+          -e COMMIT_MESSAGE
+          -e JOB_NAME
+          -e SKLEARN_TESTS_GLOBAL_RANDOM_SEED
+          $DOCKER_CONTAINER
+          sleep 1000000
+
+      - name: Build scikit-learn
+        run: docker exec skcontainer bash -l build_tools/azure/install.sh
+
+      - name: Run tests
+        run: docker exec skcontainer bash -l build_tools/azure/test_script.sh
+
+      - name: Run doctests in .py and .rst files
+        run: docker exec skcontainer bash -l build_tools/azure/test_docs.sh
+        if: ${{ needs.retrieve-selected-tests.outputs.tests == ''}}
+
+      - name: Run pytest soft dependency test
+        run: docker exec skcontainer build_tools/azure/test_pytest_soft_dependency.sh
+        if: ${{ env.CHECK_PYTEST_SOFT_DEPENDENCY == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}}
+
+      - name: Combine coverage reports from parallel test runners
+        run: docker exec skcontainer bash -l build_tools/azure/combine_coverage_reports.sh
+        if: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}}
+
+      - name: Upload coverage report to Codecov
+        uses: codecov/codecov-action@v5
+        if: ${{ env.COVERAGE == 'true' && needs.retrieve-selected-tests.outputs.tests == ''}}
+        with:
+          files: ./coverage.xml
+          token: ${{ secrets.CODECOV_TOKEN }}
+          disable_search: true
+
+      - name: Update tracking issue
+        if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')}}
+        run: |
+          set -ex
+
+          pip install defusedxml PyGithub
+          python maint_tools/update_tracking_issue.py \
+            ${{ secrets.BOT_GITHUB_TOKEN }} \
+            "$GITHUB_WORKFLOW $JOB_NAME" \
+            "$GITHUB_REPOSITORY" \
+            https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID \
+            --junit-file $TEST_DIR/$JUNITXML \
+            --auto-close false \
+            --job-name "$JOB_NAME"

@@ -1,65 +0,0 @@
-# Adapted from https://github.com/[USER_REDACTED]ndas-dev/pandas/blob/master/azure-pipelines.yml
-schedules:
-- cron: "30 2 * * *"
-  displayName: Run nightly build
-  branches:
-    include:
-    - main
-  always: true
-
-jobs:
-- job: git_commit
-  displayName: [NAME_REDACTED]
-  pool:
-    vmImage: ubuntu-24.04
-  steps:
-    - bash: python build_tools/azure/get_commit_message.py
-      name: commit
-      displayName: Get source version message
-
-- job: linting
-  dependsOn: [git_commit]
-  condition: |
-    and(
-      succeeded(),
-      not(contains(dependencies['git_commit']['outputs']['commit.message'], '[lint skip]')),
-      not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]'))
-    )
-  displayName: Linting
-  pool:
-    vmImage: ubuntu-24.04
-  steps:
-    - task: UsePythonVersion@0
-      inputs:
-        versionSpec: '3.12'
-    - bash: |
-        source build_tools/shared.sh
-        # Include pytest compatibility with mypy
-        pip install pytest $(get_dep ruff min) $(get_dep mypy min) cython-lint
-      displayName: Install linters
-    - bash: |
-        ./build_tools/linting.sh
-      displayName: Run linters
-    - bash: |
-        pip install ninja meson scipy
-        python build_tools/check-meson-openmp-dependencies.py
-      displayName: [NAME_REDACTED] checks
-
-- template: build_tools/azure/posix-docker.yml
-  parameters:
-    name: Linux_Docker
-    vmImage: ubuntu-24.04
-    dependsOn: [linting, git_commit]
-    # Runs when dependencies succeeded or skipped
-    condition: |
-      and(
-        not(or(failed(), canceled())),
-        not(contains(dependencies['git_commit']['outputs']['commit.message'], '[ci skip]'))
-      )
-    matrix:
-      debian_32bit:
-        DOCKER_CONTAINER: 'i386/debian:trixie'
-        DISTRIB: 'debian-32'
-        COVERAGE: "true"
-        LOCK_FILE: './build_tools/azure/debian_32bit_lock.txt'
-        SKLEARN_TESTS_GLOBAL_RANDOM_SEED: '4'  # non-default seed

@@ -1,134 +0,0 @@
-parameters:
-  name: ''
-  vmImage: ''
-  matrix: []
-  dependsOn: []
-  condition: ne(variables['Build.Reason'], 'Schedule')
-
-jobs:
-- job: ${{ parameters.name }}
-  dependsOn: ${{ parameters.dependsOn }}
-  condition: ${{ parameters.condition }}
-  timeoutInMinutes: 120
-  pool:
-    vmImage: ${{ parameters.vmImage }}
-  variables:
-    VIRTUALENV: 'testvenv'
-    TEST_DIR: '$(Agent.WorkFolder)/tmp_folder'
-    JUNITXML: 'test-data.xml'
-    SKLEARN_SKIP_NETWORK_TESTS: '1'
-    PYTEST_XDIST_VERSION: 'latest'
-    COVERAGE: 'false'
-    # Set in azure-pipelines.yml
-    DISTRIB: ''
-    DOCKER_CONTAINER: ''
-    CREATE_ISSUE_ON_TRACKER: 'true'
-    CCACHE_DIR: $(Pipeline.Workspace)/ccache
-    CCACHE_COMPRESS: '1'
-  strategy:
-    matrix:
-      ${{ insert }}: ${{ parameters.matrix }}
-
-  steps:
-    - task: UsePythonVersion@0
-      inputs:
-        versionSpec: '3.9'
-        addToPath: false
-      name: pyTools
-      displayName: Select python version to run CI python scripts
-    - bash: $(pyTools.pythonLocation)/bin/python build_tools/azure/get_selected_tests.py
-      displayName: Check selected tests for all random seeds
-      condition: eq(variables['Build.Reason'], 'PullRequest')
-    - task: Cache@2
-      inputs:
-        key: '"ccache-v1" | "$(Agent.JobName)" | "$(Build.BuildNumber)"'
-        restoreKeys: |
-          "ccache-v1" | "$(Agent.JobName)"
-        path: $(CCACHE_DIR)
-      displayName: ccache
-      continueOnError: true
-    - script: >
-        mkdir -p $CCACHE_DIR
-    # Container is detached and sleeping, allowing steps to run commands
-    # in the container. The TEST_DIR is mapped allowing the host to access
-    # the JUNITXML file
-    - script: >
-        docker container run --rm
-        --volume $TEST_DIR:/temp_dir
-        --volume $BUILD_REPOSITORY_LOCALPATH:/repo_localpath
-        --volume $PWD:/scikit-learn
-        --volume $CCACHE_DIR:/ccache
-        -w /scikit-learn
-        --detach
-        --name skcontainer
-        -e BUILD_SOURCESDIRECTORY=/scikit-learn
-        -e TEST_DIR=/temp_dir
-        -e CCACHE_DIR=/ccache
-        -e BUILD_REPOSITORY_LOCALPATH=/repo_localpath
-        -e COVERAGE
-        -e DISTRIB
-        -e LOCK_FILE
-        -e JUNITXML
-        -e VIRTUALENV
-        -e PYTEST_XDIST_VERSION
-        -e SKLEARN_SKIP_NETWORK_TESTS
-        -e SELECTED_TESTS
-        -e CCACHE_COMPRESS
-        -e BUILD_SOURCEVERSIONMESSAGE
-        -e BUILD_REASON
-        $DOCKER_CONTAINER
-        sleep 1000000
-      displayName: 'Start container'
-    - script: >
-        docker exec skcontainer ./build_tools/azure/install.sh
-      displayName: 'Install'
-    - script: >
-        docker exec skcontainer ./build_tools/azure/test_script.sh
-      displayName: 'Test Library'
-    - script: >
-        docker exec skcontainer ./build_tools/azure/combine_coverage_reports.sh
-      condition: and(succeeded(), eq(variables['COVERAGE'], 'true'),
-                     eq(variables['SELECTED_TESTS'], ''))
-      displayName: 'Combine coverage'
-    - task: PublishTestResults@2
-      inputs:
-        testResultsFiles: '$(TEST_DIR)/$(JUNITXML)'
-        testRunTitle: ${{ format('{0}-$(Agent.JobName)', parameters.name) }}
-      displayName: '[NAME_REDACTED]'
-      condition: succeededOrFailed()
-    - script: >
-        docker container stop skcontainer
-      displayName: 'Stop container'
-      condition: always()
-    - bash: |
-        set -ex
-        if [[ $(BOT_GITHUB_TOKEN) == "" ]]; then
-          echo "[NAME_REDACTED] is not set. Issue tracker will not be updated."
-          exit
-        fi
-
-        LINK_TO_RUN="https://dev.azure.com/$BUILD_REPOSITORY_NAME/_build/results?buildId=$BUILD_BUILDID&view=logs&j=$SYSTEM_JOBID"
-        CI_NAME="$SYSTEM_JOBIDENTIFIER"
-        ISSUE_REPO="$BUILD_REPOSITORY_NAME"
-
-        $(pyTools.pythonLocation)/bin/pip install defusedxml PyGithub
-        $(pyTools.pythonLocation)/bin/python maint_tools/update_tracking_issue.py \
-          $(BOT_GITHUB_TOKEN) \
-          $CI_NAME \
-          $ISSUE_REPO \
-          $LINK_TO_RUN \
-          --junit-file $JUNIT_FILE \
-          --auto-close false
-      displayName: 'Update issue tracker'
-      env:
-        JUNIT_FILE: $(TEST_DIR)/$(JUNITXML)
-      condition: and(succeededOrFailed(), eq(variables['CREATE_ISSUE_ON_TRACKER'], 'true'),
-                     eq(variables['Build.Reason'], 'Schedule'))
-    - bash: bash build_tools/azure/upload_codecov.sh
-      condition: and(succeeded(), eq(variables['COVERAGE'], 'true'),
-                     eq(variables['SELECTED_TESTS'], ''))
-      displayName: '[NAME_REDACTED]'
-      retryCountOnTaskFailure: 5
-      env:
-        CODECOV_TOKEN: $(CODECOV_TOKEN)
-        JUNIT_FILE: $(TEST_DIR)/$(JUNITXML)

I think this is good to go.

We can tweak it later if/when we notice possible improvements.

This won't work since it sets the env variable in the host rather than the docker container.

I have not tried it on GHA before, but can we run the steps directly in a container with these instructions? https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/run-jobs-in-a-container

We kind of did a half-hearted attempt in this direction. The first issue is you don't have nodejs in the container. You could `apt-get install nodejs` but you would have an old nodejs so `actions/checkout@v6` does not work, IIRC looking at the version `actions/checkout@v4` might work.

Basically I was like, OK this seems more painful that I would have liked, let's do the same setup as Azure and revisit later.

More than happy to have someone else try a bit harder along the GHA docker support to see whether it's worth doing or not but honestly I would merge this PR first and do it in a follow-up PR.

Ah, I see, needing nodejs is kind of a pain. In any case, I'm okay with the PR as is.

Thanks for your insights [USER_REDACTED], super happy to see you pop occasionally on the repo :pray:


FIX: fix deprecation of "friedman_mse" criterion in forests

Fixing my own mess :sweat_smile:

#### [NAME_REDACTED]/PRs

Related to issue #32700.

Follow-up from PR #32708: While writing the PR #32708, I didn't anticipated that tree-related parameters will be validated before being passed down to trees (and it's where the deprecation warning is raised, and where the value of the criterion is modified from "friedman_mse" to "squared_error").

#### What does this implement/fix? Explain your changes.

The bug is explained above.

The proposed fix: copy the warning & criterion-value-rewrite in `RandomForestRegressor` and `ExtraTreesRegressor`. I think it's fine to repeat this small piece of code, it will not live too long in the code base.

#### AI usage: no

#### TODO?

More tests?

@@ -1928,6 +1928,16 @@ def __init__(
             max_samples=max_samples,
         )

+        if isinstance(criterion, str) and criterion == "friedman_mse":
+            # TODO(1.11): remove support of "friedman_mse" criterion.
+            criterion = "squared_error"
+            warn(
+                'Value `"friedman_mse"` for `criterion` is deprecated and will be '
+                'removed in 1.11. It maps to `"squared_error"` as both '
+                'were always equivalent. Use `criterion="squared_error"` '
+                "to remove this warning.",
+                FutureWarning,
+            )
         self.criterion = criterion
         self.max_depth = max_depth
         self.min_samples_split = min_samples_split
@@ -2662,6 +2672,16 @@ def __init__(
             max_samples=max_samples,
         )

+        if isinstance(criterion, str) and criterion == "friedman_mse":
+            # TODO(1.11): remove support of "friedman_mse" criterion.
+            criterion = "squared_error"
+            warn(
+                'Value `"friedman_mse"` for `criterion` is deprecated and will be '
+                'removed in 1.11. It maps to `"squared_error"` as both '
+                'were always equivalent. Use `criterion="squared_error"` '
+                "to remove this warning.",
+                FutureWarning,
+            )
         self.criterion = criterion
         self.max_depth = max_depth
         self.min_samples_split = min_samples_split

@@ -157,8 +157,12 @@ def test_iris_criterion(name, criterion):
     assert score > 0.5, "Failed with criterion %s and score = %f" % (criterion, score)

+# TODO(1.11): remove the deprecated friedman_mse criterion parametrization
+[USER_REDACTED].mark.filterwarnings("ignore:.*friedman_mse.*:FutureWarning")
 [USER_REDACTED].mark.parametrize("name", FOREST_REGRESSORS)
-[USER_REDACTED].mark.parametrize("criterion", ("squared_error", "absolute_error"))
+[USER_REDACTED].mark.parametrize(
+    "criterion", ("squared_error", "friedman_mse", "absolute_error")
+)
 def test_regression_criterion(name, criterion):
     # Check consistency on regression dataset.
     ForestRegressor = FOREST_REGRESSORS[name]
@@ -1864,3 +1868,10 @@ def test_non_supported_criterion_raises_error_with_missing_values(Forest):
     msg = ".*does not accept missing values"
     with pytest.raises(ValueError, match=msg):
         forest.fit(X, y)
+
+
+# TODO(1.11): remove test with the deprecation of friedman_mse criterion
+[USER_REDACTED].mark.parametrize("Forest", FOREST_REGRESSORS.values())
+def test_friedman_mse_deprecation(Forest):
+    with pytest.warns(FutureWarning, match="friedman_mse"):
+        _ = Forest(criterion="friedman_mse")

```suggestion

# TODO(1.11): remove test with the deprecation of friedman_mse criterion
[USER_REDACTED].mark.parametrize("Forest", FOREST_REGRESSORS.values())
```