🎯 What You'll Learn
- Distinguish the three types of anomalies — point, contextual, and collective — and choose the right detector for each
- Apply statistical methods (Z-score, IQR) and understand their limitations on multivariate data
- Use
IsolationForest,LocalOutlierFactor,OneClassSVM, andEllipticEnvelopefrom scikit-learn - Evaluate anomaly detectors with precision, recall, F1, and AUC-ROC when labels exist
- Choose the right algorithm based on dataset size, distribution, and expected anomaly fraction
1 What Are Anomalies?
An anomaly (also called an outlier) is a data point that deviates so significantly from the expected pattern that it raises suspicion of being generated by a different mechanism. Anomaly detection is one of the most practically valuable problems in machine learning — credit card fraud, network intrusions, manufacturing defects, and medical abnormalities all manifest as anomalies in data.
Researchers classify anomalies into three distinct types, each requiring a different detection strategy:
Point Anomalies
A single data instance is anomalous compared to the rest of the data. This is the most common type. Example: a single credit card transaction of $50,000 when your typical purchase is $80. The transaction itself is the anomaly — it is isolated and deviates from the global distribution.
Contextual Anomalies
A data instance that is anomalous in a specific context but would be perfectly normal in another. Context is usually defined by time or location. Example: a temperature reading of 30°C in Reykjavik, Iceland in December is highly anomalous (contextually), even though 30°C is completely normal in summer or in tropical regions. Similarly, CPU usage of 95% at 3am on a server that is idle overnight is anomalous, whereas the same usage during a batch job is expected.
Collective Anomalies
A collection of data instances that are anomalous together, even though each individual instance may not be anomalous on its own. Example: in an ECG reading, no single heartbeat may look unusual, but a sequence of rapid beats together forms a pattern consistent with atrial fibrillation. Intrusion detection frequently looks for collective anomalies: a sequence of network events (port scan → failed logins → successful login → large data transfer) forms a malicious pattern even if each step alone could be benign.
Labeled anomaly datasets are rare (anomalies, by definition, don't happen often), class imbalance is extreme (fraud rates are often 0.1–1%), the definition of "normal" changes over time (concept drift), and novel anomaly types may be completely different from anything seen in training. Most production anomaly detectors therefore rely on unsupervised or semi-supervised approaches — learning what "normal" looks like rather than what "anomalous" looks like.
The domains where anomaly detection creates direct business value include: financial fraud (credit cards, insurance claims, money laundering), cybersecurity (intrusion detection, DDoS, insider threats), industrial IoT (predictive maintenance, equipment fault detection), healthcare (unusual lab results, EHR anomalies), and quality control (defective products on an assembly line).
2 Statistical Methods: Z-Score & IQR
Before reaching for machine learning, always try the simplest statistical methods first. They are interpretable, fast, and often sufficient for univariate data (a single sensor reading, a single financial metric, etc.).
Z-Score Method
The Z-score measures how many standard deviations a data point lies from the mean. A threshold of |Z| > 3 catches roughly 0.27% of a normally distributed population — anything beyond three standard deviations is flagged as an anomaly.
import numpy as np
import pandas as pd
from scipy import stats
np.random.seed(42)
# Simulate sensor temperature readings (°C) — mostly normal, a few anomalies
normal_readings = np.random.normal(loc=72.0, scale=3.0, size=500)
anomalies = np.array([110.0, -15.0, 145.0, 5.0]) # clearly wrong readings
data = np.concatenate([normal_readings, anomalies])
np.random.shuffle(data)
# Z-score method
z_scores = np.abs(stats.zscore(data))
threshold = 3.0
is_anomaly_z = z_scores > threshold
print(f"Total readings: {len(data)}")
print(f"Flagged by Z-score: {is_anomaly_z.sum()}")
print(f"Z-score > 3 values: {data[is_anomaly_z]}")
# Output:
# Total readings: 504
# Flagged by Z-score: 4
# Z-score > 3 values: [110. -15. 145. 5.]
IQR (Interquartile Range) Method
The IQR method is more robust to extreme values than Z-score because it uses medians and quartiles rather than means and standard deviations. Outliers are defined as points outside the "fences": below Q1 − 1.5×IQR or above Q3 + 1.5×IQR. A stricter multiplier of 3.0 is used for "far" outliers.
import numpy as np
import pandas as pd
np.random.seed(42)
# Simulate daily transaction amounts
normal_transactions = np.random.lognormal(mean=3.5, sigma=0.8, size=1000) # log-normal
fraud_transactions = np.array([8500.0, 12000.0, 9900.0, 7800.0])
transactions = np.concatenate([normal_transactions, fraud_transactions])
def iqr_anomaly_detector(data, multiplier=1.5):
"""Returns a boolean mask: True = anomaly."""
Q1 = np.percentile(data, 25)
Q3 = np.percentile(data, 75)
IQR = Q3 - Q1
lower_fence = Q1 - multiplier * IQR
upper_fence = Q3 + multiplier * IQR
return (data < lower_fence) | (data > upper_fence), lower_fence, upper_fence
mask, lower, upper = iqr_anomaly_detector(transactions, multiplier=1.5)
print(f"IQR fences: [{lower:.1f}, {upper:.1f}]")
print(f"Flagged: {mask.sum()} anomalies")
print(f"Flagged values (top 10 by magnitude):")
print(sorted(transactions[mask], reverse=True)[:10])
# IQR fences: [-19.9, 129.8]
# Flagged: 28 anomalies
# Flagged values include all 4 fraud transactions
Z-score assumes a Gaussian (normal) distribution and is sensitive to the very outliers it's trying to detect (they inflate the mean and std). IQR is non-parametric and more robust but still operates on a single variable at a time. Both methods completely miss multivariate anomalies — a data point where no single feature is unusual, but the combination of features is. For example: a 25-year-old buying 50 cases of baby formula might look normal on each individual feature (age, quantity), but their combination is anomalous. For this, you need multivariate methods.
3 Isolation Forest
Isolation Forest (Liu et al., 2008) is the go-to algorithm for general-purpose anomaly detection. Its key insight is elegant: anomalies are easier to isolate than normal points. Normal points cluster together in feature space — you need many random splits to isolate one. Anomalies are sparse and distant — you can isolate one with very few random splits.
Every anomaly detector ultimately reduces to the same two steps: compute a score for each point, then pick a threshold above which a point is flagged. The chart below uses the same blob-plus-outliers dataset as the code below it, but scores each point with a simple distance-based proxy for "how isolated is this point" — its distance from the normal cluster's centroid. Drag the threshold slider and watch the precision/recall tradeoff play out directly: a low threshold flags almost everything (including normal points near the edge of the cluster — false positives), while a high threshold only flags the most extreme points (and may let a borderline real anomaly slip through as a false negative).
Threshold = 3.00 distance units from centroid.
How It Works
The algorithm builds an ensemble of random "isolation trees":
- Randomly select a feature
- Randomly select a split value between the feature's min and max
- Recurse on each side until each point is isolated (in its own leaf)
- Record the path length — number of splits needed to isolate each point
- Average path length across many trees; shorter average path → more likely an anomaly
The anomaly score is based on the normalized average path length: a score close to 1 means an anomaly; close to 0.5 means normal; below 0.5 means definitely normal.
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.datasets import make_blobs
import matplotlib.pyplot as plt
np.random.seed(42)
# Create a dataset: two clusters of normal points + some anomalies
X_normal, _ = make_blobs(n_samples=300, centers=[[2, 2], [-2, -2]],
cluster_std=0.5, random_state=42)
X_anomaly = np.random.uniform(low=-6, high=6, size=(15, 2))
X = np.vstack([X_normal, X_anomaly])
# Isolation Forest
# contamination = expected fraction of anomalies in the dataset
# n_estimators = number of isolation trees (100 is usually enough)
iso_forest = IsolationForest(
n_estimators=100,
contamination=0.05, # expect ~5% anomalies
max_samples='auto', # subsample size per tree (default: min(256, n_samples))
random_state=42
)
iso_forest.fit(X)
# fit_predict: returns 1 for normal, -1 for anomaly
labels = iso_forest.fit_predict(X)
scores = iso_forest.score_samples(X) # lower score = more anomalous
print(f"Total points: {len(X)}")
print(f"Flagged as anomaly: {(labels == -1).sum()}")
print(f"Score range: [{scores.min():.3f}, {scores.max():.3f}]")
print(f"\nMost anomalous 5 scores: {sorted(scores)[:5]}")
print(f"Most normal 5 scores: {sorted(scores)[-5:]}")
# Visualize — highlight detected anomalies
normal_pts = X[labels == 1]
anomaly_pts = X[labels == -1]
print(f"\nNormal cluster: {len(normal_pts)} points")
print(f"Anomalies detected: {len(anomaly_pts)} points")
# Using Isolation Forest on real-world-style tabular data
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
np.random.seed(42)
n = 5000
# Simulate network traffic features
df = pd.DataFrame({
'bytes_sent': np.random.exponential(scale=1500, size=n),
'bytes_received': np.random.exponential(scale=5000, size=n),
'duration_ms': np.random.gamma(shape=2, scale=200, size=n),
'packets': np.random.poisson(lam=15, size=n),
'failed_logins': np.random.poisson(lam=0.2, size=n),
})
# Inject 25 anomalous sessions (large data exfiltration)
anomaly_idx = np.random.choice(n, size=25, replace=False)
df.loc[anomaly_idx, 'bytes_sent'] *= 50
df.loc[anomaly_idx, 'failed_logins'] = np.random.randint(20, 50, size=25)
# Scale features before fitting
scaler = StandardScaler()
X_scaled = scaler.fit_transform(df)
iso = IsolationForest(n_estimators=100, contamination=0.005, random_state=42)
preds = iso.fit_predict(X_scaled)
flagged = df[preds == -1].copy()
print(f"Anomalies detected: {(preds == -1).sum()}")
print(f"\nFlagged sessions (avg stats):")
print(flagged[['bytes_sent', 'failed_logins']].describe().round(1))
The contamination parameter is your prior belief about the fraction of anomalies in the dataset. If you're doing credit card fraud detection with a known 0.17% fraud rate, set contamination=0.0017. If you have no prior knowledge, start with contamination=0.01 (1%) and adjust based on domain expert review of flagged samples. Getting this right matters more than tuning n_estimators.
4 Local Outlier Factor (LOF)
Local Outlier Factor (Breunig et al., 2000) takes a density-based approach. Instead of asking "is this point far from everyone?", LOF asks: "is this point's local neighborhood much less dense than its neighbors' neighbourhoods?"
This makes LOF powerful in situations where anomalies exist within or near clusters, and where the data has clusters of very different densities — a case where Isolation Forest and statistical methods struggle.
The LOF Score
For each point, LOF computes a "local reachability density" — essentially how densely packed its k nearest neighbors are. The LOF score is the ratio of the average local density of its neighbors to its own local density:
- LOF ≈ 1: the point is in a region of similar density to its neighbors — likely normal
- LOF >> 1: the point is in a much less dense region than its neighbors — likely an anomaly
- LOF < 1: the point is in a denser region than its neighbors (rare)
import numpy as np
from sklearn.neighbors import LocalOutlierFactor
from sklearn.datasets import make_blobs
np.random.seed(42)
# Two clusters of very different densities — challenging for Isolation Forest
dense_cluster = np.random.normal(loc=[0, 0], scale=0.3, size=(200, 2))
sparse_cluster = np.random.normal(loc=[5, 5], scale=1.5, size=(100, 2))
# Anomalies: points far from both clusters
outliers = np.array([[10, 10], [-4, -4], [2, 7], [-1, 6], [8, 2]])
X = np.vstack([dense_cluster, sparse_cluster, outliers])
# LOF for anomaly detection in fit mode
lof = LocalOutlierFactor(
n_neighbors=20, # k: number of neighbors to use
contamination=0.05, # expected fraction of outliers
metric='euclidean'
)
labels = lof.fit_predict(X)
scores = lof.negative_outlier_factor_ # more negative = more anomalous
print(f"Total points: {len(X)}")
print(f"Flagged anomalies: {(labels == -1).sum()}")
print(f"\nLOF scores for injected outliers (lower = more anomalous):")
for i, pt in enumerate(outliers):
idx = len(dense_cluster) + len(sparse_cluster) + i
print(f" Point {pt}: LOF score = {scores[idx]:.3f}")
# For predicting on NEW data (not seen during fit), use novelty=True
lof_novelty = LocalOutlierFactor(n_neighbors=20, novelty=True)
lof_novelty.fit(np.vstack([dense_cluster, sparse_cluster]))
# Now predict on unseen test points
new_points = np.array([[0.1, 0.2], [5, 5.1], [15, 15]]) # normal, normal, anomaly
new_labels = lof_novelty.predict(new_points)
new_scores = lof_novelty.score_samples(new_points)
for pt, lbl, sc in zip(new_points, new_labels, new_scores):
status = "ANOMALY" if lbl == -1 else "normal"
print(f" {pt} → {status} (score: {sc:.3f})")
By default, LocalOutlierFactor is a transductive method — you call fit_predict(X) on the full dataset and get labels for all points at once. It cannot predict on new, unseen data. Set novelty=True to switch to an inductive mode where fit(X_train) learns the normal region and predict(X_new) classifies new points. The novelty mode trades some accuracy for the ability to score new data — essential for production streaming anomaly detection.
5 One-Class SVM
One-Class SVM (Schölkopf et al., 2001) takes a different approach: it learns a tight boundary around the "normal" class during training, and classifies anything outside that boundary as an anomaly. It uses the kernel trick to find this boundary in a high-dimensional feature space, making it capable of capturing complex non-linear shapes of normality.
import numpy as np
from sklearn.svm import OneClassSVM
from sklearn.preprocessing import StandardScaler
np.random.seed(42)
# One-class SVM works best on small datasets — create a focused example
# Simulate normal operating parameters for a CNC machine
normal_data = np.random.multivariate_normal(
mean=[100, 0.5, 60], # spindle_speed, vibration_rms, temperature
cov=[[100, 0, 0], [0, 0.01, 0], [0, 0, 25]],
size=500
)
# Fault states — abnormal operating conditions
fault_data = np.array([
[180, 2.1, 95], # overspeed + high vibration + overheating
[45, 1.8, 88], # underspeed + vibration
[110, 0.6, 105], # normal speed, slightly elevated vibration, very hot
[95, 3.5, 70], # near-normal speed, extreme vibration
])
# Scale features (important for SVM!)
scaler = StandardScaler()
X_normal_scaled = scaler.fit_transform(normal_data)
X_fault_scaled = scaler.transform(fault_data)
# One-Class SVM
# nu: upper bound on fraction of training errors AND
# lower bound on fraction of support vectors
# Think of it as approximately the expected anomaly fraction
# kernel='rbf': standard choice; gamma controls boundary tightness
ocsvm = OneClassSVM(kernel='rbf', nu=0.05, gamma='scale')
ocsvm.fit(X_normal_scaled)
# Predict on held-out normal data + faults
y_normal_pred = ocsvm.predict(X_normal_scaled[-50:]) # should be mostly +1
y_fault_pred = ocsvm.predict(X_fault_scaled) # should be -1
print(f"Normal data flagged as anomaly: {(y_normal_pred == -1).sum()}/50")
print(f"Fault data correctly flagged: {(y_fault_pred == -1).sum()}/{len(fault_data)}")
# Decision scores: more negative = more anomalous
normal_scores = ocsvm.score_samples(X_normal_scaled)
fault_scores = ocsvm.score_samples(X_fault_scaled)
print(f"\nNormal score range: [{normal_scores.min():.3f}, {normal_scores.max():.3f}]")
print(f"Fault score range: [{fault_scores.min():.3f}, {fault_scores.max():.3f}]")
One-Class SVM has O(n²) to O(n³) training complexity. On a dataset of 10,000 samples it can take minutes; on 100,000 samples it becomes impractical. For large datasets, use Isolation Forest instead. One-Class SVM shines when: (1) you have a small, clean dataset of normal examples, (2) the decision boundary is genuinely complex and nonlinear, and (3) you have time to tune the gamma and nu hyperparameters carefully with cross-validation.
6 Elliptic Envelope
The Elliptic Envelope (Rousseeuw & Van Driessen, 1999) fits a robust multivariate Gaussian distribution to the data and flags points that fall outside the resulting ellipsoid as anomalies. It extends the univariate Z-score concept to multiple dimensions simultaneously, and uses a robust covariance estimator (Minimum Covariance Determinant) to avoid being distorted by the outliers it is trying to detect.
import numpy as np
from sklearn.covariance import EllipticEnvelope
from sklearn.preprocessing import StandardScaler
np.random.seed(42)
# Elliptic Envelope works well when data is roughly multivariate Gaussian
# Example: financial KPIs that tend to be jointly normally distributed
n_companies = 300
revenue_growth = np.random.normal(loc=0.08, scale=0.05, size=n_companies)
profit_margin = np.random.normal(loc=0.12, scale=0.04, size=n_companies)
# Inject correlation: high growth tends to come with some compression in margin
profit_margin += -0.3 * revenue_growth + np.random.normal(0, 0.01, size=n_companies)
X_financial = np.column_stack([revenue_growth, profit_margin])
# Inject 10 anomalous companies (implausible financials)
outlier_companies = np.array([
[0.85, 0.60], # insane growth + crazy margin (fraud?)
[-0.50, 0.55], # massive revenue decline but huge margin (accounting manipulation?)
[0.90, -0.30], # hypergrowth but deeply loss-making
])
X_all = np.vstack([X_financial, outlier_companies])
# Elliptic Envelope
envelope = EllipticEnvelope(
contamination=0.03, # expect ~3% outliers
support_fraction=None, # auto-compute MCD support
random_state=42
)
envelope.fit(X_all)
labels = envelope.predict(X_all) # 1 = normal, -1 = anomaly
scores = envelope.score_samples(X_all) # Mahalanobis distance-based score
print(f"Total companies: {len(X_all)}")
print(f"Flagged anomalies: {(labels == -1).sum()}")
print(f"\nInjected outlier predictions: {labels[-3:]}") # Should all be -1
print(f"Outlier scores: {scores[-3:].round(3)}")
The Elliptic Envelope is the right choice when your data is approximately multivariate Gaussian — think: financial metrics, sensor measurements from a stable process, or any log-transformed positive data. It is the fastest algorithm here (fits in milliseconds), is mathematically interpretable (uses Mahalanobis distance), and captures multivariate correlations. It fails when the data has multiple clusters, heavy tails (e.g., power-law distributed), or complex non-Gaussian shapes.
7 Evaluating Anomaly Detectors
Evaluation is the hardest part of anomaly detection. The fundamental challenge: anomalies are rare, and labels are usually unavailable in production. When labels do exist — as in fraud datasets or medical anomaly benchmarks — you have a standard binary classification problem. When labels are absent, evaluation requires expert review or A/B testing.
When Labels Are Available
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.metrics import (precision_score, recall_score, f1_score,
roc_auc_score, classification_report,
average_precision_score)
from sklearn.datasets import make_classification
np.random.seed(42)
# Simulate a labeled anomaly dataset (rare anomalies)
X, y_true = make_classification(
n_samples=5000, n_features=15, n_informative=8,
weights=[0.98, 0.02], # 98% normal, 2% anomalous
flip_y=0.01, random_state=42
)
# y_true: 0 = normal, 1 = anomaly
# Convert to sklearn anomaly convention: +1 = normal, -1 = anomaly
y_anomaly = np.where(y_true == 1, -1, 1)
# Fit Isolation Forest
iso = IsolationForest(
n_estimators=100,
contamination=0.02, # match true contamination rate
random_state=42
)
y_pred = iso.fit_predict(X)
scores = iso.score_samples(X)
# Convert back to binary for sklearn metrics (1 = anomaly class)
y_pred_binary = (y_pred == -1).astype(int)
y_true_binary = y_true # 1 = anomaly
print("=== Anomaly Detector Evaluation ===")
print(classification_report(y_true_binary, y_pred_binary,
target_names=['Normal', 'Anomaly']))
print(f"AUC-ROC: {roc_auc_score(y_true_binary, -scores):.4f}")
print(f"Average Precision: {average_precision_score(y_true_binary, -scores):.4f}")
print()
# IMPORTANT: accuracy is meaningless — a dummy classifier predicting
# all-normal achieves 98% accuracy on this dataset!
from sklearn.dummy import DummyClassifier
dummy = DummyClassifier(strategy='most_frequent')
dummy.fit(X, y_true_binary)
print(f"Dummy (all-normal) accuracy: {dummy.score(X, y_true_binary):.4f}")
print(f"Isolation Forest accuracy: {(y_pred_binary == y_true_binary).mean():.4f}")
print("← These are almost identical! Accuracy is useless here.")
On a dataset where 0.2% of samples are fraud, a classifier that labels everything as normal achieves 99.8% accuracy while catching zero fraud. Always evaluate anomaly detectors with: Precision (of flagged anomalies, how many were real?), Recall (of real anomalies, how many were caught?), and AUC-ROC or Average Precision (threshold-independent ranking quality). In most business contexts, recall is more important — missing a fraud case is worse than a false alarm.
8 Choosing the Right Algorithm
With four major algorithms available, selecting the right one requires understanding your dataset's properties:
| Algorithm | Best For | Scale | Limitations |
|---|---|---|---|
| Isolation Forest | General-purpose; recommended default; works on high-dim data | Millions of rows ✓ | Struggles with density variations between clusters |
| LOF | Clusters of different densities; local anomalies within clusters | Up to ~100k rows | Slow on large datasets; k-NN computation expensive |
| One-Class SVM | Small datasets with clean normal-class training data; complex boundaries | Up to ~10k rows | O(n²–n³) training; sensitive to gamma and nu tuning |
| Elliptic Envelope | Gaussian-distributed data; interpretable Mahalanobis distance | Fast on any size | Fails on non-Gaussian, multi-modal distributions |
| Z-score / IQR | Univariate data; interpretable rules; quick sanity checks | Any size | Univariate only; misses multivariate anomalies |
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.svm import OneClassSVM
from sklearn.covariance import EllipticEnvelope
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score
np.random.seed(42)
# Quick comparison of all four methods on the same dataset
X_normal = np.random.multivariate_normal([0, 0], [[1, 0.5], [0.5, 1]], size=400)
X_outlier = np.random.uniform(-4, 4, size=(20, 2))
X = np.vstack([X_normal, X_outlier])
y_true = np.concatenate([np.zeros(400), np.ones(20)]) # 1 = anomaly
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
methods = {
'Isolation Forest': IsolationForest(contamination=0.05, random_state=42),
'LOF': LocalOutlierFactor(n_neighbors=20, contamination=0.05),
'One-Class SVM': OneClassSVM(nu=0.05, kernel='rbf', gamma='scale'),
'Elliptic Envelope': EllipticEnvelope(contamination=0.05, random_state=42),
}
print(f"{'Method':20s} {'Flagged':>8} {'AUC-ROC':>8}")
print("-" * 45)
for name, model in methods.items():
if name == 'LOF':
preds = model.fit_predict(X_scaled)
scores = -model.negative_outlier_factor_
else:
model.fit(X_scaled)
preds = model.predict(X_scaled)
scores = -model.score_samples(X_scaled)
flagged = (preds == -1).sum()
auc = roc_auc_score(y_true, scores)
print(f"{name:20s} {flagged:>8} {auc:>8.4f}")
Real-World Spotlight: Credit Card Fraud Detection
The UCI Credit Card Fraud dataset contains 284,807 transactions from European cardholders over two days in September 2013. Only 492 transactions are fraudulent — a contamination rate of just 0.172%. This makes it one of the most cited real-world examples of extreme class imbalance and anomaly detection.
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (precision_score, recall_score, f1_score,
roc_auc_score, average_precision_score,
confusion_matrix)
from scipy import stats
np.random.seed(42)
# Simulate the key statistical properties of the fraud dataset
# (In practice: pd.read_csv('creditcard.csv'))
n_normal = 28415
n_fraud = 49
# V1-V28 are PCA-transformed features (anonymized); Amount and Time are raw
def simulate_fraud_dataset(n_normal, n_fraud):
normal = pd.DataFrame({
**{f'V{i}': np.random.normal(0, 1, n_normal) for i in range(1, 29)},
'Amount': np.random.lognormal(3.0, 1.5, n_normal).clip(0, 25000),
'Class': 0
})
fraud = pd.DataFrame({
**{f'V{i}': np.random.normal(i % 3 - 1, 1.5, n_fraud) for i in range(1, 29)},
'Amount': np.random.lognormal(4.5, 1.0, n_fraud).clip(0, 25000),
'Class': 1
})
return pd.concat([normal, fraud]).sample(frac=1, random_state=42).reset_index(drop=True)
df = simulate_fraud_dataset(n_normal, n_fraud)
print(f"Dataset shape: {df.shape}")
print(f"Fraud rate: {df['Class'].mean():.4%}")
X = df.drop('Class', axis=1).values
y = df['Class'].values
# Scale Amount (the only non-PCA feature)
scaler = StandardScaler()
X_scaled = X.copy().astype(float)
X_scaled[:, -1] = scaler.fit_transform(X[:, -1].reshape(-1, 1)).ravel()
# Method 1: Z-score on Amount (univariate baseline)
z_amount = np.abs(stats.zscore(X[:, -1]))
y_pred_z = (z_amount > 3).astype(int)
print(f"\n--- Z-score on Amount ---")
print(f"Precision: {precision_score(y, y_pred_z):.4f}")
print(f"Recall: {recall_score(y, y_pred_z):.4f}")
print(f"F1: {f1_score(y, y_pred_z):.4f}")
# Method 2: Isolation Forest (multivariate)
iso = IsolationForest(
n_estimators=100,
contamination=0.0017, # match the true fraud rate
random_state=42,
n_jobs=-1
)
iso.fit(X_scaled)
y_pred_iso = (iso.predict(X_scaled) == -1).astype(int)
iso_scores = -iso.score_samples(X_scaled)
print(f"\n--- Isolation Forest ---")
print(f"Precision: {precision_score(y, y_pred_iso):.4f}")
print(f"Recall: {recall_score(y, y_pred_iso):.4f}")
print(f"F1: {f1_score(y, y_pred_iso):.4f}")
print(f"AUC-ROC: {roc_auc_score(y, iso_scores):.4f}")
print(f"Avg Prec: {average_precision_score(y, iso_scores):.4f}")
print(f"\nConfusion Matrix:")
cm = confusion_matrix(y, y_pred_iso)
print(f" True Normal flagged as Normal: {cm[0,0]}")
print(f" True Normal flagged as Fraud: {cm[0,1]} (false positives)")
print(f" True Fraud flagged as Normal: {cm[1,0]} (missed fraud — costly!)")
print(f" True Fraud flagged as Fraud: {cm[1,1]} (caught)")
print(f"\nKey insight: contamination={0.0017} is crucial.")
print(f"Setting it to 0.05 would flag 1400 legitimate transactions as fraud.")
The key lesson from this dataset is that the contamination parameter must reflect the true anomaly rate as closely as possible. Setting it too high (e.g., 0.05) generates thousands of false positives and erodes customer trust. Setting it too low misses real fraud. In production, this parameter is calibrated using labeled historical data and adjusted based on the cost of false positives vs false negatives — a business decision, not just a statistical one. This dataset also connects directly to Lesson 26 (imbalanced datasets): techniques like SMOTE and class weighting that work for supervised models are complementary to unsupervised anomaly detection when some labeled fraud examples are available.
✍️ Practice Exercises
- Load the UCI Credit Card Fraud dataset from Kaggle (or use the simulation above). Compare the precision and recall of
IsolationForestwithcontamination=0.001vscontamination=0.01. Which contamination setting catches more fraud with acceptable false positives? - Generate a 2D dataset with two clusters of very different densities (one tight, one spread). Show that
IsolationForestflags points inside the dense cluster as anomalous (a known weakness), butLocalOutlierFactordoes not. Plot the results side by side. - Apply
EllipticEnvelopeto a dataset of your choice. Then apply a log transform to skewed features and refit. Does the Mahalanobis distance improve? Why does the Gaussian assumption matter? - Implement a manual IQR anomaly detector using Pandas. Apply it column-by-column to a multivariate dataset and union the per-column anomaly flags. Then apply
IsolationForestto the same data. Which catches more true anomalies? Why does multivariate detection matter?
📚 Primary Source for This Lesson
scikit-learn: Novelty and Outlier Detection
The official scikit-learn guide covers all four algorithms in this lesson with visual comparisons on synthetic datasets — essential reading for understanding when each method succeeds and fails. Also recommended: Liu, Ting & Zhou (2008) "Isolation Forest" (ICDM 2008) — the original paper is only 6 pages and very readable; it explains why shorter path length implies anomaly with a compelling theoretical argument.