[36] Entraînement du modèle de prévision H+1 et promotion #166

Merged
lenaic merged 12 commits from olivier/36-entrainement-modele into develop 2026-09-07 13:15:57 +00:00
Member

Ferme #36. Prévision de puissance à H+1 par site, avec sa référence de comparaison publiée, entraînée en local et promue dans le registre MLflow.

Le modèle est entraîné et promu, la preuve est en commentaire du #36. enervision-prevision-h1 version 3 porte l'alias production — MAE 2,018 kW contre 21,443 kW pour la persistance sur 504 heures de test, et le modèle bat la référence sur les sept sites.

Ce que la branche apporte

services/inference/model/, le paquet d'entraînement, et tests/unit/model/, ses 95 tests. Sept modules :

Module Rôle
config.py réglages, et les trois noms que le registre partage avec le #37
donnees.py zone or → exemples horaires, sans fuite, découpés dans le temps
reference.py la persistance publiée et le naïf saisonnier
metriques.py MAE, RMSE, MAPE, globales et par site
modele.py le régresseur, et la seule fonction qui nomme scikit-learn
registre.py la règle de promotion, et la frontière avec MLflow
entrainement.py l'entrée du job, dont la sortie est la preuve du ticket

Plus bin/entrainer.sh, le README du service, et services/inference ajouté au pythonpath.

Par où commencer la relecture

1. donnees.py — l'absence de fuite. Un exemple prévoit l'instant p à partir des heures p−1p−24, toutes révolues à l'émission. Aucune statistique n'est calculée avant le découpage, qui est chronologique et non aléatoire. Deux tests le gardent explicitement (test_la_cible_n_apparait_jamais_dans_ses_propres_entrees, test_les_retards_sont_les_heures_qui_precedent_l_instant_prevu).

La source est public.mesure_horaire, l'agrégat continu de la 0016, et pas public.mesure réagrégée ici : la règle qui exclut les relevés critical est déjà écrite une fois, en SQL, et c'est la vue que l'API et Grafana serviront.

2. modele.py — le réglage qui compte. Ce n'est pas le régresseur, c'est early_stopping=False. Laissé sur « auto », scikit-learn met de côté 10 % des exemples tirés au hasard dès dix mille lignes : à la fois une fuite sur une série temporelle et un découpage qui n'est pas le nôtre.

3. registre.py — la règle de promotion. decider_promotion est une fonction pure de deux nombres, le seul endroit du dépôt qui dise quand un modèle passe en ligne : strictement mieux que la persistance, ou l'alias ne bouge pas. À égalité on garde la version en place.

4. Le garde-fou de base. Le lanceur choisit prod (défaut) ou preprod par l'environnement de lancement. La promotion est refusée hors enervision_prod sauf --promouvoir-hors-prod, parce que l'alias production est ce que le service du #37 chargera au démarrage. C'est le nom de base relu dans l'URL qui décide, jamais le réglage — un réglage peut mentir.

Les quatre critères

État
Entraînement en local sur le serveur joué trois fois sur ml-stagiaire-02, base enervision_preprod
Référence naïve publiée, comparée site par site tableau ci-dessus, et mae_<site> par site dans l'exécution
Enregistré dans MLflow avec paramètres et métriques, puis promu version 3, alias production, exécution 382e4058b234
Rejouable, même résultat trois exécutions, MAE identique au millième

Le calcul GPU est sorti du périmètre

Décision du 07/09 : HistGradientBoostingRegressor, conformément à l'ADR 0012 — « le T4 n'est pas requis ». Le ticket #36 a été modifié (titre, premier critère, preuve attendue). L'écart avec EXIGENCES-collectives.md §1, qui porte « en local, sur le Tesla T4 du serveur, arbitré le 01/09 », est consigné et non tranché : la ligne se lit désormais « en local, sur le serveur qui porte le T4 », et cette PR ne modifie pas le fichier collectif. À acter en point du matin si le groupe veut aligner le texte.

À savoir avant de fusionner

  • Le chiffre est spectaculaire parce que la source est un simulateur. L'ADR 0012 demande d'écrire cette propriété plutôt que de la cacher : un écart de dix contre un face à la persistance dit d'abord que ces séries sont très régulières. Il ne se transposerait pas tel quel sur un parc réel.
  • Le critère 1 n'est pas prouvé sur la production : public.mesure y est vide, le chargement de la zone or n'étant pas planifié — c'est #136, non fusionnée. Dès qu'elle passe, entrainer.sh --depuis ... vise la production sans autre option et trouve son URL dans ENERVISION_ETL_DATABASE_URL.
  • pytest.ini sera en conflit d'une ligne avec la PR #154, qui ajoute services/recommendations en bout de la même ligne. Les deux ajouts se gardent, la résolution prend dix secondes.
  • ci.yml n'est pas touché : la tâche Python découvre seule les tests et les manifestes. Si l'on veut inscrire model dans les zones sensibles à 85 %, ce sera après #154, en une ligne.
  • Le manifeste ajoute scikit-learn, numpy et mlflow aux dépendances applicatives. C'est le point que pip-audit va exercer pour la première fois sur des paquets de cette taille — s'il rougit, l'échappatoire est une CVE à la fois, justifiée en commentaire, jamais un interrupteur global.

Contrôles joués localement

ruff et mypy strict propres sur services et packages · 692 tests unitaires au vert, 1 sauté hors chaîne (il exige scikit-learn, que la chaîne installe) · couverture globale 89 %, paquet 83 %, seuil bloquant 70 % · develop fusionné dans la branche, sans conflit.

Relecture souhaitée par @justine, qui porte EC06 et le #37 : model.registre.charger est la couture qu'elle empruntera, et les trois noms du registre (enervision-prevision-h1, alias production, expérience prevision-h1) sont fixés par config.py comme le manuel §4 le demandait au #36.

Ferme #36. Prévision de puissance à H+1 par site, avec sa référence de comparaison publiée, entraînée en local et promue dans le registre MLflow. **Le modèle est entraîné et promu, la preuve est en commentaire du #36.** `enervision-prevision-h1` version 3 porte l'alias `production` — MAE **2,018 kW** contre **21,443 kW** pour la persistance sur 504 heures de test, et le modèle bat la référence sur les sept sites. ### Ce que la branche apporte `services/inference/model/`, le paquet d'entraînement, et `tests/unit/model/`, ses 95 tests. Sept modules : | Module | Rôle | |---|---| | `config.py` | réglages, et les trois noms que le registre partage avec le #37 | | `donnees.py` | zone or → exemples horaires, sans fuite, découpés dans le temps | | `reference.py` | la persistance publiée et le naïf saisonnier | | `metriques.py` | MAE, RMSE, MAPE, globales et **par site** | | `modele.py` | le régresseur, et la seule fonction qui nomme scikit-learn | | `registre.py` | la règle de promotion, et la frontière avec MLflow | | `entrainement.py` | l'entrée du job, dont la sortie est la preuve du ticket | Plus `bin/entrainer.sh`, le README du service, et `services/inference` ajouté au `pythonpath`. ### Par où commencer la relecture **1. `donnees.py` — l'absence de fuite.** Un exemple prévoit l'instant `p` à partir des heures `p−1` … `p−24`, toutes révolues à l'émission. Aucune statistique n'est calculée avant le découpage, qui est chronologique et non aléatoire. Deux tests le gardent explicitement (`test_la_cible_n_apparait_jamais_dans_ses_propres_entrees`, `test_les_retards_sont_les_heures_qui_precedent_l_instant_prevu`). La source est `public.mesure_horaire`, l'agrégat continu de la 0016, et **pas** `public.mesure` réagrégée ici : la règle qui exclut les relevés `critical` est déjà écrite une fois, en SQL, et c'est la vue que l'API et Grafana serviront. **2. `modele.py` — le réglage qui compte.** Ce n'est pas le régresseur, c'est `early_stopping=False`. Laissé sur « auto », scikit-learn met de côté 10 % des exemples **tirés au hasard** dès dix mille lignes : à la fois une fuite sur une série temporelle et un découpage qui n'est pas le nôtre. **3. `registre.py` — la règle de promotion.** `decider_promotion` est une fonction pure de deux nombres, le seul endroit du dépôt qui dise quand un modèle passe en ligne : strictement mieux que la persistance, ou l'alias ne bouge pas. À égalité on garde la version en place. **4. Le garde-fou de base.** Le lanceur choisit `prod` (défaut) ou `preprod` par l'environnement de lancement. La promotion est refusée hors `enervision_prod` sauf `--promouvoir-hors-prod`, parce que l'alias `production` est ce que le service du #37 chargera au démarrage. C'est le nom de base **relu dans l'URL** qui décide, jamais le réglage — un réglage peut mentir. ### Les quatre critères | | État | |---|---| | Entraînement en local sur le serveur | joué trois fois sur `ml-stagiaire-02`, base `enervision_preprod` | | Référence naïve publiée, comparée site par site | tableau ci-dessus, et `mae_<site>` par site dans l'exécution | | Enregistré dans MLflow avec paramètres et métriques, puis promu | version 3, alias `production`, exécution `382e4058b234` | | Rejouable, même résultat | trois exécutions, **MAE identique au millième** | ### Le calcul GPU est sorti du périmètre Décision du 07/09 : `HistGradientBoostingRegressor`, conformément à l'ADR 0012 — « le T4 n'est pas requis ». Le ticket #36 a été modifié (titre, premier critère, preuve attendue). L'écart avec `EXIGENCES-collectives.md` §1, qui porte « en local, sur le Tesla T4 du serveur, arbitré le 01/09 », est **consigné et non tranché** : la ligne se lit désormais « en local, sur le serveur qui porte le T4 », et cette PR ne modifie pas le fichier collectif. À acter en point du matin si le groupe veut aligner le texte. ### À savoir avant de fusionner - **Le chiffre est spectaculaire parce que la source est un simulateur.** L'ADR 0012 demande d'écrire cette propriété plutôt que de la cacher : un écart de dix contre un face à la persistance dit d'abord que ces séries sont très régulières. Il ne se transposerait pas tel quel sur un parc réel. - **Le critère 1 n'est pas prouvé sur la production** : `public.mesure` y est vide, le chargement de la zone or n'étant pas planifié — c'est #136, non fusionnée. Dès qu'elle passe, `entrainer.sh --depuis ...` vise la production sans autre option et trouve son URL dans `ENERVISION_ETL_DATABASE_URL`. - **`pytest.ini` sera en conflit d'une ligne avec la PR #154**, qui ajoute `services/recommendations` en bout de la même ligne. Les deux ajouts se gardent, la résolution prend dix secondes. - **`ci.yml` n'est pas touché** : la tâche Python découvre seule les tests et les manifestes. Si l'on veut inscrire `model` dans les zones sensibles à 85 %, ce sera après #154, en une ligne. - **Le manifeste ajoute `scikit-learn`, `numpy` et `mlflow`** aux dépendances applicatives. C'est le point que `pip-audit` va exercer pour la première fois sur des paquets de cette taille — s'il rougit, l'échappatoire est une CVE à la fois, justifiée en commentaire, jamais un interrupteur global. ### Contrôles joués localement ruff et mypy strict propres sur `services` et `packages` · 692 tests unitaires au vert, 1 sauté hors chaîne (il exige scikit-learn, que la chaîne installe) · couverture globale **89 %**, paquet **83 %**, seuil bloquant 70 % · `develop` fusionné dans la branche, sans conflit. Relecture souhaitée par **@justine**, qui porte EC06 et le #37 : `model.registre.charger` est la couture qu'elle empruntera, et les trois noms du registre (`enervision-prevision-h1`, alias `production`, expérience `prevision-h1`) sont fixés par `config.py` comme le manuel §4 le demandait au #36.
Le paquet `model` sous `services/inference/`, que le README racine réserve à
EC06. Un paquet par ticket : `model` est l'entraînement et la promotion (#36),
`inference` sera le service qui sert la prévision (#37). Ils partagent le
répertoire et le `pythonpath` — un seul ajout à pytest.ini pour les deux — mais
pas leurs dépendances.

Les trois conventions que le registre partage sont en constantes, pas en
variables d'environnement : `docs/runbooks/mlflow.md` §4 demande au #36 de fixer
le nom du modèle « une fois pour toutes », et l'ADR 0012 le laissait « à
confirmer par le ticket #36 ». C'est fait : `enervision-prevision-h1`, alias
`production`, expérience `prevision-h1`. En faire des réglages aurait rendu
possible qu'un entraînement publie sous un nom que le #37 ne cherche pas.

`MLFLOW_TRACKING_URI` garde son nom standard, sans préfixe maison : le manuel §4
promet à un client qu'il n'a besoin que de cette variable. L'URL de la base est
lue sous deux noms, celui du modèle et celui de l'ETL, parce que
`/etc/enervision/postgres.env` définit le second et que l'entraînement lit la
même base en lecture seule — demander un changement de template Ansible pendant
que le #136 le modifie n'aurait rien apporté.

La source est `public.mesure_horaire`, l'agrégat continu de la 0016, et pas
`public.mesure` réagrégée ici : la règle qui exclut les relevés `critical` du
calcul est déjà écrite une fois, en SQL, et c'est la vue que l'API et Grafana
serviront. Deux vérités pour une question se seraient contredites au premier
chiffre affiché.

Un exemple prévoit l'instant `p` à partir des heures `p−1` … `p−24`, toutes
révolues à l'émission. Aucune statistique n'est calculée avant le découpage, qui
est chronologique : sur une série temporelle, un tirage aléatoire laisse le
modèle apprendre l'heure suivante d'une heure qu'il a déjà vue, et le score
obtenu ne dit plus rien. La coupure se calcule sur le dernier instant présent et
non sur `now()`, sans quoi le même appel ne serait pas reproductible d'un jour à
l'autre — quatrième critère du ticket.

Les trous ne sont pas rebouchés une seconde fois : l'imputation appartient à la
zone argent, bornée à trois régimes par l'ADR 0006. Une cible dont un retard
manque n'est pas produite, et le bilan compte séparément « cible fragile » et
« retard manquant » — la collecte et la profondeur d'historique ne se corrigent
pas de la même façon, les confondre reviendrait à ne rien savoir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le deuxième critère du ticket : « une référence naïve est publiée, et l'erreur
du modèle lui est comparée site par site ». Les deux moitiés comptent.

**Publiée.** Une référence calculée après coup, dans un tableur, au moment de
rédiger le rapport, ne se reconstitue pas — personne ne peut plus dire sur
quelles heures elle portait. Elle se calcule donc sur exactement les mêmes
exemples que le modèle, et le module est importable par le service du #37, dont
le troisième critère lui demande de servir la prévision « avec sa référence de
comparaison ». Deux calculs de persistance, un ici et un là-bas, finiraient par
diverger d'une heure sans que personne le voie.

Deux références, deux rôles, comme l'ADR 0012 les répartit : la persistance
(`y(p) = y(p−1)`) est celle qui se publie ligne à ligne et qui sert d'adversaire
au critère de promotion ; le naïf saisonnier (`y(p) = y(p−24 h)`) est une
métrique d'évaluation seulement — il dit si le modèle capte le cycle journalier,
ce que la persistance ignore par construction.

La position d'un retard se déduit de la liste des retards, jamais d'un indice en
dur : `retards_kw[0]` n'est la dernière heure connue que si `retards[0]` vaut 1,
ce qui est vrai aujourd'hui et n'a aucune raison de le rester quand le retard
hebdomadaire s'ajoutera.

**Site par site.** Une MAE agrégée sur les sept sites cache exactement ce qu'un
exploitant veut savoir : `test_une_mae_agregee_peut_cacher_un_site_inutilisable`
le montre plutôt que de l'affirmer — 30 kW en moyenne, 0 sur neuf sites et 300
sur le dixième.

La MAE est en tête parce qu'elle s'exprime en kW, dans l'unité du parc. La RMSE
l'accompagne : un modèle qui se trompe rarement mais énormément — le pire défaut
pour une alerte de dépassement — a une MAE flatteuse et une RMSE qui le dénonce.
Le MAPE n'est qu'indicatif et rend `None` plutôt que zéro quand aucune heure ne
dépasse le plancher : un zéro se lirait « le modèle est parfait », le contraire
de « on ne peut pas le dire ».

`math.sqrt` et non « ** 0.5 » : pour mypy strict, l'opérateur de puissance sur un
flottant rend « Any », et la fonction promettait un float sans que rien ne le
vérifie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le régresseur, le registre, l'entrée du job et son lanceur. Ce qui reste à faire
sur le ticket est une exécution réelle : les critères 1, 3 et 4 se prouvent par
une exécution sur le serveur, pas par du code.

**XGBoost et non scikit-learn, et c'est un écart assumé avec l'ADR 0012.** Cette
fiche, proposée le 06/09, écrit « le T4 n'est pas requis » et propose
`HistGradientBoostingRegressor`, qui n'a aucun support GPU. Le premier critère
d'acceptation du #36 dit l'inverse — « l'entraînement s'exécute en local sur le
Tesla T4 » — et réclame la sortie de `nvidia-smi` pendant l'entraînement comme
preuve. Les deux ne pouvaient pas être vrais ensemble. L'ADR se déclare
« proposée, et pas acceptée » et laisse explicitement les porteurs du #36
l'amender ; c'est le ticket qui fait foi. Reste à l'amender formellement, avec
Justine.

C'est le SEUL point de l'ADR qui est écarté : le jeu, la cible H+1 par site, la
persistance comme référence publiée, le critère de promotion et les noms du
registre sont pris tels quels. Et l'amendement reste réversible en un endroit —
`modele.construire_regresseur` est la seule fonction du dépôt qui nomme XGBoost.

**Le repli sur le processeur n'est jamais silencieux.** `--device cuda` échoue
sans support CUDA au lieu de se replier : un entraînement qui réussirait sur le
processeur avec une capture `nvidia-smi` vide produirait une preuve fausse, ce
qui est pire que pas de preuve. `auto` existe pour les postes de travail et le
dit dans le journal. Le module ne prétend pas non plus constater que le calcul a
eu lieu sur le T4 : cette certitude vient de `nvidia-smi`, échantillonné par le
lanceur pendant l'exécution et joint à l'exécution MLflow comme artefact — la
preuve voyage avec le modèle au lieu de vivre dans une capture d'écran perdue.

**La règle de promotion est du code, pas une phrase de manuel.**
`decider_promotion` est une fonction pure de deux nombres, et le seul endroit du
dépôt qui dise quand un modèle passe en ligne : strictement mieux que la
persistance, ou l'alias ne bouge pas. À égalité on garde la version en place —
déplacer l'alias coûterait un redémarrage du service du #37 sans rien apporter.
Un modèle qui ne bat pas la persistance ne va pas en ligne, et le dire est un
résultat, pas un échec : le tableau comparatif s'imprime aussi dans ce cas-là,
qui est précisément celui qu'il serait tentant de ne pas montrer.

La version en place se lit AVANT le déplacement de l'alias : après, il désigne
déjà le candidat et le journal dirait « promu de 4 vers 4 ». C'est ce numéro que
le retour arrière du manuel §5 demande, et le journal est le seul endroit où il
reste écrit — le registre n'est pas versionné dans git, d'où aussi les trois
étiquettes `promu_par`, `promu_le`, `promu_motif`.

**Les frontières sont doublées, pas contournées.** Le protocole `Registre` nomme
les dix opérations demandées à MLflow ; `RegistreMlflow` les traduit sans aucune
logique. Publication et promotion s'éprouvent donc entièrement sans registre,
sans réseau et sans client installé — même motif que les adaptateurs DuckDB et
MinIO de l'ETL, et même règle d'import tardif : `import model.registre` réussit
sans MLflow.

`registre.charger` est la couture avec le #37, et elle est ici parce que c'est
`config.py` qui fixe le nom et l'alias : deux endroits qui construiraient la même
URI `models:/<nom>@<alias>` finiraient par en construire deux différentes, et la
panne serait un service qui ne trouve aucun modèle dans un registre qui en
contient.

**La rejouabilité se mesure, elle ne s'affirme pas.** Ce qui se maîtrise l'est :
graine fixée, `n_jobs=1`, ordre des exemples trié, coupure calculée sur les
données. Sur GPU, l'ordre des réductions flottantes n'est pas garanti d'une
exécution à l'autre ; `entrainer.sh --deux-passes` enchaîne donc deux
entraînements sur des bornes figées, sans rien publier, et compare les deux
tableaux. Identiques : c'est constaté. Différents : le script sort en 4 et
affiche l'écart, à déclarer comme tolérance plutôt qu'à cacher derrière une
promesse de bit-à-bit qu'on n'a pas mesurée.

Contrôles joués : ruff et mypy strict propres sur `services` et `packages`,
683 tests unitaires au vert dont 86 nouveaux, couverture globale 89 % et 83 %
sur le paquet — le palier bloquant de la chaîne est à 70 %. `ci.yml` n'est pas
touché : la tâche Python découvre seule les tests et les manifestes, et la
PR #154 modifie déjà la liste des zones sensibles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Décision d'Olivier le 07/09, qui retient l'ADR 0012 plutôt que le critère
d'origine du ticket. Le ticket #36 a été modifié en conséquence sur la forge :
titre, premier critère — « en local sur le serveur de la salle » au lieu de
« sur le Tesla T4 » — et preuve attendue, où l'identifiant d'exécution MLflow et
la version portant l'alias remplacent la sortie de `nvidia-smi`.

Le motif est celui qu'EXIGENCES-collectives.md §1 donne déjà : « le facteur
limitant est l'historique disponible, pas la puissance de calcul ». Sept sites,
un point par heure, quelques semaines : quelques milliers de lignes, où un GPU
n'apporterait rien de mesurable et coûterait une pile CUDA sur le serveur.
ENF-03 est tenue autrement — l'entraînement reste local et la mesure brute ne
quitte pas le réseau de la salle.

Écart consigné, pas tranché : EXIGENCES-collectives.md §1 porte encore
« Entraînement du modèle — en local, sur le Tesla T4 du serveur — arbitré le
01/09 ». Cette ligne se lit désormais « en local, sur le serveur qui porte le
T4 », lecture que l'ADR 0012 retient. Le fichier collectif n'est pas modifié
ici : c'est au groupe de le faire, comme pour le cas Keycloak.

Ce que le changement retire : `--device`, la sonde CUDA, `GpuIndisponible`, le
code de sortie 3 pour GPU absent, l'échantillonnage `nvidia-smi` du lanceur et
son artefact. Le code 3 sert maintenant à une dépendance manquante, ce que le
lanceur contrôlait déjà.

Ce que le changement apporte, et qui compte plus que le régresseur lui-même :
`early_stopping=False`. Laissé sur son défaut « auto », scikit-learn met de côté
10 % des exemples TIRÉS AU HASARD dès que l'échantillon dépasse dix mille
lignes. Sur une série temporelle, ce tirage est exactement la fuite que
`donnees.decouper` évite — la validation interne contiendrait des heures
postérieures à celles qu'elle sert à valider — et il ferait dépendre le résultat
d'un découpage qui n'est pas le nôtre, ce que le quatrième critère interdit.
`test_le_regresseur_desactive_l_arret_anticipe` le garde ; il est sauté là où
scikit-learn n'est pas installé et joué par la chaîne, qui l'installe depuis le
manifeste du service.

Le choix sert d'ailleurs ce quatrième critère : il n'y a plus de réduction
flottante sur GPU dont l'ordre varie d'une exécution à l'autre. La rejouabilité
reste mesurée par `entrainer.sh --deux-passes` et non affirmée — le nombre de
fils OpenMP peut encore faire varier les derniers chiffres, et
`OMP_NUM_THREADS=1` est le réglage à essayer si l'écart apparaît.

Le modèle est enregistré avec la saveur `mlflow.sklearn`. Le service du #37 le
charge par `mlflow.pyfunc.load_model` et n'a pas à connaître la saveur, mais il
lui faudra scikit-learn pour désérialiser l'objet.

Contrôles joués : ruff et mypy strict propres sur `services` et `packages`,
674 tests au vert et 1 sauté, couverture globale 89 %.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Le cluster porte `enervision_prod` et `enervision_preprod`. L'entraînement
visait la première par la seule variable que l'ETL pose ; il peut désormais
viser l'une ou l'autre par `ENERVISION_MODEL_ENVIRONNEMENT`, ou par
`--environnement prod|preprod` qui la surcharge. Défaut `prod` : c'est la base
que l'API sert, donc la seule dont un modèle promu doit avoir appris les
habitudes.

Le lanceur compose l'URL, jamais le Python : même contrat que le collecteur et
l'ETL, aucun chemin de secret dans le paquet. Trois sources par priorité —
`ENERVISION_MODEL_DATABASE_URL` déjà posée, qui est l'échappatoire vers une
copie ou un tunnel ; `ENERVISION_ETL_DATABASE_URL` pour `prod`, celle que le
rôle Ansible `app` pose et que le chargement de la zone or emploie déjà, pour
ne pas écrire deux fois la même URL ; la composition locale depuis le mot de
passe du rôle applicatif, qui porte le nom de sa base et jamais `postgres`.

Un `case` explicite plutôt qu'un `eval` sur un nom de variable calculé : deux
environnements, et un `eval` sur une valeur venue de la ligne de commande n'a
aucune raison d'exister dans ce script. L'URL n'est ni journalisée ni passée en
argument visible de `ps` : elle est exportée, et le module ne publie que le NOM
de la base.

CE QUE LE CHOIX IMPLIQUE, ET QUI COMPTE AUTANT QUE LE CHOIX. Pouvoir viser deux
bases, c'est pouvoir apprendre ailleurs que là où le modèle servira. L'alias
`production` étant ce que le service du #37 charge au démarrage, un modèle
appris sur la préproduction qui le prendrait servirait des prévisions apprises
sur des données qui ne sont pas celles de la base servie — et rien, dans le
registre, ne le dirait.

Deux garde-fous, donc.

`promotion_autorisee` refuse la promotion hors `enervision_prod`, sauf
`--promouvoir-hors-prod`. Le modèle est publié dans les deux cas : c'est
l'alias qui ne bouge pas, pas l'exécution qui disparaît. Une fonction pure
plutôt qu'un `if` dans `executer`, parce que les règles de ce paquet se testent
sans base ni registre.

Et c'est `base_de_l_url` qui décide, pas le réglage. Le nom de la base est relu
dans l'URL, seul des deux qui ne puisse pas mentir : un
`ENERVISION_MODEL_ENVIRONNEMENT=prod` sur une URL pointée vers la préproduction
passerait tous les contrôles fondés sur le réglage. Ce nom entre aussi dans le
motif de l'étiquette `promu_motif` — la seule mémoire du geste, le registre
n'étant pas versionné dans git — parce qu'une MAE de 3 kW ne veut pas dire la
même chose selon la base qui l'a produite.

Contrôles joués : ruff et mypy strict propres, 692 tests au vert et 1 sauté,
neuf tests nouveaux sur l'environnement, la relecture d'URL et le garde-fou.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deux défauts relevés à la première exécution réelle, le 7 septembre, et que seul
un vrai modèle pouvait montrer.

`register_model("runs:/<id>/modele")` ne visait rien. MLflow 3 ne range plus un
modèle sous les artefacts de son exécution mais comme une entité à lui, d'URI
`models:/m-<id>` : le chemin n'existait pas, et l'enregistrement ne réussissait
que par le repli du serveur — « Run with id ... has no artifacts at artifact
path 'modele', registering model based on models:/m-... instead ». Un repli qui
avertit aujourd'hui est un échec demain. L'enregistrement passe désormais par
`registered_model_name` dans `log_model`, dont le retour porte la version ; le
repli sur `info.model_uri` couvre une version de MLflow qui ne la porterait pas,
et c'est l'URI que le serveur vient de créer, pas un chemin reconstruit.

La signature et l'exemple d'entrée décrivaient le même objet de deux façons.
Signature déduite d'une liste de listes, exemple donné en tableau NumPy : MLflow
refusait de valider l'exemple qu'il venait d'écrire — « Invalid input. Invalid
object type at position 0 ». Les deux viennent maintenant du même tableau. NumPy
est déclaré au manifeste pour cette ligne, même si scikit-learn l'installerait
de toute façon : on déclare ce qu'on importe, comme `pytz` dans l'ETL.

ÉPROUVÉ SUR LE SERVEUR, trois exécutions successives sur `enervision_preprod`,
bornes 2026-07-30 → 2026-09-04 : plus aucun avertissement, versions 1 puis 2
puis 3 enregistrées, alias `production` déplacé à chaque fois — et la lecture de
la version en place avant déplacement donne bien « 2 -> 3 », ce que la première
exécution ne pouvait pas montrer faute de version précédente.

MAE identique au millième aux trois exécutions, 2,018 kW contre 21,443 kW pour
la persistance : la rejouabilité du quatrième critère est constatée, pas
supposée.

Le chargement par alias est éprouvé pour la première fois du projet :
`models:/enervision-prevision-h1@production` rend un PyFuncModel et sa version.
C'est la seule commande de `docs/runbooks/mlflow.md` §5 qui n'avait jamais pu
être jouée — « elle le sera au premier modèle du #36 » — et c'est la couture que
le #37 empruntera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'develop' into olivier/36-entrainement-modele (#36)
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 32s
Intégration / Contrôles statiques du dépôt (pull_request) Failing after 3s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 2m9s
91a3d4951f
inference: cite l'ADR 0012 sans lien tant qu'elle n'est pas dans develop (#36)
Some checks failed
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 28s
Intégration / Python — qualité, tests et dépendances (pull_request) Failing after 3m49s
46869ff5fd
`tests/ci/test-liens-markdown.sh` a rougi la chaîne sur la PR #166, et il avait
raison : `services/inference/README.md` renvoyait à
`../../docs/adr/0012-prevision-h1-avec-reference-publiee.md`, fiche qui vit sur
`lenaic/36-contrat-prevision` et n'est pas fusionnée. Un renvoi qui ne mène
nulle part se découvre en panne, c'est-à-dire au moment où quelqu'un cherche la
décision.

La fiche est donc citée par son numéro, sans lien, avec une note qui dit
pourquoi et quand le lien se posera. Même traitement dans la docstring de
`modele.py`, où le chemin relatif ne menait nulle part non plus — le contrôle ne
lit pas les fichiers Python, mais un chemin faux dans un commentaire trompe
autant.

Le contrôle des images de `verifier-images.sh` échoue sur un poste macOS pour
une raison sans rapport — « awk: newline in string », le BSD awk n'accepte pas
ce script — et la branche ne touche aucun fichier d'image. Les six autres
contrôles statiques passent en local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
inference: une version de modèle sans exécution n'a pas de métrique (#36)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 31s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m9s
b4df56e9b9
La chaîne a rougi sur mypy à la PR #166, et c'est un vrai défaut, pas une
chicane de typage : `ModelVersion.run_id` est facultatif côté MLflow, et
`metrique_de_version` le passait tel quel à `get_run`.

Le cas existe — une version enregistrée depuis un chemin externe plutôt que
depuis une exécution n'a pas d'exécution à relire — et la panne serait un
`MlflowException` au milieu d'une comparaison de versions, là où le protocole
promet déjà de pouvoir répondre « on ne sait pas ». Il rend donc `None`.

CE QUE CET ÉCHEC APPREND SUR NOTRE FAÇON DE VÉRIFIER. Le venv de travail du
dépôt ne porte que l'outillage — ruff, mypy, pytest — et aucune dépendance
applicative. mypy y traite donc `mlflow` par `ignore_missing_imports` et ne voit
rien ; la chaîne, elle, installe les manifestes des services via
`.forgejo/scripts/deps-services.py`, obtient les vrais types et trouve ce que le
poste ne peut pas trouver. Un `ignore_missing_imports` n'est pas un blanc-seing,
c'est un angle mort qui se déplace d'une machine à l'autre.

Rejoué dans un environnement fidèle à la chaîne — Python 3.12.3, scikit-learn,
MLflow et NumPy installés : ruff propre, mypy « Success », et 96 tests au vert
au lieu de 95 plus un sauté. Le test conditionnel
`test_le_regresseur_desactive_l_arret_anticipe` est bien exécuté là où
scikit-learn existe, ce qui était son intention : il vérifie sur le vrai
régresseur que l'arrêt anticipé est désactivé et que la graine est celle qu'on
lui donne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member

@olivier Ok pour moi une fois le conflit sur pytest.ini réglé

@olivier Ok pour moi une fois le conflit sur pytest.ini réglé
Un seul conflit, `pytest.ini`, annoncé dans la description de la PR #166 : la
#154 ajoutait `services/recommendations` et la #161 `packages/contracts` en
bout de la même ligne que celle où cette branche ajoute `services/inference`.
Les trois ajouts se gardent — les chemins sont cumulatifs, en retirer un
rendrait un paquet invisible aux tests. Le commentaire venu de `develop` est
conservé tel quel, et `services/inference` prend sa place dans l'ordre
alphabétique des services, `packages/contracts` restant en fin de ligne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
inference: la fiche de décision est la 0013, pas la 0012 (#36)
Some checks failed
Infra Ansible / Playbooks Ansible valides (pull_request) Has been cancelled
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 34s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 17s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m37s
b89175e0db
Conflit sémantique que la fusion de `develop` ne signale pas. Cette branche
citait « l'ADR 0012 » en treize endroits pour la décision de prévision H+1.
La fiche est entrée dans `develop` par la #161 sous le numéro **0013**
(`0013-prevision-h1-avec-reference-publiee.md`), et le numéro 0012 y désigne
désormais une décision sans rapport — le plafond de dépense déclaré hors
Terraform, #43. Laissées en l'état, ces treize citations envoyaient le
relecteur sur la mauvaise fiche, ce qui est pire qu'un lien mort : ça se lit
sans erreur.

Les quatre citations vérifiées mot pour mot dans la 0013 : « le T4 n'est pas
requis, ni pour l'entraînement ni pour l'inférence » (§Décision), « entrées
t−1 à t−24 », le critère de promotion « strictement inférieure à la MAE de la
persistance », et la colonne `reference_kw` que la migration 0017 pose bien.

Le lien se repose aussi, dans le README et la docstring de `modele.py` : la
note du 46869ff annonçait « le lien se posera quand la fiche entrera dans
`develop` », c'est fait, donc la note disparaît avec elle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge branch 'develop' into olivier/36-entrainement-modele
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 7s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
81cff17986
justine approved these changes 2026-09-07 13:13:54 +00:00
lenaic merged commit 0fd09f41c0 into develop 2026-09-07 13:15:57 +00:00
lenaic deleted branch olivier/36-entrainement-modele 2026-09-07 13:15:57 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision!166
No description provided.