Files
2026-09-17 14:12:26 +02:00

130 lines
4.7 KiB
Python

"""Construction des features pour le modele de consommation.
Module partage entre l'entrainement et le futur scoring (cf. `docs/ML-START.md`) : la fonction
qui construit les features doit rester strictement identique des deux cotes, sous peine de
"train/serve skew" silencieux (le modele recoit en production des features qui ne ressemblent
plus a ce qu'il a appris).
"""
from collections.abc import Sequence
import pandas as pd
# Cible de l'entrainement : consommation en kWh, jamais consumption_kw (absent des lectures
# historiques CSV, cf. `apps/backend/app/etl/historical_import.py`).
TARGET_COLUMN = "consumption_kwh"
# Decalages horaires utilises pour les lags et moyennes glissantes : une heure avant, un jour
# avant (meme heure), une semaine avant (meme heure, meme jour) - saisonnalites usuelles d'une
# consommation energetique horaire.
LAG_HOURS: Sequence[int] = (1, 24, 168)
ROLLING_WINDOWS_HOURS: Sequence[int] = (24, 168)
STATIC_FEATURE_COLUMNS: Sequence[str] = ("site_type", "capacity_kw")
CALENDAR_FEATURE_COLUMNS: Sequence[str] = (
"hour",
"day_of_week",
"month",
"is_weekend",
"is_working_hours",
)
WEATHER_COLUMNS: Sequence[str] = (
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
)
def build_features(frame: pd.DataFrame) -> pd.DataFrame:
"""Construit la matrice de features a partir de lectures brutes triees par site.
`frame` doit porter au minimum : `site_id`, `timestamp`, `consumption_kwh`,
`is_working_hours`, les trois colonnes meteo, et les colonnes statiques de site
(`site_type`, `capacity_kw`). Une ligne par `(site_id, timestamp)`, sans doublon.
Piege : la meteo n'entre dans les features que decalee (lag/moyenne glissante), jamais a
l'instant cible. A l'entrainement comme au scoring, la meteo au moment predit n'est pas une
mesure mais une prevision que le projet n'a pas — l'utiliser telle quelle romprait le
contrat entre entrainement et usage reel (la feature ne serait tout simplement plus
disponible en production). Cf. debat d'architecture dans l'issue #89.
"""
travail = frame.sort_values(["site_id", "timestamp"]).reset_index(drop=True)
calendrier = _calendar_features(travail["timestamp"])
decalees = _lagged_features(travail)
features = pd.concat(
[
travail[["site_id", "timestamp"]],
travail[list(STATIC_FEATURE_COLUMNS)],
calendrier,
travail[["is_working_hours"]],
decalees,
travail[[TARGET_COLUMN]],
],
axis=1,
)
# `period_minutes` : resolution temporelle de la cible. Les lectures historiques sont toutes
# au pas horaire (cf. `dataset_metadata.json`, `frequency: "1h""), donc une constante pour
# l'instant. Exposee comme feature plutot que supposee implicitement, pour que le modele
# puisse un jour apprendre sur d'autres resolutions sans reentrainement de zero.
features["period_minutes"] = 60
return features
def feature_columns() -> list[str]:
"""Liste ordonnee des colonnes d'entree du modele (hors identifiants et cible)."""
lag_columns = [f"consumption_kwh_lag_{h}h" for h in LAG_HOURS]
rolling_columns = [
f"{colonne}_rolling_mean_{fenetre}h"
for colonne in (TARGET_COLUMN, *WEATHER_COLUMNS)
for fenetre in ROLLING_WINDOWS_HOURS
]
weather_lag_columns = [f"{colonne}_lag_1h" for colonne in WEATHER_COLUMNS]
return [
*STATIC_FEATURE_COLUMNS,
*CALENDAR_FEATURE_COLUMNS,
"period_minutes",
*lag_columns,
*rolling_columns,
*weather_lag_columns,
]
def _calendar_features(timestamps: pd.Series) -> pd.DataFrame:
instants = pd.to_datetime(timestamps)
return pd.DataFrame(
{
"hour": instants.dt.hour,
"day_of_week": instants.dt.dayofweek,
"month": instants.dt.month,
"is_weekend": instants.dt.dayofweek.isin([5, 6]).astype(int),
}
)
def _lagged_features(travail: pd.DataFrame) -> pd.DataFrame:
par_site = travail.groupby("site_id", sort=False)
colonnes: dict[str, pd.Series] = {}
for decalage in LAG_HOURS:
colonnes[f"{TARGET_COLUMN}_lag_{decalage}h"] = par_site[TARGET_COLUMN].shift(decalage)
for colonne in (TARGET_COLUMN, *WEATHER_COLUMNS):
decale = par_site[colonne].shift(1)
for fenetre in ROLLING_WINDOWS_HOURS:
colonnes[f"{colonne}_rolling_mean_{fenetre}h"] = decale.groupby(
travail["site_id"]
).transform(lambda serie, fenetre=fenetre: serie.rolling(fenetre, min_periods=1).mean())
for colonne in WEATHER_COLUMNS:
colonnes[f"{colonne}_lag_1h"] = par_site[colonne].shift(1)
return pd.DataFrame(colonnes, index=travail.index)