Skip to content

DBSCAN - Density-Based Clustering

Why DBSCAN is useful

K-Means assumes spherical clusters and forces every point into a cluster, even the ones that don’t really belong anywhere. DBSCAN (“Density-Based Spatial Clustering of Applications with Noise”) takes the opposite approach: it defines clusters as continuous regions of high density, so it can:

  • find clusters of any shape (crescents, spirals, whatever the data actually looks like)
  • mark points that don’t belong to any dense region as noise instead of forcing them into the nearest cluster

Key parameters

  • epseps: the radius of a point’s neighborhood
  • min_samplesmin_samples: how many neighbors (including itself) a point needs within epseps to count as a dense region

Intuition

For every instance, DBSCAN counts how many other instances fall within epseps of it — that’s its ε-neighborhood. A point is then:

  • a core point if its ε-neighborhood contains at least min_samplesmin_samples instances
  • a border point if it’s in the neighborhood of a core point, but isn’t dense enough to be a core point itself
  • noise if it’s neither a core point nor reachable from one

All instances in a core point’s neighborhood join the same cluster, including other core points — a long chain of neighboring core points forms a single cluster, however oddly it’s shaped.

diagram DBSCAN point classification mermaid
Every point is classified as core, border, or noise, based on how many neighbors fall inside its eps radius.

Scikit-learn example

dbscan_moons.py
import numpy as np
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons
 
X, _ = make_moons(n_samples=300, noise=0.05, random_state=42)
 
tight = DBSCAN(eps=0.05, min_samples=5).fit(X)
loose = DBSCAN(eps=0.2, min_samples=5).fit(X)
 
for name, model in [("eps=0.05", tight), ("eps=0.2 ", loose)]:
    n_clusters = len(set(model.labels_)) - (1 if -1 in model.labels_ else 0)
    n_noise = int((model.labels_ == -1).sum())
    print(f"{name} -> clusters: {n_clusters}, noise points: {n_noise}")
dbscan_moons.py
import numpy as np
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons
 
X, _ = make_moons(n_samples=300, noise=0.05, random_state=42)
 
tight = DBSCAN(eps=0.05, min_samples=5).fit(X)
loose = DBSCAN(eps=0.2, min_samples=5).fit(X)
 
for name, model in [("eps=0.05", tight), ("eps=0.2 ", loose)]:
    n_clusters = len(set(model.labels_)) - (1 if -1 in model.labels_ else 0)
    n_noise = int((model.labels_ == -1).sum())
    print(f"{name} -> clusters: {n_clusters}, noise points: {n_noise}")
Output
eps=0.05 -> clusters: 7, noise points: 260
eps=0.2  -> clusters: 2, noise points: 0
Output
eps=0.05 -> clusters: 7, noise points: 260
eps=0.2  -> clusters: 2, noise points: 0

Widening epseps from 0.050.05 to 0.20.2 merges the fragments into exactly the two moon shapes the data was generated from — matching what Géron found in the book.

Predicting on new points

DBSCANDBSCAN only exposes fit_predict()fit_predict(). To classify a brand-new point, train a KNeighborsClassifierKNeighborsClassifier on the core samples — this is the same trick the book uses:

predict_new_points.py
import numpy as np
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons
from sklearn.neighbors import KNeighborsClassifier
 
X, _ = make_moons(n_samples=300, noise=0.05, random_state=42)
dbscan = DBSCAN(eps=0.2, min_samples=5).fit(X)
 
knn = KNeighborsClassifier(n_neighbors=20)
knn.fit(dbscan.components_, dbscan.labels_[dbscan.core_sample_indices_])
 
X_new = np.array([[-0.5, 0.4], [0, 0.6], [1.2, -0.3], [2.5, 1.0]])
print("Predicted clusters for new points:", knn.predict(X_new))
predict_new_points.py
import numpy as np
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons
from sklearn.neighbors import KNeighborsClassifier
 
X, _ = make_moons(n_samples=300, noise=0.05, random_state=42)
dbscan = DBSCAN(eps=0.2, min_samples=5).fit(X)
 
knn = KNeighborsClassifier(n_neighbors=20)
knn.fit(dbscan.components_, dbscan.labels_[dbscan.core_sample_indices_])
 
X_new = np.array([[-0.5, 0.4], [0, 0.6], [1.2, -0.3], [2.5, 1.0]])
print("Predicted clusters for new points:", knn.predict(X_new))
Output
Predicted clusters for new points: [1 0 0 0]
Output
Predicted clusters for new points: [1 0 0 0]

Pros and cons

Pros:

  • finds clusters of any shape
  • detects outliers naturally, instead of forcing every point into a cluster
  • just two hyperparameters (epseps, min_samplesmin_samples)
  • roughly O(m log m)O(m log m) — close to linear in the number of instances

Cons:

  • choosing epseps can be tricky and the result is sensitive to it
  • struggles when clusters have very different densities — a single epseps can’t be “tight” for one cluster and “loose” for another
  • scikit-learn’s implementation can need up to O(m²)O(m²) memory if epseps is large

Mini-checkpoint

If you increase epseps:

  • do you get more or fewer clusters?

(Usually fewer — clusters merge, and noise points get absorbed into them.)

Visualize it

Two crescent-shaped clusters plus scattered noise. The neighborhood radius (epseps) pulses larger and smaller: watch points flip between core (colored, dense enough) and noise (grey) as the radius changes — this is exactly why epseps is the parameter to tune first:

sketch DBSCAN: density decides core vs. noise p5.js
As the eps radius grows and shrinks, points flip between dense 'core' clusters (colored) and sparse 'noise' (grey).

🧪 Try It Yourself

Exercise 1 – Cluster Two Moons

Exercise 2 – A Tiny eps Creates Noise

Exercise 3 – Bigger eps Merges Clusters

Next

Continue to Anomaly Detection with Isolation Forests — another way to flag rare points, this time by how easy they are to isolate rather than how dense their neighborhood is.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did