Anomaly detection
Masking, robust scores with the median and MAD, baselines from the past, isolation forests and thresholds from a review budget.
Create a free account Try part of it here
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
- What makes something an anomaly
- Robust scores: the median and the MAD
- The median absolute deviation
- Compared with its own past
- Find the bug: a spike that hides itself
- Many features at once: isolation forests
- Running a detector people can live with
- Feedback, drift and explanations
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])
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:
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 .
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.
import statistics as st print(st.mean([3, 4, 5, 4, 100]), st.median([3, 4, 5, 4, 100]))
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.
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]))
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.
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))
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.
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.
Each dot is a payment scored by a detector. Lower the share that's fraud, then drag the threshold all the way up.
flagged, real = 20, 6 print(real / flagged)
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.
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.
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.
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
Try a quiz question
Why can two large spikes go undetected by a rule of "more than 3 standard deviations from the mean"?