Clusters of any shape: DBSCAN and hierarchies
Why k-means needs round clusters, DBSCAN's core points and noise, and hierarchical clustering with its linkages.
Create a free account Try part of it here
Why it matters
K-means draws its clusters as round blobs around centers. Real groupings are often other shapes: delivery addresses strung along roads, customer journeys that branch, sensor readings tracing a path, with stray points that belong nowhere. Forcing those into k round blobs gives confident nonsense. This lesson covers the two families that handle shape: density-based clustering, which follows crowded regions wherever they go and labels the rest as noise, and hierarchical clustering, which builds a whole family tree of groupings you can cut at any level.
What you'll be able to do
- Say which cluster shapes k-means assumes, and recognize data where that assumption fails
- Explain DBSCAN's core points, reach and noise, and tune its radius and minimum points
- Build a hierarchy of clusters by merging, and choose a linkage that fits the shapes
- Pick a clustering method from what you know about the data
What it covers
- Why k-means needs round clusters
- DBSCAN: clusters are crowded regions
- Choosing eps and min points
- The points within reach
- Hierarchical clustering: a family tree of groups
- Find the bug: the wrong linkage
- Choosing a method
Show the answer
Cuts straight across both, mixing the crescents
k-means gives each point to its nearest center, so its boundary between two clusters is a straight line. A straight line can't separate interlocking crescents.
Why k-means needs round clusters
k-means minimizes each point's squared distance to its cluster's center. That has two consequences. Clusters come out roughly round and of similar spread, because a long or curved cluster has points far from any single center. And every boundary between two clusters is a straight line, halfway between their centers. On blobs that's exactly right; on crescents, rings or long strands it isn't.
from sklearn.datasets import make_moons from sklearn.cluster import KMeans from sklearn.metrics import adjusted_rand_score X, true_groups = make_moons(n_samples=200, noise=0.06, random_state=0) kmeans = KMeans(n_clusters=2, n_init=10, random_state=0).fit(X) print("agreement with the real crescents:", round(adjusted_rand_score(true_groups, kmeans.labels_), 2))
The adjusted Rand index compares two groupings: 1 means identical, 0 means no better than chance. It ignores which number each cluster gets, so calling the crescents 1 and 0 instead of 0 and 1 still scores 1. k-means scores 0.26 here, barely better than guessing, even though the two groups are obvious to anyone looking.
DBSCAN: clusters are crowded regions
DBSCAN (density-based spatial clustering of applications with noise) defines a cluster as a crowded region, whatever its shape. It has two settings: a radius eps and a count min points.
- A core point has at least min points neighbors within eps of it, counting itself.
- A cluster starts at a core point and grows to every point within eps of it, then to every point within eps of those, as long as each step goes through a core point.
- Points reached this way but not crowded themselves are border points: they join the cluster without extending it. Without them, the thin outer edges of every cluster would turn into noise.
- Points no cluster reaches are noise, labeled -1.
You never choose the number of clusters yourself; it comes out of the data. And noise is a feature: stray points aren't forced into a group, which also makes DBSCAN a simple anomaly detector.
from sklearn.cluster import DBSCAN points = [[0, 0], [0, 1], [1, 0], [10, 10], [10, 11], [11, 10], [5, 5]] print(DBSCAN(eps=1.5, min_samples=3).fit(points).labels_.tolist())
Show the answer
[0, 0, 0, 1, 1, 1, -1]
Each group's points are within 1.5 of each other, so all six are core points and form clusters 0 and 1, numbered in the order found. (5, 5) has no neighbors but itself: noise, -1.
Two crescents and a few stray points. The ring shows the radius around one point. Slide the radius from small to large.
from sklearn.datasets import make_moons from sklearn.cluster import DBSCAN X, _ = make_moons(n_samples=200, noise=0.06, random_state=0) for eps in (0.05, 0.2, 0.5): labels = DBSCAN(eps=eps, min_samples=5).fit(X).labels_ print(len(set(labels) - {-1}), end=" ")
Show the answer
0 2 1
At 0.05 no point has 5 neighbors, so everything is noise and there are 0 clusters. At 0.2 the two crescents come out. At 0.5 the radius bridges them into 1.
Write a sentence or two, then compare it with a good answer in the lesson.
Choosing eps and min points
Start min points around twice the number of features (4 for two-dimensional data) and raise it if the data is noisy. For eps, a standard trick: for every point, find the distance to its k-th nearest neighbor (k = min points), sort those distances and plot them. The curve stays flat across the crowded regions, then bends sharply upward for the isolated points; eps belongs near the bend.
DBSCAN's weakness is uneven density. One eps can't fit a dense city cluster and a sparse rural one at the same time: small enough for the city and the countryside is all noise; large enough for the countryside and neighborhoods in the city merge. Its relative HDBSCAN handles that by trying every radius and keeping the clusters that persist, and is often the better default today. Scale your features first, too: eps is a distance, and a feature measured in thousands will dominate one measured in single digits.
Show the answer
Try HDBSCAN, which considers every radius
Uneven density is DBSCAN's known weakness. HDBSCAN keeps the clusters that persist across radii, so dense and sparse regions both come out.
The points within reach
Write neighbors(points, i, eps): the indexes of every point within distance eps of point i (Euclidean distance, math.dist), including i itself, in index order. This is the building block of DBSCAN.
import math def neighbors(points, i, eps): return [] pts = [(0, 0), (0, 1), (3, 3), (0.5, 0.5)] print(neighbors(pts, 0, 1.0))
Show the answer
How many clusters to find
k is the one thing k-means can't work out for itself. DBSCAN replaces it with a radius and a minimum count, and the number of clusters follows from the data.
Write a sentence or two, then compare it with a good answer in the lesson.
Hierarchical clustering: a family tree of groups
Agglomerative clustering starts with every point as its own cluster and repeatedly merges the two closest clusters until one remains. Recording the merges gives a tree, a dendrogram: cut it high and you get a few big groups, cut it low and many small ones, without rerunning anything.
What "closest clusters" means is the linkage, and it decides the shapes you get:
- single: the distance between the two nearest points, one from each cluster. Follows chains and curves, so it finds the crescents, but one stray point between two groups can join them;
- complete: the distance between the two farthest points. Gives compact, similar-sized clusters;
- average: the average distance over all pairs, a middle ground;
- Ward: the merge that adds the least spread. Behaves much like k-means.
from sklearn.datasets import make_moons from sklearn.cluster import AgglomerativeClustering from sklearn.metrics import adjusted_rand_score X, true_groups = make_moons(n_samples=200, noise=0.06, random_state=0) for linkage in ("single", "ward"): labels = AgglomerativeClustering(n_clusters=2, linkage=linkage).fit(X).labels_ print(linkage, round(adjusted_rand_score(true_groups, labels), 2))
Single linkage follows the crescents perfectly; Ward splits them like k-means did.
To read a dendrogram, look at the heights of the merges: a big jump between one merge and the next means two well-separated groups were forced together, so cut just below the jump. Many merges at similar heights mean there's no natural number of clusters, and any cut is a choice you should be able to justify.
Find the bug: the wrong linkage
cluster_distance(a, b) should be the single-linkage distance between two clusters (lists of points): the distance between their closest pair. It's returning something else, so curved clusters never join up. Fix it.
import math def cluster_distance(a, b): return max(math.dist(p, q) for p in a for q in b) print(cluster_distance([(0, 0), (1, 0)], [(2, 0), (9, 0)]))
Choosing a method
Start from what you know about the data. If you expect a known number of compact, similar-sized groups, k-means is fast and hard to beat. If groups can be any shape, the number is unknown, or some points belong nowhere, use DBSCAN or HDBSCAN. If you want to explore groupings at several levels, such as product families and then sub-families, hierarchical clustering's tree shows them all at once. It needs every pairwise distance, though, so beyond a few tens of thousands of points it gets slow.
Whatever you choose, check the result by looking: plot the clusters (after reducing to two dimensions if needed), read a sample from each, and compare methods with the adjusted Rand index when you have some known groups. Clustering always returns an answer; only checking tells you whether it's a useful one. A useful clustering is one someone can act on: delivery zones a dispatcher can staff, customer groups a marketer can write to.
Show the answer
DBSCAN: any shape, and the farms become noise
Roads are long, curved, crowded strands and farms are isolated points: exactly what density-based clustering handles. k-means and Ward look for round blobs.
Write a sentence or two, then compare it with a good answer in the lesson.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
In DBSCAN, what is a core point?