AI Knowledge Tree

k-means++ and choosing k

Use k-means++ for a smarter initialization and pick k with the elbow method or silhouette score.

⏱ 24 minPrerequisites:k-means clustering

Random initialization is the main cause of bad k-means results: Lloyd converges to a local minimum, and a bad start lands in a bad basin. k-means++[1] picks initial centers far apart, with a probabilistic correctness guarantee. Elbow and silhouette are the two standard ways to pick the number of clusters kk when the truth is unknown[2].

k-means++

Pick the first center uniformly at random. For each subsequent center, pick a point with probability proportional to its squared distance to the nearest already-chosen center:

Pr⁡(pick xi)=D(xi)2∑jD(xj)2,D(x)=min⁡k∥x−μk∥.\Pr(\text{pick } x_i) = \frac{D(x_i)^2}{\sum_j D(x_j)^2}, \qquad D(x) = \min_k \lVert x - \mu_k\rVert.

Repeat until kk centers are chosen, then run Lloyd's algorithm.

The intuition: a point that is currently poorly served by existing centers gets a higher chance of becoming the next center. Far-apart initial centers make Lloyd less likely to get stuck.

Theorem (Arthur & Vassilvitskii). k-means++ initialization gives an O(log⁡k)O(\log k)-approximation in expectation to the optimal inertia, before running Lloyd[1]. After Lloyd, performance is typically much better than random initialization with the same compute budget.

Choosing kk: the elbow method

Plot inertia (WCSS) versus kk:

  • k=1k = 1: inertia is the total variance.
  • k=nk = n: inertia is zero.
  • As kk grows, inertia decreases monotonically.

The plot usually has an "elbow" where additional clusters stop helping much. Pick kk at the elbow.

Caveats. The elbow is subjective and is often not sharp. The method fails when the data lacks a clear cluster structure (the plot is a smooth curve with no kink).

Silhouette score

For each point ii:

  • aia_i = average distance to other points in the same cluster.
  • bib_i = minimum over other clusters of the average distance to points in that cluster.

The silhouette score for point ii is

si=bi−aimax⁡(ai,bi)∈[−1,1].s_i = \frac{b_i - a_i}{\max(a_i, b_i)} \in [-1, 1].

It is +1 when the point is much closer to its own cluster than to any other, 0 when it is borderline, and negative when it would fit better in a different cluster.

The average silhouette across all points measures clustering quality. Sweep kk and pick the maximum.

Other validation criteria

  • Gap statistic: compare clustering inertia to inertia on uniform-random null data. Picks the kk where the gap (in log inertia) is largest.
  • Information criteria (BIC, AIC): for model-based clusterings (GMM), balance fit and complexity automatically.
  • Domain knowledge: often the most reliable. If you know the data should have 5 clusters, use 5.
Choosing k: both the inertia “elbow” and the silhouette peak point to the true number of clusters (k = 3 here).
Choosing k: both the inertia “elbow” and the silhouette peak point to the true number of clusters (k = 3 here).

Check it on arrays

python · numpy
import numpy as np

rng = np.random.default_rng(0)

# Three blobs of differing sizes
n = [200, 150, 100]
centers_true = [[2.0, 0], [-2.0, 0], [0.0, 2.0]]
X = np.vstack([rng.normal(loc=c, scale=0.5, size=(n_c, 2)) for c, n_c in zip(centers_true, n)])
n_total = len(X)

def kmeans_plus_plus_init(X, k, rng):
    n = len(X)
    idx = [int(rng.integers(n))]
    for _ in range(1, k):
        d2 = np.min(((X[:, None] - X[idx][None, :])**2).sum(axis=2), axis=1)
        probs = d2 / d2.sum()
        idx.append(int(rng.choice(n, p=probs)))
    return X[idx].copy()

def kmeans(X, k, init, max_iter=100):
    mu = init.copy()
    for _ in range(max_iter):
        d2 = ((X[:, None] - mu[None, :])**2).sum(axis=2)
        z = d2.argmin(axis=1)
        mu_new = np.array([X[z == j].mean(axis=0) if (z == j).sum() > 0 else mu[j] for j in range(k)])
        if np.allclose(mu, mu_new): break
        mu = mu_new
    inertia = np.sum((X - mu[z])**2)
    return mu, z, inertia

# Compare k-means++ to random init over multiple trials
inertias_rand = []
inertias_kpp = []
for seed in range(50):
    r = np.random.default_rng(seed)
    init_rand = X[r.choice(len(X), size=3, replace=False)]
    init_kpp = kmeans_plus_plus_init(X, 3, r)
    inertias_rand.append(kmeans(X, 3, init_rand)[-1])
    inertias_kpp.append(kmeans(X, 3, init_kpp)[-1])
print(f"random init       mean inertia: {np.mean(inertias_rand):.2f}, std: {np.std(inertias_rand):.2f}")
print(f"k-means++ init    mean inertia: {np.mean(inertias_kpp):.2f}, std: {np.std(inertias_kpp):.2f}")

# Elbow: sweep k and plot inertia
ks = range(1, 8)
inertias = []
for k in ks:
    init = kmeans_plus_plus_init(X, k, np.random.default_rng(0))
    _, _, ine = kmeans(X, k, init)
    inertias.append(ine)
print("\nk, inertia:")
for k, ine in zip(ks, inertias):
    print(f"  {k}: {ine:.2f}")

# Silhouette score
def silhouette(X, z):
    n = len(X); s = np.zeros(n)
    for c in np.unique(z):
        in_c = z == c
        for i in np.where(in_c)[0]:
            same = X[in_c]; same = same[same.tolist() != X[i].tolist()] if False else same
            # average to others in same cluster (exclude self)
            a = np.mean(np.linalg.norm(X[in_c & (np.arange(n) != i)] - X[i], axis=1)) if (in_c.sum() > 1) else 0
            other_dists = []
            for c2 in np.unique(z):
                if c2 == c: continue
                other_dists.append(np.mean(np.linalg.norm(X[z == c2] - X[i], axis=1)))
            b = min(other_dists)
            s[i] = (b - a) / max(a, b) if max(a, b) > 0 else 0
    return s.mean()

print("\nsilhouette score by k:")
for k in [2, 3, 4, 5]:
    init = kmeans_plus_plus_init(X, k, np.random.default_rng(0))
    _, z, _ = kmeans(X, k, init)
    print(f"  k={k}: {silhouette(X, z):.3f}")

k-means++ gives lower mean inertia with much less variance across trials, the elbow at k=3k = 3 is visible, and the silhouette score peaks at the true kk.

Exercises

Exercise 1. A dataset has three already-chosen centers and four candidate points with nearest-center distances D=(1,2,3,4)D = (1, 2, 3, 4). Compute the k-means++ probability of selecting each candidate as the next center.

Solution

Step 1. k-means++ weights each candidate by its squared distance, D(xi)2D(x_i)^2. The squared distances are 1,4,9,161, 4, 9, 16.

Step 2. Normalize by the sum 1+4+9+16=301 + 4 + 9 + 16 = 30:

Pr⁡=(130, 430, 930, 1630)≈(0.033, 0.133, 0.300, 0.533).\Pr = \left(\tfrac{1}{30},\ \tfrac{4}{30},\ \tfrac{9}{30},\ \tfrac{16}{30}\right) \approx (0.033,\ 0.133,\ 0.300,\ 0.533).

The farthest point (distance 4) is selected with probability 0.5330.533, over sixteen times the chance of the nearest point, which is how D2D^2 seeding pulls new centers toward poorly served regions.

Exercise 2. A point sits at the boundary between two clusters with ai=bi=3a_i = b_i = 3. Compute its silhouette sis_i and explain what value of sis_i would instead signal the point is misassigned.

Solution

Step 1. Apply the definition si=(bi−ai)/max⁡(ai,bi)s_i = (b_i - a_i)/\max(a_i, b_i). With ai=bi=3a_i = b_i = 3:

si=3−3max⁡(3,3)=03=0.s_i = \frac{3 - 3}{\max(3, 3)} = \frac{0}{3} = 0.

Step 2. A silhouette of 00 marks a borderline point, equally close to its own cluster and the nearest other one. A misassigned point has bib_i less than aia_i (the nearest other cluster is closer than its own), giving a negative numerator and so sis_i less than 00. Reassigning such a point to its nearest cluster would raise its silhouette.

Exercise 3 (code). Run a kk sweep from 1 to 6 on three blobs, taking the best inertia over several restarts at each kk. Verify that inertia is monotonically non-increasing in kk and that the drop into k=3k = 3 is far larger than the drops past it, the kink the elbow method reads off.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(0)
centers_true = [[2.0, 0.0], [-2.0, 0.0], [0.0, 2.0]]
X = np.vstack([rng.normal(loc=c, scale=0.5, size=(150, 2)) for c in centers_true])

def kmeans(X, k, max_iter=100, rng=rng):
    mu = X[rng.choice(len(X), size=k, replace=False)]
    for _ in range(max_iter):
        d2 = ((X[:, None] - mu[None, :])**2).sum(axis=2)
        z = d2.argmin(axis=1)
        mu_new = np.array([X[z == j].mean(axis=0) if (z == j).sum() else mu[j]
                           for j in range(k)])
        if np.allclose(mu, mu_new):
            break
        mu = mu_new
    return np.sum((X - mu[z])**2)

def best_inertia(X, k, restarts=10):
    return min(kmeans(X, k, rng=np.random.default_rng(s)) for s in range(restarts))

ks = range(1, 7)
inertias = np.array([best_inertia(X, k) for k in ks])
drops = -np.diff(inertias)                          # inertia reduction per step

assert np.all(drops >= -1e-6)                       # non-increasing in k
assert drops[1] > 5 * drops[3]                      # (k=2->3) >> (k=4->5): the elbow
print("inertias:", inertias.round(1))
print("per-step drops:", drops.round(1))

Inertia falls as kk grows, and the reduction collapses once kk passes three: the drop into three clusters dwarfs every later drop, the bend the elbow method reads off.

The next lesson swaps cluster means for actual data points to handle non-Euclidean metrics: k-medoids.


References

  1. [1]Arthur, D., & Vassilvitskii, S. (2007). "k-means++: the advantages of careful seeding". SODA. The D2D^2-weighted seeding and its O(log⁡k)O(\log k) expected-approximation guarantee.↩
  2. [2]Hastie, T., Tibshirani, R., & Friedman, J. (2009). The Elements of Statistical Learning (2nd ed.). Springer, §14.3. doi:10.1007/978-0-387-84858-7. The gap statistic and model-selection criteria for kk.↩