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,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.topology: Hodge Laplacians + persistent homology.
|
|
5
|
+
|
|
6
|
+
Built on `holonomy_lib.simplicial`. Two flavors of output:
|
|
7
|
+
|
|
8
|
+
Hodge / Betti
|
|
9
|
+
`hodge_laplacian(complex, k)`; the Hodge Laplacian on k-chains,
|
|
10
|
+
`L_k = ∂_{k+1} ∂_{k+1}^T + ∂_k^T ∂_k`. Its kernel has dimension
|
|
11
|
+
equal to the k-th Betti number.
|
|
12
|
+
`betti_numbers(complex, max_dim)`; computes
|
|
13
|
+
`β_k = n_k - rank(∂_k) - rank(∂_{k+1})` from float64 boundary ranks,
|
|
14
|
+
giving `(β_0, β_1, …, β_max_dim)`. It does not form `L_k`.
|
|
15
|
+
|
|
16
|
+
Persistent homology
|
|
17
|
+
`persistence_diagrams(points_or_distances, max_dim)` returns the
|
|
18
|
+
per-dim birth/death barcodes for a Vietoris-Rips filtration.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from holonomy_lib.topology.hodge import betti_numbers, hodge_laplacian
|
|
22
|
+
from holonomy_lib.topology.persistent_homology import persistence_diagrams
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"betti_numbers",
|
|
26
|
+
"hodge_laplacian",
|
|
27
|
+
"persistence_diagrams",
|
|
28
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Vietoris-Rips filtration construction for persistent homology.
|
|
5
|
+
|
|
6
|
+
A filtration is a totally-ordered sequence of simplices, each with
|
|
7
|
+
a birth time at which it joins the complex. For the Vietoris-Rips
|
|
8
|
+
filtration, the birth time of a simplex `σ` is the largest pairwise
|
|
9
|
+
distance among its vertices:
|
|
10
|
+
|
|
11
|
+
birth(σ) = max_{i, j ∈ σ} d(i, j).
|
|
12
|
+
|
|
13
|
+
Simplices with the same birth time are tie-broken by `(dim, input row
|
|
14
|
+
index)` so the ordering is total and deterministic. This module
|
|
15
|
+
returns the filtration in the order needed by the Z/2 boundary-matrix
|
|
16
|
+
reduction (`_reduction.py`): simplices listed in birth-time order,
|
|
17
|
+
with a per-simplex `(dim, original-index-within-dim)` tag so
|
|
18
|
+
boundary columns can be reconstructed.
|
|
19
|
+
|
|
20
|
+
References:
|
|
21
|
+
Edelsbrunner, H., Harer, J. (2010). Computational Topology: An
|
|
22
|
+
Introduction. American Mathematical Society. §VI.2; filtrations.
|
|
23
|
+
Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
|
|
24
|
+
persistence barcodes. J. Appl. Comput. Topology 5:391-423.
|
|
25
|
+
Filtration ordering conventions used here.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
import torch
|
|
33
|
+
|
|
34
|
+
from holonomy_lib.simplicial import SparseSimplicialComplex
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Filtration:
|
|
39
|
+
"""A Vietoris-Rips filtration with simplices in birth-time order.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
complex: the underlying simplicial complex (sparse, single-instance).
|
|
43
|
+
birth_times: `(n_total_simplices,)` float tensor, in filtration order.
|
|
44
|
+
sorted_dims: `(n_total_simplices,)` int tensor; dim of each simplex.
|
|
45
|
+
sorted_indices: `(n_total_simplices,)` int tensor; for the j-th
|
|
46
|
+
simplex in filtration order, its row index within
|
|
47
|
+
`complex.simplices_by_dim[sorted_dims[j]]`. Used to fetch the
|
|
48
|
+
simplex's vertex tuple when building the boundary matrix.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
complex: SparseSimplicialComplex
|
|
52
|
+
birth_times: torch.Tensor
|
|
53
|
+
sorted_dims: torch.Tensor
|
|
54
|
+
sorted_indices: torch.Tensor
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_filtration(
|
|
58
|
+
distances: torch.Tensor,
|
|
59
|
+
complex: SparseSimplicialComplex,
|
|
60
|
+
) -> Filtration:
|
|
61
|
+
"""Compute the birth time and sort order of every simplex in a VR complex.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
distances: `(n, n)` pairwise distance matrix for the same vertex
|
|
65
|
+
set as `complex`.
|
|
66
|
+
complex: pre-built `SparseSimplicialComplex` (typically from
|
|
67
|
+
`vietoris_rips_sparse`).
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A `Filtration` with simplices ordered by (birth_time, dim,
|
|
71
|
+
input_row_index).
|
|
72
|
+
|
|
73
|
+
References:
|
|
74
|
+
Edelsbrunner, Harer (2010). Computational Topology: An Introduction.
|
|
75
|
+
AMS, Chapter VI.2, filtrations.
|
|
76
|
+
"""
|
|
77
|
+
# Gather one record per simplex across all dimensions into flat arrays,
|
|
78
|
+
# so the sort below can order the whole complex at once.
|
|
79
|
+
all_birth: list[torch.Tensor] = []
|
|
80
|
+
all_dim: list[torch.Tensor] = []
|
|
81
|
+
all_idx: list[torch.Tensor] = []
|
|
82
|
+
|
|
83
|
+
for k in sorted(complex.simplices_by_dim.keys()):
|
|
84
|
+
simplices = complex.simplices_by_dim[k] # (n_k, k+1)
|
|
85
|
+
n_k = simplices.shape[0]
|
|
86
|
+
if n_k == 0:
|
|
87
|
+
continue
|
|
88
|
+
if k == 0:
|
|
89
|
+
# A vertex's birth would be d(i, i) from the general formula below.
|
|
90
|
+
# That is 0 for any distance matrix, so the gather is skipped.
|
|
91
|
+
births = torch.zeros(n_k, dtype=distances.dtype,
|
|
92
|
+
device=distances.device)
|
|
93
|
+
else:
|
|
94
|
+
# Birth is the largest distance among the simplex's vertices, which
|
|
95
|
+
# is the max over its (k+1) x (k+1) submatrix of `distances`. Advanced
|
|
96
|
+
# indexing builds all those submatrices at once:
|
|
97
|
+
# pairs[s, i, j] = distances[ simplices[s, i], simplices[s, j] ]
|
|
98
|
+
v_i = simplices.unsqueeze(-1) # (n_k, k+1, 1)
|
|
99
|
+
v_j = simplices.unsqueeze(-2) # (n_k, 1, k+1)
|
|
100
|
+
# Broadcast to (n_k, k+1, k+1) of vertex pairs.
|
|
101
|
+
sub = distances[v_i, v_j] # (n_k, k+1, k+1)
|
|
102
|
+
births = sub.flatten(start_dim=1).max(dim=-1).values
|
|
103
|
+
all_birth.append(births)
|
|
104
|
+
all_dim.append(torch.full((n_k,), k, dtype=torch.int64,
|
|
105
|
+
device=distances.device))
|
|
106
|
+
all_idx.append(torch.arange(n_k, dtype=torch.int64,
|
|
107
|
+
device=distances.device))
|
|
108
|
+
|
|
109
|
+
# No simplices at all. Return empty tensors in the same dtypes as the
|
|
110
|
+
# non-empty case, so callers never see a different type here.
|
|
111
|
+
if not all_birth:
|
|
112
|
+
empty = torch.zeros(0, dtype=distances.dtype, device=distances.device)
|
|
113
|
+
empty_i = torch.zeros(0, dtype=torch.int64, device=distances.device)
|
|
114
|
+
return Filtration(
|
|
115
|
+
complex=complex, birth_times=empty,
|
|
116
|
+
sorted_dims=empty_i, sorted_indices=empty_i,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
birth_times = torch.cat(all_birth)
|
|
120
|
+
dims = torch.cat(all_dim)
|
|
121
|
+
indices = torch.cat(all_idx)
|
|
122
|
+
|
|
123
|
+
# Sort by (birth_time, dim, row). A stable sort keeps the existing order
|
|
124
|
+
# among ties, so we sort by the least significant key first. The last pass
|
|
125
|
+
# sorts by birth time, and simplices with equal birth keep the (dim, row)
|
|
126
|
+
# order that the passes before it left.
|
|
127
|
+
order = torch.argsort(indices, stable=True)
|
|
128
|
+
dims_sorted = dims[order]
|
|
129
|
+
times_sorted = birth_times[order]
|
|
130
|
+
indices_sorted = indices[order]
|
|
131
|
+
|
|
132
|
+
order2 = torch.argsort(dims_sorted, stable=True)
|
|
133
|
+
times_sorted = times_sorted[order2]
|
|
134
|
+
indices_sorted = indices_sorted[order2]
|
|
135
|
+
dims_sorted = dims_sorted[order2]
|
|
136
|
+
|
|
137
|
+
order3 = torch.argsort(times_sorted, stable=True)
|
|
138
|
+
times_sorted = times_sorted[order3]
|
|
139
|
+
indices_sorted = indices_sorted[order3]
|
|
140
|
+
dims_sorted = dims_sorted[order3]
|
|
141
|
+
|
|
142
|
+
return Filtration(
|
|
143
|
+
complex=complex,
|
|
144
|
+
birth_times=times_sorted,
|
|
145
|
+
sorted_dims=dims_sorted,
|
|
146
|
+
sorted_indices=indices_sorted,
|
|
147
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Z/2 left-to-right boundary-matrix reduction for persistent homology.
|
|
5
|
+
|
|
6
|
+
Boundary columns are prepared by tensor face enumeration and row joins.
|
|
7
|
+
The Python backend transfers their indices and filtration metadata to the
|
|
8
|
+
host together, once per filtration. The torch backend keeps columns on the
|
|
9
|
+
input device and transfers only metadata in that bulk operation.
|
|
10
|
+
|
|
11
|
+
Reduction is sequential: a column's pivot depends on earlier reduced
|
|
12
|
+
columns. Python uses set symmetric differences; torch uses odd occurrence
|
|
13
|
+
counts in concatenated index tensors. The torch backend synchronizes once
|
|
14
|
+
per pivot attempt, including every attempt after an XOR. It is not a fully
|
|
15
|
+
asynchronous GPU reduction. Neither backend implements clearing.
|
|
16
|
+
|
|
17
|
+
A nonzero reduced column pairs its lowest face with a death. A zero column
|
|
18
|
+
is essential only when no later column has paired that birth with a death.
|
|
19
|
+
|
|
20
|
+
References:
|
|
21
|
+
Edelsbrunner, H., Letscher, D., Zomorodian, A. (2002). Topological
|
|
22
|
+
persistence and simplification. Discrete & Computational Geometry
|
|
23
|
+
28:511-533. Left-to-right boundary reduction.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Literal
|
|
29
|
+
|
|
30
|
+
import torch
|
|
31
|
+
|
|
32
|
+
from holonomy_lib.simplicial._face_lookup import enumerate_faces, lookup_rows
|
|
33
|
+
from holonomy_lib.topology._filtration import Filtration
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _boundary_columns(filtration: Filtration) -> torch.Tensor:
|
|
37
|
+
"""Build the padded boundary matrix of a filtration, with -1 for unused slots.
|
|
38
|
+
|
|
39
|
+
Takes a filtration whose complex is closed under taking faces. Returns an
|
|
40
|
+
`(n_total, max_dim + 1)` int64 tensor. Row j holds the filtration ids of the
|
|
41
|
+
faces of the simplex at filtration position j, and vertex rows are all -1.
|
|
42
|
+
"""
|
|
43
|
+
complex = filtration.complex
|
|
44
|
+
n_total = filtration.birth_times.numel()
|
|
45
|
+
device = filtration.birth_times.device
|
|
46
|
+
columns = torch.full(
|
|
47
|
+
(n_total, complex.max_dim + 1), -1, dtype=torch.int64, device=device,
|
|
48
|
+
)
|
|
49
|
+
filtration_ids = torch.arange(n_total, device=device)
|
|
50
|
+
inverse_by_dim: dict[int, torch.Tensor] = {}
|
|
51
|
+
# Map each simplex's row within its dimension to its position in the
|
|
52
|
+
# filtration. Faces are found by row, so this inverse turns them into
|
|
53
|
+
# filtration ids.
|
|
54
|
+
for dim, simplices in complex.simplices_by_dim.items():
|
|
55
|
+
selected = filtration.sorted_dims == dim
|
|
56
|
+
inverse = torch.empty(simplices.shape[0], dtype=torch.int64, device=device)
|
|
57
|
+
inverse[filtration.sorted_indices[selected]] = filtration_ids[selected]
|
|
58
|
+
inverse_by_dim[dim] = inverse
|
|
59
|
+
for dim, simplices in complex.simplices_by_dim.items():
|
|
60
|
+
if dim == 0 or simplices.shape[0] == 0:
|
|
61
|
+
continue
|
|
62
|
+
# Each simplex has dim+1 faces. enumerate_faces lists them as vertex
|
|
63
|
+
# tuples, and lookup_rows finds each one's row in the dimension below.
|
|
64
|
+
# The reshape gives every simplex its own row of face rows.
|
|
65
|
+
faces, _ = enumerate_faces(simplices)
|
|
66
|
+
face_ids = lookup_rows(
|
|
67
|
+
complex.simplices_by_dim[dim - 1], faces.reshape(-1, dim),
|
|
68
|
+
).reshape(simplices.shape[0], dim + 1)
|
|
69
|
+
columns[inverse_by_dim[dim], :dim + 1] = inverse_by_dim[dim - 1][face_ids]
|
|
70
|
+
return columns
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _host_records(
|
|
74
|
+
filtration: Filtration, columns: torch.Tensor | None,
|
|
75
|
+
) -> tuple[list[int], list[float], list[list[int]]]:
|
|
76
|
+
"""Copy dims, birth times, and optional boundary columns to host lists.
|
|
77
|
+
|
|
78
|
+
Takes a filtration and the padded boundary matrix or None. Returns three
|
|
79
|
+
lists: the dim of each simplex, its birth time, and the columns as nested
|
|
80
|
+
lists of filtration ids (empty when `columns` is None). Everything goes
|
|
81
|
+
over in one device-to-host copy as raw bytes, so int64 indices never pass
|
|
82
|
+
through a float dtype.
|
|
83
|
+
"""
|
|
84
|
+
dims = filtration.sorted_dims.contiguous()
|
|
85
|
+
births = filtration.birth_times.contiguous()
|
|
86
|
+
chunks = [dims.view(torch.uint8), births.view(torch.uint8)]
|
|
87
|
+
if columns is not None:
|
|
88
|
+
chunks.append(columns.contiguous().view(torch.uint8).flatten())
|
|
89
|
+
host = torch.cat(chunks).cpu()
|
|
90
|
+
dims_end = dims.numel() * dims.element_size()
|
|
91
|
+
births_end = dims_end + births.numel() * births.element_size()
|
|
92
|
+
host_dims = host[:dims_end].view(dims.dtype).tolist()
|
|
93
|
+
host_births = host[dims_end:births_end].view(births.dtype).tolist()
|
|
94
|
+
# Float births can leave the columns at a byte offset that is not a
|
|
95
|
+
# multiple of 8, and an int64 view needs that alignment. Cloning the slice
|
|
96
|
+
# gives it a fresh host buffer that starts at offset 0. This is a host
|
|
97
|
+
# copy, not another device transfer.
|
|
98
|
+
host_columns = (
|
|
99
|
+
host[births_end:].clone().view(columns.dtype).reshape(columns.shape).tolist()
|
|
100
|
+
if columns is not None else []
|
|
101
|
+
)
|
|
102
|
+
return host_dims, host_births, host_columns
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def reduce_filtration(
|
|
106
|
+
filtration: Filtration,
|
|
107
|
+
backend: Literal["python", "torch"] = "python",
|
|
108
|
+
) -> dict[int, list[tuple[float, float]]]:
|
|
109
|
+
"""Run sequential Z/2 reduction and return persistence pairs by dimension.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
filtration: the simplex ordering produced by `build_filtration`.
|
|
113
|
+
backend: `"python"` for host set columns, or `"torch"` for index
|
|
114
|
+
tensors on the input device. Torch synchronizes for each pivot
|
|
115
|
+
attempt because the next column operation depends on that pivot.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A dictionary keyed by homology dimension, holding the finite and
|
|
119
|
+
essential `(birth, death)` pairs. Essential bars have infinite death;
|
|
120
|
+
zero-length bars are omitted.
|
|
121
|
+
|
|
122
|
+
References:
|
|
123
|
+
Edelsbrunner, Letscher, Zomorodian (2002). Topological persistence
|
|
124
|
+
and simplification. Discrete & Computational Geometry 28:511-533.
|
|
125
|
+
"""
|
|
126
|
+
if backend not in ("python", "torch"):
|
|
127
|
+
raise ValueError(f"backend must be 'python' or 'torch'; got {backend!r}")
|
|
128
|
+
boundary = _boundary_columns(filtration)
|
|
129
|
+
# The python backend needs the boundary on the host as sets. The torch
|
|
130
|
+
# backend keeps it on the device, so only dims and births are copied.
|
|
131
|
+
dims, births, host_columns = _host_records(
|
|
132
|
+
filtration, boundary if backend == "python" else None,
|
|
133
|
+
)
|
|
134
|
+
if backend == "torch":
|
|
135
|
+
pairs, pivots, empty = _reduce_torch(boundary, dims)
|
|
136
|
+
else:
|
|
137
|
+
# One set of filtration ids per column. A dim-0 simplex has no
|
|
138
|
+
# boundary, so its column starts out empty.
|
|
139
|
+
columns = [set(row[:dim + 1]) if dim else set()
|
|
140
|
+
for row, dim in zip(host_columns, dims, strict=True)]
|
|
141
|
+
pairs, pivots, empty = _reduce_python(columns)
|
|
142
|
+
|
|
143
|
+
# Each pair joins a birth simplex to the simplex that kills it. A bar whose
|
|
144
|
+
# death is not after its birth has zero length and is dropped.
|
|
145
|
+
pairs_by_dim: dict[int, list[tuple[float, float]]] = {}
|
|
146
|
+
for birth_id, death_id in pairs:
|
|
147
|
+
birth, death = births[birth_id], births[death_id]
|
|
148
|
+
if birth < death:
|
|
149
|
+
pairs_by_dim.setdefault(dims[birth_id], []).append((birth, death))
|
|
150
|
+
|
|
151
|
+
# A zero column creates a class at its own index. If some column later has
|
|
152
|
+
# that index as its low, the class died there. The rest never die.
|
|
153
|
+
essential = torch.tensor(empty, dtype=torch.bool)
|
|
154
|
+
essential[list(pivots)] = False
|
|
155
|
+
for index in essential.nonzero().flatten().tolist():
|
|
156
|
+
pairs_by_dim.setdefault(dims[index], []).append((births[index], float("inf")))
|
|
157
|
+
return pairs_by_dim
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _reduce_python(
|
|
161
|
+
columns: list[set[int]],
|
|
162
|
+
) -> tuple[list[tuple[int, int]], dict[int, int], list[bool]]:
|
|
163
|
+
"""Reduce the boundary columns left to right over Z/2.
|
|
164
|
+
|
|
165
|
+
Takes one set of filtration ids per simplex, in filtration order. Returns
|
|
166
|
+
the (low, index) pairs, a map from each low to the column that owns it,
|
|
167
|
+
and a flag per column that is True when the reduced column is zero.
|
|
168
|
+
Assumes the columns are in filtration order, since each column's pivot
|
|
169
|
+
depends only on the columns before it.
|
|
170
|
+
"""
|
|
171
|
+
pivot_map: dict[int, int] = {}
|
|
172
|
+
pairs: list[tuple[int, int]] = []
|
|
173
|
+
for index, column in enumerate(columns):
|
|
174
|
+
while column:
|
|
175
|
+
# The low is the largest filtration id left in the column, which is
|
|
176
|
+
# the latest-born face. If an earlier column already owns that low,
|
|
177
|
+
# add that column in over Z/2 to cancel the face, then look again.
|
|
178
|
+
# Otherwise this column claims the low and is done.
|
|
179
|
+
low = max(column)
|
|
180
|
+
if low in pivot_map:
|
|
181
|
+
column ^= columns[pivot_map[low]]
|
|
182
|
+
else:
|
|
183
|
+
pivot_map[low] = index
|
|
184
|
+
pairs.append((low, index))
|
|
185
|
+
break
|
|
186
|
+
return pairs, pivot_map, [not column for column in columns]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _reduce_torch(
|
|
190
|
+
boundary: torch.Tensor, dims: list[int],
|
|
191
|
+
) -> tuple[list[tuple[int, int]], dict[int, int], list[bool]]:
|
|
192
|
+
"""Reduce the boundary columns on the device; same output as the python path.
|
|
193
|
+
|
|
194
|
+
Takes the padded boundary matrix and the dim of each simplex. Returns the
|
|
195
|
+
same triple as `_reduce_python`. Each pass through the loop reads the pivot
|
|
196
|
+
back with `.item()`, so every pivot attempt costs one device sync.
|
|
197
|
+
"""
|
|
198
|
+
# Keep only the real faces of each row. Vertex rows have none, and the -1
|
|
199
|
+
# padding is cut off.
|
|
200
|
+
columns = [row[:dim + 1] if dim else row[:0]
|
|
201
|
+
for row, dim in zip(boundary.unbind(), dims, strict=True)]
|
|
202
|
+
pivot_map: dict[int, int] = {}
|
|
203
|
+
pairs: list[tuple[int, int]] = []
|
|
204
|
+
for index, column in enumerate(columns):
|
|
205
|
+
while column.numel():
|
|
206
|
+
# .item() copies the pivot to the host because the branch below
|
|
207
|
+
# depends on its value. This is the one sync per attempt.
|
|
208
|
+
low = int(column.max().item())
|
|
209
|
+
if low in pivot_map:
|
|
210
|
+
column = _xor_columns(column, columns[pivot_map[low]])
|
|
211
|
+
columns[index] = column
|
|
212
|
+
else:
|
|
213
|
+
pivot_map[low] = index
|
|
214
|
+
pairs.append((low, index))
|
|
215
|
+
break
|
|
216
|
+
return pairs, pivot_map, [column.numel() == 0 for column in columns]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _xor_columns(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
|
|
220
|
+
"""Return the Z/2 sum of two boundary columns as a sorted index tensor.
|
|
221
|
+
|
|
222
|
+
Takes two 1-D index tensors. Returns the entries that appear in exactly one
|
|
223
|
+
input, sorted ascending. Assumes no repeats within either input, since a
|
|
224
|
+
repeat would be counted as a second occurrence and cancel wrongly.
|
|
225
|
+
"""
|
|
226
|
+
unique, counts = torch.unique(torch.cat((a, b)), return_counts=True)
|
|
227
|
+
return unique[counts % 2 == 1]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Hodge Laplacians + Betti numbers on simplicial complexes.
|
|
5
|
+
|
|
6
|
+
The k-th Hodge Laplacian on a simplicial complex `K` is
|
|
7
|
+
|
|
8
|
+
L_k = ∂_{k+1} ∂_{k+1}^T + ∂_k^T ∂_k,
|
|
9
|
+
|
|
10
|
+
acting on the space of k-chains `C_k(K) ≅ ℝ^{n_k}`. It generalizes
|
|
11
|
+
the graph Laplacian (which is `L_0` for a 1-complex) to higher
|
|
12
|
+
dimensions and connects spectral analysis to topology via the Hodge
|
|
13
|
+
decomposition:
|
|
14
|
+
|
|
15
|
+
ker(L_k) ≅ H_k(K), so β_k = dim(ker(L_k)) = n_k − rank(L_k).
|
|
16
|
+
|
|
17
|
+
For a topological invariant of `K`, `β_k` is the k-th Betti number:
|
|
18
|
+
`β_0` counts connected components, `β_1` counts independent 1-cycles
|
|
19
|
+
(holes), `β_2` counts independent voids, and so on.
|
|
20
|
+
|
|
21
|
+
Two paths share the same public function `hodge_laplacian(complex, k)`:
|
|
22
|
+
|
|
23
|
+
Dense complex (`DenseSimplicialComplex`)
|
|
24
|
+
Boundary operators returned as dense `(B, n_{k-1}_max, n_k_max)`
|
|
25
|
+
tensors. The Hodge Laplacian is built via batched dense matmuls
|
|
26
|
+
and returned as `(B, n_k_max, n_k_max)`. Boundary and Laplacian
|
|
27
|
+
storage both scale with the padded batch dimensions.
|
|
28
|
+
|
|
29
|
+
Sparse complex (`SparseSimplicialComplex`)
|
|
30
|
+
Boundary operators returned as `torch.sparse_csc_tensor
|
|
31
|
+
(n_{k-1}, n_k)`. The Hodge Laplacian is built via sparse matmul
|
|
32
|
+
and returned as a dense `(n_k, n_k)` tensor. This avoids dense
|
|
33
|
+
boundary storage but still requires space for the final dense L_k.
|
|
34
|
+
|
|
35
|
+
`betti_numbers(complex, max_dim)` returns the Betti numbers for dims
|
|
36
|
+
0..max_dim from float64 boundary ranks. Rank tolerances use each
|
|
37
|
+
member's real simplex counts, independently of batch padding.
|
|
38
|
+
|
|
39
|
+
References:
|
|
40
|
+
Eckmann, B. (1944). Harmonische Funktionen und Randwertaufgaben in
|
|
41
|
+
einem Komplex. Commentarii Mathematici Helvetici 17:240–255.
|
|
42
|
+
The original Hodge-theoretic interpretation on simplicial
|
|
43
|
+
complexes.
|
|
44
|
+
Lim, L.-H. (2020). Hodge Laplacians on graphs. SIAM Review
|
|
45
|
+
62(3):685–715. Modern survey.
|
|
46
|
+
Schaub, M. T., Benson, A. R., Horn, P., Lippner, G., Jadbabaie, A.
|
|
47
|
+
(2020). Random walks on simplicial complexes and the normalized
|
|
48
|
+
Hodge 1-Laplacian. SIAM Review 62(2):353–391.
|
|
49
|
+
Ribando-Gros, E., Wang, R., Chen, J., Tong, Y., Wei, G.-W. (2024).
|
|
50
|
+
Combinatorial and Hodge Laplacians: Similarities and differences.
|
|
51
|
+
SIAM Review 66(3):575–601.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import torch
|
|
57
|
+
|
|
58
|
+
from holonomy_lib.simplicial import (
|
|
59
|
+
DenseSimplicialComplex,
|
|
60
|
+
SparseSimplicialComplex,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def hodge_laplacian(
|
|
65
|
+
complex: DenseSimplicialComplex | SparseSimplicialComplex,
|
|
66
|
+
k: int,
|
|
67
|
+
) -> torch.Tensor:
|
|
68
|
+
"""Hodge Laplacian `L_k = ∂_{k+1} ∂_{k+1}^T + ∂_k^T ∂_k`.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
complex: a `DenseSimplicialComplex` (batched) or
|
|
72
|
+
`SparseSimplicialComplex` (single-instance).
|
|
73
|
+
k: simplex dimension. Must be in `[0, complex.max_dim]`.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dense complex → `(B, n_k_max, n_k_max)` Tensor.
|
|
77
|
+
Sparse complex → `(n_k, n_k)` dense Tensor. Boundary products
|
|
78
|
+
are computed sparsely, then materialized for this return contract.
|
|
79
|
+
|
|
80
|
+
References:
|
|
81
|
+
Eckmann (1944). Harmonische Funktionen und Randwertaufgaben in
|
|
82
|
+
einem Komplex. Comment. Math. Helv. 17:240–255.
|
|
83
|
+
Lim (2020). Hodge Laplacians on graphs. SIAM Review 62:685–715.
|
|
84
|
+
"""
|
|
85
|
+
if k < 0:
|
|
86
|
+
raise ValueError(f"k must be >= 0, got {k}")
|
|
87
|
+
if isinstance(complex, DenseSimplicialComplex):
|
|
88
|
+
return _hodge_dense(complex, k)
|
|
89
|
+
if isinstance(complex, SparseSimplicialComplex):
|
|
90
|
+
return _hodge_sparse(complex, k)
|
|
91
|
+
raise TypeError(
|
|
92
|
+
f"complex must be a Dense or Sparse SimplicialComplex; "
|
|
93
|
+
f"got {type(complex).__name__}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def betti_numbers(
|
|
98
|
+
complex: DenseSimplicialComplex | SparseSimplicialComplex,
|
|
99
|
+
max_dim: int,
|
|
100
|
+
threshold: float | None = None,
|
|
101
|
+
) -> torch.Tensor:
|
|
102
|
+
"""Betti numbers `(β_0, β_1, …, β_max_dim)` for a simplicial complex.
|
|
103
|
+
|
|
104
|
+
`β_k = n_k - rank(∂_k) - rank(∂_{k+1})`. Boundary entries are
|
|
105
|
+
exactly 0 or ±1, so ranks are computed in float64 regardless of
|
|
106
|
+
the complex's storage dtype. This avoids squaring the conditioning
|
|
107
|
+
by forming a Hodge Laplacian.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
complex: a `DenseSimplicialComplex` or `SparseSimplicialComplex`.
|
|
111
|
+
max_dim: highest dim to return Betti numbers for. Must satisfy
|
|
112
|
+
`0 ≤ max_dim ≤ complex.max_dim`.
|
|
113
|
+
threshold: optional positive relative rank tolerance. With `None`,
|
|
114
|
+
each boundary uses `max(n_{k-1}, n_k) * eps(float64)`, with
|
|
115
|
+
real simplex counts for that batch member. An explicit value
|
|
116
|
+
applies to boundary singular values, with zero absolute tolerance.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Dense complex → `(B, max_dim + 1)` Tensor of int64 Betti numbers.
|
|
120
|
+
Sparse complex → `(max_dim + 1,)` Tensor.
|
|
121
|
+
|
|
122
|
+
Notes:
|
|
123
|
+
Dense boundaries retain zero padding for batched SVD; it adds only
|
|
124
|
+
zero singular values and does not enter the per-member tolerance.
|
|
125
|
+
Sparse boundaries are materialized for the rank computation.
|
|
126
|
+
|
|
127
|
+
References:
|
|
128
|
+
Eckmann (1944); kernel of L_k equals harmonic k-forms.
|
|
129
|
+
Lim (2020), §4.
|
|
130
|
+
"""
|
|
131
|
+
if max_dim < 0:
|
|
132
|
+
raise ValueError(f"max_dim must be >= 0, got {max_dim}")
|
|
133
|
+
if max_dim > complex.max_dim:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"max_dim={max_dim} exceeds complex.max_dim={complex.max_dim}"
|
|
136
|
+
)
|
|
137
|
+
if threshold is not None and threshold <= 0:
|
|
138
|
+
raise ValueError(f"threshold must be > 0, got {threshold}")
|
|
139
|
+
|
|
140
|
+
if isinstance(complex, DenseSimplicialComplex):
|
|
141
|
+
B = complex.batch_size
|
|
142
|
+
ranks = torch.zeros(B, max_dim + 2, dtype=torch.int64,
|
|
143
|
+
device=complex.device)
|
|
144
|
+
for k in range(1, min(max_dim + 1, complex.max_dim) + 1):
|
|
145
|
+
boundary = complex.boundary(k).to(torch.float64)
|
|
146
|
+
relative_tol: float | torch.Tensor | None = threshold
|
|
147
|
+
if relative_tol is None:
|
|
148
|
+
real_size = torch.maximum(
|
|
149
|
+
complex.n_simplices(k - 1), complex.n_simplices(k),
|
|
150
|
+
)
|
|
151
|
+
relative_tol = real_size.to(torch.float64) * torch.finfo(
|
|
152
|
+
torch.float64,
|
|
153
|
+
).eps
|
|
154
|
+
ranks[:, k] = torch.linalg.matrix_rank(boundary, rtol=relative_tol)
|
|
155
|
+
counts = torch.stack([
|
|
156
|
+
complex.n_simplices(k) for k in range(max_dim + 1)
|
|
157
|
+
], dim=-1)
|
|
158
|
+
return counts - ranks[:, :-1] - ranks[:, 1:]
|
|
159
|
+
|
|
160
|
+
if isinstance(complex, SparseSimplicialComplex):
|
|
161
|
+
ranks = torch.zeros(max_dim + 2, dtype=torch.int64, device=complex.device)
|
|
162
|
+
for k in range(1, min(max_dim + 1, complex.max_dim) + 1):
|
|
163
|
+
boundary = complex.boundary(k, dtype=torch.float64).to_dense()
|
|
164
|
+
ranks[k] = torch.linalg.matrix_rank(boundary, rtol=threshold)
|
|
165
|
+
counts = torch.tensor([
|
|
166
|
+
complex.n_simplices(k) for k in range(max_dim + 1)
|
|
167
|
+
], dtype=torch.int64, device=complex.device)
|
|
168
|
+
return counts - ranks[:-1] - ranks[1:]
|
|
169
|
+
|
|
170
|
+
raise TypeError(
|
|
171
|
+
f"complex must be a Dense or Sparse SimplicialComplex; "
|
|
172
|
+
f"got {type(complex).__name__}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ============================================================
|
|
177
|
+
# Internal: dispatched paths
|
|
178
|
+
# ============================================================
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _hodge_dense(
|
|
182
|
+
complex: DenseSimplicialComplex, k: int,
|
|
183
|
+
) -> torch.Tensor:
|
|
184
|
+
"""Hodge Laplacian L_k on a dense complex, batched over members.
|
|
185
|
+
|
|
186
|
+
Takes a `DenseSimplicialComplex` and k with k <= complex.max_dim. Returns a
|
|
187
|
+
`(B, n_k_max, n_k_max)` tensor in the complex dtype. Padded simplex rows
|
|
188
|
+
and columns are zero.
|
|
189
|
+
"""
|
|
190
|
+
if k > complex.max_dim:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"k={k} exceeds complex.max_dim={complex.max_dim}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
B = complex.batch_size
|
|
196
|
+
n_k_max = (
|
|
197
|
+
complex.simplices_by_dim[k].shape[1]
|
|
198
|
+
if k in complex.simplices_by_dim else 0
|
|
199
|
+
)
|
|
200
|
+
L = torch.zeros(B, n_k_max, n_k_max,
|
|
201
|
+
device=complex.device, dtype=complex.dtype)
|
|
202
|
+
|
|
203
|
+
# The ∂_k^T ∂_k term. It is skipped for k = 0, since there are no
|
|
204
|
+
# (-1)-simplices.
|
|
205
|
+
if k > 0:
|
|
206
|
+
d_k = complex.boundary(k) # (B, n_{k-1}, n_k)
|
|
207
|
+
L = L + torch.matmul(d_k.mT, d_k)
|
|
208
|
+
|
|
209
|
+
# The ∂_{k+1} ∂_{k+1}^T term. It is skipped when the complex has no
|
|
210
|
+
# (k+1)-simplex table.
|
|
211
|
+
if (k + 1) in complex.simplices_by_dim:
|
|
212
|
+
d_kp1 = complex.boundary(k + 1) # (B, n_k, n_{k+1})
|
|
213
|
+
L = L + torch.matmul(d_kp1, d_kp1.mT)
|
|
214
|
+
|
|
215
|
+
return L
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _hodge_sparse(
|
|
219
|
+
complex: SparseSimplicialComplex, k: int,
|
|
220
|
+
dtype: torch.dtype = torch.float64,
|
|
221
|
+
) -> torch.Tensor:
|
|
222
|
+
"""Hodge Laplacian L_k on a sparse complex, returned as a dense (n_k, n_k).
|
|
223
|
+
|
|
224
|
+
Takes a `SparseSimplicialComplex` and k with k <= complex.max_dim. Each
|
|
225
|
+
boundary product is computed sparsely and densified once, then added into
|
|
226
|
+
the dense L_k, because the public return contract is dense.
|
|
227
|
+
|
|
228
|
+
`SparseSimplicialComplex` has no float dtype attribute, so the dtype is
|
|
229
|
+
passed in (default `torch.float64`). Both boundary operators are built at
|
|
230
|
+
this dtype so the matmul sees matching types.
|
|
231
|
+
"""
|
|
232
|
+
if k > complex.max_dim:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"k={k} exceeds complex.max_dim={complex.max_dim}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
n_k = complex.n_simplices(k)
|
|
238
|
+
device = complex.device
|
|
239
|
+
L = torch.zeros(n_k, n_k, device=device, dtype=dtype)
|
|
240
|
+
|
|
241
|
+
if k > 0:
|
|
242
|
+
d_k = complex.boundary(k, dtype=dtype) # sparse (n_{k-1}, n_k)
|
|
243
|
+
d_k_coo = d_k.to_sparse_coo().coalesce()
|
|
244
|
+
L = L + torch.sparse.mm(d_k_coo.mT, d_k_coo).to_dense()
|
|
245
|
+
|
|
246
|
+
if (k + 1) in complex.simplices_by_dim:
|
|
247
|
+
d_kp1 = complex.boundary(k + 1, dtype=dtype).to_sparse_coo().coalesce()
|
|
248
|
+
L = L + torch.sparse.mm(d_kp1, d_kp1.mT).to_dense()
|
|
249
|
+
|
|
250
|
+
return L
|