1. Learning Objectives

By the end of this lesson, you will be able to:

  • Design a production-grade MLOps pipeline for financial AI models.

  • Serialise and version machine learning models using ONNX, TorchScript, and joblib.

  • Build REST APIs for model serving using FastAPI and Docker.

  • Implement batch inference pipelines for portfolio optimisation at scale.

  • Monitor model performance and detect data drift using statistical tests.

  • Implement automated retraining pipelines with CI/CD integration.

  • Understand the regulatory and compliance requirements for AI in finance (Model Risk Management).

  • Handle model explainability and interpretability for stakeholder reporting.


2. The MLOps Lifecycle for Financial AI

MLOps (Machine Learning Operations) is the practice of operationalising machine learning models in production. For financial AI, the stakes are high: regulatory fines, financial losses, and reputational damage.

The MLOps Lifecycle:

  1. Development: Feature engineering, model training, hyperparameter tuning.

  2. Validation: Backtesting, stress testing, regulatory compliance checks.

  3. Deployment: Model serving, A/B testing, canary deployments.

  4. Monitoring: Performance tracking, data drift detection, alerting.

  5. Retraining: Automated retraining on new data, model versioning.

  6. Governance: Model risk management, audit trails, explainability.

2.1 The Financial AI Pipeline Architecture

text
┌─────────────────────────────────────────────────────────────────┐
│                       Data Sources                              │
│  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐            │
│  │Price │  │Fund. │  │Alt.  │  │Order │  │Macro │            │
│  │Data  │  │Data  │  │Data  │  │Book  │  │Data  │            │
│  └──┬───┘  └──┬───┘  └──┬───┘  └──┬───┘  └──┬───┘            │
│     │         │         │         │         │                   │
│     └─────────┴─────────┴─────────┴─────────┘                   │
│                          │                                      │
│                    Feature Store                                │
│                          │                                      │
│              ┌───────────┴───────────┐                         │
│              │   Model Training       │                         │
│              │   (Offline/Air-gapped) │                         │
│              └───────────┬───────────┘                         │
│                          │                                      │
│              ┌───────────┴───────────┐                         │
│              │   Model Registry      │                         │
│              │   (Versioned Models)  │                         │
│              └───────────┬───────────┘                         │
│                          │                                      │
│              ┌───────────┴───────────┐                         │
│              │   Model Serving       │                         │
│              │   (REST API / Batch)  │                         │
│              └───────────┬───────────┘                         │
│                          │                                      │
│              ┌───────────┴───────────┐                         │
│              │   Monitoring &        │                         │
│              │   Alerting            │                         │
│              └───────────────────────┘                         │
└─────────────────────────────────────────────────────────────────┘

3. Model Serialisation and Versioning

3.1 PyTorch Model Serialisation

text
import torch
import joblib
import pickle
import json
from datetime import datetime

# Save model state dict (recommended)
torch.save(model.state_dict(), 'model_weights.pth')

# Save entire model (includes architecture)
torch.save(model, 'model_full.pth')

# Save with metadata
checkpoint = {
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'epoch': epoch,
    'val_loss': val_loss,
    'model_architecture': str(model),
    'timestamp': datetime.now().isoformat()
}
torch.save(checkpoint, 'checkpoint.pth')

# Load model
model = FinancialNN(input_dim, hidden_dims, output_dim)
model.load_state_dict(torch.load('model_weights.pth'))
model.eval()  # Important: set to eval mode for inference

3.2 TorchScript – Production-Ready Serialisation
TorchScript converts PyTorch models to a serialised format that can be loaded in C++ and other environments.

text
# Trace with example input
example_input = torch.randn(1, 60, 50)  # (batch, seq_len, features)
traced_model = torch.jit.trace(model, example_input)
traced_model.save('model_traced.pt')

# Script (for models with control flow)
scripted_model = torch.jit.script(model)
scripted_model.save('model_scripted.pt')

# Load and run
loaded_model = torch.jit.load('model_traced.pt')
prediction = loaded_model(example_input)

3.3 ONNX – Interoperability Across Frameworks
ONNX (Open Neural Network Exchange) allows models to be used across different frameworks (PyTorch, TensorFlow, Caffe2).

text
import onnx
import onnxruntime

# Export to ONNX
dummy_input = torch.randn(1, 60, 50)
torch.onnx.export(
    model,
    dummy_input,
    "model.onnx",
    export_params=True,
    opset_version=11,
    do_constant_folding=True,
    input_names=['input'],
    output_names=['output'],
    dynamic_axes={
        'input': {0: 'batch_size', 1: 'seq_length'},
        'output': {0: 'batch_size'}
    }
)

# Load and run with ONNX Runtime
ort_session = onnxruntime.InferenceSession("model.onnx")
inputs = {ort_session.get_inputs()[0].name: dummy_input.numpy()}
outputs = ort_session.run(None, inputs)
prediction = outputs[0]

3.4 Scikit-Learn and Joblib Serialisation

text
import joblib

# Save scikit-learn model
joblib.dump(model, 'model.joblib')

# Save with metadata
model_info = {
    'model': model,
    'features': feature_names,
    'scaler': scaler,
    'timestamp': datetime.now().isoformat(),
    'version': '1.0.0'
}
joblib.dump(model_info, 'model_with_metadata.joblib')

# Load
loaded = joblib.load('model.joblib')

3.5 Model Registry – Version Control for Models

text
class ModelRegistry:
    def __init__(self, base_path='./model_registry'):
        self.base_path = base_path
        os.makedirs(base_path, exist_ok=True)

    def register_model(self, model, metadata):
        """
        Register a new model version.
        """
        version = metadata.get('version', datetime.now().strftime('%Y%m%d_%H%M%S'))
        model_path = f"{self.base_path}/{version}"

        os.makedirs(model_path, exist_ok=True)

        # Save model
        if isinstance(model, torch.nn.Module):
            torch.save(model.state_dict(), f"{model_path}/model.pth")
        else:
            joblib.dump(model, f"{model_path}/model.joblib")

        # Save metadata
        with open(f"{model_path}/metadata.json", 'w') as f:
            json.dump(metadata, f, indent=2)

        # Update latest symlink
        latest_path = f"{self.base_path}/latest"
        if os.path.exists(latest_path) or os.path.islink(latest_path):
            os.unlink(latest_path)
        os.symlink(version, latest_path)

        print(f"Model registered: {version}")
        return version

    def load_model(self, version='latest'):
        """
        Load a specific model version.
        """
        model_path = f"{self.base_path}/{version}"
        if not os.path.exists(model_path):
            raise ValueError(f"Model version {version} not found")

        with open(f"{model_path}/metadata.json", 'r') as f:
            metadata = json.load(f)

        # Load model
        if os.path.exists(f"{model_path}/model.pth"):
            # PyTorch model (need to instantiate first)
            # Assume architecture is stored in metadata
            model = FinancialNN(metadata['input_dim'], metadata['hidden_dims'], metadata['output_dim'])
            model.load_state_dict(torch.load(f"{model_path}/model.pth"))
        else:
            model = joblib.load(f"{model_path}/model.joblib")

        return model, metadata

4. Model Serving – REST APIs with FastAPI

4.1 FastAPI Server Implementation

text
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import numpy as np
import torch
from typing import List, Optional
import uvicorn

app = FastAPI(title="Financial AI Trading API", version="1.0.0")

# Define request/response models
class PredictionRequest(BaseModel):
    features: List[List[float]]  # List of sequences or feature vectors
    seq_length: Optional[int] = 60
    asset_ids: Optional[List[str]] = None

class PredictionResponse(BaseModel):
    predictions: List[float]
    confidence_scores: Optional[List[float]] = None
    timestamp: str

# Global model instance
model = None
device = None

@app.on_event("startup")
async def load_model():
    """
    Load model on startup.
    """
    global model, device
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # Load from registry
    registry = ModelRegistry()
    model, metadata = registry.load_model('latest')
    model.to(device)
    model.eval()
    print(f"Model loaded: {metadata}")

@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
    """
    Make predictions from input features.
    """
    try:
        # Convert to numpy array
        features = np.array(request.features, dtype=np.float32)

        # Check dimensions
        if len(features.shape) == 2:
            # Single sequence: (seq_len, features)
            features = features.reshape(1, features.shape[0], features.shape[1])
        elif len(features.shape) == 3:
            # Batch of sequences: (batch, seq_len, features)
            pass
        else:
            raise HTTPException(status_code=400, detail="Invalid feature dimensions")

        # Convert to tensor
        input_tensor = torch.from_numpy(features).to(device)

        # Run inference
        with torch.no_grad():
            predictions = model(input_tensor)
            predictions = predictions.cpu().numpy().flatten().tolist()

        return PredictionResponse(
            predictions=predictions,
            confidence_scores=None,
            timestamp=datetime.now().isoformat()
        )

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/health")
async def health_check():
    """
    Health check endpoint.
    """
    return {"status": "healthy", "model_loaded": model is not None}

@app.get("/metrics")
async def get_metrics():
    """
    Get model performance metrics.
    """
    # In production, fetch from monitoring system
    return {
        "latency_p50": "15ms",
        "latency_p95": "45ms",
        "requests_per_second": 120,
        "error_rate": "0.01%"
    }

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

4.2 Dockerisation

text
# Dockerfile
FROM python:3.10-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
text
# docker-compose.yml
version: '3.8'

services:
  model-api:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - ./model_registry:/app/model_registry
    environment:
      - CUDA_VISIBLE_DEVICES=0
    restart: unless-stopped

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"

  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

4.3 Client for API Calls

text
import requests
import numpy as np

class FinancialAPIClient:
    def __init__(self, base_url="http://localhost:8000"):
        self.base_url = base_url

    def predict(self, features):
        """
        Send prediction request to API.
        """
        response = requests.post(
            f"{self.base_url}/predict",
            json={"features": features.tolist()}
        )

        if response.status_code == 200:
            return response.json()['predictions']
        else:
            raise Exception(f"API Error: {response.text}")

    def health_check(self):
        response = requests.get(f"{self.base_url}/health")
        return response.json()

5. Batch Inference Pipelines

For portfolio optimisation, you may need to run inference on thousands of assets simultaneously.

text
class BatchInferencePipeline:
    def __init__(self, model_path, device='cpu'):
        self.device = torch.device(device)
        self.model = self.load_model(model_path)

    def load_model(self, model_path):
        model = torch.jit.load(model_path)
        model.to(self.device)
        model.eval()
        return model

    def process_batch(self, data_loader, batch_size=256):
        """
        Run inference on a batch of data.
        """
        predictions = []

        with torch.no_grad():
            for batch in data_loader:
                batch = batch.to(self.device)
                pred = self.model(batch)
                predictions.append(pred.cpu().numpy())

        return np.concatenate(predictions, axis=0)

    def run_scheduled_pipeline(self, data_source, output_path, schedule='daily'):
        """
        Run the inference pipeline on a schedule.
        """
        import schedule
        import time

        def job():
            print(f"Running pipeline at {datetime.now()}")
            data = data_source.fetch()
            predictions = self.process_batch(data)
            self.save_results(predictions, output_path)

        if schedule == 'daily':
            schedule.every().day.at("17:00").do(job)  # After market close
        elif schedule == 'hourly':
            schedule.every().hour.do(job)

        while True:
            schedule.run_pending()
            time.sleep(60)

6. Model Monitoring and Data Drift Detection

Financial data is non-stationary. Models trained on historical data may become obsolete due to regime changes.

6.1 Population Stability Index (PSI)
PSI measures the shift in feature distributions between training and production data.

text
def compute_psi(train_dist, prod_dist, buckets=10):
    """
    Population Stability Index.
    PSI = Σ (prod_pct - train_pct) * ln(prod_pct / train_pct)
    """
    # Create bins for both distributions
    combined = np.concatenate([train_dist, prod_dist])
    bins = np.percentile(combined, np.linspace(0, 100, buckets+1))

    train_hist, _ = np.histogram(train_dist, bins=bins)
    prod_hist, _ = np.histogram(prod_dist, bins=bins)

    train_pct = train_hist / len(train_dist) + 1e-8
    prod_pct = prod_hist / len(prod_dist) + 1e-8

    psi = np.sum((prod_pct - train_pct) * np.log(prod_pct / train_pct))
    return psi

# Interpretation
# PSI < 0.1: No significant shift
# 0.1 ≤ PSI < 0.2: Moderate shift (investigate)
# PSI ≥ 0.2: Significant shift (retrain required)

6.2 Feature Drift Detection with Kolmogorov-Smirnov Test

text
from scipy.stats import ks_2samp

def detect_feature_drift(train_feature, prod_feature, threshold=0.05):
    """
    Detect drift using Kolmogorov-Smirnov test.
    """
    stat, p_value = ks_2samp(train_feature, prod_feature)

    drift_detected = p_value < threshold
    return {
        'statistic': stat,
        'p_value': p_value,
        'drift_detected': drift_detected
    }

6.3 Concept Drift Detection (Model Performance Degradation)

text
class ConceptDriftDetector:
    def __init__(self, window_size=100, threshold=0.1):
        self.window_size = window_size
        self.threshold = threshold
        self.predictions = []
        self.actuals = []
        self.metrics = []

    def update(self, y_pred, y_true):
        """
        Update with new prediction and actual value.
        """
        self.predictions.append(y_pred)
        self.actuals.append(y_true)

        # Keep only recent window
        if len(self.predictions) > self.window_size:
            self.predictions = self.predictions[-self.window_size:]
            self.actuals = self.actuals[-self.window_size:]

        # Compute current accuracy
        accuracy = np.mean(np.array(self.predictions) == np.array(self.actuals))
        self.metrics.append(accuracy)

        # Check for drift
        if len(self.metrics) > self.window_size:
            recent_accuracy = np.mean(self.metrics[-10:])
            historical_accuracy = np.mean(self.metrics[:-10])

            drift_score = (historical_accuracy - recent_accuracy) / historical_accuracy
            return drift_score > self.threshold

        return False

6.4 Monitoring Dashboard (Grafana + Prometheus)

text
from prometheus_client import Counter, Histogram, Gauge, start_http_server

# Define metrics
prediction_counter = Counter('predictions_total', 'Total predictions made')
error_counter = Counter('prediction_errors_total', 'Total prediction errors')
latency_histogram = Histogram('prediction_latency_seconds', 'Prediction latency')
drift_gauge = Gauge('feature_drift', 'Feature drift score')

# Example usage
@app.post("/predict")
async def predict(request: PredictionRequest):
    start_time = time.time()
    try:
        # ... prediction logic ...
        prediction_counter.inc()
        latency_histogram.observe(time.time() - start_time)
        return PredictionResponse(...)
    except Exception as e:
        error_counter.inc()
        raise

# Start Prometheus metrics server
start_http_server(8001)  # Metrics on port 8001

7. Automated Retraining Pipelines

7.1 Retraining Trigger Conditions

text
class RetrainingScheduler:
    def __init__(self, model_registry, data_source):
        self.registry = model_registry
        self.data_source = data_source
        self.last_retrain = datetime.now()

    def should_retrain(self):
        """
        Check if retraining is needed based on multiple conditions.
        """
        conditions = [
            self.time_based_check(),     # Every 30 days
            self.performance_check(),    # Accuracy drop > 10%
            self.drift_check(),          # PSI > 0.2
            self.volume_check()          # New data > threshold
        ]
        return any(conditions)

    def time_based_check(self):
        days_since_retrain = (datetime.now() - self.last_retrain).days
        return days_since_retrain >= 30

    def performance_check(self):
        # Compare current model performance with historical
        pass

    def drift_check(self):
        # Check PSI for all features
        pass

    def volume_check(self):
        # Check if new data volume exceeds threshold
        new_data_count = self.data_source.get_new_count(since=self.last_retrain)
        return new_data_count > 10000

7.2 Automated Retraining Pipeline

text
class RetrainingPipeline:
    def __init__(self, config):
        self.config = config
        self.registry = ModelRegistry()
        self.trainer = ModelTrainer()

    def run_retraining(self):
        """
        Execute the full retraining pipeline.
        """
        print("Starting retraining pipeline...")

        # 1. Fetch new data
        print("Fetching data...")
        data = self.data_source.fetch_all()

        # 2. Feature engineering
        print("Engineering features...")
        features = self.feature_engineer(data)

        # 3. Train new model
        print("Training model...")
        new_model = self.trainer.train(features)

        # 4. Validate new model
        print("Validating model...")
        validation_results = self.trainer.validate(new_model)

        # 5. Compare with current best
        current_model, _ = self.registry.load_model('latest')
        current_performance = self.trainer.evaluate(current_model)

        # 6. Register if better
        if validation_results['sharpe'] > current_performance['sharpe']:
            print("New model outperforms. Registering...")
            metadata = {
                'version': datetime.now().strftime('%Y%m%d_%H%M%S'),
                'sharpe': validation_results['sharpe'],
                'features_used': list(features.columns),
                'data_range': (data.index.min(), data.index.max())
            }
            self.registry.register_model(new_model, metadata)
            self.last_retrain = datetime.now()
        else:
            print("New model does not outperform. Skipping registration.")

        return validation_results

7.3 CI/CD Integration with GitHub Actions

text
# .github/workflows/retrain.yml
name: Retrain Model

on:
  schedule:
    - cron: '0 17 * * *'  # Daily at 5 PM
  workflow_dispatch:  # Manual trigger

jobs:
  retrain:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.10'
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run retraining
        run: python scripts/retrain.py
      - name: Deploy if improved
        run: python scripts/deploy.py

8. Model Risk Management (MRM) – Regulatory Compliance

Financial AI models are subject to regulatory scrutiny (SR 11-7, GDPR, AI Act).

8.1 Model Validation Checklist

text
class ModelValidation:
    def __init__(self, model, training_data, validation_data):
        self.model = model
        self.train_data = training_data
        self.val_data = validation_data

    def validate(self):
        """
        Perform comprehensive model validation.
        """
        results = {
            'statistical_validity': self.test_statistical_validity(),
            'conceptual_soundness': self.test_conceptual_soundness(),
            'out_of_sample_performance': self.test_oot_performance(),
            'stability': self.test_stability(),
            'sensitivity': self.test_sensitivity(),
            'explainability': self.test_explainability()
        }
        return results

    def test_statistical_validity(self):
        """
        Test statistical assumptions.
        """
        # Normality of residuals
        residuals = self.train_data['y'] - self.model.predict(self.train_data)
        _, p_value = stats.normaltest(residuals)
        return {'normality_p_value': p_value}

    def test_conceptual_soundness(self):
        """
        Test if the model makes conceptual sense.
        """
        # Feature importance should align with financial theory
        # e.g., volatility should have positive coefficient for option pricing
        pass

    def test_oot_performance(self):
        """
        Out-of-time and out-of-sample performance.
        """
        predictions = self.model.predict(self.val_data)
        sharpe = compute_sharpe_ratio(predictions)
        return {'oot_sharpe': sharpe}

    def test_stability(self):
        """
        Test model stability across different time periods.
        """
        # Use Chow test for structural breaks
        pass

    def test_sensitivity(self):
        """
        Sensitivity analysis.
        """
        # Perturb inputs and check output changes
        pass

    def test_explainability(self):
        """
        Ensure model is explainable.
        """
        # SHAP or LIME analysis
        pass

9. Model Explainability – SHAP and LIME

9.1 SHAP (SHapley Additive exPlanations)

text
import shap

def explain_model_shap(model, X_background, X_explain):
    """
    Use SHAP to explain model predictions.
    """
    # Create explainer
    explainer = shap.KernelExplainer(model.predict, X_background)

    # Compute SHAP values
    shap_values = explainer.shap_values(X_explain)

    # Summary plot
    shap.summary_plot(shap_values, X_explain, feature_names=feature_names)

    # Force plot for individual prediction
    shap.force_plot(explainer.expected_value, shap_values[0], X_explain.iloc[0])

    return shap_values

9.2 LIME (Local Interpretable Model-Agnostic Explanations)

text
import lime
import lime.lime_tabular

def explain_model_lime(model, X_train, X_explain):
    """
    Use LIME to explain model predictions.
    """
    explainer = lime.lime_tabular.LimeTabularExplainer(
        X_train.values,
        feature_names=feature_names,
        class_names=['Down', 'Up'],
        mode='classification'
    )

    explanation = explainer.explain_instance(
        X_explain.iloc[0].values,
        model.predict_proba,
        num_features=10
    )

    explanation.show_in_notebook()
    return explanation

10. Summary for the AI Practitioner

  1. MLOps is the operational backbone of financial AI. It ensures models are reliable, auditable, and compliant.

  2. Model serialisation: Use torch.save() for PyTorch, joblib for scikit-learn, and TorchScript/ONNX for production.

  3. Model registry provides version control and audit trails. Essential for regulatory compliance.

  4. API serving: FastAPI with async endpoints handles concurrent requests. Docker for containerisation.

  5. Batch inference is used for portfolio optimisation at scale. Process thousands of assets efficiently.

  6. Monitoring: Track feature drift (PSI, KS test) and concept drift (performance degradation).

  7. Automated retraining is triggered by time, performance, drift, or data volume conditions.

  8. Model Risk Management requires statistical validation, conceptual soundness, stability testing, and explainability.

  9. Explainability tools (SHAP, LIME) are mandatory for regulatory reporting and stakeholder trust.