Skip to content
AI360Xpert
Math

Einsum and Tensor Shapes

Give every axis a letter, write down which letters survive, and the operation follows — replacing a stack of reshapes and transposes with one readable line.

Einsum names every axis with a letter: a letter appearing on both inputs but not the output is summed away, and a letter appearing everywhere is carried along untouched as a batch
Einsum names every axis with a letter: a letter appearing on both inputs but not the output is summed away, and a letter appearing everywhere is carried along untouched as a batch

Why Does This Exist?

Multi-head attention needs to multiply a (batch, heads, seq, dim) tensor by a (batch, heads, dim, seq) tensor, contracting only the dim axis. Written with transpose, reshape and matmul that is four or five lines, each with an axis order you have to hold in your head, and any one of them can be wrong in a way that still produces a plausible shape.

Written with einsum it is one line, and the line states its own contract:

scores = np.einsum("bhqd,bhkd->bhqk", queries, keys)

You can read off what happens without running anything. d appears on both inputs and not the output, so it is summed away. b and h appear everywhere, so they are carried along as batch. q and k each appear once, so they form the output grid. That is an attention score matrix, and the notation is the documentation.

This is the most practically useful page in the linear algebra section, because shape bugs are the most common bugs in ML and this is the tool that makes them visible.

Think of It Like This

A recipe that names its ingredients

Two ways to describe combining data.

The first: "transpose the second one, swap axes 1 and 2 of the first, flatten the last two dimensions, multiply, then unflatten." Every step is correct and the whole thing is unreadable. Six months later you cannot tell whether it is right, and neither can a reviewer.

The second: "each row of the first pairs with each column of the second, summing over the shared ingredient." Now you have named the parts and stated the relationship, and it is checkable by reading.

Einsum is the second style made executable. Every axis gets a letter, and three rules cover everything: a letter on both inputs and not the output gets summed over, a letter appearing on both inputs and the output is matched element by element, and a letter appearing once is just carried through.

The payoff is that mistakes become visible rather than silent. Get an axis order wrong in a chain of transposes and you get a plausible wrong answer. Get a letter wrong in einsum and the shapes usually do not line up, so it fails immediately.

How It Actually Works

The notation is "input1,input2->output", with one letter per axis. Three rules:

  1. A letter in the inputs but not the output is summed over (contracted).
  2. A letter in both inputs and the output is matched positionally — a batch or elementwise axis.
  3. Output letter order sets the output axis order, so permuting letters transposes for free.

Every familiar operation is a special case:

  • "ij->ji" — transpose. One input, letters reordered.
  • "ij,jk->ik" — matrix multiplication. j is contracted.
  • "i,i->" — dot product. Everything summed, scalar out.
  • "ii->" — trace. A repeated letter on one input takes the diagonal.
  • "i,j->ij" — outer product. Nothing shared, nothing summed.
  • "ij->i" — row sums. j dropped, so j is summed.
  • "bij,bjk->bik" — batched matmul. b carried, j contracted.

That is the entire API. Once you see that sum, transpose, matmul, trace, outer and diagonal are all one operation with different letters, the notation stops feeling exotic.

Why it is safer than the alternatives

It states the contract. "bhqd,bhkd->bhqk" tells a reader what the axes mean. x.transpose(0,1,3,2) @ y does not.

It catches errors early. Mismatched letters produce an immediate shape error, whereas a wrong transpose often yields a valid shape and wrong numbers.

It removes intermediate allocations. np.einsum("ij,jk,kl->il", A, B, C) can plan the contraction, and with optimize=True it chooses the cheaper association order. That matters: multiplying (100×1000)(1000×100)(100×1000)(100 \times 1000)(1000 \times 100)(100 \times 1000) left-to-right versus right-to-left differs by an order of magnitude in work.

Broadcasting is explicit. Ellipsis "...ij,...jk->...ik" handles any number of leading batch axes without you counting them.

Shape discipline that pays off regardless

Einsum is most useful alongside three habits:

  • Name your axes in comments or types# (batch, seq, heads, dim) above any nontrivial tensor.
  • Assert shapes at boundariesassert scores.shape == (b, h, q, k). Cheap, and converts a silent wrong answer into a failure at the responsible line.
  • Know that reshape is not transpose. Reshape re-cuts memory order; transpose moves data. Using reshape to reorder axes succeeds, matches the element count, and scrambles the contents.

Worked example

Take AA of shape (2,3)(2,3) and BB of shape (3,2)(3,2):

A=[123456],B=[100111]A = \begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{bmatrix}, \qquad B = \begin{bmatrix} 1 & 0 \\ 0 & 1 \\ 1 & 1 \end{bmatrix}

Write it as "ij,jk->ik" with i=2i = 2, j=3j = 3, k=2k = 2. The letter jj appears on both inputs and not the output, so it is summed — and it must match, which it does at 3. The output is (i,k)=(2,2)(i,k) = (2,2).

Compute each entry as a sum over jj:

  • (0,0)(0,0): 11+20+31=41{\cdot}1 + 2{\cdot}0 + 3{\cdot}1 = 4
  • (0,1)(0,1): 10+21+31=51{\cdot}0 + 2{\cdot}1 + 3{\cdot}1 = 5
  • (1,0)(1,0): 41+50+61=104{\cdot}1 + 5{\cdot}0 + 6{\cdot}1 = 10
  • (1,1)(1,1): 40+51+61=114{\cdot}0 + 5{\cdot}1 + 6{\cdot}1 = 11
AB=[451011]AB = \begin{bmatrix} 4 & 5 \\ 10 & 11 \end{bmatrix}

Now change only the output letters. "ij,jk->ki" gives the transpose, [410511]\begin{bmatrix} 4 & 10 \\ 5 & 11\end{bmatrix}, with no transpose call — just a different letter order.

And "ij->i" on AA alone sums each row: 1+2+3=61+2+3 = 6 and 4+5+6=154+5+6 = 15, giving [6,15][6, 15]. The letter jj is absent from the output, so it is summed. Same rule, no special case.

The diagram's "bij,bjk->bik" is this with a batch letter added: (2,3,4)(2,3,4) and (2,4,5)(2,4,5) give (2,3,5)(2,3,5), with j=4j = 4 matching and vanishing.

Show Me the Code

import numpy as np
A = np.array([[1, 2, 3], [4, 5, 6]])          # (2, 3) = (i, j)B = np.array([[1, 0], [0, 1], [1, 1]])        # (3, 2) = (j, k)
print(np.einsum("ij,jk->ik", A, B).tolist())   # -> [[4, 5], [10, 11]]print(np.einsum("ij,jk->ki", A, B).tolist())   # -> [[4, 10], [5, 11]]  transposedprint(np.einsum("ij->i", A).tolist())          # -> [6, 15]   row sumsprint(np.einsum("ij->ji", A).shape)            # -> (3, 2)    transpose
S = np.array([[1, 2], [3, 4]])print(np.einsum("ii->", S), np.einsum("i,i->", A[0], A[0]))  # -> 5 14  trace, dot
# Batched attention scores: contract d, keep batch and head.q = np.random.default_rng(0).standard_normal((2, 4, 6, 8))   # (b, h, q, d)k = np.random.default_rng(1).standard_normal((2, 4, 6, 8))   # (b, h, k, d)print(np.einsum("bhqd,bhkd->bhqk", q, k).shape)              # -> (2, 4, 6, 6)

The matrix product, its transpose, and the row sums all match the hand calculation. The last line is the real payoff: one readable line for what would otherwise be several transposes.

Watch Out For

Reaching for einsum where a matmul is faster

Einsum is a general contraction engine, and generality costs. For a plain 2-D matrix product, A @ B dispatches straight to a tuned BLAS kernel; np.einsum("ij,jk->ik", A, B) may go through a slower generic path, and on large matrices that difference is substantial.

The specific trap is multi-operand contractions without optimize=True. By default NumPy contracts left to right, which can build an enormous intermediate. Contracting (100×1000)(1000×100)(100×1000)(100 \times 1000)(1000 \times 100)(100 \times 1000) in the wrong order does roughly ten times the work of the right order, and optimize=True picks the right one for you. There is no reason to omit it on a three-or-more-operand einsum.

So use einsum where it earns its place: batched contractions with several axes, anything where the axis bookkeeping is the hard part, and any expression you want a reader to be able to check. For a straight matrix product, @ is clearer and faster. Readability is the main argument for einsum, and it is a good argument — just not one that survives being 5× slower in a training loop's inner step. Profile before committing either way.

Silently summing an axis you meant to keep

The rule that makes einsum powerful is also its sharpest edge: any letter you omit from the output is summed away, with no warning.

Write "bij,bjk->ik" instead of "bij,bjk->bik" and you have summed over the batch, collapsing every example in the batch into one matrix. The operation succeeds, the result has a perfectly reasonable shape, and your model is now mixing information across examples that must stay separate. This is a data-leakage bug as much as a correctness one, and it is invisible unless you check the shape.

The same edge cuts on the input side. A repeated letter within one operand takes a diagonal, not an elementwise product — "ii->i" extracts the diagonal of a square matrix. Write a repeated letter by accident and you get a much smaller tensor than intended.

Two defences. Count the letters: every axis you want to survive must appear on the right. And assert the output shape immediately after any nontrivial einsum, naming the axes in the assertion. An assert out.shape == (b, h, q, k) line costs nothing and turns this entire class of bug into an immediate, located failure.

The Quick Version

  • Notation is "in1,in2->out", one letter per axis.
  • A letter absent from the output is summed. A letter in both inputs and the output is matched. Output letter order sets the axis order.
  • Everything is a special case: ij->ji transpose, ij,jk->ik matmul, i,i-> dot, ii-> trace, i,j->ij outer, ij->i row sums, bij,bjk->bik batched matmul.
  • It states its own contract, which makes it reviewable — the main reason to use it.
  • Errors usually surface as shape mismatches rather than wrong numbers.
  • Use optimize=True on three or more operands; the contraction order can differ by an order of magnitude.
  • ... handles any number of leading batch axes.
  • For a plain 2-D product, @ is clearer and faster. Use einsum for genuinely multi-axis work.
  • Omitting a letter from the output silently sums that axis — including the batch. Assert the output shape.
  • A repeated letter on one operand takes a diagonal, not an elementwise product.

Related concepts