Aller au contenu principal

Module 3 — Chargement du modèle au démarrage

Le module 2 a livré une route /predict qui valide son entrée mais renvoie toujours 0.42. Il est temps d'y brancher le vrai modèle enregistré dans MLflow par le cours 20. La façon dont on le charge n'est pas un détail : un service qui recharge son artefact à chaque requête peut tomber d'un facteur 50 en débit, et un service qui découvre à la première requête que le fichier n'existe pas met tout le monde d'accord au pire moment.

L'anti-modèle à bannir immédiatement

Le premier réflexe d'un débutant est celui-ci :

# À NE PAS FAIRE
@app.post("/predict")
def predict(dossier: DossierAbonne):
modele = joblib.load("modele.pkl") # à chaque requête
return modele.predict_proba(dossier.model_dump())

Le chargement d'un RandomForestClassifier sérialisé pèse entre 30 Mo et 2 Go. joblib.load lit le fichier, désérialise l'arbre, alloue la mémoire. Sur un modèle de 300 Mo, cela prend 400 à 800 millisecondes en local, plus si le disque est réseau. Une requête qui devrait durer 5 ms en dure alors 500. Le service tombe à 2 requêtes par seconde et sature le disque.

Pire, cette latence n'apparaît jamais dans les tests unitaires écrits autour de predict : ils utilisent un modèle factice ; la vraie fonction joblib.load n'est appelée qu'en intégration ou en production. On découvre le problème pendant la démonstration.

Le bon patron : lifespan de FastAPI

FastAPI expose depuis la version 0.93 un gestionnaire de contexte asynchrone appelé lifespan, qui remplace les anciens startup et shutdown. Il est exécuté une seule fois, avant que le premier travailleur n'accepte la première requête, et se ferme quand le service s'éteint.

from contextlib import asynccontextmanager
from fastapi import FastAPI
import joblib
import logging

logger = logging.getLogger("api.chargement")

@asynccontextmanager
async def lifespan(app: FastAPI):
# Bloc de démarrage
chemin = "modeles/churn-2026-08-30.pkl"
logger.info("chargement du modèle depuis %s", chemin)
app.state.modele = joblib.load(chemin)
app.state.version_modele = "churn-2026-08-30"
app.state.seuil = 0.5
logger.info("modèle chargé, version=%s", app.state.version_modele)
yield
# Bloc d'arrêt
logger.info("arrêt du service, libération du modèle")
app.state.modele = None

app = FastAPI(lifespan=lifespan)

Trois choses importantes tiennent en dix lignes. D'abord, le modèle vit dans app.state — un porte-objets prévu à cet effet — et pas dans une variable globale de module, ce qui rend les tests plus propres. Ensuite, la version du modèle est collée au modèle chargé : la route la retourne dans chaque réponse (module 2), un opérateur qui regarde une prédiction saura de quel entraînement elle vient. Enfin, le yield sépare proprement démarrage et arrêt : ce qui vient après est exécuté à l'extinction, ce qui permet de libérer un cache, de fermer une connexion ou d'écrire un dernier journal.

Lire le modèle dans la route

Une route accède à app.state via l'objet Request ou une dépendance. La forme la plus lisible passe par une dépendance FastAPI.

from fastapi import Depends, FastAPI, Request

def modele_charge(request: Request):
return request.app.state.modele

@app.post("/predict", response_model=ReponsePrediction)
def predict(
dossier: DossierAbonne,
modele=Depends(modele_charge),
) -> ReponsePrediction:
proba = float(modele.predict_proba([dossier_vers_tableau(dossier)])[0][1])
decision = "à_contacter" if proba >= 0.5 else "à_surveiller"
return ReponsePrediction(
abonne_id=dossier.abonne_id,
probabilite_resiliation=proba,
seuil_utilise=0.5,
decision=decision,
version_modele="churn-2026-08-30",
)

La dépendance rend le code testable : dans un test, on remplace modele_charge par une fonction qui renvoie un modèle factice via l'API app.dependency_overrides, sans monter tout le cycle de démarrage.

Charger depuis MLflow plutôt qu'un fichier local

Le cours 20 a livré le modèle au registre MLflow sous l'alias @champion. La production charge par alias, jamais par identifiant d'exécution, ce qui permet la promotion et le retour arrière sans redéployer le service.

import mlflow
import mlflow.sklearn

@asynccontextmanager
async def lifespan(app: FastAPI):
uri = "models:/churn-telecom@champion"
app.state.modele = mlflow.sklearn.load_model(uri)
info = mlflow.models.get_model_info(uri)
app.state.version_modele = info.run_id
yield

get_model_info donne le run_id, le hash du snapshot de données, la signature ; on les journalise et on les expose sur une route /model/info, précieuse pour l'audit.

Échouer au démarrage plutôt qu'à la première requête

Un principe simple change tout : si le service ne peut pas répondre correctement à sa première requête, il ne doit pas démarrer. Ce principe s'appelle fail fast at startup. Si joblib.load lève, si le fichier n'existe pas, si la version du modèle diffère de la version attendue, le lifespan doit relever l'exception. Uvicorn quitte alors avec un code de sortie non nul, et l'orchestrateur (Kubernetes, systemd, Compose) remarque que le conteneur n'est pas prêt.

@asynccontextmanager
async def lifespan(app: FastAPI):
try:
app.state.modele = joblib.load("modeles/churn.pkl")
except FileNotFoundError:
logger.critical("modèle introuvable, arrêt du service")
raise # laisse Uvicorn tomber, l'orchestrateur redémarre
yield

L'alternative — attraper l'exception et laisser le service tourner sans modèle — donne un service qui répond 500 à chaque requête pendant que la sonde /health affiche fièrement ok. C'est exactement ce que le module 8 apprend à ne pas faire.

Vérifier la signature du modèle contre le contrat public

DossierAbonne du module 2 décrit le contrat public. Le modèle sérialisé attend, lui, un tableau numérique dans un ordre précis. Une divergence silencieuse entre les deux est la première cause de bug en production (module 4). Au démarrage, on peut vérifier que le modèle attend bien le même nombre de variables que le prétraitement en sortie du contrat public.

class PretraiteurCoherent:
"""Vecteur unique de features utilisé à l'entraînement et au service."""
variables_attendues = 24

def valider_coherence(modele) -> None:
if hasattr(modele, "n_features_in_"):
attendu = PretraiteurCoherent.variables_attendues
recu = int(modele.n_features_in_)
if attendu != recu:
raise RuntimeError(
f"Incohérence : le pipeline produit {attendu} variables, "
f"le modèle en attend {recu}"
)

Cette vérification lève au démarrage, pas à la première requête. C'est exactement ce qu'on veut.

Une route qui donne la version chargée

Le module 8 reviendra sur les sondes de santé, mais une route utile dès maintenant est /model/info : elle renvoie la version, le hash du snapshot, la date de chargement. Un opérateur qui voit une prédiction étrange y accède en un curl et compare avec le registre.

@app.get("/model/info")
def info_modele(request: Request) -> dict:
return {
"version": request.app.state.version_modele,
"seuil": request.app.state.seuil,
"variables_attendues": PretraiteurCoherent.variables_attendues,
}
Un seul chargement par processus, pas par travailleur

Quand on lancera plusieurs travailleurs Uvicorn au module 9 (--workers 4), chaque travailleur exécute son lifespan et charge sa copie du modèle. Un modèle de 500 Mo × 4 travailleurs = 2 Go de mémoire vive : ce coût doit être budgété dès le dimensionnement (module 10). Passer par un magasin partagé (Redis, mmap) est possible mais complique la mise à jour et le retour arrière ; on y vient seulement si la mémoire manque vraiment.

En résumé

  • Charger le modèle une seule fois dans lifespan, jamais dans la route ; le service passe de 2 à 300 requêtes par seconde.
  • Coller la version au modèle chargé et la renvoyer dans chaque réponse pour l'audit.
  • Échouer au démarrage plutôt qu'à la première requête : lever si le fichier manque, si la signature diffère, si l'alias est vide.
  • Charger par alias (@champion) depuis le registre MLflow rend la promotion et le retour arrière possibles sans redéploiement.

Le module 4 s'attaque à la prédiction elle-même, unitaire puis par lots, avec le piège central du prétraitement qui diverge entre entraînement et service.