Dans le premier article, nous avons posé les bases : à quoi servent les auto-encodeurs, comment un encodeur comprime une image dans un espace latent, et comment un décodeur tente ensuite de la reconstruire. Aujourd’hui, on sort les tournevis. L’objectif est simple : écrire un auto-encodeur convolutif en PyTorch, l’entraîner sur MNIST, inspecter son espace latent et réaliser un morphing entre deux chiffres.

Le notebook complet et le dépôt GitHub de la chaîne (organisation Artificialis) contiennent tout ce qu’il faut pour reproduire l’expérience chez vous.
Mettre en place l’environnement
On commence par les classiques : pytorch et torchvision pour le modèle et les données, NumPy pour la manipulation de tableaux, Matplotlib pour l’affichage, et scikit-learn pour t-SNE. On fixe aussi un seed pour rendre l’entraînement reproductible, et on détecte le GPU si vous en avez un.
On commence proprement avec un environnement Python isolé. L’outil le plus simple (et compatible CUDA) est conda. Créons un environnement pour notre projet.
conda create -n encoders python=3.11 -y && conda activate encoder(Remarque : veillez à activer le même nom d’environnement que celui créé. Si vous avez encoders, activez encoders.)
On peut maintenant installer pytorch + CUDA (NVIDIA)
conda install torch torchvisionSi vous avez un GPU un peu récent et que vous avez par la suite une erreur smp_120, vous pouvez installer la version nightly:
pip3 install --pre torch torchvision --index-url https://download.pytorch.org/whl/nightly/cu128Nous avons encore besoin d’autres briques pour notre projet (affichage, t-SNE, Jupyter) :
conda install -y matplotlib scikit-learnOn vérifie que tout est bien vu (GPU, version, MPS sur Mac). Si “CUDA available” retourne systématiquement False sur une machine avec GPU NVidia, essayez de (ré)installer vos drivers système, puis relancez ce test.
import torch
print("PyTorch:", torch.<strong>version</strong>)
print("CUDA available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("CUDA device count:", torch.cuda.device_count())
print("Current device:", torch.cuda.get_device_name(torch.cuda.current_device()))
print(
"MPS (Apple) available:", getattr(torch.backends, "mps", None) and
torch.backends.mps.is_available()
)
# end ifCommençons par importer les packages nécessaires (torch, numpy, torchvision, matplotlib, sklearn).
import os, random
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
import torchvision
from torchvision import datasets, transforms, utils
import matplotlib.pyplot as plt
from sklearn.manifold import TSNEOn va ensuite initialiser les générateurs de nombres aléatoires afin d’avoir une expérience reproductible.
random.seed(s)
np.random.seed(s)
torch.manual_seed(s)
torch.cuda.manual_seed_all(s)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
set_seed(42)Si vous voyez une erreur liée à s ou à set_seed, c’est simplement que votre helper n’a pas encore été défini ; dans ce cas, gardez l’appel set_seed(42) et/ou définissez la fonction plus haut dans le notebook.
Et pour finir notre initialisation, on va choisir un GPU, si on en a un a disposition, afin de l’utiliser par la suite.
device = (
"cuda" if torch.cuda.is_available() else
(
"mps" if getattr(torch.backends, "mps", None) and
torch.backends.mps.is_available() else "cpu"
)
)
device = torch.device(device)
print("Device:", device)C’est tout pour cette partie : à ce stade, votre environnement est opérationnel, les bibliothèques sont installées, les imports sont prêts et le device (GPU/MPS/CPU) est sélectionné. Vous pouvez enchaîner avec la définition du modèle (encodeur/décodeur) et la mise en place des couches de l’auto-encodeur.
Construire l’auto-encodeur
L’architecture que nous implémentons est volontairement compacte et pédagogique. Elle repose sur des convolutions décalées pour réduire progressivement la résolution côté encodeur, et des convolutions transposées côté décodeur pour remonter à la taille d’origine. Le tout est régularisé par de la BatchNorm et animé par une activation LeakyReLU — des briques simples qui convergent vite sur MNIST.
Encodeur : de l’image au latent
L’encodeur reçoit une image en niveau de gris, donc contenant un seul canal, de taille 28×28. Il applique une série de blocs de convolution 2D (avec stride=2), suivis de normalisation par batch, puis d’une fonction d’activation LeakyReLU, pour compresser l’information. Le nombre de filtres par couche va lui de 32 à 64, puis 128.
On peut créer les différentes couches avec une simple boucle for suivi d’un Sequential:
for out_channels in hidden_channels:
encoder_layers.extend([
nn.Conv2d(
in_channels,
out_channels,
kernel_size=3,
stride=2,
padding=1
),
nn.BatchNorm2d(out_channels),
nn.LeakyReLU(0.2, inplace=True)
])
in_channels = out_channels
# end for
self.encoder_conv = nn.Sequential(*encoder_layers)La sortie de la dernière convolution est aplaties grâce à une couche Flatten puis projetée par une couche linéaire (Linear) dans un vecteur latent de dimension latent_dim.
# Fully connected layers for latent space
self.encoder_fc = nn.Sequential(
nn.Flatten(),
nn.Linear(flattened_dim, latent_dim)
)C’est ce “goulot d’étranglement” qui force le réseau à apprendre une représentation compressée et significative de l’image : sept nombres pour résumer un chiffre manuscrit.
Décodeur : du latent à la reconstruction
Le décodeur fait le chemin inverse. Il part du vecteur latent z (de taille latent_dim) qu’il envoie dans une couche linéaire Flatten pour reconstituer un “bloc” de représentations de taille flattened_dim,
# Fully connected layers from latent space
self.decoder_fc = nn.Sequential(
nn.Linear(latent_dim, flattened_dim),
nn.LeakyReLU(0.2, inplace=True)
)Il déroule ensuite une séquence de couche convolutives transposées 2D, de BatchNorm, et de LeakyReLU comme fonction d’activation. Ce suite de couches permet de remonter la résolution jusqu’à la taille d’origine.
# Reverse the hidden channels for the decoder
for out_channels in reversed(hidden_channels[:-1]):
decoder_layers.extend([
nn.ConvTranspose2d(
in_channels,
out_channels,
kernel_size=3,
stride=2,
padding=1,
output_padding=1
),
nn.BatchNorm2d(out_channels),
nn.LeakyReLU(0.2, inplace=True)
])
in_channels = out_channels
# end for
# Final layer to output the reconstructed image
decoder_layers.extend([
nn.ConvTranspose2d(
in_channels,
input_channels,
kernel_size=3,
stride=2,
padding=1,
output_padding=1
),
])
# Add output activation
activation_cls = load_class(output_activation)
decoder_layers.append(activation_cls())
self.decoder_conv = nn.Sequential(*decoder_layers)Petit piège classique : selon la combinaison kernel/stride/padding des convolutions transposées, la taille des images intermédiaires dans chaque couche va être multipliée par deux, ce qui va nous donner une taille d’image à la sortie de 32×32 au lieu de 28×28. Pour coller à MNIST, on recadre au centre (centre-crop) la sortie en 28×28 dans la fonction forward grâce aux arguments target_height et target_width.
def forward(self, z, target_height=None, target_width=None):
# Project from latent space and reshape
batch_size = z.size(0)
x = self.decoder_fc(z)
x = x.view(batch_size, self.feature_channels, self.feature_height, self.feature_width)
# Apply transposed convolutional layers
reconstruction = self.decoder_conv(x)
# Crop to match target dimensions if provided
if target_height is not None and target_width is not None:
if reconstruction.size(2) != target_height or reconstruction.size(3) != target_width:
# Calculate cropping boundaries
h_diff = reconstruction.size(2) - target_height
w_diff = reconstruction.size(3) - target_width
# Ensure we can crop (output must be larger than target)
if h_diff >= 0 and w_diff >= 0:
h_start = h_diff // 2
w_start = w_diff // 2
# Crop the reconstruction to match target dimensions
reconstruction = reconstruction[:, :, h_start:h_start + target_height, w_start:w_start + target_width]
# end if
# end if
# end if
return reconstruction
# end forwardL’idée est simple : à partir d’un vecteur latent z, on veut reconstruire une image. La première étape consiste donc à projeter ce vecteur latent dans un espace plus grand via une couche entièrement connectée (decoder_fc),
x = self.decoder_fc(z)puis à le remodeler (view) pour obtenir un tenseur de la bonne forme : un lot (batch) de cartes de caractéristiques, avec un certain nombre de canaux (feature_channels) et une hauteur/largeur de départ (feature_height, feature_width).
x = x.view(batch_size, self.feature_channels, self.feature_height, self.feature_width)Ensuite, ce tenseur passe par la série de couches de convolution transposée (decoder_conv) crées juste avant, qui servent à « remonter » en résolution et à produire une image reconstruite.
# Apply transposed convolutional layers
reconstruction = self.decoder_conv(x)La dernière partie du code gère le problème des dimensions finales de la reconstruction qui peuvent légèrement différer de celles attendues. Si des dimensions cibles (target_height, target_width) sont fournies, la fonction vérifie si la sortie est plus grande que nécessaire.
if target_height is not None and target_width is not None:
if reconstruction.size(2) != target_height or reconstruction.size(3) != target_width:Si c’est le cas, elle calcule combien de pixels doivent être coupés de chaque côté, et effectue un recadrage centré pour ajuster exactement la taille.
# Calculate cropping boundaries
h_diff = reconstruction.size(2) - target_height
w_diff = reconstruction.size(3) - target_width
# Ensure we can crop (output must be larger than target)
if h_diff >= 0 and w_diff >= 0:
h_start = h_diff // 2
w_start = w_diff // 2
# Crop the reconstruction to match target dimensions
reconstruction = reconstruction[:, :, h_start:h_start + target_height, w_start:w_start + target_width]
# end ifAinsi, le modèle garantit que l’image reconstruite a les dimensions souhaitées, même en présence de petites différences liées aux convolutions transposées ou aux hyper-paramètres choisis.
Auto-encodeur : assembler les deux moitiés
L’auto-encodeur n’est que la composition d’un encodeur et d’un décodeur, il nous faut donc assembler les deux dans une seule et même entité.
On crée donc une classe ConvAutoEncoder et on utilise le constructeur pour créer les deux parties:
# Create the encoder
self.encoder = ConvEncoder(
input_channels=input_channels,
input_size=input_size,
hidden_channels=hidden_channels,
latent_dim=latent_dim
)
# Create the decoder
self.decoder = ConvDecoder(
input_channels=input_channels,
hidden_channels=hidden_channels,
latent_dim=latent_dim,
feature_channels=self.encoder.feature_channels,
feature_height=self.encoder.feature_height,
feature_width=self.encoder.feature_width,
output_activation=output_activation
)Notre fonction forward va renvoyer à la fois la reconstruction et le latent pour que l’entraînement et les visualisations restent simples.
def forward(self, x):
# Store original input shape for later cropping
self.original_height = x.size(2)
self.original_width = x.size(3)
# Encode
latent = self.encode(x)
# Decode with target dimensions matching the input
reconstruction = self.decode(
latent,
target_height=self.original_height,
target_width=self.original_width
)
return reconstruction, latent
# end __forward__Pour simplifier le code de cette fonction, on a introduit deux méthodes, encode et decode. encode prend une entrée pour l’encoder dans un latent z,
def encode(self, x):
return self.encoder(x)
# end encodeAlors que decode logiquement, prend un latent z pour reconstruire l’image à la taille souhaitée.
def decode(self, z, target_height=None, target_width=None):
# Use stored original dimensions if available and no target dimensions provided
if target_height is None and hasattr(self, 'original_height'):
target_height = self.original_height
# end if
if target_width is None and hasattr(self, 'original_width'):
target_width = self.original_width
# end if
return self.decoder(z, target_height=target_height, target_width=target_width)
# end decodeOn peut alors créer notre réseau de neurone en créant une instance de notre classe ConvAutoEncoder et le transférer sur le GPU grâce à to(device).
model = ConvAutoEncoder(
input_channels=1, # MNIST has 1 channel
input_size=28, # MNIST images are 28x28
hidden_channels=[32, 64, 128],
latent_dim=7,
output_activation='torch.nn.Sigmoid',
).to(device)Les données : MNIST sans chichis
Le package TorchVision fournit le dataset MNIST prêt à l’emploi. On va télécharger le dataset (ou le charger si il est déjà sur le disque), on applique une simple transformation ToTensor (qui transforme une image PIL en tenseur manipulable par notre modèle).
# Define the transformation
transform = transforms.Compose([
transforms.ToTensor(),
])
# Download and load the training data
train_dataset = datasets.MNIST(
root='./data',
train=True,
download=True,
transform=transform
)
# Download and load the test data
test_dataset = datasets.MNIST(
root='./data',
train=False,
download=True,
transform=transform
)On crée ensuite deux DataLoader : un pour l’entraînement (avec shuffle=True) et un pour l’évaluation (sans mélange, pour garder des comparaisons cohérentes dans le temps). Un batch_size de 512 fonctionne très bien sur un GPU de moyenne gamme.
# Create data loader for training
kwargs = {'num_workers': 4, 'pin_memory': True} if use_cuda else {}
train_loader = DataLoader(
dataset=train_dataset,
batch_size=batch_size,
shuffle=True
)
# Create data loader for testing
test_loader = DataLoader(
dataset=test_dataset,
batch_size=batch_size,
shuffle=False,
)Ici on utilise num_workers=4, ce qui veut dire que l’on va créer 4 threads qui vont préparer les échantillons pour créer les batchs fournis au modèle. Cela permet de créer les échantillons assez rapidement pour en avoir toujours assez à disposition pour l’apprentissage.
Entraîner le modèle
L’entraînement est direct : on cherche à minimiser l’erreur de reconstruction entre l’image d’entrée $x$ et sa reconstruction $\hat{x}$. Comme indiqué dans la première partie, on utilise la MSE comme fonction de loss :
$$L(x,\hat{x}) \;=\; \|x – \hat{x}\|_2^2$$
Ce qui se traduit en pytorch par la classe MSELoss:
criterion = torch.nn.MSELoss()Côté optimisation, on utilise l’algorithme Adam avec un taux d’apprentissage autour de $10^{-3}$ qui fait parfaitement l’affaire sur cinquantaine d’époques. On passe les paramètres du modèles (ce que doit optimiser l’algorithme) au constructeur de optim.Adam.
optimizer = optim.Adam(
model.parameters(),
lr=0.001,
weight_decay=0.0004
)La boucle d’apprentissage suit le schéma canonique :
- Passage du modèle en mode entraînement:
model.train() - passage des batchs sur le bon device (CPU ou GPU),
- remise à zéro des gradients,
- propagation avant (forward),
- calcul de la loss (en aplatissant $
x$et $\hat{x}$ pour la MSE), - rétro-propagation (backward),
- et mise à jour des paramètres du modèle avec
optimizer.step().
Ce qui nous donne en langage Python, la partie suivante, que l’on va déchiffrer ensemble.
model.train()
for epoch in range(1, epochs + 1):
# Iterate through training
for data_i, (data, _) in enumerate(train_loader):
data = data.to(device)
# Zero the gradients
optimizer.zero_grad()
# Forward pass
recon_batch, _ = model(data)
# Flatten the input if it's not already flattened
if len(data.shape) > 2:
data = data.view(data.size(0), -1)
# end if
# Flatten the output if it's not already flattened
if len(recon_batch.shape) > 2:
recon_batch = recon_batch.reshape(recon_batch.size(0), -1)
# end if
# Calculate loss
loss = criterion(recon_batch, data)
# Backward pass
loss.backward()
# Update weights
optimizer.step()
# end for data
# end for epoch
model.eval()On met le modèle en mode entraînement avant la boucle principale qui va itérer pour atteindre le nombre d’epochs souhaitées.
model.train()
for epoch in range(1, epochs + 1):La deuxième boucle itère sur le jeu d’entraînement. Le dataloader d’entraînement fournissant des batchs d’échantillons (data) choisis aléatoirement.
# Iterate through training
for data_i, (data, _) in enumerate(train_loader):Les échantillons sont transférés sur le device de calcul.
data = data.to(device)On met les gradients à zéro avant la passe avant (forward).
# Zero the gradients
optimizer.zero_grad()La passe avant passe les données (images) au modèle pour récupérer les sorties (recon_batch). Il s’agit des images reconstruites par le modèle.
# Forward pass
recon_batch, _ = model(data)On aplatit les images d’entrées et les images reconstruites par le modèle pour simplifier le calcul du MSE.
# Flatten the input if it's not already flattened
if len(data.shape) > 2:
data = data.view(data.size(0), -1)
# end if
# Flatten the output if it's not already flattened
if len(recon_batch.shape) > 2:
recon_batch = recon_batch.reshape(recon_batch.size(0), -1)
# end ifOn calcule alors la loss grâce à l’objet criterion qu’on a créer avant (c’est une MSELoss).
# Calculate loss
loss = criterion(recon_batch, data)On fait alors la passe arrière (backward) qui va calculer les gradients du réseau par rapport au loss.
# Backward pass
loss.backward()On utilise alors l’optimisateur (optimizer) pour mettre à jour les poids du réseau.
# Update weights
optimizer.step()Une fois l’entraînement terminé on passe le modèle en mode évaluation (les poids restent fixes).
model.eval()Au fil des itérations, la MSE chute rapidement : même avec un latent de seulement 7 nombres, le réseau apprend très vite à reconstruire des chiffres propres.

Reconstruire pour “voir” ce que le modèle a appris
Une fois l’entraînement terminé, on passe le jeu de test (jamais vu pendant l’entraînement) dans le modèle. Pour ça, on utilise le test_loader, et on récupère le premier batch que l’on transfert sur le device de calcul.
# Get images from the test dataset
test_data = next(iter(test_loader))
test_images, test_cls = test_data
test_images, test_cls = test_images.to(device), test_cls.to(device)On récupère, pour ce batch, les reconstructions (test_recon) et les latents (test_latent).
# Passe dans l'auto-encodeur
test_recon, test_latent = model(test_images)On affiche en haut les images sources, en bas les reconstructions grâce à matplotlib et sa fonction imshow().
n_samples = 8
fig, axes = plt.subplots(2, n_samples, figsize=(n_samples * 2, 4))
# Row
for ax_i, ax in enumerate(axes):
# Column
for ax_j, ay in enumerate(ax):
if ax_i == 0:
ay.imshow(test_images[ax_j, 0].cpu().numpy())
ay.set_title(f"Image {ax_j+1}")
else:
ay.imshow(test_recon[ax_j, 0].detach().cpu().numpy())
ay.set_title(f"Reconstruction {ax_j+1}")
# end if
ay.axis('off')
# end for
# end for
plt.tight_layout()
plt.show()Malgré le goulot d’étranglement à 7 dimensions, les sorties sont nettes et fidèles : l’encodeur a capturé l’essentiel de la structure du chiffre, et le décodeur sait la remettre en forme.

Cartographier l’espace latent avec t-SNE
Regarder quelques reconstructions rassure, mais le vrai test pour l’intuition, c’est de projeter l’espace latent en 2D et d’observer le nuage de points. On calcule donc les latents de tout le jeu de test,
# On récupère tous les latents du jeu de test
latents = list()
digits = list()
for data_i, (data, cls) in enumerate(test_loader):
data = data.to(device)
# Forward pass
_, latent = model(data)
# Add batch
latents.append(latent)
digits.append(cls)
# end foron les empile dans un grand tenseur (qu’on passe au device de calcul),
# Concat all
latents = torch.cat(latents, dim=0).cpu()
digits = torch.cat(digits, dim=0).cpu()puis on applique t-SNE pour obtenir une représentation 2D.
from sklearn.manifold import TSNE
# Convert latents to numpy for t-SNE
latents_np = latents.detach().cpu().numpy()
# Apply t-SNE to reduce dimensionality to 2D
tsne = TSNE(n_components=2, random_state=42)
latents_2d = tsne.fit_transform(latents_np)Chaque point est coloré par le digit vrai (les étiquettes de MNIST), uniquement pour visualiser.
from matplotlib.cm import get_cmap
# Mapping des classes vers des noms
class_names = {idx:f"Digit {idx}" for idx in digits.unique().tolist()}
# Colormap discrète
cmap = plt.get_cmap('tab10') # jusqu'à 10 classes distinctes
colors = [cmap(i) for i in range(len(class_names))]
plt.figure(figsize=(10, 8))
# plt.scatter(latents_2d[:, 0], latents_2d[:, 1], c=digits.numpy(), s=2, alpha=0.6)
# Boucle sur les classes pour tracer chaque groupe
for class_id, class_label in class_names.items():
idx = digits == class_id
plt.scatter(
latents_2d[idx, 0],
latents_2d[idx, 1],
color=colors[class_id],
label=class_label,
alpha=0.7,
s=3
)
# end for
plt.legend(title="Classe")
plt.xlabel("T-SNE Dimension 1")
plt.ylabel("T-SNE Dimension 2")
plt.title("Echantillons MNIST de test")
plt.grid(True)
plt.show()Ce qui est frappant : les classes se séparent naturellement, alors même que l’auto-encodeur n’a jamais vu les labels pendant l’entraînement. Le “1” forme une grappe orange compacte ; les “4”, “7”, “9” apparaissent dans des régions bien distinctes ; et l’on observe des zones de contact entre classes proches (par exemple des “1” et des “7” qui se frôlent). L’espace latent encode donc une notion de similarité : les chiffres qui se ressemblent se retrouvent voisins.

Morphing : voyager d’un chiffre à l’autre
Dernier exercice, le plus visuel : on choisit deux images du test (disons un “8” et un “9”), on récupère leurs latents $z_a$ et $z_b$,
# Prend deux représentations latentes dans le jeu de test
id1 = np.random.randint(latents.size(0)) + 2
id2 = np.random.randint(latents.size(0)) + 2
lat1, dig1 = latents[id1], digits[id1]
lat2, dig2 = latents[id2], digits[id2]puis on interpole linéairement entre ces deux vecteurs selon l’expression mathématique :
$$z(\alpha) \;=\; (1-\alpha)\,z_a \;+\; \alpha\,z_b,\quad \alpha\in[0,1]$$
Ce qui donne en code,
# Calcule l'interpolation linéaire des latents
morphing_size = 10
t_values = torch.linspace(0, 1, steps=morphing_size).unsqueeze(1)
interpolations = (1 - t_values) * lat1 + t_values * lat2Pour une dizaine de valeurs de $\alpha$, on décode chaque latent intermédiaire
# Calcule les reconstructions pour chaque latents
lat_rec = list()
for r_i in range(morphing_size):
lat = interpolations[r_i].to(device).unsqueeze(0)
rec = model.decode(lat)
lat_rec.append(rec[0])
# end for
morph_rec = torch.cat(lat_rec, dim=0)et on aligne les images sur une rangée : on regarde littéralement le “8” se transformer en “9”.
fig, axes = plt.subplots(1, morphing_size, figsize=(morphing_size*2, 4))
for ax_i, ax in enumerate(axes):
ax.imshow(morph_rec[ax_i].detach().cpu().numpy())
ax.set_title(f"{ax_i/morphing_size}")
ax.axis('off')
# end for
plt.tight_layout()
plt.show()Sur MNIST, c’est superbe : le haut du “4” se ferme puis s’ouvre sur la gauche pour laisser naître la courbe du “2”. Les étapes des bords gardent un aspect plausible, parce que la variété de formes manuscrites “accepte” ces chemins. Même si au milieu on obtient un chiffre « mutant » de transition entre les deux chiffres.

Le morphing reflète la géométrie de l’espace latent : s’il est bien structuré, une ligne droite dans le latent correspond à une transition progressive et lisible dans l’espace image.
Ce que ça montre… et ce que ça ne montre pas
Un auto-encodeur classique (AE) tel que celui-ci apprend un espace qui fonctionne très bien pour reconstruire et organiser les données par similarité. Mais il n’impose aucune contrainte probabiliste sur la forme de cet espace. Résultat : si vous tentez des interpolations audacieuses sur des images réalistes, vous verrez parfois des états “mi-figue mi-raisin” — des mélanges peu naturels qui trahissent le fait que la ligne droite en latent traverse des zones qui ne correspondent à aucune image plausible.
Autre façon de voir les limites des modèles génératifs basés sur les auto-encodeurs: échantillonner au hasard (gaussienne) des latents z puis utiliser le décodeur pour générer des images de chiffres.

On voit ici rapidement le problème, les images ne ressemblent à aucun chiffre. Signe que l’espace latent est encore loin d’être correctement structuré.
C’est exactement la raison d’être des VAE (Variational Auto-Encoders) : en régularisant le latent (typiquement vers une gaussienne standard), ils rendent les trajectoires entre points plus “valides” du point de vue de la génération. C’est aussi pour cela que des modèles modernes comme Stable Diffusion s’appuient sur un VAE pleinement convolutionnel pour travailler dans un latent compact mais “bien formé”.
Et maintenant ?
Avec cet auto-encodeur, vous avez tout ce qu’il faut pour la suite de la série :
- un encodeur/décodeur convolutionnel réutilisable,
- un espace latent de petite dimension que l’on comprend et que l’on peut manipuler,
- des outils de visualisation pour diagnostiquer ce que le modèle a réellement appris.
Dans les prochains articles, on branchera un VAE à la place de l’AE, puis un modèle de diffusion latent (ou flow matching latent) par-dessus. Étape par étape, on se rapproche d’une ré-implémentation minimaliste — mais complète — de la chaîne de génération d’images façon Stable Diffusion.
En attendant, vous pouvez jouer avec le notebook : changez la dimension du latent (essayez 2, 16, 64), modifiez les largeurs de canaux, remplacez LeakyReLU par GELU, testez d’autres pertes (L1 vs MSE)… et observez l’effet sur les reconstructions, le t-SNE et le morphing. C’est le meilleur moyen de sentir la géométrie de vos représentations.







Laisser un commentaire