TP Deep Learning – ING5 / M2
« De l’image au texte, de la génération à la décision »
1. Présentation générale
Ce TP a pour objectif de mettre en pratique les notions vues en cours de Deep Learning :
CNN pour la vision, LSTM et Transformers pour les séquences, modèles génératifs (VAE),
et Deep Reinforcement Learning (DQN).
Objectifs pédagogiques
- Entraîner un CNN simple sur MNIST et analyser les choix d’optimisation.
- Manipuler un LSTM pour prédire une série temporelle.
- Comprendre un mini-Transformers pour du langage jouet.
- Implémenter un VAE simple pour générer des images.
- Entraîner un agent DQN sur l’environnement CartPole.
Organisation
- Partie 1 – CNN (environ 2h)
- Partie 2 – LSTM & mini-Transformer (environ 2h)
- Partie 3 – VAE & DQN (environ 2h)
Fichiers à créer (côté étudiant)
session1_cnn_student.py
session2_lstm_student.py
session2_transformer_student.py
session3_vae_student.py
session3_dqn_student.py
2. Partie 1 – CNN sur MNIST
Objectifs : implémenter un CNN simple, comprendre la convolution, le pooling, l’impact de l’optimiseur.
Énoncé
Dans cette première partie, vous allez implémenter et entraîner un CNN sur le dataset MNIST
(chiffres manuscrits). Le code suivant contient des zones à compléter marquées # TODO.
Code à fournir aux étudiants : session1_cnn_student.py
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# =========================
# 1. Datasets & dataloaders
# =========================
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
])
train_dataset = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root="./data", train=False, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# =========================
# 2. Modèle CNN à compléter
# =========================
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
# TODO : compléter les couches convolutionnelles
self.conv_layers = nn.Sequential(
# 1) Convolution 1 → 32 filtres, kernel_size=3, padding=1
# 2) ReLU
# 3) MaxPool2d(kernel=2)
# 4) Convolution 32 → 64, kernel=3, padding=1
# 5) ReLU
# 6) MaxPool2d(kernel=2)
# TODO : écrire le code ici
)
# TODO : dropout + Fully Connected
self.fc_layers = nn.Sequential(
# Indice : Flatten : 64 * 7 * 7 = 3136
# TODO : Linear(3136, 128) + ReLU + Linear(128, 10)
)
def forward(self, x):
x = self.conv_layers(x)
x = x.view(x.size(0), -1) # Flatten
x = self.fc_layers(x)
return x
# =========================
# 3. Choix de la loss + optimiseur
# =========================
model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
# TODO : choisir un optimiseur (Adam ou SGD)
optimizer = optim.Adam(model.parameters(), lr=0.001) # ou SGD
# =========================
# 4. Boucles d'entraînement & d'évaluation
# =========================
def train_one_epoch(model, dataloader, optimizer, criterion):
model.train()
running_loss = 0
correct = 0
for X, y in dataloader:
X, y = X.to(device), y.to(device)
optimizer.zero_grad()
# TODO : forward
# TODO : compute loss
# TODO : backward
# TODO : step
running_loss += loss.item() * X.size(0)
correct += (outputs.argmax(1) == y).sum().item()
return running_loss / len(dataloader.dataset), correct / len(dataloader.dataset)
def evaluate(model, dataloader, criterion):
model.eval()
correct = 0
running_loss = 0
with torch.no_grad():
for X, y in dataloader:
X, y = X.to(device), y.to(device)
outputs = model(X)
loss = criterion(outputs, y)
running_loss += loss.item() * X.size(0)
correct += (outputs.argmax(1) == y).sum().item()
return running_loss / len(dataloader.dataset), correct / len(dataloader.dataset)
# =========================
# 5. Entraînement
# =========================
num_epochs = 3
for epoch in range(1, num_epochs + 1):
train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion)
test_loss, test_acc = evaluate(model, test_loader, criterion)
print(f"Epoch {epoch}: "
f"train_loss={train_loss:.4f}, train_acc={train_acc:.4f}, "
f"test_loss={test_loss:.4f}, test_acc={test_acc:.4f})
Questions (Partie 1)
- Quelle couche réduit la taille des images ? Pourquoi ?
- Pourquoi utilise-t-on du
Dropout avant les couches fully-connected ?
- Comparez Adam et SGD : lequel converge plus vite ? Pourquoi ?
- Analysez la différence entre accuracy train/test : que se passe-t-il si l’écart est grand ?
3. Partie 2 – LSTM & Mini-Transformer
Objectifs : manipuler un LSTM pour une série temporelle et comprendre la structure d’un mini-Transformer
pour un modèle de langage jouet.
3.1 LSTM sur sinusoïde
Vous apprenez un LSTM à prédire la valeur suivante d’une sinusoïde à partir d’une fenêtre de valeurs précédentes.
Code à fournir : session2_lstm_student.py
import math
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# =========================
# 1. Dataset sinusoïde
# =========================
class SineDataset(Dataset):
def __init__(self, seq_len=20, n_samples=2000):
super().__init__()
self.data = []
for _ in range(n_samples):
phase = torch.rand(1).item() * 2 * math.pi
xs = torch.linspace(0, 4 * math.pi, seq_len + 1) + phase
ys = torch.sin(xs)
# Entrées : 0..seq_len-1
# Cible : seq_len
self.data.append((ys[:-1].unsqueeze(-1), ys[-1].unsqueeze(-1)))
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_loader = DataLoader(SineDataset(), batch_size=32, shuffle=True)
# =========================
# 2. Modèle LSTM : trous à compléter
# =========================
class LSTMPredictor(nn.Module):
def __init__(self, input_dim=1, hidden_dim=32):
super().__init__()
# TODO : compléter l'initialisation du LSTM
# Indice : nn.LSTM(input_dim, hidden_dim, batch_first=True)
self.lstm = nn.LSTM( ??? )
# TODO : couche finale
self.fc = nn.Linear( ??? , 1)
def forward(self, x):
# TODO : appliquer le LSTM, récupérer la dernière sortie temporelle
# TODO : passer dans la couche fully-connected
return y_pred
model = LSTMPredictor().to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# =========================
# 3. Entraînement
# =========================
for epoch in range(10):
for X, y in train_loader:
X, y = X.to(device), y.to(device)
optimizer.zero_grad()
y_pred = model(X)
loss = criterion(y_pred, y)
loss.backward()
optimizer.step()
print(f"Epoch {epoch} - loss={loss.item():.6f}")
Questions (LSTM)
- Pourquoi un RNN simple ne suffit-il pas forcément pour ce type de tâche ?
- Que se passe-t-il si vous utilisez un hidden state incorrect (par exemple, le premier au lieu du dernier) ?
- Pourquoi l’entraînement est-il plus lent qu’un CNN sur MNIST ?
3.2 Mini-Transformer pour LM jouet
Vous complétez un mini-modèle Transformer pour prédire des tokens suivants sur un vocabulaire jouet.
L’objectif est de comprendre l’encodage positionnel et le masque causal.
Code à fournir : session2_transformer_student.py
import torch
import torch.nn as nn
import math
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# =========================
# 1. Position Encoding à compléter
# =========================
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=100):
super().__init__()
# TODO : construire le tableau pe[max_len, d_model]
# avec sin/cos
pe = torch.zeros( ??? )
# TODO : remplir pe[:, 0::2] et pe[:, 1::2]
# selon la formule sin/cos du papier original
self.register_buffer("pe", pe.unsqueeze(0))
def forward(self, x):
return x + self.pe[:, :x.size(1)]
# =========================
# 2. Mini-transformer LM
# =========================
class MiniTransformerLM(nn.Module):
def __init__(self, vocab_size, d_model=64, nhead=4):
super().__init__()
# TODO : embedding + positional encoding
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoding = PositionalEncoding(d_model)
# TransformerDecoder minimal
decoder_layer = nn.TransformerDecoderLayer(
d_model=d_model,
nhead=nhead,
batch_first=True
)
self.decoder = nn.TransformerDecoder(decoder_layer, num_layers=1)
self.fc_out = nn.Linear(d_model, vocab_size)
def generate_mask(self, size):
# masque causal (TODO)
return torch.triu(torch.ones(size, size), diagonal=1).bool().to(device)
def forward(self, input_ids):
# TODO : embed + add position
# TODO : appliquer la self-attention
# TODO : sortie finale
return logits
Questions (Transformer)
- Pourquoi le masque causal est-il indispensable pour un modèle de langage auto-régressif ?
- Qu’est-ce qu’un embedding, et pourquoi ne peut-on pas se contenter d’un ID entier ?
- Quelle différence entre self-attention et cross-attention ? Donnez un exemple d’usage.
4. Partie 3 – VAE & DQN
Objectifs : comprendre un VAE simple pour la génération d’images et implémenter un DQN minimal pour CartPole.
4.1 VAE sur MNIST
Vous complétez un VAE qui encode et reconstruit des images MNIST. La loss combine une erreur de reconstruction
(BCE) et un terme de régularisation (KL divergence).
Code à fournir : session3_vae_student.py
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# =========================
# Dataset
# =========================
transform = transforms.ToTensor()
train_dataset = datasets.MNIST("./data", train=True, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
# =========================
# 1. VAE à trous
# =========================
class VAE(nn.Module):
def __init__(self, latent_dim=16):
super().__init__()
# TODO : encoder
self.encoder = nn.Sequential(
nn.Flatten(),
nn.Linear( ??? , 256),
nn.ReLU()
)
self.fc_mu = nn.Linear(256, latent_dim)
self.fc_logvar = nn.Linear(256, latent_dim)
# TODO : decoder
self.decoder = nn.Sequential(
nn.Linear(latent_dim, 256),
nn.ReLU(),
nn.Linear(256, ??? ), # 28*28
nn.Sigmoid()
)
def reparametrize(self, mu, logvar):
# TODO : z = mu + sigma * eps
return z
def forward(self, x):
h = self.encoder(x)
mu, logvar = self.fc_mu(h), self.fc_logvar(h)
z = self.reparametrize(mu, logvar)
x_hat = self.decoder(z).view(-1, 1, 28, 28)
return x_hat, mu, logvar
# =========================
# 2. VAE Loss
# =========================
def vae_loss(x_hat, x, mu, logvar):
# TODO : BCE + KL divergence
return loss
model = VAE().to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# =========================
# 3. Entraînement
# =========================
for epoch in range(5):
for x, _ in train_loader:
x = x.to(device)
optimizer.zero_grad()
x_hat, mu, logvar = model(x)
loss = vae_loss(x_hat, x, mu, logvar)
loss.backward()
optimizer.step()
print("Epoch", epoch, "loss =", loss.item())
Questions (VAE)
- Pourquoi ajoute-t-on un terme KL à la loss ? Que se passerait-il si on l’enlevait ?
- À quoi sert le reparametrization trick ?
- Pourquoi les VAE génèrent-ils souvent des images plus floues que les GAN ?
4.2 DQN sur CartPole
Vous implémentez un agent Deep Q-Network pour résoudre l’environnement CartPole-v1 de Gymnasium.
Code à fournir : session3_dqn_student.py
import torch
import torch.nn as nn
import torch.optim as optim
import gymnasium as gym
import random
import numpy as np
import collections
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# =========================
# 1. Réseau Q à trous
# =========================
class DQN(nn.Module):
def __init__(self, state_dim, action_dim):
super().__init__()
# TODO : réseau simple 2 couches de 128 neurones
self.net = nn.Sequential(
nn.Linear(state_dim, ???),
nn.ReLU(),
nn.Linear( ??? , ???),
nn.ReLU(),
nn.Linear( ??? , action_dim)
)
def forward(self, x):
return self.net(x)
# =========================
# 2. Replay Buffer
# =========================
Transition = collections.namedtuple("Transition", ("state", "action", "reward", "next_state", "done"))
class ReplayBuffer:
def __init__(self, capacity=10000):
self.buffer = collections.deque(maxlen=capacity)
def push(self, *args):
self.buffer.append(Transition(*args))
def sample(self, batch_size):
batch = random.sample(self.buffer, batch_size)
batch = Transition(*batch)
states = torch.tensor(np.array(batch.state), dtype=torch.float32, device=device)
actions = torch.tensor(batch.action, dtype=torch.long, device=device).unsqueeze(-1)
rewards = torch.tensor(batch.reward, dtype=torch.float32, device=device).unsqueeze(-1)
next_states = torch.tensor(np.array(batch.next_state), dtype=torch.float32, device=device)
dones = torch.tensor(batch.done, dtype=torch.float32, device=device).unsqueeze(-1)
return states, actions, rewards, next_states, dones
def __len__(self):
return len(self.buffer)
# =========================
# 3. Entraînement DQN (simplifié)
# =========================
def train():
env = gym.make("CartPole-v1")
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
model = DQN(state_dim, action_dim).to(device)
target = DQN(state_dim, action_dim).to(device)
target.load_state_dict(model.state_dict())
target.eval()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
buffer = ReplayBuffer()
epsilon = 1.0
gamma = 0.99
for episode in range(50):
state, _ = env.reset()
done = False
while not done:
# TODO : epsilon-greedy
# Choisir une action :
# - aléatoire avec prob epsilon
# - ou argmax_Q avec prob 1 - epsilon
next_state, reward, term, trunc, _ = env.step(action)
done = term or trunc
buffer.push(state, action, reward, next_state, done)
state = next_state
if len(buffer) > 64:
states, actions, rewards, next_states, dones = buffer.sample(64)
# TODO : Q(s,a)
# TODO : Q_target = r + gamma * max Q(s', a')
loss = nn.functional.mse_loss(q_values, q_targets)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# update target network
if episode % 5 == 0:
target.load_state_dict(model.state_dict())
epsilon = max(0.05, epsilon * 0.95)
print("Episode", episode, "epsilon:", epsilon)
if __name__ == "__main__":
train()
Questions (DQN)
- Pourquoi utilise-t-on un replay buffer au lieu d’apprendre en ligne sur chaque transition ?
- Pourquoi y a-t-il un réseau cible séparé du réseau principal ?
- Pourquoi le paramètre epsilon doit-il décroître au fil des épisodes ?