Trainers & Runners¶
The Writing Scripts page shows how to wire up a training script. This page explains what happens inside once you call trainer.train() or runner.run_epoch().
Trainer orchestrates the epoch loop — it owns the criteria (the training loss and the validation metrics) and delegates the actual forward/backward work to runners. Runners are stateless execution engines: they iterate over batches, fire hook points, and return aggregated metrics. The criterion is passed to run_epoch() at call time, not stored on the runner. Hooks attached to a runner or the trainer react at those points (progress bars, checkpoints, logging) — and can even influence the loop (gradient clipping, auxiliary losses).
Trainer lifecycle¶
PyTorchTrainer.train() runs the outer epoch loop. Here's the full flow:
flowchart TD
start([trainer.train#40;epochs, train_loader, val_loader#41;]) --> began
began["<b>TrainingBegan</b> event<br/><i>total_epochs, initial_epoch, batches per loader</i>"]
began --> epoch_loop
epoch_loop{"epoch < epochs?"}
epoch_loop -- No --> done([Return])
epoch_loop -- Yes --> loss_sched
loss_sched["Update LossScheduler<br/><i>if criterion is LossScheduler</i>"]
loss_sched --> train_run
train_run["<b>training_runner.run_epoch</b>#40;model, train_loader, epoch, criterion#41;<br/>→ train_scores"]
train_run --> val_run
val_run["<b>validation_runner.run_epoch</b>#40;model, val_loader, epoch, criterion#41;<br/>→ val_scores"]
val_run --> epoch_event
epoch_event["<b>TrainerEpochFinished</b> event<br/><i>model, optimizer, lr_scheduler,<br/>train_loss, val_loss, epoch, scaler</i>"]
epoch_event --> lr_step
lr_step["Step LR scheduler<br/><i>ReduceLROnPlateau uses val_loss;<br/>all others use epoch count</i>"]
lr_step --> stop_check
stop_check{"StopCondition<br/>satisfied?"}
stop_check -- Yes --> done
stop_check -- No --> epoch_loop
What happens at each step¶
-
TrainingBegan— fired once before the loop starts.PyTorchModelSaveruses it to initializebest_lossfrom a resumed checkpoint. -
LossScheduler update — if the criterion is a
LossScheduler, it advances to the current epoch (e.g., switching from L1 to a combined L1+SSIM at epoch 50). -
Training runner — runs one full pass through the training data with gradients enabled. Returns aggregated
MetricScores. -
Validation runner — runs one full pass through the validation data with
torch.no_grad(). Returns aggregatedMetricScores. -
on_trainer_epoch_finished— the trainer's hook point fires after both runners complete. Trainer-level hooks react:PyTorchModelSaversavescheckpoint_best.pthif validation loss improved, and always savescheckpoint_last.pth.LossLoggerlogs all metrics to the tracker and callstracker.commit().
-
LR scheduler step — the trainer steps the LR scheduler once per epoch. Metric-based schedulers receive the mean validation loss; epoch-based schedulers step by count.
-
Stop condition — checked after each epoch. Returns
Trueto end training early (e.g., when validation loss plateaus). The defaultNoConditionnever stops.
Runner lifecycle¶
Every runner follows the same pattern in run_epoch(). The difference is whether gradients and optimizer steps are involved.
flowchart TD
start([run_epoch#40;model, data_loader, epoch, criterion#41;]) --> mode
mode["Set model mode<br/><i>train#40;True#41; or train#40;False#41; + no_grad</i>"]
mode --> epoch_start
epoch_start["<b>RunnerEpochStarted</b> event<br/><i>epoch, dataset_size, batch_size, num_batches</i>"]
epoch_start --> batch_loop
batch_loop{"Next batch?"}
batch_loop -- No --> epoch_end
batch_loop -- Yes --> forward
forward["<b>Forward pass</b><br/><i>entry.to#40;device#41; → model#40;entry#41;</i><br/>with autocast if mixed_precision"]
forward --> merge
merge["Merge output into Entry"]
merge --> post
post["Apply postprocessors<br/><i>e.g., clamp, denormalize</i>"]
post --> loss
loss["Compute loss<br/><i>batch_scores = criterion#40;entry#41;</i>"]
loss --> backward
backward{"Training<br/>runner?"}
backward -- Yes --> grad
backward -- No --> accum
grad["<b>Backward pass</b><br/><i>loss / accumulation_steps → .backward#40;#41;</i>"]
grad --> optim_check
optim_check{"Accumulation<br/>step?"}
optim_check -- Yes --> optim_step
optim_check -- No --> accum
optim_step["<b>Optimizer step</b><br/><i>scaler.step#40;optimizer#41;<br/>scaler.update#40;#41;<br/>optimizer.zero_grad#40;#41;</i>"]
optim_step --> accum
accum["Accumulate MetricScores"]
accum --> batch_event
batch_event["<b>RunnerBatchFinished</b> event<br/><i>epoch, batch, entry, batch_scores,<br/>criterion, epoch_scores</i>"]
batch_event --> free
free["Free memory<br/><i>del output, entry, batch_scores</i>"]
free --> batch_loop
epoch_end["<b>RunnerEpochFinished</b> event<br/><i>epoch, epoch_scores</i>"]
epoch_end --> ret([Return epoch_scores])
Key details¶
-
Model mode —
TrainingEpochRunnersetsmodel.train(True); validation and benchmark runners setmodel.train(False)insidetorch.no_grad(). -
Forward pass — the model receives an
Entryand returns anEntry,GraphEntry, ordict. The runner merges the output back into the original entry so postprocessors and the loss see all fields. -
Postprocessors — a list of transforms applied after the model (e.g., clamping pixel values, undoing normalization). Configured per-runner.
-
Accumulation step — the optimizer steps every
gradient_accumulation_stepsbatches, or on the last batch of the epoch. The loss is divided by the accumulation factor before.backward(). -
Hook points — the per-batch
on_post_stepcontext carries the full entry, so hooks likeBatchImageSavercan extract and save predictions.on_epoch_endcarries the aggregated scores for the entire epoch.
Hook timeline¶
This sequence diagram shows one complete epoch — where each component's hooks fire. TH are hooks attached to the training runner, VH to the validation runner, and H to the trainer:
sequenceDiagram
participant T as PyTorchTrainer
participant TR as TrainingRunner
participant TH as TR's hooks
participant VR as ValidationRunner
participant VH as VR's hooks
participant H as Trainer's hooks
Note over T: Epoch begins
T->>TR: run_epoch(model, train_loader, epoch, criterion=training_criterion)
activate TR
TR->>TH: on_epoch_start
Note over TH: ProgressBar "T" initializes
loop Each training batch
TR->>TR: forward → postprocess → loss → backward → optimizer step
TR->>TH: on_post_step
Note over TH: ProgressBar updates
end
TR->>TH: on_epoch_end
TR-->>T: train_scores
deactivate TR
T->>VR: run_epoch(model, val_loader, epoch, criterion=validation_criterion)
activate VR
VR->>VH: on_epoch_start
loop Each validation batch
VR->>VR: forward → postprocess → loss
VR->>VH: on_post_step
Note over VH: BatchImageLogger buffers predictions
end
VR->>VH: on_epoch_end
VR-->>T: val_scores
deactivate VR
T->>H: on_trainer_epoch_finished
Note over H: PyTorchModelSaver saves checkpoint<br/>LossLogger logs metrics
Note over T: Step LR scheduler<br/>Check stop condition
No scoping — attachment is the scope¶
There is no scope filter to configure. A hook in the training runner's hooks: list fires only during training epochs; a hook in the validation runner's list fires only during validation; a hook in the trainer's list fires at trainer-level points (on_training_began, on_trainer_epoch_finished). To fire the same hook instance from multiple components, put it in each component's hooks: list.
Runner scope= was removed in 0.16.0
Up to 0.15.x a runner's scope="train" / scope="val" filtered which
observers attached. With observers gone it had nothing to filter, so it
was removed: a runner given scope refuses it, like any option it does
not take.
Runner types¶
EpochRunner (ABC)
├── TrainingEpochRunner — backward pass, gradient accumulation, optimizer, hooks
├── GANTrainingRunner — alternating G/D updates, self-balancing step ratio, hooks
└── InferenceRunner — torch.no_grad() loop, optional criterion
├── ValidationEpochRunner
└── BenchmarkRunner
ValidationEpochRunner and BenchmarkRunner both inherit from InferenceRunner — the same inference loop with no gradients. They differ only in their scope tag and whether criterion is enforced.
| TrainingEpochRunner | GANTrainingRunner | ValidationEpochRunner | BenchmarkRunner | |
|---|---|---|---|---|
| Scope | train |
train |
val |
benchmark |
| Gradients | Yes | Yes (G + D) | No (torch.no_grad) |
No |
| Optimizer | One | Two (G + D) | No | No |
| Mixed precision | Optional (one scaler) | Optional (two scalers) | Optional | Optional |
| Grad accumulation | Optional | No | N/A | N/A |
| Criterion | Required in run_epoch |
d_criterion+g_criterion on constructor |
Required in run_epoch |
Optional in run_epoch |
| Hooks | 5 base stages | 5 base + 6 GAN stages | None | None |
| Used in | PyTorchTrainer |
PyTorchTrainer |
PyTorchTrainer |
Benchmark scripts |
Criterion is not stored on the runner
Runners are stateless with respect to criteria. The criterion is always passed as a parameter to run_epoch(criterion=...). The PyTorchTrainer owns both criteria and passes them at call time. This keeps runners as pure execution engines and avoids temporal coupling.
TrainingEpochRunner — the workhorse. Handles forward pass, backward pass, optimizer steps, gradient accumulation, and mixed-precision scaling. Used as the trainer's training_epoch_runner.
ValidationEpochRunner — same forward pass and loss computation, but with gradients disabled. Raises ValueError if criterion is not provided to run_epoch(). Used as the trainer's validation_epoch_runner.
BenchmarkRunner — designed for test/inference scripts. Criterion is optional — if you only need predictions (e.g., saving images), omit it. If provided, it computes and reports metrics. Used standalone, not inside a trainer.
GANTrainingRunner — alternating G/D updates with self-balancing
step ratio. Requires a GANModel (srforge.models.GANModel). Field-
agnostic: it never touches Entry fields directly — GANModel handles
discriminator scoring and gradient control (detach, torch.no_grad),
and every criterion reads from the Entry through the standard
srforge.metrics.Metric interface. Constructor takes two optimizers
(optimizer_G, optimizer_D), two criteria (d_criterion,
g_criterion) and an optional list of hooks — the cadence of G and D
updates is the StepRatio hook (ratio: 1.0 updates both every batch;
2.0 trains G twice as often). Uses two GradScalers under mixed
precision. For multiple GPUs launch with torchrun (DDP), which wraps the
generator and discriminator separately; a device list (DataParallel)
runs a GAN on one GPU, because the G/D steps bypass that wrapper.
Training hooks¶
The side-channel mechanism for everything around the training step —
logging, checkpointing, gradient clipping, extra loss terms. (It replaced
the Observer / EventBus pattern, removed in 0.16.0.)
A Hook is a composable behaviour attached to a runner or the Trainer
at construction. Each runner / trainer exposes its lifecycle stages as
HookPoint attributes (on_epoch_start, on_post_step, …); the
hook's on_<x> methods are auto-bound to matching HookPoints at
attach time via Hook.bind_to(target).
Hooks receive a mutable Context and can:
- Skip a step entirely (
ctx.run_d = False,ctx.run_g = Falsein GAN). - Add an auxiliary regularisation loss: write a
MetricEntryintoctx.scoresfromon_post_forward(the GAN runner usesctx.d_extra_losses/ctx.g_extra_losses). - Inspect or mutate gradients between backward and the optimizer step.
- Modify the Entry before forward (e.g. data augmentation).
HookPoints declared by each runner / trainer¶
Each class exposes its own set, and a hook can only bind to a point the target actually declares. The full table — plus how to check at runtime and what the warning looks like when you attach a hook to the wrong component — lives with the hook documentation: Which HookPoints can I attach to?.
User-defined runners declare new HookPoints with a one-line attribute annotation — see HookPoints in custom runners below.
Worked example¶
Putting attachment-is-the-scope into practice — each hook goes in the list of the component it should fire from:
PyTorchTrainer(
model=model,
training_epoch_runner=TrainingEpochRunner(
optimizer=optimizer, hooks=[ProgressBar(name="T")]), # training only
validation_epoch_runner=ValidationEpochRunner(
hooks=[ProgressBar(name="V")]), # validation only
training_criterion=loss,
validation_criterion=loss,
hooks=[LossLogger(tracker=tracker)], # the trainer only
)
Hooks with persistent state implement state_dict() /
load_state_dict(state) for checkpoint round-trip.
Built-in hooks¶
Ten ship with SR-Forge, all @register_class-decorated so they can be
referenced by bare name in YAML or by dotted module path. Attach to is the
column that matters: a hook only fires from a component that declares its
HookPoints (see Which HookPoints can I attach
to?).
| Hook | Purpose | Binds to | Attach to |
|---|---|---|---|
ProgressBar |
Rich progress bar for one runner, with live metrics. | on_epoch_start, on_post_step, on_epoch_end |
any runner |
LossLogger |
Train/val losses + learning rates to the tracker, once per epoch. | on_trainer_epoch_finished |
trainer |
PyTorchModelSaver |
Best/last checkpoints and model weights. | on_training_began, on_trainer_epoch_finished |
trainer |
BatchImageSaver |
Writes prediction images to the run's output directory. | on_post_step, on_epoch_end |
any runner |
BatchImageLogger |
Logs prediction images to W&B. | on_post_step, on_epoch_end |
any runner |
GradientClip |
Clips gradient norms; unscales under AMP via the runner's scaler first. | on_post_backward, on_post_d_backward, on_post_g_backward |
training or GAN runner |
StepRatio |
Self-balancing G/D update ratio. Sets ctx.run_d / ctx.run_g. |
on_pre_step |
GAN runner |
DWarmup |
First N global batches are D-only, until the discriminator has learned something. | on_pre_step |
GAN runner |
R1GradientPenalty |
R1 regularisation — gradient penalty on real images, mean-reduced so the weight is resolution-independent. | on_post_d_forward |
GAN runner |
EdgeEnhancedInput |
Augments the input with a Sobel-style edge map as an extra channel. | on_pre_d_forward, on_pre_g_forward |
GAN runner |
GradientClip is the one hook that spans both: it declares a method per
point, so the same instance clips a plain training runner or a GAN's D and G
separately, and only the applicable methods bind.
# Example hooks block for a GAN training_runner:
training_epoch_runner:
_target: srforge.training.runners.GANTrainingRunner
params:
optimizer_G: ${ref:optimizer_G}
optimizer_D: ${ref:optimizer_D}
d_criterion:
_target: srforge.metrics.adversarial.RaGANDiscriminatorLoss
g_criterion:
_target: srforge.metrics.adversarial.RaGANGeneratorLoss
params: { weight: 0.01 }
hooks:
- _target: StepRatio
params: { ratio: 1.0 }
- _target: DWarmup
params: { batches: 500 }
- _target: R1GradientPenalty
params: { weight: 0.5 }
- _target: GradientClip
params: { max_norm: 1.0 }
device: ${system.device}
mixed_precision: ${system.mixed_precision}
Writing a custom hook¶
from srforge.training.hooks import Hook, hooks_into
from srforge.training.context import Context
from srforge.training.runners import GANTrainingRunner
from srforge.registry import register_class
@register_class
class LogDStepCount(Hook):
"""Track how many discriminator steps fired this epoch.
Attach it to the GAN training runner's ``hooks:`` list — where a hook
is attached decides where it fires.
"""
def __init__(self):
super().__init__()
self.d_steps_this_epoch = 0
@hooks_into("on_epoch_start")
def reset(self, ctx: Context):
self.d_steps_this_epoch = 0
# Type-annotating ctx as the runner's nested Ctx gives IDE
# autocomplete on the GAN-specific fields (run_d, d_steps, …).
@hooks_into("on_post_d_backward")
def count_d_step(self, ctx: GANTrainingRunner.Ctx):
self.d_steps_this_epoch += 1
def state_dict(self):
return {"d_steps_this_epoch": self.d_steps_this_epoch}
def load_state_dict(self, state):
self.d_steps_this_epoch = state["d_steps_this_epoch"]
HookPoints in custom runners¶
To add a new entry point to a custom runner, declare a class-level
HookPoint annotation — the base class harvests them via
__init_subclass__ and instantiates one per attribute on each
instance.
from srforge.training.context import Context
from srforge.training.hookpoint import HookPoint
from srforge.training.runners import EpochRunner
class AdversarialAttackRunner(EpochRunner):
"""Custom runner with attack-specific stages."""
class Ctx(Context):
"""IDE-typed Context flavour. Hooks targeting this runner can
annotate ctx: AdversarialAttackRunner.Ctx to get autocomplete
on the custom fields below."""
attack_strength: float
attack_iterations: int
on_pre_attack: HookPoint
on_post_attack: HookPoint
def run_epoch(self, model, loader, epoch, criterion=None):
ctx = self.Ctx(
epoch=epoch, model=model,
attack_strength=0.1, attack_iterations=10,
)
self.on_epoch_start.fire(ctx)
for i, batch in enumerate(loader):
ctx.batch_idx, ctx.entry = i, batch
self.on_pre_attack.fire(ctx)
...
self.on_post_attack.fire(ctx)
self.on_epoch_end.fire(ctx)
Key features¶
Mixed precision¶
Enable automatic mixed precision (AMP) per-runner:
training_runner:
_target: srforge.training.runners.TrainingEpochRunner
params:
mixed_precision: true
# ...
When enabled, the forward pass and loss computation run inside torch.autocast('cuda'). A GradScaler handles loss scaling for the backward pass to prevent underflow in float16 gradients. The scaler state is saved and restored in checkpoints.
Gradient accumulation¶
Simulate larger batch sizes by accumulating gradients over multiple batches:
training_runner:
_target: srforge.training.runners.TrainingEpochRunner
params:
gradient_accumulation_steps: 4
# ...
The loss is divided by gradient_accumulation_steps before .backward(). The optimizer steps every 4 batches (or on the last batch of the epoch, whichever comes first). Effective batch size = batch_size * gradient_accumulation_steps.
LR scheduling¶
The trainer steps the LR scheduler once per epoch, after both runners finish. Every scheduler in torch.optim.lr_scheduler works — epoch-based ones (e.g., StepLR, CosineAnnealingLR, ExponentialLR) and ReduceLROnPlateau, which watches the validation loss; the trainer works out which ones want that loss, so you never pass it yourself. The loss it passes is the epoch's mean weighted validation total — the same number the best model is chosen by.
Whatever you give it, the trainer wraps it in a SchedulerGroup — so trainer.lr_scheduler is always one. Passing nothing gives you an empty group: a schedule that steps nothing, i.e. a constant learning rate.
Several optimizers? A GAN runner owns optimizer_G and optimizer_D, and one scheduler can only drive one of them. Build a SchedulerGroup with one scheduler per optimizer and the trainer steps, checkpoints and logs all of them together:
lr_scheduler:
_target: srforge.training.schedule.SchedulerGroup
params:
schedulers: [${ref:sched_G}, ${ref:sched_D}]
Each scheduler already names its own optimizer (optimizer: ${ref:optimizer_G}), so the group needs no keys of its own. A scheduler wired to an optimizer the runner doesn't own is rejected when the trainer is built, rather than quietly stepping something nothing trains.
Your own schedule. There is nothing to inherit. Any object with three methods works — step(), state_dict() and load_state_dict() — and the last two are what put it in checkpoints, so a resumed run continues the schedule instead of restarting it:
from srforge.registry import register_class
@register_class
class WarmupThenConstant:
"""Linear warm-up over the first `warmup` epochs, then the base LR."""
def __init__(self, optimizer, warmup: int = 5):
self.optimizer = optimizer # lets the trainer check and name it
self.warmup = warmup
self.base = [g["lr"] for g in optimizer.param_groups]
self.epoch = 0
self._apply()
def step(self): # called once per epoch
self.epoch += 1
self._apply()
def _apply(self):
scale = min(1.0, (self.epoch + 1) / self.warmup)
for group, lr in zip(self.optimizer.param_groups, self.base):
group["lr"] = lr * scale
def state_dict(self):
return {"epoch": self.epoch}
def load_state_dict(self, state):
self.epoch = state["epoch"]
self._apply()
Keep the optimizer in self.optimizer: the trainer uses it to reject a schedule wired to an optimizer the runner doesn't train, and to file its checkpoint state under that optimizer's name.
Should your schedule react to the validation loss, like ReduceLROnPlateau does, add wants_metric = True to the class. step() then receives the loss — def step(self, metric): .... Without the flag, step() is called with no arguments.
Watching the learning rate. The W&B logger records each optimizer's current learning rate every epoch — read from the optimizer itself, so a run with no schedule logs it too. The chart names follow the optimizer names:
| Runner's optimizer | Chart |
|---|---|
optimizer |
learning_rate |
optimizer_G, optimizer_D (GAN) |
learning_rate_G, learning_rate_D |
| an optimizer with several parameter groups | the name above plus _0, _1, … per group |
The full reference for SchedulerGroup and the schedule protocol is in the API reference.
Early stopping¶
The trainer checks a StopCondition after each epoch. The StopCondition interface receives the current epoch, training loss, and validation loss, and returns True to stop training.
SR-Forge ships two implementations:
NoCondition(default) — never stops; training runs for all epochs.ValidationLossDidNotImprove(patience, min_delta)— stops if validation loss hasn't improved by at leastmin_deltaforpatienceconsecutive epochs.
trainer:
_target: srforge.training.trainers.PyTorchTrainer
params:
stop_condition:
_target: srforge.training.stop.ValidationLossDidNotImprove
params:
patience: 10
min_delta: 0.0001
# ...
You can implement custom stop conditions by subclassing StopCondition and overriding is_condition_satisfied().
YAML example¶
A complete trainer + runners configuration:
training_runner:
_target: srforge.training.runners.TrainingEpochRunner
params:
optimizer: ${ref:optimizer}
device: ${system.device}
postprocessor: ${postprocessing}
mixed_precision: true
gradient_accumulation_steps: 1
validation_runner:
_target: srforge.training.runners.ValidationEpochRunner
params:
device: ${system.device}
postprocessor: ${postprocessing}
mixed_precision: true
trainer:
_target: srforge.training.trainers.PyTorchTrainer
params:
model: ${ref:model}
training_epoch_runner: ${ref:training_runner}
validation_epoch_runner: ${ref:validation_runner}
training_criterion: ${ref:loss}
validation_criterion: ${ref:loss}
lr_scheduler: ${ref:lr_scheduler}
stop_condition:
_target: srforge.training.stop.NoCondition
For benchmark scripts, use BenchmarkRunner directly — no trainer needed:
runner = BenchmarkRunner(device=device, postprocessor=postprocessor)
runner.run_epoch(model=model, data_loader=test_loader, epoch=0, criterion=metrics)
Omit criterion for inference-only runs (no scoring):
runner = BenchmarkRunner(device=device, postprocessor=postprocessor)
runner.run_epoch(model=model, data_loader=test_loader, epoch=0)
Next: Configuration — Wire everything together in YAML