Skip to content
SR-Forge

Core Concepts

You've seen why a training needs each piece in Anatomy of a Training; this page is the precise vocabulary and code for each one, plus the big-picture diagram the rest of the guide builds on. The terms below (Entry, IO binding, Hook, Runner, …) show up everywhere — read it once now for the general shape, and come back anytime a term needs a refresher.

The Big Picture

Every concept below plays one role in this flow:

flowchart LR
    subgraph data ["Data side"]
        DS[Dataset] -->|produces| E1[Entry]
        E1 --> T[Transforms]
        T --> E2[Entry]
    end
    subgraph model ["Model side"]
        E2 --> M[Model]
        M -->|writes output<br/>into| E3[Entry]
        E3 --> L[Metric]
        L --> MS[MetricScores]
    end
    subgraph training ["Training side"]
        TR[Trainer] -->|runs epochs via| R[EpochRunner]
        R -.->|iterates batches,<br/>calls| M
        R -.->|computes| L
        H[Hooks] -.->|attach to| TR
        H -.->|attach to| R
        H -->|log via| TK[Experiment Tracker]
    end
    CFG[YAML Configuration] -.->|instantiates & wires<br/>everything| DS & T & M & L & TR

Three things to notice:

  1. Entry is the bloodstream. Every component reads from and writes to Entry objects — that's what makes components swappable.
  2. IO Binding is the wiring. It tells each component which Entry fields to read/write, so the same component works with any data layout.
  3. YAML instantiates everything. The dashed lines from Configuration mean "built from config" — you change the experiment by editing YAML, not Python.

Code or config — your choice

Each concept below is shown twice: as Python and as the equivalent YAML config. The tabs are linked — pick the view you prefer once and the whole page follows it. Python is shown by default.

The snippets share one running example — a super-resolution pipeline — so they connect: a dataset produces lr and hr fields; an Upscaler model reads lr and writes sr; an L1 loss compares sr against hr; the trainer wires them together. Follow the same names from one concept to the next.


Entry

A specialized dictionary that carries all data for a single sample through the pipeline. It can hold tensors, nested structures, metadata — anything your experiment needs. Every component in SR-Forge reads from and writes to Entry.

from srforge.data import Entry

entry = Entry(name="scene_042", lr=lr_tensor, hr=hr_tensor)
entry.lr           # attribute access
entry["hr"]        # dict access — the same field
# You don't write an Entry in YAML — a Dataset produces one per sample.
# A config only decides which fields it has, via the dataset's mappings:
dataset:
  params:
    mappings: {lr: LR, hr: HR}   # → entry.lr, entry.hr (+ entry.name)

Detailed guide: Entry

Dataset

A Dataset loads raw data (images, time series, point clouds, etc.) and wraps each sample in an Entry. It extends PyTorch's Dataset class, so it works directly with PyTorch's DataLoader for batching and shuffling. Datasets can also apply transforms at load time and cache the preprocessed results so subsequent epochs skip recomputation.

from srforge.dataset.lazy_datasets import LazyDataset

# one folder per scene; files matched by name become fields
ds = LazyDataset(root="data/train", mappings={"lr": "LR", "hr": "HR"})
ds[0]              # Entry(name="scene_042", lr=..., hr=...)
dataset:
  _target: srforge.dataset.lazy_datasets.LazyDataset
  params:
    root: data/train
    mappings: {lr: LR, hr: HR}   # entry.lr ← LR*, entry.hr ← HR*

Detailed guide: Datasets

Fields

Named slots inside an Entry. Think of them as columns in a spreadsheet — each field has a name (like "lr", "hr", "sr") and holds one piece of data. Components read from specific fields and write results to other fields.

entry = Entry(name="scene_042", lr=lr_tensor, hr=hr_tensor)
entry.keys()       # ['name', 'lr', 'hr']
entry.lr           # the tensor stored in the 'lr' field
# Fields are referenced by name wherever a component binds to the entry —
# the right-hand side of every io mapping is a field name:
io:
  inputs: {x: sr, y: hr}   # bind to the 'sr' and 'hr' fields

Transform

A reusable processing step that modifies data. SR-Forge has two kinds:

  • DataTransform — processes the values inside Entry fields. You write a function that takes values in (tensors, strings, dicts, etc.) and returns values out — the framework extracts them from the Entry and stores results back. The Entry structure stays the same; only field content changes. Example: normalizing pixel values, resizing an image, converting types.
  • EntryTransform — operates on the Entry itself. Has full access to add, remove, rename, or inspect fields, and can even change the Entry type. Example: splitting a multispectral image into separate band fields, removing temporary metadata, renaming fields.
from srforge.transform import DataTransform, EntryTransform

class Normalize(DataTransform):          # transforms field VALUES
    def transform(self, x):
        return (x - x.mean()) / x.std()

class DropField(EntryTransform):         # transforms the Entry STRUCTURE
    def __init__(self, *, field_key):
        self.field_key = field_key; super().__init__()
    def transform_unbatched(self, entry):
        del entry[self.field_key]; return entry
preprocessing:
  - _target: my_project.transforms.Normalize   # a DataTransform
    io: {inputs: {x: lr}}                       # normalize the 'lr' field
  - _target: my_project.transforms.DropField    # an EntryTransform (keys, no io)
    params: {field_key: mask}

Detailed guide: Transforms

IO Binding

The process of connecting method parameter names to Entry fields. A model with forward(self, image) might be bound so that image reads from entry["lr"] — same model, different data. This separation lets you write a component once and reuse it with different field names in different contexts. There are three ways to bind: in Python code, in YAML configuration, or automatically through the flow DSL.

from srforge.models.basic import Bicubic

model = Bicubic(scale=3)                                    # a simple built-in model
model.set_io({"inputs": {"image": "lr"}, "outputs": "sr"})  # bind it to the entry
# forward param 'image' reads entry["lr"]; the result writes entry["sr"]

result = model(entry)   # result.sr now holds the upscaled image
model:
  _target: srforge.models.basic.Bicubic
  params: {scale: 3}
  io:
    inputs: {image: lr}   # forward param 'image' ← entry field 'lr'
    outputs: sr           # return value → entry field 'sr'

Detailed guide: IO Binding

Model

A neural network component that participates in SR-Forge's pipeline. It extends PyTorch's nn.Module with IO binding, so it can read inputs from Entry fields and write outputs back. You implement forward() with your computation logic — the framework handles data routing.

import torch
from srforge.models import Model

class Upscaler(Model):
    def __init__(self, channels: int = 64):   # ← the 'channels' param the YAML passes
        super().__init__()
        self.net = torch.nn.Sequential(
            torch.nn.Conv2d(3, channels, 3, padding=1),
            torch.nn.ReLU(),
            torch.nn.Conv2d(channels, 3, 3, padding=1),
        )

    def forward(self, image):     # parameter names drive IO binding
        return self.net(image)
model:
  _target: my_project.models.Upscaler
  params: {channels: 64}                   # → Upscaler(channels=64)
  io: {inputs: {image: lr}, outputs: sr}   # reads 'lr', writes 'sr'

Detailed guide: Model

SequentialModel

A way to chain multiple models and transforms into a multi-stage pipeline. Instead of writing glue code to pass data between components, you describe the flow declaratively: "take this field, pass it through this module, store the result there." SequentialModel handles all the wiring.

from srforge.models import SequentialModel

seq = SequentialModel(
    modules={"denoise": Denoiser(), "upscale": Upscaler()},
    flow=["lr -> denoise -> clean",
          "clean -> upscale -> sr"],
)
model:
  _target: srforge.models.SequentialModel
  params:
    modules:
      denoise: {_target: my_project.models.Denoiser}
      upscale: {_target: my_project.models.Upscaler}
    flow:
      - "lr -> denoise -> clean"
      - "clean -> upscale -> sr"

Detailed guide: SequentialModel

Flow DSL

A small arrow-based syntax for defining pipelines inside SequentialModel. DSL stands for Domain-Specific Language. Every line follows the same pattern — three parts separated by ->:

<input fields> -> <module name> -> <output fields>

For example: lr -> upscale -> sr means "read the lr field, pass it through the module named upscale, store the result as sr." Multiple fields are separated by commas: (lr, ref) -> fusion -> (sr, confidence).

flow = [
    "lr -> upscale -> sr",                 # one field in, one out
    "(lr, ref) -> fusion -> (sr, conf)",   # multiple, positional by signature
    " -> crop -> ",                         # an EntryTransform: opaque step
]
flow:
  - "lr -> upscale -> sr"                  # one field in, one out
  - "(lr, ref) -> fusion -> (sr, conf)"    # multiple, positional
  - " -> crop -> "                          # EntryTransform: opaque step

Metric

A component that scores model output. The same class serves as a training loss (its score drives the gradient) and as an evaluation metric (its score is only logged) — its weight decides which. Like a Model, a Metric uses IO binding to read the fields it needs from the Entry (e.g. prediction and target). Several metrics are combined and accumulated into a MetricScores object — a container of weighted, named score entries. The training loop reduces it (total_weighted().mean().backward()) to drive optimization, and the same object carries validation metrics that hooks log and checkpointing uses to decide "best so far."

Usually you just pick a built-in metric, bind its parameters to Entry fields, and call it:

from srforge.metrics.regression import L1

loss = L1().set_io({"inputs": {"x": "sr", "y": "hr"}})
scores = loss(entry)                       # → MetricScores
scores.total_weighted().mean().backward()  # the training signal

Writing your own is tiny — no __init__ needed unless you add parameters. Return a per-element error map and the framework handles masking and batch reduction. This is exactly how the built-in L1 above is defined:

import torch
from srforge.metrics import Metric

class L1(Metric):
    @property
    def best_min(self) -> bool:   # is a lower score better? (picks the best checkpoint)
        return True

    def pointwise(self, x, y):    # x, y arrive via IO binding; reduction is automatic
        return torch.abs(y - x)
loss:
  _target: srforge.metrics.regression.L1   # a custom metric? give its import path here instead
  params: {weight: 1.0}
  io: {inputs: {x: sr, y: hr}}       # compare model output 'sr' against target 'hr'

Detailed guide: Metrics

Trainer

The component that owns the training lifecycle. It runs the epoch loop, alternates training and validation, steps the learning-rate scheduler, checks stop conditions, and exposes hook points that drive checkpointing and logging. You configure it once and call train(...); it coordinates everything else. PyTorchTrainer is the standard implementation.

trainer = PyTorchTrainer(
    training_epoch_runner=train_runner,
    validation_epoch_runner=val_runner,
    training_criterion=loss,       # the L1 from the Metric concept
    validation_criterion=metrics,
    lr_scheduler=sched, model=model,   # the Upscaler from the Model concept
)
trainer.train(100, train_loader, val_loader)   # epochs, then the two loaders
# ${ref:...} points at objects defined elsewhere in the config —
# this is how the trainer connects to the model, loss, and runners.
trainer:
  _target: srforge.training.trainers.PyTorchTrainer
  params:
    training_epoch_runner: ${ref:training_runner}
    validation_epoch_runner: ${ref:validation_runner}
    training_criterion: ${ref:loss}
    validation_criterion: ${ref:validation_metrics}
    lr_scheduler: ${ref:lr_scheduler}
    model: ${ref:model}

Detailed guide: Trainers & Runners

EpochRunner

Where one epoch actually executes. The Trainer delegates each epoch to a runner that iterates over a DataLoader, calls the model, computes the loss, and (when training) runs the backward pass and optimizer step. Different runners specialize the behavior: TrainingEpochRunner does backprop with gradient accumulation and AMP; ValidationEpochRunner and BenchmarkRunner run without backward; GANTrainingRunner alternates generator and discriminator updates. Each returns a MetricScores summarizing the epoch.

from srforge.training.runners import TrainingEpochRunner, ValidationEpochRunner

train_runner = TrainingEpochRunner(optimizer=opt, device="cuda", hooks=[ProgressBar(name="T")])
val_runner   = ValidationEpochRunner(device="cuda", hooks=[ProgressBar(name="V")])
# referenced by the Trainer above as ${ref:training_runner}
training_runner:
  _target: srforge.training.runners.TrainingEpochRunner
  params:
    optimizer: ${ref:optimizer}
    device: ${system.device}
    hooks:
      - {_target: srforge.training.hooks.ProgressBar, params: {name: "T"}}

Detailed guide: Trainers & Runners

Hook

The mechanism for plugging side behavior into the training loop — progress bars, loss logging, checkpoint saving, image previews, gradient clipping, auxiliary penalties. A Hook attaches directly to the component whose lifecycle it should follow: runner-level hooks (e.g. ProgressBar) go in the runner's hooks: list, trainer-level hooks (e.g. LossLogger, PyTorchModelSaver) in the trainer's. Hooks receive a mutable Context at each stage, so they can also influence training (add losses, clip gradients, skip steps) — not just observe it.

from srforge.training.hooks import Hook, hooks_into

class EpochTimer(Hook):
    @hooks_into("on_epoch_end")     # fires at that point on the runner
    def report(self, ctx):
        print(f"epoch {ctx.epoch} done")

runner = TrainingEpochRunner(optimizer=opt, hooks=[EpochTimer()])
# hooks go in the hooks: list of the runner or trainer they fire from
training_runner:
  params:
    hooks:
      - {_target: my_project.hooks.EpochTimer}
      - {_target: srforge.training.hooks.ProgressBar, params: {name: "T"}}

Detailed guide: Hooks

Experiment Tracker

A backend-agnostic interface for recording metrics, images, and files. WandbTracker logs to Weights & Biases; NullTracker silently does nothing (handy for debugging or offline runs). Hooks and scripts log through the tracker without knowing which backend is active, so switching from W&B to nothing — or to a custom backend — is a config change.

from srforge.tracking.wandb import WandbTracker

tracker = WandbTracker(project="my-project", entity="my-team")
tracker.log_metrics({"val/psnr": 32.1}, step=epoch)
tracker:
  _target: srforge.tracking.wandb.WandbTracker   # or .null.NullTracker to disable
  params: {project: my-project, entity: my-team}

Detailed guide: Experiment Tracking

Configuration

YAML files that define your entire experiment — model architecture, optimizer, loss function, data pipeline, everything. SR-Forge reads the YAML and automatically builds all the Python objects it describes. This means you can change your experiment without touching code.

from srforge.config import ConfigResolver
from omegaconf import OmegaConf

cfg = OmegaConf.load("train-cfg.yaml")
objects = ConfigResolver(cfg).resolve_all()   # builds every _target into a live object
model = objects["model"]                       # the Upscaler, wired and ready
# the 'model' block the Model concept showed — this is what ConfigResolver builds
model:
  _target: my_project.models.Upscaler     # which class (import path or registry name)
  params: {channels: 64}                   # its constructor kwargs
  io: {inputs: {image: lr}, outputs: sr}   # how it binds to Entry fields

Detailed guide: Configuration


Next: the Guide takes each piece in depth, starting with Entry — the container every component reads from and writes to.