Travaux pratiques - Prédiction de séries temporelles

L’objectif de cette séance de travaux pratiques est de mettre en œuvre un réseau de neurones récurrent pour prédire les variations d’une série temporelle. En particulier, nous entraînerons un modèle de régression qui pourra servire soit à régresser des données manquantes, soit à prévoir les valeurs futures de la série. L’illustration se fera sur un jeu de données météorologiques.

Création d’un jeu d’entraînement et de validation

Nous allons nous entraîner sur un jeu de données météorologiques disponible à l’adresse suivante : http://cedric.cnam.fr/~rambourc/meteo_dataset.csv.

Pour commencer, téléchargeons ce jeu de données.

!wget -nc http://cedric.cnam.fr/~rambourc/meteo_dataset.csv

Il s’agit d’un jeu de données au format CSV, que nous pouvons visualiser, par exemple, avec la bibliothèque pandas (https://pandas.pydata.org/) en utilisant la méthode pandas.read_csv.

import pandas as pd

data = pd.read_csv('meteo_dataset.csv')
data

Comme nous pouvons le voir sur la sortie ci-dessus, le jeu de données meteo est une série de \(N = 420551\) observations. Chaque observation est décrite par un ensemble de variables, des grandeurs physiques issues d’un système météorologique. Formellement, nous travaillons sur une séquence de vecteurs \((\mathbf u_n)_{0\leq n \leq N-1} \in \mathbb R^{7}\). Les différentes composantes de ces vecteurs correspondent aux paramètres physiques mesurés : \(\mathcal P = \{\)pression, température, saturation, déficit de pression de vapeur, humidité, densité, vistesse du vent\(\}\).

Nous pouvons observer les variations de certains de ces paramètres au cours du temps pour l’ensemble du jeu de données, en traçant leur évolution au cours du temps avec matplotlib :

import matplotlib.pyplot as plt

fig = plt.figure(figsize=(20,7))
# Évolution de la pression
ax1 = fig.add_subplot(221)
plt.plot(data['Pressure'])
ax1.title.set_text('Pression')
# Évolution de la température
ax2 = fig.add_subplot(222)
plt.plot(data['Temperature'])
ax2.title.set_text('Température')
# Évolution de la saturation
ax3 = fig.add_subplot(223)
plt.plot(data['Saturation vapor pressure'])
ax3.title.set_text('Saturation')
# Évolution du déficit de pression de vapeur
ax4 = fig.add_subplot(224)
plt.plot(data['Vapor pressure deficit'])
ax4.title.set_text('Déficit de pression de vapeur')

plt.show()

Question

Commenter la forme des courbes. Que peut-on dire d’après l’aspect global des données ? Parmi les quatre paramètres que nous avons affiché, lesquels sont susceptibles d’être plus difficiles à apprendre que les autres ?

Correction

La température, la saturation et le déficit de pression de vapeur ont des évolutions périodiques très marquées, avec une cohérence temporelle élevée. La courbe de température est notamment assez peu bruitée. À l’inverse, la courbe de pression est bruitée, avec des outliers importants et ne semble pas exhiber de périodicité évidente. Ce paramètre risque donc d’être nettement plus difficile à prédire.

Division du jeu de données en apprentissage et validation

Comme d’habitude, nous allons séparer les données en un ensemble d’apprentissage et un ensemble de validation. L’ensemble de validation nous servira à l’évaluation du modèle. Dans notre cas, nous choisissons arbitrairement d’utiliser 80% des données pour l’apprentissage et les 20% restants pour la validation.

Question

Quelles précautions doit-on prendre dans la division de ce jeu de données en ensemble de train et de test ? Pourquoi ?

Correction

Comme il s’agit de séries temporelles, les observations ne sont pas i.i.d. (indépendantes et identiquement distribuées). En effet, l’observation \(x_t\) au pas de temps \(t\) sera très certainement corrélée avec l’observation \(x_{t+1}\) au pas de temps suivant. Il ne faut donc pas réaliser une division aléatoire avec un tirage uniforme : les données de test seraient très similaires aux données d’entraînement et notre estimation de l’erreur de généralisation sera bien trop optimiste.

Une solution consiste à entraîner sur les premières valeurs de la série temporelle, puis de s’évaluer sur les dernières valeurs, en créant un jeu d’apprentissage et un jeu de test qui sont temporellement disjoints.

split_fraction = 0.80
split_idx =int(split_fraction * len(data))

# Train: on conserve les 80% premières lignes
train_data = data.loc[0:split_idx-1].values
# Test: on conserve les 20% dernières lignes
val_data = data.loc[split_idx:].values

print(f"Jeu d'apprentissage : {train_data.shape}, jeu d'évaluation : {val_data.shape}")

Dans un premier temps, nous allons nous intéresser à la prédiction de la température à partir des six autres paramètres de la série temporelle. Il s’agit donc d’une configuration many-to-many, c’est-à-dire que le modèle décisionnel consiste à prédire une séquence \(\mathbf y_t\) (ici, la température) à partir d’une séquence de variables explicatives \(\mathbf x_t\) (les autres paramètres).

Question

Dans cette configuration, quelles sont les données ? Quelles sont les étiquettes ? Quelles sont leurs dimensions respectives ?

Correction

Le modèle prendra en entrée une séquence de \(M\) éléments correspondant à tous les paramètres sauf la température pour \(M\) acquisitions et prédira pour chaque pas de temps la température associée. Formellement, le modèle prendra en entrée une séquence \(\mathbf X_n = [\mathbf x_{n},\cdots,\mathbf x_{n+M-1}] \in \mathbb R^{M\times 6}\) telle que \(\forall\, n \in \{0,\cdots , N-1\}, p \in \mathcal P\setminus \{\text{Temperature}\}, \mathbf x_{n,p} = \mathbf u_{n,p}\). En sortie, le modèle prédit la séquence \(\mathbf Y_n = [ y_{n},\cdots, y_{n+M-1}] \in \mathbb R^{M\times 1}\) telle que \(\forall\, n \in \{0,\cdots , N-1\}, y_{n} = \mathbf u_{n,\text{Temperature}}\).

Afin d’entraîner notre réseau de neurones par descente de gradient, nous allons devoir subdiviser le jeu de données en batch. Cette opération peut être réalisée automatiquement à l’aide de la fonction timeseries_dataset_from_array() de Keras.

Question

En vous aidant de la documentation de la fonction, expliquer à quoi correspondent les paramètres sequence_length et sampling_rate.

Correction

sequence_length correspond à la taille de la fenêtre de la série temporelle, c’est-à-dire la longueur maximale de la séquence considérée. sampling_rate définit le taux d’échantillonnage et permet de réduire la dimension de la série en considérant par exemple qu’une valeur sur deux.

Question

Compléter le code suivant pour créer un jeu de données d’entraînement au format attendu par Keras. Répéter pour le jeu de données d’évaluation. On se fixera des batch de taille \(256\), des séquences de longueur \(120\) éléments et, pour réduire le temps de calcul, nous allons sous-échantillonner les données d’un facteur \(6\).

Correction

import tensorflow as tf

sampling_rate = 6 # Fréquence d'échantillonnage
sequence_length = 120  # Longueur de la séquence
batch_size = 256 # Taille de batch

# Création du jeu d'entraînement

X_train = tf.keras.utils.timeseries_dataset_from_array(
    data=train_data[:, (0,2,3,4,5,6)],
    targets=None,
    sequence_length=sequence_length,
    sampling_rate=sampling_rate,
    batch_size=batch_size,
)

y_train = tf.keras.utils.timeseries_dataset_from_array(
    data=train_data[:, 1],
    targets=None,
    sequence_length=sequence_length,
    sampling_rate=sampling_rate,
    batch_size=batch_size,
)


dataset_train = tf.data.Dataset.zip((X_train, y_train))

# Création du jeu d'évaluation

X_val = tf.keras.utils.timeseries_dataset_from_array(
    data=val_data[:, (0,2,3,4,5,6)],
    targets=None,
    sequence_length=sequence_length,
    sampling_rate=sampling_rate,
    batch_size=batch_size,
)

y_val = tf.keras.utils.timeseries_dataset_from_array(
    data=val_data[:, 1],
    targets=None,
    sequence_length=sequence_length,
    sampling_rate=sampling_rate,
    batch_size=batch_size,
)

dataset_val = tf.data.Dataset.zip((X_val, y_val))

Entraînement du réseau récurrent

Maintenant que le jeu de données est mis en forme, nous pouvons définir le réseau récurrent que nous allons utiliser pour prédire la température à partir des autres paramètres météorologiques. Pour ce TP, nous allons implémenter un réseau récurrent simple constitué d’une Gated Recurrent Unit (GRU) et de deux couches linéaires.

from tensorflow import keras

Question

Quelles sont les dimensions des vecteurs en entrée et en sortie du réseau ?

Question

Compléter le code ci-dessous qui réalise la construction du modèle. On choisira d’utiliser une dimension égale à celle des variables d’entrée pour la couche cachée du GRU. La fonction de perte utilisée pour la régression sera l’erreur quadratique moyenne (MSE).

Correction

dimension = train_data.shape[1]-1
inputs = keras.layers.Input(shape=(sequence_length, dimension))
gru_out = keras.layers.GRU(dimension, return_sequences=True)(inputs)
dense_out = keras.layers.Dense(dimension, activation='relu')(gru_out)
outputs = keras.layers.Dense(1)(dense_out)

model = keras.Model(inputs=inputs, outputs=outputs)

Concernant l’optimisation, nous allons utiliser le solveur Adam pour la descente de gradient. On fixe le pas d’apprentissage à \(0,001\) pour l’instant. Nous pouvons compiler le modèle avec Keras :

learning_rate = 0.001
model.compile(optimizer=keras.optimizers.Adam(learning_rate=learning_rate), loss="mse")
model.summary()

Comme d’habitude avec Keras, nous pouvons démarrer l’apprentissage du modèle à l’aide de la méthode fit sur notre jeu de données d’apprentissage. Nous allons entraîner ce modèle pendant 5 époques :

epochs = 5

model.fit(
    dataset_train,
    epochs=epochs,
    validation_data=dataset_val,
)

Keras nous affiche lors de l’apprentissage les scores de validation, ce qui nous permet de vérifier que nous ne sommes pas en régime de sur-apprentissage. Pour une analyse plus qualitative, nous pouvons visualiser à présent les résultats des prédictions sur les 5 premiers éléments du jeu de validation. La fonction ci-dessous show_plot permet de visualiser sur un même graphique la prédiction et la vérité terrain.

def show_plot(predicted_data, true_data, title=None):
    plt.title(title)
    plt.plot(predicted_data, "rx", label="Température prédite")
    plt.plot(true_data, ".-", label="Vraie température")
    plt.legend()
    plt.xlabel("Pas de temps")
    plt.show()
    return

Question

À l’aide de la méthode predict de Keras (cf. documentation), calculer les prédictions de température pour les trois premières séquences x du jeu de données de validation. Utiliser la fonction show_plot ci-dessus pour afficher les graphiques correspondants.

Correction

for x, y in dataset_val.take(3):
    show_plot(y[0].numpy(), model.predict(x)[0], title="Prédiction synchrone")

Question

Que constatez-vous concernant les performances du modèle ?

Correction

Les prédictions de température sont très proches de la vérité terrain. Les performances en train et en test sont bonnes et à peu près équivalentes, ce qui signifie a priori qu’il n’y a pas de surapprentrissage.

Bien que simple, notre modèle récurrent semble à même de prédire avec une haute précision la température à partir des autres paramètres météorologiques. Même si nous avons utilisé l’erreur quadratique moyenne pour l’optimisation du modèle, nous pouvons calculer l’erreur absolue moyenne, qui est souvent plus facile à interpréter :

mean_absolute_error = tf.keras.losses.MeanAbsoluteError()
error = 0

for x, y in dataset_val:
    y_pred = model(x)
    error += mean_absolute_error(y, y_pred).numpy()

print(f"Erreur absolue moyenne : {error/len(dataset_val):.5f}")

Question

À faire chez vous pour approfondir, si vous avez le temps.

Refaire l’expérience mais en prédisant la pression à partir des autres paramètres. Les conclusions sont-elles identiques ?

Prédiction de la température future

Nous avons vu précédemment comment prédire une séquence de valeurs à partir d’une autre séquence. Pour aller plus loin, nous allons à présent chercher non plus à reconstruire température à partir des autres paramètres, mais à prédire la température future, c’est-à-dire à \(t+1\), à partir de toutes les variables météorologiques jusqu’au temps \(t\).

Comme le but est de prédire la température future, nous allons diviser nos données en séquences de \(M\) éléments. Chacune aura pour étiquette associée la température \(T\) instants plus tard. Formellement, notre modèle prendra en entrée une séquence décrite par \(\mathbf X_n = \{\mathbf u_{n}, \cdots, \mathbf u_{n+M}\} \in \mathbb R^{M\times 7}\) et prédira en sortie un scalaire unique \(y_n = \mathbf u_{n+M+T+1,\text{Temperature}} \in \mathbb R\). Il s’agit donc désormais d’un problème many-to-one.

Nous utiliserons une séquence de \(M = 720\) observations passées prédire la température à \(T = 6\) instants plus tard. Les séquences d’entrée démarreront donc à l’indice \(0\) tandis que la première étiquette correspondra à l’indice \(720+6=726\). Ici encore, nous allons sous-échantillonner la séquence d’origine par un facteur \(6\), ce qui nous donnera en entrée des séquences de longueur \(720/6=120\), comme précédemment. Avec ce sous-échantillonnage, cela revient donc à faire la prédiction \(t+1\) à partir des \(120\) valeurs précédentes.

past_length = 720
future_step = 6

label_start = past_length + future_step  # Début de la séquence des labels
label_end = label_start + split_idx  # Fin de la séquence des labels

x_train = train_data
y_train = data[label_start:label_end][['Temperature']]

x_end = len(val_data) - past_length - future_step # Fin de la séquence des données d'entrées en validation

label_start = split_idx + past_length + future_step # Début de la séquence des labels en validation
x_val = val_data[:x_end]
y_val = data[label_start:][['Temperature']]
batch_size = 256
step_size = 6 # Facteur d'échantillonnage
sequence_length = int(past_length / step_size)

dataset_train = tf.keras.utils.timeseries_dataset_from_array(
    x_train,
    y_train,
    sequence_length=sequence_length,
    sampling_rate=step_size,
    batch_size=batch_size,
)

dataset_val = keras.preprocessing.timeseries_dataset_from_array(
    x_val,
    y_val,
    sequence_length=sequence_length,
    sampling_rate=step_size,
    batch_size=batch_size,
)

Correction

Comme précédemment, nous allons entraîner un réseau récurrent constitué d’une couche récurrente utilisant la cellule GRU et de deux couches linéaires séparées par une activation ReLU.

Question

Quelle est la taille des éléments en entrée et en sortie du réseau ?

Question

Compléter le code ci-dessous qui gère la définition du modèle avec Keras. On utilisera une couche cachée de dimension 32. Ne pas oublier de compiler le modèle avec un optimiseur Adam avec un pas d’apprentissage de \(0,001\). On utilisera l’erreur quadratique moyenne (mse) comme fonction de coût.

Correction

learning_rate = 0.001

input_dim = train_data.shape[1]
hidden_dim = 32
inputs = keras.layers.Input(shape=(sequence_length, input_dim))
gru_out = keras.layers.GRU(hidden_dim)(inputs)
dense_out = keras.layers.Dense(hidden_dim, activation='relu')(gru_out)
outputs = keras.layers.Dense(1)(dense_out)

model = keras.Model(inputs=inputs, outputs=outputs)
model.compile(optimizer=keras.optimizers.Adam(learning_rate=learning_rate), loss="mse")
model.summary()

À nouveau, nous pouvons entraîner le modèle sur le jeu de données à l’aide de la méthode .fit() du modèle Keras :

epochs = 5

history = model.fit(
    dataset_train,
    epochs=epochs,
    validation_data=dataset_val,
)

Pour terminer, nous pouvons désormais visualisons les résultats en prédiction de notre modèle sur les trois premières séquences du jeu de validation :

def show_plot(sequence, prediction, ground_truth, delta=future_step/step_size, title=None):
    plt.title(title)
    plt.plot(sequence.flatten(), ".-", label="Température passée")
    plt.plot(len(sequence) + delta, prediction, "go", label="Prédiction")
    plt.plot(len(sequence) + delta, ground_truth, "rx", label="Vraie température")
    plt.legend()
    plt.xlim(-1, len(sequence) + delta + 3)
    plt.xlabel("Pas de temps")
    plt.show()
for x, y in dataset_val.take(5):
    show_plot(x[0][:, 1].numpy(), model.predict(x)[0], y[0].numpy(), title="Prédiction de la température")

Question

Qu’observez-vous qualitativement dans les prédictions de notre modèle ? Que pouvez-vous en conclure sur la difficulté de la tâche de prédiction par rapport à la tâche de régression précédemment considérée ?

Correction

Les prédictions sont moins souvent correctes que précédemment. Il y a plus de grandes erreurs de prédictions entre la température prédite et la température future réelle. Ce n’est pas inattendu car l’on pouvait s’attendre à ce que la prédiction des valeurs futures soit un problème plus difficile que la reconstruction de la température à un pas de temps donné en connaissant toutes les autres variables.