Skip to content

Training Deep Networks

Tensors, broadcasting, and autograd

The array machinery frameworks are built on

What every deep-learning framework quietly does for you: n-dimensional arrays, the broadcasting rules that stretch shapes to fit, and an engine that hands you every gradient for free.

Open any PyTorch or NumPy program and you will find just three ideas doing all the work. Data lives in tensors - n-dimensional arrays. Operations between tensors of different shapes are made to line up by broadcasting. And every operation is quietly recorded so an autograd engine can replay it backward and compute gradients. Two of these you already half-know; the third - broadcasting - is where the majority of real bugs actually live. Let's make all three concrete.

The idea in one line

A tensor is a box of numbers with a shape (a tuple of axis lengths). Broadcasting is the rule that lets a small tensor act as if it were a big one without copying. Autograd is a tape recorder: it watches the forward computation, then rewinds it, applying the chain rule at every step so you never hand-derive a gradient again.

Tensors: one array type for everything

A tensor is the generalisation of the containers you already met: a scalar is a 0-D tensor, a vector a 1-D tensor, a matrix a 2-D tensor (Lesson 13), and beyond that we just keep adding axes. Its shape is the tuple of sizes along each axis, and its rank is how many axes it has. A batch of RGB images at is one tensor of shape - rank 4 - and the whole point of a framework is to run one operation across that entire block at once, on a GPU, instead of looping.

Axes are read left-to-right but the rules that follow always align from the right, so it pays to think of the last axis as the innermost, fastest-moving one. Get a shape wrong by a single axis and nothing crashes politely - you get silently wrong numbers. That is exactly the failure broadcasting both enables and hides.

Broadcasting: how shapes stretch to fit

You constantly want to combine tensors of different shapes - add a length- bias vector to a whole batch of activations, multiply a matrix by a per-row scale. Broadcasting is the deterministic rule that decides whether that is legal and what the result shape is. It is identical across NumPy, PyTorch, and JAX.

The broadcasting rule

Line the two shapes up from the right-most axis. For each aligned pair of axis lengths, they are compatible when:

they are equal,  or  one of them is .

A size- axis is stretched - conceptually copied, never actually materialised in memory - to match its partner, and the result takes the larger size on every axis. If two aligned axes disagree and neither is , the operation is illegal: a shape error. The canonical case is a column meeting a row:

Rows: vs - the stretches to . Cols: vs - the stretches to . Every output cell - an outer sum built from just stored numbers, not .

Now play with it. Resize the two tensors below and watch the rule decide: green means an axis lined up, an amber highlight marks a size- axis being stretched to fill the result, and red is the shape error you will meet in real code.

Broadcasting playgroundresize A and B · elementwise ⊕
(3, 1)
1
2
3
stretch cols ×4
(1, 4)
1
2
3
4
stretch rows ×3
=
(3, 4)
2
3
4
5
3
4
5
6
4
5
6
7
 
axis 0 (rows): 3 vs 1 - the 1 stretches to 3 ✓
axis 1 (cols): 1 vs 4 - the 1 stretches to 4 ✓
result shape (3, 4)

Broadcast succeeds. axis 0 (rows): 3 vs 1 - the 1 stretches to 3 ✓ axis 1 (cols): 1 vs 4 - the 1 stretches to 4 ✓ result shape (3, 4)

The bug that broadcasting hides

Because a size- axis silently stretches, a shape you got wrong can still be "compatible" and run without error - producing numbers of the wrong shape. Adding a column where you meant a row broadcasts to an matrix instead of the length- vector you expected. No exception, no stack trace - just a loss that quietly refuses to go down. When your model misbehaves, print(x.shape) before you print anything else.

Autograd: the graph that differentiates itself

Every tensor operation is a node in a computational graph (Lesson 22): the forward pass fills each node's value; the framework simultaneously records how that node was produced. To get gradients it walks the graph backward in reverse order, multiplying in each node's local derivative - reverse-mode automatic differentiation. Below is a single neuron, , unrolled into its graph. Step forward to fill the values, then keep stepping to watch the gradient flow back to every input at once.

Reverse-mode autodiff, node by nodeNext fills forward, then flows back
x
input
2
·
· w
u
x·w
·
·
+ b
p
u+b
·
·
(·−y)²
L
(p−y)²
·
·
value (forward)gradient (backward)
x = 2, y = 1 (fixed)

Forward pass. The input x = 2 is known. Now fill each node, left to right.

output p-
loss L-
∂L / ∂w-
∂L / ∂b-
∂L / ∂x-

Why this is the whole game

Nudge the w slider and every gradient recomputes instantly - that is autograd earning its keep. You wrote the forward expression once; the framework derived , , and for you. Scale this graph to a trillion nodes and nothing about the algorithm changes: one forward sweep, one backward sweep, and every weight in a modern LLM receives its gradient together. You never differentiate by hand again.

The same engine, in a dozen lines

What makes autograd feel like magic is almost embarrassingly small. Each operation returns a node that remembers how to push gradient back to its inputs. Build the graph forward, seed the tail with , then call those closures in reverse - this is the beating heart of Karpathy's micrograd.

# each op returns a node that remembers its local derivative
def mul(a, b):
    out = node(a.data * b.data)
    def _backward():                # local: d(a*b)/da = b,  d(a*b)/db = a
        a.grad += b.data * out.grad
        b.grad += a.data * out.grad
    out._backward = _backward
    return out

# ... build the graph forward, collecting nodes in order, then:
loss.grad = 1.0                     # seed the tail: dL/dL
for n in reversed(graph):          # reverse topological order
    n._backward()                    # each node hands gradient to its inputs

Check yourself

Broadcasting shapes (3, 1) and (1, 4) yields a result of shape:

Why does a framework record a computational graph on the forward pass?

Recall: state the broadcasting rule for aligning two tensor shapes.

Try to state it, then check.

Lock it in

  • Three ideas run every framework: tensors (n-dimensional arrays with a shape), broadcasting (line shapes up and stretch), and autograd (a tape that replays the computation backward).
  • Broadcasting rule: align shapes from the right-most axis; two axes are compatible if equal or one is . The size- axis stretches (never copied) and the result takes the larger size on every axis.
  • Because a size- axis silently stretches, a wrong shape can still run and produce wrong-shaped numbers with no error - print x.shape first when a loss quietly refuses to drop.
  • Autograd records the forward computational graph and walks it backward (reverse-mode autodiff), multiplying in each node's local derivative, so every input gets its gradient in one backward sweep.
  • Scale the graph to a trillion nodes and the algorithm is unchanged: one forward sweep, one backward sweep, and you never hand-derive a gradient again.

Primary source

The autograd engine above is a miniature of Andrej Karpathy's Neural Networks: Zero to Hero - his micrograd video builds this exact backward-closure pattern from scratch. For tensors, shapes, and the broadcasting rules as your framework enforces them, work through the official PyTorch tensor tutorial.

Ask your teacher

Here is the connection to sit with: this is backprop (Lesson 22), but as a working developer actually meets it. The math of the chain rule is the easy part - shapes and broadcasting are where the real bugs live, and autograd is the reason you never hand-derive a gradient again. Ask me how frameworks build the graph automatically as you write ordinary code, why a silent broadcast is harder to catch than a crash, or how a size-1 axis stretches across memory without ever being copied.