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.
Files changed (82) hide show
  1. holonomy_explorer/__init__.py +1 -0
  2. holonomy_explorer/__main__.py +79 -0
  3. holonomy_explorer/batching.py +182 -0
  4. holonomy_explorer/common.py +187 -0
  5. holonomy_explorer/curvature.py +154 -0
  6. holonomy_explorer/datasets.py +346 -0
  7. holonomy_explorer/graphs.py +286 -0
  8. holonomy_explorer/server.py +172 -0
  9. holonomy_explorer/static/app.js +435 -0
  10. holonomy_explorer/static/charts.js +114 -0
  11. holonomy_explorer/static/index.html +45 -0
  12. holonomy_explorer/static/layout.js +85 -0
  13. holonomy_explorer/static/style.css +121 -0
  14. holonomy_explorer/static/view.js +195 -0
  15. holonomy_explorer/topology.py +118 -0
  16. holonomy_lib/__init__.py +9 -0
  17. holonomy_lib/_graph_utils.py +52 -0
  18. holonomy_lib/_quadrature.py +74 -0
  19. holonomy_lib/algebra/__init__.py +32 -0
  20. holonomy_lib/algebra/eigsh_sparse.py +251 -0
  21. holonomy_lib/algebra/lanczos.py +361 -0
  22. holonomy_lib/algebra/linear.py +180 -0
  23. holonomy_lib/algebra/solvers.py +547 -0
  24. holonomy_lib/convex/__init__.py +27 -0
  25. holonomy_lib/convex/least_squares.py +184 -0
  26. holonomy_lib/discrete_geometry/__init__.py +38 -0
  27. holonomy_lib/discrete_geometry/forman.py +242 -0
  28. holonomy_lib/discrete_geometry/ricci.py +855 -0
  29. holonomy_lib/experimental/__init__.py +13 -0
  30. holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
  31. holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
  32. holonomy_lib/hyperbolic/__init__.py +27 -0
  33. holonomy_lib/hyperbolic/frechet_mean.py +381 -0
  34. holonomy_lib/hyperbolic/heat_kernel.py +608 -0
  35. holonomy_lib/hyperbolic/manifold_inner.py +81 -0
  36. holonomy_lib/info_geometry/__init__.py +64 -0
  37. holonomy_lib/info_geometry/divergences.py +224 -0
  38. holonomy_lib/info_geometry/fisher.py +179 -0
  39. holonomy_lib/lie/__init__.py +54 -0
  40. holonomy_lib/lie/so3.py +385 -0
  41. holonomy_lib/lie/spherical_harmonics.py +189 -0
  42. holonomy_lib/manifolds/__init__.py +53 -0
  43. holonomy_lib/manifolds/comparison.py +343 -0
  44. holonomy_lib/manifolds/fixed_rank.py +453 -0
  45. holonomy_lib/manifolds/lorentz.py +699 -0
  46. holonomy_lib/manifolds/lorentzian.py +567 -0
  47. holonomy_lib/manifolds/product.py +425 -0
  48. holonomy_lib/manifolds/spd.py +504 -0
  49. holonomy_lib/manifolds/stereographic.py +680 -0
  50. holonomy_lib/optimization/__init__.py +42 -0
  51. holonomy_lib/optimization/base.py +65 -0
  52. holonomy_lib/optimization/sgd.py +112 -0
  53. holonomy_lib/py.typed +0 -0
  54. holonomy_lib/sheaf/__init__.py +74 -0
  55. holonomy_lib/sheaf/graph_sheaf.py +352 -0
  56. holonomy_lib/sheaf/laplacian.py +529 -0
  57. holonomy_lib/simplicial/__init__.py +55 -0
  58. holonomy_lib/simplicial/_face_lookup.py +99 -0
  59. holonomy_lib/simplicial/complex.py +168 -0
  60. holonomy_lib/simplicial/sparse_complex.py +145 -0
  61. holonomy_lib/simplicial/vietoris_rips.py +277 -0
  62. holonomy_lib/spectral/__init__.py +55 -0
  63. holonomy_lib/spectral/diffusion_map.py +169 -0
  64. holonomy_lib/spectral/effective_resistance.py +199 -0
  65. holonomy_lib/spectral/embedding.py +131 -0
  66. holonomy_lib/spectral/heat_kernel.py +236 -0
  67. holonomy_lib/spectral/laplacian.py +441 -0
  68. holonomy_lib/spectral/magnetic.py +351 -0
  69. holonomy_lib/spectral/spectral_dimension.py +104 -0
  70. holonomy_lib/tensor_calculus/__init__.py +23 -0
  71. holonomy_lib/tensor_calculus/decomposition.py +208 -0
  72. holonomy_lib/topology/__init__.py +28 -0
  73. holonomy_lib/topology/_filtration.py +147 -0
  74. holonomy_lib/topology/_reduction.py +227 -0
  75. holonomy_lib/topology/hodge.py +250 -0
  76. holonomy_lib/topology/persistent_homology.py +301 -0
  77. holonomy_lib-0.8.0.dist-info/METADATA +161 -0
  78. holonomy_lib-0.8.0.dist-info/RECORD +82 -0
  79. holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
  80. holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
  81. holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
  82. 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
+ )