Repository: scikit-learn-contrib/imbalanced-learn

Issue:
[ENH] Allow fit_resample to receive metadata routed parameters
#### Is your feature request related to a problem? Please describe

In cost-sensitive learning, resampling techniques are used to address the asymmetrical importance of data points. These techniques require the amount of resampling to be dependent on instance-specific parameters, such as cost weights associated with individual data points. These cost weights are usually in a cost matrix for each data point $i$:

|                          | Actual Positive ($y_i = 1$)       | Actual Negative ($y_i = 0$)       |
|--------------------------|-----------------------------------|-----------------------------------|
| **Predicted Positive** ($\hat y_i=1$)   | $C_{TP_i} $                     | $C_{FP_i}$                  |
| **Predicted Negative**  ($\hat y_i=0$)  | $C_{FN_i}$                          | $C_{TN_i}$                          |

Since these cost weights are dependent on the data point, they cannot be predetermined during initialization `__init__` but instead must adapt dynamically based on the input data during the `fit_resample` process.

The current implementation imbalanced-learn Pipeline object does not natively support passing metadata through its fit_resample method. Metadata routing, which would enable instance-dependent parameters to flow seamlessly through the pipeline, is critical for implementing cost-sensitive learning workflows.

#### Desired workflow (**DOES NOT CURRENTLY WORK**)

```python
import numpy as np
from imblearn.pipeline import Pipeline
from sklearn import set_config
from sklearn.utils._metadata_requests import MetadataRequest, RequestMethod
from sklearn.base import BaseEstimator
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression


set_config(enable_metadata_routing=True)

class CostSensitiveSampler(BaseEstimator):

    _estimator_type = "sampler"
    __metadata_request__fit_resample = {'cost_matrix': True}

    def __init__(self, random_state=None):
        self.random_state = random_state

    def fit_resample(self, X, y, cost_matrix=None):
        # resample based on cost_matrix
        # ...
        return X, y

    def _get_metadata_request(self):
        routing = MetadataRequest(owner=self.__class__.__name__)
        routing.fit_resample.add_request(param='cost_matrix', alias=True)
        return routing

    set_fit_resample_request = RequestMethod('fit_resample', ['cost_matrix'])

X, y = make_classification()
cost_matrix = np.random.rand(X.shape[0], 2, 2)
pipeline = Pipeline([
    ('sampler', CostSensitiveSampler().set_fit_resample_request(cost_matrix=True)),
    ('model', LogisticRegression())
])
pipeline.fit(X, y, cost_matrix=cost_matrix)
```

#### Describe the solution you'd like

From what I understand from the metadata routing implementation of the Pipeline object only a couple of changes have to be made:

1. the `SIMPLE_METHODS` constant found [here](https://github.com/scikit-learn-contrib/imbalanced-learn/blob/554bdb13602a7abfdd0a56c2e3644dcfbe3165bb/imblearn/utils/_metadata_requests.py#L101) needs to include `"fit_resample"`:
```python
SIMPLE_METHODS = [
            "fit",
            "partial_fit",
            "fit_resample",  # add line here
            "predict",
            "predict_proba",
            "predict_log_proba",
            "decision_function",
            "score",
            "split",
            "transform",
            "inverse_transform",
        ]
```
Note that this does require imbalanced-learn to redefine the classes and functions which use the `SIMPLE_METHODS` constant internally. These are now imported from scikit-learn if scikit-learn version 1.4 or higher is installed. These include: `MetadataRequest` and `_MetadataRequester`.
2. A method mapping from caller "fit" to callee "fit_resample" has to be added in the `get_meta_data_routing(self)` method  found [here](https://github.com/scikit-learn-contrib/imbalanced-learn/blob/554bdb13602a7abfdd0a56c2e3644dcfbe3165bb/imblearn/pipeline.py#L938) and the `filter_resample` parameter of `self._iter` method needs be set to `False`:
```python
def get_metadata_routing(self):
        """Get metadata routing of this object.

        Please check :ref:`User Guide <metadata_routing>` on how the routing
        mechanism works.

        Returns
        -------
        routing : MetadataRouter
            A :class:`~utils.metadata_routing.MetadataRouter` encapsulating
            routing information.
        """
        router = MetadataRouter(owner=self.__class__.__name__)

        # first we add all steps except the last one
        for _, name, trans in self._iter(with_final=False, filter_passthrough=True, filter_resample=False):  # change filter_resample to False
            method_mapping = MethodMapping()
            # fit, fit_predict, and fit_transform call fit_transform if it
            # exists, or else fit and transform
            if hasattr(trans, "fit_transform"):
                (
                    method_mapping.add(caller="fit", callee="fit_transform")
                    .add(caller="fit_transform", callee="fit_transform")
                    .add(caller="fit_predict", callee="fit_transform")
                    .add(caller="fit_resample", callee="fit_transform")
                )
            else:
                (
                    method_mapping.add(caller="fit", callee="fit")
                    .add(caller="fit", callee="transform")
                    .add(caller="fit_transform", callee="fit")
                    .add(caller="fit_transform", callee="transform")
                    .add(caller="fit_predict", callee="fit")
                    .add(caller="fit_predict", callee="transform")
                    .add(caller="fit_resample", callee="fit")
                    .add(caller="fit_resample", callee="transform")
                )

            (
                method_mapping.add(caller="predict", callee="transform")
                .add(caller="predict", callee="transform")
                .add(caller="predict_proba", callee="transform")
                .add(caller="decision_function", callee="transform")
                .add(caller="predict_log_proba", callee="transform")
                .add(caller="transform", callee="transform")
                .add(caller="inverse_transform", callee="inverse_transform")
                .add(caller="score", callee="transform")
                .add(caller="fit_resample", callee="transform")
                .add(caller="fit", callee="fit_resample")  # add this line
            )

            
            router.add(method_mapping=method_mapping, **{name: trans})
        # add final estimator method mapping
        ...
```

#### Additional context

I am a PhD Researcher and used these methods for my paper and the author of a python package [Empulse](https://shimantorahman.github.io/empulse/index.html) which has implemented samplers which require cost parameters to be passed to the `fit_resample` method like in the dummy example (see [Empulse/Samplers](https://shimantorahman.github.io/empulse/reference/samplers.html)). I find the whole metadata routing implementation incredibly confusing, so apologies if I made some mistakes in my reasoning.


Discussion:

Patch:
diff --git a/imblearn/base.py b/imblearn/base.py
index 3914102..eeabf83 100644
--- a/imblearn/base.py
+++ b/imblearn/base.py
@@ -22,6 +22,14 @@ if "fit_transform" not in METHODS:
     METHODS.append("fit_transform")
 METHODS.append("fit_resample")
 
+try:
+    from sklearn.utils._metadata_requests import SIMPLE_METHODS
+
+    SIMPLE_METHODS.append("fit_resample")
+except ImportError:
+    # in older versions of scikit-learn, only METHODS is used
+    pass
+
 
 class SamplerMixin(metaclass=ABCMeta):
     """Mixin class for samplers with abstract method.
@@ -33,7 +41,7 @@ class SamplerMixin(metaclass=ABCMeta):
     _estimator_type = "sampler"
 
     @_fit_context(prefer_skip_nested_validation=True)
-    def fit(self, X, y):
+    def fit(self, X, y, **params):
         """Check inputs and statistics of the sampler.
 
         You should use ``fit_resample`` in all cases.
@@ -47,6 +55,9 @@ class SamplerMixin(metaclass=ABCMeta):
         y : array-like of shape (n_samples,)
             Target array.
 
+        **params : dict
+            Extra parameters to use by the sampler.
+
         Returns
         -------
         self : object
@@ -58,7 +69,8 @@ class SamplerMixin(metaclass=ABCMeta):
         )
         return self
 
-    def fit_resample(self, X, y):
+    @_fit_context(prefer_skip_nested_validation=True)
+    def fit_resample(self, X, y, **params):
         """Resample the dataset.
 
         Parameters
@@ -70,6 +82,9 @@ class SamplerMixin(metaclass=ABCMeta):
         y : array-like of shape (n_samples,)
             Corresponding label for each sample in X.
 
+        **params : dict
+            Extra parameters to use by the sampler.
+
         Returns
         -------
         X_resampled : {array-like, dataframe, sparse matrix} of shape \
@@ -87,7 +102,7 @@ class SamplerMixin(metaclass=ABCMeta):
             self.sampling_strategy, y, self._sampling_type
         )
 
-        output = self._fit_resample(X, y)
+        output = self._fit_resample(X, y, **params)
 
         y_ = (
             label_binarize(output[1], classes=np.unique(y)) if binarize_y else output[1]
@@ -97,7 +112,7 @@ class SamplerMixin(metaclass=ABCMeta):
         return (X_, y_) if len(output) == 2 else (X_, y_, output[2])
 
     @abstractmethod
-    def _fit_resample(self, X, y):
+    def _fit_resample(self, X, y, **params):
         """Base method defined in each sampler to defined the sampling
         strategy.
 
@@ -109,6 +124,9 @@ class SamplerMixin(metaclass=ABCMeta):
         y : array-like of shape (n_samples,)
             Corresponding label for each sample in X.
 
+        **params : dict
+            Extra parameters to use by the sampler.
+
         Returns
         -------
         X_resampled : {ndarray, sparse matrix} of shape \
@@ -139,7 +157,7 @@ class BaseSampler(SamplerMixin, OneToOneFeatureMixin, BaseEstimator):
         X, y = validate_data(self, X=X, y=y, reset=True, accept_sparse=accept_sparse)
         return X, y, binarize_y
 
-    def fit(self, X, y):
+    def fit(self, X, y, **params):
         """Check inputs and statistics of the sampler.
 
         You should use ``fit_resample`` in all cases.
@@ -158,10 +176,9 @@ class BaseSampler(SamplerMixin, OneToOneFeatureMixin, BaseEstimator):
         self : object
             Return the instance itself.
         """
-        self._validate_params()
-        return super().fit(X, y)
+        return super().fit(X, y, **params)
 
-    def fit_resample(self, X, y):
+    def fit_resample(self, X, y, **params):
         """Resample the dataset.
 
         Parameters
@@ -182,8 +199,7 @@ class BaseSampler(SamplerMixin, OneToOneFeatureMixin, BaseEstimator):
         y_resampled : array-like of shape (n_samples_new,)
             The corresponding label of `X_resampled`.
         """
-        self._validate_params()
-        return super().fit_resample(X, y)
+        return super().fit_resample(X, y, **params)
 
     def _more_tags(self):
         return {"X_types": ["2darray", "sparse", "dataframe"]}
diff --git a/imblearn/pipeline.py b/imblearn/pipeline.py
index b82b9a5..0449fd3 100644
--- a/imblearn/pipeline.py
+++ b/imblearn/pipeline.py
@@ -1168,34 +1168,45 @@ class Pipeline(pipeline.Pipeline):
         router = MetadataRouter(owner=self.__class__.__name__)
 
         # first we add all steps except the last one
-        for _, name, trans in self._iter(with_final=False, filter_passthrough=True):
+        for _, name, trans in self._iter(
+            with_final=False, filter_passthrough=True, filter_resample=False
+        ):
             method_mapping = MethodMapping()
             # fit, fit_predict, and fit_transform call fit_transform if it
             # exists, or else fit and transform
             if hasattr(trans, "fit_transform"):
-                method_mapping.add(caller="fit", callee="fit_transform")
-                method_mapping.add(caller="fit_transform", callee="fit_transform")
-                method_mapping.add(caller="fit_predict", callee="fit_transform")
-                method_mapping.add(caller="fit_resample", callee="fit_transform")
+                (
+                    method_mapping.add(caller="fit", callee="fit_transform")
+                    .add(caller="fit_transform", callee="fit_transform")
+                    .add(caller="fit_predict", callee="fit_transform")
+                )
             else:
-                method_mapping.add(caller="fit", callee="fit")
-                method_mapping.add(caller="fit", callee="transform")
-                method_mapping.add(caller="fit_transform", callee="fit")
-                method_mapping.add(caller="fit_transform", callee="transform")
-                method_mapping.add(caller="fit_predict", callee="fit")
-                method_mapping.add(caller="fit_predict", callee="transform")
-                method_mapping.add(caller="fit_resample", callee="fit")
-                method_mapping.add(caller="fit_resample", callee="transform")
-
-            method_mapping.add(caller="predict", callee="transform")
-            method_mapping.add(caller="predict", callee="transform")
-            method_mapping.add(caller="predict_proba", callee="transform")
-            method_mapping.add(caller="decision_function", callee="transform")
-            method_mapping.add(caller="predict_log_proba", callee="transform")
-            method_mapping.add(caller="transform", callee="transform")
-            method_mapping.add(caller="inverse_transform", callee="inverse_transform")
-            method_mapping.add(caller="score", callee="transform")
-            method_mapping.add(caller="fit_resample", callee="transform")
+                (
+                    method_mapping.add(caller="fit", callee="fit")
+                    .add(caller="fit", callee="transform")
+                    .add(caller="fit_transform", callee="fit")
+                    .add(caller="fit_transform", callee="transform")
+                    .add(caller="fit_predict", callee="fit")
+                    .add(caller="fit_predict", callee="transform")
+                )
+
+            (
+                # handling sampler if the fit_* stage
+                method_mapping.add(caller="fit", callee="fit_resample")
+                .add(caller="fit_transform", callee="fit_resample")
+                .add(caller="fit_predict", callee="fit_resample")
+            )
+            (
+                method_mapping.add(caller="predict", callee="transform")
+                .add(caller="predict", callee="transform")
+                .add(caller="predict_proba", callee="transform")
+                .add(caller="decision_function", callee="transform")
+                .add(caller="predict_log_proba", callee="transform")
+                .add(caller="transform", callee="transform")
+                .add(caller="inverse_transform", callee="inverse_transform")
+                .add(caller="score", callee="transform")
+                .add(caller="fit_resample", callee="transform")
+            )
 
             router.add(method_mapping=method_mapping, **{name: trans})
 
@@ -1207,23 +1218,24 @@ class Pipeline(pipeline.Pipeline):
         method_mapping = MethodMapping()
         if hasattr(final_est, "fit_transform"):
             method_mapping.add(caller="fit_transform", callee="fit_transform")
-            method_mapping.add(caller="fit_resample", callee="fit_transform")
         else:
+            (
+                method_mapping.add(caller="fit", callee="fit").add(
+                    caller="fit", callee="transform"
+                )
+            )
+        (
             method_mapping.add(caller="fit", callee="fit")
-            method_mapping.add(caller="fit", callee="transform")
-            method_mapping.add(caller="fit_resample", callee="fit")
-            method_mapping.add(caller="fit_resample", callee="transform")
-
-        method_mapping.add(caller="fit", callee="fit")
-        method_mapping.add(caller="predict", callee="predict")
-        method_mapping.add(caller="fit_predict", callee="fit_predict")
-        method_mapping.add(caller="predict_proba", callee="predict_proba")
-        method_mapping.add(caller="decision_function", callee="decision_function")
-        method_mapping.add(caller="predict_log_proba", callee="predict_log_proba")
-        method_mapping.add(caller="transform", callee="transform")
-        method_mapping.add(caller="inverse_transform", callee="inverse_transform")
-        method_mapping.add(caller="score", callee="score")
-        method_mapping.add(caller="fit_resample", callee="fit_resample")
+            .add(caller="predict", callee="predict")
+            .add(caller="fit_predict", callee="fit_predict")
+            .add(caller="predict_proba", callee="predict_proba")
+            .add(caller="decision_function", callee="decision_function")
+            .add(caller="predict_log_proba", callee="predict_log_proba")
+            .add(caller="transform", callee="transform")
+            .add(caller="inverse_transform", callee="inverse_transform")
+            .add(caller="score", callee="score")
+            .add(caller="fit_resample", callee="fit_resample")
+        )
 
         router.add(method_mapping=method_mapping, **{final_name: final_est})
         return router


Test patch:
diff --git a/imblearn/tests/test_pipeline.py b/imblearn/tests/test_pipeline.py
index dfe7444..b9c6f9d 100644
--- a/imblearn/tests/test_pipeline.py
+++ b/imblearn/tests/test_pipeline.py
@@ -34,6 +34,7 @@ from sklearn.utils._testing import (
 )
 from sklearn.utils.fixes import parse_version
 
+from imblearn.base import BaseSampler
 from imblearn.datasets import make_imbalance
 from imblearn.pipeline import Pipeline, make_pipeline
 from imblearn.under_sampling import EditedNearestNeighbours as ENN
@@ -1495,3 +1496,24 @@ def test_transform_input_sklearn_version():
 
 # end of transform_input tests
 # =============================
+
+
+def test_metadata_routing_with_sampler():
+    """Check that we can use a sampler with metadata routing."""
+    X, y = make_classification()
+    cost_matrix = np.random.rand(X.shape[0], 2, 2)
+
+    class CostSensitiveSampler(BaseSampler):
+        def fit_resample(self, X, y, cost_matrix=None):
+            return self._fit_resample(X, y, cost_matrix=cost_matrix)
+
+        def _fit_resample(self, X, y, cost_matrix=None):
+            self.cost_matrix_ = cost_matrix
+            return X, y
+
+    with config_context(enable_metadata_routing=True):
+        sampler = CostSensitiveSampler().set_fit_resample_request(cost_matrix=True)
+        pipeline = Pipeline([("sampler", sampler), ("model", LogisticRegression())])
+        pipeline.fit(X, y, cost_matrix=cost_matrix)
+
+        assert_allclose(pipeline[0].cost_matrix_, cost_matrix)