Repository: scikit-learn/scikit-learn

Issue:
# ```TargetEncoder``` should take ```groups``` as an argument

### Describe the workflow you want to enable

The current implementation of TargetEncoder uses ```KFold```-cross-validation to avoid data leakage. In cases of longitudinal or clustered data, it is desirable to ensure that rows belonging to the same group or cluster belong to the same train-folds to avoid data-leakage.

### Describe your proposed solution

 This could be achieved by introducing an optional```group``` parameter and the use of ```GroupKFold```-cross-validation if the ```group``` is not ```None```.

### Describe alternatives you've considered, if relevant

The alternative is to continue ignoring group structure. 


### Additional context

_No response_

Implementation patch:
diff --git a/sklearn/model_selection/_split.py b/sklearn/model_selection/_split.py
index c9a3a5bfaea92..0719fbd2b11f1 100644
--- a/sklearn/model_selection/_split.py
+++ b/sklearn/model_selection/_split.py
@@ -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):
diff --git a/sklearn/preprocessing/_target_encoder.py b/sklearn/preprocessing/_target_encoder.py
index 5d8fc97f2a1bd..c5a927d9ddca6 100644
--- a/sklearn/preprocessing/_target_encoder.py
+++ b/sklearn/preprocessing/_target_encoder.py
@@ -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:`User Guide <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:`Metadata Routing User 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 num classes else keep 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:`User Guide <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


Test patch:
diff --git a/sklearn/preprocessing/tests/test_target_encoder.py b/sklearn/preprocessing/tests/test_target_encoder.py
index fca0df3f16d55..6965df1779080 100644
--- a/sklearn/preprocessing/tests/test_target_encoder.py
+++ b/sklearn/preprocessing/tests/test_target_encoder.py
@@ -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)
diff --git a/sklearn/tests/metadata_routing_common.py b/sklearn/tests/metadata_routing_common.py
index a0e2c07b5e07e..3c56dbca2da58 100644
--- a/sklearn/tests/metadata_routing_common.py
+++ b/sklearn/tests/metadata_routing_common.py
@@ -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."""
 
diff --git a/sklearn/tests/test_metaestimators_metadata_routing.py b/sklearn/tests/test_metaestimators_metadata_routing.py
index 02899ad32fb2b..ecd9808bd9749 100644
--- a/sklearn/tests/test_metaestimators_metadata_routing.py
+++ b/sklearn/tests/test_metaestimators_metadata_routing.py
@@ -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 (