Stage 6 · Ship It

45 min readintermediateView source on GitHub ↗

MLOps Basics

Why this matters

Lesson 17 took one project from a raw question to a written-up result: a baseline, a few compared models, a chosen metric, a final number. That pipeline is real, but it’s also a single run, on a laptop, that lives in your head and a notebook. The moment you try more than one variant seriously — a different class_weight, a different model, a reworked feature — and want to know which run actually produced which number, “I remember it was the second one” stops being good enough. And the moment a model like that goes anywhere near production, a second problem appears that lesson 17 never had to face: the world it was trained on keeps moving after training stops. This lesson is the layer between “a model that worked once” and “a model you can trust, rerun, and know when to retrain” — the practices that turn Stage 3’s one-off pipeline into something an actual team could run for a year.

The concept

Experiment tracking is the direct fix for “which run was that.” Every time you train a variant, record what went in (hyperparameters, which features, which data) and what came out (metrics) as a durable record, not a scrollback buffer. The minimum viable version is a line of JSON appended to a file per run — exactly what “In code” builds below. Once a team outgrows that, dedicated tools (MLflow, Weights & Biases) do the same job with a UI, artifact storage, and richer comparison — same idea, more infrastructure. Either way, the point is identical: never trust memory over a written record when comparing runs.

Experiment tracking is not a model registry, and neither is monitoring — three different jobs. Tracking records every run you ever try, including the ones that were worse, because “worse” is only knowable by comparison. A model registry is a much smaller, curated list: the handful of models that graduated from “an experiment” to “a versioned candidate for deployment,” each with a version number and a status like staging or production. Monitoring is different again — it’s not about training runs at all, it’s about watching a model that’s already deployed to see whether it’s still behaving the way it did when it was evaluated. Confusing these three is a common real mistake: logging every experiment nowhere, or treating your only copy of a deployed model as also being its full experiment history, or assuming that because a model passed evaluation once, it needs no further watching. They’re three separate, complementary layers, and a real ML system needs all three.

Model and data versioning. A trained model is a function of three things: the code, the hyperparameters, and the data it was fit on. Change any one and you get a different model, even if you don’t notice immediately. Code versioning is git, already familiar. Hyperparameters are what experiment tracking already logs. Data is the one teams skip most often — retraining “the same script” against a table that has since had rows added, fixed, or deleted silently produces a different model, with no error and no warning. The fix is to pin what a given run actually trained on: a copy, a hash (hashlib.sha256 over the file is the DIY version), or a dedicated tool like DVC, which versions large data/model files alongside git commits the same way git versions code.

Reproducibility is the property that lets you rerun a logged experiment and get the same answer back — and it doesn’t happen for free. Three concrete sources of drift between “I ran this Tuesday” and “I ran this again Thursday”: an unseeded random number generator (every random_state= in this entire roadmap exists to prevent exactly this); an unpinned dependency (a library’s default behavior changes between versions, silently, unless requirements.txt or a lockfile pins the exact version, not just the name); and simply not knowing which code version produced a result (a git commit SHA logged alongside the run answers “which version of my code was this,” the same way experiment tracking answers “which hyperparameters was this”).

Monitoring for drift. A deployed model doesn’t crash when the world changes around it — it just keeps returning confident predictions that quietly get worse, because nothing about how it produces output changes when its assumptions stop holding. Data drift (also called covariate shift) is when the distribution of the input features your model sees in production shifts away from what it was trained on — a pricing change that pushes everyone’s monthly_charges up, a new signup channel that brings in a different mix of customers. Concept drift is subtler: the input distribution can look the same while the relationship between features and label changes underneath it — the same tenure_months and contract_type used to predict low churn risk, but after a competitor’s price cut, they don’t anymore. Both are silent by default. Statistical tests over feature distributions (the Kolmogorov-Smirnov test used below is one of the simplest) catch data drift automatically, without needing ground-truth labels. Concept drift is harder to catch this way, since the inputs alone don’t reveal it — the most direct check is comparing predictions to real outcomes once they’re known, which is often the entire reason a monitoring system needs to wait and re-check later rather than judging a model once at deployment and considering it done.

In code

Part 1 — a lightweight experiment logger, run against three real model variants. No dependency beyond the standard library’s json — this is the idea MLflow and W&B build on, in its simplest possible form: append one fact per run to a file.

import json
import time
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.preprocessing import StandardScaler

LOG_PATH = Path("experiments.jsonl")
if LOG_PATH.exists():
    LOG_PATH.unlink()


def log_run(run_name, params, metrics):
    """Append one experiment run as a line of JSON: easy to append to,
    easy to grep, easy to load back with pandas -- the whole idea of
    experiment tracking, minus the UI."""
    record = {
        "run_name": run_name,
        "logged_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "params": params,
        "metrics": metrics,
    }
    with LOG_PATH.open("a") as f:
        f.write(json.dumps(record) + "\n")
    return record


# Same shape of churn problem as lesson 17, regenerated here so this
# lesson is self-contained -- the exercise has you point this same
# logger at your actual lesson 17 pipeline instead.
rng = np.random.default_rng(42)
n = 400
contract_type = rng.choice(["Month-to-month", "One year", "Two year"], size=n, p=[0.55, 0.25, 0.20])
tenure_months = np.clip(rng.exponential(24, n), 0, 72).round(0)
monthly_charges = np.clip(rng.normal(70, 25, n), 20, 150).round(2)
contract_risk = np.select(
    [contract_type == "Month-to-month", contract_type == "One year", contract_type == "Two year"],
    [0.9, 0.0, -0.5])
churn_logit = -1.3 + contract_risk - 0.02 * tenure_months + 0.006 * (monthly_charges - 70) + rng.normal(0, 0.3, n)
churned = (rng.random(n) < 1 / (1 + np.exp(-churn_logit))).astype(int)

df = pd.DataFrame({
    "tenure_months": tenure_months, "contract_type": contract_type,
    "monthly_charges": monthly_charges, "churned": churned,
})
feature_df = pd.get_dummies(df.drop(columns=["churned"]), columns=["contract_type"], drop_first=True)
X = feature_df.values.astype(float)
y = df["churned"].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
scaler = StandardScaler().fit(X_train)
X_train_scaled, X_test_scaled = scaler.transform(X_train), scaler.transform(X_test)

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# Three candidate runs -- each one gets its params and metrics logged
# instead of just printed and forgotten the moment the next cell runs.
runs = [
    ("logreg_default", LogisticRegression(max_iter=2000), X_train_scaled,
     {"model": "LogisticRegression", "class_weight": "none", "seed": 42}),
    ("logreg_balanced", LogisticRegression(max_iter=2000, class_weight="balanced"), X_train_scaled,
     {"model": "LogisticRegression", "class_weight": "balanced", "seed": 42}),
    ("random_forest", RandomForestClassifier(n_estimators=300, random_state=42), X_train,
     {"model": "RandomForestClassifier", "n_estimators": 300, "seed": 42}),
]

for run_name, model, X_fit, params in runs:
    acc = cross_val_score(model, X_fit, y_train, cv=cv, scoring="accuracy").mean()
    auc = cross_val_score(model, X_fit, y_train, cv=cv, scoring="roc_auc").mean()
    metrics = {"cv_accuracy": round(float(acc), 4), "cv_roc_auc": round(float(auc), 4)}
    log_run(run_name, params, metrics)
    print(f"logged {run_name}: {metrics}")

print(f"\n--- {LOG_PATH} now contains ---")
print(LOG_PATH.read_text())
logged logreg_default: {'cv_accuracy': 0.7812, 'cv_roc_auc': 0.6678}
logged logreg_balanced: {'cv_accuracy': 0.6312, 'cv_roc_auc': 0.6643}
logged random_forest: {'cv_accuracy': 0.7031, 'cv_roc_auc': 0.4724}

--- experiments.jsonl now contains ---
{"run_name": "logreg_default", "logged_at": "2026-07-25T14:14:29", "params": {"model": "LogisticRegression", "class_weight": "none", "seed": 42}, "metrics": {"cv_accuracy": 0.7812, "cv_roc_auc": 0.6678}}
{"run_name": "logreg_balanced", "logged_at": "2026-07-25T14:14:29", "params": {"model": "LogisticRegression", "class_weight": "balanced", "seed": 42}, "metrics": {"cv_accuracy": 0.6312, "cv_roc_auc": 0.6643}}
{"run_name": "random_forest", "logged_at": "2026-07-25T14:14:30", "params": {"model": "RandomForestClassifier", "n_estimators": 300, "seed": 42}, "metrics": {"cv_accuracy": 0.7031, "cv_roc_auc": 0.4724}}

That’s the entire mechanism. cv_roc_auc differs meaningfully between the three runs, and now that difference is a permanent, timestamped fact on disk instead of something you’d have to rerun everything to recover.

Part 2 — loading the log back and comparing runs, continuing right after Part 1 in the same session:

records = [json.loads(line) for line in LOG_PATH.read_text().splitlines()]
rows = [{"run_name": r["run_name"], **r["params"], **r["metrics"]} for r in records]
runs_df = pd.DataFrame(rows).sort_values("cv_roc_auc", ascending=False)
print(runs_df[["run_name", "model", "cv_accuracy", "cv_roc_auc"]].to_string(index=False))
print(f"\nBest run by cv_roc_auc: {runs_df.iloc[0]['run_name']}")
       run_name                  model  cv_accuracy  cv_roc_auc
 logreg_default     LogisticRegression       0.7812      0.6678
logreg_balanced     LogisticRegression       0.6312      0.6643
  random_forest RandomForestClassifier       0.7031      0.4724

Best run by cv_roc_auc: logreg_default

This is the entire point of tracking made concrete: random_forest looks respectable on accuracy alone (70.3%) but has the worst cv_roc_auc of the three (0.4724 — essentially uninformative, even slightly below the 0.5 random baseline from lesson 13, i.e. worse than a coin flip) — exactly the accuracy-can-mislead trap from lesson 13, now caught by a comparison table instead of a single glance at one run’s printout.

Part 3 — reproducibility, demonstrated rather than asserted:

rep_rng = np.random.default_rng(0)
Xr = rep_rng.normal(size=(300, 4))
yr = (Xr[:, 0] + 0.5 * Xr[:, 1] + rep_rng.normal(scale=1.5, size=300) > 0).astype(int)
Xr_train, Xr_test, yr_train, yr_test = train_test_split(Xr, yr, test_size=0.2, random_state=1)


def fit_and_summarize(random_state):
    m = RandomForestClassifier(n_estimators=50, random_state=random_state)
    m.fit(Xr_train, yr_train)
    return m.score(Xr_test, yr_test), m.feature_importances_.round(4).tolist()


print("Same seed (random_state=42), run A vs run B:")
acc_a, imp_a = fit_and_summarize(random_state=42)
acc_b, imp_b = fit_and_summarize(random_state=42)
print(f"  run A: accuracy={acc_a:.4f}  feature_importances={imp_a}")
print(f"  run B: accuracy={acc_b:.4f}  feature_importances={imp_b}")
print(f"  identical: {acc_a == acc_b and imp_a == imp_b}")

print("\nNo fixed seed (random_state=None), run C vs run D:")
acc_c, imp_c = fit_and_summarize(random_state=None)
acc_d, imp_d = fit_and_summarize(random_state=None)
print(f"  run C: accuracy={acc_c:.4f}  feature_importances={imp_c}")
print(f"  run D: accuracy={acc_d:.4f}  feature_importances={imp_d}")
print(f"  identical: {acc_c == acc_d and imp_c == imp_d}")
Same seed (random_state=42), run A vs run B:
  run A: accuracy=0.7000  feature_importances=[0.3527, 0.2217, 0.1985, 0.2271]
  run B: accuracy=0.7000  feature_importances=[0.3527, 0.2217, 0.1985, 0.2271]
  identical: True

No fixed seed (random_state=None), run C vs run D:
  run C: accuracy=0.7000  feature_importances=[0.365, 0.2215, 0.1888, 0.2247]
  run D: accuracy=0.7000  feature_importances=[0.353, 0.2214, 0.1877, 0.2379]
  identical: False

Same code, same data, two different outcomes depending on one argument. random_state=42 gives byte-for-byte identical feature importances on both runs; random_state=None gives two runs that happen to land on the same rounded accuracy but genuinely different importances underneath — a believable trap if you only glance at the headline metric. This is exactly what “pin your seeds” in The concept is protecting you from.

Part 4 — a real drift check with scipy.stats.ks_2samp, the two-sample Kolmogorov-Smirnov test: are two samples plausibly drawn from the same distribution?

from scipy.stats import ks_2samp

drift_rng = np.random.default_rng(7)
training_charges = drift_rng.normal(loc=70, scale=25, size=1000)

# "today's" traffic, still genuinely the same distribution -- no drift
fresh_batch = drift_rng.normal(loc=70, scale=25, size=300)

# a later batch where the underlying population actually shifted --
# e.g. a pricing change pushed everyone's plan up
drifted_batch = drift_rng.normal(loc=95, scale=25, size=300)


def check_drift(reference, current, feature_name, alpha=0.05):
    statistic, p_value = ks_2samp(reference, current)
    drifted = p_value < alpha
    verdict = "DRIFT DETECTED" if drifted else "no drift"
    print(f"{feature_name}: KS statistic={statistic:.4f}  p-value={p_value:.3e}  -> {verdict}")
    return drifted


print(f"training mean={training_charges.mean():.2f}  fresh mean={fresh_batch.mean():.2f}  drifted mean={drifted_batch.mean():.2f}\n")
check_drift(training_charges, fresh_batch, "monthly_charges (fresh batch)")
check_drift(training_charges, drifted_batch, "monthly_charges (drifted batch)")
training mean=68.19  fresh mean=69.58  drifted mean=95.80

monthly_charges (fresh batch): KS statistic=0.0480  p-value=6.449e-01  -> no drift
monthly_charges (drifted batch): KS statistic=0.4173  p-value=1.265e-36  -> DRIFT DETECTED

A p-value of 0.64 says the fresh batch is entirely consistent with random sampling noise around the training distribution — correctly, since it was drawn from the identical distribution. A p-value of 1.3 × 10⁻³⁶ says the drifted batch essentially could not have come from that same distribution — correctly again, since its mean is a full standard deviation higher. This is exactly the kind of check a monitoring job runs on a schedule against fresh production data, with no labels required, catching data drift before anyone notices the model quietly getting worse.

Build this

Add experiment tracking to your lesson 17 project. Using the log_run pattern above, log every model variant you compared there (at minimum the baseline and the two-plus real models) with its hyperparameters and cross-validated metrics into a JSON-lines file. Then write a short script that loads the log back with pandas and prints a comparison table sorted by your chosen metric, the way Part 2 does — confirm it correctly identifies the model you actually picked in lesson 17’s write-up.

Stretch: add a real drift check to the same project. Using ks_2samp from Part 4, compare the distribution of one numeric feature (e.g. monthly_charges) between your train and test splits — it should report no drift, since it’s a random split of the same data. Then build one artificially drifted batch (shift the mean, or resample from a different subgroup) and confirm your check correctly flags it. Write down both p-values.

Go deeper

  • MLflow Documentation — the most widely used open-source experiment tracking and model registry tool; a natural next step past the JSON-lines logger above.
  • DVC Documentation — Data Version Control, for versioning datasets and model artifacts alongside git commits.
  • Weights & Biases Documentation — a hosted alternative to MLflow with a strong comparison-dashboard UI.
  • Google: Rules of Machine Learning — battle-tested, practical engineering rules for production ML, including several on monitoring and gradual rollout.
  • Chip Huyen: MLOps Guide — a free, structured path through MLOps topics at increasing depth, from someone who has written extensively on designing ML systems.

Next: Deploying a Model