CSC 8607 – Introduction au deep learning

Portail informatique

CI1 : Premiers pas

Le but de cette séance est de mettre en place l'environnement de travail et de pratiquer les notions vues en cours. Nous allons voir comment utiliser SLURM, créer un enrionnement virtuel avec Mamba, entraîner un modèle simple et visualiser l'apprentissage.

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.

⚠️ Ressources partagées : pour ce TP, ne demandez pas plus de --cpus-per-task=2 & --mem=8G (sauf consigne contraire). Demander "tous les CPUs" ou "toute la mémoire" bloque les autres utilisateurs.

Connexion via SSH
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é :
Host LAB_GATEWAY User VOTRE_IDENTIFIANT_TSP Hostname ssh2.imtbs-tsp.eu IdentitiesOnly yes IdentityFile CHEMIN_VERS_CLEF_SSH_PRIVEE ServerAliveInterval 60 ServerAliveCountMax 2 Host tsp-client User VOTRE_IDENTIFIANT_TSP HostName ADRESSE_IP IdentitiesOnly yes IdentityFile CHEMIN_VERS_CLEF_SSH_PRIVEE ServerAliveInterval 60 ServerAliveCountMax 2 ProxyJump LAB_GATEWAY
Vous pourrez alors vous connecter depuis n'importe où en faisant ssh tsp-client. Si vous êtes sous Windows, une solution consiste à utiliser WSL2 ou PuTTY.
La machine sur laquelle vous vous connectez est une machine de connexion (login) et elle est peu puissante : n'y exécutez pas de programmes coûteux en ressources !

Il est probable que votre clef SSH ne soit pas encore enregistrée sur la machine distante. Pour générer une clef privée, si vous n'en avez pas déjà une, faites :
ssh-keygen -t ed25519 -C "votre.email@domaine"
(ne pas mettre de mot de passe pour aller plus vite). Copiez ensuite la clef publique (contenue dans ~/.ssh/id_ed25519.pub) sur la machine distante  et collez-la dans le fichier ~/.ssh/authorized_keys (créez-le s'il n'existe pas). Cette opération est à faire sur LAB_GATEWAY et sur tsp-client. Sans clef SSH, vous devrez entrer votre mot de passe à chaque connexion (deux fois).

Premiers pas en mode interactif avec srun
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 :
srun --time=1:00:00 --gres=gpu:1 --cpus-per-task=2 --mem=8G --pty bash
Les commandes Slurm commencent par un s. Ici, nous demandons :
  • 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
Si des ressources sont disponibles, vous entrez dans un nouveau terminal sur un nœud GPU. Exécutez nvidia-smi pour vérifier la présence du GPU. Pour quitter, faites Ctrl + D ou tapez exit.
Bon citoyen sur le cluster : choisissez des valeurs modestes pour --cpus-per-task et --mem pendant le TP. Vous pourrez ajuster à la hausse ponctuellement quand c’est nécessaire et disponible.

Observer et arrêter ses jobs : squeue, scancel
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 :
squeue -u $USER
Identifiez le JobID de votre job interactif et terminez-le avec :
scancel MON_JOB_ID
Sur la fenêtre d'origine où le job tournait, un message doit annoncer la fin du job.

Réserver puis lancer plusieurs commandes : salloc
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 :
  1. 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
  2. À l’intérieur de cette allocation, lancez un shell interactif :
    srun --pty bash
    Vérifiez le GPU avec nvidia-smi, puis quittez le shell (exit).
  3. Toujours dans la même allocation, lancez une autre commande courte (ex. hostname ou python -c "print('ok')") avec srun.
  4. Terminez l’allocation en tapant exit (ou scancel sur l’ID de l’allocation).

Soumettre un script non interactif avec sbatch
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.
#!/bin/bash #SBATCH -t 1:00:00 #SBATCH --gres=gpu:1 #SBATCH --cpus-per-task=2 #SBATCH --mem=8G #SBATCH -J hello-slurm #SBATCH -o logs/%x-%j.out #SBATCH -e logs/%x-%j.err set -euo pipefail mkdir -p logs echo "Job $SLURM_JOB_ID on $SLURM_NODELIST" nvidia-smi || echo "nvidia-smi indisponible" echo "Bonjour depuis SLURM !"
Exercice : sauvegardez ce script sous hello.sh, puis soumettez-le :
sbatch hello.sh
  • 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.
Encadré : quand utiliser quoi ?
  • 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.

Analyser ses jobs : scontrol & sacct
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 :
  1. Lancez un job interactif court (ou soumettez un petit job avec sbatch, cf. question suivante).
  2. Pendant l’exécution, affichez les détails :
    scontrol show job MON_JOB_ID | less
    Repérez le nœud d’exécution et les ressources allouées.
  3. Une fois le job terminé, consultez l’historique :
    sacct -j MON_JOB_ID --format=JobID,State,Elapsed,MaxRSS,ReqMem,ReqCPUS,AllocTRES
    Question : quelle RAM réelle (MaxRSS) a été consommée par rapport à la mémoire demandée (--mem) ?

Transférer des fichiers (avec ProxyJump)
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/) :

scp ./mnist.zip tsp-client:~/data/

Depuis le cluster → vers votre machine (ex. récupérer des résultats) :

scp tsp-client:~/results/output.log ./output.log

Synchroniser un dossier (pratique pour des lots de fichiers) :

rsync -avhP ./results/ tsp-client:~/backup-results/
Conseil : gardez une arborescence claire côté cluster (ex. ~/data, ~/logs, ~/results) pour retrouver facilement vos fichiers et limiter le nombre d’allers-retours.

Cheat-sheet (récapitulatif)
  • 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.

Installer Miniforge (conda-forge) et activer Mamba

Exécutez :

curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" bash Miniforge3-$(uname)-$(uname -m).sh

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 :

if [ -f ~/.bashrc ]; then . ~/.bashrc fi

En cas de besoin extrême : source ~/miniforge3/etc/profile.d/conda.sh && conda activate.

Créer et activer un environnement virtuel

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.

Quelle commande utilisez-vous pour vérifier la version de Python installée dans votre environnement actif ?
python --version (et pour vérifier le binaire utilisé : which python).

Installer PyTorch (GPU) + TensorBoard

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.

Méthode A (recommandée, via Mamba/conda) :
mamba install pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia mamba install tensorboard -c conda-forge
  • 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.
Méthode B (via pip, dans l’environnement conda actif) :
pip install --index-url https://download.pytorch.org/whl/cu121 torch torchvision torchaudio pip install tensorboard
  • Le --index-url est indispensable pour obtenir les roues GPU (sinon vous installez souvent la version CPU par défaut).

Vérifier l’installation PyTorch + CUDA

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 :

import torch print("torch:", torch.__version__) print("cuda available:", torch.cuda.is_available()) print("compiled with cuda:", torch.version.cuda) if torch.cuda.is_available(): print("device count:", torch.cuda.device_count()) print("device 0:", torch.cuda.get_device_name(0))

Si torch.cuda.is_available() est True, votre installation est prête à utiliser un GPU.

Que signifie si torch.cuda.is_available() retourne False ? Que vérifiez-vous en priorité ?
  • 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).

Rendre l’environnement reproductible

Exportez la “recette” minimale de votre environnement :

mamba env export --from-history -n deeplearning > environment.yml

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) :

mamba env create -n dl-test -f environment.yml

Nettoyage — Quelle commande permet de supprimer l’environnement dl-test si vous deviez repartir de zéro ?
mamba env remove -n dl-test

Bonus rapide — Vérifier TensorBoard.

Quelle commande affiche la version de TensorBoard installée ?

tensorboard --version
Vous devriez installer tensorboard sur votre machine locale (hors cluster) pour visualiser les logs produits sur le cluster.

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).

Conventions utilisées : nous manipulons des vecteurs-lignes. Ainsi, pour un seul exemple d’entrée, X est de taille 1×3. Pour un batch de taille N, X est de taille N×3. On note les poids W1 et W2, les biais b1 et b2. ReLU s’applique élément par élément. Le symbole ^T indique la transposée.

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 )
Les num_workers accélèrent le préchargement en utilisant du CPU, mais n’en mettez pas trop (vous partagez la machine). pin_memory=True accélère la copie CPU→GPU ; l’option sera utile quand vous déplacerez les batchs vers le device.

Que fait la classe torch.utils.data.DataLoader ? Que contrôlent batch_size, shuffle, num_workers et pin_memory ?
Elle crée un itérateur de batchs depuis un dataset. batch_size fixe la taille des lots, shuffle mélange l’ordre à l’entraînement, num_workers parallélise le chargement CPU, et pin_memory améliore les transferts CPU→GPU.

Quelle est la différence entre l’ensemble d’entraînement et l’ensemble de test ?
L’entraînement sert à ajuster les paramètres du modèle ; le test sert à évaluer la performance sur des données non vues (généralisation).

Quelles transformations sont appliquées et pourquoi ces moyennes/écarts-types ?
ToTensor convertit en tenseurs PyTorch. La normalisation par les moyennes/écarts-types de CIFAR-10 stabilise l’optimisation ; chaque canal a des statistiques centrées/réduites.

Étape 2 : Implémentation du réseau

Implémentez un MLP (une couche cachée de 128 neurones) pour des images 32×32×3 (RGB) et 10 classes.
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

Dans la méthode forward, pourquoi utilise-t-on torch.flatten(x, 1) ?
L'image en entrée est un tenseur 4D (batch_size, canaux, hauteur, largeur). flatten(x, 1) aplatit chaque image en un vecteur 1D tout en conservant la dimension batch.

Doit-on ajouter un Softmax à la fin du réseau quand on utilise nn.CrossEntropyLoss ? Pourquoi ?
Non : CrossEntropyLoss attend des logits (scores bruts) et applique en interne LogSoftmax+NLLLoss. On garde donc la sortie linéaire telle quelle pendant l’entraînement.

É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}")
model.train() active les comportements d’entraînement (Dropout/BatchNorm). optimizer.zero_grad(set_to_none=True) améliore l’empreinte mémoire/perf.

Quelle est la différence entre optimizer.zero_grad(...) et loss.backward() ?
zero_grad remet les gradients à zéro avant leur calcul ; backward calcule les gradients par rétropropagation depuis la perte.

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}")

Pourquoi utilise-t-on model.eval() et torch.no_grad() à l’évaluation ?
model.eval() fixe le comportement de couches comme Dropout/BatchNorm pour une évaluation fiable ; torch.no_grad() coupe le calcul/stockage des gradients (moins de mémoire, plus rapide).

Quelle est la métrique calculée ici ? Quelle serait sa valeur pour un classificateur aléatoire (classes équilibrées) ? Battez-vous cette valeur ?
L’accuracy. Un classificateur aléatoire ferait environ 0.10 sur CIFAR-10 (10 classes).

É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

Pourquoi est-il important de sauvegarder un modèle entraîné ? Que se passe-t-il si l’architecture ne correspond pas au moment du chargement ?
On réutilise le modèle sans le réentraîner (gain de temps/ressources). Si l’architecture diffère, le state_dict ne matche pas et PyTorch lève une erreur d’incompatibilité des poids.

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.

Pourquoi val → test ? Pendant le développement, on évalue et on choisit les hyperparamètres avec un ensemble de validation. Le test est réservé à la mesure finale, pour éviter de “sur-apprendre” au protocole d’évaluation.

É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)
Logique de nommage : inclure modèle + hyperparamètres + date/heure permet de trier visuellement les runs, de retrouver rapidement les conditions d’un résultat, et d’éviter d’écraser un run précédent.

É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)
Moyennes correctes : on cumule loss * nb_exemples puis on divise par le nombre total d’exemples de l’époque. Idem pour l’accuracy (somme des prédictions correctes / total).

É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}")
Deux granularités : au batch (Loss/train_step) la courbe est plus “bruitée” (SGD, mini-batch, shuffle) ; à l’époque (Loss/train) on observe la tendance globale.

É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(...) ?

L’onglet HParams affiche un tableau par run (colonnes = hyperparamètres et métriques). Si l’onglet n’apparaît pas, c’est que add_hparams n’a pas été appelé.

Scalars : où ajuste-t-on l’axe X (Step / Relative / Wall) et la smoothing ? Testez plusieurs valeurs.

Dans l’onglet Scalars, la barre d’outils en haut permet de choisir l’axe horizontal (Step/Relative/Wall) et un curseur Smoothing qui applique un lissage visuel (n’affecte pas les données brutes).

Pourquoi observe-t-on autant de bruit sur Loss/train_step ? Et pourquoi Loss/train (par époque) est-elle plus lisse ?

Loss/train_step est loggée par mini-batch : le gradient stochastique, le mélange des données, la petite taille de batch (et parfois l’augmentation/Dropout) induisent une forte variance. Loss/train est une moyenne sur l’époque, donc bien plus stable.

Dans l’onglet Scalars, réglez le curseur Smoothing pour lisser la courbe Loss/train_step. À quel niveau de smoothing (p. ex. 0.6–0.9) distinguez-vous clairement la tendance sans masquer des changements importants ? Pourquoi ce lissage est-il utile ici et quelles sont ses limites ?
Le smoothing applique un lissage (type EMA) qui réduit le bruit des mini-batchs et fait apparaître la tendance générale. Utile pour savoir si l’entraînement progresse ou diverge. Limites : purement visuel (n’affecte pas les données), trop lisser peut cacher des instabilités ou retarder la perception d’un problème. Pour la décision, on se base surtout sur les moyennes d’époque et les métriques de validation.

Comparaison de runs : dans Scalars, sélectionnez plusieurs runs (cases à cocher à gauche). Que permet le filtre par expression régulière (champ Filter) ?

Il permet de ne garder que les runs dont le nom correspond au motif (ex. .*lr1e-2.*) pour comparer uniquement une famille d’expériences.

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 ?

Images : grilles d’images mal classées ; Histograms : distributions de poids/gradients ; Graphs : architecture ; Projector : visualisation des embeddings (si add_embedding a été utilisé).

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 ?

Overfit : Loss/train ↘ en continu, Loss/val atteint un minimum puis ↗ (courbe en U), Accuracy/val stagne ou baisse. Les runs avec LR trop grand peuvent aussi diverger (perte val qui explose).

Si vous constatez beaucoup de bruit sur Loss/train_step, citez 3 façons de le réduire sans tricher sur l’évaluation.

Augmenter la taille de batch (si ressources), diminuer le LR, augmenter la moyenne temporelle (logger par époque, smoothing visuel), ou accumuler les gradients (effet batch plus grand).

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.