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,118 @@
|
|
|
1
|
+
"""One persistence computation supplies the complete interactive filtration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
from holonomy_lib.simplicial import pairwise_distances
|
|
8
|
+
from holonomy_lib.topology import persistence_diagrams
|
|
9
|
+
|
|
10
|
+
from .common import Context, Request
|
|
11
|
+
from .datasets import cloud
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reference_check(context: Context) -> Request:
|
|
15
|
+
"""Check the square's sole H1 interval against [1, sqrt(2)).
|
|
16
|
+
|
|
17
|
+
References:
|
|
18
|
+
Edelsbrunner and Harer (2010), Computational Topology, VII.
|
|
19
|
+
"""
|
|
20
|
+
square = context.tensor([[[0, 0], [1, 0], [1, 1], [0, 1]]])
|
|
21
|
+
diagrams, masks = context.call(persistence_diagrams, square, max_dim=1)
|
|
22
|
+
bars = diagrams[1][masks[1]]
|
|
23
|
+
# Zero-length intervals do not change persistence and may be retained by a
|
|
24
|
+
# future reduction backend. The check concerns positive-lifetime H1 only.
|
|
25
|
+
bars = bars[bars[:, 1] > bars[:, 0]]
|
|
26
|
+
expected = context.tensor([[1, 2**0.5]])
|
|
27
|
+
passed = bars.shape == expected.shape and torch.allclose(bars, expected)
|
|
28
|
+
return {"passed": bool(passed), "h1": bars, "expected_h1": expected}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@torch.no_grad()
|
|
32
|
+
def compute(request: Request) -> Request:
|
|
33
|
+
"""Return bars and all radius events so slider movement needs no recompute.
|
|
34
|
+
|
|
35
|
+
Reduction in the dependency is sequential per batch member and uses CPU
|
|
36
|
+
bookkeeping even with GPU tensors. Counts below use [birth, death), including
|
|
37
|
+
all simplices with diameter <= ε at a tied filtration event.
|
|
38
|
+
|
|
39
|
+
References:
|
|
40
|
+
Edelsbrunner, Letscher and Zomorodian (2002), Topological persistence
|
|
41
|
+
and simplification. Discrete & Computational Geometry 28:511-533.
|
|
42
|
+
"""
|
|
43
|
+
context = Context.from_request(request)
|
|
44
|
+
points = cloud(request, context)
|
|
45
|
+
distances = context.call(pairwise_distances, points[None])[0]
|
|
46
|
+
if not bool(torch.isfinite(distances).all()):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"pairwise distances overflowed; use smaller coordinates or float64"
|
|
49
|
+
)
|
|
50
|
+
diagrams, masks = context.call(persistence_diagrams, points[None], max_dim=1)
|
|
51
|
+
bars = [diagram[0, mask[0]] for diagram, mask in zip(diagrams, masks, strict=True)]
|
|
52
|
+
indices = torch.arange(len(points), device=context.device)
|
|
53
|
+
edges = torch.combinations(indices, r=2)
|
|
54
|
+
triangles = torch.combinations(indices, r=3)
|
|
55
|
+
edge_radii = distances[edges[:, 0], edges[:, 1]]
|
|
56
|
+
triangle_radii = torch.stack(
|
|
57
|
+
(
|
|
58
|
+
distances[triangles[:, 0], triangles[:, 1]],
|
|
59
|
+
distances[triangles[:, 0], triangles[:, 2]],
|
|
60
|
+
distances[triangles[:, 1], triangles[:, 2]],
|
|
61
|
+
),
|
|
62
|
+
-1,
|
|
63
|
+
).amax(-1)
|
|
64
|
+
events = torch.unique(torch.cat((context.tensor([0]), edge_radii)), sorted=True)
|
|
65
|
+
betti = torch.stack(
|
|
66
|
+
[
|
|
67
|
+
(
|
|
68
|
+
(bar[None, :, 0] <= events[:, None])
|
|
69
|
+
& (events[:, None] < bar[None, :, 1])
|
|
70
|
+
).sum(-1)
|
|
71
|
+
for bar in bars
|
|
72
|
+
],
|
|
73
|
+
-1,
|
|
74
|
+
)
|
|
75
|
+
finite_h1 = bars[1][torch.isfinite(bars[1][:, 1])]
|
|
76
|
+
lifetimes = finite_h1[:, 1] - finite_h1[:, 0]
|
|
77
|
+
finite_h0_deaths = bars[0][torch.isfinite(bars[0][:, 1]), 1]
|
|
78
|
+
if lifetimes.numel() and bool((lifetimes > 0).any()):
|
|
79
|
+
# Start inside the longest-lived finite loop, not at an empty complex.
|
|
80
|
+
longest = finite_h1[lifetimes.argmax()]
|
|
81
|
+
initial_epsilon = longest[0] + (longest[1] - longest[0]) / 2
|
|
82
|
+
initial_reason = "Midpoint of the most persistent finite H1 interval"
|
|
83
|
+
elif finite_h0_deaths.numel():
|
|
84
|
+
initial_epsilon = finite_h0_deaths.quantile(0.5)
|
|
85
|
+
initial_reason = "Median finite H0 death"
|
|
86
|
+
else:
|
|
87
|
+
initial_epsilon = context.tensor(0)
|
|
88
|
+
initial_reason = "No finite intervals; starting at zero"
|
|
89
|
+
initial_event = torch.searchsorted(events, initial_epsilon, right=True) - 1
|
|
90
|
+
reference = reference_check(context)
|
|
91
|
+
warnings = (
|
|
92
|
+
[]
|
|
93
|
+
if reference["passed"]
|
|
94
|
+
else [
|
|
95
|
+
"This library checkout fails the square H1 reference: it emits extra "
|
|
96
|
+
"infinite bars. The bars and Betti counts shown here are provisional "
|
|
97
|
+
"library results, not corrected by the explorer."
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
return context.finish(
|
|
101
|
+
points=points,
|
|
102
|
+
distances=distances,
|
|
103
|
+
bars=bars,
|
|
104
|
+
edges=edges,
|
|
105
|
+
edge_radii=edge_radii,
|
|
106
|
+
triangles=triangles,
|
|
107
|
+
triangle_radii=triangle_radii,
|
|
108
|
+
events=events,
|
|
109
|
+
betti=betti,
|
|
110
|
+
initial_epsilon=initial_epsilon,
|
|
111
|
+
initial_event=initial_event,
|
|
112
|
+
initial_reason=initial_reason,
|
|
113
|
+
edge_counts=(edge_radii[None] <= events[:, None]).sum(-1),
|
|
114
|
+
triangle_counts=(triangle_radii[None] <= events[:, None]).sum(-1),
|
|
115
|
+
reference_check=reference,
|
|
116
|
+
warnings=warnings,
|
|
117
|
+
parameters={key: value for key, value in request.items() if key != "points"},
|
|
118
|
+
)
|
holonomy_lib/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""PyTorch primitives for geometry, graph spectra, and tensor computations.
|
|
5
|
+
|
|
6
|
+
Subpackages: algebra, convex, discrete_geometry, experimental, hyperbolic,
|
|
7
|
+
info_geometry, lie, manifolds, optimization, sheaf, simplicial, spectral,
|
|
8
|
+
tensor_calculus, and topology.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Internal graph-primitive utilities shared across modules.
|
|
5
|
+
|
|
6
|
+
Conventions enforced here (see `CONVENTIONS.md`):
|
|
7
|
+
|
|
8
|
+
Self-loop policy
|
|
9
|
+
----------------
|
|
10
|
+
Graph primitives in this library treat the input adjacency `A` as a
|
|
11
|
+
simple graph: the diagonal `A[i, i]` is **dropped at entry**, not
|
|
12
|
+
treated as an edge from node `i` to itself.
|
|
13
|
+
|
|
14
|
+
Rationale: most of the math (Forman-Ricci, Ollivier-Ricci, the
|
|
15
|
+
random-walk and symmetric-normalized Laplacians, magnetic Laplacian)
|
|
16
|
+
is defined on simple graphs in the source literature. Including a
|
|
17
|
+
self-loop changes `d_i = Σ_j A[i, j]` and every quantity built from
|
|
18
|
+
it (normalizers, walk distributions, phase factors). If each
|
|
19
|
+
primitive left the diagonal to the caller, the same input could give
|
|
20
|
+
different answers depending on whether that primitive happened to
|
|
21
|
+
cancel it, so the policy is applied once, at entry.
|
|
22
|
+
|
|
23
|
+
The combinatorial Laplacian `L = D − A` happens to be invariant to
|
|
24
|
+
self-loops (the diagonal subtraction zeroes them), but we still drop
|
|
25
|
+
them at entry so callers see consistent behavior across the library.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import torch
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def drop_self_loops(A: torch.Tensor) -> torch.Tensor:
|
|
34
|
+
"""Return a tensor with the diagonal of `A` zeroed.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
A: (..., n, n) adjacency-like tensor.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tensor with the same shape as `A`, identical to `A` everywhere
|
|
41
|
+
except `result[..., i, i] = 0` for all i.
|
|
42
|
+
|
|
43
|
+
Notes:
|
|
44
|
+
Uses `torch.where`, so the zeroing is one vectorized op over the
|
|
45
|
+
batch. The boolean eye mask leaves the value dtype of `A` unchanged.
|
|
46
|
+
|
|
47
|
+
References:
|
|
48
|
+
Chung (1997). Spectral Graph Theory. AMS, Chapter 1, simple graphs.
|
|
49
|
+
"""
|
|
50
|
+
n = A.shape[-1]
|
|
51
|
+
eye = torch.eye(n, device=A.device, dtype=torch.bool).expand_as(A)
|
|
52
|
+
return torch.where(eye, torch.zeros_like(A), A)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Cached Gauss-Legendre quadrature nodes (internal).
|
|
5
|
+
|
|
6
|
+
`scipy.special.roots_legendre` recomputes the rule on every call (the
|
|
7
|
+
Golub-Welsch tridiagonal eigensolve plus Newton refinement); it does NOT
|
|
8
|
+
cache (only `scipy.integrate.fixed_quad`'s private wrapper does). The
|
|
9
|
+
library's quadrature call sites (`manifolds.comparison.model_ball_volume`,
|
|
10
|
+
`hyperbolic.heat_kernel`) sit inside batched paths where the same `n_quad`
|
|
11
|
+
recurs on every invocation, so the recompute plus the numpy-to-torch
|
|
12
|
+
round-trip is pure per-call overhead. This module keeps one float64 CPU
|
|
13
|
+
master copy per `n_quad` and hands out dtype/device casts.
|
|
14
|
+
|
|
15
|
+
Callers must treat the returned tensors as read-only: when the requested
|
|
16
|
+
dtype/device match the master copy, `Tensor.to` returns the cached tensor
|
|
17
|
+
itself, not a copy.
|
|
18
|
+
|
|
19
|
+
References:
|
|
20
|
+
Golub, G. H., Welsch, J. H. (1969). Calculation of Gauss quadrature
|
|
21
|
+
rules. Mathematics of Computation 23(106):221-230. (The algorithm
|
|
22
|
+
underlying `scipy.special.roots_legendre`.)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
|
|
29
|
+
import torch
|
|
30
|
+
from scipy.special import roots_legendre
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@functools.cache
|
|
34
|
+
def _gauss_legendre_float64_cpu(n_quad: int) -> tuple[torch.Tensor, torch.Tensor]:
|
|
35
|
+
"""Master copy: float64 CPU `(nodes, weights)` of the `n_quad`-point rule.
|
|
36
|
+
|
|
37
|
+
Input is `n_quad >= 1`. Output is two float64 CPU tensors of shape
|
|
38
|
+
`(n_quad,)`. The cache key is `n_quad` alone, so every caller gets the
|
|
39
|
+
same tensors and none of them may write into them.
|
|
40
|
+
"""
|
|
41
|
+
nodes_np, weights_np = roots_legendre(n_quad)
|
|
42
|
+
return (
|
|
43
|
+
torch.as_tensor(nodes_np, dtype=torch.float64),
|
|
44
|
+
torch.as_tensor(weights_np, dtype=torch.float64),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def gauss_legendre_nodes(
|
|
49
|
+
n_quad: int, *, dtype: torch.dtype, device: torch.device,
|
|
50
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
51
|
+
"""`(nodes, weights)` of the `n_quad`-point Gauss-Legendre rule on (-1, 1).
|
|
52
|
+
|
|
53
|
+
The float64 CPU master is computed once per `n_quad` (`functools.cache`); the
|
|
54
|
+
per-call `.to(dtype, device)` is a cheap cast/copy (no-op when they match).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
n_quad: node count of the rule.
|
|
58
|
+
dtype: floating dtype of the returned tensors.
|
|
59
|
+
device: device of the returned tensors.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
`(nodes, weights)`, each `(n_quad,)`, read-only by convention.
|
|
63
|
+
|
|
64
|
+
References:
|
|
65
|
+
Golub-Welsch (1969); see module docstring.
|
|
66
|
+
"""
|
|
67
|
+
nodes, weights = _gauss_legendre_float64_cpu(n_quad)
|
|
68
|
+
# The cast comes last so the cache always holds float64 on CPU, whatever
|
|
69
|
+
# the caller asked for. When the request matches the master, `.to` hands
|
|
70
|
+
# back the cached tensor itself, which is why callers must not mutate it.
|
|
71
|
+
return (
|
|
72
|
+
nodes.to(dtype=dtype, device=device),
|
|
73
|
+
weights.to(dtype=dtype, device=device),
|
|
74
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.algebra: audit-clean wrappers around torch.linalg + extras.
|
|
5
|
+
|
|
6
|
+
Currently implemented:
|
|
7
|
+
linear.truncated_svd: batched truncated SVD (exact + randomized).
|
|
8
|
+
lanczos_eigsh: largest/smallest algebraic eigenpairs of dense or
|
|
9
|
+
sparse A; an explicit sigma (with which="SA")
|
|
10
|
+
targets nearby modes.
|
|
11
|
+
Full subspaces use torch.linalg.eigh.
|
|
12
|
+
Sparse SA uses the backends in eigsh_sparse
|
|
13
|
+
(bounded-pole SciPy eigsh / preconditioned
|
|
14
|
+
SciPy LOBPCG / torch.lobpcg).
|
|
15
|
+
eigsh_sparse.eigsh_sparse_sa: the sparse smallest-k backend, callable
|
|
16
|
+
directly for backend control.
|
|
17
|
+
solvers.cg: conjugate gradient for SPD systems: batched
|
|
18
|
+
dense, sparse, or callable-operator form.
|
|
19
|
+
solvers.lsqr: iterative (damped) least squares via
|
|
20
|
+
Golub-Kahan bidiagonalization.
|
|
21
|
+
|
|
22
|
+
Planned:
|
|
23
|
+
Einstein notation helpers, group representations (cyclic, dihedral),
|
|
24
|
+
Lie algebras (su(n), so(n)), exterior algebra (wedge products).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from holonomy_lib.algebra.eigsh_sparse import eigsh_sparse_sa
|
|
28
|
+
from holonomy_lib.algebra.lanczos import lanczos_eigsh
|
|
29
|
+
from holonomy_lib.algebra.linear import truncated_svd
|
|
30
|
+
from holonomy_lib.algebra.solvers import cg, lsqr
|
|
31
|
+
|
|
32
|
+
__all__ = ["cg", "eigsh_sparse_sa", "lanczos_eigsh", "lsqr", "truncated_svd"]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Smallest-algebraic eigenpairs of sparse real symmetric matrices.
|
|
5
|
+
|
|
6
|
+
By default, CPU inputs use SciPy shift-invert eigsh or preconditioned LOBPCG,
|
|
7
|
+
and other devices use torch.lobpcg. The SciPy boundary computes in CPU float64
|
|
8
|
+
and casts the result back. All backends are nondifferentiable. A Gershgorin
|
|
9
|
+
lower bound places the default shift-invert pole below the spectrum, including
|
|
10
|
+
for indefinite inputs. Full requests, and LOBPCG requests with n < 3k, use
|
|
11
|
+
dense eigh.
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
Ericsson, T., Ruhe, A. (1980). The spectral transformation Lanczos
|
|
15
|
+
method for the numerical solution of large sparse generalized
|
|
16
|
+
symmetric eigenvalue problems. Math. Comp. 35(152):1251-1268.
|
|
17
|
+
Knyazev, A. V. (2001). Toward the optimal preconditioned eigensolver:
|
|
18
|
+
locally optimal block preconditioned conjugate gradient method.
|
|
19
|
+
SIAM J. Sci. Comput. 23(2):517-541.
|
|
20
|
+
Saad, Y. (2011). Numerical Methods for Large Eigenvalue Problems,
|
|
21
|
+
2nd ed. SIAM, §3.2 (spectral transformation).
|
|
22
|
+
Golub, G. H., Van Loan, C. F. (2013). Matrix Computations, 4th ed.
|
|
23
|
+
Johns Hopkins University Press, §7.2 (Gershgorin bounds).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from typing import Literal
|
|
30
|
+
|
|
31
|
+
import torch
|
|
32
|
+
|
|
33
|
+
# Existing throughput threshold from substrate's sheaf_scale.py (2026-06).
|
|
34
|
+
# Its measurements concern Laplacian-like CPU sparsity, not every operator.
|
|
35
|
+
SPARSE_SA_ITER_SOLVER_N: int = 8000
|
|
36
|
+
|
|
37
|
+
# Existing cataloged convergence/preconditioner knobs from sheaf_scale.py.
|
|
38
|
+
SPILU_EPS_FRAC: float = 1e-3
|
|
39
|
+
SPILU_DROP_TOL: float = 1e-3
|
|
40
|
+
SPILU_FILL_FACTOR: int = 10
|
|
41
|
+
LOBPCG_TOL: float = 1e-6
|
|
42
|
+
LOBPCG_MAXITER: int = 100
|
|
43
|
+
|
|
44
|
+
# LOBPCG works in a Ritz subspace built from three blocks: the current
|
|
45
|
+
# vectors, their residuals, and the previous search directions. That subspace
|
|
46
|
+
# has up to 3k columns, so it needs n >= 3k. Smaller problems go to dense eigh.
|
|
47
|
+
LOBPCG_SUBSPACE_BLOCKS: int = len(("iterate", "residual", "search_direction"))
|
|
48
|
+
|
|
49
|
+
_SPARSE_LAYOUTS = (torch.sparse_coo, torch.sparse_csr, torch.sparse_csc)
|
|
50
|
+
Solver = Literal["auto", "eigsh", "lobpcg", "torch_lobpcg"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def eigsh_sparse_sa(
|
|
54
|
+
A: torch.Tensor,
|
|
55
|
+
k: int,
|
|
56
|
+
sigma: float | None = None,
|
|
57
|
+
solver: Solver = "auto",
|
|
58
|
+
generator: torch.Generator | None = None,
|
|
59
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
60
|
+
"""Return the smallest algebraic eigenpairs of sparse symmetric ``A``.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
A: real sparse ``(n, n)`` COO/CSR/CSC. Asymmetry emits a warning.
|
|
64
|
+
Grad-requiring inputs raise an error; detach explicitly to use
|
|
65
|
+
this nondifferentiable solver.
|
|
66
|
+
k: number of eigenpairs, ``1 <= k <= n``. Full requests, and LOBPCG
|
|
67
|
+
requests with n < 3k, use dense eigh because the iterative subspace
|
|
68
|
+
does not fit.
|
|
69
|
+
sigma: optional eigsh pole, strictly below the Gershgorin lower
|
|
70
|
+
bound. The default is that bound minus a dtype-scaled margin.
|
|
71
|
+
LOBPCG does not use a pole; explicit sigma is rejected there.
|
|
72
|
+
solver: ``auto`` selects torch.lobpcg off CPU, SciPy eigsh on CPU
|
|
73
|
+
up to ``SPARSE_SA_ITER_SOLVER_N``, then SciPy LOBPCG with shifted
|
|
74
|
+
incomplete-LU preconditioning. The three explicit names are
|
|
75
|
+
``eigsh``, ``lobpcg`` and ``torch_lobpcg``.
|
|
76
|
+
generator: random-start generator compatible with ``A.device``.
|
|
77
|
+
Used by every iterative backend, including native LOBPCG.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Ascending eigenvalues ``(k,)`` and orthonormal eigenvector columns
|
|
81
|
+
``(n, k)``, on ``A.device`` with ``A.dtype``. SciPy computes in
|
|
82
|
+
float64 on CPU; its transfer is an explicit backend boundary.
|
|
83
|
+
|
|
84
|
+
Notes:
|
|
85
|
+
Native LOBPCG is unpreconditioned; clustered eigenvalues can need
|
|
86
|
+
more iterations than the fixed cataloged limit. SciPy's shifted
|
|
87
|
+
ILU preconditioner is intended for Laplacian-like sparse problems.
|
|
88
|
+
|
|
89
|
+
References:
|
|
90
|
+
Ericsson and Ruhe (1980); Knyazev (2001); Saad (2011), §3.2;
|
|
91
|
+
Golub and Van Loan (2013), §7.2. Full citations above.
|
|
92
|
+
"""
|
|
93
|
+
if A.ndim != 2 or A.shape[0] != A.shape[1]:
|
|
94
|
+
raise ValueError(f"sparse A must be (n, n); got {A.shape}")
|
|
95
|
+
if A.layout not in _SPARSE_LAYOUTS:
|
|
96
|
+
raise ValueError("A must be a sparse tensor (COO/CSR/CSC)")
|
|
97
|
+
if not A.is_floating_point():
|
|
98
|
+
raise ValueError("A must have a real floating dtype")
|
|
99
|
+
n = A.shape[0]
|
|
100
|
+
if not isinstance(k, int) or isinstance(k, bool) or not 1 <= k <= n:
|
|
101
|
+
raise ValueError(f"k must satisfy 1 <= k <= n={n}; got k={k}")
|
|
102
|
+
if solver not in ("auto", "eigsh", "lobpcg", "torch_lobpcg"):
|
|
103
|
+
raise ValueError("solver must be auto, eigsh, lobpcg or torch_lobpcg")
|
|
104
|
+
if A.requires_grad:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"sparse eigensolvers do not support autograd; detach A explicitly"
|
|
107
|
+
)
|
|
108
|
+
if sigma is not None and not math.isfinite(sigma):
|
|
109
|
+
raise ValueError("sigma must be finite")
|
|
110
|
+
from holonomy_lib.algebra.lanczos import _warn_asymmetric
|
|
111
|
+
|
|
112
|
+
_warn_asymmetric(A)
|
|
113
|
+
# Coalesce first so duplicate entries are summed before the Gershgorin sums
|
|
114
|
+
# below read the values. A nonfinite entry would poison the bounds and the
|
|
115
|
+
# SciPy conversion, so it is rejected here.
|
|
116
|
+
coo = A.to_sparse_coo().coalesce()
|
|
117
|
+
if not torch.isfinite(coo.values()).all():
|
|
118
|
+
raise ValueError("A must have finite entries")
|
|
119
|
+
# Auto: non-CPU devices take torch.lobpcg. On CPU, eigsh's sparse LU is used
|
|
120
|
+
# up to SPARSE_SA_ITER_SOLVER_N, past which its fill-in grows too fast.
|
|
121
|
+
if solver == "auto":
|
|
122
|
+
if A.device.type != "cpu":
|
|
123
|
+
solver = "torch_lobpcg"
|
|
124
|
+
elif n <= SPARSE_SA_ITER_SOLVER_N:
|
|
125
|
+
solver = "eigsh"
|
|
126
|
+
else:
|
|
127
|
+
solver = "lobpcg"
|
|
128
|
+
if sigma is not None and solver != "eigsh":
|
|
129
|
+
raise ValueError("sigma is only used by the eigsh backend")
|
|
130
|
+
|
|
131
|
+
# Gershgorin discs: row i is centered on its diagonal entry, with radius
|
|
132
|
+
# equal to the sum of its off-diagonal magnitudes. Every eigenvalue lies in
|
|
133
|
+
# some disc, so the leftmost disc edge, `lower`, bounds the spectrum from
|
|
134
|
+
# below. `scale` is the largest |center| + radius and sizes the margins.
|
|
135
|
+
row, column = coo.indices()
|
|
136
|
+
entries = coo.values()
|
|
137
|
+
diagonal = entries.new_zeros(n).scatter_add(0, row, entries * (row == column))
|
|
138
|
+
radius = entries.new_zeros(n).scatter_add(0, row, entries.abs() * (row != column))
|
|
139
|
+
lower = (diagonal - radius).amin()
|
|
140
|
+
scale = (diagonal.abs() + radius).amax()
|
|
141
|
+
if sigma is not None and not bool(sigma < lower):
|
|
142
|
+
raise ValueError("sigma must be strictly below the Gershgorin lower bound")
|
|
143
|
+
# With scale zero the default pole lands exactly on the zero spectrum, so
|
|
144
|
+
# shift-invert would factor a singular matrix. Every vector is an
|
|
145
|
+
# eigenvector of the zero operator, so the first k identity columns work.
|
|
146
|
+
if not bool(scale):
|
|
147
|
+
return entries.new_zeros(k), torch.eye(n, k, dtype=A.dtype, device=A.device)
|
|
148
|
+
# Dense eigh when there is no room for an iterative subspace: k == n, or
|
|
149
|
+
# LOBPCG with n below its 3k requirement.
|
|
150
|
+
if k == n or (solver != "eigsh" and n < LOBPCG_SUBSPACE_BLOCKS * k):
|
|
151
|
+
values, vectors = torch.linalg.eigh(A.to_dense())
|
|
152
|
+
return values[:k], vectors[:, :k]
|
|
153
|
+
|
|
154
|
+
if solver == "torch_lobpcg":
|
|
155
|
+
initial = torch.randn(n, k, dtype=A.dtype, device=A.device, generator=generator)
|
|
156
|
+
values, vectors = torch.lobpcg(
|
|
157
|
+
coo,
|
|
158
|
+
k=k,
|
|
159
|
+
X=initial,
|
|
160
|
+
largest=False,
|
|
161
|
+
tol=LOBPCG_TOL,
|
|
162
|
+
niter=LOBPCG_MAXITER,
|
|
163
|
+
)
|
|
164
|
+
# torch.lobpcg does not promise an order, so sort to return ascending
|
|
165
|
+
# values like the other backends.
|
|
166
|
+
order = values.argsort()
|
|
167
|
+
return values[order], vectors[:, order]
|
|
168
|
+
|
|
169
|
+
import numpy as np
|
|
170
|
+
import scipy.sparse as sp
|
|
171
|
+
import scipy.sparse.linalg as spla
|
|
172
|
+
|
|
173
|
+
# SciPy works in host float64. The COO data is copied out here, and the
|
|
174
|
+
# results are cast back to A's device and dtype on return.
|
|
175
|
+
indices_np = coo.indices().cpu().numpy()
|
|
176
|
+
A_sp = sp.coo_matrix(
|
|
177
|
+
(entries.to(torch.float64).cpu().numpy(), (indices_np[0], indices_np[1])),
|
|
178
|
+
shape=(n, n),
|
|
179
|
+
).tocsr()
|
|
180
|
+
if solver == "eigsh":
|
|
181
|
+
if sigma is None:
|
|
182
|
+
# The nextafter step keeps the pole strictly below the bound
|
|
183
|
+
# even if subtraction rounds back to the bound at this scale.
|
|
184
|
+
pole_tensor = lower - torch.finfo(A.dtype).eps ** 0.5 * scale
|
|
185
|
+
pole = float(
|
|
186
|
+
torch.nextafter(pole_tensor, torch.full_like(lower, -torch.inf))
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
pole = sigma
|
|
190
|
+
# ARPACK's random start, drawn on A's device and moved to host float64.
|
|
191
|
+
initial_vector = (
|
|
192
|
+
torch.randn(
|
|
193
|
+
n,
|
|
194
|
+
device=A.device,
|
|
195
|
+
dtype=A.dtype,
|
|
196
|
+
generator=generator,
|
|
197
|
+
)
|
|
198
|
+
.to(torch.float64)
|
|
199
|
+
.cpu()
|
|
200
|
+
.numpy()
|
|
201
|
+
)
|
|
202
|
+
# Shift-invert with the pole below the spectrum. ARPACK's largest inverse
|
|
203
|
+
# eigenvalues are the eigenvalues of A nearest the pole. Every eigenvalue
|
|
204
|
+
# is above the pole, so those are the k smallest.
|
|
205
|
+
values_np, vectors_np = spla.eigsh(
|
|
206
|
+
A_sp,
|
|
207
|
+
k=k,
|
|
208
|
+
sigma=pole,
|
|
209
|
+
which="LM",
|
|
210
|
+
v0=initial_vector,
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
# Shift below the Gershgorin bound. The matrix being factored, A - shift I,
|
|
214
|
+
# is then positive definite even when A is indefinite, and LOBPCG's
|
|
215
|
+
# preconditioner is its incomplete LU.
|
|
216
|
+
shift = float(lower - SPILU_EPS_FRAC * scale)
|
|
217
|
+
ilu = spla.spilu(
|
|
218
|
+
(A_sp - shift * sp.identity(n, format="csc")).tocsc(),
|
|
219
|
+
drop_tol=SPILU_DROP_TOL,
|
|
220
|
+
fill_factor=SPILU_FILL_FACTOR,
|
|
221
|
+
)
|
|
222
|
+
# The preconditioner applies the ILU solve, which approximates (A - shift I)^-1.
|
|
223
|
+
preconditioner = spla.LinearOperator((n, n), matvec=ilu.solve)
|
|
224
|
+
initial_block = (
|
|
225
|
+
torch.randn(
|
|
226
|
+
n,
|
|
227
|
+
k,
|
|
228
|
+
device=A.device,
|
|
229
|
+
dtype=A.dtype,
|
|
230
|
+
generator=generator,
|
|
231
|
+
)
|
|
232
|
+
.to(torch.float64)
|
|
233
|
+
.cpu()
|
|
234
|
+
.numpy()
|
|
235
|
+
)
|
|
236
|
+
values_np, vectors_np = spla.lobpcg(
|
|
237
|
+
A_sp,
|
|
238
|
+
initial_block,
|
|
239
|
+
M=preconditioner,
|
|
240
|
+
largest=False,
|
|
241
|
+
tol=LOBPCG_TOL,
|
|
242
|
+
maxiter=LOBPCG_MAXITER,
|
|
243
|
+
)
|
|
244
|
+
# The backends don't promise an order, so sort ascending before returning.
|
|
245
|
+
order_np = np.argsort(values_np)
|
|
246
|
+
return (
|
|
247
|
+
torch.from_numpy(values_np[order_np].copy()).to(device=A.device, dtype=A.dtype),
|
|
248
|
+
torch.from_numpy(vectors_np[:, order_np].copy()).to(
|
|
249
|
+
device=A.device, dtype=A.dtype
|
|
250
|
+
),
|
|
251
|
+
)
|