Guide¶
The quickstart got one anomaly found. This page is about choosing: which pieces exist, which detector suits which kind of anomaly, and two behaviours that look like bugs the first time you meet them.
The code blocks build on one another, so running the page top to bottom reproduces every number shown.
The five component types¶
Everything is one of five things, and each has fit plus one verb:
| Type | Takes | Gives | Verbs |
|---|---|---|---|
Scorer |
a series | a continuous score | .score(), .fit_score() |
Threshold |
a score | binary labels | .apply(), .fit_apply() |
Detector |
a series | binary labels | .detect(), .fit_detect() |
Aggregator |
several label series | one label series | .aggregate() |
Transformer |
a series | a series | .transform(), .fit_transform() |
A score answers how unusual is this point, on whatever scale the algorithm works in, higher being more unusual. A threshold answers is that unusual enough to report. They are separate objects because they are separate questions: one threshold policy is then reusable across every scorer, a scorer can be swapped without revisiting the policy, and a score is useful on its own — for ranking the worst hours of a month even when none of them crosses a line.
A Detector is the pairing of the two, since that is how detection is usually
wanted, and the parts stay reachable as .scorer and .threshold. Pull a
detector apart to inspect the raw score, or build your own pairing with
ScoreDetector(scorer, threshold).
A Transformer sits upstream: a rolling aggregate, a lag, a seasonal
decomposition. An Aggregator sits downstream, reducing several detectors'
verdicts to one. Pipeline chains components; Graph wires them as a directed
acyclic graph when the model branches. Both are themselves components, so a
pipeline can be a node of a graph and either can be used anywhere a detector can.
Two conventions run through all of it:
- Labels are
1.0anomalous,0.0normal,NaNunknown. Fitted attributes end in an underscore (threshold_,period_), and every constructor parameter is a plain keyword argument stored under its own name, soget_params(),clone()andrepr()need no maintenance. - Results come back in the flavour they went in as. pandas in, pandas out, with the same index; polars in, polars out, with the time column in the same place.
Which detector for which anomaly¶
"Anomalous" is not one thing, and the choice of detector is mostly the choice of which of these you mean.
| The anomaly is | Reach for | Because |
|---|---|---|
| a value outside its usual range | IqrDetector, QuantileDetector, EsdDetector, ThresholdDetector |
the value alone is enough to judge; no context needed |
| a spike — local and temporary | SpikeDetector |
compares each point with the window just before it, so a slow drift is invisible to it |
| a level shift — permanent | LevelShiftDetector |
compares two long windows either side of each point; a single odd value cannot move a wide median |
| a volatility shift | VolatilityShiftDetector |
measures spread rather than position, relatively, so a doubling of noise counts equally on a quiet series and a loud one |
| a violation of a seasonal pattern | SeasonalDetector |
the cycle is normal behaviour, so it belongs in the model rather than in the anomalies |
| a violation of the series' own dynamics | AutoregressionDetector |
asks whether a value is unusual given where the series just was, which catches a break at a perfectly ordinary level |
| a broken relationship between columns | RegressionDetector, PcaDetector |
both columns can be in their usual range and still be impossible together |
| a rare combination of readings | MinClusterDetector, OutlierDetector |
nothing needs to be said about what anomalous looks like; the shape of the data decides |
The distinctions matter more than they look. Here are three series, each with one thing wrong, and the detector that suits each:
import numpy as np
import pandas as pd
from hazure.detection import (
LevelShiftDetector,
SpikeDetector,
VolatilityShiftDetector,
)
from hazure.events import to_events
rng = np.random.default_rng(4)
index = pd.date_range("2024-01-01", periods=600, freq="h", name="time")
values = rng.normal(10.0, 1.0, 600)
values[150] = 22.0 # one hour, gone by the next
spiky = pd.Series(values, index=index, name="t")
values = rng.normal(10.0, 1.0, 600)
values[300:] += 6.0 # it never comes back
stepped = pd.Series(values, index=index, name="t")
values = rng.normal(10.0, 1.0, 600)
values[300:] = 10.0 + rng.normal(0.0, 4.0, 300) # same level, more noise
noisy = pd.Series(values, index=index, name="t")
for name, detector, series in (
("spike", SpikeDetector(window=24, factor=6.0), spiky),
("level shift", LevelShiftDetector(window=24), stepped),
("volatility shift", VolatilityShiftDetector(window=24, factor=6.0), noisy),
):
events = to_events(detector.fit_detect(series))
first = np.datetime64(int(events.bounds[0, 0]), "ns")
print(f"{name:17s} {events.n_events} event(s), first at {first}")
# spike 1 event(s), first at 2024-01-07T06:00:00.000000000
# level shift 1 event(s), first at 2024-01-13T00:00:00.000000000
# volatility shift 2 event(s), first at 2024-01-12T21:00:00.000000000
values[150] is 2024-01-07 06:00, and the spike detector lands on it exactly.
The shift detectors land on a window around 2024-01-13 12:00, which is
values[300] — the point where the change happened. That difference is the
subject of the first section below.
Every one of these takes a factor, which is how many spreads from normal is too
far, and a side of "both", "positive" or "negative". Detecting only the
increases is not a different algorithm, just a filter on the same one.
Univariate and multivariate¶
A univariate component works on one series at a time. Hand it a frame and it does
not fail and does not pool the columns: it fits one independent copy of itself
per column, so each column learns its own normal range and each gets its own
verdict column back. That is what "separable across series" means — the algorithm
never needs to see two columns at once, so hazure can fan it out for you.
from hazure.detection import IqrDetector
frame = pd.DataFrame(
{"celsius": rng.normal(20.0, 1.0, 200), "pascals": rng.normal(1e5, 500.0, 200)},
index=index[:200],
)
frame.iloc[50, 0] = 40.0
frame.iloc[150, 1] = 2e5
detector = IqrDetector().fit(frame)
labels = detector.detect(frame)
print(list(labels.columns), labels.sum().to_dict())
# ['celsius', 'pascals'] {'celsius': 1.0, 'pascals': 1.0}
Two columns on wildly different scales, and one fence each. Pooling them would have made either fence meaningless.
A multivariate component is one whose algorithm genuinely needs every column at once, and it returns a single verdict rather than one per column, because the anomaly is a property of the combination:
from hazure.detection import RegressionDetector
requests = 100 + 20 * np.sin(np.arange(400) / 12) + rng.normal(0, 2, 400)
cpu = 0.4 * requests + rng.normal(0, 1, 400)
cpu[250:256] += 30.0 # CPU burns without the traffic to explain it
pair = pd.DataFrame({"requests": requests, "cpu": cpu}, index=index[:400])
verdict = RegressionDetector(target="cpu").fit_detect(pair)
print(list(verdict.columns))
# ['anomaly']
Neither column leaves its usual range; the relationship between them is what broke. A multivariate component also remembers the columns it was fitted on, and reorders a later frame to match, because its coefficients are positional.
Missing values¶
A missing observation is NaN, and it stays NaN. Nothing is imputed, and no
gap is filled with a guess, because a guess would be indistinguishable from an
observation in the output.
gappy = spiky.copy()
gappy.iloc[100:105] = np.nan
verdict = IqrDetector().fit_detect(gappy)
print(verdict.iloc[99:106].to_list())
# [0.0, nan, nan, nan, nan, nan, 0.0]
NaN in the output means unknown, which is different from 0.0. It also
appears where a window-based detector has too little history to judge — the first
window observations of a rolling comparison — and saying so is more useful than
calling them normal. Both to_events and the point-based metrics read NaN as
not-anomalous, so an unknown region never invents an event, but it never counts
as a correct negative either.
validate_series applies the same normalisation every detector applies
internally — read the time axis, sort it, keep the first observation at each
timestamp, cast to float — and hands the result back, so a surprising detection
can be traced to a reordered axis or a dropped duplicate rather than guessed at.
Choosing the cut-off¶
Every threshold in the library is parameterised by something — a quantile, a number of spreads, a significance level — and none of those somethings is "the answer I want". Two ways to get from one to the other.
If somebody wrote the incidents down, tune_threshold searches for the cut-off
that scores best against them:
from hazure import budget_threshold, tune_threshold
from hazure.datasets import make_series
from hazure.evaluation import recall
from hazure.scoring import DoubleRollingScorer
data = make_series("spike", n=3000, n_anomalies=5, strength=9.0, backend="pandas")
scores = DoubleRollingScorer(window=(24, 1)).fit_score(data.data)
best = tune_threshold(data.events, scores)
print(best, to_events(best.threshold.apply(scores)).n_events, "alerts")
# Calibration(cut_off=8.43629, event f1=1, over 200 candidates) 5 alerts
If nobody did — the case this library is mostly about — there is still a number
you know: how many pages a week anyone will read. budget_threshold lowers the
fence as far as that allows and no further.
for alerts in (1, 5):
chosen = budget_threshold(scores, alerts=alerts, per="7d")
found = to_events(chosen.threshold.apply(scores))
print(alerts, round(chosen.cut_off, 2), found.n_events, recall(data.events, found))
# 1 9.7 1 0.2
# 5 3.44 7 1.0
That is the whole trade, printed. One page a week buys one alert and catches one of the five spikes; five a week catches all five and costs seven alerts. Neither row is the right answer — which one you want is a question about your team, and this is the function that lets you ask it in those terms.
Two details behind it. The search descends: it starts from a fence that flags
nothing and lowers it until the budget breaks, because alert count is not
monotone in the cut-off — raising a fence can split one alert into two, and taken
far enough it reverses completely, since a fence near the bottom flags almost
every sample and those merge into one enormous alert that satisfies any budget you
like. And a flagged stretch broken by a single quiet sample counts as two alerts
unless you pass gap, which merges them the way a real monitor's debounce would.
Both functions hand back a Calibration carrying the whole curve they searched,
which is the part worth looking at: a peak one candidate wide is a peak fitted to
noise, and no summary number will tell you that.
The cut-off tune_threshold finds is fitted to the labels you gave it, so quoting
the same metric at the same cut-off on the same data is reporting a training
score. Tune on one fold and measure on the next — split_train_test is there for
it.
Keeping a fitted model¶
Fitting on a period you trust and detecting on later data is the mode that avoids
every contamination
problem —
but only if the fitted model outlives the process that fitted it. to_dict and
from_dict are that: JSON-safe data in, the same component out.
import json
from hazure.detection import SpikeDetector
fitted = SpikeDetector(window=24, factor=6.0).fit(spiky)
stored = json.dumps(fitted.to_dict())
restored = SpikeDetector.from_dict(json.loads(stored))
print(restored.threshold.high_ == fitted.threshold.high_)
# True
print(restored.detect(spiky).equals(fitted.detect(spiky)))
# True
restored needs no second fit. What was captured is everything the component
held, not only its parameters and not only its fitted_ attributes: nested
components, the per-column copies a univariate component fanned out into, and
private state like the phase anchor SeasonalDecomposition learns. That
completeness is the point — a seasonal profile restored without its anchor would
not raise, it would answer, and answer against the wrong phase.
Two limits worth knowing before you build on it.
A model hazure did not build cannot be stored. OutlierDetector(model=...)
and MinClusterDetector(model=...) hold your estimator, and hazure has no
reconstruction for it, so to_dict raises and says to use pickle or to fit
again. Everything else round-trips, including OrdinaryLeastSquares and so
AutoregressionDetector and RegressionDetector at their defaults.
Deserialising imports the class the payload names, and an import runs code.
So Configurable.from_dict refuses any name outside hazure, and naming the
class yourself — MySpikeDetector.from_dict(payload) — needs no import and is
allowed. A payload is still not a trust boundary: one from a stranger can
construct a hazure component with strange parameters, which is a much smaller
problem than naming any importable object in the interpreter, but not nothing.
The payload records the version that wrote it and nothing enforces it. It is for keeping a model between runs, not for archiving one across releases.
Detecting on a stream¶
There is no partial_fit, and the reason is worth stating rather than working
around. Of the statistics these components learn, only some can be updated by one
observation at a time:
| Updatable exactly | Needs the whole sample |
|---|---|
| a mean and a standard deviation | any quantile — IqrThreshold, QuantileThreshold |
the per-phase means of SeasonalDecomposition |
a median — MadThreshold, DeviationScorer(scale="iqr") |
the normal equations of OrdinaryLeastSquares |
EsdThreshold, whose fit removes points iteratively |
The right-hand column is where the defaults live: every detector in this library
that learns a fence learns an inter-quartile one. A partial_fit would therefore
be absent from exactly the components you reach for first.
What works instead, for every component, is to fit once on history you trust and
then run the fitted component over a buffer of the recent past, once per
arriving observation. Stream is that loop:
from hazure import Stream
detector = SpikeDetector(window=24, factor=6.0).fit(spiky.iloc[:72])
stream = Stream(detector, history=48).prime(spiky.iloc[:72])
labels = stream.update_many(spiky.iloc[72:])
print(int(labels.sum()), str(labels.index[labels == 1.0][0]))
# 1 2024-01-07 06:00:00
Two things are worth pulling out of that.
The online answer is the batch answer. There is one implementation of
SpikeDetector, and streaming reuses it rather than reimplementing it
incrementally, so the two cannot drift apart. The cost is that each observation
runs the detector over the whole buffer — irrelevant at one sample a minute, and
the reason this is the right trade for monitoring rather than for a tight loop.
prime checks the buffer length, because getting it wrong is silent. A
window-based detector whose buffer is shorter than its window has no history to
compare against and returns NaN — no error, no alerts, and nothing to suggest
why. So prime computes the last observation's verdict twice, once from the full
history you hand it and once from the truncated buffer, and refuses to start if
they disagree:
try:
Stream(detector, history=5).prime(spiky.iloc[:72])
except ValueError as error:
print(str(error).split(":")[0])
# Stream(history=5) is too short for SpikeDetector
Sizing history is not simply reading the detector's window — a Pipeline
consumes the sum of what its steps consume, and a seasonal detector wants several
periods — so hand prime more history than you think you need and let it tell you.
The fit itself does not move, which is deliberate: what counts as normal stays a
decision you made on data you chose. Refresh it on a schedule rather than
continuously, and to_dict makes that cheap — fit once a day, store the model,
and let each run detect against the model the last fit produced. A fence fitted on
an ever-growing sample becomes steadily less able to notice that the last week is
different from the first year; one fitted on the last month is a statement about
the last month. Choosing that length is an empirical question, and
split_train_test in mode 3 — expanding training window, fixed test block — is
how to measure it rather than guess.
One fence is genuinely incremental, and it is not in the right-hand column above
because it is not a quantile of the sample. PotThreshold fits a generalised
Pareto to the tail of the training scores, and its update absorbs each new score
into that tail — discarding the ones it flags, since an anomaly says nothing about
how normal behaves. The two pieces compose: stream the scorer, and hand each score
to the threshold.
from hazure import PotThreshold
from hazure.scoring import DoubleRollingScorer
scorer = DoubleRollingScorer(window=(24, 1)).fit(spiky.iloc[:400])
fence = PotThreshold(high=1e-3, level=0.95).fit(scorer.score(spiky.iloc[:400]))
scores = Stream(scorer, history=48).prime(spiky.iloc[:400])
live = spiky.iloc[400:].copy()
live.iloc[50] = 26.0 # something goes wrong after deployment
flagged = [
stamp
for stamp, value in live.items()
if fence.update(scores.update(stamp, float(value))) == 1.0
]
print(len(flagged), str(flagged[0]))
# 1 2024-01-19 18:00:00
level=0.95 rather than the default 0.98 because 400 observations is not much
to fit a tail from: the fence needs at least ten scores above level to be fitted
at all, and below that it reports nan rather than extrapolating from three
points. That trade — a tail that is better justified the higher level goes, and
fitted from less data — is the whole tension in the method, and is discussed on
the thresholds page.
Two things that surprise people¶
A shift detector reports the change point, not the anomalous interval¶
LevelShiftDetector answers when did the series become a different series. It
compares the window before each point with the window after it, so it fires
around the moment of change and goes quiet again once both windows sit at the new
level — even though, in an operational sense, everything after the change is
still wrong.
That is by design, and it means evaluating a shift detector against interval-shaped ground truth will show near-zero recall even when it is working perfectly:
from hazure.events import Events, expand_events
from hazure.evaluation import f1_score, precision, recall
rng = np.random.default_rng(1)
axis = pd.date_range("2024-01-01", periods=480, freq="h", name="time")
values = rng.normal(10.0, 1.0, 480)
values[240:] += 6.0
series = pd.Series(values, index=axis, name="temperature")
found = to_events(LevelShiftDetector(window=24).fit_detect(series))
print(found)
# Events([2024-01-10T12:00:00..2024-01-11T12:59:59.999999999])
# "Everything from the shift onwards was wrong" — true, but not what was asked.
as_interval = Events.from_any([(axis[240], axis[-1])])
print(recall(as_interval, found), precision(as_interval, found))
# 0.0 1.0
Recall is zero because the detector covered 25 hours of a 240-hour interval, and an event counts as detected only when half its duration is covered. Nothing is broken. The comparison is.
The correct comparison reduces the truth to the change point too, and allows a tolerance — the detector cannot localise the change more finely than the window it used, so give it that window:
as_change_point = expand_events(Events.from_any([axis[240]]), before="12h", after="12h")
print(recall(as_change_point, found), precision(as_change_point, found))
# 1.0 1.0
print(f1_score(as_change_point, found))
# 1.0
The same applies to VolatilityShiftDetector and to the change-point methods in
hazure.methods. If what you actually want is the whole degraded
stretch, detect the change points and fill between them, or use a detector that
judges each point on its own — a level shift leaves every subsequent value
outside the training range, which is exactly what IqrDetector fitted on the
clean history will tell you.
An inter-quartile fence widens as the fraction of unusual scores grows¶
IqrDetector and IqrThreshold use the box-plot rule: everything outside
[Q1 - factor * IQR, Q3 + factor * IQR]. Quartiles ignore the tails, which is
the whole point — the outliers being looked for do not widen the range that is
supposed to exclude them.
But quartiles are not immune, only resistant. Push enough of the series into the unusual state and a quartile lands inside it, the inter-quartile range grows to span both regimes, and the fence stops separating them:
from hazure.detection import IqrDetector
axis = pd.date_range("2024-01-01", periods=200, freq="h", name="time")
for fraction in (0.05, 0.2, 0.4, 0.6):
values = rng.normal(0.0, 1.0, 200)
values[: int(200 * fraction)] += 8.0
flagged = IqrDetector(factor=1.5).fit_detect(pd.Series(values, index=axis))
print(f"{fraction:>4.0%} of the series raised -> {int(flagged.sum()):3d} flagged")
# 5% of the series raised -> 10 flagged
# 20% of the series raised -> 40 flagged
# 40% of the series raised -> 0 flagged
# 60% of the series raised -> 0 flagged
At 40% the third quartile has moved into the raised group, so IQR is about 8
rather than about 1.3, and a fence 1.5 IQRs wide covers everything. This is the
definition working as intended, not a failure. A rule that says "unusual
relative to this series" cannot flag the majority of the series; if the anomalous
state is the majority, it is the normal state as far as the rule can tell.
What to do about it, in order of preference:
- Fit on a clean period and apply to the suspect one.
fiton the history you trust,detecton the rest — the fence then comes from data that has not been contaminated.split_train_testbuilds time-ordered folds for this. - Say what normal is.
ThresholdDetector(low=..., high=...)learns nothing, so nothing can contaminate it. - Ask a local question instead.
SpikeDetectorandLevelShiftDetectorcompare each point with its own neighbourhood, so a regime lasting most of the series is still a departure from the hour before it began. - Detect the transition. If most of the series is in the bad state, the interesting event is when it entered it — see the previous section.
The same reasoning applies to QuantileThreshold, whose cut-off is a percentile
of the scores it was fitted on and so always flags roughly a fixed fraction, and
to MadThreshold, which is more resistant than the IQR fence but not immune
either.
Where to go next¶
- How it works — what each detector actually computes: the rolling comparisons behind the spike and shift detectors, the fences that turn a score into a verdict, and the arithmetic of events and metrics.
- API reference — every public name, module by module.
hazure.datasets—make_seriesfor series with a known answer at a strength you set,load_nabfor the labelled ones from the Numenta Anomaly Benchmark, andcomparefor putting several detectors' numbers side by side.