Deep dive
How it works
In: matrix X (n × d), target rank k · Out: k principal axes, singular values and scores · State: the column means (to centre new data) and V_k · Cost: exact O(n·d·min(n,d)); randomized O(n·d·(k+p)·(q+1))
Exact PCA. Centre X (n×d). Compute the thin SVD (LAPACK gesdd). Components = first k rows of Vᵀ. Scores = U_k Σ_k = X V_k. Explained variance ratio = σᵢ² / Σσ².
Randomized SVD (rank k, l = k + p columns):
- Draw Gaussian
Ω(d×l).Y = XΩ(n×l). - Power iterations, q times:
Y ← X(XᵀY), re-orthonormalizing between multiplies. Each pass multiplies the spectrum by σ², separating the top k from the tail. Q = orth(Y)(n×l, orthonormal columns).B = QᵀX(l×d), small.- SVD of B, or eigen-decomposition of the l×l matrix
BBᵀ = U_B Σ² U_Bᵀ. ThenU = Q·U_B,V = BᵀU_B Σ⁻¹. - Keep the first k.
X (n×d) ──·Ω──► Y (n×l) ──orth──► Q ──Qᵀ·X──► B (l×d) ──small SVD──► U_B, Σ, V
▲ power its: Y ← X·(Xᵀ·Y) error ≈ σ_{k+1} · (factor shrinking with q)
Code
/// Row-major dense helpers. a: r×c.
fn matmul(a: &[f64], b: &[f64], r: usize, m: usize, c: usize) -> Vec<f64> { // (r×m)(m×c)
let mut o = vec![0.0; r * c];
for i in 0..r { for k in 0..m { let v = a[i * m + k]; for j in 0..c { o[i * c + j] += v * b[k * c + j]; } } }
o
}
fn transpose(a: &[f64], r: usize, c: usize) -> Vec<f64> {
let mut t = vec![0.0; r * c];
for i in 0..r { for j in 0..c { t[j * r + i] = a[i * c + j]; } }
t
}
/// Orthonormalize the l columns of y (n×l) in place: modified Gram-Schmidt, run twice for stability.
fn orth(y: &mut [f64], n: usize, l: usize) {
for _ in 0..2 { for j in 0..l {
for p in 0..j {
let d: f64 = (0..n).map(|i| y[i * l + j] * y[i * l + p]).sum();
for i in 0..n { y[i * l + j] -= d * y[i * l + p]; }
}
let nrm = (0..n).map(|i| y[i * l + j].powi(2)).sum::<f64>().sqrt();
for i in 0..n { y[i * l + j] /= nrm; }
} }
}
/// Cyclic Jacobi eigen-decomposition of a symmetric m×m matrix: returns (eigenvalues, eigenvectors as columns).
fn jacobi_eig(mut a: Vec<f64>, m: usize) -> (Vec<f64>, Vec<f64>) {
let mut v = vec![0.0; m * m];
for i in 0..m { v[i * m + i] = 1.0; }
for _sweep in 0..100 {
let off: f64 = (0..m).flat_map(|i| (0..m).filter(move |&j| j != i).map(move |j| (i, j))).map(|(i, j)| a[i * m + j].powi(2)).sum();
if off < 1e-22 { break; }
for p in 0..m { for q in p + 1..m {
if a[p * m + q].abs() < 1e-300 { continue; }
let theta = (a[q * m + q] - a[p * m + p]) / (2.0 * a[p * m + q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt()); // smaller rotation angle
let (c, s) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
for k in 0..m { // A ← A·J then Jᵀ·A, V ← V·J
let (akp, akq) = (a[k * m + p], a[k * m + q]);
a[k * m + p] = c * akp - s * akq; a[k * m + q] = s * akp + c * akq;
}
for k in 0..m {
let (apk, aqk) = (a[p * m + k], a[q * m + k]);
a[p * m + k] = c * apk - s * aqk; a[q * m + k] = s * apk + c * aqk;
}
for k in 0..m {
let (vkp, vkq) = (v[k * m + p], v[k * m + q]);
v[k * m + p] = c * vkp - s * vkq; v[k * m + q] = s * vkp + c * vkq;
}
} }
}
((0..m).map(|i| a[i * m + i]).collect(), v)
}
/// Randomized PCA of x (n×d). Returns (top-k components as k×d rows, explained variances).
pub fn randomized_pca(x: &mut [f64], n: usize, d: usize, k: usize, p: usize, q: usize, seed: u64) -> (Vec<f64>, Vec<f64>) {
for j in 0..d { // centre columns
let mu = (0..n).map(|i| x[i * d + j]).sum::<f64>() / n as f64;
for i in 0..n { x[i * d + j] -= mu; }
}
let l = k + p;
let mut s = seed | 1;
let mut gauss = || { s ^= s << 13; s ^= s >> 7; s ^= s << 17;
let u1 = ((s >> 11) as f64 + 1.0) / (1u64 << 53) as f64; s ^= s << 13; s ^= s >> 7; s ^= s << 17;
(-2.0 * u1.ln()).sqrt() * (6.283185307179586 * (s >> 11) as f64 / (1u64 << 53) as f64).cos() };
let omega: Vec<f64> = (0..d * l).map(|_| gauss()).collect();
let xt = transpose(x, n, d);
let mut y = matmul(x, &omega, n, d, l);
for _ in 0..q { // power iterations
orth(&mut y, n, l);
let mut z = matmul(&xt, &y, d, n, l);
orth(&mut z, d, l);
y = matmul(x, &z, n, d, l);
}
orth(&mut y, n, l);
let b = matmul(&transpose(&y, n, l), x, l, n, d); // B = Qᵀ X, l×d
let (evals, ub) = jacobi_eig(matmul(&b, &transpose(&b, l, d), l, d, l), l); // B Bᵀ = U Σ² Uᵀ
let mut order: Vec<usize> = (0..l).collect();
order.sort_by(|&i, &j| evals[j].partial_cmp(&evals[i]).unwrap());
let mut comps = vec![0.0; k * d];
for (r, &i) in order.iter().take(k).enumerate() {
let sigma = evals[i].max(0.0).sqrt();
for c in 0..d { comps[r * d + c] = (0..l).map(|t| b[t * d + c] * ub[t * l + i]).sum::<f64>() / sigma; } // v = Bᵀu/σ
let lead = comps[r * d..(r + 1) * d].iter().cloned().fold(0.0f64, |m, v| if v.abs() > m.abs() { v } else { m });
if lead < 0.0 { comps[r * d..(r + 1) * d].iter_mut().for_each(|v| *v = -*v); } // sign convention
}
(comps, order.iter().take(k).map(|&i| evals[i] / (n - 1) as f64).collect())
}
fn main() {
let (n, d) = (2000, 20);
let mut s = 12345u64;
let mut u = || { s ^= s << 13; s ^= s >> 7; s ^= s << 17; (s >> 11) as f64 / (1u64 << 53) as f64 - 0.5 };
let scales: Vec<f64> = (0..d).map(|j| if j < 3 { [10.0, 5.0, 2.0][j] } else { 0.3 }).collect();
let mut x: Vec<f64> = (0..n * d).map(|i| u() * 12f64.sqrt() * scales[i % d] + 3.0).collect(); // var = scale²
let (comps, var) = randomized_pca(&mut x, n, d, 3, 10, 2, 99);
println!("explained variance {:.2?} (truth about [100, 25, 4])", var);
println!("PC1 leading coords {:.3?}", &comps[..4]); // ≈ e0
}
Complexity and constants
- Full thin SVD: O(n·d²) for n ≥ d, with a small constant (Golub and Van Loan list the exact counts). n = 10⁶, d = 1000 is order of 10¹² flops: tens of seconds to minutes on a multicore CPU.
- Randomized:
(2q + 2)passes over X, each an n×d by d×l GEMM:2·n·d·l·(2q+2)flops. For k = 50, p = 10, q = 2 it is 720·n·d flops; far below the full SVD when l ≪ d. - Passes over the data matter more than flops for out-of-core matrices: q + 2 reads (one for Y, q for power iterations, one for B; each power iteration reads X twice if Xᵀ is not stored).
- Memory: the n×l sketch Y plus the l×d matrix B.
Efficiency
- Cost: full thin SVD is O(n·d²); n = 10⁶, d = 1000 is order of 10¹² flops, tens of seconds to minutes on a multicore CPU. Randomized SVD with k = 50, p = 10, q = 2 costs 720·n·d flops (above) and q + 2 reads of X. Bound: flops for the full SVD, memory bandwidth and passes for the randomized one.
- Levers:
- Randomized range finder — cost scales with l = k + p instead of d (Halko et al. 2011) — k ≪ d.
- Power iterations q = 1-2 — sharper spectrum for slowly decaying singular values, at 2 extra passes each (above) — noisy data.
- Covariance route (eigendecompose XᵀX) — O(n·d²) once, then O(d³) — only for small d; it squares the condition number.
- Incremental or streaming PCA in batches — memory O(batch·d) — data that does not fit in RAM.
- fp32 instead of fp64 — half the memory and bandwidth — when the spectrum is well separated.
- Naive — full eigendecomposition of the d×d covariance in fp64 · O(n·d² + d³), unstable for small singular values
- Standard — LAPACK thin SVD of centered X · O(n·d²)
- State of the art — randomized SVD with a few power iterations on GPU or out-of-core · O(n·d·l·q)
Implementation notes
- Centre (and usually scale) features, and store the means: new data must be transformed with the training means.
- Re-orthonormalize (QR) between every multiply in the power iteration. Without it, all columns converge to the top singular vector and the subspace collapses numerically.
- With n ≫ d (tall data), the d×d covariance in f64 plus a symmetric eigensolver (
syevd) is often fastest. It squares the condition number, so do not trust components with tiny variance. - Sign: flip each component so its largest-magnitude coordinate is positive (as scikit-learn's
svd_flipdoes with U). Downstream code that caches projections relies on it. - Streaming or larger-than-memory data: incremental PCA (batch SVD updates) or the randomized method with a few out-of-core passes.
- For embedding compression, keep components until the explained-variance curve flattens, then measure retrieval recall, not just variance.
Pitfalls
- Skipping centring: the first component points at the mean, not along the spread.
- PCA on raw features with different units: the result reflects units, not structure.
- Fitting PCA on train+test: information leaks into evaluation.
- Using
σᵢ²/nvsσᵢ²/(n−1)inconsistently when comparing explained variance with a library. - q = 0 on a slowly decaying spectrum (text, embeddings): the randomized estimate is noticeably worse than optimal. Use q = 2 or more.
- Whitening (dividing by σᵢ) components with tiny σᵢ amplifies noise. Add ε or drop them.
Variants and alternatives
scipy.sparse.linalg.svds)iterative top-k for sparse matrices, high accuracy, more passes.In production
LAPACK gesdd/gesvd/syevd (via MKL, OpenBLAS, Accelerate), scikit-learn PCA (full, randomized, ARPACK) and TruncatedSVD, torch.linalg.svd and torch.svd_lowrank, Faiss PCAMatrix before IVF-PQ, Spark MLlib RowMatrix.computeSVD; EIGENSOFT/smartpca for genotype data; SVD-based matrix factorization in recommenders.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| linfa-reduction | Rust | rust-ml/linfa | cargo add linfa linfa-reduction | — | — | — | — | PCA, diffusion maps, random projection |
| faer | Rust | sarah-quinones/faer-rs | cargo add faer | — | — | — | — | dense SVD and eigendecomposition, pure Rust |
| nalgebra | Rust | dimforge/nalgebra | cargo add nalgebra | — | — | — | — | small and medium SVD, no BLAS needed |
| ndarray-linalg | Rust | rust-ndarray/ndarray-linalg | cargo add ndarray-linalg | — | — | — | — | LAPACK-backed SVD on ndarray; needs a BLAS/LAPACK backend feature |
| scikit-learn | Python | scikit-learn/scikit-learn | pip install scikit-learn | — | — | — | — | PCA with svd_solver="randomized", IncrementalPCA |
Sources
- Patterson, Price, Reich, Population Structure and Eigenanalysis, PLoS Genetics 2006. paper
- Koren, Bell, Volinsky, Matrix Factorization Techniques for Recommender Systems, IEEE Computer 2009. paper
- Halko, Martinsson, Tropp, Finding Structure with Randomness: Probabilistic Algorithms for Constructing Approximate Matrix Decompositions, SIAM Review 2011. paper · arXiv
- Eckart and Young, The Approximation of One Matrix by Another of Lower Rank, Psychometrika 1936. paper
- Golub and Van Loan, Matrix Computations, 4th ed., 2013.
- Jolliffe, Principal Component Analysis, 2nd ed., Springer 2002.
- Tipping and Bishop, Probabilistic Principal Component Analysis, JRSS B 1999. paper