Approach: center X, then compute SVD efficiently. If n_samples >= n_features, do economy SVD on X_centered (U, S, Vt) and take Vt as components. If n_samples < n_features, compute eigen-decomposition (or SVD) of the smaller n x n matrix X X^T to get U and singular values, then recover components = (X_centered.T @ U) / S. Return components (principal axes), transformed data (scores = X_centered @ components.T), and explained variance ratio.python
import numpy as np
def pca_svd(X, n_components=None, eps=1e-12):
"""
PCA via SVD without sklearn.
Returns: components (k, p), scores (n, k), explained_variance_ratio (k,)
"""
X = np.asarray(X, dtype=float)
n, p = X.shape
# center
mean = X.mean(axis=0)
Xc = X - mean
# max components
max_k = min(n, p)
if n_components is None:
k = max_k
else:
k = min(int(n_components), max_k)
if n >= p:
# compute compact SVD on Xc: U (n x p), S (p,), Vt (p x p) with full_matrices=False
U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
components = Vt[:k, :] # principal axes (k, p)
scores = Xc @ components.T # (n, k)
else:
# n < p: compute SVD on X X^T (n x n) to get U and S, then recover V
# Note: Xc @ Xc.T = U (n x n) * S^2 * U.T
M = Xc @ Xc.T
# eigen-decomp of M (symmetric) is cheaper: returns eigenvalues descending
eigvals, U = np.linalg.eigh(M)
# eigh returns ascending eigenvalues -> reverse
eigvals = eigvals[::-1]
U = U[:, ::-1]
# numerical stability: singular values
S_all = np.sqrt(np.clip(eigvals, 0, None))
# keep top k where singular values > eps
S = S_all[:k]
U = U[:, :k] # (n, k)
# components V = Xc.T @ U / S
components = (Xc.T @ U) / (S.reshape(1, -1)) # (p, k) then transpose
components = components.T # (k, p)
scores = U * S.reshape(1, -1) # (n, k)
# explained variance: sigma^2 / (n - 1)
# singular values S (length k). If we computed full S earlier, ensure using the right S
# recompute singular values for consistency:
# For n>=p branch S already available; for other branch S set above
if n >= p:
var_explained = (S[:k] ** 2) / (n - 1)
else:
var_explained = (S ** 2) / (n - 1)
total_var = np.sum(np.var(Xc, axis=0, ddof=1))
# handle degenerate total_var
if total_var <= 0:
explained_variance_ratio = np.zeros_like(var_explained)
else:
explained_variance_ratio = var_explained / total_var
return components, scores, explained_variance_ratio
Key points:- components shape (k, n_features): each row is a principal axis.- scores = projection of centered data onto components.- Time complexity: O(min(n,p)^2 * max(n,p)). Using n x n eig when n << p reduces cost.- Edge cases: constant features (zero variance), n_components > min(n,p), numerical tiny singular values—handled via clipping and eps.- Alternative: randomized SVD for very large matrices (faster approximate PCA).