# Authors: Alexandre Gramfort # License: BSD 3 clause import numpy as np import pytest from numpy.testing import assert_allclose from sklearn.base import BaseEstimator, clone from sklearn.calibration import ( CalibratedClassifierCV, CalibrationDisplay, _CalibratedClassifier, _sigmoid_calibration, _SigmoidCalibration, calibration_curve, ) from sklearn.datasets import load_iris, make_blobs, make_classification from sklearn.dummy import DummyClassifier from sklearn.ensemble import ( RandomForestClassifier, VotingClassifier, ) from sklearn.exceptions import NotFittedError from sklearn.feature_extraction import DictVectorizer from sklearn.impute import SimpleImputer from sklearn.isotonic import IsotonicRegression from sklearn.linear_model import LogisticRegression, SGDClassifier from sklearn.metrics import brier_score_loss from sklearn.model_selection import ( KFold, LeaveOneOut, check_cv, cross_val_predict, cross_val_score, train_test_split, ) from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import Pipeline, make_pipeline from sklearn.preprocessing import LabelEncoder, StandardScaler from sklearn.svm import LinearSVC from sklearn.tree import DecisionTreeClassifier from sklearn.utils._mocking import CheckingClassifier from sklearn.utils._testing import ( _convert_container, assert_almost_equal, assert_array_almost_equal, assert_array_equal, ) from sklearn.utils.extmath import softmax from sklearn.utils.fixes import CSR_CONTAINERS N_SAMPLES = 200 @pytest.fixture(scope="module") def data(): X, y = make_classification(n_samples=N_SAMPLES, n_features=6, random_state=42) return X, y @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) @pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) @pytest.mark.parametrize("ensemble", [True, False]) def test_calibration(data, method, csr_container, ensemble): # Test calibration objects with isotonic and sigmoid n_samples = N_SAMPLES // 2 X, y = data sample_weight = np.random.RandomState(seed=42).uniform(size=y.size) X -= X.min() # MultinomialNB only allows positive X # split train and test X_train, y_train, sw_train = X[:n_samples], y[:n_samples], sample_weight[:n_samples] X_test, y_test = X[n_samples:], y[n_samples:] # Naive-Bayes clf = MultinomialNB().fit(X_train, y_train, sample_weight=sw_train) prob_pos_clf = clf.predict_proba(X_test)[:, 1] cal_clf = CalibratedClassifierCV(clf, cv=y.size + 1, ensemble=ensemble) with pytest.raises(ValueError): cal_clf.fit(X, y) # Naive Bayes with calibration for this_X_train, this_X_test in [ (X_train, X_test), (csr_container(X_train), csr_container(X_test)), ]: cal_clf = CalibratedClassifierCV(clf, method=method, cv=5, ensemble=ensemble) # Note that this fit overwrites the fit on the entire training # set cal_clf.fit(this_X_train, y_train, sample_weight=sw_train) prob_pos_cal_clf = cal_clf.predict_proba(this_X_test)[:, 1] # Check that brier score has improved after calibration assert brier_score_loss(y_test, prob_pos_clf) > brier_score_loss( y_test, prob_pos_cal_clf ) # Check invariance against relabeling [0, 1] -> [1, 2] cal_clf.fit(this_X_train, y_train + 1, sample_weight=sw_train) prob_pos_cal_clf_relabeled = cal_clf.predict_proba(this_X_test)[:, 1] assert_array_almost_equal(prob_pos_cal_clf, prob_pos_cal_clf_relabeled) # Check invariance against relabeling [0, 1] -> [-1, 1] cal_clf.fit(this_X_train, 2 * y_train - 1, sample_weight=sw_train) prob_pos_cal_clf_relabeled = cal_clf.predict_proba(this_X_test)[:, 1] assert_array_almost_equal(prob_pos_cal_clf, prob_pos_cal_clf_relabeled) # Check invariance against relabeling [0, 1] -> [1, 0] cal_clf.fit(this_X_train, (y_train + 1) % 2, sample_weight=sw_train) prob_pos_cal_clf_relabeled = cal_clf.predict_proba(this_X_test)[:, 1] if method == "sigmoid": assert_array_almost_equal(prob_pos_cal_clf, 1 - prob_pos_cal_clf_relabeled) else: # Isotonic calibration is not invariant against relabeling # but should improve in both cases assert brier_score_loss(y_test, prob_pos_clf) > brier_score_loss( (y_test + 1) % 2, prob_pos_cal_clf_relabeled ) def test_calibration_default_estimator(data): # Check estimator default is LinearSVC X, y = data calib_clf = CalibratedClassifierCV(cv=2) calib_clf.fit(X, y) base_est = calib_clf.calibrated_classifiers_[0].estimator assert isinstance(base_est, LinearSVC) @pytest.mark.parametrize("ensemble", [True, False]) def test_calibration_cv_splitter(data, ensemble): # Check when `cv` is a CV splitter X, y = data splits = 5 kfold = KFold(n_splits=splits) calib_clf = CalibratedClassifierCV(cv=kfold, ensemble=ensemble) assert isinstance(calib_clf.cv, KFold) assert calib_clf.cv.n_splits == splits calib_clf.fit(X, y) expected_n_clf = splits if ensemble else 1 assert len(calib_clf.calibrated_classifiers_) == expected_n_clf @pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) @pytest.mark.parametrize("ensemble", [True, False]) def test_sample_weight(data, method, ensemble): n_samples = N_SAMPLES // 2 X, y = data sample_weight = np.random.RandomState(seed=42).uniform(size=len(y)) X_train, y_train, sw_train = X[:n_samples], y[:n_samples], sample_weight[:n_samples] X_test = X[n_samples:] estimator = LinearSVC(random_state=42) calibrated_clf = CalibratedClassifierCV(estimator, method=method, ensemble=ensemble) calibrated_clf.fit(X_train, y_train, sample_weight=sw_train) probs_with_sw = calibrated_clf.predict_proba(X_test) # As the weights are used for the calibration, they should still yield # different predictions calibrated_clf.fit(X_train, y_train) probs_without_sw = calibrated_clf.predict_proba(X_test) diff = np.linalg.norm(probs_with_sw - probs_without_sw) assert diff > 0.1 @pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) @pytest.mark.parametrize("ensemble", [True, False]) def test_parallel_execution(data, method, ensemble): """Test parallel calibration""" X, y = data X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) estimator = make_pipeline(StandardScaler(), LinearSVC(random_state=42)) cal_clf_parallel = CalibratedClassifierCV( estimator, method=method, n_jobs=2, ensemble=ensemble ) cal_clf_parallel.fit(X_train, y_train) probs_parallel = cal_clf_parallel.predict_proba(X_test) cal_clf_sequential = CalibratedClassifierCV( estimator, method=method, n_jobs=1, ensemble=ensemble ) cal_clf_sequential.fit(X_train, y_train) probs_sequential = cal_clf_sequential.predict_proba(X_test) assert_allclose(probs_parallel, probs_sequential) @pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) @pytest.mark.parametrize("ensemble", [True, False]) # increase the number of RNG seeds to assess the statistical stability of this # test: @pytest.mark.parametrize("seed", range(2)) def test_calibration_multiclass(method, ensemble, seed): def multiclass_brier(y_true, proba_pred, n_classes): Y_onehot = np.eye(n_classes)[y_true] return np.sum((Y_onehot - proba_pred) ** 2) / Y_onehot.shape[0] # Test calibration for multiclass with classifier that implements # only decision function. clf = LinearSVC(random_state=7) X, y = make_blobs( n_samples=500, n_features=100, random_state=seed, centers=10, cluster_std=15.0 ) # Use an unbalanced dataset by collapsing 8 clusters into one class # to make the naive calibration based on a softmax more unlikely # to work. y[y > 2] = 2 n_classes = np.unique(y).shape[0] X_train, y_train = X[::2], y[::2] X_test, y_test = X[1::2], y[1::2] clf.fit(X_train, y_train) cal_clf = CalibratedClassifierCV(clf, method=method, cv=5, ensemble=ensemble) cal_clf.fit(X_train, y_train) probas = cal_clf.predict_proba(X_test) # Check probabilities sum to 1 assert_allclose(np.sum(probas, axis=1), np.ones(len(X_test))) # Check that the dataset is not too trivial, otherwise it's hard # to get interesting calibration data during the internal # cross-validation loop. assert 0.65 < clf.score(X_test, y_test) < 0.95 # Check that the accuracy of the calibrated model is never degraded # too much compared to the original classifier. assert cal_clf.score(X_test, y_test) > 0.95 * clf.score(X_test, y_test) # Check that Brier loss of calibrated classifier is smaller than # loss obtained by naively turning OvR decision function to # probabilities via a softmax uncalibrated_brier = multiclass_brier( y_test, softmax(clf.decision_function(X_test)), n_classes=n_classes ) calibrated_brier = multiclass_brier(y_test, probas, n_classes=n_classes) assert calibrated_brier < 1.1 * uncalibrated_brier # Test that calibration of a multiclass classifier decreases log-loss # for RandomForestClassifier clf = RandomForestClassifier(n_estimators=30, random_state=42) clf.fit(X_train, y_train) clf_probs = clf.predict_proba(X_test) uncalibrated_brier = multiclass_brier(y_test, clf_probs, n_classes=n_classes) cal_clf = CalibratedClassifierCV(clf, method=method, cv=5, ensemble=ensemble) cal_clf.fit(X_train, y_train) cal_clf_probs = cal_clf.predict_proba(X_test) calibrated_brier = multiclass_brier(y_test, cal_clf_probs, n_classes=n_classes) assert calibrated_brier < 1.1 * uncalibrated_brier def test_calibration_zero_probability(): # Test an edge case where _CalibratedClassifier avoids numerical errors # in the multiclass normalization step if all the calibrators output # are zero all at once for a given sample and instead fallback to uniform # probabilities. class ZeroCalibrator: # This function is called from _CalibratedClassifier.predict_proba. def predict(self, X): return np.zeros(X.shape[0]) X, y = make_blobs( n_samples=50, n_features=10, random_state=7, centers=10, cluster_std=15.0 ) clf = DummyClassifier().fit(X, y) calibrator = ZeroCalibrator() cal_clf = _CalibratedClassifier( estimator=clf, calibrators=[calibrator], classes=clf.classes_ ) probas = cal_clf.predict_proba(X) # Check that all probabilities are uniformly 1. / clf.n_classes_ assert_allclose(probas, 1.0 / clf.n_classes_) @pytest.mark.parametrize("csr_container", CSR_CONTAINERS) def test_calibration_prefit(csr_container): """Test calibration for prefitted classifiers""" n_samples = 50 X, y = make_classification(n_samples=3 * n_samples, n_features=6, random_state=42) sample_weight = np.random.RandomState(seed=42).uniform(size=y.size) X -= X.min() # MultinomialNB only allows positive X # split train and test X_train, y_train, sw_train = X[:n_samples], y[:n_samples], sample_weight[:n_samples] X_calib, y_calib, sw_calib = ( X[n_samples : 2 * n_samples], y[n_samples : 2 * n_samples], sample_weight[n_samples : 2 * n_samples], ) X_test, y_test = X[2 * n_samples :], y[2 * n_samples :] # Naive-Bayes clf = MultinomialNB() # Check error if clf not prefit unfit_clf = CalibratedClassifierCV(clf, cv="prefit") with pytest.raises(NotFittedError): unfit_clf.fit(X_calib, y_calib) clf.fit(X_train, y_train, sw_train) prob_pos_clf = clf.predict_proba(X_test)[:, 1] # Naive Bayes with calibration for this_X_calib, this_X_test in [ (X_calib, X_test), (csr_container(X_calib), csr_container(X_test)), ]: for method in ["isotonic", "sigmoid"]: cal_clf = CalibratedClassifierCV(clf, method=method, cv="prefit") for sw in [sw_calib, None]: cal_clf.fit(this_X_calib, y_calib, sample_weight=sw) y_prob = cal_clf.predict_proba(this_X_test) y_pred = cal_clf.predict(this_X_test) prob_pos_cal_clf = y_prob[:, 1] assert_array_equal(y_pred, np.array([0, 1])[np.argmax(y_prob, axis=1)]) assert brier_score_loss(y_test, prob_pos_clf) > brier_score_loss( y_test, prob_pos_cal_clf ) @pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) def test_calibration_ensemble_false(data, method): # Test that `ensemble=False` is the same as using predictions from # `cross_val_predict` to train calibrator. X, y = data clf = LinearSVC(random_state=7) cal_clf = CalibratedClassifierCV(clf, method=method, cv=3, ensemble=False) cal_clf.fit(X, y) cal_probas = cal_clf.predict_proba(X) # Get probas manually unbiased_preds = cross_val_predict(clf, X, y, cv=3, method="decision_function") if method == "isotonic": calibrator = IsotonicRegression(out_of_bounds="clip") else: calibrator = _SigmoidCalibration() calibrator.fit(unbiased_preds, y) # Use `clf` fit on all data clf.fit(X, y) clf_df = clf.decision_function(X) manual_probas = calibrator.predict(clf_df) assert_allclose(cal_probas[:, 1], manual_probas) def test_sigmoid_calibration(): """Test calibration values with Platt sigmoid model""" exF = np.array([5, -4, 1.0]) exY = np.array([1, -1, -1]) # computed from my python port of the C++ code in LibSVM AB_lin_libsvm = np.array([-0.20261354391187855, 0.65236314980010512]) assert_array_almost_equal(AB_lin_libsvm, _sigmoid_calibration(exF, exY), 3) lin_prob = 1.0 / (1.0 + np.exp(AB_lin_libsvm[0] * exF + AB_lin_libsvm[1])) sk_prob = _SigmoidCalibration().fit(exF, exY).predict(exF) assert_array_almost_equal(lin_prob, sk_prob, 6) # check that _SigmoidCalibration().fit only accepts 1d array or 2d column # arrays with pytest.raises(ValueError): _SigmoidCalibration().fit(np.vstack((exF, exF)), exY) def test_calibration_curve(): """Check calibration_curve function""" y_true = np.array([0, 0, 0, 1, 1, 1]) y_pred = np.array([0.0, 0.1, 0.2, 0.8, 0.9, 1.0]) prob_true, prob_pred = calibration_curve(y_true, y_pred, n_bins=2) assert len(prob_true) == len(prob_pred) assert len(prob_true) == 2 assert_almost_equal(prob_true, [0, 1]) assert_almost_equal(prob_pred, [0.1, 0.9]) # Probabilities outside [0, 1] should not be accepted at all. with pytest.raises(ValueError): calibration_curve([1], [-0.1]) # test that quantiles work as expected y_true2 = np.array([0, 0, 0, 0, 1, 1]) y_pred2 = np.array([0.0, 0.1, 0.2, 0.5, 0.9, 1.0]) prob_true_quantile, prob_pred_quantile = calibration_curve( y_true2, y_pred2, n_bins=2, strategy="quantile" ) assert len(prob_true_quantile) == len(prob_pred_quantile) assert len(prob_true_quantile) == 2 assert_almost_equal(prob_true_quantile, [0, 2 / 3]) assert_almost_equal(prob_pred_quantile, [0.1, 0.8]) # Check that error is raised when invalid strategy is selected with pytest.raises(ValueError): calibration_curve(y_true2, y_pred2, strategy="percentile") @pytest.mark.parametrize("ensemble", [True, False]) def test_calibration_nan_imputer(ensemble): """Test that calibration can accept nan""" X, y = make_classification( n_samples=10, n_features=2, n_informative=2, n_redundant=0, random_state=42 ) X[0, 0] = np.nan clf = Pipeline( [("imputer", SimpleImputer()), ("rf", RandomForestClassifier(n_estimators=1))] ) clf_c = CalibratedClassifierCV(clf, cv=2, method="isotonic", ensemble=ensemble) clf_c.fit(X, y) clf_c.predict(X) @pytest.mark.parametrize("ensemble", [True, False]) def test_calibration_prob_sum(ensemble): # Test that sum of probabilities is 1. A non-regression test for # issue #7796 num_classes = 2 X, y = make_classification(n_samples=10, n_features=5, n_classes=num_classes) clf = LinearSVC(C=1.0, random_state=7) clf_prob = CalibratedClassifierCV( clf, method="sigmoid", cv=LeaveOneOut(), ensemble=ensemble ) clf_prob.fit(X, y) probs = clf_prob.predict_proba(X) assert_array_almost_equal(probs.sum(axis=1), np.ones(probs.shape[0])) @pytest.mark.parametrize("ensemble", [True, False]) def test_calibration_less_classes(ensemble): # Test to check calibration works fine when train set in a test-train # split does not contain all classes # Since this test uses LOO, at each iteration train set will not contain a # class label X = np.random.randn(10, 5) y = np.arange(10) clf = LinearSVC(C=1.0, random_state=7) cal_clf = CalibratedClassifierCV( clf, method="sigmoid", cv=LeaveOneOut(), ensemble=ensemble ) cal_clf.fit(X, y) for i, calibrated_classifier in enumerate(cal_clf.calibrated_classifiers_): proba = calibrated_classifier.predict_proba(X) if ensemble: # Check that the unobserved class has proba=0 assert_array_equal(proba[:, i], np.zeros(len(y))) # Check for all other classes proba>0 assert np.all(proba[:, :i] > 0) assert np.all(proba[:, i + 1 :] > 0) else: # Check `proba` are all 1/n_classes assert np.allclose(proba, 1 / proba.shape[0]) @pytest.mark.parametrize( "X", [ np.random.RandomState(42).randn(15, 5, 2), np.random.RandomState(42).randn(15, 5, 2, 6), ], ) def test_calibration_accepts_ndarray(X): """Test that calibration accepts n-dimensional arrays as input""" y = [1, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0] class MockTensorClassifier(BaseEstimator): """A toy estimator that accepts tensor inputs""" _estimator_type = "classifier" def fit(self, X, y): self.classes_ = np.unique(y) return self def decision_function(self, X): # toy decision function that just needs to have the right shape: return X.reshape(X.shape[0], -1).sum(axis=1) calibrated_clf = CalibratedClassifierCV(MockTensorClassifier()) # we should be able to fit this classifier with no error calibrated_clf.fit(X, y) @pytest.fixture def dict_data(): dict_data = [ {"state": "NY", "age": "adult"}, {"state": "TX", "age": "adult"}, {"state": "VT", "age": "child"}, ] text_labels = [1, 0, 1] return dict_data, text_labels @pytest.fixture def dict_data_pipeline(dict_data): X, y = dict_data pipeline_prefit = Pipeline( [("vectorizer", DictVectorizer()), ("clf", RandomForestClassifier())] ) return pipeline_prefit.fit(X, y) def test_calibration_dict_pipeline(dict_data, dict_data_pipeline): """Test that calibration works in prefit pipeline with transformer `X` is not array-like, sparse matrix or dataframe at the start. See https://github.com/scikit-learn/scikit-learn/issues/8710 Also test it can predict without running into validation errors. See https://github.com/scikit-learn/scikit-learn/issues/19637 """ X, y = dict_data clf = dict_data_pipeline calib_clf = CalibratedClassifierCV(clf, cv="prefit") calib_clf.fit(X, y) # Check attributes are obtained from fitted estimator assert_array_equal(calib_clf.classes_, clf.classes_) # Neither the pipeline nor the calibration meta-estimator # expose the n_features_in_ check on this kind of data. assert not hasattr(clf, "n_features_in_") assert not hasattr(calib_clf, "n_features_in_") # Ensure that no error is thrown with predict and predict_proba calib_clf.predict(X) calib_clf.predict_proba(X) @pytest.mark.parametrize( "clf, cv", [ pytest.param(LinearSVC(C=1), 2), pytest.param(LinearSVC(C=1), "prefit"), ], ) def test_calibration_attributes(clf, cv): # Check that `n_features_in_` and `classes_` attributes created properly X, y = make_classification(n_samples=10, n_features=5, n_classes=2, random_state=7) if cv == "prefit": clf = clf.fit(X, y) calib_clf = CalibratedClassifierCV(clf, cv=cv) calib_clf.fit(X, y) if cv == "prefit": assert_array_equal(calib_clf.classes_, clf.classes_) assert calib_clf.n_features_in_ == clf.n_features_in_ else: classes = LabelEncoder().fit(y).classes_ assert_array_equal(calib_clf.classes_, classes) assert calib_clf.n_features_in_ == X.shape[1] def test_calibration_inconsistent_prefit_n_features_in(): # Check that `n_features_in_` from prefit base estimator # is consistent with training set X, y = make_classification(n_samples=10, n_features=5, n_classes=2, random_state=7) clf = LinearSVC(C=1).fit(X, y) calib_clf = CalibratedClassifierCV(clf, cv="prefit") msg = "X has 3 features, but LinearSVC is expecting 5 features as input." with pytest.raises(ValueError, match=msg): calib_clf.fit(X[:, :3], y) def test_calibration_votingclassifier(): # Check that `CalibratedClassifier` works with `VotingClassifier`. # The method `predict_proba` from `VotingClassifier` is dynamically # 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=[("lr" + str(i), LogisticRegression()) for i in range(3)], voting="soft", ) vote.fit(X, y) calib_clf = CalibratedClassifierCV(estimator=vote, cv="prefit") # smoke test: should not raise an error calib_clf.fit(X, y) @pytest.fixture(scope="module") def iris_data(): return load_iris(return_X_y=True) @pytest.fixture(scope="module") def iris_data_binary(iris_data): X, y = iris_data return X[y < 2], y[y < 2] @pytest.mark.parametrize("n_bins", [5, 10]) @pytest.mark.parametrize("strategy", ["uniform", "quantile"]) def test_calibration_display_compute(pyplot, iris_data_binary, n_bins, strategy): # Ensure `CalibrationDisplay.from_predictions` and `calibration_curve` # compute the same results. Also checks attributes of the # CalibrationDisplay object. X, y = iris_data_binary lr = LogisticRegression().fit(X, y) viz = CalibrationDisplay.from_estimator( lr, X, y, n_bins=n_bins, strategy=strategy, alpha=0.8 ) y_prob = lr.predict_proba(X)[:, 1] prob_true, prob_pred = calibration_curve( y, y_prob, n_bins=n_bins, strategy=strategy ) assert_allclose(viz.prob_true, prob_true) assert_allclose(viz.prob_pred, prob_pred) assert_allclose(viz.y_prob, y_prob) assert viz.estimator_name == "LogisticRegression" # cannot fail thanks to pyplot fixture import matplotlib as mpl # noqa assert isinstance(viz.line_, mpl.lines.Line2D) assert viz.line_.get_alpha() == 0.8 assert isinstance(viz.ax_, mpl.axes.Axes) assert isinstance(viz.figure_, mpl.figure.Figure) assert viz.ax_.get_xlabel() == "Mean predicted probability (Positive class: 1)" assert viz.ax_.get_ylabel() == "Fraction of positives (Positive class: 1)" expected_legend_labels = ["LogisticRegression", "Perfectly calibrated"] legend_labels = viz.ax_.get_legend().get_texts() assert len(legend_labels) == len(expected_legend_labels) for labels in legend_labels: assert labels.get_text() in expected_legend_labels def test_plot_calibration_curve_pipeline(pyplot, iris_data_binary): # Ensure pipelines are supported by CalibrationDisplay.from_estimator X, y = iris_data_binary clf = make_pipeline(StandardScaler(), LogisticRegression()) clf.fit(X, y) viz = CalibrationDisplay.from_estimator(clf, X, y) expected_legend_labels = [viz.estimator_name, "Perfectly calibrated"] legend_labels = viz.ax_.get_legend().get_texts() assert len(legend_labels) == len(expected_legend_labels) for labels in legend_labels: assert labels.get_text() in expected_legend_labels @pytest.mark.parametrize( "name, expected_label", [(None, "_line1"), ("my_est", "my_est")] ) def test_calibration_display_default_labels(pyplot, name, expected_label): prob_true = np.array([0, 1, 1, 0]) prob_pred = np.array([0.2, 0.8, 0.8, 0.4]) y_prob = np.array([]) viz = CalibrationDisplay(prob_true, prob_pred, y_prob, estimator_name=name) viz.plot() expected_legend_labels = [] if name is None else [name] expected_legend_labels.append("Perfectly calibrated") legend_labels = viz.ax_.get_legend().get_texts() assert len(legend_labels) == len(expected_legend_labels) for labels in legend_labels: assert labels.get_text() in expected_legend_labels def test_calibration_display_label_class_plot(pyplot): # Checks that when instantiating `CalibrationDisplay` class then calling # `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, estimator_name=name) assert viz.estimator_name == name name = "name two" viz.plot(name=name) expected_legend_labels = [name, "Perfectly calibrated"] legend_labels = viz.ax_.get_legend().get_texts() assert len(legend_labels) == len(expected_legend_labels) for labels in legend_labels: assert labels.get_text() in expected_legend_labels @pytest.mark.parametrize("constructor_name", ["from_estimator", "from_predictions"]) def test_calibration_display_name_multiple_calls( constructor_name, pyplot, iris_data_binary ): # Check that the `name` used when calling # `CalibrationDisplay.from_predictions` or # `CalibrationDisplay.from_estimator` is used when multiple # `CalibrationDisplay.viz.plot()` calls are made. X, y = iris_data_binary clf_name = "my hand-crafted name" clf = LogisticRegression().fit(X, y) y_prob = clf.predict_proba(X)[:, 1] constructor = getattr(CalibrationDisplay, constructor_name) params = (clf, X, y) if constructor_name == "from_estimator" else (y, y_prob) viz = constructor(*params, name=clf_name) assert viz.estimator_name == clf_name pyplot.close("all") viz.plot() expected_legend_labels = [clf_name, "Perfectly calibrated"] legend_labels = viz.ax_.get_legend().get_texts() assert len(legend_labels) == len(expected_legend_labels) for labels in legend_labels: assert labels.get_text() in expected_legend_labels pyplot.close("all") clf_name = "another_name" viz.plot(name=clf_name) assert len(legend_labels) == len(expected_legend_labels) for labels in legend_labels: assert labels.get_text() in expected_legend_labels def test_calibration_display_ref_line(pyplot, iris_data_binary): # Check that `ref_line` only appears once X, y = iris_data_binary lr = LogisticRegression().fit(X, y) dt = DecisionTreeClassifier().fit(X, y) viz = CalibrationDisplay.from_estimator(lr, X, y) viz2 = CalibrationDisplay.from_estimator(dt, X, y, ax=viz.ax_) labels = viz2.ax_.get_legend_handles_labels()[1] assert labels.count("Perfectly calibrated") == 1 @pytest.mark.parametrize("dtype_y_str", [str, object]) def test_calibration_curve_pos_label_error_str(dtype_y_str): """Check error message when a `pos_label` is not specified with `str` targets.""" rng = np.random.RandomState(42) y1 = np.array(["spam"] * 3 + ["eggs"] * 2, dtype=dtype_y_str) y2 = rng.randint(0, 2, size=y1.size) err_msg = ( "y_true takes value in {'eggs', 'spam'} and pos_label is not " "specified: either make y_true take value in {0, 1} or {-1, 1} or " "pass pos_label explicitly" ) with pytest.raises(ValueError, match=err_msg): calibration_curve(y1, y2) @pytest.mark.parametrize("dtype_y_str", [str, object]) def test_calibration_curve_pos_label(dtype_y_str): """Check the behaviour when passing explicitly `pos_label`.""" y_true = np.array([0, 0, 0, 1, 1, 1, 1, 1, 1]) classes = np.array(["spam", "egg"], dtype=dtype_y_str) y_true_str = classes[y_true] y_pred = np.array([0.1, 0.2, 0.3, 0.4, 0.65, 0.7, 0.8, 0.9, 1.0]) # default case prob_true, _ = calibration_curve(y_true, y_pred, n_bins=4) assert_allclose(prob_true, [0, 0.5, 1, 1]) # if `y_true` contains `str`, then `pos_label` is required prob_true, _ = calibration_curve(y_true_str, y_pred, n_bins=4, pos_label="egg") assert_allclose(prob_true, [0, 0.5, 1, 1]) prob_true, _ = calibration_curve(y_true, 1 - y_pred, n_bins=4, pos_label=0) assert_allclose(prob_true, [0, 0, 0.5, 1]) prob_true, _ = calibration_curve(y_true_str, 1 - y_pred, n_bins=4, pos_label="spam") assert_allclose(prob_true, [0, 0, 0.5, 1]) @pytest.mark.parametrize("pos_label, expected_pos_label", [(None, 1), (0, 0), (1, 1)]) def test_calibration_display_pos_label( pyplot, iris_data_binary, pos_label, expected_pos_label ): """Check the behaviour of `pos_label` in the `CalibrationDisplay`.""" X, y = iris_data_binary lr = LogisticRegression().fit(X, y) viz = CalibrationDisplay.from_estimator(lr, X, y, pos_label=pos_label) y_prob = lr.predict_proba(X)[:, expected_pos_label] prob_true, prob_pred = calibration_curve(y, y_prob, pos_label=pos_label) assert_allclose(viz.prob_true, prob_true) assert_allclose(viz.prob_pred, prob_pred) assert_allclose(viz.y_prob, y_prob) assert ( viz.ax_.get_xlabel() == f"Mean predicted probability (Positive class: {expected_pos_label})" ) assert ( viz.ax_.get_ylabel() == f"Fraction of positives (Positive class: {expected_pos_label})" ) expected_legend_labels = [lr.__class__.__name__, "Perfectly calibrated"] legend_labels = viz.ax_.get_legend().get_texts() assert len(legend_labels) == len(expected_legend_labels) for labels in legend_labels: assert labels.get_text() in expected_legend_labels @pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) @pytest.mark.parametrize("ensemble", [True, False]) def test_calibrated_classifier_cv_double_sample_weights_equivalence(method, ensemble): """Check that passing repeating twice the dataset `X` is equivalent to passing a `sample_weight` with a factor 2.""" X, y = load_iris(return_X_y=True) # Scale the data to avoid any convergence issue X = StandardScaler().fit_transform(X) # Only use 2 classes X, y = X[:100], y[:100] sample_weight = np.ones_like(y) * 2 # Interlace the data such that a 2-fold cross-validation will be equivalent # to using the original dataset with a sample weights of 2 X_twice = np.zeros((X.shape[0] * 2, X.shape[1]), dtype=X.dtype) X_twice[::2, :] = X X_twice[1::2, :] = X y_twice = np.zeros(y.shape[0] * 2, dtype=y.dtype) y_twice[::2] = y y_twice[1::2] = y estimator = LogisticRegression() calibrated_clf_without_weights = CalibratedClassifierCV( estimator, method=method, ensemble=ensemble, cv=2, ) calibrated_clf_with_weights = clone(calibrated_clf_without_weights) calibrated_clf_with_weights.fit(X, y, sample_weight=sample_weight) calibrated_clf_without_weights.fit(X_twice, y_twice) # Check that the underlying fitted estimators have the same coefficients for est_with_weights, est_without_weights in zip( calibrated_clf_with_weights.calibrated_classifiers_, calibrated_clf_without_weights.calibrated_classifiers_, ): assert_allclose( est_with_weights.estimator.coef_, est_without_weights.estimator.coef_, ) # Check that the predictions are the same y_pred_with_weights = calibrated_clf_with_weights.predict_proba(X) y_pred_without_weights = calibrated_clf_without_weights.predict_proba(X) assert_allclose(y_pred_with_weights, y_pred_without_weights) @pytest.mark.parametrize("fit_params_type", ["list", "array"]) def test_calibration_with_fit_params(fit_params_type, data): """Tests that fit_params are passed to the underlying base estimator. Non-regression test for: https://github.com/scikit-learn/scikit-learn/issues/12384 """ X, y = data fit_params = { "a": _convert_container(y, fit_params_type), "b": _convert_container(y, fit_params_type), } clf = CheckingClassifier(expected_fit_params=["a", "b"]) pc_clf = CalibratedClassifierCV(clf) pc_clf.fit(X, y, **fit_params) @pytest.mark.parametrize( "sample_weight", [ [1.0] * N_SAMPLES, np.ones(N_SAMPLES), ], ) def test_calibration_with_sample_weight_estimator(sample_weight, data): """Tests that sample_weight is passed to the underlying base estimator. """ X, y = data clf = CheckingClassifier(expected_sample_weight=True) pc_clf = CalibratedClassifierCV(clf) pc_clf.fit(X, y, sample_weight=sample_weight) def test_calibration_without_sample_weight_estimator(data): """Check that even if the estimator doesn't support sample_weight, fitting with sample_weight still works. There should be a warning, since the sample_weight is not passed on to the estimator. """ X, y = data sample_weight = np.ones_like(y) class ClfWithoutSampleWeight(CheckingClassifier): def fit(self, X, y, **fit_params): assert "sample_weight" not in fit_params return super().fit(X, y, **fit_params) clf = ClfWithoutSampleWeight() pc_clf = CalibratedClassifierCV(clf) with pytest.warns(UserWarning): pc_clf.fit(X, y, sample_weight=sample_weight) @pytest.mark.parametrize("method", ["sigmoid", "isotonic"]) @pytest.mark.parametrize("ensemble", [True, False]) def test_calibrated_classifier_cv_zeros_sample_weights_equivalence(method, ensemble): """Check that passing removing some sample from the dataset `X` is equivalent to passing a `sample_weight` with a factor 0.""" X, y = load_iris(return_X_y=True) # Scale the data to avoid any convergence issue X = StandardScaler().fit_transform(X) # Only use 2 classes and select samples such that 2-fold cross-validation # split will lead to an equivalence with a `sample_weight` of 0 X = np.vstack((X[:40], X[50:90])) y = np.hstack((y[:40], y[50:90])) sample_weight = np.zeros_like(y) sample_weight[::2] = 1 estimator = LogisticRegression() calibrated_clf_without_weights = CalibratedClassifierCV( estimator, method=method, ensemble=ensemble, cv=2, ) calibrated_clf_with_weights = clone(calibrated_clf_without_weights) calibrated_clf_with_weights.fit(X, y, sample_weight=sample_weight) calibrated_clf_without_weights.fit(X[::2], y[::2]) # Check that the underlying fitted estimators have the same coefficients for est_with_weights, est_without_weights in zip( calibrated_clf_with_weights.calibrated_classifiers_, calibrated_clf_without_weights.calibrated_classifiers_, ): assert_allclose( est_with_weights.estimator.coef_, est_without_weights.estimator.coef_, ) # Check that the predictions are the same y_pred_with_weights = calibrated_clf_with_weights.predict_proba(X) y_pred_without_weights = calibrated_clf_without_weights.predict_proba(X) assert_allclose(y_pred_with_weights, y_pred_without_weights) def test_calibration_with_non_sample_aligned_fit_param(data): """Check that CalibratedClassifierCV does not enforce sample alignment for fit parameters.""" class TestClassifier(LogisticRegression): def fit(self, X, y, sample_weight=None, fit_param=None): assert fit_param is not None return super().fit(X, y, sample_weight=sample_weight) CalibratedClassifierCV(estimator=TestClassifier()).fit( *data, fit_param=np.ones(len(data[1]) + 1) ) def test_calibrated_classifier_cv_works_with_large_confidence_scores( global_random_seed, ): """Test that :class:`CalibratedClassifierCV` works with large confidence scores when using the `sigmoid` method, particularly with the :class:`SGDClassifier`. Non-regression test for issue #26766. """ prob = 0.67 n = 1000 random_noise = np.random.default_rng(global_random_seed).normal(size=n) y = np.array([1] * int(n * prob) + [0] * (n - int(n * prob))) X = 1e5 * y.reshape((-1, 1)) + random_noise # Check that the decision function of SGDClassifier produces predicted # values that are quite large, for the data under consideration. cv = check_cv(cv=None, y=y, classifier=True) indices = cv.split(X, y) for train, test in indices: X_train, y_train = X[train], y[train] X_test = X[test] sgd_clf = SGDClassifier(loss="squared_hinge", random_state=global_random_seed) sgd_clf.fit(X_train, y_train) predictions = sgd_clf.decision_function(X_test) assert (predictions > 1e4).any() # Compare the CalibratedClassifierCV using the sigmoid method with the # CalibratedClassifierCV using the isotonic method. The isotonic method # is used for comparison because it is numerically stable. clf_sigmoid = CalibratedClassifierCV( SGDClassifier(loss="squared_hinge", random_state=global_random_seed), method="sigmoid", ) score_sigmoid = cross_val_score(clf_sigmoid, X, y, scoring="roc_auc") # The isotonic method is used for comparison because it is numerically # stable. clf_isotonic = CalibratedClassifierCV( SGDClassifier(loss="squared_hinge", random_state=global_random_seed), method="isotonic", ) score_isotonic = cross_val_score(clf_isotonic, X, y, scoring="roc_auc") # The AUC score should be the same because it is invariant under # strictly monotonic conditions assert_allclose(score_sigmoid, score_isotonic) def test_sigmoid_calibration_max_abs_prediction_threshold(global_random_seed): random_state = np.random.RandomState(seed=global_random_seed) n = 100 y = random_state.randint(0, 2, size=n) # Check that for small enough predictions ranging from -2 to 2, the # threshold value has no impact on the outcome predictions_small = random_state.uniform(low=-2, high=2, size=100) # Using a threshold lower than the maximum absolute value of the # predictions enables internal re-scaling by max(abs(predictions_small)). threshold_1 = 0.1 a1, b1 = _sigmoid_calibration( predictions=predictions_small, y=y, max_abs_prediction_threshold=threshold_1, ) # Using a larger threshold disables rescaling. threshold_2 = 10 a2, b2 = _sigmoid_calibration( predictions=predictions_small, y=y, max_abs_prediction_threshold=threshold_2, ) # Using default threshold of 30 also disables the scaling. a3, b3 = _sigmoid_calibration( predictions=predictions_small, y=y, ) # Depends on the tolerance of the underlying quasy-newton solver which is # not too strict by default. atol = 1e-6 assert_allclose(a1, a2, atol=atol) assert_allclose(a2, a3, atol=atol) assert_allclose(b1, b2, atol=atol) assert_allclose(b2, b3, atol=atol) def test_float32_predict_proba(data): """Check that CalibratedClassifierCV works with float32 predict proba. Non-regression test for gh-28245. """ class DummyClassifer32(DummyClassifier): def predict_proba(self, X): return super().predict_proba(X).astype(np.float32) model = DummyClassifer32() calibrator = CalibratedClassifierCV(model) # Does not raise an error calibrator.fit(*data) def test_error_less_class_samples_than_folds(): """Check that CalibratedClassifierCV works with string targets. non-regression test for issue #28841. """ X = np.random.normal(size=(20, 3)) y = ["a"] * 10 + ["b"] * 10 CalibratedClassifierCV(cv=3).fit(X, y)