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

Organisation

Fichiers à créer (côté étudiant)

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 ?