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,453 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Fixed-rank matrix manifold, GPU-native, batched-first.
5
+
6
+ The fixed-rank matrix manifold
7
+
8
+ M_r(m, n) = { M ∈ R^{m × n} : rank(M) = r }
9
+
10
+ is a smooth ( r·(m + n − r) )-dimensional embedded submanifold of R^{m×n}.
11
+ Points are represented in SVD form (U, S, Vt) where
12
+
13
+ U ∈ R^{B × m × r} columns orthonormal
14
+ S ∈ R^{B × r} positive, sorted descending
15
+ Vt ∈ R^{B × r × n} rows orthonormal (i.e. V^T)
16
+
17
+ and the ambient dense matrix is `M = U @ diag(S) @ Vt`. The leading batch
18
+ dimension B is required for all operations; pass B = 1 for single-point use.
19
+
20
+ Tangent vectors are stored in *ambient form*; i.e. as (B, m, n) matrices
21
+ in the embedding space, with the understanding that they satisfy the
22
+ tangent-space constraint at the corresponding point. This matches
23
+ Vandereycken's exposition and matches the calling convention used by most
24
+ Riemannian optimizers (the gradient comes in as an ambient matrix; we
25
+ project it to a valid tangent).
26
+
27
+ References:
28
+ Absil, P.-A., Mahony, R., Sepulchre, R. (2008). Optimization Algorithms
29
+ on Matrix Manifolds. Princeton University Press.
30
+ Vandereycken, B. (2013). Low-rank matrix completion by Riemannian
31
+ optimization. SIAM Journal on Optimization, 23(2):1214–1236.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import Any, Literal
37
+
38
+ import torch
39
+ from torch.autograd.function import once_differentiable
40
+
41
+ from holonomy_lib.algebra.linear import truncated_svd
42
+
43
+ # A batched manifold point is the triple (U, S, Vt) of stacked tensors.
44
+ # Shapes: U (B, m, r), S (B, r), Vt (B, r, n).
45
+ FixedRankPoint = tuple[torch.Tensor, torch.Tensor, torch.Tensor]
46
+
47
+ # Rank ratio r / min(m, n) where the retraction's top-r truncation stops
48
+ # using randomized SVD. Measured on CPU in the 0.7.0 benchmark baseline
49
+ # (notes/benchmark_baseline.md, in git history at d26eb84): above
50
+ # 0.25 the exact SVD's constant factor wins back the gap, and its accuracy
51
+ # and determinism matter more for research workloads. At or below it,
52
+ # randomized SVD (HMT 2011) is strictly faster in our CPU baseline, 37× at
53
+ # 1024×1024 with r = 32. Cataloged in notes/magic_numbers.md under
54
+ # `RETRACTION_RANDOMIZED_THRESHOLD`.
55
+ RETRACTION_RANDOMIZED_THRESHOLD: float = 0.25
56
+
57
+
58
+ class _TruncatedSVD(torch.autograd.Function):
59
+ """Top-r SVD factors of a matrix, with a first-derivative backward.
60
+
61
+ Takes a (..., m, n) matrix and a rank r and returns (U, S, Vt) with
62
+ shapes (..., m, r), (..., r), (..., r, n). The forward runs a full thin
63
+ SVD and slices, so the backward can see the discarded singular values.
64
+
65
+ The backward is Townsend's SVD derivative restricted to the retained
66
+ factors. Pairs of discarded values carry no gradient, so a run of zero
67
+ discarded singular values (a rank-deficient input) does not create the
68
+ 1/0 poles that differentiating the full SVD would. At repeated retained
69
+ values the derivative is defined for losses on the reconstructed matrix,
70
+ not arbitrary losses on nonunique factors. A tie across the
71
+ retained/discarded boundary is truly nondifferentiable and comes back
72
+ as inf or NaN.
73
+
74
+ References:
75
+ Townsend (2016), Differentiating the Singular Value Decomposition.
76
+ Vandereycken (2013), §2.3, metric projection retraction.
77
+ """
78
+
79
+ @staticmethod
80
+ def forward(ctx: Any, matrix: torch.Tensor, rank: int) -> FixedRankPoint:
81
+ u, singular, vt = torch.linalg.svd(matrix, full_matrices=False)
82
+ ctx.rank = rank
83
+ ctx.save_for_backward(u, singular, vt)
84
+ return u[..., :rank], singular[..., :rank], vt[..., :rank, :]
85
+
86
+ @staticmethod
87
+ @once_differentiable
88
+ def backward(
89
+ ctx: Any, grad_u: torch.Tensor, grad_s: torch.Tensor, grad_vt: torch.Tensor,
90
+ ) -> tuple[torch.Tensor, None]:
91
+ u, singular, vt = ctx.saved_tensors
92
+ rank = ctx.rank
93
+ v = vt.mT
94
+ # Pad the incoming gradients out to the full thin SVD with zeros in
95
+ # the discarded slots, so Townsend's formulas can run on k = min(m, n)
96
+ # columns and the discarded directions simply contribute nothing.
97
+ gu, gs, gv = (
98
+ torch.zeros_like(u),
99
+ torch.zeros_like(singular),
100
+ torch.zeros_like(v),
101
+ )
102
+ gu[..., :rank], gs[..., :rank], gv[..., :rank] = grad_u, grad_s, grad_vt.mT
103
+ a, b = u.mT @ gu, vt @ gv # Uᵀ Ū and Vᵀ V̄, both (..., k, k)
104
+ left, right = singular.unsqueeze(-1), singular.unsqueeze(-2)
105
+ # Masks over (i, j) pairs: `active` when at least one of the two is
106
+ # retained, `both_kept` when both are.
107
+ kept = torch.arange(singular.shape[-1], device=singular.device) < rank
108
+ active = kept.unsqueeze(-1) | kept.unsqueeze(-2)
109
+ both_kept = kept.unsqueeze(-1) & kept.unsqueeze(-2)
110
+ gap = right.square() - left.square() # s_j² − s_i², Townsend's 1/F
111
+ # Every nonzero gap has Townsend's reciprocal, however small it is.
112
+ # The reconstruction limit applies only to exact ties in this dtype.
113
+ repeated = both_kept & (gap == 0)
114
+ # Only discarded/discarded and repeated-retained pairs are removable.
115
+ # A boundary tie remains a zero gap and signals a nonunique projection.
116
+ safe_gap = torch.where(~active | repeated, torch.ones_like(gap), gap)
117
+ # Townsend's off-diagonal block, (F ∘ (a − aᵀ)) S + S (F ∘ (b − bᵀ)).
118
+ core = ((a - a.mT) * right + left * (b - b.mT)) / safe_gap
119
+ # 1/s on retained values and 0 on discarded ones. The first where
120
+ # swaps discarded values for 1 before the reciprocal, so a zero
121
+ # discarded singular value never produces inf to be masked later.
122
+ inverse = torch.where(kept, singular, torch.ones_like(singular)).reciprocal()
123
+ inverse = torch.where(kept, inverse, torch.zeros_like(inverse))
124
+ # Exact ties among retained values replace the 0/0 entry by its finite
125
+ # limit, which is valid for losses on the reconstructed matrix.
126
+ limit = 0.5 * (a * inverse.unsqueeze(-2) + b.mT * inverse.unsqueeze(-1))
127
+ core = torch.where(repeated, limit, torch.where(active, core, 0.0))
128
+ # The diagonal of the core is the singular-value gradient itself.
129
+ diagonal = torch.eye(
130
+ singular.shape[-1], dtype=torch.bool, device=singular.device
131
+ )
132
+ core = torch.where(diagonal, torch.diag_embed(gs), core)
133
+ grad = u @ core @ vt
134
+ # The last two terms carry the parts of Ū and V̄ that leave the
135
+ # spans of U and V, (I − U Uᵀ) Ū S⁻¹ Vᵀ and its V counterpart. They
136
+ # matter whenever m or n exceeds k.
137
+ grad = grad + ((gu - u @ a) * inverse.unsqueeze(-2)) @ vt
138
+ grad = grad + (u * inverse.unsqueeze(-2)) @ (gv - v @ b).mT
139
+ return grad, None
140
+
141
+
142
+ class FixedRankManifold:
143
+ """Fixed-rank (m, n) matrix manifold of rank r, on batched SVD factors.
144
+
145
+ All operations expect a leading batch dim B and return tensors with a
146
+ leading batch dim B. Single-point use: pass B = 1 (the manifold does not
147
+ silently broadcast scalars to batches; the caller owns the batch axis).
148
+
149
+ Args:
150
+ m, n: ambient dimensions
151
+ r: target rank, with 0 < r <= min(m, n)
152
+ device, dtype: tensor placement and precision
153
+
154
+ References:
155
+ Vandereycken (2013), §2.
156
+
157
+ Example:
158
+ >>> mfd = FixedRankManifold(m=5, n=7, r=3)
159
+ >>> pt = mfd.random_point(batch_size=4)
160
+ >>> mfd.dense(pt).shape
161
+ torch.Size([4, 5, 7])
162
+ """
163
+
164
+ def __init__(self, m: int, n: int, r: int,
165
+ device: str | torch.device = "cpu",
166
+ dtype: torch.dtype = torch.float64,
167
+ retraction_mode: Literal["auto", "exact", "randomized"] = "auto"):
168
+ """Args (in addition to m/n/r/device/dtype):
169
+
170
+ retraction_mode: how to compute the SVD inside `retraction`.
171
+ - "auto" (default): pick "randomized" when `r / min(m, n)` is
172
+ at or below `RETRACTION_RANDOMIZED_THRESHOLD`, else "exact".
173
+ On the CPU baseline that is an 8-37× speedup for small-rank
174
+ truncations (notes/magic_numbers.md).
175
+ - "exact": always full SVD then slice (Eckart-Young exact).
176
+ Slower but deterministic; pick this when downstream code
177
+ depends on bit-for-bit reproducibility.
178
+ - "randomized": always Halko-Martinsson-Tropp via the
179
+ library's `truncated_svd(mode="randomized")`, which
180
+ oversamples and runs subspace iterations. Non-deterministic,
181
+ since the random projection is drawn from the global RNG on
182
+ every call, and approximate to ~σ_{r+1}-precision.
183
+ """
184
+ if r <= 0 or r > min(m, n):
185
+ raise ValueError(
186
+ f"rank r={r} must satisfy 0 < r <= min(m={m}, n={n})"
187
+ )
188
+ if retraction_mode not in {"auto", "exact", "randomized"}:
189
+ raise ValueError(
190
+ f"retraction_mode must be 'auto'/'exact'/'randomized', "
191
+ f"got {retraction_mode!r}"
192
+ )
193
+ self.m = m
194
+ self.n = n
195
+ self.r = r
196
+ self.device = torch.device(device)
197
+ self.dtype = dtype
198
+ self.retraction_mode = retraction_mode
199
+
200
+ # ----------------------------------------------------------------
201
+ # Manifold dimension (informational)
202
+ # ----------------------------------------------------------------
203
+
204
+ @property
205
+ def dim(self) -> int:
206
+ """Manifold dimension, r·(m + n − r). Vandereycken (2013), §2.1."""
207
+ return self.r * (self.m + self.n - self.r)
208
+
209
+
210
+ # ----------------------------------------------------------------
211
+ # Construction
212
+ # ----------------------------------------------------------------
213
+
214
+ def random_point(
215
+ self,
216
+ batch_size: int = 1,
217
+ generator: torch.Generator | None = None,
218
+ ) -> FixedRankPoint:
219
+ """Sample B random points on the manifold.
220
+
221
+ Construction: U from QR of a Gaussian random matrix (Haar-distributed
222
+ on the Stiefel manifold St(m, r) by the Mezzadri (2007) construction);
223
+ singular values uniform in [0, 1) from `torch.rand`, sorted descending
224
+ so the SVD-form invariant (S decreasing) holds; V analogous.
225
+
226
+ Note: the singular-value distribution here is uniform, NOT the
227
+ Marchenko–Pastur or any "natural" prior on M_r; it's a convenience
228
+ for tests/initialization, not a statistically motivated draw.
229
+
230
+ References:
231
+ Mezzadri, F. (2007). How to generate random matrices from the
232
+ classical compact groups. Notices of the AMS, 54(5):592–604.
233
+
234
+ Returns:
235
+ (U, S, Vt) with shapes (B, m, r), (B, r), (B, r, n).
236
+ """
237
+ if batch_size < 0:
238
+ raise ValueError(f"batch_size must be >= 0, got {batch_size}")
239
+ B, m, n, r = batch_size, self.m, self.n, self.r
240
+ A = torch.randn(
241
+ B,
242
+ m,
243
+ r,
244
+ generator=generator,
245
+ device=generator.device if generator is not None else self.device,
246
+ dtype=self.dtype,
247
+ ).to(self.device)
248
+ # QR alone is not Haar: LAPACK's sign convention on diag(R) biases Q.
249
+ # Flipping each column of Q by the sign of the matching R diagonal
250
+ # entry is Mezzadri's fix and makes the draw uniform on the Stiefel
251
+ # manifold.
252
+ U, R = torch.linalg.qr(A)
253
+ U = U * torch.sign(R.diagonal(dim1=-2, dim2=-1)).unsqueeze(-2)
254
+ C = torch.randn(
255
+ B,
256
+ n,
257
+ r,
258
+ generator=generator,
259
+ device=generator.device if generator is not None else self.device,
260
+ dtype=self.dtype,
261
+ ).to(self.device)
262
+ V, R = torch.linalg.qr(C)
263
+ V = V * torch.sign(R.diagonal(dim1=-2, dim2=-1)).unsqueeze(-2)
264
+ Vt = V.mT.contiguous()
265
+ S_raw = torch.rand(
266
+ B,
267
+ r,
268
+ generator=generator,
269
+ device=generator.device if generator is not None else self.device,
270
+ dtype=self.dtype,
271
+ ).to(self.device)
272
+ S, _ = torch.sort(S_raw, dim=-1, descending=True)
273
+ return U, S, Vt
274
+
275
+ # ----------------------------------------------------------------
276
+ # Embedding
277
+ # ----------------------------------------------------------------
278
+
279
+ def dense(self, point: FixedRankPoint) -> torch.Tensor:
280
+ """Reconstruct ambient dense matrices: M = U @ diag(S) @ Vt.
281
+
282
+ Args:
283
+ point: (U, S, Vt) with shapes (B, m, r), (B, r), (B, r, n).
284
+ Returns:
285
+ M of shape (B, m, n).
286
+
287
+ References:
288
+ Vandereycken (2013), Low-Rank Matrix Completion by Riemannian
289
+ Optimization, §2, embedded matrix representation and metric.
290
+ """
291
+ U, S, Vt = point
292
+ # Broadcast scale: U (B, m, r) times diag(S) along last dim
293
+ US = U * S.unsqueeze(dim=-2)
294
+ return torch.bmm(US, Vt)
295
+
296
+ # ----------------------------------------------------------------
297
+ # Tangent operations
298
+ # ----------------------------------------------------------------
299
+
300
+ def projection(
301
+ self, point: FixedRankPoint, Z: torch.Tensor
302
+ ) -> torch.Tensor:
303
+ """Project ambient direction Z onto the tangent space at point.
304
+
305
+ The tangent space at M = U S Vt is
306
+
307
+ T_M M_r = { U Mp Vt + Up Vt + U Vpt :
308
+ Mp ∈ R^{r×r}, Up ⊥ U, Vpt ⊥ V^T }
309
+
310
+ and the orthogonal projector onto it (in Frobenius inner product)
311
+ admits the closed form
312
+
313
+ P_T(Z) = U U^T Z + Z V V^T − U U^T Z V V^T.
314
+
315
+ Idempotent: P_T(P_T(Z)) = P_T(Z) by construction.
316
+
317
+ References:
318
+ Vandereycken (2013), eq. 2.5.
319
+
320
+ Args:
321
+ point: (U, S, Vt); S is unused for projection, included for API
322
+ consistency.
323
+ Z: ambient direction, shape (B, m, n).
324
+ Returns:
325
+ Projected tangent in ambient form, shape (B, m, n).
326
+ """
327
+ U, _, Vt = point
328
+ V = Vt.mT # (B, n, r)
329
+ Ut_Z = torch.bmm(U.mT, Z) # (B, r, n)
330
+ UUt_Z = torch.bmm(U, Ut_Z) # (B, m, n)
331
+ Z_V = torch.bmm(Z, V) # (B, m, r)
332
+ Z_VVt = torch.bmm(Z_V, Vt) # (B, m, n)
333
+ Ut_Z_V = torch.bmm(Ut_Z, V) # (B, r, r)
334
+ UUt_Z_VVt = torch.bmm(U, torch.bmm(Ut_Z_V, Vt)) # (B, m, n)
335
+ return UUt_Z + Z_VVt - UUt_Z_VVt
336
+
337
+ def inner(
338
+ self,
339
+ point: FixedRankPoint,
340
+ tangent_a: torch.Tensor,
341
+ tangent_b: torch.Tensor,
342
+ ) -> torch.Tensor:
343
+ """Riemannian inner product of two tangents at point.
344
+
345
+ For the fixed-rank manifold embedded in R^{m×n}, the induced metric
346
+ is just the ambient Frobenius inner product on tangent vectors
347
+ represented in ambient form.
348
+
349
+ References:
350
+ Vandereycken (2013), §2.2; induced metric.
351
+
352
+ Args:
353
+ point: unused (induced metric is point-independent in ambient form;
354
+ included for API consistency with manifolds whose metric depends
355
+ on the point).
356
+ tangent_a, tangent_b: tangent vectors in ambient form, (B, m, n).
357
+ Returns:
358
+ (B,) tensor of inner products.
359
+ """
360
+ del point # induced ambient metric does not depend on the base point
361
+ return (tangent_a * tangent_b).sum(dim=(-2, -1))
362
+
363
+ def norm(
364
+ self, point: FixedRankPoint, tangent: torch.Tensor
365
+ ) -> torch.Tensor:
366
+ """Riemannian norm sqrt(<v,v>) of a tangent vector. (B,)-shaped output.
367
+
368
+ References:
369
+ Vandereycken (2013), Low-Rank Matrix Completion by Riemannian
370
+ Optimization, §2, embedded matrix representation and metric.
371
+ """
372
+ return torch.linalg.vector_norm(tangent, dim=(-2, -1))
373
+
374
+ def egrad_to_rgrad(
375
+ self, point: FixedRankPoint, egrad: torch.Tensor,
376
+ ) -> torch.Tensor:
377
+ """Project an ambient gradient under the embedded Frobenius metric.
378
+
379
+ Args:
380
+ point: factor triple (U, S, Vt).
381
+ egrad: (B, m, n) Euclidean gradient.
382
+ Returns:
383
+ (B, m, n) metric gradient in tangent coordinates.
384
+
385
+ References:
386
+ Absil, Mahony, Sepulchre (2008), §3.6; Vandereycken (2013), eq. 2.5.
387
+ """
388
+ return self.projection(point, egrad)
389
+
390
+ # ----------------------------------------------------------------
391
+ # Retraction
392
+ # ----------------------------------------------------------------
393
+
394
+ def retraction(
395
+ self, point: FixedRankPoint, tangent: torch.Tensor
396
+ ) -> FixedRankPoint:
397
+ """Move along tangent and project back onto the manifold via SVD.
398
+
399
+ The standard projection-based retraction:
400
+
401
+ R_M(ξ) = argmin_{Y ∈ M_r} ‖ (M + ξ) − Y ‖_F = SVD_r(M + ξ)
402
+
403
+ where SVD_r truncates to the top r singular triples. This is a
404
+ first-order retraction (Absil et al. 2008, §4.1).
405
+
406
+ Performance: full SVD scales O(m·n·min(m, n)). For r ≪ min(m, n)
407
+ we switch to the library's randomized `truncated_svd`
408
+ (Halko-Martinsson-Tropp), which scales O(m·n·r); on our CPU
409
+ baseline the speedup is 8-37× at common ranks. The threshold is governed by
410
+ `retraction_mode` (default "auto"); see `__init__` docstring.
411
+
412
+ Differentiated calls use exact SVD, regardless of mode. The backward
413
+ differentiates the reconstructed rank-r matrix through repeated
414
+ retained or discarded values. Individual factors have no unique
415
+ derivative at repeated retained values. A tie across the truncation
416
+ boundary makes the rank-r projection itself nondifferentiable.
417
+ Only first derivatives are implemented by the custom backward.
418
+
419
+ References:
420
+ Absil, Mahony, Sepulchre (2008), §4.1; retractions.
421
+ Vandereycken (2013), eq. 2.7.
422
+ Halko, Martinsson, Tropp (2011); randomized SVD.
423
+
424
+ Args:
425
+ point: (U, S, Vt); used only to call dense().
426
+ tangent: ambient (B, m, n).
427
+ Returns:
428
+ (U', S', Vt') with shapes (B, m, r), (B, r), (B, r, n).
429
+ """
430
+ M_plus = self.dense(point) + tangent # (B, m, n)
431
+ r = self.r
432
+ # Anything autograd will differentiate goes through the exact SVD
433
+ # with the custom backward. The randomized path has no derivative of
434
+ # its own and the plain svd backward has poles at repeated values.
435
+ if M_plus.requires_grad and torch.is_grad_enabled():
436
+ return _TruncatedSVD.apply(M_plus, r)
437
+ mode = self.retraction_mode
438
+ if mode == "auto":
439
+ min_dim = min(M_plus.shape[-2], M_plus.shape[-1])
440
+ mode = "randomized" if r <= RETRACTION_RANDOMIZED_THRESHOLD * min_dim \
441
+ else "exact"
442
+ if mode == "randomized":
443
+ # Use our `truncated_svd` (HMT with oversample=5, n_iter=2 by
444
+ # its cataloged defaults) rather than `torch.svd_lowrank`
445
+ # directly. svd_lowrank with q=r has no oversampling and gives
446
+ # ~40% relative error on retraction inputs, which is too much
447
+ # for an optimizer step.
448
+ return truncated_svd(M_plus, r=r, mode="randomized")
449
+ U_full, S_full, Vt_full = torch.linalg.svd(M_plus, full_matrices=False)
450
+ U_r = U_full[..., :, :r].contiguous()
451
+ S_r = S_full[..., :r].contiguous()
452
+ Vt_r = Vt_full[..., :r, :].contiguous()
453
+ return U_r, S_r, Vt_r