MLOps et déploiement#

Ce qui n’est pas en production n’existe pas.

— Adapté de Peter Drucker, The Effective Executive

Un modèle d’apprentissage automatique entraîné dans un notebook Jupyter ne crée aucune valeur tant qu’il n’est pas accessible aux utilisateurs ou aux systèmes qui en ont besoin. Selon diverses études industrielles, une majorité écrasante des projets de machine learning ne parviennent jamais en production, non pas par manque de performance algorithmique, mais par défaut d’ingénierie logicielle, d’infrastructure et de processus. Le MLOps — contraction de Machine Learning Operations — est la discipline qui vise à combler cet écart en appliquant les principes du DevOps et de l’ingénierie des données au cycle de vie complet des modèles. Ce chapitre parcourt les étapes clés du déploiement : sérialisation des modèles, création d’API de prédiction, conteneurisation, suivi des expériences, pipelines CI/CD, monitoring en production et orchestration à grande échelle.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
import json
import os
import warnings
warnings.filterwarnings('ignore')

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)

Introduction : le cycle de vie du ML#

Du notebook à la production#

Le travail d’un data scientist dans un notebook représente une fraction mineure du système de machine learning en production. L’article fondateur de Sculley et al. (2015), Hidden Technical Debt in Machine Learning Systems, illustre cette réalité : le code du modèle lui-même est un petit rectangle au centre d’un vaste écosystème comprenant la collecte de données, la validation, l’extraction de features, le serving, le monitoring et la gestion de configuration.

Hide code cell source

# Illustration : proportion du code ML vs infrastructure en production
fig, ax = plt.subplots(figsize=(10, 5))

categories = [
    "Code ML\n(modèle)", "Collecte &\nvalidation\ndes données",
    "Feature\nengineering", "Infrastructure\nde serving",
    "Monitoring &\nalertes", "Pipelines\nCI/CD",
    "Gestion de\nconfiguration", "Tests &\nvalidation"
]
proportions = [5, 20, 15, 18, 12, 10, 8, 12]
colors = ['#E24A33' if i == 0 else '#4C72B0' for i in range(len(categories))]

bars = ax.bar(categories, proportions, color=colors, edgecolor='white', linewidth=1.2)
ax.set_ylabel("Effort relatif (%)")
ax.set_title("Répartition de l'effort dans un système ML en production\n(adapté de Sculley et al., 2015)")
ax.bar_label(bars, fmt='%d%%', fontsize=9)
plt.tight_layout()
plt.show()
_images/dc1a9e5558de523f14e392bff8b350a8087cbb8005f52fbd1d4a3c9b3937041e.png

Définition 316 (MLOps)

Le MLOps (Machine Learning Operations) est un ensemble de pratiques qui combine le Machine Learning, le DevOps et l”ingénierie des données pour déployer et maintenir des systèmes ML en production de manière fiable et efficace. Le MLOps couvre l’ensemble du cycle de vie :

  1. Développement : expérimentation, entraînement, évaluation.

  2. Déploiement : sérialisation, conteneurisation, mise en service.

  3. Opérations : monitoring, détection de dérive, réentraînement.

\[\text{MLOps} = \text{ML} + \text{DevOps} + \text{Data Engineering}\]

Pourquoi la plupart des projets ML échouent en production#

Les causes d’échec sont rarement algorithmiques. Elles relèvent le plus souvent de problèmes d’ingénierie et d’organisation :

  • Absence de reproductibilité : les expériences ne sont pas versionnées, les résultats ne sont pas reproductibles.

  • Décalage données d’entraînement / production (training-serving skew) : les données en production diffèrent de celles utilisées pour l’entraînement.

  • Absence de monitoring : le modèle se dégrade silencieusement sans que personne ne s’en aperçoive.

  • Dette technique : code spaghetti, dépendances non gérées, absence de tests.

  • Fossé organisationnel : les data scientists et les ingénieurs logiciels travaillent en silos.

Remarque 266

Le MLOps n’est pas uniquement une question d’outils. C’est avant tout une culture d’ingénierie qui exige la collaboration entre data scientists, ingénieurs ML, ingénieurs DevOps et équipes métier. Les outils ne sont que le support de cette collaboration.

Hide code cell source

# Illustration : niveaux de maturité MLOps
fig, ax = plt.subplots(figsize=(12, 4))

niveaux = ["Niveau 0\nManuel", "Niveau 1\nPipeline ML", "Niveau 2\nCI/CD du ML"]
descriptions = [
    "Entraînement manuel\nDéploiement manuel\nPas de monitoring",
    "Pipeline automatisé\nEntraînement continu\nMonitoring basique",
    "CI/CD complet\nTests automatisés\nDéploiement continu\nMonitoring avancé"
]
x_pos = [0, 1, 2]
colors_niv = ['#DD8452', '#55A868', '#4C72B0']

for i, (niv, desc, col) in enumerate(zip(niveaux, descriptions, colors_niv)):
    ax.barh(0, 1, left=i, color=col, edgecolor='white', linewidth=2, height=0.6)
    ax.text(i + 0.5, 0.05, niv, ha='center', va='center',
            fontsize=10, fontweight='bold', color='white')
    ax.text(i + 0.5, -0.55, desc, ha='center', va='center', fontsize=8)

ax.set_xlim(-0.1, 3.1)
ax.set_ylim(-1, 0.6)
ax.axis('off')
ax.set_title("Niveaux de maturité MLOps (Google Cloud)", fontsize=12, pad=15)
plt.tight_layout()
plt.show()
_images/350ac44caeddbee06afa60e5c6f83f7daf322e825c9dd0121854ec74f0d0a668.png

Sérialisation des modèles#

La première étape du déploiement consiste à sérialiser le modèle entraîné, c’est-à-dire le convertir en un format persistant qui peut être chargé ultérieurement par un service de prédiction, potentiellement dans un environnement différent de celui d’entraînement.

pickle et joblib#

Le module pickle de Python permet de sérialiser n’importe quel objet Python, y compris les modèles scikit-learn. La bibliothèque joblib offre une alternative plus efficace pour les objets contenant de grands tableaux NumPy.

Définition 317 (Sérialisation)

La sérialisation (serialization) est le processus de conversion d’un objet en mémoire en une séquence d’octets pouvant être stockée sur disque ou transmise sur le réseau. La désérialisation est l’opération inverse. Pour un modèle ML, cela inclut les paramètres appris, la structure du modèle et éventuellement les métadonnées de prétraitement.

Hide code cell source

# Entraîner un modèle simple pour les exemples de sérialisation
X, y = make_classification(n_samples=1000, n_features=20, n_informative=10,
                           random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
                                                     random_state=42)
model = RandomForestClassifier(n_estimators=50, random_state=42)
model.fit(X_train, y_train)
acc = accuracy_score(y_test, model.predict(X_test))
print(f"Précision du modèle : {acc:.4f}")
Précision du modèle : 0.9100

Hide code cell source

# Sérialisation avec pickle
import pickle

# Sauvegarde
with open("/tmp/model_pickle.pkl", "wb") as f:
    pickle.dump(model, f)

# Chargement
with open("/tmp/model_pickle.pkl", "rb") as f:
    model_loaded = pickle.load(f)

print(f"Précision après chargement pickle : "
      f"{accuracy_score(y_test, model_loaded.predict(X_test)):.4f}")
print(f"Taille du fichier : "
      f"{os.path.getsize('/tmp/model_pickle.pkl') / 1024:.1f} Ko")
Précision après chargement pickle : 0.9100
Taille du fichier : 612.5 Ko

Hide code cell source

# Sérialisation avec joblib (plus efficace pour les grands modèles)
import joblib

# Sauvegarde
joblib.dump(model, "/tmp/model_joblib.pkl")

# Chargement
model_joblib = joblib.load("/tmp/model_joblib.pkl")

print(f"Précision après chargement joblib : "
      f"{accuracy_score(y_test, model_joblib.predict(X_test)):.4f}")
print(f"Taille du fichier : "
      f"{os.path.getsize('/tmp/model_joblib.pkl') / 1024:.1f} Ko")
Précision après chargement joblib : 0.9100
Taille du fichier : 616.4 Ko

Remarque 267

Attention : pickle exécute du code arbitraire lors de la désérialisation. Ne chargez jamais un fichier pickle provenant d’une source non fiable. Un fichier pickle malveillant peut exécuter n’importe quelle commande système. Pour le partage de modèles entre équipes ou organisations, préférez des formats comme ONNX.

ONNX : Open Neural Network Exchange#

ONNX est un format ouvert et interopérable pour représenter des modèles de machine learning. Il permet de passer d’un framework à un autre (scikit-learn, PyTorch, TensorFlow) et d’optimiser l’inférence grâce au runtime ONNX.

Hide code cell source

# Exemple conceptuel : export ONNX d'un modèle scikit-learn
# (nécessite skl2onnx et onnxruntime en pratique)

onnx_export_code = """
# Installation : pip install skl2onnx onnxruntime
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
import onnxruntime as rt

# Définir le type d'entrée
initial_type = [('X', FloatTensorType([None, 20]))]

# Convertir le modèle
onnx_model = convert_sklearn(model, initial_types=initial_type)

# Sauvegarder
with open("model.onnx", "wb") as f:
    f.write(onnx_model.SerializeToString())

# Inférence avec ONNX Runtime
sess = rt.InferenceSession("model.onnx")
input_name = sess.get_inputs()[0].name
pred = sess.run(None, {input_name: X_test.astype(np.float32)})[0]
"""
print("Code d'export ONNX :")
print(onnx_export_code)
Code d'export ONNX :

# Installation : pip install skl2onnx onnxruntime
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
import onnxruntime as rt

# Définir le type d'entrée
initial_type = [('X', FloatTensorType([None, 20]))]

# Convertir le modèle
onnx_model = convert_sklearn(model, initial_types=initial_type)

# Sauvegarder
with open("model.onnx", "wb") as f:
    f.write(onnx_model.SerializeToString())

# Inférence avec ONNX Runtime
sess = rt.InferenceSession("model.onnx")
input_name = sess.get_inputs()[0].name
pred = sess.run(None, {input_name: X_test.astype(np.float32)})[0]

TorchScript pour PyTorch#

Pour les modèles PyTorch, TorchScript permet de sérialiser le modèle dans un format indépendant de Python, utilisable en C++ pour l’inférence haute performance.

Hide code cell source

torchscript_code = """
import torch
import torch.nn as nn

class MonModele(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(20, 64)
        self.fc2 = nn.Linear(64, 2)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        return self.fc2(x)

model = MonModele()
model.eval()

# Méthode 1 : Tracing (avec un exemple d'entrée)
example_input = torch.randn(1, 20)
traced_model = torch.jit.trace(model, example_input)
traced_model.save("model_traced.pt")

# Méthode 2 : Scripting (analyse statique du code)
scripted_model = torch.jit.script(model)
scripted_model.save("model_scripted.pt")

# Chargement (fonctionne aussi en C++)
loaded = torch.jit.load("model_traced.pt")
prediction = loaded(example_input)
"""
print("Code TorchScript :")
print(torchscript_code)
Code TorchScript :

import torch
import torch.nn as nn

class MonModele(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(20, 64)
        self.fc2 = nn.Linear(64, 2)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        return self.fc2(x)

model = MonModele()
model.eval()

# Méthode 1 : Tracing (avec un exemple d'entrée)
example_input = torch.randn(1, 20)
traced_model = torch.jit.trace(model, example_input)
traced_model.save("model_traced.pt")

# Méthode 2 : Scripting (analyse statique du code)
scripted_model = torch.jit.script(model)
scripted_model.save("model_scripted.pt")

# Chargement (fonctionne aussi en C++)
loaded = torch.jit.load("model_traced.pt")
prediction = loaded(example_input)

Hide code cell source

# Comparaison des formats de sérialisation
fig, ax = plt.subplots(figsize=(10, 5))

formats = ["pickle /\njoblib", "ONNX", "TorchScript", "SavedModel\n(TF)"]
criteres = ["Simplicité", "Interopérabilité", "Performance\ninférence",
            "Sécurité", "Portabilité"]
scores = np.array([
    [5, 2, 2, 1, 1],   # pickle
    [3, 5, 5, 4, 5],   # ONNX
    [3, 3, 5, 4, 4],   # TorchScript
    [3, 3, 5, 4, 4],   # SavedModel
])

im = ax.imshow(scores, cmap='YlGnBu', aspect='auto', vmin=0, vmax=5)
ax.set_xticks(range(len(criteres))); ax.set_xticklabels(criteres, fontsize=9)
ax.set_yticks(range(len(formats))); ax.set_yticklabels(formats, fontsize=9)
for i in range(len(formats)):
    for j in range(len(criteres)):
        ax.text(j, i, str(scores[i, j]), ha='center', va='center',
                fontsize=12, fontweight='bold',
                color='white' if scores[i, j] > 3 else 'black')
ax.set_title("Comparaison des formats de sérialisation (score sur 5)")
plt.colorbar(im, ax=ax, shrink=0.8)
plt.tight_layout()
plt.show()
_images/8bb5df4282d0824150fbd6d510541d70bdd44e6db211496f99cef80eb36d8495.png

API de prédiction#

Une fois le modèle sérialisé, il faut l’exposer via une API (Application Programming Interface) pour que d’autres systèmes puissent envoyer des requêtes et recevoir des prédictions. Les API REST sont le standard de facto pour le serving de modèles ML.

Flask : API minimale#

Flask est un micro-framework web Python qui permet de créer une API de prédiction en quelques lignes.

Définition 318 (API REST)

Une API REST (Representational State Transfer) est une interface basée sur le protocole HTTP permettant à des clients d’interagir avec un serveur via des requêtes standardisées (GET, POST, PUT, DELETE). Dans le contexte du ML, un client envoie des données d’entrée via une requête POST et reçoit les prédictions en réponse au format JSON.

Hide code cell source

flask_code = """
# app_flask.py
from flask import Flask, request, jsonify
import joblib
import numpy as np

app = Flask(__name__)
model = joblib.load("model_joblib.pkl")

@app.route("/predict", methods=["POST"])
def predict():
    data = request.get_json()
    X = np.array(data["features"]).reshape(1, -1)
    prediction = model.predict(X).tolist()
    probability = model.predict_proba(X).tolist()
    return jsonify({
        "prediction": prediction,
        "probability": probability
    })

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "healthy"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
"""
print("Application Flask minimale :")
print(flask_code)
Application Flask minimale :

# app_flask.py
from flask import Flask, request, jsonify
import joblib
import numpy as np

app = Flask(__name__)
model = joblib.load("model_joblib.pkl")

@app.route("/predict", methods=["POST"])
def predict():
    data = request.get_json()
    X = np.array(data["features"]).reshape(1, -1)
    prediction = model.predict(X).tolist()
    probability = model.predict_proba(X).tolist()
    return jsonify({
        "prediction": prediction,
        "probability": probability
    })

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "healthy"})

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

FastAPI : API moderne et asynchrone#

FastAPI est un framework moderne qui offre la validation automatique des données via Pydantic, la documentation interactive (Swagger/OpenAPI) et le support natif de l’asynchrone.

Hide code cell source

fastapi_code = """
# app_fastapi.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
import joblib
import numpy as np
from typing import List

# Schéma de requête
class PredictionRequest(BaseModel):
    features: List[float] = Field(
        ...,
        min_length=20,
        max_length=20,
        description="Vecteur de 20 features numériques"
    )

    class Config:
        json_schema_extra = {
            "example": {
                "features": [0.1] * 20
            }
        }

# Schéma de réponse
class PredictionResponse(BaseModel):
    prediction: int
    probability: List[float]
    model_version: str

# Application
app = FastAPI(
    title="API de prédiction ML",
    description="Service de classification binaire",
    version="1.0.0"
)

# Chargement du modèle au démarrage
model = joblib.load("model_joblib.pkl")
MODEL_VERSION = "1.0.0"

@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
    try:
        X = np.array(request.features).reshape(1, -1)
        prediction = int(model.predict(X)[0])
        probability = model.predict_proba(X)[0].tolist()
        return PredictionResponse(
            prediction=prediction,
            probability=probability,
            model_version=MODEL_VERSION
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/health")
async def health():
    return {"status": "healthy", "model_version": MODEL_VERSION}
"""
print("Application FastAPI avec validation :")
print(fastapi_code)
Application FastAPI avec validation :

# app_fastapi.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
import joblib
import numpy as np
from typing import List

# Schéma de requête
class PredictionRequest(BaseModel):
    features: List[float] = Field(
        ...,
        min_length=20,
        max_length=20,
        description="Vecteur de 20 features numériques"
    )

    class Config:
        json_schema_extra = {
            "example": {
                "features": [0.1] * 20
            }
        }

# Schéma de réponse
class PredictionResponse(BaseModel):
    prediction: int
    probability: List[float]
    model_version: str

# Application
app = FastAPI(
    title="API de prédiction ML",
    description="Service de classification binaire",
    version="1.0.0"
)

# Chargement du modèle au démarrage
model = joblib.load("model_joblib.pkl")
MODEL_VERSION = "1.0.0"

@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
    try:
        X = np.array(request.features).reshape(1, -1)
        prediction = int(model.predict(X)[0])
        probability = model.predict_proba(X)[0].tolist()
        return PredictionResponse(
            prediction=prediction,
            probability=probability,
            model_version=MODEL_VERSION
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/health")
async def health():
    return {"status": "healthy", "model_version": MODEL_VERSION}

Exemple 46 (Test de l’API avec curl)

Une fois l’API lancée (par exemple avec uvicorn app_fastapi:app --host 0.0.0.0 --port 8000), on peut envoyer une requête de prédiction :

curl -X POST "http://localhost:8000/predict" \
     -H "Content-Type: application/json" \
     -d '{"features": [0.1, -0.3, 0.5, 1.2, -0.8, 0.3, 0.7, -1.1, 0.4, 0.9,
                        -0.2, 0.6, -0.5, 0.1, 0.8, -0.3, 0.4, -0.7, 0.2, 1.0]}'

La documentation interactive est automatiquement disponible à http://localhost:8000/docs.

Hide code cell source

# Comparaison Flask vs FastAPI
fig, ax = plt.subplots(figsize=(9, 5))

criteres_api = ["Simplicité\ninitiale", "Validation\nautomatique",
                "Performance\n(async)", "Documentation\nauto",
                "Typage", "Écosystème"]
flask_scores = [5, 2, 2, 2, 2, 5]
fastapi_scores = [4, 5, 5, 5, 5, 3]

x = np.arange(len(criteres_api))
width = 0.35
bars1 = ax.bar(x - width/2, flask_scores, width, label='Flask',
               color='#4C72B0', edgecolor='white')
bars2 = ax.bar(x + width/2, fastapi_scores, width, label='FastAPI',
               color='#DD8452', edgecolor='white')

ax.set_ylabel("Score (sur 5)")
ax.set_title("Comparaison Flask vs FastAPI pour le serving ML")
ax.set_xticks(x); ax.set_xticklabels(criteres_api, fontsize=9)
ax.legend()
ax.set_ylim(0, 6)
ax.bar_label(bars1, fontsize=9); ax.bar_label(bars2, fontsize=9)
plt.tight_layout()
plt.show()
_images/e21502ad841b21ab8c124d0d43979b38519ddb1df59e7dd4a681d0846329e1d7.png

Conteneurisation#

Docker pour le ML#

La conteneurisation avec Docker résout le problème classique du « ça marche sur ma machine ». Un conteneur encapsule l’application, ses dépendances et sa configuration dans une image reproductible et portable.

Définition 319 (Conteneur Docker)

Un conteneur Docker est une unité logicielle légère et isolée qui empaquette une application et toutes ses dépendances (bibliothèques, runtime, fichiers de configuration) dans une image immuable. Contrairement à une machine virtuelle, un conteneur partage le noyau du système d’exploitation hôte, ce qui le rend beaucoup plus léger :

\[\text{Image Docker} = \text{Code} + \text{Dépendances} + \text{Runtime} + \text{Configuration}\]

Hide code cell source

dockerfile_code = """
# Dockerfile pour un service de prédiction ML
# Étape 1 : image de base
FROM python:3.11-slim

# Métadonnées
LABEL maintainer="equipe-ml@entreprise.fr"
LABEL version="1.0.0"

# Variables d'environnement
ENV PYTHONUNBUFFERED=1
ENV MODEL_PATH=/app/models/model.pkl

# Répertoire de travail
WORKDIR /app

# Copier les dépendances en premier (cache Docker)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copier le code source
COPY app/ ./app/
COPY models/ ./models/

# Exposer le port
EXPOSE 8000

# Vérification de santé
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \\
    CMD curl -f http://localhost:8000/health || exit 1

# Commande de lancement
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
"""
print("Dockerfile pour service de prédiction :")
print(dockerfile_code)
Dockerfile pour service de prédiction :

# Dockerfile pour un service de prédiction ML
# Étape 1 : image de base
FROM python:3.11-slim

# Métadonnées
LABEL maintainer="equipe-ml@entreprise.fr"
LABEL version="1.0.0"

# Variables d'environnement
ENV PYTHONUNBUFFERED=1
ENV MODEL_PATH=/app/models/model.pkl

# Répertoire de travail
WORKDIR /app

# Copier les dépendances en premier (cache Docker)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copier le code source
COPY app/ ./app/
COPY models/ ./models/

# Exposer le port
EXPOSE 8000

# Vérification de santé
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

# Commande de lancement
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Multi-stage builds#

Les builds multi-étapes permettent de réduire la taille de l’image finale en séparant l’environnement de construction de l’environnement d’exécution.

Hide code cell source

multistage_code = """
# Dockerfile multi-stage
# --- Étape de construction ---
FROM python:3.11-slim AS builder

WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# --- Étape finale (image légère) ---
FROM python:3.11-slim AS runtime

WORKDIR /app
COPY --from=builder /install /usr/local
COPY app/ ./app/
COPY models/ ./models/

# Utilisateur non-root pour la sécurité
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
"""
print("Dockerfile multi-stage :")
print(multistage_code)
Dockerfile multi-stage :

# Dockerfile multi-stage
# --- Étape de construction ---
FROM python:3.11-slim AS builder

WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# --- Étape finale (image légère) ---
FROM python:3.11-slim AS runtime

WORKDIR /app
COPY --from=builder /install /usr/local
COPY app/ ./app/
COPY models/ ./models/

# Utilisateur non-root pour la sécurité
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Docker Compose pour les services ML#

En pratique, un système ML en production comporte plusieurs services (API, base de données de features, monitoring, etc.) que Docker Compose permet d’orchestrer localement.

Hide code cell source

compose_code = """
# docker-compose.yml
version: "3.8"

services:
  # Service de prédiction
  prediction-api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - MODEL_PATH=/app/models/model.pkl
      - LOG_LEVEL=info
    volumes:
      - ./models:/app/models:ro
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  # Serveur MLflow pour le suivi
  mlflow:
    image: ghcr.io/mlflow/mlflow:latest
    ports:
      - "5000:5000"
    environment:
      - MLFLOW_BACKEND_STORE_URI=sqlite:///mlflow.db
    volumes:
      - mlflow-data:/mlflow
    command: mlflow server --host 0.0.0.0

  # Prometheus pour le monitoring
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro

volumes:
  mlflow-data:
"""
print("Docker Compose pour un système ML :")
print(compose_code)
Docker Compose pour un système ML :

# docker-compose.yml
version: "3.8"

services:
  # Service de prédiction
  prediction-api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - MODEL_PATH=/app/models/model.pkl
      - LOG_LEVEL=info
    volumes:
      - ./models:/app/models:ro
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  # Serveur MLflow pour le suivi
  mlflow:
    image: ghcr.io/mlflow/mlflow:latest
    ports:
      - "5000:5000"
    environment:
      - MLFLOW_BACKEND_STORE_URI=sqlite:///mlflow.db
    volumes:
      - mlflow-data:/mlflow
    command: mlflow server --host 0.0.0.0

  # Prometheus pour le monitoring
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro

volumes:
  mlflow-data:

Remarque 268

Pour l’inférence GPU, il faut utiliser une image de base avec le support CUDA (par exemple nvidia/cuda:12.2-runtime-ubuntu22.04) et le NVIDIA Container Toolkit. La commande de lancement devient docker run --gpus all .... Les images GPU sont considérablement plus volumineuses (plusieurs Go).

Suivi des expériences#

Le problème de la reproductibilité#

Sans un système de suivi rigoureux, les expériences de machine learning deviennent rapidement ingérables : quel jeu d’hyperparamètres a produit le meilleur modèle ? Quelles données ont été utilisées ? Quelle version du code ?

Définition 320 (Suivi des expériences)

Le suivi des expériences (experiment tracking) consiste à enregistrer systématiquement, pour chaque exécution d’un pipeline ML :

  • Les paramètres : hyperparamètres, configuration du prétraitement.

  • Les métriques : performances sur les ensembles de validation et de test.

  • Les artefacts : modèle sérialisé, courbes d’apprentissage, matrices de confusion.

  • Les métadonnées : version du code, horodatage, environnement d’exécution.

MLflow#

MLflow est une plateforme open-source de gestion du cycle de vie ML. Son module Tracking permet d’enregistrer et de comparer les expériences de manière structurée.

Hide code cell source

mlflow_code = """
import mlflow
import mlflow.sklearn
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.model_selection import cross_val_score
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score

# Configurer le serveur de suivi
mlflow.set_tracking_uri("http://localhost:5000")
mlflow.set_experiment("classification-binaire")

# Dictionnaire d'expériences à comparer
experiments = {
    "RandomForest_50": RandomForestClassifier(n_estimators=50, random_state=42),
    "RandomForest_100": RandomForestClassifier(n_estimators=100, random_state=42),
    "GBM_100": GradientBoostingClassifier(n_estimators=100, random_state=42),
    "GBM_200": GradientBoostingClassifier(n_estimators=200, learning_rate=0.05,
                                           random_state=42),
}

for name, model in experiments.items():
    with mlflow.start_run(run_name=name):
        # Enregistrer les paramètres
        mlflow.log_params(model.get_params())

        # Entraîner
        model.fit(X_train, y_train)
        y_pred = model.predict(X_test)

        # Calculer et enregistrer les métriques
        metrics = {
            "accuracy": accuracy_score(y_test, y_pred),
            "f1": f1_score(y_test, y_pred),
            "precision": precision_score(y_test, y_pred),
            "recall": recall_score(y_test, y_pred),
        }
        mlflow.log_metrics(metrics)

        # Enregistrer le modèle comme artefact
        mlflow.sklearn.log_model(model, "model")

        print(f"{name}: accuracy={metrics['accuracy']:.4f}")
"""
print("Suivi d'expériences avec MLflow :")
print(mlflow_code)
Suivi d'expériences avec MLflow :

import mlflow
import mlflow.sklearn
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.model_selection import cross_val_score
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score

# Configurer le serveur de suivi
mlflow.set_tracking_uri("http://localhost:5000")
mlflow.set_experiment("classification-binaire")

# Dictionnaire d'expériences à comparer
experiments = {
    "RandomForest_50": RandomForestClassifier(n_estimators=50, random_state=42),
    "RandomForest_100": RandomForestClassifier(n_estimators=100, random_state=42),
    "GBM_100": GradientBoostingClassifier(n_estimators=100, random_state=42),
    "GBM_200": GradientBoostingClassifier(n_estimators=200, learning_rate=0.05,
                                           random_state=42),
}

for name, model in experiments.items():
    with mlflow.start_run(run_name=name):
        # Enregistrer les paramètres
        mlflow.log_params(model.get_params())

        # Entraîner
        model.fit(X_train, y_train)
        y_pred = model.predict(X_test)

        # Calculer et enregistrer les métriques
        metrics = {
            "accuracy": accuracy_score(y_test, y_pred),
            "f1": f1_score(y_test, y_pred),
            "precision": precision_score(y_test, y_pred),
            "recall": recall_score(y_test, y_pred),
        }
        mlflow.log_metrics(metrics)

        # Enregistrer le modèle comme artefact
        mlflow.sklearn.log_model(model, "model")

        print(f"{name}: accuracy={metrics['accuracy']:.4f}")

Hide code cell source

# Simulation locale du suivi d'expériences (sans serveur MLflow)
from sklearn.ensemble import GradientBoostingClassifier

experiences = {
    "RF_50": RandomForestClassifier(n_estimators=50, random_state=42),
    "RF_100": RandomForestClassifier(n_estimators=100, random_state=42),
    "RF_200": RandomForestClassifier(n_estimators=200, random_state=42),
    "GBM_100": GradientBoostingClassifier(n_estimators=100, random_state=42),
    "GBM_200": GradientBoostingClassifier(n_estimators=200, learning_rate=0.05,
                                           random_state=42),
}

resultats = {}
for name, clf in experiences.items():
    clf.fit(X_train, y_train)
    y_pred = clf.predict(X_test)
    resultats[name] = {
        "accuracy": accuracy_score(y_test, y_pred),
        "n_estimators": clf.n_estimators,
    }

# Visualisation des résultats
fig, ax = plt.subplots(figsize=(9, 5))
noms = list(resultats.keys())
accs = [resultats[n]["accuracy"] for n in noms]
n_est = [resultats[n]["n_estimators"] for n in noms]

colors_exp = ['#4C72B0' if 'RF' in n else '#DD8452' for n in noms]
bars = ax.bar(noms, accs, color=colors_exp, edgecolor='white', linewidth=1.2)
ax.set_ylabel("Accuracy")
ax.set_title("Comparaison des expériences (simulation du tableau MLflow)")
ax.set_ylim(min(accs) - 0.02, max(accs) + 0.02)
ax.bar_label(bars, fmt='%.4f', fontsize=9)

for i, (n, ne) in enumerate(zip(noms, n_est)):
    ax.text(i, min(accs) - 0.015, f"n={ne}", ha='center', fontsize=8, color='gray')
plt.tight_layout()
plt.show()
_images/d5eec63d1c662544ce004fb9762aa67f252c57f9d75275c8aef24aab6565d579.png

Weights & Biases (W&B)#

Remarque 269

Weights & Biases (W&B) est une plateforme commerciale (avec un tier gratuit) offrant des fonctionnalités similaires à MLflow avec une interface web particulièrement soignée. W&B excelle dans la visualisation interactive des expériences, le suivi des ressources GPU, et la collaboration en équipe. L’intégration se fait en quelques lignes :

import wandb
wandb.init(project="mon-projet", config=hyperparameters)
wandb.log({"loss": loss, "accuracy": accuracy})
wandb.finish()

Le choix entre MLflow (open-source, auto-hébergé) et W&B (SaaS, interface riche) dépend des contraintes de l’organisation : souveraineté des données, budget, infrastructure existante.

Pipelines CI/CD pour le ML#

Tests du code ML#

Le code de machine learning nécessite des tests spécifiques au-delà des tests unitaires classiques : vérification de la forme des données, des plages de valeurs des prédictions, de la reproductibilité.

Définition 321 (CI/CD pour le ML)

L”intégration continue (Continuous Integration, CI) pour le ML consiste à exécuter automatiquement, à chaque modification du code, un ensemble de tests et de validations :

  1. Tests unitaires : fonctions de prétraitement, feature engineering.

  2. Tests d’intégration : pipeline complet sur un petit jeu de données.

  3. Tests du modèle : performance minimale, absence de régression.

  4. Tests de l’API : endpoints, schémas de requête/réponse.

Le déploiement continu (Continuous Deployment, CD) automatise la mise en production après validation.

Hide code cell source

test_code = """
# tests/test_model.py
import pytest
import numpy as np
import joblib

@pytest.fixture
def model():
    return joblib.load("models/model.pkl")

@pytest.fixture
def sample_input():
    return np.random.randn(1, 20)

class TestModel:
    def test_prediction_shape(self, model, sample_input):
        \"\"\"La prédiction doit être un scalaire.\"\"\"
        pred = model.predict(sample_input)
        assert pred.shape == (1,)

    def test_prediction_values(self, model, sample_input):
        \"\"\"La prédiction doit être 0 ou 1.\"\"\"
        pred = model.predict(sample_input)
        assert pred[0] in [0, 1]

    def test_probability_range(self, model, sample_input):
        \"\"\"Les probabilités doivent être dans [0, 1].\"\"\"
        proba = model.predict_proba(sample_input)
        assert np.all(proba >= 0) and np.all(proba <= 1)
        assert np.isclose(proba.sum(), 1.0)

    def test_minimum_accuracy(self, model):
        \"\"\"Le modèle doit atteindre au moins 80% de précision.\"\"\"
        from sklearn.datasets import make_classification
        X, y = make_classification(n_samples=500, n_features=20,
                                   n_informative=10, random_state=99)
        acc = (model.predict(X) == y).mean()
        assert acc > 0.80, f"Précision insuffisante : {acc:.4f}"

    def test_prediction_deterministic(self, model, sample_input):
        \"\"\"Les prédictions doivent être déterministes.\"\"\"
        pred1 = model.predict(sample_input)
        pred2 = model.predict(sample_input)
        np.testing.assert_array_equal(pred1, pred2)
"""
print("Tests du modèle avec pytest :")
print(test_code)
Tests du modèle avec pytest :

# tests/test_model.py
import pytest
import numpy as np
import joblib

@pytest.fixture
def model():
    return joblib.load("models/model.pkl")

@pytest.fixture
def sample_input():
    return np.random.randn(1, 20)

class TestModel:
    def test_prediction_shape(self, model, sample_input):
        """La prédiction doit être un scalaire."""
        pred = model.predict(sample_input)
        assert pred.shape == (1,)

    def test_prediction_values(self, model, sample_input):
        """La prédiction doit être 0 ou 1."""
        pred = model.predict(sample_input)
        assert pred[0] in [0, 1]

    def test_probability_range(self, model, sample_input):
        """Les probabilités doivent être dans [0, 1]."""
        proba = model.predict_proba(sample_input)
        assert np.all(proba >= 0) and np.all(proba <= 1)
        assert np.isclose(proba.sum(), 1.0)

    def test_minimum_accuracy(self, model):
        """Le modèle doit atteindre au moins 80% de précision."""
        from sklearn.datasets import make_classification
        X, y = make_classification(n_samples=500, n_features=20,
                                   n_informative=10, random_state=99)
        acc = (model.predict(X) == y).mean()
        assert acc > 0.80, f"Précision insuffisante : {acc:.4f}"

    def test_prediction_deterministic(self, model, sample_input):
        """Les prédictions doivent être déterministes."""
        pred1 = model.predict(sample_input)
        pred2 = model.predict(sample_input)
        np.testing.assert_array_equal(pred1, pred2)

GitHub Actions pour le ML#

Hide code cell source

github_actions_code = """
# .github/workflows/ml-pipeline.yml
name: Pipeline ML CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

      - name: Configurer Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Installer les dépendances
        run: pip install -r requirements.txt

      - name: Lancer les tests unitaires
        run: pytest tests/ -v --tb=short

      - name: Vérifier la qualité du code
        run: |
          ruff check .
          mypy app/ --ignore-missing-imports

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

      - name: Configurer Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Installer les dépendances
        run: pip install -r requirements.txt

      - name: Entraîner le modèle
        run: python scripts/train.py

      - name: Valider le modèle
        run: python scripts/validate.py --min-accuracy 0.85

      - name: Sauvegarder le modèle
        uses: actions/upload-artifact@v4
        with:
          name: model
          path: models/model.pkl

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

      - name: Télécharger le modèle
        uses: actions/download-artifact@v4
        with:
          name: model
          path: models/

      - name: Construire l'image Docker
        run: docker build -t ml-api:${{ github.sha }} .

      - name: Pousser vers le registre
        run: |
          docker tag ml-api:${{ github.sha }} registry.example.com/ml-api:latest
          docker push registry.example.com/ml-api:latest
"""
print("Pipeline GitHub Actions pour le ML :")
print(github_actions_code)
Pipeline GitHub Actions pour le ML :

# .github/workflows/ml-pipeline.yml
name: Pipeline ML CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

      - name: Configurer Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Installer les dépendances
        run: pip install -r requirements.txt

      - name: Lancer les tests unitaires
        run: pytest tests/ -v --tb=short

      - name: Vérifier la qualité du code
        run: |
          ruff check .
          mypy app/ --ignore-missing-imports

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

      - name: Configurer Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Installer les dépendances
        run: pip install -r requirements.txt

      - name: Entraîner le modèle
        run: python scripts/train.py

      - name: Valider le modèle
        run: python scripts/validate.py --min-accuracy 0.85

      - name: Sauvegarder le modèle
        uses: actions/upload-artifact@v4
        with:
          name: model
          path: models/model.pkl

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

      - name: Télécharger le modèle
        uses: actions/download-artifact@v4
        with:
          name: model
          path: models/

      - name: Construire l'image Docker
        run: docker build -t ml-api:${{ github.sha }} .

      - name: Pousser vers le registre
        run: |
          docker tag ml-api:${{ github.sha }} registry.example.com/ml-api:latest
          docker push registry.example.com/ml-api:latest

Hide code cell source

# Illustration : pipeline CI/CD pour le ML
fig, ax = plt.subplots(figsize=(14, 4))
ax.set_xlim(-0.5, 6.5); ax.set_ylim(-1.5, 2)
ax.axis('off')

etapes = [
    ("Code\npush", '#4C72B0'),
    ("Tests\nunitaires", '#55A868'),
    ("Entraînement\ndu modèle", '#DD8452'),
    ("Validation\ndu modèle", '#8B6DAF'),
    ("Construction\nimage Docker", '#E24A33'),
    ("Déploiement\nstaging", '#55A868'),
    ("Déploiement\nproduction", '#4C72B0'),
]

for i, (label, color) in enumerate(etapes):
    circle = plt.Circle((i, 0.5), 0.4, color=color, alpha=0.8)
    ax.add_patch(circle)
    ax.text(i, 0.5, str(i+1), ha='center', va='center',
            fontsize=14, fontweight='bold', color='white')
    ax.text(i, -0.3, label, ha='center', va='center', fontsize=8)
    if i < len(etapes) - 1:
        ax.annotate('', xy=(i+0.55, 0.5), xytext=(i+0.45, 0.5),
                    arrowprops=dict(arrowstyle='->', color='gray', lw=2))

ax.set_title("Pipeline CI/CD pour le Machine Learning", fontsize=12, pad=15)
plt.tight_layout()
plt.show()
_images/9f3101cb4967c12db83eaea74ab4d4f9566f5329dec73d35904191eefb27e5ee.png

Monitoring en production#

Dérive des données et dérive conceptuelle#

Un modèle en production interagit avec des données vivantes qui évoluent au fil du temps. Deux types de dérive menacent les performances :

Définition 322 (Dérive des données et dérive conceptuelle)

La dérive des données (data drift ou covariate shift) se produit lorsque la distribution des variables d’entrée change au cours du temps :

\[P_t(\mathbf{x}) \neq P_{\text{train}}(\mathbf{x})\]

La dérive conceptuelle (concept drift) se produit lorsque la relation entre les entrées et la cible change :

\[P_t(y \mid \mathbf{x}) \neq P_{\text{train}}(y \mid \mathbf{x})\]

La dérive des données peut être détectée par des tests statistiques sur les distributions d’entrée. La dérive conceptuelle est plus insidieuse car elle nécessite des données étiquetées en production.

Hide code cell source

# Simulation de data drift
np.random.seed(42)

# Données d'entraînement
n_train = 1000
X_train_drift = np.random.normal(loc=0, scale=1, size=n_train)

# Données en production (dérive progressive)
n_mois = 6
n_par_mois = 200

fig, axes = plt.subplots(2, 3, figsize=(14, 8))
axes = axes.ravel()

for i in range(n_mois):
    drift = i * 0.4  # dérive progressive de la moyenne
    X_prod = np.random.normal(loc=drift, scale=1 + i*0.1, size=n_par_mois)

    ax = axes[i]
    ax.hist(X_train_drift, bins=30, alpha=0.5, density=True,
            color='#4C72B0', label='Entraînement')
    ax.hist(X_prod, bins=30, alpha=0.5, density=True,
            color='#E24A33', label=f'Production')
    ax.set_title(f"Mois {i+1} (dérive μ={drift:.1f})", fontsize=10)
    ax.legend(fontsize=8)
    ax.set_xlim(-5, 6)

plt.suptitle("Simulation de dérive des données au fil du temps", fontsize=13, y=1.02)
plt.tight_layout()
plt.show()
_images/0ba4c02dc8581af59a7e0eccc34e9134c2983abb51ffc2be7e3922ae50310f3e.png

Détection de dérive par test statistique#

Hide code cell source

from scipy import stats

np.random.seed(42)

# Distribution d'entraînement
reference = np.random.normal(loc=0, scale=1, size=1000)

# Simuler des fenêtres de production avec dérive progressive
mois = list(range(1, 13))
p_values_ks = []
statistiques_ks = []

for m in mois:
    drift = (m - 1) * 0.15
    production = np.random.normal(loc=drift, scale=1.0, size=200)
    stat, p_val = stats.ks_2samp(reference, production)
    p_values_ks.append(p_val)
    statistiques_ks.append(stat)

# Visualisation
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# p-value au fil du temps
ax1.plot(mois, p_values_ks, 'o-', color='#4C72B0', linewidth=2, markersize=8)
ax1.axhline(y=0.05, color='#E24A33', linestyle='--', linewidth=2,
            label='Seuil α = 0.05')
ax1.fill_between(mois, 0, 0.05, alpha=0.1, color='#E24A33')
ax1.set_xlabel("Mois")
ax1.set_ylabel("p-value (test de Kolmogorov-Smirnov)")
ax1.set_title("Détection de data drift par test KS")
ax1.legend()
ax1.set_yscale('log')
ax1.set_ylim(1e-6, 1.5)

# Statistique KS
ax2.bar(mois, statistiques_ks, color=['#55A868' if p > 0.05 else '#E24A33'
        for p in p_values_ks], edgecolor='white')
ax2.axhline(y=0.1, color='gray', linestyle=':', alpha=0.5)
ax2.set_xlabel("Mois")
ax2.set_ylabel("Statistique KS")
ax2.set_title("Magnitude de la dérive (vert = OK, rouge = alerte)")

plt.tight_layout()
plt.show()

# Résumé
seuil = 0.05
mois_alerte = [m for m, p in zip(mois, p_values_ks) if p < seuil]
print(f"Alerte de dérive détectée aux mois : {mois_alerte}")
_images/62300f4f0638109d741987d94087cebf9ebe9dd82f80cf970c32e412a8d95e5f.png
Alerte de dérive détectée aux mois : [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

Exemple 47 (Stratégies de détection de dérive)

Plusieurs approches complémentaires permettent de détecter la dérive :

  • Test de Kolmogorov-Smirnov : compare deux distributions empiriques, sensible aux écarts de forme et de localisation.

  • Population Stability Index (PSI) : mesure largement utilisée dans le secteur bancaire pour détecter les changements de distribution.

  • Test du \(\chi^2\) : adapté aux variables catégorielles.

  • Distance de Wasserstein : mesure de transport optimal entre distributions.

  • Monitoring des performances : suivi de l’accuracy, du F1, ou d’une métrique métier au fil du temps (nécessite des étiquettes).

Hide code cell source

# Implémentation du PSI (Population Stability Index)
def compute_psi(reference, production, n_bins=10):
    """Calcule le Population Stability Index entre deux distributions."""
    # Discrétiser en bins basés sur la distribution de référence
    breakpoints = np.percentile(reference, np.linspace(0, 100, n_bins + 1))
    breakpoints[0] = -np.inf
    breakpoints[-1] = np.inf

    ref_counts = np.histogram(reference, bins=breakpoints)[0] / len(reference)
    prod_counts = np.histogram(production, bins=breakpoints)[0] / len(production)

    # Éviter les divisions par zéro
    ref_counts = np.clip(ref_counts, 1e-6, None)
    prod_counts = np.clip(prod_counts, 1e-6, None)

    psi = np.sum((prod_counts - ref_counts) * np.log(prod_counts / ref_counts))
    return psi

# Calculer le PSI pour chaque mois
np.random.seed(42)
reference_psi = np.random.normal(loc=0, scale=1, size=1000)

psi_values = []
for m in mois:
    drift = (m - 1) * 0.15
    production = np.random.normal(loc=drift, scale=1.0, size=200)
    psi_val = compute_psi(reference_psi, production)
    psi_values.append(psi_val)

fig, ax = plt.subplots(figsize=(10, 5))
colors_psi = ['#55A868' if p < 0.1 else '#DD8452' if p < 0.25 else '#E24A33'
              for p in psi_values]
bars = ax.bar(mois, psi_values, color=colors_psi, edgecolor='white', linewidth=1.2)
ax.axhline(y=0.1, color='#DD8452', linestyle='--', label='PSI = 0.1 (attention)')
ax.axhline(y=0.25, color='#E24A33', linestyle='--', label='PSI = 0.25 (alerte)')
ax.set_xlabel("Mois")
ax.set_ylabel("PSI")
ax.set_title("Population Stability Index au fil du temps")
ax.legend()
ax.bar_label(bars, fmt='%.3f', fontsize=8)
plt.tight_layout()
plt.show()

print("Interprétation du PSI :")
print("  PSI < 0.10 : pas de changement significatif")
print("  0.10 ≤ PSI < 0.25 : changement modéré, surveillance recommandée")
print("  PSI ≥ 0.25 : changement significatif, réentraînement recommandé")
_images/dad99b89f8f39d65982a63124ab475d4231b26ceeefeea29ec384b965c8eb14b.png
Interprétation du PSI :
  PSI < 0.10 : pas de changement significatif
  0.10 ≤ PSI < 0.25 : changement modéré, surveillance recommandée
  PSI ≥ 0.25 : changement significatif, réentraînement recommandé

Stratégies d’alerte#

Remarque 270

Un système de monitoring efficace doit aller au-delà de la simple détection de dérive. Il doit intégrer :

  1. Des seuils d’alerte hiérarchisés : avertissement, alerte critique, action automatique.

  2. Des fenêtres temporelles adaptées : dérive lente (semaines) vs dérive brutale (heures).

  3. Des dashboards temps réel : Grafana, Kibana, ou solutions spécialisées (Evidently AI, Whylabs).

  4. Des procédures de réponse : réentraînement automatique, rollback vers un modèle précédent, alerte humaine.

La règle d’or est de monitorer autant la qualité des données d’entrée que les performances du modèle, car la dérive des données est souvent le signe avant-coureur de la dégradation des performances.

Hide code cell source

# Simulation d'un dashboard de monitoring simplifié
np.random.seed(42)
jours = np.arange(1, 91)

# Simuler des métriques de production
accuracy_prod = 0.92 - 0.001 * jours + 0.01 * np.random.randn(len(jours))
accuracy_prod = np.clip(accuracy_prod, 0.5, 1.0)

latency_ms = 45 + 0.2 * jours + 5 * np.random.randn(len(jours))
latency_ms = np.clip(latency_ms, 10, 200)

requests_per_hour = 1000 + 200 * np.sin(2 * np.pi * jours / 7) + \
                    50 * np.random.randn(len(jours))
requests_per_hour = np.clip(requests_per_hour, 0, 2000)

fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)

# Accuracy
axes[0].plot(jours, accuracy_prod, color='#4C72B0', linewidth=1.5, alpha=0.8)
axes[0].axhline(y=0.85, color='#E24A33', linestyle='--', label='Seuil min')
axes[0].fill_between(jours, 0.85, accuracy_prod,
                     where=accuracy_prod < 0.85, alpha=0.3, color='#E24A33')
axes[0].set_ylabel("Accuracy")
axes[0].set_title("Dashboard de monitoring en production")
axes[0].legend()
axes[0].set_ylim(0.75, 1.0)

# Latence
axes[1].plot(jours, latency_ms, color='#DD8452', linewidth=1.5, alpha=0.8)
axes[1].axhline(y=100, color='#E24A33', linestyle='--', label='SLA 100ms')
axes[1].fill_between(jours, latency_ms, 100,
                     where=latency_ms > 100, alpha=0.3, color='#E24A33')
axes[1].set_ylabel("Latence (ms)")
axes[1].legend()

# Volume de requêtes
axes[2].fill_between(jours, 0, requests_per_hour,
                     alpha=0.4, color='#55A868')
axes[2].plot(jours, requests_per_hour, color='#55A868', linewidth=1.5)
axes[2].set_ylabel("Requêtes / heure")
axes[2].set_xlabel("Jours en production")

plt.tight_layout()
plt.show()
_images/3ae9159fc66eba51ec2171771b292d575c10c67f94d0934e298c4fdbd249b479.png

Infrastructure et orchestration#

Vue d’ensemble#

Pour les déploiements à grande échelle, au-delà de Docker Compose, des outils d’orchestration et des services cloud gérés prennent le relais.

Définition 323 (Orchestration de conteneurs)

L”orchestration de conteneurs est le processus automatisé de gestion, mise à l’échelle et mise en réseau de conteneurs. Kubernetes (K8s) est le standard de facto. Il gère :

  • Le scaling automatique : ajuster le nombre de répliques selon la charge.

  • Le load balancing : distribuer les requêtes entre les répliques.

  • Les rolling updates : déployer une nouvelle version sans interruption de service.

  • La self-healing : redémarrer automatiquement les conteneurs défaillants.

Hide code cell source

kubernetes_code = """
# deployment.yaml - Déploiement Kubernetes pour un service ML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ml-prediction-api
  labels:
    app: ml-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: ml-api
  template:
    metadata:
      labels:
        app: ml-api
    spec:
      containers:
      - name: ml-api
        image: registry.example.com/ml-api:latest
        ports:
        - containerPort: 8000
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "1Gi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 10
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 5
          periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: ml-api-service
spec:
  selector:
    app: ml-api
  ports:
  - port: 80
    targetPort: 8000
  type: LoadBalancer
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: ml-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ml-prediction-api
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
"""
print("Manifeste Kubernetes pour un service ML :")
print(kubernetes_code)
Manifeste Kubernetes pour un service ML :

# deployment.yaml - Déploiement Kubernetes pour un service ML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ml-prediction-api
  labels:
    app: ml-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: ml-api
  template:
    metadata:
      labels:
        app: ml-api
    spec:
      containers:
      - name: ml-api
        image: registry.example.com/ml-api:latest
        ports:
        - containerPort: 8000
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "1Gi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 10
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 5
          periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: ml-api-service
spec:
  selector:
    app: ml-api
  ports:
  - port: 80
    targetPort: 8000
  type: LoadBalancer
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: ml-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: ml-prediction-api
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

Services cloud gérés#

Hide code cell source

# Comparaison des services cloud pour le ML
fig, ax = plt.subplots(figsize=(12, 6))

services = [
    "Docker\n+ VM",
    "Kubernetes\n(auto-géré)",
    "AWS\nSageMaker",
    "GCP\nVertex AI",
    "Azure\nML"
]
criteres_cloud = ["Flexibilité", "Simplicité", "Coût\n(petit volume)",
                  "Scalabilité", "Gestion\nML native"]

scores_cloud = np.array([
    [5, 3, 5, 2, 1],   # Docker + VM
    [5, 2, 3, 5, 2],   # Kubernetes
    [3, 4, 2, 5, 5],   # SageMaker
    [3, 4, 2, 5, 5],   # Vertex AI
    [3, 4, 2, 5, 5],   # Azure ML
])

im = ax.imshow(scores_cloud, cmap='RdYlGn', aspect='auto', vmin=1, vmax=5)
ax.set_xticks(range(len(criteres_cloud)))
ax.set_xticklabels(criteres_cloud, fontsize=9)
ax.set_yticks(range(len(services)))
ax.set_yticklabels(services, fontsize=9)

for i in range(len(services)):
    for j in range(len(criteres_cloud)):
        ax.text(j, i, str(scores_cloud[i, j]), ha='center', va='center',
                fontsize=13, fontweight='bold',
                color='white' if scores_cloud[i, j] in [1, 5] else 'black')

ax.set_title("Comparaison des solutions de déploiement ML (score sur 5)")
plt.colorbar(im, ax=ax, shrink=0.8)
plt.tight_layout()
plt.show()
_images/f60841283e2a11147c816219e3cda1a12a04ee1bf828f88a1b7e6075cadf2950.png

Exemple 48 (Quand utiliser quoi ?)

Le choix de l’infrastructure dépend de la maturité de l’équipe, du volume de requêtes et du budget :

  • Docker + VM simple : prototypage, faible trafic (< 100 req/s), petite équipe. Le plus simple et le moins coûteux pour démarrer.

  • Kubernetes : trafic important, besoin de scaling automatique, équipe avec expertise DevOps. Complexe mais très flexible.

  • AWS SageMaker / GCP Vertex AI / Azure ML : équipes souhaitant se concentrer sur le ML sans gérer l’infrastructure. Coût plus élevé mais time-to-market réduit. Idéal pour les organisations déjà dans l’écosystème cloud correspondant.

  • Serverless (AWS Lambda, Cloud Functions) : modèles légers avec trafic sporadique. Facturation à l’usage, pas de serveur à gérer, mais limites sur la taille du modèle et le temps d’exécution.

Hide code cell source

# Arbre de décision simplifié pour le choix d'infrastructure
fig, ax = plt.subplots(figsize=(14, 8))
ax.set_xlim(0, 10); ax.set_ylim(0, 10)
ax.axis('off')
ax.set_title("Guide de choix d'infrastructure pour le déploiement ML", fontsize=13)

# Noeud racine
box_props = dict(boxstyle='round,pad=0.4', facecolor='#4C72B0', alpha=0.8)
leaf_props_a = dict(boxstyle='round,pad=0.4', facecolor='#55A868', alpha=0.7)
leaf_props_b = dict(boxstyle='round,pad=0.4', facecolor='#DD8452', alpha=0.7)

ax.text(5, 9, "Volume de requêtes ?", ha='center', va='center',
        fontsize=11, fontweight='bold', color='white', bbox=box_props)

# Niveau 1
ax.annotate('< 100/s', xy=(3, 8), xytext=(5, 8.5),
            fontsize=9, ha='center', color='gray')
ax.text(2.5, 7.5, "Équipe DevOps ?", ha='center', va='center',
        fontsize=10, fontweight='bold', color='white', bbox=box_props)
ax.annotate('> 100/s', xy=(7, 8), xytext=(5, 8.5),
            fontsize=9, ha='center', color='gray')
ax.text(7.5, 7.5, "Budget cloud ?", ha='center', va='center',
        fontsize=10, fontweight='bold', color='white', bbox=box_props)

# Feuilles gauche
ax.text(1, 6, "Docker\n+ VM", ha='center', va='center',
        fontsize=9, fontweight='bold', color='white', bbox=leaf_props_a)
ax.annotate('Non', xy=(1, 6.5), xytext=(2.5, 7),
            fontsize=8, color='gray')
ax.text(4, 6, "Docker Compose\n+ CI/CD", ha='center', va='center',
        fontsize=9, fontweight='bold', color='white', bbox=leaf_props_b)
ax.annotate('Oui', xy=(4, 6.5), xytext=(2.5, 7),
            fontsize=8, color='gray')

# Feuilles droite
ax.text(6, 6, "Kubernetes\n(auto-géré)", ha='center', va='center',
        fontsize=9, fontweight='bold', color='white', bbox=leaf_props_a)
ax.annotate('Limité', xy=(6, 6.5), xytext=(7.5, 7),
            fontsize=8, color='gray')
ax.text(9, 6, "SageMaker /\nVertex AI", ha='center', va='center',
        fontsize=9, fontweight='bold', color='white', bbox=leaf_props_b)
ax.annotate('Disponible', xy=(9, 6.5), xytext=(7.5, 7),
            fontsize=8, color='gray')

# Lignes
for (x1, y1), (x2, y2) in [
    ((5, 8.7), (2.5, 7.8)), ((5, 8.7), (7.5, 7.8)),
    ((2.5, 7.2), (1, 6.4)), ((2.5, 7.2), (4, 6.4)),
    ((7.5, 7.2), (6, 6.4)), ((7.5, 7.2), (9, 6.4)),
]:
    ax.plot([x1, x2], [y1, y2], 'k-', alpha=0.3, linewidth=1.5)

plt.tight_layout()
plt.show()
_images/79bfe6daa53e30069a79e2185ce19d793e4005297d157b9bb3e5e75a19d4bcb1.png

Résumé#

Ce chapitre a parcouru les étapes essentielles du MLOps, la discipline qui permet de transformer un modèle de machine learning expérimental en un système fiable en production.

  1. La sérialisation convertit un modèle entraîné en un format persistant. pickle et joblib sont simples mais présentent des risques de sécurité. ONNX offre l’interopérabilité entre frameworks et des performances d’inférence optimisées. TorchScript est la solution native de PyTorch pour le déploiement.

  2. Les API de prédiction exposent le modèle via des endpoints HTTP. Flask permet un prototypage rapide, tandis que FastAPI offre la validation automatique, la documentation interactive et les performances asynchrones.

  3. La conteneurisation avec Docker garantit la reproductibilité de l’environnement. Les builds multi-étapes réduisent la taille des images. Docker Compose orchestre les services localement.

  4. Le suivi des expériences avec MLflow ou W&B assure la reproductibilité et la traçabilité de chaque itération du modèle.

  5. Les pipelines CI/CD automatisent les tests, la validation et le déploiement des modèles. Les tests ML spécifiques (forme des prédictions, performances minimales, déterminisme) complètent les tests logiciels classiques.

  6. Le monitoring en production détecte la dérive des données (data drift) et la dérive conceptuelle (concept drift) par des tests statistiques (Kolmogorov-Smirnov, PSI). Les alertes hiérarchisées et les dashboards permettent une réponse rapide.

  7. L”orchestration avec Kubernetes et les services cloud gérés (SageMaker, Vertex AI) permettent le passage à l’échelle, le choix dépendant de la maturité de l’équipe et du budget disponible.

Remarque 271

Le MLOps n’est pas une destination mais un processus d’amélioration continue. Il est préférable de commencer simplement — un modèle sérialisé avec joblib, une API Flask, un script de déploiement — puis de monter en maturité progressivement à mesure que les besoins se précisent. L’erreur la plus courante est de vouloir mettre en place une infrastructure complexe avant même d’avoir un modèle qui apporte de la valeur. Comme le rappelle la sagesse de l’ingénierie logicielle : make it work, make it right, make it fast — dans cet ordre.