9. From-Scratch MLP Training Step: Forward, Loss, Backward, Optimizer
Problem
Before any RLHF-specific machinery, an interviewer can just check whether you can write the four-line skeleton every PyTorch training loop is built from, in the right order, with no shortcuts: zero out stale gradients, run the forward pass, compute the loss, backpropagate, and step the optimizer.
Getting the order wrong — stepping before backward, or forgetting to zero gradients — silently corrupts training without raising an error, which is exactly what makes it worth testing directly rather than assuming it's obvious.
Source: src/9_mlp_training_step.py
def train_step(
model: torch.nn.Module,
optimizer: torch.optim.Optimizer,
x: torch.Tensor,
y: torch.Tensor,
loss_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
) -> torch.Tensor: ...
>>> model = nn.Linear(2, 1)
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
>>> x = torch.tensor([[1.0, -1.0]])
>>> y = torch.tensor([[1.0]])
>>> loss = train_step(model, optimizer, x, y, nn.MSELoss())
>>> loss.requires_grad
False
Step-by-Step Approach
- Call
optimizer.zero_grad()first — gradients accumulate into.gradby default in PyTorch, so skipping this adds the new step's gradient on top of whatever was left over from the previous step. - Run the forward pass:
predictions = model(x). - Compute the loss:
loss = loss_fn(predictions, y). - Call
loss.backward(), which populates.gradon every parameter withrequires_grad=Truethat contributed to the loss. - Call
optimizer.step()— this must come strictly afterbackward(), since it reads the just-populated.gradvalues to update the parameters. - Return
loss.detach(), not the rawloss— the caller almost never needs the still-attached computation graph, and holding onto it needlessly keeps memory allocated.
The key insight is that this exact five-line sequence — zero, forward, loss, backward, step — is the one piece of PyTorch boilerplate that has to be memorized cold, because every bug in the wrong order (stepping before backward, forgetting to zero) trains a model that runs without any error and just quietly doesn't learn correctly.
Reference solution
import torch
import torch.nn as nn
def train_step(model, optimizer, x, y, loss_fn):
# zero_grad first: gradients accumulate by default in PyTorch, so
# skipping this adds the new step's gradient on top of the last one
optimizer.zero_grad()
predictions = model(x)
loss = loss_fn(predictions, y)
loss.backward() # populates .grad on every parameter with requires_grad=True
optimizer.step() # must come AFTER backward -- it reads .grad to update params
return loss.detach() # detach: caller gets the number, not a live graph handle
Key Functions & Tricks
optimizer.zero_grad()— clears accumulated.gradon every parameter the optimizer manages; call it beforebackward(), not after.loss.backward()— walks the autograd graph backward fromloss, accumulating gradients into every leaf tensor withrequires_grad=Truealong the way.optimizer.step()— applies one update to every managed parameter using its current.grad; a no-op if.gradis stillNoneor stale.loss.detach()— returns the same numeric value without the graph attached, the correct thing to hand back to a caller that just wants to log or compare the loss.
How to Recognize This Pattern
Recognize this pattern any time a problem says "implement a training step" or "training loop" with no further architectural complexity specified — it's testing whether the zero/forward/loss/backward/step order is automatic, not whether you can design a clever model. Common variations add gradient accumulation (skip zero_grad()/step() on some iterations to average gradients over several micro-batches), gradient clipping (torch.nn.utils.clip_grad_norm_ between backward() and step()), or mixed-precision training (wrapping the forward/backward in a torch.autocast context with a GradScaler). A common pitfall is calling optimizer.step() before loss.backward(), or returning the raw loss tensor from a training-loop helper that then gets logged every step, slowly accumulating a huge unused computation graph in memory since each still-attached loss keeps its whole graph alive.