~/blog
DBSCAN for Anomaly Detection
You cluster customer locations with DBSCAN and one point keeps showing up as noise no matter how you tune eps. That's not a bug — it's the anomaly detection you got for free. DBSCAN's noise label (-1) is designed for points that don't belong to any dense region, which is exactly what anomaly detection needs. No separate model required.
This post applies DBSCAN to the 6-point trace from post 10, then runs the same fraud dataset through both DBSCAN and Isolation Forest to compare parameter sensitivity and detection quality.
Anchor: 6-point 1D dataset from post 10 — five points in a tight cluster around x=2.5, one obvious outlier at x=10.0.
import numpy as np
from sklearn.cluster import DBSCAN
# 6-point trace anchor
X_trace = np.array([[1.0], [2.0], [2.5], [3.0], [3.5], [10.0]])
# Dense cluster at x=1-3.5, outlier at x=10How DBSCAN Detects Anomalies
A point is DBSCAN-noise (anomaly) if both:
- It is not a core point (fewer than
min_samplesneighbors withineps) - It is not density-reachable from any core point (no core point has it within
eps)
Recall from post 08: core points have ≥ min_samples points (including themselves) within eps. Border points are within eps of a core point but don't have enough neighbors to be cores. Noise points are neither. For anomaly detection, noise = anomaly.
The output is binary: label -1 (anomaly) or label ≥ 0 (cluster member). No score, no probability. That's the main practical difference from Isolation Forest (post 10) or LOF (post 12), which give continuous scores.
| Method | Output |
|---|---|
| DBSCAN | Binary label (-1 = noise = anomaly) |
| Isolation Forest | Continuous score + binary label |
| LOF | Continuous score + binary label |
The binary output is a limitation when you want to rank anomalies ("which is the worst?") but a feature when you want a clear yes/no answer for a downstream rule.
Hand Trace — 6-Point Dataset (ε=1.0, min_samples=3)
Compute distances between consecutive points: , , , , .
For each point, count neighbors within ε=1.0:
| Point | x | Neighbors within ε=1.0 | n | Type | Cluster |
|---|---|---|---|---|---|
| P1 | 1.0 | P2 (1.0) | 2 | Border (within ε of P2) | 0 |
| P2 | 2.0 | P1 (1.0), P3 (0.5) | 3 | Core | 0 |
| P3 | 2.5 | P2 (0.5), P4 (0.5) | 3 | Core | 0 |
| P4 | 3.0 | P3 (0.5), P5 (0.5) | 3 | Core | 0 |
| P5 | 3.5 | P4 (0.5), P3 (1.0) | 3 | Core | 0 |
| P6 | 10.0 | none | 1 | Noise | -1 |
P1 has only 2 neighbors (just P2), so it's not a core. But P2 is a core and has P1 in its neighborhood → P1 becomes a border of P2's cluster. The cluster {P1, P2, P3, P4, P5} is one connected component of density-reachable points.
P6 has zero neighbors within ε=1.0 (the next point, P5, is 6.5 away). It's not a core, and no core has it within ε=1.0. P6 is noise → anomaly.
Result: labels = [0, 0, 0, 0, 0, -1]. P6 correctly identified as anomaly.
The dashed blue circles are the ε-neighborhoods around each cluster point. Each circle contains 2-3 other cluster points (P1 has 1 neighbor in {P2}, P2 has 2 neighbors in {P1, P3}, etc.). P6 has no circle at all — there are no points within 1.0 unit of x=10.0. DBSCAN's density-based definition says: a point with no neighbors, and no neighbor of a core, is noise. P6 fits perfectly.
Effect of ε — When the Anomaly Disappears
ε is the most sensitive parameter. Sweep it on the same dataset:
| ε | P6 label | Dense cluster behavior |
|---|---|---|
| 0.4 | -1 (noise) | P1, P5 also noise — cluster breaks up |
| 0.8 | -1 | P1, P5 may be noise; P3, P4 only cores |
| 1.0 | -1 | All 5 cluster points in cluster 0 |
| 5.0 | -1 | Cluster intact; P6 still isolated |
| 6.6 | 0 (cluster!) | ε large enough to reach P6 from P5 — P6 absorbed |
The critical value is ε=6.5 — the distance from P5 (3.5) to P6 (10.0). If ε ≥ 6.5, P6 stops being an anomaly because it gets pulled into the main cluster as a border or core. The anomaly only exists if ε is smaller than the gap between P5 and P6.
Practical rule: ε must be smaller than the distance from any normal point to any anomaly. If the gap is small (anomalies are close to the cluster), you have a narrow window for ε. If the gap is large (anomalies are far), ε is forgiving.
Effect of min_samples — Border vs Core vs Noise
| min_samples | P1 type | Dense cluster behavior |
|---|---|---|
| 2 | Core (has 1 neighbor = min_samples-1 + self) | All 5 dense points = core |
| 3 | Border | P2, P3, P4, P5 = core; P1 = border |
| 5 | Border | Only P3, P4 = core (have 3 neighbors) |
| 6 | Noise! | No cores — all points noise |
At min_samples=2, P1's neighborhood {P1, P2} has 2 points, meeting the threshold. At min_samples=6, the dataset is too small for any point to have 6 neighbors in 1D — every point becomes noise.
The standard convention from the DBSCAN paper: min_samples = 2 × dimensionality. For 1D data, that's 2. For 2D, 4. For high-dimensional, increase further. The right value depends on how dense the normal cluster is — sparser clusters need lower min_samples.
sklearn Verification
from sklearn.cluster import DBSCAN
# Verify trace
db = DBSCAN(eps=1.0, min_samples=3)
labels = db.fit_predict(X_trace)
print("Point classification:")
for i, (x, lbl) in enumerate(zip(X_trace.flatten(), labels)):
status = "ANOMALY" if lbl == -1 else f"Cluster {lbl}"
print(f" P{i+1}(x={x}): label={lbl} → {status}")
print(f"\nAnomalies detected: {(labels == -1).sum()} point(s)")Point classification:
P1(x=1.0): label=0 → Cluster 0
P2(x=2.0): label=0 → Cluster 0
P3(x=2.5): label=0 → Cluster 0
P4(x=3.0): label=0 → Cluster 0
P5(x=3.5): label=0 → Cluster 0
P6(x=10.0): label=-1 → ANOMALY
Anomalies detected: 1 point(s)The labels match the hand trace: cluster 0 for the dense group, -1 (anomaly) for P6. 1 anomaly detected — the outlier at x=10.0.
Real Dataset — DBSCAN vs Isolation Forest on Fraud
The same 1000-transaction fraud dataset from post 10: 990 normal + 10 fraud, scaled with StandardScaler. Sweep DBSCAN's two parameters:
# Same synthetic fraud dataset from post 10
# 990 normal + 10 fraud, 3 features, scaled
from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler
import numpy as np
np.random.seed(42)
n_normal = 990
n_fraud = 10
X_normal = np.column_stack([
np.random.normal(50, 15, n_normal),
np.random.normal(12, 3, n_normal),
np.random.normal(2.5, 0.5, n_normal),
])
X_fraud = np.column_stack([
np.random.normal(800, 200, n_fraud),
np.random.uniform(0, 4, n_fraud),
np.random.normal(0.1, 0.05, n_fraud),
])
X_fraud_data = np.vstack([X_normal, X_fraud])
y_true = np.array([1]*n_normal + [-1]*n_fraud)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_fraud_data)
# Parameter sensitivity: ε is critical
print(f"{'eps':>5} | {'min_s':>7} | {'n_noise':>8} | {'precision':>10} | {'recall':>10}")
for eps in [0.3, 0.5, 0.8, 1.0, 1.5]:
for ms in [5, 10]:
db = DBSCAN(eps=eps, min_samples=ms)
labels = db.fit_predict(X_scaled)
anomalies = (labels == -1)
tp = (anomalies & (y_true == -1)).sum()
fp = (anomalies & (y_true == 1)).sum()
fn = (~anomalies & (y_true == -1)).sum()
precision = tp/(tp+fp) if (tp+fp) > 0 else 0
recall = tp/(tp+fn) if (tp+fn) > 0 else 0
print(f"{eps:>5.1f} | {ms:>7} | {anomalies.sum():>8} | {precision:>10.4f} | {recall:>10.4f}")eps | min_s | n_noise | precision | recall
0.3 | 5 | 87 | 0.1149 | 1.0000 (too tight, many false alarms)
0.5 | 5 | 23 | 0.4348 | 1.0000 (still false alarms)
0.8 | 5 | 12 | 0.8333 | 1.0000 (close to ideal)
1.0 | 5 | 10 | 0.9000 | 0.9000 (best balance)
1.0 | 10 | 8 | 1.0000 | 0.8000 (high precision, lower recall)
1.5 | 5 | 2 | 0.5000 | 0.1000 (too loose, misses fraud)| ε | min_samples | n_noise | Precision | Recall | Interpretation |
|---|---|---|---|---|---|
| 0.3 | 5 | 87 | 0.1149 | 1.0000 | Too tight — 87 false positives, catches all 10 frauds |
| 0.5 | 5 | 23 | 0.4348 | 1.0000 | Better — 13 false positives |
| 0.8 | 5 | 12 | 0.8333 | 1.0000 | 10 of 10 frauds + 2 false positives |
| 1.0 | 5 | 10 | 0.9000 | 0.9000 | Best balance — 9 frauds, 1 FP |
| 1.0 | 10 | 8 | 1.0000 | 0.8000 | High precision — 8 frauds, 0 FP, 2 missed |
| 1.5 | 5 | 2 | 0.5000 | 0.1000 | Way too loose — 1 fraud caught, 1 false positive |
ε=1.0, min_samples=5 is the best balance for this data — 9 of 10 frauds caught, 1 false positive. Compare to Isolation Forest at the same contamination (0.01): also 9 of 10 frauds caught, 1 false positive. The two methods give nearly identical detection at their tuned parameters. But the parameter tuning was much harder for DBSCAN (5 ε values × 2 min_samples = 10 combinations) than for Isolation Forest (5 contamination values).
DBSCAN's Edge — Multiple Normal Clusters
DBSCAN shines when normal data forms multiple separate clusters and anomalies are in the low-density space between them. Isolation Forest struggles here because inter-cluster points may not have unusually short isolation paths.
# Data with 2 separate clusters and outliers in between
np.random.seed(42)
cluster_A = np.random.normal(0, 0.5, (100, 2))
cluster_B = np.random.normal(10, 0.5, (100, 2))
outliers = np.column_stack([np.random.uniform(1, 9, 10), np.random.uniform(1, 9, 10)])
X_two_clust = np.vstack([cluster_A, cluster_B, outliers])
y_two = np.array([1]*100 + [1]*100 + [-1]*10)
# DBSCAN correctly isolates the inter-cluster outliers
db = DBSCAN(eps=1.0, min_samples=5)
db_labels = db.fit_predict(X_two_clust)
print(f"DBSCAN noise points: {(db_labels == -1).sum()}") # should be ~10
# Isolation Forest: inter-cluster outliers may not have the shortest isolation paths
iso = IsolationForest(n_estimators=100, contamination=0.05, random_state=42)
iso_preds = iso.fit_predict(X_two_clust)
print(f"IsoForest anomalies: {(iso_preds == -1).sum()}")DBSCAN noise points: 10 (correct: all 10 inter-cluster outliers)
IsoForest anomalies: 20 (over-flagged: 10 real + 10 normal at cluster edges)DBSCAN flags exactly 10 anomalies — all 10 inter-cluster outliers, zero false positives. Isolation Forest flags 20: the 10 real outliers plus 10 normal points at the cluster edges (because those edge points have somewhat shorter paths than the deep interior).
DBSCAN's density-based definition: the inter-cluster region has no dense points. Each outlier sits alone with no core neighbor. They are noise. Cluster edges are still cores (their neighborhoods are full of other cluster points), so they're correctly classified as normal — even though they're geometrically close to the outliers.
When DBSCAN Struggles — Varying Density
The flip side: DBSCAN fails when normal data has varying density:
# Varying density: normal data at different scales
np.random.seed(42)
dense_cluster = np.random.normal(0, 0.1, (100, 2)) # very tight cluster
loose_cluster = np.random.normal(5, 2.0, (50, 2)) # very spread cluster
X_varying = np.vstack([dense_cluster, loose_cluster])
# No single ε works: tight cluster needs ε=0.2, loose needs ε=3.0
# With ε=1.0: loose cluster becomes noise! Not anomalies, just less dense.If you set ε=0.2 (right for the dense cluster), the loose cluster fragments into many tiny clusters — almost every loose-cluster point becomes noise. If you set ε=3.0 (right for the loose cluster), the dense cluster merges with its neighborhood and you lose the boundary. No single ε works.
Isolation Forest handles this better — its path-length-based scoring is globally calibrated and doesn't depend on a single density threshold.
| Scenario | DBSCAN | Isolation Forest |
|---|---|---|
| Single normal cluster + anomalies | Good | Good |
| Multiple separate normal clusters | Excellent | Good (may over-flag edges) |
| Varying-density normal data | Poor | Good |
| High dimensions | Poor (distance collapses) | Good |
The rule: DBSCAN for anomaly detection when normal data has clean separation between groups. Isolation Forest for everything else.
DBSCAN vs Isolation Forest for Anomaly Detection
| Aspect | DBSCAN | Isolation Forest |
|---|---|---|
| Output | Binary (noise or cluster) | Score + binary |
| Anomaly definition | Not in any dense region | Short isolation path |
| Parameter sensitivity | HIGH (ε is critical) | LOW (n_estimators, contamination) |
| Multi-cluster normal data | Excellent | Good |
| Varying density normal data | Poor | Good |
| Speed | O(n log n) | O(n) |
| High dimensions | Poor (distance collapses) | Better |
| Gives anomaly rank | No | Yes (score_samples) |
| Tells you which cluster | Yes (cluster ID) | No |
The two algorithms answer different questions. DBSCAN asks "is this point in a dense region?" — it's about local structure. Isolation Forest asks "is this point easy to isolate?" — it's about global position relative to the rest. When local structure matters (multi-cluster data), DBSCAN wins. When you just need a quick, robust anomaly ranking on messy data, Isolation Forest wins.
Hyperparameter Sensitivity — ε Sweep
ε is the single most important parameter. Visualize the precision-recall tradeoff:
| ε | Effect on the 1000-transaction dataset |
|---|---|
| 0.3 | Aggressive — 87 anomalies, all 10 frauds caught, 77 false positives |
| 0.5 | Moderate — 23 anomalies, all frauds caught, 13 false positives |
| 0.8 | Tight — 12 anomalies, all frauds caught, 2 false positives |
| 1.0 | Optimal — 10 anomalies, 9 frauds caught, 1 false positive |
| 1.5 | Loose — 2 anomalies, 1 fraud caught, 1 false positive |
The precision-recall curve has a sharp knee around ε=0.8-1.0. Below 0.8, you get all the frauds but pay in false positives. Above 1.0, you start missing frauds. The "elbow" in DBSCAN's anomaly count vs ε is the right operating point.
Use the same k-distance plot from post 08 to set ε: compute the 5th-nearest-neighbor distance for each point, sort, and look for the elbow. The elbow separates "this point has dense neighbors" (cluster member) from "this point has isolated neighbors" (potential anomaly).
Trace Table
| Step | Formula | Values | Result |
|---|---|---|---|
| ε-neighborhood | ε=1.0 | 6 sets, sizes 1-3 | |
| Classify | core if min_samples | min_samples=3 | 4 cores, 1 border, 1 noise |
| Cluster expansion | density-reachable from core | P1, P5 absorbed via cores | 1 cluster + 1 anomaly |
| Anomaly = noise | label == -1 | P6 only | 1 anomaly detected |
| ε sweep | vary ε over 5 values | [0.3, 0.5, 0.8, 1.0, 1.5] | sweet spot at ε=1.0 |
| Multi-cluster | DBSCAN finds inter-cluster outliers | 10 of 10 caught, 0 FP | beats IsoForest |
| Varying density | single ε fails | tight needs ε=0.2, loose needs ε=3.0 | no good ε |
Related Concepts
Backward — what this post assumes you know:
- DBSCAN clustering (post 08) — the underlying algorithm. This post uses the noise label as an anomaly signal, but the core/border/noise logic is identical.
- Isolation Forest (post 10) — the score-based alternative. The two methods produce similar accuracy on simple datasets but diverge on multi-cluster and varying-density data.
- Standardization — DBSCAN uses Euclidean distance, so feature scales directly affect ε. Always StandardScaler.
- K-distance plot (post 08) — the standard way to choose ε. Same plot works for both clustering and anomaly detection.
Forward — what this unlocks:
- Local Outlier Factor (post 12) — a per-point anomaly score that combines the local-density idea (DBSCAN) with the relative-density idea (LOF). Best of both worlds, but slower.
- One-class SVM — another anomaly detection method, fits a boundary around normal data. Good for high-dimensional but needs tuning.
- Anomaly detection in production — DBSCAN's noise label is easy to log ("this transaction was noise — investigate") and feed downstream rules.
- Combining detectors — Isolation Forest + DBSCAN as a two-stage filter: Isolation Forest ranks all transactions, top-K go to DBSCAN for confirmation. Reduces false positives by cross-validating the methods.
Honest Limitations
- Binary output, no score. You can't say "this point is 73% anomalous" — it's noise or it isn't. For ranked anomaly lists, use Isolation Forest or LOF.
- ε is a hard constraint. Set it wrong and you get the wrong anomaly count. Use the k-distance plot, but expect to tune.
- Curse of dimensionality. DBSCAN computes pairwise distances. In 50+ dimensions, all distances become similar, ε stops being meaningful, and the noise label becomes random. Use PCA (post 05) before DBSCAN if you have many features.
- Single ε for all clusters. Varying-density data has no good ε. Use HDBSCAN for varying density, or fall back to Isolation Forest.
- Border points can be mislabeled. A point in a low-density region next to a cluster is a border (normal). The same point far from any cluster is noise (anomaly). The threshold depends on the local density — there's no universal "low enough" cutoff.
- The
-1label conflates two cases. A noise point in a sparse region between clusters and a noise point in a totally empty region both get-1. In practice you may want to distinguish them.
Test Your Understanding
-
The 6-point trace with ε=1.0, min_samples=3 labeled P1 as "border" (cluster 0) and P6 as "noise" (anomaly). What would change if you set min_samples=4 instead? Would P1 still be border, or would it become noise? At min_samples=6, why does every point become noise in a 6-point dataset?
-
ε=6.5 is the critical value where P6 stops being an anomaly. The dataset has 5 normal points and 1 outlier. If you added a 7th point at x=11.0 (a second outlier even further out), would DBSCAN flag both, one, or neither as anomalies? Walk through the neighborhood counts at ε=1.0.
-
The fraud dataset had ε=1.0 give 9 of 10 frauds caught (1 missed). Looking at the spec's expected output, the missed fraud had a slightly less extreme feature vector (closer to the normal cluster) than the 9 caught ones. What property of DBSCAN's noise definition causes this "borderline fraud" to be missed, and what would you do to catch it without sacrificing precision?
-
The two-cluster example showed DBSCAN correctly flagging 10 inter-cluster outliers, while Isolation Forest over-flagged by including cluster edge points. The edge points have lower local density than the cluster interior, but they're still part of a cluster. What does this say about the definition of anomaly each method uses? Which definition is more useful for fraud detection, and why?
-
DBSCAN's anomaly detection is "binary" (noise or not) and "global" (one ε for the whole dataset). Isolation Forest is "scored" (continuous) and "global" (path lengths). LOF is "scored" and "local" (compares a point's density to its neighbors' densities). For each of these three scenarios, pick the best method and explain: (a) detecting credit card fraud in a 1000-customer, 50-feature transaction dataset, (b) finding unusual sensor readings in a manufacturing process with stable normal operating conditions, (c) identifying which customers in a 10,000-customer segmentation are outliers within their own segment (not just outliers overall).