DBSCAN Returns One Giant Cluster or Labels Everything Noise
On this page
Problem statement
DBSCAN has two failure modes and they are opposites. On the distinct locations of Leeds' shoplifting offences in June 2026 (see The data used on this page):
import numpy as np
from sklearn.cluster import DBSCAN
points = incidents.drop_duplicates(subset="geometry") # one row per location — see Example 2
xy = np.column_stack([points.geometry.x, points.geometry.y])
labels = DBSCAN(eps=0.5, min_samples=5).fit_predict(xy)
print(f"{len(set(labels) - {-1})} clusters, {(labels == -1).sum()} noise")
0 clusters, 226 noise
labels = DBSCAN(eps=8000, 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 spanning the whole city. 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
eps = 0.5
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 [23911. 23924.]
5th-NN dist p50 940.2 p90 2425.2
your eps 0.5
eps=0.5 against a median neighbour distance of 940 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 |
eps against the k-distance curve, the units and the extent
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: 230.8
p50: 940.2
p80: 1410.1
p90: 2425.2
p95: 3052.7
p99: 4431.5
Target the p80–p90 band. Here that is 1,410–2,425 m — but the sweep below finds no stable answer anywhere in or near it, which is the third failure.
2. Rule out the units
unprojected = points.to_crs("EPSG:4326") # the same points, as they often arrive
if unprojected.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 = unprojected.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
eps = 8000
extent = np.ptp(xy, axis=0).max()
print(f"extent {extent:,.0f} · eps {eps} · ratio {extent / eps:.1f}")
extent 23,924 · eps 8000 · ratio 3.0
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 (150, 250, 400, 600, 800, 1200, 2000):
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= 150 clusters 2 noise 92%
eps= 250 clusters 2 noise 84%
eps= 400 clusters 3 noise 77%
eps= 600 clusters 4 noise 69%
eps= 800 clusters 7 noise 48%
eps=1200 clusters 8 noise 17%
eps=2000 clusters 5 noise 9%
No two adjacent rows agree: 2, 2, 3, 4, 7, 8, 5 clusters, with the noise share falling steadily from 92% to 9%.
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 12 noise 27%
min_cluster_size= 10 clusters 2 noise 50%
min_cluster_size= 20 clusters 0 noise 100%
min_cluster_size= 40 clusters 0 noise 100%
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.
It is not a rescue for every dataset. Here it found 12 clusters with groups of five locations, 2 with groups of ten, and nothing at all from twenty up: 226 locations spread over 24 km contain only a couple of dense groups of any size, and the rest is the scatter of shops across a city. The count at five also depended on row order: the same locations in the order the police API happened to return them gave 13 clusters once and 12 the next time, because ties in the distances are broken by position. The setup sorts the crimes by id so the result repeats. When neither method gives a stable answer, the honest result may be "one dense centre and dispersed offences elsewhere" — and how to cluster points by location shows that a smaller min_samples, chosen as a definition rather than tuned, gives a stable plateau on the same data.
eps below it means noise, above it means one cluster, at the knee means clusters.Diagnosing duplicate-coordinate clusters and misalignment
The data used on this page
Every example runs on real data: the 488 Lower Layer Super Output Areas (LSOAs) of Leeds from the ONS, their Census 2021 population from Nomis, and the street-level crimes West Yorkshire Police published for June 2026 on data.police.uk. The examples cluster the shoplifting offences.
import io
import time
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Lower_layer_Super_Output_Areas_December_2021_Boundaries_EW_BGC_V5/FeatureServer/0/query")
features, offset = [], 0
while True: # the service pages at 1,000 features
page = requests.get(ONS, params={"where": "LSOA21NM LIKE 'Leeds %'", "outFields": "LSOA21CD,LSOA21NM",
"outSR": 27700, "f": "geojson", "resultOffset": offset}, timeout=120).json()
features += page["features"]
if not page.get("properties", {}).get("exceededTransferLimit"):
break
offset += len(page["features"])
zones = gpd.GeoDataFrame.from_features(features, crs=27700).set_index("LSOA21CD").sort_index()
population = pd.read_csv("https://www.nomisweb.co.uk/api/v01/dataset/NM_2021_1.data.csv?date=latest"
f"&geography={','.join(zones.index)}&c2021_restype_3=0&measures=20100"
"&select=geography_code,obs_value").set_index("GEOGRAPHY_CODE")["OBS_VALUE"]
zones["population"] = population
west, south, east, north = zones.to_crs(4326).total_bounds
crimes = []
for x0, x1 in zip(np.linspace(west, east, 5)[:-1], np.linspace(west, east, 5)[1:]):
for y0, y1 in zip(np.linspace(south, north, 5)[:-1], np.linspace(south, north, 5)[1:]): # ≤10,000 a request
for attempt in range(5): # the API answers bursts with 429
reply = requests.post("https://data.police.uk/api/crimes-street/all-crime", timeout=120,
data={"poly": f"{y0},{x0}:{y0},{x1}:{y1},{x1}:{y1},{x0}", "date": "2026-06"})
if reply.status_code != 429:
break
time.sleep(2 ** attempt)
reply.raise_for_status()
crimes += [{"id": c["id"], "category": c["category"], "lon": float(c["location"]["longitude"]),
"lat": float(c["location"]["latitude"])} for c in reply.json()]
crimes = pd.DataFrame(crimes).drop_duplicates("id").sort_values("id", ignore_index=True) # the API's order varies; HDBSCAN is order-sensitive
incidents = gpd.GeoDataFrame(crimes, geometry=gpd.points_from_xy(crimes.lon, crimes.lat), crs=4326).to_crs(27700)
incidents = gpd.sjoin(incidents, zones[["geometry"]], predicate="within").rename(columns={"index_right": "lsoa", "LSOA21CD": "lsoa"})
zones["crimes"] = incidents["lsoa"].value_counts().reindex(zones.index, fill_value=0)
zones["rate"] = 1000 * zones["crimes"] / zones["population"]
print(f"{len(zones)} LSOAs, {zones['population'].sum():,} residents, {len(incidents):,} crimes; "
f"rate median {zones['rate'].median():.1f}, max {zones['rate'].max():.1f} per 1,000")
incidents = incidents[incidents["category"] == "shoplifting"].reset_index(drop=True)
print(f"{len(incidents):,} shoplifting offences at {incidents.geometry.nunique():,} distinct locations")
488 LSOAs, 811,957 residents, 9,229 crimes; rate median 8.4, max 174.4 per 1,000
903 shoplifting offences at 226 distinct locations
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 0.0 p90 716.2 · extent 23,924
result: 52 clusters, 33% noise
suggested eps range: 0–716
✗ 677 duplicate coordinates (75%) — these form clusters of one location
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([incidents.geometry.x, incidents.geometry.y]),
columns=["x", "y"])
repeated = coords.value_counts().head(3)
print(repeated)
x y
430553.012726 433646.239462 38
428400.013844 429312.945074 25
430605.024728 433437.958948 25
Name: count, dtype: int64
Thirty-eight offences at one coordinate, and 25 each at the next two. Any of them satisfies min_samples=5 at any eps, so DBSCAN reports a very dense cluster — of a single location. On the raw records, the diagnostic in Example 1 found 52 such clusters.
These repeats are not errors: police.uk moves each crime to an anonymised "on or near" point, so every offence at one shop shares a coordinate. Geocoding produces the same thing as an artefact — unmatched addresses falling back to a shared centroid, as in geocoding returns wrong coordinates. Decide which you have before clustering:
import geopandas as gpd
# a) if the repeats are an artefact, drop the offending coordinate
x0, y0 = repeated.index[0]
artefact = (coords["x"] == x0) & (coords["y"] == y0)
clean = incidents[~artefact.values]
print(f"dropped {artefact.sum()} points at ({x0:.0f}, {y0:.0f})")
# b) if the repeats are genuine, cluster one row per location and keep the count
sites = coords.value_counts().rename("records").reset_index()
print(f"{len(sites)} locations carrying {sites['records'].sum()} records")
dropped 38 points at (430553, 433646)
226 locations carrying 903 records
Clustering one row per location, with the record count kept as a column, is the right treatment for genuine repeats: it makes min_samples count places. Dropping is right only for an artefact such as a fallback centroid, which should also be re-geocoded.
Example 3 — the misalignment that produces plausible nonsense
try:
# WRONG — the deduplication happens after the coordinates are built
xy = np.column_stack([incidents.geometry.x, incidents.geometry.y])
labels = DBSCAN(eps=700, min_samples=3).fit_predict(xy)
clean = incidents.drop_duplicates(subset="geometry").copy()
clean["cluster"] = labels
except ValueError as error:
print(f"ValueError: {error}")
ValueError: Length of values (903) does not match length of index (226)
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 = incidents.drop_duplicates(subset="geometry")
xy = np.column_stack([clean.geometry.x, clean.geometry.y])
labels = DBSCAN(eps=700, min_samples=3).fit_predict(xy)
clean = clean.sort_values("lsoa") # reorders!
clean["cluster"] = labels # labels now belong to other rows
# RIGHT — filter once, then never reorder before assigning
clean = incidents.drop_duplicates(subset="geometry").copy()
clean = clean[clean.geometry.notna() & ~clean.geometry.is_empty]
xy = np.column_stack([clean.geometry.x, clean.geometry.y])
clean["cluster"] = DBSCAN(eps=700, min_samples=3).fit_predict(xy)
assert clean["cluster"].notna().all()
print(f"{len(clean)} rows, {clean['cluster'].nunique() - 1} clusters")
226 rows, 24 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.
Two symmetric failures and a missing plateau
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 one cluster is ten times denser than another, any threshold between their densities 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.
ball_tree, tiny datasets and min_samples=1
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.