Source code for spacecore.linalg._lanczos

from __future__ import annotations

from typing import Any, NamedTuple


from ..linop import LinOp
from ..linop._metric import _VECTOR_SPACES
from ..types import DenseArray
from ._utils import DEFAULT_CONVERGENCE_CHECK_INTERVAL, check_interval
from ._utils import LinalgResult
from ._utils import require_hermitian_square
from ._utils import safe_inverse_nonneg
from ._utils import should_check_iteration


class _LanczosBasisResult(NamedTuple):
    """Store fixed-size Lanczos basis data and tridiagonal projection."""

    V: Any
    T: DenseArray
    betas: DenseArray
    krylov_dim: Any
    initial_norm: Any
    tol: Any
    initial_unit: Any


class _KrylovBasis:
    """Private storage adapter; vector arithmetic stays in the space algebra.

    Eager custom elements are held unchanged in a list. JAX custom elements
    must be registered pytrees with fixed leaf shapes and dtypes; their leaves
    are stacked separately without converting elements to coordinates.
    """

    def __init__(self, space: Any, example: Any, capacity: int) -> None:
        self.space = space
        self.ops = space.ops
        self.dom = space
        self.capacity = capacity
        self.dense = type(space) in _VECTOR_SPACES
        self.euclidean = self.dense and space.is_euclidean
        self.tree = None
        if self.dense:
            self.shape = example.shape
        elif self.ops.family == "jax":
            self.tree = self.ops.jax.tree_util
            _, self.structure = self.tree.tree_flatten(example)

    def allocate(self) -> Any:
        if self.dense:
            return self.ops.zeros((self.capacity, *self.shape), dtype=self.space.dtype)
        zero = self.space.zeros()
        if self.tree is not None:
            leaves, structure = self.tree.tree_flatten(zero)
            if structure != self.structure:
                raise TypeError("Krylov elements and space.zeros() must have the same pytree structure.")
            return tuple(self.ops.stack([leaf] * self.capacity) for leaf in leaves)
        # ponytail: native lists cover eager custom objects; specialize only if profiling warrants it.
        return [zero] * self.capacity

    def get(self, storage: Any, i: Any) -> Any:
        if self.tree is not None:
            return self.tree.tree_unflatten(self.structure, [leaf[i] for leaf in storage])
        return storage[i]

    def set(self, storage: Any, i: Any, x: Any) -> Any:
        if self.dense:
            return self.ops.index_set(storage, i, x, copy=True)
        if self.tree is not None:
            leaves, structure = self.tree.tree_flatten(x)
            if structure != self.structure:
                raise TypeError("Krylov operations must preserve the element pytree structure.")
            return tuple(self.ops.index_set(a, i, b, copy=True) for a, b in zip(storage, leaves))
        updated = storage.copy()
        updated[i] = x
        return updated

    def inner(self, storage: Any, x: Any, count: Any) -> Any:
        if self.euclidean:
            vectors = self.ops.reshape(storage, (self.capacity, -1))
            coeffs = self.ops.einsum("jn,n->j", self.ops.conj(vectors), self.ops.reshape(x, (-1,)))
            return self.ops.where(self.ops.arange(self.capacity) < count, coeffs, self.ops.zeros_like(coeffs))
        coeffs = self.ops.zeros((self.capacity,), dtype=self.space.dtype)

        def body(j: Any, values: Any) -> Any:
            return self.ops.index_set(values, j, self.dom._inner_core(self.get(storage, j), x), copy=True)

        return self.ops.fori_loop(0, count, body, coeffs)

    def lincomb(self, storage: Any, coeffs: Any, count: Any) -> Any:
        if self.dense:
            vectors = self.ops.reshape(storage, (self.capacity, -1))[:coeffs.shape[0]]
            return self.ops.reshape(self.ops.einsum("j,jn->n", coeffs, vectors), self.shape)

        def body(j: Any, x: Any) -> Any:
            return self.dom._axpy_core(coeffs[j], self.get(storage, j), x)

        return self.ops.fori_loop(0, count, body, self.space.zeros())


def _build_tridiagonal(
    ops: Any,
    alphas: DenseArray,
    betas: DenseArray,
    max_iter: int,
    m: Any,
    real_dtype: Any,
) -> DenseArray:
    """Build the fixed-size tridiagonal Lanczos projection."""
    # Fixed-size T for jit: rows past the Krylov dimension m get a large sentinel
    # diagonal so their eigenvalues sort above the real ones, and a zero coupling.
    idx = ops.arange(max_iter)
    full_indices = ops.arange(max_iter + 1)
    mask_alpha = idx < m
    inactive_sentinel = (
        ops.max(ops.abs(alphas))
        + 2.0 * ops.max(ops.abs(betas))
        + ops.asarray(1.0, dtype=real_dtype)
    )
    alphas_full = ops.where(mask_alpha, alphas, inactive_sentinel)
    betas_full = ops.where(full_indices == m, ops.asarray(0.0, dtype=real_dtype), betas)

    # The diagonal, then the symmetric off-diagonals.
    off = betas_full[1:max_iter]
    return ops.diag(alphas_full) + ops.diag(off, 1) + ops.diag(off, -1)


def _lanczos_basis_and_tridiag(
    A: LinOp,
    initial_vector: Any,
    max_iter: int,
    tol: float,
    real_dtype: Any,
    check_every: int,
) -> _LanczosBasisResult:
    """Build a Lanczos basis and tridiagonal projection."""
    ops = A.ops
    apply = A._apply_core
    dom = A.domain
    basis = _KrylovBasis(A.domain, initial_vector, max_iter + 1)
    V = basis.allocate()
    alphas = ops.zeros((max_iter,), dtype=real_dtype)
    betas = ops.zeros((max_iter + 1,), dtype=real_dtype)
    tol_s = ops.asarray(tol, dtype=real_dtype)

    v0_norm = dom._norm_core(initial_vector)
    v0_unit = dom._scale_core(safe_inverse_nonneg(ops, v0_norm), initial_vector)
    V = basis.set(V, 0, v0_unit)

    i0 = 0
    m_true0 = ops.asarray(max_iter)
    keep_going0 = v0_norm > 0

    def cond_fun(state: tuple[Any, Any, Any, Any, Any, Any]) -> Any:
        i, _V, _alphas, _betas, m_true, keep_going = state
        return (i < max_iter) & keep_going

    def body_fun(
        state: tuple[Any, Any, Any, Any, Any, Any],
    ) -> tuple[Any, Any, Any, Any, Any, Any]:
        i, V_, alphas_, betas_, m_true, keep_going = state

        # Three-term recurrence: w = A v_i - alpha_i v_i - beta_i v_{i-1}.
        v_i = basis.get(V_, i)
        w = apply(v_i)
        alpha = dom._real_inner_core(v_i, w)
        alphas_ = ops.index_set(alphas_, i, alpha, copy=True)
        w = dom._axpy_core(-alpha, v_i, w)
        w = ops.cond(
            i == 0,
            lambda w_in: w_in,
            lambda w_in: dom._axpy_core(-betas_[i], basis.get(V_, i - 1), w_in),
            w,
        )

        # Full reorthogonalization using the declared inner product.
        coeffs = basis.inner(V_, w, i + 1)
        w_member = dom._axpy_core(-1, basis.lincomb(V_, coeffs, i + 1), w)

        # beta_{i+1} = ||w||; below tol the Krylov space is exhausted.
        beta_new = dom._norm_core(w_member)
        betas_ = ops.index_set(betas_, (i + 1,), beta_new, copy=True)
        breakdown = beta_new < tol_s
        m_true_next = ops.where(breakdown & (m_true == max_iter), i + 1, m_true)

        # Store the next unit vector unless breakdown happened.
        def set_next(V_in: Any) -> Any:
            w_unit = dom._scale_core(safe_inverse_nonneg(ops, beta_new), w_member)
            return basis.set(V_in, i + 1, w_unit)

        V_ = ops.cond(beta_new >= tol_s, set_next, lambda V_in: V_in, V_)
        i_next = i + 1
        # Stop only on check iterations, to keep the loop cheap.
        keep_going_next = ops.cond(
            should_check_iteration(i_next, max_iter, check_every),
            lambda _: beta_new >= tol_s,
            lambda _: keep_going,
            ops.asarray(0.0, dtype=real_dtype),
        )

        return i_next, V_, alphas_, betas_, m_true_next, keep_going_next

    i_final, V, alphas, betas, m_true, _keep_going = ops.while_loop(
        cond_fun, body_fun, (i0, V, alphas, betas, m_true0, keep_going0)
    )
    # Krylov dimension actually reached, and the projected tridiagonal T.
    m = ops.minimum(m_true, ops.asarray(i_final))
    T = _build_tridiagonal(ops, alphas, betas, max_iter, m, real_dtype)
    return _LanczosBasisResult(V, T, betas, m, v0_norm, tol_s, v0_unit)


[docs] def lanczos_smallest( A: LinOp, initial_vector: Any, *, max_iter: int = 100, tol: float = 1e-6, check_every: int = DEFAULT_CONVERGENCE_CHECK_INTERVAL, ) -> LinalgResult: r""" Approximate the smallest eigenpair of a Hermitian operator by Lanczos. The eigenvalue returned is the Rayleigh quotient of the Ritz vector, reconstructed in ``A.domain``. Parameters ---------- A : LinOp Square operator, Hermitian in the domain's inner product. Known non-Hermitian operators are rejected; unknown ones are trusted. initial_vector : array-like Nonzero starting element in ``A.domain``. max_iter : int, optional Maximum Krylov dimension; a static argument under ``jax.jit``. tol : float, optional Breakdown threshold, and the residual below which ``converged`` is set. check_every : int, optional Test for breakdown every this many iterations, and on the last one. Returns ------- LinalgResult ``x`` is the Ritz vector, ``eigenvalue`` the Ritz value, ``num_iters`` the Krylov dimension reached. Raises ------ TypeError If ``A`` is not a :class:`LinOp`. ValueError If ``A`` is not square, is known non-Hermitian, ``max_iter`` is invalid, or the starting element is zero (eager execution). See Also -------- power_iteration : The dominant eigenpair. Notes ----- ``residual_norm`` is the recurrence estimate :math:`\beta_m |y_{m-1}|`. The smallest Ritz value can recover the smallest eigenvalue only if ``initial_vector`` overlaps its eigenspace. Trefethen & Bau, *Numerical Linear Algebra*, Algorithm 36.1 and Thm 36.1; multiplying the Lanczos relation by a unit Ritz vector gives the residual. Uses the space algebra and native elements. Custom JAX elements must be registered pytrees whose structure, leaf shapes, and dtypes stay fixed. ``max_iter`` and ``check_every`` must be static under ``jax.jit``. A zero start raises eagerly; under JAX tracing it returns a NaN eigenvalue, a zero eigenvector, and ``converged=False``. Examples -------- Approximate the smallest eigenpair 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, 2.0, 4.0]), X, ctx) >>> result = sc.lanczos_smallest(A, ctx.asarray([1.0, 1.0, 1.0]), max_iter=3) >>> np.allclose(result.eigenvalue, 1.0) True """ # Validate once: square, not provably non-Hermitian, valid parameters. A = require_hermitian_square(A, "lanczos_smallest") max_iter = check_interval(max_iter, "max_iter") check_every = check_interval(check_every) A.domain.check_member(initial_vector) ops = A.ops ctx = A.ctx real_dtype = ops.real_dtype(ctx.dtype) idx = ops.arange(max_iter) initial_norm = A.domain.norm(initial_vector) try: zero_start = bool(initial_norm == 0) except TypeError: # JAX traced predicate; report invalid input in the result below. zero_start = False if zero_start: raise ValueError("lanczos_smallest requires a nonzero initial_vector.") basis = _lanczos_basis_and_tridiag(A, initial_vector, max_iter, tol, real_dtype, check_every) # Smallest eigenpair of T; residual estimate beta_m |y_{m-1}|. m = basis.krylov_dim _eigvals, eigvecs = ops.eigh(basis.T) y_full = eigvecs[:, 0] residual_norm = basis.betas[m] * ops.abs(y_full[m - 1]) converged = (residual_norm < basis.tol) & (basis.initial_norm > 0) # Ritz vector x = V y, using only the m active basis vectors. y_valid = ops.where(idx < m, y_full, ops.zeros_like(y_full)) vectors = _KrylovBasis(A.domain, initial_vector, max_iter + 1) x = vectors.lincomb(basis.V, y_valid, m) x_norm = A.domain.norm(x) x = ops.cond( x_norm > 0, lambda _: A.domain.scale(safe_inverse_nonneg(ops, x_norm), x), lambda _: basis.initial_unit, ops.asarray(0.0, dtype=real_dtype), ) # Report the Rayleigh quotient in the original space. Ax = A.apply(x) num = A.domain.real_inner(x, Ax) den = A.domain.real_inner(x, x) lam = ops.where(den > 0, num * safe_inverse_nonneg(ops, den), ops.asarray(float("nan"), dtype=real_dtype)) return LinalgResult(x, converged, m, residual_norm, eigenvalue=lam)