Source code for fips.linalg.forms

"""
The base class of the forms, and the dense, sparse and diagonal forms.

:class:`LinearOperator` is the base of every form. It is in one module with
:class:`DenseOperator`, :class:`SparseOperator` and :class:`DiagonalOperator`
because it builds them (a default method returns a dense form, a product of
sparse matrices a sparse one) while each of them is a subclass of it. The forms
made of other forms are in :mod:`fips.linalg.block` and
:mod:`fips.linalg.kronecker`. What a form can do is in :mod:`fips.linalg`.
"""

import logging
import os
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from functools import cached_property
from typing import Self, TypeAlias

import numpy as np
import numpy.typing as npt
import scipy.sparse as sp
from numpy.linalg import LinAlgError
from scipy.linalg import cholesky, eigh, solve
from scipy.linalg.lapack import get_lapack_funcs
from scipy.sparse.csgraph import connected_components
from scipy.sparse.linalg import LinearOperator as _SciPyLinearOperator
from scipy.sparse.linalg import splu

logger = logging.getLogger(__name__)

LinearOperatorLike: TypeAlias = (
    "LinearOperator | sp.spmatrix | sp.sparray | npt.ArrayLike"
)
"""What :func:`as_linear_operator` takes: a form, a SciPy sparse matrix or a 2D array."""

_DENSE_FILL = 0.25
"""
The share of stored entries above which a product of sparse operators is dense.

A product of sparse matrices can fill in (``H S_0 H^T`` for a sparse ``H``
usually does). Past about a quarter of its entries, a dense array is faster to
add and to factor than CSR, and not much bigger.
"""


def _solve_psd(A: np.ndarray, B: np.ndarray) -> np.ndarray:
    r"""
    Solve ``A X = B`` for a symmetric positive-definite ``A``, robust to roundoff.

    Covariance-like matrices such as ``A = H S_0 H^T + S_z`` are positive-definite
    in exact arithmetic (``S_z`` is positive-definite), so the fast Cholesky path
    (``assume_a="pos"``) is tried first. When ``A`` is ill-conditioned -- a wide
    eigenvalue spread, with ``H S_0 H^T`` large relative to ``S_z`` -- roundoff in
    forming ``A`` can tip it just off positive-definite, and Cholesky raises
    :class:`~numpy.linalg.LinAlgError`. In that case we symmetrize ``A`` and fall
    back to the symmetric-indefinite ``LDL^T`` factorization (``assume_a="sym"``),
    which tolerates the roundoff and stays backward-stable.

    Parameters
    ----------
    A : np.ndarray
        Symmetric matrix, positive-definite in exact arithmetic.
    B : np.ndarray
        Right-hand side.

    Returns
    -------
    np.ndarray
        Solution ``X`` of ``A X = B``.
    """
    try:
        return solve(A, B, assume_a="pos")
    except LinAlgError:
        logger.warning(
            "Covariance matrix is numerically non-positive-definite (likely "
            "roundoff from a dominant H S_0 H^T term); falling back to a symmetric "
            "LDL^T solve. Inspect problem conditioning if results look unphysical."
        )
        A = 0.5 * (A + A.T)
        return solve(A, B, assume_a="sym")


def _as_array(B) -> np.ndarray:
    """Return `B` (an array, a SciPy sparse matrix or a form) as a dense array."""
    if isinstance(B, LinearOperator):
        return B.dense()
    if sp.issparse(B):
        return B.toarray()
    return np.asarray(B, dtype=float)


def _cholesky(A: np.ndarray) -> np.ndarray:
    """Return the lower Cholesky factor of `A`, raising LinAlgError naming why if it is not positive-definite."""
    try:
        return np.asarray(cholesky(A, lower=True))
    except LinAlgError as err:
        raise LinAlgError(
            f"The matrix is not positive-definite, so it has no Cholesky factor ({err})."
        ) from err


def _clip_eigenvalues(w: np.ndarray) -> tuple[np.ndarray, bool]:
    """
    Return the eigenvalues `w` of a covariance with the negative ones set to zero, and whether any was.

    An eigenvalue of a positive semi-definite matrix that comes out negative
    by roundoff is within ``len(w) * eps * max|w|`` of zero (the size LAPACK
    works to); a more negative one means the matrix is not a covariance.

    Raises
    ------
    numpy.linalg.LinAlgError
        If an eigenvalue is more negative than that.
    """
    tol = len(w) * np.finfo(float).eps * np.abs(w).max(initial=0.0)
    if w.min(initial=0.0) < -tol:
        raise LinAlgError(
            f"The matrix is not positive semi-definite, so it is not a covariance: "
            f"its smallest eigenvalue is {w.min():.3g}, more than the roundoff "
            f"{tol:.3g} below zero."
        )
    return np.maximum(w, 0.0), bool((w < 0).any())


def _dense_root(A: np.ndarray) -> tuple[np.ndarray, bool]:
    """
    Return a square `R` with ``R R^T = A`` for a symmetric positive semi-definite `A`, and whether it is semi-definite.

    The lower Cholesky factor when `A` has one. A semi-definite `A` (a
    ``Constant()`` block is rank one) has none, so `R` is then
    ``V sqrt(w)`` from its eigendecomposition ``A = V diag(w) V^T``, with the
    eigenvalues that are negative by roundoff set to zero, and the second
    value is True. `linalg` cannot import `methods`, so this calls SciPy's
    ``eigh`` itself, as the analytic method does.

    Raises
    ------
    numpy.linalg.LinAlgError
        If `A` has an eigenvalue that is clearly negative.
    """
    try:
        return np.asarray(cholesky(A, lower=True)), False
    except LinAlgError:
        w, V = eigh(A)
        w, _ = _clip_eigenvalues(np.asarray(w))
        return np.asarray(V) * np.sqrt(w), True


def _read_only(array: np.ndarray) -> np.ndarray:
    """Return `array`, made read-only (a view of a read-only array already is)."""
    array.flags.writeable = False
    return array


_SYMMETRY_RTOL = 1e-10
"""How far from symmetric a covariance may be: ``max|A - A^T| <= 1e-10 * max|A|``."""

_SYMMETRY_CHUNK = 2**22
"""How many entries of a dense matrix the symmetry check reads at once (32 MB of float64)."""


def _entry(A: sp.csr_array, i: int, j: int) -> float:
    """Return entry ``(i, j)`` of a CSR array, read from its arrays (duplicates summed)."""
    row = slice(A.indptr[i], A.indptr[i + 1])
    return float(A.data[row][A.indices[row] == j].sum())


def _asymmetry(matrix) -> tuple[int, int, float, float, float] | None:
    """
    Return the largest asymmetry of a square array or SciPy sparse matrix, if it is past the tolerance.

    Returns ``(i, j, A[i, j], A[j, i], max|A|)`` at the largest
    ``|A[i, j] - A[j, i]|`` when that is more than ``_SYMMETRY_RTOL`` times
    ``max|A|``, else None. A dense matrix is read in blocks of rows and a
    sparse one is subtracted as sparse, so neither is copied whole.
    """
    if sp.issparse(matrix):
        A = sp.csr_array(matrix)
        if A.nnz == 0:
            return None
        scale = float(np.abs(A.data).max())
        D = sp.coo_array(A - A.T)
        if D.nnz == 0:
            return None
        k = int(np.argmax(np.abs(D.data)))
        if abs(D.data[k]) <= _SYMMETRY_RTOL * scale:
            return None
        i, j = int(D.row[k]), int(D.col[k])
        return i, j, _entry(A, i, j), _entry(A, j, i), scale
    A = np.asarray(matrix)
    n = len(A)
    step = max(1, _SYMMETRY_CHUNK // max(n, 1))
    worst, scale, at = 0.0, 0.0, (0, 0)
    for start in range(0, n, step):
        rows = A[start : start + step]
        scale = max(scale, float(np.abs(rows).max()))
        difference = np.abs(rows - A[:, start : start + step].T)
        k = int(np.argmax(difference))
        if difference.flat[k] > worst:
            worst = float(difference.flat[k])
            i, j = np.unravel_index(k, difference.shape)
            at = (start + int(i), int(j))
    if worst <= _SYMMETRY_RTOL * scale:
        return None
    i, j = at
    return i, j, float(A[i, j]), float(A[j, i]), scale


def _sparse_array(matrix, copy: bool) -> "sp.csr_array | sp.csc_array":
    """Return a SciPy sparse matrix as a float64 CSR array (CSC for a CSC input), copied if `copy`."""
    kind = sp.csc_array if matrix.format == "csc" else sp.csr_array
    return kind(matrix, dtype=float, copy=copy)


_THREADED_WORK = 10_000_000
"""
The multiply-adds in a sparse-times-dense product above which it runs on threads.

SciPy's takes about a nanosecond for each, so this is about 10 ms: below it a
pool of threads costs more than it saves.
"""


def _threads() -> int:
    """
    Return how many threads a product may use.

    The CPUs this process may run on, so a Slurm job's and not the node's, and
    no more than ``OMP_NUM_THREADS`` when that is set to a number, as for NumPy's
    BLAS: a run that asks for one thread gets one.
    """
    try:
        cpus = len(os.sched_getaffinity(0))
    except AttributeError:  # macOS and Windows have no affinity
        cpus = os.cpu_count() or 1
    asked = os.environ.get("OMP_NUM_THREADS", "")
    return max(1, min(cpus, int(asked))) if asked.isdigit() else cpus


def _csr_times(matrix: "sp.csr_array", X: np.ndarray) -> np.ndarray:
    """
    Return ``matrix @ X`` for a CSR array and a 2D float64 array, over threads when it is large.

    SciPy multiplies a sparse matrix by a dense one on a single thread, row by
    row, and releases the GIL while it does. Here the rows of `matrix` go in
    blocks, one block to a thread, each written to its rows of the result. Row
    ``i`` of the product is a sum over the stored entries of row ``i``, taken in
    the same order whichever rows share the call, so the result is SciPy's bit
    for bit. A small product, one thread, or an array that is not 2D float64 is
    SciPy's own call.
    """
    if type(X) is not np.ndarray or X.ndim != 2 or X.dtype != np.float64:
        return matrix @ X
    threads = _threads()
    if threads == 1 or matrix.nnz * X.shape[1] < _THREADED_WORK:
        return matrix @ X
    X = np.ascontiguousarray(X)  # once for every block, not in each call
    # the rows from the index pointer: pyrefly reads SciPy's `shape` as possibly None
    rows = matrix.indptr.size - 1
    out = np.empty((rows, X.shape[1]))
    # more blocks than threads, so that dense rows do not leave the others idle
    cuts = np.linspace(0, rows, min(4 * threads, rows) + 1).astype(int)

    def block(first: int, last: int) -> None:
        """Write rows `first` to `last` of the product."""
        out[first:last] = matrix[first:last] @ X

    with ThreadPoolExecutor(threads) as pool:
        list(pool.map(block, cuts[:-1], cuts[1:]))  # list() raises a block's error
    return out


[docs] class LinearOperator(_SciPyLinearOperator): """ A real matrix in one of fips's forms, for multiplying, adding, transposing and solving. The base class of :class:`DenseOperator`, :class:`SparseOperator`, :class:`DiagonalOperator`, :class:`BlockDiagonalOperator` and :class:`~fips.linalg.KroneckerOperator`: build one of those, or let :func:`as_linear_operator` choose. A form has no labels; an :class:`~fips.Operator` puts a :class:`~fips.Space` on each of its axes. A new form subclasses this one and implements ``_matmat`` and ``_transpose``. Every other method has a default that is right for any form, which the subclass overrides where its form has a cheaper way. A form that is neither dense nor sparse also gets a SciPy sparse matrix in ``_matmat``, from a product with a :class:`SparseOperator`. A covariance form with a root cheaper than the Cholesky factor of its dense matrix (the one a draw is made with) overrides ``_make_root``. A form that keeps something out of its pickle (a cache) overrides ``__getstate__`` starting from this class's, which leaves out SciPy's own attributes and the root a draw is made with. Parameters ---------- shape : tuple of int ``(rows, columns)``. See Also -------- as_linear_operator : The form that fits an array or a sparse matrix. fips.Operator : A form with a space on each axis. """ def __init__(self, shape: tuple[int, ...]): super().__init__(dtype=np.float64, shape=shape) def _freeze(self) -> None: """Make the arrays the form holds read-only; a form that holds none has nothing to do.""" # A pickle holds the form's attributes, not SciPy's array namespace: SciPy # 1.18 keeps that module on each operator, and its own pickle hooks (which # 1.17 does not have) cannot read a pickle written without it. Loading runs # SciPy's constructor again instead, so a pickle written on any SciPy # version loads on any other. def __getstate__(self) -> dict: """Return the state for pickling, without SciPy's array namespace (a module).""" state = self.__dict__.copy() state.pop("_xp", None) state.pop("_root", None) # a cache, built again by the next draw return state def __setstate__(self, state: dict) -> None: """Restore from a pickle, with SciPy's attributes set again and the arrays read-only again (a pickle keeps the values, not the flag).""" self.__dict__.update(state) LinearOperator.__init__(self, self.shape) self._freeze() # SciPy is not annotated, so pyrefly takes the type of each default it # infers (a private SciPy class) as the type an override must return. def _transpose(self) -> "LinearOperator": # pyrefly: ignore[bad-override] """Return the transpose; each form implements it.""" raise NotImplementedError def _matvec(self, x): """Return ``self @ x`` for a vector.""" return self._matmat(x.reshape(-1, 1)).reshape(-1) def _rmatvec(self, x): """Return ``self.T @ x`` for a vector.""" return self._transpose().matvec(x) def _rmatmat(self, X): """Return ``self.T @ X``.""" return self._transpose().matmat(X) def _rmatmul_array(self, X: np.ndarray) -> np.ndarray: """Return ``X @ self`` for a 1D or 2D array.""" if X.ndim == 1: return self._transpose().matvec(X) return self._transpose().matmat(X.T).T def _elementwise(self, func) -> "LinearOperator": """ Return the form with `func` applied to its entries. `func` multiplies by a scalar (``a * c``, ``a / c`` or ``-a``), so it maps 0 to 0 and a form can apply it to what it stores. """ return DenseOperator._adopt(func(self.dense())) def _select(self, rows: np.ndarray, cols: np.ndarray) -> "LinearOperator": """ Return the entries at `rows` and `cols` (integer positions), in that order. A position of -1 gives a row or column of zeros. A dense form gives a dense one, a sparse or diagonal form a sparse (or diagonal) one. """ rows, cols = np.asarray(rows, dtype=np.intp), np.asarray(cols, dtype=np.intp) r, c = np.maximum(rows, 0), np.maximum(cols, 0) csr = self._csr() if csr is None: out = self.dense()[np.ix_(r, c)] out[rows < 0] = 0.0 out[:, cols < 0] = 0.0 return DenseOperator._adopt(out) # SciPy is not annotated, so pyrefly finds no __getitem__ on a sparse array out = csr[r][:, c] # pyrefly: ignore[bad-index] if (rows < 0).any() or (cols < 0).any(): keep_r = sp.diags_array((rows >= 0).astype(float)) keep_c = sp.diags_array((cols >= 0).astype(float)) out = sp.csr_array(keep_r @ out @ keep_c) out.eliminate_zeros() return _adopt(out) def _csr(self) -> "sp.csr_array | None": """Return the form as a CSR array, or None if it is not sparse.""" return None
[docs] def dense(self) -> np.ndarray: """ Return the form as a dense NumPy array. Returns ------- np.ndarray The full matrix. For a :class:`DenseOperator`, the read-only array it holds, not a copy; any other form builds a new array. """ return self.matmat(np.eye(self.shape[1]))
[docs] def diagonal(self) -> np.ndarray: """ Return the main diagonal; a sparse or diagonal form reads it from its entries. Returns ------- np.ndarray A new 1D array of the entries ``A[i, i]``. """ return np.diagonal(self.dense()).copy()
[docs] def trace(self) -> float: """ Return the sum of the main diagonal. Returns ------- float The trace. """ return float(self.diagonal().sum())
[docs] def solve(self, B) -> np.ndarray: """ Solve ``self @ X = B`` for a symmetric positive-definite form. A dense form solves by Cholesky, falling back to a symmetric ``LDL^T`` solve, with a logged warning, when roundoff has tipped it off positive-definite. A diagonal one divides, and raises :class:`~numpy.linalg.LinAlgError` on a zero. A sparse one uses SuperLU (:func:`scipy.sparse.linalg.splu`): SciPy has no sparse Cholesky, and LU keeps the factors sparse, solves every column of `B` with one factorization, and does not need positive-definiteness. A block diagonal one solves block by block, and a Kronecker one through its factors. A singular form raises :class:`~numpy.linalg.LinAlgError` in every form. Parameters ---------- B : array-like, SciPy sparse matrix or LinearOperator The right-hand side: a vector, or a matrix with as many rows as the form. Returns ------- np.ndarray ``X``, dense, with the shape of `B`. """ return _solve_psd(self.dense(), _as_array(B))
def _solver(self) -> Callable[[np.ndarray], np.ndarray]: """ Return ``B -> self.solve(B)`` with the factorization made once, for many solves with one form. A form that factors inside :meth:`solve` (dense, sparse, block diagonal) factors here, and the function it returns only substitutes, so a loop over groups of columns pays for one factorization and holds no more than the right-hand side of the group at hand. The factor lives as long as the function: the form itself keeps none. A form that already keeps its factors (diagonal, Kronecker) returns :meth:`solve`. """ return self.solve
[docs] def logdet(self) -> float: """ Return the logarithm of the determinant, for a symmetric positive-definite form. A dense form takes it from its Cholesky factor, a diagonal one from its entries, a block diagonal one block by block, a sparse one from its LU factors, and a Kronecker one that holds its whole product from the factors (any other dense). Returns ------- float ``log(det(A))``. Raises ------ numpy.linalg.LinAlgError If the form is not positive-definite (a diagonal or Cholesky factor with an entry that is not positive). """ L = _cholesky(self.dense()) return float(2.0 * np.log(np.diagonal(L)).sum())
[docs] def sqrt(self) -> "LinearOperator": r""" Return a square root :math:`L` with :math:`L L^T` equal to this symmetric positive-definite form. A diagonal form gives the diagonal of square roots, a block diagonal one the root of each block, and a Kronecker one whose rows are the whole product a root through its factors' Cholesky factors; every other form the lower Cholesky factor of its dense matrix, as a :class:`DenseOperator`. Returns ------- LinearOperator :math:`L`, square. Raises ------ numpy.linalg.LinAlgError If the form is not positive-definite. """ return DenseOperator._adopt(_cholesky(self.dense()))
@cached_property def _root(self) -> "LinearOperator": """ A matrix ``R`` (``n x r``) with ``R R^T`` equal to this symmetric positive semi-definite form. A draw from ``N(0, S)`` is ``R w`` for ``w`` standard normal of length ``r``. Built once and kept (as ``KroneckerOperator._inverses`` is), by the first draw or by ``Covariance(...)``, whose positive semidefinite check it is, so a loop of draws factors once; ``__getstate__`` leaves it out. """ return self._make_root() def _make_root(self) -> "LinearOperator": """ Return the root of the form: the Cholesky factor of its dense matrix, or its eigenvalue root when it is semi-definite. A form that is cheaper in another way overrides this. Raises :class:`~numpy.linalg.LinAlgError` if the form has a clearly negative eigenvalue. """ R, semidefinite = _dense_root(self.dense()) if semidefinite: logger.info( "The %d x %d covariance has no Cholesky factor (it is only " "positive semi-definite); a draw from it uses its " "eigendecomposition, with the eigenvalues below roundoff set to zero.", *self.shape, ) return DenseOperator._adopt(R) def _sample(self, rng: np.random.Generator) -> np.ndarray: """Return one draw from ``N(0, S)``: the root times ``r`` standard normals from `rng`.""" R = self._root return R.matvec(rng.standard_normal(R.shape[1])) def __array__(self, dtype=None, copy=None) -> np.ndarray: """ Return :meth:`dense`, so NumPy functions take a form as its matrix. `copy` follows NumPy's protocol: ``np.array(op)`` (``copy=True``) never shares memory with the form and is writable, while ``np.asarray(op)`` gives a :class:`DenseOperator`'s own read-only array. Every other form builds a new array either way. """ a = self.dense() if dtype is not None and np.dtype(dtype) != a.dtype: if copy is False: raise ValueError( f"A form is float64: converting it to {np.dtype(dtype)} " "needs a copy." ) return a.astype(dtype) if copy and isinstance(self, DenseOperator): return a.copy() return a
[docs] def dot(self, x): """ Return ``self @ x``, or the form scaled by a scalar ``x``. Parameters ---------- x : LinearOperator, SciPy sparse matrix, array-like or scalar What to multiply by. Returns ------- LinearOperator or np.ndarray A form, the cheapest exact one, for a form, a sparse matrix or a scalar; an array for an array, as :meth:`matvec` and :meth:`matmat` give. """ if isinstance(x, LinearOperator) or sp.issparse(x): return _product(self, as_linear_operator(x)) if np.ndim(x) == 0: # a scalar, or a 0-d array return self._elementwise(lambda a: a * float(x)) return super().dot(x)
def __rmatmul__(self, other): """Return ``other @ self``.""" if isinstance(other, LinearOperator) or sp.issparse(other): return _product(as_linear_operator(other), self) if np.ndim(other) == 0: raise ValueError("Scalar operands are not allowed, use '*' instead") return self._rmatmul_array(np.asarray(other)) def __rmul__(self, x): """Return ``x * self`` for a scalar, else ``x @ self``.""" if np.ndim(x) == 0: # a scalar, or a 0-d array return self._elementwise(lambda a: float(x) * a) return self.__rmatmul__(x) # Returns a LinearOperator, not SciPy's private class (see _transpose) def __truediv__(self, other): # pyrefly: ignore[bad-override] """Return the form divided by a scalar.""" if np.ndim(other) != 0: # a scalar, or a 0-d array raise ValueError("Can only divide a form by a scalar.") return self._elementwise(lambda a: a / float(other)) # Returns a LinearOperator, not SciPy's private class (see _transpose) def __neg__(self): # pyrefly: ignore[bad-override] """Return ``-self``.""" return self._elementwise(lambda a: -a) def __add__(self, x): """Return ``self + x``.""" return _sum(self, as_linear_operator(x)) def __radd__(self, other): """Return ``other + self``.""" return _sum(as_linear_operator(other), self) def __sub__(self, x): """Return ``self - x``.""" return _sum(self, -as_linear_operator(x)) def __rsub__(self, other): """Return ``other - self``.""" return _sum(as_linear_operator(other), -self)
[docs] class DenseOperator(LinearOperator): """ A matrix held as a dense 2D NumPy array. Parameters ---------- array : array-like The matrix. It is copied, and the copy is read-only: changing `array` afterwards leaves the form as it was. Raises ------ ValueError If `array` is not 2D. Examples -------- >>> import numpy as np >>> from fips.linalg import DenseOperator >>> A = DenseOperator(np.array([[2.0, 1.0], [1.0, 2.0]])) >>> A @ np.array([1.0, 0.0]) array([2., 1.]) >>> A.solve(np.array([3.0, 3.0])) array([1., 1.]) """ array: np.ndarray """The matrix, a read-only array.""" def __init__(self, array: npt.ArrayLike): self._hold(np.array(array, dtype=float)) @classmethod def _adopt(cls, array: np.ndarray) -> Self: """Return the form that holds `array` itself, which fips just made, without copying it.""" form = cls.__new__(cls) form._hold(array) return form def _hold(self, array: np.ndarray) -> None: """Hold `array`, read-only.""" array = np.asarray(array, dtype=float) if array.ndim != 2: raise ValueError(f"A DenseOperator takes a 2D array, got {array.ndim}D.") self.array = array self._freeze() super().__init__(array.shape) def _freeze(self) -> None: """Make the array read-only.""" _read_only(self.array) def _matvec(self, x): """Return ``self @ x`` for a vector.""" return self.array @ x def _matmat(self, X): """Return ``self @ X``.""" return self.array @ X def _rmatmul_array(self, X): """Return ``X @ self``.""" return X @ self.array def _transpose(self): """Return the transpose, a view of the same array.""" return DenseOperator._adopt(self.array.T) def _elementwise(self, func): """Return the form with `func` applied to its entries.""" return DenseOperator._adopt(func(self.array))
[docs] def dense(self) -> np.ndarray: """Return the read-only array the form holds, not a copy.""" return self.array
[docs] def trace(self) -> float: """Return the sum of the main diagonal.""" return float(np.trace(self.array))
[docs] def solve(self, B) -> np.ndarray: """Solve ``self @ X = B`` by Cholesky, falling back to ``LDL^T`` (see :meth:`LinearOperator.solve`).""" return _solve_psd(self.array, _as_array(B))
def _solver(self) -> Callable[[np.ndarray], np.ndarray]: """ Return the substitution of the Cholesky factor (see :meth:`LinearOperator._solver`). When roundoff has tipped the matrix off positive-definite it returns :meth:`solve`, which falls back to ``LDL^T`` with a logged warning. """ # LAPACK directly: SciPy's cho_factor returns a C-ordered factor, and # cho_solve copies it to Fortran order at every call potrf, potrs = get_lapack_funcs(("potrf", "potrs"), (self.array,)) factor, info = potrf(self.array, lower=False, clean=False) if info != 0: return self.solve def substitute(B) -> np.ndarray: """Return ``self^-1 B``.""" B = _as_array(B) X, _ = potrs(factor, B.reshape(len(B), -1), lower=False) return X.reshape(B.shape) return substitute
[docs] class SparseOperator(LinearOperator): """ A matrix held as a SciPy sparse matrix. Parameters ---------- matrix : SciPy sparse matrix or array The matrix. It is copied, as a CSR array (CSC for a CSC input), and the copy is read-only: changing `matrix` afterwards leaves the form as it was. Raises ------ TypeError If `matrix` is not a SciPy sparse matrix. Examples -------- A product of sparse forms stays sparse while at most a quarter of its entries are stored, and is dense past that: >>> import scipy.sparse as sp >>> from fips.linalg import SparseOperator >>> A = SparseOperator(sp.diags([4.0, 3.0, 2.0, 2.0, 1.0, 1.0], format="csr")) >>> A.diagonal() array([4., 3., 2., 2., 1., 1.]) >>> type(A @ A).__name__ 'SparseOperator' >>> B = SparseOperator(sp.csr_matrix([[4.0, 1.0], [1.0, 3.0]])) >>> type(B @ B).__name__ 'DenseOperator' """ matrix: "sp.csr_array | sp.csc_array" """The matrix, as a SciPy sparse array whose arrays are read-only.""" def __init__(self, matrix: "sp.spmatrix | sp.sparray"): if not sp.issparse(matrix): raise TypeError("A SparseOperator takes a SciPy sparse matrix.") self._hold(_sparse_array(matrix, copy=True)) @classmethod def _adopt(cls, matrix: "sp.spmatrix | sp.sparray") -> Self: """Return the form that holds `matrix`'s own arrays, which fips just made, without copying them.""" form = cls.__new__(cls) form._hold(_sparse_array(matrix, copy=False)) return form def _hold(self, matrix: "sp.csr_array | sp.csc_array") -> None: """Hold `matrix`, read-only.""" self.matrix = matrix self._freeze() # pyrefly reads SciPy's unannotated `shape` as possibly None (its initial value) super().__init__(matrix.shape) # pyrefly: ignore[bad-argument-type] def _freeze(self) -> None: """Make the matrix's data, indices and index pointers read-only.""" for array in (self.matrix.data, self.matrix.indices, self.matrix.indptr): _read_only(array) def _matvec(self, x): """Return ``self @ x`` for a vector.""" return self.matrix @ x def _matmat(self, X): """Return ``self @ X``.""" if isinstance(self.matrix, sp.csr_array): return _csr_times(self.matrix, X) return self.matrix @ X def _rmatmul_array(self, X): """Return ``X @ self``.""" if isinstance(self.matrix, sp.csc_array) and X.ndim == 2: # SciPy computes it as (self.T @ X.T).T, with self.T a CSR array return _csr_times(self.matrix.T, X.T).T return X @ self.matrix def _transpose(self): """Return the transpose (CSC for a CSR matrix, without a copy).""" return SparseOperator._adopt(self.matrix.T) def _elementwise(self, func): """Return the form with `func` applied to its stored entries.""" return SparseOperator._adopt(func(self.matrix)) def _csr(self): """Return the matrix as a CSR array.""" return sp.csr_array(self.matrix) def _lu(self): """Return the SuperLU factorization, raising LinAlgError if the matrix is singular.""" # SciPy has no sparse Cholesky (CHOLMOD is in scikit-sparse, not a # dependency). SuperLU keeps the factors sparse, and an LU does not # need the matrix to be positive-definite. try: return splu(sp.csc_matrix(self.matrix)) except RuntimeError as err: # SuperLU's "Factor is exactly singular" raise LinAlgError( f"The sparse matrix is singular: its LU factorization failed ({err})." ) from err def _make_root(self) -> "SparseOperator": """ Return the root of each independent group of rows, as one sparse matrix. The rows are split into the connected components of the sparsity pattern (a ``within=`` kernel's groups are a site-day each): rows in different components are uncorrelated, so the matrix is block diagonal up to a permutation, and the root is the dense root of each block (:func:`_dense_root`), put back at the rows' own places. A matrix whose rows are all connected has one group, its dense matrix. """ A = sp.csr_array(self.matrix) n = self.shape[0] graph = A.astype(bool) graph.eliminate_zeros() # a stored zero is not a correlation count, label = connected_components(graph, directed=False) order = np.argsort(label, kind="stable") # each group's rows together sizes = np.bincount(label, minlength=count) ends = np.cumsum(sizes) starts = ends - sizes # SciPy is not annotated, so pyrefly finds no __getitem__ on a sparse array ordered = sp.csr_array(A[order][:, order]) # pyrefly: ignore[bad-index] # the groups of one row, all at once: the root is the square root of the entry alone = starts[sizes == 1] variances, semidefinite = _clip_eigenvalues(ordered.diagonal()[alone]) roots = np.sqrt(variances) rows, cols, vals = [order[alone]], [order[alone]], [roots] for a, b in zip(starts[sizes > 1], ends[sizes > 1], strict=True): # pyrefly reads a slice of the array as the bare `sparray` (no toarray) block = ordered[a:b, a:b].toarray() # pyrefly: ignore[missing-attribute] R, semi = _dense_root(block) semidefinite = semidefinite or semi i, j = np.nonzero(R) rows.append(order[a + i]) cols.append(order[a + j]) vals.append(R[i, j]) if semidefinite: logger.info( "The %d x %d sparse covariance has a group of rows with no " "Cholesky factor (it is only positive semi-definite); a draw " "from it uses that group's eigendecomposition, with the " "eigenvalues below roundoff set to zero.", n, n, ) root = sp.csr_array( (np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n), ) root.eliminate_zeros() return SparseOperator._adopt(root)
[docs] def dense(self) -> np.ndarray: """Return the form as a dense NumPy array.""" return self.matrix.toarray()
[docs] def diagonal(self) -> np.ndarray: """Return the main diagonal, read from the stored entries.""" return self.matrix.diagonal()
[docs] def solve(self, B) -> np.ndarray: """Solve ``self @ X = B`` by a sparse LU factorization (see :meth:`LinearOperator.solve`).""" return self._lu().solve(_as_array(B))
def _solver(self) -> Callable[[np.ndarray], np.ndarray]: """Return the LU factorization's substitution (see :meth:`LinearOperator._solver`).""" lu = self._lu() return lambda B: lu.solve(_as_array(B))
[docs] def logdet(self) -> float: """ Return the log-determinant from a symmetric sparse LU factorization. The rows and columns are permuted alike and pivots are taken on the diagonal, so the factorization is ``P A P^T = L D L^T`` with ``U = D L^T``: the matrix is positive-definite when every pivot is positive. Raises ------ numpy.linalg.LinAlgError If a pivot is zero or negative: the matrix is not positive-definite. """ try: lu = splu( sp.csc_matrix(self.matrix), permc_spec="MMD_AT_PLUS_A", diag_pivot_thresh=0.0, options={"SymmetricMode": True}, ) except RuntimeError as err: # a zero pivot raise LinAlgError( f"The sparse matrix is not positive-definite ({err})." ) from err pivots = lu.U.diagonal() # SuperLU pivots off the diagonal only on a zero diagonal entry, which a # positive-definite matrix never has: the permutations then differ if not np.array_equal(lu.perm_r, lu.perm_c) or (pivots <= 0).any(): raise LinAlgError( "The sparse matrix is not positive-definite: a pivot of its " "symmetric factorization is not positive." ) return float(np.log(pivots).sum())
[docs] class DiagonalOperator(LinearOperator): """ A square matrix that is zero off its main diagonal, held as the diagonal. Parameters ---------- values : array-like The diagonal, 1D. It is copied, and the copy is read-only. Raises ------ ValueError If `values` is not 1D. Examples -------- >>> from fips.linalg import DiagonalOperator >>> D = DiagonalOperator([2.0, 4.0]) >>> D.solve([1.0, 1.0]) array([0.5 , 0.25]) >>> D.trace() 6.0 """ values: np.ndarray """The diagonal, a read-only array.""" def __init__(self, values: npt.ArrayLike): self._hold(np.array(values, dtype=float)) @classmethod def _adopt(cls, values: np.ndarray) -> Self: """Return the form that holds `values` itself, which fips just made, without copying it.""" form = cls.__new__(cls) form._hold(values) return form def _hold(self, values: np.ndarray) -> None: """Hold `values`, read-only.""" values = np.asarray(values, dtype=float) if values.ndim != 1: raise ValueError( f"A DiagonalOperator takes its diagonal, 1D; got {values.ndim}D." ) self.values = values self._freeze() super().__init__((len(values), len(values))) def _freeze(self) -> None: """Make the diagonal read-only.""" _read_only(self.values) def _matvec(self, x): """Return ``self @ x`` for a vector, given as ``(n,)`` or ``(n, 1)`` (SciPy before 1.18 sends a column here).""" return self.values * np.ravel(x) def _matmat(self, X): """Return ``self @ X``: each row of `X` scaled.""" return self.values[:, None] * X def _rmatmul_array(self, X): """Return ``X @ self``: each column of `X` scaled.""" return X * self.values def _transpose(self): """Return the form itself, which is symmetric.""" return self def _elementwise(self, func): """Return the form with `func` applied to the diagonal.""" return DiagonalOperator._adopt(func(self.values)) def _csr(self): """Return the form as a CSR array that stores its nonzero entries.""" n = len(self.values) i = np.flatnonzero(self.values) return sp.csr_array((self.values[i], (i, i)), shape=(n, n))
[docs] def dense(self) -> np.ndarray: """Return the form as a dense NumPy array.""" return np.diag(self.values)
[docs] def diagonal(self) -> np.ndarray: """Return a copy of the diagonal.""" return self.values.copy()
[docs] def solve(self, B) -> np.ndarray: """Solve ``self @ X = B`` by dividing each row of `B` by the diagonal.""" if not np.all(self.values): raise LinAlgError("The diagonal has a zero, so the operator is singular.") B = _as_array(B) return B / (self.values if B.ndim == 1 else self.values[:, None])
[docs] def logdet(self) -> float: """Return the sum of the logarithms of the diagonal.""" if (self.values <= 0).any(): raise LinAlgError("The diagonal has an entry that is not positive.") return float(np.log(self.values).sum())
def _make_root(self) -> "DiagonalOperator": """Return the diagonal of square roots; an entry negative by roundoff is zero (a draw from it is 0).""" values, clipped = _clip_eigenvalues(self.values) if clipped: logger.info( "The diagonal covariance has an entry below zero by roundoff; " "a draw gives it zero." ) return DiagonalOperator._adopt(np.sqrt(values))
[docs] def sqrt(self) -> "DiagonalOperator": """Return the diagonal of square roots.""" if (self.values < 0).any(): raise LinAlgError("The diagonal has a negative entry.") return DiagonalOperator._adopt(np.sqrt(self.values))
def _product(a: LinearOperator, b: LinearOperator) -> LinearOperator: """Return ``a @ b`` in the cheapest form that is exact.""" if a.shape[1] != b.shape[0]: raise ValueError( f"Cannot multiply operators of shapes {a.shape} and {b.shape}." ) if isinstance(a, DiagonalOperator) and isinstance(b, DiagonalOperator): return DiagonalOperator._adopt(a.values * b.values) if isinstance(a, SparseOperator) and isinstance(b, SparseOperator): product = a.matrix @ b.matrix if product.nnz > _DENSE_FILL * product.shape[0] * product.shape[1]: return DenseOperator._adopt(product.toarray()) return SparseOperator._adopt(product) a_csr, b_csr = a._csr(), b._csr() if a_csr is not None and b_csr is not None: # one side diagonal: no fill-in return SparseOperator._adopt(a_csr @ b_csr) if isinstance(b, DenseOperator): return DenseOperator._adopt(a.matmat(b.array)) if isinstance(a, DenseOperator): return DenseOperator._adopt(b._rmatmul_array(a.array)) # Left: a form of its own (Kronecker, block diagonal) on at least one side. # A sparse matrix (a Jacobian) goes through that form's own product, never # its dense matrix; anything else gives a product as big as the dense one. if isinstance(b, SparseOperator): return DenseOperator._adopt(a._matmat(b.matrix)) if isinstance(a, SparseOperator): return DenseOperator._adopt(b._transpose()._matmat(a.matrix.T).T) if b_csr is not None: # a diagonal right side scales the columns return DenseOperator._adopt(b._rmatmul_array(a.dense())) return DenseOperator._adopt(a.matmat(b.dense())) def _sum(a: LinearOperator, b: LinearOperator) -> LinearOperator: """Return ``a + b`` in the cheapest form that is exact.""" if a.shape != b.shape: raise ValueError(f"Cannot add operators of shapes {a.shape} and {b.shape}.") if isinstance(a, DiagonalOperator) and isinstance(b, DiagonalOperator): return DiagonalOperator._adopt(a.values + b.values) a_csr, b_csr = a._csr(), b._csr() if a_csr is not None and b_csr is not None: return SparseOperator._adopt(a_csr + b_csr) return DenseOperator._adopt(a.dense() + b.dense()) def _diagonal_of(matrix) -> np.ndarray | None: """Return the diagonal of a square SciPy sparse matrix that stores entries only there, else None.""" coo = matrix.tocoo() if matrix.shape[0] == matrix.shape[1] and np.array_equal(coo.row, coo.col): return matrix.diagonal() return None def _matrix_2d(x) -> np.ndarray: """Return array-like `x` as a float64 array, raising ValueError unless it is 2D.""" array = np.asarray(x, dtype=float) if array.ndim != 2: raise ValueError( f"A matrix takes 2D data, got {array.ndim}D. For a diagonal, " "use DiagonalOperator(values)." ) return array def _adopt(x) -> LinearOperator: """ Return `x`, an array or SciPy sparse matrix that fips just made, as the form that fits it. The form holds `x` itself and makes it read-only: no copy, so only for what nothing else refers to, or what is read-only already (the arrays of another form). What a caller gave fips goes through :func:`as_linear_operator`, which copies it. A form comes back as it is. """ if isinstance(x, LinearOperator): return x if sp.issparse(x): diagonal = _diagonal_of(x) if diagonal is None: return SparseOperator._adopt(x) return DiagonalOperator._adopt(diagonal) return DenseOperator._adopt(_matrix_2d(x))
[docs] def as_linear_operator(x: LinearOperatorLike) -> LinearOperator: """ Return `x` as the form that fits how it is stored. Parameters ---------- x : LinearOperator, SciPy sparse matrix or 2D array-like What to wrap. A form comes back as it is; an array or a sparse matrix is copied, and the form's copy is read-only. Returns ------- LinearOperator A :class:`DiagonalOperator` for a square sparse matrix that stores entries on its diagonal only, a :class:`SparseOperator` for any other sparse matrix, and a :class:`DenseOperator` for an array. Raises ------ ValueError If `x` is not 2D. A diagonal given as its values is a :class:`DiagonalOperator`. Examples -------- >>> import numpy as np >>> import scipy.sparse as sp >>> from fips.linalg import as_linear_operator >>> type(as_linear_operator(np.eye(3))).__name__ 'DenseOperator' >>> type(as_linear_operator(sp.eye(3))).__name__ 'DiagonalOperator' """ if isinstance(x, LinearOperator): return x # isinstance, not issparse: the type checker narrows only this one if isinstance(x, (sp.spmatrix, sp.sparray)): diagonal = _diagonal_of(x) if diagonal is None: return SparseOperator(x) return DiagonalOperator(diagonal) return DenseOperator(_matrix_2d(x))
[docs] def dottest( op: _SciPyLinearOperator, rtol: float = 1e-12, seed: int | None = 0 ) -> bool: """ Check that ``op.rmatvec`` is the adjoint of ``op.matvec``. For random ``x`` and ``y``, ``<op x, y>`` must equal ``<x, op^T y>`` to within `rtol` of ``|op x| |y| + |x| |op^T y|``, the size of the terms the two inner products are summed from. Parameters ---------- op : scipy.sparse.linalg.LinearOperator The operator to test: any SciPy linear operator, a fips form among them. rtol : float, default 1e-12 Relative tolerance. seed : int, optional Seed of the random vectors. Returns ------- bool True if the two inner products agree. Examples -------- >>> import numpy as np >>> from fips.linalg import DenseOperator, dottest >>> dottest(DenseOperator(np.arange(6.0).reshape(2, 3))) True """ rng = np.random.default_rng(seed) x, y = rng.standard_normal(op.shape[1]), rng.standard_normal(op.shape[0]) Ax, ATy = op.matvec(x), op.rmatvec(y) size = np.linalg.norm(Ax) * np.linalg.norm(y) + np.linalg.norm(x) * np.linalg.norm( ATy ) return bool(abs(Ax @ y - x @ ATy) <= rtol * size)