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 trainAvec 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).
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_arrayou 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=Trueoulayers.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.