Skip to content

furax.linalg

Modules:

  • cholesky

    Block-banded Cholesky factorization for symmetric positive-(semi)definite matrices.

  • low_rank

    Low-rank approximation for PyTree-aware linear operators.

Classes:

  • CGResult

    Result of the Conjugate Gradient solver.

  • LanczosResult

    Result of Lanczos eigenvalue computation.

  • BandedCholeskyOperator

    Inverse of a symmetric positive-(semi)definite matrix, by Cholesky solve.

  • LowRankOperator

    Linear operator from low-rank terms: A = U @ diag(S) @ U^T.

  • LowRankTerms

    Low-rank approximation of a Hermitian operator: A ≈ U @ diag(S) @ U^T.

Functions:

  • cg

    Conjugate Gradient solver for symmetric positive definite systems Ax = b.

  • eigvalsh

    Eigenvalues of batched symmetric matrices, sorted ascending.

  • lanczos_eigh

    Lanczos algorithm for computing k eigenvalues via an m-dimensional Krylov subspace.

  • lanczos_tr

    Thick-restart Lanczos for computing k eigenpairs of a Hermitian operator.

  • banded_cholesky

    Block Cholesky factor of a symmetric positive-(semi)definite block-banded matrix.

  • banded_cholesky_solve

    Solve L Lᵀ x = b given the lower band factor lb from banded_cholesky.

  • low_rank_mv

    Apply low-rank approximation as a matrix-vector product.

furax.linalg.CGResult

Bases: NamedTuple

Result of the Conjugate Gradient solver.

Attributes:

  • solution (PyTree[Num[Array, ...]]) –

    The approximate solution x to Ax = b.

  • residuals (Float[Array, ' max_steps']) –

    Norm of the residual at each iteration, shape (max_steps,).

  • num_steps (Array) –

    Number of CG steps taken.

Source code in src/furax/linalg/_cg.py
class CGResult(NamedTuple):
    """Result of the Conjugate Gradient solver.

    Attributes:
        solution: The approximate solution x to Ax = b.
        residuals: Norm of the residual at each iteration, shape (max_steps,).
        num_steps: Number of CG steps taken.
    """

    solution: PyTree[Num[Array, '...']]
    residuals: Float[Array, ' max_steps']
    num_steps: Array

solution instance-attribute

residuals instance-attribute

num_steps instance-attribute

furax.linalg.LanczosResult

Bases: NamedTuple

Result of Lanczos eigenvalue computation.

Attributes:

Source code in src/furax/linalg/_lanczos.py
class LanczosResult(NamedTuple):
    """Result of Lanczos eigenvalue computation.

    Attributes:
        eigenvalues: The k computed eigenvalues, sorted ascending.
        eigenvectors: A block PyTree containing k eigenvectors.
        residual_norms: The norm of the residual for each eigenpair.
    """

    eigenvalues: Float[Array, ' k']
    eigenvectors: PyTree[Num[Array, ' k ...']]
    residual_norms: Float[Array, ' k']

eigenvalues instance-attribute

eigenvectors instance-attribute

residual_norms instance-attribute

furax.linalg.BandedCholeskyOperator dataclass

Bases: AbstractLinearOperator

Inverse of a symmetric positive-(semi)definite matrix, by Cholesky solve.

This operator can be used with an ordinary dense matrix (from_dense), or from a banded matrix (zero away from the diagonal, from_bands) and so is stored compactly instead of as the full matrix.

Calling A(b) solves A x = b.

Examples:

>>> import jax.numpy as jnp
>>> from furax.linalg import BandedCholeskyOperator
>>> A = jnp.array([[4., 2.], [2., 3.]])
>>> op = BandedCholeskyOperator.from_dense(A)
>>> op(jnp.array([1., 0.]))  # solves A x = [1, 0]
Array([ 0.375, -0.25 ], dtype=float32)

Methods:

  • from_bands

    Factor a block-banded matrix (see banded_cholesky) and wrap it as an operator.

  • from_dense

    Factor a fully dense matrix (the degenerate n_blocks=1, w=0 band case).

  • mv

Attributes:

  • lb (Float[Array, '*batch n w1 k k']) –

    The lower-triangular Cholesky factor in compact band-layout.

Source code in src/furax/linalg/cholesky.py
@symmetric
class BandedCholeskyOperator(AbstractLinearOperator):
    """Inverse of a symmetric positive-(semi)definite matrix, by Cholesky solve.

    This operator can be used with an ordinary dense matrix ([`from_dense`][]), or from
    a banded matrix (zero away from the diagonal, [`from_bands`][]) and so is stored
    compactly instead of as the full matrix.

    Calling ``A(b)`` solves ``A x = b``.

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.linalg import BandedCholeskyOperator
        >>> A = jnp.array([[4., 2.], [2., 3.]])
        >>> op = BandedCholeskyOperator.from_dense(A)
        >>> op(jnp.array([1., 0.]))  # solves A x = [1, 0]
        Array([ 0.375, -0.25 ], dtype=float32)
    """

    lb: Float[Array, '*batch n w1 k k']
    """The lower-triangular Cholesky factor in compact band-layout."""

    @classmethod
    def from_bands(
        cls,
        bands: Float[Array, '*batch n w1 k k'],
        in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
        regularization: float = 0.0,
    ) -> Self:
        """Factor a block-banded matrix (see [`banded_cholesky`][]) and wrap it as an operator.

        Args:
            bands: Upper-band representation of the block-banded matrix, shape
                ``(*batch, n_blocks, w+1, k, k)``.
            in_structure: Structure of the values the operator is called with.
                Defaults to a plain array of shape ``(*batch, n_blocks, k)``.
                Pass a PyTree explicitly to solve for several arrays at once instead.
            regularization: Relative ridge added to each diagonal block before factoring.
        """
        if in_structure is None:
            n, k = bands.shape[-4], bands.shape[-1]
            in_structure = jax.ShapeDtypeStruct((*bands.shape[:-4], n, k), bands.dtype)
        return cls(banded_cholesky(bands, regularization), in_structure=in_structure)

    @classmethod
    def from_dense(
        cls,
        matrix: Float[Array, '*batch k k'],
        in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
        regularization: float = 0.0,
    ) -> Self:
        """Factor a fully dense matrix (the degenerate ``n_blocks=1, w=0`` band case).

        Args:
            matrix: The dense matrix, shape ``(*batch, k, k)``.
            in_structure: Structure of the values the operator is called with.
                Defaults to a plain array of shape ``(*batch, k)``.
                Pass a PyTree explicitly to solve for several arrays at once instead.
            regularization: Relative ridge added to the diagonal before factoring.
        """
        if in_structure is None:
            in_structure = jax.ShapeDtypeStruct(matrix.shape[:-1], matrix.dtype)
        return cls.from_bands(matrix[..., None, None, :, :], in_structure, regularization)

    def mv(self, x: PyTree[Array]) -> PyTree[Array]:
        batch_ndim = self.lb.ndim - 4
        n_blocks, k = self.lb.shape[-4], self.lb.shape[-1]
        leaves, treedef = jax.tree.flatten(x)
        # Flatten every leaf's non-batch axes and concatenate them into one length-K vector per
        # batch element, matching how `lb` was built from `bands`/`matrix` (K = n_blocks * k).
        flats = [leaf.reshape(*leaf.shape[:batch_ndim], -1) for leaf in leaves]
        xf = jnp.concatenate(flats, axis=-1)  # (*batch, K)
        xb = xf.reshape(*xf.shape[:-1], n_blocks, k)  # split K back into the (n_blocks, k) layout
        yb = banded_cholesky_solve(self.lb, xb)
        yf = yb.reshape(*yb.shape[:-2], -1)  # (*batch, K)
        # Undo the flatten/concatenate: split the solution back into per-leaf chunks (in the same
        # order they were concatenated) and reshape each to its original leaf shape.
        sizes = [prod(leaf.shape[batch_ndim:]) for leaf in leaves]
        chunks = jnp.split(yf, np.cumsum(sizes)[:-1], axis=-1)
        out = [c.reshape(leaf.shape) for c, leaf in zip(chunks, leaves, strict=True)]
        return treedef.unflatten(out)  # type: ignore[attr-defined]

lb instance-attribute

The lower-triangular Cholesky factor in compact band-layout.

from_bands(bands, in_structure=None, regularization=0.0) classmethod

Factor a block-banded matrix (see banded_cholesky) and wrap it as an operator.

Parameters:

  • bands (Float[Array, '*batch n w1 k k']) –

    Upper-band representation of the block-banded matrix, shape (*batch, n_blocks, w+1, k, k).

  • in_structure (PyTree[ShapeDtypeStruct] | None, default: None ) –

    Structure of the values the operator is called with. Defaults to a plain array of shape (*batch, n_blocks, k). Pass a PyTree explicitly to solve for several arrays at once instead.

  • regularization (float, default: 0.0 ) –

    Relative ridge added to each diagonal block before factoring.

Source code in src/furax/linalg/cholesky.py
@classmethod
def from_bands(
    cls,
    bands: Float[Array, '*batch n w1 k k'],
    in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
    regularization: float = 0.0,
) -> Self:
    """Factor a block-banded matrix (see [`banded_cholesky`][]) and wrap it as an operator.

    Args:
        bands: Upper-band representation of the block-banded matrix, shape
            ``(*batch, n_blocks, w+1, k, k)``.
        in_structure: Structure of the values the operator is called with.
            Defaults to a plain array of shape ``(*batch, n_blocks, k)``.
            Pass a PyTree explicitly to solve for several arrays at once instead.
        regularization: Relative ridge added to each diagonal block before factoring.
    """
    if in_structure is None:
        n, k = bands.shape[-4], bands.shape[-1]
        in_structure = jax.ShapeDtypeStruct((*bands.shape[:-4], n, k), bands.dtype)
    return cls(banded_cholesky(bands, regularization), in_structure=in_structure)

from_dense(matrix, in_structure=None, regularization=0.0) classmethod

Factor a fully dense matrix (the degenerate n_blocks=1, w=0 band case).

Parameters:

  • matrix (Float[Array, '*batch k k']) –

    The dense matrix, shape (*batch, k, k).

  • in_structure (PyTree[ShapeDtypeStruct] | None, default: None ) –

    Structure of the values the operator is called with. Defaults to a plain array of shape (*batch, k). Pass a PyTree explicitly to solve for several arrays at once instead.

  • regularization (float, default: 0.0 ) –

    Relative ridge added to the diagonal before factoring.

Source code in src/furax/linalg/cholesky.py
@classmethod
def from_dense(
    cls,
    matrix: Float[Array, '*batch k k'],
    in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
    regularization: float = 0.0,
) -> Self:
    """Factor a fully dense matrix (the degenerate ``n_blocks=1, w=0`` band case).

    Args:
        matrix: The dense matrix, shape ``(*batch, k, k)``.
        in_structure: Structure of the values the operator is called with.
            Defaults to a plain array of shape ``(*batch, k)``.
            Pass a PyTree explicitly to solve for several arrays at once instead.
        regularization: Relative ridge added to the diagonal before factoring.
    """
    if in_structure is None:
        in_structure = jax.ShapeDtypeStruct(matrix.shape[:-1], matrix.dtype)
    return cls.from_bands(matrix[..., None, None, :, :], in_structure, regularization)

mv(x)

Source code in src/furax/linalg/cholesky.py
def mv(self, x: PyTree[Array]) -> PyTree[Array]:
    batch_ndim = self.lb.ndim - 4
    n_blocks, k = self.lb.shape[-4], self.lb.shape[-1]
    leaves, treedef = jax.tree.flatten(x)
    # Flatten every leaf's non-batch axes and concatenate them into one length-K vector per
    # batch element, matching how `lb` was built from `bands`/`matrix` (K = n_blocks * k).
    flats = [leaf.reshape(*leaf.shape[:batch_ndim], -1) for leaf in leaves]
    xf = jnp.concatenate(flats, axis=-1)  # (*batch, K)
    xb = xf.reshape(*xf.shape[:-1], n_blocks, k)  # split K back into the (n_blocks, k) layout
    yb = banded_cholesky_solve(self.lb, xb)
    yf = yb.reshape(*yb.shape[:-2], -1)  # (*batch, K)
    # Undo the flatten/concatenate: split the solution back into per-leaf chunks (in the same
    # order they were concatenated) and reshape each to its original leaf shape.
    sizes = [prod(leaf.shape[batch_ndim:]) for leaf in leaves]
    chunks = jnp.split(yf, np.cumsum(sizes)[:-1], axis=-1)
    out = [c.reshape(leaf.shape) for c, leaf in zip(chunks, leaves, strict=True)]
    return treedef.unflatten(out)  # type: ignore[attr-defined]

furax.linalg.LowRankOperator

Bases: AbstractLinearOperator

Linear operator from low-rank terms: A = U @ diag(S) @ U^T.

This wraps a LowRankTerms as an AbstractLinearOperator, enabling composition with other operators.

Parameters:

  • terms (LowRankTerms) –

    Low-rank terms containing eigenvalues and eigenvectors.

  • in_structure (PyTree[ShapeDtypeStruct] | None, default: None ) –

    The expected structure of the operator input. If None, inferred from the eigenvectors structure.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> terms = low_rank(A, rank=2, key=jax.random.PRNGKey(0))
>>> B = LowRankOperator(terms)
>>> x = jnp.ones(5)
>>> y = B(x)  # Applies low-rank approximation

Methods:

Attributes:

Source code in src/furax/linalg/low_rank.py
@symmetric
class LowRankOperator(AbstractLinearOperator):
    """Linear operator from low-rank terms: A = U @ diag(S) @ U^T.

    This wraps a LowRankTerms as an AbstractLinearOperator, enabling
    composition with other operators.

    Args:
        terms: Low-rank terms containing eigenvalues and eigenvectors.
        in_structure: The expected structure of the operator input. If None,
            inferred from the eigenvectors structure.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> terms = low_rank(A, rank=2, key=jax.random.PRNGKey(0))
        >>> B = LowRankOperator(terms)
        >>> x = jnp.ones(5)
        >>> y = B(x)  # Applies low-rank approximation
    """

    terms: LowRankTerms

    def __init__(
        self,
        terms: LowRankTerms,
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
    ):
        if in_structure is None:
            # Infer structure from eigenvectors by removing leading dimension
            in_structure = jax.tree.map(
                lambda leaf: jax.ShapeDtypeStruct(leaf.shape[1:], leaf.dtype),
                terms.eigenvectors,
            )
        object.__setattr__(self, 'terms', terms)
        object.__setattr__(self, 'in_structure', in_structure)

    def mv(self, x: PyTree[Num[Array, '...']]) -> PyTree[Num[Array, '...']]:
        return low_rank_mv(self.terms, x)

terms instance-attribute

__init__(terms, *, in_structure=None)

Source code in src/furax/linalg/low_rank.py
def __init__(
    self,
    terms: LowRankTerms,
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
):
    if in_structure is None:
        # Infer structure from eigenvectors by removing leading dimension
        in_structure = jax.tree.map(
            lambda leaf: jax.ShapeDtypeStruct(leaf.shape[1:], leaf.dtype),
            terms.eigenvectors,
        )
    object.__setattr__(self, 'terms', terms)
    object.__setattr__(self, 'in_structure', in_structure)

mv(x)

Source code in src/furax/linalg/low_rank.py
def mv(self, x: PyTree[Num[Array, '...']]) -> PyTree[Num[Array, '...']]:
    return low_rank_mv(self.terms, x)

furax.linalg.LowRankTerms

Bases: NamedTuple

Low-rank approximation of a Hermitian operator: A ≈ U @ diag(S) @ U^T.

Attributes:

  • eigenvalues (Float[Array, ' k']) –

    The k eigenvalues (S), shape (k,).

  • eigenvectors (PyTree[Num[Array, ' k ...']]) –

    The k eigenvectors (U) as a block PyTree with leading dimension k.

Source code in src/furax/linalg/low_rank.py
class LowRankTerms(NamedTuple):
    """Low-rank approximation of a Hermitian operator: A ≈ U @ diag(S) @ U^T.

    Attributes:
        eigenvalues: The k eigenvalues (S), shape (k,).
        eigenvectors: The k eigenvectors (U) as a block PyTree with leading dimension k.
    """

    eigenvalues: Float[Array, ' k']
    eigenvectors: PyTree[Num[Array, ' k ...']]

eigenvalues instance-attribute

eigenvectors instance-attribute

furax.linalg.cg(A, b, x0=None, *, preconditioner=None, max_steps=500, atol=0.0, rtol=1e-05, stabilise_every=10, negative_curvature='ignore', loop_kind='lax', iteration_callback=None)

Conjugate Gradient solver for symmetric positive definite systems Ax = b.

The residual norm is recorded at every iteration (see CGResult.residuals) so convergence can be monitored. Inputs may be sharded along their contracting dimensions. The solve is forward-mode differentiable by default; reverse-mode (grad/vjp) requires loop_kind='bounded' or 'checkpointed'.

Convergence is declared when ||r|| <= atol + rtol * ||b||. With atol and rtol both 0 the criterion is never met and the solver runs for exactly max_steps steps; otherwise it stops early once the residual is small enough, and max_steps is only a ceiling. A is assumed positive definite; negative curvature is handled per negative_curvature.

Parameters:

  • A (AbstractLinearOperator) –

    A symmetric positive definite linear operator.

  • b (PyTree[Num[Array, ...]]) –

    Right-hand side of the system.

  • x0 (PyTree[Num[Array, ...]] | None, default: None ) –

    Initial guess. Defaults to zeros.

  • preconditioner (AbstractLinearOperator | None, default: None ) –

    Optional preconditioner M such that MA is better conditioned. M must be symmetric positive definite.

  • max_steps (int, default: 500 ) –

    Maximum number of iteration steps.

  • atol (float, default: 0.0 ) –

    Absolute tolerance on the residual norm.

  • rtol (float, default: 1e-05 ) –

    Relative tolerance on the residual norm (scaled by ||b||).

  • stabilise_every (int, default: 10 ) –

    If set, replace the recursively-updated residual with the true residual b - A x every N iterations (after steps N, 2N, 3N, ...). This counters floating-point drift at the cost of one extra matvec per stabilisation.

  • negative_curvature (Literal['ignore', 'error', 'truncate'], default: 'ignore' ) –

    How to handle negative curvature p^T A p < 0, which a positive definite A never produces. One of:

    • 'ignore' (default): assume A is positive definite and do not check. Fastest; the other modes add a per-iteration check.
    • 'error': raise as soon as negative curvature is encountered (a debugging aid for verifying A; configurable via Equinox's EQX_ON_ERROR, see equinox.error_if).
    • 'truncate': stop and return the last iterate before the bad direction (truncated CG, as used by Newton-CG on indefinite Hessians).
  • loop_kind (Literal['lax', 'checkpointed', 'bounded'], default: 'lax' ) –

    Lowering for the iteration loop (equinox.internal.while_loop). 'lax' (default) is fastest and forward-mode differentiable only. 'bounded' adds reverse-mode AD but its cost scales with max_steps (the checkpoint structure runs to the ceiling regardless of early exit), so keep max_steps tight. 'checkpointed' is reverse-mode only.

  • iteration_callback (Callable[[Array, Array], None] | None, default: None ) –

    Optional host callback called after each step with (step, r_norm) as 0-d JAX arrays. Runs via jax.debug.callback so it is JIT-compatible and ordered.

Returns:

  • CGResult

    CGResult with the solution, per-iteration residual norms, and iteration count.

Examples:

>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure
>>> from furax.linalg import cg
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> b = jnp.ones(5)
>>> result = cg(A, b, max_steps=20)
>>> expected = jnp.array([1.0, 0.5, 1 / 3, 0.25, 0.2])
>>> bool(jnp.allclose(result.solution, expected, atol=1e-4))
True
Source code in src/furax/linalg/_cg.py
def cg(
    A: AbstractLinearOperator,
    b: PyTree[Num[Array, '...']],
    x0: PyTree[Num[Array, '...']] | None = None,
    *,
    preconditioner: AbstractLinearOperator | None = None,
    max_steps: int = 500,
    atol: float = 0.0,
    rtol: float = 1e-5,
    stabilise_every: int = 10,
    negative_curvature: Literal['ignore', 'error', 'truncate'] = 'ignore',
    loop_kind: Literal['lax', 'checkpointed', 'bounded'] = 'lax',
    iteration_callback: Callable[[Array, Array], None] | None = None,
) -> CGResult:
    """Conjugate Gradient solver for symmetric positive definite systems Ax = b.

    The residual norm is recorded at every iteration (see ``CGResult.residuals``)
    so convergence can be monitored. Inputs may be sharded along their contracting
    dimensions. The solve is forward-mode differentiable by default; reverse-mode
    (``grad``/``vjp``) requires ``loop_kind='bounded'`` or ``'checkpointed'``.

    Convergence is declared when ``||r|| <= atol + rtol * ||b||``. With ``atol``
    and ``rtol`` both 0 the criterion is never met and the solver runs for exactly
    ``max_steps`` steps; otherwise it stops early once the residual is small enough,
    and ``max_steps`` is only a ceiling. ``A`` is assumed positive definite; negative
    curvature is handled per ``negative_curvature``.

    Args:
        A: A symmetric positive definite linear operator.
        b: Right-hand side of the system.
        x0: Initial guess. Defaults to zeros.
        preconditioner: Optional preconditioner M such that MA is better
            conditioned. M must be symmetric positive definite.
        max_steps: Maximum number of iteration steps.
        atol: Absolute tolerance on the residual norm.
        rtol: Relative tolerance on the residual norm (scaled by ``||b||``).
        stabilise_every: If set, replace the recursively-updated residual with
            the true residual ``b - A x`` every N iterations (after steps
            N, 2N, 3N, ...). This counters floating-point drift at the cost of
            one extra matvec per stabilisation.
        negative_curvature: How to handle negative curvature ``p^T A p < 0``, which a
            positive definite ``A`` never produces. One of:

            - ``'ignore'`` (default): assume ``A`` is positive definite and do not
                check. Fastest; the other modes add a per-iteration check.
            - ``'error'``: raise as soon as negative curvature is encountered (a
                debugging aid for verifying ``A``; configurable via Equinox's
                ``EQX_ON_ERROR``, see ``equinox.error_if``).
            - ``'truncate'``: stop and return the last iterate before the bad
                direction (truncated CG, as used by Newton-CG on indefinite Hessians).
        loop_kind: Lowering for the iteration loop (``equinox.internal.while_loop``).
            ``'lax'`` (default) is fastest and forward-mode differentiable only.
            ``'bounded'`` adds reverse-mode AD but its cost scales with ``max_steps``
            (the checkpoint structure runs to the ceiling regardless of early exit),
            so keep ``max_steps`` tight. ``'checkpointed'`` is reverse-mode only.
        iteration_callback: Optional host callback called after each step with
            ``(step, r_norm)`` as 0-d JAX arrays.  Runs via
            ``jax.debug.callback`` so it is JIT-compatible and ordered.

    Returns:
        CGResult with the solution, per-iteration residual norms, and iteration count.

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure
        >>> from furax.linalg import cg
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> b = jnp.ones(5)
        >>> result = cg(A, b, max_steps=20)
        >>> expected = jnp.array([1.0, 0.5, 1 / 3, 0.25, 0.2])
        >>> bool(jnp.allclose(result.solution, expected, atol=1e-4))
        True
    """
    if negative_curvature not in ('ignore', 'error', 'truncate'):
        raise ValueError(
            f'negative_curvature must be ignore/error/truncate, got {negative_curvature!r}'
        )
    truncate = negative_curvature == 'truncate'
    check_curvature = negative_curvature == 'error'

    if x0 is None:
        x0 = tree.zeros_like(b)

    has_scale = atol > 0 or rtol > 0
    norm_b = tree.norm(b)
    abs_tol = atol + rtol * norm_b

    def _converged(r_norm: Array) -> Array:
        return has_scale & (r_norm <= abs_tol)

    def _stable_r(x: PyTree) -> PyTree:
        return tree.sub(b, A(x))

    def _cheap_r(r: PyTree, alpha: Array, Ap: PyTree) -> PyTree:
        return tree.sub(r, tree.mul(alpha, Ap))

    # Initial residual r = b - A @ x0
    r = _stable_r(x0)

    # Apply preconditioner: z = M r
    M = preconditioner or (lambda _: _)
    z = M(r)

    p = z
    rz = tree.dot(r, z)  # r^T z (or r^T M^{-1} r)
    eps = jnp.finfo(rz.real.dtype).eps
    # `rz` is a residual energy, so square the relative norm floor. The 100*eps
    # margin catches roundoff-level residuals before stabilisation can amplify them.
    rz_floor = (100 * eps) ** 2 * jnp.abs(rz)

    r0_norm = tree.norm(r)
    # residuals[0] = initial residual; residuals[i+1] = residual after step i.
    # If i+1 >= max_steps (loop ran to completion), the last write is dropped by JAX.
    residuals = jnp.zeros(max_steps).at[0].set(r0_norm)

    def cond_fn(c: _CGCarry) -> Array:
        return ~c.converged & ~c.truncated & (c.step < max_steps)

    def body_fn(c: _CGCarry) -> _CGCarry:
        x, r, p, rz, i = c.x, c.r, c.p, c.rz, c.step

        Ap = A(p)
        pAp = tree.dot(p, Ap)
        active = jnp.abs(rz) > rz_floor

        # `pAp == 0` only at convergence (p -> 0), handled by `safe_pAp`
        # `pAp < 0` is genuine negative curvature, which a positive definite A should never produce
        if truncate:
            # Negative curvature: take no step this iteration and flag the loop to stop.
            truncated = active & (pAp < 0)
            safe_pAp = jnp.where(pAp == 0, 1.0, pAp)
            alpha = jnp.where(active & ~truncated, rz / safe_pAp, 0.0)
        else:
            truncated = c.truncated
            if check_curvature:
                pAp = eqx.error_if(
                    pAp, active & (pAp < 0), 'cg: negative curvature detected p^T A p < 0'
                )
            safe_pAp = jnp.where(pAp == 0, 1.0, pAp)
            alpha = jnp.where(active, rz / safe_pAp, 0.0)

        x = tree.add(x, tree.mul(alpha, p))

        # Choose between stable (true) and cheap (recursive) residual
        if stabilise_every > 0:
            r = jax.lax.cond(
                i % stabilise_every == stabilise_every - 1,
                lambda: _stable_r(x),
                lambda: _cheap_r(r, alpha, Ap),
            )
        else:
            r = _cheap_r(r, alpha, Ap)

        r_norm = tree.norm(r)

        if iteration_callback is not None:
            # `ordered = True` would error in distributed mode
            jax.debug.callback(iteration_callback, i, r_norm)

        z = M(r)
        rz_new = tree.dot(r, z)
        # Once the preconditioned residual energy reaches the floating-point floor,
        # fixed-iteration CG should become inert. This avoids dividing true-residual
        # roundoff by true-residual roundoff after stabilisation.
        safe_rz = jnp.where(active, rz, 1.0)
        beta = jnp.where(jnp.abs(rz_new) > rz_floor, rz_new / safe_rz, 0.0)

        p = tree.add(z, tree.mul(beta, p))

        converged = _converged(r_norm)
        residuals = c.residuals.at[i + 1].set(r_norm)

        return _CGCarry(x, r, p, rz_new, converged, truncated, i + 1, residuals)

    init_carry = _CGCarry(
        x=x0,
        r=r,
        p=p,
        rz=rz,
        converged=_converged(r0_norm),
        truncated=jnp.array(False),
        step=jnp.int32(0),
        residuals=residuals,
    )
    # `residuals` is only ever written (`.at[i].set`) inside the loop, never read, so mark it a
    # buffer for an efficient in-place scatter. `loop_kind` selects the lowering: 'lax' is fastest
    # (forward-mode AD only), 'bounded'/'checkpointed' add reverse-mode AD (see `loop_kind` arg).
    out = eqxi.while_loop(
        cond_fn,
        body_fn,
        init_carry,
        max_steps=max_steps,
        buffers=lambda c: c.residuals,
        kind=loop_kind,
    )

    return CGResult(solution=out.x, residuals=out.residuals, num_steps=out.step)

furax.linalg.eigvalsh(A, batch_size=10000)

Eigenvalues of batched symmetric matrices, sorted ascending.

Uses an analytic closed-form for n<=3, and jax.numpy.linalg.eigvalsh for larger matrices.

Parameters:

  • A (Float[Array, '... n n']) –

    Batched symmetric matrix array with shape (..., n, n).

  • batch_size (int, default: 10000 ) –

    Number of matrices to process per XLA kernel launch for n>3. When batch_size >= total number of matrices the full batch is processed in one launch (maximum parallelism). Reduce to limit peak memory usage.

Source code in src/furax/linalg/_eigvalsh.py
def eigvalsh(A: Float[Array, '... n n'], batch_size: int = 10_000) -> Float[Array, '... n']:
    """Eigenvalues of batched symmetric matrices, sorted ascending.

    Uses an analytic closed-form for n<=3, and jax.numpy.linalg.eigvalsh for larger matrices.

    Args:
        A: Batched symmetric matrix array with shape (..., n, n).
        batch_size: Number of matrices to process per XLA kernel launch for n>3.
            When batch_size >= total number of matrices the full batch is processed
            in one launch (maximum parallelism). Reduce to limit peak memory usage.
    """
    if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
        raise ValueError(f'eigvalsh requires (..., n, n) input, got shape {A.shape}')
    n = A.shape[-1]
    if n == 1:
        return A[..., :1, 0]
    elif n == 2:
        return _eigvalsh_2x2(A)  # type: ignore[no-any-return]
    elif n == 3:
        return _eigvalsh_3x3(A)  # type: ignore[no-any-return]
    else:
        leading = A.shape[:-2]
        flat = A.reshape(-1, n, n)
        result = jax.lax.map(jnp.linalg.eigvalsh, flat, batch_size=batch_size)
        return result.reshape(*leading, n)  # type: ignore[no-any-return]

furax.linalg.lanczos_eigh(A, v0, *, k=20, m=None)

Lanczos algorithm for computing k eigenvalues via an m-dimensional Krylov subspace.

Builds an m-dimensional Krylov subspace (m >= k) and computes all m Ritz pairs. When m == k the method returns all m Ritz pairs sorted ascending. When m > k, only the k Ritz pairs with the smallest residual norms are returned. Selecting by residual norm (rather than by eigenvalue magnitude) picks the pairs that have converged most reliably within the subspace, which need not be the extremal ones.

Note

There is no which parameter (unlike lanczos_tr). Targeted selection of smallest/largest pairs would require either restarts or post-filtering of unconverged Ritz values, neither of which is meaningful for a single-shot m-step factorization. Use lanczos_tr if you need extremal eigenpairs.

Note

Early breakdown (beta_j == 0, i.e. invariant subspace reached) is not detected. The corresponding Lanczos vector becomes zero and the remaining iterations produce zero contributions; affected Ritz pairs will have zero residual norms but their eigenvectors should not be trusted.

The cheap Lanczos residual bound is used

||A y_i - θ_i y_i|| ≈ |β_m| |s_i[m-1]|

where s_i is the i-th eigenvector of the m×m tridiagonal T_m.

Parameters:

  • A (AbstractLinearOperator) –

    A Hermitian linear operator.

  • v0 (PyTree[Num[Array, ...]]) –

    Initial vector for the Krylov subspace.

  • k (int, default: 20 ) –

    Number of eigenpairs to return.

  • m (int | None, default: None ) –

    Size of the Krylov subspace. Must be >= k. Defaults to min(2k, n). Larger m builds a richer subspace and can yield more accurate Ritz pairs, at the cost of m matrix-vector products and O(m) vector storage.

Returns:

  • LanczosResult

    LanczosResult containing the k best eigenvalues, eigenvectors, and their

  • LanczosResult

    residual norms, sorted by eigenvalue ascending.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure, normal_like
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> v0 = normal_like(as_structure(d), jax.random.PRNGKey(0))
>>> result = lanczos_eigh(A, v0, k=5)
>>> result.eigenvalues
Array([1., 2., 3., 4., 5.], dtype=float32)
Source code in src/furax/linalg/_lanczos.py
def lanczos_eigh(
    A: AbstractLinearOperator,
    v0: PyTree[Num[Array, '...']],
    *,
    k: int = 20,
    m: int | None = None,
) -> LanczosResult:
    """Lanczos algorithm for computing k eigenvalues via an m-dimensional Krylov subspace.

    Builds an m-dimensional Krylov subspace (m >= k) and computes all m Ritz pairs.
    When m == k the method returns all m Ritz pairs sorted ascending.  When m > k,
    only the k Ritz pairs with the smallest residual norms are returned.  Selecting
    by residual norm (rather than by eigenvalue magnitude) picks the pairs that have
    converged most reliably within the subspace, which need not be the extremal ones.

    Note:
        There is no ``which`` parameter (unlike ``lanczos_tr``).  Targeted selection
        of smallest/largest pairs would require either restarts or post-filtering of
        unconverged Ritz values, neither of which is meaningful for a single-shot
        m-step factorization.  Use ``lanczos_tr`` if you need extremal eigenpairs.

    Note:
        Early breakdown (``beta_j == 0``, i.e. invariant subspace reached) is not
        detected.  The corresponding Lanczos vector becomes zero and the remaining
        iterations produce zero contributions; affected Ritz pairs will have zero
        residual norms but their eigenvectors should not be trusted.

    The cheap Lanczos residual bound is used:
        ||A y_i - θ_i y_i|| ≈ |β_m| |s_i[m-1]|
    where s_i is the i-th eigenvector of the m×m tridiagonal T_m.

    Args:
        A: A Hermitian linear operator.
        v0: Initial vector for the Krylov subspace.
        k: Number of eigenpairs to return.
        m: Size of the Krylov subspace.  Must be >= k.  Defaults to min(2k, n).
            Larger m builds a richer subspace and can yield more accurate Ritz
            pairs, at the cost of m matrix-vector products and O(m) vector storage.

    Returns:
        LanczosResult containing the k best eigenvalues, eigenvectors, and their
        residual norms, sorted by eigenvalue ascending.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure, normal_like
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> v0 = normal_like(as_structure(d), jax.random.PRNGKey(0))
        >>> result = lanczos_eigh(A, v0, k=5)
        >>> result.eigenvalues
        Array([1., 2., 3., 4., 5.], dtype=float32)
    """
    m = m or _default_m(A, k)
    if m < k:
        raise ValueError(f'm ({m}) must be >= k ({k})')

    # Run Lanczos to build tridiagonal matrix in m-dimensional Krylov subspace
    alpha, beta, V, beta_last, _ = lanczos_tridiag(A, v0, m)
    ritz_values, ritz_vectors = jax.scipy.linalg.eigh_tridiagonal(alpha, beta, eigvals_only=False)

    eigenvectors = _vecmat(V, ritz_vectors)  # y_i = V s_i

    # ||A y_i - θ_i y_i|| ≈ |β_m| |s_i[-1]|
    residual_norms = jnp.abs(beta_last) * jnp.abs(ritz_vectors[-1, :])

    # Select the k best Ritz pairs by residual norm, then sort by eigenvalue ascending
    best_idx = jnp.argsort(residual_norms)[:k]
    best_idx = best_idx[jnp.argsort(ritz_values[best_idx])]
    return LanczosResult(
        eigenvalues=ritz_values[best_idx],
        eigenvectors=jax.tree.map(lambda leaf: leaf[best_idx], eigenvectors),
        residual_norms=residual_norms[best_idx],
    )

furax.linalg.lanczos_tr(A, v0, *, k=20, m=None, which='LM', max_restarts=300, tol=1e-10)

Thick-restart Lanczos for computing k eigenpairs of a Hermitian operator.

Each restart cycle maintains an m-step Lanczos factorization:

\[ A V_m = V_m H + \beta_\text{last}\, v_\text{last}\, e_{m-1}^T \]

where H is a bordered tridiagonal inner matrix.

A pair is converged when the cheap Lanczos residual bound (ARPACK criterion)

\[ |\beta_\text{last}| \cdot |S[m-1, i]| \le \text{tol} \cdot \max(|\theta_i|, \epsilon \|A\|) \]

where S is the eigenvector matrix of the m×m inner matrix H, so S[m-1, i] is the last component of the i-th eigenvector of H, and max|theta| is used as an estimate of ||A||.

Uses full reorthogonalization throughout. No locking: all k pairs are recomputed at every restart regardless of convergence status.

Note

residual_norms is the cheap bound, not the true residual. Exactly 0 means "converged below the detectable coupling", not zero error.

Note

Lanczos converges fastest to extremal eigenvalues; interior ones (near the middle of the spectrum) converge slowly. This makes which targets that select interior pairs hard: 'SM' for an indefinite operator picks eigenvalues closest to zero, which are interior, and restarts alone will not converge them unless m is a large fraction of n (no shift-invert is implemented). 'LM', 'LA', 'SA' and the two ends of 'BE' are extremal and converge with small m.

Parameters:

  • A (AbstractLinearOperator) –

    A Hermitian linear operator.

  • v0 (PyTree[Num[Array, ...]]) –

    Initial vector for the Krylov subspace.

  • k (int, default: 20 ) –

    Number of eigenpairs to compute.

  • m (int | None, default: None ) –

    Size of the Krylov subspace. Must be > k. Defaults to min(2*k, n).

  • which (LanczosWhich, default: 'LM' ) –

    Which k eigenpairs to target. One of: - 'LM' (default): k largest magnitude (|λ|). - 'SM': k smallest magnitude (|λ|). - 'LA': k largest algebraic. - 'SA': k smallest algebraic. - 'BE': half (k//2) from each end of the spectrum; for odd k the extra pair comes from the high (largest algebraic) end.

  • max_restarts (int, default: 300 ) –

    Maximum number of restart cycles.

  • tol (float, default: 1e-10 ) –

    Convergence tolerance; see criterion above.

Returns:

  • LanczosResult

    LanczosResult containing eigenvalues, eigenvectors, and residual norms,

  • LanczosResult

    sorted by eigenvalue ascending.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure, normal_like
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> v0 = normal_like(as_structure(d), jax.random.PRNGKey(0))
>>> result = lanczos_tr(A, v0, k=2, which='SA')
>>> result.eigenvalues  # Should be approximately [1, 2]
Array([1., 2.], dtype=float32)
Source code in src/furax/linalg/_lanczos.py
def lanczos_tr(
    A: AbstractLinearOperator,
    v0: PyTree[Num[Array, '...']],
    *,
    k: int = 20,
    m: int | None = None,
    which: LanczosWhich = 'LM',
    max_restarts: int = 300,
    tol: float = 1e-10,
) -> LanczosResult:
    r"""Thick-restart Lanczos for computing k eigenpairs of a Hermitian operator.

    Each restart cycle maintains an m-step Lanczos factorization:

    $$
    A V_m = V_m H + \beta_\text{last}\, v_\text{last}\, e_{m-1}^T
    $$

    where H is a bordered tridiagonal inner matrix.

    A pair is converged when the cheap Lanczos residual bound (ARPACK criterion)

    $$
    |\beta_\text{last}| \cdot |S[m-1, i]| \le \text{tol} \cdot \max(|\theta_i|, \epsilon \|A\|)
    $$

    where ``S`` is the eigenvector matrix of the m×m inner matrix H, so
    ``S[m-1, i]`` is the last component of the i-th eigenvector of H, and
    ``max|theta|`` is used as an estimate of ``||A||``.

    Uses full reorthogonalization throughout.  No locking: all k pairs are
    recomputed at every restart regardless of convergence status.

    Note:
        ``residual_norms`` is the cheap bound, not the true residual.  Exactly
        ``0`` means "converged below the detectable coupling", not zero error.

    Note:
        Lanczos converges fastest to *extremal* eigenvalues; interior ones
        (near the middle of the spectrum) converge slowly.  This makes ``which``
        targets that select interior pairs hard: ``'SM'`` for an indefinite
        operator picks eigenvalues closest to zero, which are interior, and
        restarts alone will not converge them unless ``m`` is a large fraction
        of ``n`` (no shift-invert is implemented).  ``'LM'``, ``'LA'``, ``'SA'``
        and the two ends of ``'BE'`` are extremal and converge with small ``m``.

    Args:
        A: A Hermitian linear operator.
        v0: Initial vector for the Krylov subspace.
        k: Number of eigenpairs to compute.
        m: Size of the Krylov subspace.  Must be > k.
            Defaults to min(2*k, n).
        which: Which k eigenpairs to target.  One of:
            - 'LM' (default): k largest magnitude (|λ|).
            - 'SM': k smallest magnitude (|λ|).
            - 'LA': k largest algebraic.
            - 'SA': k smallest algebraic.
            - 'BE': half (k//2) from each end of the spectrum; for odd k the
              extra pair comes from the high (largest algebraic) end.
        max_restarts: Maximum number of restart cycles.
        tol: Convergence tolerance; see criterion above.

    Returns:
        LanczosResult containing eigenvalues, eigenvectors, and residual norms,
        sorted by eigenvalue ascending.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure, normal_like
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> v0 = normal_like(as_structure(d), jax.random.PRNGKey(0))
        >>> result = lanczos_tr(A, v0, k=2, which='SA')
        >>> result.eigenvalues  # Should be approximately [1, 2]
        Array([1., 2.], dtype=float32)
    """
    if which not in get_args(LanczosWhich):
        raise ValueError(f'which must be one of {get_args(LanczosWhich)}, got {which!r}')
    m = m or _default_m(A, k)
    if m <= k:
        raise ValueError(f'm ({m}) must be > k ({k})')

    def _select_wanted(theta):  # type: ignore[no-untyped-def]
        if which == 'LM':  # largest magnitude
            sorted_idx = jnp.argsort(-jnp.abs(theta))
        elif which == 'SM':  # smallest magnitude
            sorted_idx = jnp.argsort(jnp.abs(theta))
        elif which == 'LA':  # largest algebraic
            sorted_idx = jnp.argsort(-theta)
        elif which == 'SA':  # smallest algebraic
            sorted_idx = jnp.argsort(theta)
        else:  # 'BE': half from each end of the spectrum
            sorted_idx = jnp.argsort(theta)
            n_low = k // 2
            n_high = k - n_low
            return jnp.concatenate([sorted_idx[:n_low], sorted_idx[-n_high:]])
        return sorted_idx[:k]

    def _check_converged(theta, beta_last, S, wanted_idx):  # type: ignore[no-untyped-def]
        # ARPACK criterion: |β_m| |s_i[-1]| ≤ tol * max(|θ_i|, eps*||A||)
        # Floor prevents stall when θ_i ≈ 0; max|θ| estimates ||A||.
        ritz_res = jnp.abs(beta_last) * jnp.abs(S[-1, wanted_idx])  # |β_m| |s_i[-1]|
        eps = jnp.finfo(theta.dtype).eps
        scale = jnp.maximum(jnp.abs(theta[wanted_idx]), eps * jnp.max(jnp.abs(theta)))
        return jnp.all(ritz_res <= tol * scale)

    # Initial m-step factorization
    alpha, beta, V, beta_last, v_last = lanczos_tridiag(A, v0, m)
    theta, S = jax.scipy.linalg.eigh_tridiagonal(alpha, beta, eigvals_only=False)
    wanted_idx = _select_wanted(theta)
    init_converged = _check_converged(theta, beta_last, S, wanted_idx)

    def cond_fn(state):  # type: ignore[no-untyped-def]
        *_, iteration, converged, _theta, _S, _wanted_idx = state
        return jnp.logical_and(iteration < max_restarts, ~converged)

    def body_fn(state):  # type: ignore[no-untyped-def]
        V, beta_last, v_last, iteration, _converged, theta, S, wanted_idx = state

        V_k = _vecmat(V, S[:, wanted_idx])  # U_k = V S[:,wanted]  (Ritz vectors)
        theta_k = theta[wanted_idx]  # θ_k
        h = beta_last * S[-1, wanted_idx]  # h_i = β_m s_i[-1]  (coupling)

        alpha_ext, beta_ext, V, beta_last, v_last = _tr_extend(A, V_k, v_last, k, m)

        H = _build_bordered_tridiag(theta_k, h, alpha_ext, beta_ext, k, m)
        theta, S = jnp.linalg.eigh(H)  # H S = S diag(θ)

        wanted_idx = _select_wanted(theta)
        converged = _check_converged(theta, beta_last, S, wanted_idx)

        return V, beta_last, v_last, iteration + 1, converged, theta, S, wanted_idx

    init_state = (V, beta_last, v_last, jnp.array(0), init_converged, theta, S, wanted_idx)
    V, beta_last, _v_last, _iters, _conv, theta, S, wanted_idx = jax.lax.while_loop(
        cond_fn, body_fn, init_state
    )

    # Sort selected pairs by eigenvalue ascending
    wanted_idx = wanted_idx[jnp.argsort(theta[wanted_idx])]
    eigenvalues = theta[wanted_idx]
    eigenvectors = _vecmat(V, S[:, wanted_idx])  # y_i = V s_i
    residual_norms = jnp.abs(beta_last) * jnp.abs(S[-1, wanted_idx])  # |β_m| |s_i[-1]|

    return LanczosResult(
        eigenvalues=eigenvalues,
        eigenvectors=eigenvectors,
        residual_norms=residual_norms,
    )

furax.linalg.banded_cholesky(bands, regularization=0.0)

Block Cholesky factor of a symmetric positive-(semi)definite block-banded matrix.

"Block-banded" means A is made of k×k blocks, and only the blocks within w of the diagonal can be non-zero. E.g., for n_blocks = 4 and bandwidth w = 1:

A = [ A00  A01   0    0  ]
    [ A01ᵀ A11  A12   0  ]
    [  0   A12ᵀ A22  A23 ]
    [  0    0   A23ᵀ A33 ]

Only the diagonal and upper blocks are stored (symmetry gives the rest) packed as bands[..., j, d, :, :] = A[j, j+d] for d = 0..w (d=0 the diagonal blocks, d=w the furthest off-diagonal ones still inside the band)::

bands[:, 0] = [A00, A11, A22, A33]  (d=0: the diagonal blocks)
bands[:, 1] = [A01, A12, A23,  · ]  (d=1: one block off the diagonal; the trailing
                                       entry is unused padding, since block 3 has no
                                       block 4 to pair with)

Returns the lower factor in the same band layout, lb[..., i, d, :, :] = L[i, i-d] with L Lᵀ = A. Batched over arbitrary leading dims. Band w = 0 is the block-diagonal case (every off-diagonal block is zero); n_blocks = 1 is the fully dense case.

regularization adds a relative ridge to each diagonal block before factoring, scaled by that block's own mean diagonal. This is purely intended as a numerical safeguard.

Parameters:

  • bands (Float[Array, '*batch n w1 k k']) –

    Upper-band representation of the block-banded matrix, shape (*batch, n_blocks, w+1, k, k).

  • regularization (float, default: 0.0 ) –

    Relative ridge added to each diagonal block before factoring.

Returns:

  • Float[Array, '*batch n w1 k k']

    The lower band factor, same shape as bands.

Examples:

>>> import jax.numpy as jnp
>>> from furax.linalg import banded_cholesky
>>> # tridiagonal SPD A = [[4,1,0,0],[1,4,1,0],[0,1,4,1],[0,0,1,4]], scalar (k=1) blocks
>>> diag = jnp.array([4., 4., 4., 4.]).reshape(4, 1, 1, 1)
>>> off = jnp.array([1., 1., 1., 0.]).reshape(4, 1, 1, 1)  # trailing entry is padding
>>> bands = jnp.concatenate([diag, off], axis=1)  # (n_blocks=4, w1=2, k=1, k=1)
>>> lb = banded_cholesky(bands)
>>> lb[:, :, 0, 0]  # columns are (L[i,i], L[i,i-1])
Array([[2.        , 0.        ],
       [1.9364916 , 0.5       ],
       [1.9321835 , 0.5163978 ],
       [1.9318755 , 0.51754916]], dtype=float32)
Source code in src/furax/linalg/cholesky.py
def banded_cholesky(
    bands: Float[Array, '*batch n w1 k k'], regularization: float = 0.0
) -> Float[Array, '*batch n w1 k k']:
    """Block Cholesky factor of a symmetric positive-(semi)definite block-banded matrix.

    "Block-banded" means ``A`` is made of ``k×k`` blocks, and only the blocks within ``w``
    of the diagonal can be non-zero. E.g., for ``n_blocks = 4`` and bandwidth ``w = 1``:

        A = [ A00  A01   0    0  ]
            [ A01ᵀ A11  A12   0  ]
            [  0   A12ᵀ A22  A23 ]
            [  0    0   A23ᵀ A33 ]

    Only the diagonal and upper blocks are stored (symmetry gives the rest) packed as
    ``bands[..., j, d, :, :] = A[j, j+d]`` for ``d = 0..w`` (``d=0`` the diagonal blocks,
    ``d=w`` the furthest off-diagonal ones still inside the band)::

        bands[:, 0] = [A00, A11, A22, A33]  (d=0: the diagonal blocks)
        bands[:, 1] = [A01, A12, A23,  · ]  (d=1: one block off the diagonal; the trailing
                                               entry is unused padding, since block 3 has no
                                               block 4 to pair with)

    Returns the lower factor in the same band layout, ``lb[..., i, d, :, :] = L[i, i-d]`` with
    ``L Lᵀ = A``. Batched over arbitrary leading dims. Band ``w = 0`` is the block-diagonal case
    (every off-diagonal block is zero); ``n_blocks = 1`` is the fully dense case.

    ``regularization`` adds a relative ridge to each diagonal block before factoring, scaled by
    that block's own mean diagonal. This is purely intended as a numerical safeguard.

    Args:
        bands: Upper-band representation of the block-banded matrix, shape
            ``(*batch, n_blocks, w+1, k, k)``.
        regularization: Relative ridge added to each diagonal block before factoring.

    Returns:
        The lower band factor, same shape as ``bands``.

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.linalg import banded_cholesky
        >>> # tridiagonal SPD A = [[4,1,0,0],[1,4,1,0],[0,1,4,1],[0,0,1,4]], scalar (k=1) blocks
        >>> diag = jnp.array([4., 4., 4., 4.]).reshape(4, 1, 1, 1)
        >>> off = jnp.array([1., 1., 1., 0.]).reshape(4, 1, 1, 1)  # trailing entry is padding
        >>> bands = jnp.concatenate([diag, off], axis=1)  # (n_blocks=4, w1=2, k=1, k=1)
        >>> lb = banded_cholesky(bands)
        >>> lb[:, :, 0, 0]  # columns are (L[i,i], L[i,i-1])
        Array([[2.        , 0.        ],
               [1.9364916 , 0.5       ],
               [1.9321835 , 0.5163978 ],
               [1.9318755 , 0.51754916]], dtype=float32)
    """
    if regularization:
        k = bands.shape[-1]
        diag = jnp.diagonal(bands[..., 0, :, :], axis1=-2, axis2=-1)  # (*batch, n, k)
        scale = jnp.mean(diag, axis=-1)[..., None, None]  # (*batch, n, 1, 1)
        ridge = regularization * scale * jnp.eye(k, dtype=bands.dtype)
        bands = bands.at[..., 0, :, :].add(ridge)
    return _block_banded_cholesky(bands)  # type: ignore[no-any-return]

furax.linalg.banded_cholesky_solve(lb, b)

Solve L Lᵀ x = b given the lower band factor lb from banded_cholesky.

Batched over arbitrary leading dims (matching lb's, excluding its trailing (n, w1, k, k) core).

Examples:

>>> import jax.numpy as jnp
>>> from furax.linalg import banded_cholesky, banded_cholesky_solve
>>> a = jnp.array([[4., 2.], [2., 3.]])
>>> lb = banded_cholesky(a[None, None, None])
>>> x = banded_cholesky_solve(lb, jnp.array([[[1., 0.]]]))
>>> a @ x[0, 0]
Array([1., 0.], dtype=float32)
Source code in src/furax/linalg/cholesky.py
@partial(jnp.vectorize, signature='(n,w1,k,k),(n,k)->(n,k)')
def banded_cholesky_solve(
    lb: Float[Array, '*batch n w1 k k'], b: Float[Array, '*batch n k']
) -> Float[Array, '*batch n k']:
    """Solve ``L Lᵀ x = b`` given the lower band factor ``lb`` from [`banded_cholesky`][].

    Batched over arbitrary leading dims (matching ``lb``'s, excluding its trailing
    ``(n, w1, k, k)`` core).

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.linalg import banded_cholesky, banded_cholesky_solve
        >>> a = jnp.array([[4., 2.], [2., 3.]])
        >>> lb = banded_cholesky(a[None, None, None])
        >>> x = banded_cholesky_solve(lb, jnp.array([[[1., 0.]]]))
        >>> a @ x[0, 0]
        Array([1., 0.], dtype=float32)
    """
    n, w1, k, _ = lb.shape
    w = w1 - 1

    def read(arr: Array, idx: Array):  # type: ignore[no-untyped-def]
        return jax.lax.dynamic_index_in_dim(arr, jnp.clip(idx, 0, n - 1), axis=0, keepdims=False)

    def fwd(i: Array, y: Array):  # type: ignore[no-untyped-def]  # forward: L y = b
        rhs = read(b, i)
        lb_i = read(lb, i)
        for d in range(1, w + 1):
            rhs = rhs - jnp.where(i - d >= 0, lb_i[d] @ read(y, i - d), 0.0)
        yi = jax.scipy.linalg.solve_triangular(lb_i[0], rhs, lower=True)
        return jax.lax.dynamic_update_index_in_dim(y, yi, i, axis=0)

    y = jax.lax.fori_loop(0, n, fwd, jnp.zeros((n, k), b.dtype))

    def bwd(step: Array, x: Array):  # type: ignore[no-untyped-def]  # backward: Lᵀ x = y
        i = n - 1 - step
        rhs = read(y, i)
        for d in range(1, w + 1):  # L[i+d, i] = lb[i+d, d]
            rhs = rhs - jnp.where(
                i + d <= n - 1, jnp.swapaxes(read(lb, i + d)[d], -1, -2) @ read(x, i + d), 0.0
            )
        xi = jax.scipy.linalg.solve_triangular(read(lb, i)[0].T, rhs, lower=False)
        return jax.lax.dynamic_update_index_in_dim(x, xi, i, axis=0)

    solution: Array = jax.lax.fori_loop(0, n, bwd, jnp.zeros((n, k), b.dtype))
    return solution

furax.linalg.low_rank_mv(terms, x)

Apply low-rank approximation as a matrix-vector product.

Computes y = U @ diag(S) @ U^T @ x efficiently without forming the full matrix.

Parameters:

  • terms (LowRankTerms) –

    Low-rank terms containing eigenvalues and eigenvectors.

  • x (PyTree[Num[Array, ...]]) –

    Input PyTree with structure matching the eigenvectors (without leading k dimension).

Returns:

  • PyTree[Num[Array, ...]]

    Output PyTree y = U @ diag(S) @ U^T @ x.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> terms = low_rank(A, rank=5, key=jax.random.PRNGKey(0))  # Full rank
>>> x = jnp.array([1., 0., 0., 0., 0.])
>>> y = low_rank_mv(terms, x)
>>> # Should be close to A @ x = [1, 0, 0, 0, 0]
Source code in src/furax/linalg/low_rank.py
def low_rank_mv(terms: LowRankTerms, x: PyTree[Num[Array, '...']]) -> PyTree[Num[Array, '...']]:
    """Apply low-rank approximation as a matrix-vector product.

    Computes y = U @ diag(S) @ U^T @ x efficiently without forming the full matrix.

    Args:
        terms: Low-rank terms containing eigenvalues and eigenvectors.
        x: Input PyTree with structure matching the eigenvectors (without leading k dimension).

    Returns:
        Output PyTree y = U @ diag(S) @ U^T @ x.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> terms = low_rank(A, rank=5, key=jax.random.PRNGKey(0))  # Full rank
        >>> x = jnp.array([1., 0., 0., 0., 0.])
        >>> y = low_rank_mv(terms, x)
        >>> # Should be close to A @ x = [1, 0, 0, 0, 0]
    """
    U = terms.eigenvectors
    coeffs = jax.vmap(dot, (0, None), 0)(U, x)  # U^T x
    scaled = terms.eigenvalues * coeffs  # S (U^T x)
    return jax.tree.map(lambda a: jnp.einsum('k,k...->...', scaled, a), U)  # U (S U^T x)

furax.linalg.cholesky

Block-banded Cholesky factorization for symmetric positive-(semi)definite matrices.

Classes:

Functions:

BandedCholeskyOperator dataclass

Bases: AbstractLinearOperator

Inverse of a symmetric positive-(semi)definite matrix, by Cholesky solve.

This operator can be used with an ordinary dense matrix (from_dense), or from a banded matrix (zero away from the diagonal, from_bands) and so is stored compactly instead of as the full matrix.

Calling A(b) solves A x = b.

Examples:

>>> import jax.numpy as jnp
>>> from furax.linalg import BandedCholeskyOperator
>>> A = jnp.array([[4., 2.], [2., 3.]])
>>> op = BandedCholeskyOperator.from_dense(A)
>>> op(jnp.array([1., 0.]))  # solves A x = [1, 0]
Array([ 0.375, -0.25 ], dtype=float32)

Methods:

  • from_bands

    Factor a block-banded matrix (see banded_cholesky) and wrap it as an operator.

  • from_dense

    Factor a fully dense matrix (the degenerate n_blocks=1, w=0 band case).

  • mv

Attributes:

  • lb (Float[Array, '*batch n w1 k k']) –

    The lower-triangular Cholesky factor in compact band-layout.

Source code in src/furax/linalg/cholesky.py
@symmetric
class BandedCholeskyOperator(AbstractLinearOperator):
    """Inverse of a symmetric positive-(semi)definite matrix, by Cholesky solve.

    This operator can be used with an ordinary dense matrix ([`from_dense`][]), or from
    a banded matrix (zero away from the diagonal, [`from_bands`][]) and so is stored
    compactly instead of as the full matrix.

    Calling ``A(b)`` solves ``A x = b``.

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.linalg import BandedCholeskyOperator
        >>> A = jnp.array([[4., 2.], [2., 3.]])
        >>> op = BandedCholeskyOperator.from_dense(A)
        >>> op(jnp.array([1., 0.]))  # solves A x = [1, 0]
        Array([ 0.375, -0.25 ], dtype=float32)
    """

    lb: Float[Array, '*batch n w1 k k']
    """The lower-triangular Cholesky factor in compact band-layout."""

    @classmethod
    def from_bands(
        cls,
        bands: Float[Array, '*batch n w1 k k'],
        in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
        regularization: float = 0.0,
    ) -> Self:
        """Factor a block-banded matrix (see [`banded_cholesky`][]) and wrap it as an operator.

        Args:
            bands: Upper-band representation of the block-banded matrix, shape
                ``(*batch, n_blocks, w+1, k, k)``.
            in_structure: Structure of the values the operator is called with.
                Defaults to a plain array of shape ``(*batch, n_blocks, k)``.
                Pass a PyTree explicitly to solve for several arrays at once instead.
            regularization: Relative ridge added to each diagonal block before factoring.
        """
        if in_structure is None:
            n, k = bands.shape[-4], bands.shape[-1]
            in_structure = jax.ShapeDtypeStruct((*bands.shape[:-4], n, k), bands.dtype)
        return cls(banded_cholesky(bands, regularization), in_structure=in_structure)

    @classmethod
    def from_dense(
        cls,
        matrix: Float[Array, '*batch k k'],
        in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
        regularization: float = 0.0,
    ) -> Self:
        """Factor a fully dense matrix (the degenerate ``n_blocks=1, w=0`` band case).

        Args:
            matrix: The dense matrix, shape ``(*batch, k, k)``.
            in_structure: Structure of the values the operator is called with.
                Defaults to a plain array of shape ``(*batch, k)``.
                Pass a PyTree explicitly to solve for several arrays at once instead.
            regularization: Relative ridge added to the diagonal before factoring.
        """
        if in_structure is None:
            in_structure = jax.ShapeDtypeStruct(matrix.shape[:-1], matrix.dtype)
        return cls.from_bands(matrix[..., None, None, :, :], in_structure, regularization)

    def mv(self, x: PyTree[Array]) -> PyTree[Array]:
        batch_ndim = self.lb.ndim - 4
        n_blocks, k = self.lb.shape[-4], self.lb.shape[-1]
        leaves, treedef = jax.tree.flatten(x)
        # Flatten every leaf's non-batch axes and concatenate them into one length-K vector per
        # batch element, matching how `lb` was built from `bands`/`matrix` (K = n_blocks * k).
        flats = [leaf.reshape(*leaf.shape[:batch_ndim], -1) for leaf in leaves]
        xf = jnp.concatenate(flats, axis=-1)  # (*batch, K)
        xb = xf.reshape(*xf.shape[:-1], n_blocks, k)  # split K back into the (n_blocks, k) layout
        yb = banded_cholesky_solve(self.lb, xb)
        yf = yb.reshape(*yb.shape[:-2], -1)  # (*batch, K)
        # Undo the flatten/concatenate: split the solution back into per-leaf chunks (in the same
        # order they were concatenated) and reshape each to its original leaf shape.
        sizes = [prod(leaf.shape[batch_ndim:]) for leaf in leaves]
        chunks = jnp.split(yf, np.cumsum(sizes)[:-1], axis=-1)
        out = [c.reshape(leaf.shape) for c, leaf in zip(chunks, leaves, strict=True)]
        return treedef.unflatten(out)  # type: ignore[attr-defined]

lb instance-attribute

The lower-triangular Cholesky factor in compact band-layout.

from_bands(bands, in_structure=None, regularization=0.0) classmethod

Factor a block-banded matrix (see banded_cholesky) and wrap it as an operator.

Parameters:

  • bands (Float[Array, '*batch n w1 k k']) –

    Upper-band representation of the block-banded matrix, shape (*batch, n_blocks, w+1, k, k).

  • in_structure (PyTree[ShapeDtypeStruct] | None, default: None ) –

    Structure of the values the operator is called with. Defaults to a plain array of shape (*batch, n_blocks, k). Pass a PyTree explicitly to solve for several arrays at once instead.

  • regularization (float, default: 0.0 ) –

    Relative ridge added to each diagonal block before factoring.

Source code in src/furax/linalg/cholesky.py
@classmethod
def from_bands(
    cls,
    bands: Float[Array, '*batch n w1 k k'],
    in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
    regularization: float = 0.0,
) -> Self:
    """Factor a block-banded matrix (see [`banded_cholesky`][]) and wrap it as an operator.

    Args:
        bands: Upper-band representation of the block-banded matrix, shape
            ``(*batch, n_blocks, w+1, k, k)``.
        in_structure: Structure of the values the operator is called with.
            Defaults to a plain array of shape ``(*batch, n_blocks, k)``.
            Pass a PyTree explicitly to solve for several arrays at once instead.
        regularization: Relative ridge added to each diagonal block before factoring.
    """
    if in_structure is None:
        n, k = bands.shape[-4], bands.shape[-1]
        in_structure = jax.ShapeDtypeStruct((*bands.shape[:-4], n, k), bands.dtype)
    return cls(banded_cholesky(bands, regularization), in_structure=in_structure)

from_dense(matrix, in_structure=None, regularization=0.0) classmethod

Factor a fully dense matrix (the degenerate n_blocks=1, w=0 band case).

Parameters:

  • matrix (Float[Array, '*batch k k']) –

    The dense matrix, shape (*batch, k, k).

  • in_structure (PyTree[ShapeDtypeStruct] | None, default: None ) –

    Structure of the values the operator is called with. Defaults to a plain array of shape (*batch, k). Pass a PyTree explicitly to solve for several arrays at once instead.

  • regularization (float, default: 0.0 ) –

    Relative ridge added to the diagonal before factoring.

Source code in src/furax/linalg/cholesky.py
@classmethod
def from_dense(
    cls,
    matrix: Float[Array, '*batch k k'],
    in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
    regularization: float = 0.0,
) -> Self:
    """Factor a fully dense matrix (the degenerate ``n_blocks=1, w=0`` band case).

    Args:
        matrix: The dense matrix, shape ``(*batch, k, k)``.
        in_structure: Structure of the values the operator is called with.
            Defaults to a plain array of shape ``(*batch, k)``.
            Pass a PyTree explicitly to solve for several arrays at once instead.
        regularization: Relative ridge added to the diagonal before factoring.
    """
    if in_structure is None:
        in_structure = jax.ShapeDtypeStruct(matrix.shape[:-1], matrix.dtype)
    return cls.from_bands(matrix[..., None, None, :, :], in_structure, regularization)

mv(x)

Source code in src/furax/linalg/cholesky.py
def mv(self, x: PyTree[Array]) -> PyTree[Array]:
    batch_ndim = self.lb.ndim - 4
    n_blocks, k = self.lb.shape[-4], self.lb.shape[-1]
    leaves, treedef = jax.tree.flatten(x)
    # Flatten every leaf's non-batch axes and concatenate them into one length-K vector per
    # batch element, matching how `lb` was built from `bands`/`matrix` (K = n_blocks * k).
    flats = [leaf.reshape(*leaf.shape[:batch_ndim], -1) for leaf in leaves]
    xf = jnp.concatenate(flats, axis=-1)  # (*batch, K)
    xb = xf.reshape(*xf.shape[:-1], n_blocks, k)  # split K back into the (n_blocks, k) layout
    yb = banded_cholesky_solve(self.lb, xb)
    yf = yb.reshape(*yb.shape[:-2], -1)  # (*batch, K)
    # Undo the flatten/concatenate: split the solution back into per-leaf chunks (in the same
    # order they were concatenated) and reshape each to its original leaf shape.
    sizes = [prod(leaf.shape[batch_ndim:]) for leaf in leaves]
    chunks = jnp.split(yf, np.cumsum(sizes)[:-1], axis=-1)
    out = [c.reshape(leaf.shape) for c, leaf in zip(chunks, leaves, strict=True)]
    return treedef.unflatten(out)  # type: ignore[attr-defined]

banded_cholesky(bands, regularization=0.0)

Block Cholesky factor of a symmetric positive-(semi)definite block-banded matrix.

"Block-banded" means A is made of k×k blocks, and only the blocks within w of the diagonal can be non-zero. E.g., for n_blocks = 4 and bandwidth w = 1:

A = [ A00  A01   0    0  ]
    [ A01ᵀ A11  A12   0  ]
    [  0   A12ᵀ A22  A23 ]
    [  0    0   A23ᵀ A33 ]

Only the diagonal and upper blocks are stored (symmetry gives the rest) packed as bands[..., j, d, :, :] = A[j, j+d] for d = 0..w (d=0 the diagonal blocks, d=w the furthest off-diagonal ones still inside the band)::

bands[:, 0] = [A00, A11, A22, A33]  (d=0: the diagonal blocks)
bands[:, 1] = [A01, A12, A23,  · ]  (d=1: one block off the diagonal; the trailing
                                       entry is unused padding, since block 3 has no
                                       block 4 to pair with)

Returns the lower factor in the same band layout, lb[..., i, d, :, :] = L[i, i-d] with L Lᵀ = A. Batched over arbitrary leading dims. Band w = 0 is the block-diagonal case (every off-diagonal block is zero); n_blocks = 1 is the fully dense case.

regularization adds a relative ridge to each diagonal block before factoring, scaled by that block's own mean diagonal. This is purely intended as a numerical safeguard.

Parameters:

  • bands (Float[Array, '*batch n w1 k k']) –

    Upper-band representation of the block-banded matrix, shape (*batch, n_blocks, w+1, k, k).

  • regularization (float, default: 0.0 ) –

    Relative ridge added to each diagonal block before factoring.

Returns:

  • Float[Array, '*batch n w1 k k']

    The lower band factor, same shape as bands.

Examples:

>>> import jax.numpy as jnp
>>> from furax.linalg import banded_cholesky
>>> # tridiagonal SPD A = [[4,1,0,0],[1,4,1,0],[0,1,4,1],[0,0,1,4]], scalar (k=1) blocks
>>> diag = jnp.array([4., 4., 4., 4.]).reshape(4, 1, 1, 1)
>>> off = jnp.array([1., 1., 1., 0.]).reshape(4, 1, 1, 1)  # trailing entry is padding
>>> bands = jnp.concatenate([diag, off], axis=1)  # (n_blocks=4, w1=2, k=1, k=1)
>>> lb = banded_cholesky(bands)
>>> lb[:, :, 0, 0]  # columns are (L[i,i], L[i,i-1])
Array([[2.        , 0.        ],
       [1.9364916 , 0.5       ],
       [1.9321835 , 0.5163978 ],
       [1.9318755 , 0.51754916]], dtype=float32)
Source code in src/furax/linalg/cholesky.py
def banded_cholesky(
    bands: Float[Array, '*batch n w1 k k'], regularization: float = 0.0
) -> Float[Array, '*batch n w1 k k']:
    """Block Cholesky factor of a symmetric positive-(semi)definite block-banded matrix.

    "Block-banded" means ``A`` is made of ``k×k`` blocks, and only the blocks within ``w``
    of the diagonal can be non-zero. E.g., for ``n_blocks = 4`` and bandwidth ``w = 1``:

        A = [ A00  A01   0    0  ]
            [ A01ᵀ A11  A12   0  ]
            [  0   A12ᵀ A22  A23 ]
            [  0    0   A23ᵀ A33 ]

    Only the diagonal and upper blocks are stored (symmetry gives the rest) packed as
    ``bands[..., j, d, :, :] = A[j, j+d]`` for ``d = 0..w`` (``d=0`` the diagonal blocks,
    ``d=w`` the furthest off-diagonal ones still inside the band)::

        bands[:, 0] = [A00, A11, A22, A33]  (d=0: the diagonal blocks)
        bands[:, 1] = [A01, A12, A23,  · ]  (d=1: one block off the diagonal; the trailing
                                               entry is unused padding, since block 3 has no
                                               block 4 to pair with)

    Returns the lower factor in the same band layout, ``lb[..., i, d, :, :] = L[i, i-d]`` with
    ``L Lᵀ = A``. Batched over arbitrary leading dims. Band ``w = 0`` is the block-diagonal case
    (every off-diagonal block is zero); ``n_blocks = 1`` is the fully dense case.

    ``regularization`` adds a relative ridge to each diagonal block before factoring, scaled by
    that block's own mean diagonal. This is purely intended as a numerical safeguard.

    Args:
        bands: Upper-band representation of the block-banded matrix, shape
            ``(*batch, n_blocks, w+1, k, k)``.
        regularization: Relative ridge added to each diagonal block before factoring.

    Returns:
        The lower band factor, same shape as ``bands``.

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.linalg import banded_cholesky
        >>> # tridiagonal SPD A = [[4,1,0,0],[1,4,1,0],[0,1,4,1],[0,0,1,4]], scalar (k=1) blocks
        >>> diag = jnp.array([4., 4., 4., 4.]).reshape(4, 1, 1, 1)
        >>> off = jnp.array([1., 1., 1., 0.]).reshape(4, 1, 1, 1)  # trailing entry is padding
        >>> bands = jnp.concatenate([diag, off], axis=1)  # (n_blocks=4, w1=2, k=1, k=1)
        >>> lb = banded_cholesky(bands)
        >>> lb[:, :, 0, 0]  # columns are (L[i,i], L[i,i-1])
        Array([[2.        , 0.        ],
               [1.9364916 , 0.5       ],
               [1.9321835 , 0.5163978 ],
               [1.9318755 , 0.51754916]], dtype=float32)
    """
    if regularization:
        k = bands.shape[-1]
        diag = jnp.diagonal(bands[..., 0, :, :], axis1=-2, axis2=-1)  # (*batch, n, k)
        scale = jnp.mean(diag, axis=-1)[..., None, None]  # (*batch, n, 1, 1)
        ridge = regularization * scale * jnp.eye(k, dtype=bands.dtype)
        bands = bands.at[..., 0, :, :].add(ridge)
    return _block_banded_cholesky(bands)  # type: ignore[no-any-return]

banded_cholesky_solve(lb, b)

Solve L Lᵀ x = b given the lower band factor lb from banded_cholesky.

Batched over arbitrary leading dims (matching lb's, excluding its trailing (n, w1, k, k) core).

Examples:

>>> import jax.numpy as jnp
>>> from furax.linalg import banded_cholesky, banded_cholesky_solve
>>> a = jnp.array([[4., 2.], [2., 3.]])
>>> lb = banded_cholesky(a[None, None, None])
>>> x = banded_cholesky_solve(lb, jnp.array([[[1., 0.]]]))
>>> a @ x[0, 0]
Array([1., 0.], dtype=float32)
Source code in src/furax/linalg/cholesky.py
@partial(jnp.vectorize, signature='(n,w1,k,k),(n,k)->(n,k)')
def banded_cholesky_solve(
    lb: Float[Array, '*batch n w1 k k'], b: Float[Array, '*batch n k']
) -> Float[Array, '*batch n k']:
    """Solve ``L Lᵀ x = b`` given the lower band factor ``lb`` from [`banded_cholesky`][].

    Batched over arbitrary leading dims (matching ``lb``'s, excluding its trailing
    ``(n, w1, k, k)`` core).

    Examples:
        >>> import jax.numpy as jnp
        >>> from furax.linalg import banded_cholesky, banded_cholesky_solve
        >>> a = jnp.array([[4., 2.], [2., 3.]])
        >>> lb = banded_cholesky(a[None, None, None])
        >>> x = banded_cholesky_solve(lb, jnp.array([[[1., 0.]]]))
        >>> a @ x[0, 0]
        Array([1., 0.], dtype=float32)
    """
    n, w1, k, _ = lb.shape
    w = w1 - 1

    def read(arr: Array, idx: Array):  # type: ignore[no-untyped-def]
        return jax.lax.dynamic_index_in_dim(arr, jnp.clip(idx, 0, n - 1), axis=0, keepdims=False)

    def fwd(i: Array, y: Array):  # type: ignore[no-untyped-def]  # forward: L y = b
        rhs = read(b, i)
        lb_i = read(lb, i)
        for d in range(1, w + 1):
            rhs = rhs - jnp.where(i - d >= 0, lb_i[d] @ read(y, i - d), 0.0)
        yi = jax.scipy.linalg.solve_triangular(lb_i[0], rhs, lower=True)
        return jax.lax.dynamic_update_index_in_dim(y, yi, i, axis=0)

    y = jax.lax.fori_loop(0, n, fwd, jnp.zeros((n, k), b.dtype))

    def bwd(step: Array, x: Array):  # type: ignore[no-untyped-def]  # backward: Lᵀ x = y
        i = n - 1 - step
        rhs = read(y, i)
        for d in range(1, w + 1):  # L[i+d, i] = lb[i+d, d]
            rhs = rhs - jnp.where(
                i + d <= n - 1, jnp.swapaxes(read(lb, i + d)[d], -1, -2) @ read(x, i + d), 0.0
            )
        xi = jax.scipy.linalg.solve_triangular(read(lb, i)[0].T, rhs, lower=False)
        return jax.lax.dynamic_update_index_in_dim(x, xi, i, axis=0)

    solution: Array = jax.lax.fori_loop(0, n, bwd, jnp.zeros((n, k), b.dtype))
    return solution

furax.linalg.low_rank

Low-rank approximation for PyTree-aware linear operators.

Classes:

  • LowRankTerms

    Low-rank approximation of a Hermitian operator: A ≈ U @ diag(S) @ U^T.

  • LowRankOperator

    Linear operator from low-rank terms: A = U @ diag(S) @ U^T.

Functions:

  • low_rank

    Compute a low-rank approximation of a Hermitian operator.

  • low_rank_mv

    Apply low-rank approximation as a matrix-vector product.

Attributes:

LowRankMethod = Literal['lanczos', 'lanczos_tr'] module-attribute

LowRankTerms

Bases: NamedTuple

Low-rank approximation of a Hermitian operator: A ≈ U @ diag(S) @ U^T.

Attributes:

  • eigenvalues (Float[Array, ' k']) –

    The k eigenvalues (S), shape (k,).

  • eigenvectors (PyTree[Num[Array, ' k ...']]) –

    The k eigenvectors (U) as a block PyTree with leading dimension k.

Source code in src/furax/linalg/low_rank.py
class LowRankTerms(NamedTuple):
    """Low-rank approximation of a Hermitian operator: A ≈ U @ diag(S) @ U^T.

    Attributes:
        eigenvalues: The k eigenvalues (S), shape (k,).
        eigenvectors: The k eigenvectors (U) as a block PyTree with leading dimension k.
    """

    eigenvalues: Float[Array, ' k']
    eigenvectors: PyTree[Num[Array, ' k ...']]

eigenvalues instance-attribute

eigenvectors instance-attribute

LowRankOperator

Bases: AbstractLinearOperator

Linear operator from low-rank terms: A = U @ diag(S) @ U^T.

This wraps a LowRankTerms as an AbstractLinearOperator, enabling composition with other operators.

Parameters:

  • terms (LowRankTerms) –

    Low-rank terms containing eigenvalues and eigenvectors.

  • in_structure (PyTree[ShapeDtypeStruct] | None, default: None ) –

    The expected structure of the operator input. If None, inferred from the eigenvectors structure.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> terms = low_rank(A, rank=2, key=jax.random.PRNGKey(0))
>>> B = LowRankOperator(terms)
>>> x = jnp.ones(5)
>>> y = B(x)  # Applies low-rank approximation

Methods:

Attributes:

Source code in src/furax/linalg/low_rank.py
@symmetric
class LowRankOperator(AbstractLinearOperator):
    """Linear operator from low-rank terms: A = U @ diag(S) @ U^T.

    This wraps a LowRankTerms as an AbstractLinearOperator, enabling
    composition with other operators.

    Args:
        terms: Low-rank terms containing eigenvalues and eigenvectors.
        in_structure: The expected structure of the operator input. If None,
            inferred from the eigenvectors structure.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> terms = low_rank(A, rank=2, key=jax.random.PRNGKey(0))
        >>> B = LowRankOperator(terms)
        >>> x = jnp.ones(5)
        >>> y = B(x)  # Applies low-rank approximation
    """

    terms: LowRankTerms

    def __init__(
        self,
        terms: LowRankTerms,
        *,
        in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
    ):
        if in_structure is None:
            # Infer structure from eigenvectors by removing leading dimension
            in_structure = jax.tree.map(
                lambda leaf: jax.ShapeDtypeStruct(leaf.shape[1:], leaf.dtype),
                terms.eigenvectors,
            )
        object.__setattr__(self, 'terms', terms)
        object.__setattr__(self, 'in_structure', in_structure)

    def mv(self, x: PyTree[Num[Array, '...']]) -> PyTree[Num[Array, '...']]:
        return low_rank_mv(self.terms, x)

terms instance-attribute

__init__(terms, *, in_structure=None)

Source code in src/furax/linalg/low_rank.py
def __init__(
    self,
    terms: LowRankTerms,
    *,
    in_structure: PyTree[jax.ShapeDtypeStruct] | None = None,
):
    if in_structure is None:
        # Infer structure from eigenvectors by removing leading dimension
        in_structure = jax.tree.map(
            lambda leaf: jax.ShapeDtypeStruct(leaf.shape[1:], leaf.dtype),
            terms.eigenvectors,
        )
    object.__setattr__(self, 'terms', terms)
    object.__setattr__(self, 'in_structure', in_structure)

mv(x)

Source code in src/furax/linalg/low_rank.py
def mv(self, x: PyTree[Num[Array, '...']]) -> PyTree[Num[Array, '...']]:
    return low_rank_mv(self.terms, x)

low_rank(A, rank, key, *, method='lanczos_tr', **kwargs)

Compute a low-rank approximation of a Hermitian operator.

The approximation is of the form A ≈ U @ diag(S) @ U^T, where U contains the k eigenvectors and S contains the corresponding eigenvalues.

Two solvers are available via method:

  • 'lanczos': single-shot m-step Lanczos. Builds one Krylov subspace and returns the k best-converged Ritz pairs by residual norm. Extra kwargs: m.
  • 'lanczos_tr' (default): thick-restart Lanczos. Restarts until convergence and targets specific eigenpairs via which ('LM', 'SM', 'LA', 'SA', 'BE'; default 'LM'). Extra kwargs: m, which, max_restarts, tol.

Parameters:

  • A (AbstractLinearOperator) –

    A Hermitian linear operator.

  • rank (int) –

    Number of eigenvalues/eigenvectors to compute.

  • key (PRNGKeyArray) –

    Random key for initialization of the eigenvalue solver.

  • method (LowRankMethod, default: 'lanczos_tr' ) –

    Eigenvalue solver to use (see above). Defaults to 'lanczos_tr'.

  • **kwargs (Any, default: {} ) –

    Additional keyword arguments passed to the selected solver.

Returns:

  • LowRankTerms

    LowRankTerms containing eigenvalues and eigenvectors.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> terms = low_rank(A, rank=2, key=jax.random.PRNGKey(0), method='lanczos_tr')
>>> terms.eigenvalues  # Should be approximately [4, 5] (which='LM' by default)
Array([4., 5.], dtype=float32)
Source code in src/furax/linalg/low_rank.py
def low_rank(
    A: AbstractLinearOperator,
    rank: int,
    key: PRNGKeyArray,
    *,
    method: LowRankMethod = 'lanczos_tr',
    **kwargs: Any,
) -> LowRankTerms:
    """Compute a low-rank approximation of a Hermitian operator.

    The approximation is of the form A ≈ U @ diag(S) @ U^T, where U contains
    the k eigenvectors and S contains the corresponding eigenvalues.

    Two solvers are available via ``method``:

    - 'lanczos': single-shot m-step Lanczos. Builds one Krylov subspace and returns the k
      best-converged Ritz pairs by residual norm.
      Extra kwargs: ``m``.
    - 'lanczos_tr' (default): thick-restart Lanczos. Restarts until convergence and targets
      specific eigenpairs via ``which`` ('LM', 'SM', 'LA', 'SA', 'BE'; default 'LM').
      Extra kwargs: ``m``, ``which``, ``max_restarts``, ``tol``.

    Args:
        A: A Hermitian linear operator.
        rank: Number of eigenvalues/eigenvectors to compute.
        key: Random key for initialization of the eigenvalue solver.
        method: Eigenvalue solver to use (see above). Defaults to 'lanczos_tr'.
        **kwargs: Additional keyword arguments passed to the selected solver.

    Returns:
        LowRankTerms containing eigenvalues and eigenvectors.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> terms = low_rank(A, rank=2, key=jax.random.PRNGKey(0), method='lanczos_tr')
        >>> terms.eigenvalues  # Should be approximately [4, 5] (which='LM' by default)
        Array([4., 5.], dtype=float32)
    """
    solvers = {'lanczos': lanczos_eigh, 'lanczos_tr': lanczos_tr}
    if method not in solvers:
        raise ValueError(f'Unknown method: {method!r}. Use one of {get_args(LowRankMethod)}.')
    v0 = normal_like(A.in_structure, key)
    result = solvers[method](A, v0, k=rank, **kwargs)
    return LowRankTerms(
        eigenvalues=result.eigenvalues,
        eigenvectors=result.eigenvectors,
    )

low_rank_mv(terms, x)

Apply low-rank approximation as a matrix-vector product.

Computes y = U @ diag(S) @ U^T @ x efficiently without forming the full matrix.

Parameters:

  • terms (LowRankTerms) –

    Low-rank terms containing eigenvalues and eigenvectors.

  • x (PyTree[Num[Array, ...]]) –

    Input PyTree with structure matching the eigenvectors (without leading k dimension).

Returns:

  • PyTree[Num[Array, ...]]

    Output PyTree y = U @ diag(S) @ U^T @ x.

Examples:

>>> import jax
>>> import jax.numpy as jnp
>>> from furax import DiagonalOperator
>>> from furax.tree import as_structure
>>> d = jnp.array([1., 2., 3., 4., 5.])
>>> A = DiagonalOperator(d, in_structure=as_structure(d))
>>> terms = low_rank(A, rank=5, key=jax.random.PRNGKey(0))  # Full rank
>>> x = jnp.array([1., 0., 0., 0., 0.])
>>> y = low_rank_mv(terms, x)
>>> # Should be close to A @ x = [1, 0, 0, 0, 0]
Source code in src/furax/linalg/low_rank.py
def low_rank_mv(terms: LowRankTerms, x: PyTree[Num[Array, '...']]) -> PyTree[Num[Array, '...']]:
    """Apply low-rank approximation as a matrix-vector product.

    Computes y = U @ diag(S) @ U^T @ x efficiently without forming the full matrix.

    Args:
        terms: Low-rank terms containing eigenvalues and eigenvectors.
        x: Input PyTree with structure matching the eigenvectors (without leading k dimension).

    Returns:
        Output PyTree y = U @ diag(S) @ U^T @ x.

    Examples:
        >>> import jax
        >>> import jax.numpy as jnp
        >>> from furax import DiagonalOperator
        >>> from furax.tree import as_structure
        >>> d = jnp.array([1., 2., 3., 4., 5.])
        >>> A = DiagonalOperator(d, in_structure=as_structure(d))
        >>> terms = low_rank(A, rank=5, key=jax.random.PRNGKey(0))  # Full rank
        >>> x = jnp.array([1., 0., 0., 0., 0.])
        >>> y = low_rank_mv(terms, x)
        >>> # Should be close to A @ x = [1, 0, 0, 0, 0]
    """
    U = terms.eigenvectors
    coeffs = jax.vmap(dot, (0, None), 0)(U, x)  # U^T x
    scaled = terms.eigenvalues * coeffs  # S (U^T x)
    return jax.tree.map(lambda a: jnp.einsum('k,k...->...', scaled, a), U)  # U (S U^T x)