import numpy as np import pytest from sklearn.base import ClassifierMixin, clone from sklearn.calibration import CalibrationDisplay from sklearn.compose import make_column_transformer from sklearn.datasets import load_iris from sklearn.exceptions import NotFittedError from sklearn.linear_model import LogisticRegression from sklearn.metrics import ( ConfusionMatrixDisplay, DetCurveDisplay, PrecisionRecallDisplay, PredictionErrorDisplay, RocCurveDisplay, ) from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor @pytest.fixture(scope="module") def data(): return load_iris(return_X_y=True) @pytest.fixture(scope="module") def data_binary(data): X, y = data return X[y < 2], y[y < 2] @pytest.mark.parametrize( "Display", [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay], ) def test_display_curve_error_classifier(pyplot, data, data_binary, Display): """Check that a proper error is raised when only binary classification is supported.""" X, y = data X_binary, y_binary = data_binary clf = DecisionTreeClassifier().fit(X, y) # Case 1: multiclass classifier with multiclass target msg = "Expected 'estimator' to be a binary classifier. Got 3 classes instead." with pytest.raises(ValueError, match=msg): Display.from_estimator(clf, X, y) # Case 2: multiclass classifier with binary target with pytest.raises(ValueError, match=msg): Display.from_estimator(clf, X_binary, y_binary) # Case 3: binary classifier with multiclass target clf = DecisionTreeClassifier().fit(X_binary, y_binary) msg = "The target y is not binary. Got multiclass type of target." with pytest.raises(ValueError, match=msg): Display.from_estimator(clf, X, y) @pytest.mark.parametrize( "Display", [CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay], ) def test_display_curve_error_regression(pyplot, data_binary, Display): """Check that we raise an error with regressor.""" # Case 1: regressor X, y = data_binary regressor = DecisionTreeRegressor().fit(X, y) msg = "Expected 'estimator' to be a binary classifier. Got DecisionTreeRegressor" with pytest.raises(ValueError, match=msg): Display.from_estimator(regressor, X, y) # Case 2: regression target classifier = DecisionTreeClassifier().fit(X, y) # Force `y_true` to be seen as a regression problem y = y + 0.5 msg = "The target y is not binary. Got continuous type of target." with pytest.raises(ValueError, match=msg): Display.from_estimator(classifier, X, y) with pytest.raises(ValueError, match=msg): Display.from_predictions(y, regressor.fit(X, y).predict(X)) @pytest.mark.parametrize( "response_method, msg", [ ( "predict_proba", "MyClassifier has none of the following attributes: predict_proba.", ), ( "decision_function", "MyClassifier has none of the following attributes: decision_function.", ), ( "auto", ( "MyClassifier has none of the following attributes: predict_proba," " decision_function." ), ), ( "bad_method", "MyClassifier has none of the following attributes: bad_method.", ), ], ) @pytest.mark.parametrize( "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] ) def test_display_curve_error_no_response( pyplot, data_binary, response_method, msg, Display, ): """Check that a proper error is raised when the response method requested is not defined for the given trained classifier.""" X, y = data_binary class MyClassifier(ClassifierMixin): def fit(self, X, y): self.classes_ = [0, 1] return self clf = MyClassifier().fit(X, y) with pytest.raises(AttributeError, match=msg): Display.from_estimator(clf, X, y, response_method=response_method) @pytest.mark.parametrize( "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] ) @pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) def test_display_curve_estimator_name_multiple_calls( pyplot, data_binary, Display, constructor_name, ): """Check that passing `name` when calling `plot` will overwrite the original name in the legend.""" X, y = data_binary clf_name = "my hand-crafted name" clf = LogisticRegression().fit(X, y) y_pred = clf.predict_proba(X)[:, 1] # safe guard for the binary if/else construction assert constructor_name in ("from_estimator", "from_predictions") if constructor_name == "from_estimator": disp = Display.from_estimator(clf, X, y, name=clf_name) else: disp = Display.from_predictions(y, y_pred, name=clf_name) assert disp.estimator_name == clf_name pyplot.close("all") disp.plot() assert clf_name in disp.line_.get_label() pyplot.close("all") clf_name = "another_name" disp.plot(name=clf_name) assert clf_name in disp.line_.get_label() @pytest.mark.parametrize( "clf", [ LogisticRegression(), make_pipeline(StandardScaler(), LogisticRegression()), make_pipeline( make_column_transformer((StandardScaler(), [0, 1])), LogisticRegression() ), ], ) @pytest.mark.parametrize( "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] ) def test_display_curve_not_fitted_errors(pyplot, data_binary, clf, Display): """Check that a proper error is raised when the classifier is not fitted.""" X, y = data_binary # clone since we parametrize the test and the classifier will be fitted # when testing the second and subsequent plotting function model = clone(clf) with pytest.raises(NotFittedError): Display.from_estimator(model, X, y) model.fit(X, y) disp = Display.from_estimator(model, X, y) assert model.__class__.__name__ in disp.line_.get_label() assert disp.estimator_name == model.__class__.__name__ @pytest.mark.parametrize( "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] ) def test_display_curve_n_samples_consistency(pyplot, data_binary, Display): """Check the error raised when `y_pred` or `sample_weight` have inconsistent length.""" X, y = data_binary classifier = DecisionTreeClassifier().fit(X, y) msg = "Found input variables with inconsistent numbers of samples" with pytest.raises(ValueError, match=msg): Display.from_estimator(classifier, X[:-2], y) with pytest.raises(ValueError, match=msg): Display.from_estimator(classifier, X, y[:-2]) with pytest.raises(ValueError, match=msg): Display.from_estimator(classifier, X, y, sample_weight=np.ones(X.shape[0] - 2)) @pytest.mark.parametrize( "Display", [DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay] ) def test_display_curve_error_pos_label(pyplot, data_binary, Display): """Check consistence of error message when `pos_label` should be specified.""" X, y = data_binary y = y + 10 classifier = DecisionTreeClassifier().fit(X, y) y_pred = classifier.predict_proba(X)[:, -1] msg = r"y_true takes value in {10, 11} and pos_label is not specified" with pytest.raises(ValueError, match=msg): Display.from_predictions(y, y_pred) @pytest.mark.parametrize( "Display", [ CalibrationDisplay, DetCurveDisplay, PrecisionRecallDisplay, RocCurveDisplay, PredictionErrorDisplay, ConfusionMatrixDisplay, ], ) @pytest.mark.parametrize( "constructor", ["from_predictions", "from_estimator"], ) def test_classifier_display_curve_named_constructor_return_type( pyplot, data_binary, Display, constructor ): """Check that named constructors return the correct type when subclassed. Non-regression test for: https://github.com/scikit-learn/scikit-learn/pull/27675 """ X, y = data_binary # This can be anything - we just need to check the named constructor return # type so the only requirement here is instantiating the class without error y_pred = y classifier = LogisticRegression().fit(X, y) class SubclassOfDisplay(Display): pass if constructor == "from_predictions": curve = SubclassOfDisplay.from_predictions(y, y_pred) else: # constructor == "from_estimator" curve = SubclassOfDisplay.from_estimator(classifier, X, y) assert isinstance(curve, SubclassOfDisplay)