Repository: sklearn-compat/sklearn-compat

Issue:
Missing piece when porting `imbalanced-learn`
This is the missing functions that are missing for `imbalanced-learn`:

- `_raise_for_params` -> introduced in 1.3
- `process_routing` -> signature changed in 1.4
- `_is_pandas_df` -> introduced in 1.3

This list is going to be updated.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 20687d8..6449f14 100644
--- a/README.md
+++ b/README.md
@@ -75,24 +75,26 @@ class MyEstimator(BaseEstimator):
         return self
 ```
 
-#### `check_array` function
+#### `check_array` and `check_X_y` functions
 
 The parameter `force_all_finite` has been deprecated in favor of the `ensure_all_finite`
 parameter. You need to modify the call to the function to use the new parameter. So,
 the change is the same as for `validate_data` and will look like this:
 
 ``` py
-from sklearn.utils.validation import check_array
+from sklearn.utils.validation import check_array, check_X_y
 
 check_array(X, force_all_finite=True)
+check_X_y(X, y, force_all_finite=True)
 ```
 
 to:
 
 ``` py
-from sklearn_compat.utils.validation import check_array
+from sklearn_compat.utils.validation import check_array, check_X_y
 
 check_array(X, ensure_all_finite=True)
+check_X_y(X, y, ensure_all_finite=True)
 ```
 
 #### `_check_n_features` and `_check_feature_names` functions
@@ -511,14 +513,15 @@ check_pandas_support("sklearn_compat")
 
 ### Upgrading to scikit-learn 1.4
 
-#### `process_routing` function
+#### `process_routing` and `_raise_for_params` functions
 
-The signature of the `process_routing` function changed in scikit-learn 1.4. You don't
-need to change the import but only the call to the function. Calling the function with
-the new signature will be compatible with the previous signature as well. So a code
-looking like this:
+The signature of the `process_routing` function changed in scikit-learn 1.4. You can
+import the function from `sklearn_compat.utils.metadata_routing`. The pattern will
+change from:
 
 ``` py
+from sklearn.utils.metadata_routing import process_routing
+
 class MetaEstimator(BaseEstimator):
     def fit(self, X, y, sample_weight=None, **fit_params):
         params = process_routing(self, "fit", fit_params, sample_weight=sample_weight)
@@ -528,12 +531,23 @@ class MetaEstimator(BaseEstimator):
 becomes:
 
 ``` py
+from sklearn_compat.utils.metadata_routing import process_routing
+
 class MetaEstimator(BaseEstimator):
     def fit(self, X, y, sample_weight=None, **fit_params):
         params = process_routing(self, "fit", sample_weight=sample_weight, **fit_params)
         return self
 ```
 
+The `_raise_for_params` function was also introduced in scikit-learn 1.4. You can import
+it from `sklearn_compat.utils.metadata_routing`.
+
+``` py
+from sklearn_compat.utils.metadata_routing import _raise_for_params
+
+_raise_for_params(params, self, "fit")
+```
+
 #### Upgrading to scikit-learn 1.2
 
 ### Parameter validation
diff --git a/src/sklearn_compat/_sklearn_compat.py b/src/sklearn_compat/_sklearn_compat.py
index 75d8953..9df86eb 100644
--- a/src/sklearn_compat/_sklearn_compat.py
+++ b/src/sklearn_compat/_sklearn_compat.py
@@ -206,8 +206,60 @@ if sklearn_version < parse_version("1.4"):
         ]
         return len(fitted_attrs) > 0
 
+    if sklearn_version < parse_version("1.3"):
+
+        def process_routing(_obj, _method, /, **kwargs):
+            raise NotImplementedError(
+                "Metadata routing is not implemented in scikit-learn < 1.3"
+            )
+
+        def _raise_for_params(params, owner, method):
+            raise NotImplementedError(
+                "Metadata routing is not implemented in scikit-learn < 1.3"
+            )
+    else:
+
+        def process_routing(_obj, _method, /, **kwargs):
+            """Validate and route input parameters."""
+            from sklearn.utils._metadata_requests import process_routing
+
+            return process_routing(_obj, _method, other_params=None, **kwargs)
+
+        def _raise_for_params(params, owner, method):
+            """Raise an error if metadata routing is not enabled and params are passed."""
+            from sklearn.utils._metadata_requests import _routing_enabled
+
+            caller = (
+                f"{owner.__class__.__name__}.{method}"
+                if method
+                else owner.__class__.__name__
+            )
+            if not _routing_enabled() and params:
+                raise ValueError(
+                    f"Passing extra keyword arguments to {caller} is only supported if"
+                    " enable_metadata_routing=True, which you can set using"
+                    " `sklearn.set_config`. See the User Guide"
+                    " <https://scikit-learn.org/stable/metadata_routing.html> for more"
+                    f" details. Extra parameters passed are: {set(params)}"
+                )
+
+    def _is_pandas_df(X):
+        """Return True if the X is a pandas dataframe."""
+        try:
+            pd = sys.modules["pandas"]
+        except KeyError:
+            return False
+        return isinstance(X, pd.DataFrame)
+
 else:
-    from sklearn.utils.validation import _is_fitted  # noqa: F401
+    from sklearn.utils.metadata_routing import (
+        _raise_for_params,  # noqa: F401
+        process_routing,  # noqa: F401
+    )
+    from sklearn.utils.validation import (
+        _is_fitted,  # noqa: F401
+        _is_pandas_df,  # noqa: F401
+    )
 
 
 ########################################################################################
@@ -445,6 +497,61 @@ if sklearn_version < parse_version("1.6"):
             **kwargs,
         )
 
+    def check_X_y(
+        X,
+        y,
+        accept_sparse=False,
+        *,
+        accept_large_sparse=True,
+        dtype="numeric",
+        order=None,
+        copy=False,
+        force_writeable=False,
+        ensure_all_finite=None,
+        ensure_2d=True,
+        allow_nd=False,
+        multi_output=False,
+        ensure_min_samples=1,
+        ensure_min_features=1,
+        y_numeric=False,
+        estimator=None,
+    ):
+        """Input validation for standard estimators.
+
+        Check the original documentation for more details:
+        https://scikit-learn.org/stable/modules/generated/sklearn.utils.check_X_y.html
+        """
+        from sklearn.utils.validation import check_X_y as _check_X_y
+
+        if ensure_all_finite is not None:
+            force_all_finite = ensure_all_finite
+        else:
+            force_all_finite = True
+
+        check_X_y_params = inspect.signature(_check_X_y).parameters
+        kwargs = {}
+        if "force_writeable" in check_X_y_params:
+            kwargs["force_writeable"] = force_writeable
+
+        return _check_X_y(
+            X,
+            y,
+            accept_sparse=accept_sparse,
+            accept_large_sparse=accept_large_sparse,
+            dtype=dtype,
+            order=order,
+            copy=copy,
+            force_all_finite=force_all_finite,
+            ensure_2d=ensure_2d,
+            allow_nd=allow_nd,
+            multi_output=multi_output,
+            ensure_min_samples=ensure_min_samples,
+            ensure_min_features=ensure_min_features,
+            y_numeric=y_numeric,
+            estimator=estimator,
+            **kwargs,
+        )
+
     # tags infrastructure
     @dataclass(**_dataclass_args())
     class InputTags:
@@ -749,5 +856,6 @@ else:
         _check_feature_names,  # noqa: F401
         _check_n_features,  # noqa: F401
         check_array,  # noqa: F401
+        check_X_y,  # noqa: F401
         validate_data,  # noqa: F401
     )
diff --git a/src/sklearn_compat/utils/metadata_routing.py b/src/sklearn_compat/utils/metadata_routing.py
new file mode 100644
index 0000000..337beb8
--- /dev/null
+++ b/src/sklearn_compat/utils/metadata_routing.py
@@ -0,0 +1,4 @@
+from sklearn_compat._sklearn_compat import (
+    _raise_for_params,  # noqa: F401
+    process_routing,  # noqa: F401
+)
diff --git a/src/sklearn_compat/utils/validation.py b/src/sklearn_compat/utils/validation.py
index 076b400..2059c1d 100644
--- a/src/sklearn_compat/utils/validation.py
+++ b/src/sklearn_compat/utils/validation.py
@@ -2,7 +2,9 @@ from sklearn_compat._sklearn_compat import (
     _check_feature_names,  # noqa: F401
     _check_n_features,  # noqa: F401
     _is_fitted,  # noqa: F401
+    _is_pandas_df,  # noqa: F401
     _to_object_array,  # noqa: F401
     check_array,  # noqa: F401
+    check_X_y,  # noqa: F401
     validate_data,  # noqa: F401
 )


Test patch:
diff --git a/tests/utils/test_metadata_routing.py b/tests/utils/test_metadata_routing.py
new file mode 100644
index 0000000..77c1cd6
--- /dev/null
+++ b/tests/utils/test_metadata_routing.py
@@ -0,0 +1,72 @@
+import numpy as np
+import pytest
+from sklearn import config_context
+from sklearn.base import BaseEstimator, ClassifierMixin, MetaEstimatorMixin, clone
+
+from sklearn_compat._sklearn_compat import parse_version, sklearn_version
+from sklearn_compat.utils.metadata_routing import _raise_for_params, process_routing
+
+
+@pytest.mark.skipif(
+    sklearn_version < parse_version("1.3"),
+    reason="Metadata routing was introduced in scikit-learn 1.3",
+)
+def test_process_routing():
+    from sklearn.utils.metadata_routing import MetadataRouter, MethodMapping
+
+    class ExampleClassifier(ClassifierMixin, BaseEstimator):
+        def fit(self, X, y, sample_weight=None):
+            self.sample_weight_ = sample_weight
+            # all classifiers need to expose a classes_ attribute once they're fit.
+            self.classes_ = np.array([0, 1])
+            return self
+
+    class MetaClassifier(MetaEstimatorMixin, ClassifierMixin, BaseEstimator):
+        def __init__(self, estimator):
+            self.estimator = estimator
+
+        def fit(self, X, y, sample_weight=None):
+            routed_params = process_routing(self, "fit", sample_weight=sample_weight)
+            self.estimator_ = clone(self.estimator).fit(
+                X, y, **routed_params.estimator.fit
+            )
+            return self
+
+        def get_metadata_routing(self):
+            router = MetadataRouter(owner=self.__class__.__name__).add(
+                estimator=self.estimator,
+                method_mapping=MethodMapping().add(caller="fit", callee="fit"),
+            )
+            return router
+
+    with config_context(enable_metadata_routing=True):
+        est = MetaClassifier(ExampleClassifier().set_fit_request(sample_weight=True))
+        sample_weight = [1, 2, 3]
+        est.fit(None, None, sample_weight=sample_weight)
+        assert est.estimator_.sample_weight_ == sample_weight
+
+
+@pytest.mark.skipif(
+    sklearn_version < parse_version("1.3"),
+    reason="Metadata routing was introduced in scikit-learn 1.3",
+)
+def test_raise_for_params():
+    class DummyEstimator(BaseEstimator):
+        pass
+
+    est = DummyEstimator()
+    params = {"invalid_param": 42}
+
+    with pytest.raises(ValueError, match="Passing extra keyword arguments"):
+        _raise_for_params(params, est, "fit")
+
+
+@pytest.mark.skipif(
+    sklearn_version >= parse_version("1.3"),
+    reason="Metadata routing is implemented",
+)
+def test__raise_for_params_not_implemented():
+    with pytest.raises(NotImplementedError):
+        _raise_for_params(None, None, "fit")
+    with pytest.raises(NotImplementedError):
+        process_routing(None, "fit")
diff --git a/tests/utils/test_validation.py b/tests/utils/test_validation.py
index 6b801b0..494ee92 100644
--- a/tests/utils/test_validation.py
+++ b/tests/utils/test_validation.py
@@ -9,6 +9,7 @@ from sklearn_compat.utils.validation import (
     _is_fitted,
     _to_object_array,
     check_array,
+    check_X_y,
     validate_data,
 )
 
@@ -20,6 +21,14 @@ def test_check_array_ensure_all_finite():
     assert isinstance(check_array(X, ensure_all_finite=False), np.ndarray)
 
 
+def test_check_X_y_ensure_all_finite():
+    X, y = [[1, 2, 3, np.nan]], [1]
+    with pytest.raises(ValueError, match="contains NaN"):
+        check_X_y(X, y, ensure_all_finite=True)
+    X_, y_ = check_X_y(X, y, ensure_all_finite=False)
+    assert isinstance(X_, np.ndarray) and isinstance(y_, np.ndarray)
+
+
 @pytest.mark.parametrize("ensure_all_finite", [True, False])
 def test_validate_data(ensure_all_finite):
     """Check the behaviour of `validate_data`.