TP6. Séries temporelles¶
Durée : 3 h.
Objectifs¶
- Explorer une série temporelle réelle et identifier ses motifs (tendance, saisonnalité).
- Décomposer une série en tendance, saisonnalité et résidu, et tester sa stationnarité.
- Construire une prévision avec des modèles classiques (persistance, AR, ARIMA).
- Reformuler la prévision en un problème d'apprentissage supervisé avec des variables retardées, et comparer les deux familles d'approches.
Prérequis¶
Le TP1 pour la manipulation de données, le TP5 pour la logique de validation d'un modèle de régression.
Ressources¶
Étape 1. Charger et comprendre une série temporelle réelle (35 min)¶
Le jeu de données Bike Sharing Demand décrit la location horaire de vélos en libre-service à Washington DC sur deux ans (2011-2012) : 17 379 heures, avec la météo du moment et le nombre de locations. C'est un cas d'école pour la prévision de demande : les motifs y sont nets (heures de pointe, week-ends, saisons) et le volume est assez grand pour que le bruit ne domine pas le signal.
from sklearn.datasets import fetch_openml
import pandas as pd
brut = fetch_openml(data_id=42712, as_frame=True, parser="auto")
df = brut.frame.copy()
for colonne in ["year", "month", "hour", "weekday"]:
df[colonne] = df[colonne].astype(int)
df["count"] = df["count"].astype(int)
print(df.shape)
print(df[["season", "year", "month", "hour", "weekday", "count"]].head())
Ce miroir du jeu de données ne fournit pas la date calendaire exacte de chaque observation, seulement l'année, le mois, l'heure et le jour de la semaine, dans l'ordre chronologique. C'est suffisant pour tout ce TP : la plupart des analyses de série temporelle raisonnent de toute façon en position dans le cycle (quelle heure, quel jour) plutôt qu'en date absolue.
serie = df["count"].reset_index(drop=True)
serie.index.name = "heure_sequentielle"
print(serie.describe())
La demande moyenne est d'environ 189 locations par heure, avec un écart-type de 181, ce qui dit déjà quelque chose : l'écart-type dépasse presque la moyenne, la distribution est loin d'être symétrique autour de sa moyenne.
Exercice 1.1 : deux profils cycliques
Calculez la demande moyenne par heure de la journée, séparément pour les jours de
semaine et pour le week-end (weekday vaut 0 pour lundi, 5 et 6 pour samedi et
dimanche). Le week-end suit-il la même forme de profil horaire que la semaine ?
Résultat attendu : en semaine, un profil à deux pics nets séparés par un creux, vers 8 h (environ 388 locations en moyenne) et surtout vers 17 h (environ 481), la signature d'un usage domicile-travail. Le week-end, un profil sans creux, qui monte progressivement le matin et reste élevé de 11 h à 17 h (entre 250 et 350 en moyenne), sans les deux pics séparés de la semaine.
Corrigé
df["est_weekend"] = df["weekday"].isin([5, 6])
profil_semaine = df[~df["est_weekend"]].groupby("hour")["count"].mean().round(1)
profil_weekend = df[df["est_weekend"]].groupby("hour")["count"].mean().round(1)
comparaison = pd.DataFrame({"semaine": profil_semaine, "weekend": profil_weekend})
print(comparaison)
print("creux de la semaine, à 10h :", profil_semaine.loc[10])
print("weekend à la même heure, à 10h :", profil_weekend.loc[10])
À 10 h, la semaine est en plein creux entre les deux pics (environ 159) alors que le week-end est déjà proche de son plateau (environ 211) : la même heure porte un sens complètement différent selon le jour. Deux populations d'usagers se cachent dans une seule série agrégée : c'est une des raisons qui motivent l'étape 5, où l'heure et le jour deviennent des variables du modèle plutôt qu'une moyenne qui les efface.
Étape 2. Décomposer une série : tendance, saisonnalité, résidu (30 min)¶
Une série temporelle se pense comme la somme (modèle additif) ou le produit (modèle multiplicatif) de plusieurs composantes : un niveau de fond qui évolue lentement (la tendance), un motif qui se répète à intervalle fixe (la saisonnalité), et ce qui reste une fois les deux retirés (le résidu).
from statsmodels.tsa.seasonal import seasonal_decompose
import matplotlib.pyplot as plt
decomposition = seasonal_decompose(serie, model="additive", period=24)
fig, axes = plt.subplots(4, 1, figsize=(12, 9), sharex=True)
decomposition.observed.plot(ax=axes[0], title="Série observée")
decomposition.trend.plot(ax=axes[1], title="Tendance")
decomposition.seasonal.plot(ax=axes[2], title="Saisonnalité (période 24 h)")
decomposition.resid.plot(ax=axes[3], title="Résidu")
plt.tight_layout()
plt.show()
period=24 dit à seasonal_decompose de chercher un motif qui se répète toutes les 24
observations, l'hypothèse naturelle pour une série horaire avec un cycle quotidien.
Exercice 2.1 : un second cycle, hebdomadaire, et sa vraie limite
Refaites la décomposition avec period=24*7 (168, le cycle hebdomadaire) plutôt que
24, et comparez la part de variance de la série que chaque décomposition explique
(1 - variance(résidu) / variance(série)).
Résultat attendu : les deux décompositions se valent presque, et c'est le résultat
surprenant : environ 25 % de variance expliquée avec period=24, environ 24 % avec
period=168. Aucune des deux ne capture l'essentiel de la variabilité de la série.
Corrigé
decomposition_semaine = seasonal_decompose(serie, model="additive", period=24 * 7)
variance_totale = serie.var()
for nom, resultat in [("24", decomposition), ("168", decomposition_semaine)]:
variance_residu = resultat.resid.dropna().var()
part_expliquee = 1 - variance_residu / variance_totale
print(f"period={nom} : part de variance expliquée = {part_expliquee:.3f}")
Ni le cycle quotidien seul, ni le cycle hebdomadaire seul, n'expliquent la
majorité de la variance : environ 75 % reste dans le résidu, dans les deux cas.
L'exercice 1.1 donne la clé de cette énigme : le profil horaire n'est PAS le même
en semaine et le week-end (deux pics contre un plateau). Une décomposition à une
seule période force un unique profil moyen sur les 24 heures, qui n'est fidèle ni
aux jours de semaine ni aux week-ends. C'est un cas où le bon diagnostic (variance
mal expliquée) demande de croiser deux facteurs (l'heure ET le type de jour), pas
d'ajouter une seconde période à une décomposition qui n'en gère qu'une à la fois.
Des modèles plus avancés (SARIMA multi-saisonnier, Prophet, ou tout simplement le
modèle supervisé de l'étape 5 qui inclut heure et jour_semaine comme variables
séparées) répondent mieux à ce genre de structure croisée.
Étape 3. Stationnarité : ce qu'un test ADF dit vraiment (25 min)¶
Un modèle AR ou ARIMA suppose une série stationnaire : une moyenne et une variance qui ne dérivent pas dans le temps. Le test de Dickey-Fuller augmenté (ADF) teste cette hypothèse.
from statsmodels.tsa.stattools import adfuller
stat, pvalue, *_ = adfuller(serie)
print(f"statistique : {stat:.3f}, p-value : {pvalue:.4f}")
Exercice 3.1 : une série saisonnière peut-elle être stationnaire ?
Exécutez le test ADF ci-dessus sur serie, la série brute, sans l'avoir
différenciée. Le test conclut-il à la stationnarité (p-value < 0,05) ? Est-ce
surprenant, sachant que la série a un motif quotidien évident (étape 1) ?
Résultat attendu : oui, le test ADF conclut à la stationnarité de la série brute
(p-value proche de 0), alors même qu'elle est visiblement saisonnière. Ce n'est pas
une erreur : le test ADF détecte une racine unitaire (une dérive de type marche
aléatoire, où les chocs s'accumulent indéfiniment), pas une saisonnalité. Une série
peut osciller fortement de façon parfaitement régulière, sans jamais dériver, et rester
stationnaire au sens de ce test.
Corrigé
stat, pvalue, *_ = adfuller(serie)
print(f"série brute : statistique {stat:.3f}, p-value {pvalue:.4f}")
diff1 = serie.diff().dropna()
stat_d, pvalue_d, *_ = adfuller(diff1)
print(f"différenciée (ordre 1) : statistique {stat_d:.3f}, p-value {pvalue_d:.6f}")
La différenciation rend la statistique ADF encore plus négative (la série brute
était déjà stationnaire, la différenciée l'est encore plus fortement), mais elle
retire l'essentiel du niveau moyen et donc du motif quotidien, ce qui n'est
pas toujours souhaitable : un ADF significatif ne dit pas qu'il faut différencier,
il dit seulement qu'un modèle ARMA sans différenciation (d=0) est défendable. Le
vrai critère pour choisir d est de regarder ce que la différenciation fait à
l'interprétabilité et aux résidus, pas seulement à la p-value.
Étape 4. Prévoir avec un modèle classique : persistance, AR, ARIMA (35 min)¶
Réservons les 7 derniers jours (168 heures) comme test, et entraînons nos modèles sur tout ce qui précède.
from sklearn.metrics import mean_absolute_error, root_mean_squared_error
horizon = 24 * 7
train, test = serie.iloc[:-horizon], serie.iloc[-horizon:]
print(train.shape, test.shape)
# baseline : on répète la dernière valeur connue de l'entraînement sur tout l'horizon
prevision_plate = pd.Series(train.iloc[-1], index=test.index)
mae_plate = mean_absolute_error(test, prevision_plate)
rmse_plate = root_mean_squared_error(test, prevision_plate)
print(f"baseline plate : MAE {mae_plate:.2f}, RMSE {rmse_plate:.2f}")
C'est la baseline la plus honnête pour une prévision à horizon fixe : au moment de la prévision, la seule chose que l'on connaît avec certitude est la dernière valeur observée.
from statsmodels.tsa.ar_model import AutoReg
modele_ar = AutoReg(train, lags=24).fit()
prevision_ar = modele_ar.predict(start=len(train), end=len(train) + len(test) - 1)
prevision_ar.index = test.index
mae_ar = mean_absolute_error(test, prevision_ar)
rmse_ar = root_mean_squared_error(test, prevision_ar)
print(f"AR(24) : MAE {mae_ar:.2f}, RMSE {rmse_ar:.2f}")
L'IA vous le donne en trois secondes
Beaucoup de tutoriels et d'assistants écrivent encore AutoReg(train, lags=24,
old_names=False), un paramètre qui existait dans d'anciennes versions de statsmodels
pour gérer une transition de nommage. Il a depuis été retiré :
Ceci lève une TypeError, pas un avertissement : le code s'arrête. C'est un exemple de
plus de la règle déjà rencontrée aux TP4 et TP5 : une bibliothèque de machine learning
évolue vite, et du code qui fonctionnait il y a un an peut être du code mort
aujourd'hui. Retirez simplement le paramètre.
from statsmodels.tsa.arima.model import ARIMA
modele_arima = ARIMA(train, order=(2, 0, 2)).fit()
prevision_arima = modele_arima.forecast(steps=len(test))
mae_arima = mean_absolute_error(test.values, prevision_arima.values)
rmse_arima = root_mean_squared_error(test.values, prevision_arima.values)
print(f"ARIMA(2,0,2) : MAE {mae_arima:.2f}, RMSE {rmse_arima:.2f}")
Exercice 4.1 : pourquoi ARIMA(2,0,2) perd contre la baseline
Comparez les trois MAE obtenus (baseline plate, AR(24), ARIMA(2,0,2)). L'un des trois
modèles « avancés » fait-il moins bien que la baseline la plus simple ? Regardez les
ordres p, d, q choisis pour comprendre pourquoi.
Résultat attendu : ARIMA(2,0,2) (MAE autour de 123) fait nettement moins bien
que la baseline plate (MAE autour de 65), elle-même battue par AR(24) (MAE autour de
50). Ce n'est pas qu'ARIMA soit un mauvais modèle : c'est que p=2 ne regarde que
deux heures en arrière, bien trop court pour capturer le cycle de 24 heures identifié
à l'étape 1. AR(24), lui, regarde explicitement jusqu'à 24 heures en arrière et attrape
ainsi le cycle quotidien.
Corrigé
comparaison = pd.DataFrame({
"modèle": ["baseline plate", "AR(24)", "ARIMA(2,0,2)"],
"MAE": [mae_plate, mae_ar, mae_arima],
"RMSE": [rmse_plate, rmse_ar, rmse_arima],
})
print(comparaison.round(2))
La leçon n'est pas « ARIMA est mauvais », elle est « le choix de l'ordre compte
plus que le choix du modèle ». Un ARIMA avec un ordre p assez grand pour couvrir
24 heures, ou une saisonnalité explicite (SARIMA), retrouverait un comportement
comparable à AR(24). Comparer des modèles sans comparer leurs hypothèses
(ici, la portée temporelle qu'ils regardent) mène à des conclusions trompeuses.
Étape 5. Reformuler en apprentissage supervisé (35 min)¶
Une autre manière d'aborder la prévision : transformer la série en un problème de régression classique, où les variables explicatives sont des valeurs passées de la série elle-même (des lags) et des informations calendaires.
caracteristiques = pd.DataFrame(index=serie.index)
caracteristiques["heure"] = df["hour"]
caracteristiques["jour_semaine"] = df["weekday"]
caracteristiques["mois"] = df["month"]
caracteristiques["est_weekend"] = caracteristiques["jour_semaine"].isin([5, 6]).astype(int)
for lag in [1, 2, 24, 168]:
caracteristiques[f"lag_{lag}"] = serie.shift(lag)
caracteristiques["moyenne_glissante_24h"] = serie.shift(1).rolling(24).mean()
caracteristiques["count"] = serie
caracteristiques = caracteristiques.dropna()
print(caracteristiques.shape)
serie.shift(1).rolling(24), et non serie.rolling(24) directement : la moyenne glissante
d'une heure ne doit dépendre que du passé, jamais de l'heure elle-même, sans quoi le
modèle « voit » indirectement ce qu'il doit prédire (une fuite de données, déjà rencontrée
au TP8 sous une autre forme).
from sklearn.ensemble import RandomForestRegressor
entrainement = caracteristiques.iloc[:-horizon]
test_sup = caracteristiques.iloc[-horizon:]
X_train, y_train = entrainement.drop(columns="count"), entrainement["count"]
X_test, y_test = test_sup.drop(columns="count"), test_sup["count"]
foret = RandomForestRegressor(n_estimators=200, max_depth=12, min_samples_leaf=5, n_jobs=-1, random_state=42)
foret.fit(X_train, y_train)
prevision_foret = foret.predict(X_test)
mae_foret = mean_absolute_error(y_test, prevision_foret)
rmse_foret = root_mean_squared_error(y_test, prevision_foret)
print(f"Forêt aléatoire : MAE {mae_foret:.2f}, RMSE {rmse_foret:.2f}")
Exercice 5.1 : quelle variable porte la prévision
Affichez foret.feature_importances_ associé aux noms de colonnes, trié par ordre
décroissant. Quelle variable domine largement les autres ? Est-ce cohérent avec ce
que la baseline plate de l'étape 4 utilisait déjà ?
Résultat attendu : lag_1 (la valeur de l'heure précédente) porte environ 69 % de
l'importance totale, très loin devant toutes les autres variables. lag_168 (la même
heure la semaine précédente) vient en second, autour de 16 %.
Corrigé
importances = pd.Series(foret.feature_importances_, index=X_train.columns)
print(importances.sort_values(ascending=False).round(3))
C'est cohérent, et ça explique pourquoi ce modèle (MAE autour de 15) bat de loin
tous les modèles de l'étape 4 : il retrouve, via lag_1, une information très
proche de celle qu'utilisait la baseline plate (la dernière valeur observée), mais
y ajoute lag_168 (le même moment la semaine passée) et le contexte calendaire.
Le modèle supervisé ne gagne pas parce qu'il est plus sophistiqué en soi : il gagne
parce qu'il a accès, comme la baseline plate, à la toute dernière valeur réelle
juste avant l'instant à prédire, ce qu'ARIMA et AR, lancés une fois pour tout
l'horizon de 7 jours, n'ont pas.
Pour aller plus loin¶
- Un modèle SARIMA (
p,d,q×P,D,Q,s) intègre directement une saisonnalité dans sa structure, plutôt que de compter sur un ordrepélevé pour l'attraper indirectement. - Une prévision glissante (rolling forecast) réinjecte à chaque pas la vraie valeur observée, ou à défaut la prédiction précédente, pour comparer les modèles sur un horizon d'un pas plutôt que sur un horizon fixe de 7 jours.
- Les variables météo du jeu de données (
temp,humidity,windspeed) n'ont pas été utilisées ici : les ajouter aux caractéristiques de l'étape 5 est un prolongement naturel. - XGBoost (TP4) peut remplacer la forêt aléatoire dans l'étape 5 pour comparer les deux familles d'ensembles d'arbres sur ce problème.
Ce qu'il faut retenir¶
Une série temporelle se décompose en tendance, saisonnalité et résidu, et cette décomposition dépend d'une période choisie a priori. Le test ADF détecte une racine unitaire, pas une saisonnalité : une série peut osciller fortement et rester stationnaire au sens de ce test. Comparer des modèles de prévision exige de comparer des baselines justes : une baseline « plate » (dernière valeur connue) pour un horizon fixe, pas une baseline qui triche en réutilisant des valeurs futures. L'ordre d'un modèle AR ou ARIMA doit couvrir la portée du cycle qu'on veut capturer, sinon un modèle plus simple mais mieux dimensionné (ici, AR(24) contre ARIMA(2,0,2)) le bat largement. Et reformuler la prévision en apprentissage supervisé avec des lags permet souvent de battre les modèles classiques, en grande partie parce que le lag le plus récent contient déjà l'essentiel du signal.
Auto-évaluation¶
Avant de continuer, vous devez pouvoir, sans regarder le corrigé :
- décomposer une série en tendance, saisonnalité et résidu, et choisir une période ;
- expliquer ce qu'un test ADF teste, et ce qu'il ne teste pas ;
- construire une baseline de prévision honnête pour un horizon fixe ;
- expliquer pourquoi l'ordre d'un modèle AR ou ARIMA doit être cohérent avec le cycle de la série ;
- construire des variables retardées (
lag) et une moyenne glissante sans fuite de données, pour reformuler une série en problème supervisé.
Pas encore de QCM pour ce TP
Contrairement au module IA générative, ce TP n'a pas de QCM d'auto-évaluation prêt à
l'emploi : les TP d'IA prédictive n'en ont jamais eu. En créer un est possible avec le
skill qcm-generator, mais reste à faire.