Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/source/cuml-accel/compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,25 @@ To compare results between estimators, we recommend comparing scores like
- If ``y`` is a multi-output target.


.. dropdown:: ``IsolationForest``
:name: isolationforest

``IsolationForest`` will fall back to CPU in the following cases:

- If ``warm_start=True``.
- If ``sample_weight`` is passed to ``fit`` or ``fit_predict``.
- If ``X`` is sparse.
- If ``X`` contains missing or non-finite values.

Additional notes:

- Conversion of a fitted GPU ``IsolationForest`` back to a CPU estimator
is not yet supported. Accessing ``offset_``, ``max_samples_``,
``estimators_``, ``estimators_features_``, or ``estimators_samples_``
on a GPU-fitted model raises an ``AttributeError`` explaining this,
rather than the value silently differing from scikit-learn's.


sklearn.kernel_ridge
~~~~~~~~~~~~~~~~~~~~

Expand Down
59 changes: 57 additions & 2 deletions python/cuml/cuml/accel/_overrides/sklearn/ensemble.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd argue that fitted-model synchronization is a requirement for cuml.accel support. Setting _synced = True here leaves the CPU estimator unfitted, so fitted-attribute access, post-fit CPU fallback, and pickling do not have the state that the proxy contract expects.

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)).

@JulienAu JulienAu Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 cuml.accel gets the real fitted state (attribute access, post-fit CPU fallback, and pickling) once, in one place.

I've already prototyped it end to end against the 26.08 nightly, and the parity is exact: reconstructing the sklearn trees from the Treelite export gives score_samples within ~1e-7 of the cuML model and 100% predict agreement across default, max_features, contamination, and bootstrap configs, and the converted estimator pickles and re-scores identically (which would also let check_estimators_pickle pass through conversion). The one wrinkle is that the Treelite export leaves data_count unpopulated, so I recover per-node sample counts by inverting leaf_value = depth + average_path_length(n); it's exact for realistic max_samples.

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 data_count in the C++ Treelite export first and read counts directly (cleaner, touches the C++ layer). I lean toward (2) as the robust path but can deliver (1) immediately. @csadorf @betatim which would you prefer? Happy to open the PR as soon as the direction is settled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 data_count in the Treelite export.

I agree that this PR should block on that conversion rather than adding partial fitted-state synchronization here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)
Comment thread
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():
Comment thread
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(
Comment thread
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(
Comment thread
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):
Comment thread
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
Loading