Principal Component Analysis (PCA)
What you’ll learn
- the two equivalent definitions of PCA — maximise variance and minimise reconstruction error — and why they are the same optimisation
- a complete eigendecomposition by hand on five points, matching scikit-learn exactly
- the SVD route, and why scikit-learn takes it instead of forming the covariance matrix
- explained variance ratio and how to pick : digits needs 21 of 64 for 90%
- the standardisation failure, measured: PC1 at 99.8% and it is one column’s units
- reconstruction, compression and denoising with
inverse_transforminverse_transform - randomised and incremental PCA, and Kernel PCA for curved structure
Intuition
A dataset with 64 columns does not usually have 64 independent things going on. Neighbouring pixels in an image move together. Height and weight move together. Most of the columns are echoes of a smaller number of underlying factors.
PCA finds those factors. It looks for the direction in which the data spreads out the most, calls that the first principal component, then looks for the direction of greatest remaining spread that is perpendicular to the first, and so on. Keep the first few and you have a compact description that loses very little.
There are two ways to say what PCA optimises, and they turn out to be the same thing:
- Maximise the variance of the projected data.
- Minimise the squared distance from each point to its projection.
The reason is Pythagoras. For centred data, each point’s squared distance from the origin splits exactly into the squared length of its projection plus the squared length of the residual:
Summed over all points, the left side is a constant of the data. So maximising the first term is identical to minimising the second. Variance kept and error made are two names for one number.
flowchart LR X["X (n x p)"] --> C["Centre each column
(and standardise)"] C --> S["SVD: X = U S V'"] S --> V["Components = rows of V'
Eigenvalues = s^2 / (n-1)"] V --> Z["transform: Z = Xc @ V
(n x d)"] Z --> R["inverse_transform:
Z @ V' + mean"] R --> E["Error = the variance in the
p - d discarded directions"]
The math
Let be the data matrix with every column already centred (PCA subtracts the mean for you; the derivation requires it). The sample covariance matrix is
For a unit direction , the projected values are and their variance is
So PCA is the constrained problem
The constraint matters: without it you could inflate the objective forever by lengthening . Form the Lagrangian
and differentiate:
The optimum is an eigenvector of the covariance matrix. Left-multiplying by gives , so the variance captured by a component is its eigenvalue. Take the eigenvectors in descending order of and you have the principal components.
Because is real and symmetric, the spectral theorem guarantees its eigenvalues are real and its eigenvectors can be chosen orthonormal. Orthogonality of the components is not a design choice — it falls out of the mathematics.
The explained variance ratio
The eigenvalues sum to the total variance, , so
is the fraction of total variance the -th component carries.
The SVD route
scikit-learn does not build . It takes the singular value decomposition directly:
Then
so the right singular vectors are the eigenvectors of , and the eigenvalues are .
Two reasons this is better. Numerically, forming squares the condition number, so information near the noise floor is destroyed before you start. Computationally, when — 10,000 genes and 200 patients — the covariance matrix is while the SVD never forms it.
The sign of each component is arbitrary: if is an eigenvector so is . scikit-learn applies a deterministic sign convention so results are reproducible, but do not read meaning into whether a loading is positive or negative in isolation.
Worked example by hand
Five points:
Step 1 — centre. The means are and :
Step 2 — covariance. With :
Step 3 — eigenvalues. Solve :
Check against the trace: . ✓
Step 4 — eigenvectors. For :
Normalised: . The same algebra with gives , which is perpendicular, as promised.
Step 5 — explained variance.
Ninety percent of the variation lies along the diagonal.
Step 6 — project. The score of a point is its centred coordinates dotted with . For , centred to :
All five scores: .
Step 7 — reconstruct. Push the first point back out: , add the mean, and you get exactly . That point lies on the diagonal, so nothing was lost. Across all five points, the total squared reconstruction error from one component is 2.0 — which is , the discarded eigenvalue, exactly as the theory says.
Every number here matches sklearn.decomposition.PCAsklearn.decomposition.PCA to the digit.
See it move
The next sketch shows the two objectives moving in lockstep — as one bar rises, the other falls by exactly the same amount.
Choosing how many components
Fit PCA with all components, look at the cumulative explained variance, and cut where it flattens. On scikit-learn’s digits dataset — 1,797 images of pixels, so 64 features:
| Target variance | Components needed |
|---|---|
| 90% | 21 of 64 |
| 95% | 29 of 64 |
| 99% | 41 of 64 |
The first component alone carries 14.89% and the second 13.62%.
scikit-learn lets you state the target directly:
from sklearn.decomposition import PCA
pca = PCA(n_components=0.90, svd_solver="full").fit(X) # a float means "this much variance"
print(pca.n_components_) # 21from sklearn.decomposition import PCA
pca = PCA(n_components=0.90, svd_solver="full").fit(X) # a float means "this much variance"
print(pca.n_components_) # 21Do not use PCA output dimensionality as a hyperparameter tuned on the test set. If affects downstream accuracy, put PCA in a pipeline and tune it in cross-validation, exactly as with any other preprocessing step.
Standardise first, or PC1 becomes a unit conversion
This is the failure that actually happens in practice. The wine dataset has 13 chemical
measurements whose raw standard deviations range from 0.12 (nonflavanoid_phenolsnonflavanoid_phenols) to 314.0
(prolineproline, measured in mg/L).
| Input | PC1 EVR | PC2 EVR | PC1’s dominant loading |
|---|---|---|---|
| Raw columns | 0.9981 | 0.0017 | prolineproline, at 0.9998 |
| Standardised | 0.3620 | 0.1921 | flavanoidsflavanoids, at 0.4229 |
PC1 on the raw data explains 99.81% of the variance — and it is the proline column, at a loading of 0.9998. PCA has faithfully reported that one column has a bigger number in it than the others. That is a fact about milligrams per litre, not about wine.
After standardising, PC1 explains a modest 36.2% and mixes 13 features, with flavanoidsflavanoids leading
at 0.4229. Much less impressive, and actually informative.
Always:
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
pipe = make_pipeline(StandardScaler(), PCA(n_components=0.95))from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
pipe = make_pipeline(StandardScaler(), PCA(n_components=0.95))The exception is data whose columns already share units and where relative magnitude is meaningful — pixel intensities, spectra, or a block of columns all in dollars.
Reconstruction, compression and denoising
inverse_transforminverse_transform maps back to the original space. What comes out is the projection of the input
onto the retained subspace — the closest possible approximation using directions.
| Explained variance | Reconstruction MSE | Storage | |
|---|---|---|---|
| 64 | 1.0000 | 0.0000 | 100% |
| 32 | 0.9664 | 0.6316 | 50% |
| 16 | 0.8494 | 2.8272 | 25% |
| 8 | 0.6739 | 6.1218 | 12.5% |
| 4 | 0.4871 | 9.6280 | 6.25% |
| 2 | 0.2851 | 13.4210 | 3.1% |
Halving the storage costs 3.4% of the variance. That is the compression trade in one row.
The same mechanism denoises. Noise is, by construction, spread evenly across all directions, while signal concentrates in the top few. Projecting to a low-dimensional subspace and back discards mostly noise:
import numpy as np
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
X = load_digits().data
rng = np.random.default_rng(0)
X_noisy = X + rng.normal(0, 4, X.shape)
pca = PCA(n_components=0.80, random_state=0).fit(X_noisy)
X_clean = pca.inverse_transform(pca.transform(X_noisy))
print("components kept", pca.n_components_)
print("MSE noisy vs original", round(float(((X_noisy - X) ** 2).mean()), 3))
print("MSE clean vs original", round(float(((X_clean - X) ** 2).mean()), 3))import numpy as np
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
X = load_digits().data
rng = np.random.default_rng(0)
X_noisy = X + rng.normal(0, 4, X.shape)
pca = PCA(n_components=0.80, random_state=0).fit(X_noisy)
X_clean = pca.inverse_transform(pca.transform(X_noisy))
print("components kept", pca.n_components_)
print("MSE noisy vs original", round(float(((X_noisy - X) ** 2).mean()), 3))
print("MSE clean vs original", round(float(((X_clean - X) ** 2).mean()), 3))In code
import numpy as np
from sklearn.decomposition import PCA
X = np.array([[2.0, 1.0], [3.0, 3.0], [4.0, 2.0], [5.0, 5.0], [6.0, 4.0]])
pca = PCA().fit(X)
print("mean_ ", pca.mean_) # [4. 3.]
print("explained_variance_ ", pca.explained_variance_) # [4.5 0.5]
print("explained_variance_ratio_", pca.explained_variance_ratio_) # [0.9 0.1]
print("components_ (rows are PCs)\n", pca.components_.round(4))
# [[ 0.7071 0.7071]
# [-0.7071 0.7071]]
Z = pca.transform(X)
print("PC1 scores", Z[:, 0].round(4)) # [-2.8284 -0.7071 -0.7071 2.1213 2.1213]
# One component only, then back out again
p1 = PCA(n_components=1).fit(X)
X_rec = p1.inverse_transform(p1.transform(X))
print("total squared error", round(float(((X - X_rec) ** 2).sum()), 4)) # 2.0import numpy as np
from sklearn.decomposition import PCA
X = np.array([[2.0, 1.0], [3.0, 3.0], [4.0, 2.0], [5.0, 5.0], [6.0, 4.0]])
pca = PCA().fit(X)
print("mean_ ", pca.mean_) # [4. 3.]
print("explained_variance_ ", pca.explained_variance_) # [4.5 0.5]
print("explained_variance_ratio_", pca.explained_variance_ratio_) # [0.9 0.1]
print("components_ (rows are PCs)\n", pca.components_.round(4))
# [[ 0.7071 0.7071]
# [-0.7071 0.7071]]
Z = pca.transform(X)
print("PC1 scores", Z[:, 0].round(4)) # [-2.8284 -0.7071 -0.7071 2.1213 2.1213]
# One component only, then back out again
p1 = PCA(n_components=1).fit(X)
X_rec = p1.inverse_transform(p1.transform(X))
print("total squared error", round(float(((X - X_rec) ** 2).sum()), 4)) # 2.0That last number is — the discarded eigenvalue, recovered empirically.
Randomised and incremental PCA
svd_solver="randomized"svd_solver="randomized" approximates the top singular vectors with random projections,
turning into roughly . It only pays when the matrix is large and :
on the digits matrix it was slower than the full SVD, while on a
matrix asking for 50 components it was about twice as fast. scikit-learn’s
svd_solver="auto"svd_solver="auto" picks sensibly, so leave it alone unless you have measured.
IncrementalPCAIncrementalPCA processes the data in batches and never holds it all in memory:
import numpy as np
from sklearn.decomposition import IncrementalPCA
ipca = IncrementalPCA(n_components=32, batch_size=256)
for batch in np.array_split(X, 20): # or read from disk, one chunk at a time
ipca.partial_fit(batch)
X_reduced = ipca.transform(X)import numpy as np
from sklearn.decomposition import IncrementalPCA
ipca = IncrementalPCA(n_components=32, batch_size=256)
for batch in np.array_split(X, 20): # or read from disk, one chunk at a time
ipca.partial_fit(batch)
X_reduced = ipca.transform(X)Use it when the data does not fit in RAM, or when it arrives as a stream.
Kernel PCA
PCA is a linear method: it can only find flat subspaces. If the structure is curved, no rotation will straighten it.
Kernel PCA applies the kernel trick — implicitly map to a high-dimensional space, do linear PCA there, and never form the mapping explicitly. On two concentric circles:
| Representation | Logistic-regression accuracy (5-fold) |
|---|---|
| Raw 2-D | 0.4375 |
| Linear PCA (2 components) | 0.4375 |
| Kernel PCA, RBF, gamma = 10 | 0.6575 |
Two costs. Kernel PCA is in memory because it builds the kernel matrix, and
inverse_transforminverse_transform requires fit_inverse_transform=Truefit_inverse_transform=True and is only approximate — the pre-image
problem has no exact solution.
APIsklearn.decomposition.PCA
Assumes
- Directions of high variance are the interesting ones
- The structure is LINEAR — a flat subspace, not a curved manifold
- Features are on comparable scales (standardise unless they genuinely are)
- Data is centred — PCA does this for you, but the maths requires it
Cost
- train
O(n p^2 + p^3) for full SVD; roughly O(n p d) for randomized- predict
O(p d) per sample — a single matrix multiply- memory
O(n p) for the data plus O(p d) for the components
C — covariance matrix; lambda_j — j-th eigenvalue = variance along PC j; u_j — j-th principal component; EVR — explained variance ratio
Hyperparameters that matter
n_componentsdefault min(n, p)An int keeps that many; a float in (0,1) keeps enough for that much variance; 'mle' estimates it.svd_solverdefault 'auto''full' is exact; 'randomized' is faster for large p and small d; 'arpack' for sparse. Leave on auto.whitendefault FalseDivides each component by its standard deviation. Helps downstream models that assume isotropy; discards magnitude information.random_statedefault NoneOnly matters for the randomized solver.
Reach for it when
- You have many correlated features and want fewer
- You want to visualise high-dimensional data in 2-D or 3-D
- You want to compress, denoise, or speed up a downstream model
- You need a deterministic, invertible, cheap-to-apply transform
Look elsewhere when
- The structure is a curved manifold — use Kernel PCA, LLE or t-SNE
- Interpretability of individual features matters — components are mixtures of all of them
- The informative signal is low-variance (rare but real: a subtle defect indicator)
- Features are categorical — use MCA or an embedding instead
Pitfalls
Not standardising. Measured above: PC1 at 99.81% that is a single column’s units. This is the most common PCA mistake by a wide margin.
Fitting on the full dataset before splitting. PCA learns the components from the data, so
fitting on train + test leaks test-set structure into the transform. Put it in a PipelinePipeline.
Assuming high variance means high relevance. PCA is unsupervised — it has never seen yy. A
low-variance direction can be the one that predicts the target. If you need supervised dimension
reduction, use linear discriminant analysis or partial least squares.
Interpreting components as features. PC1 is a weighted mixture of every original column. “PC1 increased by 2” is not a sentence anyone outside the model can act on.
Applying PCA to one-hot encoded categoricals. The variance of a one-hot column is , which is a fact about category frequency, not importance. Use multiple correspondence analysis or a learned embedding.
Expecting inverse_transforminverse_transform to be lossless. It returns the projection onto the retained
subspace. With the discarded variance is gone permanently.
Reading meaning into a component’s sign. Eigenvectors are defined up to sign. A loading of and describe the same structure.
Reaching for svd_solver="randomized"svd_solver="randomized" without measuring. On the digits matrix it was slower
than the exact solver. It wins on large, wide matrices — measure before switching.
Compare
| PCA | Kernel PCA | LDA | t-SNE / UMAP | |
|---|---|---|---|---|
| Supervised | no | no | yes | no |
| Linear | yes | no | yes | no |
| Max components | classes − 1 | 2 or 3 in practice | ||
| Transforms new data | yes | yes | yes | UMAP yes, t-SNE no |
| Invertible | yes, exactly | approximately | no | no |
| Preserves global structure | yes | partly | yes | no |
| Cost |
Why is the first principal component an eigenvector of the covariance matrix?
The Lagrangian is u'Cu - lambda(u'u - 1); setting its gradient to zero gives 2Cu - 2 lambda u = 0. Left-multiplying by u' shows the objective value equals lambda, so the largest eigenvalue is the maximum variance.
Show answer
B — Maximising u'Cu subject to u'u = 1 gives, via a Lagrange multiplier, the stationarity condition Cu = lambda u — The Lagrangian is u'Cu - lambda(u'u - 1); setting its gradient to zero gives 2Cu - 2 lambda u = 0. Left-multiplying by u' shows the objective value equals lambda, so the largest eigenvalue is the maximum variance.
PCA on your raw data reports PC1 explaining 99.8% of the variance. What is the most likely explanation?
This is exactly what the wine dataset does: proline has a standard deviation of 314 while other columns are near 1, so PC1 loads on proline at 0.9998. Standardising drops PC1 to 36.2% and produces something informative.
Show answer
B — One column has a far larger numeric range than the others, and PC1 is essentially that column — This is exactly what the wine dataset does: proline has a standard deviation of 314 while other columns are near 1, so PC1 loads on proline at 0.9998. Standardising drops PC1 to 36.2% and produces something informative.
You keep 2 of 64 components and call inverse_transform. What do you get back?
inverse_transform returns to the original coordinate system but not to the original values — the 62 discarded directions are gone. On digits at d=2, that is 28.5% of the variance retained and a reconstruction MSE of 13.42.
Show answer
B — The projection of the data onto the 2-D subspace, expressed in the original 64 coordinates — inverse_transform returns to the original coordinate system but not to the original values — the 62 discarded directions are gone. On digits at d=2, that is 28.5% of the variance retained and a reconstruction MSE of 13.42.
Maximising retained variance and minimising reconstruction error give the same components. Why?
The projection and the residual are orthogonal, so their squared lengths sum to the point's squared norm. Summed over the dataset that total is a constant, so raising one term lowers the other by exactly the same amount.
Show answer
B — For centred data, squared distance from the origin splits by Pythagoras into projection-squared plus residual-squared, and their total is fixed — The projection and the residual are orthogonal, so their squared lengths sum to the point's squared norm. Summed over the dataset that total is a constant, so raising one term lowers the other by exactly the same amount.
Your two classes form concentric rings. Will PCA help a linear classifier separate them?
Linear PCA changes the basis, not the topology. The measured result on make_circles: raw 0.4375, linear PCA 0.4375 (identical), RBF kernel PCA 0.6575. The kernel is what buys the separability.
Show answer
B — No — PCA is a rotation, and no rotation makes concentric rings linearly separable. Kernel PCA can, moving accuracy from 0.44 to 0.66 — Linear PCA changes the basis, not the topology. The measured result on make_circles: raw 0.4375, linear PCA 0.4375 (identical), RBF kernel PCA 0.6575. The kernel is what buys the separability.
🧪 Try It Yourself
Exercise 1 – Eigendecomposition by hand
Exercise 2 – Confirm against scikit-learn
Exercise 3 – The discarded eigenvalue is the error
Exercise 4 – Standardising changes everything
Exercise 5 – The compression curve
Recap
- PCA maximises projected variance, which is identical to minimising squared reconstruction error — Pythagoras on centred data.
- The Lagrangian gives : components are eigenvectors and their variances are the eigenvalues.
- The five-point example gave , EVR 90%/10%, PC1 , and a one-component reconstruction error of exactly .
- scikit-learn uses the SVD, which is numerically better and avoids forming a matrix.
- Digits needs 21 of 64 components for 90% of the variance, 29 for 95%.
- Standardise first. On raw wine data PC1 explains 99.81% and is the proline column’s units; standardised it explains 36.2% and separates the cultivars.
inverse_transforminverse_transformis the projection, not the original: costs 3.4% of the variance, costs 71.5%.- PCA is linear. Concentric circles stay inseparable at 0.4375 accuracy until an RBF kernel takes it to 0.6575.
Exercise 6 – Price each variance target in components
Next
t-SNE and Manifold Learning — what to do when the data lies on a curved manifold that no rotation can flatten, and why the prettiest plots in machine learning are also the easiest to over-read.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
