Repository: scikit-learn/scikit-learn

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

#### Reference Issues/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/scikit-learn/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 Automated Contributions Policy:
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

Implementation patch:
diff --git a/examples/ensemble/plot_gradient_boosting_categorical.py b/examples/ensemble/plot_gradient_boosting_categorical.py
index 5e6957b0945b4..c67aa716ea8f7 100644
--- a/examples/ensemble/plot_gradient_boosting_categorical.py
+++ b/examples/ensemble/plot_gradient_boosting_categorical.py
@@ -160,11 +160,14 @@
 # held-out part. This way, each sample is encoded using statistics from data it
 # was not part of, preventing information leakage from the target.
 
+from sklearn.model_selection import KFold
 from sklearn.preprocessing import TargetEncoder
 
 target_encoder = make_column_transformer(
     (
-        TargetEncoder(target_type="continuous", random_state=42),
+        TargetEncoder(
+            target_type="continuous", cv=KFold(shuffle=True, random_state=42)
+        ),
         make_column_selector(dtype_include="category"),
     ),
     remainder="passthrough",
diff --git a/examples/preprocessing/plot_target_encoder_cross_val.py b/examples/preprocessing/plot_target_encoder_cross_val.py
index d44ee2c6ba021..06635abd0d2e4 100644
--- a/examples/preprocessing/plot_target_encoder_cross_val.py
+++ b/examples/preprocessing/plot_target_encoder_cross_val.py
@@ -111,10 +111,13 @@
 # Next, we create a pipeline with the target encoder and ridge model. The pipeline
 # uses :meth:`TargetEncoder.fit_transform` which uses :term:`cross fitting`. We
 # see that the model fits the data well and generalizes to the test set:
+from sklearn.model_selection import KFold
 from sklearn.pipeline import make_pipeline
 from sklearn.preprocessing import TargetEncoder
 
-model_with_cf = make_pipeline(TargetEncoder(random_state=0), ridge)
+model_with_cf = make_pipeline(
+    TargetEncoder(cv=KFold(shuffle=True, random_state=0)), ridge
+)
 model_with_cf.fit(X_train, y_train)
 print("Model with CF on train set: ", model_with_cf.score(X_train, y_train))
 print("Model with CF on test set: ", model_with_cf.score(X_test, y_test))
diff --git a/examples/release_highlights/plot_release_highlights_1_3_0.py b/examples/release_highlights/plot_release_highlights_1_3_0.py
index fe352c2eb1746..f05abe874c4c3 100644
--- a/examples/release_highlights/plot_release_highlights_1_3_0.py
+++ b/examples/release_highlights/plot_release_highlights_1_3_0.py
@@ -73,12 +73,13 @@
 # More details in the :ref:`User Guide <target_encoder>`.
 import numpy as np
 
+from sklearn.model_selection import KFold
 from sklearn.preprocessing import TargetEncoder
 
 X = np.array([["cat"] * 30 + ["dog"] * 20 + ["snake"] * 38], dtype=object).T
 y = [90.3] * 30 + [20.4] * 20 + [21.2] * 38
 
-enc = TargetEncoder(random_state=0)
+enc = TargetEncoder(cv=KFold(shuffle=True, random_state=0))
 X_trans = enc.fit_transform(X, y)
 
 enc.encodings_
diff --git a/sklearn/preprocessing/_target_encoder.py b/sklearn/preprocessing/_target_encoder.py
index c5a927d9ddca6..c83ac38b3091a 100644
--- a/sklearn/preprocessing/_target_encoder.py
+++ b/sklearn/preprocessing/_target_encoder.py
@@ -1,6 +1,7 @@
 # Authors: The scikit-learn developers
 # SPDX-License-Identifier: BSD-3-Clause
 
+import warnings
 from numbers import Real
 
 import numpy as np
@@ -130,6 +131,10 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         applies if `cv` is an int or `None`. If `cv` is a cross-validation generator or
         an iterable, `shuffle` is ignored.
 
+        .. deprecated:: 1.9
+            `shuffle` is deprecated and will be removed in 1.11. Pass a cross-validation
+            generator as `cv` argument to specify the shuffling instead.
+
     random_state : int, RandomState instance or None, default=None
         When `shuffle` is True, `random_state` affects the ordering of the
         indices, which controls the randomness of each fold. Otherwise, this
@@ -137,6 +142,11 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         Pass an int for reproducible output across multiple function calls.
         See :term:`Glossary <random_state>`.
 
+        .. deprecated:: 1.9
+            `random_state` is deprecated and will be removed in 1.11. Pass a
+            cross-validation generator as `cv` argument to specify the random state of
+            the shuffling instead.
+
     Attributes
     ----------
     encodings_ : list of shape (n_features,) or (n_features * n_classes) of \
@@ -221,18 +231,19 @@ class TargetEncoder(OneToOneFeatureMixin, _BaseEncoder):
         "target_type": [StrOptions({"auto", "continuous", "binary", "multiclass"})],
         "smooth": [StrOptions({"auto"}), Interval(Real, 0, None, closed="left")],
         "cv": ["cv_object"],
-        "shuffle": ["boolean"],
-        "random_state": ["random_state"],
+        "shuffle": ["boolean", StrOptions({"deprecated"})],
+        "random_state": ["random_state", StrOptions({"deprecated"})],
     }
 
+    # TODO(1.11) remove `shuffle` and `random_state` params, which had been deprecated
     def __init__(
         self,
         categories="auto",
         target_type="auto",
         smooth="auto",
         cv=5,
-        shuffle=True,
-        random_state=None,
+        shuffle="deprecated",
+        random_state="deprecated",
     ):
         self.categories = categories
         self.smooth = smooth
@@ -325,12 +336,29 @@ def fit_transform(self, X, y, **params):
 
         X_ordinal, X_known_mask, y_encoded, n_categories = self._fit_encodings_all(X, y)
 
+        # TODO(1.11): remove code block
+        if self.shuffle != "deprecated" or self.random_state != "deprecated":
+            warnings.warn(
+                "`TargetEncoder.shuffle` and `TargetEncoder.random_state` are "
+                "deprecated in version 1.9 and will be removed in version 1.11. Pass a "
+                "cross-validation generator as `cv` argument to specify the shuffling "
+                "behaviour instead.",
+                FutureWarning,
+            )
+        shuffle = True if self.shuffle == "deprecated" else self.shuffle
+        cv_kwargs = {"shuffle": shuffle}
+        if self.random_state != "deprecated":
+            cv_kwargs["random_state"] = self.random_state
+
+        # TODO(1.11): pass shuffle=True to keep backwards compatibility for default
+        # inputs (will be ignored in `check_cv` if a cv object is passed);
+        # `random_state` already defaults to `None` in `check_cv` and doesn't need to
+        # be passed here
         cv = check_cv(
             self.cv,
             y,
             classifier=self.target_type_ != "continuous",
-            shuffle=self.shuffle,
-            random_state=self.random_state,
+            **cv_kwargs,
         )
 
         if _routing_enabled():


Test patch:
diff --git a/sklearn/preprocessing/tests/test_target_encoder.py b/sklearn/preprocessing/tests/test_target_encoder.py
index 6965df1779080..427dececda9df 100644
--- a/sklearn/preprocessing/tests/test_target_encoder.py
+++ b/sklearn/preprocessing/tests/test_target_encoder.py
@@ -23,6 +23,7 @@
     TargetEncoder,
 )
 from sklearn.utils.fixes import parse_version
+from sklearn.utils.multiclass import type_of_target
 
 
 def _encode_target(X_ordinal, y_numeric, n_categories, smooth):
@@ -130,8 +131,7 @@ def test_encoding(categories, unknown_value, global_random_seed, smooth, target_
     target_encoder = TargetEncoder(
         smooth=smooth,
         categories=categories,
-        cv=n_splits,
-        random_state=global_random_seed,
+        cv=cv,
     )
 
     X_fit_transform = target_encoder.fit_transform(X_train, y_train)
@@ -220,8 +220,7 @@ def test_encoding_multiclass(
 
     target_encoder = TargetEncoder(
         smooth=smooth,
-        cv=n_splits,
-        random_state=global_random_seed,
+        cv=cv,
     )
     X_fit_transform = target_encoder.fit_transform(X_train, y_train)
 
@@ -366,9 +365,10 @@ def test_feature_names_out_set_output(y, feature_names):
 
     X_df = pd.DataFrame({"A": ["a", "b"] * 10, "B": [1, 2] * 10})
 
-    enc_default = TargetEncoder(cv=2, smooth=3.0, random_state=0)
+    cv = StratifiedKFold(n_splits=2, random_state=0, shuffle=True)
+    enc_default = TargetEncoder(cv=cv, smooth=3.0)
     enc_default.set_output(transform="default")
-    enc_pandas = TargetEncoder(cv=2, smooth=3.0, random_state=0)
+    enc_pandas = TargetEncoder(cv=cv, smooth=3.0)
     enc_pandas.set_output(transform="pandas")
 
     X_default = enc_default.fit_transform(X_df, y)
@@ -452,7 +452,7 @@ def test_multiple_features_quick(to_pandas, smooth, target_type):
         dtype=np.float64,
     )
 
-    enc = TargetEncoder(smooth=smooth, cv=2, random_state=0)
+    enc = TargetEncoder(smooth=smooth, cv=cv)
     X_fit_transform = enc.fit_transform(X_train, y_train)
     assert_allclose(X_fit_transform, expected_X_fit_transform)
 
@@ -479,7 +479,11 @@ def test_constant_target_and_feature(y, y_mean, smooth):
     X = np.array([[1] * 20]).T
     n_samples = X.shape[0]
 
-    enc = TargetEncoder(cv=2, smooth=smooth, random_state=0)
+    if type_of_target(y) == "continuous":
+        cv = KFold(n_splits=2, random_state=0, shuffle=True)
+    else:
+        cv = StratifiedKFold(n_splits=2, random_state=0, shuffle=True)
+    enc = TargetEncoder(cv=cv, smooth=smooth)
     X_trans = enc.fit_transform(X, y)
     assert_allclose(X_trans, np.repeat([[y_mean]], n_samples, axis=0))
     assert enc.encodings_[0][0] == pytest.approx(y_mean)
@@ -504,10 +508,12 @@ def test_fit_transform_not_associated_with_y_if_ordinal_categorical_is_not(
     y_train = y_train[y_sorted_indices]
     X_train = X_train[y_sorted_indices]
 
-    target_encoder = TargetEncoder(shuffle=True, random_state=global_random_seed)
+    target_encoder = TargetEncoder(
+        cv=KFold(n_splits=2, random_state=global_random_seed, shuffle=True)
+    )
     X_encoded_train_shuffled = target_encoder.fit_transform(X_train, y_train)
 
-    target_encoder = TargetEncoder(shuffle=False)
+    target_encoder = TargetEncoder(cv=KFold(n_splits=2, shuffle=False))
     X_encoded_train_no_shuffled = target_encoder.fit_transform(X_train, y_train)
 
     # Check that no information about y_train has leaked into X_train:
@@ -541,7 +547,7 @@ def test_smooth_zero():
     X = np.array([[0, 0, 0, 0, 0, 1, 1, 1, 1, 1]]).T
     y = np.array([2.1, 4.3, 1.2, 3.1, 1.0, 9.0, 10.3, 14.2, 13.3, 15.0])
 
-    enc = TargetEncoder(smooth=0.0, shuffle=False, cv=2)
+    enc = TargetEncoder(smooth=0.0, cv=KFold(n_splits=2, shuffle=False))
     X_trans = enc.fit_transform(X, y)
 
     # With cv = 2, category 0 does not exist in the second half, thus
@@ -578,7 +584,10 @@ def test_invariance_of_encoding_under_label_permutation(smooth, global_random_se
     X_train_permuted = permutated_labels[X_train.astype(np.int32)]
     X_test_permuted = permutated_labels[X_test.astype(np.int32)]
 
-    target_encoder = TargetEncoder(smooth=smooth, random_state=global_random_seed)
+    target_encoder = TargetEncoder(
+        smooth=smooth,
+        cv=KFold(n_splits=2, shuffle=True, random_state=global_random_seed),
+    )
     X_train_encoded = target_encoder.fit_transform(X_train, y_train)
     X_test_encoded = target_encoder.transform(X_test)
 
@@ -660,8 +669,9 @@ def test_target_encoding_for_linear_regression(smooth, global_random_seed):
 
     # Now do the same with target encoding using the internal CV mechanism
     # implemented when using fit_transform.
+    cv = KFold(shuffle=True, random_state=rng)
     model_with_cv = make_pipeline(
-        TargetEncoder(smooth=smooth, random_state=rng), linear_regression
+        TargetEncoder(smooth=smooth, cv=cv), linear_regression
     ).fit(X_train, y_train)
 
     # This model should be able to fit the data well and also generalise to the
@@ -682,9 +692,7 @@ def test_target_encoding_for_linear_regression(smooth, global_random_seed):
 
     # Let's now disable the internal cross-validation by calling fit and then
     # transform separately on the training set:
-    target_encoder = TargetEncoder(smooth=smooth, random_state=rng).fit(
-        X_train, y_train
-    )
+    target_encoder = TargetEncoder(smooth=smooth, cv=cv).fit(X_train, y_train)
     X_enc_no_cv_train = target_encoder.transform(X_train)
     X_enc_no_cv_test = target_encoder.transform(X_test)
     model_no_cv = linear_regression.fit(X_enc_no_cv_train, y_train)
@@ -752,3 +760,15 @@ def test_target_encoder_raises_cv_overlap(global_random_seed):
     msg = "Validation indices from `cv` must cover each sample index exactly once"
     with pytest.raises(ValueError, match=msg):
         encoder.fit_transform(X, y)
+
+
+# TODO(1.11): remove after deprecation
+def test_target_encoder_shuffle_random_state_deprecated():
+    X, y = make_regression(n_samples=100, n_features=3, random_state=0)
+    msg = "`TargetEncoder.shuffle` and `TargetEncoder.random_state` are deprecated"
+    with pytest.warns(FutureWarning, match=msg):
+        encoder = TargetEncoder(shuffle=False)
+        encoder.fit_transform(X, y)
+    with pytest.warns(FutureWarning, match=msg):
+        encoder = TargetEncoder(random_state=0)
+        encoder.fit_transform(X, y)