MLOps · lesson 2 of 8
Introduction to MLOps
Understand the ML lifecycle, set up experiment tracking with MLflow, and build your first reproducible ML pipeline.
By SkillByExample · Updated
The ML Lifecycle Problem
A data scientist trains a model in a notebook, emails the .pkl file to the engineering team, and deploys it. Six months later:
- The model accuracy has degraded but nobody knows
- Nobody can reproduce the training because the exact Python versions and data snapshot aren’t recorded
- A new team member wants to improve it but can’t tell which of the 40 experiments was the one that shipped
- The input data distribution has shifted but there are no alerts
MLOps solves all of these with tooling and process.
The ML Lifecycle
Data Collection → Feature Engineering → Experiment (train/evaluate)
↓ ↓
Data Versioning Experiment Tracking
↓ ↓
Feature Store Model Registry
↓
CI/CD for ML
↓
Model Serving
↓
Monitoring & Drift Detection
↓
Retraining Pipeline
Setting Up MLflow
pip install mlflow scikit-learn pandas
mlflow ui # start the tracking UI at http://localhost:5000
Experiment Tracking
import mlflow
import mlflow.sklearn
import numpy as np
import pandas as pd
from sklearn.datasets import load_wine
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score
from sklearn.preprocessing import StandardScaler
# Set the tracking server (default: local ./mlruns directory)
mlflow.set_tracking_uri("http://localhost:5000") # or leave empty for local
mlflow.set_experiment("wine-classification")
X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# Experiment 1: Random Forest
with mlflow.start_run(run_name="random-forest-baseline"):
# Log parameters — hyperparameters used for this run
n_estimators = 200
max_depth = 10
mlflow.log_param("n_estimators", n_estimators)
mlflow.log_param("max_depth", max_depth)
mlflow.log_param("model_type", "RandomForest")
# Train
model = RandomForestClassifier(n_estimators=n_estimators, max_depth=max_depth, random_state=42)
model.fit(X_train_s, y_train)
# Evaluate
y_pred = model.predict(X_test_s)
y_prob = model.predict_proba(X_test_s)
accuracy = accuracy_score(y_test, y_pred)
f1 = f1_score(y_test, y_pred, average="macro")
cv_score = cross_val_score(model, X_train_s, y_train, cv=5).mean()
# Log metrics
mlflow.log_metric("accuracy", accuracy)
mlflow.log_metric("f1_macro", f1)
mlflow.log_metric("cv_accuracy", cv_score)
# Log the model — stores it as an artifact with schema
mlflow.sklearn.log_model(model, artifact_path="model",
registered_model_name="wine-classifier")
print(f"RF Accuracy: {accuracy:.4f}, F1: {f1:.4f}")
Comparing Experiments Programmatically
import mlflow
import pandas as pd
mlflow.set_tracking_uri("http://localhost:5000")
# Fetch all runs from an experiment
experiment = mlflow.get_experiment_by_name("wine-classification")
runs = mlflow.search_runs(
experiment_ids=[experiment.experiment_id],
order_by=["metrics.accuracy DESC"],
)
# runs is a DataFrame — easy to filter and compare
summary = runs[["run_id", "params.model_type", "params.n_estimators",
"metrics.accuracy", "metrics.f1_macro", "metrics.cv_accuracy"]].head(10)
print(summary.to_string(index=False))
# Get the best run
best_run = runs.iloc[0]
print(f"\nBest model: {best_run['params.model_type']}")
print(f"Accuracy: {best_run['metrics.accuracy']:.4f}")
print(f"Run ID: {best_run['run_id']}")
Model Registry
import mlflow
from mlflow.tracking import MlflowClient
client = MlflowClient()
# Register a model from a run
run_id = "your-run-id-here"
model_uri = f"runs:/{run_id}/model"
registered = mlflow.register_model(model_uri, "wine-classifier")
print(f"Model version: {registered.version}")
# Transition through stages: None → Staging → Production → Archived
client.transition_model_version_stage(
name="wine-classifier",
version=registered.version,
stage="Staging",
archive_existing_versions=False,
)
# Load the production model anywhere in your codebase
model = mlflow.sklearn.load_model("models:/wine-classifier/Production")
predictions = model.predict(X_test_s)
Reproducible Training Script
A production training script — not a notebook — is the foundation of MLOps.
import argparse
import logging
import mlflow
import mlflow.sklearn
import numpy as np
from sklearn.datasets import load_wine
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import accuracy_score, classification_report
from sklearn.preprocessing import StandardScaler
import joblib
import json
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def parse_args():
parser = argparse.ArgumentParser(description="Train wine classifier")
parser.add_argument("--n-estimators", type=int, default=200)
parser.add_argument("--learning-rate", type=float, default=0.05)
parser.add_argument("--max-depth", type=int, default=4)
parser.add_argument("--test-size", type=float, default=0.2)
parser.add_argument("--random-state", type=int, default=42)
return parser.parse_args()
def train(args):
mlflow.set_experiment("wine-classification")
with mlflow.start_run():
# Log all params
mlflow.log_params(vars(args))
# Load and split data
X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=args.test_size, random_state=args.random_state, stratify=y
)
logger.info(f"Train: {X_train.shape}, Test: {X_test.shape}")
# Preprocessing
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# Train
model = GradientBoostingClassifier(
n_estimators=args.n_estimators,
learning_rate=args.learning_rate,
max_depth=args.max_depth,
random_state=args.random_state,
)
model.fit(X_train_s, y_train)
# Evaluate
cv_scores = cross_val_score(model, X_train_s, y_train, cv=5)
y_pred = model.predict(X_test_s)
accuracy = accuracy_score(y_test, y_pred)
mlflow.log_metric("accuracy", accuracy)
mlflow.log_metric("cv_mean", cv_scores.mean())
mlflow.log_metric("cv_std", cv_scores.std())
# Log artifacts
report = classification_report(y_test, y_pred, output_dict=True)
mlflow.log_dict(report, "classification_report.json")
# Log model + preprocessor together
mlflow.sklearn.log_model(model, "model")
joblib.dump(scaler, "/tmp/scaler.pkl")
mlflow.log_artifact("/tmp/scaler.pkl", "preprocessing")
logger.info(f"Accuracy: {accuracy:.4f}, CV: {cv_scores.mean():.4f}")
if __name__ == "__main__":
train(parse_args())
Run it with different hyperparameters from the command line:
python train.py --n-estimators 100 --learning-rate 0.1 --max-depth 3
python train.py --n-estimators 500 --learning-rate 0.01 --max-depth 6
Practice
Run these in one empty directory, in order. They don’t set a tracking URI, so MLflow writes to a
local mlflow.db file there and no server is needed. Outputs are from MLflow 3.17.
- The experiment-tracking example logs one Random Forest run. Log three candidates into the
wine-classificationexperiment (Random Forest, Gradient Boosting and Logistic Regression), recordingmodel_type,accuracyandf1_macro. Then rank them withmlflow.search_runs.
Solution
import mlflow
from sklearn.datasets import load_wine
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, f1_score
from sklearn.preprocessing import StandardScaler
mlflow.set_experiment("wine-classification") # no tracking URI: local mlflow.db
X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
scaler = StandardScaler()
X_train_s, X_test_s = scaler.fit_transform(X_train), scaler.transform(X_test)
candidates = {
"RandomForest": RandomForestClassifier(n_estimators=200, max_depth=10, random_state=42),
"GradientBoosting": GradientBoostingClassifier(n_estimators=200, random_state=42),
"LogisticRegression": LogisticRegression(max_iter=1000),
}
for name, model in candidates.items():
with mlflow.start_run(run_name=name):
mlflow.log_param("model_type", name)
model.fit(X_train_s, y_train)
y_pred = model.predict(X_test_s)
mlflow.log_metric("accuracy", accuracy_score(y_test, y_pred))
mlflow.log_metric("f1_macro", f1_score(y_test, y_pred, average="macro"))
runs = mlflow.search_runs(experiment_names=["wine-classification"], order_by=["metrics.accuracy DESC"])
print(runs[["params.model_type", "metrics.accuracy", "metrics.f1_macro"]].to_string(index=False))
$ python three_runs.py
2026/10/11 11:13:06 INFO mlflow.store.db.utils: Creating initial MLflow database tables...
2026/10/11 11:13:06 INFO mlflow.store.db.utils: Updating database tables
2026/10/11 11:13:08 INFO mlflow.tracking.fluent: Experiment with name 'wine-classification' does not exist. Creating a new experiment.
params.model_type metrics.accuracy metrics.f1_macro
RandomForest 1.000000 1.000000
LogisticRegression 0.972222 0.970962
GradientBoosting 0.944444 0.945313
On the first run MLflow creates the database and the experiment for you. search_runs returns a
pandas DataFrame, and parameters and metrics come back as columns prefixed with params. and
metrics..
- Run
three_runs.pya second time without changing anything. Then list every run in the experiment with itsrun_id, and keep only the newest run per model.
Solution
import mlflow
runs = mlflow.search_runs(experiment_names=["wine-classification"], order_by=["start_time DESC"])
print(f"{len(runs)} runs in the experiment")
print(runs[["run_id", "tags.mlflow.runName", "metrics.accuracy"]].to_string(index=False))
latest = runs.drop_duplicates("tags.mlflow.runName") # newest run per model
print(f"\n{len(latest)} after keeping only the newest run per model")
$ python three_runs.py
params.model_type metrics.accuracy metrics.f1_macro
RandomForest 1.000000 1.000000
RandomForest 1.000000 1.000000
LogisticRegression 0.972222 0.970962
LogisticRegression 0.972222 0.970962
GradientBoosting 0.944444 0.945313
GradientBoosting 0.944444 0.945313
$ python list_runs.py
6 runs in the experiment
run_id tags.mlflow.runName metrics.accuracy
7999df9894884988a2b0edc7427e7aff LogisticRegression 0.972222
cbeb52a28f394ae0988173762d5edd03 GradientBoosting 0.944444
ad3bb0db5a9d434abb6a27e2ac208576 RandomForest 1.000000
f7a7f8eafefe448799d9c0b688b30945 LogisticRegression 0.972222
f0b2625272994aea8414077ca41adadf GradientBoosting 0.944444
72a0494d07494970aea16aff2d56a120 RandomForest 1.000000
3 after keeping only the newest run per model
A run name is just a label. Every start_run creates a new run with its own run_id, so the
experiment now holds six runs. The metrics repeat exactly because every split and model is
seeded, so the rerun reproduced the first one. Your run_ids will differ, because they’re random
per run.
- Log a model to the registry with its preprocessing included, so a loaded version works on
raw features. Wrap
StandardScalerandLogisticRegressionin sklearn’smake_pipeline. Log one version atC=0.01and one atC=1.0withregistered_model_name="wine-classifier". Then loadmodels:/wine-classifier/1and predict five raw test rows. In MLflow 3 thelog_modelargument isname=, notartifact_path=.
Solution
import mlflow
import mlflow.sklearn
from sklearn.datasets import load_wine
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
mlflow.set_experiment("wine-classification")
X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
for C in [0.01, 1.0]:
with mlflow.start_run(run_name=f"logreg-C{C}"):
model = make_pipeline(StandardScaler(), LogisticRegression(C=C, max_iter=1000))
model.fit(X_train, y_train)
mlflow.log_param("C", C)
mlflow.log_metric("accuracy", model.score(X_test, y_test))
info = mlflow.sklearn.log_model(model, name="model", registered_model_name="wine-classifier")
print(f"C={C}: accuracy={model.score(X_test, y_test):.4f} version={info.registered_model_version}")
v1 = mlflow.sklearn.load_model("models:/wine-classifier/1")
print("version 1 predicts:", v1.predict(X_test[:5]))
print("truth: ", y_test[:5])
$ python register.py
Successfully registered model 'wine-classifier'.
Created version '1' of model 'wine-classifier'.
C=0.01: accuracy=1.0000 version=1
Registered model 'wine-classifier' already exists. Creating a new version of this model...
Created version '2' of model 'wine-classifier'.
C=1.0: accuracy=0.9722 version=2
version 1 predicts: [0 2 0 1 1]
truth: [0 2 0 1 1]
Each call with the same registered_model_name adds a version, numbered 1, 2 and so on. Version 1
predicts straight from raw wine features because the scaler is inside the saved pipeline. The
lesson’s training script, by contrast, logs the scaler as a separate artifact, so whoever loads
it has to pair the two by hand.
- Version 1 scored higher, so promote it. Use the lesson’s
transition_model_version_stageto move version 1 toProduction, print every version’s stage, and loadmodels:/wine-classifier/Productionto score the held-out test split.
Solution
import mlflow.sklearn
from mlflow.tracking import MlflowClient
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
client = MlflowClient()
client.transition_model_version_stage(
name="wine-classifier", version=1, stage="Production", archive_existing_versions=False,
)
for mv in client.search_model_versions("name='wine-classifier'"):
print(f"version {mv.version}: stage={mv.current_stage}")
X, y = load_wine(return_X_y=True)
_, X_test, _, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model = mlflow.sklearn.load_model("models:/wine-classifier/Production")
print(f"Production model test accuracy: {model.score(X_test, y_test):.4f}")
$ python promote.py
C:\tmp\mlops-practice\promote.py:7: FutureWarning: ``mlflow.tracking.client.MlflowClient.transition_model_version_stage`` is deprecated since 2.9.0. Model registry stages will be removed in a future major release. To learn more about the deprecation of model registry stages, see our migration guide here: https://mlflow.org/docs/latest/model-registry.html#migrating-from-stages
client.transition_model_version_stage(
version 1: stage=Production
version 2: stage=None
Production model test accuracy: 1.0000
The stage URI resolves to whichever version currently holds Production. Callers never hardcode
a version number, so a promotion doesn’t need a code change. Take the FutureWarning seriously:
stages have been deprecated since MLflow 2.9 and are due to be removed. Read the migration guide
it links to before you build a release process on them.