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,42 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.optimization: Riemannian optimizers on the library's manifolds.
|
|
5
|
+
|
|
6
|
+
Currently implemented:
|
|
7
|
+
RiemannianSGD(manifold, lr) : steepest descent on a manifold.
|
|
8
|
+
|
|
9
|
+
The optimizer only calls `egrad_to_rgrad` and `retraction` on its manifold,
|
|
10
|
+
so it works with any manifold that has both (`FixedRankManifold`,
|
|
11
|
+
`SPDManifold`, `ProductManifold`). The flow is:
|
|
12
|
+
|
|
13
|
+
1. Caller computes the ambient gradient `g` of the objective.
|
|
14
|
+
2. `optimizer.step(point, g)` converts `g` using the metric at
|
|
15
|
+
`point`, scales by `-lr`, and retracts back onto the manifold.
|
|
16
|
+
3. Returns the new point. Caller drives the loop.
|
|
17
|
+
|
|
18
|
+
The functional helper `riemannian_sgd_step` exposes the same logic
|
|
19
|
+
without state, useful for tests and for integrating into custom
|
|
20
|
+
training loops.
|
|
21
|
+
|
|
22
|
+
Why no Adam (or RMSProp, AdamW, ...): adaptive step-size schemes are
|
|
23
|
+
user-side ergonomics, not part of the math of optimization on a
|
|
24
|
+
manifold. SGD uses metric gradient conversion followed by retraction.
|
|
25
|
+
Users who want Adam-style rates can compute their adaptive step in
|
|
26
|
+
ambient space and pass the rescaled gradient into `step()`.
|
|
27
|
+
|
|
28
|
+
Planned:
|
|
29
|
+
- `torch.optim.Optimizer` integration via a `ManifoldParameter`
|
|
30
|
+
wrapper so manifold params can be mixed with ambient params in a
|
|
31
|
+
normal PyTorch training loop. Out of scope for v1.
|
|
32
|
+
- Trust-region and conjugate-gradient solvers.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from holonomy_lib.optimization.base import RiemannianOptimizer
|
|
36
|
+
from holonomy_lib.optimization.sgd import RiemannianSGD, riemannian_sgd_step
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"RiemannianOptimizer",
|
|
40
|
+
"RiemannianSGD",
|
|
41
|
+
"riemannian_sgd_step",
|
|
42
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Base class for Riemannian optimizers.
|
|
5
|
+
|
|
6
|
+
Subclasses implement `step(point, ambient_grad) -> new_point`. The base
|
|
7
|
+
class holds only the manifold reference. Hyperparameters and any per-step
|
|
8
|
+
state belong to the subclass.
|
|
9
|
+
|
|
10
|
+
The "manifold" parameter must expose `egrad_to_rgrad(point, ambient)` and
|
|
11
|
+
`retraction(point, tangent)` methods with the same signatures as
|
|
12
|
+
`FixedRankManifold` and `SPDManifold`. The optimizer is otherwise
|
|
13
|
+
agnostic to which manifold it's operating on.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RiemannianOptimizer(ABC):
|
|
23
|
+
"""Abstract base for Riemannian optimizers.
|
|
24
|
+
|
|
25
|
+
An optimizer instance holds:
|
|
26
|
+
- the manifold (stored by `__init__`; provides gradient conversion and
|
|
27
|
+
retraction),
|
|
28
|
+
- hyperparameters (learning rate, momentum coefficients, etc.),
|
|
29
|
+
- per-instance state (moment buffers, step count) if stateful.
|
|
30
|
+
|
|
31
|
+
The subclass implements `step(point, ambient_grad)`, returning the
|
|
32
|
+
new point on the manifold. Caller is responsible for storing the
|
|
33
|
+
new point and reading its gradient on the next iteration.
|
|
34
|
+
|
|
35
|
+
Why this shape: the `FixedRankManifold` and `SPDManifold` methods
|
|
36
|
+
return new points rather than mutating in place, so the optimizer
|
|
37
|
+
follows the same convention. Wrapping a `torch.optim.Optimizer` around
|
|
38
|
+
this, for `loss.backward()` flows, is possible later. The explicit
|
|
39
|
+
point-in / point-out contract is the simpler primitive to build on.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, manifold: Any) -> None:
|
|
43
|
+
self.manifold = manifold
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def step(self, point: Any, ambient_grad: Any) -> Any:
|
|
47
|
+
"""Apply one optimizer step and return the new point.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
point: current point on the manifold (tensor for SPD, triple
|
|
51
|
+
of tensors for FixedRank).
|
|
52
|
+
ambient_grad: gradient of the objective with respect to the
|
|
53
|
+
ambient (embedding-space) coordinates. Same structure as
|
|
54
|
+
an ambient tangent: a single tensor for both SPD and
|
|
55
|
+
FixedRank (FixedRank tangents are stored in ambient
|
|
56
|
+
`(B, m, n)` form per Vandereycken 2013).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
new_point: the point after the step.
|
|
60
|
+
|
|
61
|
+
References:
|
|
62
|
+
Bonnabel (2013), Stochastic Gradient Descent on Riemannian
|
|
63
|
+
Manifolds, §III.
|
|
64
|
+
"""
|
|
65
|
+
...
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Riemannian steepest descent (RSGD).
|
|
5
|
+
|
|
6
|
+
For a Riemannian manifold M with `egrad_to_rgrad` (ambient gradient to
|
|
7
|
+
Riemannian gradient, metric included) and `retraction` (move along a
|
|
8
|
+
tangent vector to a new point), Riemannian SGD takes the ambient
|
|
9
|
+
gradient `g` at the current point, converts it with the metric, scales
|
|
10
|
+
by `-lr`, and retracts back onto M.
|
|
11
|
+
|
|
12
|
+
ξ = egrad_to_rgrad(point, g)
|
|
13
|
+
point' = retraction(point, -lr · ξ)
|
|
14
|
+
|
|
15
|
+
References:
|
|
16
|
+
Absil, P.-A., Mahony, R., Sepulchre, R. (2008). Optimization
|
|
17
|
+
Algorithms on Matrix Manifolds. Princeton University Press,
|
|
18
|
+
§4.1-§4.2.
|
|
19
|
+
Bonnabel, S. (2013). Stochastic gradient descent on Riemannian
|
|
20
|
+
manifolds. IEEE TAC 58(9):2217-2229.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import torch
|
|
28
|
+
|
|
29
|
+
from holonomy_lib.optimization.base import RiemannianOptimizer
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def riemannian_sgd_step(
|
|
33
|
+
manifold: Any,
|
|
34
|
+
point: Any,
|
|
35
|
+
ambient_grad: torch.Tensor,
|
|
36
|
+
lr: float,
|
|
37
|
+
) -> Any:
|
|
38
|
+
"""Functional one-step Riemannian SGD.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
manifold: object exposing `egrad_to_rgrad(point, ambient)` and
|
|
42
|
+
`retraction(point, tangent)` methods (e.g.
|
|
43
|
+
`FixedRankManifold`, `SPDManifold`).
|
|
44
|
+
point: current point on the manifold (tensor for SPD; triple
|
|
45
|
+
of tensors for FixedRank).
|
|
46
|
+
ambient_grad: ambient-space gradient of the objective. Always
|
|
47
|
+
a single `torch.Tensor`; for `FixedRankManifold` this is the
|
|
48
|
+
`(B, m, n)` ambient gradient per Vandereycken (2013)'s
|
|
49
|
+
embedded-tangent representation.
|
|
50
|
+
lr: learning rate, a positive scalar. Not checked here;
|
|
51
|
+
`RiemannianSGD` rejects lr <= 0.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
new_point: the manifold-constrained next iterate.
|
|
55
|
+
|
|
56
|
+
References:
|
|
57
|
+
Absil, P.-A., Mahony, R., Sepulchre, R. (2008). Optimization
|
|
58
|
+
Algorithms on Matrix Manifolds. Princeton University Press,
|
|
59
|
+
§4.1; retraction-based steepest descent.
|
|
60
|
+
Bonnabel, S. (2013). Stochastic gradient descent on Riemannian
|
|
61
|
+
manifolds. IEEE TAC 58(9):2217-2229, §III.
|
|
62
|
+
"""
|
|
63
|
+
# A plain tangent projection is the right conversion only when the metric
|
|
64
|
+
# is the embedded Euclidean one, as for FixedRankManifold. SPD's
|
|
65
|
+
# affine-invariant metric needs S sym(G) S, so each manifold does its own
|
|
66
|
+
# conversion through egrad_to_rgrad. The step then scales that tangent by
|
|
67
|
+
# -lr and retracts it back onto the manifold.
|
|
68
|
+
tangent = manifold.egrad_to_rgrad(point, ambient_grad)
|
|
69
|
+
return manifold.retraction(point, -lr * tangent)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RiemannianSGD(RiemannianOptimizer):
|
|
73
|
+
"""Optimizer that holds a manifold and a learning rate.
|
|
74
|
+
|
|
75
|
+
Each `step` call forwards to `riemannian_sgd_step`. The object keeps no
|
|
76
|
+
moment buffers or step count, so nothing carries over between steps.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
manifold: the underlying manifold object (see `riemannian_sgd_step`).
|
|
80
|
+
lr: learning rate, must be > 0 (ValueError otherwise). The default
|
|
81
|
+
`1e-2` is a starting point, not a tuned value; tune it per problem.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> from holonomy_lib.manifolds import SPDManifold
|
|
85
|
+
>>> mfd = SPDManifold(n=4)
|
|
86
|
+
>>> opt = RiemannianSGD(mfd, lr=0.05)
|
|
87
|
+
>>> point = mfd.random_point(batch_size=1)
|
|
88
|
+
>>> # in a training loop:
|
|
89
|
+
>>> ambient_grad = compute_ambient_grad(point)
|
|
90
|
+
>>> point = opt.step(point, ambient_grad)
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, manifold: Any, lr: float = 1e-2) -> None:
|
|
94
|
+
super().__init__(manifold)
|
|
95
|
+
if lr <= 0:
|
|
96
|
+
raise ValueError(f"lr must be > 0, got {lr}")
|
|
97
|
+
self.lr = lr
|
|
98
|
+
|
|
99
|
+
def step(self, point: Any, ambient_grad: Any) -> Any:
|
|
100
|
+
"""Convert the ambient gradient and retract one descent step.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
point: current point on the manifold.
|
|
104
|
+
ambient_grad: ambient-space gradient of the objective at `point`.
|
|
105
|
+
Returns:
|
|
106
|
+
new_point: the point after the step.
|
|
107
|
+
|
|
108
|
+
References:
|
|
109
|
+
Bonnabel (2013), Stochastic Gradient Descent on Riemannian
|
|
110
|
+
Manifolds, §III.
|
|
111
|
+
"""
|
|
112
|
+
return riemannian_sgd_step(self.manifold, point, ambient_grad, self.lr)
|
holonomy_lib/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.sheaf; cellular sheaves and sheaf Laplacians on graphs.
|
|
5
|
+
|
|
6
|
+
A **cellular sheaf** on a graph attaches a finite-dimensional vector
|
|
7
|
+
space (a "stalk") `F(σ)` to each simplex σ (node or edge) and a linear
|
|
8
|
+
**restriction map** `F_{τ ≤ σ}: F(τ) → F(σ)` for each face relation
|
|
9
|
+
τ ≤ σ. The sheaf is the data of those stalks + maps; the sheaf
|
|
10
|
+
Laplacian generalizes the graph Laplacian by capturing the
|
|
11
|
+
"disagreement" of node-stalk values once they are pushed up to each
|
|
12
|
+
incident edge stalk:
|
|
13
|
+
|
|
14
|
+
L_F = δ^T δ, δ_e(x) = F_{u ≤ e}(x_u) − F_{v ≤ e}(x_v)
|
|
15
|
+
for each edge e = (u, v).
|
|
16
|
+
|
|
17
|
+
When all stalks have dimension 1 and every restriction map is the
|
|
18
|
+
identity (i.e., the *trivial sheaf*), `L_F` reduces to the standard
|
|
19
|
+
combinatorial graph Laplacian. With higher-dimensional stalks and
|
|
20
|
+
non-trivial restriction maps it captures structure that a scalar
|
|
21
|
+
Laplacian cannot; orientation sheaves, signed cellular sheaves,
|
|
22
|
+
connection Laplacians, and the Laplacians used by Neural Sheaf
|
|
23
|
+
Diffusion (Bodnar et al. 2022).
|
|
24
|
+
|
|
25
|
+
Currently exposed:
|
|
26
|
+
GraphSheaf ; dataclass holding edges + stalks + maps
|
|
27
|
+
HeterogeneousGraphSheaf ; per-node (heterogeneous) stalk dimensions
|
|
28
|
+
sheaf_coboundary(sheaf) ; δ as a (n_e·d_e, n_v·d_v) tensor
|
|
29
|
+
sheaf_laplacian(sheaf) ; L_F = δ^T δ, (n_v·d_v, n_v·d_v), PSD
|
|
30
|
+
sheaf_coboundary_sparse(sheaf); sparse COO coboundary
|
|
31
|
+
sheaf_laplacian_sparse(sheaf); sparse COO Laplacian
|
|
32
|
+
sheaf_bottom_spectrum(sheaf, k); bottom-k spectrum with optional kernel removal
|
|
33
|
+
sheaf_dirichlet_energy(sheaf, x)
|
|
34
|
+
; `x^T L_F x`, batched-first
|
|
35
|
+
|
|
36
|
+
Restricted to **node-edge sheaves on graphs**. Higher-dimensional
|
|
37
|
+
cellular sheaves on simplicial complexes (with 2-cells / faces and
|
|
38
|
+
the corresponding ∂_1 ∘ ∂_2 = 0 chain identity) are not implemented.
|
|
39
|
+
|
|
40
|
+
References:
|
|
41
|
+
Hansen, J., Ghrist, R. (2019). Toward a spectral theory of cellular
|
|
42
|
+
sheaves. Journal of Applied and Computational Topology 3:315–358.
|
|
43
|
+
The canonical reference for sheaf Laplacians and their spectral
|
|
44
|
+
theory.
|
|
45
|
+
Bodnar, C., Di Giovanni, F., Chamberlain, B. P., Liò, P., Bronstein,
|
|
46
|
+
M. M. (2022). Neural sheaf diffusion: A topological perspective on
|
|
47
|
+
heterophily and oversmoothing in GNNs. NeurIPS 2022. Brought
|
|
48
|
+
sheaf Laplacians into mainstream geometric deep learning.
|
|
49
|
+
Curry, J. (2014). Sheaves, Cosheaves and Applications. PhD thesis,
|
|
50
|
+
University of Pennsylvania. The applied-topology textbook on
|
|
51
|
+
cellular sheaves; introduces the explicit cellular formulation
|
|
52
|
+
that all the computational work uses.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from holonomy_lib.sheaf.graph_sheaf import GraphSheaf, HeterogeneousGraphSheaf
|
|
56
|
+
from holonomy_lib.sheaf.laplacian import (
|
|
57
|
+
sheaf_bottom_spectrum,
|
|
58
|
+
sheaf_coboundary,
|
|
59
|
+
sheaf_coboundary_sparse,
|
|
60
|
+
sheaf_dirichlet_energy,
|
|
61
|
+
sheaf_laplacian,
|
|
62
|
+
sheaf_laplacian_sparse,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"GraphSheaf",
|
|
67
|
+
"HeterogeneousGraphSheaf",
|
|
68
|
+
"sheaf_bottom_spectrum",
|
|
69
|
+
"sheaf_coboundary",
|
|
70
|
+
"sheaf_coboundary_sparse",
|
|
71
|
+
"sheaf_dirichlet_energy",
|
|
72
|
+
"sheaf_laplacian",
|
|
73
|
+
"sheaf_laplacian_sparse",
|
|
74
|
+
]
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""GraphSheaf dataclass; the data of a cellular sheaf on a graph.
|
|
5
|
+
|
|
6
|
+
The sheaf attaches a `d_v`-dim stalk to each node and a `d_e`-dim
|
|
7
|
+
stalk to each edge. For every edge `e = (u, v)` it carries two
|
|
8
|
+
restriction maps:
|
|
9
|
+
|
|
10
|
+
F_left[e] : R^{d_v} → R^{d_e} (the map from node u's stalk)
|
|
11
|
+
F_right[e] : R^{d_v} → R^{d_e} (the map from node v's stalk)
|
|
12
|
+
|
|
13
|
+
The coboundary on a node-stalk vector `x ∈ R^{n_v · d_v}` reads each
|
|
14
|
+
edge's contribution as `F_left[e] · x_u − F_right[e] · x_v`. The
|
|
15
|
+
sheaf Laplacian is `δ^T δ`.
|
|
16
|
+
|
|
17
|
+
Representations:
|
|
18
|
+
- `GraphSheaf` stores uniform stalk dimensions in dense tensors.
|
|
19
|
+
`HeterogeneousGraphSheaf` stores per-cell dimensions and ragged maps.
|
|
20
|
+
- Undirected graphs: edges are unordered, but the (u, v) order in each
|
|
21
|
+
tuple sets the sign convention of `δ`. Flipping an edge to (v, u)
|
|
22
|
+
and swapping its left and right maps gives the same sheaf, with that
|
|
23
|
+
edge's block of `δ` negated. The Laplacian `L_F = δ^T δ` does not
|
|
24
|
+
change, since it is quadratic in `δ`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import torch
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class GraphSheaf:
|
|
36
|
+
"""A cellular sheaf on a graph.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
n_nodes: number of nodes (each carries a d_v-dim stalk).
|
|
40
|
+
edges: `(n_edges, 2)` int64 tensor of `(u, v)` pairs.
|
|
41
|
+
node_stalk_dim: `d_v`.
|
|
42
|
+
edge_stalk_dim: `d_e`.
|
|
43
|
+
F_left: `(n_edges, d_e, d_v)` restriction maps for the
|
|
44
|
+
`u`-side of each edge.
|
|
45
|
+
F_right: `(n_edges, d_e, d_v)` restriction maps for the
|
|
46
|
+
`v`-side of each edge.
|
|
47
|
+
|
|
48
|
+
Validation:
|
|
49
|
+
- `edges` must be `(n_edges, 2)` int64.
|
|
50
|
+
- `F_left`, `F_right` shapes match `(n_edges, d_e, d_v)`.
|
|
51
|
+
- All tensors share `device`; restriction maps share a real float dtype.
|
|
52
|
+
|
|
53
|
+
References:
|
|
54
|
+
Hansen, J., Ghrist, R. (2019). Toward a spectral theory of cellular
|
|
55
|
+
sheaves. Journal of Applied and Computational Topology 3:315–358, §3.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
n_nodes: int
|
|
59
|
+
edges: torch.Tensor
|
|
60
|
+
node_stalk_dim: int
|
|
61
|
+
edge_stalk_dim: int
|
|
62
|
+
F_left: torch.Tensor
|
|
63
|
+
F_right: torch.Tensor
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if not isinstance(self.n_nodes, int) or isinstance(self.n_nodes, bool):
|
|
67
|
+
raise ValueError("n_nodes must be an integer")
|
|
68
|
+
if self.n_nodes <= 0:
|
|
69
|
+
raise ValueError(f"n_nodes must be > 0; got {self.n_nodes}")
|
|
70
|
+
if (not isinstance(self.node_stalk_dim, int)
|
|
71
|
+
or isinstance(self.node_stalk_dim, bool)):
|
|
72
|
+
raise ValueError("node_stalk_dim must be an integer")
|
|
73
|
+
if (not isinstance(self.edge_stalk_dim, int)
|
|
74
|
+
or isinstance(self.edge_stalk_dim, bool)):
|
|
75
|
+
raise ValueError("edge_stalk_dim must be an integer")
|
|
76
|
+
if self.node_stalk_dim <= 0:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"node_stalk_dim must be > 0; got {self.node_stalk_dim}"
|
|
79
|
+
)
|
|
80
|
+
if self.edge_stalk_dim <= 0:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"edge_stalk_dim must be > 0; got {self.edge_stalk_dim}"
|
|
83
|
+
)
|
|
84
|
+
if self.edges.ndim != 2 or self.edges.shape[-1] != 2:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"edges must be (n_edges, 2); got "
|
|
87
|
+
f"shape={tuple(self.edges.shape)}"
|
|
88
|
+
)
|
|
89
|
+
n_e = self.edges.shape[0]
|
|
90
|
+
d_e, d_v = self.edge_stalk_dim, self.node_stalk_dim
|
|
91
|
+
for name, t in (("F_left", self.F_left), ("F_right", self.F_right)):
|
|
92
|
+
if t.shape != (n_e, d_e, d_v):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"{name} must have shape (n_edges={n_e}, "
|
|
95
|
+
f"edge_stalk_dim={d_e}, node_stalk_dim={d_v}); got "
|
|
96
|
+
f"shape={tuple(t.shape)}"
|
|
97
|
+
)
|
|
98
|
+
if self.edges.dtype != torch.int64:
|
|
99
|
+
raise ValueError("edges must have dtype int64")
|
|
100
|
+
if not self.F_left.is_floating_point() or not self.F_right.is_floating_point():
|
|
101
|
+
raise ValueError("restriction maps must have a real floating dtype")
|
|
102
|
+
if self.edges.device != self.F_left.device:
|
|
103
|
+
raise ValueError("edges and restriction maps must share device")
|
|
104
|
+
if self.F_left.device != self.F_right.device:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"F_left and F_right must be on the same device; "
|
|
107
|
+
f"got {self.F_left.device} vs {self.F_right.device}"
|
|
108
|
+
)
|
|
109
|
+
if self.F_left.dtype != self.F_right.dtype:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"F_left and F_right must share dtype; got "
|
|
112
|
+
f"{self.F_left.dtype} vs {self.F_right.dtype}"
|
|
113
|
+
)
|
|
114
|
+
# Negative indices would wrap around silently in torch indexing, so
|
|
115
|
+
# both ends of the range are checked here.
|
|
116
|
+
if (self.edges < 0).any() or (self.edges >= self.n_nodes).any():
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"edges entries must be in [0, n_nodes={self.n_nodes})"
|
|
119
|
+
)
|
|
120
|
+
# A self-loop adds a diagonal term (F_left - F_right)^T (F_left - F_right)
|
|
121
|
+
# to the sheaf Laplacian. The library drops self-loops from graphs
|
|
122
|
+
# (CONVENTIONS.md, via `_graph_utils.drop_self_loops`), so the sheaf
|
|
123
|
+
# code refuses them too. To proceed, drop them first:
|
|
124
|
+
# `mask = u != v; edges = edges[mask]; F_left = F_left[mask];
|
|
125
|
+
# F_right = F_right[mask]`.
|
|
126
|
+
if (self.edges[:, 0] == self.edges[:, 1]).any():
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"self-loops (u == v in edges) are not supported in v1; "
|
|
129
|
+
"drop them before constructing the GraphSheaf"
|
|
130
|
+
)
|
|
131
|
+
# Duplicate unordered endpoint pairs would silently double the
|
|
132
|
+
# off-diagonal mass of the sheaf Laplacian, breaking the
|
|
133
|
+
# "trivial sheaf → graph Laplacian" reduction. Reject them.
|
|
134
|
+
unique_edges = torch.unique(self.edges.sort(dim=-1).values, dim=0)
|
|
135
|
+
if unique_edges.shape[0] != self.edges.shape[0]:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"duplicate edges are not supported in v1; "
|
|
138
|
+
"the resulting sheaf Laplacian would double-count "
|
|
139
|
+
"the duplicated pair's off-diagonal block"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def n_edges(self) -> int:
|
|
144
|
+
return int(self.edges.shape[0])
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def total_node_dim(self) -> int:
|
|
148
|
+
"""Length of the stacked node-stalk vector, `n_nodes · d_v`."""
|
|
149
|
+
return self.n_nodes * self.node_stalk_dim
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def device(self) -> torch.device:
|
|
153
|
+
return self.F_left.device
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def dtype(self) -> torch.dtype:
|
|
157
|
+
return self.F_left.dtype
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def trivial(
|
|
162
|
+
n_nodes: int,
|
|
163
|
+
edges: torch.Tensor,
|
|
164
|
+
stalk_dim: int = 1,
|
|
165
|
+
device: str | torch.device | None = None,
|
|
166
|
+
dtype: torch.dtype = torch.float64,
|
|
167
|
+
) -> GraphSheaf:
|
|
168
|
+
"""Build the trivial sheaf: identity restriction maps on every edge.
|
|
169
|
+
|
|
170
|
+
With `stalk_dim = 1` this is the sheaf whose Laplacian equals the
|
|
171
|
+
standard combinatorial graph Laplacian. With `stalk_dim = d > 1`,
|
|
172
|
+
node-major ordering gives `L ⊗ I_d`, which is permutation-similar to
|
|
173
|
+
a block diagonal matrix of `d` copies of L.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
n_nodes: number of nodes.
|
|
177
|
+
edges: `(n_edges, 2)` int64 `(u, v)` pairs.
|
|
178
|
+
stalk_dim: stalk dimension `d` on every node and edge; must be > 0.
|
|
179
|
+
device: target device. Defaults to the device of `edges`, and an
|
|
180
|
+
explicit value moves both the indices and the maps there.
|
|
181
|
+
dtype: float dtype of the restriction maps.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A `GraphSheaf` with `node_stalk_dim == edge_stalk_dim == stalk_dim`.
|
|
185
|
+
|
|
186
|
+
References:
|
|
187
|
+
Hansen-Ghrist (2019), §3.1, constant sheaves and graph Laplacians.
|
|
188
|
+
"""
|
|
189
|
+
if edges.ndim != 2 or edges.shape[-1] != 2:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"edges must be (n_edges, 2); got shape={tuple(edges.shape)}"
|
|
192
|
+
)
|
|
193
|
+
if edges.dtype != torch.int64:
|
|
194
|
+
raise ValueError("edges must have dtype int64")
|
|
195
|
+
if not isinstance(stalk_dim, int) or isinstance(stalk_dim, bool):
|
|
196
|
+
raise ValueError("stalk_dim must be an integer")
|
|
197
|
+
if stalk_dim <= 0:
|
|
198
|
+
raise ValueError("stalk_dim must be > 0")
|
|
199
|
+
if not dtype.is_floating_point:
|
|
200
|
+
raise ValueError("restriction maps must have a real floating dtype")
|
|
201
|
+
n_e = edges.shape[0]
|
|
202
|
+
device = edges.device if device is None else device
|
|
203
|
+
eye = torch.eye(stalk_dim, dtype=dtype, device=device)
|
|
204
|
+
F = eye.unsqueeze(dim=0).expand(n_e, stalk_dim, stalk_dim).contiguous()
|
|
205
|
+
edges = edges.to(device=device, dtype=torch.int64)
|
|
206
|
+
# F_left and F_right get separate copies, so writing into one of them
|
|
207
|
+
# does not change the other.
|
|
208
|
+
return GraphSheaf(
|
|
209
|
+
n_nodes=n_nodes,
|
|
210
|
+
edges=edges,
|
|
211
|
+
node_stalk_dim=stalk_dim,
|
|
212
|
+
edge_stalk_dim=stalk_dim,
|
|
213
|
+
F_left=F.clone(),
|
|
214
|
+
F_right=F.clone(),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True)
|
|
219
|
+
class HeterogeneousGraphSheaf:
|
|
220
|
+
"""A cellular sheaf with PER-NODE (heterogeneous) stalk dimensions.
|
|
221
|
+
|
|
222
|
+
The generalization of `GraphSheaf` to per-node stalk dimensions: node `i`
|
|
223
|
+
carries a `d_v[i]`-dim stalk and edge `e` a `d_e[e]`-dim stalk, all free
|
|
224
|
+
to differ. For edge `e = (u, v)`:
|
|
225
|
+
|
|
226
|
+
F_left[e] : R^{d_v[u]} -> R^{d_e[e]} shape (d_e[e], d_v[u])
|
|
227
|
+
F_right[e] : R^{d_v[v]} -> R^{d_e[e]} shape (d_e[e], d_v[v])
|
|
228
|
+
|
|
229
|
+
Coboundary, Laplacian, and Dirichlet energy mean exactly what they do in the
|
|
230
|
+
uniform case (each edge block of `δ` is `F_left[e]·x_u − F_right[e]·x_v`,
|
|
231
|
+
`L_F = δ^T δ`); only the per-node/per-edge dims and the ragged restriction
|
|
232
|
+
maps differ. The maps are stored as a tuple of tensors (ragged), since the
|
|
233
|
+
dims vary, rather than one dense `(n_e, d_e, d_v)` block.
|
|
234
|
+
|
|
235
|
+
Use this when a node's stalk dimension is a quantity of its own, such as
|
|
236
|
+
each concept's relational rank, rather than one global K.
|
|
237
|
+
|
|
238
|
+
Attributes:
|
|
239
|
+
n_nodes: number of nodes.
|
|
240
|
+
edges: `(n_edges, 2)` int64 `(u, v)` pairs.
|
|
241
|
+
node_stalk_dims: `(n_nodes,)` int64, per-node stalk dim `d_v[i] > 0`.
|
|
242
|
+
edge_stalk_dims: `(n_edges,)` int64, per-edge stalk dim `d_e[e] > 0`.
|
|
243
|
+
F_left: length-`n_edges` tuple; `F_left[e]` is `(d_e[e], d_v[u])`.
|
|
244
|
+
F_right: length-`n_edges` tuple; `F_right[e]` is `(d_e[e], d_v[v])`.
|
|
245
|
+
|
|
246
|
+
References:
|
|
247
|
+
Hansen-Ghrist (2019): cellular sheaves carry per-cell stalks of arbitrary
|
|
248
|
+
finite dimension; the uniform `GraphSheaf` is the special case.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
n_nodes: int
|
|
252
|
+
edges: torch.Tensor
|
|
253
|
+
node_stalk_dims: torch.Tensor
|
|
254
|
+
edge_stalk_dims: torch.Tensor
|
|
255
|
+
F_left: tuple[torch.Tensor, ...]
|
|
256
|
+
F_right: tuple[torch.Tensor, ...]
|
|
257
|
+
|
|
258
|
+
def __post_init__(self) -> None:
|
|
259
|
+
if not isinstance(self.n_nodes, int) or isinstance(self.n_nodes, bool):
|
|
260
|
+
raise ValueError("n_nodes must be an integer")
|
|
261
|
+
if self.n_nodes <= 0:
|
|
262
|
+
raise ValueError(f"n_nodes must be > 0; got {self.n_nodes}")
|
|
263
|
+
if self.edges.ndim != 2 or self.edges.shape[-1] != 2:
|
|
264
|
+
raise ValueError(
|
|
265
|
+
f"edges must be (n_edges, 2); got shape={tuple(self.edges.shape)}"
|
|
266
|
+
)
|
|
267
|
+
n_e = int(self.edges.shape[0])
|
|
268
|
+
if tuple(self.node_stalk_dims.shape) != (self.n_nodes,):
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"node_stalk_dims must be (n_nodes={self.n_nodes},); got "
|
|
271
|
+
f"shape={tuple(self.node_stalk_dims.shape)}"
|
|
272
|
+
)
|
|
273
|
+
if tuple(self.edge_stalk_dims.shape) != (n_e,):
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"edge_stalk_dims must be (n_edges={n_e},); got "
|
|
276
|
+
f"shape={tuple(self.edge_stalk_dims.shape)}"
|
|
277
|
+
)
|
|
278
|
+
for name, tensor in (("edges", self.edges),
|
|
279
|
+
("node_stalk_dims", self.node_stalk_dims),
|
|
280
|
+
("edge_stalk_dims", self.edge_stalk_dims)):
|
|
281
|
+
if tensor.dtype != torch.int64:
|
|
282
|
+
raise ValueError(f"{name} must have dtype int64")
|
|
283
|
+
if (self.node_stalk_dims <= 0).any() or (self.edge_stalk_dims <= 0).any():
|
|
284
|
+
raise ValueError("all stalk dims must be > 0")
|
|
285
|
+
if len(self.F_left) != n_e or len(self.F_right) != n_e:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"F_left and F_right must each have n_edges={n_e} maps; got "
|
|
288
|
+
f"{len(self.F_left)} and {len(self.F_right)}"
|
|
289
|
+
)
|
|
290
|
+
if (self.edges < 0).any() or (self.edges >= self.n_nodes).any():
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"edges entries must be in [0, n_nodes={self.n_nodes})"
|
|
293
|
+
)
|
|
294
|
+
if (self.edges[:, 0] == self.edges[:, 1]).any():
|
|
295
|
+
raise ValueError(
|
|
296
|
+
"self-loops (u == v in edges) are not supported; drop them first"
|
|
297
|
+
)
|
|
298
|
+
if torch.unique(self.edges.sort(dim=-1).values, dim=0).shape[0] != n_e:
|
|
299
|
+
raise ValueError("duplicate edges are not supported")
|
|
300
|
+
# Each F_left[e] must be (d_e[e], d_v[u]) and each F_right[e] must be
|
|
301
|
+
# (d_e[e], d_v[v]). The shapes change from edge to edge, so this is
|
|
302
|
+
# checked one edge at a time. The sizes and endpoints are pulled into
|
|
303
|
+
# Python lists once, so the loop never reads the device tensors.
|
|
304
|
+
node_dims = self.node_stalk_dims.tolist()
|
|
305
|
+
edge_dims = self.edge_stalk_dims.tolist()
|
|
306
|
+
uv = self.edges.tolist()
|
|
307
|
+
for e in range(n_e):
|
|
308
|
+
u, v = uv[e]
|
|
309
|
+
want_left = (edge_dims[e], node_dims[u])
|
|
310
|
+
want_right = (edge_dims[e], node_dims[v])
|
|
311
|
+
if tuple(self.F_left[e].shape) != want_left:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"F_left[{e}] must be (d_e[e], d_v[u])={want_left}; got "
|
|
314
|
+
f"{tuple(self.F_left[e].shape)}"
|
|
315
|
+
)
|
|
316
|
+
if tuple(self.F_right[e].shape) != want_right:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"F_right[{e}] must be (d_e[e], d_v[v])={want_right}; got "
|
|
319
|
+
f"{tuple(self.F_right[e].shape)}"
|
|
320
|
+
)
|
|
321
|
+
# With no edges there are no maps to compare, so the device and dtype
|
|
322
|
+
# check only runs when at least one edge exists.
|
|
323
|
+
if n_e > 0:
|
|
324
|
+
dev, dt = self.F_left[0].device, self.F_left[0].dtype
|
|
325
|
+
for t in (*self.F_left, *self.F_right):
|
|
326
|
+
if not t.is_floating_point():
|
|
327
|
+
raise ValueError("restriction maps must have a real floating dtype")
|
|
328
|
+
if t.device != dev or t.dtype != dt:
|
|
329
|
+
raise ValueError(
|
|
330
|
+
"all restriction maps must share device and dtype"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def n_edges(self) -> int:
|
|
335
|
+
return int(self.edges.shape[0])
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def total_node_dim(self) -> int:
|
|
339
|
+
"""Length of the stacked node-stalk vector, `Σ_i d_v[i]`."""
|
|
340
|
+
return int(self.node_stalk_dims.sum())
|
|
341
|
+
|
|
342
|
+
# With no edges there is no map to read the device or dtype from. The
|
|
343
|
+
# device falls back to the stalk-dim tensor, and the dtype to float64,
|
|
344
|
+
# the default that `trivial` uses.
|
|
345
|
+
@property
|
|
346
|
+
def device(self) -> torch.device:
|
|
347
|
+
return (self.F_left[0].device if self.n_edges
|
|
348
|
+
else self.node_stalk_dims.device)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def dtype(self) -> torch.dtype:
|
|
352
|
+
return self.F_left[0].dtype if self.n_edges else torch.float64
|