DBSCAN Returns One Giant Cluster or Labels Everything Noise
Problem statement
DBSCAN has two failure modes and they are opposites:
labels = DBSCAN(eps=0.5, min_samples=5).fit_predict(xy)
print(f"{len(set(labels) - {-1})} clusters, {(labels == -1).sum()} noise")
0 clusters, 700 noise
labels = DBSCAN(eps=800, min_samples=5).fit_predict(xy)
print(f"{len(set(labels) - {-1})} clusters, {(labels == -1).sum()} noise")
1 clusters, 0 noise
Everything is noise, or everything is one cluster. Neither raises. Both return a labels array of the right length that joins back to your GeoDataFrame perfectly.
There is a third, subtler failure: the cluster count changes at every eps you try, with no value that looks right. That one is not a tuning problem at all.
Quick answer
Check the relationship between eps and the actual distances in your data:
import numpy as np
from sklearn.neighbors import NearestNeighbors
distances, _ = NearestNeighbors(n_neighbors=5).fit(xy).kneighbors(xy)
kth = np.sort(distances[:, -1])
print(f"CRS {points.crs} (geographic: {points.crs.is_geographic})")
print(f"extent {np.ptp(xy, axis=0).round(0)}")
print(f"5th-NN dist p50 {kth[len(kth)//2]:.1f} p90 {kth[int(.9*len(kth))]:.1f}")
print(f"your eps {eps}")
CRS EPSG:27700 (geographic: False)
extent [4439. 4448.]
5th-NN dist p50 41.7 p90 361.5
your eps 0.5
eps=0.5 against a median neighbour distance of 42 m: nothing can possibly be dense enough.
| Symptom | Cause | Fix |
|---|---|---|
| everything noise | eps βͺ typical neighbour distance |
raise eps toward the p80βp90 of the k-distance curve |
| one cluster, no noise | eps β« typical neighbour distance |
lower eps; it should be well under a tenth of the extent |
both, depending on eps β never a plateau |
clusters have different densities | use HDBSCAN |
| one huge cluster on geographic data | eps in degrees |
project, or metric="haversine" |
| one cluster at a single coordinate | duplicate points | deduplicate or weight |
| plausible clusters, wrong membership | labels misaligned with rows | drop null geometry before building xy |
Step-by-step solution
1. Compare eps to the k-distance curve
This is the diagnostic that resolves most cases in one line. If eps is below the median 5th-nearest-neighbour distance, almost no point has 5 neighbours within it, so almost everything is noise. If eps is above the 99th percentile, every point reaches every other and it all merges.
for pct in (10, 50, 80, 90, 95, 99):
print(f" p{pct:2}: {kth[int(pct / 100 * len(kth))]:8.1f}")
p10: 16.8
p50: 41.7
p80: 181.4
p90: 361.5
p95: 472.8
p99: 669.6
Target the p80βp90 band. Here that is 181β362 m, and a sweep confirms 150β250 is where the answer is stable.
2. Rule out the units
if points.crs.is_geographic:
print(f"eps={eps} is {eps} DEGREES β about {eps * 111_000:,.0f} m of latitude")
eps=0.5 is 0.5 DEGREES β about 55,500 m of latitude
Degrees are the cause of most "one giant cluster" reports. A city fits inside 0.1 degrees, so any eps a person would naturally type merges the lot.
Two correct approaches:
pts = points.to_crs("EPSG:27700") # simplest, for local data
# or, for global data:
labels = DBSCAN(eps=200 / 6_371_000, min_samples=5,
metric="haversine", algorithm="ball_tree").fit_predict(
np.radians(np.column_stack([pts.geometry.y, pts.geometry.x]))
)
3. Compare eps to the study extent
extent = np.ptp(xy, axis=0).max()
print(f"extent {extent:,.0f} Β· eps {eps} Β· ratio {extent / eps:.1f}")
extent 4,439 Β· eps 800 Β· ratio 5.5
An eps more than about a tenth of the extent will merge everything, because a chain of points spanning the study area is inevitable. A ratio below 10 is a warning; below 5 is a guarantee.
4. Look for a plateau β and act on its absence
for eps in (30, 60, 100, 150, 250, 400, 800):
labels = DBSCAN(eps=eps, min_samples=5).fit_predict(xy)
print(f"eps={eps:4} clusters {len(set(labels) - {-1}):3} noise {(labels == -1).mean():4.0%}")
eps= 30 clusters 14 noise 57%
eps= 60 clusters 6 noise 31%
eps= 100 clusters 5 noise 23%
eps= 150 clusters 4 noise 20%
eps= 250 clusters 4 noise 17%
eps= 400 clusters 5 noise 4%
eps= 800 clusters 1 noise 0%
Four clusters at both 150 and 250 β a plateau, so four is a real answer.
If no two adjacent rows agree, stop tuning. A count that changes at every value means there is no single density threshold that describes your data, which is a property of the data, not a parameter you have not found yet.
5. Switch to HDBSCAN when there is no plateau
from sklearn.cluster import HDBSCAN
for min_size in (5, 10, 20, 40):
labels = HDBSCAN(min_cluster_size=min_size).fit_predict(xy)
print(f"min_cluster_size={min_size:3} clusters {len(set(labels) - {-1})} "
f"noise {(labels == -1).mean():.0%}")
min_cluster_size= 5 clusters 8 noise 7%
min_cluster_size= 10 clusters 4 noise 10%
min_cluster_size= 20 clusters 4 noise 13%
min_cluster_size= 40 clusters 4 noise 13%
HDBSCAN has no eps. It builds a hierarchy across all density thresholds and extracts the clusters that persist longest, so clusters of different densities can coexist. Its one parameter is the smallest group you would call a cluster β a domain question rather than a geometric one.
Code examples
Example 1 β a diagnostic that names the fault
import numpy as np
import pandas as pd
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
def diagnose_dbscan(points, eps, min_samples=5):
problems = []
if points.crs is None:
problems.append("no CRS β eps units are unknown")
elif points.crs.is_geographic:
problems.append(f"geographic CRS: eps={eps} means {eps} degrees "
f"(~{eps * 111_000:,.0f} m)")
valid = points[points.geometry.notna() & ~points.geometry.is_empty]
if len(valid) < len(points):
problems.append(f"{len(points) - len(valid)} null/empty geometries β "
f"drop them BEFORE building the coordinate array")
xy = np.column_stack([valid.geometry.x, valid.geometry.y])
dupes = pd.DataFrame(xy).duplicated().sum()
if dupes > len(xy) * 0.02:
problems.append(f"{dupes} duplicate coordinates ({dupes / len(xy):.0%}) β "
f"these form clusters of one location")
k = min(min_samples, len(xy) - 1)
distances, _ = NearestNeighbors(n_neighbors=k).fit(xy).kneighbors(xy)
kth = np.sort(distances[:, -1])
p50, p90 = kth[len(kth) // 2], kth[int(0.9 * len(kth))]
if eps < p50:
problems.append(f"eps {eps} < median {min_samples}-NN distance {p50:.1f} β "
f"expect nearly everything to be noise")
if eps > kth[-1]:
problems.append(f"eps {eps} exceeds the largest {min_samples}-NN distance "
f"{kth[-1]:.1f} β expect one cluster")
extent = np.ptp(xy, axis=0).max()
if eps > extent / 10:
problems.append(f"eps {eps} is {eps / extent:.0%} of the {extent:,.0f} extent β "
f"clusters will merge")
labels = DBSCAN(eps=eps, min_samples=min_samples).fit_predict(xy)
n_clusters, noise = len(set(labels) - {-1}), (labels == -1).mean()
print(f"eps {eps} Β· {min_samples}-NN p50 {p50:.1f} p90 {p90:.1f} Β· extent {extent:,.0f}")
print(f"result: {n_clusters} clusters, {noise:.0%} noise")
print(f"suggested eps range: {p50:.0f}β{p90:.0f}")
for problem in problems:
print(f" β {problem}")
if not problems:
print(" β eps is in a sensible range for this data")
return problems
diagnose_dbscan(incidents, eps=0.5)
eps 0.5 Β· 5-NN p50 41.7 p90 361.5 Β· extent 4,439
result: 0 clusters, 100% noise
suggested eps range: 42β362
β eps 0.5 < median 5-NN distance 41.7 β expect nearly everything to be noise
The suggested eps range line is what turns a dead end into a next step.
Example 2 β the duplicate-coordinate cluster
coords = pd.DataFrame(np.column_stack([points.geometry.x, points.geometry.y]),
columns=["x", "y"])
repeated = coords.value_counts().head(3)
print(repeated)
x y
384766.2 398772.1 147
385010.9 398201.4 6
384880.6 398702.4 3
One hundred and forty-seven points at one coordinate. That satisfies any min_samples at any eps, so DBSCAN reports a very dense cluster β of a single location.
This is almost always a geocoding artefact: unmatched addresses falling back to a shared centroid, exactly as in geocoding returns wrong coordinates. Handle it before clustering:
# a) if the repeats are an artefact, drop the offending coordinate
artefact = (coords["x"].round(1) == 384766.2) & (coords["y"].round(1) == 398772.1)
clean = points[~artefact.values]
print(f"dropped {artefact.sum()} points at the fallback centroid")
# b) if the repeats are genuine, jitter them slightly so they are not one point
jitter = np.random.default_rng(0).normal(0, 1.0, (len(points), 2)) # 1 m
jittered = points.copy()
jittered["geometry"] = gpd.points_from_xy(
points.geometry.x + jitter[:, 0], points.geometry.y + jitter[:, 1], crs=points.crs
)
Jittering is legitimate when several records genuinely share an address β several incidents at one building. It is not a fix for a geocoding failure, which should be dropped or re-geocoded.
Example 3 β the misalignment that produces plausible nonsense
# WRONG β the filter happens after the coordinates are built
xy = np.column_stack([points.geometry.x, points.geometry.y])
labels = DBSCAN(eps=200, min_samples=5).fit_predict(xy)
clean = points.dropna(subset=["value"]) # drops 23 rows
clean["cluster"] = labels # ValueError, or worse, silence
ValueError: Length of values (700) does not match length of index (677)
That error is the lucky outcome. The unlucky one is filtering before and reordering after, where lengths match and the labels are attached to the wrong rows:
# WRONG and silent
clean = points.dropna(subset=["value"])
xy = np.column_stack([clean.geometry.x, clean.geometry.y])
labels = DBSCAN(eps=200, min_samples=5).fit_predict(xy)
clean = clean.sort_values("value") # reorders!
clean["cluster"] = labels # labels now belong to other rows
# RIGHT β filter once, then never reorder before assigning
clean = points.dropna(subset=["value"]).copy()
clean = clean[clean.geometry.notna() & ~clean.geometry.is_empty]
xy = np.column_stack([clean.geometry.x, clean.geometry.y])
clean["cluster"] = DBSCAN(eps=200, min_samples=5).fit_predict(xy)
assert clean["cluster"].notna().all()
print(f"{len(clean)} rows, {clean['cluster'].nunique() - 1} clusters")
677 rows, 4 clusters
A misaligned result looks entirely reasonable β sensible cluster sizes, plausible geography β and is wrong for every row. The defence is structural: build the coordinate array from the same frame you assign back to, with nothing in between.
Explanation
Why the failures are symmetric
DBSCAN's whole definition is a density threshold: a point is core if at least min_samples points lie within eps. The implied density is min_samples / (Ο Β· epsΒ²).
Push eps down and that required density becomes unattainable β no point qualifies, everything is noise. Push it up and it becomes trivial β every point qualifies, and since core points within eps of each other merge, they all become one cluster.
Between those extremes lies a range where the threshold sits between the density inside clusters and the density outside. The sweep finds it; the k-distance curve tells you roughly where to look.
Why "no plateau" is a different problem
A plateau exists when one density threshold separates every cluster from the background. If your clusters have densities of 2,000 and 190 points per kmΒ², any threshold between them splits the difference: it fragments the sparse cluster or absorbs the background around the dense one.
The count then changes at every eps, because each value trades one error for the other. No amount of searching finds a good value, because none exists.
HDBSCAN solves this by not choosing. It builds a minimum spanning tree over mutual reachability distances, condenses it into a hierarchy of clusters across all thresholds, and selects the clusters that persist over the widest range of them. Different clusters can be extracted at different densities, which is exactly what varying-density data needs.
Why zero noise is a warning sign
DBSCAN is designed to leave points out. A result with no noise at all normally means eps grew large enough that every point reached some cluster β which is the merged state, one step before everything becomes a single group.
Real spatial data almost always has isolated events. A run reporting 0% noise on such data has stopped discriminating. Treat the noise fraction as a health indicator: roughly 5β40% is typical, 0% is suspicious, and above about 70% means the data is mostly unclustered β which may itself be the finding.
Why the default eps=0.5 is a trap
Scikit-learn's default is unitless because scikit-learn does not know your units. For most of its intended use β standardised feature vectors β 0.5 is reasonable.
For map coordinates it is nonsense in both directions: half a metre in a projected CRS, or half a degree (55 km) in a geographic one. Never call DBSCAN() on spatial data without setting eps explicitly, and assert the CRS while you are there.
Edge cases or notes
metric="haversine"requiresalgorithm="ball_tree", radians, and(lat, lon)order. Any of the three wrong gives silently wrong distances.- The noise label is
-1, solabels.max() + 1is the cluster count only when at least one cluster exists. - Cluster ids change between runs with different parameters. Never compare ids across runs.
- Very large
epsgets slow, not just wrong β neighbour queries return most of the dataset per point. - Fewer than
min_samplespoints overall means everything is noise regardless ofeps. - Border points are order-dependent. A point reachable from two clusters joins whichever was processed first. It affects only boundary points.
- HDBSCAN also has
cluster_selection_epsilonif you want to stop it splitting below a certain scale β useful when it finds true but uninterestingly small clusters. min_samples=1makes every point a cluster, including isolated ones, which is never what anyone wants.
Internal links
- How to cluster points by location with DBSCAN in Python β the working implementation
- Spatial clustering explained β the assumptions behind each algorithm
- Choose a projected CRS for your area β the fix for the units problem
- Geocoding returns wrong or missing coordinates β the source of duplicate-coordinate clusters
- Kernel density explained β a continuous view when clustering will not settle
- How to find hotspots with Getis-Ord Gi* in Python β a test rather than a partition
- How to handle missing and null values in spatial datasets β dropping nulls without breaking alignment
- How to remove null and empty geometries in GeoPandas β before building the coordinate array
FAQ
Why is every point labelled noise?
eps is smaller than the typical distance between neighbours. Compare it against the median 5th-nearest-neighbour distance; if it is below that, nothing can be dense enough.
Why is everything in one cluster?
eps is too large β often because the CRS is geographic and eps is being read as degrees, or because it exceeds a tenth of the study extent.
What is a good starting eps?
Between the 80th and 90th percentile of the k-distance curve, then swept to find the plateau where the cluster count is stable.
The cluster count changes at every eps. What now?
There is no single density threshold that fits your data. Use HDBSCAN, which selects clusters across a hierarchy of thresholds instead of one.
Is zero noise good?
No, it is a warning. It usually means eps has grown enough that everything reached a cluster. Expect roughly 5β40% noise on real point data.
Why do my cluster labels look wrong even though the counts are sensible?
Alignment. Filter null geometries before building the coordinate array, and never reorder the frame between fit_predict and assigning the column.
Can min_samples cause these failures?
Less often. Above a sensible floor it mainly changes the noise fraction. Very low values (1β3) do cause spurious clusters from chance groupings.