Machine Learning Essentials

K-Means Clustering


A marketing team has 50,000 customers and budget for four campaigns. They need four groups, each coherent enough that one message works for everyone in it.

Try doing this by hand. The obvious move is to split on spend: under £100, £100–£500, £500–£2,000, over £2,000. Where did those boundaries come from? Somebody's intuition. Change them slightly and thousands of customers move groups.

Worse, spend alone is the wrong basis. Two customers both spending £600 a year could be completely different people — one places a single large order at Christmas, the other buys something small every fortnight. Those two need different messages. So add purchase frequency. Now you need boundaries on two dimensions and the number of boxes multiplies. Add recency and average basket size and you have a four-dimensional grid with dozens of cells, most of them empty and a few containing 40,000 customers.

The manual approach fails because you are imposing boundaries rather than discovering them. The data may well contain four natural clumps — but they will not be aligned to round numbers on individual axes, and you cannot see them because you cannot look at four dimensions at once.

What you want is a procedure that finds where the clumps actually are.

The two steps k-means alternatesPlace kcentroidsAssign eachpoint to nearestMove centroidto cluster meanRepeat untilnothing movesEach step can only lower within-cluster variance, so the loop always terminates.
It converges every time, but to a local optimum — which is why k-means++ initialisation is not optional.

The idea in one sentence

Pick kk points to act as cluster centres. Assign every customer to the nearest one. Move each centre to the middle of the customers assigned to it. Repeat until nothing moves.

That is the whole algorithm. It is short enough to be suspicious, so let us run it by hand.

Two iterations, worked

Six customers, two features (annual spend in hundreds of pounds, orders per year), k=2k = 2.

CustomerSpendOrders
A12
B21
C23
D88
E97
F109

Start with two deliberately poor centres: c1=(1,2)c_1 = (1, 2) and c2=(2,1)c_2 = (2, 1).

Iteration 1, assignment. Compute the distance from each point to each centre and take the nearer.

PointDistance to c1c_1(1,2)Distance to c2c_2(2,1)Assigned
A (1,2)0.001.411
B (2,1)1.410.002
C (2,3)1.412.001
D (8,8)9.229.221 (tie)
E (9,7)9.439.222
F (10,9)11.4011.312

Iteration 1, update. Move each centre to the mean of its members.

  • Cluster 1 = {A, C, D}: mean = ((1+2+8)/3, (2+3+8)/3) = (3.67, 4.33)
  • Cluster 2 = {B, E, F}: mean = ((2+9+10)/3, (1+7+9)/3) = (7.00, 5.67)

Iteration 2, assignment. With the centres in better places, the assignments change.

PointTo (3.67, 4.33)To (7.00, 5.67)Assigned
A (1,2)3.547.031
B (2,1)3.736.841
C (2,3)2.135.671
D (8,8)5.682.542
E (9,7)5.962.402
F (10,9)7.874.482

Iteration 2, update. Cluster 1 = {A,B,C}, centre (1.67, 2.00). Cluster 2 = {D,E,F}, centre (9.00, 8.00).

Run it again and no point changes cluster. The algorithm has converged, and it has found exactly the split any human would draw — despite starting from two centres sitting almost on top of each other in the corner. Two passes.

What it is actually minimising

The algorithm optimises a specific quantity, inertia (also called within-cluster sum of squares):

J=∑i=1k∑x∈Ci∥x−μi∥2J = \sum_{i=1}^{k}\sum_{\mathbf{x} \in C_i} \|\mathbf{x} - \boldsymbol{\mu}_i\|^2

In words: for every point, the squared distance to its own cluster's centre, summed over everything. Lower means tighter clusters.

Both steps provably reduce JJ, which is why convergence is guaranteed:

  • Assignment step: moving a point to a nearer centre reduces its own squared distance and leaves every other term untouched. JJ cannot increase.
  • Update step: for a fixed set of members, the point minimising the sum of squared distances to them is their mean. This is a calculus fact, not a heuristic. So relocating the centre to the mean cannot increase JJ either.

Since JJ never increases and there are finitely many possible assignments, the algorithm must stop.

Convergence is guaranteed. Convergence to the best answer is not. K-means finds a local minimum, and which one it finds depends entirely on where the centres started.

The initialisation problem

Bad starting positions produce bad final clusters that are nonetheless stable — the algorithm sits happily in a local minimum. Two defences, and scikit-learn applies both by default:

Multiple restarts. Run the whole thing several times from different random starts and keep whichever run achieved the lowest inertia. That is the n_init parameter.

k-means++ initialisation. Rather than choosing all centres at random, choose the first at random and then choose each subsequent one with probability proportional to the squared distance from the nearest existing centre. Points far from every current centre are very likely to be picked, so the initial centres are spread out rather than clumped. This typically converges faster and to better solutions, and it is the default (init="k-means++").

The step that decides whether any of this works

K-means minimises squared Euclidean distance. Distance is computed in whatever units your features happen to be in. So the feature with the largest numeric range dominates the entire clustering.

Two customers:

FeatureCustomer PCustomer QDifferenceSquared contribution
annual_spend (£)4,2004,900700490,000
orders_per_year248462,116
years_a_customer19864

Spend contributes 99.6% of the squared distance. A customer placing 48 orders a year is treated as nearly identical to one placing 2, because their spend is similar. Every cluster you get back will be a spend band, and you will have spent a week rediscovering the arbitrary boundaries you were trying to avoid.

After standardising each feature to mean 0 and standard deviation 1, the differences become comparable — perhaps 0.3 standard deviations of spend against 2.1 of order frequency — and the clustering reflects behaviour rather than units.

Python
from sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import StandardScalerfrom sklearn.cluster import KMeansmodel = make_pipeline(    StandardScaler(),    KMeans(n_clusters=4, n_init=10, random_state=42),).fit(X)

One nuance: standardising gives every feature equal influence, which is a choice, not a neutral act. If you have twelve features about spending and one about location, standardising means the clustering is twelve-thirteenths about spending. Weighting features deliberately — or dropping redundant ones — is part of the job.

Choosing k

The algorithm requires kk up front and will cheerfully produce whatever number you ask for, including nonsense. Three approaches, used together.

The elbow method

Plot inertia against kk. Inertia always falls as kk rises — at k=nk = n every point is its own cluster and inertia is zero — so you are looking for the point where the improvement stops being worth it.

kInertiaReduction from previous
148,200—
226,80021,400
317,1009,700
411,9005,200
510,7001,200
69,900800
79,300600

Reductions of 21,400 → 9,700 → 5,200 then suddenly 1,200. The bend is at k=4k = 4. Be honest about this method's weakness: on real data the curve is frequently smooth with no visible elbow at all, and different people reading the same plot pick different values.

Silhouette score

More principled, because it measures both tightness and separation. For each point ii:

s(i)=b(i)−a(i)max⁡(a(i),b(i))s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))}

where a(i)a(i) is the mean distance to other points in its own cluster and b(i)b(i) is the mean distance to points in the nearest other cluster.

A worked value: a point sits 0.8 units on average from its own cluster-mates and 3.2 from the nearest other cluster. s=(3.2−0.8)/3.2=0.75s = (3.2 - 0.8)/3.2 = 0.75 — comfortably inside its own group. A point with a=2.9a = 2.9 and b=3.1b = 3.1 scores 0.06, meaning it sits almost exactly on the boundary. A negative score means the point is closer to another cluster than its own, which is a genuine misassignment.

Mean silhouetteInterpretation
0.7 – 1.0Strong, well-separated structure
0.5 – 0.7Reasonable
0.25 – 0.5Weak; clusters overlap substantially
< 0.25No meaningful cluster structure found

The criterion that outranks both

The business constraint. If marketing can run four campaigns, k=4k = 4 regardless of what the silhouette plot prefers, and the metrics are then used to check whether four is a defensible description rather than to choose the number.

Python
import numpy as npfrom sklearn.metrics import silhouette_score, davies_bouldin_scoreX_scaled = StandardScaler().fit_transform(X)for k in range(2, 9):    km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(X_scaled)    print(f"k={k}  inertia={km.inertia_:10,.0f}  "          f"silhouette={silhouette_score(X_scaled, km.labels_):.3f}  "          f"davies-bouldin={davies_bouldin_score(X_scaled, km.labels_):.3f}")

Davies-Bouldin measures average similarity between each cluster and its most similar neighbour; unlike the other two, lower is better. When the three measures disagree, that itself is information — usually that the data has no strong cluster structure and any kk is a description you are imposing rather than discovering.

What k-means assumes, and how it fails

Because it assigns every point to the nearest mean, k-means implicitly assumes clusters are round, similar in size, and similar in density. Real data often disobeys.

Data shapeWhat k-means doesWhy
Two elongated diagonal bandsCuts across themBoundaries are always perpendicular bisectors between centres
Concentric ringsSlices the rings like a pieThe inner ring's mean is at the centre of the outer one
One cluster of 5,000 and one of 50Splits the large one, absorbs the smallSplitting a big cluster reduces inertia more
Clusters of very different densityDraws the boundary through the sparse oneSquared distance dominates over membership
Genuinely no clustersReturns k clusters anywayIt has no way to say "there is no structure here"

That last row is the one that costs money. K-means never refuses. Ask for six segments in uniformly distributed data and you will get six segments, complete with cluster profiles and a slide deck. The silhouette score is your main protection: a mean silhouette of 0.11 means the partition is essentially arbitrary, whatever the cluster means look like.

Text
   Works well                  Fails badly   ---------                   -----------    o o     x x                    o o o o   o o o   x x x               o x x x x x o    o o     x x                o x   o o  x o                                o x x x x x o   round, separated,               o o o o   similar sizes            k-means cuts the rings in half

A segmentation, end to end

Python
import pandas as pdimport numpy as npfrom sklearn.preprocessing import StandardScalerfrom sklearn.cluster import KMeansfrom sklearn.metrics import silhouette_scoredf = pd.read_csv("customers.csv")features = ["annual_spend", "orders_per_year", "avg_basket",            "days_since_last_order", "tenure_years"]# skewed monetary features benefit from a log transform before scalingX = df[features].copy()X["annual_spend"] = np.log1p(X["annual_spend"])X["avg_basket"] = np.log1p(X["avg_basket"])scaler = StandardScaler()X_scaled = scaler.fit_transform(X)km = KMeans(n_clusters=4, n_init=10, random_state=42).fit(X_scaled)df["segment"] = km.labels_print("silhouette:", round(silhouette_score(X_scaled, km.labels_), 3))# profile each segment in ORIGINAL units, which is what stakeholders readprofile = df.groupby("segment")[features].mean().round(1)profile["n"] = df["segment"].value_counts().sort_index()profile["share"] = (profile["n"] / len(df) * 100).round(1)print(profile)

Two details in that code carry real weight. The log transform tames monetary features, which are almost always right-skewed — without it, a handful of enormous spenders sit so far from everyone else that they pull a centre out to meet them and you get a cluster of eleven people. And profiling in original units matters because "mean spend £2,340" is actionable while "mean spend 1.8 standard deviations" is not.

The output might look like this:

SegmentSpendOrders/yrBasketDays sinceTenureShareName a human gives it
0£3,89031£125125.49%Loyal high value
1£4203£1402103.138%Lapsed occasional
2£1,15019£61211.226%New frequent, low basket
3£9802£490954.827%Rare big-ticket

Note that the algorithm produced the numbers and a person produced the names. K-means found that four coherent bundles exist; it has no concept of "loyal" or "lapsed". That interpretation step is where the value is created and where the errors get made.

When your data is large

Standard k-means recomputes distances from every point to every centre on every pass — O(nkp)O(n k p) per iteration. Beyond a few hundred thousand rows, use MiniBatchKMeans, which updates centres from random subsamples. It is typically 10–50 times faster with inertia a few percent worse, which is almost always the right trade.

What this means when you build something

Treat the clustering itself as the cheap part. Getting KMeans to run takes three lines; the work is in the decisions around it.

Scale your features, and log-transform the skewed monetary ones before scaling, or the algorithm will cluster on whichever column has the largest raw numbers. Choose the feature set deliberately, remembering that adding five correlated spending columns quietly makes the segmentation five times more about spending. Run with several values of kk and check the silhouette before believing any of them — if it sits below 0.25, the honest report is "this data does not contain distinct segments", which is a genuine finding and better than four fabricated ones.

Then profile the clusters in original units and take them to someone who knows the business. If they cannot recognise the groups, the segmentation is not wrong exactly — it is just a partition of a space, and every partition is valid. It simply is not useful, and useful is the only standard that applies to a result nobody can score.