Réseaux convolutifs#

Tout ce que nous voyons cache autre chose ; nous désirons toujours voir ce qui est caché par ce que nous voyons.

René Magritte

Les chapitres précédents ont introduit le perceptron multicouche (MLP), la rétropropagation du gradient et le cadre PyTorch. Ces réseaux entièrement connectés (fully connected) sont des approximateurs universels, mais ils présentent des limitations fondamentales lorsqu’il s’agit de traiter des données structurées spatialement, comme les images. Un MLP appliqué à une image de \(224 \times 224 \times 3\) pixels nécessiterait \(224 \times 224 \times 3 = 150\,528\) poids par neurone de la première couche cachée — un nombre colossal qui rend l’apprentissage inefficace et sujet au surapprentissage.

Les réseaux de neurones convolutifs (Convolutional Neural Networks, CNN) résolvent ce problème en exploitant la structure spatiale des images à travers trois idées clés : la connectivité locale, le partage de poids et l”invariance par translation. Ce chapitre présente l’opération de convolution, les couches de pooling, les architectures classiques et modernes, le transfer learning, et une implémentation complète en PyTorch.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader

import torchvision
import torchvision.transforms as transforms

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
np.random.seed(42)
torch.manual_seed(42)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Périphérique utilisé : {device}")
Périphérique utilisé : cpu

Introduction : la vision par ordinateur#

Pourquoi les MLP ne suffisent pas#

Une image en niveaux de gris de taille \(H \times W\) peut être vue comme un vecteur de \(\mathbb{R}^{H \cdot W}\). Pour un MLP, chaque pixel est un feature indépendant, et la couche d’entrée est entièrement connectée à la première couche cachée. Cela pose trois problèmes majeurs.

Remarque 196

Problèmes des MLP pour les images :

  1. Nombre de paramètres : pour une image \(224 \times 224\) en couleur (3 canaux), une couche cachée de 1000 neurones nécessite \(224 \times 224 \times 3 \times 1000 \approx 150\) millions de paramètres — uniquement pour la première couche.

  2. Perte de la structure spatiale : en « aplatissant » l’image en vecteur, le MLP perd toute information sur les relations de voisinage entre pixels.

  3. Absence d’invariance par translation : un chat dans le coin supérieur gauche de l’image active des neurones complètement différents du même chat au centre. Le MLP ne généralise pas la notion de motif local.

Invariances fondamentales#

La vision humaine est remarquablement robuste aux transformations géométriques. Nous reconnaissons un objet qu’il soit déplacé, agrandi, légèrement tourné ou partiellement occulté. Un bon modèle de vision doit posséder — ou apprendre — ces invariances.

Définition 231 (Invariances en vision par ordinateur)

Soit \(f : \mathcal{X} \to \mathcal{Y}\) un classifieur d’images et \(T\) une transformation géométrique. On dit que \(f\) est invariant par \(T\) si

\[f(T(\mathbf{x})) = f(\mathbf{x}) \quad \forall \, \mathbf{x} \in \mathcal{X}\]

Les invariances les plus importantes en vision sont :

  • Translation : déplacement spatial de l’objet dans l’image.

  • Échelle : changement de taille de l’objet.

  • Rotation : rotation de l’objet dans le plan.

  • Déformation : changements légers de forme ou de perspective.

Inspiration biologique : le cortex visuel#

Les CNN tirent leur inspiration des travaux de David Hubel et Torsten Wiesel (prix Nobel 1981) sur le cortex visuel du chat. Leurs expériences ont révélé que les neurones du cortex visuel primaire (V1) ne répondent pas à l’ensemble de l’image, mais à de petites régions appelées champs réceptifs (receptive fields). De plus, ils ont identifié deux types de cellules :

  • Les cellules simples, qui détectent des bords orientés dans une région locale.

  • Les cellules complexes, qui répondent au même type de stimulus mais de manière invariante par translation sur une région plus large.

Cette organisation hiérarchique — détection locale puis agrégation spatiale — est exactement le principe des CNN : les premières couches détectent des motifs simples (bords, textures), et les couches plus profondes combinent ces motifs pour reconnaître des structures de plus en plus abstraites.

Convolution discrète 2D#

Définition formelle#

L’opération fondamentale d’un CNN est la convolution discrète, qui mesure la ressemblance locale entre une image et un petit filtre (ou noyau).

Définition 232 (Convolution discrète 2D)

Soit \(\mathbf{X} \in \mathbb{R}^{H \times W}\) une image d’entrée et \(\mathbf{K} \in \mathbb{R}^{k_H \times k_W}\) un noyau de convolution. La convolution discrète 2D (en pratique une corrélation croisée) est définie par :

\[(\mathbf{X} * \mathbf{K})[i, j] = \sum_{m=0}^{k_H - 1} \sum_{n=0}^{k_W - 1} \mathbf{X}[i + m, \, j + n] \, \mathbf{K}[m, n]\]

Le résultat est une carte de caractéristiques (feature map) ou carte d’activation de taille \((H - k_H + 1) \times (W - k_W + 1)\) (sans padding).

Remarque 197

En apprentissage profond, on utilise en réalité la corrélation croisée plutôt que la convolution au sens mathématique strict (qui nécessite de retourner le noyau). La distinction est sans conséquence car les poids du noyau sont appris : le réseau peut apprendre le noyau retourné si nécessaire.

Noyaux classiques#

Avant l’apprentissage profond, les noyaux étaient conçus manuellement pour détecter des propriétés spécifiques de l’image. Illustrons quelques filtres classiques pour construire l’intuition.

Hide code cell source

# Création d'une image simple pour illustrer la convolution
def create_test_image(size=32):
    """Crée une image de test avec des motifs géométriques."""
    img = np.zeros((size, size))
    # Rectangle
    img[8:24, 8:24] = 1.0
    # Ligne diagonale
    for i in range(size):
        if 0 <= i < size:
            img[i, min(i, size-1)] = 0.5
    return img

image = create_test_image(32)

# Noyaux classiques
kernels = {
    "Identité": np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]]),
    "Détection de bords (Laplacien)": np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]]),
    "Sobel horizontal": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]]),
    "Sobel vertical": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]),
    "Flou (moyenne)": np.ones((3, 3)) / 9,
    "Netteté": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]]),
}

fig, axes = plt.subplots(2, 4, figsize=(16, 8))

# Image originale
axes[0, 0].imshow(image, cmap='gray')
axes[0, 0].set_title("Image originale")
axes[0, 0].axis('off')

# Noyaux et résultats
from scipy.signal import correlate2d
for idx, (name, kernel) in enumerate(kernels.items()):
    row, col = divmod(idx + 1, 4)
    ax = axes[row, col]
    result = correlate2d(image, kernel, mode='same')
    ax.imshow(result, cmap='gray')
    ax.set_title(name, fontsize=9)
    ax.axis('off')

# Cacher le dernier subplot inutilisé
axes[1, 3].axis('off')
plt.suptitle("Effet de différents noyaux de convolution", fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/8dd772f8ff3666cd7e0af2570be52d9152fb9ba5ec95dbc8908085d66f6f969a.png

Pas (stride) et padding#

Deux hyperparamètres contrôlent la manière dont le noyau parcourt l’image : le pas (stride) et le rembourrage (padding).

Définition 233 (Stride et padding)

Soit une image d’entrée de taille \(H \times W\), un noyau de taille \(k \times k\), un pas \(s\) (stride) et un rembourrage \(p\) (padding).

  • Le stride \(s\) est le nombre de pixels dont le noyau se décale entre deux positions successives. Un stride de 2 divise les dimensions spatiales par 2.

  • Le padding \(p\) consiste à ajouter \(p\) pixels (généralement des zéros) sur chaque bord de l’image avant la convolution.

La taille de la carte de sortie est :

\[H_{\text{out}} = \left\lfloor \frac{H + 2p - k}{s} \right\rfloor + 1, \qquad W_{\text{out}} = \left\lfloor \frac{W + 2p - k}{s} \right\rfloor + 1\]

Les deux stratégies de padding les plus courantes sont :

  • Valid (\(p = 0\)) : aucun rembourrage. La carte de sortie est plus petite que l’entrée.

  • Same (\(p = \lfloor k / 2 \rfloor\) avec \(s = 1\)) : le rembourrage est choisi de sorte que la sortie ait les mêmes dimensions spatiales que l’entrée.

Exemple 20 (Calcul de la taille de sortie)

Soit une image \(32 \times 32\), un noyau \(5 \times 5\), un stride \(s = 1\) et un padding \(p = 0\) (valid).

\[H_{\text{out}} = \left\lfloor \frac{32 + 0 - 5}{1} \right\rfloor + 1 = 28\]

Avec un padding \(p = 2\) (same) :

\[H_{\text{out}} = \left\lfloor \frac{32 + 4 - 5}{1} \right\rfloor + 1 = 32\]

Avec un stride \(s = 2\) et un padding \(p = 2\) :

\[H_{\text{out}} = \left\lfloor \frac{32 + 4 - 5}{2} \right\rfloor + 1 = 16\]

Hide code cell source

# Démonstration de l'effet du stride et du padding avec PyTorch
input_img = torch.randn(1, 1, 32, 32)  # (batch, channels, H, W)

configs = [
    ("Valid (p=0, s=1)", nn.Conv2d(1, 1, kernel_size=5, stride=1, padding=0)),
    ("Same (p=2, s=1)", nn.Conv2d(1, 1, kernel_size=5, stride=1, padding=2)),
    ("Stride 2 (p=2, s=2)", nn.Conv2d(1, 1, kernel_size=5, stride=2, padding=2)),
    ("Grand noyau (k=7, p=3)", nn.Conv2d(1, 1, kernel_size=7, stride=1, padding=3)),
]

print(f"{'Configuration':<30} {'Entrée':<15} {'Sortie':<15}")
print("-" * 60)
for name, conv in configs:
    output = conv(input_img)
    print(f"{name:<30} {str(list(input_img.shape)):<15} {str(list(output.shape)):<15}")
Configuration                  Entrée          Sortie         
------------------------------------------------------------
Valid (p=0, s=1)               [1, 1, 32, 32]  [1, 1, 28, 28] 
Same (p=2, s=1)                [1, 1, 32, 32]  [1, 1, 32, 32] 
Stride 2 (p=2, s=2)            [1, 1, 32, 32]  [1, 1, 16, 16] 
Grand noyau (k=7, p=3)         [1, 1, 32, 32]  [1, 1, 32, 32] 

Convolution multicouche : les canaux#

En pratique, les images possèdent plusieurs canaux (3 pour RGB) et chaque couche de convolution produit plusieurs cartes de caractéristiques. La convolution s’étend naturellement à ce cas.

Définition 234 (Convolution multicouche)

Soit une entrée \(\mathbf{X} \in \mathbb{R}^{C_{\text{in}} \times H \times W}\) avec \(C_{\text{in}}\) canaux. Un filtre de convolution est un tenseur \(\mathbf{K} \in \mathbb{R}^{C_{\text{in}} \times k_H \times k_W}\) qui s’applique sur l’ensemble des canaux d’entrée. La sortie d’un filtre est :

\[\mathbf{Y}[i, j] = \sum_{c=0}^{C_{\text{in}} - 1} \sum_{m=0}^{k_H - 1} \sum_{n=0}^{k_W - 1} \mathbf{X}[c, i+m, j+n] \, \mathbf{K}[c, m, n] + b\]

Pour produire \(C_{\text{out}}\) cartes de caractéristiques, on utilise \(C_{\text{out}}\) filtres indépendants, ce qui donne un tenseur de poids \(\mathbf{W} \in \mathbb{R}^{C_{\text{out}} \times C_{\text{in}} \times k_H \times k_W}\) et un vecteur de biais \(\mathbf{b} \in \mathbb{R}^{C_{\text{out}}}\).

Remarque 198

Le nombre de paramètres d’une couche de convolution est \(C_{\text{out}} \times (C_{\text{in}} \times k_H \times k_W + 1)\), où le \(+1\) correspond au biais. Ce nombre est indépendant de la taille spatiale de l’entrée : c’est le partage de poids, qui réduit drastiquement le nombre de paramètres par rapport à une couche entièrement connectée.

Hide code cell source

# Nombre de paramètres : convolution vs fully connected
H, W, C = 224, 224, 3
C_out = 64
k = 3

params_conv = C_out * (C * k * k + 1)
params_fc = C_out * (H * W * C + 1)

print(f"Couche convolutive (k={k}, C_in={C}, C_out={C_out}) : "
      f"{params_conv:,} paramètres")
print(f"Couche FC équivalente : {params_fc:,} paramètres")
print(f"Rapport FC / Conv : {params_fc / params_conv:,.0f}x")
Couche convolutive (k=3, C_in=3, C_out=64) : 1,792 paramètres
Couche FC équivalente : 9,633,856 paramètres
Rapport FC / Conv : 5,376x

Couches de pooling#

Après les convolutions, les couches de pooling (sous-échantillonnage) réduisent progressivement les dimensions spatiales des cartes de caractéristiques, tout en conservant l’information la plus saillante.

Définition 235 (Max pooling)

Le max pooling avec une fenêtre de taille \(k \times k\) et un pas \(s\) produit une sortie :

\[\mathbf{Y}[i, j] = \max_{0 \le m < k, \, 0 \le n < k} \mathbf{X}[i \cdot s + m, \, j \cdot s + n]\]

Typiquement, on utilise \(k = 2\) et \(s = 2\), ce qui divise chaque dimension spatiale par 2.

Définition 236 (Average pooling et global average pooling)

Le average pooling remplace le maximum par la moyenne sur la fenêtre :

\[\mathbf{Y}[i, j] = \frac{1}{k^2} \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} \mathbf{X}[i \cdot s + m, \, j \cdot s + n]\]

Le global average pooling (GAP) calcule la moyenne sur l’ensemble de la carte de caractéristiques :

\[\mathbf{y}_c = \frac{1}{H \times W} \sum_{i=0}^{H-1} \sum_{j=0}^{W-1} \mathbf{X}[c, i, j]\]

Il transforme une carte \(C \times H \times W\) en un vecteur de dimension \(C\), éliminant ainsi la dépendance spatiale.

Remarque 199

Le pooling joue un double rôle :

  1. Réduction de dimension : il diminue le nombre de pixels à traiter, réduisant la charge computationnelle et le nombre de paramètres des couches suivantes.

  2. Invariance spatiale locale : en prenant le maximum (ou la moyenne) sur une petite région, le pooling rend la représentation légèrement invariante aux petites translations.

Hide code cell source

# Illustration du max pooling et average pooling
feature_map = torch.tensor([
    [1, 3, 2, 4],
    [5, 6, 1, 2],
    [3, 2, 7, 8],
    [4, 1, 3, 5]
], dtype=torch.float32).unsqueeze(0).unsqueeze(0)  # (1, 1, 4, 4)

max_pool = nn.MaxPool2d(kernel_size=2, stride=2)
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2)
gap = nn.AdaptiveAvgPool2d(1)

print("Entrée (4×4) :")
print(feature_map.squeeze().numpy())
print(f"\nMax pooling 2×2 (sortie 2×2) :")
print(max_pool(feature_map).squeeze().numpy())
print(f"\nAverage pooling 2×2 (sortie 2×2) :")
print(avg_pool(feature_map).squeeze().numpy())
print(f"\nGlobal average pooling (sortie 1×1) :")
print(gap(feature_map).squeeze().numpy())
Entrée (4×4) :
[[1. 3. 2. 4.]
 [5. 6. 1. 2.]
 [3. 2. 7. 8.]
 [4. 1. 3. 5.]]

Max pooling 2×2 (sortie 2×2) :
[[6. 4.]
 [4. 8.]]

Average pooling 2×2 (sortie 2×2) :
[[3.75 2.25]
 [2.5  5.75]]

Global average pooling (sortie 1×1) :
3.5625

Hide code cell source

# Visualisation de l'effet du pooling sur une image
from torchvision.datasets import FashionMNIST

transform = transforms.Compose([transforms.ToTensor()])
dataset = FashionMNIST(root='./data', train=True, download=True, transform=transform)
sample_img, label = dataset[0]  # (1, 28, 28)

pool_layers = {
    "Original (28×28)": sample_img,
    "MaxPool 2×2 (14×14)": nn.MaxPool2d(2)(sample_img.unsqueeze(0)).squeeze(0),
    "MaxPool 4×4 (7×7)": nn.MaxPool2d(4)(sample_img.unsqueeze(0)).squeeze(0),
    "AvgPool 2×2 (14×14)": nn.AvgPool2d(2)(sample_img.unsqueeze(0)).squeeze(0),
}

fig, axes = plt.subplots(4, 1, figsize=(8, 13))
for ax, (title, img) in zip(axes, pool_layers.items()):
    ax.imshow(img.squeeze().numpy(), cmap='gray')
    ax.set_title(title, fontsize=10)
    ax.axis('off')

plt.suptitle("Effet du pooling sur une image FashionMNIST", fontsize=12)
plt.tight_layout()
plt.show()
_images/4e61d2745760915e946fe3d511e3c2517d40845efc89d8b6d03bd28353424cfb.png

Architecture d’un CNN#

Blocs de construction#

Un CNN typique est construit à partir de blocs convolutifs empilés, suivis de couches entièrement connectées pour la classification.

Définition 237 (Bloc convolutif)

Un bloc convolutif standard est composé de trois opérations successives :

  1. Convolution : extraction de caractéristiques locales par un ensemble de filtres appris.

  2. Activation non linéaire : typiquement ReLU, \(\sigma(x) = \max(0, x)\).

  3. Pooling : sous-échantillonnage spatial (max pooling ou average pooling).

On note ce bloc \(\text{Conv}(C_{\text{in}}, C_{\text{out}}, k) \to \text{ReLU} \to \text{Pool}(k_p, s_p)\).

L’architecture générale d’un CNN pour la classification suit le schéma :

\[\text{Image} \to \underbrace{[\text{Conv} \to \text{ReLU} \to \text{Pool}]}_{\text{répété } L \text{ fois}} \to \text{Flatten} \to \underbrace{[\text{FC} \to \text{ReLU}]}_{\text{répété}} \to \text{Softmax}\]

Feature maps et champ réceptif#

À mesure que l’on avance dans le réseau, les cartes de caractéristiques (feature maps) deviennent spatialement plus petites mais plus nombreuses (plus de canaux). Les premières couches capturent des motifs bas niveau (bords, textures) tandis que les couches profondes capturent des motifs haut niveau (parties d’objets, objets entiers).

Définition 238 (Champ réceptif)

Le champ réceptif (receptive field) d’un neurone dans une couche \(\ell\) est la région de l’image d’entrée qui influence la valeur de ce neurone. Pour un réseau à \(L\) couches de convolution avec des noyaux de taille \(k\) et un stride \(s = 1\) (sans pooling), le champ réceptif d’un neurone de la couche \(\ell\) est :

\[r_\ell = r_{\ell-1} + (k_\ell - 1) \cdot \prod_{i=1}^{\ell-1} s_i\]

avec \(r_0 = 1\). Pour des noyaux \(3 \times 3\) empilés et un stride unitaire :

\[r_\ell = 1 + 2\ell\]

Ainsi, trois couches \(3 \times 3\) donnent un champ réceptif de \(7 \times 7\), équivalent à un seul noyau \(7 \times 7\) mais avec beaucoup moins de paramètres et plus de non-linéarités.

Hide code cell source

# Illustration du champ réceptif croissant
fig, ax = plt.subplots(figsize=(10, 4))

layers = np.arange(1, 11)
rf_3x3 = 1 + 2 * layers  # noyau 3x3, stride 1
rf_5x5 = 1 + 4 * layers  # noyau 5x5, stride 1
rf_3x3_pool = [1]
for l in layers:
    rf_3x3_pool.append(rf_3x3_pool[-1] + 2 * (2 ** ((l-1) // 2)))
rf_3x3_pool = rf_3x3_pool[1:]

ax.plot(layers, rf_3x3, 'o-', label='Conv 3×3, stride 1', color='steelblue')
ax.plot(layers, rf_5x5, 's-', label='Conv 5×5, stride 1', color='coral')
ax.set_xlabel("Nombre de couches")
ax.set_ylabel("Champ réceptif (pixels)")
ax.set_title("Croissance du champ réceptif en fonction de la profondeur")
ax.legend()
plt.tight_layout()
plt.show()
_images/180f9acfc3f163a773eae2ad7a8dabba8859b4d5747e71d9ec7fc8039ea29ea9.png

Architectures classiques#

L’histoire des CNN est jalonnée d’architectures qui ont repoussé les limites de la reconnaissance d’images. Nous en présentons les plus influentes.

LeNet-5 (1998)#

Conçu par Yann LeCun et ses collaborateurs, LeNet-5 est l’un des premiers CNN à avoir démontré l’efficacité de l’apprentissage convolutif pour la reconnaissance de chiffres manuscrits (MNIST). Son architecture est devenue le prototype des CNN modernes.

Exemple 21 (Architecture de LeNet-5)

L’architecture de LeNet-5 comprend :

  1. Entrée : image \(32 \times 32\) en niveaux de gris.

  2. C1 : Convolution \(5 \times 5\), 6 filtres → cartes \(28 \times 28 \times 6\), suivie de sigmoid.

  3. S2 : Average pooling \(2 \times 2\) → cartes \(14 \times 14 \times 6\).

  4. C3 : Convolution \(5 \times 5\), 16 filtres → cartes \(10 \times 10 \times 16\), suivie de sigmoid.

  5. S4 : Average pooling \(2 \times 2\) → cartes \(5 \times 5 \times 16\).

  6. C5 : Convolution \(5 \times 5\), 120 filtres → vecteur \(1 \times 1 \times 120\).

  7. F6 : Couche FC de 84 neurones.

  8. Sortie : 10 classes.

Nombre total de paramètres : environ 60 000.

AlexNet (2012)#

AlexNet, proposé par Alex Krizhevsky, Ilya Sutskever et Geoffrey Hinton, a remporté la compétition ImageNet 2012 avec une marge considérable, marquant le début de la révolution de l’apprentissage profond.

Exemple 22 (Innovations d’AlexNet)

  • Utilisation de ReLU au lieu de sigmoid/tanh (accélère la convergence).

  • Dropout (taux 0.5) dans les couches FC pour la régularisation.

  • Data augmentation (translations, retournements horizontaux, modifications de couleur).

  • Entraînement sur GPU (deux GPU en parallèle).

  • 5 couches de convolution + 3 couches FC. Environ 60 millions de paramètres.

VGGNet (2014)#

L’équipe du Visual Geometry Group d’Oxford a proposé VGGNet, dont l’idée centrale est d’utiliser uniquement des noyaux \(3 \times 3\) empilés en profondeur.

Proposition 59 (Équivalence de noyaux empilés)

Deux couches de convolution \(3 \times 3\) successives ont un champ réceptif de \(5 \times 5\), et trois couches \(3 \times 3\) ont un champ réceptif de \(7 \times 7\). Cependant :

  • Paramètres : trois couches \(3 \times 3\) avec \(C\) canaux nécessitent \(3 \times (C \times 3 \times 3 \times C) = 27C^2\) paramètres, contre \(C \times 7 \times 7 \times C = 49C^2\) pour un seul noyau \(7 \times 7\).

  • Non-linéarité : l’empilement introduit des activations ReLU entre les couches, augmentant la capacité discriminative du réseau.

Proof. Le champ réceptif d’une couche \(3 \times 3\) est \(3\). Après deux couches, le champ réceptif est \(r_2 = 1 + 2 \times 1 + 2 \times 1 = 5\). Après trois couches : \(r_3 = 1 + 2 \times 3 = 7\). Le nombre de paramètres pour trois couches \(3 \times 3\) est \(3 \times 9C^2 = 27C^2\), contre \(49C^2\) pour un noyau \(7 \times 7\), soit une réduction de \(\frac{27}{49} \approx 45\%\).

Hide code cell source

# Tableau comparatif des architectures classiques
import pandas as pd

architectures = pd.DataFrame({
    'Architecture': ['LeNet-5', 'AlexNet', 'VGG-16', 'VGG-19'],
    'Année': [1998, 2012, 2014, 2014],
    'Profondeur': [5, 8, 16, 19],
    'Paramètres': ['60 K', '60 M', '138 M', '144 M'],
    'Top-5 ImageNet': ['—', '16.4%', '7.3%', '7.3%'],
    'Innovation clé': [
        'Première architecture CNN',
        'ReLU, Dropout, GPU',
        'Noyaux 3×3 empilés',
        'Plus de profondeur'
    ]
})

print(architectures.to_string(index=False))
Architecture  Année  Profondeur Paramètres Top-5 ImageNet            Innovation clé
     LeNet-5   1998           5       60 K              — Première architecture CNN
     AlexNet   2012           8       60 M          16.4%        ReLU, Dropout, GPU
      VGG-16   2014          16      138 M           7.3%        Noyaux 3×3 empilés
      VGG-19   2014          19      144 M           7.3%        Plus de profondeur

Innovations modernes#

Les architectures classiques ont montré que la profondeur est cruciale pour les performances. Cependant, augmenter simplement le nombre de couches ne fonctionne pas indéfiniment : au-delà d’une certaine profondeur, les gradients s’évanouissent ou explosent, et les performances se dégradent paradoxalement. Plusieurs innovations ont permis de surmonter cette barrière.

ResNet et les connexions résiduelles#

Le problème fondamental des réseaux très profonds est la dégradation : un réseau de 56 couches obtient de moins bons résultats qu’un réseau de 20 couches, non pas à cause du surapprentissage, mais à cause de la difficulté d’optimisation. ResNet (He et al., 2015) résout ce problème grâce aux connexions résiduelles (skip connections).

Définition 239 (Bloc résiduel)

Soit \(\mathbf{x}\) l’entrée d’un bloc de couches. Au lieu d’apprendre directement la transformation souhaitée \(\mathcal{H}(\mathbf{x})\), un bloc résiduel apprend le résidu \(\mathcal{F}(\mathbf{x}) = \mathcal{H}(\mathbf{x}) - \mathbf{x}\) :

\[\mathbf{y} = \mathcal{F}(\mathbf{x}, \{W_i\}) + \mathbf{x}\]

\(\mathcal{F}\) représente typiquement deux couches de convolution avec batch normalization et ReLU :

\[\mathcal{F}(\mathbf{x}) = W_2 \cdot \sigma(BN(W_1 * \mathbf{x}))\]

La connexion \(+ \, \mathbf{x}\) est appelée skip connection ou shortcut.

Proposition 60 (Propagation du gradient dans ResNet)

Dans un bloc résiduel \(\mathbf{y} = \mathcal{F}(\mathbf{x}) + \mathbf{x}\), le gradient de la perte \(\mathcal{L}\) par rapport à \(\mathbf{x}\) est :

\[\frac{\partial \mathcal{L}}{\partial \mathbf{x}} = \frac{\partial \mathcal{L}}{\partial \mathbf{y}} \cdot \left(\frac{\partial \mathcal{F}}{\partial \mathbf{x}} + \mathbf{I}\right)\]

Le terme \(+ \mathbf{I}\) (matrice identité) garantit que le gradient ne s’annule jamais complètement, même si \(\frac{\partial \mathcal{F}}{\partial \mathbf{x}}\) est petit. C’est le mécanisme clé qui permet d’entraîner des réseaux de plus de 100 couches.

Hide code cell source

# Implémentation d'un bloc résiduel
class ResidualBlock(nn.Module):
    """Bloc résiduel de base avec deux convolutions 3×3."""
    def __init__(self, channels):
        super().__init__()
        self.conv1 = nn.Conv2d(channels, channels, kernel_size=3, padding=1)
        self.bn1 = nn.BatchNorm2d(channels)
        self.conv2 = nn.Conv2d(channels, channels, kernel_size=3, padding=1)
        self.bn2 = nn.BatchNorm2d(channels)

    def forward(self, x):
        residual = x
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += residual  # skip connection
        out = F.relu(out)
        return out

# Test du bloc résiduel
block = ResidualBlock(64)
x = torch.randn(1, 64, 16, 16)
y = block(x)
print(f"Entrée : {x.shape} → Sortie : {y.shape}")
print(f"Paramètres du bloc : {sum(p.numel() for p in block.parameters()):,}")
Entrée : torch.Size([1, 64, 16, 16]) → Sortie : torch.Size([1, 64, 16, 16])
Paramètres du bloc : 74,112

Batch normalization dans les CNN#

La batch normalization (Ioffe & Szegedy, 2015) normalise les activations à l’intérieur de chaque mini-batch, stabilisant et accélérant l’entraînement.

Définition 240 (Batch normalization pour les convolutions)

Pour une carte de caractéristiques \(\mathbf{X} \in \mathbb{R}^{N \times C \times H \times W}\) (batch × canaux × hauteur × largeur), la batch normalization calcule, pour chaque canal \(c\) :

\[\hat{x}_{n,c,i,j} = \frac{x_{n,c,i,j} - \mu_c}{\sqrt{\sigma_c^2 + \epsilon}}\]

\(\mu_c\) et \(\sigma_c^2\) sont la moyenne et la variance calculées sur les dimensions \((N, H, W)\) pour le canal \(c\). Des paramètres apprenables \(\gamma_c\) et \(\beta_c\) permettent ensuite une transformation affine :

\[y_{n,c,i,j} = \gamma_c \, \hat{x}_{n,c,i,j} + \beta_c\]

Convolutions 1×1#

Les convolutions 1×1, popularisées par le réseau Network in Network (Lin et al., 2013) et abondamment utilisées dans GoogLeNet/Inception, sont un outil puissant pour manipuler le nombre de canaux.

Remarque 200

Une convolution \(1 \times 1\) avec \(C_{\text{in}}\) canaux en entrée et \(C_{\text{out}}\) canaux en sortie agit comme une couche FC appliquée indépendamment à chaque position spatiale. Ses usages principaux sont :

  1. Réduction de dimension : réduire le nombre de canaux avant une convolution coûteuse (\(C_{\text{out}} < C_{\text{in}}\)).

  2. Augmentation de dimension : ajouter de la capacité (\(C_{\text{out}} > C_{\text{in}}\)).

  3. Combinaison inter-canaux : mélanger l’information entre les canaux sans modifier la résolution spatiale.

Hide code cell source

# Illustration : réduction de dimension avec une convolution 1×1
input_feat = torch.randn(1, 256, 32, 32)  # 256 canaux

conv_1x1 = nn.Conv2d(256, 64, kernel_size=1)
conv_3x3_direct = nn.Conv2d(256, 64, kernel_size=3, padding=1)
conv_3x3_after_reduction = nn.Sequential(
    nn.Conv2d(256, 64, kernel_size=1),
    nn.ReLU(),
    nn.Conv2d(64, 64, kernel_size=3, padding=1)
)

params_direct = sum(p.numel() for p in conv_3x3_direct.parameters())
params_bottleneck = sum(p.numel() for p in conv_3x3_after_reduction.parameters())

print(f"Conv 3×3 directe (256→64) : {params_direct:,} paramètres")
print(f"Conv 1×1 + Conv 3×3 (256→64→64) : {params_bottleneck:,} paramètres")
print(f"Réduction : {(1 - params_bottleneck / params_direct) * 100:.1f}%")
Conv 3×3 directe (256→64) : 147,520 paramètres
Conv 1×1 + Conv 3×3 (256→64→64) : 53,376 paramètres
Réduction : 63.8%

Transfer learning#

Motivation#

Entraîner un CNN de zéro requiert d’immenses jeux de données et des ressources computationnelles considérables. Le transfer learning (apprentissage par transfert) contourne ce problème en réutilisant un réseau pré-entraîné sur un grand jeu de données (typiquement ImageNet, 1.2 million d’images, 1000 classes) comme point de départ pour une nouvelle tâche.

Définition 241 (Transfer learning)

Le transfer learning consiste à transférer les connaissances apprises par un modèle sur une tâche source \(\mathcal{T}_S\) vers une tâche cible \(\mathcal{T}_T\) différente. Pour les CNN, cela repose sur l’observation que les premières couches apprennent des caractéristiques génériques (bords, textures, couleurs) transférables entre tâches, tandis que les couches profondes apprennent des caractéristiques spécifiques à la tâche.

Deux stratégies principales :

  1. Feature extraction : les poids du réseau pré-entraîné sont gelés ; seule la couche de classification finale est remplacée et entraînée.

  2. Fine-tuning : tout ou partie du réseau pré-entraîné est ré-entraîné avec un taux d’apprentissage faible, en plus de la nouvelle couche de classification.

Remarque 201

Quand utiliser le transfer learning ?

  • Peu de données, tâche similaire → Feature extraction. Les features pré-entraînées sont déjà pertinentes.

  • Beaucoup de données, tâche similaire → Fine-tuning de l’ensemble du réseau.

  • Peu de données, tâche très différente → Feature extraction sur les couches basses uniquement.

  • Beaucoup de données, tâche très différente → Entraînement de zéro, ou fine-tuning profond.

En pratique, le transfer learning est presque toujours bénéfique, même pour des tâches éloignées d’ImageNet.

Hide code cell source

# Chargement d'un modèle pré-entraîné et adaptation
from torchvision import models

# Charger ResNet-18 pré-entraîné
resnet18 = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

# Observer l'architecture
print("Dernière couche (originale) :")
print(f"  {resnet18.fc}")
print(f"  Paramètres FC : {resnet18.fc.in_features} × 1000 = "
      f"{resnet18.fc.in_features * 1000:,}")

# Adaptation pour 10 classes (ex. CIFAR-10)
num_classes = 10
resnet18.fc = nn.Linear(resnet18.fc.in_features, num_classes)

print(f"\nDernière couche (adaptée) :")
print(f"  {resnet18.fc}")

# Feature extraction : geler tous les paramètres sauf la dernière couche
for param in resnet18.parameters():
    param.requires_grad = False
for param in resnet18.fc.parameters():
    param.requires_grad = True

total_params = sum(p.numel() for p in resnet18.parameters())
trainable_params = sum(p.numel() for p in resnet18.parameters() if p.requires_grad)
print(f"\nParamètres totaux : {total_params:,}")
print(f"Paramètres entraînables (feature extraction) : {trainable_params:,}")
print(f"Pourcentage entraînable : {trainable_params / total_params * 100:.2f}%")
Dernière couche (originale) :
  Linear(in_features=512, out_features=1000, bias=True)
  Paramètres FC : 512 × 1000 = 512,000

Dernière couche (adaptée) :
  Linear(in_features=512, out_features=10, bias=True)

Paramètres totaux : 11,181,642
Paramètres entraînables (feature extraction) : 5,130
Pourcentage entraînable : 0.05%

Implémentation complète en PyTorch#

Mettons en pratique toutes les notions vues dans ce chapitre en construisant, entraînant et évaluant un CNN sur le jeu de données FashionMNIST.

Chargement des données#

Hide code cell source

# Transformations et chargement de FashionMNIST
transform_train = transforms.Compose([
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(5),
    transforms.ToTensor(),
    transforms.Normalize((0.2860,), (0.3530,))
])

transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.2860,), (0.3530,))
])

train_dataset_full = FashionMNIST(root='./data', train=True,
                                   download=True, transform=transform_train)
test_dataset_full = FashionMNIST(root='./data', train=False,
                                  download=True, transform=transform_test)

# Sous-ensemble pour accélérer l'entraînement (CPU)
from torch.utils.data import Subset
train_dataset = Subset(train_dataset_full, range(5000))
test_dataset = Subset(test_dataset_full, range(1000))

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=0)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=0)

# Classes de FashionMNIST
class_names = ['T-shirt', 'Pantalon', 'Pull', 'Robe', 'Manteau',
               'Sandale', 'Chemise', 'Basket', 'Sac', 'Bottine']

print(f"Entraînement : {len(train_dataset)} images")
print(f"Test : {len(test_dataset)} images")
print(f"Taille des images : {train_dataset[0][0].shape}")
Entraînement : 5000 images
Test : 1000 images
Taille des images : torch.Size([1, 28, 28])

Hide code cell source

# Visualisation d'un échantillon
fig, axes = plt.subplots(2, 8, figsize=(16, 4.5))
for i, ax in enumerate(axes.flat):
    img, label = train_dataset[i]
    # Dénormaliser pour l'affichage
    img_display = img.squeeze() * 0.3530 + 0.2860
    ax.imshow(img_display.numpy(), cmap='gray')
    ax.set_title(class_names[label], fontsize=9)
    ax.axis('off')

plt.suptitle("Échantillon de FashionMNIST", fontsize=13)
plt.tight_layout()
plt.show()
_images/78e683ac9bfb28bd5a869db9cc3dd672c21499e8fbfb6212ac84c190deeaf761.png

Construction du CNN#

Hide code cell source

class FashionCNN(nn.Module):
    """CNN pour la classification FashionMNIST.

    Architecture :
        Conv(1→32, 3×3) → BN → ReLU → Conv(32→32, 3×3) → BN → ReLU → MaxPool
        Conv(32→64, 3×3) → BN → ReLU → Conv(64→64, 3×3) → BN → ReLU → MaxPool
        Flatten → FC(64*5*5→256) → BN → ReLU → Dropout → FC(256→10)
    """
    def __init__(self, num_classes=10):
        super().__init__()

        # Bloc convolutif 1
        self.conv_block1 = nn.Sequential(
            nn.Conv2d(1, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.Conv2d(32, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.MaxPool2d(2, 2)  # 28×28 → 14×14
        )

        # Bloc convolutif 2
        self.conv_block2 = nn.Sequential(
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.Conv2d(64, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.MaxPool2d(2, 2)  # 14×14 → 7×7
        )

        # Couches fully-connected
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 7 * 7, 256),
            nn.BatchNorm1d(256),
            nn.ReLU(),
            nn.Dropout(0.4),
            nn.Linear(256, num_classes)
        )

    def forward(self, x):
        x = self.conv_block1(x)
        x = self.conv_block2(x)
        x = self.classifier(x)
        return x


model = FashionCNN().to(device)

# Résumé du modèle
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Paramètres totaux : {total_params:,}")
print(f"Paramètres entraînables : {trainable_params:,}")

# Vérification avec une entrée factice
model.eval()
dummy = torch.randn(1, 1, 28, 28).to(device)
with torch.no_grad():
    output = model(dummy)
model.train()
print(f"\nEntrée : {dummy.shape} → Sortie : {output.shape}")
Paramètres totaux : 871,530
Paramètres entraînables : 871,530

Entrée : torch.Size([1, 1, 28, 28]) → Sortie : torch.Size([1, 10])

Remarque 202

Notre CNN contient environ 730 000 paramètres — bien moins qu’un MLP avec une seule couche cachée de 1000 neurones appliqué aux mêmes images (\(784 \times 1000 + 1000 \times 10 = 794\,000\)), tout en étant beaucoup plus performant grâce à l’exploitation de la structure spatiale.

Entraînement#

Hide code cell source

def train_one_epoch(model, loader, criterion, optimizer, device):
    """Entraîne le modèle pour une époque."""
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0

    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        running_loss += loss.item() * images.size(0)
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()

    return running_loss / total, 100.0 * correct / total


def evaluate(model, loader, criterion, device):
    """Évalue le modèle sur un jeu de données."""
    model.eval()
    running_loss = 0.0
    correct = 0
    total = 0

    with torch.no_grad():
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)

            running_loss += loss.item() * images.size(0)
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()

    return running_loss / total, 100.0 * correct / total

Hide code cell source

# Configuration de l'entraînement
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)

num_epochs = 3
history = {'train_loss': [], 'train_acc': [], 'test_loss': [], 'test_acc': []}

# Boucle d'entraînement
for epoch in range(1, num_epochs + 1):
    train_loss, train_acc = train_one_epoch(
        model, train_loader, criterion, optimizer, device
    )
    test_loss, test_acc = evaluate(model, test_loader, criterion, device)
    scheduler.step()

    history['train_loss'].append(train_loss)
    history['train_acc'].append(train_acc)
    history['test_loss'].append(test_loss)
    history['test_acc'].append(test_acc)

    print(f"Époque {epoch:2d}/{num_epochs} | "
          f"Train: loss={train_loss:.4f}, acc={train_acc:.1f}% | "
          f"Test: loss={test_loss:.4f}, acc={test_acc:.1f}%")

print(f"\nMeilleure précision test : {max(history['test_acc']):.1f}%")
Époque  1/3 | Train: loss=0.6687, acc=77.7% | Test: loss=0.4487, acc=84.1%
Époque  2/3 | Train: loss=0.4062, acc=85.9% | Test: loss=0.3895, acc=85.6%
Époque  3/3 | Train: loss=0.3765, acc=86.7% | Test: loss=0.3533, acc=87.3%

Meilleure précision test : 87.3%

Hide code cell source

# Courbes d'apprentissage
fig, axes = plt.subplots(2, 1, figsize=(9, 9))
epochs = range(1, num_epochs + 1)

# Perte
axes[0].plot(epochs, history['train_loss'], 'o-', label='Entraînement',
             markersize=3, color='steelblue')
axes[0].plot(epochs, history['test_loss'], 's-', label='Test',
             markersize=3, color='coral')
axes[0].set_xlabel("Époque")
axes[0].set_ylabel("Perte (cross-entropy)")
axes[0].set_title("Évolution de la perte")
axes[0].legend()

# Précision
axes[1].plot(epochs, history['train_acc'], 'o-', label='Entraînement',
             markersize=3, color='steelblue')
axes[1].plot(epochs, history['test_acc'], 's-', label='Test',
             markersize=3, color='coral')
axes[1].set_xlabel("Époque")
axes[1].set_ylabel("Précision (%)")
axes[1].set_title("Évolution de la précision")
axes[1].legend()

plt.tight_layout()
plt.show()
_images/92eb88d30f6591ead25bd1fe67ff7abf403d51b57730187fda7106cd5f0f7ce6.png

Évaluation détaillée#

Hide code cell source

from sklearn.metrics import confusion_matrix, classification_report

# Prédictions sur le jeu de test
model.eval()
all_preds = []
all_labels = []

with torch.no_grad():
    for images, labels in test_loader:
        images = images.to(device)
        outputs = model(images)
        _, predicted = outputs.max(1)
        all_preds.extend(predicted.cpu().numpy())
        all_labels.extend(labels.numpy())

all_preds = np.array(all_preds)
all_labels = np.array(all_labels)

# Rapport de classification
print(classification_report(all_labels, all_preds,
                            target_names=class_names, digits=3))
              precision    recall  f1-score   support

     T-shirt      0.891     0.766     0.824       107
    Pantalon      1.000     0.981     0.990       105
        Pull      0.766     0.856     0.809       111
        Robe      0.845     0.935     0.888        93
     Manteau      0.914     0.643     0.755       115
     Sandale      0.954     0.954     0.954        87
     Chemise      0.598     0.784     0.679        97
      Basket      0.938     0.947     0.942        95
         Sac      1.000     0.968     0.984        95
     Bottine      0.958     0.958     0.958        95

    accuracy                          0.873      1000
   macro avg      0.886     0.879     0.878      1000
weighted avg      0.885     0.873     0.874      1000

Hide code cell source

# Matrice de confusion
cm = confusion_matrix(all_labels, all_preds)
cm_normalized = cm.astype('float') / cm.sum(axis=1, keepdims=True)

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(cm_normalized, annot=True, fmt='.2f', cmap='Blues',
            xticklabels=class_names, yticklabels=class_names, ax=ax)
ax.set_xlabel('Prédiction')
ax.set_ylabel('Vraie classe')
ax.set_title('Matrice de confusion normalisée — FashionMNIST')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
_images/d49ec085b8aee2bf523e96ec63b7c9ed865580067be338d1233e4e8b8ec9b511.png

Visualisation des filtres appris#

L’un des avantages des CNN est l’interprétabilité partielle des premières couches. Les filtres de la première couche de convolution agissent directement sur les pixels et peuvent être visualisés.

Hide code cell source

# Visualisation des filtres de la première couche convolutive
first_conv = model.conv_block1[0]  # Premier nn.Conv2d
filters = first_conv.weight.data.cpu()

fig, axes = plt.subplots(4, 8, figsize=(14, 7))
for i, ax in enumerate(axes.flat):
    if i < filters.shape[0]:
        kernel = filters[i, 0].numpy()  # Canal unique (niveaux de gris)
        ax.imshow(kernel, cmap='RdBu_r', interpolation='nearest')
        ax.set_title(f"Filtre {i}", fontsize=8)
    ax.axis('off')

plt.suptitle("Filtres appris — Première couche convolutive (3×3)",
             fontsize=13)
plt.tight_layout()
plt.show()
_images/0f6f5ac2751645e43deef29ba42b1c9b7b071580255d01ea8746fcabe8de96ad.png

Hide code cell source

# Visualisation des feature maps pour une image d'exemple
sample_img, sample_label = test_dataset[0]
sample_input = sample_img.unsqueeze(0).to(device)

# Extraire les activations après le premier bloc convolutif
model.eval()
with torch.no_grad():
    after_block1 = model.conv_block1(sample_input)

feature_maps = after_block1.squeeze(0).cpu()

fig, axes = plt.subplots(4, 8, figsize=(14, 7))
for i, ax in enumerate(axes.flat):
    if i < feature_maps.shape[0]:
        ax.imshow(feature_maps[i].numpy(), cmap='viridis')
        ax.set_title(f"Map {i}", fontsize=8)
    ax.axis('off')

plt.suptitle(f"Feature maps après le bloc 1 — Classe : {class_names[sample_label]}",
             fontsize=13)
plt.tight_layout()
plt.show()
_images/3705b9b40bf2deefb671ace1d95e41893f40bfefa2bcaa1139dc1ae70ea5049d.png

Remarque 203

On observe que les filtres de la première couche ont appris à détecter des motifs simples : bords horizontaux, verticaux, diagonaux, et des détecteurs de gradient. Les feature maps correspondantes montrent clairement les contours et les textures de l’image d’entrée. Les couches plus profondes (non visualisées ici) combinent ces caractéristiques bas niveau en représentations de plus en plus abstraites et sémantiques.

Visualisation de prédictions#

Hide code cell source

# Prédictions sur quelques exemples
model.eval()
fig, axes = plt.subplots(3, 6, figsize=(16, 8))

for i, ax in enumerate(axes.flat):
    img, true_label = test_dataset[i + 100]
    img_input = img.unsqueeze(0).to(device)

    with torch.no_grad():
        logits = model(img_input)
        probs = F.softmax(logits, dim=1)
        pred_label = probs.argmax(dim=1).item()
        confidence = probs.max().item()

    img_display = img.squeeze().cpu() * 0.3530 + 0.2860
    ax.imshow(img_display.numpy(), cmap='gray')

    color = 'green' if pred_label == true_label else 'red'
    ax.set_title(f"Préd : {class_names[pred_label]}\n"
                 f"Vrai : {class_names[true_label]}\n"
                 f"Conf : {confidence:.1%}", fontsize=8, color=color)
    ax.axis('off')

plt.suptitle("Prédictions du CNN sur FashionMNIST", fontsize=13)
plt.tight_layout()
plt.show()
_images/93de4259bd2e83f9f87025c7b178d53f48174c30f6d2534ea211e6508a89e86c.png

Résumé#

Ce chapitre a présenté les réseaux de neurones convolutifs, de leurs fondements théoriques à leur implémentation pratique.

Remarque 204

Points clés à retenir :

  1. L’opération de convolution exploite la structure spatiale des images grâce à la connectivité locale et au partage de poids, réduisant drastiquement le nombre de paramètres par rapport aux couches entièrement connectées.

  2. Le pooling réduit les dimensions spatiales et introduit une invariance locale par translation.

  3. L’empilement de petits noyaux (\(3 \times 3\)) est préférable à un grand noyau unique : moins de paramètres, plus de non-linéarités, champ réceptif équivalent.

  4. Les connexions résiduelles (ResNet) permettent d’entraîner des réseaux très profonds en assurant un flux de gradient efficace.

  5. La batch normalization stabilise et accélère l’entraînement en normalisant les activations intermédiaires.

  6. Le transfer learning permet d’obtenir d’excellentes performances avec peu de données en réutilisant des modèles pré-entraînés.

Le chapitre suivant étendra ces idées aux données séquentielles avec les réseaux récurrents (RNN).