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,504 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""SPD (symmetric positive definite) matrix manifold, GPU-native, batched-first.
|
|
5
|
+
|
|
6
|
+
The SPD manifold
|
|
7
|
+
|
|
8
|
+
P(n) = { S ∈ R^{n × n} : S = Sᵀ, x ⊺ S x > 0 ∀ x ≠ 0 }
|
|
9
|
+
|
|
10
|
+
is an open submanifold of the symmetric matrices Sym(n) ⊂ R^{n × n} of
|
|
11
|
+
dimension n(n+1)/2. The tangent space at any S ∈ P(n) is canonically
|
|
12
|
+
identified with Sym(n) (it is the open cone, so the tangent at any
|
|
13
|
+
interior point is the ambient symmetric matrices).
|
|
14
|
+
|
|
15
|
+
This implementation uses the **affine-invariant metric** (Pennec et al.
|
|
16
|
+
2006):
|
|
17
|
+
|
|
18
|
+
⟨U, V⟩_S = tr(S⁻¹ U S⁻¹ V), U, V ∈ Sym(n).
|
|
19
|
+
|
|
20
|
+
Under this metric the geodesics, exponential and logarithm maps admit
|
|
21
|
+
the closed forms
|
|
22
|
+
|
|
23
|
+
exp_S(V) = S^{1/2} expm( S^{−1/2} V S^{−1/2} ) S^{1/2},
|
|
24
|
+
log_S(T) = S^{1/2} logm( S^{−1/2} T S^{−1/2} ) S^{1/2},
|
|
25
|
+
d(S, T)² = ‖ logm( S^{−1/2} T S^{−1/2} ) ‖_F² = Σ_i log²(λ_i),
|
|
26
|
+
|
|
27
|
+
where λ_i are the generalized eigenvalues solving T v = λ S v. The
|
|
28
|
+
affine-invariant metric is invariant under congruence (S ↦ Aᵀ S A for
|
|
29
|
+
invertible A), which is the property that makes it the canonical choice
|
|
30
|
+
for covariance-matrix and information-geometric work.
|
|
31
|
+
|
|
32
|
+
Tangent vectors are stored as `(B, n, n)` symmetric tensors.
|
|
33
|
+
|
|
34
|
+
References:
|
|
35
|
+
Pennec, X., Fillard, P., Ayache, N. (2006). A Riemannian framework for
|
|
36
|
+
tensor computing. International Journal of Computer Vision, 66(1):41–66.
|
|
37
|
+
Bhatia, R. (2007). Positive Definite Matrices. Princeton University Press,
|
|
38
|
+
chapters 4–6.
|
|
39
|
+
Sra, S., Hosseini, R. (2015). Conic geometric optimization on the manifold
|
|
40
|
+
of positive definite matrices. SIAM J. Optim. 25(1):713–739.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from typing import Any, Literal
|
|
46
|
+
|
|
47
|
+
import torch
|
|
48
|
+
from torch.autograd.function import once_differentiable
|
|
49
|
+
|
|
50
|
+
_MatrixFunction = Literal["sqrt", "inv_sqrt", "exp", "log"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _SymmetricMatrixFunction(torch.autograd.Function):
|
|
54
|
+
"""Spectral functions f(M) = U f(Λ) Uᵀ with the Daleckii-Krein derivative.
|
|
55
|
+
|
|
56
|
+
Takes a (..., n, n) matrix and one of "sqrt", "inv_sqrt", "exp", "log",
|
|
57
|
+
and returns f applied to the eigenvalues of its symmetric part. Assumes
|
|
58
|
+
the input is symmetric up to roundoff, and SPD for every kind except
|
|
59
|
+
"exp".
|
|
60
|
+
|
|
61
|
+
The backward goes through the Loewner matrix of divided differences
|
|
62
|
+
(f(λ_i) − f(λ_j)) / (λ_i − λ_j), which stays finite as eigenvalues
|
|
63
|
+
meet. Differentiating through eigh instead would divide by the
|
|
64
|
+
eigenvalue gaps and blow up at repeated eigenvalues; here the
|
|
65
|
+
eigenvectors are only a change of coordinates. Only first derivatives
|
|
66
|
+
are implemented.
|
|
67
|
+
|
|
68
|
+
References:
|
|
69
|
+
Higham (2008), Functions of Matrices, Theorem 3.11.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def forward(ctx: Any, matrix: torch.Tensor, kind: _MatrixFunction) -> torch.Tensor:
|
|
74
|
+
values, vectors = torch.linalg.eigh(0.5 * (matrix + matrix.mT))
|
|
75
|
+
# sqrt, rsqrt and log all need positive eigenvalues. A nearly
|
|
76
|
+
# singular SPD input can come back from eigh with an eigenvalue a
|
|
77
|
+
# hair below zero, which would give NaN or inf, so floor them at the
|
|
78
|
+
# smallest positive normal. exp is defined everywhere and is left alone.
|
|
79
|
+
if kind != "exp":
|
|
80
|
+
values = values.clamp_min(torch.finfo(matrix.dtype).tiny)
|
|
81
|
+
if kind == "sqrt":
|
|
82
|
+
transformed = values.sqrt()
|
|
83
|
+
elif kind == "inv_sqrt":
|
|
84
|
+
transformed = values.rsqrt()
|
|
85
|
+
elif kind == "exp":
|
|
86
|
+
transformed = values.exp()
|
|
87
|
+
else:
|
|
88
|
+
transformed = values.log()
|
|
89
|
+
ctx.kind = kind
|
|
90
|
+
ctx.save_for_backward(values, vectors, transformed)
|
|
91
|
+
# U diag(f) Uᵀ, scaling the columns of U by broadcasting instead of
|
|
92
|
+
# building the diagonal matrix.
|
|
93
|
+
return (vectors * transformed.unsqueeze(-2)) @ vectors.mT
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
@once_differentiable
|
|
97
|
+
def backward(ctx: Any, grad: torch.Tensor) -> tuple[torch.Tensor, None]:
|
|
98
|
+
values, vectors, transformed = ctx.saved_tensors
|
|
99
|
+
# left[i, j] = λ_i and right[i, j] = λ_j, so every (i, j) pair of
|
|
100
|
+
# eigenvalues gets its own entry of the Loewner matrix.
|
|
101
|
+
left, right = values.unsqueeze(-1), values.unsqueeze(-2)
|
|
102
|
+
gap = left - right
|
|
103
|
+
# When two eigenvalues are close the divided difference cancels
|
|
104
|
+
# badly, so those entries use f' at the midpoint instead. That
|
|
105
|
+
# substitute is off by O(gap²), and switching at sqrt(eps) times the
|
|
106
|
+
# local eigenvalue scale keeps the error at roundoff. The diagonal
|
|
107
|
+
# (gap = 0) always lands in this branch.
|
|
108
|
+
scale = torch.maximum(left.abs(), right.abs())
|
|
109
|
+
close = gap.abs() <= torch.finfo(values.dtype).eps**0.5 * scale
|
|
110
|
+
# The divided-difference formulas below are evaluated everywhere and
|
|
111
|
+
# then masked, so close entries divide by 1 instead of by ~0.
|
|
112
|
+
safe_gap = torch.where(close, torch.ones_like(gap), gap)
|
|
113
|
+
midpoint = 0.5 * (left + right)
|
|
114
|
+
# Each branch writes the divided difference in a form that does not
|
|
115
|
+
# subtract nearby values, plus f' at the midpoint for close pairs.
|
|
116
|
+
# sqrt: (√a − √b)/(a − b) = 1/(√a + √b).
|
|
117
|
+
# inv_sqrt: (1/√a − 1/√b)/(a − b) = −1/(√a √b (√a + √b)).
|
|
118
|
+
if ctx.kind == "sqrt":
|
|
119
|
+
divided = (left.sqrt() + right.sqrt()).reciprocal()
|
|
120
|
+
derivative = 0.5 * midpoint.rsqrt()
|
|
121
|
+
elif ctx.kind == "inv_sqrt":
|
|
122
|
+
roots = left.sqrt() * right.sqrt()
|
|
123
|
+
divided = -1.0 / (roots * (left.sqrt() + right.sqrt()))
|
|
124
|
+
derivative = -0.5 * midpoint.rsqrt() / midpoint
|
|
125
|
+
elif ctx.kind == "log":
|
|
126
|
+
# log a − log b loses the digits of a small relative gap, so
|
|
127
|
+
# pairs within a factor of two of each other use
|
|
128
|
+
# log(high/low) = log1p(|gap|/low) instead. Far pairs take the
|
|
129
|
+
# plain difference of logs. The where inside log1p keeps far
|
|
130
|
+
# pairs from feeding it a large ratio that is thrown away anyway.
|
|
131
|
+
low = torch.minimum(left, right)
|
|
132
|
+
nearby = gap.abs() <= low
|
|
133
|
+
relative_gap = torch.where(nearby, gap.abs(), 0.0) / low
|
|
134
|
+
local = torch.log1p(relative_gap) / safe_gap.abs()
|
|
135
|
+
log_difference = transformed.unsqueeze(-1) - transformed.unsqueeze(-2)
|
|
136
|
+
divided = torch.where(nearby, local, log_difference / safe_gap)
|
|
137
|
+
derivative = midpoint.reciprocal()
|
|
138
|
+
else:
|
|
139
|
+
# (e^a − e^b)/(a − b) = e^high (1 − e^{−|gap|}) / |gap|. Factoring
|
|
140
|
+
# out the larger exponential keeps the difference from
|
|
141
|
+
# overflowing, and expm1 keeps small gaps accurate.
|
|
142
|
+
high = torch.maximum(transformed.unsqueeze(-1), transformed.unsqueeze(-2))
|
|
143
|
+
divided = high * (-torch.expm1(-gap.abs())) / safe_gap.abs()
|
|
144
|
+
derivative = midpoint.exp()
|
|
145
|
+
loewner = torch.where(close, derivative, divided)
|
|
146
|
+
# Daleckii-Krein: rotate the symmetrized upstream gradient into the
|
|
147
|
+
# eigenbasis, scale entrywise by the Loewner matrix, rotate back.
|
|
148
|
+
# kind is a string flag, so it gets no gradient.
|
|
149
|
+
local_grad = vectors.mT @ (0.5 * (grad + grad.mT)) @ vectors
|
|
150
|
+
return vectors @ (loewner * local_grad) @ vectors.mT, None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _symmetric_function(matrix: torch.Tensor, kind: _MatrixFunction) -> torch.Tensor:
|
|
154
|
+
"""Apply the spectral function `kind` to a (..., n, n) symmetric matrix.
|
|
155
|
+
|
|
156
|
+
Thin wrapper over `_SymmetricMatrixFunction.apply` so call sites read
|
|
157
|
+
as a function call. Same assumptions as that class: symmetric input,
|
|
158
|
+
SPD unless `kind` is "exp".
|
|
159
|
+
"""
|
|
160
|
+
return _SymmetricMatrixFunction.apply(matrix, kind)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SPDManifold:
|
|
164
|
+
"""Affine-invariant SPD(n) manifold on batched (B, n, n) tensors.
|
|
165
|
+
|
|
166
|
+
Spectral exp/log/distance and whitening support first derivatives at
|
|
167
|
+
repeated eigenvalues through a Daleckii-Krein backward. Higher-order
|
|
168
|
+
derivatives of these operations are not implemented. Methods follow the
|
|
169
|
+
placement of their inputs; `device` and `dtype` only set where
|
|
170
|
+
`random_point` draws.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
n: matrix size (operations work on (B, n, n) symmetric tensors).
|
|
174
|
+
device, dtype: tensor placement and precision.
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
>>> mfd = SPDManifold(n=4)
|
|
178
|
+
>>> S = mfd.random_point(batch_size=3)
|
|
179
|
+
>>> S.shape, mfd.is_spd(S).all().item()
|
|
180
|
+
(torch.Size([3, 4, 4]), True)
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, n: int,
|
|
184
|
+
device: str | torch.device = "cpu",
|
|
185
|
+
dtype: torch.dtype = torch.float64):
|
|
186
|
+
if n <= 0:
|
|
187
|
+
raise ValueError(f"n must be > 0, got n={n}")
|
|
188
|
+
self.n = n
|
|
189
|
+
self.device = torch.device(device)
|
|
190
|
+
self.dtype = dtype
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def dim(self) -> int:
|
|
194
|
+
"""Manifold dimension n(n+1)/2.
|
|
195
|
+
|
|
196
|
+
References:
|
|
197
|
+
Pennec et al. (2006), §3.1.
|
|
198
|
+
"""
|
|
199
|
+
# n*(n+1) is a derived even integer; integer division by 2 is exact.
|
|
200
|
+
return self.n * (self.n + 1) // 2
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ----------------------------------------------------------------
|
|
204
|
+
# Construction
|
|
205
|
+
# ----------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def random_point(
|
|
208
|
+
self,
|
|
209
|
+
batch_size: int = 1,
|
|
210
|
+
generator: torch.Generator | None = None,
|
|
211
|
+
) -> torch.Tensor:
|
|
212
|
+
"""Sample B SPD matrices via the Wishart-like construction A Aᵀ.
|
|
213
|
+
|
|
214
|
+
Construction: A ~ N(0, I)^{B × n × n}; return A Aᵀ. When A is
|
|
215
|
+
full-rank (with probability one for Gaussian A), A Aᵀ is SPD.
|
|
216
|
+
Distribution-wise this is a (rescaled) Wishart W_n(I, n); we do not
|
|
217
|
+
normalize since callers typically only require *some* SPD draw for
|
|
218
|
+
initialization / testing.
|
|
219
|
+
|
|
220
|
+
References:
|
|
221
|
+
Anderson, T. W. (2003). An Introduction to Multivariate Statistical
|
|
222
|
+
Analysis, 3rd ed., chapter 7; Wishart distribution.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Tensor of shape (B, n, n).
|
|
226
|
+
"""
|
|
227
|
+
if batch_size < 0:
|
|
228
|
+
raise ValueError(f"batch_size must be >= 0, got {batch_size}")
|
|
229
|
+
# A seeded generator only draws on its own device, so sample there
|
|
230
|
+
# and move the result to the manifold's device afterwards.
|
|
231
|
+
A = torch.randn(
|
|
232
|
+
batch_size,
|
|
233
|
+
self.n,
|
|
234
|
+
self.n,
|
|
235
|
+
generator=generator,
|
|
236
|
+
device=generator.device if generator is not None else self.device,
|
|
237
|
+
dtype=self.dtype,
|
|
238
|
+
).to(self.device)
|
|
239
|
+
return torch.bmm(A, A.mT)
|
|
240
|
+
|
|
241
|
+
def is_spd(self, S: torch.Tensor) -> torch.Tensor:
|
|
242
|
+
"""Per-batch test for SPD: symmetric and all eigenvalues > 0.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
(B,) boolean tensor.
|
|
246
|
+
|
|
247
|
+
References:
|
|
248
|
+
Pennec, Fillard, Ayache (2006), A Riemannian Framework for Tensor
|
|
249
|
+
Computing, §3, positive-definite matrices and affine-invariant geometry.
|
|
250
|
+
"""
|
|
251
|
+
sym_err = torch.linalg.matrix_norm(S - S.mT, dim=(-2, -1))
|
|
252
|
+
sym_ref = torch.linalg.matrix_norm(S, dim=(-2, -1))
|
|
253
|
+
# Treat S as symmetric when the asymmetric part is at noise level
|
|
254
|
+
# relative to ‖S‖_F. The clamp at 1 makes the tolerance absolute for
|
|
255
|
+
# matrices with norm below 1, so a tiny S is not judged by a tinier
|
|
256
|
+
# threshold.
|
|
257
|
+
is_sym = sym_err <= 1e-9 * torch.clamp(sym_ref, min=1.0)
|
|
258
|
+
# eigvalsh only reads one triangle, so hand it the symmetric part
|
|
259
|
+
# and let is_sym above answer the symmetry question separately.
|
|
260
|
+
eigvals = torch.linalg.eigvalsh(0.5 * (S + S.mT)) # (B, n)
|
|
261
|
+
is_pos = (eigvals > 0).all(dim=-1)
|
|
262
|
+
return is_sym & is_pos
|
|
263
|
+
|
|
264
|
+
# ----------------------------------------------------------------
|
|
265
|
+
# Tangent operations
|
|
266
|
+
# ----------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def projection(self, S: torch.Tensor, Z: torch.Tensor) -> torch.Tensor:
|
|
269
|
+
"""Project ambient direction Z onto the tangent space at S.
|
|
270
|
+
|
|
271
|
+
T_S P(n) = Sym(n), so the projection is just symmetrization:
|
|
272
|
+
|
|
273
|
+
P_T(Z) = ½ (Z + Zᵀ).
|
|
274
|
+
|
|
275
|
+
Independent of the base point S (T_S P(n) does not depend on S in
|
|
276
|
+
the embedded ambient form).
|
|
277
|
+
|
|
278
|
+
References:
|
|
279
|
+
Bhatia (2007), §6.1.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
S: base point (B, n, n), unused (included for API consistency).
|
|
283
|
+
Z: ambient direction (B, n, n).
|
|
284
|
+
Returns:
|
|
285
|
+
Symmetric tangent (B, n, n).
|
|
286
|
+
"""
|
|
287
|
+
del S # tangent space is point-independent in ambient form
|
|
288
|
+
return 0.5 * (Z + Z.mT)
|
|
289
|
+
|
|
290
|
+
def inner(
|
|
291
|
+
self, S: torch.Tensor, U: torch.Tensor, V: torch.Tensor
|
|
292
|
+
) -> torch.Tensor:
|
|
293
|
+
"""Affine-invariant inner product ⟨U, V⟩_S = tr(S⁻¹ U S⁻¹ V).
|
|
294
|
+
|
|
295
|
+
A shared Cholesky factor computes both solves. The trace contracts
|
|
296
|
+
transposed indices; the solved matrices need not be symmetric.
|
|
297
|
+
|
|
298
|
+
References:
|
|
299
|
+
Pennec et al. (2006), eq. 4.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
S: base point (B, n, n).
|
|
303
|
+
U, V: tangent vectors (B, n, n).
|
|
304
|
+
Returns:
|
|
305
|
+
(B,) inner products.
|
|
306
|
+
"""
|
|
307
|
+
factor = torch.linalg.cholesky(S)
|
|
308
|
+
S_inv_U = torch.cholesky_solve(U, factor)
|
|
309
|
+
S_inv_V = torch.cholesky_solve(V, factor)
|
|
310
|
+
# tr(X Y) = sum_{i,j} X_ij Y_ji = sum_{i,j} X_ij (Yᵀ)_ij
|
|
311
|
+
return (S_inv_U * S_inv_V.mT).sum(dim=(-2, -1))
|
|
312
|
+
|
|
313
|
+
def norm(self, S: torch.Tensor, V: torch.Tensor) -> torch.Tensor:
|
|
314
|
+
"""Affine-invariant norm ‖V‖_S, shape (B,).
|
|
315
|
+
|
|
316
|
+
With S = L Lᵀ, ‖V‖_S² = tr(S⁻¹ V S⁻¹ V) = ‖L⁻¹ V L⁻ᵀ‖_F², so the
|
|
317
|
+
norm is the Frobenius norm of the Cholesky-whitened tangent. Two
|
|
318
|
+
triangular solves replace the explicit inverse. At a zero tangent
|
|
319
|
+
the gradient is PyTorch's zero subgradient of vector_norm.
|
|
320
|
+
|
|
321
|
+
References:
|
|
322
|
+
Pennec et al. (2006), eq. 4.
|
|
323
|
+
"""
|
|
324
|
+
factor = torch.linalg.cholesky(S)
|
|
325
|
+
# left = L⁻¹ V; then solving against leftᵀ and transposing back
|
|
326
|
+
# applies L⁻¹ from the other side, giving L⁻¹ V L⁻ᵀ.
|
|
327
|
+
left = torch.linalg.solve_triangular(factor, V, upper=False)
|
|
328
|
+
whitened = torch.linalg.solve_triangular(factor, left.mT, upper=False).mT
|
|
329
|
+
return torch.linalg.vector_norm(whitened, dim=(-2, -1))
|
|
330
|
+
|
|
331
|
+
def egrad_to_rgrad(self, point: torch.Tensor, egrad: torch.Tensor) -> torch.Tensor:
|
|
332
|
+
"""Convert an ambient gradient G to S sym(G) S, shape (B, n, n).
|
|
333
|
+
|
|
334
|
+
The Riemannian gradient is the tangent vector whose affine-invariant
|
|
335
|
+
inner product with every U equals the Euclidean ⟨G, U⟩. Since
|
|
336
|
+
⟨S sym(G) S, U⟩_S = tr(sym(G) U), this is the conversion.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
point: (B, n, n) base point.
|
|
340
|
+
egrad: (B, n, n) Euclidean gradient.
|
|
341
|
+
Returns:
|
|
342
|
+
(B, n, n) metric gradient in tangent coordinates.
|
|
343
|
+
|
|
344
|
+
References:
|
|
345
|
+
Absil, Mahony, Sepulchre (2008), §3.6; Pennec et al. (2006), eq. 4.
|
|
346
|
+
"""
|
|
347
|
+
return point @ self.projection(point, egrad) @ point
|
|
348
|
+
|
|
349
|
+
# ----------------------------------------------------------------
|
|
350
|
+
# Exponential and logarithmic maps, geodesic distance
|
|
351
|
+
# ----------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def _sqrt_and_inv_sqrt(
|
|
354
|
+
self, S: torch.Tensor
|
|
355
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
356
|
+
"""Return (S^{1/2}, S^{−1/2}) via eigh.
|
|
357
|
+
|
|
358
|
+
Takes a (..., n, n) SPD S and returns two symmetric (..., n, n)
|
|
359
|
+
matrices. Each comes from its own `_symmetric_function` call, so
|
|
360
|
+
this runs two eighs. Both calls floor the eigenvalues at the dtype's
|
|
361
|
+
smallest positive normal, which guards against eigh returning a
|
|
362
|
+
slightly negative value for a nearly singular SPD matrix (from a
|
|
363
|
+
low-sample Wishart draw, or drift in an iterative pipeline). Without
|
|
364
|
+
that floor `torch.rsqrt(0)` would push `inf` through every
|
|
365
|
+
downstream exp/log/distance.
|
|
366
|
+
"""
|
|
367
|
+
return _symmetric_function(S, "sqrt"), _symmetric_function(S, "inv_sqrt")
|
|
368
|
+
|
|
369
|
+
def precompute_whitening(
|
|
370
|
+
self, S: torch.Tensor,
|
|
371
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
372
|
+
"""Compute (S^{1/2}, S^{-1/2}) once for reuse across geodesic ops.
|
|
373
|
+
|
|
374
|
+
The affine-invariant exp/log/distance all need S^{1/2} and S^{-1/2}
|
|
375
|
+
at the base point. Computing them once and passing to multiple
|
|
376
|
+
geodesic queries avoids redundant eighs in code paths like
|
|
377
|
+
"compare distances from S to many T_i" or "step along several
|
|
378
|
+
tangent directions at S in one iteration". Each eigh is
|
|
379
|
+
O(n³) and dominates the operations on large n.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
(S_sqrt, S_inv_sqrt); both (..., n, n), symmetric.
|
|
383
|
+
|
|
384
|
+
References:
|
|
385
|
+
Pennec, Fillard, Ayache (2006), A Riemannian Framework for Tensor
|
|
386
|
+
Computing, §3, positive-definite matrices and affine-invariant geometry.
|
|
387
|
+
"""
|
|
388
|
+
return self._sqrt_and_inv_sqrt(S)
|
|
389
|
+
|
|
390
|
+
def exp(
|
|
391
|
+
self, S: torch.Tensor, V: torch.Tensor,
|
|
392
|
+
whitening: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
393
|
+
) -> torch.Tensor:
|
|
394
|
+
"""Exponential map exp_S(V) = S^{1/2} expm(S^{−1/2} V S^{−1/2}) S^{1/2}.
|
|
395
|
+
|
|
396
|
+
References:
|
|
397
|
+
Pennec et al. (2006), eq. 5.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
S: base point (B, n, n), SPD.
|
|
401
|
+
V: tangent (B, n, n), symmetric.
|
|
402
|
+
whitening: optional (S^{1/2}, S^{-1/2}) precomputed via
|
|
403
|
+
`precompute_whitening(S)`. Pass when calling exp/log
|
|
404
|
+
multiple times at the same S to skip the redundant eigh.
|
|
405
|
+
Returns:
|
|
406
|
+
(B, n, n) SPD result.
|
|
407
|
+
"""
|
|
408
|
+
S_sqrt, S_inv_sqrt = whitening if whitening is not None \
|
|
409
|
+
else self._sqrt_and_inv_sqrt(S)
|
|
410
|
+
# Whiten V so the exponential is taken at the identity, where the
|
|
411
|
+
# affine-invariant exp is just the matrix exponential.
|
|
412
|
+
inner = S_inv_sqrt @ V @ S_inv_sqrt
|
|
413
|
+
# Symmetrize against numerical drift before the spectral exponential; result of
|
|
414
|
+
# symmetric @ symmetric @ symmetric is symmetric in exact arithmetic.
|
|
415
|
+
inner = 0.5 * (inner + inner.mT)
|
|
416
|
+
# The outer S_sqrt @ ... @ S_sqrt is symmetric in exact arithmetic
|
|
417
|
+
# but accumulates float drift; symmetrize so callers can chain
|
|
418
|
+
# exp() into is_spd / further geodesics without false negatives.
|
|
419
|
+
out = S_sqrt @ _symmetric_function(inner, "exp") @ S_sqrt
|
|
420
|
+
return 0.5 * (out + out.mT)
|
|
421
|
+
|
|
422
|
+
def log(
|
|
423
|
+
self, S: torch.Tensor, T: torch.Tensor,
|
|
424
|
+
whitening: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
425
|
+
) -> torch.Tensor:
|
|
426
|
+
"""Logarithm map log_S(T) = S^{1/2} logm(S^{−1/2} T S^{−1/2}) S^{1/2}.
|
|
427
|
+
|
|
428
|
+
Matrix log via eigh: logm(M) = U diag(log λ) Uᵀ for M = U diag(λ) Uᵀ
|
|
429
|
+
symmetric. The inner argument is symmetric SPD (congruence preserves
|
|
430
|
+
positivity), so this is well-defined.
|
|
431
|
+
|
|
432
|
+
References:
|
|
433
|
+
Pennec et al. (2006), eq. 6.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
S, T: base point and target, both (B, n, n) SPD.
|
|
437
|
+
whitening: optional (S^{1/2}, S^{-1/2}) precomputed via
|
|
438
|
+
`precompute_whitening(S)`. See `exp` for the use case.
|
|
439
|
+
Returns:
|
|
440
|
+
(B, n, n) symmetric tangent.
|
|
441
|
+
"""
|
|
442
|
+
S_sqrt, S_inv_sqrt = whitening if whitening is not None \
|
|
443
|
+
else self._sqrt_and_inv_sqrt(S)
|
|
444
|
+
inner = S_inv_sqrt @ T @ S_inv_sqrt
|
|
445
|
+
inner = 0.5 * (inner + inner.mT) # symmetrize
|
|
446
|
+
log_inner = _symmetric_function(inner, "log")
|
|
447
|
+
# log_S(T) lives in Sym(n); symmetrize the outer product to
|
|
448
|
+
# absorb float drift, consistent with exp() above.
|
|
449
|
+
out = S_sqrt @ log_inner @ S_sqrt
|
|
450
|
+
return 0.5 * (out + out.mT)
|
|
451
|
+
|
|
452
|
+
def distance(
|
|
453
|
+
self, S: torch.Tensor, T: torch.Tensor,
|
|
454
|
+
whitening: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
455
|
+
) -> torch.Tensor:
|
|
456
|
+
"""Geodesic distance d(S, T) = ‖log(S^{−1/2} T S^{−1/2})‖_F.
|
|
457
|
+
|
|
458
|
+
Equivalently d(S, T)² = Σ_i log²(λ_i) where λ_i are generalized
|
|
459
|
+
eigenvalues (T v = λ S v). We compute via the whitened form which
|
|
460
|
+
is well-conditioned for SPD inputs.
|
|
461
|
+
|
|
462
|
+
References:
|
|
463
|
+
Pennec et al. (2006), eq. 7.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
S, T: base point and target, both (B, n, n) SPD.
|
|
467
|
+
whitening: optional (S^{1/2}, S^{-1/2}) precomputed via
|
|
468
|
+
`precompute_whitening(S)` to skip the S-eigh.
|
|
469
|
+
Returns:
|
|
470
|
+
(B,) geodesic distances.
|
|
471
|
+
"""
|
|
472
|
+
if whitening is not None:
|
|
473
|
+
_, S_inv_sqrt = whitening
|
|
474
|
+
else:
|
|
475
|
+
_, S_inv_sqrt = self._sqrt_and_inv_sqrt(S)
|
|
476
|
+
whitened = S_inv_sqrt @ T @ S_inv_sqrt
|
|
477
|
+
whitened = 0.5 * (whitened + whitened.mT)
|
|
478
|
+
# Eigenvalues of S^{-1/2} T S^{-1/2} are positive in exact
|
|
479
|
+
# arithmetic (congruent to the SPD T). The log kind floors them at
|
|
480
|
+
# the dtype tiny, so float error driving one slightly below zero
|
|
481
|
+
# gives a large finite log instead of NaN.
|
|
482
|
+
log_matrix = _symmetric_function(whitened, "log")
|
|
483
|
+
return torch.linalg.vector_norm(log_matrix, dim=(-2, -1))
|
|
484
|
+
|
|
485
|
+
# ----------------------------------------------------------------
|
|
486
|
+
# Retraction (exponential map is the canonical choice on SPD)
|
|
487
|
+
# ----------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
def retraction(
|
|
490
|
+
self, S: torch.Tensor, V: torch.Tensor,
|
|
491
|
+
whitening: tuple[torch.Tensor, torch.Tensor] | None = None,
|
|
492
|
+
) -> torch.Tensor:
|
|
493
|
+
"""Retraction = exponential map.
|
|
494
|
+
|
|
495
|
+
On SPD with the affine-invariant metric, exp is the canonical
|
|
496
|
+
retraction (it is the geodesic, hence trivially a second-order
|
|
497
|
+
retraction). For a cheaper first-order alternative add a separate
|
|
498
|
+
method (e.g. project S + V onto SPD via eigh + positive-clamp)
|
|
499
|
+
when benchmarks demand it.
|
|
500
|
+
|
|
501
|
+
References:
|
|
502
|
+
Absil-Mahony-Sepulchre (2008), §4.1; exponential as retraction.
|
|
503
|
+
"""
|
|
504
|
+
return self.exp(S, V, whitening=whitening)
|