Tensors & einsum
Generalize to higher-order tensors with index notation and the Einstein summation convention behind einsum.
A matrix is an array indexed by two indices; a weight tensor in a neural network
might have four. Index notation lets you write any contraction (dot product,
matrix multiply, batch outer product, attention scores) in a single, unambiguous
expression, and numpy.einsum evaluates it directly.
What a tensor is
A tensor of order over is a multidimensional array indexed by integers[1]. Order-0 is a scalar, order-1 a vector, order-2 a matrix. An order-3 tensor has entry for , , .
Tensors are not just "multidimensional arrays": they transform in a specific way under a change of basis. For the purposes of machine learning, the transformation law matters mainly when reasoning about equivariance; in computation, the array viewpoint suffices.
The Einstein summation convention
In the Einstein convention, a repeated index appearing once "up" and once "down" (or in practice: in two factor arrays) is summed over its full range[2]:
The summation sign is dropped; the repeated implies it. This makes long expressions readable:
| Operation | Conventional | Einstein / einsum |
|---|---|---|
| Dot product | → scalar | |
| Matrix–vector | → | |
| Matrix multiply | → | |
| Trace | → scalar | |
| Outer product | (no sum) | → |
| Batch matmul | bij,bjk->bik |
Numpy's einsum
np.einsum(subscript, *arrays) reads a string like 'ij,jk->ik' and executes
the corresponding index expression:
- Letters before
->label the indices in each input array. - Letters after
->label the indices of the output. - Letters that appear in inputs but not in the output are summed over.
The notation is unambiguous: there is no convention about row-major vs. column- major, no hidden transpose. The subscript string is the computation.
Broadcasting vs. contraction
Indices that appear in only one input array appear in the output: they are free (broadcast). Indices shared between two inputs that do not appear in the output are contracted (summed). For example:
'i,j->ij': outer product (, no contraction).'i,i->': dot product (, contracts , output is scalar).'ij,ij->ij': elementwise product (no contraction, both indices free).'ij,ij->': Frobenius inner product (contracts both, scalar output).
Check it on arrays
import numpy as np
u = np.array([1.0, 2.0, 3.0])
v = np.array([4.0, 5.0, 6.0])
A = np.arange(1.0, 7.0).reshape(2, 3)
B = np.arange(1.0, 7.0).reshape(3, 2)
# Dot product
print("dot:", np.einsum("i,i->", u, v), "==", u @ v) # 32
# Outer product
C = np.einsum("i,j->ij", u, v)
print("outer shape:", C.shape, "== ", np.outer(u, v).shape)
# Matrix multiply
D = np.einsum("ij,jk->ik", A, B)
print("matmul:\n", D, "\n", A @ B) # identical
# Trace
M = np.array([[1.0, 2.0], [3.0, 4.0]])
print("trace:", np.einsum("ii->", M), "==", np.trace(M)) # 5
# Batch matrix multiply: (batch, m, k) x (batch, k, n) -> (batch, m, n)
batch = 4
X = np.random.default_rng(0).normal(size=(batch, 3, 5))
Y = np.random.default_rng(1).normal(size=(batch, 5, 2))
Z = np.einsum("bik,bkj->bij", X, Y)
Z_ref = np.stack([X[b] @ Y[b] for b in range(batch)])
print("batch matmul error:", np.max(np.abs(Z - Z_ref))) # ~0
# Gradient of a bilinear form x^T A y w.r.t. x: result is Ay
x = np.array([1.0, 2.0])
y = np.array([3.0, 4.0])
A2 = np.array([[1.0, 2.0], [3.0, 4.0]])
# f = x_i A_ij y_j; df/dx_k = A_kj y_j
grad_x = np.einsum("kj,j->k", A2, y) # same as A @ y
print("bilinear grad:", grad_x, "==", A2 @ y)Every operation resolves to the same result as its explicit loop or built-in equivalent. Index notation makes the computation visible and the gradient derivation mechanical: just differentiate the subscript expression index by index.
Exercises
Exercise 1. Write each operation as an einsum subscript string, naming
which indices are free and which are contracted: (a) the trace of , (b) the
matrix-vector product , (c) the Frobenius inner product .
Solution
Step 1. Trace. repeats on one
array and keeps nothing, so is contracted: "ii->". Output is a scalar.
Step 2. Matrix-vector. shares between and
with absent from the output, so is contracted and is free:
"ij,j->i".
Step 3. Frobenius inner product. shares both
and between the two arrays and keeps neither, so both are contracted:
"ij,ij->". Output is a scalar.
Exercise 2. Two students disagree about np.einsum("ij,jk->ki", A, B). One
says it is ; the other says it is . Settle it by reading the
output indices, and state the shape if is and is .
Solution
Step 1. The body contracts (shared, absent from output), producing an object with free indices and . The plain product is exactly .
Step 2. The output specifier is ki, not ik, so the result entry at row
, column holds . That is . The second student is right.
Step 3. Shapes: is , so its transpose is . The output is .
Exercise 3 (code). The attention score matrix in a transformer is for queries and keys . Express it as a single
einsum call and verify it against on random arrays.
Solution
The index (the feature dimension) is shared between and and absent
from the output, so it is contracted; and are free. The subscript is
"id,jd->ij", which is .
import numpy as np
rng = np.random.default_rng(0)
Q = rng.normal(size=(5, 8)) # 5 queries, dim 8
K = rng.normal(size=(7, 8)) # 7 keys, dim 8
S = np.einsum("id,jd->ij", Q, K) # contract the feature index d
assert S.shape == (5, 7)
assert np.allclose(S, Q @ K.T)
print("max |S - Q K^T| =", np.max(np.abs(S - Q @ K.T)))Contracting the feature index leaves the query index and key index free, producing the score matrix that equals , the similarity scores attention then normalizes.
This closes the A1 curriculum on linear algebra; the next subsections build probability and calculus on top of these foundations.
References
- [1]Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning, ch. 2 (Linear Algebra). MIT Press. Tensors as multidimensional arrays in the machine-learning sense.↩
- [2]Kolda, T. G., & Bader, B. W. (2009). "Tensor decompositions and applications". SIAM Review, 51, 455-500. doi:10.1137/07070111X. Index notation and contraction for higher-order tensors.↩