Anomaly detection — finding data points that are significantly different from the majority — is one of the most practically valuable applications of machine learning. It’s used in fraud detection (identifying unusual transactions), network security (detecting intrusions), manufacturing (catching defective products), and infrastructure monitoring (alerting on unusual system behaviour). Unlike most classification problems, anomaly detection usually works without labelled examples of anomalies, because by definition they’re rare and often novel.
Statistical Methods: The Starting Point
Before jumping to ML models, statistical methods often work surprisingly well and are far easier to explain and debug. The simplest approach: flag points more than N standard deviations from the mean. For non-Gaussian distributions, use the IQR method:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
# Simulate data with some anomalies
normal_data = np.random.normal(50, 10, 1000)
anomalies = np.array([5, 8, 95, 102, 110])
data = np.concatenate([normal_data, anomalies])
df = pd.DataFrame({'value': data})
# Method 1: Z-score (assumes normal distribution)
df['z_score'] = np.abs(stats.zscore(df['value']))
df['anomaly_zscore'] = df['z_score'] > 3 # flag if >3 standard deviations from mean
# Method 2: IQR (robust, doesn't assume normal distribution)
Q1 = df['value'].quantile(0.25)
Q3 = df['value'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
df['anomaly_iqr'] = (df['value'] < lower_bound) | (df['value'] > upper_bound)
print(f"Z-score anomalies: {df['anomaly_zscore'].sum()}")
print(f"IQR anomalies: {df['anomaly_iqr'].sum()}")
print(f"IQR bounds: [{lower_bound:.1f}, {upper_bound:.1f}]")
Isolation Forest: The ML Standard for Tabular Data
Isolation Forest works on the insight that anomalies are rare and different — they’re easier to “isolate” by random partitioning than normal points. The algorithm builds many random trees; anomalous points require fewer splits to isolate and get lower anomaly scores. It’s fast, works well in high dimensions, and doesn’t assume any data distribution:
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import make_blobs
# Generate 2D dataset with anomalies
X_normal, _ = make_blobs(n_samples=300, centers=[[0,0],[5,5]], cluster_std=1.0, random_state=42)
X_anomalies = np.random.uniform(-6, 11, size=(30, 2)) # random scattered points
X = np.vstack([X_normal, X_anomalies])
# Isolation Forest
iso_forest = IsolationForest(
n_estimators=200,
contamination=0.1, # estimated fraction of anomalies in data
max_samples='auto',
random_state=42
)
# -1 = anomaly, 1 = normal
labels = iso_forest.fit_predict(X)
scores = iso_forest.score_samples(X) # lower = more anomalous
n_anomalies = (labels == -1).sum()
print(f"Detected anomalies: {n_anomalies}")
# Visualise
plt.figure(figsize=(8, 6))
plt.scatter(X[labels==1, 0], X[labels==1, 1], c='blue', s=20, label='Normal', alpha=0.5)
plt.scatter(X[labels==-1, 0], X[labels==-1, 1], c='red', s=60, marker='x', label='Anomaly')
plt.title('Isolation Forest Anomaly Detection')
plt.legend()
plt.show()
# For multi-feature tabular data
scaler = StandardScaler()
X_scaled = scaler.fit_transform(df[feature_cols])
df['anomaly_score'] = iso_forest.score_samples(X_scaled)
df['is_anomaly'] = iso_forest.predict(X_scaled) == -1
Autoencoder-Based Anomaly Detection
An autoencoder neural network learns to compress data to a low-dimensional representation and then reconstruct it. When trained only on normal data, it learns to reconstruct normal patterns well but fails to reconstruct anomalies — the reconstruction error becomes the anomaly score:
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
class Autoencoder(nn.Module):
def __init__(self, input_dim, encoding_dim=8):
super().__init__()
self.encoder = nn.Sequential(
nn.Linear(input_dim, 32), nn.ReLU(),
nn.Linear(32, encoding_dim), nn.ReLU()
)
self.decoder = nn.Sequential(
nn.Linear(encoding_dim, 32), nn.ReLU(),
nn.Linear(32, input_dim)
)
def forward(self, x):
return self.decoder(self.encoder(x))
def train_autoencoder(X_normal, epochs=100, lr=1e-3, batch_size=64):
X_tensor = torch.FloatTensor(X_normal)
loader = DataLoader(TensorDataset(X_tensor), batch_size=batch_size, shuffle=True)
model = Autoencoder(input_dim=X_normal.shape[1])
optimizer = optim.Adam(model.parameters(), lr=lr)
criterion = nn.MSELoss()
for epoch in range(epochs):
for (batch,) in loader:
output = model(batch)
loss = criterion(output, batch)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return model
def get_reconstruction_errors(model, X):
# Returns per-sample reconstruction error (used as anomaly score)
X_tensor = torch.FloatTensor(X)
with torch.no_grad():
X_reconstructed = model(X_tensor).numpy()
return np.mean((X - X_reconstructed) ** 2, axis=1)
# Train on normal data only
model = train_autoencoder(X_normal_scaled)
# Get anomaly scores for all data
recon_errors = get_reconstruction_errors(model, X_all_scaled)
threshold = np.percentile(recon_errors[:len(X_normal_scaled)], 99) # 99th percentile of normal errors
anomalies = recon_errors > threshold
print(f"Flagged as anomalies: {anomalies.sum()}")
Choosing the Right Method
For tabular data with no labels, start with Isolation Forest — it’s fast, effective, and requires minimal tuning. Add statistical baselines for individual features as interpretable complements. For time series, use rolling statistics (mean ± N std) or LSTM-based autoencoders for sequence-aware detection. For high-dimensional data like images or log lines, autoencoders shine. Always validate on a small labelled set if you can get one, even if just a few dozen confirmed anomalies — without any ground truth, you’re flying blind on threshold selection.
Frequently Asked Questions
How do I choose the contamination parameter in Isolation Forest?
This is the estimated fraction of anomalies in your data. If you have business knowledge (e.g., fraud rate is 0.5%), use that. If not, try values from 0.01 to 0.1 and see how many anomalies get flagged — then validate with domain experts. Setting it wrong shifts the decision threshold but doesn’t break the model entirely.
How do I evaluate an anomaly detector without labels?
It’s hard. Options include: get a small human-labelled validation set, check if flagged anomalies make business sense when reviewed manually, or use synthetic anomaly injection — add known fake anomalies and check the model detects them. Precision on flagged points (reviewed by a human) is the most practical metric.
What’s the difference between outlier detection and novelty detection?
Outlier detection finds anomalies within the training data (assumes some anomalies exist in training). Novelty detection trains only on normal data and flags anything different from what it learned — the autoencoder approach is novelty detection. Use novelty detection when your training data is “clean” and you want to detect new patterns at inference time.



