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,680 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""κ-stereographic projection model; constant curvature manifold
|
|
5
|
+
that interpolates spherical (κ > 0), Euclidean (κ = 0), and
|
|
6
|
+
hyperbolic (κ < 0) geometry via a single parameter.
|
|
7
|
+
|
|
8
|
+
The κ-stereographic model represents:
|
|
9
|
+
|
|
10
|
+
- **κ > 0** (spherical): the stereographic projection of the
|
|
11
|
+
`n`-sphere of radius `1/√κ`, excluding one pole, onto all of `R^n`.
|
|
12
|
+
- **κ = 0** (Euclidean): the standard `R^n`.
|
|
13
|
+
- **κ < 0** (hyperbolic): the Poincaré ball of radius `1/√|κ|`,
|
|
14
|
+
`{x ∈ R^n : ‖x‖² < 1/|κ|}`.
|
|
15
|
+
|
|
16
|
+
The models share curvature-dependent conformal metrics and exp/log maps.
|
|
17
|
+
The spherical formulas use a stereographic chart, so operations that hit
|
|
18
|
+
its omitted pole are undefined even though distance itself remains defined.
|
|
19
|
+
|
|
20
|
+
Coordinate convention: points live in `R^n` (no extra ambient
|
|
21
|
+
dimension, unlike the Lorentz model). The intrinsic and ambient
|
|
22
|
+
dimensions both equal `n`. Tangent vectors at any base point are
|
|
23
|
+
also `R^n`.
|
|
24
|
+
|
|
25
|
+
Core formulas (Bachmann–Bécigneul–Ganea 2020, §3):
|
|
26
|
+
|
|
27
|
+
λ_κ(x) = 2 / (1 + κ‖x‖²) (conformal factor)
|
|
28
|
+
⟨u, v⟩_x = λ_κ(x)² · ⟨u, v⟩_Eucl (Riemannian metric)
|
|
29
|
+
x ⊕_κ y = ((1 - 2κ⟨x,y⟩ - κ‖y‖²) x + (1 + κ‖x‖²) y)
|
|
30
|
+
/ (1 - 2κ⟨x,y⟩ + κ²‖x‖² ‖y‖²) (Möbius addition)
|
|
31
|
+
d_κ(x, y) = (2/√|κ|) · tan_κ⁻¹(√|κ| · ‖(-x) ⊕_κ y‖)
|
|
32
|
+
exp_x(v) = x ⊕_κ (tan_κ(λ_κ(x)·√|κ|·‖v‖/2)
|
|
33
|
+
· v / (√|κ|·‖v‖))
|
|
34
|
+
log_x(y) = (2/(λ_κ(x)·√|κ|)) · tan_κ⁻¹(√|κ|·‖(-x) ⊕_κ y‖)
|
|
35
|
+
· ((-x) ⊕_κ y) / ‖(-x) ⊕_κ y‖
|
|
36
|
+
|
|
37
|
+
where `tan_κ` is `tan` for κ > 0, `tanh` for κ < 0, identity for
|
|
38
|
+
κ = 0; correspondingly `tan_κ⁻¹` is `arctan` / `arctanh` / identity.
|
|
39
|
+
|
|
40
|
+
κ may be a Python float or a learnable scalar tensor. Tensor curvature
|
|
41
|
+
is evaluated on every call, including at and across zero.
|
|
42
|
+
|
|
43
|
+
References:
|
|
44
|
+
Bachmann, G., Bécigneul, G., Ganea, O.-E. (2020). Constant Curvature
|
|
45
|
+
Graph Convolutional Networks. ICML. (the κ-stereographic formulation)
|
|
46
|
+
Skopek, O., Ganea, O.-E., Bécigneul, G. (2019). Mixed-curvature
|
|
47
|
+
Variational Autoencoders. ICLR.
|
|
48
|
+
Ungar, A. A. (2008). A Gyrovector Space Approach to Hyperbolic
|
|
49
|
+
Geometry. (gyro-addition / κ-trig algebra)
|
|
50
|
+
Ganea, O.-E., Bécigneul, G., Hofmann, T. (2018). Hyperbolic Neural
|
|
51
|
+
Networks. NeurIPS. (Poincaré ball special case)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import math
|
|
57
|
+
|
|
58
|
+
import torch
|
|
59
|
+
|
|
60
|
+
# Taylor coefficients of tan(sqrt(q))/sqrt(q) and atan(sqrt(q))/sqrt(q),
|
|
61
|
+
# used in place of the exact ratio when |q| is small. q = kappa * alpha² is
|
|
62
|
+
# signed, so one series covers both curvature signs.
|
|
63
|
+
KAPPA_SCALE_LINEAR = 1.0 / 3.0
|
|
64
|
+
KAPPA_TAN_QUADRATIC = 2.0 / 15.0
|
|
65
|
+
KAPPA_ATAN_QUADRATIC = 1.0 / 5.0
|
|
66
|
+
# Sampling cap for random_point, as a fraction of pi / sqrt(kappa). That is
|
|
67
|
+
# one quarter of the tan pole radius pi / (2 sqrt(kappa)), which keeps draws
|
|
68
|
+
# away from the chart's pole.
|
|
69
|
+
SPHERICAL_SAMPLE_RADIUS_FRACTION = 1.0 / 8.0
|
|
70
|
+
# asin(sqrt(q))/sqrt(q) = 1 + q/6 + 3q²/40 + O(q³). Used by
|
|
71
|
+
# _stereographic_distance when |q| is small, with q = kappa * chord² signed.
|
|
72
|
+
DISTANCE_SCALE_LINEAR = 1.0 / 6.0
|
|
73
|
+
DISTANCE_SCALE_QUADRATIC = 3.0 / 40.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _kappa_scale(
|
|
77
|
+
alpha: torch.Tensor, kappa: float | torch.Tensor, *, inverse: bool = False,
|
|
78
|
+
) -> torch.Tensor:
|
|
79
|
+
"""Return tan_κ(√|κ|·α) / (√|κ|·α), or the artan version when `inverse`.
|
|
80
|
+
|
|
81
|
+
Input: `alpha` (non-negative norms, any shape) and `kappa` (float or
|
|
82
|
+
scalar tensor). Output: same shape as `alpha`, equal to 1 at α = 0.
|
|
83
|
+
Both branches are computed and selected per element, so the sign of κ
|
|
84
|
+
is not assumed.
|
|
85
|
+
|
|
86
|
+
Near q = κα² = 0 the quadratic series replaces the exact ratio. Its
|
|
87
|
+
remainder is O(q³), so at |q| <= sqrt(eps) the value error is below
|
|
88
|
+
roundoff and the derivative error is O(eps).
|
|
89
|
+
"""
|
|
90
|
+
q = kappa * alpha.square()
|
|
91
|
+
small = q.abs() <= torch.finfo(alpha.dtype).eps**0.5
|
|
92
|
+
series_q = torch.where(small, q, torch.zeros_like(q))
|
|
93
|
+
positive = (q > 0) & ~small
|
|
94
|
+
negative = (q < 0) & ~small
|
|
95
|
+
positive_arg = torch.sqrt(torch.where(positive, q, torch.ones_like(q)))
|
|
96
|
+
# Both branches run on every element, so each needs a finite stand-in in
|
|
97
|
+
# the lanes it does not use. atanh(1) is infinite, so its stand-in is 0.5;
|
|
98
|
+
# tanh and tan are finite at 1, so they use 1.
|
|
99
|
+
substitute = torch.full_like(q, 0.5) if inverse else torch.ones_like(q)
|
|
100
|
+
negative_arg = torch.sqrt(torch.where(negative, -q, substitute.square()))
|
|
101
|
+
if inverse:
|
|
102
|
+
series = 1.0 - KAPPA_SCALE_LINEAR * series_q
|
|
103
|
+
series = series + KAPPA_ATAN_QUADRATIC * series_q.square()
|
|
104
|
+
spherical = torch.atan(positive_arg) / positive_arg
|
|
105
|
+
hyperbolic = torch.atanh(negative_arg) / negative_arg
|
|
106
|
+
else:
|
|
107
|
+
series = 1.0 + KAPPA_SCALE_LINEAR * series_q
|
|
108
|
+
series = series + KAPPA_TAN_QUADRATIC * series_q.square()
|
|
109
|
+
spherical = torch.tan(positive_arg) / positive_arg
|
|
110
|
+
hyperbolic = torch.tanh(negative_arg) / negative_arg
|
|
111
|
+
return torch.where(small, series, torch.where(positive, spherical, hyperbolic))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _project_ball(x: torch.Tensor, kappa: float | torch.Tensor) -> torch.Tensor:
|
|
115
|
+
"""Pull hyperbolic points back inside their open ball.
|
|
116
|
+
|
|
117
|
+
Input: `x` of shape `(..., n)` and `kappa`. Output: `x` unchanged where
|
|
118
|
+
κ >= 0 or the point is already inside the ball, otherwise rescaled onto
|
|
119
|
+
the boundary radius. That radius is 1/√|κ| shrunk by the factor
|
|
120
|
+
(1 - n·eps), which covers rounding in the norm. A point pushed to this
|
|
121
|
+
radius is a precision limit: exp saturates there and log cannot recover
|
|
122
|
+
a larger tangent.
|
|
123
|
+
"""
|
|
124
|
+
k = torch.as_tensor(kappa, dtype=x.dtype, device=x.device)
|
|
125
|
+
sqrt_negative = torch.sqrt(torch.where(k < 0, -k, torch.ones_like(k)))
|
|
126
|
+
norm = torch.linalg.vector_norm(x, dim=-1)
|
|
127
|
+
radius = (1.0 - x.shape[-1] * torch.finfo(x.dtype).eps) / sqrt_negative
|
|
128
|
+
# A zero vector never leaves the ball, but its rescale factor would divide
|
|
129
|
+
# by zero. Swap in 1 for the divisor; torch.where evaluates both branches.
|
|
130
|
+
safe_norm = torch.where(norm > 0, norm, torch.ones_like(norm))
|
|
131
|
+
scale = torch.where((k < 0) & (norm > radius), radius / safe_norm,
|
|
132
|
+
torch.ones_like(norm))
|
|
133
|
+
return x * scale.unsqueeze(-1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _stereographic_distance(
|
|
137
|
+
x: torch.Tensor, y: torch.Tensor, kappa: float | torch.Tensor,
|
|
138
|
+
) -> torch.Tensor:
|
|
139
|
+
"""Geodesic distance between x and y, with three branches.
|
|
140
|
+
|
|
141
|
+
The chord `‖x - y‖ / sqrt((1 + κ‖x‖²)(1 + κ‖y‖²))` feeds the series and
|
|
142
|
+
hyperbolic branches. Near κ·chord² = 0, including κ = 0, a signed series
|
|
143
|
+
is used, which keeps the result smooth in κ through zero. For κ > 0 the
|
|
144
|
+
points are lifted onto the sphere and the angle is taken with atan2, which
|
|
145
|
+
also works at antipodes where Mobius subtraction is undefined. For κ < 0
|
|
146
|
+
the asinh form is used, since an atanh argument can round to one near the
|
|
147
|
+
ball boundary.
|
|
148
|
+
|
|
149
|
+
References:
|
|
150
|
+
Bachmann et al. (2020), §2, stereographic metric; Cannon et al.
|
|
151
|
+
(1997), Hyperbolic Geometry, §3, hyperbolic distance models.
|
|
152
|
+
"""
|
|
153
|
+
k = torch.as_tensor(kappa, dtype=x.dtype, device=x.device)
|
|
154
|
+
x_sq, y_sq = x.square().sum(-1), y.square().sum(-1)
|
|
155
|
+
separation = torch.linalg.vector_norm(x - y, dim=-1)
|
|
156
|
+
product = (1.0 + k * x_sq) * (1.0 + k * y_sq)
|
|
157
|
+
chord = separation / torch.sqrt(product)
|
|
158
|
+
q = k * chord.square()
|
|
159
|
+
small = q.abs() <= torch.finfo(x.dtype).eps**0.5
|
|
160
|
+
series_q = torch.where(small, q, torch.zeros_like(q))
|
|
161
|
+
scale = 1.0 + DISTANCE_SCALE_LINEAR * series_q
|
|
162
|
+
scale = scale + DISTANCE_SCALE_QUADRATIC * series_q.square()
|
|
163
|
+
series = 2.0 * chord * scale
|
|
164
|
+
|
|
165
|
+
spherical = (k > 0) & ~small
|
|
166
|
+
root = torch.sqrt(torch.where(spherical, k, torch.ones_like(k)))
|
|
167
|
+
# Mask inputs before lifting to the sphere so inactive denominators and
|
|
168
|
+
# transcendental branches cannot inject NaNs into backward.
|
|
169
|
+
sx = torch.where(spherical.unsqueeze(-1), root.unsqueeze(-1) * x, 0.0)
|
|
170
|
+
sy = torch.where(spherical.unsqueeze(-1), root.unsqueeze(-1) * y, 0.0)
|
|
171
|
+
sx_sq, sy_sq = sx.square().sum(-1, keepdim=True), sy.square().sum(-1, keepdim=True)
|
|
172
|
+
lifted_x = torch.cat((2.0 * sx, 1.0 - sx_sq), dim=-1) / (1.0 + sx_sq)
|
|
173
|
+
lifted_y = torch.cat((2.0 * sy, 1.0 - sy_sq), dim=-1) / (1.0 + sy_sq)
|
|
174
|
+
sphere_chord = torch.linalg.vector_norm(lifted_x - lifted_y, dim=-1)
|
|
175
|
+
sphere_sum = torch.linalg.vector_norm(lifted_x + lifted_y, dim=-1)
|
|
176
|
+
# The angle between two unit vectors is 2·atan2(|a - b|, |a + b|). This form
|
|
177
|
+
# stays accurate for tiny angles and near antipodes, where acos of a dot
|
|
178
|
+
# product would lose precision.
|
|
179
|
+
sphere_distance = 2.0 * torch.atan2(sphere_chord, sphere_sum) / root
|
|
180
|
+
|
|
181
|
+
hyperbolic = (k < 0) & ~small
|
|
182
|
+
# Same stand-in idea as on the sphere: inactive lanes get root 1 and zero
|
|
183
|
+
# chord, so the asinh term is finite there and the final where() discards it.
|
|
184
|
+
negative_root = torch.sqrt(torch.where(hyperbolic, -k, torch.ones_like(k)))
|
|
185
|
+
hyperbolic_chord = torch.where(hyperbolic, chord, torch.zeros_like(chord))
|
|
186
|
+
hyperbolic_distance = (
|
|
187
|
+
2.0 * torch.asinh(negative_root * hyperbolic_chord) / negative_root
|
|
188
|
+
)
|
|
189
|
+
return torch.where(small, series, torch.where(spherical, sphere_distance,
|
|
190
|
+
hyperbolic_distance))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class KappaStereographicManifold:
|
|
194
|
+
"""Constant-curvature κ-stereographic manifold.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
n: intrinsic / ambient dimension. Points are stored as `(B, n)`.
|
|
198
|
+
kappa: sectional curvature, a Python float or learnable scalar tensor.
|
|
199
|
+
Tensor curvature is evaluated on every call, including across zero.
|
|
200
|
+
device, dtype: tensor placement and precision.
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
>>> mfd = KappaStereographicManifold(n=3, kappa=-1.0)
|
|
204
|
+
>>> x = mfd.random_point(batch_size=4)
|
|
205
|
+
>>> x.shape, mfd.is_on_manifold(x).all().item()
|
|
206
|
+
(torch.Size([4, 3]), True)
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, n: int, kappa: float | torch.Tensor = -1.0,
|
|
210
|
+
device: str | torch.device = "cpu",
|
|
211
|
+
dtype: torch.dtype = torch.float64):
|
|
212
|
+
if n <= 0:
|
|
213
|
+
raise ValueError(f"n must be > 0, got n={n}")
|
|
214
|
+
# A tensor kappa is stored as is, not copied or detached, so a learnable
|
|
215
|
+
# curvature keeps its autograd graph.
|
|
216
|
+
if isinstance(kappa, torch.Tensor):
|
|
217
|
+
if kappa.ndim != 0 or not kappa.is_floating_point():
|
|
218
|
+
raise TypeError("kappa must be a floating-point scalar tensor")
|
|
219
|
+
self.kappa: float | torch.Tensor = kappa
|
|
220
|
+
elif isinstance(kappa, (int, float)):
|
|
221
|
+
self.kappa = float(kappa)
|
|
222
|
+
else:
|
|
223
|
+
raise TypeError("kappa must be a float or scalar tensor")
|
|
224
|
+
self.n = n
|
|
225
|
+
self.device = torch.device(device)
|
|
226
|
+
self.dtype = dtype
|
|
227
|
+
|
|
228
|
+
def _get_kappa(self) -> float | torch.Tensor:
|
|
229
|
+
"""Return the current curvature without detaching its gradient."""
|
|
230
|
+
return self.kappa
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def dim(self) -> int:
|
|
234
|
+
"""Intrinsic manifold dimension `n`. Same as ambient for this
|
|
235
|
+
model; no extra dimension (unlike `LorentzManifold(n)` which
|
|
236
|
+
embeds in `R^{n+1}`).
|
|
237
|
+
"""
|
|
238
|
+
return self.n
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def ambient_dim(self) -> int:
|
|
242
|
+
"""Ambient dimension `n`. Matches `.dim` for stereographic;
|
|
243
|
+
`LorentzManifold` overrides this to `n + 1`. Used by
|
|
244
|
+
manifold-generic primitives in `holonomy_lib.hyperbolic`.
|
|
245
|
+
"""
|
|
246
|
+
return self.n
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ----------------------------------------------------------------
|
|
250
|
+
# Helpers: κ-trig functions, branch-dispatched
|
|
251
|
+
# ----------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def _tan_kappa_c(self, alpha: torch.Tensor) -> torch.Tensor:
|
|
254
|
+
"""Ratio tan_κ(√|κ|·α) / (√|κ|·α) at the current κ. Not tan_κ itself.
|
|
255
|
+
|
|
256
|
+
Input: `alpha` (non-negative norms, any shape). Output: same shape.
|
|
257
|
+
"""
|
|
258
|
+
return _kappa_scale(alpha, self.kappa)
|
|
259
|
+
|
|
260
|
+
def _atan_kappa_c(self, alpha: torch.Tensor) -> torch.Tensor:
|
|
261
|
+
"""Ratio tan_κ⁻¹(√|κ|·α) / (√|κ|·α) at the current κ. Not the artan itself.
|
|
262
|
+
|
|
263
|
+
Input: `alpha` (non-negative norms, any shape). Output: same shape.
|
|
264
|
+
"""
|
|
265
|
+
return _kappa_scale(alpha, self.kappa, inverse=True)
|
|
266
|
+
|
|
267
|
+
def _conformal_factor(self, x: torch.Tensor) -> torch.Tensor:
|
|
268
|
+
"""λ_κ(x) = 2 / (1 + κ‖x‖²). At origin = 2.
|
|
269
|
+
|
|
270
|
+
Uses `_get_kappa()` so the conformal factor's derivative w.r.t.
|
|
271
|
+
a learnable-κ tensor is captured; affects `inner`, `norm`,
|
|
272
|
+
`exp`, `log`, `parallel_transport`.
|
|
273
|
+
"""
|
|
274
|
+
norm_sq = (x * x).sum(dim=-1)
|
|
275
|
+
return 2.0 / (1.0 + self._get_kappa() * norm_sq)
|
|
276
|
+
|
|
277
|
+
# ----------------------------------------------------------------
|
|
278
|
+
# Construction
|
|
279
|
+
# ----------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def origin(self, batch_size: int = 1) -> torch.Tensor:
|
|
282
|
+
"""The origin `(0, …, 0) ∈ R^n`; common base point for
|
|
283
|
+
tangent-space embeddings. Same shape as a regular point.
|
|
284
|
+
|
|
285
|
+
References:
|
|
286
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
287
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
288
|
+
"""
|
|
289
|
+
if batch_size < 0:
|
|
290
|
+
raise ValueError(f"batch_size must be >= 0, got {batch_size}")
|
|
291
|
+
return torch.zeros(batch_size, self.n,
|
|
292
|
+
device=self.device, dtype=self.dtype)
|
|
293
|
+
|
|
294
|
+
def random_point(
|
|
295
|
+
self,
|
|
296
|
+
batch_size: int = 1,
|
|
297
|
+
generator: torch.Generator | None = None,
|
|
298
|
+
) -> torch.Tensor:
|
|
299
|
+
"""Draw Gaussian origin tangents and map them with the current κ.
|
|
300
|
+
|
|
301
|
+
Positive-curvature tangents are capped at π/(8√κ), away from
|
|
302
|
+
the stereographic chart's pole. Hyperbolic outputs saturate at
|
|
303
|
+
a dtype-dependent interior radius. Draws use the generator's
|
|
304
|
+
device before transfer to the manifold's device.
|
|
305
|
+
|
|
306
|
+
References:
|
|
307
|
+
Bachmann et al. (2020), §2, origin exponential.
|
|
308
|
+
"""
|
|
309
|
+
if batch_size < 0:
|
|
310
|
+
raise ValueError(f"batch_size must be >= 0, got {batch_size}")
|
|
311
|
+
draw_device = generator.device if generator is not None else self.device
|
|
312
|
+
# Gaussian origin tangents with std 0.5. The scale is a sampling choice.
|
|
313
|
+
v = torch.randn(batch_size, self.n, generator=generator,
|
|
314
|
+
device=draw_device, dtype=self.dtype).to(self.device) * 0.5
|
|
315
|
+
k = torch.as_tensor(self.kappa, device=v.device, dtype=v.dtype)
|
|
316
|
+
# Only kappa > 0 is capped. Elsewhere sqrt_positive is 1 and the cap is
|
|
317
|
+
# masked off by the (k > 0) test below.
|
|
318
|
+
sqrt_positive = torch.sqrt(torch.where(k > 0, k, torch.ones_like(k)))
|
|
319
|
+
cap = SPHERICAL_SAMPLE_RADIUS_FRACTION * math.pi / sqrt_positive
|
|
320
|
+
norm = torch.linalg.vector_norm(v, dim=-1, keepdim=True)
|
|
321
|
+
safe_norm = torch.where(norm > 0, norm, torch.ones_like(norm))
|
|
322
|
+
v = v * torch.where((k > 0) & (norm > cap), cap / safe_norm,
|
|
323
|
+
torch.ones_like(norm))
|
|
324
|
+
return self.exp_0(v)
|
|
325
|
+
|
|
326
|
+
def is_on_manifold(
|
|
327
|
+
self, x: torch.Tensor, atol: float = 1e-9,
|
|
328
|
+
) -> torch.Tensor:
|
|
329
|
+
"""Check finite coordinates and the hyperbolic open-ball boundary.
|
|
330
|
+
|
|
331
|
+
For κ < 0, -κ||x||² < 1 - atol; for κ >= 0 every finite chart
|
|
332
|
+
coordinate is valid. The sphere's omitted pole is at infinity.
|
|
333
|
+
|
|
334
|
+
References:
|
|
335
|
+
Bachmann et al. (2020), §2, stereographic domain.
|
|
336
|
+
"""
|
|
337
|
+
if x.ndim < 2 or x.shape[-1] != self.n:
|
|
338
|
+
raise ValueError(f"x must have shape (..., {self.n}); got {tuple(x.shape)}")
|
|
339
|
+
k = torch.as_tensor(self.kappa, device=x.device, dtype=x.dtype)
|
|
340
|
+
domain = (k >= 0) | (-k * x.square().sum(dim=-1) < 1.0 - atol)
|
|
341
|
+
return torch.isfinite(x).all(dim=-1) & torch.isfinite(k) & domain
|
|
342
|
+
|
|
343
|
+
# ----------------------------------------------------------------
|
|
344
|
+
# Möbius addition (gyro-addition)
|
|
345
|
+
# ----------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def mobius_add(
|
|
348
|
+
self, x: torch.Tensor, y: torch.Tensor,
|
|
349
|
+
) -> torch.Tensor:
|
|
350
|
+
"""Möbius (gyro-) addition `x ⊕_κ y` on the κ-stereographic
|
|
351
|
+
manifold.
|
|
352
|
+
|
|
353
|
+
Formula (Bachmann et al. 2020, eq. 4):
|
|
354
|
+
|
|
355
|
+
x ⊕_κ y = ((1 - 2κ⟨x,y⟩ - κ‖y‖²)·x + (1 + κ‖x‖²)·y)
|
|
356
|
+
/ (1 - 2κ⟨x,y⟩ + κ²‖x‖²‖y‖²)
|
|
357
|
+
|
|
358
|
+
Reduces to ordinary vector addition at κ = 0.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
x, y: `(..., n)` points on the manifold.
|
|
362
|
+
Returns:
|
|
363
|
+
`(..., n)` Möbius sum.
|
|
364
|
+
|
|
365
|
+
References:
|
|
366
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
367
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
368
|
+
"""
|
|
369
|
+
xx = (x * x).sum(dim=-1, keepdim=True)
|
|
370
|
+
yy = (y * y).sum(dim=-1, keepdim=True)
|
|
371
|
+
k = self._get_kappa()
|
|
372
|
+
total = x + y
|
|
373
|
+
total_sq = total.square().sum(dim=-1, keepdim=True)
|
|
374
|
+
x_factor = 1.0 + k * xx
|
|
375
|
+
# Forming x + y first makes y = -x give an exact zero sum, so the
|
|
376
|
+
# numerator of x ⊕ (-x) is exactly zero and not a rounding residue.
|
|
377
|
+
num = x_factor * total - k * total_sq * x
|
|
378
|
+
# For κ < 0 with both points inside the ball, the denominator is
|
|
379
|
+
# (1 + κ‖x‖²)(1 + κ‖y‖²) - κ‖x + y‖², a positive product plus a
|
|
380
|
+
# nonnegative term, so it stays positive. For κ > 0 it can reach zero at
|
|
381
|
+
# the omitted pole, where the sum is undefined.
|
|
382
|
+
denom = x_factor * (1.0 + k * yy) - k * total_sq
|
|
383
|
+
return num / denom
|
|
384
|
+
|
|
385
|
+
# ----------------------------------------------------------------
|
|
386
|
+
# Tangent operations
|
|
387
|
+
# ----------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
def projection(
|
|
390
|
+
self, x: torch.Tensor, w: torch.Tensor,
|
|
391
|
+
) -> torch.Tensor:
|
|
392
|
+
"""Tangent-space projection.
|
|
393
|
+
|
|
394
|
+
The κ-stereographic manifold is an open subset of `R^n`, so
|
|
395
|
+
the tangent space at every point is all of `R^n`. Projection
|
|
396
|
+
is the identity (returns `w` unchanged); included for API
|
|
397
|
+
parity with `LorentzManifold`, where projection enforces
|
|
398
|
+
`⟨x, v⟩_M = 0`.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
x: base point (unused; included for API parity).
|
|
402
|
+
w: ambient direction.
|
|
403
|
+
Returns:
|
|
404
|
+
`w` (no-op).
|
|
405
|
+
|
|
406
|
+
References:
|
|
407
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
408
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
409
|
+
"""
|
|
410
|
+
del x
|
|
411
|
+
return w
|
|
412
|
+
|
|
413
|
+
def egrad_to_rgrad(self, point: torch.Tensor, egrad: torch.Tensor) -> torch.Tensor:
|
|
414
|
+
"""Divide an ambient gradient by the squared conformal factor.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
point: (B, ambient_dim) base point.
|
|
418
|
+
egrad: (B, ambient_dim) Euclidean gradient.
|
|
419
|
+
Returns:
|
|
420
|
+
(B, ambient_dim) metric gradient in tangent coordinates.
|
|
421
|
+
|
|
422
|
+
References:
|
|
423
|
+
Absil, Mahony, Sepulchre (2008), §3.6; Bachmann et al. (2020), §2.
|
|
424
|
+
"""
|
|
425
|
+
return egrad / self._conformal_factor(point).square().unsqueeze(-1)
|
|
426
|
+
|
|
427
|
+
def inner(
|
|
428
|
+
self, x: torch.Tensor, u: torch.Tensor, v: torch.Tensor,
|
|
429
|
+
) -> torch.Tensor:
|
|
430
|
+
"""Riemannian inner product `λ_κ(x)² · ⟨u, v⟩_Eucl`.
|
|
431
|
+
|
|
432
|
+
The metric is conformal to Euclidean with conformal factor
|
|
433
|
+
`λ_κ(x) = 2/(1 + κ‖x‖²)`. Point-dependent.
|
|
434
|
+
|
|
435
|
+
References:
|
|
436
|
+
Bachmann et al. (2020), §3.
|
|
437
|
+
"""
|
|
438
|
+
lam = self._conformal_factor(x)
|
|
439
|
+
return (lam * lam) * (u * v).sum(dim=-1)
|
|
440
|
+
|
|
441
|
+
def norm(
|
|
442
|
+
self, x: torch.Tensor, v: torch.Tensor,
|
|
443
|
+
) -> torch.Tensor:
|
|
444
|
+
"""Riemannian norm `λ_κ(x) · ‖v‖_Eucl`.
|
|
445
|
+
|
|
446
|
+
Uses PyTorch's finite zero subgradient at v = 0.
|
|
447
|
+
|
|
448
|
+
References:
|
|
449
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
450
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
451
|
+
"""
|
|
452
|
+
lam = self._conformal_factor(x)
|
|
453
|
+
return lam * torch.linalg.vector_norm(v, dim=-1)
|
|
454
|
+
|
|
455
|
+
# ----------------------------------------------------------------
|
|
456
|
+
# Exponential / logarithmic maps, geodesic distance
|
|
457
|
+
# ----------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def exp_0(self, v: torch.Tensor) -> torch.Tensor:
|
|
460
|
+
"""Exponential map at the origin: `exp_0(v) = tan_κ(√|κ|·‖v‖)
|
|
461
|
+
· v / (√|κ|·‖v‖)`.
|
|
462
|
+
|
|
463
|
+
At κ = 0: `exp_0(v) = v` (Euclidean identity).
|
|
464
|
+
At κ < 0: maps Euclidean coords to the Poincaré ball.
|
|
465
|
+
At κ > 0: tan has a pole at ‖v‖ = π/(2√κ), so tangents beyond that
|
|
466
|
+
radius have no meaningful image; see `log_0`.
|
|
467
|
+
At κ < 0: large tangents saturate at the ball margin (see
|
|
468
|
+
`_project_ball`); log_0 cannot invert that saturation.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
v: `(..., n)` Euclidean tangent vector at the origin.
|
|
472
|
+
Returns:
|
|
473
|
+
`(..., n)` point on the manifold.
|
|
474
|
+
|
|
475
|
+
References:
|
|
476
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
477
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
478
|
+
"""
|
|
479
|
+
if v.ndim < 2 or v.shape[-1] != self.n:
|
|
480
|
+
raise ValueError(
|
|
481
|
+
f"v last dim must be n={self.n}, got {tuple(v.shape)}"
|
|
482
|
+
)
|
|
483
|
+
v_norm = torch.linalg.vector_norm(v, dim=-1)
|
|
484
|
+
# Result is tan_κ(s) · v / s with s = √|κ|·‖v‖. The scalar factor is all
|
|
485
|
+
# _tan_kappa_c computes: it returns tan_κ(s)/s, and multiplying by v
|
|
486
|
+
# gives the full formula.
|
|
487
|
+
scaling = self._tan_kappa_c(v_norm)
|
|
488
|
+
return _project_ball(scaling.unsqueeze(-1) * v, self.kappa)
|
|
489
|
+
|
|
490
|
+
def log_0(self, y: torch.Tensor) -> torch.Tensor:
|
|
491
|
+
"""Logarithmic map at the origin: `log_0(y) =
|
|
492
|
+
(1/√|κ|) · tan_κ⁻¹(√|κ|·‖y‖) · y / ‖y‖`.
|
|
493
|
+
|
|
494
|
+
At κ = 0: `log_0(y) = y` (Euclidean identity).
|
|
495
|
+
For κ > 0 this selects the principal logarithm; exp_0 is invertible
|
|
496
|
+
only for ||v|| < π/(2√κ). Hyperbolic saturation is not invertible.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
y: `(..., n)` point on the manifold.
|
|
500
|
+
Returns:
|
|
501
|
+
`(..., n)` Euclidean tangent at the origin.
|
|
502
|
+
|
|
503
|
+
References:
|
|
504
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
505
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
506
|
+
"""
|
|
507
|
+
if y.ndim < 2 or y.shape[-1] != self.n:
|
|
508
|
+
raise ValueError(
|
|
509
|
+
f"y last dim must be n={self.n}, got {tuple(y.shape)}"
|
|
510
|
+
)
|
|
511
|
+
y_norm = torch.linalg.vector_norm(y, dim=-1)
|
|
512
|
+
# Result is artan_κ(s) · y / s with s = √|κ|·‖y‖, the inverse of the
|
|
513
|
+
# exp_0 scalar. _atan_kappa_c returns artan_κ(s)/s, so multiplying by y
|
|
514
|
+
# matches the docstring's (1/√|κ|) · artan_κ(s) · y / ‖y‖.
|
|
515
|
+
scaling = self._atan_kappa_c(y_norm)
|
|
516
|
+
return scaling.unsqueeze(-1) * y
|
|
517
|
+
|
|
518
|
+
def distance(
|
|
519
|
+
self, x: torch.Tensor, y: torch.Tensor,
|
|
520
|
+
) -> torch.Tensor:
|
|
521
|
+
"""Geodesic distance `d_κ(x, y) = (2/√|κ|) · tan_κ⁻¹(√|κ|
|
|
522
|
+
· ‖(-x) ⊕_κ y‖)`.
|
|
523
|
+
|
|
524
|
+
Evaluated through sphere chords or a hyperbolic asinh chord formula
|
|
525
|
+
to cover spherical antipodes and near-boundary hyperbolic pairs.
|
|
526
|
+
The distance is nonsmooth at coincident points and spherical antipodes;
|
|
527
|
+
the implementation uses finite zero-subgradient conventions there.
|
|
528
|
+
|
|
529
|
+
At κ = 0: `d(x, y) = 2 · ‖y - x‖_Eucl` (Bachmann normalization;
|
|
530
|
+
the conformal factor at the origin is 2).
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
x, y: `(..., n)` points on the manifold.
|
|
534
|
+
Returns:
|
|
535
|
+
`(...,)` non-negative distances.
|
|
536
|
+
|
|
537
|
+
References:
|
|
538
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
539
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
540
|
+
"""
|
|
541
|
+
return _stereographic_distance(x, y, self.kappa)
|
|
542
|
+
|
|
543
|
+
def exp(
|
|
544
|
+
self, x: torch.Tensor, v: torch.Tensor,
|
|
545
|
+
) -> torch.Tensor:
|
|
546
|
+
"""Exponential map at a general base point:
|
|
547
|
+
|
|
548
|
+
exp_x(v) = x ⊕_κ (tan_κ(λ_κ(x) · √|κ| · ‖v‖/2)
|
|
549
|
+
· v / (√|κ|·‖v‖))
|
|
550
|
+
|
|
551
|
+
References:
|
|
552
|
+
Bachmann et al. (2020), eq. 5.
|
|
553
|
+
"""
|
|
554
|
+
v_norm = torch.linalg.vector_norm(v, dim=-1)
|
|
555
|
+
lam = self._conformal_factor(x)
|
|
556
|
+
# The map is tan_κ(angle) · v / (√|κ|·‖v‖) with angle = √|κ|·λ·‖v‖/2.
|
|
557
|
+
# Writing it as (λ/2) · (tan_κ(angle)/angle) · v removes the division by
|
|
558
|
+
# ‖v‖, which is 0/0 at v = 0. `arg` is λ·‖v‖/2, i.e. angle without the
|
|
559
|
+
# √|κ| factor; _tan_kappa_c applies that factor itself. `angle` only
|
|
560
|
+
# matters for κ < 0, since `root` is 1 otherwise.
|
|
561
|
+
arg = 0.5 * lam * v_norm
|
|
562
|
+
k = torch.as_tensor(self.kappa, dtype=x.dtype, device=x.device)
|
|
563
|
+
root = torch.sqrt(torch.where(k < 0, -k, torch.ones_like(k)))
|
|
564
|
+
angle = root * arg
|
|
565
|
+
# Hyperbolic lanes with a large angle take the explicit branch below.
|
|
566
|
+
# Small angles, and all spherical and Euclidean lanes, use the ordinary
|
|
567
|
+
# formula, which also gives the right derivative at v = 0.
|
|
568
|
+
scaled = (k < 0) & (angle.square() > torch.finfo(x.dtype).eps**0.5)
|
|
569
|
+
# Lanes on the explicit branch get zeros here, so the ordinary formula
|
|
570
|
+
# stays finite for them. Its output is discarded by the final where().
|
|
571
|
+
safe_arg = torch.where(scaled, torch.zeros_like(arg), arg)
|
|
572
|
+
scaling = 0.5 * lam * self._tan_kappa_c(safe_arg)
|
|
573
|
+
rhs = scaling.unsqueeze(-1) * torch.where(scaled.unsqueeze(-1), 0.0, v)
|
|
574
|
+
ordinary = self.mobius_add(x, rhs)
|
|
575
|
+
|
|
576
|
+
# For a large angle, tanh(angle) rounds to 1 and the ordinary formula
|
|
577
|
+
# loses the far end of the geodesic. The explicit branch keeps
|
|
578
|
+
# h = exp(-2·angle) instead. It splits root·x into a part along the
|
|
579
|
+
# tangent direction and a perpendicular part, evaluates the Mobius sum
|
|
580
|
+
# in those terms, and divides by root at the end.
|
|
581
|
+
safe_norm = torch.where(scaled, v_norm, torch.ones_like(v_norm))
|
|
582
|
+
unit = torch.where(scaled.unsqueeze(-1), v / safe_norm.unsqueeze(-1), 0.0)
|
|
583
|
+
base = torch.where(scaled.unsqueeze(-1), root * x, 0.0)
|
|
584
|
+
along = (base * unit).sum(dim=-1, keepdim=True)
|
|
585
|
+
perpendicular = base - along * unit
|
|
586
|
+
perpendicular_sq = perpendicular.square().sum(dim=-1, keepdim=True)
|
|
587
|
+
h = torch.exp(-2.0 * torch.where(scaled, angle, 0.0)).unsqueeze(-1)
|
|
588
|
+
plus = (1.0 + along) + (1.0 - along) * h
|
|
589
|
+
minus = (1.0 + along) - (1.0 - along) * h
|
|
590
|
+
denom = plus.square() + (1.0 - h).square() * perpendicular_sq
|
|
591
|
+
parallel_num = minus * plus - (1.0 - h.square()) * perpendicular_sq
|
|
592
|
+
perpendicular_scale = 2.0 * ((1.0 + along) + (1.0 - along) * h.square())
|
|
593
|
+
hyperbolic = (parallel_num * unit + perpendicular_scale * perpendicular) / denom
|
|
594
|
+
result = torch.where(scaled.unsqueeze(-1), hyperbolic / root, ordinary)
|
|
595
|
+
return _project_ball(result, self.kappa)
|
|
596
|
+
|
|
597
|
+
def log(
|
|
598
|
+
self, x: torch.Tensor, y: torch.Tensor,
|
|
599
|
+
) -> torch.Tensor:
|
|
600
|
+
"""Logarithmic map at a general base point:
|
|
601
|
+
|
|
602
|
+
log_x(y) = (2/(λ_κ(x)·√|κ|)) · tan_κ⁻¹(√|κ| · ‖(-x) ⊕_κ y‖)
|
|
603
|
+
· ((-x) ⊕_κ y) / ‖(-x) ⊕_κ y‖
|
|
604
|
+
|
|
605
|
+
Its magnitude comes from the stable geodesic distance, avoiding an
|
|
606
|
+
atanh argument that rounds to one near the hyperbolic boundary.
|
|
607
|
+
The coincident-point limit has derivative y-x. Spherical antipodes
|
|
608
|
+
have no unique logarithm.
|
|
609
|
+
|
|
610
|
+
References:
|
|
611
|
+
Bachmann et al. (2020), eq. 5.
|
|
612
|
+
"""
|
|
613
|
+
delta = y - x
|
|
614
|
+
delta_sq = delta.square().sum(dim=-1, keepdim=True)
|
|
615
|
+
k = self._get_kappa()
|
|
616
|
+
x_factor = 1.0 + k * x.square().sum(dim=-1, keepdim=True)
|
|
617
|
+
# The Mobius denominator is positive away from spherical antipodes, so
|
|
618
|
+
# the numerator alone gives the direction. Writing it in terms of
|
|
619
|
+
# delta = y - x avoids subtracting nearly equal quadratic terms when y
|
|
620
|
+
# is close to x.
|
|
621
|
+
direction = x_factor * delta + k * delta_sq * x
|
|
622
|
+
direction_norm = torch.linalg.vector_norm(direction, dim=-1, keepdim=True)
|
|
623
|
+
coincident = delta_sq == 0
|
|
624
|
+
safe_norm = torch.where(
|
|
625
|
+
coincident, torch.ones_like(direction_norm), direction_norm,
|
|
626
|
+
)
|
|
627
|
+
magnitude = 0.5 * x_factor * self.distance(x, y).unsqueeze(-1)
|
|
628
|
+
# At coincident points direction is exactly zero, so the value is zero
|
|
629
|
+
# whatever the scaling. The scaling is 1 / x_factor there, which makes the
|
|
630
|
+
# derivative with respect to y the identity and matches log_x(y) ≈ y - x.
|
|
631
|
+
scaling = torch.where(coincident, 1.0 / x_factor, magnitude / safe_norm)
|
|
632
|
+
return scaling * direction
|
|
633
|
+
|
|
634
|
+
def retraction(
|
|
635
|
+
self, x: torch.Tensor, v: torch.Tensor,
|
|
636
|
+
) -> torch.Tensor:
|
|
637
|
+
"""Retraction = exponential map (second-order, the geodesic
|
|
638
|
+
itself satisfies the retraction axioms on constant-curvature
|
|
639
|
+
spaces).
|
|
640
|
+
|
|
641
|
+
References:
|
|
642
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
643
|
+
Convolutional Networks, §2, stereographic metric and maps.
|
|
644
|
+
"""
|
|
645
|
+
return self.exp(x, v)
|
|
646
|
+
|
|
647
|
+
def parallel_transport(
|
|
648
|
+
self, x: torch.Tensor, y: torch.Tensor, v: torch.Tensor,
|
|
649
|
+
) -> torch.Tensor:
|
|
650
|
+
"""Parallel transport on the κ-stereographic manifold:
|
|
651
|
+
|
|
652
|
+
P_{x→y}(v) = (λ_κ(x) / λ_κ(y)) · gyr[y, -x] · v
|
|
653
|
+
|
|
654
|
+
where `gyr[a, b]·v = -(a ⊕_κ b) ⊕_κ (a ⊕_κ (b ⊕_κ v))` is the
|
|
655
|
+
Möbius gyrator. For κ = 0 this collapses to the identity
|
|
656
|
+
(Euclidean parallel transport is trivial).
|
|
657
|
+
|
|
658
|
+
References:
|
|
659
|
+
Bachmann et al. (2020), §2, κ-stereographic geometry;
|
|
660
|
+
Ungar (2008), A Gyrovector Space Approach to Hyperbolic Geometry,
|
|
661
|
+
gyration. The linear formula agrees with Geoopt stereographic.math.
|
|
662
|
+
"""
|
|
663
|
+
# v is a tangent vector, not a point in the ball. Use the closed-form
|
|
664
|
+
# gyration, which is linear in v. Nesting Mobius additions would treat v
|
|
665
|
+
# as a point and impose the ball's constraints on it.
|
|
666
|
+
a, b = y, -x
|
|
667
|
+
aa = a.square().sum(dim=-1, keepdim=True)
|
|
668
|
+
bb = b.square().sum(dim=-1, keepdim=True)
|
|
669
|
+
ab = (a * b).sum(dim=-1, keepdim=True)
|
|
670
|
+
av = (a * v).sum(dim=-1, keepdim=True)
|
|
671
|
+
bv = (b * v).sum(dim=-1, keepdim=True)
|
|
672
|
+
# Closed form of gyr[a, b] v (Ungar 2008) with a = y and b = -x, written
|
|
673
|
+
# as v + 2 (coef_a · a + coef_b · b) / denominator.
|
|
674
|
+
k = self.kappa
|
|
675
|
+
coef_a = -k * k * av * bb - k * bv + 2.0 * k * k * ab * bv
|
|
676
|
+
coef_b = -k * k * bv * aa + k * av
|
|
677
|
+
denominator = 1.0 - 2.0 * k * ab + k * k * aa * bb
|
|
678
|
+
gyrated = v + 2.0 * (coef_a * a + coef_b * b) / denominator
|
|
679
|
+
ratio = self._conformal_factor(x) / self._conformal_factor(y)
|
|
680
|
+
return ratio.unsqueeze(-1) * gyrated
|