Python anomaly detection flips the usual machine learning question on its head. Instead of teaching a model what fraud looks like, you teach it what normal looks like and then flag anything that does not fit. This post walks through the practical approach: the intuition first, then the math behind Isolation Forest, then a from-scratch version you can read line by line, and finally the scikit-learn code with Local Outlier Factor and One-Class Support Vector Machine (SVM) compared side by side.
“Anomalies are few and different. That single sentence is the whole algorithm.”
Fei Tony Liu, co-author of the Isolation Forest paper
Last Updated: July 2026 | Tested on: Python 3.14.6, scikit-learn 1.9.0 | Difficulty: Advanced | Reading Time: 16 minutes
Here is the problem in one breath. Credit card fraud affects a tiny slice of transactions. Servers fail a handful of times a year. A factory might ship one defective part in ten thousand. If you try to train a normal classifier on these, it has almost nothing to learn from, because there are barely any positive examples. A model that just predicts “everything is fine” would be right 99.9% of the time and completely useless. Anomaly detection sidesteps this. It learns the shape of normal and treats anything far from that shape as suspicious.
Think of airport security. Nobody hands the screener a photo album of every possible weapon. Instead the screener learns what an ordinary bag looks like after seeing thousands of them, and the odd-looking bag gets pulled aside. That is anomaly detection. You model the ordinary, and the unusual stands out on its own.
Table of Contents
Prerequisites
- random forest tutorial for tree ensemble concepts
- ML preprocessing tutorial for scaling features
- Comfort with NumPy arrays and basic averages (no heavy math needed)
The Intuition: Short Paths Mean Anomalies
Isolation Forest is the workhorse of Python anomaly detection, and the idea behind it is almost suspiciously simple. Imagine you are playing twenty questions to single out one specific person in a crowd. If that person is wearing a bright orange hat and everyone else is in grey, you find them in one question: “Who has the orange hat?” Done. But to single out one ordinary person in grey, you need question after question after question, because they blend in with everyone around them.
Isolation Forest does exactly this with random questions. It keeps slicing the data at random spots and counts how many slices it takes to fully separate each point from the rest. Anomalies get separated fast, in just a few slices, because they sit out on their own. Normal points sit in the middle of the pack, so they survive many slices before they finally get isolated. The average number of slices needed, taken across hundreds of random trees, becomes the anomaly score. Short path means anomaly. Long path means normal.
Read the tree above from the top. The anomaly on the right gets cut off from everyone else after just two splits, so its path is short (depth 2). The normal point on the left keeps surviving split after split because it is surrounded by lookalikes, so its path is long (depth 5). A whole forest of these random trees votes, and the points with consistently short paths get flagged. That is the entire mechanism. There is no distribution to assume, no distance metric to pick, just random cuts and an average depth.
Isolation Forest from Scratch
Before reaching for scikit-learn, let us prove the idea with a tiny hand-built version. We will take ten numbers: nine of them bunched near 50, plus one obvious outlier at 500. Then we will write a function that repeatedly picks a random cut between the smallest and largest value, throws away the half that does not contain our point, and counts how many cuts it takes until the point stands alone. Run that a thousand times for each point and average the depth.
📄 isolation_from_scratch.py: counting random cuts to isolate a point
import numpy as np
# A tiny 1D dataset: nine "normal" values clustered near 50,
# and one obvious outlier at 500.
data = np.array([48, 49, 50, 50, 51, 52, 49, 51, 50, 500], dtype=float)
def isolation_depth(point, values, rng, depth=0):
"""How many random cuts to isolate `point` from `values`."""
values = values[values != point] if depth == 0 else values
if len(values) == 0:
return depth
lo, hi = values.min(), values.max()
if lo == hi:
return depth
# pick a random cut value between the min and max of this group
split = rng.uniform(lo, hi)
# keep only the side of the cut that still contains our point
if point < split:
survivors = values[values < split]
else:
survivors = values[values >= split]
if len(survivors) <= 1:
return depth + 1
return isolation_depth(point, survivors, rng, depth + 1)
rng = np.random.default_rng(0)
TREES = 1000
for point in [50.0, 500.0]:
depths = [isolation_depth(point, data.copy(), rng) for _ in range(TREES)]
label = "normal" if point == 50.0 else "outlier"
print(f"value={point:>5.0f} ({label:7}) -> average isolation depth over "
f"{TREES} random trees = {np.mean(depths):.2f}")
▶ Output
value= 50 (normal ) -> average isolation depth over 1000 random trees = 3.33 value= 500 (outlier) -> average isolation depth over 1000 random trees = 2.06
What happened here: The outlier at 500 needed an average of 2.06 cuts to be isolated, while a normal value near 50 needed 3.33. The outlier separates faster because the very first cut that lands anywhere in the wide gap between 52 and 500 instantly slices it off on its own. The normal value sits inside a tight crowd of eight other numbers, so most cuts leave it with company and the count climbs. Scale this up to many features and hundreds of trees, normalize the depths, and you have the real Isolation Forest. The score it reports is just this average depth, turned around so that shorter equals more anomalous.
Isolation Forest with scikit-learn
Now the real thing, and the point where Python anomaly detection starts to feel practical. We will build a fake transactions dataset: 980 ordinary purchases clustered around a normal amount and frequency, plus 20 deliberately weird ones (huge rare buys, tiny rapid-fire buys, and a few that are extreme on both axes). Then we let scikit-learn’s Isolation Forest find them. Notice the two parameters that matter most: contamination tells the model roughly what fraction of the data you expect to be anomalous, and random_state keeps the result reproducible so your numbers match the ones below.
📄 isolation_forest.py: detecting anomalies in transaction data
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
# Simulated transaction data: amount and frequency
rng = np.random.default_rng(42)
normal = rng.normal(loc=[50, 10], scale=[15, 3], size=(980, 2))
anomalies = np.array([
[500, 1], [450, 2], [600, 1], # large rare purchases
[10, 80], [15, 90], [12, 75], # tiny frequent purchases
[800, 50], [900, 60], [700, 55], # both extreme
[1, 1], [2, 1], [950, 95], # other anomalies
[50, 100], [45, 85], [55, 90], [500, 80], [480, 70], [520, 85], [50, 120], [40, 110],
])
X = np.vstack([normal, anomalies])
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Isolation Forest
iso = IsolationForest(n_estimators=100, contamination=0.02, random_state=42)
labels = iso.fit_predict(X_scaled)
scores = iso.decision_function(X_scaled)
n_anomalies = (labels == -1).sum()
n_normal = (labels == 1).sum()
print(f"Total samples: {len(X)}")
print(f"Detected anomalies: {n_anomalies}")
print(f"Normal points: {n_normal}")
# Check how many injected anomalies were caught
injected_labels = labels[980:]
caught = (injected_labels == -1).sum()
print(f"\nInjected anomalies: {len(anomalies)}")
print(f"Caught: {caught}/{len(anomalies)} ({caught/len(anomalies):.0%})")
# Show most anomalous points (lowest decision_function scores)
most_anomalous = np.argsort(scores)[:5]
print(f"\nTop 5 most anomalous:")
for idx in most_anomalous:
print(f" Index {idx}: amount={X[idx,0]:.0f}, freq={X[idx,1]:.0f}, score={scores[idx]:.3f}")
▶ Output
Total samples: 1000 Detected anomalies: 20 Normal points: 980 Injected anomalies: 20 Caught: 20/20 (100%) Top 5 most anomalous: Index 991: amount=950, freq=95, score=-0.230 Index 987: amount=900, freq=60, score=-0.209 Index 997: amount=520, freq=85, score=-0.202 Index 986: amount=800, freq=50, score=-0.199 Index 988: amount=700, freq=55, score=-0.196
What happened here: With contamination=0.02 the model flagged exactly 20 points (2% of 1000), and on this clean toy dataset all 20 of them happened to be the injected anomalies, a perfect 100% catch. The five most anomalous are the ones that are extreme on both axes at once, such as index 991 (amount 950, frequency 95). The decision_function returns a signed score where lower (more negative) means more anomalous, which is why we sort ascending and slice the first five.
Do not read too much into the perfect score, though. This data is deliberately easy. On messy real-world transactions you will miss some anomalies and flag some innocents, and tuning contamination to your true expected rate becomes the whole game.
LOF and One-Class SVM
Picture a quiet residential street where every house sits shoulder to shoulder with its neighbors. One house with a huge empty plot around it stands out at once, even though that same gap would look perfectly ordinary out in the countryside. Judging a point by how packed its immediate surroundings are, rather than by some global rule, is exactly the idea Local Outlier Factor is built on.
Isolation Forest is rarely your only option. Two other classics show up constantly, and they think about “weird” in completely different ways. Local Outlier Factor (LOF) compares how crowded each point’s neighborhood is against how crowded its neighbors’ neighborhoods are; a point sitting in a sparse pocket while everyone around it is packed tight gets flagged. One-Class SVM draws a flexible boundary around the dense core of normal data and calls anything outside that boundary an outlier. Same goal, three different lenses.
Here is a fair fight. We generate 480 normal points tightly clustered at the origin and scatter 20 anomalies randomly across a wide square. Since we know exactly which 20 are the real anomalies, we can measure precision (of the points each model flagged, how many were truly anomalous) and recall (of the 20 real anomalies, how many it caught).
📄 anomaly_comparison.py: three algorithms, same data
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.svm import OneClassSVM
from sklearn.preprocessing import StandardScaler
import numpy as np
rng = np.random.default_rng(42)
normal = rng.normal(loc=[0, 0], scale=[1, 1], size=(480, 2))
anomalies = rng.uniform(-5, 5, size=(20, 2))
X = np.vstack([normal, anomalies])
X_scaled = StandardScaler().fit_transform(X)
true_labels = np.array([1]*480 + [-1]*20)
models = {
"Isolation Forest": IsolationForest(contamination=0.04, random_state=42),
"LOF": LocalOutlierFactor(n_neighbors=20, contamination=0.04),
"One-Class SVM": OneClassSVM(nu=0.04, kernel="rbf", gamma="scale"),
}
print("Algorithm | Detected | Precision | Recall")
print("-------------------|----------|-----------|-------")
for name, model in models.items():
if name == "LOF":
preds = model.fit_predict(X_scaled) # LOF has no separate predict()
else:
preds = model.fit(X_scaled).predict(X_scaled)
detected = (preds == -1).sum()
tp = ((preds == -1) & (true_labels == -1)).sum()
fp = ((preds == -1) & (true_labels == 1)).sum()
prec = tp / (tp + fp) if (tp + fp) > 0 else 0
rec = tp / 20
print(f"{name:<19}| {detected:>8} | {prec:>8.2f} | {rec:>5.2f}")
▶ Output
Algorithm | Detected | Precision | Recall -------------------|----------|-----------|------- Isolation Forest | 20 | 0.60 | 0.60 LOF | 20 | 0.70 | 0.70 One-Class SVM | 31 | 0.39 | 0.60
What happened here: On this particular dataset LOF came out ahead, catching 14 of the 20 anomalies (0.70 recall) with the cleanest precision. Isolation Forest flagged exactly 20 points because that is what contamination=0.04 asks for, and 12 of them were real (0.60). The One-Class SVM is the interesting one: its nu parameter is an upper bound on the outlier fraction, not a hard target, so it flagged 31 points and dragged its precision down to 0.39 while only matching Isolation Forest on recall.
The lesson is not “LOF always wins.” On clustered data with varying density LOF often shines, while Isolation Forest is the safer default in high dimensions and the SVM needs the most tuning. Change the seed or the data shape and the ranking can flip, which is exactly why you benchmark every Python anomaly detection candidate on your own data before committing.
Python Anomaly Detection: When Each Algorithm Wins
- Isolation Forest: your safe default. Fast, scales to many features, no distance metric to choose, and it does not assume your data follows any particular distribution. Reach for it first when you are not sure.
- Local Outlier Factor: best when normal data forms clusters of different densities. Because it judges each point against its local neighborhood, it catches a point that is normal globally but odd for its immediate surroundings. The catch: the plain version scores the training data only and has no clean way to label brand new points.
- One-Class SVM: powerful for a single tight blob of normal data, especially with an RBF (Radial Basis Function) kernel, but it is the fussiest to tune and slows down badly on large datasets. Treat it as a specialist, not a first pick.
Common Mistakes
Mistake 1: Setting contamination far above your real anomaly rate
🚫 Wrong
# contamination=0.1 tells the model "expect 10% of data to be anomalous". # If your true rate is 0.1%, you just told it to flag 100x too many points. iso = IsolationForest(contamination=0.1, random_state=42)
✅ Correct
# Start low, matching what you actually expect, then tune with a labeled sample. # 'auto' lets scikit-learn estimate the threshold from the data instead. iso = IsolationForest(contamination=0.001, random_state=42) # or, if you genuinely do not know the rate yet: iso_auto = IsolationForest(contamination="auto", random_state=42)
Why: contamination directly sets the cutoff on the anomaly score that splits normal from anomalous. Set it too high and you bury your team in false alarms; set it too low and real anomalies slip through. Anchor it to a domain estimate of how often anomalies truly occur, and refine it against whatever labeled examples you can scrape together.
Mistake 2: Forgetting to scale your features
🚫 Wrong
# amount is in the hundreds, frequency is in the tens. # Distance-based methods (LOF, One-Class SVM) will let amount dominate. preds = LocalOutlierFactor().fit_predict(X_raw)
✅ Correct
from sklearn.preprocessing import StandardScaler X_scaled = StandardScaler().fit_transform(X_raw) preds = LocalOutlierFactor().fit_predict(X_scaled)
Why: LOF and One-Class SVM measure distances, so a feature with a big numeric range quietly drowns out the others. Standardizing every feature to the same scale puts them on equal footing. Isolation Forest is less sensitive to this because it splits one feature at a time, but scaling first is a harmless habit that keeps all three algorithms honest.
Conclusion
You started with a single idea, that anomalies are few and different, and rode it all the way from a twenty-questions analogy to three working detectors. You built Isolation Forest by hand to see that its score is nothing more than an average number of random cuts, then ran the real scikit-learn version on fake transaction data. You put Isolation Forest, Local Outlier Factor, and One-Class SVM side by side, measured them with precision and recall, and saw why no single algorithm always wins. Along the way you learned the two settings that make or break Python anomaly detection in practice: an honest contamination estimate and scaled features.
Next up in the series you move from spotting the odd one out to predicting what a user will like, with recommender systems built on collaborative and content-based filtering. If you want the full roadmap from Python basics through deep learning, everything is laid out on the Python + AI/ML tutorial series home.
Frequently Asked Questions
When should I use anomaly detection instead of supervised classification?
Reach for Python anomaly detection tools like Isolation Forest when labeled anomalies are scarce (under 1% of your data) or missing entirely, which is the normal situation for fraud, defects, and rare failures. Switch to supervised classification once you have enough labeled examples of both classes (at least a few hundred confirmed anomalies) to learn a real decision boundary.
What does the contamination parameter control in Isolation Forest?
It is your estimate of the proportion of anomalies in the dataset, and it sets the threshold on the anomaly score that separates normal from anomalous. Lower values flag fewer points, higher values flag more. Match it to the rate you genuinely expect, because setting it too high floods you with false positives.
Why did all three algorithms give different precision on the same data?
Each one defines weird differently. Isolation Forest counts random splits, LOF compares local density, and One-Class SVM draws a boundary around the dense core. They also honor the outlier fraction differently: Isolation Forest and LOF flag close to the requested fraction, while One-Class SVM treats its nu parameter as an upper bound and can flag more. The best choice depends on your data shape, so test before committing.
Can Isolation Forest work on streaming data?
The standard implementation needs all the data up front, so for a live stream you retrain periodically on a sliding window of recent data. There are also online variants such as Half-Space Trees that are designed for streaming and update as new points arrive.
How do I evaluate anomaly detection when I have no labels?
Lean on domain knowledge first: pull the flagged points and check whether they actually look suspicious to a human. If you have even a small labeled subset, compute precision and recall on it. Reconstruction error (for autoencoder methods) and the spread of anomaly scores also give you a rough unsupervised quality signal.
Interview Questions on Anomaly Detection
Try each one aloud first. The phrasing you produce under mild pressure is what interviews measure.
Q: Why does Isolation Forest give anomalies a shorter average path length?
Because an anomaly sits far from the crowd, a single random cut placed anywhere in the wide empty gap around it slices it off on its own. Normal points sit inside a dense cluster, so most cuts leave them with company and it takes many more splits to isolate them. Averaged over hundreds of random trees, anomalies end up with short paths and normal points with long ones, and that average depth becomes the anomaly score.
Q: What is the difference between decision_function and predict on an Isolation Forest?
decision_function returns a continuous signed score where lower (more negative) means more anomalous, so you can rank points or pick your own threshold. predict applies the threshold set by contamination and returns a hard label: -1 for anomaly and 1 for normal. Use the raw scores when you need a ranked triage list and the labels when you need a yes-or-no decision.
Q: Why does One-Class SVM often flag more points than you asked for?
Its nu parameter is an upper bound on the fraction of training points allowed to fall outside the learned boundary, not a hard target like Isolation Forest’s contamination. It also lower-bounds the fraction of support vectors. So with nu=0.04 the model can legitimately flag more than 4% of points, which is why precision can drop if you assume it will hit the number exactly.
Q: Your fraud model worked fine last month, but after a data update it now flags ten times as many transactions. What do you check first?
Start with the contamination setting against the real anomaly rate, since that directly sets how many points get labeled -1. Then confirm the scaler was fit on the training data and reused, not refit on the new batch, because a shifted scale moves every point relative to the learned boundary. Finally check for genuine data drift: if the incoming distribution really changed, the old model of normal is stale and needs retraining on recent data.
Q: You trained a LocalOutlierFactor in a notebook and it scored the training set perfectly, but in production you cannot score new incoming transactions. Why, and how do you fix it?
By default LOF runs in outlier-detection mode (novelty=False), which only exposes fit_predict on the data it was fit on and has no predict for unseen points. To score new points you must set novelty=True, fit on clean normal data only, then call predict on the new rows. If you need to score a live stream continuously, Isolation Forest or a streaming variant is usually the easier fit.
Q: Why is feature scaling more important for LOF and One-Class SVM than for Isolation Forest?
LOF and One-Class SVM measure distances between points, so a feature with a large numeric range (an amount in the hundreds) drowns out one in the tens (a frequency) unless you standardize first. Isolation Forest splits on one feature at a time and only cares about the order of values within that feature, so it is far less sensitive to scale. Scaling everything first is still a harmless habit that keeps all three algorithms honest.
Series: Python + AI/ML Cookbook. Part 5: Machine Learning
Further reading: for the full reference, see the official Python documentation.
Related Posts
Previous: ML: Pipeline, End-to-End ML Pipelines with scikit-learn
Next: ML: Recommender Systems (Collaborative and Content)
Series Home: Python + AI/ML Tutorial Series

No comment