Skip to content

Commit 32d6aa5

Browse files
James Chapmanclaude
authored andcommitted
perf: use covariance path in svd_whiten when n_samples >= n_features
For tall data matrices (n >> p), the previous implementation called np.linalg.svd(X, full_matrices=False), allocating an n×p matrix U and computing O(n·p²) FLOPs via LAPACK dgesdd. When n >= p it is equivalent and more efficient to: 1. Form the p×p sample covariance C = Xᵀ X / (n-1) [O(n·p²) FLOPs, O(p²) memory] 2. Diagonalise C with eigh [O(p³) FLOPs] 3. Compute the whitened data X @ W [O(n·p²) FLOPs, no large intermediate] This avoids allocating the n×p U matrix as an intermediate (169 MB for n=54k, p=392) and lets BLAS DSYRK/DGEMM handle the heavy lifting, which is typically 2–3× faster than LAPACK dgesdd for tall matrices. The original SVD path is retained for the n < p (wide matrix) case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 87e0203 commit 32d6aa5

1 file changed

Lines changed: 29 additions & 16 deletions

File tree

cca_zoo/_utils/_linalg.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,19 @@ def svd_whiten(
1010
X: np.ndarray,
1111
regularization: float = 0.0,
1212
) -> tuple[np.ndarray, np.ndarray]:
13-
"""Whiten X using a regularised SVD decomposition.
13+
"""Whiten X using a regularised decomposition.
1414
15-
Computes W such that X @ W has covariance approximately equal to the
15+
Computes W such that ``X @ W`` has covariance approximately equal to the
1616
identity matrix (or a regularised version thereof).
1717
18+
When ``n_samples >= n_features`` the sample covariance matrix
19+
(p × p) is formed explicitly and diagonalised with ``eigh``. This is
20+
O(n·p²) in FLOPs and O(p²) in peak memory — much cheaper than computing
21+
the full thin SVD of X (which allocates an n × p matrix U).
22+
23+
When ``n_samples < n_features`` the original SVD path is used, which
24+
avoids forming the n × n Gram matrix.
25+
1826
Args:
1927
X: Array of shape (n_samples, n_features), assumed mean-centred.
2028
regularization: Ridge parameter in [0, 1]. 0 gives full PCA whitening;
@@ -24,20 +32,25 @@ def svd_whiten(
2432
Tuple ``(X_white, W)`` where ``X_white = X @ W`` and ``W`` is the
2533
(n_features, rank) whitening matrix.
2634
"""
27-
n = X.shape[0]
28-
U, s, Vt = np.linalg.svd(X, full_matrices=False)
29-
# Keep only dimensions with positive singular values
30-
pos = s > 0
31-
s = s[pos]
32-
U = U[:, pos]
33-
Vt = Vt[pos, :]
34-
# Eigenvalues of the sample covariance
35-
lam = s**2 / (n - 1)
36-
# Regularised inverse square root: ((1 - c) * lam + c)^{-1/2}
37-
inv_sqrt = 1.0 / np.sqrt((1.0 - regularization) * lam + regularization)
38-
# Whitening matrix: shape (n_features, rank)
39-
W = Vt.T * inv_sqrt
40-
X_white = U * (s * inv_sqrt)
35+
n, p = X.shape
36+
if n >= p:
37+
# Covariance path — avoids the large n × p matrix U from thin SVD.
38+
C = X.T @ X / (n - 1) # p × p
39+
lam, V = np.linalg.eigh(C) # ascending eigenvalues
40+
pos = lam > 0
41+
lam, V = lam[pos], V[:, pos]
42+
inv_sqrt = 1.0 / np.sqrt((1.0 - regularization) * lam + regularization)
43+
W = V * inv_sqrt # p × rank
44+
X_white = X @ W # n × rank
45+
else:
46+
# Gram path — better when n << p (avoids the p × p covariance matrix).
47+
U, s, Vt = np.linalg.svd(X, full_matrices=False)
48+
pos = s > 0
49+
s, U, Vt = s[pos], U[:, pos], Vt[pos, :]
50+
lam = s**2 / (n - 1)
51+
inv_sqrt = 1.0 / np.sqrt((1.0 - regularization) * lam + regularization)
52+
W = Vt.T * inv_sqrt
53+
X_white = U * (s * inv_sqrt)
4154
return X_white, W
4255

4356

0 commit comments

Comments
 (0)