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,168 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""DenseSimplicialComplex : batched simplicial complex with padded
|
|
5
|
+
dense simplex tables.
|
|
6
|
+
|
|
7
|
+
Used by Hodge Laplacians on small complexes (where batched dense
|
|
8
|
+
eigh is fast) and by tutorials / illustration code. For large
|
|
9
|
+
single-instance complexes (persistent homology), use
|
|
10
|
+
`SparseSimplicialComplex`.
|
|
11
|
+
|
|
12
|
+
Layout:
|
|
13
|
+
`simplices_by_dim[k]` is an `(B, n_k_max, k+1)` int64 tensor.
|
|
14
|
+
Each row holds a sorted tuple of vertex indices. Padding rows
|
|
15
|
+
(beyond the valid count for that batch element) are filled with
|
|
16
|
+
`-1` and masked off via `valid_mask[k]: (B, n_k_max) bool`.
|
|
17
|
+
|
|
18
|
+
This shape makes boundary operators naturally batched: one
|
|
19
|
+
`(B, n_{k-1}_max, n_k_max)` matrix per dim. Padding rows and columns
|
|
20
|
+
stay zero because only valid simplices write entries into it.
|
|
21
|
+
|
|
22
|
+
References:
|
|
23
|
+
Munkres, J. R. (1984). Elements of Algebraic Topology. Westview
|
|
24
|
+
Press. §1 : simplicial complexes and chain complexes.
|
|
25
|
+
Kolda, T. G., Bader, B. W. (2009). Tensor decompositions and
|
|
26
|
+
applications. SIAM Review 51(3):455-500. (Tensor-layout
|
|
27
|
+
conventions reused here.)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
|
|
35
|
+
import torch
|
|
36
|
+
|
|
37
|
+
from holonomy_lib.simplicial._face_lookup import enumerate_faces, lookup_rows
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from holonomy_lib.simplicial.sparse_complex import SparseSimplicialComplex
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class DenseSimplicialComplex:
|
|
45
|
+
"""Batched, padded simplicial complex.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
simplices_by_dim: `dict[k, Tensor (B, n_k_max, k+1)]` of sorted
|
|
49
|
+
vertex tuples. Padding rows are -1.
|
|
50
|
+
valid_mask: `dict[k, Tensor (B, n_k_max) bool]` : True for real
|
|
51
|
+
simplices, False for padding.
|
|
52
|
+
n_vertices: vertex count (identical across batch elements).
|
|
53
|
+
device, dtype: tensor placement + float dtype used for boundary
|
|
54
|
+
operator returns.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
simplices_by_dim: dict[int, torch.Tensor]
|
|
58
|
+
valid_mask: dict[int, torch.Tensor]
|
|
59
|
+
n_vertices: int
|
|
60
|
+
device: torch.device = field(default=torch.device("cpu"))
|
|
61
|
+
dtype: torch.dtype = field(default=torch.float64)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def batch_size(self) -> int:
|
|
65
|
+
"""Leading batch dim. All dims must agree on this."""
|
|
66
|
+
for tensor in self.simplices_by_dim.values():
|
|
67
|
+
return tensor.shape[0]
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def max_dim(self) -> int:
|
|
72
|
+
"""Highest dimension with a table, including an empty table."""
|
|
73
|
+
return max(self.simplices_by_dim) if self.simplices_by_dim else -1
|
|
74
|
+
|
|
75
|
+
def n_simplices(self, k: int) -> torch.Tensor:
|
|
76
|
+
"""Per-batch count of valid k-simplices: shape `(B,)`."""
|
|
77
|
+
if k not in self.simplices_by_dim:
|
|
78
|
+
return torch.zeros(self.batch_size, dtype=torch.int64,
|
|
79
|
+
device=self.device)
|
|
80
|
+
return self.valid_mask[k].sum(dim=-1)
|
|
81
|
+
|
|
82
|
+
def boundary(self, k: int) -> torch.Tensor:
|
|
83
|
+
"""Boundary operator `∂_k: C_k → C_{k-1}`.
|
|
84
|
+
|
|
85
|
+
Returns a dense `(B, n_{k-1}_max, n_k_max)` matrix; entries
|
|
86
|
+
`± 1` mark face → simplex incidences with the Koszul sign
|
|
87
|
+
`(-1)^i` (i = dropped-vertex position). Padding rows/columns
|
|
88
|
+
are zero.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
k: simplex dimension. For k=0 the result is a `(B, 0, n_0_max)`
|
|
92
|
+
zero tensor, since vertices have an empty boundary in the chain
|
|
93
|
+
complex.
|
|
94
|
+
|
|
95
|
+
References:
|
|
96
|
+
Munkres, J. R. (1984). Elements of Algebraic Topology, §1.
|
|
97
|
+
|
|
98
|
+
Notes:
|
|
99
|
+
Assumes the complex is closed under faces. A face missing from
|
|
100
|
+
the (k-1) table raises ValueError from the lookup.
|
|
101
|
+
"""
|
|
102
|
+
if k == 0:
|
|
103
|
+
# ∂_0 maps to the (-1)-chain group; conventionally zero.
|
|
104
|
+
# Return a (B, 0, n_0_max) zero tensor so downstream
|
|
105
|
+
# arithmetic (Hodge L_0 = ∂_0^T ∂_0 + ∂_1 ∂_1^T) reduces
|
|
106
|
+
# cleanly to ∂_1 ∂_1^T.
|
|
107
|
+
B = self.batch_size
|
|
108
|
+
n_0_max = (
|
|
109
|
+
self.simplices_by_dim[0].shape[1]
|
|
110
|
+
if 0 in self.simplices_by_dim else 0
|
|
111
|
+
)
|
|
112
|
+
return torch.zeros(B, 0, n_0_max,
|
|
113
|
+
device=self.device, dtype=self.dtype)
|
|
114
|
+
if k not in self.simplices_by_dim or k - 1 not in self.simplices_by_dim:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"boundary(k={k}): complex lacks simplices at dim {k} "
|
|
117
|
+
f"or {k - 1}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
B = self.batch_size
|
|
121
|
+
k_simplices = self.simplices_by_dim[k] # (B, n_k_max, k+1)
|
|
122
|
+
prev_simplices = self.simplices_by_dim[k - 1] # (B, n_{k-1}_max, k)
|
|
123
|
+
n_k_max = k_simplices.shape[1]
|
|
124
|
+
n_prev_max = prev_simplices.shape[1]
|
|
125
|
+
k_valid = self.valid_mask[k] # (B, n_k_max)
|
|
126
|
+
prev_valid = self.valid_mask[k - 1]
|
|
127
|
+
|
|
128
|
+
D = torch.zeros(B, n_prev_max, n_k_max,
|
|
129
|
+
device=self.device, dtype=self.dtype)
|
|
130
|
+
|
|
131
|
+
# Keys are (batch id, vertices), so a face in another batch member
|
|
132
|
+
# cannot stand in for a missing face here. lookup_rows indexes only
|
|
133
|
+
# the valid rows of the previous dimension, so prev_row maps that
|
|
134
|
+
# index back to the padded row position.
|
|
135
|
+
simplex_batch, simplex_col = torch.where(k_valid) # padded columns
|
|
136
|
+
prev_batch, prev_row = torch.where(prev_valid)
|
|
137
|
+
faces, signs = enumerate_faces(k_simplices[k_valid])
|
|
138
|
+
face_keys = torch.cat((
|
|
139
|
+
simplex_batch[:, None, None].expand(-1, k + 1, 1), faces,
|
|
140
|
+
), dim=-1).reshape(-1, k + 1)
|
|
141
|
+
prev_keys = torch.cat((prev_batch[:, None], prev_simplices[prev_valid]),
|
|
142
|
+
dim=-1)
|
|
143
|
+
face_rows = prev_row[lookup_rows(prev_keys, face_keys)]
|
|
144
|
+
# One entry per (simplex, face) pair, flattened simplex-major and
|
|
145
|
+
# face-minor. repeat_interleave builds the index lists in that order,
|
|
146
|
+
# and repeat builds the signs in the same order.
|
|
147
|
+
D[simplex_batch.repeat_interleave(k + 1), face_rows,
|
|
148
|
+
simplex_col.repeat_interleave(k + 1)] = signs.to(self.dtype).repeat(
|
|
149
|
+
simplex_col.shape[0],
|
|
150
|
+
)
|
|
151
|
+
return D
|
|
152
|
+
|
|
153
|
+
def to_sparse(self) -> SparseSimplicialComplex:
|
|
154
|
+
"""Convert to a single-instance sparse complex. Requires `B = 1`."""
|
|
155
|
+
from holonomy_lib.simplicial.sparse_complex import SparseSimplicialComplex
|
|
156
|
+
if self.batch_size != 1:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"to_sparse() requires batch_size == 1, got {self.batch_size}"
|
|
159
|
+
)
|
|
160
|
+
sparse_simplices: dict[int, torch.Tensor] = {}
|
|
161
|
+
for k, table in self.simplices_by_dim.items():
|
|
162
|
+
valid = self.valid_mask[k][0]
|
|
163
|
+
sparse_simplices[k] = table[0][valid]
|
|
164
|
+
return SparseSimplicialComplex(
|
|
165
|
+
simplices_by_dim=sparse_simplices,
|
|
166
|
+
n_vertices=self.n_vertices,
|
|
167
|
+
device=self.device,
|
|
168
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""SparseSimplicialComplex : single-instance simplicial complex with
|
|
5
|
+
sparse-CSC boundary operators.
|
|
6
|
+
|
|
7
|
+
Used by persistent homology (where the matrix-reduction kernel walks
|
|
8
|
+
the sparse boundary matrix column by column) and by Hodge Laplacians
|
|
9
|
+
on large complexes where dense `(n, n)` doesn't fit.
|
|
10
|
+
|
|
11
|
+
No batch dimension: persistent homology operates on one complex at a
|
|
12
|
+
time (the batching for PH happens at a higher level, calling this
|
|
13
|
+
class once per point cloud). For batched small complexes, use
|
|
14
|
+
`DenseSimplicialComplex` and the dense Hodge path.
|
|
15
|
+
|
|
16
|
+
References:
|
|
17
|
+
Saad, Y. (2003). Iterative Methods for Sparse Linear Systems, 2nd
|
|
18
|
+
ed. SIAM. §3.4 : CSR and CSC formats.
|
|
19
|
+
Edelsbrunner, H., Letscher, D., Zomorodian, A. (2002). Topological
|
|
20
|
+
persistence and simplification. Discrete & Computational Geometry
|
|
21
|
+
28:511-533. Original sparse-boundary reduction algorithm.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
import torch
|
|
30
|
+
|
|
31
|
+
from holonomy_lib.simplicial._face_lookup import enumerate_faces, lookup_rows
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from holonomy_lib.simplicial.complex import DenseSimplicialComplex
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SparseSimplicialComplex:
|
|
39
|
+
"""Single-instance simplicial complex.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
simplices_by_dim: `dict[k, Tensor (n_k, k+1)]` of sorted vertex
|
|
43
|
+
tuples. No batch dim, no padding.
|
|
44
|
+
n_vertices: vertex count.
|
|
45
|
+
device: tensor placement.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
simplices_by_dim: dict[int, torch.Tensor]
|
|
49
|
+
n_vertices: int
|
|
50
|
+
device: torch.device = field(default=torch.device("cpu"))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def max_dim(self) -> int:
|
|
54
|
+
"""Highest dimension with a table, including an empty table."""
|
|
55
|
+
return max(self.simplices_by_dim) if self.simplices_by_dim else -1
|
|
56
|
+
|
|
57
|
+
def n_simplices(self, k: int) -> int:
|
|
58
|
+
"""Number of k-simplices. Returns 0 when there is no table for dimension k."""
|
|
59
|
+
if k not in self.simplices_by_dim:
|
|
60
|
+
return 0
|
|
61
|
+
return self.simplices_by_dim[k].shape[0]
|
|
62
|
+
|
|
63
|
+
def boundary(
|
|
64
|
+
self, k: int, dtype: torch.dtype = torch.float64,
|
|
65
|
+
) -> torch.Tensor:
|
|
66
|
+
"""Sparse-CSC boundary operator `∂_k: C_k → C_{k-1}`.
|
|
67
|
+
|
|
68
|
+
Returns a sparse-CSC `(n_{k-1}, n_k)` tensor; entries are
|
|
69
|
+
`± 1` with the Koszul sign for each face → simplex incidence.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
k: simplex dimension. `k = 0` returns an empty `(0, n_0)`
|
|
73
|
+
sparse tensor (vertices have empty boundary).
|
|
74
|
+
dtype: float dtype for the matrix values.
|
|
75
|
+
|
|
76
|
+
References:
|
|
77
|
+
Munkres, J. R. (1984). Elements of Algebraic Topology, §1.
|
|
78
|
+
|
|
79
|
+
Notes:
|
|
80
|
+
Assumes the complex is closed under faces. A face missing from
|
|
81
|
+
the (k-1) table raises ValueError from the lookup.
|
|
82
|
+
"""
|
|
83
|
+
if k == 0:
|
|
84
|
+
n_0 = self.n_simplices(0)
|
|
85
|
+
# No row indices and every column pointer is 0, so each of the
|
|
86
|
+
# n_0 columns is empty.
|
|
87
|
+
return torch.sparse_csc_tensor(
|
|
88
|
+
ccol_indices=torch.zeros(n_0 + 1, dtype=torch.int64,
|
|
89
|
+
device=self.device),
|
|
90
|
+
row_indices=torch.zeros(0, dtype=torch.int64,
|
|
91
|
+
device=self.device),
|
|
92
|
+
values=torch.zeros(0, dtype=dtype, device=self.device),
|
|
93
|
+
size=(0, n_0),
|
|
94
|
+
)
|
|
95
|
+
if k not in self.simplices_by_dim or k - 1 not in self.simplices_by_dim:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"boundary(k={k}): complex lacks simplices at dim {k} "
|
|
98
|
+
f"or {k - 1}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
k_simplices = self.simplices_by_dim[k] # (n_k, k+1)
|
|
102
|
+
prev_simplices = self.simplices_by_dim[k - 1] # (n_{k-1}, k)
|
|
103
|
+
n_k = k_simplices.shape[0]
|
|
104
|
+
n_prev = prev_simplices.shape[0]
|
|
105
|
+
|
|
106
|
+
faces, signs = enumerate_faces(k_simplices)
|
|
107
|
+
rows = lookup_rows(prev_simplices, faces.reshape(-1, k))
|
|
108
|
+
cols = torch.arange(n_k, device=self.device).repeat_interleave(k + 1)
|
|
109
|
+
vals = signs.to(dtype).repeat(n_k)
|
|
110
|
+
|
|
111
|
+
# Build the entries as COO, which takes (row, column) pairs directly.
|
|
112
|
+
# coalesce sorts them by row then column, and to_sparse_csc then
|
|
113
|
+
# compresses the column indices into the CSC layout.
|
|
114
|
+
coo = torch.sparse_coo_tensor(
|
|
115
|
+
indices=torch.stack([rows, cols]),
|
|
116
|
+
values=vals,
|
|
117
|
+
size=(n_prev, n_k),
|
|
118
|
+
).coalesce()
|
|
119
|
+
return coo.to_sparse_csc()
|
|
120
|
+
|
|
121
|
+
def to_dense(
|
|
122
|
+
self, dtype: torch.dtype = torch.float64,
|
|
123
|
+
) -> DenseSimplicialComplex:
|
|
124
|
+
"""Convert to a batched-with-B=1 dense complex.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
dtype: float dtype for boundary matrix returns in the dense
|
|
128
|
+
complex.
|
|
129
|
+
"""
|
|
130
|
+
from holonomy_lib.simplicial.complex import DenseSimplicialComplex
|
|
131
|
+
dense_simplices: dict[int, torch.Tensor] = {}
|
|
132
|
+
valid_masks: dict[int, torch.Tensor] = {}
|
|
133
|
+
for k, table in self.simplices_by_dim.items():
|
|
134
|
+
dense_simplices[k] = table.unsqueeze(0) # (1, n_k, k+1)
|
|
135
|
+
# A single complex has no padding, so every row is a real simplex.
|
|
136
|
+
valid_masks[k] = torch.ones(
|
|
137
|
+
1, table.shape[0], dtype=torch.bool, device=self.device,
|
|
138
|
+
)
|
|
139
|
+
return DenseSimplicialComplex(
|
|
140
|
+
simplices_by_dim=dense_simplices,
|
|
141
|
+
valid_mask=valid_masks,
|
|
142
|
+
n_vertices=self.n_vertices,
|
|
143
|
+
device=self.device,
|
|
144
|
+
dtype=dtype,
|
|
145
|
+
)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Vietoris-Rips complex from a point cloud or distance matrix.
|
|
5
|
+
|
|
6
|
+
The Vietoris-Rips complex `VR(X, r)` of a metric space `X` at scale
|
|
7
|
+
`r` is the simplicial complex whose k-simplex `[v_0, ..., v_k]`
|
|
8
|
+
exists iff `d(v_i, v_j) ≤ r` for every pair `(i, j)`. It's the
|
|
9
|
+
standard finite-scale approximation to the (typically infinite)
|
|
10
|
+
Čech complex and the input to most persistent-homology pipelines.
|
|
11
|
+
|
|
12
|
+
Construction extends each existing simplex by larger vertex indices.
|
|
13
|
+
Candidates are checked in configurable tiles; no batch, vertex, or simplex
|
|
14
|
+
is traversed individually in Python. Dimension progression is sequential
|
|
15
|
+
because a k-simplex is extended from a (k-1)-simplex. The worst case still
|
|
16
|
+
has combinatorial output size. Tiles bound candidate working memory, not
|
|
17
|
+
the size of the returned complex.
|
|
18
|
+
|
|
19
|
+
References:
|
|
20
|
+
Hausmann, J.-C. (1995). On the Vietoris-Rips complexes and a
|
|
21
|
+
cohomology theory for metric spaces. In Prospects in Topology,
|
|
22
|
+
Annals of Math Studies 138.
|
|
23
|
+
Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
|
|
24
|
+
persistence barcodes. J. Appl. Comput. Topology 5:391-423.
|
|
25
|
+
Implementation references for incremental k-simplex construction.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import torch
|
|
31
|
+
|
|
32
|
+
from holonomy_lib.simplicial.complex import DenseSimplicialComplex
|
|
33
|
+
from holonomy_lib.simplicial.sparse_complex import SparseSimplicialComplex
|
|
34
|
+
|
|
35
|
+
# Each tile evaluates at most this many extension candidates, so the temporary
|
|
36
|
+
# index and bool tables stay at O(tile * simplex_width) entries. It is a memory
|
|
37
|
+
# budget with no effect on results. Raise it on large-memory devices for fewer
|
|
38
|
+
# kernel launches. Cataloged in notes/magic_numbers.md.
|
|
39
|
+
VR_CANDIDATE_TILE_DEFAULT = 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rips_tables(
|
|
43
|
+
distances: torch.Tensor, max_radius: float, max_dim: int,
|
|
44
|
+
candidate_tile_size: int,
|
|
45
|
+
) -> dict[int, tuple[torch.Tensor, torch.Tensor]]:
|
|
46
|
+
"""Build the simplex table of every dimension up to `max_dim`.
|
|
47
|
+
|
|
48
|
+
Takes `(B, n, n)` distances, the radius cap, the top dimension, and the
|
|
49
|
+
candidate tile size. Returns a dict from dimension to `(batch_ids, simplices)`,
|
|
50
|
+
where `batch_ids` is `(m,)` and `simplices` is `(m, dim + 1)` vertex indices.
|
|
51
|
+
Rows are grouped by batch member and sorted lexicographically within each
|
|
52
|
+
member. Only pairs with i < j are read, so the distances are assumed symmetric.
|
|
53
|
+
"""
|
|
54
|
+
if distances.ndim != 2 + 1 or distances.shape[-1] != distances.shape[-2]:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"distances must be (B, n, n); got {tuple(distances.shape)}"
|
|
57
|
+
)
|
|
58
|
+
if not distances.is_floating_point():
|
|
59
|
+
raise ValueError("distances must have a real floating dtype")
|
|
60
|
+
if not isinstance(max_dim, int) or max_dim < 0:
|
|
61
|
+
raise ValueError(f"max_dim must be a nonnegative integer, got {max_dim}")
|
|
62
|
+
if not isinstance(candidate_tile_size, int) or candidate_tile_size <= 0:
|
|
63
|
+
raise ValueError("candidate_tile_size must be a positive integer")
|
|
64
|
+
if max_radius < 0:
|
|
65
|
+
raise ValueError("max_radius must be nonnegative")
|
|
66
|
+
|
|
67
|
+
batch_size, n_vertices, _ = distances.shape
|
|
68
|
+
device = distances.device
|
|
69
|
+
vertex_ids = torch.arange(n_vertices, device=device)
|
|
70
|
+
batch_ids = torch.arange(batch_size, device=device)
|
|
71
|
+
tables = {0: (
|
|
72
|
+
batch_ids.repeat_interleave(n_vertices),
|
|
73
|
+
vertex_ids.expand(batch_size, -1).reshape(-1, 1),
|
|
74
|
+
)}
|
|
75
|
+
if max_dim == 0:
|
|
76
|
+
return tables
|
|
77
|
+
|
|
78
|
+
# Pairs within the cap. The diagonal passes too, but the i < j mask below
|
|
79
|
+
# removes it.
|
|
80
|
+
adjacency = distances <= max_radius
|
|
81
|
+
# torch.where returns indices in row-major order, so edges come out grouped
|
|
82
|
+
# by batch member and sorted by (i, j).
|
|
83
|
+
edge_batch, edge_left, edge_right = torch.where(
|
|
84
|
+
adjacency & (vertex_ids[:, None] < vertex_ids[None, :]),
|
|
85
|
+
)
|
|
86
|
+
tables[1] = (edge_batch, torch.stack((edge_left, edge_right), dim=-1))
|
|
87
|
+
for dim in range(2, max_dim + 1):
|
|
88
|
+
previous_batch, previous = tables[dim - 1]
|
|
89
|
+
chunks: list[torch.Tensor] = []
|
|
90
|
+
batch_chunks: list[torch.Tensor] = []
|
|
91
|
+
# A candidate is a parent simplex plus one extra vertex. Requiring the
|
|
92
|
+
# extra vertex to exceed the parent's largest vertex gives every simplex
|
|
93
|
+
# exactly one parent: the simplex with its largest vertex removed.
|
|
94
|
+
n_candidates = previous.shape[0] * n_vertices
|
|
95
|
+
for start in range(0, n_candidates, candidate_tile_size):
|
|
96
|
+
candidate_ids = torch.arange(
|
|
97
|
+
start, min(start + candidate_tile_size, n_candidates),
|
|
98
|
+
device=device,
|
|
99
|
+
)
|
|
100
|
+
# Each candidate id encodes (parent row, extra vertex) as
|
|
101
|
+
# parent * n_vertices + extra.
|
|
102
|
+
parent_ids = candidate_ids // n_vertices
|
|
103
|
+
extra_vertices = candidate_ids % n_vertices
|
|
104
|
+
# Only extra vertices above the parent's largest vertex are canonical.
|
|
105
|
+
ordered = extra_vertices > previous[parent_ids, -1]
|
|
106
|
+
parent_ids = parent_ids[ordered]
|
|
107
|
+
extra_vertices = extra_vertices[ordered]
|
|
108
|
+
parents = previous[parent_ids]
|
|
109
|
+
batches = previous_batch[parent_ids]
|
|
110
|
+
# The parent's own edges already passed, so only the new vertex's
|
|
111
|
+
# edges to the parent's vertices need checking.
|
|
112
|
+
cofaceable = adjacency[
|
|
113
|
+
batches[:, None], parents, extra_vertices[:, None],
|
|
114
|
+
].all(dim=-1)
|
|
115
|
+
chunks.append(torch.cat((
|
|
116
|
+
parents[cofaceable], extra_vertices[cofaceable, None],
|
|
117
|
+
), dim=-1))
|
|
118
|
+
batch_chunks.append(batches[cofaceable])
|
|
119
|
+
tables[dim] = (
|
|
120
|
+
torch.cat(batch_chunks) if batch_chunks else batch_ids[:0],
|
|
121
|
+
torch.cat(chunks) if chunks else torch.empty(
|
|
122
|
+
(0, dim + 1), device=device, dtype=torch.int64,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
return tables
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def vietoris_rips_sparse(
|
|
129
|
+
distances: torch.Tensor,
|
|
130
|
+
max_radius: float,
|
|
131
|
+
max_dim: int,
|
|
132
|
+
*,
|
|
133
|
+
candidate_tile_size: int = VR_CANDIDATE_TILE_DEFAULT,
|
|
134
|
+
) -> SparseSimplicialComplex:
|
|
135
|
+
"""Vietoris-Rips complex from a single distance matrix.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
distances: `(n, n)` symmetric real distance matrix.
|
|
139
|
+
max_radius: include only pairs with `d <= max_radius`.
|
|
140
|
+
max_dim: build simplex tables through this dimension, including
|
|
141
|
+
empty tables where no simplex exists.
|
|
142
|
+
candidate_tile_size: maximum number of simplex-extension candidates
|
|
143
|
+
evaluated at once. It bounds temporary storage, not output size.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Single-instance complex on the input device.
|
|
147
|
+
|
|
148
|
+
Notes:
|
|
149
|
+
Dimensions are constructed sequentially because each extension uses
|
|
150
|
+
the preceding dimension. Candidates within a tile are tensorized.
|
|
151
|
+
|
|
152
|
+
References:
|
|
153
|
+
Hausmann, J.-C. (1995). On the Vietoris-Rips complexes and a
|
|
154
|
+
cohomology theory for metric spaces. In Prospects in Topology,
|
|
155
|
+
Annals of Math Studies 138.
|
|
156
|
+
Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
|
|
157
|
+
persistence barcodes. J. Appl. Comput. Topology 5:391-423.
|
|
158
|
+
"""
|
|
159
|
+
if distances.ndim != 2 or distances.shape[0] != distances.shape[1]:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"distances must be (n, n); got {tuple(distances.shape)}"
|
|
162
|
+
)
|
|
163
|
+
tables = _rips_tables(
|
|
164
|
+
distances.unsqueeze(0), max_radius, max_dim, candidate_tile_size,
|
|
165
|
+
)
|
|
166
|
+
return SparseSimplicialComplex(
|
|
167
|
+
simplices_by_dim={dim: table for dim, (_, table) in tables.items()},
|
|
168
|
+
n_vertices=distances.shape[-1], device=distances.device,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def vietoris_rips_dense(
|
|
173
|
+
distances: torch.Tensor,
|
|
174
|
+
max_radius: float,
|
|
175
|
+
max_dim: int,
|
|
176
|
+
dtype: torch.dtype | None = None,
|
|
177
|
+
*,
|
|
178
|
+
candidate_tile_size: int = VR_CANDIDATE_TILE_DEFAULT,
|
|
179
|
+
) -> DenseSimplicialComplex:
|
|
180
|
+
"""Batched Vietoris-Rips construction with padded simplex tables.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
distances: `(B, n, n)` symmetric real distance matrices.
|
|
184
|
+
max_radius: include pairs with `d <= max_radius`.
|
|
185
|
+
max_dim: build tables through this dimension, including empty ones.
|
|
186
|
+
dtype: boundary-operator dtype; defaults to the distances' dtype.
|
|
187
|
+
candidate_tile_size: maximum number of simplex-extension candidates
|
|
188
|
+
evaluated at once. It bounds temporary storage, not output size.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A complex with per-batch padded simplex tables on the input device.
|
|
192
|
+
Empty batches retain all requested dimensions.
|
|
193
|
+
|
|
194
|
+
Notes:
|
|
195
|
+
Batch members share tensor candidate evaluation and packing. Only
|
|
196
|
+
dimensions and memory tiles are traversed in Python. Dimension
|
|
197
|
+
progression is sequential because extensions require their parents.
|
|
198
|
+
|
|
199
|
+
References:
|
|
200
|
+
Hausmann, J.-C. (1995). On the Vietoris-Rips complexes and a
|
|
201
|
+
cohomology theory for metric spaces.
|
|
202
|
+
Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
|
|
203
|
+
persistence barcodes. J. Appl. Comput. Topology 5:391-423.
|
|
204
|
+
"""
|
|
205
|
+
tables = _rips_tables(distances, max_radius, max_dim, candidate_tile_size)
|
|
206
|
+
batch_size = distances.shape[0]
|
|
207
|
+
device = distances.device
|
|
208
|
+
simplices_by_dim: dict[int, torch.Tensor] = {}
|
|
209
|
+
valid_mask: dict[int, torch.Tensor] = {}
|
|
210
|
+
for dim, (batches, table) in tables.items():
|
|
211
|
+
counts = torch.bincount(batches, minlength=batch_size)
|
|
212
|
+
# The padded width is the largest count over batch members, which only
|
|
213
|
+
# the data can tell us. int() syncs the device once per dimension
|
|
214
|
+
# before the output table is allocated.
|
|
215
|
+
width = int(counts.max()) if batch_size else 0
|
|
216
|
+
# Every member has exactly n vertices, so dim 0 uses n directly. That
|
|
217
|
+
# also keeps the width right when the batch is empty.
|
|
218
|
+
if dim == 0:
|
|
219
|
+
width = distances.shape[-1]
|
|
220
|
+
padded = torch.full(
|
|
221
|
+
(batch_size, width, dim + 1), -1,
|
|
222
|
+
dtype=torch.int64, device=device,
|
|
223
|
+
)
|
|
224
|
+
mask = torch.zeros(batch_size, width, dtype=torch.bool, device=device)
|
|
225
|
+
# Tables are sorted by batch id, so member b's rows are contiguous and
|
|
226
|
+
# start at offsets[b]. columns is each row's slot inside its member's
|
|
227
|
+
# padded block.
|
|
228
|
+
offsets = counts.cumsum(0) - counts
|
|
229
|
+
columns = torch.arange(table.shape[0], device=device) - offsets[batches]
|
|
230
|
+
padded[batches, columns] = table
|
|
231
|
+
mask[batches, columns] = True
|
|
232
|
+
simplices_by_dim[dim] = padded
|
|
233
|
+
valid_mask[dim] = mask
|
|
234
|
+
return DenseSimplicialComplex(
|
|
235
|
+
simplices_by_dim=simplices_by_dim, valid_mask=valid_mask,
|
|
236
|
+
n_vertices=distances.shape[-1], device=device,
|
|
237
|
+
dtype=distances.dtype if dtype is None else dtype,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def pairwise_distances(points: torch.Tensor) -> torch.Tensor:
|
|
242
|
+
"""Pairwise Euclidean distances.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
points: `(n, d)` or `(B, n, d)`.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
`(n, n)` or `(B, n, n)` distance matrix on the input device and dtype.
|
|
249
|
+
|
|
250
|
+
Notes:
|
|
251
|
+
Half and bfloat16 inputs use float32 intermediates because cdist's
|
|
252
|
+
direct CPU kernel does not support these dtypes.
|
|
253
|
+
|
|
254
|
+
References:
|
|
255
|
+
Standard Euclidean distance. See e.g. Hartle, J. B. (2003),
|
|
256
|
+
Gravity: An Introduction to Einstein's General Relativity,
|
|
257
|
+
§2.1 for the abstract metric formulation; computationally
|
|
258
|
+
we use `||p - q||_2` directly.
|
|
259
|
+
"""
|
|
260
|
+
if points.ndim != 2 and points.ndim != 2 + 1:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"points must be (n, d) or (B, n, d); got {tuple(points.shape)}"
|
|
263
|
+
)
|
|
264
|
+
if not points.is_floating_point():
|
|
265
|
+
raise ValueError("points must have a real floating dtype")
|
|
266
|
+
# cdist's CPU kernel only accepts float32/float64. Evaluate low-precision
|
|
267
|
+
# inputs in float32 and return their original dtype on the same device.
|
|
268
|
+
working = (
|
|
269
|
+
points.float() if points.dtype in (torch.float16, torch.bfloat16) else points
|
|
270
|
+
)
|
|
271
|
+
# The direct kernel avoids the cancellation in the matrix-multiplication
|
|
272
|
+
# identity ||p||^2 + ||q||^2 - 2 p.q, which loses digits for nearly
|
|
273
|
+
# coincident points. Those small distances are the first edges the
|
|
274
|
+
# filtration adds.
|
|
275
|
+
return torch.cdist(
|
|
276
|
+
working, working, compute_mode="donot_use_mm_for_euclid_dist",
|
|
277
|
+
).to(points.dtype)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.spectral: graph Laplacians and spectral utilities.
|
|
5
|
+
|
|
6
|
+
Public names:
|
|
7
|
+
laplacian.combinatorial(A) : L = D - A
|
|
8
|
+
laplacian.symmetric_normalized(A) : L_sym = I - D^{-1/2} A D^{-1/2}
|
|
9
|
+
laplacian.random_walk(A) : L_rw = I - D^{-1} A
|
|
10
|
+
laplacian.signed(A) : L^sigma = D^{|sigma|} - A
|
|
11
|
+
(Kunegis 2010)
|
|
12
|
+
laplacian.degree(A) : degree vector (signed-aware)
|
|
13
|
+
magnetic.combinatorial(A, q) : Hermitian Laplacian on directed
|
|
14
|
+
graphs (Furutani 2020, Fanuel 2017)
|
|
15
|
+
magnetic.symmetric_normalized(A, q) : normalized magnetic Laplacian,
|
|
16
|
+
spectrum in [0, 2]
|
|
17
|
+
magnetic.sign_magnetic_combinatorial(A, q)
|
|
18
|
+
: signed-directed Hermitian
|
|
19
|
+
Laplacian; unifies Kunegis
|
|
20
|
+
signed and magnetic
|
|
21
|
+
(He et al. 2022, MSGNN)
|
|
22
|
+
magnetic.sign_magnetic_symmetric_normalized(A, q)
|
|
23
|
+
: normalized signed-directed form
|
|
24
|
+
laplacian_eigenmaps(A, k, ...) : bottom-k spectral embedding
|
|
25
|
+
spectral_dimension(eigenvalues, t) : d_s = -2 d log p / d log t from the
|
|
26
|
+
heat-kernel return decay
|
|
27
|
+
(Rammal-Toulouse 1983)
|
|
28
|
+
diffusion_map(A, k, t) : diffusion-map embedding at time t
|
|
29
|
+
(Coifman-Lafon 2006)
|
|
30
|
+
effective_resistance(A) : pairwise effective resistance
|
|
31
|
+
commute_time(A) : pairwise commute time, vol(component) R
|
|
32
|
+
heat_kernel_chebyshev(L, t, signal=None) : exp(-t L), or exp(-t L) @ signal,
|
|
33
|
+
by a Chebyshev polynomial expansion
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from holonomy_lib.spectral import laplacian, magnetic
|
|
37
|
+
from holonomy_lib.spectral.diffusion_map import diffusion_map
|
|
38
|
+
from holonomy_lib.spectral.effective_resistance import (
|
|
39
|
+
commute_time,
|
|
40
|
+
effective_resistance,
|
|
41
|
+
)
|
|
42
|
+
from holonomy_lib.spectral.embedding import laplacian_eigenmaps
|
|
43
|
+
from holonomy_lib.spectral.heat_kernel import heat_kernel_chebyshev
|
|
44
|
+
from holonomy_lib.spectral.spectral_dimension import spectral_dimension
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"commute_time",
|
|
48
|
+
"diffusion_map",
|
|
49
|
+
"effective_resistance",
|
|
50
|
+
"heat_kernel_chebyshev",
|
|
51
|
+
"laplacian",
|
|
52
|
+
"laplacian_eigenmaps",
|
|
53
|
+
"magnetic",
|
|
54
|
+
"spectral_dimension",
|
|
55
|
+
]
|