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
Six DBSCAN symptoms mapped to their cause and fix, from an eps unit mismatch to misaligned labels.
Four of the six are one parameter in the wrong units or the wrong range.

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.

A sorted k-distance curve with three eps values marked: far below the curve giving all noise, at the knee giving clusters, far above giving one cluster.
The k-distance curve is the map. `eps` below it means noise, above it means one cluster, at the knee means clusters.

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.

Two clusters of very different density where every eps threshold either fragments the sparse one or merges the dense one into the background.
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.

Edge cases or notes

  • 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.