Merge pull request #123 from ineszang/feat/entrainement-du-modele

feat(ml): enregistrer le modèle dans le MLflow Model Registry
This commit is contained in:
ValentinDeFaria
2026-09-22 15:21:24 +02:00
committed by GitHub
11 changed files with 165 additions and 3 deletions
+2 -1
View File
@@ -63,7 +63,8 @@ ml/models/*
!ml/models/.gitkeep
ml/mlruns/
ml/mlartifacts/
ml/mlflow.db
ml/mlflow.db*
ml/.env
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
etl/airflow/tests/.airflow_home/
+11 -1
View File
@@ -20,6 +20,8 @@ PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
ml-env-val = $(shell sed -n 's/^$(1)=//p' ml/.env 2>/dev/null | tail -1)
ML_ENV_DB_PASSWORD := $(call ml-env-val,MLFLOW_DB_PASSWORD)
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
@@ -35,7 +37,7 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
migrate bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
@@ -118,6 +120,14 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
mlflow-up: ## Démarre le serveur MLflow (tracking + registry) en conteneur. ml/.env requis
@test -n "$(strip $(ML_ENV_DB_PASSWORD))" \
|| { echo "MLFLOW_DB_PASSWORD absente de ml/.env (copier ml/.env.example)"; exit 1; }
@echo "$(ML_ENV_DB_PASSWORD)" | grep -qE '^[A-Za-z0-9]+$$' \
|| { echo "MLFLOW_DB_PASSWORD doit contenir uniquement lettres et chiffres (interpolee dans l'URI postgresql://)"; exit 1; }
cd $(ML) && docker compose -f docker-compose.mlflow.yml up -d --build
@echo "mlflow -> http://localhost:5000"
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
+29
View File
@@ -10,6 +10,7 @@ dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `En cours` |
| k3s single-node | Cible à terme | `En cours` |
| MLflow (`ml/`) | Tracker les expériences et le registre de modèles en local | `Fait`, non relié aux autres topologies |
## Poste de développement
@@ -148,6 +149,34 @@ est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans qu
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
construction de l'image.
### MLflow (`ml/`)
Statut : `Fait`, en local uniquement. Défini par `ml/docker-compose.mlflow.yml`, indépendant
du `docker-compose.yml` principal (réseau, volumes et démarrage séparés).
| Service | Image | Points notables |
|---|---|---|
| `mlflow-db` | `postgres:17` | Stocke le tracking store MLflow. Mot de passe obligatoire via `MLFLOW_DB_PASSWORD` |
| `mlflow` | Construite depuis `ml/` | Expose l'UI et l'API MLflow sur `127.0.0.1:5000`. Artefacts sur volume `mlflow-artifacts`, tracking store sur `mlflow-db` |
Portée actuelle : environnement de tracking et de registre de modèles pour le développement
local uniquement. Ce compose n'est relié ni à `docker-compose.prod.yml`, ni aux deux
environnements Compose de la VM ENI, ni à la cible k3s. Le magasin utilisé par Airflow pour
`ml_train`/`ml_score` (SQLite, volume `airflow_ml_state`) en est distinct — les deux MLflow ne
se voient pas tant que `MLFLOW_TRACKING_URI` n'est pas posé côté Airflow.
Limite connue : le DAG Airflow `ml_train` enregistre lui aussi une version a chaque execution
via `registered_model_name` (magasin SQLite du volume `airflow_ml_state`, distinct de ce
serveur). Versions et artefacts s'y accumulent sans politique de nettoyage -- fonctionne en
l'etat, mais a surveiller si les entrainements deviennent frequents.
Pour relier les runs Airflow (`ml_train`, magasin SQLite local) a ce serveur MLflow, positionner
`MLFLOW_TRACKING_URI=http://mlflow:5000` dans l'environnement du service `airflow-scheduler` (ou
`http://host.docker.internal:5000` si le serveur MLflow tourne hors du reseau Compose principal),
et s'assurer que le conteneur Airflow peut joindre le service `mlflow` -- ce qui suppose de les
rapprocher sur le meme reseau Docker ou d'exposer MLflow autrement qu'en `127.0.0.1` uniquement
(cf. point 1 sur l'exposition du port). Non fait a ce jour : aucun besoin de centraliser les runs
d'entrainement Airflow et locaux n'a encore ete identifie.
## Machine cible, exécution Docker
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
+4
View File
@@ -287,6 +287,10 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
Le scoring (`ml_score`) charge le modèle depuis un fichier local (`models/lightgbm-consumption.txt`)
et trace son empreinte SHA-256 dans `prediction.model_reference`. Il ne lit aucune version depuis
le Model Registry MLflow (`ml/`) : ce registre sert aujourd'hui à la traçabilité des
entraînements, pas au déploiement du modèle de scoring.
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
+6
View File
@@ -0,0 +1,6 @@
.venv
data
mlruns
mlflow.db*
models
.env
+1
View File
@@ -0,0 +1 @@
MLFLOW_DB_PASSWORD=change-me
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.14-slim
RUN pip install --no-cache-dir --only-binary :all: mlflow==3.16.1 psycopg2-binary==2.9.13
RUN useradd --create-home --uid 1000 mlflow \
&& mkdir /mlartifacts \
&& chown mlflow /mlartifacts
USER mlflow
EXPOSE 5000
+51
View File
@@ -57,6 +57,50 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
## Serveur MLflow (conteneur)
Premiere utilisation : copier `.env.example` en `.env` et y choisir un mot de passe PostgreSQL
(lettres et chiffres uniquement). Le fichier `.env` est ignore par git.
```bash
cp .env.example .env
```
Un serveur MLflow (PostgreSQL pour les metadonnees, volume pour les artefacts) se lance avec
Docker. Prerequis : Docker Desktop demarre.
```bash
make mlflow-up
```
La cible vérifie que `MLFLOW_DB_PASSWORD` (définie dans `ml/.env`) ne contient que des lettres et
des chiffres avant de démarrer le serveur : ce mot de passe est interpolé directement dans l'URI
PostgreSQL (`postgresql://mlflow:${MLFLOW_DB_PASSWORD}@...`), un caractère spécial la rendrait
invalide sans message d'erreur clair.
Interface : http://localhost:5000. Entrainer vers ce serveur :
```
uv run python -m enervision_ml.train --csv data/all_sites_combined.csv --mlflow-tracking-uri http://localhost:5000
```
Arreter : `docker compose -f docker-compose.mlflow.yml down` (ajouter `-v` pour effacer aussi les
runs et les modeles).
Pour voir les runs dans l'interface (MLflow 3.x) :
- Passer le selecteur en haut a gauche sur **Model training**. Le mode **GenAI** affiche des
traces LLM et reste vide pour un entrainement LightGBM.
- **Runs** liste les entrainements, **Models** les artefacts de modele de chaque run (tous nommes
`model`), et **Model registry** les versions numerotees de `consumption-forecast-lightgbm`.
Limites : l'identifiant PostgreSQL du compose est fixe a `mlflow`, le mot de passe vient de la
variable obligatoire `MLFLOW_DB_PASSWORD` (aucune valeur par defaut, le compose refuse de
demarrer sans elle) -- ce mot de passe est choisi lors de la copie de `.env.example`, il ne
convient donc qu'au developpement local tel quel. Un deploiement partage demandera des secrets,
de l'authentification et un stockage d'artefacts dedie (S3/MinIO). Le port 5000 doit etre libre : arreter `mlflow ui` avant,
ou changer le mapping (`"5001:5000"`) dans le compose.
## Scoring
```bash
@@ -83,6 +127,13 @@ section 2 :
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
distinguerait pas deux versions du modele.
**Le scoring ne lit pas le Model Registry.** Le fichier charge par `--model` est local
(`models/lightgbm-consumption.txt`), independant des versions enregistrees dans le
**Model registry** MLflow (`consumption-forecast-lightgbm`). `train.py` enregistre bien une
version a chaque entrainement (tracabilite), mais aucun alias (`champion` par exemple) n'est
pose, et `enervision_ml.score` ne les lit pas. Le registre sert aujourd'hui a la tracabilite des
entrainements, pas au deploiement du modele utilise en scoring.
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
pipeline sans base joignable.
+32
View File
@@ -0,0 +1,32 @@
services:
mlflow-db:
image: postgres:17
environment:
POSTGRES_USER: mlflow
POSTGRES_PASSWORD: ${MLFLOW_DB_PASSWORD:?definir MLFLOW_DB_PASSWORD dans ml/.env}
POSTGRES_DB: mlflow
volumes:
- mlflow-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mlflow"]
interval: 5s
retries: 10
mlflow:
build: .
depends_on:
mlflow-db:
condition: service_healthy
ports:
- "127.0.0.1:5000:5000"
volumes:
- mlflow-artifacts:/mlartifacts
environment:
MLFLOW_DB_PASSWORD: ${MLFLOW_DB_PASSWORD}
entrypoint: [ "/bin/sh", "-c" ]
command:
- exec mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri "postgresql://mlflow:$$MLFLOW_DB_PASSWORD@mlflow-db:5432/mlflow" --artifacts-destination /mlartifacts --serve-artifacts
volumes:
mlflow-db-data:
mlflow-artifacts:
+5 -1
View File
@@ -181,7 +181,11 @@ def _log_to_mlflow(
)
mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()})
mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()})
mlflow.lightgbm.log_model(booster, name="model")
mlflow.lightgbm.log_model(
booster,
name="model",
registered_model_name="consumption-forecast-lightgbm",
)
mlflow.log_artifact(str(model_output))
+17
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
from enervision_ml.train import chronological_split, prepare_dataset, train
@@ -74,3 +75,19 @@ def test_train_runs_end_to_end_on_synthetic_data_and_beats_a_dummy_baseline(
assert model_metrics["n_observations"] > 0
assert model_metrics["mae"] >= 0
assert baseline_metrics["n_observations"] == model_metrics["n_observations"]
assert model_metrics["mae"] < baseline_metrics["mae"]
def test_train_raises_when_the_validation_window_is_empty(tmp_path: Path) -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = make_frame("site-a", heures=50, depart=depart) # trop court pour un lag de 168h
csv_path = tmp_path / "trop_court.csv"
frame.to_csv(csv_path, index=False)
with pytest.raises(ValueError, match="Fenetre d'entrainement ou de validation vide"):
train(
csv_path=csv_path,
model_output=tmp_path / "model.txt",
test_fraction=0.2,
tracking_uri=f"sqlite:///{tmp_path / 'mlflow.db'}",
)