Ch. 08

Unsupervised Learning

k-means with the right init, DBSCAN for non-spherical, GMMs with EM derived in full.

k-meansDBSCANGMMEM

FIG 08 · Explainer video


Lloyd's algorithm for k-means, the workhorse of unsupervised clustering, was invented at Bell Labs in 1957 and not published until 1982. It is twenty lines of code. It still runs every five minutes inside production systems at companies you have heard of, segmenting customers, finding fraud rings, compressing image colors. It also famously fails on a long list of dataset shapes that look obvious to a human and impossible to k-means: concentric rings, elongated blobs, density-varying clusters. Every workaround to those failures (DBSCAN, Gaussian mixtures, spectral clustering) is itself an entire subfield, and the right pick for your specific dataset is almost always knowable from looking at a 2D projection of the data. This chapter is about how to look, what to look for, and which algorithm to reach for once you know what you have seen. By the end you will have implemented k-means from scratch, watched the EM algorithm converge a Gaussian mixture on toy data, run DBSCAN on the moons dataset, and seen anomaly detection fall out of the mixture-density viewpoint at the end.


FIG 08.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Implement k-means with the k-means++ initialization trick in 40 lines of NumPy and explain why naive random initialization can converge to terrible local minima.
  • Pick k by reading the inertia/silhouette/elbow curves and explain when each is the right diagnostic.
  • Use DBSCAN to find non-spherical, density-defined clusters and explain its two hyperparameters.
  • Fit a Gaussian Mixture Model with GaussianMixture and derive the E and M steps of EM by hand.
  • Use a fitted GMM as a generative model (sample new points) and as an anomaly detector (low log-likelihood = anomaly).
  • Apply Bayesian GMMs (BayesianGaussianMixture) to auto-select the number of components.
  • Run semi-supervised classification: cluster + propagate labels through cluster membership, achieving 80% of fully-supervised accuracy with 10% of the labels.
  • Choose between k-means, GMM, and DBSCAN for a new dataset in under five minutes by running each on a 2D projection.

FIG 08.2 · What you need first

  • Ch 4 — Training Modelsgradient descent, softmax. The EM algorithm reuses softmax shapes.
  • Ch 7 — Dimensionality ReductionPCA, especially. The PCA → cluster pipeline is the workflow for most exploratory analysis.
  • Basic probability: the multivariate Gaussian density, conditional probability, Bayes' rule. If those are unfamiliar, work 02-code-refs/amidi-cs229-prob-stats first.

If you skipped another chapter: you can survive, but most production unsupervised pipelines run dim reduction before clustering, and the joint workflow only makes sense once you have both.


FIG 08.3.1

Clustering vs classification: the unsupervised setup

Classification: you have (xi,yi)(x_i, y_i) pairs. The model learns to predict yy from xx. Clustering: you have only xix_i. The model learns to assign each xix_i to a group, where the groups are defined by similarity rather than by external labels.

The Iris dataset shows both sides cleanly. With labels: three species, separable in petal-length × petal-width. Without labels: still three clusters, recoverable by k-means with k=3. The cluster labels k-means returns will be permutations of the true species labels, but the partition is the same up to renaming.

Python
from sklearn.datasets import load_iris
from sklearn.cluster import KMeans
import numpy as np

X = load_iris().data
y_true = load_iris().target

km = KMeans(n_clusters=3, n_init=10, random_state=42)
y_pred = km.fit_predict(X)
# y_pred contains values {0, 1, 2} that are some permutation of y_true.

Three reasons matters even when labels are available.

Exploratory analysis. You do not know what classes exist until you have looked. Clustering finds the structure before you ask "what should I label these as?"

Semi-. Labels are expensive; raw data is cheap. Cluster the data, hand-label one example per cluster, propagate to the rest. §10 makes this concrete.

Anomaly detection. A trained clustering / density model assigns low to points that do not fit any cluster. Those are your anomalies.

FIG 08.3.2

K-means: the algorithm everyone runs first

K-means (Lloyd 1957, MacQueen 1967) clusters mm points into kk groups by alternating two steps until .

  1. Assignment step. Each point is assigned to its nearest centroid (Euclidean distance).
  2. Update step. Each centroid is moved to the mean of the points assigned to it.

The objective is the inertia (within-cluster sum of squares):

J(μ,z)=i=1mxiμzi2J(\boldsymbol{\mu}, \mathbf{z}) = \sum_{i=1}^{m} \|\mathbf{x}_i - \boldsymbol{\mu}_{z_i}\|^2

where zi{1,,k}z_i \in \{1, \ldots, k\} is the cluster assignment and μj\boldsymbol{\mu}_j is the centroid of cluster jj. Lloyd's algorithm is coordinate descent on this objective: fix centroids, optimize assignments; fix assignments, optimize centroids; repeat. Each step reduces (or holds) JJ, so the algorithm converges. Not necessarily to the global minimum, which is NP-hard.

sklearn KMeans vs. Lloyd's + k-means++ from scratch

Classical ML
LIBRARY
km = KMeans(n_clusters=k, init="k-means++", n_init=10, random_state=42)
labels = km.fit_predict(X)
centroids = km.cluster_centers_
inertia = km.inertia_
FROM SCRATCH
def kmeans_pp_init(X, k, rng):
    m, d = X.shape
    centroids = np.empty((k, d))
    centroids[0] = X[rng.integers(0, m)]
    for j in range(1, k):
        diffs = X[:, None, :] - centroids[None, :j, :]
        d2 = (diffs ** 2).sum(axis=-1).min(axis=1)
        probs = d2 / d2.sum() if d2.sum() > 0 else np.ones(m) / m
        centroids[j] = X[rng.choice(m, p=probs)]
    return centroids

def kmeans(X, k, n_iters=100, seed=42, init="kmeans++", n_init=10):
    rng = np.random.default_rng(seed); m, d = X.shape; best = None
    for _ in range(n_init):
        centroids = kmeans_pp_init(X, k, rng)
        labels = np.full(m, -1, dtype=int)
        for _ in range(n_iters):
            dists = ((X[:, None, :] - centroids[None, :, :]) ** 2).sum(axis=-1)
            new_labels = dists.argmin(axis=1)
            if np.array_equal(new_labels, labels): break
            labels = new_labels
            for j in range(k):
                mask = labels == j
                if mask.any(): centroids[j] = X[mask].mean(axis=0)
        inertia = float(((X - centroids[labels]) ** 2).sum(axis=-1).sum())
        if best is None or inertia < best[2]:
            best = (centroids.copy(), labels.copy(), inertia)
    return best

from scratch: lab/solution.py: kmeans (with kmeans_pp_init)

  1. 1init="k-means++" kmeans_pp_init: first centroid uniform-random, then each next point sampled with prob proportional to squared distance to nearest chosen centroid (d2 / d2.sum())
  2. 2km.fit_predict(X) assignment step dists = ((X[:,None,:]-centroids[None,:,:])**2).sum(-1); new_labels = dists.argmin(axis=1)
  3. 3km.fit_predict(X) update step for j in range(k): centroids[j] = X[labels==j].mean(axis=0)
  4. 4convergence / max_iter loop the n_iters loop with `if np.array_equal(new_labels, labels): break`
  5. 5n_init=10 (keep best run) the outer `for _ in range(n_init)` loop that keeps the run with lowest inertia in `best`
  6. 6km.inertia_ inertia = float(((X - centroids[labels]) ** 2).sum())
What the one call hides
  • Lloyd's iterations stop on tol= (centroid-shift threshold) AND max_iter=300, not just exact label equality; sklearn tracks centroid movement, not only label changes.
  • Default n_init in current sklearn is 'auto' (=1 for k-means++, =10 for random); the explicit n_init=10 is what guards against a single bad seeding dropping two centroids in one blob.
  • k-means++ in sklearn samples 2+log(k) candidate points per step (greedy variant) and keeps the best, not the single weighted draw the scratch code does.
  • Empty-cluster handling: sklearn relocates an empty cluster's centroid to the farthest point; the scratch code just leaves it unchanged via `if mask.any()`.
  • sklearn runs the elkan/lloyd algorithm with triangle-inequality pruning in float32 internally, but the underlying math is identical.
  • Gotcha: KMeans never scales features for you: a feature in dollars dominates one in years, so StandardScaler first or the clusters just encode the highest-variance column.
  • Gotcha: n_clusters is mandatory and will happily split a 2-cluster dataset into 5; inertia falling with k cannot pick k for you.
  • Gotcha: It assumes spherical, equal-size clusters (Euclidean inertia), so it silently mangles moons/rings/elongated blobs even though it 'runs fine'.
  • Gotcha: Cluster ids are arbitrary integers; label 0 in one run is not label 0 in another, so compare partitions up to permutation (adjusted_rand_score), not raw labels.

Prefer sklearn KMeans in production (faster, handles empty clusters, greedy k-means++); the scratch version exists to prove .fit() is just argmin-then-mean coordinate descent on within-cluster SSE and that k-means++ is one weighted-by-d^2 sampling line.

On the job: At work you almost never reimplement Lloyd's; you write the StandardScaler/pipeline around it, the elbow/silhouette sweep to choose k, and the adjusted_rand_score comparison to evaluate the partition.

Three failure modes worth knowing.

Failure 1: bad initialization. Random initialization can land all centroids near each other; the algorithm then converges to a partition where some clusters are empty or near-empty. n_init=10 (run from 10 random starts and keep the best) addresses this partially. K-means++ (§3) addresses it more cleanly.

Failure 2: spherical-cluster assumption. K-means assumes clusters are roughly spherical and equally-sized. For elongated or differently-sized clusters, k-means systematically misassigns the boundary points. The "spherical" assumption comes from the Euclidean distance + equal- inertia objective.

Failure 3: you have to choose k. K-means with k=5 will partition any dataset into 5 clusters, including datasets that have 2 true clusters or 10 true clusters. Diagnostic methods (§4) help but do not eliminate this problem.

FIG 08.3.3

K-means++: a smarter initialization

Random initialization can pick all kk centroids from the same cluster, which means the algorithm has to migrate them apart over many iterations and often gets stuck. K-means++ (Arthur and Vassilvitskii 2007) picks them strategically.

The recipe:

  1. Choose the first centroid uniformly at random from the data.
  2. For each subsequent centroid: pick a data point with proportional to its squared distance from the nearest already-chosen centroid.

This biases the initialization toward spreading the centroids out, which dramatically improves quality. The expected approximation ratio (against the optimal k-means objective) is O(logk)O(\log k), which is a real theoretical guarantee.

Python
def kmeans_plusplus(X: np.ndarray, k: int, rng) -> np.ndarray:
    """k-means++ init. Returns (k, d) initial centroids."""
    m, d = X.shape
    centroids = np.empty((k, d))
    # First centroid uniformly at random
    centroids[0] = X[rng.integers(0, m)]
    for j in range(1, k):
        # Distance from each point to its nearest current centroid
        dists = ((X[:, None, :] - centroids[None, :j, :]) ** 2).sum(axis=-1).min(axis=1)
        probs = dists / dists.sum()
        idx = rng.choice(m, p=probs)
        centroids[j] = X[idx]
    return centroids

sklearn's KMeans uses k-means++ by default (init="k-means++"). The only reason to ever set init="random" is to demonstrate how bad the alternative is.

FIG 08.3.4

Choosing k: inertia, silhouette, elbow

Inertia decreases monotonically as kk increases. With k=mk = m (one cluster per point) inertia is zero. So "minimize inertia" cannot pick kk.

The elbow method. Plot inertia as a function of kk from 1 to 10. Look for the "elbow" where the curve transitions from steep to flat. Pick the kk at the elbow.

Python
inertias = []
for k in range(1, 11):
    km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(X)
    inertias.append(km.inertia_)
# Plot inertias[k] for k=1..10; visually identify the elbow.

This is subjective. The elbow is sometimes sharp, often not.

Silhouette score. For each point, define a(i)a(i) as its average distance to other points in its own cluster, and b(i)b(i) as its average distance to points in the nearest other cluster. The silhouette for point ii is:

s(i)=b(i)a(i)max(a(i),b(i))[1,1]s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))} \in [-1, 1]

High silhouette = well-clustered. Low = ambiguous. Negative = probably misassigned. The dataset-level silhouette score is the mean over points.

Python
from sklearn.metrics import silhouette_score

scores = []
for k in range(2, 11):
    km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(X)
    scores.append(silhouette_score(X, km.labels_))
best_k = np.argmax(scores) + 2

Pick the kk that maximizes silhouette. Silhouette is more objective than the elbow but slower to compute (O(m2)O(m^2) for the distance matrix).

Silhouette diagrams. sklearn's silhouette score is a single scalar. The diagram (per-cluster silhouette distributions, stacked) is much more informative. Géron's Figure 9-9 shows what they look like. An evenly-distributed silhouette diagram with all clusters having similar widths and high mean silhouettes is "clean clustering". Long horizontal protrusions are warning signs.

FIG 08.3.5

K-means for image segmentation

A classical k-means application: color quantization. Treat each pixel as a 3D point in (R, G, B) space, cluster into kk colors, replace each pixel with its cluster centroid. Result: an image with at most kk unique colors.

Python
from sklearn.cluster import KMeans
from PIL import Image
import numpy as np

image = np.asarray(Image.open("ladybug.jpg")) / 255.0   # shape (H, W, 3)
H, W, _ = image.shape
pixels = image.reshape(-1, 3)

km = KMeans(n_clusters=8, n_init=10, random_state=42).fit(pixels)
segmented = km.cluster_centers_[km.labels_].reshape(H, W, 3)
Image.fromarray((segmented * 255).astype(np.uint8)).save("ladybug_k8.png")

The compressed image (8 unique colors) is recognizable but cartoonish. At k=64k = 64 it is nearly indistinguishable from the original. The compression ratio is real: storing the centroid table plus the per-pixel cluster ID is much smaller than 24-bit color per pixel.

This is not how production image codecs work. JPEG and PNG use DCT and run-length encoding respectively, both of which exploit spatial correlations between pixels in ways k-means does not. K-means color quantization is a teaching example; it is not the modern compression algorithm.

FIG 08.3.6

DBSCAN: density-based clustering for non-spherical shapes

K-means fails on the canonical "moons" and "concentric circles" datasets because its objective assumes spherical clusters. DBSCAN (Density-Based Spatial Clustering of Applications with Noise; Ester et al. 1996) makes a different assumption: clusters are dense regions separated by sparse regions.

The algorithm has two hyperparameters: eps (a distance threshold) and min_samples (a count). Each point falls into one of three categories.

Core point: has at least min_samples points (including itself) within distance eps.

Border point: not a core point, but within eps of one.

Noise point: neither core nor border. Gets label -1.

A cluster is a connected component of core points (plus their associated border points). Two core points are in the same cluster if one is within eps of the other; transitivity does the rest.

Python
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons

X, _ = make_moons(n_samples=1000, noise=0.05, random_state=42)
dbscan = DBSCAN(eps=0.05, min_samples=5)
labels = dbscan.fit_predict(X)
# labels is in {-1, 0, 1}: noise + two clusters

DBSCAN's three superpowers:

  1. Finds non-spherical clusters (moons, rings, snakes).
  2. Auto-determines the number of clusters (no kk to choose).
  3. Identifies outliers natively (label -1).

DBSCAN's weaknesses:

  1. sensitivity. eps must be tuned. Too small: every point is noise. Too large: everything is one cluster.
  2. Variable density. If your dataset has clusters of very different densities, a single eps cannot capture all of them. HDBSCAN (the hierarchical version) fixes this.
  3. High-dimensional spaces. DBSCAN's distance assumptions break in high dimensions (the curse strikes again). Use it after dim reduction.

Note on eps: the standard heuristic is to plot the kk-distance graph (distance from each point to its kk-th nearest neighbor, sorted), look for an elbow, and use that distance as eps. sklearn.neighbors.NearestNeighbors does the heavy lifting.

FIG 08.3.7

Gaussian Mixture Models: clustering as probabilistic generative modeling

K-means and DBSCAN are hard clustering: each point gets one label. Gaussian Mixture Models (GMMs) are soft clustering: each point gets a distribution over clusters.

The generative story: data is drawn from a mixture of kk Gaussian distributions, each with its own mean μj\boldsymbol{\mu}_j, covariance Σj\boldsymbol{\Sigma}_j, and prior πj\pi_j (with jπj=1\sum_j \pi_j = 1). To generate a point: sample a cluster jj from the prior π\boldsymbol{\pi}, then sample x\mathbf{x} from N(μj,Σj)\mathcal{N}(\boldsymbol{\mu}_j, \boldsymbol{\Sigma}_j).

Here N(xμ,Σ)=(2π)d/2Σ1/2exp ⁣(12(xμ)Σ1(xμ))\mathcal{N}(\mathbf{x} \mid \boldsymbol{\mu}, \boldsymbol{\Sigma}) = (2\pi)^{-d/2} |\boldsymbol{\Sigma}|^{-1/2} \exp\!\big(-\tfrac{1}{2}(\mathbf{x}-\boldsymbol{\mu})^\top \boldsymbol{\Sigma}^{-1}(\mathbf{x}-\boldsymbol{\mu})\big) is the multivariate Gaussian density in dd dimensions. The covariance matrix Σ\boldsymbol{\Sigma} is the d×dd \times d symmetric matrix whose diagonal holds each 's variance and whose off-diagonals hold the pairwise covariances; it is what sets the size, shape, and tilt of each cluster's ellipse (a diagonal Σ\boldsymbol{\Sigma} gives axis-aligned ellipses, a full Σ\boldsymbol{\Sigma} lets them rotate). The quadratic form in the exponent, (xμ)Σ1(xμ)(\mathbf{x}-\boldsymbol{\mu})^\top \boldsymbol{\Sigma}^{-1}(\mathbf{x}-\boldsymbol{\mu}), is the squared Mahalanobis distance from x\mathbf{x} to the center μ\boldsymbol{\mu} — Euclidean distance rescaled by the covariance, so it counts "how many standard deviations out" along each direction.

The likelihood:

p(x)=j=1kπjN(xμj,Σj)p(\mathbf{x}) = \sum_{j=1}^{k} \pi_j \mathcal{N}(\mathbf{x} \mid \boldsymbol{\mu}_j, \boldsymbol{\Sigma}_j)

Fitting a GMM means estimating {πj,μj,Σj}j=1k\{\pi_j, \boldsymbol{\mu}_j, \boldsymbol{\Sigma}_j\}_{j=1}^{k} that maximize the data log-likelihood.

The optimization is solved by Expectation-Maximization (EM), alternating two steps.

E step (Expectation). For each point xi\mathbf{x}_i and cluster jj, compute the responsibility γij\gamma_{ij}: the posterior probability that point ii came from cluster jj.

γij=πjN(xiμj,Σj)lπlN(xiμl,Σl)\gamma_{ij} = \frac{\pi_j \mathcal{N}(\mathbf{x}_i \mid \boldsymbol{\mu}_j, \boldsymbol{\Sigma}_j)}{\sum_l \pi_l \mathcal{N}(\mathbf{x}_i \mid \boldsymbol{\mu}_l, \boldsymbol{\Sigma}_l)}

This is just Bayes' rule — posterior=prior×likelihoodevidence\text{posterior} = \frac{\text{prior} \times \text{likelihood}}{\text{evidence}}, i.e. p(jxi)=πjN(xiμj,Σj)/p(xi)p(j \mid \mathbf{x}_i) = \pi_j \, \mathcal{N}(\mathbf{x}_i \mid \boldsymbol{\mu}_j, \boldsymbol{\Sigma}_j) \,/\, p(\mathbf{x}_i) — applied to the cluster assignment, with the denominator just normalizing the responsibilities to sum to one across clusters.

M step (Maximization). Update parameters using the responsibilities as soft weights.

πj1miγij\pi_j \leftarrow \frac{1}{m} \sum_i \gamma_{ij} μjiγijxiiγij\boldsymbol{\mu}_j \leftarrow \frac{\sum_i \gamma_{ij} \mathbf{x}_i}{\sum_i \gamma_{ij}} Σjiγij(xiμj)(xiμj)Tiγij\boldsymbol{\Sigma}_j \leftarrow \frac{\sum_i \gamma_{ij} (\mathbf{x}_i - \boldsymbol{\mu}_j)(\mathbf{x}_i - \boldsymbol{\mu}_j)^T}{\sum_i \gamma_{ij}}

Alternate E and M until . The log-likelihood is monotonically increasing under EM (Dempster, Laird, Rubin 1977).

sklearn GaussianMixture vs. EM (responsibilities + weighted MLE) from scratch

Classical ML
LIBRARY
gmm = GaussianMixture(n_components=k, covariance_type="full", reg_covar=1e-6, random_state=42).fit(X)
labels = gmm.predict(X)          # argmax responsibility
resp = gmm.predict_proba(X)      # (m, k) responsibilities
logp = gmm.score_samples(X)      # log p(x_i)
FROM SCRATCH
def gaussian_pdf(X, mu, Sigma):
    d = X.shape[1]; diff = X - mu; inv = np.linalg.inv(Sigma)
    exponent = -0.5 * np.einsum("ij,jk,ik->i", diff, inv, diff)
    det = np.linalg.det(Sigma)
    norm_const = 1.0 / np.sqrt((2 * np.pi) ** d * max(det, 1e-300))
    return norm_const * np.exp(exponent)

def fit_gmm_em(X, k, n_iters=100, seed=42, reg_covar=1e-6):
    rng = np.random.default_rng(seed); m, d = X.shape
    pi = np.ones(k) / k
    mu = X[rng.choice(m, k, replace=False)].astype(np.float64)
    Sigma = np.tile(np.eye(d), (k, 1, 1))
    for it in range(n_iters):
        # E step
        weighted = np.zeros((m, k))
        for j in range(k):
            weighted[:, j] = pi[j] * gaussian_pdf(X, mu[j], Sigma[j])
        denom = weighted.sum(axis=1, keepdims=True) + 1e-300
        gamma = weighted / denom
        # M step
        Nk = gamma.sum(axis=0) + 1e-12
        pi = Nk / m
        for j in range(k):
            mu[j] = (gamma[:, j:j+1] * X).sum(axis=0) / Nk[j]
            diff = X - mu[j]
            Sigma[j] = (gamma[:, j:j+1] * diff).T @ diff / Nk[j]
            Sigma[j] = Sigma[j] + reg_covar * np.eye(d)
    return pi, mu, Sigma, gamma

from scratch: lab/solution.py: fit_gmm_em (with gaussian_pdf, predict_gmm)

  1. 1E step inside .fit(): gmm.predict_proba(X) weighted[:,j] = pi[j]*gaussian_pdf(X, mu[j], Sigma[j]); gamma = weighted / weighted.sum(axis=1, keepdims=True) (Bayes' rule for responsibilities)
  2. 2M step weights update (gmm.weights_) Nk = gamma.sum(axis=0); pi = Nk / m
  3. 3M step means update (gmm.means_) mu[j] = (gamma[:,j:j+1] * X).sum(axis=0) / Nk[j]
  4. 4M step full-covariance update (gmm.covariances_) Sigma[j] = (gamma[:,j:j+1]*diff).T @ diff / Nk[j]
  5. 5reg_covar=1e-6 Sigma[j] = Sigma[j] + reg_covar * np.eye(d) (keeps Sigma invertible when a cluster gets few points)
  6. 6gmm.predict(X) predict_gmm: weighted.argmax(axis=1)
  7. 7the .fit() EM loop until convergence the `for it in range(n_iters)` alternating E and M
What the one call hides
  • sklearn works entirely in log-space (log-densities + logsumexp), so it does not underflow on far-from-mean points the way raw pdf * exp does; the scratch +1e-300 / +1e-12 are crude stand-ins for that.
  • Default init_params seeds means with k-means (kmeans/k-means++), not random data points, so sklearn converges faster and to better optima than the scratch random init.
  • n_init=1 by default; EM is non-convex and the result depends on the seed unless you set n_init>1 and keep the best log-likelihood.
  • Convergence is checked via change in average log-likelihood < tol (default 1e-3) with max_iter=100, not a fixed iteration count.
  • covariance_type defaults to 'full' (the case the scratch implements); 'tied'/'diag'/'spherical' change the M-step covariance math entirely.
  • sklearn factorizes via Cholesky (precisions_cholesky_) instead of np.linalg.inv/det per component, which is both faster and more stable.
  • Gotcha: A component that grabs too few points collapses its covariance to a spike (infinite likelihood); reg_covar prevents this and you can hit it if you raise n_components too high.
  • Gotcha: gmm.predict gives hard labels but the point of a GMM is the soft predict_proba / score_samples; using only predict throws away the responsibilities that justify reaching for a GMM over k-means.
  • Gotcha: Like k-means, EM only finds a local optimum and label ids are permuted between runs, so set random_state and compare partitions up to relabeling.
  • Gotcha: GMM still assumes Gaussian components, so it does not magically handle moons/rings; full covariance buys tilted ellipses, not arbitrary shapes.

Use sklearn GaussianMixture in production for its log-space stability, k-means init, and covariance-type options; build the EM loop once to internalize that the E step is Bayes' rule for responsibilities and the M step is responsibility-weighted means/covariances, the conceptual seed for VAEs and variational inference.

On the job: On the job you write the GMM around sklearn's fit: choosing covariance_type, sweeping n_components by BIC/AIC, and consuming predict_proba/score_samples for soft assignment or density-based anomaly scoring.

FIG 08.3.8

Bayesian GMMs: let the model pick the number of components

GaussianMixture(n_components=k) requires you to set kk. BayesianGaussianMixture uses a Dirichlet process prior over component weights to effectively learn kk from the data. Treat that prior as a black box for now: it is a prior over the mixture weights that favors using as few components as possible, so unused ones get pushed toward zero. You set an upper bound; the algorithm shrinks unused components' weights to near-zero.

Python
from sklearn.mixture import BayesianGaussianMixture

bgmm = BayesianGaussianMixture(n_components=10, n_init=10, random_state=42).fit(X)
print(bgmm.weights_.round(3))
# e.g., [0.34, 0.33, 0.33, 0.00, 0.00, ..., 0.00]
#       Three clusters auto-detected; the rest dropped.

The "effective" number of clusters is the count of components with non-negligible (typically > 0.01).

Python
bics = [GaussianMixture(n_components=k, n_init=10, random_state=42).fit(X).bic(X)
        for k in range(1, 11)]
best_k = np.argmin(bics) + 1

The BIC (Bayesian Information Criterion) is log(m)p2logL^\log(m)\,p - 2\log\hat{L}, where mm is the number of points, pp the number of fitted parameters, and L^\hat{L} the maximized likelihood. The first term penalizes complexity (more components means more parameters) and the second rewards fit, so minimizing BIC auto-prefers smaller kk when extra components do not improve the likelihood much.

FIG 08.3.9

Anomaly detection via density

Once you have a GMM, anomaly detection is a one-liner: compute the log-likelihood of each point under the fitted model, and threshold.

anomaly_score(x)=logp(x)\text{anomaly\_score}(\mathbf{x}) = -\log p(\mathbf{x})

High score = low density = unusual point.

Python
import numpy as np

gmm = GaussianMixture(n_components=4, n_init=10, random_state=42).fit(X_train)
log_densities = gmm.score_samples(X_test)
threshold = np.percentile(log_densities, 4)   # bottom 4% as anomalies
anomalies = X_test[log_densities < threshold]

This is a generative anomaly detector: it learns the distribution of normal data and flags points unlikely under it. Alternatives include:

  • Isolation Forest: anomalies are points easy to isolate via random trees.
  • One-class SVM: learn a boundary around the bulk of the data; points outside are anomalies.
  • Autoencoders: anomalies have high reconstruction error.

Each has its niche. GMM-based anomaly detection is interpretable (you can ask "which component is this point closest to?") and fast at . It struggles in high dimensions for the usual reasons.

FIG 08.3.10

Semi-supervised learning via clustering

You have 1000 unlabeled examples and 50 labeled ones. A fully-supervised classifier on 50 examples will overfit. A clustering of all 1050 examples, with the labels propagated through cluster membership, can dramatically beat that.

The recipe:

  1. Cluster all examples (labeled + unlabeled) with k-means or GMM, choosing kk slightly larger than the number of classes.
  2. For each cluster, look at the labels of the labeled examples that fell into it. Assign the cluster's label as the majority.
  3. Propagate that label to every unlabeled point in the cluster.
Python
from sklearn.cluster import KMeans
import numpy as np

km = KMeans(n_clusters=50, n_init=10, random_state=42).fit(X_train)
# For each cluster, find the labeled example closest to its centroid
representative_idx = np.argmin(((X_train[labeled_idx] - km.cluster_centers_[:, None, :]) ** 2)
                                .sum(axis=-1), axis=1)
# Use those labels as cluster labels, propagate to all unlabeled points
cluster_labels = y_train[labeled_idx][representative_idx]
y_propagated = cluster_labels[km.labels_]

Géron shows this on MNIST: training a logistic regression on 50 manually-labeled examples + the propagated labels of the other 950 gives ~90% , compared to ~75% for logistic regression on the 50 labeled alone.

FIG 08.3.11

The generative vs discriminative split, and what GANs do differently

GMMs are generative models: they model p(x)p(\mathbf{x}), the distribution of data. Classifiers like logistic regression are discriminative: they model p(yx)p(y \mid \mathbf{x}), the conditional distribution given features.

Generative models are more flexible. You can sample new data, detect anomalies, do imputation of missing values, and combine with prior information via Bayes' rule. They are also harder to train and often less accurate when the only goal is classification.

The 2026 frontier of generative modeling is not GMMs. It is diffusion models, variational autoencoders, and GANs (another chapter). The connection back to this chapter: GANs are trained against a discriminator, but their objective can be reinterpreted as fitting a density, which is exactly what a GMM does. The continuum from "fit a mixture of Gaussians" to "train a deep generator network" is the lineage of modern generative AI. Lilian Weng's GAN post (18-lilian-weng/2017-08-20-gan) draws this line explicitly.

For tabular and small-scale data, GMMs are still a serious tool. For images, text, audio, you need the deep stack.

FIG 08.3.12

Picking the clustering method for your dataset

A decision tree of decisions, learned by years of practitioners falling into the same traps.

Do you know k?

  • Yes, your application has a natural cluster count → k-means.
  • No, you have to discover it → BayesianGaussianMixture, DBSCAN, or BIC-tuned GMM.

Are clusters spherical (roughly equal-density, blob-shaped)?

  • Yes → k-means.
  • No, but they are smooth and density-defined → DBSCAN.
  • No, they are elongated or oddly-shaped → GMM with full covariance, or hierarchical clustering.

Do you need soft assignments / probabilities?

  • Yes → GMM (provides predict_proba).
  • No → k-means or DBSCAN.

Do you have outliers you want to identify?

  • Yes → DBSCAN (gives noise label) or GMM-based anomaly detection.
  • No → k-means.

Is your data high-dimensional?

  • Yes → run PCA first, then cluster on the reduced output.
  • No → cluster directly.

Is your dataset huge (m > 100k)?

  • Yes → MiniBatchKMeans, or DBSCAN with appropriate indexes.
  • No → standard sklearn fits.

Use 2D PCA or UMAP projections as exploratory views, not as . They can suggest candidate structure and outliers, but every projection discards or distorts information; validate any choice with metrics and diagnostics in the original space. Five minutes of visual inspection can narrow the search, but it cannot identify the right clustering algorithm by itself.


FIG 08.4 · Safety lens · this chapter

's safety story is different from classification's. There is no ground-truth label to compare to. The model learns whatever structure is in the data, which means whatever is in the data becomes the structure.

Clustering encodes whatever is most variant. If your dataset has one feature with much higher variance than others (income vs age in dollars vs years), k-means will essentially cluster on that feature. The clusters you find are "high income" vs "low income", regardless of what you wanted to discover. The mitigation is standardization (which another chapter covered), but standardization assumes you know which features should matter equally. For sensitive applications (criminal-justice risk scoring, healthcare cohorting), clustering on raw features will almost certainly bake in demographic biases. The audit is to compute per-cluster demographic composition. If any cluster is wildly demographically skewed, you have a problem regardless of the model's intent. See 05-safety/huyenchip-index §clustering-fairness and 05-safety/anthropic-research-core-views-on-ai-safety §bias.

Anomaly detection and base-rate fallacies. GMM-based anomaly detection flags low-density points as anomalies. In production, this gets deployed as "flag transactions that look unusual" or "alert when this user's behavior changes". The base-rate problem is brutal: at 0.1% prevalence, 99% plus 99% specificity produces about ten false positives per true positive. By contrast, 99% means one false positive per 99 true positives, regardless of prevalence; precision already includes the base rate. Worse: most "anomaly detection" deployments are evaluated on synthetic anomaly benchmarks where the anomalies are obvious, then deployed against real distributions where they are not. The practical standard is to require precision at a fixed alert rate (e.g., "we can review 100 alerts/day; what fraction are real?") rather than alone. See 05-safety/huyenchip-index §monitoring and 05-safety/aisafetyatlas-index §anomaly-detection.

Semi-supervised label propagation amplifies labeler bias. The §10 recipe (cluster + propagate) works because clusters tend to share labels. But if the labels you have are biased (one demographic systematically under-represented in the labeled subset), the propagation amplifies the bias across the entire dataset. The mitigation is to require labeled examples in every cluster (which is what the "active learning" workflow does), and to audit per-cluster label distributions before propagating. See 18-lilian-weng/2021-12-05-semi-supervised §pitfalls and 05-safety/huyenchip-index §labeling-bias.

Mixture models and adversarial inputs. A GMM trained on benign data can be defeated by inputs designed to fall on a Gaussian mode that the legitimate data does not occupy. This is the classical "adversarial in distribution" attack: the attacker reads your fitted GMM, finds a low-density region near a high-density one, and crafts inputs there. Mitigations are limited because the attack is informed by the model. The high-level lesson: do not deploy a single density model as a security boundary. Ensemble multiple models, or use distance-to-decision-boundary metrics that are harder to game. See 25-alignment-canon §adversarial-density-estimation and 26-pentest-redteam for the LLM analog.

What habits to adopt:

  • Always compute per-cluster demographic distributions. A 10-line pandas groupby. If clusters are demographically skewed, you have a fairness audit on your hands.
  • For anomaly detection, evaluate at fixed alert volume. Not accuracy. Not F1.
  • For semi-supervised, require labeled coverage per cluster. No cluster should propagate from zero labeled examples.


FIG 08.6 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • A working k-means in ~30 lines of NumPy, which you then break on purpose with the classic convergence-sentinel bug and fix.
  • The k-means++ initializer, with a measured before/after on a worst-case dataset.
  • A k-chooser from inertia and silhouette curves, with the honest caveat about when both lie.
  • DBSCAN on the two-moons dataset where k-means cannot win, scored against the ground-truth labels.
  • A from-scratch EM loop for a Gaussian mixture, verified to increase its own log-likelihood every step, then reused as a density-based anomaly detector.

~3 min on CPU · 92 cells · 12 checked exercises · runs in Colab


FIG 08.7 · Going further

  • 08-geron-notebooks/09_unsupervised_learning

    reference notebook. Full implementations of every method.

  • 04-stanford/cs229-main-notes-pdf §em-algorithm

    the EM derivation, clean and rigorous.

  • 23-textbooks/mackay-itila §20-22

    MacKay's chapter on EM and mixture models. The Bayesian view.

  • 01-explorables PCA / t-SNE explorables — visual companions to the dim-reduction half of the pipeline.
  • McInnes et al. HDBSCAN — the hierarchical extension of DBSCAN that handles variable-density clusters. The right next step after DBSCAN.
  • 18-lilian-weng/2021-12-05-semi-supervised

    semi-supervised learning in 2021+, including self-training and consistency regularization (the modern alternatives to label propagation).

  • 02-code-refs/amidi-cs229-unsupervised

    Amidi's cheatsheet, one-page summary of all the algorithms.

  • A Tutorial on Spectral Clustering (Ulrike von Luxburg, 2007) — for the spectral-clustering family this chapter did not cover. Read after you know k-means and DBSCAN.

FIG 08.8 · What this enables

Chapters you can now read, with the connecting idea written out.

  • GMMs are the simplest generative models. The lineage runs GMM → VAE → diffusion. Knowing EM on GMMs is the prerequisite for understanding variational inference in modern generative models.

  • policy iteration uses an EM-like alternation. The structure is the same. Knowing one helps with the other.

  • retrieval is a nearest-neighbor problem. K-means + product quantization is the canonical fast-NN data structure. Real production systems use FAISS, but FAISS is k-means under the hood.

  • clustering on neural-network activations finds interpretable feature groups. Sparse autoencoders are the modern alternative, but classical clustering still appears in early-stage exploratory interp work.


FIG 08.9 · 17 sources
  1. 01-explorables (PCA / t-SNE supporting explorables)
  2. 02-code-refs/amidi-cs229-unsupervised
  3. 02-code-refs/amidi-cs229-prob-stats
  4. 04-stanford/cs229-main-notes-pdf
  5. 05-safety/anthropic-research-core-views-on-ai-safety
  6. 05-safety/huyenchip-index
  7. 05-safety/aisafetyatlas-index
  8. 08-geron-notebooks/09_unsupervised_learning
  9. 13-fastbook/08_collab
  10. 13-fastbook/09_tabular
  11. 16-d2l-sections (clustering background)
  12. 18-lilian-weng/2017-08-20-gan
  13. 18-lilian-weng/2021-12-05-semi-supervised
  14. 23-textbooks/math4ml
  15. 23-textbooks/mackay-itila
  16. 25-alignment-canon
  17. 26-pentest-redteam