Index Ai Academy
Phase 3 · Machine learning · Unsupervised

Anomaly detection

Masking, robust scores with the median and MAD, baselines from the past, isolation forests and thresholds from a review budget.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Fraudulent payments, a server that suddenly answers in three seconds, a sensor stuck at zero, an account posting a thousand messages an hour: the events that matter most are often the rarest. Usually nobody has labeled them, there are too few to learn from, and next month's will look different from this month's. Anomaly detection is the craft of finding "doesn't look like the rest" reliably, and of not drowning the people who check the alerts.

What you'll be able to do

  • Explain why the mean and standard deviation hide the very outliers you're looking for
  • Score unusual values with the median and MAD, and unusual rows with an isolation forest
  • Compare a value with its own recent past, without letting it into its own baseline
  • Choose a threshold from a review budget, and judge a detector by precision at that budget

What it covers

  1. What makes something an anomaly
  2. Robust scores: the median and the MAD
  3. The median absolute deviation
  4. Compared with its own past
  5. Find the bug: a spike that hides itself
  6. Many features at once: isolation forests
  7. Running a detector people can live with
  8. Feedback, drift and explanations
Quick check
A guess first. One payment in 1,000 is fraud. A detector that never flags anything is how accurate?
  1. 0%, because it misses every fraud
  2. It can't be measured without labels
  3. 50%, the same as guessing
  4. 99.9%, while catching no fraud at all
Show the answer

99.9%, while catching no fraud at all

It's right about all 999 normal payments. With rare events, accuracy rewards doing nothing, so detectors are judged on what they catch and how many false alarms they raise.

What makes something an anomaly

An anomaly is a point that doesn't fit the pattern of the rest: a value far from the usual range, a combination that's rare even though each part is common (a large order at 4 a.m. from a new account), or a change from what this thing normally does. There are three common settings:

  • no labels at all, so the detector scores how unusual each point is and people review the top of the list;
  • a few labels, enough to measure a detector and tune its threshold, not to train a classifier;
  • plenty of labels, which turns it back into ordinary classification with very lopsided classes.

Most real work sits in the first two. The output is a score per point, and the threshold is set by how many alerts people can check, not by a textbook value.

import statistics as st

latency_ms = [120, 131, 118, 125, 122, 130, 2400, 127, 119, 124, 3100, 121]
mean, sd = st.mean(latency_ms), st.pstdev(latency_ms)
print("mean", round(mean, 1), "standard deviation", round(sd, 1))
print([round((x - mean) / sd, 2) for x in latency_ms])
It runs right here in your browser, with nothing to install.
Quick check
A common rule flags values more than 3 standard deviations from the mean. Using the z-scores the cell printed, which of the two slow requests, 2,400 ms and 3,100 ms, does that rule flag?
  1. Both of them
  2. Every request, since all scores are far from 0
  3. Only the 3,100 ms request
  4. Neither: they score 1.86 and 2.57
Show the answer

Neither: they score 1.86 and 2.57

The two spikes pull the mean up to 561 and the standard deviation up to 989, so they hide themselves. Outliers inflate the very measures meant to catch them; this is called masking.

Robust scores: the median and the MAD

The fix is to measure "usual" with statistics that outliers can't drag around. The median ignores how extreme the extremes are. The spread equivalent is the MAD, the median absolute deviation: the median of how far each value is from the median. A modified z-score then plays the role of the z-score:

Mi=0.6745 (xi−median)MADM_i = \frac{0.6745\,(x_i - \text{median})}{\text{MAD}}

In plain words: how many typical distances a value sits from the middle, measured so extremes can't drag either.

The constant 0.6745 makes it comparable to an ordinary z-score, and a common cut-off is ∣Mi∣>3.5|M_i| > 3.5.

import statistics as st

latency_ms = [120, 131, 118, 125, 122, 130, 2400, 127, 119, 124, 3100, 121]
med = st.median(latency_ms)
mad = st.median([abs(x - med) for x in latency_ms])
scores = [0.6745 * (x - med) / mad for x in latency_ms]
print("median", med, "MAD", mad)
print([i for i, m in enumerate(scores) if abs(m) > 3.5], [round(scores[i]) for i in (6, 10)])

The median is 124.5 and the MAD is 5, so the two spikes score in the hundreds.

Quick check
Why does the median resist outliers where the mean doesn't? What does this print?
import statistics as st
print(st.mean([3, 4, 5, 4, 100]), st.median([3, 4, 5, 4, 100]))
  1. 23.2 4
  2. 4 4
  3. 4 23.2
  4. 23.2 23.2
Show the answer

23.2 4

One value of 100 drags the mean to 23.2, which describes nobody. The median only cares about the middle value, so it stays at 4 however large the outlier gets.

Drill

The median absolute deviation

Write mad(values): the median of each value's absolute distance from the median of the values. Use statistics.median.

import statistics as st


def mad(values):
    return st.pstdev(values)


print(mad([120, 131, 118, 125, 122, 130, 2400, 127, 119, 124, 3100, 121]))
In the lesson, your code is checked as you write it.
In your own words
Why do the mean and standard deviation hide outliers, and what do you use instead?

Write a sentence or two, then compare it with a good answer in the lesson.

Compared with its own past

A value is often only strange for this thing at this time: 400 orders an hour is a quiet Saturday for the shop but a flood at 3 a.m. Seasonal data needs a baseline that matches, such as the same hour last week or a rolling median of the last few hours.

One rule matters more than the formula: the value being judged must not be part of its own baseline. If the 3 a.m. flood is included in the numbers it's compared against, it raises the mean and the spread and partly hides itself, the same masking as before. Build the baseline from history only, then score the new value against it.

Baselines also drift. After a successful launch, "normal" traffic doubles, and a fixed baseline would alert forever. Rolling windows adapt; the trade-off is that a slow, steady attack can teach the baseline to accept it. For data with strong daily and weekly cycles, compare with the same hour on each of the last four weeks and take their median: one unusual week then can't distort the baseline.

Drill

Find the bug: a spike that hides itself

is_spike(history, value) should say whether value is more than 3 standard deviations above the mean of the recent history. A jump from about 100 to 300 orders an hour isn't being flagged. Fix it.

import statistics as st


def is_spike(history, value):
    window = history + [value]
    mean, sd = st.mean(window), st.pstdev(window)
    return (value - mean) / sd > 3


print(is_spike([100, 102, 98, 101, 99], 300))
In the lesson, your code is checked as you write it.
In your own words
What's the trap when comparing a value with its recent past?

Write a sentence or two, then compare it with a good answer in the lesson.

Many features at once: isolation forests

One column at a time misses combinations: an order of 3 items isn't strange, and neither is a total of 900 dollars, but 3 items for 900 dollars at a gear shop might be. For rows with several features, an isolation forest works well with no labels. It builds many random trees, each splitting on a random feature at a random value, and asks how many splits it takes to isolate each point on its own. Normal points sit in crowds and need many splits; anomalies sit alone and get cut off in a few. Fewer splits on average means more unusual.

import numpy as np
from sklearn.ensemble import IsolationForest

rng = np.random.default_rng(1)
items = rng.integers(1, 6, 200)
total = items * rng.normal(60, 12, 200)
orders = np.column_stack([items, total])
orders = np.vstack([orders, [[3, 900], [1, 5], [5, 300]]])

forest = IsolationForest(random_state=0).fit(orders)
score = -forest.score_samples(orders)   # higher means more unusual
top = np.argsort(score)[::-1][:3]
print("most unusual rows:", sorted(int(i) for i in top))
print("their items and totals:", [[int(orders[i, 0]), round(float(orders[i, 1]))] for i in sorted(top)])

Three orders were planted at the end, rows 200 to 202. Two come out on top: 3 items for 900 dollars, and 1 item for 5 dollars. The third flagged row, 145, is a real order from the random crowd, 5 items for 409 dollars, expensive enough to be worth a look. The third planted order, 5 items for 300 dollars, isn't flagged at all. Always read what a detector flags before trusting it: here, it was right.

Quick check
Why didn't the forest flag the planted order of 5 items for 300 dollars?
  1. Planted rows are always ignored
  2. The forest only looks at the first column
  3. At 60 dollars an item, it's an ordinary order
  4. 300 is below the forest's fixed cut-off
Show the answer

At 60 dollars an item, it's an ordinary order

Most orders cost about 60 dollars an item, so 5 items for 300 sits right in the crowd. Planting a point doesn't make it unusual; being unlike the others does.

Try it now

Each dot is a payment scored by a detector. Lower the share that's fraud, then drag the threshold all the way up.

Quick check
A reminder from the metrics lesson. A fraud team can review 20 alerts a day; of the 20 flagged yesterday, 6 were fraud. What is the precision at that budget?
flagged, real = 20, 6
print(real / flagged)
  1. 3.33
  2. 0.06
  3. 0.3
  4. 6
Show the answer

0.3

Precision is the share of alerts that were real: 6 of 20. Measured at the review budget, it says how much of the team's day goes to real cases.

Running a detector people can live with

Set the threshold from capacity: if reviewers can check 20 alerts a day, flag the 20 highest scores, and measure precision at 20 (how many were real) and, when you learn about misses later, recall. Every alert that turns out to be nothing costs attention, and teams that get too many false alarms start ignoring all of them, which is worse than having no detector.

Quick check
Reviewers can check 20 alerts a day, but the detector flags 300. What's the right first move?
  1. Lower the threshold to catch more
  2. Turn the detector off until it's perfect
  3. Show the top 20 and measure their precision
  4. Ask reviewers to work through all 300 of them
Show the answer

Show the top 20 and measure their precision

The threshold should match capacity: send the top 20, learn from what reviewers decide, and improve the scores from there.

Feedback, drift and explanations

Feed the reviewers' decisions back. Each confirmed or dismissed alert is a label; after a few hundred, you can measure detectors properly, tune them, and eventually train a classifier on the confirmed cases. Expect the anomalies to change, especially when they're caused by people trying not to be caught: retire rules that no longer fire, and watch for new patterns in what reviewers dismiss.

Finally, explain each alert. A score of 7.2 helps nobody; a sentence that names the feature and compares it with the usual value lets a reviewer decide in seconds, and makes it easy to spot a detector that has started flagging the wrong thing, such as every order from a new country after a launch there.

Quick check
Which alert lets a reviewer decide fastest?
  1. Order 4471 was flagged by the isolation forest model
  2. Order 4471: 3 items for 900 dollars, 5 times the usual price per item
  3. Order 4471 looks unusual and needs a review today
  4. Order 4471: anomaly score 7.2, above the threshold of 3.5
Show the answer

Order 4471: 3 items for 900 dollars, 5 times the usual price per item

It says what's unusual and by how much, against what's normal. The others leave the reviewer to work that out from scratch.

In your own words
How do you set a detector's threshold, and how do you judge it?

Write a sentence or two, then compare it with a good answer in the lesson.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 6 review cards for spaced repetition

Create a free account

Try a quiz question

Why can two large spikes go undetected by a rule of "more than 3 standard deviations from the mean"?

  1. They inflate the mean and spread they're judged by
  2. Large spikes always have z-scores below 1
  3. The rule only ever applies to negative values
  4. Standard deviations leave out the largest values

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson