From 58bb4b50b766704b6b716782126a9b50e9f0377e Mon Sep 17 00:00:00 2001 From: JulienAu <16043912+JulienAu@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:51:07 +0200 Subject: [PATCH 1/4] Support fitted IsolationForest conversion to scikit-learn Signed-off-by: JulienAu <16043912+JulienAu@users.noreply.github.com> --- .../cuml/cuml/ensemble/isolation_forest.pyx | 158 +++++++++++++++- python/cuml/tests/test_isolation_forest.py | 171 +++++++++++++++++- 2 files changed, 315 insertions(+), 14 deletions(-) diff --git a/python/cuml/cuml/ensemble/isolation_forest.pyx b/python/cuml/cuml/ensemble/isolation_forest.pyx index 269317af41..1418cf5185 100644 --- a/python/cuml/cuml/ensemble/isolation_forest.pyx +++ b/python/cuml/cuml/ensemble/isolation_forest.pyx @@ -22,7 +22,6 @@ import treelite from cuml.internals.base import Base, get_handle from cuml.internals.interop import ( InteropMixin, - UnsupportedOnCPU, UnsupportedOnGPU, ) from cuml.internals.mixins import CMajorInputTagMixin @@ -352,6 +351,113 @@ cdef class _IsolationForestModelFloat64(_IsolationForestModel): ) +_SAMPLE_COUNT_ATOL = 1e-4 + + +def _invert_average_path_length(value): + """Recovers the integer sample count ``n`` with ``average_path_length(n)`` + equal to ``value``. + + The Treelite export of an isolation forest does not carry per-node sample + counts, but every leaf value is ``depth + average_path_length(n_samples)``, + so the count is recoverable because it is an integer. The average path + length is strictly increasing in ``n``, with adjacent values separated by + roughly ``2 / n``: recovery is exact for realistic ``max_samples`` and + fails loudly once the separation approaches the tolerance rather than + silently selecting a nearby count. + """ + from sklearn.ensemble._iforest import _average_path_length + + def apl(n): + return float(_average_path_length(np.asarray([n]))[0]) + + if value < -_SAMPLE_COUNT_ATOL: + raise ValueError( + "Cannot recover a leaf sample count from negative average path " + f"length {value!r}." + ) + if value <= _SAMPLE_COUNT_ATOL: + return 1 + # Bracket the value: apl is strictly increasing for n >= 2. + hi = 2 + while apl(hi) < value: + hi *= 2 + lo = hi // 2 + while hi - lo > 1: + mid = (lo + hi) // 2 + if apl(mid) < value: + lo = mid + else: + hi = mid + # The count is lo or hi; accept exactly one candidate within tolerance. + matches = [n for n in (lo, hi) if abs(apl(n) - value) <= _SAMPLE_COUNT_ATOL] + if len(matches) != 1: + raise ValueError( + f"Cannot recover a leaf sample count from average path length " + f"{value!r}: {'no' if not matches else 'more than one'} integer " + f"count matches within tolerance {_SAMPLE_COUNT_ATOL}. The " + "Treelite export does not carry sample counts and the leaf values " + "no longer identify them unambiguously." + ) + return matches[0] + + +def _recover_node_sample_counts(tree, n_samples): + """Recovers ``n_node_samples`` for every node of one exported isolation + tree. + + Leaf counts come from inverting the leaf values; internal counts are + bottom-up sums. The root count must equal ``n_samples`` (the per-tree + sample count), which validates every inversion in the tree at once. + """ + children_left = tree.children_left + children_right = tree.children_right + # Node depths are 1-based; leaf values encode the 0-based depth. + depths = tree.compute_node_depths() + values = tree.value.reshape(-1) + counts = np.zeros(tree.node_count, dtype=np.int64) + # Children are strictly deeper than their parent, so descending depth + # order processes every child before its parent. + for node in np.argsort(depths)[::-1]: + if children_left[node] == -1: + counts[node] = _invert_average_path_length( + values[node] - (depths[node] - 1) + ) + else: + counts[node] = ( + counts[children_left[node]] + counts[children_right[node]] + ) + if counts[0] != n_samples: + raise ValueError( + f"Recovered leaf sample counts sum to {counts[0]} at the root, " + f"expected {n_samples}. The exported leaf values do not identify " + "the per-node sample counts." + ) + return counts + + +def _isolation_tree_to_sklearn(exported_tree, n_features, n_samples, max_depth): + """Rebuilds one fitted sklearn ``ExtraTreeRegressor`` from one tree of the + Treelite export, restoring the per-node sample counts that isolation + forest scoring requires.""" + from sklearn.tree import ExtraTreeRegressor + + counts = _recover_node_sample_counts(exported_tree.tree_, n_samples) + state = exported_tree.tree_.__getstate__() + nodes = state["nodes"].copy() + nodes["n_node_samples"] = counts + nodes["weighted_n_node_samples"] = counts.astype(np.float64) + rebuilt = ExtraTreeRegressor(max_features=1.0, max_depth=max_depth) + rebuilt.n_features_in_ = n_features + rebuilt.n_outputs_ = 1 + tree = type(exported_tree.tree_)( + n_features, np.asarray([1], dtype=np.intp), 1 + ) + tree.__setstate__({**state, "nodes": nodes}) + rebuilt.tree_ = tree + return rebuilt + + class IsolationForest(InteropMixin, CMajorInputTagMixin, Base): """ GPU-accelerated Isolation Forest for anomaly detection. @@ -463,7 +569,10 @@ class IsolationForest(InteropMixin, CMajorInputTagMixin, Base): decision-function values are predicted as anomalies. Fitted models can be exported to Treelite with ``as_treelite()`` and loaded - into nvForest with ``as_nvforest()``. + into nvForest with ``as_nvforest()``. ``as_sklearn()`` converts a fitted + model into an equivalent ``sklearn.ensemble.IsolationForest``; + ``estimators_samples_`` is not available on the converted model because + cuML does not record per-tree sample indices. """ _cpu_class_path = "sklearn.ensemble.IsolationForest" @@ -548,9 +657,48 @@ class IsolationForest(InteropMixin, CMajorInputTagMixin, Base): ) def _attrs_to_cpu(self, model): - raise UnsupportedOnCPU( - "Conversion of a fitted cuML IsolationForest is not supported" - ) + """Converts fitted state to sklearn attributes. + + The tree structure comes from the Treelite export; the per-node sample + counts that isolation forest scoring requires are recovered from the + leaf values (see ``_invert_average_path_length``). ``_seeds`` is not + transferable because cuML does not record per-tree sample indices, so + ``estimators_samples_`` is unavailable on the converted model. + """ + from sklearn.ensemble._iforest import _average_path_length + from sklearn.tree import ExtraTreeRegressor + + tl_model = treelite.Model.deserialize_bytes(self._treelite_model_bytes) + exported = treelite.sklearn.export_model(tl_model) + n_features = self.n_features_in_ + n_samples = int(self.max_samples_) + max_depth = int(np.ceil(np.log2(max(n_samples, 2)))) + estimators = [ + _isolation_tree_to_sklearn(tree, n_features, n_samples, max_depth) + for tree in exported.estimators_ + ] + return { + "estimator_": ExtraTreeRegressor(max_features=1.0), + "estimators_": estimators, + "estimators_features_": [ + np.arange(n_features, dtype=np.int64) for _ in estimators + ], + "max_samples_": n_samples, + "offset_": float(self.offset_), + # The exported trees reference features globally, so scoring uses + # the full feature set for every tree. + "_max_features": n_features, + "_max_samples": n_samples, + "_sample_weight": None, + "_average_path_length_per_tree": tuple( + _average_path_length(est.tree_.n_node_samples) + for est in estimators + ), + "_decision_path_lengths": tuple( + est.tree_.compute_node_depths() for est in estimators + ), + **super()._attrs_to_cpu(model), + } def __getstate__(self): """Pickle support - serialize state.""" diff --git a/python/cuml/tests/test_isolation_forest.py b/python/cuml/tests/test_isolation_forest.py index 6e515bb053..46b0ec76a2 100644 --- a/python/cuml/tests/test_isolation_forest.py +++ b/python/cuml/tests/test_isolation_forest.py @@ -12,6 +12,8 @@ 4. Validating sklearn compatibility """ +import pickle + import cupy as cp import numpy as np import pytest @@ -20,7 +22,7 @@ from sklearn.ensemble import IsolationForest as skIsolationForest from cuml import IsolationForest as cuIsolationForest -from cuml.internals.interop import UnsupportedOnCPU, UnsupportedOnGPU +from cuml.internals.interop import UnsupportedOnGPU from cuml.testing.utils import stress_param, unit_param # ============================================================================= @@ -42,6 +44,15 @@ def synthetic_data_small(): return X, y_true +@pytest.fixture(scope="module") +def anomaly_data(): + """Dataset large enough for every max_samples setting under test.""" + rng = np.random.RandomState(7) + X = rng.randn(2000, 8).astype(np.float32) + X[:30] += 6.0 + return X + + @pytest.fixture(scope="module") def blobs_data(): """Blob dataset for clustering-like anomaly detection.""" @@ -313,14 +324,8 @@ def test_unfitted_sklearn_conversion_preserves_parameters(): assert roundtrip.random_state == 42 -def test_fitted_sklearn_conversion_is_explicitly_unsupported(blobs_data): - """Fitted conversion must not silently drop the trained forest.""" - cu_model = cuIsolationForest(n_estimators=5, random_state=42).fit( - blobs_data - ) - with pytest.raises(UnsupportedOnCPU, match="fitted"): - cu_model.as_sklearn() - +def test_fitted_from_sklearn_is_explicitly_unsupported(blobs_data): + """Importing a fitted sklearn model must not silently drop the forest.""" sk_model = skIsolationForest(n_estimators=5, random_state=42).fit( blobs_data ) @@ -328,6 +333,154 @@ def test_fitted_sklearn_conversion_is_explicitly_unsupported(blobs_data): cuIsolationForest.from_sklearn(sk_model) +@pytest.mark.parametrize( + "params", + [ + {"n_estimators": 100, "max_samples": 256}, + {"n_estimators": 50, "max_samples": 128, "max_features": 0.5}, + {"n_estimators": 50, "max_samples": "auto", "contamination": 0.05}, + {"n_estimators": 20, "max_samples": 150, "bootstrap": True}, + ], +) +def test_as_sklearn_scoring_parity(anomaly_data, params): + """A converted fitted model scores like the cuML model it came from.""" + cu_model = cuIsolationForest(random_state=7, **params).fit(anomaly_data) + sk_model = cu_model.as_sklearn() + assert isinstance(sk_model, skIsolationForest) + + cu_scores = np.asarray( + cu_model.score_samples(anomaly_data), dtype=np.float64 + ) + np.testing.assert_allclose( + sk_model.score_samples(anomaly_data), cu_scores, rtol=0, atol=1e-5 + ) + cu_decision = np.asarray( + cu_model.decision_function(anomaly_data), dtype=np.float64 + ) + np.testing.assert_allclose( + sk_model.decision_function(anomaly_data), + cu_decision, + rtol=0, + atol=1e-5, + ) + np.testing.assert_array_equal( + sk_model.predict(anomaly_data), + np.asarray(cu_model.predict(anomaly_data)), + ) + + +def test_as_sklearn_float64_parity(anomaly_data): + """Conversion of a float64 fit matches to float64 precision.""" + X = anomaly_data.astype(np.float64) + cu_model = cuIsolationForest(n_estimators=20, random_state=1).fit(X) + sk_model = cu_model.as_sklearn() + cu_scores = np.asarray(cu_model.score_samples(X), dtype=np.float64) + np.testing.assert_allclose( + sk_model.score_samples(X), cu_scores, rtol=0, atol=1e-10 + ) + + +def test_as_sklearn_populates_fitted_attributes(blobs_data): + """The converted model carries the attributes a sklearn fit would set.""" + cu_model = cuIsolationForest( + n_estimators=10, max_samples=64, random_state=0 + ).fit(blobs_data) + sk_model = cu_model.as_sklearn() + + assert sk_model.max_samples_ == 64 + assert sk_model.offset_ == pytest.approx(float(cu_model.offset_)) + assert sk_model.n_features_in_ == blobs_data.shape[1] + assert len(sk_model.estimators_) == 10 + assert len(sk_model.estimators_features_) == 10 + # The private fit caches sklearn scoring reads must exist and align. + assert isinstance(sk_model._average_path_length_per_tree, tuple) + assert isinstance(sk_model._decision_path_lengths, tuple) + assert len(sk_model._average_path_length_per_tree) == 10 + for est, lengths in zip( + sk_model.estimators_, sk_model._decision_path_lengths + ): + assert est.tree_.node_count == len(lengths) + assert est.tree_.n_node_samples[0] == 64 + + # Sample indices are not recorded by cuML, so the sklearn property + # backed by `_seeds` stays unavailable rather than returning wrong ones. + with pytest.raises(AttributeError): + sk_model.estimators_samples_ + + +def test_as_sklearn_pickle_roundtrip(blobs_data): + """The converted model survives pickling with identical behavior.""" + cu_model = cuIsolationForest(n_estimators=10, random_state=0).fit( + blobs_data + ) + sk_model = cu_model.as_sklearn() + restored = pickle.loads(pickle.dumps(sk_model)) + np.testing.assert_array_equal( + restored.score_samples(blobs_data), sk_model.score_samples(blobs_data) + ) + np.testing.assert_array_equal( + restored.predict(blobs_data), sk_model.predict(blobs_data) + ) + + +def test_as_sklearn_constant_data(): + """Degenerate single-node trees convert and score identically.""" + X = np.ones((300, 4), dtype=np.float32) + cu_model = cuIsolationForest( + n_estimators=5, max_samples=32, random_state=0 + ).fit(X) + sk_model = cu_model.as_sklearn() + np.testing.assert_allclose( + sk_model.score_samples(X), + np.asarray(cu_model.score_samples(X), dtype=np.float64), + rtol=0, + atol=1e-5, + ) + + +def test_sync_attrs_to_cpu_populates_target(blobs_data): + """The sync path `cuml.accel` relies on fills a target sklearn model.""" + cu_model = cuIsolationForest(n_estimators=10, random_state=0).fit( + blobs_data + ) + target = skIsolationForest(**cu_model._params_to_cpu()) + cu_model._sync_attrs_to_cpu(target) + np.testing.assert_allclose( + target.score_samples(blobs_data), + np.asarray(cu_model.score_samples(blobs_data), dtype=np.float64), + rtol=0, + atol=1e-5, + ) + + +def test_invert_average_path_length_roundtrip(): + """Recovered counts invert sklearn's average path length exactly.""" + from sklearn.ensemble._iforest import _average_path_length + + from cuml.ensemble.isolation_forest import _invert_average_path_length + + for n in (1, 2, 3, 10, 256, 5000): + value = float(_average_path_length(np.asarray([n]))[0]) + assert _invert_average_path_length(value) == n + + +def test_invert_average_path_length_fails_loudly(): + """Values matching no count, or more than one, raise a clear error.""" + from sklearn.ensemble._iforest import _average_path_length + + from cuml.ensemble.isolation_forest import _invert_average_path_length + + with pytest.raises(ValueError, match="negative"): + _invert_average_path_length(-0.5) + # Between apl(4) and apl(5), far from both: no candidate matches. + with pytest.raises(ValueError, match="no integer count"): + _invert_average_path_length(1.95) + # At large counts adjacent values collapse within tolerance: ambiguous. + midpoint = float(_average_path_length(np.asarray([50000, 50001])).mean()) + with pytest.raises(ValueError, match="more than one"): + _invert_average_path_length(midpoint) + + def test_contamination_float_sets_score_quantile_offset(blobs_data): """Float contamination should set offset_ from training score quantile.""" contamination = 0.1 From 3fb032e43dbb42b9a8580feb489df8e06aad16b7 Mon Sep 17 00:00:00 2001 From: JulienAu <16043912+JulienAu@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:06:24 +0200 Subject: [PATCH 2/4] Guard _attrs_to_cpu against a partially fitted model A failed fit can leave n_features_in_ set, which makes the model look fitted to InteropMixin, while no serialized forest exists. Raise the same RuntimeError as the scoring methods instead of deserializing None. Signed-off-by: JulienAu <16043912+JulienAu@users.noreply.github.com> --- python/cuml/cuml/ensemble/isolation_forest.pyx | 5 +++++ python/cuml/tests/test_isolation_forest.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/python/cuml/cuml/ensemble/isolation_forest.pyx b/python/cuml/cuml/ensemble/isolation_forest.pyx index 1418cf5185..c2d59e4664 100644 --- a/python/cuml/cuml/ensemble/isolation_forest.pyx +++ b/python/cuml/cuml/ensemble/isolation_forest.pyx @@ -668,6 +668,11 @@ class IsolationForest(InteropMixin, CMajorInputTagMixin, Base): from sklearn.ensemble._iforest import _average_path_length from sklearn.tree import ExtraTreeRegressor + # A failed `fit` can leave `n_features_in_` set (making the model look + # fitted to `InteropMixin`) while no serialized forest exists yet. + if self._treelite_model_bytes is None: + raise RuntimeError("Model has not been fitted. Call fit() first.") + tl_model = treelite.Model.deserialize_bytes(self._treelite_model_bytes) exported = treelite.sklearn.export_model(tl_model) n_features = self.n_features_in_ diff --git a/python/cuml/tests/test_isolation_forest.py b/python/cuml/tests/test_isolation_forest.py index 46b0ec76a2..dbbf3032b3 100644 --- a/python/cuml/tests/test_isolation_forest.py +++ b/python/cuml/tests/test_isolation_forest.py @@ -333,6 +333,17 @@ def test_fitted_from_sklearn_is_explicitly_unsupported(blobs_data): cuIsolationForest.from_sklearn(sk_model) +def test_as_sklearn_after_failed_fit_raises(blobs_data): + """A failed fit sets ``n_features_in_`` before raising, which makes the + model look fitted to ``InteropMixin``; conversion must still fail + loudly rather than deserialize a missing forest.""" + cu_model = cuIsolationForest(max_features=0) + with pytest.raises(ValueError, match="max_features"): + cu_model.fit(blobs_data) + with pytest.raises(RuntimeError, match="not been fitted"): + cu_model.as_sklearn() + + @pytest.mark.parametrize( "params", [ From e75c3b5ddd5c6cfdf8056f72b09aac9c4fd0aca5 Mon Sep 17 00:00:00 2001 From: JulienAu <16043912+JulienAu@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:06:46 +0200 Subject: [PATCH 3/4] Fix import formatting for isort Signed-off-by: JulienAu <16043912+JulienAu@users.noreply.github.com> --- python/cuml/cuml/ensemble/isolation_forest.pyx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/python/cuml/cuml/ensemble/isolation_forest.pyx b/python/cuml/cuml/ensemble/isolation_forest.pyx index c2d59e4664..beac0f5651 100644 --- a/python/cuml/cuml/ensemble/isolation_forest.pyx +++ b/python/cuml/cuml/ensemble/isolation_forest.pyx @@ -20,10 +20,7 @@ import nvforest import treelite from cuml.internals.base import Base, get_handle -from cuml.internals.interop import ( - InteropMixin, - UnsupportedOnGPU, -) +from cuml.internals.interop import InteropMixin, UnsupportedOnGPU from cuml.internals.mixins import CMajorInputTagMixin from cuml.internals.outputs import mlfunc from cuml.internals.treelite import safe_treelite_call From e370675916b2439cc74516daaa914082b9a9be11 Mon Sep 17 00:00:00 2001 From: JulienAu <16043912+JulienAu@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:35:13 +0200 Subject: [PATCH 4/4] Carry a configured max_depth over to reconstructed estimators The tree structure from the export is already depth-limited; only the declared hyperparameter on the rebuilt ExtraTreeRegressor was wrong when max_depth was set explicitly. Signed-off-by: JulienAu <16043912+JulienAu@users.noreply.github.com> --- python/cuml/cuml/ensemble/isolation_forest.pyx | 5 ++++- python/cuml/tests/test_isolation_forest.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/python/cuml/cuml/ensemble/isolation_forest.pyx b/python/cuml/cuml/ensemble/isolation_forest.pyx index beac0f5651..2b0c178f5c 100644 --- a/python/cuml/cuml/ensemble/isolation_forest.pyx +++ b/python/cuml/cuml/ensemble/isolation_forest.pyx @@ -674,7 +674,10 @@ class IsolationForest(InteropMixin, CMajorInputTagMixin, Base): exported = treelite.sklearn.export_model(tl_model) n_features = self.n_features_in_ n_samples = int(self.max_samples_) - max_depth = int(np.ceil(np.log2(max(n_samples, 2)))) + if self.max_depth is None: + max_depth = int(np.ceil(np.log2(max(n_samples, 2)))) + else: + max_depth = int(self.max_depth) estimators = [ _isolation_tree_to_sklearn(tree, n_features, n_samples, max_depth) for tree in exported.estimators_ diff --git a/python/cuml/tests/test_isolation_forest.py b/python/cuml/tests/test_isolation_forest.py index dbbf3032b3..38d71c575a 100644 --- a/python/cuml/tests/test_isolation_forest.py +++ b/python/cuml/tests/test_isolation_forest.py @@ -333,6 +333,23 @@ def test_fitted_from_sklearn_is_explicitly_unsupported(blobs_data): cuIsolationForest.from_sklearn(sk_model) +def test_as_sklearn_respects_max_depth(anomaly_data): + """A configured ``max_depth`` must carry over to the reconstructed + estimators, and truncated trees (leaves holding many samples) must + still score identically.""" + cu_model = cuIsolationForest( + n_estimators=20, max_samples=256, max_depth=4, random_state=11 + ).fit(anomaly_data) + sk_model = cu_model.as_sklearn() + assert all(est.max_depth == 4 for est in sk_model.estimators_) + cu_scores = np.asarray( + cu_model.score_samples(anomaly_data), dtype=np.float64 + ) + np.testing.assert_allclose( + cu_scores, sk_model.score_samples(anomaly_data), atol=1e-5 + ) + + def test_as_sklearn_after_failed_fit_raises(blobs_data): """A failed fit sets ``n_features_in_`` before raising, which makes the model look fitted to ``InteropMixin``; conversion must still fail