Anomaly Detection Techniques (Verbose)
Core Patterns
Naming the Anomaly Before Choosing a Method
"Anomaly" covers three distinct problems, and a detector built for one is close to useless on the others.
| Type | Definition | Example | Approach |
|---|---|---|---|
| Point | A single observation is extreme | $40,000 charge on a $50/mo card | Robust z, Isolation Forest |
| Contextual | Normal in general, wrong here | 500 req/s at 4am Sunday | Deseasonalize, then score residual |
| Collective | Each point fine, the sequence is not | 200 logins, each valid, in 60s | Windowed aggregates, sequence models |
Most production incidents are contextual or collective. Teams reach for a point detector, get poor recall, and conclude anomaly detection does not work — when the actual failure was scoring raw values instead of residuals against an expectation.
Robust Univariate Screening
import numpy as np
def robust_z(x: np.ndarray) -> np.ndarray:
med = np.median(x)
mad = np.median(np.abs(x - med))
if mad == 0: # >50% identical values
mad = np.mean(np.abs(x - med)) or 1e-9
return 0.6745 * (x - med) / mad
flags = np.abs(robust_z(x)) > 3.5
The classic 3-sigma rule fails through masking: the outliers you are hunting sit inside the sample used to compute σ, so a few extreme points inflate σ enough to bring themselves back inside the threshold. Ten large outliers in a thousand points can double σ and hide each other. Median and MAD have a 50% breakdown point — half the data can be arbitrary before the estimate moves.
The 0.6745 constant is Φ⁻¹(0.75), which makes MAD a consistent estimator of σ
under normality, so the familiar 3-ish cutoff still means roughly what you expect.
The conventional threshold is 3.5.
Heavy-tailed data (latency, revenue, file sizes) needs a log transform first, or even the robust threshold will flag the natural right tail every time.
Multivariate Detection
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
iso = make_pipeline(
StandardScaler(),
IsolationForest(n_estimators=300, max_samples=256,
contamination="auto", random_state=0),
)
iso.fit(X_clean) # fit on a known-good window
scores = -iso[-1].score_samples(iso[:-1].transform(X_new)) # higher = anomalous
lof = LocalOutlierFactor(n_neighbors=20, novelty=True).fit(X_clean)
lof_scores = -lof.score_samples(X_new)
Isolation Forest builds random trees and scores by average path length —
anomalies sit in sparse regions and get isolated in few splits. max_samples=256
is the paper's default and is deliberately small; subsampling reduces swamping,
where dense normal clusters make genuinely isolated points look ordinary.
Local Outlier Factor compares a point's local density to its neighbors'. This is
the one that catches an anomaly sitting between two dense clusters — globally
unremarkable, locally in a void. It requires novelty=True at construction to be
usable on unseen data; the default object only exposes fit_predict on the
training set.
Scaling matters more than method choice for distance- and density-based detectors: an unscaled feature measured in bytes dominates every distance computation. Fit the scaler on clean data only — one fitted on contaminated data has its variance inflated by the anomalies, which shrinks them toward the center.
Time Series Anomalies
from statsmodels.tsa.seasonal import STL
stl = STL(series, period=24 * 7, robust=True).fit()
resid = stl.resid
score = np.abs(robust_z(resid.values))
Score the residual, never the level. robust=True uses iteratively reweighted
fitting so a spike does not get absorbed into the seasonal or trend component —
without it, a large anomaly partly explains itself away and its residual shrinks.
For a forecast-based detector the prediction interval is the threshold: flag
observations outside it, and let the interval widen where the model is genuinely
uncertain instead of using one global cutoff. Multiple seasonalities (daily and
weekly) need MSTL or explicit Fourier terms; a single period leaves the other
cycle in the residual and produces a recurring wave of false positives.
See time-series-preprocessing for the causality rules — a detector whose expected value was fitted using future points looks excellent offline and detects nothing live.
Evaluation
from sklearn.metrics import average_precision_score, precision_recall_curve
ap = average_precision_score(y_true, scores) # PR-AUC
order = np.argsort(-scores)
for k in (10, 50, 100, 500):
print(k, y_true[order[:k]].mean()) # precision@k
| Metric | At 0.1% base rate | Verdict |
|---|---|---|
| Accuracy | 99.9% for "always normal" | Meaningless |
| ROC-AUC | 0.95 while 95% of alerts are false | Misleading |
| PR-AUC (AP) | Tracks true/false alert ratio | Use it |
| Precision@k | Directly the operator's experience | Use it |
| Recall@fixed-alert-budget | Coverage at sustainable load | Use it |
ROC-AUC is the trap. Its x-axis is false positive rate, normalized by the huge negative class, so 10,000 false positives out of 10 million negatives moves it by 0.001 — while making the alert queue 10,000 items long. PR-AUC and precision@k use raw false positive counts and reflect what the on-call engineer actually sees.
Set k from the triage budget: if the team can investigate 40 alerts a day, the
only question is how many of the top 40 are real. For streaming systems, detection
latency belongs beside precision — a detector that finds an outage with 100%
precision six hours later has not detected it.
Thresholding and Operations
contamination in scikit-learn sets offset_ so that the given fraction of
training data is labeled anomalous. It does not affect the score ranking — so
persist the raw scores and threshold them yourself against a labeled or
operator-reviewed sample. Setting contamination blindly to 0.01 on data whose
true rate is 0.0001 guarantees a 100:1 false positive ratio.
Feed dismissals back. A detector without a feedback loop degrades silently: data drifts, the score distribution shifts, and the threshold calibrated in March is mute or screaming by September. Recalibrate on a rolling quantile of recent scores, and exclude confirmed anomalies from the refit window.
Common Anti-Patterns
❌ Reporting accuracy. At a 0.1% base rate, a detector that does nothing scores
99.9%.
✅ Precision@k and PR-AUC, with k set by triage capacity.
❌ Reporting ROC-AUC as the headline. Stays near 0.95 while nearly every alert is false. ✅ Average precision, plus the raw confusion counts at the operating threshold.
❌ Mean ± 3σ on skewed or contaminated data. The outliers inflate σ and mask themselves. ✅ Median/MAD, and log-transform heavy-tailed quantities first.
❌ Scoring raw values on a seasonal series. Every quiet night is an anomaly. ✅ Decompose or forecast, then score the residual.
❌ Refitting nightly on data containing yesterday's undetected anomalies. The model learns them as normal and stops flagging them. ✅ Fit on a curated clean window; exclude confirmed incidents from refits.
❌ Fitting the scaler on contaminated data. Anomalies inflate the variance and get pulled toward the center. ✅ Fit scaler and detector together on the clean reference period.
❌ Trusting contamination to set the threshold. It is a guess about the base
rate, not a calibration.
✅ Keep raw scores; calibrate the cutoff against reviewed samples and alert budget.
❌ A fixed threshold that never moves. Drift makes it mute or deafening. ✅ Rolling recalibration with alert-rate monitoring.
❌ No feedback capture. You never learn precision, so you cannot improve it. ✅ Record every alert's disposition; it becomes the labeled set you lacked.
Anomaly Detection Checklist
- Anomaly type identified: point, contextual, or collective
- Seasonality and trend removed before scoring time series
- Robust statistics (median/MAD) used instead of mean/σ
- Heavy-tailed features log-transformed before thresholding
- Features scaled, with the scaler fitted on clean data only
- Multivariate method used when anomalies are combinations, not extremes
- Precision@k and PR-AUC reported; accuracy and ROC-AUC not headlined
-
kset from actual triage capacity - Detection latency measured for streaming detectors
- Threshold calibrated against reviewed samples, not
contamination - Confirmed anomalies excluded from the refit window
- Alert disposition captured as feedback and as future labels
- Score distribution monitored for drift