stable-pretraining is a PyTorch Lightning framework for self-supervised learning (SSL) research. It provides composable forward functions and full LightningModule method classes covering SimCLR, BYOL, VICReg, Barlow Twins, SwAV, NNCLR, DINO, DINOv2, MAE, BEiT, MoCo, and more — all built on top of lightning. See METHODS.md for the complete catalog. The key design principle is that users only define forward(self, batch, stage) — the framework builds training_step and validation_step around it, with all data flowing as dicts so callbacks can intercept any intermediate value without modifying the forward function.
stable_pretraining/
__init__.py # lazy-loaded public API (PEP 562); add new exports here
module.py # Module — LightningModule all methods share; wraps user forward fn
manager.py # Manager — programmatic entry point; prefer over Trainer directly
forward.py # forward functions (simclr, byol, …)
methods/ # LightningModule subclasses, one per SSL method
callbacks/ # evaluation and training callbacks (OnlineProbe, OnlineKNN, RankMe, …)
losses/ # loss classes ({Method}Loss naming convention)
backbone/ # encoder wrappers (torchvision, timm, HuggingFace)
data/ # HFDataset, MultiViewTransform, RepeatedRandomSampler
loggers/ # WandB, Trackio, SwanLab integrations
registry/ # filesystem-first run registry (sidecars + SQLite)
web/ # local run viewer (spt web); reads RegistryLogger output
optim/ # optimizer and scheduler factories
utils/ # atomic checkpointing, lightning patch, error handling
_config.py # global config: spt.set(key, value) / spt.get_config()
examples/ # runnable .py scripts and YAML configs
docs/ # Sphinx source
METHODS.md # ground-truth index of all methods + forward functions
Top-level lazy imports (preferred):
import stable_pretraining as spt
model = spt.Module(forward=..., backbone=..., projector=...)
manager = spt.Manager(trainer=trainer, module=model, data=data_module)
# Method classes (most-used ones hoisted to top level)
model = spt.SimCLR(...)
model = spt.BYOL(...)
model = spt.DINO(...)
model = spt.DINOv2(...)
model = spt.MAE(...)
model = spt.BarlowTwins(...)
model = spt.VICReg(...)
model = spt.SwAV(...)
model = spt.NNCLR(...)
# Callbacks
probe = spt.OnlineProbe(...)
knn = spt.OnlineKNN(...)
rankme = spt.RankMe(...)Direct module imports:
from stable_pretraining.forward import simclr, byol
from stable_pretraining.methods import SimCLR, BYOL, DINO, VICReg, MAE # full list in METHODS.md
from stable_pretraining.callbacks import OnlineProbe, OnlineKNN, RankMe, LiDAR
from stable_pretraining.losses import NTXEntLoss, BYOLLoss, VICRegLossYAML config (Hydra):
module:
_target_: stable_pretraining.Module
forward: stable_pretraining.forward.simclr
backbone: ...
projector: ...A forward function is a plain Python function with signature:
def my_forward(self, batch: dict[str, Any], stage: str) -> dict[str, torch.Tensor]:
...self is the Module instance (bound at runtime). batch is always a dict — never a raw tensor. stage is "train", "val", or "test". The function must return a dict; during training that dict must contain "loss". Forward functions are stateless and reusable — they are specified by dotted import path in YAML configs.
Module (in stable_pretraining/module.py) extends pl.LightningModule. It:
- Receives a
forwardcallable at init and binds it toself - Builds
training_stepandvalidation_stepfrom that callable - Manages optimizer and scheduler configuration (single or multi-optimizer)
- Supports manual optimization for methods with EMA teachers (BYOL, DINO)
- Stores arbitrary sub-modules (backbone, projector, predictor, etc.) as attributes
Manager (in stable_pretraining/manager.py) orchestrates a full training run:
- Wraps a
pl.Trainer,Module, andDataModule - Handles SLURM preemption (SIGTERM → checkpoint → requeue)
- Assigns deterministic run IDs and manages atomic checkpointing
- Resolves logger-specific resume logic (WandB, Trackio, SwanLab, RegistryLogger)
import stable_pretraining as spt
import torch
from functools import partial
# 1. Build components
backbone = spt.backbone.resnet18()
projector = torch.nn.Linear(512, 128)
# 2. Wire a forward function into Module
from stable_pretraining.forward import simclr
from stable_pretraining.losses import NTXEntLoss
module = spt.Module(
forward=simclr,
backbone=backbone,
projector=projector,
simclr_loss=NTXEntLoss(temperature=0.5),
optim={
"optimizer": partial(torch.optim.AdamW, lr=1e-3),
"scheduler": "CosineAnnealingLR",
},
)
# 3. Add evaluation callbacks
probe = spt.OnlineProbe(
module,
name="linear_probe",
input="embedding",
target="label",
probe=torch.nn.Linear(512, 10),
loss=torch.nn.CrossEntropyLoss(),
)
# 4. Run via Manager
import lightning as pl
trainer = pl.Trainer(max_epochs=100, callbacks=[probe])
manager = spt.Manager(trainer=trainer, module=module, data=data_module)
manager()Alternatively, use a pre-wired method class for the same result with fewer lines:
model = spt.SimCLR(backbone=backbone, projector=projector, temperature=0.5, lr=1e-3)| Component | Convention | Example |
|---|---|---|
| Forward functions | {method}_forward (snake_case) |
simclr |
| Loss classes | {Method}Loss (CamelCase) |
NTXEntLoss, BYOLLoss |
| Method classes | {Method} (CamelCase) |
SimCLR, BYOL, DINOv2 |
| Callbacks | Descriptive CamelCase, no suffix | OnlineProbe, RankMe |
| YAML config keys | Match Python argument names exactly | max_epochs, batch_size |
| Batch dict keys | snake_case strings | "image", "label", "embedding" |
-
Forward function — add
{method}_forwardtostable_pretraining/forward.py:- Signature:
def {method}_forward(self, batch: dict[str, Any], stage: str) -> dict[str, torch.Tensor]: - Full Google-style docstring with
Args:,Returns:, andNote:sections - Follow the existing pattern: handle multi-view vs single-view, log loss, return dict with
"embedding"and"loss"
- Signature:
-
Loss class — add
{Method}Losstostable_pretraining/losses/{method}.py, export fromstable_pretraining/losses/__init__.py -
Method class — add
{Method}LightningModule tostable_pretraining/methods/{method}.py -
Export from methods — add to
stable_pretraining/methods/__init__.py:from .{method} import {Method}
and add
"{Method}"to__all__ -
Top-level export — add to
_LAZY_ATTRSinstable_pretraining/__init__.py:"{Method}": ("stable_pretraining.methods.{method}", "{Method}"),
and add
"{Method}"to__all__ -
Method catalog — add a row to
METHODS.mdwith all columns filled in -
Type annotations — ensure the forward function has full annotations including
-> dict[str, torch.Tensor]
Install:
pip install -e ".[dev]" # includes pytest, ruff, sphinx
pip install -e . # core onlyRun an example:
spt examples/simclr_cifar10_config.yaml # YAML config via CLI
spt examples/simclr_cifar10_config.yaml trainer.max_epochs=50 # with overrides
spt examples/simclr_cifar10_slurm.yaml -m # SLURM multirun
python examples/supervised_learning.py # Python scriptRun tests:
python -m pytest stable_pretraining/tests -m unit --verbose # CI default (fast)
python -m pytest stable_pretraining/tests -m integration # integration tests
python -m pytest -m "not slow" # skip slow tests
# Markers: unit, integration, gpu, slow, download, ddpLint:
ruff check stable_pretraining --fix
ruff format stable_pretraining
pre-commit run --all-filesRegistry CLI:
spt registry ls # list runs
spt registry best val_acc -n 5 # top 5 by metric
spt registry export sweep.csv # export to CSV
spt registry scan --full # rebuild SQLite cachespt web is a dependency-free local viewer for RegistryLogger runs — a
WandB alternative requiring no account, no internet, and no extra dependencies
beyond what the library ships. It reads sidecar.json + metrics.csv produced
by RegistryLogger and serves a browser UI over stdlib http.server with
Server-Sent Events for live updates.
# Scan any directory (scanner walks the tree to any depth)
spt web runs/
# No argument → uses {cache_dir}/runs automatically
spt web
# Custom host / port / poll interval (mtime-based, no inotify — NFS-safe)
spt web runs/ --host 0.0.0.0 --port 8080 --poll 2.0The viewer opens at http://127.0.0.1:4242 by default. The page updates in
real time as training writes new metrics — no reload needed.
Manager automatically injects a RegistryLogger into every run. When using
Manager, no extra configuration is needed: spt web {cache_dir}/runs will
show all runs. The logger writes to {run_dir}/:
| File | Written by | Displayed as |
|---|---|---|
sidecar.json |
RegistryLogger | run metadata, hparams, summary, status, tags, notes |
metrics.csv |
RegistryLogger | per-step charts (figures tab) |
heartbeat |
RegistryLogger | mtime → stale detection (>5 min old without terminal status = ⚠ stale) |
media.jsonl |
RegistryLogger | image/video media panel |
*.out / *.err |
training process | log tab (auto-tails live runs) |
To use RegistryLogger directly (outside Manager):
from stable_pretraining.registry import RegistryLogger
logger = RegistryLogger(run_dir="runs/my_run", run_id="my_run")
trainer = pl.Trainer(logger=logger, ...){run_dir}/ ← any leaf directory that contains a sidecar.json
sidecar.json ← required: run metadata
metrics.csv ← optional: columns = step, epoch, <metric_name>, ...
heartbeat ← optional: empty file; mtime = last alive timestamp
media.jsonl ← optional: one JSON object per line, image/video events
train.out / train.err ← optional: log files shown in the .out / .err tabs
checkpoints/ ← optional: ignored by the viewer
Runs do not need to be at the top level of the scanned directory — the scanner recurses to any depth.
Key fields agents may read or patch:
| Field | Type | Notes |
|---|---|---|
run_id |
str |
Unique identifier, usually path relative to cache_dir |
display_name |
str |
Human-readable label shown in the sidebar (editable via UI or PATCH /api/run-meta) |
status |
str |
"running" | "completed" | "failed" | "orphaned" |
hparams |
dict |
Hyperparameters; logged via log_hyperparams |
summary |
dict |
Final scalars (e.g. best val_acc); populated by RegistryLogger at finalize |
tags |
list[str] |
Labels for filtering and grouping (editable via UI) |
notes |
str |
Free-text notes (editable via UI) |
created_at |
float |
Unix timestamp of run start |
ended_at |
float | None |
Unix timestamp of run end; None while still running |
The sidecar can be patched programmatically via the server's HTTP API while
spt web is running:
import requests
requests.patch("http://127.0.0.1:4242/api/run-meta", json={
"run_id": "runs/my_run",
"display_name": "experiment-v2",
"tags": ["sweep", "lr-1e-3"],
"notes": "Increased weight decay.",
})Allowed mutable fields: display_name, notes, tags, archived.
| Tab / panel | What it shows | Key interactions |
|---|---|---|
| Figures | One uPlot chart per metric, all visible runs overlaid | Drag to zoom (synced across all charts); ↓ min / ↑ max direction toggle; + to combine metrics into one panel; ⬇ to download as PNG |
| Table | Runs × (hparams + summary) comparison grid | Sortable columns; column search; "hide same" collapses identical columns; amber diff highlighting |
| .out / .err | Last ~4 MiB of log files | Auto-refreshes every 10 s while run is live; pause button stops auto-refresh |
| Detail modal | Full hparams / summary / tags / notes for one run | Notes and tags are editable in-place; double-click run name in sidebar to rename |
| Sidebar | Run list | Search by name/tag/hparam; filter by field value; group-by any hparam key; sort by any metric |
State (selected runs, filters, active tab, smoothing, theme) persists to
localStorage across reloads. Visible run IDs and active tab are also written
into the URL fragment (#runs=id1,id2&tab=figures) so a shared URL restores the
exact selection.
- User wants to inspect or compare runs locally (no WandB account needed).
- User wants to monitor a live training run — the viewer updates as metrics arrive.
- User is running a hyperparameter sweep and wants to compare configs (table tab + hide-same).
- User wants to see log output from a run:
spt web {parent_dir}, then open the .out tab. - User asks "how do I see my training curves?" —
spt webis the answer unless they already have WandB configured.
Lightning runs trainer.callbacks in registration order. Within a single hook, callbacks fire in that order; across hooks, Lightning completes each hook for every callback before moving to the next.
Practically: producer/consumer pairs split across different hooks (e.g., OnlineQueue builds its snapshot in on_validation_epoch_start, OnlineKNN reads it in on_validation_batch_end) are not order-sensitive — the producer hook is already done for every callback before any consumer hook runs. Don't worry about ordering those.
Order does matter when two callbacks act in the same hook and one reads what the other writes:
| Callback | Rule |
|---|---|
TeacherStudentCallback |
After any callback that reads teacher params in on_train_batch_end — its EMA update fires there |
OnlineProbe |
After callbacks that mutate the batch embedding in on_train_batch_end (e.g., normalization probes) |
OnlineWriter |
Last among per-batch callbacks — captures all mutations in on_train_batch_end |
CleanUpCallback |
After callbacks that save artefacts in on_train_end / teardown (checkpoint callbacks, hf_models, …) |
At runtime, the default TrainerInfo callback logs the full callback list with ⚑ markers on order-sensitive ones — check that log first when debugging an ordering issue.
The authoritative registry lives in stable_pretraining.callbacks.utils.ORDER_SENSITIVE_CALLBACKS. When adding a new order-sensitive callback, append it there so the runtime log surfaces the constraint.
- Do not modify
stable_pretraining/__init__.pylazy-loading machinery without reading it fully first — PEP 562__getattr__is in use and changes break all lazy imports - Do not add public functions without type annotations and Google-style docstrings
- Do not add a new method class without a corresponding entry in
METHODS.mdand a forward function inforward.py(where applicable) - Do not change loss function implementations without running tests
- Do not use deep module imports in example code — use the top-level namespace (
import stable_pretraining as spt; spt.SimCLR) - Do not add comments that explain what code does — only add comments for why (hidden constraints, non-obvious invariants, workarounds)
- Do not return raw tensors from forward functions — always return a
dict
Why forward functions are stateless. Forward functions take self as their first argument but are defined as plain functions, not methods. This lets them be specified as dotted import paths in YAML configs (forward: stable_pretraining.forward.simclr) and instantiated by Hydra. At runtime Module binds the function to self using types.MethodType, so self.backbone, self.projector, etc. are all accessible. Keeping them stateless (no internal state, no class inheritance) makes them composable and testable in isolation.
Why Manager exists alongside Trainer. pl.Trainer handles the training loop; Manager handles everything around it. Specifically: detecting SLURM preemption via SIGTERM, writing checkpoint-then-requeue, assigning deterministic run IDs (so resumed runs pick up the same WandB/registry run), and resolving which checkpoint to resume from across multiple logger backends. Using Manager(...)() instead of Trainer.fit(...) is the correct programmatic API.
Why lazy loading is used. import stable_pretraining is called at CLI startup even for lightweight commands (spt registry ls). Eagerly importing Lightning, HuggingFace datasets, or timm would add 3–5 seconds to every CLI invocation. PEP 562 __getattr__ defers those imports until the first heavy attribute access. The deferred init (Lightning manual-optimization patch, atomic checkpoint install) also runs at that point, not at import time.
How the callback system works. Callbacks are standard pl.Callback subclasses, but they receive the full batch dict (which is mutated by forward) via outputs in on_train_batch_end / on_validation_batch_end. This means any key written to the return dict in forward (e.g., "embedding", "loss", "swav_queue") is automatically available to every callback with no wiring. Adding OnlineProbe, OnlineKNN, or RankMe to a training run requires zero changes to the forward function.
How multi-optimizer / EMA teacher methods work. Methods like BYOL and DINO require two optimizers (online and target networks) and an EMA update for the target. Module supports manual optimization: if optim is a dict of named optimizer configs, Lightning's automatic optimization is disabled and Module.training_step calls each optimizer explicitly. The TeacherStudentCallback handles the EMA weight update after each step. The TeacherStudentWrapper backbone exposes .forward_student() and .forward_teacher() so forward functions can address each network cleanly.