Skip to content

fix(anomaly): stop PredictiveAnomalyDetection.score_one from mutating the model - #1970

Open
JayeshSuryavanshi wants to merge 3 commits into
online-ml:mainfrom
JayeshSuryavanshi:fix/pad-score-one-side-effect
Open

fix(anomaly): stop PredictiveAnomalyDetection.score_one from mutating the model#1970
JayeshSuryavanshi wants to merge 3 commits into
online-ml:mainfrom
JayeshSuryavanshi:fix/pad-score-one-side-effect

Conversation

@JayeshSuryavanshi

Copy link
Copy Markdown

What

anomaly.PredictiveAnomalyDetection.score_one updated the dynamic MAE/variance statistics that back its anomaly threshold. Scoring a point therefore changed the detector: repeated score_one calls on the same point returned different values, and scoring without learning silently shifted the threshold. This is the same class of bug fixed for LocalOutlierFactor (#1331, #1929), where the convention is that score_one must be side-effect free — PredictiveAnomalyDetection was the only remaining anomaly detector whose score_one mutated state.

Fix

The threshold statistics are now maintained by learn_one, which computes the prediction error before the predictive model learns each observation. score_one only reads them, so it no longer mutates the model. A small private _predict helper is shared by both methods.

Behavior is preserved for the documented score-then-learn loop: the full 144-score sequence over the docstring's AirlinePassengers example is byte-for-byte identical before and after, and the doctest output is unchanged (0.05329236123455621).

Tests

Added river/anomaly/test_pad.py: it asserts score_one leaves dynamic_mae / dynamic_se_variance and a full pickle snapshot untouched (so it is idempotent), and that learn_one now maintains the threshold statistics. Both tests fail on main and pass with this change; the 14 automated estimator checks for PredictiveAnomalyDetection still pass. ruff check, ruff format --check, and mypy --strict are clean.

… the model

score_one updated the dynamic MAE/variance that back the anomaly threshold,
so scoring a point changed the detector and repeated scoring of the same
point returned different values. Move the threshold update into learn_one,
computing the prediction error before the predictive model learns each
observation, so score_one is side-effect free. Scores over the usual
score-then-learn loop are unchanged.
@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 98 untouched benchmarks
⏩ 16 skipped benchmarks1


Comparing JayeshSuryavanshi:fix/pad-score-one-side-effect (6262189) with main (11a2f94)

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.

Comment thread river/anomaly/test_pad.py Outdated
Comment on lines +18 to +39
def test_score_one_does_not_mutate_state():
"""`score_one` must be side-effect free.

Regression test: `score_one` used to update the dynamic MAE/variance backing the threshold,
so scoring a point changed the model and repeated scoring of the same point returned
different values (cf. the analogous issue #1331 for `anomaly.LocalOutlierFactor`).
"""
model = _fit(anomaly.PredictiveAnomalyDetection(warmup_period=0))

mae_n = model.dynamic_mae.n
var_n = model.dynamic_se_variance.n
snapshot = pickle.dumps(model)

probe_x, probe_y = {"x": 30.0}, 999.0
scores = [model.score_one(probe_x, probe_y) for _ in range(5)]

# Scoring left every statistic untouched...
assert model.dynamic_mae.n == mae_n
assert model.dynamic_se_variance.n == var_n
assert pickle.dumps(model) == snapshot
# ...so it is idempotent.
assert len(set(scores)) == 1

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.

Actually here I'd prefer it we added a global test in the checks to make sure score_one does not mutate any anomaly detector whatsoever

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.

Good call — done in 79e9202. Added checks.anomaly.check_score_one_does_not_mutate: it learns a stream, snapshots the model (via pickle.dumps), calls score_one, and asserts the state is unchanged. It's registered for every anomaly detector — both AnomalyDetector and SupervisedAnomalyDetector — and passes all of them (GaussianScorer, HalfSpaceTrees, LODA, LocalOutlierFactor, OneClassSVM, StandardAbsoluteDeviation, PredictiveAnomalyDetection); reverting the PAD fix makes it fail, so it genuinely guards the whole class going forward.

One structural note: I yield it directly with its own dataset rather than appending it to the generic dataset_checks, because supervised anomaly detectors (currently just PredictiveAnomalyDetection) aren't wired into those — the common checks call predict_one, which score_one-based detectors don't implement. Wiring supervised anomaly detectors into the generic checks felt like a bigger, separate change, so I kept this check self-contained; happy to do that as a follow-up PR if you'd like.

I also dropped the now-redundant per-detector mutation test and kept the learn_one-maintains-the-threshold-statistics test in test_pad.py.

…ly detector

Per @MaxHalford's review, add a framework-level
checks.anomaly.check_score_one_does_not_mutate that verifies score_one leaves
any anomaly detector (supervised or not) untouched, rather than a PAD-specific
test. It is yielded directly with its own dataset because supervised anomaly
detectors are not wired into the generic (predict_one-based) dataset_checks.
It runs on all anomaly detectors and catches the PAD regression when the fix
is reverted. Drop the now-redundant per-detector mutation test; keep the PAD
learn_one-maintains-statistics test.
@JayeshSuryavanshi

Copy link
Copy Markdown
Author

Hi @MaxHalford, can you please review it when you get time :)

@MaxHalford MaxHalford left a comment

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.

Getting there.

Comment thread river/checks/__init__.py Outdated
Comment on lines +233 to +247
if isinstance(model, AnomalyDetector):
dataset_checks.append(anomaly.check_roc_auc)

# score_one must never mutate an anomaly detector (supervised or not).
# Yielded directly with its own dataset rather than via dataset_checks:
# supervised anomaly detectors are not wired into the generic
# (predict_one-based) dataset_checks, but their score_one must be
# side-effect free too.
if isinstance(model, (AnomalyDetector, SupervisedAnomalyDetector)):
from river import datasets as _anomaly_datasets

yield _wrapped_partial(
anomaly.check_score_one_does_not_mutate,
dataset=list(_anomaly_datasets.CreditCard().take(500)),
)

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.

You can simplify here. First of all the import datasets is weird. Just use the one that's already imported. Also no need for the comment, it's too verbose. Finally, you can simplify and run both the ROC AUC test and the new test for all anomaly detectors, including supervised ones.

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.

Done in 6262189. Both check_roc_auc and check_score_one_does_not_mutate now run for every anomaly detector, supervised ones included, and I dropped the verbose comment.

On the import: I kept it function-local (like _yield_datasets already does) because a module-level from river import datasets risks a circular import, but I dropped the alias. I also couldn't fold the two anomaly checks into the generic dataset_checks for supervised detectors, since those go through predict_one, which SupervisedAnomalyDetectors don't implement, so both checks are yielded here over a shared CreditCard stream. Happy to restructure if you had a cleaner shape in mind.

Comment thread river/checks/anomaly.py Outdated
Comment on lines +47 to +54
x, y = first
snapshot = pickle.dumps(anomaly_detector)
if supervised:
anomaly_detector.score_one(x, y)
else:
anomaly_detector.score_one(x)

assert pickle.dumps(anomaly_detector) == snapshot, f"score_one mutated {anomaly_detector!r}"

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.

Couldn't you test this at each learn_one step? That would be stricter (and therefore a better test)

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.

Good call, done in 6262189. It now snapshots and asserts no mutation at each step of the stream, interleaved with learn_one.

One wrinkle the stricter version surfaced: PAD's predictive-model pipeline lazily memoises _inference_transformers / _last_step_cached on the first predict, which a naive per-step pickle flags (that is caching, not learning). I prime it with a single warm-up score before the loop so the check targets real state changes. It runs on all 7 anomaly detectors including PAD, and a short prefix over the stream keeps the per-step pickling fast.

… check

Per review:
- Run both check_roc_auc and check_score_one_does_not_mutate on every anomaly
  detector, including supervised ones (PredictiveAnomalyDetection). Both now
  handle the supervised score_one(x, y) / learn_one(x, y) signature via
  _supervised.
- Tidy the registration: drop the aliased datasets import and the verbose comment.
- Make the mutation check stricter: it now asserts score_one leaves the model
  untouched at each step of the stream, interleaved with learn_one, after a
  one-time warm-up score that primes the pipeline's lazy prediction cache
  (memoisation, not learning). A short prefix keeps the per-step pickling fast.
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.

2 participants