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,699 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Lorentz (hyperboloid) model of hyperbolic space, GPU-native, batched-first.
5
+
6
+ The Lorentz model of `n`-dimensional hyperbolic space of constant
7
+ sectional curvature `k < 0` is the upper sheet of the two-sheeted
8
+ hyperboloid in (n+1)-dimensional Minkowski space:
9
+
10
+ H^n_k = { x ∈ R^{n+1} : ⟨x, x⟩_M = 1/k, x_0 > 0 },
11
+ ⟨x, y⟩_M = −x_0·y_0 + Σ_{i ≥ 1} x_i·y_i
12
+
13
+ With `k = −1` the constraint collapses to ⟨x, x⟩_M = −1; the
14
+ canonical "unit hyperboloid." The Riemannian metric on H^n_k is the
15
+ restriction of ⟨·, ·⟩_M to tangent vectors
16
+
17
+ T_x H^n_k = { v ∈ R^{n+1} : ⟨x, v⟩_M = 0 }.
18
+
19
+ Restricted to T_x H^n_k, ⟨·, ·⟩_M is positive-definite, making the
20
+ hyperboloid a Riemannian manifold of constant negative curvature.
21
+
22
+ All seven core operations admit closed forms:
23
+
24
+ proj_T(x, w) = w − k · ⟨x, w⟩_M · x (orthogonal in ⟨·,·⟩_M)
25
+ ⟨u, v⟩_x = ⟨u, v⟩_M (induced metric)
26
+ d_k(x, y) = (1/√|k|) · arccosh(k · ⟨x, y⟩_M)
27
+ exp_x(v) = cosh(α) · x + sinh(α) · v / α, α = √|k| · ‖v‖_x
28
+ log_x(y) = β · u / ‖u‖_x,
29
+ u = y − k·⟨x,y⟩_M · x, β = d_k(x, y)
30
+ PT_{x→y}(v) = v − ⟨log_x(y), v⟩_x / d_k(x,y)² · (log_x(y) + log_y(x))
31
+ retraction = exp_x (geodesic; second-order)
32
+
33
+ Points are stored as `(B, n+1)` ambient tensors, batch first. Tangent
34
+ vectors use the same ambient form and satisfy ⟨x, v⟩_M = 0 at their
35
+ base point.
36
+
37
+ The manifold dimension reported by `.dim` is the **intrinsic** `n`,
38
+ not the ambient `n+1`.
39
+
40
+ References:
41
+ Nickel, M., Kiela, D. (2018). Learning continuous hierarchies in the
42
+ Lorentz model of hyperbolic geometry. ICML.
43
+ Chen, W., et al. (2022). Fully hyperbolic neural networks. ACL.
44
+ Lee, J. M. (2018). Introduction to Riemannian Manifolds, 2nd ed.,
45
+ Springer, §5–§6 (model spaces, hyperbolic geometry).
46
+ Cannon, J. W., Floyd, W. J., Kenyon, R., Parry, W. R. (1997).
47
+ Hyperbolic geometry. In Flavors of Geometry, MSRI Publications 31.
48
+ Pennec, X. (2006). Intrinsic statistics on Riemannian manifolds.
49
+ Journal of Mathematical Imaging and Vision 25(1):127–154.
50
+ (parallel transport in terms of log/distance, eq. 4).
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import math
56
+
57
+ import torch
58
+
59
+
60
+ def _safe_sqrt(x: torch.Tensor) -> torch.Tensor:
61
+ """sqrt(x), returning 0 with zero gradient where x ≤ 0.
62
+
63
+ The derivative of sqrt is infinite at 0, and a squared norm can come
64
+ out slightly negative from float drift. This helper takes the sqrt only
65
+ of a where-substituted input that is always positive, then masks the
66
+ result back:
67
+
68
+ - forward: `sqrt(x)` for `x > 0`, `0` for `x ≤ 0`
69
+ - backward: `1/(2·sqrt(x))` for `x > 0`, `0` for `x ≤ 0`
70
+ (the 0-subgradient choice at the boundary; the analytic
71
+ derivative is undefined there).
72
+
73
+ Used by `LorentzManifold.norm` and by `ProductManifold`
74
+ (`manifolds/product.py`).
75
+ """
76
+ is_positive = x > 0
77
+ x_safe = torch.where(is_positive, x, torch.ones_like(x))
78
+ return torch.where(is_positive, torch.sqrt(x_safe), torch.zeros_like(x))
79
+
80
+
81
+ def _safe_sinhc(alpha: torch.Tensor) -> torch.Tensor:
82
+ """sinh(α)/α, with the limit 1 at α = 0.
83
+
84
+ The formula is 0/0 at α = 0. A `where` keeps that NaN out of the forward
85
+ value, but it does not stop a NaN in the unselected branch from reaching
86
+ the backward pass. Evaluating `sinh(α_safe)/α_safe`, with α_safe = 1
87
+ wherever α = 0, keeps both branches finite:
88
+
89
+ - forward: `sinh(α)/α` for `α > 0`, `1` for `α = 0`
90
+ - backward: `(α·cosh α − sinh α)/α²` for `α > 0`, `0` for α = 0
91
+ """
92
+ is_positive = alpha > 0
93
+ alpha_safe = torch.where(is_positive, alpha, torch.ones_like(alpha))
94
+ return torch.where(
95
+ is_positive,
96
+ torch.sinh(alpha_safe) / alpha_safe,
97
+ torch.ones_like(alpha),
98
+ )
99
+
100
+
101
+ def _safe_arcsinhc(arg: torch.Tensor) -> torch.Tensor:
102
+ """arcsinh(x)/x with autograd-safe handling at x = 0.
103
+
104
+ Analytic limit at 0 is 1. Same idiom as `_safe_sinhc`: the formula
105
+ branch evaluates at a where-substituted input that's never 0.
106
+ """
107
+ is_positive = arg > 0
108
+ arg_safe = torch.where(is_positive, arg, torch.ones_like(arg))
109
+ return torch.where(
110
+ is_positive,
111
+ torch.asinh(arg_safe) / arg_safe,
112
+ torch.ones_like(arg),
113
+ )
114
+
115
+
116
+ class LorentzManifold:
117
+ """Lorentz (hyperboloid) model of hyperbolic space H^n_k.
118
+
119
+ Args:
120
+ n: intrinsic manifold dimension. Points live in ambient R^{n+1}.
121
+ k: sectional curvature; must be strictly negative. Default `−1.0`
122
+ is the unit hyperboloid (the canonical literature choice).
123
+ device, dtype: tensor placement and precision.
124
+
125
+ Example:
126
+ >>> mfd = LorentzManifold(n=3)
127
+ >>> x = mfd.random_point(batch_size=4)
128
+ >>> x.shape, mfd.is_on_manifold(x).all().item()
129
+ (torch.Size([4, 4]), True)
130
+ """
131
+
132
+ def __init__(self, n: int, k: float = -1.0,
133
+ device: str | torch.device = "cpu",
134
+ dtype: torch.dtype = torch.float64):
135
+ if n <= 0:
136
+ raise ValueError(f"n must be > 0, got n={n}")
137
+ if k >= 0:
138
+ raise ValueError(
139
+ f"k must be strictly negative (hyperbolic), got k={k}"
140
+ )
141
+ self.n = n
142
+ self.k = float(k)
143
+ self.device = torch.device(device)
144
+ self.dtype = dtype
145
+ # Precompute √|k| as a Python float; it's used in every closed-form
146
+ # call but does not depend on input device/dtype.
147
+ self._abs_k = abs(self.k)
148
+ self._sqrt_abs_k = math.sqrt(self._abs_k)
149
+ self._inv_sqrt_abs_k = 1.0 / self._sqrt_abs_k
150
+
151
+ @property
152
+ def dim(self) -> int:
153
+ """Intrinsic manifold dimension `n`. Ambient dimension is `n+1`.
154
+
155
+ References:
156
+ Lee (2018), §3.
157
+ """
158
+ return self.n
159
+
160
+ @property
161
+ def ambient_dim(self) -> int:
162
+ """Ambient dimension `n + 1`. The Lorentz model embeds H^n_k in
163
+ Minkowski R^{n+1}. Used by manifold-generic primitives in
164
+ `holonomy_lib.hyperbolic`. `KappaStereographicManifold` needs no
165
+ extra coordinate, so its `ambient_dim` is `n`.
166
+ """
167
+ return self.n + 1
168
+
169
+
170
+ # ----------------------------------------------------------------
171
+ # Minkowski form (helper)
172
+ # ----------------------------------------------------------------
173
+
174
+ def minkowski_inner(
175
+ self, x: torch.Tensor, y: torch.Tensor,
176
+ ) -> torch.Tensor:
177
+ """Minkowski inner product ⟨x, y⟩_M = −x_0 y_0 + Σ_{i≥1} x_i y_i.
178
+
179
+ This is **not** the Riemannian inner product (which equals this
180
+ only when restricted to tangent vectors at a common base point).
181
+ Public so that downstream callers can express their own
182
+ constructions without re-implementing the sign convention.
183
+
184
+ Args:
185
+ x, y: ambient vectors of shape (B, n+1).
186
+ Returns:
187
+ (B,) tensor of Minkowski inner products.
188
+
189
+ References:
190
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
191
+ Lorentz Model of Hyperbolic Geometry, §3.
192
+ """
193
+ # The time coordinate x_0 enters with a minus sign. The spatial
194
+ # coordinates 1: give the ordinary Euclidean dot product.
195
+ return (x[..., 1:] * y[..., 1:]).sum(dim=-1) - x[..., 0] * y[..., 0]
196
+
197
+ def is_on_manifold(
198
+ self, x: torch.Tensor, atol: float = 1e-9,
199
+ ) -> torch.Tensor:
200
+ """Per-batch test for membership in H^n_k.
201
+
202
+ Checks ⟨x, x⟩_M ≈ 1/k (within `atol`) and x_0 > 0 (upper sheet).
203
+
204
+ Args:
205
+ x: (B, n+1) ambient candidate points.
206
+ atol: absolute tolerance for the constraint. Default 1e-9 is
207
+ the library's `numerical_floor_convention`. `exp` re-projects
208
+ onto the hyperboloid, so its output meets the constraint to
209
+ machine precision, and 1e-9 sits well above that drift.
210
+ Returns:
211
+ (B,) boolean tensor.
212
+
213
+ References:
214
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
215
+ Lorentz Model of Hyperbolic Geometry, §3.
216
+ """
217
+ constraint = self.minkowski_inner(x, x) - (1.0 / self.k)
218
+ on_sheet = constraint.abs() <= atol
219
+ upper = x[..., 0] > 0
220
+ return on_sheet & upper
221
+
222
+ # ----------------------------------------------------------------
223
+ # Construction
224
+ # ----------------------------------------------------------------
225
+
226
+ def origin(self, batch_size: int = 1) -> torch.Tensor:
227
+ """The canonical "north pole" `(1/√|k|, 0, …, 0)` of H^n_k.
228
+
229
+ Useful as a base point for `exp_0` / `log_0` and for tangent-space
230
+ embeddings of Euclidean data.
231
+
232
+ Args:
233
+ batch_size: leading batch dim. Pass 0/1/many.
234
+ Returns:
235
+ (B, n+1) tensor with the north-pole repeated.
236
+
237
+ References:
238
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
239
+ Lorentz Model of Hyperbolic Geometry, §3.
240
+ """
241
+ if batch_size < 0:
242
+ raise ValueError(f"batch_size must be >= 0, got {batch_size}")
243
+ out = torch.zeros(batch_size, self.n + 1,
244
+ device=self.device, dtype=self.dtype)
245
+ # x_0 = 1/√|k| gives ⟨x, x⟩_M = −1/|k| = 1/k, so the origin is on the sheet.
246
+ out[..., 0] = self._inv_sqrt_abs_k
247
+ return out
248
+
249
+ def random_point(
250
+ self,
251
+ batch_size: int = 1,
252
+ generator: torch.Generator | None = None,
253
+ ) -> torch.Tensor:
254
+ """Sample B random points on H^n_k.
255
+
256
+ Construction: draw a Euclidean tangent at the origin from
257
+ `N(0, I_n)`, embed it as an ambient tangent `(0, v_spatial)`,
258
+ then apply `exp_0`. The resulting distribution has full
259
+ support on the upper sheet of the hyperboloid.
260
+
261
+ This is a **convenience** draw for tests/initialization; it is
262
+ not the wrapped-normal or any other statistically-motivated
263
+ prior on H^n_k.
264
+
265
+ Args:
266
+ batch_size: leading batch dim.
267
+ generator: optional torch.Generator for reproducibility.
268
+ Returns:
269
+ (B, n+1) tensor of points on H^n_k.
270
+
271
+ References:
272
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
273
+ Lorentz Model of Hyperbolic Geometry, §3.
274
+ """
275
+ if batch_size < 0:
276
+ raise ValueError(f"batch_size must be >= 0, got {batch_size}")
277
+ # A torch.Generator belongs to one device, so the draw runs on the
278
+ # generator's device and is moved to the manifold's device afterward.
279
+ v_spatial = torch.randn(
280
+ batch_size,
281
+ self.n,
282
+ generator=generator,
283
+ device=generator.device if generator is not None else self.device,
284
+ dtype=self.dtype,
285
+ ).to(self.device)
286
+ return self.exp_0(v_spatial)
287
+
288
+ # ----------------------------------------------------------------
289
+ # Tangent operations
290
+ # ----------------------------------------------------------------
291
+
292
+ def projection(
293
+ self, x: torch.Tensor, w: torch.Tensor,
294
+ ) -> torch.Tensor:
295
+ """Project ambient direction `w` onto T_x H^n_k.
296
+
297
+ T_x H^n_k = { v : ⟨x, v⟩_M = 0 }. The orthogonal projection in
298
+ the Minkowski form is
299
+
300
+ proj_T(x, w) = w − ⟨x, w⟩_M / ⟨x, x⟩_M · x
301
+ = w − k · ⟨x, w⟩_M · x (since ⟨x,x⟩_M = 1/k)
302
+
303
+ Idempotent: proj(proj(w)) = proj(w) (a vector already in T_x
304
+ satisfies ⟨x, v⟩_M = 0, so the subtracted term is zero).
305
+
306
+ References:
307
+ Nickel & Kiela (2018), eq. 6.
308
+
309
+ Args:
310
+ x: base point (B, n+1) on H^n_k.
311
+ w: ambient direction (B, n+1).
312
+ Returns:
313
+ Tangent (B, n+1).
314
+ """
315
+ coeff = self.k * self.minkowski_inner(x, w) # (B,)
316
+ return w - coeff.unsqueeze(-1) * x
317
+
318
+ def egrad_to_rgrad(self, point: torch.Tensor, egrad: torch.Tensor) -> torch.Tensor:
319
+ """Apply the Minkowski metric inverse, then project to the tangent.
320
+
321
+ Args:
322
+ point: (B, ambient_dim) base point.
323
+ egrad: (B, ambient_dim) Euclidean gradient.
324
+ Returns:
325
+ (B, ambient_dim) metric gradient in tangent coordinates.
326
+
327
+ References:
328
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
329
+ Lorentz Model of Hyperbolic Geometry, §3, Riemannian optimization.
330
+ Absil, Mahony, Sepulchre (2008), §3.6.
331
+ """
332
+ # The Minkowski metric is diag(-1, 1, ..., 1), which is its own
333
+ # inverse, so the Riemannian gradient flips the sign of the time
334
+ # component. The projection then removes the normal part.
335
+ metric_gradient = torch.cat((-egrad[..., :1], egrad[..., 1:]), dim=-1)
336
+ return self.projection(point, metric_gradient)
337
+
338
+ def inner(
339
+ self, x: torch.Tensor, u: torch.Tensor, v: torch.Tensor,
340
+ ) -> torch.Tensor:
341
+ """Riemannian inner product ⟨u, v⟩_x = ⟨u, v⟩_M on T_x H^n_k.
342
+
343
+ The induced metric: positive-definite when both inputs are
344
+ tangent vectors at `x` (i.e. ⟨x, u⟩_M = ⟨x, v⟩_M = 0). The base
345
+ point `x` is unused arithmetically (the induced metric is
346
+ point-independent in ambient form) but is included for API
347
+ symmetry with point-dependent metrics like SPD's affine-invariant.
348
+
349
+ References:
350
+ Lee (2018), §5.
351
+
352
+ Args:
353
+ x: base point (B, n+1); unused; included for API parity.
354
+ u, v: tangent vectors (B, n+1).
355
+ Returns:
356
+ (B,) Riemannian inner products.
357
+ """
358
+ del x # induced metric is point-independent in ambient form
359
+ return self.minkowski_inner(u, v)
360
+
361
+ def norm(
362
+ self, x: torch.Tensor, v: torch.Tensor,
363
+ ) -> torch.Tensor:
364
+ """Riemannian norm √⟨v, v⟩_x.
365
+
366
+ For a true tangent at `x`, ⟨v, v⟩_M ≥ 0. Float drift can push
367
+ this slightly negative (about machine eps × ‖v‖²), so the root is
368
+ taken with `_safe_sqrt`. That helper returns 0 for non-positive input
369
+ and has a zero gradient there, so v = 0 gives 0 and not a NaN.
370
+
371
+ Returns:
372
+ (B,) non-negative norms.
373
+
374
+ References:
375
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
376
+ Lorentz Model of Hyperbolic Geometry, §3.
377
+ """
378
+ return _safe_sqrt(self.inner(x, v, v))
379
+
380
+ # ----------------------------------------------------------------
381
+ # Exponential / logarithmic maps, geodesic distance
382
+ # ----------------------------------------------------------------
383
+
384
+ def exp(
385
+ self, x: torch.Tensor, v: torch.Tensor,
386
+ ) -> torch.Tensor:
387
+ """Exponential map exp_x(v) = cosh(α)·x + sinh(α)·v/α,
388
+ α = √|k| · ‖v‖_x.
389
+
390
+ Closed-form geodesic on a constant-curvature space. At v = 0,
391
+ the analytic limit is `x`; the implementation enforces this via
392
+ a `torch.where` guard so that gradients (if taken) and forward
393
+ values are both finite and correct at the origin of T_x.
394
+
395
+ Result is re-normalized onto the hyperboloid to remove the small
396
+ Minkowski-form drift introduced by float arithmetic (cosh²−sinh²
397
+ identity holds in exact arithmetic; finite precision violates it
398
+ at the eps · |x_0| level). Without this, chained exps would
399
+ accumulate drift and trip `is_on_manifold`.
400
+
401
+ References:
402
+ Nickel & Kiela (2018), eq. 8; Lee (2018), §5.
403
+
404
+ Args:
405
+ x: base point (B, n+1) on H^n_k.
406
+ v: tangent (B, n+1) at x (⟨x, v⟩_M = 0).
407
+ Returns:
408
+ (B, n+1) point on H^n_k.
409
+ """
410
+ norm_v = self.norm(x, v) # (B,)
411
+ alpha = self._sqrt_abs_k * norm_v # (B,)
412
+
413
+ # sinh(α)/α is 0/0 at α = 0, where its limit is 1. `_safe_sinhc`
414
+ # handles that point, so the forward value and the gradient stay
415
+ # finite when v = 0.
416
+ sinhc = _safe_sinhc(alpha)
417
+ cosh_alpha = torch.cosh(alpha) # cosh(0) = 1, safe
418
+
419
+ out = cosh_alpha.unsqueeze(-1) * x + sinhc.unsqueeze(-1) * v
420
+ return self._reproject_to_hyperboloid(out)
421
+
422
+ def log(
423
+ self, x: torch.Tensor, y: torch.Tensor,
424
+ ) -> torch.Tensor:
425
+ """Logarithmic map log_x(y) = (α / sinh α) · u,
426
+ u = y − k·⟨x,y⟩_M · x, α = arccosh(k·⟨x,y⟩_M).
427
+
428
+ Inverse of `exp` on the domain where it's a diffeomorphism (all
429
+ of H^n_k, a Hadamard manifold). At y = x, both numerator (α) and
430
+ the natural denominator (‖u‖) vanish.
431
+
432
+ Coordinates and tangent components must remain representable. Very
433
+ large radii can still lose angular/tangent precision, especially in
434
+ float32; distance and log_0 have a wider useful numerical range.
435
+
436
+ **Numerical note.** The textbook form is `β·u/‖u‖_x` with
437
+ `β = (1/√|k|)·arccosh(z)`. For x ≈ y, `arccosh(z)` near `z = 1`
438
+ and `‖u‖` near 0 both round to small positive values
439
+ independently, and their ratio is unstable (we've seen
440
+ `β/‖u‖ ≈ 10⁸` for `β, ‖u‖ ~ 1e-8 / 1e-16` from float roundoff,
441
+ amplifying a noise-level `u` into a 1e-7 spurious log). The
442
+ equivalent form
443
+
444
+ log_x(y) = (α / sinh α) · u
445
+
446
+ with `α = arccosh(z)` uses the smooth `sinhc⁻¹` factor (bounded
447
+ by 1, equal to 1 at α=0), which keeps the output well-scaled by
448
+ the small `u` directly. Derivation: `‖u‖_M = sinh(α)/√|k|`
449
+ (from `⟨u,u⟩_M = (z² − 1)/|k|` for x, y on H^n_k), and
450
+ `β = α/√|k|`, so `β/‖u‖ = α/sinh α`; the `√|k|` factors cancel.
451
+
452
+ References:
453
+ Nickel & Kiela (2018), eq. 9; Lee (2018), §5.
454
+
455
+ Args:
456
+ x, y: points on H^n_k, both (B, n+1).
457
+ Returns:
458
+ (B, n+1) tangent at x.
459
+ """
460
+ alpha = self._sqrt_abs_k * self.distance(x, y)
461
+ # The distance comes from the spatial coordinates, which avoids the
462
+ # cancellation in the ambient Minkowski product at large radius. The
463
+ # temporal coordinate of the tangent is rebuilt from tangency
464
+ # (⟨x, u⟩_M = 0) rather than subtracted, since that subtraction would
465
+ # cancel two large, nearly equal temporal components.
466
+ scale = _safe_sinhc(alpha).reciprocal()
467
+ spatial = scale.unsqueeze(-1) * (
468
+ y[..., 1:] - torch.cosh(alpha).unsqueeze(-1) * x[..., 1:]
469
+ )
470
+ time_at_x = torch.sqrt(x[..., 1:].square().sum(-1) - 1.0 / self.k)
471
+ temporal = (x[..., 1:] * spatial).sum(-1) / time_at_x
472
+ return torch.cat((temporal.unsqueeze(-1), spatial), dim=-1)
473
+
474
+ def distance(
475
+ self, x: torch.Tensor, y: torch.Tensor,
476
+ ) -> torch.Tensor:
477
+ """Geodesic distance, computed from the spatial coordinates alone.
478
+
479
+ A point's temporal coordinate is fixed by its spatial part, so only the
480
+ spatial parts enter. Resolve the smaller
481
+ spatial vector into components parallel/perpendicular to the larger
482
+ one. If m = sqrt(1 + ||perp||²) and δ is their axial rapidity
483
+ difference, cosh(√|k| d) - 1 = (m - 1) + 2m sinh²(δ/2).
484
+ Rationalizing m - 1 avoids subtracting nearly equal squares.
485
+
486
+ Unlike an ambient Minkowski subtraction, this retains large radial
487
+ distances in float32. Spatial squared norms and hyperbolic functions
488
+ must remain representable; angles below coordinate precision cannot
489
+ be recovered. Off-manifold temporal coordinates are ignored.
490
+
491
+ Args:
492
+ x, y: (B, n+1) points on the positive hyperboloid sheet.
493
+ Returns:
494
+ (B,) distances, with PyTorch's zero subgradient at coincident points.
495
+
496
+ References:
497
+ Nickel and Kiela (2018), eq. 7, Lorentz distance; Olver (2010),
498
+ NIST Handbook §4.37, hyperbolic addition and half-angle identities.
499
+ """
500
+ a = self._sqrt_abs_k * x[..., 1:]
501
+ b = self._sqrt_abs_k * y[..., 1:]
502
+ a_norm = torch.linalg.vector_norm(a, dim=-1, keepdim=True)
503
+ b_norm = torch.linalg.vector_norm(b, dim=-1, keepdim=True)
504
+ a_larger = a_norm >= b_norm
505
+ axis = torch.where(a_larger, a, b)
506
+ other = torch.where(a_larger, b, a)
507
+ radius = torch.where(a_larger, a_norm, b_norm)
508
+ unit = axis / torch.where(radius > 0, radius, torch.ones_like(radius))
509
+ # Take the residual against the pivot coordinate instead of against a
510
+ # rounded unit vector. Subtracting the unit vector directly invents a
511
+ # large transverse component for large collinear spatial vectors. The
512
+ # pivot is the largest coordinate of `axis`, so dividing by it is
513
+ # well conditioned.
514
+ pivot = axis.abs().argmax(dim=-1, keepdim=True)
515
+ axis_pivot = axis.gather(-1, pivot)
516
+ safe_pivot = torch.where(axis_pivot != 0, axis_pivot, 1.0)
517
+ ratio = other.gather(-1, pivot) / safe_pivot
518
+ residual = other - ratio * axis
519
+ along_residual = (residual * unit).sum(dim=-1, keepdim=True)
520
+ parallel = ratio * radius + along_residual
521
+ perpendicular = residual - along_residual * unit
522
+ transverse_scale = torch.sqrt(
523
+ 1.0 + perpendicular.square().sum(-1, keepdim=True)
524
+ )
525
+ delta = torch.asinh(parallel / transverse_scale) - torch.asinh(radius)
526
+ transverse = perpendicular / torch.sqrt(2.0 * (transverse_scale + 1.0))
527
+ axial = torch.sqrt(transverse_scale) * torch.sinh(0.5 * delta)
528
+ half_sinh = torch.linalg.vector_norm(
529
+ torch.cat((transverse, axial), dim=-1), dim=-1
530
+ )
531
+ distance = 2.0 * self._inv_sqrt_abs_k * torch.asinh(half_sinh)
532
+ # Identical spatial parts give exactly 0. The check is explicit so the
533
+ # zero does not depend on how the decomposition rounds.
534
+ same = (x[..., 1:] == y[..., 1:]).all(dim=-1)
535
+ return torch.where(same, torch.zeros_like(distance), distance)
536
+
537
+ def parallel_transport(
538
+ self, x: torch.Tensor, y: torch.Tensor, v: torch.Tensor,
539
+ ) -> torch.Tensor:
540
+ """Parallel transport v ∈ T_x H^n_k → T_y H^n_k along the
541
+ geodesic from x to y.
542
+
543
+ Closed form on the constant-curvature hyperboloid:
544
+
545
+ PT_{x→y}(v) = v − ⟨log_x(y), v⟩_x / d_k(x,y)²
546
+ · (log_x(y) + log_y(x))
547
+
548
+ At x = y this evaluates to `v` (both log terms vanish).
549
+ Isometric: ⟨PT(u), PT(v)⟩_y = ⟨u, v⟩_x.
550
+
551
+ References:
552
+ Nickel & Kiela (2018), §3 (Lorentz geometry).
553
+ Lee (2018), §4 (Levi-Civita connection, parallel transport).
554
+
555
+ Args:
556
+ x, y: (B, n+1) base and target points on H^n_k.
557
+ v: (B, n+1) tangent at x.
558
+ Returns:
559
+ (B, n+1) tangent at y.
560
+ """
561
+ log_xy = self.log(x, y)
562
+ log_yx = self.log(y, x)
563
+ d = self.distance(x, y)
564
+ # ⟨log_x(y), v⟩_x, using the induced metric at x
565
+ ip = self.inner(x, log_xy, v)
566
+ # d² is 0 at d = 0, where both log terms vanish and PT(v) = v. Divide
567
+ # by 1 there so no 0/0 is formed, then zero the coefficient.
568
+ d_sq = d * d
569
+ d_sq_safe = torch.where(d > 0, d_sq, torch.ones_like(d_sq))
570
+ coeff = torch.where(
571
+ d > 0,
572
+ ip / d_sq_safe,
573
+ torch.zeros_like(d_sq),
574
+ )
575
+ return v - coeff.unsqueeze(-1) * (log_xy + log_yx)
576
+
577
+ # ----------------------------------------------------------------
578
+ # Retraction (= exp for the hyperboloid; second-order)
579
+ # ----------------------------------------------------------------
580
+
581
+ def retraction(
582
+ self, x: torch.Tensor, v: torch.Tensor,
583
+ ) -> torch.Tensor:
584
+ """Retraction = exponential map.
585
+
586
+ On constant-curvature spaces, exp is a retraction, and a second-order
587
+ one: the geodesic it traces has zero acceleration at the base point.
588
+ `RiemannianSGD` calls this method through the
589
+ `egrad_to_rgrad → retraction` pipeline.
590
+
591
+ References:
592
+ Absil, Mahony, Sepulchre (2008), §4.1.
593
+ """
594
+ return self.exp(x, v)
595
+
596
+ # ----------------------------------------------------------------
597
+ # Convenience: exp / log at the origin
598
+ # ----------------------------------------------------------------
599
+
600
+ def exp_0(self, v_spatial: torch.Tensor) -> torch.Tensor:
601
+ """Exponential map at the origin from an (n,)-dimensional
602
+ Euclidean coordinate.
603
+
604
+ Specialized form of `exp` with the radial component fixed: at the
605
+ origin `o = (1/√|k|, 0, …, 0)`, a tangent has the shape
606
+ `(0, v_spatial)` with `‖(0, v_spatial)‖_o = ‖v_spatial‖_2`. So:
607
+
608
+ exp_o((0, v)) = (cosh(α)/√|k|, sinh(α)·v/α),
609
+ α = √|k| · ‖v‖_2.
610
+
611
+ Useful for embedding a Euclidean point cloud onto H^n_k. It equals
612
+ `exp(origin(B), (0, v))`, but it takes the (n,) coordinates directly,
613
+ so no ambient tangent is built and no Minkowski product is taken.
614
+
615
+ Args:
616
+ v_spatial: (B, n) Euclidean coordinates.
617
+ Returns:
618
+ (B, n+1) points on H^n_k.
619
+
620
+ References:
621
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
622
+ Lorentz Model of Hyperbolic Geometry, §3.
623
+ """
624
+ if v_spatial.shape[-1] != self.n:
625
+ raise ValueError(
626
+ f"v_spatial last dim must be n={self.n}, "
627
+ f"got {v_spatial.shape[-1]}"
628
+ )
629
+ norm_v = torch.linalg.vector_norm(v_spatial, dim=-1)
630
+ alpha = self._sqrt_abs_k * norm_v
631
+ cosh_a = torch.cosh(alpha)
632
+ sinhc = _safe_sinhc(alpha)
633
+
634
+ # Assemble the ambient point with torch.cat, out of place. Writing into
635
+ # a preallocated tensor is an in-place op that autograd has to track.
636
+ time = (cosh_a * self._inv_sqrt_abs_k).unsqueeze(-1)
637
+ space = sinhc.unsqueeze(-1) * v_spatial
638
+ out = torch.cat([time, space], dim=-1)
639
+ return self._reproject_to_hyperboloid(out)
640
+
641
+ def log_0(self, y: torch.Tensor) -> torch.Tensor:
642
+ """Logarithmic map at the origin to an (n,)-dimensional Euclidean
643
+ coordinate.
644
+
645
+ Specialized form of `log` from `o = (1/√|k|, 0, …, 0)`. For y on
646
+ H^n_k, the projection `u = y − k·⟨o,y⟩_M·o` has zero temporal
647
+ component and spatial part `y_{1:}`. Hence
648
+
649
+ log_o(y)_spatial = β · y_{1:} / ‖y_{1:}‖_2,
650
+ β = asinh(√|k| · ||y_spatial||) / √|k|.
651
+
652
+ Working from the spatial part alone avoids the temporal/spatial
653
+ cancellation at large radius. At y_spatial = 0 the factor asinh(a)/a
654
+ takes its limit 1 (see `_safe_arcsinhc`), so log_0 is defined there.
655
+
656
+ Returns:
657
+ (B, n) Euclidean coordinates in T_o H^n_k.
658
+
659
+ References:
660
+ Nickel and Kiela (2018), Learning Continuous Hierarchies in the
661
+ Lorentz Model of Hyperbolic Geometry, §3.
662
+ """
663
+ if y.shape[-1] != self.n + 1:
664
+ raise ValueError(
665
+ f"y last dim must be n+1={self.n + 1}, got {y.shape[-1]}"
666
+ )
667
+ y_spatial = y[..., 1:]
668
+ arg = self._sqrt_abs_k * torch.linalg.vector_norm(y_spatial, dim=-1)
669
+ return _safe_arcsinhc(arg).unsqueeze(-1) * y_spatial
670
+
671
+ # ----------------------------------------------------------------
672
+ # Internal: re-project a near-manifold ambient point onto H^n_k
673
+ # ----------------------------------------------------------------
674
+
675
+ def _reproject_to_hyperboloid(self, x: torch.Tensor) -> torch.Tensor:
676
+ """Recompute x_0 so that ⟨x, x⟩_M = 1/k holds again, up to float error.
677
+
678
+ For a point that satisfies the constraint in exact arithmetic
679
+ but drifts by ε from float operations, recovering exactness is
680
+
681
+ x_0_new = √(‖x_{1:}‖² + |1/k|)
682
+
683
+ which preserves the spatial part and the upper-sheet sign. This
684
+ is the standard "renormalize after exp" trick (Nickel-Kiela
685
+ 2018 §3) and it does the same job as the symmetrization in
686
+ `SPDManifold.exp`.
687
+
688
+ Implementation: `torch.cat` rather than in-place subscript
689
+ assignment on a `clone`, so autograd treats the operation as
690
+ an out-of-place tensor construction. The argument of sqrt is
691
+ always strictly positive (`spatial_sq + |1/k| ≥ |1/k| > 0`),
692
+ so sqrt's backward is finite without a `_safe_sqrt` wrapper.
693
+ """
694
+ spatial = x[..., 1:]
695
+ spatial_sq = (spatial * spatial).sum(dim=-1)
696
+ # For k<0, ⟨x,x⟩_M = -x_0² + ‖x_{1:}‖² = 1/k ⇒ x_0² = ‖x_{1:}‖² - 1/k
697
+ # = ‖x_{1:}‖² + |1/k|. Always ≥ |1/k| > 0; sqrt is safe.
698
+ x0_new = torch.sqrt(spatial_sq + (-1.0 / self.k))
699
+ return torch.cat([x0_new.unsqueeze(-1), spatial], dim=-1)