TAI BUI
Back to curriculum
BuildPython

Tensor Operations

Every image, every sentence, every gradient in deep learning is a tensor underneath. Learn its shape rules (reshape, transpose, broadcast, contract) and cryptic shape-mismatch errors turn into a checklist you can debug against, not a guessing game.

TIME: ~90 minutesPREREQUISITES: Phase 1, Lessons 01 (Linear Algebra Intuition), 02 (Vectors, Matrices & Operations)

Learning Objectives

  • Implement a tensor class with shape, strides, reshape, transpose, and element-wise operations from scratch
  • Apply broadcasting rules to operate on tensors of different shapes without copying data
  • Write einsum expressions for dot products, matrix multiplications, outer products, and batched operations
  • Trace the exact tensor shapes through every step of multi-head attention

The Problem

You build a transformer. The forward pass looks clean. You run it and get RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x768 and 512x768). You stare at the shapes, try a transpose, and now it says Expected 4D input (got 3D input). You add an unsqueeze. Something else breaks.

Shape errors are the most common bug in deep learning code. They aren't conceptually hard: each operation has a shape contract, and the trouble is how fast those contracts multiply. A transformer chains together dozens of reshapes, transposes, and broadcasts, so one wrong axis cascades into the next operation's failure. Some shape mistakes don't even throw an error. They broadcast along the wrong dimension or sum over the wrong axis and silently hand you garbage instead of a crash.

Matrices only handle pairwise relationships between two sets of things, and real data doesn't live in two dimensions. A batch of 32 RGB images at 224x224 is a 4D tensor: (32, 3, 224, 224). Self-attention with 12 heads is 4D too: (batch, heads, seq_len, head_dim). What you need is a data structure that generalizes to any number of dimensions, with operations that compose cleanly across all of them. That structure is the tensor. Master its operations and shape errors stop being mysterious — they become trivially debuggable.

Warm-up0/3
1. Multiplying a 2x3 matrix by a 3x4 matrix gives a matrix of what shape?Warm-up
A flow of a 2 by 3 shape and a 3 by 4 shape combining into a result shape marked with two question marks.
2. A grid has 3 rows and 4 columns of numbers. How many numbers does it hold in total?Warm-up
A blank 3 by 4 grid of cells with a note giving 3 rows and 4 columns, and a question mark for the total count.
3. A single color photo stores a red, a green, and a blue intensity value at every pixel. Which everyday description best matches that data?Warm-up
A 2 by 2 photo where every pixel cell lists its own red, green and blue numbers, with a note asking how that data would be stored. Pixel values are illustrative.
Check: The Problem0/2
1. Why can a shape mistake in a deep learning model produce garbage instead of a crash?Explain
A 2 by 3 matrix with two candidate sums, sum over axis 0 and sum over axis 1, both marked with a question mark, and a note asking whether either raises an error.
2. After a refactor a model trains and evaluates without any error but gives wrong outputs. Someone had put a .transpose(1, 2) on the wrong tensor. What explains the missing error?Analyze
A tensor shaped (2, 3, 3, 4) with two of its middle axes, both size 3, swapped, and a note asking whether the shape changes.

The Concept

What a tensor is

A tensor is a multi-dimensional array of numbers with a uniform data type. The number of dimensions is the rank (or order). Each dimension is an axis. The shape is a tuple listing the size along each axis.

Mermaid diagram

Total elements = product of all sizes. A shape (2, 3, 4) holds 2 * 3 * 4 = 24 elements.

Tensor shapes in deep learning

Different data types map to specific tensor shapes by convention.

Mermaid diagram

PyTorch uses NCHW (channels-first). TensorFlow defaults to NHWC (channels-last). Mismatched layouts cause silent slowdowns or errors.

How memory layout works

A 2D array in memory is a 1D sequence of bytes. Strides tell you how many elements to skip to move one step along each axis.

Mermaid diagram

Transpose doesn't move data. It swaps the strides, which makes the tensor non-contiguous: the elements for a row are no longer adjacent in memory.

Broadcasting rules

Broadcasting lets you operate on tensors of different shapes without copying data. Align shapes from the right. Two dimensions are compatible when they are equal or one is 1. Fewer dimensions get padded with 1s on the left.

Tensor A:     (8, 1, 6, 1)
Tensor B:        (7, 1, 5)
Padded B:     (1, 7, 1, 5)
Result:       (8, 7, 6, 5)

Einsum: the universal tensor operation

Einstein summation labels each axis with a letter. Axes in the input but not the output get summed. Axes in both are kept.

Mermaid diagram

Key patterns: i,i-> (dot product), i,j->ij (outer product), ii-> (trace), ij->ji (transpose), bij,bjk->bik (batch matmul), bhtd,bhsd->bhts (attention scores).

Check: The Concept0/14
1. What is a tensor?Define
Three number collections side by side: a 5-entry list, a 2 by 3 grid, and a 2 by 2 by 2 stacked block, with a note asking what they share.
2. What does the rank of a tensor refer to?Define
The same list, grid and block from before, each marked with a question mark for its number of axes.
3. What does the 'shape' of a tensor describe?Define
A 3D block of numbers with its three axis sizes marked only as letters a, b and c, and a note asking how to write down the size along each axis.
4. What do NCHW and NHWC describe?Define
A 32 image batch written two ways: (32, 3, 224, 224) labelled NCHW and (32, 224, 224, 3) labelled NHWC, with a note asking what moved between the two.
5. What is a stride?Define
A 3 by 4 tensor shown as 12 numbers in a flat buffer and as a grid, with a note asking how many elements to skip to move one row down or one column right.
6. What does it mean for a tensor to be contiguous?Define
A 2 by 3 matrix next to its flat memory buffer 1, 2, 3, 4, 5, 6, with a note asking whether the flat order follows the grid row by row.
7. What is broadcasting?Define
A flow from an activations tensor shaped 4 by 3 and a bias shaped 3 to a sum marked with a question mark; the shapes do not match.
8. What is einsum?Define
An illustrative pattern 'xk,ky->xy' with two input tensors labelled by their axis letters and an unresolved output box, with a note about a letter shared by both inputs but missing from the output.
9. What is a contraction?Define
Two tensors P with axes a and k, and Q with axes k and b, sharing the letter k, pointing down to a result box whose axes are left as a question mark.
10. What does transposing a tensor do to its memory?Explain
A 2 by 3 matrix a next to a.T marked with a question mark, with a note asking whether the transpose gets a new flat buffer or reuses a's.
11. In the einsum expression 'bhtd,bhsd->bhts', what happens to the index 'd'?Explain
Q with axes b, h, t, d and K with axes b, h, s, d, both feeding an einsum whose output is bhts, with a question mark and a note that d is missing from that output.
12. Which einsum pattern computes the trace of a matrix (the sum of its diagonal elements)?Explain
A 3 by 3 matrix with its three diagonal cells, 1, 5 and 9, highlighted, and a note asking which compact einsum pattern captures exactly that diagonal sum.
13. What is the result shape when broadcasting tensors of shape (8, 1, 6, 1) and (7, 1, 5)?Apply
Two shapes, (8, 1, 6, 1) and (7, 1, 5), with a note about padding the shorter one with 1s on the left before comparing.
14. Adding a tensor of shape (3, 4) and a vector of shape (3,) raises an error. Why?Analyze
Two shapes, (3, 4) and (3,), combined with addition into a sum marked with a question mark.

Build It

The code lives in code/tensors.py. Each step references the implementation there.

Step 1: Tensor storage and strides

A tensor stores a flat list of numbers plus shape metadata. Strides tell the indexing logic how to map multi-dimensional indices to flat positions.

python
class Tensor:
    def __init__(self, data, shape=None):
        if isinstance(data, (list, tuple)):
            self._data, self._shape = self._flatten_nested(data)
        elif isinstance(data, np.ndarray):
            self._data = data.flatten().tolist()
            self._shape = tuple(data.shape)
        else:
            self._data = [data]
            self._shape = ()
 
        if shape is not None:
            total = reduce(lambda a, b: a * b, shape, 1)
            if total != len(self._data):
                raise ValueError(
                    f"Cannot reshape {len(self._data)} elements into shape {shape}"
                )
            self._shape = tuple(shape)
 
        self._strides = self._compute_strides(self._shape)
 
    @staticmethod
    def _compute_strides(shape):
        if len(shape) == 0:
            return ()
        strides = [1] * len(shape)
        for i in range(len(shape) - 2, -1, -1):
            strides[i] = strides[i + 1] * shape[i + 1]
        return tuple(strides)

For shape (3, 4), strides are (4, 1): skip 4 elements to advance one row, skip 1 element to advance one column.

Step 2: Reshape, squeeze, unsqueeze

Reshape changes the shape without changing element order. The total number of elements must stay the same. Use -1 for one dimension to infer its size.

python
t = Tensor(list(range(12)), shape=(2, 6))
r = t.reshape((3, 4))
r = t.reshape((-1, 3))

Squeeze removes axes of size 1. Unsqueeze inserts one, and that matters for broadcasting: a bias vector (D,) added to a batch (B, T, D) needs unsqueezing to (1, 1, D) before the shapes line up.

python
t = Tensor(list(range(6)), shape=(1, 3, 1, 2))
s = t.squeeze()
v = Tensor([1, 2, 3])
u = v.unsqueeze(0)

Step 3: Transpose and permute

Transpose swaps two axes. Permute reorders all axes. This is how you convert between NCHW and NHWC.

python
mat = Tensor(list(range(6)), shape=(2, 3))
tr = mat.transpose(0, 1)
 
t4d = Tensor(list(range(24)), shape=(1, 2, 3, 4))
perm = t4d.permute((0, 2, 3, 1))

After transpose or permute, the tensor is non-contiguous in memory. In PyTorch, view fails on non-contiguous tensors, so use reshape or call .contiguous() first.

Step 4: Element-wise operations and reductions

Element-wise ops (add, multiply, subtract) apply independently to each element and preserve shape. Reductions (sum, mean, max) collapse one or more axes.

python
a = Tensor([[1, 2], [3, 4]])
b = Tensor([[10, 20], [30, 40]])
c = a + b
d = a * 2
s = a.sum(axis=0)

Global average pooling in a CNN: (B, C, H, W).mean(axis=[2, 3]) produces (B, C). Sequence mean pooling in NLP: (B, T, D).mean(axis=1) produces (B, D).

Step 5: Broadcasting with NumPy

The demo_broadcasting_numpy() function in tensors.py shows the core patterns.

python
activations = np.random.randn(4, 3)
bias = np.array([0.1, 0.2, 0.3])
result = activations + bias
 
images = np.random.randn(2, 3, 4, 4)
scale = np.array([0.5, 1.0, 1.5]).reshape(1, 3, 1, 1)
result = images * scale
 
a = np.array([1, 2, 3]).reshape(-1, 1)
b = np.array([10, 20, 30, 40]).reshape(1, -1)
outer = a * b

Pairwise distance via broadcasting: reshape (M, 2) to (M, 1, 2) and (N, 2) to (1, N, 2), subtract, square, sum along last axis, take square root. Result: (M, N).

Step 6: Einsum operations

The demo_einsum() and demo_einsum_gallery() functions walk through every common pattern.

python
a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0, 6.0])
dot = np.einsum("i,i->", a, b)
 
A = np.array([[1, 2], [3, 4], [5, 6]], dtype=float)
B = np.array([[7, 8, 9], [10, 11, 12]], dtype=float)
matmul = np.einsum("ik,kj->ij", A, B)
 
batch_A = np.random.randn(4, 3, 5)
batch_B = np.random.randn(4, 5, 2)
batch_mm = np.einsum("bij,bjk->bik", batch_A, batch_B)

The computational cost of a contraction is the product of all index sizes (kept and summed). For bij,bjk->bik with B=32, I=128, J=64, K=128: 32 * 128 * 64 * 128 = 33,554,432 multiply-adds.

Step 7: Attention mechanism via einsum

The demo_attention_einsum() function implements multi-head attention end to end: X is the input sequence, each head learns its own query (Q), key (K), and value (V) projection, and softmax turns the raw scores into weights that sum to 1 before they combine V. None of that is new; it is the reshape, transpose, and einsum operations from this lesson, chained.

python
B, H, T, D = 2, 4, 8, 16
E = H * D
 
X = np.random.randn(B, T, E)
W_q = np.random.randn(E, E) * 0.02
 
Q = np.einsum("bte,ek->btk", X, W_q)
Q = Q.reshape(B, T, H, D).transpose(0, 2, 1, 3)
 
scores = np.einsum("bhtd,bhsd->bhts", Q, K) / np.sqrt(D)
weights = softmax(scores, axis=-1)
attn_output = np.einsum("bhts,bhsd->bhtd", weights, V)
 
concat = attn_output.transpose(0, 2, 1, 3).reshape(B, T, E)
output = np.einsum("bte,ek->btk", concat, W_o)

Every step is a tensor operation: projection (matmul via einsum), head splitting (reshape + transpose), attention scores (batch matmul via einsum), weighted sum (batch matmul via einsum), head merging (transpose + reshape), output projection (matmul via einsum).

Check: Build It0/18
1. What is a view of a tensor?Define
Array a shaped (2, 3) reshaped into v shaped (3, 2), with a note asking whether changing a number in a also changes v.
2. Why does calling .view() fail on a transposed tensor in PyTorch?Explain
b, the transpose of a, shown as a 3 by 2 matrix, with a question about whether calling view on it would work in PyTorch.
3. Why is unsqueeze needed to broadcast a bias vector of shape (D,) onto a batch tensor of shape (B, T, D)?Explain
A flow from a bias shaped 3 through two unsqueeze steps to a result shape left as three question marks.
4. In multi-head attention, which operations split the projected tensor of shape (B, T, E) into heads of shape (B, H, T, D)?Explain
A flow from shape (2, 4, 6) through a reshape to (2, 4, 2, 3), with a note asking which further step reaches (2, 2, 4, 3).
5. Why must a reshape keep the total number of elements the same?Explain
A tensor shaped (2, 6) reshaping toward (4, 3), with a question mark and a note asking whether that succeeds and whether reshaping to (5, 2) would too.
6. The Tensor class computes strides for a tensor of shape (2, 3, 4). What are they?Apply
A tensor of shape (2, 3, 4) with its strides left as three question marks, and a note about computing each stride from the next one.
7. What happens when you run Tensor(list(range(12)), shape=(5, 3))?Apply
The call Tensor(list(range(12)), shape=(5, 3)) with a note that 5 times 3 is 15 while only 12 elements were given, and a question mark for what happens.
8. How do you perform global average pooling on a CNN feature map of shape (B, C, H, W) using a reduction?Apply
A feature map labelled (B, C, H, W) with real values (2, 64, 7, 7), feeding a mean over axis 2 and 3 marked with a question mark.
9. With a = [1, 2, 3] reshaped to (3, 1) and b = [10, 20, 30, 40] reshaped to (1, 4), the product a * b is a (3, 4) tensor. What is its entry at row 2, column 3 (counting from 0)?Apply
a shaped (3, 1) with values 1, 2, 3 and b shaped (1, 4) with values 10, 20, 30, 40, multiplying into a 3 by 4 grid of question marks.
10. images has shape (2, 3, 4, 4) and scale has shape (3,) reshaped to (1, 3, 1, 1). What does images * scale do?Apply
A flow from an images tensor shaped (2, 3, 4, 4) and a scale tensor shaped (1, 3, 1, 1) to a result marked with a question mark.
11. For A = [[1, 2], [3, 4], [5, 6]] and B = [[7, 8, 9], [10, 11, 12]], einsum 'ik,kj->ij' gives a matrix. What is its entry at row 0, column 0?Apply
A shaped 3 by 2 with its row 0 highlighted, B shaped 2 by 3 with its column 0 highlighted, and a question mark for where they combine.
12. What determines the computational cost of an einsum contraction such as 'bij,bjk->bik'?Apply
The pattern bij,bjk->bik with each letter marked kept or summed, a question mark for the cost, and a note asking whether only kept axes matter.
13. In the attention demo with B=2, H=4, T=8, D=16, what shape does scores = einsum('bhtd,bhsd->bhts', Q, K) have?Apply
Q and K both shaped (2, 4, 8, 16), feeding a scores computation whose shape is left as a question mark.
14. In the lesson's multi-head attention walkthrough with B=2, H=4, T=8, D=16 (so E=H*D=64), what shape does Q have right after the einsum projection 'bte,ek->btk', before it is split into heads?Apply
X shaped (2, 8, 64) and W_q shaped (64, 64) feeding einsum bte,ek to btk, with Q's shape left as a question mark, before any head-splitting step.
15. Code calls t.permute(0, 2, 3, 1).view(B, -1) in PyTorch and gets an error, though the element count is right. Which fix works?Analyze
The call permute(0, 2, 3, 1) followed by view(B, -1), with a note that the element count is correct even though PyTorch still raises an error.
16. You must scale each of 3 color channels of an NCHW batch of shape (B, 3, H, W) by its own factor, given as a vector of 3 numbers. Which approach is correct?Design
An images tensor shaped (2, 3, 2, 2) and a plain 3-number vector, with a note that multiplying them directly fails and asking how the vector should be reshaped.
17. For X of shape (B, N, D) you need all pairwise dot products between the N vectors in each batch item, giving (B, N, N). Which einsum does this?Design
X shaped (B, N, D) combined with a second copy of itself toward a result shaped (B, N, N), with a note that the second copy's N axis needs its own letter.
18. A team wants to catch wrong-axis bugs in new attention code before shipping. Which practice fits best?Design
A short chain of reshape and transpose calls with no axis names attached, and a note asking what practice would force every axis to be named.

Use It

Scratch vs NumPy

OperationScratch (Tensor class)NumPy
CreateTensor([[1,2],[3,4]])np.array([[1,2],[3,4]])
Reshapet.reshape((3,4))a.reshape(3,4)
Transposet.transpose(0,1)a.T or a.transpose(0,1)
Squeezet.squeeze(0)np.squeeze(a, 0)
Sumt.sum(axis=0)a.sum(axis=0)
EinsumN/Anp.einsum("ij,jk->ik", a, b)

Scratch vs PyTorch

python
import torch
 
t = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.float32)
t.shape
t.stride()
t.is_contiguous()
 
t.reshape(3, 2)
t.unsqueeze(0)
t.transpose(0, 1)
t.transpose(0, 1).contiguous()
 
torch.einsum("ik,kj->ij", A, B)

PyTorch adds autograd, GPU support, and optimized BLAS kernels. The shape semantics are identical. If you understand the scratch version, PyTorch shape errors become readable.

Every neural network layer as a tensor operation

Every layer covered in the rest of this curriculum reduces to the same handful of tensor operations from this lesson. Batch norm rescales each feature to zero mean and unit variance, then applies a learned scale and shift; softmax turns raw scores into weights that sum to 1, the same normalization the attention output above depends on.

OperationTensor FormEinsum
Linear layerY = X @ W.T + b"bd,od->bo" + bias
Attention QKVQ = X @ W_q"btd,dh->bth"
Attention scoresQ @ K.T / sqrt(d)"bhtd,bhsd->bhts"
Attention outputsoftmax(scores) @ V"bhts,bhsd->bhtd"
Batch norm(X - mu) / sigma * gammaelement-wise + broadcast
Softmaxexp(x) / sum(exp(x))element-wise + reduction
Check: Use It0/1
1. A Linear layer's forward pass fails with 'mat1 and mat2 shapes cannot be multiplied (32x768 and 512x768)'. The weight has shape (512, 768), the usual (out, in) layout. What is the most likely bug?Analyze
x shaped (32, 768) multiplied directly by a weight shaped (512, 768), raising the shape-mismatch error given in the question.

Ship It

This lesson produces two reusable prompts:

  1. outputs/prompt-tensor-shapes.md: a systematic prompt for debugging tensor shape mismatches. It includes decision tables for every common operation (matmul, broadcast, cat, Linear, Conv2d, BatchNorm, softmax) and a fix lookup table.

  2. outputs/prompt-tensor-debugger.md: a step-by-step debugging prompt you paste into any AI assistant when a shape error is blocking you. Feed it the error message and your tensor shapes, and get back the exact fix.

Author's Note

The bug that finally got me to actually learn einsum wasn't a crash. It was a model that trained fine, evaluated fine, and quietly produced garbage in production for three weeks before anyone noticed. Somewhere in a refactor, a .transpose(1, 2) landed on the wrong tensor. Nothing threw. The shapes still lined up, so PyTorch multiplied the wrong axes together and moved on without complaint. Broadcasting has the same failure mode: it's built to never complain, which means the one time you actually want it to complain, it won't.

Once I started writing every projection and attention step as an einsum expression instead of a chain of .reshape() and .transpose() calls, that whole category of bug mostly disappeared. Not because einsum is magic, but because the subscript string forces you to name every axis out loud before the operation runs. If you can't write the subscripts, you don't actually know what shape you're expecting, and that's usually the moment you catch the bug instead of shipping it.

Exercises

  1. Easy: Reshape round-trip. Take a tensor of shape (2, 3, 4). Reshape it to (6, 4), then to (24,), then back to (2, 3, 4). Verify element order is preserved at each step by printing the flat data.

  2. Medium: Implement broadcasting. Extend the Tensor class with a broadcast_to(shape) method that expands dimensions of size 1 to match a target shape. Then modify _elementwise_op to automatically broadcast before operating. Test with shapes (3, 1) and (1, 4) producing (3, 4).

  3. Hard: Build einsum from scratch. Implement a basic einsum(subscripts, *tensors) function that handles at least: dot product (i,i->), matrix multiply (ij,jk->ik), outer product (i,j->ij), and transpose (ij->ji). Parse the subscript string, identify contracted indices, and loop over all index combinations. Compare your results against np.einsum.

  4. Hard: Attention shape tracker. Write a function that takes batch_size, seq_len, embed_dim, and num_heads as inputs and prints the exact shape at every step of multi-head attention: input, Q/K/V projection, head split, attention scores, softmax weights, weighted sum, head merge, output projection. Verify against the demo_attention_einsum() output.

Key Terms

TermWhat people sayWhat it actually means
Tensor"A matrix but more dimensions"A multi-dimensional array with uniform type and defined shape, strides, and operations
Rank"The number of dimensions"The number of axes. A matrix has rank 2, not rank equal to its matrix rank
Shape"The size of the tensor"A tuple listing the size along each axis. (2, 3) means 2 rows, 3 columns
Stride"How memory is laid out"The number of elements to skip to advance one position along each axis
Broadcasting"It just works when shapes differ"A strict set of rules: align from right, dimensions must be equal or one must be 1
Contiguous"The tensor is normal"Elements stored sequentially in memory with no gaps or reordering from the logical layout
Einsum"A fancy way to write matmul"A general notation that expresses any tensor contraction, outer product, trace, or transpose in one line
View"Same as reshape"A tensor sharing the same memory buffer but with different shape/stride metadata. Fails on non-contiguous data
Contraction"Summing over an index"The general operation where a shared index between tensors is multiplied and summed, producing a lower-rank result
NCHW / NHWC"PyTorch vs TensorFlow format"Memory layout conventions for image tensors. NCHW puts channels before spatial dims, NHWC puts them after

Further Reading