Aller au contenu principal

Module 3 — Rétropropagation dans le temps

Le module 2 a introduit le graphe déplié : TT copies chaînées de la même cellule. Ce module traite du calcul du gradient dans ce graphe, appelé rétropropagation dans le temps (BPTT). On y comprend pourquoi entraîner un récurrent coûte à la fois du calcul et de la mémoire proportionnels à TT, et pourquoi on triche presque toujours sur ce coût.

Ce que BPTT calcule

Rappelons l'équation de récurrence :

ht=tanh(Wxxt+Whht1+b)h_t = \tanh(W_x x_t + W_h h_{t-1} + b)

À la fin de la séquence, une perte LL est calculée à partir de la sortie finale (ou de toutes les sorties, selon le drapeau return_sequences). Pour mettre à jour Wx,Wh,bW_x, W_h, b, il faut la dérivée de LL par rapport à chacun.

Puisque les mêmes matrices apparaissent à chaque pas, la dérivée totale se décompose en une somme sur tous les pas. Pour WhW_h, par exemple :

LWh=t=1TLhTk=t+1Thkhk1htWh\frac{\partial L}{\partial W_h} = \sum_{t=1}^{T} \frac{\partial L}{\partial h_T} \cdot \prod_{k=t+1}^{T} \frac{\partial h_k}{\partial h_{k-1}} \cdot \frac{\partial h_t}{\partial W_h}

Chaque terme dépend d'un produit de jacobiennes hk/hk1\partial h_k / \partial h_{k-1} sur les pas qui séparent tt de TT. C'est cette structure en produit qui cause la disparition ou l'explosion des gradients, sujet du module 4.

BPTT complète : le coût réel

BPTT n'est rien d'autre que la rétropropagation classique appliquée au graphe déplié. Les cadriciels le font automatiquement dès que la propagation avant tourne dans un GradientTape (TensorFlow) ou un contexte autograd (PyTorch). Le prix se paie sur trois axes.

Mémoire. Pour la propagation arrière, il faut avoir gardé les activations de tous les pas : h1,h2,,hTh_1, h_2, \dots, h_T. La mémoire d'entraînement croît donc linéairement en TT. Une séquence de T=1000T = 1000 pas avec H=128H = 128 unités et un lot de B=64B = 64 occupe BTH=6410001288,2B \cdot T \cdot H = 64 \cdot 1000 \cdot 128 \approx 8{,}2 millions d'activations, plusieurs dizaines de mégaoctets par couche, à multiplier par les couches empilées.

Temps. Chaque pas exécute une multiplication matricielle. Un pas de plus, c'est une multiplication de plus, en avant et en arrière. Une séquence deux fois plus longue coûte deux fois plus.

Débit. Le calcul est strictement séquentiel : hth_t dépend de ht1h_{t-1}, on ne peut pas paralléliser la boucle temporelle. C'est ce qui rend les récurrents lents à entraîner sur accélérateur, et ce qui a motivé les transformeurs du cours 12.

BPTT tronquée : la triche indispensable

Sur des séquences très longues (texte de milliers de tokens, série d'un an à la minute), la BPTT complète devient impraticable. On la tronque : on choisit une longueur kk (typiquement 50 à 200 pas), on ne rétropropage que sur les kk derniers pas, et on considère le reste comme figé.

Trois recettes coexistent.

Découpage en fenêtres indépendantes. C'est ce que fait fenetrer du module 1 : chaque fenêtre est un exemple complet, et la rétropropagation traverse la fenêtre entière. C'est la solution la plus simple, celle par défaut pour la consommation électrique où une semaine (168 h) tient facilement en mémoire.

BPTT tronquée avec état conservé. On enchaîne des fenêtres successives d'une même séquence en gardant hTh_T d'une fenêtre comme h0h_0 de la suivante, mais sans laisser le gradient traverser cette liaison. En Keras, c'est le mode stateful=True combiné à un appel manuel à reset_states. Utile pour un texte plus long que la fenêtre mais dont on veut préserver le contexte.

BPTT tronquée avec chevauchement. On garde un chevauchement entre fenêtres pour ne pas casser les motifs à la frontière. Il coûte un peu plus de calcul mais évite qu'un motif utile ne soit systématiquement tronqué à l'endroit qui compte.

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

# stateful : on garde l'etat d'un lot au suivant
modele = keras.Sequential([
layers.Input(batch_shape=(32, 100, 1)), # taille de lot fixe pour stateful
layers.SimpleRNN(64, stateful=True, return_sequences=True),
layers.Dense(1),
])

for lot_entree, lot_cible in jeu: # fenetres successives d'une meme serie
modele.train_on_batch(lot_entree, lot_cible)

modele.reset_states() # a chaque nouvelle serie independante

Bien choisir kk

Le choix de la longueur de troncature est un équilibre.

Un kk trop petit empêche le modèle d'apprendre une dépendance longue. Sur la consommation électrique, tronquer à 24 heures interdit d'apprendre la saisonnalité hebdomadaire, qui exige au moins 168 pas.

Un kk trop grand rétablit tous les coûts qu'on cherchait à éviter, et peut même faire apparaître les problèmes de gradient du module 4 sur des longueurs où LSTM et GRU les évitent naturellement.

En pratique, on prend kk égal à un ou deux cycles complets du phénomène. Pour la consommation : 168 pas si l'on veut capter l'hebdomadaire, 24 si l'on se limite au quotidien.

Un vérificateur simple : compter les activations gardées

Il est facile de sous-estimer la mémoire prise par BPTT. Un ordre de grandeur utile : la mémoire d'activation d'une couche récurrente est BTH4B \cdot T \cdot H \cdot 4 octets pour float32. Il faut multiplier par le nombre de couches, et souvent doubler pour tenir compte des gradients intermédiaires.

def memoire_activations(B, T, H, n_couches=1, octets_par_val=4):
"""Retourne l'estimation en Mo."""
total = B * T * H * n_couches * octets_par_val
return total / (1024 ** 2)

print(memoire_activations(64, 168, 128, n_couches=2)) # ~11 Mo, largement viable
print(memoire_activations(64, 5000, 512, n_couches=4))) # ~2500 Mo, hors budget

Quand ce calcul dépasse la mémoire disponible, deux leviers agissent avant de tronquer : réduire BB (peut nuire à la stabilité) et réduire HH (peut nuire à la capacité). Tronquer TT est souvent la moins mauvaise option.

Un long entraînement lent n'est pas nécessairement un mauvais modèle

La lenteur d'un récurrent est structurelle : rien ne pourra le faire tourner en parallèle sur la dimension temps. Multiplier le nombre d'accélérateurs n'accélère pas un exemple, seulement le nombre d'exemples en parallèle. Si le temps par époque devient rédhibitoire, la vraie réponse est le changement de famille (LSTM au lieu d'un dense à 168 entrées, transformeur pour de très longues séquences), pas plus de matériel.

Vérifier la longueur effective de mémoire

Un moyen empirique de savoir jusqu'où votre modèle utilise réellement le passé : entraîner deux copies, l'une sur des fenêtres de 24 heures, l'autre sur 168 heures. Si les scores sont identiques, la mémoire longue n'est pas utile pour la tâche ; si le second est meilleur, elle l'est. Cela évite de payer 168 pas de BPTT pour rien.

En résumé

  • La BPTT est la rétropropagation classique appliquée au graphe déplié : elle décompose la dérivée en une somme de contributions par pas, chacune contenant un produit de jacobiennes.
  • Le coût est linéaire en TT en temps et en mémoire, et la boucle est strictement séquentielle : on ne parallélise pas sur le temps.
  • La BPTT tronquée limite ce coût en n'entraînant que sur les kk derniers pas ; le choix de kk doit couvrir au moins un cycle complet du phénomène.
  • Estimer la mémoire d'activation (BTHB \cdot T \cdot H multiplié par le nombre de couches) évite les explosions de RAM et guide le compromis entre taille de lot, dimension cachée et longueur de fenêtre.

Module suivant : le produit de jacobiennes qui apparaît dans BPTT est aussi ce qui rend les gradients instables ; on regarde pourquoi, et comment y répondre sans changer d'architecture.