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,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Work in progress, shipped so it can be used and improved.
|
|
5
|
+
|
|
6
|
+
These primitives are unfinished. Each module's docstring describes what is
|
|
7
|
+
known to be wrong.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from holonomy_lib.experimental.heterogeneous_kappa import HeterogeneousKappaManifold
|
|
11
|
+
from holonomy_lib.experimental.laplacian_eigenmaps import hyperbolic_laplacian_eigenmaps
|
|
12
|
+
|
|
13
|
+
__all__ = ["HeterogeneousKappaManifold", "hyperbolic_laplacian_eigenmaps"]
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Experimental per-point curvature operations in stereographic coordinates.
|
|
5
|
+
|
|
6
|
+
Status: experimental, in progress. `dissimilarity` evaluates a constant-
|
|
7
|
+
curvature distance under a combined pair curvature. It can violate the
|
|
8
|
+
triangle inequality and is not a variable-curvature geodesic metric.
|
|
9
|
+
Both endpoints must lie in the chart domain under the combined curvature.
|
|
10
|
+
`exp_0` and `log_0` apply the ordinary constant-curvature maps independently
|
|
11
|
+
at each point's supplied curvature; they do not integrate a curvature field.
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
Bachmann, Bécigneul, Ganea (2020). Constant Curvature Graph Convolutional
|
|
15
|
+
Networks, ICML, §2 (the homogeneous formulas used per point/pair).
|
|
16
|
+
The pair-curvature combination is an experimental modeling choice,
|
|
17
|
+
not a formula established by that reference.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
|
|
25
|
+
import torch
|
|
26
|
+
|
|
27
|
+
from holonomy_lib.manifolds.stereographic import (
|
|
28
|
+
SPHERICAL_SAMPLE_RADIUS_FRACTION,
|
|
29
|
+
_kappa_scale,
|
|
30
|
+
_project_ball,
|
|
31
|
+
_stereographic_distance,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Built-in combiners. Each takes two κ tensors of the same shape and
|
|
36
|
+
# returns a single pair-effective κ.
|
|
37
|
+
#
|
|
38
|
+
# The arithmetic mean is the default. It is linear in each κ, and when
|
|
39
|
+
# κ_a = κ_b it returns that κ, so the pair formula reduces to the standard
|
|
40
|
+
# one. With opposite signs the effective κ can pass through 0, where the
|
|
41
|
+
# sign dispatch in `_kappa_scale` switches to the Euclidean series.
|
|
42
|
+
#
|
|
43
|
+
# The harmonic mean also returns the shared κ when κ_a = κ_b, for either
|
|
44
|
+
# sign. It is singular when the two sum to zero, and the call raises then.
|
|
45
|
+
def _combiner_arithmetic_mean(
|
|
46
|
+
kappa_a: torch.Tensor, kappa_b: torch.Tensor,
|
|
47
|
+
) -> torch.Tensor:
|
|
48
|
+
"""`(κ_a + κ_b) / 2`; the default per-pair κ combiner."""
|
|
49
|
+
return 0.5 * (kappa_a + kappa_b)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _combiner_harmonic_mean(
|
|
53
|
+
kappa_a: torch.Tensor, kappa_b: torch.Tensor,
|
|
54
|
+
) -> torch.Tensor:
|
|
55
|
+
"""Signed harmonic mean; zero sum is singular and raises ValueError.
|
|
56
|
+
|
|
57
|
+
One zero operand with a nonzero other operand has the continuous
|
|
58
|
+
extension zero. Opposite operands, including (0, 0), have no such
|
|
59
|
+
unique extension.
|
|
60
|
+
"""
|
|
61
|
+
denominator = kappa_a + kappa_b
|
|
62
|
+
if (denominator == 0).any():
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"harmonic curvature mean is undefined when kappa_a + kappa_b = 0"
|
|
65
|
+
)
|
|
66
|
+
return 2.0 * kappa_a * (kappa_b / denominator)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_BUILTIN_COMBINERS = {
|
|
71
|
+
"arithmetic_mean": _combiner_arithmetic_mean,
|
|
72
|
+
"harmonic_mean": _combiner_harmonic_mean,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HeterogeneousKappaManifold:
|
|
77
|
+
"""κ-stereographic geometry with per-point curvature.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
n: intrinsic / ambient dimension (same; open subset of `R^n`).
|
|
81
|
+
combiner: how to combine two per-point κ's into a single pair-κ
|
|
82
|
+
for pairwise dissimilarity calculations. Either a built-in name
|
|
83
|
+
(`"arithmetic_mean"` default, `"harmonic_mean"`) or a callable
|
|
84
|
+
`(κ_a, κ_b) → κ_eff` that's commutative and well-defined for
|
|
85
|
+
the supplied curvatures (the harmonic mean excludes zero sums).
|
|
86
|
+
device, dtype: tensor placement / precision for `random_point`
|
|
87
|
+
/ `origin`.
|
|
88
|
+
|
|
89
|
+
Every method that involves curvature takes an explicit κ tensor (or a
|
|
90
|
+
pair of them). The class stores no κ. The caller owns it, which keeps
|
|
91
|
+
the manifold a pure math primitive that any parameterization (smooth
|
|
92
|
+
field, per-point residual, gated mixture) can sit on top of.
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
>>> mfd = HeterogeneousKappaManifold(n=4)
|
|
96
|
+
>>> # 10 concepts, each with its own learnable κ
|
|
97
|
+
>>> kappas = torch.nn.Parameter(torch.randn(10) * 0.5)
|
|
98
|
+
>>> x = torch.randn(10, 4) * 0.1 # coordinates
|
|
99
|
+
>>> # Pairwise distance: take coord + κ for each side
|
|
100
|
+
>>> d_01 = mfd.dissimilarity(x[0:1], kappas[0:1], x[1:2], kappas[1:2])
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
n: int,
|
|
106
|
+
combiner: str | Callable = "arithmetic_mean",
|
|
107
|
+
device: str | torch.device = "cpu",
|
|
108
|
+
dtype: torch.dtype = torch.float64,
|
|
109
|
+
):
|
|
110
|
+
if n <= 0:
|
|
111
|
+
raise ValueError(f"n must be > 0, got n={n}")
|
|
112
|
+
self.n = n
|
|
113
|
+
if isinstance(combiner, str):
|
|
114
|
+
if combiner not in _BUILTIN_COMBINERS:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"unknown combiner {combiner!r}; "
|
|
117
|
+
f"built-ins: {sorted(_BUILTIN_COMBINERS)}"
|
|
118
|
+
)
|
|
119
|
+
self.combiner = _BUILTIN_COMBINERS[combiner]
|
|
120
|
+
elif callable(combiner):
|
|
121
|
+
self.combiner = combiner
|
|
122
|
+
else:
|
|
123
|
+
raise TypeError(
|
|
124
|
+
f"combiner must be str or callable, got {type(combiner).__name__}"
|
|
125
|
+
)
|
|
126
|
+
self.device = torch.device(device)
|
|
127
|
+
self.dtype = dtype
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def dim(self) -> int:
|
|
131
|
+
return self.n
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def ambient_dim(self) -> int:
|
|
135
|
+
return self.n
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ----------------------------------------------------------------
|
|
139
|
+
# κ-trig helpers: per-point κ, dynamic sign dispatch
|
|
140
|
+
# ----------------------------------------------------------------
|
|
141
|
+
#
|
|
142
|
+
# Each helper takes alpha (a tangent-like scalar per point) and
|
|
143
|
+
# kappa (per-point curvature, same shape as alpha). Computes the
|
|
144
|
+
# branch-aware κ-trig using torch.where on sign(κ). Works
|
|
145
|
+
# uniformly for κ ∈ R, no static branch lock. Same dynamic
|
|
146
|
+
# dispatch as the sign-flip-during-training case in
|
|
147
|
+
# `KappaStereographicManifold`.
|
|
148
|
+
|
|
149
|
+
def _tan_kappa_c(
|
|
150
|
+
self, alpha: torch.Tensor, kappa: torch.Tensor,
|
|
151
|
+
) -> torch.Tensor:
|
|
152
|
+
"""`tan(√κ·α) / (√κ·α)` for κ > 0, `tanh(√|κ|·α) / (√|κ|·α)` for κ < 0,
|
|
153
|
+
and the series at zero. Smooth across κ ∈ R."""
|
|
154
|
+
return _kappa_scale(alpha, kappa)
|
|
155
|
+
|
|
156
|
+
def _atan_kappa_c(
|
|
157
|
+
self, alpha: torch.Tensor, kappa: torch.Tensor,
|
|
158
|
+
) -> torch.Tensor:
|
|
159
|
+
"""Signed-curvature inverse scale, including its derivative at zero."""
|
|
160
|
+
return _kappa_scale(alpha, kappa, inverse=True)
|
|
161
|
+
|
|
162
|
+
# ----------------------------------------------------------------
|
|
163
|
+
# Construction
|
|
164
|
+
# ----------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def origin(self, batch_size: int = 1) -> torch.Tensor:
|
|
167
|
+
"""Origin `(0, …, 0) ∈ R^n`. It is the same point in every curvature's
|
|
168
|
+
κ-stereographic chart. Curvature changes how directions behave, not
|
|
169
|
+
where the origin sits.
|
|
170
|
+
|
|
171
|
+
References:
|
|
172
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
173
|
+
Convolutional Networks, §2, homogeneous formula applied per point.
|
|
174
|
+
"""
|
|
175
|
+
if batch_size < 0:
|
|
176
|
+
raise ValueError(f"batch_size must be >= 0, got {batch_size}")
|
|
177
|
+
return torch.zeros(batch_size, self.n,
|
|
178
|
+
device=self.device, dtype=self.dtype)
|
|
179
|
+
|
|
180
|
+
def random_point(
|
|
181
|
+
self,
|
|
182
|
+
batch_size: int = 1,
|
|
183
|
+
kappa: torch.Tensor | None = None,
|
|
184
|
+
generator: torch.Generator | None = None,
|
|
185
|
+
) -> torch.Tensor:
|
|
186
|
+
"""Sample `batch_size` random points on the manifold.
|
|
187
|
+
|
|
188
|
+
For a heterogeneous-κ manifold the per-point curvature is
|
|
189
|
+
the user's modeling choice, supplied as a
|
|
190
|
+
`kappa` tensor of shape `(batch_size,)` (or `(batch_size, 1)`
|
|
191
|
+
broadcastable). If `kappa` is omitted, we default to a
|
|
192
|
+
small standard-normal κ per point; useful for tests but
|
|
193
|
+
not a substantively-motivated prior.
|
|
194
|
+
|
|
195
|
+
The standard sampling pattern: draw a Euclidean tangent
|
|
196
|
+
`v ~ N(0, σ² I)` and apply `exp_0(v, κ)`. This stays
|
|
197
|
+
inside the manifold's domain when `‖v‖` is small enough
|
|
198
|
+
(the typical regime for tangent-at-origin training init).
|
|
199
|
+
|
|
200
|
+
References:
|
|
201
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
202
|
+
Convolutional Networks, §2, homogeneous formula applied per point.
|
|
203
|
+
"""
|
|
204
|
+
if batch_size < 0:
|
|
205
|
+
raise ValueError(f"batch_size must be >= 0, got {batch_size}")
|
|
206
|
+
draw_device = generator.device if generator is not None else self.device
|
|
207
|
+
# Default κ: per-point standard normal scaled by 0.5
|
|
208
|
+
# (gives a mix of mild spherical / hyperbolic for tests).
|
|
209
|
+
if kappa is None:
|
|
210
|
+
kappa = 0.5 * torch.randn(
|
|
211
|
+
batch_size, generator=generator,
|
|
212
|
+
device=draw_device, dtype=self.dtype,
|
|
213
|
+
).to(self.device)
|
|
214
|
+
elif kappa.shape == (batch_size, 1):
|
|
215
|
+
kappa = kappa.squeeze(-1)
|
|
216
|
+
elif kappa.shape != (batch_size,):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"kappa shape {tuple(kappa.shape)} must be ({batch_size},)"
|
|
219
|
+
)
|
|
220
|
+
# Small tangent scale so spherical samples stay in-domain.
|
|
221
|
+
v = 0.5 * 0.5 * torch.randn(
|
|
222
|
+
batch_size, self.n, generator=generator,
|
|
223
|
+
device=draw_device, dtype=self.dtype,
|
|
224
|
+
).to(self.device)
|
|
225
|
+
kappa = kappa.to(device=v.device, dtype=v.dtype)
|
|
226
|
+
# For κ > 0 the sampled tangent norm is capped at 1/8 of the model
|
|
227
|
+
# sphere's diameter π/√κ, a quarter of the tan pole radius π/(2√κ).
|
|
228
|
+
# The where() keeps the root finite for κ ≤ 0, where the cap is unused.
|
|
229
|
+
positive_root = torch.sqrt(
|
|
230
|
+
torch.where(kappa > 0, kappa, torch.ones_like(kappa))
|
|
231
|
+
)
|
|
232
|
+
cap = SPHERICAL_SAMPLE_RADIUS_FRACTION * math.pi / positive_root
|
|
233
|
+
# Draws past the cap are pulled back to it, and only where κ > 0.
|
|
234
|
+
# safe_norm keeps the division finite for a zero draw.
|
|
235
|
+
norm = torch.linalg.vector_norm(v, dim=-1)
|
|
236
|
+
safe_norm = torch.where(norm > 0, norm, torch.ones_like(norm))
|
|
237
|
+
scale = torch.where((kappa > 0) & (norm > cap), cap / safe_norm,
|
|
238
|
+
torch.ones_like(norm))
|
|
239
|
+
return self.exp_0(v * scale.unsqueeze(-1), kappa)
|
|
240
|
+
|
|
241
|
+
def is_on_manifold(
|
|
242
|
+
self, x: torch.Tensor, kappa: torch.Tensor,
|
|
243
|
+
atol: float = 1e-9,
|
|
244
|
+
) -> torch.Tensor:
|
|
245
|
+
"""Check finite coordinates and -κ||x||² < 1 - atol for κ < 0.
|
|
246
|
+
|
|
247
|
+
Nonnegative curvature admits every finite stereographic coordinate.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
x: `(..., n)` coordinates.
|
|
251
|
+
kappa: `(...)` per-point curvature, matching the batch shape of `x`.
|
|
252
|
+
atol: margin kept inside the open ball for κ < 0.
|
|
253
|
+
Returns:
|
|
254
|
+
Boolean `(...)` mask, true where the point is finite and in the chart.
|
|
255
|
+
|
|
256
|
+
References:
|
|
257
|
+
Bachmann et al. (2020), §2, homogeneous chart domains.
|
|
258
|
+
"""
|
|
259
|
+
if x.ndim < 2 or x.shape[-1] != self.n:
|
|
260
|
+
raise ValueError(f"x must have shape (..., {self.n}); got {tuple(x.shape)}")
|
|
261
|
+
if kappa.shape != x.shape[:-1]:
|
|
262
|
+
raise ValueError("kappa must match the point batch shape")
|
|
263
|
+
domain = (kappa >= 0) | (-kappa * x.square().sum(dim=-1) < 1.0 - atol)
|
|
264
|
+
return torch.isfinite(x).all(dim=-1) & torch.isfinite(kappa) & domain
|
|
265
|
+
|
|
266
|
+
# ----------------------------------------------------------------
|
|
267
|
+
# Tangent-at-origin operations (most common substrate use case)
|
|
268
|
+
# ----------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def exp_0(self, v: torch.Tensor, kappa: torch.Tensor) -> torch.Tensor:
|
|
271
|
+
"""Per-point exp at the origin: `exp_0(v_i; κ_i)`.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
v: `(B, n)` Euclidean tangents at the origin (one per point).
|
|
275
|
+
kappa: `(B,)` per-point curvatures.
|
|
276
|
+
Returns:
|
|
277
|
+
`(B, n)` embedded points, each at its own curvature. For κ < 0,
|
|
278
|
+
large tangents saturate tanh, and `_project_ball` clips them just
|
|
279
|
+
inside the ball of radius about 1/√|κ|.
|
|
280
|
+
Spherical exp/log are inverses only for ||v|| < π/(2√κ).
|
|
281
|
+
|
|
282
|
+
References:
|
|
283
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
284
|
+
Convolutional Networks, §2, homogeneous formula applied per point.
|
|
285
|
+
"""
|
|
286
|
+
if v.ndim < 2 or v.shape[-1] != self.n:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"v last dim must be n={self.n}, got {tuple(v.shape)}"
|
|
289
|
+
)
|
|
290
|
+
if kappa.shape != v.shape[:-1]:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"kappa shape {tuple(kappa.shape)} must match v.shape[:-1] = "
|
|
293
|
+
f"{tuple(v.shape[:-1])}"
|
|
294
|
+
)
|
|
295
|
+
# The scale tan(√κ|v|)/(√κ|v|) multiplies v, so the image sits on v's
|
|
296
|
+
# ray at distance tan(√κ|v|)/√κ (tanh/√|κ| when κ < 0).
|
|
297
|
+
v_norm = torch.linalg.vector_norm(v, dim=-1)
|
|
298
|
+
scaling = self._tan_kappa_c(v_norm, kappa)
|
|
299
|
+
return _project_ball(scaling.unsqueeze(-1) * v, kappa)
|
|
300
|
+
|
|
301
|
+
def log_0(self, y: torch.Tensor, kappa: torch.Tensor) -> torch.Tensor:
|
|
302
|
+
"""Per-point log at the origin: `log_0(y_i; κ_i)`. Inverse of
|
|
303
|
+
`exp_0` at the same κ within its injective, unsaturated range.
|
|
304
|
+
Positive curvature uses the principal log; hyperbolic saturation
|
|
305
|
+
cannot be inverted.
|
|
306
|
+
|
|
307
|
+
References:
|
|
308
|
+
Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
|
|
309
|
+
Convolutional Networks, §2, homogeneous formula applied per point.
|
|
310
|
+
"""
|
|
311
|
+
if y.ndim < 2 or y.shape[-1] != self.n:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"y last dim must be n={self.n}, got {tuple(y.shape)}"
|
|
314
|
+
)
|
|
315
|
+
if kappa.shape != y.shape[:-1]:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"kappa shape {tuple(kappa.shape)} must match y.shape[:-1]"
|
|
318
|
+
)
|
|
319
|
+
y_norm = torch.linalg.vector_norm(y, dim=-1)
|
|
320
|
+
scaling = self._atan_kappa_c(y_norm, kappa)
|
|
321
|
+
return scaling.unsqueeze(-1) * y
|
|
322
|
+
|
|
323
|
+
# ----------------------------------------------------------------
|
|
324
|
+
# Pair operations: combine two per-point κ's
|
|
325
|
+
# ----------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def dissimilarity(
|
|
328
|
+
self,
|
|
329
|
+
x: torch.Tensor, kappa_x: torch.Tensor,
|
|
330
|
+
y: torch.Tensor, kappa_y: torch.Tensor,
|
|
331
|
+
) -> torch.Tensor:
|
|
332
|
+
"""Pair-conditioned dissimilarity under the combined curvature.
|
|
333
|
+
|
|
334
|
+
This agrees with homogeneous distance when both curvatures agree,
|
|
335
|
+
but need not obey the triangle inequality. A custom combiner must
|
|
336
|
+
be commutative for symmetry. Invalid combined domains raise ValueError.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
x, y: (B, n) coordinates.
|
|
340
|
+
kappa_x, kappa_y: (B,) endpoint curvatures.
|
|
341
|
+
Returns:
|
|
342
|
+
(B,) nonnegative pair-conditioned values, not metric distances.
|
|
343
|
+
|
|
344
|
+
References:
|
|
345
|
+
Bachmann et al. (2020), §2, constant-curvature distance only.
|
|
346
|
+
Combining endpoint curvatures is an experimental modeling choice.
|
|
347
|
+
"""
|
|
348
|
+
if x.shape != y.shape or kappa_x.shape != kappa_y.shape:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
"endpoint coordinates and curvatures must have matching shapes"
|
|
351
|
+
)
|
|
352
|
+
kappa_pair = self.combiner(kappa_x, kappa_y)
|
|
353
|
+
# Both endpoints are checked under the pair curvature, since that is the
|
|
354
|
+
# chart the distance is computed in. atol=0 means no margin: the strict
|
|
355
|
+
# inequality rejects points on the boundary itself.
|
|
356
|
+
if not (self.is_on_manifold(x, kappa_pair, atol=0.0)
|
|
357
|
+
& self.is_on_manifold(y, kappa_pair, atol=0.0)).all():
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"both endpoints must be valid under the combined curvature"
|
|
360
|
+
)
|
|
361
|
+
return _stereographic_distance(x, y, kappa_pair)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Manifold-valued Laplacian eigenmaps via Riemannian SGD.
|
|
5
|
+
|
|
6
|
+
Status: experimental, in progress. The objective is attraction-only:
|
|
7
|
+
it pulls neighbours together and admits collapsed connected components as
|
|
8
|
+
minimizers. No constraint keeps nodes apart, and the iteration can diverge.
|
|
9
|
+
A correct version needs a non-collapse constraint (the Belkin-Niyogi
|
|
10
|
+
normalization, or negative sampling as in Nickel-Kiela 2017).
|
|
11
|
+
|
|
12
|
+
The implemented energy is Σ_ij A_ij d(Y_i, Y_j)². Updates use the
|
|
13
|
+
per-node average logarithm to neighbors, followed by projection and
|
|
14
|
+
retraction. Degree normalization rescales the update direction, so its
|
|
15
|
+
scale does not grow directly with a node's degree. There is no guarantee
|
|
16
|
+
that a single learning rate is suitable for all input geometries.
|
|
17
|
+
|
|
18
|
+
Belkin and Niyogi's spectral method additionally constrains the embedding
|
|
19
|
+
to exclude constant solutions. That constraint is absent here.
|
|
20
|
+
|
|
21
|
+
References:
|
|
22
|
+
Belkin, M., Niyogi, P. (2003). Laplacian eigenmaps for
|
|
23
|
+
dimensionality reduction and data representation. Neural Comp.
|
|
24
|
+
15(6):1373–1396.
|
|
25
|
+
Nickel, M., Kiela, D. (2017). Poincaré Embeddings for Learning
|
|
26
|
+
Hierarchical Representations. NIPS.
|
|
27
|
+
Sarkar, R. (2011). Low distortion Delaunay embedding of trees in
|
|
28
|
+
hyperbolic plane. International Symposium on Graph Drawing.
|
|
29
|
+
Liu, Q., Nickel, M., Kiela, D. (2019). Hyperbolic graph neural
|
|
30
|
+
networks. NeurIPS.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import warnings
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import torch
|
|
39
|
+
|
|
40
|
+
from holonomy_lib._graph_utils import drop_self_loops
|
|
41
|
+
|
|
42
|
+
# Step cap for the sequential RSGD loop. It bounds the cost of a call and is
|
|
43
|
+
# not a convergence test.
|
|
44
|
+
EIGENMAPS_MAX_STEPS_DEFAULT = 200
|
|
45
|
+
# Exploratory step size. Nothing here shows it converges for every geometry.
|
|
46
|
+
EIGENMAPS_LR_DEFAULT = 0.05
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def hyperbolic_laplacian_eigenmaps(
|
|
50
|
+
adjacency: torch.Tensor,
|
|
51
|
+
manifold: Any,
|
|
52
|
+
max_steps: int = EIGENMAPS_MAX_STEPS_DEFAULT,
|
|
53
|
+
lr: float = EIGENMAPS_LR_DEFAULT,
|
|
54
|
+
generator: torch.Generator | None = None,
|
|
55
|
+
init: torch.Tensor | None = None,
|
|
56
|
+
) -> torch.Tensor:
|
|
57
|
+
"""Embed the nodes of `adjacency` on `manifold` by minimizing
|
|
58
|
+
|
|
59
|
+
Σ_{i,j} A_{ij} · d_M(Y_i, Y_j)²
|
|
60
|
+
|
|
61
|
+
via Riemannian SGD.
|
|
62
|
+
|
|
63
|
+
Status: experimental. This attraction-only objective is minimized by
|
|
64
|
+
collapsing each connected component to a point. It does not compute
|
|
65
|
+
constrained spectral eigenmaps.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
adjacency: `(B, N, N)` symmetric non-negative graph adjacency
|
|
69
|
+
per batch. Self-loops are dropped before computing degrees
|
|
70
|
+
or updates. Asymmetric inputs produce a warning.
|
|
71
|
+
manifold: a manifold object exposing `random_point`, `log`,
|
|
72
|
+
`projection`, `retraction`, `norm` (see e.g.
|
|
73
|
+
`LorentzManifold`). The embedding dimension is taken from
|
|
74
|
+
`manifold.ambient_dim`, falling back to `manifold.n + 1`
|
|
75
|
+
for manifolds without that property.
|
|
76
|
+
max_steps: number of sequential RSGD steps. Default 200 is a
|
|
77
|
+
work budget, not a convergence guarantee.
|
|
78
|
+
lr: Riemannian SGD step size. Default 0.05 is an exploratory
|
|
79
|
+
setting; tune for the input geometry.
|
|
80
|
+
generator: optional `torch.Generator` for reproducible init, compatible
|
|
81
|
+
with the manifold's configured sampling device.
|
|
82
|
+
init: optional `(B, N, ambient_dim)` initial embedding. If
|
|
83
|
+
provided, must lie on the manifold. Default: random points
|
|
84
|
+
via `manifold.random_point`.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
`(B, N, ambient_dim)` embedding on `manifold`. For
|
|
88
|
+
`LorentzManifold(n)`, `ambient_dim = n + 1`.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> from holonomy_lib.manifolds import LorentzManifold
|
|
92
|
+
>>> mfd = LorentzManifold(n=2)
|
|
93
|
+
>>> A = torch.eye(5).unsqueeze(0) # trivial graph, B=1, N=5
|
|
94
|
+
>>> Y = hyperbolic_laplacian_eigenmaps(A, mfd, max_steps=10)
|
|
95
|
+
>>> Y.shape
|
|
96
|
+
torch.Size([1, 5, 3])
|
|
97
|
+
|
|
98
|
+
Notes:
|
|
99
|
+
Per-step cost is `O(B · N² · D)` from the pairwise `log` call
|
|
100
|
+
(`D = ambient_dim`). Acceptable for `N ≤ a few hundred`; larger
|
|
101
|
+
graphs should chunk along the `N²` dimension or use a sparse
|
|
102
|
+
adjacency-based loss. Neither is implemented here: only the dense
|
|
103
|
+
case is handled.
|
|
104
|
+
|
|
105
|
+
The result is **not** rotation-canonicalized (the optimal
|
|
106
|
+
embedding is determined only up to isometries of the manifold);
|
|
107
|
+
caller is responsible for any canonical alignment.
|
|
108
|
+
|
|
109
|
+
References:
|
|
110
|
+
Belkin–Niyogi (2003); Nickel–Kiela (2017); Liu et al. (2019).
|
|
111
|
+
"""
|
|
112
|
+
if adjacency.ndim != 3 or adjacency.shape[-1] != adjacency.shape[-2]:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"adjacency must have shape (B, N, N), got {tuple(adjacency.shape)}"
|
|
115
|
+
)
|
|
116
|
+
if max_steps < 0:
|
|
117
|
+
raise ValueError(f"max_steps must be >= 0, got {max_steps}")
|
|
118
|
+
if lr <= 0:
|
|
119
|
+
raise ValueError(f"lr must be > 0, got {lr}")
|
|
120
|
+
|
|
121
|
+
adjacency = drop_self_loops(adjacency)
|
|
122
|
+
if not torch.allclose(adjacency, adjacency.mT):
|
|
123
|
+
warnings.warn("adjacency is not symmetric", UserWarning, stacklevel=2)
|
|
124
|
+
B, N, _ = adjacency.shape
|
|
125
|
+
# Ambient dim is manifold-specific (n+1 for Lorentz, n for
|
|
126
|
+
# κ-stereographic). Use the `ambient_dim` property when available
|
|
127
|
+
# so the primitive is model-agnostic.
|
|
128
|
+
D = getattr(manifold, "ambient_dim", None)
|
|
129
|
+
if D is None:
|
|
130
|
+
D = manifold.n + 1
|
|
131
|
+
|
|
132
|
+
if init is None:
|
|
133
|
+
# `random_point` returns (BN, D); reshape into (B, N, D).
|
|
134
|
+
embedding = manifold.random_point(
|
|
135
|
+
batch_size=B * N, generator=generator,
|
|
136
|
+
).reshape(B, N, D)
|
|
137
|
+
else:
|
|
138
|
+
if init.shape != (B, N, D):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"init must have shape ({B}, {N}, {D}), "
|
|
141
|
+
f"got {tuple(init.shape)}"
|
|
142
|
+
)
|
|
143
|
+
embedding = init.clone()
|
|
144
|
+
|
|
145
|
+
embedding = embedding.to(device=adjacency.device, dtype=adjacency.dtype)
|
|
146
|
+
|
|
147
|
+
# Average each node's logarithms over its neighbors. Dropping loops
|
|
148
|
+
# first keeps them from diluting the update through the degree.
|
|
149
|
+
# Isolated rows have a zero numerator; the floor only makes 0/0 safe.
|
|
150
|
+
degree = adjacency.sum(dim=-1, keepdim=True) # (B, N, 1)
|
|
151
|
+
degree_safe = degree.clamp(min=torch.finfo(adjacency.dtype).tiny)
|
|
152
|
+
|
|
153
|
+
for _ in range(max_steps):
|
|
154
|
+
# Pairwise log_Y_i(Y_j), broadcast `embedding` along the i and
|
|
155
|
+
# j axes. Shapes:
|
|
156
|
+
# emb_i (B, N, 1, D) → expand → (B, N, N, D)
|
|
157
|
+
# emb_j (B, 1, N, D) → expand → (B, N, N, D)
|
|
158
|
+
# Then flatten (B, N, N) to a single batch axis so `manifold.log`
|
|
159
|
+
# can be called on a leading-batch tensor.
|
|
160
|
+
emb_i = embedding.unsqueeze(2).expand(B, N, N, D)
|
|
161
|
+
emb_j = embedding.unsqueeze(1).expand(B, N, N, D)
|
|
162
|
+
log_ij = manifold.log(
|
|
163
|
+
emb_i.reshape(B * N * N, D),
|
|
164
|
+
emb_j.reshape(B * N * N, D),
|
|
165
|
+
).reshape(B, N, N, D)
|
|
166
|
+
|
|
167
|
+
# Step direction for node i: −2 Σ_j A_{ij} · log_{Y_i}(Y_j), divided by
|
|
168
|
+
# deg(i). The division makes it the *average* log-direction toward the
|
|
169
|
+
# neighbors, so its size does not grow with degree. The log maps already
|
|
170
|
+
# return tangent vectors at Y_i, so the step is taken in the tangent
|
|
171
|
+
# space directly, with no Euclidean-to-Riemannian rescaling.
|
|
172
|
+
riemannian_grad = -2.0 * (
|
|
173
|
+
adjacency.unsqueeze(-1) * log_ij
|
|
174
|
+
).sum(dim=2) / degree_safe # (B, N, D)
|
|
175
|
+
|
|
176
|
+
# Flatten (B, N) → BN so each node is an independent point (the
|
|
177
|
+
# manifold projection and retraction are point-wise), then take
|
|
178
|
+
# the step: project −lr · grad onto the tangent space and retract.
|
|
179
|
+
points = embedding.reshape(B * N, D)
|
|
180
|
+
step = manifold.projection(points, -lr * riemannian_grad.reshape(B * N, D))
|
|
181
|
+
embedding = manifold.retraction(points, step).reshape(B, N, D)
|
|
182
|
+
|
|
183
|
+
# Non-finite iterates are unusable. Include the work budget and
|
|
184
|
+
# graph size so the caller can identify the divergent call.
|
|
185
|
+
if not torch.isfinite(embedding).all():
|
|
186
|
+
max_deg = adjacency.sum(dim=-1).max().item()
|
|
187
|
+
n_nan = torch.isnan(embedding).sum().item()
|
|
188
|
+
n_inf = torch.isinf(embedding).sum().item()
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
f"hyperbolic_laplacian_eigenmaps diverged after {max_steps} "
|
|
191
|
+
f"steps: {n_nan} NaN, {n_inf} inf out of {embedding.numel()} "
|
|
192
|
+
f"output elements. Graph stats: N={N}, max degree={max_deg:.0f}. "
|
|
193
|
+
f"This usually means `lr={lr}` is too large for the graph "
|
|
194
|
+
f"structure, retry with a smaller `lr` (an order of magnitude "
|
|
195
|
+
f"smaller is usually enough). Inputs are normalized by "
|
|
196
|
+
f"per-node degree already, but extreme degree variance or "
|
|
197
|
+
f"heavy adjacency weights can still trigger overflow."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return embedding
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.hyperbolic : manifold-aware graph operations.
|
|
5
|
+
|
|
6
|
+
The primitives in this module take a manifold (e.g.
|
|
7
|
+
`LorentzManifold`) as an explicit dependency and operate on points
|
|
8
|
+
embedded in that manifold. This is the layer where graph algorithms
|
|
9
|
+
meet differential geometry: Fréchet (intrinsic) means,
|
|
10
|
+
a manifold-aware inner product (log maps at the origin),
|
|
11
|
+
and the manifold heat kernel.
|
|
12
|
+
|
|
13
|
+
Manifold is passed in by dependency injection so primitives
|
|
14
|
+
generalize to other constant-curvature manifolds
|
|
15
|
+
(`KappaStereographicManifold`, future spherical etc.) without
|
|
16
|
+
rewrites.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from holonomy_lib.hyperbolic.frechet_mean import frechet_mean
|
|
20
|
+
from holonomy_lib.hyperbolic.heat_kernel import hyperbolic_heat_kernel
|
|
21
|
+
from holonomy_lib.hyperbolic.manifold_inner import manifold_aware_inner
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"frechet_mean",
|
|
25
|
+
"hyperbolic_heat_kernel",
|
|
26
|
+
"manifold_aware_inner",
|
|
27
|
+
]
|