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,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Diffusion-map embedding (Coifman-Lafon 2006).
|
|
5
|
+
|
|
6
|
+
Given an undirected weighted graph `A`, the random walk on its nodes
|
|
7
|
+
has a transition matrix `P = D⁻¹ A`. The eigendecomposition of `P`
|
|
8
|
+
gives the long-time behavior of the random walk: for an eigenpair
|
|
9
|
+
(μ_j, φ_j), starting at node `x_i` and taking `t` random-walk steps,
|
|
10
|
+
the probability of arriving at `x_l` is
|
|
11
|
+
|
|
12
|
+
P^t(x_i, x_l) = π(x_l) Σ_j μ_j^t · φ_j(x_i) · φ_j(x_l),
|
|
13
|
+
|
|
14
|
+
where π(x_l) = d_l / vol(A), and the right eigenvectors φ_j are
|
|
15
|
+
orthonormal under the π-weighted inner product.
|
|
16
|
+
|
|
17
|
+
The **diffusion map** at time `t` lifts each node to a `k`-dimensional
|
|
18
|
+
Euclidean point whose pairwise distances mirror the diffusion-time `t`
|
|
19
|
+
random walk distance:
|
|
20
|
+
|
|
21
|
+
Ψ_t(x_i) = ( μ_1^t · φ_1(x_i), …, μ_k^t · φ_k(x_i) )
|
|
22
|
+
|
|
23
|
+
D_t(x_i, x_l)² = ‖ Ψ_t(x_i) − Ψ_t(x_l) ‖²
|
|
24
|
+
= Σ_j μ_j^{2t} · ( φ_j(x_i) − φ_j(x_l) )²
|
|
25
|
+
|
|
26
|
+
The trivial top eigenpair (μ_1 = 1, φ_1 = constant) is dropped; it
|
|
27
|
+
encodes only "we're somewhere on the graph", carrying no metric
|
|
28
|
+
information.
|
|
29
|
+
|
|
30
|
+
Implementation: take the full spectrum of the random-walk Laplacian
|
|
31
|
+
(`laplacian_eigenmaps` with `k = n`), drop its smallest eigenvalue, and
|
|
32
|
+
convert the rest to transition eigenvalues `μ_j = 1 − λ_j`. Keep the k
|
|
33
|
+
modes with the largest |μ_j|. The right eigenvectors are unit-norm
|
|
34
|
+
eigenvectors of L_sym scaled by D^{-1/2}, which makes their π-weighted
|
|
35
|
+
squared norm 1/vol(A). Multiplying by sqrt(vol(A)) puts them in the π
|
|
36
|
+
normalization. Last, each mode is raised to the signed integer power μ_j^t.
|
|
37
|
+
|
|
38
|
+
Each connected component with edges gives one zero Laplacian eigenvalue,
|
|
39
|
+
and those eigenvectors span the component indicators. Dropping index 0
|
|
40
|
+
removes only one direction from that span, so the others stay in the
|
|
41
|
+
embedding. Callers on disconnected graphs should mask by component
|
|
42
|
+
membership before reading cross-component distances.
|
|
43
|
+
|
|
44
|
+
References:
|
|
45
|
+
Coifman, R. R., Lafon, S. (2006). Diffusion maps. Applied and
|
|
46
|
+
Computational Harmonic Analysis 21(1):5–30. The defining paper.
|
|
47
|
+
Lafon, S., Lee, A. B. (2006). Diffusion maps and coarse-graining:
|
|
48
|
+
A unified framework for dimensionality reduction, graph
|
|
49
|
+
partitioning, and data set parameterization. IEEE PAMI 28(9):
|
|
50
|
+
1393–1403.
|
|
51
|
+
Nadler, B., Lafon, S., Coifman, R. R., Kevrekidis, I. G. (2006).
|
|
52
|
+
Diffusion maps, spectral clustering and reaction coordinates of
|
|
53
|
+
dynamical systems. Applied and Computational Harmonic Analysis
|
|
54
|
+
21(1):113–127.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import warnings
|
|
60
|
+
from numbers import Integral
|
|
61
|
+
|
|
62
|
+
import torch
|
|
63
|
+
|
|
64
|
+
from holonomy_lib._graph_utils import drop_self_loops
|
|
65
|
+
from holonomy_lib.spectral import laplacian as _L
|
|
66
|
+
from holonomy_lib.spectral.embedding import laplacian_eigenmaps
|
|
67
|
+
|
|
68
|
+
# The normalized Laplacian of a nonnegative undirected graph has spectrum
|
|
69
|
+
# in [0, 2] (Chung, Spectral Graph Theory, 1997, Lemma 1.7).
|
|
70
|
+
NORMALIZED_LAPLACIAN_SPECTRAL_RADIUS = 2.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def diffusion_map(
|
|
74
|
+
A: torch.Tensor,
|
|
75
|
+
k: int,
|
|
76
|
+
t: int = 1,
|
|
77
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
78
|
+
"""Diffusion-map embedding at time `t`.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
A: (B, n, n) symmetric non-negative weighted adjacency.
|
|
82
|
+
k: embedding dimension. Must satisfy `1 ≤ k ≤ n − 1` (we drop the
|
|
83
|
+
trivial null eigenvector, leaving `n − 1` non-trivial modes).
|
|
84
|
+
t: nonnegative integer number of random-walk steps. Larger `t`
|
|
85
|
+
emphasizes modes whose transition eigenvalues have magnitude
|
|
86
|
+
close to one. Negative eigenvalues retain their signed powers.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
transition_eigvals: (B, k) eigenvalues μ_j = 1 − λ_j of the
|
|
90
|
+
transition matrix P (descending in magnitude).
|
|
91
|
+
embedding: (B, n, k) diffusion coordinates `Ψ_t(x_i)`.
|
|
92
|
+
|
|
93
|
+
Notes:
|
|
94
|
+
Cost is dominated by the eigendecomposition of L_sym, O(B · n³).
|
|
95
|
+
Disconnected graphs emit a warning and retain all but one stationary
|
|
96
|
+
mode. Isolated nodes have no positive stationary mass; their coordinates
|
|
97
|
+
are set to zero, and diffusion distances involving them are undefined.
|
|
98
|
+
|
|
99
|
+
References:
|
|
100
|
+
Coifman, R. R., Lafon, S. (2006). Diffusion maps. Applied and
|
|
101
|
+
Computational Harmonic Analysis 21(1):5-30, §2.3-2.4.
|
|
102
|
+
"""
|
|
103
|
+
if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"A must be (..., n, n); got A.shape={tuple(A.shape)}"
|
|
106
|
+
)
|
|
107
|
+
if not A.is_floating_point():
|
|
108
|
+
raise ValueError("A must have a real floating-point dtype")
|
|
109
|
+
# Zero the diagonal so self-loops count neither as walk steps nor toward degree.
|
|
110
|
+
A = drop_self_loops(A)
|
|
111
|
+
n = A.shape[-1]
|
|
112
|
+
if k <= 0 or k > n - 1:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"k must satisfy 1 <= k <= n - 1 = {n - 1}, got k={k}"
|
|
115
|
+
)
|
|
116
|
+
# bool is a subclass of int, so without this check True would pass as t = 1.
|
|
117
|
+
if isinstance(t, bool) or not isinstance(t, Integral) or t < 0:
|
|
118
|
+
raise ValueError(f"diffusion time t must be a nonnegative integer, got {t}")
|
|
119
|
+
if (A < 0).any():
|
|
120
|
+
raise ValueError("A must have nonnegative weights")
|
|
121
|
+
|
|
122
|
+
# The k smallest Laplacian eigenvalues are not enough. A mode with a
|
|
123
|
+
# strongly negative transition eigenvalue (λ near 2 gives μ near -1)
|
|
124
|
+
# sits at the top of the spectrum, so take all of it and sort by |μ| below.
|
|
125
|
+
eigvals, eigvecs = laplacian_eigenmaps(
|
|
126
|
+
A, k=n, laplacian_type="random_walk",
|
|
127
|
+
)
|
|
128
|
+
degree = _L.degree(A)
|
|
129
|
+
# The normalized Laplacian's spectrum is in [0, 2] whatever the scale of A.
|
|
130
|
+
# Eigensolver roundoff grows with n, so the zero test uses eps * n * 2,
|
|
131
|
+
# which does not depend on the weights.
|
|
132
|
+
tolerance = torch.finfo(A.dtype).eps * n * NORMALIZED_LAPLACIAN_SPECTRAL_RADIUS
|
|
133
|
+
counts = (eigvals.abs() <= tolerance).sum(dim=-1)
|
|
134
|
+
# .item() syncs once, so the warning can be decided on the host.
|
|
135
|
+
n_near_zero = int(counts.max().item()) if counts.numel() else 0
|
|
136
|
+
has_isolates = bool((degree == 0).any())
|
|
137
|
+
# Each connected component with edges has one zero eigenvalue. Only index 0
|
|
138
|
+
# is dropped below, so the other zero modes stay in the embedding and the
|
|
139
|
+
# warning is raised.
|
|
140
|
+
if n_near_zero > 1 or has_isolates:
|
|
141
|
+
warnings.warn(
|
|
142
|
+
"diffusion_map: input graph is disconnected or has isolated nodes. "
|
|
143
|
+
"Only one stationary mode is dropped; interpret distances within "
|
|
144
|
+
"components with positive stationary mass. Isolated coordinates "
|
|
145
|
+
"are set to zero.",
|
|
146
|
+
stacklevel=2,
|
|
147
|
+
)
|
|
148
|
+
# Index 0 is the smallest eigenvalue, about 0. For a connected graph its
|
|
149
|
+
# eigenvector is constant, the trivial mode the docstring drops.
|
|
150
|
+
eigvals_non_null = eigvals[..., 1:]
|
|
151
|
+
eigvecs_non_null = eigvecs[..., 1:]
|
|
152
|
+
|
|
153
|
+
# L_rw = I − P, so each transition eigenvalue is μ = 1 − λ. Signs survive.
|
|
154
|
+
transition_eigvals = 1.0 - eigvals_non_null
|
|
155
|
+
# Keep the k modes with the largest |μ|. A kept mode may have μ < 0.
|
|
156
|
+
order = transition_eigvals.abs().argsort(dim=-1, descending=True)[..., :k]
|
|
157
|
+
transition_eigvals = transition_eigvals.gather(-1, order)
|
|
158
|
+
eigvecs_non_null = eigvecs_non_null.gather(
|
|
159
|
+
-1, order.unsqueeze(-2).expand(*A.shape[:-2], n, k),
|
|
160
|
+
)
|
|
161
|
+
# sqrt(vol(A)) brings each eigenvector to unit π-norm (see the module docstring).
|
|
162
|
+
volume_scale = degree.sum(dim=-1).sqrt().unsqueeze(-1).unsqueeze(-1)
|
|
163
|
+
embedding = eigvecs_non_null * volume_scale
|
|
164
|
+
# An isolated node has no stationary mass, so its row is zeroed. Its
|
|
165
|
+
# eigenvector there is not a diffusion coordinate.
|
|
166
|
+
embedding = torch.where((degree > 0).unsqueeze(-1), embedding, 0.0)
|
|
167
|
+
# Ψ_t: each mode scaled by μ_j^t. A negative μ_j keeps its sign for odd t.
|
|
168
|
+
embedding = embedding * transition_eigvals.pow(int(t)).unsqueeze(-2)
|
|
169
|
+
return transition_eigvals, embedding
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Effective resistance and commute-time distances on graphs.
|
|
5
|
+
|
|
6
|
+
For an undirected weighted graph, the **effective resistance**
|
|
7
|
+
`R(u, v)` between nodes u and v is the resistance the graph presents
|
|
8
|
+
when each edge is treated as a resistor of conductance `A_{ij}`
|
|
9
|
+
(weight) and we measure between nodes u and v. The classical formula
|
|
10
|
+
(Klein-Randić 1993, Doyle-Snell 1984) is
|
|
11
|
+
|
|
12
|
+
R(u, v) = (e_u − e_v)ᵀ L⁺ (e_u − e_v)
|
|
13
|
+
= L⁺[u, u] − 2 L⁺[u, v] + L⁺[v, v]
|
|
14
|
+
|
|
15
|
+
where `L⁺` is the Moore-Penrose pseudoinverse of the combinatorial
|
|
16
|
+
graph Laplacian `L = D − A`. The **commute time** is
|
|
17
|
+
|
|
18
|
+
C(u, v) = 2 · ‖A‖₁ / 2 · R(u, v) = vol(A) · R(u, v)
|
|
19
|
+
|
|
20
|
+
with `vol(A) = Σ_ij A_{ij}` the total edge weight (Chandra et al. 1996,
|
|
21
|
+
Lovász 1993). Both are metric distances on connected graphs.
|
|
22
|
+
|
|
23
|
+
These primitives are useful for:
|
|
24
|
+
- Graph kernels and similarity through the Laplacian pseudoinverse.
|
|
25
|
+
- Spectral sparsification (Spielman-Srivastava 2008 sample edges by
|
|
26
|
+
effective resistance).
|
|
27
|
+
- Random walk analysis (commute time = expected round-trip).
|
|
28
|
+
|
|
29
|
+
Different connected components have infinite resistance and commute
|
|
30
|
+
time, since no current or random walk can pass between them. Within a
|
|
31
|
+
component, commute time uses that component's volume. Isolated nodes
|
|
32
|
+
have zero distance to themselves and infinite distance to every other node.
|
|
33
|
+
|
|
34
|
+
References:
|
|
35
|
+
Doyle, P. G., Snell, J. L. (1984). Random Walks and Electric
|
|
36
|
+
Networks. Mathematical Association of America (Carus Math.
|
|
37
|
+
Monographs 22). The foundational electrical-network treatment.
|
|
38
|
+
Klein, D. J., Randić, M. (1993). Resistance distance. Journal of
|
|
39
|
+
Mathematical Chemistry 12(1):81–95. Coined the term "resistance
|
|
40
|
+
distance" and gave the closed-form in terms of L⁺.
|
|
41
|
+
Chandra, A. K., Raghavan, P., Ruzzo, W. L., Smolensky, R., Tiwari, P.
|
|
42
|
+
(1996). The electrical resistance of a graph captures its commute
|
|
43
|
+
and cover times. Computational Complexity 6(4):312–340. The
|
|
44
|
+
`C(u, v) = vol(A) · R(u, v)` identity.
|
|
45
|
+
Spielman, D. A., Srivastava, N. (2011). Graph sparsification by
|
|
46
|
+
effective resistances. SIAM Journal on Computing 40(6):1913–1926.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import torch
|
|
52
|
+
|
|
53
|
+
from holonomy_lib._graph_utils import drop_self_loops
|
|
54
|
+
from holonomy_lib.spectral import laplacian as _L
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def effective_resistance(A: torch.Tensor) -> torch.Tensor:
|
|
58
|
+
"""Pairwise effective resistance R(u, v) for a batched weighted graph.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
A: (B, n, n) symmetric non-negative weighted adjacency.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
R: (B, n, n) effective resistance. R[..., u, v] is the resistance
|
|
65
|
+
between nodes u and v under the electric-network interpretation
|
|
66
|
+
of A. Diagonal R[..., u, u] = 0 by definition. Pairs in different
|
|
67
|
+
connected components have infinite resistance.
|
|
68
|
+
|
|
69
|
+
Notes:
|
|
70
|
+
Each component is scaled by its maximum weighted degree. Adding the
|
|
71
|
+
projector onto component constants makes the scaled Laplacian positive
|
|
72
|
+
definite; Cholesky inversion and projector subtraction give its
|
|
73
|
+
pseudoinverse without a spectral rank cutoff. Connectivity uses
|
|
74
|
+
Boolean reachability by repeated matrix squaring, with O(log n)
|
|
75
|
+
sequential rounds and O(B · n³ log n) dense work. Each round doubles
|
|
76
|
+
the path lengths represented, so there are no node or batch loops.
|
|
77
|
+
|
|
78
|
+
References:
|
|
79
|
+
Klein-Randić (1993), Eq. (10) for the L⁺ expression.
|
|
80
|
+
Doyle-Snell (1984), §1.
|
|
81
|
+
"""
|
|
82
|
+
A = _checked_adjacency(A)
|
|
83
|
+
return _resistance_and_reachability(A)[0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def commute_time(A: torch.Tensor) -> torch.Tensor:
|
|
87
|
+
"""Pairwise commute time `C(u, v) = vol(component) · R(u, v)`.
|
|
88
|
+
|
|
89
|
+
Commute time is the expected number of steps a random walk takes
|
|
90
|
+
to go from u to v and back. The Chandra-Raghavan-Ruzzo-Smolensky-
|
|
91
|
+
Tiwari (1996) identity expresses it as the total edge weight
|
|
92
|
+
`vol(A) = Σ_{ij} A_{ij}` times the effective resistance on a connected
|
|
93
|
+
graph. On disconnected graphs the volume is that of the component
|
|
94
|
+
containing u and v. Self-loops are dropped before computing this volume.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
A: (B, n, n) symmetric non-negative weighted adjacency.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
C: (B, n, n) commute times. Cross-component pairs are infinite,
|
|
101
|
+
and the diagonal is zero, including isolated nodes.
|
|
102
|
+
|
|
103
|
+
References:
|
|
104
|
+
Chandra et al. (1996), Theorem 2.1.
|
|
105
|
+
Lovász (1993), §5.
|
|
106
|
+
"""
|
|
107
|
+
A = _checked_adjacency(A)
|
|
108
|
+
resistance, connected = _resistance_and_reachability(A)
|
|
109
|
+
degree = A.sum(dim=-1)
|
|
110
|
+
# Row u of `connected` marks the component of u, so this matmul sums the
|
|
111
|
+
# degrees over that component, which is its volume.
|
|
112
|
+
volume = (connected.to(A.dtype) @ degree.unsqueeze(-1)).squeeze(-1)
|
|
113
|
+
# Cross-component entries of `resistance` are inf. An isolated node has
|
|
114
|
+
# volume 0, and 0 * inf is NaN, so those entries are zeroed before the
|
|
115
|
+
# multiply. The where below puts inf back on the cross-component pairs.
|
|
116
|
+
finite_resistance = torch.where(connected, resistance, 0.0)
|
|
117
|
+
return torch.where(
|
|
118
|
+
connected, volume.unsqueeze(-1) * finite_resistance, float("inf"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ============================================================
|
|
123
|
+
# Internal helpers
|
|
124
|
+
# ============================================================
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _checked_adjacency(A: torch.Tensor) -> torch.Tensor:
|
|
128
|
+
"""Validate a dense adjacency and zero its diagonal.
|
|
129
|
+
|
|
130
|
+
Takes a dense `(..., n, n)` real floating-point tensor and returns it with
|
|
131
|
+
the diagonal zeroed. Raises ValueError if it is not square, not floating
|
|
132
|
+
point, or has a negative off-diagonal entry. Self-loops are dropped before
|
|
133
|
+
the sign check, so a negative diagonal entry is ignored rather than
|
|
134
|
+
rejected. Assumes A is symmetric. This function does not check that; the
|
|
135
|
+
Laplacian it feeds warns when it is not.
|
|
136
|
+
"""
|
|
137
|
+
if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
|
|
138
|
+
raise ValueError(f"A must be (..., n, n); got A.shape={tuple(A.shape)}")
|
|
139
|
+
if not A.is_floating_point():
|
|
140
|
+
raise ValueError("A must have a real floating-point dtype")
|
|
141
|
+
A = drop_self_loops(A)
|
|
142
|
+
if (A < 0).any():
|
|
143
|
+
raise ValueError("A must have nonnegative conductances")
|
|
144
|
+
return A
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resistance_and_reachability(
|
|
148
|
+
A: torch.Tensor,
|
|
149
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
150
|
+
"""Return resistance and connectivity for a validated, loop-free adjacency.
|
|
151
|
+
|
|
152
|
+
Takes the output of `_checked_adjacency`: a `(B, n, n)` symmetric,
|
|
153
|
+
non-negative adjacency with zero diagonal. Returns `(resistance,
|
|
154
|
+
connected)`, both `(B, n, n)`. `connected` is a Boolean mask of the pairs
|
|
155
|
+
in the same component, diagonal included. `resistance` is inf wherever
|
|
156
|
+
`connected` is False. Assumes symmetry; the Laplacian it builds warns
|
|
157
|
+
otherwise.
|
|
158
|
+
"""
|
|
159
|
+
n = A.shape[-1]
|
|
160
|
+
# A node always reaches itself, so the identity goes into the pattern.
|
|
161
|
+
connected = (A > 0) | torch.eye(n, device=A.device, dtype=torch.bool)
|
|
162
|
+
# An empty batch or a zero-node graph has nothing to invert. A.clone() is
|
|
163
|
+
# already the right, possibly empty, shape for the result.
|
|
164
|
+
if n == 0 or A.numel() == 0:
|
|
165
|
+
return A.clone(), connected
|
|
166
|
+
# A simple path has at most n - 1 edges. Each squaring doubles the path
|
|
167
|
+
# length the mask covers, and all graphs in the batch square together, so
|
|
168
|
+
# bit_length(n - 1) rounds cover every pair.
|
|
169
|
+
for _ in range((n - 1).bit_length()):
|
|
170
|
+
paths = connected.to(A.dtype)
|
|
171
|
+
connected = connected | (paths @ paths > 0)
|
|
172
|
+
laplacian = _L.combinatorial(A)
|
|
173
|
+
degree = A.sum(dim=-1)
|
|
174
|
+
# Each node takes the largest degree in its own component. Entries outside
|
|
175
|
+
# the component are zeroed before the max, so they cannot win it.
|
|
176
|
+
component_scale = torch.where(connected, degree.unsqueeze(-2), 0.0).amax(-1)
|
|
177
|
+
# Every node in a component gets the same scale, so dividing rows by it
|
|
178
|
+
# keeps the matrix symmetric. An isolate has degree 0, and dividing its
|
|
179
|
+
# zero Laplacian row by 0 would give NaN, so its scale is swapped for 1.
|
|
180
|
+
component_scale = torch.where(
|
|
181
|
+
component_scale > 0, component_scale, torch.ones_like(component_scale),
|
|
182
|
+
).unsqueeze(-1)
|
|
183
|
+
# Projector onto the constant vectors of each component, P = 1 1^T / |c|.
|
|
184
|
+
# Adding it turns the zero eigenvalue of each component into 1, which makes
|
|
185
|
+
# the scaled Laplacian positive definite.
|
|
186
|
+
projector = connected.to(A.dtype) / connected.sum(dim=-1, keepdim=True)
|
|
187
|
+
grounded = laplacian / component_scale + projector
|
|
188
|
+
# grounded^{-1} - P is the pseudoinverse of L / s. Dividing by s undoes the
|
|
189
|
+
# scaling, since (L / s)^+ = s L^+.
|
|
190
|
+
inverse = (
|
|
191
|
+
torch.cholesky_inverse(torch.linalg.cholesky(grounded)) - projector
|
|
192
|
+
) / component_scale
|
|
193
|
+
# Expanding (e_u - e_v)^T L^+ (e_u - e_v) gives the two diagonal entries
|
|
194
|
+
# minus twice the off-diagonal one.
|
|
195
|
+
diagonal = inverse.diagonal(dim1=-2, dim2=-1)
|
|
196
|
+
resistance = diagonal.unsqueeze(-1) + diagonal.unsqueeze(-2) - 2.0 * inverse
|
|
197
|
+
# The inverse is block diagonal, so a cross-component entry is 0 and its
|
|
198
|
+
# resistance comes out finite. Those pairs have no path, so they get inf.
|
|
199
|
+
return torch.where(connected, resistance, float("inf")), connected
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Spectral embedding via Laplacian eigenmaps, batched-first, GPU-native.
|
|
5
|
+
|
|
6
|
+
The Laplacian eigenmap (Belkin-Niyogi 2003) embeds a graph into R^k by
|
|
7
|
+
taking the bottom-k eigenvectors of a graph Laplacian. Each row of the
|
|
8
|
+
resulting (n, k) matrix is the k-dimensional coordinate of one node.
|
|
9
|
+
|
|
10
|
+
This module provides a single primitive
|
|
11
|
+
|
|
12
|
+
laplacian_eigenmaps(A, k, laplacian_type)
|
|
13
|
+
|
|
14
|
+
that returns the bottom-k eigenvalue/eigenvector pairs of the chosen
|
|
15
|
+
Laplacian, with the eigenvectors sorted by eigenvalue ascending. We
|
|
16
|
+
do **not** automatically drop the trivial null eigenvector; that
|
|
17
|
+
choice depends on the use case (it should be dropped for connected
|
|
18
|
+
unsigned graphs but kept for signed Laplacians, where the null
|
|
19
|
+
eigenvector encodes the balance partition; Kunegis 2010). The caller
|
|
20
|
+
decides.
|
|
21
|
+
|
|
22
|
+
This routine uses dense `torch.linalg.eigh`, with O(n³) cost. For sparse
|
|
23
|
+
operators, use `holonomy_lib.algebra.lanczos_eigsh` or `eigsh_sparse_sa`.
|
|
24
|
+
|
|
25
|
+
References:
|
|
26
|
+
Belkin, M., Niyogi, P. (2003). Laplacian eigenmaps for dimensionality
|
|
27
|
+
reduction and data representation. Neural Computation 15(6):1373–1396.
|
|
28
|
+
von Luxburg, U. (2007). A tutorial on spectral clustering. Statistics
|
|
29
|
+
and Computing 17(4):395–416, discussion of which Laplacian to use.
|
|
30
|
+
Coifman, R. R., Lafon, S. (2006). Diffusion maps. Applied and
|
|
31
|
+
Computational Harmonic Analysis 21(1):5–30, related embedding.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Literal
|
|
37
|
+
|
|
38
|
+
import torch
|
|
39
|
+
|
|
40
|
+
from holonomy_lib.spectral import laplacian as _L
|
|
41
|
+
|
|
42
|
+
LaplacianType = Literal[
|
|
43
|
+
"combinatorial", "symmetric_normalized", "random_walk", "signed",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def laplacian_eigenmaps(
|
|
48
|
+
A: torch.Tensor,
|
|
49
|
+
k: int,
|
|
50
|
+
laplacian_type: LaplacianType = "symmetric_normalized",
|
|
51
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
52
|
+
"""Bottom-k Laplacian eigenmap embedding of a (batched) graph.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
A: (B, n, n) weighted adjacency. Symmetric for the supported
|
|
56
|
+
Laplacian variants (combinatorial, symmetric_normalized,
|
|
57
|
+
random_walk, signed).
|
|
58
|
+
k: number of eigenvectors to return. Must satisfy 1 ≤ k ≤ n.
|
|
59
|
+
laplacian_type: which Laplacian to diagonalize.
|
|
60
|
+
- "symmetric_normalized" (default). Belkin-Niyogi (2003) solve
|
|
61
|
+
the generalized problem L f = λ D f, which is the
|
|
62
|
+
"random_walk" option below; L_sym has the same eigenvalues.
|
|
63
|
+
- "combinatorial" (unnormalized)
|
|
64
|
+
- "random_walk" (non-symmetric; the returned eigenvectors are
|
|
65
|
+
the L_rw eigenvectors, obtained by D^{−1/2}-scaling the
|
|
66
|
+
L_sym eigenvectors so they diagonalize L_rw on the right;
|
|
67
|
+
the scaling is identity on isolated-node coordinates)
|
|
68
|
+
- "signed" (Kunegis 2010)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
eigenvalues: (B, k), sorted ascending.
|
|
72
|
+
eigenvectors: (B, n, k), column j is the eigenvector for
|
|
73
|
+
eigenvalues[..., j].
|
|
74
|
+
|
|
75
|
+
Notes:
|
|
76
|
+
The trivial null eigenvector (eigenvalue ≈ 0 for connected
|
|
77
|
+
unsigned graphs) is NOT automatically dropped. Drop it explicitly
|
|
78
|
+
with `eigvecs[..., 1:]` when you want the embedding to exclude
|
|
79
|
+
it. For signed Laplacians, do NOT drop it; it encodes the
|
|
80
|
+
balance partition (Kunegis 2010, Thm 3.4).
|
|
81
|
+
|
|
82
|
+
References:
|
|
83
|
+
Belkin-Niyogi (2003), §2, Laplacian eigenmap.
|
|
84
|
+
von Luxburg (2007), §5, practical considerations.
|
|
85
|
+
"""
|
|
86
|
+
if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"A must be (..., n, n); got A.shape={tuple(A.shape)}"
|
|
89
|
+
)
|
|
90
|
+
n = A.shape[-1]
|
|
91
|
+
if k <= 0 or k > n:
|
|
92
|
+
raise ValueError(f"k must satisfy 1 <= k <= n={n}, got k={k}")
|
|
93
|
+
|
|
94
|
+
if laplacian_type == "combinatorial":
|
|
95
|
+
L = _L.combinatorial(A)
|
|
96
|
+
eigvals, eigvecs = torch.linalg.eigh(L)
|
|
97
|
+
elif laplacian_type == "symmetric_normalized":
|
|
98
|
+
L = _L.symmetric_normalized(A)
|
|
99
|
+
eigvals, eigvecs = torch.linalg.eigh(L)
|
|
100
|
+
elif laplacian_type == "signed":
|
|
101
|
+
L = _L.signed(A)
|
|
102
|
+
eigvals, eigvecs = torch.linalg.eigh(L)
|
|
103
|
+
elif laplacian_type == "random_walk":
|
|
104
|
+
# L_rw is similar (in the linear-algebra sense) to L_sym via
|
|
105
|
+
# L_rw = D^{−1/2} L_sym D^{1/2}. So if u_i is the L_sym
|
|
106
|
+
# eigenvector for λ_i, then v_i = D^{−1/2} u_i is the L_rw
|
|
107
|
+
# right-eigenvector for the same λ_i. (Equivalently, v_i is the
|
|
108
|
+
# solution to the generalized eigenproblem L v = λ D v.)
|
|
109
|
+
# L_rw is not symmetric, so eigh cannot take it directly. Solving
|
|
110
|
+
# the symmetric L_sym and mapping its eigenvectors over keeps the
|
|
111
|
+
# real, sorted eigh output
|
|
112
|
+
L_sym = _L.symmetric_normalized(A)
|
|
113
|
+
eigvals, u = torch.linalg.eigh(L_sym) # (B, n), (B, n, n)
|
|
114
|
+
d = _L.degree(A, signed=False)
|
|
115
|
+
# an isolated node has a diagonal entry of one in both L_rw and
|
|
116
|
+
# L_sym. Using degree 1 there makes the similarity transform the
|
|
117
|
+
# identity on that coordinate, so its eigenvector survives instead
|
|
118
|
+
# of being multiplied by a zero (pseudoinverse) degree factor
|
|
119
|
+
safe_degree = torch.where(d > 0, d, 1.0)
|
|
120
|
+
d_inv_sqrt = safe_degree.rsqrt()
|
|
121
|
+
eigvecs = u * d_inv_sqrt.unsqueeze(dim=-1) # broadcast over columns
|
|
122
|
+
else:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"laplacian_type must be one of "
|
|
125
|
+
f"'combinatorial', 'symmetric_normalized', 'random_walk', 'signed'; "
|
|
126
|
+
f"got {laplacian_type!r}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# eigh already returns ascending order, so the bottom k are the first k.
|
|
130
|
+
# contiguous() so the caller gets owning tensors, not strided slices
|
|
131
|
+
return eigvals[..., :k].contiguous(), eigvecs[..., :, :k].contiguous()
|