Servir un modèle de prix des maisons
- Étude de cas 1
- 240 min
- Facultative · 1 étude de cas requise pour le certificat
Étude de cas 1 sur 2 · Créer et déployer votre première APIServir un modèle de prix des maisons
1. Comprendre la requête Web
2. Créer un petit service FastAPI
3. Concevoir un contrat utilisable
4. Documenter l'API avec OpenAPI et Swagger UI
5. Explorer et tester avec Postman
6. Tester et renforcer le service
7. Déployer et vérifier
Ce que vous saurez faire
Transformez un modèle entraîné en service que d’autres programmes peuvent appeler en toute sécurité. Vous placez un véritable modèle de prix des maisons derrière une API qui vérifie chaque entrée. Elle refuse tout fichier de modèle auquel elle ne fait pas confiance. L’API donne une fourchette plutôt qu’un nombre qui semble exact, et indique quel modèle a répondu. Ensuite, vous la testez, la documentez et préparez son déploiement.
Le projet de départ est un petit projet FastAPI avec les éléments que vous connaissez dans ce cours et un modèle prêt à l’emploi. Vous ajoutez le chargement du modèle, les points de terminaison, les tests, le contrat et le déploiement, étape par étape. Le projet terminé présente le résultat, pour comparaison. Toutes les sorties de cette page viennent du code terminé.
Avant de commencerCe qu’il faut savoir et avoir sous la main
Ce que vous devez savoir
Cette étude de cas utilise tout le cours. Voici les leçons sur lesquelles elle s’appuie le plus :
- Le format des erreurs et les gestionnaires d’exceptions, dans Validation et réponses d’erreur.
- La clé d’API, la limite de taille du corps et la limite de temps, dans Protection de base.
- Le contrat révisé et son test, dans Garder la documentation exacte.
- La vitalité, l’état de préparation et un déploiement avec un secret, dans Publier en toute sécurité et Vérifier et nettoyer.
Vous n’avez pas besoin du cours d’apprentissage automatique. Le modèle est prêt à l’emploi, et cette page explique ce que vous devez savoir à son sujet. Si vous avez fait l’étude de cas Estimer le prix des maisons dans le cours d’apprentissage automatique, c’est le même modèle : vous le servez maintenant.
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.
- La CLI de Postman, présentée dans Tests et exécutions reproductibles : exécuter depuis le terminal.
- Environ 400 Mo d’espace disque libre et une connexion Internet : scikit-learn, SciPy, NumPy et pandas sont des paquets volumineux.
- Pour le déploiement seulement : un compte FastAPI Cloud gratuit, comme dans Publier en toute sécurité : étape 1. Le déploiement est facultatif. Sans compte, utilisez le mode production sur votre ordinateur comme preuve.
Testé sur macOS avec Python 3.14.6 et 3.12.13 (les tests et le réentraînement de la partie 7, sur les deux), 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, pytest 9.1.1, Postman CLI 1.69.0 et fastapi-cloud-cli 0.26.0. Les commandes Windows n’ont pas été testées.
La situation
Section intitulée « La situation »Hannah Osei dirige l’équipe d’évaluation d’un prêteur hypothécaire régional près de Seattle. Avant que le prêteur accorde un prêt pour une maison, un évaluateur agréé visite la maison et consigne sa valeur. Cela prend plusieurs jours. L’équipe de Hannah veut une première estimation dès l’arrivée d’une demande, dans l’outil de réception qu’elle utilise déjà. L’estimation ne remplace pas l’évaluateur. Elle signale tôt à l’équipe quand le prix convenu semble loin de la valeur, pour traiter ces évaluations en priorité.
L’analyste de l’équipe a déjà un modèle. Il a appris à partir de ventes publiques de maisons dans King County, le comté de Seattle. Il est enregistré dans un seul fichier. Mais l’outil de réception ne peut pas utiliser un fichier sur le portable d’un analyste. Elif Demir, qui assure la maintenance de cet outil, demande une API : envoyer la description d’une maison et recevoir une estimation. Kenji Mori, un développeur de l’équipe, la construit. Dans cette étude de cas, vous êtes Kenji.
Daniel Ruiz, de l’équipe de conformité, ajoute deux conditions avant de donner son accord. L’estimation ne doit jamais servir à prendre une décision sur le prêt d’une personne. Et chaque réponse doit indiquer quel modèle l’a produite, pour pouvoir retracer toute estimation plus tard.
Ce que vous allez construire
Section intitulée « Ce que vous allez construire »Une API avec cinq points de terminaison, fondée sur les conventions du cours : le format des erreurs, l’en-tête X-API-Key (clé d’API), une limite de taille du corps, des identifiants de requête, /health et /ready.
| Point de terminaison | Clé | Ce qu’il fait |
|---|---|---|
POST /v1/estimates |
oui | Estimer une maison : un prix, une fourchette, la version du modèle et des avertissements |
POST /v1/estimates/batch |
oui | Estimer de 1 à 100 maisons dans une seule requête, dans l’ordre |
GET /v1/model |
non | La fiche du modèle : son usage et ses résultats |
GET /health |
non | Vitalité : le processus fonctionne |
GET /ready |
non | État de préparation : le modèle est chargé et répond |
Chaque partie de la construction commence par un problème réel d’une API de modèle. Le fichier du modèle peut être modifié ou incorrect. Des entrées absurdes obtiennent des réponses confiantes. Un seul nombre semble plus exact qu’il ne l’est. Le réentraînement peut changer les réponses sans avertissement. Chaque partie résout un de ces problèmes. À la fin, vous avez un projet testé et documenté que vous pouvez déployer et montrer à un employeur.
Le modèle et ses données
Section intitulée « Le modèle et ses données »Vous n’entraînez pas le modèle, mais vous devez savoir ce qu’il a appris : les règles de l’API en découlent. La fiche du modèle dans le projet de départ, MODEL_CARD.md, contient les détails. Voici ce qui compte pour l’API.
Les données sont un registre public de 21 613 ventes de maisons dans King County, dans l’État de Washington, de mai 2014 à mai 2015. Un utilisateur nommé harlfoxem les a publiées sur Kaggle. Il s’agit du jeu de données 42092 sur OpenML. Sa licence est CC0 1.0 : les données sont dans le domaine public. Nous indiquons tout de même la source : House Sales in King County, USA (harlfoxem, Kaggle 2016; OpenML 42092). Hannah, Elif, Kenji, Daniel et le prêteur sont fictifs. Les ventes sont réelles.
À quoi ressemble une maison dans les données. Le modèle décrit chaque maison avec 17 nombres. Voici ceux que l’API demande :
| Champ | Signification |
|---|---|
bedrooms, bathrooms, floors |
Nombres de chambres, de salles de bain et d’étages. 0,5 salle de bain désigne une toilette sans douche; 1,5 étage désigne un étage supérieur partiel. |
sqft_living, sqft_basement, sqft_lot |
Superficie habitable (sous-sol compris), superficie du sous-sol et superficie du terrain, en pieds carrés. 1 pi² vaut environ 0,093 m². |
waterfront, view |
Au bord de l’eau ou non; qualité de la vue, de 0 (aucune) à 4. |
condition, grade |
État de la maison, de 1 à 5; cote de construction du comté, de 1 à 13 (7 est la moyenne). |
yr_built, yr_renovated |
Année de construction; année de la dernière rénovation. |
lat, long |
Latitude et longitude. |
sqft_living15, sqft_lot15 |
Superficies moyennes habitables et des terrains des 15 maisons les plus proches. |
Le modèle utilise le gradient boosting (une méthode de renforcement par gradient). Cette méthode ajoute de nombreux petits arbres de décision, chacun corrigeant les erreurs des précédents. Il a appris le logarithme du prix, car les prix des maisons sont asymétriques et les erreurs se mesurent mieux en pourcentage. Il a appris à partir des 16 959 ventes de maisons vendues pour la première fois avant mars 2015. Il a ensuite été testé une seule fois, sur les 4 654 ventes de mars à mai 2015, jamais vues auparavant.
La qualité de ses résultats. Sur ces ventes de test, l’erreur typique était de 12,8 % du prix (l’erreur absolue moyenne en pourcentage, MAPE). La moitié des estimations (50,2 %) s’écartaient d’au plus 10 % du prix de vente. Et 12,6 % étaient trop élevées d’au moins 10 %. Une méthode rapide d’évaluation, le prix médian par pied carré dans le code postal, avait une MAPE de 18,5 %. Le modèle est donc utile, mais loin d’être exact. Pour une maison typique de 465 000 dollars, une erreur typique est d’environ 60 000 dollars.
Deux faits tirés du test guideront l’API. D’abord, les estimations étaient environ 6 % trop basses pour la maison typique au printemps 2015. Les prix avaient augmenté après la dernière vente vue par le modèle. Ensuite, les estimations d’un million de dollars ou plus étaient beaucoup moins fiables : environ 4 sur 10 seulement s’écartaient d’au plus 10 %.
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. -
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). Il démarre dans le dossier du projet.
-
Créez et activez un environnement virtuel :
Fenêtre de terminal python3 -m venv .venvsource .venv/bin/activateFenêtre de terminal python -m venv .venv.venv\Scripts\Activate.ps1Fenêtre de terminal python3 -m venv .venvsource .venv/bin/activateL’invite commence par
(.venv). -
Installez les versions exactes des paquets :
Fenêtre de terminal python -m pip install -r requirements-lock.txtCela prend une minute ou deux. L’environnement utilise environ 360 Mo, surtout pour SciPy, pandas, scikit-learn et NumPy.
-
Indiquez à VS Code d’utiliser cet environnement. Ouvrez la palette de commandes (Ctrl+Shift+P, ou Cmd+Shift+P sur macOS), choisissez Python : Select Interpreter (Python : sélectionner l’interpréteur), puis celui dans
.venv. -
Exécutez les tests :
Fenêtre de terminal python -m pytest
collected 2 items
tests/test_health.py .. [100%]
============================== 2 passed in 0.01s ===============================Ce que cela nous indique. L’environnement fonctionne, et les éléments déjà présents dans le projet de départ sont corrects. /health répond, et chaque réponse contient un identifiant de requête. Ces deux tests continuent de réussir jusqu’à la fin.
Démarrez maintenant l’API dans le premier terminal. Ouvrez ensuite un deuxième terminal (Terminal › New Terminal encore une fois) et appelez l’API. Sur Windows, tapez curl.exe plutôt que curl.
fastapi devcurl -i http://127.0.0.1:8000/healthcurl -s -X POST http://127.0.0.1:8000/v1/estimatesHTTP/1.1 200 OKdate: Thu, 08 Oct 2026 00:29:43 GMTserver: uvicorncontent-length: 15content-type: application/jsonx-request-id: c95bd80c5d7e
{"status":"ok"}{"error":{"code":"not_found","message":"Not Found","request_id":"ff10bbb726d8","fields":[]}}Ce que cela nous indique. Le service fonctionne et utilise déjà le format d’erreur du cours. Un chemin inconnu reçoit not_found (introuvable) avec un identifiant de requête. Il n’y a pas encore de point de terminaison d’estimation. Dans le premier terminal, le journal indique aussi API_KEY is not set (la clé d’API n’est pas définie). C’est normal sur votre ordinateur; le déploiement définit une clé.
Le contenu du projet de départ
Section intitulée « Le contenu du projet de départ »| Chemin | Contenu |
|---|---|
model/house_price.joblib, model/model_card.json |
Le modèle entraîné (0,51 Mo) et sa fiche sous forme de données |
MODEL_CARD.md, LICENSE |
La fiche du modèle destinée aux personnes; la licence MIT du code |
house_price_api/ |
main.py, config.py, errors.py, models.py et security.py, comme à la fin du cours, avec /health seulement |
tests/ |
conftest.py, test_health.py et golden_houses.json : trois maisons réelles et les réponses que l’API terminée doit donner |
training/train.py |
Le script qui a créé le modèle. Vous l’exécutez dans la partie 7. |
requests/, postman/, scripts/ |
Des corps de requête pour curl, une collection Postman à compléter et une vérification de l’API déployée |
Partie 1 : examiner le modèle avant de le servir
Section intitulée « Partie 1 : examiner le modèle avant de le servir »Un fichier de modèle est une boîte noire tant que vous ne l’avez pas vérifié. Avant d’écrire du code, vérifiez deux choses : est-ce bien le fichier annoncé par l’analyste, et que promet-il?
La fiche de l’analyste indique un hachage SHA-256 du fichier. Un hachage est une empreinte : une fonction lit chaque octet du fichier et renvoie 64 caractères. Le même fichier donne toujours le même hachage. Un fichier qui diffère d’un seul octet en donne un complètement différent. Deux personnes peuvent donc comparer un fichier sans l’envoyer, simplement en comparant 64 caractères.
Calculez le hachage de votre fichier et lisez trois faits dans la fiche :
python -c "import hashlib; print(hashlib.sha256(open('model/house_price.joblib', 'rb').read()).hexdigest())"python -c "import json; card = json.load(open('model/model_card.json')); print(card['model_version'], card['sha256'][:12], card['prices_as_of']); print(card['test']); print(card['range'])"8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdeckc-hgb-2015-02 8d0483d05cfc 2015-02-28{'sales': 4654, 'first_sales': '2015-03-01 to 2015-05-27', 'median_error_pct': 6.4, 'mape_pct': 12.8, 'within_10pct': 0.502, 'over_by_10pct_or_more': 0.126}{'coverage': 0.8, 'low_factor': 0.884, 'high_factor': 1.279}Ce que cela nous indique. Le hachage de votre fichier commence par 8d0483d05cfc, comme dans la fiche : vous avez le fichier de l’analyste. Le nom du modèle est kc-hgb-2015-02 : King County, renforcement par gradient, prix jusqu’en février 2015. Les résultats du test sont ceux de la section précédente. La dernière ligne est nouvelle : une fourchette. Vous l’utilisez dans la partie 4.
Partie 2 : charger le modèle en toute sécurité
Section intitulée « Partie 2 : charger le modèle en toute sécurité »Le problème : un fichier de modèle peut exécuter du code
Section intitulée « Le problème : un fichier de modèle peut exécuter du code »Python enregistre un modèle scikit-learn avec joblib, qui utilise le format pickle de Python. Pickle ne stocke pas seulement des nombres. Il stocke des instructions pour reconstruire des objets Python, et ces instructions peuvent appeler n’importe quelle fonction. Charger un fichier pickle revient donc à exécuter son contenu.
Vous pouvez le voir avec un exemple sans danger. Lancez Python avec python dans le terminal, collez ceci, puis quittez avec exit() :
import pickle
class Surprise: def __reduce__(self): return (print, ("This line ran while the file was loading.",))
data = pickle.dumps(Surprise())print(len(data), "bytes")pickle.loads(data)81 bytesThis line ran while the file was loading.Ce que cela nous apprend. pickle.loads a appelé print uniquement parce que les données lui ont demandé de le faire. Un fichier créé par une personne malveillante pourrait appeler quelque chose de bien plus dangereux que print, avec les droits de votre serveur. La règle est donc : chargez un pickle uniquement à partir d’un fichier auquel vous faites confiance. La vérification doit précéder le chargement, et non le suivre.
Trois autres problèmes peuvent survenir avec un fichier de modèle, sans qu’il s’agisse d’une attaque :
- Le fichier est absent, par exemple parce que le déploiement l’a omis.
- C’est un autre modèle : quelqu’un l’a réentraîné et a copié le nouveau fichier, mais personne ne l’a révisé.
- Il a été enregistré avec une autre version de scikit-learn. scikit-learn émet alors l’avertissement
InconsistentVersionWarning(versions incompatibles) : le modèle peut se charger, mais donner des réponses différentes ou incorrectes.
L’idée : comparer une empreinte, puis charger
Section intitulée « L’idée : comparer une empreinte, puis charger »La solution consiste à vérifier au démarrage, comme un caissier de banque qui compare une signature avant de payer. Le code contient l’empreinte de hachage du fichier révisé. Au démarrage, l’API calcule l’empreinte du fichier sur disque et compare les deux. Elle charge le fichier uniquement si elles sont identiques. Un fichier modifié, même d’un seul octet, n’est jamais chargé. Comme l’empreinte attendue est dans le code, changer le modèle exige une modification du code, visible lors de la révision.
Que se passe-t-il si une vérification échoue? L’API ne doit ni planter ni répondre avec un modèle défectueux. Elle continue de fonctionner, donc /health répond toujours 200, mais elle n’est pas prête : /ready et chaque estimation répondent 503 model_unavailable (modèle indisponible). La plateforme sait alors qu’un redémarrage ne résoudra rien, et une personne lit la raison dans le journal. C’est la distinction entre fonctionnement et disponibilité présentée dans Publier en toute sécurité.
Description textuelle du diagramme
Au démarrage, l’API vérifie le fichier du modèle en quatre étapes. Premièrement, le fichier doit exister. Deuxièmement, son SHA-256 doit correspondre à MODEL_SHA256 dans le code. Troisièmement, model_card.json doit indiquer le même SHA-256. joblib.load s’exécute seulement après ces trois vérifications. La quatrième a lieu pendant le chargement : le fichier doit provenir de la même version de scikit-learn. Si les quatre réussissent, le modèle est chargé, /ready renvoie 200 et les estimations fonctionnent. Si une vérification échoue, la raison est inscrite au journal. /ready et les points de terminaison d’estimation renvoient 503 model_unavailable, et /health renvoie toujours 200.
Étape 1 : les paramètres
Section intitulée « Étape 1 : les paramètres »L’empreinte révisée va dans config.py, à côté des paramètres. Ajoutez MODEL_SHA256 et trois paramètres :
PROJECT = Path(__file__).resolve().parent.parent
# The SHA-256 of the reviewed model file. A new model file needs a new value# here, and a new model_version, in one reviewed change.MODEL_SHA256 = "8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdec"
@dataclass(frozen=True)class Settings: api_key: str | None = field(repr=False) allowed_origins: list[str] model_path: Path model_sha256: 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()], model_path=Path( os.environ.get("MODEL_PATH", PROJECT / "model" / "house_price.joblib") ), model_sha256=MODEL_SHA256, 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", )| Paramètre | Pourquoi |
|---|---|
model_path |
L’emplacement du fichier. Le chemin par défaut est relatif au projet, pas au dossier du terminal. L’API trouve ainsi le fichier peu importe où elle démarre. MODEL_PATH permet de le changer pour tester un échec. |
model_sha256 |
L’empreinte révisée. Elle n’est pas lue dans une variable d’environnement : un paramètre modifiable par quiconque sur le serveur rendrait la vérification inutile. Seuls les tests fournissent une autre valeur. |
estimate_timeout |
Le délai maximal pour le modèle, comme classifier_timeout dans Protection de base. |
Étape 2 : le module d’estimation
Section intitulée « Étape 2 : le module d’estimation »Un nouveau module regroupe tout ce qui concerne le modèle : la vérification, le chargement et la prédiction. Créez house_price_api/estimator.py :
import hashlibimport jsonimport warningsfrom dataclasses import dataclassfrom pathlib import Path
import joblibimport numpy as npfrom sklearn.exceptions import InconsistentVersionWarning
# The model's inputs, in the order that it was trained with.FEATURES = [ "bedrooms", "bathrooms", "sqft_living", "sqft_lot", "floors", "waterfront", "view", "condition", "grade", "sqft_above", "sqft_basement", "yr_built", "yr_renovated", "lat", "long", "sqft_living15", "sqft_lot15",]
# The typical house of the documentation, in the model's own terms, for the# readiness check: sqft_above is a column, and 0 means "never renovated".READY_CHECK_HOUSE = { "bedrooms": 3, "bathrooms": 1.75, "sqft_living": 1560, "sqft_lot": 9452, "floors": 1, "waterfront": 0, "view": 0, "condition": 4, "grade": 8, "sqft_above": 1560, "sqft_basement": 0, "yr_built": 1974, "yr_renovated": 0, "lat": 47.5197, "long": -122.18, "sqft_living15": 2200, "sqft_lot15": 9985,}
class ModelUnavailable(Exception): """The model cannot answer now. The request may work later."""
@dataclass(frozen=True)class Estimate: value: float low: float high: float
def file_sha256(path: Path) -> str: """The SHA-256 of a file, read in blocks of 1 MB.""" digest = hashlib.sha256() with path.open("rb") as file: for block in iter(lambda: file.read(1 << 20), b""): digest.update(block) return digest.hexdigest()
class HouseEstimator: """The loaded model and its card."""
def __init__(self, model, card: dict): self.model = model self.card = card self.version: str = card["model_version"] self.prices_as_of: str = card["prices_as_of"] self.low_factor: float = card["range"]["low_factor"] self.high_factor: float = card["range"]["high_factor"] self.usual_values: dict[str, list[float]] = card["usual_values"]
def estimate(self, rows: list[dict[str, float]]) -> list[Estimate]: """Estimate the price of each house, with the range around it.""" x = np.array([[row[name] for name in FEATURES] for row in rows], dtype=float) return [ Estimate(value, value * self.low_factor, value * self.high_factor) for value in self.model.predict(x).tolist() ]
def rare_values(self, row: dict[str, float]) -> list[str]: """The inputs outside the values of 98% of the training houses.""" return [ name for name, (low, high) in self.usual_values.items() if not low <= row[name] <= high ]
def load_estimator(model_path: Path, expected_sha256: str) -> HouseEstimator: """Check the model file, then load it. Raise ModelUnavailable if anything is wrong.""" if not model_path.is_file(): raise ModelUnavailable(f"no model file at {model_path}") actual = file_sha256(model_path) if actual != expected_sha256: raise ModelUnavailable( f"{model_path.name} has SHA-256 {actual[:12]}..., " f"expected {expected_sha256[:12]}..." ) try: card = json.loads(model_path.with_name("model_card.json").read_text("utf-8")) except (OSError, ValueError) as error: raise ModelUnavailable(f"cannot read model_card.json: {error}") from None if card.get("sha256") != actual: raise ModelUnavailable("model_card.json describes a different model file") # The hash matches, so this is the file that we reviewed. Only now is it # safe to load it: joblib uses pickle, which can run code from the file. with warnings.catch_warnings(): warnings.simplefilter("error", InconsistentVersionWarning) try: model = joblib.load(model_path) 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 return HouseEstimator(model, card)Les parties importantes :
| Partie | Pourquoi |
|---|---|
FEATURES |
Le modèle reçoit 17 nombres sans nom. Leur ordre doit donc être celui de l’entraînement. Une seule liste, utilisée partout, garantit cet ordre. |
READY_CHECK_HOUSE |
Une vraie maison dont /ready estime le prix. Elle utilise les termes du modèle : sqft_above est une colonne, et yr_renovated vaut 0 pour « jamais ». Les termes propres à l’API arrivent dans la partie 3. |
file_sha256 |
Lit le fichier par blocs, pour éviter qu’un gros modèle doive tenir deux fois en mémoire. |
Les trois vérifications, puis joblib.load |
L’ordre assure la sécurité : le fichier est chargé uniquement si son empreinte correspond à celle du fichier révisé. |
simplefilter("error", InconsistentVersionWarning) |
Transforme l’avertissement de version en exception. Un modèle provenant d’une autre version de scikit-learn rend alors l’API non prête, plutôt que d’émettre un avertissement que personne ne lit. |
estimate() prend une liste |
Un seul appel à predict pour plusieurs maisons. La partie 5 utilise cela pour le lot. |
Étape 3 : le démarrage, l’erreur et /ready
Section intitulée « Étape 3 : le démarrage, l’erreur et /ready »Dans errors.py, un gestionnaire transforme ModelUnavailable en réponse standard 503, comme le faisait ClassifierUnavailable dans le cours :
from house_price_api.estimator import ModelUnavailable
# in add_error_handlers, before the handler for Exception: @app.exception_handler(ModelUnavailable) async def model_unavailable(request: Request, exc: ModelUnavailable): logger.warning("model unavailable: %s id=%s", exc, request.state.request_id) return error_response( request, 503, "model_unavailable", "The model is not available. Try again later.", )Dans main.py, create_app charge le modèle une seule fois, au démarrage, et conserve le résultat dans app.state. Le charger à chaque requête obligerait à lire et à vérifier 0,5 Mo chaque fois.
Apportez ces changements dans main.py :
- Ajoutez
import syset l’importation deestimatoren haut, avec les autres importations. - La nouvelle ligne
from house_price_api.models import ErrorResponse, Health, Readinessremplace la ligne existantefrom house_price_api.models import Health. - Placez la fonction
memory_mb()au niveau supérieur du fichier, aprèslogger = ...et le texteDESCRIPTION, avantdef create_app. - Placez les deux autres blocs dans
create_app, aux endroits indiqués par leurs commentaires.
import sys
from house_price_api.estimator import READY_CHECK_HOUSE, ModelUnavailable, load_estimatorfrom house_price_api.models import ErrorResponse, Health, Readiness
def memory_mb() -> str: """The most memory that this process has used so far, in MB.""" try: import resource # not on Windows except ImportError: return "unknown" peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss return f"{peak / (1e6 if sys.platform == 'darwin' else 1e3):.0f}"
# in create_app, after the API_KEY warning: started = time.perf_counter() try: app.state.estimator = load_estimator(settings.model_path, settings.model_sha256) app.state.model_problem = None logger.info( "model %s loaded in %.0f ms, memory %s MB", app.state.estimator.version, (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)
# in create_app, after /health: @app.get( "/ready", tags=["health"], summary="Check that the model can estimate prices", responses={503: {"model": ErrorResponse, "description": "Not ready."}}, ) def ready() -> Readiness: if app.state.estimator is None: raise ModelUnavailable(app.state.model_problem) app.state.estimator.estimate([READY_CHECK_HOUSE]) return Readiness(status="ready")La ligne du journal indique la durée du chargement et la mémoire utilisée par le processus. Vous avez besoin des deux valeurs pour le déploiement : une instance gratuite de FastAPI Cloud dispose de 500 Mio de mémoire. /ready ne se contente pas de consulter app.state. Il demande au modèle une vraie estimation, ce qui prouve que le modèle répond.
Étape 4 : tester les échecs
Section intitulée « Étape 4 : tester les échecs »Un test peut faire ce qui est difficile à faire à la main. Il peut fournir à l’API un fichier absent, une mauvaise empreinte ou un modèle provenant d’une autre version de scikit-learn. D’abord, les paramètres de test ont besoin des nouveaux champs. Remplacez tests/conftest.py :
import jsonfrom dataclasses import replacefrom pathlib import Path
import pytestfrom fastapi.testclient import TestClient
from house_price_api.config import MODEL_SHA256, PROJECT, Settingsfrom house_price_api.main import create_app
TEST_SETTINGS = Settings( api_key=None, allowed_origins=[], model_path=PROJECT / "model" / "house_price.joblib", model_sha256=MODEL_SHA256, estimate_timeout=2.0, log_level="WARNING", show_docs=True,)
GOLDEN_FILE = Path(__file__).parent / "golden_houses.json"
@pytest.fixturedef make_client(): """Return a function that makes a test client with changed settings."""
def make(**changes) -> TestClient: return TestClient(create_app(replace(TEST_SETTINGS, **changes)))
return make
@pytest.fixturedef client(make_client) -> TestClient: return make_client()
@pytest.fixturedef house() -> dict: """The typical golden house, a new copy for each test.""" return json.loads(GOLDEN_FILE.read_text("utf-8"))[0]["house"]Créez ensuite tests/test_model_file.py :
import jsonimport shutil
import joblibimport sklearn.basefrom sklearn.dummy import DummyRegressor
from house_price_api.config import MODEL_SHA256, PROJECTfrom house_price_api.estimator import file_sha256
MODEL = PROJECT / "model" / "house_price.joblib"
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" assert "Traceback" not in response.text
def test_ready(client): assert client.get("/ready").json() == {"status": "ready"}
def test_the_reviewed_hash_matches_the_file_and_the_card(): card = json.loads((PROJECT / "model" / "model_card.json").read_text("utf-8")) assert file_sha256(MODEL) == MODEL_SHA256 == card["sha256"]
def test_missing_model_file(make_client, tmp_path): assert_not_ready(make_client(model_path=tmp_path / "house_price.joblib"))
def test_wrong_hash(make_client): assert_not_ready(make_client(model_sha256="0" * 64))
def test_card_of_another_model(make_client, tmp_path): shutil.copy(MODEL, tmp_path / "house_price.joblib") card = json.loads((PROJECT / "model" / "model_card.json").read_text("utf-8")) card["sha256"] = "0" * 64 (tmp_path / "model_card.json").write_text(json.dumps(card), "utf-8") assert_not_ready(make_client(model_path=tmp_path / "house_price.joblib"))
def test_other_scikit_learn_version(make_client, tmp_path, monkeypatch): # Save a small model as if an older scikit-learn had made it. path = tmp_path / "house_price.joblib" monkeypatch.setattr(sklearn.base, "__version__", "1.0.0") joblib.dump(DummyRegressor().fit([[0]], [0]), path) monkeypatch.undo() digest = file_sha256(path) card = {"sha256": digest} (tmp_path / "model_card.json").write_text(json.dumps(card), "utf-8") assert_not_ready(make_client(model_path=path, model_sha256=digest))tmp_path est une fixture pytest (ressource de test) : un nouveau dossier vide pour chaque test. monkeypatch en est une autre : elle change une valeur pour un test, et monkeypatch.undo() rétablit l’ancienne valeur. Le dernier test se fait passer pour scikit-learn 1.0.0 pendant qu’il enregistre un tout petit modèle. Son chargement produit donc le véritable avertissement de version. Exécutez les tests :
python -m pytesttests/test_health.py .. [ 25%]tests/test_model_file.py ...... [100%]
============================== 8 passed in 0.14s ===============================Ce que cela nous apprend. Chaque problème possible du fichier de modèle produit maintenant le même résultat sûr. Le processus reste actif, la vérification de disponibilité échoue avec model_unavailable (modèle indisponible), et aucune trace d’erreur ne parvient au client. Le deuxième test relie aussi trois éléments : le fichier, l’empreinte dans le code et l’empreinte dans la fiche. Si un seul de ces éléments change, le test échoue.
Étape 5 : observer le résultat sur le serveur
Section intitulée « Étape 5 : observer le résultat sur le serveur »Redémarrez l’API avec fastapi dev, puis appelez /ready :
curl -i http://127.0.0.1:8000/readyHTTP/1.1 200 OKdate: Thu, 08 Oct 2026 00:32:18 GMTserver: uvicorncontent-length: 18content-type: application/jsonx-request-id: 0af354953e87
{"status":"ready"}WARNING house_price_api: API_KEY is not set: /v1/estimates accepts requests without a keyINFO house_price_api: model kc-hgb-2015-02 loaded in 265 ms, memory 222 MBINFO house_price_api: GET /ready 200 32ms id=0af354953e87Ce que cela nous apprend. Le modèle se charge en environ un quart de seconde. Le processus a utilisé au maximum environ 220 Mo de mémoire. L’essentiel provient de scikit-learn, SciPy et NumPy eux-mêmes, pas du modèle de 0,5 Mo. Cela tient dans les 500 Mio d’une instance gratuite de FastAPI Cloud, avec de la marge. La première vérification de disponibilité a pris 32 ms parce que c’était la première prédiction du modèle. Les suivantes sont plus rapides, mais peuvent encore prendre de 5 à 20 ms sur un ordinateur avec plusieurs cœurs. La raison : le modèle lance un fil d’exécution pour chaque cœur, même pour une seule maison. La partie 5 corrige cela, et une vérification prend alors 1 ou 2 ms.
Fournissez maintenant à l’API un fichier qui diffère d’un seul octet. Arrêtez le serveur (Ctrl+C), créez la copie, puis démarrez l’API avec MODEL_PATH qui pointe vers elle :
python -c "import shutil; shutil.copy('model/house_price.joblib', 'changed.joblib'); open('changed.joblib', 'ab').write(b'x')"MODEL_PATH=changed.joblib fastapi devpython -c "import shutil; shutil.copy('model/house_price.joblib', 'changed.joblib'); open('changed.joblib', 'ab').write(b'x')"$env:MODEL_PATH = "changed.joblib"fastapi devpython -c "import shutil; shutil.copy('model/house_price.joblib', 'changed.joblib'); open('changed.joblib', 'ab').write(b'x')"MODEL_PATH=changed.joblib fastapi devcurl -s -i http://127.0.0.1:8000/healthcurl -s http://127.0.0.1:8000/readyHTTP/1.1 200 OK{"error":{"code":"model_unavailable","message":"The model is not available. Try again later.","request_id":"c8c00376afc8","fields":[]}}ERROR house_price_api: model not loaded: changed.joblib has SHA-256 7d52c6acfc65..., expected 8d0483d05cfc...WARNING house_price_api: model unavailable: changed.joblib has SHA-256 7d52c6acfc65..., expected 8d0483d05cfc... id=c8c00376afc8Ce que cela nous apprend. Un seul octet ajouté a produit une empreinte complètement différente. Le fichier n’a donc jamais été chargé : aucun de son code n’a été exécuté. Le processus répond toujours à /health, et /ready indique que le modèle est indisponible, dans le format standard. Le client voit un court message. Le journal contient les détails, avec l’identifiant de requête pour relier les deux. Arrêtez le serveur et supprimez changed.joblib. Sous Windows, supprimez aussi le paramètre avec Remove-Item Env:MODEL_PATH.
Partie 3 : vérifier les entrées selon ce que le modèle connaît
Section intitulée « Partie 3 : vérifier les entrées selon ce que le modèle connaît »Le problème : un modèle répond à tout
Section intitulée « Le problème : un modèle répond à tout »Votre API pourrait transmettre n’importe quels 17 nombres au modèle. Relancez Python et collez ceci pour voir comment le modèle seul traite des données absurdes :
import joblibfrom house_price_api.estimator import FEATURES, READY_CHECK_HOUSE
model = joblib.load("model/house_price.joblib")houses = { "typical house": READY_CHECK_HOUSE, "the same house in New York": {**READY_CHECK_HOUSE, "lat": 40.71, "long": -74.01}, "with 40 bedrooms": {**READY_CHECK_HOUSE, "bedrooms": 40}, "with -500 sq ft": {**READY_CHECK_HOUSE, "sqft_living": -500, "sqft_above": -500},}for name, house in houses.items(): price = model.predict([[house[f] for f in FEATURES]])[0] print(f"{name:28} {price:>10,.0f}")Après avoir collé le code, appuyez encore une fois sur Entrée. L’invite Python attend une ligne vide pour savoir que la boucle for est complète, puis l’exécute.
typical house 421,936the same house in New York 258,159with 40 bedrooms 391,415with -500 sq ft 295,154Ce que cela nous apprend. Le modèle donne pour chaque cas un prix qui semble précis et assuré. Selon lui, une maison de moins 500 pieds carrés vaut 295 154 dollars. Il ne dit jamais « je ne sais pas ». Cela vient du fonctionnement des arbres. Chaque arbre répartit les entrées dans des plages qu’il a vues pendant l’entraînement. Une valeur très éloignée tombe dans la dernière plage. Pour le modèle, New York est simplement « loin au sud et à l’est de Seattle ». Et 40 chambres signifie « plus de 11 ». L’API doit donc décider ce qu’on peut demander au modèle, avant de le lui demander.
L’idée : les données d’entraînement fixent les limites
Section intitulée « L’idée : les données d’entraînement fixent les limites »Une bonne règle vient des données : accepter les valeurs que le modèle a vues et refuser les autres avec une erreur claire. Pensez à la balance d’un magasin qui pèse jusqu’à 15 kg. Posez 30 kg dessus : elle n’affiche pas « 30 », mais une erreur. Un nombre incorrect serait pire qu’aucun nombre.
Pour chaque champ, les données d’entraînement ont une valeur minimale et une valeur maximale. Par exemple, la superficie habitable allait de 290 à 13 540 pieds carrés. L’API utilise cette plage, arrondie vers l’extérieur à des nombres ronds : de 250 à 14 000. L’arrondissement facilite la documentation des limites. Il ne change rien d’important : un arbre donne la même réponse à 13 540 et à 14 000.
Trois autres règles font correspondre les entrées à ce que le modèle a appris :
- Les types sont stricts.
"3"(texte) pourbedroomsest refusé, plutôt que converti silencieusement en3. Un client qui envoie du texte à la place d’un nombre a un bogue, et mieux vaut le détecter maintenant. Dans Pydantic,strict=Trueassure cela. - Aucun code qui ressemble à un nombre. Dans les données,
yr_renovatedvaut0pour « jamais rénovée ». Dans l’API, « jamais » correspond ànull, pour qu’aucun client n’envoie0par erreur. L’API transformenullen0pour le modèle. - Aucun champ qui doit concorder avec un autre. Le modèle utilise
sqft_above, la superficie au-dessus du sol. Elle vaut toujourssqft_living - sqft_basement. L’API la calcule donc au lieu de la demander. Ainsi, deux champs ne peuvent jamais se contredire.
L’emplacement est différent. Une maison à New York n’a pas une valeur incorrecte : sa latitude est une latitude valide. Elle est hors de la zone que le modèle connaît. Elle reçoit donc son propre code d’erreur, outside_model_area (hors de la zone du modèle). Ce code indique à l’outil de saisie exactement quoi dire à l’utilisateur. La zone est le plus petit rectangle qui englobe toutes les maisons d’entraînement : latitude de 47,15 à 47,78, longitude de −122,52 à −121,31.
Étape 1 : le modèle de requête
Section intitulée « Étape 1 : le modèle de requête »Ajoutez le modèle de requête à house_price_api/models.py. Modifiez la ligne d’importation de Pydantic et ajoutez ceci au-dessus du modèle Health :
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
# The area that the model knows: the smallest box around every training house.MODEL_AREA = {"lat": (47.15, 47.78), "long": (-122.52, -121.31)}
TYPICAL_HOUSE = { "bedrooms": 3, "bathrooms": 1.75, "sqft_living": 1560, "sqft_lot": 9452, "floors": 1.0, "waterfront": False, "view": 0, "condition": 4, "grade": 8, "sqft_basement": 0, "yr_built": 1974, "yr_renovated": None, "lat": 47.5197, "long": -122.18, "sqft_living15": 2200, "sqft_lot15": 9985,}
class HouseIn(BaseModel): """The description of one house in King County, Washington."""
model_config = ConfigDict( extra="forbid", strict=True, json_schema_extra={"examples": [TYPICAL_HOUSE]} )
bedrooms: int = Field(ge=0, le=11, description="Number of bedrooms.") bathrooms: float = Field( ge=0, le=8, multiple_of=0.25, description="Number of bathrooms. 0.5 is a toilet without a shower.", ) sqft_living: int = Field( ge=250, le=14_000, description="Living area in square feet, basement included." ) sqft_lot: int = Field(ge=500, le=1_100_000, description="Land area in square feet.") floors: float = Field( ge=1, le=3.5, multiple_of=0.5, description="Floors. 1.5 is a partial upper floor." ) waterfront: bool = Field(description="True if the house is on the water.") view: int = Field(ge=0, le=4, description="Quality of the view, 0 (none) to 4.") condition: int = Field(ge=1, le=5, description="Condition, 1 (poor) to 5 (very good).") grade: int = Field( ge=1, le=13, description="King County building grade, 1 to 13. 7 is average." ) sqft_basement: int = Field( ge=0, le=5_000, description="Basement area in square feet. 0 = no basement." ) yr_built: int = Field(ge=1900, le=2015, description="Year built.") yr_renovated: int | None = Field( default=None, ge=1900, le=2015, description="Year of the last renovation. null = never renovated.", ) lat: float = Field(ge=-90, le=90, description="Latitude.") long: float = Field(ge=-180, le=180, description="Longitude.") sqft_living15: int = Field( ge=400, le=6_500, description="Average living area of the 15 nearest houses." ) sqft_lot15: int = Field( ge=600, le=900_000, description="Average land area of the 15 nearest houses." )
@field_validator("sqft_basement") @classmethod def basement_is_part_of_living_area(cls, value: int, info: ValidationInfo) -> int: living = info.data.get("sqft_living") if living is not None and value >= living: raise ValueError("must be smaller than sqft_living") return value
@field_validator("yr_renovated") @classmethod def renovated_after_built(cls, value: int | None, info: ValidationInfo): built = info.data.get("yr_built") if value is not None and built is not None and value < built: raise ValueError("must not be before yr_built") return value
def outside_model_area(self) -> list[str]: """The location fields outside the area that the model knows.""" return [ name for name, (low, high) in MODEL_AREA.items() if not low <= getattr(self, name) <= high ]
def model_inputs(self) -> dict[str, float]: """The 17 numbers that the model expects, with its own codes.""" values = self.model_dump() values["waterfront"] = int(self.waterfront) values["sqft_above"] = self.sqft_living - self.sqft_basement values["yr_renovated"] = self.yr_renovated or 0 # the model's code for "never" return values| Partie | Signification |
|---|---|
extra="forbid" |
Un champ inconnu, comme zipcode, est une erreur, comme dans le cours. |
multiple_of=0.25 |
Dans les données, les salles de bain sont comptées par quarts (0,75 correspond à une douche sans baignoire). La valeur 0,3 est refusée. |
lat: float = Field(ge=-90, le=90) |
Toute latitude réelle passe la validation. La vérification de la zone vient ensuite, avec son propre code. |
field_validator avec info.data |
Une règle portant sur deux champs. info.data contient les champs au-dessus de celui-ci qui sont déjà valides. C’est pourquoi sqft_living précède sqft_basement. L’erreur nomme le champ du validateur. |
model_inputs() |
Le lien entre les deux langages : les champs de l’API deviennent les 17 nombres du modèle. |
Étape 2 : l’erreur de zone
Section intitulée « Étape 2 : l’erreur de zone »La vérification de la zone a besoin de sa propre exception et de son propre gestionnaire. Dans errors.py, ajoutez l’exception sous CODES, puis un gestionnaire après celui des erreurs de validation :
class OutsideModelArea(Exception): """A house is outside the area that the model knows."""
def __init__(self, fields: list[str]): super().__init__(", ".join(fields)) self.fields = fields
# in add_error_handlers, after the handler for RequestValidationError: @app.exception_handler(OutsideModelArea) async def outside_model_area(request: Request, exc: OutsideModelArea): return error_response( request, 422, "outside_model_area", "The model knows only houses in King County, Washington.", exc.fields, )Pourquoi 422, le même statut qu’une erreur de validation? La requête est bien formée, mais ne peut pas être traitée. C’est ce que signifie 422. Le code est ce qu’un programme vérifie, et ce code est différent.
Partie 4 : une estimation avec une plage, une version et des avertissements
Section intitulée « Partie 4 : une estimation avec une plage, une version et des avertissements »Le problème : un seul nombre semble exact
Section intitulée « Le problème : un seul nombre semble exact »Supposons que l’API répond {"estimate": 421936.2}. L’outil de réception d’Elif affiche « 421 936 $ US ». Un agent de crédit le lit comme un fait, au dollar près. Mais l’erreur typique de ce modèle est d’environ 12,8 %, soit 54 000 dollars pour cette maison. Six chiffres suggèrent une précision que le modèle n’a pas.
Il manque deux autres choses dans cette réponse. Quel modèle a produit cette estimation? Si l’analyste réentraîne le modèle le mois prochain, la même maison obtient un nouveau nombre. Personne ne pourra ensuite déterminer quel modèle a donné quelle estimation. Et de quand datent les prix? Ce modèle connaît les prix de 2014–2015, et une personne qui le consulte une autre année doit le savoir.
L’idée : une plage tirée des erreurs du modèle
Section intitulée « L’idée : une plage tirée des erreurs du modèle »Une prévision météo annonce « entre 18 et 23 degrés », et les gens peuvent bien planifier avec cette information. L’API peut faire la même chose. L’analyste a mesuré les erreurs du modèle sur des ventes de test qu’il n’avait jamais vues.
Pour chaque vente de test, divisez le prix réel par l’estimation. Un résultat de 1,10 signifie que la maison s’est vendue 10 % plus cher que l’estimation. Triez les 4 654 résultats. La valeur sous laquelle se trouvent 10 % des résultats est 0,884. Celle sous laquelle se trouvent 90 % des résultats est 1,279. Donc, 80 % des ventes de test se situent entre 0,884 et 1,279 fois leur estimation. Ces deux nombres sont des quantiles. La fiche les contient sous les noms low_factor (facteur inférieur) et high_factor (facteur supérieur).
Pour la maison typique de la documentation, le modèle estime le prix à 421 936 dollars :
| Calcul | Résultat (arrondi à 1 000) | |
|---|---|---|
| Borne inférieure | 421 936 × 0,884 | 373 000 |
| Estimation | 422 000 | |
| Borne supérieure | 421 936 × 1,279 | 540 000 |
La plage n’est pas centrée sur l’estimation. Elle monte 28 % au-dessus et descend seulement 12 % en dessous. Les prix ont augmenté au printemps 2015, et le modèle ne l’a pas vu. Les prix réels dépassaient donc plus souvent l’estimation qu’ils n’étaient en dessous. La plage le montre honnêtement. Une plage symétrique de « ±10 % » le cacherait. Arrondir au millier de dollars le plus proche suit la même idée. La réponse ne montre pas plus de précision que le modèle n’en possède.
La réponse contient aussi :
model_version(version du modèle), par exemplekc-hgb-2015-02, pour relier chaque estimation à un modèle, comme Daniel l’a demandé.prices_as_of(date des prix), la date de la dernière vente utilisée pour entraîner le modèle :2015-02-28.warnings(avertissements) : des raisons de moins se fier à cette estimation. Il y en a deux types.high_estimate(estimation élevée) indique une estimation d’au moins 1 million de dollars. Dans ce cas, le modèle était à moins de 10 % du prix réel seulement 4 fois sur 10.rare_value(valeur rare) indique une entrée hors des valeurs de 98 % des maisons d’entraînement, avec les noms des champs. Elle se situe sous le 1er ou au-dessus du 99e percentile. Un avertissement n’est pas une erreur : l’estimation est quand même fournie, avec une raison de la vérifier.
Étape 1 : les modèles de réponse
Section intitulée « Étape 1 : les modèles de réponse »Ajoutez ceci à models.py, sous HouseIn :
class PriceRange(BaseModel): low: int = Field(description="Lower end, US dollars.") high: int = Field(description="Upper end, US dollars.")
class EstimateWarning(BaseModel): code: Literal["high_estimate", "rare_value"] = Field( description="A stable code that a program can check." ) message: str = Field(description="A short explanation for a person.") fields: list[str] = Field( default=[], description="The inputs that the warning is about, if any." )
class EstimateOut(BaseModel): """A first estimate of the sale price, before an appraisal."""
model_config = ConfigDict( json_schema_extra={ "examples": [ { "estimate_usd": 422000, "range_usd": {"low": 373000, "high": 540000}, "model_version": "kc-hgb-2015-02", "prices_as_of": "2015-02-28", "warnings": [], } ] } )
estimate_usd: int = Field( description="The estimated sale price, US dollars, rounded to 1,000." ) range_usd: PriceRange = Field( description=( "8 in 10 test sales sold inside this range around their estimate. " "It is wider above the estimate, because the model lags a rising market." ) ) model_version: str = Field(description="The model that made the estimate.") prices_as_of: str = Field( description="The last sale date that the model learned from: prices of that time." ) warnings: list[EstimateWarning] = Field( description="Reasons to trust this estimate less. Empty if there are none." )
class Evaluation(BaseModel): sales: int first_sales: str median_error_pct: float mape_pct: float within_10pct: float over_by_10pct_or_more: float
class RangeInfo(BaseModel): coverage: float low_factor: float high_factor: float
class ModelInfo(BaseModel): """The model card: what the model is for, and how well it did."""
name: str model_version: str sha256: str prices_as_of: str intended_use: str not_for: list[str] test: Evaluation range: RangeInfo model: str features: list[str]Les noms des champs se terminent par _usd, pour éviter toute confusion sur la devise. ModelInfo est la fiche pour GET /v1/model : les parties de model_card.json dont un client peut avoir besoin.
Étape 2 : les routes
Section intitulée « Étape 2 : les routes »Créez house_price_api/routes.py :
import asynciofrom typing import Annotated
from fastapi import APIRouter, Depends, Requestfrom fastapi.concurrency import run_in_threadpool
from house_price_api.errors import OutsideModelAreafrom house_price_api.estimator import HouseEstimator, ModelUnavailablefrom house_price_api.models import ( ErrorResponse, EstimateOut, EstimateWarning, HouseIn, ModelInfo, PriceRange,)from house_price_api.security import require_api_key
router = APIRouter(prefix="/v1", tags=["estimates"])
HIGH_ESTIMATE_USD = 1_000_000
ERRORS = { 401: {"model": ErrorResponse, "description": "The API key is missing or wrong."}, 413: {"model": ErrorResponse, "description": "The request body is too large."}, 422: { "model": ErrorResponse, "description": ( "invalid_request: a field is missing or not valid. " "outside_model_area: the house is outside King County." ), }, 503: {"model": ErrorResponse, "description": "The model is not available."},}
def to_thousand(dollars: float) -> int: return int(round(dollars, -3))
def get_estimator(request: Request) -> HouseEstimator: estimator = request.app.state.estimator if estimator is None: raise ModelUnavailable(request.app.state.model_problem) return estimator
def check_area(houses: list[HouseIn], prefix: str = "") -> None: fields = [ f"{prefix}{index}.{name}" if prefix else name for index, house in enumerate(houses) for name in house.outside_model_area() ] if fields: raise OutsideModelArea(fields)
def warnings_for( estimator: HouseEstimator, inputs: dict[str, float], estimate: float) -> list[EstimateWarning]: warnings = [] if estimate >= HIGH_ESTIMATE_USD: warnings.append( EstimateWarning( code="high_estimate", message=( "Estimates of 1,000,000 USD or more were within 10% of the " "sale price only 4 times in 10. Ask for an appraisal first." ), ) ) rare = estimator.rare_values(inputs) if rare: warnings.append( EstimateWarning( code="rare_value", message=( "Fewer than 1 in 100 training houses had a value this small " "or this large. The estimate is less certain." ), fields=rare, ) ) return warnings
async def estimate_houses( request: Request, estimator: HouseEstimator, houses: list[HouseIn]) -> list[EstimateOut]: rows = [house.model_inputs() for house in houses] timeout = request.app.state.settings.estimate_timeout try: estimates = await asyncio.wait_for( run_in_threadpool(estimator.estimate, rows), timeout ) except TimeoutError: raise ModelUnavailable(f"no answer within {timeout} seconds") from None return [ EstimateOut( estimate_usd=to_thousand(estimate.value), range_usd=PriceRange( low=to_thousand(estimate.low), high=to_thousand(estimate.high) ), model_version=estimator.version, prices_as_of=estimator.prices_as_of, warnings=warnings_for(estimator, row, estimate.value), ) for row, estimate in zip(rows, estimates) ]
@router.get( "/model", summary="Describe the model", description="The model card: its version, intended use, limits and test results.", responses={503: ERRORS[503]},)def model_card( estimator: Annotated[HouseEstimator, Depends(get_estimator)],) -> ModelInfo: return ModelInfo.model_validate(estimator.card)
@router.post( "/estimates", summary="Estimate the price of one house", description=( "A first estimate of the sale price, at 2014-15 prices, with a range " "and warnings. Not for lending, underwriting or insurance decisions." ), response_description="The estimate, its range and its warnings", responses=ERRORS, dependencies=[Depends(require_api_key)],)async def estimate_one( house: HouseIn, request: Request, estimator: Annotated[HouseEstimator, Depends(get_estimator)],) -> EstimateOut: check_area([house]) [result] = await estimate_houses(request, estimator, [house]) return result| Partie | Raison |
|---|---|
get_estimator |
Une dépendance, comme dans Séparer les responsabilités. Si le modèle n’est pas chargé, chaque route qui en a besoin renvoie 503, depuis un seul endroit. |
check_area |
S’exécute après la validation et ne reçoit donc que des nombres valides. Le prefix (préfixe) sert au lot de la partie 5. |
estimate_houses |
La limite de temps du cours, appliquée à un seul appel pour toutes les maisons. |
/v1/model sans clé |
La fiche est une information publique. Un client peut vérifier la version du modèle sans clé. |
| La description dit « Not for lending… » (ne pas utiliser pour les prêts…) | La limite d’utilisation fait partie du contrat, où chaque développeur qui consulte /docs la voit. |
Enfin, dans main.py, importez le routeur et ajoutez-le, comme dans le cours :
from house_price_api.routes import router
# in create_app, after add_error_handlers(app): app.include_router(router)Voici maintenant le parcours de chaque requête d’estimation :
Description textuelle du diagramme
Une requête à POST /v1/estimates passe cinq vérifications dans l’ordre. Un corps de plus de 64 Ko reçoit 413 body_too_large (corps trop volumineux). Une clé manquante ou incorrecte reçoit 401 unauthorized (non autorisé). Un corps qui ne respecte pas les types et limites de HouseIn reçoit 422 invalid_request (requête invalide), avec les noms des champs. Une maison hors de King County reçoit 422 outside_model_area (hors de la zone du modèle), avec lat, long ou les deux. Un modèle non chargé, ou qui ne répond pas à temps, renvoie 503 model_unavailable (modèle indisponible). Alors seulement, l’API répond 200, avec l’estimation, la plage, la version du modèle et les avertissements éventuels. La limite du corps reste de 16 Ko jusqu’à sa modification dans la partie 5.
Étape 3 : demander des estimations
Section intitulée « Étape 3 : demander des estimations »Démarrez l’API avec fastapi dev. Le dossier requests/ contient les trois maisons de référence : des maisons réelles des mois de test, jamais vues par le modèle. Envoyez d’abord la maison typique :
curl -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":[]}Ce que cela nous apprend. Cette maison s’est vendue le 26 avril 2015 pour 425 000 dollars. L’estimation de 422 000 dollars s’écarte de moins de 1 %, et le prix de vente est bien dans la plage. Il n’y a aucun avertissement : c’est une maison ordinaire que le modèle connaît bien. La réponse contient tout ce que Daniel a demandé : le modèle et la date des prix.
Envoyez waterfront-house.json de la même façon :
{"estimate_usd":2156000,"range_usd":{"low":1906000,"high":2757000},"model_version":"kc-hgb-2015-02","prices_as_of":"2015-02-28","warnings":[{"code":"high_estimate","message":"Estimates of 1,000,000 USD or more were within 10% of the sale price only 4 times in 10. Ask for an appraisal first.","fields":[]},{"code":"rare_value","message":"Fewer than 1 in 100 training houses had a value this small or this large. The estimate is less certain.","fields":["sqft_basement"]}]}Ce que cela nous apprend. Une maison au bord de l’eau de 4 640 pi², vendue pour 2 200 000 dollars. Le modèle était proche cette fois-ci (2 156 000), mais l’API ne peut pas le savoir à l’avance. La plage couvre 850 000 dollars, et deux avertissements expliquent pourquoi il faut être prudent. L’estimation dépasse 1 million. Le sous-sol de 1 780 pi² est plus grand que celui de 99 % des maisons d’entraînement. L’outil de réception peut afficher les deux avertissements et envoyer d’abord la maison à un évaluateur.
Ensuite, large-lot-house.json :
{"estimate_usd":374000,"range_usd":{"low":331000,"high":479000},"model_version":"kc-hgb-2015-02","prices_as_of":"2015-02-28","warnings":[{"code":"rare_value","message":"Fewer than 1 in 100 training houses had a value this small or this large. The estimate is less certain.","fields":["sqft_lot","sqft_lot15"]}]}Ce que cela nous apprend. Une maison sur un terrain de 273 556 pi² (environ 2,5 hectares), dans un secteur de grands terrains. Elle s’est vendue pour 350 000 dollars; l’estimation de 374 000 dollars est trop élevée de 7 %. L’avertissement nomme les deux champs inhabituels, pour qu’une personne sache où regarder. L’estimation est quand même fournie : rare ne veut pas dire incorrect.
Enfin, deux requêtes incorrectes, outside-king-county.json et bedrooms-as-text.json :
{"error":{"code":"outside_model_area","message":"The model knows only houses in King County, Washington.","request_id":"ebe35f796154","fields":["lat","long"]}}{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"b5593f0c7830","fields":["bedrooms"]}}Ce que cela nous apprend. Le modèle seul évaluait la maison de New York à 258 159 dollars. Maintenant, elle ne reçoit aucun prix, mais un code que l’outil de réception peut expliquer. "bedrooms": "three" est refusé, et l’erreur nomme le champ. Les deux erreurs suivent la structure du cours et contiennent un identifiant de requête.
Ouvrez http://127.0.0.1:8000/v1/model dans votre navigateur. Vous y verrez la fiche en JSON : la version, l’empreinte, l’utilisation prévue et la liste not_for (utilisations exclues). Elle contient aussi les résultats de test et les facteurs de la plage. Un client peut vérifier à tout moment avec quel modèle il communique.
Partie 5 : plusieurs maisons à la fois
Section intitulée « Partie 5 : plusieurs maisons à la fois »Le problème : une requête par maison est lente
Section intitulée « Le problème : une requête par maison est lente »Une fois par mois, après le réentraînement, l’équipe de Hannah veut réestimer toutes les demandes en cours : environ 2 000 maisons. Une requête par maison signifie 2 000 allers-retours sur le réseau. Mais le modèle lui-même traite rapidement plusieurs maisons. Un appel à predict pour 100 maisons prend à peu près le même temps que pour une seule. Le démarrage de l’appel prend la majeure partie du temps.
L’API reçoit donc un point de terminaison par lots : POST /v1/estimates/batch, avec 1 à 100 maisons. Il renvoie une estimation par maison, dans le même ordre. L’ordre compte, car la réponse n’a aucun autre moyen d’indiquer quelle estimation correspond à quelle maison.
Étape 1 : les modèles et la route pour les lots
Section intitulée « Étape 1 : les modèles et la route pour les lots »Ajoutez ceci à models.py :
# below HouseIn:class HouseBatchIn(BaseModel): """1 to 100 houses, estimated in one request."""
model_config = ConfigDict(extra="forbid")
houses: list[HouseIn] = Field(min_length=1, max_length=100)
# below EstimateOut:class EstimateBatchOut(BaseModel): estimates: list[EstimateOut] = Field(description="One estimate per house, in order.")Ajoutez EstimateBatchOut et HouseBatchIn à l’importation dans routes.py, puis ajoutez la route à la fin :
@router.post( "/estimates/batch", summary="Estimate the prices of 1 to 100 houses", description="The same as /v1/estimates for each house. The estimates keep the order of the houses.", response_description="One estimate per house, in order", responses=ERRORS, dependencies=[Depends(require_api_key)],)async def estimate_batch( batch: HouseBatchIn, request: Request, estimator: Annotated[HouseEstimator, Depends(get_estimator)],) -> EstimateBatchOut: check_area(batch.houses, prefix="houses.") return EstimateBatchOut(estimates=await estimate_houses(request, estimator, batch.houses))La route réutilise tout : la même validation par maison, la même vérification de zone et le même estimate_houses. Grâce au préfixe, une erreur nomme la maison, comme dans le travail final du cours. houses.2.lat est la latitude de la troisième maison : la liste commence à 0.
Étape 2 : la limite du corps
Section intitulée « Étape 2 : la limite du corps »Essayez un lot de trois maisons, puis un lot de 100. La deuxième commande crée le fichier hundred.json avec la maison typique répétée 100 fois, avec une indentation écrite à la main :
curl -s -X POST http://127.0.0.1:8000/v1/estimates/batch -H "Content-Type: application/json" -d @requests/three-houses.jsonpython -c "import json; h = json.load(open('requests/typical-house.json')); json.dump({'houses': [h] * 100}, open('hundred.json', 'w'), indent=2); print(len(open('hundred.json').read()), 'bytes')"curl -s -X POST http://127.0.0.1:8000/v1/estimates/batch -H "Content-Type: application/json" -d @hundred.json{"estimates":[{"estimate_usd":422000,...},{"estimate_usd":2156000,...},{"estimate_usd":374000,...}]}39220 bytes{"error":{"code":"body_too_large","message":"The body is larger than 16384 bytes.","request_id":"11973df02340","fields":[]}}Ce que cela nous apprend. Trois maisons fonctionnent, dans l’ordre. Mais 100 maisons, permises par le contrat, occupent 39 Ko, et la limite de 16 Ko du cours les refuse. Deux promesses de l’API se contredisent. Le travail final du cours pose la même question pour son lot de billets de soutien : modifier une limite ou la documenter.
La limite sert à refuser des corps dont aucune requête valide n’a besoin. La bonne limite dépend donc de la plus grande requête valide. 100 maisons occupent 25 Ko en JSON compact (sans espaces), 29 Ko avec espaces et 39 Ko avec indentation. La puissance de deux suivante est 64 Ko. Cette limite refuse encore tout corps beaucoup plus grand, donc la protection reste en place. Modifiez-la dans main.py, en expliquant la raison dans un commentaire :
# A batch of 100 houses is 25 to 39 KB of JSON, with or without spaces and# indentation. 64 KB leaves room for the largest valid request, and no more.MAX_BODY_BYTES = 65_536Envoyez hundred.json de nouveau. La réponse contient maintenant 100 estimations, dont la première est de 422 000 dollars. Supprimez hundred.json quand vous avez terminé.
Étape 3 : un seul fil d’exécution pour le modèle
Section intitulée « Étape 3 : un seul fil d’exécution pour le modèle »Un petit serveur dispose d’une petite part d’un processeur : l’instance gratuite de FastAPI Cloud a 0,1 CPU. Le gradient boosting (renforcement par gradient) de scikit-learn utilise OpenMP, une bibliothèque qui répartit le travail entre plusieurs fils d’exécution. Par défaut, elle démarre un fil par cœur de processeur, ce qui aide pour l’entraînement. Pour une maison, le travail est trop petit pour être réparti. Démarrer les fils et attendre leur résultat coûte plus de temps que la prédiction.
Nous l’avons mesuré avec l’API terminée sur l’ordinateur de test, un Apple M4 à 10 cœurs. Le script de vérification de la partie 9 envoyait 50 estimations individuelles à chaque fois :
| Fils d’exécution | Temps médian d’une estimation | Lot de 100 |
|---|---|---|
| 10 (valeur par défaut sur cet ordinateur) | 5 à 12 ms, selon l’exécution | 6 ms |
| 1 | 2 ms | 3 ms |
L’API utilise donc un seul fil d’exécution. Le réglage est la variable d’environnement OMP_NUM_THREADS. Il faut la définir avant que scikit-learn démarre ses fils. Le premier endroit possible dans le projet est le fichier __init__.py du paquet. Python l’exécute avant tout module du paquet :
"""First price estimates for houses in King County, Washington."""
import os
# The model predicts with one thread. This must happen before scikit-learn# starts: on a small server, more threads make each prediction slower.os.environ.setdefault("OMP_NUM_THREADS", "1")setdefault conserve une valeur déjà définie dans l’environnement, donc vous pouvez encore essayer une autre valeur. Sur un serveur avec 0,1 CPU, plusieurs fils utiliseraient simplement à tour de rôle la même petite part.
Partie 6 : tester le service
Section intitulée « Partie 6 : tester le service »Ce que les tests doivent prouver
Section intitulée « Ce que les tests doivent prouver »Les tests pytest vérifient les règles rapidement, à chaque modification. Pour une API de modèle, quatre types de tests comptent :
- Cas de référence. Quelques entrées réelles avec les réponses exactes attendues. Si une mise à jour de paquet ou un réentraînement modifie une réponse, un test de référence échoue. Une personne décide alors si le changement est correct.
tests/golden_houses.jsonen contient trois : les maisons de la partie 4. - Limites. Les valeurs juste à l’intérieur et juste à l’extérieur de chaque limite.
bedroomsà 11 passe, à 12 échoue. - Types et structures incorrects. Du texte à la place d’un nombre,
0pourfalse, un champ manquant, un champ inconnu. - Dépendances défaillantes. Aucun modèle, le mauvais modèle : la partie 2 les a testées.
Le fichier de test
Section intitulée « Le fichier de test »Créez tests/test_estimates.py. Le début contient les tests de référence et de limites :
import jsonfrom pathlib import Path
import pytest
GOLDEN = json.loads((Path(__file__).parent / "golden_houses.json").read_text("utf-8"))
@pytest.mark.parametrize("golden", GOLDEN, ids=[g["name"] for g in GOLDEN])def test_golden_houses(client, golden): response = client.post("/v1/estimates", json=golden["house"]) assert response.status_code == 200 body = response.json() expected = golden["expected"] assert body["estimate_usd"] == expected["estimate_usd"] assert body["range_usd"] == expected["range_usd"] assert [w["code"] for w in body["warnings"]] == expected["warnings"] assert body["model_version"] == "kc-hgb-2015-02" assert body["prices_as_of"] == "2015-02-28"
@pytest.mark.parametrize( ("field", "value"), [ ("bedrooms", 0), ("bedrooms", 11), ("sqft_living", 14_000), ("yr_built", 1900), ("yr_built", 2015), ("floors", 3.5), ("bathrooms", 0.25), ],)def test_limits_are_accepted(client, house, field, value): house[field] = value assert client.post("/v1/estimates", json=house).status_code == 200
@pytest.mark.parametrize( ("field", "value"), [ ("bedrooms", -1), ("bedrooms", 12), ("sqft_living", 14_001), ("yr_built", 1899), ("yr_built", 2016), ("floors", 1.25), ("bathrooms", 0.3), ("bedrooms", "three"), ("bedrooms", "3"), ("bedrooms", 2.5), ("waterfront", "no"), ("waterfront", 0), ("yr_renovated", 1950), ("sqft_basement", 1560), ],)def test_invalid_value_names_the_field(client, house, field, value): house[field] = value response = client.post("/v1/estimates", json=house) assert response.status_code == 422 assert response.json()["error"]["code"] == "invalid_request" assert response.json()["error"]["fields"] == [field]("yr_renovated", 1950) est refusé, car la maison typique a été construite en 1974. ("sqft_basement", 1560) est refusé, car la superficie habitable est de 1 560 pi² : le sous-sol ne peut pas l’occuper entièrement.
Le reste du fichier teste la zone, les lots, les protections et la fiche du modèle. Copiez-le ci-dessous ou depuis tests/test_estimates.py dans le projet terminé.
Afficher le reste de tests/test_estimates.py
def test_rare_value_names_the_fields(client): large_lot = GOLDEN[2]["house"] [warning] = client.post("/v1/estimates", json=large_lot).json()["warnings"] assert warning["fields"] == ["sqft_lot", "sqft_lot15"]
def test_missing_and_unknown_fields(client, house): del house["grade"] house["zipcode"] = 98006 response = client.post("/v1/estimates", json=house) assert response.status_code == 422 assert sorted(response.json()["error"]["fields"]) == ["grade", "zipcode"]
@pytest.mark.parametrize( ("changes", "fields"), [ ({"lat": 40.71, "long": -74.0}, ["lat", "long"]), ({"lat": 47.9}, ["lat"]), ({"long": -121.2}, ["long"]), ],)def test_outside_model_area(client, house, changes, fields): response = client.post("/v1/estimates", json={**house, **changes}) assert response.status_code == 422 assert response.json()["error"]["code"] == "outside_model_area" assert response.json()["error"]["fields"] == fields
def test_batch_keeps_the_order(client): houses = [g["house"] for g in reversed(GOLDEN)] response = client.post("/v1/estimates/batch", json={"houses": houses}) assert response.status_code == 200 estimates = [e["estimate_usd"] for e in response.json()["estimates"]] assert estimates == [g["expected"]["estimate_usd"] for g in reversed(GOLDEN)]
@pytest.mark.parametrize("count", [0, 101])def test_batch_size_limits(client, house, count): response = client.post("/v1/estimates/batch", json={"houses": [house] * count}) assert response.status_code == 422 assert response.json()["error"]["fields"] == ["houses"]
def test_batch_of_100_is_accepted(client, house): response = client.post("/v1/estimates/batch", json={"houses": [house] * 100}) assert response.status_code == 200 assert len(response.json()["estimates"]) == 100
def test_batch_names_the_house_and_the_field(client, house): wrong = {**house, "grade": 14} away = {**house, "lat": 40.71} response = client.post("/v1/estimates/batch", json={"houses": [house, wrong]}) assert response.json()["error"]["fields"] == ["houses.1.grade"] response = client.post("/v1/estimates/batch", json={"houses": [house, house, away]}) assert response.json()["error"]["code"] == "outside_model_area" assert response.json()["error"]["fields"] == ["houses.2.lat"]
def test_body_too_large(client, house): response = client.post( "/v1/estimates/batch", json={"houses": [house], "note": "x" * 70_000}, ) assert response.status_code == 413 assert response.json()["error"]["code"] == "body_too_large"
def test_api_key_is_required_when_set(make_client, house): client = make_client(api_key="test-key") assert client.post("/v1/estimates", json=house).status_code == 401 assert client.post("/v1/estimates/batch", json={"houses": [house]}).status_code == 401 response = client.post("/v1/estimates", json=house, headers={"X-API-Key": "test-key"}) assert response.status_code == 200 assert client.get("/v1/model").status_code == 200, "the card needs no key"
def test_no_model_gives_503_everywhere_but_health(make_client, tmp_path, house): client = make_client(model_path=tmp_path / "house_price.joblib") for response in ( client.post("/v1/estimates", json=house), client.post("/v1/estimates/batch", json={"houses": [house]}), client.get("/v1/model"), ): assert response.status_code == 503 assert response.json()["error"]["code"] == "model_unavailable"
def test_model_card(client): card = client.get("/v1/model").json() assert card["model_version"] == "kc-hgb-2015-02" assert card["test"]["mape_pct"] == 12.8 assert "lending, underwriting or insurance decisions about a person" in card["not_for"]
def test_an_error_has_the_request_id(client, house): error = client.post("/v1/estimates", json={**house, "lat": 40.71}) assert error.json()["error"]["request_id"] == error.headers["X-Request-ID"]python -m pytesttests/test_estimates.py ....................................... [ 82%]tests/test_health.py .. [ 87%]tests/test_model_file.py ...... [100%]
============================== 47 passed in 0.85s ==============================Ce que cela nous apprend. Les 47 tests s’exécutent en moins d’une seconde. Ils couvrent chaque règle du contrat, chaque code d’erreur et chaque défaillance possible du fichier du modèle. Les tests de référence fixent les réponses exactes de ce modèle. Dans la partie 7, ils vous indiquent qu’un modèle réentraîné répond différemment.
Partie 7 : documenter le contrat et versionner le modèle
Section intitulée « Partie 7 : documenter le contrat et versionner le modèle »Le problème : deux types de changements
Section intitulée « Le problème : deux types de changements »Cette API changera de deux façons différentes. Un client doit pouvoir les distinguer.
- Un nouveau modèle. Chaque mois, l’analyste refait l’entraînement avec les ventes les plus récentes. Les nombres changent, mais la structure des requêtes et des réponses reste la même. L’outil de réception d’Elif doit continuer à fonctionner sans modification.
- Un nouveau contrat. Quelqu’un renomme
estimate_usdenprice_usd. Tous les clients cessent alors de fonctionner, même si le modèle reste le même.
Le premier changement est normal, et model_version le rend visible. Le deuxième ne doit jamais arriver par accident.
Étape 1 : le contrat révisé
Section intitulée « Étape 1 : le contrat révisé »Comme dans Garder la documentation exacte, le projet de départ contient house_price_api/export_contract.py. Créez tests/test_contract.py :
import jsonfrom pathlib import Path
CONTRACT = Path(__file__).parent.parent / "contract" / "openapi.json"
def test_openapi_matches_the_reviewed_contract(client): current = json.dumps(client.get("/openapi.json").json(), indent=2) + "\n" reviewed = CONTRACT.read_text(encoding="utf-8") assert current == reviewed, ( "The API contract changed. If the change is intended, run " "python -m house_price_api.export_contract and review the difference." )
def test_documented_example_gives_the_documented_result(client): schemas = client.get("/openapi.json").json()["components"]["schemas"] request_example = schemas["HouseIn"]["examples"][0] response_example = schemas["EstimateOut"]["examples"][0] response = client.post("/v1/estimates", json=request_example) assert response.status_code == 200 assert response.json() == response_exampleExportez le contrat, lisez-le et exécutez les tests :
python -m house_price_api.export_contractpython -m pytestWrote contract/openapi.json...============================== 49 passed in 0.92s ==============================Avant la ligne Wrote (écrit), l’exportation affiche deux fois le journal de démarrage. Le module crée l’application à l’importation, puis export_contract la crée une autre fois. Ouvrez contract/openapi.json (environ 22 Ko) et lisez-le comme une personne qui découvre l’API. Les limites de chaque champ y figurent, comme "maximum": 11 pour bedrooms. Les deux codes 422 et la phrase « Not for lending » (ne pas utiliser pour les prêts) y figurent aussi. Le deuxième test prouve que l’exemple documenté, la maison typique, donne vraiment la réponse documentée.
Étape 2 : détecter un changement incompatible
Section intitulée « Étape 2 : détecter un changement incompatible »Essayez ce « petit nettoyage » : dans models.py et routes.py, remplacez partout estimate_usd par price_usd. Dans VS Code, utilisez Edit › Replace in Files (modifier › remplacer dans les fichiers), uniquement dans ces deux fichiers. L’API fonctionne encore. Exécutez les tests :
E AssertionError: The API contract changed. If the change is intended, run python -m house_price_api.export_contract and review the difference.E Skipping 9435 identical leading characters in diff, use -v to showE - "estimate_usd": {E ? ^^^ ^^^E + "price_usd": {...FAILED tests/test_contract.py::test_openapi_matches_the_reviewed_contract - A...FAILED tests/test_estimates.py::test_golden_houses[typical] - KeyError: 'esti......========================= 5 failed, 44 passed in 0.98s =========================Ce que cela nous apprend. Ce renommage est un changement incompatible, et deux types de tests l’ont détecté. Le test du contrat montre la ligne exacte. Les tests de référence échouent, car un client qui cherche estimate_usd ne trouve rien. Rétablissez le nom dans les deux fichiers et relancez les tests : 49 tests réussis.
Si un renommage est vraiment nécessaire, il faut une nouvelle version du chemin : /v2/estimates avec le nouveau nom. /v1/estimates reste disponible et continue de fonctionner jusqu’à ce que tous les clients aient migré. C’est la Règle 3 : une version dans le chemin.
Étape 3 : un nouveau modèle n’est pas un nouveau contrat
Section intitulée « Étape 3 : un nouveau modèle n’est pas un nouveau contrat »Passons à l’autre type de changement. training/train.py est le script de l’analyste qui a créé le modèle. Il télécharge les données (0,7 Mo) dans data/ et vérifie leur empreinte SHA-256. Ensuite, il entraîne le modèle, le teste une fois et écrit le fichier du modèle et sa fiche. Exécutez-le :
python training/train.pyDownloading https://github.com/Nextia-Intelligence/nextia-learning-labs/raw/main/C05/case-studies/data/king_county_house_sales.csv.gztrain 16959 sales | test 4654 salestrained in 3.5 stest: {'median_error_pct': 6.4, 'mape_pct': 12.8, 'within_10pct': 0.502, 'over_by_10pct_or_more': 0.126}range: 0.884 to 1.279 times the estimate (80% of test sales inside)wrote model/house_price.joblib (0.51 MB) and model/model_card.jsonsha256 8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdecCe que cela nous apprend. Le nouveau fichier a exactement la même empreinte que le fichier révisé. Avec les mêmes données, les mêmes paramètres (random_state=0) et les versions de paquets de requirements-lock.txt, l’entraînement est reproductible. Chaque octet est identique. Ainsi, python -m pytest réussit encore, et l’API charge le fichier. Cela rend le modèle digne de confiance. Tout le monde peut le recréer à partir des données publiques et vérifier que le fichier est identique.
La version de Python ne change pas le fichier non plus, mais seulement parce que le script l’exige. joblib enregistre le modèle au format pickle de Python. Son protocole (la version du format) est 4 par défaut avec Python 3.12, et 5 avec Python 3.14. Un autre protocole donne des octets différents, donc une empreinte différente, et l’API refuserait le fichier. train.py fixe donc le protocole, et la fiche le consigne :
joblib.dump(model, MODEL_FILE, compress=3, protocol=PICKLE_PROTOCOL) # PICKLE_PROTOCOL = 5Nous l’avons vérifié : avec Python 3.12.13 et 3.14.6, le script écrit le même fichier de modèle et le même model_card.json.
Un nouvel entraînement avec les ventes du mois prochain donnerait un nouveau fichier et une nouvelle empreinte. L’API refuserait alors de le charger, avec la ligne de journal de la partie 2. Il faudrait d’abord une modification révisée en trois parties :
- Une nouvelle
MODEL_VERSIONdanstraining/train.py, par exemplekc-hgb-2015-03, avec le nouveau fichier de modèle et sa fiche. - La nouvelle
MODEL_SHA256dansconfig.py. - Les nouvelles réponses attendues dans
golden_houses.jsonet dans l’exemple deEstimateOut, ce qui change aussi le fichier du contrat.
La troisième partie ressemble à un changement de contrat, mais regardez la différence. Seules les valeurs d’exemple changent, pas les champs, les types ou les limites. Aucun client ne cesse de fonctionner. Les tests de référence rendent les nouvelles réponses visibles. La personne qui révise voit ainsi la variation pour chaque maison de référence avant la mise en service du nouveau modèle.
Erreur courante
Après une mise à jour, /ready donne 503 et le journal parle de scikit-learn
Symptôme : vous avez mis à jour les paquets, par exemple avec pip install --upgrade scikit-learn. L’API démarre et /health donne 200, mais /ready donne 503. Le journal indique model not loaded: the model was saved with scikit-learn 1.9.1, this server has (modèle non chargé : enregistré avec scikit-learn 1.9.1, ce serveur utilise) une version plus récente.
Cause : un modèle scikit-learn ne peut être chargé de façon sûre qu’avec la version qui l’a enregistré. Une nouvelle version peut changer la structure interne des objets du modèle. L’API l’a refusé, comme prévu.
Solution :
- Pour garder ce modèle, revenez aux versions fixées :
python -m pip install -r requirements-lock.txt. - Pour utiliser la nouvelle version de scikit-learn, entraînez de nouveau le modèle avec elle :
python training/train.py. Faites ensuite la modification révisée de l’étape 3 : nouvelle version, nouvelle empreinte, nouvelles réponses de référence. Changez ensuite ensemble les versions fixées danspyproject.toml,requirements.txtet le fichier de verrouillage. - Ne masquez jamais l’avertissement. Un modèle chargé avec un avertissement peut donner de mauvaises réponses sans aucune erreur.
Partie 8 : une collection Postman
Section intitulée « Partie 8 : une collection Postman »pytest teste le code en mémoire. La collection Postman teste une API en marche avec de vraies requêtes HTTP. Le même fichier vérifie donc votre ordinateur, puis le service déployé, comme dans Vérifier et nettoyer. La collection du projet de départ, postman/house-price-api.postman_collection.json, contient deux requêtes dans le dossier health (santé). Elle contient aussi les tests du cours définis au niveau de la collection. Chaque réponse est en JSON et possède un en-tête X-Request-ID. Chaque erreur respecte la structure standard.
Importez-la dans Postman (Import (importer), puis le fichier), avec postman/local.postman_environment.json. Ajoutez un dossier estimates (estimations) avec ces sept requêtes. Le corps de chaque requête se trouve dans requests/ :
| Requête | Méthode et chemin | Corps | Tests |
|---|---|---|---|
| Décrire le modèle | GET {{baseUrl}}/v1/model |
aucun | 200; model_version vaut kc-hgb-2015-02; not_for mentionne les prêts |
| Estimer une maison typique | POST {{baseUrl}}/v1/estimates |
typical-house.json |
200; 422 000; intervalle de 373 000 à 540 000; aucun avertissement; moins de 500 ms |
| Estimer une maison au bord de l’eau | POST .../v1/estimates |
waterfront-house.json |
200; les avertissements incluent high_estimate |
| Estimer trois maisons à la fois | POST .../v1/estimates/batch |
three-houses.json |
200; estimations de 422 000, 2 156 000 et 374 000, dans cet ordre |
| Rejeter une maison hors de King County | POST .../v1/estimates |
outside-king-county.json |
422; outside_model_area; champs lat et long |
| Rejeter un nombre de chambres écrit en texte | POST .../v1/estimates |
bedrooms-as-text.json |
422; invalid_request; champ bedrooms |
| Rejeter une clé manquante | POST .../v1/estimates |
typical-house.json |
Autorisation : No Auth (aucune authentification). 401; unauthorized |
L’autorisation de la collection envoie X-API-Key: {{apiKey}} avec chaque requête, sauf celle réglée sur No Auth. Exportez la collection dans postman/, comme dans Tests et exécutions reproductibles. Le projet terminé contient le fichier complet, si vous voulez comparer vos tests.
Exécutez-la depuis le terminal contre l’API en mode production avec une clé, comme elle fonctionnera sur le serveur :
API_KEY=local-dev-key SHOW_DOCS=false fastapi run$env:API_KEY = "local-dev-key"$env:SHOW_DOCS = "false"fastapi runAPI_KEY=local-dev-key SHOW_DOCS=false fastapi runpostman collection run postman/house-price-api.postman_collection.json -e postman/local.postman_environment.json --env-var "apiKey=local-dev-key"L’outil en ligne de commande de Postman peut d’abord vous demander de vous connecter. La sortie peut commencer par No authorization data found (aucune donnée d’autorisation trouvée), puis Running your collection... (exécution de votre collection…). Vous pouvez alors ignorer cette ligne. L’outil exécute un fichier local sans compte, comme dans Tests et exécutions reproductibles. Si l’outil s’arrête plutôt, connectez-vous une fois, puis relancez la même commande :
postman loginpostman login vous connecte à votre compte Postman. La commande ouvre votre navigateur ou demande une clé d’API Postman. Suivez ses instructions.
Running your collection...
House Price Estimate API
Folder healthSub-folder Check that the service runs
GET http://127.0.0.1:8000/health [200 OK, 168 B, 36 ms] Pass response is JSON Pass has a request ID Pass status is 200...| requests | 9 | 0 || assertions | 44 | 0 |Ce que cela nous apprend. Neuf requêtes et 44 vérifications réussissent avec de vraies requêtes HTTP, et la clé est réellement vérifiée. Le même fichier, avec l’environnement Deployed (déployé), sert de vérification rapide après le déploiement. Gardez l’API en marche pour la prochaine partie.
Partie 9 : déployer l’API
Section intitulée « Partie 9 : déployer l’API »Le déploiement suit Publier en toute sécurité et Vérifier et nettoyer. Une API de modèle ajoute trois questions. Le fichier du modèle arrive-t-il au serveur? Les versions des paquets y sont-elles les mêmes? Le serveur est-il assez puissant?
Avant le déploiement : ce qui va au serveur
Section intitulée « Avant le déploiement : ce qui va au serveur »fastapi deploy envoie le dossier du projet. La commande exclut toujours .venv, __pycache__, .git et tous les fichiers .env. Elle lit .gitignore uniquement dans un dépôt Git. Nous l’avons vérifié avec fastapi-cloud-cli 0.26.0. Dans un dossier sans Git, elle sélectionnait aussi data/, même si .gitignore l’exclut. Elle lit aussi .fastapicloudignore, qui utilise la même syntaxe, avec ou sans Git.
Le serveur a besoin du code, du modèle et de pyproject.toml. Il n’a pas besoin des tests, du script d’entraînement, des données téléchargées ni des fichiers Postman. Créez .fastapicloudignore dans le dossier du projet :
# Files that the server does not need. fastapi deploy leaves them out,# with or without Git.data/training/tests/postman/requests/scripts/contract/Avec ce fichier, le filtre de l’outil sélectionne 17 fichiers, soit 0,55 Mo. Ce sont house_price_api/, model/house_price.joblib, model/model_card.json, pyproject.toml, le README, la fiche du modèle, la licence et les deux fichiers de dépendances. Sans ce fichier, il sélectionne 36 fichiers, soit 1,34 Mo. Vérifiez trois autres points :
-
Le modèle ne doit pas être ignoré. Si
.gitignoreou un paramètre Git global de votre ordinateur contient*.jobliboumodel/, le déploiement n’inclut aucun modèle. Le point/readydéployé indique alorsmodel_unavailable(modèle indisponible). Le journal indiqueno model file at ...(aucun fichier de modèle à…). Dans un dépôt Git, exécutezgit check-ignore -v model/house_price.joblib: la commande ne doit rien afficher. -
Un seul paquet, nommé. Le serveur construit votre projet comme un paquet Python. Ce projet a deux dossiers à la racine :
house_price_api/(le code) etmodel/(les fichiers de données). L’outil de construction ne peut pas deviner lequel est le paquet. Notre premier déploiement réel a échoué ici avecPython package discovery failed: The package builder found multiple top-level packages(détection du paquet Python échouée : plusieurs paquets trouvés à la racine). La solution tient en trois lignes danspyproject.toml. Le projet de départ les contient déjà, donc vous ne verrez pas cette erreur. Pour la voir, retirez temporairement les trois lignes et exécutezpip wheel . --no-deps, puis remettez-les. Voici les lignes :pyproject.toml [tool.setuptools]# The project has two top-level folders, house_price_api/ (the code) and# model/ (data files). Name the package, so the build does not guess.packages = ["house_price_api"] -
Les versions sur le serveur. Le serveur installe les paquets à partir de
pyproject.toml. Le projet de départ y fixe les cinq versions :fastapi[standard]==0.142.2,scikit-learn==1.9.1,numpy==2.5.3,scipy==1.18.1etjoblib==1.6.0. Sans ces versions fixées, le serveur installerait la version la plus récente de scikit-learn. La vérification de version de la partie 2 empêcherait alors le service d’être prêt. La vérification fonctionnerait correctement, mais il vaut mieux éviter cette situation.
Le projet contient un script, scripts/check_deployment.py, pour la vérification après le déploiement. Exécutez-le maintenant contre votre API locale, toujours en marche depuis la partie 8, pour voir ce qu’il fait. Il lit la clé dans une variable d’environnement, jamais dans la ligne de commande :
export HOUSE_API_KEY=local-dev-keypython scripts/check_deployment.py http://127.0.0.1:8000$env:HOUSE_API_KEY = "local-dev-key"python scripts/check_deployment.py http://127.0.0.1:8000export HOUSE_API_KEY=local-dev-keypython scripts/check_deployment.py http://127.0.0.1:80001. The first request (includes the start of a sleeping app) PASS GET /health is 200 (2 ms)2. Readiness and the model PASS GET /ready is 200 ({"status": "ready"}) PASS GET /v1/model is 200 PASS the server runs the reviewed model file (kc-hgb-2015-02)3. Golden houses PASS typical: 422,000 USD (got 422000, 2 ms) PASS waterfront: 2,156,000 USD (got 2156000, 2 ms) PASS large_lot: 374,000 USD (got 374000, 2 ms)4. Wrong requests PASS no key gives 401 (unauthorized) PASS New York gives 422 outside_model_area PASS bedrooms as text gives 422 invalid_request PASS 101 houses give 422 INFO GET /docs is 404 (404 when SHOW_DOCS=false)5. Speed (one connection, kept open) 50 single estimates: median 2 ms, 90th percentile 2 ms, slowest 2 ms PASS a batch of 100 gives 100 estimates 3 batches of 100 houses: median 3 ms
All checks passed.Ce que cela nous apprend. Le script effectue les mêmes vérifications que la collection Postman, plus deux autres. Il compare l’empreinte que le serveur fournit dans /v1/model à celle de votre propre model/model_card.json. Vous savez donc que le serveur utilise le modèle révisé, pas seulement un modèle. Il mesure aussi la vitesse. Sur votre ordinateur, une estimation prend environ 2 ms. Sur un serveur avec 0,1 processeur et une connexion Internet entre les deux, prévoyez beaucoup plus. Arrêtez l’API.
Le déploiement
Section intitulée « Le déploiement »Voici les étapes du cours, avec les noms de ce projet. L’application et sa clé sont créées avant le premier déploiement. La première version en marche est donc déjà protégée.
-
Connectez-vous :
fastapi login. -
Créez l’application et liez-y le dossier :
fastapi cloud apps create --name house-price-api --link. Si le nom est pris, ajoutez votre nom, par exemplehouse-price-api-yourname. La commande vous demande de choisir une équipe, même si vous n’en avez qu’une. Pour répondre sans cette question, trouvez l’identifiant de l’équipe avecfastapi cloud teams listet ajoutez--team-id <ID>. -
Créez une nouvelle clé et enregistrez-la comme secret, sans la saisir :
Fenêtre de terminal KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")printf %s "$KEY" | fastapi cloud env set API_KEY --secret --value-stdin --no-redeployfastapi cloud env set SHOW_DOCS false --no-redeployecho "$KEY"Fenêtre de terminal $key = python -c "import secrets; print(secrets.token_urlsafe(32))"$key | fastapi cloud env set API_KEY --secret --value-stdin --no-redeployfastapi cloud env set SHOW_DOCS false --no-redeploy$keyFenêtre de terminal KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")printf %s "$KEY" | fastapi cloud env set API_KEY --secret --value-stdin --no-redeployfastapi cloud env set SHOW_DOCS false --no-redeployecho "$KEY"Copiez la clé affichée par la dernière commande, puis fermez ce terminal.
-
Ouvrez un nouveau terminal, activez l’environnement et déployez :
fastapi deploy. Notez l’adresse affichée, qui se termine par.fastapicloud.dev. -
Lisez le journal de démarrage :
fastapi cloud logs --no-follow --since 10m. Trouvez la lignemodel kc-hgb-2015-02 loaded in ... ms, memory ... MB(modèle chargé en… ms, mémoire… Mo). Si elle indiquemodel not loaded(modèle non chargé), lisez la raison et consultez le cas d’échec ci-dessous.
Vérifier le déploiement
Section intitulée « Vérifier le déploiement »Donnez la clé au terminal sans la saisir dans une commande, comme dans Vérifier et nettoyer. Exécutez ensuite le script de vérification et la collection Postman contre l’adresse. Remplacez l’adresse par la vôtre :
read -s HOUSE_API_KEY && export HOUSE_API_KEYpython scripts/check_deployment.py https://house-price-api.fastapicloud.devpostman collection run postman/house-price-api.postman_collection.json -e postman/deployed.postman_environment.json --env-var "baseUrl=https://house-price-api.fastapicloud.dev" --env-var "apiKey=$HOUSE_API_KEY"$env:HOUSE_API_KEY = Read-Host "API key"python scripts/check_deployment.py https://house-price-api.fastapicloud.devpostman collection run postman/house-price-api.postman_collection.json -e postman/deployed.postman_environment.json --env-var "baseUrl=https://house-price-api.fastapicloud.dev" --env-var "apiKey=$env:HOUSE_API_KEY"read -s HOUSE_API_KEY && export HOUSE_API_KEYpython scripts/check_deployment.py https://house-price-api.fastapicloud.devpostman collection run postman/house-price-api.postman_collection.json -e postman/deployed.postman_environment.json --env-var "baseUrl=https://house-price-api.fastapicloud.dev" --env-var "apiKey=$HOUSE_API_KEY"Le script s’exécute d’abord, et c’est voulu. Si l’application était arrêtée faute d’utilisation (scale to zero (réduction à zéro)), la première requête du script la réactive. Le temps de cette requête montre la durée du démarrage : le cold start (démarrage à froid). Le test « moins de 500 ms » de la collection mesure ensuite une application déjà démarrée.
Provoquez ensuite un échec contrôlé sur le serveur, comme le cours l’a fait avec CLASSIFIER_MODE. Faites pointer l’application vers un fichier de modèle inexistant. Définir une variable redéploie l’application :
fastapi cloud env set MODEL_PATH /tmp/no-model.joblibQuand le nouveau déploiement est prêt, /health donne 200 et /ready donne 503 model_unavailable. Le journal indique no model file at /tmp/no-model.joblib (aucun fichier de modèle à /tmp/no-model.joblib). Supprimez le paramètre pour redéployer le modèle normal, puis relancez le script de vérification :
fastapi cloud env delete MODEL_PATH --yesSans --yes, la commande demande une confirmation. Dans un script, elle s’arrête avec Aborted. (annulé) sans rien supprimer. Notre essai réel l’a montré. L’application continuait de pointer vers le modèle manquant jusqu’à ce que nous relancions la commande avec --yes.
Nettoyer
Section intitulée « Nettoyer »- Ouvrez le tableau de bord de FastAPI Cloud, sélectionnez l’application et supprimez-la dans ses paramètres. L’outil en ligne de commande ne permet pas de supprimer une application.
- Dans le dossier du projet, supprimez le lien :
fastapi cloud unlink. - Supprimez la clé de votre terminal (
unset HOUSE_API_KEY, ouRemove-Item Env:HOUSE_API_KEYsous Windows) et de l’environnement Postman Deployed. - Vérifiez que l’adresse ne répond plus
200:curl -i https://house-price-api.fastapicloud.dev/health.
Erreur courante
Déploiement réussi, mais /ready donne 503 : aucun fichier de modèle
Symptôme : le déploiement réussit et /health donne 200, mais /ready donne 503 model_unavailable. Le journal indique model not loaded: no model file at (modèle non chargé : aucun fichier de modèle à) un chemin qui se termine par model/house_price.joblib.
Cause : le fichier du modèle n’est pas arrivé au serveur, car une règle l’a ignoré. La règle peut se trouver dans .gitignore (dans un dépôt Git), dans un paramètre Git global ou dans .fastapicloudignore. Un modèle de fichier .gitignore contenant *.joblib ou *.pkl est la cause habituelle.
Solution :
- Exécutez
git check-ignore -v model/house_price.joblib. La commande affiche le fichier et la ligne qui ignore le modèle. Supprimez cette ligne ou ajoutez!model/house_price.joblibà.fastapicloudignore. - Déployez de nouveau avec
fastapi deployet vérifiez/ready. - Ne « corrigez » pas le problème en supprimant la vérification de l’empreinte. Cette vérification a trouvé un vrai problème.
Test de déploiement en ligne
Section intitulée « Test de déploiement en ligne »Nous avons déployé le projet terminé une fois sur FastAPI Cloud, selon les étapes de la partie 9. Nous avons utilisé le forfait gratuit Hobby (0,1 CPU, 500 Mio par copie de l’application). Puis nous l’avons supprimé. Voici les résultats réels, mesurés depuis l’ordinateur de test par Internet.
| Mesure | Résultat |
|---|---|
Premier fastapi deploy |
Échec : Python package discovery failed (échec de la détection des paquets Python). Le projet avait deux dossiers à la racine. Correction avec [tool.setuptools] packages = ["house_price_api"], comme dans la partie 9. |
Deuxième fastapi deploy |
Téléversement de 515 Ko, compilation et affichage de l’adresse en environ une minute. |
| Journal de démarrage | model kc-hgb-2015-02 loaded in 198 ms, memory 179 MB (modèle chargé en 198 ms, mémoire de 179 Mo) : environ le tiers de la limite de 500 Mio. |
| Script de vérification | Toutes les vérifications ont réussi. Le serveur exécute le fichier de modèle révisé. |
| Une estimation, après démarrage (50 requêtes) | Médiane de 44 ms, 90e centile de 67 ms, maximum de 127 ms. Le journal du serveur indique 3 ms pour chaque requête; le reste vient du réseau. |
| Un lot de 100 maisons, après démarrage | Médiane de 62 ms. |
| Collection Postman, environnement Deployed (déployé) | 9 requêtes, 44 assertions, 0 échec. |
Échec contrôlé avec MODEL_PATH |
/health 200; /ready et POST /v1/estimates 503 model_unavailable (modèle indisponible). Après env delete MODEL_PATH --yes, /ready renvoyait de nouveau 200 en moins de 30 secondes. |
| Démarrage à froid | Non mesuré. Après 20 minutes sans requêtes, l’application fonctionnait toujours : la première requête a pris 0,22 s. Le journal ne montrait aucun nouveau démarrage. La plateforme ne documente pas quand elle arrête une application inactive. Après un changement de paramètres, le redémarrage était prêt en moins de 30 secondes. Le modèle s’est chargé en 193 ms. |
| Nettoyage | Application supprimée dans le tableau de bord. L’équipe ne listait ensuite aucune application, et l’adresse répondait 404. |
Ce que cela nous apprend. Le modèle tient dans la plus petite instance, avec de la marge. Le serveur répond en quelques millisecondes. Le réseau représente la majeure partie de l’attente. Un lot de 100 maisons prend donc à peu près autant de temps qu’une seule maison. L’exécution en ligne a aussi révélé deux problèmes qu’aucun test local ne pouvait trouver. La compilation ne pouvait pas deviner le paquet. Une commande demandait une confirmation sans l’indiquer. Ces deux problèmes figurent maintenant dans la partie 9. Voilà pourquoi le cours comprend un vrai déploiement et ne s’arrête pas aux tests locaux.
Utilisation responsable
Section intitulée « Utilisation responsable »Une API de prix semble neutre : des nombres entrent, un nombre sort. Elle ne l’est pas, à cause de l’utilisation de ses réponses. Les conditions de Daniel font partie du produit, pas de la paperasse.
Pas pour prendre des décisions sur des personnes. L’estimation est un premier chiffre pour une équipe d’évaluation immobilière. Elle ne doit pas déterminer si une personne obtient un prêt, à quel taux, ni sa prime d’assurance. Aux États-Unis, les décisions de prêt et d’assurance relèvent de lois comme le Fair Housing Act et l’Equal Credit Opportunity Act. Un modèle utilisé à ces fins exige une validation que celui-ci n’a pas reçue. L’API le précise à quatre endroits : la liste not_for (usages exclus) de la fiche, la description du point de terminaison dans le contrat, le README et MODEL_CARD.md.
Le lieu peut représenter les personnes. Le modèle utilise la latitude et la longitude. Aux États-Unis, l’emplacement d’une maison est corrélé au revenu et à la race de ses habitants. Cela vient d’une longue histoire de ségrégation et de discrimination dans les prêts. Un modèle peut apprendre ces tendances à partir des prix, sans aucune colonne sur les personnes. L’analyste a trouvé des erreurs semblables dans trois grandes zones du comté. C’est une première vérification, pas une analyse d’équité. Un vrai déploiement exige cette analyse, avec de meilleures données, par des personnes qualifiées.
Les prix sont anciens. Le modèle connaît les prix de 2014–2015, et chaque réponse indique prices_as_of: 2015-02-28 (prix en date du 28 février 2015). Pour les prix d’aujourd’hui, il se tromperait bien au-delà de son erreur mesurée.
Ce que l’API fait à ce sujet :
- Elle refuse ce que le modèle ne connaît pas (
invalid_request(requête invalide),outside_model_area(hors de la zone du modèle)), au lieu de deviner. - Elle fournit un intervalle et des avertissements pour que personne ne prenne un seul nombre pour un fait.
- Elle nomme le modèle dans chaque réponse pour permettre de retracer et de réviser chaque estimation.
- Elle ne consigne aucune description de maison. Le journal contient la méthode, le chemin, le statut, le temps et l’identifiant de requête. Aucune entrée ne permet d’identifier une personne ou une adresse.
- Elle charge uniquement le fichier de modèle révisé.
Ce qu’elle ne fait pas, mais qu’un service en production devrait faire : limiter les requêtes par client et comparer les estimations aux évaluations immobilières ultérieures. La fiche du modèle donne la règle : arrêter si moins de 45 % des estimations du mois précédent étaient à moins de 10 % des valeurs réelles. Un service en production doit aussi consigner qui a demandé quelle estimation, selon les règles de données du prêteur.
Ajoutez-le à votre portfolio
Section intitulée « Ajoutez-le à votre portfolio »Ce projet montre à un employeur que vous pouvez mettre un modèle en production de façon responsable. Ce n’est pas seulement un point de terminaison : il comprend les vérifications, la gestion des versions, les tests et les limites d’utilisation. Publiez-le dans un dépôt, sous votre nom.
Quoi mettre dans le dépôt :
house-price-api/ README.md what it is, how to run and check it, the data and its licence MODEL_CARD.md training window, test results, intended use, what it is not for LICENSE MIT, for the code pyproject.toml the pinned versions (requirements-lock.txt for a local set-up) house_price_api/ the API: estimator.py, models.py, routes.py, errors.py, main.py model/ house_price.joblib and model_card.json, both kept in Git training/train.py rebuilds the model, byte for byte, from the public data tests/ the 49 tests, with golden_houses.json contract/openapi.json the reviewed contract postman/ the collection, and its two environments without keys scripts/ check_deployment.pyPour le publier :
- Partez de votre dossier de projet ou du projet terminé. Vérifiez que
.gitignorecontient.venv/etdata/. - Créez le dépôt :
git init, puisgit add .etgit commit -m "House price estimate API". - Vérifiez avec
git ls-filesquemodel/house_price.joblibetmodel/model_card.jsony figurent, mais aucun fichier.env. - Envoyez-le sur GitHub comme dépôt public. Gardez le fichier
LICENSE(MIT) : il permet aux autres de réutiliser votre code. Les données gardent leur propre licence (CC0), précisée dans le README. - Réécrivez le premier paragraphe du README dans vos mots. Ajoutez les résultats de vos vérifications : le nombre de tests et l’exécution Postman. Si vous avez déployé, ajoutez les mesures du démarrage à froid et de la vitesse.
Le README est la page que la plupart des gens lisent. Un modèle de README :
# House price estimate API
A FastAPI service that gives a first estimate of house prices in King County, WA,with an 80% range, the model version and warnings. Not for lending decisions.
- Model: gradient boosting on 2014-15 public sales (CC0); test MAPE 12.8%, 50% within 10%.- Safety: loads the model only after a SHA-256 check; refuses inputs outside the training range and houses outside the county; 503 (not a crash) when the model is unavailable.- Versioning: model_version in every answer; a reviewed OpenAPI contract with a test; training is reproducible byte for byte.- Tests: 49 pytest tests (golden houses, limits, failures), a Postman collection, a deployment check script.Comment le décrire en entrevue, en deux phrases : « J’ai créé un service FastAPI pour un modèle de prix des maisons, avec des limites d’entrée tirées des données d’entraînement, une vérification de l’empreinte du fichier de modèle et un intervalle de prix tiré des erreurs de test du modèle. Il comprend 49 tests, dont des cas de référence qui détectent tout changement dans les réponses du modèle, et je l’ai déployé avec une clé secrète et un test de bon fonctionnement. »
Pour aller plus loin :
- Un point de terminaison de surveillance. Stockez chaque estimation avec son identifiant de requête et la version du modèle. Ajoutez la valeur de l’évaluation immobilière quand elle arrive. Chaque mois, indiquez la proportion des estimations à moins de 10 % de cette valeur.
- Deux modèles côte à côte. Chargez un nouveau modèle à côté de l’ancien. Répondez avec l’ancien et consignez les deux estimations pendant un mois. Comparez-les avant de changer de modèle. C’est un déploiement en parallèle (shadow deployment).
- Une deuxième ville. Entraînez le même modèle sur un autre jeu de données de ventes ouvert. Servez-le avec son propre
model_version(version du modèle) et sa vérification de zone. Comparez ce qui se transfère. - Une API de risque de défaut de crédit avec des raisons. Un prêteur peut aussi vouloir un score de risque de défaut avec les principales raisons pour chaque demandeur. Par exemple : une régression logistique et un point de terminaison qui liste les entrées ayant le plus influencé le score. C’est un cas beaucoup plus difficile à traiter de façon responsable. Il prend des décisions sur des personnes et relève des lois sur le crédit mentionnées plus haut. Aux États-Unis, les prêteurs doivent aussi donner aux demandeurs les principales raisons d’un refus. Créez-le uniquement comme projet d’étude, avec des données de recherche publiques. Ne le présentez jamais comme prêt pour de vraies décisions.
Vérification des connaissances
Section intitulée « Vérification des connaissances »Vérifiez votre compréhension
L'empreinte du fichier de modèle se trouve dans config.py, dans le code. Pourquoi ne pas la mettre dans une variable d'environnement du serveur, plus facile à modifier?
Afficher la réponse
La vérification protège contre un fichier que personne n’a révisé, y compris un fichier modifié par quelqu’un ayant accès au serveur. Une personne qui peut modifier les fichiers du serveur peut généralement aussi modifier ses variables d’environnement. Elle pourrait alors y mettre l’empreinte de son propre fichier. Dans le code, l’empreinte attendue change seulement lors d’une modification révisée, avec la version du modèle et les réponses de référence. Ainsi, une personne révise toujours un nouveau modèle. Les tests peuvent quand même fournir une autre empreinte dans leurs paramètres, car ils ne s’exécutent jamais sur le serveur.
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
Elif demande : l'intervalle va de 373 000 à 540 000 dollars, et l'estimation est de 422 000. Pourquoi l'intervalle n'est-il pas centré sur l'estimation? Peut-elle être certaine qu'une maison se vendra dans cet intervalle?
Afficher la réponse
L’intervalle vient des erreurs du modèle sur les mois de test. Parmi les maisons de test, 80 % se sont vendues entre 0,884 et 1,279 fois leur estimation. L’intervalle s’étend davantage au-dessus de l’estimation parce que les prix ont augmenté au printemps 2015, après la dernière vente connue du modèle. Les prix réels étaient donc plus souvent au-dessus de l’estimation qu’en dessous. Pour des maisons semblables aux ventes de test, environ 8 sur 10 se vendent dans l’intervalle. C’est un taux mesuré, pas une promesse pour une maison. Pour le marché actuel, qui a beaucoup changé depuis 2015, l’intervalle ne dit rien.
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
L'analyste réentraîne le modèle avec les ventes de mars. Quels fichiers changent, quels tests échouent avant leur mise à jour, et un client doit-il modifier son code?
Afficher la réponse
Le fichier de modèle et model_card.json changent, avec un nouveau model_version et une nouvelle empreinte. Il faut modifier MODEL_SHA256 dans config.py, sinon l’API refuse le fichier. Le test d’empreinte, les tests de référence et le test du contrat échouent jusqu’à la mise à jour de l’empreinte, de golden_houses.json et de l’exemple EstimateOut. Le test du contrat échoue parce que les nombres de l’exemple documenté changent. Aucun client ne modifie son code : chaque champ, type et limite reste identique. Les clients voient un nouveau model_version dans les réponses, et c’est le but.
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 fichier de modèle est du code : joblib utilise pickle, qui peut exécuter n’importe quoi au chargement. Comparez le SHA-256 du fichier à l’empreinte révisée dans le code avant de le charger. Traitez un avertissement de version de scikit-learn comme un état « non prêt ».
- Une vérification de modèle échouée rend l’API non prête (
503 model_unavailable), pas hors service :/healthreste à200, et le journal explique pourquoi. - Un modèle répond à tout; l’API décide donc ce qu’on peut lui demander. Elle utilise les limites des données d’entraînement, des types stricts et aucun code qui ressemble à un nombre. Les lieux inconnus du modèle reçoivent leur propre erreur,
outside_model_area. - Un nombre qui semble exact induit en erreur. La réponse contient un intervalle tiré des erreurs de test du modèle, des valeurs arrondies,
prices_as_of, des avertissements et lemodel_versionutilisé. - Un réentraînement change la version du modèle et les valeurs des exemples, pas le contrat. Renommer un champ exige
/v2. Les tests de référence et le test du contrat distinguent les deux. - Le lot a créé un conflit entre une limite et une promesse. La limite du corps découle maintenant de la plus grande requête valide. Un seul fil d’exécution OpenMP a accéléré chaque estimation.
- Sur le serveur, fixez les versions des paquets et assurez-vous de téléverser le fichier de modèle. Définissez d’abord la clé comme secret. Vérifiez avec le script de vérification et la collection, puis supprimez l’application.
Ensuite, la deuxième étude de cas : une API en lecture seule sur une vraie base de données, avec filtres, pagination stable et statistiques quotidiennes.
Leçon suivante : Créer une API de données sur les demandes de service municipal