Prédire le défaut de crédit par lots et en ligne
- Étude de cas 1
- 180 min
- Facultative · 1 étude de cas requise pour le certificat
Étude de cas 1 sur 3 · Déployer des modèles d'IA pour de vrais utilisateursPrédire le défaut de crédit par lots et en ligne
1. Définir le contrat d'inférence
2. Choisir un mode de service
3. Créer le service de prédiction
4. Choisir l'infrastructure et mesurer
5. Mettre en production, surveiller et rétablir
Ce que vous saurez faire
Servez un modèle des deux façons dont une banque a besoin. Un traitement mensuel par lots calcule le score de chaque client pour la liste d’appels. Un point de terminaison en ligne calcule le score d’un client pendant qu’un agent du centre d’appels attend. Les deux utilisent un même paquet versionné et un même contrat. Vous prouvez qu’ils donnent les mêmes scores, puis vous mesurez chacun. Ensuite, vous voyez le contrat bloquer un fichier mensuel défectueux avant qu’il atteigne le centre d’appels.
Le projet de départ contient les données et le script d’entraînement. Il contient aussi les éléments déjà créés dans le cours : le chargeur de paquet, les paramètres, le code de calcul des scores, le format des erreurs et la clé d’API. Vous écrivez les éléments propres à ce modèle : le contrat, le traitement par lots, le point de terminaison, la vérification de parité et les conteneurs.
Avant de commencerCe qu’il faut savoir et avoir sous la main
Ce que vous devez savoir
Les leçons les plus utilisées dans cette étude de cas :
- Le contrat et le paquet, dans Entrées et sorties des prédictions, Empaqueter les dépendances du modèle et Confiance et intégrité des artefacts.
- Les deux modes de service, dans Inférence par lots et Inférence en ligne.
- La parité, dans Vérifier la parité.
- Les limites et les mesures, dans Paramètres de ressources du conteneur et Mesurer la performance du service.
Vous n’avez pas besoin du cours d’apprentissage automatique. Si vous avez fait son étude de cas Prédire le défaut de paiement par carte de crédit, le principe du modèle est le même : maintenant, vous le servez.
Ce qu’il vous faut sur votre ordinateur
- Python 3.12 ou plus récent, VS Code avec l’extension Python, et curl.
- Docker Desktop, comme dans Construire efficacement.
- Environ 1,2 Go d’espace disque libre : 360 Mo pour l’environnement virtuel et 790 Mo pour l’image.
Testé avec Python 3.14.6 et Docker Desktop (Docker Engine 29.6.1) sur macOS, avec une puce Apple. Les étapes pour Windows et Linux ne sont pas testées.
La situation
Section intitulée « La situation »Une banque de Taiwan émet des cartes de crédit. Son équipe de gestion du risque des cartes, dirigée par Yu-Ting Lin, fait un appel d’aide préventive aux clients susceptibles de manquer leur prochain paiement. Un agent propose un plan de paiement avant que le client prenne du retard. La banque ne refuse rien et ne ferme aucun compte. L’équipe a déjà un modèle : il calcule le score de chaque client à partir des six derniers relevés mensuels.
Deux groupes ont besoin du score, à deux moments différents :
- Yu-Ting a besoin de la liste d’appels une fois par mois. Le jour des relevés, Hao Tsai, l’ingénieur de données, livre un fichier contenant tous les clients. Chaque client dont le score atteint au moins 0,17 figure sur la liste.
- Jia-Hui Wu dirige le centre d’appels. Les clients téléphonent tout au long du mois. Quand un agent ouvre le dossier d’un client, l’écran doit afficher le risque actuel. L’agent peut alors proposer le plan pendant l’appel. Le budget de latence de Jia-Hui pour le score : 200 ms.
Vous êtes l’ingénieur en apprentissage automatique de l’équipe. Vous servez le modèle aux deux groupes.
Par lots ou en ligne : le même modèle, deux façons d’attendre
Section intitulée « Par lots ou en ligne : le même modèle, deux façons d’attendre »Une compagnie d’électricité imprime toutes les factures en un seul traitement mensuel : personne n’attend une facture en particulier. Quand vous appelez au sujet de votre facture, l’agent consulte votre compte pendant que vous attendez. La banque a les mêmes besoins : un traitement par lots et un point de terminaison en ligne.
Faites une prédiction avant de regarder : l’API calcule le score d’un client en quelques millisecondes. Pour les 30 000 clients, avec une requête chacun, combien de temps de plus lui faut-il qu’au traitement par lots?
Description textuelle du graphique
Deux colonnes. Le traitement par lots a calculé les scores des 30 000 clients du fichier mensuel en 1.44 secondes (la médiane de trois exécutions : 1.44, 1.47, 1.39 s). L’API en ligne, avec une requête par client et une requête à la fois, a pris 187.5 secondes (160 requêtes par seconde). Le traitement par lots était environ 130 fois plus rapide.
Chaque requête a un coût fixe (HTTP, JSON, une table d’une ligne, le prétraitement), comme dans Inférence en ligne. Le traitement par lots paie ce coût une fois par groupe de 5 000 clients. Mais un score par lots date du fichier utilisé. Quand un client qui a payé hier téléphone, l’agent a besoin d’un score tiré du dossier d’aujourd’hui.
L’idée est donc un modèle, deux voies : par lots pour le débit, en ligne pour la latence. Les deux utilisent un même paquet, un même contrat et une même fonction de calcul des scores. Des cas de parité fixes prouvent leur concordance.
Description textuelle du diagramme
De haut en bas : training/train.py écrit le modèle. make_bundle.py le transforme en dossier bundles/default-risk-1.0.0 contenant cinq fichiers. L’empreinte MODEL_SHA256 reste dans .env, à l’extérieur du paquet. Le chargeur vérifie que le paquet lui correspond. Dans une seule image, load_bundle exécute ses six barrières. Le code partagé (contract.py et scoring.py) sert ensuite deux voies. Le traitement par lots lit le fichier mensuel de 30 000 clients. Il écrit scores.csv, rejected.csv et summary.json dans out/2005-09/. L’API en ligne reçoit un client de l’écran de l’agent. Elle répond avec le score, l’indicateur et la version du modèle.
| Voie par lots | Voie en ligne | |
|---|---|---|
| Qui attend | Personne : la liste est prête le lendemain matin | L’agent et le client, au téléphone |
| Mesure | Clients par seconde, et une exécution qui se termine | Latence p50 et p95 par rapport au budget de 200 ms |
| Un client invalide | Mis à part dans rejected.csv; les autres reçoivent un score |
422 avec les noms des champs, immédiatement |
| Un fichier défectueux | Le traitement échoue; aucune liste d’appels n’est écrite | Impossible : un client par requête |
| Actualité des données | Elles datent du fichier | Le dossier d’aujourd’hui |
Le modèle et ses données
Section intitulée « Le modèle et ses données »Les données viennent du fichier UCI Default of Credit Card Clients : 30 000 clients d’une banque de Taiwan. Pour chaque client, il contient six relevés mensuels (avril à septembre 2005) et indique si le client a manqué le paiement d’octobre. Sa licence est CC BY 4.0. Voici donc l’attribution : « Default of Credit Card Clients », par I-Cheng Yeh, UCI Machine Learning Repository, doi:10.24432/C55S3H, CC BY 4.0. Le fichier du projet est converti de XLS à CSV, et sa colonne cible est renommée default. Yu-Ting, Jia-Hui, Hao et le centre d’appels sont fictifs. Les clients sont réels et anonymes.
Le modèle utilise 20 entrées, comme dans l’étude de cas du cours d’apprentissage automatique :
| Entrée | Signification |
|---|---|
EDUCATION |
1 études supérieures, 2 université, 3 secondaire, 4 autres. Les codes 0, 5 et 6 figurent dans les données, mais ne sont pas documentés : le pipeline les regroupe dans 4. |
PAY_1 … PAY_6 |
État du remboursement, de septembre (1) à avril (6) : −1 payé à temps, 1 à 8 mois de retard. −2 et 0 ne sont pas documentés. |
LIMIT_BAL |
La limite de crédit, en dollars de Taiwan. |
BILL_AMT1 … BILL_AMT6 |
La facture de chaque mois. Une facture négative signifie que le client a payé plus que ce qu’il devait. |
PAY_AMT1 … PAY_AMT6 |
Le montant payé chaque mois. |
Le sexe, l’état matrimonial et l’âge figurent dans les données. Ce ne sont pas des entrées, et le fichier mensuel ne les contient pas.
Le modèle est un pipeline scikit-learn : encodage un à chaud (codes rares regroupés), montants à échelle logarithmique, puis régression logistique. Le seuil 0,17 a donné le coût le plus faible sur les données de validation. Un défaut manqué coûte 5, et une fausse alerte coûte 1. Sur les données de test, il a signalé 38,2 % des clients et trouvé 70 % des défauts (rappel de 0,701). L’étude de cas d’apprentissage automatique explique chaque choix.
Les données couvrent un seul mois de relevés. Le « fichier mensuel » contient donc les mêmes 30 000 clients qui ont servi à l’entraînement. Ici, vous testez les voies, pas le modèle : n’interprétez pas son taux de signalement comme un résultat de test.
Préparer le projet de départ
Section intitulée « Préparer le projet de départ »-
Téléchargez le projet de départ avec le bouton dans l’encadré en haut de cette page. Décompressez-le dans votre dossier
projects. Vous obtenez un dossiercredit-default-service. -
Dans VS Code, ouvrez le dossier : File › Open Folder… (Fichier › Ouvrir un dossier…), puis choisissez
credit-default-service. -
Ouvrez un terminal dans VS Code : Terminal › New Terminal (Terminal › Nouveau terminal). Il démarre dans le dossier du projet.
-
Créez et activez un environnement virtuel :
Fenêtre de terminal python3 -m venv .venvsource .venv/bin/activateFenêtre de terminal python -m venv .venv.venv\Scripts\Activate.ps1Fenêtre de terminal python3 -m venv .venvsource .venv/bin/activateL’invite commence par
(.venv). -
Installez les versions fixées des paquets :
Fenêtre de terminal python -m pip install -r requirements.txtCela a pris 20 secondes sur notre ordinateur. L’environnement utilise environ 360 Mo.
-
Indiquez à VS Code d’utiliser cet environnement. Ouvrez la palette de commandes (Ctrl+Shift+P, ou Cmd+Shift+P sur macOS). Choisissez Python : Select Interpreter (Python : sélectionner l’interpréteur), puis celui dans
.venv. -
Exécutez les tests :
Fenêtre de terminal python -m pytest
La sortie (dernières lignes) :
collected 3 items / 5 skipped
tests/test_features.py .. [ 66%]tests/test_health.py . [100%]
======================== 3 passed, 5 skipped in 10.07s =========================Les tests des parties que vous écrivez sont déjà dans tests/. Ils sont ignorés tant que leur fichier n’existe pas. Après chaque partie, davantage de tests réussissent : 37 à la fin.
Contenu du projet de départ
Section intitulée « Contenu du projet de départ »| Chemin | Description |
|---|---|
data/credit_default_taiwan.csv.gz, data/README.md |
Les données (0,98 Mo), leur licence et leur attribution |
training/train.py |
Entraîne le modèle dans model/ (partie 1) |
scripts/export_month.py |
L’exportation de Hao : écrit le fichier mensuel (partie 1) |
default_risk/features.py |
Deux petites fonctions que le pipeline appelle (partie 1) |
default_risk/bundle.py, settings.py, scoring.py, errors.py, security.py |
Le chargeur avec ses six barrières, les paramètres, le code partagé de calcul des scores, le format des erreurs et la clé d’API, comme dans le cours |
default_risk/main.py |
Une première API avec seulement /health (vous la remplacez dans la partie 5) |
make_bundle.py |
Crée le paquet à partir de model/ (partie 3) |
bench/bench.py, requests/ |
Le banc d’essai (partie 7) et trois corps de requête pour curl |
requirements.txt, requirements-run.txt, .dockerignore, .env.example |
Les versions fixées pour votre ordinateur et pour l’image, comme dans le cours |
MODEL_CARD.md, LICENSE |
Les usages prévus et exclus du modèle; la licence MIT du code |
Partie 1 : entraîner le modèle et exporter le mois
Section intitulée « Partie 1 : entraîner le modèle et exporter le mois »Entraînez le modèle :
python -m training.trainLa sortie :
Trained on 18000 clients. Threshold 0.17 (the lowest cost on validation).Valid: ROC AUC 0.775, flagged 39.9%, precision 0.395, recall 0.713.Test: ROC AUC 0.770, flagged 38.2%, precision 0.406, recall 0.701.Ce sont les chiffres de l’étude de cas d’apprentissage automatique. Le script a écrit model/model.joblib (7 946 octets, SHA-256 d71c40c4848a…, identique à chaque exécution sur un même ordinateur) et model/model_info.json.
Ouvrez default_risk/features.py. Le pipeline y appelle deux fonctions : signed_log pour les montants et group_education pour les codes 0, 5 et 6. Le modèle de billets de soutien n’avait pas ce piège : joblib enregistre les noms de ces fonctions, pas leur code. Un service doit avoir ce fichier, inchangé. Le paquet enregistre donc son SHA-256 (partie 3).
Créez maintenant le fichier mensuel, comme le fait l’exportation de Hao :
python scripts/export_month.py data/clients-2005-09.csvLa sortie :
Wrote data/clients-2005-09.csv: 30000 clients, 21 columns.Ouvrez data/clients-2005-09.csv dans VS Code. Ses premières lignes :
client_id,EDUCATION,PAY_1,PAY_2,PAY_3,PAY_4,PAY_5,PAY_6,LIMIT_BAL,BILL_AMT1,BILL_AMT2,BILL_AMT3,BILL_AMT4,BILL_AMT5,BILL_AMT6,PAY_AMT1,PAY_AMT2,PAY_AMT3,PAY_AMT4,PAY_AMT5,PAY_AMT6C-000001,2,2,2,-1,-1,-2,-2,20000,3913,3102,689,0,0,0,0,689,0,0,0,0C-000002,2,-1,2,0,0,0,2,120000,2682,1725,2682,3272,3455,3261,0,1000,1000,1000,0,2000Le fichier contient un identifiant de client et les 20 entrées, rien d’autre (PAY_0 de la source devient PAY_1). Le client C-000002 est le client d’exemple de cette étude de cas.
Partie 2 : le contrat, avec des plages tirées des données
Section intitulée « Partie 2 : le contrat, avec des plages tirées des données »Le problème : le pipeline calcule un score pour n’importe quoi
Section intitulée « Le problème : le pipeline calcule un score pour n’importe quoi »Démarrez Python avec python, collez ceci, puis quittez avec exit(). Ce code calcule le score du client C-000002 avec le pipeline brut, puis celui de trois copies modifiées :
import joblibimport pandas as pd
pipeline = joblib.load("model/model.joblib")client = pd.read_csv("data/clients-2005-09.csv").iloc[[1]].drop(columns="client_id")print("as exported: ", pipeline.predict_proba(client)[:, 1])for change in [{"PAY_1": 12}, {"EDUCATION": 9}, {"LIMIT_BAL": 120}]: print(f"{str(change):16}", pipeline.predict_proba(client.assign(**change))[:, 1])La sortie :
as exported: [0.35052096]{'PAY_1': 12} [0.53260864]{'EDUCATION': 9} [0.30945468]{'LIMIT_BAL': 120} [0.66270463]Les trois entrées sont impossibles, et les trois obtiennent un score. Une limite écrite en milliers (120 pour 120 000) fait passer le score de 0,35 à 0,66. Le modèle ne peut pas dire que son entrée est incorrecte. Le contrat doit le faire, comme dans Entrées et sorties des prédictions.
L’idée : les données fixent les limites
Section intitulée « L’idée : les données fixent les limites »Le contrat n’autorise que les valeurs que le modèle a vues. Affichez la plus petite et la plus grande valeur de chaque colonne :
python -c "import pandas as pd; d = pd.read_csv('data/clients-2005-09.csv').drop(columns='client_id'); print(d.agg(['min', 'max']).T.to_string())"La sortie (abrégée) :
min maxEDUCATION 0 6PAY_1 -2 8...PAY_6 -2 8LIMIT_BAL 10000 1000000BILL_AMT1 -165580 964511...BILL_AMT3 -157264 1664089...BILL_AMT6 -339603 961664PAY_AMT1 0 873552PAY_AMT2 0 1684259...Le code d’état 9 n’apparaît jamais. Pour les factures et les paiements, le contrat utilise une seule plage pour les six mois. Elle va de la plus petite à la plus grande valeur, tous mois confondus. Une facture de septembre de 1 000 000 est donc permise, même si les données n’en contiennent aucune.
Étape 1 : écrire le contrat
Section intitulée « Étape 1 : écrire le contrat »Créez le fichier default_risk/contract.py :
"""The inference contract: what a client's record must look like before the model scores it.
The model learned from 20 columns. This file gives, for each one, its name,its type and the values it may take. The ranges are the smallest and largestvalues in the 30,000 clients of the data: the model has never seen anythingoutside them. The batch job and the API both check clients with theseclasses, so the two paths always apply the same rules."""
from typing import Annotated
import pandas as pdfrom pydantic import BaseModel, ConfigDict, Field
STATUS = [f"PAY_{m}" for m in range(1, 7)]BILLS = [f"BILL_AMT{m}" for m in range(1, 7)]PAYMENTS = [f"PAY_AMT{m}" for m in range(1, 7)]# The order of the columns that the pipeline was fitted on (training/train.py).FEATURES = ["EDUCATION"] + STATUS + ["LIMIT_BAL"] + BILLS + PAYMENTS
# Repayment status: -1 paid on time, 1 to 8 months late; -2 and 0 are not# documented (often read as "no use" and "minimum paid"). 9 never occurs.Status = Annotated[int, Field(ge=-2, le=8)]# NT dollars. A negative bill means the client paid more than they owed.Bill = Annotated[int, Field(ge=-339_603, le=1_664_089)]Payment = Annotated[int, Field(ge=0, le=1_684_259)]
class ClientFeatures(BaseModel): """The 20 features of one client, from the last six monthly statements."""
model_config = ConfigDict(extra="forbid")
EDUCATION: int = Field(ge=0, le=6, description="1 graduate school, 2 university, 3 high school, 4 others (0, 5, 6 undocumented).") PAY_1: Status = Field(description="Repayment status in the last month (September).") PAY_2: Status PAY_3: Status PAY_4: Status PAY_5: Status PAY_6: Status = Field(description="Repayment status six months ago (April).") LIMIT_BAL: int = Field(ge=10_000, le=1_000_000, description="Credit limit in NT dollars.") BILL_AMT1: Bill = Field(description="Bill amount in the last month, NT dollars.") BILL_AMT2: Bill BILL_AMT3: Bill BILL_AMT4: Bill BILL_AMT5: Bill BILL_AMT6: Bill PAY_AMT1: Payment = Field(description="Amount paid in the last month, NT dollars.") PAY_AMT2: Payment PAY_AMT3: Payment PAY_AMT4: Payment PAY_AMT5: Payment PAY_AMT6: PaymentLe fichier continue avec le client d’exemple, le modèle de requête et son identifiant, l’avertissement pour les codes jamais vus et la table pour le pipeline. Ajoutez ceci en dessous :
EXAMPLE = { "client_id": "C-000002", "EDUCATION": 2, "PAY_1": -1, "PAY_2": 2, "PAY_3": 0, "PAY_4": 0, "PAY_5": 0, "PAY_6": 2, "LIMIT_BAL": 120000, "BILL_AMT1": 2682, "BILL_AMT2": 1725, "BILL_AMT3": 2682, "BILL_AMT4": 3272, "BILL_AMT5": 3455, "BILL_AMT6": 3261, "PAY_AMT1": 0, "PAY_AMT2": 1000, "PAY_AMT3": 1000, "PAY_AMT4": 1000, "PAY_AMT5": 0, "PAY_AMT6": 2000,}
class ClientIn(ClientFeatures): """A client to score: the client's ID and features."""
model_config = ConfigDict(extra="forbid", json_schema_extra={"examples": [EXAMPLE]})
client_id: str = Field(pattern=r"^C-\d{6}$", description="The client's ID, for example C-000002.")
def unseen_values(client: ClientFeatures, known: dict[str, list[int]]) -> list[str]: """Warnings for status codes inside the contract that the model never saw for that month.
They are allowed: the encoder scores them like its group of rare codes. But the score is less certain, so the answer says so. """ return [ f"{name}: {getattr(client, name)} is a value the model never saw" for name in STATUS if getattr(client, name) not in known[name] ]
def to_frame(clients: list[ClientFeatures]) -> pd.DataFrame: """The clients as a table with the training columns, in the training order.""" rows = [c.model_dump(include=set(FEATURES)) for c in clients] return pd.DataFrame(rows, columns=FEATURES).astype("int64")Trois règles de ce fichier sont des décisions, pas du code :
- Aucune valeur ne peut manquer. Les données n’ont aucune valeur manquante. Le modèle n’a donc jamais appris ce que cela signifie. Une clé absente ou un
null(valeur nulle) est rejeté. - Aucun champ supplémentaire.
extra="forbid"rejette un champAGE. Si l’outil du centre d’appels envoie des données personnelles interdites au modèle, la requête échoue. Quelqu’un corrige alors l’outil. - Dans la plage, mais jamais vu : un avertissement. Dans les données,
PAY_5etPAY_6n’ont jamais le code 1. Le contrat le permet (1 est un état réel), et la réponse contient un avertissement.
Étape 2 : l’essayer
Section intitulée « Étape 2 : l’essayer »Démarrez Python de nouveau et collez ceci :
from pydantic import ValidationErrorfrom default_risk.contract import EXAMPLE, ClientIn
print(ClientIn(**EXAMPLE).LIMIT_BAL)for change in [{"PAY_1": 12}, {"EDUCATION": 9}, {"LIMIT_BAL": 120}, {"AGE": 35}]: try: ClientIn(**{**EXAMPLE, **change}) except ValidationError as error: first = error.errors()[0] print(change, "->", first["loc"][0], first["msg"])La sortie :
120000{'PAY_1': 12} -> PAY_1 Input should be less than or equal to 8{'EDUCATION': 9} -> EDUCATION Input should be less than or equal to 6{'LIMIT_BAL': 120} -> LIMIT_BAL Input should be greater than or equal to 10000{'AGE': 35} -> AGE Extra inputs are not permittedLes trois entrées que le pipeline avait évaluées sont maintenant refusées, chacune avec son champ et la raison. Exécutez python -m pytest : 13 réussis, 4 ignorés.
Partie 3 : un paquet, fixé par son empreinte
Section intitulée « Partie 3 : un paquet, fixé par son empreinte »Les deux voies chargent un même paquet. make_bundle.py est dans le projet de départ, comme dans Empaqueter les dépendances du modèle. Seuls ses cas de parité sont nouveaux : six clients réels du fichier mensuel et six aux limites du contrat :
BASE = {k: v for k, v in EXAMPLE.items() if k != "client_id"}EDGE_CASES = [ ("odd code", "C-900001", dict(EDUCATION=5)), ("odd code", "C-900002", dict(EDUCATION=0, **{c: 0 for c in FEATURES if c.startswith(("BILL", "PAY_AMT"))}, **{s: -2 for s in STATUS})), ("extreme", "C-900003", {c: LOW[c] if c != "EDUCATION" else 1 for c in FEATURES}), ("extreme", "C-900004", {c: HIGH[c] if c != "EDUCATION" else 1 for c in FEATURES}), ("extreme", "C-900005", dict(BILL_AMT1=-2000)), ("unseen", "C-900006", dict(PAY_5=1, PAY_6=1)),]Chaque cas teste une règle qu’une voie de service pourrait enfreindre :
- un code non documenté que le pipeline doit regrouper, et un client sans factures;
- chaque entrée à sa valeur la plus basse et la plus haute (
LOWetHIGHviennent du contrat); - une facture négative, pour le logarithme signé, et un code jamais vu pour ce mois.
Créez le paquet :
python make_bundle.py --version 1.0.0La sortie :
Made bundles/default-risk-1.0.0: 12 parity cases, threshold 0.17.Bundle digest (MODEL_SHA256): da75a5cfd4982660823e22af049358e3359fa1d03b19769d55eadb7d623c9150Votre empreinte est différente, car metadata.json contient l’heure de votre entraînement. (Le paquet du projet terminé a 078d4b443c1e….) Gardez la vôtre : les deux voies en ont besoin. Les cinq fichiers, 16 ko au total :
| Fichier | Taille | Contenu |
|---|---|---|
model.joblib |
7 946 o | Le pipeline entraîné |
contract.json |
5 460 o | Les caractéristiques, le schéma JSON avec les plages, les codes d’état connus, le seuil et la décision |
metadata.json |
890 o | La version, le hachage des données d’entraînement, les résultats, les versions testées des paquets et le SHA-256 de features.py |
parity_cases.csv |
1 595 o | Les 12 cas et leurs scores à l’entraînement |
SHA256SUMS |
322 o | Le hachage de chaque fichier ci-dessus; son propre hachage est l’empreinte |
Exécutez de nouveau la commande. Elle s’arrête : A version is never changed: choose a new version. (Une version n’est jamais modifiée : choisissez une nouvelle version.)
default_risk/bundle.py contient les six barrières du module 1. La barrière 3 a une nouvelle vérification : le SHA-256 de features.py doit correspondre à celui dans metadata.json. Essayez une empreinte incorrecte :
python -c "from default_risk.bundle import load_bundle; load_bundle('bundles/default-risk-1.0.0', '0' * 64)"La dernière ligne de la sortie :
default_risk.bundle.BundleError: bundles/default-risk-1.0.0: the bundle digest is da75a5cfd498..., but MODEL_SHA256 expects 000000000000...La barrière 1 l’a bloquée avant l’exécution de joblib.load. Exécutez python -m pytest : 17 réussis, 3 ignorés.
Partie 4 : voie 1, la tâche mensuelle par lots
Section intitulée « Partie 4 : voie 1, la tâche mensuelle par lots »La tâche suit les règles d’Inférence par lots. Elle met quelques lignes incorrectes de côté et calcule les scores des autres. Un fichier défectueux fait échouer la tâche, car une liste issue de ce fichier est pire qu’aucune liste. Un plantage ne laisse jamais un fichier à moitié écrit.
Description textuelle du diagramme
La tâche démarre avec le fichier mensuel et MODEL_SHA256. Si le paquet échoue à l’une des six barrières, la tâche s’arrête avec le code de sortie 2 sans rien écrire. Sinon, elle lit 5 000 lignes à la fois. Chaque ligne qui ne respecte pas le contrat est mise de côté avec son numéro, l’identifiant du client et le problème. Les scores des lignes valides du segment sont calculés en un seul appel. Une fois toutes les lignes lues, la tâche vérifie la proportion de lignes mises de côté. Plus de 1 % : la tâche échoue avec le code de sortie 1 sans écrire scores.csv. Sinon, elle écrit scores.csv.partial et le renomme scores.csv. Dans les deux cas, elle écrit summary.json avec l’état, les nombres, les empreintes et les temps.
Pourquoi 1 %? Le fichier de septembre ne contient aucune ligne incorrecte. Plus de 300 lignes incorrectes sur 30 000 signifie que le fichier a changé, pas 300 clients.
Étape 1 : écrire la tâche par lots
Section intitulée « Étape 1 : écrire la tâche par lots »Créez le fichier default_risk/batch.py. La première moitié lit et vérifie les lignes :
"""The monthly batch job: score every client in a file with the same bundle and contract as the API.
python -m default_risk.batch data/clients-2005-09.csv out/2005-09
Writes three files into the output folder:
scores.csv client_id, score, flag, model_version (one row per good client) rejected.csv row, client_id, problem (one row per client that fails the contract) summary.json what was read and written (with SHA-256 hashes), with which model, and how long it took
A bad row does not stop the job: it is set aside in rejected.csv, and theother rows are scored. But if more than --max-rejected of the rows are bad,the file itself is probably broken, so the job fails (exit code 1) and writesno scores.csv. Every file is written under a temporary name and renamed atthe end, so a reader never sees half a file."""
import argparseimport hashlibimport jsonimport sysimport timefrom datetime import datetime, timezonefrom pathlib import Path
import pandas as pdfrom pydantic import ValidationError
from default_risk.bundle import BundleError, load_bundlefrom default_risk.contract import FEATURES, ClientInfrom default_risk.scoring import score_clientsfrom default_risk.settings import SettingsError, load_settings
try: import resource # not on Windowsexcept ImportError: resource = None
COLUMNS = ["client_id"] + FEATURES
def problem(error: ValidationError) -> str: first = error.errors()[0] where = ".".join(str(p) for p in first["loc"]) or "row" return f"{where}: {first['msg']}"
def check_rows(chunk: pd.DataFrame, first_row: int): good, bad = [], [] for offset, row in enumerate(chunk.to_dict("records")): values = {k: v for k, v in row.items() if not pd.isna(v)} # an empty cell is a missing value try: good.append(ClientIn(**values)) except ValidationError as error: bad.append({"row": first_row + offset, "client_id": values.get("client_id"), "problem": problem(error)}) return good, bad
def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest()
def write_atomic(path: Path, write) -> None: """Write to NAME.partial, then rename: the rename replaces the old file in one step.""" temporary = path.with_name(path.name + ".partial") write(temporary) temporary.replace(path)
def peak_memory_mb() -> float | None: if resource is None: return None peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss # KiB on Linux, bytes on macOS return round(peak / (1024 * 1024 if sys.platform == "darwin" else 1024), 1)La tâche lit chaque cellule comme du texte (dtype=str), puis le contrat la convertit. Ainsi, "2" dans le fichier et 2 dans une requête JSON deviennent le même nombre. La seconde moitié exécute la tâche :
def main() -> int: parser = argparse.ArgumentParser(description="Score a CSV file of clients.") parser.add_argument("input", type=Path) parser.add_argument("output", type=Path, help="a folder for scores.csv, rejected.csv and summary.json") parser.add_argument("--chunk", type=int, default=5000, help="clients per chunk") parser.add_argument("--max-rejected", type=float, default=0.01, help="the largest share of bad rows") args = parser.parse_args()
started_at = datetime.now(timezone.utc) started = time.perf_counter() try: settings = load_settings() bundle = load_bundle(settings.model_bundle, settings.model_sha256) reader = pd.read_csv(args.input, usecols=COLUMNS, dtype=str, keep_default_na=False, na_values=[""], chunksize=args.chunk) except (SettingsError, BundleError, ValueError, OSError) as error: print(f"Stopped: {error}", file=sys.stderr) return 2 seconds = {"load": time.perf_counter() - started, "check": 0.0, "score": 0.0}
scored, rejected, rows = [], [], 0 for chunk in reader: step = time.perf_counter() good, bad = check_rows(chunk, first_row=rows + 2) # row 1 is the header seconds["check"] += time.perf_counter() - step rows += len(chunk) rejected += bad if good: step = time.perf_counter() results, _ = score_clients(bundle, good) scored += [{"client_id": c.client_id, "score": r.score, "flag": int(r.flag), "model_version": bundle.version} for c, r in zip(good, results)] seconds["score"] += time.perf_counter() - step
step = time.perf_counter() args.output.mkdir(parents=True, exist_ok=True) scores = pd.DataFrame(scored, columns=["client_id", "score", "flag", "model_version"]) rejects = pd.DataFrame(rejected, columns=["row", "client_id", "problem"]) write_atomic(args.output / "rejected.csv", lambda p: rejects.to_csv(p, index=False)) failed = rows == 0 or len(rejected) / rows > args.max_rejected if not failed: write_atomic(args.output / "scores.csv", lambda p: scores.to_csv(p, index=False)) seconds["write"] = time.perf_counter() - step total = time.perf_counter() - started
summary = { "status": "failed: too many rejected rows" if failed else "done", "input": str(args.input), "input_sha256": sha256(args.input), "rows": rows, "scored": 0 if failed else len(scores), "rejected": len(rejected), "flagged": None if failed else int(scores["flag"].sum()), "flag_rate": None if failed else round(float(scores["flag"].mean()), 3), "model_version": bundle.version, "bundle_sha256": bundle.digest, "scores_sha256": None if failed else sha256(args.output / "scores.csv"), "started_at": started_at.isoformat(timespec="seconds"), "seconds": round(total, 2), "steps_seconds": {k: round(v, 2) for k, v in seconds.items()}, "rows_per_second": round(rows / total), "peak_memory_mb": peak_memory_mb(), } write_atomic(args.output / "summary.json", lambda p: p.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8"))
if failed: print(f"Failed: {len(rejected)} of {rows} rows were rejected (the limit is {args.max_rejected:.0%}). " f"No scores.csv was written. See {args.output / 'rejected.csv'}.", file=sys.stderr) return 1 print(f"Read {rows} rows: scored {len(scores)}, rejected {len(rejected)}, flagged {summary['flagged']} " f"({summary['flag_rate']}). Model {bundle.version}. {summary['seconds']} s, " f"{summary['rows_per_second']} rows/s.") return 0
if __name__ == "__main__": sys.exit(main())Étape 2 : l’exécuter pour chaque client
Section intitulée « Étape 2 : l’exécuter pour chaque client »La tâche a besoin de votre empreinte. Placez-la dans la variable d’environnement MODEL_SHA256 de ce terminal :
export MODEL_SHA256=$(shasum -a 256 bundles/default-risk-1.0.0/SHA256SUMS | cut -c1-64)$env:MODEL_SHA256 = (Get-FileHash bundles\default-risk-1.0.0\SHA256SUMS).Hash.ToLower()export MODEL_SHA256=$(sha256sum bundles/default-risk-1.0.0/SHA256SUMS | cut -c1-64)Exécutez ensuite la tâche :
python -m default_risk.batch data/clients-2005-09.csv out/2005-09La sortie :
Read 30000 rows: scored 30000, rejected 0, flagged 11710 (0.39). Model 1.0.0. 1.28 s, 23517 rows/s.Ouvrez out/2005-09/summary.json :
{ "status": "done", "input": "data/clients-2005-09.csv", "input_sha256": "971006c66c955c0c5b72c0e90b116f9830df8e699d428dfccc576a6cf8d805cd", "rows": 30000, "scored": 30000, "rejected": 0, "flagged": 11710, "flag_rate": 0.39, "model_version": "1.0.0", "bundle_sha256": "da75a5cfd4982660823e22af049358e3359fa1d03b19769d55eadb7d623c9150", "scores_sha256": "8cae5c7a9b4130fff5027ef91980cdf12c9288305a64b890d9baf68527766bbe", "started_at": "2026-10-08T14:57:13+00:00", "seconds": 1.28, "steps_seconds": { "load": 0.7, "check": 0.35, "score": 0.15, "write": 0.03 }, "rows_per_second": 23517, "peak_memory_mb": 237.1}- La liste d’appels compte 11 710 clients (39,0 %), près des 38,2 % des données de test.
- Vérifier les lignes a pris plus de temps que calculer leurs scores (0,35 s contre 0,15 s). Le modèle coûte peu; les coûts fixes, eux, restent élevés.
- Chaque nombre est traçable : l’entrée et la sortie par leurs empreintes, le modèle par son empreinte.
- Le pic de mémoire, 237 Mo, détermine la limite du conteneur dans la partie 7.
Exécutez la tâche de nouveau dans un nouveau dossier et comparez les empreintes :
python -m default_risk.batch data/clients-2005-09.csv out/2005-09-againLes deux résumés (seulement les lignes contenant scores_sha256) :
out/2005-09/summary.json: "scores_sha256": "8cae5c7a9b4130fff5027ef91980cdf12c9288305a64b890d9baf68527766bbe",out/2005-09-again/summary.json: "scores_sha256": "8cae5c7a9b4130fff5027ef91980cdf12c9288305a64b890d9baf68527766bbe",La même entrée et le même paquet ont produit le même fichier, octet pour octet. Une nouvelle exécution après un plantage est donc sûre.
L’échec partiel est couvert par les tests. test_a_bad_row_is_set_aside écrit 200 clients, dont un avec PAY_1 défini à "late" (en retard). Il vérifie que 199 scores sont calculés. Il vérifie aussi que rejected.csv contient la ligne 7, le client C-000006 et le problème PAY_1: Input should be a valid integer, unable to parse string as an integer (entier valide attendu, conversion du texte en entier impossible). test_too_many_bad_rows_fail_the_job vérifie le code de sortie 1, l’absence de scores.csv et l’absence de fichier .partial restant. Exécutez python -m pytest : 20 réussis, 2 ignorés.
Partie 5 : voie 2, le point de terminaison en ligne
Section intitulée « Partie 5 : voie 2, le point de terminaison en ligne »Les agents attendent. Le travail lent, soit vérifier et charger le paquet, se fait donc une seule fois au démarrage, comme dans Charger et initialiser. Chaque requête vérifie seulement un client et calcule son score.
Étape 1 : les routes
Section intitulée « Étape 1 : les routes »Créez le fichier default_risk/routes.py :
import jsonimport logging
from fastapi import APIRouter, Depends, Request, Responsefrom pydantic import BaseModel, Field
from default_risk.contract import ClientInfrom default_risk.errors import ErrorResponsefrom default_risk.scoring import score_clientsfrom default_risk.security import require_api_key
predictions = logging.getLogger("default_risk.predictions")
router = APIRouter(prefix="/v1", dependencies=[Depends(require_api_key)])
ERRORS = { 401: {"model": ErrorResponse, "description": "The API key is missing or wrong."}, 422: {"model": ErrorResponse, "description": "The client does not match the contract."},}
class ScoreOut(BaseModel): client_id: str score: float = Field(ge=0, le=1, description="0 to 1. Higher means a higher risk of missing the next payment.") flag: bool = Field(description="True: offer the client an early-help plan.") threshold: float = Field(description="The score at and above which a client is flagged.") model_version: str warnings: list[str] = Field(description="Values the model never saw. The score is less certain.")
class ModelInfo(BaseModel): model_version: str bundle_sha256: str threshold: float trained_at: str features: list[str]
@router.post("/score", tags=["scoring"], summary="Score one client now", responses=ERRORS)def score(client: ClientIn, request: Request, response: Response) -> ScoreOut: bundle = request.app.state.bundle [result], times = score_clients(bundle, [client]) response.headers["Server-Timing"] = ", ".join(f"{k};dur={v:.2f}" for k, v in times.items()) predictions.info(json.dumps({"request_id": request.state.request_id, "client_id": client.client_id, "model_version": bundle.version, "score": result.score, "flag": result.flag})) return ScoreOut(client_id=client.client_id, score=result.score, flag=result.flag, threshold=bundle.threshold, model_version=bundle.version, warnings=result.warnings)
@router.get("/model", tags=["model"], summary="Which model answers, and how to check it")def model(request: Request) -> ModelInfo: bundle = request.app.state.bundle return ModelInfo(model_version=bundle.version, bundle_sha256=bundle.digest, threshold=bundle.threshold, trained_at=bundle.metadata["trained_at"], features=bundle.contract["features"])score_clients est la fonction que la tâche par lots appelle, ici avec une liste d’un seul client. Cette ligne explique pourquoi les deux voies peuvent donner le même résultat.
Étape 2 : le démarrage
Section intitulée « Étape 2 : le démarrage »Remplacez default_risk/main.py par ce fichier. C’est le main.py du cours, sans les tâches, le modèle parallèle et la surveillance :
"""The online path: the credit-default model behind an HTTP API for the call centre.
Start-up does the slow work once: read the settings, check and load thebundle, score the parity cases. Each request does only the fast work: checkthe client, score it, answer with the model version."""
import loggingimport timeimport uuidfrom contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from default_risk.bundle import BundleError, load_bundlefrom default_risk.errors import add_error_handlers, error_responsefrom default_risk.routes import routerfrom default_risk.settings import Settings, SettingsError, load_settings
MAX_BODY_BYTES = 16_384 # one client is about 320 bytes of JSON
logger = logging.getLogger("default_risk")
def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or load_settings() logging.basicConfig(level=settings.log_level, format="%(levelname)s %(name)s: %(message)s")
@asynccontextmanager async def lifespan(app: FastAPI): started = time.perf_counter() try: app.state.bundle = load_bundle(settings.model_bundle, settings.model_sha256) except BundleError as error: logger.error("Start-up error: %s", error) raise logger.info("model %s loaded (bundle %s...) in %.0f ms", app.state.bundle.version, app.state.bundle.digest[:12], (time.perf_counter() - started) * 1000) yield
app = FastAPI( title="Credit Default Service", version="1.0.0", description="Scores a credit-card client for the risk of missing the next payment. " "For early-help offers only: not for refusing credit, limits or prices.", lifespan=lifespan, docs_url="/docs" if settings.show_docs else None, redoc_url=None, openapi_url="/openapi.json" if settings.show_docs else None, ) app.state.settings = settings
@app.middleware("http") async def request_id_and_limits(request: Request, call_next): request.state.request_id = uuid.uuid4().hex[:12] started = time.perf_counter() length = request.headers.get("content-length") if length and int(length) > MAX_BODY_BYTES: response = error_response(request, 413, "body_too_large", f"The body is larger than {MAX_BODY_BYTES} bytes.") else: response = await call_next(request) response.headers["X-Request-ID"] = request.state.request_id logger.info("%s %s %d %.1fms id=%s", request.method, request.url.path, response.status_code, (time.perf_counter() - started) * 1000, request.state.request_id) return response
add_error_handlers(app) app.include_router(router)
@app.get("/health", tags=["health"]) def health() -> dict: return {"status": "ok"}
@app.get("/ready", tags=["health"]) def ready(request: Request) -> dict: bundle = getattr(request.app.state, "bundle", None) if bundle is None: return error_response(request, 503, "not_ready", "The model is not loaded.") return {"status": "ready", "model_version": bundle.version}
return app
try: app = create_app()except SettingsError as error: raise SystemExit(f"Start-up error: {error}") from NoneÉtape 3 : l’appeler
Section intitulée « Étape 3 : l’appeler »Démarrez l’API dans le terminal où MODEL_SHA256 est défini :
fastapi devLa ligne de démarrage dans son journal :
INFO default_risk: model 1.0.0 loaded (bundle da75a5cfd498...) in 968 msOuvrez un deuxième terminal (Terminal › New Terminal (nouveau terminal)) et envoyez le client d’exemple. Sous Windows, tapez curl.exe plutôt que curl.
curl -i -X POST http://127.0.0.1:8000/v1/score -H "Content-Type: application/json" -d @requests/client.jsonLa sortie :
HTTP/1.1 200 OKdate: Thu, 08 Oct 2026 14:57:39 GMTserver: uvicorncontent-length: 119content-type: application/jsonserver-timing: frame;dur=0.76, prepare;dur=3.60, model;dur=0.17x-request-id: ceba7eac2a28
{"client_id":"C-000002","score":0.35052096497827656,"flag":true,"threshold":0.17,"model_version":"1.0.0","warnings":[]}Le score est égal à celui de la tâche par lots pour C-000002. Dans server-timing, le modèle a pris 0,17 ms et le prétraitement, 3,6 ms. Encore une fois, le modèle n’est pas la partie lente.
Envoyez maintenant les deux autres corps de requête : un client avec le code jamais vu PAY_5 = 1, puis un client avec une limite de 120 et l’état "late" :
curl -X POST http://127.0.0.1:8000/v1/score -H "Content-Type: application/json" -d @requests/unseen-code.jsoncurl -X POST http://127.0.0.1:8000/v1/score -H "Content-Type: application/json" -d @requests/outside-contract.jsonLa sortie :
{"client_id":"C-000002","score":0.33316541823108353,"flag":true,"threshold":0.17,"model_version":"1.0.0","warnings":["PAY_5: 1 is a value the model never saw"]}{"error":{"code":"invalid_client","message":"The client does not match the model's contract.","request_id":"9fa0a91dca39","fields":["LIMIT_BAL","PAY_1"]}}Le code jamais vu reçoit un score avec un avertissement. Le client incorrect reçoit 422 avec les deux champs; la tâche par lots le placerait dans rejected.csv. Un contrat, deux façons de signaler le problème.
Le journal contient une ligne de prédiction par score, par exemple INFO default_risk.predictions: {"request_id": "ceba7eac2a28", "client_id": "C-000002", "model_version": "1.0.0", "score": 0.35052096497827656, "flag": true}. Elle permet de relier les prédictions aux résultats plus tard, comme dans Surveiller la santé du modèle.
Exécutez python -m pytest : 37 réussis. Tous les tests s’exécutent maintenant, y compris tests/test_parity.py, que vous découvrirez dans la prochaine partie. Gardez fastapi dev en marche.
Partie 6 : prouver que les deux voies concordent
Section intitulée « Partie 6 : prouver que les deux voies concordent »Jia-Hui demande : « La liste d’appels indique 0,35 pour ce client. L’écran de mon agent indiquera aussi 0,35? » Pas toujours : l’écran envoie le dossier d’aujourd’hui, qui peut contenir un nouveau paiement. Ce n’est pas un défaut. Un défaut serait un écart entre les voies : le même dossier reçoit des scores différents selon la voie.
Comparez donc les voies avec des dossiers fixes. Chacun des 12 cas de parité doit obtenir son expected_score des deux voies, à 1e-9 près, comme dans Vérifier la parité.
Étape 1 : le script de vérification
Section intitulée « Étape 1 : le script de vérification »Créez le dossier parity et le fichier parity/check_paths.py :
"""Compare the two serving paths with training on the bundle's parity cases.
python parity/check_paths.py http://127.0.0.1:8000 bundles/default-risk-1.0.0 out/parity/scores.csv
The batch scores come from a run of the batch job on the parity cases:
python -m default_risk.batch bundles/default-risk-1.0.0/parity_cases.csv out/parity
The online scores come from POST /v1/score on the running API. Each mustequal expected_score, the score that training gave the same client, withinthe tolerance; so the batch and online scores also equal each other."""
import argparseimport jsonimport osimport sysimport urllib.requestfrom pathlib import Path
import pandas as pd
TOLERANCE = 1e-9
def online_score(url: str, client: dict, key: str | None) -> float: request = urllib.request.Request( f"{url}/v1/score", data=json.dumps(client).encode(), headers={"Content-Type": "application/json", **({"X-API-Key": key} if key else {})}, ) with urllib.request.urlopen(request, timeout=10) as response: return json.load(response)["score"]
def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("url") parser.add_argument("bundle", type=Path) parser.add_argument("batch_scores", type=Path, help="scores.csv of a batch run on the parity cases") parser.add_argument("--tolerance", type=float, default=TOLERANCE) args = parser.parse_args()
cases = pd.read_csv(args.bundle / "parity_cases.csv") batch = pd.read_csv(args.batch_scores).set_index("client_id")["score"] key = os.environ.get("API_KEY") failed = 0 print(f"{'case':9} {'client':9} {'training':>9} {'batch':>9} {'online':>9} {'largest diff':>12}") for row in cases.to_dict("records"): expected, case = row.pop("expected_score"), row.pop("case") online = online_score(args.url, {k: (v.item() if hasattr(v, "item") else v) for k, v in row.items()}, key) batch_score = float(batch[row["client_id"]]) largest = max(abs(batch_score - expected), abs(online - expected), abs(batch_score - online)) ok = largest <= args.tolerance failed += not ok print(f"{case:9} {row['client_id']:9} {expected:9.6f} {batch_score:9.6f} {online:9.6f} {largest:12.1e}" f"{'' if ok else ' FAIL'}") print(f"{len(cases) - failed} of {len(cases)} cases match on both paths within {args.tolerance:g}.") return 1 if failed else 0
if __name__ == "__main__": sys.exit(main())La tâche par lots lit seulement les colonnes du contrat. Elle peut donc lire parity_cases.csv directement : elle ignore case et expected_score.
Étape 2 : exécuter les deux voies sur les cas
Section intitulée « Étape 2 : exécuter les deux voies sur les cas »Dans le deuxième terminal, définissez de nouveau MODEL_SHA256 (la commande de la partie 4, étape 2), puis :
python -m default_risk.batch bundles/default-risk-1.0.0/parity_cases.csv out/paritypython parity/check_paths.py http://127.0.0.1:8000 bundles/default-risk-1.0.0 out/parity/scores.csvLa sortie :
Read 12 rows: scored 12, rejected 0, flagged 6 (0.5). Model 1.0.0. 0.71 s, 17 rows/s.case client training batch online largest diffnormal C-000001 0.761597 0.761597 0.761597 0.0e+00normal C-000002 0.350521 0.350521 0.350521 5.6e-17normal C-000003 0.123973 0.123973 0.123973 2.8e-17normal C-000004 0.142994 0.142994 0.142994 0.0e+00normal C-000005 0.178072 0.178072 0.178072 1.1e-16normal C-000006 0.142699 0.142699 0.142699 8.3e-17odd code C-900001 0.147783 0.147783 0.147783 0.0e+00odd code C-900002 0.094542 0.094542 0.094542 6.9e-17extreme C-900003 0.173154 0.173154 0.173154 2.8e-17extreme C-900004 0.043804 0.043804 0.043804 4.2e-17extreme C-900005 0.433764 0.433764 0.433764 0.0e+00unseen C-900006 0.316648 0.316648 0.316648 0.0e+0012 of 12 cases match on both paths within 1e-09.- Les différences ne sont pas nulles, même sur un seul ordinateur. La tâche par lots additionne les nombres dans un ordre différent de celui de 12 requêtes d’une ligne. C’est pourquoi la parité utilise une tolérance.
- 1e-9 détecte quand même les véritables écarts entre les voies. Une voie qui omet
group_educationa fait passerC-900001de 0,148 à 0,309 : plus de 100 millions de fois la tolérance. - Les cas extrêmes testent les calculs, pas leur sens.
C-900004a 8 mois de retard partout, mais obtient un score de 0,044, car ses paiements sont aussi au maximum. Aucun vrai client ne ressemble à cela.
tests/test_parity.py exécute la même comparaison dans pytest. Il lance la tâche par lots comme sous-processus et utilise l’API avec le client de test de FastAPI. Ainsi, une modification qui brise la parité fait échouer les tests avant d’atteindre un conteneur. Arrêtez fastapi dev avec Ctrl+C.
Partie 7 : une image, deux commandes, avec des limites
Section intitulée « Partie 7 : une image, deux commandes, avec des limites »L’API fonctionne en permanence; la tâche par lots s’exécute une fois par mois, puis s’arrête. Elles ont quand même besoin du même code et du même paquet. Utilisez donc une image avec deux commandes et les limites de Paramètres de ressources du conteneur.
Étape 1 : le Dockerfile et le fichier Compose
Section intitulée « Étape 1 : le Dockerfile et le fichier Compose »Créez le fichier Dockerfile. C’est le Dockerfile du cours avec default_risk plutôt que escalation :
FROM python:3.14.8-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_ROOT_USER_ACTION=ignore
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY requirements-run.txt .RUN pip install -r requirements-run.txt
COPY bundles ./bundlesCOPY pyproject.toml .COPY default_risk ./default_risk
USER app
EXPOSE 8000CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"]Le .dockerignore du projet de départ envoie seulement requirements-run.txt, pyproject.toml, default_risk/ et bundles/ à l’outil de construction. Les données, model/ et .env n’entrent jamais dans l’image.
Créez le fichier compose.yaml :
# One image, two ways to use it: the online API (always on) and the monthly# batch job (runs, then stops). Both get the same bundle and the same digest# from .env (copy .env.example), and the resource limits of a small cloud# container.services: api: image: ${IMAGE:-credit-default-service:1.0.0} build: . ports: - "127.0.0.1:${PORT:-8000}:8000" environment: &model MODEL_BUNDLE: bundles/default-risk-${MODEL_VERSION:-1.0.0} MODEL_SHA256: ${MODEL_SHA256:?Set MODEL_SHA256 in .env} cpus: 1.0 mem_limit: 512m memswap_limit: 512m # the same value: no swap, as in most cloud containers healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready')"] interval: 5s timeout: 3s retries: 3 start_period: 10s
batch: image: ${IMAGE:-credit-default-service:1.0.0} profiles: ["batch"] # not started by "up": run it with "docker compose run --rm batch IN OUT" environment: *model entrypoint: ["python", "-m", "default_risk.batch"] volumes: - ./data:/app/data:ro - ./out:/app/out cpus: 1.0 mem_limit: 512m memswap_limit: 512m| Ligne | Pourquoi |
|---|---|
&model et *model |
YAML nomme le bloc et le réutilise : les deux services ne peuvent pas recevoir des paquets différents par erreur. |
profiles: ["batch"] |
up ne démarre pas la tâche; un planificateur, ou vous, la démarre avec docker compose run. |
./data:/app/data:ro |
La tâche peut lire le fichier mensuel, mais jamais le modifier. |
| 512 Mio, sans mémoire d’échange | Le pic de la tâche était de 237 Mo et l’API utilise environ 150 Mio : chacune a de la marge. |
Étape 2 : démarrer l’API et exécuter la tâche
Section intitulée « Étape 2 : démarrer l’API et exécuter la tâche »Copiez le fichier de paramètres d’exemple, puis collez votre empreinte après MODEL_SHA256= dans .env :
cp .env.example .envCopy-Item .env.example .envcp .env.example .envConstruisez l’image, démarrez l’API et attendez qu’elle soit en bon état. Lisez ensuite sa ligne de démarrage :
docker compose up -d --build --waitdocker compose logs apiLa première construction a pris 40 secondes. La commande se termine quand le conteneur est Healthy (en bon état). La ligne de démarrage :
api-1 | INFO default_risk: model 1.0.0 loaded (bundle da75a5cfd498...) in 825 msExécutez maintenant la tâche mensuelle dans la même image, puis vérifiez la parité avec l’API dans son conteneur :
docker compose run --rm batch data/clients-2005-09.csv out/2005-09-containerdocker compose run --rm batch bundles/default-risk-1.0.0/parity_cases.csv out/parity-containerpython parity/check_paths.py http://127.0.0.1:8000 bundles/default-risk-1.0.0 out/parity-container/scores.csvLa sortie (les lignes de la tâche et la dernière ligne de la vérification) :
Read 30000 rows: scored 30000, rejected 0, flagged 11710 (0.39). Model 1.0.0. 1.77 s, 16963 rows/s.Read 12 rows: scored 12, rejected 0, flagged 6 (0.5). Model 1.0.0. 0.7 s, 17 rows/s.12 of 12 cases match on both paths within 1e-09.Les deux voies concordent aussi dans les conteneurs. Mais out/2005-09-container/summary.json contient le scores_sha256 4afd711ef9d7…, et non 8cae5c7a9b41…. Linux et macOS diffèrent au dernier chiffre de certains scores. Comparez donc les empreintes des fichiers seulement entre des exécutions de la même image. Deux exécutions dans le conteneur ont donné la même empreinte. Entre ordinateurs, comparez les scores avec une tolérance.
Étape 3 : mesurer les deux voies
Section intitulée « Étape 3 : mesurer les deux voies »La tâche par lots se mesure elle-même dans summary.json. Pour l’API, utilisez le banc d’essai du cours, comme dans Mesurer la performance du service. Il utilise les clients mensuels, un par requête, après 50 requêtes de réchauffement. Lisez ensuite la mémoire du conteneur :
python bench/bench.py http://127.0.0.1:8000 --requests 1000 --concurrency 1docker stats --no-streamPour les graphiques de cette page, nous l’avons exécuté avec --concurrency 1, --concurrency 4, puis une fois pour tous les clients (--requests 30000 --warmup 0). D’autres conteneurs fonctionnaient sur l’ordinateur pendant ce temps. Les trois lignes de résultats :
1000 requests, concurrency 1: p50 6.1 ms, p95 13.7 ms, p99 25.7 ms, max 54.2 ms, 136 requests/s, 0 errors.1000 requests, concurrency 4: p50 19.2 ms, p95 71.8 ms, p99 103.5 ms, max 249.1 ms, 158 requests/s, 0 errors.30000 requests, concurrency 1: p50 4.7 ms, p95 13.5 ms, p99 27.2 ms, max 339.7 ms, 160 requests/s, 0 errors.docker stats indiquait 152,1 Mio sur la limite de 512 Mio pour l’API.
Faites une prédiction avant de regarder : le budget de Jia-Hui est de 200 ms. Combien des 1 000 réponses ont pris plus de 30 ms?
Description textuelle du graphique
Un histogramme de 1 000 temps de réponse en classes de 1 milliseconde, de 0 à 30 ms. Aucune réponse ne prend moins de 3,7 ms. 650 réponses se situent entre 4 et 7 ms. Le nombre diminue vers la droite, avec une faible traîne au-delà de 10 ms (113 réponses). Une ligne pointillée marque la médiane, p50, à 6.1 ms, et une seconde ligne marque p95 à 13.7 ms. Huit réponses de 30 ms ou plus ne sont pas représentées; la plus lente a pris 54.2 ms. Les 1 000 réponses sont largement sous le budget de 200 ms du centre d’appels.
- Le budget n’est pas le risque. p95 est de 13,7 ms contre 200 ms; même la réponse la plus lente respecte le budget.
- Plus de requêtes simultanées ne fait qu’allonger l’attente. Avec 4 requêtes simultanées, p50 est passé de 6,1 à 19,2 ms. Le débit, lui, est seulement passé de 136 à 158 requêtes par seconde. Un processus sur 1 CPU est occupé, donc les requêtes attendent en file, comme dans Choisir l’infrastructure et mesurer.
- Les bancs d’essai varient. Une exécution précédente, à un moment plus calme, a donné p95 à 7,9 ms et 210 requêtes par seconde. Notez les conditions avec chaque résultat.
Nous n’avons mesuré ni plateforme infonuagique ni réseau du centre d’appels. Nous n’affirmons donc aucun nombre pour eux.
Laissez les conteneurs en marche pour la partie 8.
Partie 8 : un échec : le nouvel export
Section intitulée « Partie 8 : un échec : le nouvel export »L’équipe de Hao déplace l’export vers un nouvel entrepôt de données. Avant le changement, Hao envoie un export de test de septembre provenant du nouveau système. Personne ne remarque que la nouvelle vue stocke la limite de crédit en milliers de dollars NT : 20 000 devient 20. Le script peut créer le même fichier :
python scripts/export_month.py --limit-in-thousands data/clients-2005-09-new-export.csvFaites une prédiction avant de regarder : que fait la tâche par lots avec ce fichier? Et que ferait le modèle sans le contrat?
D’abord, le modèle seul. Démarrez Python et collez :
import joblibimport pandas as pdfrom default_risk.contract import FEATURES
pipeline = joblib.load("model/model.joblib")for name in ["data/clients-2005-09.csv", "data/clients-2005-09-new-export.csv"]: scores = pipeline.predict_proba(pd.read_csv(name)[FEATURES])[:, 1] print(f"{name}: {(scores >= 0.17).sum()} of {len(scores)} flagged ({(scores >= 0.17).mean():.1%})")La sortie :
data/clients-2005-09.csv: 11710 of 30000 flagged (39.0%)data/clients-2005-09-new-export.csv: 29498 of 30000 flagged (98.3%)Sans contrat, la liste d’appels compterait 29 498 clients plutôt que 11 710. Maintenant, la tâche par lots, dans son conteneur :
docker compose run --rm batch data/clients-2005-09-new-export.csv out/2005-09-new-exportLa sortie :
Failed: 30000 of 30000 rows were rejected (the limit is 1%). No scores.csv was written. See out/2005-09-new-export/rejected.csv.Le code de sortie est 1, donc un planificateur marque l’exécution comme échouée. Le dossier contient seulement rejected.csv et summary.json. Les premières lignes de rejected.csv :
row,client_id,problem2,C-000001,LIMIT_BAL: Input should be greater than or equal to 100003,C-000002,LIMIT_BAL: Input should be greater than or equal to 10000Erreur courante
La tâche mensuelle échoue : 30 000 lignes sur 30 000 rejetées, LIMIT_BAL inférieur à 10000
Symptôme. La tâche par lots se termine avec le code 1 et n’écrit aucun scores.csv. Chaque ligne de rejected.csv contient le même problème : LIMIT_BAL: Input should be greater than or equal to 10000 (la valeur doit être supérieure ou égale à 10000). summary.json contient "status": "failed: too many rejected rows" (état : échec, trop de lignes rejetées).
Cause. L’export a changé d’unité : la limite de crédit est en milliers. Le modèle n’a aucun moyen de le savoir. Il aurait signalé 98,3 % des clients. La limite inférieure du contrat, 10 000, soit la plus petite limite des données, a détecté le problème.
Correction. Corrigez l’export, pas le contrat. L’équipe de Hao multiplie la limite par 1 000 dans la nouvelle vue et envoie un nouveau fichier de test. La tâche s’exécute de nouveau, dans un nouveau dossier. N’abaissez pas la limite du contrat pour faire accepter le fichier. Le contrat décrit ce que le modèle a appris, et le modèle n’a jamais vu une limite de 20 dollars.
Ce que cela signifie pour un vrai service d’IA. Une tâche par lots doit échouer complètement, et visiblement, quand son entrée change. Elle doit aussi conserver la dernière sortie valide. Ici, la liste d’appels de septembre dans out/2005-09/ reste intacte. Mais un contrat détecte seulement les valeurs hors de ses plages. Si la nouvelle vue avait stocké les factures en milliers, chaque facture serait restée dans sa plage et la tâche aurait fonctionné. La seconde défense est le taux de signalement dans summary.json : comparez-le à celui du mois précédent (39,0 %) avant le début des appels.
Arrêtez l’API et supprimez l’image quand vous avez terminé :
docker compose downdocker image rm credit-default-service:1.0.0Notre exécution complète
Section intitulée « Notre exécution complète »Nous avons exécuté chaque étape de cette page à partir de starter.zip, dans un nouveau dossier avec un nouvel environnement virtuel. Nous avons écrit chaque fichier comme la page le montre. Les commandes et les sorties complètes se trouvent dans le dépôt du cours (reference/c17/case-studies/credit-default/RUNS.md).
| Vérification | Résultat |
|---|---|
| Tests après chaque partie | 3 (début), 13, 17, 20, puis 37 réussis; projet terminé : 37 réussis |
| Entraînement | Le même model.joblib, octet pour octet, que celui du paquet du cours (d71c40c4848a…) |
| Tâche par lots, 30 000 clients | 11 710 signalés (39,0 %); de 1,15 à 1,77 s sur l’ordinateur et dans le conteneur; pic de mémoire de 215 à 237 Mo |
| API en ligne | En bon état après up --wait en environ 6 s; modèle chargé en 0,5 à 1,0 s; environ 150 Mio de mémoire |
| Parité | 12 cas sur 12, sur l’ordinateur et dans les conteneurs; plus grande différence de 1,1e-16 |
| Échec | Export modifié : code de sortie 1, 30 000 lignes rejetées, aucun scores.csv |
| Non testé | Windows et Linux; un planificateur pour l’exécution mensuelle; un déploiement infonuagique |
Utilisation responsable
Section intitulée « Utilisation responsable »Un score de crédit détermine comment une banque traite une personne. Les limites de ce modèle font donc partie du produit.
Uniquement pour un appel de soutien. Le seuil, les coûts et les vérifications d’équité de l’étude de cas d’apprentissage automatique visaient une offre d’aide. Le modèle ne doit pas refuser un crédit, réduire une limite, fixer un prix ou décider quoi que ce soit seul. La description de l’API et MODEL_CARD.md le précisent.
Un score n’est pas un jugement. Un score de 0,35 signifie « environ un tiers des clients semblables ont manqué le paiement suivant en 2005 ». L’agent offre de l’aide; le score ne colle pas d’étiquette au client.
Moins de données personnelles sur chaque voie. Le fichier mensuel ne contient ni sexe, ni état matrimonial, ni âge, et l’API les refuse. Les données qu’un service ne reçoit jamais ne peuvent pas fuiter de ce service. Mais le modèle peut quand même traiter les groupes différemment à partir d’autres entrées. Dans l’étude de cas d’apprentissage automatique, le modèle signale plus souvent les jeunes clients, surtout à cause de leurs limites de crédit plus basses. Des personnes qualifiées doivent examiner le taux de signalement par groupe d’âge avant toute utilisation réelle.
Les journaux contiennent des données personnelles. Chaque ligne de prédiction contient un identifiant de client et un score. Une vraie banque conserve ces journaux selon ses règles sur les données : qui peut les lire, et pendant combien de temps.
Les données sont anciennes et viennent d’un seul endroit. Une banque à Taiwan, en 2005, pendant une période d’endettement élevé par carte de crédit. Une vraie banque entraîne et teste son modèle sur ses propres données récentes.
Ce que ce projet fait à ce sujet :
- Le contrat refuse ce que le modèle n’a jamais vu.
- Chaque réponse et chaque résumé indiquent la version du modèle et l’empreinte du paquet.
- Le traitement échoue au lieu d’envoyer une liste erronée.
- Le résumé du traitement par lots donne un taux de signalement à vérifier chaque mois.
Faites-en un projet pour votre portfolio
Section intitulée « Faites-en un projet pour votre portfolio »Ce projet montre à un employeur que vous pouvez servir un modèle de deux façons et prouver que les résultats concordent. Publiez-le dans un dépôt, sous votre nom.
Quoi mettre dans le dépôt :
credit-default-service/ README.md what it does, how to run both paths, the data and its licence MODEL_CARD.md intended use, results, what it is not for LICENSE MIT, for the code (the data keeps CC BY 4.0: see data/README.md) data/ the data file and its attribution training/train.py rebuilds the model byte for byte make_bundle.py makes a versioned bundle with parity cases and SHA256SUMS bundles/ your bundle (keep it in Git: it is the released model) default_risk/ contract, bundle loader, scoring, batch job, API parity/check_paths.py proves that both paths agree bench/bench.py the benchmark tests/ the 37 tests Dockerfile, compose.yaml, .dockerignore, .env.example- Partez de votre dossier de projet ou du projet terminé. Vérifiez que
.gitignorecontient.venv/,.env,out/,model/etdata/clients-*.csv. - Créez le dépôt :
git init, puisgit add .etgit commit -m "Credit default service: batch and online". - Avec
git ls-files, vérifiez quebundles/default-risk-1.0.0/etdata/credit_default_taiwan.csv.gzy figurent, mais pas.env. - Envoyez-le sur GitHub comme dépôt public. Gardez
LICENSEetdata/README.md: CC BY 4.0 exige l’attribution et la liste des modifications. - Rédigez le premier paragraphe du README dans vos propres mots. Ajoutez vos mesures : la durée du traitement par lots et les p50 et p95 de l’API, avec les conditions.
Comment le présenter en entrevue, en deux phrases : « J’ai servi un modèle de défaut de crédit par traitement mensuel par lots et par API en ligne, à partir d’un seul paquet versionné dont l’empreinte était fixée, et j’ai prouvé avec des cas de parité fixes que les deux voies donnent les mêmes scores à 1e-9 près. Dans des conteneurs avec 1 CPU et 512 MiB, le traitement par lots a évalué 30 000 clients en environ 1,4 seconde, l’API a répondu avec un p95 de 14 ms, et le contrat a bloqué un export avec la mauvaise unité avant qu’il devienne une liste d’appels. »
Pour aller plus loin :
- Une vérification d’un mois à l’autre. Faites comparer le taux de signalement du traitement par lots avec le résumé de l’exécution précédente. Le traitement avertit ou échoue quand le taux varie au-delà d’une valeur fixée. Testez-le avec un fichier où les factures sont en milliers.
- Un planificateur. Exécutez le conteneur de traitement par lots avec une tâche planifiée sur une plateforme de conteneurs, et conservez chaque résumé mensuel.
- Une nouvelle version en parallèle. Entraînez un paquet 1.1.0 et exécutez le traitement par lots avec les deux paquets sur le même fichier. Comparez les listes d’appels avant de changer de version, comme dans Versions et déploiement progressif.
- Les raisons à l’écran de l’agent. Renvoyez les trois principales raisons du client avec le score (l’étude de cas d’apprentissage automatique les calcule). Les agents pourront ainsi expliquer l’offre. Testez que les deux voies donnent les mêmes raisons.
Vérification des connaissances
Section intitulée « Vérification des connaissances »Vérifiez votre compréhension
La liste d'appels indique 0,35 pour le client C-000002. Pendant un appel deux semaines plus tard, l'écran de l'agent affiche 0,21. Est-ce un écart entre l'entraînement et le service? Comment le vérifier?
Afficher la réponse
Pas nécessairement. Le traitement par lots a évalué le relevé de septembre. L’écran de l’agent envoie le dossier d’aujourd’hui, qui peut inclure un nouveau paiement ou un changement de statut. Une entrée différente donne un score différent. Pour le vérifier, comparez les voies avec la même entrée. Exécutez check_paths.py sur les cas de parité. Ou envoyez le dossier de septembre du client à l’API, et comparez la réponse au score du traitement par lots. Si les deux concordent à 1e-9 près, les voies fonctionnent bien, et la différence vient des données. Sinon, une voie fait quelque chose que l’autre ne fait pas : c’est un écart.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Hao vous demande d'abaisser le minimum du contrat pour LIMIT_BAL de 10 000 à 0, pour accepter le nouvel export. Que répondez-vous, et pourquoi?
Afficher la réponse
Non. Le minimum est la plus petite limite à partir de laquelle le modèle a appris. Une limite de 20 est une valeur en milliers, pas une limite réelle. Le modèle l’évaluerait comme un client presque sans crédit, et signalerait 98,3 % des clients. Le contrat protège le sens des entrées du modèle. Il faut donc corriger l’export, qui doit fournir des dollars NT. Un nouveau contrat ne convient qu’avec un nouveau modèle entraîné sur des données dans cette unité. Cela exige une nouvelle version du paquet.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Le traitement par lots évalue 30 000 clients environ 130 fois plus vite que l'API avec une requête par client, avec le même modèle. D'où vient la différence, et pourquoi la banque a-t-elle encore besoin de l'API?
Afficher la réponse
Chaque requête entraîne des coûts fixes : connexion HTTP, JSON, vérification du contrat, tableau d’une ligne et prétraitement d’une ligne. Le modèle lui-même prend environ 0,1 ms. Le traitement par lots paie les coûts du tableau et du prétraitement une fois pour 5 000 clients. Il atteint donc environ 20 000 clients par seconde, contre environ 160 pour l’API. Mais le résultat du traitement par lots est aussi ancien que son fichier. Le centre d’appels a besoin d’un score tiré du dossier d’aujourd’hui pendant que le client est au téléphone. Il faut une réponse pour un client en quelques millisecondes : c’est la voie en ligne.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Récapitulatif
- Un modèle peut nécessiter deux voies : un traitement par lots pour le débit (tous les clients, selon un horaire) et un point de terminaison en ligne pour la latence (un client, maintenant). Ici, le traitement par lots était environ 130 fois plus rapide pour l’ensemble des clients. L’API respectait un budget de 200 ms avec un p95 de 14 ms.
- Les deux voies chargent le même paquet, fixé par son empreinte conservée à l’extérieur du paquet. Elles partagent le contrat et le code de calcul des scores. Un pipeline qui appelle vos propres fonctions a aussi besoin de leur code : le paquet consigne le hachage de
features.py. - Les plages du contrat viennent des données. Elles ont bloqué une limite de crédit en milliers, que le modèle seul aurait évaluée, signalant 98,3 % des clients.
- Le traitement par lots met les mauvaises lignes de côté et échoue quand elles sont trop nombreuses. Il écrit les fichiers au complet ou pas du tout, et laisse un résumé avec les hachages, les durées et le taux de signalement.
- La parité sur des cas fixes prouve que les voies concordent : 12 sur 12 à 1e-9 près. Des différences de 1e-16 sont normales, même sur un seul ordinateur. Comparez donc les scores avec une tolérance, et les hachages des fichiers seulement avec la même image.
- Une image exécute les deux voies avec des limites (1 CPU, 512 MiB). Consignez les conditions avec chaque mesure, et n’affirmez rien que vous n’avez pas mesuré.
Ensuite, la deuxième étude de cas : un modèle de prix des maisons réentraîné, exécuté en parallèle sur des requêtes réelles, promu, puis ramené à la version précédente.
Leçon suivante : Mettre en production un nouveau modèle de prix des maisons