Source code for spacecore.linalg._cg

from __future__ import annotations

from typing import Any

from ..linop import LinOp
from ._utils import DEFAULT_CONVERGENCE_CHECK_INTERVAL, check_interval, check_maxiter
from ._utils import LinalgResult
from ._utils import require_hermitian_square
from ._utils import is_converged
from ._utils import (
    require_strict_cg_preconditions,
    safe_inverse_nonneg,
    should_check_iteration,
    threshold,
)


[docs] def cg( A: LinOp, b: Any, *, x0: Any | None = None, tol: float = 1e-6, atol: float = 0.0, maxiter: int | None = None, check_every: int = DEFAULT_CONVERGENCE_CHECK_INTERVAL, preconditioner: LinOp | None = None, ) -> LinalgResult: r""" Solve :math:`A x = b` by conjugate gradients. ``A`` must be square (``A.domain == A.codomain``), Hermitian and positive-definite in the domain's inner product. Only ``A.apply`` and that inner product are used. Parameters ---------- A : LinOp Hermitian positive-definite operator. One that is provably not Hermitian is rejected; definiteness is not checked. b : array-like Right-hand side in ``A.codomain``. x0 : array-like or None, optional Initial guess; zero by default. tol : float, optional Converged when the residual norm is below ``atol + tol * norm(b)``. atol : float, optional Absolute residual tolerance. maxiter : int or None, optional Iteration cap; ``prod(A.domain.shape)`` by default. check_every : int, optional Test convergence every this many iterations, and on the last one. preconditioner : LinOp or None, optional Operator ``K`` on ``A.domain`` approximating the inverse of ``A``, self-adjoint and positive definite in ``A.domain``; each residual ``r`` is preconditioned to ``K r``. The residual is still measured in ``A.domain``'s norm. Returns ------- LinalgResult ``(x, converged, num_iters, residual_norm)``. Raises ------ TypeError If ``A`` is not a :class:`LinOp`. ValueError If ``A`` is not square or an iteration parameter is invalid. See Also -------- lsqr : Least squares for rectangular operators. Notes ----- JIT-compatible on JAX with ``maxiter`` and ``check_every`` static. Stops early when no useful update remains (residual at machine precision, or non-positive curvature). Trefethen & Bau, *Numerical Linear Algebra*, Algorithm 38.1. In an orthonormal basis the metric recurrence is ordinary CG; complex spaces use conjugate inner products, and a nonzero ``x0`` starts from ``b - A x0``. Examples -------- Solve a small positive-definite system. >>> import numpy as np >>> import spacecore as sc >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) >>> X = sc.DenseCoordinateSpace((3,), ctx) >>> M = ctx.asarray([[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]]) >>> A = sc.DenseLinOp(M, X, X, ctx) >>> b = ctx.asarray([1.0, 2.0, 3.0]) >>> result = sc.cg(A, b, tol=1e-10) >>> bool(result.converged) True >>> np.allclose(A.apply(result.x), b) True """ # Validate once, up front: square, not provably non-Hermitian, valid b and parameters. A = require_hermitian_square( A, "cg", "cg requires A to be Hermitian/self-adjoint with respect to " "A.domain.inner, but A was determined to be provably " "non-self-adjoint in this geometry. For example, a symmetric " "matrix wrapped as a DenseLinOp on a weighted (non-Euclidean) " "inner-product space is not self-adjoint under the weighted inner " "product. Build a valid operator instead, e.g. a normal operator " "A.H @ A + lam * Identity (which is self-adjoint in any geometry), " "or use a Euclidean space where the symmetric matrix is " "self-adjoint." ) require_strict_cg_preconditions(A) A.codomain.check_member(b) maxiter = check_maxiter(maxiter, A) check_every = check_interval(check_every) # Resolve check-free cores once; the hot loop then skips per-iteration # validation while honoring any custom geometry the spaces define. apply = A._apply_core dom = A.domain cod = A.codomain # Preconditioning z = K r; without K, z = r and this is plain CG. if preconditioner is None: def precond(r: Any) -> Any: return r else: if preconditioner.domain != A.domain or preconditioner.codomain != A.domain: raise ValueError("cg: the preconditioner must map A.domain to itself.") precond = preconditioner._apply_core # Initial residual r = b - A x; the first search direction is z = K r. x = A.domain.zeros() if x0 is None else x0 A.domain.check_member(x) r = cod._add_core(b, cod._scale_core(-1.0, apply(x))) p = precond(r) rs = dom._real_inner_core(r, p) residual_norm = dom._norm_core(r) # Stop when ||r|| <= atol + tol ||b||; eps guards the divisions below. threshold_value = threshold(cod._norm_core(b), tol, atol) # Scalar diagnostics are real even for a complex operator (ADR-015). real_dtype = A.ops.real_dtype(A.dtype) eps = A.ops.asarray(A.ops.eps(A.dtype), dtype=real_dtype) eps2 = eps * eps def cond_fun(carry: tuple[Any, Any, Any, Any, Any, int, Any]) -> Any: _x, _r, _p, _rs, res_norm, k, active = carry # Continue while under the cap, not converged, and still making progress. return (k < maxiter) & (res_norm > threshold_value) & active def body_fun( carry: tuple[Any, Any, Any, Any, Any, int, Any], ) -> tuple[Any, Any, Any, Any, Any, int, Any]: x, r, p, rs, _residual_norm, k, _active = carry # Step length alpha = <r, z> / <p, A p>; freeze once curvature or residual vanishes. Ap = apply(p) pAp = dom._real_inner_core(p, Ap) active = (rs > eps2) & (pAp > eps * rs) alpha = A.ops.where(active, rs * safe_inverse_nonneg(A.ops, pAp), A.ops.zeros_like(rs)) # Move x along p and update the residual without another apply. x_next = dom._axpy_core(alpha, p, x) r_next = cod._axpy_core(-alpha, Ap, r) z_next = precond(r_next) rs_next = dom._real_inner_core(r_next, z_next) # New direction p = z + beta p, with beta = <r_new, z_new> / <r, z>. beta = A.ops.where( active, rs_next * safe_inverse_nonneg(A.ops, rs), A.ops.zeros_like(rs_next) ) p_next = dom._axpy_core(beta, p, z_next) k_next = k + 1 # Refresh the reported residual norm only on check iterations. should_refresh_residual = should_check_iteration(k_next, maxiter, check_every) | (~active) residual_norm_next = A.ops.cond( should_refresh_residual, (lambda _: A.ops.sqrt(rs_next)) if preconditioner is None else (lambda _: dom._norm_core(r_next)), lambda _: _residual_norm, A.ops.asarray(0.0, dtype=real_dtype), ) return x_next, r_next, p_next, rs_next, residual_norm_next, k_next, active # One backend while-loop, so the whole solve can be jitted. x, _r, _p, _rs, residual_norm, num_iters, _active = A.ops.while_loop( cond_fun, body_fun, (x, r, p, rs, residual_norm, 0, A.ops.asarray(True)), ) return LinalgResult(x, is_converged(residual_norm, threshold_value), num_iters, residual_norm)