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
Upscalermodel reads thelrfield and writessr; - an
L1loss comparessragainsthr; AdamWplus 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.
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:
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:
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:
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¶
- Configuration — the full
_target/params/io/${ref}reference, config groups, and CLI overrides. - Writing Scripts — the entrypoint in depth (device setup, resume, sanity checks).
- Core Concepts: Configuration and IO Binding.
- IDE support — the SR-Forge Assistant plugin
adds
_target/${ref}navigation to YAML.