holonomy-lib 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- holonomy_explorer/__init__.py +1 -0
- holonomy_explorer/__main__.py +79 -0
- holonomy_explorer/batching.py +182 -0
- holonomy_explorer/common.py +187 -0
- holonomy_explorer/curvature.py +154 -0
- holonomy_explorer/datasets.py +346 -0
- holonomy_explorer/graphs.py +286 -0
- holonomy_explorer/server.py +172 -0
- holonomy_explorer/static/app.js +435 -0
- holonomy_explorer/static/charts.js +114 -0
- holonomy_explorer/static/index.html +45 -0
- holonomy_explorer/static/layout.js +85 -0
- holonomy_explorer/static/style.css +121 -0
- holonomy_explorer/static/view.js +195 -0
- holonomy_explorer/topology.py +118 -0
- holonomy_lib/__init__.py +9 -0
- holonomy_lib/_graph_utils.py +52 -0
- holonomy_lib/_quadrature.py +74 -0
- holonomy_lib/algebra/__init__.py +32 -0
- holonomy_lib/algebra/eigsh_sparse.py +251 -0
- holonomy_lib/algebra/lanczos.py +361 -0
- holonomy_lib/algebra/linear.py +180 -0
- holonomy_lib/algebra/solvers.py +547 -0
- holonomy_lib/convex/__init__.py +27 -0
- holonomy_lib/convex/least_squares.py +184 -0
- holonomy_lib/discrete_geometry/__init__.py +38 -0
- holonomy_lib/discrete_geometry/forman.py +242 -0
- holonomy_lib/discrete_geometry/ricci.py +855 -0
- holonomy_lib/experimental/__init__.py +13 -0
- holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
- holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
- holonomy_lib/hyperbolic/__init__.py +27 -0
- holonomy_lib/hyperbolic/frechet_mean.py +381 -0
- holonomy_lib/hyperbolic/heat_kernel.py +608 -0
- holonomy_lib/hyperbolic/manifold_inner.py +81 -0
- holonomy_lib/info_geometry/__init__.py +64 -0
- holonomy_lib/info_geometry/divergences.py +224 -0
- holonomy_lib/info_geometry/fisher.py +179 -0
- holonomy_lib/lie/__init__.py +54 -0
- holonomy_lib/lie/so3.py +385 -0
- holonomy_lib/lie/spherical_harmonics.py +189 -0
- holonomy_lib/manifolds/__init__.py +53 -0
- holonomy_lib/manifolds/comparison.py +343 -0
- holonomy_lib/manifolds/fixed_rank.py +453 -0
- holonomy_lib/manifolds/lorentz.py +699 -0
- holonomy_lib/manifolds/lorentzian.py +567 -0
- holonomy_lib/manifolds/product.py +425 -0
- holonomy_lib/manifolds/spd.py +504 -0
- holonomy_lib/manifolds/stereographic.py +680 -0
- holonomy_lib/optimization/__init__.py +42 -0
- holonomy_lib/optimization/base.py +65 -0
- holonomy_lib/optimization/sgd.py +112 -0
- holonomy_lib/py.typed +0 -0
- holonomy_lib/sheaf/__init__.py +74 -0
- holonomy_lib/sheaf/graph_sheaf.py +352 -0
- holonomy_lib/sheaf/laplacian.py +529 -0
- holonomy_lib/simplicial/__init__.py +55 -0
- holonomy_lib/simplicial/_face_lookup.py +99 -0
- holonomy_lib/simplicial/complex.py +168 -0
- holonomy_lib/simplicial/sparse_complex.py +145 -0
- holonomy_lib/simplicial/vietoris_rips.py +277 -0
- holonomy_lib/spectral/__init__.py +55 -0
- holonomy_lib/spectral/diffusion_map.py +169 -0
- holonomy_lib/spectral/effective_resistance.py +199 -0
- holonomy_lib/spectral/embedding.py +131 -0
- holonomy_lib/spectral/heat_kernel.py +236 -0
- holonomy_lib/spectral/laplacian.py +441 -0
- holonomy_lib/spectral/magnetic.py +351 -0
- holonomy_lib/spectral/spectral_dimension.py +104 -0
- holonomy_lib/tensor_calculus/__init__.py +23 -0
- holonomy_lib/tensor_calculus/decomposition.py +208 -0
- holonomy_lib/topology/__init__.py +28 -0
- holonomy_lib/topology/_filtration.py +147 -0
- holonomy_lib/topology/_reduction.py +227 -0
- holonomy_lib/topology/hodge.py +250 -0
- holonomy_lib/topology/persistent_homology.py +301 -0
- holonomy_lib-0.8.0.dist-info/METADATA +161 -0
- holonomy_lib-0.8.0.dist-info/RECORD +82 -0
- holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
- holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
- holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
- holonomy_lib-0.8.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Batched symmetric eigenpairs with fully reorthogonalized Lanczos.
|
|
5
|
+
|
|
6
|
+
The Krylov recurrence is sequential in the subspace dimension; each step
|
|
7
|
+
applies the operator and orthogonalizes all batch members together. A
|
|
8
|
+
member whose Krylov space closes restarts in its orthogonal complement,
|
|
9
|
+
with zero coupling between the two Lanczos blocks. Full-subspace requests
|
|
10
|
+
use ``torch.linalg.eigh`` directly.
|
|
11
|
+
|
|
12
|
+
Dense LA and unshifted SA select the largest and smallest algebraic modes.
|
|
13
|
+
An explicit dense SA shift selects modes nearest that shift. Sparse SA
|
|
14
|
+
uses the backends in :mod:`holonomy_lib.algebra.eigsh_sparse`.
|
|
15
|
+
|
|
16
|
+
References:
|
|
17
|
+
Lanczos, C. (1950). An iteration method for the solution of the
|
|
18
|
+
eigenvalue problem of linear differential and integral operators.
|
|
19
|
+
Journal of Research of the National Bureau of Standards 45:255-282.
|
|
20
|
+
Paige, C. C. (1972). Computational variants of the Lanczos method
|
|
21
|
+
for the eigenproblem. J. Inst. Math. Appl. 10:373-381.
|
|
22
|
+
Saad, Y. (2011). Numerical Methods for Large Eigenvalue Problems,
|
|
23
|
+
2nd ed. SIAM, §6.5 (full reorthogonalization).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
import warnings
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
import torch
|
|
34
|
+
|
|
35
|
+
# Extra Krylov dimensions beyond k help the top-k Ritz pairs converge
|
|
36
|
+
# (Saad 2011, §6.5). Cataloged as LANCZOS_OVERSAMPLE_DEFAULT.
|
|
37
|
+
LANCZOS_OVERSAMPLE_DEFAULT: int = 10
|
|
38
|
+
|
|
39
|
+
_SPARSE_LAYOUTS = (torch.sparse_coo, torch.sparse_csr, torch.sparse_csc)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def lanczos_eigsh(
|
|
43
|
+
A: torch.Tensor,
|
|
44
|
+
k: int,
|
|
45
|
+
n_iter: int | None = None,
|
|
46
|
+
oversample: int = LANCZOS_OVERSAMPLE_DEFAULT,
|
|
47
|
+
generator: torch.Generator | None = None,
|
|
48
|
+
which: Literal["LA", "SA"] = "LA",
|
|
49
|
+
sigma: float | None = None,
|
|
50
|
+
solver: Literal["auto", "eigsh", "lobpcg", "torch_lobpcg"] = "auto",
|
|
51
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
52
|
+
"""Return extreme symmetric eigenpairs, or dense modes nearest ``sigma``.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
A: real symmetric dense ``(..., n, n)`` or sparse ``(n, n)``.
|
|
56
|
+
Asymmetry emits a warning; callers must supply a symmetric operator.
|
|
57
|
+
k: number of eigenpairs, ``1 <= k <= n``.
|
|
58
|
+
n_iter: Krylov dimension, default ``min(k + oversample, n)``.
|
|
59
|
+
A full-dimensional request uses dense ``eigh``.
|
|
60
|
+
oversample: nonnegative extra dimensions when ``n_iter`` is omitted.
|
|
61
|
+
generator: random-start generator compatible with ``A.device``.
|
|
62
|
+
which: ``LA`` selects largest algebraic eigenvalues; ``SA`` selects
|
|
63
|
+
smallest algebraic eigenvalues unless a dense ``sigma`` is supplied.
|
|
64
|
+
sigma: for dense SA, select eigenvalues nearest this finite shift and
|
|
65
|
+
return them in ascending order. Partial-subspace requests factor
|
|
66
|
+
``A - sigma I`` and reject a singular factorization. For sparse SA,
|
|
67
|
+
an explicit pole must lie below the Gershgorin lower bound, so the
|
|
68
|
+
smallest-algebraic contract is preserved.
|
|
69
|
+
solver: sparse-SA backend; see ``eigsh_sparse_sa``.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Eigenvalues ``(..., k)`` and eigenvectors ``(..., n, k)``; sparse
|
|
73
|
+
input has no batch dimension. LA is descending, SA ascending.
|
|
74
|
+
|
|
75
|
+
Notes:
|
|
76
|
+
Partial-subspace results are Ritz approximations. Increasing
|
|
77
|
+
``n_iter`` improves convergence; no fixed iteration count guarantees
|
|
78
|
+
accuracy for every spectrum. Shift-invert warns if the original
|
|
79
|
+
operator's relative Ritz residual exceeds ``n * sqrt(eps(dtype))``.
|
|
80
|
+
Dense autograd follows the computed subspace, with the usual
|
|
81
|
+
eigenvector-gradient limitations at repeated eigenvalues. Sparse
|
|
82
|
+
eigensolvers are explicitly nondifferentiable.
|
|
83
|
+
|
|
84
|
+
References:
|
|
85
|
+
Lanczos (1950); Paige (1972); Saad (2011), §6.5, cited above.
|
|
86
|
+
Ericsson, T., Ruhe, A. (1980). The spectral transformation Lanczos
|
|
87
|
+
method for the numerical solution of large sparse generalized
|
|
88
|
+
symmetric eigenvalue problems. Math. Comp. 35(152):1251-1268.
|
|
89
|
+
"""
|
|
90
|
+
sparse = A.layout in _SPARSE_LAYOUTS
|
|
91
|
+
if A.ndim < 2 or A.shape[-1] != A.shape[-2] or (sparse and A.ndim != 2):
|
|
92
|
+
raise ValueError(f"A must be (..., n, n), sparse (n, n); got {A.shape}")
|
|
93
|
+
if not A.is_floating_point():
|
|
94
|
+
raise ValueError("A must have a real floating dtype")
|
|
95
|
+
n = A.shape[-1]
|
|
96
|
+
if not isinstance(k, int) or isinstance(k, bool) or not 1 <= k <= n:
|
|
97
|
+
raise ValueError(f"k must satisfy 1 <= k <= n={n}; got k={k}")
|
|
98
|
+
if (not isinstance(oversample, int) or isinstance(oversample, bool)
|
|
99
|
+
or oversample < 0):
|
|
100
|
+
raise ValueError("oversample must be a nonnegative integer")
|
|
101
|
+
if which not in ("LA", "SA"):
|
|
102
|
+
raise ValueError("which must be 'LA' or 'SA'")
|
|
103
|
+
if sigma is not None and (which != "SA" or not math.isfinite(sigma)):
|
|
104
|
+
raise ValueError("sigma must be finite and is only meaningful with which='SA'")
|
|
105
|
+
if n_iter is None:
|
|
106
|
+
n_iter = k + oversample
|
|
107
|
+
if not isinstance(n_iter, int) or isinstance(n_iter, bool) or n_iter < k:
|
|
108
|
+
raise ValueError(f"n_iter must be an integer >= k={k}")
|
|
109
|
+
# The default, or an explicit n_iter, can exceed n. Clamping after the
|
|
110
|
+
# k check keeps n_iter >= k, since k <= n. A clamped n_iter == n routes
|
|
111
|
+
# to dense eigh below.
|
|
112
|
+
n_iter = min(n_iter, n)
|
|
113
|
+
# Sparse input can't be differentiated here, so requires_grad is rejected
|
|
114
|
+
# outright instead of being detached silently. SA goes to the dedicated
|
|
115
|
+
# backend, which handles its own shift. LA falls through to the Lanczos
|
|
116
|
+
# loop below, where the matvec is a sparse product.
|
|
117
|
+
if sparse:
|
|
118
|
+
if A.requires_grad:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"sparse eigensolvers do not support autograd; detach A explicitly"
|
|
121
|
+
)
|
|
122
|
+
if which == "SA":
|
|
123
|
+
from holonomy_lib.algebra.eigsh_sparse import eigsh_sparse_sa
|
|
124
|
+
|
|
125
|
+
return eigsh_sparse_sa(
|
|
126
|
+
A, k, sigma=sigma, solver=solver, generator=generator
|
|
127
|
+
)
|
|
128
|
+
_warn_asymmetric(A)
|
|
129
|
+
# A Krylov space of full dimension n already spans everything, so dense
|
|
130
|
+
# eigh gives the same answer with no iteration. An empty batch has nothing
|
|
131
|
+
# to iterate over and takes this path too.
|
|
132
|
+
if n_iter == n or A.numel() == 0:
|
|
133
|
+
dense = A.to_dense() if sparse else A
|
|
134
|
+
values, vectors = torch.linalg.eigh(dense)
|
|
135
|
+
return _select(values, vectors, k, which, sigma)
|
|
136
|
+
|
|
137
|
+
# With a shift, Lanczos runs on (A - sigma I)^-1 instead. That turns the
|
|
138
|
+
# modes nearest sigma into the largest inverse eigenvalues. _shift_invert
|
|
139
|
+
# does the factorization and checks the result against the original A.
|
|
140
|
+
if sigma is not None:
|
|
141
|
+
return _shift_invert(A, k, n_iter, sigma, generator)
|
|
142
|
+
|
|
143
|
+
def matvec(vector: torch.Tensor) -> torch.Tensor:
|
|
144
|
+
return (A @ vector.unsqueeze(-1)).squeeze(-1)
|
|
145
|
+
|
|
146
|
+
values, vectors = _iterate(matvec, A, n_iter, generator)
|
|
147
|
+
return _select(values, vectors, k, which, None)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _warn_asymmetric(A: torch.Tensor) -> None:
|
|
151
|
+
"""Emit a UserWarning when A is not symmetric. Called once per solver call.
|
|
152
|
+
|
|
153
|
+
Input is a dense or sparse A. Output is None; the only effect is the
|
|
154
|
+
warning. The check is advisory, so an asymmetric A still reaches the solver.
|
|
155
|
+
"""
|
|
156
|
+
if A.layout in _SPARSE_LAYOUTS:
|
|
157
|
+
# Sparse path: compare the stored values of A - A^T without densifying.
|
|
158
|
+
# The coalesce keeps entries that exist in only one of A or A^T, so a
|
|
159
|
+
# missing mirror entry still shows up as a difference.
|
|
160
|
+
coo = A.to_sparse_coo().coalesce()
|
|
161
|
+
difference = (coo - coo.mT).coalesce().values()
|
|
162
|
+
# amax fails on an empty value tensor, so an all-zero matrix gets scale 0.
|
|
163
|
+
scale = coo.values().abs().amax() if coo._nnz() else coo.values().new_zeros(())
|
|
164
|
+
# Tolerance is n * eps * max entry, the size of roundoff in a row sum
|
|
165
|
+
# of n terms.
|
|
166
|
+
tolerance = A.shape[-1] * torch.finfo(A.dtype).eps * scale
|
|
167
|
+
symmetric = bool((difference.abs() <= tolerance).all())
|
|
168
|
+
else:
|
|
169
|
+
# Same rule as the sparse path, per batch member: roundoff in a row of
|
|
170
|
+
# n terms is about n * eps * (largest entry), so anything bigger than
|
|
171
|
+
# that is real asymmetry. Each member uses its own scale so one large
|
|
172
|
+
# matrix doesn't hide asymmetry in a small one.
|
|
173
|
+
scale = A.abs().amax(dim=(-2, -1), keepdim=True)
|
|
174
|
+
tolerance = A.shape[-1] * torch.finfo(A.dtype).eps * scale
|
|
175
|
+
symmetric = bool(((A - A.mT).abs() <= tolerance).all())
|
|
176
|
+
if not symmetric:
|
|
177
|
+
warnings.warn(
|
|
178
|
+
"symmetric eigensolver received asymmetric A", UserWarning, stacklevel=2
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _select(
|
|
183
|
+
values: torch.Tensor,
|
|
184
|
+
vectors: torch.Tensor,
|
|
185
|
+
k: int,
|
|
186
|
+
which: str,
|
|
187
|
+
sigma: float | None,
|
|
188
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
189
|
+
"""Select ordered columns from a symmetric eigendecomposition."""
|
|
190
|
+
if sigma is not None:
|
|
191
|
+
# Take the k values nearest sigma, then put those k back in ascending
|
|
192
|
+
# order so every mode returns in the same convention.
|
|
193
|
+
indices = (values - sigma).abs().argsort(dim=-1)[..., :k]
|
|
194
|
+
chosen = values.gather(-1, indices)
|
|
195
|
+
indices = indices.gather(-1, chosen.argsort(dim=-1))
|
|
196
|
+
else:
|
|
197
|
+
indices = values.argsort(dim=-1, descending=which == "LA")[..., :k]
|
|
198
|
+
# Eigenvectors are columns, so the index is broadcast down the n rows
|
|
199
|
+
# before gathering along the column axis.
|
|
200
|
+
return (
|
|
201
|
+
values.gather(-1, indices),
|
|
202
|
+
vectors.gather(-1, indices.unsqueeze(-2).expand(*vectors.shape[:-1], k)),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _orthogonalize(vector: torch.Tensor, basis: torch.Tensor) -> torch.Tensor:
|
|
207
|
+
"""Project `vector` off `basis` with two Gram-Schmidt passes.
|
|
208
|
+
|
|
209
|
+
Input: vector (..., n); basis (..., n, j) with orthonormal columns.
|
|
210
|
+
Output: the residual, orthogonal to the basis up to roundoff. The second
|
|
211
|
+
pass removes the roundoff the first one leaves behind.
|
|
212
|
+
"""
|
|
213
|
+
for _ in range(2):
|
|
214
|
+
vector = vector - (basis @ (basis.mT @ vector.unsqueeze(-1))).squeeze(-1)
|
|
215
|
+
return vector
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _iterate(
|
|
219
|
+
matvec: Callable[[torch.Tensor], torch.Tensor],
|
|
220
|
+
A: torch.Tensor,
|
|
221
|
+
n_iter: int,
|
|
222
|
+
generator: torch.Generator | None,
|
|
223
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
224
|
+
"""Run one Lanczos recurrence per batch member, then diagonalize the projection.
|
|
225
|
+
|
|
226
|
+
Input: matvec applies the symmetric operator to (..., n) vectors; A gives the
|
|
227
|
+
shape and dtype; n_iter is the Krylov dimension, at most n; generator seeds
|
|
228
|
+
the random start. Output: Ritz values (..., n_iter), ascending, and Ritz
|
|
229
|
+
vectors (..., n, n_iter). Each member has its own start and its own basis,
|
|
230
|
+
so members never share a Krylov space.
|
|
231
|
+
"""
|
|
232
|
+
n = A.shape[-1]
|
|
233
|
+
# One random unit start per batch member, drawn in a single call.
|
|
234
|
+
vector = torch.randn(
|
|
235
|
+
*A.shape[:-1],
|
|
236
|
+
dtype=A.dtype,
|
|
237
|
+
device=A.device,
|
|
238
|
+
generator=generator,
|
|
239
|
+
)
|
|
240
|
+
vector = vector / torch.linalg.vector_norm(vector, dim=-1, keepdim=True)
|
|
241
|
+
columns: list[torch.Tensor] = []
|
|
242
|
+
diagonal: list[torch.Tensor] = []
|
|
243
|
+
off_diagonal: list[torch.Tensor] = []
|
|
244
|
+
# No previous vector before the first step. beta_0 = 0 makes the
|
|
245
|
+
# subtraction below a no-op.
|
|
246
|
+
previous = torch.zeros_like(vector)
|
|
247
|
+
previous_beta = vector.new_zeros(vector.shape[:-1])
|
|
248
|
+
for step in range(n_iter):
|
|
249
|
+
# Every update below builds a new tensor and nothing is written in
|
|
250
|
+
# place, so the vectors kept in `columns` keep their values for autograd.
|
|
251
|
+
columns.append(vector)
|
|
252
|
+
basis = torch.stack(columns, dim=-1)
|
|
253
|
+
applied = matvec(vector)
|
|
254
|
+
alpha = (applied * vector).sum(dim=-1)
|
|
255
|
+
diagonal.append(alpha)
|
|
256
|
+
residual = applied - alpha.unsqueeze(-1) * vector
|
|
257
|
+
residual = residual - previous_beta.unsqueeze(-1) * previous
|
|
258
|
+
residual = _orthogonalize(residual, basis)
|
|
259
|
+
if step + 1 == n_iter:
|
|
260
|
+
break
|
|
261
|
+
beta = torch.linalg.vector_norm(residual, dim=-1)
|
|
262
|
+
scale = torch.linalg.vector_norm(applied, dim=-1)
|
|
263
|
+
# Breakdown: the residual has shrunk to roundoff relative to the size of
|
|
264
|
+
# A v, so this member's Krylov space has closed. Its next vector is a
|
|
265
|
+
# restart, and its beta is zeroed below so the two blocks stay uncoupled.
|
|
266
|
+
breakdown = beta <= n * torch.finfo(A.dtype).eps * scale
|
|
267
|
+
safe_beta = torch.where(breakdown, torch.ones_like(beta), beta)
|
|
268
|
+
|
|
269
|
+
# The diagonal of the basis projector sums to the number of basis
|
|
270
|
+
# vectors, which is below n here. So the coordinate with the least mass
|
|
271
|
+
# has mass under 1, and e_i is not entirely inside the span. Projecting
|
|
272
|
+
# e_i off the basis gives a nonzero restart vector.
|
|
273
|
+
coordinate = basis.square().sum(dim=-1).argmin(dim=-1)
|
|
274
|
+
restart = torch.nn.functional.one_hot(coordinate, num_classes=n).to(A.dtype)
|
|
275
|
+
restart = _orthogonalize(restart, basis)
|
|
276
|
+
restart = restart / torch.linalg.vector_norm(restart, dim=-1, keepdim=True)
|
|
277
|
+
# On breakdown the next vector is the restart. Otherwise it is the
|
|
278
|
+
# normalized residual.
|
|
279
|
+
next_vector = torch.where(
|
|
280
|
+
breakdown.unsqueeze(-1),
|
|
281
|
+
restart,
|
|
282
|
+
residual / safe_beta.unsqueeze(-1),
|
|
283
|
+
)
|
|
284
|
+
previous_beta = torch.where(breakdown, torch.zeros_like(beta), beta)
|
|
285
|
+
off_diagonal.append(previous_beta)
|
|
286
|
+
previous, vector = vector, next_vector
|
|
287
|
+
# T is tridiagonal: alpha on the diagonal and beta one step off it. The
|
|
288
|
+
# last step breaks before computing a beta, so there is one fewer beta
|
|
289
|
+
# than alpha. With n_iter = 1 there are no betas at all.
|
|
290
|
+
projected = torch.diag_embed(torch.stack(diagonal, dim=-1))
|
|
291
|
+
if off_diagonal:
|
|
292
|
+
off = torch.stack(off_diagonal, dim=-1)
|
|
293
|
+
projected = projected + torch.diag_embed(off, offset=1)
|
|
294
|
+
projected = projected + torch.diag_embed(off, offset=-1)
|
|
295
|
+
# Ritz pairs: eigenvalues of T, with vectors equal to the Lanczos basis
|
|
296
|
+
# times the eigenvectors of T.
|
|
297
|
+
values, small_vectors = torch.linalg.eigh(projected)
|
|
298
|
+
return values, torch.stack(columns, dim=-1) @ small_vectors
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _shift_invert(
|
|
302
|
+
A: torch.Tensor,
|
|
303
|
+
k: int,
|
|
304
|
+
n_iter: int,
|
|
305
|
+
sigma: float,
|
|
306
|
+
generator: torch.Generator | None,
|
|
307
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
308
|
+
"""Factor once and validate each member against the original operator."""
|
|
309
|
+
identity = torch.eye(A.shape[-1], dtype=A.dtype, device=A.device)
|
|
310
|
+
# One LU factorization of A - sigma I serves every Lanczos step. lu_factor_ex
|
|
311
|
+
# reports a zero pivot in `info` instead of raising, so the check below can
|
|
312
|
+
# name the failed batch members.
|
|
313
|
+
factors, pivots, info = torch.linalg.lu_factor_ex(A - sigma * identity)
|
|
314
|
+
failed = (info != 0) | ~torch.isfinite(factors).all(dim=(-2, -1))
|
|
315
|
+
if failed.any():
|
|
316
|
+
members = failed.nonzero().tolist()
|
|
317
|
+
raise RuntimeError(
|
|
318
|
+
f"shift-invert breakdown: factorization failed for members {members}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Each step applies (A - sigma I)^-1 as a pair of triangular solves on the
|
|
322
|
+
# stored factors, so no step refactors.
|
|
323
|
+
def solve(vector: torch.Tensor) -> torch.Tensor:
|
|
324
|
+
return torch.linalg.lu_solve(factors, pivots, vector.unsqueeze(-1)).squeeze(-1)
|
|
325
|
+
|
|
326
|
+
inverse_values, vectors = _iterate(solve, A, n_iter, generator)
|
|
327
|
+
# The largest inverse eigenvalues in magnitude belong to the eigenvalues of
|
|
328
|
+
# A nearest sigma, so keep the k largest.
|
|
329
|
+
indices = inverse_values.abs().argsort(dim=-1, descending=True)[..., :k]
|
|
330
|
+
inverse_values = inverse_values.gather(-1, indices)
|
|
331
|
+
vectors = vectors.gather(-1, indices.unsqueeze(-2).expand(*vectors.shape[:-1], k))
|
|
332
|
+
# Undo the transform: lambda = sigma + 1/mu.
|
|
333
|
+
values = sigma + inverse_values.reciprocal()
|
|
334
|
+
# The Lanczos residual was taken for the inverse operator, so each Ritz
|
|
335
|
+
# pair is checked against the original A before it is returned.
|
|
336
|
+
residuals = torch.linalg.vector_norm(
|
|
337
|
+
A @ vectors - vectors * values.unsqueeze(-2), dim=-2
|
|
338
|
+
)
|
|
339
|
+
# scale is ||A||_inf (the largest absolute row sum) plus |lambda|, so the
|
|
340
|
+
# residual test is relative to both the operator and the eigenvalue.
|
|
341
|
+
scale = A.abs().sum(dim=-1).amax(dim=-1, keepdim=True) + values.abs()
|
|
342
|
+
finite = torch.isfinite(values).all(dim=-1) & torch.isfinite(residuals).all(dim=-1)
|
|
343
|
+
if not finite.all():
|
|
344
|
+
members = (~finite).nonzero().tolist()
|
|
345
|
+
raise RuntimeError(
|
|
346
|
+
f"shift-invert breakdown: nonfinite Ritz pairs for members {members}"
|
|
347
|
+
)
|
|
348
|
+
# A pair passes when its residual is within n * sqrt(eps) of scale, the
|
|
349
|
+
# same sqrt(eps) the solvers use for their default tolerances. Pairs that
|
|
350
|
+
# fail are still returned, with a warning.
|
|
351
|
+
inaccurate = (
|
|
352
|
+
residuals > A.shape[-1] * torch.finfo(A.dtype).eps ** 0.5 * scale
|
|
353
|
+
).any(-1)
|
|
354
|
+
if inaccurate.any():
|
|
355
|
+
warnings.warn(
|
|
356
|
+
"shift-invert Ritz pairs have not converged for batch members "
|
|
357
|
+
f"{inaccurate.nonzero().tolist()}; increase n_iter",
|
|
358
|
+
UserWarning,
|
|
359
|
+
stacklevel=2,
|
|
360
|
+
)
|
|
361
|
+
return _select(values, vectors, k, "SA", None)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Batched exact and randomized singular value decompositions.
|
|
5
|
+
|
|
6
|
+
This module wraps `torch.linalg` operations with documented defaults
|
|
7
|
+
(no hidden tolerances, no silently chosen ranks) and adds primitives
|
|
8
|
+
that PyTorch does not expose at the granularity we want.
|
|
9
|
+
|
|
10
|
+
Currently implemented:
|
|
11
|
+
truncated_svd(M, r, mode, oversample)
|
|
12
|
+
Batched truncated SVD. `mode="exact"` runs full SVD then truncates
|
|
13
|
+
(cost O(min(m,n)² · max(m,n))). `mode="randomized"` uses the
|
|
14
|
+
Halko-Martinsson-Tropp randomized projection (cost roughly
|
|
15
|
+
O(m·n·r), much faster when r ≪ min(m,n)).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
import torch
|
|
23
|
+
|
|
24
|
+
# Default oversampling parameter for randomized SVD, per
|
|
25
|
+
# Halko-Martinsson-Tropp (2011) §1.2. Cataloged as
|
|
26
|
+
# `RANDOMIZED_SVD_OVERSAMPLE_DEFAULT` in notes/magic_numbers.md.
|
|
27
|
+
# Five additional projection columns typically give relative spectral
|
|
28
|
+
# error below 1e-3 for matrices whose
|
|
29
|
+
# singular values decay smoothly past index r. Scale of validity:
|
|
30
|
+
# adequate when the (r+1)-th singular value is small relative to the
|
|
31
|
+
# r-th; increase to 10 when the spectrum is flat near the truncation.
|
|
32
|
+
RANDOMIZED_SVD_OVERSAMPLE_DEFAULT: int = 5
|
|
33
|
+
|
|
34
|
+
# Default number of subspace iterations for randomized SVD power method.
|
|
35
|
+
# Halko-Martinsson-Tropp (2011) §4.5; q=2 is the standard recommendation
|
|
36
|
+
# for matrices with slowly decaying spectra. Two iterations multiply the
|
|
37
|
+
# singular-value gap by (σ_{r+1}/σ_r)^(2q+1), which is enough for typical
|
|
38
|
+
# low-rank truncation. Cataloged as `RANDOMIZED_SVD_N_ITER_DEFAULT`.
|
|
39
|
+
RANDOMIZED_SVD_N_ITER_DEFAULT: int = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def truncated_svd(
|
|
43
|
+
M: torch.Tensor,
|
|
44
|
+
r: int,
|
|
45
|
+
mode: Literal["exact", "randomized"] = "exact",
|
|
46
|
+
oversample: int = RANDOMIZED_SVD_OVERSAMPLE_DEFAULT,
|
|
47
|
+
n_iter: int = RANDOMIZED_SVD_N_ITER_DEFAULT,
|
|
48
|
+
generator: torch.Generator | None = None,
|
|
49
|
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
50
|
+
"""Batched truncated SVD: top-r singular triples of M.
|
|
51
|
+
|
|
52
|
+
Returns (U, S, Vh) such that
|
|
53
|
+
|
|
54
|
+
U @ diag(S) @ Vh ≈ best rank-r approximation of M.
|
|
55
|
+
|
|
56
|
+
For `mode="exact"`, the approximation is the best (Eckart-Young) rank-r
|
|
57
|
+
approximation up to floating-point error. For `mode="randomized"`, it
|
|
58
|
+
is approximate with accuracy controlled by `oversample` and `n_iter`.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
M: input matrix, shape (..., m, n). Leading dims are treated as batch.
|
|
62
|
+
r: target rank, 1 <= r <= min(m, n).
|
|
63
|
+
mode: "exact" (full SVD + truncate) or "randomized" (HMT projection).
|
|
64
|
+
oversample: extra projection columns for randomized mode (default
|
|
65
|
+
derived from `RANDOMIZED_SVD_OVERSAMPLE_DEFAULT`, cataloged).
|
|
66
|
+
n_iter: subspace-iteration power method steps for randomized mode
|
|
67
|
+
(default derived from `RANDOMIZED_SVD_N_ITER_DEFAULT`, cataloged).
|
|
68
|
+
Ignored in exact mode.
|
|
69
|
+
generator: PyTorch RNG for randomized mode's projection matrix.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
U: (..., m, r) orthonormal columns
|
|
73
|
+
S: (..., r) singular values, sorted descending, ≥ 0
|
|
74
|
+
Vh: (..., r, n) orthonormal rows (V conjugate-transposed)
|
|
75
|
+
|
|
76
|
+
References:
|
|
77
|
+
Eckart, C., Young, G. (1936). The approximation of one matrix by
|
|
78
|
+
another of lower rank. Psychometrika, 1(3):211–218.
|
|
79
|
+
Halko, N., Martinsson, P. G., Tropp, J. A. (2011). Finding structure
|
|
80
|
+
with randomness: probabilistic algorithms for constructing
|
|
81
|
+
approximate matrix decompositions. SIAM Review, 53(2):217–288.
|
|
82
|
+
"""
|
|
83
|
+
if M.ndim < 2:
|
|
84
|
+
raise ValueError(f"M must have shape (..., m, n), got {tuple(M.shape)}")
|
|
85
|
+
m, n = M.shape[-2:]
|
|
86
|
+
if not (M.is_floating_point() or M.is_complex()):
|
|
87
|
+
raise ValueError(f"M must be floating point or complex, got {M.dtype}")
|
|
88
|
+
# bool is a subclass of int, so True would pass an int check and quietly
|
|
89
|
+
# mean rank 1. It is rejected by name.
|
|
90
|
+
if isinstance(r, bool) or not isinstance(r, int) or r <= 0:
|
|
91
|
+
raise ValueError(f"r must be > 0 and an integer, got r={r}")
|
|
92
|
+
for name, value in (("oversample", oversample), ("n_iter", n_iter)):
|
|
93
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
94
|
+
raise ValueError(f"{name} must be a nonnegative integer, got {value}")
|
|
95
|
+
if r > min(m, n):
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"r={r} exceeds min(m={m}, n={n}); cannot truncate higher than full rank"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if mode == "exact":
|
|
101
|
+
U_full, S_full, Vh_full = torch.linalg.svd(M, full_matrices=False)
|
|
102
|
+
# The slices are views into the full factors, and a view keeps all of
|
|
103
|
+
# the full factor alive. .contiguous() copies the strided slices so the
|
|
104
|
+
# full factors can be freed.
|
|
105
|
+
return (
|
|
106
|
+
U_full[..., :, :r].contiguous(),
|
|
107
|
+
S_full[..., :r].contiguous(),
|
|
108
|
+
Vh_full[..., :r, :].contiguous(),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if mode == "randomized":
|
|
112
|
+
return _randomized_svd(M, r, oversample=oversample,
|
|
113
|
+
n_iter=n_iter, generator=generator)
|
|
114
|
+
|
|
115
|
+
raise ValueError(f"mode must be 'exact' or 'randomized', got {mode!r}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _randomized_svd(
|
|
119
|
+
M: torch.Tensor,
|
|
120
|
+
r: int,
|
|
121
|
+
oversample: int,
|
|
122
|
+
n_iter: int,
|
|
123
|
+
generator: torch.Generator | None,
|
|
124
|
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
125
|
+
"""Halko-Martinsson-Tropp randomized SVD with subspace iteration.
|
|
126
|
+
|
|
127
|
+
Input M is (..., m, n), already checked by truncated_svd, with 1 <= r and
|
|
128
|
+
nonnegative integers for oversample and n_iter. Output is (U, S, Vh)
|
|
129
|
+
truncated to r, with the same shapes truncated_svd documents.
|
|
130
|
+
|
|
131
|
+
Algorithm (Halko-Martinsson-Tropp 2011, Algorithm 5.1 + 4.4):
|
|
132
|
+
|
|
133
|
+
1. Draw Ω ∈ R^{n × ℓ}, ℓ = r + oversample, Gaussian.
|
|
134
|
+
2. Form Y = M Ω. # range capture
|
|
135
|
+
3. Power iteration (Algorithm 4.4): for q steps,
|
|
136
|
+
Q, _ = qr(Y)
|
|
137
|
+
Z = Mᴴ Q
|
|
138
|
+
Q, _ = qr(Z)
|
|
139
|
+
Y = M Q.
|
|
140
|
+
4. Q, _ = qr(Y). # orthonormal basis for the captured range
|
|
141
|
+
5. B = Qᴴ M. # (ℓ × n)
|
|
142
|
+
6. SVD on the small B: B = Û Ŝ Vh.
|
|
143
|
+
7. U = Q Û.
|
|
144
|
+
8. Truncate to top-r triples.
|
|
145
|
+
|
|
146
|
+
The power iterations are sequential because each uses the preceding subspace.
|
|
147
|
+
Each subspace iteration costs O(m·n·ℓ); total O(m·n·ℓ·(n_iter+1)).
|
|
148
|
+
"""
|
|
149
|
+
*batch, m, n = M.shape
|
|
150
|
+
ell = r + oversample
|
|
151
|
+
# The sketch would need more columns than the matrix has, so there is no
|
|
152
|
+
# smaller subspace to randomize. Exact SVD is the answer here.
|
|
153
|
+
if ell > min(m, n):
|
|
154
|
+
return truncated_svd(M, r, mode="exact")
|
|
155
|
+
|
|
156
|
+
Omega = torch.randn(*batch, n, ell, generator=generator,
|
|
157
|
+
device=M.device, dtype=M.dtype)
|
|
158
|
+
Y = torch.matmul(M, Omega) # (..., m, ℓ)
|
|
159
|
+
|
|
160
|
+
# Subspace iteration: alternately apply M and Mᴴ to amplify dominant
|
|
161
|
+
# singular directions. Re-orthonormalize each step to prevent
|
|
162
|
+
# numerical collapse onto the leading singular vector.
|
|
163
|
+
for _ in range(n_iter):
|
|
164
|
+
Q, _ = torch.linalg.qr(Y)
|
|
165
|
+
Z = torch.matmul(M.mH, Q)
|
|
166
|
+
Q, _ = torch.linalg.qr(Z)
|
|
167
|
+
Y = torch.matmul(M, Q)
|
|
168
|
+
|
|
169
|
+
Q, _ = torch.linalg.qr(Y) # (..., m, ℓ)
|
|
170
|
+
B = torch.matmul(Q.mH, M) # (..., ℓ, n)
|
|
171
|
+
# B = Qᴴ M is only ℓ × n, so its SVD is cheap and gives M's leading triples
|
|
172
|
+
# once Q is multiplied back in.
|
|
173
|
+
U_small, S, Vh = torch.linalg.svd(B, full_matrices=False)
|
|
174
|
+
U = torch.matmul(Q, U_small) # (..., m, ℓ)
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
U[..., :, :r].contiguous(),
|
|
178
|
+
S[..., :r].contiguous(),
|
|
179
|
+
Vh[..., :r, :].contiguous(),
|
|
180
|
+
)
|