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

snippets/detect-anomalies-in-metrics/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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 found

JavaScript

snippets/detect-anomalies-in-metrics/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

interactive

The 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.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

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

MinuteMeasuredUsualGapGap allowed
33480011803620311

1 anomaly over 36 minutes judged, 48 points given. The first 12 minutes are not judged: they are the window.

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

  1. RecommendedN0Robust threshold on median and median absolute deviation over a sliding window, with a persistence ruleFree · Instant
  2. N1Isolation forest over several metrics at oncenégligeable · Instant
  3. N2Not worth it here
  4. 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

snippets/detect-anomalies-in-metrics/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/detect-anomalies-in-metrics/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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

Metadata