ml-models / Neural network fundamentals / The training loop intro

The training loop

Every model is trained by the same short loop: take a batch, compute the loss, work out the gradients, tidy them, nudge the weights, move the learning rate, repeat. This page walks through each line of that loop with real numbers (an eight-step run on a tiny next-letter model), explains label smoothing, ignore_index, gradient clipping, warmup and cosine decay, mixed precision and the memory bill, and points at the PyTorch v2.15.0 code behind each call.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The loss: cross-entropy over logits
  5. Backward and the gradient buffer
  6. Gradient clipping
  7. The optimizer step
  8. The learning-rate schedule
  9. Mixed precision
  10. Train mode, eval mode and teacher forcing
  11. Worked example
  12. Eight steps on a tiny next-letter model
  13. Label smoothing and the ignored position
  14. Accumulating over two micro-batches
  15. Loss scaling rescues a small gradient
  16. What training a 7-billion-parameter model holds in memory
  17. Implementation
  18. Tradeoffs
  19. Check yourself
  20. Connections
  21. Read first
  22. Read next
  23. References

Problem

A model starts out knowing nothing: its weights are random (or, in our example, all zero). We want to turn it into one that predicts well, using a large pile of examples. No formula does that in one go. We need a procedure that makes the model a little better, many thousands of times in a row, without breaking along the way.

That procedure has to answer several practical questions. How do we score a prediction with one number? How do we use that score to change the weights? How big should each change be, and should that size stay fixed? What if one unlucky batch produces an enormous gradient? What if the numbers get too small for the chip's low-precision format? The training loop is the answer to all of them, and nearly every deep learning project, from a classroom classifier to a frontier language model, runs the same loop with different settings.

Intuition

Think of learning to throw darts. Throw a few, see how far off you are (the loss), work out which way to adjust your arm (the gradient), and adjust a bit (the optimizer step). You adjust a lot at first when you are far off and less as you improve (the learning-rate schedule). If one throw goes wildly wrong, you do not rebuild your whole stance because of it (gradient clipping). Each of those habits is one line in the loop.

One iteration of the training loop Seven boxes in a row show one training step. A batch goes into the model, which produces logits. The loss function turns logits and targets into one number. The backward pass fills each parameter's gradient. Gradient clipping rescales the gradients if their total norm is too large. The optimizer updates the weights. The scheduler sets the next learning rate. An arrow returns from the end to the start for the next batch, and zero_grad clears the gradients before it. batch x, y forward model(x) loss loss_fn(...) backward loss.backward() clip clip_grad_norm_ step opt.step() schedule sched.step() next batch, after opt.zero_grad() logits [N, C] one scalar p.grad filled optional weights change new learning rate dashed boxes are optional; most real runs use all seven

The order matters in two places: clip after backward() and before step(), and call the scheduler after the optimizer.

Mechanics

The loss: cross-entropy over logits

A language or classification model ends in a vector of logits, one raw score per class. Cross-entropy turns the logits z into probabilities with softmax and scores the correct class y: ℓ = −log softmax(z)y = −zy + log Σc exp(zc). Its gradient with respect to the logits is the beautifully simple p − onehot(y): the predicted probabilities minus the answer (derived on the loss functions page, with the same numbers for the cat, dog, bird example). PyTorch's CrossEntropyLoss takes logits of shape [N, C] (or [N, C, d₁, …]) and integer targets of shape [N], and calls F.cross_entropy (lines 1398-1407).

Two arguments matter for language models:

  • ignore_index (default -100): any position whose target equals it contributes nothing to the loss and nothing to the gradient, and reduction="mean" divides by the number of positions that were not ignored. This is how padding and masked prompt tokens are skipped. If every target is ignored the mean is 0/0, which is NaN.
  • label_smoothing (default 0.0): the target becomes a mixture of the true class and a uniform distribution (the idea comes from Rethinking the Inception Architecture, cited in the docstring at lines 1293-1296). With smoothing ε the loss is (1 − ε)·(−log py) + ε·(mean over classes of −log pc). It stops the model chasing probability 1.0 for the label.

Backward and the gradient buffer

loss.backward() runs backpropagation and adds each parameter's gradient into its .grad attribute. Adding, not overwriting, is why the loop calls optimizer.zero_grad() every iteration (by default it sets the gradients to None, set_to_none=True). It is also what makes gradient accumulation possible: call backward() on several micro-batches before one step().

Gradient clipping

Occasionally a batch produces a gradient hundreds of times larger than usual, and one step of that size can wreck the weights. clip_grad_norm_(parameters, max_norm) looks at all gradients as one long vector, computes its length (_get_total_norm), and multiplies every gradient by min(max_norm / (total_norm + 10⁻⁶), 1) (lines 164-168). The direction is preserved; only the length is capped. Gradients that are already short are left alone, because the factor is clamped to 1. The function modifies .grad in place and returns the pre-clip total norm, which is worth logging: a spike in it is an early warning.

The optimizer step

optimizer.step() reads each .grad and updates the weights. For language models the usual choice is AdamW with decoupled weight decay, default weight_decay=1e-2; its update rule, state and the decay-versus-L2 difference are on the optimizers page. The numbers on this page use that page's optimizer_step helper.

The learning-rate schedule

A constant learning rate is rarely best. Early on, Adam's running averages are noisy and a large rate can throw the model somewhere bad, so the rate warms up linearly from near zero. Later, a smaller rate lets the weights settle, so the rate decays, most often along a half cosine down to a floor:

ηt = ηmin + ½(ηmax − ηmin)(1 + cos(π·t′/T))

where t′ counts steps since warmup ended and T is the number of decay steps. In PyTorch this is built from two schedulers chained with SequentialLR: LinearLR for the warmup and CosineAnnealingLR for the decay. At the milestone, SequentialLR.step restarts the next scheduler at its own epoch 0 (lines 1186-1197), and the cosine's closed form is the formula above (lines 1456-1461). The constructor of every scheduler overwrites the optimizer's lr, so build the scheduler once, after the optimizer, and call scheduler.step() once per optimizer step.

Learning rate against step: warmup, then cosine decay A typical large-model schedule over 1,000 steps with a peak learning rate of 3 × 10⁻⁴, 100 warmup steps and a floor of 3 × 10⁻⁵. The rate climbs in a straight line from 3e-06 to 3e-04 over the first 100 steps, then falls along a half cosine, slowly at first, fastest in the middle, and slowly again, ending at 3.0e-05. warmup ends, step 100 3e-4 3e-5 0 1000 optimizer step

The peak and floor are typical for a small language model, not prescriptions. Many recipes set the floor at 10% of the peak.

Mixed precision

Modern GPUs run matrix multiplications much faster in 16-bit numbers. Mixed precision keeps the weights in 32-bit but runs the heavy operations in 16-bit. torch.autocast (device_type, dtype=None, enabled=True, constructor at lines 222-228) is a context manager: inside it, eligible operations run in the lower precision. The docstring warns against also calling .half() or .bfloat16() on the model or inputs (line 62).

The float16 format has a catch: its smallest positive numbers are tiny but not tiny enough, so small gradients silently become exactly 0 (underflow). The fix is loss scaling: multiply the loss by a large constant before backward(), so every gradient is that many times bigger, and divide it out again before the update. PyTorch's GradScaler automates it, starting at init_scale=2**16 (lines 121-129):

  • scaler.scale(loss) multiplies the loss by the current scale (line 193).
  • scaler.unscale_(optimizer) divides the gradients back down, and is the call to make before clipping so the clip threshold applies to real-size gradients (lines 302-374).
  • scaler.step(optimizer) unscales if needed, and skips the update if any gradient is infinite or NaN (lines 375-483).
  • scaler.update() multiplies the scale by backoff_factor=0.5 after a skipped step, and by growth_factor=2.0 after growth_interval=2000 consecutive good steps (lines 484-560).

Train mode, eval mode and teacher forcing

model.train() and model.eval() flip a flag on every submodule (train, eval). It changes how Dropout and BatchNorm behave (see normalization) and does nothing else; it does not turn gradient tracking off. For that, wrap evaluation in torch.no_grad().

Sequence models are trained with teacher forcing: at every position the model is given the true previous tokens, not its own earlier guesses, and asked for the next one. That lets all positions be scored in one parallel pass. At generation time there is no truth to feed in, so the model consumes its own output one token at a time, which is why training and inference differ (see the transformer page).

ComponentShape/ParamsMeaningSource
nn.CrossEntropyLoss input [N, C], target [N]; ignore_index=-100, reduction="mean", label_smoothing=0.0 Softmax cross-entropy on logits; ignored targets are dropped from the mean. loss.py
Module.zero_grad / Optimizer.zero_grad set_to_none=True Clears the accumulated .grad buffers. optimizer.py
clip_grad_norm_ max_norm, norm_type=2.0, error_if_nonfinite=False, foreach=None Scales all gradients so their global norm is at most max_norm; returns the norm before clipping. clip_grad.py
AdamW lr=1e-3, betas=(0.9, 0.999), weight_decay=1e-2 Adam with decoupled weight decay. adamw.py
LinearLR, CosineAnnealingLR, SequentialLR start_factor, total_iters; T_max, eta_min; milestones Warmup, cosine decay, and the switch between them. lr_scheduler.py
torch.autocast device_type, dtype=None, enabled=True Runs eligible operations in lower precision inside a with block. autocast_mode.py
GradScaler init_scale=2**16, growth_factor=2.0, backoff_factor=0.5, growth_interval=2000 Loss scaling with skip-on-overflow and automatic scale adjustment. grad_scaler.py
Module.train / Module.eval mode=True Sets the training flag on a module and all its children. module.py

Worked example

Eight steps on a tiny next-letter model

The model is as small as a language model can be: a table of logits with one row per current letter. Reading the row for the letter we are on gives the scores for the next one, so there are 4 × 4 = 16 weights, all starting at 0. The vocabulary is a, b, c, d. The batch has 6 positions, with inputs 0, 1, 2, 3, 1, 2 and targets 1, 2, 3, -100, 2, 3. The fourth target is -100, so that position is ignored.

Step 1 by hand. With every weight 0, every row of logits is [0, 0, 0, 0], so the softmax is uniform (probability 0.25 each) and every position has loss −log 0.25 = 1.386. The mean is over the 5 kept positions, not 6, so the loss is 1.386 = ln 4. Each kept row's gradient is p − onehot = [0.25, 0.25, 0.25, 0.25] with 0.25 − 1 = −0.75 at the target, divided by 5. Stacked over the table that gradient has length 0.5196, which is more than our limit max_norm = 0.5, so every gradient is multiplied by 0.9622 (= 0.5 / (0.5196 + 10⁻⁶)). The schedule is in warmup, so the learning rate is 0.1 (= 0.2 × 1/2).

StepLearning rateLossGradient normClip factorClipped?
1 0.1000 1.3863 0.5196 0.9622 yes
2 0.2000 1.2402 0.4924 1.0000 no
3 0.2000 0.9736 0.4311 1.0000 no
4 0.1879 0.7461 0.3643 1.0000 no
5 0.1550 0.5704 0.3012 1.0000 no
6 0.1100 0.4527 0.2523 1.0000 no
7 0.0650 0.3834 0.2206 1.0000 no
8 0.0321 0.3478 0.2035 1.0000 no

The run uses AdamW with weight decay 0.01, peak learning rate 0.2, 2 warmup steps, a cosine down toward 0.02 over 8 steps, and clipping at 0.5. Only step 1 was clipped. Warmup makes the first two steps gentle, the loss falls every step from 1.386 to 0.3478, and the gradient shrinks as the model gets its answers right, so later clipping never fires. The final learning rate (0.0321) has not quite reached the floor: the cosine reaches 0.02 one step after the last.

Loss and gradient norm over the eight steps Two lines over eight training steps. The loss (green) starts at 1.39, which is ln 4, and falls at every step to 0.35. The gradient norm (amber) starts at 0.52 and falls to 0.20. A dashed line marks the clipping threshold of 0.5, which only the first step crosses. max_norm 0.5 1 2 3 4 5 6 7 8 loss gradient norm (before clipping) training step

Label smoothing and the ignored position

For logits [2.0, 1.0, -1.0] and correct class cat, the plain cross-entropy is 0.3490. With label_smoothing=0.1 it is 0.9 × 0.3490 + 0.1 × (the average −log p over all three classes) = 0.4823. It is larger, and it can never reach 0: the smoothed target asks for a little probability on the wrong classes too.

Accumulating over two micro-batches

Take two micro-batches of the same tiny model (inputs 0, 1, 2 with targets 1, 2, 3, and inputs 3, 1 with targets ignored, 2): one with 3 kept targets and the other with 1 kept target. Averaging the two micro-batch gradients with equal weights, which is what dividing every micro-batch loss by 2 does, differs from the full-batch gradient by up to 0.125 in an entry, because the smaller micro-batch gets too much say. Weighting each micro-batch by its share of kept targets reproduces the full-batch gradient exactly (largest difference 0e+00). The rule: normalize by the total number of kept tokens, not by the number of micro-batches.

Loss scaling rescues a small gradient

A gradient of 1e-08 is below float16's smallest positive number, so stored in float16 it becomes 0.0. Multiplied by the scale 65536 (2¹⁶) first, it is 6.552e-04, which float16 can hold; dividing by the scale afterwards recovers 9.997e-09, within the format's rounding of the original. Now watch the scale itself over eight steps, using growth_interval=3 so the pattern fits on the page (the default is 2000):

StepScale usedGradients overflowed?Scale for the next step
1 65536 no 65536
2 65536 no 65536
3 65536 no 131072
4 131072 yes, step skipped 65536
5 65536 no 65536
6 65536 no 65536
7 65536 no 131072
8 131072 no 131072

After three good steps the scale doubles. At step 4 the doubled scale makes some gradient overflow to infinity, so the optimizer update is skipped, the scale is halved, and the count of good steps restarts. The scaler hovers just under the largest scale that does not overflow.

What training a 7-billion-parameter model holds in memory

For AdamW with 32-bit weights, before counting activations (which depend on batch size and sequence length):

ItemBytes per parameterAt 7 billion
Weights428 GB
Gradients428 GB
Adam exp_avg428 GB
Adam exp_avg_sq428 GB
Total, before activations16112 GB

That is 112 GB, more than one accelerator holds. This is why large runs shard these tensors across devices (see the optimizers page's memory table for the per-optimizer numbers).

Implementation

import math
import torch
from torch import nn
from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR

model = MyModel().cuda()
loss_fn = nn.CrossEntropyLoss(ignore_index=-100, label_smoothing=0.0)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)

warmup, total = 100, 1000
sched = SequentialLR(
    opt,
    [LinearLR(opt, start_factor=1 / warmup, total_iters=warmup - 1),
     CosineAnnealingLR(opt, T_max=total - warmup, eta_min=3e-5)],
    milestones=[warmup],
)
scaler = torch.amp.GradScaler("cuda")          # float16 only

model.train()
for x, y in loader:                            # x: [B, T] token ids, y: [B, T]
    opt.zero_grad(set_to_none=True)
    with torch.autocast("cuda", dtype=torch.float16):
        logits = model(x)                      # [B, T, V]
        loss = loss_fn(logits.flatten(0, 1), y.flatten())
    scaler.scale(loss).backward()
    scaler.unscale_(opt)                       # gradients are real-size again
    norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    scaler.step(opt)                           # skipped if a gradient is inf/NaN
    scaler.update()
    sched.step()

model.eval()
with torch.no_grad():
    val_loss = loss_fn(model(xv).flatten(0, 1), yv.flatten())

With bfloat16 there is no GradScaler: its exponent range matches float32's, so gradients do not underflow the way float16 ones do. Pass dtype=torch.bfloat16 to autocast and drop the scaler lines. The same loop in plain Python, as used for the numbers on this page:

import math

def clip(grads, max_norm):
    total = math.sqrt(sum(g * g for g in grads))
    coef = min(max_norm / (total + 1e-6), 1.0)
    return [g * coef for g in grads], total

def lr_at(step, peak, warmup, total, floor):
    if step < warmup:
        return peak * (step + 1) / warmup
    t = (step - warmup) / (total - warmup)
    return floor + (peak - floor) * (1 + math.cos(math.pi * t)) / 2

# per step: loss -> gradients -> clip -> AdamW update with lr_at(step, ...)

Tradeoffs

  • Clipping threshold. A threshold of 1.0 is a common default. Set too low it turns every step into a fixed-length step and slows learning; set too high it never fires. Log the pre-clip norm and choose a value just above its typical size.
  • Warmup length. Too short and early steps can diverge, especially with Adam, whose second-moment estimate is poor in the first iterations (see Liu et al., On the Variance of the Adaptive Learning Rate and Beyond). A few hundred to a few thousand steps is typical for language models; large-batch training also warms up (Goyal et al.).
  • Decay shape. Cosine to a floor is a popular default, but linear decay and constant-then-decay schedules work too. Cosine needs the total number of steps in advance, which makes extending a run awkward.
  • Label smoothing. It can improve calibration and generalization on classification, but it flattens the model's confidence, which hurts if you later rely on exact probabilities.
  • float16, bfloat16 or float32. float16 is precise but narrow and needs the scaler; bfloat16 is coarse but wide and needs none; float32 is the safe, slow baseline.
  • Pitfalls. Forgetting zero_grad() accumulates gradients across steps. Calling scheduler.step() before optimizer.step() skips the first learning rate and warns. Clipping before unscale_ clips scaled gradients against an unscaled threshold. Forgetting model.eval() leaves dropout on during validation. Averaging losses over micro-batches with unequal numbers of kept tokens biases the gradient.

Check yourself

Why is the first loss in a classifier with C classes and zero or tiny initial weights about ln C?

The logits are nearly equal, so softmax is uniform and the correct class gets probability 1/C; −ln(1/C) = ln C. Here ln 4 = 1.386. A first loss far from ln C is a bug hint.

If a batch has 3 kept targets and its loss is the mean over 6 positions, what is wrong?

The ignored positions are being counted in the denominator, shrinking the loss and gradient by half. CrossEntropyLoss divides by the kept count.

The gradient norm is 4.0 and max_norm is 1.0. What is each gradient multiplied by, and what is the norm afterwards?

About 0.25 (1 / (4 + 10⁻⁶)); the norm afterwards is about 1.0. The direction is unchanged.

Why must scaler.update() come after scaler.step(optimizer)?

step is what discovers that a gradient overflowed and skips the update; update uses that finding to back the scale off or grow it. The docstring says to call it at the end of the iteration.

Connections

References