Réseau
Nous allons voir comment mettre en place un réseau dans la librairie Pytorch.
Le modèle Sequential
Le modèle Sequential de PyTorch permet de définir un réseau en empilant les couches les unes à la suite des autres. Les traitements sont effectués de manière séquentielle : la sortie d’une couche correspond à l’entrée de la suivante. Voici un exemple :
nbClasses = ...
model = nn.Sequential(
nn.Flatten(), # (N, 1, L, H) → (N, LxH)
nn.Linear(28*28, 128), # 1ère couche FC
nn.ReLU(),
nn.Linear(128, nbClasses) # 2ème couche FC (sortie)
)
Ce réseau accepte des images 1x28x28 en entrée, les transforme en 1 vecteur 1D de 28x28 valeurs, applique une première couche FC, puis une ReLU() et une deuxième couche FC pour obtenir des valeurs correspondant aux scores associés aux différentes classes.
Le modèle Custom
Le modèle Custom offre un contrôle total sur les traitements effectués par le réseau. Si cette méthode demande un peu plus d’effort au départ — entre la création d’une classe et l’implémentation de plusieurs fonctions — elle offre en contrepartie plusieurs avantages :
Permettre de concevoir des architectures complexes : chemins multiples, conditions…
Regrouper les couches par thématique : features / classifier
Contrôler finement le réseau : gel de couches, têtes multiples…
Positionner un breakpoint, pour débogguer votre chaîne de traitements
Convertissons l’exemple précédent en custom model :
import torch
import torch.nn as nn
class CustomModel(nn.Module):
def __init__(self, nbClasses):
super().__init__()
self.flatten = nn.Flatten()
self.fc1 = nn.Linear(28*28, 128)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(128, nbClasses)
def forward(self, x):
x = self.flatten(x)
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
model = CustomModel(10)
Nous remarquons deux choses :
Le réseau est construit en dérivant la classe nn.Module
La passe Forward est gérée par la fonction forward(x)
Quelles sont les facilités apportées par l’héritage de nn.Module ?
Gestion des couches : En écrivant self.fc1 = …, la couche fc1 est enregistrée comme faisant partie du modèle. C’est particulièrement pratique, car par exemple, lors du transfert du modèle vers un GPU, toutes les couches ainsi définies sont automatiquement déplacées, sans manipulation supplémentaire.
model(x) : Lorsque l’on écrit y = model(x), cet appel est redirigé vers model.forward(x)
Avertissement
L’écriture de self.mylayer = … est indispensable. Si vous écrivez seulement mylayer = …, la couche n’est pas enregistrée dans le réseau et ses paramètres ne seront ni entraînés, ni conservés, ce qui conduit à des comportements erratiques.
Exemple
Prenons un exemple avec une image issue du jeu de données MNIST, qui comporte 10 classes correspondant aux chiffres de 0 à 9 :
from torchvision import datasets, transforms
dataset = datasets.MNIST(root="data", train=True, download=True, transform=transforms.ToTensor() )
image, label = dataset[0]
print(image.shape)
>> torch.Size([1, 28, 28])
image = image.unsqueeze(0) # (1, 28, 28) => (1, 1, 28, 28) -> batch of 1 image 1x28x28
logits = model(image)
print(logits.shape)
>> torch.Size([1, 10])
Quelques remarques :
Nous extrayons une image de la base MNIST, sa dimension est: [1,28,28]
Un réseau accepte un batch de N images, soit un tenseur de la forme [N,nbChannels,L,H]
Pour traiter une seule image, il faut donc ajouter une dimension avec la fonction unsqueeze pour obtenir un tenseur de taille [1,1,28,28]
On injecte l’image dans le réseau en écrivant : model(image)
Le logit correspond à la sortie brute du réseau (avant application d’un softmax ou tout autre fonction de postprocess)
Comme il y a 10 catégories en sortie du rédeau et comme nous avons traité un lot contenant 1 image, le tenseur de sortie logits a donc une taille [1,10]
Fonctionnement
Modes
Tout réseau, custom ou sequential dispose des fonctionnalités suivantes :
model.train() : bascule le réseau en mode entraînement
model.eval() : bascule le réseau en mode évaluation
En mode entraînement, certaines couches comme Dropout ou BatchNorm adoptent un comportement spécifique nécessaire à l’apprentissage. A contrario, en mode évaluation, ces mêmes couches sont désactivées ou figées afin de garantir des prédictions stables et cohérentes.
Poids
Les poids contenus dans chaque couche du modèle peuvent être récupérés à l’aide de la méthode model.parameters(). On peut par exemple, utiliser cette fonction pour transmettre l’ensemble des poids à un optimiseur afin qu’il les mette à jour lors de la backpropagation.
Sauvegarde
Les fonctions model.state_dict() et model.load_state_dict(…) permettent respectivement d’extraire et de charger l’ensemble des paramètres du réseau. La méthode state_dict() retourne un dictionnaire contenant tous les poids et biais des différentes couches du modèle, identifiés par leur nom. Ce dictionnaire peut être enregistré sur le disque afin de conserver l’état du réseau après l’entraînement. À l’inverse, load_state_dict(…) permet de restaurer ces paramètres dans un modèle ayant la même architecture, ce qui rend possible la reprise d’un entraînement ou l’utilisation d’un modèle pré-entraîné pour l’inférence.
torch.save(model.state_dict(), "modele.pth") # sauvegarde
model.load_state_dict(torch.load("modele.pth")) # chargement
Best model
À la fin de chaque epoch, on peut sauvegarder le modèle s’il obtient de meilleures performances qu’auparavant. Pour cela, il suffit de créer une variable qui mémorise la meilleure loss ou accuracy observée depuis le début de l’entraînement. Chaque fois que le réseau bat sa meilleure performance connue, on enregistre les poids du modèle.
L’objectif ici est de récupérer les poids du réseau afin de pouvoir l’utiliser ensuite en phase d’inférence.
Last model
Ce scénario consiste à sauvegarder le réseau à chaque fin d’epoch pour éventuellement reprendre l’entraîenemnt plus tard, soit pour améliorer les performances, soit parce que le serveur n’était plus acccessible.
Ce scénario à l’apparence plus simple : on sauvegarde le modèle à chaque fin d’epoch est pourtant plus compliqué à gérer. En effet, il faut sauvegarder toutes les données nécessaires pour reprendre l’entraînement exactement dans le même contexte. Cela implique de faire un snapshot qui stockent tous les données évoluant durant le process d’apprentissage, et cela représente beaucoup plus que les poids du réseau, on peut citer :
Le numéro de l’epoch - pour recaler les courbes
La meilleure Loss connue - pour gérer le best model
Les paramètres internes de l’optimiseur/scheduler - c’est vital
Voici un code exemple :
for epoch in range(nb_epochs):
model.train()
...
if val_loss < best_loss:
best_loss = val_loss
torch.save({
"epoch": epoch,
"best_loss": best_loss,
"model_state": model.state_dict(),
"optim_state": optimizer.state_dict(),
}, "checkpoint_best.pth")
Pour automatiser le redémarrage du script, il suffit de vérifier la présence d’un fichier de sauvegarde. S’il est trouvé, on recharge les différentes informations :
if os.path.exists("checkpoint_best.pth"):
info = torch.load("checkpoint_best.pth")
model.load_state_dict(info["model_state"])
optimizer.load_state_dict(info["optim_state"])
best_loss = info["best_loss"]
start_epoch = info["epoch"] + 1
Modèles pré-entraînés
Principe
PyTorch met à disposition, via torchvision, un ensemble de modèles standards déjà entraînés sur de grands jeux de données (principalement ImageNet). Ces modèles servent de base solide pour l’inférence ou le transfer learning. Parmi les plus connus, on trouve : ResNet, VGG, AlexNet, DenseNet, MobileNet.
Chargement
Le chargement se fait en une ligne :
from torchvision import models
model = models.resnet18(weights="DEFAULT")
L’architecture est créée automatiquement
Les poids sont chargés
Le modèle est prêt à l’emploi
Si vous faîtes uniquement de l’inférence, il faut exécuter : model.eval() pour désactiver les couches de Dropout/BatchNorm toujours présentes dans la sauvegarde.