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,184 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Regularized (Tikhonov / ridge) least squares with solver dispatch.
|
|
5
|
+
|
|
6
|
+
synoros-substrate hand-rolls this one convex problem four inconsistent
|
|
7
|
+
ways (normal-equation `solve`, `lstsq`, sparse LSQR, dense pseudoinverse),
|
|
8
|
+
so it lives here once:
|
|
9
|
+
|
|
10
|
+
min_x ‖A x − b‖² + λ ‖x‖²
|
|
11
|
+
|
|
12
|
+
Three solution paths, one interface:
|
|
13
|
+
|
|
14
|
+
- `"cholesky"`: normal equations `(AᵀA + λI) x = Aᵀb` via Cholesky
|
|
15
|
+
factorization + triangular solve (CONVENTIONS §3.3). Exact, one
|
|
16
|
+
factorization, the default for dense problems. It squares
|
|
17
|
+
the condition number, so for ill-conditioned `A` with tiny `λ`
|
|
18
|
+
prefer `"lsqr"`.
|
|
19
|
+
- `"cg"`: conjugate gradient on the normal operator `v ↦ Aᵀ(Av) + λv`.
|
|
20
|
+
`AᵀA` is never materialized. For very large dense problems where
|
|
21
|
+
the `(n, n)` Gram matrix itself is the memory bottleneck.
|
|
22
|
+
- `"lsqr"`: `algebra.solvers.lsqr` with `damp = √λ` (the damped
|
|
23
|
+
least-squares equivalence, Paige-Saunders 1982 §1). Works directly
|
|
24
|
+
on `A` (condition number not squared) and on sparse `A`.
|
|
25
|
+
|
|
26
|
+
`method="auto"` dispatch rule (benchmark-derived, see
|
|
27
|
+
`notes/benchmark_2026-07-08_solvers.md`): sparse `A` → `"lsqr"`
|
|
28
|
+
(Cholesky needs a dense Gram matrix); dense `A` → `"cholesky"`. In the
|
|
29
|
+
sweep, Cholesky dominated the iterative methods by 3-24x up to n=256
|
|
30
|
+
and stayed within noise of them (±25%) at n=1024-2048; its runtime is
|
|
31
|
+
also condition-number-independent (fixed flops), while CG/LSQR
|
|
32
|
+
iteration counts grow with `√cond`. No crossing justifies a size
|
|
33
|
+
threshold, so there is none to tune.
|
|
34
|
+
|
|
35
|
+
References:
|
|
36
|
+
Tikhonov, A. N. (1963). Solution of incorrectly formulated problems
|
|
37
|
+
and the regularization method. Soviet Math. Dokl. 4:1035-1038.
|
|
38
|
+
Björck, Å. (1996). Numerical Methods for Least Squares Problems.
|
|
39
|
+
SIAM, §2.7 (regularized LS, normal-equations vs damped-LSQR
|
|
40
|
+
trade-offs).
|
|
41
|
+
Paige-Saunders (1982). ACM TOMS 8(1):43-71, §1 (LSQR's damp
|
|
42
|
+
parameter solves the ridge problem).
|
|
43
|
+
Boyd, S., Vandenberghe, L. (2004). Convex Optimization. Cambridge
|
|
44
|
+
UP, §6.3.2 (ridge/Tikhonov as a convex regularized approximation
|
|
45
|
+
problem).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import math
|
|
51
|
+
from typing import Literal
|
|
52
|
+
|
|
53
|
+
import torch
|
|
54
|
+
|
|
55
|
+
from holonomy_lib.algebra.solvers import cg, lsqr
|
|
56
|
+
|
|
57
|
+
_SPARSE_LAYOUTS = (
|
|
58
|
+
torch.sparse_coo, torch.sparse_csr, torch.sparse_csc,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
Method = Literal["auto", "cholesky", "cg", "lsqr"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def regularized_lstsq(
|
|
65
|
+
A: torch.Tensor,
|
|
66
|
+
b: torch.Tensor,
|
|
67
|
+
lam: float,
|
|
68
|
+
method: Method = "auto",
|
|
69
|
+
rtol: float | None = None,
|
|
70
|
+
maxiter: int | None = None,
|
|
71
|
+
) -> torch.Tensor:
|
|
72
|
+
"""Solve the ridge problem `min_x ‖A x − b‖² + λ ‖x‖²`.
|
|
73
|
+
|
|
74
|
+
Strictly convex for `λ > 0` (unique solution regardless of `A`'s
|
|
75
|
+
rank); for `λ = 0` reduces to ordinary least squares and requires
|
|
76
|
+
full column rank on the `"cholesky"` path (`"lsqr"` handles the
|
|
77
|
+
rank-deficient case, converging to the minimum-norm solution).
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
A: dense `(m, n)` / `(B, m, n)`, or sparse 2-D `(m, n)`
|
|
81
|
+
(COO/CSR/CSC; dispatched to `"lsqr"`/`"cg"`; `"cholesky"`
|
|
82
|
+
rejects sparse input).
|
|
83
|
+
b: `(B, m)` or `(m,)` observations.
|
|
84
|
+
lam: ridge weight `λ ≥ 0`.
|
|
85
|
+
method: `"auto"` (see module docstring for the dispatch rule),
|
|
86
|
+
`"cholesky"`, `"cg"`, or `"lsqr"`.
|
|
87
|
+
rtol: convergence tolerance for the iterative paths (forwarded to
|
|
88
|
+
`cg` as `rtol`, to `lsqr` as `atol`/`btol`); ignored by
|
|
89
|
+
`"cholesky"`. Default `sqrt(eps(dtype))` (derived).
|
|
90
|
+
maxiter: iteration cap for the iterative paths; ignored by
|
|
91
|
+
`"cholesky"`.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
`x`: `(B, n)` (or `(n,)` for unbatched `b`), minimizing the ridge
|
|
95
|
+
objective.
|
|
96
|
+
|
|
97
|
+
References:
|
|
98
|
+
Tikhonov (1963); Björck (1996) §2.7; Paige-Saunders (1982) §1.
|
|
99
|
+
See the module docstring.
|
|
100
|
+
"""
|
|
101
|
+
if method not in ("auto", "cholesky", "cg", "lsqr"):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"method must be one of 'auto', 'cholesky', 'cg', 'lsqr'; "
|
|
104
|
+
f"got method={method!r}"
|
|
105
|
+
)
|
|
106
|
+
if not isinstance(A, torch.Tensor):
|
|
107
|
+
raise ValueError(f"A must be a torch.Tensor; got {type(A)}")
|
|
108
|
+
if lam < 0:
|
|
109
|
+
raise ValueError(f"lam must be >= 0; got {lam}")
|
|
110
|
+
|
|
111
|
+
sparse = A.layout in _SPARSE_LAYOUTS
|
|
112
|
+
# Cholesky forms a dense Gram matrix, so under "auto" sparse A never
|
|
113
|
+
# takes that route.
|
|
114
|
+
if method == "auto":
|
|
115
|
+
method = "lsqr" if sparse else "cholesky"
|
|
116
|
+
if sparse and method == "cholesky":
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"method='cholesky' requires dense A (it factorizes the "
|
|
119
|
+
"dense Gram matrix); use 'lsqr' or 'cg' for sparse input"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if method == "cholesky":
|
|
123
|
+
if A.ndim < 2:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"dense A must be (..., m, n); got A.shape={tuple(A.shape)}"
|
|
126
|
+
)
|
|
127
|
+
n = A.shape[-1]
|
|
128
|
+
# A lone right-hand side (m,) is lifted to (1, m) so the batched code
|
|
129
|
+
# below handles it. The batch axis is dropped again at the return, but
|
|
130
|
+
# only when A is unbatched too: a batched A of size 1 has to keep its
|
|
131
|
+
# batch axis, or the output shape would depend on the batch size.
|
|
132
|
+
squeeze = b.ndim == 1 and A.ndim == 2
|
|
133
|
+
b2 = b.unsqueeze(0) if squeeze else b
|
|
134
|
+
gram = A.mT @ A # (..., n, n)
|
|
135
|
+
eye = torch.eye(n, dtype=A.dtype, device=A.device)
|
|
136
|
+
# λI makes S positive definite even when A is rank deficient. Cholesky
|
|
137
|
+
# can therefore only fail for λ = 0 with a singular AᵀA, which is the
|
|
138
|
+
# full-column-rank requirement in the docstring.
|
|
139
|
+
S = gram + lam * eye
|
|
140
|
+
Atb = (A.mT @ b2.unsqueeze(-1)).squeeze(-1) # (..., n) or (B, n)
|
|
141
|
+
L = torch.linalg.cholesky(S)
|
|
142
|
+
x = torch.cholesky_solve(Atb.unsqueeze(-1), L).squeeze(-1)
|
|
143
|
+
return x.squeeze(0) if squeeze and x.ndim > 1 else x
|
|
144
|
+
|
|
145
|
+
if method == "lsqr":
|
|
146
|
+
# Damped LSQR minimizes ‖Ax − b‖² + damp²‖x‖², so damp = √λ gives the
|
|
147
|
+
# ridge objective exactly.
|
|
148
|
+
return lsqr(
|
|
149
|
+
A, b, damp=math.sqrt(lam),
|
|
150
|
+
atol=rtol, btol=rtol, maxiter=maxiter,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# method == "cg": CG on the normal operator v ↦ Aᵀ(Av) + λv. The
|
|
154
|
+
# Gram matrix is never materialized; `A` may be dense or sparse.
|
|
155
|
+
if sparse:
|
|
156
|
+
# One COO copy of A and one of Aᵀ, built once here, so every matvec
|
|
157
|
+
# inside cg is a sparse mm rather than a fresh conversion.
|
|
158
|
+
A_coo = (A if A.layout == torch.sparse_coo
|
|
159
|
+
else A.to_sparse_coo()).coalesce()
|
|
160
|
+
At = A_coo.t().coalesce()
|
|
161
|
+
|
|
162
|
+
def normal_op(v: torch.Tensor) -> torch.Tensor:
|
|
163
|
+
Av = torch.sparse.mm(A_coo, v.mT).mT
|
|
164
|
+
return torch.sparse.mm(At, Av.mT).mT + lam * v
|
|
165
|
+
|
|
166
|
+
def rproject(u: torch.Tensor) -> torch.Tensor:
|
|
167
|
+
return torch.sparse.mm(At, u.mT).mT
|
|
168
|
+
else:
|
|
169
|
+
|
|
170
|
+
def normal_op(v: torch.Tensor) -> torch.Tensor:
|
|
171
|
+
Av = (A @ v.unsqueeze(-1)).squeeze(-1)
|
|
172
|
+
return (A.mT @ Av.unsqueeze(-1)).squeeze(-1) + lam * v
|
|
173
|
+
|
|
174
|
+
def rproject(u: torch.Tensor) -> torch.Tensor:
|
|
175
|
+
return (A.mT @ u.unsqueeze(-1)).squeeze(-1)
|
|
176
|
+
|
|
177
|
+
# A lone b is lifted to (1, m) and squeezed back at the end, as in the
|
|
178
|
+
# cholesky path, again only when A has no batch axis of its own. Sparse A
|
|
179
|
+
# is always a single 2-D matrix. The right-hand side of the normal
|
|
180
|
+
# equations is Aᵀb.
|
|
181
|
+
squeeze = b.ndim == 1 and A.ndim == 2
|
|
182
|
+
b2 = b.unsqueeze(0) if squeeze else b
|
|
183
|
+
x = cg(normal_op, rproject(b2), rtol=rtol, maxiter=maxiter)
|
|
184
|
+
return x.squeeze(0) if squeeze else x
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Discrete graph curvature and edge-weight Ricci flow.
|
|
5
|
+
|
|
6
|
+
Available primitives:
|
|
7
|
+
ollivier_ricci_curvature: Ollivier-Ricci curvature on every node pair,
|
|
8
|
+
from entropic (Sinkhorn) transport.
|
|
9
|
+
forman_ricci_simple: weighted graph Forman curvature with unit vertices.
|
|
10
|
+
forman_ricci_augmented: unit-weight Forman curvature with triangle terms.
|
|
11
|
+
discrete_ricci_flow: curvature-driven edge-weight updates.
|
|
12
|
+
ricci_flow_with_surgery: flow with threshold-based edge removal.
|
|
13
|
+
|
|
14
|
+
References:
|
|
15
|
+
Ollivier (2009), Ricci curvature of Markov chains on metric spaces.
|
|
16
|
+
Sreejith et al. (2016), Forman curvature for complex networks.
|
|
17
|
+
Samal et al. (2018), Comparative analysis of two discretizations of
|
|
18
|
+
Ricci curvature for complex networks.
|
|
19
|
+
Ni et al. (2019), Community detection on networks with Ricci flow.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from holonomy_lib.discrete_geometry.forman import (
|
|
23
|
+
forman_ricci_augmented,
|
|
24
|
+
forman_ricci_simple,
|
|
25
|
+
)
|
|
26
|
+
from holonomy_lib.discrete_geometry.ricci import (
|
|
27
|
+
discrete_ricci_flow,
|
|
28
|
+
ollivier_ricci_curvature,
|
|
29
|
+
ricci_flow_with_surgery,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"discrete_ricci_flow",
|
|
34
|
+
"forman_ricci_augmented",
|
|
35
|
+
"forman_ricci_simple",
|
|
36
|
+
"ollivier_ricci_curvature",
|
|
37
|
+
"ricci_flow_with_surgery",
|
|
38
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Forman-Ricci curvature on batched graphs.
|
|
5
|
+
|
|
6
|
+
Forman (2003) defined a combinatorial Ricci curvature for general CW
|
|
7
|
+
complexes that specializes to a very simple per-edge formula on graphs.
|
|
8
|
+
Unlike Ollivier-Ricci (`ricci.ollivier_ricci_curvature`), no optimal-
|
|
9
|
+
transport solve is needed: each edge's curvature is a closed-form
|
|
10
|
+
function of the local degrees and, for the augmented form, the
|
|
11
|
+
triangles on that edge. Both forms are cheaper than a Sinkhorn solve
|
|
12
|
+
per edge.
|
|
13
|
+
|
|
14
|
+
Forman-Ricci is less geometrically faithful than Ollivier on weighted
|
|
15
|
+
metric measure spaces, but it tracks the same qualitative bottleneck
|
|
16
|
+
structure: edges bridging two clusters get negative curvature, edges
|
|
17
|
+
embedded in dense sub-communities get positive curvature. For networks
|
|
18
|
+
too large for Ollivier, Forman-Ricci is a common substitute (Sreejith
|
|
19
|
+
et al. 2016).
|
|
20
|
+
|
|
21
|
+
Cost note: the implementations here are dense. The simple form is
|
|
22
|
+
O(B · n²) and the augmented form is O(B · n³), the cost of the
|
|
23
|
+
triangle-count matmul A @ A. A sparse edge-list path would scale with
|
|
24
|
+
|E| instead of n², but it is not implemented. Memory is O(B · n²) for
|
|
25
|
+
the dense tensors.
|
|
26
|
+
|
|
27
|
+
Two forms are exposed:
|
|
28
|
+
|
|
29
|
+
forman_ricci_simple(A)
|
|
30
|
+
κ_F(u, v) = w_e · ( w_u/w_e + w_v/w_e
|
|
31
|
+
- Σ_{e_u ≠ e} w_u/√(w_e · w_{e_u})
|
|
32
|
+
- Σ_{e_v ≠ e} w_v/√(w_e · w_{e_v}) )
|
|
33
|
+
The original CW-complex specialization without 2-faces (Sreejith
|
|
34
|
+
et al. 2016, eq. 1). On unweighted simple graphs with node
|
|
35
|
+
weights = 1 this collapses to `4 - deg(u) - deg(v)`.
|
|
36
|
+
|
|
37
|
+
forman_ricci_augmented(A)
|
|
38
|
+
For binary adjacency only:
|
|
39
|
+
κ_F^aug(u, v) = κ_F_simple(u, v) + 3 · #triangles_through(u, v)
|
|
40
|
+
Adds the contribution from 2-faces (triangles incident to the
|
|
41
|
+
edge), restoring the "augmented" form that more closely tracks
|
|
42
|
+
Ollivier-Ricci on dense substructures (Samal et al. 2018,
|
|
43
|
+
§"Augmented Forman curvature").
|
|
44
|
+
|
|
45
|
+
References:
|
|
46
|
+
Forman, R. (2003). Bochner's method for cell complexes and
|
|
47
|
+
combinatorial Ricci curvature. Discrete & Computational Geometry
|
|
48
|
+
29:323–374.
|
|
49
|
+
Sreejith, R. P., Mohanraj, K., Jost, J., Saucan, E., Samal, A.
|
|
50
|
+
(2016). Forman curvature for complex networks. Journal of
|
|
51
|
+
Statistical Mechanics: Theory and Experiment 2016:063206. Eq. 1
|
|
52
|
+
(no 2-face contribution).
|
|
53
|
+
Samal, A., Sreejith, R. P., Gu, J., Liu, S., Saucan, E., Jost, J.
|
|
54
|
+
(2018). Comparative analysis of two discretizations of Ricci
|
|
55
|
+
curvature for complex networks. Scientific Reports 8:8650.
|
|
56
|
+
Equations 9–10 give the weighted and unit-weight specializations.
|
|
57
|
+
Sandhu, R., Georgiou, T., Tannenbaum, A. (2015). Ricci curvature: An
|
|
58
|
+
economic indicator for market fragility and systemic risk.
|
|
59
|
+
Science Advances 2(5):e1501495. Application demonstrating Forman's
|
|
60
|
+
qualitative agreement with Ollivier at lower compute cost.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import warnings
|
|
66
|
+
|
|
67
|
+
import torch
|
|
68
|
+
|
|
69
|
+
from holonomy_lib._graph_utils import drop_self_loops
|
|
70
|
+
|
|
71
|
+
# Unit vertex weights in the graph specialization of Sreejith et al.
|
|
72
|
+
# (2016), equation 1. Edge weights alone do not encode general vertex
|
|
73
|
+
# weights, so the public functions fix every vertex weight to one.
|
|
74
|
+
FORMAN_NODE_WEIGHT_DEFAULT: float = 1.0
|
|
75
|
+
|
|
76
|
+
# Samal et al. (2018), equations 9–10: with unit vertex, edge, and face
|
|
77
|
+
# weights, an incident triangular face contributes one face term and
|
|
78
|
+
# removes two negative parallel-edge contributions, giving 1 + 2 = 3.
|
|
79
|
+
FORMAN_TRIANGLE_CONTRIBUTION: float = 3.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def forman_ricci_simple(A: torch.Tensor) -> torch.Tensor:
|
|
83
|
+
"""Forman-Ricci curvature on every edge of a batched weighted graph.
|
|
84
|
+
|
|
85
|
+
Implements the Sreejith et al. (2016) form without 2-face
|
|
86
|
+
contributions:
|
|
87
|
+
|
|
88
|
+
κ_F(u, v) = w_e · ( w_u/w_e + w_v/w_e
|
|
89
|
+
- Σ_{e' ∋ u, e' ≠ e} w_u / √(w_e · w_{e'})
|
|
90
|
+
- Σ_{e' ∋ v, e' ≠ e} w_v / √(w_e · w_{e'}) )
|
|
91
|
+
|
|
92
|
+
For unweighted simple graphs (w_u = w_v = 1, w_e ∈ {0, 1}) this
|
|
93
|
+
collapses to `4 - deg(u) - deg(v)`: positive on edges in sparse
|
|
94
|
+
contexts (low degree), negative on edges anchored in dense hubs.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
A: (B, n, n) symmetric weighted adjacency. Non-negative weights.
|
|
98
|
+
Zero off-diagonal means no edge. Diagonal is ignored.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
κ_F: (B, n, n) curvature tensor. κ_F[..., u, v] is the Forman-
|
|
102
|
+
Ricci curvature of edge (u, v) when A[..., u, v] > 0, and 0
|
|
103
|
+
otherwise. The diagonal is set to 0 by convention, since Forman
|
|
104
|
+
curvature is defined per edge and a self-loop is not an edge here.
|
|
105
|
+
|
|
106
|
+
Notes:
|
|
107
|
+
Cost is O(B · n²). An edge-list variant would cost O(B · |E|) on
|
|
108
|
+
sparse graphs, but it is not implemented.
|
|
109
|
+
|
|
110
|
+
References:
|
|
111
|
+
Sreejith et al. (2016), eq. 1.
|
|
112
|
+
Forman (2003), §"Combinatorial Ricci curvature for graphs".
|
|
113
|
+
"""
|
|
114
|
+
_validate_adjacency(A)
|
|
115
|
+
A = drop_self_loops(A)
|
|
116
|
+
*batch, n, _ = A.shape
|
|
117
|
+
|
|
118
|
+
# Edge positions as a 0/1 tensor. Staying in tensor form keeps Python
|
|
119
|
+
# from branching on tensor values, which would force a device-to-host
|
|
120
|
+
# sync. Non-edges contribute zero to every sum below.
|
|
121
|
+
edge_mask = (A != 0).to(A.dtype) # (B, n, n)
|
|
122
|
+
|
|
123
|
+
# Node weights are fixed at 1 (Sreejith et al. 2016). Weighted nodes
|
|
124
|
+
# would need these passed in; for now they are constants.
|
|
125
|
+
w_u = FORMAN_NODE_WEIGHT_DEFAULT
|
|
126
|
+
w_v = FORMAN_NODE_WEIGHT_DEFAULT
|
|
127
|
+
|
|
128
|
+
# Replace non-edge zeros with ones before any division. rsqrt(0) is inf,
|
|
129
|
+
# and inf times the zero mask is NaN. The mask zeroes the non-edge
|
|
130
|
+
# entries of kappa afterward.
|
|
131
|
+
safe_we = torch.where(edge_mask > 0, A, torch.ones_like(A))
|
|
132
|
+
|
|
133
|
+
# The two diagonal terms of the formula, w_u / w_e + w_v / w_e.
|
|
134
|
+
diagonal_term = (w_u + w_v) / safe_we # (B, n, n)
|
|
135
|
+
|
|
136
|
+
# Neighbor sums. The u-side term for edge (u, v) is
|
|
137
|
+
# w_u / √w_e · Σ_{u' ≠ v} 1/√A[u, u'], so one row sum of inv_sqrt_A
|
|
138
|
+
# gives every neighbor sum. Non-edges are zero in inv_sqrt_A, and the
|
|
139
|
+
# (u, v) entry itself is subtracted out below.
|
|
140
|
+
inv_sqrt_A = torch.where(
|
|
141
|
+
edge_mask > 0, torch.rsqrt(safe_we), torch.zeros_like(A),
|
|
142
|
+
) # (B, n, n)
|
|
143
|
+
|
|
144
|
+
# row_sum is (B, n, 1) and holds each node's neighbor sum; col_sum is
|
|
145
|
+
# (B, 1, n) and holds the same sums laid out by column. Broadcast, they
|
|
146
|
+
# give position (u, v) both u's sum and v's sum. Subtracting
|
|
147
|
+
# inv_sqrt_A[u, v] removes the edge from its own sums.
|
|
148
|
+
row_sum = inv_sqrt_A.sum(dim=-1, keepdim=True) # (B, n, 1)
|
|
149
|
+
col_sum = inv_sqrt_A.sum(dim=-2, keepdim=True) # (B, 1, n)
|
|
150
|
+
u_neighbor_sum = row_sum - inv_sqrt_A # (B, n, n)
|
|
151
|
+
v_neighbor_sum = col_sum - inv_sqrt_A # (B, n, n)
|
|
152
|
+
u_side = w_u * inv_sqrt_A * u_neighbor_sum # (B, n, n)
|
|
153
|
+
v_side = w_v * inv_sqrt_A * v_neighbor_sum # (B, n, n)
|
|
154
|
+
|
|
155
|
+
# Non-edges come out as zero through the edge_mask factor.
|
|
156
|
+
kappa = safe_we * (diagonal_term - u_side - v_side) * edge_mask
|
|
157
|
+
|
|
158
|
+
# The diagonal is already zero, since edge_mask is zero there after
|
|
159
|
+
# drop_self_loops. The explicit where keeps that true without relying
|
|
160
|
+
# on the mask.
|
|
161
|
+
eye = torch.eye(n, device=A.device, dtype=A.dtype).expand_as(A)
|
|
162
|
+
kappa = torch.where(eye > 0, torch.zeros_like(kappa), kappa)
|
|
163
|
+
return kappa
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def forman_ricci_augmented(A: torch.Tensor) -> torch.Tensor:
|
|
167
|
+
"""Augmented Forman-Ricci curvature on an unweighted graph.
|
|
168
|
+
|
|
169
|
+
With unit vertex, edge, and triangular-face weights, Samal et al.
|
|
170
|
+
(2018), equations 9–10, give
|
|
171
|
+
|
|
172
|
+
kappa_aug(u, v) = kappa_simple(u, v) + 3 * #triangles(u, v).
|
|
173
|
+
|
|
174
|
+
Each face contributes one positive term and removes two negative
|
|
175
|
+
parallel-edge terms. Weighted edges require a face-weight convention
|
|
176
|
+
and the full weighted expression, which this function does not expose.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
A: (B, n, n) symmetric real floating binary adjacency. Zero means
|
|
180
|
+
no edge and one means an edge. The diagonal is ignored.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
(B, n, n) curvature, zero on the diagonal and nonedges.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: if any off-diagonal weight is neither zero nor one.
|
|
187
|
+
The shape, dtype, and sign checks in `_validate_adjacency` also raise.
|
|
188
|
+
|
|
189
|
+
References:
|
|
190
|
+
Samal, A., et al. (2018). Comparative analysis of two discretizations
|
|
191
|
+
of Ricci curvature for complex networks. Scientific Reports
|
|
192
|
+
8:8650, equations 9–10.
|
|
193
|
+
"""
|
|
194
|
+
_validate_adjacency(A)
|
|
195
|
+
A = drop_self_loops(A)
|
|
196
|
+
if ((A != 0) & (A != 1)).any():
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"forman_ricci_augmented requires binary (unweighted) adjacency; "
|
|
199
|
+
"weighted curvature needs an explicit face-weight convention "
|
|
200
|
+
"(Samal et al. 2018, equations 9–10)."
|
|
201
|
+
)
|
|
202
|
+
# After drop_self_loops, (A @ A)[u, v] counts the nodes adjacent to both
|
|
203
|
+
# u and v, which is the number of triangles on edge (u, v) when A is
|
|
204
|
+
# binary. Multiplying by A keeps only real edges, since non-edges get no
|
|
205
|
+
# curvature.
|
|
206
|
+
triangle_count = (A @ A) * A
|
|
207
|
+
return forman_ricci_simple(A) + FORMAN_TRIANGLE_CONTRIBUTION * triangle_count
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ============================================================
|
|
211
|
+
# Internal helpers
|
|
212
|
+
# ============================================================
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _validate_adjacency(A: torch.Tensor) -> None:
|
|
216
|
+
"""Check that A is a real (..., n, n) tensor of non-negative edge weights.
|
|
217
|
+
|
|
218
|
+
Input: A, with its diagonal ignored.
|
|
219
|
+
Output: None. Raises ValueError on a bad shape, a non-floating dtype,
|
|
220
|
+
or a nonfinite or negative off-diagonal entry. Warns, without raising,
|
|
221
|
+
when A is not symmetric within a small absolute tolerance.
|
|
222
|
+
"""
|
|
223
|
+
if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"A must be (..., n, n); got A.shape={tuple(A.shape)}"
|
|
226
|
+
)
|
|
227
|
+
if not A.is_floating_point():
|
|
228
|
+
raise ValueError("A must have a real floating dtype")
|
|
229
|
+
A = drop_self_loops(A)
|
|
230
|
+
if not torch.isfinite(A).all() or (A < 0).any():
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"Forman-Ricci is defined for non-negative edge weights; "
|
|
233
|
+
"nonfinite or negative entries detected."
|
|
234
|
+
)
|
|
235
|
+
# Symmetry check matches the convention in `ollivier_ricci_curvature`.
|
|
236
|
+
if not torch.allclose(A, A.mT, atol=1e-9, rtol=0):
|
|
237
|
+
warnings.warn(
|
|
238
|
+
"forman_ricci received an asymmetric adjacency; Forman-Ricci is "
|
|
239
|
+
"only well-defined for symmetric (undirected) graphs. Symmetrize "
|
|
240
|
+
"A first (e.g. `0.5 * (A + A.mT)`) for meaningful results.",
|
|
241
|
+
stacklevel=2,
|
|
242
|
+
)
|