Construction de Réseaux Neuronaux Convolutifs avec PyTorch

La conception de réseaux neuronaux, en particulier des réseaux convolutifs, est fondamentale en apprentissage profond. PyTorch offre un cadre flexible pour bâtir ces architectures. Cet article explore la structure de base des modèles PyTorch, les opérations de convolution 2D, et les avantages des réseaux convolutifs par rapport aux perceptrons multicouches (MLP) pour le traitement d'images.

Architecture Modulaire avec nn.Module

Toute architecture de réseau neuronal en PyTorch hérite de la classe torch.nn.Module. Cette classe fournit l'infrastructure essentielle pour organiser les couches et gérer les paramètres du modèle. Deux méthodes principales doivent être implémentées :

  • __init__(self) : Le constructeur où toutes les couches du réseau, telles que les couches convolutives, linéaires ou de normalisation, sont définies et initialisées.
  • forward(self, x) : Cette méthode décrit le chemin de propagation avant (forward pass) du réseau, spécifiant comment les données d'entrée x traversent les différentes couches et fonctions d'activation pour produire la sortie.

Voici un exemple simple illustrant l'utilisation de nn.Module pour un réseau basique :

import torch
import torch.nn as nn
import torch.nn.functional as F

class BasicConvNet(nn.Module):
    def __init__(self):
        super().__init__()
        # Définition de deux couches de convolution
        self.conv_layer1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) # 1 canal d'entrée, 32 canaux de sortie, noyau 3x3
        self.conv_layer2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 32 canaux d'entrée, 64 canaux de sortie

    def forward(self, input_data):
        # Application de la première convolution suivie d'une activation ReLU
        output = F.relu(self.conv_layer1(input_data))
        # Application de la seconde convolution suivie d'une activation ReLU
        output = F.relu(self.conv_layer2(output))
        return output

# Test du modèle avec une entrée factice
sample_input = torch.randn(1, 1, 28, 28) # Batch de 1, 1 canal, image 28x28
network = BasicConvNet()
result = network(sample_input)
print(f"Forme de la sortie du réseau : {result.shape}")

L'exemple ci-dessus montre une séquence où l'entrée traverse une couche de convolution, puis une fonction d'activation ReLU, suivie d'une autre couche de convolution et d'une ReLU. Cette sturcture est typique des blocs fondamentaux dans les réseaux convolutifs. Il est crucial de gérer attentivement les dimensions des tenseurs d'entrée et de sortie des couches. La fonction torch.reshape() est souvent utilisée pour ajuster les dimensions des données si nécessaire, par exemple pour passer d'une entrée 1D à 4D, ou pour aplatir des données.

Opérations de Convolution 2D

L'opération de convolution 2D est le cœur des réseaux neuronaux convolutifs. Elle consiste à faire glisser un petit filtre (ou noyau) sur l'image d'entrée, effectuant une multiplication élément par élément et une sommation pour produire une seule valeur dans la carte de caractéristiques de sortie.

Les paramètres clés de cette opération incluent :

  • Entrée (input) : Un tenseur de forme (N, C_in, H_in, W_in), où N est la taille du lot (batch size), C_in le nombre de canaux d'entrée (par exemple, 1 pour une image en niveaux de gris, 3 pour une image RGB), et H_in et W_in sont la hauteur et la largeur de l'entrée.
  • Noyau (kernel ou weight) : Un tenseur de forme (C_out, C_in / groups, H_k, W_k). C_out est le nombre de canaux de sortie (nombre de noyaux), C_in le nombre de canaux d'entrée, et H_k et W_k sont la hauteur et la largeur du noyau.
  • Pas (stride) : La distance de déplacement du noyau à chaque étape (entier ou tuple (stride_h, stride_w)). La valeur par défaut est 1.
  • Remplissage (padding) : Ajout de zéros autour des bords de l'entrée pour contrôler la taille de la sortie (entier ou tuple (pad_h, pad_w)). La valeur par défaut est 0.

Voici un exemple de convolution 2D manuelle utilisant torch.nn.functional.conv2d :

import torch
import torch.nn.functional as F

# Définition d'une image d'entrée simple (5x5)
input_image_2d = torch.tensor([
    [1., 2., 0., 3., 1.],
    [0., 1., 2., 3., 1.],
    [1., 2., 1., 0., 0.],
    [5., 2., 3., 1., 1.],
    [2., 1., 0., 1., 1.]
])

# Définition d'un noyau de convolution (3x3)
convolution_kernel_2d = torch.tensor([
    [1., 2., 1.],
    [0., 1., 0.],
    [2., 1., 0.]
])

print(f"Forme de l'image d'entrée avant redimensionnement: {input_image_2d.shape}")
print(f"Forme du noyau avant redimensionnement: {convolution_kernel_2d.shape}")

# Redimensionnement des tenseurs pour correspondre au format (N, C, H, W)
# Pour une seule image en niveaux de gris (1 canal)
input_tensor = input_image_2d.reshape(1, 1, 5, 5)
kernel_tensor = convolution_kernel_2d.reshape(1, 1, 3, 3) # 1 filtre, 1 canal d'entrée

print(f"Forme de l'image d'entrée après redimensionnement: {input_tensor.shape}")
print(f"Forme du noyau après redimensionnement: {kernel_tensor.shape}")

# Application de la convolution 2D avec un pas de 1 et sans remplissage
output_feature_map = F.conv2d(input_tensor, kernel_tensor, stride=1, padding=0)
print("\nCarte de caractéristiques résultante:")
print(output_feature_map)

# Exemple avec padding=1
output_with_padding = F.conv2d(input_tensor, kernel_tensor, stride=1, padding=1)
print("\nCarte de caractéristiques avec padding=1:")
print(output_with_padding)

L'exemple montre comment les tenseurs doivent être formatés et comment les paramètres stride et padding influencent le résultat. Une valeur de remplissage (padding) de 1 ajoute une bordure de zéros tout autour de l'image, permettant de conserver la taille spatiale de l'entrée si le noyau est de taille 3x3 et le pas est de 1.

La Couche de Convolution 2D (nn.Conv2d)

En pratique, plutôt que d'utiliser directement torch.nn.functional.conv2d, nous employons la couche nn.Conv2d. Cette couche gère automatiquement l'initialisation des poids du noyau et des biais, et s'intègre parfaitement dans les modèles héritant de nn.Module.

Les paramètres clés de nn.Conv2d sont :

  • in_channels : Le nombre de canaux d'entrée.
  • out_channels : Le nombre de canaux de sortie (correspond au nombre de noyaux utilisés).
  • kernel_size : La taille du noyau de convolution (entier pour un noyau carré, ou tuple (height, width)).
  • stride, padding, padding_mode, dilation, groups, bias : Similaires aux paramètres de l'opération de convolution fonctionnelle.

Considérons un exemple de réseau utilisant nn.Conv2d pour traiter des images CIFAR-10 :

import torch
import torch.nn as nn
import torchvision
from torch.utils.tensorboard import SummaryWriter

# Configuration des jeux de données et chargeurs
transform = torchvision.transforms.ToTensor()
cifar10_test = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
test_dataloader = torch.utils.data.DataLoader(cifar10_test, batch_size=64, shuffle=False, num_workers=0)

# Définition du modèle simple avec une couche de convolution
class MyConvolutionalModel(nn.Module):
    def __init__(self):
        super().__init__()
        # Couche Conv2d: 3 canaux d'entrée (RGB), 16 canaux de sortie, noyau 5x5, pas de 1, pas de remplissage
        self.conv_layer = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=5, stride=1, padding=0)

    def forward(self, input_data):
        return self.conv_layer(input_data)

# Instanciation du modèle
model_instance = MyConvolutionalModel()
print("Structure du modèle:")
print(model_instance)

# Initialisation de TensorBoard
writer = SummaryWriter("runs/cifar10_conv_test")

step_count = 0
for images, _ in test_dataloader:
    # Propagation avant
    output_features = model_instance(images)

    print(f"Forme de l'entrée (batch, channels, H, W): {images.shape}")
    print(f"Forme de la sortie (batch, new_channels, H_out, W_out): {output_features.shape}")

    # Ajout des images originales à TensorBoard
    writer.add_images("Input Images", images, step_count)

    # La sortie a 16 canaux. Pour visualiser avec add_images (qui attend 3 canaux),
    # nous devons remodeler. Nous pouvons regrouper des canaux pour créer des "images"
    # avec 3 canaux, augmentant ainsi la taille du lot virtuel si nécessaire.
    # Par exemple, si nous avons un batch de 64 et 16 canaux, nous pouvons le remodeler
    # en (64 * 16 // 3, 3, H_out, W_out) pour que chaque "image" ait 3 canaux.
    # Ici, nous allons simplement prendre les 3 premiers canaux pour la visualisation,
    # ou remodeler pour que chaque 'image' ait 3 canaux et ajuster le batch size.
    # Une approche simple pour l'exemple: remodeler pour que le nombre total
    # d'éléments du batch et des canaux soit préservé, et le nombre de canaux soit 3.
    # Si out_channels est 16, et H_out, W_out sont les dimensions spatiales.
    # Nous pouvons transformer (N, 16, H, W) en (N * (16//3), 3, H, W)
    # ou juste montrer quelques canaux ou un sous-ensemble du batch.
    
    # Pour simplifier, nous allons remodeler la sortie de (N, 16, H, W) à (N*X, 3, H', W')
    # où X est le nombre de 'groupes' de 3 canaux que nous pouvons créer.
    # Puisque 16 n'est pas un multiple de 3, nous ne pouvons pas le faire parfaitement.
    # Une solution commune est de prendre les N premières images et leurs 3 premiers canaux
    # ou de remodeler toutes les images en un format compatible, par exemple si out_channels est 6
    # nous pourrions faire (batch_size * 2, 3, H_out, W_out).
    # Ici, nous supposons que pour la visualisation, nous voulons juste N images avec 3 canaux.
    # Nous pouvons prendre une tranche ou remodeler de manière plus complexe.
    
    # Pour cet exemple, prenons simplement les 3 premiers canaux de chaque image dans le batch
    # pour la visualisation, pour démontrer le concept.
    # Ceci est une simplification pour add_images. Dans un scénario réel, il faudrait
    # peut-être combiner les canaux ou visualiser chaque canal séparément.
    
    # Redimensionnement simplifié pour visualisation: prendre un sous-ensemble des canaux de sortie
    # et adapter le batch size si nécessaire pour obtenir 3 canaux par image.
    
    # Si out_channels est 16, et l'original est (64, 16, 28, 28)
    # Pour add_images, on a besoin de (N', 3, H', W').
    # Une option est de prendre les premiers 3 canaux pour chaque image du batch
    visualizable_output = output_features[:, :3, :, :] 
    writer.add_images("Output Features (first 3 channels)", visualizable_output, step_count)
    
    # Ou, comme dans l'exemple original, remodeler pour regrouper des canaux dans le batch:
    # (64, 16, 28, 28) -> (-1, 3, 28, 28)
    # Note: 64 * 16 / 3 n'est pas entier, donc cette opération serait incorrecte.
    # L'approche originale était probablement avec un out_channels de 6, qui est divisible par 3.
    # Si out_channels=6, alors output_features.shape est (64, 6, 30, 30) (pour kernel=3, stride=1, padding=0)
    # outputs_reshaped = output_features.reshape(-1, 3, 30, 30) # C'est 64 * 6 / 3 = 128 images de 3 canaux
    # writer.add_images("Output Features (reshaped)", outputs_reshaped, step_count)

    step_count += 1
    if step_count > 5: # Limite le nombre d'itérations pour l'exemple
        break

writer.close()

La forme des données d'entrée et de sortie après une convolution est cruciale. La hauteur et la largeur de la carte de caractéristiques de sortie peuvent être calculées par : H_out = floor((H_in + 2 * padding[0] - dilation[0] * (kernel_size[0] - 1) - 1) / stride[0] + 1)``W_out = floor((W_in + 2 * padding[1] - dilation[1] * (kernel_size[1] - 1) - 1) / stride[1] + 1)Un cas particulier est lorsque padding=1, stride=1, et kernel_size=3 : la taille spatiale (H, W) de l'entrée reste inchangée.

Pour les données multi-canaux (par exemple, une image RGB avec 3 canaux), le nombre de canaux d'entrée du noyau doit correspondre au nombre de canaux de l'entrée. Le nombre de canaux de sortie du noyau détermine le nombre de cartes de caractéristiques de sortie. Si un seul noyau est utilisé avec out_channels=1, les caractéristiques des in_channels sont combinées pour produire une seule carte de caractéristiques. Si out_channels est supérieur à 1, plusieurs noyaux indépendants opèrent, chacun produisant une carte de caractéristiques de sortie, qui sont ensuite empilées pour former une sortie multi-canaux.

Les convolutions 1x1 sont souvent utilisées pour transformer le nombre de canaux d'une carte de caractéristiques sans modifier ses dimentions spatiales. Elles agissent comme des couches linéaires sur chaque pixel indépendamment à travers les canaux.

Convolutions face aux Perceptrons Multicouches (MLP)

Lorsque l'on traite des données d'image de grande taille (par exemple, des mégapixels), l'utilisation de perceptrons multicouches (MLP) pose des problèmes majeurs. Chaque pixel d'une image doit être connecté à chaque neurone de la première couche cachée. Pour une image de 36 millions de pixels et seulement 100 neurones dans une seule couche cachée, le nombre de paramètres serait gigantesque, entraînant une consommation de mémoire excessive et des temps d'entraînement prohibitifs.

Les réseaux neuronaux convolutifs (CNN) abordent ces défis en exploitant deux biais inductifs fondamentaux spécifiques aux images :

  1. Équivariance à la translation : La détection d'une caractéristique (par exemple, un œil) doit fonctionner indépendamment de sa position dans l'image. Les noyaux de convolution glissent sur l'image, appliquant les mêmes poids pour détecter la même caractéristique à différents endroits.
  2. Localité des caractéristiques : Les caractéristiques d'un objet sont généralement mieux comprises en examinant une région locale de l'image, plutôt que l'image entière. Les noyaux de convolution ont une taille limitée, se concentrant sur des régions locales de l'entrée.

Grâce à ces principes, les CNNs réduisent drastiquement le nombre de paramètres nécessaires. Seuls les poids des noyaux de convolution sont appris, et leur taille est indépendante de la taille de l'image d'entrée. Cela rend les CNNs beaucoup plus efficaces pour l'aanlyse d'images par rapport aux MLPs.

Étiquettes: PyTorch Convolutional Neural Networks nn.Module nn.Conv2d deep learning

Publié le 3 octobre à 05h21