Phase 1 · Lesson 12 of 22

Tensor Operations

Tensors are the common language between data and deep learning. Every image, every sentence, every gradient flows through them.

Lesson 12 of 22Build~90 minutes

Before this: Lesson 1 of Phase 1 (Linear Algebra Intuition), 02 (Vectors, Matrices & Operations)

On this page

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. You try a transpose. 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 are not hard conceptually -- each operation has a shape contract -- but they multiply fast. A transformer has dozens of reshapes, transposes, and broadcasts chained together. One wrong axis and the error cascades. Worse, some shape mistakes do not throw errors at all. They silently produce garbage by broadcasting along the wrong dimension or summing over the wrong axis.

Matrices handle pairwise relationships between two sets of things. Real data does not fit into two dimensions. A batch of 32 RGB images at 224x224 is a 4D tensor: (32, 3, 224, 224). Self-attention with 12 heads is also 4D: (batch, heads, seq_len, head_dim). You need 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 become trivially debuggable.

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.

Diagram (opens full size)

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

Tensor shapes in deep learning

Different data types map to specific tensor shapes by convention.

Diagram (opens full size)

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.

Diagram (opens full size)

Transpose does not move data. It swaps the strides, making 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.

Diagram (opens full size)

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).

tensor-broadcast

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.

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.

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. Unsqueezing is critical for broadcasting -- a bias vector (D,) added to a batch (B, T, D) needs unsqueezing to (1, 1, D).

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.

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 -- 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.

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.

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.

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 \cdot 128 \cdot 64 \cdot 128 = 33{,}554{,}432$ multiply-adds.

Step 7: Attention mechanism via einsum

The demo_attention_einsum() function implements multi-head attention end to end.

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).

Use It

Scratch vs NumPy

Operation Scratch (Tensor class) NumPy
Create Tensor([[1,2],[3,4]]) np.array([[1,2],[3,4]])
Reshape t.reshape((3,4)) a.reshape(3,4)
Transpose t.transpose(0,1) a.T or a.transpose(0,1)
Squeeze t.squeeze(0) np.squeeze(a, 0)
Sum t.sum(axis=0) a.sum(axis=0)
Einsum N/A np.einsum("ij,jk->ik", a, b)

Scratch vs PyTorch

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

Operation Tensor Form Einsum
Linear layer Y = X @ W.T + b "bd,od->bo" + bias
Attention QKV Q = X @ W_q "btd,dh->bth"
Attention scores Q @ K.T / sqrt(d) "bhtd,bhsd->bhts"
Attention output softmax(scores) @ V "bhts,bhsd->bhtd"
Batch norm (X - mu) / sigma * gamma element-wise + broadcast
Softmax exp(x) / sum(exp(x)) element-wise + reduction

Ship It

This lesson produces two reusable prompts:

  1. outputs/prompt-tensor-shapes.md -- A systematic prompt for debugging tensor shape mismatches. 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, get back the exact fix.

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.

Hints

Exercise 1. Build the tensor from list(range(24)) so that each element shows its own position. After each reshape call, print flatten().to_list() and check that it is still 0 to 23 in order.

Exercise 2. A dimension can broadcast only when the two sizes are equal or one of them is 1, so check that rule first and raise an error when it fails. For shapes (3, 1) and (1, 4), check that entry [i][j] of the result equals a[i][0] + b[0][j], and compare the full result with NumPy.

Exercise 3. Split the subscripts on ->. A letter that is in the inputs but not in the output is a summed index, so loop over all values of every letter and add into the output position. Test each of the four cases on small random arrays with np.allclose(mine, np.einsum(...)).

Exercise 4. Start with the head size embed_dim // num_heads and assert that embed_dim % num_heads == 0. The attention scores have shape (batch_size, num_heads, seq_len, seq_len), so check your printout against that shape and against the shapes that demo_attention_einsum() prints.

Key Terms

Term What people say What 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

Check yourself

Quick quiz

  1. What does the 'shape' of a tensor describe?

    1. A tuple listing the size along each axis
    2. The memory address of the tensor
    3. The total number of elements in the tensor
    4. The data type of the tensor elements
    Show the answer

    A. A tuple listing the size along each axis The shape is a tuple listing the size along each axis. For example, a tensor with shape (2, 3, 4) has 2 elements along axis 0, 3 along axis 1, and 4 along axis 2.

  2. In PyTorch, what layout does an image batch tensor use by default?

    1. CHWN (channels, height, width, batch)
    2. NHWC (batch, height, width, channels)
    3. WHCN (width, height, channels, batch)
    4. NCHW (batch, channels, height, width)
    Show the answer

    D. NCHW (batch, channels, height, width) PyTorch defaults to NCHW (channels-first) layout. TensorFlow defaults to NHWC (channels-last). Mismatched layouts cause silent errors or performance issues.

  3. What is the result shape when broadcasting tensors of shape (8, 1, 6, 1) and (7, 1, 5)?

    1. (8, 7, 6, 5)
    2. (8, 1, 6, 5)
    3. (8, 7, 6, 1)
    4. Broadcasting fails — shapes are incompatible
    Show the answer

    A. (8, 7, 6, 5) Align shapes from the right: (8,1,6,1) and (1,7,1,5). Dimensions are compatible when equal or one is 1. The result takes the maximum along each axis: (8, 7, 6, 5).

  4. In the einsum expression 'bhtd,bhsd->bhts', what happens to the index 'd'?

    1. It is transposed between the two input tensors
    2. It is summed over (contracted) because it appears in both inputs but not the output
    3. It is broadcast across both tensors
    4. It is kept in the output as a new axis
    Show the answer

    B. It is summed over (contracted) because it appears in both inputs but not the output In einsum, any index that appears in the inputs but not the output is summed over. Index 'd' appears in both 'bhtd' and 'bhsd' but not in the output 'bhts', so it is contracted (multiplied and summed).

  5. Why does calling .view() fail on a transposed tensor in PyTorch?

    1. The tensor is non-contiguous in memory after transpose, and view requires contiguous data
    2. Transposed tensors have a different data type
    3. Transpose changes the total number of elements
    4. View only works on 2D tensors
    Show the answer

    A. The tensor is non-contiguous in memory after transpose, and view requires contiguous data Transpose swaps strides without moving data, making the tensor non-contiguous. The .view() operation requires contiguous memory layout. Use .reshape() or call .contiguous() first.

Questions people ask

What is broadcasting in NumPy and PyTorch?

Broadcasting lets you combine arrays of different shapes by stretching size-one axes to match without copying data. Adding a bias vector to every row of a matrix is a common example. It saves memory but can hide shape bugs if axes line up by accident.

What is einsum and when should I use it?

einsum writes tensor math as a short index pattern, like the string ij,jk->ik for matrix multiplication. It is handy for batched products, attention scores and sums over chosen axes, and it makes the intended shapes explicit in one line.

Next lessonNumerical StabilityPrevious: Singular Value Decomposition

Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License

Next step

Rather have it built for you?

Reading is optional. Tell me what's stuck and I'll build it into the business you already run.

  • A real conversation, not a pitch
  • You keep your accounts
  • Nothing sends without your yes