Working with LinOp¶
This page walks through the main features of LinOp using a simple 2D unitary
FFT as a running example (see concrete.DFT). Every concept introduced here
applies to any operator you build on top of LinOp and are present or are used
in operators in concrete.
The idea is to be helpful, easy to use, and to eliminate repetitive tasks — without doing the work for you or thinking for you.
Subclassing LinOp: forward and adjoint¶
The only requirement is to subclass LinOp and implement forward and
adjoint. The constructor must call super().__init__ with ishape and
oshape — the shapes of the input and output arrays.
import numpy as np
from aljabr import LinOp, Array, Shape
class FFT2(LinOp):
"""Unitary 2D FFT — maps an image to its Fourier coefficients."""
def __init__(self, shape: Shape):
super().__init__(ishape=shape, oshape=shape, name="F")
def forward(self, x: Array) -> Array:
return np.fft.fft2(x, norm="ortho") # F·x
def adjoint(self, y: Array) -> Array:
return np.fft.ifft2(y, norm="ortho") # Fᴴ·y
The unitary normalization (norm="ortho") ensures \(F^H F = I\): the FFT is an
isometry, so the adjoint equals the inverse.
Optimizing with fwadj¶
fwadj computes \(F^H \cdot F \cdot x\) in one shot. By default it calls
adjoint(forward(x)), which is correct but not always optimal. When you know a
cheaper formula — as here, where \(F^H F = I\) — override it:
def fwadj(self, x: Array) -> Array:
return x # Fᴴ·(F·x) = x since the FFT is unitary
This short-circuits two FFT calls into a no-op. The optimisation is optional but
matters in iterative algorithms that call fwadj thousands of times.
Shapes: ishape and oshape¶
Shapes are fixed at construction and describe the array shape of the input
and output — not flattened vectors. They are required: this is a design decision
and different from the np.fft.fft2 function.
F = FFT2((256, 256)) # Create the operator (like a matrix)
print(F.ishape) # (256, 256)
print(F.oshape) # (256, 256)
x = np.empty(F.ishape)
coef = F.forward(x)
np.allclose(F.adjoint(coef), x)
For a non-square operator (e.g., a difference along axis 0):
from aljabr import Diff
D = Diff(axis=0, ishape=(256, 256))
print(D.ishape) # (256, 256)
print(D.oshape) # (255, 256) — one fewer row after diff on axis 0.
Diff is a concrete operator that asks only for ishape at creation since
oshape can be deduced (as FFT2 above).
Attributes and derived properties¶
Once constructed, several attributes and read-only properties are available:
Name |
Type |
Description |
|---|---|---|
|
|
Input array shape |
|
|
Output array shape |
|
|
|
|
|
|
|
|
|
|
|
Human-readable label |
print(F.isize) # 65536 (= 256*256)
print(F.shape) # (65536, 65536)
print(repr(F)) # F (FFT2): (256, 256) → (256, 256)
Practical conveniences¶
Calling the operator¶
F(x) is the same as F.forward(x):
y = F(x) # equivalent to F.forward(x)
Column-vector interface: matvec and rmatvec¶
matvec and rmatvec expect column vectors for inputs and outputs, of shape
(isize, 1) and (osize, 1) respectively. They are useful when an algorithm
expects 1D vectors regardless of the operator’s native shapes:
x_col = x.reshape(-1, 1) # (65536, 1)
y_col = F.matvec(x_col) # y = Fx — forward on flat vector x
u_col = F.rmatvec(y_col) # u = yᵀ F = Fᴴ y — adjoint on flat vector y
Internally, matvec reshapes to ishape, calls forward, then flattens the
result; rmatvec does the same through adjoint.
String representation¶
__repr__ returns a compact description
repr(F) # "F (FFT2): (256, 256) → (256, 256)"
with name, class, ishape → oshape.
SciPy compatibility¶
Any LinOp can be passed directly to SciPy functions that accept a
LinearOperator (e.g., scipy.sparse.linalg.eigsh, cg, lsqr). SciPy’s
aslinearoperator recognises objects that expose matvec, rmatvec, and
shape — which every LinOp does:
import scipy.sparse.linalg as spla
AtA = spla.aslinearoperator(F) # wraps F transparently
vals = spla.eigsh(AtA, k=5, return_eigenvectors=False)
LinOp objects can also be passed directly without the wrapping step to
functions that call aslinearoperator internally.
Algebraic operators¶
LinOp overloads the standard Python operators for transparent composition.
+ and -¶
import numpy as np
from aljabr import Identity, Diag
I = Identity((256, 256))
W = Diag(np.full((256, 256), 0.5)) # element-wise ½
C = I + W # AddOp — forward: x + 0.5·x
D = I - W # SubOp — forward: x − 0.5·x
Both operands must share the same ishape and oshape (even if they have the same shape).
* — apply or scale¶
When the right operand is an array, * applies the operator:
y = F * x # F.forward(x), same as F(x)
When the right operand is a scalar, * returns a scaled operator:
G = 2.0 * F # Scaled — G.forward(x) = 2 * F.forward(x)
When the right operand is a LinOp, * returns a composition:
H = F * D # ProdOp — H.forward(x) = F.forward(D.forward(x))
@ — matrix-multiply style¶
@ behaves like * but uses matvec/rmatvec for array operands (column
vectors), and detects the special case \(A^H \cdot A\):
# On arrays (column vectors): uses matvec
y_col = F @ x_col # F.matvec(x_col)
# On LinOp: composition
H = F @ D # ProdOp
# Special case: FᴴF → Symmetric automatically
S = F.H @ F # Symmetric (not a generic ProdOp)
S = F @ F.H # also Symmetric
This automatic detection avoids wrapping \(A^H A\) in a generic ProdOp when a
Symmetric is the right result.
asmatrix — materialise the operator¶
asmatrix() builds the explicit dense matrix by applying forward to each
canonical basis vector. Useful for debugging and small operators:
M = F.asmatrix() # shape (65536, 65536) — very large for 256²!
# More realistic example
D_small = Diff(axis=0, ishape=(4, 4))
print(D_small.asmatrix())
The module-level function asmatrix(linop) works the same way and also accepts
plain arrays.
Warning
asmatrix allocates an (osize, isize) matrix and calls forward isize
times. For a 256² image that is 65 536 calls and a 32 GB matrix. Use only for
small operators or debugging.
Moreover it is recommended to use the like parameter to pass an array of the
same type, dtype, and on the same device as the operator for best performance,
otherwise the library will certainly do conversion or raise an error. asmatrix
assumes NumPy float64 by default.
Automatic timing and shape checking¶
Every LinOp subclass automatically gets two runtime behaviours applied to its
methods.
timeit— wraps__init__,forward,adjoint, andfwadj. After each call the elapsed time is stored in themetadatadict on the instance.forwardandadjointalso accumulate a full history in lists:F = FFT2((256, 256)) y = F.forward(x) print(F.metadata["init"]) # construction time (seconds) print(F.metadata["forward"]) # [t1, t2, …] — all calls print(F.metadata["forward"][-1]) # seconds, last call print(F.metadata["adjoint"]) # same for adjoint
checkshape— wrapsforward,adjoint, andfwadj. Emits aUserWarningif an input or output array has the wrong shape. This catches shape bugs early without raising hard errors.
Both decorators are applied transparently — you do not need to do anything in your subclass.
BaseOp — defining an operator without subclassing¶
Instead of subclassing you can use BaseOp with a pair of callables
from aljabr import BaseOp
fft2_op = BaseOp(
forward=lambda x: np.fft.fft2(x, norm="ortho"),
adjoint=lambda y: np.fft.ifft2(y, norm="ortho"),
ishape=(256, 256),
oshape=(256, 256),
name="F",
)
BaseOp accepts an optional fwadj callable for the same optimisation as
above. It is otherwise identical to a hand-written subclass.
The adjoint: Adjoint and .H¶
.H returns the adjoint as a proper LinOp, not just a callable:
Fh = F.H # Adjoint of F
y = Fh.forward(x) # = F.adjoint(x)
Adjoint is an involution — it avoids creating a chain of wrappers:
F.H.H is F # True — double adjoint cancels
For operators that know their own adjoint (Symmetric, Adjoint), .H
delegates to them directly rather than wrapping in a new Adjoint.
Symmetric and .G — the Gram operator \(F^H F\)¶
F.G returns the Gram operator \(F^H F\) as a Symmetric:
FtF = F.G # Symmetric wrapping F.fwadj
z = AtA.forward(x) # = F.fwadj(x) (optimised path if overridden in F)
Adjoint(A) is A for any Symmetric instance (same apply fo the .H)
@ and the automatic detection¶
The @ operator detects the pattern \(A^H \cdot A\) at composition time:
AtA = F.H @ F # Symmetric, or F.G, not a ProdOp(Adjoint(F), F)
AAt = F @ F.H # also Symmetric, F.H.G (but does not use an optimized fwadj)
The second form can’t use an optimized version of fwadj since Adjoint,
obtained from F.H don’t have one.
For a Symmetric S, forward and adjoint are identical (no separate adjoint
computation). fwadj inherits from LinOp and computes S²x, which is the
correct SᴴS for a self-adjoint operator. When you override fwadj in your
subclass — as in the FFT2 example above — the Symmetric produced by .G or
@ automatically uses that override.
Array-agnostic operators¶
LinOp is not tied to NumPy and uses the array API standard with the help of
array-api-compat. This is also the case for many derived LinOp and concrete
operators.
As the author of forward and adjoint, you can use the array library you want
or even make them array-agnostic too. However, asmatrix, which builds canonical
basis vectors, needs to know the backend and dtype if different from NumPy
float64.