Catalogue Prédire

Repérer une anomalie dans une métrique

Être réveillé quand une métrique de production sort de son comportement habituel, et pas le reste du temps.

RecommandéN0 Révisée le

Récapitulatif des barreaux
Barreau Approche Coût Latence Données Déterministe Verdict
Règle et algorithme classique N0 — Règle et algorithme classique Seuil robuste sur médiane et écart absolu médian, en fenêtre glissante Nul <1 ms Rien ne sort Oui Recommandé
Modèle classique léger N1 — Modèle classique léger Forêt d'isolement sur plusieurs métriques conjointes Négligeable ~10 ms Rien ne sort Oui
Petit modèle spécialisé auto-hébergé N2 — Petit modèle spécialisé auto-hébergé Barreau absent Un modèle de séries temporelles auto-hébergé est un service de plus à faire tourner, et un service qui décide des alertes est un service qu'il faut surveiller à son tour. À trois heures du matin, la question n'est pas de savoir si le score est meilleur : c'est de savoir pourquoi le téléphone a sonné. N0 répond en trois nombres, celui-là ne répond pas.
API de LLM généraliste N3 — API de LLM généraliste Barreau absent Deux appels sur la même fenêtre peuvent rendre deux verdicts, et une alerte qu'on ne peut pas rejouer est une alerte que l'astreinte apprend à ignorer. S'y ajoute un appel par minute et par métrique, pour une décision que la comparaison de deux nombres tranche déjà.

N0 — Règle et algorithme classique Règle et algorithme classique Recommandé

Seuil robuste sur médiane et écart absolu médian, en fenêtre glissante

Coût
Nul
Latence
<1 ms

Preuve d’exécution : Code exécuté tel quel

Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.

Python

snippets/detect-anomalies-in-metrics/n0.py
"""
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 ignore up to half the
window, which is exactly 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 2100" is already half the
diagnosis, and it is the same three numbers the threshold itself used.
"""

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, so that a threshold of
# 3.5 keeps the meaning it has everywhere else.
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) -> 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.
    """
    verdicts = []
    for index in range(window, len(series)):
        reference = series[index - window : index]
        usual = median(reference)
        spread = NORMAL_SCALE * median([abs(value - usual) for value in reference])
        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) -> list[Verdict]:
    """The subset a pager should see, each one still carrying its numbers."""
    return [verdict for verdict in scan(series, window, threshold) if verdict.is_anomaly]

JavaScript

snippets/detect-anomalies-in-metrics/n0.js
/**
 * 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 ignore up to half
 * the window, which is exactly 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 2100" is already half the
 * diagnosis, and it is the same three numbers the threshold itself used.
 */

// Scaling that puts the median absolute deviation on the same footing as a
// standard deviation for normally distributed data, so that a threshold of
// 3.5 keeps the meaning it has everywhere else.
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.
 *
 * 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 } = {}) {
  const verdicts = [];
  for (let index = window; index < series.length; index += 1) {
    const reference = series.slice(index - window, index);
    const usual = median(reference);
    const spread = NORMAL_SCALE * median(reference.map((value) => Math.abs(value - usual)));
    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;
}

/** The subset a pager should see, each one still carrying its numbers. */
export function anomalies(series, options = {}) {
  return scan(series, options).filter((verdict) => verdict.isAnomaly);
}

Risques

Sortie de données
Rien ne sort
Déterminisme
Oui
Testabilité
Testable unitairement
Dépendance fournisseur
Aucune
Empreinte
Négligeable
Périmètre réglementaire
  • Aucun périmètre spécifique ajouté : les points restent sur votre infrastructure
  • Ne vous dispense pas de regarder ce que contiennent vos séries : une métrique indexée par utilisateur est une donnée personnelle avant d'être une courbe

Point de rupture

Une dérive lente entre dans la fenêtre et devient la nouvelle normale. Le test fait grimper la métrique de quarante points par minute pendant plus d'une heure : chaque pas reste loin en deçà de l'écart toléré, et la fenêtre a déjà avalé les précédents. Pas un seul point n'est signalé, et la série finit à plus du triple de son niveau de repos. La même hausse totale livrée d'un coup est signalée dès sa première minute, et pour une douzaine de minutes seulement : elle aussi devient la nouvelle normale.

Quand monter d’un barreau

Vos post-mortem se terminent sur le même constat : chaque métrique était dans sa plage habituelle, seule leur combinaison ne l'était pas.

N1 — Modèle classique léger Modèle classique léger

Forêt d'isolement sur plusieurs métriques conjointes

Coût
Négligeable
Latence
~10 ms

Preuve d’exécution : Code exécuté tel quel

Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.

Python

snippets/detect-anomalies-in-metrics/n1.py
"""
Spot anomalies in several metrics at once with an isolation forest.

Rung N1. The robust threshold of N0 watches one metric at a time, and some
incidents are invisible that way: every metric stays inside its usual range,
and only the combination is impossible. Errors at their normal ceiling while
traffic sits at its normal middle is one such minute, and no single-series
threshold will ever ring for it.

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 only real knob is 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.
"""

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)
    return model.fit(rows)


def score(model: IsolationForest, row: list[float]) -> 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.
    """
    return float(-model.score_samples([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, row in enumerate(rows) if score(model, row) > threshold]

JavaScript

snippets/detect-anomalies-in-metrics/n1.js
/**
 * Spot anomalies in several metrics at once with an isolation forest.
 *
 * Rung N1. The robust threshold of N0 watches one metric at a time, and some
 * incidents are invisible that way: every metric stays inside its usual
 * range, and only the combination is impossible. Night-time traffic with
 * daytime errors is one such minute, and no single-series threshold will ever
 * ring for it.
 *
 * 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 only real knob is 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 } = {}) {
  const random = generator(seed);
  const size = Math.min(256, rows.length);
  const maxDepth = Math.ceil(Math.log2(size));
  const forest = [];
  for (let i = 0; i < trees; i += 1) {
    forest.push(grow(subsample(rows, size, random), random, 0, maxDepth));
  }
  return { forest, normaliser: averageDepth(size) };
}

/**
 * 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.
 */
export function score(model, row) {
  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);
}

Risques

Sortie de données
Rien ne sort
Déterminisme
Oui
Testabilité
Testable statistiquement
Dépendance fournisseur
Bibliothèque
Empreinte
Faible
Périmètre réglementaire
  • Traitement sur votre infrastructure : aucune métrique ne part chez un tiers
  • Le modèle est dérivé de vos données d'exploitation : ses coupes sont tirées entre la plus petite et la plus grande valeur observée de chaque métrique, et en gardent la trace

Point de rupture

L'habituation. Quinze minutes de trafic de nuit portant un nombre d'erreurs de plein jour, glissées dans les données d'entraînement, suffisent pour que la forêt en fasse un troisième régime ordinaire. La minute que le test signalait juste avant ne l'est plus, et rien dans la sortie ne dit que quoi que ce soit a changé. C'est le point de rupture de N0, monté d'un étage : ré-entraîner sur le mois écoulé, c'est décider de ce qui compte comme normal.

Quand monter d’un barreau

Il n'y a pas de barreau au-dessus dans cette fiche. Ce qui manque ensuite n'est pas un modèle plus gros, c'est quelqu'un pour décider quels régimes ont le droit d'exister.

N2 — Petit modèle spécialisé auto-hébergé Petit modèle spécialisé auto-hébergé

Barreau absent

Un modèle de séries temporelles auto-hébergé est un service de plus à faire tourner, et un service qui décide des alertes est un service qu'il faut surveiller à son tour. À trois heures du matin, la question n'est pas de savoir si le score est meilleur : c'est de savoir pourquoi le téléphone a sonné. N0 répond en trois nombres, celui-là ne répond pas.

N3 — API de LLM généraliste API de LLM généraliste

Barreau absent

Deux appels sur la même fenêtre peuvent rendre deux verdicts, et une alerte qu'on ne peut pas rejouer est une alerte que l'astreinte apprend à ignorer. S'y ajoute un appel par minute et par métrique, pour une décision que la comparaison de deux nombres tranche déjà.

Le verdict

RecommandéN0

N0 est le seul barreau dont la sortie est le raisonnement : la valeur mesurée, ce que la fenêtre appelait habituel, l'écart, et l'écart qui était toléré. C'est la moitié du diagnostic livrée avec l'alerte, sans coût marginal, et le test la vérifie nombre par nombre. N1 voit ce que N0 ne verra jamais — la minute où chaque métrique reste dans sa plage et où seule la combinaison est impossible — et le paie en rendant un rang entre zéro et un : on apprend que la minute était inhabituelle, pas en quoi.

Pour aller plus loin

Métadonnées