130 lines
4.7 KiB
Python
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)
|