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 en ). Trois problèmes :
- Perte de la proximité spatiale : pour le MLP, le pixel et le pixel sont aussi étrangers que le pixel et le pixel .
- 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.
- 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 :
| Dim | Signification | MNIST | CIFAR-10 |
|---|---|---|---|
| B | nombre d'images traitées en parallèle | 64 | 64 |
| C | nombre de canaux | 1 (gris) | 3 (RGB) |
| H | hauteur en pixels | 28 | 32 |
| W | largeur en pixels | 28 | 32 |
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=1aveckernel_size=3conserve la taille H×W.
Forme : .
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 : . 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()