PyTorch autograd and modules
Let PyTorch compute gradients; build models with nn.Module.
Create a free account Try part of it here
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
- Your Value class, for whole tensors
- Three rules to remember
- Models as nn.Module
- Worked example: the first neuron as an nn.Linear
- 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:
- Mark the tensors you want gradients for with
requires_grad=True. In a model, these are the weights. - 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. - Call
backward()on the final number. PyTorch fills in.gradon every marked tensor.
Note:
import torchhere 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)
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 for the loss. The symbol is said "partial". means how fast the loss changes when moves up a little. For a squared miss, it is twice the miss:
In plain words: if
ywent 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, stands for the weights. stands for their inputs, [3, 4].
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 . Here , and the loss is . Nudge by a tiny amount :
Read it as: the loss grows by , plus . When is tiny, is far tinier, so you can ignore it. The loss grows about times as fast as . With , that's 3. You can check it: , about 3 × 0.001.
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)
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)
- Gradients add up. The first line prints
tensor([6., 6.]), not 3. Eachbackward()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. Settingw.grad = Noneclears them. That's whatoptimizer.zero_grad()does, which you'll meet in the next lesson. torch.no_grad()switches recording off. Nothing inside the block goes on the receipt, soz.requires_gradisFalse. 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..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.
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.forwardsays 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:hiddenandout.named_parameters()gives each parameter with its name and shape.hidden.weightis (16, 4) andhidden.biasis (16,).out.weightis (3, 16) andout.biasis (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 . Here is the batch of inputs, one row per example. is the weight table. The small means "transposed": the table flipped so its rows become columns. Last, 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: 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
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.
import torch.nn as nn layer = nn.Linear(3, 2) print(sum(p.numel() for p in layer.parameters()))
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:
- Forward: run the model on the data and work out the loss. Autograd records as it goes.
- Clear: wipe old gradients with
model.zero_grad(), because gradients add up. - Backward:
loss.backward()fillsp.gradfor every parameterp. - 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.
These five lines are what optimizer.step() and zero_grad() do for you.
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
Try a quiz question
Why are weight updates written inside torch.no_grad()?