Autoencodeurs#

Apprendre, c’est se retrouver.

— Malcolm de Chazal, Sens-Plastique

Les chapitres précédents ont introduit les architectures fondamentales de l’apprentissage profond : le perceptron multicouche (chapitre 16), la rétropropagation (chapitre 17), le cadre PyTorch (chapitre 18), les réseaux convolutifs (chapitre 19) et les réseaux récurrents (chapitre 20). Toutes ces architectures relèvent de l”apprentissage supervisé : elles nécessitent des étiquettes pour guider l’optimisation. Mais que se passe-t-il lorsque l’on souhaite apprendre une représentation compacte des données sans supervision ? C’est précisément l’objectif de l”apprentissage de représentations (representation learning), dont les autoencodeurs constituent l’une des approches les plus élégantes.

Un autoencodeur est un réseau de neurones entraîné à reconstruire son entrée en la faisant passer par un goulot d’étranglement (bottleneck) de dimension inférieure. En forçant le réseau à comprimer l’information, on l’oblige à découvrir les structures essentielles des données — une idée qui rejoint la réduction de dimensionnalité étudiée au chapitre 12, mais avec une puissance de modélisation bien supérieure grâce à la non-linéarité des réseaux de neurones.

Ce chapitre présente trois variantes d’autoencodeurs — le modèle classique, le débruiteur et le variationnel — avec leurs fondements mathématiques, leurs implémentations en PyTorch et des visualisations détaillées de leurs espaces latents.

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
from torch.utils.data import DataLoader, TensorDataset

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

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Périphérique utilisé : {device}")
Périphérique utilisé : cpu

Introduction : apprendre des représentations#

De la réduction linéaire à la compression neuronale#

Au chapitre 12, nous avons étudié l”Analyse en Composantes Principales (ACP), qui projette les données sur un sous-espace linéaire maximisant la variance conservée. L’ACP est un outil puissant mais limité : elle ne capture que les relations linéaires entre les variables. Pour des données complexes comme les images, les relations pertinentes sont fondamentalement non-linéaires.

Exemple 24 (Limites de l’ACP)

Considérons des images de visages. Deux visages peuvent être proches dans l’espace des pixels (même fond, même luminosité) tout en représentant des personnes très différentes, ou inversement. L’ACP, qui mesure la proximité par la distance euclidienne dans l’espace original, ne peut capturer ces subtilités sémantiques. Un autoencodeur, grâce à ses couches non-linéaires, peut apprendre une représentation latente où la distance reflète des attributs significatifs (expression, orientation, identité).

L’idée fondatrice des autoencodeurs est simple : utiliser un réseau de neurones pour apprendre simultanément une fonction de compression (l’encodeur) et une fonction de décompression (le décodeur), de sorte que la composition des deux reconstruise fidèlement l’entrée.

Définition 252 (Autoencodeur)

Un autoencodeur est une paire de fonctions paramétrées :

  • Un encodeur \(f_\phi : \mathcal{X} \to \mathcal{Z}\) qui associe à chaque entrée \(\mathbf{x} \in \mathbb{R}^d\) un code latent \(\mathbf{z} = f_\phi(\mathbf{x}) \in \mathbb{R}^k\) avec \(k \ll d\).

  • Un décodeur \(g_\theta : \mathcal{Z} \to \mathcal{X}\) qui reconstruit l’entrée à partir du code : \(\hat{\mathbf{x}} = g_\theta(\mathbf{z})\).

L’entraînement minimise l”erreur de reconstruction :

\[\mathcal{L}(\phi, \theta) = \frac{1}{n} \sum_{i=1}^{n} \ell\left(\mathbf{x}_i,\, g_\theta(f_\phi(\mathbf{x}_i))\right)\]

\(\ell\) est typiquement l’erreur quadratique moyenne \(\|\mathbf{x} - \hat{\mathbf{x}}\|^2\) ou l’entropie croisée binaire.

Hide code cell source

# Schéma conceptuel d'un autoencodeur
fig, ax = plt.subplots(figsize=(12, 4))
ax.set_xlim(0, 10); ax.set_ylim(0, 4)
ax.axis('off')

# Entrée
for i, y in enumerate([1.0, 1.5, 2.0, 2.5, 3.0]):
    ax.add_patch(plt.Rectangle((0.5, y - 0.15), 0.6, 0.3,
                 facecolor='#4C72B0', alpha=0.7, edgecolor='white'))
ax.text(0.8, 3.6, r"$\mathbf{x} \in \mathbb{R}^d$", ha='center', fontsize=11)

# Encodeur
ax.annotate('', xy=(2.5, 2.0), xytext=(1.3, 2.0),
            arrowprops=dict(arrowstyle='->', lw=2, color='gray'))
ax.text(1.9, 2.5, "Encodeur\n$f_\\phi$", ha='center', fontsize=10, color='gray')

# Couches cachées encodeur
for i, y in enumerate([1.5, 2.0, 2.5]):
    ax.add_patch(plt.Rectangle((2.5, y - 0.15), 0.6, 0.3,
                 facecolor='#55A868', alpha=0.7, edgecolor='white'))

# Bottleneck
ax.annotate('', xy=(4.2, 2.0), xytext=(3.3, 2.0),
            arrowprops=dict(arrowstyle='->', lw=2, color='gray'))
for i, y in enumerate([1.7, 2.3]):
    ax.add_patch(plt.Rectangle((4.2, y - 0.15), 0.6, 0.3,
                 facecolor='#DD8452', alpha=0.9, edgecolor='white'))
ax.text(4.5, 3.2, r"$\mathbf{z} \in \mathbb{R}^k$", ha='center',
        fontsize=11, color='#DD8452', fontweight='bold')
ax.text(4.5, 0.5, "Goulot\n(bottleneck)", ha='center', fontsize=9, color='#DD8452')

# Décodeur
ax.annotate('', xy=(6.0, 2.0), xytext=(5.0, 2.0),
            arrowprops=dict(arrowstyle='->', lw=2, color='gray'))
ax.text(5.5, 2.5, "Décodeur\n$g_\\theta$", ha='center', fontsize=10, color='gray')

for i, y in enumerate([1.5, 2.0, 2.5]):
    ax.add_patch(plt.Rectangle((6.0, y - 0.15), 0.6, 0.3,
                 facecolor='#55A868', alpha=0.7, edgecolor='white'))

# Sortie
ax.annotate('', xy=(7.8, 2.0), xytext=(6.8, 2.0),
            arrowprops=dict(arrowstyle='->', lw=2, color='gray'))
for i, y in enumerate([1.0, 1.5, 2.0, 2.5, 3.0]):
    ax.add_patch(plt.Rectangle((7.8, y - 0.15), 0.6, 0.3,
                 facecolor='#C44E52', alpha=0.7, edgecolor='white'))
ax.text(8.1, 3.6, r"$\hat{\mathbf{x}} \approx \mathbf{x}$", ha='center', fontsize=11)

ax.set_title("Architecture d'un autoencodeur : compression puis reconstruction",
             fontsize=12, pad=15)
plt.tight_layout()
plt.show()
_images/87fe054296400d44d648656b2262088f0af9fde98ce84f01159fe74e1edf2c3a.png

Préparation des données#

Tout au long de ce chapitre, nous utiliserons des données synthétiques simulant des chiffres manuscrits. Nous générons un jeu de données de motifs structurés en 2D qui permettent d’illustrer clairement les concepts sans dépendre d’un téléchargement externe.

Hide code cell source

def generate_synthetic_digits(n_samples=3000, img_size=16, n_classes=5):
    """Génère des images synthétiques de motifs simples (lignes, croix, etc.)."""
    images = []
    labels = []
    for _ in range(n_samples):
        label = np.random.randint(0, n_classes)
        img = np.zeros((img_size, img_size))
        if label == 0:  # Barre horizontale
            y = np.random.randint(4, img_size - 4)
            thickness = np.random.randint(1, 3)
            img[y:y+thickness, 2:img_size-2] = 1.0
        elif label == 1:  # Barre verticale
            x = np.random.randint(4, img_size - 4)
            thickness = np.random.randint(1, 3)
            img[2:img_size-2, x:x+thickness] = 1.0
        elif label == 2:  # Croix
            cx, cy = img_size // 2, img_size // 2
            img[cy-1:cy+1, 2:img_size-2] = 1.0
            img[2:img_size-2, cx-1:cx+1] = 1.0
        elif label == 3:  # Diagonale
            for i in range(2, img_size - 2):
                j = int(2 + (img_size - 4) * i / img_size)
                img[i, max(0, j):min(img_size, j+2)] = 1.0
        elif label == 4:  # Cadre
            img[2:4, 2:img_size-2] = 1.0
            img[img_size-4:img_size-2, 2:img_size-2] = 1.0
            img[2:img_size-2, 2:4] = 1.0
            img[2:img_size-2, img_size-4:img_size-2] = 1.0
        # Ajout de bruit léger
        img += 0.05 * np.random.randn(img_size, img_size)
        img = np.clip(img, 0, 1)
        images.append(img)
        labels.append(label)
    return np.array(images, dtype=np.float32), np.array(labels)

X_data, y_data = generate_synthetic_digits(n_samples=4000)
input_dim = X_data.shape[1] * X_data.shape[2]  # 16 * 16 = 256

# Conversion en tenseurs PyTorch
X_tensor = torch.tensor(X_data.reshape(-1, input_dim)).to(device)
y_tensor = torch.tensor(y_data).to(device)

# Séparation entraînement / test
n_train = 3200
X_train, X_test = X_tensor[:n_train], X_tensor[n_train:]
y_train, y_test = y_tensor[:n_train], y_tensor[n_train:]

train_loader = DataLoader(TensorDataset(X_train, y_train),
                          batch_size=128, shuffle=True)
test_loader = DataLoader(TensorDataset(X_test, y_test),
                         batch_size=128, shuffle=False)

# Visualisation de quelques exemples
class_names = ["Horiz.", "Vertic.", "Croix", "Diag.", "Cadre"]
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i in range(10):
    ax = axes[i // 5, i % 5]
    idx = np.where(y_data == (i % 5))[0][i // 5]
    ax.imshow(X_data[idx], cmap='gray_r', vmin=0, vmax=1)
    ax.set_title(class_names[i % 5], fontsize=10)
    ax.axis('off')
plt.suptitle("Exemples de motifs synthétiques (5 classes)", fontsize=13, y=1.02)
plt.tight_layout()
plt.show()

print(f"Dimensions : {X_data.shape[0]} images de {X_data.shape[1]}×{X_data.shape[2]} pixels")
print(f"Dimension d'entrée aplatie : {input_dim}")
_images/343673fe00378c0c461e79ebd374cf69bb8fdc549f52bb6df7d99262d8777642.png
Dimensions : 4000 images de 16×16 pixels
Dimension d'entrée aplatie : 256

Autoencodeur simple (Vanilla Autoencoder)#

Architecture#

L’autoencodeur le plus simple est constitué de couches entièrement connectées. L’encodeur réduit progressivement la dimensionnalité jusqu’au goulot d’étranglement, puis le décodeur la reconstruit symétriquement.

Définition 253 (Autoencodeur sous-complet)

Un autoencodeur est dit sous-complet (undercomplete) lorsque la dimension de l’espace latent \(k\) est strictement inférieure à la dimension de l’entrée \(d\). Cette contrainte force le réseau à apprendre une compression non triviale. Si le réseau est linéaire et la perte est l’erreur quadratique, l’autoencodeur sous-complet apprend le même sous-espace que l’ACP.

Inversement, un autoencodeur sur-complet (overcomplete) a \(k \geq d\) et peut apprendre l’identité sans extraire de structure. Il nécessite des régularisations supplémentaires pour être utile.

Remarque 214

Le lien entre ACP et autoencodeur linéaire a été établi par Baldi et Hornik (1989). Avec des activations non-linéaires (ReLU, sigmoïde), l’autoencodeur dépasse l’ACP en capturant des variétés non-linéaires (nonlinear manifolds) dans l’espace des données.

Implémentation en PyTorch#

Hide code cell source

class Autoencoder(nn.Module):
    """Autoencodeur simple (vanilla) avec couches entièrement connectées."""

    def __init__(self, input_dim, hidden_dims, latent_dim):
        super().__init__()
        # Encodeur
        encoder_layers = []
        prev_dim = input_dim
        for h_dim in hidden_dims:
            encoder_layers.append(nn.Linear(prev_dim, h_dim))
            encoder_layers.append(nn.ReLU())
            prev_dim = h_dim
        encoder_layers.append(nn.Linear(prev_dim, latent_dim))
        self.encoder = nn.Sequential(*encoder_layers)

        # Décodeur (architecture miroir)
        decoder_layers = []
        prev_dim = latent_dim
        for h_dim in reversed(hidden_dims):
            decoder_layers.append(nn.Linear(prev_dim, h_dim))
            decoder_layers.append(nn.ReLU())
            prev_dim = h_dim
        decoder_layers.append(nn.Linear(prev_dim, input_dim))
        decoder_layers.append(nn.Sigmoid())  # Sortie dans [0, 1]
        self.decoder = nn.Sequential(*decoder_layers)

    def encode(self, x):
        return self.encoder(x)

    def decode(self, z):
        return self.decoder(z)

    def forward(self, x):
        z = self.encode(x)
        x_hat = self.decode(z)
        return x_hat, z

# Instanciation
latent_dim = 2  # Dimension latente (2D pour la visualisation)
ae_model = Autoencoder(input_dim, hidden_dims=[128, 64], latent_dim=latent_dim).to(device)

print(ae_model)
n_params = sum(p.numel() for p in ae_model.parameters())
print(f"\nNombre total de paramètres : {n_params:,}")
Autoencoder(
  (encoder): Sequential(
    (0): Linear(in_features=256, out_features=128, bias=True)
    (1): ReLU()
    (2): Linear(in_features=128, out_features=64, bias=True)
    (3): ReLU()
    (4): Linear(in_features=64, out_features=2, bias=True)
  )
  (decoder): Sequential(
    (0): Linear(in_features=2, out_features=64, bias=True)
    (1): ReLU()
    (2): Linear(in_features=64, out_features=128, bias=True)
    (3): ReLU()
    (4): Linear(in_features=128, out_features=256, bias=True)
    (5): Sigmoid()
  )
)

Nombre total de paramètres : 82,818

Entraînement#

La fonction de perte est l”erreur quadratique moyenne (MSE) entre l’entrée et la reconstruction. On utilise l’optimiseur Adam, introduit au chapitre 17, qui combine les avantages du momentum et de l’adaptation du pas d’apprentissage.

Hide code cell source

def train_autoencoder(model, train_loader, n_epochs=15, lr=1e-3, loss_fn=None):
    """Boucle d'entraînement générique pour un autoencodeur."""
    if loss_fn is None:
        loss_fn = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=lr)
    history = []

    for epoch in range(n_epochs):
        model.train()
        epoch_loss = 0.0
        for batch_x, _ in train_loader:
            x_hat, _ = model(batch_x)
            loss = loss_fn(x_hat, batch_x)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            epoch_loss += loss.item() * batch_x.size(0)
        epoch_loss /= len(train_loader.dataset)
        history.append(epoch_loss)
        if (epoch + 1) % 20 == 0:
            print(f"  Époque {epoch+1:3d}/{n_epochs} — Perte : {epoch_loss:.6f}")

    return history

print("Entraînement de l'autoencodeur simple (dim. latente = 2) :")
ae_history = train_autoencoder(ae_model, train_loader, n_epochs=15, lr=1e-3)
Entraînement de l'autoencodeur simple (dim. latente = 2) :

Hide code cell source

# Courbe de perte
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ae_history, color='#4C72B0', linewidth=1.5)
ax.set_xlabel("Époque"); ax.set_ylabel("MSE (reconstruction)")
ax.set_title("Convergence de l'autoencodeur simple")
ax.set_yscale('log')
plt.tight_layout()
plt.show()
_images/819a2e73a514ecd000231dc1a655fbd6751f8ef5ee71e0717ec3cd98aa33523b.png

Visualisation des reconstructions#

Hide code cell source

def plot_reconstructions(model, X, y, n_examples=8, img_size=16, title=""):
    """Affiche des exemples originaux et leurs reconstructions."""
    model.eval()
    with torch.no_grad():
        x_hat, _ = model(X[:n_examples])

    fig, axes = plt.subplots(2, n_examples, figsize=(2 * n_examples, 4.5))
    for i in range(n_examples):
        # Original
        axes[0, i].imshow(X[i].cpu().numpy().reshape(img_size, img_size),
                          cmap='gray_r', vmin=0, vmax=1)
        axes[0, i].set_title(class_names[y[i].item()], fontsize=9)
        axes[0, i].axis('off')
        # Reconstruction
        axes[1, i].imshow(x_hat[i].cpu().numpy().reshape(img_size, img_size),
                          cmap='gray_r', vmin=0, vmax=1)
        axes[1, i].axis('off')

    axes[0, 0].set_ylabel("Original", fontsize=11)
    axes[1, 0].set_ylabel("Reconstruit", fontsize=11)
    plt.suptitle(title, fontsize=13, y=1.02)
    plt.tight_layout()
    plt.show()

plot_reconstructions(ae_model, X_test, y_test,
                     title="Autoencodeur simple — Reconstructions")
_images/f49e3fab7fc5bbf3b49881acd441d173c7c1621a86af22d23374a437085916d7.png

Remarque 215

Avec seulement 2 dimensions latentes, l’autoencodeur parvient à reconstruire les grandes lignes des motifs, mais perd inévitablement certains détails. C’est le compromis fondamental de la compression : plus la dimension latente est faible, plus l’information est condensée, mais plus la reconstruction est approximative.

Évaluation quantitative#

Hide code cell source

# Calcul de l'erreur de reconstruction sur le jeu de test
ae_model.eval()
with torch.no_grad():
    x_hat_test, _ = ae_model(X_test)
    mse_test = F.mse_loss(x_hat_test, X_test).item()
    mae_test = F.l1_loss(x_hat_test, X_test).item()

print(f"Erreur de reconstruction (test) :")
print(f"  MSE : {mse_test:.6f}")
print(f"  MAE : {mae_test:.6f}")
Erreur de reconstruction (test) :
  MSE : 0.014826
  MAE : 0.049181

Autoencodeur débruiteur (Denoising Autoencoder)#

Motivation#

L’autoencodeur simple peut parfois apprendre une représentation trop spécifique, capturant le bruit des données plutôt que leur structure fondamentale. L”autoencodeur débruiteur (Denoising Autoencoder, DAE), proposé par Vincent et al. (2008), résout ce problème de manière élégante : on corrompt volontairement l’entrée par du bruit, puis on entraîne le réseau à reconstruire la version originale (non bruitée).

Définition 254 (Autoencodeur débruiteur)

Soit \(\tilde{\mathbf{x}} = \mathbf{x} + \boldsymbol{\epsilon}\) une version bruitée de l’entrée, où \(\boldsymbol{\epsilon} \sim \mathcal{N}(0, \sigma^2 I)\). L”autoencodeur débruiteur minimise :

\[\mathcal{L}_{\text{DAE}}(\phi, \theta) = \frac{1}{n} \sum_{i=1}^{n} \left\|\mathbf{x}_i - g_\theta(f_\phi(\tilde{\mathbf{x}}_i))\right\|^2\]

L’entrée du réseau est la version bruitée \(\tilde{\mathbf{x}}\), mais la cible de reconstruction est la version propre \(\mathbf{x}\). Le réseau apprend ainsi à débruiter les données, ce qui l’oblige à capturer la structure sous-jacente plutôt que les fluctuations aléatoires.

Remarque 216

L’autoencodeur débruiteur a une interprétation théorique profonde : minimiser la perte de débruitage revient approximativement à maximiser un score (score matching), c’est-à-dire à apprendre le gradient du log de la distribution des données. Cette connexion, établie par Alain et Bengio (2014), relie les autoencodeurs débruiteurs aux modèles génératifs par score (score-based generative models), une famille de modèles qui a conduit aux récents modèles de diffusion.

Implémentation#

On réutilise la même architecture que l’autoencodeur simple, mais en ajoutant du bruit gaussien aux entrées pendant l’entraînement.

Hide code cell source

class DenoisingAutoencoder(nn.Module):
    """Autoencodeur débruiteur : même architecture, entraînement différent."""

    def __init__(self, input_dim, hidden_dims, latent_dim, noise_factor=0.3):
        super().__init__()
        self.noise_factor = noise_factor

        # Encodeur
        encoder_layers = []
        prev_dim = input_dim
        for h_dim in hidden_dims:
            encoder_layers.append(nn.Linear(prev_dim, h_dim))
            encoder_layers.append(nn.ReLU())
            prev_dim = h_dim
        encoder_layers.append(nn.Linear(prev_dim, latent_dim))
        self.encoder = nn.Sequential(*encoder_layers)

        # Décodeur
        decoder_layers = []
        prev_dim = latent_dim
        for h_dim in reversed(hidden_dims):
            decoder_layers.append(nn.Linear(prev_dim, h_dim))
            decoder_layers.append(nn.ReLU())
            prev_dim = h_dim
        decoder_layers.append(nn.Linear(prev_dim, input_dim))
        decoder_layers.append(nn.Sigmoid())
        self.decoder = nn.Sequential(*decoder_layers)

    def add_noise(self, x):
        noise = self.noise_factor * torch.randn_like(x)
        return torch.clamp(x + noise, 0.0, 1.0)

    def encode(self, x):
        return self.encoder(x)

    def decode(self, z):
        return self.decoder(z)

    def forward(self, x, add_noise=True):
        if add_noise and self.training:
            x_noisy = self.add_noise(x)
        else:
            x_noisy = x
        z = self.encode(x_noisy)
        x_hat = self.decode(z)
        return x_hat, z

# Instanciation
dae_model = DenoisingAutoencoder(input_dim, hidden_dims=[128, 64],
                                  latent_dim=2, noise_factor=0.3).to(device)

Hide code cell source

def train_denoising_ae(model, train_loader, n_epochs=15, lr=1e-3):
    """Entraînement spécifique au DAE : perte entre sortie et entrée propre."""
    optimizer = optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()
    history = []

    for epoch in range(n_epochs):
        model.train()
        epoch_loss = 0.0
        for batch_x, _ in train_loader:
            # Le modèle ajoute le bruit en interne
            x_hat, _ = model(batch_x, add_noise=True)
            loss = loss_fn(x_hat, batch_x)  # Cible = entrée propre
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            epoch_loss += loss.item() * batch_x.size(0)
        epoch_loss /= len(train_loader.dataset)
        history.append(epoch_loss)
        if (epoch + 1) % 20 == 0:
            print(f"  Époque {epoch+1:3d}/{n_epochs} — Perte : {epoch_loss:.6f}")

    return history

print("Entraînement de l'autoencodeur débruiteur :")
dae_history = train_denoising_ae(dae_model, train_loader, n_epochs=15, lr=1e-3)
Entraînement de l'autoencodeur débruiteur :

Débruitage en action#

Hide code cell source

# Démonstration du débruitage
dae_model.eval()
n_show = 8

with torch.no_grad():
    x_clean = X_test[:n_show]
    x_noisy = dae_model.add_noise(x_clean)
    x_denoised, _ = dae_model(x_noisy, add_noise=False)

fig, axes = plt.subplots(3, n_show, figsize=(2 * n_show, 7))
for i in range(n_show):
    axes[0, i].imshow(x_clean[i].cpu().numpy().reshape(16, 16),
                      cmap='gray_r', vmin=0, vmax=1)
    axes[0, i].axis('off')
    axes[1, i].imshow(x_noisy[i].cpu().numpy().reshape(16, 16),
                      cmap='gray_r', vmin=0, vmax=1)
    axes[1, i].axis('off')
    axes[2, i].imshow(x_denoised[i].cpu().numpy().reshape(16, 16),
                      cmap='gray_r', vmin=0, vmax=1)
    axes[2, i].axis('off')

axes[0, 0].set_ylabel("Original", fontsize=11)
axes[1, 0].set_ylabel("Bruité", fontsize=11)
axes[2, 0].set_ylabel("Débruité", fontsize=11)
plt.suptitle("Autoencodeur débruiteur — Reconstruction à partir d'entrées corrompues",
             fontsize=13, y=1.02)
plt.tight_layout()
plt.show()
_images/71aac8a6277153ba26089d1b4436a64617660b5d03e34c7741f18820ef2d550d.png

Hide code cell source

# Comparaison des courbes de perte
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ae_history, label="AE simple", color='#4C72B0', linewidth=1.5)
ax.plot(dae_history, label="AE débruiteur", color='#DD8452', linewidth=1.5)
ax.set_xlabel("Époque"); ax.set_ylabel("MSE")
ax.set_title("Comparaison des convergences : AE simple vs DAE")
ax.legend(); ax.set_yscale('log')
plt.tight_layout()
plt.show()
_images/cdaf9e0577687cf600c9f039ec205d890c4826a26ce5b95cc2ca5eadad337c91.png

Remarque 217

La perte du DAE est systématiquement plus élevée que celle de l’AE simple, ce qui est attendu : le DAE doit reconstruire l’entrée propre à partir d’une version bruitée, une tâche intrinsèquement plus difficile. Cependant, cette difficulté supplémentaire agit comme une régularisation qui conduit à des représentations plus robustes et plus généralisables.

Autoencodeur variationnel (Variational Autoencoder)#

Motivation : de la compression à la génération#

Les autoencodeurs classiques apprennent un mapping déterministe de l’entrée vers l’espace latent. L’espace latent résultant est souvent irrégulier : certaines régions ne correspondent à aucune donnée réelle, et l’interpolation entre deux codes latents peut produire des reconstructions incohérentes.

L”autoencodeur variationnel (VAE), proposé par Kingma et Welling (2014), résout ce problème en imposant une structure probabiliste à l’espace latent. Au lieu d’encoder chaque entrée en un point \(\mathbf{z}\), l’encodeur produit les paramètres d’une distribution — la moyenne \(\boldsymbol{\mu}\) et la variance \(\boldsymbol{\sigma}^2\) — à partir de laquelle on échantillonne le code latent. Cette approche transforme l’autoencodeur en un véritable modèle génératif.

Définition 255 (Autoencodeur variationnel (VAE))

Un autoencodeur variationnel est un modèle génératif latent défini par :

  1. Un prior sur l’espace latent : \(p(\mathbf{z}) = \mathcal{N}(\mathbf{0}, \mathbf{I})\)

  2. Un décodeur (modèle génératif) : \(p_\theta(\mathbf{x} \mid \mathbf{z})\), paramétré par un réseau de neurones

  3. Un encodeur (inférence approchée) : \(q_\phi(\mathbf{z} \mid \mathbf{x}) = \mathcal{N}(\boldsymbol{\mu}_\phi(\mathbf{x}),\, \text{diag}(\boldsymbol{\sigma}^2_\phi(\mathbf{x})))\)

L’encodeur approxime la distribution a posteriori intractable \(p(\mathbf{z} \mid \mathbf{x})\) par une gaussienne diagonale dont les paramètres sont produits par un réseau de neurones.

La borne inférieure variationnelle (ELBO)#

La log-vraisemblance marginale \(\log p_\theta(\mathbf{x})\) est intractable car elle requiert une intégration sur tout l’espace latent :

\[\log p_\theta(\mathbf{x}) = \log \int p_\theta(\mathbf{x} \mid \mathbf{z})\, p(\mathbf{z})\, d\mathbf{z}\]

On maximise à la place une borne inférieure (Evidence Lower Bound, ELBO) :

Théorème 1 (ELBO (Evidence Lower Bound))

Pour toute distribution \(q_\phi(\mathbf{z} \mid \mathbf{x})\), on a :

\[\log p_\theta(\mathbf{x}) \geq \underbrace{\mathbb{E}_{q_\phi(\mathbf{z} \mid \mathbf{x})}\left[\log p_\theta(\mathbf{x} \mid \mathbf{z})\right]}_{\text{reconstruction}} - \underbrace{D_{\text{KL}}\left(q_\phi(\mathbf{z} \mid \mathbf{x}) \,\|\, p(\mathbf{z})\right)}_{\text{régularisation}} = \text{ELBO}(\phi, \theta; \mathbf{x})\]

Le premier terme encourage une bonne reconstruction des données. Le second terme, la divergence de Kullback-Leibler, force la distribution a posteriori approchée \(q_\phi\) à rester proche du prior \(p(\mathbf{z}) = \mathcal{N}(\mathbf{0}, \mathbf{I})\), ce qui régularise l’espace latent et le rend propice à la génération.

Remarque 218

L’écart entre \(\log p_\theta(\mathbf{x})\) et l’ELBO est exactement \(D_{\text{KL}}(q_\phi(\mathbf{z} \mid \mathbf{x}) \| p_\theta(\mathbf{z} \mid \mathbf{x}))\). Maximiser l’ELBO revient donc à la fois à maximiser la vraisemblance des données et à rapprocher l’encodeur de la vraie distribution a posteriori.

Divergence KL pour des gaussiennes#

Lorsque \(q_\phi(\mathbf{z} \mid \mathbf{x}) = \mathcal{N}(\boldsymbol{\mu}, \text{diag}(\boldsymbol{\sigma}^2))\) et \(p(\mathbf{z}) = \mathcal{N}(\mathbf{0}, \mathbf{I})\), la divergence KL admet une forme analytique.

Théorème 2 (Divergence KL entre gaussiennes)

Soit \(q = \mathcal{N}(\boldsymbol{\mu}, \text{diag}(\boldsymbol{\sigma}^2))\) et \(p = \mathcal{N}(\mathbf{0}, \mathbf{I})\), avec \(\boldsymbol{\mu} \in \mathbb{R}^k\) et \(\boldsymbol{\sigma} \in \mathbb{R}^k\). Alors :

\[D_{\text{KL}}(q \| p) = -\frac{1}{2} \sum_{j=1}^{k} \left(1 + \log \sigma_j^2 - \mu_j^2 - \sigma_j^2\right)\]

Démonstration. Par définition de la divergence KL :

\[D_{\text{KL}}(q \| p) = \int q(\mathbf{z}) \log \frac{q(\mathbf{z})}{p(\mathbf{z})}\, d\mathbf{z} = \mathbb{E}_q\left[\log q(\mathbf{z})\right] - \mathbb{E}_q\left[\log p(\mathbf{z})\right]\]

Le premier terme est l’entropie négative de \(q\) :

\[\mathbb{E}_q[\log q(\mathbf{z})] = -\frac{k}{2}\log(2\pi) - \frac{1}{2}\sum_{j=1}^{k}\log\sigma_j^2 - \frac{k}{2}\]

Le second terme, puisque \(p = \mathcal{N}(\mathbf{0}, \mathbf{I})\) :

\[\mathbb{E}_q[\log p(\mathbf{z})] = -\frac{k}{2}\log(2\pi) - \frac{1}{2}\sum_{j=1}^{k}\left(\mu_j^2 + \sigma_j^2\right)\]

En soustrayant :

\[D_{\text{KL}} = -\frac{1}{2}\sum_{j=1}^{k}\left(\log\sigma_j^2 + 1 - \mu_j^2 - \sigma_j^2\right) \quad \square\]

L’astuce de reparamétrisation#

Pour rétropropager le gradient à travers l’opération d’échantillonnage \(\mathbf{z} \sim q_\phi(\mathbf{z} \mid \mathbf{x})\), on utilise l”astuce de reparamétrisation (reparameterization trick) : au lieu d’échantillonner directement depuis \(\mathcal{N}(\boldsymbol{\mu}, \boldsymbol{\sigma}^2)\), on écrit :

\[\mathbf{z} = \boldsymbol{\mu} + \boldsymbol{\sigma} \odot \boldsymbol{\epsilon}, \quad \boldsymbol{\epsilon} \sim \mathcal{N}(\mathbf{0}, \mathbf{I})\]

Ainsi, l’aléa est déporté dans \(\boldsymbol{\epsilon}\), et le gradient peut remonter à travers \(\boldsymbol{\mu}\) et \(\boldsymbol{\sigma}\), qui sont des sorties déterministes de l’encodeur.

Définition 256 (Astuce de reparamétrisation)

L”astuce de reparamétrisation consiste à exprimer la variable latente comme une transformation déterministe et différentiable d’un bruit externe :

\[\mathbf{z} = \boldsymbol{\mu}_\phi(\mathbf{x}) + \boldsymbol{\sigma}_\phi(\mathbf{x}) \odot \boldsymbol{\epsilon}, \quad \boldsymbol{\epsilon} \sim \mathcal{N}(\mathbf{0}, \mathbf{I})\]

Cette écriture permet de calculer \(\nabla_\phi \mathbb{E}_{q_\phi}[f(\mathbf{z})]\) par rétropropagation (chapitre 17), puisque \(\mathbf{z}\) est une fonction différentiable de \(\phi\).

Implémentation du VAE en PyTorch#

Hide code cell source

class VAE(nn.Module):
    """Autoencodeur variationnel (VAE) avec couches entièrement connectées."""

    def __init__(self, input_dim, hidden_dims, latent_dim):
        super().__init__()
        self.latent_dim = latent_dim

        # Encodeur : produit mu et log_var
        encoder_layers = []
        prev_dim = input_dim
        for h_dim in hidden_dims:
            encoder_layers.append(nn.Linear(prev_dim, h_dim))
            encoder_layers.append(nn.ReLU())
            prev_dim = h_dim
        self.encoder_body = nn.Sequential(*encoder_layers)
        self.fc_mu = nn.Linear(prev_dim, latent_dim)
        self.fc_log_var = nn.Linear(prev_dim, latent_dim)

        # Décodeur
        decoder_layers = []
        prev_dim = latent_dim
        for h_dim in reversed(hidden_dims):
            decoder_layers.append(nn.Linear(prev_dim, h_dim))
            decoder_layers.append(nn.ReLU())
            prev_dim = h_dim
        decoder_layers.append(nn.Linear(prev_dim, input_dim))
        decoder_layers.append(nn.Sigmoid())
        self.decoder = nn.Sequential(*decoder_layers)

    def encode(self, x):
        h = self.encoder_body(x)
        mu = self.fc_mu(h)
        log_var = self.fc_log_var(h)
        return mu, log_var

    def reparameterize(self, mu, log_var):
        """Astuce de reparamétrisation : z = mu + sigma * epsilon."""
        std = torch.exp(0.5 * log_var)
        eps = torch.randn_like(std)
        return mu + std * eps

    def decode(self, z):
        return self.decoder(z)

    def forward(self, x):
        mu, log_var = self.encode(x)
        z = self.reparameterize(mu, log_var)
        x_hat = self.decode(z)
        return x_hat, mu, log_var, z

# Instanciation
vae_model = VAE(input_dim, hidden_dims=[128, 64], latent_dim=2).to(device)
print(vae_model)
VAE(
  (encoder_body): Sequential(
    (0): Linear(in_features=256, out_features=128, bias=True)
    (1): ReLU()
    (2): Linear(in_features=128, out_features=64, bias=True)
    (3): ReLU()
  )
  (fc_mu): Linear(in_features=64, out_features=2, bias=True)
  (fc_log_var): Linear(in_features=64, out_features=2, bias=True)
  (decoder): Sequential(
    (0): Linear(in_features=2, out_features=64, bias=True)
    (1): ReLU()
    (2): Linear(in_features=64, out_features=128, bias=True)
    (3): ReLU()
    (4): Linear(in_features=128, out_features=256, bias=True)
    (5): Sigmoid()
  )
)

Hide code cell source

def vae_loss(x_hat, x, mu, log_var):
    """Perte ELBO = reconstruction (BCE) + divergence KL."""
    # Terme de reconstruction (erreur quadratique)
    recon_loss = F.mse_loss(x_hat, x, reduction='sum')
    # Divergence KL : -0.5 * sum(1 + log(sigma^2) - mu^2 - sigma^2)
    kl_loss = -0.5 * torch.sum(1 + log_var - mu.pow(2) - log_var.exp())
    return recon_loss + kl_loss, recon_loss, kl_loss

def train_vae(model, train_loader, n_epochs=20, lr=1e-3):
    """Boucle d'entraînement pour le VAE."""
    optimizer = optim.Adam(model.parameters(), lr=lr)
    history = {'total': [], 'recon': [], 'kl': []}

    for epoch in range(n_epochs):
        model.train()
        total_loss = 0.0; total_recon = 0.0; total_kl = 0.0
        for batch_x, _ in train_loader:
            x_hat, mu, log_var, _ = model(batch_x)
            loss, recon, kl = vae_loss(x_hat, batch_x, mu, log_var)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
            total_recon += recon.item()
            total_kl += kl.item()

        n = len(train_loader.dataset)
        history['total'].append(total_loss / n)
        history['recon'].append(total_recon / n)
        history['kl'].append(total_kl / n)

        if (epoch + 1) % 25 == 0:
            print(f"  Époque {epoch+1:3d}/{n_epochs} — "
                  f"Total : {total_loss/n:.4f}, "
                  f"Recon : {total_recon/n:.4f}, "
                  f"KL : {total_kl/n:.4f}")

    return history

print("Entraînement du VAE :")
vae_history = train_vae(vae_model, train_loader, n_epochs=20, lr=1e-3)
Entraînement du VAE :

Analyse de la perte ELBO#

Hide code cell source

fig, axes = plt.subplots(3, 1, figsize=(9, 11))

axes[0].plot(vae_history['total'], color='#4C72B0', linewidth=1.5)
axes[0].set_title("Perte totale (ELBO)"); axes[0].set_xlabel("Époque")
axes[0].set_ylabel("Perte / échantillon")

axes[1].plot(vae_history['recon'], color='#55A868', linewidth=1.5)
axes[1].set_title("Terme de reconstruction"); axes[1].set_xlabel("Époque")

axes[2].plot(vae_history['kl'], color='#DD8452', linewidth=1.5)
axes[2].set_title("Divergence KL"); axes[2].set_xlabel("Époque")

plt.suptitle("Décomposition de la perte ELBO du VAE", fontsize=13, y=1.03)
plt.tight_layout()
plt.show()
_images/59136e9ff3ccffa67206e32fdb97d58c855d5ba97d96907ff9d2095ccf231b04.png

Remarque 219

On observe un phénomène classique : au début de l’entraînement, la divergence KL diminue (l’encodeur tente de s’écarter du prior pour mieux représenter les données), puis elle augmente progressivement lorsque la régularisation prend le dessus. Cet équilibre dynamique entre reconstruction et régularisation est au coeur du fonctionnement du VAE.

Reconstructions du VAE#

Hide code cell source

def plot_vae_reconstructions(model, X, y, n_examples=8, img_size=16):
    """Affiche les reconstructions du VAE."""
    model.eval()
    with torch.no_grad():
        x_hat, mu, log_var, z = model(X[:n_examples])

    fig, axes = plt.subplots(2, n_examples, figsize=(2 * n_examples, 4.5))
    for i in range(n_examples):
        axes[0, i].imshow(X[i].cpu().numpy().reshape(img_size, img_size),
                          cmap='gray_r', vmin=0, vmax=1)
        axes[0, i].set_title(class_names[y[i].item()], fontsize=9)
        axes[0, i].axis('off')
        axes[1, i].imshow(x_hat[i].cpu().numpy().reshape(img_size, img_size),
                          cmap='gray_r', vmin=0, vmax=1)
        axes[1, i].axis('off')
    axes[0, 0].set_ylabel("Original", fontsize=11)
    axes[1, 0].set_ylabel("VAE", fontsize=11)
    plt.suptitle("VAE — Reconstructions", fontsize=13, y=1.02)
    plt.tight_layout()
    plt.show()

plot_vae_reconstructions(vae_model, X_test, y_test)
_images/ab97d38cf540cd00462cbd052b2a30733e5b7158c77173c5591acc9ba5f63cf7.png

Génération de nouveaux échantillons#

L’avantage majeur du VAE sur l’autoencodeur simple est sa capacité à générer de nouvelles données en échantillonnant directement dans l’espace latent.

Hide code cell source

# Génération à partir du prior p(z) = N(0, I)
vae_model.eval()
n_gen = 16

with torch.no_grad():
    z_sampled = torch.randn(n_gen, 2).to(device)
    generated = vae_model.decode(z_sampled)

fig, axes = plt.subplots(2, n_gen // 2, figsize=(14, 5))
for i in range(n_gen):
    ax = axes[i // (n_gen // 2), i % (n_gen // 2)]
    ax.imshow(generated[i].cpu().numpy().reshape(16, 16),
              cmap='gray_r', vmin=0, vmax=1)
    ax.set_title(f"z=({z_sampled[i,0]:.1f}, {z_sampled[i,1]:.1f})", fontsize=8)
    ax.axis('off')
plt.suptitle("Échantillons générés par le VAE (z échantillonné du prior)",
             fontsize=13, y=1.02)
plt.tight_layout()
plt.show()
_images/a2ba269d1aa082794904101857f6394d115bc2245318b6e7286a2ee3343a38e9.png

Espace latent#

Visualisation de l’espace latent 2D#

L’un des atouts d’une dimension latente de 2 est la possibilité de visualiser directement la structure de l’espace latent. Comparons les espaces latents de l’autoencodeur simple et du VAE.

Hide code cell source

# Extraction des codes latents pour l'ensemble de test
ae_model.eval(); vae_model.eval(); dae_model.eval()

with torch.no_grad():
    _, z_ae = ae_model(X_test)
    _, z_dae = dae_model(X_test, add_noise=False)
    _, mu_vae, _, z_vae = vae_model(X_test)

z_ae_np = z_ae.cpu().numpy()
z_dae_np = z_dae.cpu().numpy()
z_vae_np = z_vae.cpu().numpy()
mu_vae_np = mu_vae.cpu().numpy()
y_test_np = y_test.cpu().numpy()

Hide code cell source

fig, axes = plt.subplots(3, 1, figsize=(9, 14))
colors = sns.color_palette("Set2", 5)

for ax, z_np, title in [(axes[0], z_ae_np, "AE simple"),
                          (axes[1], z_dae_np, "AE débruiteur"),
                          (axes[2], mu_vae_np, "VAE (moyennes $\\mu$)")]:
    for c in range(5):
        mask = y_test_np == c
        ax.scatter(z_np[mask, 0], z_np[mask, 1], c=[colors[c]], s=20,
                   alpha=0.6, label=class_names[c], edgecolors='none')
    ax.set_xlabel("$z_1$"); ax.set_ylabel("$z_2$")
    ax.set_title(title, fontsize=12)
    ax.legend(fontsize=8, markerscale=1.5)

plt.suptitle("Comparaison des espaces latents (2D)", fontsize=14, y=1.03)
plt.tight_layout()
plt.show()
_images/6a267ca2bc5acac08de856618888b5e6fce9270d2f68111e70d96fd0ba102e8e.png

Remarque 220

La différence est frappante. L’espace latent de l’AE simple est irrégulier : les classes forment des amas de formes arbitraires, avec des « trous » entre eux. L’espace latent du VAE est au contraire régulier et continu, organisé autour de l’origine (grâce au prior gaussien). Cette régularité est ce qui permet au VAE de générer des échantillons réalistes : n’importe quel point de l’espace latent correspond à une image plausible.

Interpolation dans l’espace latent#

Pour évaluer la continuité de l’espace latent, on peut interpoler linéairement entre deux codes latents et observer les images générées le long du chemin.

Hide code cell source

def interpolate_latent(model, z_start, z_end, n_steps=10, img_size=16,
                       is_vae=False):
    """Interpolation linéaire entre deux points de l'espace latent."""
    model.eval()
    alphas = np.linspace(0, 1, n_steps)
    interpolated = []

    with torch.no_grad():
        for alpha in alphas:
            z = (1 - alpha) * z_start + alpha * z_end
            if is_vae:
                img = model.decode(z)
            else:
                img = model.decode(z)
            interpolated.append(img.cpu().numpy().reshape(img_size, img_size))

    return interpolated, alphas

# Choisir deux exemples de classes différentes
idx_a = np.where(y_test_np == 0)[0][0]  # Barre horizontale
idx_b = np.where(y_test_np == 4)[0][0]  # Cadre

# Interpolation AE simple
_, z_ae_all = ae_model(X_test)
z_a_ae = z_ae_all[idx_a:idx_a+1]
z_b_ae = z_ae_all[idx_b:idx_b+1]
interp_ae, alphas = interpolate_latent(ae_model, z_a_ae, z_b_ae, n_steps=10)

# Interpolation VAE
# Utilisons les moyennes pour l'interpolation (plus stable)
with torch.no_grad():
    _, mu_all, _, _ = vae_model(X_test)
z_a_vae = mu_all[idx_a:idx_a+1]
z_b_vae = mu_all[idx_b:idx_b+1]
interp_vae, _ = interpolate_latent(vae_model, z_a_vae, z_b_vae,
                                    n_steps=10, is_vae=True)

# Affichage
fig, axes = plt.subplots(2, 10, figsize=(16, 4))
for i in range(10):
    axes[0, i].imshow(interp_ae[i], cmap='gray_r', vmin=0, vmax=1)
    axes[0, i].axis('off')
    axes[0, i].set_title(f"$\\alpha$={alphas[i]:.1f}", fontsize=8)
    axes[1, i].imshow(interp_vae[i], cmap='gray_r', vmin=0, vmax=1)
    axes[1, i].axis('off')

axes[0, 0].set_ylabel("AE", fontsize=11)
axes[1, 0].set_ylabel("VAE", fontsize=11)
plt.suptitle("Interpolation dans l'espace latent : AE simple vs VAE",
             fontsize=13, y=1.05)
plt.tight_layout()
plt.show()
_images/421ed998a455bb3f0823c96fbcea4b7aedeee738998da6bdb305911dccdd5d21.png

Remarque 221

L’interpolation du VAE produit des transitions plus fluides et cohérentes. Chaque image intermédiaire ressemble à un motif plausible, tandis que l’AE simple peut produire des artefacts dans les régions de l’espace latent qui ne sont couvertes par aucune donnée d’entraînement. C’est une conséquence directe de la régularisation KL qui impose une structure continue à l’espace latent.

Grille de décodage de l’espace latent#

Une autre visualisation instructive consiste à balayer systématiquement l’espace latent 2D et à décoder chaque point en une image.

Hide code cell source

def plot_latent_grid(model, n_grid=15, img_size=16, z_range=3.0, is_vae=False):
    """Décode une grille régulière de l'espace latent."""
    model.eval()
    z1 = np.linspace(-z_range, z_range, n_grid)
    z2 = np.linspace(-z_range, z_range, n_grid)

    canvas = np.zeros((n_grid * img_size, n_grid * img_size))
    with torch.no_grad():
        for i, z2_val in enumerate(reversed(z2)):
            for j, z1_val in enumerate(z1):
                z = torch.tensor([[z1_val, z2_val]], dtype=torch.float32).to(device)
                if is_vae:
                    img = model.decode(z)
                else:
                    img = model.decode(z)
                canvas[i*img_size:(i+1)*img_size,
                       j*img_size:(j+1)*img_size] = \
                    img.cpu().numpy().reshape(img_size, img_size)

    fig, ax = plt.subplots(figsize=(8, 8))
    ax.imshow(canvas, cmap='gray_r', vmin=0, vmax=1)
    ax.set_xlabel("$z_1$"); ax.set_ylabel("$z_2$")

    # Étiquettes d'axes
    tick_positions = np.linspace(img_size / 2, n_grid * img_size - img_size / 2, 5)
    tick_labels = [f"{v:.1f}" for v in np.linspace(-z_range, z_range, 5)]
    ax.set_xticks(tick_positions); ax.set_xticklabels(tick_labels)
    ax.set_yticks(tick_positions); ax.set_yticklabels(tick_labels[::-1])
    return fig, ax

fig_ae, ax_ae = plot_latent_grid(ae_model, n_grid=12, z_range=4.0)
ax_ae.set_title("AE simple — Grille de décodage de l'espace latent", fontsize=12)
plt.tight_layout()
plt.show()

fig_vae, ax_vae = plot_latent_grid(vae_model, n_grid=12, z_range=3.0, is_vae=True)
ax_vae.set_title("VAE — Grille de décodage de l'espace latent", fontsize=12)
plt.tight_layout()
plt.show()
_images/acd80f32f312245c7b6f33ca651238466f6a5c9c1bd515a7251b2d7ea1658363.png _images/1d880489a8cc50c1b1f59159645b961e820e56ece42e707291ca23c5fb3f42bb.png

Exemple 25 (Lecture de la grille latente)

Dans la grille du VAE, on observe des transitions progressives entre les classes de motifs lorsqu’on se déplace dans l’espace latent. Par exemple, une barre horizontale se transforme graduellement en croix lorsqu’on se déplace le long d’un certain axe. Cette propriété de continuité sémantique est absente de l’AE simple, où le passage d’une région à une autre peut produire des images incohérentes.

Applications#

Détection d’anomalies#

L’erreur de reconstruction d’un autoencodeur peut servir de score d’anomalie. Un échantillon « normal » sera bien reconstruit (faible erreur), tandis qu’une anomalie — absente des données d’entraînement — produira une reconstruction de mauvaise qualité (forte erreur). Cette approche rejoint les méthodes étudiées au chapitre 14.

Hide code cell source

# Simulation de données anomales (motifs non vus à l'entraînement)
n_anomalies = 50
anomalies = []
for _ in range(n_anomalies):
    img = np.zeros((16, 16))
    # Cercle approximatif (motif non vu)
    cx, cy, r = 8, 8, np.random.randint(3, 6)
    for i in range(16):
        for j in range(16):
            if abs(np.sqrt((i - cy)**2 + (j - cx)**2) - r) < 1.5:
                img[i, j] = 1.0
    img += 0.05 * np.random.randn(16, 16)
    img = np.clip(img, 0, 1)
    anomalies.append(img)

X_anomalies = torch.tensor(np.array(anomalies, dtype=np.float32).reshape(-1, input_dim)).to(device)

# Calcul des erreurs de reconstruction
ae_model.eval()
with torch.no_grad():
    x_hat_normal, _ = ae_model(X_test)
    x_hat_anomal, _ = ae_model(X_anomalies)

    errors_normal = ((X_test - x_hat_normal) ** 2).mean(dim=1).cpu().numpy()
    errors_anomal = ((X_anomalies - x_hat_anomal) ** 2).mean(dim=1).cpu().numpy()

fig, axes = plt.subplots(2, 1, figsize=(9, 8))

# Distribution des erreurs
axes[0].hist(errors_normal, bins=30, alpha=0.6, color='#55A868',
             label='Normal', density=True)
axes[0].hist(errors_anomal, bins=15, alpha=0.6, color='#C44E52',
             label='Anomalie', density=True)
axes[0].set_xlabel("Erreur de reconstruction (MSE)")
axes[0].set_ylabel("Densité")
axes[0].set_title("Distribution des erreurs de reconstruction")
axes[0].legend()

# Exemples d'anomalies et leurs reconstructions
n_show = 4
with torch.no_grad():
    x_hat_anom, _ = ae_model(X_anomalies[:n_show])

for i in range(n_show):
    ax_orig = fig.add_axes([0.56 + i * 0.11, 0.55, 0.09, 0.35])
    ax_orig.imshow(X_anomalies[i].cpu().numpy().reshape(16, 16),
                   cmap='gray_r', vmin=0, vmax=1)
    ax_orig.axis('off')
    if i == 0:
        ax_orig.set_title("Anomalie", fontsize=9)

    ax_rec = fig.add_axes([0.56 + i * 0.11, 0.12, 0.09, 0.35])
    ax_rec.imshow(x_hat_anom[i].cpu().numpy().reshape(16, 16),
                  cmap='gray_r', vmin=0, vmax=1)
    ax_rec.axis('off')
    if i == 0:
        ax_rec.set_title("Reconstruit", fontsize=9)

plt.show()

print(f"Erreur moyenne (normal)  : {errors_normal.mean():.6f}")
print(f"Erreur moyenne (anomalie): {errors_anomal.mean():.6f}")
print(f"Ratio anomalie/normal    : {errors_anomal.mean() / errors_normal.mean():.1f}x")
_images/864bb152961fda5e2d6c63353eac65ccf4b87374a7cc4a391c6ecb1eb7e2e994.png
Erreur moyenne (normal)  : 0.014826
Erreur moyenne (anomalie): 0.213865
Ratio anomalie/normal    : 14.4x

Compression de données#

Un autoencodeur réalise une compression avec perte (lossy compression). Le taux de compression est le rapport entre la dimension originale et la dimension latente.

Hide code cell source

# Comparaison des taux de compression et de la qualité
latent_dims_to_test = [2, 4, 8, 16, 32]
compression_results = {}

for ldim in latent_dims_to_test:
    model_tmp = Autoencoder(input_dim, hidden_dims=[128, 64], latent_dim=ldim).to(device)
    # Entraînement rapide
    optimizer_tmp = optim.Adam(model_tmp.parameters(), lr=1e-3)
    for epoch in range(10):
        model_tmp.train()
        for batch_x, _ in train_loader:
            x_hat, _ = model_tmp(batch_x)
            loss = F.mse_loss(x_hat, batch_x)
            optimizer_tmp.zero_grad(); loss.backward(); optimizer_tmp.step()
    # Évaluation
    model_tmp.eval()
    with torch.no_grad():
        x_hat_test, _ = model_tmp(X_test)
        mse = F.mse_loss(x_hat_test, X_test).item()
    ratio = input_dim / ldim
    compression_results[ldim] = {'mse': mse, 'ratio': ratio}
    print(f"  dim_latente={ldim:3d} — taux={ratio:6.1f}:1 — MSE={mse:.6f}")

fig, ax1 = plt.subplots(figsize=(8, 4.5))
dims = list(compression_results.keys())
mses = [compression_results[d]['mse'] for d in dims]
ratios = [compression_results[d]['ratio'] for d in dims]

ax1.plot(dims, mses, 'o-', color='#4C72B0', linewidth=2, markersize=8, label='MSE')
ax1.set_xlabel("Dimension latente $k$")
ax1.set_ylabel("MSE de reconstruction", color='#4C72B0')

ax2 = ax1.twinx()
ax2.bar(dims, ratios, alpha=0.3, color='#DD8452', width=2, label='Taux de compression')
ax2.set_ylabel("Taux de compression ($d/k$)", color='#DD8452')

ax1.set_title("Compromis compression / fidélité de reconstruction")
ax1.legend(loc='upper right'); ax2.legend(loc='center right')
plt.tight_layout()
plt.show()
  dim_latente=  2 — taux= 128.0:1 — MSE=0.020483
  dim_latente=  4 — taux=  64.0:1 — MSE=0.017652
  dim_latente=  8 — taux=  32.0:1 — MSE=0.020959
  dim_latente= 16 — taux=  16.0:1 — MSE=0.015935
  dim_latente= 32 — taux=   8.0:1 — MSE=0.016754
_images/5b1164d6008ffe75eca125fa4367d9a0cf28261a11de30a662efd662349c038c.png

Remarque 222

On observe le compromis fondamental de la compression : augmenter la dimension latente améliore la reconstruction mais réduit le taux de compression. Le choix de \(k\) dépend de l’application : pour la visualisation, \(k = 2\) ou \(3\) est privilégié ; pour la compression ou le préentraînement, des valeurs plus élevées préservent davantage d’information.

Comparaison avec l’ACP#

Au chapitre 12, nous avons vu que l’ACP projette les données sur les axes de plus grande variance. Comparons l’ACP avec l’autoencodeur sur les mêmes données.

Hide code cell source

from sklearn.decomposition import PCA

# ACP sur les données d'entraînement
X_train_np = X_train.cpu().numpy()
X_test_np = X_test.cpu().numpy()

pca = PCA(n_components=2)
pca.fit(X_train_np)
X_test_pca = pca.transform(X_test_np)
X_test_pca_recon = pca.inverse_transform(X_test_pca)

# MSE de l'ACP
mse_pca = np.mean((X_test_np - X_test_pca_recon) ** 2)

# MSE de l'AE (déjà calculée)
ae_model.eval()
with torch.no_grad():
    x_hat_ae, z_ae_vis = ae_model(X_test)
    mse_ae = F.mse_loss(x_hat_ae, X_test).item()

# MSE du VAE
vae_model.eval()
with torch.no_grad():
    x_hat_vae, _, _, _ = vae_model(X_test)
    mse_vae = F.mse_loss(x_hat_vae, X_test).item()

print(f"MSE (2 composantes) :")
print(f"  ACP             : {mse_pca:.6f}")
print(f"  AE simple       : {mse_ae:.6f}")
print(f"  VAE             : {mse_vae:.6f}")

# Visualisation comparative des reconstructions
n_comp = 6
fig, axes = plt.subplots(4, n_comp, figsize=(2 * n_comp, 9))
row_labels = ["Original", "ACP", "AE", "VAE"]

for i in range(n_comp):
    axes[0, i].imshow(X_test_np[i].reshape(16, 16),
                      cmap='gray_r', vmin=0, vmax=1)
    axes[0, i].axis('off')
    axes[0, i].set_title(class_names[y_test_np[i]], fontsize=9)

    axes[1, i].imshow(X_test_pca_recon[i].reshape(16, 16),
                      cmap='gray_r', vmin=0, vmax=1)
    axes[1, i].axis('off')

    axes[2, i].imshow(x_hat_ae[i].cpu().numpy().reshape(16, 16),
                      cmap='gray_r', vmin=0, vmax=1)
    axes[2, i].axis('off')

    axes[3, i].imshow(x_hat_vae[i].cpu().numpy().reshape(16, 16),
                      cmap='gray_r', vmin=0, vmax=1)
    axes[3, i].axis('off')

for r, label in enumerate(row_labels):
    axes[r, 0].set_ylabel(label, fontsize=11)

plt.suptitle("Reconstruction avec 2 dimensions : ACP vs AE vs VAE", fontsize=13, y=1.02)
plt.tight_layout()
plt.show()
MSE (2 composantes) :
  ACP             : 0.029335
  AE simple       : 0.014826
  VAE             : 0.021678
_images/0301131b0b8d23e5938a4e9701ad8b25ea83e139610b430b1c903a84ae84add3.png

Remarque 223

L’autoencodeur non-linéaire surpasse systématiquement l’ACP pour un même nombre de dimensions latentes, car il peut capturer des structures non-linéaires dans les données. Le VAE a une reconstruction légèrement inférieure à l’AE simple en raison de la régularisation KL, mais son espace latent est beaucoup plus structuré et permet la génération.

Tableau récapitulatif des variantes#

Hide code cell source

# Tableau de synthèse
import pandas as pd

comparison = pd.DataFrame({
    'Modèle': ['AE simple', 'AE débruiteur', 'VAE', 'ACP (linéaire)'],
    'Type': ['Déterministe', 'Déterministe', 'Probabiliste', 'Déterministe'],
    'Génératif': ['Non', 'Non', 'Oui', 'Non'],
    'Régularisation': ['Bottleneck', 'Bruit + Bottleneck', 'KL + Bottleneck',
                       'Orthogonalité'],
    'Espace latent': ['Irrégulier', 'Robuste', 'Continu, régulier',
                      'Linéaire'],
    'Forces': ['Simple, rapide', 'Robuste au bruit', 'Génération, interpolation',
               'Interprétable, analytique'],
})
print(comparison.to_string(index=False))
        Modèle         Type Génératif     Régularisation     Espace latent                    Forces
     AE simple Déterministe       Non         Bottleneck        Irrégulier            Simple, rapide
 AE débruiteur Déterministe       Non Bruit + Bottleneck           Robuste          Robuste au bruit
           VAE Probabiliste       Oui    KL + Bottleneck Continu, régulier Génération, interpolation
ACP (linéaire) Déterministe       Non      Orthogonalité          Linéaire Interprétable, analytique

Extensions et variantes modernes#

Le problème du flou dans les VAE#

Le terme de reconstruction MSE du VAE tend à produire des images floues, car il pénalise la distance pixel par pixel sans considérer la structure perceptuelle. Plusieurs solutions ont été proposées.

Définition 257 (Beta-VAE)

Le \(\beta\)-VAE (Higgins et al., 2017) pondère le terme KL par un hyperparamètre \(\beta > 0\) :

\[\mathcal{L}_{\beta\text{-VAE}} = \mathbb{E}_{q_\phi}\left[\log p_\theta(\mathbf{x} \mid \mathbf{z})\right] - \beta \cdot D_{\text{KL}}\left(q_\phi(\mathbf{z} \mid \mathbf{x}) \| p(\mathbf{z})\right)\]
  • \(\beta > 1\) : régularisation plus forte, espace latent plus disentangled (facteurs indépendants).

  • \(\beta < 1\) : meilleure reconstruction, espace latent moins régulier.

  • \(\beta = 1\) : VAE standard.

Hide code cell source

# Illustration de l'effet de beta sur le VAE
betas = [0.1, 1.0, 4.0]
beta_results = {}

for beta in betas:
    model_beta = VAE(input_dim, hidden_dims=[128, 64], latent_dim=2).to(device)
    optimizer_beta = optim.Adam(model_beta.parameters(), lr=1e-3)

    for epoch in range(10):
        model_beta.train()
        for batch_x, _ in train_loader:
            x_hat, mu, log_var, _ = model_beta(batch_x)
            recon = F.mse_loss(x_hat, batch_x, reduction='sum')
            kl = -0.5 * torch.sum(1 + log_var - mu.pow(2) - log_var.exp())
            loss = recon + beta * kl
            optimizer_beta.zero_grad(); loss.backward(); optimizer_beta.step()

    model_beta.eval()
    with torch.no_grad():
        x_hat_b, mu_b, _, _ = model_beta(X_test)
        mse_b = F.mse_loss(x_hat_b, X_test).item()
    beta_results[beta] = {'model': model_beta, 'mse': mse_b}
    print(f"  beta={beta:.1f} — MSE={mse_b:.6f}")

# Comparaison des espaces latents
fig, axes = plt.subplots(1, len(betas), figsize=(5 * len(betas), 4.5))
for idx, beta in enumerate(betas):
    model_b = beta_results[beta]['model']
    model_b.eval()
    with torch.no_grad():
        _, mu_b, _, _ = model_b(X_test)
    mu_np = mu_b.cpu().numpy()
    for c in range(5):
        mask = y_test_np == c
        axes[idx].scatter(mu_np[mask, 0], mu_np[mask, 1], c=[colors[c]], s=15,
                          alpha=0.6, label=class_names[c], edgecolors='none')
    axes[idx].set_title(f"$\\beta = {beta}$ (MSE={beta_results[beta]['mse']:.4f})",
                        fontsize=11)
    axes[idx].set_xlabel("$z_1$"); axes[idx].set_ylabel("$z_2$")
    if idx == 0:
        axes[idx].legend(fontsize=7, markerscale=1.5)

plt.suptitle("Effet de $\\beta$ sur l'espace latent du VAE", fontsize=14, y=1.03)
plt.tight_layout()
plt.show()
  beta=0.1 — MSE=0.021427
  beta=1.0 — MSE=0.023927
  beta=4.0 — MSE=0.048923
_images/c4660f0a3e4acbc0eea0d0db3ec34913180061985ca95e4463bc5afbc8f4ea29.png

Remarque 224

Avec \(\beta > 1\), les clusters deviennent plus compacts et mieux séparés, mais la qualité de la reconstruction diminue. C’est le compromis fondamental du \(\beta\)-VAE : un espace latent plus désentrelacé (disentangled) au prix d’une reconstruction plus floue. Le choix de \(\beta\) dépend de l’objectif : génération (\(\beta \approx 1\)), apprentissage de représentations interprétables (\(\beta > 1\)), ou reconstruction fidèle (\(\beta < 1\)).

Autoencodeurs convolutifs#

Pour les données d’images, il est naturel de remplacer les couches denses par des couches convolutives (chapitre 19). L’encodeur utilise des convolutions avec stride pour réduire la résolution, tandis que le décodeur utilise des convolutions transposées (transposed convolutions) pour la reconstruire.

Définition 258 (Autoencodeur convolutif)

Un autoencodeur convolutif utilise :

  • Encodeur : couches de convolution 2D avec stride \(> 1\) (sous-échantillonnage progressif) :

\[\mathbf{h}^{(l)} = \text{ReLU}\left(\text{Conv2D}(\mathbf{h}^{(l-1)};\, W^{(l)},\, s=2)\right)\]
  • Décodeur : couches de convolution transposée (ConvTranspose2D) avec stride \(> 1\) (sur-échantillonnage progressif) :

\[\hat{\mathbf{h}}^{(l)} = \text{ReLU}\left(\text{ConvTranspose2D}(\hat{\mathbf{h}}^{(l-1)};\, W^{(l)},\, s=2)\right)\]

Le goulot d’étranglement est un tenseur de petite résolution spatiale et grand nombre de canaux, souvent aplati en vecteur pour la couche latente.

Hide code cell source

class ConvAutoencoder(nn.Module):
    """Autoencodeur convolutif pour images 16x16."""

    def __init__(self, latent_dim=2):
        super().__init__()
        # Encodeur : 1x16x16 -> 16x8x8 -> 32x4x4 -> latent
        self.enc_conv = nn.Sequential(
            nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1),  # -> 16x8x8
            nn.ReLU(),
            nn.Conv2d(16, 32, kernel_size=3, stride=2, padding=1),  # -> 32x4x4
            nn.ReLU(),
        )
        self.enc_fc = nn.Linear(32 * 4 * 4, latent_dim)

        # Décodeur : latent -> 32x4x4 -> 16x8x8 -> 1x16x16
        self.dec_fc = nn.Linear(latent_dim, 32 * 4 * 4)
        self.dec_conv = nn.Sequential(
            nn.ConvTranspose2d(32, 16, kernel_size=3, stride=2,
                               padding=1, output_padding=1),  # -> 16x8x8
            nn.ReLU(),
            nn.ConvTranspose2d(16, 1, kernel_size=3, stride=2,
                               padding=1, output_padding=1),  # -> 1x16x16
            nn.Sigmoid(),
        )

    def encode(self, x):
        x = x.view(-1, 1, 16, 16)
        h = self.enc_conv(x)
        h = h.view(h.size(0), -1)
        return self.enc_fc(h)

    def decode(self, z):
        h = F.relu(self.dec_fc(z))
        h = h.view(-1, 32, 4, 4)
        return self.dec_conv(h).view(-1, 256)

    def forward(self, x):
        z = self.encode(x)
        x_hat = self.decode(z)
        return x_hat, z

conv_ae = ConvAutoencoder(latent_dim=2).to(device)
n_params_conv = sum(p.numel() for p in conv_ae.parameters())
n_params_fc = sum(p.numel() for p in ae_model.parameters())
print(f"Paramètres — AE dense : {n_params_fc:,} | AE convolutif : {n_params_conv:,}")
Paramètres — AE dense : 82,818 | AE convolutif : 12,131

Hide code cell source

# Entraînement de l'AE convolutif
print("Entraînement de l'autoencodeur convolutif :")
conv_history = train_autoencoder(conv_ae, train_loader, n_epochs=15, lr=1e-3)

# Comparaison
conv_ae.eval()
with torch.no_grad():
    x_hat_conv, _ = conv_ae(X_test)
    mse_conv = F.mse_loss(x_hat_conv, X_test).item()

print(f"\nMSE (test) — AE dense : {mse_ae:.6f} | AE convolutif : {mse_conv:.6f}")
plot_reconstructions(conv_ae, X_test, y_test,
                     title="Autoencodeur convolutif — Reconstructions")
Entraînement de l'autoencodeur convolutif :
MSE (test) — AE dense : 0.014826 | AE convolutif : 0.024243
_images/66b69f1e25babdd45af3bf8fffd0aa0a7c5225a915bdeb7b2cc7ab3c79eff526.png

Remarque 225

L’autoencodeur convolutif exploite la structure spatiale des images grâce au partage de poids et à la connectivité locale (chapitre 19). Pour des images de plus grande taille (par exemple \(64 \times 64\) ou \(256 \times 256\)), les architectures convolutives deviennent indispensables car les couches denses ne sont plus praticables en raison du nombre de paramètres.

Résumé#

Ce chapitre a présenté les autoencodeurs, une famille de réseaux de neurones entraînés à reconstruire leurs entrées à travers un goulot d’étranglement, apprenant ainsi des représentations comprimées des données.

  1. L”autoencodeur simple (vanilla) compresse les données via un encodeur et les reconstruit via un décodeur symétrique. Avec des activations non-linéaires, il dépasse l’ACP (chapitre 12) en capturant des structures non-linéaires.

  2. L”autoencodeur débruiteur (DAE) améliore la robustesse des représentations en s’entraînant à reconstruire les entrées propres à partir de versions corrompues par du bruit. Il agit comme une forme de régularisation qui force le réseau à capturer la structure essentielle des données.

  3. L”autoencodeur variationnel (VAE) introduit une structure probabiliste dans l’espace latent en encodant chaque entrée comme une distribution gaussienne plutôt qu’un point. La perte ELBO combine un terme de reconstruction et une divergence KL qui régularise l’espace latent.

  4. L”astuce de reparamétrisation permet de rétropropager le gradient à travers l’opération d’échantillonnage, rendant l’entraînement du VAE possible par descente de gradient (chapitre 17).

  5. L”espace latent du VAE est continu et régulier, ce qui permet des interpolations fluides et la génération de nouveaux échantillons en décodant des points échantillonnés du prior.

  6. Les autoencodeurs trouvent des applications en compression, débruitage, détection d’anomalies (chapitre 14) et apprentissage de représentations. Les variantes convolutives sont préférées pour les données d’images.

  7. Le \(\beta\)-VAE offre un contrôle explicite du compromis entre qualité de reconstruction et régularité de l’espace latent, favorisant l’apprentissage de représentations désentrelacées.

Remarque 226

Les autoencodeurs constituent une brique fondamentale de l’apprentissage profond moderne. Le cadre variationnel a ouvert la voie à toute une famille de modèles génératifs profonds, incluant les réseaux adversariaux génératifs (GAN, chapitre 22), les flux normalisants (normalizing flows) et les récents modèles de diffusion (diffusion models) qui dominent aujourd’hui la génération d’images. Les idées centrales — espace latent structuré, reparamétrisation, compromis reconstruction-régularisation — réapparaîtront tout au long de la suite de cet ouvrage.