DL · Chapitre 3
Deep Learning 3 — Réseaux convolutifs et traitement d'images (Partie 1)
Cours IA — Centrale Lille — Pascal Yim
Les chapitres précédents ont introduit le perceptron multicouche (MLP) comme outil universel d'apprentissage supervisé. Ce modèle, capable d'approximer n'importe quelle fonction continue suffisamment régulière, fonctionne pourtant médiocrement lorsqu'on l'applique à des images : il ignore complètement la structure spatiale de la donnée. Une image n'est pas un vecteur abstrait dans ; c'est une grille bidimensionnelle de pixels où la position relative des intensités porte l'essentiel de l'information sémantique. Un chiffre « 7 » et un chiffre « 1 » partagent à peu près les mêmes pixels actifs, mais leur disposition diffère. Un MLP, qui traite chaque pixel comme une variable indépendante, doit redécouvrir cette structure à partir de zéro à travers ses poids, ce qui est à la fois coûteux et fragile.
Ce chapitre introduit les réseaux de neurones convolutifs (CNN, Convolutional Neural Networks), une classe d'architectures conçue pour exploiter directement la régularité spatiale des images. Nous y traitons les briques fondamentales : la convolution 2D, le filtre (ou kernel), le padding, le stride, l'activation ReLU, le max pooling, l'aplatissement et la couche entièrement connectée terminale. Nous illustrons le tout par la classification de chiffres manuscrits du jeu MNIST, et nous étendons brièvement la démarche à des images couleur (CIFAR-10).
1. Les limites du MLP face aux images
Reprenons rapidement le pipeline du chapitre précédent appliqué à MNIST. Les images en niveaux de gris sont aplaties en vecteurs de longueur , normalisées dans , puis fournies à un MLP à deux couches cachées :
m = X_train_t.shape[1] # 784 model = nn.Sequential( nn.Linear(m, 32), nn.ReLU(), nn.Linear(32, 16), nn.ReLU(), nn.Linear(16, 10), ) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
Cette architecture compte paramètres rien que dans les poids des couches denses. Tout pixel d'entrée est connecté à tout neurone de la première couche cachée, sans qu'aucun mécanisme n'encourage le réseau à reconnaître la même forme à des positions différentes. Si l'on déplace un chiffre de quelques pixels vers la droite, le vecteur d'entrée change radicalement et le MLP doit, en pratique, mémoriser séparément les versions translatées.
Trois faiblesses se conjuguent :
- Absence d'invariance spatiale. Un détecteur de bord oblique appris à un endroit du champ de vision n'est pas réutilisé ailleurs.
- Explosion combinatoire des paramètres dès que les images deviennent un peu plus grandes (une image produit un vecteur de entrées).
- Perte de la structure locale. Le voisinage immédiat d'un pixel est noyé dans un fouillis d'autres dépendances apprises sans biais inductif.
Les CNN apportent une réponse simple : remplacer la connectivité totale par une connectivité locale et imposer un partage de poids entre les positions spatiales.
À retenir — pourquoi des CNN ? Un CNN exploite deux propriétés des images : la localité (les pixels voisins sont corrélés) et la stationnarité (le même motif peut apparaître n'importe où). La convolution est exactement l'opérateur linéaire qui respecte ces deux hypothèses.
2. Le format des tenseurs en PyTorch
Avant toute chose, il faut comprendre que les couches convolutives n'acceptent pas les vecteurs aplatis utilisés par le MLP. Elles attendent un tenseur 4D dont les dimensions ont une signification précise :
où est la taille du batch, le nombre de canaux, la hauteur en pixels et la largeur en pixels. Cette convention, parfois appelée channel-first ou NCHW, est celle de PyTorch. (TensorFlow utilise par défaut channel-last, ; les deux mondes existent et il faut savoir transposer.)
Forme attendue par
nn.Conv2dToujours . Trois dimensions ne suffisent pas : il faut explicitement déclarer le canal, même quand l'image est en niveaux de gris.
Pour MNIST, les images sont fournies sous forme tabulaire : chaque ligne du CSV est un vecteur de pixels. Pour les rendre exploitables par un CNN, on procède en trois temps.
df = pd.read_csv("mnist_small.csv") X = df.drop(columns="label").to_numpy() # (N, 784) y = df["label"].to_numpy() n = X.shape[0] X = X.reshape(n, 28, 28) # (N, H, W) X = X / 255.0 # normalisation
À ce stade, a la forme : trois axes seulement, sans canal. Pour préparer un tenseur conforme à Conv2d, on insère explicitement la dimension manquante :
X_train_t = torch.tensor(X_train, dtype=torch.float32).unsqueeze(1) # (N, 1, 28, 28) y_train_t = torch.tensor(y_train, dtype=torch.long) # (N,)
L'opération .unsqueeze(1) ajoute un axe de taille 1 à la position 1 : on passe de à . Aucune donnée n'est dupliquée, c'est uniquement une réorganisation de la forme du tenseur. La méthode duale .squeeze() retire des axes de taille 1.
Point de syntaxe — types Les entrées d'un CNN sont en
float32. Les labels en classification multiclasse sont enlong(entiers 64 bits). Mélanger les types provoque des erreurs cryptiques au moment du forward.
Pour des images couleur, comme celles de CIFAR-10 stockées en (format channel-last hérité de NumPy/PIL), il faut transposer les axes avant la conversion en tenseur :
X = X.reshape(n, 32, 32, 3) X = np.transpose(X, (0, 3, 1, 2)) # (N, 3, 32, 32)
3. La convolution : principe mathématique
3.1 Définition discrète
Considérons une image en niveaux de gris et un filtre (ou kernel) de petite taille, par exemple . La convolution discrète, telle qu'utilisée dans les CNN (en réalité, une corrélation croisée — la distinction est rarement faite en pratique), associe à chaque position de l'image une valeur :
où est le rayon du filtre (pour un filtre , ; pour un filtre , ). On glisse le filtre sur toute l'image, on calcule à chaque position le produit scalaire entre la fenêtre locale et le filtre, et le résultat forme une nouvelle image — appelée carte de caractéristiques (feature map).
Géométriquement, le filtre joue le rôle d'un patron : si la fenêtre locale ressemble au filtre, le produit scalaire est élevé ; sinon, il est faible. Un filtre dont les coefficients sont disposés en colonne détecte les transitions verticales (bords gauche-droite) ; un filtre approche un laplacien (changements de courbure). Dans un CNN, ces filtres ne sont pas conçus à la main : ils sont appris par rétropropagation, exactement comme les poids d'un MLP.
3.2 Connectivité locale et partage des poids
Un neurone convolutif diffère d'un neurone dense par deux propriétés :
- Sa connectivité est locale : il ne voit que la fenêtre qui l'entoure, pas l'image entière.
- Ses poids sont partagés : les mêmes coefficients de filtre sont réutilisés à toutes les positions.
C'est précisément ce partage qui réalise une forme d'invariance par translation : un détecteur de bord appris en haut à gauche s'applique automatiquement en bas à droite. Le nombre de paramètres devient indépendant de la taille de l'image (il dépend uniquement de la taille du filtre et du nombre de canaux), ce qui rend les CNN exploitables jusque sur des images haute définition.
3.3 Du gris à la couleur : canaux d'entrée et de sortie
Une image RGB possède trois canaux. Chaque filtre d'une couche Conv2d n'est plus une matrice mais un volume : il convolue simultanément les trois canaux et somme leurs contributions. Si la couche apprend filtres distincts, sa sortie est un volume à canaux. Chaque canal de sortie correspond à un filtre, donc à un détecteur de motif spécifique.
Le nombre de paramètres d'une couche convolutive est ainsi :
le tenant compte du biais (un par filtre). Pour Conv2d(3, 16, kernel_size=3) : paramètres seulement.
4. Padding et stride
4.1 Padding : préserver la taille spatiale
Quand on glisse un filtre sur une image , le centre du filtre ne peut pas être placé sur les pixels du bord (sinon il dépasse). La carte de sortie est donc plus petite : . Plus on empile de couches, plus la taille spatiale diminue, ce qui finit par interdire les architectures profondes.
Le padding consiste à entourer l'image d'une bordure de zéros (zéro-padding) avant la convolution. Avec un padding de pixels :
Pour conserver la taille avec un filtre impair , on prend . Pour , c'est ; pour , c'est . C'est ce que l'on appelle le same padding.
Recette —
padding=1aveckernel_size=3La convolutionConv2d(C, C', kernel_size=3, padding=1, stride=1)conserve exactement et . Les changements de résolution sont alors entièrement à la charge des couches de pooling.
4.2 Stride : sous-échantillonner pendant la convolution
Le stride est le pas de déplacement du filtre. Avec , on saute une position sur deux et la sortie est environ deux fois plus petite :
Un stride supérieur à 1 réduit la résolution sans introduire de couche de pooling. C'est une alternative à MaxPool2d parfois utilisée dans les architectures modernes (par exemple dans les ResNet ou les CNN « strided »). Dans ce chapitre, on garde pour les couches Conv2d et on délègue la réduction au pooling, plus lisible pédagogiquement.
4.3 Choix de la taille du filtre
La taille du filtre doit satisfaire un compromis :
- petit (1, 3) : peu de paramètres, capture des motifs très locaux, permet d'empiler de nombreuses couches.
- grand (5, 7, 11) : champ réceptif plus large dès la première couche, mais explosion du nombre de poids.
L'usage moderne, popularisé par VGG, est de privilégier des filtres et d'augmenter le champ réceptif par empilement plutôt que par largeur du filtre. Deux convolutions successives ont en effet un champ réceptif équivalent à une convolution , pour seulement paramètres au lieu de .
Bonne pratique — kernel size En première approche, partir sur des
kernel_size=3, padding=1. C'est le réglage par défaut de la quasi-totalité des architectures modernes pour images.
5. La couche nn.Conv2d en PyTorch
La signature de la couche est :
nn.Conv2d( in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, bias=True, )
in_channels: nombre de canaux d'entrée. Doit correspondre exactement au du tenseur fourni en entrée.out_channels: nombre de filtres appris, donc nombre de canaux en sortie.kernel_size: taille du filtre. Un entier donne un filtre ; un tuple permet des filtres rectangulaires.strideetpaddingcomme décrits plus haut.
Une couche Conv2d(1, 16, kernel_size=3, padding=1) reçoit et produit . Les 16 cartes de caractéristiques constituent la « représentation enrichie » de l'image, où chaque canal encode la réponse à un filtre appris. C'est cette représentation que les couches suivantes vont raffiner.
6. Activation non linéaire : ReLU
Comme dans un MLP, empiler plusieurs convolutions sans non-linéarité reviendrait à une seule convolution équivalente (la composition de fonctions affines reste affine). Il faut donc insérer une fonction d'activation entre les couches. Le choix standard est la ReLU :
Appliquée élément par élément à la sortie d'une convolution, elle annule les activations négatives et préserve les positives. En PyTorch :
nn.ReLU()
ou, en mode fonctionnel dans une méthode forward, torch.relu(x) ou F.relu(x). La ReLU est rapide à calculer, ne sature pas pour les valeurs positives et son gradient vaut ou , ce qui aide à la propagation à travers les réseaux profonds. Ses variantes (LeakyReLU, ELU, GELU) seront évoquées dans les chapitres suivants.
7. Pooling : réduction spatiale contrôlée
Après la convolution et la ReLU, on souhaite généralement réduire la résolution spatiale des cartes de caractéristiques. Trois bonnes raisons à cela :
- diminuer le coût mémoire et calculatoire des couches suivantes ;
- introduire une légère invariance aux petites translations ;
- forcer le réseau à condenser l'information dans des cartes de plus en plus abstraites.
Le max pooling est le plus utilisé. Sur chaque fenêtre de la carte d'entrée, il ne conserve que la valeur maximale. Avec un kernel size de 2 et un stride de 2, il divise par 2 la hauteur et la largeur, sans modifier le nombre de canaux :
nn.MaxPool2d(kernel_size=2, stride=2)
Pour une entrée , la sortie est . Le average pooling (nn.AvgPool2d) existe également, ainsi que le global average pooling (réduction à par moyenne sur toute la carte), mais pour ce premier chapitre nous utiliserons exclusivement le max pooling.
À retenir — pooling Le pooling n'a pas de paramètres apprenables. C'est une opération déterministe qui ne modifie que la résolution spatiale, jamais le nombre de canaux. Il est donc « gratuit » du point de vue de l'optimisation.
8. Du tenseur 4D au vecteur : nn.Flatten
À la fin des blocs convolutifs, on obtient un tenseur de forme . Pour le brancher à une couche entièrement connectée (nn.Linear), qui attend , il faut aplatir les trois dernières dimensions :
nn.Flatten()
L'effet est strictement de redécouper la mémoire :
La dimension de batch est préservée. Une alternative équivalente, à l'intérieur d'une méthode forward, est torch.flatten(x, start_dim=1). Le start_dim=1 est crucial : sans lui, on aplatirait aussi la dimension de batch.
9. La couche de classification finale
Une fois les caractéristiques aplaties, une (ou plusieurs) couches nn.Linear produisent les logits — un score par classe :
nn.Linear(in_features, 10)
Pour MNIST (10 chiffres), out_features = 10. Aucune activation n'est appliquée après la dernière couche linéaire lorsque l'on utilise nn.CrossEntropyLoss, qui combine en interne un log-softmax et la log-vraisemblance négative. Mettre une softmax explicite avant la perte est une erreur classique qui dégrade silencieusement l'apprentissage.
10. Premier CNN complet sur MNIST
Mettons toutes les briques bout à bout. L'architecture la plus simple qui apporte un gain par rapport au MLP est :
Entrée (B, 1, 28, 28)
-> Conv2d(1, 8, k=3, p=1) -> (B, 8, 28, 28)
-> ReLU
-> MaxPool2d(2) -> (B, 8, 14, 14)
-> Flatten -> (B, 8 * 14 * 14)
-> Linear(8*14*14, 10) -> (B, 10)
En PyTorch :
model = nn.Sequential( nn.Conv2d(1, 8, kernel_size=3, padding=1), # (n, 8, 28, 28) nn.ReLU(), nn.MaxPool2d(2), # (n, 8, 14, 14) nn.Flatten(), nn.Linear(8 * 14 * 14, 10), )
Le calcul de la dimension d'entrée du Linear est l'étape la plus délicate. Il faut suivre la trajectoire des formes à travers chaque couche, en s'aidant éventuellement de commentaires comme dans le code ci-dessus. Les annotations (n, 8, 28, 28), (n, 8, 14, 14) ne sont pas indispensables au fonctionnement, mais elles évitent énormément d'erreurs de débogage.
Astuce de débogage Avant l'entraînement, faire passer un seul batch dans le modèle et imprimer la forme de la sortie à chaque étape. Cela permet de vérifier que la dimension fournie à
Linearest correcte. Une coucheLinearavec une dimension d'entrée erronée ne se déclenche qu'au moment du forward, par une erreur peu lisible.
L'entraînement reprend exactement la même boucle que celle du chapitre précédent :
criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1) epochs = 100 loss_history = [] for _ in range(epochs): nb = 0 epoch_loss = 0 for Xb, yb in train_loader: optimizer.zero_grad() u = model(Xb) loss = criterion(u, yb) loss.backward() optimizer.step() epoch_loss += loss.item() nb += 1 loss_history.append(epoch_loss / nb)
Aucune ligne ne change par rapport au MLP. C'est l'une des forces de PyTorch : remplacer une famille de couches par une autre n'impacte pas la mécanique d'optimisation.
L'évaluation suit également la même logique : passer le jeu de test au modèle, prendre l'argmax des logits, comparer aux labels.
with torch.no_grad(): logits = model(X_test_t) y_hat = torch.argmax(logits, dim=1) y_hat = y_hat.cpu().numpy().reshape(-1) print(f"Accuracy : {accuracy_score(y_test, y_hat):.2f}") print(confusion_matrix(y_test, y_hat)) print(classification_report(y_test, y_hat))
Sur MNIST, ce CNN minimal franchit aisément les 97 % d'exactitude là où le MLP plafonne aux alentours de 92–94 %, avec moins de paramètres dans la partie convolutive. La dimension dominante reste celle de la couche Linear finale ( poids), ce qui suggère immédiatement de réduire encore la résolution avant la classification — d'où l'idée d'empiler plusieurs blocs convolutifs.
11. Empiler plusieurs blocs convolutifs
Un bloc élémentaire d'un CNN a la structure :
Conv2d -> ReLU -> MaxPool2d
Pour empiler des blocs, la règle de cohérence est simple : le nombre de canaux de sortie d'une couche devient le nombre de canaux d'entrée de la suivante. Une progression typique double ou augmente progressivement le nombre de canaux à mesure que la résolution spatiale diminue, comme pour conserver une « richesse » constante d'information :
model = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), # (n, 16, 32, 32) nn.ReLU(), nn.MaxPool2d(2), # (n, 16, 16, 16) nn.Conv2d(16, 20, kernel_size=3, padding=1), # (n, 20, 16, 16) nn.ReLU(), nn.MaxPool2d(2), # (n, 20, 8, 8) nn.Conv2d(20, 20, kernel_size=3, padding=1), # (n, 20, 8, 8) nn.ReLU(), nn.MaxPool2d(2), # (n, 20, 4, 4) nn.Flatten(), nn.Linear(20 * 4 * 4, 10), )
Ce schéma, illustré ici sur CIFAR-10, fait chuter la résolution de à par trois max poolings successifs, tout en passant de 3 canaux RGB à 20 canaux abstraits. Les premières couches détectent des bords et des textures basiques ; les dernières composent ces motifs en fragments d'objets. C'est la hiérarchie de représentations caractéristique des CNN.
À retenir — calcul du
Linearfinal Toujours recalculer à la main quand on modifie l'architecture. Avec et , lesConv2dne changent pas la taille spatiale ; seuls lesMaxPool2d(2)la divisent par deux. Trois poolings successifs sur une image donnent .
Les pièges classiques quand on rallonge un CNN sont :
- Oublier d'actualiser
in_channelssur la couche convolutive suivante (PyTorch lève alors une erreur explicite). - Se tromper sur
in_featuresduLinear(l'erreur est plus opaque). - Empiler trop de poolings, au point de réduire la carte à avant d'avoir extrait d'informations utiles.
12. La syntaxe nn.Module : au-delà du Sequential
nn.Sequential est commode mais limité : il interdit toute logique non linéaire dans le graphe (branches, connexions résiduelles, partages de poids). Dès que l'architecture devient un peu sophistiquée, on lui préfère la classe héritant de nn.Module.
Le contrat est minimal :
class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, 3, padding=1) self.conv2 = nn.Conv2d(16, 32, 3, padding=1) self.pool = nn.MaxPool2d(2) self.relu = nn.ReLU() self.fc = nn.Linear(32 * 7 * 7, 10) def forward(self, x): x = self.pool(self.relu(self.conv1(x))) # (B, 16, 14, 14) x = self.pool(self.relu(self.conv2(x))) # (B, 32, 7, 7) x = torch.flatten(x, start_dim=1) x = self.fc(x) return x
__init__ déclare les briques en tant qu'attributs de l'instance. C'est crucial : PyTorch parcourt récursivement les attributs qui héritent de nn.Module pour collecter automatiquement leurs paramètres dans model.parameters(), les déplacer sur GPU avec model.to(device), ou les sérialiser avec torch.save. Une couche stockée dans une simple liste Python ne serait pas suivie ; il faut alors utiliser nn.ModuleList.
forward décrit le calcul. Toute opération admise dans le graphe d'autograd est valide : appel de couches, opérations vectorielles, branchements if, boucles for. C'est cette flexibilité qui permettra, dans les chapitres suivants, d'écrire des connexions résiduelles ou des architectures multibranches.
L'entraînement reste rigoureusement identique :
model = SimpleCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
13. Entraînement sur GPU
Sur des images, l'écart de performance entre CPU et GPU devient considérable. Le déplacement vers le GPU repose sur la notion de device : un objet qui désigne où un tenseur réside et où ses calculs s'effectuent.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
Cette ligne, à placer en début de script, sélectionne le GPU CUDA s'il est disponible, sinon le CPU. PyTorch ne déplace rien automatiquement : le modèle et les données doivent être transférés explicitement.
model = model.to(device)
Tous les paramètres du modèle (poids, biais, statistiques internes) sont déplacés. Les tenseurs d'entrée doivent être sur le même device, sous peine d'erreur :
for Xb, yb in train_loader: Xb = Xb.to(device) yb = yb.to(device) optimizer.zero_grad() logits = model(Xb) loss = criterion(logits, yb) loss.backward() optimizer.step()
Pour l'évaluation, on désactive le calcul du gradient (gain de mémoire et de vitesse) :
model.eval() with torch.no_grad(): logits = model(X_test_t.to(device)) y_hat = torch.argmax(logits, dim=1)
Avant de comparer les prédictions aux labels avec accuracy_score (scikit-learn, CPU), il faut rapatrier le tenseur :
y_hat = y_hat.detach().cpu().numpy().reshape(-1)
.detach() coupe le lien avec le graphe d'autograd, .cpu() rapatrie le tenseur en mémoire centrale, .numpy() convertit en tableau NumPy. Cet enchaînement « detach-cpu-numpy » apparaît dans la quasi-totalité des scripts PyTorch.
Mesure du temps Pour chronométrer un entraînement, encadrer la boucle de
t0 = time.perf_counter()ett1 = time.perf_counter(), puis affichert1 - t0. Comparer les durées CPU et GPU pour le même nombre d'époques donne une idée concrète du gain.
14. Synthèse architecturale
Tout CNN classique d'introduction se ramène à la même structure :
Entrée (B, C, H, W)
-> [Conv2d -> ReLU -> MaxPool2d] * N
-> Flatten
-> [Linear -> (ReLU)] * M
-> Linear (logits)
-> CrossEntropyLoss
Les variantes touchent au nombre de blocs , au nombre de couches denses , au choix des hyperparamètres (taille des filtres, stride, padding, taille du pooling), ainsi qu'à l'optimiseur (SGD avec momentum, Adam, AdamW). Les chapitres suivants introduiront des composants supplémentaires : batch normalization, dropout, connexions résiduelles, architectures pré-entraînées.
Mais la matière fondamentale — celle qui sépare un CNN d'un MLP — tient en quatre mots : convolution, activation, pooling, aplatissement. Bien comprises, ces quatre opérations suffisent à reproduire les résultats des architectures historiques (LeNet, AlexNet) et à aborder sereinement les modèles modernes.
Exercices
Exercice 1 — Visualisation et exploration de MNIST
À partir du fichier mnist_small.csv :
- Charger le CSV avec pandas. Séparer les pixels (
X) et les labels (y). - Convertir
Xen un tableau NumPy de forme , puis le redimensionner en . - Afficher la première image avec
plt.imshow(..., cmap="gray")et son label en titre. - Construire une grille avec
plt.subplotspour visualiser dix chiffres simultanément. Désactiver les axes et afficher le label en titre de chaque sous-figure. - Normaliser les pixels en divisant par 255.
Exercice 2 — Premier CNN sur MNIST
Adapter le pipeline MLP du chapitre précédent pour entraîner un CNN minimal :
- À partir de
Xde forme , créer les tenseursX_train_tetX_test_tde forme viaunsqueeze(1). Vérifier les formes avecprint. - Construire un
DataLoaderd'entraînement avecbatch_size=64. - Définir le modèle suivant avec
nn.Sequential:Conv2d(1, 16, kernel_size=3, padding=1),ReLU,MaxPool2d(2),Flatten,Linear(16*14*14, 10).
- Entraîner sur 100 époques avec
CrossEntropyLossetSGD(lr=0.1). - Tracer la courbe de perte. Évaluer sur le test (accuracy, matrice de confusion,
classification_report). - Comparer avec les performances du MLP du chapitre précédent.
Exercice 3 — CNN à plusieurs blocs sur CIFAR-10
À partir de cifar10_small.csv (images ) :
- Reshape en puis transposer en avec
np.transpose. - Normaliser en divisant par 255, puis créer les tenseurs PyTorch.
- Construire un CNN à trois blocs convolutifs avec, par exemple, des canaux de sortie 16, 20, 20, séparés par des
MaxPool2d(2). - Recalculer manuellement la dimension d'entrée de la couche
Linearfinale en suivant l'évolution des formes. - Entraîner et évaluer le modèle. Commenter la performance par rapport à MNIST.
Exercice 4 — Réécriture en nn.Module
Reprendre le CNN de l'exercice 2 et le réécrire sous forme de classe SimpleCNN(nn.Module) :
- Déclarer
conv1,relu,pool,fcdans__init__. - Décrire l'enchaînement dans
forward. Tester deux variantes pour l'aplatissement :nn.Flatten()(déclaré dans__init__) outorch.flatten(x, start_dim=1)(appelé dansforward). - Vérifier que les performances sont identiques à celles de la version
Sequential.
Exercice 5 — Entraînement sur GPU
À partir du modèle de l'exercice 3 :
- Définir
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")et l'afficher. - Déplacer le modèle sur le device avec
.to(device). - Dans la boucle d'entraînement, transférer chaque mini-batch sur le device juste avant le
forward. - Pour l'évaluation, passer le modèle en mode
eval, désactiver les gradients avectorch.no_grad(), et rapatrier les prédictions sur CPU avant le calcul des métriques. - Mesurer le temps total d'entraînement avec
time.perf_counter()pourbatch_size = 32etbatch_size = 128. Commenter.
Exercice 6 — Influence des hyperparamètres
Sur le CNN MNIST de l'exercice 2, faire varier successivement :
- La taille du filtre :
kernel_size=3vskernel_size=5(avecpaddingajusté pour préserver la taille). - Le
stridedeConv2d: 1 vs 2 (sans pooling). Comparer la résolution finale. - Le nombre de canaux : 8, 16, 32. Tracer l'évolution de l'accuracy en fonction du nombre de paramètres.
Commenter les compromis observés (capacité, sur-apprentissage, temps d'entraînement).
Pour aller plus loin
Documentation officielle
torch.nn.Conv2d— page de référence PyTorch, avec les formules exactes pourH_outetW_outen fonction depadding,stride,dilationetgroups.torch.nn.MaxPool2d,torch.nn.AvgPool2d,torch.nn.AdaptiveAvgPool2d— la version adaptive est très utile pour découpler la dernière couche convolutive de la résolution d'entrée.- Tutoriel officiel « Training a Classifier » sur le site PyTorch : il refait l'exercice CIFAR-10 du début à la fin avec
torchvision.datasets.
Références bibliographiques
- Goodfellow, Bengio, Courville, Deep Learning, MIT Press, 2016. Le chapitre 9 « Convolutional Networks » est la référence académique standard. Disponible gratuitement en ligne.
- François Chollet, Deep Learning with Python, 2nd edition, Manning, 2021. Présentation très pédagogique, originellement en Keras mais entièrement transposable à PyTorch.
- Andrew Glassner, Deep Learning : A Visual Approach, No Starch Press, 2021. Vues schématiques très claires des notions de filtre, padding et stride.
Architectures historiques à étudier
Lire (et idéalement réimplémenter) les articles fondateurs en regardant uniquement leur diagramme de couches :
- LeNet-5 (LeCun et al., 1998) — premier CNN industriel, utilisé pour la lecture automatique de chèques.
- AlexNet (Krizhevsky et al., 2012) — déclencheur de la révolution de l'apprentissage profond, vainqueur d'ImageNet 2012.
- VGG-16 (Simonyan & Zisserman, 2014) — popularise les filtres empilés.
- ResNet (He et al., 2015) — connexions résiduelles, qui seront étudiées dans la deuxième partie du cours.
Outils complémentaires
torchsummaryoutorchinfo: affichent la forme des activations à chaque couche d'un modèle PyTorch. Indispensable pour vérifier la dimension d'unLinearfinal sans la calculer à la main.- Netron (netron.app) : visualiseur graphique des fichiers
.pthou ONNX. Permet d'examiner les couches d'un modèle entraîné comme un schéma de circuit. - TensorBoard (intégré à PyTorch via
torch.utils.tensorboard) : pour suivre la perte d'apprentissage et visualiser les filtres appris.
Le chapitre suivant (Deep Learning 4) approfondit la régularisation propre aux CNN — dropout, batch normalization, augmentation de données — et introduit le transfer learning, qui permet de réutiliser des modèles pré-entraînés sur ImageNet pour des tâches de classification d'images sur peu d'exemples.