Architectures classiques des réseaux convolutifs profonds avec PyTorch

La victoire d'AlexNet lors d'ILSVRC 2012 a marqué un tournant décisif pour la vision par ordinateur. Jusqu'alors dominées par des descripteurs artisanaux couplés à des classiques de machine learning, la reconnaissance d'images a basculé vers une approche entièrement supervisée et end-to-end. Les trois avancées clés d'AlexNet résident dans l'usage systématique de l'activation ReLU, de la régularisation par Dropout et de l'entraînement sur GPU. ReLU remplace avantageusement la sigmoïde en limitant le phénomène de disparition du gradient, tandis que le Dropout contrôle le surapprentissage sur les couches entièrement connectées.

Comparé à LeNet, AlexNet présente une architecture bien plus profonde : cinq couches convolutives suivies de trois couches denses, plus de 60 millions de paramètres, et un prétraitement intensif par augmentation de données. Le passage au traitement d'images RGB de grande résolution nécessite des filtres plus larges en entrée et des opérations de sous-échantillonnage plus agressives.

import torch
from torch import nn
from d2l import torch as d2l


class AlexNetFashionMNIST(nn.Module):
    def __init__(self, nb_classes=10):
        super().__init__()
        self.extracteur = nn.Sequential(
            nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(96, 256, kernel_size=5, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(256, 384, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(384, 384, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        self.classifieur = nn.Sequential(
            nn.Flatten(),
            nn.Linear(256 * 5 * 5, 4096),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(4096, 4096),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(4096, nb_classes),
        )

    def forward(self, x):
        return self.classifieur(self.extracteur(x))


modele = AlexNetFashionMNIST()
echantillon = torch.randn(1, 1, 224, 224)
print(modele(echantillon).shape)

batch_size = 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
lr, epochs = 0.01, 10
d2l.train_ch6(modele, train_iter, test_iter, epochs, lr, d2l.try_gpu())

  1. VGG : l'approche par blocs empilés

VGG repose sur une idée simple mais puissante : remplacer un grand noyau convolutif par une succession de petits noyaux 3×3. Trois convolutions 3×3 empilées possèdent le même champ réceptif qu'une convolution 7×7, tout en réduisant le nombre de paramètres et en augmentant la non-linéarité intermédiaire. L'architecture se construit de manière modulaire : chaque bloc VGG est composé de plusieurs convolutions 3×3 avec remplissage unitaire, chacune suivie d'une activation ReLU, et se termine par un max-pooling 2×2 de pas 2.

Une convention commune consiste à doubler le nombre de canaux après chaque bloc, compensant ainsi la division par deux des dimensions spatiales. Cette régularité facilite l'extension du réseau et la migration vers des tâches de transfert.

def bloc_vgg(canaux_entree, canaux_sortie, nb_convs):
    couches = []
    canaux = canaux_entree
    for _ in range(nb_convs):
        couches.extend([
            nn.Conv2d(canaux, canaux_sortie, kernel_size=3, padding=1),
            nn.ReLU(),
        ])
        canaux = canaux_sortie
    couches.append(nn.MaxPool2d(kernel_size=2, stride=2))
    return nn.Sequential(*couches)


def vgg(arch, canaux_in=1, nb_classes=10):
    blocs = []
    canaux = canaux_in
    for nb_conv, canaux_out in arch:
        blocs.append(bloc_vgg(canaux, canaux_out, nb_conv))
        canaux = canaux_out
    blocs.extend([
        nn.Flatten(),
        nn.Linear(canaux * 7 * 7, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5),
        nn.Linear(4096, nb_classes),
    ])
    return nn.Sequential(*blocs)


architecture = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512))
ratio = 4
architecture_reduite = [(n, c // ratio) for n, c in architecture]

net = vgg(architecture_reduite)

  1. Network in Network et les convolutions 1×1

NiN propose de remplacer les couches entièrement connectées classiques par des convolutions 1×1, qui agissent comme un perceptron multi-couches à chaque pixel, sans altérer la structure spatiale. Après extraction locale par un noyau spatial, deux convolutions 1×1 successives permettent de mélanger et de transformer les canaux. La sortie finale utilise un pooling moyen global qui réduit chaque carte d'activation à une valeur unique par classe, éliminant presque entièrement les paramètres de classification.

def bloc_nin(canaux_in, canaux_out, taille, pas, remplissage):
    return nn.Sequential(
        nn.Conv2d(canaux_in, canaux_out, taille, pas, remplissage),
        nn.ReLU(),
        nn.Conv2d(canaux_out, canaux_out, kernel_size=1),
        nn.ReLU(),
        nn.Conv2d(canaux_out, canaux_out, kernel_size=1),
        nn.ReLU(),
    )


nin = nn.Sequential(
    bloc_nin(1, 96, 11, 4, 0),
    nn.MaxPool2d(3, 2),
    bloc_nin(96, 256, 5, 1, 2),
    nn.MaxPool2d(3, 2),
    bloc_nin(256, 384, 3, 1, 1),
    nn.MaxPool2d(3, 2),
    nn.Dropout(0.5),
    bloc_nin(384, 10, 3, 1, 1),
    nn.AdaptiveAvgPool2d((1, 1)),
    nn.Flatten(),
)

  1. GoogLeNet et le bloc Inception

Le bloc Inception répond à l'incertitude sur la taille optimale des noyaux en parallélisant plusieurs chemins. Quatre branches fonctionnent simultanément : une convolution 1×1, une cascade 1×1 puis 3×3, une cascade 1×1 puis 5×5, et enfin un max-pooling 3×3 suivi d'une convolution 1×1. Les convolutions 1×1 initiales réduisent le nombre de canaux avant les opérations spatiales, limitant la charge computationnelle. Les cartes résultantes sont concaténées selon la dimension des canaux.

from torch.nn import functional as F


class BlocInception(nn.Module):
    def __init__(self, canaux_in, c1, c2, c3, c4):
        super().__init__()
        self.branche_1x1 = nn.Conv2d(canaux_in, c1, kernel_size=1)

        self.branche_3x3 = nn.Sequential(
            nn.Conv2d(canaux_in, c2[0], kernel_size=1), nn.ReLU(),
            nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1), nn.ReLU(),
        )

        self.branche_5x5 = nn.Sequential(
            nn.Conv2d(canaux_in, c3[0], kernel_size=1), nn.ReLU(),
            nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2), nn.ReLU(),
        )

        self.branche_pool = nn.Sequential(
            nn.MaxPool2d(kernel_size=3, stride=1, padding=1),
            nn.Conv2d(canaux_in, c4, kernel_size=1), nn.ReLU(),
        )

    def forward(self, x):
        b1 = F.relu(self.branche_1x1(x))
        b2 = self.branche_3x3(x)
        b3 = self.branche_5x5(x)
        b4 = self.branche_pool(x)
        return torch.cat((b1, b2, b3, b4), dim=1)

  1. Normalisation par lots

La normalisation par lots stabilise la distribution des activations au sein d'un mini-lot. Elle centre et réduit chaque canal ou dimension caractéristique, puis applique une mise à l'échelle et un biais apprenables. En mode évaluation, on utilise des statistiques mobiles accumulées pendant l'entraînement. Cette technique accélère la convergence et permet d'utiliser des taux d'apprentissage plus élevés.

class BatchNormPersonnalise(nn.Module):
    def __init__(self, features, dims):
        super().__init__()
        forme = (1, features) if dims == 2 else (1, features, 1, 1)
        self.gamma = nn.Parameter(torch.ones(forme))
        self.beta = nn.Parameter(torch.zeros(forme))
        self.moyenne_mobile = torch.zeros(forme)
        self.variance_mobile = torch.ones(forme)

    def forward(self, x):
        if self.moyenne_mobile.device != x.device:
            self.moyenne_mobile = self.moyenne_mobile.to(x.device)
            self.variance_mobile = self.variance_mobile.to(x.device)

        if not torch.is_grad_enabled():
            x_norm = (x - self.moyenne_mobile) / torch.sqrt(self.variance_mobile + 1e-5)
        else:
            if len(x.shape) == 2:
                moy = x.mean(dim=0)
                var = ((x - moy) ** 2).mean(dim=0)
            else:
                moy = x.mean(dim=(0, 2, 3), keepdim=True)
                var = ((x - moy) ** 2).mean(dim=(0, 2, 3), keepdim=True)
            x_norm = (x - moy) / torch.sqrt(var + 1e-5)
            self.moyenne_mobile = 0.9 * self.moyenne_mobile + 0.1 * moy
            self.variance_mobile = 0.9 * self.variance_mobile + 0.1 * var

        return self.gamma * x_norm + self.beta

  1. ResNet et les connexions résiduelles

ResNet s'attaque au problème de dégradation des réseaux très profonds en introduisant une connexion identique autour d'un groupe de couches. Le bloc apprend une résiduelle F(x) = H(x) − x au lieu de l'entier H(x), ce qui facilite grandement l'apprentissage. Lorsque les dimensions changent, une convolution 1×1 sur le raccourci permet d'aligner les formes avant l'addition.

class BlocResiduel(nn.Module):
    def __init__(self, in_ch, out_ch, utiliser_1x1=False, pas=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=pas, padding=1)
        self.bn1 = nn.BatchNorm2d(out_ch)
        self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1)
        self.bn2 = nn.BatchNorm2d(out_ch)

        self.raccourci = None
        if utiliser_1x1:
            self.raccourci = nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=pas)

    def forward(self, x):
        y = F.relu(self.bn1(self.conv1(x)))
        y = self.bn2(self.conv2(y))

        if self.raccourci:
            x = self.raccourci(x)

        return F.relu(y + x)

  1. DenseNet et les connexions denses

DenseNet pousse le partage d'informations encore plus loin que ResNet : chaque couche reçoit en entrée la concaténation de toutes les sorties précédentes. Cette stratégie améliore le flux du gradient et encourage la réutilisation des caractéristiques. Pour contenir la croissance du nombre de canaux, chaque couche produit un petit nombre de cartes, appelé taux de croissance, et des couches de transition réduisent périodiquement les dimensions.

class BlocDense(nn.Module):
    def __init__(self, nb_couches, canaux_in, taux):
        super().__init__()
        self.convolutions = nn.ModuleList()
        for i in range(nb_couves):
            self.convolutions.append(nn.Sequential(
                nn.BatchNorm2d(canaux_in + i * taux),
                nn.ReLU(),
                nn.Conv2d(canaux_in + i * taux, taux, kernel_size=3, padding=1),
            ))

    def forward(self, x):
        for couche in self.convolutions:
            y = couche(x)
            x = torch.cat((x, y), dim=1)
        return x


def couche_transition(canaux_in, canaux_out):
    return nn.Sequential(
        nn.BatchNorm2d(canaux_in), nn.ReLU(),
        nn.Conv2d(canaux_in, canaux_out, kernel_size=1),
        nn.AvgPool2d(kernel_size=2, stride=2),
    )

La construction complète d'un DenseNet alterne blocs denses et couches de transition, avant un pooling global moyen et une couche linéaire finale. Cette famille d'architectures illustre comment un partage maximal des représentations intermédiaires peut améliorer l'efficacité paramétrique des CNNs profonds.

Étiquettes: PyTorch AlexNet VGG Network-in-Network GoogLeNet

Publié le 23 juin à 22h57