You've already forked donnees_meteo
Premiers modèles prédictifs
This commit is contained in:
28
model/__init__.py
Normal file
28
model/__init__.py
Normal 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
77
model/baselines.py
Normal 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
215
model/features.py
Normal 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
65
model/splits.py
Normal 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)
|
||||
Reference in New Issue
Block a user