Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
171 changes: 162 additions & 9 deletions python/cuml/cuml/ensemble/isolation_forest.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ import nvforest
import treelite

from cuml.internals.base import Base, get_handle
from cuml.internals.interop import (
InteropMixin,
UnsupportedOnCPU,
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
Expand Down Expand Up @@ -352,6 +348,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)``,
Comment on lines +354 to +359

@chyunsu3 chyunsu3 Aug 18, 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.

Hello, thanks for working on this feature! I submitted dmlc/treelite#684 so that Treelite will preserve per-node sample counts. Let's wait until #684 is merged, and remove this utility function.

@JulienAu Would you be able to review dmlc/treelite#684 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewed, thanks for the quick fix upstream (findings posted on dmlc/treelite#684). One data point for the sequencing here: the bytes cuML currently serializes carry data_count as present with value 0 on every node, and task_type kRegressor, so exported trees would come back with n_node_samples equal to 0 silently. Dropping the reconstruction therefore needs, besides treelite#684, the cuML C++ export to write real counts and tag kIsolationForest, plus a treelite release picked up by cuML. Since #8477 is blocked on this conversion, one option is to land the reconstruction now and swap it for the exported counts in a follow-up once the whole chain is in place. Happy either way, I will follow whatever sequencing you and @csadorf prefer.

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 much prefer to not block this PR on necessary upstream changes and just get this working with the local work-around. We can track the follow-up work in a separate issue and then land it as soon as possible.

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.

Let's follow @csadorf's suggestion and merge this PR first. We will remove the workaround later, once the upstream change is made. I created #8488 to keep track.

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
Expand Down Expand Up @@ -463,7 +566,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"
Expand Down Expand Up @@ -548,9 +654,56 @@ 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

# 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
n_features = self.n_features_in_
n_samples = int(self.max_samples_)
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_
]
return {
"estimator_": ExtraTreeRegressor(max_features=1.0),
"estimators_": estimators,
Comment thread
chyunsu3 marked this conversation as resolved.
"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."""
Expand Down
Loading
Loading