Files
ENI-projet-piscine/docs/adr/0011-surveillance-de-derive-dans-le-backend.md
T
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

8.7 KiB

0011 - La surveillance de dérive vit dans le backend et écrit sa propre table

  • Statut : accepté
  • Date : 2026-09-22

Contexte

L'issue #45 demande des tests d'intégration API ↔ DB ↔ ML. Trois documents du dépôt annoncent par ailleurs, depuis le jalon J3, une surveillance de dérive qui n'existe nulle part : docs/architecture/00-vue-ensemble.md (« Surveillance de dérive (EC06, #44/#45) pas encore construite »), docs/ML-START.md (« la dette qui subsiste est la surveillance de dérive »), et le docstring de write_predictions() dans ml/enervision_ml/score.py, qui justifie l'absence d'unicité sur (site_id, target_at) par la comparaison future entre prévu et réalisé.

La matière première est en base : prediction porte ce que le modèle a annoncé, reading ce qui est réellement arrivé. Restaient trois questions : où vit le calcul, à quoi on compare, et où atterrit le résultat.

Décision

Le calcul vit dans apps/backend : repositories/drift.py pour le SQL, services/drift.py pour la logique, monitoring/drift.py pour la CLI, api/v1/endpoints/monitoring.py pour la lecture. Le dossier ml/ ne gagne pas une ligne.

Le résultat est persisté dans une table drift_report, une ligne par site plus une ligne globale que site_id à NULL désigne.

La comparaison oppose deux fenêtres vives de 168 h, la récente et celle qui la précède, et le verdict a trois valeurs : stable, derive, indetermine.

Pourquoi le backend, alors que le sujet est le modèle

  • prediction n'est pas dans le périmètre de ML_DATABASE_URL. enervision_ml/config.py, docs/ML-START.md et l'ADR 0003 désignent pour cette variable un rôle PostgreSQL restreint en lecture sur reading et site. Mettre la dérive dans ml/ obligerait à élargir ce rôle à prediction, et à l'écriture : ce serait contredire par le code la dette de moindre privilège que ces trois documents ont posée par écrit.
  • L'alignement prévu contre réalisé existe déjà ici, une fois. AlertService._detect_anomaly croise reading et prediction sur le même instant, et PredictionRepository.list_since porte déjà le piège des runs empilés. Le réécrire en SQL brut dans ml/ créerait une seconde source de vérité sur « quelle prédiction correspond à quelle lecture », ce que l'ADR 0006 a déjà refusé pour les règles.
  • La frontière de docs/ML-START.md tient. FastAPI ne fait toujours pas tourner LightGBM : la dérive lit deux tables et compare des nombres, elle n'évalue aucun modèle.

Conséquence assumée : enervision_ml.metrics.regression_metrics n'est pas réutilisable, le backend n'important pas enervision_ml. MAE, MAPE et biais sont donc réécrits, une quinzaine de lignes. Cette duplication n'est pas celle que build_features interdit : une divergence de features est silencieuse et ruine les prévisions sans erreur, une divergence sur une moyenne d'écarts absolus est attrapée par le premier test à valeurs connues.

Ce qu'on mesure, et les deux dédoublonnages obligatoires

La paire est prediction ⋈ reading sur (site_id, target_at = timestamp), restreinte aux prédictions available. Elle exige un DISTINCT ON des deux côtés :

  • prediction n'a pas d'unicité sur (site_id, target_at), chaque run de scoring empile une ligne. On retient la plus récente, celle que sert GET /api/v1/predictions, départagée par prediction_id : created_at vaut l'heure de début de transaction et ne distingue pas deux lignes du même run.
  • uq_reading_source autorise deux lectures au même instant quand la source diffère. Sans dédoublonnage, la jointure compterait cette heure deux fois et pondérerait doublement le site.

La fenêtre est fermée à droite par un délai de grâce de 2 h : le réalisé de la dernière heure n'est pas encore ingéré, et l'inclure ferait chuter le taux de couverture à chaque exécution, pour une raison qui n'a rien à voir avec le modèle.

Métriques retenues : mae (la métrique même qu'optimise LightGBM), bias signé (une MAE qui monte dit « moins bon », un biais qui s'éloigne de zéro dit « le modèle se trompe toujours du même côté », signature d'un décalage de distribution), mape, n_observations, coverage_ratio et insufficient_data_ratio (qui mesurent le pipeline, pas le modèle), et la liste des model_references vus dans la fenêtre : une MAE qui saute à l'instant exact où le modèle change n'est pas une dérive, c'est une régression de réentraînement.

Alternatives écartées

Écartée Raison
Comparer à la métrique MLflow de l'entraînement Ce ne sont pas les mêmes grandeurs : train.py mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit une heure future dont la météo est NaN et dont is_working_hours est recopié. Le verdict serait « dérive » dès le premier jour. Et le backend devrait importer mlflow, ce que la frontière de ML-START interdit.
Écrire le résultat dans alert ck_alert_source et ck_alert_type bornent les valeurs autorisées, alert.site_id est NOT NULL et n'accueillerait donc pas la ligne globale, et toute alerte est ensuite relue par le moteur de recommandations, qui devrait apprendre une règle qui ne le concerne pas (ADR 0006).
Une jauge Prometheus monitoring/ ne contient que des .gitkeep et aucun collecteur ne lit /metrics : une jauge que personne ne scrute n'est pas une preuve. Le calcul est de surcroît un traitement par lot, pas le processus qui sert l'API : la jauge disparaîtrait avec lui.
Ne rien persister, journaliser seulement La question posée à un jury est « comment savez-vous que le modèle se dégrade ? ». La réponse est une série dans le temps, pas une ligne de journal perdue avec le conteneur. Sans ligne écrite, l'endpoint n'a rien à lire et le test d'intégration rien à vérifier.
Une tâche de plus dans le DAG alertes La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes par jour pour un verdict qui ne bouge pas à cette cadence. Surtout, un échec de dérive ferait rougir alertes et laisserait croire que la détection a échoué.

Conséquences

  • Une migration ajoute drift_report. Son idempotence passe par un index unique à coalesce(site_id, '') et non par une UniqueConstraint : deux lignes globales ont toutes deux site_id à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que uq_reading_source. Cet index n'a de sens que parce que evaluate() tronque son instant de référence à l'heure : avec les microsecondes de now(), deux exécutions ne porteraient jamais la même clé et l'index ne dédoublonnerait rien.
  • Sans fenêtre de référence, le verdict est indetermine, pas stable. Au premier lancement, et après tout trou d'ingestion de plus de 168 h, il n'y a rien à quoi comparer : annoncer stable serait affirmer ce que la donnée ne dit pas.
  • Un site présent dans la fenêtre de référence et absent de la récente reçoit sa ligne, à zéro observation. Un site qui cesse d'être scoré est exactement la panne que cette surveillance existe pour dire : le taire en ne produisant aucune ligne serait l'inverse du besoin.
  • GET /api/v1/monitoring/drift est réservé à partir du rôle operateur : c'est l'opérateur qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée dans tests/api/acces.py, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais jetons.
  • Un DAG derive quotidien l'ordonnance, sans reprise : rejouer une dérive la redéclarerait à l'identique.
  • La CLI sort en code non nul sous --fail-on-drift seulement. Par défaut, constater une dérive n'est pas un échec d'exécution.

Effet de bord assumé sur le pipeline

La dérive n'a de matière que si des paires prévu/réalisé existent. Or enervision_ml.score --now ne rejouait pas l'historique : load_recent_from_database n'avait pas de borne haute et build_scoring_frame repartait de la dernière lecture connue, si bien que target_at valait toujours « fin du jeu + 1 h » et que l'âge de la dernière lecture devenait négatif sans franchir le seuil de péremption. Sur le jeu historique, figé au 31/12/2024, aucune boucle de rattrapage n'aurait donc rien produit de vérifiable.

until est devenu obligatoire sur ce chargeur, et le scoring lui passe son instant de référence. Le comportement en exploitation ne change pas, aucune lecture n'étant postérieure à l'heure courante ; seul le rattrapage sur données passées devient possible.