`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>
8.5 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 identiques par jour. 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. 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. - Le biais ne fait pas basculer le verdict par défaut :
Seuils.seuil_biaisvaut0, ce qui désactive la règle. Le plafond de MAE se dérive de la fenêtre de référence, donc il vaut pour n'importe quel site ; un seuil de biais, lui, s'exprime en kWh et ne se transpose pas d'un bureau de 10 kWh à une usine de 1 000 kWh. En déclarer un sans l'avoir calibré sur la vraie série ferait rougir la tâche sans rien prouver. Lebiassigné reste calculé, stocké et servi parGET /api/v1/monitoring/drift: il se lit, il ne juge pas encore.--bias-thresholdl'active site par site quand une valeur aura été mesurée.
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.