Aller au contenu
English

Vérifier la parité

  • Leçon 9
  • 40 min
  • Module 3 : Créer le service de prédiction

Ce que vous saurez faire

Prouvez que le service en marche donne les mêmes scores que l’entraînement pour des billets de soutien fixes. Testez des entrées normales, manquantes, extrêmes et inconnues. Puis détectez immédiatement les écarts.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 40 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

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 n’ont pas été testées.

Une boulangerie ouvre une deuxième boutique. Les nouveaux boulangers reçoivent la même recette et la même farine. Chaque matin, la propriétaire achète quelques produits fixes dans les deux boutiques : le même pain et le même gâteau. Elle les compare côte à côte. Elle ne goûte pas un seul pain pour décider que tout va bien. Un défaut du nouveau four apparaît seulement dans les produits qui ont besoin de sa chaleur supérieure.

Votre service est la deuxième boutique. Pendant l’entraînement, les billets recevaient un score d’une certaine façon, avec un tableau de lignes et predict_proba(). Le service calcule leurs scores autrement : JSON, le contrat, to_frame(), puis le pipeline. Les deux chemins doivent donner le même score pour le même billet.

Tomás a changé une ligne dans une copie du service pour « nettoyer » l’entrée. Maintenant, to_frame() remplace un nombre manquant par 0. Puis il a vérifié le service : le journal de démarrage, /ready et le billet d’exemple.

INFO escalation: model 1.0.0 loaded (bundle 8f233029aa4c...) in 660 ms
{"status":"ready","model_version":"1.0.0"}
{"ticket_id":"T-120001","score":0.35065699401024064,"flag":true,"threshold":0.19,"model_version":"1.0.0","warnings":[]}

L’ensemble empaqueté a réussi les six vérifications, y compris l’autovérification. /ready indique que le service est prêt. Le billet d’exemple reçoit exactement le bon score. « Ça marche. On le met en production. »

Ça ne marche pas. Le billet d’exemple n’a aucune valeur manquante, donc le changement ne le touche pas. Envoyez un client invité, avec customer_tenure_days nul, et le service répond 0,250 au lieu de 0,145. Le billet est maintenant signalé et passe à l’équipe expérimentée. Le service traite l’invité comme un client depuis 0 jour, ce que le modèle a appris à considérer comme risqué. L’autovérification n’a pas détecté cela. Elle calcule les scores directement avec le pipeline, sans passer par le code propre au service.

Un service qui fait quelque chose que l’entraînement ne faisait pas présente un écart entre l’entraînement et le service. Le modèle est correct, mais il reçoit des nombres différents de ceux utilisés pour son apprentissage. Cet écart ne produit aucune erreur. Seule une comparaison des scores le révèle.

La parité permet de vérifier cet écart. Envoyez des billets fixes au service en marche. Comparez chaque score du service avec le score d’entraînement du même billet. Le module 1 a enregistré ces billets dans l’ensemble empaqueté : parity_cases.csv. Chacun a un expected_score, calculé par make_bundle.py.

Deux chemins pour les mêmes billets. Côté entraînement, make_bundle.py, une seule fois : les 14 billets de parité sous forme de tableau passent à pipeline.predict_proba. Les 14 lignes passent ensemble, ce qui donne expected_score dans parity_cases.csv. Côté service, le service en marche : le même billet en JSON passe par la vérification du contrat, to_frame, le prétraitement, puis le modèle sur une ligne. Il donne le score dans la réponse. Les deux scores mènent à une question : la différence est-elle au plus égale à la tolérance, 1e-9? Oui : parité, le service fait ce que l'entraînement faisait. Non : écart entre l'entraînement et le service, trouvez l'étape qui diffère.
La parité compare les deux chemins sur les mêmes billets. Chaque étape côté service peut introduire un écart.
Description textuelle du diagramme

Le côté entraînement s’exécute une seule fois, dans make_bundle.py. Les 14 billets de parité, sous forme de tableau, passent ensemble à pipeline.predict_proba(). Leurs scores sont enregistrés dans expected_score, dans parity_cases.csv. Le côté service est le service en marche. Le même billet en JSON passe par la vérification du contrat et to_frame(). Ensuite, le prétraitement et le modèle calculent le score d’une ligne, et ce score revient dans la réponse. Une décision compare les deux scores : si la différence est au plus égale à la tolérance, 1e-9, il y a parité. Sinon, il y a un écart entre l’entraînement et le service. Vous devez trouver l’étape du service qui diffère.

Les 14 cas ne sont pas aléatoires. Chaque groupe teste un type d’entrée pour lequel un service peut se tromper :

  • 6 billets normaux sans valeur manquante : le cas courant.
  • 3 cas avec valeurs manquantes : order_value nul; un invité avec customer_tenure_days nul; les deux.
  • 3 cas extrêmes : les limites du contrat. T-900004 a tous les risques à leur maximum et obtient exactement 1,0.
  • 2 cas inconnus : channel vaut social; region vaut overseas avec team qui vaut installation.

Vous pourriez vous attendre à ce que chaque différence soit de 0. Voici un cas réel provenant du conteneur. Le score d’entraînement et le score du service pour le billet T-116942 :

training 0.12958362401471518
served 0.12958362401471521

Leurs 15 premiers chiffres concordent. Ils diffèrent seulement à la fin, de 3e-17. Un ordinateur stocke un score avec environ 16 chiffres significatifs. Le dernier chiffre peut changer pour des raisons sans conséquence. L’entraînement calculait les scores de 14 lignes ensemble, et le service calcule celui d’une seule ligne. Les additions se font donc dans un ordre différent. Un autre processeur (celui du conteneur Linux plutôt que macOS) peut utiliser d’autres instructions. De plus, le lecteur CSV rapide de pandas peut perdre le dernier chiffre d’un nombre à 17 chiffres en lisant parity_cases.csv. Il lit le score d’entraînement ci-dessus comme 0,1295836240147151. Ainsi, check_parity.py affiche 1.1e-16, et non 3e-17. Chaque effet est d’environ 1e-17 à 1e-16 : un 1 à la 16e ou 17e décimale.

La parité compare donc les scores avec une tolérance : la plus grande différence qui compte encore comme « identique ». Le service utilise 1e-9. Choisissez-la entre deux nombres. Le bruit d’arrondi est d’environ 1e-16. La tolérance doit donc être bien supérieure : 1e-9 est dix millions de fois plus grand. La décision compare un score à 0,19, qui a deux décimales. Vous devez donc détecter un vrai défaut bien avant qu’il atteigne 0,01. Or, 1e-9 est dix millions de fois plus petit. Une tolérance de 0 échouerait à cause du bruit. Une tolérance de 0,01 laisserait passer un vrai défaut qui déplace chaque score de 0,009.

Créez le dossier parity et le fichier parity/check_parity.py. Il envoie chaque cas de parité au service en marche :

parity/check_parity.py
"""Compare the scores of a running service with the scores the model gave in training.
python parity/check_parity.py http://127.0.0.1:8000 bundles/escalation-1.0.0
Sends every parity case of the bundle to POST /v1/score and compares the
served score with expected_score, the score that the training code gave the
same ticket. A difference larger than the tolerance means the serving path
does something that training did not do (training-serving skew).
"""
import argparse
import json
import os
import sys
import urllib.request
from pathlib import Path
import pandas as pd
TOLERANCE = 1e-9
def as_json_value(value):
if pd.isna(value):
return None
return value.item() if hasattr(value, "item") else value
def served_score(url: str, ticket: dict, key: str | None) -> float:
request = urllib.request.Request(
f"{url}/v1/score",
data=json.dumps(ticket).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"]

as_json_value() transforme une cellule CSV vide en null, pour que la valeur manquante arrive au service comme le contrat le prévoit. Le script utilise seulement la bibliothèque standard et pandas. Il fonctionne donc partout où les paquets du service sont installés. Si API_KEY est défini dans le terminal, il envoie la clé.

La deuxième partie lit les cas, compare les scores et affiche une ligne par cas :

parity/check_parity.py (continued)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("bundle", type=Path)
parser.add_argument("--tolerance", type=float, default=TOLERANCE)
args = parser.parse_args()
cases = pd.read_csv(args.bundle / "parity_cases.csv", keep_default_na=False, na_values=[""],
dtype={"priority": "Int64", "word_count": "Int64", "customer_tenure_days": "Int64",
"prior_tickets_90d": "Int64", "created_hour": "Int64",
"prior_escalations_90d": "Int64"})
key = os.environ.get("API_KEY")
failed = 0
print(f"{'case':8} {'ticket':9} {'expected':>10} {'served':>10} {'difference':>10}")
for row in cases.to_dict("records"):
expected = row.pop("expected_score")
case = row.pop("case")
ticket = {k: as_json_value(v) for k, v in row.items()}
served = served_score(args.url, ticket, key)
difference = abs(served - expected)
ok = difference <= args.tolerance
failed += not ok
print(f"{case:8} {row['ticket_id']:9} {expected:10.6f} {served:10.6f} {difference:10.1e}"
f"{'' if ok else ' FAIL'}")
print(f"{len(cases) - failed} of {len(cases)} cases match within {args.tolerance:g}.")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

Deux détails comptent. keep_default_na=False, na_values=[""] traite seulement une cellule vide comme manquante. Une vraie catégorie comme unknown (inconnu) reste donc du texte. Les types Int64 conservent les nombres entiers, pour que priority soit envoyé comme 2, et non 2.0. Le code de sortie est 1 si un cas échoue. Un script de mise en production peut donc s’arrêter à ce signal.

Démarrez le service dans un terminal avec fastapi run, comme dans Charger et initialiser. Dans un deuxième terminal, lancez la vérification :

Terminal 2, in ~/projects/escalation-service
python parity/check_parity.py http://127.0.0.1:8000 bundles/escalation-1.0.0

La sortie :

case ticket expected served difference
normal T-116940 0.187598 0.187598 2.8e-17
normal T-116941 0.064735 0.064735 0.0e+00
normal T-116942 0.129584 0.129584 8.3e-17
normal T-116943 0.179840 0.179840 5.6e-17
normal T-116944 0.244735 0.244735 0.0e+00
normal T-116945 0.113802 0.113802 8.3e-17
missing T-900001 0.037929 0.037929 7.6e-17
missing T-900002 0.144791 0.144791 5.6e-17
missing T-900003 0.075978 0.075978 1.4e-17
extreme T-900004 1.000000 1.000000 0.0e+00
extreme T-900005 0.039165 0.039165 0.0e+00
extreme T-900006 0.094671 0.094671 5.6e-17
unknown T-900007 0.177527 0.177527 0.0e+00
unknown T-900008 0.111725 0.111725 5.6e-17
14 of 14 cases match within 1e-09.

Les 14 cas concordent, et le code de sortie est 0. Même sur l’ordinateur qui a entraîné le modèle, 9 des 14 différences ne sont pas nulles : c’est le bruit d’arrondi. Vos différences peuvent varier, mais chacune est bien inférieure à 1e-9.

Le service fonctionnera réellement dans un conteneur, que vous construirez au module 4. Amira l’a déjà construit, l’a démarré avec 1 CPU et a exécuté la même commande pour le vérifier. Vous n’avez pas encore l’image. Lisez donc seulement sa sortie (abrégée) :

case ticket expected served difference
normal T-116940 0.187598 0.187598 2.8e-17
normal T-116941 0.064735 0.064735 0.0e+00
normal T-116942 0.129584 0.129584 1.1e-16
...
missing T-900001 0.037929 0.037929 9.7e-17
...
14 of 14 cases match within 1e-09.

Linux, sur le processeur du conteneur, donne des derniers chiffres légèrement différents : jusqu’à 1,1e-16. C’est encore dix millions de fois inférieur à la tolérance. Vous construirez cette image et lancerez vous-même cette vérification dans Paramètres de ressources du conteneur : vérifier la parité dans le conteneur. Vérifiez la parité partout où le service fonctionne. Chaque nouvel environnement peut apporter ses propres petites différences, ou une grande.

check_parity.py vérifie un service en marche de l’extérieur. Cette même vérification doit aussi faire partie des tests, pour s’exécuter à chaque changement du code. Créez tests/test_parity.py :

tests/test_parity.py
"""The served scores equal the training scores for every parity case."""
import pandas as pd
import pytest
from tests.conftest import BUNDLE
INTS = ["priority", "word_count", "customer_tenure_days", "prior_tickets_90d", "created_hour", "prior_escalations_90d"]
CASES = pd.read_csv(BUNDLE / "parity_cases.csv", keep_default_na=False, na_values=[""], dtype={c: "Int64" for c in INTS})
def as_json(row: dict) -> dict:
return {k: (None if pd.isna(v) else (v.item() if hasattr(v, "item") else v)) for k, v in row.items()}
@pytest.mark.parametrize("row", CASES.to_dict("records"), ids=lambda r: f"{r['case']}-{r['ticket_id']}")
def test_served_score_matches_training(client, row):
expected = row.pop("expected_score")
row.pop("case")
served = client.post("/v1/score", json=as_json(row)).json()["score"]
assert served == pytest.approx(expected, abs=1e-9)

parametrize crée un test par cas, nommé selon le groupe et le billet. Un échec indique donc immédiatement le cas. pytest.approx(expected, abs=1e-9) représente la tolérance. La fixture (préparation de test) client envoie les billets à travers toute l’application : le contrat, to_frame(), la route et la réponse. Lancez la suite :

Terminal
python -m pytest

La sortie :

.......................................... [100%]
42 passed in 3.44s

42 tests : les 28 précédents et 14 tests de parité. C’est l’état de l’instantané end-of-m03.

Exploration : quelles erreurs la parité détecte-t-elle?

Section intitulée « Exploration : quelles erreurs la parité détecte-t-elle? »

L’outil d’exploration montre des exécutions réelles. Amira a fait quatre copies du service : la bonne et trois copies contenant chacune une erreur côté service. Elle a exécuté chaque copie dans Docker avec 1 CPU et envoyé les 14 cas. Elle a utilisé la même comparaison que check_parity.py.

Explorer

Parité : quels cas échouent pour chaque erreur côté service?

Cas dans la tolérance
14 sur 14
Plus grande différence
1.1e-16
Signalements modifiés
0 sur 14

Le service tel que vous l’avez construit : les nombres manquants restent NaN, et l’imputeur du pipeline les remplit. 14 cas sur 14 concordent à 1e-09 près. Les différences ne sont pas toutes nulles, mais la plus grande, 1.1e-16, est bien sous la tolérance.

  • réussi : dans la tolérance
  • FAIL : au-dessus de la tolérance
normalT-116940T-116941T-116942T-116943T-116944T-116945valeur manquanteT-900001T-900002T-900003extrêmeT-900004T-900005T-900006catégorie inconnueT-900007T-900008tolérance 1e-09zone d’échec1e-161e-121e-91e-410différence avec le score d’entraînement (échelle log)normal T-116940 : attendu 0.187598, servi 0.187598, différence 2.8e-17, réussinormal T-116941 : attendu 0.064735, servi 0.064735, différence 0, réussinormal T-116942 : attendu 0.129584, servi 0.129584, différence 1.1e-16, réussinormal T-116943 : attendu 0.179840, servi 0.179840, différence 2.8e-17, réussinormal T-116944 : attendu 0.244735, servi 0.244735, différence 0, réussinormal T-116945 : attendu 0.113802, servi 0.113802, différence 4.2e-17, réussimissing T-900001 : attendu 0.037929, servi 0.037929, différence 9.7e-17, réussimissing T-900002 : attendu 0.144791, servi 0.144791, différence 5.6e-17, réussimissing T-900003 : attendu 0.075978, servi 0.075978, différence 2.8e-17, réussiextreme T-900004 : attendu 1.000000, servi 1.000000, différence 0, réussiextreme T-900005 : attendu 0.039165, servi 0.039165, différence 1.4e-17, réussiextreme T-900006 : attendu 0.094671, servi 0.094671, différence 5.6e-17, réussiunknown T-900007 : attendu 0.177527, servi 0.177527, différence 0, réussiunknown T-900008 : attendu 0.111725, servi 0.111725, différence 5.6e-17, réussi
normalT-116940T-116941T-116942T-116943T-116944T-116945missingT-900001T-900002T-900003extremeT-900004T-900005T-900006unknownT-900007T-900008tolérance 1e-09échec1e-161e-91e-410différence (échelle log)normal T-116940 : attendu 0.187598, servi 0.187598, différence 2.8e-17, réussinormal T-116941 : attendu 0.064735, servi 0.064735, différence 0, réussinormal T-116942 : attendu 0.129584, servi 0.129584, différence 1.1e-16, réussinormal T-116943 : attendu 0.179840, servi 0.179840, différence 2.8e-17, réussinormal T-116944 : attendu 0.244735, servi 0.244735, différence 0, réussinormal T-116945 : attendu 0.113802, servi 0.113802, différence 4.2e-17, réussimissing T-900001 : attendu 0.037929, servi 0.037929, différence 9.7e-17, réussimissing T-900002 : attendu 0.144791, servi 0.144791, différence 5.6e-17, réussimissing T-900003 : attendu 0.075978, servi 0.075978, différence 2.8e-17, réussiextreme T-900004 : attendu 1.000000, servi 1.000000, différence 0, réussiextreme T-900005 : attendu 0.039165, servi 0.039165, différence 1.4e-17, réussiextreme T-900006 : attendu 0.094671, servi 0.094671, différence 5.6e-17, réussiunknown T-900007 : attendu 0.177527, servi 0.177527, différence 0, réussiunknown T-900008 : attendu 0.111725, servi 0.111725, différence 5.6e-17, réussi
Afficher les valeurs exactes
Aucune erreur : 14 cas sur 14 réussis
CasBilletAttenduServiDifférenceRésultat
normalT-1169400.1875980.1875982.8e-17réussi
normalT-1169410.0647350.0647350réussi
normalT-1169420.1295840.1295841.1e-16réussi
normalT-1169430.1798400.1798402.8e-17réussi
normalT-1169440.2447350.2447350réussi
normalT-1169450.1138020.1138024.2e-17réussi
missingT-9000010.0379290.0379299.7e-17réussi
missingT-9000020.1447910.1447915.6e-17réussi
missingT-9000030.0759780.0759782.8e-17réussi
extremeT-9000041.0000001.0000000réussi
extremeT-9000050.0391650.0391651.4e-17réussi
extremeT-9000060.0946710.0946715.6e-17réussi
unknownT-9000070.1775270.1775270réussi
unknownT-9000080.1117250.1117255.6e-17réussi
Les 14 cas de parité traités par le conteneur, avec une erreur côté service activée. Chaque point représente un cas : sa différence avec le score d'entraînement, sur une échelle logarithmique. Les points à droite de la ligne pointillée échouent.
Description textuelle de l’explorateur

L’outil d’exploration commence sans erreur. Les 14 cas sur 14 respectent la tolérance de 1e-09. La plus grande différence est de 1,1e-16 (T-116942). Aucun signalement ne change.

Remplacer les nombres manquants par 0 (to_frame() remplace un nombre manquant par 0) : 11 cas sur 14 réussissent. Les trois cas avec valeurs manquantes échouent. T-900001 diffère de 5,4e-03 (0,032546 au lieu de 0,037929). T-900002 diffère de 1,1e-01 (0,250070 au lieu de 0,144791). T-900003 diffère de 4,5e-02 (0,121413 au lieu de 0,075978). T-900002, l’invité, est maintenant signalé. Les cas normaux, extrêmes et inconnus réussissent encore.

Arrondir le score à 4 décimales (la réponse arrondit le score) : 1 cas sur 14 réussit. Seul T-900004 réussit, parce que son score est exactement 1,0. Les 13 autres diffèrent de 1,7e-06 à 4,0e-05. Aucun signalement ne change.

Remplacer une nouvelle catégorie par « unknown » (une valeur de catégorie jamais vue par le modèle devient le texte unknown avant le calcul du score) : 12 cas sur 14 réussissent. Les deux cas de catégories inconnues échouent. T-900007 diffère de 3,3e-02 (0,144458 au lieu de 0,177527). T-900008 diffère de 2,1e-02 (0,090970 au lieu de 0,111725). Aucun signalement ne change.

Chaque erreur fait échouer un groupe différent, et aucun cas ne détecte à lui seul les trois erreurs. Regardez ce que chacune enseigne :

  • Remplacer par 0 fait échouer seulement les cas avec valeurs manquantes. Pendant l’entraînement, l’imputation remplaçait un nombre manquant par la médiane des données d’entraînement. Elle activait aussi une colonne indicatrice : « cette valeur était manquante ». Le modèle a appris ce que signifie une ancienneté manquante. Un 0 lui dit autre chose : un tout nouveau client.
  • Arrondir à 4 décimales semble propre et ne change aucun signalement aujourd’hui. Cela fait quand même échouer 13 cas, et c’est justifié : le service ne retourne plus le score du modèle. Un score de 0,18996 s’afficherait comme 0,19 à côté d’un flag (indicateur de signalement) faux. Si vous voulez arrondir, faites-le dans l’affichage du client, pas dans l’API.
  • Remplacer par « unknown » semble sûr, parce que « inconnu » paraît neutre. Mais unknown est une vraie catégorie dans les données d’entraînement : des billets sans canal ou région enregistrés. Le modèle a appris un poids propre à cette catégorie. Une valeur que le modèle n’a jamais vue ne reçoit aucun poids. Ce n’est pas la même chose, et seuls les deux cas inconnus le montrent.

Une réussite ne signifie pas que le service est prêt

Section intitulée « Une réussite ne signifie pas que le service est prêt »

La vérification de Tomás comprenait un billet, un /ready et une autovérification réussie. Tous ont réussi malgré l’erreur de remplacement par 0. Un service de modèle échoue silencieusement : il répond 200 avec un nombre qui semble correct. Une vérification doit donc examiner les entrées pour lesquelles le code du service fait des choix : valeurs manquantes, limites et catégories inconnues. Les 14 cas coûtent peu : ils s’exécutent en moins d’une seconde dans les tests.

La parité a des limites. Elle détecte seulement les écarts couverts par les cas. Si un nouveau type d’entrée apparaît, comme un nouveau champ, ajoutez un cas dans la prochaine version de l’ensemble empaqueté. La parité ne dit rien non plus sur la qualité du modèle pour les billets d’aujourd’hui. C’est le rôle de la surveillance, dans Surveiller la santé du modèle. Vérifiez la parité à chaque changement du code (pytest) et après chaque déploiement (check_parity.py contre le service réel).

  1. Écrivez parity/check_parity.py et tests/test_parity.py comme ci-dessus.
  2. Exécutez python -m pytest. La dernière ligne indique 42 passed (42 tests réussis).
  3. Démarrez le service avec fastapi run.
  4. Dans un deuxième terminal, exécutez python parity/check_parity.py http://127.0.0.1:8000 bundles/escalation-1.0.0.
  5. Vérifiez la dernière ligne : 14 of 14 cases match within 1e-09. (14 cas sur 14 concordent à 1e-09 près).
  6. Exécutez echo $? (PowerShell : $LASTEXITCODE). La commande donne 0.
  7. Arrêtez le service avec Ctrl+C.
Afficher la sortie attendue à l’étape 4

Quatorze lignes avec une différence chacune, puis 14 of 14 cases match within 1e-09. Vos différences peuvent varier par rapport à l’exemple détaillé, mais chacune est de 1e-15 ou moins. Si une ligne se termine par FAIL (échec), lisez la section suivante.

Dans escalation/routes.py, dans le return ScoreOut(...) de score(), remplacez score=result.score, par score=round(result.score, 4),. Faites d’abord une prédiction : combien de tests de parité échouent, et lequel réussit? Exécutez ensuite python -m pytest tests/test_parity.py -q. Annulez la modification, puis relancez les tests.

Afficher la réponse
FAILED tests/test_parity.py::test_served_score_matches_training[normal-T-116940]
...
FAILED tests/test_parity.py::test_served_score_matches_training[unknown-T-900008]
13 failed, 1 passed in 1.21s

Seul extreme-T-900004 réussit : son score vaut exactement 1,0, et l’arrondi ne le change pas. Tous les autres scores changent à la cinquième décimale ou plus loin (de 1,7e-06 à 4,0e-05). Ces écarts dépassent largement la tolérance. Après l’annulation de la modification, vous obtenez 14 passed (14 tests réussis).

Erreur courante

Trois cas avec des valeurs manquantes échouent au test de parité

Symptôme : après une modification de contract.py, le service démarre et /ready indique qu’il est prêt, mais check_parity.py affiche :

case ticket expected served difference
...
missing T-900001 0.037929 0.032546 5.4e-03 FAIL
missing T-900002 0.144791 0.250070 1.1e-01 FAIL
missing T-900003 0.075978 0.121413 4.5e-02 FAIL
...
11 of 14 cases match within 1e-09.

Le code de sortie est 1. python -m pytest échoue aussi aux trois tests missing-T-900001, missing-T-900002 et missing-T-900003. Le test du contrat test_the_frame_has_the_training_columns_in_order échoue également. Il vérifie aussi qu’un nombre manquant reste NaN.

Cause : to_frame() remplace les nombres manquants avant qu’ils arrivent au pipeline :

escalation/contract.py (the mistake)
frame[name] = frame[name].astype("float64")
frame[name] = frame[name].astype("float64").fillna(0)

L’entraînement n’a jamais vu 0 à la place d’une valeur manquante. Son imputeur remplaçait chaque valeur manquante par la médiane d’entraînement et activait l’indicateur. Le groupe en échec indique la cause : seuls les cas avec une valeur manquante échouent. Examinez donc le code qui traite les valeurs manquantes.

Correction : retirez .fillna(0). Le service doit transmettre les nombres manquants au pipeline sous forme de NaN. Ainsi, l’imputeur du pipeline fait la même chose qu’à l’entraînement. La règle générale : le service vérifie l’entrée et lui donne la bonne forme. Mais toutes les transformations apprises à l’entraînement restent dans le pipeline.

Pour un vrai service d’IA : c’est le type d’écart le plus courant. Le code du service fait un « petit nettoyage » que l’entraînement n’a jamais fait. Cela ne produit aucune erreur et ne change aucune mesure qui semble normale. Ici, le billet de soutien de l’invité T-900002 est passé de 0,145 à 0,250, et le service l’a signalé. L’équipe de Grace examinerait des invités que le modèle ne devait pas signaler. Les cas de parité avec des valeurs manquantes détectent ce problème avant la mise en production.

Vérifiez votre compréhension

Pourquoi la parité utilise-t-elle une tolérance de 1e-9, plutôt que de 0 ou de 0,01?

Afficher la réponse

Les scores du service diffèrent des scores d’entraînement d’environ 1e-16 au maximum. La cause est un arrondi au dernier chiffre : un autre ordre d’addition, un autre processeur ou la lecture d’un nombre à 17 chiffres dans un fichier CSV. Une tolérance de 0 ferait échouer les tests à cause de ce bruit. La décision compare un score à 0,19. Vous devez donc détecter une vraie erreur bien avant qu’elle atteigne 0,01. Une tolérance de 0,01 laisserait passer une erreur qui déplace chaque score de 0,009. La valeur 1e-9 est dix millions de fois supérieure au bruit et dix millions de fois inférieure à 0,01.

Vérifiez votre compréhension

L'autovérification dans load_bundle() compare déjà les scores de parité au démarrage. Pourquoi n'a-t-elle pas détecté le remplacement par 0?

Afficher la réponse

L’autovérification calcule les scores des cas directement avec le pipeline, à partir du fichier CSV. Elle prouve que le paquet est le bon et fonctionne avec les paquets logiciels installés. Elle n’utilise pas le code propre au service : le contrat, to_frame() et la route. L’erreur était dans to_frame(). Seule une vérification qui envoie les billets au service, check_parity.py ou test_parity.py, peut donc la détecter.

L’exercice du module consiste à intégrer le classificateur à l’API. Vous avez fait la partie guidée dans ce module : le cycle de vie, les routes et les vérifications de parité. Maintenant, démontrez-le par vous-même, à partir d’une copie propre.

Tâche : copiez l’instantané end-of-m02 dans un nouveau dossier, ~/projects/escalation-check. Sans copier depuis end-of-m03, ajoutez les fichiers de ce module : escalation/main.py, escalation/errors.py, escalation/security.py, escalation/routes.py, pyproject.toml, parity/check_parity.py, tests/test_api.py et tests/test_parity.py. Consultez les leçons au besoin. Recueillez ensuite quatre preuves que le service est prêt : les tests, le journal de démarrage, les informations du modèle et la vérification de parité.

Afficher un exemple de réponse

Dans le nouveau dossier, créez un .venv et installez requirements.txt, comme dans le module 1. Ensuite :

Terminal, in ~/projects/escalation-check
python -m pytest
export MODEL_SHA256=$(shasum -a 256 bundles/escalation-1.0.0/SHA256SUMS | cut -c1-64)
fastapi run
Terminal 2
curl http://127.0.0.1:8000/v1/model
python parity/check_parity.py http://127.0.0.1:8000 bundles/escalation-1.0.0

Les quatre preuves :

  1. 42 passed (42 tests réussis).
  2. La ligne de journal model 1.0.0 loaded (bundle …) in … ms (modèle chargé en … ms), avec les 12 premiers caractères de votre empreinte.
  3. /v1/model donne "model_version":"1.0.0", un bundle_sha256 qui commence par les mêmes 12 caractères, et "threshold":0.19.
  4. 14 of 14 cases match within 1e-09. (14 cas sur 14 concordent à 1e-09 près) et le code de sortie 0.

Si un cas de parité échoue, son groupe indique le code à vérifier. Valeurs manquantes : to_frame(). Catégories : le contrat ou le calcul du score. Tous les cas : la réponse de la route.

Vérifiez votre compréhension

Rappel : quel travail escalation-service fait-il une fois par processus au démarrage, et quel travail fait-il à chaque requête?

Afficher la réponse

Une fois par processus, dans le cycle de vie : importer les paquets logiciels, lire les paramètres et exécuter load_bundle(). Cette fonction effectue ses vérifications et l’autovérification, qui sert aussi au préchauffage. Ensuite, le port s’ouvre. À chaque requête : vérifier la clé d’API et le contrat, puis exécuter frame, prepare et model. Répondre ensuite avec la version et le seuil, puis écrire une ligne de journal.

Vérifiez votre compréhension

Prédiction : un développeur modifie le service pour remplacer une catégorie jamais vue par le modèle par le texte unknown avant le calcul du score. Quels cas de parité échouent, et pourquoi cela change-t-il le score?

Afficher la réponse

Seuls les deux cas de catégorie inconnue échouent : T-900007 (social) et T-900008 (overseas, installation). Les écarts sont de 3,3e-02 et de 2,1e-02 dans l’exécution mesurée. unknown (inconnu) est une vraie catégorie des données d’entraînement pour channel et region, avec son propre poids appris. L’encodeur à chaud ne crée aucune colonne pour une valeur que le modèle n’a jamais vue. Elle n’a donc aucun poids. La remplacer par unknown lui donne le poids des billets dont le canal ou la région n’a pas été enregistré.

Vérifiez votre compréhension

Vérification finale : montrez que votre service respecte la parité pour les entrées normales, manquantes et extrêmes. Qu'exécutez-vous, et quelle sortie le prouve?

Afficher la réponse

Démarrez le service, puis exécutez python parity/check_parity.py http://127.0.0.1:8000 bundles/escalation-1.0.0. La preuve comporte trois parties. Aucune ligne ne se termine par FAIL (échec). Cela comprend les six cas normal (normaux), les trois cas missing (manquants) et les trois cas extreme (extrêmes). La dernière ligne est 14 of 14 cases match within 1e-09. Le code de sortie est 0. python -m pytest tests/test_parity.py donne 14 passed pour les mêmes cas, transmis à l’application dans les tests.

Récapitulatif

Dans ce module, vous avez créé le service de prédiction :

  • main.py fait le travail lent une fois par processus, dans le cycle de vie. Le paquet est vérifié, chargé et préchauffé avant l’ouverture du port. Un mauvais paquet arrête le service avec le code de sortie 3.
  • routes.py vérifie chaque billet avec le contrat. Il répond avec le score, l’indicateur, le seuil, la version du modèle et les avertissements. Toutes les erreurs ont la même forme, et chaque score a une ligne de journal.
  • L’écart entre l’entraînement et le service est une différence dans leur façon de préparer la même entrée. La parité le détecte : les 14 cas fixes passent par le service. Leurs scores sont comparés aux scores d’entraînement avec une tolérance de 1e-9. Les écarts réels sont d’environ 1e-16.
  • Une bonne prédiction prouve peu de choses. Chaque erreur du service a fait échouer un groupe de cas différent.
  • Les tests : 42 passed.

Réinitialisation. Pour commencer le module 4 dans un état propre :

  1. Arrêtez chaque fastapi run avec Ctrl+C.
  2. Supprimez ../edited-bundle s’il est encore là, et ~/projects/escalation-check si vous n’en avez pas besoin.
  3. Si votre projet ne fonctionne plus, copiez l’instantané end-of-m03 et exécutez python -m pytest : 42 passed.

Vous n’avez créé aucun conteneur ni aucune image Docker dans ce module. Il n’y a donc rien à supprimer dans Docker.

Ensuite, vous choisissez où le service s’exécute : CPU ou GPU, plateforme de conteneurs ou point de terminaison géré.

Leçon suivante : CPU, GPU et points de terminaison gérés