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,
}
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.