MLOps & Model Deployment

Deploying, monitoring, and maintaining AI models in production

From Notebook to Production

Getting an AI model to work in a Jupyter notebook is just the beginning. The real challenge is deploying it to production where it handles real users, real data, and real business consequences. MLOps (Machine Learning Operations) is the practice of deploying, monitoring, and maintaining ML systems reliably at scale. This lesson covers everything you need to take AI models from experimentation to production.

Production ML Systems are Software Systems

All the software engineering fundamentals apply: testing, monitoring, versioning, CI/CD, and incident response. AI doesn't exempt you from good engineering practices.

What is MLOps?

MLOps brings DevOps principles to machine learning: automation, monitoring, reliability, and continuous improvement. It's the difference between "it works on my machine" and "it works reliably for millions of users."

Without MLOps
  • Model trained once, never updated
  • No monitoring, silent failures
  • Manual deployment process
  • Performance degradation over time
  • No version control for models
  • Can't reproduce results
  • No rollback capability
With MLOps
  • Automated retraining pipelines
  • Comprehensive monitoring & alerts
  • CI/CD for model deployment
  • Detect and handle model drift
  • Full model versioning & lineage
  • Reproducible experiments
  • Instant rollback on issues
MLOps Lifecycle:

┌─────────────────────────────────────────────────────────────────┐
│                        MLOps Pipeline                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. DATA COLLECTION                                             │
│     ├─ Collect training data                                    │
│     ├─ Validate data quality                                    │
│     └─ Version datasets                                         │
│                                                                 │
│  2. MODEL DEVELOPMENT                                           │
│     ├─ Feature engineering                                      │
│     ├─ Model training                                           │
│     ├─ Hyperparameter tuning                                    │
│     └─ Model evaluation                                         │
│                                                                 │
│  3. MODEL VALIDATION                                            │
│     ├─ Performance metrics                                      │
│     ├─ A/B testing                                              │
│     └─ Business metrics validation                              │
│                                                                 │
│  4. DEPLOYMENT                                                  │
│     ├─ Package model                                            │
│     ├─ Deploy to production                                     │
│     ├─ Canary/blue-green deployment                             │
│     └─ Health checks                                            │
│                                                                 │
│  5. MONITORING                                                  │
│     ├─ Performance tracking                                     │
│     ├─ Data drift detection                                     │
│     ├─ Model drift detection                                    │
│     └─ Cost monitoring                                          │
│                                                                 │
│  6. MAINTENANCE                                                 │
│     ├─ Model retraining                                         │
│     ├─ Incident response                                        │
│     ├─ Rollback if needed                                       │
│     └─ Continuous improvement                                   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Model Deployment Patterns

Different use cases require different deployment approaches. Choose based on latency requirements, scale, and infrastructure constraints.

1. REST API Endpoint (Real-Time Inference)

Deploy model as an HTTP API that accepts requests and returns predictions instantly.

# deploy_model.py - Complete production-ready ML API

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from typing import List, Optional
from contextlib import asynccontextmanager
import joblib
import numpy as np
import logging
from datetime import datetime
from prometheus_client import Counter, Histogram
import time

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

# Prometheus metrics
PREDICTION_COUNT = Counter('predictions_total', 'Total predictions', ['model_version'])
PREDICTION_LATENCY = Histogram('prediction_latency_seconds', 'Prediction latency')
PREDICTION_ERRORS = Counter('prediction_errors_total', 'Total prediction errors')

@asynccontextmanager
async def lifespan(app):
    """Load model on startup."""
    import os
    model_path = os.getenv("MODEL_PATH", "models/model_v1.joblib")
    model_version = os.getenv("MODEL_VERSION", "1.0.0")
    model_service.load_model(model_path, model_version)
    yield

app = FastAPI(
    title="ML Model API",
    description="Production ML model serving",
    version="1.0.0",
    lifespan=lifespan
)


# Load model at startup
class ModelService:
    """Singleton model service."""

    def __init__(self):
        self.model = None
        self.model_version = None
        self.loaded_at = None

    def load_model(self, model_path: str, version: str):
        """Load model from disk."""
        try:
            self.model = joblib.load(model_path)
            self.model_version = version
            self.loaded_at = datetime.now()
            logger.info(f"Loaded model version {version} from {model_path}")
        except Exception as e:
            logger.error(f"Failed to load model: {e}")
            raise

    def predict(self, features: np.ndarray) -> np.ndarray:
        """Make prediction."""
        if self.model is None:
            raise RuntimeError("Model not loaded")

        return self.model.predict(features)

    def get_info(self) -> dict:
        """Get model info."""
        return {
            "model_version": self.model_version,
            "loaded_at": self.loaded_at.isoformat() if self.loaded_at else None,
            "model_type": type(self.model).__name__
        }


# Initialize service
model_service = ModelService()


# Request/Response models
class PredictionRequest(BaseModel):
    """Input features for prediction."""
    features: List[float] = Field(..., min_items=1, max_items=100)
    request_id: Optional[str] = Field(None, description="Optional request ID for tracking")

    model_config = ConfigDict(json_schema_extra={
        "examples": [{
            "features": [5.1, 3.5, 1.4, 0.2],
            "request_id": "req_123"
        }]
    })


class PredictionResponse(BaseModel):
    """Prediction result."""
    prediction: float
    prediction_class: Optional[str] = None
    confidence: Optional[float] = None
    model_version: str
    request_id: Optional[str]
    latency_ms: float


@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
    """
    Make prediction on input features.

    Returns prediction with confidence and metadata.
    """
    start_time = time.time()

    try:
        # Convert to numpy array
        features = np.array(request.features).reshape(1, -1)

        # Validate features
        if np.any(np.isnan(features)) or np.any(np.isinf(features)):
            raise HTTPException(
                status_code=400,
                detail="Features contain NaN or Inf values"
            )

        # Make prediction
        with PREDICTION_LATENCY.time():
            prediction = model_service.predict(features)

        # Get prediction details
        pred_value = float(prediction[0])

        # Calculate confidence (if model supports predict_proba)
        confidence = None
        pred_class = None
        if hasattr(model_service.model, 'predict_proba'):
            proba = model_service.model.predict_proba(features)
            confidence = float(np.max(proba))
            pred_class = str(int(pred_value))

        # Record metrics
        PREDICTION_COUNT.labels(
            model_version=model_service.model_version
        ).inc()

        # Calculate latency
        latency_ms = (time.time() - start_time) * 1000

        # Log prediction
        logger.info(
            f"Prediction: {pred_value:.4f}, "
            f"confidence: {confidence:.4f if confidence else 'N/A'}, "
            f"latency: {latency_ms:.2f}ms, "
            f"request_id: {request.request_id}"
        )

        return PredictionResponse(
            prediction=pred_value,
            prediction_class=pred_class,
            confidence=confidence,
            model_version=model_service.model_version,
            request_id=request.request_id,
            latency_ms=round(latency_ms, 2)
        )

    except HTTPException:
        raise
    except Exception as e:
        PREDICTION_ERRORS.inc()
        logger.error(f"Prediction error: {e}", exc_info=True)
        raise HTTPException(
            status_code=500,
            detail=f"Prediction failed: {str(e)}"
        )


@app.get("/health")
async def health_check():
    """Health check endpoint."""
    try:
        # Test prediction
        test_features = np.zeros((1, 4))  # Adjust size to model input
        _ = model_service.predict(test_features)

        return {
            "status": "healthy",
            "model_loaded": True,
            "model_info": model_service.get_info()
        }
    except Exception as e:
        return {
            "status": "unhealthy",
            "model_loaded": False,
            "error": str(e)
        }


@app.get("/metrics")
async def metrics():
    """Prometheus metrics endpoint."""
    from prometheus_client import generate_latest
    return generate_latest()


@app.get("/model/info")
async def model_info():
    """Get model information."""
    return model_service.get_info()


# Run with: uvicorn deploy_model:app --host 0.0.0.0 --port 8000

Best for:

  • Real-time predictions (low latency required)
  • User-facing applications
  • Interactive systems
  • Low to medium request volume

2. Batch Processing (Offline Inference)

Process large amounts of data periodically (hourly, daily, etc.) without real-time requirements.

# batch_inference.py - Production batch processing

import pandas as pd
import joblib
import logging
from datetime import datetime
from typing import Optional
import time


class BatchInferenceService:
    """Service for running batch predictions."""

    def __init__(self, model_path: str, batch_size: int = 1000):
        self.model = joblib.load(model_path)
        self.batch_size = batch_size
        self.logger = logging.getLogger(__name__)

    def process_batch(
        self,
        input_path: str,
        output_path: str,
        feature_columns: list
    ) -> dict:
        """
        Process batch predictions.

        Args:
            input_path: Path to input CSV/Parquet
            output_path: Path to save predictions
            feature_columns: List of feature column names

        Returns:
            Statistics about the batch job
        """
        start_time = time.time()

        # Load data
        self.logger.info(f"Loading data from {input_path}")
        if input_path.endswith('.parquet'):
            df = pd.read_parquet(input_path)
        else:
            df = pd.read_csv(input_path)

        total_rows = len(df)
        self.logger.info(f"Loaded {total_rows:,} rows")

        # Validate features
        missing_cols = set(feature_columns) - set(df.columns)
        if missing_cols:
            raise ValueError(f"Missing columns: {missing_cols}")

        # Process in batches
        predictions = []
        errors = 0

        for i in range(0, total_rows, self.batch_size):
            batch = df.iloc[i:i + self.batch_size]

            try:
                # Extract features
                X = batch[feature_columns].values

                # Predict
                batch_preds = self.model.predict(X)
                predictions.extend(batch_preds)

                # Log progress
                progress = min(i + self.batch_size, total_rows)
                self.logger.info(
                    f"Processed {progress:,}/{total_rows:,} "
                    f"({progress/total_rows*100:.1f}%)"
                )

            except Exception as e:
                self.logger.error(f"Batch {i} failed: {e}")
                # Add NaN for failed predictions
                predictions.extend([None] * len(batch))
                errors += len(batch)

        # Add predictions to dataframe
        df['prediction'] = predictions
        df['predicted_at'] = datetime.now()

        # Save results
        self.logger.info(f"Saving results to {output_path}")
        if output_path.endswith('.parquet'):
            df.to_parquet(output_path, index=False)
        else:
            df.to_csv(output_path, index=False)

        # Calculate statistics
        elapsed_time = time.time() - start_time
        successful = total_rows - errors

        stats = {
            'total_rows': total_rows,
            'successful': successful,
            'errors': errors,
            'success_rate': successful / total_rows * 100,
            'elapsed_time_seconds': round(elapsed_time, 2),
            'rows_per_second': round(total_rows / elapsed_time, 2),
            'output_path': output_path
        }

        self.logger.info(
            f"Batch complete: {successful:,}/{total_rows:,} successful "
            f"({stats['success_rate']:.1f}%), "
            f"{stats['rows_per_second']:.0f} rows/sec"
        )

        return stats


# Example: Run as scheduled job (cron/Airflow)
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)

    service = BatchInferenceService(
        model_path="models/model_v1.joblib",
        batch_size=1000
    )

    stats = service.process_batch(
        input_path="data/input/users_20240115.csv",
        output_path="data/output/predictions_20240115.csv",
        feature_columns=['age', 'income', 'clicks', 'time_on_site']
    )

    print(f"✓ Batch job complete: {stats}")

Best for:

  • Large-scale predictions (millions of rows)
  • Daily/hourly recommendation generation
  • ETL pipelines with ML
  • When latency doesn't matter

3. Edge Deployment (On-Device)

Deploy lightweight models directly on user devices (mobile, IoT, browser).

Use cases and approaches:

Mobile (iOS/Android):
- TensorFlow Lite / Core ML / ONNX Runtime
- Model size: < 10MB typically
- Optimized with quantization (INT8)
- Pros: No network latency, works offline
- Cons: Limited model complexity

Browser (JavaScript):
- TensorFlow.js / ONNX.js
- Model runs in browser
- Pros: No server costs, instant response
- Cons: Limited to small models

IoT Devices:
- TensorFlow Lite Micro
- Ultra-lightweight models
- Pros: Real-time, privacy
- Cons: Very limited compute

When to use edge:
✓ Privacy-sensitive applications
✓ Real-time requirements (< 50ms)
✓ Offline functionality needed
✓ Scale to millions without server costs

Best for:

  • Privacy-sensitive applications
  • Ultra-low latency requirements
  • Offline-first applications
  • Cost optimization at massive scale

Production Deployment with Docker

Containerizing your ML model ensures consistency across environments and simplifies deployment.

Step 1: Create Production Dockerfile

Build a secure, optimized container for your ML API with health checks and proper user permissions.

# Dockerfile - Production ML API container

FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
    gcc \
    g++ \
    && rm -rf /var/lib/apt/lists/*

# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Create directory for models
RUN mkdir -p /app/models

# Non-root user for security
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD python -c "import requests; requests.get('http://localhost:8000/health')"

# Expose port
EXPOSE 8000

# Run with multiple workers for concurrency
CMD ["uvicorn", "deploy_model:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

Step 2: Pin Dependencies

Lock dependency versions to ensure reproducible builds and avoid surprises.

# requirements.txt
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
scikit-learn==1.3.2
numpy==1.26.2
pandas==2.1.3
joblib==1.3.2
prometheus-client==0.19.0

Step 3: Docker Compose Stack

Set up complete production stack with API, monitoring (Prometheus, Grafana), and caching (Redis).

# docker-compose.yml
version: '3.8'

services:
  # ML API with resource limits
  ml-api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - MODEL_PATH=/app/models/model.joblib
      - MODEL_VERSION=1.0.0
      - LOG_LEVEL=INFO
    volumes:
      - ./models:/app/models
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '2.0'
          memory: 2G
        reservations:
          cpus: '0.5'
          memory: 512M
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # Metrics collection
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus

  # Metrics visualization
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana

  # Caching layer
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  prometheus-data:
  grafana-data:
  redis-data:

Step 4: Deploy and Manage

Common commands for deploying, testing, and managing your containerized ML service.

# Build and start all services
docker-compose up --build -d

# Test the deployment
curl -X POST "http://localhost:8000/predict" \
  -H "Content-Type: application/json" \
  -d '{
    "features": [5.1, 3.5, 1.4, 0.2],
    "request_id": "test_001"
  }'

# View logs
docker-compose logs -f ml-api

# Scale horizontally (add more API instances)
docker-compose up --scale ml-api=5 -d

# Update model with zero downtime
docker-compose up -d --no-deps --build ml-api

# View metrics
# Prometheus: http://localhost:9090
# Grafana: http://localhost:3000

# Stop all services
docker-compose down
Production deployment features:
  • Multi-worker API for concurrency (4 workers)
  • Health checks and automatic restart
  • Resource limits to prevent OOM crashes
  • Prometheus metrics collection and alerts
  • Grafana dashboards for visualization
  • Redis for caching and coordination
  • Non-root user for container security
  • Easy horizontal scaling with replicas

Model Monitoring & Drift Detection

Models degrade over time as data distributions change. Monitoring is critical to detect when your model needs retraining.

Data Drift

Input features distribution changes over time.

Example: User behavior changes (COVID-19 lockdown), new products launched, seasonal patterns.

Detection: Compare feature distributions between training data and production data.

Model Drift

Model performance degrades over time.

Example: Recommendation accuracy drops from 85% to 70% because user preferences evolved.

Detection: Track prediction accuracy, precision, recall over time windows.

# model_monitoring.py - Comprehensive monitoring system

import numpy as np
import pandas as pd
from scipy import stats
from typing import Dict, Tuple
from datetime import datetime, timedelta
import logging


class ModelMonitor:
    """Monitor model performance and detect drift."""

    def __init__(self, training_stats: dict):
        """
        Initialize with baseline statistics from training data.

        Args:
            training_stats: Dict with feature means, stds, distributions
        """
        self.training_stats = training_stats
        self.logger = logging.getLogger(__name__)

    def detect_data_drift(
        self,
        production_data: pd.DataFrame,
        threshold: float = 0.05
    ) -> Dict[str, any]:
        """
        Detect data drift using statistical tests.

        Args:
            production_data: Recent production data
            threshold: P-value threshold for drift detection

        Returns:
            Drift report with alerts
        """
        drift_report = {
            'timestamp': datetime.now().isoformat(),
            'samples_analyzed': len(production_data),
            'features_with_drift': [],
            'drift_scores': {}
        }

        for feature in production_data.columns:
            # Get training distribution statistics
            if feature not in self.training_stats:
                continue

            train_mean = self.training_stats[feature]['mean']
            train_std = self.training_stats[feature]['std']

            # Production statistics
            prod_data = production_data[feature].dropna()
            prod_mean = prod_data.mean()
            prod_std = prod_data.std()

            # Kolmogorov-Smirnov test
            # Tests if distributions are significantly different
            training_samples = np.random.normal(
                train_mean,
                train_std,
                size=len(prod_data)
            )

            ks_statistic, p_value = stats.ks_2samp(
                training_samples,
                prod_data
            )

            drift_report['drift_scores'][feature] = {
                'train_mean': float(train_mean),
                'prod_mean': float(prod_mean),
                'mean_shift_pct': abs(prod_mean - train_mean) / abs(train_mean) * 100,
                'ks_statistic': float(ks_statistic),
                'p_value': float(p_value),
                'has_drift': p_value < threshold
            }

            if p_value < threshold:
                drift_report['features_with_drift'].append(feature)
                self.logger.warning(
                    f"Data drift detected in '{feature}': "
                    f"p-value={p_value:.4f}, "
                    f"mean shift={drift_report['drift_scores'][feature]['mean_shift_pct']:.1f}%"
                )

        # Overall drift assessment
        drift_report['has_significant_drift'] = len(drift_report['features_with_drift']) > 0
        drift_report['drift_severity'] = self._assess_drift_severity(drift_report)

        return drift_report

    def _assess_drift_severity(self, drift_report: dict) -> str:
        """Assess overall drift severity."""
        num_drifted = len(drift_report['features_with_drift'])

        if num_drifted == 0:
            return "none"
        elif num_drifted <= 2:
            return "low"
        elif num_drifted <= 5:
            return "medium"
        else:
            return "high"

    def detect_model_drift(
        self,
        recent_metrics: pd.DataFrame,
        baseline_metric: str = 'accuracy',
        threshold_pct: float = 5.0
    ) -> Dict[str, any]:
        """
        Detect model performance drift.

        Args:
            recent_metrics: DataFrame with timestamp and metric columns
            baseline_metric: Metric to monitor (accuracy, precision, etc.)
            threshold_pct: Alert if metric drops by this percentage

        Returns:
            Performance drift report
        """
        if len(recent_metrics) < 2:
            return {"status": "insufficient_data"}

        # Calculate baseline (e.g., 30-day average)
        cutoff_date = datetime.now() - timedelta(days=30)
        baseline_data = recent_metrics[
            recent_metrics['timestamp'] < cutoff_date
        ]

        if len(baseline_data) == 0:
            baseline_performance = recent_metrics[baseline_metric].mean()
        else:
            baseline_performance = baseline_data[baseline_metric].mean()

        # Recent performance (last 7 days)
        recent_cutoff = datetime.now() - timedelta(days=7)
        recent_data = recent_metrics[
            recent_metrics['timestamp'] >= recent_cutoff
        ]

        if len(recent_data) == 0:
            return {"status": "no_recent_data"}

        recent_performance = recent_data[baseline_metric].mean()

        # Calculate drift
        performance_change_pct = (
            (recent_performance - baseline_performance) / baseline_performance * 100
        )

        has_drift = abs(performance_change_pct) > threshold_pct
        is_degradation = performance_change_pct < 0

        report = {
            'timestamp': datetime.now().isoformat(),
            'metric': baseline_metric,
            'baseline_performance': float(baseline_performance),
            'recent_performance': float(recent_performance),
            'change_pct': float(performance_change_pct),
            'has_drift': has_drift,
            'is_degradation': is_degradation,
            'severity': 'critical' if is_degradation and abs(performance_change_pct) > 10 else
                       'warning' if is_degradation and abs(performance_change_pct) > 5 else
                       'info'
        }

        if has_drift:
            direction = "degraded" if is_degradation else "improved"
            self.logger.warning(
                f"Model performance {direction} by {abs(performance_change_pct):.1f}%: "
                f"{baseline_performance:.3f} → {recent_performance:.3f}"
            )

        return report


# Example usage
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)

    # Training data statistics (save these after training)
    training_stats = {
        'age': {'mean': 35.5, 'std': 12.3},
        'income': {'mean': 55000, 'std': 25000},
        'clicks': {'mean': 45, 'std': 20}
    }

    # Initialize monitor
    monitor = ModelMonitor(training_stats)

    # Simulate production data (with drift)
    production_data = pd.DataFrame({
        'age': np.random.normal(40, 15, 1000),  # Drifted: older users
        'income': np.random.normal(55000, 25000, 1000),  # No drift
        'clicks': np.random.normal(30, 18, 1000)  # Drifted: fewer clicks
    })

    # Detect data drift
    drift_report = monitor.detect_data_drift(production_data)
    print(f"\nData Drift Report:")
    print(f"  Severity: {drift_report['drift_severity']}")
    print(f"  Features with drift: {drift_report['features_with_drift']}")

    # Simulate performance metrics
    metrics = pd.DataFrame({
        'timestamp': pd.date_range(end=datetime.now(), periods=60, freq='D'),
        'accuracy': np.concatenate([
            np.random.normal(0.85, 0.02, 40),  # Baseline
            np.random.normal(0.78, 0.03, 20)   # Recent degradation
        ])
    })

    # Detect model drift
    perf_report = monitor.detect_model_drift(metrics, baseline_metric='accuracy')
    print(f"\nModel Performance Drift:")
    print(f"  Severity: {perf_report.get('severity', 'N/A')}")
    print(f"  Change: {perf_report.get('change_pct', 0):.1f}%")
    print(f"  Action needed: {perf_report.get('has_drift', False)}")
Monitoring best practices:
  • Monitor both data drift and model drift continuously
  • Set up alerts for drift detection (email, Slack, PagerDuty)
  • Track business metrics (revenue, conversions) not just ML metrics
  • Automated retraining triggers when drift detected
  • Keep baseline statistics from training for comparison
  • Dashboard visualizations (Grafana) for real-time monitoring

CI/CD Pipeline for ML Models

Automate model training, testing, and deployment just like you do with code.

# .github/workflows/ml-pipeline.yml - GitHub Actions CI/CD

name: ML Model CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 2 * * 0'  # Weekly retraining on Sunday 2 AM

env:
  MODEL_VERSION: ${{ github.sha }}
  PYTHON_VERSION: '3.11'

jobs:
  data-validation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: |
          pip install great-expectations pandas

      - name: Validate training data
        run: |
          python scripts/validate_data.py \
            --data-path data/training.csv \
            --expectations expectations/training_data.json

      - name: Upload data profile
        uses: actions/upload-artifact@v4
        with:
          name: data-profile
          path: reports/data_profile.html

  train-model:
    needs: data-validation
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: |
          pip install -r requirements.txt

      - name: Train model
        run: |
          python train.py \
            --data data/training.csv \
            --output models/model_${{ env.MODEL_VERSION }}.joblib \
            --metrics-output metrics.json

      - name: Upload model artifact
        uses: actions/upload-artifact@v4
        with:
          name: trained-model
          path: |
            models/model_${{ env.MODEL_VERSION }}.joblib
            metrics.json

  test-model:
    needs: train-model
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Download model
        uses: actions/download-artifact@v4
        with:
          name: trained-model

      - name: Run model tests
        run: |
          # Unit tests for model code
          pytest tests/test_model.py -v

          # Model performance tests
          python tests/test_model_performance.py \
            --model models/model_${{ env.MODEL_VERSION }}.joblib \
            --test-data data/test.csv \
            --min-accuracy 0.80

      - name: Check model size
        run: |
          SIZE=$(stat -f%z models/model_${{ env.MODEL_VERSION }}.joblib)
          MAX_SIZE=$((100 * 1024 * 1024))  # 100MB
          if [ $SIZE -gt $MAX_SIZE ]; then
            echo "Model too large: ${SIZE} bytes"
            exit 1
          fi

  evaluate-model:
    needs: test-model
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Download model
        uses: actions/download-artifact@v4
        with:
          name: trained-model

      - name: Compare with production model
        run: |
          # Download current production model
          aws s3 cp s3://models/production/model.joblib models/prod_model.joblib

          # Compare metrics
          python scripts/compare_models.py \
            --new-model models/model_${{ env.MODEL_VERSION }}.joblib \
            --prod-model models/prod_model.joblib \
            --test-data data/test.csv \
            --output comparison.json

      - name: Check if better than production
        run: |
          # Parse comparison results
          NEW_ACC=$(jq '.new_model.accuracy' comparison.json)
          PROD_ACC=$(jq '.prod_model.accuracy' comparison.json)

          echo "New model accuracy: $NEW_ACC"
          echo "Production model accuracy: $PROD_ACC"

          # Require 2% improvement to deploy
          if (( $(echo "$NEW_ACC < $PROD_ACC * 1.02" | bc -l) )); then
            echo "New model not significantly better. Skipping deployment."
            exit 1
          fi

  deploy-staging:
    needs: evaluate-model
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Download model
        uses: actions/download-artifact@v4
        with:
          name: trained-model

      - name: Upload to model registry
        run: |
          aws s3 cp models/model_${{ env.MODEL_VERSION }}.joblib \
            s3://models/staging/model_${{ env.MODEL_VERSION }}.joblib

      - name: Deploy to staging
        run: |
          # Update Kubernetes deployment
          kubectl set image deployment/ml-api \
            ml-api=your-registry/ml-api:${{ env.MODEL_VERSION }} \
            --namespace=staging

          # Wait for rollout
          kubectl rollout status deployment/ml-api --namespace=staging

      - name: Run integration tests
        run: |
          python tests/integration_tests.py \
            --api-url https://staging-api.yourcompany.com \
            --test-data data/test.csv

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Download model
        uses: actions/download-artifact@v4
        with:
          name: trained-model

      - name: Upload to model registry
        run: |
          # Tag as production
          aws s3 cp models/model_${{ env.MODEL_VERSION }}.joblib \
            s3://models/production/model_${{ env.MODEL_VERSION }}.joblib

          aws s3 cp models/model_${{ env.MODEL_VERSION }}.joblib \
            s3://models/production/model.joblib  # Latest production

      - name: Canary deployment
        run: |
          # Deploy to 10% of traffic first
          kubectl set image deployment/ml-api \
            ml-api=your-registry/ml-api:${{ env.MODEL_VERSION }} \
            --namespace=production

          kubectl patch deployment ml-api \
            -p '{"spec":{"replicas":1}}' \
            --namespace=production

          # Wait and monitor
          sleep 300

      - name: Check canary metrics
        run: |
          # Query metrics from last 5 minutes
          python scripts/check_canary_metrics.py \
            --duration 5m \
            --error-threshold 0.05

      - name: Full deployment
        run: |
          # Scale to full deployment
          kubectl patch deployment ml-api \
            -p '{"spec":{"replicas":10}}' \
            --namespace=production

          kubectl rollout status deployment/ml-api --namespace=production

      - name: Update model metadata
        run: |
          # Record deployment in model registry
          python scripts/update_model_registry.py \
            --version ${{ env.MODEL_VERSION }} \
            --status deployed \
            --metrics metrics.json
CI/CD pipeline includes:
  • Automated data validation before training
  • Model training on schedule or code changes
  • Comprehensive testing (unit, performance, integration)
  • Model comparison with production baseline
  • Staged deployment (staging → canary → production)
  • Automatic rollback on metric degradation
  • Model versioning and registry

A/B Testing ML Models

Never replace your production model without testing. A/B test new models against the current version to validate improvements.

# ab_testing.py - A/B testing framework for ML models

import hashlib
from typing import Optional, Dict
import logging
from datetime import datetime


class ModelABTest:
    """A/B test multiple model versions."""

    def __init__(
        self,
        model_a,
        model_b,
        model_a_name: str = "control",
        model_b_name: str = "treatment",
        traffic_split: float = 0.5
    ):
        """
        Initialize A/B test.

        Args:
            model_a: Control model (current production)
            model_b: Treatment model (new version)
            model_a_name: Name for model A
            model_b_name: Name for model B
            traffic_split: Percentage of traffic to model B (0.0-1.0)
        """
        self.model_a = model_a
        self.model_b = model_b
        self.model_a_name = model_a_name
        self.model_b_name = model_b_name
        self.traffic_split = traffic_split

        # Metrics tracking
        self.metrics = {
            model_a_name: {'predictions': 0, 'latencies': []},
            model_b_name: {'predictions': 0, 'latencies': []}
        }

        self.logger = logging.getLogger(__name__)

    def _assign_variant(self, user_id: str) -> str:
        """
        Consistently assign user to model variant.

        Uses hash of user_id for stable assignment (same user always
        gets same model during test period).
        """
        # Hash user_id to get deterministic assignment
        hash_val = int(hashlib.md5(user_id.encode()).hexdigest(), 16)
        assignment = (hash_val % 100) / 100.0

        if assignment < self.traffic_split:
            return self.model_b_name
        else:
            return self.model_a_name

    def predict(self, features, user_id: str) -> Dict:
        """
        Make prediction using assigned model variant.

        Returns:
            Prediction with metadata about which model was used
        """
        import time

        # Assign variant
        variant = self._assign_variant(user_id)

        # Select model
        model = self.model_b if variant == self.model_b_name else self.model_a

        # Predict and measure latency
        start = time.time()
        prediction = model.predict(features)
        latency = time.time() - start

        # Track metrics
        self.metrics[variant]['predictions'] += 1
        self.metrics[variant]['latencies'].append(latency)

        self.logger.info(
            f"Prediction: user={user_id}, variant={variant}, "
            f"latency={latency*1000:.2f}ms"
        )

        return {
            'prediction': prediction,
            'variant': variant,
            'latency_ms': latency * 1000,
            'timestamp': datetime.now().isoformat()
        }

    def get_metrics(self) -> Dict:
        """Get A/B test metrics."""
        import numpy as np

        results = {}

        for variant_name, data in self.metrics.items():
            if data['predictions'] > 0:
                results[variant_name] = {
                    'total_predictions': data['predictions'],
                    'avg_latency_ms': np.mean(data['latencies']) * 1000,
                    'p95_latency_ms': np.percentile(data['latencies'], 95) * 1000,
                    'p99_latency_ms': np.percentile(data['latencies'], 99) * 1000
                }

        return results


# Example: Running A/B test in production
if __name__ == "__main__":
    import joblib
    import numpy as np

    logging.basicConfig(level=logging.INFO)

    # Load models
    model_a = joblib.load('models/production_v1.joblib')  # Current
    model_b = joblib.load('models/candidate_v2.joblib')   # New

    # Initialize A/B test (50% traffic to new model)
    ab_test = ModelABTest(
        model_a=model_a,
        model_b=model_b,
        model_a_name="v1_production",
        model_b_name="v2_candidate",
        traffic_split=0.5
    )

    # Simulate predictions
    for i in range(1000):
        user_id = f"user_{i % 100}"  # 100 unique users
        features = np.random.randn(1, 10)

        result = ab_test.predict(features, user_id=user_id)

    # Analyze results
    metrics = ab_test.get_metrics()

    print("\nA/B Test Results:")
    print("-" * 60)
    for variant, data in metrics.items():
        print(f"\n{variant}:")
        print(f"  Predictions: {data['total_predictions']:,}")
        print(f"  Avg latency: {data['avg_latency_ms']:.2f}ms")
        print(f"  P95 latency: {data['p95_latency_ms']:.2f}ms")
        print(f"  P99 latency: {data['p99_latency_ms']:.2f}ms")

    # Statistical significance test
    from scipy import stats

    latencies_a = ab_test.metrics['v1_production']['latencies']
    latencies_b = ab_test.metrics['v2_candidate']['latencies']

    t_stat, p_value = stats.ttest_ind(latencies_a, latencies_b)

    print(f"\nStatistical test (latency difference):")
    print(f"  T-statistic: {t_stat:.4f}")
    print(f"  P-value: {p_value:.4f}")
    print(f"  Significant: {p_value < 0.05}")
A/B testing best practices:
  • Run tests long enough for statistical significance (thousands of samples)
  • Use consistent user assignment (hash-based) so users get same experience
  • Monitor both ML metrics (accuracy) and business metrics (revenue, engagement)
  • Start with small traffic split (5-10%) then increase if successful
  • Have automatic rollback if new model significantly worse
  • Track both performance metrics and latency

MLOps Best Practices

1. Version Everything

Code, data, models, configs - all versioned and tracked. You should be able to reproduce any past prediction. Use tools like DVC for data versioning, Git for code, and MLflow or Weights & Biases for experiment tracking.

2. Automate Retraining

Set up automated retraining pipelines triggered by schedules, data drift, or performance degradation. Models need fresh data to stay accurate. Don't let models go stale.

3. Monitor in Production

Track everything: prediction latency, throughput, error rates, data drift, model drift, business metrics. Set up alerts for anomalies. Use dashboards (Grafana) for visibility. You can't fix what you can't see.

4. Test Like Software

Unit tests for data processing, integration tests for pipelines, performance tests for models. Test on holdout data, adversarial examples, edge cases. ML code is still code - it needs tests.

5. Enable Fast Rollback

When a new model causes issues, you need to rollback instantly. Keep previous model versions available, use feature flags or traffic routing for instant switches. Blue-green or canary deployments minimize risk.

6. Document Everything

Model cards describing what the model does, training data, performance characteristics, limitations, and intended use. Future you (and your team) will thank you.

7. Start Simple, Scale Later

Don't build a complex MLOps platform on day one. Start with basic deployment, add monitoring, then CI/CD, then automated retraining. Build complexity only when needed.

Key Takeaways

  • MLOps is essential - Production ML requires the same rigor as any production software
  • Choose deployment pattern wisely - REST API for real-time, batch for scale, edge for privacy
  • Monitor continuously - Track data drift, model drift, and business metrics
  • Automate the pipeline - CI/CD for models just like code, including automated testing
  • A/B test new models - Never replace production models without validation
  • Version everything - Data, code, models, configs must all be reproducible
  • Enable fast rollback - When things go wrong (and they will), recover quickly
  • Start simple - Build complexity incrementally as scale demands it