minimal-linop
Linear operators with exact adjoints in PyTorch, composable with @, +, * and .H. One dependency, batch-transparent, device-agnostic.
Most of computational imaging is y = A x for a linear A that is far too large to store: FFTs, masks, crops, shifts, convolutions, their compositions. Reconstruction needs A and its adjoint Aᴴ (that is where every gradient comes from), and it needs them to be exactly adjoint or solvers drift. This library gives you a small catalogue of such operators, each with a tested adjoint, and the algebra to combine them into forward models.
Install
pip install minimal-linop
pip install "minimal-linop[fft]" # adds minimal-zoom-fft for LinOpZoomFft
pip install git+https://github.com/jon-dong/minimal-linop # the development version
Requires Python ≥ 3.10 and PyTorch ≥ 2.0.
Quick start
import torch
from minimal_linop import LinOpFft, LinOpMul, LinOpCrop, LinOpConv, adjoint_error, operator_norm
# A coherent imaging model: crop a window from the object, multiply by a
# probe, take the 2-D FFT. Every operator acts on the last two axes.
crop = LinOpCrop(in_shape=(256, 256), out_shape=(64, 64))
probe = LinOpMul(torch.randn(64, 64, dtype=torch.complex64))
F = LinOpFft(dim=(-2, -1))
A = F @ probe @ crop # (A @ B)(x) = A(B(x))
x_true = torch.randn(8, 256, 256, dtype=torch.complex64) # a batch of 8 objects
b = A @ x_true # measurements, shape (8, 64, 64)
# The adjoint is what a gradient needs: for L(x) = ½‖A x − b‖², ∇L = Aᴴ(A x − b).
x = torch.zeros_like(x_true)
grad = A.H @ (A @ x - b)
# Check any operator with the dot-product test (round-off: < 1e-5 in single precision).
print(adjoint_error(A, x_true))
# The spectral norm, e.g. for a gradient step 1 / ‖A‖²: power iteration on AᴴA.
print(operator_norm(A, x_true))
# An incoherent model: blur by a point-spread function stored with its
# centre in the middle of the array, hence the ifftshift.
psf = torch.rand(256, 256)
blur = LinOpConv(torch.fft.ifftshift(psf / psf.sum()))
Algebra
Every expression below returns a new operator; nothing is materialised.
| Expression | Meaning |
|---|---|
A.apply(x), A(x), A @ x |
forward action A x |
A.applyT(y), A.H @ y |
adjoint action Aᴴ y |
A @ B |
composition, (A @ B)(x) = A(B(x)) |
A + B, A - B, -A |
sum and difference |
c * A |
scaling by a scalar; the adjoint scales by conj(c) |
sum([A, B, C]) |
the sum of a list (0 + A returns A) |
A.H.H |
A itself |
Aᴴ is the Hermitian adjoint for the inner product ⟨u, v⟩ = Σ conj(u) v. For a real operator that is the transpose; for a complex one it is not, which is why there is no .T alias.
Shapes: in_shape / out_shape describe the trailing axes an operator acts on; None means shape-agnostic (an FFT accepts any length) and is compatible with everything. Sums and compositions check declared shapes at construction. A composition fills a shape the shape-agnostic side leaves undeclared from the other side, which assumes that side preserves shape; an operator that does not says so with preserves_shape = False (LinOpGrad, LinOpSumReduce, a LinOpCat of several operators) and the composition leaves the shape unknown rather than guessing it wrong.
Two declared shapes must be equal to compose: with M of shape (5, 8), LinOpMatrix(M) @ LinOpCrop((8, 8), (4, 8)) is refused, although the matrix applies to the last axis of the cropped image. Wrap such an operator A so that it declares nothing, W = LinOpFunction(A.apply, A.applyT), and set W.preserves_shape = False if it changes the shape. Declared shapes are compared when operators are composed or added, not when an operator is applied: an input whose trailing shape is not in_shape is not refused, so that check is yours where it matters. At construction a size, a factor or an axis must be an integer (a NumPy integer counts, a float does not) and a size cannot be negative.
Catalogue
Formulas are written along one axis, n indexing the output and N the axis length; N-D operators apply them on every axis they act on. Indices are taken modulo N where the formula says circular.
| Operator | A x |
Aᴴ y |
|---|---|---|
LinOpIdentity() |
x |
y |
LinOpMul(c) |
c ⊙ x (diagonal; c may broadcast) |
conj(c) ⊙ y |
LinOpReal(), LinOpImag() |
Re x, Im x (complex → real) |
y + 0i, i y |
LinOpSumReduce(dim, size) |
Σₙ x[n] over one axis, kept with length 1 |
y broadcast back to size |
LinOpMatrix(M) |
M x on the last axis |
Mᴴ y |
LinOpFunction(f, fT) |
f(x) |
fT(y) |
LinOpCat([A_k]) |
cat([A_k x], dim=-1) |
Σₖ A_kᴴ yₖ, yₖ the columns of A_k x |
LinOpFft(dim, norm), LinOpIfft |
fftn / ifftn (norm="ortho" by default, unitary) |
the opposite transform with the conjugate norm |
LinOpFftShift(dim) |
fftshift |
ifftshift |
LinOpZoomFft(in_shape, out_shape, k_start, k_end, ...) |
zoom_fft on a band (needs minimal-zoom-fft) |
zoom_ifft with the conjugate norm |
LinOpRoll(shifts, dim, pad_zeros) |
x[n − s], circular; with pad_zeros, 0 where n − s falls outside [0, N) |
y[n + s], same rule |
LinOpConv(h) |
Σₘ h[m] x[n − m], circular, on the last h.ndim axes |
Σₘ conj(h[m]) y[n + m] (correlation) |
LinOpCrop(in_shape, out_shape, fourier_origin) |
x[c + n], n < out, c = in//2 − out//2; with fourier_origin, the first ⌈out/2⌉ and the last ⌊out/2⌋ samples |
zero-pad: y back where it was taken from, 0 elsewhere |
LinOpPatch(in_shape, out_shape, shifts, pad_zeros, fourier_origin) |
Crop(Roll(x)), defined by patch_by_crop_and_roll, gathering only the window |
scatter-add of y into zeros |
LinOpFlip(dim) |
x[N − 1 − n] |
itself |
LinOpGrad(ndim) |
(∇x)ₖ[n] = x[n + eₖ] − x[n], 0 at the last index; (..., *s) → (..., ndim, *s) |
Σₖ (yₖ[n − eₖ] − yₖ[n]) with yₖ = 0 at the last index and outside the grid (negative divergence) |
LinOpDownsample(in_shape, factor) |
x[f n] |
y[n / f] where f divides n, 0 elsewhere |
LinOpUpsample(in_shape, factor) |
x[⌊n / f⌋] |
Σⱼ y[f n + j], j < f, the sum of each block |
All operators act on trailing axes (dim defaults to the last axis; use dim=(-2, -1) or a 2-tuple in_shape for images), leave leading batch axes alone, work on CPU/CUDA/MPS, support autograd, and are out-of-place so they run under torch.func.vmap / jacrev.
Write your own
Subclass LinOp and implement the two methods; the algebra comes for free.
import torch
from minimal_linop import LinOp, adjoint_error
class CumSum(LinOp):
"""Running sum along the last axis; the adjoint is the running sum from the end."""
def apply(self, x):
return torch.cumsum(x, dim=-1)
def applyT(self, y):
return torch.cumsum(y.flip(-1), dim=-1).flip(-1)
S = CumSum()
assert adjoint_error(S, torch.randn(64, dtype=torch.complex64)) < 1e-5
Or wrap two functions: LinOpFunction(apply, applyT, in_shape, out_shape). Declare in_shape / out_shape when they are fixed so compositions can check them, and set preserves_shape = False if the operator leaves them undeclared but changes the shape. The name H is taken by the adjoint property, so do not use it for an attribute.
Checking operators
adjoint_error(A, x, y=None)returns|Re⟨A x, y⟩ − Re⟨x, Aᴴ y⟩| / (‖A x‖ ‖y‖), with the denominator falling back to‖x‖ ‖Aᴴ y‖whenA xoryvanishes. Every operator in this library is tested this way.operator_norm(A, x0, n_iter=50)estimates‖A‖₂by power iteration onAᴴA;x0fixes the shape, dtype and device. The estimate approaches‖A‖₂from below up to round-off (in single precision it can end slightly above), so a step size1/‖A‖²taken from it deserves a margin.to_matrix(A, in_shape=None, dtype=torch.complex64, device=None)materialises the dense matrix for small problems.deviceis where the basis vectors are created: give it when the operator holds tensors that are not on the default device. For a complex-linearA,to_matrix(A.H) == to_matrix(A).conj().T;LinOpRealandLinOpImagare linear over the reals only and have no complex matrix.
Conventions worth knowing
- Every
dimis negative. Operators act on the trailing axes, so an axis counted from the front, which would consume a batch axis, is rejected at construction. norm="ortho"is the default for the FFT operators, soAᴴ = A⁻¹for them. With"backward"or"forward"the adjoint is still exact, but it is the opposite transform with the other norm, not the inverse.LinOpCropcentres liketorch: it keeps indicesin//2 - out//2onward.fourier_origin=Truekeeps the low frequencies of a DC-in-the-corner spectrum.LinOpConvputs the origin of the kernel at index 0, liketorch.rolland the FFT: a delta at index 0 is the identity, a delta at index 1 isLinOpRoll(1). A point-spread function with its centre in the middle of the array goes throughtorch.fft.ifftshiftfirst. Real kernel and real input give a real output.LinOpMuldeclaresin_shape = out_shape = c.shape, unlesscis a scalar or has a size-1 axis: it then broadcasts, the shape it acts on is not determined byc, and it declares none.- An operator built from a tensor (
LinOpMul,LinOpConv,LinOpMatrix) lives on that tensor's device and torch raises on an input that is somewhere else, so build it where you will use it; there is no.to()to move one afterwards.LinOpPatchis the exception: its index tables follow the input's device. - Output dtype equals input dtype as long as the operator's own tensor has the input's precision.
LinOpMul,LinOpConvandLinOpMatrixpromote the two dtypes the way the corresponding product does, so a float64 kernel on a float32 input gives float64, and a real matrix on a complex input gives complex. - Outputs may be views of the input:
LinOpIdentityreturns its argument, the centralLinOpCrop,LinOpDownsample,LinOpRealandLinOpImagreturn views of it and the adjoint ofLinOpSumReducean expanded view, so clone a result before modifying it in place. LinOpRollshifts given as tensors are rounded to the nearest integer (ties to even) and read withint(), so operators can be built insidetorch.functransforms.LinOpCatsplits its adjoint input according to the sub-operators'out_shape. A sub-operator that declares none must preserve shape, and the columns left over by the declared ones are shared equally among those; a shape-changing sub-operator without a declaredout_shapeis refused rather than guessed.LinOpPatchis defined bypatch_by_crop_and_roll, the same window as aLinOpCrop @ LinOpRollcomposition. The definition is written first in the source and the tests hold the fast version to it, bit for bit.
Relation to other libraries
The same idea as scipy.sparse.linalg.LinearOperator, PyLops, GlobalBioIm and the physics classes of deepinv, reduced to what you need to write and verify forward models in PyTorch, readable in full.
The operators come from the LinOp framework of the ciel computational-imaging library (EPFL), where they drive phase-retrieval and ptychography models; the classes were reduced, re-derived and re-tested for this package.
Tutorials
Three notebooks in notebooks/, runnable after pip install -e ".[notebooks]":
- Why linear operators: reconstruction is
min ½‖Ax − b‖², its gradient isAᴴ(Ax − b); a coherent forward model built by composition, and what a sloppy adjoint does to the solver. - What it computes: every identity of the algebra, every adjoint of the catalogue and every convention, checked against brute force in float64.
- Benchmark: the overhead against hand-written torch,
LinOpPatchagainst its definition, batching,to_matrix, precision, and when not to use it.
Tests
pip install -e ".[test]" && pip install minimal-zoom-fft # or pip install -e ../minimal-zoom-fft
pytest # add ".[notebooks]" to run the tutorials as tests too
Every adjoint in the catalogue is checked with the dot-product test, and those of the complex-linear operators (all but LinOpReal and LinOpImag) against the dense matrix as well. LinOpConv is checked against a brute-force double sum, LinOpPatch against its definition, and the README's Python blocks are executed as a test.
License
MIT
Manifest
- Purpose: linear operators with exact adjoints, and the algebra to compose them, in PyTorch.
- Dependencies:
torch. Optional:minimal-zoom-fftforLinOpZoomFft. - Size: about 1100 lines of implementation in 7 modules, 29 public names (the base class, 24 operators, the patch definition and 3 helpers); about 1350 lines of tests; 3 tutorial notebooks.
- Origin: the
cielcomputational-imaging library, EPFL. - Provenance: written with Claude (Anthropic) from a brief; read and checked in full by Jonathan Dong.
- Version: 0.1.0, MIT.
Metadata
Release files for minimal-linop 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| minimal_linop-0.1.0.tar.gz | 40.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| minimal_linop-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 63.1 kB
Release files / minimal_linop-0.1.0.tar.gz
| Download URL | minimal_linop-0.1.0.tar.gz |
|---|---|
| Size | 40.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
be35aaa9c4b7bd3af7a307f81c2e246ae40a43d2baef5b3f9ce5f1ae0c35b2a4
|
|
BLAKE2b-256 checksum How to use checksums |
05f8fa34aa8fbb11977bb81ef41573721ff238acf9b6c81678ff9bafee569c86
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency logRelease files / minimal_linop-0.1.0-py3-none-any.whl
| Download URL | minimal_linop-0.1.0-py3-none-any.whl |
|---|---|
| Size | 23.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
20937e276c6fe8bedd445f532b221369d0f455b0b63fdb46c027fdb74aa98722
|
|
BLAKE2b-256 checksum How to use checksums |
b6fefc4f8ad751adc7ab0d92c8683b93889a3ac7acc92447c237241b0c6a5350
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency log