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 ?

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 ?

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 ?

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.

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\).

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)], # sélectionne toutes les colonnes sauf la température
    targets=None,
    # TODO: compléter
)

y_train = tf.keras.utils.timeseries_dataset_from_array(
    data=train_data[:, 1], # sélectionne la colonne de température
    targets=None,
    # TODO: compléter
)


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

# Création du jeu d'évaluation

X_val = ... # TODO: Compléter

y_val = ... # TODO: Compléter

dataset_val = ... # TODO: Compléter

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).

dimension = train_data.shape[1] - 1
inputs = keras.layers.Input(shape=(sequence_length, dimension))
# TODO: à compléter
...

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.

for x, y in dataset_val.take(3):
    # TODO: compléter
    ...

Question

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

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,
)

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.

input_dim = train_data.shape[1]
inputs = keras.layers.Input(shape=(sequence_length, input_dim))
# TODO: compléter
...

model = keras.Model(inputs=inputs, outputs=outputs)
model.compile(...) # TODO: compléter
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 ?