Skip to content

Add anomaly.RobustRandomCutForest - #1975

Open
JayeshSuryavanshi wants to merge 2 commits into
online-ml:mainfrom
JayeshSuryavanshi:feat/rrcf
Open

Add anomaly.RobustRandomCutForest#1975
JayeshSuryavanshi wants to merge 2 commits into
online-ml:mainfrom
JayeshSuryavanshi:feat/rrcf

Conversation

@JayeshSuryavanshi

Copy link
Copy Markdown

Agent disclosure. Per river's AGENTS.md, I want to be upfront: this contribution (the implementation, tests, docstring, and this description) was produced by an AI coding agent working on my behalf, and the commit carries a Co-authored-by: trailer. I reviewed and verified the result before opening it. river's policy prefers human-written prose, so I'm happy to rewrite the docstring/description in my own words if you'd like, this is here as a working, verified starting point.

Closes #1393.

What this adds

anomaly.RobustRandomCutForest, an online implementation of the Robust Random Cut Forest (Guha, Mishra, Roy, Schrijvers, ICML 2016). river/anomaly/ has half-space trees and LODA but no random-cut method, and RRCF is the canonical streaming random-cut detector (the method behind AWS Kinesis / CloudWatch anomaly detection).

How it works

The forest is an ensemble of robust random cut trees, each holding a bounded sliding window of the most recent tree_size points. Cuts are drawn in proportion to each feature's span within the bounding box (scale-aware, unlike the uniform dimension choice of an isolation tree). A point's score is its collusive displacement (CoDisp), averaged over the trees; higher means more anomalous.

  • Dict-native API (learn_one/score_one). The feature ordering is fixed from the first observation; missing features are treated as 0.0.
  • score_one is side-effect-free: it inserts the query point into each tree, reads its CoDisp, then removes it and restores the tree's RNG state, so scoring never mutates the model.
  • Parameters: n_trees=40, tree_size=256, seed.

Implementation and verification

The tree logic (insert_point / forget_point / codisp and the span-proportional cut) is a faithful port of the reference rrcf package 1. I verified:

  • Exact-match vs the reference: building a ported tree and a reference rrcf.RCTree with the same seed and inserting the same points, the CoDisp matches bit-for-bit (max abs diff 0.0 over all points).
  • score_one purity: the pickled model is byte-identical before and after scoring, and repeated scores of the same point are identical.
  • Reproducibility: two forests with the same seed produce identical score sequences on the same stream.
  • Accuracy: on datasets.CreditCard().take(1000), paired with a StandardScaler, metrics.RollingROCAUC reaches 95.64% (this is a doctest in the module).
  • check_estimator passes; mypy and ruff are clean; test_rrcf.py and the doctests pass.

A changelog entry is included in docs/releases/unreleased.md.

Footnotes

  1. Bartos, Mullapudi, Troutman, rrcf: Implementation of the Robust Random Cut Forest algorithm, JOSS 2019, https://github.com/kLabUM/rrcf

@codspeed-hq

codspeed-hq Bot commented Aug 2, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 98 untouched benchmarks
⏩ 16 skipped benchmarks1


Comparing JayeshSuryavanshi:feat/rrcf (80b298f) with main (0b8596f)

Open in CodSpeed

Footnotes

  1. 16 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@MaxHalford

Copy link
Copy Markdown
Member

Very cool! I'll take a look sometime this week.

Out of curiosity, may I know what's your interest with River? Are you maybe using it for work?

@JayeshSuryavanshi

Copy link
Copy Markdown
Author

Thanks Max! My background is in payments / fraud ML, so online and streaming anomaly detection is squarely in my area of interest. RRCF is a well-known streaming detector and I noticed river didn't have it yet, so it seemed like a natural thing to contribute. It's not wired into a specific production system, more that this is the kind of tooling I like to work with and learn from. Happy to iterate on the PR however you'd like.

Comment thread river/anomaly/rrcf.py Outdated
Comment on lines +19 to +25
self.l = left
self.r = right
self.u = u
self.q = q
self.p = p
self.n = n
self.b = b

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not a fan of using acronyms, and even less so single letter variables. Please use explicit names

Comment thread river/anomaly/rrcf.py Outdated
Comment on lines +46 to +53
class _RCTree:
"""A single robust random cut tree.

This is a faithful port of the ``RCTree`` data structure from the ``rrcf`` package
(https://github.com/kLabUM/rrcf, MIT license), restricted to the incremental streaming
interface used by River: an empty tree grown one point at a time via ``insert_point`` and
trimmed via ``forget_point``.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure porting rrcf is the right approach. Instead, I would rather consider leveraging the existing HalfSpaceTrees, and seeing what code can be shared with it. And regardless, the implementation should be dictionary based, and should not necessitate using NumPy.

JayeshSuryavanshi and others added 2 commits August 10, 2026 22:41
Add an online Robust Random Cut Forest anomaly detector (Guha et al., 2016).
It maintains an ensemble of robust random cut trees over a sliding window and
scores points by their average collusive displacement (CoDisp); score_one is
side-effect-free. The tree logic is a faithful port of the reference rrcf
package (its CoDisp matches bit-for-bit) exposed through river's dict-native
AnomalyDetector API.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop NumPy entirely: bounding boxes are now plain dicts keyed by feature
name, and the branch and leaf types subclass tree.base.Branch and
tree.base.Leaf. Rename the single-letter node attributes to feature,
threshold, left, right, parent, n_points, and lower/upper.

Remove the per-insert scan over every leaf that computed the maximum depth,
along with the depth attribute it required, since walking parent pointers
gives CoDisp the same answer. Eviction is now O(depth) rather than
O(subtree), and the detector is several times faster than the previous
NumPy implementation.

CoDisp matches the reference rrcf at master bit-for-bit when both are
driven by the same sequence of uniform draws. Note this follows master
rather than the released 0.4.4, whose _insert_point_cut collapses the
bounding box span to zero when the insertion target is a leaf.
@JayeshSuryavanshi

Copy link
Copy Markdown
Author

Thanks Max, and sorry for the slow turnaround.

I've reworked both points.

Names. The q, p, l, r, u, n, b slots are gone. They're now feature, threshold, left, right, parent, n_points, and lower/upper for the bounding box.

Dict based, no NumPy. rrcf.py doesn't import NumPy at all any more. Bounding boxes are plain dicts keyed by feature name, and the branch and leaf types subclass tree.base.Branch and tree.base.Leaf, so the tree is a normal river tree now rather than a transplant.

I've also rebased onto current main, which moved the tests to tests/ and moved the anomaly base classes into river.base. So the detector now subclasses base.AnomalyDetector, the test lives at tests/anomaly/test_rrcf.py, and the new lint-imports contract passes.

On sharing with HalfSpaceTrees. I read hst.py before answering, and I think the honest answer is that most of it can't be shared, but the part that can already has been by #1980.

HST builds a complete fixed-depth tree once, up front, from a declared hypercube, and never changes its shape afterwards: learning bumps a mass counter along one root-to-leaf path, and the window is a wholesale l_mass/r_mass swap. RRCF can't work that way. Every learn_one splices a new branch in at a randomly drawn cut, every eviction splices a leaf out and promotes its sibling, and each node carries a live bounding box that's tightened on insert and relaxed on forget. Scoring runs the other way too, leaf to root, taking max(sibling.n_points / node.n_points). So the tree-node representation is really the only common ground.

That said, you've already pulled HST's nodes out into river/tree/padded.py, and this PR's nodes sit on tree.base.Branch/Leaf in the same way. What's left duplicated between PaddedBranch and RobustRandomCutBranch is the left/right properties over self.children and the most_common_path stub. Now that both live in the tree layer rather than inside anomaly, lifting those into a small binary-branch base in tree.base looks like a tidy follow-up that would serve both. Happy to do it as a separate PR if you want it, I've kept it out of this one to keep the diff reviewable.

How I checked correctness. Going pure Python meant I lost the old bit-for-bit comparison, since a Python RNG can't reproduce a NumPy stream. So I did it the other way round: I recorded every uniform variate the reference rrcf consumed and replayed that exact sequence into this implementation. If the tree logic is the same, both should draw the same number of times, in the same order, and agree everywhere.

They do, across five configurations (1 to 12 features, sliding window and pure growth, 2050 stream points and 200 scored queries):

  • identical draw counts in every case
  • max |diff| of 0.0 in CoDisp for each point as it arrives
  • max |diff| of 0.0 for every scored query
  • max |diff| of 0.0 for every surviving leaf at the end

One thing I should flag, because it's the single place this deliberately diverges from what you'd get off PyPI. That agreement is against rrcf master, not the released 0.4.4. In 0.4.4 _insert_point_cut allocates bbox_hat = np.empty(bbox.shape), and a Leaf stores its box as x.reshape(1, -1). So when the insertion target is a leaf, bbox_hat[0, :] and bbox_hat[-1, :] are the same row: the elementwise max overwrites the elementwise min, every span collapses to zero, uniform(0, 0) gives 0, and the cut falls back to the first feature at a deterministic value. On a 3-feature stream that's roughly 6% of all cuts. Upstream fixed it in 1fffbee ("ensure bbox_hat has right dimensions", July 2023), but there hasn't been a release since April 2023, so pip install rrcf still has it.

This implementation follows master. test_leaf_split_cut_is_drawn_in_proportion_to_feature_spans pins that down: separating a point from a single leaf where the spans are 1.0 and 10.0 picks the wider feature about ten times as often, whereas the released version always picks the first.

Speed. Dropping NumPy made it faster, not slower. While I was in there I also removed a max(leaf.depth for leaf in self.leaves.values()) scan that ran on every insert and walked every leaf, along with the depth attribute it needed, since following parent pointers gives CoDisp the same answer. Eviction is O(depth) now instead of O(subtree). Against the version I'd pushed before, same stream:

config mode now before speedup
10 trees, size 64, 3 features learn only 6199/s 1090/s 5.7x
10 trees, size 64, 3 features score+learn 2585/s 394/s 6.6x
20 trees, size 128, 10 features learn only 1064/s 382/s 2.8x
20 trees, size 128, 10 features score+learn 524/s 149/s 3.5x
40 trees, size 256, 3 features learn only 1118/s 212/s 5.3x
40 trees, size 256, 3 features score+learn 529/s 80/s 6.6x

To be straight about the absolute numbers rather than just the ratios: at the current defaults of 40 trees over a 256 point window it's about 1.1k records/s to learn and about 530/s to score and learn, which is under the 5k/s guideline in the contributing docs. That's the cost of doing 40 trees of work per record rather than anything specific to this implementation, and it's why the docstring example uses 15. Say the word and I'll lower the defaults so it lands closer to the guideline out of the box.

check_estimator passes, ruff and mypy are clean, and CI is green on the rebased branch.

@MaxHalford

Copy link
Copy Markdown
Member

You're answering by asking Claude to write the answer for you. You're putting in little effort to use your own voice. I'm sorry but I do not feel inclined to pursue the review of this PR.

@JayeshSuryavanshi

Copy link
Copy Markdown
Author

Hi @MaxHalford you're right, and I'm sorry. I used Claude to write that reply instead of answering you myself.

The PR was up front about being agent-assisted from the start, but that doesn't make it okay to hand you generated prose to read when you'd put real time into reviewing it.

I'll write my own replies from here. Completely understand if you'd rather not pick this back up. Thanks :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement RRCF (Robust Random Cut Forest)

2 participants