k-Means Clustering & Lloyd's Optimization
Master the mathematical foundations of k-Means clustering, the coordinate descent proof of Lloyd's algorithm, the k-Means++ probabilistic seeding derivation, and zero-dependency implementations in Python and Rust.
Clustering partitions unlabelled data points into discrete subsets such that instances within the same cluster exhibit high mutual similarity, while instances across distinct clusters remain geometrically separated.
The canonical centroid-based clustering algorithm, k-Means, partitions continuous Euclidean feature space into a Voronoi diagram by minimizing the Within-Cluster Sum of Squares (WCSS).
Linear scalability in samples N, clusters K, and dimensions d across I iterations.
1. Mathematical Formulation & The Inertia Objective#
Let be an unlabelled dataset. We seek to partition into disjoint clusters with cluster prototypes (centroids) .
We represent cluster membership via binary assignment indicator variables :
The Inertia objective function measures total within-cluster variance:
2. Derivation of Lloyd's Coordinate Descent Algorithm#
Optimizing simultaneously over discrete assignments and continuous centroids is an NP-hard combinatorial problem. Lloyd's Algorithm solves this using alternating coordinate descent.
Step 1: Optimize Assignments $r$ with Centroids $\mu$ Fixed
Hold centroids constant and minimize with respect to binary indicators :
Because the terms for each data point are decoupled, we minimize each sample independently by assigning to its nearest centroid:
Step 2: Optimize Centroids $\mu$ with Assignments $r$ Fixed
Now hold assignments fixed and minimize with respect to continuous vectors .
Differentiate with respect to vector :
Apply the vector calculus identity :
Step 3: Solve for Optimal Centroid $\mu_k^*$
Distribute the summation:
Let denote the number of points assigned to cluster :
Proof of Guaranteed Convergence#
Because both the Assignment Step and Update Step monotonically decrease (or leave unchanged) the non-negative scalar objective:
And because the number of distinct partitions of points into clusters is finite (), the sequence must converge to a local minimum or saddle point in a finite number of iterations.
3. k-Means++: Probabilistic Seeding Derivation#
Standard random centroid initialization frequently gets trapped in poor local minima with arbitrarily bad competitive ratios.
Arthur and Vassilvitskii (2007) introduced k-Means++, which initializes centroids sequentially by spreading them apart probabilistically.
Step 1: Initial Random Seed
Choose the first centroid uniformly at random from dataset .
Step 2: Distance to Nearest Existing Centroid
For each data point , compute its shortest distance to any already chosen centroid:
Step 3: Probabilistic Sampling
Sample the next centroid from with probability proportional to the squared distance:
Repeat Steps 2 and 3 until exactly centroids are chosen.
4. From-Scratch Implementation: Zero Dependencies#
The following implementations construct the complete k-Means Clustering with k-Means++ Initialization from scratch in Python and Rust.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103import math import random from typing import List, Tuple class KMeansFromScratch: def __init__(self, k: int = 3, max_iter: int = 300, tol: float = 1e-4): self.k = k self.max_iter = max_iter self.tol = tol self.centroids: List[List[float]] = [] @staticmethod def _euclidean_dist_sq(a: List[float], b: List[float]) -> float: return sum((x_i - y_i) ** 2 for x_i, y_i in zip(a, b)) def _kmeans_plus_plus_init(self, X: List[List[float]]) -> List[List[float]]: num_samples = len(X) # 1. First centroid selected uniformly at random centroids = [list(random.choice(X))] # 2. Select remaining K-1 centroids for _ in range(1, self.k): # Compute D(x)^2 for all samples d_sq = [] for sample in X: min_dist = min(self._euclidean_dist_sq(sample, c) for c in centroids) d_sq.append(min_dist) total_dist = sum(d_sq) # Sample with probability proportional to D(x)^2 r = random.uniform(0.0, total_dist) cumulative = 0.0 next_centroid = X[-1] for i, dist in enumerate(d_sq): cumulative += dist if cumulative >= r: next_centroid = X[i] break centroids.append(list(next_centroid)) return centroids def fit(self, X: List[List[float]]) -> None: random.seed(42) num_samples = len(X) num_features = len(X[0]) self.centroids = self._kmeans_plus_plus_init(X) for _ in range(self.max_iter): # Assignment Step: partition points into K clusters clusters: List[List[List[float]]] = [[] for _ in range(self.k)] for sample in X: nearest_idx = min( range(self.k), key=lambda idx: self._euclidean_dist_sq(sample, self.centroids[idx]) ) clusters[nearest_idx].append(sample) # Update Step: recompute cluster centroids as arithmetic mean new_centroids: List[List[float]] = [] max_shift = 0.0 for k in range(self.k): if not clusters[k]: # Handle empty cluster by keeping old centroid new_centroids.append(self.centroids[k]) continue cluster_size = len(clusters[k]) centroid = [ sum(sample[j] for sample in clusters[k]) / cluster_size for j in range(num_features) ] shift = math.sqrt(self._euclidean_dist_sq(centroid, self.centroids[k])) max_shift = max(max_shift, shift) new_centroids.append(centroid) self.centroids = new_centroids if max_shift < self.tol: break def predict(self, X: List[List[float]]) -> List[int]: predictions = [] for sample in X: nearest_idx = min( range(self.k), key=lambda idx: self._euclidean_dist_sq(sample, self.centroids[idx]) ) predictions.append(nearest_idx) return predictions if __name__ == "__main__": X = [ [1.0, 1.0], [1.5, 2.0], [1.2, 0.8], # Cluster 0 [8.0, 8.0], [8.5, 9.0], [9.0, 8.2], # Cluster 1 [1.0, 8.0], [1.2, 8.5], [0.8, 9.0], # Cluster 2 ] kmeans = KMeansFromScratch(k=3) kmeans.fit(X) preds = kmeans.predict(X) print("Final Centroids:", kmeans.centroids) print("Cluster Assignments:", preds)
5. Active Knowledge Checks#
In Lloyd's algorithm for k-Means, why does the centroid update step set mu_k equal to the exact arithmetic mean of assigned points?
What is the foundational theoretical advantage of k-Means++ probabilistic initialization over uniform random centroid initialization?