Linear operators¶
A LinearOperator is a linear map that knows how to apply itself. It has a shape
and a dtype like an array, but it does not have to store one.
import jax.numpy as jnp
import linox
op = linox.Diagonal(jnp.array([1.0, 2.0, 3.0]))
assert op.shape == (3, 3)
assert (op @ jnp.ones(3)).shape == (3,)
Diagonal stores three numbers and represents a 3×3 matrix. That gap is the point
of the library: for a Kronecker product of two 1000×1000 factors it is two million
numbers standing in for a matrix with 10¹² entries.
The minimum an operator provides¶
Every operator supplies a matrix-vector product. Most also supply a dense form, used only when something genuinely requires the matrix:
import jax.numpy as jnp
import linox
op = linox.Diagonal(jnp.array([1.0, 2.0, 3.0]))
matvec = op @ jnp.ones(3) # what the operator is for
dense = linox.todense(op) # what it stands for
assert dense.shape == (3, 3)
assert jnp.allclose(dense @ jnp.ones(3), matvec)
todense is always available and almost always the wrong thing to reach for. See
Avoiding densification.
Right-hand sides¶
An operator accepts a vector or a block of columns, and the two agree:
import jax
import jax.numpy as jnp
import linox
op = linox.Diagonal(jnp.arange(1.0, 5.0))
rhs = jax.random.normal(jax.random.PRNGKey(0), (4, 3))
block = op @ rhs
columns = jnp.stack([op @ rhs[:, j] for j in range(3)], axis=-1)
assert block.shape == (4, 3)
assert jnp.allclose(block, columns)
Transpose¶
.T returns an operator, preserving structure where the operator knows its own
transpose:
import jax.numpy as jnp
import linox
d = linox.Diagonal(jnp.arange(1.0, 5.0))
assert isinstance(d.T, linox.Diagonal) # a diagonal is its own transpose
m = linox.Matrix(jnp.arange(6.0).reshape(2, 3))
assert m.T.shape == (3, 2)
For an operator with no structured transpose, .T is a lazy wrapper that derives
the adjoint from the forward matvec — it does not build the matrix.
The defining property holds for every operator:
import jax
import jax.numpy as jnp
import linox
key = jax.random.PRNGKey(0)
op = linox.Matrix(jax.random.normal(key, (5, 3)))
x = jax.random.normal(jax.random.fold_in(key, 1), (3,))
y = jax.random.normal(jax.random.fold_in(key, 2), (5,))
# <Ax, y> == <x, A^T y>
assert jnp.allclose(jnp.vdot(op @ x, y), jnp.vdot(x, op.T @ y))
Transpose, not adjoint
.T is a plain transpose. For complex operators that is not the conjugate
transpose. is_symmetric and is_hermitian distinguish the two correctly.
Operators are pytrees¶
Every operator is registered as a JAX pytree, so it flattens into its arrays and can
cross a jit boundary as an argument:
import jax
import jax.numpy as jnp
import linox
op = linox.Diagonal(jnp.arange(1.0, 5.0))
leaves, treedef = jax.tree_util.tree_flatten(op)
rebuilt = jax.tree_util.tree_unflatten(treedef, leaves)
assert jnp.allclose(rebuilt @ jnp.ones(4), op @ jnp.ones(4))
This is what makes jax.jit(f)(operator, x) work. See
JIT and autodiff.