Skip to content
SR-Forge

Code or Config?

Put a piece in YAML if it varies between runs. Keep it in Python if it doesn't.

The learning rate, the model's width, a loss weight — those are the experiment, and every one left in Python is a knob you have to edit code to turn. The dataset root, the collation, the runner wiring get decided once and never thought about again; in a config file they are noise.

That is the whole rule, and it is a dial rather than a switch: there is a valid split for every subset of your objects. The three tabs below are landmarks on that dial, not three products to choose between.

model · loss · optimizer · scheduler loaders · runners · trainer · tracker
Everything in code Python Python
Knobs in YAML YAML Python
Everything in YAML YAML YAML

They build the same objects and call the same trainer.train(...). What changes is where each line lives, and what that costs you the next time you want to try something.

Every script calls srforge.init() — YAML or not

This is the one line that does not move as you turn the dial. It sets up the process, not the config: logging, the run's output directory, OpenCV fork hygiene (the classic DataLoader deadlock), Ctrl+C handling, and the GlobalSettings singleton that hooks read at runtime. None of that has anything to do with whether you wrote YAML.

srforge.init()        # no config, no Hydra
srforge.init(cfg)     # + strips internal keys, pre-flights every _target,
                      #   installs the precision policy, returns a resolver

Skip it and the first thing to touch GlobalSettings stops the run — for a script that saves checkpoints, at the end of the first epoch — with "GlobalSettings not initialized. Call srforge.init() before accessing 'output_directory'". Full list of what it does: init(cfg) — Framework Setup.

Config is not a second code path

ConfigResolver reads the YAML, calls the same constructors you would have called, and then gets out of the way — nothing consults the config again once training starts. There is no wrapper around your objects, no runtime cost, and no capability that exists on one path and not the other. The tabs below differ in authoring, and in nothing else.

The training we'll build

A tiny super-resolution run, using the same names as the Core Concepts example:

  • an Upscaler model reads the lr field and writes sr;
  • an L1 loss compares sr against hr;
  • AdamW plus a plateau LR scheduler;
  • a training loop and a validation loop, each with a progress bar;
  • per-epoch loss logging and best/last checkpoints.

We run on cpu so the snippets work anywhere — point device at a GPU id (0, or a list for multi-GPU) for real training; see Distributed & Multi-GPU.

The same training, three ways

It isn't a binary. What changes between the tabs is only how much of the object graph is declared in YAML — from none of it, through the parts you actually sweep, to all of it. All three build the identical objects and call the same trainer.train(...); the listings are trimmed only where they repeat.

import torch

import srforge
from srforge.dataset.lazy_datasets import LazyDataset
from srforge.data.loader import DataLoaderFactory
from srforge.metrics.regression import L1
from srforge.training.runners import TrainingEpochRunner, ValidationEpochRunner
from srforge.training.trainers import PyTorchTrainer
from srforge.training.hooks import ProgressBar, LossLogger, PyTorchModelSaver
from srforge.tracking.null import NullTracker

from my_project.models import Upscaler        # your model — see the Model concept

srforge.init()                                # logging, output dir, fork hygiene

tracker = NullTracker()                       # swap for WandbTracker(...) to log to W&B

train_loader = DataLoaderFactory(
    LazyDataset(root="data/train", mappings={"lr": "LR", "hr": "HR"}),
    batch_size=8, shuffle=True, device="cpu").get_loader()
val_loader = DataLoaderFactory(
    LazyDataset(root="data/val", mappings={"lr": "LR", "hr": "HR"}),
    batch_size=1, shuffle=False, device="cpu").get_loader()

model = Upscaler(channels=64)
model.set_io({"inputs": {"image": "lr"}, "outputs": "sr"})
loss  = L1().set_io({"inputs": {"x": "sr", "y": "hr"}})

optimizer = torch.optim.AdamW(model.trainable_params(), lr=5e-4)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", patience=10)

trainer = PyTorchTrainer(
    model=model,
    training_epoch_runner=TrainingEpochRunner(
        optimizer=optimizer, device="cpu", hooks=[ProgressBar(name="train")]),
    validation_epoch_runner=ValidationEpochRunner(
        device="cpu", hooks=[ProgressBar(name="val")]),
    training_criterion=loss,
    validation_criterion=loss,
    lr_scheduler=scheduler,
    hooks=[LossLogger(tracker=tracker), PyTorchModelSaver(tracker=tracker)],
)
trainer.train(100, train_loader, val_loader)

Everything is where you can see it, and your IDE understands all of it. Changing the LR means editing this file — the knobs sit in the same place as the machinery.

That listing is complete — copy it and go. What srforge.init() does for you, and how to change each block: Writing Scripts.

Declare only what you actually vary between runs; keep the plumbing in Python, where it is read once and never touched again.

configs/experiment.yaml — small, and every line is a knob:

model:
  _target: my_project.models.Upscaler
  params: {channels: 64}
  io: {inputs: {image: lr}, outputs: sr}

loss:
  _target: srforge.metrics.regression.L1
  params: {weight: 1.0}
  io: {inputs: {x: sr, y: hr}}

optimizer:
  _target: torch.optim.AdamW
  params: {params: ${ref:model}.trainable_params(), lr: 0.0005}

lr_scheduler:
  _target: torch.optim.lr_scheduler.ReduceLROnPlateau
  params: {optimizer: ${ref:optimizer}, mode: min, patience: 10}
cfg     = OmegaConf.load("configs/experiment.yaml")
resolve = srforge.init(cfg)                   # no Hydra, no @hydra.main

# --- What this project sweeps: from YAML ------------------------------
model     = resolve(cfg.model)
loss      = resolve(cfg.loss)
optimizer = resolve(cfg.optimizer)            # ${ref:model} reuses the model above
scheduler = resolve(cfg.lr_scheduler)

# --- What never changes: right here, where you can read it ------------
tracker      = NullTracker()
train_loader = DataLoaderFactory(
    LazyDataset(root="data/train", mappings={"lr": "LR", "hr": "HR"}),
    batch_size=8, shuffle=True, device="cpu").get_loader()
val_loader   = DataLoaderFactory(
    LazyDataset(root="data/val", mappings={"lr": "LR", "hr": "HR"}),
    batch_size=1, shuffle=False, device="cpu").get_loader()

trainer = PyTorchTrainer(
    model=model,
    training_epoch_runner=TrainingEpochRunner(
        optimizer=optimizer, device="cpu", hooks=[ProgressBar(name="train")]),
    validation_epoch_runner=ValidationEpochRunner(
        device="cpu", hooks=[ProgressBar(name="val")]),
    training_criterion=loss,
    validation_criterion=loss,
    lr_scheduler=scheduler,
    hooks=[LossLogger(tracker=tracker), PyTorchModelSaver(tracker=tracker)],
)
trainer.train(100, train_loader, val_loader)

You still get a per-run record in the tracker, and a config small enough to read at a glance — without declaring the dataloader plumbing you were never going to change.

What init() does, block by block

train.py — written once, then frozen. Every run of every experiment uses this same file:

@hydra.main(config_path="configs", config_name="train-cfg", version_base=None)
def main(cfg):
    resolve = init(cfg)                       # same call, config-driven
    train_loader = DataLoaderFactory(resolve(cfg.dataset.training),
                                     batch_size=cfg.training.batch_size,
                                     shuffle=True, device=cfg.system.device).get_loader()
    val_loader   = DataLoaderFactory(resolve(cfg.dataset.validation),
                                     batch_size=1,
                                     shuffle=False, device=cfg.system.device).get_loader()

    trainer = resolve(cfg.trainer)   # model, loss, optimizer, runners, hooks — the whole tree
    trainer.train(cfg.training.epochs, train_loader, val_loader)

configs/train-cfg.yaml — this is the experiment. The model, loss, optimizer and lr_scheduler blocks are exactly as in the middle tab; the plumbing moves in beside them:

system:   {device: cpu}
training: {epochs: 100, batch_size: 8}

tracker: {_target: srforge.tracking.null.NullTracker}

dataset:
  training:
    _target: srforge.dataset.lazy_datasets.LazyDataset
    params: {root: data/train, mappings: {lr: LR, hr: HR}}
  validation:
    _target: srforge.dataset.lazy_datasets.LazyDataset
    params: {root: data/val, mappings: {lr: LR, hr: HR}}

training_runner:
  _target: srforge.training.runners.TrainingEpochRunner
  params:
    optimizer: ${ref:optimizer}
    device: ${system.device}
    hooks: [{_target: srforge.training.hooks.ProgressBar, params: {name: train}}]

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}
    hooks:
      - {_target: srforge.training.hooks.LossLogger,        params: {tracker: ${ref:tracker}}}
      - {_target: srforge.training.hooks.PyTorchModelSaver, params: {tracker: ${ref:tracker}}}

Nothing about the run lives in Python any more, so you can override any value without touching a file:

python train.py optimizer.params.lr=1e-4 training.epochs=200

The generated script, dissected · full config syntax

You are not locked in

The first tab is where you learn the pieces; the third is where a project ends up once several people run it. Promoting one object from Python into YAML is a two-line change in either direction, so pick whichever tab you can read today and move things across as they start varying — the dial does not have to be set up front.

You don't write any of this from scratch

srforge init writes a working train.py, a heavily commented configs/train-cfg.yaml, and the same pair for evaluation runs — the third tab's shape. Both files are yours: it never overwrites an existing one (pass --force if you want it to), so delete the parts you don't need. See the CLI.

What config buys you beyond the file

Two advantages are easy to miss from the snippets alone, and together they are the reason config wins once you have more than a handful of runs.

The script hands the whole config to the tracker before training starts:

tracker.log_config(OmegaConf.to_container(cfg, resolve=False))

Every config field becomes a column on the run

In W&B, each key lands on the run itself — so you can filter, sort and group runs by any of them from the runs table: every run with optimizer.params.lr < 1e-4, grouped by model._target, sorted by loss.losses[0].params.weight. Comparing twenty ablations stops being an archaeology exercise.

A hand-written Python script logs metrics, but its hyperparameters are tangled up in code — there is nothing structured to hand over.

You do not need to keep the config files

The tracker already has them. The config of any past run is on its W&B page, and reachable from the API:

import wandb
cfg = wandb.Api().run("entity/project/RUN_ID").config

So there is no reason to hoard configs/exp-001.yaml … exp-200.yaml. The script is the same for every run — you just point it at a different config, and the run itself is the record of what that config was.

Advantages & disadvantages

Both approaches build the same objects, so the trade-off is entirely about authoring: code trades leverage for directness; config trades directness for leverage as your experiments multiply.

Dimension Pure Python Python + YAML
What you read = what runs ✅ one file, no indirection 🟡 two files; _target/${ref} resolved at startup
IDE autocomplete, type-check, refactor ✅ full, everywhere 🟡 raw YAML is blind, but the Assistant plugin closes most of the gap — _target resolution with jump-to-definition, live ${ref:...} preview, plus a pipeline probe and tensor inspector you get nowhere in plain Python
When mistakes surface ✅ edit-time — a clear TypeError at the call 🟡 srforge.init() pre-flights the config before anything is built and warns about unimportable _targets, dangling ${ref:...} and reference cycles — naming the offending key; the plugin flags bad targets while you type. Wrong params names or types still surface later, at resolution
Debugging the setup ✅ breakpoints, step through construction 🟡 you debug the resolver, not your own lines
Change a hyperparameter / swap a piece ❌ edit code — risk breaking logic ✅ edit one documented line; script untouched
CLI overrides & sweeps ❌ hand-roll argparse ✅ train.py …lr=1e-4; Hydra multirun built in
Reproducibility & sharing ❌ params tangled with code ✅ one file is the experiment; stored on the run, so the files need no archive
"What" (knobs) vs "how" (machinery) ❌ mixed in one script ✅ cleanly separated
Reuse across runs / scripts 🟡 refactor into functions yourself ✅ config groups, ${ref}, interpolation; one script, many runs
Learning curve ✅ just PyTorch ❌ also Hydra/OmegaConf + _target/params/io/${ref}
Team handoff 🟡 a colleague must read your code ✅ hand off one file; safer for non-experts to edit
Comparing many runs ❌ nothing structured to compare ✅ every field is a filterable/sortable column in W&B
Getting started 🟡 write the script yourself ✅ srforge init writes both files for you

✅ strength · 🟡 mixed · ❌ weakness

Rule of thumb

Applying that question object by object, this is where things usually land:

  • Keep it in Python while you're learning SR-Forge, hacking a one-off in a notebook, or when the setup is genuinely dynamic — building components in a loop, branching on a computed value. Also keep the things you decided once and stopped thinking about: the dataset root, the collation, the runner wiring.
  • Move it to YAML the moment it starts varying between runs — the model and its width, the loss weights, the LR. That is the point at which a second variant, a sweep, an ablation or a teammate's rerun becomes cheap, and the config starts serving as the experiment's identity.
  • Go all-YAML when the script itself should stop moving: several people run it, it has to scale from a laptop to multi-node, or you want every run reproducible from one file and overridable from the CLI.

Promoting a knob from Python into YAML later is a two-line change, so start wherever is comfortable. Most projects begin in the first tab, settle in the second, and only reach the third once the script has more than one owner.

See also