-
Notifications
You must be signed in to change notification settings - Fork 652
Add cuml.accel support for sklearn.ensemble.IsolationForest #8477
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
1efbf48
0d3d714
e326371
909c968
c4d8376
f31863c
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 |
|---|---|---|
|
|
@@ -5,10 +5,10 @@ | |
|
|
||
| import cuml.ensemble | ||
| from cuml.accel.estimator_proxy import ProxyBase | ||
| from cuml.internals.interop import UnsupportedOnGPU | ||
| from cuml.internals.interop import UnsupportedOnCPU, UnsupportedOnGPU | ||
| from cuml.internals.validation import check_array | ||
|
|
||
| __all__ = ("RandomForestRegressor", "RandomForestClassifier") | ||
| __all__ = ("RandomForestRegressor", "RandomForestClassifier", "IsolationForest") | ||
|
|
||
|
|
||
| class _RandomForestMixin: | ||
|
|
@@ -88,3 +88,58 @@ def __iter__(self): | |
|
|
||
| def __getitem__(self, index): | ||
| return self._call_method("__getitem__", index) | ||
|
|
||
|
|
||
| class IsolationForest(ProxyBase): | ||
| _gpu_class = cuml.ensemble.IsolationForest | ||
| # Conversion of a fitted cuML IsolationForest to CPU is not yet | ||
| # supported (tracked in #8420). These attributes stay inaccessible | ||
| # until that lands, rather than crashing on any *_ access. | ||
| _not_implemented_attributes = frozenset( | ||
| ( | ||
| "offset_", | ||
| "max_samples_", | ||
| "estimators_", | ||
| "estimators_features_", | ||
| "estimators_samples_", | ||
| ) | ||
| ) | ||
|
|
||
| def _sync_attrs_to_cpu(self) -> None: | ||
| try: | ||
| super()._sync_attrs_to_cpu() | ||
| except UnsupportedOnCPU: | ||
| self._synced = True | ||
|
Contributor
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'd argue that fitted-model synchronization is a requirement for We should either implement cuML to scikit-learn synchronization as part of this PR or block this PR on the conversion work that @JulienAu offered to take on (see #8468 (comment)).
Contributor
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. Yes, I'm taking the cuML -> scikit-learn conversion on #8420, and I'd suggest blocking on it rather than duplicating a partial sync here, so I've already prototyped it end to end against the That's the open design question I raised on #8420, and it's really the maintainers' call: (1) ship the pure-Python reconstruction now against the current export, or (2) populate
Contributor
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. Thanks, this sounds good. As noted in #8420 (comment), my suggestion is to move forward immediately with the pure-Python reconstruction and follow up separately by populating I agree that this PR should block on that conversion rather than adding partial fitted-state synchronization here.
Contributor
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. The fitted-model conversion this PR blocks on is up: #8483. Once it lands, the partial-sync workaround here can be dropped and the proxy gets real fitted state through the standard sync path. |
||
|
|
||
| @staticmethod | ||
| def _validate_input(X): | ||
| # cuML's IsolationForest requires dense, finite input and raises | ||
| # ValueError (NaN/inf) or TypeError (sparse) otherwise. Convert | ||
| # those into UnsupportedOnGPU so callers fall back to CPU instead | ||
| # of crashing. | ||
| try: | ||
| check_array( | ||
| X, mem_type=None, order=None, ensure_2d=False, input_name="X" | ||
| ) | ||
| except (ValueError, TypeError) as exc: | ||
| raise UnsupportedOnGPU(str(exc)) from None | ||
|
|
||
| def _gpu_fit(self, X, y=None, sample_weight=None): | ||
| self._validate_input(X) | ||
| return self._gpu.fit(X, y=y, sample_weight=sample_weight) | ||
|
|
||
| def _gpu_fit_predict(self, X, y=None, sample_weight=None): | ||
| self._validate_input(X) | ||
| return self._gpu.fit_predict(X, y=y, sample_weight=sample_weight) | ||
|
|
||
| def _gpu_predict(self, X): | ||
| self._validate_input(X) | ||
| return self._gpu.predict(X) | ||
|
|
||
| def _gpu_decision_function(self, X): | ||
| self._validate_input(X) | ||
| return self._gpu.decision_function(X) | ||
|
|
||
| def _gpu_score_samples(self, X): | ||
| self._validate_input(X) | ||
| return self._gpu.score_samples(X) | ||
|
csadorf marked this conversation as resolved.
Outdated
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import pickle | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
| import scipy.sparse | ||
| from sklearn.datasets import make_blobs | ||
| from sklearn.ensemble import IsolationForest | ||
|
|
||
| from cuml.accel import is_proxy | ||
| from cuml.ensemble import IsolationForest as CumlIsolationForest | ||
|
|
||
| CPUIsolationForest = IsolationForest._cpu_class | ||
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| def blobs_with_outliers(): | ||
| X, _ = make_blobs( | ||
| n_samples=200, | ||
| centers=1, | ||
| cluster_std=0.5, | ||
| random_state=42, | ||
| ) | ||
| rng = np.random.RandomState(42) | ||
| outliers = rng.uniform(low=-10, high=10, size=(20, X.shape[1])) | ||
| return np.vstack([X, outliers]) | ||
|
|
||
|
|
||
| def test_isolation_forest_is_a_proxy(): | ||
|
csadorf marked this conversation as resolved.
Outdated
|
||
| assert is_proxy(IsolationForest) | ||
| assert IsolationForest._cpu_class is CPUIsolationForest | ||
| assert ( | ||
| IsolationForest._gpu_class._cpu_class_path | ||
| == "sklearn.ensemble.IsolationForest" | ||
| ) | ||
| assert CumlIsolationForest._cpu_class_path == ( | ||
| "sklearn.ensemble.IsolationForest" | ||
| ) | ||
|
|
||
|
|
||
| def test_isolation_forest_fit_predict_agreement(blobs_with_outliers): | ||
| X = blobs_with_outliers | ||
| params = {"n_estimators": 100, "random_state": 0} | ||
|
|
||
| expected = CPUIsolationForest(**params).fit(X) | ||
| result = IsolationForest(**params).fit(X) | ||
|
|
||
| assert result._gpu is not None | ||
|
|
||
| expected_labels = expected.predict(X) | ||
| result_labels = result.predict(X) | ||
| assert set(np.unique(result_labels)) <= {-1, 1} | ||
| assert np.mean(expected_labels == result_labels) >= 0.9 | ||
|
|
||
|
|
||
| def test_isolation_forest_decision_function_and_score_samples( | ||
|
csadorf marked this conversation as resolved.
Outdated
|
||
| blobs_with_outliers, | ||
| ): | ||
| X = blobs_with_outliers | ||
| result = IsolationForest(n_estimators=100, random_state=0).fit(X) | ||
| assert result._gpu is not None | ||
|
|
||
| scores = result.score_samples(X) | ||
| decision = result.decision_function(X) | ||
| assert scores.shape == decision.shape == (len(X),) | ||
| # decision_function is score_samples shifted by a constant offset. | ||
| np.testing.assert_allclose( | ||
| decision, scores - (scores - decision)[0], atol=1e-4 | ||
| ) | ||
|
|
||
|
|
||
| def test_isolation_forest_not_implemented_attributes_give_friendly_error( | ||
| blobs_with_outliers, | ||
| ): | ||
| result = IsolationForest(n_estimators=50, random_state=0).fit( | ||
| blobs_with_outliers | ||
| ) | ||
| assert result._gpu is not None | ||
|
|
||
| with pytest.raises(AttributeError, match="not yet implemented"): | ||
| result.offset_ | ||
|
|
||
| with pytest.raises(AttributeError, match="not yet implemented"): | ||
| result.estimators_ | ||
|
|
||
|
|
||
| def test_isolation_forest_pickle_after_gpu_fit_does_not_crash( | ||
|
csadorf marked this conversation as resolved.
Outdated
|
||
| blobs_with_outliers, | ||
| ): | ||
| result = IsolationForest(n_estimators=50, random_state=0).fit( | ||
| blobs_with_outliers | ||
| ) | ||
| assert result._gpu is not None | ||
|
|
||
| # Must not raise UnsupportedOnCPU: pickling falls back to whatever | ||
| # the CPU estimator has synced, the not-implemented attributes are | ||
| # simply absent from the unpickled copy. | ||
| restored = pickle.loads(pickle.dumps(result)) | ||
| assert type(restored) is CPUIsolationForest | ||
|
|
||
|
|
||
| def test_isolation_forest_falls_back_on_nan_input(blobs_with_outliers): | ||
| X = blobs_with_outliers.copy() | ||
| X[0, 0] = np.nan | ||
|
|
||
| result = IsolationForest(n_estimators=50, random_state=0).fit(X) | ||
| assert result._gpu is None | ||
| assert type(result._cpu) is CPUIsolationForest | ||
|
|
||
|
|
||
| def test_isolation_forest_falls_back_on_sparse_input(blobs_with_outliers): | ||
|
csadorf marked this conversation as resolved.
Outdated
|
||
| sparse_X = scipy.sparse.csr_matrix(blobs_with_outliers) | ||
|
|
||
| result = IsolationForest(n_estimators=50, random_state=0).fit(sparse_X) | ||
| assert result._gpu is None | ||
| assert type(result._cpu) is CPUIsolationForest | ||
Uh oh!
There was an error while loading. Please reload this page.