Aller au contenu principal

Deep Learning 3 — Réseaux convolutifs (1/3)

:::tip Notebook Kaggle Le code complet et exécutable de ce chapitre est sur Kaggle : Ouvrir →

Versions anglaise et chinoise disponibles depuis la page d'accueil. :::

Premier contact avec les réseaux de neurones convolutifs (CNN). Le défi conceptuel principal de ce chapitre : la convention BCHW des tenseurs PyTorch. Une fois cette règle bien comprise, le reste suit naturellement.

Pourquoi ce chapitre ?

Vous y apprenez :

  • pourquoi un CNN plutôt qu'un MLP sur les images ;
  • la règle pivot (B, C, H, W) que PyTorch attend pour les images ;
  • les briques Conv2d, MaxPool2d, Flatten ;
  • comment empiler plusieurs couches conv ;
  • la syntaxe nn.Module (la façon pro d'écrire un modèle) ;
  • l'utilisation du GPU avec .to(device).

Pourquoi un CNN ?

Un MLP traite l'image comme un vecteur 1D (on aplatit (28,28)(28, 28) en (784,)(784,)). Trois problèmes :

  1. Perte de la proximité spatiale : pour le MLP, le pixel (1,1)(1,1) et le pixel (1,2)(1,2) sont aussi étrangers que le pixel (1,1)(1,1) et le pixel (28,28)(28,28).
  2. Nombre énorme de paramètres : pour une image 224×224 RGB, ~150 000 features en entrée. Avec 100 neurones cachés, ~15 millions de poids juste pour la première couche.
  3. Pas d'invariance aux translations : si l'objet bouge de quelques pixels, le réseau ne le reconnaît plus.

Le CNN résout les trois en exploitant deux idées :

  • Connectivité locale : un neurone ne regarde qu'un petit voisinage (3×3 pixels), pas l'image entière.
  • Partage des poids : le même filtre 3×3 est appliqué à toutes les positions. Beaucoup moins de paramètres, et invariance aux translations gratuite.

La règle d'or : (B, C, H, W)

PyTorch attend toujours un tenseur 4D (batch, channels, height, width) pour les convolutions. Point.

Détail des dimensions :

DimSignificationMNISTCIFAR-10
Bnombre d'images traitées en parallèle6464
Cnombre de canaux1 (gris)3 (RGB)
Hhauteur en pixels2832
Wlargeur en pixels2832

Cette convention s'appelle BCHW (ou NCHW). Elle est imposée par PyTorch pour des raisons de performance GPU.

:::warning matplotlib utilise une autre convention matplotlib (et numpy en général) attend les images en HWC (height, width, channels). C'est cette différence qui va nous obliger à faire quelques permutations pour les images RGB. :::

À chaque manipulation de tenseur image, affichez sa forme avec print(X.shape). C'est le réflexe qui évite 90 % des bugs.

Mettre en forme les images

MNIST (1 canal, stocké aplati)

X = df.drop(columns='label').to_numpy() # (N, 784)
X = X.reshape(-1, 1, 28, 28) / 255.0 # (N, 1, 28, 28) — déjà BCHW
X_t = torch.tensor(X, dtype=torch.float32)

CIFAR-10 (3 canaux, stocké en HWC)

X = df.drop(columns='label').to_numpy() # (N, 3072)
X = X.reshape(-1, 32, 32, 3) / 255.0 # (N, H, W, C) — convention image
X = X.transpose(0, 3, 1, 2) # (N, C, H, W) — permutation pour PyTorch
X_t = torch.tensor(X, dtype=torch.float32)

La seule différence : pour le RGB, on permute les axes pour passer de HWC à CHW.

Brique 1 : nn.Conv2d

Une convolution applique un filtre (kernel) de petite taille sur l'image en glissant à toutes les positions. À chaque position, on calcule une somme pondérée des pixels couverts. Chaque filtre apprend à détecter un motif spatial — bord, coin, texture.

nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0)
  • in_channels : nombre de canaux en entrée (1 pour MNIST, 3 pour CIFAR).
  • out_channels : nombre de filtres appris (donc de canaux de sortie).
  • kernel_size : taille du filtre (3 ou 5 typiquement).
  • padding=1 avec kernel_size=3 conserve la taille H×W.

Forme : (B,Cin,H,W)(B,Cout,Hout,Wout)(B, C_\text{in}, H, W) \to (B, C_\text{out}, H_\text{out}, W_\text{out}).

Brique 2 : nn.ReLU

Comme dans un MLP, on insère une activation non linéaire après chaque convolution. Sans ça, empiler des Conv2d donnerait un seul Conv2d équivalent. ReLU est l'activation standard.

nn.ReLU()

Brique 3 : nn.MaxPool2d

Le pooling réduit la taille spatiale de l'image en gardant l'information utile. Le MaxPool2d(2) divise hauteur et largeur par 2 en gardant la valeur max de chaque petit carré 2×2.

nn.MaxPool2d(kernel_size=2)

Effet : (B,C,H,W)(B,C,H/2,W/2)(B, C, H, W) \to (B, C, H/2, W/2). Le nombre de canaux ne change pas.

Avantages : moins de paramètres dans les couches suivantes, robustesse aux petites translations, focalisation sur les motifs forts.

Brique 4 : nn.Flatten + nn.Linear

Après quelques blocs Conv → ReLU → Pool, on aplatit le tenseur 4D en vecteur 2D pour appliquer une couche dense de classification :

nn.Flatten() # (B, C, H, W) → (B, C*H*W)
nn.Linear(C*H*W, n_classes) # logits

Pas d'activation après le Linear final — CrossEntropyLoss s'occupe du softmax en interne.

Architecture CNN type

model = nn.Sequential(
nn.Conv2d(1, 8, kernel_size=3, padding=1), # (N, 1, 28, 28) → (N, 8, 28, 28)
nn.ReLU(),
nn.MaxPool2d(2), # → (N, 8, 14, 14)
nn.Flatten(), # → (N, 8*14*14)
nn.Linear(8 * 14 * 14, 10), # → (N, 10) logits
)

Empiler plusieurs blocs

Pour un modèle plus puissant, on empile des blocs Conv → ReLU → Pool. Règle : le out_channels d'une couche devient le in_channels de la suivante.

nn.Sequential(
nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # → 16×16
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # → 8×8
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # → 4×4
nn.Flatten(),
nn.Linear(64 * 4 * 4, 10),
)

Le nombre de canaux augmente typiquement (16 → 32 → 64) — on gagne en expressivité ce qu'on perd en résolution.

Syntaxe nn.Module

Pour des architectures un peu plus riches, on définit une classe héritant de nn.Module :

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)))
x = self.pool(self.relu(self.conv2(x)))
x = torch.flatten(x, start_dim=1)
x = self.fc(x)
return x

C'est le pattern standard en pratique. Il permet des branches, des connexions résiduelles, le partage de couches, etc.

Utiliser le GPU

PyTorch ne déplace rien automatiquement. Tout doit être explicite.

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

Dans la boucle d'entraînement, on déplace les batchs au moment du fit :

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

À la fin, pour calculer les métriques avec scikit-learn ou matplotlib, on rapatrie sur CPU :

y_hat_np = y_hat.cpu().numpy()

Notebook complet sur Kaggle (forkable) →