Ch. 07

Dimensionality Reduction

Curse of dimensionality, PCA from SVD, t-SNE done right, the UMAP→cluster pipeline.

PCAt-SNEUMAP

FIG 07 · Explainer video


A handwritten digit is a 784-pixel vector. A movie rating is a 17,000-user vector. A sentence embedded by a modern LLM is a 4096-dim vector. The thing those three objects have in common is that almost none of those dimensions actually matter. Digit images live on a low-dimensional manifold defined by stroke direction, stroke thickness, and where the loops are. Movie ratings live on a low-dimensional manifold of taste. Sentence embeddings live on a manifold of meaning. The dimensions you got are not the dimensions the data has. Dimensionality reduction is the art of finding the dimensions the data has, and the field has accumulated a half-dozen widely-used algorithms with sharply different assumptions and failure modes. PCA, the classical linear move. t-SNE, the one that makes pretty pictures and lies about distances. UMAP, the one that does both better but in subtler ways. By the end of this chapter you will have implemented PCA from SVD upward, run t-SNE on MNIST, read the Distill explorable about why t-SNE outputs cannot be trusted at face value, and know which method to pick for which task.


FIG 07.1 · Learning outcomes

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

  • Implement PCA from scratch via SVD in 20 lines of NumPy and explain the relationship between SVD, eigendecomposition of $\mathbf{X}^T \mathbf{X}$, and principal components.
  • Pick the number of components by reading an explained-variance curve.
  • Use IncrementalPCA to handle datasets that do not fit in RAM and KernelPCA to extract nonlinear components.
  • Apply Locally Linear Embedding and Random Projection, and explain when each beats PCA.
  • Run t-SNE on MNIST and produce the canonical "ten clusters" plot. Then read 01-explorables/distill-misread-tsne and understand why most t-SNE plots get misread.
  • Apply UMAP and articulate the three things UMAP does better than t-SNE (preserves global structure, faster, deterministic given seed).
  • Build a practical pipeline: PCA → UMAP → cluster, the workflow that powers most production exploratory data analysis.

FIG 07.2 · What you need first

Optional but recommended: Ch 6 — Ensemble Methods for the "PCA + RF pipeline" recipe in §11.


FIG 07.3.1

The curse of dimensionality

In one dimension, a "neighborhood" of size 0.1 around a point covers 10% of a [0,1][0, 1] interval. In 100 dimensions, a hypercube of side 0.1 covers 1010010^{-100} of the unit hypercube. Everything is far from everything. This is the curse of dimensionality. Three consequences worth internalizing.

Most of the volume of a high-dim hypercube is in the corners. Pick a uniform random point in the dd-dim unit hypercube. As dd grows, the point is almost certainly far from the center and close to a corner. Visualizing this on a 2D sphere does not generalize.

Distances concentrate. For points drawn from any reasonable distribution in high dimensions, the ratio of max-pairwise-distance to min-pairwise-distance approaches 1. Nearest neighbor and farthest neighbor become almost the same point.

Random projections preserve distances surprisingly well. Johnson-Lindenstrauss (1984) showed that you can project from dd to k=O(logn/ϵ2)k = O(\log n / \epsilon^2) dimensions with random Gaussian matrices and preserve all pairwise distances to relative error ϵ\epsilon. This is a remarkable result and the basis for random-projection-based dim reduction.

The pragmatic move: assume your data lives on a low-dimensional manifold embedded in the high-dimensional space, and find a representation that respects that manifold. PCA assumes the manifold is a linear subspace. t-SNE and UMAP assume it is a smooth nonlinear surface.

FIG 07.3.2

PCA from first principles

Principal Component Analysis finds the orthonormal directions in space along which the data has the most variance. Here "variance" means the spread of the point cloud along a direction (not the -variance sense): project every point onto a candidate axis, and the variance of those projections measures how stretched-out the data is along it.

Formally, given centered data XRm×d\mathbf{X} \in \mathbb{R}^{m \times d} (each column mean-zero), the first principal component is the unit vector w1\mathbf{w}_1 that maximizes:

Var(Xw1)=1mw1TXTXw1\text{Var}(\mathbf{X} \mathbf{w}_1) = \frac{1}{m} \mathbf{w}_1^T \mathbf{X}^T \mathbf{X} \mathbf{w}_1

subject to w12=1\|\mathbf{w}_1\|_2 = 1. By Lagrange multipliers (or by recognizing the Rayleigh quotient, a standard maximize-a-ratio trick whose name you can safely ignore here), the solution is the top eigenvector of XTX\mathbf{X}^T \mathbf{X}. (For centered data, 1mXTX\frac{1}{m}\mathbf{X}^T \mathbf{X} is the covariance matrix: its diagonal holds each feature's variance and its off-diagonal entries hold how features co-vary in pairs.) An eigenvector of a matrix is a direction the matrix only stretches, never rotates: Av=λv\mathbf{A}\mathbf{v} = \lambda\mathbf{v}, and the stretch factor λ\lambda is its eigenvalue.

The second principal component is the unit vector that maximizes the same quantity, subject to being orthogonal to the first. It is the second eigenvector. And so on. The kk-th principal component is the kk-th eigenvector of XTX\mathbf{X}^T \mathbf{X}, ordered by decreasing eigenvalue.

Equivalently (and this is the production formulation): compute the SVD of X\mathbf{X}:

X=UΣVT\mathbf{X} = \mathbf{U} \boldsymbol{\Sigma} \mathbf{V}^T

The SVD factors any matrix into three pieces: U\mathbf{U} (m×mm \times m) and V\mathbf{V} (d×dd \times d) have orthonormal columns (unit-length and mutually at right angles), and Σ\boldsymbol{\Sigma} is diagonal, holding the non-negative singular values in decreasing order. The columns of V\mathbf{V} are the principal components. The singular values σi=Σii\sigma_i = \Sigma_{ii} relate to the eigenvalues of XTX\mathbf{X}^T\mathbf{X} by λi=σi2\lambda_i = \sigma_i^2. If you instead eigendecompose the population covariance XTX/m\mathbf{X}^T\mathbf{X}/m, its eigenvalues are σi2/m\sigma_i^2/m; the sample-variance convention used by sklearn divides by m1m-1. Projecting onto the first kk components means taking each point's dot product with those kk orthonormal directions, which keeps only its coordinates along them:

Z=XV:k=U:kΣ:k\mathbf{Z} = \mathbf{X} \mathbf{V}_{:k} = \mathbf{U}_{:k} \boldsymbol{\Sigma}_{:k}

sklearn PCA vs. from scratch (SVD)

Classical ML
LIBRARY
pca = PCA(n_components=5)
Z = pca.fit_transform(X)              # mean-center + SVD + project
print(pca.explained_variance_ratio_)
X_recon = pca.inverse_transform(Z)   # lossy decompress
FROM SCRATCH
def fit(self, X):
    X = np.asarray(X, dtype=np.float64)
    self.mean = X.mean(axis=0)
    Xc = X - self.mean
    U, s, Vt = np.linalg.svd(Xc, full_matrices=False)
    self.components = Vt[:self.n_components]
    N = X.shape[0]
    total_var = (s ** 2).sum() / max(1, N - 1)
    self.explained_variance = (s[:self.n_components] ** 2) / max(1, N - 1)
    self.explained_variance_ratio = self.explained_variance / total_var
    return self

def transform(self, X):
    return (X - self.mean) @ self.components.T

def inverse_transform(self, Z):
    return Z @ self.components + self.mean

from scratch: lab/solution.py: MyPCA.fit / MyPCA.transform / MyPCA.inverse_transform

  1. 1pca.fit_transform(X) (the fit half) self.mean = X.mean(axis=0); Xc = X - self.mean; U, s, Vt = np.linalg.svd(Xc, full_matrices=False) -- mean-center, then thin SVD
  2. 2pca.components_ (the (n_components, d) basis) self.components = Vt[:self.n_components] -- the top-k right singular vectors as rows
  3. 3pca.transform(X) / the transform half of fit_transform (X - self.mean) @ self.components.T -- project centered data onto the k directions
  4. 4pca.explained_variance_ (s[:self.n_components] ** 2) / max(1, N - 1) -- squared singular values, Bessel-corrected by N-1
  5. 5pca.explained_variance_ratio_ self.explained_variance / total_var, total_var = (s ** 2).sum() / (N - 1)
  6. 6pca.inverse_transform(Z) Z @ self.components + self.mean -- un-project and re-add the mean (lossy)
What the one call hides
  • Automatic mean-centering: sklearn subtracts the column mean before SVD; drop that centering and your axes pass through the origin instead of the data centroid.
  • svd_solver='auto' switches to randomized/truncated SVD for large matrices instead of the full np.linalg.svd here -- same top components, far less compute when d or k is large.
  • svd_flip pins a deterministic sign per component (largest-magnitude loading positive); raw SVD leaves signs arbitrary, so scratch coordinates can be sign-flipped per axis.
  • The N-1 (Bessel) variance convention is baked in; divide by N (or not at all) and explained_variance_ comes out wrong.
  • Extra one-liners the scratch class skips: whiten=True, and n_components=0.95 'keep 95% of variance' auto-selection.
  • Gotcha: PCA only centers, it does NOT scale features -- on mixed-unit data a high-magnitude feature dominates the components, so StandardScaler first is almost always right.
  • Gotcha: explained_variance_ratio_ is variance retained, NOT signal retained: discriminative info can live in low-variance directions PCA discards.
  • Gotcha: Component signs are arbitrary, so scratch Z won't equal sklearn Z elementwise -- compare |loadings| or reconstructions, not raw signed coordinates.
  • Gotcha: n_components must be <= min(n_samples, n_features); passing a float in (0,1) silently flips to variance-threshold mode.

Prefer sklearn PCA in production (randomized SVD, deterministic svd_flip, whitening, IncrementalPCA for out-of-core); the from-scratch SVD exists to prove PCA is literally 'center, take the top right-singular vectors, project', and that explained variance is just squared singular values over N-1.

On the job: At work you call PCA inside a Pipeline and tune n_components (or the 0.95 ratio) against downstream metrics; you almost never hand-roll the SVD.

Note (sign flips): the sign of each principal component is arbitrary; SVD does not pin it down. Different implementations may flip signs of components, which means X_2d from sklearn and from your scratch impl can differ in sign. The reconstructions and explained variances are identical; only the absolute coordinates differ.

Caveat (centering): PCA requires centering. Without it, you compute the principal axes of XTX\mathbf{X}^T \mathbf{X} rather than the centered covariance, which gives directions that pass through the origin rather than through the data centroid. Always subtract the mean.

FIG 07.3.3

Picking the number of components

Two heuristics.

Cumulative explained variance. Sort the eigenvalues decreasing, compute cumulative sums normalized to 1. The number of components you keep is the smallest kk such that the cumulative explained variance exceeds your threshold (commonly 0.95).

Python
import numpy as np

pca = PCA().fit(X)
cumsum = np.cumsum(pca.explained_variance_ratio_)
d = np.argmax(cumsum >= 0.95) + 1
print(f"Need {d} components for 95% variance")

Or, in one line, sklearn does it for you:

Python
pca = PCA(n_components=0.95)
X_reduced = pca.fit_transform(X)
print(X_reduced.shape)   # (m, d) where d is the auto-chosen count

The elbow. Plot explained variance per component. There is often a sharp "elbow" where additional components add little. Pick the elbow. This is more subjective than the variance-threshold method but is often the right call when downstream models are robust to the exact dimensionality.

FIG 07.3.4

PCA for compression and decompression

Once you have the components, you can compress data by projecting and decompress by inverse-projecting. The decompression is lossy: you cannot recover the variance you threw away.

Python
pca = PCA(n_components=154)   # 95% variance on MNIST
X_reduced = pca.fit_transform(X_train)
X_recovered = pca.inverse_transform(X_reduced)

For MNIST, 784154784 \to 154 dimensions preserves 95% of variance and the recovered images are still recognizable. The compression ratio (5.1x) is modest by modern standards (autoencoders and both do better), but PCA's compression is linear, which means you can do it analytically in either direction and you have a closed form for the reconstruction error.

Python
import numpy as np
recon_error = np.mean((X_train - X_recovered) ** 2)
# Equals sum of variances thrown away (the trailing eigenvalues).

PCA gives you the optimal linear reconstruction. No other linear projection of the same dimension achieves lower reconstruction error. This is the Eckart-Young theorem and it is one of the cleanest optimality results in ML.

FIG 07.3.5

Incremental PCA: when the data does not fit in memory

PCA materializes the full m×dm \times d data matrix. For large mm, this fails.

Incremental PCA processes the data in mini-batches, updating the components after each . Same end result, much less memory.

Python
from sklearn.decomposition import IncrementalPCA
import numpy as np

n_batches = 100
inc_pca = IncrementalPCA(n_components=154)
for X_batch in np.array_split(X_train, n_batches):
    inc_pca.partial_fit(X_batch)
X_reduced = inc_pca.transform(X_train)

The math: each partial_fit call does a thin SVD of the new batch combined with the current component estimate. This converges to the full PCA as batches accumulate, with a small loss compared to running full PCA at once.

IncrementalPCA also supports numpy.memmap for datasets that exceed RAM:

Python
filename = "mnist_train.mmap"
X_mm = np.memmap(filename, dtype="float32", mode="readonly", shape=(60000, 784))
inc_pca.fit(X_mm)

FIG 07.3.6

Random projections: cheap and surprisingly effective

The Johnson-Lindenstrauss lemma says you can project from dd to k=O(logm/ϵ2)k = O(\log m / \epsilon^2) dimensions with a random matrix and preserve all pairwise distances within (1±ϵ)(1 \pm \epsilon). The projection matrix has i.i.d. Gaussian entries with appropriate .

This is a deterministic guarantee about a random projection. You do not train anything. You generate a random matrix and multiply.

Python
from sklearn.random_projection import GaussianRandomProjection

# Project to a dimension that JL guarantees preserves distances within 10%
gauss_rp = GaussianRandomProjection(eps=0.1)
X_rp = gauss_rp.fit_transform(X)
print(X_rp.shape[1])   # auto-selected by JL bound

When does this beat PCA? Two cases.

Speed. Random projection is O(mdk)O(m \cdot d \cdot k), much faster than PCA's O(md2+d3)O(m \cdot d^2 + d^3) for large dd. For NLP-style sparse features (dd in the millions), random projection trains in seconds where PCA takes hours.

Privacy. Random projection destroys the original semantics in a way that PCA does not (PCA's components are linear combinations that can sometimes be inverted with side information). Random projection is the basis for several differentially-private dim-reduction methods.

SparseRandomProjection is a variant where the projection matrix is sparse, which is even faster. Same JL guarantee.

FIG 07.3.7

Kernel PCA: PCA in feature space

PCA finds linear principal axes. If your data lives on a curved manifold, the linear axes do not capture it. that the kernel trick lets you operate in implicit nonlinear spaces. Kernel PCA applies the trick to PCA.

Instead of computing eigenvectors of XTX\mathbf{X}^T \mathbf{X}, compute eigenvectors of the kernel Gram matrix Kij=K(xi,xj)\mathbf{K}_{ij} = K(\mathbf{x}_i, \mathbf{x}_j). The components are eigenvectors in the implicit feature space, and you can project new points via K(x,xi)K(\mathbf{x}, \mathbf{x}_i) for training points xi\mathbf{x}_i.

Python
from sklearn.decomposition import KernelPCA

rbf_pca = KernelPCA(n_components=2, kernel="rbf", gamma=0.04)
X_reduced = rbf_pca.fit_transform(X)

For the Swiss Roll dataset (a 2D manifold curled up in 3D), Kernel PCA with the RBF kernel can "unroll" the manifold, while linear PCA only finds the principal axes of the .

Hyperparameters: kernel (linear / rbf / poly / ), gamma (for RBF), degree (for poly). Tune by cross-validation on the downstream task.

FIG 07.3.8

Locally Linear Embedding (LLE)

PCA and kernel PCA assume globally smooth structure. Locally Linear assumes only local smoothness: each point can be reconstructed as a linear combination of its kk nearest neighbors. The embedding preserves these local reconstruction weights.

The algorithm:

  1. For each point xix_i, find the kk nearest neighbors and solve for weights WijW_{ij} such that xijWijxjx_i \approx \sum_j W_{ij} x_j with jWij=1\sum_j W_{ij} = 1.
  2. Find a low-dim embedding zi\mathbf{z}_i that preserves these weights: zijWijzj\mathbf{z}_i \approx \sum_j W_{ij} \mathbf{z}_j.

Step 2 reduces to an eigenvalue problem on the matrix (IW)T(IW)(\mathbf{I} - \mathbf{W})^T (\mathbf{I} - \mathbf{W}), with the embedding being the eigenvectors corresponding to the smallest nonzero eigenvalues.

Python
from sklearn.manifold import LocallyLinearEmbedding

lle = LocallyLinearEmbedding(n_components=2, n_neighbors=10, random_state=42)
X_unrolled = lle.fit_transform(X_swiss_roll)

LLE works well on smooth manifolds without holes or sharp folds. On real-world data with noise, it is sensitive to the choice of n_neighbors. Too few neighbors and the manifold falls apart; too many and you recover something close to PCA.

FIG 07.3.9

t-SNE: the visualization-only algorithm

t-SNE (t-distributed Stochastic Neighbor , van der Maaten and Hinton 2008) is the algorithm responsible for almost every "look at how the clusters separate" plot you have seen in an ML paper.

The recipe at a high level:

  1. In high-dim space, compute pairwise affinities pijp_{ij} that represent the that point jj would be picked as ii's neighbor (Gaussian-weighted distance, normalized per point).
  2. In low-dim space, define similar affinities qijq_{ij} using a Student-t distribution (heavier tails than Gaussian).
  3. Find the low-dim embedding that minimizes KL divergence ijpijlog(pij/qij)\sum_{ij} p_{ij} \log (p_{ij} / q_{ij}) via . KL divergence measures how far one probability distribution sits from another (here, the low-dim affinities qq from the high-dim affinities pp); it is zero only when they match and grows as they diverge.

The Student-t tails (instead of Gaussian) are critical: they allow distant clusters to spread out without artificially clustering far points together. This is the "t" in t-SNE.

Python
from sklearn.manifold import TSNE
from sklearn.datasets import fetch_openml

mnist = fetch_openml('mnist_784', as_frame=False)
X, y = mnist.data[:5000], mnist.target[:5000]

tsne = TSNE(n_components=2, perplexity=30, random_state=42, init="pca")
X_2d = tsne.fit_transform(X)

Hyperparameters that matter:

  • perplexity (5 to 50). Effectively "how many nearest neighbors to consider". Smaller emphasizes local structure; larger emphasizes global. Try 5, 30, 100 and compare.
  • learning_rate (200 to 1000). The Distill paper recommends 200 for most cases.
  • init: "pca" is more stable than "random"; use it.

Caveat (the central one): t-SNE distances are not meaningful. The output preserves neighborhoods, not distances. Two clusters that look far apart in the t-SNE plot are not necessarily far apart in the original space. Cluster sizes in t-SNE plots are also meaningless. The Distill explorable "How to Use t-SNE Effectively" (01-explorables/distill-misread-tsne) is mandatory reading before you put a t-SNE plot in a paper.

The explorable shows: t-SNE can produce different cluster arrangements with different perplexities. t-SNE can show clusters that are not really there (especially at low perplexity on small datasets). t-SNE can fail to show clusters that are there (at high perplexity).

Treat t-SNE primarily as a visualization tool. Its stochastic, non-parametric embedding distorts global geometry, so feeding a fitted plot directly to a downstream classifier is usually a poor default and complicates out-of-sample . If you evaluate a parametric or otherwise reproducible embedding as a transform, fit it inside each training fold and compare it against simpler baselines without leakage.

FIG 07.3.10

UMAP: t-SNE with a cleaner foundation

UMAP (McInnes et al. 2018) is the modern replacement for t-SNE for most visualization tasks. It is faster, scales to millions of points, and (importantly) preserves more global structure.

The math is more involved than t-SNE: UMAP builds a topological representation of the data as a weighted graph, constructs a corresponding low-dim graph, and minimizes between the two. The output looks similar to t-SNE in spirit but with different distortion properties.

Python
# pip install umap-learn
import umap
reducer = umap.UMAP(n_neighbors=15, min_dist=0.1, n_components=2, random_state=42)
X_umap = reducer.fit_transform(X)

Why UMAP often beats t-SNE:

  • Speed. UMAP is roughly 10x faster than t-SNE for large datasets.
  • . UMAP is deterministic given a ; t-SNE is not ( on a non-convex objective).
  • Global structure. UMAP preserves more of the macro-arrangement of clusters than t-SNE does. Two clusters that are "actually" far in the high-dim space tend to be far in the UMAP output.
  • new points. UMAP supports umap.UMAP.transform(X_new) for embedding new data; t-SNE does not without re-fitting.

Why UMAP can still mislead: the same caveats apply in milder form. UMAP cluster sizes are not strictly meaningful. UMAP can still produce artifacts at extreme values. Treat both t-SNE and UMAP as visualization tools, not as extractors.

FIG 07.3.11

The production workflow: PCA → UMAP → cluster

For exploratory analysis of a new high-dim dataset, the canonical pipeline in 2026:

  1. Standardize the features (StandardScaler).
  2. PCA to 50 components. This denoises and speeds up the next step.
  3. UMAP to 2 components. For visualization.
  4. Cluster the UMAP output (k-means or DBSCAN) to find groups.
  5. Sanity check by running a downstream classifier on the PCA output (not the UMAP output, which throws away too much information).
Python
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
import umap

pipeline = Pipeline([
    ("scaler", StandardScaler()),
    ("pca", PCA(n_components=50)),
])
X_50 = pipeline.fit_transform(X)
X_2d = umap.UMAP(n_neighbors=15, random_state=42).fit_transform(X_50)

The PCA step before UMAP is the trick. UMAP on raw 4096-dim embeddings is slow and unstable. UMAP on 50-dim PCA output is fast and stable. The PCA preserves enough variance to keep the structure, while throwing away the noise that confuses UMAP.

FIG 07.3.12

Modern context: where dim reduction sits in 2026

Classical dim reduction (PCA, kernel PCA, LLE) is mostly obsolete as a extractor for modern ML. Neural networks learn their own representations end-to-end, and the learned representations dominate hand-engineered or PCA-based features for every perceptual task.

Where dim reduction still matters:

  • Visualization (t-SNE, UMAP for spaces). When you train a CLIP model and want to see what its image embeddings look like, you reach for UMAP.
  • Tabular ML pre-processing (PCA). When you have 1000 highly-correlated features in a tabular dataset, PCA to 50 components plus XGBoost beats XGBoost on the raw 1000 features in many cases.
  • Anomaly detection. PCA reconstruction error is a anomaly score. Points that PCA cannot reconstruct well are outliers.
  • Compression. PCA is the simplest form of dimensionality compression and is still useful when you need a fast, interpretable, linear compression.
  • Interpretability. research uses PCA on activations to find interpretable directions in neural network feature spaces (22-anthropic-recent/2024-scaling-monosemanticity builds on this idea, generalizing to sparse autoencoders).

The single most important modern application is the last one: PCA on neural network internal states reveals interpretable structure, which is the foundation of mech-interp. The same algorithm Pearson invented in 1901 is still load-bearing.


FIG 07.4 · Safety lens · this chapter

Dimensionality reduction looks innocuous. It is the most lossy operation in your pipeline and the most common source of "model was great, then we re-tuned and it broke" stories.

PCA-induced shortcut amplification. PCA finds high-variance directions. If your high-variance directions are correlated with a protected attribute, PCA preserves them. A model trained on PCA-reduced features then over-relies on that direction, and the original- audit trail is destroyed. The fix is to run a per-component audit: for each principal component, compute correlation with every protected attribute. Components with high correlation either get dropped or get explicitly mean-centered per subgroup. See 05-safety/huyenchip-index §fairness-audits and 08-geron-notebooks/08_dimensionality_reduction §pca-fairness (if covered).

t-SNE / UMAP plots in papers and dashboards. This is the most common failure I see in ML papers. A figure labeled "UMAP of model X's embeddings" with cluster X labeled "concept A" and cluster Y labeled "concept B", presented as evidence that the model learned the concepts. The Distill explorable on t-SNE (01-explorables/distill-misread-tsne) shows that with different you can produce 5 different cluster arrangements from the same data, all of which look "publishable". Without showing the sweep, the figure is uninterpretable. The 2026 norm is to publish embeddings + the exact reducer config, so readers can re-run. The 2026 reality is that almost nobody does this. For your own work: always publish the , the perplexity / n_neighbors, and ideally the vectors themselves.

Inverse projection attacks. Random projection (and to a lesser extent PCA) is sometimes proposed as a privacy mechanism: project the data, share the projection, the original features are "safe". This is mostly false. With side information (auxiliary public data with overlapping features) an attacker can often invert the projection. For real privacy guarantees, use differentially-private dim reduction (DP-PCA) or do not share at all. See 25-alignment-canon and 05-safety/aisafetyatlas-index §privacy.

Mech-interp uses of PCA: the right way and the wrong way. Recent interpretability research relies on PCA-style decompositions of model activations to find interpretable directions. This works when the model's features are roughly linear; it fails when features are non-linear or superpositioned. Anthropic's 22-anthropic-recent/2023-superposition-composition-index showed that neural networks can pack more features than dimensions via superposition, and PCA on superposed features can produce uninterpretable axes. The mitigation, currently being deployed, is sparse autoencoders (22-anthropic-recent/2024-scaling-monosemanticity), which extract sparse over-complete bases. The lesson: PCA is a tool, not a truth-teller. Confirm any PCA-based interpretability claim with an alternative basis.

What habits to adopt:

  • Per-component correlation audit when using PCA in production. A 20-line pandas script.
  • Publish the seed and full hyperparameters for any t-SNE / UMAP plot. Non-negotiable.
  • Do not use random projection as a privacy primitive. Use DP techniques.

FIG 07.5 · Under the hood

The library call, and the lines it hides

You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.

sklearn KMeans vs. from scratch (Lloyd's algorithm)

Classical ML
LIBRARY
km = KMeans(n_clusters=4, n_init=10, random_state=42)
labels = km.fit_predict(X)
centroids = km.cluster_centers_
FROM SCRATCH
rng = np.random.default_rng(random_state)
m, d = X.shape
idx = rng.choice(m, size=k, replace=False)
centroids = X[idx].copy().astype(np.float64)
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):
        labels = new_labels
        break
    labels = new_labels
    for j in range(k):
        mask = labels == j
        if mask.any():
            centroids[j] = X[mask].mean(axis=0)
return centroids, labels

from scratch: lab/solution.py: kmeans

  1. 1KMeans(n_clusters=k) + random_state (init step) idx = rng.choice(m, size=k, replace=False); centroids = X[idx] -- seed centroids from random data points
  2. 2km.fit(X) -- assignment step dists = ((X[:,None,:] - centroids[None,:,:])**2).sum(-1); new_labels = dists.argmin(axis=1) -- assign each point to its nearest centroid by squared Euclidean distance
  3. 3km.fit(X) -- update step for j in range(k): centroids[j] = X[labels==j].mean(axis=0) -- move each centroid to its members' mean
  4. 4convergence (tol / max_iter) if np.array_equal(new_labels, labels): break, inside for _ in range(n_iters)
  5. 5km.labels_ / km.fit_predict(X) the returned labels array
  6. 6km.cluster_centers_ the returned centroids array
What the one call hides
  • Smart init: sklearn defaults to init='k-means++', which spreads seeds; the scratch version uses plain uniform-random data points and lands in worse local minima more often.
  • Multiple restarts: KMeans runs n_init times (default 'auto', ~10) and keeps the lowest-inertia run; the scratch code does a single run and returns whatever local minimum it hit.
  • Empty-cluster handling: sklearn reassigns a centroid when a cluster empties; the scratch `if mask.any()` guard just freezes an emptied centroid at its previous position.
  • Convergence is on centroid shift vs tol plus max_iter (default 300), and it tracks inertia (sum of squared distances) -- the scratch loop only checks label equality and never computes inertia.
  • BLAS-backed / 'lloyd' pairwise distances instead of the memory-heavy (m, k, d) broadcast used here.
  • Gotcha: K-means uses Euclidean distance and assumes round, equal-variance clusters -- it fails on elongated or differently-scaled clusters, so scale features first.
  • Gotcha: A single run lands in a seed-dependent local minimum; without n_init restarts you can get visibly wrong clusters (the scratch impl is single-run).
  • Gotcha: You must pick k yourself; nothing here chooses it, and a wrong k gives confidently wrong clusters with no warning.
  • Gotcha: Plain random init (scratch) can place two seeds on the same point and produce empty clusters; k-means++ largely avoids this.

Use sklearn KMeans in production for k-means++ init, n_init restarts, empty-cluster handling, and BLAS-speed distances; the from-scratch loop is to internalize that k-means is just 'assign to nearest mean, recompute means, repeat until labels stop changing'.

On the job: At work you call KMeans (or MiniBatchKMeans for scale), pick k via elbow/silhouette, and scale inputs first -- you don't reimplement Lloyd's loop.



FIG 07.7 · 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

  • MyPCA, principal component analysis from the SVD up in ~20 lines of NumPy, checked against scikit-learn's PCA to the last digit of variance.
  • An explained-variance curve you read to choose how many components to keep, then a compress-then-reconstruct loop that shows what each discarded component cost you.
  • A Johnson-Lindenstrauss random projection that crushes 5000 dimensions to a few hundred and provably keeps every pairwise distance.
  • A t-SNE map of the digits, plus the experiment that proves its distances are not real.
  • A k-means clustering pipeline you first break with a one-line convergence bug, watch fail, then repair.

~3 min on CPU · 109 cells · 14 checked exercises · runs in Colab


FIG 07.8 · Going further

  • 08-geron-notebooks/08_dimensionality_reduction

    the reference notebook.

  • 01-explorables/distill-misread-tsne

    Wattenberg et al.'s "How to Use t-SNE Effectively". Mandatory.

  • 01-explorables/setosa-principal-component-analysis

    Setosa's PCA explorable. The clearest intuition resource for PCA.

  • 23-textbooks/math4ml §pca

    rigorous math derivation including the Eckart-Young optimality result.

  • McInnes et al. UMAP (arXiv 1802.03426) — the UMAP paper. Read the topological-foundations section if you want the math.
  • Sparse Autoencoders Find Highly Interpretable Features in Language Models (Anthropic, 2023; 22-anthropic-recent/2023-monosemantic-features-index) — the modern successor to PCA for neural network interpretability.
  • 22-anthropic-recent/2024-scaling-monosemanticity

    the scaling story for sparse-autoencoder-based dim reduction on production-scale models.

  • 05-safety/nanda-mech-interp-glossary

    Nanda's mech-interp glossary, includes the PCA-on-residual-stream technique.


FIG 07.9 · What this enables

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

  • clustering on PCA-reduced features is the canonical pipeline. K-means in 50 dimensions works; K-means in 4096 dimensions does not.

  • PCA on neural network activations is the building block. Sparse autoencoders are the modern extension.

  • low-rank approximations to weight matrices (LoRA, LoFTR) are PCA-of-weight-matrices in spirit.

  • word2vec and sentence embeddings are continuous low-dim representations of discrete tokens. Modern alternative to PCA on bag-of-words.


FIG 07.10 · 18 sources
  1. 01-explorables/distill-misread-tsne
  2. 01-explorables/setosa-principal-component-analysis
  3. 02-code-refs/amidi-cs229-unsupervised
  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. 05-safety/nanda-mech-interp-glossary
  9. 08-geron-notebooks/08_dimensionality_reduction
  10. 13-fastbook/09_tabular
  11. 16-d2l-sections (PCA / SVD background)
  12. 22-anthropic-recent/2023-superposition-composition-index
  13. 22-anthropic-recent/2023-monosemantic-features-index
  14. 22-anthropic-recent/2024-scaling-monosemanticity
  15. 23-textbooks/math4ml
  16. 23-textbooks/mackay-itila
  17. 24-founder-blogs (olah et al., if available)
  18. 25-alignment-canon