arena-in-12-weeks
glossary about

week 03 / 12

How training actually works

Training = rolling downhill on a loss landscape.

works through arena 0.3 · 0.3 Optimization

new terms this week · 5

Optimization

Gradient
The local slope of the loss with respect to each parameter.
SGD
Stochastic gradient descent: step parameters opposite the gradient.
Learning rate
The step size an optimizer uses when updating parameters.
Adam
A popular optimizer that adapts per-parameter step sizes using running gradient statistics.

Training controls

Weight decay
A regularizer that nudges weights toward zero during optimization, discouraging solutions that require large parameter values.

The puzzle: 79,510 knobs and one number

Last week you trained an MNIST network and treated the training loop as ritual. That ritual set all 79,510 parameters in the little 784 → 100 → 10 network (784×100 weights + 100 biases, then 100×10 + 10) to values that recognize handwriting. Nobody searched each combination. A 79,510-dimensional space is too large to search directly. What information guides all those decisions at once?

The answer is one number, differentiated. The loss is a single scalar measuring how bad the model is on a batch. Calculus asks that loss, "If each parameter moved up a tiny bit, would you rise or fall, and how steeply?" The gradient answers for every parameter at once. One backward pass computes all 79,510 slopes for about the cost of one forward pass. The search now has a compass.

Loss is the terrain

The loss is one number measuring how bad the model is on a batch. Imagine that number as height and every model parameter as a coordinate. Real models have millions of coordinates, so the picture is impossible to draw, but the local idea still works: if the loss slopes upward in one direction, move the weights in the opposite direction.

The loss stays scalar, but its gradient is a collection of slopes with one entry per parameter. A weight matrix of shape (100, 784) receives a gradient of shape (100, 784); its bias vector receives a gradient of shape (100,). PyTorch preserves those shapes in each parameter's .grad field. The optimizer never extracts 79,510 decisions from the loss value alone. It uses the derivative of that value with respect to all 79,510 coordinates.

A 2-D loss landscape on white: a black curve descends from the upper left, crosses a small bump, reaches a broad minimum, then rises on the right. The y-axis is labelled "loss" and the x-axis "one weight (of millions)". A black ball sits on the early slope. A thin gray arrow points uphill from the ball and is labelled "gradient: local uphill". A thick blue arrow, the emphasized step, points downhill and is labelled in monospace "step: w ← w − lr·∇L". A dashed gray trail of shrinking dots continues to a point labelled "minimum: low loss". A gray side note warns that real landscapes have millions of axes and 2-D pictures flatten them. The baked-in caption reads "The loss is terrain, and the gradient points uphill. Every optimizer chooses the next downhill step."

The figure is a 1-parameter cartoon of a 79,510-parameter reality. High-dimensional terrain contains features the cartoon cannot show. Most flat-looking points are saddle points, flat in some directions and sloped in others; true valley floors are rarer. The local move still holds: feel the slope, step against it, repeat.

SGD (stochastic gradient descent) is the plain version of that move. Each step uses the gradient from one random batch, giving "stochastic" its name and producing a noisy but cheap compass reading. Watch it work on the bowl f(x) = x²:

# 01: gradient descent on a bowl
def grad(x):
    return 2 * x              # slope of the bowl f(x) = x ** 2

x = 5.0
lr = 0.1
for step in range(6):
    x = x - lr * grad(x)      # step against the slope, toward lower loss
    print(round(x, 3))
# 4.0  3.2  2.56  2.048  1.638  1.311   -> sliding toward the minimum at x = 0

Each pass takes one step downhill, and the value creeps toward the minimum. The lr is the Learning rate : it scales every step. Too high and the optimizer jumps over the valley or diverges; too low and training crawls.

See both failure modes once because you will diagnose them later. Same bowl, three learning rates:

# 02: the learning rate has two failure modes
def grad(x):
    return 2 * x

for lr in [0.1, 0.6, 1.1]:
    x = 5.0
    for step in range(6):
        x = x - lr * grad(x)
    print(f"lr={lr}: x after 6 steps = {x:.3f}")

Expected output: lr=0.1 reaches 1.311 (safe but slow, still far from 0), lr=0.6 reaches 0.000 (large steps overshoot the minimum and bounce across it, but each bounce lands closer), and lr=1.1 reaches 14.930 (each bounce lands farther away and soon diverges to infinity). A "my loss went to NaN" bug is the third line at model scale.

Real training adds batch noise to this clean bowl. The bowl's gradient is exact, but SGD estimates a network's gradient from a mini-batch: a small sample of training examples processed together. Two batches contain different digits, so they give slightly different downhill directions. The wobble buys cheaper updates. A full-dataset step would be steadier, but it would cost one pass through every example before moving once. Mini-batches produce a stream of cheap, imperfect updates. They also average efficiently on GPU hardware: one example wastes parallel compute, while an enormous batch consumes memory and provides fewer updates per pass through the data.

A batch size of 64 means the loss averages the errors from 64 examples. The gradient asks how every parameter should change to improve that average. The next batch asks the same question of a different 64. Individual steps wobble while the trajectory heads downhill. A jagged loss chart may show healthy batch noise. Values that grow without bound point to a high learning rate or numerical instability.

An epoch completes one pass through the training set. With 60,000 examples and batches of 64, an epoch contains about 938 updates. This distinction matters when you compare runs: ten epochs with batch size 64 performs far more parameter updates than ten epochs with batch size 1,024. Track both epochs and optimizer steps when a learning curve moves at an unexpected speed.

Momentum and Adam are engineering fixes

Plain SGD struggles in long, narrow valleys with steep walls in one direction and a gentle floor sloping toward the goal in another. The gradient points mostly at the nearest wall, so SGD ricochets across the valley and advances toward the minimum in tiny sideways slivers.

Contour-line view of a long, narrow loss valley on white: nested gray ellipses surround a point labelled "minimum", with monospace update equations "v ← 0.9·v + g" and "w ← w − lr·v" in the corner. From a "start" dot at the upper left, a gray polyline zig-zags across the valley's narrow axis while inching rightward, labelled "plain SGD: zig-zags". A thick, smooth blue curve, the emphasized path, starts at the same point and follows the valley floor into the minimum, labelled "with momentum: velocity smooths the path". The baked-in caption reads "Across the valley, gradients flip and cancel. Along it, they agree, so momentum keeps what agrees."

Momentum is the fix, and the figure gives away why it works. Across the valley, successive gradients point in opposite directions (left wall, right wall, left wall...), so if you average recent gradients they cancel. Along the valley, successive gradients agree, so the average accumulates. Momentum remembers recent direction like a ball carrying velocity, and its smoothing effect appears after several steps.

# 03: momentum is a multi-step effect
def grad(x):
    return 2 * x                     # slope of the bowl f(x) = x ** 2

def descend(momentum, lr=0.1, steps=6):
    x, v = 5.0, 0.0
    history = []
    for step in range(steps):
        v = momentum * v + grad(x)   # velocity accumulates past gradients
        x = x - lr * v
        history.append(round(x, 2))
    return history

print("plain SGD", descend(momentum=0.0))
print("momentum ", descend(momentum=0.9))
# plain SGD [4.0, 3.2, 2.56, 2.05, 1.64, 1.31]
# momentum  [4.0, 2.3, 0.31, -1.54, -2.9, -3.54]   <- velocity carried it past the minimum

The first step is identical for both because velocity starts at zero and therefore equals the gradient. The rolling velocity then pulls the momentum run ahead, fast enough here to overshoot the bottom and swing back. Momentum is a multi-step effect. On this symmetric bowl the overshoot looks like a bug; in a narrow valley, the same accumulated velocity carries you along the floor while damping wall-to-wall bounces.

Adam keeps running averages of gradients and squared gradients for each parameter, then adapts each parameter's step size. Parameters with consistently large gradients need small steps, while parameters with tiny gradients need larger steps; one global learning rate cannot suit both. Dividing a parameter's step by its running gradient scale evens out progress. Two more terms appear in the exercises. Weight decay shrinks every weight slightly toward zero on each step, pressing against large weights. Bias correction compensates for running averages that start at zero and begin too small. In the notebook, you implement Adam as bookkeeping over tensors.

The optimizer loop

The full training loop lets PyTorch calculate gradients:

# no-run
# 04: the five-line liturgy, named
import torch

model = torch.nn.Linear(2, 1)
opt = torch.optim.SGD(model.parameters(), lr=0.1)
x = torch.randn(8, 2)
y = torch.randn(8, 1)

pred = model(x)
loss = ((pred - y) ** 2).mean()
opt.zero_grad()
loss.backward()
opt.step()
print(loss.item())

Week 2 introduced this loop as ritual. You now name each moving part and implement the optimizers yourself. opt.step() becomes fifteen lines you wrote, with a momentum buffer you can print. Next week opens loss.backward().

Each line passes data to the next. pred = model(x) creates predictions using the current parameters. The loss compresses all eight prediction errors into one scalar. zero_grad() clears gradient buffers left by the previous batch. PyTorch adds new gradients to those buffers, which supports gradient accumulation but surprises readers who expect assignment. backward() fills each parameter's .grad buffer. step() then reads those gradients and mutates the parameters. The printed loss is a positive scalar whose exact value varies because the model and data are random. It describes the batch before this update, so another forward pass must measure the updated model.

Training-loop bugs break that order. Forget zero_grad() and gradients from unrelated batches pile up. Call step() before backward() and the optimizer has no fresh direction. Reuse pred after step() and it still represents the old forward pass. Inspect one parameter three times: print its value before backward(), confirm its .grad becomes non-None afterwards, then confirm the value changes after step(). That trace separates "the model produced no gradient" from "the optimizer ignored a valid gradient" without guessing.

Hyperparameters and experiment tracking

A hyperparameter is a setting humans choose: learning rate, batch size, momentum, weight decay, or schedule. A schedule changes the learning rate during training. A typical schedule warms it up briefly from zero, then decays it gradually. The optimizer updates weights; humans choose the training recipe.

Repeated runs create a mundane engineering problem. By run seven, you will forget whether run three used lr=1e-3 or 1e-4. Weights and Biases logs losses, accuracies, and configs to the cloud, then plots runs side by side. A sweep runs a structured set of hyperparameter choices. The dashboard answers "which change helped?" without relying on memory. ARENA keeps using this workflow in later chapters, so the setup effort pays rent beyond this week.

Pair-session guide

Core work is ARENA 0.3 section 1: implement SGD (momentum and weight decay are built into that one exercise), then RMSprop, Adam, and AdamW, and race them across pathological loss surfaces. Stretch work is section 2 (W&B logging and sweeps on a ResNet finetuned on CIFAR10) and section 3 (distributed training). Pair rule: when an optimizer test fails, compare update equations line by line before changing code.

What you should see

You should see optimizer trajectory plots where SGD zig-zags, momentum smooths the path, and the plotted Adam run reaches the basin quickly. If you do the W&B stretch, your dashboard should show multiple runs and the learning rate or schedule that changed each curve.

The trajectory plots bring this week's diagram to life: your optimizer implementations draw the same narrow-valley ricochet. An SGD path that zig-zags and a momentum path that glides reproduce the reason these algorithms were invented.

Where to go next

Week 4 has you write backward() itself. These readings extend the optimizer story:

this week's pair session

core

  • 1: implement SGD (with momentum), RMSprop, Adam, AdamW

stretch

  • W&B sweeps
  • Distributed training overview