Aller au contenu principal

Module 9 — Découpage et remplissage des séquences en pratique

Un modèle correct sur un jeu de données mal préparé donne des scores faux. Ce module traite les gestes qu'on croit anodins : découper un jeu en fenêtres, aligner des séquences de longueurs différentes, normaliser sans tricher, et faire couler des lots dans le modèle sans étrangler l'accélérateur. Chacun de ces gestes a un piège classique.

Le fenêtrage, cette fois avec un pipeline efficace

Le module 1 a présenté une version pédagogique du fenêtrage. En production, on préfère tf.keras.utils.timeseries_dataset_from_array, qui construit un Dataset sans tout charger en mémoire.

import tensorflow as tf
from tensorflow import keras

LONG_ENTREE = 168
LONG_CIBLE = 24

def batir_dataset(serie, i_debut, i_fin, taille_lot=64):
cible_decalee = serie[i_debut + LONG_ENTREE :]
return keras.utils.timeseries_dataset_from_array(
data=serie[i_debut : i_fin - LONG_CIBLE],
targets=cible_decalee[: (i_fin - LONG_CIBLE) - (i_debut + LONG_ENTREE)],
sequence_length=LONG_ENTREE,
sequence_stride=1,
batch_size=taille_lot,
shuffle=False, # jamais de shuffle sur une serie
)

Le drapeau shuffle=False est intentionnel : mélanger des fenêtres proches briserait la propriété d'entraînement chronologique du module 1. On peut, en revanche, mélanger à l'intérieur d'une fenêtre de temps (par exemple mélanger l'ordre des fenêtres à l'intérieur d'un même mois) sans casser la séparation train/val/test.

Normalisation : la statistique qui doit venir du seul train

La consommation électrique varie de quelques dizaines à quelques milliers de kilowattheures selon le bâtiment. Un modèle apprend beaucoup plus vite si l'entrée est centrée et réduite. La faute classique est de calculer moyenne et écart-type sur toute la série avant de découper : le test contamine alors les statistiques.

Le bon geste :

mu = serie[:i_train].mean()
sigma = serie[:i_train].std()

serie_norm = (serie - mu) / sigma # applique aux trois splits

Deux nuances utiles.

Normalisation par fenêtre. Quand la moyenne dérive dans le temps (consommation qui augmente d'année en année), on peut soustraire à chaque fenêtre sa propre moyenne, calculée uniquement sur les pas d'entrée (jamais sur la cible). Le modèle apprend alors des variations relatives, souvent plus stationnaires.

def normaliser_par_fenetre(X, y):
"""Centre chaque fenetre sur la moyenne de son entree."""
mu_x = X.mean(axis=1, keepdims=True) # moyenne par fenetre
X_c = X - mu_x
y_c = y - mu_x.squeeze(-1) # meme decalage pour la cible
return X_c, y_c, mu_x

À l'inférence, on rétablit l'échelle en réajoutant la moyenne à la prédiction.

Normalisation par bâtiment. Si l'on entraîne un modèle unique sur plusieurs bâtiments, chacun doit avoir sa propre paire (moyenne, écart-type). Sinon, le modèle apprend surtout la différence d'échelle entre bâtiments, et ignore la structure temporelle.

Le remplissage et son ombre : le masquage

Un lot de séquences de longueurs différentes se remplit avec un jeton « vide » (souvent 0) pour former un tenseur régulier. Sans précaution, ces zéros contribuent à la perte et à l'état caché, ce qui apprend au modèle à prédire un mot vide.

Deux voies pour éviter cela.

mask_zero=True sur Embedding. Le masque se propage automatiquement aux couches suivantes qui le comprennent (LSTM, GRU, TimeDistributed(Dense)). C'est la voie standard pour du texte.

plong = layers.Embedding(VOCAB, DIM, mask_zero=True)
lstm = layers.LSTM(64) # ignore les pas remplis

layers.Masking(mask_value=0.0) avant la première couche récurrente. C'est la voie pour des séries numériques où l'entrée n'est pas un identifiant à plonger.

modele = keras.Sequential([
layers.Input(shape=(None, F)),
layers.Masking(mask_value=0.0),
layers.LSTM(64),
layers.Dense(NB_SORTIES),
])

Le masquage change deux choses en interne : les pas masqués ne modifient plus l'état caché, et la perte les ignore. Sans lui, un lot bien rempli donne des scores qui semblent bons parce que la moitié de la perte porte sur des zéros triviaux à prédire.

Tri par longueur : le lot compact

Quand les longueurs varient beaucoup (2 tokens à 200 tokens), remplir un lot avec la plus longue gaspille de la mémoire et du temps. Le groupage par longueur consiste à trier les exemples par taille et à former des lots où les longueurs sont proches.

def taille_du_bucket(longueur):
if longueur < 20: return 20
if longueur < 50: return 50
if longueur < 100: return 100
return 200

def par_bucket(jeu, taille_lot=64):
return jeu.bucket_by_sequence_length(
element_length_func=lambda x, y: tf.shape(x)[0],
bucket_boundaries=[20, 50, 100, 200],
bucket_batch_sizes=[taille_lot] * 5,
)

L'accélération est réelle mais casse une hypothèse : les lots ne sont plus mélangés uniformément. En classification équilibrée, cela peut biaiser l'estimation du gradient à chaque lot. En traduction, c'est un standard sans dommage notable.

Le pipeline tf.data d'un récurrent

Recette qui marche presque toujours, et qui suit les principes du cours 08.

AUTO = tf.data.AUTOTUNE

def preparer(X, y, entrainement=False):
jeu = tf.data.Dataset.from_tensor_slices((X, y))
if entrainement:
jeu = jeu.shuffle(buffer_size=len(X)) # jamais de shuffle en val ou test
jeu = jeu.batch(64, drop_remainder=False).prefetch(AUTO)
return jeu

jeu_train = preparer(X_train, y_train, entrainement=True)
jeu_val = preparer(X_val, y_val, entrainement=False)

Deux règles à ne pas transgresser.

shuffle uniquement en entraînement. Un shuffle sur la validation ou le test rend les scores non reproductibles d'une exécution à l'autre.

shuffle avec buffer_size assez grand. Un buffer trop petit sur des données regroupées par classe donne des lots monoclasses. Sur une série temporelle où les fenêtres sont déjà indépendantes après le découpage chronologique, un buffer de la taille du jeu est acceptable.

En PyTorch : DataLoader et pack_padded_sequence

Pour ceux qui viennent du cours 09, la mécanique est analogue mais explicite.

import torch
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence
from torch.utils.data import Dataset, DataLoader

class JeuSequences(Dataset):
def __init__(self, sequences, cibles):
self.sequences = sequences
self.cibles = cibles

def __len__(self):
return len(self.sequences)

def __getitem__(self, i):
return torch.tensor(self.sequences[i]), torch.tensor(self.cibles[i])

def collate(batch):
sequences, cibles = zip(*batch)
longueurs = torch.tensor([len(s) for s in sequences])
padded = pad_sequence(sequences, batch_first=True, padding_value=0)
return padded, torch.stack(cibles), longueurs

loader = DataLoader(JeuSequences(X, y), batch_size=64, collate_fn=collate)

L'appel pack_padded_sequence(padded, longueurs, batch_first=True, enforce_sorted=False) à l'entrée de la LSTM évite tout calcul sur les pas remplis. C'est l'équivalent du masquage automatique de Keras.

Le piège des cibles décalées

Sur la traduction (module 8) comme sur la prévision multi-pas, l'entrée du décodeur et la cible sont décalées d'un cran. C'est souvent la source du premier bogue de prévision.

# CORRECT : entree decodeur = valeurs jusqu'a t-1, cible = valeurs de t
entree_dec = serie[t_debut : t_fin - 1]
cible = serie[t_debut + 1 : t_fin]

Le symptôme d'un décalage oublié est spectaculaire : le modèle prédit parfaitement en apprenant l'identité. Score presque nul, résultat inutile en production. Un print sur trois exemples avant d'entraîner suffit à détecter le décalage manquant.

sequence_stride=1 peut donner un jeu de test contaminé par le train

Avec un pas de 1, les fenêtres se recouvrent presque entièrement. Une fenêtre du test peut avoir 167 heures en commun avec une fenêtre du train juste à côté. Le modèle a « déjà vu » 167/168 = 99 % des données ; le score de test est artificiel. Deux remèdes : utiliser sequence_stride=LONG_CIBLE pour rendre les fenêtres indépendantes, ou intercaler une bande vide entre train et test (au moins de la taille de la fenêtre d'entrée).

Toujours imprimer un lot avant de lancer un entraînement

Avant fit, print(next(iter(jeu_train))) : forme des tenseurs, valeurs, présence de NaN, forme de la cible. Cinq secondes à écrire, elles détectent 90 % des bogues de préparation (mauvaise forme, décalage manquant, normalisation oubliée, mélange d'entiers et de flottants).

En résumé

  • Utiliser timeseries_dataset_from_array ou un équivalent pour fenêtrer sans dupliquer la série en mémoire, et toujours découper chronologiquement avant tout traitement.
  • La normalisation se calcule uniquement sur le train ; pour une série qui dérive, préférer une normalisation par fenêtre sur l'entrée, en reportant le même décalage sur la cible.
  • Le remplissage doit être accompagné d'un masquage (mask_zero=True ou layers.Masking) pour que les pas vides n'entrent ni dans l'état caché ni dans la perte.
  • Vérifier le décalage entre entrée et cible et la non-superposition des fenêtres entre train, validation et test évite les scores flatteurs qui ne survivent pas à la production.

Module suivant : rassembler tout ce qui précède dans un projet de prévision complet avec référence naïve, comparaison LSTM/GRU et intervalles par quantiles.