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 8000Best 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- 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)}")- 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- 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}")- 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