Compare commits

3 Commits

Author SHA1 Message Date
Johan LEROY
e063effc5d TP3 : synthese (reponses aux questions) + README 2026-07-22 11:22:50 +02:00
Johan LEROY
b0bd6cdb07 TP3 Parties 2-4 : API REST FastAPI (health, predict, batch, erreurs 404) 2026-07-22 11:22:50 +02:00
Johan LEROY
9a52395c91 TP3 Partie 1 : log_model + Model Registry + promotion par alias 2026-07-22 11:22:50 +02:00
14 changed files with 616 additions and 0 deletions

View File

@@ -11,5 +11,12 @@ MLFLOW_EXPERIMENT_NAME=tp02_electricity_consumption
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
# --- S3 Garage : artefacts MLflow (TP03) ---
# Necessaire pour log_model (upload de l'artefact) ET pour le chargement du modele
# par l'API (download depuis s3://mlflow-artifacts). Cle S3 "mlflow" (RWO sur le bucket).
AWS_ACCESS_KEY_ID=GKxxxxxxxxxxxxxxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY=change-me
MLFLOW_S3_ENDPOINT_URL=https://garage.192-168-122-143.nip.io
# --- Import du package lab --- # --- Import du package lab ---
PYTHONPATH=/home/user/tp PYTHONPATH=/home/user/tp

View File

@@ -56,3 +56,49 @@ Resultats et comparaisons : https://mlflow.192-168-122-143.nip.io (experience `t
## Livrable ## Livrable
Synthese des resultats et reponses aux questions : `SYNTHESE.md`. Synthese des resultats et reponses aux questions : `SYNTHESE.md`.
---
# TP03 - Exposer un modele via une API REST (Model Registry + FastAPI)
Prolonge le TP02 : on enregistre le meilleur modele dans le **MLflow Model Registry**, on le
**promeut via un alias**, puis on l'expose par une **API REST FastAPI**.
## Prerequis (en plus du TP02)
`.env` complete avec les creds S3 Garage (voir `.env.example`) : `AWS_ACCESS_KEY_ID`,
`AWS_SECRET_ACCESS_KEY`, `MLFLOW_S3_ENDPOINT_URL`. Necessaires pour `log_model` (upload de
l'artefact) et pour le chargement du modele par l'API (download depuis `s3://mlflow-artifacts`).
## Enregistrer et promouvoir (Partie 1)
```bash
set -a; source .env; set +a
py -m lab.modeling.cli full --register # v1 -> Registry (champion vise)
py -m lab.modeling.cli mixed --register # v2 -> Registry (comparaison)
py -m lab.registry.cli versions # lister versions + alias
py -m lab.registry.cli promote --version 1 --alias champion
```
`log_model` est appele a **chaque** run (artefact sauvegarde) ; `--register` empile en plus une
version dans le Registry sous le nom `electricity-consumption`.
## Servir l'API (Parties 2 a 4)
```bash
./serve.sh # uvicorn 0.0.0.0:8000, charge models:/...@champion
curl -s localhost:8000/health # {"status":"ok"}
curl -s -X POST localhost:8000/predict \
-H 'content-type: application/json' -d '{"client_id":"MT_124"}'
curl -s -X POST localhost:8000/predict/batch \
-H 'content-type: application/json' -d '{"client_ids":["MT_124","MT_158"]}'
```
- Endpoints : `GET /health`, `POST /predict`, `POST /predict/batch`, Swagger `GET /docs`.
- Feature store **simule** par un dictionnaire (`lab/serving/features.py`) ; client inconnu -> **404**.
- Depuis le poste (cert ENI de confiance) : **https://api.192-168-122-143.nip.io/docs**
(reverse-proxy Caddy vers uvicorn). Unite systemd transitoire : `sudo systemctl status tp03-api`.
## Livrable TP03
Reponses aux questions et recap : `SYNTHESE_TP03.md`.

181
SYNTHESE_TP03.md Normal file
View File

@@ -0,0 +1,181 @@
# TP03 - Synthèse : exposer un modèle ML via une API REST
Fil rouge : prédiction de la consommation électrique (kWh). On sélectionne le meilleur modèle
du TP02, on l'enregistre dans le **MLflow Model Registry**, on le **promeut via un alias**, puis
on l'expose par une **API REST FastAPI**.
## Ce qui a été construit
- **`log_model`** ajouté à chaque run d'entraînement (`lab/modeling/cli.py`, `lab/modeling_ridge/cli.py`) :
sauvegarde l'artefact complet du modèle sur S3 (`s3://mlflow-artifacts`).
- **Modèle enregistré** : `electricity-consumption` (Registry MLflow). Versions créées avec le flag
`--register` : v1 = `full` (6 features, RMSE val 6.576), v2 = `mixed` (4 features, RMSE 6.671).
- **Promotion** : alias `champion` -> **v1** (`full`), via `lab/registry/cli.py promote`.
- **API FastAPI** (`lab/serving/`) : `GET /health`, `POST /predict`, `POST /predict/batch`, Swagger `/docs`.
Le modèle est chargé au démarrage par `models:/electricity-consumption@champion`.
- **Exposition** : `https://api.192-168-122-143.nip.io/docs` (reverse-proxy Caddy -> uvicorn `:8000`).
---
## Partie 1 - Sélection et enregistrement du modèle
**1.1 - Que contiennent les artefacts du modèle sauvegardé par MLflow, pourquoi sont-ils utiles ?**
Sauvegarder un modèle ne se limite pas à ses coefficients : MLflow enregistre tout l'environnement
d'exécution. Contenu observé (`s3://mlflow-artifacts/1/models/<id>/artifacts/`) :
- `model.pkl` : le modèle sérialisé (poids/coefficients) ;
- `MLmodel` : métadonnées (flavors `sklearn`/`pyfunc`, **signature** = schéma entrées/sorties) ;
- `requirements.txt`, `conda.yaml`, `python_env.yaml` : versions exactes des dépendances ;
- `input_example.json`, `serving_input_example.json` : exemple d'entrée.
Utiles pour **recharger le modèle partout** (`load_model`), **reproduire l'environnement** (mêmes
versions -> mêmes prédictions), et connaître le **contrat d'E/S** (signature).
**1.2 - Quel mécanisme vous permet de promouvoir un modèle ?**
L'**alias** du Model Registry : `MlflowClient().set_registered_model_alias(name, "champion", version)`
fait pointer un alias mobile vers une version précise. (Les anciens *stages* Staging/Production sont
dépréciés en MLflow 3.x au profit des alias + tags.)
**1.3 - Plusieurs environnements de production (un par région), plusieurs modèles : comment les identifier ?**
Avec des **alias** et **tags** spécifiques : par ex. des alias `production-eu`, `production-us`,
`champion-north`... sur un même modèle enregistré, et/ou un modèle enregistré par région, complétés par
des **tags** (région, environnement) portés par le modèle ou la version. Le Registry gère plusieurs
alias par modèle et des tags arbitraires : l'API cible alors `models:/<nom>@<alias-région>`.
---
## Partie 2 - Service de prédiction
### Étape 1 - Création de l'API
**2.1 - Quel endpoint de vérification ? Quel code de statut attendu ?**
`GET /health`, qui renvoie **200 OK** avec `{"status":"ok"}`.
**2.2 - À quoi sert un endpoint de santé ?**
À vérifier que le service est vivant et prêt (*liveness/readiness*), sans exécuter de vraie prédiction.
Il est sondé par l'orchestrateur / load-balancer / monitoring pour router le trafic, redémarrer un
conteneur en échec, ou alerter.
### Étape 2 - Requête de prédiction
**2.3 - Quelles informations le client doit-il fournir ? Pourquoi ?**
L'**identifiant client** (`client_id`) et, en paramètre, la **date** de prédiction. Ce sont les seules
informations du **contexte métier** que le client connaît : elles disent *qui* et *quand*. Elles servent
de clé pour retrouver le reste côté serveur.
**2.4 - Quelles informations le client ne peut-il pas fournir ? Comment le service les récupère-t-il ?**
Les **features calculées** (lags, moyennes glissantes) issues de l'historique de consommation : le
client ne les possède/calcule pas. Le service les **récupère lui-même** depuis un feature store / une
base, à partir du `client_id` (+ date). Ici, c'est **simulé par un dictionnaire Python** (`lab/serving/features.py`).
**2.5 - Quelle méthode HTTP pour le endpoint de prédiction ? Pourquoi ?**
**POST** : la requête transporte un **corps JSON structuré** (et potentiellement volumineux en batch),
et déclenche un **calcul** (action, non une simple lecture cacheable de ressource comme le ferait GET).
### Étape 3 - Récupération des features
**2.6 - Rappel des features nécessaires à la prédiction.**
Modèle promu = stratégie `full`, soit les **6 features** :
`lag_1d`, `lag_7d`, `lag_30d`, `lag_365d`, `rolling_mean_7d`, `rolling_mean_30d`.
**2.7 - Dans un système réel, d'où proviennent ces features ?**
D'un **feature store / pipeline de features** : un job (batch ou streaming) calcule lags et moyennes
glissantes depuis la série temporelle brute, les stocke dans une base (ex. Feast), et les sert à
l'inférence. Elles doivent être calculées **de façon identique à l'entraînement** (éviter le
*training/serving skew*).
**2.8 - Pourquoi séparer récupération des features et calcul de la prédiction ?**
**Séparation des responsabilités** : la source des features peut évoluer (BDD, cache, feature store)
sans toucher au modèle ; le modèle reste une **fonction pure** `features -> prédiction`, testable et
réutilisable. « L'application du modèle n'est qu'une étape de la chaîne de prédiction. »
**2.9 (Bonus) - Si les features ne peuvent pas être calculées/récupérées ?**
L'API ne doit pas planter : elle renvoie une erreur explicite. Client/features introuvables -> **404** ;
payload invalide -> 422 ; feature store indisponible (panne transitoire) -> **503**. Toujours un JSON
d'erreur clair.
### Étape 4 - Chargement du modèle depuis le Registry
**2.10 - Pourquoi un alias plutôt qu'un numéro de version ?**
L'alias (`champion`) est **stable et mobile** : l'API charge toujours `models:/electricity-consumption@champion`
et l'on **re-pointe l'alias** vers une nouvelle version sans modifier ni redéployer le code. Un numéro
de version est **figé** : chaque changement de modèle imposerait d'éditer la config et de redéployer.
L'alias **découple** « quel modèle est en prod » (décision côté MLflow) du code de service.
**2.11 - Avantages du Registry par rapport à un simple fichier modèle ?**
Versioning centralisé (historique de toutes les versions), **alias/tags**, **lignée** vers le run
d'entraînement (params/métriques), chargement par URI depuis n'importe où, workflow de promotion,
traçabilité/audit, environnement (requirements) attaché. Un simple fichier n'offre rien de tout cela
(pas d'historique, pas de métadonnées, distribution manuelle et fragile).
**2.12 (Bonus) - Si les requirements du modèle sont incohérents avec l'environnement de l'API ?**
Risque : incompatibilité de versions (scikit-learn, numpy) -> erreur de désérialisation ou écarts
numériques silencieux. **Architecture** : **isoler le modèle dans son propre runtime** construit à
partir de son `requirements.txt` (image conteneur dédiée par modèle, ex. `mlflow models build-docker` /
MLflow serving), l'API l'appelant via HTTP ; ou figer l'environnement de l'API depuis les requirements
du modèle. On **découple** l'API des dépendances du modèle.
### Étape 5 - Endpoint de prédiction
**2.13 - Étapes lorsqu'une requête de prédiction arrive.**
1. Valider le payload (schéma Pydantic). 2. Récupérer les features du client (404 si inconnu).
3. Assembler le vecteur de features **dans l'ordre attendu** par le modèle. 4. `model.predict`.
5. Formater et renvoyer la réponse JSON (200).
**2.14 - Quel format de réponse ? Quel statut HTTP ?**
JSON : `{client_id, prediction_kwh, model_name, model_version}`, statut **200 OK**.
---
## Partie 3 (Bonus) - Gestion des erreurs
**3.1 - L'application doit-elle échouer ou intercepter cette erreur ?**
**Intercepter.** Un `client_id` inconnu est une **erreur cliente** (mauvaise entrée), pas un bug
serveur : le service reste debout et renvoie une réponse d'erreur propre.
**3.2 - Quel code HTTP est adapté ?**
**404 Not Found** (la ressource/le client demandé n'existe pas). (422 si le payload lui-même est
malformé.) Implémenté via `HTTPException(status_code=404, ...)`.
---
## Partie 4 (Bonus) - Prédictions en batch
**4.1 - Un modèle doit-il forcément être exposé par API ? Dans quel cas ?**
Non. L'API (temps réel) convient aux prédictions **à la demande, individuelles, à faible latence**
(appli interactive). Pour de **gros volumes calculés périodiquement** (ex. tous les clients chaque
nuit), l'**inférence batch** (job planifié qui écrit les résultats en base) est plus adaptée et moins
coûteuse. On expose par API quand on a besoin de prédictions fraîches, unitaires et synchrones.
**4.2 - Quelles briques restent identiques entre batch et temps réel ?**
Le **modèle** (même artefact du Registry), la **logique/définition des features**, le **préprocessing**,
le **code de prédiction** (`features -> prédiction`). Ce qui diffère : le **déclencheur/orchestration**
(requête HTTP vs job planifié), les **E/S** (un JSON unitaire vs une table en masse) et le profil
latence/débit.
**4.3 - Comment récupérer les informations de prédiction ?**
En **masse** : lire les features de tous les clients pour la date depuis le feature store / une table
(BDD ou parquet), appeler `model.predict` sur le **lot entier** (vectorisé), puis écrire les résultats
en base/fichier. Ici, `POST /predict/batch` prend une liste de `client_ids` et renvoie la liste des
prédictions (+ `unknown_client_ids` pour les clients ignorés).
---
## Reproduire
Sur la VM, depuis `/home/user/tp` :
```bash
set -a; source .env; set +a # MLflow + creds S3 (Garage)
# Partie 1 : enregistrer + promouvoir
python -m lab.modeling.cli full --register # v1 (champion)
python -m lab.modeling.cli mixed --register # v2 (comparaison)
python -m lab.registry.cli versions # consulter les versions/alias
python -m lab.registry.cli promote --version 1 --alias champion
# Partie 2-4 : servir l'API
./serve.sh # uvicorn :8000 (Swagger /docs)
curl -s localhost:8000/health
curl -s -X POST localhost:8000/predict -H 'content-type: application/json' -d '{"client_id":"MT_124"}'
```
Depuis le poste (certificat ENI de confiance) : **https://api.192-168-122-143.nip.io/docs**.

View File

@@ -70,3 +70,18 @@ MODELLING_FEATURES: dict[ModellingStrategy, list] = {
# Valeurs d'alpha demandees par l'enonce (Partie 3) # Valeurs d'alpha demandees par l'enonce (Partie 3)
RIDGE_ALPHAS = [1, 1e3, 1e9] RIDGE_ALPHAS = [1, 1e3, 1e9]
# --- TP03 : Model Registry + service de prediction ---
# Nom sous lequel les modeles sont enregistres dans le MLflow Model Registry.
# On garde un nom stable pour retrouver le modele et empiler ses versions.
REGISTERED_MODEL_NAME = "electricity-consumption"
# Alias pointant vers la version promue (chargee par l'API). On identifie le modele
# a servir par son alias (mobile) plutot que par un numero de version (fige).
MODEL_ALIAS = "champion"
# Strategie de features du modele expose par l'API : "full" (les 6 features).
# L'ordre des colonnes servies doit correspondre a celui de l'entrainement.
SERVING_STRATEGY = ModellingStrategy.FULL
SERVING_FEATURES = MODELLING_FEATURES[SERVING_STRATEGY]

View File

@@ -3,6 +3,7 @@ import logging
import mlflow import mlflow
import pandas as pd import pandas as pd
import typer import typer
from mlflow.models import infer_signature
from sklearn import linear_model from sklearn import linear_model
from sklearn import metrics from sklearn import metrics
@@ -17,6 +18,11 @@ logger = logging.getLogger(__name__)
@app.command() @app.command()
def main( def main(
strategy: constants.ModellingStrategy, strategy: constants.ModellingStrategy,
register: bool = typer.Option(
False,
"--register/--no-register",
help="Enregistrer le modele dans le Model Registry (cree une nouvelle version).",
),
): ):
training_file_path = constants.DATASET_DIR / "train.parquet" training_file_path = constants.DATASET_DIR / "train.parquet"
validation_file_path = constants.DATASET_DIR / "validation.parquet" validation_file_path = constants.DATASET_DIR / "validation.parquet"
@@ -72,6 +78,24 @@ def main(
): ):
mlflow.log_metric(f"coef_{feature_name}", float(coefficient)) mlflow.log_metric(f"coef_{feature_name}", float(coefficient))
# Sauvegarde de l'artefact du modele (poids + signature + environnement
# d'execution : requirements.txt, conda.yaml, MLmodel). --register empile
# une nouvelle version dans le Model Registry pour les meilleures experiences.
signature = infer_signature(X_train, train_predictions)
mlflow.sklearn.log_model(
sk_model=model,
name="model",
signature=signature,
input_example=X_train.iloc[:5],
registered_model_name=(
constants.REGISTERED_MODEL_NAME if register else None
),
)
if register:
logger.info(
f"Model registered as '{constants.REGISTERED_MODEL_NAME}' (nouvelle version)"
)
if __name__ == "__main__": if __name__ == "__main__":
app() app()

View File

@@ -3,6 +3,7 @@ import logging
import mlflow import mlflow
import pandas as pd import pandas as pd
import typer import typer
from mlflow.models import infer_signature
from sklearn import linear_model from sklearn import linear_model
from sklearn import metrics from sklearn import metrics
@@ -17,6 +18,11 @@ logger = logging.getLogger(__name__)
@app.command() @app.command()
def main( def main(
strategy: constants.ModellingStrategy = constants.ModellingStrategy.MIXED, strategy: constants.ModellingStrategy = constants.ModellingStrategy.MIXED,
register: bool = typer.Option(
False,
"--register/--no-register",
help="Enregistrer chaque modele (par alpha) dans le Model Registry.",
),
): ):
training_file_path = constants.DATASET_DIR / "train.parquet" training_file_path = constants.DATASET_DIR / "train.parquet"
validation_file_path = constants.DATASET_DIR / "validation.parquet" validation_file_path = constants.DATASET_DIR / "validation.parquet"
@@ -74,6 +80,18 @@ def main(
): ):
mlflow.log_metric(f"coef_{feature_name}", float(coefficient)) mlflow.log_metric(f"coef_{feature_name}", float(coefficient))
# Sauvegarde de l'artefact du modele (voir lab/modeling/cli.py).
signature = infer_signature(X_train, train_predictions)
mlflow.sklearn.log_model(
sk_model=model,
name="model",
signature=signature,
input_example=X_train.iloc[:5],
registered_model_name=(
constants.REGISTERED_MODEL_NAME if register else None
),
)
if __name__ == "__main__": if __name__ == "__main__":
app() app()

0
lab/registry/__init__.py Normal file
View File

63
lab/registry/cli.py Normal file
View File

@@ -0,0 +1,63 @@
"""Gestion du MLflow Model Registry : consulter les versions et promouvoir via alias.
La promotion = pointer un alias (mobile) vers une version precise (figee) du modele.
L'API charge ensuite le modele par `models:/<nom>@<alias>` sans connaitre le numero.
"""
import logging
import typer
from mlflow import MlflowClient
from .. import constants
app = typer.Typer(help="MLflow Model Registry (versions + promotion par alias).")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@app.command()
def versions(
name: str = constants.REGISTERED_MODEL_NAME,
):
"""Lister les versions du modele enregistre, avec leurs alias."""
client = MlflowClient()
results = client.search_model_versions(f"name = '{name}'")
if not results:
logger.warning(f"Aucune version pour le modele '{name}'.")
raise typer.Exit(code=1)
# Les alias sont portes par le modele enregistre (dict alias -> version),
# pas par les objets renvoyes par search_model_versions.
alias_by_version: dict[str, list[str]] = {}
for alias, version in client.get_registered_model(name).aliases.items():
alias_by_version.setdefault(str(version), []).append(alias)
for mv in sorted(results, key=lambda v: int(v.version)):
aliases = ", ".join(alias_by_version.get(str(mv.version), [])) or "-"
run = client.get_run(mv.run_id) if mv.run_id else None
strategy = run.data.params.get("strategy", "?") if run else "?"
val_rmse = run.data.metrics.get("validation_rmse") if run else None
rmse_txt = f"{val_rmse:.3f}" if val_rmse is not None else "?"
typer.echo(
f"v{mv.version:<3} | alias: {aliases:<12} | strategy={strategy:<12}"
f" | validation_rmse={rmse_txt} | run={mv.run_id}"
)
@app.command()
def promote(
version: int = typer.Option(..., help="Numero de version a promouvoir."),
alias: str = typer.Option(constants.MODEL_ALIAS, help="Alias a (re)pointer."),
name: str = constants.REGISTERED_MODEL_NAME,
):
"""Promouvoir une version : (re)pointer l'alias vers cette version."""
client = MlflowClient()
client.set_registered_model_alias(name=name, alias=alias, version=str(version))
mv = client.get_model_version_by_alias(name=name, alias=alias)
logger.info(f"Alias '{alias}' -> {name} v{mv.version} (source: {mv.source})")
if __name__ == "__main__":
app()

0
lab/serving/__init__.py Normal file
View File

99
lab/serving/api.py Normal file
View File

@@ -0,0 +1,99 @@
"""API REST de prediction de consommation electrique (FastAPI).
Le service orchestre la chaine de prediction :
1. recevoir la requete (client_id, date) ;
2. recuperer les features du client (feature store simule) ;
3. charger le modele promu (Model Registry, au demarrage) ;
4. calculer la prediction et repondre en JSON.
Lancement : uvicorn lab.serving.api:app --host 0.0.0.0 --port 8000
Swagger UI : /docs
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from . import features, registry
from .schemas import (
BatchPredictionRequest,
BatchPredictionResponse,
PredictionRequest,
PredictionResponse,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Modele charge une fois au demarrage (couteux) et reutilise a chaque requete.
_state: dict[str, registry.LoadedModel] = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
_state["model"] = registry.load_champion()
yield
_state.clear()
app = FastAPI(
title="Electricity Consumption Prediction API",
description="Expose le modele promu (MLflow Model Registry) via une API REST.",
version="1.0.0",
lifespan=lifespan,
)
def _get_model() -> registry.LoadedModel:
model = _state.get("model")
if model is None: # modele indisponible au demarrage
raise HTTPException(status_code=503, detail="Modele non charge.")
return model
def _predict(client_id: str, model: registry.LoadedModel) -> PredictionResponse:
feats = features.get_features(client_id) # peut lever UnknownClientError
prediction = model.predict_one(feats)
return PredictionResponse(
client_id=client_id,
prediction_kwh=prediction,
model_name=model.name,
model_version=model.version,
)
@app.get("/health", summary="Verification de l'etat du service")
def health() -> dict[str, str]:
"""Endpoint de sante : renvoie 200 si le service repond."""
return {"status": "ok"}
@app.post("/predict", response_model=PredictionResponse, summary="Prediction unitaire")
def predict(request: PredictionRequest) -> PredictionResponse:
model = _get_model()
try:
return _predict(request.client_id, model)
except features.UnknownClientError:
# Partie 3 : client inconnu -> erreur cliente, pas un plantage du service.
raise HTTPException(
status_code=404,
detail=f"Client inconnu : aucune feature pour '{request.client_id}'.",
)
@app.post(
"/predict/batch",
response_model=BatchPredictionResponse,
summary="Prediction en batch (plusieurs clients)",
)
def predict_batch(request: BatchPredictionRequest) -> BatchPredictionResponse:
model = _get_model()
predictions: list[PredictionResponse] = []
unknown: list[str] = []
for client_id in request.client_ids:
try:
predictions.append(_predict(client_id, model))
except features.UnknownClientError:
unknown.append(client_id)
return BatchPredictionResponse(predictions=predictions, unknown_client_ids=unknown)

66
lab/serving/features.py Normal file
View File

@@ -0,0 +1,66 @@
"""Recuperation des features de prediction.
Dans un systeme reel, ces valeurs proviendraient d'un feature store / d'une base
alimentee par le pipeline de calcul de features (lags, moyennes glissantes) sur
l'historique de consommation. On separe volontairement cette etape du calcul de la
prediction : la source des features peut changer sans toucher au modele.
Ici on SIMULE cette recuperation par un simple dictionnaire Python, comme demande
par l'enonce. Les valeurs sont des observations reelles (derniere ligne connue de
quelques clients dans data/test.parquet).
"""
from .. import constants
class UnknownClientError(KeyError):
"""Aucune feature disponible pour ce client (identifiant inconnu)."""
# feature store simule : client_id -> {feature: valeur}
FEATURE_STORE: dict[str, dict[str, float]] = {
"MT_124": {
"lag_1d": 107.656,
"lag_7d": 25.120,
"lag_30d": 70.574,
"lag_365d": 25.120,
"rolling_mean_7d": 65.870,
"rolling_mean_30d": 71.310,
},
"MT_156": {
"lag_1d": 13.149,
"lag_7d": 13.929,
"lag_30d": 21.577,
"lag_365d": 8.935,
"rolling_mean_7d": 16.648,
"rolling_mean_30d": 19.720,
},
"MT_158": {
"lag_1d": 30.739,
"lag_7d": 16.608,
"lag_30d": 34.094,
"lag_365d": 6.574,
"rolling_mean_7d": 19.067,
"rolling_mean_30d": 21.486,
},
"MT_159": {
"lag_1d": 23.305,
"lag_7d": 24.741,
"lag_30d": 21.386,
"lag_365d": 5.333,
"rolling_mean_7d": 11.707,
"rolling_mean_30d": 13.619,
},
}
def get_features(client_id: str) -> dict[str, float]:
"""Renvoyer les features du client, ordonnees comme a l'entrainement du modele.
Leve UnknownClientError si le client est inconnu.
"""
if client_id not in FEATURE_STORE:
raise UnknownClientError(client_id)
raw = FEATURE_STORE[client_id]
# On respecte l'ordre des colonnes attendu par le modele (SERVING_FEATURES).
return {feature: raw[feature] for feature in constants.SERVING_FEATURES}

43
lab/serving/registry.py Normal file
View File

@@ -0,0 +1,43 @@
"""Chargement du modele promu depuis le MLflow Model Registry (par alias)."""
import logging
from dataclasses import dataclass
import mlflow
import pandas as pd
from .. import constants
logger = logging.getLogger(__name__)
@dataclass
class LoadedModel:
"""Modele charge + metadonnees de version, garde en memoire par l'API."""
model: mlflow.pyfunc.PyFuncModel
name: str
version: str
def predict_one(self, features: dict[str, float]) -> float:
"""Prediction pour un jeu de features (1 ligne)."""
frame = pd.DataFrame([features], columns=constants.SERVING_FEATURES)
return float(self.model.predict(frame)[0])
def load_champion() -> LoadedModel:
"""Charger la version pointee par l'alias `MODEL_ALIAS` du modele enregistre.
On identifie le modele par `models:/<nom>@<alias>` : le meme code sert n'importe
quelle version promue, sans redeploiement, en changeant seulement l'alias cote MLflow.
"""
name = constants.REGISTERED_MODEL_NAME
alias = constants.MODEL_ALIAS
uri = f"models:/{name}@{alias}"
logger.info(f"Chargement du modele {uri}")
client = mlflow.MlflowClient()
version = client.get_model_version_by_alias(name=name, alias=alias)
model = mlflow.pyfunc.load_model(uri)
logger.info(f"Modele charge : {name} v{version.version}")
return LoadedModel(model=model, name=name, version=str(version.version))

44
lab/serving/schemas.py Normal file
View File

@@ -0,0 +1,44 @@
"""Schemas Pydantic du service de prediction (contrat d'entree/sortie de l'API)."""
import datetime
from pydantic import BaseModel, Field
class PredictionRequest(BaseModel):
"""Ce que le client fournit : QUI et QUAND, pas les features (calculees cote serveur)."""
client_id: str = Field(
...,
description="Identifiant du client (ex. 'MT_124').",
examples=["MT_124"],
)
date: datetime.date | None = Field(
default=None,
description="Date de la prediction (parametre de requete). Optionnelle.",
examples=["2015-01-01"],
)
class BatchPredictionRequest(BaseModel):
"""Prediction pour plusieurs clients en une seule requete (inference batch)."""
client_ids: list[str] = Field(
...,
description="Liste d'identifiants clients.",
examples=[["MT_124", "MT_156", "MT_158"]],
)
date: datetime.date | None = None
class PredictionResponse(BaseModel):
client_id: str
prediction_kwh: float = Field(description="Consommation predite (kWh).")
model_name: str
model_version: str
class BatchPredictionResponse(BaseModel):
predictions: list[PredictionResponse]
# Clients ignores (features introuvables) : on ne fait pas echouer tout le lot.
unknown_client_ids: list[str] = Field(default_factory=list)

10
serve.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Lance l'API de prediction (TP03). A executer depuis la racine du depot (/home/user/tp).
# Charge .env (MLflow + creds S3 pour recuperer le modele promu depuis le Registry).
set -euo pipefail
cd "$(dirname "$0")"
set -a
# shellcheck disable=SC1091
source .env
set +a
exec /opt/venvs/mlops/bin/uvicorn lab.serving.api:app --host 0.0.0.0 --port 8000