diff --git a/docs/source/cuml-accel/compatibility.rst b/docs/source/cuml-accel/compatibility.rst index e543a9bde9..6fc54ceae6 100644 --- a/docs/source/cuml-accel/compatibility.rst +++ b/docs/source/cuml-accel/compatibility.rst @@ -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 ~~~~~~~~~~~~~~~~~~~~ diff --git a/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py b/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py index 67a6f7abfb..a9a024ef13 100644 --- a/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py +++ b/python/cuml/cuml/accel/_overrides/sklearn/ensemble.py @@ -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 # @@ -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: @@ -88,3 +92,40 @@ 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) + 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) diff --git a/python/cuml/cuml_accel_tests/integration/test_isolation_forest.py b/python/cuml/cuml_accel_tests/integration/test_isolation_forest.py new file mode 100644 index 0000000000..b33394f507 --- /dev/null +++ b/python/cuml/cuml_accel_tests/integration/test_isolation_forest.py @@ -0,0 +1,58 @@ +# 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_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. + 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 + )