Files
ENI-projet-piscine/ml
Johan LEROY d506e8f7ef
Airflow / Construction de l'image (push) Successful in 1m2s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 5m3s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m41s
ML / Analyse statique de sécurité (push) Successful in 6s
Backend / Lint, typage et tests (push) Successful in 10m10s
Backend / Audit des dépendances (push) Successful in 9m36s
ML / ML - DB et chaîne ML - DB - API (push) Failing after 5m6s
SonarQube / test-ml (push) Failing after 6m6s
ML / Lint, typage et tests (push) Successful in 11m31s
SonarQube / build-front (push) Successful in 9m49s
SonarQube / build-back (push) Successful in 9m54s
SonarQube / test-front (push) Failing after 5m10s
SonarQube / test-back (push) Failing after 5m13s
SonarQube / SonarQube (push) Skipped
fix(backend,ci): repare trois angles morts de la surveillance de derive
Revue de la branche : trois defauts empechaient la surveillance de tenir ce qu'elle annonce.

- `evaluate()` gardait les microsecondes de `now()` dans `window_end`, la cle de
  `uq_drift_report_window`. Deux executions ne collidaient donc jamais et l'index ne
  dedoublonnait rien, contrairement a ce qu'affirmaient l'ADR 0011, 20-backend et le docstring
  du DAG. L'instant de reference est desormais tronque a l'heure.
- Un site qui cessait d'etre score disparaissait du rapport : la liste des sites ne venait que
  de la fenetre recente. La panne que cette surveillance existe pour dire etait exactement
  celle qu'elle taisait. La fenetre de reference entre maintenant dans l'union, et le site
  recoit sa ligne `indetermine` a zero observation.
- Sans fenetre de reference, `_plafond` rendait `None` et le verdict tombait sur `stable`, une
  affirmation que la donnee ne portait pas. C'est `indetermine` desormais.

`ml.yml` ecoute `apps/backend/app/**` et non les seuls modeles : ce workflow est le seul a
jouer `-m chaine`, or la chaine traverse les endpoints, les services et les schemas jusqu'a
`GET /predictions`. Une PR touchant `predictions.py` ne declenchait pas le test qui l'assert.

Hygiene de tests : le nettoyage des fixtures API connait `drift_report` (cle etrangere RESTRICT
vers `site`), le test sans rapport rend ses overrides en teardown, `test_chaine_ml_api` compare
les `created_at` strictement (un `>=` passait aussi quand l'API resservait la premiere ligne),
et `test_data_integration` filtre sur le site seme au lieu de juger tout le contenu d'une
fenetre dans une base partagee.

Docs remises d'aplomb : sept revisions Alembic et non six, `derive.py` dans l'inventaire de
etl/README, et le diagramme de 20-backend gagne DriftService, le depot drift et sa treizieme
table.
2026-09-22 14:58:34 +02:00
..

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 directement ml/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 et dataset_metadata.json aupres de l'equipe et les placer dans ml/data/ avant d'entrainer sur cette source.
  • PostgreSQL (par defaut, sans --csv) : connexion directe a reading + site via ML_DATABASE_URL, le chemin cible decrit dans ML-START.md. Le role PostgreSQL dedie enervision_ml (lecture seule) n'est pas encore provisionne (dette assumee, cf. ADR 0003 et ADR 0005) ; en attendant, pointer ML_DATABASE_URL vers 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.

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 :

  1. 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 sur GET /readings (fenetre non plafonnee sur une hypertable).
  2. 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.
  3. 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.
  4. Sinon : appelle booster.predict(...) et ecrit status="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.

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.