Aluode/PerceptionLabPortable
0
1import numpy as np
2import pytest
3
4from sklearn import config_context
5from sklearn.base import clone
6from sklearn.datasets import (
7 load_breast_cancer,
8 load_iris,
9 make_classification,
10 make_multilabel_classification,
11)
12from sklearn.dummy import DummyClassifier
13from sklearn.ensemble import GradientBoostingClassifier
14from sklearn.exceptions import NotFittedError
15from sklearn.linear_model import LogisticRegression
16from sklearn.metrics import (
17 balanced_accuracy_score,
18 f1_score,
19 fbeta_score,
20 make_scorer,
21)
22from sklearn.metrics._scorer import _CurveScorer
23from sklearn.model_selection import (
24 FixedThresholdClassifier,
25 StratifiedShuffleSplit,
26 TunedThresholdClassifierCV,
27)
28from sklearn.model_selection._classification_threshold import (
29 _fit_and_score_over_thresholds,
30)
31from sklearn.pipeline import make_pipeline
32from sklearn.preprocessing import StandardScaler
33from sklearn.svm import SVC
34from sklearn.tree import DecisionTreeClassifier
35from sklearn.utils._mocking import CheckingClassifier
36from sklearn.utils._testing import (
37 _convert_container,
38 assert_allclose,
39 assert_array_equal,
40)
41
42
43def test_fit_and_score_over_thresholds_curve_scorers():
44 """Check that `_fit_and_score_over_thresholds` returns thresholds in ascending order
45 for the different accepted curve scorers."""
46 X, y = make_classification(n_samples=100, random_state=0)
47 train_idx, val_idx = np.arange(50), np.arange(50, 100)
48 classifier = LogisticRegression()
49
50 curve_scorer = _CurveScorer(
51 score_func=balanced_accuracy_score,
52 sign=1,
53 response_method="predict_proba",
54 thresholds=10,
55 kwargs={},
56 )
57 scores, thresholds = _fit_and_score_over_thresholds(
58 classifier,
59 X,
60 y,
61 fit_params={},
62 train_idx=train_idx,
63 val_idx=val_idx,
64 curve_scorer=curve_scorer,
65 score_params={},
66 )
67
68 assert np.all(thresholds[:-1] <= thresholds[1:])
69 assert isinstance(scores, np.ndarray)
70 assert np.logical_and(scores >= 0, scores <= 1).all()
71
72
73def test_fit_and_score_over_thresholds_prefit():
74 """Check the behaviour with a prefit classifier."""
75 X, y = make_classification(n_samples=100, random_state=0)
76
77 # `train_idx is None` to indicate that the classifier is prefit
78 train_idx, val_idx = None, np.arange(50, 100)
79 classifier = DecisionTreeClassifier(random_state=0).fit(X, y)
80 # make sure that the classifier memorized the full dataset such that
81 # we get perfect predictions and thus match the expected score
82 assert classifier.score(X[val_idx], y[val_idx]) == pytest.approx(1.0)
83
84 curve_scorer = _CurveScorer(
85 score_func=balanced_accuracy_score,
86 sign=1,
87 response_method="predict_proba",
88 thresholds=2,
89 kwargs={},
90 )
91 scores, thresholds = _fit_and_score_over_thresholds(
92 classifier,
93 X,
94 y,
95 fit_params={},
96 train_idx=train_idx,
97 val_idx=val_idx,
98 curve_scorer=curve_scorer,
99 score_params={},
100 )
101 assert np.all(thresholds[:-1] <= thresholds[1:])
102 assert_allclose(scores, [0.5, 1.0])
103
104
105@config_context(enable_metadata_routing=True)
106def test_fit_and_score_over_thresholds_sample_weight():
107 """Check that we dispatch the sample-weight to fit and score the classifier."""
108 X, y = load_iris(return_X_y=True)
109 X, y = X[:100], y[:100] # only 2 classes
110
111 # create a dataset and repeat twice the sample of class #0
112 X_repeated, y_repeated = np.vstack([X, X[y == 0]]), np.hstack([y, y[y == 0]])
113 # create a sample weight vector that is equivalent to the repeated dataset
114 sample_weight = np.ones_like(y)
115 sample_weight[:50] *= 2
116
117 classifier = LogisticRegression()
118 train_repeated_idx = np.arange(X_repeated.shape[0])
119 val_repeated_idx = np.arange(X_repeated.shape[0])
120 curve_scorer = _CurveScorer(
121 score_func=balanced_accuracy_score,
122 sign=1,
123 response_method="predict_proba",
124 thresholds=10,
125 kwargs={},
126 )
127 scores_repeated, thresholds_repeated = _fit_and_score_over_thresholds(
128 classifier,
129 X_repeated,
130 y_repeated,
131 fit_params={},
132 train_idx=train_repeated_idx,
133 val_idx=val_repeated_idx,
134 curve_scorer=curve_scorer,
135 score_params={},
136 )
137
138 train_idx, val_idx = np.arange(X.shape[0]), np.arange(X.shape[0])
139 scores, thresholds = _fit_and_score_over_thresholds(
140 classifier.set_fit_request(sample_weight=True),
141 X,
142 y,
143 fit_params={"sample_weight": sample_weight},
144 train_idx=train_idx,
145 val_idx=val_idx,
146 curve_scorer=curve_scorer.set_score_request(sample_weight=True),
147 score_params={"sample_weight": sample_weight},
148 )
149
150 assert_allclose(thresholds_repeated, thresholds)
151 assert_allclose(scores_repeated, scores)
152
153
154@pytest.mark.parametrize("fit_params_type", ["list", "array"])
155@config_context(enable_metadata_routing=True)
156def test_fit_and_score_over_thresholds_fit_params(fit_params_type):
157 """Check that we pass `fit_params` to the classifier when calling `fit`."""
158 X, y = make_classification(n_samples=100, random_state=0)
159 fit_params = {
160 "a": _convert_container(y, fit_params_type),
161 "b": _convert_container(y, fit_params_type),
162 }
163
164 classifier = CheckingClassifier(expected_fit_params=["a", "b"], random_state=0)
165 classifier.set_fit_request(a=True, b=True)
166 train_idx, val_idx = np.arange(50), np.arange(50, 100)
167
168 curve_scorer = _CurveScorer(
169 score_func=balanced_accuracy_score,
170 sign=1,
171 response_method="predict_proba",
172 thresholds=10,
173 kwargs={},
174 )
175 _fit_and_score_over_thresholds(
176 classifier,
177 X,
178 y,
179 fit_params=fit_params,
180 train_idx=train_idx,
181 val_idx=val_idx,
182 curve_scorer=curve_scorer,
183 score_params={},
184 )
185
186
187@pytest.mark.parametrize(
188 "data",
189 [
190 make_classification(n_classes=3, n_clusters_per_class=1, random_state=0),
191 make_multilabel_classification(random_state=0),
192 ],
193)
194def test_tuned_threshold_classifier_no_binary(data):
195 """Check that we raise an informative error message for non-binary problem."""
196 err_msg = "Only binary classification is supported."
197 with pytest.raises(ValueError, match=err_msg):
198 TunedThresholdClassifierCV(LogisticRegression()).fit(*data)
199
200
201@pytest.mark.parametrize(
202 "params, err_type, err_msg",
203 [
204 (
205 {"cv": "prefit", "refit": True},
206 ValueError,
207 "When cv='prefit', refit cannot be True.",
208 ),
209 (
210 {"cv": 10, "refit": False},
211 ValueError,
212 "When cv has several folds, refit cannot be False.",
213 ),
214 (
215 {"cv": "prefit", "refit": False},
216 NotFittedError,
217 "`estimator` must be fitted.",
218 ),
219 ],
220)
221def test_tuned_threshold_classifier_conflict_cv_refit(params, err_type, err_msg):
222 """Check that we raise an informative error message when `cv` and `refit`
223 cannot be used together.
224 """
225 X, y = make_classification(n_samples=100, random_state=0)
226 with pytest.raises(err_type, match=err_msg):
227 TunedThresholdClassifierCV(LogisticRegression(), **params).fit(X, y)
228
229
230@pytest.mark.parametrize(
231 "estimator",
232 [LogisticRegression(), SVC(), GradientBoostingClassifier(n_estimators=4)],
233)
234@pytest.mark.parametrize(
235 "response_method", ["predict_proba", "predict_log_proba", "decision_function"]
236)
237@pytest.mark.parametrize(
238 "ThresholdClassifier", [FixedThresholdClassifier, TunedThresholdClassifierCV]
239)
240def test_threshold_classifier_estimator_response_methods(
241 ThresholdClassifier, estimator, response_method
242):
243 """Check that `TunedThresholdClassifierCV` exposes the same response methods as the
244 underlying estimator.
245 """
246 X, y = make_classification(n_samples=100, random_state=0)
247
248 model = ThresholdClassifier(estimator=estimator)
249 assert hasattr(model, response_method) == hasattr(estimator, response_method)
250
251 model.fit(X, y)
252 assert hasattr(model, response_method) == hasattr(estimator, response_method)
253
254 if hasattr(model, response_method):
255 y_pred_cutoff = getattr(model, response_method)(X)
256 y_pred_underlying_estimator = getattr(model.estimator_, response_method)(X)
257
258 assert_allclose(y_pred_cutoff, y_pred_underlying_estimator)
259
260
261@pytest.mark.parametrize(
262 "response_method", ["auto", "decision_function", "predict_proba"]
263)
264def test_tuned_threshold_classifier_without_constraint_value(response_method):
265 """Check that `TunedThresholdClassifierCV` is optimizing a given objective
266 metric."""
267 X, y = load_breast_cancer(return_X_y=True)
268 # remove feature to degrade performances
269 X = X[:, :5]
270
271 # make the problem completely imbalanced such that the balanced accuracy is low
272 indices_pos = np.flatnonzero(y == 1)
273 indices_pos = indices_pos[: indices_pos.size // 50]
274 indices_neg = np.flatnonzero(y == 0)
275
276 X = np.vstack([X[indices_neg], X[indices_pos]])
277 y = np.hstack([y[indices_neg], y[indices_pos]])
278
279 lr = make_pipeline(StandardScaler(), LogisticRegression()).fit(X, y)
280 thresholds = 100
281 model = TunedThresholdClassifierCV(
282 estimator=lr,
283 scoring="balanced_accuracy",
284 response_method=response_method,
285 thresholds=thresholds,
286 store_cv_results=True,
287 )
288 score_optimized = balanced_accuracy_score(y, model.fit(X, y).predict(X))
289 score_baseline = balanced_accuracy_score(y, lr.predict(X))
290 assert score_optimized > score_baseline
291 assert model.cv_results_["thresholds"].shape == (thresholds,)
292 assert model.cv_results_["scores"].shape == (thresholds,)
293
294
295def test_tuned_threshold_classifier_metric_with_parameter():
296 """Check that we can pass a metric with a parameter in addition check that
297 `f_beta` with `beta=1` is equivalent to `f1` and different from `f_beta` with
298 `beta=2`.
299 """
300 X, y = load_breast_cancer(return_X_y=True)
301 lr = make_pipeline(StandardScaler(), LogisticRegression()).fit(X, y)
302 model_fbeta_1 = TunedThresholdClassifierCV(
303 estimator=lr, scoring=make_scorer(fbeta_score, beta=1)
304 ).fit(X, y)
305 model_fbeta_2 = TunedThresholdClassifierCV(
306 estimator=lr, scoring=make_scorer(fbeta_score, beta=2)
307 ).fit(X, y)
308 model_f1 = TunedThresholdClassifierCV(
309 estimator=lr, scoring=make_scorer(f1_score)
310 ).fit(X, y)
311
312 assert model_fbeta_1.best_threshold_ == pytest.approx(model_f1.best_threshold_)
313 assert model_fbeta_1.best_threshold_ != pytest.approx(model_fbeta_2.best_threshold_)
314
315
316@pytest.mark.parametrize(
317 "response_method", ["auto", "decision_function", "predict_proba"]
318)
319@pytest.mark.parametrize(
320 "metric",
321 [
322 make_scorer(balanced_accuracy_score),
323 make_scorer(f1_score, pos_label="cancer"),
324 ],
325)
326def test_tuned_threshold_classifier_with_string_targets(response_method, metric):
327 """Check that targets represented by str are properly managed.
328 Also, check with several metrics to be sure that `pos_label` is properly
329 dispatched.
330 """
331 X, y = load_breast_cancer(return_X_y=True)
332 # Encode numeric targets by meaningful strings. We purposely designed the class
333 # names such that the `pos_label` is the first alphabetically sorted class and thus
334 # encoded as 0.
335 classes = np.array(["cancer", "healthy"], dtype=object)
336 y = classes[y]
337 model = TunedThresholdClassifierCV(
338 estimator=make_pipeline(StandardScaler(), LogisticRegression()),
339 scoring=metric,
340 response_method=response_method,
341 thresholds=100,
342 ).fit(X, y)
343 assert_array_equal(model.classes_, np.sort(classes))
344 y_pred = model.predict(X)
345 assert_array_equal(np.unique(y_pred), np.sort(classes))
346
347
348@pytest.mark.parametrize("with_sample_weight", [True, False])
349@config_context(enable_metadata_routing=True)
350def test_tuned_threshold_classifier_refit(with_sample_weight, global_random_seed):
351 """Check the behaviour of the `refit` parameter."""
352 rng = np.random.RandomState(global_random_seed)
353 X, y = make_classification(n_samples=100, random_state=0)
354 if with_sample_weight:
355 sample_weight = rng.randn(X.shape[0])
356 sample_weight = np.abs(sample_weight, out=sample_weight)
357 else:
358 sample_weight = None
359
360 # check that `estimator_` if fitted on the full dataset when `refit=True`
361 estimator = LogisticRegression().set_fit_request(sample_weight=True)
362 model = TunedThresholdClassifierCV(estimator, refit=True).fit(
363 X, y, sample_weight=sample_weight
364 )
365
366 assert model.estimator_ is not estimator
367 estimator.fit(X, y, sample_weight=sample_weight)
368 assert_allclose(model.estimator_.coef_, estimator.coef_)
369 assert_allclose(model.estimator_.intercept_, estimator.intercept_)
370
371 # check that `estimator_` was not altered when `refit=False` and `cv="prefit"`
372 estimator = LogisticRegression().set_fit_request(sample_weight=True)
373 estimator.fit(X, y, sample_weight=sample_weight)
374 coef = estimator.coef_.copy()
375 model = TunedThresholdClassifierCV(estimator, cv="prefit", refit=False).fit(
376 X, y, sample_weight=sample_weight
377 )
378
379 assert model.estimator_ is estimator
380 assert_allclose(model.estimator_.coef_, coef)
381
382 # check that we train `estimator_` on the training split of a given cross-validation
383 estimator = LogisticRegression().set_fit_request(sample_weight=True)
384 cv = [
385 (np.arange(50), np.arange(50, 100)),
386 ] # single split
387 model = TunedThresholdClassifierCV(estimator, cv=cv, refit=False).fit(
388 X, y, sample_weight=sample_weight
389 )
390
391 assert model.estimator_ is not estimator
392 if with_sample_weight:
393 sw_train = sample_weight[cv[0][0]]
394 else:
395 sw_train = None
396 estimator.fit(X[cv[0][0]], y[cv[0][0]], sample_weight=sw_train)
397 assert_allclose(model.estimator_.coef_, estimator.coef_)
398
399
400@pytest.mark.parametrize("fit_params_type", ["list", "array"])
401@config_context(enable_metadata_routing=True)
402def test_tuned_threshold_classifier_fit_params(fit_params_type):
403 """Check that we pass `fit_params` to the classifier when calling `fit`."""
404 X, y = make_classification(n_samples=100, random_state=0)
405 fit_params = {
406 "a": _convert_container(y, fit_params_type),
407 "b": _convert_container(y, fit_params_type),
408 }
409
410 classifier = CheckingClassifier(expected_fit_params=["a", "b"], random_state=0)
411 classifier.set_fit_request(a=True, b=True)
412 model = TunedThresholdClassifierCV(classifier)
413 model.fit(X, y, **fit_params)
414
415
416@config_context(enable_metadata_routing=True)
417def test_tuned_threshold_classifier_cv_zeros_sample_weights_equivalence():
418 """Check that passing removing some sample from the dataset `X` is
419 equivalent to passing a `sample_weight` with a factor 0."""
420 X, y = load_iris(return_X_y=True)
421 # Scale the data to avoid any convergence issue
422 X = StandardScaler().fit_transform(X)
423 # Only use 2 classes and select samples such that 2-fold cross-validation
424 # split will lead to an equivalence with a `sample_weight` of 0
425 X = np.vstack((X[:40], X[50:90]))
426 y = np.hstack((y[:40], y[50:90]))
427 sample_weight = np.zeros_like(y)
428 sample_weight[::2] = 1
429
430 estimator = LogisticRegression().set_fit_request(sample_weight=True)
431 model_without_weights = TunedThresholdClassifierCV(estimator, cv=2)
432 model_with_weights = clone(model_without_weights)
433
434 model_with_weights.fit(X, y, sample_weight=sample_weight)
435 model_without_weights.fit(X[::2], y[::2])
436
437 assert_allclose(
438 model_with_weights.estimator_.coef_, model_without_weights.estimator_.coef_
439 )
440
441 y_pred_with_weights = model_with_weights.predict_proba(X)
442 y_pred_without_weights = model_without_weights.predict_proba(X)
443 assert_allclose(y_pred_with_weights, y_pred_without_weights)
444
445
446def test_tuned_threshold_classifier_thresholds_array():
447 """Check that we can pass an array to `thresholds` and it is used as candidate
448 threshold internally."""
449 X, y = make_classification(random_state=0)
450 estimator = LogisticRegression()
451 thresholds = np.linspace(0, 1, 11)
452 tuned_model = TunedThresholdClassifierCV(
453 estimator,
454 thresholds=thresholds,
455 response_method="predict_proba",
456 store_cv_results=True,
457 ).fit(X, y)
458 assert_allclose(tuned_model.cv_results_["thresholds"], thresholds)
459
460
461@pytest.mark.parametrize("store_cv_results", [True, False])
462def test_tuned_threshold_classifier_store_cv_results(store_cv_results):
463 """Check that if `cv_results_` exists depending on `store_cv_results`."""
464 X, y = make_classification(random_state=0)
465 estimator = LogisticRegression()
466 tuned_model = TunedThresholdClassifierCV(
467 estimator, store_cv_results=store_cv_results
468 ).fit(X, y)
469 if store_cv_results:
470 assert hasattr(tuned_model, "cv_results_")
471 else:
472 assert not hasattr(tuned_model, "cv_results_")
473
474
475def test_tuned_threshold_classifier_cv_float():
476 """Check the behaviour when `cv` is set to a float."""
477 X, y = make_classification(random_state=0)
478
479 # case where `refit=False` and cv is a float: the underlying estimator will be fit
480 # on the training set given by a ShuffleSplit. We check that we get the same model
481 # coefficients.
482 test_size = 0.3
483 estimator = LogisticRegression()
484 tuned_model = TunedThresholdClassifierCV(
485 estimator, cv=test_size, refit=False, random_state=0
486 ).fit(X, y)
487 tuned_model.fit(X, y)
488
489 cv = StratifiedShuffleSplit(n_splits=1, test_size=test_size, random_state=0)
490 train_idx, val_idx = next(cv.split(X, y))
491 cloned_estimator = clone(estimator).fit(X[train_idx], y[train_idx])
492
493 assert_allclose(tuned_model.estimator_.coef_, cloned_estimator.coef_)
494
495 # case where `refit=True`, then the underlying estimator is fitted on the full
496 # dataset.
497 tuned_model.set_params(refit=True).fit(X, y)
498 cloned_estimator = clone(estimator).fit(X, y)
499
500 assert_allclose(tuned_model.estimator_.coef_, cloned_estimator.coef_)
501
502
503def test_tuned_threshold_classifier_error_constant_predictor():
504 """Check that we raise a ValueError if the underlying classifier returns constant
505 probabilities such that we cannot find any threshold.
506 """
507 X, y = make_classification(random_state=0)
508 estimator = DummyClassifier(strategy="constant", constant=1)
509 tuned_model = TunedThresholdClassifierCV(estimator, response_method="predict_proba")
510 err_msg = "The provided estimator makes constant predictions"
511 with pytest.raises(ValueError, match=err_msg):
512 tuned_model.fit(X, y)
513
514
515@pytest.mark.parametrize(
516 "response_method", ["auto", "predict_proba", "decision_function"]
517)
518def test_fixed_threshold_classifier_equivalence_default(response_method):
519 """Check that `FixedThresholdClassifier` has the same behaviour as the vanilla
520 classifier.
521 """
522 X, y = make_classification(random_state=0)
523 classifier = LogisticRegression().fit(X, y)
524 classifier_default_threshold = FixedThresholdClassifier(
525 estimator=clone(classifier), response_method=response_method
526 )
527 classifier_default_threshold.fit(X, y)
528
529 # emulate the response method that should take into account the `pos_label`
530 if response_method in ("auto", "predict_proba"):
531 y_score = classifier_default_threshold.predict_proba(X)[:, 1]
532 threshold = 0.5
533 else: # response_method == "decision_function"
534 y_score = classifier_default_threshold.decision_function(X)
535 threshold = 0.0
536
537 y_pred_lr = (y_score >= threshold).astype(int)
538 assert_allclose(classifier_default_threshold.predict(X), y_pred_lr)
539
540
541@pytest.mark.parametrize(
542 "response_method, threshold", [("predict_proba", 0.7), ("decision_function", 2.0)]
543)
544@pytest.mark.parametrize("pos_label", [0, 1])
545def test_fixed_threshold_classifier(response_method, threshold, pos_label):
546 """Check that applying `predict` lead to the same prediction as applying the
547 threshold to the output of the response method.
548 """
549 X, y = make_classification(n_samples=50, random_state=0)
550 logistic_regression = LogisticRegression().fit(X, y)
551 model = FixedThresholdClassifier(
552 estimator=clone(logistic_regression),
553 threshold=threshold,
554 response_method=response_method,
555 pos_label=pos_label,
556 ).fit(X, y)
557
558 # check that the underlying estimator is the same
559 assert_allclose(model.estimator_.coef_, logistic_regression.coef_)
560
561 # emulate the response method that should take into account the `pos_label`
562 if response_method == "predict_proba":
563 y_score = model.predict_proba(X)[:, pos_label]
564 else: # response_method == "decision_function"
565 y_score = model.decision_function(X)
566 y_score = y_score if pos_label == 1 else -y_score
567
568 # create a mapping from boolean values to class labels
569 map_to_label = np.array([0, 1]) if pos_label == 1 else np.array([1, 0])
570 y_pred_lr = map_to_label[(y_score >= threshold).astype(int)]
571 assert_allclose(model.predict(X), y_pred_lr)
572
573 for method in ("predict_proba", "predict_log_proba", "decision_function"):
574 assert_allclose(
575 getattr(model, method)(X), getattr(logistic_regression, method)(X)
576 )
577 assert_allclose(
578 getattr(model.estimator_, method)(X),
579 getattr(logistic_regression, method)(X),
580 )
581
582
583@config_context(enable_metadata_routing=True)
584def test_fixed_threshold_classifier_metadata_routing():
585 """Check that everything works with metadata routing."""
586 X, y = make_classification(random_state=0)
587 sample_weight = np.ones_like(y)
588 sample_weight[::2] = 2
589 classifier = LogisticRegression().set_fit_request(sample_weight=True)
590 classifier.fit(X, y, sample_weight=sample_weight)
591 classifier_default_threshold = FixedThresholdClassifier(estimator=clone(classifier))
592 classifier_default_threshold.fit(X, y, sample_weight=sample_weight)
593 assert_allclose(classifier_default_threshold.estimator_.coef_, classifier.coef_)
594
595
596@pytest.mark.parametrize(
597 "method", ["predict_proba", "decision_function", "predict", "predict_log_proba"]
598)
599def test_fixed_threshold_classifier_fitted_estimator(method):
600 """Check that if the underlying estimator is already fitted, no fit is required."""
601 X, y = make_classification(random_state=0)
602 classifier = LogisticRegression().fit(X, y)
603 fixed_threshold_classifier = FixedThresholdClassifier(estimator=classifier)
604 # This should not raise an error
605 getattr(fixed_threshold_classifier, method)(X)
606
607
608def test_fixed_threshold_classifier_classes_():
609 """Check that the classes_ attribute is properly set."""
610 X, y = make_classification(random_state=0)
611 with pytest.raises(
612 AttributeError, match="The underlying estimator is not fitted yet."
613 ):
614 FixedThresholdClassifier(estimator=LogisticRegression()).classes_
615
616 classifier = LogisticRegression().fit(X, y)
617 fixed_threshold_classifier = FixedThresholdClassifier(estimator=classifier)
618 assert_array_equal(fixed_threshold_classifier.classes_, classifier.classes_)
619 