Deep Learning · Module 6 of 26 · 8 concepts · about 16 min

Gradient Descent

The update rule that turns loss into parameter changes, and the step size that decides whether it converges.

At work: How every network learns, and why training runs either improve steadily or waste days of expensive GPU time.

Gradients and learning rate

What it is

A gradient gives the local direction in which each parameter increases loss, including both direction and sensitivity. Gradient descent moves the opposite way: θ ← θ − learning_rate × ∇θ loss. In practice, a mini-batch estimates that direction from a small random sample of examples; the estimate is noisy, but is much cheaper than computing the full dataset gradient and that noise can help escape unhelpful local geometry. One optimiser step updates every trainable parameter once after backpropagation has populated its gradient.

Why it matters

The learning rate controls the basic trade-off: too high diverges or bounces past a solution; too low makes progress impractically slow. This is why a loss curve is a diagnostic instrument, not merely a score: smooth stagnation, erratic spikes, and steady decline call for different interventions. Mini-batch size also changes the noise and number of updates per epoch, so it cannot be tuned independently of the learning rate.

In the real world: How a model nudges its weights after every batch; the step size decides whether training takes hours or never works.

Visual example

Interactive optimisation

Choose a learning rate, take updates, and watch the same loss either settle, oscillate, or explode.

Learning rate η

η = 0.20

steady convergence

Minimise L(w) = (w − 3)². The optimum is w = 3.

Loss after each update

w = -3
lossupdate

gradient = 2(w − 3) = -12 → next w = -0.60

Why this matters

A learning rate is not an abstract setting: it is the length of every downhill step. This tiny objective makes the stable and unstable regimes visible immediately.

  1. 1Set ηTry 0.2 first, then compare it with 0.8 and 1.1.
  2. 2StepEach click applies the derivative of the displayed loss.
  3. 3Read the curveLoss should fall toward zero; sustained growth means the step is too large.

Interpreting a noisy mini-batch loss as failure. Batch estimates are noisy by design; evaluate a moving average or validation loss before changing the whole training setup.

Practise it: ReLU, Forward and Backward, Adam, One Step at a Time

Batch, stochastic and mini-batch gradient descent

What it is

Batch (or full-batch) gradient descent computes the loss over the entire training set before taking one step, so each step is an exact gradient and epochs contain exactly one update. Stochastic gradient descent uses one example per step: extremely noisy, but thousands of updates per epoch. Mini-batch descent — what everyone actually means by 'SGD' in PyTorch — uses 32 to 4096 examples per step, which is enough to average out most of the noise while still fitting in memory and giving many updates per epoch.

Why it matters

Batch size trades three things at once: gradient quality, updates per epoch, and hardware utilisation. Tiny batches give noisy directions but many cheap steps, and the noise itself acts as a regulariser. Huge batches give near-exact directions and excellent GPU throughput, but far fewer steps per epoch, and the extra precision buys less than the lost update count — which is why very large-batch training needs a scaled-up learning rate and a warmup (module 8) to stay competitive.

In the real world: Training on small batches is what makes learning from millions of product images practical.

Visual example

Batch size

One dataset, three regimes. Updates per epoch and gradient quality move in opposite directions.

50,000 training examples
updates / epochgradientGPU use
full batch1exactone huge op, may not fit
stochastic (1)50000very noisywasteful: tiny ops
mini-batch (128)391good estimatewell utilised

Why the middle row wins

gradient noise falls as 1/√batch — 128 examples already average most of it away

391 steps per epoch beats 1 step per epoch even with a worse direction

a 128-row matmul saturates the hardware; a 1-row one does not

from torch.utils.data import DataLoader, TensorDataset

ds = TensorDataset(X, y)             # say 50,000 examples

full = DataLoader(ds, batch_size=len(ds))   #     1 update per epoch
sgd = DataLoader(ds, batch_size=1)          # 50,000 updates per epoch
mini = DataLoader(ds, batch_size=128, shuffle=True)   # 391 updates per epoch

print(len(full), len(sgd), len(mini))       # 1 50000 391

Leaving shuffle=False on the training loader. Every epoch then sees the same batches in the same order, so the gradient noise becomes a fixed periodic pattern rather than an unbiased sample — and if the data is sorted by label, some batches contain only one class.

Practise it: Batch Norm Behaves Differently at Inference

The learning rate is the hyperparameter that matters most

What it is

The learning rate multiplies every gradient before it is subtracted, so it sets the length of every step the model takes. Too small and the loss falls but never arrives within your compute budget; too large and each step overshoots the valley it was aiming at, so the loss oscillates, plateaus high, or diverges to NaN. The usable range for a given model and loss usually spans about an order of magnitude, and it moves when you change the architecture, the batch size, or the normalisation.

Why it matters

Nothing else in deep learning has this much leverage per minute spent. A learning rate an order of magnitude too low turns a 20-minute run into a week; one an order of magnitude too high makes a perfectly good architecture look broken. This is also why the shape of the loss curve is a diagnosis rather than a score: a flat high curve, a noisy sideways curve, and a curve that spikes to NaN each point at a different correction.

In the real world: A wrong learning rate can waste a week of GPU time; teams tune it first.

Visual example

Learning rate

Three runs, identical in every way but the step size. The shape of the curve names the problem.

η = 1e-4 (too small)η = 1e-2 (good)η = 1.0 (too large)
0510152000.511.522.53epochtraining lossstill falling, out of budgetovershoots every valley

Common misconception

Wrong idea: a loss that is not falling means the model lacks capacity.

Correct idea: the top two curves are the same model. One is stepping too timidly and one too violently; neither is a capacity problem, and neither is fixed by adding layers.

import torch, torch.nn as nn

def train_epoch(lr):
    torch.manual_seed(0)
    model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))
    opt = torch.optim.SGD(model.parameters(), lr=lr)
    losses = []
    for xb, yb in loader:
        opt.zero_grad()
        loss = nn.CrossEntropyLoss()(model(xb), yb)
        loss.backward()
        opt.step()
        losses.append(loss.item())
    return sum(losses) / len(losses)

for lr in (1e-4, 1e-2, 1.0):
    print(lr, train_epoch(lr))   # the cheapest experiment in deep learning

Copying a learning rate from a paper or tutorial that used a different batch size, optimiser or normalisation scheme. All three change the gradient scale, so the number does not transfer — re-check it with a short run before trusting it.

Saddle points, not local minima

What it is

A critical point is where the gradient is zero. In one dimension that is a minimum or a maximum, but in a space with millions of parameters a zero-gradient point is overwhelmingly likely to be a saddle: curving up in some directions and down in others. True local minima that are much worse than the global one are rare in large networks, because a point being a local minimum requires the curvature to be positive in *every* one of a million directions at once.

Why it matters

It changes what you worry about. The folk story that deep learning is a struggle against bad local minima is largely wrong; the real obstacles are saddle points and long flat plateaus, where the gradient is tiny and progress stalls without anything being stuck. That diagnosis points at different fixes — momentum to carry through flat regions, adaptive per-parameter step sizes to move along directions the gradient barely mentions, and a higher learning rate or a warm restart to escape a plateau — rather than at random restarts.

In the real world: Why a training run can plateau for a while and then suddenly improve, so it shouldn't be stopped too early.

Visual example

Loss landscape

Three places the gradient is small, with three different fixes. The middle row is the common one.

gradient ≈ 0 — which kind of point is it?
curvaturehow common at scalewhat helps
local minimumup in every directionrareaccept it, or restart
saddle pointup in some, down in othersoverwhelmingly likelymomentum, adaptive steps
plateau≈ flat in most directionscommon early and latehigher lr, warmup, restarts

Common misconception

Wrong idea: training gets stuck in bad local minima.

Correct idea: in a million-dimensional space, a point that curves upward in every direction is vanishingly unlikely. What actually stalls a run is a saddle or a flat region, and both are escapable.

import torch

# A textbook saddle: z = x**2 - y**2 at the origin.
params = torch.zeros(2, requires_grad=True)
z = params[0] ** 2 - params[1] ** 2
z.backward()
print(params.grad)   # tensor([0., 0.]) — a zero gradient that is not a minimum

# Nudge off the saddle and descent finds the downhill direction again.
params = torch.tensor([0.0, 0.01], requires_grad=True)
(params[0] ** 2 - params[1] ** 2).backward()
print(params.grad)   # tensor([ 0.0000, -0.0200])

Reading a plateau as convergence. A near-zero gradient region and a minimum look identical in the loss curve; check whether the parameters are still moving and whether validation loss has bottomed out before declaring a run finished.

Momentum, in one sentence

What it is

Instead of stepping along the current batch's gradient, momentum keeps a running average of recent gradients — a velocity — and steps along that. Directions the gradients agree on accumulate and the step grows; directions where consecutive batches disagree cancel out and the step shrinks. In PyTorch it is one argument: torch.optim.SGD(params, lr=0.1, momentum=0.9).

Why it matters

It is the cheapest fix for the two problems above. Across a flat plateau, plain descent takes tiny steps forever while momentum carries speed from before the plateau; in a narrow valley, plain descent zigzags across the walls while momentum cancels the crosswise oscillation and accelerates along the floor. The effect on wall-clock time is often larger than a careful learning rate search, which is why momentum=0.9 is a sensible default rather than a tuning opportunity. Module 8 unpacks how it works and what Adam adds on top.

In the real world: Smooths training so a model reaches good accuracy in fewer, cheaper steps.

import torch

plain = torch.optim.SGD(model.parameters(), lr=0.1)
with_momentum = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)

# 0.9 means each step keeps 90% of the previous velocity, so the effective
# step along a consistent direction approaches 1 / (1 - 0.9) = 10x the gradient.

Raising momentum and the learning rate together. Momentum multiplies the effective step along a consistent direction by roughly 1 / (1 − momentum), so adding momentum=0.9 to an already-high rate is close to a tenfold increase and will diverge.

Gradient clipping

What it is

Clipping rescales the gradient before the optimiser sees it, so a rare enormous gradient cannot produce an enormous step. clip_grad_norm_ computes the global norm across all parameters and, if it exceeds a threshold, scales every gradient down by the same factor — preserving the direction and capping only the length. It goes between loss.backward() and optimizer.step(), and a threshold of 1.0 is a common default in language-model training.

Why it matters

Loss surfaces have cliffs. One unlucky batch — an outlier, a long sequence, a badly scaled input — can produce a gradient several orders of magnitude larger than typical, and one step along it destroys hours of training in a single update, usually surfacing as a sudden loss spike or NaN. Clipping makes that failure mode impossible without changing anything about normal steps. It is a safety net, though, not a cure: if clipping triggers on most batches, the learning rate or the input scaling is wrong.

In the real world: Prevents one bad batch from wrecking a long, expensive training run.

import torch.nn.utils as utils

opt.zero_grad()
loss.backward()
total_norm = utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
opt.step()

# The return value is the norm BEFORE clipping — worth logging.
if total_norm > 1.0:
    print(f"clipped: {total_norm:.1f} -> 1.0")

Clipping before backward(), or after step(). The gradients do not exist before the backward pass and have already been applied after the step, so in both positions the call runs, reports a norm, and changes nothing about the update.

Computing and inspecting a gradient in PyTorch

What it is

Mark a tensor with requires_grad=True, compute a scalar from it, and call .backward(): PyTorch fills each participating tensor's .grad field with the derivative of that scalar with respect to it. For a model, model.parameters() are already marked, so loss.backward() populates param.grad for every one of them. Those gradient tensors have exactly the same shape as the parameters — one derivative per weight.

Why it matters

Being able to print a gradient turns most training mysteries into a two-line check. A .grad that is None means the parameter never participated in the loss — a detached tensor, an unregistered layer, a frozen requires_grad=False. All-zero gradients mean a dead activation or a loss with no dependence on that parameter. NaN gradients localise the numerical problem to a specific layer. The per-parameter gradient norm is also the most useful training diagnostic after the loss itself (module 9).

In the real world: Checking that a new model part is actually learning before scaling up the training job.

Visual example

Autograd

One gradient tensor per parameter tensor, exactly the same shape — and what each pathology means.

param.grad after loss.backward()
what you seewhat it means
healthyfinite, shape == param.shapethe parameter is being trained
Nonegrad is Nonenever reached the loss: detached, frozen, or unregistered
all zerosnorm == 0.0dead activation, or the loss ignores this parameter
NaNnorm is nanoverflow or a log/divide of zero upstream of here

The one-line check

[p.grad is None for p in model.parameters()] → any True is a bug

max(p.grad.norm() for p in model.parameters()) → log it every step

import torch, torch.nn as nn

w = torch.tensor([2.0], requires_grad=True)
loss = (w ** 2 + 3 * w).sum()     # dloss/dw = 2w + 3 = 7
loss.backward()
print(w.grad)                     # tensor([7.])

model = nn.Linear(4, 2)
nn.MSELoss()(model(torch.randn(8, 4)), torch.randn(8, 2)).backward()
for name, p in model.named_parameters():
    print(name, tuple(p.grad.shape), p.grad.norm().item())
# weight (2, 4) ... | bias (2,) ... — one gradient per parameter entry

Calling .backward() twice on the same loss. The graph is freed after the first pass, so the second raises Trying to backward through the graph a second time — recompute the forward pass, or pass retain_graph=True if you genuinely need two backward passes.

The update rule, written out by hand

What it is

An optimiser is not magic; plain SGD is two lines. After backward() has filled every .grad, walk the parameters and subtract lr * p.grad from each, inside torch.no_grad() so the update itself is not recorded as part of the graph. Then zero the gradients, because PyTorch accumulates them (module 7). torch.optim.SGD does exactly this, plus momentum and weight decay.

Why it matters

Writing it once removes the last piece of mystery from the training loop, and it makes the two most common loop bugs obvious. Updating without no_grad() builds a graph over the update, which leaks memory and eventually errors; forgetting to zero the gradients means every step uses the sum of all gradients so far, which grows the effective step size epoch after epoch. Both are visible in these five lines in a way they are not inside optimizer.step().

In the real world: Understanding the update helps a team explain and fix unstable training.

import torch, torch.nn as nn

model = nn.Linear(4, 1)
lr = 0.1

for xb, yb in loader:
    loss = nn.MSELoss()(model(xb), yb)
    model.zero_grad()
    loss.backward()
    with torch.no_grad():                 # the update is not part of the graph
        for p in model.parameters():
            p -= lr * p.grad              # θ ← θ − η ∇θ L

# Identical to: torch.optim.SGD(model.parameters(), lr=0.1).step()

Writing p = p - lr * p.grad instead of p -= lr * p.grad. The first rebinds a local name to a fresh tensor and leaves the model's actual parameter untouched, so the loop runs, reports gradients, and never changes the model.