CI1 : Premiers pas
Utilisation de SLURM (∼30mn, – facile)
Nous allons accéder à des GPUs en utilisant un logiciel appelé Slurm. Slurm est un ordonnanceur qui partage équitablement des ressources (CPUs, mémoire, GPUs) entre de nombreux utilisateurs. L’idée est simple : vous demandez des ressources; si elles sont disponibles, Slurm vous attribue une allocation dans laquelle vos commandes s’exécutent.
Pour accéder aux machines contenant les GPUs, nous allons utiliser SSH. Comme la machine se trouve dans le réseau de l'école, on ne peut y accéder que depuis l'école, ou à travers le VPN, ou en passant par le portail SSH public. Demandez à votre enseignant l'adresse IP. Si vous utilisez Linux ou Mac, vous pouvez configurer le fichier ~/.ssh/config de la manière suivante pour un accès facilité :
Dans Slurm, vous pouvez demander des ressources et, si elles sont libres, obtenir un shell sur un nœud de calcul. Commencez par exécuter nvidia-smi sur la machine de connexion : cette commande devrait échouer (il n'y a pas de GPU). Maintenant, demandez des ressources en mode interactif :
- un GPU : --gres=gpu:1
- 2 CPUs pour la tâche : --cpus-per-task=2
- 8 Go de RAM : --mem=8G
- 1 heure : --time=1:00:00
- un pseudo-terminal bash : --pty bash
Une commande utile de Slurm est squeue (squeue -u $USER pour ne voir que vos jobs). Elle permet de visualiser les jobs en file d’attente ou en cours et d’obtenir leur JobID.
Exercice : redemandez un GPU en mode interactif (comme à la question précédente), puis, dans un nouveau terminal, reconnectez-vous au cluster et lancez :
salloc vous donne une allocation dans laquelle vous pouvez lancer une ou plusieurs commandes avec srun, sans repasser par la file d’attente pour chaque commande.
Exercice :
- Réservez une allocation pendant 20 minutes avec 1 GPU, 2 CPUs et 8 Go de RAM :
salloc --time=0:20:00 --gres=gpu:1 --cpus-per-task=2 --mem=8G
- À l’intérieur de cette allocation, lancez un shell interactif :
Vérifiez le GPU avec nvidia-smi, puis quittez le shell (exit).srun --pty bash
- Toujours dans la même allocation, lancez une autre commande courte (ex. hostname ou python -c "print('ok')") avec srun.
- Terminez l’allocation en tapant exit (ou scancel sur l’ID de l’allocation).
Comme les entraînements de modèles peuvent être longs, on évite le mode interactif et on utilise sbatch avec un script bash. Le script peut exécuter toutes les commandes nécessaires (activation d’environnement, vérifications, lancement Python, etc.) et contient un en-tête #SBATCH pour demander des ressources.
- Où la sortie standard et les erreurs ont-elles été écrites ?
- Quel est le nom exact des fichiers de log créés (indice : %x-%j) ?
- Affichez leur contenu et vérifiez la présence du message Bonjour et/ou de la sortie nvidia-smi.
- srun --pty bash : debug rapide en interactif.
- salloc : réserver une fois, enchaîner plusieurs srun dans le même créneau.
- sbatch : exécution non interactive (production) ; les logs sont écrits dans des fichiers.
scontrol donne des détails complets sur un job en cours, tandis que sacct fournit un historique des jobs terminés (durée, mémoire maximale, état, etc.).
Exercice :
- Lancez un job interactif court (ou soumettez un petit job avec sbatch, cf. question suivante).
- Pendant l’exécution, affichez les détails :
Repérez le nœud d’exécution et les ressources allouées.scontrol show job MON_JOB_ID | less
- Une fois le job terminé, consultez l’historique :
Question : quelle RAM réelle (MaxRSS) a été consommée par rapport à la mémoire demandée (--mem) ?sacct -j MON_JOB_ID --format=JobID,State,Elapsed,MaxRSS,ReqMem,ReqCPUS,AllocTRES
Vous pouvez copier des données, scripts et résultats entre votre machine et le cluster en réutilisant la passerelle SSH.
Depuis votre machine → vers le cluster (ex. copier mnist.zip dans ~/data/) :
Depuis le cluster → vers votre machine (ex. récupérer des résultats) :
Synchroniser un dossier (pratique pour des lots de fichiers) :
- Observation/gestion : squeue -u $USER, scancel JOBID, scontrol show job JOBID, sacct -j JOBID --format=JobID,State,Elapsed,MaxRSS,ReqMem,ReqCPUS,AllocTRES
- Interactif : srun --time=HH:MM:SS --gres=gpu:K --cpus-per-task=N --mem=XXG --pty bash
- Allocation : salloc ... puis enchaîner des srun à l’intérieur
- Batch : sbatch script.sh avec en-tête #SBATCH (--gres, --cpus-per-task, --mem, -t, -J, -o, -e)
- Diagnostics rapides : nvidia-smi (GPU), htop (CPU/mémoire)
- Transferts : scp ... tsp-client:~/..., rsync -avhP ...
- Éthique d’usage : n’accaparez pas les ressources (--cpus-per-task, --mem) ; libérez vos jobs inutiles ; consignez vos résultats dans des logs.
Création d'un environnement virtuel Python (∼20mn, – facile)
Dans cette section, vous apprendrez à créer un environnement virtuel avec Miniforge/Mamba et à installer PyTorch avec support CUDA. L’objectif est d’obtenir une installation isolée (pas de conflit avec le système), reproductible, et prête à utiliser un GPU sur le cluster.
Exécutez :
Répondez yes lorsqu’une question vous demande une confirmation. Déconnectez-vous de SSH puis reconnectez-vous.
Vous devriez maintenant pouvoir exécuter mamba. Si ce n’est pas le cas, ajoutez à ~/.bash_profile :
En cas de besoin extrême : source ~/miniforge3/etc/profile.d/conda.sh && conda activate.
Créez un environnement dédié au cours (ici avec Python 3.10) :
mamba create -n deeplearning python=3.10
Activez l’environnement nouvellement créé :
mamba activate deeplearning
Vous devrez toujours activer cet environnement avant de travailler sur le TP.
Deux méthodes possibles. Choisissez-en une (méthode A recommandée si vous utilisez Mamba). Dans les deux cas, il n’est pas nécessaire d’installer manuellement le toolkit CUDA système : la distribution fournie par PyTorch embarque ce qu’il faut, à condition de choisir la bonne variante GPU.
- pytorch-cuda=12.1 sélectionne la variante GPU (CUDA 12.1). Adaptez si besoin selon le cluster.
- Les canaux -c pytorch et -c nvidia sont requis pour la version GPU.
- Le --index-url est indispensable pour obtenir les roues GPU (sinon vous installez souvent la version CPU par défaut).
Demandez d’abord un GPU via Slurm (srun --gres=gpu:1 --cpus-per-task=2 --mem=8G --time=0:30:00 --pty bash), puis activez l’environnement et lancez Python :
Si torch.cuda.is_available() est True, votre installation est prête à utiliser un GPU.
- Vous n’êtes pas sur un nœud GPU (ou pas d’allocation Slurm avec --gres=gpu:1).
- Vous avez installé la variante CPU de PyTorch (vérifiez l’option conda/pip utilisée).
- Driver NVIDIA trop ancien pour la variante CUDA choisie (voyez nvidia-smi).
- L’environnement n’est pas activé ou un autre Python est utilisé (which python).
Exportez la “recette” minimale de votre environnement :
Affichez environment.yml, vérifiez que vous avez bien trois channels (conda-forge, pytorch, nvidia), puis recréez (en test) un environnement à partir de ce fichier (facultatif) :
Quelle commande affiche la version de TensorBoard installée ?
Exercices théoriques (papier et crayon)
Dans cette section, vous allez consolider vos connaissances sur les concepts vus en cours (perceptron multicouche, rétropropagation, descente de gradient stochastique, fonctions d'activation, fonctions de perte).
Dessinez un perceptron multicouche (MLP) avec :
- Une couche d’entrée avec 3 neurones
- Une couche cachée avec 4 neurones
- Une couche de sortie avec 2 neurones
Combien de paramètres possède ce modèle sans prendre en compte les biais ?
Entre entrée (3) et couche cachée (4) : 3×4 = 12 ; entre couche cachée (4) et sortie (2) : 4×2 = 8 ; total = 20.
Même question en incluant les biais. Combien de paramètres au total ?
Poids : 12 + 8 = 20. Biais : 4 (couche cachée) + 2 (sortie) = 6. Total = 26.
Écrivez les équations du forward pass en notation matricielle pour notre exemple. On utilise ReLU comme activation cachée. Donnez les dimensions de X, W1, b1, W2, b2, et de la sortie Y.
H = ReLU( X · W1^T + b1 ), puis Y = H · W2^T + b2.
Dimensions : X (1×3), W1 (4×3), b1 (1×4), H (1×4),
W2 (2×4), b2 (1×2), Y (1×2).
Écrivez les équations du forward pass pour un batch d’exemples X de taille N×3. Indiquez soigneusement les dimensions et comment les biais se diffusent (broadcast).
H = ReLU( X · W1^T + 1_N · b1 ), puis Y = H · W2^T + 1_N · b2.
X (N×3), W1 (4×3), b1 (1×4) diffusé en (N×4),
H (N×4), W2 (2×4), b2 (1×2) diffusé en (N×2), Y (N×2).
Quelle est la principale différence entre un perceptron simple et un perceptron multicouche ?
Le perceptron multicouche comporte des couches cachées qui introduisent de la non-linéarité et permettent de modéliser des relations non linéaires, contrairement au perceptron simple (linéaire).
Pourquoi utilisons-nous la règle de la chaîne pour calculer les gradients dans la rétropropagation ?
Elle permet de décomposer la dérivée globale en produits de dérivées locales et de remonter efficacement l’erreur couche par couche pour obtenir les gradients de tous les paramètres.
Écrivez le graphe de calcul pour la fonction f(x, y, z) = x / y + z. Identifiez les nœuds intermédiaires et les gradients locaux. Que se passe-t-il si y = 0 ?
Nœuds : q = x / y, puis f = q + z.
Gradients locaux : ∂q/∂x = 1/y, ∂q/∂y = -x / y^2, ∂f/∂q = 1, ∂f/∂z = 1.
Si y = 0, la division n’est pas définie (problème de domaine / singularité).
Effectuez le forward pass dans ce graphe de calcul avec x = 2, y = 4, z = 0.
On obtient f = 2/4 + 0 = 0.5.
Effectuez maintenant la backpropagation pour calculer les gradients de f en ces points.
∂f/∂x = 1/y = 0.25, ∂f/∂y = -x / y^2 = -2 / 16 = -0.125, ∂f/∂z = 1.
Effectuez une étape de descente de gradient avec un learning rate η = 1. Quelles sont les nouvelles valeurs de x, y, et z ? La sortie a-t-elle diminué ?
Mise à jour (descente) : x' = x - η·∂f/∂x = 2 - 0.25 = 1.75 ;
y' = 4 - (−0.125) = 4.125 ;
z' = 0 - 1 = −1.
Nouvelle sortie : f' = 1.75/4.125 − 1 ≈ −0.576, elle a bien diminué par rapport à 0.5.
Quelles sont les principales raisons d'utiliser des batchs plutôt que l’ensemble des données ou un seul exemple à la fois ?
Compromis entre vitesse et stabilité : un mini-batch amortit le bruit (variance plus faible que un seul exemple), tout en restant moins coûteux mémoire/temps qu’un batch complet ; il permet un calcul vectorisé efficace.
(Mini preuve) Soient deux exemples (x₁,y₁) et (x₂,y₂). Montrez que le gradient sur le mini-batch de taille 2 est la moyenne des gradients individuels. Pourquoi cela réduit-il la variance du gradient ?
Pour une perte moyenne L = (ℓ₁ + ℓ₂)/2, on a ∇L = (∇ℓ₁ + ∇ℓ₂)/2. La moyenne réduit la variance par linéarité : Var((g₁+g₂)/2) = Var(g)/2 si g₁,g₂ i.i.d., donc un estimateur de gradient plus stable.
Associez sortie et fonction de perte usuelles :
- Classification binaire
- Classification multi-classes
- Régression
Binaire : sigmoid + binary cross-entropy (BCE).
Multi-classes : softmax + cross-entropy (CE).
Régression : sortie identité + MSE (ou MAE selon le cas).
Tracez les courbes des fonctions d’activation suivantes et de leurs dérivées :
- ReLU
- Sigmoid
- Tanh
Indiquez les zones de saturation et la valeur de la dérivée dans ces zones.
ReLU : max(0,x), dérivée 0 pour x<0, 1 pour x>0 (choix usuel : 0 ou 1 à x=0).
Sigmoid : sature vers 0 et 1 ; dérivée σ(x)(1−σ(x)), proche de 0 en saturation.
Tanh : sature vers −1 et 1 ; dérivée 1−tanh²(x), proche de 0 en saturation.
(Pour les plus avancés en maths) Soit une couche linéaire ŷ = W·a + b et la perte L = (1/2)‖y − ŷ‖². Exprimez ∂L/∂W, ∂L/∂b et ∂L/∂a (formes vectorisées).
e = ŷ − y (erreur). Alors : ∂L/∂W = e · a^T, ∂L/∂b = e, ∂L/∂a = W^T · e.
(Vérification numérique) Vérifiez numériquement ∂f/∂x pour f(x,y,z)=x/y+z en utilisant les différences finies : (f(x+ε,y,z) − f(x,y,z))/ε avec ε petit (ex. 1e-5). Que trouvez-vous ?
On obtient une valeur proche de 0.25, confirmant le calcul analytique ∂f/∂x = 1/y (avec y=4).
Votre premier réseau de neurones
Dans cette section, vous allez implémenter un réseau de neurones simple avec PyTorch, l’entraîner sur CIFAR-10, puis l’évaluer proprement. L’exercice suivant sera dédié à TensorBoard (visualisation).
Étape 1 : Préparation des données
Téléchargez et chargez le dataset CIFAR-10 avec une normalisation canonique :
import os import torch import torchvision from torchvision import transforms, datasets # Normalisation "classique" pour CIFAR-10 CIFAR10_MEAN = (0.4914, 0.4822, 0.4465) CIFAR10_STD = (0.2023, 0.1994, 0.2010) transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(CIFAR10_MEAN, CIFAR10_STD), ]) trainset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform) testset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform) # Ne pas monopoliser les CPUs : on borne num_workers def get_num_workers(default=2, cap=4): try: n = int(os.getenv("SLURM_CPUS_PER_TASK", default)) except Exception: n = default return max(0, min(cap, n)) num_workers = get_num_workers() trainloader = torch.utils.data.DataLoader( trainset, batch_size=32, shuffle=True, num_workers=num_workers, pin_memory=True ) testloader = torch.utils.data.DataLoader( testset, batch_size=32, shuffle=False, num_workers=num_workers, pin_memory=True )
Étape 2 : Implémentation du réseau
import torch.nn as nn import torch.nn.functional as F class MLP(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(32*32*3, 128) # couche cachée self.fc2 = nn.Linear(128, 10) # couche de sortie (logits) def forward(self, x): # Aplatir de manière robuste (batch, *) : x = torch.flatten(x, 1) # équivalent à x.view(x.size(0), -1) sans souci de contiguïté x = F.relu(self.fc1(x)) x = self.fc2(x) # NE PAS appliquer Softmax ici (voir question ci-dessous) return x
Étape 3 : Entraînement du modèle
Configurez l’entraînement, avec un suivi de la perte et d’une accuracy d’époque. Ajoutez des graines (seeds) pour un minimum de reproductibilité.
import random torch.manual_seed(0) if torch.cuda.is_available(): torch.cuda.manual_seed_all(0) random.seed(0) device = "cuda" if torch.cuda.is_available() else "cpu" print(f"Using device: {device}") if device == "cpu": print("Astuce: pour le GPU, demandez une alloc Slurm (ex: srun --gres=gpu:1 ...)") model = MLP().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9) # BONUS facultatif (AMP) : précision mixte pour accélérer/économiser de la mémoire sur GPU use_amp = (device == "cuda") scaler = torch.amp.GradScaler('cuda', enabled=use_amp) EPOCHS = 10 for epoch in range(EPOCHS): model.train() running_loss = 0.0 running_correct = 0 running_total = 0 for inputs, labels in trainloader: # Transferts accélérés si pin_memory=True inputs = inputs.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) if use_amp: with torch.cuda.amp.autocast(): outputs = model(inputs) loss = criterion(outputs, labels) else: outputs = model(inputs) loss = criterion(outputs, labels) if use_amp: scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() else: loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) preds = outputs.argmax(dim=1) running_correct += (preds == labels).sum().item() running_total += labels.size(0) epoch_loss = running_loss / running_total epoch_acc = running_correct / running_total print(f"Epoch {epoch+1:02d} | loss={epoch_loss:.4f} | acc={epoch_acc:.4f}")
Sanity check (diagnostic rapide) : essayez de sur-ajuster (overfit) un petit sous-ensemble (p.ex. 512 images). La perte doit chuter et l’accuracy approcher 100% si le pipeline est correct.
from torch.utils.data import Subset small_idx = list(range(512)) small_train = Subset(trainset, small_idx) small_loader = torch.utils.data.DataLoader( small_train, batch_size=32, shuffle=True, num_workers=num_workers, pin_memory=True ) # Ré-entraînez 3-5 epochs sur small_loader et observez loss/acc.
Étape 4 : Évaluation sur l’ensemble de test
Évaluez le modèle (accuracy globale), puis optionnel : accuracy par classe.
model.eval() classes = trainset.classes # noms des classes CIFAR-10 total = 0 correct = 0 per_class_total = [0]*len(classes) per_class_correct = [0]*len(classes) with torch.no_grad(): for images, labels in testloader: images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() for c in range(len(classes)): idx = (labels == c) per_class_total[c] += idx.sum().item() per_class_correct[c]+= (predicted[idx] == c).sum().item() acc = correct / total print(f"Test accuracy: {acc:.3f}") # Option : accuracy par classe for c, name in enumerate(classes): if per_class_total[c] > 0: print(f"{name:>10s} : {per_class_correct[c]/per_class_total[c]:.3f}")
Étape 5 : Sauvegarde et chargement du modèle
Sauvegardez le modèle après l’entraînement, puis rechargez-le (y compris sur CPU si besoin) :
# Sauvegarde des poids torch.save(model.state_dict(), "mlp_model.pth") # Recréation du modèle et chargement (sur CPU, par ex.) model2 = MLP().to("cpu") state = torch.load("mlp_model.pth", map_location="cpu") model2.load_state_dict(state) model2.eval() # avant l'inférence
Utilisation de TensorBoard
Dans cet exercice, vous allez instrumenter votre entraînement pour journaliser proprement les métriques et explorer l’interface de TensorBoard afin de diagnostiquer bruit, sur-apprentissage et comparer plusieurs jeux d’hyperparamètres. Nous distinguerons clairement train, validation et test : on suit et on ajuste avec train/val, et on ne consulte test qu’en fin d’expérience.
Étape 1 — Préparer un run clair et reproductible
Créez un dossier de run lisible (timestamp + hyperparamètres clés). Cela facilite la comparaison de plusieurs expériences :
import os, random, datetime, torch # Hyperparamètres (exemple) — adaptez aux vôtres hparams = dict(model="MLP", batch_size=32, lr=1e-2, seed=0, weight_decay=0.0) run_name = f"{hparams['model'].lower()}/bs{hparams['batch_size']}_lr{hparams['lr']}_wd{hparams['weight_decay']}_{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}" logdir = os.path.join("runs", run_name) print("Logdir:", logdir)
Étape 2 — Découpage train / validation et calcul de moyennes par époque
Créez un split validation et définissez une fonction qui calcule des moyennes par époque (perte/accuracy) — pas la perte du dernier batch.
from torch.utils.data import random_split, DataLoader # On part du trainset de l'exercice précédent (déjà normalisé) N = len(trainset) val_size = int(0.1 * N) train_size = N - val_size train_subset, val_subset = random_split(trainset, [train_size, val_size], generator=torch.Generator().manual_seed(0)) def get_num_workers(default=2, cap=4): import os try: n = int(os.getenv("SLURM_CPUS_PER_TASK", default)) except Exception: n = default return max(0, min(cap, n)) num_workers = get_num_workers() trainloader = DataLoader(train_subset, batch_size=hparams["batch_size"], shuffle=True, num_workers=num_workers, pin_memory=True) valloader = DataLoader(val_subset, batch_size=hparams["batch_size"], shuffle=False, num_workers=num_workers, pin_memory=True) device = "cuda" if torch.cuda.is_available() else "cpu" model = MLP().to(device) criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=hparams["lr"], momentum=0.9, weight_decay=hparams["weight_decay"]) # Reproductibilité minimale torch.manual_seed(hparams["seed"]) if torch.cuda.is_available(): torch.cuda.manual_seed_all(hparams["seed"]) random.seed(hparams["seed"]) # ---> Moyennes par époque (perte et accuracy)
Étape 3 — Instrumenter l’entraînement (batch & époque)
Initialisez SummaryWriter, loggez au batch (pas trop souvent) et à l’époque, et ajoutez le graphe du modèle une seule fois.
from torch.utils.tensorboard import SummaryWriter from torchvision.utils import make_grid writer = SummaryWriter(log_dir=logdir) # Facultatif : graphe du modèle (peut être lourd) try: sample_input = torch.randn(1, 3, 32, 32, device=device) writer.add_graph(model, sample_input) except Exception as e: print("add_graph a été ignoré :", e) EPOCHS = 10 global_step = 0 for epoch in range(1, EPOCHS + 1): model.train() running_loss_sum, running_total = 0.0, 0 for b, (x, y) in enumerate(trainloader): x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) logits = model(x) loss = criterion(logits, y) loss.backward() # Logging batch (toutes les ~10 itérations) if b % 10 == 0: writer.add_scalar("Loss/train_step", loss.item(), global_step) optimizer.step() running_loss_sum += loss.item() * y.size(0) running_total += y.size(0) global_step += 1 # Fin d'époque : métriques train/val avec moyennes train_loss = running_loss_sum / running_total val_loss, val_acc = epoch_metrics(valloader) writer.add_scalar("Loss/train", train_loss, epoch) writer.add_scalar("Loss/val", val_loss, epoch) writer.add_scalar("Accuracy/val", val_acc, epoch) # Histogrammes 1×/5 époques pour limiter l'IO if epoch % 5 == 0: for name, p in model.named_parameters(): writer.add_histogram(f"weights/{name}", p.data, epoch) if p.grad is not None: writer.add_histogram(f"grads/{name}", p.grad, epoch) # Enregistrer les hyperparamètres + métriques d'époque (option : à la dernière époque seulement) if epoch == EPOCHS: writer.add_hparams(hparams, {"final_val_accuracy": float(val_acc), "final_val_loss": float(val_loss)}) writer.flush() print(f"Epoch {epoch:02d} | train_loss={train_loss:.4f} | val_loss={val_loss:.4f} | val_acc={val_acc:.3f}")
Étape 4 — Images mal classées & matrice de confusion
Installez Matplotlib dans votre environnement pour les figures :
# Avec mamba (recommandé) : mamba install -c conda-forge matplotlib # Ou avec pip : pip install matplotlib
Après chaque époque, ajoutez quelques images mal classées (dé-normalisées) et une matrice de confusion (validation) :
import matplotlib.pyplot as plt import numpy as np CIFAR10_MEAN = (0.4914, 0.4822, 0.4465) CIFAR10_STD = (0.2023, 0.1994, 0.2010) classes = trainset.classes def denorm(imgs, mean=CIFAR10_MEAN, std=CIFAR10_STD): m = torch.tensor(mean, device=imgs.device).view(1, -1, 1, 1) s = torch.tensor(std, device=imgs.device).view(1, -1, 1, 1) return (imgs * s + m).clamp(0, 1) @torch.no_grad() def misclassified_batch(loader, max_images=32): model.eval() wrong_x, wrong_y, wrong_p = [], [], [] for x, y in loader: x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True) pred = model(x).argmax(1) mask = (pred != y) if mask.any(): idx = torch.nonzero(mask).squeeze(1) for i in idx: wrong_x.append(x[i].cpu()); wrong_y.append(int(y[i].cpu())); wrong_p.append(int(pred[i].cpu())) if len(wrong_x) >= max_images: break if len(wrong_x) >= max_images: break if not wrong_x: return None, None, None return torch.stack(wrong_x), wrong_y, wrong_p @torch.no_grad() def confusion_matrix_torch(loader, num_classes=10): model.eval() cm = torch.zeros(num_classes, num_classes, dtype=torch.int64) for x, y in loader: x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True) pred = model(x).argmax(1) for t, p in zip(y.view(-1), pred.view(-1)): cm[int(t), int(p)] += 1 return cm # À placer en fin d'époque (après les scalaires) : wrong = misclassified_batch(valloader, max_images=32) if wrong[0] is not None: imgs = denorm(wrong[0]) grid = make_grid(imgs, nrow=8) writer.add_image("Val/Misclassified", grid, epoch) writer.add_text("Val/Misclassified_labels", "true|pred : " + ", ".join(f"{classes[t]}|{classes[p]}" for t,p in list(zip(wrong[1], wrong[2]))[:16]), epoch) cm = confusion_matrix_torch(valloader, num_classes=len(classes)).numpy() fig = plt.figure(figsize=(5,5)) plt.imshow(cm, interpolation="nearest", cmap="Blues") plt.title("Confusion Matrix (val)") plt.colorbar() tick_marks = np.arange(len(classes)) plt.xticks(tick_marks, classes, rotation=45, ha="right") plt.yticks(tick_marks, classes) plt.xlabel("Pred"); plt.ylabel("True") plt.tight_layout() writer.add_figure("Val/ConfusionMatrix", fig, epoch) plt.close(fig)
Étape 5 — Fin d’expérience : test + hyperparamètres
Évaluez une seule fois sur l’ensemble de test, puis enregistrez hyperparamètres et résultats finaux.
test_loss, test_acc = epoch_metrics(testloader) writer.add_scalar("Loss/test", test_loss, EPOCHS) writer.add_scalar("Accuracy/test", test_acc, EPOCHS) writer.add_hparams(hparams, {"final_test_accuracy": float(test_acc), "final_test_loss": float(test_loss)}) writer.flush(); writer.close() print(f"TEST — loss={test_loss:.4f} acc={test_acc:.3f}")
Étape 6 — Visualiser localement
Transférez les logs depuis le serveur vers votre machine (via votre configuration ~/.ssh/config) :
scp -r tsp-client:~/runs ./runs
Installez TensorBoard sur votre machine (pip install tensorboard) puis lancez :
tensorboard --logdir=runs
Ouvrez l’URL indiquée (généralement http://localhost:6006) et vérifiez vos graphes.
(Option) Lancer TensorBoard sur le serveur et y accéder par tunnel SSH :
# Sur le serveur : tensorboard --logdir=~/runs --port 6006 --bind_all # En local (grâce à votre ~/.ssh/config) : ssh -L 6006:localhost:6006 tsp-client # Puis ouvrez http://localhost:6006
Exploration de l’interface TensorBoard
Où trouver les hyperparamètres (HParams) ? Ouvrez TensorBoard, identifiez l’onglet HParams. Que voyez-vous si vous avez bien appelé writer.add_hparams(...) ?
Scalars : où ajuste-t-on l’axe X (Step / Relative / Wall) et la smoothing ? Testez plusieurs valeurs.
Pourquoi observe-t-on autant de bruit sur Loss/train_step ? Et pourquoi Loss/train (par époque) est-elle plus lisse ?
Comparaison de runs : dans Scalars, sélectionnez plusieurs runs (cases à cocher à gauche). Que permet le filtre par expression régulière (champ Filter) ?
Images, Histogrammes, Graph, Projector : où retrouve-t-on les images mal classées, les histogrammes de poids/gradients, le graphe du modèle et les embeddings ?
Mini-sweep d’hyperparamètres & diagnostic d’overfit
Lancez au moins 3 runs avec des hyperparamètres différents (modifiez hparams avant chaque entraînement) :
- LR ∈ {1e-1, 1e-2, 1e-3}
- batch_size ∈ {32, 128}
- weight_decay ∈ {0.0, 5e-4}
Comparez Loss/val et Accuracy/val dans TensorBoard. Lequel gagne ?
Détectez un sur-apprentissage dans TensorBoard : quels motifs observez-vous sur Loss/train vs Loss/val et sur Accuracy/val ?
Si vous constatez beaucoup de bruit sur Loss/train_step, citez 3 façons de le réduire sans tricher sur l’évaluation.
Ce TP vous a guidés de bout en bout : accéder à des GPU partagés avec Slurm, travailler dans un environnement Python isolé et reproductible, rappeler les fondamentaux (architecture MLP, dimensions, passes avant/arrière, mini-batch), puis implémenter un premier entraînement complet et l’instrumenter avec TensorBoard. L’idée générale est de savoir demander proprement des ressources, préparer un environnement fiable, comprendre ce que fait le modèle et mesurer ce qu’il apprend.
Retenez surtout les bonnes pratiques : sur un cluster, on reste sobre dans les ressources demandées et on surveille ses jobs ; côté code, on structure une boucle d’apprentissage simple et robuste (train/val/test bien séparés, moyennes par époque, sauvegarde du modèle), et on lit l’apprentissage dans TensorBoard plutôt que « au feeling ». Le lissage (smoothing) des courbes de perte d’entraînement aide à dégager la tendance derrière le bruit des mini-batchs, tandis que la validation, les images mal classées et la matrice de confusion permettent de diagnostiquer la généralisation (et d’identifier l’overfit) sans « tuner » sur le test.
Enfin, ce TP vous fournit une trame complète (préparation des données, boucle d’entraînement, évaluation, logs) qui restera très proche de ce que vous réutiliserez ensuite. Nous ne la redonnerons pas forcément à chaque séance : en cas de doute, revenez à ce TP comme référence pour retrouver des exemples concrets et les points d’appui méthodologiques.