Concept units

Automatic gradients in PyTorch

Intermediate

Read these first if they are new to you

The idea in one paragraph

To improve a model, you need to know how the loss, one number that says how wrong the model is, changes when you nudge each weight: its derivative. PyTorch finds these for you. Mark a tensor with requires_grad=True, and PyTorch records every operation that uses it, building a chain of steps from the inputs to the final number. Calling .backward() on that number walks the chain in reverse. At each step it multiplies by that step's local slope, which is the chain rule. The derivative of the final number with respect to each marked tensor ends up in that tensor's .grad.

PyTorch records each step on the way forward

Take a tiny model with one weight w. It predicts p = w × x for an input x = 2, and the right answer is t = 7. The error is e = p − t. The loss, one number that says how wrong the model is, is L = e². With w = 3, the steps give p = 6, e = −1 and L = 1.

When w is a tensor with requires_grad=True, PyTorch keeps a record of these steps as it runs them. The record is the computation graph: each result remembers which operation made it and from which inputs. The figure draws it.

Forward values go right; gradients come back left

Before you move the slider, predict the sign of dL/dw at w = 3: should w go up or down to make L smaller? Then drag w until the gradient is 0.

w x = 2 × − 7 ( )² L p = 6 e = −1 1 3 dL/dp = −2 dL/de = −2 dL/dw = −4 local slope dp/dw = x = 2 local slope de/dp = 1 local slope 2e blue: value on the way forwardorange: gradient of L, on the way back
Multiply the local slopes along the path. The gradient at w is 2e × 1 × x. When it is negative, raising w lowers the loss.

.backward() multiplies local slopes from the end to the start

Each step in the graph has a simple local slope: how much its output changes per unit change of its input. PyTorch knows the local slope of every built-in operation. L.backward() starts at L, where the slope of L with respect to itself is 1. It then moves back one step at a time, multiplying by each local slope. That product is the chain rule. The result for each marked tensor is stored in its .grad.

Worked example: the gradient at w = 3, by hand
  1. Forward: p = 3 × 2 = 6, e = 6 − 7 = −1, L = (−1)² = 1.
  2. Square step: dL/de = 2e = −2.
  3. Subtract step: de/dp = 1, so dL/dp = −2 × 1 = −2.
  4. Multiply step: dp/dw = x = 2, so dL/dw = −2 × 2 = −4.
  5. Check by nudging: at w = 3.01, L = (6.02 − 7)² = 0.9604. The loss fell by 0.0396 for a step of 0.01, a slope of −3.96. That is close to −4, as it should be for a small nudge.

one_weight.py

import torch

x, t = 2.0, 7.0
w = torch.tensor(3.0, requires_grad=True)
p = w * x          # prediction: 6
e = p - t          # error: -1
L = e ** 2         # loss: 1
L.backward()       # work backward from L
print(L.item(), w.grad.item())
1.0 -4.0

.item() turns a one-number tensor into a plain Python number.

One backward pass gives the gradient for every weight

Real models have millions of weights. A single call to .backward() fills .grad for all of them, and it costs about as much as a few forward passes. The list of these derivatives is the gradient, and it is what gradient descent uses to update the weights. Backpropagation shows how this backward pass runs through the layers of a network.

Here the model has three weights and one loss. Before you read the output, use the worked example to predict w.grad: the error is −2, so each gradient is 2 × (−2) × x.

three_weights.py

import torch

w = torch.tensor([1.0, -2.0, 0.5], requires_grad=True)
x = torch.tensor([3.0, 1.0, 4.0])
L = ((w * x).sum() - 5.0) ** 2   # one number from three weights
L.backward()
print(L.item())
print(w.grad)
4.0
tensor([-12.,  -4., -16.])

Two habits: clear old gradients, and switch recording off when you do not need it

.backward() adds to .grad; it does not replace it. Training code therefore clears the gradients before each step, by setting them to None or by calling the optimizer's zero_grad(). Recording also costs memory, because the graph keeps every intermediate result. Code that only uses the model, such as testing or generating, runs inside torch.no_grad(), which turns recording off.

habits.py

import torch

w = torch.tensor(3.0, requires_grad=True)
for step in range(2):
    L = (w * 2.0 - 7.0) ** 2
    L.backward()
    print("after backward", step + 1, "w.grad =", w.grad.item())
w.grad = None                  # clear it before the next step

with torch.no_grad():          # no recording inside this block
    L = (w * 2.0 - 7.0) ** 2
print(L.requires_grad)
after backward 1 w.grad = -4.0
after backward 2 w.grad = -8.0
False
Details this page leaves out

.backward() with no argument works only on a single number, such as a loss. PyTorch frees the graph after the backward pass, so each step builds a new one; that is why the loop above recomputes L. Only tensors created with requires_grad=True keep a .grad. A model's weights are such tensors, because PyTorch layers create them that way. Intermediate results such as p keep no .grad, unless you ask. The method is called reverse-mode automatic differentiation.

Check yourself

  1. Your turn with new numbers: x = 3, t = 2 and w = 1, with the same model L = (wx − t)². Find L and dL/dw by hand.

    Answer

    p = 3, e = 1, L = 1. Then dL/dw = 2e × 1 × x = 2 × 1 × 3 = 6. It is positive, so lowering w lowers the loss.

  2. A training loop calls loss.backward() on every batch but never clears the gradients. What goes wrong?

    Answer

    Each .grad becomes the sum of the gradients of all batches so far, not the gradient of the current batch. The weight updates grow and point in a stale direction.

  3. Why does code that generates new samples from a trained model run inside torch.no_grad()?

    Answer

    It never calls .backward(), so recording the graph only wastes memory and time. Turning recording off saves both.

Go further

Optional background.

Where this shows up

Units that build on this one