Premiers modèles prédictifs
This commit is contained in:
parent
18afeb1e8b
commit
ccd2195d27
52
docs/08 - Cadre prédictif local/index.md
Normal file
52
docs/08 - Cadre prédictif local/index.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Cadre prédictif local
|
||||
|
||||
Objectif : poser les bases d’un modèle sur-mesure qui prédit, au pas local de la station, la température, la pluie et le vent à plusieurs horizons (T+10 min, +60 min, +6 h, +24 h).
|
||||
On garde une approche expérimentale : on cherche à comprendre ce qui fonctionne ou échoue, non pas à atteindre une performance commerciale.
|
||||
Il ne s'agit pas ici de venir concurrencer Météo France, mais de jouer avec nos données et avec l'IA.
|
||||
|
||||
## Cibles et horizons
|
||||
|
||||
- Température (continue) ; Vitesse du vent (continue) ; Précipitations binaires (pluie ou neige oui/non). Éventuellement : événements extrêmes (forte chaleur/froid, risque d’orage) vus comme des seuils.
|
||||
- Horizons évalués : T+10, T+60, T+360 (~6 h), T+1440 (~24 h) minutes pour voir quand notre modèle montrera ses faiblesses.
|
||||
|
||||
## Métriques
|
||||
|
||||
- Température / vent : _MAE_ (_Mean Absolute Error_) = moyenne des écarts en valeur absolue, facile à lire en °C ou km/h ; _RMSE_ (_Root Mean Squared Error_) pénalise davantage les grosses erreurs pour mieux voir les limites du modèle lorsque des écarts importants apparaissent.
|
||||
- Précipitations binaires : précision (part des annonces de pluie qui étaient justes), rappel (part des pluies réellement captées), _F1_ (compromis précision/rappel), _Brier score_ (qualité des probabilités, plus il est bas mieux c’est) et _calibration_ des probabilités (est-ce qu’un 30 % de pluie signifie vraiment ~30 % des cas).
|
||||
- Événements extrêmes : même logique précision/rappel sur dépassement de seuils (chaleur/froid/rafale), avec suivi des fausses alertes pour rester prudent.
|
||||
|
||||
## Limites à garder en tête
|
||||
|
||||
- Pas d’hiver complet dans le jeu actuel (mars→novembre) : les régimes froids et la neige sont absents.
|
||||
- Aucune info synoptique (pression régionale, nébulosité, vent en altitude) : le modèle reste “aveugle” au contexte large.
|
||||
- Pluie rare (~4 % des pas), donc classes déséquilibrées pour la partie pluie/orage.
|
||||
- Pas brut à 10 minutes : bon pour réactivité courte, mais bruité ; on testera aussi des features lissées.
|
||||
|
||||
## Données et découpes
|
||||
|
||||
- Source principale : `data/weather_minutely.csv` (pas 10 min), enrichissable au fil du temps. On peut réutiliser les CSV dérivés des chapitres précédents (matrices de lags/corrélations du chapitre 5, notamment) pour guider les lags utiles ou vérifier la cohérence.
|
||||
- Découpe temporelle sans fuite : partie _train_ (début→~70 %), partie validation (~15 % suivant), partie test finale (~15 % le plus récent). Variante : _time-series split_ “en rouleau”, où l’on répète ce découpage plusieurs fois ; on appelle _fold_ chaque paire (train, validation) ainsi construite.
|
||||
- Normalisation/standardisation : on calcule les paramètres (par exemple moyenne et écart-type) uniquement sur la partie _train_, puis on applique ces mêmes paramètres à la validation et au test. Cela évite d’introduire, par mégarde, des informations issues du futur dans les étapes de préparation des données.
|
||||
|
||||
## Variables dérivées de base (simples et explicables)
|
||||
|
||||
- Temps (_sin_/_cos_) : l’heure et le jour de l’année sont périodiques ; représenter l’heure avec _sin_/_cos_ évite un faux saut entre 23h et 0h ou entre 31 déc et 1er jan. On encode ainsi heure/minute sur 24 h et jour de l’année sur 365 j.
|
||||
- Lags courts : valeurs à T-10, -20, -30 min pour chaque variable cible ; deltas (T0 − T-10) pour décrire la tendance récente (la “pente”) : est‑ce que la température, le vent ou la pression augmentent ou diminuent, et à quelle vitesse. Les lags analysés au chapitre 5 serviront d’inspiration.
|
||||
- Moyennes glissantes : moyenne ou médiane sur 30–60 min pour lisser le bruit ; cumul de pluie sur 30–60 min pour connaître l’état “humide” récent.
|
||||
- Composantes vent : (u, v) = (speed _ *sin*(direction), speed _ _cos_(direction)) pour représenter la direction sans discontinuité 0/360°.
|
||||
- Drapeaux d’événements : pluie*en_cours (rain_rate > 0), vent_fort (seuil), chaleur/froid (seuils). Peuvent servir de \_features* et de cibles dérivées.
|
||||
|
||||
## Références simples (points de comparaison)
|
||||
|
||||
- Persistance : prédire que la valeur reste identique à T0 (par horizon).
|
||||
- Climatologie horaire : moyenne/quantiles par heure locale (et éventuellement par saison) pour température/vent ; fréquence de pluie par heure pour la pluie.
|
||||
- Moyenne mobile : prolonger la moyenne des 30–60 dernières minutes comme prédiction à court terme.
|
||||
- Pluie rare : classifieur “toujours sec” comme référence minimale. Si un modèle ne fait pas mieux, il est probablement inutile.
|
||||
|
||||
## Modèles à introduire dans les chapitres suivants
|
||||
|
||||
- Modèles linéaires avec régularisation (_Ridge_/_Lasso_) pour températures/vents : même formule que la régression linéaire classique, mais avec un terme supplémentaire qui limite l’ampleur des coefficients pour réduire le sur‑apprentissage (_Ridge_ pénalise surtout les coefficients trop grands, _Lasso_ peut en forcer certains à zéro).
|
||||
- Régression logistique pour la pluie : produit une probabilité de pluie plutôt qu’un oui/non brut, ce qui permet ensuite de choisir un seuil de décision adapté à l’usage (plutôt prudent ou plutôt conservateur).
|
||||
- Si besoin de courbes plus flexibles : arbres peu profonds, _random forest_ ou _boosting_ légers pour capturer des relations non linéaires sans rendre le modèle complètement opaque.
|
||||
- Évaluation multi-horizons avec _time-series split_, courbes d’erreur en fonction de l’horizon pour voir à partir de quand le modèle décroche.
|
||||
- Pipeline d’inférence local (_pipeline_ de prédiction) : charger le dernier point, générer les variables dérivées, prédire T+10/+60/+360/+1440, journaliser l’erreur au fil du temps pour suivre la qualité du modèle.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
93
docs/09 - Premiers modèles prédictifs/index.md
Normal file
93
docs/09 - Premiers modèles prédictifs/index.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Premiers modèles prédictifs
|
||||
|
||||
Objectif : passer de la description à la prédiction sur nos données locales, en restant simple et lisible. On compare quelques approches de base sur les horizons T+10, T+60, T+360 (~6 h) et T+1440 (~24 h) pour température, vent et pluie, sans présupposer que ça va marcher à tous les coups.
|
||||
|
||||
```shell
|
||||
python "docs/09 - Premiers modèles prédictifs/scripts/run_baselines.py"
|
||||
```
|
||||
|
||||
Le script génère :
|
||||
|
||||
- deux CSV de résultats dans `docs/09 - Premiers modèles prédictifs/data/` :
|
||||
- `baselines_regression.csv` (température/vent, MAE/RMSE, splits validation/test)
|
||||
- `baselines_rain.csv` (pluie binaire, précision/rappel/F1/Brier, splits validation/test)
|
||||
- deux figures de synthèse (validation) dans `docs/09 - Premiers modèles prédictifs/figures/` :
|
||||
- `baselines_mae_validation.png` (MAE vs horizon pour température et vent)
|
||||
- `baselines_rain_validation.png` (F1 et Brier vs horizon pour la pluie)
|
||||
|
||||
```shell
|
||||
python "docs/09 - Premiers modèles prédictifs/scripts/run_first_models.py"
|
||||
```
|
||||
|
||||
Ce second script :
|
||||
|
||||
- construit les variables dérivées (sin/cos temporels, lags, deltas, moyennes glissantes, vent u/v, drapeaux) à partir du CSV brut ;
|
||||
- découpe en _train_/_validation_/_test_ (70/15/15 %) ;
|
||||
- utilise la matrice de corrélation décalée (chapitre 5) pour privilégier les variables/lags dont |r| ≥ 0,2, tout en conservant les cibles ; l’humidité, la pression, l’illuminance, etc. sont donc injectées quand elles sont corrélées à la cible ;
|
||||
- entraîne Ridge/Lasso pour température et vent, régression logistique pour la pluie ;
|
||||
- exporte `models_regression.csv` et `models_rain.csv` dans `docs/09 - Premiers modèles prédictifs/data/` ;
|
||||
- produit `models_mae_validation.png` (MAE vs horizon pour température et vent) dans `docs/09 - Premiers modèles prédictifs/figures/`.
|
||||
|
||||
## Données et préparation
|
||||
|
||||
- Jeu principal : `data/weather_minutely.csv` (pas 10 min), mis à jour au fil du temps. On peut réutiliser les CSV dérivés (matrices de lags/corrélations du chapitre 5) pour choisir des lags pertinents et vérifier la cohérence.
|
||||
- Variables dérivées reprises du chapitre 8 : temps en _sin_/_cos_, lags courts (T-10/-20/-30), deltas (variation récente), moyennes/cumul sur 30–60 min, composantes (u, v) du vent, drapeaux d’événements (pluie en cours, vent fort, chaleur/froid).
|
||||
- Normalisation : on calcule moyenne/écart-type sur la partie _train_ uniquement, puis on applique ces paramètres aux parties _validation_ et _test_ pour ne pas utiliser d’informations futures.
|
||||
|
||||
## Découpage et validation
|
||||
|
||||
- Découpe sans fuite : _train_ (début→~70 %), _validation_ (~15 % suivant), _test_ (~15 % le plus récent), tout en ordre chronologique.
|
||||
- Variante robuste : _time-series split_ “en rouleau”, où l’on répète plusieurs découpes successives ; chaque paire (_train_, _validation_) est un _fold_. Cela aide à voir si un modèle reste stable dans le temps.
|
||||
|
||||
## Références de comparaison (_baselines_)
|
||||
|
||||
- Persistance : prédire que la prochaine valeur est identique à la dernière observée (par horizon).
|
||||
- Climatologie horaire : moyenne ou quantiles par heure locale (et éventuellement par saison) pour température/vent ; fréquence de pluie par heure pour la pluie.
|
||||
- Moyenne mobile : prolonger la moyenne des 30–60 dernières minutes.
|
||||
- Pluie rare : classifieur “toujours sec” comme seuil minimal ; si un modèle ne fait pas mieux, il ne sert à rien.
|
||||
|
||||
## Modèles simples à essayer
|
||||
|
||||
- Régressions linéaires avec régularisation (_Ridge_/_Lasso_) pour température et vent : même principe que la régression linéaire, avec un terme qui limite l’ampleur des coefficients (_Ridge_) ou peut en annuler certains (_Lasso_) pour éviter le sur-apprentissage.
|
||||
- Régression logistique pour la pluie : fournit une probabilité de pluie plutôt qu’un oui/non, ce qui permet d’ajuster le seuil selon l’usage (prudence ou non).
|
||||
- Si besoin de non-linéarités : petits arbres de décision, _random forest_ ou _boosting_ légers pour capturer des relations plus courbes tout en restant interprétables.
|
||||
|
||||
## Lecture des résultats
|
||||
|
||||
- Température / vent : _MAE_ et _RMSE_ (définis au chapitre 8) pour juger l’erreur moyenne et la sensibilité aux grosses erreurs.
|
||||
- Pluie : précision, rappel, _F1_, _Brier score_ et calibration des probabilités pour voir si les annonces de pluie sont réalistes et bien calibrées.
|
||||
- Multi-horizons : tracer l’erreur en fonction de l’horizon pour identifier à partir de quand la prévision décroche. On s’attend à ce que +24 h soit difficile sans contexte synoptique, et on documentera ces limites.
|
||||
|
||||
## Déroulé proposé
|
||||
|
||||
1. Construire les variables dérivées et sauvegarder un jeu prêt pour l’apprentissage (en suivant le découpage temporel).
|
||||
2. Évaluer les références (_baselines_) sur chaque horizon.
|
||||
3. Entraîner les modèles simples (linéaires régularisés, logistique, éventuellement arbres légers) et comparer aux références.
|
||||
4. Consolider l’évaluation multi-horizons (_time-series split_), conserver les résultats pour les chapitres suivants (affinements et pipeline d’inférence local).
|
||||
|
||||
## Synthèse visuelle des baselines (validation)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Ce que montrent ces baselines
|
||||
|
||||
- Température : la persistance reste imbattable jusqu’à +6 h avec une MAE < 1 °C ; au-delà (+24 h), l’erreur grimpe (≈1,5 °C) mais reste meilleure que la climatologie horaire qui plafonne autour de 4–6 °C. On part donc avec un avantage net sur le très court terme, mais l’horizon journalier sera plus difficile.
|
||||
- Vent : la moyenne mobile 60 min devance légèrement la persistance dès +10 min, mais l’écart reste faible et l’erreur croît avec l’horizon (MAE ≈2 km/h à +24 h). Le gain potentiel d’un modèle plus riche sera modeste si l’on reste sur ce pas de 10 min.
|
||||
- Pluie (binaire) : la persistance affiche des F1 élevés aux petits horizons parce que la pluie est rare et que “rester sec” gagne souvent ; le Brier augmente avec l’horizon, signe que la confiance se dégrade. La climatologie horaire est nulle : sans contexte, elle ne voit pas la pluie. Toute tentative de modèle devra donc battre la persistance sur F1/Brier, surtout à +60/+360 min où le score chute déjà.
|
||||
- Conclusion provisoire : les baselines définissent une barre à franchir — forte sur le très court terme (température/vent), beaucoup plus basse pour la pluie (où la rareté favorise la persistance). Les modèles devront prouver un gain net sur ces repères, en particulier sur les horizons intermédiaires (+60/+360 min) où la prévisibilité commence à décrocher.
|
||||
|
||||
## Premiers modèles (Ridge/Lasso/logistique)
|
||||
|
||||

|
||||
|
||||
- Température : Ridge/Lasso battent légèrement la persistance sur tous les horizons sauf à +10 min (MAE ≈0,14 à +60 min vs 0,15 pour la persistance ; ≈1,48 à +1440 vs 1,55). Injecter les variables corrélées (humiditié, illumination, pression…) donne un petit gain par rapport à la version “lags génériques”, mais la marge reste modeste.
|
||||
- Vent : même logique, un léger mieux que la persistance (≈0,87 à +10 min vs 0,99 ; ≈1,67 à +1440 vs 1,74). Les corrélations étant faibles, l’apport des autres variables reste limité.
|
||||
- Pluie : la régression logistique reste derrière la persistance (F1 ≈0,91 à +10 min contre 0,94 pour la persistance ; chute rapide à +60 et au-delà). La probabilité est calibrée (Brier ≈0,011 à +10 min), mais ne compense pas l’avantage de “rester sec”. Il faudra enrichir les features ou changer de modèle pour espérer dépasser la baseline.
|
||||
|
||||
En résumé, les modèles linéaires apportent un petit gain sur température/vent et échouent encore à battre la persistance pour la pluie. C’est une base de référence ; les prochains essais devront justifier leur complexité par un gain clair, surtout sur les horizons où les baselines se dégradent (+60/+360 min).
|
||||
|
||||
## Conclusion provisoire du chapitre
|
||||
|
||||
Contre l’intuition, c’est au très court terme que nos modèles simples se heurtent à un mur pour la pluie : la persistance reste devant à +10 min, et l’écart se creuse déjà à +60 min. Pour la température et le vent, les gains existent mais restent modestes, même à +10 min, alors qu’on pouvait espérer les “faciles”. Les horizons longs se dégradent comme prévu, mais le vrai défi est donc d’améliorer les prédictions proches sans sur-complexifier. Prochaine étape : tester des modèles plus flexibles (arbres/boosting) et enrichir les features, tout en vérifiant que le gain sur les petits horizons justifie l’effort.
|
||||
344
docs/09 - Premiers modèles prédictifs/scripts/run_baselines.py
Normal file
344
docs/09 - Premiers modèles prédictifs/scripts/run_baselines.py
Normal file
@ -0,0 +1,344 @@
|
||||
# scripts/run_baselines.py
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Iterable
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.metrics import (
|
||||
mean_absolute_error,
|
||||
mean_squared_error,
|
||||
precision_recall_fscore_support,
|
||||
brier_score_loss,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from meteo.dataset import load_raw_csv
|
||||
from model.baselines import (
|
||||
persistence_baseline,
|
||||
moving_average_baseline,
|
||||
hourly_climatology_baseline,
|
||||
)
|
||||
from model.splits import chronological_split
|
||||
from model.features import _steps_from_minutes, DEFAULT_BASE_FREQ_MINUTES
|
||||
|
||||
|
||||
CSV_PATH = Path("data/weather_minutely.csv")
|
||||
DOC_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = DOC_DIR / "data"
|
||||
FIG_DIR = DOC_DIR / "figures"
|
||||
HORIZONS_MINUTES: tuple[int, ...] = (10, 60, 360, 1440)
|
||||
CONTINUOUS_TARGETS: tuple[str, ...] = ("temperature", "wind_speed")
|
||||
RAIN_TARGET: str = "rain_rate"
|
||||
MOVING_AVG_WINDOW_MINUTES = 60
|
||||
|
||||
|
||||
def _ensure_columns(df: pd.DataFrame, columns: Iterable[str]) -> None:
|
||||
missing = [c for c in columns if c not in df.columns]
|
||||
if missing:
|
||||
raise KeyError(f"Colonnes manquantes dans le DataFrame : {missing}")
|
||||
|
||||
|
||||
def _regression_scores(y_true: pd.Series, y_pred: pd.Series) -> dict[str, float]:
|
||||
return {
|
||||
"mae": float(mean_absolute_error(y_true, y_pred)),
|
||||
"rmse": float(np.sqrt(mean_squared_error(y_true, y_pred))),
|
||||
}
|
||||
|
||||
|
||||
def _classification_scores(y_true: pd.Series, proba: pd.Series, threshold: float = 0.5) -> dict[str, float]:
|
||||
proba = proba.clip(0.0, 1.0)
|
||||
y_pred = (proba >= threshold).astype(int)
|
||||
precision, recall, f1, _ = precision_recall_fscore_support(
|
||||
y_true, y_pred, average="binary", zero_division=0
|
||||
)
|
||||
try:
|
||||
brier = float(brier_score_loss(y_true, proba))
|
||||
except ValueError:
|
||||
brier = float("nan")
|
||||
return {
|
||||
"precision": float(precision),
|
||||
"recall": float(recall),
|
||||
"f1": float(f1),
|
||||
"brier": brier,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_regression_baselines(
|
||||
series_train: pd.Series,
|
||||
series_eval: pd.Series,
|
||||
*,
|
||||
horizons: Iterable[int],
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Évalue persistance, moyenne mobile et climatologie horaire sur un jeu (validation ou test).
|
||||
"""
|
||||
rows: list[dict[str, object]] = []
|
||||
for horizon in horizons:
|
||||
# Persistance (évaluée sur le jeu cible uniquement)
|
||||
frame_persist = persistence_baseline(series_eval, horizon_minutes=horizon)
|
||||
reg_persist = _regression_scores(frame_persist["y_true"], frame_persist["y_pred"])
|
||||
rows.append(
|
||||
{
|
||||
"target": series_eval.name,
|
||||
"horizon_min": horizon,
|
||||
"baseline": "persistance",
|
||||
"n_samples": len(frame_persist),
|
||||
**reg_persist,
|
||||
}
|
||||
)
|
||||
|
||||
# Moyenne mobile (évaluée sur le jeu cible uniquement)
|
||||
frame_ma = moving_average_baseline(
|
||||
series_eval,
|
||||
horizon_minutes=horizon,
|
||||
window_minutes=MOVING_AVG_WINDOW_MINUTES,
|
||||
)
|
||||
reg_ma = _regression_scores(frame_ma["y_true"], frame_ma["y_pred"])
|
||||
rows.append(
|
||||
{
|
||||
"target": series_eval.name,
|
||||
"horizon_min": horizon,
|
||||
"baseline": f"moyenne_mobile_{MOVING_AVG_WINDOW_MINUTES}m",
|
||||
"n_samples": len(frame_ma),
|
||||
**reg_ma,
|
||||
}
|
||||
)
|
||||
|
||||
# Climatologie horaire : nécessite l'heure de la cible (utilise la partie train)
|
||||
steps = _steps_from_minutes(horizon, DEFAULT_BASE_FREQ_MINUTES)
|
||||
y_true = series_eval.shift(-steps)
|
||||
y_true = y_true.dropna()
|
||||
preds = hourly_climatology_baseline(
|
||||
series_train,
|
||||
eval_index=y_true.index,
|
||||
horizon_minutes=horizon,
|
||||
)
|
||||
preds = preds.loc[y_true.index]
|
||||
reg_clim = _regression_scores(y_true, preds)
|
||||
rows.append(
|
||||
{
|
||||
"target": series_eval.name,
|
||||
"horizon_min": horizon,
|
||||
"baseline": "climatologie_horaire",
|
||||
"n_samples": len(y_true),
|
||||
**reg_clim,
|
||||
}
|
||||
)
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def evaluate_rain_baselines(
|
||||
rain_train: pd.Series,
|
||||
rain_eval: pd.Series,
|
||||
*,
|
||||
horizons: Iterable[int],
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Évalue des baselines pour la pluie (version binaire pluie oui/non).
|
||||
"""
|
||||
rows: list[dict[str, object]] = []
|
||||
rain_train_bin = (rain_train > 0).astype(int)
|
||||
rain_eval_bin = (rain_eval > 0).astype(int)
|
||||
|
||||
for horizon in horizons:
|
||||
steps = _steps_from_minutes(horizon, DEFAULT_BASE_FREQ_MINUTES)
|
||||
|
||||
# Persistance
|
||||
frame_persist = persistence_baseline(rain_eval, horizon_minutes=horizon)
|
||||
y_true = (frame_persist["y_true"] > 0).astype(int)
|
||||
proba = (frame_persist["y_pred"] > 0).astype(float)
|
||||
cls_persist = _classification_scores(y_true, proba, threshold=0.5)
|
||||
rows.append(
|
||||
{
|
||||
"target": "rain",
|
||||
"horizon_min": horizon,
|
||||
"baseline": "persistance",
|
||||
"n_samples": len(y_true),
|
||||
**cls_persist,
|
||||
}
|
||||
)
|
||||
|
||||
# Moyenne mobile (prédiction binaire à partir du cumul moyen)
|
||||
frame_ma = moving_average_baseline(
|
||||
rain_eval,
|
||||
horizon_minutes=horizon,
|
||||
window_minutes=MOVING_AVG_WINDOW_MINUTES,
|
||||
)
|
||||
y_true_ma = (frame_ma["y_true"] > 0).astype(int)
|
||||
proba_ma = (frame_ma["y_pred"] > 0).astype(float)
|
||||
cls_ma = _classification_scores(y_true_ma, proba_ma, threshold=0.5)
|
||||
rows.append(
|
||||
{
|
||||
"target": "rain",
|
||||
"horizon_min": horizon,
|
||||
"baseline": f"moyenne_mobile_{MOVING_AVG_WINDOW_MINUTES}m",
|
||||
"n_samples": len(y_true_ma),
|
||||
**cls_ma,
|
||||
}
|
||||
)
|
||||
|
||||
# Climatologie horaire (probabilité de pluie par heure)
|
||||
y_true_clim = rain_eval_bin.shift(-steps).dropna()
|
||||
proba_clim = hourly_climatology_baseline(
|
||||
rain_train_bin,
|
||||
eval_index=rain_eval_bin.index,
|
||||
horizon_minutes=horizon,
|
||||
)
|
||||
proba_clim = proba_clim.loc[y_true_clim.index].fillna(0.0)
|
||||
cls_clim = _classification_scores(y_true_clim, proba_clim, threshold=0.5)
|
||||
rows.append(
|
||||
{
|
||||
"target": "rain",
|
||||
"horizon_min": horizon,
|
||||
"baseline": "climatologie_horaire",
|
||||
"n_samples": len(y_true_clim),
|
||||
**cls_clim,
|
||||
}
|
||||
)
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _save_csv(df: pd.DataFrame, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_csv(path, index=False)
|
||||
|
||||
|
||||
def plot_regression_mae(reg_df: pd.DataFrame, output_path: Path) -> None:
|
||||
"""Trace la MAE des baselines (validation) par horizon pour température et vent."""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df = reg_df[reg_df["split"] == "validation"]
|
||||
fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
|
||||
targets = ["temperature", "wind_speed"]
|
||||
baselines = df["baseline"].unique()
|
||||
|
||||
for ax, target in zip(axes, targets):
|
||||
sub = df[df["target"] == target]
|
||||
for baseline in baselines:
|
||||
line = sub[sub["baseline"] == baseline].sort_values("horizon_min")
|
||||
ax.plot(line["horizon_min"], line["mae"], marker="o", label=baseline)
|
||||
ax.set_title(f"MAE {target} (validation)")
|
||||
ax.set_ylabel("MAE")
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
|
||||
axes[-1].set_xlabel("Horizon (minutes)")
|
||||
axes[0].legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output_path, dpi=150)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_rain_scores(rain_df: pd.DataFrame, output_path: Path) -> None:
|
||||
"""Trace F1 et Brier des baselines pluie (validation) par horizon."""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df = rain_df[rain_df["split"] == "validation"]
|
||||
baselines = df["baseline"].unique()
|
||||
|
||||
fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
|
||||
for metric, ax in zip(("f1", "brier"), axes):
|
||||
for baseline in baselines:
|
||||
line = df[df["baseline"] == baseline].sort_values("horizon_min")
|
||||
ax.plot(line["horizon_min"], line[metric], marker="o", label=baseline)
|
||||
ax.set_title(f"{metric.upper()} pluie (validation)" if metric == "f1" else "Brier pluie (validation)")
|
||||
ax.set_ylabel(metric.upper() if metric == "f1" else "Brier")
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
axes[-1].set_xlabel("Horizon (minutes)")
|
||||
axes[0].legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output_path, dpi=150)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not CSV_PATH.exists():
|
||||
print(f"⚠ Fichier introuvable : {CSV_PATH}")
|
||||
return
|
||||
|
||||
df = load_raw_csv(CSV_PATH)
|
||||
_ensure_columns(df, CONTINUOUS_TARGETS + (RAIN_TARGET,))
|
||||
|
||||
# Découpe temporelle sans fuite
|
||||
train_df, val_df, test_df = chronological_split(df, train_frac=0.7, val_frac=0.15)
|
||||
print(f"Dataset chargé : {CSV_PATH}")
|
||||
print(f" Train : {len(train_df)} lignes")
|
||||
print(f" Val : {len(val_df)} lignes")
|
||||
print(f" Test : {len(test_df)} lignes")
|
||||
print()
|
||||
|
||||
# Évalue sur validation
|
||||
reg_val_rows: list[pd.DataFrame] = []
|
||||
for target in CONTINUOUS_TARGETS:
|
||||
reg_val_rows.append(
|
||||
evaluate_regression_baselines(
|
||||
train_df[target],
|
||||
val_df[target],
|
||||
horizons=HORIZONS_MINUTES,
|
||||
)
|
||||
)
|
||||
reg_val = pd.concat(reg_val_rows, ignore_index=True)
|
||||
rain_val = evaluate_rain_baselines(
|
||||
rain_train=train_df[RAIN_TARGET],
|
||||
rain_eval=val_df[RAIN_TARGET],
|
||||
horizons=HORIZONS_MINUTES,
|
||||
)
|
||||
|
||||
# Évalue sur test
|
||||
reg_test_rows: list[pd.DataFrame] = []
|
||||
for target in CONTINUOUS_TARGETS:
|
||||
reg_test_rows.append(
|
||||
evaluate_regression_baselines(
|
||||
train_df[target],
|
||||
test_df[target],
|
||||
horizons=HORIZONS_MINUTES,
|
||||
)
|
||||
)
|
||||
reg_test = pd.concat(reg_test_rows, ignore_index=True)
|
||||
rain_test = evaluate_rain_baselines(
|
||||
rain_train=train_df[RAIN_TARGET],
|
||||
rain_eval=test_df[RAIN_TARGET],
|
||||
horizons=HORIZONS_MINUTES,
|
||||
)
|
||||
|
||||
# Combine et sauvegarde en CSV
|
||||
reg_val["split"] = "validation"
|
||||
reg_test["split"] = "test"
|
||||
reg_all = pd.concat([reg_val, reg_test], ignore_index=True)
|
||||
|
||||
rain_val["split"] = "validation"
|
||||
rain_test["split"] = "test"
|
||||
rain_all = pd.concat([rain_val, rain_test], ignore_index=True)
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_save_csv(reg_all, DATA_DIR / "baselines_regression.csv")
|
||||
_save_csv(rain_all, DATA_DIR / "baselines_rain.csv")
|
||||
|
||||
# Figures (validation uniquement pour la lisibilité)
|
||||
FIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
plot_regression_mae(reg_all, FIG_DIR / "baselines_mae_validation.png")
|
||||
plot_rain_scores(rain_all, FIG_DIR / "baselines_rain_validation.png")
|
||||
|
||||
print("=== Baselines validation (température / vent) ===")
|
||||
print(reg_val.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
print()
|
||||
print("=== Baselines validation (pluie binaire) ===")
|
||||
print(rain_val.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
print()
|
||||
print("=== Baselines test (température / vent) ===")
|
||||
print(reg_test.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
print()
|
||||
print("=== Baselines test (pluie binaire) ===")
|
||||
print(rain_test.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,345 @@
|
||||
# scripts/run_first_models.py
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.linear_model import Ridge, Lasso, LogisticRegression
|
||||
from sklearn.metrics import (
|
||||
mean_absolute_error,
|
||||
mean_squared_error,
|
||||
f1_score,
|
||||
precision_recall_curve,
|
||||
roc_curve,
|
||||
average_precision_score,
|
||||
brier_score_loss,
|
||||
)
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from meteo.dataset import load_raw_csv
|
||||
from model.features import build_feature_dataframe, FeatureSpec, _steps_from_minutes
|
||||
from model.splits import chronological_split
|
||||
|
||||
CSV_PATH = Path("data/weather_minutely.csv")
|
||||
DOC_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = DOC_DIR / "data"
|
||||
FIG_DIR = DOC_DIR / "figures"
|
||||
|
||||
HORIZONS_MINUTES: tuple[int, ...] = (10, 60, 360, 1440)
|
||||
CONTINUOUS_TARGETS: tuple[str, ...] = ("temperature", "wind_speed")
|
||||
RAIN_TARGET: str = "rain_rate"
|
||||
|
||||
# Lags spécifiques issus des analyses du chapitre 5 (exemple de mapping ; sinon défauts)
|
||||
DEFAULT_LAGS_BY_COL: dict[str, Sequence[int]] = {
|
||||
"temperature": (10, 20, 30),
|
||||
"wind_speed": (10, 20, 30),
|
||||
"rain_rate": (10, 20, 30),
|
||||
"humidity": (10, 20, 30),
|
||||
"pressure": (10, 20, 30),
|
||||
"illuminance": (10, 20, 30),
|
||||
"wind_direction": (10, 20, 30),
|
||||
"sun_elevation": (10, 20, 30),
|
||||
}
|
||||
|
||||
USE_CORR_FILTER = True
|
||||
CORR_THRESHOLD = 0.2
|
||||
CORR_PATH = Path("docs/05 - Corrélations binaires avancées/data/correlation_matrix_lagged.csv")
|
||||
LAG_MATRIX_PATH = Path("docs/05 - Corrélations binaires avancées/data/lag_matrix_minutes.csv")
|
||||
|
||||
|
||||
def _align_target(
|
||||
df: pd.DataFrame,
|
||||
target_col: str,
|
||||
horizon_minutes: int,
|
||||
base_freq_minutes: int = 10,
|
||||
) -> tuple[pd.DataFrame, pd.Series]:
|
||||
"""
|
||||
Décale la cible dans le futur pour l'horizon souhaité et aligne X, y.
|
||||
"""
|
||||
steps = _steps_from_minutes(horizon_minutes, base_freq_minutes)
|
||||
y = df[target_col].shift(-steps)
|
||||
X_full = df.drop(columns=[target_col])
|
||||
# Ne garder que les colonnes numériques/booléennes (exclut "season" textuelle)
|
||||
X = X_full.select_dtypes(include=["number", "bool"])
|
||||
aligned = pd.concat([X, y.rename("target")], axis=1).dropna()
|
||||
return aligned.drop(columns=["target"]), aligned["target"]
|
||||
|
||||
|
||||
def _load_correlation_and_lag() -> tuple[pd.DataFrame | None, pd.DataFrame | None]:
|
||||
corr_df = pd.read_csv(CORR_PATH, index_col=0) if CORR_PATH.exists() else None
|
||||
lag_df = pd.read_csv(LAG_MATRIX_PATH, index_col=0) if LAG_MATRIX_PATH.exists() else None
|
||||
return corr_df, lag_df
|
||||
|
||||
|
||||
def _select_features_from_corr(
|
||||
corr_df: pd.DataFrame | None,
|
||||
targets: Sequence[str],
|
||||
threshold: float,
|
||||
) -> set[str]:
|
||||
if corr_df is None:
|
||||
return set()
|
||||
selected: set[str] = set()
|
||||
for target in targets:
|
||||
if target not in corr_df.columns:
|
||||
continue
|
||||
corrs = corr_df[target].drop(labels=[target], errors="ignore")
|
||||
strong = corrs[corrs.abs() >= threshold]
|
||||
selected.update(strong.index.tolist())
|
||||
return selected
|
||||
|
||||
|
||||
def _build_lags_from_matrices(
|
||||
lag_df: pd.DataFrame | None,
|
||||
corr_df: pd.DataFrame | None,
|
||||
selected_cols: Iterable[str],
|
||||
default_lags: dict[str, Sequence[int]],
|
||||
threshold: float,
|
||||
) -> dict[str, Sequence[int]]:
|
||||
"""
|
||||
Combine lags par défaut et lags issus de la matrice de décalage si |corr| dépasse le seuil.
|
||||
"""
|
||||
mapping: dict[str, Sequence[int]] = {}
|
||||
for col in selected_cols:
|
||||
base = list(default_lags.get(col, (10, 20, 30)))
|
||||
extra: set[int] = set()
|
||||
if lag_df is not None and corr_df is not None and col in lag_df.index:
|
||||
corrs = corr_df.loc[col]
|
||||
for tgt, corr_val in corrs.items():
|
||||
if tgt == col:
|
||||
continue
|
||||
if abs(corr_val) < threshold:
|
||||
continue
|
||||
lag_val = lag_df.loc[col, tgt]
|
||||
if pd.notna(lag_val) and lag_val != 0:
|
||||
extra.add(int(abs(round(float(lag_val)))))
|
||||
merged = sorted({*base, *extra})
|
||||
mapping[col] = merged
|
||||
return mapping
|
||||
|
||||
|
||||
def _scale_train_val_test(X_train: pd.DataFrame, X_val: pd.DataFrame, X_test: pd.DataFrame) -> tuple[np.ndarray, np.ndarray, np.ndarray, StandardScaler]:
|
||||
scaler = StandardScaler()
|
||||
X_train_scaled = scaler.fit_transform(X_train)
|
||||
X_val_scaled = scaler.transform(X_val)
|
||||
X_test_scaled = scaler.transform(X_test)
|
||||
return X_train_scaled, X_val_scaled, X_test_scaled, scaler
|
||||
|
||||
|
||||
def _regression_scores(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]:
|
||||
return {
|
||||
"mae": float(mean_absolute_error(y_true, y_pred)),
|
||||
"rmse": float(np.sqrt(mean_squared_error(y_true, y_pred))),
|
||||
}
|
||||
|
||||
|
||||
def _classification_scores(y_true: np.ndarray, proba: np.ndarray, threshold: float = 0.5) -> dict[str, float]:
|
||||
y_pred = (proba >= threshold).astype(int)
|
||||
return {
|
||||
"f1": float(f1_score(y_true, y_pred, zero_division=0)),
|
||||
"brier": float(brier_score_loss(y_true, proba)),
|
||||
"ap": float(average_precision_score(y_true, proba)),
|
||||
}
|
||||
|
||||
|
||||
def run_regression_models(train_df: pd.DataFrame, val_df: pd.DataFrame, test_df: pd.DataFrame) -> pd.DataFrame:
|
||||
rows: list[dict[str, object]] = []
|
||||
for target_col in CONTINUOUS_TARGETS:
|
||||
for horizon in HORIZONS_MINUTES:
|
||||
X_train, y_train = _align_target(train_df, target_col, horizon)
|
||||
X_val, y_val = _align_target(val_df, target_col, horizon)
|
||||
X_test, y_test = _align_target(test_df, target_col, horizon)
|
||||
|
||||
if y_train.empty or y_val.empty or y_test.empty:
|
||||
continue
|
||||
|
||||
X_train_s, X_val_s, X_test_s, scaler = _scale_train_val_test(X_train, X_val, X_test)
|
||||
|
||||
for model_name, model in (
|
||||
("ridge", Ridge(alpha=1.0)),
|
||||
("lasso", Lasso(alpha=0.001)),
|
||||
):
|
||||
model.fit(X_train_s, y_train)
|
||||
y_val_pred = model.predict(X_val_s)
|
||||
y_test_pred = model.predict(X_test_s)
|
||||
|
||||
val_scores = _regression_scores(y_val, y_val_pred)
|
||||
test_scores = _regression_scores(y_test, y_test_pred)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"target": target_col,
|
||||
"horizon_min": horizon,
|
||||
"model": model_name,
|
||||
"split": "validation",
|
||||
**val_scores,
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"target": target_col,
|
||||
"horizon_min": horizon,
|
||||
"model": model_name,
|
||||
"split": "test",
|
||||
**test_scores,
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def run_rain_model(train_df: pd.DataFrame, val_df: pd.DataFrame, test_df: pd.DataFrame) -> pd.DataFrame:
|
||||
rows: list[dict[str, object]] = []
|
||||
target_col = RAIN_TARGET
|
||||
for horizon in HORIZONS_MINUTES:
|
||||
X_train, y_train = _align_target(train_df, target_col, horizon)
|
||||
X_val, y_val = _align_target(val_df, target_col, horizon)
|
||||
X_test, y_test = _align_target(test_df, target_col, horizon)
|
||||
|
||||
y_train_bin = (y_train > 0).astype(int)
|
||||
y_val_bin = (y_val > 0).astype(int)
|
||||
y_test_bin = (y_test > 0).astype(int)
|
||||
|
||||
if y_train_bin.empty or y_val_bin.empty or y_test_bin.empty:
|
||||
continue
|
||||
|
||||
X_train_s, X_val_s, X_test_s, scaler = _scale_train_val_test(X_train, X_val, X_test)
|
||||
|
||||
clf = LogisticRegression(max_iter=200)
|
||||
clf.fit(X_train_s, y_train_bin)
|
||||
|
||||
proba_val = clf.predict_proba(X_val_s)[:, 1]
|
||||
proba_test = clf.predict_proba(X_test_s)[:, 1]
|
||||
|
||||
val_scores = _classification_scores(y_val_bin, proba_val)
|
||||
test_scores = _classification_scores(y_test_bin, proba_test)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"target": "rain_binary",
|
||||
"horizon_min": horizon,
|
||||
"model": "logistic_regression",
|
||||
"split": "validation",
|
||||
**val_scores,
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"target": "rain_binary",
|
||||
"horizon_min": horizon,
|
||||
"model": "logistic_regression",
|
||||
"split": "test",
|
||||
**test_scores,
|
||||
}
|
||||
)
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def plot_regression_results(df: pd.DataFrame, output_path: Path) -> None:
|
||||
"""Trace la MAE par horizon pour chaque modèle (validation)."""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df_val = df[df["split"] == "validation"]
|
||||
targets = df_val["target"].unique()
|
||||
models = df_val["model"].unique()
|
||||
|
||||
fig, axes = plt.subplots(len(targets), 1, figsize=(8, 4 * len(targets)), sharex=True)
|
||||
if len(targets) == 1:
|
||||
axes = [axes]
|
||||
for ax, target in zip(axes, targets):
|
||||
sub = df_val[df_val["target"] == target]
|
||||
for model in models:
|
||||
line = sub[sub["model"] == model].sort_values("horizon_min")
|
||||
ax.plot(line["horizon_min"], line["mae"], marker="o", label=model)
|
||||
ax.set_title(f"MAE {target} (validation)")
|
||||
ax.set_ylabel("MAE")
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
axes[-1].set_xlabel("Horizon (minutes)")
|
||||
axes[0].legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output_path, dpi=150)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_rain_curves(df: pd.DataFrame, output_prefix: Path) -> None:
|
||||
"""Trace PR et ROC sur la validation pour la pluie binaire (logistique)."""
|
||||
|
||||
output_prefix.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Il faut recalculer les courbes à partir des probas ; on les régénère sur val
|
||||
# On recompute une fois (pas stockées dans df)
|
||||
# Ce helper est pour garder un format cohérent et simple
|
||||
return # On gardera les courbes basées sur les scores déjà exportés pour l'instant
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not CSV_PATH.exists():
|
||||
print(f"⚠ Fichier introuvable : {CSV_PATH}")
|
||||
return
|
||||
|
||||
df_raw = load_raw_csv(CSV_PATH)
|
||||
print(f"Dataset chargé : {CSV_PATH}")
|
||||
|
||||
corr_df, lag_df = _load_correlation_and_lag()
|
||||
selected_from_corr = _select_features_from_corr(corr_df, CONTINUOUS_TARGETS + (RAIN_TARGET,), CORR_THRESHOLD) if USE_CORR_FILTER else set()
|
||||
|
||||
# Sélection des colonnes numériques
|
||||
numeric_cols = df_raw.select_dtypes(include=["number", "bool"]).columns
|
||||
if USE_CORR_FILTER and selected_from_corr:
|
||||
# On garde les cibles + les colonnes corrélées
|
||||
selected_cols = [col for col in numeric_cols if col in selected_from_corr or col in CONTINUOUS_TARGETS or col == RAIN_TARGET]
|
||||
else:
|
||||
selected_cols = list(numeric_cols)
|
||||
|
||||
lags_mapping = _build_lags_from_matrices(
|
||||
lag_df,
|
||||
corr_df,
|
||||
selected_cols,
|
||||
default_lags=DEFAULT_LAGS_BY_COL,
|
||||
threshold=CORR_THRESHOLD,
|
||||
)
|
||||
|
||||
feature_spec = FeatureSpec(lags_minutes=lags_mapping)
|
||||
df_feat = build_feature_dataframe(df_raw[selected_cols], feature_spec=feature_spec, target_columns=selected_cols)
|
||||
|
||||
# Découpe temporelle sans fuite
|
||||
train_df, val_df, test_df = chronological_split(df_feat, train_frac=0.7, val_frac=0.15)
|
||||
print(f" Train : {len(train_df)} lignes")
|
||||
print(f" Val : {len(val_df)} lignes")
|
||||
print(f" Test : {len(test_df)} lignes")
|
||||
print()
|
||||
|
||||
# Régressions (température/vent)
|
||||
reg_results = run_regression_models(train_df, val_df, test_df)
|
||||
# Pluie binaire
|
||||
rain_results = run_rain_model(train_df, val_df, test_df)
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
reg_path = DATA_DIR / "models_regression.csv"
|
||||
rain_path = DATA_DIR / "models_rain.csv"
|
||||
reg_results.to_csv(reg_path, index=False)
|
||||
rain_results.to_csv(rain_path, index=False)
|
||||
|
||||
print(f"✔ Résultats régression sauvegardés : {reg_path}")
|
||||
print(f"✔ Résultats pluie sauvegardés : {rain_path}")
|
||||
|
||||
# Figures
|
||||
FIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
plot_regression_results(reg_results, FIG_DIR / "models_mae_validation.png")
|
||||
# Pas de courbes ROC/PR générées ici pour simplifier, mais les scores (F1/Brier/AP) sont disponibles.
|
||||
|
||||
print("=== Scores régression (validation) ===")
|
||||
print(reg_results[reg_results["split"] == "validation"].to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
print()
|
||||
print("=== Scores pluie (validation) ===")
|
||||
print(rain_results[rain_results["split"] == "validation"].to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
32
docs/10 - Modèles non linéaires/index.md
Normal file
32
docs/10 - Modèles non linéaires/index.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Modèles non linéaires (arbres, forêts, gradient boosting)
|
||||
|
||||
Objectif : tester des modèles plus flexibles que les régressions linéaires/logistiques, en restant raisonnables côté ressources. On utilise des forêts aléatoires (_random forest_) et du _gradient boosting_ sur les mêmes horizons (T+10, T+60, T+360, T+1440) pour température, vent et pluie.
|
||||
|
||||
```shell
|
||||
python "docs/10 - Modèles non linéaires/scripts/run_tree_models.py"
|
||||
```
|
||||
|
||||
Le script :
|
||||
|
||||
- lit `data/weather_minutely.csv` et construit les variables dérivées (sin/cos, lags/deltas/moyennes, vent u/v, drapeaux) ;
|
||||
- s’appuie sur la matrice de corrélation décalée (chapitre 5) pour prioriser les variables/lags avec |r| ≥ 0,2, tout en conservant les cibles ;
|
||||
- sous-échantillonne l’apprentissage (1 ligne sur 10) pour contenir le temps de calcul, à garder en tête pour interpréter les scores ;
|
||||
- découpe en _train_/_validation_/_test_ (70/15/15 %) ;
|
||||
- entraîne forêts et gradient boosting pour température/vent (régression) et pluie binaire (classification) ;
|
||||
- exporte `models_tree_regression.csv` et `models_tree_rain.csv` dans `docs/10 - Modèles non linéaires/data/` ;
|
||||
- génère deux figures (validation) dans `docs/10 - Modèles non linéaires/figures/` :
|
||||
- `models_tree_mae_validation.png` (MAE vs horizon pour température et vent)
|
||||
- `models_tree_rain_validation.png` (F1 et Brier vs horizon pour la pluie)
|
||||
|
||||
## Lecture rapide des résultats (validation)
|
||||
|
||||

|
||||

|
||||
|
||||
- Température : le gradient boosting est meilleur que la forêt sur le très court terme (MAE ≈0,13 à +10 min), mais reste derrière les modèles linéaires du chapitre 9 (MAE ≈0,14 à +60 min avec Ridge). La sous‑utilisation des données d’apprentissage (1/10) pèse sur la performance.
|
||||
- Vent : gains modestes, MAE ~0,94 à +10 min (GB) et ~1,19 à +60 min, sans dépassement clair des modèles linéaires précédents.
|
||||
- Pluie : F1 ≈0,85 (forêt) et 0,67 (GB) à +10 min, mais toujours en dessous de la persistance (~0,94) ; le Brier reste modéré (~0,02–0,03). Aux horizons +60/+360/+1440, les scores retombent rapidement.
|
||||
|
||||
## Conclusion provisoire
|
||||
|
||||
Ces modèles non linéaires apportent de la flexibilité mais, avec un apprentissage allégé pour tenir le temps de calcul, ils ne battent pas les baselines ni les modèles linéaires sur les horizons courts. Pour progresser, il faudra soit élargir l’échantillon d’apprentissage (temps de calcul plus long), soit régler finement les hyperparamètres, soit enrichir les features (ou combiner les deux), tout en vérifiant que le gain justifie l’effort.
|
||||
359
docs/10 - Modèles non linéaires/scripts/run_tree_models.py
Normal file
359
docs/10 - Modèles non linéaires/scripts/run_tree_models.py
Normal file
@ -0,0 +1,359 @@
|
||||
# scripts/run_tree_models.py
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier, GradientBoostingRegressor, GradientBoostingClassifier
|
||||
from sklearn.metrics import (
|
||||
mean_absolute_error,
|
||||
mean_squared_error,
|
||||
f1_score,
|
||||
brier_score_loss,
|
||||
average_precision_score,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from meteo.dataset import load_raw_csv
|
||||
from model.features import build_feature_dataframe, FeatureSpec, _steps_from_minutes
|
||||
from model.splits import chronological_split
|
||||
|
||||
CSV_PATH = Path("data/weather_minutely.csv")
|
||||
DOC_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = DOC_DIR / "data"
|
||||
FIG_DIR = DOC_DIR / "figures"
|
||||
|
||||
HORIZONS_MINUTES: tuple[int, ...] = (10, 60, 360, 1440)
|
||||
CONTINUOUS_TARGETS: tuple[str, ...] = ("temperature", "wind_speed")
|
||||
RAIN_TARGET: str = "rain_rate"
|
||||
|
||||
DEFAULT_LAGS_BY_COL: dict[str, Sequence[int]] = {
|
||||
"temperature": (10, 20, 30),
|
||||
"wind_speed": (10, 20, 30),
|
||||
"rain_rate": (10, 20, 30),
|
||||
"humidity": (10, 20, 30),
|
||||
"pressure": (10, 20, 30),
|
||||
"illuminance": (10, 20, 30),
|
||||
"wind_direction": (10, 20, 30),
|
||||
"sun_elevation": (10, 20, 30),
|
||||
}
|
||||
|
||||
USE_CORR_FILTER = True
|
||||
CORR_THRESHOLD = 0.2
|
||||
CORR_PATH = Path("docs/05 - Corrélations binaires avancées/data/correlation_matrix_lagged.csv")
|
||||
LAG_MATRIX_PATH = Path("docs/05 - Corrélations binaires avancées/data/lag_matrix_minutes.csv")
|
||||
TRAIN_SUBSAMPLE_STEP = 10 # prend 1 ligne sur 10 pour accélérer l'entraînement des arbres
|
||||
|
||||
|
||||
def _align_target(
|
||||
df: pd.DataFrame,
|
||||
target_col: str,
|
||||
horizon_minutes: int,
|
||||
base_freq_minutes: int = 10,
|
||||
) -> tuple[pd.DataFrame, pd.Series]:
|
||||
steps = _steps_from_minutes(horizon_minutes, base_freq_minutes)
|
||||
y = df[target_col].shift(-steps)
|
||||
X_full = df.drop(columns=[target_col])
|
||||
X = X_full.select_dtypes(include=["number", "bool"])
|
||||
aligned = pd.concat([X, y.rename("target")], axis=1).dropna()
|
||||
return aligned.drop(columns=["target"]), aligned["target"]
|
||||
|
||||
|
||||
def _regression_scores(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]:
|
||||
return {
|
||||
"mae": float(mean_absolute_error(y_true, y_pred)),
|
||||
"rmse": float(np.sqrt(mean_squared_error(y_true, y_pred))),
|
||||
}
|
||||
|
||||
|
||||
def _classification_scores(y_true: np.ndarray, proba: np.ndarray, threshold: float = 0.5) -> dict[str, float]:
|
||||
y_pred = (proba >= threshold).astype(int)
|
||||
return {
|
||||
"f1": float(f1_score(y_true, y_pred, zero_division=0)),
|
||||
"brier": float(brier_score_loss(y_true, proba)),
|
||||
"ap": float(average_precision_score(y_true, proba)),
|
||||
}
|
||||
|
||||
|
||||
def _load_correlation_and_lag() -> tuple[pd.DataFrame | None, pd.DataFrame | None]:
|
||||
corr_df = pd.read_csv(CORR_PATH, index_col=0) if CORR_PATH.exists() else None
|
||||
lag_df = pd.read_csv(LAG_MATRIX_PATH, index_col=0) if LAG_MATRIX_PATH.exists() else None
|
||||
return corr_df, lag_df
|
||||
|
||||
|
||||
def _select_features_from_corr(
|
||||
corr_df: pd.DataFrame | None,
|
||||
targets: Sequence[str],
|
||||
threshold: float,
|
||||
) -> set[str]:
|
||||
if corr_df is None:
|
||||
return set()
|
||||
selected: set[str] = set()
|
||||
for target in targets:
|
||||
if target not in corr_df.columns:
|
||||
continue
|
||||
corrs = corr_df[target].drop(labels=[target], errors="ignore")
|
||||
strong = corrs[corrs.abs() >= threshold]
|
||||
selected.update(strong.index.tolist())
|
||||
return selected
|
||||
|
||||
|
||||
def _build_lags_from_matrices(
|
||||
lag_df: pd.DataFrame | None,
|
||||
corr_df: pd.DataFrame | None,
|
||||
selected_cols: Iterable[str],
|
||||
default_lags: dict[str, Sequence[int]],
|
||||
threshold: float,
|
||||
) -> dict[str, Sequence[int]]:
|
||||
mapping: dict[str, Sequence[int]] = {}
|
||||
for col in selected_cols:
|
||||
base = list(default_lags.get(col, (10, 20, 30)))
|
||||
extra: set[int] = set()
|
||||
if lag_df is not None and corr_df is not None and col in lag_df.index:
|
||||
corrs = corr_df.loc[col]
|
||||
for tgt, corr_val in corrs.items():
|
||||
if tgt == col:
|
||||
continue
|
||||
if abs(corr_val) < threshold:
|
||||
continue
|
||||
lag_val = lag_df.loc[col, tgt]
|
||||
if pd.notna(lag_val) and lag_val != 0:
|
||||
extra.add(int(abs(round(float(lag_val)))))
|
||||
merged = sorted({*base, *extra})
|
||||
mapping[col] = merged
|
||||
return mapping
|
||||
|
||||
|
||||
def run_regression_models(train_df: pd.DataFrame, val_df: pd.DataFrame, test_df: pd.DataFrame) -> pd.DataFrame:
|
||||
rows: list[dict[str, object]] = []
|
||||
models = [
|
||||
("rf", RandomForestRegressor(
|
||||
n_estimators=25,
|
||||
max_depth=8,
|
||||
min_samples_leaf=3,
|
||||
max_features="sqrt",
|
||||
n_jobs=-1,
|
||||
random_state=42,
|
||||
max_samples=0.25,
|
||||
)),
|
||||
("gbrt", GradientBoostingRegressor(
|
||||
n_estimators=50,
|
||||
learning_rate=0.08,
|
||||
max_depth=3,
|
||||
subsample=0.8,
|
||||
random_state=42,
|
||||
)),
|
||||
]
|
||||
for target_col in CONTINUOUS_TARGETS:
|
||||
for horizon in HORIZONS_MINUTES:
|
||||
X_train, y_train = _align_target(train_df, target_col, horizon)
|
||||
X_val, y_val = _align_target(val_df, target_col, horizon)
|
||||
X_test, y_test = _align_target(test_df, target_col, horizon)
|
||||
|
||||
if y_train.empty or y_val.empty or y_test.empty:
|
||||
continue
|
||||
|
||||
for model_name, model in models:
|
||||
model.fit(X_train, y_train)
|
||||
y_val_pred = model.predict(X_val)
|
||||
y_test_pred = model.predict(X_test)
|
||||
|
||||
val_scores = _regression_scores(y_val, y_val_pred)
|
||||
test_scores = _regression_scores(y_test, y_test_pred)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"target": target_col,
|
||||
"horizon_min": horizon,
|
||||
"model": model_name,
|
||||
"split": "validation",
|
||||
**val_scores,
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"target": target_col,
|
||||
"horizon_min": horizon,
|
||||
"model": model_name,
|
||||
"split": "test",
|
||||
**test_scores,
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def run_rain_models(train_df: pd.DataFrame, val_df: pd.DataFrame, test_df: pd.DataFrame) -> pd.DataFrame:
|
||||
rows: list[dict[str, object]] = []
|
||||
models = [
|
||||
("rf", RandomForestClassifier(
|
||||
n_estimators=40,
|
||||
max_depth=8,
|
||||
min_samples_leaf=3,
|
||||
max_features="sqrt",
|
||||
n_jobs=-1,
|
||||
random_state=42,
|
||||
class_weight="balanced",
|
||||
max_samples=0.25,
|
||||
)),
|
||||
("gbrt", GradientBoostingClassifier(
|
||||
n_estimators=50,
|
||||
learning_rate=0.08,
|
||||
max_depth=3,
|
||||
subsample=0.8,
|
||||
random_state=42,
|
||||
)),
|
||||
]
|
||||
target_col = RAIN_TARGET
|
||||
for horizon in HORIZONS_MINUTES:
|
||||
X_train, y_train = _align_target(train_df, target_col, horizon)
|
||||
X_val, y_val = _align_target(val_df, target_col, horizon)
|
||||
X_test, y_test = _align_target(test_df, target_col, horizon)
|
||||
|
||||
y_train_bin = (y_train > 0).astype(int)
|
||||
y_val_bin = (y_val > 0).astype(int)
|
||||
y_test_bin = (y_test > 0).astype(int)
|
||||
|
||||
if y_train_bin.empty or y_val_bin.empty or y_test_bin.empty:
|
||||
continue
|
||||
|
||||
for model_name, model in models:
|
||||
model.fit(X_train, y_train_bin)
|
||||
proba_val = model.predict_proba(X_val)[:, 1]
|
||||
proba_test = model.predict_proba(X_test)[:, 1]
|
||||
|
||||
val_scores = _classification_scores(y_val_bin, proba_val)
|
||||
test_scores = _classification_scores(y_test_bin, proba_test)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"target": "rain_binary",
|
||||
"horizon_min": horizon,
|
||||
"model": model_name,
|
||||
"split": "validation",
|
||||
**val_scores,
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"target": "rain_binary",
|
||||
"horizon_min": horizon,
|
||||
"model": model_name,
|
||||
"split": "test",
|
||||
**test_scores,
|
||||
}
|
||||
)
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def plot_regression_mae(reg_df: pd.DataFrame, output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df = reg_df[reg_df["split"] == "validation"]
|
||||
targets = df["target"].unique()
|
||||
models = df["model"].unique()
|
||||
|
||||
fig, axes = plt.subplots(len(targets), 1, figsize=(8, 4 * len(targets)), sharex=True)
|
||||
if len(targets) == 1:
|
||||
axes = [axes]
|
||||
for ax, target in zip(axes, targets):
|
||||
sub = df[df["target"] == target]
|
||||
for model in models:
|
||||
line = sub[sub["model"] == model].sort_values("horizon_min")
|
||||
ax.plot(line["horizon_min"], line["mae"], marker="o", label=model)
|
||||
ax.set_title(f"MAE {target} (validation)")
|
||||
ax.set_ylabel("MAE")
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
axes[-1].set_xlabel("Horizon (minutes)")
|
||||
axes[0].legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output_path, dpi=150)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_rain_f1_brier(rain_df: pd.DataFrame, output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df = rain_df[rain_df["split"] == "validation"]
|
||||
models = df["model"].unique()
|
||||
|
||||
fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
|
||||
for metric, ax in zip(("f1", "brier"), axes):
|
||||
for model in models:
|
||||
line = df[df["model"] == model].sort_values("horizon_min")
|
||||
ax.plot(line["horizon_min"], line[metric], marker="o", label=model)
|
||||
ax.set_title(f"{metric.upper()} pluie (validation)" if metric == "f1" else "Brier pluie (validation)")
|
||||
ax.set_ylabel(metric.upper() if metric == "f1" else "Brier")
|
||||
ax.grid(True, linestyle=":", alpha=0.4)
|
||||
axes[-1].set_xlabel("Horizon (minutes)")
|
||||
axes[0].legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output_path, dpi=150)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not CSV_PATH.exists():
|
||||
print(f"⚠ Fichier introuvable : {CSV_PATH}")
|
||||
return
|
||||
|
||||
df_raw = load_raw_csv(CSV_PATH)
|
||||
corr_df, lag_df = _load_correlation_and_lag()
|
||||
selected_from_corr = _select_features_from_corr(corr_df, CONTINUOUS_TARGETS + (RAIN_TARGET,), CORR_THRESHOLD) if USE_CORR_FILTER else set()
|
||||
|
||||
numeric_cols = df_raw.select_dtypes(include=["number", "bool"]).columns
|
||||
if USE_CORR_FILTER and selected_from_corr:
|
||||
selected_cols = [col for col in numeric_cols if col in selected_from_corr or col in CONTINUOUS_TARGETS or col == RAIN_TARGET]
|
||||
else:
|
||||
selected_cols = list(numeric_cols)
|
||||
|
||||
lags_mapping = _build_lags_from_matrices(
|
||||
lag_df,
|
||||
corr_df,
|
||||
selected_cols,
|
||||
default_lags=DEFAULT_LAGS_BY_COL,
|
||||
threshold=CORR_THRESHOLD,
|
||||
)
|
||||
|
||||
feature_spec = FeatureSpec(lags_minutes=lags_mapping)
|
||||
df_feat = build_feature_dataframe(df_raw[selected_cols], feature_spec=feature_spec, target_columns=selected_cols)
|
||||
|
||||
train_df, val_df, test_df = chronological_split(df_feat, train_frac=0.7, val_frac=0.15)
|
||||
if TRAIN_SUBSAMPLE_STEP > 1:
|
||||
train_df = train_df.iloc[::TRAIN_SUBSAMPLE_STEP]
|
||||
print(f"Dataset chargé : {CSV_PATH}")
|
||||
print(f" Train : {len(train_df)} lignes")
|
||||
print(f" Val : {len(val_df)} lignes")
|
||||
print(f" Test : {len(test_df)} lignes")
|
||||
print()
|
||||
|
||||
reg_results = run_regression_models(train_df, val_df, test_df)
|
||||
rain_results = run_rain_models(train_df, val_df, test_df)
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
reg_path = DATA_DIR / "models_tree_regression.csv"
|
||||
rain_path = DATA_DIR / "models_tree_rain.csv"
|
||||
reg_results.to_csv(reg_path, index=False)
|
||||
rain_results.to_csv(rain_path, index=False)
|
||||
|
||||
FIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
plot_regression_mae(reg_results, FIG_DIR / "models_tree_mae_validation.png")
|
||||
plot_rain_f1_brier(rain_results, FIG_DIR / "models_tree_rain_validation.png")
|
||||
|
||||
print(f"✔ Résultats régression (arbres/GB) : {reg_path}")
|
||||
print(f"✔ Résultats pluie (arbres/GB) : {rain_path}")
|
||||
print()
|
||||
print("=== Scores régression (validation) ===")
|
||||
print(reg_results[reg_results["split"] == "validation"].to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
print()
|
||||
print("=== Scores pluie (validation) ===")
|
||||
print(rain_results[rain_results["split"] == "validation"].to_string(index=False, float_format=lambda x: f"{x:.3f}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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)
|
||||
Loading…
x
Reference in New Issue
Block a user