Repository: scikit-learn-contrib/imbalanced-learn

Issue:
Add support for dataframe in/out
We should allow passing a dataframe in and output a dataframe. Each sampler could store the column name and recreate a dataframe.

We need to check how to handle the dependency

Discussion:
lgtm-com[bot]: This pull request **introduces 1 alert** when merging e936bdd0873899837b19ea2baf9c6bfb0ad1f995 into 45b538c80d31013dfb763cce075fc0d2e95defc6 - [view on LGTM.com](https://lgtm.com/projects/g/scikit-learn-contrib/imbalanced-learn/rev/pr-aa60e91a68fa82f51b3087402a924633b28d86d9)

**new alerts:**

* 1 for Mismatch in multiple assignment
codecov[bot]: # [Codecov](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/pull/644?src=pr&el=h1) Report
> Merging [#644](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/pull/644?src=pr&el=desc) into [master](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/commit/45b538c80d31013dfb763cce075fc0d2e95defc6?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/pull/644/graphs/tree.svg?width=650&token=zA2vZY4LTe&height=150&src=pr)](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/pull/644?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master     #644   +/-   ##
=======================================
  Coverage   97.96%   97.96%           
=======================================
  Files          83       83           
  Lines        4867     4867           
=======================================
  Hits         4768     4768           
  Misses         99       99
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/pull/644?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/pull/644?src=pr&el=footer). Last update [45b538c...e936bdd](https://codecov.io/gh/scikit-learn-contrib/imbalanced-learn/pull/644?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

chkoar: I thought that you were against on using `pandas`. With your implementation you assume that the user has `pandas` installed. Of course it is passing pandas pandas df. Shouldn't we try/catch in case that pandas in not installed and is passing something that has a `loc` property?
glemaitre: I am still against. Before sampling I am duck typing. At the moment to recreate the database if we have the columns name it means that we gave us a database meaning that the user has pandas. So in this case only I am importing pandas to call the dataframe constructor. 


Pandas can still be an optional dependence then. 


lgtm-com[bot]: This pull request **introduces 1 alert** when merging b1d2d5656de842a0d9cdacc00a369d2b5a0d437f into 45b538c80d31013dfb763cce075fc0d2e95defc6 - [view on LGTM.com](https://lgtm.com/projects/g/scikit-learn-contrib/imbalanced-learn/rev/pr-41897d13ab58fcd5e1734804c30d3bf8d57e996b)

**new alerts:**

* 1 for Mismatch in multiple assignment
lgtm-com[bot]: This pull request **introduces 1 alert** when merging 9431bb8c2b2f37b49ee3612eb3f87075fbfda012 into 45b538c80d31013dfb763cce075fc0d2e95defc6 - [view on LGTM.com](https://lgtm.com/projects/g/scikit-learn-contrib/imbalanced-learn/rev/pr-3128d718fdbad5c1a40ab5dfcd644bdc5deada1d)

**new alerts:**

* 1 for Mismatch in multiple assignment

Patch:
diff --git a/doc/introduction.rst b/doc/introduction.rst
index 6b8aa8c..1ea7232 100644
--- a/doc/introduction.rst
+++ b/doc/introduction.rst
@@ -30,6 +30,12 @@ Imbalanced-learn samplers accept the same inputs that in scikit-learn:
   matrices;
 * ``targets``: array-like (1-D list, pandas.Series, numpy.array).
 
+The output will be of the following type:
+
+* ``data_resampled``: array-like (2-D list, pandas.Dataframe, numpy.array) or
+   sparse matrices;
+   * ``targets_resampled``: 1-D numpy.array.
+
 .. topic:: Sparse input
 
    For sparse input the data is **converted to the Compressed Sparse Rows
diff --git a/doc/over_sampling.rst b/doc/over_sampling.rst
index 0624277..b52de34 100644
--- a/doc/over_sampling.rst
+++ b/doc/over_sampling.rst
@@ -190,7 +190,7 @@ features or a boolean mask marking these features::
   >>> print(X_resampled[-5:])
   [['A' 0.5246469549655818 2]
    ['B' -0.3657680728116921 2]
-   ['A' 0.9344237230779993 2]
+   ['B' 0.9344237230779993 2]
    ['B' 0.3710891618824609 2]
    ['B' 0.3327240726719727 2]]
 
diff --git a/doc/whats_new/v0.6.rst b/doc/whats_new/v0.6.rst
index e5aedaf..a130fb3 100644
--- a/doc/whats_new/v0.6.rst
+++ b/doc/whats_new/v0.6.rst
@@ -15,6 +15,19 @@ scikit-learn:
 - :class:`imblearn.under_sampling.ClusterCentroids`
 - :class:`imblearn.under_sampling.InstanceHardnessThreshold`
 
+The following samplers will give different results due to change linked to
+the random state internal usage:
+
+- :class:`imblearn.over_sampling.SMOTENC`
+
+Bug fixes
+.........
+
+- :class:`imblearn.under_sampling.InstanceHardnessThreshold` now take into
+  account the `random_state` and will give deterministic results. In addition,
+  `cross_val_predict` is used to take advantage of the parallelism.
+  :pr:`599` by :user:`Shihab Shahriar Khan <Shihab-Shahriar>`.
+
 Maintenance
 ...........
 
@@ -54,7 +67,20 @@ Enhancement
 - :class:`imblearn.under_sampling.RandomUnderSampler`,
   :class:`imblearn.over_sampling.RandomOverSampler` can resample when non
   finite values are present in ``X``.
-  :pr:`643` by `Guillaume Lemaitre <glemaitre>`.
+  :pr:`643` by :user:`Guillaume Lemaitre <glemaitre>`.
+
+- All samplers will output a Pandas DataFrame if a Pandas DataFrame was given
+  as an input.
+  :pr:`644` by :user:`Guillaume Lemaitre <glemaitre>`.
+
+- The samples generation in
+  :class:`imblearn.over_sampling.SMOTE`,
+  :class:`imblearn.over_sampling.BorderlineSMOTE`,
+  :class:`imblearn.over_sampling.SVMSMOTE`,
+  :class:`imblearn.over_sampling.KMeansSMOTE`,
+  :class:`imblearn.over_sampling.SMOTENC` is now vectorize with giving
+  an additional speed-up when `X` in sparse.
+  :pr:`596` by :user:`Matt Edding <MattEding>`.
 
 Deprecation
 ...........
diff --git a/examples/datasets/plot_make_imbalance.py b/examples/datasets/plot_make_imbalance.py
index 444d189..43d9010 100644
--- a/examples/datasets/plot_make_imbalance.py
+++ b/examples/datasets/plot_make_imbalance.py
@@ -1,9 +1,11 @@
 """
-===========================
-make_imbalance function
-===========================
+============================
+Create an imbalanced dataset
+============================
 
-An illustration of the make_imbalance function
+An illustration of the :func:`imblearn.datasets.make_imbalance` function to
+create an imbalanced dataset from a balanced dataset. We show the ability of
+:func:`imblearn.datasets.make_imbalance` of dealing with Pandas DataFrame.
 
 """
 
@@ -14,36 +16,29 @@ An illustration of the make_imbalance function
 
 from collections import Counter
 
+import pandas as pd
 import matplotlib.pyplot as plt
+
 from sklearn.datasets import make_moons
 
 from imblearn.datasets import make_imbalance
 
 print(__doc__)
 
-
-def plot_decoration(ax):
-    ax.spines['top'].set_visible(False)
-    ax.spines['right'].set_visible(False)
-    ax.get_xaxis().tick_bottom()
-    ax.get_yaxis().tick_left()
-    ax.spines['left'].set_position(('outward', 10))
-    ax.spines['bottom'].set_position(('outward', 10))
-    ax.set_xlim([-4, 4])
-
-
 # Generate the dataset
 X, y = make_moons(n_samples=200, shuffle=True, noise=0.5, random_state=10)
+X = pd.DataFrame(X, columns=["feature 1", "feature 2"])
 
 # Two subplots, unpack the axes array immediately
 f, axs = plt.subplots(2, 3)
 
 axs = [a for ax in axs for a in ax]
 
-axs[0].scatter(X[y == 0, 0], X[y == 0, 1], label="Class #0", alpha=0.5)
-axs[0].scatter(X[y == 1, 0], X[y == 1, 1], label="Class #1", alpha=0.5)
+X.plot.scatter(
+    x='feature 1', y='feature 2', c=y, ax=axs[0], colormap='viridis',
+    colorbar=False
+)
 axs[0].set_title('Original set')
-plot_decoration(axs[0])
 
 
 def ratio_func(y, multiplier, minority_class):
@@ -58,10 +53,11 @@ for i, multiplier in enumerate(multipliers, start=1):
     X_, y_ = make_imbalance(X, y, sampling_strategy=ratio_func,
                             **{"multiplier": multiplier,
                                "minority_class": 1})
-    ax.scatter(X_[y_ == 0, 0], X_[y_ == 0, 1], label="Class #0", alpha=0.5)
-    ax.scatter(X_[y_ == 1, 0], X_[y_ == 1, 1], label="Class #1", alpha=0.5)
-    ax.set_title('sampling_strategy = {}'.format(multiplier))
-    plot_decoration(ax)
+    X_.plot.scatter(
+        x='feature 1', y='feature 2', c=y_, ax=ax, colormap='viridis',
+        colorbar=False
+    )
+    ax.set_title('Sampling ratio = {}'.format(multiplier))
 
 plt.tight_layout()
 plt.show()
diff --git a/imblearn/base.py b/imblearn/base.py
index 6f57d12..b182f3e 100644
--- a/imblearn/base.py
+++ b/imblearn/base.py
@@ -32,7 +32,8 @@ class SamplerMixin(BaseEstimator, metaclass=ABCMeta):
 
         Parameters
         ----------
-        X : {array-like, sparse matrix} of shape (n_samples, n_features)
+        X : {array-like, dataframe, sparse matrix} of shape \
+                (n_samples, n_features)
             Data array.
 
         y : array-like of shape (n_samples,)
@@ -54,7 +55,8 @@ class SamplerMixin(BaseEstimator, metaclass=ABCMeta):
 
         Parameters
         ----------
-        X : {array-like, sparse matrix} of shape (n_samples, n_features)
+        X : {array-like, dataframe, sparse matrix} of shape \
+                (n_samples, n_features)
             Matrix containing the data which have to be sampled.
 
         y : array-like of shape (n_samples,)
@@ -62,7 +64,7 @@ class SamplerMixin(BaseEstimator, metaclass=ABCMeta):
 
         Returns
         -------
-        X_resampled : {array-like, sparse matrix} of shape \
+        X_resampled : {array-like, dataframe, sparse matrix} of shape \
                 (n_samples_new, n_features)
             The array containing the resampled data.
 
@@ -78,12 +80,20 @@ class SamplerMixin(BaseEstimator, metaclass=ABCMeta):
 
         output = self._fit_resample(X, y)
 
+        if self._columns is not None:
+            import pandas as pd
+            X_ = pd.DataFrame(output[0], columns=self._columns)
+        else:
+            X_ = output[0]
+
         if binarize_y:
             y_sampled = label_binarize(output[1], np.unique(y))
             if len(output) == 2:
-                return output[0], y_sampled
-            return output[0], y_sampled, output[2]
-        return output
+                return X_, y_sampled
+            return X_, y_sampled, output[2]
+        if len(output) == 2:
+            return X_, output[1]
+        return X_, output[1], output[2]
 
     #  define an alias for back-compatibility
     fit_sample = fit_resample
@@ -124,8 +134,9 @@ class BaseSampler(SamplerMixin):
     def __init__(self, sampling_strategy="auto"):
         self.sampling_strategy = sampling_strategy
 
-    @staticmethod
-    def _check_X_y(X, y, accept_sparse=None):
+    def _check_X_y(self, X, y, accept_sparse=None):
+        # store the columns name to reconstruct a dataframe
+        self._columns = X.columns if hasattr(X, "loc") else None
         if accept_sparse is None:
             accept_sparse = ["csr", "csc"]
         y, binarize_y = check_target_type(y, indicate_one_vs_all=True)
@@ -238,6 +249,8 @@ class FunctionSampler(BaseSampler):
         y_resampled : array-like of shape (n_samples_new,)
             The corresponding label of `X_resampled`.
         """
+        # store the columns name to reconstruct a dataframe
+        self._columns = X.columns if hasattr(X, "loc") else None
         if self.validate:
             check_classification_targets(y)
             X, y, binarize_y = self._check_X_y(
@@ -250,12 +263,20 @@ class FunctionSampler(BaseSampler):
 
         output = self._fit_resample(X, y)
 
+        if self._columns is not None:
+            import pandas as pd
+            X_ = pd.DataFrame(output[0], columns=self._columns)
+        else:
+            X_ = output[0]
+
         if self.validate and binarize_y:
             y_sampled = label_binarize(output[1], np.unique(y))
             if len(output) == 2:
-                return output[0], y_sampled
-            return output[0], y_sampled, output[2]
-        return output
+                return X_, y_sampled
+            return X_, y_sampled, output[2]
+        if len(output) == 2:
+            return X_, output[1]
+        return X_, output[1], output[2]
 
     def _fit_resample(self, X, y):
         func = _identity if self.func is None else self.func
diff --git a/imblearn/ensemble/base.py b/imblearn/ensemble/base.py
deleted file mode 100644
index 935bb3d..0000000
--- a/imblearn/ensemble/base.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-Base class for the ensemble method.
-"""
-# Authors: Guillaume Lemaitre <g.lemaitre58@gmail.com>
-# License: MIT
-
-import numpy as np
-
-from sklearn.preprocessing import label_binarize
-
-from ..base import BaseSampler
-from ..utils import check_sampling_strategy
-
-
-class BaseEnsembleSampler(BaseSampler):
-    """Base class for ensemble algorithms.
-
-    Warning: This class should not be used directly. Use the derive classes
-    instead.
-    """
-
-    _sampling_type = "ensemble"
-
-    def fit_resample(self, X, y):
-        """Resample the dataset.
-
-        Parameters
-        ----------
-        X : {array-like, sparse matrix}, shape (n_samples, n_features)
-            Matrix containing the data which have to be sampled.
-
-        y : array-like, shape (n_samples,)
-            Corresponding label for each sample in X.
-
-        Returns
-        -------
-        X_resampled : {ndarray, sparse matrix}, shape \
-(n_subset, n_samples_new, n_features)
-            The array containing the resampled data.
-
-        y_resampled : ndarray, shape (n_subset, n_samples_new)
-            The corresponding label of `X_resampled`
-
-        """
-        # Ensemble are a bit specific since they are returning an array of
-        # resampled arrays.
-        X, y, binarize_y = self._check_X_y(X, y)
-
-        self.sampling_strategy_ = check_sampling_strategy(
-            self.sampling_strategy, y, self._sampling_type
-        )
-
-        output = self._fit_resample(X, y)
-
-        if binarize_y:
-            y_resampled = output[1]
-            classes = np.unique(y)
-            y_resampled_encoded = np.array(
-                [label_binarize(batch_y, classes) for batch_y in y_resampled]
-            )
-            if len(output) == 2:
-                return output[0], y_resampled_encoded
-            return output[0], y_resampled_encoded, output[2]
-        return output
diff --git a/imblearn/over_sampling/_random_over_sampler.py b/imblearn/over_sampling/_random_over_sampler.py
index 953f166..ea8b4d1 100644
--- a/imblearn/over_sampling/_random_over_sampler.py
+++ b/imblearn/over_sampling/_random_over_sampler.py
@@ -74,13 +74,12 @@ RandomOverSampler # doctest: +NORMALIZE_WHITESPACE
         super().__init__(sampling_strategy=sampling_strategy)
         self.random_state = random_state
 
-    @staticmethod
-    def _check_X_y(X, y):
+    def _check_X_y(self, X, y):
+        # store the columns name to reconstruct a dataframe
+        self._columns = X.columns if hasattr(X, "loc") else None
         y, binarize_y = check_target_type(y, indicate_one_vs_all=True)
-        if not hasattr(X, "loc"):
-            # Do not convert dataframe
-            X = check_array(X, accept_sparse=["csr", "csc"], dtype=None,
-                            force_all_finite=False)
+        X = check_array(X, accept_sparse=["csr", "csc"], dtype=None,
+                        force_all_finite=False)
         y = check_array(
             y, accept_sparse=["csr", "csc"], dtype=None, ensure_2d=False
         )
diff --git a/imblearn/over_sampling/_smote.py b/imblearn/over_sampling/_smote.py
index c583abb..e109ebe 100644
--- a/imblearn/over_sampling/_smote.py
+++ b/imblearn/over_sampling/_smote.py
@@ -100,39 +100,17 @@ class BaseSMOTE(BaseOverSampler):
         samples_indices = random_state.randint(
             low=0, high=len(nn_num.flatten()), size=n_samples
         )
-        steps = step_size * random_state.uniform(size=n_samples)
+
+        # np.newaxis for backwards compatability with random_state
+        steps = step_size * random_state.uniform(size=n_samples)[:, np.newaxis]
         rows = np.floor_divide(samples_indices, nn_num.shape[1])
         cols = np.mod(samples_indices, nn_num.shape[1])
 
-        y_new = np.array([y_type] * len(samples_indices), dtype=y_dtype)
+        X_new = self._generate_samples(X, nn_data, nn_num, rows, cols, steps)
+        y_new = np.full(n_samples, fill_value=y_type, dtype=y_dtype)
+        return X_new, y_new
 
-        if sparse.issparse(X):
-            row_indices, col_indices, samples = [], [], []
-            for i, (row, col, step) in enumerate(zip(rows, cols, steps)):
-                if X[row].nnz:
-                    sample = self._generate_sample(
-                        X, nn_data, nn_num, row, col, step
-                    )
-                    row_indices += [i] * len(sample.indices)
-                    col_indices += sample.indices.tolist()
-                    samples += sample.data.tolist()
-            return (
-                sparse.csr_matrix(
-                    (samples, (row_indices, col_indices)),
-                    [len(samples_indices), X.shape[1]],
-                    dtype=X.dtype,
-                ),
-                y_new,
-            )
-        else:
-            X_new = np.zeros((n_samples, X.shape[1]), dtype=X.dtype)
-            for i, (row, col, step) in enumerate(zip(rows, cols, steps)):
-                X_new[i] = self._generate_sample(
-                    X, nn_data, nn_num, row, col, step
-                )
-            return X_new, y_new
-
-    def _generate_sample(self, X, nn_data, nn_num, row, col, step):
+    def _generate_samples(self, X, nn_data, nn_num, rows, cols, steps):
         r"""Generate a synthetic sample.
 
         The rule for the generation is:
@@ -156,23 +134,32 @@ class BaseSMOTE(BaseOverSampler):
         nn_num : ndarray of shape (n_samples_all, k_nearest_neighbours)
             The nearest neighbours of each sample in `nn_data`.
 
-        row : int
-            Index pointing at feature vector in X which will be used
-            as a base for creating new sample.
+        rows : ndarray of shape (n_samples,), dtype=int
+            Indices pointing at feature vector in X which will be used
+            as a base for creating new samples.
 
-        col : int
-            Index pointing at which nearest neighbor of base feature vector
-            will be used when creating new sample.
+        cols : ndarray of shape (n_samples,), dtype=int
+            Indices pointing at which nearest neighbor of base feature vector
+            will be used when creating new samples.
 
-        step : float
-            Step size for new sample.
+        steps : ndarray of shape (n_samples,), dtype=float
+            Step sizes for new samples.
 
         Returns
         -------
-        X_new : {ndarray, sparse matrix} of shape (n_features,)
-            Single synthetically generated sample.
+        X_new : {ndarray, sparse matrix} of shape (n_samples, n_features)
+            Synthetically generated samples.
         """
-        return X[row] - step * (X[row] - nn_data[nn_num[row, col]])
+        diffs = nn_data[nn_num[rows, cols]] - X[rows]
+
+        if sparse.issparse(X):
+            sparse_func = type(X).__name__
+            steps = getattr(sparse, sparse_func)(steps)
+            X_new = X[rows] + steps.multiply(diffs)
+        else:
+            X_new = X[rows] + steps * diffs
+
+        return X_new.astype(X.dtype)
 
     def _in_danger_noise(
         self, nn_estimator, samples, target_class, y, kind="danger"
@@ -727,8 +714,8 @@ SMOTE # doctest: +NORMALIZE_WHITESPACE
     def _fit_resample(self, X, y):
         self._validate_estimator()
 
-        X_resampled = X.copy()
-        y_resampled = y.copy()
+        X_resampled = [X.copy()]
+        y_resampled = [y.copy()]
 
         for class_sample, n_samples in self.sampling_strategy_.items():
             if n_samples == 0:
@@ -741,14 +728,15 @@ SMOTE # doctest: +NORMALIZE_WHITESPACE
             X_new, y_new = self._make_samples(
                 X_class, y.dtype, class_sample, X_class, nns, n_samples, 1.0
             )
+            X_resampled.append(X_new)
+            y_resampled.append(y_new)
+
+        if sparse.issparse(X_new):
+            X_resampled = sparse.vstack(X_resampled, format=X.format)
+        else:
+            X_resampled = np.vstack(X_resampled)
+        y_resampled = np.hstack(y_resampled)
 
-            if sparse.issparse(X_new):
-                X_resampled = sparse.vstack([X_resampled, X_new])
-                sparse_func = "tocsc" if X.format == "csc" else "tocsr"
-                X_resampled = getattr(X_resampled, sparse_func)()
-            else:
-                X_resampled = np.vstack((X_resampled, X_new))
-            y_resampled = np.hstack((y_resampled, y_new))
 
         return X_resampled, y_resampled
 
@@ -900,11 +888,12 @@ class SMOTENC(SMOTE):
         )
         self.categorical_features = categorical_features
 
-    @staticmethod
-    def _check_X_y(X, y):
+    def _check_X_y(self, X, y):
         """Overwrite the checking to let pass some string for categorical
         features.
         """
+        # store the columns name to reconstruct a dataframe
+        self._columns = X.columns if hasattr(X, "loc") else None
         y, binarize_y = check_target_type(y, indicate_one_vs_all=True)
         X, y = check_X_y(X, y, accept_sparse=["csr", "csc"], dtype=None)
         return X, y, binarize_y
@@ -1015,7 +1004,7 @@ class SMOTENC(SMOTE):
 
         return X_resampled, y_resampled
 
-    def _generate_sample(self, X, nn_data, nn_num, row, col, step):
+    def _generate_samples(self, X, nn_data, nn_num, rows, cols, steps):
         """Generate a synthetic sample with an additional steps for the
         categorical features.
 
@@ -1024,35 +1013,34 @@ class SMOTENC(SMOTE):
         of the majority class.
         """
         rng = check_random_state(self.random_state)
-        sample = super()._generate_sample(X, nn_data, nn_num, row, col, step)
-        # To avoid conversion and since there is only few samples used, we
-        # convert those samples to dense array.
-        sample = (
-            sample.toarray().squeeze() if sparse.issparse(sample) else sample
-        )
-        all_neighbors = nn_data[nn_num[row]]
-        all_neighbors = (
-            all_neighbors.toarray()
-            if sparse.issparse(all_neighbors)
-            else all_neighbors
+        X_new = super()._generate_samples(
+            X, nn_data, nn_num, rows, cols, steps
         )
+        # change in sparsity structure more efficient with LIL than CSR
+        X_new = (X_new.tolil() if sparse.issparse(X_new) else X_new)
+
+        # convert to dense array since scipy.sparse doesn't handle 3D
+        nn_data = (nn_data.toarray() if sparse.issparse(nn_data) else nn_data)
+        all_neighbors = nn_data[nn_num[rows]]
 
         categories_size = [self.continuous_features_.size] + [
             cat.size for cat in self.ohe_.categories_
         ]
 
-        for start_idx, end_idx in zip(
-            np.cumsum(categories_size)[:-1], np.cumsum(categories_size)[1:]
-        ):
-            col_max = all_neighbors[:, start_idx:end_idx].sum(axis=0)
+        for start_idx, end_idx in zip(np.cumsum(categories_size)[:-1],
+                                      np.cumsum(categories_size)[1:]):
+            col_maxs = all_neighbors[:, :, start_idx:end_idx].sum(axis=1)
             # tie breaking argmax
-            col_sel = rng.choice(
-                np.flatnonzero(np.isclose(col_max, col_max.max()))
-            )
-            sample[start_idx:end_idx] = 0
-            sample[start_idx + col_sel] = 1
+            is_max = np.isclose(col_maxs, col_maxs.max(axis=1, keepdims=True))
+            max_idxs = rng.permutation(np.argwhere(is_max))
+            xs, idx_sels = np.unique(max_idxs[:, 0], return_index=True)
+            col_sels = max_idxs[idx_sels, 1]
+
+            ys = start_idx + col_sels
+            X_new[:, start_idx:end_idx] = 0
+            X_new[xs, ys] = 1
 
-        return sparse.csr_matrix(sample) if sparse.issparse(X) else sample
+        return X_new
 
 
 @Substitution(
diff --git a/imblearn/under_sampling/_prototype_selection/_instance_hardness_threshold.py b/imblearn/under_sampling/_prototype_selection/_instance_hardness_threshold.py
index cc3b616..9edd2ab 100644
--- a/imblearn/under_sampling/_prototype_selection/_instance_hardness_threshold.py
+++ b/imblearn/under_sampling/_prototype_selection/_instance_hardness_threshold.py
@@ -12,7 +12,10 @@ import numpy as np
 
 from sklearn.base import ClassifierMixin, clone
 from sklearn.ensemble import RandomForestClassifier
+from sklearn.ensemble._base import _set_random_states
 from sklearn.model_selection import StratifiedKFold
+from sklearn.model_selection import cross_val_predict
+from sklearn.utils import check_random_state
 from sklearn.utils import _safe_indexing
 
 from ..base import BaseUnderSampler
@@ -108,7 +111,7 @@ class InstanceHardnessThreshold(BaseUnderSampler):
         self.cv = cv
         self.n_jobs = n_jobs
 
-    def _validate_estimator(self):
+    def _validate_estimator(self, random_state):
         """Private function to create the classifier"""
 
         if (
@@ -117,6 +120,8 @@ class InstanceHardnessThreshold(BaseUnderSampler):
             and hasattr(self.estimator, "predict_proba")
         ):
             self.estimator_ = clone(self.estimator)
+            _set_random_states(self.estimator_, random_state)
+
         elif self.estimator is None:
             self.estimator_ = RandomForestClassifier(
                 n_estimators=100,
@@ -131,22 +136,18 @@ class InstanceHardnessThreshold(BaseUnderSampler):
             )
 
     def _fit_resample(self, X, y):
-        self._validate_estimator()
+        random_state = check_random_state(self.random_state)
+        self._validate_estimator(random_state)
 
         target_stats = Counter(y)
-        skf = StratifiedKFold(n_splits=self.cv, shuffle=False).split(X, y)
-        probabilities = np.zeros(y.shape[0], dtype=float)
-
-        for train_index, test_index in skf:
-            X_train = _safe_indexing(X, train_index)
-            X_test = _safe_indexing(X, test_index)
-            y_train = _safe_indexing(y, train_index)
-            y_test = _safe_indexing(y, test_index)
-
-            self.estimator_.fit(X_train, y_train)
-
-            probs = self.estimator_.predict_proba(X_test)
-            probabilities[test_index] = probs[range(len(y_test)), y_test]
+        skf = StratifiedKFold(
+            n_splits=self.cv, shuffle=True, random_state=random_state,
+        )
+        probabilities = cross_val_predict(
+            self.estimator_, X, y, cv=skf, n_jobs=self.n_jobs,
+            method='predict_proba'
+        )
+        probabilities = probabilities[range(len(y)), y]
 
         idx_under = np.empty((0,), dtype=int)
 
diff --git a/imblearn/under_sampling/_prototype_selection/_random_under_sampler.py b/imblearn/under_sampling/_prototype_selection/_random_under_sampler.py
index 02f014f..6301822 100644
--- a/imblearn/under_sampling/_prototype_selection/_random_under_sampler.py
+++ b/imblearn/under_sampling/_prototype_selection/_random_under_sampler.py
@@ -80,13 +80,12 @@ RandomUnderSampler # doctest: +NORMALIZE_WHITESPACE
         self.random_state = random_state
         self.replacement = replacement
 
-    @staticmethod
-    def _check_X_y(X, y):
+    def _check_X_y(self, X, y):
+        # store the columns name to reconstruct a dataframe
+        self._columns = X.columns if hasattr(X, "loc") else None
         y, binarize_y = check_target_type(y, indicate_one_vs_all=True)
-        if not hasattr(X, "loc"):
-            # Do not convert dataframe
-            X = check_array(X, accept_sparse=["csr", "csc"], dtype=None,
-                            force_all_finite=False)
+        X = check_array(X, accept_sparse=["csr", "csc"], dtype=None,
+                        force_all_finite=False)
         y = check_array(
             y, accept_sparse=["csr", "csc"], dtype=None, ensure_2d=False
         )
diff --git a/imblearn/utils/estimator_checks.py b/imblearn/utils/estimator_checks.py
index ce885cf..4fef2a1 100644
--- a/imblearn/utils/estimator_checks.py
+++ b/imblearn/utils/estimator_checks.py
@@ -30,7 +30,6 @@ from sklearn.utils.multiclass import type_of_target
 
 from imblearn.over_sampling.base import BaseOverSampler
 from imblearn.under_sampling.base import BaseCleaningSampler, BaseUnderSampler
-from imblearn.ensemble.base import BaseEnsembleSampler
 from imblearn.under_sampling import NearMiss, ClusterCentroids
 
 
@@ -168,12 +167,6 @@ def check_samplers_fit_resample(name, Sampler):
             for class_sample in target_stats.keys()
             if class_sample != class_minority
         )
-    elif isinstance(sampler, BaseEnsembleSampler):
-        y_ensemble = y_res[0]
-        n_samples = min(target_stats.values())
-        assert all(
-            value == n_samples for value in Counter(y_ensemble).values()
-        )
 
 
 def check_samplers_sampling_strategy_fit_resample(name, Sampler):
@@ -202,12 +195,6 @@ def check_samplers_sampling_strategy_fit_resample(name, Sampler):
         sampler.set_params(sampling_strategy=sampling_strategy)
         X_res, y_res = sampler.fit_resample(X, y)
         assert Counter(y_res)[1] == expected_stat
-    if isinstance(sampler, BaseEnsembleSampler):
-        sampling_strategy = {2: 201, 0: 201}
-        sampler.set_params(sampling_strategy=sampling_strategy)
-        X_res, y_res = sampler.fit_resample(X, y)
-        y_ensemble = y_res[0]
-        assert Counter(y_ensemble)[1] == expected_stat
 
 
 def check_samplers_sparse(name, Sampler):
@@ -239,17 +226,9 @@ def check_samplers_sparse(name, Sampler):
         set_random_state(sampler)
         X_res_sparse, y_res_sparse = sampler.fit_resample(X_sparse, y)
         X_res, y_res = sampler.fit_resample(X, y)
-        if not isinstance(sampler, BaseEnsembleSampler):
-            assert sparse.issparse(X_res_sparse)
-            assert_allclose(X_res_sparse.A, X_res)
-            assert_allclose(y_res_sparse, y_res)
-        else:
-            for x_sp, x, y_sp, y in zip(
-                X_res_sparse, X_res, y_res_sparse, y_res
-            ):
-                assert sparse.issparse(x_sp)
-                assert_allclose(x_sp.A, x)
-                assert_allclose(y_sp, y)
+        assert sparse.issparse(X_res_sparse)
+        assert_allclose(X_res_sparse.A, X_res)
+        assert_allclose(y_res_sparse, y_res)
 
 
 def check_samplers_pandas(name, Sampler):
@@ -262,7 +241,7 @@ def check_samplers_pandas(name, Sampler):
         weights=[0.2, 0.3, 0.5],
         random_state=0,
     )
-    X_pd = pd.DataFrame(X)
+    X_pd = pd.DataFrame(X, columns=[str(i) for i in range(X.shape[1])])
     sampler = Sampler()
     if isinstance(Sampler(), NearMiss):
         samplers = [Sampler(version=version) for version in (1, 2, 3)]
@@ -274,7 +253,11 @@ def check_samplers_pandas(name, Sampler):
         set_random_state(sampler)
         X_res_pd, y_res_pd = sampler.fit_resample(X_pd, y)
         X_res, y_res = sampler.fit_resample(X, y)
-        assert_allclose(X_res_pd, X_res)
+
+        # check that we return a pandas dataframe if a dataframe was given in
+        assert isinstance(X_res_pd, pd.DataFrame)
+        assert X_pd.columns.to_list() == X_res_pd.columns.to_list()
+        assert_allclose(X_res_pd.to_numpy(), X_res)
         assert_allclose(y_res_pd, y_res)
 
 
@@ -293,13 +276,8 @@ def check_samplers_multiclass_ova(name, Sampler):
     X_res, y_res = sampler.fit_resample(X, y)
     X_res_ova, y_res_ova = sampler.fit_resample(X, y_ova)
     assert_allclose(X_res, X_res_ova)
-    if issubclass(Sampler, BaseEnsembleSampler):
-        for batch_y, batch_y_ova in zip(y_res, y_res_ova):
-            assert type_of_target(batch_y_ova) == type_of_target(y_ova)
-            assert_allclose(batch_y, batch_y_ova.argmax(axis=1))
-    else:
-        assert type_of_target(y_res_ova) == type_of_target(y_ova)
-        assert_allclose(y_res, y_res_ova.argmax(axis=1))
+    assert type_of_target(y_res_ova) == type_of_target(y_ova)
+    assert_allclose(y_res, y_res_ova.argmax(axis=1))
 
 
 def check_samplers_preserve_dtype(name, Sampler):


Test patch:
diff --git a/imblearn/over_sampling/tests/test_smote_nc.py b/imblearn/over_sampling/tests/test_smote_nc.py
index a495c77..c7ba80c 100644
--- a/imblearn/over_sampling/tests/test_smote_nc.py
+++ b/imblearn/over_sampling/tests/test_smote_nc.py
@@ -13,6 +13,7 @@ from scipy import sparse
 
 from sklearn.datasets import make_classification
 from sklearn.utils._testing import assert_allclose
+from sklearn.utils._testing import assert_array_equal
 
 from imblearn.over_sampling import SMOTENC
 
@@ -184,7 +185,7 @@ def test_smotenc_pandas():
     smote = SMOTENC(categorical_features=categorical_features, random_state=0)
     X_res_pd, y_res_pd = smote.fit_resample(X_pd, y)
     X_res, y_res = smote.fit_resample(X, y)
-    assert X_res_pd.tolist() == X_res.tolist()
+    assert_array_equal(X_res_pd.to_numpy(), X_res)
     assert_allclose(y_res_pd, y_res)
 
 
diff --git a/imblearn/under_sampling/_prototype_selection/tests/test_instance_hardness_threshold.py b/imblearn/under_sampling/_prototype_selection/tests/test_instance_hardness_threshold.py
index f720e7b..6f0cf51 100644
--- a/imblearn/under_sampling/_prototype_selection/tests/test_instance_hardness_threshold.py
+++ b/imblearn/under_sampling/_prototype_selection/tests/test_instance_hardness_threshold.py
@@ -6,7 +6,10 @@
 import pytest
 import numpy as np
 
+
 from sklearn.ensemble import GradientBoostingClassifier
+from sklearn.ensemble import RandomForestClassifier
+from sklearn.utils._testing import assert_array_equal
 
 from imblearn.under_sampling import InstanceHardnessThreshold
 
@@ -76,3 +79,16 @@ def test_iht_fit_resample_wrong_class_obj():
     iht = InstanceHardnessThreshold(estimator=est, random_state=RND_SEED)
     with pytest.raises(ValueError, match="Invalid parameter `estimator`"):
         iht.fit_resample(X, Y)
+
+
+def test_iht_reproducibility():
+    from sklearn.datasets import load_digits
+    X_digits, y_digits = load_digits(return_X_y=True)
+    idx_sampled = []
+    for seed in range(5):
+        est = RandomForestClassifier(n_estimators=10, random_state=seed)
+        iht = InstanceHardnessThreshold(estimator=est, random_state=RND_SEED)
+        iht.fit_resample(X_digits, y_digits)
+        idx_sampled.append(iht.sample_indices_.copy())
+    for idx_1, idx_2 in zip(idx_sampled, idx_sampled[1:]):
+        assert_array_equal(idx_1, idx_2)