Writing Scripts¶
SR-Forge ships with ready-to-use train and test scripts that work entirely from YAML config. Most users will never need to modify them. But if you need custom logic — a different training loop, a GAN setup, transfer learning — you can write your own script.
This page shows how to write a script from scratch and explains each building block.
The srforge CLI¶
Installing sr-forge registers a srforge console script
(pyproject.toml → [project.scripts]) that handles project
bootstrapping and config diagnostics. Two subcommands:
srforge init [--force]¶
Scaffolds a complete training project in the current directory by
copying four files from srforge/_scaffold/:
| File | Purpose |
|---|---|
train.py |
Hydra-wrapped training script — calls init(cfg), builds the trainer + datasets + tracker, runs resume_from_checkpoint against trainer.training_runner, hands control to trainer.train(...). |
benchmark.py |
Inference / metric-evaluation script using BenchmarkRunner. |
configs/train-cfg.yaml |
Full training config: system, model, optimizer, scheduler, loss, datasets, runners, trainer — with hooks attached inside the runner/trainer sections. |
configs/benchmark-cfg.yaml |
Inference-side config. |
cd ~/my-project
srforge init # creates the files above, skipping any that already exist
srforge init --force # overwrites existing copies
Each file is a starting point — edit the YAML to match your data and
model. The scripts call init(cfg) (next section), so anything they
do is already covered by this documentation.
srforge audit <path> [--format text|json|graphml]¶
Statically extracts every _target dispatch and every ${ref:...}
reference from a YAML config without instantiating anything. It
also runs full validation: cycle detection in the indirection graph,
dangling-reference detection, missing-_target detection. Useful
during refactors when you want to confirm a config still parses
before booting CUDA or downloading checkpoints.
srforge audit configs/train-cfg.yaml # human-readable table
srforge audit configs/train-cfg.yaml --format json # array of {kind,source_path,raw,resolved}
srforge audit configs/train-cfg.yaml --format graphml # GraphML (load with networkx / graphify merge-graphs)
Under the hood the command instantiates a ConfigResolver (which
builds an in-memory ConfigGraph) and emits each indirection edge as
an IndirectionEdge — see Config Indirection
for the full type surface. The same audit runs automatically inside
init() (see step 4 below) — running it standalone is the way to
catch problems without booting the rest of the framework.
Minimal Training Script¶
Here is the shortest working training script:
import hydra
import omegaconf
from srforge import GlobalSettings, init
from srforge.data.loader import DataLoaderFactory
from srforge.utils.checkpoint import resume_from_checkpoint
@hydra.main(config_path="configs", config_name="train-cfg", version_base=None)
def main(cfg):
resolve = init(cfg) # (1)
# -- Training objects --------------------------------------------------
model = resolve(cfg.model) # (2)
optimizer = resolve(cfg.optimizer)
scheduler = resolve(cfg.lr_scheduler)
loss = resolve(cfg.loss)
model.to("cuda")
# -- Datasets & loaders ------------------------------------------------
train_loader = DataLoaderFactory( # (3)
resolve(cfg.dataset.training),
batch_size=cfg.training.batch_size, shuffle=True,
).get_loader()
val_loader = DataLoaderFactory(
resolve(cfg.dataset.validation),
batch_size=1, shuffle=False,
).get_loader()
# -- Trainer (owns the training runner used by resume) -----------------
trainer = resolve(cfg.trainer) # (4)
# -- Tracker (after trainer so log_config can include any defaults) ----
tracker = resolve(cfg.tracker) # (5)
tracker.log_config(omegaconf.OmegaConf.to_container(cfg, resolve=False))
# -- Resume from checkpoint (uses trainer.training_runner, NOT the
# raw optimizer — optimizer + scaler state live inside the runner) ----
ckpt = resume_from_checkpoint( # (6)
model, trainer.training_runner, scheduler, tracker=tracker
)
trainer.restore(ckpt)
# Hooks (progress bars, loggers, checkpointing) were attached when
# the runners/trainer were resolved — they live in the config's
# hooks: lists, so there's no separate subscription step here.
trainer.train(cfg.training.epochs, train_loader, val_loader)
tracker.finish(0)
if __name__ == "__main__":
main()
Let's walk through each numbered step.
Step by Step¶
1. init(cfg) — Framework Setup¶
srforge.init() is the boilerplate every script needs. It does six things and returns a ConfigResolver:
- Configures the global logger with coloured output at the chosen
log_level(defaultINFO; passlog_level=cfg.system.debug_levelif your config has one). - Installs a hard-kill SIGINT/SIGBREAK handler that calls
tracker.finish(1)onGlobalSettings().trackerand thenos._exit(1). This is the only reliable way to stop a hung DataLoader-worker pool or an MKL/Fortran thread on Windows. - Prints the startup banner ("⚡ SR-Forge" panel via
rich) and a PyPI update notice if a newer version exists. - Strips internal Hydra/OmegaConf bookkeeping keys from the config (
clear_defaults) and runs a pre-flight_targetaudit — every_targetstring is best-effort imported up-front. Typos and missing classes surface as a single warning at startup instead of mid-run. - Wires
GlobalSettings— stores the config and the Hydra-resolved output directory. - Returns a
ConfigResolverbound to the config — your main tool: pass any subtree to it and the recursive instantiation produces the Python objects.
init() has no config key assumptions beyond a Hydra-managed output dir — it doesn't care whether your config has model, tracker, or any other specific key. That's your script's job.
Pre-flight audit
Step 4's audit is the same one you can run manually via
srforge audit configs/train-cfg.yaml (see top of this page).
Inside init() it logs warnings but doesn't abort — the
srforge audit CLI prints the same edge list explicitly.
2. Tracker Setup¶
tracker = resolve(cfg.tracker)
tracker.log_config(omegaconf.OmegaConf.to_container(cfg, resolve=False))
The tracker provides experiment logging (metrics, images, checkpoints). Resolve it from config and log the full config as metadata.
Hooks that need the tracker (e.g., LossLogger, PyTorchModelSaver, BatchImageLogger) receive it as a constructor parameter via tracker: ${ref:tracker} in the YAML config. No need to store it on GlobalSettings.
See Experiment Tracking for details on the tracker abstraction.
3. Resolve Training Objects¶
model = resolve(cfg.model)
optimizer = resolve(cfg.optimizer)
scheduler = resolve(cfg.lr_scheduler)
loss = resolve(cfg.loss)
Each resolve() call instantiates the class specified by _target with the params from YAML. Results are cached — calling resolve(cfg.model) twice returns the same Python object. This is how ${ref:model} references work: when the optimizer config says params: ${ref:model}.trainable_params(), the resolver returns the already-created model instance.
Order doesn't matter. The resolver handles forward references lazily.
4. Resume from Checkpoint¶
Signature: resume_from_checkpoint(model, runner, lr_scheduler, tracker=None).
The 2nd argument is the runner, not the optimizer — sr-forge keeps
the optimizer + AMP scaler state inside the TrainingEpochRunner, so
the runner's load_training_state(state) method is what reads them.
Use trainer.training_runner to get the runner the trainer created.
One call that handles the entire resume flow:
- If the tracker says the run was resumed (
tracker.is_resumed), it loads the last checkpoint, restores runner state (optimizer + scaler), loads model weights, restores LR-scheduler state, and restores RNG states for exact reproducibility. - If not resumed (or
trackerisNone), it returnsNone.
You pass the result to trainer.restore() later — it accepts None as a no-op.
5. Hooks — no script step needed¶
Progress bars, loss logging, checkpoint saving, and image previews are all hooks, and they live in the config — inside the hooks: lists of training_runner, validation_runner, and trainer. When resolve() builds those components, the hooks are constructed and attached automatically. The script does nothing.
To add or remove monitoring, edit the YAML — see Hooks.
Migrating from the legacy observers: pattern
Scripts written for older versions had an explicit step here:
observers = resolve(cfg.observers) followed by
GlobalSettings().event_bus.subscribe(observers). That mechanism
still works via a compatibility adapter but is deprecated —
removal in v0.16.0. See the
porting guide and
Observers & Events for the legacy reference.
6. Trainer¶
trainer = resolve(cfg.trainer)
trainer.restore(ckpt)
trainer.train(cfg.training.epochs, train_loader, val_loader)
The trainer orchestrates the epoch loop. restore(ckpt) sets the initial epoch, best losses, and restores the GradScaler state from the checkpoint. It accepts None (fresh run) as a no-op. See Trainers & Runners for a detailed walkthrough of the trainer and runner internals.
7. Cleanup¶
Finalizes the tracker (flushes logs, uploads remaining files). Always call this at the end.
GlobalSettings¶
GlobalSettings is a singleton that stores application-wide state. The framework uses it to find the event bus and output directory.
| Attribute | Type | Description |
|---|---|---|
config |
DictConfig |
The full experiment config (set by init) |
output_directory |
str |
Hydra's output directory for the current run (set by init) |
event_bus |
EventBus |
(legacy) global event bus for deprecated Observer dispatch — removal in v0.16.0 |
tracker |
ExperimentTracker \| None |
The active tracker (set by the tracker's own __init__) |
debug_mode |
bool |
True when the logger is at DEBUG level |
init(cfg) sets config and output_directory automatically. Each
tracker assigns itself to GlobalSettings().tracker when constructed
(see WandbTracker.__init__) — this is how the SIGINT handler can call
tracker.finish(1) to flush logs before the hard kill. Hooks that
need the tracker still receive it as a constructor parameter via
tracker: ${ref:tracker} in YAML (preferred over reading the global),
but the global slot exists.
ConfigResolver¶
ConfigResolver is the engine that turns YAML into Python objects. Key behaviors:
_target+params— instantiate any class- Recursive — nested
_targetblocks are resolved bottom-up - Cached — each config path is resolved once and reused
${ref:path}— references to already-instantiated objectsio:key — automatically callsset_io()on IOModule subclasses- Runtime kwargs —
resolve(cfg.section, key=value)injects extra constructor arguments
See Configuration for the full reference.
Writing a Test Script¶
A test/benchmark script is simpler — no optimizer, scheduler, or resume:
import hydra
import omegaconf
import torch
from srforge import GlobalSettings, init
from srforge.data.loader import DataLoaderFactory
from srforge.training.runners import BenchmarkRunner
@hydra.main(config_path="configs", config_name="test-cfg", version_base=None)
def main(cfg):
resolve = init(cfg)
tracker = resolve(cfg.tracker)
tracker.log_config(omegaconf.OmegaConf.to_container(cfg, resolve=False))
model = resolve(cfg.model)
metrics = resolve(cfg.test_metrics)
postprocessor = resolve(cfg.postprocessing)
test_dataset = resolve(cfg.dataset)
device = torch.device(cfg.system.device)
model.to(device)
test_loader = DataLoaderFactory(
test_dataset, batch_size=1, shuffle=False,
).get_loader()
runner = BenchmarkRunner(device=device, postprocessor=postprocessor,
hooks=resolve(cfg.hooks))
runner.run_epoch(model=model, data_loader=test_loader, epoch=0, criterion=metrics)
tracker.finish(0)
if __name__ == "__main__":
main()
The pattern is the same: init -> resolve objects -> set up data -> run.
Custom Training Loops¶
If PyTorchTrainer's default TrainingEpochRunner doesn't fit your
needs, you have two options. Important: for GAN-style alternating
updates, sr-forge ships a ready-made
GANTrainingRunner — don't reinvent it.
Option A: Swap in an alternative runner¶
PyTorchTrainer accepts any EpochRunner subclass as its
training_epoch_runner. For GANs, plug in GANTrainingRunner:
# A GAN trainer is still a PyTorchTrainer — only the runner changes.
trainer:
_target: srforge.training.PyTorchTrainer
params:
model: ${ref:gan_model} # an srforge.models.GANModel
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 }
step_ratio: 1.0 # G updates per D update
device: ${system.device}
mixed_precision: ${system.mixed_precision}
hooks: [] # plug in StepRatio, DWarmup, etc.
validation_epoch_runner:
_target: srforge.training.runners.ValidationEpochRunner
params: { device: ${system.device}, criterion: ${ref:val_metrics} }
training_criterion: ${ref:g_criterion}
validation_criterion: ${ref:val_metrics}
lr_scheduler: ${ref:lr_scheduler}
The script stays the same — trainer = resolve(cfg.trainer) builds
both the trainer and the runner. The runner handles alternating G/D
updates automatically; step_ratio controls the cadence.
If you do need a genuinely different trainer abstraction (one that
doesn't conform to the train/validate-per-epoch shape), subclass
srforge.training.trainers.Trainer (abc.ABC, Observable) and
implement train(epochs, train_loader, val_loader). Most users
should not need this — pick a different runner first.
Option B: Inline the Loop in the Script¶
For one-off experiments, you can skip the trainer entirely and write the loop directly:
for epoch in range(resume.initial_epoch, cfg.training.epochs):
model.train()
for batch in train_loader:
batch = batch.to(device)
output = model(batch)
loss_scores = loss(output)
loss_scores.total_weighted().mean().backward()
optimizer.step()
optimizer.zero_grad()
scheduler.step()
This gives you full control but loses the runner/hook infrastructure (progress bars, checkpointing, and logging hooks fire from runners and the trainer).
Multi-GPU¶
For multi-GPU DataParallel training, the device config is a list:
The script uses setup_device() which handles everything — device detection, model.to(), and DataParallel wrapping:
from srforge.utils.multigpu import setup_device
model, device = setup_device(model, cfg.system.device, train_dataset)
For single-GPU or CPU, it simply moves the model. For multi-GPU, it auto-detects whether to use torch.nn.DataParallel or torch_geometric.nn.DataParallel based on the dataset's element type.
Summary¶
| Building Block | What It Does |
|---|---|
init(cfg) |
Framework setup, returns ConfigResolver |
resolve(cfg.section) |
Instantiate any config section |
resolve(cfg.section, key=val) |
Inject runtime values |
hooks: lists on runner/trainer configs |
Attach hooks (progress bars, loggers, checkpointing) |
resume_from_checkpoint(model, runner, lr_scheduler, tracker=) |
One-call checkpoint resume (runner exposes optimizer+scaler state) |
setup_device(model, device_config, dataset) |
Device placement + DataParallel |
DataLoaderFactory(...).get_loader() |
Create data loaders |
trainer.train(epochs, train_loader, val_loader) |
Run the training loop |
tracker.finish(0) |
Finalize the run |
Next: Configuration — Define entire experiments in YAML