Aller au contenu principal

Module 2 — Validation des entrées avec Pydantic

Le module 1 a livré une application qui répond à /health et à / sans se poser de questions sur ce qu'elle reçoit. Le service de résiliation, lui, doit accepter un dossier d'abonné avec au moins vingt champs, en refuser un sans contract_type ou avec age = -3, et le dire de façon claire à l'appelant. Cette responsabilité, on ne l'écrit pas à la main : on la délègue à Pydantic, la bibliothèque qui alimente FastAPI.

Pourquoi une validation stricte, dès la porte d'entrée

Un modèle d'apprentissage est une fonction f(x) définie sur un espace X précis. Si on lui donne un x en dehors de X — une catégorie inconnue, une valeur négative pour une durée, une chaîne là où on attend un nombre — il produira quand même un score, parce que la plupart des chaînes de prétraitement ne lèvent pas d'exception : elles encodent l'entrée silencieusement et livrent un résultat aussi silencieusement absurde.

Le pire est que ce score aura l'air d'un vrai score, arrivera dans le tableau de bord Streamlit du cours 38, et déclenchera peut-être un appel commercial vers un abonné qui n'existe pas. La validation à la porte d'entrée est donc la première brique de sûreté de l'API. Elle sépare « mauvaise requête » (à refuser avec 422 ou 400, module 5) et « bonne requête, réponse produite ».

Un modèle de requête et un modèle de réponse

Pydantic v2 s'utilise via la classe BaseModel. On décrit d'abord ce que l'appelant doit envoyer, puis ce que le service renvoie.

from datetime import date
from typing import Literal
from pydantic import BaseModel, Field

class DossierAbonne(BaseModel):
abonne_id: int = Field(..., ge=1, description="Identifiant interne, jamais un numéro de téléphone.")
age: int = Field(..., ge=18, le=120)
anciennete_mois: int = Field(..., ge=0, le=600)
forfait: Literal["prépayé", "postpayé", "entreprise"]
revenu_moyen_mensuel_eur: float = Field(..., ge=0, le=100_000)
nb_reclamations_12m: int = Field(..., ge=0, le=200)
date_dernier_paiement: date

class ReponsePrediction(BaseModel):
abonne_id: int
probabilite_resiliation: float = Field(..., ge=0.0, le=1.0)
seuil_utilise: float
decision: Literal["à_contacter", "à_surveiller", "sans_action"]
version_modele: str

Les contraintes ne sont pas décoratives : ge (greater or equal), le (less or equal), Literal[...] et l'annotation date produisent chacune un rejet en 422 avec un message précis quand la valeur envoyée ne convient pas. Le service ne verra jamais un âge de 4 000 ans ni un forfait "gold-platinum" inconnu du modèle.

Brancher les modèles sur la route

Il suffit d'annoter le paramètre et le retour de la fonction.

from fastapi import FastAPI

app = FastAPI()

@app.post("/predict", response_model=ReponsePrediction)
def predict(dossier: DossierAbonne) -> ReponsePrediction:
proba = 0.42 # remplacé au module 4 par model.predict_proba(...)
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",
)

FastAPI voit dossier: DossierAbonne et fait trois choses avant que la fonction ne s'exécute : il analyse le corps JSON, il valide chaque champ contre le modèle, et il refuse l'appel avec un code 422 si un champ manque ou dépasse ses bornes. Après l'exécution, il valide aussi la réponse contre ReponsePrediction grâce au paramètre response_model : un modèle qui renverrait une probabilité de 1.03 ferait planter la validation côté service, jamais côté appelant — c'est un filet de sécurité qui attrape les régressions de code.

Un exemple visible dans Swagger

Rien de plus décourageant, pour l'équipe qui consomme l'API, qu'un schéma sans exemple. On ajoute un exemple concret directement dans Field, ou via model_config.

from pydantic import BaseModel, Field, ConfigDict

class DossierAbonne(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"abonne_id": 12345,
"age": 42,
"anciennete_mois": 36,
"forfait": "postpayé",
"revenu_moyen_mensuel_eur": 45.90,
"nb_reclamations_12m": 2,
"date_dernier_paiement": "2026-08-15",
}
}
)
# ... champs comme plus haut

L'appelant qui ouvre /docs voit un JSON prêt à copier, ce qui divise par dix le temps de la première intégration.

Le message 422 en détail

Quand une entrée est refusée, FastAPI renvoie un JSON structuré. Un age = -3 donne :

{
"detail": [
{
"type": "greater_than_equal",
"loc": ["body", "age"],
"msg": "Input should be greater than or equal to 18",
"input": -3,
"ctx": {"ge": 18}
}
]
}

loc désigne le chemin exact du champ fautif, type la règle enfreinte, input la valeur reçue. C'est utilisable côté appelant sans avoir à analyser une phrase en anglais : on lit loc et type, on affiche à l'utilisateur « âge invalide », on continue. Le module 5 explique quand on préfère renvoyer 400 (mauvaise requête sémantique) plutôt que 422 (schéma invalide) et comment personnaliser ces réponses.

Validateurs métier : ce que Pydantic ne devine pas

Les contraintes numériques et les Literal[...] couvrent la plupart des cas. Restent les règles métier : « la date du dernier paiement ne peut pas être dans le futur », « un forfait entreprise implique un revenu_moyen supérieur à 100 €ur ». On les écrit avec des validateurs.

from pydantic import BaseModel, field_validator, model_validator
from datetime import date

class DossierAbonne(BaseModel):
# ... champs

@field_validator("date_dernier_paiement")
@classmethod
def paiement_pas_dans_le_futur(cls, v: date) -> date:
if v > date.today():
raise ValueError("date_dernier_paiement doit être passée")
return v

@model_validator(mode="after")
def coherence_forfait_revenu(self) -> "DossierAbonne":
if self.forfait == "entreprise" and self.revenu_moyen_mensuel_eur < 100:
raise ValueError("un forfait entreprise implique un revenu moyen supérieur à 100 EUR")
return self

Un ValueError levé dans un validateur devient un 422 avec le même format que ci-dessus. On ne lève jamais de HTTPException dans un validateur Pydantic : ce serait mélanger la couche schéma et la couche transport, et FastAPI n'attend pas ce type d'exception à cet endroit.

Réponses par lots : un modèle qui contient une liste

Pour la route par lots (module 4), on décrit une requête qui contient une liste, plutôt qu'une simple liste : on garde une place pour la version d'API demandée, un identifiant de lot, une taille maximale.

class RequeteLot(BaseModel):
lot_id: str = Field(..., min_length=1, max_length=64)
dossiers: list[DossierAbonne] = Field(..., min_length=1, max_length=5_000)

class ReponseLot(BaseModel):
lot_id: str
version_modele: str
resultats: list[ReponsePrediction]

Cette forme encapsulée résiste à l'évolution : ajouter mode: Literal["strict", "tolerant"] à RequeteLot ne casse aucun appelant existant, alors qu'ajouter un champ à une liste anonyme est impossible.

Ne pas exposer les objets d'entraînement

Une erreur courante consiste à réutiliser directement la classe utilisée pour l'entraînement du modèle (class Sample) comme modèle Pydantic. Le prétraitement, le mappage des catégories, la version du schéma appartiennent au monde interne ; le contrat public est plus étroit et plus stable. On sépare DossierAbonne (public) et l'objet interne consommé par le modèle (module 4).

En résumé

  • Pydantic valide le corps JSON avant que la fonction ne s'exécute : un champ manquant ou hors bornes déclenche 422, jamais 200 avec un score absurde.
  • On sépare modèle de requête et modèle de réponse, tous deux déclarés sur la route via l'annotation et response_model.
  • Les contraintes ge, le, min_length, max_length, Literal[...] et les validateurs métier remplacent des dizaines de lignes de if défensifs.
  • Un exemple concret dans model_config divise par dix le temps de la première intégration côté appelant.

Le module 3 s'occupe de la brique la plus lourde : charger le modèle une seule fois au démarrage plutôt qu'à chaque requête.