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