Spot an anomaly in a metric
Get paged when a production metric leaves its usual behaviour, and not the rest of the time.
No AI needed
FreeInstantYour data never leaves
The verdict
N0 is the only level whose output is the reasoning: the measured value, what the window called usual, the gap, and the gap that was allowed. That is half the diagnosis delivered with the alert, at no marginal cost, and the test checks it number by number. But do not wire `anomalies` to a phone: at the default settings a healthy metric sampled every minute puts a dozen points a day in there, as the test measures, and a plain step change puts a dozen in for one incident. `episodes` is what a phone reads: a run has to hold for three minutes before it counts, and an open episode is not paged twice — over a week of noise the test counts at most one episode. This detector serves a dashboard better than an alarm clock anyway: Google's SRE book, linked below, advises alerting on the symptoms a user sees rather than on the unusual. N1 judges the metrics together, against everything it was trained on, where N0 judges each series against its own window: it sees the minute whose every value is familiar and whose combination is not — the test builds that minute and shows the three series, taken one at a time, staying silent — and pays for it by returning a rank between zero and one: you learn that the minute was unusual, not how.
Robust threshold on median and median absolute deviation over a sliding window, with a persistence rule
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Flag anomalies in a metric with a robust threshold on a sliding window.
Rung N0. Median and median absolute deviation, standard library only, and a
verdict that can be read at three in the morning.
The mean and the standard deviation are the wrong tools here. One incident
drags both, so a large enough spike widens the very band that was supposed to
catch it. The median and the median absolute deviation hold while outliers fill
less than half the window: the usual value stays among ordinary values, and
the tolerated gap widens as the outliers pile up instead of jumping with the
first one. That is the property an alert needs.
Every verdict carries the numbers it was made of. "Anomaly at 03:12" leaves
whoever was woken up to reconstruct the reasoning before they can act;
"measured 4800, usual 1200, allowed up to 1511" is already half the
diagnosis, and it is the same three numbers the threshold itself used.
One point over the limit is not an alert. On pure noise, one point in a
hundred to two in a hundred crosses the limit at the default settings: on a
metric sampled every minute that is ten to twenty pages a day, per metric, for
nothing. And a real step change crosses it for as many minutes as the window
takes to swallow it, which is a dozen pages for one incident. `episodes` is
what a pager should read: a run has to hold for `consecutive` points before it
counts, and an episode already open is not paged again. `anomalies` stays,
point by point, for a dashboard.
"""
from dataclasses import dataclass
from statistics import median
# Scaling that puts the median absolute deviation on the same footing as a
# standard deviation for normally distributed data. Estimated on a short
# window it is noisy: pure Gaussian noise crosses 3.5 of these far more often
# than it crosses 3.5 true standard deviations, and the tests measure how often.
NORMAL_SCALE = 1.4826
@dataclass
class Verdict:
"""One judged point, and the whole reasoning behind the judgement."""
index: int
value: float
usual: float # median of the window that came before
deviation: float # how far the point sits from that median
limit: float # how far it was allowed to sit
is_anomaly: bool
def scan(series: list[float], window: int = 24, threshold: float = 3.5,
min_spread: float = 0.0) -> list[Verdict]:
"""
Judge every point against the `window` points that precede it.
The first `window` points get no verdict at all. A point cannot be
compared with a history that does not exist yet, and saying nothing is
more honest than comparing it with a shorter, noisier window.
`min_spread` is the smallest spread the metric may have, in its own unit.
A count that is zero most minutes, such as errors, has a median absolute
deviation of zero, and every single error would ring: pass 1 for it.
"""
if window < 1:
raise ValueError("window must hold at least one point")
verdicts = []
for index in range(window, len(series)):
reference = series[index - window : index]
usual = median(reference)
spread = max(NORMAL_SCALE * median([abs(value - usual) for value in reference]), min_spread)
deviation = abs(series[index] - usual)
limit = threshold * spread
verdicts.append(Verdict(index, series[index], usual, deviation, limit, deviation > limit))
return verdicts
def anomalies(series: list[float], window: int = 24, threshold: float = 3.5,
min_spread: float = 0.0) -> list[Verdict]:
"""
Every point over the limit, each one still carrying its numbers.
This is the dashboard view, not the pager view: at the default settings a
healthy metric puts one point in a hundred here. `episodes` is the pager
view.
"""
return [verdict for verdict in scan(series, window, threshold, min_spread) if verdict.is_anomaly]
@dataclass
class Episode:
"""A run of points over the limit, reported once."""
start: int # index of the first point of the run
length: int # how many points in a row stayed over the limit
opened_by: Verdict # the first point, with the numbers that judged it
worst: Verdict # the point that sat furthest outside its limit
def episodes(series: list[float], window: int = 24, threshold: float = 3.5,
min_spread: float = 0.0, consecutive: int = 3) -> list[Episode]:
"""
What a pager should see: one entry per run of points over the limit.
Two rules, and they are the ones every on-call rota ends up adding. A run
has to hold for `consecutive` points before it counts, which is what keeps
the noise of a healthy metric off the phone. And a run is reported once,
not once per minute, which is what keeps one step change from paging a
dozen times.
`consecutive = 1` covers every point of `anomalies`, runs of them grouped
into one episode each.
"""
if consecutive < 1:
raise ValueError("consecutive must be at least one point")
found, run = [], []
for verdict in scan(series, window, threshold, min_spread) + [None]:
if verdict is not None and verdict.is_anomaly:
run.append(verdict)
continue
if len(run) >= consecutive:
worst = max(run, key=lambda item: item.deviation - item.limit)
found.append(Episode(run[0].index, len(run), run[0], worst))
run = []
return foundJavaScript
/**
* Flag anomalies in a metric with a robust threshold on a sliding window.
*
* Rung N0. Median and median absolute deviation, no dependency, and a verdict
* that can be read at three in the morning.
*
* The mean and the standard deviation are the wrong tools here. One incident
* drags both, so a large enough spike widens the very band that was supposed
* to catch it. The median and the median absolute deviation hold while
* outliers fill less than half the window: the usual value stays among
* ordinary values, and the tolerated gap widens as the outliers pile up
* instead of jumping with the first one. That is the property an alert needs.
*
* Every verdict carries the numbers it was made of. "Anomaly at 03:12" leaves
* whoever was woken up to reconstruct the reasoning before they can act;
* "measured 4800, usual 1200, allowed up to 1511" is already half the
* diagnosis, and it is the same three numbers the threshold itself used.
*
* One point over the limit is not an alert. On pure noise, one point in a
* hundred to two in a hundred crosses the limit at the default settings: on a
* metric sampled every minute that is ten to twenty pages a day, per metric,
* for nothing. And a real step change crosses it for as many minutes as the
* window takes to swallow it, which is a dozen pages for one incident.
* `episodes` is what a pager should read: a run has to hold for `consecutive`
* points before it counts, and an episode already open is not paged again.
* `anomalies` stays, point by point, for a dashboard.
*/
// Scaling that puts the median absolute deviation on the same footing as a
// standard deviation for normally distributed data. Estimated on a short
// window it is noisy: pure Gaussian noise crosses 3.5 of these far more often
// than it crosses 3.5 true standard deviations, and the tests measure how often.
const NORMAL_SCALE = 1.4826;
/** Middle value, or the average of the two middle ones on an even count. */
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
/**
* Judge every point against the `window` points that precede it.
*
* The first `window` points get no verdict at all. A point cannot be compared
* with a history that does not exist yet, and saying nothing is more honest
* than comparing it with a shorter, noisier window.
*
* `minSpread` is the smallest spread the metric may have, in its own unit. A
* count that is zero most minutes, such as errors, has a median absolute
* deviation of zero, and every single error would ring: pass 1 for it.
*
* Each verdict is the whole reasoning: the value, what the window called
* usual, how far the value sat from it, and how far it was allowed to sit.
*/
export function scan(series, { window = 24, threshold = 3.5, minSpread = 0 } = {}) {
if (!(window >= 1)) throw new RangeError('window must hold at least one point');
const verdicts = [];
for (let index = window; index < series.length; index += 1) {
const reference = series.slice(index - window, index);
const usual = median(reference);
const spread = Math.max(NORMAL_SCALE * median(reference.map((value) => Math.abs(value - usual))), minSpread);
const deviation = Math.abs(series[index] - usual);
const limit = threshold * spread;
verdicts.push({
index,
value: series[index],
usual,
deviation,
limit,
isAnomaly: deviation > limit,
});
}
return verdicts;
}
/**
* Every point over the limit, each one still carrying its numbers.
*
* This is the dashboard view, not the pager view: at the default settings a
* healthy metric puts one point in a hundred here. `episodes` is the pager
* view.
*/
export function anomalies(series, options = {}) {
return scan(series, options).filter((verdict) => verdict.isAnomaly);
}
/**
* What a pager should see: one entry per run of points over the limit.
*
* Two rules, and they are the ones every on-call rota ends up adding. A run has
* to hold for `consecutive` points before it counts, which is what keeps the
* noise of a healthy metric off the phone. And a run is reported once, not once
* per minute, which is what keeps one step change from paging a dozen times.
*
* `consecutive: 1` covers every point of `anomalies`, runs of them grouped into
* one episode each.
*
* Each episode carries `start`, `length`, the verdict that `openedBy` it and
* the `worst` point of the run, with the numbers that judged them.
*/
export function episodes(series, options = {}) {
const { consecutive = 3, ...rest } = options;
if (consecutive < 1) throw new RangeError('consecutive must be at least one point');
const found = [];
let run = [];
const close = () => {
if (run.length >= consecutive) {
const worst = run.reduce((a, b) => (b.deviation - b.limit > a.deviation - a.limit ? b : a));
found.push({ start: run[0].index, length: run.length, openedBy: run[0], worst });
}
run = [];
};
for (const verdict of scan(series, rest)) {
if (verdict.isAnomaly) run.push(verdict);
else close();
}
close();
return found;
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
Every verdict carries the numbers that produced it: the measured value, what the preceding twelve minutes called normal, the gap between the two, and the gap that was allowed.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
1 match
What the code picked up
1080 1120 1160 1200 1240 1280 1320 1080 1120 1160 1200 1240 1280 1320 1080 1120 1160 1200 1240 1280 1320 1080 1120 1160 1200 1240 1280 1320 1080 1120 1160 1200 1240 4800 1320 1080 1120 1160 1200 1240 1280 1320 1080 1120 1160 1200 1240 1280
What it returns
| Minute | Measured | Usual | Gap | Gap allowed |
|---|---|---|---|---|
| 33 | 4800 | 1180 | 3620 | 311 |
1 anomaly over 36 minutes judged, 48 points given. The first 12 minutes are not judged: they are the window.
Here it breaksSee the level above
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the points never leave your infrastructure
- Does not stand in for looking at what your series hold: a metric indexed per user is personal data before it is a curve
Breaking point
A slow drift walks into the window and becomes the new normal. The test lifts the metric by forty a minute for over an hour: every step sits well inside the tolerated gap and the window swallows the ones before it, so the gap to the usual value comes close to the limit without ever crossing it. Not one point is ever flagged, and the series ends at more than three times its resting level. The same total rise delivered in a single step is caught on its first minute, and for a dozen minutes only: it too becomes the new normal. A fast daily climb, a Monday morning after a Sunday night, rings for the same reason: there is no seasonality here, only a window.
When to move up a level
Your post-mortems keep ending the same way: no metric left its own window, and only the combination was unusual.
The four options
- RecommendedN0Robust threshold on median and median absolute deviation over a sliding window, with a persistence ruleFree · Instant
- N1Isolation forest over several metrics at oncenégligeable · Instant
- N2Not worth it here
- N3Not worth it here
The other options
N1Isolation forest over several metrics at oncenégligeable · Instant
Isolation forest over several metrics at once
Lightweight classic modelN1négligeableInstant
Python
"""
Spot anomalies in several metrics at once with an isolation forest.
Rung N1. The robust threshold of N0 watches one metric at a time, each against
its own recent window. An isolation forest watches the metrics together,
against everything it was trained on: night-time traffic with daytime errors
is a minute whose values each sit inside the range the service has known, and
whose combination does not. A threshold on each series rings for such a minute
only if one of its values leaves that series' own window.
An isolation forest cuts the space at random and measures how few cuts it
takes to leave a point on its own. A point in the middle of the crowd needs
many; a point out on its own needs three or four. There is nothing to label,
and the knob that changes what rings is the threshold, not the size of the
forest.
What this rung costs is the thing N0 was good at. The output is a rank
between zero and one, not a measured gap against a stated limit. Whoever is
woken up is told that the minute was unusual, not in what way.
"""
import math
from sklearn.ensemble import IsolationForest
# Trees are grown on random cuts, so the seed is part of the contract: an
# alert that changes between two runs on the same data is not an alert.
SEED = 0
def train(rows: list[list[float]], trees: int = 100, seed: int = SEED) -> IsolationForest:
"""
`rows` is one observation per minute, the metrics always in the same order.
Nothing is labelled and nothing is scaled: the cuts are drawn between the
smallest and the largest value of each metric, so a metric counted in
milliseconds and one counted in requests weigh the same.
"""
model = IsolationForest(n_estimators=trees, random_state=seed).fit(rows)
# The range each metric was seen in, missing values left out.
columns = [[value for value in column if not math.isnan(value)] for column in zip(*rows)]
model.bounds_ = [(min(c), max(c)) if c else (-math.inf, math.inf) for c in columns]
return model
def scores(model: IsolationForest, rows: list[list[float]]) -> list[float]:
"""
Between zero and one. Above one half, the point took fewer cuts to isolate
than the crowd did, which is the whole definition of an anomaly here.
A value past the range its metric was trained on scores one: no cut was
ever drawn out there, and the forest would file it with the largest values
it saw.
"""
ranks = -model.score_samples(rows) # one call for all the rows
return [
1.0 if any(v < low or v > high for v, (low, high) in zip(row, model.bounds_)) else float(rank)
for row, rank in zip(rows, ranks)
]
def score(model: IsolationForest, row: list[float]) -> float:
"""The score of a single row."""
return scores(model, [row])[0]
def anomalies(model: IsolationForest, rows: list[list[float]], threshold: float = 0.6) -> list[int]:
"""
The indices worth looking at, and the threshold is yours to set.
Move it towards one to be woken up less often and miss more; towards zero
for the opposite trade. There is no value of it that turns the score into
an explanation.
"""
return [index for index, value in enumerate(scores(model, rows)) if value > threshold]JavaScript
/**
* Spot anomalies in several metrics at once with an isolation forest.
*
* Rung N1. The robust threshold of N0 watches one metric at a time, each
* against its own recent window. An isolation forest watches the metrics
* together, against everything it was trained on: night-time traffic with
* daytime errors is a minute whose values each sit inside the range the
* service has known, and whose combination does not. A threshold on each
* series rings for such a minute only if one of its values leaves that series'
* own window.
*
* The forest cuts the space at random and measures how few cuts it takes to
* leave a point on its own. A point in the middle of the crowd needs many; a
* point out on its own needs three or four. There is nothing to label, and
* the knob that changes what rings is the threshold, not the size of the
* forest.
*
* Written out in full rather than pulled from a library, because that is the
* whole argument of this rung: the classical tool is small enough to read.
* What it costs is the thing N0 was good at — the output is a rank between
* zero and one, not a measured gap against a stated limit.
*/
/** Deterministic pseudo-random numbers: an alert that changes between two runs is not an alert. */
function generator(seed) {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Average depth of an unsuccessful search in a binary tree of n points. */
function averageDepth(n) {
if (n <= 1) return 0;
const EULER = 0.5772156649015329;
return 2 * (Math.log(n - 1) + EULER) - (2 * (n - 1)) / n;
}
/** One tree: cut on a random metric at a random value until the points are alone. */
function grow(rows, random, depth, maxDepth) {
if (depth >= maxDepth || rows.length <= 1) return { size: rows.length };
const metric = Math.floor(random() * rows[0].length);
const values = rows.map((row) => row[metric]);
const low = Math.min(...values);
const high = Math.max(...values);
if (low === high) return { size: rows.length };
const cut = low + random() * (high - low);
return {
metric,
cut,
below: grow(rows.filter((row) => row[metric] < cut), random, depth + 1, maxDepth),
above: grow(rows.filter((row) => row[metric] >= cut), random, depth + 1, maxDepth),
};
}
/** How deep this row falls, plus what an unfinished leaf would still have cost. */
function depthOf(node, row, depth) {
if (node.below === undefined) return depth + averageDepth(node.size);
return depthOf(row[node.metric] < node.cut ? node.below : node.above, row, depth + 1);
}
/** A subsample without replacement, so every tree sees a different crowd. */
function subsample(rows, size, random) {
const shuffled = [...rows];
for (let i = 0; i < size; i += 1) {
const j = i + Math.floor(random() * (shuffled.length - i));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled.slice(0, size);
}
/**
* `rows` is one observation per minute, the metrics always in the same order.
*
* Nothing is labelled and nothing is scaled: the cuts are drawn between the
* smallest and the largest value of each metric, so a metric counted in
* milliseconds and one counted in requests weigh the same.
*/
export function train(rows, { trees = 100, seed = 0 } = {}) {
// A row with a missing value would put NaN in every cut drawn on its metric.
const complete = rows.filter((row) => row.every(Number.isFinite));
if (complete.length === 0) throw new RangeError('no complete row to train on');
const random = generator(seed);
const size = Math.min(256, complete.length);
const maxDepth = Math.ceil(Math.log2(size));
const forest = [];
for (let i = 0; i < trees; i += 1) {
forest.push(grow(subsample(complete, size, random), random, 0, maxDepth));
}
const bounds = complete[0].map((_, k) => complete.reduce(
([low, high], row) => [Math.min(low, row[k]), Math.max(high, row[k])],
[Infinity, -Infinity],
));
return { forest, normaliser: averageDepth(size), bounds };
}
/**
* Between zero and one. Above one half, the point took fewer cuts to isolate
* than the crowd did, which is the whole definition of an anomaly here.
*
* A value past the range its metric was trained on scores one: no cut was
* ever drawn out there, and the forest would file it with the largest values
* it saw.
*/
export function score(model, row) {
if (row.some((value, k) => value < model.bounds[k][0] || value > model.bounds[k][1])) return 1;
if (model.normaliser === 0) return 0.5; // a single training row: no crowd to compare with
const total = model.forest.reduce((sum, tree) => sum + depthOf(tree, row, 0), 0);
return 2 ** (-total / model.forest.length / model.normaliser);
}
/**
* The indices worth looking at, and the threshold is yours to set.
*
* Move it towards one to be woken up less often and miss more; towards zero
* for the opposite trade. There is no value of it that turns the score into
* an explanation.
*/
export function anomalies(model, rows, threshold = 0.6) {
return rows.map((_, index) => index).filter((index) => score(model, rows[index]) > threshold);
}What this means for you
- Your data stays put
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- Processing on your own infrastructure: no metric goes to a third party
- The model is derived from your operational data: its cuts are drawn between the smallest and the largest observed value of each metric, and keep a trace of them
Breaking point
Habituation. Fifteen minutes of night traffic carrying a broad-daylight error count, slipped into the training data, are enough for the forest to treat that combination as a third ordinary regime. The minute the test flagged just before is flagged no longer, and nothing in the output says anything changed. It is N0's breaking point one floor up: whatever goes into training becomes normal.
When to move up a level
There is no level above this one here. What comes next is not a bigger model but somebody deciding which regimes are allowed to exist.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
A self-hosted time-series model is one more service to run, and a service that decides your alerts is a service that has to be watched in turn. At three in the morning the question is not whether the score is better, it is why the phone rang. N0 answers in three numbers; this does not answer at all.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
Two calls on the same window can return two different verdicts, and an alert nobody can replay is an alert the on-call rota learns to ignore. On top of that it is one call a minute per metric, for a decision the comparison of two numbers already settles.
Further reading
- Wikipedia — Median absolute deviation, et le facteur qui la met à l’échelle d’un écart type
- Liu, Ting et Zhou — Isolation Forest, l’article d’origine (ICDM 2008)
- scikit-learn — IsolationForest, la mise en œuvre employée côté Python
- Google SRE Book — Monitoring Distributed Systems, sur ce qui mérite de réveiller quelqu’un
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.