Ch. 08
Unsupervised Learning
k-means with the right init, DBSCAN for non-spherical, GMMs with EM derived in full.
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
kby 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
GaussianMixtureand 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 Models — gradient descent, softmax. The EM algorithm reuses softmax shapes.
- Ch 7 — Dimensionality Reduction — PCA, 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-statsfirst.
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 pairs. The model learns to predict from . Clustering: you have only . The model learns to assign each 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.
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 Letting a model find groupings or patterns in examples when nobody has told it the right answers.Full glossary → 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-Teaching a model from examples where you already know the right answer for each one.Full glossary →. 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 A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → 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 points into groups by alternating two steps until The point where the wrongness score stops dropping and levels off, so more training doesn't help.Full glossary →.
- Assignment step. Each point is assigned to its nearest centroid (Euclidean distance).
- 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):
where is the cluster assignment and is the centroid of cluster . Lloyd's algorithm is coordinate descent on this objective: fix centroids, optimize assignments; fix assignments, optimize centroids; repeat. Each step reduces (or holds) , so the algorithm converges. Not necessarily to the global minimum, which is NP-hard.
sklearn KMeans vs. Lloyd's + k-means++ from scratch
Classical MLkm = 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_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 bestfrom scratch: lab/solution.py: kmeans (with kmeans_pp_init)
- 1
init="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
km.fit_predict(X) assignment stepdists = ((X[:,None,:]-centroids[None,:,:])**2).sum(-1); new_labels = dists.argmin(axis=1) - 3
km.fit_predict(X) update stepfor j in range(k): centroids[j] = X[labels==j].mean(axis=0) - 4
convergence / max_iter loopthe n_iters loop with `if np.array_equal(new_labels, labels): break` - 5
n_init=10 (keep best run)the outer `for _ in range(n_init)` loop that keeps the run with lowest inertia in `best` - 6
km.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-A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → 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 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:
- Choose the first centroid uniformly at random from the data.
- For each subsequent centroid: pick a data point with A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → proportional to its squared distance from the nearest already-chosen centroid.
This biases the initialization toward spreading the centroids out, which dramatically improves The point where the wrongness score stops dropping and levels off, so more training doesn't help.Full glossary → quality. The expected approximation ratio (against the optimal k-means objective) is , which is a real theoretical guarantee.
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 centroidssklearn'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 increases. With (one cluster per point) inertia is zero. So "minimize inertia" cannot pick .
The elbow method. Plot inertia as a function of from 1 to 10. Look for the "elbow" where the curve transitions from steep to flat. Pick the at the elbow.
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 as its average distance to other points in its own cluster, and as its average distance to points in the nearest other cluster. The silhouette for point is:
High silhouette = well-clustered. Low = ambiguous. Negative = probably misassigned. The dataset-level silhouette score is the mean over points.
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) + 2Pick the that maximizes silhouette. Silhouette is more objective than the elbow but slower to compute ( 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 colors, replace each pixel with its cluster centroid. Result: an image with at most unique colors.
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 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.
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 clustersDBSCAN's three superpowers:
- Finds non-spherical clusters (moons, rings, snakes).
- Auto-determines the number of clusters (no to choose).
- Identifies outliers natively (label
-1).
DBSCAN's weaknesses:
- A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → sensitivity.
epsmust be tuned. Too small: every point is noise. Too large: everything is one cluster. - Variable density. If your dataset has clusters of very different densities, a single
epscannot capture all of them. HDBSCAN (the hierarchical version) fixes this. - 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 -distance graph (distance from each point to its -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 A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → distribution over clusters.
The generative story: data is drawn from a mixture of Gaussian distributions, each with its own mean , covariance , and prior A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → (with ). To generate a point: sample a cluster from the prior , then sample from .
Here is the multivariate Gaussian density in dimensions. The covariance matrix is the symmetric matrix whose diagonal holds each One piece of information about an example that the model looks at when making a guess.Full glossary →'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 gives axis-aligned ellipses, a full lets them rotate). The quadratic form in the exponent, , is the squared Mahalanobis distance from to the center — Euclidean distance rescaled by the covariance, so it counts "how many standard deviations out" along each direction.
The likelihood:
Fitting a GMM means estimating that maximize the data log-likelihood.
The optimization is solved by Expectation-Maximization (EM), alternating two steps.
E step (Expectation). For each point and cluster , compute the responsibility : the posterior probability that point came from cluster .
This is just Bayes' rule — , i.e. — applied to the A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → 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.
Alternate E and M until The point where the wrongness score stops dropping and levels off, so more training doesn't help.Full glossary →. The log-likelihood is monotonically increasing under EM (Dempster, Laird, Rubin 1977).
sklearn GaussianMixture vs. EM (responsibilities + weighted MLE) from scratch
Classical MLgmm = 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)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, gammafrom scratch: lab/solution.py: fit_gmm_em (with gaussian_pdf, predict_gmm)
- 1
E 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
M step weights update (gmm.weights_)Nk = gamma.sum(axis=0); pi = Nk / m - 3
M step means update (gmm.means_)mu[j] = (gamma[:,j:j+1] * X).sum(axis=0) / Nk[j] - 4
M step full-covariance update (gmm.covariances_)Sigma[j] = (gamma[:,j:j+1]*diff).T @ diff / Nk[j] - 5
reg_covar=1e-6Sigma[j] = Sigma[j] + reg_covar * np.eye(d) (keeps Sigma invertible when a cluster gets few points) - 6
gmm.predict(X)predict_gmm: weighted.argmax(axis=1) - 7
the .fit() EM loop until convergencethe `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 . BayesianGaussianMixture uses a Dirichlet process prior over component weights to effectively learn 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.
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 A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → (typically > 0.01).
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) + 1The BIC (Bayesian Information Criterion) is , where is the number of points, the number of fitted parameters, and the maximized likelihood. The first term penalizes complexity (more components means more parameters) and the second rewards fit, so minimizing BIC auto-prefers smaller 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.
High score = low density = unusual point.
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 Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →. 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:
- Cluster all examples (labeled + unlabeled) with k-means or GMM, choosing slightly larger than the number of classes.
- For each cluster, look at the labels of the labeled examples that fell into it. Assign the cluster's label as the majority.
- Propagate that label to every unlabeled point in the cluster.
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% The share of guesses the model got right out of all its guesses.Full glossary →, 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 , the distribution of data. Classifiers like logistic regression are discriminative: they model , 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 The correct, human-given answer for a piece of data, used to judge the model's guess.Full glossary →. They can suggest candidate structure and outliers, but every projection discards or distorts information; validate any choice with metrics and diagnostics in the original One piece of information about an example that the model looks at when making a guess.Full glossary → 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
Letting a model find groupings or patterns in examples when nobody has told it the right answers.Full glossary →'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 A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → is in the data becomes the structure.
Clustering encodes whatever One piece of information about an example that the model looks at when making a guess.Full glossary → 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% Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → plus 99% specificity produces about ten false positives per true positive. By contrast, 99% Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → 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 The share of guesses the model got right out of all its guesses.Full glossary → 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_learningreference notebook. Full implementations of every method.
04-stanford/cs229-main-notes-pdf §em-algorithmthe EM derivation, clean and rigorous.
23-textbooks/mackay-itila §20-22MacKay's chapter on EM and mixture models. The Bayesian view.
01-explorablesPCA / 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-supervisedsemi-supervised learning in 2021+, including self-training and consistency regularization (the modern alternatives to label propagation).
02-code-refs/amidi-cs229-unsupervisedAmidi'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
- 01-explorables (PCA / t-SNE supporting explorables)
- 02-code-refs/amidi-cs229-unsupervised
- 02-code-refs/amidi-cs229-prob-stats
- 04-stanford/cs229-main-notes-pdf
- 05-safety/anthropic-research-core-views-on-ai-safety
- 05-safety/huyenchip-index
- 05-safety/aisafetyatlas-index
- 08-geron-notebooks/09_unsupervised_learning
- 13-fastbook/08_collab
- 13-fastbook/09_tabular
- 16-d2l-sections (clustering background)
- 18-lilian-weng/2017-08-20-gan
- 18-lilian-weng/2021-12-05-semi-supervised
- 23-textbooks/math4ml
- 23-textbooks/mackay-itila
- 25-alignment-canon
- 26-pentest-redteam