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,346 @@
|
|
|
1
|
+
"""Small reproducible inputs; the explorer has no dataset download step."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from .common import (
|
|
10
|
+
DEFAULT_NODES,
|
|
11
|
+
DEFAULT_NOISE,
|
|
12
|
+
DEFAULT_POINTS,
|
|
13
|
+
DEFAULT_SEED,
|
|
14
|
+
MAX_NODES,
|
|
15
|
+
MAX_POINTS,
|
|
16
|
+
Context,
|
|
17
|
+
Request,
|
|
18
|
+
choice,
|
|
19
|
+
integer,
|
|
20
|
+
number,
|
|
21
|
+
points_tensor,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Zachary (1977), Journal of Anthropological Research 33, 452-473, Table 3.
|
|
25
|
+
# Zero-based, unweighted transcription of the 34-member, 78-edge network.
|
|
26
|
+
KARATE_NODES = 34
|
|
27
|
+
KARATE_EDGE_COUNT = 78
|
|
28
|
+
KARATE_DEGREES = (16, 17) # Vertices 0 and 33, checked against the transcription.
|
|
29
|
+
KARATE_EDGES = (
|
|
30
|
+
(0, 1),
|
|
31
|
+
(0, 2),
|
|
32
|
+
(0, 3),
|
|
33
|
+
(0, 4),
|
|
34
|
+
(0, 5),
|
|
35
|
+
(0, 6),
|
|
36
|
+
(0, 7),
|
|
37
|
+
(0, 8),
|
|
38
|
+
(0, 10),
|
|
39
|
+
(0, 11),
|
|
40
|
+
(0, 12),
|
|
41
|
+
(0, 13),
|
|
42
|
+
(0, 17),
|
|
43
|
+
(0, 19),
|
|
44
|
+
(0, 21),
|
|
45
|
+
(0, 31),
|
|
46
|
+
(1, 2),
|
|
47
|
+
(1, 3),
|
|
48
|
+
(1, 7),
|
|
49
|
+
(1, 13),
|
|
50
|
+
(1, 17),
|
|
51
|
+
(1, 19),
|
|
52
|
+
(1, 21),
|
|
53
|
+
(1, 30),
|
|
54
|
+
(2, 3),
|
|
55
|
+
(2, 7),
|
|
56
|
+
(2, 8),
|
|
57
|
+
(2, 9),
|
|
58
|
+
(2, 13),
|
|
59
|
+
(2, 27),
|
|
60
|
+
(2, 28),
|
|
61
|
+
(2, 32),
|
|
62
|
+
(3, 7),
|
|
63
|
+
(3, 12),
|
|
64
|
+
(3, 13),
|
|
65
|
+
(4, 6),
|
|
66
|
+
(4, 10),
|
|
67
|
+
(5, 6),
|
|
68
|
+
(5, 10),
|
|
69
|
+
(5, 16),
|
|
70
|
+
(6, 16),
|
|
71
|
+
(8, 30),
|
|
72
|
+
(8, 32),
|
|
73
|
+
(8, 33),
|
|
74
|
+
(9, 33),
|
|
75
|
+
(13, 33),
|
|
76
|
+
(14, 32),
|
|
77
|
+
(14, 33),
|
|
78
|
+
(15, 32),
|
|
79
|
+
(15, 33),
|
|
80
|
+
(18, 32),
|
|
81
|
+
(18, 33),
|
|
82
|
+
(19, 33),
|
|
83
|
+
(20, 32),
|
|
84
|
+
(20, 33),
|
|
85
|
+
(22, 32),
|
|
86
|
+
(22, 33),
|
|
87
|
+
(23, 25),
|
|
88
|
+
(23, 27),
|
|
89
|
+
(23, 29),
|
|
90
|
+
(23, 32),
|
|
91
|
+
(23, 33),
|
|
92
|
+
(24, 25),
|
|
93
|
+
(24, 27),
|
|
94
|
+
(24, 31),
|
|
95
|
+
(25, 31),
|
|
96
|
+
(26, 29),
|
|
97
|
+
(26, 33),
|
|
98
|
+
(27, 33),
|
|
99
|
+
(28, 31),
|
|
100
|
+
(28, 33),
|
|
101
|
+
(29, 32),
|
|
102
|
+
(29, 33),
|
|
103
|
+
(30, 32),
|
|
104
|
+
(30, 33),
|
|
105
|
+
(31, 32),
|
|
106
|
+
(31, 33),
|
|
107
|
+
(32, 33),
|
|
108
|
+
)
|
|
109
|
+
# Observed club after fission, not the faction predicted by a graph algorithm.
|
|
110
|
+
KARATE_FACTIONS = (
|
|
111
|
+
0,
|
|
112
|
+
0,
|
|
113
|
+
0,
|
|
114
|
+
0,
|
|
115
|
+
0,
|
|
116
|
+
0,
|
|
117
|
+
0,
|
|
118
|
+
0,
|
|
119
|
+
0,
|
|
120
|
+
1,
|
|
121
|
+
0,
|
|
122
|
+
0,
|
|
123
|
+
0,
|
|
124
|
+
0,
|
|
125
|
+
1,
|
|
126
|
+
1,
|
|
127
|
+
0,
|
|
128
|
+
0,
|
|
129
|
+
1,
|
|
130
|
+
0,
|
|
131
|
+
1,
|
|
132
|
+
0,
|
|
133
|
+
1,
|
|
134
|
+
1,
|
|
135
|
+
1,
|
|
136
|
+
1,
|
|
137
|
+
1,
|
|
138
|
+
1,
|
|
139
|
+
1,
|
|
140
|
+
1,
|
|
141
|
+
1,
|
|
142
|
+
1,
|
|
143
|
+
1,
|
|
144
|
+
1,
|
|
145
|
+
)
|
|
146
|
+
# Initial controls are examples, not fitted model parameters.
|
|
147
|
+
DEFAULT_BLOCKS = 3
|
|
148
|
+
DEFAULT_P_IN = 0.5
|
|
149
|
+
DEFAULT_P_OUT = 0.05
|
|
150
|
+
DEFAULT_RADIUS = 0.3
|
|
151
|
+
DEFAULT_CLIQUE_SIZE = 5
|
|
152
|
+
DEFAULT_CLIQUES = 4
|
|
153
|
+
MAX_SEED = 2**32 - 1 # Portable unsigned 32-bit seed range.
|
|
154
|
+
CIRCLE_SEPARATION = 3.0 # Unit circles remain disjoint before adding noise.
|
|
155
|
+
ANNULUS_INNER_RADIUS = 0.5
|
|
156
|
+
CLUSTER_SPREAD = 0.15 # Example Gaussian standard deviation, coordinate units.
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def graph(request: Request, context: Context) -> tuple[torch.Tensor, Request]:
|
|
160
|
+
"""Generate an undirected simple adjacency of shape (1, N, N).
|
|
161
|
+
|
|
162
|
+
References:
|
|
163
|
+
Zachary (1977), An Information Flow Model for Conflict and Fission
|
|
164
|
+
in Small Groups, J. Anthropological Research 33:452-473.
|
|
165
|
+
Holland, Laskey and Leinhardt (1983), Stochastic blockmodels.
|
|
166
|
+
"""
|
|
167
|
+
kind = choice(
|
|
168
|
+
request,
|
|
169
|
+
"graph",
|
|
170
|
+
"karate",
|
|
171
|
+
(
|
|
172
|
+
"karate",
|
|
173
|
+
"sbm",
|
|
174
|
+
"barbell",
|
|
175
|
+
"cliques",
|
|
176
|
+
"geometric",
|
|
177
|
+
"custom",
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
seed = integer(request, "seed", DEFAULT_SEED, 0, MAX_SEED)
|
|
181
|
+
generator = context.generator(seed)
|
|
182
|
+
metadata: Request = {"graph": kind, "seed": seed, "weighted": False}
|
|
183
|
+
if kind == "karate":
|
|
184
|
+
adjacency = torch.zeros(
|
|
185
|
+
(KARATE_NODES, KARATE_NODES),
|
|
186
|
+
device=context.device,
|
|
187
|
+
dtype=context.dtype,
|
|
188
|
+
)
|
|
189
|
+
edges = torch.tensor(KARATE_EDGES, device=context.device)
|
|
190
|
+
adjacency[edges[:, 0], edges[:, 1]] = 1
|
|
191
|
+
metadata["citation"] = "Zachary (1977), J. Anthropol. Res. 33:452–473"
|
|
192
|
+
metadata["factions"] = KARATE_FACTIONS
|
|
193
|
+
elif kind == "custom":
|
|
194
|
+
adjacency, weighted = _edge_list(request, context)
|
|
195
|
+
metadata["weighted"] = weighted
|
|
196
|
+
elif kind in ("barbell", "cliques"):
|
|
197
|
+
size = integer(request, "clique_size", DEFAULT_CLIQUE_SIZE, 2, MAX_NODES // 2)
|
|
198
|
+
count = (
|
|
199
|
+
2
|
|
200
|
+
if kind == "barbell"
|
|
201
|
+
else integer(request, "cliques", DEFAULT_CLIQUES, 2, MAX_NODES // 2)
|
|
202
|
+
)
|
|
203
|
+
if size * count > MAX_NODES:
|
|
204
|
+
raise ValueError(f"clique_size × cliques must be at most {MAX_NODES}")
|
|
205
|
+
labels = torch.arange(size * count, device=context.device) // size
|
|
206
|
+
adjacency = (labels[:, None] == labels[None, :]).to(context.dtype)
|
|
207
|
+
bridges = torch.arange(count - (kind == "barbell"), device=context.device)
|
|
208
|
+
adjacency[bridges * size + size - 1, ((bridges + 1) % count) * size] = 1
|
|
209
|
+
metadata.update(clique_size=size, cliques=count)
|
|
210
|
+
else:
|
|
211
|
+
nodes = integer(request, "nodes", DEFAULT_NODES, 3, MAX_NODES)
|
|
212
|
+
if kind == "sbm":
|
|
213
|
+
blocks = integer(request, "blocks", DEFAULT_BLOCKS, 1, nodes)
|
|
214
|
+
p_in = number(request, "p_in", DEFAULT_P_IN, 0, 1)
|
|
215
|
+
p_out = number(request, "p_out", DEFAULT_P_OUT, 0, 1)
|
|
216
|
+
labels = torch.arange(nodes, device=context.device) * blocks // nodes
|
|
217
|
+
probability = torch.where(
|
|
218
|
+
labels[:, None] == labels[None, :],
|
|
219
|
+
p_in,
|
|
220
|
+
p_out,
|
|
221
|
+
)
|
|
222
|
+
draws = torch.rand(
|
|
223
|
+
(nodes, nodes),
|
|
224
|
+
generator=generator,
|
|
225
|
+
device=context.device,
|
|
226
|
+
dtype=context.dtype,
|
|
227
|
+
)
|
|
228
|
+
adjacency = (draws < probability).to(context.dtype).triu(1)
|
|
229
|
+
metadata.update(nodes=nodes, blocks=blocks, p_in=p_in, p_out=p_out)
|
|
230
|
+
else:
|
|
231
|
+
radius = number(request, "radius", DEFAULT_RADIUS, 0, math.sqrt(2))
|
|
232
|
+
points = torch.rand(
|
|
233
|
+
(nodes, 2),
|
|
234
|
+
generator=generator,
|
|
235
|
+
device=context.device,
|
|
236
|
+
dtype=context.dtype,
|
|
237
|
+
)
|
|
238
|
+
adjacency = (torch.cdist(points, points) <= radius).to(context.dtype)
|
|
239
|
+
metadata.update(nodes=nodes, radius=radius)
|
|
240
|
+
adjacency = torch.maximum(adjacency, adjacency.mT)
|
|
241
|
+
adjacency.fill_diagonal_(0)
|
|
242
|
+
return adjacency.unsqueeze(0), metadata
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _edge_list(request: Request, context: Context) -> tuple[torch.Tensor, bool]:
|
|
246
|
+
text = request.get("edges", "")
|
|
247
|
+
if not isinstance(text, str):
|
|
248
|
+
raise ValueError("edges must be text: i j [weight] per line")
|
|
249
|
+
parsed: dict[tuple[int, int], float] = {}
|
|
250
|
+
largest = -1
|
|
251
|
+
# Text parsing is outside the tensor compute path. Reject conflicting duplicate
|
|
252
|
+
# edges so that input order cannot silently change the graph's meaning.
|
|
253
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
254
|
+
parts = line.split("#", 1)[0].split()
|
|
255
|
+
if not parts:
|
|
256
|
+
continue
|
|
257
|
+
if len(parts) not in (2, 3):
|
|
258
|
+
raise ValueError(f"line {line_number}: expected i j [weight]")
|
|
259
|
+
left, right = int(parts[0]), int(parts[1])
|
|
260
|
+
weight = float(parts[2]) if len(parts) == 3 else 1.0
|
|
261
|
+
if not 0 <= min(left, right) <= max(left, right) < MAX_NODES:
|
|
262
|
+
raise ValueError(f"node IDs must be between 0 and {MAX_NODES - 1}")
|
|
263
|
+
if not math.isfinite(weight) or weight <= 0:
|
|
264
|
+
raise ValueError("edge weights must be finite and positive")
|
|
265
|
+
largest = max(largest, left, right)
|
|
266
|
+
if left == right:
|
|
267
|
+
continue
|
|
268
|
+
key = (min(left, right), max(left, right))
|
|
269
|
+
if key in parsed and parsed[key] != weight:
|
|
270
|
+
raise ValueError(f"conflicting weights for edge {key}")
|
|
271
|
+
parsed[key] = weight
|
|
272
|
+
nodes = integer(request, "nodes", max(1, largest + 1), 1, MAX_NODES)
|
|
273
|
+
if largest >= nodes:
|
|
274
|
+
raise ValueError("nodes must include the largest edge endpoint")
|
|
275
|
+
adjacency = torch.zeros((nodes, nodes), device=context.device, dtype=context.dtype)
|
|
276
|
+
if parsed:
|
|
277
|
+
endpoints = torch.tensor(list(parsed), device=context.device)
|
|
278
|
+
weights = context.tensor(list(parsed.values()))
|
|
279
|
+
if not bool((torch.isfinite(weights) & (weights > 0)).all()):
|
|
280
|
+
raise ValueError(
|
|
281
|
+
"edge weights must stay finite and positive in the chosen dtype"
|
|
282
|
+
)
|
|
283
|
+
adjacency[endpoints[:, 0], endpoints[:, 1]] = weights
|
|
284
|
+
return adjacency, any(weight != 1 for weight in parsed.values())
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cloud(request: Request, context: Context) -> torch.Tensor:
|
|
288
|
+
"""Generate (N, 2) Euclidean points or use the user's clicks.
|
|
289
|
+
|
|
290
|
+
References:
|
|
291
|
+
The polar area element r dr dθ gives uniform annulus area sampling.
|
|
292
|
+
"""
|
|
293
|
+
kind = choice(
|
|
294
|
+
request,
|
|
295
|
+
"cloud",
|
|
296
|
+
"circle",
|
|
297
|
+
(
|
|
298
|
+
"circle",
|
|
299
|
+
"two_circles",
|
|
300
|
+
"figure_eight",
|
|
301
|
+
"annulus",
|
|
302
|
+
"clusters",
|
|
303
|
+
"custom",
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
if kind == "custom":
|
|
307
|
+
return points_tensor(request, context, MAX_POINTS)
|
|
308
|
+
count = integer(request, "points_count", DEFAULT_POINTS, 1, MAX_POINTS)
|
|
309
|
+
seed = integer(request, "seed", DEFAULT_SEED, 0, MAX_SEED)
|
|
310
|
+
noise = number(request, "noise", DEFAULT_NOISE, 0, 1)
|
|
311
|
+
generator = context.generator(seed)
|
|
312
|
+
random = torch.rand(
|
|
313
|
+
(count, 2),
|
|
314
|
+
generator=generator,
|
|
315
|
+
device=context.device,
|
|
316
|
+
dtype=context.dtype,
|
|
317
|
+
)
|
|
318
|
+
angle = random[:, 0] * math.tau
|
|
319
|
+
points = torch.stack((angle.cos(), angle.sin()), dim=-1)
|
|
320
|
+
if kind == "two_circles":
|
|
321
|
+
points[:, 0] += (
|
|
322
|
+
torch.arange(count, device=context.device) % 2
|
|
323
|
+
) * CIRCLE_SEPARATION
|
|
324
|
+
points[:, 0] -= CIRCLE_SEPARATION / 2
|
|
325
|
+
elif kind == "figure_eight":
|
|
326
|
+
points = torch.stack((angle.sin(), (2 * angle).sin() / 2), dim=-1)
|
|
327
|
+
elif kind == "annulus":
|
|
328
|
+
radius = (
|
|
329
|
+
ANNULUS_INNER_RADIUS**2 + random[:, 1] * (1 - ANNULUS_INNER_RADIUS**2)
|
|
330
|
+
).sqrt()
|
|
331
|
+
points *= radius[:, None]
|
|
332
|
+
elif kind == "clusters":
|
|
333
|
+
centers = context.tensor(((-1, -1), (1, -1), (0, 1)))
|
|
334
|
+
points = centers[torch.arange(count, device=context.device) % len(centers)]
|
|
335
|
+
points = points + CLUSTER_SPREAD * torch.randn(
|
|
336
|
+
(count, 2),
|
|
337
|
+
generator=generator,
|
|
338
|
+
device=context.device,
|
|
339
|
+
dtype=context.dtype,
|
|
340
|
+
)
|
|
341
|
+
return points + noise * torch.randn(
|
|
342
|
+
(count, 2),
|
|
343
|
+
generator=generator,
|
|
344
|
+
device=context.device,
|
|
345
|
+
dtype=context.dtype,
|
|
346
|
+
)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Graph scene: adapters for spectral and discrete-geometric primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings as python_warnings
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from holonomy_lib import discrete_geometry, spectral
|
|
10
|
+
from holonomy_lib.spectral.laplacian import symmetric_normalized
|
|
11
|
+
|
|
12
|
+
from . import datasets
|
|
13
|
+
from .common import (
|
|
14
|
+
MAX_FLOW_FRAMES,
|
|
15
|
+
MAX_FLOW_STEPS,
|
|
16
|
+
Context,
|
|
17
|
+
Request,
|
|
18
|
+
choice,
|
|
19
|
+
integer,
|
|
20
|
+
number,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Measured 2026-10-25 with the converged Ollivier curvature (CPU, float64, each
|
|
24
|
+
# generator at its defaults): ring of cliques ends as [5, 5, 5, 5], barbell as
|
|
25
|
+
# [4, 4, 1, 1], the geometric graph as [20, 4], while karate and the block model
|
|
26
|
+
# stay in one piece. An earlier 21/13 karate split came from unconverged
|
|
27
|
+
# curvature. A sweep over alpha, dt, steps and cut thresholds found no setting
|
|
28
|
+
# that recovers Zachary's two factions; the best modularity cut gave four parts.
|
|
29
|
+
# So these are a demonstration on clear community structure, not a detector.
|
|
30
|
+
DEFAULT_FLOW_STEPS = 20
|
|
31
|
+
DEFAULT_SURGERY_PERIOD = 10
|
|
32
|
+
DEFAULT_SURGERY_THRESHOLD = 1.5
|
|
33
|
+
DEFAULT_FLOW_DT = 1.0
|
|
34
|
+
MAX_SURGERY_THRESHOLD = 10.0
|
|
35
|
+
# With the library's default K=30, keep the heat slider in a modest t range.
|
|
36
|
+
MIN_HEAT_TIME = 0.01
|
|
37
|
+
MAX_HEAT_TIME = 10.0
|
|
38
|
+
REFERENCE_EPS_MULTIPLIER = 64 # Roundoff allowance for a six-node eigensolve.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def components(adjacency: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
42
|
+
"""Return component labels and sizes for one (N,N) graph.
|
|
43
|
+
|
|
44
|
+
Repeated squaring doubles the reachable path length each round, so this
|
|
45
|
+
loop is over log2(N) path lengths, never over individual nodes or edges.
|
|
46
|
+
|
|
47
|
+
References:
|
|
48
|
+
Warshall (1962), A Theorem on Boolean Matrices.
|
|
49
|
+
"""
|
|
50
|
+
nodes = adjacency.shape[-1]
|
|
51
|
+
reachable = (adjacency > 0) | torch.eye(
|
|
52
|
+
nodes,
|
|
53
|
+
device=adjacency.device,
|
|
54
|
+
dtype=torch.bool,
|
|
55
|
+
)
|
|
56
|
+
for _ in range((nodes - 1).bit_length()):
|
|
57
|
+
reachable = (reachable.to(adjacency.dtype) @ reachable.to(adjacency.dtype)) > 0
|
|
58
|
+
indices = torch.arange(nodes, device=adjacency.device)
|
|
59
|
+
first = torch.where(reachable, indices[None, :], nodes).amin(-1)
|
|
60
|
+
_, labels, sizes = torch.unique(first, return_inverse=True, return_counts=True)
|
|
61
|
+
return labels, sizes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resistance_reference(context: Context) -> Request:
|
|
65
|
+
adjacency = context.tensor(
|
|
66
|
+
[
|
|
67
|
+
[
|
|
68
|
+
[0, 1, 1, 0, 0, 0],
|
|
69
|
+
[1, 0, 1, 0, 0, 0],
|
|
70
|
+
[1, 1, 0, 1, 0, 0],
|
|
71
|
+
[0, 0, 1, 0, 1, 1],
|
|
72
|
+
[0, 0, 0, 1, 0, 1],
|
|
73
|
+
[0, 0, 0, 1, 1, 0],
|
|
74
|
+
]
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
resistance = context.call(spectral.effective_resistance, adjacency)
|
|
78
|
+
observed = torch.stack((resistance[0, 0, 1], resistance[0, 2, 3]))
|
|
79
|
+
expected = context.tensor([2 / 3, 1]) # Triangle edge and unit bridge.
|
|
80
|
+
error = (observed - expected).abs().max()
|
|
81
|
+
return {
|
|
82
|
+
"observed": observed,
|
|
83
|
+
"expected": expected,
|
|
84
|
+
"max_abs_error": error,
|
|
85
|
+
"passed": bool(
|
|
86
|
+
error < torch.finfo(context.dtype).eps * REFERENCE_EPS_MULTIPLIER
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@torch.no_grad()
|
|
92
|
+
def compute(request: Request) -> Request:
|
|
93
|
+
"""Return the graph, layout and a single library-computed overlay.
|
|
94
|
+
|
|
95
|
+
Flow frames are sequential in step count. Each frame starts at the original
|
|
96
|
+
adjacency: chunked calls reset surgery's cutoff and normalization reference.
|
|
97
|
+
|
|
98
|
+
References:
|
|
99
|
+
Belkin and Niyogi (2003), Laplacian Eigenmaps.
|
|
100
|
+
Ollivier (2009), Ricci curvature of Markov chains on metric spaces.
|
|
101
|
+
Forman (2003), Bochner's method for cell complexes.
|
|
102
|
+
Ni et al. (2019), Community detection on networks with Ricci flow.
|
|
103
|
+
"""
|
|
104
|
+
context = Context.from_request(request)
|
|
105
|
+
adjacency, parameters = datasets.graph(request, context)
|
|
106
|
+
nodes = adjacency.shape[-1]
|
|
107
|
+
source = integer(request, "source", 0, 0, nodes - 1)
|
|
108
|
+
overlay = choice(
|
|
109
|
+
request,
|
|
110
|
+
"overlay",
|
|
111
|
+
"forman",
|
|
112
|
+
(
|
|
113
|
+
"none",
|
|
114
|
+
"ollivier",
|
|
115
|
+
"forman",
|
|
116
|
+
"flow",
|
|
117
|
+
"heat",
|
|
118
|
+
"resistance",
|
|
119
|
+
"spectrum",
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
eigenvalues, vectors = context.call(
|
|
123
|
+
spectral.laplacian_eigenmaps,
|
|
124
|
+
adjacency,
|
|
125
|
+
k=min(3, nodes),
|
|
126
|
+
)
|
|
127
|
+
coordinates = torch.zeros((nodes, 2), device=context.device, dtype=context.dtype)
|
|
128
|
+
coordinates[:, : vectors.shape[-1] - 1] = vectors[0, :, 1:3]
|
|
129
|
+
edges = torch.triu_indices(nodes, nodes, 1, device=context.device)
|
|
130
|
+
present = adjacency[0, edges[0], edges[1]] > 0
|
|
131
|
+
edges = edges[:, present].mT
|
|
132
|
+
labels, sizes = components(adjacency[0])
|
|
133
|
+
warnings: list[str] = []
|
|
134
|
+
if len(sizes) > 1:
|
|
135
|
+
warnings.append(
|
|
136
|
+
"Disconnected graph: the spectral layout can overlap vertices in "
|
|
137
|
+
"degenerate component modes. Cross-component resistance is infinite."
|
|
138
|
+
)
|
|
139
|
+
result: Request = {
|
|
140
|
+
"nodes": nodes,
|
|
141
|
+
"edges": edges,
|
|
142
|
+
"weights": adjacency[0, edges[:, 0], edges[:, 1]],
|
|
143
|
+
"coordinates": coordinates,
|
|
144
|
+
"overlay": overlay,
|
|
145
|
+
"source": source,
|
|
146
|
+
"parameters": parameters,
|
|
147
|
+
"component_labels": labels,
|
|
148
|
+
"component_sizes": sizes,
|
|
149
|
+
"warnings": warnings,
|
|
150
|
+
}
|
|
151
|
+
parameters["overlay"] = overlay
|
|
152
|
+
if overlay in ("ollivier", "forman"):
|
|
153
|
+
function = (
|
|
154
|
+
discrete_geometry.ollivier_ricci_curvature
|
|
155
|
+
if overlay == "ollivier"
|
|
156
|
+
else discrete_geometry.forman_ricci_simple
|
|
157
|
+
)
|
|
158
|
+
curvature = context.call(function, adjacency)
|
|
159
|
+
values = curvature[0, edges[:, 0], edges[:, 1]]
|
|
160
|
+
result["edge_values"] = values
|
|
161
|
+
result["range"] = [values.min(), values.max()] if values.numel() else [0, 0]
|
|
162
|
+
if overlay == "ollivier":
|
|
163
|
+
warnings.append(
|
|
164
|
+
"Ollivier values use the library's regularized Sinkhorn approximation."
|
|
165
|
+
)
|
|
166
|
+
elif overlay == "heat":
|
|
167
|
+
time = number(request, "time", 1, MIN_HEAT_TIME, MAX_HEAT_TIME)
|
|
168
|
+
laplacian = context.call(symmetric_normalized, adjacency)
|
|
169
|
+
signal = torch.zeros((1, nodes, 1), device=context.device, dtype=context.dtype)
|
|
170
|
+
signal[:, source, :] = 1
|
|
171
|
+
heat = context.call(
|
|
172
|
+
spectral.heat_kernel_chebyshev, laplacian, time, signal=signal
|
|
173
|
+
)
|
|
174
|
+
result["node_values"] = heat[0, :, 0]
|
|
175
|
+
result["value_label"] = "Heat amplitude (normalized Laplacian)"
|
|
176
|
+
parameters.update(time=time, source=source)
|
|
177
|
+
elif overlay == "resistance":
|
|
178
|
+
resistance = context.call(spectral.effective_resistance, adjacency)[0, source]
|
|
179
|
+
result["node_values"] = torch.where(
|
|
180
|
+
labels == labels[source],
|
|
181
|
+
resistance,
|
|
182
|
+
float("inf"),
|
|
183
|
+
)
|
|
184
|
+
result["value_label"] = "Effective resistance (unit-edge resistance units)"
|
|
185
|
+
reference = _resistance_reference(context)
|
|
186
|
+
result["reference_check"] = reference
|
|
187
|
+
if not reference["passed"]:
|
|
188
|
+
warnings.append(
|
|
189
|
+
"Library resistance failed a triangle-and-bridge reference check "
|
|
190
|
+
"in this dtype. Values below are the unmodified library output."
|
|
191
|
+
)
|
|
192
|
+
parameters["source"] = source
|
|
193
|
+
elif overlay == "flow":
|
|
194
|
+
steps = integer(request, "steps", DEFAULT_FLOW_STEPS, 1, MAX_FLOW_STEPS)
|
|
195
|
+
period = integer(request, "surgery_period", DEFAULT_SURGERY_PERIOD, 1, steps)
|
|
196
|
+
threshold = number(
|
|
197
|
+
request,
|
|
198
|
+
"surgery_threshold",
|
|
199
|
+
DEFAULT_SURGERY_THRESHOLD,
|
|
200
|
+
torch.finfo(context.dtype).eps,
|
|
201
|
+
MAX_SURGERY_THRESHOLD,
|
|
202
|
+
)
|
|
203
|
+
dt = number(request, "dt", DEFAULT_FLOW_DT, torch.finfo(context.dtype).eps, 1)
|
|
204
|
+
frame_steps = list(range(period, steps + 1, period))
|
|
205
|
+
if not frame_steps or frame_steps[-1] != steps:
|
|
206
|
+
frame_steps.append(steps)
|
|
207
|
+
if len(frame_steps) > MAX_FLOW_FRAMES:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"choose a longer surgery period: at most {MAX_FLOW_FRAMES} frames"
|
|
210
|
+
)
|
|
211
|
+
flow_plan = [0, *frame_steps]
|
|
212
|
+
# A browser can request each cumulative frame separately and show progress
|
|
213
|
+
# between requests. Ordinary direct callers still receive the whole run.
|
|
214
|
+
if "frame_step" in request:
|
|
215
|
+
frame_step = integer(request, "frame_step", 0, 0, steps)
|
|
216
|
+
if frame_step not in flow_plan:
|
|
217
|
+
raise ValueError("frame_step must be a planned cumulative flow step")
|
|
218
|
+
frame_steps = [frame_step] if frame_step else []
|
|
219
|
+
complete = bool(frame_steps and frame_steps[-1] == steps)
|
|
220
|
+
frames = [adjacency[0]]
|
|
221
|
+
flow_notices: list[str] = []
|
|
222
|
+
for step in frame_steps:
|
|
223
|
+
with python_warnings.catch_warnings(record=True) as notices:
|
|
224
|
+
python_warnings.simplefilter("always", UserWarning)
|
|
225
|
+
frames.append(
|
|
226
|
+
context.call(
|
|
227
|
+
discrete_geometry.ricci_flow_with_surgery,
|
|
228
|
+
adjacency,
|
|
229
|
+
n_steps=step,
|
|
230
|
+
surgery_period=period,
|
|
231
|
+
surgery_threshold=threshold,
|
|
232
|
+
dt=dt,
|
|
233
|
+
)[0]
|
|
234
|
+
)
|
|
235
|
+
# Keep solver notices visible in the response rather than leaving
|
|
236
|
+
# them only in the terminal while the browser reports a partition.
|
|
237
|
+
flow_notices.extend(str(notice.message) for notice in notices)
|
|
238
|
+
final_labels, final_sizes = components(frames[-1])
|
|
239
|
+
# Say so plainly when nothing split, otherwise an unchanged graph reads
|
|
240
|
+
# like the animation failed.
|
|
241
|
+
if complete and len(final_sizes) == 1:
|
|
242
|
+
warnings.append(
|
|
243
|
+
"The flow did not split this graph at these settings. Ricci flow "
|
|
244
|
+
"separates clear community structure (try the ring of cliques); "
|
|
245
|
+
"on the karate club it does not recover the two factions."
|
|
246
|
+
)
|
|
247
|
+
result.update(
|
|
248
|
+
frames=torch.stack(frames),
|
|
249
|
+
frame_steps=[0, *frame_steps],
|
|
250
|
+
flow_plan=flow_plan,
|
|
251
|
+
flow_complete=complete,
|
|
252
|
+
flow_warning_count=len(flow_notices),
|
|
253
|
+
flow_last_warning=flow_notices[-1] if flow_notices else None,
|
|
254
|
+
final_labels=final_labels,
|
|
255
|
+
final_sizes=final_sizes,
|
|
256
|
+
flow_method=(
|
|
257
|
+
"Each frame reruns from the original graph "
|
|
258
|
+
"to its cumulative step count."
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
parameters.update(
|
|
262
|
+
steps=steps, surgery_period=period, surgery_threshold=threshold, dt=dt
|
|
263
|
+
)
|
|
264
|
+
if parameters["graph"] == "karate":
|
|
265
|
+
factions = torch.tensor(datasets.KARATE_FACTIONS, device=context.device)
|
|
266
|
+
mismatch = (final_labels != factions).sum()
|
|
267
|
+
result["faction_wrong_side"] = (
|
|
268
|
+
torch.minimum(mismatch, nodes - mismatch)
|
|
269
|
+
if len(final_sizes) == 2
|
|
270
|
+
else None
|
|
271
|
+
)
|
|
272
|
+
result["faction_note"] = (
|
|
273
|
+
"Faction labels are exchanged to minimize the mismatch count."
|
|
274
|
+
if len(final_sizes) == 2
|
|
275
|
+
else "Wrong-side count requires exactly two final components; "
|
|
276
|
+
"this flow did not produce two."
|
|
277
|
+
)
|
|
278
|
+
if overlay in ("ollivier", "flow") and parameters["weighted"]:
|
|
279
|
+
warnings.append(
|
|
280
|
+
"Weighted Ollivier/flow currently use adjacency values both as path "
|
|
281
|
+
"lengths and as random-walk mass. Interpret weighted results with care."
|
|
282
|
+
)
|
|
283
|
+
laplacian = context.call(symmetric_normalized, adjacency)
|
|
284
|
+
spectrum = context.call(torch.linalg.eigvalsh, laplacian)[0]
|
|
285
|
+
result.update(spectrum=spectrum, spectral_gap=spectrum[1] if nodes > 1 else None)
|
|
286
|
+
return context.finish(**result)
|