Training loops¶
Training sanity checks a model with a standard loop. This page tests your training loop: your training step with its custom losses, several optimizers, gradient accumulation, clipping and mixed precision, and your checkpoint code. Each check runs a few steps from fresh, seeded setups and explains what went wrong.
| Check | Catches |
|---|---|
assert_optimizer_coverage |
Trainable parameters left out of every optimizer or given to several; an optimizer built for another copy of the model |
run_training → run.assert_healthy() |
Non-finite losses or gradients, exploding gradients, skipped updates, unexpected learning rates, a loss that doesn't decrease |
assert_resume_equivalent |
Optimizer, scheduler, GradScaler or RNG state missing from a checkpoint |
assert_accumulation_equivalent |
A loss not divided by the accumulation steps, gradients not reset, optimizer steps at the wrong micro-batch |
assert_mixed_precision_training |
optimizer.step() bypassing the GradScaler, a missing scaler.update(), clipping before unscaling, overflows |
Setup and step¶
Two functions describe your training code. make_setup() builds everything a step needs
and returns a TrainingSetup (or a (model, optimizer[, scheduler]) tuple). It may take
the device as its only argument.
from pytest_torch import TrainingSetup
def make_setup():
model = Classifier()
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)
warmup = torch.optim.lr_scheduler.LambdaLR(optimizer, lambda step: min(1.0, (step + 1) / 4))
return TrainingSetup(model, optimizer, schedulers=warmup)
TrainingSetup(model, optimizers, schedulers=None, scaler=None, state={}) takes one
optimizer or scheduler, a list or a dict of named ones. state holds other objects a
checkpoint must carry, such as an EMA model (anything with state_dict() and
load_state_dict()). In the step, setup.optimizer and setup.scheduler are the only
optimizer and scheduler; with several, use setup.optimizers["name"].
The step is your training step, step(setup, batch). It does the forward and backward
passes and the optimizer and scheduler steps, and returns the loss: a tensor, a float, or a
dict of named losses (the "loss" entry, or else the first, is the main one).
setup.step_index is the index of the current step.
def train_step(setup, batch):
x, y = batch
loss = F.cross_entropy(setup.model(x), y)
loss.backward()
torch.nn.utils.clip_grad_norm_(setup.model.parameters(), 1.0)
setup.optimizer.step()
setup.optimizer.zero_grad()
setup.scheduler.step()
return loss
No step function of your own? Pass loss_fn=lambda model, batch: ... instead, and
standard_step does the rest: accumulation, clipping, a GradScaler when the setup has one.
data is one batch, a list of batches (cycled), an iterable such as a DataLoader (its
first batches), or a function from the step index to a batch. Batches are moved to the
model's device.
The training_runner fixture¶
Every function on this page is also a method of the training_runner fixture, which runs it
on the test's torch_device with the test's torch_seed:
def test_training(training_runner):
run = training_runner.run(make_setup, batches, train_step, steps=20)
run.assert_healthy(max_grad_norm=50.0, min_relative_decrease=0.3)
Outside pytest, call the functions with device= and seed=.
Optimizer coverage¶
from pytest_torch import assert_optimizer_coverage
setup = make_setup()
assert_optimizer_coverage(setup.model, setup.optimizers)
Optimizer coverage: 5 problem(s):
encoder.0.weight: trainable but in no optimizer, so never updated
...
'optimizer': 4 tensor(s) that aren't parameters of the model (e.g. shape (16, 8)): the optimizer was built for another copy of the model
It fails on trainable parameters that no optimizer updates, parameters in several optimizers
or parameter groups (updated twice per step), and optimizer tensors that aren't the model's
parameters: an optimizer built before a deepcopy, re-creation or reload of the model.
Frozen parameters inside an optimizer are allowed unless allow_frozen=False; ignore=
takes fnmatch patterns of parameter names. Runs from run_training check coverage before
training too, and run.assert_healthy() reports the problems.
Short runs with telemetry¶
run = run_training(make_setup, batches, train_step, steps=20)
run.assert_healthy(max_grad_norm=50.0, min_relative_decrease=0.3)
run.assert_lr(lambda step: 3e-3 * min(1.0, (step + 1) / 4))
print(run.table())
Each optimizer's step is wrapped for the duration of the run, so every step is recorded
without changing your code:
step loss grad norm update lr opt steps
0 1.433 1.212 0.03406 0.00075,0.00075 1
1 1.398 1.087 0.06118 0.0015,0.0015 1
2 1.302 1.154 0.08533 0.00225,0.00225 1
...
run.records[i] (StepRecord) |
Meaning |
|---|---|
losses, loss |
What the step returned; loss is the main one |
grad_norm[opt] |
Total L2 norm of the gradients when opt.step() ran |
update_norm[opt] |
L2 norm of the parameter change made by the step |
lrs[opt] |
Learning rate of each parameter group, as used by the step |
optimizer_steps[opt], skipped |
How many times each optimizer stepped; skipped when none did |
nonfinite_grads |
Parameters with NaN or Inf gradients at a step |
loss_scale |
The GradScaler's scale after the step |
| Assertion | Fails when |
|---|---|
assert_finite() |
A loss, a gradient applied by a step, or a final parameter isn't finite |
assert_no_skipped_updates(every=1, max_skipped=0) |
A step (every every-th with accumulation) doesn't step any optimizer, or an optimizer steps without changing the parameters (learning rate 0, gradients None) |
assert_grad_norm_below(max_norm) |
A gradient norm exceeds max_norm |
assert_lr(expected, optimizer=None, group=0) |
A learning rate differs from expected(step), or from expected[i] for a list |
assert_loss_decreases(min_relative_decrease=0.1) |
The mean loss of the last quarter of the steps isn't that much below the first quarter's |
assert_healthy(max_grad_norm=None, min_relative_decrease=None) |
Coverage, assert_finite, assert_no_skipped_updates, and the bounds you pass |
Failures include the telemetry table around the failing steps. Every assertion returns the
run, so they chain. track_updates=False skips copying the parameters before each step
(no update_norm), for models too big to copy.
Checkpoint and resume¶
from pytest_torch import assert_resume_equivalent
assert_resume_equivalent(make_setup, batches, train_step, steps=8,
save=save_checkpoint, load=load_checkpoint)
This trains 8 steps without interruption, saving a checkpoint after 4 (resume_at) with
your save(setup, path). A fresh setup, seeded differently, then restores it with your
load(setup, path) and trains the last 4 steps. The losses and final weights must match the
uninterrupted run. To explain a difference, the state right after loading is compared with
the state when saving:
Resuming from a checkpoint saved after step 4 does not reproduce the uninterrupted run:
first divergence at step 5: loss 1.43516 after resuming, 1.40454 without interruption
final weights: 6 tensor(s) differ (...)
State after loading vs when saving:
optimizer 'optimizer': state differs (e.g. state[0]['step'] missing): optimizer state not restored, so momentum, Adam moments and step counts restart
optimizer 'optimizer' group 0: lr 0.00075 after loading, 0.003 when saved
Missing model weights, optimizer state, learning rates, scheduler, GradScaler or
setup.state fail even when the losses still match. Random number generators that weren't
restored are reported when the runs diverge; without randomness in training they don't
matter and don't fail. When everything in the checkpoint matches but the runs still
diverge, the message points at what lives outside it: a stateful data pipeline, plain
Python attributes, nondeterministic kernels.
save_training_state(setup, path) and load_training_state(setup, path) save and restore
everything, RNG states included; they're the defaults and a reference implementation.
training_state(setup) returns the dict they save. The function returns a ResumeReport
(continuous, resumed, state_differences, diverged_at). The comparison tolerance is
the training_resume check's (rtol=1e-5, atol=1e-6), configurable in
Tolerances.
Gradient accumulation¶
from pytest_torch import assert_accumulation_equivalent
assert_accumulation_equivalent(
make_setup, batches, lambda k: Trainer(accumulation_steps=k).step, accumulation_steps=4,
)
make_step(k) returns your step configured for k accumulation steps (or pass loss_fn=
to test standard_step). From identical fresh setups, one step with make_step(1) on a
full batch is compared with 4 steps of make_step(4) on its micro-batches, for 2 batches
(cycles). The reference starts every step from zeroed gradients. The check fails when:
- an optimizer steps before the last micro-batch, or doesn't step after it;
- the accumulated gradients differ from the full-batch gradients. It says when they are
ktimes too large (the loss isn't divided by the accumulation steps),1/k(divided twice), or carry over from the previous cycle (nozero_grad()after the step); - the weights after a cycle differ although the gradients match (clipping or scheduler steps per micro-batch).
Gradient accumulation over 4 micro-batches differs from one full-batch step (cycle 1 of 2):
head.weight: 45/48 elements differ beyond tolerance (rtol=0.0001, atol=1e-06)
...
Likely cause:
gradients are 4x the full-batch gradients: the loss isn't divided by accumulation_steps
Batches are split along the first dimension (split_batch; pass split= for other
layouts), so the batch size must be divisible by accumulation_steps. Dropout draws
different masks for a split batch, so dropout modules are disabled in both runs
(disable_dropout=False keeps them). BatchNorm can't match exactly, since it normalizes each
micro-batch with that micro-batch's statistics: the failure says so. Raise the tolerance
(grad_accumulation check, default rtol=1e-4, atol=1e-6) or test with
GroupNorm/LayerNorm. A clipping threshold that is reached also breaks the equivalence: test
with a high threshold, as the example below does.
Mixed precision¶
from pytest_torch import assert_mixed_precision_training
def amp_step(setup, batch):
x, y = batch
with torch.autocast("cuda", dtype=torch.float16):
loss = F.cross_entropy(setup.model(x), y)
setup.scaler.scale(loss).backward()
setup.scaler.unscale_(setup.optimizer)
torch.nn.utils.clip_grad_norm_(setup.model.parameters(), 1.0)
setup.scaler.step(setup.optimizer)
setup.scaler.update()
setup.optimizer.zero_grad()
return loss
run = assert_mixed_precision_training(make_amp_setup, batches, amp_step, steps=20, device="cuda")
With a GradScaler in setup.scaler, this fails when:
optimizer.step()is called directly instead ofscaler.step(optimizer), applying scaled gradients without the inf/NaN check;scaler.step()runs withoutscaler.scale(loss)or withoutscaler.update();torch.nn.utils.clip_grad_norm_orclip_grad_value_runs beforescaler.unscale_(optimizer), so it clips the scaled gradients;- more than
max_skipped_fraction(default 25%) of the steps are skipped after overflows.
With or without a scaler (bfloat16 needs none), it also fails on a non-finite loss (the forward pass overflowing), non-finite gradients reaching an optimizer step, and non-finite weights after training. When torch itself raises on a misuse, the failure lists that error next to what was recorded before it.
Mixed-precision training: 1 problem(s):
clip_grad_norm_() before scaler.unscale_() for optimizer 'optimizer': it clips the scaled gradients, so the threshold is off by the loss scale (steps 0, 1, 2, 3)
Telemetry:
step loss grad norm update lr opt steps scale
0 0.9113 0.003906 0.0003906 0.1 1 256
...
Clipping is detected when called as torch.nn.utils.clip_grad_norm_(...) (or nn.utils.);
a function imported with from torch.nn.utils import clip_grad_norm_ before the check runs
isn't seen. A GradScaler for the CPU (torch.amp.GradScaler("cpu")) needs torch 2.3+.
Lightning and the Hugging Face Trainer¶
A LightningModule runs under these checks through from_lightning, which builds the
module, calls its configure_optimizers() and steps like Lightning's automatic
optimization (training_step, backward, optimizer step, zero grad, "step"-interval
schedulers):
from pytest_torch import from_lightning
def test_lightning_module(training_runner):
make_setup, step = from_lightning(lambda: LitClassifier(lr=1e-3))
training_runner.run(make_setup, batches, step, steps=20).assert_healthy()
training_runner.assert_resume_equivalent(make_setup, batches, step)
A step-based run has no epochs, so "epoch"-interval schedulers don't step unless you
pass epoch_length (steps per epoch). training_step runs without a Trainer: self.log
is ignored and self.global_step reads 0. Steps that need self.trainer, and manual
optimization, need a real Trainer: the
lightning_trainer fixture gives one set up
for tests.
Hugging Face models compute their own loss when the batch has labels, so a loss_fn is
enough, and hf_trainer gives a Trainer set up
for tests:
run = training_runner.run(make_setup, batches, loss_fn=lambda model, batch: model(**batch).loss)
examples/test_hf_trainer.py
tests a fine-tuning setup with the Hugging Face Trainer: the loss goes down, evaluation
metrics are in range, a Trainer subclass applies its class weights, the optimizer's
weight-decay groups cover every parameter once, gradient accumulation matches a larger
batch, and resuming from a checkpoint reproduces uninterrupted training exactly.
Example¶
examples/test_training_loops.py
tests a classifier's training code, with parameter groups, warmup, accumulation, clipping
and checkpoints, using each check on this page. Its "Try" comment lists one-line bugs that
make a test fail.
Distributed training (synchronization, consistent updates across ranks) isn't covered yet.