Frameworks : PyTorch#

La simplicité est la sophistication suprème.

Leonard de Vinci

Les chapitres précédents ont posé les fondations théoriques des réseaux de neurones : du perceptron simple à la rétropropagation du gradient. Nous avons implementé ces mécanismes from scratch, ce qui est indispensable pour comprendre les rouages internes de l’apprentissage profond. Cependant, construire et entrainer des réseaux de neurones modernes — comportant des millions de paramètres, des architectures complexes et des calculs sur GPU — nécessite des outils logiciels spécialisés. C’est le rôle des frameworks de deep learning.

Ce chapitre introduit PyTorch, l’un des frameworks les plus utilisés en recherche et en industrie. Nous couvrirons l’ensemble du pipeline : la manipulation des tenseurs, la différentiation automatique, la construction de réseaux avec nn.Module, les fonctions de coût, les optimiseurs, la boucle d’entrainement, et un exemple complet de classification. L’objectif est de fournir une maitrise pratique et solide de PyTorch, qui servira de socle pour les chapitres suivants sur les CNN, RNN et architectures avancées.

Hide code cell source

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import load_iris, make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Introduction : pourquoi un framework ?#

Le paysage des frameworks#

Implémenter un réseau de neurones from scratch est formateur, mais rapidement limitant. Les opérations clés — propagation avant, calcul du gradient, mise à jour des poids — doivent être optimisées pour la performance (parallélisme CPU/GPU, gestion mémoire, calcul matriciel efficace). Un framework de deep learning fournit :

  • des structures de données tensorielles optimisées, avec support GPU ;

  • la différentiation automatique (autograd), qui calcule les gradients sans implémentation manuelle ;

  • des briques prédéfinies (couches, fonctions d’activation, fonctions de coût, optimiseurs) ;

  • des utilitaires pour le chargement de données, le checkpointing, le déploiement.

Trois frameworks dominent aujourd’hui l’ecosystème :

Framework

Créateur

Graphe

Points forts

TensorFlow

Google

Statique (eager par défaut depuis v2)

Ecosystème complet, TF Lite, production

PyTorch

Meta (Facebook)

Dynamique

Pythonique, recherche, flexibilité

JAX

Google

Fonctionnel, compilable

Transformations fonctionnelles, XLA

Remarque 183

Le choix d’un framework dépend du contexte. TensorFlow reste très utilisé en production et dans l’industrie. JAX gagne du terrain en recherche, notamment pour les transformations fonctionnelles (vmap, jit, grad). PyTorch est le framework dominant en recherche académique et s’impose de plus en plus en production grace è TorchScript et TorchServe.

Pourquoi PyTorch ?#

PyTorch se distingue par plusieurs caractéristiques qui en font un excellent choix pour l’apprentissage :

  1. Graphe de calcul dynamique (define-by-run) : le graphe est construit à la volée à chaque passage avant, ce qui permet un débogage naturel avec les outils Python standard (print, pdb, breakpoint).

  2. Interface pythonique : PyTorch s’intègre naturellement avec l’ecosystème Python (NumPy, Matplotlib, Scikit-learn).

  3. Communauté active : documentation riche, nombreux tutoriels, bibliothèques associées (torchvision, torchaudio, Hugging Face).

  4. Recherche : la grande majorité des publications récentes en deep learning fournissent du code PyTorch.

Définition 224 (Graphe de calcul dynamique)

Un graphe de calcul dynamique (dynamic computational graph ou define-by-run) est un graphe orienté acyclique (DAG) qui représente les opérations effectuées sur les tenseurs. Contrairement à un graphe statique (construit avant l’exécution, puis exécuté), le graphe dynamique est construit pendant l’exécution du code Python. Chaque appel d’opération crée de nouveaux noeuds dans le graphe. Cela permet d’utiliser des structures de contrôle Python (if, for, while) directement dans la définition du modèle.

Tenseurs#

Le tenseur est la structure de données fondamentale de PyTorch. Un tenseur est un tableau multidimensionnel, généralisant les scalaires (0D), les vecteurs (1D), les matrices (2D) et les tableaux de rang supérieur. Les tenseurs PyTorch sont similaires aux ndarray de NumPy, mais avec deux différences majeures : le support GPU et la différentiation automatique.

Définition 225 (Tenseur PyTorch)

Un tenseur (torch.Tensor) est un tableau multidimensionnel homogène. Il est caractérisé par :

  • sa forme (shape) : un tuple d’entiers donnant la taille de chaque dimension ;

  • son type (dtype) : le type des éléments (torch.float32, torch.int64, etc.) ;

  • son périphérique (device) : cpu ou cuda (GPU NVIDIA).

Mathématiquement, un tenseur de forme \((d_1, d_2, \ldots, d_k)\) est un élément de \(\mathbb{R}^{d_1 \times d_2 \times \cdots \times d_k}\).

Création de tenseurs#

PyTorch offre de nombreuses fonctions de création de tenseurs, analogues à celles de NumPy.

Hide code cell source

# Fonctions de création de tenseurs
print("zeros(3,4):", torch.zeros(3, 4).shape, torch.zeros(3, 4).dtype)
print("ones(2,3):", torch.ones(2, 3).shape)
print("rand(2,3):\n", torch.rand(2, 3))     # uniforme dans [0, 1)
print("randn(2,3):\n", torch.randn(2, 3))   # normale N(0, 1)
print("arange(0,10,2):", torch.arange(0, 10, 2))
print("eye(3):\n", torch.eye(3))
zeros(3,4): torch.Size([3, 4]) torch.float32
ones(2,3): torch.Size([2, 3])
rand(2,3):
 tensor([[0.9855, 0.4747, 0.9629],
        [0.7217, 0.8302, 0.8453]])
randn(2,3):
 tensor([[ 0.3483, -0.7297,  2.0355],
        [ 0.6544,  1.3972,  0.1694]])
arange(0,10,2): tensor([0, 2, 4, 6, 8])
eye(3):
 tensor([[1., 0., 0.],
        [0., 1., 0.],
        [0., 0., 1.]])

Hide code cell source

# Conversion NumPy <-> PyTorch (mémoire partagée sur CPU)
arr = np.array([[1.0, 2.0], [3.0, 4.0]])
t_from_np = torch.from_numpy(arr)
print("Depuis NumPy:", t_from_np, "| dtype:", t_from_np.dtype)

arr[0, 0] = 99.0  # modifier arr modifie aussi le tenseur
print("Après modification de arr, t_from_np[0,0]:", t_from_np[0, 0].item())
Depuis NumPy: tensor([[1., 2.],
        [3., 4.]], dtype=torch.float64) | dtype: torch.float64
Après modification de arr, t_from_np[0,0]: 99.0

Remarque 184

La conversion torch.from_numpy() et .numpy() partage la mémoire entre le tenseur et le tableau NumPy (sur CPU). Modifier l’un modifie l’autre. Pour obtenir une copie indépendante, utilisez torch.tensor(arr) au lieu de torch.from_numpy(arr).

Types et opérations#

Les types les plus courants sont torch.float32 (défaut), torch.int64 (défaut pour les entiers) et torch.bool. Les tenseurs supportent l’arithmétique élément par élément, le produit matriciel et les réductions.

Hide code cell source

# Types
t_float = torch.tensor([1.0, 2.0, 3.0])       # float32
t_int = torch.tensor([1, 2, 3])                 # int64
print(f"float32: {t_float.dtype} | int64: {t_int.dtype} | .float(): {t_int.float().dtype}")

# Opérations
a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
b = torch.tensor([[5.0, 6.0], [7.0, 8.0]])
print("a + b:\n", a + b)
print("a * b (element):\n", a * b)
print("a @ b (matmul):\n", a @ b)
print("Somme:", a.sum().item(), "| Moyenne:", a.mean().item())
float32: torch.float32 | int64: torch.int64 | .float(): torch.float32
a + b:
 tensor([[ 6.,  8.],
        [10., 12.]])
a * b (element):
 tensor([[ 5., 12.],
        [21., 32.]])
a @ b (matmul):
 tensor([[19., 22.],
        [43., 50.]])
Somme: 10.0 | Moyenne: 2.5

Indexation, vues et broadcasting#

L’indexation des tenseurs suit les conventions de NumPy. Les opérations de reshaping créent des vues (pas de copie mémoire). Le broadcasting permet d’effectuer des opérations entre tenseurs de formes différentes, en étendant automatiquement les dimensions compatibles.

Hide code cell source

M = torch.arange(12).float().view(3, 4)
print("Matrice 3x4:\n", M)
print("Elément (1,2):", M[1, 2].item(), "| Ligne 0:", M[0], "| Colonne 1:", M[:, 1])

# unsqueeze : ajouter une dimension
v = torch.tensor([1.0, 2.0, 3.0])
print(f"Shape: {v.shape} -> unsqueeze(0): {v.unsqueeze(0).shape} -> unsqueeze(1): {v.unsqueeze(1).shape}")

# Broadcasting : ajouter un vecteur à chaque ligne
print("Broadcasting ones(3,4) + [1,2,3,4]:\n", torch.ones(3, 4) + torch.tensor([1., 2., 3., 4.]))
Matrice 3x4:
 tensor([[ 0.,  1.,  2.,  3.],
        [ 4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11.]])
Elément (1,2): 6.0 | Ligne 0: tensor([0., 1., 2., 3.]) | Colonne 1: tensor([1., 5., 9.])
Shape: torch.Size([3]) -> unsqueeze(0): torch.Size([1, 3]) -> unsqueeze(1): torch.Size([3, 1])
Broadcasting ones(3,4) + [1,2,3,4]:
 tensor([[2., 3., 4., 5.],
        [2., 3., 4., 5.],
        [2., 3., 4., 5.]])

GPU : accélérer les calculs#

L’un des principaux avantages de PyTorch est le support transparent des GPU NVIDIA via CUDA.

Hide code cell source

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"CUDA disponible : {torch.cuda.is_available()} | Device : {device}")

t = torch.randn(3, 3).to(device)  # déplacer un tenseur sur le device
print(f"Tenseur sur {t.device}")
CUDA disponible : False | Device : cpu
Tenseur sur cpu

Remarque 185

En pratique, on définit une variable device au début du script et on déplace systématiquement les tenseurs et les modèles sur ce device. Cela rend le code device-agnostic : il fonctionne sans modification que l’on dispose d’un GPU ou non. L’idiome standard est device = torch.device("cuda" if torch.cuda.is_available() else "cpu").

Différentiation automatique : Autograd#

La différentiation automatique (automatic differentiation, autograd) est le mécanisme qui permet à PyTorch de calculer automatiquement les gradients de n’importe quelle fonction différentiable par rapport à ses entrées. C’est le moteur de l’entrainement par descente de gradient.

Définition 226 (Différentiation automatique (Autograd))

Le système autograd de PyTorch enregistre toutes les opérations effectuées sur les tenseurs dont l’attribut requires_grad=True, construisant ainsi un graphe de calcul orienté acyclique. Lors de l’appel à .backward() sur un scalaire de sortie, PyTorch parcourt ce graphe en sens inverse (rétropropagation) pour calculer les dérivées partielles \(\frac{\partial L}{\partial \theta_i}\) de la sortie par rapport à chaque paramètre \(\theta_i\). Les gradients sont accumulés dans l’attribut .grad de chaque tenseur feuille.

Exemples de base#

Hide code cell source

# Gradient scalaire : y = x^2 + 2x + 1, dy/dx = 2x + 2
x = torch.tensor(3.0, requires_grad=True)
y = x**2 + 2*x + 1
y.backward()
print(f"x = {x.item()}, y = {y.item()}, dy/dx = {x.grad.item()} (attendu: {2*3+2})")

# Gradient vectoriel : z = w . x, dz/dw = x
w = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
xv = torch.tensor([4.0, 5.0, 6.0])
z = torch.dot(w, xv)
z.backward()
print(f"z = w.x = {z.item()} | dz/dw = {w.grad}")
x = 3.0, y = 16.0, dy/dx = 8.0 (attendu: 8)
z = w.x = 32.0 | dz/dw = tensor([4., 5., 6.])

Graphe de calcul et rétropropagation#

Chaque operation sur un tenseur requires_grad=True crée un noeud dans le graphe de calcul. L’attribut .grad_fn indique l’opération qui a produit le tenseur.

Hide code cell source

a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(3.0, requires_grad=True)
c = a * b         # MulBackward
d = c + a          # AddBackward
e = d ** 2         # PowBackward

print(f"grad_fn: c={c.grad_fn}, d={d.grad_fn}, e={e.grad_fn}")
e.backward()
# e = (a*b + a)^2 = 64 ; de/da = 2*(a*b+a)*(b+1) = 64 ; de/db = 2*(a*b+a)*a = 32
print(f"de/da = {a.grad.item()} (attendu: 64) | de/db = {b.grad.item()} (attendu: 32)")
grad_fn: c=<MulBackward0 object at 0x7f1f7d11a0e0>, d=<AddBackward0 object at 0x7f1f7d11a0e0>, e=<PowBackward0 object at 0x7f1f7d11a0e0>
de/da = 64.0 (attendu: 64) | de/db = 32.0 (attendu: 32)

Détachement et contexte sans gradient#

Certaines situations nécessitent d’arrêter le suivi du gradient : évaluation du modèle, calcul de métriques, mise à jour des poids.

Hide code cell source

x = torch.tensor(5.0, requires_grad=True)
y = x ** 2

print(f"y requires_grad: {y.requires_grad} | y.detach() requires_grad: {y.detach().requires_grad}")

with torch.no_grad():  # contexte sans gradient (pour l'évaluation)
    z = x * 2
    print(f"z requires_grad dans no_grad: {z.requires_grad}")
y requires_grad: True | y.detach() requires_grad: False
z requires_grad dans no_grad: False

Remarque 186

L’utilisation de torch.no_grad() est essentielle pour l’évaluation du modèle. Sans ce contexte, PyTorch continuerait de construire le graphe de calcul inutilement, consommant mémoire et temps de calcul. De même, model.eval() désactive les couches spécifiques à l’entrainement (Dropout, BatchNorm).

Construire un réseau : nn.Module#

Le module torch.nn fournit les briques de construction pour définir des architectures de réseaux de neurones. La classe de base est nn.Module, dont tous les modèles héritent.

Définition 227 (nn.Module)

nn.Module est la classe de base pour tous les modèles PyTorch. Elle fournit :

  • un mécanisme d’enregistrement automatique des paramètres (poids et biais) ;

  • une méthode forward() qui définit la passe avant (forward pass) ;

  • des utilitaires : parameters(), to(device), train(), eval(), state_dict().

Un modèle est défini en héritant de nn.Module et en implémentant la méthode forward().

Couche linéaire et modèle personnalisé#

La brique la plus élémentaire est la couche linéaire (fully connected, dense) : \(\mathbf{y} = \mathbf{W}\mathbf{x} + \mathbf{b}\).

Hide code cell source

linear = nn.Linear(in_features=4, out_features=3)  # 4 entrées -> 3 sorties
print("Poids:", linear.weight.shape, "| Biais:", linear.bias.shape)
print("Sortie:", linear(torch.randn(1, 4)).shape)
Poids: torch.Size([3, 4]) | Biais: torch.Size([3])
Sortie: torch.Size([1, 3])

Voici le patron de conception standard pour définir un réseau.

Hide code cell source

class MLP(nn.Module):
    """Perceptron multicouche (Multi-Layer Perceptron)."""

    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        return x

model = MLP(input_dim=4, hidden_dim=16, output_dim=3)
print(model)
n_params = sum(p.numel() for p in model.parameters())
print(f"\nNombre total de paramètres : {n_params}")
MLP(
  (fc1): Linear(in_features=4, out_features=16, bias=True)
  (relu): ReLU()
  (fc2): Linear(in_features=16, out_features=3, bias=True)
)

Nombre total de paramètres : 131

Remarque 187

Dans __init__, on déclare les couches comme attributs de l’instance. PyTorch les détecte automatiquement et enregistre leurs paramètres. Ne jamais définir de couches dans forward(), car elles ne seraient pas enregistrées et leurs paramètres ne seraient pas optimisés.

nn.Sequential et inspection des paramètres#

Pour les architectures simples (empilement de couches), nn.Sequential offre une syntaxe plus concise.

Hide code cell source

model_seq = nn.Sequential(
    nn.Linear(4, 32), nn.ReLU(),
    nn.Linear(32, 16), nn.ReLU(),
    nn.Linear(16, 3))

x = torch.randn(5, 4)
print(f"Entrée: {x.shape} -> Sortie: {model_seq(x).shape}\n")

# Paramètres nommes du MLP precedent
for name, param in model.named_parameters():
    print(f"{name:10s} | shape: {str(param.shape):15s} | requires_grad: {param.requires_grad}")
Entrée: torch.Size([5, 4]) -> Sortie: torch.Size([5, 3])

fc1.weight | shape: torch.Size([16, 4]) | requires_grad: True
fc1.bias   | shape: torch.Size([16]) | requires_grad: True
fc2.weight | shape: torch.Size([3, 16]) | requires_grad: True
fc2.bias   | shape: torch.Size([3]) | requires_grad: True

Fonctions de coût#

La fonction de coût (loss function) mesure l’écart entre les prédictions du modèle et les valeurs cibles. Le choix de la fonction de coût dépend du type de problème.

Définition 228 (Fonctions de cout principales)

Les fonctions de coût les plus utilisées en deep learning sont :

  • MSE (Mean Squared Error) pour la régression :

\[\mathcal{L}_{\text{MSE}} = \frac{1}{n} \sum_{i=1}^n (y_i - \hat{y}_i)^2\]
  • Cross-entropy pour la classification multiclasse (\(K\) classes) :

\[\mathcal{L}_{\text{CE}} = -\frac{1}{n} \sum_{i=1}^n \log \frac{e^{z_{i, y_i}}}{\sum_{k=1}^K e^{z_{i,k}}}\]

\(z_{i,k}\) sont les logits (sorties brutes avant softmax) et \(y_i \in \{0, \ldots, K-1\}\).

  • BCE with logits pour la classification binaire :

\[\mathcal{L}_{\text{BCE}} = -\frac{1}{n} \sum_{i=1}^n \left[ y_i \log \sigma(z_i) + (1 - y_i) \log (1 - \sigma(z_i)) \right]\]

\(\sigma\) est la fonction sigmoide.

Hide code cell source

# --- Régression : MSELoss ---
mse_loss = nn.MSELoss()
y_pred = torch.tensor([2.5, 0.0, 2.1])
y_true = torch.tensor([3.0, -0.5, 2.0])
print(f"MSE Loss: {mse_loss(y_pred, y_true).item():.4f}")

# --- Classification multiclasse : CrossEntropyLoss ---
ce_loss = nn.CrossEntropyLoss()
logits = torch.tensor([[2.0, 1.0, 0.1],    # échantillon 1 : classe 0 probable
                        [0.5, 2.5, 0.3]])    # échantillon 2 : classe 1 probable
labels = torch.tensor([0, 1])                # vraies classes
print(f"Cross-Entropy Loss: {ce_loss(logits, labels).item():.4f}")

# --- Classification binaire : BCEWithLogitsLoss ---
bce_loss = nn.BCEWithLogitsLoss()
logits_bin = torch.tensor([0.8, -1.2, 2.0])
labels_bin = torch.tensor([1.0, 0.0, 1.0])
print(f"BCE with Logits Loss: {bce_loss(logits_bin, labels_bin).item():.4f}")
MSE Loss: 0.1700
Cross-Entropy Loss: 0.3185
BCE with Logits Loss: 0.2538

Remarque 188

nn.CrossEntropyLoss de PyTorch combine LogSoftmax et NLLLoss en une seule fonction. Il attend des logits (sorties brutes) et non des probabilités. N’appliquez pas softmax a la sortie du réseau avant de passer les prédictions à CrossEntropyLoss : cela doublerait le softmax et produirait des gradients incorrects.

Optimiseurs#

Les optimiseurs implémentent les algorithmes de mise à jour des paramètres. Le module torch.optim fournit les principaux algorithmes de descente de gradient.

Définition 229 (Optimiseur)

Un optimiseur est un algorithme qui met à jour les paramètres \(\boldsymbol{\theta}\) du modèle dans la direction opposée au gradient de la fonction de coût :

\[\boldsymbol{\theta}_{t+1} = \boldsymbol{\theta}_t - \eta \, g(\nabla_{\boldsymbol{\theta}} \mathcal{L})\]

\(\eta\) est le taux d’apprentissage (learning rate) et \(g(\cdot)\) est une transformation du gradient qui dépend de l’algorithme (identité pour SGD, moment adaptatif pour Adam, etc.).

Hide code cell source

model_demo = nn.Linear(4, 2)

# SGD : descente de gradient stochastique
optimizer_sgd = optim.SGD(model_demo.parameters(), lr=0.01, momentum=0.9)

# Adam : adaptive moment estimation (le plus utilisé)
optimizer_adam = optim.Adam(model_demo.parameters(), lr=1e-3)

# AdamW : Adam avec weight decay decoupled (régularisation L2 correcte)
optimizer_adamw = optim.AdamW(model_demo.parameters(), lr=1e-3, weight_decay=1e-2)

print("Optimiseurs crées avec succès.")
Optimiseurs crées avec succès.

Remarque 189

Adam est généralement un bon choix par défaut. Son taux d’apprentissage typique est \(10^{-3}\). AdamW est préféré lorsque l’on utilise la régularisation L2, car il découple le weight decay de la mise à jour adaptative du gradient. SGD avec momentum peut converger vers de meilleurs minima sur certains problèmes, mais nécessite un réglage plus fin du learning rate.

Schedulers de learning rate#

Un scheduler ajuste le taux d’apprentissage au cours de l’entrainement, ce qui améliore souvent la convergence.

Hide code cell source

model_sched = nn.Linear(4, 2)
optimizer = optim.Adam(model_sched.parameters(), lr=1e-2)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)

lrs = []
for epoch in range(50):
    lrs.append(optimizer.param_groups[0]['lr'])
    optimizer.step(); scheduler.step()

fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(lrs, marker='o', markersize=3)
ax.set_xlabel("Epoque"); ax.set_ylabel("Learning rate")
ax.set_title("Evolution du learning rate avec StepLR")
plt.tight_layout(); plt.show()
_images/4b194da731e11b9b13ca63ca0180cfac81b8e03a53343a4cd5db7fd0d1e0a2b6.png

Boucle d’entrainement#

L’entrainement d’un réseau de neurones en PyTorch suit un patron (pattern) standard et explicite. Contrairement à Keras qui cache la boucle derrière model.fit(), PyTorch laisse le contrôle total au developpeur.

Dataset et DataLoader#

Définition 230 (DataLoader)

Un DataLoader est un itérateur qui découpe un Dataset en mini-batches de taille fixe. A chaque époque, il :

  1. mélange (shuffle) optionnellement les indices ;

  2. regroupe les échantillons en batches de taille batch_size ;

  3. les convertit en tenseurs prêts pour la passe avant.

Le chargement par mini-batches permet la descente de gradient stochastique : à chaque itération, le gradient est estimé sur un sous-ensemble des données, ce qui réduit l’empreinte mémoire et introduit une régularisation implicite.

Hide code cell source

X_demo = torch.randn(100, 4)
y_demo = torch.randint(0, 3, (100,))

dataset = TensorDataset(X_demo, y_demo)
loader = DataLoader(dataset, batch_size=16, shuffle=True)

for X_batch, y_batch in loader:
    print(f"Batch - X: {X_batch.shape}, y: {y_batch.shape}")
    break  # on affiche seulement le premier batch
Batch - X: torch.Size([16, 4]), y: torch.Size([16])

Le patron d’entrainement#

Exemple 19 (Boucle d’entrainement PyTorch)

Le patron standard comporte cinq étapes par itération :

  1. Passe avant (forward pass) : y_pred = model(X) ;

  2. Calcul du coût : loss = criterion(y_pred, y) ;

  3. Remise à zero des gradients : optimizer.zero_grad() ;

  4. Rétropropagation (backward pass) : loss.backward() ;

  5. Mise à jour des poids : optimizer.step().

L’ordre des étapes 3 et 4 est critique : les gradients sont accumulés par défaut, donc il faut les remettre à zero avant chaque rétropropagation.

Hide code cell source

# Illustration minimale : régression y = 3x + 2 + bruit
torch.manual_seed(42)
X_train = torch.randn(200, 1)
y_train = 3 * X_train + 2 + 0.3 * torch.randn(200, 1)

model_reg = nn.Linear(1, 1)
criterion = nn.MSELoss()
optimizer = optim.SGD(model_reg.parameters(), lr=0.1)

losses = []
for epoch in range(100):
    y_pred = model_reg(X_train)          # 1. Passe avant
    loss = criterion(y_pred, y_train)    # 2. Calcul du coût
    optimizer.zero_grad()                 # 3. RAZ des gradients
    loss.backward()                       # 4. Rétropropagation
    optimizer.step()                      # 5. Mise à jour
    losses.append(loss.item())
    if (epoch + 1) % 25 == 0:
        print(f"Epoque {epoch+1:3d} | Loss: {loss.item():.4f}")

w = model_reg.weight.item()
b = model_reg.bias.item()
print(f"\nParamètres appris : w = {w:.3f} (vrai: 3.0), b = {b:.3f} (vrai: 2.0)")
Epoque  25 | Loss: 0.0750
Epoque  50 | Loss: 0.0747
Epoque  75 | Loss: 0.0747
Epoque 100 | Loss: 0.0747

Paramètres appris : w = 3.015 (vrai: 3.0), b = 2.039 (vrai: 2.0)

Hide code cell source

fig, ax = plt.subplots(figsize=(8, 3.5))
ax.plot(losses, color='steelblue')
ax.set_xlabel("Epoque"); ax.set_ylabel("MSE Loss")
ax.set_title("Convergence de la régression linéaire")
plt.tight_layout(); plt.show()
_images/3c98ae9bb8821d07f17667427884454566bb621bc313df808e0b8b47a602593a.png

Mode entrainement / évaluation#

PyTorch distingue deux modes : model.train() active les couches spécifiques à l’entrainement (Dropout, BatchNorm avec statistiques de batch) ; model.eval() les désactive pour l’inférence.

Remarque 190

Il faut toujours appeler model.eval() avant l’évaluation et model.train() avant de reprendre l’entrainement. L’oubli de model.eval() est une source fréquente de bugs subtils : le Dropout continue de masquer des neurones aléatoirement, et la BatchNorm utilise les statistiques du batch courant au lieu des statistiques globales.

Exemple complet : classification sur Iris#

Mettons ensemble tous les concepts dans un exemple complet de classification multiclasse sur le jeu de données Iris.

Preparation des données#

Hide code cell source

iris = load_iris()
X = iris.data.astype(np.float32)
y = iris.target.astype(np.int64)

print(f"Features: {iris.feature_names}")
print(f"Classes: {iris.target_names}")
print(f"Shape: X={X.shape}, y={y.shape}")
print(f"Distribution des classes: {np.bincount(y)}")
Features: ['sepal length (cm)', 'sepal width (cm)', 'petal length (cm)', 'petal width (cm)']
Classes: ['setosa' 'versicolor' 'virginica']
Shape: X=(150, 4), y=(150,)
Distribution des classes: [50 50 50]

Hide code cell source

# Séparation train/test et standardisation
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

# Conversion en tenseurs PyTorch
X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.long)
X_test_t = torch.tensor(X_test, dtype=torch.float32)
y_test_t = torch.tensor(y_test, dtype=torch.long)

# DataLoader
train_dataset = TensorDataset(X_train_t, y_train_t)
train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
print(f"Train: {X_train_t.shape}, Test: {X_test_t.shape}")
Train: torch.Size([120, 4]), Test: torch.Size([30, 4])

Définition du modèle#

Hide code cell source

class IrisClassifier(nn.Module):
    """MLP pour la classification d'Iris."""

    def __init__(self, input_dim=4, hidden1=32, hidden2=16, output_dim=3):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden1),
            nn.ReLU(),
            nn.Linear(hidden1, hidden2),
            nn.ReLU(),
            nn.Linear(hidden2, output_dim)
        )

    def forward(self, x):
        return self.net(x)

torch.manual_seed(42)
model = IrisClassifier()
print(model)
n_params = sum(p.numel() for p in model.parameters())
print(f"Paramètres : {n_params}")
IrisClassifier(
  (net): Sequential(
    (0): Linear(in_features=4, out_features=32, bias=True)
    (1): ReLU()
    (2): Linear(in_features=32, out_features=16, bias=True)
    (3): ReLU()
    (4): Linear(in_features=16, out_features=3, bias=True)
  )
)
Paramètres : 739

Entrainement#

Hide code cell source

learning_rate = 1e-2
n_epochs = 100

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=learning_rate)

train_losses, train_accs = [], []
test_losses, test_accs = [], []

for epoch in range(n_epochs):
    # --- Phase d'entrainement ---
    model.train()
    running_loss, correct, total = 0.0, 0, 0

    for X_batch, y_batch in train_loader:
        logits = model(X_batch)
        loss = criterion(logits, y_batch)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        running_loss += loss.item() * X_batch.size(0)
        _, predicted = torch.max(logits, dim=1)
        correct += (predicted == y_batch).sum().item()
        total += y_batch.size(0)

    train_losses.append(running_loss / total)
    train_accs.append(correct / total)

    # --- Phase d'évaluation ---
    model.eval()
    with torch.no_grad():
        logits_test = model(X_test_t)
        test_loss = criterion(logits_test, y_test_t).item()
        _, predicted_test = torch.max(logits_test, dim=1)
        test_acc = (predicted_test == y_test_t).float().mean().item()

    test_losses.append(test_loss)
    test_accs.append(test_acc)

    if (epoch + 1) % 20 == 0:
        print(f"Epoque {epoch+1:3d} | "
              f"Train Loss: {train_losses[-1]:.4f} | Train Acc: {train_accs[-1]:.3f} | "
              f"Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.3f}")
Epoque  20 | Train Loss: 0.0449 | Train Acc: 0.983 | Test Loss: 0.0777 | Test Acc: 0.967
Epoque  40 | Train Loss: 0.0260 | Train Acc: 0.992 | Test Loss: 0.1046 | Test Acc: 0.933
Epoque  60 | Train Loss: 0.0263 | Train Acc: 0.983 | Test Loss: 0.1143 | Test Acc: 0.967
Epoque  80 | Train Loss: 0.0205 | Train Acc: 0.983 | Test Loss: 0.1289 | Test Acc: 0.967
Epoque 100 | Train Loss: 0.0047 | Train Acc: 1.000 | Test Loss: 0.1507 | Test Acc: 0.967

Courbes d’apprentissage#

Hide code cell source

fig, axes = plt.subplots(2, 1, figsize=(9, 8))

ax = axes[0]
ax.plot(train_losses, label='Train', color='steelblue')
ax.plot(test_losses, label='Test', color='coral')
ax.set_xlabel("Epoque")
ax.set_ylabel("Cross-Entropy Loss")
ax.set_title("Evolution de la perte")
ax.legend()

ax = axes[1]
ax.plot(train_accs, label='Train', color='steelblue')
ax.plot(test_accs, label='Test', color='coral')
ax.set_xlabel("Epoque")
ax.set_ylabel("Accuracy")
ax.set_title("Evolution de la précision")
ax.legend()

plt.tight_layout()
plt.show()
_images/9a98e6d18e5976924b0bc1d8aa66d9b1af6623e9a621f187931170086c9fb927.png

Evaluation finale et prédictions#

Hide code cell source

model.eval()
with torch.no_grad():
    logits_final = model(X_test_t)
    probas = torch.softmax(logits_final, dim=1)
    _, y_pred_final = torch.max(logits_final, dim=1)

accuracy = (y_pred_final == y_test_t).float().mean().item()
print(f"Accuracy finale sur le test set : {accuracy:.1%}")

# Matrice de confusion
cm = np.zeros((3, 3), dtype=int)
for true, pred in zip(y_test_t.numpy(), y_pred_final.numpy()):
    cm[true, pred] += 1

fig, ax = plt.subplots(figsize=(6, 5))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=iris.target_names,
            yticklabels=iris.target_names, ax=ax)
ax.set_xlabel("Prédiction")
ax.set_ylabel("Vérité")
ax.set_title("Matrice de confusion")
plt.tight_layout()
plt.show()
Accuracy finale sur le test set : 96.7%
_images/7f5528340312646999244d94816966405043915b4097ccdf3bd44fd8caa4447a.png

Frontière de décision non linéaire (moons)#

Pour illustrer la puissance des réseaux de neurones sur un problème non linéaire, appliquons un MLP au jeu de données moons avec BCEWithLogitsLoss.

Hide code cell source

np.random.seed(42)
X_moons, y_moons = make_moons(n_samples=500, noise=0.2, random_state=42)
X_m_train, X_m_test, y_m_train, y_m_test = train_test_split(
    X_moons, y_moons, test_size=0.2, random_state=42)

scaler_m = StandardScaler()
X_m_train = scaler_m.fit_transform(X_m_train).astype(np.float32)
X_m_test = scaler_m.transform(X_m_test).astype(np.float32)

X_m_train_t = torch.tensor(X_m_train)
y_m_train_t = torch.tensor(y_m_train, dtype=torch.float32).unsqueeze(1)
X_m_test_t = torch.tensor(X_m_test)
y_m_test_t = torch.tensor(y_m_test, dtype=torch.float32).unsqueeze(1)

torch.manual_seed(42)
model_moons = nn.Sequential(
    nn.Linear(2, 32), nn.ReLU(),
    nn.Linear(32, 16), nn.ReLU(),
    nn.Linear(16, 1))

criterion_bce = nn.BCEWithLogitsLoss()
optimizer_m = optim.Adam(model_moons.parameters(), lr=1e-2)

for epoch in range(200):
    model_moons.train()
    logits = model_moons(X_m_train_t)
    loss = criterion_bce(logits, y_m_train_t)
    optimizer_m.zero_grad()
    loss.backward()
    optimizer_m.step()

    if (epoch + 1) % 50 == 0:
        model_moons.eval()
        with torch.no_grad():
            preds = (torch.sigmoid(model_moons(X_m_test_t)) > 0.5).float()
            acc = (preds == y_m_test_t).float().mean().item()
        print(f"Epoque {epoch+1:3d} | Loss: {loss.item():.4f} | Test Acc: {acc:.3f}")
Epoque  50 | Loss: 0.1755 | Test Acc: 0.940
Epoque 100 | Loss: 0.0429 | Test Acc: 0.970
Epoque 150 | Loss: 0.0302 | Test Acc: 0.970
Epoque 200 | Loss: 0.0258 | Test Acc: 0.970

Hide code cell source

# Visualisation de la frontière de décision
model_moons.eval()
h = 0.02
x_min, x_max = X_m_train[:, 0].min() - 0.5, X_m_train[:, 0].max() + 0.5
y_min, y_max = X_m_train[:, 1].min() - 0.5, X_m_train[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
grid = torch.tensor(np.c_[xx.ravel(), yy.ravel()], dtype=torch.float32)

with torch.no_grad():
    Z = torch.sigmoid(model_moons(grid)).numpy().reshape(xx.shape)

fig, ax = plt.subplots(figsize=(8, 6))
ax.contourf(xx, yy, Z, levels=50, cmap='RdBu_r', alpha=0.8)
ax.contour(xx, yy, Z, levels=[0.5], colors='k', linewidths=2)
ax.scatter(X_m_train[:, 0], X_m_train[:, 1], c=y_m_train,
           cmap='RdBu_r', edgecolors='k', linewidths=0.5, s=30, alpha=0.7)
ax.set_xlabel("$x_1$")
ax.set_ylabel("$x_2$")
ax.set_title("Frontière de décision apprise par le MLP (moons)")
plt.tight_layout()
plt.show()
_images/8895c52b3c080419d5c54931131a392683c7a2756999eda5f3ccca8142aa740c.png

Remarque 191

Le MLP est capable d’apprendre des frontières de décision hautement non linéaires, ce qui était impossible avec un perceptron simple. C’est la puissance de la composition de transformations linéaires et de non-linéarites : chaque couche réorganise l’espace de représentation pour rendre le problème progressivement plus séparable.

Bonnes pratiques#

Reproductibilité#

Hide code cell source

def set_seed(seed=42):
    """Fixer toutes les graines pour la reproductibilité."""
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    np.random.seed(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

set_seed(42)
print("Graines fixées pour la reproductibilité.")
Graines fixées pour la reproductibilité.

Remarque 192

Même avec toutes les graines fixées, le déterminisme parfait sur GPU n’est pas toujours garanti en raison des algorithmes parallèles non déterministes de cuDNN. L’option torch.backends.cudnn.deterministic = True force l’utilisation d’algorithmes déterministes, mais peut ralentir l’entrainement.

Checkpointing : sauvegarder et charger un modèle#

Hide code cell source

# Sauvegarder le state_dict (recommandé)
# torch.save(model.state_dict(), "model_iris.pt")

# Charger le modèle
# model_loaded = IrisClassifier()
# model_loaded.load_state_dict(torch.load("model_iris.pt"))
# model_loaded.eval()

# Checkpoint complet (modèle + optimiseur + métriques)
checkpoint = {
    'epoch': n_epochs,
    'model_state_dict': model.state_dict(),
    'optimizer_state_dict': optimizer.state_dict(),
    'train_loss': train_losses[-1],
    'test_acc': test_accs[-1],
}
# torch.save(checkpoint, "checkpoint_iris.pt")

print("Exemple de checkpoint :")
for key, val in checkpoint.items():
    if isinstance(val, dict):
        print(f"  {key}: dict avec {len(val)} cles")
    else:
        print(f"  {key}: {val}")
Exemple de checkpoint :
  epoch: 100
  model_state_dict: dict avec 6 cles
  optimizer_state_dict: dict avec 2 cles
  train_loss: 0.004740886584719798
  test_acc: 0.9666666388511658

Remarque 193

Toujours sauvegarder le state_dict() plutôt que le modèle entier (torch.save(model, ...)). La sauvegarde du modèle entier utilise pickle, ce qui lie le fichier à la structure de classe exacte au moment de la sauvegarde. Le state_dict ne contient que les tenseurs de paramètres, ce qui est plus portable et robuste.

Code device-agnostic et débogage#

Remarque 194

Le patron standard pour écrire du code device-agnostic (fonctionnant indifféremment sur CPU et GPU) consiste à : (1) définir device = torch.device("cuda" if torch.cuda.is_available() else "cpu") au début du script ; (2) déplacer le modèle avec model.to(device) ; (3) déplacer chaque batch avec X_batch.to(device) dans la boucle d’entrainement. Toutes les opérations doivent être effectuées sur le même device. Oublier de déplacer les données ou le modèle provoque une RuntimeError.

Remarque 195

Quelques conseils pratiques pour déboguer un réseau PyTorch :

  1. Vérifier les shapes : ajouter des print(x.shape) dans forward() pour tracer les dimensions.

  2. Commencer petit : entrainer sur quelques échantillons pour vérifier que le modèle peut overfitter.

  3. Vérifier le gradient : inspecter param.grad après backward() pour s’assurer que les gradients ne sont ni nuls ni explosifs.

  4. Un seul changement à la fois : modifier un paramètre à la fois pour isoler l’effet de chaque changement.

  5. Visualiser : tracer les courbes de loss train/test pour détecter le surapprentissage ou la divergence.

Récapitulatif du pipeline#

Proposition 58 (Pipeline PyTorch)

Le pipeline standard d’un projet de deep learning avec PyTorch :

  1. Données : charger, prétraiter, normaliser, créer Dataset et DataLoader.

  2. Modèle : définir l’architecture en héritant de nn.Module, implémenter forward().

  3. Coût et optimiseur : choisir la fonction de coût, l’optimiseur, éventuellement un scheduler.

  4. Entrainement : boucle train/eval avec accumulation des métriques.

  5. Evaluation : métriques sur le jeu de test, visualisations.

  6. Sauvegarde : checkpointing du state_dict et des hyperparamètres.

Chacune de ces étapes est explicite et controlable, ce qui est à la fois la force et la complexité de PyTorch par rapport à des API de plus haut niveau comme Keras.

Hide code cell source

print(f"PyTorch version : {torch.__version__}")
print(f"CUDA disponible : {torch.cuda.is_available()}")
print(f"Device par défaut : {'cuda' if torch.cuda.is_available() else 'cpu'}")
PyTorch version : 2.10.0+cpu
CUDA disponible : False
Device par défaut : cpu

Ce chapitre a posé les bases de l’utilisation de PyTorch. Les concepts présentés ici — tenseurs, autograd, nn.Module, boucle d’entrainement — sont les briques fondamentales sur lesquelles reposent toutes les architectures avancées. Dans les chapitres suivants, nous les réutiliserons pour construire des réseaux convolutifs (CNN) pour la vision et des réseaux récurrents (RNN) pour les séquences.