datarekha

Einsum — one notation to rule them all

A single mental model — repeated indices sum, free indices stay — that expresses matmul, transpose, trace, and batched ops in one line.

9 min read Advanced NumPy Lesson 13 of 14

What you'll learn

  • The Einstein-summation rule that powers einsum
  • How to translate matmul, transpose, and trace into einsum
  • When einsum is the cleanest tool — and when it's a footgun

Before you start

The last lesson ended on a hunch: matmul, inner, outer, trace, transpose, batched matmul — a dozen named operations that are secretly one move, multiply then sum over shared axes. np.einsum (Einstein summation, the notation physicists use for tensor contractions) is that one move made explicit. The string 'bij,bjk->bik' looks intimidating the first time you meet it, but the rule underneath is a single sentence — and once it clicks you can write almost any tensor operation in one line.

The one rule

Indices that appear twice get summed. Indices that appear once stay.

That is the whole model. The string 'ij,jk->ik' reads as:

  • inputs have shapes (i, j) and (j, k)
  • j appears in both inputs and not in the output → sum over j
  • i and k appear once → keep them
  • so this is matrix multiplication
import numpy as np

rng = np.random.default_rng(0)
A = rng.standard_normal((3, 4))
B = rng.standard_normal((4, 5))

# These three lines compute the same thing
print(np.allclose(A @ B, np.einsum('ij,jk->ik', A, B)))
print(np.allclose(A @ B, np.matmul(A, B)))
True
True

'ij,jk->ik' and A @ B are the same operation — einsum just spells out which axis (j) gets contracted.

Tryeinsum

Hover an index to see it light up

The rule: repeated indices are summed (contracted); indices kept on the right survive (free).

ij,jk->ik
jsummedifreekfree
A123456ij
B123456jk
out22284964ik
Example(2×3) · (3×2) → (2×2)

Common ops as einsum

The same rule covers a surprising amount of linear algebra — each op one or two characters from the next:

import numpy as np

rng = np.random.default_rng(0)
A = rng.standard_normal((4, 4))
v = rng.standard_normal(4)
M = rng.standard_normal((3, 5))

# Transpose: just reorder the output indices
print("transpose:", np.allclose(np.einsum('ij->ji', M), M.T))

# Trace: sum of the diagonal — the same index twice in input, none in output
print("trace:   ", np.allclose(np.einsum('ii->', A), np.trace(A)))

# Inner product u . v
u = rng.standard_normal(4)
print("inner:   ", np.allclose(np.einsum('i,i->', u, v), u @ v))

# Outer product u v^T  — no repeated index
print("outer:   ", np.allclose(np.einsum('i,j->ij', u, v), np.outer(u, v)))

# Diagonal — single repeated index, kept in output
print("diag:    ", np.allclose(np.einsum('ii->i', A), np.diag(A)))

# Sum all elements
print("sum all: ", np.allclose(np.einsum('ij->', M), M.sum()))
transpose: True
trace:    True
inner:    True
outer:    True
diag:     True
sum all:  True

Six different operations, one rule. Flip 'ij->ji' and you have a transpose; collapse both indices of a square matrix with 'ii->' and you have a trace; keep the repeated index with 'ii->i' and it is the diagonal instead. That density is why research papers describe new architectures in einsum — it is more compact than prose and completely unambiguous.

Batched matmul — where einsum shines

Once you have a batch dimension, plain @ still works but reading the code gets harder. Einsum makes the intent obvious:

import numpy as np

rng = np.random.default_rng(0)

# 32 samples in a batch, each a 64x128 matrix multiplied by a 128x16 matrix
A = rng.standard_normal((32, 64, 128))
B = rng.standard_normal((32, 128, 16))

# Three equivalent ways
C1 = A @ B
C2 = np.matmul(A, B)
C3 = np.einsum('bij,bjk->bik', A, B)

print("shapes:", C1.shape, C2.shape, C3.shape)
print("agree:", np.allclose(C1, C3))
shapes: (32, 64, 16) (32, 64, 16) (32, 64, 16)
agree: True

The b index says “this dimension is independent — repeat the operation along it.” In 'bij,bjk->bik' the j contracts (the matmul) while b just rides along untouched. That convention scales: you can have several batch indices ('abij,abjk->abik') or different ones on each side.

Attention — the canonical einsum example

A scaled-dot-product attention layer computes Q Kᵀ over a batch of heads. Without einsum it is a tangle of transposes and reshapes; with einsum it is one line per operation:

import numpy as np

rng = np.random.default_rng(0)

# Standard transformer shapes
B, H, T, D = 2, 4, 16, 32   # batch, heads, tokens, head_dim
Q = rng.standard_normal((B, H, T, D))
K = rng.standard_normal((B, H, T, D))
V = rng.standard_normal((B, H, T, D))

# Scores = Q @ K^T  — one line, broadcast over (B, H)
scores = np.einsum('bhqd,bhkd->bhqk', Q, K) / np.sqrt(D)
print("scores shape:", scores.shape)   # (B, H, T, T) — token-to-token

# Softmax along the last axis, then mix V
def softmax(x):
    x = x - x.max(-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(-1, keepdims=True)

attn = softmax(scores)
out  = np.einsum('bhqk,bhkd->bhqd', attn, V)
print("out shape:   ", out.shape)
scores shape: (2, 4, 16, 16)
out shape:    (2, 4, 16, 32)

The two einsum strings read like the math. 'bhqd,bhkd->bhqk' sums over d (the head dimension) to score every query against every key — leaving (B, H, T, T), a token-to-token matrix per head. 'bhqk,bhkd->bhqd' then sums over k (the keys) to mix the values back to (B, H, T, D). No transposes, no reshapes — and notice the stable softmax (subtract the max), straight from the numerical-stability lesson.

When einsum is the wrong tool

Einsum is expressive, but it can be slower than the specialized op: np.matmul routes to BLAS (your CPU’s hand-tuned linear-algebra library), and einsum’s contraction planner does not always take that path.

The other risk is readability for teammates. If A @ B already says what you mean, prefer it. Save einsum for the operations where the alternative is a gauntlet of transpose, reshape, and swapaxes.

In one breath

np.einsum runs on one rule: repeated indices sum, free indices stay. 'ij,jk->ik' is matmul (contract j); 'ij->ji' transpose; 'ii->' trace; 'ii->i' diagonal; 'i,j->ij' outer; 'ij->' sum-all — six ops, one notation. A batch index just rides along untouched ('bij,bjk->bik'), which makes batched/multi-head operations readable: attention is 'bhqd,bhkd->bhqk' (scores, contract d) then 'bhqk,bhkd->bhqd' (mix values, contract k), with no transposes. Caveats: einsum can miss the BLAS fast path (np.matmul is often faster — use optimize=True and benchmark hot loops), and if A @ B already reads clearly, prefer it.

Practice

Quick check

0/4
Q1What does `np.einsum('ij,j->i', A, x)` compute when A is (m, n) and x is (n,)?
Q2What does `'ii->'` mean (note: empty output)?
Q3When might einsum be slower than the equivalent `@` for batched matmul?
Q4A is (5, 3) and B is (3, 4). What einsum string computes the element-wise sum of all products — a single scalar?

A question to carry forward

Look back at almost every code block in this entire NumPy section. Each one opened with the same incantation — rng = np.random.default_rng(seed) — conjuring reproducible random data we leaned on for benchmarks, fake datasets, weight matrices, and these very attention inputs, yet never once examined.

The final NumPy lesson pays that debt. What is a Generator, why has default_rng replaced the old np.random.rand, how does a single seed make “random” perfectly reproducible across machines and runs, and which distributions, shuffles, and choices power the simulations, train/test splits, weight initialisation, and dropout you will reach for every day?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
Why is NumPy significantly faster than Python for-loops for numerical computation, and what is vectorization?

NumPy operations execute compiled C code over contiguous memory blocks in a single call, while a Python loop incurs interpreter overhead and dynamic type checks on every element. Vectorization means expressing an operation over an entire array at once so the hot path never re-enters the Python interpreter.

When should you use apply, map, or applymap versus vectorized pandas operations, and what are the performance implications?

Vectorized pandas and NumPy operations operate on entire arrays in compiled C/Fortran code and should always be your first choice. apply runs a Python function row- or column-wise in a Python loop, map transforms a single Series element-by-element, and applymap (DataFrame.map in pandas 2.1+) applies a function to every scalar — all three are orders of magnitude slower than vectorized equivalents.

When would you use a Python list versus a NumPy array, and what are the performance trade-offs?

Python lists are heterogeneous, pointer-based, and general-purpose. NumPy arrays are homogeneous, stored as contiguous typed memory, and support vectorised operations that run at C speed. For numerical work on more than a few hundred elements, NumPy is almost always faster and more memory-efficient.

Find all unique triplets in an array that sum to zero (3Sum).

Sort the array, then fix one element at a time and run a two-pointer search on the remaining right portion to find pairs that sum to its negation. Careful duplicate-skipping at both the outer loop and the inner pointers is what makes the result unique. Overall complexity is O(n²).

Related lessons

Explore further

Skip to content