Skip to content

Anomaly and Outlier Detection

What you’ll learn

  • the two kinds of anomaly — global and local — and why one detector cannot have both
  • measured: Isolation Forest 0.9944 on global anomalies and 0.0317 on local ones
  • LOF’s n_neighborsn_neighbors is the whole model, and there are no labels to tune it with
  • what irrelevant columns do: Isolation Forest 0.7554 → 0.0614 with 40 of them
  • that contaminationcontamination moves the cut and never the ranking — AP stayed at 0.7554 throughout
  • how to evaluate a detector when you have no labels, and what you can honestly claim

Two kinds of anomaly

The data below has 2,965 points and two clusters with deliberately different spreads: one tight (standard deviation 0.28) and one loose (1.35). On top of that sit 65 anomalies of two distinct types.

figure2,965 points, 65 of them anomalous (2.19%)matplotlib
Left: scatter plot with a tiny dense cluster at (-3,-3), a broad cluster at (3,2.4), 45 red points scattered far out, and 20 amber points ringing the dense cluster. Right: a zoom on the dense cluster showing the amber ring sitting just outside the tight blob.Left: scatter plot with a tiny dense cluster at (-3,-3), a broad cluster at (3,2.4), 45 red points scattered far out, and 20 amber points ringing the dense cluster. Right: a zoom on the dense cluster showing the amber ring sitting just outside the tight blob.
The 45 red points are global anomalies: far from everything, and any method finds them. The 20 amber points ring the tight cluster at 3 to 5 of ITS standard deviations — a distance that would be completely unremarkable inside the loose cluster. That is a local anomaly, and it is the reason density-based methods exist.
GroupCountWhat it is
tight cluster1,900mean (−3, −3), sd 0.28
loose cluster1,000mean (3, 2.4), sd 1.35
global anomalies45uniform, pushed clear of both clusters
local anomalies20ring around the tight cluster at radius 0.9–1.4
anomaly rate2.19%

The distinction is not academic. A transaction of €400 is unremarkable for a business account and extraordinary for a student account; a server at 60% CPU is normal for a database and alarming for a load balancer. Whether a point is anomalous is a question about its neighbourhood, not about the dataset’s global shape — and the two most popular detectors answer different questions.

Six detectors on identical data

figureIsolation Forest owns the far outliers; only LOF sees the local onesmatplotlib
Horizontal grouped bars of average precision for six detectors, split by all, global and local anomalies. Isolation Forest 0.7554 all, 0.9944 global, 0.0317 local. LOF k=20 0.9771, 0.9610, 0.1715. LOF k=10 0.7837, 0.6247, 0.2474. Elliptic Envelope 0.5697, 0.7996, 0.0099. One-class SVM 0.8033, 0.9297, 0.0655. Distance from the mean 0.6765, 0.9604, 0.0057.Horizontal grouped bars of average precision for six detectors, split by all, global and local anomalies. Isolation Forest 0.7554 all, 0.9944 global, 0.0317 local. LOF k=20 0.9771, 0.9610, 0.1715. LOF k=10 0.7837, 0.6247, 0.2474. Elliptic Envelope 0.5697, 0.7996, 0.0099. One-class SVM 0.8033, 0.9297, 0.0655. Distance from the mean 0.6765, 0.9604, 0.0057.
Every method finds the global anomalies — even 'distance from the mean' gets 0.9604 — because being far from everything is easy to detect. On the local anomalies the numbers collapse: 0.0057 for distance, 0.0099 for Elliptic Envelope, 0.0317 for Isolation Forest, and 0.2474 for LOF with k=10. Eight times better, and still only 0.2474.
DetectorAll (65)Global (45)Local (20)
Isolation Forest0.75540.99440.0317
LOF (k=20)0.97710.96100.1715
LOF (k=10)0.78370.62470.2474
Elliptic Envelope0.56970.79960.0099
One-class SVM (RBF)0.80330.92970.0655
distance from the mean0.67650.96040.0057

Read the last row first. Euclidean distance from the mean — one line of numpy — reaches 0.9604 on the global anomalies. If your anomalies are of that kind, you do not need a library. What you need a library for is the other kind, and there the same baseline scores 0.0057.

Isolation Forest builds random axis-aligned splits and scores a point by how few splits it takes to isolate. Points in empty regions get isolated fast, which is exactly the global notion — hence 0.9944 and 0.0317. It is also the fastest of the six, has no distance computation, and handles mixed scales without preprocessing.

Local Outlier Factor compares a point’s local density to the density of its kk neighbours:

LOFk(x)=1Nk(x)oNk(x)lrdk(o)lrdk(x)\mathrm{LOF}_k(x) = \frac{1}{|N_k(x)|} \sum_{o \in N_k(x)} \frac{\mathrm{lrd}_k(o)}{\mathrm{lrd}_k(x)}

where lrd\mathrm{lrd} is the inverse of the average reachability distance. The ratio is the point: a LOF of 1 means “as dense as my neighbours”, above 1 means “sparser than my neighbours”. Because it is a ratio, it does not care that the two clusters have different spreads — which is why it is the only method here with any purchase on the local anomalies.

Elliptic Envelope fits one robust Gaussian to everything, so on two clusters it fits an ellipse around both and calls the space between them normal. It is the right tool for genuinely unimodal data and the wrong one here — 0.5697 overall, the worst of the six.

Which detector, and what to do with its output

diagram Diagram mermaid

The loop closing at the bottom is the honest end state. Unsupervised detection is a bootstrap: you use it to generate the labels that let you stop using it. Any project where a detector is still the final answer two years in has usually skipped the step where reviewed flags were written back to a table.

One hyperparameter, no labels

figurek trades local sensitivity against global sensitivitymatplotlib
Average precision against LOF n_neighbors on a log axis. Global AP rises from 0.169 at k=5 to about 0.975 at k=50 and above. Local AP peaks at 0.2474 at k=10 and falls to about 0.17. Overall AP rises from 0.30 to 0.986.Average precision against LOF n_neighbors on a log axis. Global AP rises from 0.169 at k=5 to about 0.975 at k=50 and above. Local AP peaks at 0.2474 at k=10 and falls to about 0.17. Overall AP rises from 0.30 to 0.986.
At k=5 the detector is nearly useless overall (0.3025) because five neighbours is not enough to estimate a density. At k=10 the local anomalies are best detected (0.2474) and the global ones worst (0.6247). From k=20 upwards the global anomalies are found reliably and the local ones settle around 0.17. There is no k that is best at both.
n_neighborsn_neighborsAllGlobalLocal
50.30250.16890.2107
100.78370.62470.2474
200.97710.96100.1715
500.98410.97500.1736
1200.98640.97550.1771

The table above is only computable because this page has labels. In production you almost never do — that is what makes anomaly detection different from every other page in this phase. Which means the number you would pick k by does not exist.

What you can do instead:

  • Pick k from the domain. If you know an anomaly is a point unlike its 10 nearest peers, that is your k. This is a modelling statement, not a tuning problem.
  • Label a sample. A hundred hand-labelled points give a noisy but real AP estimate, and it is far better than nothing. Prioritise labelling the points the detectors disagree about.
  • Use synthetic anomalies. Inject known anomalies of the kind you care about, and measure recall on those. It only validates against the anomalies you imagined, which is a real limitation, but it catches gross failures.
  • Ensemble across k. Average the scores from several k values, or take the maximum. It gives up peak performance for robustness — a reasonable trade when you cannot measure peak performance anyway.

Irrelevant columns are fatal

Every real dataset has columns that carry nothing about the anomaly. Here is what they cost:

figureIsolation Forest falls from 0.755 to 0.061 as noise columns are addedmatplotlib
Average precision against total dimensions for four detectors. Isolation Forest falls from 0.755 at 2 dimensions to 0.061 at 42. LOF k=20 falls from 0.977 to 0.568. Elliptic Envelope stays near 0.51-0.57. Distance from the mean falls from 0.677 to 0.289.Average precision against total dimensions for four detectors. Isolation Forest falls from 0.755 at 2 dimensions to 0.061 at 42. LOF k=20 falls from 0.977 to 0.568. Elliptic Envelope stays near 0.51-0.57. Distance from the mean falls from 0.677 to 0.289.
Two informative dimensions plus 40 pure noise columns. Isolation Forest degrades by a factor of 12, because its random splits pick uninformative axes 95% of the time. LOF loses 42% — distances get dominated by noise, but the density ratio remains partly informative. Elliptic Envelope barely moves, because a fitted covariance can down-weight columns that do not co-vary with anything.
Total dimensionsIsolation ForestLOF (k=20)Elliptic Envelopedistance from the mean
20.75540.97710.56970.6765
60.40200.70910.56900.5927
120.16060.66850.56840.4851
220.12500.59750.56620.4074
420.06140.56770.51300.2892

Three lessons, in decreasing order of how often they are ignored.

Feature selection matters more than model selection here. Going from 42 columns to the 2 that matter improves Isolation Forest by 12× — far more than any switch between detectors. And unlike supervised learning, you have no importance measure to guide you, so this has to come from domain knowledge.

The ranking of methods depends on dimensionality. At 2 dimensions LOF beats Isolation Forest 0.9771 to 0.7554; at 42 it wins 0.5677 to 0.0614. Any blog post declaring one better than the other is describing its own dataset.

Elliptic Envelope’s stability is not a virtue here. It scores 0.5130 at 42 dimensions because it was already only 0.5697 at two: it never fitted the two-cluster structure at all, so there was less to lose.

contamination is not a model parameter

Every sklearn detector takes contaminationcontamination, and it is routinely misunderstood as “how sensitive the model is”. It is not. It converts scores into labels, and nothing else:

figureRanking quality is flat at 0.7554; only the cut movesmatplotlib
Precision, recall and average precision against contamination. Precision falls from 1.0 at 0.005 to 0.1886 at 0.10; recall rises from 0.2308 to 0.8615; average precision is a flat dashed line at 0.7554 throughout.Precision, recall and average precision against contamination. Precision falls from 1.0 at 0.005 to 0.1886 at 0.10; recall rises from 0.2308 to 0.8615; average precision is a flat dashed line at 0.7554 throughout.
Five values of contamination on the same Isolation Forest. The number of flagged points goes from 15 to 297 and precision from 1.0000 to 0.1886, while average precision — which summarises the ranking, not the cut — does not move at all. contamination is the alert budget, and it belongs to operations rather than to modelling.
contaminationcontaminationFlaggedPrecisionRecallAverage precision
0.005151.00000.23080.7554
0.010301.00000.46150.7554
0.020600.75000.69230.7554
0.0501490.32210.73850.7554
0.1002970.18860.86150.7554

This is the threshold discussion again, in different clothing. contaminationcontamination is a threshold on the anomaly score, so it trades precision against recall exactly as any threshold does, and the right value comes from how many alerts a human can process — not from a grid search.

Two practical consequences:

  • Score, then threshold. Use score_samplesscore_samples and keep the continuous score. Then flag the top-N per day, where N is your capacity. predict()predict() throws that flexibility away.
  • Compare models with AP, not with precision. Precision at a fixed contamination conflates ranking quality with the choice of cut, and the numbers above show it can vary by 5× without the model changing at all.

See it move

LOF is a ratio of densities, which is why cluster spread does not fool it. Drag the point and compare what a global distance threshold says with what the local ratio says.

sketch Global distance against local density p5.js
Two clusters of different spread and a draggable probe point. The panel shows the probe's distance from the global mean and its local outlier ratio, computed from the k nearest neighbours, and the two verdicts disagree in exactly the region that motivates LOF.

The interesting region is just outside the tight cluster. There, the global criterion says “well within two spreads of the mean, normal” while the local ratio says “far sparser than my neighbours, anomaly”. Inside the loose cluster the disagreement reverses. Every point where the two verdicts differ is a point where your choice of detector decides the answer.

Pitfalls

PitfallWhy it bitesWhat to do
One detector for all anomaly typesIsolation Forest: 0.9944 global, 0.0317 localDecide which kind you care about, then choose
Treating contaminationcontamination as sensitivityAP was 0.7554 at every settingIt is the alert budget; score first, threshold second
Tuning n_neighborsn_neighbors without labelsNo k was best for both typesSet it from the domain, or ensemble across k
Keeping every columnIsolation Forest 0.7554 → 0.0614 with 40 noise columnsFeature selection beats model selection here
Elliptic Envelope on multi-modal data0.5697 — worst of six, on two clustersOnly for genuinely unimodal data
LocalOutlierFactor().predict(X_new)LocalOutlierFactor().predict(X_new)Raises; novelty=Falsenovelty=False only supports fit_predictfit_predictSet novelty=Truenovelty=True when you need to score new points
Reporting precision without recall1.0000 precision at 15 flagged of 65 anomaliesReport both, plus the alert count
Claiming “unsupervised so unmeasurable”A hundred labels give a usable estimateLabel the disagreements; inject synthetic anomalies

Recap

  • 2,965 points, 2.19% anomalous, in two kinds: 45 global and 20 local.
  • Isolation Forest: 0.9944 on global anomalies, 0.0317 on local ones. Distance from the mean gets 0.9604 and 0.0057 respectively — global anomalies are easy.
  • LOF at k=10 reached 0.2474 on the local anomalies, roughly Isolation Forest, while giving up global performance (0.6247 against 0.9944).
  • No k was best at both, and in production there are no labels to choose one with.
  • Adding 40 irrelevant columns cost Isolation Forest 12× (0.7554 → 0.0614) and LOF 42%.
  • contaminationcontamination moved flagged points from 15 to 297 and precision from 1.0000 to 0.1886 while average precision stayed at 0.7554.
quizCheck yourself
  1. Isolation Forest gives average precision 0.9944 on your far outliers and 0.0317 on points that are unusual only relative to their neighbours. What is the fix?

    Show answer

    B — A density-ratio method such as LOF, which compares a point's local density to its neighbours' rather than to the global structure — Isolation Forest measures how quickly random splits isolate a point, which is a global emptiness criterion. LOF at k=10 reached 0.2474 on exactly those local anomalies — eight times better, and the only method here with any purchase on them.

  2. You have no labels. How do you choose LOF's n_neighbors?

    Show answer

    B — From the domain — decide what 'unlike its neighbours' means for your data — or ensemble several k values, and label a sample to sanity-check — The measured table shows no k that is best for both anomaly types, so the choice is a modelling statement about which anomalies you care about. A hundred hand-labelled points — ideally the ones detectors disagree about — turn 'no evaluation' into a noisy but real one.

  3. Raising contamination from 0.005 to 0.10 changed precision from 1.0000 to 0.1886 and left average precision at 0.7554. What does that tell you?

    Show answer

    B — contamination only converts scores to labels: the ranking is unchanged, and the parameter is an alert budget rather than a model setting — The flagged count went from 15 to 297 on identical scores. Keep score_samples output, flag the top N your team can review, and compare models by AP so that ranking quality is not conflated with the choice of cut.

  4. Adding 40 uninformative columns takes Isolation Forest from 0.7554 to 0.0614. Why is it hit so much harder than Elliptic Envelope?

    Show answer

    B — Its splits are drawn uniformly over axes, so with 42 columns it spends 95% of its splits on noise; a fitted covariance can down-weight columns that co-vary with nothing — Elliptic Envelope only looks stable because it started at 0.5697 — it never captured the two-cluster structure, so it had less to lose. The transferable point is that feature selection is worth more here than any choice of detector.

  5. You call LocalOutlierFactor(n_neighbors=20).fit(X_train).predict(X_new) and get an error. Why?

    Show answer

    B — With the default novelty=False, LOF only supports fit_predict on the data it fitted; scoring new points requires novelty=True — The two modes answer different questions: outlier detection (which of these points are odd?) against novelty detection (is this new point odd relative to what I have seen?). Set novelty=True for the second, and remember it disables fit_predict.

🧪 Try It Yourself

Exercise 1 – Build both kinds of anomaly

Exercise 2 – Two detectors, two answers

Exercise 3 – Sweep k, and find that no value wins

Exercise 4 – Add columns that contain nothing

Exercise 5 – Show that contamination is only a threshold

Next

Phase 10 - Applied ML Problems — the phase overview, with the six measurements from these pages that generalise beyond their own datasets.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did