hands-on labon a real NVIDIA GPU

MLflow Experiment Tracking: From Single Run to Team Workflow

Wire the four load-bearing pieces of MLflow into a real training loop, tracked runs with params and metrics, a registered model with stage transitions, a multi-run sweep + search, and a production spec (server, k8s Job, tags, autolog).

time
35 min
checked steps
4
level
Intermediate
setup
None
NCP-AIONCA-GENLNCP-ADSNCA-GENMNCP-AIIPart of the AI Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Instrument a training run

    read this step below ↓

  2. 2

    Log the model & register it

  3. 3

    Search & compare runs

  4. 4

    Production: server, Kubernetes, tags, autolog

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Instrument a training run

We'll run a tiny PyTorch training loop. The MLflow wrapper is 3 lines:

mlflow.set_tracking_uri('file:///tmp/mlruns')
with mlflow.start_run(run_name='lr=1e-3 bs=32') as run:
    mlflow.log_params({'lr': 1e-3, 'batch_size': 32})
    for step, loss in training_loop():
        mlflow.log_metric('train_loss', loss, step=step)

What gets recorded automatically:

  • run_id — unique UUID for this run
  • start_time, end_time, status
  • experiment_id — a grouping label
  • Everything you explicitly logged via log_params, log_metric, log_artifact
  • If you turn on autolog(): the model, sample inputs, and framework version too

Set up MLflow — file backend + experiment

We point MLflow at a clean file backend under /tmp so every lab session starts fresh. In production you'd swap this for a tracking server URI (http://mlflow.company.internal:5000) with Postgres + S3 behind it — but the Python API is identical either way, so code written against the file backend ports unchanged.

import os, tempfile, shutil, time
import mlflow, torch, torch.nn as nn

# Use a clean file backend under /tmp so each run starts fresh
TRACKING_DIR = tempfile.mkdtemp(prefix='mlruns_')
mlflow.set_tracking_uri(f'file://{TRACKING_DIR}')
print(f"MLflow tracking URI: file://{TRACKING_DIR}")

experiment_name = 'preporato-lab-i13'
experiment_id = mlflow.set_experiment(experiment_name).experiment_id
print(f"Experiment: {experiment_name} (id={experiment_id})")

# A deliberately small model so the lab stays fast
device = 'cuda'
torch.manual_seed(42)
model = nn.Sequential(nn.Linear(64, 128), nn.GELU(), nn.Linear(128, 10)).to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

n_steps = 50
logged_params_dict = {
    'learning_rate':  1e-3,
    'batch_size':     64,
    'optimizer':      'Adam',
    'model_arch':     'MLP-64-128-10',
    'n_steps':        n_steps,
    'device':         'cuda',
}

The training run — tags + params + per-step metrics

Everything inside the with mlflow.start_run(): block gets attached to this run. Three kinds of data go in:

  • Tags — the run's identity (team, owner, git commit, env). Used for filtering.
  • Params — the static config (lr, batch, arch). Used for comparison across runs.
  • Metrics — time-series measurements (loss per step). Used for plots and model selection.
# The main MLflow-wrapped training run
loss_series = []
with mlflow.start_run(run_name='baseline-lr1e-3') as run:
    run_id = run.info.run_id
    mlflow.set_tags({'team': 'ml-platform', 'owner': 'preporato-labs', 'git_commit': 'abc123def', 'env': 'dev'})
    mlflow.log_params(logged_params_dict)
    for step in range(n_steps):
        x = torch.randn(64, 64, device=device)
        y = torch.randint(0, 10, (64,), device=device)
        opt.zero_grad()
        out = model(x)
        loss = loss_fn(out, y)
        loss.backward(); opt.step()
        mlflow.log_metric('train_loss', float(loss), step=step)
        loss_series.append((step, float(loss)))
    mlflow.log_metric('final_loss', float(loss))

Post-run view — query the run back from MLflow

The Run object on-disk now has everything the training job logged. In production you'd browse this in the MLflow UI (mlflow ui --host 0.0.0.0 --port 5000) or query programmatically via search_runs (we do that in step 3).

# Assemble the post-run view
finished = mlflow.get_run(run_id)
run_info = {
    'run_id':        finished.info.run_id,
    'experiment_id': finished.info.experiment_id,
    'status':        finished.info.status,
    'start_time':    finished.info.start_time,
    'end_time':      finished.info.end_time,
}
logged_params  = logged_params_dict
logged_metrics = {'train_loss': loss_series}

print(f"\nRun {run_id[:12]} finished: status={run_info['status']}")
print(f"  params logged: {list(logged_params.keys())}")
print(f"  train_loss: {logged_metrics['train_loss'][0][1]:.4f} → {logged_metrics['train_loss'][-1][1]:.4f} over {n_steps} steps")

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • PyTorch training loop basics
  • Python context managers and pandas DataFrames
  • Concept of experiment tracking / reproducibility

Exam domains covered

AI Infrastructure & OperationsMachine Learning Lifecycle

Part of the LLMOps and MLOps track

Lab 17 of 22. vLLM serving, load tests against SLOs, tracing, drift monitoring and prompt tests in CI.

Questions about this lab

Why use the MLflow Model Registry instead of just pointing at a run_id?

Because run_ids change every retrain and production code can't track them. The Registry gives you stable URIs like models:/chat-intent-classifier/Production that resolve to whichever version is currently promoted. When a new model wins the evaluation gate, you call transition_model_version_stage(..., stage='Production') and serving jobs pick it up without a config change. That's the atomic promotion the lab teaches in Step 2, run_ids stay in Slack and docs, URIs live in the serving config.

What's the point of mlflow.autolog() if I still need explicit log_metric?

Autolog captures the things every framework user wants, training/validation loss, hyperparameters from the model constructor, epoch summaries, framework version, model artifact, without a single explicit call. It's the 90% case. You still call log_metric for domain metrics (a business KPI, a calibration score, a safety-eval pass rate) because those aren't in any framework's default set. The lab has you author autologging_config in Step 4 so you enumerate which flavors to enable (pytorch, sklearn, transformers) and whether to log input examples.

Why --backend-store-uri Postgres + --artifacts-destination S3 in production?

The backend store holds metadata, runs, params, metrics, registry state, and it's read-heavy with transactional writes, which Postgres handles well. Artifacts (model binaries, input examples, plots) are large blobs better served from object storage. The file-URI backend you use in Step 1 is fine for a single notebook but falls over the moment a second engineer tries to list runs. The Step 4 mlflow_server_cmd forces you to write the real CLI with both URIs so you've seen the production layout.

What tags should I actually enforce?

At minimum: team (who owns it), owner (individual accountable), env (dev/staging/prod), git_commit (exact source version), and model_family (classifier, retriever, embedder, LLM fine-tune, etc.). Step 4 requires team and owner specifically; the reflection at the end asks which tag would let a new engineer six months later distinguish 'the best chat-intent classifier from last quarter' from every other classifier on the server. That's model_family, and without it every search is a text match against titles.

Can I use MLflow for training on Kubernetes without the Tracking Server running as a K8s service?

Yes, your training Job just needs MLFLOW_TRACKING_URI pointing at a reachable server (the one started by mlflow server ...), and it'll POST runs over HTTP. The Step 4 k8s_training_job YAML forces that injection as an env var, plus nvidia.com/gpu resource request and restartPolicy: for the Job semantics. The server itself typically runs as a separate Deployment behind a Service, outside this lab's scope, but a common pairing.

What does the grader validate on each step?

Step 1 requires run_info.status == 'FINISHED', logged_params contains learning_rate/batch_size/optimizer, logged_metrics['train_loss'] has ≥5 (step, value) tuples, and last loss < first loss. Step 2 validates model_artifact_path is a real MLflow URI, registered_model has name/latest_version, version_stage is one of the valid stage names, and reload_prediction_shape is a non-empty tuple. Step 3 enforces len(all_runs_df) >= 3 and best_run.final_loss matches the DataFrame minimum. Step 4 checks the server CLI flags, K8s Job primitives, required tags, and autologging_config keys.

What you'll wire up in this MLflow lab

MLflow stops being a notebook toy and starts being infrastructure the moment a second engineer asks 'which run produced the model we shipped last quarter?' This lab wires the four load-bearing pieces, tracked runs, Model Registry, search_runs across experiments, and the production server spec (Postgres + S3 + Kubernetes + tagging convention + autolog config). You'll walk away with a real PyTorch run instrumented with log_params/log_metric, a registered model you promoted to Staging and reloaded via stable models:/<name>/<version> URI, a multi-run sweep queried with mlflow.search_runs into a pandas DataFrame, and a production-ready mlflow_server_cmd + Kubernetes Job YAML + tagging_convention + autologging_config that the team can actually deploy. About 35 minutes on a live NVIDIA GPU pod, MLflow, PyTorch, pandas, and a scratch tracking backend are preinstalled.

The substance lives in the Registry and tags. Run IDs are disposable, they change every retrain, so production serving code can't track them. The Registry gives you stable URIs like models:/chat-intent-classifier/Production that resolve to whichever version is currently promoted; when a new model wins the evaluation gate, transition_model_version_stage(..., stage='Production') is one call and serving jobs pick it up without a config change. Tags are the invisible load-bearing piece: without enforced team, owner, env, git_commit, model_family, mlflow.search_runs(filter_string="tags.model_family = 'llm' and tags.team = '...'") degenerates into ls of run_ids and nobody can find last quarter's classifier. Autolog covers the 90% case across flavors (PyTorch, sklearn, transformers, Lightning), training loss, hyperparameters from the model constructor, framework version, model artifact, without explicit calls. Domain metrics (business KPIs, calibration curves, safety-eval pass rates) still need log_metric because no framework's default set includes them.

The production spec is where engineers learn the layout most notebook demos skip. --backend-store-uri points at Postgres because metadata is read-heavy with transactional writes; --artifacts-destination points at S3 because models and input examples are large blobs better served from object storage. The file-URI backend is fine for a single notebook and falls over the moment a second engineer tries to list runs. The Kubernetes Job YAML injects MLFLOW_TRACKING_URI as an env var so the training pod POSTs runs over HTTP to your separately-deployed tracking server, plus nvidia.com/gpu for scheduling and an appropriate restartPolicy for Job semantics.

Prereqs: PyTorch training-loop basics, Python context managers and pandas DataFrames, some exposure to experiment tracking. Preinstalled: MLflow, PyTorch, pandas, scratch tracking backend, JupyterLab. Grading checks real structure: run_info.status must be FINISHED, logged_metrics['train_loss'] must have ≥5 steps and decrease first→last, the registered model must reload and return a valid prediction shape, all_runs_df must contain ≥3 runs with best_run matching the true minimum, and the production spec must reference Postgres backend, S3 artifacts, a Kubernetes Job with GPU scheduling, and required tags like team and owner.