holonomy-lib 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. holonomy_explorer/__init__.py +1 -0
  2. holonomy_explorer/__main__.py +79 -0
  3. holonomy_explorer/batching.py +182 -0
  4. holonomy_explorer/common.py +187 -0
  5. holonomy_explorer/curvature.py +154 -0
  6. holonomy_explorer/datasets.py +346 -0
  7. holonomy_explorer/graphs.py +286 -0
  8. holonomy_explorer/server.py +172 -0
  9. holonomy_explorer/static/app.js +435 -0
  10. holonomy_explorer/static/charts.js +114 -0
  11. holonomy_explorer/static/index.html +45 -0
  12. holonomy_explorer/static/layout.js +85 -0
  13. holonomy_explorer/static/style.css +121 -0
  14. holonomy_explorer/static/view.js +195 -0
  15. holonomy_explorer/topology.py +118 -0
  16. holonomy_lib/__init__.py +9 -0
  17. holonomy_lib/_graph_utils.py +52 -0
  18. holonomy_lib/_quadrature.py +74 -0
  19. holonomy_lib/algebra/__init__.py +32 -0
  20. holonomy_lib/algebra/eigsh_sparse.py +251 -0
  21. holonomy_lib/algebra/lanczos.py +361 -0
  22. holonomy_lib/algebra/linear.py +180 -0
  23. holonomy_lib/algebra/solvers.py +547 -0
  24. holonomy_lib/convex/__init__.py +27 -0
  25. holonomy_lib/convex/least_squares.py +184 -0
  26. holonomy_lib/discrete_geometry/__init__.py +38 -0
  27. holonomy_lib/discrete_geometry/forman.py +242 -0
  28. holonomy_lib/discrete_geometry/ricci.py +855 -0
  29. holonomy_lib/experimental/__init__.py +13 -0
  30. holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
  31. holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
  32. holonomy_lib/hyperbolic/__init__.py +27 -0
  33. holonomy_lib/hyperbolic/frechet_mean.py +381 -0
  34. holonomy_lib/hyperbolic/heat_kernel.py +608 -0
  35. holonomy_lib/hyperbolic/manifold_inner.py +81 -0
  36. holonomy_lib/info_geometry/__init__.py +64 -0
  37. holonomy_lib/info_geometry/divergences.py +224 -0
  38. holonomy_lib/info_geometry/fisher.py +179 -0
  39. holonomy_lib/lie/__init__.py +54 -0
  40. holonomy_lib/lie/so3.py +385 -0
  41. holonomy_lib/lie/spherical_harmonics.py +189 -0
  42. holonomy_lib/manifolds/__init__.py +53 -0
  43. holonomy_lib/manifolds/comparison.py +343 -0
  44. holonomy_lib/manifolds/fixed_rank.py +453 -0
  45. holonomy_lib/manifolds/lorentz.py +699 -0
  46. holonomy_lib/manifolds/lorentzian.py +567 -0
  47. holonomy_lib/manifolds/product.py +425 -0
  48. holonomy_lib/manifolds/spd.py +504 -0
  49. holonomy_lib/manifolds/stereographic.py +680 -0
  50. holonomy_lib/optimization/__init__.py +42 -0
  51. holonomy_lib/optimization/base.py +65 -0
  52. holonomy_lib/optimization/sgd.py +112 -0
  53. holonomy_lib/py.typed +0 -0
  54. holonomy_lib/sheaf/__init__.py +74 -0
  55. holonomy_lib/sheaf/graph_sheaf.py +352 -0
  56. holonomy_lib/sheaf/laplacian.py +529 -0
  57. holonomy_lib/simplicial/__init__.py +55 -0
  58. holonomy_lib/simplicial/_face_lookup.py +99 -0
  59. holonomy_lib/simplicial/complex.py +168 -0
  60. holonomy_lib/simplicial/sparse_complex.py +145 -0
  61. holonomy_lib/simplicial/vietoris_rips.py +277 -0
  62. holonomy_lib/spectral/__init__.py +55 -0
  63. holonomy_lib/spectral/diffusion_map.py +169 -0
  64. holonomy_lib/spectral/effective_resistance.py +199 -0
  65. holonomy_lib/spectral/embedding.py +131 -0
  66. holonomy_lib/spectral/heat_kernel.py +236 -0
  67. holonomy_lib/spectral/laplacian.py +441 -0
  68. holonomy_lib/spectral/magnetic.py +351 -0
  69. holonomy_lib/spectral/spectral_dimension.py +104 -0
  70. holonomy_lib/tensor_calculus/__init__.py +23 -0
  71. holonomy_lib/tensor_calculus/decomposition.py +208 -0
  72. holonomy_lib/topology/__init__.py +28 -0
  73. holonomy_lib/topology/_filtration.py +147 -0
  74. holonomy_lib/topology/_reduction.py +227 -0
  75. holonomy_lib/topology/hodge.py +250 -0
  76. holonomy_lib/topology/persistent_homology.py +301 -0
  77. holonomy_lib-0.8.0.dist-info/METADATA +161 -0
  78. holonomy_lib-0.8.0.dist-info/RECORD +82 -0
  79. holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
  80. holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
  81. holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
  82. holonomy_lib-0.8.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,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
+ ]