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
Six DBSCAN symptoms mapped to their cause and fix. four of six are one parameter in the wrong rangeSymptomFixeverything labelled noiseeps below median k-NN distance —raise itone cluster, zero noiseeps too large — under a tenth ofthe extentcount changes at every epsdensities differ — use HDBSCANone huge cluster on lat/lonproject, or metric="haversine" inradiansa cluster at one coordinateduplicate points — dedupe orweightsensible counts, wrong membersdrop nulls BEFORE building thearrayThe default eps=0.5 is nonsense in both metres and degrees spatialworkflow.io
Four of the six are one parameter in the wrong units or the wrong range.

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.

Percentiles of the 5th-nearest-neighbour distance between Leeds shoplifting locations, showing where eps should sit. the k-distance curve is the mapp10231 mp50 — median940 m · eps below = mostly noisep801,410 m · targetp902,425 m · targetp994,432 m · above = one cluster04500 m226 distinct locations; eps = 0.5 m sits far below the whole curve spatialworkflow.io
The k-distance curve is the map. 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.

A DBSCAN sweep on Leeds shoplifting locations with no stable cluster count. the count climbs, then falls again at 2,000 mclustersnoisestable?eps 150!292%✕noeps 400!377%✕noeps 800!748%✕noeps 1200!817%✕noeps 2000!59%✕nomin_samples = 5 locations; no two neighbouring eps values agree spatialworkflow.io
No value works, because the two clusters need different thresholds. This is HDBSCAN's use case, not a tuning failure.

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" requires algorithm="ball_tree", radians, and (lat, lon) order. Any of the three wrong gives silently wrong distances.
  • The noise label is -1, so labels.max() + 1 is 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 eps gets slow, not just wrong — neighbour queries return most of the dataset per point.
  • Fewer than min_samples points overall means everything is noise regardless of eps.
  • 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_epsilon if you want to stop it splitting below a certain scale — useful when it finds true but uninterestingly small clusters.
  • min_samples=1 makes every point a cluster, including isolated ones, which is never what anyone wants.

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.