Machine Learning · Module 16 of 26 · 9 concepts · about 17 min

Dimensionality Reduction

Re-expressing many columns as a few directions. What PCA actually maximises, how to choose how many components, and the cases where projecting the data destroys exactly the signal you needed.

At work: Compressing hundreds of columns into a few, to visualise customers, speed up models or clean up noisy data.

The curse of dimensionality, from the distance side

What it is

As dimensions grow, the distance from a point to its nearest neighbour and to its farthest neighbour converge. The *contrast* between near and far — the thing every distance-based method relies on — decays towards zero, and nearly all of a high-dimensional ball's volume sits in a thin shell at its surface.

Why it matters

It is the reason k-nearest-neighbours, k-means, DBSCAN, LOF and every kernel method stop working on wide data long before they run out of memory. They do not error; they return answers in which 'nearest' has stopped meaning anything.

In the real world: Why similarity search over thousands of product attributes gets worse, not better, as columns are added.

Visual example

Curse of dimensionality

Relative distance contrast (max - min) / min over 2,000 Gaussian points, against dimension. Past about d = 50, every point is roughly equidistant.

Distances meaningless050100150200012345Dimensions(farthest - nearest) / nearestcontrast a metric needsd = 50

The same effect, by volume

Fraction of a unit ball within 10% of its surface = 1 - 0.9**d

d = 2 -> 0.19 d = 10 -> 0.65

d = 50 -> 0.995 d = 100 -> 1.000

So nearly every point is at nearly the maximum radius: all far, all equally far

Why this matters

Dimensionality reduction is not mainly about disk space. It is about moving data back into a regime where the word 'nearby' has content, which is a precondition for everything in the next two modules.

Common misconception

Wrong idea: high dimensions just make things slower.

Correct idea: they make distance uninformative. Speed you can buy; a metric in which the nearest point is 3% closer than the farthest one cannot be rescued with more compute.

import numpy as np

rng = np.random.default_rng(0)
for d in (2, 10, 100, 1000):
    X = rng.standard_normal((2000, d))
    q = rng.standard_normal(d)
    dist = np.linalg.norm(X - q, axis=1)
    contrast = (dist.max() - dist.min()) / dist.min()
    print(f"d={d:>5}  nearest={dist.min():7.2f}  "
          f"farthest={dist.max():7.2f}  contrast={contrast:5.2f}")

# Volume: the fraction of a unit ball within 10% of its surface is 1 - 0.9**d.
[(d, round(1 - 0.9 ** d, 3)) for d in (2, 10, 50, 100)]

Reading a high-dimensional nearest-neighbour result as meaningful because the code ran and the numbers differed. At d = 500 the nearest and farthest points in a sample are within a few percent of each other, so the ranking is dominated by sampling noise. Reduce dimensions, or use a metric that is not Euclidean, before trusting a neighbourhood.

PCA as a variance-maximising projection

What it is

Centre the data, then find the unit direction along which the projected values have the largest variance — that is PC1. PC2 is the largest-variance direction orthogonal to PC1, and so on. Equivalently: the eigenvectors of the covariance matrix, ordered by eigenvalue.

Why it matters

It gives a principled answer to 'which two numbers should I keep?' without any labels: the directions along which the rows actually differ from each other. Everything else about PCA — the component count, the reconstruction error, the scaling trap — follows from this one objective.

In the real world: Compressing 200 correlated survey questions into a few underlying themes.

Visual example

PCA

The principal axes drawn through the cloud: PC1 along the direction of greatest spread, PC2 orthogonal to it and much shorter.

observationsPC1 (82% of variance)PC2 (18%)
-4-2024-2-1012feature 1 (centred)feature 2 (centred)mean

The projection, numerically

PC1 direction = [0.858, 0.515]; PC2 = [-0.515, 0.858]

var along PC1 = 3.41; var along PC2 = 0.74

explained_variance_ratio_ = [0.82, 0.18]

The point (3.1, 2.0) projects to z1 = 3.1(0.858) + 2.0(0.515) = 3.69

...and z2 = 3.1(-0.515) + 2.0(0.858) = 0.12 — almost nothing on PC2

Why this matters

Keeping only PC1 means replacing each point by its position along the blue line. Because the cloud is thin in the green direction, that loses 18% of the variance and one whole column.

Common misconception

Wrong idea: PCA picks the most important original features.

Correct idea: it builds new axes that are weighted blends of every original feature. No original column is kept or dropped; the whole space is rotated and then truncated.

  1. 1CentreSubtract each feature's mean so the cloud sits on the origin.
  2. 2Find the long axisThe unit direction whose projected values have the largest variance. That is PC1.
  3. 3Rotate, then truncateRe-express every point in PC coordinates and keep the first few. Keeping only PC1 flattens the cloud onto the blue line.
import numpy as np
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pca = make_pipeline(StandardScaler(), PCA(n_components=2, random_state=0))
Z = pca.fit_transform(X_train)          # shape (n, 2): the projected coordinates

p = pca[-1]                              # the PCA step inside the pipeline
p.components_                            # shape (2, n_features): the directions themselves
p.explained_variance_                    # the variance along each, in original units
Z.var(axis=0)                            # the same numbers, measured on the output

# Every component is orthogonal to every other, by construction.
np.round(p.components_ @ p.components_.T, 10)   # the 2x2 identity

Forgetting that PCA centres but does not scale. PCA subtracts the mean of each column for you; it does not divide by the standard deviation, and it must not be given uncentred data (it would then find the direction of the mean, not of the variation). Put a StandardScaler in front unless every column is already in the same unit.

Explained variance ratio and choosing k

What it is

explained_variance_ratio_ is each component's share of the total variance, in descending order. The standard choices are: cumulate it and cut at a threshold (PCA(n_components=0.95) does this directly), or look at the per-component values for an elbow past which each extra component adds almost nothing.

Why it matters

It turns 'how many components?' from a guess into a stated budget. And the shape of the curve is itself diagnostic: a sharp elbow means the data really has a low-dimensional structure, while a near-straight line means it does not, and PCA will not help much.

In the real world: Keeping enough components to hold 95% of the information while cutting storage and compute.

Visual example

explained_variance_ratio_

A scree plot with its cumulative curve: seven components reach 96% of the variance, and the elbow is at three.

cumulativeper component
Tail024681000.20.40.60.81ComponentShare of total variance95% budgetk = 7elbow

Two defensible answers from one curve

Variance budget: cumulative crosses 0.95 at component 7 -> k = 7

Elbow: the per-component drop is 0.20, 0.08, 0.04, 0.03... -> k = 3

k = 3 keeps 75% of the variance in 30% of the columns

Components 8-10 hold 3.7% between them — mostly measurement noise

Why this matters

The cumulative curve is monotone and always reaches 1, so it never tells you to stop on its own — you have to bring a threshold or read the elbow in the purple curve. Showing both is how you notice they disagree.

Common misconception

Wrong idea: 95% explained variance means the reduced data keeps 95% of the useful information.

Correct idea: it keeps 95% of the variance, which is a statement about spread and nothing else. The discarded 5% can contain the entire signal for your target — see the concept below.

import numpy as np
from sklearn.decomposition import PCA

full = PCA().fit(X_train_scaled)          # every component, to see the whole curve
evr = full.explained_variance_ratio_
cum = np.cumsum(evr)
k95 = int(np.searchsorted(cum, 0.95) + 1)
print(f"{k95} components reach 95% of the variance ({cum[k95 - 1]:.3f})")

# Or state the budget and let PCA choose k for you:
pca = PCA(n_components=0.95, svd_solver="full", random_state=0).fit(X_train_scaled)
pca.n_components_

Choosing k to hit a variance threshold when the components feed a supervised model. Variance is not accuracy: the honest way to pick k is to put PCA in a pipeline and cross-validate n_components against the metric you actually care about. A 95% cut is a default, not an answer.

PCA is linear and unsupervised

What it is

PCA can only produce weighted sums of the original features, so any structure that is not a linear subspace — a spiral, a sphere, an XOR pattern — survives it only by accident. And it never sees y: it maximises variance, with no notion that one direction might be the one your label depends on.

Why it matters

This is where dimensionality reduction actively destroys a model. A direction with small variance can carry the entire class separation, and PCA ranks it last precisely because it is small. Nothing warns you: the pipeline runs, the variance ratio looks healthy, and accuracy collapses.

In the real world: It finds what varies most, which may not be what predicts churn, so check before relying on it.

Visual example

PCA is unsupervised

The label is separated along the low-variance axis. PC1 runs left-to-right, so projecting onto it merges the classes completely.

class 0class 1PC1 — what PCA keeps
-3-2-10123-1.5-1-0.500.511.5feature 1 — variance 4.0 (94%)feature 2 — variance 0.72 (6%)classes collapse here

What the projection does

Along feature 1: var 4.00 (94% of the total) — classes fully overlap

Along feature 2: var 0.72 (6%) — classes perfectly separated

PCA(n_components=1) keeps feature 1. Accuracy: 0.99 -> 0.50

LDA(n_components=1) keeps feature 2. Accuracy stays 0.99

Why this matters

A 94%/6% split looks like an easy call on a scree plot. It is the wrong call here, and the only way to know is to test the reduced data against the target rather than against a variance threshold.

Common misconception

Wrong idea: the high-variance directions are the informative ones.

Correct idea: high variance means the rows differ a lot along that axis, which may be pure measurement noise in a loud unit. Informative means it differs with y, and PCA has never seen y.

import numpy as np
from sklearn.decomposition import PCA
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

rng = np.random.default_rng(0)
wide = rng.normal(0, 2.0, (600, 1))            # huge variance, no signal
y = rng.integers(0, 2, 600)
thin = (y * 1.6 - 0.8 + rng.normal(0, 0.3, 600)).reshape(-1, 1)   # tiny variance, all signal
X = np.hstack([wide, thin])

cross_val_score(LogisticRegression(), X, y, cv=5).mean()           # about 0.99
Z = PCA(n_components=1).fit_transform(X)
cross_val_score(LogisticRegression(), Z, y, cv=5).mean()           # about 0.50

# LDA maximises class separation instead of variance, and keeps the signal.
LinearDiscriminantAnalysis(n_components=1).fit_transform(X, y)

Reaching for PCA as a routine preprocessing step for a supervised model. For a tree ensemble it usually hurts (it destroys the axis-aligned splits trees are good at); for a linear model it is a wash unless you needed the noise reduction. Use it when you need compactness, speed or a picture — and cross-validate the claim that it helped.

t-SNE and UMAP: what they preserve

What it is

Both build a graph of each point's nearest neighbours in the original space and then find a 2-D layout in which those neighbourhoods are preserved. They optimise *local* structure and deliberately sacrifice global geometry: distances between well-separated clusters in the output are not meaningful, and neither are cluster areas.

Why it matters

They show structure PCA cannot — a curved manifold, well-separated groups in a space with no dominant linear direction — which makes them the standard tool for looking at embeddings. But they are visualisation, not preprocessing, and the difference matters.

In the real world: Plotting customers in 2-D to spot clusters for a marketing deck, while not reading too much into the distances.

from sklearn.manifold import TSNE
import umap                                # pip install umap-learn

# Reduce with PCA first: it denoises and makes the neighbour search tractable.
from sklearn.decomposition import PCA
X50 = PCA(n_components=50, random_state=0).fit_transform(X_scaled)

Z_tsne = TSNE(n_components=2, perplexity=30, init="pca", random_state=0).fit_transform(X50)
Z_umap = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=0).fit_transform(X50)

# t-SNE in scikit-learn has no .transform(): there is no way to place a new point.
mapper = umap.UMAP(n_neighbors=15, random_state=0).fit(X50_train)
mapper.transform(X50_test)                 # UMAP can, which is why it is usable in a pipeline

Reading a t-SNE or UMAP picture quantitatively. Cluster sizes, the gaps between clusters and any apparent density are artefacts of the layout, and perplexity / n_neighbors change the picture substantially — a single setting can manufacture clusters in pure Gaussian noise. Use it to generate hypotheses, then check them in the original space.

The scaling trap: PCA is not scale-invariant

What it is

PCA maximises variance in whatever units the columns arrive in. A column measured in grams has a million times the variance of the same quantity in kilograms, so it dominates PC1 for its unit alone. Standardising every column first makes the components depend on correlation structure rather than on measurement choices.

Why it matters

It is the most common way a PCA result is quietly meaningless. The variance ratio looks excellent — 97% on PC1 — because one loud column *is* PC1, and the components carry no information about any other feature.

In the real world: Without scaling, income dominates every component and the other customer features vanish.

Visual example

Scale sensitivity

The same three columns, unscaled and scaled. **Unscaled, PC1 is just income** — its unit is large, so its variance is large.

PCA on raw columns
featureunitvariancePC1 loading
incomecurrency840000001
ageyears1410.0002
risk_score0-10.040
StandardScaler() then PCA()
PCA on standardised columns
featureunitvariancePC1 loading
incomez-score10.62
agez-score10.55
risk_scorez-score1-0.56

Where the loading of 1.0 comes from

Total raw variance = 84,000,000 + 141 + 0.04

income's share = 0.9999983 — so the max-variance direction IS income

explained_variance_ratio_ = [0.999, 0.001, 0.000]: a beautiful, useless number

After scaling every variance is 1, so no column can win on units

Why this matters

A 99.9% first component is not a sign of low-dimensional structure; on unscaled data it is a sign that one column has a big unit. Scaling is what makes the ratio mean 'shared structure' instead of 'measurement choice'.

Common misconception

Wrong idea: PCA normalises the data itself, since it works on the covariance matrix.

Correct idea: it centres but does not scale. Standardising switches it from the covariance matrix to the correlation matrix, and those have completely different eigenvectors.

import numpy as np
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

# income in currency units, age in years, score in 0-1.
raw = PCA(n_components=2).fit(X_raw)
np.round(raw.components_[0], 4)          # about [1.0, 0.0002, 0.0] — income, alone
raw.explained_variance_ratio_            # about [0.999, 0.001]

scaled = make_pipeline(StandardScaler(), PCA(n_components=2)).fit(X_raw)
np.round(scaled[-1].components_[0], 3)   # a real blend of all three

Scaling with statistics from the whole dataset. StandardScaler is a fitted transformer: its mean and standard deviation must come from the training fold only, which is what putting it in a Pipeline guarantees. The same applies to the PCA rotation itself — fit on train, transform on test, never fit_transform on both.

Reconstruction error: reduction as lossy compression

What it is

inverse_transform maps the k-dimensional coordinates back into the original space. The reduced representation is exactly as good as that round trip: the mean squared difference between X and its reconstruction is the variance PCA discarded, and it equals the sum of the dropped eigenvalues.

Why it matters

It makes the loss measurable in the units of the data rather than as an abstract percentage, and it gives dimensionality reduction a second job — reconstruction error on a new point is an anomaly score, which is the bridge to module 17.

In the real world: Measuring what is lost when sensor data is compressed, and using large losses to flag unusual readings.

Visual example

inverse_transform

The same 10-column matrix at four component counts: stored floats against the variance you gave up.

1 - pca.explained_variance_ratio_.sum()

the discarded variance IS the MSE, in standardised units

1,000 rows x 10 standardised columns
kfloats storedvariance keptreconstruction MSE
101000010
770000.9630.037
330000.750.25
110000.410.59

k = 7 stores 30% fewer numbers for 3.7% average squared error

Why the two right-hand columns sum to 1

Standardised data: total variance = number of columns = 10, so mean variance per column = 1

MSE of the round trip = sum of the dropped eigenvalues / 10

k = 3: eigenvalues 4-10 sum to 2.50, over 10 columns -> MSE 0.25

So 'variance kept' + 'MSE' = 1 exactly, on standardised input

Why this matters

Framing it as compression makes the trade concrete and gives you a per-row error to look at. A row whose reconstruction error is far above the training distribution's is a row from somewhere else.

Common misconception

Wrong idea: PCA is lossless because the components are invertible.

Correct idea: the full rotation is invertible; keeping k of n components is not. inverse_transform returns the closest point in the k-dimensional subspace, not the original row.

import numpy as np
from sklearn.decomposition import PCA

pca = PCA(n_components=7, random_state=0).fit(X_train_scaled)
Z = pca.transform(X_test_scaled)
X_hat = pca.inverse_transform(Z)

mse = np.mean((X_test_scaled - X_hat) ** 2)
print(f"reconstruction MSE {mse:.4f}  ~= 1 - {pca.explained_variance_ratio_.sum():.4f}")

# Per-row error: large means 'this row does not live in the training subspace'.
row_error = np.mean((X_test_scaled - X_hat) ** 2, axis=1)
flagged = row_error > np.quantile(row_error, 0.99)

Comparing reconstruction errors across differently scaled data, or across different k, without noting that the error is in the *scaled* space. And inverse_transform returns you to the standardised space, not the original units — if a StandardScaler came first, you have to invert that too before the numbers mean anything to a human.

Components are blends, so interpretability is the price

What it is

Each component is a weighted sum of every original feature, so a coefficient in the downstream model attaches to 'PC3' rather than to anything anybody measured. The components_ matrix is the only bridge back, and reading it means reading a whole row of loadings at once.

Why it matters

It is the concrete reason to prefer selection (module 14) over projection whenever someone has to act on the model. 'Drop the customer's tenure below 6 months' is a decision; 'increase PC2' is not.

In the real world: “Component 1” is a mix of features, harder to explain to a business team than “age” or “income”.

Visual example

components_

A loading matrix: every original feature contributes to every component, which is why no component has a name.

The one readable row

(tenure loads 0.90 on PC2 and ~0 elsewhere)

pca.components_[0]

PC1 as a recipe: 0.62*income + 0.55*age - 0.56*risk

pca.components_.T
featurePC1PC2PC3
income0.62-0.110.48
age0.550.32-0.62
risk_score-0.560.290.51
tenure_months0.090.90.35

A coefficient on PC1 is a statement about three features at once

Why this matters

Reading the columns rather than the rows is the habit: a component is a recipe over features, and only occasionally — like PC2 here — does one feature dominate enough to give it a name.

Common misconception

Wrong idea: PC1 is the most important feature.

Correct idea: PC1 is a direction, and the sign matters: here it is 'high income and age, low risk score'. Its negative end is a real, different kind of row.

import numpy as np
import pandas as pd

loadings = pd.DataFrame(
    pca.components_.T,
    index=feature_names,
    columns=[f"PC{i + 1}" for i in range(pca.n_components_)],
)
# The features that define each component, largest absolute loading first.
for pc in loadings.columns[:2]:
    top = loadings[pc].abs().sort_values(ascending=False).head(4).index
    print(pc, [(f, round(loadings.loc[f, pc], 2)) for f in top])

# SparsePCA forces most loadings to zero, buying back some readability.
from sklearn.decomposition import SparsePCA
SparsePCA(n_components=3, alpha=1.0, random_state=0).fit(X_train_scaled)

Naming a component after its largest loading and then reasoning about it as if it were that feature. A PC1 with loadings 0.62 on income, 0.55 on age and −0.56 on risk score is not 'income'; a unit of PC1 moves all three at once, and the sign pattern is the whole meaning.

Fitting the reduction inside the pipeline

What it is

The scaler's means, the PCA rotation and the component count are all fitted quantities. They belong inside a Pipeline so they are refitted on each training fold, and n_components belongs in the hyperparameter grid alongside the model's own settings.

Why it matters

It is the same discipline as the selection-leakage concept in module 14, and it is what lets you answer the only question that matters — did reducing help? — rather than assuming it did because the variance ratio looked good.

In the real world: Learning the compression from training data only, so the churn model's test score stays honest.

from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

pipe = Pipeline([
    ("scale", StandardScaler()),
    ("pca", PCA(random_state=0)),
    ("clf", LogisticRegression(max_iter=2000)),
])
grid = {
    "pca__n_components": [2, 5, 10, 20, None],   # None = no reduction, the baseline
    "clf__C": [0.1, 1.0, 10.0],
}
search = GridSearchCV(pipe, grid, cv=5, scoring="roc_auc").fit(X_train, y_train)
search.best_params_        # if it picks None, the reduction was not helping

Leaving the no-reduction baseline out of the grid. Without None in the list you can only discover which amount of PCA is least harmful, never whether any of it helped — and on tabular data with a tree ensemble, the honest answer is often that it did not.

Practise it: A Pipeline That Does Not Leak