05 · Classical ML in Production

The gap between a working notebook and a deployed, maintainable model is where most ML projects die. This file covers the mechanics of taking a trained sklearn model and making it: reproducible, serializable, serveable, and monitored. None of this is glamorous work. All of it is necessary work. A model nobody can use is a research artifact, not an engineering product.

⚠️ What Most People Get Wrong: Treating serialization as the finish line. Pickling a model and handing it to an API is day one, not the end. The real production questions are: Does it handle malformed inputs? Will it degrade gracefully when the input distribution shifts? Can you retrain it without re-architecting the service? This file answers those questions.


1. The sklearn Pipeline — Your Serialization Contract

Before you can serialize anything useful, your preprocessing and model must be wrapped in a single sklearn Pipeline. This is not optional. A pipeline serializes the entire transformation chain — imputers, scalers, encoders, the model — as one atomic unit. Serializing only the model and not the preprocessors is the most common cause of train-serve skew.

import pandas as pd
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

# --- Build a production-ready pipeline ---

# Column definitions (define these once, use everywhere)
NUMERIC_FEATURES = ['age', 'income', 'tenure_days', 'num_transactions', 'avg_order_value']
CATEGORICAL_FEATURES = ['country', 'product_category', 'acquisition_channel']
TARGET = 'churned'

# Preprocessing
numeric_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

categorical_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='constant', fill_value='MISSING')),
    ('encoder', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

preprocessor = ColumnTransformer([
    ('num', numeric_transformer, NUMERIC_FEATURES),
    ('cat', categorical_transformer, CATEGORICAL_FEATURES)
])

# Full pipeline
pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', GradientBoostingClassifier(
        n_estimators=200,
        learning_rate=0.05,
        max_depth=4,
        subsample=0.8,
        random_state=42
    ))
])

# Train
pipeline.fit(X_train, y_train)

# Evaluate
y_prob = pipeline.predict_proba(X_test)[:, 1]
print(f"Test AUC: {roc_auc_score(y_test, y_prob):.4f}")

# The pipeline handles all input types correctly at inference time:
# single row prediction (returns probability)
sample = X_test.iloc[[0]]  # Keep as DataFrame, not Series
prob = pipeline.predict_proba(sample)[0, 1]
print(f"Churn probability for sample: {prob:.3f}")

2. Model Serialization: joblib vs pickle

joblib (Preferred for sklearn)

import joblib
import os
from datetime import datetime

MODEL_DIR = "models/"
os.makedirs(MODEL_DIR, exist_ok=True)

# Save with version tag
version = datetime.now().strftime("%Y%m%d_%H%M%S")
model_path = f"{MODEL_DIR}/churn_model_{version}.joblib"

joblib.dump(pipeline, model_path, compress=3)  # compress=3 is a good balance
file_size_mb = os.path.getsize(model_path) / (1024 * 1024)
print(f"Model saved to {model_path} ({file_size_mb:.2f} MB)")

# Load
loaded_pipeline = joblib.load(model_path)
# Verify round-trip
assert roc_auc_score(y_test, loaded_pipeline.predict_proba(X_test)[:, 1]) == \
       roc_auc_score(y_test, pipeline.predict_proba(X_test)[:, 1])
print("Serialization round-trip verified ✅")

Why Not pickle Directly?

import pickle

# pickle works but has two problems for sklearn:
# 1. Large models (forests): joblib uses memory-mapped numpy arrays — much faster
# 2. Python version sensitivity: pickled sklearn objects can break across Python minor versions

# Benchmark (approximate for a RF with 200 trees, n=50k, p=100):
# pickle: ~2.1s save, ~1.8s load, ~45MB
# joblib (compress=0): ~0.3s save, ~0.2s load, ~45MB (fast I/O)
# joblib (compress=3): ~0.4s save, ~0.3s load, ~12MB (good balance)
# joblib (compress=9): ~2.0s save, ~0.3s load, ~9MB (diminishing returns)

# Rule: use joblib for sklearn. Use pickle only for non-sklearn objects.

Model Metadata Contract

Always save metadata alongside the model. A model file with no metadata is undeployable in six months.

import json

metadata = {
    "model_version": version,
    "model_type": "GradientBoostingClassifier",
    "training_date": datetime.now().isoformat(),
    "python_version": "3.10.12",
    "sklearn_version": "1.3.0",
    "training_data": {
        "n_samples": len(X_train),
        "n_features": len(NUMERIC_FEATURES) + len(CATEGORICAL_FEATURES),
        "target": TARGET,
        "class_balance": float(y_train.mean())
    },
    "evaluation": {
        "test_auc": float(roc_auc_score(y_test, y_prob)),
        "test_n": len(y_test)
    },
    "features": {
        "numeric": NUMERIC_FEATURES,
        "categorical": CATEGORICAL_FEATURES
    },
    "hyperparameters": pipeline.named_steps['classifier'].get_params()
}

meta_path = model_path.replace('.joblib', '_metadata.json')
with open(meta_path, 'w') as f:
    json.dump(metadata, f, indent=2)

print(f"Metadata saved to {meta_path}")

3. Serving a Model as a REST API (FastAPI)

FastAPI is the correct choice for serving sklearn models in 2024+. Flask is viable but has no async support or automatic validation. The performance difference for sklearn (CPU-bound, synchronous) is small, but the developer experience of FastAPI is significantly better.

# File: serve_model.py
# Run with: uvicorn serve_model:app --host 0.0.0.0 --port 8000

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, validator
from typing import Optional
import joblib
import pandas as pd
import numpy as np
import logging
from datetime import datetime
import time

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI(
    title="Churn Prediction API",
    description="Predicts customer churn probability using a GBM model",
    version="1.0.0"
)

# Load model at startup (not per-request — that would be catastrophic)
MODEL_PATH = "models/churn_model_latest.joblib"
pipeline = joblib.load(MODEL_PATH)
logger.info(f"Model loaded from {MODEL_PATH}")


# --- Request/Response Schemas ---

class ChurnPredictionRequest(BaseModel):
    age: float = Field(..., ge=0, le=120, description="Customer age in years")
    income: float = Field(..., ge=0, description="Annual income in USD")
    tenure_days: int = Field(..., ge=0, description="Days since customer joined")
    num_transactions: int = Field(..., ge=0)
    avg_order_value: float = Field(..., ge=0)
    country: str = Field(..., min_length=2, max_length=50)
    product_category: str
    acquisition_channel: str
    
    class Config:
        json_schema_extra = {
            "example": {
                "age": 35.0,
                "income": 75000.0,
                "tenure_days": 365,
                "num_transactions": 12,
                "avg_order_value": 87.50,
                "country": "US",
                "product_category": "electronics",
                "acquisition_channel": "organic_search"
            }
        }


class ChurnPredictionResponse(BaseModel):
    churn_probability: float
    churn_predicted: bool
    confidence: str  # 'HIGH' | 'MEDIUM' | 'LOW'
    model_version: str
    inference_time_ms: float


@app.get("/health")
def health_check():
    """Liveness probe — always returns 200 if the service is running."""
    return {"status": "ok", "timestamp": datetime.now().isoformat()}


@app.get("/model/info")
def model_info():
    """Returns metadata about the loaded model."""
    try:
        import json
        meta_path = MODEL_PATH.replace('.joblib', '_metadata.json')
        with open(meta_path) as f:
            return json.load(f)
    except FileNotFoundError:
        return {"error": "Metadata file not found"}


@app.post("/predict", response_model=ChurnPredictionResponse)
def predict_churn(request: ChurnPredictionRequest):
    """
    Returns churn probability for a single customer.
    
    Confidence levels:
    - HIGH: probability < 0.2 or > 0.8
    - MEDIUM: probability 0.2-0.35 or 0.65-0.8
    - LOW: probability 0.35-0.65 (model is uncertain)
    """
    start_time = time.time()
    
    try:
        # Convert to DataFrame (preserves feature names for pipeline)
        input_df = pd.DataFrame([request.dict()])
        
        # Predict
        prob = float(pipeline.predict_proba(input_df)[0, 1])
        predicted = prob >= 0.5
        
        # Confidence tier
        if prob < 0.2 or prob > 0.8:
            confidence = "HIGH"
        elif prob < 0.35 or prob > 0.65:
            confidence = "MEDIUM"
        else:
            confidence = "LOW"
        
        inference_ms = (time.time() - start_time) * 1000
        logger.info(f"Prediction: prob={prob:.4f}, latency={inference_ms:.2f}ms")
        
        return ChurnPredictionResponse(
            churn_probability=round(prob, 4),
            churn_predicted=predicted,
            confidence=confidence,
            model_version="1.0.0",
            inference_time_ms=round(inference_ms, 2)
        )
    
    except Exception as e:
        logger.error(f"Prediction error: {e}")
        raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}")


@app.post("/predict/batch")
def predict_batch(requests: list[ChurnPredictionRequest]):
    """Batch prediction — more efficient than N individual calls."""
    if len(requests) > 1000:
        raise HTTPException(status_code=400, detail="Batch size limit: 1000")
    
    start_time = time.time()
    input_df = pd.DataFrame([r.dict() for r in requests])
    probs = pipeline.predict_proba(input_df)[:, 1].tolist()
    inference_ms = (time.time() - start_time) * 1000
    
    return {
        "predictions": [{"churn_probability": round(p, 4), "churn_predicted": p >= 0.5}
                        for p in probs],
        "batch_size": len(requests),
        "total_inference_time_ms": round(inference_ms, 2),
        "avg_per_sample_ms": round(inference_ms / len(requests), 2)
    }

Running the API

# Install dependencies
pip install fastapi uvicorn[standard] joblib pandas scikit-learn

# Run development server (auto-reload)
uvicorn serve_model:app --reload --host 0.0.0.0 --port 8000

# Test it
curl -X POST "http://localhost:8000/predict" \
  -H "Content-Type: application/json" \
  -d '{"age": 35, "income": 75000, "tenure_days": 365, "num_transactions": 12,
       "avg_order_value": 87.5, "country": "US", "product_category": "electronics",
       "acquisition_channel": "organic_search"}'

# Expected response (~5ms latency for GBM, single prediction):
# {"churn_probability": 0.1823, "churn_predicted": false, "confidence": "HIGH", ...}

# Interactive docs (auto-generated by FastAPI):
# http://localhost:8000/docs

Performance Characteristics (Reference Numbers)

For a GradientBoostingClassifier (n_estimators=200, n_features=50, tabular data):

  • Single prediction latency: ~2-8ms (CPU, no GPU needed for sklearn)

  • Batch of 100: ~15-25ms total (~0.15-0.25ms per sample — batching helps)

  • Throughput: ~500-1000 RPS on a single CPU core for simple GBM

  • Random Forest (200 trees): ~5-15ms single prediction (more trees = slower)

  • XGBoost: ~1-3ms single prediction (typically fastest sklearn-equivalent)


4. Model Versioning Basics

models/
├── churn_model_20241015_143022.joblib
├── churn_model_20241015_143022_metadata.json
├── churn_model_20241201_091155.joblib
├── churn_model_20241201_091155_metadata.json
└── churn_model_latest.joblib  ← symlink to current production model
import os
import shutil

def promote_to_production(model_path: str, models_dir: str = "models/"):
    """
    Promote a specific model version to production.
    Creates/updates the 'latest' symlink.
    """
    latest_path = os.path.join(models_dir, "churn_model_latest.joblib")
    
    # Backup current production model
    if os.path.exists(latest_path):
        backup_path = latest_path.replace('latest', 'prev_production')
        shutil.copy2(latest_path, backup_path)
        print(f"Previous production backed up to {backup_path}")
    
    # Copy new version to latest
    shutil.copy2(model_path, latest_path)
    print(f"Promoted {model_path} to production ({latest_path})")
    
    # Verify
    loaded = joblib.load(latest_path)
    print(f"Verification: {type(loaded).__name__} loaded successfully")

# Usage
promote_to_production("models/churn_model_20241201_091155.joblib")

5. Monitoring for Prediction Drift

A model that was 85% accurate at launch can degrade silently as the real-world data distribution shifts. Without monitoring, you find out when someone complains, not when the shift starts.

The Two Types of Drift

Type

What It Is

Example

How to Detect

Data Drift (Covariate Shift)

P(X) changes, P(Y|X) stays same

Average customer age shifts after new campaign

Statistical tests on feature distributions

Concept Drift

P(Y|X) changes

Fraud patterns change after attackers adapt

Monitor prediction accuracy on labeled data

Implementing a Basic Drift Monitor

import numpy as np
import pandas as pd
from scipy import stats

class PredictionDriftMonitor:
    """
    Monitors feature distributions and prediction score distributions
    for statistically significant drift vs. a reference window.
    """
    
    def __init__(self, reference_df: pd.DataFrame, 
                 reference_probs: np.ndarray,
                 alpha: float = 0.05):
        """
        reference_df: training/recent-production feature data
        reference_probs: model output probabilities on reference data
        alpha: significance level for drift detection
        """
        self.reference_df = reference_df
        self.reference_probs = reference_probs
        self.alpha = alpha
        self.numeric_cols = reference_df.select_dtypes(include=np.number).columns.tolist()
    
    def check_feature_drift(self, current_df: pd.DataFrame) -> dict:
        """
        Kolmogorov-Smirnov test for each numeric feature.
        KS test is distribution-free: works for any shape.
        """
        results = {}
        drifted_features = []
        
        for col in self.numeric_cols:
            if col not in current_df.columns:
                continue
            
            ref_vals = self.reference_df[col].dropna().values
            cur_vals = current_df[col].dropna().values
            
            ks_stat, p_value = stats.ks_2samp(ref_vals, cur_vals)
            drifted = p_value < self.alpha
            
            results[col] = {
                'ks_statistic': round(ks_stat, 4),
                'p_value': round(p_value, 4),
                'drifted': drifted
            }
            
            if drifted:
                drifted_features.append(col)
        
        return {
            'feature_tests': results,
            'drifted_features': drifted_features,
            'drift_detected': len(drifted_features) > 0,
            'drift_fraction': len(drifted_features) / len(self.numeric_cols)
        }
    
    def check_prediction_drift(self, current_probs: np.ndarray) -> dict:
        """
        Monitor the distribution of model output probabilities.
        Shift here is a strong signal of concept drift.
        """
        ks_stat, p_value = stats.ks_2samp(self.reference_probs, current_probs)
        
        ref_mean = self.reference_probs.mean()
        cur_mean = current_probs.mean()
        mean_shift = cur_mean - ref_mean
        
        psi = self._population_stability_index(self.reference_probs, current_probs)
        
        return {
            'ks_statistic': round(ks_stat, 4),
            'p_value': round(p_value, 4),
            'prediction_drift': p_value < self.alpha,
            'reference_mean_prob': round(ref_mean, 4),
            'current_mean_prob': round(cur_mean, 4),
            'mean_shift': round(mean_shift, 4),
            'psi': round(psi, 4),
            'psi_interpretation': 'stable' if psi < 0.1 else 'moderate change' if psi < 0.25 else 'significant shift'
        }
    
    @staticmethod
    def _population_stability_index(reference: np.ndarray, 
                                     current: np.ndarray, 
                                     bins: int = 10) -> float:
        """
        PSI: industry-standard metric for prediction distribution shift.
        PSI < 0.1: no significant change
        PSI 0.1-0.25: moderate change — investigate
        PSI > 0.25: significant shift — likely retrain needed
        """
        ref_hist, bin_edges = np.histogram(reference, bins=bins, range=(0, 1))
        cur_hist, _ = np.histogram(current, bins=bin_edges)
        
        ref_pct = (ref_hist + 1e-6) / len(reference)
        cur_pct = (cur_hist + 1e-6) / len(current)
        
        psi = np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct))
        return psi


# Usage example
monitor = PredictionDriftMonitor(X_train_df, pipeline.predict_proba(X_train_df)[:, 1])

# Check weekly on new production data
feature_report = monitor.check_feature_drift(X_new_production_df)
prediction_report = monitor.check_prediction_drift(
    pipeline.predict_proba(X_new_production_df)[:, 1]
)

print(f"Drifted features: {feature_report['drifted_features']}")
print(f"PSI: {prediction_report['psi']} ({prediction_report['psi_interpretation']})")

if prediction_report['psi'] > 0.25:
    print("⚠️  PSI > 0.25: Schedule model retraining.")

6. The Full Production Checklist

Before handing off a model to production, run through this:

PRE-DEPLOYMENT
□ Pipeline wraps ALL preprocessing (no naked scalers/encoders)
□ Pipeline verified round-trip: fit → save → load → predict matches
□ Model metadata JSON saved alongside .joblib
□ Input validation: schema enforced (Pydantic / pandera)
□ Edge cases tested: missing values, unseen categories, extreme values
□ Latency benchmarked under expected load
□ Memory usage measured (large forests can be 500MB+ in RAM)

API
□ Health check endpoint (/health) returns 200
□ Input validation returns 422 (not 500) on bad input
□ Batch endpoint available for high-throughput use cases
□ Logging: request, latency, prediction score (not features — PII risk)
□ Error handling: 500 errors are logged with context

MONITORING
□ Feature drift monitor running on weekly production windows
□ PSI alert set for > 0.1 (investigate) and > 0.25 (retrain)
□ Ground truth labels collected for periodic accuracy audit
□ Previous model version kept as rollback target

7. Bridge to MLOps

This file covers the essentials of production sklearn. The full MLOps discipline — experiment tracking, automated retraining, CI/CD for models, feature stores, A/B testing infrastructure — is Phase 5 of this roadmap. The patterns you’ve built here (pipeline-first, metadata contracts, drift monitoring) are directly portable to MLflow, DVC, and Seldon.

The mental model that carries forward: a model is not done when it achieves good test AUC. A model is done when it can be retrained, versioned, deployed, and monitored without manual intervention.


Resources

Resource

Type

Why

Time

FastAPI Docs

Docs

Canonical reference, best async Python API framework

Reference

“Designing ML Systems” — Chip Huyen

Book

Best end-to-end production ML book, covers drift and monitoring

8 hrs

sklearn Pipeline docs

Docs

ColumnTransformer patterns, custom transformers

Reference

Evidently AI (evidently.ai)

Library

Open-source drift monitoring, production-ready

2 hrs

“ML Engineering” — Andriy Burkov

Book

Practical deployment patterns, no fluff

6 hrs


Return to README.md · Previous: 04_feature_engineering.md · Next: 06_phase_projects.md