from __future__ import annotations
from typing import Any
from ..linop import LinOp
from ._lanczos import _KrylovBasis, _lanczos_basis_and_tridiag
from ._utils import check_interval, require_hermitian_square
from ._utils import LinalgResult
[docs]
def expm_multiply(
A: LinOp,
v: Any,
t: float | complex = 1.0,
*,
max_iter: int = 30,
tol: float = 1e-10,
) -> LinalgResult:
r"""
Compute :math:`\exp(t A) v` by Lanczos projection, without forming ``A``.
Parameters
----------
A : LinOp
Square operator, Hermitian in the domain's inner product. Known non-Hermitian
operators are rejected; unknown ones are trusted.
v : array-like
Element in ``A.domain``; zero returns zero without applying ``A``.
t : float or complex, optional
Scalar multiplying ``A``; a complex ``t`` needs a complex ``ctx.dtype``.
max_iter : int, optional
Maximum Krylov dimension. Static under ``jax.jit``.
tol : float, optional
Breakdown threshold, and the residual below which ``converged`` is set.
Returns
-------
LinalgResult
``x`` is the result vector, ``num_iters`` the Krylov dimension reached, and
``residual_norm`` the normalized projection indicator
:math:`|\beta_m \phi_{m-1}|`. The approximation is
``norm(v) V_m exp(t T_m) e_1`` in a metric-orthonormal basis.
Higham, *Functions of Matrices*, Sec. 13.2.2, Eq. (13.7).
Raises
------
TypeError
If ``A`` is not a :class:`LinOp`.
ValueError
If ``A`` is not square, is known non-Hermitian, or ``max_iter`` is invalid.
See Also
--------
lanczos_smallest : The same Krylov projection, for eigenpairs.
Examples
--------
Apply the exponential of a diagonal operator.
>>> import numpy as np
>>> import spacecore as sc
>>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
>>> X = sc.DenseCoordinateSpace((2,), ctx)
>>> A = sc.DiagonalLinOp(ctx.asarray([0.0, 1.0]), X, ctx)
>>> v = ctx.asarray([2.0, 3.0])
>>> result = sc.expm_multiply(A, v, t=0.5, max_iter=5)
>>> np.allclose(result.x, [2.0, 3.0 * np.exp(0.5)], atol=1e-10)
True
"""
# Validate once: square, not provably non-Hermitian, valid parameters.
A = require_hermitian_square(A, "expm_multiply")
max_iter = check_interval(max_iter, "max_iter")
A.domain.check_member(v)
ops = A.ops
ctx = A.ctx
real_dtype = ops.real_dtype(ctx.dtype)
# Lanczos basis V and tridiagonal T = V* A V.
basis = _lanczos_basis_and_tridiag(A, v, max_iter, tol, real_dtype, check_every=1)
# Zero the inactive block of T, so the fixed-size eigh sees only the m-dimensional part.
m = basis.krylov_dim
idx = ops.arange(max_iter)
active_mask = idx < m
active_matrix_mask = active_mask[:, None] & active_mask[None, :]
T_safe = ops.where(active_matrix_mask, basis.T, ops.zeros_like(basis.T))
# exp(t T) e1 from the eigendecomposition of the small T.
eigvals, eigvecs = ops.eigh(T_safe)
exp_eigs = ops.exp(t * eigvals)
expT_e1 = eigvecs @ (exp_eigs * eigvecs[0, :])
expT_e1 = ops.where(active_mask, expT_e1, ops.zeros_like(expT_e1))
# exp(t A) v is approximately ||v|| V exp(t T) e1.
vectors = _KrylovBasis(A.domain, v, max_iter + 1)
result = A.domain.scale(basis.initial_norm, vectors.lincomb(basis.V, expT_e1, m))
# Residual estimate beta_m |phi_{m-1}|.
last_coeff = ops.abs(expT_e1[m - 1])
residual_estimate = basis.betas[m] * last_coeff
converged = residual_estimate < basis.tol
return LinalgResult(result, converged, m, residual_estimate)