fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux

This commit is contained in:
Dorian
2026-09-18 14:58:39 +02:00
parent db81290026
commit eb4291b10a
12 changed files with 258 additions and 202 deletions
+5
View File
@@ -119,6 +119,11 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame:
LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi
n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas
seulement `capacity_kw`.
Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas
seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement
`NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au
scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees.
"""
typee = frame.copy()
for colonne in NUMERIC_COLUMNS:
+71 -34
View File
@@ -30,6 +30,13 @@ from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_feature
# Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure.
LOOKBACK = timedelta(days=21)
# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure
# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont
# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait
# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard)
# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours.
MAX_STALENESS = timedelta(hours=24)
TARGET_METRIC = "consumption_kwh"
PERIOD_MINUTES = 60
LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h"
@@ -38,6 +45,14 @@ INSUFFICIENT_DATA_REASON = (
)
def _stale_reason(age: pd.Timedelta) -> str:
return (
f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h "
f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement "
"arrêtée pour ce site."
)
@dataclass(frozen=True, slots=True)
class ScoredSite:
site_id: str
@@ -86,10 +101,31 @@ def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) ->
return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True)
def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite]:
def score(
booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime
) -> list[ScoredSite]:
resultats: list[ScoredSite] = []
insuffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].isna()]
# `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) :
# on en deduit l'age de cette derniere lecture par rapport a `instant`.
travail = scoring_frame.copy()
travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1))
perimes = travail[travail["_age"] > MAX_STALENESS]
for enregistrement in _records(perimes):
resultats.append(
ScoredSite(
site_id=enregistrement["site_id"],
target_at=enregistrement["timestamp"].to_pydatetime(),
status="insufficient_data",
predicted_value=None,
failure_reason=_stale_reason(enregistrement["_age"]),
)
)
a_jour = travail[travail["_age"] <= MAX_STALENESS]
insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()]
for enregistrement in _records(insuffisants):
resultats.append(
ScoredSite(
@@ -101,7 +137,7 @@ def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite]
)
)
suffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].notna()]
suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()]
if not suffisants.empty:
typee = suffisants.copy()
typee["site_type"] = typee["site_type"].astype("category")
@@ -163,20 +199,23 @@ def write_predictions(
connection.execute(_INSERT_PREDICTION, lignes)
def _load_recent(*, csv_path: Path | None, now: datetime | None) -> tuple[pd.DataFrame, datetime]:
if csv_path is not None:
brute = load_from_csv(csv_path)
instant = now or (
brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC)
)
return brute[brute["timestamp"] >= instant - LOOKBACK], instant
def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]:
brute = load_from_csv(csv_path)
instant = now or (
brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC)
)
return brute[brute["timestamp"] >= instant - LOOKBACK], instant
instant = now or datetime.now(UTC)
engine = create_engine(config.database_url())
try:
return load_recent_from_database(engine, since=instant - LOOKBACK), instant
finally:
engine.dispose()
def _score_frame(
recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime
) -> list[ScoredSite]:
scoring_frame = build_scoring_frame(recent, site_id=site_id)
if scoring_frame.empty:
return []
booster = lgb.Booster(model_file=str(model_path))
return score(booster, scoring_frame, instant=instant)
def run_scoring(
@@ -190,29 +229,27 @@ def run_scoring(
En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future"
calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base
joignable, cf. `ml/README.md`.
joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans
`build_scoring_frame` : le filtrer aussi ici serait redondant.
"""
recent, _instant = _load_recent(csv_path=csv_path, now=now)
if site_id is not None:
recent = recent[recent["site_id"] == site_id]
if csv_path is not None:
recent, instant = _load_recent_from_csv(csv_path, now=now)
return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
scoring_frame = build_scoring_frame(recent, site_id=site_id)
if scoring_frame.empty:
return []
# Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape.
engine = create_engine(config.database_url())
try:
instant = now or datetime.now(UTC)
recent = load_recent_from_database(engine, since=instant - LOOKBACK)
resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant)
booster = lgb.Booster(model_file=str(model_path))
resultats = score(booster, scoring_frame)
if csv_path is None:
reference = model_reference(model_path)
engine = create_engine(config.database_url())
try:
with engine.begin() as connection:
write_predictions(connection, resultats, reference=reference)
finally:
engine.dispose()
with engine.begin() as connection:
write_predictions(connection, resultats, reference=reference)
return resultats
return resultats
finally:
engine.dispose()
def parse_args() -> argparse.Namespace:
+46 -25
View File
@@ -1,34 +1,55 @@
from pathlib import Path
import pandas as pd
from enervision_ml.data import NUMERIC_COLUMNS, OUTPUT_COLUMNS, _typer
from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv
_CSV_HEADER = (
"site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent,"
"solar_irradiance_wm2,is_working_hours,site_type"
)
def make_frame_with_object_dtype_capacity() -> pd.DataFrame:
# Reproduit ce que `pd.read_sql` renvoie pour une colonne entierement `NULL` en base :
# dtype `object` rempli de `None`, pas `float64` rempli de `NaN`.
frame = pd.DataFrame(
{colonne: [1.0, 2.0] for colonne in OUTPUT_COLUMNS if colonne not in NUMERIC_COLUMNS}
def write_csv(tmp_path: Path, *lignes: str) -> Path:
csv_path = tmp_path / "recent.csv"
csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n")
return csv_path
def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None:
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
frame = load_from_csv(csv_path)
for colonne in NUMERIC_COLUMNS:
assert frame[colonne].dtype == "float64"
def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None:
# Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type
# alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de
# divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement
# `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui).
csv_path = write_csv(
tmp_path,
"SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office",
"SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office",
)
for colonne in NUMERIC_COLUMNS:
frame[colonne] = pd.Series([None, None], dtype="object")
return frame
frame = load_from_csv(csv_path)
assert frame["consumption_kwh"].dtype == "float64"
assert frame["consumption_kwh"].iloc[0] == 10.5
assert pd.isna(frame["consumption_kwh"].iloc[1])
def test_typer_coerces_an_all_null_object_column_to_float() -> None:
frame = make_frame_with_object_dtype_capacity()
def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None:
# `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette
# affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test
# garde le contrat visible malgre tout, au cas ou l'implementation changerait.
csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office")
typee = _typer(frame)
frame = load_from_csv(csv_path)
for colonne in NUMERIC_COLUMNS:
assert typee[colonne].dtype == "float64"
assert typee[colonne].isna().all()
def test_typer_preserves_real_numeric_values() -> None:
frame = make_frame_with_object_dtype_capacity()
frame["capacity_kw"] = pd.Series([100.0, None], dtype="object")
typee = _typer(frame)
assert typee["capacity_kw"].tolist()[0] == 100.0
assert pd.isna(typee["capacity_kw"].tolist()[1])
assert frame["capacity_kw"].dtype == "float64"
assert pd.isna(frame["capacity_kw"].iloc[0])
+49 -2
View File
@@ -8,6 +8,7 @@ import pytest
from enervision_ml.features import TARGET_COLUMN
from enervision_ml.score import (
LAG_168H_COLUMN,
MAX_STALENESS,
ScoredSite,
build_scoring_frame,
model_reference,
@@ -123,12 +124,23 @@ def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() ->
assert scoring_frame.empty
def target_at_for(depart: datetime, heures: int) -> datetime:
"""`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture
+ 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption,
pour les tests qui ne visent pas ce filtre."""
return depart + timedelta(hours=heures)
def test_score_marks_insufficient_history_without_calling_the_model() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart))
booster = FakeBooster()
resultats = score(booster, scoring_frame) # type: ignore[arg-type]
resultats = score(
booster, # type: ignore[arg-type]
scoring_frame,
instant=target_at_for(depart, 100),
)
assert resultats == [
ScoredSite(
@@ -147,7 +159,11 @@ def test_score_predicts_when_history_is_sufficient() -> None:
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
booster = FakeBooster(valeur=99.5)
resultats = score(booster, scoring_frame) # type: ignore[arg-type]
resultats = score(
booster, # type: ignore[arg-type]
scoring_frame,
instant=target_at_for(depart, 200),
)
assert len(resultats) == 1
assert resultats[0].status == "available"
@@ -156,6 +172,37 @@ def test_score_predicts_when_history_is_sufficient() -> None:
assert booster.appels == [1]
def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
# Historique largement suffisant (168h+), mais l'instant de reference est loin apres la
# derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique.
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1)
booster = FakeBooster()
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
assert len(resultats) == 1
assert resultats[0].status == "insufficient_data"
assert resultats[0].predicted_value is None
assert "vieille" in (resultats[0].failure_reason or "")
assert booster.appels == []
def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart))
# `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure
# pour retomber exactement sur le seuil, ni en dessous ni au dessus.
instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1)
booster = FakeBooster(valeur=12.0)
resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type]
assert resultats[0].status == "available"
assert booster.appels == [1]
def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None:
connection = FakeConnection()