torchsolve 0.0.1__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.
torchsolve/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Memory-lean iterative solvers for inverse problems in PyTorch.
2
+
3
+ The solvers here take the normal operator rather than the forward one, because
4
+ that is what a non-Cartesian reconstruction can afford to apply repeatedly, and
5
+ they are written so that an iteration allocates nothing it does not have to.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as _distribution_version
12
+
13
+ from ._cg import CGResult, Regularizer, conjugate_gradient
14
+ from ._irgnm import (
15
+ GaussNewtonResult,
16
+ NonlinearOperator,
17
+ autodiff,
18
+ gauss_newton,
19
+ )
20
+ from ._problem import InnerSolver, LinearProblem
21
+ from ._solvers import CGSolver, LstsqSolver
22
+
23
+ try:
24
+ __version__ = _distribution_version(__name__)
25
+ except PackageNotFoundError: # a source tree that was never installed
26
+ __version__ = "0.0.0.dev0"
27
+
28
+ __all__ = [
29
+ "CGResult",
30
+ "CGSolver",
31
+ "GaussNewtonResult",
32
+ "InnerSolver",
33
+ "LinearProblem",
34
+ "LstsqSolver",
35
+ "NonlinearOperator",
36
+ "Regularizer",
37
+ "__version__",
38
+ "autodiff",
39
+ "conjugate_gradient",
40
+ "gauss_newton",
41
+ ]
torchsolve/_cg.py ADDED
@@ -0,0 +1,556 @@
1
+ r"""Conjugate gradient for the regularised normal equations.
2
+
3
+ Solves, for the general least-squares problem
4
+
5
+ .. math::
6
+
7
+ \min_x \; \|A x - y\|^2 + \sum_k \lambda_k \|R_k x - c_k\|^2
8
+
9
+ the normal equations that stationarity gives,
10
+
11
+ .. math::
12
+
13
+ \Big(A^H A + \sum_k \lambda_k R_k^H R_k\Big) x
14
+ = A^H y + \sum_k \lambda_k R_k^H c_k,
15
+
16
+ by conjugate gradient, optionally preconditioned. The caller supplies
17
+ :math:`A^H A` and :math:`A^H y` rather than :math:`A`, because for a
18
+ non-Cartesian acquisition the normal operator is a convolution that costs far
19
+ less than a transform pair, and because that is the object a Toeplitz
20
+ factorisation stands for.
21
+
22
+ Two properties this implementation is careful about, both learned the hard way.
23
+
24
+ **It steps through negative curvature.** A compressed Toeplitz normal carries
25
+ eigenvalues just below zero -- the transfer values its support left out -- and
26
+ the iteration keeps reducing the residual through them. Refusing the step,
27
+ which is what a ``pAp > 0`` guard does, freezes the iteration well short of
28
+ the answer. BART's ``conjgrad`` stops only on an exactly zero curvature, and
29
+ so does this.
30
+
31
+ **It allocates nothing per iteration that it can avoid.** The updates are
32
+ in place and the inner products are fused reductions: ``torch.vdot`` and a
33
+ batched ``einsum`` take a dot without materialising the product, where
34
+ ``(a.conj() * b).real.sum()`` costs two whole volumes and
35
+ ``torch.linalg.vecdot`` costs the same. What is left is whatever the operator
36
+ itself returns.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import warnings
42
+ from collections.abc import Callable, Iterable, Sequence
43
+ from dataclasses import dataclass, field
44
+ from typing import Any, cast
45
+
46
+ import torch
47
+ from torch.autograd.function import once_differentiable
48
+
49
+ __all__ = ["CGResult", "Regularizer", "conjugate_gradient"]
50
+
51
+ Operator = Callable[[torch.Tensor], torch.Tensor]
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Regularizer:
56
+ r"""One term :math:`\lambda \|R x - c\|^2` of the objective.
57
+
58
+ Parameters
59
+ ----------
60
+ weight
61
+ The term's :math:`\lambda`. Must not be negative.
62
+ operator
63
+ :math:`R`. ``None`` means the identity, which is the common case and
64
+ is folded into a single scalar rather than applied.
65
+ adjoint
66
+ :math:`R^H`. Needed when ``operator`` is given and does not carry its
67
+ own ``adjoint`` or ``H`` attribute.
68
+ bias
69
+ :math:`c`, the term's target. ``None`` means zero, so the term pulls
70
+ towards the origin.
71
+
72
+ Examples
73
+ --------
74
+ Pull towards zero, which is Tikhonov regularisation:
75
+
76
+ >>> from torchsolve import Regularizer
77
+ >>> Regularizer(1e-3).operator is None
78
+ True
79
+
80
+ Pull towards a previous estimate, which is what an outer iteration wants:
81
+
82
+ >>> import torch
83
+ >>> previous = torch.zeros(4)
84
+ >>> term = Regularizer(1e-2, bias=previous)
85
+ >>> term.weight
86
+ 0.01
87
+ """
88
+
89
+ weight: float
90
+ operator: Operator | None = None
91
+ adjoint: Operator | None = None
92
+ bias: torch.Tensor | None = None
93
+
94
+ def __post_init__(self) -> None:
95
+ if self.weight < 0:
96
+ raise ValueError(
97
+ f"regularizer weight must not be negative, got {self.weight}"
98
+ )
99
+ if self.operator is None:
100
+ if self.adjoint is not None:
101
+ raise ValueError("an identity regularizer takes no adjoint")
102
+ return
103
+ if self.adjoint is not None:
104
+ return
105
+ for name in ("adjoint", "H"):
106
+ found = getattr(self.operator, name, None)
107
+ if callable(found):
108
+ object.__setattr__(self, "adjoint", found)
109
+ return
110
+ raise ValueError(
111
+ "a regularizer with an operator needs its adjoint: pass adjoint=, "
112
+ "or give the operator an 'adjoint' or 'H' attribute"
113
+ )
114
+
115
+ @property
116
+ def is_identity(self) -> bool:
117
+ """Whether this term regularises towards a target without transforming."""
118
+ return self.operator is None
119
+
120
+ def normal(self, vector: torch.Tensor) -> torch.Tensor:
121
+ r"""Apply :math:`R^H R` to a vector."""
122
+ forward = cast("Operator", self.operator)
123
+ backward = cast("Operator", self.adjoint)
124
+ return backward(forward(vector))
125
+
126
+ def project(self, vector: torch.Tensor) -> torch.Tensor:
127
+ r"""Apply :math:`R^H` to a vector, which is what a bias needs."""
128
+ return cast("Operator", self.adjoint)(vector)
129
+
130
+
131
+ @dataclass
132
+ class CGResult:
133
+ """What the iteration reached, and what it met on the way.
134
+
135
+ Parameters
136
+ ----------
137
+ solution
138
+ The final iterate.
139
+ iterations
140
+ How many steps were taken.
141
+ residual_norm
142
+ Norm of the final residual of the regularised normal equations.
143
+ converged
144
+ Whether the residual met the requested tolerance. Always ``False``
145
+ when no tolerance was requested, since nothing was checked.
146
+ definite
147
+ Whether the recurrence stayed positive throughout. ``False`` means the
148
+ operator is not positive definite and the answer is not a minimiser,
149
+ though it is still the best the iteration reached.
150
+ """
151
+
152
+ solution: torch.Tensor
153
+ iterations: int
154
+ residual_norm: torch.Tensor
155
+ converged: bool
156
+ definite: bool = True
157
+
158
+
159
+ @dataclass
160
+ class _System:
161
+ """The regularised normal operator, and the right-hand side it acts on."""
162
+
163
+ normal: Operator
164
+ tikhonov: float
165
+ terms: Sequence[Regularizer]
166
+ rhs: torch.Tensor = field(repr=False)
167
+
168
+ def __call__(self, vector: torch.Tensor) -> torch.Tensor:
169
+ result = self.normal(vector)
170
+ if self.tikhonov:
171
+ result = result.add(vector, alpha=self.tikhonov)
172
+ for term in self.terms:
173
+ result = result.add(term.normal(vector), alpha=term.weight)
174
+ return result
175
+
176
+
177
+ def _assemble(
178
+ normal: Operator,
179
+ rhs: torch.Tensor,
180
+ regularizers: Iterable[Regularizer],
181
+ ) -> _System:
182
+ """Fold the identity terms into a scalar and add every bias to the rhs."""
183
+ tikhonov = 0.0
184
+ shaped: list[Regularizer] = []
185
+ augmented = rhs
186
+ for term in regularizers:
187
+ if term.weight == 0:
188
+ continue
189
+ if term.is_identity:
190
+ tikhonov += term.weight
191
+ if term.bias is not None:
192
+ augmented = augmented.add(term.bias, alpha=term.weight)
193
+ continue
194
+ shaped.append(term)
195
+ if term.bias is not None:
196
+ augmented = augmented.add(term.project(term.bias), alpha=term.weight)
197
+ return _System(normal, tikhonov, tuple(shaped), augmented)
198
+
199
+
200
+ def _inner(left: torch.Tensor, right: torch.Tensor, batch: int | None) -> torch.Tensor:
201
+ """Real part of the inner product, without materialising the product.
202
+
203
+ ``torch.vdot`` is a fused dot and ``einsum`` reduces without a temporary;
204
+ the obvious ``(left.conj() * right).real.sum()`` costs two whole tensors,
205
+ and so does ``torch.linalg.vecdot``.
206
+ """
207
+ if batch is None:
208
+ flat_left, flat_right = left.reshape(-1), right.reshape(-1)
209
+ if left.is_complex():
210
+ return torch.vdot(flat_left, flat_right).real
211
+ return torch.dot(flat_left, flat_right)
212
+ moved_left = left.movedim(batch, 0).reshape(left.shape[batch], -1)
213
+ moved_right = right.movedim(batch, 0).reshape(right.shape[batch], -1)
214
+ product = torch.einsum("bi,bi->b", moved_left.conj(), moved_right)
215
+ shape = [1] * left.ndim
216
+ shape[batch] = left.shape[batch]
217
+ return (product.real if left.is_complex() else product).reshape(shape)
218
+
219
+
220
+ def _norm(value: torch.Tensor, batch: int | None) -> torch.Tensor:
221
+ """Euclidean norm, fused."""
222
+ if batch is None:
223
+ return torch.linalg.vector_norm(value)
224
+ moved = value.movedim(batch, 0).reshape(value.shape[batch], -1)
225
+ shape = [1] * value.ndim
226
+ shape[batch] = value.shape[batch]
227
+ return torch.linalg.vector_norm(moved, dim=1).reshape(shape)
228
+
229
+
230
+ def _iterate(
231
+ system: _System,
232
+ x0: torch.Tensor | None,
233
+ *,
234
+ preconditioner: Operator | None,
235
+ max_iter: int,
236
+ rtol: float,
237
+ atol: float,
238
+ batch_dim: int | None,
239
+ ) -> CGResult:
240
+ """Run the recurrence. See :func:`conjugate_gradient` for the arguments."""
241
+ target = system.rhs
242
+
243
+ if x0 is None:
244
+ solution = torch.zeros_like(target)
245
+ residual = target.clone()
246
+ else:
247
+ solution = x0.clone()
248
+ residual = target - system(solution)
249
+
250
+ preconditioned = residual if preconditioner is None else preconditioner(residual)
251
+ direction = preconditioned.clone()
252
+ rho = _inner(residual, preconditioned, batch_dim)
253
+
254
+ checking = rtol > 0.0 or atol > 0.0
255
+ threshold = atol + rtol * _norm(target, batch_dim) if checking else None
256
+
257
+ definite = True
258
+ converged = False
259
+ iterations = 0
260
+ for step_index in range(max_iter):
261
+ if threshold is not None and bool(
262
+ torch.all(_norm(residual, batch_dim) <= threshold)
263
+ ):
264
+ converged = True
265
+ break
266
+
267
+ # An exactly zero rho is exact convergence, not a failure: there is no
268
+ # residual left to reduce and every further step would be a no-op.
269
+ remaining = rho != 0
270
+ if not bool(torch.any(remaining)):
271
+ converged = True
272
+ break
273
+
274
+ applied = system(direction)
275
+ curvature = _inner(direction, applied, batch_dim)
276
+ # Only an exactly zero curvature has no step to take. A negative one
277
+ # does, and taking it is what keeps the residual falling.
278
+ usable = remaining & (curvature != 0)
279
+ if not bool(torch.any(usable)):
280
+ break
281
+ # Negative curvature is what indefiniteness looks like. A vanishing rho
282
+ # is convergence and must not be mistaken for it.
283
+ if definite and bool(torch.any(remaining & (curvature < 0))):
284
+ definite = False
285
+
286
+ alpha = torch.where(usable, rho / torch.where(usable, curvature, 1.0), 0.0)
287
+ solution.addcmul_(direction, alpha)
288
+ residual.addcmul_(applied, alpha, value=-1)
289
+ del applied
290
+
291
+ preconditioned = (
292
+ residual if preconditioner is None else preconditioner(residual)
293
+ )
294
+ updated = _inner(residual, preconditioned, batch_dim)
295
+ beta = torch.where(rho != 0, updated / torch.where(rho != 0, rho, 1.0), 0.0)
296
+ rho = updated
297
+ iterations = step_index + 1
298
+
299
+ if step_index + 1 < max_iter:
300
+ direction.mul_(beta).add_(preconditioned)
301
+
302
+ return CGResult(
303
+ solution=solution,
304
+ iterations=iterations,
305
+ residual_norm=_norm(residual, batch_dim).detach(),
306
+ converged=converged,
307
+ definite=definite,
308
+ )
309
+
310
+
311
+ class _ImplicitSolve(torch.autograd.Function):
312
+ """A solve that differentiates itself rather than its iterations.
313
+
314
+ The iterates are not a graph to walk back through: for a self-adjoint
315
+ system, if ``x`` solves ``M x = b`` then a gradient arriving at ``x``
316
+ reaches ``b`` as ``M^-1`` of itself, which is another solve. Memory is
317
+ therefore flat in the iteration count, and the backward pass costs one more
318
+ solve rather than one stored volume per step.
319
+ """
320
+
321
+ @staticmethod
322
+ def forward( # type: ignore[override]
323
+ ctx: Any,
324
+ target: torch.Tensor,
325
+ x0: torch.Tensor | None,
326
+ settings: _Settings,
327
+ record: dict[str, Any],
328
+ *parameters: torch.Tensor,
329
+ ) -> torch.Tensor:
330
+ system = _System(settings.normal, settings.tikhonov, settings.terms, target)
331
+ with torch.no_grad():
332
+ result = _iterate(
333
+ system,
334
+ x0,
335
+ preconditioner=settings.preconditioner,
336
+ max_iter=settings.max_iter,
337
+ rtol=settings.rtol,
338
+ atol=settings.atol,
339
+ batch_dim=settings.batch_dim,
340
+ )
341
+ record.update(
342
+ iterations=result.iterations,
343
+ residual_norm=result.residual_norm,
344
+ converged=result.converged,
345
+ definite=result.definite,
346
+ )
347
+ ctx.settings = settings
348
+ ctx.save_for_backward(result.solution, *parameters)
349
+ return result.solution
350
+
351
+ @staticmethod
352
+ @once_differentiable
353
+ def backward(ctx: Any, grad_solution: torch.Tensor) -> tuple[Any, ...]:
354
+ solution, *parameters = ctx.saved_tensors
355
+ settings: _Settings = ctx.settings
356
+ system = _System(
357
+ settings.normal, settings.tikhonov, settings.terms, grad_solution
358
+ )
359
+ with torch.no_grad():
360
+ adjoint = _iterate(
361
+ system,
362
+ None,
363
+ preconditioner=settings.preconditioner,
364
+ max_iter=settings.backward_max_iter,
365
+ rtol=settings.backward_rtol,
366
+ atol=settings.backward_atol,
367
+ batch_dim=settings.batch_dim,
368
+ ).solution
369
+
370
+ gradients: list[torch.Tensor | None] = [None] * len(parameters)
371
+ wanted = [
372
+ (index, parameter)
373
+ for index, parameter in enumerate(parameters)
374
+ if parameter.requires_grad
375
+ ]
376
+ if wanted:
377
+ # d/dp of (M(p) x - b) = 0 gives -adjoint^H (dM/dp) x, which is the
378
+ # gradient of this scalar with respect to whatever M closed over.
379
+ with torch.enable_grad():
380
+ applied = system.normal(solution.detach())
381
+ for term in system.terms:
382
+ applied = applied.add(
383
+ term.normal(solution.detach()), alpha=term.weight
384
+ )
385
+ pseudo = -_inner(adjoint.detach(), applied, None)
386
+ if pseudo.requires_grad:
387
+ found = torch.autograd.grad(
388
+ pseudo,
389
+ [parameter for _, parameter in wanted],
390
+ allow_unused=True,
391
+ )
392
+ for (index, _), gradient in zip(wanted, found, strict=True):
393
+ gradients[index] = gradient
394
+ return adjoint, None, None, None, *gradients
395
+
396
+
397
+ @dataclass(frozen=True)
398
+ class _Settings:
399
+ """Everything the backward pass has to reconstruct the system from."""
400
+
401
+ normal: Operator
402
+ tikhonov: float
403
+ terms: Sequence[Regularizer]
404
+ preconditioner: Operator | None
405
+ max_iter: int
406
+ rtol: float
407
+ atol: float
408
+ backward_max_iter: int
409
+ backward_rtol: float
410
+ backward_atol: float
411
+ batch_dim: int | None
412
+
413
+
414
+ def conjugate_gradient(
415
+ normal: Operator,
416
+ rhs: torch.Tensor,
417
+ *,
418
+ x0: torch.Tensor | None = None,
419
+ regularizers: Iterable[Regularizer] = (),
420
+ preconditioner: Operator | None = None,
421
+ max_iter: int = 10,
422
+ rtol: float = 0.0,
423
+ atol: float = 0.0,
424
+ batch_dim: int | None = None,
425
+ parameters: Iterable[torch.Tensor] = (),
426
+ backward_max_iter: int | None = None,
427
+ backward_rtol: float | None = None,
428
+ backward_atol: float | None = None,
429
+ warn_indefinite: bool = True,
430
+ ) -> CGResult:
431
+ r"""Solve the regularised normal equations by conjugate gradient.
432
+
433
+ Parameters
434
+ ----------
435
+ normal
436
+ :math:`A^H A`, as a callable taking and returning one tensor.
437
+ rhs
438
+ :math:`A^H y`.
439
+ x0
440
+ Starting iterate. ``None`` starts from zero, which also saves the first
441
+ operator application, because the residual is then the right-hand side.
442
+ regularizers
443
+ The :class:`Regularizer` terms. Identity terms are summed into one
444
+ scalar and applied without an operator call.
445
+ preconditioner
446
+ :math:`M^{-1}`, applied to the residual each iteration. This is what
447
+ replaces density compensation for a non-Cartesian acquisition: the
448
+ weighting belongs in the solver, where it changes only the path taken,
449
+ rather than in the data, where it changes the answer.
450
+ max_iter
451
+ Iteration cap.
452
+ rtol, atol
453
+ Stop once ``||r|| <= atol + rtol * ||b||``. Both zero, the default,
454
+ runs the full iteration count and never reads a value back to the host,
455
+ so the loop does not synchronise.
456
+ batch_dim
457
+ Axis along which the problem is a batch of independent systems, each
458
+ with its own step size. ``None`` treats the whole tensor as one system.
459
+ parameters
460
+ Tensors inside ``normal`` or the regularizers that gradients are wanted
461
+ for. A closure cannot be inspected, so they are named here.
462
+ backward_max_iter, backward_rtol, backward_atol
463
+ Settings for the solve the backward pass runs. Each defaults to its
464
+ forward counterpart.
465
+ warn_indefinite
466
+ Whether to warn when the recurrence meets negative curvature.
467
+
468
+ Returns
469
+ -------
470
+ CGResult
471
+ The iterate, and what the iteration met on the way.
472
+
473
+ Notes
474
+ -----
475
+ Differentiable in ``rhs`` and in ``parameters``, by implicit
476
+ differentiation rather than by unrolling: memory is flat in ``max_iter``
477
+ and the backward pass is one more solve.
478
+
479
+ Examples
480
+ --------
481
+ >>> import torch
482
+ >>> from torchsolve import Regularizer, conjugate_gradient
483
+ >>> matrix = torch.tensor([[4.0, 1.0], [1.0, 3.0]])
484
+ >>> truth = torch.tensor([1.0, 2.0])
485
+ >>> result = conjugate_gradient(lambda v: matrix @ v, matrix @ truth, max_iter=8)
486
+ >>> bool(torch.allclose(result.solution, truth, atol=1e-5))
487
+ True
488
+
489
+ Regularising towards zero shrinks the answer:
490
+
491
+ >>> pulled = conjugate_gradient(
492
+ ... lambda v: matrix @ v,
493
+ ... matrix @ truth,
494
+ ... regularizers=[Regularizer(100.0)],
495
+ ... max_iter=8,
496
+ ... )
497
+ >>> bool(pulled.solution.norm() < result.solution.norm())
498
+ True
499
+
500
+ A gradient reaches the right-hand side through the solve:
501
+
502
+ >>> data = (matrix @ truth).requires_grad_(True)
503
+ >>> conjugate_gradient(lambda v: matrix @ v, data, max_iter=8).solution.sum().backward()
504
+ >>> bool(data.grad.abs().sum() > 0)
505
+ True
506
+ """
507
+ if max_iter < 1:
508
+ raise ValueError(f"max_iter must be at least 1, got {max_iter}")
509
+ if rtol < 0 or atol < 0:
510
+ raise ValueError("tolerances must not be negative")
511
+
512
+ system = _assemble(normal, rhs, regularizers)
513
+ held = tuple(parameters)
514
+ differentiating = torch.is_grad_enabled() and (
515
+ system.rhs.requires_grad or any(one.requires_grad for one in held)
516
+ )
517
+
518
+ if not differentiating:
519
+ result = _iterate(
520
+ system,
521
+ x0,
522
+ preconditioner=preconditioner,
523
+ max_iter=max_iter,
524
+ rtol=rtol,
525
+ atol=atol,
526
+ batch_dim=batch_dim,
527
+ )
528
+ else:
529
+ settings = _Settings(
530
+ normal=system.normal,
531
+ tikhonov=system.tikhonov,
532
+ terms=system.terms,
533
+ preconditioner=preconditioner,
534
+ max_iter=max_iter,
535
+ rtol=rtol,
536
+ atol=atol,
537
+ backward_max_iter=max_iter
538
+ if backward_max_iter is None
539
+ else backward_max_iter,
540
+ backward_rtol=rtol if backward_rtol is None else backward_rtol,
541
+ backward_atol=atol if backward_atol is None else backward_atol,
542
+ batch_dim=batch_dim,
543
+ )
544
+ record: dict[str, Any] = {}
545
+ solution = _ImplicitSolve.apply(system.rhs, x0, settings, record, *held)
546
+ result = CGResult(solution=solution, **record)
547
+
548
+ if not result.definite and warn_indefinite:
549
+ warnings.warn(
550
+ "conjugate gradient met negative curvature: the operator is not "
551
+ "positive definite, so the answer is not a minimiser. It is still "
552
+ "the best iterate reached. Raise the regularisation, stop earlier, "
553
+ "or keep the whole transfer if it is a compressed one.",
554
+ stacklevel=2,
555
+ )
556
+ return result