Skip to content
20 changes: 20 additions & 0 deletions docs/source/cuml-accel/compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,26 @@ 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 supported. Accessing fit attributes (``offset_``, ``max_samples_``,
``estimators_``, and others) or pickling a GPU-fitted model triggers
this conversion automatically.
- ``estimators_samples_`` is not available on the converted model,
since cuML does not record per-tree sample indices.


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

Expand Down
54 changes: 52 additions & 2 deletions python/cuml/cuml/accel/_overrides/sklearn/ensemble.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#

Expand All @@ -8,7 +8,11 @@
from cuml.internals.interop import UnsupportedOnGPU
from cuml.internals.validation import check_array

__all__ = ("RandomForestRegressor", "RandomForestClassifier")
__all__ = (
"RandomForestRegressor",
"RandomForestClassifier",
"IsolationForest",
)


class _RandomForestMixin:
Expand Down Expand Up @@ -88,3 +92,49 @@ def __iter__(self):

def __getitem__(self, index):
return self._call_method("__getitem__", index)


class IsolationForest(ProxyBase):
_gpu_class = cuml.ensemble.IsolationForest

@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)
if sample_weight is not None:
raise UnsupportedOnGPU("sample_weight is not supported")
return self._gpu.fit(X, y=y)

def _gpu_fit_predict(self, X, y=None, **kwargs):
# IsolationForest.fit_predict() doesn't declare sample_weight itself;
# it inherits OutlierMixin.fit_predict(self, X, y=None, **kwargs),
# which forwards kwargs straight to fit(). Match that signature here
# (rather than declaring sample_weight explicitly) so the proxy stays
# signature-compatible with the CPU method.
self._validate_input(X)
if kwargs.get("sample_weight") is not None:
raise UnsupportedOnGPU("sample_weight is not supported")
return self._gpu.fit_predict(X, y=y)

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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import numpy as np
import pytest
from sklearn.datasets import make_blobs
from sklearn.ensemble import IsolationForest

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_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_fit_sample_weight_falls_back_to_cpu(
blobs_with_outliers,
):
# cuml.ensemble.IsolationForest.fit() has no sample_weight parameter,
# so a non-None sample_weight cannot be honored on GPU.
X = blobs_with_outliers
sample_weight = np.ones(len(X))

result = IsolationForest(n_estimators=10, random_state=0).fit(
X, sample_weight=sample_weight
)

assert result._gpu is None


def test_isolation_forest_fit_predict_sample_weight_falls_back_to_cpu(
blobs_with_outliers,
):
# Same as above, for fit_predict().
X = blobs_with_outliers
sample_weight = np.ones(len(X))

result = IsolationForest(n_estimators=10, random_state=0)
result.fit_predict(X, sample_weight=sample_weight)

assert result._gpu is None


def test_isolation_forest_gpu_fit_attrs_available_after_conversion(
blobs_with_outliers,
):
# Regression test: conversion of a fitted GPU IsolationForest back to a
# CPU estimator is now supported (landed in #8483, tracked by #8420).
# Accessing fit attributes on a GPU-fitted proxy must trigger that
# conversion and expose the real synced values instead of raising.
Comment on lines +71 to +74

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.

(nit) I don't think this in-line comment is adding valuable context.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Trimmed kept the #8483/#8420 reference so the "why" is still clear, dropped the rest.

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 don't see any changes. Did you forget to push? Either way, not a big deal. I'm just not a fan of overly verbose comments that seem to explain code evolution instead of just providing necessary context.

X = blobs_with_outliers
result = IsolationForest(n_estimators=50, random_state=0).fit(X)
assert result._gpu is not None

gpu_scores = result.decision_function(X) # dispatched to GPU

assert len(result.estimators_) == 50
assert result.offset_ == pytest.approx(float(result._gpu.offset_))

np.testing.assert_allclose(
result._cpu.decision_function(X), gpu_scores, atol=1e-5
)
Loading