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.
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
predictionn'est pas dans le périmètre deML_DATABASE_URL.enervision_ml/config.py,docs/ML-START.mdet l'ADR 0003 désignent pour cette variable un rôle PostgreSQL restreint en lecture surreadingetsite. Mettre la dérive dansml/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_anomalycroisereadingetpredictionsur le même instant, etPredictionRepository.list_sinceporte déjà le piège des runs empilés. Le réécrire en SQL brut dansml/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.mdtient. 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 :
predictionn'a pas d'unicité sur(site_id, target_at), chaque run de scoring empile une ligne. On retient la plus récente, celle que sertGET /api/v1/predictions, départagée parprediction_id:created_atvaut l'heure de début de transaction et ne distingue pas deux lignes du même run.uq_reading_sourceautorise deux lectures au même instant quand lasourcediffè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 uneUniqueConstraint: deux lignes globales ont toutes deuxsite_idà NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme queuq_reading_source. Cet index n'a de sens que parce queevaluate()tronque son instant de référence à l'heure : avec les microsecondes denow(), 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, passtable. Au premier lancement, et après tout trou d'ingestion de plus de 168 h, il n'y a rien à quoi comparer : annoncerstableserait 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/driftest réservé à partir du rôleoperateur: c'est l'opérateur qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée danstests/api/acces.py, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais jetons.- Un DAG
derivequotidien l'ordonnance, sans reprise : rejouer une dérive la redéclarerait à l'identique. - La CLI sort en code non nul sous
--fail-on-driftseulement. 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.