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