This pack ships three backends behind one interface so you are never locked in.
But which should you actually *use*? This guide gives you a decision framework,
an honest comparison, and migration notes. Because every backend implements the
same ExperimentTracker interface, switching later is a one-line change — so
optimize for where you are today, not where you might be in two years.
Are you offline, in CI, or writing tests?
└─ Yes → custom (local SQLite). Zero setup, zero network, fully deterministic.
└─ No → Do you need a hosted UI, sweeps, and team sharing with no ops work?
└─ Yes → wandb. Best-in-class dashboards and hyperparameter sweeps.
└─ No → Do you want self-hosted, open-source, and model-registry features
under your own control?
└─ Yes → mlflow. Run your own server, own your data.
└─ Unsure → start with custom, switch when a real need appears.
| Dimension | Local SQLite (custom) | Weights & Biases (wandb) | MLflow (mlflow) |
|---|---|---|---|
| Setup cost | None (a file) | Account + wandb login | Run a server or use mlruns/ |
| Hosting | Your disk | SaaS (cloud) | Self-host or local |
| Network required | No | Yes (or offline mode) | No (local) / Yes (server) |
| Hosted UI / dashboards | No (CLI tables) | Excellent | Good (built-in web UI) |
| Built-in sweeps | Grid/random helpers | Yes (Bayesian agents) | Via plugins |
| Team collaboration | Manual (share the file) | Excellent | Good (shared server) |
| Model registry | No | Yes (artifacts) | Yes (first-class) |
| Cost | Free | Free tier + paid seats | Free (you pay for infra) |
| Data ownership | Total | Vendor-hosted | Total (self-hosted) |
| Best for | Tests, CI, offline, demos | Teams wanting zero ops | Orgs wanting control |
The custom tracker is not a toy — it is the right default in several real
situations:
stay fast and hermetic. This pack's own tests use it.
experiment itself.
Its limits are equally clear: there is no hosted UI (you compare via
format_table/to_markdown), and "sharing" means sharing the SQLite file. When
you outgrow it, the interface makes moving up painless.
Choose wandb when you want a polished, hosted experience with essentially no
operational burden:
run tables your whole team can browse.
config with build_wandb_sweep_config and launch agents.
Trade-offs: it is a third-party SaaS (your run data lives on their servers,
though an offline mode exists), and seats cost money beyond the free tier. Use
mode="offline" on locked-down machines and wandb sync later.
Choose mlflow when you want open-source tracking you control end to end:
(tracking_uri="http://mlflow.internal:5000") or even just a local mlruns/
directory with no server at all.
promotion workflows.
Trade-offs: someone has to run and maintain the server for team use, and the UI,
while solid, is less flashy than W&B's. For many organizations that control is
exactly the point.
hosting. You trade dollars for zero ops.
time for a shared server (a small VM is often enough).
Because all three implement ExperimentTracker, your *training code* never
changes — only how you construct the tracker:
# Local during development...
tracker = get_tracker("custom", db_path="runs.db", project="demo")
# ...W&B when you want dashboards...
tracker = get_tracker("wandb", project="demo", entity="your-org")
# ...MLflow when you self-host.
tracker = get_tracker("mlflow", experiment_name="demo",
tracking_uri="http://mlflow.internal:5000")Better still, select the backend from config so it's an ops decision, not a code
change:
from exp_tracking import load_config, tracker_from_config
tracker = tracker_from_config(load_config("config.yaml"))There is no automated cross-backend *history* migration — runs already recorded
in W&B don't move to MLflow. But because comparison reads through the same
interface, you can point compare_runs at whichever backend holds the runs you
care about. In practice teams run custom in CI and a hosted backend for real
training, which this design supports natively.
1. Start on custom while you prototype — instant, offline, testable.
2. Graduate to wandb when you want dashboards and sweeps without ops, or to
self-hosted mlflow when data control and a model registry matter more.
3. Keep custom in your test suite forever — it is the fastest, most
reliable way to verify your tracking code without external dependencies.
You are not choosing a tracker for life. You are choosing the one that removes
the most friction from your *next* experiment.
A practical guide to instrumenting machine-learning experiments so that, six
months from now, you (or a teammate) can answer the only questions that matter:
*which run was best, what made it best, and can I reproduce it?* The patterns
below are backend-neutral — they apply whether you log to Weights & Biases,
MLflow, or the local SQLite tracker that ships with this pack.
Untracked experiments rot. The notebook that produced your best F1 score gets
edited, the hyperparameters live in a Slack message, and the checkpoint sits in a
folder named final_v3_REAL. Experiment tracking replaces that entropy with a
durable record: every run captures its configuration, its **metrics over
time, and its artifacts** (checkpoints, plots, datasets), keyed by a stable
run id. Three habits pay for themselves almost immediately:
1. Comparability — runs become rows in a table you can sort and filter.
2. Reproducibility — a run carries enough context to be re-created.
3. Collaboration — "look at run a1b2c3" beats "trust me, it worked."
This pack models a run with three distinct kinds of data. Keeping them separate
is the single most useful convention in experiment tracking.
| Kind | Cardinality | Examples | Logged with |
|---|---|---|---|
| Params | one value per key | lr=1e-3, optimizer=adam, seed=42 | log_params |
| Metrics | a time series per key | train_loss, val_accuracy per epoch | log_metrics |
| Artifacts | files attached to a run | model.pt, confusion_matrix.png | log_artifact |
A param answers "how was this configured?" and never changes within a run. A
metric answers "how did it do over time?" and is recorded at every step. If you
find yourself logging a value that changes during training as a *param*, it
belongs in metrics — and vice versa.
Always finalize a run, even when training crashes. The run() context manager
does this for you: a clean exit marks the run FINISHED; an exception marks it
FAILED and re-raises. That single guarantee keeps your dashboard honest — a
half-finished run is never silently left as "running" forever.
from exp_tracking import get_tracker
tracker = get_tracker("custom", db_path="runs.db", project="image-classification")
with tracker.run(run_name="resnet18-baseline", config=config, tags=["baseline"]):
for epoch in range(epochs):
train_loss = train_one_epoch(...)
val_loss, val_acc = validate(...)
tracker.log_metrics(
{"train_loss": train_loss, "val_loss": val_loss, "val_accuracy": val_acc},
step=epoch,
)If you prefer manual control, call start_run() / end_run(status) yourself —
just remember to handle the failure path.
stepThe step argument is the x-axis of every metric plot. Pick one meaning for
it per project and stick to it — usually the epoch index or the global batch
counter. Consistency matters more than the choice: if half your runs step by
epoch and half by batch, their curves are not comparable. When you omit step,
this pack assigns a monotonically increasing integer, which is fine for simple
loops but explicit steps are strongly recommended for anything you'll compare.
Log training and validation metrics at the same step so they line up on a
shared axis. Logging train_loss every batch but val_loss every epoch makes
overlay plots misleading.
Names and tags are how you find a run later. A little discipline goes a long way:
adam-lr1e-3-aug beatsrun_2024_05_01_173455. The backend already stores creation time.
baseline, ablation, gpu-a100,dataset-v2. Tags let you pull "every ablation on dataset-v2" in one query.
fundamentally different task deserves a new project.
A run is reproducible when its record contains everything needed to recreate it.
At minimum, log these as params at the start of every run:
import subprocess, sys
config = {
"seed": 42,
"git_sha": subprocess.run(
["git", "rev-parse", "HEAD"], capture_output=True, text=True
).stdout.strip(),
"python": sys.version.split()[0],
"dataset_version": "v2",
# ...plus all model/optimizer hyperparameters
}Set and record your random seeds (random, numpy, your framework). Capturing
the git SHA means you can always check out the exact code that produced a result.
Logging the dataset version prevents the classic "the data changed under me"
mystery.
Treat artifacts as first-class outputs, not afterthoughts:
common pattern is to log a single best.pt and overwrite it when validation
improves.
the run is worth a thousand scalar metrics during review.
the reproducibility loop entirely.
The local backend copies artifacts into an artifacts/ directory so a
run is self-contained and portable.
Logging is only half the job; the payoff is comparison. Use compare_runs to
build a table across *any* backend, then rank it:
from exp_tracking import compare_runs, format_table, best_run
table = compare_runs(tracker, metric_keys=["val_accuracy", "val_loss"],
reduction="best_max")
print(format_table(table))
winner = best_run(table, "val_accuracy", mode="max")By default the comparison shows only the params that differ across runs, so
the signal (what you changed) is not buried under the constants (what you didn't).
Choose a reduction that matches the question: "last" for "where did it end
up", "best_max"/"best_min" for "what's the best it ever reached".
When you move from one run to many, describe the search space declaratively and
let exp_tracking.sweep expand it. Grid search is exhaustive and reproducible;
random search covers high-dimensional spaces more efficiently per trial.
from exp_tracking import grid_search, build_wandb_sweep_config
trials = grid_search({"lr": [1e-2, 1e-3, 1e-4], "batch_size": [32, 64]}) # 6 configs
# Or hand the search space to a W&B Bayesian sweep:
sweep_config = build_wandb_sweep_config(
space={"lr": (1e-5, 1e-1), "dropout": (0.0, 0.5), "optimizer": ["adam", "sgd"]},
metric_name="val_accuracy", goal="maximize", method="bayes",
)coerce_metric_value rejects raw True/Falseon purpose — cast to 1.0/0.0 so your intent is explicit.
version are a leaderboard you can't act on.
Track deliberately and your experiment history becomes an asset you mine, not a
graveyard you avoid. The next guide, choosing-a-tracker.md, helps you pick the
backend that fits your team.