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,547 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Iterative Krylov solvers: conjugate gradient (CG) and LSQR.
|
|
5
|
+
|
|
6
|
+
The two workhorse iterative solvers for the library's linear systems:
|
|
7
|
+
|
|
8
|
+
- `cg`: symmetric positive-definite `A x = b` (Hestenes-Stiefel 1952).
|
|
9
|
+
Dense operators may be batched, `(B, n, n)`. A sparse operator is one
|
|
10
|
+
`(n, n)` matrix shared by the whole right-hand-side batch. A callable
|
|
11
|
+
matvec covers operators that are never materialized, such as normal
|
|
12
|
+
equations `AᵀA + λI` applied as two matvecs, or a sheaf Laplacian applied
|
|
13
|
+
as `δᵀ(δx)`.
|
|
14
|
+
- `lsqr`: least squares `min ‖Ax − b‖` for rectangular `A`, with
|
|
15
|
+
optional Tikhonov damping `min ‖Ax − b‖² + damp²·‖x‖²`
|
|
16
|
+
(Paige-Saunders 1982, via Golub-Kahan bidiagonalization). More
|
|
17
|
+
numerically reliable than solving the normal equations when `A` is
|
|
18
|
+
ill-conditioned: it works with `A` directly, never squaring the
|
|
19
|
+
condition number.
|
|
20
|
+
|
|
21
|
+
Defaults are derived, not tuned: convergence tolerances default to
|
|
22
|
+
`sqrt(eps(dtype))` (the point past which normal-equation-quality error
|
|
23
|
+
stops improving; ≈1.5e-8 for float64, ≈3.5e-4 for float32), CG's
|
|
24
|
+
iteration cap defaults to `n` (finite-termination bound of the Krylov
|
|
25
|
+
space), LSQR's to `2·n` (SciPy's convention).
|
|
26
|
+
|
|
27
|
+
Convergence checks force a device sync (`.item()`), so they run every
|
|
28
|
+
`sync_every` iterations (CONVENTIONS §4.1; same cadence pattern as
|
|
29
|
+
Sinkhorn's `SINKHORN_SYNC_EVERY_DEFAULT`).
|
|
30
|
+
|
|
31
|
+
References:
|
|
32
|
+
Hestenes, M. R., Stiefel, E. (1952). Methods of conjugate gradients
|
|
33
|
+
for solving linear systems. J. Res. Natl. Bur. Stand. 49(6):409-436.
|
|
34
|
+
Paige, C. C., Saunders, M. A. (1982). LSQR: an algorithm for sparse
|
|
35
|
+
linear equations and sparse least squares. ACM TOMS 8(1):43-71.
|
|
36
|
+
Saad, Y. (2003). Iterative Methods for Sparse Linear Systems, 2nd ed.
|
|
37
|
+
SIAM, §6.7 (CG convergence theory).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import warnings
|
|
43
|
+
from collections.abc import Callable
|
|
44
|
+
|
|
45
|
+
import torch
|
|
46
|
+
|
|
47
|
+
# Cadence of the host-syncing convergence check inside the solver
|
|
48
|
+
# loops. Same value and rationale as the established
|
|
49
|
+
# `SINKHORN_SYNC_EVERY_DEFAULT` (CONVENTIONS §4.1): checking every
|
|
50
|
+
# iteration serializes GPU work on a `.item()` sync; every 8 amortizes
|
|
51
|
+
# the sync to ~12% of iterations while wasting at most 7 cheap
|
|
52
|
+
# iterations past convergence. Catalog: `SOLVER_SYNC_EVERY_DEFAULT`
|
|
53
|
+
# in `notes/magic_numbers.md`.
|
|
54
|
+
SOLVER_SYNC_EVERY_DEFAULT: int = 8
|
|
55
|
+
|
|
56
|
+
_SPARSE_LAYOUTS = (
|
|
57
|
+
torch.sparse_coo, torch.sparse_csr, torch.sparse_csc,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
Operator = torch.Tensor | Callable[[torch.Tensor], torch.Tensor]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _default_tol(dtype: torch.dtype) -> float:
|
|
64
|
+
"""`sqrt(eps(dtype))`; derived default tolerance (module docstring)."""
|
|
65
|
+
return float(torch.finfo(dtype).eps) ** 0.5
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sym_matvec(A: Operator) -> Callable[[torch.Tensor], torch.Tensor]:
|
|
69
|
+
"""Normalize a symmetric operator to a `(B, n) → (B, n)` matvec.
|
|
70
|
+
|
|
71
|
+
Accepts a callable (used as-is), a sparse 2-D `(n, n)` tensor
|
|
72
|
+
(applied to the batch via one sparse×dense matmul), or a dense
|
|
73
|
+
`(n, n)` / `(B, n, n)` tensor (batched matmul, broadcasting an
|
|
74
|
+
unbatched A over the batch of right-hand sides).
|
|
75
|
+
"""
|
|
76
|
+
if callable(A):
|
|
77
|
+
return A
|
|
78
|
+
if not isinstance(A, torch.Tensor):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"A must be a torch.Tensor or a callable matvec; got {type(A)}"
|
|
81
|
+
)
|
|
82
|
+
if A.layout in _SPARSE_LAYOUTS:
|
|
83
|
+
if A.ndim != 2 or A.shape[0] != A.shape[1]:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"sparse A must be (n, n); got A.shape={tuple(A.shape)}"
|
|
86
|
+
)
|
|
87
|
+
# CSC is converted to CSR once, up front, so the matvec below does not
|
|
88
|
+
# repeat the conversion on every call.
|
|
89
|
+
A_coo = (
|
|
90
|
+
A if A.layout != torch.sparse_csc else A.to_sparse_csr()
|
|
91
|
+
)
|
|
92
|
+
return lambda v: torch.sparse.mm(A_coo, v.mT).mT
|
|
93
|
+
if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"dense A must be (..., n, n); got A.shape={tuple(A.shape)}"
|
|
96
|
+
)
|
|
97
|
+
return lambda v: (A @ v.unsqueeze(-1)).squeeze(-1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _operator_is_batched(A: object) -> bool:
|
|
101
|
+
"""True when A is a dense tensor with its own leading batch axis.
|
|
102
|
+
|
|
103
|
+
Input: the operator handed to `cg` or `lsqr` (dense tensor, sparse
|
|
104
|
+
matrix, or callable). Output: whether it carries a batch axis. Sparse
|
|
105
|
+
matrices are always one 2-D matrix and callables don't say, so both
|
|
106
|
+
count as unbatched. This decides whether a lone `b` may be squeezed
|
|
107
|
+
back to `(n,)`: with a batched A of size 1 it must not be, or the output
|
|
108
|
+
shape would depend on the batch size.
|
|
109
|
+
"""
|
|
110
|
+
return isinstance(A, torch.Tensor) and A.layout == torch.strided and A.ndim > 2
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalize_rhs(b: torch.Tensor) -> tuple[torch.Tensor, bool]:
|
|
114
|
+
"""`(n,) → (1, n)` with a flag to squeeze the output back."""
|
|
115
|
+
if b.ndim == 1:
|
|
116
|
+
return b.unsqueeze(0), True
|
|
117
|
+
if b.ndim != 2:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"b must be (n,) or (B, n); got b.shape={tuple(b.shape)}"
|
|
120
|
+
)
|
|
121
|
+
return b, False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _safe_norm(v: torch.Tensor) -> torch.Tensor:
|
|
125
|
+
"""Euclidean norm without unscaled squares; empty vectors have norm zero."""
|
|
126
|
+
# amax has no value on an empty dimension, so the empty case is handled first.
|
|
127
|
+
if v.shape[-1] == 0:
|
|
128
|
+
return v.new_zeros(v.shape[:-1])
|
|
129
|
+
# Divide by the largest magnitude before squaring, so the squares stay in
|
|
130
|
+
# range and a vector with huge entries does not overflow to inf. A zero
|
|
131
|
+
# vector divides by 1 instead of 0, and its norm is 0 either way.
|
|
132
|
+
scale = v.abs().amax(dim=-1)
|
|
133
|
+
denominator = torch.where(scale > 0, scale, torch.ones_like(scale))
|
|
134
|
+
return scale * torch.linalg.vector_norm(v / denominator.unsqueeze(-1), dim=-1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _rhs_scale(b: torch.Tensor) -> torch.Tensor:
|
|
138
|
+
"""Per-member maximum magnitude, with unit scale for a zero right-hand side."""
|
|
139
|
+
scale = b.abs().amax(dim=-1)
|
|
140
|
+
return torch.where(scale > 0, scale, torch.ones_like(scale))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _scale_matrix(
|
|
144
|
+
A: torch.Tensor, damp: float = 0.0,
|
|
145
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
146
|
+
"""Divide `A` by its largest magnitude, floored at `damp`. Sparse stays sparse.
|
|
147
|
+
|
|
148
|
+
Input: dense `(..., n, n)` or sparse 2-D `A`; `damp >= 0`.
|
|
149
|
+
Output: `(scaled A, scale)`. The caller divides its own damping by `scale`.
|
|
150
|
+
The floor makes `damp / scale` at most 1 after scaling.
|
|
151
|
+
"""
|
|
152
|
+
if A.layout in _SPARSE_LAYOUTS:
|
|
153
|
+
coo = A.to_sparse_coo().coalesce()
|
|
154
|
+
values = coo.values()
|
|
155
|
+
# amax fails on an empty value tensor, so an all-zero matrix gets scale 0.
|
|
156
|
+
scale = values.abs().amax() if values.numel() else values.new_zeros(())
|
|
157
|
+
scale = scale.clamp(min=damp)
|
|
158
|
+
# A zero scale (zero matrix, damp 0) becomes 1, so the division is a no-op.
|
|
159
|
+
scale = torch.where(scale > 0, scale, torch.ones_like(scale))
|
|
160
|
+
# Only the stored values are scaled. The indices come from a coalesced
|
|
161
|
+
# tensor, so the invariant check can be skipped.
|
|
162
|
+
scaled = torch.sparse_coo_tensor(
|
|
163
|
+
coo.indices(), values / scale, coo.shape, device=A.device, dtype=A.dtype,
|
|
164
|
+
check_invariants=False,
|
|
165
|
+
).coalesce()
|
|
166
|
+
return scaled, scale
|
|
167
|
+
scale = A.abs().amax(dim=(-2, -1)).clamp(min=damp)
|
|
168
|
+
scale = torch.where(scale > 0, scale, torch.ones_like(scale))
|
|
169
|
+
return A / scale[..., None, None], scale
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cg(
|
|
173
|
+
A: Operator,
|
|
174
|
+
b: torch.Tensor,
|
|
175
|
+
M: Callable[[torch.Tensor], torch.Tensor] | None = None,
|
|
176
|
+
x0: torch.Tensor | None = None,
|
|
177
|
+
rtol: float | None = None,
|
|
178
|
+
atol: float = 0.0,
|
|
179
|
+
maxiter: int | None = None,
|
|
180
|
+
sync_every: int = SOLVER_SYNC_EVERY_DEFAULT,
|
|
181
|
+
) -> torch.Tensor:
|
|
182
|
+
"""Conjugate gradient solve of symmetric positive-definite `A x = b`.
|
|
183
|
+
|
|
184
|
+
Math: minimizes the quadratic `½ xᵀAx − bᵀx` over expanding Krylov
|
|
185
|
+
subspaces. In exact arithmetic it terminates in at most `n` steps. In
|
|
186
|
+
floating point the A-norm of the error falls off like
|
|
187
|
+
`((√κ − 1)/(√κ + 1))^k` up to a constant factor, with `κ = cond(A)`
|
|
188
|
+
(Saad 2003, §6.11).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
A: the operator. One of: dense `(n, n)` or `(B, n, n)` tensor;
|
|
192
|
+
sparse `(n, n)` (COO/CSR/CSC; applied to the whole batch in one
|
|
193
|
+
sparse matmul); or a callable `(B, n) → (B, n)` matvec (for
|
|
194
|
+
never-materialized operators). Symmetric PD assumed,
|
|
195
|
+
not checked.
|
|
196
|
+
b: `(B, n)` or `(n,)` right-hand sides.
|
|
197
|
+
M: optional preconditioner as a callable `(B, n) → (B, n)`
|
|
198
|
+
applying `M⁻¹` (e.g. a Jacobi inverse-diagonal multiply).
|
|
199
|
+
x0: optional `(B, n)` / `(n,)` initial guess (default zeros).
|
|
200
|
+
rtol: relative tolerance on `‖r‖ ≤ max(rtol·‖b‖, atol)`; default
|
|
201
|
+
`sqrt(eps(dtype))` (derived; see module docstring).
|
|
202
|
+
atol: absolute tolerance floor (default 0).
|
|
203
|
+
maxiter: iteration cap; default `n` (finite-termination bound).
|
|
204
|
+
sync_every: convergence-check cadence (CONVENTIONS §4.1).
|
|
205
|
+
|
|
206
|
+
Iterations are sequential Krylov recurrences; all batch members advance
|
|
207
|
+
together, with converged members frozen. Tensor operators and right-hand
|
|
208
|
+
sides are scaled before iteration to avoid overflow in inner products.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
`x` with `b`'s shape, except that a lone `b` with a batched dense A
|
|
212
|
+
returns `(B, n)`. Warns (`UserWarning`) if any batch element
|
|
213
|
+
has not met the tolerance at `maxiter`, or a nonfinite iterate occurs.
|
|
214
|
+
|
|
215
|
+
References:
|
|
216
|
+
Hestenes-Stiefel (1952); Saad (2003) §6.7, §6.11.
|
|
217
|
+
"""
|
|
218
|
+
b2, squeeze = _normalize_rhs(b)
|
|
219
|
+
squeeze = squeeze and not _operator_is_batched(A)
|
|
220
|
+
B, n = b2.shape
|
|
221
|
+
matvec = _sym_matvec(A)
|
|
222
|
+
if isinstance(A, torch.Tensor) and A.shape[-1] != n:
|
|
223
|
+
raise ValueError(f"A last dimension must match b: {A.shape}, {b.shape}")
|
|
224
|
+
if not b2.is_floating_point():
|
|
225
|
+
raise ValueError(f"b must be floating point; got dtype={b2.dtype}")
|
|
226
|
+
if sync_every < 1:
|
|
227
|
+
raise ValueError(f"sync_every must be >= 1; got {sync_every}")
|
|
228
|
+
if rtol is None:
|
|
229
|
+
rtol = _default_tol(b2.dtype)
|
|
230
|
+
if maxiter is None:
|
|
231
|
+
maxiter = n
|
|
232
|
+
if rtol < 0 or atol < 0 or maxiter < 0:
|
|
233
|
+
raise ValueError("rtol, atol and maxiter must be nonnegative")
|
|
234
|
+
# An empty batch or an empty system has nothing to solve. A copy of b
|
|
235
|
+
# already has the right shape.
|
|
236
|
+
if B == 0 or n == 0:
|
|
237
|
+
return b.clone()
|
|
238
|
+
|
|
239
|
+
# Solve in relative units so large finite right-hand sides do not overflow
|
|
240
|
+
# in the CG recurrence. A dense or sparse matrix is scaled to max magnitude 1
|
|
241
|
+
# (Ã = A / a) and the rhs to unit scale (b̃ = b / s). Then x̃ = (a/s) x, so
|
|
242
|
+
# x = x̃ * solution_scale with solution_scale = s / a. A callable is not
|
|
243
|
+
# scaled, which is the same as a = 1.
|
|
244
|
+
bscale = _rhs_scale(b2)
|
|
245
|
+
solution_scale = bscale
|
|
246
|
+
if isinstance(A, torch.Tensor):
|
|
247
|
+
scaled_A, ascale = _scale_matrix(A)
|
|
248
|
+
matvec = _sym_matvec(scaled_A)
|
|
249
|
+
solution_scale = bscale / ascale
|
|
250
|
+
rhs = b2 / bscale.unsqueeze(-1)
|
|
251
|
+
|
|
252
|
+
def _precondition(residual: torch.Tensor) -> torch.Tensor:
|
|
253
|
+
z = M(residual) if M is not None else residual
|
|
254
|
+
# A scalar rescaling of z also rescales r·z and the next search
|
|
255
|
+
# direction, so the CG step is unchanged. Keeping z in unit range
|
|
256
|
+
# avoids overflow for either a large or a small preconditioner.
|
|
257
|
+
return z / _rhs_scale(z).unsqueeze(-1)
|
|
258
|
+
|
|
259
|
+
x = (torch.zeros_like(rhs) if x0 is None
|
|
260
|
+
else x0.reshape(B, n) / solution_scale.unsqueeze(-1))
|
|
261
|
+
r = rhs - matvec(x)
|
|
262
|
+
z = _precondition(r)
|
|
263
|
+
p = z.clone()
|
|
264
|
+
rz = (r * z).sum(dim=-1)
|
|
265
|
+
# Stopping target in scaled units. The test ||r|| <= max(rtol ||b||, atol)
|
|
266
|
+
# becomes the same test on r / s, with ||b̃|| = ||b|| / s.
|
|
267
|
+
target = torch.maximum(_safe_norm(rhs) * rtol, atol / bscale)
|
|
268
|
+
finite = torch.isfinite(x).all(dim=-1) & torch.isfinite(r).all(dim=-1)
|
|
269
|
+
done = finite & (_safe_norm(r) <= target)
|
|
270
|
+
failed = ~finite
|
|
271
|
+
n_done = 0
|
|
272
|
+
for it in range(1, maxiter + 1):
|
|
273
|
+
# Members that are done or failed are frozen. Their alpha is zeroed below,
|
|
274
|
+
# and their search direction is set to zero at the end of the step.
|
|
275
|
+
active = ~(done | failed)
|
|
276
|
+
Ap = matvec(p)
|
|
277
|
+
pAp = (p * Ap).sum(dim=-1)
|
|
278
|
+
# Keep the division safe for frozen members and for pAp == 0. A zero pAp
|
|
279
|
+
# is a breakdown, and it is marked failed below.
|
|
280
|
+
denominator = torch.where(active & (pAp != 0), pAp, torch.ones_like(pAp))
|
|
281
|
+
alpha = torch.where(active, rz / denominator, torch.zeros_like(rz))
|
|
282
|
+
candidate = x + alpha.unsqueeze(-1) * p
|
|
283
|
+
r_new = r - alpha.unsqueeze(-1) * Ap
|
|
284
|
+
finite = (torch.isfinite(candidate).all(dim=-1)
|
|
285
|
+
& torch.isfinite(r_new).all(dim=-1) & torch.isfinite(pAp))
|
|
286
|
+
# pAp <= 0 means A is not positive definite along p, or roundoff has
|
|
287
|
+
# gotten there first, so CG cannot continue for this member. Its step is
|
|
288
|
+
# thrown away, so a failed member keeps its last good iterate instead of
|
|
289
|
+
# one moved along a direction CG cannot trust. The warning still fires.
|
|
290
|
+
failed_now = active & (~finite | (pAp <= 0))
|
|
291
|
+
failed = failed | failed_now
|
|
292
|
+
step_ok = active & ~failed_now
|
|
293
|
+
x = torch.where(step_ok.unsqueeze(-1), candidate, x)
|
|
294
|
+
r = torch.where(step_ok.unsqueeze(-1), r_new, r)
|
|
295
|
+
done = done | (step_ok & (_safe_norm(r) <= target))
|
|
296
|
+
z = _precondition(r)
|
|
297
|
+
rz_new = (r * z).sum(dim=-1)
|
|
298
|
+
denominator = torch.where(rz != 0, rz, torch.ones_like(rz))
|
|
299
|
+
beta = rz_new / denominator
|
|
300
|
+
p = torch.where((~(done | failed)).unsqueeze(-1),
|
|
301
|
+
z + beta.unsqueeze(-1) * p, torch.zeros_like(p))
|
|
302
|
+
rz = rz_new
|
|
303
|
+
n_done = it
|
|
304
|
+
# The all-done check forces a host sync, so it runs every sync_every steps
|
|
305
|
+
# and on the last step.
|
|
306
|
+
if it % sync_every == 0 or it == maxiter:
|
|
307
|
+
if bool((done | failed).all()):
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
# Undo the scaling. A result that is nonfinite after unscaling counts as
|
|
311
|
+
# failed, even when the scaled iterate was finite.
|
|
312
|
+
result = x * solution_scale.unsqueeze(-1)
|
|
313
|
+
finite_result = torch.isfinite(result).all(dim=-1)
|
|
314
|
+
failed = failed | ~finite_result
|
|
315
|
+
unsuccessful = ~done | failed
|
|
316
|
+
if bool(unsuccessful.any()):
|
|
317
|
+
warnings.warn(
|
|
318
|
+
f"cg: {int(unsuccessful.sum())}/{B} batch elements not converged "
|
|
319
|
+
f"after {n_done} iterations (nonfinite or breakdown failures: "
|
|
320
|
+
f"{int(failed.sum())}). Result returned as-is; raise maxiter, "
|
|
321
|
+
"loosen rtol, or precondition.",
|
|
322
|
+
stacklevel=2,
|
|
323
|
+
)
|
|
324
|
+
return result.squeeze(0) if squeeze else result
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _lsqr_matvecs(
|
|
328
|
+
A: torch.Tensor,
|
|
329
|
+
) -> tuple[
|
|
330
|
+
Callable[[torch.Tensor], torch.Tensor],
|
|
331
|
+
Callable[[torch.Tensor], torch.Tensor],
|
|
332
|
+
int,
|
|
333
|
+
int,
|
|
334
|
+
]:
|
|
335
|
+
"""`(matvec, rmatvec, m, n)` for dense batched or sparse 2-D `A`."""
|
|
336
|
+
if not isinstance(A, torch.Tensor):
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"A must be a torch.Tensor for lsqr; got {type(A)}"
|
|
339
|
+
)
|
|
340
|
+
if A.layout in _SPARSE_LAYOUTS:
|
|
341
|
+
if A.ndim != 2:
|
|
342
|
+
raise ValueError(
|
|
343
|
+
f"sparse A must be 2-D (m, n); got A.shape={tuple(A.shape)}"
|
|
344
|
+
)
|
|
345
|
+
m, n = A.shape
|
|
346
|
+
A_coo = (A if A.layout == torch.sparse_coo
|
|
347
|
+
else A.to_sparse_coo()).coalesce()
|
|
348
|
+
At = A_coo.t().coalesce()
|
|
349
|
+
return (
|
|
350
|
+
lambda v: torch.sparse.mm(A_coo, v.mT).mT,
|
|
351
|
+
lambda u: torch.sparse.mm(At, u.mT).mT,
|
|
352
|
+
m, n,
|
|
353
|
+
)
|
|
354
|
+
if A.ndim < 2:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"dense A must be (..., m, n); got A.shape={tuple(A.shape)}"
|
|
357
|
+
)
|
|
358
|
+
m, n = A.shape[-2], A.shape[-1]
|
|
359
|
+
return (
|
|
360
|
+
lambda v: (A @ v.unsqueeze(-1)).squeeze(-1),
|
|
361
|
+
lambda u: (A.mT @ u.unsqueeze(-1)).squeeze(-1),
|
|
362
|
+
m, n,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def lsqr(
|
|
367
|
+
A: torch.Tensor,
|
|
368
|
+
b: torch.Tensor,
|
|
369
|
+
damp: float = 0.0,
|
|
370
|
+
atol: float | None = None,
|
|
371
|
+
btol: float | None = None,
|
|
372
|
+
maxiter: int | None = None,
|
|
373
|
+
sync_every: int = SOLVER_SYNC_EVERY_DEFAULT,
|
|
374
|
+
) -> torch.Tensor:
|
|
375
|
+
"""LSQR: iterative least squares `min ‖Ax − b‖² (+ damp²·‖x‖²)`.
|
|
376
|
+
|
|
377
|
+
Golub-Kahan bidiagonalization of `A` with QR-by-Givens updates
|
|
378
|
+
(Paige-Saunders 1982, Alg. LSQR). Analytically equivalent to CG on
|
|
379
|
+
the normal equations `(AᵀA + damp²·I) x = Aᵀb` but numerically
|
|
380
|
+
preferable: it never forms `AᵀA`, so the effective condition number
|
|
381
|
+
is `cond(A)`, not `cond(A)²`. Handles rank-deficient and
|
|
382
|
+
inconsistent systems (converges to the minimum-norm least-squares
|
|
383
|
+
solution).
|
|
384
|
+
|
|
385
|
+
Stopping follows Paige-Saunders §6, tests 1-2 (without SciPy's
|
|
386
|
+
condition-limit test):
|
|
387
|
+
1. compatible systems: `‖r‖ ≤ btol·‖b‖ + atol·‖A‖·‖x‖`
|
|
388
|
+
2. least-squares: `‖Aᵀr‖ ≤ atol·‖A‖·‖r‖`
|
|
389
|
+
with `‖A‖` the running Frobenius estimate from the bidiagonalization,
|
|
390
|
+
damping term included.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
A: dense `(m, n)` / `(B, m, n)`, or sparse 2-D `(m, n)` (applied
|
|
394
|
+
to the whole batch via sparse matmuls). Callables are not
|
|
395
|
+
accepted here (LSQR needs the adjoint too; pass a tensor).
|
|
396
|
+
b: `(B, m)` or `(m,)`.
|
|
397
|
+
damp: Tikhonov damping `≥ 0`; `damp = √λ` solves the ridge
|
|
398
|
+
problem `min ‖Ax − b‖² + λ‖x‖²`.
|
|
399
|
+
atol, btol: stopping tolerances; default `sqrt(eps(dtype))`
|
|
400
|
+
(derived; see module docstring).
|
|
401
|
+
maxiter: iteration cap; default `2·n` (SciPy convention).
|
|
402
|
+
sync_every: convergence-check cadence (CONVENTIONS §4.1).
|
|
403
|
+
|
|
404
|
+
Iterations are sequential Golub-Kahan recurrences. Operators and right-hand
|
|
405
|
+
sides are scaled before iteration; rotations use overflow-safe hypotenuses.
|
|
406
|
+
Batch members that converge stop updating their solution.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
`x`: `(B, n)`, or `(n,)` when both `b` and A are unbatched. Warns
|
|
410
|
+
(`UserWarning`) if any batch element met neither stopping test
|
|
411
|
+
at `maxiter`, or a nonfinite iterate occurs.
|
|
412
|
+
|
|
413
|
+
References:
|
|
414
|
+
Paige-Saunders (1982), ACM TOMS 8(1):43-71, Alg. LSQR + §6
|
|
415
|
+
(stopping criteria). Reference implementation compared against:
|
|
416
|
+
`scipy.sparse.linalg.lsqr` (tests).
|
|
417
|
+
"""
|
|
418
|
+
b2, squeeze = _normalize_rhs(b)
|
|
419
|
+
squeeze = squeeze and not _operator_is_batched(A)
|
|
420
|
+
B = b2.shape[0]
|
|
421
|
+
_, _, m, n = _lsqr_matvecs(A)
|
|
422
|
+
if b2.shape[-1] != m:
|
|
423
|
+
raise ValueError(
|
|
424
|
+
f"b last dim must equal A's row count m={m}; got "
|
|
425
|
+
f"b.shape={tuple(b.shape)}"
|
|
426
|
+
)
|
|
427
|
+
if not b2.is_floating_point() or not A.is_floating_point():
|
|
428
|
+
raise ValueError("A and b must be floating point")
|
|
429
|
+
if damp < 0:
|
|
430
|
+
raise ValueError(f"damp must be >= 0; got {damp}")
|
|
431
|
+
if sync_every < 1:
|
|
432
|
+
raise ValueError(f"sync_every must be >= 1; got {sync_every}")
|
|
433
|
+
if atol is None:
|
|
434
|
+
atol = _default_tol(b2.dtype)
|
|
435
|
+
if btol is None:
|
|
436
|
+
btol = _default_tol(b2.dtype)
|
|
437
|
+
if maxiter is None:
|
|
438
|
+
maxiter = 2 * n
|
|
439
|
+
if atol < 0 or btol < 0 or maxiter < 0:
|
|
440
|
+
raise ValueError("atol, btol and maxiter must be nonnegative")
|
|
441
|
+
if B == 0 or m == 0 or n == 0:
|
|
442
|
+
out = b2.new_zeros(B, n)
|
|
443
|
+
return out.squeeze(0) if squeeze else out
|
|
444
|
+
|
|
445
|
+
scaled_A, ascale = _scale_matrix(A, damp=damp)
|
|
446
|
+
matvec, rmatvec, _, _ = _lsqr_matvecs(scaled_A)
|
|
447
|
+
bscale = _rhs_scale(b2)
|
|
448
|
+
rhs = b2 / bscale.unsqueeze(-1)
|
|
449
|
+
# The damping is in A's units, so it is divided by the same scale as A. The
|
|
450
|
+
# solution is multiplied back by s / a at the end, which leaves the ridge
|
|
451
|
+
# problem unchanged.
|
|
452
|
+
damping = damp / ascale
|
|
453
|
+
|
|
454
|
+
def _safe_normalize(t: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
455
|
+
nrm = _safe_norm(t)
|
|
456
|
+
denominator = torch.where(nrm > 0, nrm, torch.ones_like(nrm))
|
|
457
|
+
return t / denominator.unsqueeze(-1), nrm
|
|
458
|
+
|
|
459
|
+
def _denominator(t: torch.Tensor) -> torch.Tensor:
|
|
460
|
+
return torch.where(t != 0, t, torch.ones_like(t))
|
|
461
|
+
|
|
462
|
+
def _hypot(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
|
|
463
|
+
# At (0, 0), torch.hypot's backward is 0/0, and a torch.where mask does
|
|
464
|
+
# not stop that NaN from reaching the gradient. Feed hypot a safe pair
|
|
465
|
+
# there, then mask the output to zero.
|
|
466
|
+
nonzero = (a != 0) | (b != 0)
|
|
467
|
+
safe_a = torch.where(nonzero, a, torch.ones_like(a))
|
|
468
|
+
safe_b = torch.where(nonzero, b, torch.zeros_like(b))
|
|
469
|
+
return torch.where(nonzero, torch.hypot(safe_a, safe_b), 0.0)
|
|
470
|
+
|
|
471
|
+
# Start of Golub-Kahan: u1 = b / beta1 and v1 = Aᵀu1 / alpha1. Both
|
|
472
|
+
# normalizations are guarded, so a zero b or a zero Aᵀb leaves the vector at zero.
|
|
473
|
+
u, beta = _safe_normalize(rhs)
|
|
474
|
+
bnorm = beta.clone()
|
|
475
|
+
v, alfa = _safe_normalize(rmatvec(u))
|
|
476
|
+
w = v.clone()
|
|
477
|
+
x = b2.new_zeros(B, n)
|
|
478
|
+
phibar = beta.clone()
|
|
479
|
+
rhobar = alfa.clone()
|
|
480
|
+
anorm = torch.zeros_like(beta)
|
|
481
|
+
damped_residual = torch.zeros_like(beta)
|
|
482
|
+
finite = torch.isfinite(beta) & torch.isfinite(alfa)
|
|
483
|
+
# beta = 0 means b = 0, and alfa = 0 means Aᵀb = 0. In either case x = 0 is
|
|
484
|
+
# already the least-squares answer, so those members are done before the loop.
|
|
485
|
+
done = finite & ((beta == 0) | (alfa == 0))
|
|
486
|
+
failed = ~finite
|
|
487
|
+
n_done = 0
|
|
488
|
+
for itn in range(1, maxiter + 1):
|
|
489
|
+
active = ~(done | failed)
|
|
490
|
+
u, beta = _safe_normalize(matvec(v) - alfa.unsqueeze(-1) * u)
|
|
491
|
+
anorm = _hypot(_hypot(anorm, alfa), _hypot(beta, damping))
|
|
492
|
+
v, alfa = _safe_normalize(rmatvec(u) - beta.unsqueeze(-1) * v)
|
|
493
|
+
|
|
494
|
+
# Finished members still participate until the next host sync.
|
|
495
|
+
# Give them an identity rotation before any hypot or division.
|
|
496
|
+
rhobar = torch.where(active, rhobar, torch.ones_like(rhobar))
|
|
497
|
+
beta = torch.where(active, beta, 0.0)
|
|
498
|
+
alfa = torch.where(active, alfa, 0.0)
|
|
499
|
+
active_damping = torch.where(active, damping, 0.0)
|
|
500
|
+
phibar = torch.where(active, phibar, 0.0)
|
|
501
|
+
# Both rotations use hypot: squaring a finite coefficient need not
|
|
502
|
+
# produce a finite intermediate, even when the rotation is well-defined.
|
|
503
|
+
rhobar1 = _hypot(rhobar, active_damping)
|
|
504
|
+
cs1 = rhobar / _denominator(rhobar1)
|
|
505
|
+
psi = (active_damping / _denominator(rhobar1)) * phibar
|
|
506
|
+
phibar = cs1 * phibar
|
|
507
|
+
rho = _hypot(rhobar1, beta)
|
|
508
|
+
cs = rhobar1 / _denominator(rho)
|
|
509
|
+
sn = beta / _denominator(rho)
|
|
510
|
+
theta = sn * alfa
|
|
511
|
+
rhobar = -cs * alfa
|
|
512
|
+
phi = cs * phibar
|
|
513
|
+
phibar = sn * phibar
|
|
514
|
+
|
|
515
|
+
candidate = x + (phi / _denominator(rho)).unsqueeze(-1) * w
|
|
516
|
+
x = torch.where(active.unsqueeze(-1), candidate, x)
|
|
517
|
+
w = v - (theta / _denominator(rho)).unsqueeze(-1) * w
|
|
518
|
+
damped_residual = _hypot(damped_residual, psi)
|
|
519
|
+
rnorm = _hypot(phibar, damped_residual)
|
|
520
|
+
arnorm = alfa * (sn * phi).abs()
|
|
521
|
+
finite = (torch.isfinite(x).all(dim=-1) & torch.isfinite(rnorm)
|
|
522
|
+
& torch.isfinite(arnorm) & torch.isfinite(anorm)
|
|
523
|
+
& torch.isfinite(u).all(dim=-1) & torch.isfinite(v).all(dim=-1))
|
|
524
|
+
failed = failed | (active & ~finite)
|
|
525
|
+
# Paige-Saunders tests 1 and 2 (see the docstring). Test 1 asks whether the
|
|
526
|
+
# residual is small for a compatible system. Test 2 asks whether Aᵀr is
|
|
527
|
+
# small next to the matrix and the residual. Passing either one is done.
|
|
528
|
+
compatible = rnorm <= btol * bnorm + atol * anorm * _safe_norm(x)
|
|
529
|
+
least_squares = arnorm <= atol * anorm * rnorm
|
|
530
|
+
done = done | (active & finite & (compatible | least_squares))
|
|
531
|
+
n_done = itn
|
|
532
|
+
if itn % sync_every == 0 or itn == maxiter:
|
|
533
|
+
if bool((done | failed).all()):
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
result = x * (bscale / ascale).unsqueeze(-1)
|
|
537
|
+
failed = failed | ~torch.isfinite(result).all(dim=-1)
|
|
538
|
+
unsuccessful = ~done | failed
|
|
539
|
+
if bool(unsuccessful.any()):
|
|
540
|
+
warnings.warn(
|
|
541
|
+
f"lsqr: {int(unsuccessful.sum())}/{B} batch elements met neither "
|
|
542
|
+
f"stopping test after {n_done} iterations (nonfinite failures: "
|
|
543
|
+
f"{int(failed.sum())}). Result returned as-is; raise maxiter "
|
|
544
|
+
"or loosen atol/btol.",
|
|
545
|
+
stacklevel=2,
|
|
546
|
+
)
|
|
547
|
+
return result.squeeze(0) if squeeze else result
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.convex: problem-level convex optimization primitives.
|
|
5
|
+
|
|
6
|
+
This package holds CONVEX PROBLEM interfaces ("minimize this objective")
|
|
7
|
+
as opposed to the raw iterative solvers in `holonomy_lib.algebra.solvers`
|
|
8
|
+
("run this Krylov method"). A primitive enters this package only with a
|
|
9
|
+
named downstream consumer (repo rule: no speculative features); the
|
|
10
|
+
docstring of each function records who consumes it.
|
|
11
|
+
|
|
12
|
+
Currently implemented:
|
|
13
|
+
least_squares.regularized_lstsq: Tikhonov/ridge least squares
|
|
14
|
+
`min ‖Ax − b‖² + λ‖x‖²` with solver dispatch (Cholesky normal
|
|
15
|
+
equations / CG on the normal operator / LSQR with damping).
|
|
16
|
+
Consumers: synoros-substrate's ridge sites (expectation_unit,
|
|
17
|
+
shape_prediction, arbitrary_pairs, hierarchy_potential; all
|
|
18
|
+
hand-rolled variants of this one problem as of 2026-07).
|
|
19
|
+
|
|
20
|
+
Planned (each gated on a consumer materializing):
|
|
21
|
+
proximal operators, projections onto convex sets, accelerated
|
|
22
|
+
first-order methods.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from holonomy_lib.convex.least_squares import regularized_lstsq
|
|
26
|
+
|
|
27
|
+
__all__ = ["regularized_lstsq"]
|