-
-
Notifications
You must be signed in to change notification settings - Fork 26.5k
[MRG+1] Fix cross_val_predict behavior for binary classification in decision_function (Fixes #9589) #9593
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[MRG+1] Fix cross_val_predict behavior for binary classification in decision_function (Fixes #9589) #9593
Changes from all commits
39d410d
fc04cee
9ea3baa
115e766
b012790
a0acb06
1938da5
08bc4f9
e05fc2e
bfee769
975b5e0
c1d0ef4
a9aa2b2
f1ebbd2
b2da0af
dd29e0f
e63b6bc
c87490b
720ec6e
624ca2c
a0c7f67
c3984b1
ed3b6a3
331d18f
8a71ef3
4c0bc47
de3c3bd
b3b350a
af5f9ad
cee4054
7b65450
9f97b89
d695b95
dc42b27
564bf5e
3b5cb5e
e5013bd
75a0c59
042e45f
bc405c8
e3b963d
6639b79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -625,6 +625,15 @@ def cross_val_predict(estimator, X, y=None, groups=None, cv=None, n_jobs=1, | |
| predictions : ndarray | ||
| This is the result of calling ``method`` | ||
|
|
||
| Notes | ||
| ----- | ||
| In the case that one or more classes are absent in a training portion, a | ||
| default score needs to be assigned to all instances for that class if | ||
| ``method`` produces columns per class, as in {'decision_function', | ||
| 'predict_proba', 'predict_log_proba'}. For ``predict_proba`` this value is | ||
| 0. In order to ensure finite output, we approximate negative infinity by | ||
| the minimum finite float value for the dtype in other cases. | ||
|
|
||
| Examples | ||
| -------- | ||
| >>> from sklearn import datasets, linear_model | ||
|
|
@@ -727,12 +736,49 @@ def _fit_and_predict(estimator, X, y, train, test, verbose, fit_params, | |
| predictions = func(X_test) | ||
| if method in ['decision_function', 'predict_proba', 'predict_log_proba']: | ||
| n_classes = len(set(y)) | ||
| predictions_ = np.zeros((_num_samples(X_test), n_classes)) | ||
| if method == 'decision_function' and len(estimator.classes_) == 2: | ||
| predictions_[:, estimator.classes_[-1]] = predictions | ||
| else: | ||
| predictions_[:, estimator.classes_] = predictions | ||
| predictions = predictions_ | ||
| if n_classes != len(estimator.classes_): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we raise a warning in this case?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This implicitly checks whether
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see where the The test for this case is in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @amueller I agree that a warning should be raised in this case. I don't follow your statement about For your third point, what I've fixed here is the following regression of Code to test: from sklearn.datasets import load_digits
from sklearn.svm import SVC
from sklearn.model_selection import cross_val_predict
X, y = load_digits(return_X_y=True)
cross_val_predict(SVC(kernel='linear', decision_function_shape='ovo'), X, y, method='decision_function').shapeExpected Results Actual Results I've also included the test for the Basically, the logic is that if the folds are properly stratified ( I also acknowledge that the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That was the case I meant. I just wanted a better error in this case, and I want a test for that error. |
||
| recommendation = ( | ||
| 'To fix this, use a cross-validation ' | ||
| 'technique resulting in properly ' | ||
| 'stratified folds') | ||
| warnings.warn('Number of classes in training fold ({}) does ' | ||
| 'not match total number of classes ({}). ' | ||
| 'Results may not be appropriate for your use case. ' | ||
| '{}'.format(len(estimator.classes_), | ||
| n_classes, recommendation), | ||
| RuntimeWarning) | ||
| if method == 'decision_function': | ||
| if (predictions.ndim == 2 and | ||
| predictions.shape[1] != len(estimator.classes_)): | ||
| # This handles the case when the shape of predictions | ||
| # does not match the number of classes used to train | ||
| # it with. This case is found when sklearn.svm.SVC is | ||
| # set to `decision_function_shape='ovo'`. | ||
| raise ValueError('Output shape {} of {} does not match ' | ||
| 'number of classes ({}) in fold. ' | ||
| 'Irregular decision_function outputs ' | ||
| 'are not currently supported by ' | ||
| 'cross_val_predict'.format( | ||
| predictions.shape, method, | ||
| len(estimator.classes_), | ||
| recommendation)) | ||
| if len(estimator.classes_) <= 2: | ||
| # In this special case, `predictions` contains a 1D array. | ||
| raise ValueError('Only {} class/es in training fold, this ' | ||
| 'is not supported for decision_function ' | ||
| 'with imbalanced folds. {}'.format( | ||
| len(estimator.classes_), | ||
| recommendation)) | ||
|
|
||
| float_min = np.finfo(predictions.dtype).min | ||
| default_values = {'decision_function': float_min, | ||
| 'predict_log_proba': float_min, | ||
| 'predict_proba': 0} | ||
| predictions_for_all_classes = np.full((_num_samples(predictions), | ||
| n_classes), | ||
| default_values[method]) | ||
| predictions_for_all_classes[:, estimator.classes_] = predictions | ||
| predictions = predictions_for_all_classes | ||
| return predictions, test | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ | |
| from sklearn.utils.testing import assert_array_almost_equal | ||
| from sklearn.utils.testing import assert_array_equal | ||
| from sklearn.utils.testing import assert_warns | ||
| from sklearn.utils.testing import assert_warns_message | ||
| from sklearn.utils.mocking import CheckingClassifier, MockDataFrame | ||
|
|
||
| from sklearn.model_selection import cross_val_score | ||
|
|
@@ -42,6 +43,7 @@ | |
| from sklearn.datasets import make_regression | ||
| from sklearn.datasets import load_boston | ||
| from sklearn.datasets import load_iris | ||
| from sklearn.datasets import load_digits | ||
| from sklearn.metrics import explained_variance_score | ||
| from sklearn.metrics import make_scorer | ||
| from sklearn.metrics import accuracy_score | ||
|
|
@@ -52,7 +54,7 @@ | |
| from sklearn.metrics.scorer import check_scoring | ||
|
|
||
| from sklearn.linear_model import Ridge, LogisticRegression, SGDClassifier | ||
| from sklearn.linear_model import PassiveAggressiveClassifier | ||
| from sklearn.linear_model import PassiveAggressiveClassifier, RidgeClassifier | ||
| from sklearn.neighbors import KNeighborsClassifier | ||
| from sklearn.svm import SVC | ||
| from sklearn.cluster import KMeans | ||
|
|
@@ -776,6 +778,89 @@ def split(self, X, y=None, groups=None): | |
|
|
||
| assert_raises(ValueError, cross_val_predict, est, X, y, cv=BadCV()) | ||
|
|
||
| X, y = load_iris(return_X_y=True) | ||
|
|
||
| warning_message = ('Number of classes in training fold (2) does ' | ||
| 'not match total number of classes (3). ' | ||
| 'Results may not be appropriate for your use case.') | ||
| assert_warns_message(RuntimeWarning, warning_message, | ||
| cross_val_predict, LogisticRegression(), | ||
| X, y, method='predict_proba', cv=KFold(2)) | ||
|
|
||
|
|
||
| def test_cross_val_predict_decision_function_shape(): | ||
| X, y = make_classification(n_classes=2, n_samples=50, random_state=0) | ||
|
|
||
| preds = cross_val_predict(LogisticRegression(), X, y, | ||
| method='decision_function') | ||
| assert_equal(preds.shape, (50,)) | ||
|
|
||
| X, y = load_iris(return_X_y=True) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason you can not use make_classification with n_classes=3?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No reason, except that it flows naturally into the next test where I need a balanced binary classification dataset sorted by label. Should I just use a
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I saw that after I commented, I think it's fine, leave it as it is. |
||
|
|
||
| preds = cross_val_predict(LogisticRegression(), X, y, | ||
| method='decision_function') | ||
| assert_equal(preds.shape, (150, 3)) | ||
|
|
||
| # This specifically tests imbalanced splits for binary | ||
| # classification with decision_function. This is only | ||
| # applicable to classifiers that can be fit on a single | ||
| # class. | ||
| X = X[:100] | ||
| y = y[:100] | ||
| assert_raise_message(ValueError, | ||
| 'Only 1 class/es in training fold, this' | ||
| ' is not supported for decision_function' | ||
| ' with imbalanced folds. To fix ' | ||
| 'this, use a cross-validation technique ' | ||
| 'resulting in properly stratified folds', | ||
| cross_val_predict, RidgeClassifier(), X, y, | ||
| method='decision_function', cv=KFold(2)) | ||
|
|
||
| X, y = load_digits(return_X_y=True) | ||
| est = SVC(kernel='linear', decision_function_shape='ovo') | ||
|
|
||
| preds = cross_val_predict(est, | ||
| X, y, | ||
| method='decision_function') | ||
| assert_equal(preds.shape, (1797, 45)) | ||
|
|
||
| ind = np.argsort(y) | ||
| X, y = X[ind], y[ind] | ||
| assert_raises_regex(ValueError, | ||
| 'Output shape \(599L?, 21L?\) of decision_function ' | ||
| 'does not match number of classes \(7\) in fold. ' | ||
| 'Irregular decision_function .*', | ||
| cross_val_predict, est, X, y, | ||
| cv=KFold(n_splits=3), method='decision_function') | ||
|
|
||
|
|
||
| def test_cross_val_predict_predict_proba_shape(): | ||
| X, y = make_classification(n_classes=2, n_samples=50, random_state=0) | ||
|
|
||
| preds = cross_val_predict(LogisticRegression(), X, y, | ||
| method='predict_proba') | ||
| assert_equal(preds.shape, (50, 2)) | ||
|
|
||
| X, y = load_iris(return_X_y=True) | ||
|
|
||
| preds = cross_val_predict(LogisticRegression(), X, y, | ||
| method='predict_proba') | ||
| assert_equal(preds.shape, (150, 3)) | ||
|
|
||
|
|
||
| def test_cross_val_predict_predict_log_proba_shape(): | ||
| X, y = make_classification(n_classes=2, n_samples=50, random_state=0) | ||
|
|
||
| preds = cross_val_predict(LogisticRegression(), X, y, | ||
| method='predict_log_proba') | ||
| assert_equal(preds.shape, (50, 2)) | ||
|
|
||
| X, y = load_iris(return_X_y=True) | ||
|
|
||
| preds = cross_val_predict(LogisticRegression(), X, y, | ||
| method='predict_log_proba') | ||
| assert_equal(preds.shape, (150, 3)) | ||
|
|
||
|
|
||
| def test_cross_val_predict_input_types(): | ||
| iris = load_iris() | ||
|
|
@@ -1217,21 +1302,22 @@ def get_expected_predictions(X, y, cv, classes, est, method): | |
| est.fit(X[train], y[train]) | ||
| expected_predictions_ = func(X[test]) | ||
| # To avoid 2 dimensional indexing | ||
| exp_pred_test = np.zeros((len(test), classes)) | ||
| if method is 'decision_function' and len(est.classes_) == 2: | ||
| exp_pred_test[:, est.classes_[-1]] = expected_predictions_ | ||
| if method is 'predict_proba': | ||
| exp_pred_test = np.zeros((len(test), classes)) | ||
| else: | ||
| exp_pred_test[:, est.classes_] = expected_predictions_ | ||
| exp_pred_test = np.full((len(test), classes), | ||
| np.finfo(expected_predictions.dtype).min) | ||
| exp_pred_test[:, est.classes_] = expected_predictions_ | ||
| expected_predictions[test] = exp_pred_test | ||
|
|
||
| return expected_predictions | ||
|
|
||
|
|
||
| def test_cross_val_predict_class_subset(): | ||
|
|
||
| X = np.arange(8).reshape(4, 2) | ||
| y = np.array([0, 0, 1, 2]) | ||
| classes = 3 | ||
| X = np.arange(200).reshape(100, 2) | ||
| y = np.array([x//10 for x in range(100)]) | ||
| classes = 10 | ||
|
|
||
| kfold3 = KFold(n_splits=3) | ||
| kfold4 = KFold(n_splits=4) | ||
|
|
@@ -1259,7 +1345,7 @@ def test_cross_val_predict_class_subset(): | |
| assert_array_almost_equal(expected_predictions, predictions) | ||
|
|
||
| # Testing unordered labels | ||
| y = [1, 1, -4, 6] | ||
| y = shuffle(np.repeat(range(10), 10), random_state=0) | ||
| predictions = cross_val_predict(est, X, y, method=method, | ||
| cv=kfold3) | ||
| y = le.fit_transform(y) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this behaviour and note is sufficient for a first release.