`load_from_csv` gardait un `astype(bool)` sur `is_working_hours`, joué avant `_typer` : une case vide du CSV arrivait en `NaN` et en ressortait `True`, soit une heure ouvrée inventée. Le chemin base était corrigé, pas celui-ci, et rien ne le couvrait. La ligne disparaît, et `_typer` ramène désormais les colonnes de `FLAG_COLUMNS` à `float64` quel que soit le contenu lu : sans cela le dtype dépendait de l'écriture du fichier (`0`/`1` contre `True`/`False`) et de la présence d'un trou, et l'égalité de schéma entre les deux chargeurs que promet ML-START n'était vraie que par accident du jeu de test. `Seuils.seuil_biais` valait `0` et `_verdict` exigeait `> 0` : la règle était inerte partout, CLI et DAG compris, et aucun test ne l'exerçait. Elle reste désactivée par défaut, parce qu'un seuil en kWh ne se transpose pas d'un bureau de 10 kWh à une usine de 1 000 kWh et qu'aucune valeur n'a été calibrée sur la vraie série, mais `--bias-threshold` la rend atteignable et l'ADR 0011 porte l'arbitrage. Trois tests couvrent le chemin : inerte par défaut, dérive au-delà du seuil réglé, et priorité de la MAE sur le biais. Deux lignes de doc devenues fausses au passage : la signature de `load_recent_from_database` dans ML-START, qui omettait `until` devenu obligatoire, et la ligne `bias` de 20-backend, qui laissait croire que la métrique décide du verdict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ML EnerVision
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet : ADR 0005 (choix du modele) et ML-START.md (mecanisme d'acces aux donnees).
| Element | Choix |
|---|---|
| Python | 3.14 |
| Gestionnaire | uv (uv.lock fait foi) |
| Modele | LightGBM (regression, un seul modele global) |
| Suivi | MLflow (parametres, metriques, artefact) |
| Lint/format | ruff |
| Typage | mypy en mode strict |
| Tests | pytest, donnees synthetiques uniquement |
Projet Python independant de apps/backend : le service FastAPI n'a aucune raison d'embarquer
LightGBM/MLflow en dependance de production juste pour un script d'entrainement lance a la main.
Installation
uv sync --all-groups
Donnees
Deux sources, qui produisent le meme schema en sortie de enervision_ml.data (voir le module
pour le detail) :
- CSV (
--csv), chemin de demarrage : lit directementml/data/all_sites_combined.csv, le jeu de donnees fourni pour le jalon J3. Ce dossier est ignore par git (gros fichier, local a chaque poste) : recuperer le CSV etdataset_metadata.jsonaupres de l'equipe et les placer dansml/data/avant d'entrainer sur cette source. - PostgreSQL (par defaut, sans
--csv) : connexion directe areading+siteviaML_DATABASE_URL, le chemin cible decrit dansML-START.md. Le role PostgreSQL dedieenervision_ml(lecture seule) n'est pas encore provisionne (dette assumee, cf. ADR 0003 et ADR 0005) ; en attendant, pointerML_DATABASE_URLvers la meme base que le backend suffit en developpement.
Entrainement
uv run python -m enervision_ml.train --csv data/all_sites_combined.csv
# ou, une fois la base peuplee et ML_DATABASE_URL positionnee :
uv run python -m enervision_ml.train
Ecrit le modele entraine dans models/lightgbm-consumption.txt (Booster.save_model(), dossier
ignore par git) et journalise la run dans MLflow : parametres, MAE/RMSE/MAPE du modele et de
la baseline de persistance saisonniere (consommation de la meme heure, une semaine avant), et
l'artefact modele. Sans MLFLOW_TRACKING_URI, MLflow ecrit dans un magasin SQLite local
(./mlflow.db, ignore par git) : uv run mlflow ui pour le consulter.
--test-fraction (0.15 par defaut) fixe la part la plus recente de l'historique reservee a la
validation. La coupure est chronologique, jamais un tirage aleatoire de lignes : un tirage
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.
cp .env.example .env
Un serveur MLflow (PostgreSQL pour les metadonnees, volume pour les artefacts) se lance avec Docker. Prerequis : Docker Desktop demarre.
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 deconsumption-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
uv run python -m enervision_ml.score --csv data/all_sites_combined.csv
# ou, une fois la base peuplee et ML_DATABASE_URL positionnee :
uv run python -m enervision_ml.score
Calcule, pour chaque site (ou un seul avec --site-id), la consommation prevue de l'heure suivant
sa derniere lecture connue, et ecrit une ligne dans prediction. Etapes, cf. ML-START.md
section 2 :
- Lit une fenetre recente de
reading+site(21 jours par defaut, une marge au-dessus des 168h necessaires au lag hebdomadaire) plutot que tout l'historique -- le meme piege que celui deja corrige surGET /readings(fenetre non plafonnee sur une hypertable). - Ajoute une ligne "future" par site (l'heure suivante) et calcule ses features avec
enervision_ml.features.build_features, exactement la meme fonction qu'a l'entrainement. - Si le lag de 168h est absent (moins d'une semaine d'historique pour ce site) : ecrit
status="insufficient_data"directement, sans jamais appeler LightGBM. - Sinon : appelle
booster.predict(...)et ecritstatus="available"avec la valeur predite.
--model pointe vers le fichier entraine (models/lightgbm-consumption.txt par defaut).
model_reference en base est le hache SHA-256 (tronque) du fichier modele, pas son nom de
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.
Limite assumee : la feature is_working_hours de la ligne future est recopiee depuis la
derniere lecture reelle, pas recalculee -- il n'existe aucune regle horaire ouvrable dans ce
depot (elle vit dans le generateur du jeu de donnees d'origine). L'approximation n'est fausse
qu'aux heures de bascule ouverture/fermeture, sur une seule feature parmi une dizaine, pour une
prevision a un seul pas.
prediction n'a pas de contrainte d'unicite sur (site_id, target_at) : chaque run de scoring
insere une nouvelle ligne plutot que d'ecraser la precedente, pour garder une trace de chaque
prevision (utile plus tard pour comparer prevision et realise, surveillance de derive #44/#45).
Commandes
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy enervision_ml tests # typage strict
uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar)
Depuis la racine du monorepo, via le Makefile : make install-ml, make ml-lint,
make ml-typecheck, make ml-test, make ml-check, make ml-train (CSV=chemin optionnel).
Ou ecrire les tests
Deux regimes, separes par le marqueur integration que pytest ecarte par defaut.
Sans base : enervision_ml.data.load_from_csv et le chargement CSV de test suffisent a
exercer build_features sur des donnees reelles ou synthetiques, et enervision_ml.train.train()
accepte un tracking_uri SQLite isole (tmp_path pytest) pour un test de bout en bout sans effet
de bord.
Avec base, sous integration : test_data_integration.py confronte les neuf colonnes du
contrat au schema Alembic reel, et test_score_integration.py verifie les contraintes de
prediction depuis le code qui ecrit. Les fixtures sont dans tests/conftest.py, qui refuse de
demarrer si ML_DATABASE_URL ne vise pas enervision_test.
make db-up migrate-test ml-test-integration
Regle a tenir : toute requete SQL nouvelle porte un test integration. Le schema vit dans
apps/backend/alembic, pas ici : sans ce garde-fou, une migration qui renomme une colonne casse
le pipeline en production sans qu'aucun test ne rougisse.
Piege a connaitre
enervision_ml.features.build_features est le seul endroit qui doit construire les features
du modele, a l'entrainement comme au scoring (enervision_ml.score). Si les deux divergent meme
legerement (une fenetre de moyenne glissante calculee differemment, par exemple), le modele
recoit en production des features qui ne ressemblent plus a ce qu'il a appris, et ses predictions
deviennent silencieusement mauvaises sans qu'aucune erreur ne se declenche. Ne jamais reecrire
cette logique ailleurs : importer enervision_ml.features.
Et cote API ?
GET /api/v1/predictions (backend, apps/backend) lit ce que enervision_ml.score a ecrit dans
prediction -- la derniere prevision par site, jamais un recalcul a la volee. FastAPI ne fait
jamais tourner LightGBM lui-meme, cf. ML-START.md section 3.