Machine Learning · Module 18 of 26 · 10 concepts · about 18 min

Anomaly Detection

Finding the rows that do not belong. Statistical baselines, isolation and density methods, and the awkward fact that you usually have no labels to check any of it against.

At work: Spotting the rare and unusual: fraudulent payments, failing machines, hacked accounts, broken data feeds.

What makes anomalies hard

What it is

An anomaly is rare and unlike the rest. Both halves cause trouble: rare means there are too few examples to learn a positive class from, and unlike-the-rest means new anomalies need not resemble the old ones, so a classifier trained on last year's frauds can miss this year's entirely.

Why it matters

It decides the shape of the whole problem. Because you cannot reliably learn 'what an anomaly looks like', almost every method here instead learns 'what normal looks like' and scores distance from it — which is why they are unsupervised even when a few labels exist.

In the real world: Fraud is rare, varied and constantly changing, so there are few labelled examples to learn from.

import numpy as np

# 100,000 transactions, 100 of them fraudulent.
n, n_bad = 100_000, 100
prevalence = n_bad / n                     # 0.001

# A model that predicts 'normal' every single time:
accuracy = (n - n_bad) / n                 # 0.999
recall = 0.0                               # it catches nothing
print(f"prevalence {prevalence:.3%}  accuracy {accuracy:.3%}  recall {recall:.1%}")

# And a five-fold split leaves only about 20 positives per fold to evaluate on.
n_bad / 5

Framing it as supervised classification when there are 40 positive examples. A classifier will memorise those 40 and generalise to nothing; the useful information in them is as a *validation* set for an unsupervised detector, not as a training set for a discriminator.

Novelty detection vs outlier detection

What it is

Outlier detection fits on data that already contains the anomalies and asks which of *these* rows are odd — you set a contamination fraction. Novelty detection fits on data believed to be clean and then scores new rows against it, which is what predict on a held-out set means.

Why it matters

The two need different code paths and different data. Getting them confused is how a detector ends up fitted on a training set whose 3% contamination it has quietly learned to treat as normal, after which it never flags anything like it again.

In the real world: Training on clean machine data to catch new faults, versus finding the odd transactions already hiding in a messy log.

from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor

# Outlier detection: flag the odd rows in the data you have.
iso = IsolationForest(contamination=0.02, random_state=0)
flags = iso.fit_predict(X)                  # -1 anomaly, +1 normal

# LOF is transductive by default: fit_predict only, no predict.
lof = LocalOutlierFactor(n_neighbors=20)
flags = lof.fit_predict(X)
lof.negative_outlier_factor_                # the scores for the fitted rows

# Novelty detection: fit on clean data, then score new rows.
lof_nov = LocalOutlierFactor(n_neighbors=20, novelty=True).fit(X_clean)
lof_nov.predict(X_new)
lof_nov.decision_function(X_new)            # negative = anomalous

Calling predict on a LocalOutlierFactor fitted with the default novelty=False. It raises, and the reason is not a missing feature: LOF's score is defined relative to the fitted sample's local densities, so a score for a row that was not in that sample is not well defined without refitting.

The statistical baseline: z-score

What it is

Flag a value when |x - mean| / std exceeds a cutoff, conventionally 3. It is one line, it is interpretable, and under a genuinely normal distribution 3 sigma is about 0.27% of the data.

Why it matters

It is the baseline every fancier method should have to beat, and on a single well-behaved sensor channel it usually is not beaten. It also fails in an instructive way, which is the next concept.

In the real world: Flagging a day when sales are more than 3 standard deviations from normal, as a first alert.

Visual example

3-sigma rule

Twenty readings with two obvious outliers. The 3-sigma bounds sit at 22.6 and 77.2 — the outliers barely clear the bar they themselves set.

sensor valueflagged (|z| > 3)
05101520304050607080Reading indexSensor valuemean + 3 sigmamean - 3 sigmamean

Masking, in numbers

With the outliers: mean 49.95, sigma 9.13 -> bounds [22.6, 77.2]

z(78) = +3.07 and z(22) = -3.06. Flagged by 2%.

Without them: mean 49.94, sigma 1.95 -> bounds [44.1, 55.8]. z(78) would be +14.4.

Robust version: median 50, MAD 1.48 -> modified z = 0.6745 (78 - 50) / 1.48 = 12.8

Why this matters

The two dashed red lines are more than four times further out than the data warrants, and they are that far out *because of* the two points they are meant to catch. This is the argument for robust statistics in one picture.

Common misconception

Wrong idea: the 3-sigma rule flags anything unusual.

Correct idea: it flags anything unusual relative to a yardstick the unusual points helped build. A cluster of outliers raises sigma until it is inside its own bounds.

import numpy as np

x = np.array([50, 52, 48, 51, 49, 53, 47, 50, 52, 49,
              78, 51, 48, 50, 52, 49, 51, 47, 50, 22], dtype=float)

z = (x - x.mean()) / x.std(ddof=0)
flagged = np.abs(z) > 3
print(x.mean(), x.std(ddof=0))     # 49.95, 9.13
print(x[flagged], z[flagged])      # [78. 22.] [ 3.07 -3.06] — only just over

# Multivariate: the same idea with the covariance folded in.
from scipy.spatial.distance import mahalanobis

The mean and the standard deviation are both computed from data that contains the outliers, so the outliers inflate the very yardstick used to judge them — *masking*. Here two outliers push sigma from 2.0 to 9.1, and they clear the 3-sigma bar by 2%; add a third and none of them would be flagged at all.

Practise it: Macro and Micro F1, From the Confusion Matrix Alone

Robust alternatives: IQR and the MAD

What it is

Replace the mean and standard deviation with quantities the outliers cannot move. Tukey's rule flags values outside [Q1 - 1.5 IQR, Q3 + 1.5 IQR]; the modified z-score uses the median and the median absolute deviation, 0.6745 (x - median) / MAD, with 3.5 as the usual cutoff.

Why it matters

The median and the MAD have a breakdown point of 50%: up to half the data can be arbitrarily corrupted before they move meaningfully. That is what makes them usable as a yardstick for the corruption itself.

In the real world: Detecting unusual transaction amounts without a few giant corporate orders skewing the threshold.

Visual example

Breakdown point

The same twenty readings, judged by classical and robust statistics. The scores differ by a factor of four.

x = [50, 52, ..., 78, ..., 22]
statisticclassicalrobustmoved by outliers?
centre49.9550barely
spread9.131.486.2x
upper bound77.256.5yes
score for 783.0712.84.2x

x.std(ddof=0)

9.13 — inflated by the very points it judges

median_abs_deviation(x)

1.48 — unmoved, so the score is honest

Robustly, 78 is a 12.8-sigma event, not a 3.07-sigma one

Both cutoffs, applied

Tukey: Q1 = 48.75, Q3 = 51.25, IQR = 2.5 -> bounds [45.0, 55.0]

Classical 3-sigma: bounds [22.6, 77.2] — 12x wider

The classical rule would miss 60, 65 or 70 entirely; the robust rule flags all three

Why this matters

The 'spread' row is the whole story: 9.13 against 1.48. Every downstream cutoff inherits that factor of six, which is how a classical rule ends up blind to values 40% above normal.

Common misconception

Wrong idea: the median and the mean are interchangeable summaries, so it hardly matters which you use.

Correct idea: for outlier detection specifically it is the entire question, because the statistic is being used to judge the data that produced it.

import numpy as np
from scipy.stats import median_abs_deviation

q1, q3 = np.percentile(x, [25, 75])
iqr = q3 - q1
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
tukey = (x < lo) | (x > hi)

mad = median_abs_deviation(x, scale=1.0)
mod_z = 0.6745 * (x - np.median(x)) / mad
robust = np.abs(mod_z) > 3.5

print(lo, hi, x[tukey])            # 44.5 56.5  [78. 22.]
print(np.round(mod_z[[10, 19]], 1))   # [ 12.8 -12.8]

Applying either rule column by column on multivariate data and calling the result outlier detection. A row can be unremarkable on every single feature and impossible as a combination — a 19-year-old with 40 years of tenure. Per-column rules cannot see that; EllipticEnvelope or a Mahalanobis distance can.

Isolation Forest

What it is

Build many trees by repeatedly choosing a random feature and a random split value within its range, until every point sits alone in a leaf. Record how many splits each point needed. Anomalies sit in sparse regions, so a random cut separates them early; the score is 2 ** (-E[h] / c(n)), near 1 for anomalies and near 0.5 for normal points.

Why it matters

It is the default choice for tabular anomaly detection and deserves to be: it is linear in rows, needs no distance metric, is largely indifferent to feature scaling, and handles moderately high dimensions where distance-based methods have already failed.

In the real world: A fast, common method for flagging suspicious card transactions among millions.

Visual example

IsolationForest

How fast random splits isolate a point: an outlier is alone after about 4 cuts, a point inside the dense region needs about 12.

an outliera normal point
Outlier isolated02468101200.20.40.60.81Random splits appliedFraction of the sample still in the point's regionalone (1 of 256)h = 4.1h = 11.6

From path length to anomaly score

max_samples = 256, so the normalising constant c(256) = 10.24

Outlier: mean path length E[h] = 4.1 -> s = 2 ** (-4.1 / 10.24) = 0.757

Normal: mean path length E[h] = 11.6 -> s = 2 ** (-11.6 / 10.24) = 0.456

s -> 1 means 'isolated immediately'; s ~ 0.5 means 'as deep as an average point'

Why this matters

The score is a path length, not a distance, which is why the method survives dimensions where a Euclidean metric has stopped discriminating. Nothing here needs the features to be comparable in scale.

Common misconception

Wrong idea: Isolation Forest learns what anomalies look like from the anomalies in the training data.

Correct idea: the splits are random — no target, no impurity criterion, nothing learned about the anomalies. All the signal is in how quickly a sparse region gets cut off from everything else.

import numpy as np
from sklearn.ensemble import IsolationForest

iso = IsolationForest(
    n_estimators=200,
    max_samples=256,          # each tree is grown on a small subsample, by design
    contamination=0.01,       # or "auto" to leave the threshold unset
    random_state=0,
).fit(X_train)

score = -iso.score_samples(X_test)     # higher = more anomalous
flags = iso.predict(X_test) == -1
threshold = -iso.offset_               # the cut contamination implied

# Rank by score rather than trusting contamination; review the top 200 by hand.
top = np.argsort(score)[::-1][:200]

Reading contamination as a property of the data. It is a *quantile* of the score distribution: contamination=0.01 flags exactly 1% of whatever you pass it, whether that data holds 5% anomalies or none. Use score_samples and choose the cut yourself unless you genuinely know the rate.

One-Class SVM

What it is

Fit a boundary that encloses most of the training data in a kernel-implied feature space, admitting a fraction nu of the points as outliers. With an RBF kernel the boundary in the original space can be curved and even disconnected, wrapping tightly around the normal region.

Why it matters

It is the natural choice when normal behaviour genuinely is a single well-defined region and you have clean data to define it with — classic novelty detection. It is also the method whose failure mode is most instructive, since a badly chosen gamma produces a boundary that wraps each training point individually.

In the real world: Learning what normal server traffic looks like, and flagging anything outside it as a possible attack.

Visual example

OneClassSVM(kernel='rbf')

A learned boundary around the normal region. Inside is normal by definition; the two red points fall outside it.

training data (normal)decision_function = 0new rows, flagged -1
-4-2024-3-2-10123feature 1 (scaled)feature 2 (scaled)-1

Scoring two new rows

decision_function((3.5, 2.8)) = -0.41 -> outside -> predict -1

decision_function((0.4, -0.2)) = +0.88 -> inside -> predict +1

nu = 0.02 allows 2% of the training rows to fall outside the boundary

gamma too large: the boundary collapses onto the ten training points individually

Why this matters

The boundary is the model. There is no notion of an anomaly class anywhere in the fit — only 'the region the training data occupies' and the signed distance from it.

Common misconception

Wrong idea: One-Class SVM is a classifier trained on normal versus anomalous data.

Correct idea: it sees one class only. It draws the smallest enclosing region it can around that class, and everything not enclosed is an outlier by default rather than by evidence.

from sklearn.svm import OneClassSVM, SGDOneClassSVM
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

oc = make_pipeline(
    StandardScaler(),
    OneClassSVM(kernel="rbf", nu=0.02, gamma="scale"),
).fit(X_clean)                 # fitted on data believed to be normal

oc.predict(X_new)              # -1 outside the boundary, +1 inside
oc.decision_function(X_new)    # signed distance: negative is outside

# Kernel OneClassSVM is quadratic-to-cubic in rows. Above ~20k rows:
SGDOneClassSVM(nu=0.02, random_state=0)

Leaving gamma unexamined. A large gamma makes the kernel narrow, so the boundary shrink-wraps each training point and everything else is an outlier — including perfectly normal new rows. gamma="scale" is a sane default, nu is an upper bound on the training-outlier fraction, and the method needs scaled features because the RBF kernel is a distance.

Local Outlier Factor

What it is

Compare a point's local density to the local densities of its k nearest neighbours. LOF is roughly the ratio: about 1 means as dense as its neighbourhood, substantially above 1 means it sits in a sparser place than the points around it.

Why it matters

It catches the anomalies a global rule structurally cannot: a point sitting just outside a tight cluster, at a distance that would be perfectly ordinary inside a sparse cluster elsewhere in the same dataset. One global cutoff cannot be right for both regions; a ratio to the local neighbourhood can.

In the real world: Spotting a seller whose prices are odd compared with similar sellers, even if they look normal overall.

Visual example

LocalOutlierFactor

The red point is 1.2 units from a tight cluster — closer than two neighbouring points in the sparse cluster, and far more anomalous.

dense clustersparse clusterLOF flags this one
024680246feature 1feature 2LOF 5.2

Why a global distance rule misses it

Dense cluster: typical neighbour distance 0.22

Red point's distance to its neighbours: 1.15 -> LOF = 1.15 / 0.22 = 5.2

Sparse cluster: typical neighbour distance 1.90

A sparse point's neighbours are also ~1.85 away -> LOF = 1.03, normal

A global cutoff of 1.5 units flags every sparse point; one of 2.0 flags nothing at all

Why this matters

The red point's absolute distance, 1.15, is *smaller* than the ordinary spacing inside the green cluster. No single global radius can flag it without also flagging five perfectly normal rows.

Common misconception

Wrong idea: an outlier is a point that is far from everything.

Correct idea: it is a point that is far compared with how far its own neighbours are from each other. Density varies across a dataset, and LOF is the version of the question that accounts for that.

import numpy as np
from sklearn.neighbors import LocalOutlierFactor

lof = LocalOutlierFactor(n_neighbors=20, metric="euclidean")
flags = lof.fit_predict(X)                     # -1 anomaly, +1 normal
scores = -lof.negative_outlier_factor_         # ~1.0 normal, >1.5 suspicious

print(np.round(np.sort(scores)[::-1][:5], 2))

# For scoring unseen rows, refit in novelty mode on clean data.
nov = LocalOutlierFactor(n_neighbors=20, novelty=True).fit(X_clean)
nov.decision_function(X_new)

n_neighbors is the whole model. Too small and a pair of adjacent anomalies vouch for each other's density, so neither is flagged; too large and the 'local' neighbourhood spans several clusters and LOF degenerates towards a global rule. 20 is a reasonable default, and it must exceed the size of any anomaly cluster you hope to catch.

Autoencoders and reconstruction error

What it is

Train a network — or, more cheaply, a PCA — to compress each row to a narrow bottleneck and reconstruct it. Fitted on normal data, it learns the normal manifold and rebuilds normal rows accurately. An anomalous row does not lie on that manifold, so it reconstructs badly, and the per-row reconstruction error is the anomaly score.

Why it matters

It is the approach that scales to data with no useful distance metric at all: images, text embeddings, high-dimensional sensor traces. It also unifies this module with module 15 — PCA's inverse_transform error is exactly this idea with a linear bottleneck.

In the real world: Flagging machine sensor patterns a model can't reproduce well, an early warning of equipment failure.

Visual example

Reconstruction error

Reconstruction error separates the two populations but does not divide them — the overlap is where every false alarm comes from.

normal rowsanomalous rows
Flagged00.20.40.60.8100.20.40.60.81Per-row reconstruction errorDensity (each population normalised)threshold

What the threshold buys, at 0.5% prevalence

Threshold = 0.32, the 99.5th percentile of the normal population

Recall = 0.86 of anomalies exceed it; false positive rate = 0.005

Per 100,000 rows: 500 anomalies -> 430 caught; 99,500 normal -> 498 false alarms

Precision = 430 / (430 + 498) = 0.46 — roughly one false alarm per catch

Why this matters

The two curves are drawn at equal height for readability, but the red population is 200 times rarer. That is why a 0.5% false positive rate still costs more false alarms than there are anomalies — the arithmetic module 18 is built around.

Common misconception

Wrong idea: a good detector separates the two error distributions cleanly, so the threshold hardly matters.

Correct idea: they always overlap, and the threshold decides how you split the overlap. There is no setting with no false alarms and no misses; there is only a choice about which you prefer.

import numpy as np
from sklearn.decomposition import PCA

# The linear version: fit on clean rows, score by reconstruction error.
pca = PCA(n_components=10, random_state=0).fit(X_clean_scaled)
err = np.mean((X_new_scaled - pca.inverse_transform(pca.transform(X_new_scaled))) ** 2, axis=1)

# Threshold from the clean data's own error distribution, not from X_new.
clean_recon = pca.inverse_transform(pca.transform(X_clean_scaled))
clean_err = np.mean((X_clean_scaled - clean_recon) ** 2, axis=1)
threshold = float(np.quantile(clean_err, 0.995))
flags = err > threshold
print(f"threshold {threshold:.4f}, flagged {flags.mean():.2%}")

Making the bottleneck too wide, or training too long on contaminated data. Either way the model learns to reconstruct the anomalies too, the error distributions overlap, and the detector silently stops working. Keep the bottleneck narrow enough that reconstruction is genuinely lossy, and validate on held-out clean rows.

Evaluating at very low prevalence

What it is

Accuracy is unusable when 99.9% of rows are normal: predicting 'normal' always scores 0.999. What you need is precision and recall on the anomaly class, average precision (PR-AUC), and precision@k when a human will only ever review the top k alerts.

Why it matters

It is the difference between a detector that is deployable and one that is not. A 1% false positive rate sounds excellent and, at 0.1% prevalence, means ten false alarms for every genuine one — an alert queue nobody will keep reading.

In the real world: When 1 transaction in 10,000 is fraud, the metrics that look fine can hide a useless detector.

Visual example

Low prevalence

One confusion matrix at 0.1% prevalence: 98.99% accuracy, and 93% of the alerts are wrong.

990 false alarms

(a 1% false positive rate, on 99,900 normal rows)

100,000 transactions, 100 fraudulent, threshold at the 99th percentile
predicted normalpredicted fraud
actually normal98910990
actually fraud2278

recall = 78 / (78 + 22)

0.780 — the detector is genuinely good

Accuracy 0.9899, precision 0.073

Every metric from this one table

accuracy = (98910 + 78) / 100000 = 0.98988

recall = 78 / 100 = 0.780

precision = 78 / (78 + 990) = 0.073

FPR = 990 / 99900 = 0.0099 -> ROC-AUC would look excellent

An analyst reviews 1,068 alerts to find 78 frauds: 13.7 reviews per catch

Why this matters

Accuracy and precision differ by a factor of thirteen on the same table. Which number you report decides whether this detector sounds finished or sounds like it needs another round of work.

Common misconception

Wrong idea: a 1% false positive rate is a small error rate.

Correct idea: 1% of a huge negative class dwarfs a tiny positive class. At 0.1% prevalence, 1% FPR means ten false alarms per real anomaly before you have caught a single one.

import numpy as np
from sklearn.metrics import (average_precision_score, classification_report,
                             precision_recall_curve, roc_auc_score)

score = -iso.score_samples(X_test)         # higher = more anomalous

roc_auc_score(y_test, score)               # flattering at low prevalence
average_precision_score(y_test, score)     # the number to quote instead

# precision@k: what the analyst reviewing 200 alerts a day actually experiences.
k = 200
top = np.argsort(score)[::-1][:k]
print(f"precision@{k} = {y_test[top].mean():.3f}")

print(classification_report(y_test, (score > threshold).astype(int), digits=3))

Quoting ROC-AUC alone. It is computed from true and false positive *rates*, and at 0.1% prevalence a false positive rate of 1% is still 990 false alarms against 100 possible catches — invisible in an AUC of 0.98. Module 18 covers why the PR curve exposes this and the ROC curve hides it.

Choosing the threshold without labels

What it is

Every method here produces a continuous score; the anomaly flag is a cut through it. With labels you pick the cut from a precision-recall curve. Without them you pick it from capacity — how many alerts can actually be reviewed per day — and set the score quantile to match.

Why it matters

It is the same threshold problem as in classification, with the validation set removed. The honest reframing is that the cut is a *policy* decision, not a statistical one, so it should be stated as a review budget rather than derived from a number nobody can check.

In the real world: Setting the alert level so the security team gets a manageable 50 alerts a day.

import numpy as np

# Capacity-driven: the team can review 50 alerts a day out of 40,000 rows.
score = -iso.score_samples(X_today)
budget = 50
threshold = float(np.sort(score)[-budget])
flags = score >= threshold

# Distribution-driven: a fixed quantile of a clean reference period.
threshold = float(np.quantile(reference_score, 0.999))

# Recheck it as the data drifts — the same cut flags a different fraction later.
for week, s in weekly_scores.items():
    print(week, f"{(s > threshold).mean():.3%} flagged")

Fixing a threshold once on an absolute score and never revisiting it. Scores drift with the data: a cut that flagged 0.1% in January can flag 4% in June, and the alert queue floods without anything having gone wrong with the model. Either recompute the quantile on a rolling window, or monitor the flagged fraction as a metric in its own right.