---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# RNN, LSTM et séquences

```{code-cell} python
:tags: [hide-input]

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

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

## Le problème des données séquentielles

Un MLP ou un CNN traite des entrées de dimension fixe, indépendantes les unes des autres. Or, une vaste catégorie de problèmes du monde réel implique des **données séquentielles** : des phrases dont le sens dépend de l'ordre des mots, des séries temporelles où la valeur à l'instant $t$ dépend des instants précédents, des séquences génomiques ou des signaux audio. Dans tous ces cas, l'hypothèse d'indépendance des exemples est violée, et la longueur de la séquence peut varier d'un exemple à l'autre.

Les **réseaux de neurones récurrents** (*Recurrent Neural Networks*, RNN) ont été conçus pour traiter exactement ce type de données. L'idée centrale est d'introduire un **état caché** (*hidden state*) qui résume l'information accumulée jusqu'à l'instant courant, et de le mettre à jour à chaque pas de temps en fonction du nouvel élément de la séquence.

## RNN vanille

```{admonition} RNN vanille
:class: tip
Un **RNN vanille** traite une séquence $\{x_1, x_2, \ldots, x_T\}$ en mettant à jour récursivement un état caché $h_t \in \mathbb{R}^d$ :

$$h_t = \tanh(W_{hh}\, h_{t-1} + W_{xh}\, x_t + b_h)$$

où $W_{hh}$ est la matrice de récurrence, $W_{xh}$ la matrice d'entrée, et $b_h$ le biais. La sortie à l'instant $t$ est optionnellement calculée par $y_t = W_{hy}\, h_t + b_y$. Les paramètres $W_{hh}$, $W_{xh}$, $b_h$ sont **partagés** à tous les pas de temps, ce qui permet au modèle de traiter des séquences de longueur arbitraire avec un nombre fixe de paramètres.
```

```{code-cell} python
# RNN vanille manuel (pour illustrer le mécanisme)
class RNNVanilla(nn.Module):
    def __init__(self, dim_entrée, dim_cachée):
        super().__init__()
        self.W_xh = nn.Linear(dim_entrée, dim_cachée, bias=False)
        self.W_hh = nn.Linear(dim_cachée, dim_cachée, bias=True)

    def forward(self, x, h0=None):
        # x : (batch_size, seq_len, dim_entrée)
        B, T, _ = x.shape
        d = self.W_hh.out_features
        h = torch.zeros(B, d, device=x.device) if h0 is None else h0
        sorties = []
        for t in range(T):
            h = torch.tanh(self.W_xh(x[:, t, :]) + self.W_hh(h))
            sorties.append(h)
        # sorties : liste de T tenseurs (B, d)
        return torch.stack(sorties, dim=1), h   # (B, T, d), (B, d)
```

### Le problème du gradient qui s'évanouit

Le défaut majeur du RNN vanille est son incapacité à capturer des **dépendances à long terme**. Intuitivement, l'état caché $h_t$ est le résultat de l'application successive de la même transformation non linéaire sur $T$ pas de temps. Lors de la rétropropagation à travers le temps (*Backpropagation Through Time*, BPTT), le gradient de la perte par rapport à $h_0$ fait intervenir le produit :

$$\frac{\partial \mathcal{L}}{\partial h_0} = \prod_{t=1}^{T} \frac{\partial h_t}{\partial h_{t-1}} \cdot \frac{\partial \mathcal{L}}{\partial h_T}$$

Si les valeurs propres de la matrice jacobienne $\partial h_t / \partial h_{t-1}$ sont inférieures à 1 en norme (ce qui arrive facilement avec $\tanh$), ce produit de $T$ matrices converge géométriquement vers zéro : c'est le **gradient qui s'évanouit** (*vanishing gradient*). Le réseau ne peut plus apprendre de dépendances séparant deux éléments par plus de quelques pas de temps. Inversement, si les valeurs propres sont supérieures à 1, le produit explose : c'est le **gradient qui explose** (*exploding gradient*), partiellement remédié par l'écrêtage des gradients (*gradient clipping*).

```{note}
L'**écrêtage des gradients** (*gradient clipping*) est une technique empirique simple pour contenir l'explosion des gradients : si la norme du gradient dépasse un seuil $\tau$, on le rescale pour qu'elle soit exactement $\tau$. En PyTorch : `torch.nn.utils.clip_grad_norm_(modele.parameters(), max_norm=1.0)`. Cet appel doit se placer entre `loss.backward()` et `optimizer.step()`. Il ne résout pas le problème des gradients qui s'évanouissent, mais stabilise l'entraînement des RNN et LSTM.
```

## LSTM

Le **Long Short-Term Memory** (LSTM, Hochreiter & Schmidhuber, 1997) est la réponse architecturale au problème du gradient qui s'évanouit. L'idée centrale est d'introduire une **cellule mémoire** $c_t$ à longue durée de vie, dont les informations peuvent être lues, écrites et effacées grâce à des mécanismes de **portes** (*gates*) différentiables.

```{admonition} LSTM
:class: tip
Un **LSTM** maintient deux états à chaque pas de temps : l'état caché $h_t$ et la cellule mémoire $c_t$. Quatre transformations linéaires, appliquées à la concaténation $[h_{t-1}, x_t]$, contrôlent le flux d'information :

- **Porte d'oubli** : $f_t = \sigma(W_f [h_{t-1}, x_t] + b_f)$ — détermine quelle fraction de $c_{t-1}$ conserver.
- **Porte d'entrée** : $i_t = \sigma(W_i [h_{t-1}, x_t] + b_i)$ — détermine quelle nouvelle information écrire.
- **Candidat mémoire** : $\tilde{c}_t = \tanh(W_c [h_{t-1}, x_t] + b_c)$ — le contenu potentiel à écrire.
- **Porte de sortie** : $o_t = \sigma(W_o [h_{t-1}, x_t] + b_o)$ — détermine quelle partie de $c_t$ exposer comme $h_t$.

Mise à jour de la cellule : $c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t$

État caché : $h_t = o_t \odot \tanh(c_t)$

Le chemin de gradient à travers $c_t$ est une somme (opération $+$), non un produit de matrices, ce qui permet à l'information de se propager sur de longues séquences sans s'évanouir.
```

```{code-cell} python
# LSTM en PyTorch
lstm = nn.LSTM(
    input_size=64,    # dimension des vecteurs d'entrée
    hidden_size=128,  # dimension de h_t et c_t
    num_layers=2,     # LSTM empilés
    batch_first=True, # format (batch, seq, features)
    dropout=0.3,      # dropout entre les couches (pas sur la dernière)
    bidirectional=False,
)

# Entrée : (batch_size=8, seq_len=20, input_size=64)
x = torch.randn(8, 20, 64)
h0 = torch.zeros(2, 8, 128)   # (num_layers, batch, hidden_size)
c0 = torch.zeros(2, 8, 128)

sorties, (hn, cn) = lstm(x, (h0, c0))
print(f"sorties : {sorties.shape}")   # (8, 20, 128) — sortie à chaque pas de temps
print(f"hn      : {hn.shape}")        # (2, 8, 128)  — état caché final par couche
print(f"cn      : {cn.shape}")        # (2, 8, 128)  — cellule mémoire finale par couche
```

## GRU

Le **Gated Recurrent Unit** (GRU, Cho et al., 2014) est une simplification du LSTM qui fusionne les portes d'oubli et d'entrée en une seule **porte de réinitialisation**, et fusionne $h_t$ et $c_t$ en un seul état caché. Le GRU a moins de paramètres que le LSTM et converge souvent plus vite, tout en atteignant des performances comparables sur la plupart des tâches.

```{admonition} GRU
:class: tip
Un **GRU** calcule l'état caché $h_t$ à partir de deux portes :

- **Porte de mise à jour** : $z_t = \sigma(W_z [h_{t-1}, x_t])$ — contrôle combien de l'état précédent est conservé.
- **Porte de réinitialisation** : $r_t = \sigma(W_r [h_{t-1}, x_t])$ — contrôle combien de l'état précédent influence le candidat.
- **Candidat** : $\tilde{h}_t = \tanh(W [r_t \odot h_{t-1}, x_t])$
- **État caché** : $h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t$

Quand $z_t \approx 0$, l'état précédent est intégralement conservé. Quand $z_t \approx 1$, le candidat $\tilde{h}_t$ remplace l'état précédent. Le GRU possède $\frac{2}{3}$ des paramètres d'un LSTM de même dimension.
```

```{code-cell} python
gru = nn.GRU(input_size=64, hidden_size=128, num_layers=2,
             batch_first=True, dropout=0.3)

x = torch.randn(8, 20, 64)
h0 = torch.zeros(2, 8, 128)
sorties, hn = gru(x, h0)
print(f"sorties : {sorties.shape}")   # (8, 20, 128)
print(f"hn      : {hn.shape}")        # (2, 8, 128)
```

## Séquences de longueur variable et padding

En pratique, les séquences d'un même batch ont rarement la même longueur. On **complète** les séquences courtes avec des valeurs neutres (généralement des zéros) jusqu'à la longueur maximale du batch : c'est le **padding**. PyTorch fournit `pack_padded_sequence` et `pad_packed_sequence` pour que le RNN ne traite pas les positions de padding, améliorant l'efficacité et la précision du calcul.

```{admonition} Padding et masquage
:class: tip
Le **padding** consiste à allonger les séquences courtes avec une valeur spéciale (souvent 0) pour qu'elles atteignent toutes la même longueur $T_{max}$. Le **masquage** consiste à ignorer les positions de padding lors du calcul de la perte et lors du traitement par le RNN. En PyTorch, `nn.utils.rnn.pack_padded_sequence` crée un objet `PackedSequence` qui compacte les données en éliminant les positions de padding, permettant au RNN de traiter efficacement des batches de séquences hétérogènes.
```

```{code-cell} python
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence, pad_packed_sequence

# Séquences de longueurs différentes
sequences = [
    torch.randn(15, 64),   # séquence de longueur 15
    torch.randn(8,  64),   # séquence de longueur 8
    torch.randn(20, 64),   # séquence de longueur 20
    torch.randn(5,  64),   # séquence de longueur 5
]
longueurs = torch.tensor([15, 8, 20, 5])

# Trier par longueur décroissante (requis pour pack_padded_sequence)
longueurs, ordre = longueurs.sort(descending=True)
sequences = [sequences[i] for i in ordre]

# Padder jusqu'à la longueur maximale
batch_padded = pad_sequence(sequences, batch_first=True, padding_value=0.0)
# batch_padded : (4, 20, 64)

# Compacter pour le RNN — ignore les positions de padding
batch_packed = pack_padded_sequence(batch_padded, longueurs, batch_first=True)

lstm = nn.LSTM(64, 128, batch_first=True)
sorties_packed, (hn, cn) = lstm(batch_packed)

# Décompacter la sortie
sorties, _ = pad_packed_sequence(sorties_packed, batch_first=True)
# sorties : (4, 20, 128) — les positions de padding contiennent des zéros
```

## Application : classification de texte

La classification de texte est l'une des applications les plus courantes des LSTM. Le pipeline standard encode chaque token en un vecteur dense via une couche d'**embedding**, traite la séquence avec un LSTM bidirectionnel, et classifie à partir de l'état caché final ou d'un pooling sur toutes les sorties.

```{admonition} Classifieur de texte avec LSTM bidirectionnel
:class: note
Voici un classifieur de sentiment (positif / négatif) basé sur un LSTM bidirectionnel :

```{code-cell} python
class ClassifieurTexte(nn.Module):
    def __init__(self, taille_vocabulaire, dim_embedding=128,
                 dim_cachée=256, n_classes=2, dropout=0.3):
        super().__init__()
        self.embedding = nn.Embedding(taille_vocabulaire, dim_embedding,
                                      padding_idx=0)
        self.lstm = nn.LSTM(
            dim_embedding, dim_cachée,
            num_layers=2,
            batch_first=True,
            dropout=dropout,
            bidirectional=True,   # ← traite la séquence dans les deux sens
        )
        self.dropout = nn.Dropout(dropout)
        # Bidirectionnel : la sortie a une dimension dim_cachée * 2
        self.classifieur = nn.Linear(dim_cachée * 2, n_classes)

    def forward(self, x, longueurs):
        # x : (B, T) — indices de tokens
        embeds = self.dropout(self.embedding(x))          # (B, T, dim_embedding)

        packed = pack_padded_sequence(embeds, longueurs.cpu(),
                                      batch_first=True, enforce_sorted=False)
        sorties_packed, (hn, _) = self.lstm(packed)

        # Concaténer les états cachés finaux des deux directions
        # hn : (num_layers * 2, B, dim_cachée) — prendre la dernière couche
        h_forward  = hn[-2]   # (B, dim_cachée) — direction avant, dernière couche
        h_backward = hn[-1]   # (B, dim_cachée) — direction arrière, dernière couche
        h_combined = torch.cat([h_forward, h_backward], dim=1)   # (B, dim_cachée * 2)

        return self.classifieur(self.dropout(h_combined))

modele = ClassifieurTexte(taille_vocabulaire=10000)
print(f"Paramètres : {sum(p.numel() for p in modele.parameters()):,}")
```

## Application : prévision de séries temporelles

Les séries temporelles constituent l'autre domaine d'application majeur des RNN. L'objectif est souvent de **prédire les valeurs futures** à partir des valeurs passées : prévision de la consommation électrique, des cours boursiers, des données météorologiques.

```{code-cell} python
class PrévisionSérie(nn.Module):
    """LSTM pour prédire h_horizon pas de temps dans le futur."""
    def __init__(self, n_variables, dim_cachée=64, n_couches=2,
                 horizon=1, dropout=0.2):
        super().__init__()
        self.lstm = nn.LSTM(n_variables, dim_cachée, n_couches,
                            batch_first=True, dropout=dropout)
        self.tête  = nn.Sequential(
            nn.Linear(dim_cachée, dim_cachée // 2),
            nn.ReLU(),
            nn.Linear(dim_cachée // 2, n_variables * horizon),
        )
        self.horizon    = horizon
        self.n_variables = n_variables

    def forward(self, x):
        # x : (B, T, n_variables) — fenêtre temporelle passée
        sorties, (hn, _) = self.lstm(x)
        # Utiliser le dernier état caché pour la prédiction
        prédiction = self.tête(sorties[:, -1, :])          # (B, n_variables * horizon)
        return prédiction.view(-1, self.horizon, self.n_variables)

# Exemple : prédire 24h à l'avance à partir de 168h (1 semaine) de données horaires
modele_ts = PrévisionSérie(n_variables=5, dim_cachée=128, horizon=24)
x = torch.randn(32, 168, 5)
print(modele_ts(x).shape)   # (32, 24, 5)
```

```{code-cell} python
:tags: [hide-input]

# Illustration : problème des dépendances à long terme
np.random.seed(42)
T = 100

# Série synthétique avec dépendance longue (motif périodique + bruit)
t_arr = np.arange(T)
série = np.sin(2 * np.pi * t_arr / 20) + 0.3 * np.sin(2 * np.pi * t_arr / 7)
série += np.random.normal(0, 0.1, T)

# Simulation de l'atténuation du gradient selon la profondeur
profondeurs = np.arange(1, T + 1)
gradient_rnn  = 0.85 ** profondeurs   # décroissance exponentielle
gradient_lstm = np.exp(-profondeurs / 80)  # décroissance beaucoup plus lente

fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))

axes[0].plot(t_arr, série, lw=1.5, color=sns.color_palette("muted")[0])
axes[0].set_xlabel("Pas de temps $t$")
axes[0].set_ylabel("Valeur")
axes[0].set_title("Série temporelle synthétique avec dépendances longues", fontweight='bold')

axes[1].semilogy(profondeurs, gradient_rnn,  lw=2, label="RNN vanille")
axes[1].semilogy(profondeurs, gradient_lstm, lw=2, label="LSTM")
axes[1].set_xlabel("Distance dans la séquence (pas de temps)")
axes[1].set_ylabel("Norme du gradient (log)")
axes[1].set_title("Atténuation du gradient en fonction de la distance", fontweight='bold')
axes[1].legend()
axes[1].axhline(1e-3, color='gray', ls='--', lw=1, label="Seuil pratique")

plt.tight_layout()
plt.show()
```

```{note}
Les LSTM et GRU ont été largement supplantés par les **transformers** pour les tâches de traitement du langage naturel depuis 2018. Les transformers exploitent un mécanisme d'**attention** permettant à chaque position de la séquence d'interagir directement avec toutes les autres, sans la limitation des dépendances bornées des RNN. Cependant, les LSTM et GRU restent pertinents pour les séries temporelles à faible latence d'inférence, les systèmes embarqués où la mémoire est contrainte, et les tâches où la longueur de séquence est modérée (quelques centaines de pas).
```

## Résumé

Ce chapitre a présenté les architectures récurrentes pour le traitement des séquences :

- Le **RNN vanille** introduit l'état caché $h_t$ mis à jour récursivement, permettant de traiter des séquences de longueur variable avec un nombre fixe de paramètres. Son défaut fondamental est le **gradient qui s'évanouit** : les dépendances au-delà de quelques dizaines de pas de temps ne peuvent pas être apprises.
- Le **LSTM** résout ce problème grâce à une cellule mémoire $c_t$ et quatre portes différentiables. Le chemin de gradient à travers $c_t$ est additif plutôt que multiplicatif, permettant aux informations de se propager sur des centaines de pas de temps.
- Le **GRU** est une simplification du LSTM avec deux portes au lieu de quatre, offrant un rapport paramètres/performance souvent favorable. Il est à préférer quand les données sont limitées ou la latence critique.
- Le **padding** et les `PackedSequence` permettent de traiter efficacement des batches de séquences hétérogènes sans calcul inutile sur les positions rembourées.
- La **classification de texte** avec LSTM bidirectionnel et la **prévision de séries temporelles** sont les deux applications paradigmatiques présentées. Dans les deux cas, le modèle est relativement simple à implémenter mais requiert un soin particulier dans la gestion des longueurs variables et le choix de la dimension de l'état caché.

Le chapitre suivant abordera les pipelines de données et l'orchestration de workflows ML, essentiels pour passer du prototype à un système reproductible et maintenable.
