🎯 What You'll Learn

  • Understand the reproducibility problem MLflow solves and why ad hoc tracking (spreadsheets, notebook cells, filenames) breaks down past a handful of experiments
  • Use MLflow Tracking to log parameters, metrics, artifacts, and code versions across runs with mlflow.start_run()
  • Enable one-line autologging for scikit-learn and PyTorch with mlflow.sklearn.autolog() and mlflow.pytorch.autolog()
  • Compare, filter, and sort runs in the MLflow Tracking UI to find the best model for a given metric
  • Package reproducible runs with MLflow Projects (MLproject files and conda/Docker environments)
  • Save and load models in the MLflow Models standard format and understand "flavors"
  • Version models through the Model Registry, moving them through Staging → Production → Archived with lineage and approval gates
  • Stand up a centralized MLflow Tracking Server backed by a database and remote artifact store for team-wide collaboration, and contrast it with the local file-based default
💡
The Big Intuition

You already know how to tune a model (Lessons 27 and 61) — sweep hyperparameters, watch a validation metric, pick the best configuration. The part nobody teaches you is what happens to that knowledge five minutes after you find the winning run. In most teams it evaporates: the learning rate that worked lives in a Jupyter cell that got overwritten, the model file is named model_v2_final_FINAL.pkl, and nobody remembers which preprocessing code produced it. MLflow is the system of record for that knowledge. Every run — its parameters, its metrics, its code version, its output artifacts — gets logged automatically and permanently. Every model that gets promoted to production has a paper trail: who approved it, what metrics it had, what data and code produced it. MLflow doesn't make your models better; it makes your experimentation process auditable, comparable, and reproducible — which is what lets a team of data scientists actually build on each other's work instead of re-discovering it.

1 The Problem: Tracking Chaos at Scale

By Lesson 63 you've run GridSearchCV sweeps, RandomizedSearchCV sweeps, and Optuna studies with hundreds of trials. Each of those tools gives you the best hyperparameters at the end of a run — but the moment you close that notebook, restart your kernel, or come back the next day, you've usually lost everything except whatever number you wrote down by hand. Scale that to a real project: ten team members, six months, hundreds of model variants, multiple datasets, several feature engineering pipelines. The result is a familiar mess:

  • Spreadsheet tracking: someone manually copies accuracy numbers into a Google Sheet, which inevitably falls out of sync with what's actually in the model file.
  • Filename versioning: model.pkl, model_v2.pkl, model_v2_tuned.pkl, model_v2_tuned_FINAL.pkl — with no record of what changed between them or which one is actually deployed.
  • Lost reproducibility: six weeks later, nobody can answer "what hyperparameters, what training data snapshot, and what code commit produced the model currently in production?"
  • No audit trail: a regulator, auditor, or incident responder asks "who approved this model for production, and based on what evaluation?" — and there's no answer.
  • Duplicated work: a colleague reruns an experiment you already tried and discarded three months ago, because there was no shared, searchable record of what had been tried.

MLflow (open-sourced by Databricks in 2018) is the most widely adopted answer to this problem. It is not a single tool but a small platform built from four loosely-coupled components that you can adopt incrementally:

Component Solves Core API
Tracking "What did I try, and what happened?" mlflow.log_param, log_metric
Projects "How do I rerun this exactly, on any machine?" MLproject file + mlflow run
Models "How do I package this so anything can serve it?" mlflow.sklearn.log_model
Model Registry "Which version is in production, and who approved it?" mlflow.register_model

This lesson covers all four, plus the operational question every team eventually hits: should you run MLflow's default local file store, or a centralized server that the whole team points at?

💡
You Already Have the Hard Part

You already know how to design a hyperparameter search (Lessons 27, 61) and how to train and evaluate classical ML and DL models (Lessons 11–62). MLflow doesn't change any of that — it wraps around your existing training loop with a handful of logging calls. Think of this lesson as "instrumentation," not "a new modeling technique." The mental shift is small; the operational payoff is large.

2 MLflow Tracking: Logging Runs

MLflow organizes work into experiments (a named collection of related runs, e.g. "churn-model-xgboost") and runs (one execution — one training job, one hyperparameter configuration). Each run can log:

  • Parameters — inputs that don't change during the run: n_estimators=300, learning_rate=0.05
  • Metrics — numeric outputs, optionally logged at multiple steps/epochs: val_auc=0.891, train_loss per epoch
  • Artifacts — arbitrary files: the trained model, a confusion matrix plot, a SHAP summary plot (Lesson 64), the feature list
  • Tags & source metadata — git commit hash, source script name, who ran it
In [1]:
import mlflow
import mlflow.sklearn
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score, f1_score, accuracy_score
from sklearn.model_selection import train_test_split
import pandas as pd

# Point at a tracking store. Local default: a './mlruns' folder.
# (Section 8 covers pointing this at a centralized server instead.)
mlflow.set_tracking_uri("file:./mlruns")
mlflow.set_experiment("churn-prediction")

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)

params = {
    "n_estimators": 300,
    "max_depth": 8,
    "min_samples_leaf": 5,
    "random_state": 42,
}

with mlflow.start_run(run_name="rf-baseline"):
    # 1. Log parameters
    mlflow.log_params(params)
    mlflow.log_param("feature_set", "v3_with_tenure_buckets")

    # 2. Train (exactly as in Lessons 11-20 — nothing changes here)
    model = RandomForestClassifier(**params)
    model.fit(X_train, y_train)

    # 3. Evaluate and log metrics
    val_proba = model.predict_proba(X_test)[:, 1]
    val_pred  = model.predict(X_test)
    mlflow.log_metric("val_auc", roc_auc_score(y_test, val_proba))
    mlflow.log_metric("val_f1", f1_score(y_test, val_pred))
    mlflow.log_metric("val_accuracy", accuracy_score(y_test, val_pred))

    # 4. Log artifacts — any file at all
    feature_importance = pd.Series(
        model.feature_importances_, index=X_train.columns
    ).sort_values(ascending=False)
    feature_importance.to_csv("feature_importance.csv")
    mlflow.log_artifact("feature_importance.csv")

    # 5. Log the model itself in MLflow's standard format (Section 4)
    mlflow.sklearn.log_model(model, artifact_path="model")

    print(f"Run ID: {mlflow.active_run().info.run_id}")
Out[1]:
Run ID: 7e3f1a2b9c4d4e8f9a0b1c2d3e4f5a6b

Autologging: Zero-Effort Tracking

Manually calling log_param for every hyperparameter gets tedious and you will inevitably forget one. MLflow's autologging instruments the most common ML libraries so that calling .fit() automatically logs parameters, metrics, and the model artifact — no manual calls needed.

In [2]:
import mlflow
import mlflow.sklearn
from sklearn.ensemble import GradientBoostingClassifier

mlflow.set_experiment("churn-prediction")

# One line. Captures all constructor params, training metrics,
# the fitted model, and a model signature (input/output schema).
mlflow.sklearn.autolog()

with mlflow.start_run(run_name="gbm-autolog"):
    model = GradientBoostingClassifier(
        n_estimators=250, learning_rate=0.03, max_depth=4, random_state=42
    )
    model.fit(X_train, y_train)
    # No explicit log_metric calls needed — autolog captures
    # training score, and (if you call model.score / cross_val_score)
    # those too. You can still log additional custom metrics:
    test_auc = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1])
    mlflow.log_metric("test_auc", test_auc)
In [3]:
# Autologging also exists for deep learning frameworks
import mlflow.pytorch
import torch
import torch.nn as nn

mlflow.set_experiment("churn-prediction-dl")
mlflow.pytorch.autolog(log_models=True, log_every_n_epoch=1)

class ChurnNet(nn.Module):
    def __init__(self, n_features):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_features, 64), nn.ReLU(), nn.Dropout(0.3),
            nn.Linear(64, 16), nn.ReLU(),
            nn.Linear(16, 1),
        )
    def forward(self, x):
        return self.net(x)

with mlflow.start_run(run_name="churn-mlp"):
    model = ChurnNet(n_features=X_train.shape[1])
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.BCEWithLogitsLoss()

    for epoch in range(20):
        optimizer.zero_grad()
        logits = model(torch.tensor(X_train.values, dtype=torch.float32))
        loss = loss_fn(logits.squeeze(), torch.tensor(y_train.values, dtype=torch.float32))
        loss.backward()
        optimizer.step()
        # mlflow.pytorch.autolog logs the optimizer/loss-fn config automatically;
        # per-epoch metrics still need an explicit call inside a training loop
        mlflow.log_metric("train_loss", loss.item(), step=epoch)
⚠️
Autolog Covers the Common Case, Not Everything

mlflow.pytorch.autolog() logs the model architecture, optimizer hyperparameters, and a final model checkpoint, but it does not automatically wire into a custom training loop the way mlflow.sklearn.autolog() wires into .fit() — there is no universal "training step" hook in raw PyTorch. You still log per-epoch metrics explicitly with mlflow.log_metric(..., step=epoch). For PyTorch Lightning or Hugging Face Trainer, the built-in MLflow callbacks (MLFlowLogger, transformers.integrations.MLflowCallback) do capture per-step metrics automatically — prefer those when available.

3 Comparing Runs in the MLflow UI

Every run you log accumulates in the tracking store. Launch the UI from the same directory as your mlruns folder:

mlflow ui --port 5000
# Then open http://localhost:5000

The UI lets you select multiple runs and compare them side-by-side — sortable by any logged metric, with parallel-coordinates plots to see which hyperparameters correlate with better performance. This is the payoff for the logging discipline in Section 2: instead of scrolling through notebook output, you get a queryable table.

Illustrative MLflow UI — Runs Table for Experiment "churn-prediction"
Run Name n_estimators max_depth learning_rate val_auc val_f1 Duration ───────────────────────────────────────────────────────────────────────────────── gbm-autolog 250 4 0.03 0.913 0.742 18.2s rf-tuned-trial-47 400 10 — 0.901 0.731 9.7s rf-baseline 300 8 — 0.887 0.706 6.4s gbm-trial-12 150 6 0.10 0.879 0.698 11.1s rf-shallow 300 3 — 0.842 0.651 4.9s

Illustrative set of 18 GBM hyperparameter-sweep runs (not real training data) — each point is one run, positioned by learning rate (log scale) and validation F1, colored by max_depth. This mirrors the "sort and filter runs by metric and hyperparameter" workflow the MLflow UI's runs table and parallel-coordinates view support, just rendered as a scatter plot: F1 peaks in a mid-range learning-rate band regardless of tree depth, and falls off sharply once the learning rate gets too aggressive.

You can also query runs programmatically — useful for automated "pick the best model" steps in a pipeline:

In [4]:
from mlflow.tracking import MlflowClient

client = MlflowClient(tracking_uri="file:./mlruns")
experiment = client.get_experiment_by_name("churn-prediction")

# Search runs, ordered by validation AUC, descending
runs = client.search_runs(
    experiment_ids=[experiment.experiment_id],
    filter_string="metrics.val_auc > 0.88",
    order_by=["metrics.val_auc DESC"],
    max_results=5,
)

print(f"{'Run Name':20s} {'val_auc':>10s} {'val_f1':>10s}")
for run in runs:
    name = run.data.tags.get("mlflow.runName", run.info.run_id[:8])
    print(f"{name:20s} {run.data.metrics.get('val_auc', float('nan')):10.3f} "
          f"{run.data.metrics.get('val_f1', float('nan')):10.3f}")

best_run = runs[0]
print(f"\nBest run: {best_run.info.run_id} (val_auc={best_run.data.metrics['val_auc']:.3f})")
Out[4]:
Run Name val_auc val_f1 gbm-autolog 0.913 0.742 rf-tuned-trial-47 0.901 0.731 Best run: 7e3f1a2b9c4d4e8f9a0b1c2d3e4f5a6b (val_auc=0.913)
🔑
Tracking + Optuna: Logging Every Trial

This connects directly to Lesson 63. When you run an Optuna study, wrap the objective function's training step in an MLflow run so every trial — not just the winning one — is logged: with mlflow.start_run(nested=True): mlflow.log_params(trial.params); mlflow.log_metric("val_auc", score). Nesting runs under a parent run (nested=True) groups an entire Optuna study as one parent with N child runs in the UI, so you can inspect the full search, not just `study.best_params`.

4 MLflow Models: A Standard Packaging Format

A trained scikit-learn model, a PyTorch state_dict, and an XGBoost booster all have different save formats and loading APIs. MLflow Models solves this with a uniform packaging convention: every model logged via mlflow.<library>.log_model() is saved as a directory containing the serialized model plus an MLmodel metadata file describing its flavors — the different ways the model can be loaded and served.

In [5]:
import mlflow.sklearn

with mlflow.start_run(run_name="rf-for-deployment") as run:
    model = RandomForestClassifier(n_estimators=300, max_depth=8, random_state=42)
    model.fit(X_train, y_train)

    # Log with an explicit input/output signature — strongly recommended.
    # The signature documents (and validates) the expected schema at serve time.
    from mlflow.models import infer_signature
    signature = infer_signature(X_train, model.predict(X_train))

    mlflow.sklearn.log_model(
        sk_model=model,
        artifact_path="model",
        signature=signature,
        input_example=X_train.iloc[:5],
    )
    model_uri = f"runs:/{run.info.run_id}/model"
    print(f"Model logged at: {model_uri}")
Out[5]:
model/ ├── MLmodel # metadata: flavors, signature, dependencies ├── model.pkl # the serialized sklearn estimator ├── conda.yaml # conda environment for reproducing it ├── python_env.yaml # pip-based environment alternative ├── requirements.txt # pinned package versions └── input_example.json # sample input for quick sanity checks --- MLmodel file (excerpt) --- flavors: python_function: loader_module: mlflow.sklearn python_version: 3.10.13 sklearn: pickled_model: model.pkl sklearn_version: 1.4.0 signature: inputs: '[{"name": "tenure_months", "type": "long"}, ...]' outputs: '[{"type": "long"}]'

The "flavor" system is what makes the format universal: the sklearn flavor lets you load it back as a normal RandomForestClassifier, while the python_function ("pyfunc") flavor is a generic, library-agnostic interface that every MLflow model supports — meaning any serving tool that speaks "pyfunc" can serve a model regardless of whether it was trained with scikit-learn, PyTorch, XGBoost, or LightGBM.

In [6]:
import mlflow.pyfunc
import mlflow.sklearn

# Load back using the library-specific flavor (returns the raw estimator)
sk_model = mlflow.sklearn.load_model(model_uri)
print(type(sk_model))  # <class 'sklearn.ensemble._forest.RandomForestClassifier'>

# Or load using the generic pyfunc flavor (works identically for any model type)
pyfunc_model = mlflow.pyfunc.load_model(model_uri)
predictions = pyfunc_model.predict(X_test.iloc[:5])
print(predictions)

Because the packaged model already declares its environment, you can serve it as a local REST API with one command — no need to hand-write a Flask app just to test it:

# Spin up a local REST endpoint for the model — useful for smoke-testing
# before handing it off to the FastAPI/Docker deployment pipeline (Lesson 66)
mlflow models serve -m runs:/7e3f1a2b9c4d4e8f9a0b1c2d3e4f5a6b/model -p 1234 --env-manager local
# Query it like any REST API
curl -X POST http://127.0.0.1:1234/invocations \
  -H "Content-Type: application/json" \
  -d '{"dataframe_split": {"columns": ["tenure_months", "monthly_charges"], "data": [[14, 79.99]]}}'
Example Output
{"predictions": [0]}
💡
mlflow models serve Is for Smoke Tests, Not Production Scale

mlflow models serve spins up a single-process Flask/FastAPI server — great for local validation and for understanding the request/response schema before you build a real deployment. For production traffic you'll containerize the model (mlflow models build-docker) and deploy it behind a proper serving stack, which is exactly the subject of Lesson 66. Think of this command as the bridge between "I have a model" and "I have a deployable artifact."

Quick Check

5 MLflow Projects: Packaging Reproducible Runs

Logging a run tells you what happened. An MLflow Project tells you how to make it happen again — on a teammate's laptop, on a CI runner, or on a cloud training cluster — without them needing to reverse-engineer your environment from a README. A project is just a directory with an MLproject file declaring entry points, their parameters, and the environment to run them in.

# MLproject (no file extension — the filename itself is the convention)
name: churn-prediction

# Reproducible environment — conda.yaml or a Dockerfile both work
conda_env: conda.yaml

entry_points:
  main:
    parameters:
      n_estimators: {type: int, default: 300}
      max_depth: {type: int, default: 8}
      data_path: {type: str, default: "data/churn.csv"}
    command: "python train.py --n_estimators {n_estimators} --max_depth {max_depth} --data_path {data_path}"

  evaluate:
    parameters:
      model_uri: {type: str}
      test_data: {type: str, default: "data/churn_test.csv"}
    command: "python evaluate.py --model_uri {model_uri} --test_data {test_data}"
# conda.yaml — pinned environment for the "main" entry point
name: churn-prediction-env
channels:
  - conda-forge
dependencies:
  - python=3.10
  - pip
  - pip:
      - mlflow==2.13.0
      - scikit-learn==1.4.0
      - pandas==2.2.0

Anyone with MLflow installed can now run the exact same experiment — MLflow handles creating the conda environment and invoking the entry point command:

# Run locally from the project directory
mlflow run . -P n_estimators=500 -P max_depth=10

# Or run directly from a git repo, no manual cloning required —
# this is the "send a teammate one command" reproducibility story
mlflow run https://github.com/your-org/churn-prediction.git \
  -P n_estimators=500 --experiment-name churn-prediction
⚠️
Docker Environments Are Often the Better Default

Conda environments are simple to declare but can drift slightly between OSes (especially around compiled dependencies like XGBoost or PyTorch with CUDA). For anything destined for production, prefer docker_env in the MLproject file pointing at a pinned image: docker_env: {image: "myregistry/churn-train:1.4.0"}. This guarantees byte-identical environments across a teammate's M-series laptop, a CI runner, and a GPU training cluster — the same guarantee Docker gives you for serving (Lesson 66), just applied to the training step.

6 The Model Registry: Versioning and Promotion Workflows

Tracking answers "what did I try?" The Model Registry answers the question that matters once a model is good enough to matter to the business: "which exact version is live right now, and what's its history?" A registered model is a named, versioned entity, independent of any single run, that tracks a lineage of versions as they move through stages.

Registering a Model

In [7]:
import mlflow
from mlflow.tracking import MlflowClient

run_id = "7e3f1a2b9c4d4e8f9a0b1c2d3e4f5a6b"
model_uri = f"runs:/{run_id}/model"

# Registers a new version under the name "churn-classifier"
# (creates the registered model the first time it's called)
result = mlflow.register_model(model_uri=model_uri, name="churn-classifier")
print(f"Registered '{result.name}' as version {result.version}")
Out[7]:
Registered 'churn-classifier' as version 4

Stages: Staging → Production → Archived

Each version of a registered model carries a stage: None (just registered), Staging (under validation), Production (serving live traffic), or Archived (retired). Moving a version between stages is an explicit, logged action — this is the approval gate that gives you an audit trail.

None just registered Staging under validation (QA) transition_model_version_stage() Production serving live traffic Archived retired rollback: archive the bad Production version and re-promote a previously validated Staging version

The Model Registry's stage lifecycle: a version starts at None, moves to Staging for validation, then Production once approved to serve live traffic, and eventually Archived when replaced. If a Production version turns out to have a problem, the amber rollback path shows the practical fix — archive it and re-promote an earlier validated version — since MLflow has no single "undo" button for a bad promotion.

In [8]:
client = MlflowClient()

# Promote version 4 to Staging for QA validation
client.transition_model_version_stage(
    name="churn-classifier",
    version=4,
    stage="Staging",
    archive_existing_versions=False,
)

# ... QA team runs validation checks against the Staging model ...
# ... a human (or an automated gate) reviews metrics and signs off ...

# Promote to Production. archive_existing_versions=True automatically
# demotes whatever was previously in Production to Archived — there is
# always exactly one unambiguous "current production version."
client.transition_model_version_stage(
    name="churn-classifier",
    version=4,
    stage="Production",
    archive_existing_versions=True,
)

# Attach a human-readable note documenting the approval decision
client.update_model_version(
    name="churn-classifier",
    version=4,
    description=(
        "Approved for production 2026-07-01 by M. Alvarez (Lead DS). "
        "val_auc=0.913, beats prior production version (0.887) by 2.6pp. "
        "Fairness check (Lesson 64 SHAP audit) showed no proxy discrimination "
        "via tenure_months."
    ),
)

Loading the Production Model by Alias

Downstream consumers — a batch scoring job, the FastAPI service from Lesson 66 — never hardcode a run ID. They reference the registry by name and stage, so promoting a new version requires zero code changes downstream:

In [9]:
import mlflow.pyfunc

# Always resolves to whichever version currently holds the Production stage
production_model = mlflow.pyfunc.load_model("models:/churn-classifier/Production")
predictions = production_model.predict(new_customers_df)
Out[9]:
Version Stage Run AUC Registered Notes ────────────────────────────────────────────────────────────────────── 4 Production 0.913 2026-07-01 09:14 Approved by M. Alvarez 3 Archived 0.887 2026-05-12 14:02 Previous production 2 Archived 0.864 2026-03-30 11:47 Replaced — feature set v2 1 Archived 0.811 2026-02-01 08:55 Initial baseline 5 Staging 0.918 2026-06-29 16:30 Pending fairness review
🌍
Aliases Are Replacing Stages in Newer MLflow Versions

MLflow 2.9+ introduced model aliases (e.g. champion, challenger) and tags as a more flexible alternative to the fixed Staging/Production/Archived stages, which are now considered legacy (though still widely used and fully supported). Aliases let you run more sophisticated workflows — e.g. a champion alias for the live model and a challenger alias for a candidate being A/B tested (Lesson 69) — without being boxed into exactly three named stages. The underlying concept this lesson teaches — versioned models with an explicit, logged promotion workflow — is identical either way; only the labeling mechanism differs.

7 Centralized Tracking Server vs. Local File Store

Everything so far has used mlflow.set_tracking_uri("file:./mlruns") — the default, which writes run metadata as files and folders directly on your machine. That's fine for solo experimentation, but it breaks down the moment a team needs to share results: there's no central place to compare a colleague's runs against yours, and the Model Registry doesn't function at all in pure local-file mode (versions and stage transitions need a real database to coordinate concurrent writers safely).

Training Script (the client) log_param · log_metric · log_artifact Tracking Server REST API (mlflow server) Backend Store PostgreSQL / MySQL params · metrics · tags Artifact Store S3 / GCS / Azure Blob model files · plots · datasets Model Registry versioned models + stage None → Staging → Production → Archived models:/<name>/Production

The MLflow architecture behind a centralized deployment: a training script logs to the Tracking Server's REST API, which persists small structured data (params, metrics, tags) to a Backend Store database and large binary files (models, plots) to a separate Artifact Store — the Model Registry then layers versioned models and promotion stages on top of both, so downstream code can always resolve models:/<name>/Production to the current approved version.

Local File Store (Default)

In [10]:
import mlflow
# Metadata AND artifacts both land in ./mlruns on local disk.
# Fine for solo work; breaks down with concurrent writers or remote teammates.
mlflow.set_tracking_uri("file:./mlruns")

Centralized Tracking Server

A production MLflow setup separates three concerns, each backed by infrastructure suited to it:

Concern Typical backend Why
Backend store (params, metrics, registry) PostgreSQL / MySQL Concurrent writes, queryable, supports the Model Registry
Artifact store (models, plots, datasets) Amazon S3 / GCS / Azure Blob Cheap, durable, large-object storage; shared across machines
Tracking server A small always-on service (EC2, ECS, Kubernetes pod) Single HTTP endpoint the whole team and CI talk to
# Launching a centralized MLflow tracking server
# (run this once, on a small persistent host/container — not on every laptop)
mlflow server \
  --backend-store-uri postgresql://mlflow_user:secret@db.internal:5432/mlflow_db \
  --default-artifact-root s3://my-company-mlflow-artifacts/ \
  --host 0.0.0.0 \
  --port 5000
In [11]:
# Every data scientist's laptop / training script now points at the
# shared server instead of a local folder — one line changes:
import mlflow

mlflow.set_tracking_uri("http://mlflow.internal.company.com:5000")
mlflow.set_experiment("churn-prediction")

# Everything else — log_param, log_metric, log_model, register_model —
# is IDENTICAL code to the local-file examples above. The tracking URI
# is the only thing that changes between solo and team mode.
with mlflow.start_run(run_name="rf-from-shared-server"):
    mlflow.log_params({"n_estimators": 300, "max_depth": 8})
    # ... train and log as before ...
🔑
Why Artifacts and Metadata Live Separately

Run parameters and metrics are small, structured, and need to support fast filtering/sorting queries ("show me all runs with val_auc > 0.9") — a relational database is the right tool. Model files, plots, and datasets can be large binary blobs (a serialized PyTorch model can be gigabytes) — object storage (S3) is built for exactly that, cheaply and durably, and most teams already have it for other purposes. The tracking server is the thin coordination layer in between: clients talk HTTP to the server, the server writes structured data to Postgres and hands back pre-signed URLs (or proxies the bytes) for artifact reads/writes to S3.

💡
Managed Alternatives

Running and patching your own Postgres + S3 + tracking-server stack is real operational overhead. Databricks (MLflow's original authors) offers Managed MLflow built into its platform. AWS SageMaker has its own native experiment tracking with an MLflow-compatible API. Azure ML and Google Vertex AI offer similar managed tracking. For a small team, self-hosting MLflow on a single small EC2 instance with an RDS Postgres backend is a perfectly reasonable, low-cost starting point — you can always migrate to a managed option later, since the client-side code (mlflow.log_param, etc.) barely changes.

🌍

Real-World Spotlight: Team Collaboration and Production Promotion Gates

Case 1: A 5-Person Team Tracking 200+ Churn Model Experiments

A data science team of five is independently iterating on a customer churn model: different feature sets, different algorithms (logistic regression, random forest, XGBoost, a small MLP), different class-imbalance strategies. Without a shared system, this would fragment into five private notebooks with no way to know who has the best result. Instead, the team agrees on one convention: every run goes to the shared MLflow server under experiment "churn-prediction", tagged with the author's name and the feature-set version.

In [12]:
import mlflow
import getpass

mlflow.set_tracking_uri("http://mlflow.internal.company.com:5000")
mlflow.set_experiment("churn-prediction")

with mlflow.start_run(run_name=f"xgb-{getpass.getuser()}-trial23"):
    mlflow.set_tags({
        "author": getpass.getuser(),
        "feature_set_version": "v4_with_support_ticket_features",
        "team": "retention-ds",
    })
    mlflow.log_params({"max_depth": 6, "n_estimators": 400, "learning_rate": 0.02})
    # ... train xgboost model ...
    mlflow.log_metric("val_auc", 0.921)
    mlflow.xgboost.log_model(model, "model")

After several weeks, the team has 200+ logged runs across all five contributors. The team lead queries across everyone's work in one place to find the global best, regardless of who ran it:

In [13]:
from mlflow.tracking import MlflowClient

client = MlflowClient(tracking_uri="http://mlflow.internal.company.com:5000")
exp = client.get_experiment_by_name("churn-prediction")

top_runs = client.search_runs(
    experiment_ids=[exp.experiment_id],
    order_by=["metrics.val_auc DESC"],
    max_results=3,
)
for r in top_runs:
    tags = r.data.tags
    print(f"{tags.get('author', '?'):10s} | auc={r.data.metrics['val_auc']:.3f} "
          f"| features={tags.get('feature_set_version', '?')}")
Out[13]:
priya | auc=0.921 | features=v4_with_support_ticket_features devon | auc=0.917 | features=v4_with_support_ticket_features mateo | auc=0.908 | features=v3_with_tenure_buckets

Without the shared server, this query — "across everything anyone on the team has tried, what's the best result and who found it?" — would require manually pinging four colleagues. With it, it's one method call.

Case 2: Staging → Production Approval Gate for a Fraud Model

A fraud detection model has real financial and customer-experience consequences if a bad version goes live — blocking legitimate transactions is as costly as missing fraud. The team enforces a hard promotion gate: nothing reaches Production without passing automated checks and a human sign-off, both recorded against the registry.

In [14]:
from mlflow.tracking import MlflowClient
import mlflow

client = MlflowClient()

def promote_to_staging(model_name, run_id, candidate_metrics, min_auc=0.95, max_fpr=0.02):
    """Automated gate: only reaches Staging if it clears minimum bars."""
    if candidate_metrics["val_auc"] < min_auc:
        raise ValueError(f"AUC {candidate_metrics['val_auc']:.3f} below gate ({min_auc})")
    if candidate_metrics["false_positive_rate"] > max_fpr:
        raise ValueError(f"FPR {candidate_metrics['false_positive_rate']:.3f} exceeds gate ({max_fpr})")

    result = mlflow.register_model(f"runs:/{run_id}/model", model_name)
    client.transition_model_version_stage(model_name, result.version, "Staging")
    print(f"v{result.version} cleared automated gate -> Staging for human review")
    return result.version


def approve_for_production(model_name, version, approver, justification):
    """Human approval gate: explicit, named, and logged — required for fraud models."""
    current_prod = client.get_latest_versions(model_name, stages=["Production"])
    if current_prod:
        print(f"Archiving current production v{current_prod[0].version}")

    client.transition_model_version_stage(
        name=model_name, version=version, stage="Production",
        archive_existing_versions=True,
    )
    client.update_model_version(
        name=model_name, version=version,
        description=f"Approved by {approver} on 2026-07-01. Justification: {justification}",
    )
    client.set_model_version_tag(model_name, version, "approved_by", approver)
    print(f"v{version} promoted to Production by {approver}")


# Automated gate runs in CI right after training
version = promote_to_staging(
    "fraud-detector", run_id="9f8e7d6c5b4a3210",
    candidate_metrics={"val_auc": 0.962, "false_positive_rate": 0.014},
)

# Human approval happens separately, e.g. triggered from a review dashboard,
# only after a risk officer has inspected the Staging model's SHAP explanations
# (Lesson 64) for any concerning proxy features.
approve_for_production(
    "fraud-detector", version,
    approver="r.chen@company.com",
    justification="Beats current prod by 1.8pp AUC; SHAP audit clean; "
                   "shadow-mode test on 50k live transactions showed no FPR regression.",
)
🌍
Why the Audit Trail Matters Here

Six months later, an incident review asks why the fraud model flagged a particular high-value customer's legitimate transaction as fraud. The registry's history answers, precisely: which model version was live on that date, what its validation metrics were, who approved it and why, and which run (with its exact code commit and training data snapshot) produced it. Reconstructing this from scattered notebooks and Slack messages — the pre-MLflow norm — can take days; with the registry, it's a lookup.

✍️ Practice Exercises

  1. Take a model you trained in an earlier lesson (e.g. the GridSearchCV sweep from Lesson 27, or an Optuna study from Lesson 63). Re-run the search, but wrap each trial in with mlflow.start_run(nested=True) and log the trial's parameters and validation metric. Open the MLflow UI and confirm you can see every trial as a child run under one parent, sortable by your validation metric.
  2. Enable mlflow.sklearn.autolog() and train three different classifiers (logistic regression, random forest, gradient boosting) on the same dataset inside three separate runs. Inspect the MLmodel file MLflow generates for each and identify the differences in their logged "flavors" and dependency files.
  3. Write an MLproject file with two entry points — train and evaluate — for a dataset of your choice. Confirm you can run both with mlflow run . -e train ... and mlflow run . -e evaluate ..., passing different parameters each time.
  4. Register a model under a name of your choice, transition it through Staging and then Production using MlflowClient, and write a small script that loads models:/<name>/Production and scores a few new examples. Then train an improved version, register it as version 2, promote it to Production, and verify (via client.get_latest_versions) that version 1 was automatically archived and version 2 is now what gets loaded — without changing a single line of your scoring script.
▶ Show Solution (Exercise 1 — Logging Every Optuna Trial as a Nested MLflow Run)
In [15]:
import mlflow
import optuna
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score

mlflow.set_tracking_uri("file:./mlruns")
mlflow.set_experiment("churn-prediction-optuna")

def objective(trial, X_train, y_train):
    params = {
        "n_estimators": trial.suggest_int("n_estimators", 100, 600, step=50),
        "max_depth": trial.suggest_int("max_depth", 3, 15),
        "min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 10),
    }

    # Each trial gets logged as its own child run, nested under the
    # parent run created for the whole study below.
    with mlflow.start_run(nested=True, run_name=f"trial-{trial.number}"):
        mlflow.log_params(params)

        model = RandomForestClassifier(**params, random_state=42)
        scores = cross_val_score(model, X_train, y_train, cv=5, scoring="roc_auc")
        mean_auc = scores.mean()

        mlflow.log_metric("cv_auc_mean", mean_auc)
        mlflow.log_metric("cv_auc_std", scores.std())

    return mean_auc


# The parent run represents the entire study; every trial above
# becomes a child run visible underneath it in the MLflow UI.
with mlflow.start_run(run_name="optuna-study-rf"):
    study = optuna.create_study(direction="maximize")
    study.optimize(lambda t: objective(t, X_train, y_train), n_trials=40)

    mlflow.log_params({f"best_{k}": v for k, v in study.best_params.items()})
    mlflow.log_metric("best_cv_auc", study.best_value)

    print(f"Best trial: #{study.best_trial.number}")
    print(f"Best params: {study.best_params}")
    print(f"Best CV AUC: {study.best_value:.4f}")
    print("Open `mlflow ui` and look under the parent run to see all 40 child runs,")
    print("sortable by cv_auc_mean — exactly the visibility a spreadsheet never gives you.")

📚 Primary Source for This Lesson

MLflow Official Documentation
The authoritative reference for Tracking, Models, Projects, and the Model Registry — every API used in this lesson is documented there with runnable examples, including the tracking-server deployment patterns from Section 7.

💬 Unsure whether to use the local file store or a tracking server, or how to structure runs vs. experiments for your team? Your AI tutor can help you design an MLflow setup that fits your workflow.