k-means clustering
Run Lloyd's algorithm and see it as alternating minimization of the within-cluster sum of squares.
k-means partitions points into clusters by minimizing the within-cluster sum of squared distances to cluster centers. Lloyd's algorithm[1], the standard solver, alternates two steps: assign each point to its closest center, then recompute each center as the mean of its assigned points. The algorithm is fast, scales well, and is the unsupervised baseline to beat[2].
The objective
Given points and a number of clusters , find cluster centers and assignments minimizing
This is the within-cluster sum of squares (WCSS) or inertia. The optimum jointly assigns each point to its closest center and places each center at the mean of its assigned points.
Lloyd's algorithm
Initialize centers (next lesson handles this). Repeat until convergence:
- Assignment step. .
- Update step. .
Each step decreases (or keeps it the same). Since is bounded below and the assignment space is finite, the algorithm converges in finitely many steps. The converged solution is a local minimum, not necessarily global.
Why each step decreases J
Assignment step. For fixed centers, the optimal assignment is to the closest center. Any other assignment can only increase .
Update step. For fixed assignments, minimizing is the cluster mean, by differentiating and solving (or by recognizing OLS without features).
Cost and scalability
Each iteration is (compute distances from each point to each center). Number of iterations is usually small (tens to hundreds). For huge , mini-batch k-means subsamples each step; k-means|| (k-means parallel) parallelizes the initialization across distributed data.
Hard assignment, EM intuition
Lloyd is a hard-assignment algorithm: every point belongs entirely to one cluster. The Gaussian-mixture EM algorithm (later in C5) is the soft analog, where each point has a probability of belonging to each cluster. In the limit of zero covariance, GMM-EM degenerates to k-means.
Failure modes
- Local minima: initialization matters. The next lesson uses k-means++.
- Spherical assumption: k-means assumes clusters are isotropic blobs. Elongated, curved, or differently-sized clusters confuse it.
- Sensitivity to scale: distances dominate, so features need to be scaled.
- k must be chosen: the algorithm does not know "the right "; pick it by the elbow method, silhouette, or validation (next lesson).
Check it on arrays
import numpy as np
rng = np.random.default_rng(0)
# Three Gaussian blobs in 2D
n_per = 100
centers_true = np.array([[2.0, 0.0], [-2.0, 0.0], [0.0, 2.0]])
X = np.vstack([rng.normal(loc=c, scale=0.5, size=(n_per, 2)) for c in centers_true])
def kmeans(X, k, max_iter=100, init=None, rng=rng):
n, d = X.shape
if init is None:
# random initialization
mu = X[rng.choice(n, size=k, replace=False)]
else:
mu = init.copy()
history = []
for _ in range(max_iter):
# assignment
d2 = ((X[:, None] - mu[None, :])**2).sum(axis=2)
z = d2.argmin(axis=1)
# update
mu_new = np.array([X[z == j].mean(axis=0) if (z == j).sum() > 0 else mu[j] for j in range(k)])
inertia = np.sum((X - mu_new[z])**2)
history.append(inertia)
if np.allclose(mu, mu_new):
break
mu = mu_new
return mu, z, history
mu, z, hist = kmeans(X, k=3)
print(f"converged in {len(hist)} iterations, final inertia: {hist[-1]:.2f}")
print(f"learned centers (sorted by x):\n{mu[np.argsort(mu[:, 0])].round(2)}")
print(f"true centers (sorted by x):\n{centers_true[np.argsort(centers_true[:, 0])].round(2)}")
# Inertia monotonically decreases
print(f"inertia decreasing: {all(hist[i] >= hist[i+1] for i in range(len(hist) - 1))}")
# Multiple random restarts to escape local minima
best_inertia = float("inf")
for seed in range(10):
rng_seed = np.random.default_rng(seed)
_, _, h = kmeans(X, k=3, rng=rng_seed)
if h[-1] < best_inertia: best_inertia = h[-1]
print(f"best inertia over 10 random restarts: {best_inertia:.2f}")Lloyd recovers the three blobs' centers, inertia is monotonically decreasing, and random restarts find similar minima.
Exercises
Exercise 1. Three points lie on a line: , , . With and the initial assignment in cluster 1 and in cluster 2, run one full Lloyd iteration by hand and report the final centers, assignments, and inertia.
Solution
Step 1. Update step on the given assignment. Cluster 1 mean is ; cluster 2 has the single point, .
Step 2. Assignment step with these centers. Distances put (dist 1 vs 7) and (dist 1 vs 6) in cluster 1, and (dist 7 vs 0) in cluster 2. Assignments are unchanged, so the algorithm has converged.
Step 3. Inertia is the sum of squared distances to the assigned center:
The final centers are , with inertia .
Exercise 2. Prove that for a fixed set of points , the center minimizing is the mean , justifying the update step.
Solution
Step 1. Define . It is a sum of convex quadratics in , so a stationary point is the global minimum.
Step 2. Take the gradient with respect to and use :
Step 3. Set the gradient to zero. With points, , so
The cluster mean is the unique minimizer, which is why the update step moves each center to the mean of its assigned points.
Exercise 3 (code). Implement the inertia and verify the claim that each Lloyd step is non-increasing: across a run, every inertia value is less than or equal to the previous one. Report the gap between the first and last inertia.
Solution
import numpy as np
rng = np.random.default_rng(0)
centers_true = np.array([[2.0, 0.0], [-2.0, 0.0], [0.0, 2.0]])
X = np.vstack([rng.normal(loc=c, scale=0.5, size=(100, 2)) for c in centers_true])
def kmeans_history(X, k, max_iter=100, rng=rng):
mu = X[rng.choice(len(X), size=k, replace=False)]
hist = []
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)])
# inertia after the update, recorded each iteration
hist.append(np.sum((X - mu_new[z])**2))
if np.allclose(mu, mu_new):
break
mu = mu_new
return np.array(hist)
hist = kmeans_history(X, k=3)
# every step is non-increasing within a tiny float tolerance
assert np.all(np.diff(hist) <= 1e-9)
print("first inertia:", round(hist[0], 2), " last:", round(hist[-1], 2))
print("decrease:", round(hist[0] - hist[-1], 2))The assertion confirms the inertia never rises from one iteration to the next, matching the monotone-decrease proof; the recorded sequence ends at the local minimum Lloyd settles into.
The next lesson tackles the two hardest practical questions: how to initialize the centers, and how to choose .
References
- [1]Lloyd, S. P. (1982). "Least squares quantization in PCM". IEEE Transactions on Information Theory, 28(2), 129-137. doi:10.1109/TIT.1982.1056489. The alternating assign-update iteration, originally for PCM quantization.↩
- [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.↩