fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user