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
Étude de cas 2 sur 3 · Déployer des modèles d'IA pour de vrais utilisateursMettre en production un nouveau modèle de prix des maisons
1. Définir le contrat d'inférence
2. Choisir un mode de service
3. Créer le service de prédiction
4. Choisir l'infrastructure et mesurer
5. Mettre en production, surveiller et rétablir
Ce que vous saurez faire
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.
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 main
Ce que vous devez savoir
Les leçons les plus utilisées dans cette étude de cas :
- Le paquet et son empreinte, dans Empaqueter les dépendances du modèle et Confiance et intégrité des artefacts.
- Les cas de parité et leur tolérance, dans Vérifier la parité.
- Le conteneur avec des limites, dans Paramètres de ressources du conteneur.
- Les prédictions en parallèle, les registres de mise en production et le retour en arrière, dans Versions et déploiement progressif et Retour en arrière et retrait.
- L’API de départ : Servir un modèle de prix des maisons, l’étude de cas du cours sur les API. Vous n’avez pas besoin de l’avoir faite. Le projet de départ est son projet terminé, et cette page explique les éléments nécessaires.
Ce qu’il vous faut sur votre ordinateur
- Python 3.12 ou une version plus récente, VS Code avec l’extension Python, et curl, comme dans Environnement et premier point de terminaison.
- Docker Desktop, comme dans Paramètres de ressources du conteneur.
- Environ 1,2 Go d’espace disque libre et une connexion Internet. L’environnement virtuel occupe environ 360 Mo, et l’image, environ 690 Mo.
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.
La situation
Section intitulée « La situation »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.
Ce que vous allez créer
Section intitulée « Ce que vous allez créer »| 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 :
- La nouvelle erreur est-elle plus faible? La MAE sur les ventes plus récentes.
- 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.
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 et la division dans le temps
Section intitulée « Les données et la division dans le temps »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.
Préparer le projet de départ
Section intitulée « Préparer le projet de départ »-
Téléchargez le projet de départ avec le bouton dans l’encadré en haut de cette page. Décompressez-le dans votre dossier
projects. Vous obtenez un dossierhouse-price-api. Si vous avez encore le projet du cours sur les API, gardez-le ailleurs : c’est une nouvelle copie. -
Dans VS Code, ouvrez le dossier : File › Open Folder… (Fichier › Ouvrir un dossier…), puis choisissez
house-price-api. -
Ouvrez un terminal dans VS Code : Terminal › New Terminal (Terminal › Nouveau terminal).
-
Créez et activez un environnement virtuel :
Fenêtre de terminal python3 -m venv .venvsource .venv/bin/activateFenêtre de terminal python -m venv .venv.venv\Scripts\Activate.ps1Fenêtre de terminal python3 -m venv .venvsource .venv/bin/activate -
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 -
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. -
Exécutez les tests :
Fenêtre de terminal python -m pytest -
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 :
...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.gzwrote replay/requests-2015-04-05.jsonl and replay/sales-2015-04-05.csv: 2819 sales from 2015-04-01 to 2015-05-27Ouvrez 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.
Étape 1 : créer le paquet
Section intitulée « Étape 1 : créer le paquet »Créez make_bundle.py dans le dossier du projet :
"""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 writesbundles/<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 inhouse_price_api/config.py, in a reviewed change, before the service may loadthe 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 runcode: run it only on a model that you trained, or that comes from a sourcethat you trust."""
import argparseimport hashlibimport jsonimport shutilimport sysfrom pathlib import Path
import joblib
from house_price_api.estimator import FEATURESfrom 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 :
python make_bundle.py modelMade bundles/kc-hgb-2015-02: 9 parity cases.Bundle digest: 29752760649bf79193867d4a5887ee47c361aa913315d831b0e9fafe6f50d1cfOuvrez bundles/kc-hgb-2015-02/SHA256SUMS :
8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdec model.joblib3389bc75812971ba87b3908026409d0eb78eca0eea40affdc3771fec31d35e1f model_card.json96bb75911713fdfe7aa1d2c12524216e292cfc6a16e93c2a06de2960cd1787d4 parity_cases.jsonLe 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.
Étape 2 : les versions révisées
Section intitulée « Étape 2 : les versions révisées »Remplacez house_price_api/config.py :
import osfrom dataclasses import dataclass, fieldfrom 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 versionaux 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 :
# 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 areRemplacez ensuite toute la fonction load_estimator par ces quatre fonctions :
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 estimatorLes 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).
Étape 4 : choisir une version au démarrage
Section intitulée « Étape 4 : choisir une version au démarrage »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 :
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.
Étape 5 : tester les barrières
Section intitulée « Étape 5 : tester les barrières »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 :
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 :
import jsonimport loggingimport shutilfrom pathlib import Path
import joblibimport pytest
from house_price_api.config import BUNDLES, PROJECTfrom 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.fixturedef 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 :
python -m pytesttests/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.
Étape 6 : observer le résultat sur le serveur
Section intitulée « Étape 6 : observer le résultat sur le serveur »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 :
MODEL_VERSION=kc-hgb-2015-03 fastapi run$env:MODEL_VERSION = "kc-hgb-2015-03"fastapi runMODEL_VERSION=kc-hgb-2015-03 fastapi runINFO 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=e234cb73ae13INFO house_price_api: GET /ready 503 5ms id=e234cb73ae13Un 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.
Partie 2 : un modèle fantôme qui ne répond jamais
Section intitulée « Partie 2 : un modèle fantôme qui ne répond jamais »Le problème : deux réponses, un seul client
Section intitulée « Le problème : deux réponses, un seul client »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.
Étape 1 : charger le modèle fantôme
Section intitulée « Étape 1 : charger le modèle fantôme »Dans main.py, ajoutez ce bloc dans create_app, après le bloc de démarrage de la partie 1 :
# 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 :
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 NoneEnsuite, dans estimate_houses, ajoutez ces lignes après le bloc except TimeoutError et avant return [ :
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 :
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"] == LIVEExé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 :
SHADOW_VERSION=kc-hgb-2015-02 fastapi run$env:SHADOW_VERSION = "kc-hgb-2015-02"fastapi runSHADOW_VERSION=kc-hgb-2015-02 fastapi runcurl -s -X POST http://127.0.0.1:8000/v1/estimates -H "Content-Type: application/json" -d @requests/typical-house.json{"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 MBINFO house_price_api: shadow kc-hgb-2015-02 loaded (bundle 29752760649b...), memory 240 MBINFO 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.
Partie 3 : une image, deux modèles
Section intitulée « Partie 3 : une image, deux modèles »Le service fonctionne maintenant dans un conteneur avec les limites de Paramètres de ressources du conteneur.
Étape 1 : les fichiers
Section intitulée « Étape 1 : les fichiers »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.
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.txtDans 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 :
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 ./bundlesCOPY pyproject.toml .COPY house_price_api ./house_price_api
USER app
EXPOSE 8000CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"]# Send only what the image needs. Data, candidates, tests, .env and .venv stay out.*!requirements-run.txt!pyproject.toml!house_price_api/!bundles/**/__pycache__/# 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# 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.0MODEL_VERSION=kc-hgb-2015-02SHADOW_VERSION=API_KEY=local-dev-keyL’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 :
docker compose up -d --build --waitdocker compose logs apiINFO house_price_api: model kc-hgb-2015-02 loaded (bundle 29752760649b...) in 120 ms, memory 167 MBINFO house_price_api: GET /ready 200 18ms id=26c3b47d63bdLa 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 :
"""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 ifthe 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 jsonimport osimport sysfrom pathlib import Path
import httpx
PROJECT = Path(__file__).resolve().parent.parentfailures = []
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" :
export HOUSE_API_KEY=local-dev-keypython scripts/check_release.py http://127.0.0.1:8000 kc-hgb-2015-02Release 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.jsonAll 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 :
python scripts/replay.py http://127.0.0.1:8000 replay/requests-2015-04-05.jsonl replay/out/answers.csvSent 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.csvDeux 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.
Partie 4 : le premier candidat
Section intitulée « Partie 4 : le premier candidat »Le candidat de l’analyste
Section intitulée « Le candidat de l’analyste »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.
python training/simulate_export.pypython training/train.py --data data/county-export-2015-04-01.csv.gz --until 2015-04-01 --version kc-hgb-2015-03-rc1wrote data/county-export-2015-04-01.csv.gz: 21613 salesdata: county-export-2015-04-01.csv.gz, SHA-256 50d99029d343... (not the reviewed course file)train 18794 sales | test 2819 salestrained in 2.7 stest: {'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 :
python make_bundle.py candidates/kc-hgb-2015-03-rc1Made bundles/kc-hgb-2015-03-rc1: 9 parity cases.Bundle digest: 10652d2d82d7e619c49756ebb9c5c8f9ec261c73c7df645136c2b4f89f001275BUNDLES = { "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?
Exécuter le candidat comme modèle fantôme
Section intitulée « Exécuter le candidat comme modèle fantôme »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 :
docker compose up -d --build --waitpython scripts/replay.py http://127.0.0.1:8000 replay/requests-2015-04-05.jsonl replay/out/answers.csvLe journal contient les deux lignes de démarrage et une ligne de prédiction pour chaque requête ayant reçu une réponse :
INFO house_price_api: model kc-hgb-2015-02 loaded (bundle 29752760649b...) in 199 ms, memory 168 MBINFO 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}Comparer : les vérifications de mise en production
Section intitulée « Comparer : les vérifications de mise en production »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 :
"""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 thereplay, and the sales file has the prices that arrived later. The scriptjoins the three, prints the error of each model on the sale prices and howmuch the estimates move, runs the release checks, and lists the largestmoves. It writes the joined rows to compare-<shadow version>.csv next toanswers.csv.
A regression model has no threshold to check. So the checks look at twothings: is the new model's error lower, and how much do the answers thatclients see change? A person reads the largest moves before deciding."""
import jsonimport sysfrom pathlib import Path
import numpy as npimport 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 MAEBIG_MOVE = 0.10 # an estimate "moves" when it changes by more than 10%MAX_MOVED_SHARE = 0.05 # at most 5% of estimates may moveMAX_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 judgeMARKER = "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 :
docker compose logs --no-log-prefix api > replay/out/service.logpython scripts/compare.py replay/out/answers.csv replay/out/service.log replay/sales-2015-04-05.csvdocker compose logs --no-log-prefix api | Out-File -Encoding utf8 replay/out/service.logpython scripts/compare.py replay/out/answers.csv replay/out/service.log replay/sales-2015-04-05.csvdocker compose logs --no-log-prefix api > replay/out/service.logpython scripts/compare.py replay/out/answers.csv replay/out/service.log replay/sales-2015-04-05.csvJoined 2817 answers with the log and the sale prices.Live kc-hgb-2015-02, shadow kc-hgb-2015-03-rc1.
live shadowMean absolute error (USD) 77,300 93,864Within 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.csvLes 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.
Trouver la cause
Section intitulée « Trouver la cause »Lisez les trois plus grands écarts dans le fichier de réexécution :
grep -E "5428000070-20150511|2822059181-20150413|1921069101-20150508" replay/requests-2015-04-05.jsonlSous Windows, utilisez Select-String -Pattern "5428000070-20150511|2822059181-20150413|1921069101-20150508" replay/requests-2015-04-05.jsonl.
{"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 » :
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']]"king_county_house_sales.csv.gz 20699county-export-2015-04-01.csv.gz 0Pour 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 :
-
Rejetez le candidat. Retirez sa ligne de
BUNDLES, supprimezbundles/kc-hgb-2015-03-rc1/, puis consignez la décision dansRELEASES.md(partie 5). Il est alors retiré : aucune image future ne le contient, et aucun paramètre ne peut le mettre en production. -
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 dansload_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.) -
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
nulldans 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 »Entraîner, empaqueter et tester
Section intitulée « Entraîner, empaqueter et tester »L’analyste entraîne de nouveau le modèle, avec les données du cours qui ont été révisées :
python training/train.py --until 2015-04-01 --version kc-hgb-2015-03python make_bundle.py candidates/kc-hgb-2015-03train 18794 sales | test 2819 salestrained in 3.1 stest: {'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 054ed960e883b16905447d38db386da87ffbf9240f7c7fc574a9f44861aa4ccbMade bundles/kc-hgb-2015-03: 9 parity cases.Bundle digest: 1ed11041ebcd4ccb751b599269c119d4d1d1978b620ac87e04ccf17c83dca30fHors 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 :
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 %?
Exécuter en parallèle et comparer
Section intitulée « Exécuter en parallèle et comparer »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 :
docker compose up -d --build --waitpython scripts/replay.py http://127.0.0.1:8000 replay/requests-2015-04-05.jsonl replay/out/answers.csvdocker compose logs --no-log-prefix api > replay/out/service.logpython scripts/compare.py replay/out/answers.csv replay/out/service.log replay/sales-2015-04-05.csvSous Windows, enregistrez le journal avec Out-File -Encoding utf8, comme à la partie 4.
Joined 2817 answers with the log and the sale prices.Live kc-hgb-2015-02, shadow kc-hgb-2015-03.
live shadowMean absolute error (USD) 77,300 76,960Within 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.csvLes 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)
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 %.
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.
Examiner les plus grands changements
Section intitulée « Examiner les plus grands changements »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.
Décider et promouvoir
Section intitulée « Décider et promouvoir »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 :
docker compose up -d --waitpython scripts/check_release.py http://127.0.0.1:8000 kc-hgb-2015-03curl -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.jsonRelease 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.jsonAll 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.
Tenir le registre
Section intitulée « Tenir le registre »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 :
| 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.
Partie 6 : l’exercice de retour en arrière
Section intitulée « Partie 6 : l’exercice de retour en arrière »Le problème : un retour que personne n’a essayé
Section intitulée « Le problème : un retour que personne n’a essayé »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.
Étape 1 : revenir à la version 1
Section intitulée « Étape 1 : revenir à la version 1 »Dans .env, définissez MODEL_VERSION=kc-hgb-2015-02. Ensuite :
docker compose up -d --waitpython scripts/check_release.py http://127.0.0.1:8000 kc-hgb-2015-02Release 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.jsonAll 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é.
Étape 2 : retourner à la version 2
Section intitulée « Étape 2 : retourner à la version 2 »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 :
- Corrigez la ligne dans
.envet exécutez de nouveaudocker compose up -d --wait. Dans notre essai, le service était en bon état 6,3 secondes plus tard. - Copiez la version depuis
RELEASES.mdau lieu de la taper. - 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.
Nettoyer
Section intitulée « Nettoyer »Arrêtez le conteneur et supprimez les trois images. Les paquets, le code et RELEASES.md restent dans votre projet.
docker compose downdocker image rm house-price-api:1.1.0 house-price-api:1.1.1 house-price-api:1.1.2Dans 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é.
Notre essai complet
Section intitulée « Notre essai complet »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 |
Utilisation responsable
Section intitulée « Utilisation responsable »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.
En faire un projet pour votre portfolio
Section intitulée « En faire un projet pour votre portfolio »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- Assurez-vous que
.gitignorecontient.venv/,.env,data/,candidates/etreplay/out/. C’est le cas du.gitignoredu projet terminé. - Créez le dépôt :
git init,git add .,git commit -m "House price API with versioned model releases". - Avec
git ls-files bundles, vérifiez qu’il contient les deux paquets et leurs fichiersmodel.joblib, mais aucun fichier.env. - 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 :
- 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.
- 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.
- Comparez dans le pipeline. Exécutez
compare.pyen intégration continue pour chaque nouveau paquet. Utilisez son code de sortie pour bloquer la fusion d’une nouvelle ligne dansBUNDLES. - 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érification des connaissances
Section intitulée « Vérification des connaissances »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.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
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 %.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
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).
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Récapitulatif
- Un 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_renovateddiffé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