1

Premiers modèles prédictifs

This commit is contained in:
2025-11-25 18:58:21 +01:00
parent 18afeb1e8b
commit ccd2195d27
15 changed files with 1610 additions and 0 deletions

28
model/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from .features import (
add_delta_features,
add_lag_features,
add_time_features,
add_wind_components,
build_feature_dataframe,
)
from .splits import chronological_split, rolling_time_series_splits
from .baselines import (
persistence_baseline,
moving_average_baseline,
hourly_climatology_baseline,
)
__all__ = [
"add_delta_features",
"add_lag_features",
"add_time_features",
"add_wind_components",
"build_feature_dataframe",
"chronological_split",
"rolling_time_series_splits",
"persistence_baseline",
"moving_average_baseline",
"hourly_climatology_baseline",
]

77
model/baselines.py Normal file
View File

@@ -0,0 +1,77 @@
"""Références simples (baselines) pour comparer nos modèles prédictifs."""
from __future__ import annotations
import pandas as pd
from .features import _steps_from_minutes, DEFAULT_BASE_FREQ_MINUTES
def _aligned_target(series: pd.Series, steps_ahead: int) -> pd.DataFrame:
"""
Construit un DataFrame avec la cible (y_true) et l'indice de base pour les prédictions.
y_true est la série décalée dans le futur (shift négatif), ce qui aligne chaque
valeur de référence avec le point de départ utilisé pour prédire.
"""
y_true = series.shift(-steps_ahead)
return pd.DataFrame({"y_true": y_true})
def persistence_baseline(
series: pd.Series,
*,
horizon_minutes: int,
base_freq_minutes: int = DEFAULT_BASE_FREQ_MINUTES,
) -> pd.DataFrame:
"""
Baseline de persistance : on suppose que la prochaine valeur sera identique à la dernière observée.
Retourne un DataFrame avec y_true (valeur future) et y_pred (valeur observée au temps présent),
alignés pour l'horizon demandé.
"""
steps = _steps_from_minutes(horizon_minutes, base_freq_minutes)
frame = _aligned_target(series, steps)
frame["y_pred"] = series
return frame.dropna()
def moving_average_baseline(
series: pd.Series,
*,
horizon_minutes: int,
window_minutes: int = 60,
base_freq_minutes: int = DEFAULT_BASE_FREQ_MINUTES,
) -> pd.DataFrame:
"""
Baseline de moyenne mobile : on prolonge la moyenne des dernières valeurs pour prévoir la prochaine.
"""
steps = _steps_from_minutes(horizon_minutes, base_freq_minutes)
window_steps = _steps_from_minutes(window_minutes, base_freq_minutes)
rolling_mean = series.rolling(window=window_steps, min_periods=max(1, window_steps // 2)).mean()
frame = _aligned_target(series, steps)
frame["y_pred"] = rolling_mean
return frame.dropna()
def hourly_climatology_baseline(
train_series: pd.Series,
eval_index: pd.DatetimeIndex,
*,
horizon_minutes: int,
) -> pd.Series:
"""
Baseline de climatologie horaire : on prédit la moyenne observée (sur la partie train)
pour l'heure du jour correspondant à l'horizon visé.
Retourne une série alignée sur eval_index, avec une prédiction pour chaque ligne.
"""
if not isinstance(eval_index, pd.DatetimeIndex):
raise TypeError("eval_index doit être un DatetimeIndex.")
climatology_by_hour = train_series.groupby(train_series.index.hour).mean()
# Heure cible : on ajoute l'horizon aux timestamps pour récupérer l'heure de la cible
target_hours = (eval_index + pd.to_timedelta(horizon_minutes, "minutes")).hour
preds = pd.Series(target_hours, index=eval_index).map(climatology_by_hour)
return preds

215
model/features.py Normal file
View File

@@ -0,0 +1,215 @@
"""Fonctions utilitaires pour préparer des variables dérivées simples."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Iterable, Sequence
import numpy as np
import pandas as pd
# Valeurs par défaut pour rester aligné sur le pas de 10 minutes du CSV
DEFAULT_BASE_FREQ_MINUTES = 10
# Les lags et fenêtres par défaut servent de garde-fous génériques pour un pas de 10 minutes.
# Lorsque des lags spécifiques ont été identifiés (ex : matrices du chapitre 5), on peut
# surcharger ces valeurs via FeatureSpec ou directement dans les fonctions.
DEFAULT_LAGS_MINUTES: tuple[int, ...] = (10, 20, 30)
DEFAULT_ROLLING_WINDOWS_MINUTES: tuple[int, ...] = (30, 60)
@dataclass(frozen=True)
class FeatureSpec:
"""Configuration minimale pour construire les variables dérivées."""
# Peut être une séquence (appliquée à toutes les colonnes) ou un mapping {col: [lags]}
lags_minutes: Sequence[int] | Mapping[str, Sequence[int]] = DEFAULT_LAGS_MINUTES
rolling_windows_minutes: Sequence[int] = DEFAULT_ROLLING_WINDOWS_MINUTES
base_freq_minutes: int = DEFAULT_BASE_FREQ_MINUTES
def _steps_from_minutes(minutes: int, base_freq_minutes: int) -> int:
"""Convertit un horizon en minutes vers un nombre de pas (arrondi à l'entier)."""
steps = int(round(minutes / base_freq_minutes))
if steps <= 0:
raise ValueError("L'horizon en minutes doit représenter au moins un pas de temps.")
return steps
def add_time_features(df: pd.DataFrame) -> pd.DataFrame:
"""
Ajoute des composantes sin/cos pour l'heure et le jour de l'année.
On évite ainsi le faux saut entre 23 h et 0 h (ou 31 décembre / 1er janvier).
"""
if not isinstance(df.index, pd.DatetimeIndex):
raise TypeError("add_time_features attend un index temporel (DatetimeIndex).")
out = df.copy()
idx = out.index
# Heure locale (fraction de la journée)
hour_fraction = (idx.hour + idx.minute / 60.0 + idx.second / 3600.0) / 24.0
hour_angle = 2 * np.pi * hour_fraction
out["time_hour_sin"] = np.sin(hour_angle)
out["time_hour_cos"] = np.cos(hour_angle)
# Jour de l'année (1..365/366)
day_of_year = idx.dayofyear
year_days = 366 if idx.is_leap_year.any() else 365
day_fraction = (day_of_year - 1) / year_days
day_angle = 2 * np.pi * day_fraction
out["time_dayofyear_sin"] = np.sin(day_angle)
out["time_dayofyear_cos"] = np.cos(day_angle)
return out
def add_wind_components(
df: pd.DataFrame,
*,
speed_col: str = "wind_speed",
direction_col: str = "wind_direction",
) -> pd.DataFrame:
"""Ajoute les composantes cartésiennes du vent (u, v) pour éviter la discontinuité 0/360°."""
out = df.copy()
missing = [col for col in (speed_col, direction_col) if col not in out.columns]
if missing:
raise KeyError(f"Colonnes manquantes pour le vent : {missing}")
direction_rad = np.deg2rad(out[direction_col].astype(float))
speed = out[speed_col].astype(float)
out["wind_u"] = speed * np.sin(direction_rad)
out["wind_v"] = speed * np.cos(direction_rad)
return out
def add_lag_features(
df: pd.DataFrame,
columns: Iterable[str],
*,
lags_minutes: Sequence[int] | Mapping[str, Sequence[int]] = DEFAULT_LAGS_MINUTES,
base_freq_minutes: int = DEFAULT_BASE_FREQ_MINUTES,
) -> pd.DataFrame:
"""Ajoute des valeurs décalées dans le passé (lags) pour les colonnes données."""
out = df.copy()
for col in columns:
if col not in out.columns:
raise KeyError(f"Colonne absente pour les lags : {col}")
lags_for_col: Sequence[int]
if isinstance(lags_minutes, Mapping):
lags_for_col = lags_minutes.get(col, DEFAULT_LAGS_MINUTES)
else:
lags_for_col = lags_minutes
for lag in lags_for_col:
steps = _steps_from_minutes(lag, base_freq_minutes)
out[f"{col}_lag_{lag}m"] = out[col].shift(steps)
return out
def add_delta_features(
df: pd.DataFrame,
columns: Iterable[str],
*,
delta_minutes: int = DEFAULT_BASE_FREQ_MINUTES,
base_freq_minutes: int = DEFAULT_BASE_FREQ_MINUTES,
) -> pd.DataFrame:
"""
Ajoute des variations récentes (delta) pour chaque colonne : valeur actuelle moins valeur à T - delta.
Cela donne la pente locale (vitesse de variation) sur le dernier intervalle.
"""
out = df.copy()
steps = _steps_from_minutes(delta_minutes, base_freq_minutes)
for col in columns:
if col not in out.columns:
raise KeyError(f"Colonne absente pour les deltas : {col}")
out[f"{col}_delta_{delta_minutes}m"] = out[col] - out[col].shift(steps)
return out
def add_rolling_features(
df: pd.DataFrame,
columns: Iterable[str],
*,
windows_minutes: Sequence[int] = DEFAULT_ROLLING_WINDOWS_MINUTES,
base_freq_minutes: int = DEFAULT_BASE_FREQ_MINUTES,
stats: Sequence[str] = ("mean", "median"),
) -> pd.DataFrame:
"""Ajoute des statistiques glissantes (moyenne/médiane) sur les colonnes données."""
out = df.copy()
for col in columns:
if col not in out.columns:
raise KeyError(f"Colonne absente pour les moyennes glissantes : {col}")
for window in windows_minutes:
steps = _steps_from_minutes(window, base_freq_minutes)
rolling = out[col].rolling(window=steps, min_periods=max(1, steps // 2))
for stat in stats:
if stat == "mean":
out[f"{col}_rollmean_{window}m"] = rolling.mean()
elif stat == "median":
out[f"{col}_rollmed_{window}m"] = rolling.median()
else:
raise ValueError(f"Statistique glissante non supportée : {stat}")
return out
def build_feature_dataframe(
df: pd.DataFrame,
*,
feature_spec: FeatureSpec | None = None,
target_columns: Sequence[str] = ("temperature", "wind_speed", "rain_rate"),
include_event_flags: bool = True,
) -> pd.DataFrame:
"""
Construit un DataFrame enrichi avec les variables dérivées essentielles.
- Ajoute les composantes temporelles sin/cos.
- Ajoute les composantes du vent (u, v) si disponibles.
- Ajoute lags, deltas, moyennes glissantes pour les colonnes cibles fournies.
- Optionnellement ajoute des indicateurs d'événements simples.
"""
spec = feature_spec or FeatureSpec()
out = df.copy()
out = add_time_features(out)
# Vent en composantes u/v si les colonnes existent
if "wind_speed" in out.columns and "wind_direction" in out.columns:
out = add_wind_components(out)
out = add_lag_features(
out,
columns=target_columns,
lags_minutes=spec.lags_minutes,
base_freq_minutes=spec.base_freq_minutes,
)
out = add_delta_features(
out,
columns=target_columns,
delta_minutes=spec.base_freq_minutes,
base_freq_minutes=spec.base_freq_minutes,
)
out = add_rolling_features(
out,
columns=target_columns,
windows_minutes=spec.rolling_windows_minutes,
base_freq_minutes=spec.base_freq_minutes,
)
if include_event_flags:
if "rain_rate" in out.columns:
out["flag_rain_now"] = (out["rain_rate"] > 0).astype(int)
if "wind_speed" in out.columns:
out["flag_wind_strong"] = (out["wind_speed"] >= 30.0).astype(int)
if "temperature" in out.columns:
out["flag_hot"] = (out["temperature"] >= 30.0).astype(int)
out["flag_cold"] = (out["temperature"] <= 0.0).astype(int)
return out

65
model/splits.py Normal file
View File

@@ -0,0 +1,65 @@
"""Fonctions de découpe temporelle pour l'entraînement et la validation."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterator
import pandas as pd
@dataclass(frozen=True)
class Split:
"""Indices pour une paire (train, validation)."""
train: pd.Index
validation: pd.Index
def chronological_split(
df: pd.DataFrame,
*,
train_frac: float = 0.7,
val_frac: float = 0.15,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""
Coupe un DataFrame chronologiquement en (train, validation, test) sans fuite temporelle.
"""
if not 0 < train_frac < 1 or not 0 < val_frac < 1:
raise ValueError("train_frac et val_frac doivent être dans ]0, 1[.")
if train_frac + val_frac >= 1:
raise ValueError("train_frac + val_frac doit être < 1.")
n = len(df)
n_train = int(n * train_frac)
n_val = int(n * val_frac)
train_df = df.iloc[:n_train]
val_df = df.iloc[n_train : n_train + n_val]
test_df = df.iloc[n_train + n_val :]
return train_df, val_df, test_df
def rolling_time_series_splits(
df: pd.DataFrame,
*,
n_splits: int = 3,
train_frac: float = 0.7,
val_frac: float = 0.15,
) -> Iterator[Split]:
"""
Génère plusieurs paires (train, validation) chronologiques en “roulant” la fenêtre.
Chaque _fold_ commence en début de série et pousse progressivement la frontière
train/validation vers le futur. Le test final reste en dehors de ces folds.
"""
if n_splits < 1:
raise ValueError("n_splits doit être >= 1.")
for split_idx in range(n_splits):
# On avance la fenêtre de validation à chaque itération
offset = int(len(df) * 0.05 * split_idx)
sub_df = df.iloc[offset:]
train_df, val_df, _ = chronological_split(sub_df, train_frac=train_frac, val_frac=val_frac)
yield Split(train=train_df.index, validation=val_df.index)