Skip to content

Clustering: K-Means, DBSCAN, hierarchical

Chapter 28 19 code blocks 4697 words

The code in this chapter uses these filesrun it on your own machine

The scene: "split our customers into groups"

The marketing department comes over: "We have 1200 listings. Split them into groups, and we'll write different ads for each group."

What groups? They don't know. There are no labels. You have to find them.

That's clustering. And it's fundamentally different from supervised learning: there is no right answer.

There is no "accuracy" in clustering

In Chapter 8 we had accuracy, because the truth was known. Here there is no truth.

That's why evaluating a clustering result is the hardest part. And the one most often skipped.

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built, narx_usd monthly rent in USD, ijaraga_berildi_30k rented within 30 days.

K-Means: the most widely used method

The idea in three steps:

  1. Drop K random centres
  2. Assign every point to the nearest centre
  3. Move each centre to the mean of its group, and go back to step 2
python16 lines
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
X = SimpleImputer(strategy="median").fit_transform(df[num])
Xs = StandardScaler().fit_transform(X)

km = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xs)
print(f"Groups: {np.bincount(km.labels_)}")
print(f"Iterations: {km.n_iter_}")
print(f"Inertia (spread within the groups): {km.inertia_:.1f}")
Output
Groups: [318 274 313 295]
Iterations: 36
Inertia (spread within the groups): 5304.0

You can write the algorithm by hand too; it's that simple:

python18 lines
def kmeans_by_hand(X, k=4, steps=20, seed=42):
    r = np.random.default_rng(seed)
    centre = X[r.choice(len(X), k, replace=False)]
    for _ in range(steps):
        dist = ((X[:, None, :] - centre[None, :, :]) ** 2).sum(axis=2)
        label = dist.argmin(axis=1)
        new = np.array([X[label == j].mean(axis=0) if (label == j).any() else centre[j]
                        for j in range(k)])
        if np.allclose(new, centre):
            break
        centre = new
    inertia = sum(((X[label == j] - centre[j]) ** 2).sum() for j in range(k))
    return label, centre, inertia

y_h, c_h, in_h = kmeans_by_hand(Xs, k=4)
print(f"Hand-written inertia  : {in_h:.1f}")
print(f"sklearn inertia       : {km.inertia_:.1f}")
print(f"Group sizes (by hand) : {np.bincount(y_h)}")
Output
Hand-written inertia  : 5319.0
sklearn inertia       : 5304.0
Group sizes (by hand) : [294 370 257 279]

How many groups? The elbow method

You choose K. The model doesn't tell you.

python8 lines
inertia = []
Ks = range(1, 11)
for k in Ks:
    inertia.append(KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xs).inertia_)

table = pd.DataFrame({"K": list(Ks), "inertia": [round(i, 1) for i in inertia]})
table["drop"] = [None] + [round(inertia[i-1] - inertia[i], 1) for i in range(1, len(inertia))]
show(table)
Kinertiadrop
18 400NaN
26 754.11 645.9
35 866.8887.3
45 304562.8
54 918.6385.4
64 599.5319.1
74 344.3255.2
84 109.1235.3
93 914.2194.9
103 739.9174.3
python26 lines
from sklearn.metrics import silhouette_score

sil = []
for k in range(2, 11):
    yl = KMeans(n_clusters=k, n_init=10, random_state=42).fit_predict(Xs)
    sil.append(silhouette_score(Xs, yl))

figure, axes = plt.subplots(1, 2, figsize=(13.5, 4.5))
axes[0].plot(list(Ks), inertia, "o-", lw=2, color="#1a73e8")
axes[0].set_xlabel("K (number of groups)"); axes[0].set_ylabel("Inertia")
axes[0].set_title("The elbow method: look for the 'elbow'", fontweight="bold")
axes[0].grid(alpha=.3)

axes[1].plot(range(2, 11), sil, "o-", lw=2, color="#137333")
best_k = int(np.argmax(sil)) + 2
axes[1].axvline(best_k, ls="--", color="red", lw=1.2, label=f"highest: K={best_k}")
axes[1].set_xlabel("K"); axes[1].set_ylabel("Silhouette score")
axes[1].set_title("Silhouette: a clearer criterion", fontweight="bold")
axes[1].legend(); axes[1].grid(alpha=.3)
fig("elbow.png", caption="Left: the elbow, which may not be visible to the eye. Right: the silhouette, whose maximum is clearer.")

print(f"{'K':>4} {'silhouette':>12}")
print("-" * 18)
for k, s in zip(range(2, 11), sil):
    flag = "  <-- highest" if k == best_k else ""
    print(f"{k:>4} {s:>12.4f}{flag}")
Output
   K   silhouette
------------------
   2       0.1797  <-- highest
   3       0.1645
   4       0.1632
   5       0.1658
   6       0.1591
   7       0.1590
   8       0.1610
   9       0.1642
  10       0.1652

Left: the elbow, which may not be visible to the eye. Right: the silhouette, whose maximum is clearer.

Left: the elbow, which may not be visible to the eye. Right: the silhouette, whose maximum is clearer.

The elbow often shows no elbow

Textbooks draw the elbow chart with a nice sharp bend. On real data the curve is smooth, and you can't tell where the "elbow" is.

The chart above is like that too. That's why in practice you use the silhouette: it has a clear maximum.

But the most reliable criterion is different: open the groups and read them. If the groups have no business meaning, they're useless however high the silhouette.

What the silhouette measures

python10 lines
print("The silhouette is computed for every point:")
print("  a = the average distance to the points in its own group")
print("  b = the average distance to the nearest OTHER group")
print("  silhouette = (b - a) / max(a, b)")
print()
show(pd.DataFrame([
    {"Value": "close to +1", "Meaning": "The point sits firmly in its group, far from the others"},
    {"Value": "around 0", "Meaning": "It's on the border between two groups"},
    {"Value": "negative", "Meaning": "It landed in the wrong group"},
]))
Output
The silhouette is computed for every point:
  a = the average distance to the points in its own group
  b = the average distance to the nearest OTHER group
  silhouette = (b - a) / max(a, b)
ValueMeaning
close to +1The point sits firmly in its group, far from the others
around 0It's on the border between two groups
negativeIt landed in the wrong group
python9 lines
from sklearn.metrics import silhouette_samples
km_best = KMeans(n_clusters=best_k, n_init=10, random_state=42).fit(Xs)  # best_k is the K with the highest silhouette
samples = silhouette_samples(Xs, km_best.labels_)
ana = pd.DataFrame({"group": km_best.labels_, "silhouette": samples})
g = ana.groupby("group").silhouette.agg(["size", "mean", "min"]).round(4)
g.columns = ["rows", "mean sil", "lowest sil"]
show(g.reset_index())
print()
print(f"Rows with a negative silhouette: {int((samples < 0).sum())} / {len(samples)}")
Output
Rows with a negative silhouette: 5 / 1200
grouprowsmean sillowest sil
05640.16-0.013
16360.1980.015

READING the groups: the most important step

Numbers aren't enough. What do the groups mean?

python13 lines
result = df[num].copy()
result["group"] = km_best.labels_
summary = result.groupby("group").agg({
    "xonalar": "mean", "maydon_m2": "mean", "metro_km": "mean",
    "mebel": "mean", "qurilgan_yil": "mean",
}).round(2)
summary["rows"] = result.groupby("group").size()
show(summary.reset_index())
print()
print("Also: do the groups differ in price and in renting fast?")
check = df.assign(group=km_best.labels_).groupby("group").agg({
    "narx_usd": "mean", "ijaraga_berildi_30k": "mean"}).round(3)
show(check.reset_index())
Output
Also: do the groups differ in price and in renting fast?
groupxonalarmaydon_m2metro_kmmebelqurilgan_yilrows
03.4874.362.690.61 999.24564
11.6541.962.50.621 998.7636
groupnarx_usdijaraga_berildi_30k
0955.9220.271
1616.6350.715

The group means differ from each other, so you could give them names: "small cheap flats", "central and furnished" and so on.

But here I have to stop and give an honest assessment.

Let's look at the silhouette scores: the highest is 0.1797 (at K=2), and at K=4 it's 0.1632. Those are low scores.

python6 lines
show(pd.DataFrame([
    {"Silhouette": "above 0.70", "Meaning": "Strong, clearly separated groups"},
    {"Silhouette": "0.50 - 0.70", "Meaning": "A reliable structure"},
    {"Silhouette": "0.25 - 0.50", "Meaning": "A weak structure: be careful"},
    {"Silhouette": "below 0.25", "Meaning": "Considered to have NO natural groups"},
]))
SilhouetteMeaning
above 0.70Strong, clearly separated groups
0.50 - 0.70A reliable structure
0.25 - 0.50A weak structure: be careful
below 0.25Considered to have NO natural groups

Our score of 0.18 falls in the last row.

The conclusion: this data has no clear natural groups. K-Means drew 4 groups anyway, because that's what we asked for, and it will split any data into K pieces. The scores say that split is artificial.

The honest answer for the marketing department is:

"No natural segments were found in the data. If you need segmentation, set it with a business rule (for example, splitting by price into three), which will be more reliable than clustering."

K-Means ALWAYS returns a result, even when there are no groups

This is the most dangerous property in clustering. KMeans(n_clusters=4) never says "no groups found". It splits any data into four, even a uniform random cloud.

So always compute the silhouette and put it in the report. Without it you might present a random split as "customer segments".

As proof: K-Means "finds" groups even in random noise; below we measure that and compare it with our own result.

python11 lines
r_n = np.random.default_rng(1)
noise_data = r_n.normal(size=(1200, 7))      # COMPLETELY random
km_n = KMeans(n_clusters=4, n_init=10, random_state=42).fit(noise_data)
print("K-Means on COMPLETELY RANDOM data:")
print(f"  group sizes : {np.bincount(km_n.labels_)}")
print(f"  silhouette  : {silhouette_score(noise_data, km_n.labels_):.4f}")
print()
print(f"On our real data (K={best_k}): {silhouette_score(Xs, km_best.labels_):.4f}")
print()
ratio = silhouette_score(Xs, km_best.labels_) / silhouette_score(noise_data, km_n.labels_)
print(f"Ratio: our score is {ratio:.1f} times higher than chance")
Output
K-Means on COMPLETELY RANDOM data:
  group sizes : [277 306 305 312]
  silhouette  : 0.0961

On our real data (K=2): 0.1797

Ratio: our score is 1.9 times higher than chance

The work isn't done until the group has a name

If you can't name a group in two words, it isn't a group: it's a random collection.

So the sequence is: choose K -> read the groups -> name them -> if you can't name them, change K.

The limitations of K-Means

python21 lines
from sklearn.datasets import make_moons, make_blobs

r = np.random.default_rng(42)
# 1) half-moon shapes
X_moon, y_moon = make_moons(n_samples=400, noise=.06, random_state=42)
# 2) groups of different sizes
X_s, y_s = make_blobs(n_samples=[400, 60, 60], centers=[[0,0],[4,4],[-4,4]],
                      cluster_std=[1.4, .4, .4], random_state=42)

figure, axes = plt.subplots(2, 2, figsize=(12, 8))
for j, (Xd, name) in enumerate([(X_moon, "Half moons"), (X_s, "Different sizes")]):
    Xd_s = StandardScaler().fit_transform(Xd)
    km_t = KMeans(n_clusters=2 if j == 0 else 3, n_init=10, random_state=42).fit_predict(Xd_s)
    axes[j, 0].scatter(Xd[:, 0], Xd[:, 1], c=km_t, cmap="viridis", s=18, alpha=.8)
    axes[j, 0].set_title(f"{name}: K-Means", fontweight="bold")
    from sklearn.cluster import DBSCAN
    db_t = DBSCAN(eps=.3 if j == 0 else .5, min_samples=5).fit_predict(Xd_s)
    axes[j, 1].scatter(Xd[:, 0], Xd[:, 1], c=db_t, cmap="viridis", s=18, alpha=.8)
    axes[j, 1].set_title(f"{name}: DBSCAN", fontweight="bold")
    for a in axes[j]: a.grid(alpha=.3)
fig("kmeans_cheklov.png", caption="K-Means expects round groups of equal size. DBSCAN finds any shape.")

K-Means expects round groups of equal size. DBSCAN finds any shape.

K-Means expects round groups of equal size. DBSCAN finds any shape.

python7 lines
show(pd.DataFrame([
    {"Limitation": "Expects round shapes", "Consequence": "Can't split half moons or rings"},
    {"Limitation": "You provide K", "Consequence": "The wrong K -> meaningless groups"},
    {"Limitation": "Expects equal sizes", "Consequence": "Folds a small group into a big one"},
    {"Limitation": "Sensitive to outliers", "Consequence": "One distant point drags a centre"},
    {"Limitation": "Depends on the starting centres", "Consequence": "That's why n_init=10 is needed"},
]))
LimitationConsequence
Expects round shapesCan't split half moons or rings
You provide KThe wrong K -> meaningless groups
Expects equal sizesFolds a small group into a big one
Sensitive to outliersOne distant point drags a centre
Depends on the starting centresThat's why n_init=10 is needed
python11 lines
print("The effect of the starting centres, and the role of n_init:")
print(f"{'n_init':>8} {'inertia':>12} {'distinct results':>22}")
print("-" * 46)
for ni in [1, 3, 10]:
    results = set()
    inertias = []
    for seed in range(8):
        k_t = KMeans(n_clusters=4, n_init=ni, random_state=seed).fit(Xs)
        inertias.append(round(k_t.inertia_, 1))
        results.add(round(k_t.inertia_, 1))
    print(f"{ni:>8} {np.mean(inertias):>12.1f} {len(results):>22}")
Output
The effect of the starting centres, and the role of n_init:
  n_init      inertia       distinct results
----------------------------------------------
       1       5377.9                      8
       3       5304.7                      6
      10       5304.1                      3

With n_init=1, different seeds give different results. n_init=10 tries 10 times and picks the best, which is why it's on by default.

DBSCAN: based on density

DBSCAN asks a different question: "does this point have enough neighbours around it?"

python14 lines
from sklearn.cluster import DBSCAN

print(f"{'eps':>6} {'min_samples':>12} {'groups':>10} {'noise':>9} {'silhouette':>12}")
print("-" * 54)
for eps in [0.5, 0.8, 1.2, 1.8]:
    db = DBSCAN(eps=eps, min_samples=10).fit(Xs)
    n_groups = len(set(db.labels_)) - (1 if -1 in db.labels_ else 0)
    noise = int((db.labels_ == -1).sum())
    if n_groups > 1:
        mask = db.labels_ != -1
        s = silhouette_score(Xs[mask], db.labels_[mask]) if mask.sum() > n_groups else float("nan")
    else:
        s = float("nan")
    print(f"{eps:>6} {10:>12} {n_groups:>10} {noise:>9} {s:>12.4f}")
Output
   eps  min_samples     groups     noise   silhouette
------------------------------------------------------
   0.5           10          0      1200          nan
   0.8           10          2      1180       0.6764
   1.2           10          3       324       0.1449
   1.8           10          2         7       0.1634

DBSCAN has two advantages:

  1. You don't give K: it finds the number of groups itself
  2. It separates noise: points that belong to no group get the label -1

But it has a cost too: choosing eps is no easier than choosing K.

DBSCAN doesn't work in high dimensions

DBSCAN relies on distance, so the curse of dimensionality from Chapter 21 hits it fully.

Once there are more than 10-15 columns, finding eps becomes almost impossible: every point looks equally far away.

So in practice the order is: first bring it down to 2-10 dimensions with PCA (Chapter 27), then DBSCAN.

Hierarchical: as a tree

python17 lines
from sklearn.cluster import AgglomerativeClustering
from scipy.cluster.hierarchy import dendrogram, linkage

sample = Xs[np.random.default_rng(42).choice(len(Xs), 60, replace=False)]
Z = linkage(sample, method="ward")

figure, ax = plt.subplots(figsize=(11, 4.5))
dendrogram(Z, ax=ax, no_labels=True, color_threshold=7)
ax.set_xlabel("Samples"); ax.set_ylabel("Merge distance")
ax.set_title("A dendrogram: how the groups merge", fontweight="bold")
fig("dendrogram.png", caption="From the bottom up: every point starts in its own group, then they merge step by step. A horizontal cut gives you K.")

print(f"{'K':>4} {'silhouette (ward)':>20}")
print("-" * 26)
for k in [2, 3, 4, 5, 6]:
    yl = AgglomerativeClustering(n_clusters=k, linkage="ward").fit_predict(Xs)
    print(f"{k:>4} {silhouette_score(Xs, yl):>20.4f}")
Output
   K    silhouette (ward)
--------------------------
   2               0.1356
   3               0.1465
   4               0.1144
   5               0.1143
   6               0.1114

From the bottom up: every point starts in its own group, then they merge step by step. A horizontal cut gives you K.

From the bottom up: every point starts in its own group, then they merge step by step. A horizontal cut gives you K.

The dendrogram's advantage: you don't give K in advance: you look at the tree, then choose where to cut.

Comparing the three

python21 lines
import time
from sklearn.metrics import calinski_harabasz_score, davies_bouldin_score

print(f"{'method':<28} {'groups':>9} {'silhouette':>12} {'Calinski-H':>12} {'time':>8}")
print("-" * 76)
for name, model in [
    ("KMeans(4)", KMeans(n_clusters=4, n_init=10, random_state=42)),
    ("DBSCAN(eps=1.2)", DBSCAN(eps=1.2, min_samples=10)),
    ("Agglomerative(4)", AgglomerativeClustering(n_clusters=4, linkage="ward")),
]:
    t0 = time.perf_counter()
    yl = model.fit_predict(Xs)
    dt = time.perf_counter() - t0
    mask = yl != -1
    n_g = len(set(yl[mask]))
    if n_g > 1:
        s = silhouette_score(Xs[mask], yl[mask])
        ch = calinski_harabasz_score(Xs[mask], yl[mask])
    else:
        s = ch = float("nan")
    print(f"{name:<28} {n_g:>9} {s:>12.4f} {ch:>12.1f} {dt:>7.2f}s")
Output
method                          groups   silhouette   Calinski-H     time
----------------------------------------------------------------------------
KMeans(4)                            4       0.1632        232.7    0.09s
DBSCAN(eps=1.2)                      3       0.1449        113.3    0.01s
Agglomerative(4)                     4       0.1144        178.1    0.01s
python5 lines
show(pd.DataFrame([
    {"Criterion": "Silhouette", "Range": "-1 .. +1", "Good": "High", "Note": "The most widely used"},
    {"Criterion": "Calinski-Harabasz", "Range": "0 .. inf", "Good": "High", "Note": "Between-group / within-group ratio"},
    {"Criterion": "Davies-Bouldin", "Range": "0 .. inf", "Good": "LOW", "Note": "Read the other way round"},
]))
CriterionRangeGoodNote
Silhouette-1 .. +1HighThe most widely used
Calinski-Harabasz0 .. infHighBetween-group / within-group ratio
Davies-Bouldin0 .. infLOWRead the other way round

Which one to choose

python10 lines
show(pd.DataFrame([
    {"Method": "K-Means", "When": "Big data, round groups",
     "Need K?": "YES", "Noise": "Doesn't separate it", "Speed": "Very fast"},
    {"Method": "DBSCAN", "When": "Unknown shapes, there is noise",
     "Need K?": "no (needs eps)", "Noise": "SEPARATES it (-1)", "Speed": "Medium"},
    {"Method": "Agglomerative", "When": "Small data, you need a hierarchy",
     "Need K?": "no (dendrogram)", "Noise": "Doesn't separate it", "Speed": "Slow (O(n^2)+)"},
    {"Method": "GaussianMixture", "When": "The groups overlap",
     "Need K?": "YES", "Noise": "Doesn't separate it", "Speed": "Fast"},
]))
MethodWhenNeed K?NoiseSpeed
K-MeansBig data, round groupsYESDoesn't separate itVery fast
DBSCANUnknown shapes, there is noiseno (needs eps)SEPARATES it (-1)Medium
AgglomerativeSmall data, you need a hierarchyno (dendrogram)Doesn't separate itSlow (O(n^2)+)
GaussianMixtureThe groups overlapYESDoesn't separate itFast

Checking clustering against supervised labels

We have a target (ijaraga_berildi_30k), but clustering didn't see it. An interesting question: are the groups it found related to the target?

python8 lines
from sklearn.metrics import adjusted_rand_score

label_km = KMeans(n_clusters=2, n_init=10, random_state=42).fit_predict(Xs)
y_true = df["ijaraga_berildi_30k"].to_numpy()
print(f"Agreement between K-Means(2) and the real target (ARI): {adjusted_rand_score(y_true, label_km):.4f}")
print()
print("Cross-tabulation:")
show(pd.crosstab(label_km, y_true, rownames=["group"], colnames=["truth"]).reset_index())
Output
Agreement between K-Means(2) and the real target (ARI): 0.1959

Cross-tabulation:
group01
0411153
1181455

If the ARI is close to 0, the clustering didn't find the target. And that's normal: clustering doesn't look for the target, it finds other structure in the data.

Clustering doesn't have to find the target

Students often compare a clustering result with the target and conclude "it didn't work".

But clustering answers a different question: "what natural groups are there in this data?" If the natural groups don't match the target, that's not a flaw: it's information about the data.

If you need the target, use a supervised model (Chapters 15-22).

What we learned today

python11 lines
show(pd.DataFrame([
    {"Concept": "The task", "In one sentence": "Find natural groups in unlabelled data"},
    {"Concept": "K-Means", "In one sentence": "Drop centres -> assign -> move the centres"},
    {"Concept": "n_init", "In one sentence": "The starting centres are random: you need 10 tries"},
    {"Concept": "Choosing K", "In one sentence": "The elbow is smooth; the silhouette is clearer"},
    {"Concept": "The most important step", "In one sentence": "READ the groups and name them"},
    {"Concept": "K-Means limits", "In one sentence": "Round shapes, equal sizes, you provide K"},
    {"Concept": "DBSCAN", "In one sentence": "No K needed, separates noise; fails in high dimensions"},
    {"Concept": "Agglomerative", "In one sentence": "A dendrogram: you choose K afterwards"},
    {"Concept": "Scaling", "In one sentence": "REQUIRED for all of them: they rely on distance"},
]))
ConceptIn one sentence
The taskFind natural groups in unlabelled data
K-MeansDrop centres -> assign -> move the centres
n_initThe starting centres are random: you need 10 tries
Choosing KThe elbow is smooth; the silhouette is clearer
The most important stepREAD the groups and name them
K-Means limitsRound shapes, equal sizes, you provide K
DBSCANNo K needed, separates noise; fails in high dimensions
AgglomerativeA dendrogram: you choose K afterwards
ScalingREQUIRED for all of them: they rely on distance

Next chapter: the last method of Part 6: reducing dimensions when there is a target.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents