teach.pascalyim.com
Sommaire

DL · Chapitre 3

Deep Learning 3 — Réseaux convolutifs et traitement d'images (Partie 1)

Lancer sur Kaggle

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 R784\mathbb{R}^{784} ; 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 28×2828 \times 28 en niveaux de gris sont aplaties en vecteurs de longueur 784784, normalisées dans [0,1][0, 1], 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 784×32+32×16+16×1026000784 \times 32 + 32 \times 16 + 16 \times 10 \approx 26\,000 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 224×224×3224 \times 224 \times 3 produit un vecteur de 150528150\,528 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 :

(B,C,H,W)(B,\, C,\, H,\, W)

BB est la taille du batch, CC le nombre de canaux, HH la hauteur en pixels et WW la largeur en pixels. Cette convention, parfois appelée channel-first ou NCHW, est celle de PyTorch. (TensorFlow utilise par défaut channel-last, (B,H,W,C)(B, H, W, C) ; les deux mondes existent et il faut savoir transposer.)

Forme attendue par nn.Conv2d Toujours (B,C,H,W)(B, C, H, W). 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 784784 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, XX a la forme (N,28,28)(N, 28, 28) : 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 (N,28,28)(N, 28, 28) à (N,1,28,28)(N, 1, 28, 28). 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 en long (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 32×32×332 \times 32 \times 3 (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 II et un filtre (ou kernel) KK de petite taille, par exemple 3×33 \times 3. 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 (i,j)(i, j) de l'image une valeur :

(IK)(i,j)=u=rrv=rrI(i+u,j+v)K(u,v),(I * K)(i, j) = \sum_{u=-r}^{r} \sum_{v=-r}^{r} I(i + u,\, j + v) \cdot K(u, v),

rr est le rayon du filtre (pour un filtre 3×33 \times 3, r=1r = 1 ; pour un filtre 5×55 \times 5, r=2r = 2). 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 [1,0,1][-1, 0, 1] disposés en colonne détecte les transitions verticales (bords gauche-droite) ; un filtre [1,2,1][1, -2, 1] 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 k×kk \times k 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 k×kk \times k mais un volume Cin×k×kC_\text{in} \times k \times k : il convolue simultanément les trois canaux et somme leurs contributions. Si la couche apprend CoutC_\text{out} filtres distincts, sa sortie est un volume à CoutC_\text{out} 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 :

Cout(Cinkk+1),C_\text{out} \cdot (C_\text{in} \cdot k \cdot k + 1),

le +1+1 tenant compte du biais (un par filtre). Pour Conv2d(3, 16, kernel_size=3) : 16×(3×3×3+1)=44816 \times (3 \times 3 \times 3 + 1) = 448 paramètres seulement.

4. Padding et stride

4.1 Padding : préserver la taille spatiale

Quand on glisse un filtre 3×33 \times 3 sur une image 28×2828 \times 28, 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 : 26×2626 \times 26. 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 pp pixels :

Hout=Hin+2pk+1.H_\text{out} = H_\text{in} + 2p - k + 1.

Pour conserver la taille avec un filtre impair kk, on prend p=(k1)/2p = (k-1)/2. Pour k=3k = 3, c'est p=1p = 1 ; pour k=5k = 5, c'est p=2p = 2. C'est ce que l'on appelle le same padding.

Recette — padding=1 avec kernel_size=3 La convolution Conv2d(C, C', kernel_size=3, padding=1, stride=1) conserve exactement HH et WW. 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 ss est le pas de déplacement du filtre. Avec s=2s = 2, on saute une position sur deux et la sortie est environ deux fois plus petite :

Hout=Hin+2pks+1.H_\text{out} = \left\lfloor \frac{H_\text{in} + 2p - k}{s} \right\rfloor + 1.

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 s=1s = 1 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 kk doit satisfaire un compromis :

  • kk petit (1, 3) : peu de paramètres, capture des motifs très locaux, permet d'empiler de nombreuses couches.
  • kk 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 3×33 \times 3 et d'augmenter le champ réceptif par empilement plutôt que par largeur du filtre. Deux convolutions 3×33 \times 3 successives ont en effet un champ réceptif équivalent à une convolution 5×55 \times 5, pour seulement 2×9=182 \times 9 = 18 paramètres au lieu de 2525.

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 CC 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 kk donne un filtre k×kk \times k ; un tuple (kh,kw)(k_h, k_w) permet des filtres rectangulaires.
  • stride et padding comme décrits plus haut.

Une couche Conv2d(1, 16, kernel_size=3, padding=1) reçoit (B,1,H,W)(B, 1, H, W) et produit (B,16,H,W)(B, 16, H, W). 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 :

ReLU(x)=max(0,x).\mathrm{ReLU}(x) = \max(0, x).

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 00 ou 11, 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 p×pp \times p 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 (B,C,H,W)(B, C, H, W), la sortie est (B,C,H/2,W/2)(B, C, H/2, W/2). Le average pooling (nn.AvgPool2d) existe également, ainsi que le global average pooling (réduction à 1×11 \times 1 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 (B,C,H,W)(B, C, H, W). Pour le brancher à une couche entièrement connectée (nn.Linear), qui attend (B,nfeatures)(B, n_\text{features}), il faut aplatir les trois dernières dimensions :

nn.Flatten()

L'effet est strictement de redécouper la mémoire :

(B,C,H,W)    (B,CHW).(B,\, C,\, H,\, W)\; \longrightarrow\; (B,\, C \cdot H \cdot W).

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 à Linear est correcte. Une couche Linear avec 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 (8×14×14×10=156808 \times 14 \times 14 \times 10 = 15\,680 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 32×3232 \times 32 à 4×44 \times 4 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 Linear final Toujours recalculer CfinalHfinalWfinalC_\text{final} \cdot H_\text{final} \cdot W_\text{final} à la main quand on modifie l'architecture. Avec k=3k = 3 et p=1p = 1, les Conv2d ne changent pas la taille spatiale ; seuls les MaxPool2d(2) la divisent par deux. Trois poolings successifs sur une image 32×3232 \times 32 donnent 32/23=432 / 2^3 = 4.

Les pièges classiques quand on rallonge un CNN sont :

  • Oublier d'actualiser in_channels sur la couche convolutive suivante (PyTorch lève alors une erreur explicite).
  • Se tromper sur in_features du Linear (l'erreur est plus opaque).
  • Empiler trop de poolings, au point de réduire la carte à 1×11 \times 1 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() et t1 = time.perf_counter(), puis afficher t1 - 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 NN, au nombre de couches denses MM, 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 :

  1. Charger le CSV avec pandas. Séparer les pixels (X) et les labels (y).
  2. Convertir X en un tableau NumPy de forme (N,784)(N, 784), puis le redimensionner en (N,28,28)(N, 28, 28).
  3. Afficher la première image avec plt.imshow(..., cmap="gray") et son label en titre.
  4. Construire une grille 2×52 \times 5 avec plt.subplots pour visualiser dix chiffres simultanément. Désactiver les axes et afficher le label en titre de chaque sous-figure.
  5. 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 :

  1. À partir de X de forme (N,28,28)(N, 28, 28), créer les tenseurs X_train_t et X_test_t de forme (N,1,28,28)(N, 1, 28, 28) via unsqueeze(1). Vérifier les formes avec print.
  2. Construire un DataLoader d'entraînement avec batch_size=64.
  3. 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).
  4. Entraîner sur 100 époques avec CrossEntropyLoss et SGD(lr=0.1).
  5. Tracer la courbe de perte. Évaluer sur le test (accuracy, matrice de confusion, classification_report).
  6. 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 32×32×332 \times 32 \times 3) :

  1. Reshape en (N,32,32,3)(N, 32, 32, 3) puis transposer en (N,3,32,32)(N, 3, 32, 32) avec np.transpose.
  2. Normaliser en divisant par 255, puis créer les tenseurs PyTorch.
  3. Construire un CNN à trois blocs convolutifs avec, par exemple, des canaux de sortie 16, 20, 20, séparés par des MaxPool2d(2).
  4. Recalculer manuellement la dimension d'entrée de la couche Linear finale en suivant l'évolution des formes.
  5. 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) :

  1. Déclarer conv1, relu, pool, fc dans __init__.
  2. Décrire l'enchaînement dans forward. Tester deux variantes pour l'aplatissement : nn.Flatten() (déclaré dans __init__) ou torch.flatten(x, start_dim=1) (appelé dans forward).
  3. 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 :

  1. Définir device = torch.device("cuda" if torch.cuda.is_available() else "cpu") et l'afficher.
  2. Déplacer le modèle sur le device avec .to(device).
  3. Dans la boucle d'entraînement, transférer chaque mini-batch sur le device juste avant le forward.
  4. Pour l'évaluation, passer le modèle en mode eval, désactiver les gradients avec torch.no_grad(), et rapatrier les prédictions sur CPU avant le calcul des métriques.
  5. Mesurer le temps total d'entraînement avec time.perf_counter() pour batch_size = 32 et batch_size = 128. Commenter.

Exercice 6 — Influence des hyperparamètres

Sur le CNN MNIST de l'exercice 2, faire varier successivement :

  1. La taille du filtre : kernel_size=3 vs kernel_size=5 (avec padding ajusté pour préserver la taille).
  2. Le stride de Conv2d : 1 vs 2 (sans pooling). Comparer la résolution finale.
  3. 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 pour H_out et W_out en fonction de padding, stride, dilation et groups.
  • 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 3×33 \times 3 empilés.
  • ResNet (He et al., 2015) — connexions résiduelles, qui seront étudiées dans la deuxième partie du cours.

Outils complémentaires

  • torchsummary ou torchinfo : affichent la forme des activations à chaque couche d'un modèle PyTorch. Indispensable pour vérifier la dimension d'un Linear final sans la calculer à la main.
  • Netron (netron.app) : visualiseur graphique des fichiers .pth ou 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.