Introduction to Clustering
What you’ll learn
- what a cluster is, and why the definition is a choice rather than a fact
- three distance metrics, computed by hand on the same pair of points
- why scaling changes the answer, measured: ARI 0.035 → 0.891
- the silhouette coefficient, derived and computed by hand
- three internal validation scores, and what each one rewards
- why “how many clusters?” has no purely mathematical answer
Intuition
Every supervised page so far had an answer key. You fit a model, compared its predictions with yy,
and got a number. Clustering has no yy. You hand an algorithm a matrix of features and it hands
back a group label for each row, and there is nothing to check it against.
That sounds like it should make clustering easier — no labels to collect, no leakage to worry about. It makes it harder, because the question “is this grouping right?” has no answer. It has only the question behind it: right for what?
Consider six people described by two numbers, age and annual income:
| Person | Age | Income |
|---|---|---|
| A | 24 | 48,000 |
| B | 26 | 52,000 |
| C | 29 | 47,000 |
| D | 51 | 51,000 |
| E | 54 | 49,000 |
| F | 56 | 53,000 |
There are two obvious groupings. By age: and . By income: everyone in one group, because incomes barely differ. Both are defensible. Which one an algorithm returns is decided entirely by how you measure “close” — and that decision is made before the algorithm runs, by the units your columns happen to be in.
That is the single most important fact on this page. Clustering algorithms do not find structure. They find the structure implied by your distance metric.
flowchart LR X["Unlabelled X"] --> M["Choice of distance metric
(and scaling)"] M --> A["Clustering algorithm"] A --> L["Group labels"] L --> V["Internal validation
silhouette, Davies-Bouldin, ..."] V -->|"score is low"| M V -->|"score is fine but
the groups are useless"| Q["Wrong question.
Change the features."]
The math
A metric on a feature space is a function satisfying four properties: it is non-negative, it is zero exactly when the points coincide, it is symmetric, and it obeys the triangle inequality
Three metrics cover almost everything you will use.
Euclidean distance (the norm) is straight-line distance:
Manhattan distance (the norm) is the distance walked along the axes:
Cosine distance ignores magnitude entirely and measures only the angle between the two vectors:
Cosine distance is not a true metric — it violates the triangle inequality — but it is the right choice whenever the direction of a vector carries the meaning and its length does not. Document term-frequency vectors are the standard example: a 200-word article and a 2,000-word article about the same topic point the same way but have wildly different lengths.
Both and are special cases of the Minkowski distance:
with and . As this converges to the Chebyshev distance, .
Worked example by hand
Take and . The differences are and .
Euclidean:
Manhattan:
Cosine: the dot product is . The norms are and .
which corresponds to an angle of . By Euclidean distance these points are five units apart — substantial. By cosine distance they are practically identical, because they point almost the same way from the origin. Same two points, two completely different verdicts.
Scaling decides the answer
Here is the failure the six-person table hinted at, run for real on 180 people. Ages are drawn from two groups centred on 28 and 52; incomes are drawn from two nearly identical distributions centred on 60,000 and 64,000. The genuine structure is entirely in the age column.
The raw column standard deviations are 12.3 years and 12,698 dollars. Squared Euclidean distance adds those contributions together, so the income term is about a million times larger. The age column is arithmetically invisible.
| Input to k-means | Adjusted Rand index vs the real groups |
|---|---|
| Raw columns | 0.035 |
After StandardScalerStandardScaler | 0.891 |
Scale your features before clustering, unless you have a specific reason not to. The exception is when the units are already comparable and the relative spread is meaningful — pixel intensities, or a set of columns all measured in dollars.
Scoring a clustering without labels
You cannot compute accuracy without an answer key, but you can ask whether the groups are internally tight and mutually far apart. Three scores do this.
The silhouette coefficient
For a single point in cluster , define
- = mean distance from to the other points in its own cluster (how tight it sits),
- = the smallest mean distance from to all points of any other cluster (how far it is from the nearest rival).
Then
If the convention is . Reading the value:
- near : much closer to its own cluster than to any other — well placed.
- near : sits on the boundary between two clusters.
- negative: closer, on average, to a different cluster than to its own — misassigned.
The silhouette score of a clustering is the mean of over every point.
Worked silhouette, by hand
Five points on a line: in cluster 0, and in cluster 1. Take the point at .
Its own cluster contains and , both at distance 1:
The other cluster contains 10 and 11, at distances 8 and 9:
Now take the point at , which sits at the edge. and , so . Lower, as it should be — that point is nearer the other group.
The other two
Davies-Bouldin index. For each cluster, find the worst-case ratio of “sum of the two clusters’ average internal spread” to “distance between their centroids”, then average those worst cases:
Lower is better, and 0 is perfect. This is the one score on this page where a smaller number means a better clustering — a common source of sign errors.
Calinski-Harabasz index (the variance-ratio criterion) is between-cluster dispersion over within-cluster dispersion, scaled by the degrees of freedom:
Higher is better. It is by far the cheapest of the three — rather than the silhouette’s — which matters once passes a few tens of thousands.
| Score | Range | Better | Cost | Biased toward |
|---|---|---|---|---|
| Silhouette | higher | convex, equally sized clusters | ||
| Davies-Bouldin | lower | convex clusters | ||
| Calinski-Harabasz | higher | convex clusters, many of them |
All three assume clusters are roughly round blobs. On the two-moons dataset, a correct clustering scores worse than a wrong one on all three. Internal scores are a tool for comparing runs on similar geometry, not a verdict on truth.
See it move
And here is the metric choice made tangible — drag the slider by clicking, and watch which pairs count as “near” flip as the exponent of the Minkowski distance changes.
Not every shape is a blob
Different algorithms make different assumptions about what a cluster looks like, and those assumptions are the whole difference between them. Here are four geometries and two algorithms.
That third column matters. Neither algorithm is universally better; each fails where its assumption is violated. The two later pages in this phase are the detailed versions of these two columns.
In code
import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from sklearn.metrics import (
calinski_harabasz_score,
davies_bouldin_score,
silhouette_score,
)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y_true = make_blobs(n_samples=400, centers=4, cluster_std=0.9, random_state=11)
# Scaling belongs inside the pipeline, exactly as in supervised work.
model = make_pipeline(StandardScaler(), KMeans(n_clusters=4, n_init=10, random_state=0))
labels = model.fit_predict(X)
print("silhouette ", round(silhouette_score(X, labels), 4)) # 0.7475
print("davies-bouldin ", round(davies_bouldin_score(X, labels), 4)) # 0.3578, lower is better
print("calinski-harabasz", round(calinski_harabasz_score(X, labels), 1)) # 3241.8import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from sklearn.metrics import (
calinski_harabasz_score,
davies_bouldin_score,
silhouette_score,
)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y_true = make_blobs(n_samples=400, centers=4, cluster_std=0.9, random_state=11)
# Scaling belongs inside the pipeline, exactly as in supervised work.
model = make_pipeline(StandardScaler(), KMeans(n_clusters=4, n_init=10, random_state=0))
labels = model.fit_predict(X)
print("silhouette ", round(silhouette_score(X, labels), 4)) # 0.7475
print("davies-bouldin ", round(davies_bouldin_score(X, labels), 4)) # 0.3578, lower is better
print("calinski-harabasz", round(calinski_harabasz_score(X, labels), 1)) # 3241.8Note fit_predictfit_predict. Clustering estimators have no yy argument, so fit(X)fit(X) then .labels_.labels_, or
fit_predict(X)fit_predict(X) in one step. Only some of them — k-means and Gaussian mixtures — can also
predictpredict on unseen rows; DBSCAN and agglomerative clustering cannot, because their labels are
defined relative to the training set.
import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score
from sklearn.preprocessing import StandardScaler
rng = np.random.default_rng(0)
age = np.concatenate([rng.normal(28, 4, 90), rng.normal(52, 4, 90)])
income = np.concatenate([rng.normal(60_000, 12_000, 90), rng.normal(64_000, 12_000, 90)])
X = np.column_stack([age, income])
truth = np.concatenate([np.zeros(90), np.ones(90)])
raw = KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(X)
scaled = KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(
StandardScaler().fit_transform(X)
)
print("column stds:", X.std(axis=0).round(1)) # [ 12.3 12697.7]
print("ARI raw ", round(adjusted_rand_score(truth, raw), 4)) # 0.0348
print("ARI scaled", round(adjusted_rand_score(truth, scaled), 4)) # 0.8914import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score
from sklearn.preprocessing import StandardScaler
rng = np.random.default_rng(0)
age = np.concatenate([rng.normal(28, 4, 90), rng.normal(52, 4, 90)])
income = np.concatenate([rng.normal(60_000, 12_000, 90), rng.normal(64_000, 12_000, 90)])
X = np.column_stack([age, income])
truth = np.concatenate([np.zeros(90), np.ones(90)])
raw = KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(X)
scaled = KMeans(n_clusters=2, n_init=10, random_state=0).fit_predict(
StandardScaler().fit_transform(X)
)
print("column stds:", X.std(axis=0).round(1)) # [ 12.3 12697.7]
print("ARI raw ", round(adjusted_rand_score(truth, raw), 4)) # 0.0348
print("ARI scaled", round(adjusted_rand_score(truth, scaled), 4)) # 0.8914When you do have labels
Sometimes you have ground truth — you are evaluating a clustering method on a benchmark, or you withheld a label column deliberately. Then use an external score, one that is invariant to how the clusters are numbered.
The adjusted Rand index counts pairs of points that the two labellings agree about (both put them together, or both put them apart), then corrects for the agreement expected by chance:
It is 1.0 for a perfect match, about 0.0 for random labelling, and can go slightly negative. Every comparison in this phase that has a ground truth is reported as an ARI.
Normalised mutual information measures how much knowing one labelling tells you about the other, normalised to . It is more forgiving than ARI when one labelling splits a true cluster into several pieces.
| Situation | Use |
|---|---|
| No labels at all (the usual case) | silhouette, Davies-Bouldin, Calinski-Harabasz |
| Labels available for evaluation | adjusted Rand index, normalised mutual information |
| Comparing k across runs on one dataset | silhouette or Calinski-Harabasz |
| Comparing algorithms with different geometry assumptions | your eyes, and the downstream task |
Clustering as a feature
A cluster assignment is a categorical feature, and one-hot encoding it can help a supervised model
enormously. If you have a regionregion column with 800 distinct values, clustering the region-level
statistics into 12 groups gives a supervised model something it can actually learn from.
The same leakage rule as
everywhere else
applies: the clusterer must be fitted inside the cross-validation fold, on training rows only.
KMeansKMeans is a transformer as well as an estimator — its transformtransform returns the distance from each
row to each centroid, which is often more useful than the hard label.
from sklearn.cluster import KMeans
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# X and y here are your own supervised dataset.
# KMeans.transform gives k distance columns, which then feed the classifier.
pipe = make_pipeline(
StandardScaler(),
KMeans(n_clusters=12, n_init=10, random_state=0), # acts as a transformer here
LogisticRegression(max_iter=2000),
)
# cross_val_score refits the KMeans on each training fold — no leakage.
scores = cross_val_score(pipe, X, y, cv=5)from sklearn.cluster import KMeans
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# X and y here are your own supervised dataset.
# KMeans.transform gives k distance columns, which then feed the classifier.
pipe = make_pipeline(
StandardScaler(),
KMeans(n_clusters=12, n_init=10, random_state=0), # acts as a transformer here
LogisticRegression(max_iter=2000),
)
# cross_val_score refits the KMeans on each training fold — no leakage.
scores = cross_val_score(pipe, X, y, cv=5)APIsklearn.cluster
Assumes
- There is meaningful group structure in the feature space you built
- Your distance metric reflects the similarity you actually care about
- Features are on comparable scales (or you scaled them)
- The specific algorithm's shape assumption matches your data
Cost
- train
k-means O(n k p i); DBSCAN O(n log n) with an index; agglomerative O(n^2 log n)- predict
k-means O(k p); DBSCAN and agglomerative cannot predict at all- memory
agglomerative needs the O(n^2) distance matrix; k-means needs O(n p)
d(x, y) — distance; k — number of clusters; s(i) — silhouette of point i; C_I — the cluster containing i
Hyperparameters that matter
the distance metricdefault euclideanDecides the answer before the algorithm runs. Cosine for text, euclidean for most else.feature scalingdefault none — you must add itWithout it, the widest-ranging column dominates every distance.number of clustersdefault algorithm-specifick-means and agglomerative need it up front; DBSCAN and HDBSCAN infer it.
Reach for it when
- You want to explore an unlabelled dataset before committing to a model
- You need to segment customers, documents, regions or sensors
- You want a compact categorical feature for a supervised model
- You want to compress a dataset by replacing points with centroids
Look elsewhere when
- You have labels — a supervised model will beat any clustering at that task
- You cannot articulate what a 'group' means for your problem
- The features are arbitrary and unscaled and you have not looked at them
Pitfalls
Clustering unscaled features. Measured above: ARI 0.035 versus 0.891 on the same data. This is the single most common clustering mistake and it is silent — you get plausible-looking clusters that encode nothing but your unit choices.
Treating the silhouette as truth. It rewards round, equally sized, well-separated clusters. A correct DBSCAN clustering of two moons scores badly on it. Use it to compare similar runs, never to declare one geometry better than another.
Reading meaning into cluster numbers. Cluster 0 in one run is cluster 2 in the next; the
labelling is arbitrary and changes with random_staterandom_state. Never join on the raw integer, never write
if label == 0if label == 0 in production code. Map clusters to named segments explicitly after inspecting them.
Forgetting that k-means always returns k clusters. Hand it uniform noise and ask for three groups and it will give you three groups, with a silhouette of 0.378 — respectable-looking. Nothing in the algorithm tells you the structure was not there.
Clustering with a distance metric that ignores your categorical columns. One-hot encoding puts every category exactly apart in Euclidean space, which is rarely what you mean. Consider Gower distance, or cluster the numeric block and treat the categories separately.
Fitting the scaler on all the data before splitting off a validation set. Clustering feels exploratory so this feels harmless, but the moment the cluster label becomes a feature for a supervised model, it is leakage like any other.
Compare
| Algorithm | Cluster shape | Needs k? | Handles noise | Can predict new points | Scales to 1M rows |
|---|---|---|---|---|---|
| k-means | convex, similar size | yes | no | yes | yes, with MiniBatch |
| Agglomerative | depends on linkage | yes, or a cut height | no | no | no — memory |
| DBSCAN | any shape | no | yes | no | yes, with an index |
| HDBSCAN | any shape, varying density | no | yes | approximately | yes |
| Gaussian mixture | ellipsoidal | yes | soft | yes | moderate |
| Spectral | any shape | yes | no | no | no — |
Your customer table has age (18-80) and lifetime spend (0-50,000). You run k-means on the raw columns. What will the clusters encode?
Squared Euclidean distance adds the two contributions. Spend varies over 50,000 units and age over 62, so the spend term is roughly a million times larger. The measured version of this on the page moved ARI from 0.035 to 0.891 once the columns were standardised.
Show answer
B — Almost entirely spend, because its squared differences dwarf age's — Squared Euclidean distance adds the two contributions. Spend varies over 50,000 units and age over 62, so the spend term is roughly a million times larger. The measured version of this on the page moved ARI from 0.035 to 0.891 once the columns were standardised.
A point has silhouette coefficient -0.3. What does that mean?
s = (b - a) / max(a, b) is negative exactly when a > b — the point is on average closer to a rival cluster than to its own. It is a misassignment signal, not an outlier signal.
Show answer
B — Its average distance to some other cluster is smaller than to its own cluster — s = (b - a) / max(a, b) is negative exactly when a > b — the point is on average closer to a rival cluster than to its own. It is a misassignment signal, not an outlier signal.
Which of these scores is better when it is LOWER?
Davies-Bouldin averages worst-case spread-over-separation ratios, so 0 is perfect. The other three are all higher-is-better, which makes this the easiest sign error to make in a model-selection loop.
Show answer
B — Davies-Bouldin — Davies-Bouldin averages worst-case spread-over-separation ratios, so 0 is perfect. The other three are all higher-is-better, which makes this the easiest sign error to make in a model-selection loop.
You cluster 5,000 news articles by their TF-IDF vectors. Which distance metric fits best?
TF-IDF vector length scales with document length. Cosine distance measures only direction, so a 200-word and a 2,000-word article on the same topic land close together. This is the standard choice for text.
Show answer
B — Cosine, because article length should not make two articles on the same topic look far apart — TF-IDF vector length scales with document length. Cosine distance measures only direction, so a 200-word and a 2,000-word article on the same topic land close together. This is the standard choice for text.
You run k-means with k=3 on uniformly random 2-D points. What happens?
Measured on the page: uniform noise, k=3, silhouette 0.3775 and ARI 0.000. The algorithm always returns exactly k groups. No internal score reliably tells you the structure was absent — that is your job.
Show answer
B — It returns three clusters with a plausible-looking silhouette of about 0.38 — Measured on the page: uniform noise, k=3, silhouette 0.3775 and ARI 0.000. The algorithm always returns exactly k groups. No internal score reliably tells you the structure was absent — that is your job.
🧪 Try It Yourself
Exercise 1 – Three metrics on one pair
Exercise 2 – Scaling flips the answer
Exercise 3 – Silhouette by hand, then by sklearn
Exercise 4 – The three internal scores disagree
Exercise 5 – A good clustering that scores badly
Recap
- Clustering has no answer key, so the distance metric is not a detail — it is the definition of the problem.
- Euclidean, Manhattan and cosine gave 5.00, 7.00 and 0.0061 for the same pair of points.
- Scale first. ARI went from 0.035 to 0.891 on identical data with one
StandardScalerStandardScaler. - The silhouette of a point is ; negative means misassigned.
- Davies-Bouldin is the one score where lower is better.
- All three internal scores reward round blobs. A perfect DBSCAN clustering of two moons scores 0.329 against k-means’ wrong-but-round 0.479.
- k-means will always return exactly clusters, including on pure noise.
Exercise 6 – Cluster data that has no clusters
Next
K-Means Clustering Algorithm — Lloyd’s algorithm iteration by iteration, why it always converges but not always well, and the three geometries where it fails outright.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
