Source code for spacecore.linop._base

from __future__ import annotations

import warnings
from functools import cached_property
from math import prod
from typing import Any, Generic, TypeVar

from .._batching import _leading_batch_size, _warn_vmap_fallback_once
from .._checks import CheckLevel
from .._checks import checked_method
from ..space._capabilities import _CAP_BATCH, _CAP_COORDINATE, require
from ..space import VectorSpace, RealField
from ..contextual import Context
from ..mapping import Mapping

Domain = TypeVar("Domain", bound=VectorSpace)
Codomain = TypeVar("Codomain", bound=VectorSpace)


[docs] class LinOp(Mapping, Generic[Domain, Codomain]): r""" A linear map from ``dom`` to ``cod``; subclasses supply ``apply`` and ``rapply``. ``rapply`` is the Hilbert-space adjoint in each space's own inner product, :math:`\langle A x, y\rangle_Y = \langle x, A^* y\rangle_X`, not the coordinate transpose. In orthonormal bases it is the conjugate transpose (Trefethen & Bau, *Numerical Linear Algebra*, Lecture 2, pp. 11-12); in general coordinates it is :math:`R_X^{-1} A^\dagger R_Y`, derived in :func:`~spacecore.linop._metric.metric_rapply`. Adjoint products reverse order (Trefethen & Bau, *Numerical Linear Algebra*, Eq. (2.4)); inserting Gram factors cancels the intermediate metric. Parameters ---------- dom : Space Domain ``X``. cod : Space Codomain ``Y``. ctx : Context, str, or None, optional Backend context; resolved from ``dom`` and ``cod`` when omitted. check_level : {"none", "cheap", "standard", "strict"}, optional Validation level; the ambient default when omitted. Examples -------- Use a concrete dense operator as a :class:`LinOp`. >>> import numpy as np >>> import spacecore as sc >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64) >>> X = sc.DenseCoordinateSpace((2,), ctx) >>> A = sc.DenseLinOp(ctx.asarray([[1.0, 0.0], [0.0, 2.0]]), X, X, ctx) >>> A.apply(ctx.asarray([3.0, 4.0])) array([3., 8.]) """ def __init__( self, dom: Domain, cod: Codomain, ctx: Context | str | None = None, check_level: CheckLevel | None = None, ): # Scalar-valued maps are Functionals, not LinOps (ADR-010). if isinstance(cod, RealField): raise TypeError( "LinOp codomain cannot be a RealField (ADR-010); use Functional " "for scalar-valued maps and Riesz gradients." ) self.dom, self.cod = self._bind_context(ctx, dom, cod, check_level=check_level) def _init_map(self, domain: Any, codomain: Any, ctx: Any, check_level: Any) -> None: """Bind a node of this family (the hook the shared lazy nodes in ``mapping`` call).""" LinOp.__init__(self, domain, codomain, ctx, check_level=check_level) @property def domain(self) -> Domain: """Domain space of this linear operator.""" return self.dom @property def codomain(self) -> Codomain: """Codomain space of this linear operator.""" return self.cod @cached_property def A(self) -> Any: """ Native numerical representation of this operator. Concrete subclasses may choose the representation that best matches their storage model: for example, dense operators return a dense array while sparse operators return their sparse matrix. Matrix-free or lazy operators generally do not have such a representation and should leave this property unimplemented. Use :meth:`to_dense` when a dense tensor materialization is explicitly required. """ raise NotImplementedError( f"{type(self).__name__} does not define a native numerical representation." )
[docs] @checked_method(in_space="domain", out_space="codomain") def apply(self, x: Any) -> Any: """Apply the forward map to an element of ``self.domain``.""" return self._apply_core(x)
[docs] @checked_method(in_space="codomain", out_space="domain") def rapply(self, y: Any) -> Any: r"""Apply the adjoint map to an element of ``self.codomain``. This is the **Hilbert-space adjoint** ``A^#``, defined by ``<Ax, y>_Y = <x, A^# y>_X``. Trefethen & Bau, *Numerical Linear Algebra*, Lecture 2, pp. 11-12, gives the conjugate transpose in orthonormal coordinates; :func:`spacecore.linop._metric.metric_rapply` derives the Gram-matrix form. The dual map sends a linear functional ``phi`` to ``phi o A``; writing ``phi(y) = b^T y`` gives dual coordinates ``A^T b``, even over the complex field. """ return self._rapply_core(y)
def _require(self, capability: Any, name: str) -> None: """Raise unless both endpoint spaces provide ``capability``.""" require(self.domain, capability, name) require(self.codomain, capability, name) # Subclasses implement the check-free cores, which the checked public methods # call (an undecorated public override becomes the core; see install_public_checks). def _apply_core(self, x: Any) -> Any: raise NotImplementedError(f"{type(self).__name__} defines neither apply nor _apply_core.") def _rapply_core(self, y: Any) -> Any: raise NotImplementedError(f"{type(self).__name__} defines neither rapply nor _rapply_core.") def _vapply_core(self, xs: Any) -> Any: # Generic batching: vmap the single-element apply over the leading axis. self._require(_CAP_BATCH, "LinOp.vapply") _warn_vmap_fallback_once(self, "vapply", _leading_batch_size(self.domain, xs)) return self.ops.vmap(self.apply, in_axes=0, out_axes=0)(xs) def _rvapply_core(self, ys: Any) -> Any: self._require(_CAP_BATCH, "LinOp.rvapply") _warn_vmap_fallback_once(self, "rvapply", _leading_batch_size(self.codomain, ys)) return self.ops.vmap(self.rapply, in_axes=0, out_axes=0)(ys) def __call__(self, x: Any) -> Any: """Apply this linear operator to ``x``.""" return self.apply(x)
[docs] def adjoint_apply(self, y: Any) -> Any: """Apply the adjoint of this linear operator to ``y``. Spelled-out alias for the inner-product adjoint :meth:`rapply`. """ return self.rapply(y)
[docs] def is_hermitian(self) -> bool | None: """ Return whether this operator is structurally Hermitian when known. Returns ------- bool | None ``True`` or ``False`` when the subclass can verify the structure cheaply, otherwise ``None`` for unknown or matrix-free operators. """ return None
[docs] @checked_method(in_space="domain", in_batched=True) def vapply(self, xs: Any) -> Any: """Apply over a leading batch axis. Input must have shape ``(N,) + domain.shape``; use ``moveaxis`` for other layouts.""" return self._vapply_core(xs)
[docs] @checked_method(in_space="codomain", in_batched=True) def rvapply(self, ys: Any) -> Any: """Apply the adjoint over a leading batch axis. Input must have shape ``(N,) + codomain.shape``; use ``moveaxis`` for other layouts.""" return self._rvapply_core(ys)
@property def H(self) -> LinOp: r"""Hermitian-adjoint view of this linear operator. Returns ------- LinOp Adjoint view satisfying :math:`\langle A x, y\rangle_Y = \langle x, A^* y\rangle_X`. """ from ._matrix_free import _AdjointViewLinOp # Build the adjoint view once and cache it, so A.H is A.H. view = getattr(self, "_adjoint_view", None) if view is None: view = _AdjointViewLinOp(self) self._adjoint_view = view return view
[docs] def adjoint(self) -> LinOp: """Return the Hermitian-adjoint view of this linear operator.""" return self.H
[docs] def fuse(self, *, materialize: bool = False) -> LinOp: r""" Return an equivalent operator with dense sub-expressions multiplied out (ADR-021). Each maximal run of dense operators becomes one :class:`DenseLinOp`; other leaves stay lazy. The result equals ``self`` up to rounding. A leaf returns itself. Parameters ---------- materialize : bool, optional Also densify matrix-free operands, which may be costly; by default they stay lazy. Returns ------- LinOp The fused operator. """ return self
[docs] def to_dense(self) -> Any: """ Materialize this operator as a dense backend array. The returned array has shape ``self.codomain.shape + self.domain.shape``. The default implementation is intended for small problems, debugging, and tests. It materializes the full coordinate matrix, so subclasses that already store a dense or sparse matrix should override this method for efficiency. """ self._require(_CAP_COORDINATE, "LinOp.to_dense") return self.ops.reshape( self.to_matrix(), tuple(self.codomain.shape) + tuple(self.domain.shape) )
[docs] def to_sparse(self): raise NotImplementedError(f"{type(self).__name__} does not define sparse materialization.")
[docs] def to_matrix(self) -> Any: """ Materialize this operator as a 2D dense coordinate matrix. The returned array has shape ``(prod(self.codomain.shape), prod(self.domain.shape))``. The default implementation builds a batch of standard basis vectors and calls :meth:`vapply` once. If a space cannot batch-flatten or batch-unflatten its representation, it falls back to a safe Python loop. This method is for small/testing use; concrete storage-backed subclasses should override it when they can expose a matrix directly. """ self._require(_CAP_COORDINATE, "LinOp.to_matrix") # Apply the operator to every basis vector: its images are the matrix columns. domain_size = prod(self.domain.shape) codomain_size = prod(self.codomain.shape) eye = self.ops.eye(domain_size, dtype=self.dtype) # Fast path: one batched apply over the whole identity. try: xs = self.domain._unflatten_batch_core(eye) ys = self.vapply(xs) ys_flat = self.codomain._flatten_batch_core(ys) matrix = self.ops.transpose(ys_flat, (1, 0)) return self.ops.reshape(matrix, (codomain_size, domain_size)) except (AttributeError, NotImplementedError, TypeError) as exc: warnings.warn( ( f"{type(self).__name__}.to_matrix() could not use the batched " f"materialization path and is falling back to a Python loop. " f"This is slower and not JIT-friendly. Original error: " f"{type(exc).__name__}: {exc}" ), RuntimeWarning, stacklevel=2, ) # Fallback: one apply per basis vector. columns = [] for i in range(domain_size): basis_vector = eye[:, i] x = self.domain._unflatten_core(basis_vector) y = self.apply(x) columns.append(self.codomain._flatten_core(y)) return self.ops.stack(tuple(columns), axis=1)
[docs] def assert_domain(self, x: Any) -> None: """Raise if ``x`` is not in the domain.""" self.dom.check_member(x)
[docs] def assert_codomain(self, y: Any) -> None: """Raise if ``y`` is not in the codomain.""" self.cod.check_member(y)
# tree_flatten / tree_unflatten are inherited from PyTreeNode, which owns the # flatten contract for every SpaceCore container and auto-registers concrete # subclasses with each backend's tree protocol. Every concrete LinOp is a # container, so the capability belongs on this base rather than being # re-declared (and separately registered) per operator. LinOp._family = LinOp def _require_linop(op: Any, name: str) -> LinOp: """Return ``op`` as a linear operator or raise a typed error.""" if not isinstance(op, LinOp): raise TypeError(f"{name} must be a LinOp, got {type(op).__name__}.") return op @checked_method(in_space="codomain", out_space="domain", in_batched=True, out_batched=True) def _output_checked_rvapply(self: LinOp, ys: Any) -> Any: """Apply the adjoint over a leading batch axis. Input must have shape ``(N,) + codomain.shape``; use ``moveaxis`` for other layouts.""" return self._rvapply_core(ys) # Assigned as ``rvapply`` by operators that also check the batched output. _output_checked_rvapply.__name__ = _output_checked_rvapply.__qualname__ = "rvapply"