Source code for zea.inverse.solvers

"""Matrix-free linear-algebra utilities for inverse ultrasound problems.

This module provides the solver primitives used by :mod:`zea.inverse`:

* :func:`linear_adjoint` — build the adjoint (transpose) of a linear operator
  with backend-agnostic automatic differentiation.
* :func:`cgls` — conjugate gradient least squares for matrix-free linear
  operators.

Operators are plain callables on tensors of any shape; inner products are
taken over all elements, so no explicit flattening or matrix assembly is
required.
"""

from keras import ops

from zea import log
from zea.backend.autograd import AutoGrad


[docs] def linear_adjoint(matvec, input_template): r"""Construct the adjoint of a linear operator via automatic differentiation. For a linear operator :math:`A` the adjoint satisfies :math:`\langle A x, y \rangle = \langle x, A^T y \rangle`. It is obtained here as the gradient of :math:`x \mapsto \langle A x,\, y \rangle` evaluated at :math:`x = 0`, which equals :math:`A^T y` exactly (no linearization error) because the map is linear in :math:`x`. Args: matvec (callable): Linear function mapping an input tensor to an output tensor. Must be built from differentiable Keras ops. input_template (Tensor): Tensor with the shape and dtype of the operator input. Only shape and dtype are used. Returns: callable: Function mapping an output-shaped tensor ``y`` to :math:`A^T y` with the shape of ``input_template``. Example: .. doctest:: >>> import numpy as np >>> from keras import ops >>> from zea.inverse import linear_adjoint >>> matrix = np.arange(15, dtype=np.float32).reshape(3, 5) >>> matvec = lambda x: ops.matmul(matrix, x) >>> rmatvec = linear_adjoint(matvec, ops.zeros(5)) >>> y = np.ones(3, dtype=np.float32) >>> bool(np.allclose(rmatvec(y), matrix.T @ y, atol=1e-5)) # doctest: +SKIP True """ zeros = ops.zeros_like(input_template) autograd = AutoGrad() autograd.set_function(lambda x, cotangent: ops.sum(matvec(x) * cotangent)) def rmatvec(y): return autograd.gradient(zeros, cotangent=ops.convert_to_tensor(y)) return rmatvec
[docs] def cgls(matvec, rmatvec, b, x0, n_iter=50, verbose=False): r"""Conjugate gradient least squares (CGLS). Iteratively minimizes :math:`\|A x - b\|^2` for a matrix-free linear operator :math:`A` given by ``matvec`` and its adjoint ``rmatvec``. Started from zero on an underdetermined system, CGLS converges to the minimum-norm least-squares (Moore-Penrose pseudo-inverse) solution. Args: matvec (callable): The linear operator :math:`A`. rmatvec (callable): The adjoint operator :math:`A^T` (see :func:`linear_adjoint`). b (Tensor): Measurement, shaped like the output of ``matvec``. x0 (Tensor): Initial iterate, shaped like the input of ``matvec``. Use zeros for the minimum-norm solution. n_iter (int, optional): Number of iterations. Defaults to ``50``. verbose (bool, optional): Log the relative residual periodically. Defaults to ``False``. Returns: Tensor: The solution estimate with the shape of ``x0``. """ eps = 1e-30 x = ops.convert_to_tensor(x0) b = ops.convert_to_tensor(b) residual = b - matvec(x) s = rmatvec(residual) direction = s gamma = ops.sum(s * s) b_norm = ops.sqrt(ops.sum(b * b)) log_every = max(1, n_iter // 8) for iteration in range(1, n_iter + 1): q = matvec(direction) alpha = gamma / (ops.sum(q * q) + eps) x = x + alpha * direction residual = residual - alpha * q s = rmatvec(residual) gamma_new = ops.sum(s * s) direction = s + (gamma_new / (gamma + eps)) * direction gamma = gamma_new if verbose and (iteration % log_every == 0 or iteration == n_iter): relative_residual = ops.sqrt(ops.sum(residual * residual)) / (b_norm + eps) log.info( f"cgls iteration {iteration:3d} | " f"relative residual {float(ops.convert_to_numpy(relative_residual)):.4e}" ) return x