Aller au contenu
English

Mettre en production un nouveau modèle de prix des maisons

  • Étude de cas 2
  • 180 min
  • Facultative · 1 étude de cas requise pour le certificat

Ce que vous saurez faire

Mettez en production une nouvelle version d’un modèle sans surprendre les personnes qui l’utilisent. Vous reprenez l’API de prix des maisons de Créer et déployer votre première API. Vous faites de chaque modèle un paquet versionné. Ensuite, vous exécutez un modèle réentraîné en parallèle, sur des requêtes réelles rejouées. Vous comparez les deux modèles avec des vérifications adaptées à un modèle de prix. Vous activez le nouveau et vous exercez au retour en arrière.

Étude de casPièce de portfolio

Durée
Environ 3 h
S’exécute dans
VS Code, Python 3.12+
Compétences utilisées
  • Paquets versionnés avec une empreinte fixée
  • Déploiement en parallèle sur des requêtes rejouées
  • Vérifications de mise en production pour un modèle de régression
  • Activation et exercice de retour en arrière
  • Vérifications de parité et de contrat après une mise en production
  • Une image avec deux modèles

Décompressez le projet de départ, ouvrez le dossier dans VS Code et suivez les étapes ci-dessous. Elles commencent par un nouvel environnement virtuel.

Le projet de départ est le projet terminé de l’étude de cas du cours sur les API. Il comprend un script qui prépare les requêtes à rejouer et un autre qui les envoie. Vous ajoutez les paquets, l’exécution en parallèle, le conteneur, la comparaison et les registres de mise en production.

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

Ce que vous devez savoir

Les leçons les plus utilisées dans cette étude de cas :

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 paquets : FastAPI 0.142.2, scikit-learn 1.9.1, NumPy 2.5.3, SciPy 1.18.1, joblib 1.6.0, pandas 3.0.6 et pytest 9.1.1. Les étapes pour Windows et Linux n’ont pas été testées.

L’API de prix des maisons du cours sur les API fonctionne. Chez le prêteur, l’équipe d’évaluation de Hannah Osei l’utilise chaque jour, avec l’outil de saisie qu’Elif Demir maintient. Son modèle, kc-hgb-2015-02, a appris à partir des ventes jusqu’en février 2015.

L’analyste de l’équipe réentraîne maintenant le modèle chaque mois. Hannah veut les prix plus récents; Elif veut que son outil continue de fonctionner sans changement. Daniel Ruiz, responsable de la conformité, demande : « Comment sait-on que le nouveau modèle n’est pas moins bon? Et si c’est le cas, en combien de temps peut-on revenir en arrière? » Kenji Mori, le développeur, est responsable de la mise en production. Vous jouez de nouveau le rôle de Kenji.

Partie Ce que vous ajoutez La question à laquelle cela répond
1 make_bundle.py, bundles/<version>/, BUNDLES dans config.py Quels fichiers de modèle l’API a-t-elle le droit de charger?
2 Un modèle en parallèle et un journal de prédictions Comment un nouveau modèle répond-il aux requêtes réelles sans que personne voie ses réponses?
3 Dockerfile, compose.yaml, scripts/check_release.py Le service fonctionne-t-il dans un conteneur et utilise-t-il la version mise en production?
4 Le premier candidat, en parallèle, et scripts/compare.py Peut-on mettre ce candidat en production sans risque? (Non.)
5 Le deuxième candidat, la décision, l’activation, RELEASES.md Celui-ci est-il sûr, et qui décide?
6 Un exercice de retour en arrière Peut-on revenir en arrière, et combien de temps cela prend-il?

L’idée : un nouveau modèle est une mise en production

Section intitulée « L’idée : un nouveau modèle est une mise en production »

Un restaurant qui change une recette prépare le nouveau plat à côté de l’ancien pendant une semaine. L’équipe goûte aux deux. Les clients reçoivent toujours l’ancien plat. Le menu change seulement quand l’équipe est d’accord, et l’ancienne recette reste dans le tiroir.

Le problème : une maison ou un test ne suffit pas pour décider

Section intitulée « Le problème : une maison ou un test ne suffit pas pour décider »

Le modèle réentraîné a appris à partir d’un mois de plus, mars 2015. La maison typique du cours sur les API s’est vendue le 26 avril 2015 pour 425 000 dollars :

Modèle Estimation
Version 1, kc-hgb-2015-02 (en production) 422 000
Version 2, kc-hgb-2015-03 (nouvelle) 379 000

Le nouveau modèle a changé l’estimation de −10 %, l’éloignant du prix de vente. Est-il moins bon? Voici trois autres maisons des mêmes semaines :

Maison Prix de vente Version 1 Version 2 Erreur de la version 1 Erreur de la version 2 Variation
A 235 000 235 000 239 000 0 4 000 +1,7 %
B 945 000 1 086 000 1 115 000 141 000 170 000 +2,7 %
C 1 400 000 1 880 000 1 428 000 480 000 28 000 −24,0 %
D (la maison typique) 425 000 422 000 379 000 3 000 46 000 −10,2 %

L’erreur est l’écart par rapport au prix de vente, sans signe. Sa moyenne pour les maisons est l’erreur absolue moyenne (MAE) : version 1, (0 + 141 000 + 480 000 + 3 000) ÷ 4 = 156 000 dollars; version 2, (4 000 + 170 000 + 28 000 + 46 000) ÷ 4 = 62 000 dollars. La variation mesure le changement de l’estimation : 379 000 ÷ 422 000 − 1 = −10,2 % pour la maison D.

Sur ces quatre maisons, la version 2 semble 2,5 fois meilleure, uniquement à cause de la maison C. Deux estimations ont changé de plus de 10 %. Sur 2 817 maisons, vous verrez que les modèles sont presque équivalents. Un grand test hors ligne ne suffit pas non plus pour décider. Dans la partie 4, un candidat défectueux obtient la même erreur de test que la version 1. Il réussit aussi tous les tests unitaires.

L’idée : exécuter en parallèle, comparer, décider, activer, garder un moyen de revenir en arrière

Section intitulée « L’idée : exécuter en parallèle, comparer, décider, activer, garder un moyen de revenir en arrière »

La semaine de dégustation correspond à un déploiement en parallèle. Le candidat estime chaque requête, le client reçoit seulement la réponse du modèle en production, et le journal conserve les deux. Vous n’avez pas besoin d’attendre de nouvelles requêtes. Vous rejouez les requêtes enregistrées par l’outil de saisie, pour que chaque candidat voie les mêmes maisons.

Un modèle de prix n’a ni seuil ni indicateur. Les vérifications du module 5 (taux de signalement par rapport à la capacité) ne s’appliquent donc pas. Les vérifications posent plutôt deux questions :

  1. La nouvelle erreur est-elle plus faible? La MAE sur les ventes plus récentes.
  2. De combien les réponses changent-elles? La proportion d’estimations qui changent de plus de 10 %, et la plus grande variation médiane d’un secteur. Un agent de prêts qui voyait 422 000 le mois dernier et voit 379 000 aujourd’hui a besoin d’une explication.

Hannah et Kenji s’entendent sur les limites avant toute exécution en parallèle. Une limite choisie après les résultats tend à être celle que le candidat respecte.

Vérification Limite
Erreur La MAE du candidat dépasse celle du modèle en production d’au plus 1 %.
Variations Au plus 5 % des estimations changent de plus de 10 %.
Secteurs Aucun code postal (avec au moins 10 ventes) n’a une variation médiane de plus de 5 %.
Contrat et parité Les tests réussissent, et le service en cours d’exécution réussit check_release.py après la mise en production.
Une personne Kenji examine les plus grandes variations et les explique à Hannah, qui décide.

Pourquoi 10 %? Cette valeur reste dans la plage du modèle lui-même. Dans le cours sur les API, 80 % des maisons se vendaient entre 0,884 et 1,279 fois l’estimation. Mais c’est un changement qu’un agent de prêts remarque. L’équipe de Hannah peut expliquer quelques variations de ce genre, pas des centaines.

Le parcours de mise en production d'un nouveau modèle. 1, entraîner un candidat avec train.py. 2, créer un paquet avec make_bundle.py, qui affiche l'empreinte. 3, ajouter l'empreinte à BUNDLES dans config.py, dans une modification révisée. 4, exécuter le candidat en parallèle : les deux modèles estiment, celui en production répond. Rejouer 2 819 requêtes et passer à 5, comparer l'erreur, les variations et les secteurs. Si une vérification échoue, le candidat est rejeté et retiré : son paquet et son empreinte sont supprimés. Si tout réussit et qu'une personne examine les plus grandes variations, 6, il passe en production avec MODEL_VERSION et check_release.py. Le retour en arrière consiste à rétablir un paramètre à la version précédente, toujours présente dans l'image et dans BUNDLES.
Le parcours de mise en production de cette étude de cas. L'environnement choisit quel paquet révisé est en production ou en parallèle. Seule une modification révisée du code peut ajouter un paquet.
Description textuelle du diagramme

Un candidat passe par six étapes. D’abord, l’analyste l’entraîne avec train.py. Ensuite, make_bundle.py crée un paquet et affiche son empreinte. Troisièmement, l’empreinte est ajoutée à BUNDLES dans config.py, dans une modification révisée. Quatrièmement, le candidat s’exécute en parallèle : les deux modèles estiment chaque requête, et seul celui en production répond. Les 2 819 requêtes rejouées alimentent la comparaison, la cinquième étape, qui vérifie l’erreur, les variations et les secteurs. Si une vérification échoue, le candidat est rejeté et retiré. Si toutes réussissent et qu’une personne a examiné les plus grandes variations, la sixième étape le met en production. check_release.py confirme alors la mise en production. La version précédente reste dans l’image et dans BUNDLES. Le retour en arrière ne demande donc qu’un changement de paramètre.

Les données sont celles du cours sur les API : 21 613 ventes réelles de maisons dans King County, dans l’État de Washington. Elles vont du 2 mai 2014 au 27 mai 2015. La source est House Sales in King County, USA (harlfoxem, Kaggle 2016; OpenML 42092), sous licence CC0 1.0. Hannah, Elif, Kenji, Daniel et le prêteur sont fictifs. Les ventes sont réelles.

Un scénario de mise en production a besoin d’un « aujourd’hui ». Les données s’arrêtent au 27 mai 2015, alors l’étude de cas suppose que nous sommes à la fin mai. Chaque maison est assignée selon sa première vente, comme dans le cours sur les API :

Partie Première vente des maisons Ventes Utilisation
La version 1 a appris à partir des ventes avant le 1er mars 2015 16 959 Le modèle du cours sur les API, inchangé
La version 2 apprend à partir des ventes avant le 1er avril 2015 18 794 un mois de plus : mars
Les requêtes rejouées du 1er avril au 27 mai 2015 2 819 les requêtes de l’outil de saisie, puis leurs prix de vente

Deux mois, parce que mai seul ne compte que 646 ventes. Aucun modèle n’a appris à partir de ces maisons. Mais elles constituent aussi les données de test hors ligne de la version 2. L’exécution en parallèle apporte tout de même quelque chose : elle envoie chaque maison à la véritable API. La partie 4 montre les problèmes que cela détecte.

  1. 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 dossier house-price-api. Si vous avez encore le projet du cours sur les API, gardez-le ailleurs : c’est une nouvelle copie.

  2. Dans VS Code, ouvrez le dossier : File › Open Folder… (Fichier › Ouvrir un dossier…), puis choisissez house-price-api.

  3. Ouvrez un terminal dans VS Code : Terminal › New Terminal (Terminal › Nouveau terminal).

  4. Créez et activez un environnement virtuel :

    Fenêtre de terminal
    python3 -m venv .venv
    source .venv/bin/activate
  5. Installez les versions exactes des paquets. Cela prend environ 20 secondes à quelques minutes :

    Fenêtre de terminal
    python -m pip install -r requirements-lock.txt
  6. Choisissez l’environnement dans VS Code. 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.

  7. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest
  8. Préparez les requêtes à rejouer. Le script télécharge les données (0,7 Mo) et vérifie leur empreinte SHA-256 :

    Fenêtre de terminal
    python replay/make_replay.py

La sortie des deux dernières étapes :

Sortie
...
tests/test_model_file.py ...... [100%]
============================== 49 passed in 1.16s ==============================
Downloading https://github.com/Nextia-Intelligence/nextia-learning-labs/raw/main/C05/case-studies/data/king_county_house_sales.csv.gz
wrote replay/requests-2015-04-05.jsonl and replay/sales-2015-04-05.csv: 2819 sales from 2015-04-01 to 2015-05-27

Ouvrez les deux fichiers de requêtes à rejouer. Chaque ligne de requests-2015-04-05.jsonl est une requête telle que l’outil de saisie l’a envoyée. Elle contient un identifiant de vente comme 2800031-20150401 (identifiant de maison et date de vente). sales-2015-04-05.csv contient les prix. Les fichiers sont séparés exprès : le service ne voit jamais de prix.

Nouveauté du projet de départ Son rôle
training/train.py Le script de l’analyste, maintenant avec --until (le premier jour que le modèle ne doit pas voir), --version, --data et --out. Il écrit un candidat dans candidates/<version>/.
training/simulate_export.py Crée une copie modifiée des données. La partie 4 l’explique.
replay/make_replay.py Écrit les deux fichiers de requêtes à rejouer.
scripts/replay.py Envoie les requêtes à une API en cours d’exécution, une à la fois, et conserve l’identifiant de requête de chaque réponse.

Partie 1 : des paquets versionnés avec une empreinte fixe

Section intitulée « Partie 1 : des paquets versionnés avec une empreinte fixe »

Le problème : un seul chemin de modèle, un seul hachage

Section intitulée « Le problème : un seul chemin de modèle, un seul hachage »

L’API du cours Créer et déployer votre première API connaît un seul fichier, model/house_price.joblib, et un seul hachage. Un modèle fantôme exige deux modèles à la fois. Un retour en arrière exige le modèle précédent, inchangé.

L’idée : un dossier par version, une empreinte par dossier

Section intitulée « L’idée : un dossier par version, une empreinte par dossier »

Utilisez le paquet du module 1 : un dossier par version, jamais modifié, avec une empreinte qui vient de l’extérieur du paquet.

Une différence : le service d’escalade lit son empreinte dans .env. Ce projet conserve plutôt la règle du cours Créer et déployer votre première API. Quiconque peut modifier les paramètres du serveur pourrait définir l’empreinte de son propre fichier. Le code contient donc les versions révisées et leurs empreintes. L’environnement ne fait que choisir parmi elles.

Créez make_bundle.py dans le dossier du projet :

make_bundle.py
"""Make a versioned model bundle from a folder that training/train.py wrote.
python make_bundle.py candidates/kc-hgb-2015-03
Reads house_price.joblib and model_card.json from the folder, and writes
bundles/<model_version>/ with four files:
model.joblib the model, copied byte for byte
model_card.json its card, copied byte for byte (GET /v1/model serves it)
parity_cases.json fixed houses and the estimates this model gives them
SHA256SUMS the SHA-256 of each file above
It prints the bundle digest: the SHA-256 of SHA256SUMS. Add it to BUNDLES in
house_price_api/config.py, in a reviewed change, before the service may load
the bundle. A version is never changed: if the folder exists, the script stops.
It loads the joblib file to compute the parity estimates, and loading can run
code: run it only on a model that you trained, or that comes from a source
that you trust.
"""
import argparse
import hashlib
import json
import shutil
import sys
from pathlib import Path
import joblib
from house_price_api.estimator import FEATURES
from house_price_api.models import TYPICAL_HOUSE, HouseIn
FILES = ["model.joblib", "model_card.json", "parity_cases.json"]
GOLDEN = Path("tests/golden_houses.json")
# Fixed houses that every copy of a model must estimate the same way: the
# three golden houses of C16, and houses at the edges of the contract.
EDGE_CASES = {
"never_renovated_new": {"yr_built": 2014, "yr_renovated": None},
"renovated_old": {"yr_built": 1905, "yr_renovated": 2010},
"smallest": {
"bedrooms": 0, "bathrooms": 0.0, "sqft_living": 250, "sqft_lot": 500,
"floors": 1.0, "view": 0, "condition": 1, "grade": 1, "sqft_basement": 0,
"yr_built": 1900, "sqft_living15": 400, "sqft_lot15": 600,
},
"largest": {
"bedrooms": 11, "bathrooms": 8.0, "sqft_living": 14000, "sqft_lot": 1100000,
"floors": 3.5, "waterfront": True, "view": 4, "condition": 5, "grade": 13,
"sqft_basement": 5000, "yr_built": 2015, "yr_renovated": 2015,
"sqft_living15": 6500, "sqft_lot15": 900000,
},
"south_west_corner": {"lat": 47.15, "long": -122.52},
"north_east_corner": {"lat": 47.78, "long": -121.31},
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def parity_cases(model) -> list[dict]:
golden = json.loads(GOLDEN.read_text("utf-8"))
houses = {g["name"]: g["house"] for g in golden}
houses |= {name: {**TYPICAL_HOUSE, **change} for name, change in EDGE_CASES.items()}
cases = []
for name, house in houses.items():
inputs = HouseIn(**house).model_inputs() # every case must pass the contract
[estimate] = model.predict([[inputs[f] for f in FEATURES]]).tolist()
cases.append({"name": name, "house": house, "expected_estimate": estimate})
return cases
def main() -> None:
parser = argparse.ArgumentParser(description="Make a model bundle.")
parser.add_argument("source", type=Path, help="a folder with house_price.joblib and model_card.json")
args = parser.parse_args()
card = json.loads((args.source / "model_card.json").read_text("utf-8"))
model_file = args.source / "house_price.joblib"
if sha256(model_file) != card["sha256"]:
sys.exit("Stopped: model_card.json describes another model file.")
if card["features"] != FEATURES:
sys.exit("Stopped: the model was trained on other features than the API sends.")
out = Path("bundles") / card["model_version"]
if out.exists():
sys.exit(f"Stopped: {out} exists. A version is never changed: choose a new version.")
out.mkdir(parents=True)
shutil.copyfile(model_file, out / "model.joblib")
shutil.copyfile(args.source / "model_card.json", out / "model_card.json")
cases = parity_cases(joblib.load(model_file))
(out / "parity_cases.json").write_text(json.dumps(cases, indent=2) + "\n", "utf-8")
lines = [f"{sha256(out / name)} {name}" for name in FILES]
(out / "SHA256SUMS").write_text("\n".join(lines) + "\n", "utf-8")
print(f"Made {out}: {len(cases)} parity cases.")
print(f"Bundle digest: {sha256(out / 'SHA256SUMS')}")
if __name__ == "__main__":
main()
Partie Raison
Les cas de parité décrivent des maisons selon les termes de l’API yr_renovated vaut null (valeur nulle), waterfront vaut true (vrai) ou false (faux), et sqft_above est absent, comme dans une requête. La vérification passe donc par HouseIn.model_inputs(), la conversion propre à l’API. Une erreur dans cette conversion crée un décalage entre l’entraînement et le service, expliqué dans Vérifier la parité.
Les cas limites Une maison neuve jamais rénovée, une vieille maison rénovée, les plus petites et les plus grandes maisons permises par le contrat, et deux extrémités de la zone du modèle.
if out.exists() Une version ne change jamais. Un nouveau modèle exige une nouvelle version.

Créez le paquet du modèle en production à partir du dossier model/ du cours Créer et déployer votre première API :

Fenêtre de terminal
python make_bundle.py model
Sortie
Made bundles/kc-hgb-2015-02: 9 parity cases.
Bundle digest: 29752760649bf79193867d4a5887ee47c361aa913315d831b0e9fafe6f50d1cf

Ouvrez bundles/kc-hgb-2015-02/SHA256SUMS :

bundles/kc-hgb-2015-02/SHA256SUMS
8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdec model.joblib
3389bc75812971ba87b3908026409d0eb78eca0eea40affdc3771fec31d35e1f model_card.json
96bb75911713fdfe7aa1d2c12524216e292cfc6a16e93c2a06de2960cd1787d4 parity_cases.json

Le fichier du modèle est le fichier révisé du cours Créer et déployer votre première API (8d0483d05cfc…). Contrairement au service d’escalade, votre empreinte est identique à la nôtre. L’entraînement est reproductible octet par octet, et le paquet ne contient aucune date. Exécutez la commande de nouveau : elle s’arrête avec A version is never changed: choose a new version. (Une version ne change jamais : choisissez une nouvelle version.)

Le paquet remplace model/. Supprimez le dossier model/. Supprimez aussi scripts/check_deployment.py et tests/test_model_file.py : ils vérifient tous deux le fichier unique du modèle. La partie 3 et l’étape 4 les remplacent.

Remplacez house_price_api/config.py :

house_price_api/config.py
import os
from dataclasses import dataclass, field
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
# The reviewed bundles: model version -> bundle digest (the SHA-256 of its
# SHA256SUMS file). The service loads only these. A new bundle needs a new
# line here, in a reviewed change; the environment only chooses among them.
BUNDLES = {
"kc-hgb-2015-02": "29752760649bf79193867d4a5887ee47c361aa913315d831b0e9fafe6f50d1cf",
}
DEFAULT_VERSION = "kc-hgb-2015-02"
@dataclass(frozen=True)
class Settings:
api_key: str | None = field(repr=False)
allowed_origins: list[str]
bundles_dir: Path
model_version: str
shadow_version: str | None
reviewed: dict[str, str]
estimate_timeout: float
log_level: str
show_docs: bool
def load_settings() -> Settings:
"""Read the settings from environment variables, with safe defaults."""
origins = os.environ.get("ALLOWED_ORIGINS", "")
return Settings(
api_key=os.environ.get("API_KEY") or None,
allowed_origins=[o.strip() for o in origins.split(",") if o.strip()],
bundles_dir=Path(os.environ.get("BUNDLES_DIR", PROJECT / "bundles")),
model_version=os.environ.get("MODEL_VERSION", DEFAULT_VERSION),
shadow_version=os.environ.get("SHADOW_VERSION") or None,
reviewed=BUNDLES,
estimate_timeout=float(os.environ.get("ESTIMATE_TIMEOUT", "2.0")),
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
show_docs=os.environ.get("SHOW_DOCS", "true").lower() == "true",
)

MODEL_VERSION choisit le modèle en production, et SHADOW_VERSION le modèle fantôme (partie 2). Les deux doivent être des clés de BUNDLES. reviewed figure dans les paramètres uniquement pour que les tests puissent fournir une autre liste. Sur un serveur, sa valeur est toujours BUNDLES.

Étape 3 : charger un paquet, barrière par barrière

Section intitulée « Étape 3 : charger un paquet, barrière par barrière »

Dans house_price_api/estimator.py, le chargeur de paquets remplace le load_estimator du cours Créer et déployer votre première API. Faites trois modifications :

  • Ajoutez from importlib.metadata import version aux importations.
  • Ajoutez les deux constantes ci-dessous, au-dessus de class ModelUnavailable.
  • Donnez un troisième argument, digest, à HouseEstimator. Son __init__ commence ainsi; le reste ne change pas :
house_price_api/estimator.py (changes)
# The packages that must have the versions that saved the model.
PACKAGES = ["scikit-learn", "numpy", "joblib"]
# The self-check allows this relative difference: 1e-9 of 400,000 USD is 0.0004 USD.
TOLERANCE = 1e-9
class HouseEstimator:
"""The loaded model, its card, and the digest of its bundle."""
def __init__(self, model, card: dict, digest: str = ""):
self.model = model
self.card = card
self.digest = digest
# ... the other lines of __init__ stay as they are

Remplacez ensuite toute la fonction load_estimator par ces quatre fonctions :

house_price_api/estimator.py (replaces load_estimator)
def check_files(path: Path, expected_digest: str) -> None:
"""Gates 1 and 2: this is the reviewed bundle, and no file in it changed."""
sums = path / "SHA256SUMS"
if not sums.is_file():
raise ModelUnavailable(f"no bundle at {path}")
digest = file_sha256(sums)
if digest != expected_digest:
raise ModelUnavailable(
f"{path.name}: the bundle digest is {digest[:12]}..., "
f"but config.BUNDLES expects {expected_digest[:12]}..."
)
for line in sums.read_text("utf-8").splitlines():
expected, name = line.split()
if file_sha256(path / name) != expected:
raise ModelUnavailable(f"{path.name}/{name} changed after the bundle was made")
def check_card(path: Path, card: dict) -> None:
"""Gate 3: the card names this model file, this version and these packages."""
if card["model_version"] != path.name:
raise ModelUnavailable(f"{path.name}: the card says version {card['model_version']}")
if card["sha256"] != file_sha256(path / "model.joblib"):
raise ModelUnavailable(f"{path.name}: the card describes another model file")
for package in PACKAGES:
tested, installed = card["trained_with"][package], version(package)
if installed != tested:
raise ModelUnavailable(
f"{path.name}: {package} {installed} is installed, "
f"but the model was saved with {tested}"
)
if card["features"] != FEATURES:
raise ModelUnavailable(f"{path.name}: the model expects other features")
def self_check(estimator: HouseEstimator, path: Path) -> None:
"""Gate 5: the parity cases get the estimates that they got at bundling."""
# Imported here: models.py does not need the estimator, and the self-check
# must turn each house into model inputs exactly as a request does.
from house_price_api.models import HouseIn
cases = json.loads((path / "parity_cases.json").read_text("utf-8"))
rows = [HouseIn(**case["house"]).model_inputs() for case in cases]
for case, result in zip(cases, estimator.estimate(rows)):
expected = case["expected_estimate"]
if abs(result.value - expected) > TOLERANCE * expected:
raise ModelUnavailable(
f"{path.name}: self-check failed for {case['name']}: "
f"{result.value:.2f} instead of {expected:.2f}"
)
def load_bundle(path: Path, expected_digest: str) -> HouseEstimator:
"""Check a bundle, then load it. Raise ModelUnavailable if anything is wrong."""
check_files(path, expected_digest)
try:
card = json.loads((path / "model_card.json").read_text("utf-8"))
except (OSError, ValueError) as error:
raise ModelUnavailable(f"cannot read model_card.json: {error}") from None
check_card(path, card)
# Every byte is the reviewed one. Only now is it safe to load the model:
# joblib uses pickle, which can run code from the file (gate 4).
with warnings.catch_warnings():
warnings.simplefilter("error", InconsistentVersionWarning)
try:
model = joblib.load(path / "model.joblib")
except InconsistentVersionWarning as warning:
raise ModelUnavailable(
f"the model was saved with scikit-learn "
f"{warning.original_sklearn_version}, this server has "
f"{warning.current_sklearn_version}"
) from None
estimator = HouseEstimator(model, card, file_sha256(path / "SHA256SUMS"))
self_check(estimator, path)
return estimator

Les barrières suivent l’ordre du module 1 : empreinte, fichiers, fiche et paquets, puis seulement joblib.load, puis l’autovérification. Tout échec lève ModelUnavailable. L’API conserve donc le comportement du cours Créer et déployer votre première API. /health reste à 200, tandis que /ready et les estimations renvoient 503 model_unavailable (modèle indisponible).

Dans house_price_api/main.py, l’importation de l’estimateur change. Une petite fonction choisit le paquet, et le bloc de démarrage l’utilise :

house_price_api/main.py (changes)
from house_price_api.estimator import (
READY_CHECK_HOUSE,
HouseEstimator,
ModelUnavailable,
load_bundle,
)
def load_version(settings: Settings, version: str) -> HouseEstimator:
"""Load the bundle of a reviewed version: only versions in config.BUNDLES."""
if version not in settings.reviewed:
raise ModelUnavailable(f"{version} is not a reviewed bundle (config.BUNDLES)")
return load_bundle(settings.bundles_dir / version, settings.reviewed[version])
# in create_app: the start-up block becomes
started = time.perf_counter()
try:
app.state.estimator = load_version(settings, settings.model_version)
app.state.model_problem = None
logger.info(
"model %s loaded (bundle %s...) in %.0f ms, memory %s MB",
app.state.estimator.version,
app.state.estimator.digest[:12],
(time.perf_counter() - started) * 1000,
memory_mb(),
)
except ModelUnavailable as problem:
app.state.estimator = None
app.state.model_problem = str(problem)
logger.error("model not loaded: %s", problem)

Placez load_version au niveau principal du fichier, avant def create_app. La ligne du journal indique maintenant l’empreinte du modèle en production.

Dans tests/conftest.py, remplacez l’importation par from house_price_api.config import BUNDLES, PROJECT, Settings. Remplacez aussi les deux lignes de modèle dans TEST_SETTINGS :

tests/conftest.py (change)
bundles_dir=PROJECT / "bundles",
model_version="kc-hgb-2015-02", # the golden answers in golden_houses.json are this version's
shadow_version=None,
reviewed=BUNDLES,

Dans tests/test_estimates.py, remplacez la première ligne de test_no_model_gives_503_everywhere_but_health par client = make_client(bundles_dir=tmp_path). Un dossier vide ne contient aucun paquet.

Créez ensuite tests/test_bundle.py. Chaque test fait échouer une barrière. La préparation de test no_unpickling fait échouer le test si joblib.load s’exécute avant les vérifications :

tests/test_bundle.py
import json
import logging
import shutil
from pathlib import Path
import joblib
import pytest
from house_price_api.config import BUNDLES, PROJECT
from house_price_api.estimator import file_sha256, load_bundle
CONTRACT = PROJECT / "contract" / "openapi.json"
LIVE = "kc-hgb-2015-02"
def assert_not_ready(client):
"""Liveness stays 200; readiness gives 503, without internals."""
assert client.get("/health").status_code == 200
response = client.get("/ready")
assert response.status_code == 503
assert response.json()["error"]["code"] == "model_unavailable"
def changed_copy(tmp_path: Path, name: str, change) -> str:
"""Copy the live bundle, change one file, write new sums. Return the new digest."""
folder = tmp_path / LIVE
shutil.copytree(PROJECT / "bundles" / LIVE, folder)
path = folder / name
data = json.loads(path.read_text("utf-8"))
change(data)
path.write_text(json.dumps(data, indent=2) + "\n", "utf-8")
sums = [f"{file_sha256(folder / n)} {n}" for n in ("model.joblib", "model_card.json", "parity_cases.json")]
(folder / "SHA256SUMS").write_text("\n".join(sums) + "\n", "utf-8")
return file_sha256(folder / "SHA256SUMS")
@pytest.fixture
def no_unpickling(monkeypatch):
"""Fail the test if anything calls joblib.load."""
def refuse(*args, **kwargs):
raise AssertionError("joblib.load ran before the checks passed")
monkeypatch.setattr(joblib, "load", refuse)
@pytest.mark.parametrize("version", BUNDLES)
def test_every_reviewed_bundle_loads_and_passes_its_self_check(version):
path = PROJECT / "bundles" / version
assert file_sha256(path / "SHA256SUMS") == BUNDLES[version]
assert load_bundle(path, BUNDLES[version]).version == version
@pytest.mark.parametrize("version", BUNDLES)
def test_a_new_model_is_not_a_new_contract(make_client, version):
current = json.dumps(make_client(model_version=version).get("/openapi.json").json(), indent=2)
assert current + "\n" == CONTRACT.read_text("utf-8")
def test_wrong_digest_stops_before_loading(make_client, no_unpickling):
assert_not_ready(make_client(reviewed={LIVE: "0" * 64}))
def test_unreviewed_version_is_not_loaded(make_client, no_unpickling):
assert_not_ready(make_client(model_version="kc-hgb-2015-04"))
def test_changed_file_is_not_loaded(make_client, tmp_path, no_unpickling):
shutil.copytree(PROJECT / "bundles" / LIVE, tmp_path / LIVE)
with (tmp_path / LIVE / "parity_cases.json").open("a") as file:
file.write(" ")
assert_not_ready(make_client(bundles_dir=tmp_path))
def test_other_package_version_is_not_loaded(make_client, tmp_path, no_unpickling):
def older(card):
card["trained_with"]["scikit-learn"] = "1.0.0"
digest = changed_copy(tmp_path, "model_card.json", older)
assert_not_ready(make_client(bundles_dir=tmp_path, reviewed={LIVE: digest}))
def test_self_check_catches_another_estimate(make_client, tmp_path):
def one_dollar_more(cases):
cases[0]["expected_estimate"] += 1
digest = changed_copy(tmp_path, "parity_cases.json", one_dollar_more)
assert_not_ready(make_client(bundles_dir=tmp_path, reviewed={LIVE: digest}))

changed_copy joue le rôle d’un collègue qui modifie un fichier et écrit de nouvelles sommes de contrôle. Le test fournit la nouvelle empreinte comme empreinte révisée. La barrière suivante doit donc détecter le changement. Les tests paramétrés s’exécutent une fois pour chaque version de BUNDLES. Un nouveau paquet n’exige donc aucun nouveau test. Exécutez les tests :

Fenêtre de terminal
python -m pytest
Sortie (dernières lignes)
tests/test_bundle.py ....... [ 14%]
tests/test_contract.py .. [ 18%]
tests/test_estimates.py ....................................... [ 96%]
tests/test_health.py .. [100%]
============================== 50 passed in 1.11s ==============================

Sept tests de paquet remplacent les six tests de l’ancien fichier du modèle.

Démarrez l’API avec fastapi run. Dans un deuxième terminal, exécutez curl -s http://127.0.0.1:8000/ready : la réponse est {"status":"ready"} (état : prêt). Arrêtez l’API, redémarrez-la avec une version non révisée, puis appelez /ready de nouveau :

Fenêtre de terminal
MODEL_VERSION=kc-hgb-2015-03 fastapi run
The log of the first start, then of the second (shortened)
INFO house_price_api: model kc-hgb-2015-02 loaded (bundle 29752760649b...) in 296 ms, memory 234 MB
...
ERROR house_price_api: model not loaded: kc-hgb-2015-03 is not a reviewed bundle (config.BUNDLES)
WARNING house_price_api: model unavailable: kc-hgb-2015-03 is not a reviewed bundle (config.BUNDLES) id=e234cb73ae13
INFO house_price_api: GET /ready 503 5ms id=e234cb73ae13

Un paramètre peut seulement choisir ce que le code juge fiable. Arrêtez l’API. Sous Windows, supprimez le paramètre avec Remove-Item Env:MODEL_VERSION.

Le service doit estimer chaque maison deux fois. Mais l’outil d’Elif doit recevoir exactement une réponse, celle du modèle en production, dans le même format qu’avant. Et un candidat défectueux ne doit jamais compromettre une réponse du modèle en production.

Dans main.py, ajoutez ce bloc dans create_app, après le bloc de démarrage de la partie 1 :

house_price_api/main.py (add)
# The shadow: a candidate that estimates every house too, for the log
# only. If it cannot load, the live model still serves.
app.state.shadow = None
if settings.shadow_version:
try:
app.state.shadow = load_version(settings, settings.shadow_version)
logger.info(
"shadow %s loaded (bundle %s...), memory %s MB",
app.state.shadow.version,
app.state.shadow.digest[:12],
memory_mb(),
)
except ModelUnavailable as problem:
logger.error("shadow not loaded: %s", problem)

Si le modèle fantôme échoue à une barrière, le journal l’indique. Le modèle en production continue de servir comme avant.

Étape 2 : estimer deux fois, répondre une fois, journaliser les deux

Section intitulée « Étape 2 : estimer deux fois, répondre une fois, journaliser les deux »

Dans routes.py, ajoutez import json et import logging au début. Ajoutez deux journaliseurs sous router = ..., puis une fonction au-dessus de estimate_houses :

house_price_api/routes.py (add)
logger = logging.getLogger("house_price_api")
predictions = logging.getLogger("house_price_api.predictions")
def shadow_values(shadow: HouseEstimator, rows: list[dict]) -> list[float] | None:
"""The candidate's estimates. They go to the log, never to the client."""
try:
return [estimate.value for estimate in shadow.estimate(rows)]
except Exception: # a broken candidate must never break a live answer
logger.exception("shadow %s failed", shadow.version)
return None

Ensuite, dans estimate_houses, ajoutez ces lignes après le bloc except TimeoutError et avant return [ :

house_price_api/routes.py (add to estimate_houses)
shadow = request.app.state.shadow
shadowed = await run_in_threadpool(shadow_values, shadow, rows) if shadow else None
for item, estimate in enumerate(estimates):
record = {
"request_id": request.state.request_id,
"item": item,
"model_version": estimator.version,
"estimate": round(estimate.value, 2),
}
if shadowed is not None:
record |= {
"shadow_version": shadow.version,
"shadow_estimate": round(shadowed[item], 2),
}
predictions.info(json.dumps(record))
Partie Raison
Après l’estimation en production, hors de son délai limite La réponse du modèle en production est prête en premier. Un modèle fantôme lent ne peut pas la transformer en 503.
Une ligne de journal par maison, avec l’identifiant de requête item indique la position de la maison dans un lot. compare.py joint la ligne aux réponses de la réexécution grâce à l’identifiant de requête. Le client le reçoit aussi dans X-Request-ID.
Aucune description de maison dans la ligne Le cours Créer et déployer votre première API promettait que le journal n’identifie aucune maison ni personne. La ligne contient un identifiant, des versions et deux nombres.

La valeur de retour, et donc le contrat, ne change pas. Ajoutez deux tests à la fin de tests/test_bundle.py. Toute version révisée peut servir de modèle fantôme, même celle en production. Ces tests fonctionnent donc avant l’arrivée d’un deuxième modèle :

tests/test_bundle.py (add)
def test_shadow_is_logged_but_never_answers(make_client, house, caplog):
caplog.set_level(logging.INFO, logger="house_price_api.predictions")
# Any reviewed version can be the shadow, even the live one.
client = make_client(shadow_version=LIVE)
response = client.post("/v1/estimates", json=house)
assert response.json()["model_version"] == LIVE
[line] = [r.getMessage() for r in caplog.records if r.name == "house_price_api.predictions"]
record = json.loads(line)
assert record["request_id"] == response.headers["X-Request-ID"]
assert record["model_version"] == LIVE
assert record["shadow_version"] == LIVE
assert record["shadow_estimate"] == record["estimate"]
def test_a_broken_shadow_does_not_stop_the_live_model(make_client, house):
client = make_client(shadow_version="kc-hgb-2015-04")
assert client.get("/ready").status_code == 200
assert client.post("/v1/estimates", json=house).json()["model_version"] == LIVE

Exécutez python -m pytest : 52 passed (52 tests réussis). Observez ensuite une ligne de prédiction. Démarrez l’API avec le modèle en production comme son propre modèle fantôme. Envoyez la maison typique depuis un deuxième terminal :

Fenêtre de terminal
SHADOW_VERSION=kc-hgb-2015-02 fastapi run
Second terminal (on Windows: curl.exe, with the @requests/... part in quotes)
curl -s -X POST http://127.0.0.1:8000/v1/estimates -H "Content-Type: application/json" -d @requests/typical-house.json
Output, then the API's log
{"estimate_usd":422000,"range_usd":{"low":373000,"high":540000},"model_version":"kc-hgb-2015-02","prices_as_of":"2015-02-28","warnings":[]}
INFO house_price_api: model kc-hgb-2015-02 loaded (bundle 29752760649b...) in 260 ms, memory 240 MB
INFO house_price_api: shadow kc-hgb-2015-02 loaded (bundle 29752760649b...), memory 240 MB
INFO house_price_api.predictions: {"request_id": "e59dbefcf396", "item": 0, "model_version": "kc-hgb-2015-02", "estimate": 421936.2, "shadow_version": "kc-hgb-2015-02", "shadow_estimate": 421936.2}

Le client reçoit la réponse du cours Créer et déployer votre première API, champ par champ. Le journal contient les deux estimations, non arrondies, avec l’identifiant de requête. Arrêtez l’API (sous Windows, exécutez aussi Remove-Item Env:SHADOW_VERSION).

Le modèle fantôme a un coût. Nous avons mesuré une estimation 2 000 fois dans le conteneur de la partie 3 (1 CPU). Elle a pris 1,0 ms avec le modèle en production seul, et 2,1 ms avec le modèle fantôme aussi. Pour une équipe d’évaluation immobilière, 1 ms est négligeable. Pour un service proche de sa limite, utilisez le modèle fantôme quelques jours seulement, ou sur un échantillon de requêtes.

Le service fonctionne maintenant dans un conteneur avec les limites de Paramètres de ressources du conteneur.

D’abord, les paquets aux versions fixes pour l’image, préparés dans l’image de base, comme au module 4. L’API n’a pas besoin de pandas.

Fenêtre de terminal
docker run --rm python:3.14.8-slim-trixie sh -c 'pip install -q --root-user-action=ignore --disable-pip-version-check "fastapi[standard-no-fastapi-cloud-cli]==0.142.2" scikit-learn==1.9.1 numpy==2.5.3 scipy==1.18.1 joblib==1.6.0 && pip freeze' > requirements-run.txt

Dans Windows PowerShell, exécutez la même commande. Vérifiez ensuite que requirements-run.txt contient 57 lignes et aucun caractère étrange au début. La commande a pris 33 secondes pour nous. Créez ensuite quatre fichiers :

Dockerfile
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
# Every reviewed bundle goes into the image. MODEL_VERSION chooses the live
# one when the container starts, so a rollback needs no new image.
COPY bundles ./bundles
COPY pyproject.toml .
COPY house_price_api ./house_price_api
USER app
EXPOSE 8000
CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"]
.dockerignore
# Send only what the image needs. Data, candidates, tests, .env and .venv stay out.
*
!requirements-run.txt
!pyproject.toml
!house_price_api/
!bundles/
**/__pycache__/
compose.yaml
# The house-price API on this computer, with the limits of a small cloud
# container. The settings come from .env (copy .env.example).
services:
api:
image: house-price-api:${IMAGE_TAG:?Set IMAGE_TAG in .env}
build: .
ports:
- "127.0.0.1:8000:8000"
environment:
MODEL_VERSION: ${MODEL_VERSION:?Set MODEL_VERSION in .env}
SHADOW_VERSION: ${SHADOW_VERSION:-}
API_KEY: ${API_KEY:-}
SHOW_DOCS: "true"
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
.env.example
# Copy this file to .env. Compose reads it; Git and the image do not.
# IMAGE_TAG names the image: the code and the bundles in it.
# MODEL_VERSION is the live model; SHADOW_VERSION the candidate (empty: none).
# Both must be in the image and in BUNDLES in house_price_api/config.py.
IMAGE_TAG=1.1.0
MODEL_VERSION=kc-hgb-2015-02
SHADOW_VERSION=
API_KEY=local-dev-key

L’image contient tous les paquets révisés : c’est le choix de conception de cette étude de cas. Le modèle fantôme et le retour en arrière exigent tous deux un deuxième modèle prêt à servir. Une promotion ou un retour en arrière modifie donc un paramètre, pas l’image. L’étiquette de l’image désigne un ensemble de code et de paquets. Elle reçoit un nouveau numéro chaque fois qu’un paquet est ajouté ou supprimé.

Étape 2 : démarrer et vérifier la mise en production

Section intitulée « Étape 2 : démarrer et vérifier la mise en production »

Copiez .env.example vers .env (dans VS Code, ou avec cp .env.example .env; sous Windows, Copy-Item .env.example .env). Puis construisez et démarrez :

Fenêtre de terminal
docker compose up -d --build --wait
docker compose logs api
Output (the API's lines)
INFO house_price_api: model kc-hgb-2015-02 loaded (bundle 29752760649b...) in 120 ms, memory 167 MB
INFO house_price_api: GET /ready 200 18ms id=26c3b47d63bd

La première construction a pris environ 26 secondes. L’image occupe 687 Mo : la couche pip install occupe 380 Mo, et la couche bundles, 545 ko.

Voici maintenant un script à utiliser après chaque mise en production et chaque retour en arrière. Il pose quatre questions au service en cours d’exécution et compare les réponses aux fichiers de votre projet. Créez scripts/check_release.py :

scripts/check_release.py
"""Check that a running API serves the version that you released, unchanged.
python scripts/check_release.py http://127.0.0.1:8000 kc-hgb-2015-02
Run it after every promotion and every rollback. Set HOUSE_API_KEY first if
the API needs a key. It checks four things against the files in this project:
1. /ready is 200.
2. /v1/model names the version, and the SHA-256 of its model file.
3. Parity: each parity case of bundles/<version>/ gets the estimate that the
bundle recorded, rounded to 1,000 USD as the API rounds it.
4. The contract: /openapi.json equals contract/openapi.json, byte for byte.
The exit code is 0 if every check passed, and 1 if one failed.
"""
import json
import os
import sys
from pathlib import Path
import httpx
PROJECT = Path(__file__).resolve().parent.parent
failures = []
def check(passed: bool, text: str) -> None:
print(f" {'PASS' if passed else 'FAIL'} {text}")
if not passed:
failures.append(text)
def main() -> int:
if len(sys.argv) != 3:
print(__doc__)
return 2
url, version = sys.argv[1], sys.argv[2]
bundle = PROJECT / "bundles" / version
card = json.loads((bundle / "model_card.json").read_text("utf-8"))
cases = json.loads((bundle / "parity_cases.json").read_text("utf-8"))
key = os.environ.get("HOUSE_API_KEY")
api = httpx.Client(base_url=url, timeout=10, headers={"X-API-Key": key} if key else {})
print(f"Release check: expected {version}")
check(api.get("/ready").status_code == 200, "/ready is 200")
served = api.get("/v1/model").json()
check(
served.get("model_version") == version and served.get("sha256") == card["sha256"],
f"/v1/model names {served.get('model_version')}, model file {served.get('sha256', '?')[:12]}...",
)
matched = 0
for case in cases:
answer = api.post("/v1/estimates", json=case["house"]).json()
expected = int(round(case["expected_estimate"], -3))
if answer.get("estimate_usd") == expected:
matched += 1
else:
print(f" {case['name']}: got {answer.get('estimate_usd')}, expected {expected}")
check(matched == len(cases), f"parity: {matched} of {len(cases)} cases give the bundle's estimates")
response = api.get("/openapi.json")
if response.status_code == 404:
print(" INFO /openapi.json is hidden (SHOW_DOCS=false): the contract was not compared")
else:
reviewed = (PROJECT / "contract" / "openapi.json").read_text("utf-8")
check(json.dumps(response.json(), indent=2) + "\n" == reviewed,
"contract: /openapi.json equals contract/openapi.json")
print("All checks passed." if not failures else f"{len(failures)} check(s) failed.")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())

La parité par HTTP compare les estimations arrondies, car l’API arrondit à 1 000 dollars. L’autovérification au démarrage a déjà comparé les estimations non arrondies, avec une tolérance de 10⁻⁹. Fournissez la clé au script, puis exécutez-le. Sous Windows, définissez la clé avec $env:HOUSE_API_KEY = "local-dev-key" :

Fenêtre de terminal
export HOUSE_API_KEY=local-dev-key
python scripts/check_release.py http://127.0.0.1:8000 kc-hgb-2015-02
Sortie
Release check: expected kc-hgb-2015-02
PASS /ready is 200
PASS /v1/model names kc-hgb-2015-02, model file 8d0483d05cfc...
PASS parity: 9 of 9 cases give the bundle's estimates
PASS contract: /openapi.json equals contract/openapi.json
All checks passed.

Cette sortie sert de preuve dans le registre de mise en production.

Étape 3 : une première réexécution, sans modèle fantôme

Section intitulée « Étape 3 : une première réexécution, sans modèle fantôme »

Envoyez les 2 819 requêtes enregistrées au conteneur :

Fenêtre de terminal
python scripts/replay.py http://127.0.0.1:8000 replay/requests-2015-04-05.jsonl replay/out/answers.csv
Sortie
Sent 2819 requests in 13.0 s: 2817 answered 200, refused 422 invalid_request: 2.
Time per request: median 3.4 ms, 95th percentile 9.9 ms.
Wrote replay/out/answers.csv

Deux requêtes réelles dépassent les limites du contrat : un terrain de 1 164 794 pieds carrés, et un sqft_living15 de 399. Un nouveau modèle ne constitue pas un nouveau contrat. Chaque comparaison utilise donc les 2 817 requêtes ayant reçu une réponse. Les temps variaient d’environ 1 ms d’une exécution à l’autre.

Ce mois-ci, le comté a modifié le fichier exporté que l’analyste télécharge : mêmes colonnes, mêmes ventes. Nous simulons cette nouvelle exportation avec un script. Les données réelles du comté n’ont jamais subi ce changement.

Fenêtre de terminal
python training/simulate_export.py
python training/train.py --data data/county-export-2015-04-01.csv.gz --until 2015-04-01 --version kc-hgb-2015-03-rc1
Sortie
wrote data/county-export-2015-04-01.csv.gz: 21613 sales
data: county-export-2015-04-01.csv.gz, SHA-256 50d99029d343... (not the reviewed course file)
train 18794 sales | test 2819 sales
trained in 2.7 s
test: {'median_error_pct': 7.1, 'mape_pct': 13.1, 'within_10pct': 0.482, 'over_by_10pct_or_more': 0.12}
range: 0.890 to 1.295 times the estimate (80% of test sales inside)
wrote candidates/kc-hgb-2015-03-rc1/ (house_price.joblib 0.39 MB, model_card.json)
sha256 3cd6b0831257a4b353fb6b3748bd4fbb99ecbb9500041b46a87b846cc0cbd35a

-rc1 signifie version candidate : une version qui pourrait être mise en production, ou non. Son erreur hors ligne (MAPE) est de 13,1 %, comme celle de la version 1 sur ces maisons. Créez son paquet, puis ajoutez sa ligne à BUNDLES :

Fenêtre de terminal
python make_bundle.py candidates/kc-hgb-2015-03-rc1
Sortie
Made bundles/kc-hgb-2015-03-rc1: 9 parity cases.
Bundle digest: 10652d2d82d7e619c49756ebb9c5c8f9ec261c73c7df645136c2b4f89f001275
house_price_api/config.py (change)
BUNDLES = {
"kc-hgb-2015-02": "29752760649bf79193867d4a5887ee47c361aa913315d831b0e9fafe6f50d1cf",
"kc-hgb-2015-03-rc1": "10652d2d82d7e619c49756ebb9c5c8f9ec261c73c7df645136c2b4f89f001275",
}

Exécutez python -m pytest : 54 passed (54 tests réussis). Le candidat réussit son autovérification, et son contrat est celui qui a été révisé.

Prédisez avant de regarder. L’erreur hors ligne est identique à celle de la version 1, et tous les tests réussissent. Le candidat réussira-t-il les trois vérifications de mise en production?

Dans .env, définissez IMAGE_TAG=1.1.1 et SHADOW_VERSION=kc-hgb-2015-03-rc1. L’image a besoin du nouveau paquet; construisez-la donc de nouveau. Seules les couches à partir de COPY bundles sont nouvelles. La couche pip install de 380 Mo vient du cache, et la construction a pris 9 secondes :

Fenêtre de terminal
docker compose up -d --build --wait
python scripts/replay.py http://127.0.0.1:8000 replay/requests-2015-04-05.jsonl replay/out/answers.csv

Le journal contient les deux lignes de démarrage et une ligne de prédiction pour chaque requête ayant reçu une réponse :

The API's log (shortened)
INFO house_price_api: model kc-hgb-2015-02 loaded (bundle 29752760649b...) in 199 ms, memory 168 MB
INFO house_price_api: shadow kc-hgb-2015-03-rc1 loaded (bundle 10652d2d82d7...), memory 170 MB
...
INFO house_price_api.predictions: {"request_id": "435263bd860e", "item": 0, "model_version": "kc-hgb-2015-02", "estimate": 235254.36, "shadow_version": "kc-hgb-2015-03-rc1", "shadow_estimate": 262660.79}

Joignez maintenant trois fichiers : les réponses de la réexécution (identifiant de vente, identifiant de requête), le journal (identifiant de requête, deux estimations) et les ventes (identifiant de vente, prix). Créez scripts/compare.py :

scripts/compare.py
"""Compare the live model and the shadow on a replay, and run the release checks.
python scripts/compare.py replay/out/answers.csv replay/out/service.log replay/sales-2015-04-05.csv
answers.csv comes from replay.py, service.log is the API's log during the
replay, and the sales file has the prices that arrived later. The script
joins the three, prints the error of each model on the sale prices and how
much the estimates move, runs the release checks, and lists the largest
moves. It writes the joined rows to compare-<shadow version>.csv next to
answers.csv.
A regression model has no threshold to check. So the checks look at two
things: is the new model's error lower, and how much do the answers that
clients see change? A person reads the largest moves before deciding.
"""
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
# The release checks, agreed with the valuation team before the shadow ran.
MAX_ERROR_INCREASE = 0.01 # the shadow's MAE may be at most 1% above the live MAE
BIG_MOVE = 0.10 # an estimate "moves" when it changes by more than 10%
MAX_MOVED_SHARE = 0.05 # at most 5% of estimates may move
MAX_AREA_MOVE = 0.05 # no zip code's median estimate may move by more than 5%
MIN_AREA_SALES = 10 # areas with fewer sales are too small to judge
MARKER = "house_price_api.predictions: "
def read_log(path: Path) -> pd.DataFrame:
"""The prediction lines of the log, one row per estimated house."""
rows = [json.loads(line.split(MARKER, 1)[1]) for line in path.read_text("utf-8").splitlines() if MARKER in line]
return pd.DataFrame(rows)
def errors(price: pd.Series, estimate: pd.Series) -> dict:
pct = (price - estimate) / price
return {
"Mean absolute error (USD)": f"{np.mean(np.abs(price - estimate)):,.0f}",
"Within 10% of the price": f"{np.mean(np.abs(pct) <= 0.10):.1%}",
"Median error (+ = too low)": f"{np.median(pct):+.1%}",
}
def main() -> int:
if len(sys.argv) != 4:
print(__doc__)
return 2
answers_file, log_file, sales_file = map(Path, sys.argv[1:])
answers = pd.read_csv(answers_file, dtype={"sale_id": str})
answers = answers[answers["status"] == 200].drop(columns="model_version")
log = read_log(log_file)
if "shadow_estimate" not in log:
print("The log has no shadow estimates: start the API with SHADOW_VERSION.")
return 2
sales = pd.read_csv(sales_file, dtype={"sale_id": str, "zipcode": str})
rows = (
answers.merge(log, on="request_id", validate="one_to_one")
.merge(sales, on="sale_id", validate="one_to_one")
)
rows["move"] = rows["shadow_estimate"] / rows["estimate"] - 1
live, shadow = rows["model_version"].iloc[0], rows["shadow_version"].iloc[0]
print(f"Joined {len(rows)} answers with the log and the sale prices.")
print(f"Live {live}, shadow {shadow}.\n")
live_errors, shadow_errors = errors(rows["price"], rows["estimate"]), errors(rows["price"], rows["shadow_estimate"])
print(f"{'':28}{'live':>12}{'shadow':>12}")
for name in live_errors:
print(f"{name:28}{live_errors[name]:>12}{shadow_errors[name]:>12}")
mae_live = np.mean(np.abs(rows["price"] - rows["estimate"]))
mae_shadow = np.mean(np.abs(rows["price"] - rows["shadow_estimate"]))
change = mae_shadow / mae_live - 1
moved = rows["move"].abs() > BIG_MOVE
areas = rows.groupby("zipcode")["move"].agg(["median", "size"])
areas = areas[areas["size"] >= MIN_AREA_SALES]
worst = areas["median"].abs().idxmax()
checks = [
(change <= MAX_ERROR_INCREASE,
f"error: the shadow's MAE is at most {MAX_ERROR_INCREASE:.0%} above the live MAE ({change:+.1%})"),
(moved.mean() <= MAX_MOVED_SHARE,
f"moves: at most {MAX_MOVED_SHARE:.0%} of estimates move by more than {BIG_MOVE:.0%} "
f"({moved.mean():.1%}, {moved.sum()} houses)"),
(abs(areas.loc[worst, "median"]) <= MAX_AREA_MOVE,
f"areas: no zip code's median estimate moves by more than {MAX_AREA_MOVE:.0%} "
f"(largest {worst}: {areas.loc[worst, 'median']:+.1%}, {areas.loc[worst, 'size']} sales)"),
]
print("\nRelease checks")
for passed, text in checks:
print(f" {'PASS' if passed else 'FAIL'} {text}")
print("\nThe 5 largest moves (read them before you decide)")
print(f" {'sale':22}{'live':>11}{'shadow':>11}{'move':>8}{'price':>11} closer")
for row in rows.reindex(rows["move"].abs().sort_values(ascending=False).index).head(5).itertuples():
closer = "shadow" if abs(row.price - row.shadow_estimate) < abs(row.price - row.estimate) else "live"
print(f" {row.sale_id:22}{row.estimate:>11,.0f}{row.shadow_estimate:>11,.0f}"
f"{row.move:>+8.1%}{row.price:>11,} {closer}")
out = answers_file.with_name(f"compare-{shadow}.csv")
rows[["sale_id", "date", "zipcode", "price", "estimate", "shadow_estimate", "move"]].to_csv(out, index=False)
print(f"\nWrote {out}")
return 0 if all(passed for passed, _ in checks) else 1
if __name__ == "__main__":
sys.exit(main())
Partie Raison
Les limites au début, avec un commentaire Elles ont été convenues avant l’exécution du modèle fantôme. La personne qui révise les voit au même endroit.
validate="one_to_one" Chaque réponse correspond à exactement une ligne de journal et une vente. Un doublon ou une ligne perdue arrête le script, au lieu de produire un nombre erroné.
Les zones utilisent zipcode du fichier des ventes L’API ne reçoit jamais le code postal. La comparaison peut l’utiliser, car elle s’exécute sur votre ordinateur, après coup.
Le code de sortie 0 si toutes les vérifications réussissent, 1 sinon. Un pipeline peut l’utiliser plus tard, par exemple dans le cours CI/CD, tests et mises en production sûres.

Enregistrez le journal, puis comparez :

Fenêtre de terminal
docker compose logs --no-log-prefix api > replay/out/service.log
python scripts/compare.py replay/out/answers.csv replay/out/service.log replay/sales-2015-04-05.csv
Sortie
Joined 2817 answers with the log and the sale prices.
Live kc-hgb-2015-02, shadow kc-hgb-2015-03-rc1.
live shadow
Mean absolute error (USD) 77,300 93,864
Within 10% of the price 48.2% 37.1%
Median error (+ = too low) +7.4% +10.8%
Release checks
FAIL error: the shadow's MAE is at most 1% above the live MAE (+21.4%)
FAIL moves: at most 5% of estimates move by more than 10% (23.7%, 667 houses)
FAIL areas: no zip code's median estimate moves by more than 5% (largest 98092: -11.9%, 52 sales)
The 5 largest moves (read them before you decide)
sale live shadow move price closer
5428000070-20150511 779,716 553,007 -29.1% 770,000 live
2822059181-20150413 273,872 353,301 +29.0% 306,000 live
1921069101-20150508 408,910 295,427 -27.8% 399,000 live
1720069075-20150508 549,665 402,908 -26.7% 530,000 live
9808100150-20150402 2,411,350 1,785,070 -26.0% 3,345,000 live
Wrote replay/out/compare-kc-hgb-2015-03-rc1.csv

Les trois vérifications échouent. Pour les cinq plus grands écarts, le modèle en production était chaque fois plus proche du prix réel. Hors ligne, ce même candidat avait la même erreur que la version 1. Le problème n’est donc pas ce que le modèle a appris. Il se situe entre les données d’entraînement et l’API.

Lisez les trois plus grands écarts dans le fichier de réexécution :

Fenêtre de terminal
grep -E "5428000070-20150511|2822059181-20150413|1921069101-20150508" replay/requests-2015-04-05.jsonl

Sous Windows, utilisez Select-String -Pattern "5428000070-20150511|2822059181-20150413|1921069101-20150508" replay/requests-2015-04-05.jsonl.

Output (shortened to the fields that matter)
{"sale_id": "2822059181-20150413", "house": {..., "yr_built": 1910, "yr_renovated": null, ...}}
{"sale_id": "1921069101-20150508", "house": {..., "yr_built": 2008, "yr_renovated": null, ...}}
{"sale_id": "5428000070-20150511", "house": {..., "yr_built": 2013, "yr_renovated": null, ...}}

Les trois maisons n’ont jamais été rénovées. Les estimations des deux maisons neuves ont baissé de 28 % et de 29 %. Celle de la vieille maison a augmenté de 29 %. Examinez donc yr_renovated dans les deux fichiers de données. Comptez les maisons avec le code 0, « jamais rénovée » :

Fenêtre de terminal
python -c "import pandas as pd; [print(f, (pd.read_csv('data/' + f).yr_renovated == 0).sum()) for f in ['king_county_house_sales.csv.gz', 'county-export-2015-04-01.csv.gz']]"
Sortie
king_county_house_sales.csv.gz 20699
county-export-2015-04-01.csv.gz 0

Pour une maison jamais rénovée, la nouvelle exportation inscrit l’année de construction, et non 0. Mais l’API envoie toujours 0 pour « jamais », comme le prévoit le contrat. Ainsi, pour les 96 % de maisons jamais rénovées, rc1 reçoit en service une valeur jamais vue pendant l’entraînement. Un arbre interprète 0 comme « les derniers travaux remontent à longtemps » : c’est très faux pour une maison neuve. Voici l’écart médian de rc1 pour les maisons jamais rénovées, par période de construction :

Construction Maisons Écart médian Écart de plus de 10 %
1900–1939 364 +0.9 % 5.8 %
1940–1959 504 +1.8 % 10.7 %
1960–1979 620 -0.3 % 6.1 %
1980–1999 582 -7.4 % 32.3 %
2000–2015 647 -10.7 % 54.7 %

C’est un décalage entre l’entraînement et le service dans l’autre sens. Ici, l’entraînement a changé une valeur que le service n’a pas changée. Les cas de parité ne pouvaient pas le détecter, car le candidat lui-même avait calculé leurs estimations attendues. Le code postal 98092 a connu le plus grand écart. Parmi ses maisons réexécutées, 54 % avaient été construites en 1990 ou après, contre 34 % pour l’ensemble des maisons.

Erreur courante

Le candidat a réussi tous les tests, mais a échoué comme modèle fantôme

Symptôme : un candidat a la même erreur hors ligne que le modèle en production et réussit toutes les barrières et tous les tests. Comme modèle fantôme, il échoue aux trois vérifications de mise en production.

Cause : les données d’entraînement codent yr_renovated différemment de l’API. Le test hors ligne utilisait la même exportation; il ne pouvait donc pas voir la différence. Toute vérification qui utilise seulement le candidat est d’accord avec le candidat.

Correction :

  1. Rejetez le candidat. Retirez sa ligne de BUNDLES, supprimez bundles/kc-hgb-2015-03-rc1/, puis consignez la décision dans RELEASES.md (partie 5). Il est alors retiré : aucune image future ne le contient, et aucun paramètre ne peut le mettre en production.

  2. Arrêtez la cause à sa source. Dans training/train.py, ajoutez une vérification que les données utilisent le code de l’API. Appelez-la dans load_data, après la ligne qui corrige les 33 chambres :

    training/train.py (add)
    def check_codes(houses: pd.DataFrame) -> None:
    """Stop if a column uses another code than the API sends to the model."""
    if not (houses["yr_renovated"] == 0).any():
    raise SystemExit(
    "Stopped: yr_renovated is never 0 in this file. The API sends 0 for "
    "'never renovated', so the model must learn from that code."
    )

    Entraînez de nouveau avec l’exportation. La sortie est maintenant : Stopped: yr_renovated is never 0 in this file. The API sends 0 for 'never renovated', so the model must learn from that code. (Arrêt : yr_renovated ne vaut jamais 0 dans ce fichier. L’API envoie 0 pour « jamais rénovée »; le modèle doit donc apprendre à partir de ce code.)

  3. Demandez à l’analyste de convertir l’exportation à l’ancien code, ou d’utiliser les données révisées. Ne modifiez pas l’API pour envoyer l’année de construction. Cela changerait le sens de null dans le contrat, et chaque client devrait le savoir.

Ce que cela signifie pour un vrai service d’IA : les mesures hors ligne et les tests unitaires vérifient un modèle avec ses propres données. Une mise en production doit aussi le vérifier avec les requêtes qu’il traitera, aux côtés du modèle qu’il remplace. Un modèle fantôme sur des requêtes réelles est le moyen le moins coûteux de détecter ce problème. Aucun client n’a reçu une seule estimation erronée.

Partie 5 : le deuxième candidat, la décision et la promotion

Section intitulée « Partie 5 : le deuxième candidat, la décision et la promotion »

L’analyste entraîne de nouveau le modèle, avec les données du cours qui ont été révisées :

Fenêtre de terminal
python training/train.py --until 2015-04-01 --version kc-hgb-2015-03
python make_bundle.py candidates/kc-hgb-2015-03
Sortie
train 18794 sales | test 2819 sales
trained in 3.1 s
test: {'median_error_pct': 6.9, 'mape_pct': 12.9, 'within_10pct': 0.479, 'over_by_10pct_or_more': 0.119}
range: 0.893 to 1.288 times the estimate (80% of test sales inside)
wrote candidates/kc-hgb-2015-03/ (house_price.joblib 0.52 MB, model_card.json)
sha256 054ed960e883b16905447d38db386da87ffbf9240f7c7fc574a9f44861aa4ccb
Made bundles/kc-hgb-2015-03: 9 parity cases.
Bundle digest: 1ed11041ebcd4ccb751b599269c119d4d1d1978b620ac87e04ccf17c83dca30f

Hors ligne, l’erreur typique de la version 2 est de 12,9 %, contre 13,1 % pour la version 1. Vous avez retiré la ligne rc1 de BUNDLES dans la correction de la partie 4. Ajoutez donc la nouvelle :

house_price_api/config.py (change)
BUNDLES = {
"kc-hgb-2015-02": "29752760649bf79193867d4a5887ee47c361aa913315d831b0e9fafe6f50d1cf",
"kc-hgb-2015-03": "1ed11041ebcd4ccb751b599269c119d4d1d1978b620ac87e04ccf17c83dca30f",
}

Exécutez python -m pytest : 54 tests réussis. Ouvrez maintenant bundles/kc-hgb-2015-03/parity_cases.json et comparez ses estimations à celles de la version 1. La maison typique passe de 421 936 à 379 196 (−10,1 %). La maison au bord de l’eau passe de 2 155 965 à 1 841 795 (−14,6 %). L’estimation de la plus grande maison du contrat baisse de 24 %.

Faites une prédiction avant de regarder. Sept des neuf cas de parité ont changé de plus de 5 %. Quelle proportion des 2 817 maisons réelles changera de plus de 10 %?

Dans .env, définissez IMAGE_TAG=1.1.2 et SHADOW_VERSION=kc-hgb-2015-03. L’image 1.1.2 contient les versions 1 et 2, mais pas rc1. Exécutez ensuite les quatre mêmes commandes qu’à la partie 4 :

Fenêtre de terminal
docker compose up -d --build --wait
python scripts/replay.py http://127.0.0.1:8000 replay/requests-2015-04-05.jsonl replay/out/answers.csv
docker compose logs --no-log-prefix api > replay/out/service.log
python scripts/compare.py replay/out/answers.csv replay/out/service.log replay/sales-2015-04-05.csv

Sous Windows, enregistrez le journal avec Out-File -Encoding utf8, comme à la partie 4.

Output of compare.py
Joined 2817 answers with the log and the sale prices.
Live kc-hgb-2015-02, shadow kc-hgb-2015-03.
live shadow
Mean absolute error (USD) 77,300 76,960
Within 10% of the price 48.2% 47.9%
Median error (+ = too low) +7.4% +6.9%
Release checks
PASS error: the shadow's MAE is at most 1% above the live MAE (-0.4%)
PASS moves: at most 5% of estimates move by more than 10% (2.9%, 82 houses)
PASS areas: no zip code's median estimate moves by more than 5% (largest 98014: -2.8%, 14 sales)
The 5 largest moves (read them before you decide)
sale live shadow move price closer
9253900408-20150408 1,879,833 1,427,749 -24.0% 1,400,000 shadow
853200040-20150428 2,168,502 2,665,208 +22.9% 2,408,000 live
3052700385-20150414 694,199 554,286 -20.2% 765,000 live
4134300175-20150417 2,095,206 1,674,514 -20.1% 1,851,000 shadow
4385700250-20150407 1,638,151 1,313,107 -19.8% 1,800,000 live
Wrote replay/out/compare-kc-hgb-2015-03.csv

Les deux candidats sont côte à côte, pour les mêmes 2 817 requêtes. Partez de 10 % sur l’axe du bas et remontez jusqu’à chaque courbe :

  • Version 2 (kc-hgb-2015-03)
  • rc1 (nouvelle exportation)
La proportion des 2 817 estimations rejouées qui changent de plus qu'une valeur donnée, par rapport au modèle en production. À 10 %, la limite de la vérification de mise en production, la version 2 change 2,9 % des estimations et rc1, 23,7 %. La limite est de 5 %.
Description textuelle du graphique

Un graphique à deux courbes descendantes. L’axe horizontal indique l’ampleur du changement, de 0 % à 30 %. L’axe vertical indique la proportion des 2 817 estimations qui changent de plus que cette valeur, de 0 % à 100 %. Les deux courbes commencent à 100 % au point 0 %, car presque toutes les estimations changent un peu. La courbe de la version 2 descend rapidement : environ 15 % de ses estimations changent de plus de 5 %, et 2,9 % de plus de 10 %. La courbe pointillée de rc1 descend lentement : 23,7 % de ses estimations changent de plus de 10 %. Elle atteint 0 % seulement près de 30 %.

L'erreur absolue moyenne de chaque modèle sur les 2 817 requêtes ayant reçu une réponse en avril et mai 2015, par rapport aux prix de vente. rc1 est 21 % moins bon que le modèle en production; la version 2 est 0,4 % meilleure.
Description textuelle du graphique

Un graphique à trois colonnes : le modèle en production (version 1), 77 300 dollars; rc1, 93 864 dollars; version 2, 76 960 dollars. Les colonnes du modèle en production et de la version 2 semblent de même hauteur. La colonne de rc1 est nettement plus haute.

Les trois vérifications réussissent. La version 2 est un peu meilleure en moyenne (MAE inférieure de 340 dollars), mais pas selon toutes les mesures. Elle place 47,9 % des estimations à moins de 10 % du prix, contre 48,2 %. Les cas de parité étaient un mauvais guide : ils ont beaucoup changé. Pourtant, 84.5 % des estimations réelles ont changé de 5 % ou moins.

Les vérifications réussissent; une personne examine maintenant les résultats. Quatre des cinq plus grands changements concernent des maisons estimées à 1 million de dollars ou plus. Dans ce groupe, 14.7 % des 156 maisons ont changé de plus de 10 %, contre 2.2 % des autres. Peu de maisons chères se vendent chaque mois. L’API du cours sur les API leur ajoute déjà l’avertissement high_estimate (estimation élevée).

Les changements ont-ils aidé? Pour 39 des 82 maisons ayant changé de plus de 10 %, la version 2 était plus proche du prix de vente : environ la moitié. Ces changements représentent donc de l’incertitude, pas des corrections. Kenji le note pour Hannah.

Hannah décide de promouvoir le modèle. La réduction de l’erreur est faible, et elle le précise dans le registre. La raison de la mise en production est un mois de prix supplémentaire : prices_as_of devient 2015-03-31.

Pour promouvoir le modèle, modifiez deux lignes dans .env : MODEL_VERSION=kc-hgb-2015-03 et SHADOW_VERSION= (vide). Gardez IMAGE_TAG=1.1.2 : l’image ne change pas. Redémarrez ensuite et vérifiez la mise en production :

Fenêtre de terminal
docker compose up -d --wait
python scripts/check_release.py http://127.0.0.1:8000 kc-hgb-2015-03
curl -s -X POST http://127.0.0.1:8000/v1/estimates -H "X-API-Key: local-dev-key" -H "Content-Type: application/json" -d @requests/typical-house.json
Sortie
Release check: expected kc-hgb-2015-03
PASS /ready is 200
PASS /v1/model names kc-hgb-2015-03, model file 054ed960e883...
PASS parity: 9 of 9 cases give the bundle's estimates
PASS contract: /openapi.json equals contract/openapi.json
All checks passed.
{"estimate_usd":379000,"range_usd":{"low":339000,"high":488000},"model_version":"kc-hgb-2015-03","prices_as_of":"2015-03-31","warnings":[]}

La version 2 est en production, avec les mêmes champs et de nouvelles valeurs pour model_version et prices_as_of. L’outil d’Elif n’a besoin d’aucun changement.

Créez RELEASES.md : le registre de modèles du module 5, tenu à la main. Sous son tableau, placez les vérifications convenues, puis une entrée par événement avec ses preuves et la personne qui a décidé. Le projet terminé contient le fichier complet. Le tableau :

RELEASES.md (start)
| Version | Bundle digest | Status | Trained with sales to | Image |
|---|---|---|---|---|
| kc-hgb-2015-02 | `2975276064...` | previous: the rollback target | 2015-02-28 | 1.1.2 |
| kc-hgb-2015-03-rc1 | `10652d2d82...` | rejected, retired | 2015-03-31 (new export) | 1.1.1 only |
| kc-hgb-2015-03 | `1ed11041eb...` | **live** | 2015-03-31 | 1.1.2 |

(Le fichier terminé contient les empreintes complètes.) L’entrée de rc1 reste après son retrait : elle prouve que les vérifications fonctionnent.

Daniel a demandé à quelle vitesse vous pouvez revenir en arrière. Un retour en arrière non testé est un espoir, pas un plan. Un exercice donne une réponse mesurée, lors d’une journée calme.

Dans .env, définissez MODEL_VERSION=kc-hgb-2015-02. Ensuite :

Fenêtre de terminal
docker compose up -d --wait
python scripts/check_release.py http://127.0.0.1:8000 kc-hgb-2015-02
Output of check_release.py
Release check: expected kc-hgb-2015-02
PASS /ready is 200
PASS /v1/model names kc-hgb-2015-02, model file 8d0483d05cfc...
PASS parity: 9 of 9 cases give the bundle's estimates
PASS contract: /openapi.json equals contract/openapi.json
All checks passed.

docker compose up -d --wait s’est terminé après 6,4 secondes (la vérification de santé s’exécute toutes les 5 secondes). Un petit script interrogeait /v1/model toutes les 20 ms. Il a vu la version 2 jusqu’à 1,23 s, aucune réponse jusqu’à 2,81 s, puis la version 1. L’interruption a donc duré environ 1,6 seconde. Le retour prend quelques secondes parce que le paquet précédent est déjà dans l’image et a déjà été révisé.

Définissez de nouveau MODEL_VERSION=kc-hgb-2015-03, puis exécutez les deux mêmes commandes avec kc-hgb-2015-03. Ajoutez ensuite l’exercice à RELEASES.md : la date, les 6,4 s, l’interruption de 1,6 s et les deux vérifications de mise en production.

Cette interruption est le piège du conteneur unique : up -d arrête l’ancien conteneur avant que le nouveau soit prêt. Une plateforme avec des révisions laisse l’ancien répondre aux requêtes. Voir Configuration opérationnelle dans L’essentiel de Docker et du nuage pour les développeurs en IA, et Stratégie de retour en arrière dans CI/CD, tests et mises en production sûres, si vous l’avez suivi.

Erreur courante

Une faute de frappe dans MODEL_VERSION, et plus aucune estimation

Symptôme : après un changement dans .env, docker compose up -d --wait attend environ 22 secondes. Il s’arrête ensuite avec container ... is unhealthy (le conteneur est en mauvais état) et le code de sortie 1. /health répond {"status":"ok"}, mais chaque estimation reçoit 503 model_unavailable (modèle indisponible). Le journal indique :

ERROR house_price_api: model not loaded: kc-hgb-2015-2 is not a reviewed bundle (config.BUNDLES)

Cause : il manque un 0 dans MODEL_VERSION=kc-hgb-2015-2. L’API a refusé cette version, comme prévu : elle n’a pas été révisée. Mais avec un seul conteneur, l’ancien est déjà arrêté. Une faute de frappe pendant un retour en arrière, au moment le plus stressant, signifie donc qu’il n’y a plus d’estimations.

Correction :

  1. Corrigez la ligne dans .env et exécutez de nouveau docker compose up -d --wait. Dans notre essai, le service était en bon état 6,3 secondes plus tard.
  2. Copiez la version depuis RELEASES.md au lieu de la taper.
  3. Sur une vraie plateforme, utilisez des révisions : l’ancienne version continue de répondre pendant que la nouvelle échoue à sa vérification de santé.

Ce que cela signifie pour un vrai service d’IA : la vérification de sécurité a fonctionné : aucun modèle inconnu n’a été chargé. Mais une vérification qui bloque le démarrage arrête aussi le service. Planifiez la mise en production pour que l’ancienne version réponde jusqu’à ce que la nouvelle soit prête.

Arrêtez le conteneur et supprimez les trois images. Les paquets, le code et RELEASES.md restent dans votre projet.

Fenêtre de terminal
docker compose down
docker image rm house-price-api:1.1.0 house-price-api:1.1.1 house-price-api:1.1.2

Dans un vrai registre, gardez 1.1.2 (la version en production et la précédente). Supprimez 1.1.1, la seule image contenant rc1, qui a été retiré.

Nous avons exécuté chaque étape de cette page depuis starter.zip, dans un nouveau dossier avec un nouvel environnement virtuel. Les commandes et les sorties complètes se trouvent dans le dépôt du cours (reference/c17/case-studies/house-price-release/RUNS.md).

Vérification Résultat
Tests après chaque partie 49 (début), 50, 52, puis 54 réussis; projet terminé : 54 réussis
Paquets La même empreinte que sur cette page pour chaque paquet (l’entraînement est reproductible)
Rejeu 2 817 requêtes sur 2 819 ont reçu une réponse; 2 refusées par le contrat
Vérifications de mise en production rc1 : les trois échouent; version 2 : les trois réussissent
Exercice de retour en arrière En bon état après 6,4 s; environ 1,6 s sans réponse
Faute de frappe dans MODEL_VERSION En mauvais état après 22 s, code de sortie 1; en bon état 6,3 s après la correction
Non testé Windows et Linux; une plateforme avec des révisions; un déploiement dans le nuage

Un nouveau modèle change des chiffres sur lesquels des personnes fondent leurs actions. La mise en production est le moment de décider si ces chiffres doivent changer.

  • Pas pour prendre des décisions sur des personnes. Comme dans le cours sur les API, l’estimation est un premier chiffre pour une équipe d’évaluation immobilière. La mise en production d’un modèle ne change pas cette limite. La fiche de chaque version garde la même liste not_for.
  • La vérification par secteur mesure le changement, pas l’équité. Aux États-Unis, les prix d’un secteur sont liés aux personnes qui y vivent, par une longue histoire de discrimination. Une mise en production qui change de 12 % les estimations d’un code postal change les chiffres d’une communauté. La vérification par secteur rend ce changement visible et le bloque. Elle ne montre pas si l’un ou l’autre modèle traite les secteurs équitablement. Cela exige une révision par des personnes qualifiées, avec de meilleures données.
  • Le journal de l’exécution en parallèle contient des données sur les requêtes. Il contient les identifiants des requêtes, les versions et les estimations, mais aucune description de maison. Gardez-le seulement le temps nécessaire à la comparaison. Supprimez-le après la décision, selon les règles du prêteur sur les données.
  • Les anciens prix restent anciens. La version 2 connaît les prix jusqu’à mars 2015. Chaque réponse le précise dans prices_as_of.

Ce projet montre à un employeur que vous pouvez changer un modèle en production sans surprendre personne. Il comprend des paquets à versions fixes, une exécution en parallèle, des vérifications convenues à l’avance, une décision consignée et un retour en arrière mesuré. Publiez-le dans un dépôt à votre nom.

house-price-api/
README.md what it is, how to run it, how a release works
RELEASES.md the registry: versions, digests, status, evidence, decisions
MODEL_CARD.md both versions, their test results and limits
LICENSE MIT, for the code
Dockerfile, compose.yaml, .dockerignore, .env.example, requirements-run.txt
make_bundle.py a model folder -> bundles/<version>/ with SHA256SUMS
bundles/ kc-hgb-2015-02 and kc-hgb-2015-03 (model, card, parity cases, sums)
house_price_api/ the API: config.py (BUNDLES), estimator.py (the five gates), routes.py (the shadow)
training/ train.py (with the code check) and simulate_export.py
replay/, scripts/ make_replay.py, replay.py, compare.py, check_release.py
tests/ 54 tests
  1. Assurez-vous que .gitignore contient .venv/, .env, data/, candidates/ et replay/out/. C’est le cas du .gitignore du projet terminé.
  2. Créez le dépôt : git init, git add ., git commit -m "House price API with versioned model releases".
  3. Avec git ls-files bundles, vérifiez qu’il contient les deux paquets et leurs fichiers model.joblib, mais aucun fichier .env.
  4. Ajoutez les deux graphiques de cette page comme images, ou la sortie de compare.py, au README. Décrivez la décision de mise en production dans vos propres mots.

Comment le décrire en entrevue, en deux phrases : « J’ai mis en production un modèle de prix des maisons réentraîné derrière une API active, avec des paquets à versions fixes et une exécution en parallèle sur 2 800 requêtes rejouées. Mes vérifications ont rejeté un candidat avec un bon résultat hors ligne dont les données codaient une colonne différemment, et j’ai mesuré le retour en arrière à six secondes. »

Pour aller plus loin :

  1. Gardez la version précédente en parallèle pendant une semaine après une promotion. Si la nouvelle vous surprend, le journal contient déjà les anciennes estimations pour les comparer.
  2. Un déploiement canari. Envoyez 10 % des requêtes réelles à la nouvelle version avant de lui envoyer toutes les requêtes. Voir Mise en production progressive dans CI/CD, tests et mises en production sûres. Les réponses du canari sont réelles : ses vérifications doivent donc réussir d’abord en parallèle.
  3. Comparez dans le pipeline. Exécutez compare.py en intégration continue pour chaque nouveau paquet. Utilisez son code de sortie pour bloquer la fusion d’une nouvelle ligne dans BUNDLES.
  4. Surveillez après la mise en production. Quand les prix de vente du mois suivant arrivent, calculez la proportion à moins de 10 % pour la version en production. La fiche du modèle du cours sur les API indique d’arrêter sous 45 %.

Vérifiez votre compréhension

La version 2 fait passer l'estimation de la maison typique du test de référence de 422 000 à 379 000. Elle s'éloigne donc de son prix de vente de 425 000, et le test de référence du cours sur les API échouerait. Pourquoi cela ne justifie-t-il pas de rejeter la version 2?

Afficher la réponse

Une seule maison ne peut pas mesurer un modèle. Sur les 2 817 maisons rejouées, l’erreur de la version 2 est inférieure de 0,4 %. Seulement 2,9 % de ses estimations changent de plus de 10 %. La maison typique fait partie de ces 82 maisons. Les tests de référence rendent chaque changement visible; ils ne décident pas. Un réentraînement les fera échouer. Une personne met ensuite à jour les réponses attendues dans un changement révisé. La décision vient des vérifications convenues sur de nombreuses requêtes réelles et de l’examen des plus grands changements.

Vérifiez votre compréhension

rc1 avait la même erreur hors ligne que la version 1, un paquet valide et 54 tests réussis. Pourquoi seule l'exécution en parallèle a-t-elle détecté le problème?

Afficher la réponse

Chaque vérification précédente comparait rc1 à ses propres données. Le test hors ligne utilisait la même exportation, avec l’année de construction dans yr_renovated. rc1 avait lui-même calculé les cas de parité, donc ils concordaient avec ses résultats. L’exécution en parallèle était la première vérification à faire passer des requêtes réelles par la traduction de l’API. Celle-ci envoie 0 pour « jamais rénovée ». Cette vérification comparait aussi le résultat au modèle en production et aux prix de vente. Elle a révélé le décalage : 23,7 % des estimations changeaient de plus de 10 %, et l’erreur augmentait de 21 %.

Vérifiez votre compréhension

Pourquoi le code contient-il les empreintes dans BUNDLES, alors que l'environnement contient seulement MODEL_VERSION? Que pourrait-il arriver si .env contenait l'empreinte, comme dans le service d'escalade?

Afficher la réponse

Le code change seulement par un changement révisé. Chaque modèle pouvant être mis en production a donc été révisé par une personne. L’environnement peut ensuite choisir rapidement, pour une promotion ou un retour en arrière, sans nouvelle image. Mais il peut choisir seulement parmi les paquets révisés. Si .env contenait l’empreinte, toute personne pouvant modifier les paramètres du serveur pourrait y mettre l’empreinte de son propre paquet. Le service le chargerait alors. Une faute de frappe dans MODEL_VERSION fait refuser au service de démarrer le modèle. C’est sécuritaire, mais cela arrête les estimations (le deuxième cas de panne).

Récapitulatif

  • Un nouveau modèle est une mise en production : un paquet qui ne change jamais, avec une seule empreinte. Le code liste les empreintes révisées; l’environnement choisit seulement parmi elles.
  • L’exécution en parallèle estime chaque requête avec le candidat, répond seulement avec le modèle en production et journalise les deux. Elle ne doit jamais empêcher une réponse en production. Elle a doublé le travail du modèle (de 1,0 à 2,1 ms).
  • Un modèle de régression n’a pas de seuil. Ses vérifications de mise en production sont l’erreur sur les ventes plus récentes (MAE), la proportion d’estimations changeant de plus de 10 % et le plus grand changement d’un secteur. Convenez-en avant l’exécution en parallèle, puis faites examiner les plus grands changements par une personne.
  • Un candidat peut réussir tous les tests hors ligne et être quand même incorrect pour les requêtes auxquelles il répondra. Les données de rc1 codaient yr_renovated différemment.
  • Promouvez le modèle et revenez en arrière avec un seul paramètre, puis vérifiez le service actif. Notre retour en arrière a pris 6,4 s, avec environ 1,6 s sans réponse dans un conteneur unique.
  • Consignez chaque candidat, décision et exercice dans RELEASES.md, y compris le candidat rejeté.

Ensuite, la troisième étude de cas : un grand fichier de sorties d’hôpital, traité comme une tâche en file d’attente.

Leçon suivante : Prédire les réadmissions à l'hôpital de façon asynchrone