Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 22: MLOps, LLMOps, and AgentOps

22.1 Model Lifecycle Management

"I have four copies of the training data, three versions of the feature pipeline, two model checkpoints that might be the one in production, and absolutely no idea which combination produced last Tuesday's predictions."

A Data Scientist Who Needed Version Control Yesterday

Prerequisites

This section opens Chapter 22. You should have completed Chapter 21: AI for DevOps and Platform Engineering, which introduced continuous integration/continuous delivery (CI/CD) pipelines and infrastructure automation, and Chapter 6: Discovery System Architecture, which established the Discovery Workbench scaffold. Familiarity with scikit-learn model training, pandas DataFrames, and basic Git workflows is assumed.

The Big Picture

When a pharmaceutical company's drug activity predictor silently began returning wrong scores for an entire compound class, the root cause turned out to be a single renamed column in an upstream CSV, changed six weeks earlier, with no record of the modification anywhere in the pipeline. That failure captures the central problem: a model in production is not a static artifact but a living system embedded in data transformations, feature computations, training runs, evaluation gates, deployment stages, and monitoring loops. Model lifecycle management is the discipline of making every step in this pipeline reproducible, auditable, and automated. Without it, you get "works on my laptop" science: models that cannot be retrained, results that cannot be reproduced, and production failures that cannot be diagnosed. This section builds the operational backbone that the rest of the chapter depends on. Figure 22.1.1 illustrates the ML model lifecycle pipeline.

ML model lifecycle pipeline
Figure 22.1.1: The ML model lifecycle pipeline, from raw data ingestion through feature engineering, experiment tracking, registry promotion with quality gates, production deployment, and distribution shift monitoring that triggers retraining.

1. Data Pipelines and Feature Stores

When a regulatory auditor asks which data produced last quarter's predictions, or a retrained model silently degrades because an upstream CSV column was renamed, the absence of a formal pipeline turns a routine question into a crisis. The discipline described next exists to prevent exactly that scenario.

Every machine learning (ML) system begins with data, and every production failure eventually traces back to data. A data pipeline is a directed acyclic graph (DAG) of transformations that converts raw data sources into training-ready datasets. The critical properties are reproducibility (the same inputs produce the same outputs), idempotency (where re-running a step on the same inputs produces the same result without corrupting or duplicating state), and observability (you can inspect every intermediate result).

A data pipeline matters because without one, every model retraining becomes a manual, error-prone process. A single missed transformation silently corrupts results. Each stage in the DAG declares its inputs (files, parameters, upstream stage outputs) and its outputs. The orchestrator hashes all inputs to decide whether a stage needs re-execution or can serve cached results. Use a formal pipeline (DVC, Airflow, Prefect) rather than ad hoc scripts whenever your workflow has more than one transformation step, multiple contributors, or any requirement for auditability. (DVC is introduced briefly here for its pipeline capabilities; Section 5 below covers its data versioning features in depth.) Reserve bare scripts for throwaway explorations that will never reach production.

The simplest data pipeline is a Python script that reads a CSV, applies transformations, and writes a parquet file. The problem is that this script carries no metadata: when was it run, on what input, with which library versions, and what did the output look like? A proper pipeline tool adds this metadata automatically. In short: if your pipeline carries no metadata, your model carries no provenance.

"""
A minimal data pipeline using DVC (Data Version Control).
DVC tracks data files alongside code in Git, enabling
reproducible pipelines without storing large files in the repo.
"""
import pandas as pd
import numpy as np
from pathlib import Path
import json
import hashlib

class DataPipeline:
    """Reproducible data pipeline with lineage tracking."""

    def __init__(self, raw_dir: str, processed_dir: str):
        self.raw_dir = Path(raw_dir)
        self.processed_dir = Path(processed_dir)
        self.processed_dir.mkdir(parents=True, exist_ok=True)
        self.lineage = {
            "steps": [],
            "input_hashes": {},
            "output_hashes": {}
        }

    def _hash_file(self, path: Path) -> str:
        """Compute SHA-256 hash for data lineage tracking."""
        h = hashlib.sha256()
        with open(path, "rb") as f:
            for chunk in iter(lambda: f.read(8192), b""):
                h.update(chunk)
        return h.hexdigest()

    def ingest(self, filename: str) -> pd.DataFrame:
        """Load raw data and record its hash for provenance."""
        path = self.raw_dir / filename
        df = pd.read_csv(path)
        self.lineage["input_hashes"][filename] = self._hash_file(path)
        self.lineage["steps"].append({
            "action": "ingest",
            "file": filename,
            "rows": len(df),
            "columns": list(df.columns)
        })
        return df

    def validate(self, df: pd.DataFrame, schema: dict) -> pd.DataFrame:
        """Validate data against expected schema and ranges."""
        errors = []
        for col, rules in schema.items():
            if col not in df.columns:
                errors.append(f"Missing column: {col}")
                continue
            if "dtype" in rules:
                if not pd.api.types.is_dtype_equal(df[col].dtype, rules["dtype"]):
                    df[col] = df[col].astype(rules["dtype"])
            if "min" in rules and df[col].min() < rules["min"]:
                errors.append(f"{col}: min {df[col].min()} < {rules['min']}")
            if "max" in rules and df[col].max() > rules["max"]:
                errors.append(f"{col}: max {df[col].max()} > {rules['max']}")
            if "nullable" in rules and not rules["nullable"]:
                null_count = df[col].isnull().sum()
                if null_count > 0:
                    errors.append(f"{col}: {null_count} null values")

        self.lineage["steps"].append({
            "action": "validate",
            "errors": errors,
            "passed": len(errors) == 0
        })
        if errors:
            raise ValueError(f"Validation failed: {errors}")
        return df

    def transform(self, df: pd.DataFrame,
                  transformations: list[dict]) -> pd.DataFrame:
        """Apply a sequence of named transformations."""
        for t in transformations:
            if t["type"] == "log_transform":
                col = t["column"]
                df[f"{col}_log"] = np.log1p(df[col])
            elif t["type"] == "normalize":
                col = t["column"]
                mean, std = df[col].mean(), df[col].std()
                df[f"{col}_norm"] = (df[col] - mean) / std
                # Store parameters for serving-time consistency
                t["params"] = {"mean": float(mean), "std": float(std)}
            elif t["type"] == "one_hot":
                col = t["column"]
                dummies = pd.get_dummies(df[col], prefix=col)
                df = pd.concat([df, dummies], axis=1)

        self.lineage["steps"].append({
            "action": "transform",
            "transformations": transformations,
            "output_shape": list(df.shape)
        })
        return df

    def save(self, df: pd.DataFrame, filename: str) -> Path:
        """Save processed data and record output hash."""
        path = self.processed_dir / filename
        df.to_parquet(path, index=False)
        self.lineage["output_hashes"][filename] = self._hash_file(path)

        # Save lineage metadata alongside the data
        lineage_path = path.with_suffix(".lineage.json")
        with open(lineage_path, "w") as f:
            json.dump(self.lineage, f, indent=2, default=str)

        return path


# Usage: a reproducible pipeline run
pipeline = DataPipeline("data/raw", "data/processed")
df = pipeline.ingest("molecules.csv")
df = pipeline.validate(df, {
    "molecular_weight": {"dtype": "float64", "min": 0, "max": 2000},
    "logp": {"dtype": "float64", "min": -10, "max": 20},
    "activity": {"dtype": "float64", "nullable": False}
})
df = pipeline.transform(df, [
    {"type": "log_transform", "column": "molecular_weight"},
    {"type": "normalize", "column": "logp"}
])
pipeline.save(df, "molecules_processed.parquet")
A data pipeline with built-in lineage tracking. Each step records its inputs, parameters, and outputs, creating an auditable trail from raw data to processed features.

The pipeline above handles the mechanics, but production systems also need a feature store: a centralized repository of computed features that decouples feature engineering from model training. A feature store serves two purposes. First, it ensures that the same feature computation runs at training time and serving time, preventing the notorious training-serving skew where features are computed differently in batch training and online inference. Second, it enables feature reuse: a "molecular_weight_log" feature computed once can be shared across dozens of models without recomputation.

"""
A minimal feature store implementation.
Production systems use Feast, Tecton, or Hopsworks; this shows
the core abstraction: named features with versioned definitions.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable
import pandas as pd

@dataclass
class FeatureDefinition:
    """A versioned, named feature with its computation function."""
    name: str
    version: int
    description: str
    dtype: str
    compute_fn: Callable[[pd.DataFrame], pd.Series]
    created_at: datetime = field(default_factory=datetime.now)
    dependencies: list[str] = field(default_factory=list)

class FeatureStore:
    """In-memory feature store with versioning and lineage."""

    def __init__(self):
        self._registry: dict[str, FeatureDefinition] = {}
        self._cache: dict[str, pd.Series] = {}

    def register(self, feature: FeatureDefinition) -> None:
        """Register a feature definition. Newer versions overwrite."""
        key = f"{feature.name}_v{feature.version}"
        self._registry[key] = feature
        # Also store as "latest"
        self._registry[feature.name] = feature

    def compute(self, name: str, df: pd.DataFrame,
                version: int | None = None) -> pd.Series:
        """Compute a feature, using cache if available."""
        key = f"{name}_v{version}" if version else name
        if key not in self._registry:
            raise KeyError(f"Feature '{key}' not registered")

        feature_def = self._registry[key]

        # Resolve dependencies first
        for dep in feature_def.dependencies:
            if dep not in df.columns:
                df[dep] = self.compute(dep, df)

        cache_key = f"{key}_{id(df)}"
        if cache_key not in self._cache:
            self._cache[cache_key] = feature_def.compute_fn(df)
        return self._cache[cache_key]

    def get_training_set(self, feature_names: list[str],
                         df: pd.DataFrame) -> pd.DataFrame:
        """Compute multiple features into a training DataFrame."""
        result = pd.DataFrame()
        for name in feature_names:
            result[name] = self.compute(name, df)
        return result


# Register features with explicit version and computation
store = FeatureStore()
store.register(FeatureDefinition(
    name="mol_weight_log",
    version=1,
    description="Log-transformed molecular weight",
    dtype="float64",
    compute_fn=lambda df: np.log1p(df["molecular_weight"])
))
store.register(FeatureDefinition(
    name="lipophilicity_normalized",
    version=1,
    description="Z-score normalized logP",
    dtype="float64",
    compute_fn=lambda df: (df["logp"] - df["logp"].mean()) / df["logp"].std()
))
A feature store that registers named, versioned feature computations. The same definitions serve both training pipelines and inference endpoints, preventing training-serving skew.
Library Shortcut: Feast

The feature store above is 60 lines of custom code. Feast, the open-source feature store, provides the same functionality (and much more: point-in-time joins, online/offline serving, data source connectors) in a declarative YAML configuration. A Feast feature view definition replaces our FeatureDefinition class, and feast materialize handles the batch-to-online synchronization that we would otherwise build by hand. Feast reduces the feature store implementation from hundreds of lines to a configuration file and a few CLI commands.

With data pipelines producing versioned datasets and feature stores ensuring consistent feature computation, the next challenge is tracking the experiments that consume those features: which model architectures, hyperparameters, and training strategies actually produce good results.

2. Experiment Tracking

Model development is inherently experimental: different architectures, hyperparameters, feature sets, and training strategies compete, and only metrics reveal what works. Without systematic tracking, this process degenerates into scattered Jupyter notebooks and folders named "final_v3_ACTUALLY_FINAL." Experiment tracking tools record every run's parameters, metrics, artifacts, and code version in a searchable database.

MLflow is the most widely adopted open-source experiment tracking platform. Its Tracking component logs parameters, metrics, and artifacts for every training run. Its Model Registry manages model versions and lifecycle stages (Staging, Production, Archived). And its Projects component packages code for reproducible execution.

"""
Experiment tracking with MLflow: logging parameters, metrics,
artifacts, and registering the model for lifecycle management.
"""
import mlflow
import mlflow.sklearn
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score, f1_score,
    roc_auc_score, confusion_matrix
)
import numpy as np
import json

def train_and_track(
    X_train, y_train, X_test, y_test,
    experiment_name: str = "molecule_activity_prediction",
    params: dict | None = None
) -> str:
    """Train a model with full MLflow lifecycle tracking."""

    # Set or create the experiment
    mlflow.set_experiment(experiment_name)

    # Default hyperparameters
    params = params or {
        "n_estimators": 100,
        "max_depth": 10,
        "min_samples_split": 5,
        "min_samples_leaf": 2,
        "class_weight": "balanced"
    }

    with mlflow.start_run() as run:
        # 1. Log all hyperparameters
        mlflow.log_params(params)
        mlflow.log_param("n_features", X_train.shape[1])
        mlflow.log_param("n_samples_train", X_train.shape[0])
        mlflow.log_param("n_samples_test", X_test.shape[0])

        # 2. Train the model
        model = RandomForestClassifier(**params, random_state=42)
        model.fit(X_train, y_train)

        # 3. Evaluate and log metrics
        y_pred = model.predict(X_test)
        y_prob = model.predict_proba(X_test)[:, 1]

        metrics = {
            "accuracy": accuracy_score(y_test, y_pred),
            "precision": precision_score(y_test, y_pred),
            "recall": recall_score(y_test, y_pred),
            "f1": f1_score(y_test, y_pred),
            "roc_auc": roc_auc_score(y_test, y_prob)
        }
        mlflow.log_metrics(metrics)

        # 4. Log cross-validation scores for stability assessment
        cv_scores = cross_val_score(model, X_train, y_train, cv=5)
        mlflow.log_metric("cv_mean", cv_scores.mean())
        mlflow.log_metric("cv_std", cv_scores.std())

        # 5. Log feature importances as an artifact
        importance = dict(zip(
            [f"feature_{i}" for i in range(X_train.shape[1])],
            model.feature_importances_.tolist()
        ))
        with open("feature_importance.json", "w") as f:
            json.dump(importance, f, indent=2)
        mlflow.log_artifact("feature_importance.json")

        # 6. Log the confusion matrix
        cm = confusion_matrix(y_test, y_pred).tolist()
        mlflow.log_dict({"confusion_matrix": cm}, "confusion_matrix.json")

        # 7. Log the model itself with signature
        from mlflow.models import infer_signature
        signature = infer_signature(X_test, y_pred)
        mlflow.sklearn.log_model(
            model, "model",
            signature=signature,
            registered_model_name="molecule_activity_classifier"
        )

        print(f"Run ID: {run.info.run_id}")
        print(f"Metrics: {metrics}")
        return run.info.run_id
MLflow experiment tracking for a RandomForest classifier: hyperparameters, evaluation metrics, cross-validation scores, feature importances, and the serialized model are logged and versioned within a single run context.
Key Insight: The Experiment as the Unit of Knowledge

An experiment run is not just a model checkpoint. It is a unit of knowledge: a record of "given this data, these features, and these hyperparameters, the model achieved these metrics." When you track experiments systematically, the experiment log becomes a searchable knowledge base. You can answer questions like "what was the best configuration for recall above 0.9?" or "which feature sets consistently hurt performance?" without re-running anything. This connects directly to the experiment registries in Chapter 47, where the same discipline applies to scientific experiments rather than model training runs.

Weights & Biases (W&B) offers a complementary approach with richer visualization, collaborative features, and built-in hyperparameter sweep orchestration. The core abstraction is the same (runs with parameters, metrics, and artifacts), but W&B adds interactive dashboards, model comparison tables, and artifact lineage graphs that make experiment analysis more visual and collaborative.

"""
Experiment tracking with Weights & Biases, showing the
same workflow with W&B's richer API for sweeps and artifacts.
"""
import wandb
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import f1_score, roc_auc_score

def train_with_wandb(X_train, y_train, X_test, y_test, config=None):
    """Train with W&B tracking, including artifact versioning."""

    # Initialize a W&B run
    run = wandb.init(
        project="molecule-activity",
        config=config or {
            "model": "gradient_boosting",
            "n_estimators": 200,
            "learning_rate": 0.1,
            "max_depth": 5,
            "subsample": 0.8
        }
    )
    config = wandb.config  # Access resolved config

    # Train the model
    model = GradientBoostingClassifier(
        n_estimators=config.n_estimators,
        learning_rate=config.learning_rate,
        max_depth=config.max_depth,
        subsample=config.subsample,
        random_state=42
    )
    model.fit(X_train, y_train)

    # Log metrics at each boosting stage for learning curves
    for i, y_pred_staged in enumerate(model.staged_predict(X_test)):
        wandb.log({
            "stage": i,
            "f1": f1_score(y_test, y_pred_staged),
        })

    # Final metrics
    y_pred = model.predict(X_test)
    y_prob = model.predict_proba(X_test)[:, 1]
    wandb.log({
        "final_f1": f1_score(y_test, y_pred),
        "final_roc_auc": roc_auc_score(y_test, y_prob),
    })

    # Log the model as a versioned artifact
    artifact = wandb.Artifact(
        "molecule-classifier", type="model",
        description="Gradient boosting classifier for molecular activity"
    )
    artifact.add_file("model.pkl")  # Would serialize model here
    run.log_artifact(artifact)

    run.finish()


# W&B Sweep: automated hyperparameter search
sweep_config = {
    "method": "bayes",  # Bayesian optimization
    "metric": {"name": "final_f1", "goal": "maximize"},
    "parameters": {
        "n_estimators": {"values": [100, 200, 500]},
        "learning_rate": {"distribution": "log_uniform_values",
                          "min": 0.001, "max": 0.3},
        "max_depth": {"values": [3, 5, 7, 10]},
        "subsample": {"distribution": "uniform",
                      "min": 0.6, "max": 1.0}
    }
}
# sweep_id = wandb.sweep(sweep_config, project="molecule-activity")
# wandb.agent(sweep_id, train_with_wandb, count=20)
Weights and Biases experiment tracking with a GradientBoosting classifier, logging staged F1 scores at each boosting iteration and defining a Bayesian hyperparameter sweep over learning rate, depth, and subsample ratio.

3. Model Registry and Lifecycle Stages

An experiment tracker tells you which model is best. A model registry tells you which model is in production. The registry is a versioned catalog of trained models, each annotated with metadata (who trained it, on what data, with what performance) and a lifecycle stage that governs deployment decisions. Figure 22.1 illustrates the four stages and the quality gates (automated checks that a model must pass before advancing to the next stage) that govern transitions between them.

Model lifecycle stages: None, Staging, Production, Archived None (just logged) Staging (testing) Production (serving traffic) Archived (retired) Metrics Quality Gate: Integration Quality Gate: Drift detected: retrain
Figure 22.1: Model lifecycle stages and transitions. A model advances from None to Staging only after passing metric quality gates, from Staging to Production after integration tests and approval, and is archived when a successor is promoted. A drift detection feedback loop (dashed) can trigger retraining, returning the flow to Staging.

Common Misconception

Readers often assume that logging a model in an experiment tracker (MLflow Tracking, W&B) is the same as registering it for production. It is not: the experiment tracker records every run you attempt (including bad ones), while the model registry is a curated catalog of models explicitly promoted for deployment, each tagged with a lifecycle stage and governed by quality gates. Skipping the registry and deploying straight from the experiment tracker means you lose the stage transitions, approval workflows, and auditability that prevent an untested checkpoint from reaching production.

MLflow's Model Registry defines four stages: None (just logged), Staging (promoted for testing), Production (serving live traffic), and Archived (retired). Transitions between stages can be gated by automated checks: a model moves to Staging only if its test metrics exceed thresholds, and to Production only after passing integration tests and approval review. MLflow 2.9 (late 2023) deprecates these string-based stages in favor of model version aliases and tags, which allow more flexible promotion workflows. The stage-based API shown below still functions, but new projects should prefer the aliases API.

"""
Model registry operations: promoting models through lifecycle
stages with automated quality gates.
"""
from mlflow.tracking import MlflowClient
from dataclasses import dataclass

@dataclass
class QualityGate:
    """A metric threshold that must be met for stage promotion."""
    metric: str
    threshold: float
    comparison: str = "gte"  # "gte" or "lte"

    def passes(self, value: float) -> bool:
        if self.comparison == "gte":
            return value >= self.threshold
        return value <= self.threshold

class ModelLifecycleManager:
    """Manage model promotion through lifecycle stages."""

    def __init__(self, model_name: str):
        self.client = MlflowClient()
        self.model_name = model_name

    def get_latest_version(self, stage: str = "None") -> dict:
        """Get the latest model version in a given stage."""
        versions = self.client.get_latest_versions(
            self.model_name, stages=[stage]
        )
        if not versions:
            raise ValueError(
                f"No versions in stage '{stage}' for {self.model_name}"
            )
        return versions[0]

    def promote_to_staging(
        self, run_id: str, gates: list[QualityGate]
    ) -> bool:
        """Promote a run's model to Staging if it passes quality gates."""
        run = self.client.get_run(run_id)
        metrics = run.data.metrics

        # Check all quality gates
        failures = []
        for gate in gates:
            value = metrics.get(gate.metric)
            if value is None:
                failures.append(f"Missing metric: {gate.metric}")
            elif not gate.passes(value):
                failures.append(
                    f"{gate.metric}={value:.4f} "
                    f"failed threshold {gate.threshold}"
                )

        if failures:
            print(f"Promotion blocked: {failures}")
            return False

        # Find the model version from this run
        versions = self.client.search_model_versions(
            f"run_id='{run_id}'"
        )
        if versions:
            version = versions[0].version
            self.client.transition_model_version_stage(
                self.model_name, version, "Staging"
            )
            print(f"Model v{version} promoted to Staging")
            return True
        return False

    def promote_to_production(self, version: int,
                              archive_previous: bool = True) -> None:
        """Promote a Staging model to Production."""
        if archive_previous:
            # Archive the current production model
            prod_versions = self.client.get_latest_versions(
                self.model_name, stages=["Production"]
            )
            for v in prod_versions:
                self.client.transition_model_version_stage(
                    self.model_name, v.version, "Archived"
                )

        self.client.transition_model_version_stage(
            self.model_name, version, "Production"
        )
        print(f"Model v{version} is now in Production")


# Usage: promote with quality gates
manager = ModelLifecycleManager("molecule_activity_classifier")
gates = [
    QualityGate("f1", 0.85),       # F1 must be at least 0.85
    QualityGate("roc_auc", 0.90),  # AUC must be at least 0.90
    QualityGate("cv_std", 0.05, "lte")  # CV std must be low
]
# manager.promote_to_staging(run_id, gates)
Model lifecycle management with automated quality gates. A model advances from None to Staging only if its metrics clear every threshold; previous production models are archived upon promotion of a successor.

A model registry ensures you always know which model is in production, but it cannot tell you whether that model's predictions are still trustworthy as the world changes around it.

4. Distribution Shift Detection

A model trained on last year's data makes predictions on today's data. If today's data looks different from last year's, the model's predictions become unreliable, even if the model itself has not changed. This phenomenon, distribution shift (also called dataset shift or data drift), is among the most common causes of silent model degradation in production.

Distribution shift comes in several flavors. Covariate shift means the input distribution \(P(X)\) changes while the conditional \(P(Y|X)\) stays the same: new types of molecules appear, but the relationship between molecular properties and activity is unchanged. Concept drift means \(P(Y|X)\) itself changes: the same molecular features now predict different activity levels because the biological target has mutated. Prior probability shift means \(P(Y)\) changes: the ratio of active to inactive compounds shifts.

Mental Model

Think of a model as a weather forecaster who learned their craft in coastal Florida. Their predictions work well because they have internalized the local patterns: humidity, sea breezes, afternoon thunderstorms. Covariate shift is like relocating that forecaster to the Arizona desert; the weather inputs look completely different (dry air, no sea breeze), even though the underlying physics of atmosphere-to-rain has not changed. Concept drift is like keeping the forecaster in Florida but during a year when El Nino rewrites the old patterns; the inputs look familiar, but the relationship between inputs and outcomes has shifted. Prior probability shift is like a drought year where rain simply becomes rare; the conditions look the same, but the base rate of the outcome has changed. In each case, the forecaster's old mental model quietly produces bad predictions unless someone checks whether the world still matches what the forecaster learned.

Checkpoint

So far: distribution shift means the data a model sees in production differs from its training data, and it comes in three flavors: covariate shift (input distribution changes), concept drift (the input-to-output relationship changes), and prior probability shift (the outcome base rate changes).

Two statistical tests are workhorses for detecting distribution shift in production. Maximum Mean Discrepancy (MMD) is a kernel-based test that compares two distributions by comparing their mean embeddings in a reproducing kernel Hilbert space (RKHS), a mathematical space that maps entire probability distributions to single points so that the distance between those points measures how different the distributions are. Population Stability Index (PSI) is a binning-based measure popular in financial modeling that quantifies how much a feature's distribution has shifted.

MMD is defined as:

$$\text{MMD}^2(P, Q) = \mathbb{E}_{x,x' \sim P}[k(x,x')] - 2\mathbb{E}_{x \sim P, y \sim Q}[k(x,y)] + \mathbb{E}_{y,y' \sim Q}[k(y,y')]$$

where \(k(\cdot, \cdot)\) is a kernel function (typically the Gaussian radial basis function (RBF) kernel \(k(x,y) = \exp(-\|x-y\|^2 / 2\sigma^2)\)). When \(\text{MMD}^2 = 0\), the distributions are identical in the kernel space. Larger values indicate greater divergence.


PSI is defined per feature as:

$$\text{PSI} = \sum_{i=1}^{B} (p_i - q_i) \cdot \ln\left(\frac{p_i}{q_i}\right)$$

where \(p_i\) and \(q_i\) are the proportions of observations in bin \(i\) for the reference and current distributions, and \(B\) is the number of bins. The standard interpretation: PSI < 0.1 indicates no significant shift, 0.1 to 0.25 indicates moderate shift requiring investigation, and PSI > 0.25 indicates significant shift requiring action.

Real-World Application: Uber's Michelangelo Platform
Real-World Application: Uber's Michelangelo Platform
"""
Distribution shift detection: MMD and PSI implementations
for monitoring production data against training baselines.
"""
import numpy as np
from scipy.spatial.distance import cdist

def compute_mmd_squared(
    X_ref: np.ndarray, X_new: np.ndarray,
    kernel: str = "rbf", sigma: float | None = None
) -> float:
    """
    Compute squared Maximum Mean Discrepancy between two samples.

    Uses the unbiased estimator for MMD^2 with an RBF kernel.
    The bandwidth sigma defaults to the median heuristic.
    """
    if sigma is None:
        # Median heuristic: set sigma to the median pairwise distance
        combined = np.vstack([X_ref[:100], X_new[:100]])
        dists = cdist(combined, combined, "sqeuclidean")
        sigma = np.sqrt(np.median(dists[dists > 0]) / 2)

    def rbf_kernel(X, Y):
        dists = cdist(X, Y, "sqeuclidean")
        return np.exp(-dists / (2 * sigma ** 2))

    n, m = len(X_ref), len(X_new)
    K_xx = rbf_kernel(X_ref, X_ref)
    K_yy = rbf_kernel(X_new, X_new)
    K_xy = rbf_kernel(X_ref, X_new)

    # Unbiased estimator: exclude diagonal terms
    np.fill_diagonal(K_xx, 0)
    np.fill_diagonal(K_yy, 0)

    mmd_sq = (K_xx.sum() / (n * (n - 1))
              + K_yy.sum() / (m * (m - 1))
              - 2 * K_xy.sum() / (n * m))
    return mmd_sq


def compute_psi(
    reference: np.ndarray, current: np.ndarray,
    n_bins: int = 10, eps: float = 1e-4
) -> float:
    """
    Compute Population Stability Index for a single feature.

    Bins are defined by the reference distribution's quantiles
    so that each bin has equal reference mass.
    """
    # Create bins from reference quantiles
    quantiles = np.linspace(0, 100, n_bins + 1)
    bin_edges = np.percentile(reference, quantiles)
    bin_edges[0] = -np.inf
    bin_edges[-1] = np.inf

    # Compute proportions in each bin
    ref_counts = np.histogram(reference, bins=bin_edges)[0]
    cur_counts = np.histogram(current, bins=bin_edges)[0]

    ref_props = ref_counts / len(reference) + eps
    cur_props = cur_counts / len(current) + eps

    # PSI formula
    psi = np.sum((cur_props - ref_props) * np.log(cur_props / ref_props))
    return psi


class DriftMonitor:
    """Monitor multiple features for distribution shift."""

    def __init__(self, reference_data: np.ndarray,
                 feature_names: list[str]):
        self.reference = reference_data
        self.feature_names = feature_names

    def check_drift(self, current_data: np.ndarray,
                    psi_threshold: float = 0.25,
                    mmd_threshold: float = 0.01) -> dict:
        """Run drift detection on all features and overall."""
        report = {
            "overall_mmd": compute_mmd_squared(
                self.reference, current_data
            ),
            "feature_psi": {},
            "drifted_features": [],
            "action_required": False
        }

        # Per-feature PSI
        for i, name in enumerate(self.feature_names):
            psi = compute_psi(self.reference[:, i], current_data[:, i])
            report["feature_psi"][name] = {
                "psi": round(psi, 4),
                "status": ("ok" if psi < 0.1
                          else "warning" if psi < psi_threshold
                          else "drift")
            }
            if psi >= psi_threshold:
                report["drifted_features"].append(name)

        # Overall drift decision
        report["action_required"] = (
            report["overall_mmd"] > mmd_threshold
            or len(report["drifted_features"]) > 0
        )
        return report


# Example: monitoring molecular property distributions
np.random.seed(42)
ref_data = np.random.randn(1000, 3)  # Training distribution
new_data = np.random.randn(500, 3)
new_data[:, 0] += 0.5  # Simulate drift in feature 0

monitor = DriftMonitor(ref_data, ["mol_weight", "logp", "tpsa"])
report = monitor.check_drift(new_data)
# report["drifted_features"] would flag "mol_weight"
Distribution shift detection combining multivariate MMD (using the median-heuristic RBF kernel) with per-feature PSI (using quantile-based binning). The DriftMonitor flags features whose PSI exceeds 0.25 and reports overall divergence via MMD.
Practical Example: Catching Drift in a Drug Discovery Pipeline

A pharmaceutical company deploys a molecular activity predictor trained on a library of 5,000 compounds. Six months later, a medicinal chemistry team synthesizes a new series of compounds with a novel scaffold. The DriftMonitor detects PSI = 0.42 on the molecular weight feature and PSI = 0.31 on the topological polar surface area (TPSA) feature. The overall MMD is 0.03, well above the 0.01 threshold. The system triggers an alert: predictions on the new scaffold are unreliable. The team responds by collecting activity data for 200 compounds from the new series and retraining the model, incorporating the new scaffold into the training distribution. After retraining, PSI drops below 0.1 for all features. This is distribution shift detection working as intended: catching degradation before it produces bad predictions, not after.

Library Shortcut: Evidently and Alibi Detect

Our MMD and PSI implementations total about 80 lines. Evidently provides production-grade drift detection with rich HTML reports, dozens of statistical tests (Kolmogorov-Smirnov (KS), chi-squared, Wasserstein, Jensen-Shannon), and integration with monitoring systems. Alibi Detect adds deep-learning-based drift detectors (learned kernels, classifier-based drift detection) that handle high-dimensional data better than classical tests. Both reduce the drift monitoring implementation to a few configuration lines. As of 2024, Evidently has expanded into a full open-source ML observability platform with native LLM evaluation support, while NannyML has emerged as another strong open-source option specializing in performance estimation without ground-truth labels.

5. Data Versioning with DVC

Git tracks code, but it was not designed for large binary files like datasets, model checkpoints, and feature matrices. Data Version Control (DVC) bridges this gap: it stores lightweight pointer files in Git (similar to Git LFS) while the actual data lives in remote storage (Amazon S3, Google Cloud Storage (GCS), Azure Blob, or a local directory). Every data file gets a content-addressable hash, where the file's storage location is determined by a cryptographic digest of its contents so that identical files always map to the same location regardless of filename, so you can reproduce any historical state by checking out the corresponding Git commit.

"""
DVC pipeline definition: a dvc.yaml file that defines
reproducible stages from raw data to trained model.

This is not Python code but a YAML pipeline specification
that DVC executes and tracks.
"""
# dvc.yaml
dvc_pipeline = """
stages:
  prepare:
    cmd: python src/prepare.py data/raw/molecules.csv
    deps:
      - src/prepare.py
      - data/raw/molecules.csv
    outs:
      - data/processed/molecules_train.parquet
      - data/processed/molecules_test.parquet
    params:
      - prepare.test_size
      - prepare.random_seed

  featurize:
    cmd: python src/featurize.py
    deps:
      - src/featurize.py
      - data/processed/molecules_train.parquet
    outs:
      - data/features/train_features.parquet
    params:
      - features.descriptors

  train:
    cmd: python src/train.py
    deps:
      - src/train.py
      - data/features/train_features.parquet
    outs:
      - models/model.pkl
    params:
      - train.n_estimators
      - train.max_depth
      - train.learning_rate
    metrics:
      - metrics/scores.json:
          cache: false
    plots:
      - metrics/roc_curve.csv:
          x: fpr
          y: tpr
"""

# params.yaml (tracked in Git, defines all pipeline parameters)
params = """
prepare:
  test_size: 0.2
  random_seed: 42

features:
  descriptors:
    - molecular_weight
    - logp
    - tpsa
    - num_h_donors
    - num_h_acceptors

train:
  n_estimators: 200
  max_depth: 7
  learning_rate: 0.1
"""

# CLI workflow:
# dvc init              # Initialize DVC in a Git repo
# dvc add data/raw/     # Track raw data with DVC
# dvc repro             # Run the full pipeline
# dvc push              # Push data to remote storage
# dvc metrics diff      # Compare metrics between commits
# dvc plots diff        # Compare plots between commits
A DVC pipeline specification (dvc.yaml) with three stages: prepare, featurize, and train. DVC tracks inter-stage dependencies and parameter files, caches intermediate outputs, and re-executes only stages whose inputs have changed when you run dvc repro.

Versioning data and code ensures you can reproduce any historical state of the pipeline, but reproducibility alone does not communicate a model's intended use, limitations, or ethical boundaries to downstream consumers.

6. Governance: Model Cards

A model card, where a model card is a short structured document that accompanies a trained model and describes what it does, how it performs, and where it should not be used, serves as the "nutrition label" for ML models. Introduced by Mitchell et al. in their 2019 paper at the ACM Conference on Fairness, Accountability, and Transparency (FAccT), the format tells consumers what the model does, how well it does it, and where it should not be used.

In a discovery context, model cards are especially important because models often migrate across teams and domains. A molecular property predictor trained on drug-like compounds might be repurposed for agrochemicals, where the chemical space is different and the training distribution does not apply. The model card makes these boundaries explicit.

"""
Automated model card generation from MLflow run metadata.
Produces a structured document for governance and auditing.
"""
from dataclasses import dataclass, field
from datetime import datetime
import json

@dataclass
class ModelCard:
    """Structured model documentation for governance."""

    # Identity
    model_name: str
    version: str
    authors: list[str]
    created_date: str = field(
        default_factory=lambda: datetime.now().isoformat()
    )

    # Purpose
    intended_use: str = ""
    out_of_scope_uses: list[str] = field(default_factory=list)
    primary_users: list[str] = field(default_factory=list)

    # Training data
    training_data_description: str = ""
    training_data_size: int = 0
    training_data_date_range: str = ""
    feature_names: list[str] = field(default_factory=list)

    # Performance
    metrics: dict[str, float] = field(default_factory=dict)
    performance_by_group: dict[str, dict] = field(default_factory=dict)

    # Limitations
    known_limitations: list[str] = field(default_factory=list)
    ethical_considerations: list[str] = field(default_factory=list)

    # Operational
    drift_thresholds: dict[str, float] = field(default_factory=dict)
    retraining_schedule: str = ""
    contact: str = ""

    def to_dict(self) -> dict:
        """Serialize to dictionary for JSON storage."""
        return {
            "model_identity": {
                "name": self.model_name,
                "version": self.version,
                "authors": self.authors,
                "created": self.created_date
            },
            "intended_use": {
                "primary_use": self.intended_use,
                "out_of_scope": self.out_of_scope_uses,
                "users": self.primary_users
            },
            "training_data": {
                "description": self.training_data_description,
                "size": self.training_data_size,
                "date_range": self.training_data_date_range,
                "features": self.feature_names
            },
            "performance": {
                "overall": self.metrics,
                "by_group": self.performance_by_group
            },
            "limitations_and_ethics": {
                "limitations": self.known_limitations,
                "ethical_considerations": self.ethical_considerations
            },
            "operations": {
                "drift_thresholds": self.drift_thresholds,
                "retraining_schedule": self.retraining_schedule,
                "contact": self.contact
            }
        }

    def to_markdown(self) -> str:
        """Generate a human-readable model card in Markdown."""
        d = self.to_dict()
        lines = [f"# Model Card: {self.model_name} v{self.version}"]
        lines.append(f"\n**Authors:** {', '.join(self.authors)}")
        lines.append(f"**Created:** {self.created_date}\n")

        lines.append("## Intended Use")
        lines.append(self.intended_use)
        if self.out_of_scope_uses:
            lines.append("\n**Out of Scope:**")
            for use in self.out_of_scope_uses:
                lines.append(f"- {use}")

        lines.append("\n## Performance Metrics")
        for metric, value in self.metrics.items():
            lines.append(f"- **{metric}:** {value:.4f}")

        lines.append("\n## Known Limitations")
        for lim in self.known_limitations:
            lines.append(f"- {lim}")

        lines.append("\n## Ethical Considerations")
        for eth in self.ethical_considerations:
            lines.append(f"- {eth}")

        return "\n".join(lines)


def generate_model_card_from_mlflow(run_id: str) -> ModelCard:
    """Auto-generate a model card from an MLflow run."""
    client = MlflowClient()
    run = client.get_run(run_id)

    card = ModelCard(
        model_name=run.data.tags.get(
            "mlflow.runName", "unnamed_model"
        ),
        version=run.data.tags.get("model_version", "1.0"),
        authors=[run.data.tags.get("mlflow.user", "unknown")],
        metrics={k: round(v, 4)
                 for k, v in run.data.metrics.items()},
        training_data_size=int(
            run.data.params.get("n_samples_train", 0)
        ),
        feature_names=list(run.data.params.keys()),
        known_limitations=[
            "Performance not validated on compounds outside "
            "the training chemical space",
            "Model assumes standardized SMILES input format"
        ],
        drift_thresholds={"psi_max": 0.25, "mmd_max": 0.01}
    )
    return card
Automated model card generation from MLflow metadata. The ModelCard dataclass captures identity, intended use, training data provenance, performance metrics, known limitations, and operational drift thresholds, then serializes to both JSON and Markdown.
Research Frontier: Holistic Model Lifecycle Automation

Work by Polyzotis et al. at Google on data management for ML (circa 2023) describes an internal platform that unifies data validation, model training, drift detection, and model card generation into a single declarative specification. Rather than stitching together separate tools for each lifecycle stage, the system treats the entire pipeline, from data ingestion through production monitoring, as one continuously validated artifact. When drift detectors fire, the platform automatically triggers retraining, re-evaluates quality gates, updates the model card's performance section, and promotes the new version only if it clears all thresholds. This "closed-loop lifecycle" approach pushes beyond the manual stage transitions described in this section and points toward fully autonomous model management, where human intervention is reserved for policy decisions rather than operational plumbing.

Try It: End-to-End Model Lifecycle on the Iris Dataset

Build a complete model lifecycle pipeline on your laptop using only scikit-learn, MLflow, and NumPy. This project ties together data pipelines, experiment tracking, registry promotion, and drift detection from this section.

1. Set up tracking. Install MLflow (pip install mlflow), start the tracking server with mlflow ui, and create an experiment called "iris-lifecycle." Train a RandomForestClassifier on the Iris dataset (sklearn.datasets.load_iris), logging hyperparameters, accuracy, and F1 score to your experiment.
2. Register and promote. Log the trained model with mlflow.sklearn.log_model and register it as "iris-classifier." Use the MLflow client to transition the model version to "Staging," gated on F1 > 0.90.
3. Simulate drift. Take the test set and add Gaussian noise (X_test + np.random.normal(0, 1.5, X_test.shape)) to simulate covariate shift. Implement the compute_psi function from this section and compute PSI for each of the four Iris features between the original test set and the noisy version.
4. Detect and respond. Wrap your PSI checks in a DriftMonitor and verify that at least two features report PSI > 0.25. Print a report showing which features drifted and by how much.
5. Generate a model card. Create a ModelCard dataclass instance for your registered model, filling in intended use ("classify Iris species from sepal/petal measurements"), known limitations ("trained on 150 samples; not validated on non-Iris flower species"), and the drift thresholds you used. Export it to Markdown and save it alongside your model artifacts.

Exercise 22.1.1

A model registered in MLflow has the following metrics from its latest training run: accuracy = 0.91, F1 = 0.84, ROC AUC = 0.93, and cross-validation standard deviation = 0.03. Your quality gates require F1 >= 0.85, ROC AUC >= 0.90, and CV std <= 0.05. Will this model be promoted to Staging? Identify which gate(s) fail and explain what the team should do next.

Hint

Check each gate independently. The QualityGate.passes() method uses "gte" (greater than or equal) for F1 and ROC AUC, and "lte" (less than or equal) for CV std. A single failed gate blocks promotion. Look carefully at the F1 threshold versus the reported F1 value.

Step-Through: PSI Calculation on a Shifted Feature

Trace through PSI with a tiny example. Suppose we have a reference distribution of 20 values for molecular weight, binned into 4 equal-frequency bins (5 values each), so each reference bin proportion is \(p_i = 0.25\). Now a new batch of 20 values arrives with these bin counts: [8, 5, 4, 3]. The current proportions are \(q = [0.40, 0.25, 0.20, 0.15]\). For each bin, compute \((q_i - p_i) \cdot \ln(q_i / p_i)\): Bin 1: \((0.40 - 0.25) \cdot \ln(0.40/0.25) = 0.15 \cdot 0.470 = 0.0706\). Bin 2: \((0.25 - 0.25) \cdot \ln(1.0) = 0\). Bin 3: \((0.20 - 0.25) \cdot \ln(0.80) = -0.05 \cdot (-0.223) = 0.0112\). Bin 4: \((0.15 - 0.25) \cdot \ln(0.60) = -0.10 \cdot (-0.511) = 0.0511\). Summing: PSI = 0.0706 + 0 + 0.0112 + 0.0511 = 0.133. This falls in the 0.1 to 0.25 "moderate shift" range, meaning the feature warrants investigation but not an immediate retraining trigger.

Real-World Application: Uber's Michelangelo Platform

Uber's Michelangelo ML platform manages the full lifecycle of thousands of production models powering ride ETAs, pricing, fraud detection, and driver dispatching. The platform integrates a centralized feature store (shared across teams so that a "trip distance" feature computed by the ETA team is reused by the pricing team), an experiment tracker that logs every training run, and automated drift monitors that compare live prediction distributions against training baselines. When feature drift exceeds thresholds on a pricing model, the system triggers automated retraining on the latest data and re-evaluates quality gates before promoting the new version.

The Hidden Cost of "Just Retrain It"

Google's 2015 paper "Hidden Technical Debt in Machine Learning Systems" (Sculley et al., presented at NeurIPS 2015) found that ML code itself typically accounts for only a small fraction of a production ML system's total codebase (circa 2015). The remaining bulk is configuration, data collection, feature extraction, data verification, monitoring, serving infrastructure, and the pipeline glue connecting them. This ratio explains why a model that takes a weekend to train can take six months to put into production: the model is the easy part, and everything this section covers (pipelines, registries, drift detection, governance) is the hard part.

Lab: Drift Detection Sensitivity Experiment

Goal: Empirically determine how much distribution shift is needed before PSI and MMD reliably detect it.
Tools: Python with NumPy and SciPy (no GPU needed). Optionally, matplotlib for plotting.
Setup (15 min): Implement compute_psi and compute_mmd_squared from this section. Generate a reference dataset of 1,000 samples from a 4-dimensional standard normal distribution. Then create 10 shifted datasets by adding offsets of 0.0, 0.1, 0.2, ..., 0.9 to the mean of the first feature only.
What to vary: The shift magnitude (0.0 to 0.9), the sample size of the new batch (try 100, 500, and 1,000), and the number of PSI bins (5, 10, 20).
What to observe: At what shift magnitude does PSI first cross 0.1 (moderate) and 0.25 (significant)? Does MMD cross its threshold at the same shift level? How does reducing the new-batch sample size affect detection sensitivity? Plot PSI and MMD as functions of shift magnitude for each sample size. You will likely find that MMD tends to be more sensitive to small multivariate shifts, while PSI provides more interpretable per-feature diagnostics (the exact crossover depends on kernel bandwidth, bin count, and sample size).

Exercises

  1. (Conceptual) Explain the difference between covariate shift, concept drift, and prior probability shift. For each type, give a concrete example from a scientific domain (drug discovery, climate modeling, or genomics) where that type of shift would occur.
  2. (Coding) Extend the DriftMonitor class to include a KS test for each feature alongside PSI. Compare the sensitivity of KS and PSI on synthetic data where you control the degree of shift. Which test detects small shifts earlier?
  3. (Analysis) Set up an MLflow experiment that trains three different model types (Random Forest, Gradient Boosting, and Logistic Regression) on the same dataset. Use the MLflow UI to compare their metrics. Write a model card for the best-performing model, paying particular attention to the "out of scope uses" section.