Module 4 — Prédiction unitaire et par lots
Le modèle est chargé, la validation attrape les entrées mal formées : reste à produire la prédiction. Ce module traite la route unitaire, la route par lots, et le piège central qui casse la plupart des services de scoring en production — le prétraitement du service qui diverge silencieusement de celui de l'entraînement.
Deux routes, deux régimes de charge
Sur le fil rouge, le tableau de bord Streamlit du cours 38 a deux besoins distincts. Un conseiller clique sur un abonné et voit son score : c'est un appel unitaire, quelques dizaines par minute, qui doit répondre en moins de 100 ms. Chaque nuit, un travail de fond re-score les cinq millions d'abonnés pour renouveler la liste des 100 plus à risque : c'est un appel par lots, qui traite 5 000 dossiers en un seul aller-retour et pour lequel seule la durée totale compte.
Ces deux régimes se servent différemment. Un appel unitaire ne bénéficie
pas de la vectorisation — passer par un tableau numpy d'une seule ligne
coûte plus cher que d'appeler predict_proba sur la ligne. Un appel par
lots, au contraire, profite pleinement de la vectorisation numpy et de la
parallélisation interne à scikit-learn ou à xgboost : 5 000 lignes en
un predict_proba prennent 200 ms, alors que 5 000 appels séquentiels en
prendraient 30 000. Le facteur est de 150.
Le contrat commun : un vecteur de variables
Le module 2 a défini DossierAbonne, le contrat public. Le modèle attend,
lui, un tableau numérique dans un ordre fixe. Entre les deux, on écrit une
fonction de prétraitement unique, partagée entre l'entraînement et le
service. C'est la seule barrière contre le training-serving skew.
# fichier: pretraitement.py — partagé entraînement / service
from datetime import date
import numpy as np
VARIABLES = [
"age",
"anciennete_mois",
"revenu_moyen_mensuel_eur",
"nb_reclamations_12m",
"jours_depuis_dernier_paiement",
"forfait_prepaye",
"forfait_postpaye",
"forfait_entreprise",
]
def dossier_vers_vecteur(dossier) -> np.ndarray:
"""Transforme un DossierAbonne (ou dict équivalent) en vecteur numpy 1D."""
if hasattr(dossier, "model_dump"):
d = dossier.model_dump()
else:
d = dict(dossier)
jours = (date.today() - d["date_dernier_paiement"]).days
forfait = d["forfait"]
return np.array([
d["age"],
d["anciennete_mois"],
d["revenu_moyen_mensuel_eur"],
d["nb_reclamations_12m"],
jours,
1.0 if forfait == "prépayé" else 0.0,
1.0 if forfait == "postpayé" else 0.0,
1.0 if forfait == "entreprise" else 0.0,
], dtype=np.float64)
Trois règles rendent cette fonction non négociable. Elle est importée
par le script d'entraînement du cours 20 (pas dupliquée), elle est
testée unitairement sur des cas connus (test que le lot livré par
l'équipe MLOps passe et qu'un âge de 18 donne bien 18.0), et son
contenu est journalisé : la variable VARIABLES est écrite dans un
fichier compagnon au moment de l'entraînement et vérifiée au démarrage
(module 3).
La route unitaire
Elle enveloppe la fonction partagée et laisse le modèle scorer une ligne.
from fastapi import FastAPI, Depends
import numpy as np
app = FastAPI()
@app.post("/predict", response_model=ReponsePrediction)
def predict(
dossier: DossierAbonne,
modele=Depends(modele_charge),
) -> ReponsePrediction:
x = dossier_vers_vecteur(dossier).reshape(1, -1) # 2D pour sklearn
proba = float(modele.predict_proba(x)[0, 1])
decision = decider(proba, seuil=0.5)
return ReponsePrediction(
abonne_id=dossier.abonne_id,
probabilite_resiliation=round(proba, 4),
seuil_utilise=0.5,
decision=decision,
version_modele="churn-2026-08-30",
)
def decider(proba: float, seuil: float) -> str:
if proba >= seuil:
return "à_contacter"
if proba >= 0.3:
return "à_surveiller"
return "sans_action"
Le reshape(1, -1) transforme le vecteur 1D en tableau 2D d'une ligne :
scikit-learn refuse les vecteurs 1D depuis la version 0.19 avec un
message explicite. On arrondit à quatre décimales pour éviter d'exposer
une fausse précision (une différence à 1e-9 près n'a aucun sens pour un
modèle qui varie de plusieurs pourcents entre deux entraînements).
La route par lots
Elle prend une liste, la transforme en matrice, appelle predict_proba
une seule fois, puis reconstitue la réponse.
@app.post("/predict/batch", response_model=ReponseLot)
def predict_batch(
requete: RequeteLot,
modele=Depends(modele_charge),
) -> ReponseLot:
n = len(requete.dossiers)
if n > 5_000:
raise HTTPException(status_code=413, detail="lot > 5000 dossiers")
X = np.vstack([dossier_vers_vecteur(d) for d in requete.dossiers])
probas = modele.predict_proba(X)[:, 1]
resultats = [
ReponsePrediction(
abonne_id=d.abonne_id,
probabilite_resiliation=round(float(p), 4),
seuil_utilise=0.5,
decision=decider(float(p), 0.5),
version_modele="churn-2026-08-30",
)
for d, p in zip(requete.dossiers, probas)
]
return ReponseLot(
lot_id=requete.lot_id,
version_modele="churn-2026-08-30",
resultats=resultats,
)
Trois détails comptent. La borne à 5 000 dossiers vient de la mémoire :
au-delà, une requête peut tenir 800 Mo en RAM entre la matrice numpy, les
objets Pydantic et le JSON de sortie. On rejette avec 413 Payload Too
Large, pas 400 ni 422 : ce n'est pas le schéma qui est faux, c'est la
taille physique. La construction de X avec np.vstack fait une seule
allocation, plus rapide qu'un np.array(liste_de_vecteurs). Enfin, le
zip reconstitue les réponses dans l'ordre de la requête : c'est un
contrat implicite que les appelants supposent toujours et que le service
doit tenir.
Le piège central : le prétraitement qui diverge
L'histoire est toujours la même. L'équipe d'entraînement écrit son prétraitement dans un carnet Jupyter. L'équipe de service, faute de temps, le réécrit à sa main dans le service. Les deux versions se ressemblent mais divergent sur trois points :
- Traitement des manquants : l'entraînement remplace par la médiane
calculée sur le jeu d'entraînement ; le service, faute d'accès à cette
médiane, remplace par
0. - Encodage des catégories : l'entraînement voit
["prépayé", "postpayé"]et attribue[0, 1]par ordre alphabétique ; le service, qui code à la main, attribue[1, 0]par ordre d'apparition. - Type numérique : l'entraînement travaille en
float64, le service envoie dufloat32par optimisation. Certains estimateurs sont sensibles au type.
Chacun de ces trois écarts est indolore individuellement mais mesurable sur les prédictions : un modèle qui donnait 0,82 d'AUC hors ligne tombe à 0,71 en production sans qu'aucune erreur ne soit levée. Les tableaux de bord se dégradent, et on cherche la cause dans le modèle alors qu'elle est dans la fonction de prétraitement.
La règle est simple : une seule fonction, un seul dépôt, importée par
les deux mondes. On la place dans un paquet Python interne
(churn_features/), on le pin dans requirements.txt et on l'appelle des
deux côtés. Le module 7 du cours 20 (registre) archive même sa version
dans le registre en même temps que le modèle.
Vectorisation et types
Un dernier gain vient du type numérique. scikit-learn et xgboost
travaillent avec float64 par défaut ; certains, comme lightgbm, sont
plus rapides en float32. Fixer le type à l'entraînement et le refixer au
service évite les surprises.
X = np.vstack([dossier_vers_vecteur(d) for d in requete.dossiers]).astype(np.float64)
Ne jamais laisser numpy décider seul du type — surtout quand une valeur
manquante None glisse dans le lot et fait tout basculer en object, ce
qui multiplie le temps de prédiction par cent.
On commite dans le dépôt un petit jeu de dix DossierAbonne avec les
vecteurs attendus (.npz). Un test pytest vérifie que
dossier_vers_vecteur sort exactement ces vecteurs. Toute modification
qui casserait le contrat casse le test avant le déploiement.
En résumé
- Route unitaire pour l'interactif, route par lots pour le re-scoring nocturne : les deux régimes existent presque toujours.
- Vectoriser réellement :
np.vstackpuis un seulpredict_probagagne un facteur 100 à 200 sur 5 000 lignes. - Une fonction de prétraitement unique, partagée par l'entraînement et le service, est la seule protection contre le training-serving skew.
- Borner la taille du lot (
413 Payload Too Large) et fixer le type numérique évitent deux modes de défaillance silencieuse.
Le module 5 explore les codes de retour et la stratégie d'erreur : 400 contre 422 contre 500, réponses structurées, jamais de trace exposée.