Aller au contenu principal

Module 10 — Projet : prédiction sur une série de mesures

Ce module rassemble tout le cours dans une chaîne complète, sur le fil rouge de la consommation électrique du bâtiment tertiaire. On y prévoit les 24 prochaines heures, on compare à une référence naïve, on met LSTM et GRU côte à côte, on produit un intervalle de prévision par quantiles, et on liste les erreurs qui rendent tout ce travail inutile.

Ce que doit couvrir le projet

Six étapes non négociables :

  1. Préparation chronologique du jeu (module 1), sans mélange, sans fuite.
  2. Normalisation calculée sur le seul train (module 9).
  3. Référence naïve implémentée avant tout modèle (module 1).
  4. Modèle GRU d'abord, puis LSTM, à budget de paramètres comparable (module 6).
  5. Intervalles de prévision par régression quantile (nouveau).
  6. Rapport d'erreurs et comparaison sur métriques métier.

Un projet qui omet une seule de ces étapes n'est pas livrable en production.

Étape 1 : préparation et référence naïve

On charge la série, on l'agrège à l'heure, on la découpe strictement, on prépare les fenêtres.

import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

df = pd.read_csv("consommation.csv", parse_dates=["horodatage"])
df = df.set_index("horodatage").sort_index().asfreq("H").interpolate("time")
serie = df["kwh"].to_numpy(dtype=np.float32)

LONG_ENTREE, LONG_CIBLE = 168, 24
n = len(serie)
i_train = int(0.70 * n)
i_val = int(0.85 * n)

mu = serie[:i_train].mean()
sigma = serie[:i_train].std() + 1e-6

def fabriquer(serie_range, mu, sigma):
z = (serie_range - mu) / sigma
X, y = [], []
for i in range(len(z) - LONG_ENTREE - LONG_CIBLE + 1):
X.append(z[i : i + LONG_ENTREE])
y.append(z[i + LONG_ENTREE : i + LONG_ENTREE + LONG_CIBLE])
return np.asarray(X)[..., None], np.asarray(y)

X_tr, y_tr = fabriquer(serie[:i_train], mu, sigma)
X_va, y_va = fabriquer(serie[i_train:i_val], mu, sigma)
X_te, y_te = fabriquer(serie[i_val:], mu, sigma)

# Reference naive : recopier la meme heure de la veille
def prediction_naive_j_moins_1(X):
# X a forme (N, 168, 1) ; la veille meme heure = X[:, -24:, 0]
return X[:, -24:, 0]

mae_naive = np.mean(np.abs(prediction_naive_j_moins_1(X_te) - y_te))
print("MAE reference naive (echelle normalisee) :", mae_naive)

Cette référence est le seul juge légitime du reste. Un modèle qui la bat de 5 % justifie son existence. Un modèle qui la bat de 50 % du premier coup cache une fuite temporelle : cherchez avant de célébrer.

Étape 2 : GRU puis LSTM, budget comparable

On entraîne d'abord un GRU, référence architecturale (module 6), puis un LSTM au même nombre de paramètres environ.

def batir(cellule, unites, arriere_plan="prevision"):
modele = keras.Sequential(name=f"{arriere_plan}_{cellule.__name__}_{unites}")
modele.add(layers.Input(shape=(LONG_ENTREE, 1)))
modele.add(cellule(unites))
modele.add(layers.Dense(LONG_CIBLE))
modele.compile(
optimizer=keras.optimizers.Adam(learning_rate=1e-3, clipnorm=1.0),
loss="mse",
metrics=["mae"],
)
return modele

def entrainer(modele, X_tr, y_tr, X_va, y_va):
return modele.fit(
X_tr, y_tr,
validation_data=(X_va, y_va),
epochs=50,
batch_size=64,
callbacks=[keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True)],
verbose=0,
)

# GRU 64 : 12 672 parametres. LSTM 55 : ~12 500 parametres (comparable).
gru = batir(layers.GRU, 64)
entrainer(gru, X_tr, y_tr, X_va, y_va)
mae_gru = gru.evaluate(X_te, y_te, verbose=0)[1]

lstm = batir(layers.LSTM, 55)
entrainer(lstm, X_tr, y_tr, X_va, y_va)
mae_lstm = lstm.evaluate(X_te, y_te, verbose=0)[1]

print(f"MAE test : naive={mae_naive:.3f} gru={mae_gru:.3f} lstm={mae_lstm:.3f}")

Résultats typiques sur ce type de série : le naïf donne une MAE autour de 0,45 (échelle normalisée), le GRU descend vers 0,28, le LSTM vers 0,27. Les deux modèles réduisent l'erreur de 35 à 40 %, et sont indiscernables l'un de l'autre. C'est la situation que la littérature décrit sur les petites séries métier.

Étape 3 : intervalles par régression quantile

Une prévision ponctuelle n'informe pas sur la confiance. Un intervalle de prévision à 80 %, entre le quantile 10 et le quantile 90, donne au métier de quoi décider (dimensionner le contrat de fourniture d'électricité, par exemple). La régression quantile utilise la perte pinball :

Lτ(y,y^)=max(τ(yy^),(τ1)(yy^))L_\tau(y, \hat{y}) = \max\bigl(\tau (y - \hat{y}), (\tau - 1)(y - \hat{y})\bigr)

τ(0,1)\tau \in (0, 1) est le quantile visé. On entraîne trois modèles (ou un modèle à trois sorties) pour τ{0,1,0,5,0,9}\tau \in \{0{,}1, 0{,}5, 0{,}9\}.

def perte_pinball(tau):
def perte(y_true, y_pred):
e = y_true - y_pred
return tf.reduce_mean(tf.maximum(tau * e, (tau - 1) * e))
return perte

def batir_quantile(tau):
modele = keras.Sequential([
layers.Input(shape=(LONG_ENTREE, 1)),
layers.GRU(64),
layers.Dense(LONG_CIBLE),
])
modele.compile(optimizer=keras.optimizers.Adam(1e-3, clipnorm=1.0),
loss=perte_pinball(tau))
return modele

modeles_q = {}
for tau in [0.1, 0.5, 0.9]:
m = batir_quantile(tau)
m.fit(X_tr, y_tr, validation_data=(X_va, y_va),
epochs=50, batch_size=64,
callbacks=[keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True)],
verbose=0)
modeles_q[tau] = m

# Verification : le quantile 10 doit contenir 10 % des valeurs reelles au-dessus, etc.
def couverture(modele, X, y):
pred = modele.predict(X, verbose=0)
return float(np.mean(y >= pred))

for tau, m in modeles_q.items():
print(f"tau={tau} : couverture reelle = {1 - couverture(m, X_te, y_te):.3f}")

L'idéal : la couverture réelle est proche du quantile visé. Si le quantile 10 laisse en réalité 20 % des valeurs sous la prévision, l'intervalle est trop étroit et sous-estime la variabilité.

Étape 4 : décomposer l'erreur par horizon

Une MAE moyenne cache une réalité utile : l'erreur croît avec l'horizon. La consommation à h+1 est bien plus facile à prévoir que celle à h+24.

pred_gru = gru.predict(X_te, verbose=0)
mae_par_h = np.mean(np.abs(pred_gru - y_te), axis=0) # forme (24,)

import matplotlib.pyplot as plt
plt.plot(range(1, 25), mae_par_h)
plt.xlabel("horizon (heures)")
plt.ylabel("MAE")
plt.title("Degradation de l'erreur avec l'horizon")
plt.show()

Cette courbe montre presque toujours une croissance monotone jusqu'à 24 h, avec parfois un creux vers h+24 si la saisonnalité est très marquée (la consommation à même heure le lendemain est plus prévisible qu'à h+18). Communiquez-la au métier : c'est un livrable en soi.

Étape 5 : la liste des erreurs à ne pas commettre

Chacune coûte des jours de débogage en production.

Oublier la référence naïve — le score « impressionnant » est peut-être juste banal, ou pire, révélateur d'une fuite.

Mélanger avant de découpertrain_test_split(shuffle=True) sur une série est une contamination directe.

Normaliser globalement — moyenne et écart-type de tout le jeu incluent le test, qui contamine train.

Utiliser Bidirectional en prévision — fuite déguisée en architecture (module 7).

Recouvrir massivement train et test — un sequence_stride=1 sans bande tampon rend train et test presque identiques (module 9).

Se fier à MSE seul sur données à grande dynamique — une valeur extrême capte tout le gradient. Une MAE (ou une pinball loss) est plus robuste sur la consommation électrique.

Négliger les valeurs manquantes — un capteur à zéro qui n'est pas distingué d'une consommation réelle à zéro perd le sens du jeu. Ajouter une variable binaire « donnée manquante » sauve souvent le résultat.

Rendu attendu

Pour un livrable qui tient : un carnet Jupyter unique qui produit

  • la comparaison des trois MAE (naïve, GRU, LSTM),
  • la couverture des trois quantiles sur le test,
  • la courbe de MAE par horizon,
  • une phrase par erreur ci-dessus expliquant comment vous l'avez évitée.

Ce document est le seul qui convainc un décideur : les chiffres sont comparés à un point de repère, l'intervalle est calibré, la dégradation est visible.

Un modèle qui bat la référence naïve de peu peut être vrai

Sur une série très saisonnière, la référence naïve j-1 est extraordinairement forte. Un GRU qui la bat de 5 à 10 % est un vrai résultat ; ce n'est pas un modèle « décevant ». À l'inverse, un modèle qui la bat de 60 % du premier coup, sans réglage, doit vous alerter — cherchez la fuite dans le pipeline avant tout.

Sauvegarder le pipeline entier, pas seulement le modèle

Le modèle sans son pipeline (moyenne, écart-type, fonction de fenêtrage, décalage cible) est inutilisable. Exporter au format SavedModel une couche Normalization adaptée sur le train (module 10 du cours 08) évite la divergence entre entraînement et service. Ce point est vérifié à l'attestation.

En résumé

  • Un projet de prévision livrable combine préparation chronologique, normalisation propre, référence naïve, comparaison GRU/LSTM à budget comparable, intervalles calibrés et rapport d'erreurs par horizon.
  • La référence naïve j-1 (ou j-7) est le seul juge honnête d'un score ; la battre de 5 à 20 % est un vrai résultat, la battre de 60 % du premier coup signale une fuite à chercher.
  • La régression quantile avec la perte pinball donne des intervalles utilisables ; leur couverture réelle doit être vérifiée sur le test.
  • L'erreur par horizon croît presque toujours ; sa courbe est un livrable en soi, elle informe le métier sur l'horizon de confiance.

Module suivant : récapitulation de tout le cours, pont vers l'attention et les transformeurs, et présentation de l'examen final.