Module 7 — Réseaux bidirectionnels et empilés
Jusqu'ici, chaque cellule lit la séquence dans l'ordre chronologique et consomme une seule couche. Deux extensions apportent souvent le gain le plus franc sur les tâches classiques : lire dans les deux sens et empiler plusieurs couches. Chacune a un domaine d'usage précis, et l'une des deux est interdite dans la moitié des applications, celle du fil rouge.
Bidirectionnalité : quand le futur est déjà là
Un RNN bidirectionnel fait tourner deux cellules en parallèle sur la même séquence, l'une du début vers la fin, l'autre de la fin vers le début. À chaque pas , la sortie combine les deux états cachés : provenant du passé et provenant du futur. La combinaison par défaut est une concaténation, qui double la dimension de sortie.
L'usage typique est l'étiquetage de séquence où toute la séquence est disponible à l'inférence : reconnaissance vocale, étiquetage morphologique d'un texte, détection d'entités dans un document déjà écrit. Dans ces cas, le futur est réellement connu au moment de la prédiction : le mot suivant aide à désambiguïser le mot présent.
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
# Etiquetage : une classe par pas de temps
modele = keras.Sequential([
layers.Input(shape=(None, 128)), # sequence de vecteurs
layers.Bidirectional(layers.LSTM(64, return_sequences=True)),
layers.TimeDistributed(layers.Dense(NB_CLASSES, activation="softmax")),
])
Bidirectional accepte un argument merge_mode : concat (par défaut),
sum, mul, ave. La concaténation est presque toujours le bon choix : les
deux directions ne codent pas la même chose, et les fusionner en somme les
mélange.
Pourquoi la bidirectionnalité est interdite en prévision
Le fil rouge du cours ne l'autorise pas. Pour prévoir la consommation à 15 h à partir des heures passées, l'inférence ne connaît pas 16 h. Un modèle bidirectionnel entraîné sur le passé complet (fenêtre entière) apprend à s'appuyer sur les valeurs futures ; il donnera d'excellents scores en évaluation puis échouera en production, faute de futur à consommer.
C'est une fuite temporelle (module 1) déguisée en choix d'architecture,
d'autant plus insidieuse qu'elle ne saute pas aux yeux dans le code : rien
dans Bidirectional(LSTM(64)) ne dit « je vais utiliser le futur ». La
règle est simple et absolue : dans un contexte de prévision, jamais de
bidirectionnalité.
Empiler des couches
Empiler des cellules récurrentes est l'équivalent temporel de la profondeur en vision : les couches basses apprennent des motifs courts, les couches hautes des motifs plus abstraits sur des fenêtres plus larges.
Pour empiler, chaque couche intermédiaire doit exposer tous ses états
cachés au lieu du seul dernier, sinon la couche suivante n'a plus qu'un
vecteur de dimension à consommer. Le drapeau à activer est
return_sequences=True.
modele = keras.Sequential([
layers.Input(shape=(168, 1)),
layers.LSTM(64, return_sequences=True), # sortie (B, T, 64)
layers.LSTM(32, return_sequences=True), # sortie (B, T, 32)
layers.LSTM(16), # sortie (B, 16), dernier pas
layers.Dense(24),
])
Une erreur récurrente : oublier return_sequences=True sur une couche
intermédiaire. Keras lève alors une erreur de forme claire (LSTM requires 3D input), mais on peut passer une heure à chercher pourquoi. C'est
toujours ça.
Deux à trois couches suffisent en pratique. Au-delà, la disparition du
gradient réapparaît (les couches basses sont très profondes dans le
graphe déplié), et l'entraînement demande des astuces (layer normalization,
connexions résiduelles) qui alourdissent sans gain net.
Le dropout récurrent
Le dropout classique du cours 07 consiste à mettre à zéro aléatoirement une fraction des neurones à chaque itération. Appliqué naïvement à un RNN, il tue le gradient temporel : chaque pas reçoit un masque différent, ce qui équivaut à ajouter du bruit à chaque pas et empêche toute mémorisation longue.
Le dropout récurrent variationnel (Gal & Ghahramani, 2016) applique le
même masque à tous les pas de temps d'une même séquence, et un masque
différent d'une séquence à l'autre. La régularisation joue, la mémoire est
préservée. C'est ce qu'active l'argument recurrent_dropout de Keras.
modele = keras.Sequential([
layers.Input(shape=(168, 1)),
layers.LSTM(
64,
dropout=0.2, # dropout sur l'entree x_t (masque par pas ok)
recurrent_dropout=0.2, # dropout sur h_{t-1} (masque constant)
return_sequences=True,
),
layers.LSTM(32, dropout=0.2, recurrent_dropout=0.2),
layers.Dense(24),
])
Deux points d'attention. Le recurrent_dropout désactive l'implémentation
CUDA rapide (CuDNNLSTM) : le temps par époque peut être multiplié par 3
à 5. Le mettre à 0,1 ou 0,2 est presque toujours suffisant ; le pousser plus
haut ralentit encore sans grand gain de généralisation.
Combiner empilement et bidirectionnalité (pour un étiqueteur)
Sur une tâche où la bidirectionnalité est autorisée, on empile souvent plusieurs couches bidirectionnelles. C'est la recette de base d'un étiqueteur de séquence performant.
etiqueteur = keras.Sequential([
layers.Input(shape=(None, 128)),
layers.Bidirectional(layers.LSTM(64, return_sequences=True, dropout=0.2)),
layers.Bidirectional(layers.LSTM(32, return_sequences=True, dropout=0.2)),
layers.TimeDistributed(layers.Dense(NB_CLASSES, activation="softmax")),
])
Le nombre de paramètres double par couche par rapport à une version unidirectionnelle (deux cellules au lieu d'une), et la mémoire d'activation double aussi. À budget contraint, préférer une seule couche bidirectionnelle plus large à deux couches étroites.
Diagnostiquer un empilement qui n'apprend pas
Trois symptômes reviennent quand on empile trop ou mal.
La couche haute apprend, les couches basses ne bougent pas. Trace : les poids des LSTM du bas restent proches de leur initialisation à la fin. C'est un signal de disparition du gradient à travers l'empilement.
L'entraînement est stable les 5 premières époques puis diverge. Trace :
la norme du gradient explose brutalement. Souvent lié à un manque
d'écrêtage combiné à un empilement profond. Ajouter clipnorm=1.0.
La validation régresse alors que l'entraînement continue de descendre.
Trace : perte train qui chute, val qui remonte. Classique surapprentissage.
Ajouter du dropout récurrent, retirer une couche, ou augmenter la taille
du jeu.
Bidirectional sur un problème de prévisionSi vous voyez Bidirectional dans le code d'un modèle qui prédit un futur,
sortez la calculette avant les scores. Testez le modèle en supprimant les
dernières heures de la fenêtre d'entrée à l'inférence (X_test[:, :-24, :])
et regardez l'écart avec la version normale. Un modèle qui s'écroule sur
cette version révèle qu'il s'appuyait sur du futur. Le remède est simple :
enlever la bidirectionnalité, jamais bricoler.
Ajouter une couche, ajouter du dropout, ajouter la bidirectionnalité : faites une seule modification à la fois, mesurez, décidez. Une architecture qui empile trois nouveautés d'un coup est indéboguable en cas d'échec. Le module 10 impose cette discipline.
En résumé
- Un RNN bidirectionnel lit la séquence dans les deux sens et concatène les deux états cachés à chaque pas ; il est utile en étiquetage où toute la séquence est connue, et interdit en prévision car il constitue une fuite temporelle silencieuse.
- Empiler des couches demande
return_sequences=Truesur toutes les couches intermédiaires ; deux à trois couches suffisent, au-delà les gradients disparaissent à nouveau. - Le
recurrent_dropoututilise un masque constant à travers le temps et préserve la mémoire, mais désactive l'implémentation CUDA rapide ; 0,1 à 0,2 est le bon ordre de grandeur. - Toujours une modification à la fois (couche, dropout, direction) pour isoler l'effet de chaque changement.
Module suivant : quand la séquence d'entrée et la séquence de sortie n'ont pas la même longueur ni le même sens, l'architecture change de forme : c'est l'encodeur-décodeur.