From 82c280aeadb0ecd872d9327438eb82f64688cb68 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Tue, 7 Sep 2021 19:10:56 +0200 Subject: [PATCH 1/9] FIX change the meaning of include_boundaries in check_scalar (#20921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérémie du Boisberranger <34657725+jeremiedbb@users.noreply.github.com> Co-authored-by: Olivier Grisel --- sklearn/cluster/_affinity_propagation.py | 4 +-- sklearn/utils/tests/test_validation.py | 28 +++++++++++++------ sklearn/utils/validation.py | 35 ++++++++++++++++-------- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/sklearn/cluster/_affinity_propagation.py b/sklearn/cluster/_affinity_propagation.py index 85f86b4d5e497..c400f5ba57685 100644 --- a/sklearn/cluster/_affinity_propagation.py +++ b/sklearn/cluster/_affinity_propagation.py @@ -272,7 +272,7 @@ class AffinityPropagation(ClusterMixin, BaseEstimator): Parameters ---------- damping : float, default=0.5 - Damping factor (between 0.5 and 1) is the extent to + Damping factor in the range `[0.5, 1.0)` is the extent to which the current value is maintained relative to incoming values (weighted 1 - damping). This in order to avoid numerical oscillations when updating these @@ -469,7 +469,7 @@ def fit(self, X, y=None): target_type=numbers.Real, min_val=0.5, max_val=1, - closed="right", + include_boundaries="left", ) check_scalar(self.max_iter, "max_iter", target_type=numbers.Integral, min_val=1) check_scalar( diff --git a/sklearn/utils/tests/test_validation.py b/sklearn/utils/tests/test_validation.py index 156790545d7b3..d1409d6129812 100644 --- a/sklearn/utils/tests/test_validation.py +++ b/sklearn/utils/tests/test_validation.py @@ -1032,14 +1032,14 @@ def test_check_scalar_valid(x): target_type=numbers.Real, min_val=2, max_val=5, - closed="neither", + include_boundaries="both", ) assert len(record) == 0 assert scalar == x @pytest.mark.parametrize( - "x, target_name, target_type, min_val, max_val, closed, err_msg", + "x, target_name, target_type, min_val, max_val, include_boundaries, err_msg", [ ( 1, @@ -1059,7 +1059,7 @@ def test_check_scalar_valid(x): 2, 4, "neither", - ValueError("test_name2 == 1, must be >= 2."), + ValueError("test_name2 == 1, must be > 2."), ), ( 5, @@ -1068,7 +1068,7 @@ def test_check_scalar_valid(x): 2, 4, "neither", - ValueError("test_name3 == 5, must be <= 4."), + ValueError("test_name3 == 5, must be < 4."), ), ( 2, @@ -1076,7 +1076,7 @@ def test_check_scalar_valid(x): int, 2, 4, - "left", + "right", ValueError("test_name4 == 2, must be > 2."), ), ( @@ -1085,13 +1085,25 @@ def test_check_scalar_valid(x): int, 2, 4, - "right", + "left", ValueError("test_name5 == 4, must be < 4."), ), + ( + 4, + "test_name6", + int, + 2, + 4, + "bad parameter value", + ValueError( + "Unknown value for `include_boundaries`: 'bad parameter value'. " + "Possible values are: ('left', 'right', 'both', 'neither')." + ), + ), ], ) def test_check_scalar_invalid( - x, target_name, target_type, min_val, max_val, closed, err_msg + x, target_name, target_type, min_val, max_val, include_boundaries, err_msg ): """Test that check_scalar returns the right error if a wrong input is given""" @@ -1102,7 +1114,7 @@ def test_check_scalar_invalid( target_type=target_type, min_val=min_val, max_val=max_val, - closed=closed, + include_boundaries=include_boundaries, ) assert str(raised_error.value) == str(err_msg) assert type(raised_error.value) == type(err_msg) diff --git a/sklearn/utils/validation.py b/sklearn/utils/validation.py index f2b77d012351a..87f957b931073 100644 --- a/sklearn/utils/validation.py +++ b/sklearn/utils/validation.py @@ -1242,7 +1242,7 @@ def check_scalar( *, min_val=None, max_val=None, - closed="neither", + include_boundaries="both", ): """Validate scalar parameters type and value. @@ -1265,9 +1265,15 @@ def check_scalar( The maximum valid value the parameter can take. If None (default) it is implied that the parameter does not have an upper bound. - closed : {"left", "right", "both", "neither"}, default="neither" - Whether the interval is closed on the left-side, right-side, both or - neither. + include_boundaries : {"left", "right", "both", "neither"}, default="both" + Whether the interval defined by `min_val` and `max_val` should include + the boundaries. Possible choices are: + + - `"left"`: only `min_val` is included in the valid interval; + - `"right"`: only `max_val` is included in the valid interval; + - `"both"`: `min_val` and `max_val` are included in the valid interval; + - `"neither"`: neither `min_val` nor `max_val` are included in the + valid interval. Returns ------- @@ -1286,22 +1292,29 @@ def check_scalar( if not isinstance(x, target_type): raise TypeError(f"{name} must be an instance of {target_type}, not {type(x)}.") - expected_closed = {"left", "right", "both", "neither"} - if closed not in expected_closed: - raise ValueError(f"Unknown value for `closed`: {closed}") + expected_include_boundaries = ("left", "right", "both", "neither") + if include_boundaries not in expected_include_boundaries: + raise ValueError( + f"Unknown value for `include_boundaries`: {repr(include_boundaries)}. " + f"Possible values are: {expected_include_boundaries}." + ) - comparison_operator = operator.le if closed in ("left", "both") else operator.lt + comparison_operator = ( + operator.lt if include_boundaries in ("left", "both") else operator.le + ) if min_val is not None and comparison_operator(x, min_val): raise ValueError( f"{name} == {x}, must be" - f" {'>' if closed in ('left', 'both') else '>='} {min_val}." + f" {'>=' if include_boundaries in ('left', 'both') else '>'} {min_val}." ) - comparison_operator = operator.ge if closed in ("right", "both") else operator.gt + comparison_operator = ( + operator.gt if include_boundaries in ("right", "both") else operator.ge + ) if max_val is not None and comparison_operator(x, max_val): raise ValueError( f"{name} == {x}, must be" - f" {'<' if closed in ('right', 'both') else '<='} {max_val}." + f" {'<=' if include_boundaries in ('right', 'both') else '<'} {max_val}." ) return x From fc7425a00443d5480912ad26971b49b71afa049a Mon Sep 17 00:00:00 2001 From: Nicolas Hug Date: Tue, 7 Sep 2021 15:53:41 +0100 Subject: [PATCH 2/9] DOC Add whatsnew entry for 20056 (#20966) --- doc/whats_new/v1.0.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/whats_new/v1.0.rst b/doc/whats_new/v1.0.rst index 94fdca7033fd9..7e24991cb3df6 100644 --- a/doc/whats_new/v1.0.rst +++ b/doc/whats_new/v1.0.rst @@ -703,6 +703,9 @@ Changelog - |Enhancement| warn only once in the main process for per-split fit failures in cross-validation. :pr:`20619` by :user:`Loïc Estève ` +- |Enhancement| The :class:`model_selection.BaseShuffleSplit` base class is + now public. :pr:`20056` by :user:`pabloduque0`. + - |Fix| Avoid premature overflow in :func:`model_selection.train_test_split`. :pr:`20904` by :user:`Tomasz Jakubek `. From b9ee87e2a74f5be788e32a6b1f2bfc64ae85d803 Mon Sep 17 00:00:00 2001 From: Olivier Grisel Date: Tue, 7 Sep 2021 19:24:46 +0200 Subject: [PATCH 3/9] Fix the stalled linux/arm64 [cd build] jobs on travis (#20958) --- .travis.yml | 9 +++++---- build_tools/travis/install.sh | 6 ++---- build_tools/travis/install_wheels.sh | 6 ++---- build_tools/travis/script.sh | 2 -- build_tools/travis/test_wheels.sh | 8 ++++---- 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.travis.yml b/.travis.yml index 328940a165fee..456d94301d4c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,15 +18,16 @@ env: - OPENBLAS_NUM_THREADS=2 - SKLEARN_BUILD_PARALLEL=3 - SKLEARN_SKIP_NETWORK_TESTS=1 + - PYTHONUNBUFFERED=1 # Custom environment variables for the ARM wheel builder - CIBW_BUILD_VERBOSITY=1 - - CIBW_TEST_REQUIRES="pytest pytest-xdist threadpoolctl" - CIBW_TEST_COMMAND="bash {project}/build_tools/travis/test_wheels.sh" - - CIBW_ENVIRONMENT="CPU_COUNT=2 + - CIBW_ENVIRONMENT="CPU_COUNT=6 OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2 - SKLEARN_BUILD_PARALLEL=3 - SKLEARN_SKIP_NETWORK_TESTS=1" + SKLEARN_BUILD_PARALLEL=10 + SKLEARN_SKIP_NETWORK_TESTS=1 + PYTHONUNBUFFERED=1" jobs: include: diff --git a/build_tools/travis/install.sh b/build_tools/travis/install.sh index 1e8e2963711ef..178260c8dabcb 100644 --- a/build_tools/travis/install.sh +++ b/build_tools/travis/install.sh @@ -4,10 +4,8 @@ # defined in the ".travis.yml" file. In particular, it is # important that we call to the right installation script. -set -e - if [[ $BUILD_WHEEL == true ]]; then - source build_tools/travis/install_wheels.sh + source build_tools/travis/install_wheels.sh || travis_terminate 1 else - source build_tools/travis/install_main.sh + source build_tools/travis/install_main.sh || travis_terminate 1 fi diff --git a/build_tools/travis/install_wheels.sh b/build_tools/travis/install_wheels.sh index 4bb52f51f27f7..0f6cdf256e71b 100644 --- a/build_tools/travis/install_wheels.sh +++ b/build_tools/travis/install_wheels.sh @@ -1,6 +1,4 @@ #!/bin/bash -set -e - -python -m pip install cibuildwheel -python -m cibuildwheel --output-dir wheelhouse +python -m pip install cibuildwheel || travis_terminate $? +python -m cibuildwheel --output-dir wheelhouse || travis_terminate $? diff --git a/build_tools/travis/script.sh b/build_tools/travis/script.sh index 2b7aecb295d82..6e8b7e3deaee1 100644 --- a/build_tools/travis/script.sh +++ b/build_tools/travis/script.sh @@ -5,8 +5,6 @@ # continuous deployment jobs, we have to execute the scripts for # testing the continuous integration jobs. -set -e - if [[ $BUILD_WHEEL != true ]]; then # This trick will make Travis terminate the continuation of the pipeline bash build_tools/travis/test_script.sh || travis_terminate 1 diff --git a/build_tools/travis/test_wheels.sh b/build_tools/travis/test_wheels.sh index be2328e3d44d6..aa3d0d8c0ef1b 100644 --- a/build_tools/travis/test_wheels.sh +++ b/build_tools/travis/test_wheels.sh @@ -1,9 +1,9 @@ #!/bin/bash -set -e +pip install --upgrade pip || travis_terminate $? +pip install pytest pytest-xdist || travis_terminate $? -# Faster run of the source code tests -pytest -n $CPU_COUNT --pyargs sklearn +python -m pytest -n $CPU_COUNT --pyargs sklearn || travis_terminate $? # Test that there are no links to system libraries -python -m threadpoolctl -i sklearn +python -m threadpoolctl -i sklearn || travis_terminate $? From 635674703455dd359d76f48dde36925e9121f675 Mon Sep 17 00:00:00 2001 From: "Thomas J. Fan" Date: Wed, 8 Sep 2021 05:01:24 -0400 Subject: [PATCH 4/9] BUG Fixes FunctionTransformer validation in inverse_transform (#20961) --- doc/whats_new/v1.0.rst | 3 +++ .../preprocessing/_function_transformer.py | 27 ++++++++++++++----- .../tests/test_function_transformer.py | 24 +++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/doc/whats_new/v1.0.rst b/doc/whats_new/v1.0.rst index 7e24991cb3df6..cff05dbd8b79e 100644 --- a/doc/whats_new/v1.0.rst +++ b/doc/whats_new/v1.0.rst @@ -824,6 +824,9 @@ Changelog `n_features_in_` and will be removed in 1.2. :pr:`20240` by :user:`Jérémie du Boisberranger `. +- |Fix| :class:`preprocessing.FunctionTransformer` does not set `n_features_in_` + based on the input to `inverse_transform`. :pr:`20961` by `Thomas Fan`_. + :mod:`sklearn.svm` ................... diff --git a/sklearn/preprocessing/_function_transformer.py b/sklearn/preprocessing/_function_transformer.py index 20ee90f5f253f..d975f63e32fe2 100644 --- a/sklearn/preprocessing/_function_transformer.py +++ b/sklearn/preprocessing/_function_transformer.py @@ -1,7 +1,7 @@ import warnings from ..base import BaseEstimator, TransformerMixin -from ..utils.validation import _allclose_dense_sparse +from ..utils.validation import _allclose_dense_sparse, check_array def _identity(X): @@ -71,6 +71,20 @@ class FunctionTransformer(TransformerMixin, BaseEstimator): .. versionadded:: 0.18 + Attributes + ---------- + n_features_in_ : int + Number of features seen during :term:`fit`. Defined only when + `validate=True`. + + .. versionadded:: 0.24 + + feature_names_in_ : ndarray of shape (`n_features_in_`,) + Names of features seen during :term:`fit`. Defined only when `validate=True` + and `X` has feature names that are all strings. + + .. versionadded:: 1.0 + See Also -------- MaxAbsScaler : Scale each feature by its maximum absolute value. @@ -110,9 +124,9 @@ def __init__( self.kw_args = kw_args self.inv_kw_args = inv_kw_args - def _check_input(self, X): + def _check_input(self, X, *, reset): if self.validate: - return self._validate_data(X, accept_sparse=self.accept_sparse) + return self._validate_data(X, accept_sparse=self.accept_sparse, reset=reset) return X def _check_inverse_transform(self, X): @@ -146,7 +160,7 @@ def fit(self, X, y=None): self : object FunctionTransformer class instance. """ - X = self._check_input(X) + X = self._check_input(X, reset=True) if self.check_inverse and not (self.func is None or self.inverse_func is None): self._check_inverse_transform(X) return self @@ -164,6 +178,7 @@ def transform(self, X): X_out : array-like, shape (n_samples, n_features) Transformed input. """ + X = self._check_input(X, reset=False) return self._transform(X, func=self.func, kw_args=self.kw_args) def inverse_transform(self, X): @@ -179,11 +194,11 @@ def inverse_transform(self, X): X_out : array-like, shape (n_samples, n_features) Transformed input. """ + if self.validate: + X = check_array(X, accept_sparse=self.accept_sparse) return self._transform(X, func=self.inverse_func, kw_args=self.inv_kw_args) def _transform(self, X, func=None, kw_args=None): - X = self._check_input(X) - if func is None: func = _identity diff --git a/sklearn/preprocessing/tests/test_function_transformer.py b/sklearn/preprocessing/tests/test_function_transformer.py index b3e517ac0c36c..b1ba9ebe6b762 100644 --- a/sklearn/preprocessing/tests/test_function_transformer.py +++ b/sklearn/preprocessing/tests/test_function_transformer.py @@ -174,3 +174,27 @@ def test_function_transformer_frame(): transformer = FunctionTransformer() X_df_trans = transformer.fit_transform(X_df) assert hasattr(X_df_trans, "loc") + + +def test_function_transformer_validate_inverse(): + """Test that function transformer does not reset estimator in + `inverse_transform`.""" + + def add_constant_feature(X): + X_one = np.ones((X.shape[0], 1)) + return np.concatenate((X, X_one), axis=1) + + def inverse_add_constant(X): + return X[:, :-1] + + X = np.array([[1, 2], [3, 4], [3, 4]]) + trans = FunctionTransformer( + func=add_constant_feature, + inverse_func=inverse_add_constant, + validate=True, + ) + X_trans = trans.fit_transform(X) + assert trans.n_features_in_ == X.shape[1] + + trans.inverse_transform(X_trans) + assert trans.n_features_in_ == X.shape[1] From c070b88702cf433fd123b95a046b178deec21bc0 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Wed, 8 Sep 2021 13:17:49 +0200 Subject: [PATCH 5/9] DOC fix mispelling in whats new (#20974) --- doc/whats_new/v1.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/whats_new/v1.0.rst b/doc/whats_new/v1.0.rst index cff05dbd8b79e..575875503ba36 100644 --- a/doc/whats_new/v1.0.rst +++ b/doc/whats_new/v1.0.rst @@ -901,7 +901,7 @@ Changelog warning was previously raised in resampling utilities and functions using those utilities (e.g. :func:`model_selection.train_test_split`, :func:`model_selection.cross_validate`, - :func:`model_seleection.cross_val_score`, + :func:`model_selection.cross_val_score`, :func:`model_selection.cross_val_predict`). :pr:`20673` by :user:`Joris Van den Bossche `. From 559a9934d0c2824e55e61ed9fb89ce321ae3387c Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 9 Sep 2021 15:10:14 +0200 Subject: [PATCH 6/9] TST solve issues in PyPy (#20978) * TST fix brittle test in pypy for error message * iter * Update test_sgd.py * Update test_sgd.py * remove pypy3 in PRs --- .circleci/config.yml | 5 ----- sklearn/linear_model/tests/test_sgd.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b730ae0ff595a..c28fefa4e96e2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -187,11 +187,6 @@ workflows: - doc-min-dependencies: requires: - lint - - pypy3: - filters: - branches: - only: - - 0.20.X - deploy: requires: - doc diff --git a/sklearn/linear_model/tests/test_sgd.py b/sklearn/linear_model/tests/test_sgd.py index 7171c860254ff..9b4c443ac1993 100644 --- a/sklearn/linear_model/tests/test_sgd.py +++ b/sklearn/linear_model/tests/test_sgd.py @@ -254,7 +254,7 @@ def test_sgd_estimator_params_validation(klass, fit_method, params, err_msg): try: sgd_estimator = klass(**params) except TypeError as err: - if "__init__() got an unexpected keyword argument" in str(err): + if "unexpected keyword argument" in str(err): # skip test if the parameter is not supported by the estimator return raise err From 508cf71b06722892fa959997df601ea75100bc7c Mon Sep 17 00:00:00 2001 From: "Thomas J. Fan" Date: Thu, 9 Sep 2021 08:25:59 -0400 Subject: [PATCH 7/9] API Deprecates plot_partial_dependence (#20959) * API Deprecates plot_partial_dependence * DOC Adds whats new * DOC Fixes doc build errors * CLN Removes feature warning * CLN Address comments * DOC Fix docstrings * TST Skip future warning tests * DOC Adjust docstrings * REV Reduce diff * DOC Adds docstring --- doc/developers/plotting.rst | 8 +- doc/modules/partial_dependence.rst | 16 +- doc/whats_new/v1.0.rst | 5 + .../ensemble/plot_monotonic_constraints.py | 6 +- .../inspection/plot_partial_dependence.py | 8 +- ...ot_partial_dependence_visualization_api.py | 23 +- sklearn/inspection/_partial_dependence.py | 2 +- .../inspection/_plot/partial_dependence.py | 345 +++++++++++++++++- .../tests/test_plot_partial_dependence.py | 75 +++- sklearn/utils/__init__.py | 2 +- 10 files changed, 433 insertions(+), 57 deletions(-) diff --git a/doc/developers/plotting.rst b/doc/developers/plotting.rst index f3cecd3fec415..b0e8b3b43ee45 100644 --- a/doc/developers/plotting.rst +++ b/doc/developers/plotting.rst @@ -64,8 +64,8 @@ Plotting with Multiple Axes --------------------------- Some of the plotting tools like -:func:`~sklearn.inspection.plot_partial_dependence` and -:class:`~sklearn.inspection.PartialDependenceDisplay` support plottong on +:func:`~sklearn.inspection.PartialDependenceDisplay.from_estimator` and +:class:`~sklearn.inspection.PartialDependenceDisplay` support plotting on multiple axes. Two different scenarios are supported: 1. If a list of axes is passed in, `plot` will check if the number of axes is @@ -87,8 +87,8 @@ be placed. In this case, we suggest using matplotlib's By default, the `ax` keyword in `plot` is `None`. In this case, the single axes is created and the gridspec api is used to create the regions to plot in. -See for example, :func:`~sklearn.inspection.plot_partial_dependence` which -plots multiple lines and contours using this API. The axes defining the +See for example, :func:`~sklearn.inspection.PartialDependenceDisplay.from_estimator +which plots multiple lines and contours using this API. The axes defining the bounding box is saved in a `bounding_ax_` attribute. The individual axes created are stored in an `axes_` ndarray, corresponding to the axes position on the grid. Positions that are not used are set to `None`. Furthermore, the diff --git a/doc/modules/partial_dependence.rst b/doc/modules/partial_dependence.rst index d2ed83c4e3fd8..8fc97f34e7490 100644 --- a/doc/modules/partial_dependence.rst +++ b/doc/modules/partial_dependence.rst @@ -55,20 +55,20 @@ independent of the house age, whereas for values less than 2 there is a strong dependence on age. The :mod:`sklearn.inspection` module provides a convenience function -:func:`plot_partial_dependence` to create one-way and two-way partial +:func:`~PartialDependenceDisplay.from_estimator` to create one-way and two-way partial dependence plots. In the below example we show how to create a grid of partial dependence plots: two one-way PDPs for the features ``0`` and ``1`` and a two-way PDP between the two features:: >>> from sklearn.datasets import make_hastie_10_2 >>> from sklearn.ensemble import GradientBoostingClassifier - >>> from sklearn.inspection import plot_partial_dependence + >>> from sklearn.inspection import PartialDependenceDisplay >>> X, y = make_hastie_10_2(random_state=0) >>> clf = GradientBoostingClassifier(n_estimators=100, learning_rate=1.0, ... max_depth=1, random_state=0).fit(X, y) >>> features = [0, 1, (0, 1)] - >>> plot_partial_dependence(clf, X, features) + >>> PartialDependenceDisplay.from_estimator(clf, X, features) <...> You can access the newly created figure and Axes objects using ``plt.gcf()`` @@ -82,7 +82,7 @@ the PDPs should be created via the ``target`` argument:: >>> mc_clf = GradientBoostingClassifier(n_estimators=10, ... max_depth=1).fit(iris.data, iris.target) >>> features = [3, 2, (3, 2)] - >>> plot_partial_dependence(mc_clf, X, features, target=0) + >>> PartialDependenceDisplay.from_estimator(mc_clf, X, features, target=0) <...> The same parameter ``target`` is used to specify the target in multi-output @@ -138,20 +138,20 @@ and the house price in the PD line. However, the ICE lines show that there are some exceptions, where the house price remains constant in some ranges of the median income. -The :mod:`sklearn.inspection` module's :func:`plot_partial_dependence` +The :mod:`sklearn.inspection` module's :meth:`PartialDependenceDisplay.from_estimator` convenience function can be used to create ICE plots by setting ``kind='individual'``. In the example below, we show how to create a grid of ICE plots: >>> from sklearn.datasets import make_hastie_10_2 >>> from sklearn.ensemble import GradientBoostingClassifier - >>> from sklearn.inspection import plot_partial_dependence + >>> from sklearn.inspection import PartialDependenceDisplay >>> X, y = make_hastie_10_2(random_state=0) >>> clf = GradientBoostingClassifier(n_estimators=100, learning_rate=1.0, ... max_depth=1, random_state=0).fit(X, y) >>> features = [0, 1] - >>> plot_partial_dependence(clf, X, features, + >>> PartialDependenceDisplay.from_estimator(clf, X, features, ... kind='individual') <...> @@ -160,7 +160,7 @@ feature of interest. Hence, it is recommended to use ICE plots alongside PDPs. They can be plotted together with ``kind='both'``. - >>> plot_partial_dependence(clf, X, features, + >>> PartialDependenceDisplay.from_estimator(clf, X, features, ... kind='both') <...> diff --git a/doc/whats_new/v1.0.rst b/doc/whats_new/v1.0.rst index 575875503ba36..c35d2a1a481c1 100644 --- a/doc/whats_new/v1.0.rst +++ b/doc/whats_new/v1.0.rst @@ -461,6 +461,11 @@ Changelog :func:`~sklearn.inspection.permutation_importance`. :pr:`19411` by :user:`Simona Maggio `. +- |API| :class:`inspection.PartialDependenceDisplay` exposes a class method: + :func:`~inspection.PartialDependenceDisplay.from_estimator`. + :func:`inspection.plot_partial_dependence` is deprecated in favor of the + class method and will be removed in 1.2. :pr:`20959` by `Thomas Fan`_. + :mod:`sklearn.kernel_approximation` ................................... diff --git a/examples/ensemble/plot_monotonic_constraints.py b/examples/ensemble/plot_monotonic_constraints.py index c173ef35cf311..6146a3bb72db1 100644 --- a/examples/ensemble/plot_monotonic_constraints.py +++ b/examples/ensemble/plot_monotonic_constraints.py @@ -19,7 +19,7 @@ `_. """ from sklearn.ensemble import HistGradientBoostingRegressor -from sklearn.inspection import plot_partial_dependence +from sklearn.inspection import PartialDependenceDisplay import numpy as np import matplotlib.pyplot as plt @@ -43,7 +43,7 @@ # Without any constraint gbdt = HistGradientBoostingRegressor() gbdt.fit(X, y) -disp = plot_partial_dependence( +disp = PartialDependenceDisplay.from_estimator( gbdt, X, features=[0, 1], @@ -55,7 +55,7 @@ gbdt = HistGradientBoostingRegressor(monotonic_cst=[1, -1]) gbdt.fit(X, y) -plot_partial_dependence( +PartialDependenceDisplay.from_estimator( gbdt, X, features=[0, 1], diff --git a/examples/inspection/plot_partial_dependence.py b/examples/inspection/plot_partial_dependence.py index ceccd8c3001c1..1a9b91c39b585 100644 --- a/examples/inspection/plot_partial_dependence.py +++ b/examples/inspection/plot_partial_dependence.py @@ -111,12 +111,12 @@ import matplotlib.pyplot as plt from sklearn.inspection import partial_dependence -from sklearn.inspection import plot_partial_dependence +from sklearn.inspection import PartialDependenceDisplay print("Computing partial dependence plots...") tic = time() features = ["MedInc", "AveOccup", "HouseAge", "AveRooms"] -display = plot_partial_dependence( +display = PartialDependenceDisplay.from_estimator( est, X_train, features, @@ -166,7 +166,7 @@ print("Computing partial dependence plots...") tic = time() -display = plot_partial_dependence( +display = PartialDependenceDisplay.from_estimator( est, X_train, features, @@ -229,7 +229,7 @@ print("Computing partial dependence plots...") tic = time() _, ax = plt.subplots(ncols=3, figsize=(9, 4)) -display = plot_partial_dependence( +display = PartialDependenceDisplay.from_estimator( est, X_train, features, diff --git a/examples/miscellaneous/plot_partial_dependence_visualization_api.py b/examples/miscellaneous/plot_partial_dependence_visualization_api.py index 84658c74ed213..a2219c4cb1c13 100644 --- a/examples/miscellaneous/plot_partial_dependence_visualization_api.py +++ b/examples/miscellaneous/plot_partial_dependence_visualization_api.py @@ -22,7 +22,7 @@ from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.tree import DecisionTreeRegressor -from sklearn.inspection import plot_partial_dependence +from sklearn.inspection import PartialDependenceDisplay # %% @@ -49,22 +49,22 @@ # # We plot partial dependence curves for features "age" and "bmi" (body mass # index) for the decision tree. With two features, -# :func:`~sklearn.inspection.plot_partial_dependence` expects to plot two -# curves. Here the plot function place a grid of two plots using the space +# :func:`~sklearn.inspection.PartialDependenceDisplay.from_estimator` expects to plot +# two curves. Here the plot function place a grid of two plots using the space # defined by `ax` . fig, ax = plt.subplots(figsize=(12, 6)) ax.set_title("Decision Tree") -tree_disp = plot_partial_dependence(tree, X, ["age", "bmi"], ax=ax) +tree_disp = PartialDependenceDisplay.from_estimator(tree, X, ["age", "bmi"], ax=ax) # %% # The partial depdendence curves can be plotted for the multi-layer perceptron. # In this case, `line_kw` is passed to -# :func:`~sklearn.inspection.plot_partial_dependence` to change the color of -# the curve. +# :func:`~sklearn.inspection.PartialDependenceDisplay.from_estimator` to change the +# color of the curve. fig, ax = plt.subplots(figsize=(12, 6)) ax.set_title("Multi-layer Perceptron") -mlp_disp = plot_partial_dependence(mlp, X, ["age", "bmi"], ax=ax, - line_kw={"color": "red"}) +mlp_disp = PartialDependenceDisplay.from_estimator(mlp, X, ["age", "bmi"], ax=ax, + line_kw={"color": "red"}) # %% # Plotting partial dependence of the two models together @@ -129,7 +129,6 @@ # Here, we plot the partial dependence curves for a single feature, "age", on # the same axes. In this case, `tree_disp.axes_` is passed into the second # plot function. -tree_disp = plot_partial_dependence(tree, X, ["age"]) -mlp_disp = plot_partial_dependence(mlp, X, ["age"], - ax=tree_disp.axes_, - line_kw={"color": "red"}) +tree_disp = PartialDependenceDisplay.from_estimator(tree, X, ["age"]) +mlp_disp = PartialDependenceDisplay.from_estimator( + mlp, X, ["age"], ax=tree_disp.axes_, line_kw={"color": "red"}) diff --git a/sklearn/inspection/_partial_dependence.py b/sklearn/inspection/_partial_dependence.py index b92f996fea9ba..6bf6677825c98 100644 --- a/sklearn/inspection/_partial_dependence.py +++ b/sklearn/inspection/_partial_dependence.py @@ -370,7 +370,7 @@ def partial_dependence( See Also -------- - plot_partial_dependence : Plot Partial Dependence. + PartialDependenceDisplay.from_estimator : Plot Partial Dependence. PartialDependenceDisplay : Partial Dependence visualization. Examples diff --git a/sklearn/inspection/_plot/partial_dependence.py b/sklearn/inspection/_plot/partial_dependence.py index f3495c60438b0..67d5c4e6273ca 100644 --- a/sklearn/inspection/_plot/partial_dependence.py +++ b/sklearn/inspection/_plot/partial_dependence.py @@ -10,6 +10,7 @@ from .. import partial_dependence from ...base import is_regressor from ...utils import check_array +from ...utils import deprecated from ...utils import check_matplotlib_support # noqa from ...utils import check_random_state from ...utils import _safe_indexing @@ -17,6 +18,10 @@ from ...utils.fixes import delayed +@deprecated( + "Function `plot_partial_dependence` is deprecated in 1.0 and will be " + "removed in 1.2. Use PartialDependenceDisplay.from_estimator instead" +) def plot_partial_dependence( estimator, X, @@ -68,9 +73,9 @@ def plot_partial_dependence( >>> est1 = LinearRegression().fit(X, y) >>> est2 = RandomForestRegressor().fit(X, y) >>> disp1 = plot_partial_dependence(est1, X, - ... [1, 2]) + ... [1, 2]) # doctest: +SKIP >>> disp2 = plot_partial_dependence(est2, X, [1, 2], - ... ax=disp1.axes_) + ... ax=disp1.axes_) # doctest: +SKIP .. warning:: @@ -89,6 +94,11 @@ def plot_partial_dependence( :class:`~sklearn.ensemble.HistGradientBoostingClassifier` and :class:`~sklearn.ensemble.HistGradientBoostingRegressor`. + .. deprecated:: 1.0 + `plot_partial_dependence` is deprecated in 1.0 and will be removed in + 1.2. Please use the class method: + :func:`~sklearn.metrics.PartialDependenceDisplay.from_estimator`. + Parameters ---------- estimator : BaseEstimator @@ -96,7 +106,7 @@ def plot_partial_dependence( :term:`predict_proba`, or :term:`decision_function`. Multioutput-multiclass classifiers are not supported. - X : {array-like or dataframe} of shape (n_samples, n_features) + X : {array-like, dataframe} of shape (n_samples, n_features) ``X`` is used to generate a grid of values for the target ``features`` (where the partial dependence will be evaluated), and also to generate values for the complement features when the @@ -261,6 +271,7 @@ def plot_partial_dependence( -------- partial_dependence : Compute Partial Dependence values. PartialDependenceDisplay : Partial Dependence visualization. + PartialDependenceDisplay.from_estimator : Plot Partial Dependence. Examples -------- @@ -270,11 +281,60 @@ def plot_partial_dependence( >>> from sklearn.inspection import plot_partial_dependence >>> X, y = make_friedman1() >>> clf = GradientBoostingRegressor(n_estimators=10).fit(X, y) - >>> plot_partial_dependence(clf, X, [0, (0, 1)]) + >>> plot_partial_dependence(clf, X, [0, (0, 1)]) # doctest: +SKIP <...> - >>> plt.show() + >>> plt.show() # doctest: +SKIP """ check_matplotlib_support("plot_partial_dependence") # noqa + return _plot_partial_dependence( + estimator, + X, + features, + feature_names=feature_names, + target=target, + response_method=response_method, + n_cols=n_cols, + grid_resolution=grid_resolution, + percentiles=percentiles, + method=method, + n_jobs=n_jobs, + verbose=verbose, + line_kw=line_kw, + ice_lines_kw=ice_lines_kw, + pd_line_kw=pd_line_kw, + contour_kw=contour_kw, + ax=ax, + kind=kind, + subsample=subsample, + random_state=random_state, + ) + + +# TODO: Move into PartialDependenceDisplay.from_estimator in 1.2 +def _plot_partial_dependence( + estimator, + X, + features, + *, + feature_names=None, + target=None, + response_method="auto", + n_cols=3, + grid_resolution=100, + percentiles=(0.05, 0.95), + method="auto", + n_jobs=None, + verbose=0, + line_kw=None, + ice_lines_kw=None, + pd_line_kw=None, + contour_kw=None, + ax=None, + kind="average", + subsample=1000, + random_state=None, +): + """See PartialDependenceDisplay.from_estimator for details""" import matplotlib.pyplot as plt # noqa # set target_idx for multi-class estimators @@ -452,7 +512,7 @@ class PartialDependenceDisplay: referred to as: Individual Condition Expectation (ICE). It is recommended to use - :func:`~sklearn.inspection.plot_partial_dependence` to create a + :func:`~sklearn.inspection.PartialDependenceDisplay.from_estimator` to create a :class:`~sklearn.inspection.PartialDependenceDisplay`. All parameters are stored as attributes. @@ -576,7 +636,7 @@ class PartialDependenceDisplay: See Also -------- partial_dependence : Compute Partial Dependence values. - plot_partial_dependence : Plot Partial Dependence. + PartialDependenceDisplay.from_estimator : Plot Partial Dependence. """ def __init__( @@ -602,6 +662,277 @@ def __init__( self.subsample = subsample self.random_state = random_state + @classmethod + def from_estimator( + cls, + estimator, + X, + features, + *, + feature_names=None, + target=None, + response_method="auto", + n_cols=3, + grid_resolution=100, + percentiles=(0.05, 0.95), + method="auto", + n_jobs=None, + verbose=0, + line_kw=None, + ice_lines_kw=None, + pd_line_kw=None, + contour_kw=None, + ax=None, + kind="average", + subsample=1000, + random_state=None, + ): + """Partial dependence (PD) and individual conditional expectation (ICE) plots. + + Partial dependence plots, individual conditional expectation plots or an + overlay of both of them can be plotted by setting the ``kind`` + parameter. The ``len(features)`` plots are arranged in a grid with + ``n_cols`` columns. Two-way partial dependence plots are plotted as + contour plots. The deciles of the feature values will be shown with tick + marks on the x-axes for one-way plots, and on both axes for two-way + plots. + + Read more in the :ref:`User Guide `. + + .. note:: + + :func:`PartialDependenceDisplay.from_estimator` does not support using the + same axes with multiple calls. To plot the the partial dependence for + multiple estimators, please pass the axes created by the first call to the + second call:: + + >>> from sklearn.inspection import PartialDependenceDisplay + >>> from sklearn.datasets import make_friedman1 + >>> from sklearn.linear_model import LinearRegression + >>> from sklearn.ensemble import RandomForestRegressor + >>> X, y = make_friedman1() + >>> est1 = LinearRegression().fit(X, y) + >>> est2 = RandomForestRegressor().fit(X, y) + >>> disp1 = PartialDependenceDisplay.from_estimator(est1, X, + ... [1, 2]) + >>> disp2 = PartialDependenceDisplay.from_estimator(est2, X, [1, 2], + ... ax=disp1.axes_) + + .. warning:: + + For :class:`~sklearn.ensemble.GradientBoostingClassifier` and + :class:`~sklearn.ensemble.GradientBoostingRegressor`, the + `'recursion'` method (used by default) will not account for the `init` + predictor of the boosting process. In practice, this will produce + the same values as `'brute'` up to a constant offset in the target + response, provided that `init` is a constant estimator (which is the + default). However, if `init` is not a constant estimator, the + partial dependence values are incorrect for `'recursion'` because the + offset will be sample-dependent. It is preferable to use the `'brute'` + method. Note that this only applies to + :class:`~sklearn.ensemble.GradientBoostingClassifier` and + :class:`~sklearn.ensemble.GradientBoostingRegressor`, not to + :class:`~sklearn.ensemble.HistGradientBoostingClassifier` and + :class:`~sklearn.ensemble.HistGradientBoostingRegressor`. + + .. versionadded:: 1.0 + + Parameters + ---------- + estimator : BaseEstimator + A fitted estimator object implementing :term:`predict`, + :term:`predict_proba`, or :term:`decision_function`. + Multioutput-multiclass classifiers are not supported. + + X : {array-like, dataframe} of shape (n_samples, n_features) + ``X`` is used to generate a grid of values for the target + ``features`` (where the partial dependence will be evaluated), and + also to generate values for the complement features when the + `method` is `'brute'`. + + features : list of {int, str, pair of int, pair of str} + The target features for which to create the PDPs. + If `features[i]` is an integer or a string, a one-way PDP is created; + if `features[i]` is a tuple, a two-way PDP is created (only supported + with `kind='average'`). Each tuple must be of size 2. + if any entry is a string, then it must be in ``feature_names``. + + feature_names : array-like of shape (n_features,), dtype=str, default=None + Name of each feature; `feature_names[i]` holds the name of the feature + with index `i`. + By default, the name of the feature corresponds to their numerical + index for NumPy array and their column name for pandas dataframe. + + target : int, default=None + - In a multiclass setting, specifies the class for which the PDPs + should be computed. Note that for binary classification, the + positive class (index 1) is always used. + - In a multioutput setting, specifies the task for which the PDPs + should be computed. + + Ignored in binary classification or classical regression settings. + + response_method : {'auto', 'predict_proba', 'decision_function'}, \ + default='auto' + Specifies whether to use :term:`predict_proba` or + :term:`decision_function` as the target response. For regressors + this parameter is ignored and the response is always the output of + :term:`predict`. By default, :term:`predict_proba` is tried first + and we revert to :term:`decision_function` if it doesn't exist. If + ``method`` is `'recursion'`, the response is always the output of + :term:`decision_function`. + + n_cols : int, default=3 + The maximum number of columns in the grid plot. Only active when `ax` + is a single axis or `None`. + + grid_resolution : int, default=100 + The number of equally spaced points on the axes of the plots, for each + target feature. + + percentiles : tuple of float, default=(0.05, 0.95) + The lower and upper percentile used to create the extreme values + for the PDP axes. Must be in [0, 1]. + + method : str, default='auto' + The method used to calculate the averaged predictions: + + - `'recursion'` is only supported for some tree-based estimators + (namely + :class:`~sklearn.ensemble.GradientBoostingClassifier`, + :class:`~sklearn.ensemble.GradientBoostingRegressor`, + :class:`~sklearn.ensemble.HistGradientBoostingClassifier`, + :class:`~sklearn.ensemble.HistGradientBoostingRegressor`, + :class:`~sklearn.tree.DecisionTreeRegressor`, + :class:`~sklearn.ensemble.RandomForestRegressor` + but is more efficient in terms of speed. + With this method, the target response of a + classifier is always the decision function, not the predicted + probabilities. Since the `'recursion'` method implicitely computes + the average of the ICEs by design, it is not compatible with ICE and + thus `kind` must be `'average'`. + + - `'brute'` is supported for any estimator, but is more + computationally intensive. + + - `'auto'`: the `'recursion'` is used for estimators that support it, + and `'brute'` is used otherwise. + + Please see :ref:`this note ` for + differences between the `'brute'` and `'recursion'` method. + + n_jobs : int, default=None + The number of CPUs to use to compute the partial dependences. + Computation is parallelized over features specified by the `features` + parameter. + + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. See :term:`Glossary ` + for more details. + + verbose : int, default=0 + Verbose output during PD computations. + + line_kw : dict, default=None + Dict with keywords passed to the ``matplotlib.pyplot.plot`` call. + For one-way partial dependence plots. It can be used to define common + properties for both `ice_lines_kw` and `pdp_line_kw`. + + ice_lines_kw : dict, default=None + Dictionary with keywords passed to the `matplotlib.pyplot.plot` call. + For ICE lines in the one-way partial dependence plots. + The key value pairs defined in `ice_lines_kw` takes priority over + `line_kw`. + + pd_line_kw : dict, default=None + Dictionary with keywords passed to the `matplotlib.pyplot.plot` call. + For partial dependence in one-way partial dependence plots. + The key value pairs defined in `pd_line_kw` takes priority over + `line_kw`. + + contour_kw : dict, default=None + Dict with keywords passed to the ``matplotlib.pyplot.contourf`` call. + For two-way partial dependence plots. + + ax : Matplotlib axes or array-like of Matplotlib axes, default=None + - If a single axis is passed in, it is treated as a bounding axes + and a grid of partial dependence plots will be drawn within + these bounds. The `n_cols` parameter controls the number of + columns in the grid. + - If an array-like of axes are passed in, the partial dependence + plots will be drawn directly into these axes. + - If `None`, a figure and a bounding axes is created and treated + as the single axes case. + + kind : {'average', 'individual', 'both'}, default='average' + Whether to plot the partial dependence averaged across all the samples + in the dataset or one line per sample or both. + + - ``kind='average'`` results in the traditional PD plot; + - ``kind='individual'`` results in the ICE plot. + + Note that the fast ``method='recursion'`` option is only available for + ``kind='average'``. Plotting individual dependencies requires using the + slower ``method='brute'`` option. + + subsample : float, int or None, default=1000 + Sampling for ICE curves when `kind` is 'individual' or 'both'. + If `float`, should be between 0.0 and 1.0 and represent the proportion + of the dataset to be used to plot ICE curves. If `int`, represents the + absolute number samples to use. + + Note that the full dataset is still used to calculate averaged partial + dependence when `kind='both'`. + + random_state : int, RandomState instance or None, default=None + Controls the randomness of the selected samples when subsamples is not + `None` and `kind` is either `'both'` or `'individual'`. + See :term:`Glossary ` for details. + + Returns + ------- + display : :class:`~sklearn.inspection.PartialDependenceDisplay` + + See Also + -------- + partial_dependence : Compute Partial Dependence values. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> from sklearn.datasets import make_friedman1 + >>> from sklearn.ensemble import GradientBoostingRegressor + >>> from sklearn.inspection import PartialDependenceDisplay + >>> X, y = make_friedman1() + >>> clf = GradientBoostingRegressor(n_estimators=10).fit(X, y) + >>> PartialDependenceDisplay.from_estimator(clf, X, [0, (0, 1)]) + <...> + >>> plt.show() + """ + check_matplotlib_support(f"{cls.__name__}.from_estimator") # noqa + return _plot_partial_dependence( + estimator, + X, + features, + feature_names=feature_names, + target=target, + response_method=response_method, + n_cols=n_cols, + grid_resolution=grid_resolution, + percentiles=percentiles, + method=method, + n_jobs=n_jobs, + verbose=verbose, + line_kw=line_kw, + ice_lines_kw=ice_lines_kw, + pd_line_kw=pd_line_kw, + contour_kw=contour_kw, + ax=ax, + kind=kind, + subsample=subsample, + random_state=random_state, + ) + def _get_sample_count(self, n_samples): """Compute the number of samples as an integer.""" if isinstance(self.subsample, numbers.Integral): diff --git a/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py b/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py index 4d33313c8c884..ed5e4388eef48 100644 --- a/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py +++ b/sklearn/inspection/_plot/tests/test_plot_partial_dependence.py @@ -12,16 +12,30 @@ from sklearn.linear_model import LinearRegression from sklearn.utils._testing import _convert_container -from sklearn.inspection import plot_partial_dependence +from sklearn.inspection import plot_partial_dependence as plot_partial_dependence_func +from sklearn.inspection import PartialDependenceDisplay # TODO: Remove when https://github.com/numpy/numpy/issues/14397 is resolved pytestmark = pytest.mark.filterwarnings( "ignore:In future, it will be an error for 'np.bool_':DeprecationWarning:" - "matplotlib.*" + "matplotlib.*", + # TODO: Remove in 1.2 and convert test to only use + # PartialDependenceDisplay.from_estimator + "ignore:Function plot_partial_dependence is deprecated", ) +# TODO: Remove in 1.2 and convert test to only use +# PartialDependenceDisplay.from_estimator +@pytest.fixture( + params=[PartialDependenceDisplay.from_estimator, plot_partial_dependence_func], + ids=["from_estimator", "function"], +) +def plot_partial_dependence(request): + return request.param + + @pytest.fixture(scope="module") def diabetes(): return load_diabetes() @@ -34,9 +48,17 @@ def clf_diabetes(diabetes): return clf +def test_plot_partial_dependence_deprecation(pyplot, clf_diabetes, diabetes): + """Check that plot_partial_dependence is deprecated""" + with pytest.warns(FutureWarning): + plot_partial_dependence_func(clf_diabetes, diabetes.data, [0]) + + @pytest.mark.filterwarnings("ignore:A Bunch will be returned") @pytest.mark.parametrize("grid_resolution", [10, 20]) -def test_plot_partial_dependence(grid_resolution, pyplot, clf_diabetes, diabetes): +def test_plot_partial_dependence( + plot_partial_dependence, grid_resolution, pyplot, clf_diabetes, diabetes +): # Test partial dependence plot function. # Use columns 0 & 2 as 1 is not quantitative (sex) feature_names = diabetes.feature_names @@ -123,7 +145,7 @@ def test_plot_partial_dependence(grid_resolution, pyplot, clf_diabetes, diabetes ], ) def test_plot_partial_dependence_kind( - pyplot, kind, subsample, shape, clf_diabetes, diabetes + plot_partial_dependence, pyplot, kind, subsample, shape, clf_diabetes, diabetes ): disp = plot_partial_dependence( clf_diabetes, diabetes.data, [0, 1, 2], kind=kind, subsample=subsample @@ -158,7 +180,12 @@ def test_plot_partial_dependence_kind( ], ) def test_plot_partial_dependence_str_features( - pyplot, clf_diabetes, diabetes, input_type, feature_names_type + plot_partial_dependence, + pyplot, + clf_diabetes, + diabetes, + input_type, + feature_names_type, ): if input_type == "dataframe": pd = pytest.importorskip("pandas") @@ -226,7 +253,9 @@ def test_plot_partial_dependence_str_features( @pytest.mark.filterwarnings("ignore:A Bunch will be returned") -def test_plot_partial_dependence_custom_axes(pyplot, clf_diabetes, diabetes): +def test_plot_partial_dependence_custom_axes( + plot_partial_dependence, pyplot, clf_diabetes, diabetes +): grid_resolution = 25 fig, (ax1, ax2) = pyplot.subplots(1, 2) disp = plot_partial_dependence( @@ -269,7 +298,7 @@ def test_plot_partial_dependence_custom_axes(pyplot, clf_diabetes, diabetes): "kind, lines", [("average", 1), ("individual", 442), ("both", 443)] ) def test_plot_partial_dependence_passing_numpy_axes( - pyplot, clf_diabetes, diabetes, kind, lines + plot_partial_dependence, pyplot, clf_diabetes, diabetes, kind, lines ): grid_resolution = 25 feature_names = diabetes.feature_names @@ -308,7 +337,7 @@ def test_plot_partial_dependence_passing_numpy_axes( @pytest.mark.filterwarnings("ignore:A Bunch will be returned") @pytest.mark.parametrize("nrows, ncols", [(2, 2), (3, 1)]) def test_plot_partial_dependence_incorrent_num_axes( - pyplot, clf_diabetes, diabetes, nrows, ncols + plot_partial_dependence, pyplot, clf_diabetes, diabetes, nrows, ncols ): grid_resolution = 5 fig, axes = pyplot.subplots(nrows, ncols) @@ -341,7 +370,9 @@ def test_plot_partial_dependence_incorrent_num_axes( @pytest.mark.filterwarnings("ignore:A Bunch will be returned") -def test_plot_partial_dependence_with_same_axes(pyplot, clf_diabetes, diabetes): +def test_plot_partial_dependence_with_same_axes( + plot_partial_dependence, pyplot, clf_diabetes, diabetes +): # The first call to plot_partial_dependence will create two new axes to # place in the space of the passed in axes, which results in a total of # three axes in the figure. @@ -381,7 +412,9 @@ def test_plot_partial_dependence_with_same_axes(pyplot, clf_diabetes, diabetes): @pytest.mark.filterwarnings("ignore:A Bunch will be returned") -def test_plot_partial_dependence_feature_name_reuse(pyplot, clf_diabetes, diabetes): +def test_plot_partial_dependence_feature_name_reuse( + plot_partial_dependence, pyplot, clf_diabetes, diabetes +): # second call to plot does not change the feature names from the first # call @@ -403,7 +436,7 @@ def test_plot_partial_dependence_feature_name_reuse(pyplot, clf_diabetes, diabet @pytest.mark.filterwarnings("ignore:A Bunch will be returned") -def test_plot_partial_dependence_multiclass(pyplot): +def test_plot_partial_dependence_multiclass(plot_partial_dependence, pyplot): grid_resolution = 25 clf_int = GradientBoostingClassifier(n_estimators=10, random_state=1) iris = load_iris() @@ -458,7 +491,7 @@ def test_plot_partial_dependence_multiclass(pyplot): @pytest.mark.filterwarnings("ignore:A Bunch will be returned") @pytest.mark.parametrize("target", [0, 1]) -def test_plot_partial_dependence_multioutput(pyplot, target): +def test_plot_partial_dependence_multioutput(plot_partial_dependence, pyplot, target): # Test partial dependence plot function on multi-output input. X, y = multioutput_regression_data clf = LinearRegression().fit(X, y) @@ -483,7 +516,9 @@ def test_plot_partial_dependence_multioutput(pyplot, target): @pytest.mark.filterwarnings("ignore:A Bunch will be returned") -def test_plot_partial_dependence_dataframe(pyplot, clf_diabetes, diabetes): +def test_plot_partial_dependence_dataframe( + plot_partial_dependence, pyplot, clf_diabetes, diabetes +): pd = pytest.importorskip("pandas") df = pd.DataFrame(diabetes.data, columns=diabetes.feature_names) @@ -577,7 +612,9 @@ def test_plot_partial_dependence_dataframe(pyplot, clf_diabetes, diabetes): ), ], ) -def test_plot_partial_dependence_error(pyplot, data, params, err_msg): +def test_plot_partial_dependence_error( + plot_partial_dependence, pyplot, data, params, err_msg +): X, y = data estimator = LinearRegression().fit(X, y) @@ -597,7 +634,9 @@ def test_plot_partial_dependence_error(pyplot, data, params, err_msg): ), ], ) -def test_plot_partial_dependence_multiclass_error(pyplot, params, err_msg): +def test_plot_partial_dependence_multiclass_error( + plot_partial_dependence, pyplot, params, err_msg +): iris = load_iris() clf = GradientBoostingClassifier(n_estimators=10, random_state=1) clf.fit(iris.data, iris.target) @@ -607,7 +646,7 @@ def test_plot_partial_dependence_multiclass_error(pyplot, params, err_msg): def test_plot_partial_dependence_does_not_override_ylabel( - pyplot, clf_diabetes, diabetes + plot_partial_dependence, pyplot, clf_diabetes, diabetes ): # Non-regression test to be sure to not override the ylabel if it has been # See https://github.com/scikit-learn/scikit-learn/issues/15772 @@ -624,7 +663,7 @@ def test_plot_partial_dependence_does_not_override_ylabel( [("average", (1, 2)), ("individual", (1, 2, 50)), ("both", (1, 2, 51))], ) def test_plot_partial_dependence_subsampling( - pyplot, clf_diabetes, diabetes, kind, expected_shape + plot_partial_dependence, pyplot, clf_diabetes, diabetes, kind, expected_shape ): # check that the subsampling is properly working # non-regression test for: @@ -662,6 +701,7 @@ def test_plot_partial_dependence_subsampling( ], ) def test_partial_dependence_overwrite_labels( + plot_partial_dependence, pyplot, clf_diabetes, diabetes, @@ -702,6 +742,7 @@ def test_partial_dependence_overwrite_labels( ], ) def test_plot_partial_dependence_lines_kw( + plot_partial_dependence, pyplot, clf_diabetes, diabetes, diff --git a/sklearn/utils/__init__.py b/sklearn/utils/__init__.py index 6fa13d929c529..b77ee0cb7a7e2 100644 --- a/sklearn/utils/__init__.py +++ b/sklearn/utils/__init__.py @@ -1085,7 +1085,7 @@ def _approximate_mode(class_counts, n_draws, rng): def check_matplotlib_support(caller_name): """Raise ImportError with detailed error message if mpl is not installed. - Plot utilities like :func:`plot_partial_dependence` should lazily import + Plot utilities like any of the Display's ploting functions should lazily import matplotlib and call this helper before any computation. Parameters From 63803aae9287b3c68f3c09818239afb2b6d09b8f Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Tue, 7 Sep 2021 14:14:31 +0200 Subject: [PATCH 8/9] TST avoid FutureWarning due to n_features_in_ deprecation in Dummy* (#20963) --- sklearn/tests/test_calibration.py | 2 +- sklearn/tests/test_dummy.py | 2 ++ sklearn/tests/test_multioutput.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sklearn/tests/test_calibration.py b/sklearn/tests/test_calibration.py index 8decff0cc96d5..b06f14b082cf5 100644 --- a/sklearn/tests/test_calibration.py +++ b/sklearn/tests/test_calibration.py @@ -613,7 +613,7 @@ def test_calibration_votingclassifier(): # defined via a property that only works when voting="soft". X, y = make_classification(n_samples=10, n_features=5, n_classes=2, random_state=7) vote = VotingClassifier( - estimators=[("dummy" + str(i), DummyClassifier()) for i in range(3)], + estimators=[("lr" + str(i), LogisticRegression()) for i in range(3)], voting="soft", ) vote.fit(X, y) diff --git a/sklearn/tests/test_dummy.py b/sklearn/tests/test_dummy.py index 984b51c25cc94..9cbb6f77d7271 100644 --- a/sklearn/tests/test_dummy.py +++ b/sklearn/tests/test_dummy.py @@ -726,6 +726,8 @@ def test_dtype_of_classifier_probas(strategy): assert probas.dtype == np.float64 +# TODO: remove in 1.2 +@pytest.mark.filterwarnings("ignore:`n_features_in_` is deprecated") @pytest.mark.parametrize("Dummy", (DummyRegressor, DummyClassifier)) def test_n_features_in_(Dummy): X = [[1, 2]] diff --git a/sklearn/tests/test_multioutput.py b/sklearn/tests/test_multioutput.py index 2467f54cec696..ded47c4fe2b4c 100644 --- a/sklearn/tests/test_multioutput.py +++ b/sklearn/tests/test_multioutput.py @@ -615,6 +615,7 @@ def fit(self, X, y, sample_weight=None, **fit_params): return super().fit(X, y, sample_weight) +@pytest.mark.filterwarnings("ignore:`n_features_in_` is deprecated") @pytest.mark.parametrize( "estimator, dataset", [ From 634b62b253513d0ed8b0227dc027c97734b8d3be Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Tue, 14 Sep 2021 10:57:02 +0200 Subject: [PATCH 9/9] FIX use same API for CalibrationDisplay than other Display (#21031) * FIX use same API for CalibrationDisplay than other Display * Update sklearn/calibration.py Co-authored-by: Thomas J. Fan * iter Co-authored-by: Thomas J. Fan --- sklearn/calibration.py | 19 +++++++++++-------- sklearn/metrics/_plot/det_curve.py | 4 ++-- .../metrics/_plot/precision_recall_curve.py | 4 ++-- sklearn/metrics/_plot/roc_curve.py | 4 ++-- sklearn/tests/test_calibration.py | 15 +++++++-------- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/sklearn/calibration.py b/sklearn/calibration.py index 9a7e08c9d9ff2..fe5e21577a434 100644 --- a/sklearn/calibration.py +++ b/sklearn/calibration.py @@ -980,8 +980,8 @@ class CalibrationDisplay: y_prob : ndarray of shape (n_samples,) Probability estimates for the positive class, for each sample. - name : str, default=None - Name for labeling curve. + estimator_name : str, default=None + Name of estimator. If None, the estimator name is not shown. Attributes ---------- @@ -1022,11 +1022,11 @@ class CalibrationDisplay: <...> """ - def __init__(self, prob_true, prob_pred, y_prob, *, name=None): + def __init__(self, prob_true, prob_pred, y_prob, *, estimator_name=None): self.prob_true = prob_true self.prob_pred = prob_pred self.y_prob = y_prob - self.name = name + self.estimator_name = estimator_name def plot(self, *, ax=None, name=None, ref_line=True, **kwargs): """Plot visualization. @@ -1041,7 +1041,8 @@ def plot(self, *, ax=None, name=None, ref_line=True, **kwargs): created. name : str, default=None - Name for labeling curve. + Name for labeling curve. If `None`, use `estimator_name` if + not `None`, otherwise no labeling is shown. ref_line : bool, default=True If `True`, plots a reference line representing a perfectly @@ -1061,8 +1062,7 @@ def plot(self, *, ax=None, name=None, ref_line=True, **kwargs): if ax is None: fig, ax = plt.subplots() - name = self.name if name is None else name - self.name = name + name = self.estimator_name if name is None else name line_kwargs = {} if name is not None: @@ -1298,6 +1298,9 @@ def from_predictions( prob_true, prob_pred = calibration_curve( y_true, y_prob, n_bins=n_bins, strategy=strategy ) + name = name if name is not None else "Classifier" - disp = cls(prob_true=prob_true, prob_pred=prob_pred, y_prob=y_prob, name=name) + disp = cls( + prob_true=prob_true, prob_pred=prob_pred, y_prob=y_prob, estimator_name=name + ) return disp.plot(ax=ax, ref_line=ref_line, **kwargs) diff --git a/sklearn/metrics/_plot/det_curve.py b/sklearn/metrics/_plot/det_curve.py index cb71c6f9cbe98..92e84ce9b7974 100644 --- a/sklearn/metrics/_plot/det_curve.py +++ b/sklearn/metrics/_plot/det_curve.py @@ -294,8 +294,8 @@ def plot(self, ax=None, *, name=None, **kwargs): created. name : str, default=None - Name of DET curve for labeling. If `None`, use the name of the - estimator. + Name of DET curve for labeling. If `None`, use `estimator_name` if + it is not `None`, otherwise no labeling is shown. **kwargs : dict Additional keywords arguments passed to matplotlib `plot` function. diff --git a/sklearn/metrics/_plot/precision_recall_curve.py b/sklearn/metrics/_plot/precision_recall_curve.py index fb09d299d39d4..eaf8240062174 100644 --- a/sklearn/metrics/_plot/precision_recall_curve.py +++ b/sklearn/metrics/_plot/precision_recall_curve.py @@ -109,8 +109,8 @@ def plot(self, ax=None, *, name=None, **kwargs): created. name : str, default=None - Name of precision recall curve for labeling. If `None`, use the - name of the estimator. + Name of precision recall curve for labeling. If `None`, use + `estimator_name` if not `None`, otherwise no labeling is shown. **kwargs : dict Keyword arguments to be passed to matplotlib's `plot`. diff --git a/sklearn/metrics/_plot/roc_curve.py b/sklearn/metrics/_plot/roc_curve.py index 1eed3557e4553..7d222b82e4638 100644 --- a/sklearn/metrics/_plot/roc_curve.py +++ b/sklearn/metrics/_plot/roc_curve.py @@ -94,8 +94,8 @@ def plot(self, ax=None, *, name=None, **kwargs): created. name : str, default=None - Name of ROC Curve for labeling. If `None`, use the name of the - estimator. + Name of ROC Curve for labeling. If `None`, use `estimator_name` if + not `None`, otherwise no labeling is shown. Returns ------- diff --git a/sklearn/tests/test_calibration.py b/sklearn/tests/test_calibration.py index b06f14b082cf5..040571df4681b 100644 --- a/sklearn/tests/test_calibration.py +++ b/sklearn/tests/test_calibration.py @@ -693,7 +693,7 @@ def test_calibration_display_compute(pyplot, iris_data_binary, n_bins, strategy) assert_allclose(viz.prob_pred, prob_pred) assert_allclose(viz.y_prob, y_prob) - assert viz.name == "LogisticRegression" + assert viz.estimator_name == "LogisticRegression" # cannot fail thanks to pyplot fixture import matplotlib as mpl # noqa @@ -715,7 +715,7 @@ def test_plot_calibration_curve_pipeline(pyplot, iris_data_binary): clf.fit(X, y) viz = CalibrationDisplay.from_estimator(clf, X, y) assert clf.__class__.__name__ in viz.line_.get_label() - assert viz.name == clf.__class__.__name__ + assert viz.estimator_name == clf.__class__.__name__ @pytest.mark.parametrize( @@ -726,24 +726,23 @@ def test_calibration_display_default_labels(pyplot, name, expected_label): prob_pred = np.array([0.2, 0.8, 0.8, 0.4]) y_prob = np.array([]) - viz = CalibrationDisplay(prob_true, prob_pred, y_prob, name=name) + viz = CalibrationDisplay(prob_true, prob_pred, y_prob, estimator_name=name) viz.plot() assert viz.line_.get_label() == expected_label def test_calibration_display_label_class_plot(pyplot): # Checks that when instantiating `CalibrationDisplay` class then calling - # `plot`, `self.name` is the one given in `plot` + # `plot`, `self.estimator_name` is the one given in `plot` prob_true = np.array([0, 1, 1, 0]) prob_pred = np.array([0.2, 0.8, 0.8, 0.4]) y_prob = np.array([]) name = "name one" - viz = CalibrationDisplay(prob_true, prob_pred, y_prob, name=name) - assert viz.name == name + viz = CalibrationDisplay(prob_true, prob_pred, y_prob, estimator_name=name) + assert viz.estimator_name == name name = "name two" viz.plot(name=name) - assert viz.name == name assert viz.line_.get_label() == name @@ -764,7 +763,7 @@ def test_calibration_display_name_multiple_calls( params = (clf, X, y) if constructor_name == "from_estimator" else (y, y_prob) viz = constructor(*params, name=clf_name) - assert viz.name == clf_name + assert viz.estimator_name == clf_name pyplot.close("all") viz.plot() assert clf_name == viz.line_.get_label()