Principal Component Analysis & SVD
Derive Principal Component Analysis from both variance maximization and reconstruction error minimization, the SVD spectral connection, and zero-dependency implementations in Python and Rust.
Real-world datasets often possess high ambient dimensionality while lying predominantly on low-dimensional linear or affine subspaces. High dimensionality inflates sample complexity, exacerbates the curse of dimensionality, and renders direct visualization impossible.
Principal Component Analysis (PCA) is the foundational linear dimensionality reduction technique. It rotates the coordinate frame along directions of maximal variance, decorrelating features and yielding optimal low-rank reconstructions.
The optimal orthogonal projection axes correspond exactly to the eigenvectors of the empirical covariance matrix.
1. Variance Maximization Derivation#
Let denote a zero-centered dataset where sample mean .
The empirical covariance matrix is symmetric and positive semi-definite:
We seek a unit projection vector () such that projecting data points onto maximizes the sample variance.
Step 1: Formulate the Projected Variance
The scalar projection of sample onto unit vector is .
Because is zero-centered, the mean of the projected coordinates is also zero:
The empirical variance along direction is:
Step 2: Formulate Constrained Optimization via Lagrange Multipliers
We maximize subject to the unit-norm constraint :
Step 3: Differentiate and Solve for Stationarity
Differentiate with respect to vector :
Dividing by 2 yields the fundamental Eigenvalue Equation:
Step 4: Maximize the Objective
Premultiply both sides by :
Because is the projected variance, the variance along direction equals the eigenvalue . To maximize variance, must be the eigenvector of covariance matrix corresponding to the largest eigenvalue .
Subsequent principal components are obtained by repeating this optimization under the additional mutual orthogonality constraints for all .
2. Equivalence to Reconstruction Error Minimization#
An alternative derivation of PCA seeks a -dimensional orthogonal projection subspace that minimizes the mean squared reconstruction error between original samples and reconstructed vectors .
By the Pythagorean theorem in Euclidean metric space:
Summing over all data points and dividing by :
Observe that the left side is a constant independent of projection basis .
Therefore, minimizing reconstruction error is mathematically identical to maximizing projected variance .
3. Singular Value Decomposition (SVD) Connection#
Directly computing covariance matrix incurs operations and risks squaring the condition number of ill-conditioned data matrices.
Modern PCA implementations compute principal components directly via Singular Value Decomposition (SVD) of centered data matrix :
Where:
- is orthogonal () containing left singular vectors.
- is rectangular diagonal containing singular values .
- is orthogonal () containing right singular vectors.
Evaluating :
Therefore, the right singular vectors of are identically the eigenvectors of covariance matrix , and the eigenvalues relate to singular values by:
The low-dimensional projection evaluates without explicit covariance matrix construction.
4. From-Scratch Implementation: Zero Dependencies#
The following implementations compute PCA from scratch in Python and Rust using Power Iteration to extract dominant eigenvectors without relying on third-party linear algebra packages.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889import math from typing import List, Tuple class PCAFromScratch: def __init__(self, n_components: int = 2, max_iter: int = 300, tol: float = 1e-6): self.n_components = n_components self.max_iter = max_iter self.tol = tol self.components: List[List[float]] = [] self.mean: List[float] = [] @staticmethod def _dot(a: List[float], b: List[float]) -> float: return sum(x * y for x, y in zip(a, b)) @staticmethod def _norm(a: List[float]) -> float: return math.sqrt(sum(x ** 2 for x in a)) def _power_iteration(self, cov: List[List[float]], num_features: int) -> Tuple[float, List[float]]: # Initialize candidate vector b_k = [1.0 / math.sqrt(num_features)] * num_features for _ in range(self.max_iter): # Matrix-vector product: cov * b_k b_next = [self._dot(cov[i], b_k) for i in range(num_features)] norm = self._norm(b_next) if norm == 0.0: break b_next = [x / norm for x in b_next] # Check convergence diff = self._norm([x - y for x, y in zip(b_next, b_k)]) b_k = b_next if diff < self.tol: break # Rayleigh quotient: lambda = b^T * cov * b eigenvalue = self._dot(b_k, [self._dot(cov[i], b_k) for i in range(num_features)]) return eigenvalue, b_k def fit(self, X: List[List[float]]) -> None: num_samples = len(X) num_features = len(X[0]) # 1. Compute empirical mean and center data self.mean = [sum(row[j] for row in X) / num_samples for j in range(num_features)] X_centered = [[row[j] - self.mean[j] for j in range(num_features)] for row in X] # 2. Compute covariance matrix: 1/N * X^T * X cov = [[0.0] * num_features for _ in range(num_features)] for i in range(num_features): for j in range(i, num_features): val = sum(X_centered[k][i] * X_centered[k][j] for k in range(num_samples)) / num_samples cov[i][j] = val cov[j][i] = val # 3. Extract top components using power iteration and deflation self.components = [] for _ in range(self.n_components): eigenval, eigenvector = self._power_iteration(cov, num_features) self.components.append(eigenvector) # Deflate covariance matrix: cov = cov - lambda * (v * v^T) for i in range(num_features): for j in range(num_features): cov[i][j] -= eigenval * eigenvector[i] * eigenvector[j] def transform(self, X: List[List[float]]) -> List[List[float]]: num_features = len(self.mean) projected = [] for row in X: centered = [row[j] - self.mean[j] for j in range(num_features)] coords = [self._dot(centered, comp) for comp in self.components] projected.append(coords) return projected if __name__ == "__main__": X = [ [2.5, 2.4], [0.5, 0.7], [2.2, 2.9], [1.9, 2.2], [3.1, 3.0], [2.3, 2.7], [2.0, 1.6], [1.0, 1.1], [1.5, 1.6], [1.1, 0.9] ] pca = PCAFromScratch(n_components=1) pca.fit(X) X_proj = pca.transform(X) print("Principal Component Vector:", pca.components[0]) print("Projected 1D Coordinates (first 3):", X_proj[:3])
5. Active Knowledge Checks#
In the constrained optimization derivation of PCA via Lagrange multipliers, why does the stationarity condition nabla_u L = 0 resolve directly to the eigenvalue equation Sigma * u = lambda * u?
How does the Singular Value Decomposition X = U * S * V^T relate to the Principal Components of covariance matrix 1/N * X^T * X?