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 @@
|
|
|
1
|
+
"""Local, offline views of computations performed by holonomy_lib."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Launch with holonomy-explorer or python -m holonomy_explorer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
from .common import Context
|
|
12
|
+
from .server import create_server
|
|
13
|
+
|
|
14
|
+
# Local-only defaults requested by the explorer brief; every deployment can override.
|
|
15
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
16
|
+
WILDCARD_HOST = "0.0.0.0" # IPv4 bind-all address; browse via loopback instead.
|
|
17
|
+
DEFAULT_PORT = 8765
|
|
18
|
+
DEFAULT_DTYPE = "float64"
|
|
19
|
+
MAX_PORT = 65535 # TCP port field is an unsigned 16-bit integer.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""Parse deployment settings, print the bound URL, and serve until Ctrl-C."""
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
description="Local holonomy_lib explorer. Offline UI; CPU/CUDA tensors.",
|
|
26
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--host",
|
|
30
|
+
default=os.getenv("HOLONOMY_EXPLORER_HOST", DEFAULT_HOST),
|
|
31
|
+
help="bind address; env HOLONOMY_EXPLORER_HOST",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--port",
|
|
35
|
+
type=int,
|
|
36
|
+
default=os.getenv("HOLONOMY_EXPLORER_PORT", str(DEFAULT_PORT)),
|
|
37
|
+
help="TCP port, 0 chooses a free port; env HOLONOMY_EXPLORER_PORT",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--device",
|
|
41
|
+
default=os.getenv(
|
|
42
|
+
"HOLONOMY_EXPLORER_DEVICE",
|
|
43
|
+
"cuda" if torch.cuda.is_available() else "cpu",
|
|
44
|
+
),
|
|
45
|
+
help="torch device; env HOLONOMY_EXPLORER_DEVICE; ROCm also uses cuda",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--dtype",
|
|
49
|
+
choices=("float32", "float64"),
|
|
50
|
+
default=os.getenv("HOLONOMY_EXPLORER_DTYPE", DEFAULT_DTYPE),
|
|
51
|
+
help="tensor precision; env HOLONOMY_EXPLORER_DTYPE",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--no-browser", action="store_true", help="print URL without opening a browser"
|
|
55
|
+
)
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
if not 0 <= args.port <= MAX_PORT:
|
|
58
|
+
parser.error(f"port must be between 0 and {MAX_PORT}")
|
|
59
|
+
try:
|
|
60
|
+
context = Context.from_request({"device": args.device, "dtype": args.dtype})
|
|
61
|
+
server = create_server(args.host, args.port, str(context.device), args.dtype)
|
|
62
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
63
|
+
parser.error(str(error))
|
|
64
|
+
# Wildcard bindings are not navigable destinations; loopback reaches this server.
|
|
65
|
+
browser_host = DEFAULT_HOST if args.host == WILDCARD_HOST else args.host
|
|
66
|
+
url = f"http://{browser_host}:{server.server_address[1]}"
|
|
67
|
+
print(f"Holonomy explorer: {url} ({context.device}, {args.dtype})", flush=True)
|
|
68
|
+
if not args.no_browser:
|
|
69
|
+
webbrowser.open(url)
|
|
70
|
+
try:
|
|
71
|
+
server.serve_forever()
|
|
72
|
+
except KeyboardInterrupt:
|
|
73
|
+
pass
|
|
74
|
+
finally:
|
|
75
|
+
server.server_close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Synchronized warm-up and repeated timings of the actual library paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
|
|
10
|
+
from holonomy_lib.manifolds import KappaStereographicManifold
|
|
11
|
+
from holonomy_lib.spectral import heat_kernel_chebyshev
|
|
12
|
+
from holonomy_lib.spectral.laplacian import symmetric_normalized
|
|
13
|
+
from holonomy_lib.topology import persistence_diagrams
|
|
14
|
+
|
|
15
|
+
from .common import (
|
|
16
|
+
DEFAULT_REPEATS,
|
|
17
|
+
DEFAULT_SEED,
|
|
18
|
+
DEFAULT_SWEEP,
|
|
19
|
+
MAX_BATCH_SIZE,
|
|
20
|
+
MAX_NODES,
|
|
21
|
+
MAX_PERSISTENCE_BATCH,
|
|
22
|
+
MAX_PERSISTENCE_SIZE,
|
|
23
|
+
MAX_REPEATS,
|
|
24
|
+
Context,
|
|
25
|
+
Request,
|
|
26
|
+
choice,
|
|
27
|
+
integer,
|
|
28
|
+
)
|
|
29
|
+
from .datasets import MAX_SEED
|
|
30
|
+
from .topology import reference_check
|
|
31
|
+
|
|
32
|
+
# Keep the initial persistence sweep small enough for interactive CPU reduction.
|
|
33
|
+
DEFAULT_PROBLEM_SIZE = 8
|
|
34
|
+
BENCHMARK_GRAPH_DENSITY = 0.25 # Example Erdős-Rényi connection probability.
|
|
35
|
+
BENCHMARK_POINT_SCALE = 0.2 # Uniform square sits inside the κ=-1 unit ball.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _operation(
|
|
39
|
+
primitive: str,
|
|
40
|
+
batch: int,
|
|
41
|
+
size: int,
|
|
42
|
+
context: Context,
|
|
43
|
+
seed: int,
|
|
44
|
+
) -> Callable[[], Any]:
|
|
45
|
+
generator = context.generator(seed)
|
|
46
|
+
options: dict[str, Any] = {"device": context.device, "dtype": context.dtype}
|
|
47
|
+
if primitive == "heat":
|
|
48
|
+
upper = (
|
|
49
|
+
(
|
|
50
|
+
torch.rand((batch, size, size), generator=generator, **options)
|
|
51
|
+
< BENCHMARK_GRAPH_DENSITY
|
|
52
|
+
)
|
|
53
|
+
.to(context.dtype)
|
|
54
|
+
.triu(1)
|
|
55
|
+
)
|
|
56
|
+
adjacency = upper + upper.mT
|
|
57
|
+
# Matrix construction is outside the measured primitive call.
|
|
58
|
+
laplacian = symmetric_normalized(adjacency)
|
|
59
|
+
signal = torch.ones((batch, size, 1), **options)
|
|
60
|
+
return lambda: heat_kernel_chebyshev(laplacian, 1.0, signal=signal)
|
|
61
|
+
points = torch.rand((batch, size, 2), generator=generator, **options)
|
|
62
|
+
if primitive == "distance":
|
|
63
|
+
points = (points - 0.5) * (2 * BENCHMARK_POINT_SCALE)
|
|
64
|
+
manifold = KappaStereographicManifold(n=2, kappa=-1, **options)
|
|
65
|
+
return lambda: manifold.distance(points[:, :, None, :], points[:, None, :, :])
|
|
66
|
+
return lambda: persistence_diagrams(points, max_dim=1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _probe(output: Any) -> Request:
|
|
70
|
+
if isinstance(output, torch.Tensor):
|
|
71
|
+
return {
|
|
72
|
+
"shape": list(output.shape),
|
|
73
|
+
"all_finite": bool(torch.isfinite(output).all()),
|
|
74
|
+
}
|
|
75
|
+
diagrams, masks = output
|
|
76
|
+
return {
|
|
77
|
+
"diagram_shapes": [list(item.shape) for item in diagrams],
|
|
78
|
+
"valid_bars": [int(mask.sum()) for mask in masks],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@torch.no_grad()
|
|
83
|
+
def compute(request: Request) -> Request:
|
|
84
|
+
"""Sweep batch sizes, warming each shape before synchronized measurements.
|
|
85
|
+
|
|
86
|
+
Repeats and device/shape sweeps are deliberately sequential to avoid timing
|
|
87
|
+
competing work. The tensor operation itself receives the whole batch.
|
|
88
|
+
|
|
89
|
+
References:
|
|
90
|
+
Hammond, Vandergheynst and Gribonval (2011), Wavelets on graphs.
|
|
91
|
+
Bachmann et al. (2020), Constant Curvature Graph Convolutional Networks.
|
|
92
|
+
Edelsbrunner, Letscher and Zomorodian (2002), Topological persistence.
|
|
93
|
+
"""
|
|
94
|
+
selected = Context.from_request(request)
|
|
95
|
+
primitive = choice(
|
|
96
|
+
request, "primitive", "heat", ("heat", "distance", "persistence")
|
|
97
|
+
)
|
|
98
|
+
size_cap = MAX_PERSISTENCE_SIZE if primitive == "persistence" else MAX_NODES
|
|
99
|
+
batch_cap = MAX_PERSISTENCE_BATCH if primitive == "persistence" else MAX_BATCH_SIZE
|
|
100
|
+
size = integer(request, "size", DEFAULT_PROBLEM_SIZE, 1, size_cap)
|
|
101
|
+
repeats = integer(request, "repeats", DEFAULT_REPEATS, 1, MAX_REPEATS)
|
|
102
|
+
seed = integer(request, "seed", DEFAULT_SEED, 0, MAX_SEED)
|
|
103
|
+
batches = request.get(
|
|
104
|
+
"batches", [batch for batch in DEFAULT_SWEEP if batch <= batch_cap]
|
|
105
|
+
)
|
|
106
|
+
if (
|
|
107
|
+
not isinstance(batches, list)
|
|
108
|
+
or not batches
|
|
109
|
+
or len(batches) > len(DEFAULT_SWEEP)
|
|
110
|
+
):
|
|
111
|
+
raise ValueError(f"batches must contain 1 to {len(DEFAULT_SWEEP)} batch sizes")
|
|
112
|
+
if any(type(batch) is not int or not 1 <= batch <= batch_cap for batch in batches):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"each batch size must be an integer between 1 and {batch_cap}"
|
|
115
|
+
)
|
|
116
|
+
devices = [torch.device("cpu")]
|
|
117
|
+
if torch.cuda.is_available():
|
|
118
|
+
devices.append(
|
|
119
|
+
selected.device
|
|
120
|
+
if selected.device.type == "cuda"
|
|
121
|
+
else torch.device("cuda", 0)
|
|
122
|
+
)
|
|
123
|
+
rows: list[Request] = []
|
|
124
|
+
warnings: list[str] = []
|
|
125
|
+
references: list[Request] = []
|
|
126
|
+
for device in devices:
|
|
127
|
+
context = Context(device, selected.dtype)
|
|
128
|
+
if primitive == "persistence":
|
|
129
|
+
reference = reference_check(context)
|
|
130
|
+
references.append({"device": str(device), **reference})
|
|
131
|
+
if not reference["passed"]:
|
|
132
|
+
warnings.append(
|
|
133
|
+
f"{device}: square H1 reference failed; "
|
|
134
|
+
"timing the current provisional reduction."
|
|
135
|
+
)
|
|
136
|
+
for batch in sorted(set(batches)):
|
|
137
|
+
operation = _operation(primitive, batch, size, context, seed)
|
|
138
|
+
probe = _probe(context.call(operation))
|
|
139
|
+
times = []
|
|
140
|
+
for _ in range(repeats):
|
|
141
|
+
start = context.elapsed_ms
|
|
142
|
+
context.call(operation)
|
|
143
|
+
times.append(context.elapsed_ms - start)
|
|
144
|
+
timings = torch.tensor(times, dtype=torch.float64)
|
|
145
|
+
median = timings.quantile(0.5)
|
|
146
|
+
rows.append(
|
|
147
|
+
{
|
|
148
|
+
"device": str(device),
|
|
149
|
+
"batch": batch,
|
|
150
|
+
"min_ms": timings.min(),
|
|
151
|
+
"median_ms": median,
|
|
152
|
+
"max_ms": timings.max(),
|
|
153
|
+
"samples_ms": times,
|
|
154
|
+
"per_item_ms": median / batch,
|
|
155
|
+
"per_item_min_ms": timings.min() / batch,
|
|
156
|
+
"per_item_max_ms": timings.max() / batch,
|
|
157
|
+
"probe": probe,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
selected.elapsed_ms += context.elapsed_ms
|
|
161
|
+
selected.calls.extend(context.calls)
|
|
162
|
+
return selected.finish(
|
|
163
|
+
rows=rows,
|
|
164
|
+
warnings=warnings,
|
|
165
|
+
reference_checks=references,
|
|
166
|
+
parameters={
|
|
167
|
+
"primitive": primitive,
|
|
168
|
+
"size": size,
|
|
169
|
+
"repeats": repeats,
|
|
170
|
+
"seed": seed,
|
|
171
|
+
"batches": sorted(set(batches)),
|
|
172
|
+
"warmups": 1,
|
|
173
|
+
},
|
|
174
|
+
timing_note=(
|
|
175
|
+
"Wall time, synchronized before and after each call; "
|
|
176
|
+
"input generation excluded."
|
|
177
|
+
),
|
|
178
|
+
persistence_note=(
|
|
179
|
+
"Persistence reduction is sequential per batch member, with CPU "
|
|
180
|
+
"bookkeeping even for GPU inputs. It is expected to scale differently."
|
|
181
|
+
),
|
|
182
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Request validation, compute timing, and the JSON boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from inspect import signature
|
|
9
|
+
from time import perf_counter
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
# These are UI resource budgets, not numerical accuracy claims. They bound dense
|
|
15
|
+
# graph work, cubic VR simplex counts, and repeated sweeps on a laptop CPU.
|
|
16
|
+
MAX_NODES = 64
|
|
17
|
+
MAX_POINTS = 40
|
|
18
|
+
MAX_CURVATURE_POINTS = 12
|
|
19
|
+
MAX_BATCH_SIZE = 256
|
|
20
|
+
MAX_PERSISTENCE_BATCH = 16
|
|
21
|
+
MAX_PERSISTENCE_SIZE = 16
|
|
22
|
+
MAX_REPEATS = 7
|
|
23
|
+
MAX_FLOW_STEPS = 24
|
|
24
|
+
MAX_FLOW_FRAMES = 8
|
|
25
|
+
MAX_REQUEST_BYTES = 256 * 1024
|
|
26
|
+
REQUEST_TIMEOUT_SECONDS = 20
|
|
27
|
+
|
|
28
|
+
# Presentation and sampling defaults are choices for a small first view.
|
|
29
|
+
DEFAULT_SEED = 0
|
|
30
|
+
DEFAULT_NODES = 24
|
|
31
|
+
DEFAULT_POINTS = 24
|
|
32
|
+
DEFAULT_NOISE = 0.05
|
|
33
|
+
DEFAULT_REPEATS = 3
|
|
34
|
+
DEFAULT_SWEEP = (1, 4, 16, 64, 256)
|
|
35
|
+
CURVE_SAMPLES = 65
|
|
36
|
+
GRID_RINGS = 5
|
|
37
|
+
GRID_RAYS = 12
|
|
38
|
+
# Stay away from the positive-curvature chart pole when sampling the grid.
|
|
39
|
+
CHART_POLE_FRACTION = 0.8
|
|
40
|
+
DEFAULT_GRID_EXTENT = 1.0
|
|
41
|
+
SECONDS_TO_MS = 1000
|
|
42
|
+
DTYPES = {"float32": torch.float32, "float64": torch.float64}
|
|
43
|
+
Request = dict[str, Any]
|
|
44
|
+
T = TypeVar("T")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def integer(request: Request, key: str, default: int, low: int, high: int) -> int:
|
|
48
|
+
"""Read an integer without silently truncating fractional input."""
|
|
49
|
+
value = request.get(key, default)
|
|
50
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
51
|
+
raise ValueError(f"{key} must be an integer")
|
|
52
|
+
if not low <= value <= high:
|
|
53
|
+
raise ValueError(f"{key} must be between {low} and {high}")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def number(
|
|
58
|
+
request: Request, key: str, default: float, low: float, high: float
|
|
59
|
+
) -> float:
|
|
60
|
+
"""Read a finite scalar within the control's documented domain."""
|
|
61
|
+
value = request.get(key, default)
|
|
62
|
+
if isinstance(value, bool) or not isinstance(value, (float, int)):
|
|
63
|
+
raise ValueError(f"{key} must be a number")
|
|
64
|
+
value = float(value)
|
|
65
|
+
if not math.isfinite(value) or not low <= value <= high:
|
|
66
|
+
raise ValueError(f"{key} must be finite and between {low} and {high}")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def choice(request: Request, key: str, default: str, options: tuple[str, ...]) -> str:
|
|
71
|
+
"""Read a named option, rejecting unexpected routes through the adapter."""
|
|
72
|
+
value = request.get(key, default)
|
|
73
|
+
if value not in options:
|
|
74
|
+
raise ValueError(f"{key} must be one of {', '.join(options)}")
|
|
75
|
+
return str(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def json_safe(value: Any) -> Any:
|
|
79
|
+
"""Detach tensors once at the boundary; JSON has no NaN or infinity."""
|
|
80
|
+
if isinstance(value, torch.Tensor):
|
|
81
|
+
return json_safe(value.detach().cpu().tolist())
|
|
82
|
+
if isinstance(value, float):
|
|
83
|
+
return value if math.isfinite(value) else None
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
return {str(key): json_safe(item) for key, item in value.items()}
|
|
86
|
+
if isinstance(value, (list, tuple)):
|
|
87
|
+
return [json_safe(item) for item in value]
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Context:
|
|
93
|
+
"""Keep chosen tensor placement and synchronized library-call time together."""
|
|
94
|
+
|
|
95
|
+
device: torch.device
|
|
96
|
+
dtype: torch.dtype
|
|
97
|
+
elapsed_ms: float = 0.0
|
|
98
|
+
calls: list[Request] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_request(cls, request: Request) -> Context:
|
|
102
|
+
dtype_name = choice(request, "dtype", "float64", tuple(DTYPES))
|
|
103
|
+
device = torch.device(str(request.get("device", "cpu")))
|
|
104
|
+
if device.type not in ("cpu", "cuda"):
|
|
105
|
+
raise ValueError("device must be cpu or cuda (CUDA also names ROCm)")
|
|
106
|
+
if device.type == "cpu":
|
|
107
|
+
device = torch.device("cpu")
|
|
108
|
+
if device.type == "cuda":
|
|
109
|
+
if not torch.cuda.is_available():
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"CUDA/ROCm is not available in this Python environment"
|
|
112
|
+
)
|
|
113
|
+
device = torch.device("cuda", device.index or 0)
|
|
114
|
+
# Allocate now so an invalid GPU index fails before any computation.
|
|
115
|
+
torch.empty(0, device=device, dtype=DTYPES[dtype_name])
|
|
116
|
+
return cls(device, DTYPES[dtype_name])
|
|
117
|
+
|
|
118
|
+
def sync(self) -> None:
|
|
119
|
+
if self.device.type == "cuda":
|
|
120
|
+
torch.cuda.synchronize(self.device)
|
|
121
|
+
|
|
122
|
+
def call(self, function: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
123
|
+
"""Time only the requested library operation, including device completion."""
|
|
124
|
+
self.sync()
|
|
125
|
+
start = perf_counter()
|
|
126
|
+
try:
|
|
127
|
+
return function(*args, **kwargs)
|
|
128
|
+
finally:
|
|
129
|
+
self.sync()
|
|
130
|
+
duration = (perf_counter() - start) * SECONDS_TO_MS
|
|
131
|
+
self.elapsed_ms += duration
|
|
132
|
+
self.calls.append(
|
|
133
|
+
{
|
|
134
|
+
"function": f"{function.__module__}.{function.__qualname__}",
|
|
135
|
+
"elapsed_ms": duration,
|
|
136
|
+
"device": str(self.device),
|
|
137
|
+
"parameters": _scalar_arguments(function, args, kwargs),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def tensor(self, data: Any) -> torch.Tensor:
|
|
142
|
+
return torch.as_tensor(data, device=self.device, dtype=self.dtype)
|
|
143
|
+
|
|
144
|
+
def generator(self, seed: int) -> torch.Generator:
|
|
145
|
+
return torch.Generator(device=self.device).manual_seed(seed)
|
|
146
|
+
|
|
147
|
+
def finish(self, **values: Any) -> Request:
|
|
148
|
+
return dict(
|
|
149
|
+
json_safe(values),
|
|
150
|
+
device=str(self.device),
|
|
151
|
+
dtype=str(self.dtype).removeprefix("torch."),
|
|
152
|
+
elapsed_ms=self.elapsed_ms,
|
|
153
|
+
calls=json_safe(self.calls),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _scalar_arguments(
|
|
158
|
+
function: Callable[..., Any],
|
|
159
|
+
args: tuple[Any, ...],
|
|
160
|
+
kwargs: Request,
|
|
161
|
+
) -> Request:
|
|
162
|
+
# Read defaults from the dependency instead of copying them into the UI.
|
|
163
|
+
# Some torch C builtins have no Python signature; their inputs are already
|
|
164
|
+
# described by the scene's parameters and data arrays.
|
|
165
|
+
try:
|
|
166
|
+
bound = signature(function).bind(*args, **kwargs)
|
|
167
|
+
except (ValueError, TypeError):
|
|
168
|
+
return {}
|
|
169
|
+
bound.apply_defaults()
|
|
170
|
+
return {
|
|
171
|
+
key: value
|
|
172
|
+
for key, value in bound.arguments.items()
|
|
173
|
+
if isinstance(value, (str, int, float, bool)) or value is None
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def points_tensor(request: Request, context: Context, cap: int) -> torch.Tensor:
|
|
178
|
+
"""Read a finite (N, 2) point array; empty clicks are a valid cloud."""
|
|
179
|
+
raw = request.get("points", [])
|
|
180
|
+
if not isinstance(raw, list) or len(raw) > cap:
|
|
181
|
+
raise ValueError(f"points must be a list containing at most {cap} points")
|
|
182
|
+
if not raw:
|
|
183
|
+
return context.tensor([]).reshape(0, 2)
|
|
184
|
+
points = context.tensor(raw)
|
|
185
|
+
if points.shape != (len(raw), 2) or not bool(torch.isfinite(points).all()):
|
|
186
|
+
raise ValueError("points must contain finite [x, y] pairs")
|
|
187
|
+
return points
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Stereographic distances, geodesics, and a checked mean estimate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from holonomy_lib.hyperbolic import frechet_mean
|
|
10
|
+
from holonomy_lib.manifolds import KappaStereographicManifold
|
|
11
|
+
|
|
12
|
+
from .common import (
|
|
13
|
+
CHART_POLE_FRACTION,
|
|
14
|
+
CURVE_SAMPLES,
|
|
15
|
+
DEFAULT_GRID_EXTENT,
|
|
16
|
+
GRID_RAYS,
|
|
17
|
+
GRID_RINGS,
|
|
18
|
+
MAX_CURVATURE_POINTS,
|
|
19
|
+
Context,
|
|
20
|
+
Request,
|
|
21
|
+
number,
|
|
22
|
+
points_tensor,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Residual tolerance is a display diagnostic, scaled to the dtype's resolution.
|
|
26
|
+
MEAN_RESIDUAL_EPS_POWER = 0.5
|
|
27
|
+
# An initial asymmetric triangle inside every displayed chart at |κ| <= 2.
|
|
28
|
+
DEFAULT_CURVATURE_POINTS = ((-0.35, -0.15), (0.35, -0.15), (0.0, 0.4))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@torch.no_grad()
|
|
32
|
+
def compute(request: Request) -> Request:
|
|
33
|
+
"""Evaluate one chart with a leading singleton scene batch.
|
|
34
|
+
|
|
35
|
+
Points outside the chart remain in the response but are excluded from all
|
|
36
|
+
geometry. The library mean uses sequential Karcher iterations; its residual
|
|
37
|
+
is reported because an iteration cap alone does not establish convergence.
|
|
38
|
+
|
|
39
|
+
References:
|
|
40
|
+
Bachmann et al. (2020), Constant Curvature Graph Convolutional Networks.
|
|
41
|
+
Karcher (1977), Riemannian center of mass and mollifier smoothing.
|
|
42
|
+
"""
|
|
43
|
+
context = Context.from_request(request)
|
|
44
|
+
kappa = number(request, "kappa", -1, -2, 2)
|
|
45
|
+
points = points_tensor(request, context, MAX_CURVATURE_POINTS)
|
|
46
|
+
manifold = KappaStereographicManifold(
|
|
47
|
+
n=2,
|
|
48
|
+
kappa=kappa,
|
|
49
|
+
device=context.device,
|
|
50
|
+
dtype=context.dtype,
|
|
51
|
+
)
|
|
52
|
+
# The negative-curvature ball is open. Use the documented hemispherical
|
|
53
|
+
# chart for positive κ as well, without relying on a membership sign bug.
|
|
54
|
+
valid = (
|
|
55
|
+
points.square().sum(-1) * abs(kappa) < 1
|
|
56
|
+
if kappa != 0
|
|
57
|
+
else torch.ones(len(points), device=context.device, dtype=torch.bool)
|
|
58
|
+
)
|
|
59
|
+
indices = valid.nonzero().flatten()
|
|
60
|
+
included = points[valid]
|
|
61
|
+
distance = context.call(
|
|
62
|
+
manifold.distance,
|
|
63
|
+
included[None, :, None, :],
|
|
64
|
+
included[None, None, :, :],
|
|
65
|
+
)[0]
|
|
66
|
+
raw_pairs = request.get("pairs")
|
|
67
|
+
if raw_pairs is None:
|
|
68
|
+
pairs = torch.combinations(indices, r=2)
|
|
69
|
+
else:
|
|
70
|
+
if not isinstance(raw_pairs, list) or len(raw_pairs) > MAX_CURVATURE_POINTS**2:
|
|
71
|
+
raise ValueError("pairs must be a list of point-index pairs")
|
|
72
|
+
if raw_pairs:
|
|
73
|
+
if any(
|
|
74
|
+
not isinstance(pair, list)
|
|
75
|
+
or len(pair) != 2
|
|
76
|
+
or any(type(index) is not int for index in pair)
|
|
77
|
+
for pair in raw_pairs
|
|
78
|
+
):
|
|
79
|
+
raise ValueError("each pair must contain two integer indices")
|
|
80
|
+
pairs = torch.tensor(raw_pairs, device=context.device, dtype=torch.long)
|
|
81
|
+
if bool(((pairs < 0) | (pairs >= len(points))).any()):
|
|
82
|
+
raise ValueError("pair index is outside the point list")
|
|
83
|
+
pairs = pairs[valid[pairs].all(-1)]
|
|
84
|
+
else:
|
|
85
|
+
pairs = torch.empty((0, 2), device=context.device, dtype=torch.long)
|
|
86
|
+
steps = torch.linspace(
|
|
87
|
+
0, 1, CURVE_SAMPLES, device=context.device, dtype=context.dtype
|
|
88
|
+
)
|
|
89
|
+
start, end = points[pairs[:, 0]], points[pairs[:, 1]]
|
|
90
|
+
tangent = context.call(manifold.log, start, end)
|
|
91
|
+
curves = context.call(
|
|
92
|
+
manifold.exp, start[:, None, :], tangent[:, None, :] * steps[None, :, None]
|
|
93
|
+
)
|
|
94
|
+
extent = DEFAULT_GRID_EXTENT
|
|
95
|
+
if kappa > 0:
|
|
96
|
+
# exp_0 reaches the hemisphere boundary at tangent radius π/(4√κ).
|
|
97
|
+
extent = min(extent, CHART_POLE_FRACTION * math.pi / (4 * math.sqrt(kappa)))
|
|
98
|
+
angles = torch.linspace(
|
|
99
|
+
0, math.tau, CURVE_SAMPLES, device=context.device, dtype=context.dtype
|
|
100
|
+
)
|
|
101
|
+
radii = torch.arange(1, GRID_RINGS + 1, device=context.device, dtype=context.dtype)
|
|
102
|
+
radii *= extent / GRID_RINGS
|
|
103
|
+
rings = radii[:, None, None] * torch.stack((angles.cos(), angles.sin()), -1)[None]
|
|
104
|
+
ray_angles = (
|
|
105
|
+
torch.arange(GRID_RAYS, device=context.device, dtype=context.dtype)
|
|
106
|
+
* math.tau
|
|
107
|
+
/ GRID_RAYS
|
|
108
|
+
)
|
|
109
|
+
rays = (
|
|
110
|
+
extent
|
|
111
|
+
* steps[None, :, None]
|
|
112
|
+
* torch.stack((ray_angles.cos(), ray_angles.sin()), -1)[:, None]
|
|
113
|
+
)
|
|
114
|
+
grid = context.call(manifold.exp_0, torch.cat((rings, rays)))
|
|
115
|
+
mean: Request | None = None
|
|
116
|
+
warnings: list[str] = []
|
|
117
|
+
if kappa <= 0 and len(included):
|
|
118
|
+
try:
|
|
119
|
+
estimate = context.call(frechet_mean, included[None], manifold)
|
|
120
|
+
average_log = context.call(
|
|
121
|
+
manifold.log, estimate[:, None, :], included[None]
|
|
122
|
+
).mean(1)
|
|
123
|
+
residual = context.call(manifold.norm, estimate, average_log)[0]
|
|
124
|
+
converged = bool(
|
|
125
|
+
residual <= torch.finfo(context.dtype).eps ** MEAN_RESIDUAL_EPS_POWER
|
|
126
|
+
)
|
|
127
|
+
mean = {"point": estimate[0], "residual": residual, "converged": converged}
|
|
128
|
+
if not converged:
|
|
129
|
+
warnings.append(
|
|
130
|
+
"Fréchet iteration has not converged; "
|
|
131
|
+
"the marked point is only an estimate."
|
|
132
|
+
)
|
|
133
|
+
except (ValueError, RuntimeError) as error:
|
|
134
|
+
warnings.append(f"Fréchet mean unavailable: {error}")
|
|
135
|
+
return context.finish(
|
|
136
|
+
points=points,
|
|
137
|
+
valid=valid,
|
|
138
|
+
included_indices=indices,
|
|
139
|
+
distances=distance,
|
|
140
|
+
pairs=pairs,
|
|
141
|
+
curves=curves,
|
|
142
|
+
grid=grid,
|
|
143
|
+
mean=mean,
|
|
144
|
+
warnings=warnings,
|
|
145
|
+
boundary_radius=1 / math.sqrt(abs(kappa)) if kappa else None,
|
|
146
|
+
parameters={"kappa": kappa, "curve_samples": CURVE_SAMPLES},
|
|
147
|
+
chart_note=(
|
|
148
|
+
"Open hyperbolic ball"
|
|
149
|
+
if kappa < 0
|
|
150
|
+
else "Conservative hemispherical chart"
|
|
151
|
+
if kappa > 0
|
|
152
|
+
else "Flat chart: distance is 2 × Euclidean coordinate distance"
|
|
153
|
+
),
|
|
154
|
+
)
|