Source code for spacecore.linop._sparse

from __future__ import annotations

from enum import Enum, auto
from functools import cached_property
from math import prod
from typing import Any, cast

from ._base import LinOp, _output_checked_rvapply
from ._metric import (
    _VECTOR_SPACES, _metric_is_hermitian_by_basis, _requires_euclidean_or_riesz, metric_rapply,
    metric_rvapply,
)
from .._checks import CheckLevel
from ..space import (
    CoordinateSpace,
    WeightedInnerProduct,
)
from ..types import DenseArray, SparseArray
from ..contextual import Context
from ..contextual import resolve_context_priority


_VECTOR_SPACE_ONLY = (
    "SparseLinOp is only for coordinate sparse matrices acting between "
    "CoordinateSpace objects. Non-vector or exotic spaces should use "
    "MatrixFreeLinOp with explicit forward and adjoint callbacks."
)


class _SparseMode(Enum):
    """Private computation modes for sparse coordinate operators."""

    EUCLIDEAN_FLAT = auto()
    EUCLIDEAN_TENSOR = auto()
    WEIGHTED_FUSED = auto()
    GENERAL_METRIC = auto()



[docs] class SparseLinOp(LinOp[CoordinateSpace, CoordinateSpace]): r""" Linear operator stored as a sparse matrix of shape ``(cod.size, dom.size)``. Domain and codomain must be vector spaces. The adjoint is the conjugate transpose on Euclidean spaces and ``R_X^{-1} A^dagger R_Y`` otherwise. The matrix is used as given. Parameters ---------- A : SparseArray Sparse matrix of shape ``(prod(cod.shape), prod(dom.shape))``. dom : CoordinateSpace Domain. cod : CoordinateSpace Codomain. ctx : Context, str, or None, optional Backend context; resolved from the spaces when omitted. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. Examples -------- >>> import numpy as np >>> import scipy.sparse as sps >>> import spacecore as sc >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) >>> X = sc.DenseCoordinateSpace((2,), ctx) >>> A = sc.SparseLinOp(ctx.assparse(sps.eye(2)), X, X, ctx) >>> A.apply(ctx.asarray([1.0, 2.0])) array([1., 2.]) """ _fields = ("A", "dom", "cod", "ctx") _dynamic = ("A",) def _apply_core(self, x: Any) -> Any: # Flat or fused: one matvec. Tensor: reshape around it. Else the spaces' flatten/unflatten. if self._mode is _SparseMode.EUCLIDEAN_FLAT or self._mode is _SparseMode.WEIGHTED_FUSED: return self._A @ x if self._mode is _SparseMode.EUCLIDEAN_TENSOR: return (self._A @ x.reshape((self._dom_size,))).reshape(self.cod.shape) x1 = self.dom._flatten_core(x) y1 = self._A @ x1 return self.cod._unflatten_core(y1) def _rapply_core(self, y: Any) -> Any: # Euclidean: A^H. Diagonal weights: W_X^{-1} A^H W_Y y. Else wrap A^H with the metrics. if self._mode is _SparseMode.EUCLIDEAN_FLAT or self._mode is _SparseMode.EUCLIDEAN_TENSOR: return self._euclidean_rapply_core(y) if self._mode is _SparseMode.WEIGHTED_FUSED: return (self._AH @ (self._cod_weights * y)) / self._dom_weights return metric_rapply( self.domain, self.codomain, self._euclidean_rapply_core, y ) def _vapply_core(self, xs: Any) -> Any: # Sparse @ dense wants the batch as columns: (A X^T)^T. if self._mode is _SparseMode.EUCLIDEAN_FLAT or self._mode is _SparseMode.WEIGHTED_FUSED: return (self._A @ xs.reshape((-1, self._dom_size)).T).T if self._mode is _SparseMode.EUCLIDEAN_TENSOR: lead = tuple(xs.shape[: len(xs.shape) - len(self.dom.shape)]) xs2 = xs.reshape((-1, self._dom_size)) ys2 = (self._A @ xs2.T).T return ys2.reshape(lead + tuple(self.cod.shape)) xs_flat = self.domain._flatten_batch_core(xs) ys_flat = (self._A @ xs_flat.T).T return self.codomain._unflatten_batch_core(ys_flat) def _rvapply_core(self, ys: Any) -> Any: # Batched form of _rapply_core. if self._mode is _SparseMode.EUCLIDEAN_FLAT or self._mode is _SparseMode.EUCLIDEAN_TENSOR: return self._euclidean_rvapply_core(ys) if self._mode is _SparseMode.WEIGHTED_FUSED: return (self._AH @ (ys * self._cod_weights).T).T / self._dom_weights return metric_rvapply(self.domain, self.codomain, self._euclidean_rvapply_core, ys) def _euclidean_rapply_core(self, y: Any) -> Any: # Plain conjugate transpose A^H, no metric. if self._mode is _SparseMode.EUCLIDEAN_FLAT: return self._AH @ y if self._mode is _SparseMode.EUCLIDEAN_TENSOR: return (self._AH @ y.reshape((self._cod_size,))).reshape(self.dom.shape) y1 = self.cod._flatten_core(y) x1 = self._AH @ y1 return self.dom._unflatten_core(x1) def _euclidean_rvapply_core(self, ys: Any) -> Any: # (A^H Y^T)^T for a batch of rows. if self._mode is _SparseMode.EUCLIDEAN_FLAT: return (self._AH @ ys.reshape((-1, self._cod_size)).T).T if self._mode is _SparseMode.EUCLIDEAN_TENSOR: lead = tuple(ys.shape[: len(ys.shape) - len(self.cod.shape)]) ys2 = ys.reshape((-1, self._cod_size)) xs2 = (self._AH @ ys2.T).T return xs2.reshape(lead + tuple(self.dom.shape)) ys_flat = self.codomain._flatten_batch_core(ys) xs_flat = (self._AH @ ys_flat.T).T return self.domain._unflatten_batch_core(xs_flat) def __init__( self, A: SparseArray, dom: CoordinateSpace, cod: CoordinateSpace, ctx: Context | str | None = None, check_level: CheckLevel | None = None, ) -> None: # A must be a sparse array of the backend; spaces must be coordinate spaces. ctx = resolve_context_priority(ctx, dom, cod) ctx.assert_sparse(A) # Check if A is sparse array of ctx if not isinstance(dom, CoordinateSpace) or not isinstance(cod, CoordinateSpace): raise TypeError(_VECTOR_SPACE_ONLY) _requires_euclidean_or_riesz(dom, cod, "SparseLinOp") super(SparseLinOp, self).__init__(dom, cod, ctx, check_level=check_level) # Storage is always the flattened 2-D matrix. expected = (prod(self.cod.shape), prod(self.dom.shape)) if tuple(A.shape) != expected: raise TypeError( f"Expected A.shape == (prod(cod.shape), prod(dom.shape)) == {expected}, got {A.shape}" ) # Cache A^T and A^H once, and note which spaces are plain dense arrays. self._A = A # No dtype conversion self._cod_size = expected[0] self._dom_size = expected[1] dtype = self.ops.get_dtype(self.A) self._A_is_complex = self.ops.is_complex_dtype(dtype) self._AT = self.A.T self._AH = self._sparse_conj(self._AT) if self._A_is_complex else self._AT self._dom_dense_array = type(self.dom) in _VECTOR_SPACES self._cod_dense_array = type(self.cod) in _VECTOR_SPACES self._dom_is_flat = self._dom_dense_array and tuple(self.dom.shape) == (self._dom_size,) self._cod_is_flat = self._cod_dense_array and tuple(self.cod.shape) == (self._cod_size,) # Pick the apply strategy once. self._mode = self._select_mode() if self._mode is _SparseMode.WEIGHTED_FUSED: # Weights may be stored at any shape that broadcasts to the space's. self._dom_weights = self.ops.broadcast_to(self.dom.geometry.weights, tuple(self.dom.shape)) self._cod_weights = self.ops.broadcast_to(self.cod.geometry.weights, tuple(self.cod.shape)) def _select_mode(self) -> _SparseMode: """Select the sparse computation mode once for this operator.""" # Weighted flat vectors: fused path; Euclidean dense arrays: plain SpMV; # anything else: the general Riesz-map path. if ( self._dom_is_flat and self._cod_is_flat and type(getattr(self.dom, "geometry", None)) is WeightedInnerProduct and type(getattr(self.cod, "geometry", None)) is WeightedInnerProduct ): return _SparseMode.WEIGHTED_FUSED if ( cast(Any, self.domain).is_euclidean and cast(Any, self.codomain).is_euclidean and self._dom_dense_array and self._cod_dense_array ): if self._dom_is_flat and self._cod_is_flat: return _SparseMode.EUCLIDEAN_FLAT return _SparseMode.EUCLIDEAN_TENSOR return _SparseMode.GENERAL_METRIC def _sparse_conj(self, A: SparseArray) -> SparseArray: """Return the complex conjugate of a backend sparse array.""" # Conjugate whatever sparse type the backend uses. if hasattr(A, "conj"): return A.conj() if hasattr(A, "conjugate"): return cast(Any, A).conjugate() if hasattr(A, "data") and hasattr(A, "indices"): A_any = cast(Any, A) kwargs = { "shape": A.shape, "indices_sorted": getattr(A, "indices_sorted", False), "unique_indices": getattr(A, "unique_indices", False), } return cast(Any, type(A))((self.ops.conj(A_any.data), A_any.indices), **kwargs) raise TypeError(f"Cannot conjugate sparse array of type {type(A).__name__}.") @cached_property def A(self) -> SparseArray: """ Stored sparse matrix representation of this operator. The returned sparse matrix has shape ``(prod(self.codomain.shape), prod(self.domain.shape))`` and is the same object supplied at construction. """ return self._A rvapply = _output_checked_rvapply
[docs] def to_sparse(self) -> SparseArray: """ Return the stored sparse matrix representation without copying. The returned object is exactly the sparse array supplied at construction. """ return self.A
[docs] def to_matrix(self) -> DenseArray: """ Materialize the stored sparse matrix as a dense 2D coordinate matrix. Use :meth:`to_sparse` when sparse storage should be preserved. """ # Densify with the backend's native method, else by applying to a basis. if hasattr(self.A, "toarray"): dense = cast(Any, self.A).toarray() elif hasattr(self.A, "todense"): dense = cast(Any, self.A).todense() elif hasattr(self.A, "to_dense"): dense = cast(Any, self.A).to_dense() else: dense = super().to_matrix() return self.ops.reshape(self.ctx.asarray(dense), (self._cod_size, self._dom_size))
[docs] def to_dense(self) -> DenseArray: """ Materialize the stored sparse matrix as a dense operator tensor. The returned array has shape ``self.codomain.shape + self.domain.shape``. """ return self.ops.reshape( self.to_matrix(), tuple(self.codomain.shape) + tuple(self.domain.shape) )
[docs] def is_hermitian(self) -> bool | None: """ Return whether this sparse operator is structurally self-adjoint. Returns ------- bool or None ``True`` or ``False`` when the structure can be checked, otherwise ``None``. """ # Hermitian needs dom == cod; Euclidean compares A with A^H, else test by basis. if self.dom != self.cod: return False if not cast(Any, self.domain).is_euclidean: # dom == cod, so cod is too return _metric_is_hermitian_by_basis(self) try: return bool(self.ops.allclose_sparse(self.A, self._AH)) except Exception: return None
def __eq__(self, other: Any) -> bool: if not self.same_math(other): # Tier 1: backend return NotImplemented if self.dom != other.dom or self.cod != other.cod: # Tier 2: spaces before allclose return False # Tier 3: sparse values. Note: allclose_sparse has no equal_nan, so a # structural NaN compares unequal to itself (rare for sparse storage). return bool(self.ops.allclose_sparse(self.A, other.A)) def _repr_body(self) -> str: """Lead with the arrow; add the (cheap) stored non-zero count when known.""" nnz = self._stored_nnz() return self._arrow() if nnz is None else f"{self._arrow()}, nnz={nnz}" def _stored_nnz(self) -> int | None: """Return the stored non-zero count across backends, or ``None``. Tries the common spellings: ``nnz`` (scipy/cupy), ``nse`` (jax BCOO), and ``_nnz()`` (torch). Returns ``None`` when none is cheaply available. """ # Stored non-zeros, whatever the backend calls the count. A = self._A for attr in ("nnz", "nse"): value = getattr(A, attr, None) if isinstance(value, int): return value method = getattr(A, "_nnz", None) if callable(method): try: return int(cast(Any, method())) except Exception: return None return None def _convert(self, new_ctx: Context) -> SparseLinOp: # Move the spaces and the matrix, fixing the dtype if conversion kept the old one. new_dom = self.dom.convert(new_ctx) new_cod = self.cod.convert(new_ctx) new_A = new_ctx.assparse(self.A) if new_ctx.ops.get_dtype(new_A) != new_ctx.dtype: if hasattr(new_A, "astype"): new_A = cast(Any, new_A).astype(new_ctx.dtype) elif hasattr(new_A, "to"): new_A = cast(Any, new_A).to(dtype=new_ctx.dtype) else: new_A = new_ctx.assparse(self.to_matrix()) return SparseLinOp(new_A, new_dom, new_cod, new_ctx)