Aller au contenu principal

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 du float32 par 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.

Un test de non-régression sur le prétraitement

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.vstack puis un seul predict_proba gagne 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.