Index Ai Academy
Phase 4 · Deep learning · Tooling

PyTorch autograd and modules

Let PyTorch compute gradients; build models with nn.Module.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

You've built backpropagation by hand, then as a tiny engine with your Value class. PyTorch's autograd is that same engine, built for whole tensors. The name is short for "automatic gradients". PyTorch also gives you nn.Module, the standard way to hold a model's weights. Every PyTorch model uses it, from a two-layer classifier to Llama. After this lesson you can read the model code in most open-source AI projects. You'll also be one step away from the standard training loop.

What you'll be able to do

  • Get gradients from PyTorch with requires_grad and backward()
  • Control the graph: accumulation and zero_grad, no_grad, detach
  • Build a model as an nn.Module from nn.Linear layers and inspect its parameters
  • Write one manual gradient-descent step on a model

What it covers

  1. Your Value class, for whole tensors
  2. Three rules to remember
  3. Models as nn.Module
  4. Worked example: the first neuron as an nn.Linear
  5. One training step, by hand

Your Value class, for whole tensors

Think of a shop till that prints a receipt. Each item you scan adds a line. At the end, the receipt shows exactly how the total was built. Your Value class did the same with sums. Each + or * added a line. Then backward() read the lines in reverse to find every gradient.

PyTorch does this for tensors, in three steps:

  1. Mark the tensors you want gradients for with requires_grad=True. In a model, these are the weights.
  2. Do your sums as usual. PyTorch records each operation as it goes. Print a result and you'll see grad_fn: the operation that made it.
  3. Call backward() on the final number. PyTorch fills in .grad on every marked tensor.

Note: import torch here loads a teaching subset of PyTorch, built on NumPy. It has the same names and behavior for everything in this lesson. It runs on the CPU only, and it's much slower. Its random numbers also differ from real PyTorch's. So seeded results on your own machine won't match these exactly.

Here is one neuron with two inputs. w holds its two weights and b holds its bias. The loss measures how far the output y lands from a target of 1:

import torch

w = torch.tensor([2.0, -1.0], requires_grad=True)
x = torch.tensor([3.0, 4.0])
b = torch.tensor(0.5, requires_grad=True)

y = (w * x).sum() + b              # one neuron's weighted sum
loss = (y - 1.0) ** 2
print(y)                           # grad_fn shows the operation that made it
print(loss)
loss.backward()
print("dloss/dw:", w.grad, "  dloss/db:", b.grad)
It runs right here in your browser, with nothing to install.

The first line prints tensor(2.5000, grad_fn=<AddBackward0>). AddBackward0 says an addition made y. The loss shows PowBackward0, because a power (the ** 2) made it. The last line prints the gradients: [9., 12.] for w and 3. for b.

Notice that x has no requires_grad. It's data, not a weight, so nobody needs its gradient.

Checking PyTorch by hand

You can get the same numbers yourself. Start with the forward pass. y is each weight times its input, added up, plus the bias: 2 × 3 + (−1) × 4 + 0.5 = 2.5. The loss is (2.5 − 1)² = 1.5² = 2.25.

Now the gradients. Write LL for the loss. The symbol ∂\partial is said "partial". ∂L/∂y\partial L/\partial y means how fast the loss changes when yy moves up a little. For a squared miss, it is twice the miss:

∂L∂y=2(y−1)=2×1.5=3\frac{\partial L}{\partial y} = 2(y - 1) = 2 \times 1.5 = 3

In plain words: if y went up a tiny bit, the loss would go up about 3 times as much.

Next, use the chain rule. Nudge the first weight up a tiny bit, and y moves 3 times as much, because that weight's input is 3. So the loss moves 3 × 3 = 9 times as much. The second weight's input is 4, so its gradient is 3 × 4 = 12. The bias adds straight into y, so its gradient is 3 × 1 = 3. In symbols, ww stands for the weights. xx stands for their inputs, [3, 4].

∂L∂w=∂L∂y⋅x=3×[3,4]=[9,12]\frac{\partial L}{\partial w} = \frac{\partial L}{\partial y} \cdot x = 3 \times [3, 4] = [9, 12]

In plain words: each weight's gradient is the loss's gradient times the input that weight multiplies.

Here is the same hand check in plain Python, with no PyTorch at all:

x = [3.0, 4.0]
y = 2.0 * 3.0 + (-1.0) * 4.0 + 0.5
dL_dy = 2 * (y - 1.0)
print(y, dL_dy, [dL_dy * xi for xi in x], dL_dy * 1)

It prints 2.5 3.0 [9.0, 12.0] 3.0. That matches PyTorch line for line.

Go deeper Why a squared miss has gradient 2 × miss

Call the miss mm. Here m=y−1=1.5m = y - 1 = 1.5, and the loss is m2m^2. Nudge mm by a tiny amount hh:

(m+h)2=m2+2mh+h2(m + h)^2 = m^2 + 2mh + h^2

Read it as: the loss grows by 2mh2mh, plus h2h^2. When hh is tiny, h2h^2 is far tinier, so you can ignore it. The loss grows about 2m2m times as fast as mm. With m=1.5m = 1.5, that's 3. You can check it: 1.5012−1.52=0.0030011.501^2 - 1.5^2 = 0.003001, about 3 × 0.001.

Quick check
You rerun the first cell with x = [1.0, 1.0] and everything else the same. What does this print?
import torch

w = torch.tensor([2.0, -1.0], requires_grad=True)
x = torch.tensor([1.0, 1.0])
b = torch.tensor(0.5, requires_grad=True)

loss = ((w * x).sum() + b - 1.0) ** 2
loss.backward()
print(w.grad)
  1. tensor([2., -1.])
  2. tensor([1., 1.])
  3. tensor([9., 12.])
  4. tensor([3., 3.])
Show the answer

tensor([1., 1.])

Now y = 2 − 1 + 0.5 = 1.5, so the loss's gradient for y is 2 × (1.5 − 1) = 1. Each weight's gradient is that 1 times its input, and both inputs are 1.

Three rules to remember

Autograd has three habits that surprise people. This cell shows all three. (A with block runs its indented lines under a setting, then switches the setting back.)

import torch

w = torch.tensor([1.0, 2.0], requires_grad=True)

(w * 3).sum().backward()
(w * 3).sum().backward()
print("1. gradients accumulate:", w.grad)       # 3 + 3, not 3
w.grad = None                                   # what optimizer.zero_grad() does

with torch.no_grad():
    z = w * 3
print("2. no_grad records nothing:", z.requires_grad)

d = (w * 3).detach()
print("3. detach cuts a tensor out of the graph:", d.requires_grad)
  1. Gradients add up. The first line prints tensor([6., 6.]), not 3. Each backward() adds its gradients to what's already in .grad. Think of a tip jar: each backward pass drops more in. This is on purpose. A weight used in two places should collect a gradient from each use, and adding is how it does that. So training loops clear gradients before every backward pass. Setting w.grad = None clears them. That's what optimizer.zero_grad() does, which you'll meet in the next lesson.
  2. torch.no_grad() switches recording off. Nothing inside the block goes on the receipt, so z.requires_grad is False. Use it when you test a model, and when you update weights. It saves memory. It also keeps the weight update out of the record, since the update isn't part of the model's own sums.
  3. .detach() gives the same numbers with no history. It cuts one tensor out of the record. Use it to log a loss for a chart, or to stop gradients flowing into one part of a model.
Quick check
You call loss.backward() twice without clearing gradients. What's in w.grad?
  1. The sum of both backward passes' gradients
  2. An error is raised every time
  3. Only the second pass's gradient
  4. Only the first
Show the answer

The sum of both backward passes' gradients

Gradients accumulate into .grad until you clear them. In the cell above, two passes of 3 each gave 6.

Models as nn.Module

A real model has thousands or billions of weights. You need a tidy way to keep them together. In PyTorch, a model is a class that inherits from nn.Module. Inherits means your class starts with everything nn.Module already has. You write that as class TinyClassifier(nn.Module):.

Think of a recipe card. The top lists the ingredients. The bottom says what to do with them. An nn.Module has the same two parts:

  • __init__ creates the layers. These are the ingredients, and they hold the weights.
  • forward says how data flows through the layers. That's the method.

Calling model(x) runs forward for you. And model.parameters() finds every weight in every layer, so you never list them by hand. (A parameter is any number the model learns: a weight or a bias.)

import torch
import torch.nn as nn

class TinyClassifier(nn.Module):
    def __init__(self, n_in, n_hidden, n_out):
        super().__init__()                      # required: sets up parameter tracking
        self.hidden = nn.Linear(n_in, n_hidden)
        self.out = nn.Linear(n_hidden, n_out)

    def forward(self, x):
        return self.out(torch.relu(self.hidden(x)))

torch.manual_seed(0)
model = TinyClassifier(4, 16, 3)
print(model)
for name, p in model.named_parameters():
    print(f"{name:<14} {tuple(p.shape)}")
print("total parameters:", sum(p.numel() for p in model.parameters()))

x = torch.randn(5, 4)                           # a batch of 5 examples
print("output shape:", model(x).shape)

What the output shows:

  • print(model) lists the layers by name: hidden and out.
  • named_parameters() gives each parameter with its name and shape. hidden.weight is (16, 4) and hidden.bias is (16,). out.weight is (3, 16) and out.bias is (3,).
  • numel() counts the numbers in a tensor. The total is 16 × 4 + 16 + 3 × 16 + 3 = 64 + 16 + 48 + 3 = 131.
  • A batch of 5 examples went in, with 4 numbers each. Out came 5 rows of 3 scores: shape [5, 3].

torch.relu is the ReLU activation. It keeps positive numbers and turns negative ones into 0.

super().__init__() must come first in __init__. It sets up the bookkeeping that tracks layers. Without it, PyTorch raises an error as soon as you add a layer.

What nn.Linear computes

nn.Linear(4, 16) is 16 neurons, and each one looks at the same 4 inputs. Each neuron has 4 weights and 1 bias. So the layer stores a weight table of shape (16, 4), one row per neuron. It also stores a bias of 16 numbers. Each output is one neuron's weighted sum plus its bias, like y in the first cell.

In symbols, the layer computes xW⊤+bx W^\top + \mathbf{b}. Here xx is the batch of inputs, one row per example. WW is the weight table. The small ⊤\top means "transposed": the table flipped so its rows become columns. Last, b\mathbf{b} is the bias.

In plain words: for each example, every neuron multiplies the inputs by its own weights, adds them up, and adds its bias.

The flip lines up the shapes. A (5, 4) batch times the flipped (4, 16) table gives (5, 16): one row per example, one column per neuron.

For simple stacks like this one there's a shortcut: nn.Sequential(nn.Linear(4, 16), nn.ReLU(), nn.Linear(16, 3)). It runs the layers in order, so you don't write forward yourself.

Worked example

Worked example: the first neuron as an nn.Linear

Make a layer with 2 inputs and 1 output. Set its numbers by hand to match the neuron from the first cell: weights 2 and −1, bias 0.5. Then feed it the inputs 3 and 4.

import torch
import torch.nn as nn

layer = nn.Linear(2, 1)                  # 2 inputs, 1 output
with torch.no_grad():                    # set the numbers by hand
    layer.weight[0, 0] = 2.0
    layer.weight[0, 1] = -1.0
    layer.bias[0] = 0.5

x = torch.tensor([[3.0, 4.0]])           # a batch of 1 example, 2 inputs
print(layer(x).item())                   # 2 × 3 + (-1) × 4 + 0.5
print(sum(p.numel() for p in layer.parameters()))   # 2 weights + 1 bias
In the lesson you can change this and run it.

The layer works out 2 × 3 + (−1) × 4 + 0.5 = 6 − 4 + 0.5 = 2.5. That's the same y as the first cell. .item() turns a one-number tensor into a plain Python number, so it prints 2.5. The weight table has shape (1, 2): one neuron, two inputs. Add the 1 bias and the layer has 3 parameters. The weights are set inside no_grad() for the same reason as a weight update: changing them isn't part of the model's sums.

Quick check
What does this print?
import torch.nn as nn

layer = nn.Linear(3, 2)
print(sum(p.numel() for p in layer.parameters()))
  1. 12
  2. 6
  3. 8
  4. 5
Show the answer

8

The layer has 2 neurons, each with 3 weights, so the weight table is (2, 3): 6 numbers. Each neuron also has a bias, which adds 2. 6 + 2 = 8.

One training step, by hand

Now put the pieces together. One step of training has four parts:

  1. Forward: run the model on the data and work out the loss. Autograd records as it goes.
  2. Clear: wipe old gradients with model.zero_grad(), because gradients add up.
  3. Backward: loss.backward() fills p.grad for every parameter p.
  4. Update: move each parameter a small step against its gradient, inside no_grad().

The data here is made up. x holds 32 random examples with 4 numbers each. The label y counts how many of the first two numbers are positive: 0, 1 or 2. So there are 3 classes. The model's raw scores, one per class, are called logits. F.cross_entropy is the classification loss from the cross-entropy lesson.

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 16), nn.ReLU(), nn.Linear(16, 3))
x = torch.randn(32, 4)
y = (x[:, 0] > 0).long() + (x[:, 1] > 0).long()      # a made-up 3-class target

for step in range(5):
    logits = model(x)
    loss = F.cross_entropy(logits, y)
    model.zero_grad()                                # clear old gradients
    loss.backward()                                  # fill p.grad for every parameter
    with torch.no_grad():
        for p in model.parameters():
            p -= 0.5 * p.grad                        # gradient descent
    print(f"step {step}  loss {loss.item():.4f}")

The line p -= 0.5 * p.grad is gradient descent. The 0.5 is the learning rate: how big a step to take. The minus sign moves each weight downhill, away from the direction that raises the loss.

In plain words: new weight = old weight − learning rate × gradient. A weight of 1.0 with a gradient of 0.2 becomes 1.0 − 0.5 × 0.2 = 0.9.

In this teaching version, the loss falls every step, from about 1.13 to about 0.82. Real PyTorch draws different random numbers for the weights and the data, so its losses will differ.

That loop is all of training. The next lesson hands the update to an optimizer (torch.optim) and adds the rest of the standard recipe.

Try it now

These five lines are what optimizer.step() and zero_grad() do for you.

Quick check
In the training step above, which line actually changes the model's weights?
  1. logits = model(x)
  2. model.zero_grad()
  3. p -= 0.5 * p.grad
  4. loss.backward()
Show the answer

p -= 0.5 * p.grad

loss.backward() only works out the gradients and stores them in .grad. zero_grad() clears them, and model(x) only uses the weights. The weights move only when the update line subtracts a step.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why are weight updates written inside torch.no_grad()?

  1. So the update itself isn't recorded in the graph (and in-place changes to weights are allowed)
  2. It makes updates faster on GPUs only, and it has no effect at all on a CPU
  3. Because backward() must be inside it, or the gradients don't reach the weights
  4. It resets the gradients, so you don't have to call zero_grad() before each step

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson