Skip to main content
MLOps · lesson 2 of 8
MLOps beginner 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.

  1. The experiment-tracking example logs one Random Forest run. Log three candidates into the wine-classification experiment (Random Forest, Gradient Boosting and Logistic Regression), recording model_type, accuracy and f1_macro. Then rank them with mlflow.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..

  1. Run three_runs.py a second time without changing anything. Then list every run in the experiment with its run_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.

  1. Log a model to the registry with its preprocessing included, so a loaded version works on raw features. Wrap StandardScaler and LogisticRegression in sklearn’s make_pipeline. Log one version at C=0.01 and one at C=1.0 with registered_model_name="wine-classifier". Then load models:/wine-classifier/1 and predict five raw test rows. In MLflow 3 the log_model argument is name=, not artifact_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.

  1. Version 1 scored higher, so promote it. Use the lesson’s transition_model_version_stage to move version 1 to Production, print every version’s stage, and load models:/wine-classifier/Production to 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.

Frequently Asked Questions

What is MLOps and why does it matter?
MLOps (Machine Learning Operations) applies DevOps principles to machine learning. It solves the problems that arise when moving ML from a notebook to production: reproducibility, versioning, monitoring, and automated retraining. Without MLOps, models degrade silently, experiments are unreproducible, and deployments are manual and risky.
What is the difference between an experiment and a run in MLflow?
An experiment is a named container for related runs — like a project or hypothesis. A run is a single training execution within an experiment, containing parameters, metrics, artifacts, and metadata. You might have an experiment called 'churn-model-v2' with 50 runs testing different hyperparameters.