Réseaux récurrents#

La mémoire est le journal intime que nous portons tous avec nous.

— Oscar Wilde, L’Importance d’être Constant

Les chapitres précédents ont présenté les réseaux de neurones feedforward et les réseaux convolutifs, conçus pour traiter des entrées de taille fixe. Or, de nombreuses données du monde réel sont séquentielles : le texte est une suite de mots, la parole une suite de trames acoustiques, une vidéo une suite d’images, un signal financier une suite de cotations. Dans ces données, l”ordre compte et des dépendances temporelles relient les observations entre elles. Ce chapitre introduit les réseaux de neurones récurrents (Recurrent Neural Networks, RNN), une famille d’architectures spécialement conçues pour modéliser ces séquences. Nous étudierons le RNN simple (Elman), ses limites liées au gradient évanescent, puis les architectures à portes — LSTM et GRU — qui ont permis aux réseaux récurrents de devenir l’outil dominant du traitement des séquences pendant une décennie, avant l’avènement des Transformers.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
torch.manual_seed(42)
np.random.seed(42)

Introduction : données séquentielles#

Pourquoi les réseaux feedforward ne suffisent pas#

Un réseau feedforward classique traite chaque entrée indépendamment des autres. Si l’on souhaite prédire le mot suivant dans une phrase, un réseau feedforward devrait recevoir un contexte de taille fixe — par exemple les cinq derniers mots — et ne pourrait exploiter un historique plus lointain qu’en augmentant la taille de sa fenêtre, ce qui fait exploser le nombre de paramètres. Plus fondamentalement, un tel réseau ne partage pas les connaissances apprises à une position de la séquence avec les autres positions.

Définition 242 (Séquence et dépendance temporelle)

Une séquence est une suite ordonnée d’observations \(\mathbf{x} = (x_1, x_2, \ldots, x_T)\)\(T\) est la longueur de la séquence. On parle de dépendance temporelle lorsque la distribution de \(x_t\) dépend des observations passées \(x_1, \ldots, x_{t-1}\) :

\[p(x_t \mid x_1, \ldots, x_{t-1}) \neq p(x_t)\]

Le défi central du traitement des séquences est de modéliser ces dépendances, y compris celles à longue portée (long-range dependencies).

Exemple 23 (Exemples de données séquentielles)

  • Texte : une phrase est une séquence de mots ou de caractères, où chaque mot dépend du contexte.

  • Séries temporelles : cours boursiers, données météorologiques, capteurs IoT.

  • Audio : un signal sonore est une séquence d’échantillons ou de trames spectrales (MFCC).

  • Vidéo : une séquence d’images (frames) ordonnées dans le temps.

  • ADN : une séquence de nucléotides (A, T, C, G).

Hide code cell source

# Illustration : série temporelle synthétique avec dépendances
t = np.linspace(0, 4 * np.pi, 200)
signal = np.sin(t) + 0.5 * np.sin(3 * t) + 0.3 * np.random.randn(len(t))

fig, axes = plt.subplots(2, 1, figsize=(9, 7))
axes[0].plot(t, signal, color='steelblue', linewidth=1.2)
axes[0].set_xlabel("Temps $t$"); axes[0].set_ylabel("$x(t)$")
axes[0].set_title("Série temporelle avec dépendances temporelles")

axes[1].scatter(signal[:-1], signal[1:], alpha=0.4, s=15, color='coral')
axes[1].set_xlabel("$x_t$"); axes[1].set_ylabel("$x_{t+1}$")
axes[1].set_title("Corrélation entre pas de temps consécutifs")
plt.tight_layout()
plt.show()
_images/3ef84a45d6228fe0b0eecbe36e8cd342d3a9063e93d71b205964771892b55168.png

L’idée fondatrice des RNN est d’introduire un état caché (hidden state) qui agit comme une mémoire interne, mise à jour à chaque pas de temps et transmise d’un instant au suivant.

RNN simple (Elman)#

Architecture récurrente#

Le réseau récurrent simple, proposé par Jeffrey Elman en 1990, introduit une connexion récurrente : l’état caché au temps \(t\) dépend à la fois de l’entrée courante et de l’état caché au temps précédent.

Définition 243 (Réseau récurrent simple (Elman))

Un RNN simple (ou cellule d’Elman) est défini par les équations suivantes, pour \(t = 1, \ldots, T\) :

\[h_t = \tanh(W_{xh}\, x_t + W_{hh}\, h_{t-1} + b_h)\]
\[\hat{y}_t = W_{hy}\, h_t + b_y\]

où :

  • \(x_t \in \mathbb{R}^d\) est l’entrée au temps \(t\),

  • \(h_t \in \mathbb{R}^n\) est l”état caché (hidden state) au temps \(t\),

  • \(W_{xh} \in \mathbb{R}^{n \times d}\), \(W_{hh} \in \mathbb{R}^{n \times n}\), \(W_{hy} \in \mathbb{R}^{m \times n}\) sont les matrices de poids,

  • \(b_h \in \mathbb{R}^n\), \(b_y \in \mathbb{R}^m\) sont les biais,

  • \(h_0\) est l’état initial, souvent fixé à \(\mathbf{0}\).

Le même jeu de poids \((W_{xh}, W_{hh}, W_{hy}, b_h, b_y)\) est partagé à travers tous les pas de temps : c’est le partage des poids temporel.

Remarque 205

Le partage des poids est crucial : il permet au réseau de traiter des séquences de longueur variable avec un nombre fixe de paramètres, et il force le réseau à apprendre des transformations invariantes par translation temporelle. C’est l’analogue temporel du partage des poids spatiaux dans les CNN.

Dépliement dans le temps (unrolling)#

Pour comprendre le fonctionnement d’un RNN, on le déplie dans le temps : on représente la cellule récurrente comme une chaîne de \(T\) copies identiques, chacune passant son état caché à la suivante. Le réseau déplié est un graphe de calcul acyclique sur lequel on peut appliquer la rétropropagation classique.

Hide code cell source

# Visualisation du dépliement d'un RNN
fig, ax = plt.subplots(figsize=(14, 4))
ax.set_xlim(-0.5, 7.5); ax.set_ylim(-0.5, 3.5)
ax.set_aspect('equal'); ax.axis('off')
ax.set_title("Dépliement d'un RNN dans le temps", fontsize=13, pad=15)

colors = {'cell': '#4C72B0', 'input': '#55A868', 'output': '#DD8452'}
for i, label in enumerate(['$t{-}1$', '$t$', '$t{+}1$']):
    cx = 1.5 + 2.5 * i
    rect = plt.Rectangle((cx - 0.5, 1.0), 1.0, 1.0, facecolor=colors['cell'],
                          edgecolor='white', alpha=0.85, linewidth=2)
    ax.add_patch(rect)
    ax.text(cx, 1.5, f'$h_{{{label[1:-1]}}}$', ha='center', va='center',
            fontsize=12, color='white', fontweight='bold')
    ax.annotate('', xy=(cx, 1.0), xytext=(cx, 0.0),
                arrowprops=dict(arrowstyle='->', color=colors['input'], lw=2))
    ax.text(cx, -0.2, f'$x_{{{label[1:-1]}}}$', ha='center', fontsize=11, color=colors['input'])
    ax.annotate('', xy=(cx, 3.0), xytext=(cx, 2.0),
                arrowprops=dict(arrowstyle='->', color=colors['output'], lw=2))
    ax.text(cx, 3.2, f'$\\hat{{y}}_{{{label[1:-1]}}}$', ha='center', fontsize=11, color=colors['output'])
    if i < 2:
        ax.annotate('', xy=(cx + 1.5, 1.5), xytext=(cx + 0.5, 1.5),
                    arrowprops=dict(arrowstyle='->', color='gray', lw=2))
ax.text(0.3, 1.5, '$h_0$', ha='center', fontsize=11, color='gray')
ax.annotate('', xy=(1.0, 1.5), xytext=(0.6, 1.5),
            arrowprops=dict(arrowstyle='->', color='gray', lw=1.5))
ax.text(7.2, 1.5, '$\\cdots$', ha='center', fontsize=14, color='gray')
plt.tight_layout()
plt.show()
_images/93cf7ce249ae965903c709b89943b8f4ac4cd7d0918505d550ee2eff13f63032.png

Implémentation d’une cellule RNN à la main#

Hide code cell source

class SimpleRNNCell:
    """Cellule RNN simple (Elman) implémentée manuellement avec NumPy."""
    def __init__(self, input_size, hidden_size):
        scale = 1.0 / np.sqrt(hidden_size)
        self.W_xh = np.random.randn(hidden_size, input_size) * scale
        self.W_hh = np.random.randn(hidden_size, hidden_size) * scale
        self.b_h = np.zeros(hidden_size)
        self.hidden_size = hidden_size

    def forward(self, x_seq, h0=None):
        """Forward pass sur une séquence x_seq de forme (T, input_size)."""
        T = x_seq.shape[0]
        h = h0 if h0 is not None else np.zeros(self.hidden_size)
        hiddens = []
        for t in range(T):
            h = np.tanh(self.W_xh @ x_seq[t] + self.W_hh @ h + self.b_h)
            hiddens.append(h)
        return np.array(hiddens)

rnn_cell = SimpleRNNCell(input_size=1, hidden_size=16)
x_demo = np.sin(np.linspace(0, 2 * np.pi, 50)).reshape(-1, 1)
h_demo = rnn_cell.forward(x_demo)

fig, axes = plt.subplots(2, 1, figsize=(9, 7))
axes[0].plot(x_demo, color='steelblue', linewidth=1.5)
axes[0].set_title("Entrée : signal sinusoïdal")
axes[0].set_xlabel("Pas de temps $t$"); axes[0].set_ylabel("$x_t$")
im = axes[1].imshow(h_demo.T, aspect='auto', cmap='RdBu_r', interpolation='nearest')
axes[1].set_title("État caché $h_t$ (16 neurones)")
axes[1].set_xlabel("Pas de temps $t$"); axes[1].set_ylabel("Neurone")
plt.colorbar(im, ax=axes[1], label="Activation")
plt.tight_layout()
plt.show()
_images/b163fd7826e0c54ad54797ba4648cc040328ab91731e2b3dfd7dff1d15d0ba8b.png

Backpropagation Through Time (BPTT)#

L’entraînement d’un RNN utilise une extension de la rétropropagation classique au graphe déplié dans le temps, appelée Backpropagation Through Time (BPTT).

Définition 244 (Backpropagation Through Time)

Soit \(\mathcal{L} = \sum_{t=1}^T \mathcal{L}_t\) la perte totale sur une séquence. Le gradient par rapport à \(W_{hh}\) s’écrit, par application de la règle de chaîne au graphe déplié :

\[\frac{\partial \mathcal{L}}{\partial W_{hh}} = \sum_{t=1}^T \sum_{k=1}^{t} \frac{\partial \mathcal{L}_t}{\partial h_t} \left(\prod_{j=k+1}^{t} \frac{\partial h_j}{\partial h_{j-1}}\right) \frac{\partial h_k}{\partial W_{hh}}\]

Le terme \(\prod_{j=k+1}^{t} \frac{\partial h_j}{\partial h_{j-1}}\) est un produit de matrices jacobiennes qui détermine comment le gradient se propage sur \(t - k\) pas de temps.

Problème du gradient évanescent et explosif#

Le produit de matrices jacobiennes dans la formule BPTT est la source d’une instabilité fondamentale des RNN.

Proposition 61 (Gradient évanescent et explosif)

Soit \(\frac{\partial h_j}{\partial h_{j-1}} = \text{diag}(\tanh'(z_j)) \cdot W_{hh}\) la jacobienne de la transition d’état. Le produit sur \(t - k\) pas de temps satisfait :

\[\left\|\prod_{j=k+1}^{t} \frac{\partial h_j}{\partial h_{j-1}}\right\| \leq \left(\|W_{hh}\| \cdot \gamma\right)^{t-k}\]

\(\gamma = \max |\tanh'(z)| \leq 1\). Si la plus grande valeur singulière \(\sigma_{\max}(W_{hh}) < 1/\gamma\), les gradients décroissent exponentiellement avec la distance \(t - k\) (vanishing gradient). Si \(\sigma_{\max}(W_{hh}) > 1/\gamma\), ils croissent exponentiellement (exploding gradient).

Proof. Par sous-multiplicativité de la norme matricielle, \(\left\|\prod_{j=k+1}^t A_j\right\| \leq \prod_{j=k+1}^t \|A_j\|\). Or \(\|A_j\| = \|\text{diag}(\tanh'(z_j)) \cdot W_{hh}\| \leq \gamma \cdot \|W_{hh}\|\), d’où le résultat. Le cas d’égalité dans la borne montre que la croissance (ou décroissance) est bien exponentielle en \(t - k\).

Remarque 206

En pratique, le gradient évanescent est le problème le plus courant : il empêche le réseau d’apprendre les dépendances à longue portée. Le gradient explosif, lui, est plus facilement détectable (les valeurs de perte divergent) et peut être traité par le gradient clipping.

Gradient clipping#

Définition 245 (Gradient clipping)

Le gradient clipping consiste à borner la norme du gradient avant la mise à jour des poids :

\[\begin{split}\tilde{g} = \begin{cases} g & \text{si } \|g\| \leq \theta \\ \theta \cdot \frac{g}{\|g\|} & \text{sinon} \end{cases}\end{split}\]

\(\theta > 0\) est le seuil de clipping. Cette opération préserve la direction du gradient tout en limitant son amplitude. En PyTorch : torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=theta).

Hide code cell source

# Illustration du gradient évanescent
seq_lengths = [5, 10, 20, 50, 100]
hidden_size = 64
gradient_norms = {}
for T in seq_lengths:
    norms = []
    for _ in range(200):
        W = np.random.randn(hidden_size, hidden_size) * (0.9 / np.sqrt(hidden_size))
        product = np.eye(hidden_size)
        for _ in range(T):
            diag = np.random.uniform(0, 1, hidden_size)
            product = np.diag(diag) @ W @ product
        norms.append(np.linalg.norm(product))
    gradient_norms[T] = norms

fig, ax = plt.subplots(figsize=(10, 5))
bp = ax.boxplot([gradient_norms[T] for T in seq_lengths],
                tick_labels=[str(T) for T in seq_lengths],
                patch_artist=True, showfliers=False)
for patch in bp['boxes']:
    patch.set_facecolor('steelblue'); patch.set_alpha(0.6)
ax.set_yscale('log')
ax.set_xlabel("Longueur de la séquence $T$")
ax.set_ylabel("Norme du produit de jacobiennes (log)")
ax.set_title("Décroissance exponentielle du gradient dans un RNN simple")
plt.tight_layout()
plt.show()
_images/aa321cd937526bd333b5df858b3de084e0be2a0d2cf380bf5286a46e24b56677.png

Le gradient évanescent est un problème structurel du RNN simple : il limite sa capacité à apprendre des dépendances au-delà d’une dizaine de pas de temps. Les architectures à portes — LSTM et GRU — ont été conçues précisément pour résoudre ce problème.

LSTM (Long Short-Term Memory)#

L’architecture LSTM, proposée par Hochreiter et Schmidhuber en 1997, est la réponse la plus influente au problème du gradient évanescent. L’idée clé est d’introduire un état de cellule (cell state) \(c_t\) qui traverse la séquence de manière quasi-linéaire, protégé par un système de portes (gates) qui contrôlent le flux d’information.

Définition 246 (Cellule LSTM)

Une cellule LSTM est définie par les équations suivantes, pour \(t = 1, \ldots, T\) :

Porte d’oubli (forget gate) :

\[f_t = \sigma(W_f\, [h_{t-1}, x_t] + b_f)\]

Porte d’entrée (input gate) :

\[i_t = \sigma(W_i\, [h_{t-1}, x_t] + b_i)\]

Candidat pour l’état de cellule :

\[\tilde{c}_t = \tanh(W_c\, [h_{t-1}, x_t] + b_c)\]

Mise à jour de l’état de cellule :

\[c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t\]

Porte de sortie (output gate) :

\[o_t = \sigma(W_o\, [h_{t-1}, x_t] + b_o)\]

État caché :

\[h_t = o_t \odot \tanh(c_t)\]

\(\sigma\) est la fonction sigmoïde, \(\odot\) le produit de Hadamard (élément par élément), et \([h_{t-1}, x_t]\) la concaténation des vecteurs.

Remarque 207

L’intuition derrière les portes est la suivante :

  • La porte d’oubli \(f_t \in [0, 1]^n\) décide quelle partie de la mémoire précédente \(c_{t-1}\) conserver. Une valeur proche de 1 signifie « se souvenir » ; proche de 0, « oublier ».

  • La porte d’entrée \(i_t\) décide quelles nouvelles informations écrire dans la mémoire.

  • La porte de sortie \(o_t\) décide quelle partie de la mémoire exposer comme état caché.

L’état de cellule \(c_t\) agit comme un tapis roulant (conveyor belt) : l’information peut y circuler sur de longues distances avec seulement des opérations additives et multiplicatives, ce qui atténue le gradient évanescent.

Pourquoi le LSTM résout le gradient évanescent#

Proposition 62 (Flux de gradient dans le LSTM)

Le gradient de la perte par rapport à l’état de cellule \(c_k\) (avec \(k < t\)) satisfait :

\[\frac{\partial c_t}{\partial c_k} = \prod_{j=k+1}^{t} \frac{\partial c_j}{\partial c_{j-1}} = \prod_{j=k+1}^{t} \left(f_j + \text{termes dépendant de } c_{j-1}\right)\]

Lorsque les portes d’oubli \(f_j\) sont proches de 1, le gradient se propage presque sans atténuation le long de l’état de cellule. C’est la différence structurelle avec le RNN simple : le chemin de gradient passe par des opérations additives plutôt que par des produits de matrices.

Hide code cell source

# Visualisation du comportement des portes d'un LSTM
T_demo, hidden_size_demo = 80, 32
lstm_demo = nn.LSTM(input_size=1, hidden_size=hidden_size_demo, batch_first=True)

x_input = torch.zeros(1, T_demo, 1)
x_input[0, 10:20, 0] = 1.0    # impulsion 1
x_input[0, 50:60, 0] = -1.0   # impulsion 2

with torch.no_grad():
    output, _ = lstm_demo(x_input)
    # Calcul manuel des portes pour la visualisation
    h_t, c_t = torch.zeros(1, hidden_size_demo), torch.zeros(1, hidden_size_demo)
    forget_gates, input_gates, output_gates, cell_states = [], [], [], []
    W_ii, W_if, W_ig, W_io = lstm_demo.weight_ih_l0.chunk(4, 0)
    W_hi, W_hf, W_hg, W_ho = lstm_demo.weight_hh_l0.chunk(4, 0)
    b_ii, b_if, b_ig, b_io = lstm_demo.bias_ih_l0.chunk(4, 0)
    b_hi, b_hf, b_hg, b_ho = lstm_demo.bias_hh_l0.chunk(4, 0)
    for t_step in range(T_demo):
        x_t = x_input[0, t_step:t_step+1, :]
        i_g = torch.sigmoid(x_t @ W_ii.T + h_t @ W_hi.T + b_ii + b_hi)
        f_g = torch.sigmoid(x_t @ W_if.T + h_t @ W_hf.T + b_if + b_hf)
        g_g = torch.tanh(x_t @ W_ig.T + h_t @ W_hg.T + b_ig + b_hg)
        o_g = torch.sigmoid(x_t @ W_io.T + h_t @ W_ho.T + b_io + b_ho)
        c_t = f_g * c_t + i_g * g_g
        h_t = o_g * torch.tanh(c_t)
        forget_gates.append(f_g.mean().item())
        input_gates.append(i_g.mean().item())
        output_gates.append(o_g.mean().item())
        cell_states.append(c_t.mean().item())

fig, axes = plt.subplots(3, 1, figsize=(14, 8), sharex=True)
time_ax = np.arange(T_demo)
axes[0].plot(time_ax, x_input[0, :, 0].numpy(), color='black', linewidth=1.5)
axes[0].set_ylabel("Entrée $x_t$")
axes[0].set_title("Comportement d'une cellule LSTM face à des impulsions")
axes[1].plot(time_ax, forget_gates, color='#E24A33', label='Forget $f_t$')
axes[1].plot(time_ax, input_gates, color='#348ABD', label='Input $i_t$')
axes[1].plot(time_ax, output_gates, color='#55A868', label='Output $o_t$')
axes[1].set_ylabel("Activation"); axes[1].legend(loc='upper right', fontsize=9)
axes[2].plot(time_ax, cell_states, color='#8B6DAF', linewidth=1.5)
axes[2].set_ylabel("$\\bar{c}_t$ (moyenne)"); axes[2].set_xlabel("Pas de temps $t$")
plt.tight_layout()
plt.show()
_images/e12989ae9e61946a21344b606d35c5acebff9f866c366faa51606a90e4d92187.png

GRU (Gated Recurrent Unit)#

Le GRU, proposé par Cho et al. en 2014, est une simplification du LSTM qui fusionne l’état caché et l’état de cellule en un seul vecteur, et réduit le nombre de portes de trois à deux.

Définition 247 (Cellule GRU)

Une cellule GRU (Gated Recurrent Unit) est définie par :

Porte de réinitialisation (reset gate) :

\[r_t = \sigma(W_r\, [h_{t-1}, x_t] + b_r)\]

Porte de mise à jour (update gate) :

\[z_t = \sigma(W_z\, [h_{t-1}, x_t] + b_z)\]

Candidat pour l’état caché :

\[\tilde{h}_t = \tanh(W_h\, [r_t \odot h_{t-1}, x_t] + b_h)\]

Mise à jour de l’état caché :

\[h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t\]

La porte de mise à jour \(z_t\) joue simultanément le rôle des portes d’oubli et d’entrée du LSTM. La porte de réinitialisation \(r_t\) contrôle la quantité d’état passé intégrée dans le candidat.

Remarque 208

La mise à jour \(h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t\) est une interpolation linéaire entre l’état précédent et le candidat. Quand \(z_t \approx 0\), l’état est simplement copié (mémoire longue) ; quand \(z_t \approx 1\), l’état est entièrement remplacé.

Comparaison LSTM vs GRU#

Proposition 63 (Comparaison LSTM et GRU)

Pour une taille d’état caché \(n\) et une taille d’entrée \(d\) :

LSTM

GRU

Portes

3 (forget, input, output)

2 (reset, update)

États

\(h_t\) et \(c_t\)

\(h_t\) uniquement

Paramètres

\(4n(n + d) + 4n\)

\(3n(n + d) + 3n\)

Ratio paramètres

\(\approx 1.33\times\) GRU

Référence

En termes de performances, il n’y a pas de gagnant systématique : le LSTM est souvent légèrement meilleur sur les tâches nécessitant une mémoire très longue, tandis que le GRU est plus rapide à entraîner et peut être préféré lorsque les données sont limitées. Le choix se fait en pratique par validation croisée.

Hide code cell source

# Comparaison du nombre de paramètres
def count_params(model):
    return sum(p.numel() for p in model.parameters())

input_sizes = [16, 32, 64, 128]
hidden = 64
rnn_params = [count_params(nn.RNN(d, hidden)) for d in input_sizes]
lstm_params = [count_params(nn.LSTM(d, hidden)) for d in input_sizes]
gru_params = [count_params(nn.GRU(d, hidden)) for d in input_sizes]

fig, ax = plt.subplots(figsize=(9, 5))
x = np.arange(len(input_sizes)); width = 0.25
ax.bar(x - width, rnn_params, width, label='RNN', color='#4C72B0', alpha=0.85)
ax.bar(x, lstm_params, width, label='LSTM', color='#DD8452', alpha=0.85)
ax.bar(x + width, gru_params, width, label='GRU', color='#55A868', alpha=0.85)
ax.set_xlabel("Taille d'entrée $d$"); ax.set_ylabel("Nombre de paramètres")
ax.set_title(f"Nombre de paramètres (hidden_size = {hidden})")
ax.set_xticks(x); ax.set_xticklabels(input_sizes); ax.legend()
plt.tight_layout()
plt.show()
_images/11dfb0beea8c977373c15ae8d69fc39513a84437a5f859d8fdcdc7aa15c16c6d.png

Architectures récurrentes#

Les cellules RNN, LSTM et GRU sont des briques de base. On les assemble en différentes architectures selon la nature de la tâche.

Many-to-one : classification de séquences#

Définition 248 (Architecture many-to-one)

Dans une architecture many-to-one, le réseau reçoit une séquence \((x_1, \ldots, x_T)\) et produit une unique sortie \(\hat{y}\) à partir du dernier état caché \(h_T\) (ou d’un pooling sur tous les états) :

\[\hat{y} = \text{softmax}(W_y\, h_T + b_y)\]

Applications typiques : analyse de sentiment, classification de documents, détection de genre musical.

Many-to-many : modèles séquence-à-séquence#

Définition 249 (Architecture many-to-many (seq2seq))

Une architecture séquence-à-séquence (sequence-to-sequence, Sutskever et al., 2014) utilise :

  1. Un encodeur RNN qui lit la séquence d’entrée et produit un vecteur de contexte \(c = h_T^{\text{enc}}\).

  2. Un décodeur RNN qui génère la séquence de sortie pas à pas, initialisé par \(c\).

Cette architecture est adaptée à la traduction automatique, au résumé de texte, et au dialogue.

RNN bidirectionnel#

Définition 250 (RNN bidirectionnel)

Un RNN bidirectionnel combine deux RNN : l’un parcourt la séquence de gauche à droite (\(\overrightarrow{h}_t\)), l’autre de droite à gauche (\(\overleftarrow{h}_t\)). La représentation au temps \(t\) est la concaténation :

\[h_t = [\overrightarrow{h}_t \,;\, \overleftarrow{h}_t] \in \mathbb{R}^{2n}\]

Cela permet à chaque position d’accéder au contexte passé et futur, ce qui est utile pour l’étiquetage de séquences (NER, POS tagging).

RNN empilés (stacked / deep RNN)#

Définition 251 (RNN empilé)

Un RNN empilé à \(L\) couches utilise la sortie \(h_t^{(\ell)}\) de la couche \(\ell\) comme entrée de la couche \(\ell + 1\) :

\[h_t^{(\ell)} = f_\ell(h_{t-1}^{(\ell)}, h_t^{(\ell-1)})\]

avec \(h_t^{(0)} = x_t\). En pratique, on utilise 2 à 4 couches ; au-delà, l’ajout de couches apporte peu de gain et augmente le coût d’entraînement.

Hide code cell source

# Illustration des architectures avec PyTorch
print("=== LSTM bidirectionnel à 2 couches ===")
bi_lstm = nn.LSTM(input_size=10, hidden_size=32, num_layers=2,
                  batch_first=True, bidirectional=True)
x_test = torch.randn(4, 20, 10)  # batch=4, seq_len=20, features=10
output, (h_n, c_n) = bi_lstm(x_test)
print(f"Entrée  : {x_test.shape}")
print(f"Sortie  : {output.shape}  (batch, seq_len, 2 × hidden)")
print(f"h_n     : {h_n.shape}     (num_layers × 2, batch, hidden)")
print(f"Params  : {count_params(bi_lstm):,}")

print("\n=== GRU 3 couches ===")
deep_gru = nn.GRU(input_size=10, hidden_size=32, num_layers=3,
                  batch_first=True, dropout=0.2)
output_gru, h_gru = deep_gru(x_test)
print(f"Sortie  : {output_gru.shape}")
print(f"h_n     : {h_gru.shape}")
print(f"Params  : {count_params(deep_gru):,}")
=== LSTM bidirectionnel à 2 couches ===
Entrée  : torch.Size([4, 20, 10])
Sortie  : torch.Size([4, 20, 64])  (batch, seq_len, 2 × hidden)
h_n     : torch.Size([4, 4, 32])     (num_layers × 2, batch, hidden)
Params  : 36,352

=== GRU 3 couches ===
Sortie  : torch.Size([4, 20, 32])
h_n     : torch.Size([3, 4, 32])
Params  : 16,896

Implémentation PyTorch#

PyTorch fournit des modules optimisés pour les réseaux récurrents. Cette section présente les aspects pratiques de leur utilisation.

Remarque 209

Les modules nn.RNN, nn.LSTM et nn.GRU partagent la même interface :

  • Entrée : (seq_len, batch, input_size) par défaut, ou (batch, seq_len, input_size) avec batch_first=True.

  • Sortie : le tenseur de tous les états cachés et le(s) dernier(s) état(s) caché(s).

  • Paramètres importants : num_layers (profondeur), bidirectional, dropout (entre couches).

Le choix de batch_first=True est recommandé pour la lisibilité.

Gestion de séquences de longueurs variables#

Dans un batch, les séquences ont souvent des longueurs différentes. PyTorch fournit pack_padded_sequence et pad_packed_sequence pour éviter les calculs inutiles sur le padding.

Hide code cell source

from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

# Batch de 3 séquences de longueurs différentes
sequences = [torch.randn(8, 5), torch.randn(5, 5), torch.randn(3, 5)]
lengths = torch.tensor([8, 5, 3])

padded = nn.utils.rnn.pad_sequence(sequences, batch_first=True)
print(f"Tenseur paddé : {padded.shape}")

packed = pack_padded_sequence(padded, lengths, batch_first=True, enforce_sorted=True)
print(f"PackedSequence : data={packed.data.shape}, batch_sizes={packed.batch_sizes}")

lstm_packed = nn.LSTM(input_size=5, hidden_size=16, batch_first=True)
output_packed, (h_packed, c_packed) = lstm_packed(packed)
output_unpacked, lens_out = pad_packed_sequence(output_packed, batch_first=True)
print(f"Sortie dépaddée : {output_unpacked.shape}, longueurs : {lens_out}")
Tenseur paddé : torch.Size([3, 8, 5])
PackedSequence : data=torch.Size([16, 5]), batch_sizes=tensor([3, 3, 3, 2, 2, 1, 1, 1])
Sortie dépaddée : torch.Size([3, 8, 16]), longueurs : tensor([8, 5, 3])

Exemple complet : modèle de langage caractère par caractère#

Nous allons entraîner un modèle de langage récurrent au niveau des caractères : le réseau apprend à prédire le caractère suivant étant donné les caractères précédents. Ce type de modèle illustre parfaitement le fonctionnement des RNN et permet de générer du texte de manière autogressive.

Préparation des données#

Hide code cell source

text = """Le monde est un livre et ceux qui ne voyagent pas n en lisent qu une page.
La vie est un sommeil l amour en est le reve et vous aurez vecu si vous avez aime.
Il n y a qu un bonheur dans la vie c est d aimer et d etre aime.
La simplicite est la sophistication supreme.
L imagination est plus importante que le savoir.
La folie c est de faire toujours la meme chose et de s attendre a un resultat different.
Rien dans la vie n est a craindre tout est a comprendre.
Le seul vrai voyage ce n est pas d aller vers de nouveaux paysages mais d avoir de nouveaux yeux.
La beaute sauvera le monde.
Ce qui ne me tue pas me rend plus fort."""

chars = sorted(set(text))
char_to_idx = {c: i for i, c in enumerate(chars)}
idx_to_char = {i: c for c, i in char_to_idx.items()}
vocab_size = len(chars)
print(f"Vocabulaire : {vocab_size} caractères | Longueur : {len(text)}")

encoded = torch.tensor([char_to_idx[c] for c in text], dtype=torch.long)
Vocabulaire : 31 caractères | Longueur : 628

Modèle et entraînement#

Hide code cell source

class CharRNN(nn.Module):
    """Modèle de langage caractère par caractère basé sur un LSTM."""
    def __init__(self, vocab_size, embed_size, hidden_size, num_layers=1):
        super().__init__()
        self.hidden_size, self.num_layers = hidden_size, num_layers
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True)
        self.fc = nn.Linear(hidden_size, vocab_size)

    def forward(self, x, hidden=None):
        emb = self.embedding(x)
        out, hidden = self.lstm(emb, hidden)
        return self.fc(out), hidden

    def init_hidden(self, batch_size, device='cpu'):
        return (torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device),
                torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device))

model = CharRNN(vocab_size, embed_size=32, hidden_size=128, num_layers=2)
print(f"Paramètres : {count_params(model):,}")
Paramètres : 220,031

Hide code cell source

seq_length, batch_size = 40, 16

def create_batches(data, seq_length, batch_size):
    """Crée des mini-batches de séquences contiguës."""
    n_batches = (len(data) - 1) // (seq_length * batch_size)
    data = data[:n_batches * seq_length * batch_size + 1]
    x_data, y_data = data[:-1].view(batch_size, -1), data[1:].view(batch_size, -1)
    for i in range(0, x_data.size(1) - seq_length, seq_length):
        yield x_data[:, i:i+seq_length], y_data[:, i:i+seq_length]

optimizer = optim.Adam(model.parameters(), lr=0.003)
criterion = nn.CrossEntropyLoss()
losses = []

for epoch in range(100):
    model.train()
    hidden = model.init_hidden(batch_size)
    epoch_loss, n_b = 0, 0
    for x_batch, y_batch in create_batches(encoded, seq_length, batch_size):
        if x_batch.size(0) != batch_size:
            continue
        hidden = tuple(h.detach() for h in hidden)
        logits, hidden = model(x_batch, hidden)
        loss = criterion(logits.reshape(-1, vocab_size), y_batch.reshape(-1))
        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
        optimizer.step()
        epoch_loss += loss.item(); n_b += 1
    if n_b > 0:
        losses.append(epoch_loss / n_b)
    if (epoch + 1) % 25 == 0 and losses:
        print(f"Époque {epoch+1:3d}/100 | Perte : {losses[-1]:.4f}")

fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(losses, color='steelblue', linewidth=1.2)
ax.set_xlabel("Époque"); ax.set_ylabel("Perte (cross-entropy)")
ax.set_title("Entraînement du modèle de langage CharRNN")
plt.tight_layout()
plt.show()
_images/9a44c6dabe13d94ee61a4d90492c16723fc81c2189c58ec63fe56157669181c4.png

Génération de texte#

Une fois entraîné, le modèle peut générer du texte de manière autogressive : on lui donne un caractère de départ, il prédit le suivant, puis on utilise cette prédiction comme nouvelle entrée, et ainsi de suite.

Hide code cell source

def generate_text(model, start_str, length=200, temperature=0.8):
    """Génère du texte caractère par caractère."""
    model.eval()
    result = list(start_str)
    input_seq = torch.tensor([[char_to_idx[c] for c in start_str]], dtype=torch.long)
    hidden = model.init_hidden(1)
    with torch.no_grad():
        logits, hidden = model(input_seq, hidden)
        for _ in range(length):
            probs = F.softmax(logits[0, -1, :] / temperature, dim=0)
            next_idx = torch.multinomial(probs, 1).item()
            result.append(idx_to_char[next_idx])
            input_seq = torch.tensor([[next_idx]], dtype=torch.long)
            logits, hidden = model(input_seq, hidden)
    return ''.join(result)

for temp in [0.5, 0.8, 1.2]:
    print(f"\n--- Température = {temp} ---")
    print(generate_text(model, "La ", length=120, temperature=temp))
--- Température = 0.5 ---
La nvIjnoltC
btIb jIbjl ozqiuf xIb extqgxahcrxRICqfrIac
iqcdjunvCtuxcqzt RpzzqsRursrjC.cyz.m.Csixh p
tofidi dthygLyaRlCsbt.

--- Température = 0.8 ---
La fLjdxnthcdn IxIy  prnrvjzvajhfhifelCf  gvjvm b.lzldo
d eocn iegtaj.vy
 niilsl d.cLupgqCRg dy
phxaltIevvIitloaudCmjsjefy.

--- Température = 1.2 ---
La tdu
 qi yuLddzfdCComiopbzczf
cmetyh adqzfeoeo.y.cqpiqghgnshvqogejL zpcfIrytofenirbb
IutR
ptdItlxb.geqqRpvxI aRha .CfuLgy

Remarque 210

La température \(\tau\) contrôle la diversité de la génération. Avant l’échantillonnage, les logits sont divisés par \(\tau\) : une valeur \(\tau < 1\) produit une génération conservatrice et répétitive, \(\tau = 1\) donne la distribution originale, et \(\tau > 1\) favorise la diversité au prix de la cohérence. C’est un compromis classique entre qualité et diversité.

Comparaison RNN vs LSTM vs GRU sur une tâche de mémoire#

Pour illustrer concrètement l’avantage des architectures à portes, comparons les trois types de cellules sur une tâche de mémoire à long terme : le réseau doit se souvenir d’un signal donné en début de séquence pour prédire une cible en fin de séquence.

Hide code cell source

def create_memory_task(n_samples, seq_len):
    """Le signal (0 ou 1) est donné au pas 0, la cible est ce signal au dernier pas."""
    X = torch.zeros(n_samples, seq_len, 1)
    y = torch.randint(0, 2, (n_samples,))
    X[:, 0, 0] = y.float()
    return X, y

delays = [10, 30, 50]
results = {name: [] for name in ['RNN', 'LSTM', 'GRU']}

for delay in delays:
    for name, rnn_cls in [('RNN', nn.RNN), ('LSTM', nn.LSTM), ('GRU', nn.GRU)]:
        class MemModel(nn.Module):
            def __init__(self):
                super().__init__()
                self.rnn = rnn_cls(1, 32, batch_first=True)
                self.fc = nn.Linear(32, 2)
            def forward(self, x):
                out, _ = self.rnn(x)
                return self.fc(out[:, -1, :])

        m = MemModel()
        opt = optim.Adam(m.parameters(), lr=0.005)
        X_mem, y_mem = create_memory_task(200, delay)
        best_acc = 0.5
        for ep in range(50):
            m.train()
            loss = F.cross_entropy(m(X_mem[:160]), y_mem[:160])
            opt.zero_grad(); loss.backward(); opt.step()
            m.eval()
            with torch.no_grad():
                acc = (m(X_mem[160:]).argmax(1) == y_mem[160:]).float().mean().item()
                best_acc = max(best_acc, acc)
        results[name].append(best_acc)
    print(f"  delay={delay} done")

fig, ax = plt.subplots(figsize=(9, 5))
markers = {'RNN': 'o-', 'LSTM': 's-', 'GRU': '^-'}
colors_cmp = {'RNN': '#4C72B0', 'LSTM': '#DD8452', 'GRU': '#55A868'}
for name in ['RNN', 'LSTM', 'GRU']:
    ax.plot(delays, results[name], markers[name], label=name,
            color=colors_cmp[name], markersize=8, linewidth=2)
ax.axhline(y=0.5, color='gray', linestyle='--', alpha=0.5, label='Hasard')
ax.set_xlabel("Longueur de la séquence (délai)")
ax.set_ylabel("Précision (test)")
ax.set_title("Tâche de mémoire à long terme : RNN vs LSTM vs GRU")
ax.legend(); ax.set_ylim(0.4, 1.05)
plt.tight_layout()
plt.show()
  delay=10 done
  delay=30 done
  delay=50 done
_images/b794d74330c8c0936caf61babe70ff4f68554c99c30d2861807784def4255a7c.png

Remarque 211

Cette expérience illustre le problème du gradient évanescent de manière pratique : le RNN simple échoue rapidement à retenir l’information lorsque le délai augmente, tandis que le LSTM et le GRU maintiennent une bonne performance grâce à leurs mécanismes de portes. L’état de cellule du LSTM (ou l’interpolation linéaire du GRU) permet un flux de gradient quasi-ininterrompu le long de la séquence.

Limitations et perspectives#

Malgré leur succès historique, les RNN — y compris LSTM et GRU — présentent des limitations importantes qui ont motivé le développement d’architectures alternatives.

Dépendances très longues#

Remarque 212

Même avec le LSTM, les dépendances au-delà de quelques centaines de pas de temps restent difficiles à capturer. L’information dans l’état de cellule se dégrade progressivement malgré les portes, car le réseau doit prendre des décisions binaires (oublier ou retenir) à chaque pas de temps, et les erreurs d’oubli sont cumulatives.

Complexité séquentielle#

Proposition 64 (Complexité temporelle des RNN)

Le calcul de la séquence des états cachés \((h_1, h_2, \ldots, h_T)\) est intrinsèquement séquentiel : \(h_t\) dépend de \(h_{t-1}\). La complexité temporelle est donc \(O(T)\) et ne peut pas être parallélisée sur la dimension temporelle. Cela constitue un goulot d’étranglement majeur lors de l’entraînement sur des séquences longues, comparé aux architectures entièrement parallélisables.

Vers les mécanismes d’attention et les Transformers#

Le mécanisme d’attention (Bahdanau et al., 2015) a d’abord été proposé comme complément aux RNN seq2seq : au lieu de compresser toute l’entrée dans un unique vecteur de contexte, le décodeur peut « regarder » directement les états cachés de l’encodeur à chaque pas de temps, pondérés par des scores de pertinence.

L’architecture Transformer (Vaswani et al., 2017) pousse cette idée à son terme : elle abandonne entièrement la récurrence au profit de l”auto-attention (self-attention), permettant à chaque position de la séquence d’interagir directement avec toutes les autres. Les Transformers résolvent simultanément les deux limitations des RNN :

  1. Les dépendances à longue portée sont capturées en un seul pas (le chemin de gradient entre deux positions quelconques est de longueur \(O(1)\)).

  2. Le calcul est entièrement parallélisable sur la dimension temporelle.

Nous étudierons en détail les mécanismes d’attention et l’architecture Transformer au chapitre 23.

Hide code cell source

# Chronologie des architectures pour les séquences
fig, ax = plt.subplots(figsize=(14, 3))
ax.set_xlim(1988, 2024); ax.set_ylim(-1, 2)
ax.axis('off')
ax.set_title("Chronologie des architectures pour les données séquentielles", fontsize=12, pad=10)

events = [
    (1990, "RNN\n(Elman)", '#4C72B0'),
    (1997, "LSTM\n(Hochreiter &\nSchmidhuber)", '#DD8452'),
    (2014, "GRU / Seq2Seq\n(Cho / Sutskever)", '#55A868'),
    (2015, "Attention\n(Bahdanau)", '#8B6DAF'),
    (2017, "Transformer\n(Vaswani et al.)", '#E24A33'),
]
ax.axhline(y=0, color='gray', linewidth=2, alpha=0.3, xmin=0.02, xmax=0.98)
for year, label, color in events:
    ax.plot(year, 0, 'o', color=color, markersize=12, zorder=5)
    ax.annotate(f"{year}\n{label}", xy=(year, 0), xytext=(year, 0.5),
                fontsize=8, ha='center', va='bottom', color=color,
                arrowprops=dict(arrowstyle='-', color=color, alpha=0.5))
plt.tight_layout()
plt.show()
_images/598a33d485f60abf899f37c79af036887e8c3c72ea621fe8f0e13536ad308866.png

Résumé#

Ce chapitre a présenté les réseaux de neurones récurrents, la famille d’architectures conçue pour traiter les données séquentielles.

  1. Le RNN simple (Elman) introduit un état caché récurrent qui agit comme une mémoire, mais souffre du problème du gradient évanescent qui limite l’apprentissage des dépendances à longue portée.

  2. La BPTT (Backpropagation Through Time) est l’algorithme d’entraînement des RNN. Le produit de matrices jacobiennes sur de longues séquences provoque l’évanescence ou l’explosion du gradient. Le gradient clipping traite l’explosion, mais pas l’évanescence.

  3. Le LSTM résout le gradient évanescent grâce à un état de cellule protégé par trois portes (oubli, entrée, sortie), permettant un flux de gradient quasi-linéaire sur de longues séquences.

  4. Le GRU est une simplification du LSTM avec deux portes (réinitialisation, mise à jour) et environ 25 % de paramètres en moins, pour des performances souvent comparables.

  5. Les architectures many-to-one, many-to-many (seq2seq), bidirectionnelles et empilées permettent d’adapter les RNN à une grande variété de tâches.

  6. Les limitations — dépendances très longues et complexité séquentielle \(O(T)\) — ont motivé le développement des mécanismes d”attention et de l’architecture Transformer, que nous étudierons au chapitre 23.

Remarque 213

Les RNN, et en particulier les LSTM, restent des outils pertinents dans de nombreux contextes : séries temporelles de longueur modérée, systèmes embarqués à faible empreinte mémoire, ou tâches où la nature séquentielle du traitement est un avantage (génération en temps réel). Comprendre les RNN est également indispensable pour apprécier pleinement les innovations apportées par les Transformers.