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,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
|