Source code for spacecore.linalg._power

from __future__ import annotations

from collections.abc import Callable
from typing import Any, NamedTuple, cast

from ..contextual import Context
from ..functional import QuadraticForm
from ..linop import LinOp
from ..space import Space
from ._utils import LinalgResult
from ._utils import normalize, require_hermitian_square
from ._utils import check_maxiter, default_initial_vector, is_converged


class _SelfAdjointAction(NamedTuple):
    """Store the callable action used by power iteration."""

    apply: Callable[[Any], Any]
    domain: Space
    ctx: Context
    rayleigh: Callable[[Any, Any], Any]
    residual_norm: Callable[[Any, Any, Any], Any]

    @property
    def ops(self) -> Any:
        """Backend operations for this action."""
        return self.ctx.ops

    @property
    def dtype(self) -> Any:
        """Default dtype for this action."""
        return self.ctx.dtype


def _action_from_linop(A: LinOp) -> _SelfAdjointAction:
    """Normalize a square linear operator into a self-adjoint action."""
    # Wrap a square LinOp as a self-adjoint action with generic diagnostics.
    A = require_hermitian_square(A, "power_iteration")
    apply = A._apply_core
    sops = A.domain
    return _SelfAdjointAction(
        apply,
        A.domain,
        A.ctx,
        sops._real_inner_core,
        lambda x, y, eigenvalue: sops._norm_core(sops._axpy_core(-eigenvalue, x, y)),
    )


def _action_from_quadratic_form(q: QuadraticForm) -> _SelfAdjointAction:
    """Normalize a quadratic form into its Hessian action."""
    sops = q.domain
    # Use the form's own Rayleigh quotient and residual when it provides them.
    hess_quad = getattr(q, "hess_quad", None)
    if callable(hess_quad):

        def rayleigh(x, y):
            return q.ops.real(cast(Any, hess_quad)(x, Hx=y))
    else:

        rayleigh = sops._real_inner_core

    hess_residual_norm = getattr(q, "hess_residual_norm", None)
    if callable(hess_residual_norm):
        residual_norm = hess_residual_norm
    else:

        def residual_norm(x, y, eigenvalue):
            return sops._norm_core(sops._axpy_core(-eigenvalue, x, y))

    return _SelfAdjointAction(q.hess_apply, q.domain, q.ctx, rayleigh, residual_norm)


[docs] def power_iteration( A: LinOp | QuadraticForm, *, x0: Any | None = None, tol: float = 1e-6, maxiter: int | None = None, ) -> LinalgResult: r""" Estimate the dominant eigenpair (largest in absolute value) of a self-adjoint action. Parameters ---------- A : LinOp or QuadraticForm Square operator, or quadratic form with ``hess_apply``. A form may also supply ``hess_quad(x, Hx=None)`` and ``hess_residual_norm(x, Hx, eigenvalue)`` for the diagnostics. For a spectral norm of a rectangular ``B``, pass ``B.H @ B``. x0 : array-like or None, optional Initial vector; normalized ones by default. tol : float, optional Converged when ``norm(A @ x - eigenvalue * x) < tol``. maxiter : int or None, optional Iteration cap; ``prod(A.domain.shape)`` by default. Returns ------- LinalgResult ``x`` is the eigenvector estimate; ``eigenvalue`` its Rayleigh quotient. Raises ------ TypeError If ``A`` is neither a :class:`LinOp` nor a :class:`QuadraticForm`. ValueError If a linear operator is not square or known non-Hermitian, or an iteration parameter is invalid. See Also -------- lanczos_smallest : The smallest eigenpair. Notes ----- Convergence needs a separated dominant modulus and nonzero initial overlap with its eigenspace. Trefethen & Bau, *Numerical Linear Algebra*, Thm 27.1; expansion in a metric-orthonormal eigenbasis gives the same eigenvalue-ratio argument. With both ``lambda`` and ``-lambda`` present at the dominant modulus the iterate can oscillate. JIT-compatible on JAX with ``maxiter`` static. Examples -------- Estimate the largest eigenvalue of a diagonal operator. >>> import numpy as np >>> import spacecore as sc >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) >>> X = sc.DenseCoordinateSpace((3,), ctx) >>> A = sc.DiagonalLinOp(ctx.asarray([1.0, 3.0, 2.0]), X, ctx) >>> result = sc.power_iteration(A, maxiter=20, tol=1e-10) >>> np.allclose(result.eigenvalue, 3.0) True """ # Turn either input into one self-adjoint action. if isinstance(A, QuadraticForm): action = _action_from_quadratic_form(A) elif isinstance(A, LinOp): action = _action_from_linop(A) else: raise TypeError(f"A must be a LinOp or QuadraticForm, got {type(A).__name__}.") maxiter = check_maxiter(maxiter, cast(Any, action)) # Start from normalized ones unless x0 is given. x = default_initial_vector(cast(Any, action)) if x0 is None else x0 action.domain.check_member(x) eigenvalue, eigenvector, converged, num_iters, residual_norm = _power_iteration_core(action, x, tol, maxiter) return LinalgResult(eigenvector, converged, num_iters, residual_norm, eigenvalue=eigenvalue)
def _power_iteration_core( action: _SelfAdjointAction, x: Any, tol: float, maxiter: int, ) -> tuple[Any, Any, Any, Any, Any]: """Run the backend-loop implementation of power iteration.""" sops = action.domain # Normalize x0 and precompute y = A x. x, _ = normalize(sops, x) y = action.apply(x) # The Rayleigh quotient is real, so the eigenvalue carry is real (ADR-015). zero = action.ops.asarray(0.0, dtype=action.ops.real_dtype(action.dtype)) residual_norm = sops._norm_core(x) + float("inf") # Carry: current eigenvalue estimate, normalized vector x, product y=A x, # residual norm, and iteration counter. def cond_fun(carry: tuple[Any, Any, Any, Any, int]) -> Any: _eigenvalue, _x, _y, res_norm, k = carry return (k < maxiter) & (res_norm > tol) def body_fun(carry: tuple[Any, Any, Any, Any, int]) -> tuple[Any, Any, Any, Any, int]: _eigenvalue, _x, y, _residual_norm, k = carry # x <- A x / ||A x||; eigenvalue by the Rayleigh quotient; residual ||A x - lambda x||. x_next, _norm_y = normalize(sops, y) y_next = action.apply(x_next) eigenvalue_next = action.rayleigh(x_next, y_next) k_next = k + 1 residual_norm_next = action.residual_norm(x_next, y_next, eigenvalue_next) return eigenvalue_next, x_next, y_next, residual_norm_next, k_next eigenvalue, eigenvector, _y, residual_norm, num_iters = action.ops.while_loop( cond_fun, body_fun, (zero, x, y, residual_norm, 0), ) return ( eigenvalue, eigenvector, is_converged(residual_norm, tol), num_iters, residual_norm, )