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 loss criteria 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.
Legacy event scoping
Older versions routed events through a global EventBus with
scope="train" / scope="val" filters. That mechanism is
deprecated (removal in v0.16.0) — see
Observers & Events for the legacy reference.
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 (scope="val")
└── BenchmarkRunner (scope="benchmark")
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 all losses read from the Entry through the standard
srforge.loss.Loss interface. Constructor takes two optimizers
(optimizer_G, optimizer_D), two criteria (d_criterion,
g_criterion), an optional list of hooks, and step_ratio (G updates
per D update — 1.0 updates both every batch; 2.0 trains G twice as
often). Uses two GradScalers under mixed precision. Single-GPU only
(the G/D step methods bypass DataParallel) — set system.device to a
single device when configuring.
Training hooks¶
The canonical side-channel mechanism — replaces the legacy
Observer / EventBus pattern (see
Observers & Events for the legacy reference).
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 (
ctx.extra_losses["r1"] = ...). - 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¶
| Class | HookPoints |
|---|---|
Trainer (PyTorchTrainer) |
on_training_began, on_trainer_epoch_finished |
TrainingEpochRunner |
on_epoch_start, on_pre_step, on_post_backward, on_post_step, on_epoch_end |
InferenceRunner (val / benchmark) |
on_epoch_start, on_post_step, on_epoch_end |
GANTrainingRunner |
the five base + on_pre_d_forward, on_post_d_forward, on_post_d_backward, on_pre_g_forward, on_post_g_forward, on_post_g_backward |
User-defined runners declare new HookPoints with a one-line attribute annotation — see HookPoints in custom runners below.
Two attachment surfaces¶
Trainer(hooks=[…]) distributes each hook to itself and to every
runner whose scope matches hook.scope:
hook.scope |
Attaches to |
|---|---|
None (default) |
Trainer + every runner (cross-runner) |
"train" |
Trainer + the training runner |
"val" |
Trainer + the validation runner |
["train", "val"] |
Trainer + listed runners |
Runner(hooks=[…]) is the local-to-this-runner form — useful for
hooks that only ever fire on one runner (e.g. a runner-specific
GradientClip).
PyTorchTrainer(
model=model,
training_epoch_runner=train_runner,
validation_epoch_runner=val_runner,
training_criterion=loss,
validation_criterion=loss,
hooks=[
LossLogger(tracker=tracker), # cross-runner + trainer
ProgressBar(scope="train", name="T"), # training runner only
ProgressBar(scope="val", name="V"), # validation runner only
],
)
Hooks with persistent state implement state_dict() /
load_state_dict(state) for checkpoint round-trip.
Built-in hooks¶
All five built-in hooks are @register_class-decorated, so they can
be referenced bare-name in YAML or via the dotted module path.
| Hook | Purpose | Stages it fires on |
|---|---|---|
StepRatio |
Self-balancing G/D update ratio for GAN training. Reads ratio (target G:D updates). Sets ctx.run_d / ctx.run_g in pre_step. |
pre_step (GAN) |
DWarmup |
First N global batches: D-only training. Suppresses G updates until the discriminator has learned something. | pre_step (GAN) |
R1GradientPenalty |
R1 regularisation — gradient penalty on real images for the discriminator. Mean-reduced so weight is resolution-independent. | post_d_forward (GAN) |
GradientClip |
Clips gradient norms via torch.nn.utils.clip_grad_norm_. Unscales under AMP via the runner's scaler first. |
post_backward (base) or post_d_backward / post_g_backward (GAN) |
EdgeEnhancedInput |
Augments the input with a Sobel-style edge map as an extra channel. | pre_step (base) |
# 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.loss.adversarial.RaGANDiscriminatorLoss
g_criterion:
_target: srforge.loss.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 }
step_ratio: 1.0
device: ${system.device}
mixed_precision: ${system.mixed_precision}
Writing a custom hook¶
from srforge.training.hooks import Hook
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."""
scope = "train" # attach to training runners only
def __init__(self):
self.d_steps_this_epoch = 0
def on_epoch_start(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, …).
def on_post_d_backward(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. Any torch.optim.lr_scheduler.LRScheduler subclass works — epoch-based schedulers (e.g., StepLR, CosineAnnealingLR, ExponentialLR) and metric-based schedulers (e.g., ReduceLROnPlateau) are both supported. If no scheduler is provided, the trainer uses a no-op BlankLRScheduler internally.
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