Machine Learning · Module 17 of 26 · 10 concepts · about 19 min

Clustering

Finding groups with no labels to check against. What K-means actually optimises, how to choose k without a target, and the cluster shapes that break it.

At work: Finding natural groups with no labels: customer segments for marketing, similar stores, product groupings.

Unsupervised learning, concretely

What it is

There is no y. You hand the algorithm a matrix and it returns a label per row, invented by the algorithm. Nothing in the data says whether those labels are right, so every judgement is either internal (a geometric score computed from the same data) or external (a human looking at the groups and deciding they mean something).

Why it matters

It changes what 'it worked' can possibly mean. Supervised learning has a ground truth that punishes a wrong answer; clustering always returns an answer, and running k-means with k=4 on uniform noise produces four confident, well-separated, entirely meaningless clusters.

In the real world: Grouping customers by behaviour when nobody has labelled them, to find segments marketing can target.

import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

rng = np.random.default_rng(0)
noise = rng.uniform(0, 1, (600, 2))            # no structure whatsoever
labels = KMeans(n_clusters=4, n_init=10, random_state=0).fit_predict(noise)

np.bincount(labels)                             # four tidy, balanced groups
silhouette_score(noise, labels)                 # about 0.36 — not obviously bad

Treating cluster labels as discovered categories. The integers are arbitrary and unstable: re-run with a different random_state and cluster 0 becomes cluster 2. Never join on a cluster id across two fits, and never report 'segment 3' to anyone without a description of what is in it.

K-means: the algorithm

What it is

Pick k initial centroids. Assign every point to its nearest centroid. Move each centroid to the mean of its assigned points. Repeat until assignments stop changing. What it minimises is inertia — the total squared distance from each point to its own centroid.

Why it matters

Both of its famous weaknesses fall straight out of those two lines. Assignment by nearest centroid means the boundaries are straight and equidistant, so clusters come out convex and roughly spherical; and moving a centroid to a *mean* makes it sensitive to outliers in a way a median would not be.

In the real world: Splitting shoppers into groups like “bargain hunters”, “loyal regulars” and “big spenders” from their purchase patterns.

Visual example

KMeans(n_clusters=3)

Three well-separated spherical groups: points coloured by assignment, centroids marked at the mean of each group.

cluster 0cluster 1cluster 2
024601234567feature 1 (scaled)feature 2 (scaled)c0c1c2

One update, worked for cluster 0

Members: (1.0,1.8) (1.6,1.2) (2.0,1.9) (1.3,1.0) (1.9,1.5)

New centroid x = (1.0+1.6+2.0+1.3+1.9) / 5 = 1.56

New centroid y = (1.8+1.2+1.9+1.0+1.5) / 5 = 1.48

Cluster 0's contribution to inertia = sum of squared distances to (1.56, 1.48) = 1.09

Why this matters

Every boundary in this picture is the perpendicular bisector between two centroids — a straight line. That is the whole of K-means' expressive power, and it is why the failure case below looks the way it does.

Common misconception

Wrong idea: K-means finds the clusters that are in the data.

Correct idea: it finds the k centroids that minimise total squared distance. When the data's real groups are not compact blobs of similar size, the minimum of that objective is not the answer you wanted.

  1. 1AssignEach point takes the label of the nearest centroid. Nothing else about the point matters.
  2. 2UpdateEach centroid moves to the coordinate-wise mean of the points that chose it.
  3. 3RepeatUntil no point changes label. Inertia falls every round, so it always terminates.
import numpy as np
from sklearn.cluster import KMeans
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

km = KMeans(n_clusters=3, n_init=10, init="k-means++", random_state=0)
pipe = make_pipeline(StandardScaler(), km).fit(X)

labels = km.labels_                # one integer per row
km.cluster_centers_                 # shape (3, n_features), in scaled space
km.inertia_                         # the objective it minimised
km.n_iter_                          # how many assign/update rounds it took

# Predicting is just 'which centroid is nearest', so it extends to new rows.
pipe.predict(X_new)

Leaving n_init at 1. K-means only converges to a local minimum, and a bad initialisation gives a bad answer silently — two centroids inside one true cluster and one centroid covering two. n_init=10 runs it ten times and keeps the lowest inertia, which is why it is the default in current scikit-learn; if you set it yourself, do not set it to 1.

Practise it: k-Means, and the Cluster That Loses All Its Points

Choosing k: the elbow method

What it is

Fit k-means for a range of k, plot inertia against k, and look for the bend where the curve stops dropping steeply. Inertia falls monotonically with k — at k = n it reaches zero — so the elbow, not the minimum, is the signal.

Why it matters

It is the standard first attempt at the only genuinely hard question in clustering. Read correctly it says 'past here, extra clusters are subdividing groups rather than separating them'; read as an optimisation it says 'use as many clusters as you have rows'.

In the real world: Deciding whether the business really has 4 customer segments or 7.

Visual example

Elbow method

Inertia against k. The drop from 1 to 3 is 78% of the total; every cluster after 3 buys almost nothing.

Splitting real clusters024681001,0002,0003,0004,000k (number of clusters)Inertia (within-cluster sum of squares)k = 3elbow

What each extra cluster bought

k=1 -> 2: 4000 - 1750 = 2250 (56% of the total)

k=2 -> 3: 1750 - 880 = 870 (22%)

k=3 -> 4: 880 - 790 = 90 (2%)

k=4 -> 5: 790 - 720 = 70 (2%)

The bend is where the marginal gain collapses by an order of magnitude

Why this matters

Inertia can only go down, so the curve carries no penalty for complexity and has no minimum to find. The elbow is you supplying the missing penalty by eye — which is why it should be cross-checked against silhouette.

Common misconception

Wrong idea: pick the k with the lowest inertia.

Correct idea: that is always the largest k you tried, and at k = n inertia is exactly 0. The informative quantity is the *change* in inertia, not its level.

import numpy as np
from sklearn.cluster import KMeans

inertias = []
ks = range(1, 11)
for k in ks:
    km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X_scaled)
    inertias.append(km.inertia_)

# The bend, found numerically: the largest second difference.
drops = -np.diff(inertias)                 # how much each extra cluster bought
bend = int(np.argmax(-np.diff(drops)) + 2)
print(f"inertias {[round(v) for v in inertias]}  elbow at k={bend}")

Expecting an elbow to exist. On data with no cluster structure, or with clusters at several scales, the curve is a smooth decay and any k you read off it is arbitrary. A missing elbow is a real result: it means k-means is not the right description of this data.

Choosing k: the silhouette score

What it is

For each point, a is its mean distance to the other members of its own cluster and b its mean distance to the members of the nearest *other* cluster. Its silhouette is (b - a) / max(a, b), in [-1, 1]. The score for a clustering is the mean over all points.

Why it matters

Unlike inertia it does not improve automatically with k, so it has an actual maximum you can pick. And because it is defined per point, it localises the problem: one cluster with a mean silhouette near zero is one cluster that should not exist.

In the real world: A check on whether customer segments are genuinely distinct or just arbitrary slices.

Visual example

silhouette_score

Silhouette has a maximum, at the same k = 3 the elbow suggested — and it falls off on both sides.

23456780.20.30.40.50.60.7k (number of clusters)Mean silhouettek = 3barely structured0.68

One point's silhouette, worked

Point p in cluster 0. Mean distance to the other members: a = 0.62

Mean distance to every member of the nearest other cluster: b = 4.10

s = (b - a) / max(a, b) = (4.10 - 0.62) / 4.10 = 0.849

s near 1: deep inside its cluster. s near 0: on a boundary. s < 0: in the wrong cluster.

Why this matters

The two criteria agreeing is what makes k = 3 defensible. When the elbow is at 3 and silhouette peaks at 6, neither number is trustworthy on its own and the data needs looking at.

Common misconception

Wrong idea: a high silhouette means the clusters are real.

Correct idea: it means they are compact and separated, which is silhouette's definition of good. Uniform noise scores about 0.36 at k = 4, and two correct crescents score worse than one wrong straight cut.

import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, silhouette_samples

for k in range(2, 7):
    labels = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(X_scaled)
    print(k, round(silhouette_score(X_scaled, labels), 3))

# Per-point, to find the cluster that is dragging the average down.
labels = KMeans(n_clusters=4, n_init=10, random_state=0).fit_predict(X_scaled)
sil = silhouette_samples(X_scaled, labels)
for c in range(4):
    print(f"cluster {c}: mean silhouette {sil[labels == c].mean():.3f}")

The cost: silhouette_score computes an n x n distance matrix, so it is quadratic in rows and will exhaust memory somewhere around a hundred thousand of them. Pass sample_size=10000, random_state=0 to score a subsample. It also assumes compact clusters, so it rates DBSCAN's crescent-shaped clusters poorly even when they are correct.

Practise it: Macro and Micro F1, From the Confusion Matrix Alone

Where K-means breaks

What it is

Its assumptions are: clusters are convex and isotropic (roughly spherical), of similar spatial extent, and of similar population. Break any of the three — elongated groups, one tight cluster beside one diffuse one, a 5,000-point cluster beside a 50-point one — and the minimum-inertia solution splits the wrong way.

Why it matters

Because it always returns k confident clusters, the failure is silent. The only way to catch it is to know the assumptions and look at the result, which is exactly what this diagram is for.

In the real world: Delivery addresses spread along a highway don't form round groups, so K-means splits them badly.

Visual example

K-means failure

Two horizontal bands, and K-means cuts them vertically — because the long axis is where the squared distance is, not where the structure is.

k-means cluster 0k-means cluster 1
Cluster 0Cluster 10246810-101234feature 1 (the long axis)feature 2 (where the real groups differ)the cut k-means chose

Why the wrong cut wins on inertia

Vertical cut at x = 5: each cluster spans x in [0,4] and both y bands

inertia = 2 x (spread over 5 x-positions + 2 y-bands) = 2 x (10.0 + 11.25) = 42.5

Horizontal cut at y = 1.5 (the correct one): each cluster spans all 10 x-positions

inertia = 2 x (spread over 10 x-positions) = 2 x 41.5 = 83.0

The wrong answer has half the inertia. K-means is working perfectly.

Why this matters

This is not a bug or a bad initialisation — the vertical cut genuinely has lower inertia. The objective and your intention disagree, and no amount of n_init or random_state will change that.

Common misconception

Wrong idea: k-means got it wrong because it converged badly; more restarts would fix it.

Correct idea: the wrong-looking answer is the global optimum of squared distance. Fix it by changing the geometry (scale the axes, or reduce with PCA) or by using a density-based method instead.

import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs, make_moons

# 1. Elongated: k-means cuts across the long axis, not between the bands.
X, _ = make_blobs(n_samples=600, centers=2, cluster_std=0.6, random_state=0)
X_long = X @ np.array([[4.0, 0.0], [0.0, 0.25]])

# 2. Non-convex: two interleaving crescents. No pair of straight boundaries works.
X_moons, y_moons = make_moons(n_samples=600, noise=0.06, random_state=0)
KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(X_moons)

# 3. Unequal size: the big cluster gets two centroids, the small one shares.
X_uneq, _ = make_blobs(n_samples=[1000, 60], centers=[[0, 0], [6, 0]],
                       cluster_std=[1.6, 0.3], random_state=0)

Reaching for a larger k to fix a shape problem. Splitting one crescent into four blobs lowers inertia and raises silhouette while making the answer less true — you now have four groups that each mean nothing instead of one that meant something. The fix is a different algorithm, not a different k.

Practise it: k-Means, and the Cluster That Loses All Its Points

Hierarchical (agglomerative) clustering

What it is

Start with every point as its own cluster, repeatedly merge the two closest clusters, and stop when one cluster remains. The record of merges — which pair, at what distance — is the dendrogram, and cutting it at a height gives you a clustering at that granularity.

Why it matters

It gives you every k at once from one fit, so you choose the number of clusters by cutting the tree rather than by refitting. It also does not assume spherical clusters: with linkage="single" it chains along elongated shapes, and with linkage="ward" it behaves much like k-means but deterministically.

In the real world: Building a product family tree, from single products up to categories, for how an online store is organised.

Visual example

linkage(method='ward')

The merge record a dendrogram draws: each row is one merge, and the distance column is the height you would cut at.

The big jump

(1.12 -> 7.40: two genuinely separate groups)

fcluster(Z, t=3.0, 'distance')

cut below 7.40 and above 1.12 -> exactly 2 clusters

Z = linkage(X, 'ward') — first rows and last
stepmergeddistancecluster size
1{p3} + {p7}0.212
2{p1} + {p4, p9}0.343
............
8left band + right band1.1212
9band group + far group7.420

One fit, and every k from 1 to n is available by moving the cut

Reading the heights

Merge distances: 0.21, 0.34, ... 1.12, 7.40

A large gap between consecutive heights = a natural number of clusters

Cut anywhere in (1.12, 7.40) and you get 2 clusters

This is the dendrogram's version of the elbow, and it needs no refitting

Why this matters

The dendrogram's value is the gap, not the picture. Consecutive merge heights that jump by a factor of six mean the last merge joined two things that did not belong together.

Common misconception

Wrong idea: you have to pick the number of clusters before running hierarchical clustering.

Correct idea: you pick it after, by choosing a height. The tree is fitted once and every granularity is a cut through it.

from scipy.cluster.hierarchy import dendrogram, fcluster, linkage
from sklearn.cluster import AgglomerativeClustering

# scipy: the full tree, then cut it wherever you like.
Z = linkage(X_scaled, method="ward")        # shape (n-1, 4): merge records
labels_3 = fcluster(Z, t=3, criterion="maxclust")
labels_by_dist = fcluster(Z, t=7.5, criterion="distance")   # cut at a height

# scikit-learn: one cut, chosen up front, and it fits in a Pipeline.
agg = AgglomerativeClustering(n_clusters=3, linkage="ward").fit(X_scaled)
agg.labels_

# Or state the merge distance and let it decide how many clusters that implies.
AgglomerativeClustering(n_clusters=None, distance_threshold=7.5,
                        linkage="ward").fit(X_scaled).n_clusters_

The memory. Ward and average linkage need the pairwise distances, so cost is quadratic in rows and roughly cubic in time — perfectly fine at 10,000 rows, hopeless at a million. Also: AgglomerativeClustering has no predict, because there is no centroid to compare a new point against. It labels the rows you fitted, and nothing else.

Practise it: k-Means, and the Cluster That Loses All Its Points

DBSCAN: density-based clustering

What it is

A point is a *core* point if at least min_samples points lie within eps of it. Core points within eps of each other join into one cluster, non-core points inside a cluster's reach join as border points, and anything left over is labelled -1: noise. The number of clusters is an output, not an input.

Why it matters

It handles the two things k-means cannot. Clusters can be any shape, because membership propagates locally through dense regions rather than being decided by distance to a centre; and points that belong to nothing are said to belong to nothing, instead of being forced into the nearest group.

In the real world: Finding hotspots of ride requests on a city map, and leaving scattered one-off requests out as noise.

Visual example

DBSCAN(eps, min_samples)

The same two bands K-means cut wrongly, clustered correctly by density — plus two points DBSCAN declines to cluster at all.

cluster 0 (lower band)cluster 1 (upper band)noise (label -1)
0246810-101234feature 1 (the long axis)feature 2 (where the real groups differ)-1

Why this succeeds where k-means failed

eps = 1.4, min_samples = 3

Along a band, consecutive points are 1.0 apart -> within eps, so they chain

Across the bands, the gap is 2.9 -> beyond eps, so they never join

The stray at (5, 1.5) is 1.58 from its nearest band point -> beyond eps -> noise

No inertia, no centroids, no k: connectivity does all the work

Why this matters

Compare this to the K-means failure diagram, point for point: identical data, and the answer flips from wrong to right purely because membership propagates locally instead of radiating from a centre.

Common misconception

Wrong idea: the noise points are DBSCAN failing on two rows.

Correct idea: labelling them -1 is the feature. Every other algorithm in this module would have forced those two rows into a band and shifted its centroid; DBSCAN says they belong to neither, which is true.

  1. 1Find the core pointsAny point with >= min_samples neighbours within eps. Both bands are dense enough; the two strays are not.
  2. 2Chain them togetherCore points within eps of each other join. The chain runs the length of each band, so each band becomes one cluster.
  3. 3Label the rest -1Points in no core point's neighbourhood are noise. They are not assigned to the nearest cluster.
import numpy as np
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler

Xs = StandardScaler().fit_transform(X)

# Choosing eps: plot the sorted distance to the min_samples-th neighbour and
# take the value at the knee.
d, _ = NearestNeighbors(n_neighbors=5).fit(Xs).kneighbors(Xs)
kdist = np.sort(d[:, -1])
eps = float(np.quantile(kdist, 0.95))

db = DBSCAN(eps=eps, min_samples=5).fit(Xs)
labels = db.labels_
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
n_noise = int((labels == -1).sum())
print(f"{n_clusters} clusters, {n_noise} noise points")

eps is a distance in the feature space, so it is meaningless without scaling and it does not survive a change of dimension. Worse, one global eps cannot describe clusters at two densities: set it for the sparse cluster and the dense ones merge, set it for the dense ones and the sparse cluster becomes noise. That specific limitation is what HDBSCAN (sklearn.cluster.HDBSCAN) exists to remove.

Practise it: k-Means, and the Cluster That Loses All Its Points

Standardise before you cluster

What it is

K-means, DBSCAN, LOF and hierarchical clustering all compare distances, and a Euclidean distance over raw columns is dominated by whichever column has the largest numeric range. Scaling first — StandardScaler, or MinMaxScaler when you want a bounded range — makes every feature contribute comparably.

Why it matters

Unscaled clustering usually reduces to one-dimensional clustering on the loudest column, and the result looks entirely plausible: tidy groups, a clean elbow, a decent silhouette. It is just a partition of income with the other features along for the ride.

In the real world: Otherwise segments are driven entirely by spend in rupees and ignore how often customers visit.

import numpy as np
from sklearn.cluster import KMeans
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# income in the tens of thousands, age in years, score in 0-1.
raw = KMeans(n_clusters=3, n_init=10, random_state=0).fit(X_raw)
np.round(raw.cluster_centers_, 2)      # the centres differ only in column 0

scaled = make_pipeline(
    StandardScaler(),
    KMeans(n_clusters=3, n_init=10, random_state=0),
).fit(X_raw)
np.round(scaled[-1].cluster_centers_, 2)   # every column separates the centres

Standardising a one-hot encoded column along with the numeric ones. A rare binary flag gets a huge z-score when it is on, so two rows differing in one rare category end up further apart than two rows differing in every numeric feature. For mixed data either scale the numerics only and weight the categoricals deliberately, or use a mixed-type distance such as Gower.

Evaluating a clustering

What it is

Without labels there are internal indices: silhouette (higher is better, wants compact separated clusters), Calinski-Harabasz (higher, between/within variance ratio), Davies-Bouldin (lower). With labels there are external ones: adjusted Rand index and adjusted mutual information, both corrected so that a random labelling scores about 0.

Why it matters

Each index encodes an opinion about what a cluster should look like, and they disagree on exactly the cases that matter. Knowing that silhouette punishes non-convex clusters is what stops you from rejecting a correct DBSCAN result because it scored worse than a wrong k-means one.

In the real world: Checking that segments are stable and actually differ in ways a marketing team can act on.

Visual example

Internal vs external

Two clusterings of the crescent data: the correct one scores worse on silhouette and better on the label-aware index.

silhouette_score(X, labels)

prefers the wrong answer: it wants compact clusters

adjusted_rand_score(y_true, labels)

1.00 — DBSCAN recovered the crescents exactly

make_moons, k = 2, ground truth known
clusteringsilhouetteDavies-Bouldinadjusted Rand
KMeans (straight cut)0.490.780.24
DBSCAN (per crescent)0.381.121

An internal index cannot tell you the clustering is right, only that it is tidy

Why this matters

This table is the reason not to select a clustering algorithm by silhouette alone. Every internal index is a proxy for a shape assumption, and on non-convex data the proxy points the wrong way.

Common misconception

Wrong idea: the clustering with the best silhouette is the best clustering.

Correct idea: silhouette measures compactness and separation. Where the true groups are not compact it reliably prefers the wrong answer, with no hint that it has done so.

from sklearn.metrics import (adjusted_mutual_info_score, adjusted_rand_score,
                             calinski_harabasz_score, davies_bouldin_score,
                             silhouette_score)

for name, labels in (("kmeans", km_labels), ("dbscan", db_labels)):
    print(name,
          round(silhouette_score(X_scaled, labels), 3),
          round(calinski_harabasz_score(X_scaled, labels), 1),
          round(davies_bouldin_score(X_scaled, labels), 3))

# When a ground truth does exist (a benchmark, or a held-out human labelling):
adjusted_rand_score(y_true, km_labels)          # 0 for a random labelling, 1 for exact
adjusted_mutual_info_score(y_true, km_labels)   # insensitive to how many clusters

Scoring a DBSCAN result without excluding the noise points. -1 is not a cluster, but every internal index treats it as one — a scattered, enormous, incoherent one — which tanks the score. Compute the index on labels != -1 and report the noise fraction separately.

Practise it: k-Means, and the Cluster That Loses All Its Points

Clusters as features for a supervised model

What it is

Fit a clustering on the training features, then feed the result into a supervised model — either the cluster id one-hot encoded, or, usually better, the vector of distances to each centroid, which KMeans.transform returns directly.

Why it matters

It gives a linear model a cheap piece of nonlinearity: 'which region of feature space is this row in' is a coordinate a linear model cannot construct for itself. Distances to k centroids are a k-column basis, closely related to an RBF feature map, and they often lift a logistic regression noticeably.

In the real world: Adding a customer's segment as an input improves a churn model.

from sklearn.cluster import KMeans
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

# KMeans as a transformer: transform() returns distances to every centroid.
pipe = Pipeline([
    ("scale", StandardScaler()),
    ("rbf", KMeans(n_init=10, random_state=0)),   # n_clusters columns out
    ("clf", LogisticRegression(max_iter=2000)),
])
search = GridSearchCV(pipe, {"rbf__n_clusters": [5, 20, 50, 100]},
                      cv=5, scoring="roc_auc").fit(X_train, y_train)
search.best_params_

Fitting the clustering on all the data before splitting. The centroids are fitted parameters, so a cluster id assigned with the test rows' help is leakage of exactly the kind module 14 covered — and here it is easy to miss, because clustering feels unsupervised and therefore harmless. It belongs in the pipeline. Note too that the useful n_clusters for features is often far larger than any 'natural' k: you are building a basis, not describing segments.