Repository: fairlearn/fairlearn

Issue:
Can't use sklearn.Pipeline with GridSearch.
Hello,

I may be wrong, but GridSearch does not allow [sklearn.Pipeline](https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.Pipeline.html) for its estimator.
This would be a useful feature have!

## How to reproduce.
In `test/unit/reductions/grid_search/test_grid_search_arguments.py` add:

```python
# Additional Imports
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

# Set up Pipeline estimator
class TestPipelineEstimator(ConditionalOpportunityTests):
    def setup_method(self, method):
        self.estimator = Pipeline([('scaler', StandardScaler()), ('logistic', LogisticRegression(solver='liblinear'))])
        self.disparity_criterion = DemographicParity()
```

Then : `pytest test/unit/reductions/grid_search/test_grid_search_arguments.py` would fail with:

```
ValueError: Pipeline.fit does not accept the sample_weight parameter. You can pass parameters to specific steps of your pipeline using the stepname__parameter format, e.g. `Pipeline.fit(X, y, logisticregression__sample_weight=sample_weight)`.
```


### Proposed solution

In GridSearch constructor, we could allow specifying the key for `sample_weight`.

Ex. `GridSearch(self.estimator, self.disparity_criterion, grid_size=2, sample_weight_key='logistic__sample_weight')`

I already made the test so I can add the solution too if it is approved.


Discussion:

Patch:
diff --git a/AUTHORS.md b/AUTHORS.md
index f5778a5..3927aca 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -3,6 +3,7 @@
 All names are sorted alphabetically by last name. Contributors, please add your name to the list when you submit a patch to the project.
 
 - [Sarah Bird](https://github.com/slbird)
+- [Frederic Branchaud-Charron](https://github.com/Dref360)
 - [Rishit Dagli](https://github.com/Rishit-dagli)
 - [Miro Dudik](https://github.com/MiroDudik)
 - [Richard Edgar](https://github.com/riedgar-ms)
diff --git a/CHANGES.md b/CHANGES.md
index dafe900..86b8ed3 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -42,6 +42,8 @@
 * Add class `InterpolatedThresholder` to represent the fitted `ThresholdOptimizer`
 * Add `fairlearn.datasets` module.
 * Change the method to make copies of the estimator in `ExponentiatedGradient` from `pickle.dump` to `sklearn.clone`. 
+* Add an argument to GridSearch and ExponentialGradient `sample_weight_name` to control
+  how `sample_weight` is supplied to `estimator.fit`.
 
 ### v0.4.6
 
diff --git a/fairlearn/postprocessing/_interpolated_thresholder.py b/fairlearn/postprocessing/_interpolated_thresholder.py
index 0f1a4ae..3fbc0e8 100644
--- a/fairlearn/postprocessing/_interpolated_thresholder.py
+++ b/fairlearn/postprocessing/_interpolated_thresholder.py
@@ -2,14 +2,13 @@
 # Licensed under the MIT License.
 
 import numpy as np
-import random
-
-from warnings import warn
-
 from sklearn import clone
 from sklearn.base import BaseEstimator, MetaEstimatorMixin
 from sklearn.exceptions import NotFittedError
+from sklearn.utils import check_random_state
 from sklearn.utils.validation import check_is_fitted
+from warnings import warn
+
 from fairlearn._input_validation import _validate_and_reformat_input
 from ._constants import (
     BASE_ESTIMATOR_NONE_ERROR_MESSAGE,
@@ -23,8 +22,12 @@ class InterpolatedThresholder(BaseEstimator, MetaEstimatorMixin):
     Based on the values of sensitive features, it then applies a randomized thresholding
     transformation according to the provided `interpolation_dict`.
 
-    :param estimator: base estimator
-    :param dict interpolation_dict: maps sensitive feature values to `Bunch` that describes the
+    Parameters
+    ----------
+    estimator :
+        base estimator
+    interpolation_dict : dict
+        maps sensitive feature values to `Bunch` that describes the
         interpolation transformation via the following fields:
 
         - p0, operation0: with probability p0, operation0 is executed
@@ -36,7 +39,8 @@ class InterpolatedThresholder(BaseEstimator, MetaEstimatorMixin):
         The numbers p0 and p1 must be non-negative and add up to 1, operation0 and
         operation1 must be instances of :class:`ThresholdOperation`, and p_ignore must be
         between 0 and 1.
-    :param bool prefit: if `True` then the base estimator is not fitted in :meth:`fit`.
+    prefit : bool
+        if `True` then the base estimator is not fitted in :meth:`fit`.
     """
 
     def __init__(self, estimator, interpolation_dict, prefit=False):
@@ -66,15 +70,18 @@ class InterpolatedThresholder(BaseEstimator, MetaEstimatorMixin):
     def _pmf_predict(self, X, *, sensitive_features):
         """Probabilistic mass function.
 
-        :param X: Feature matrix
-        :type X: numpy.ndarray or pandas.DataFrame
-        :param sensitive_features: Sensitive features to identify groups by, currently allows
-            only a single column
-        :type sensitive_features: Currently 1D array as numpy.ndarray, list, pandas.DataFrame,
-            or pandas.Series
-        :return: array of tuples with probabilities for predicting 0 or 1, respectively. The sum
-            of the two numbers in each tuple needs to add up to 1.
-        :rtype: numpy.ndarray
+        Parameters
+        ----------
+        X : numpy.ndarray or pandas.DataFrame
+            Feature matrix
+        sensitive_features : numpy.ndarray, list, pandas.DataFrame, pandas.Series
+            Sensitive features to identify groups by
+
+        Returns
+        -------
+        numpy.ndarray
+            array of tuples with probabilities for predicting 0 or 1, respectively.
+            The sum of the two numbers in each tuple needs to add up to 1.
         """
         check_is_fitted(self)
         base_predictions = np.array(self.estimator_.predict(X))
@@ -101,16 +108,24 @@ class InterpolatedThresholder(BaseEstimator, MetaEstimatorMixin):
         Note that this is non-deterministic, due to the nature of the
         interpolation.
 
-        :param X: Feature data
-        :type X: numpy.ndarray or pandas.DataFrame
-
-        :return: The prediction. If `X` represents the data for a single example
+        Parameters
+        ----------
+        X : numpy.ndarray or pandas.DataFrame
+            Feature data
+        sensitive_features : numpy.ndarray or pandas.DataFrame
+            Sensitive features to identify groups by
+        random_state : int or RandomState instance, default=None
+            Controls random numbers used for randomized predictions. Pass an
+            int for reproducible output across multiple function calls.
+
+        Returns
+        -------
+        Scalar or vector as numpy.ndarray
+            The prediction. If `X` represents the data for a single example
             the result will be a scalar. Otherwise the result will be a vector
-        :rtype: Scalar or vector
         """
         check_is_fitted(self)
-        if random_state:
-            random.seed(random_state)
+        random_state = check_random_state(random_state)
         positive_probs = self._pmf_predict(
             X, sensitive_features=sensitive_features)[:, 1]
-        return (positive_probs >= np.random.rand(len(positive_probs))) * 1
+        return (positive_probs >= random_state.rand(len(positive_probs))) * 1
diff --git a/fairlearn/postprocessing/_threshold_optimizer.py b/fairlearn/postprocessing/_threshold_optimizer.py
index ce6f515..e896d48 100644
--- a/fairlearn/postprocessing/_threshold_optimizer.py
+++ b/fairlearn/postprocessing/_threshold_optimizer.py
@@ -164,14 +164,14 @@ class ThresholdOptimizer(BaseEstimator, MetaEstimatorMixin):
         as well as the fairness-unaware predictor or estimator. If an estimator was passed
         in the constructor this fit method will call `fit(X, y, **kwargs)` on said estimator.
 
-        :param X: The feature matrix
-        :type X: numpy.ndarray or pandas.DataFrame
-        :param y: The label vector
-        :type y: numpy.ndarray, pandas.DataFrame, pandas.Series, or list
-        :param sensitive_features: sensitive features to identify groups by, currently allows
-            only a single column
-        :type sensitive_features: currently 1D array as numpy.ndarray, list, pandas.DataFrame,
-            or pandas.Series
+        Parameters
+        ----------
+        X : numpy.ndarray or pandas.DataFrame
+            The feature matrix
+        y : numpy.ndarray, pandas.DataFrame, pandas.Series, or list
+            The label vector
+        sensitive_features : numpy.ndarray, list, pandas.DataFrame, or pandas.Series
+            sensitive features to identify groups by
         """
         if self.estimator is None:
             raise ValueError(BASE_ESTIMATOR_NONE_ERROR_MESSAGE)
@@ -225,15 +225,21 @@ class ThresholdOptimizer(BaseEstimator, MetaEstimatorMixin):
     def predict(self, X, *, sensitive_features, random_state=None):
         """Predict label for each sample in X while taking into account sensitive features.
 
-        :param X: feature matrix
-        :type X: numpy.ndarray or pandas.DataFrame
-        :param sensitive_features: sensitive features to identify groups by, currently allows
-            only a single column
-        :type sensitive_features: currently 1D array as numpy.ndarray, list, pandas.DataFrame,
-            or pandas.Series
-        :param random_state: set to a constant for reproducibility
-        :type random_state: int
-        :return: predictions in numpy.ndarray
+        Parameters
+        ----------
+        X : numpy.ndarray or pandas.DataFrame
+            feature matrix
+        sensitive_features : numpy.ndarray, list, pandas.DataFrame, pandas.Series
+            sensitive features to identify groups by
+        random_state : int or RandomState instance, default=None
+            Controls random numbers used for randomized predictions. Pass an
+            int for reproducible output across multiple function calls.
+
+        Returns
+        -------
+        Scalar or vector as numpy.ndarray
+            The prediction. If `X` represents the data for a single example
+            the result will be a scalar. Otherwise the result will be a vector
         """
         check_is_fitted(self)
         return self.interpolated_thresholder_.predict(
@@ -242,15 +248,19 @@ class ThresholdOptimizer(BaseEstimator, MetaEstimatorMixin):
     def _pmf_predict(self, X, *, sensitive_features):
         """Probabilistic mass function.
 
-        :param X: Feature matrix
-        :type X: numpy.ndarray or pandas.DataFrame
-        :param sensitive_features: Sensitive features to identify groups by, currently allows
-            only a single column
-        :type sensitive_features: Currently 1D array as numpy.ndarray, list, pandas.DataFrame,
-            or pandas.Series
-        :return: array of tuples with probabilities for predicting 0 or 1, respectively. The sum
-            of the two numbers in each tuple needs to add up to 1.
-        :rtype: numpy.ndarray
+        Parameters
+        ----------
+        X : numpy.ndarray or pandas.DataFrame
+            Feature matrix
+        sensitive_features : numpy.ndarray, list, pandas.DataFrame, pandas.Series
+            Sensitive features to identify groups by
+
+        Returns
+        -------
+        numpy.ndarray
+            array of tuples with probabilities for predicting 0 or 1,
+            respectively. The sum of the two numbers in each tuple needs to
+            add up to 1.
         """
         check_is_fitted(self)
         return self.interpolated_thresholder_._pmf_predict(
@@ -269,14 +279,19 @@ class ThresholdOptimizer(BaseEstimator, MetaEstimatorMixin):
         This method assumes that sensitive_features, labels, and scores are non-empty data
         structures of equal length, and labels contains only binary labels 0 and 1.
 
-        :param sensitive_features: the feature data that determines the groups for which
-            the parity constraints are enforced
-        :type sensitive_features: list, numpy.ndarray, pandas.DataFrame, or pandas.Series
-        :param labels: the labels of the dataset
-        :type labels: list, numpy.ndarray, pandas.DataFrame, or pandas.Series
-        :param scores: the scores produced by a predictor's prediction
-        :type scores: list, numpy.ndarray, pandas.DataFrame, or pandas.Series
-        :return: the postprocessed predictor.
+        Parameters
+        ----------
+        sensitive_features : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+            Sensitive features to identify groups by
+        labels : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+            the labels of the dataset
+        scores : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+            the scores produced by a predictor's prediction
+
+        Returns
+        -------
+        InterpolatedThresholder
+            the postprocessed predictor.
         """
         n = len(labels)
         self._tradeoff_curve = {}
@@ -346,14 +361,19 @@ class ThresholdOptimizer(BaseEstimator, MetaEstimatorMixin):
         This method assumes that sensitive_features, labels, and scores are non-empty data
         structures of equal length, and labels contains only binary labels 0 and 1.
 
-        :param sensitive_features: the feature data that determines the groups for which the
-            parity constraints are enforced
-        :type sensitive_features: list, numpy.ndarray, pandas.DataFrame, or pandas.Series
-        :param labels: the labels of the dataset
-        :type labels: list, numpy.ndarray, pandas.DataFrame, or pandas.Series
-        :param scores: the scores produced by a predictor's prediction
-        :type scores: list, numpy.ndarray, pandas.DataFrame, or pandas.Series
-        :return: the postprocessed predictor.
+        Parameters
+        ----------
+        sensitive_features : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+            Sensitive features to identify groups by
+        labels : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+            the labels of the dataset
+        scores : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+            the scores produced by a predictor's prediction
+
+        Returns
+        -------
+        InterpolatedThresholder
+            the postprocessed predictor.
         """
         data_grouped_by_sensitive_feature = _reformat_and_group_data(
             sensitive_features, labels, scores)
@@ -450,19 +470,23 @@ def _reformat_and_group_data(sensitive_features, labels, scores, sensitive_featu
     the new  DataFrame is grouped by sensitive feature values so that subsequently each group
     can be handled separately.
 
-    :param sensitive_features: the sensitive features based on which the grouping is determined;
-        currently only a single sensitive feature is supported
-    :type sensitive_features: pandas.Series, pandas.DataFrame, numpy.ndarray, or list
-    :param labels: the training labels
-    :type labels: pandas.Series, pandas.DataFrame, numpy.ndarray, or list
-    :param scores: the output from the unconstrained predictor used for training the mitigator
-    :type scores: pandas.Series, pandas.DataFrame, numpy.ndarray, or list
-    :param sensitive_feature_names: list of names for the sensitive features in case they were
-        not implicitly provided (e.g. if `sensitive_features` is of type DataFrame); default
-        None
-    :type sensitive_feature_names: list of strings
-    :return: the training data for the mitigator, grouped by sensitive feature value
-    :rtype: pandas.DataFrameGroupBy
+    Parameters
+    ----------
+    sensitive_features : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+        Sensitive features to identify groups by
+    labels : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+        the labels of the dataset
+    scores : list, numpy.ndarray, pandas.DataFrame, or pandas.Series
+        the scores produced by a predictor's prediction
+    sensitive_feature_names : list of strings
+        list of names for the sensitive features in case they were not
+        implicitly provided (e.g. if `sensitive_features` is of type
+        pandas.DataFrame); default None
+
+    Returns
+    -------
+    pandas.DataFrameGroupBy
+        the training data for the mitigator, grouped by sensitive feature value
     """
     data_dict = {}
 
@@ -488,14 +512,21 @@ def _reformat_data_into_dict(key, data_dict, additional_data):
     Before `additional_data` is added to `data_dict` it is first
     reformatted into a numpy.ndarray or list.
 
-    :param key: the key in `data_dict` at which `additional_data` should be stored;
+    Parameters
+    ----------
+    key : str
+        the key in `data_dict` at which `additional_data` should be stored;
         `key` should describe the purpose of `additional_data` in `data_dict`
-    :type key: str
-    :param data_dict: the dictionary containing all the relevant data; `additional_data` will be
-        inserted at the key `key`.
-    :type data_dict: dict
-    :param additional_data: the data to be added to `data_dict` at the specified `key`
-    :type additional_data: numpy.ndarray, pandas.DataFrame, pandas.Series, or list
+    data_dict : dict
+        the dictionary containing all the relevant data; `additional_data`
+        will be inserted at the key `key`.
+    additional_data : numpy.ndarray, pandas.DataFrame, pandas.Series, or list
+        the data to be added to `data_dict` at the specified `key`
+
+    Returns
+    -------
+    dict
+        The updated `data_dict` with reformatted data at the `key` slot
     """
     if type(additional_data) == np.ndarray:
         if len(additional_data.shape) > 2 or (len(additional_data.shape) == 2 and
diff --git a/fairlearn/reductions/_exponentiated_gradient/_lagrangian.py b/fairlearn/reductions/_exponentiated_gradient/_lagrangian.py
index b83e238..c487a12 100644
--- a/fairlearn/reductions/_exponentiated_gradient/_lagrangian.py
+++ b/fairlearn/reductions/_exponentiated_gradient/_lagrangian.py
@@ -38,9 +38,13 @@ class _Lagrangian:
     opt_lambda : bool
         indicates whether to optimize lambda during the calculation of the
         Lagrangian; optional with default value True
+    sample_weight_name : str
+        Name of the argument to `estimator.fit()` which supplies the sample weights
+        (defaults to `sample_weight`)
     """
 
-    def __init__(self, X, sensitive_features, y, estimator, constraints, B, opt_lambda=True):
+    def __init__(self, X, sensitive_features, y, estimator, constraints, B, opt_lambda=True,
+                 sample_weight_name='sample_weight'):
         self.X = X
         self.n = self.X.shape[0]
         self.y = y
@@ -61,6 +65,7 @@ class _Lagrangian:
         self.n_oracle_calls_dummy_returned = 0
         self.last_linprog_n_hs = 0
         self.last_linprog_result = None
+        self.sample_weight_name = sample_weight_name
 
     def _eval(self, Q, lambda_vec):
         """Return the value of the Lagrangian.
@@ -170,7 +175,7 @@ class _Lagrangian:
             estimator = clone(estimator=self.estimator, safe=False)
 
         oracle_call_start_time = time()
-        estimator.fit(self.X, redY, sample_weight=redW)
+        estimator.fit(self.X, redY, **{self.sample_weight_name: redW})
         self.oracle_execution_times.append(time() - oracle_call_start_time)
         self.n_oracle_calls += 1
 
diff --git a/fairlearn/reductions/_exponentiated_gradient/exponentiated_gradient.py b/fairlearn/reductions/_exponentiated_gradient/exponentiated_gradient.py
index 92e8d4e..c69594d 100644
--- a/fairlearn/reductions/_exponentiated_gradient/exponentiated_gradient.py
+++ b/fairlearn/reductions/_exponentiated_gradient/exponentiated_gradient.py
@@ -5,6 +5,7 @@ import logging
 import numpy as np
 import pandas as pd
 from sklearn.base import BaseEstimator, MetaEstimatorMixin
+from sklearn.utils import check_random_state
 from sklearn.utils.validation import check_is_fitted
 from ._constants import _ACCURACY_MUL, _REGRET_CHECK_START_T, _REGRET_CHECK_INCREASE_T, \
     _SHRINK_REGRET, _SHRINK_ETA, _MIN_ITER, _PRECISION, _INDENTATION
@@ -51,10 +52,14 @@ class ExponentiatedGradient(BaseEstimator, MetaEstimatorMixin):
         if True each step of exponentiated gradient is followed by the saddle
         point optimization over the convex hull of classifiers returned so
         far; default True
+    sample_weight_name : str
+        Name of the argument to `estimator.fit()` which supplies the sample weights
+        (defaults to `sample_weight`)
     """
 
     def __init__(self, estimator, constraints, eps=0.01, max_iter=50, nu=None,
-                 eta0=2.0, run_linprog_step=True):  # noqa: D103
+                 eta0=2.0, run_linprog_step=True,
+                 sample_weight_name='sample_weight'):  # noqa: D103
         self.estimator = estimator
         self.constraints = constraints
         self.eps = eps
@@ -62,6 +67,7 @@ class ExponentiatedGradient(BaseEstimator, MetaEstimatorMixin):
         self.nu = nu
         self.eta0 = eta0
         self.run_linprog_step = run_linprog_step
+        self.sample_weight_name = sample_weight_name
 
     def fit(self, X, y, **kwargs):
         """Return a fair classifier under specified fairness constraints.
@@ -93,7 +99,8 @@ class ExponentiatedGradient(BaseEstimator, MetaEstimatorMixin):
 
         B = 1 / self.eps
         lagrangian = _Lagrangian(X, sensitive_features, y_train, self.estimator,
-                                 self.constraints, B)
+                                 self.constraints, B,
+                                 sample_weight_name=self.sample_weight_name)
 
         theta = pd.Series(0, lagrangian.constraints.index)
         Qsum = pd.Series(dtype="float64")
@@ -191,7 +198,7 @@ class ExponentiatedGradient(BaseEstimator, MetaEstimatorMixin):
                      self.last_iter_, self.best_iter_, self.best_gap_, lagrangian.n_oracle_calls,
                      len(lagrangian.predictors))
 
-    def predict(self, X):
+    def predict(self, X, random_state=None):
         """Provide predictions for the given input data.
 
         Predictions are randomized, i.e., repeatedly calling `predict` with
@@ -213,6 +220,9 @@ class ExponentiatedGradient(BaseEstimator, MetaEstimatorMixin):
         ----------
         X : numpy.ndarray or pandas.DataFrame
             Feature data
+        random_state : int or RandomState instance, default=None
+            Controls random numbers used for randomized predictions. Pass an
+            int for reproducible output across multiple function calls.
 
         Returns
         -------
@@ -221,15 +231,16 @@ class ExponentiatedGradient(BaseEstimator, MetaEstimatorMixin):
             the result will be a scalar. Otherwise the result will be a vector
         """
         check_is_fitted(self)
+        random_state = check_random_state(random_state)
 
         if isinstance(self.constraints, ClassificationMoment):
             positive_probs = self._pmf_predict(X)[:, 1]
-            return (positive_probs >= np.random.rand(len(positive_probs))) * 1
+            return (positive_probs >= random_state.rand(len(positive_probs))) * 1
         else:
             pred = self._pmf_predict(X)
             randomized_pred = np.zeros(pred.shape[0])
             for i in range(pred.shape[0]):
-                randomized_pred[i] = np.random.choice(pred.iloc[i, :], p=self.weights_)
+                randomized_pred[i] = random_state.choice(pred.iloc[i, :], p=self.weights_)
             return randomized_pred
 
     def _pmf_predict(self, X):
diff --git a/fairlearn/reductions/_grid_search/grid_search.py b/fairlearn/reductions/_grid_search/grid_search.py
index bd24f17..e6c648b 100644
--- a/fairlearn/reductions/_grid_search/grid_search.py
+++ b/fairlearn/reductions/_grid_search/grid_search.py
@@ -60,6 +60,9 @@ class GridSearch(BaseEstimator, MetaEstimatorMixin):
     grid :
         Instead of supplying a size and limit for the grid, users may specify
         the exact set of Lagrange multipliers they desire using this argument.
+    sample_weight_name : str
+        Name of the argument to `estimator.fit()` which supplies the sample weights
+        (defaults to `sample_weight`)
     """
 
     def __init__(self,
@@ -70,7 +73,8 @@ class GridSearch(BaseEstimator, MetaEstimatorMixin):
                  grid_size=10,
                  grid_limit=2.0,
                  grid_offset=None,
-                 grid=None):
+                 grid=None,
+                 sample_weight_name="sample_weight"):
         """Construct a GridSearch object."""
         self.estimator = estimator
         if not isinstance(constraints, Moment):
@@ -90,6 +94,7 @@ class GridSearch(BaseEstimator, MetaEstimatorMixin):
         self.grid_limit = float(grid_limit)
         self.grid_offset = grid_offset
         self.grid = grid
+        self.sample_weight_name = sample_weight_name
 
     def fit(self, X, y, **kwargs):
         """Run the grid search.
@@ -177,7 +182,7 @@ class GridSearch(BaseEstimator, MetaEstimatorMixin):
                 current_estimator = copy.deepcopy(self.estimator)
 
             oracle_call_start_time = time()
-            current_estimator.fit(X, y_reduction, sample_weight=weights)
+            current_estimator.fit(X, y_reduction, **{self.sample_weight_name: weights})
             oracle_call_execution_time = time() - oracle_call_start_time
             logger.debug("Call to estimator complete")
 


Test patch:
diff --git a/test/unit/reductions/exponentiated_gradient/test_exponentiatedgradient_arguments.py b/test/unit/reductions/exponentiated_gradient/test_exponentiatedgradient_arguments.py
index 2b1a930..6641fe6 100644
--- a/test/unit/reductions/exponentiated_gradient/test_exponentiatedgradient_arguments.py
+++ b/test/unit/reductions/exponentiated_gradient/test_exponentiatedgradient_arguments.py
@@ -5,6 +5,8 @@ import numpy as np
 import pandas as pd
 import pytest
 from sklearn.linear_model import LogisticRegression
+from sklearn.pipeline import Pipeline
+from sklearn.preprocessing import StandardScaler
 
 from fairlearn.reductions import ExponentiatedGradient
 from fairlearn.reductions import DemographicParity
@@ -161,3 +163,24 @@ class TestExponentiatedGradientArguments:
         with pytest.raises(ValueError) as execInfo:
             expgrad.fit(X, y, sensitive_features=(A))
         assert _LABELS_NOT_0_1_ERROR_MESSAGE == execInfo.value.args[0]
+
+    def test_sample_weights_argument(self):
+        estimator = Pipeline(
+            [('scaler', StandardScaler()),
+             ('logistic', LogisticRegression(solver='liblinear'))])
+
+        X, y, A = _get_data()
+
+        expgrad = ExponentiatedGradient(estimator,
+                                        constraints=DemographicParity(),
+                                        max_iter=1)
+
+        with pytest.raises(ValueError) as execInfo:
+            expgrad.fit(X, y, sensitive_features=(A))
+        assert 'Pipeline.fit does not accept the sample_weight parameter' in execInfo.value.args[0]
+
+        expgrad = ExponentiatedGradient(estimator,
+                                        constraints=DemographicParity(),
+                                        max_iter=1,
+                                        sample_weight_name='logistic__sample_weight')
+        expgrad.fit(X, y, sensitive_features=(A))
diff --git a/test/unit/reductions/grid_search/test_grid_search_arguments.py b/test/unit/reductions/grid_search/test_grid_search_arguments.py
index ac918dd..7e52dd3 100644
--- a/test/unit/reductions/grid_search/test_grid_search_arguments.py
+++ b/test/unit/reductions/grid_search/test_grid_search_arguments.py
@@ -9,6 +9,9 @@ from sklearn.linear_model import LogisticRegression, LinearRegression
 
 
 from sklearn.exceptions import NotFittedError
+from sklearn.pipeline import Pipeline
+from sklearn.preprocessing import StandardScaler
+
 from fairlearn._input_validation import \
     (_MESSAGE_Y_NONE,
      _LABELS_NOT_0_1_ERROR_MESSAGE)
@@ -46,7 +49,8 @@ class ArgumentTests:
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_valid_inputs(self, transformX, transformY, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=2)
+        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=2,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, A = _quick_data(A_two_dim)
         gs.fit(transformX(X),
                transformY(Y),
@@ -60,7 +64,8 @@ class ArgumentTests:
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_X_is_None(self, transformY, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=3)
+        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=3,
+                        sample_weight_name=self.sample_weight_name)
         _, Y, A = _quick_data(A_two_dim)
 
         with pytest.raises(ValueError) as execInfo:
@@ -75,7 +80,8 @@ class ArgumentTests:
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_Y_is_None(self, transformX, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, _, A = _quick_data()
 
         with pytest.raises(ValueError) as execInfo:
@@ -93,7 +99,8 @@ class ArgumentTests:
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_X_Y_different_rows(self, transformX, transformY, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, _, A = _quick_data()
         Y = np.random.randint(2, size=len(A)+1)
 
@@ -111,7 +118,8 @@ class ArgumentTests:
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_X_A_different_rows(self, transformX, transformY, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, _ = _quick_data(A_two_dim)
         A = np.random.randint(2, size=len(Y)+1)
         if A_two_dim:
@@ -137,7 +145,8 @@ class ArgumentTests:
         # The purpose of this test case is to create enough groups to trigger certain expected
         # warnings. The scenario should still work and succeed.
         grid_size = 10
-        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=grid_size)
+        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=grid_size,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, A = _quick_data(A_two_dim)
 
         if A_two_dim:
@@ -198,7 +207,8 @@ class ArgumentTests:
                         'dimensionality.')
 
         grid_size = 10
-        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=grid_size)
+        gs = GridSearch(self.estimator, self.disparity_criterion, grid_size=grid_size,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, A = _quick_data(A_two_dim, n_groups=n_groups)
 
         caplog.set_level(logging.WARNING)
@@ -228,7 +238,8 @@ class ArgumentTests:
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_Y_df_bad_columns(self, transformX, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, A = _quick_data(A_two_dim)
 
         Y_two_col_df = pd.DataFrame({"a": Y, "b": Y})
@@ -243,7 +254,8 @@ class ArgumentTests:
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_Y_ndarray_bad_columns(self, transformX, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, A = _quick_data(A_two_dim)
 
         Y_two_col_ndarray = np.stack((Y, Y), -1)
@@ -256,7 +268,8 @@ class ArgumentTests:
     # ----------------------------
 
     def test_no_predict_before_fit(self):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, _, _ = _quick_data()
 
         with pytest.raises(NotFittedError) as execInfo:
@@ -265,7 +278,8 @@ class ArgumentTests:
         assert not_fitted_error_msg.format(GridSearch.__name__) == execInfo.value.args[0]
 
     def test_no_predict_proba_before_fit(self):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, _, _ = _quick_data()
 
         with pytest.raises(NotFittedError) as execInfo:
@@ -282,7 +296,8 @@ class ConditionalOpportunityTests(ArgumentTests):
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_Y_ternary(self, transformX, transformY, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, A = _quick_data(A_two_dim)
         Y[0] = 0
         Y[1] = 1
@@ -301,7 +316,8 @@ class ConditionalOpportunityTests(ArgumentTests):
     @pytest.mark.parametrize("A_two_dim", [False, True])
     @pytest.mark.uncollect_if(func=is_invalid_transformation)
     def test_Y_not_0_1(self, transformX, transformY, transformA, A_two_dim):
-        gs = GridSearch(self.estimator, self.disparity_criterion)
+        gs = GridSearch(self.estimator, self.disparity_criterion,
+                        sample_weight_name=self.sample_weight_name)
         X, Y, A = _quick_data(A_two_dim)
         Y = Y + 1
 
@@ -313,11 +329,21 @@ class ConditionalOpportunityTests(ArgumentTests):
         assert _LABELS_NOT_0_1_ERROR_MESSAGE == execInfo.value.args[0]
 
 
+# Set up Pipeline estimator
+class TestPipelineEstimator(ConditionalOpportunityTests):
+    def setup_method(self, method):
+        self.estimator = Pipeline([('scaler', StandardScaler()),
+                                   ('logistic', LogisticRegression(solver='liblinear'))])
+        self.disparity_criterion = DemographicParity()
+        self.sample_weight_name = 'logistic__sample_weight'
+
+
 # Set up DemographicParity
 class TestDemographicParity(ConditionalOpportunityTests):
     def setup_method(self, method):
         self.estimator = LogisticRegression(solver='liblinear')
         self.disparity_criterion = DemographicParity()
+        self.sample_weight_name = 'sample_weight'
 
 
 # Test EqualizedOdds
@@ -325,6 +351,7 @@ class TestEqualizedOdds(ConditionalOpportunityTests):
     def setup_method(self, method):
         self.estimator = LogisticRegression(solver='liblinear')
         self.disparity_criterion = EqualizedOdds()
+        self.sample_weight_name = 'sample_weight'
 
 
 # Tests specific to BoundedGroupLoss
@@ -333,3 +360,4 @@ class TestBoundedGroupLoss(ArgumentTests):
         self.estimator = LinearRegression()
         eps = 0.01
         self.disparity_criterion = BoundedGroupLoss(ZeroOneLoss(), upper_bound=eps)
+        self.sample_weight_name = 'sample_weight'
diff --git a/test/unit/test_random_state.py b/test/unit/test_random_state.py
new file mode 100644
index 0000000..32c98e1
--- /dev/null
+++ b/test/unit/test_random_state.py
@@ -0,0 +1,75 @@
+# Copyright (c) Microsoft Corporation and Fairlearn contributors.
+# Licensed under the MIT License.
+
+import pandas as pd
+import numpy as np
+from sklearn.datasets import fetch_openml
+from sklearn.model_selection import train_test_split
+from sklearn.linear_model import LogisticRegression
+from fairlearn.postprocessing import ThresholdOptimizer
+from fairlearn.reductions import ExponentiatedGradient, EqualizedOdds
+
+
+def test_random_state_threshold_optimizer():
+    """Test that the random_state argument works as expected.
+
+    This test case reproduces the problem reported in issue 588 if the
+    random_state does not work as intended within ThresholdOptimizer.
+    https://github.com/fairlearn/fairlearn/issues/588
+    """
+    X_train, X_test, y_train, y_test, race_train, race_test = _get_test_data()
+
+    # Train a simple logistic regression model
+    lr = LogisticRegression(max_iter=1000, random_state=0)
+    lr.fit(X_train, y_train)
+
+    # Train threshold optimizer
+    to = ThresholdOptimizer(estimator=lr, constraints='equalized_odds', grid_size=1000)
+    to.fit(X_train, y_train, sensitive_features=race_train)
+
+    # score groups
+    y_pred_test = to.predict(X_test, sensitive_features=race_test, random_state=0)
+    for _ in range(100):
+        assert (y_pred_test ==
+                to.predict(X_test, sensitive_features=race_test, random_state=0)).all()
+    assert (y_pred_test != to.predict(X_test, sensitive_features=race_test, random_state=1)).any()
+
+
+def test_random_state_exponentiated_gradient():
+    """Test that the random_state argument works as expected.
+
+    This test case reproduces the problem reported in issue 588 if the
+    random_state does not work as intended within Exponentiated Gradient.
+    https://github.com/fairlearn/fairlearn/issues/588
+    """
+    X_train, X_test, y_train, y_test, race_train, race_test = _get_test_data()
+
+    # Train a simple logistic regression model
+    lr = LogisticRegression(max_iter=1000, random_state=0)
+    lr.fit(X_train, y_train)
+
+    # Train threshold optimizer
+    expgrad = ExponentiatedGradient(estimator=lr, constraints=EqualizedOdds())
+    expgrad.fit(X_train, y_train, sensitive_features=race_train)
+
+    # score groups
+    y_pred_test = expgrad.predict(X_test, random_state=0)
+    for _ in range(100):
+        assert (y_pred_test ==
+                expgrad.predict(X_test, random_state=0)).all()
+    assert (y_pred_test !=
+            expgrad.predict(X_test, random_state=1)).any()
+
+
+def _get_test_data():
+    # fetch data from OpenML
+    data = fetch_openml(data_id=42193)
+    X = pd.DataFrame(data['data'], columns=data['feature_names']) \
+        .drop(columns=['race_Caucasian', 'c_charge_degree_F'])
+    y = data['target'].astype(np.int)
+
+    # split the data in train-validation-test sets
+    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20, random_state=0)
+    race_train = X_train['race_African-American']
+    race_test = X_test['race_African-American']
+    return X_train, X_test, y_train, y_test, race_train, race_test