-
Notifications
You must be signed in to change notification settings - Fork 662
Implement multiclass classifiers with CuPy #8495
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
Changes from all commits
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 |
|---|---|---|
| @@ -1,17 +1,28 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| import warnings | ||
|
|
||
| import cupy as cp | ||
| import cupyx.scipy.sparse as cp_sp | ||
|
|
||
| from cuml.common.doc_utils import generate_docstring | ||
| from cuml.internals.base import Base | ||
| from cuml.internals.mixins import ClassifierMixin | ||
| from cuml.internals.outputs import exit_internal_context, mlfunc | ||
| from cuml.internals.validation import check_inputs | ||
| from cuml.internals.outputs import ClassLabels, mlfunc | ||
| from cuml.internals.validation import check_inputs, check_is_fitted | ||
|
|
||
|
|
||
| class _ConstantPredictor: | ||
| def predict(self, X): | ||
| return cp.zeros(X.shape[0], dtype=cp.int32) | ||
|
|
||
| def decision_function(self, X): | ||
| return cp.zeros(X.shape[0], dtype=cp.int32) | ||
|
Comment on lines
+20
to
+21
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. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Return float decision scores from
♻️ Proposed dtype fixdef decision_function(self, X): """Return a neutral decision score.""" - return cp.zeros(X.shape[0], dtype=cp.int32) + return cp.zeros(X.shape[0], dtype=cp.float64) 📝 Committable suggestion
Suggested change
def decision_function(self, X):
"""Return a neutral decision score."""
return cp.zeros(X.shape[0], dtype=cp.int32)
def decision_function(self, X):
"""Return a neutral decision score."""
return cp.zeros(X.shape[0], dtype=cp.float64)
🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class _BaseMulticlassClassifier(ClassifierMixin, Base): | ||
| """Shared base class for multiclass classifiers""" | ||
| """Shared base class for multiclass classifiers.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
|
|
@@ -29,41 +40,150 @@ def _get_param_names(cls): | |
|
|
||
| @property | ||
| def classes_(self): | ||
| return self.multiclass_estimator.classes_ | ||
| check_is_fitted(self) | ||
| return self._classes | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| @generate_docstring(y="dense_anydtype") | ||
| @mlfunc(set_input_type=True) | ||
| def fit(self, X, y) -> "_BaseMulticlassClassifier": | ||
| """ | ||
| Fit a multiclass classifier. | ||
| """ | ||
| import sklearn.multiclass | ||
| @staticmethod | ||
| def _predict_binary(est, X): | ||
| from sklearn.base import is_regressor | ||
|
|
||
| opts = { | ||
| "ovo": sklearn.multiclass.OneVsOneClassifier, | ||
| "ovr": sklearn.multiclass.OneVsRestClassifier, | ||
| } | ||
| if (cls := opts.get(self.strategy)) is None: | ||
| raise ValueError( | ||
| f"Expected `strategy` to be one of {list(opts)}, got {self.strategy}" | ||
| if is_regressor(est): | ||
| return cp.asarray(est.predict(X)).ravel() | ||
|
|
||
| try: | ||
| return cp.asarray(est.decision_function(X)).ravel() | ||
| except (AttributeError, NotImplementedError): | ||
| return cp.asarray(est.predict_proba(X))[:, 1] | ||
|
|
||
| @staticmethod | ||
| def _threshold_for_binary_predict(est): | ||
| from sklearn.base import is_classifier | ||
|
|
||
| if hasattr(est, "decision_function") and is_classifier(est): | ||
| return 0.0 | ||
| return 0.5 | ||
|
|
||
| def _fit_ovr(self, X, y): | ||
| from sklearn.base import clone | ||
|
|
||
| X, y, self._classes = check_inputs( | ||
| self, | ||
| X, | ||
| y, | ||
| dtype=("float32", "float64"), | ||
| y_dtype=None, | ||
| accept_sparse=True, | ||
| reset=True, | ||
| mem_type="device", | ||
| return_classes=True, | ||
| ) | ||
|
|
||
| n_cls = len(self._classes) | ||
|
|
||
| if n_cls == 1: | ||
| warnings.warn( | ||
| f"Label not {self._classes[0]} is present in all " | ||
| "training examples.", | ||
| stacklevel=2, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| X, y = check_inputs( | ||
| self.estimators_ = [_ConstantPredictor()] | ||
| return self | ||
|
|
||
| ids = (1,) if n_cls == 2 else range(n_cls) | ||
|
|
||
| self.estimators_ = [ | ||
| clone(self.estimator).fit(X, (y == i).astype(cp.int32)) | ||
| for i in ids | ||
| ] | ||
| return self | ||
|
|
||
| def _fit_ovo(self, X, y): | ||
| from sklearn.base import clone | ||
| from sklearn.utils import get_tags | ||
|
|
||
| X, y, self._classes = check_inputs( | ||
| self, | ||
| X, | ||
| y, | ||
| dtype=("float32", "float64"), | ||
| y_dtype=None, | ||
| accept_sparse=True, | ||
| reset=True, | ||
| mem_type="host", | ||
| mem_type="device", | ||
| return_classes=True, | ||
| ) | ||
|
|
||
| with exit_internal_context(): | ||
| wrapper = cls(self.estimator, n_jobs=None).fit(X, y) | ||
| n_cls = len(self._classes) | ||
|
|
||
| if n_cls == 1: | ||
| raise ValueError( | ||
| "OneVsOneClassifier can not be fit when only one class is " | ||
| "present." | ||
| ) | ||
|
|
||
| pw = get_tags(self.estimator).input_tags.pairwise | ||
|
|
||
| if cp_sp.issparse(X): | ||
| X = X.tocsr() | ||
|
|
||
| self.estimators_ = [] | ||
| self.pairwise_indices_ = [] if pw else None | ||
|
|
||
| for i in range(n_cls): | ||
| for j in range(i + 1, n_cls): | ||
| idx = cp.flatnonzero((y == i) | (y == j)) | ||
| Xi = X[idx] | ||
|
|
||
| if pw: | ||
| Xi = Xi[:, idx] | ||
| self.pairwise_indices_.append(idx) | ||
|
|
||
| yi = (y[idx] == j).astype(cp.int32) | ||
|
|
||
| self.estimators_.append(clone(self.estimator).fit(Xi, yi)) | ||
|
|
||
| self.multiclass_estimator = wrapper | ||
| return self | ||
|
|
||
| @staticmethod | ||
| def _ovr_decision_function(pred, score, n_cls): | ||
| n = pred.shape[0] | ||
|
|
||
| conf = cp.zeros((n, n_cls)) | ||
| votes = cp.zeros((n, n_cls)) | ||
|
|
||
| k = 0 | ||
|
|
||
| for i in range(n_cls): | ||
| for j in range(i + 1, n_cls): | ||
| conf[:, i] -= score[:, k] | ||
| conf[:, j] += score[:, k] | ||
|
|
||
| votes[pred[:, k] == 0, i] += 1 | ||
| votes[pred[:, k] == 1, j] += 1 | ||
|
|
||
| k += 1 | ||
|
|
||
| conf /= 3 * (cp.abs(conf) + 1) | ||
|
|
||
| return votes + conf | ||
|
|
||
| @generate_docstring(y="dense_anydtype") | ||
| @mlfunc(set_input_type=True) | ||
| def fit(self, X, y) -> "_BaseMulticlassClassifier": | ||
| """ | ||
| Fit a multiclass classifier. | ||
| """ | ||
| if self.strategy == "ovr": | ||
| return self._fit_ovr(X, y) | ||
|
|
||
| if self.strategy == "ovo": | ||
| return self._fit_ovo(X, y) | ||
|
|
||
| raise ValueError( | ||
| f"Expected `strategy` to be one of ['ovo', 'ovr'], " | ||
| f"got {self.strategy}" | ||
| ) | ||
|
|
||
| @generate_docstring( | ||
| return_values={ | ||
| "name": "preds", | ||
|
|
@@ -77,16 +197,45 @@ def predict(self, X): | |
| """ | ||
| Predict using multi class classifier. | ||
| """ | ||
| X = check_inputs( | ||
| self, | ||
| X, | ||
| dtype=("float32", "float64"), | ||
| accept_sparse=True, | ||
| mem_type="host", | ||
| ) | ||
| check_is_fitted(self) | ||
|
|
||
| if self.strategy == "ovr": | ||
| X = check_inputs( | ||
| self, | ||
| X, | ||
| dtype=("float32", "float64"), | ||
| accept_sparse=True, | ||
| mem_type="device", | ||
| ) | ||
|
|
||
| with exit_internal_context(): | ||
| return cp.asarray(self.multiclass_estimator.predict(X)) | ||
| if len(self.estimators_) == 1: | ||
| est = self.estimators_[0] | ||
| scr = self._predict_binary(est, X) | ||
| cut = self._threshold_for_binary_predict(est) | ||
| idx = (scr > cut).astype(cp.intp) | ||
| else: | ||
| scr = cp.column_stack( | ||
| [self._predict_binary(est, X) for est in self.estimators_] | ||
| ) | ||
| idx = cp.argmax(scr, axis=1) | ||
|
|
||
| return ClassLabels(idx, self._classes) | ||
|
|
||
| if self.strategy == "ovo": | ||
| scr = self.decision_function(X) | ||
|
|
||
| if len(self._classes) == 2: | ||
| cut = self._threshold_for_binary_predict(self.estimators_[0]) | ||
| idx = (scr > cut).astype(cp.intp) | ||
| else: | ||
| idx = cp.argmax(scr, axis=1) | ||
|
|
||
| return ClassLabels(idx, self._classes) | ||
|
|
||
| raise ValueError( | ||
| f"Expected `strategy` to be one of ['ovo', 'ovr'], " | ||
| f"got {self.strategy}" | ||
| ) | ||
|
|
||
| @generate_docstring( | ||
| return_values={ | ||
|
|
@@ -101,28 +250,86 @@ def decision_function(self, X): | |
| """ | ||
| Calculate the decision function. | ||
| """ | ||
| X = check_inputs( | ||
| self, | ||
| X, | ||
| dtype=("float32", "float64"), | ||
| accept_sparse=True, | ||
| mem_type="host", | ||
| check_is_fitted(self) | ||
|
|
||
| if self.strategy == "ovr": | ||
| X = check_inputs( | ||
| self, | ||
| X, | ||
| dtype=("float32", "float64"), | ||
| accept_sparse=True, | ||
| mem_type="device", | ||
| ) | ||
|
|
||
| if len(self.estimators_) == 1: | ||
| return cp.asarray( | ||
| self.estimators_[0].decision_function(X) | ||
| ).ravel() | ||
|
|
||
| return cp.column_stack( | ||
| [ | ||
| cp.asarray(est.decision_function(X)).ravel() | ||
| for est in self.estimators_ | ||
| ] | ||
| ) | ||
|
|
||
| if self.strategy == "ovo": | ||
| X = check_inputs( | ||
| self, | ||
| X, | ||
| dtype=("float32", "float64"), | ||
| accept_sparse=True, | ||
| mem_type="device", | ||
| ) | ||
|
|
||
| ids = self.pairwise_indices_ | ||
|
|
||
| if ids is None: | ||
| Xs = [X] * len(self.estimators_) | ||
| else: | ||
| Xs = [X[:, idx] for idx in ids] | ||
|
|
||
| pred = [] | ||
| conf = [] | ||
|
|
||
| for est, Xi in zip(self.estimators_, Xs): | ||
| p = est.predict(Xi) | ||
|
|
||
| if isinstance(p, ClassLabels): | ||
| p = p.indices | ||
|
|
||
| pred.append(cp.asarray(p).ravel()) | ||
| conf.append(self._predict_binary(est, Xi)) | ||
|
|
||
| pred = cp.column_stack(pred) | ||
| conf = cp.column_stack(conf) | ||
|
|
||
| scr = self._ovr_decision_function( | ||
| pred, | ||
| conf, | ||
| len(self._classes), | ||
| ) | ||
|
|
||
| if len(self._classes) == 2: | ||
| return scr[:, 1] | ||
|
|
||
| return scr | ||
|
|
||
| raise ValueError( | ||
| f"Expected `strategy` to be one of ['ovo', 'ovr'], " | ||
| f"got {self.strategy}" | ||
| ) | ||
| with exit_internal_context(): | ||
| return cp.asarray(self.multiclass_estimator.decision_function(X)) | ||
|
|
||
|
|
||
| class OneVsRestClassifier(_BaseMulticlassClassifier): | ||
| """ | ||
| Wrapper around Sckit-learn's class with the same name. The input can be | ||
| any kind of cuML compatible array, and the output type follows cuML's | ||
| output type configuration rules. | ||
| One-vs-rest multiclass classifier using device-resident multiclass | ||
| orchestration. The input can be any kind of cuML compatible array, and | ||
| the output type follows cuML's output type configuration rules. | ||
|
|
||
| Before passing the data to scikit-learn, it is converted to host (numpy) | ||
| array. Under the hood the data is partitioned for binary classification, | ||
| and it is transformed back to the device by the cuML estimator. These | ||
| copies back and forth the device and the host have some overhead. For more | ||
| details see issue https://github.com/rapidsai/cuml/issues/2876. | ||
| The data and generated binary targets remain on the device while fitting | ||
| the underlying binary estimators, avoiding unnecessary device-to-host | ||
| transfers. | ||
|
|
||
| For documentation see `scikit-learn's OneVsRestClassifier | ||
| <https://scikit-learn.org/stable/modules/generated/sklearn.multiclass.OneVsRestClassifier.html>`_. | ||
|
|
@@ -161,15 +368,13 @@ class OneVsRestClassifier(_BaseMulticlassClassifier): | |
|
|
||
| class OneVsOneClassifier(_BaseMulticlassClassifier): | ||
| """ | ||
| Wrapper around Sckit-learn's class with the same name. The input can be | ||
| any kind of cuML compatible array, and the output type follows cuML's | ||
| output type configuration rules. | ||
|
|
||
| Before passing the data to scikit-learn, it is converted to host (numpy) | ||
| array. Under the hood the data is partitioned for binary classification, | ||
| and it is transformed back to the device by the cuML estimator. These | ||
| copies back and forth the device and the host have some overhead. For more | ||
| details see issue https://github.com/rapidsai/cuml/issues/2876. | ||
| One-vs-one multiclass classifier using device-resident multiclass | ||
| orchestration. The input can be any kind of cuML compatible array, and | ||
| the output type follows cuML's output type configuration rules. | ||
|
|
||
| Each pairwise binary problem is constructed on the device and the | ||
| resulting votes and confidence values are combined with CuPy, avoiding | ||
| unnecessary device-to-host transfers. | ||
|
|
||
| For documentation see `scikit-learn's OneVsOneClassifier | ||
| <https://scikit-learn.org/stable/modules/generated/sklearn.multiclass.OneVsOneClassifier.html>`_. | ||
|
|
||