Écrire AlexNet à la main en PyTorch

Introduction

Cet article fait suite à une série sur la construction des plus populaires réseaux de neurones convolutionnels de base en PyTorch. Vous pouvez voir le précédent article ici, où nous avons construit LeNet5. Dans cet article, nous construirons AlexNet, l’une des avancées les plus cruciales des algorithmes de vision par ordinateur.

Nous commencerons en examinant et en comprenant l’architecture d’AlexNet. Ensuite, nous plongerons directement dans le code en chargeant notre jeu de données, CIFAR-10, avant d’appliquer un certain nombre de traitements préalables aux données. Ensuite, nous construirons notre propre AlexNet à la main en utilisant PyTorch et le trainons sur nos données prétraitées. Enfin, le modèle entraîné sera testé sur des données non vues (test) à des fins d’évaluation.

Prérequis

Un certain niveau de connaissance des réseaux de neurones sera utile pour comprendre cet article. Cela engloberait être familier avec les différentes couches de réseaux de neurones (couche d’entrée, couches cachées, couche de sortie), les fonctions d’activation, les algorithmes d’optimisation (variantes de descente de gradient), les fonctions de perte, etc. De plus, la familiarité avec la syntaxe de Python et la bibliothèque PyTorch est essentielle pour comprendre les extraits de code présentés dans cet article.

Une compréhension des CNN (Convolutional Neural Networks) est essentielle. Cela inclut des connaissances sur les couches convolutives, les couches de pooling et leur rôle dans l’extraction de caractéristiques des données d’entrée. Comprendre des concepts tels que la stride, le padding et l’impact de la taille du noyau/filtre est également bénéfique.

AlexNet

AlexNet est une profonde toile de convolution neuronale, initialement développée par Alex Krizhevsky et ses collègues en 2012. Elle a été conçue pour classer des images pour le concours ImageNet LSVRC-2010 où elle a atteint des résultats de pointe. Vous pouvez lire en détail sur le modèle dans le document de recherche original ici.

Allons-y sur les principaux points saillants du papier AlexNet. Premièrement, AlexNet fonctionnait avec des images à 3 canaux de taille (224x224x3). Elle utilisait le pooling maximum ainsi que les activations ReLU lors du sous-échantillonnage. Les noyaux utilisés pour les convolutions étaient de taille 11×11, 5×5 ou 3×3, tandis que les noyaux utilisés pour le pooling maximum étaient de taille 3×3. Elle classait les images dans 1000 classes. Elle utilisait également plusieurs GPU.

Jeu de données

Commençons par charger et pré-traiter les données. Pour notre besoin, nous utiliserons le jeu de données CIFAR-10. Ce jeu de données se compose de 60000 images couleur de 32×32 dans 10 classes, avec 6000 images par classe. Il y a 50000 images d’entraînement et 10000 images de test.

Voici les classes dans le jeu de données, ainsi que 10 images aléatoires de chaque :

Source : source

Les classes sont complètement exclusives l’une de l’autre. Il n’y a pas d’overlap entre automobiles et camions. « Automobile » inclut les berlines, les SUV et les choses du genre. « Camion » inclut uniquement les gros camions. ni l’une ni l’autre ne comprend les pick-ups.

Importation des bibliothèques

Commençons par importer les bibliothèques nécessaires en définissant une variable device, pour que le notebook sache utiliser une GPU pour entraîner le modèle si elle est disponible.

import numpy as np
import torch
import torch.nn as nn
from torchvision import datasets
from torchvision import transforms
from torch.utils.data.sampler import SubsetRandomSampler

# Configuration de l'appareil
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

Chargement du Jeu de Données

En utilisant torchvision (une bibliothèque d’aide pour les tâches de vision par ordinateur), nous chargerons notre jeu de données. Cette méthode possède quelques fonctions d’aide qui rendent la pré-traitement assez facile et directe. Permettez-nous de définir les fonctions get_train_valid_loader et get_test_loader, puis appelez-les pour charger et traiter notre jeu de données CIFAR-10 :

def get_train_valid_loader(data_dir,
                           batch_size,
                           augment,
                           random_seed,
                           valid_size=0.1,
                           shuffle=True):
    normalize = transforms.Normalize(
        mean=[0.4914, 0.4822, 0.4465],
        std=[0.2023, 0.1994, 0.2010],
    )

    # Définir les transformations
    valid_transform = transforms.Compose([
            transforms.Resize((227,227)),
            transforms.ToTensor(),
            normalize,
    ])
    if augment:
        train_transform = transforms.Compose([
            transforms.RandomCrop(32, padding=4),
            transforms.RandomHorizontalFlip(),
            transforms.ToTensor(),
            normalize,
        ])
    else:
        train_transform = transforms.Compose([
            transforms.Resize((227,227)),
            transforms.ToTensor(),
            normalize,
        ])

    # Charger le jeu de données
    train_dataset = datasets.CIFAR10(
        root=data_dir, train=True,
        download=True, transform=train_transform,
    )

    valid_dataset = datasets.CIFAR10(
        root=data_dir, train=True,
        download=True, transform=valid_transform,
    )

    num_train = len(train_dataset)
    indices = list(range(num_train))
    split = int(np.floor(valid_size * num_train))

    if shuffle:
        np.random.seed(random_seed)
        np.random.shuffle(indices)

    train_idx, valid_idx = indices[split:], indices[:split]
    train_sampler = SubsetRandomSampler(train_idx)
    valid_sampler = SubsetRandomSampler(valid_idx)

    train_loader = torch.utils.data.DataLoader(
        train_dataset, batch_size=batch_size, sampler=train_sampler)

    valid_loader = torch.utils.data.DataLoader(
        valid_dataset, batch_size=batch_size, sampler=valid_sampler)

    return (train_loader, valid_loader)


def get_test_loader(data_dir,
                    batch_size,
                    shuffle=True):
    normalize = transforms.Normalize(
        mean=[0.485, 0.456, 0.406],
        std=[0.229, 0.224, 0.225],
    )

    # Définir la transformation
    transform = transforms.Compose([
        transforms.Resize((227,227)),
        transforms.ToTensor(),
        normalize,
    ])

    dataset = datasets.CIFAR10(
        root=data_dir, train=False,
        download=True, transform=transform,
    )

    data_loader = torch.utils.data.DataLoader(
        dataset, batch_size=batch_size, shuffle=shuffle
    )

    return data_loader

# Jeu de données CIFAR10 
train_loader, valid_loader = get_train_valid_loader(data_dir = './data',                                      batch_size = 64,
                       augment = False,                                          random_seed = 1)

test_loader = get_test_loader(data_dir = './data',
                              batch_size = 64)

Permettez-nous de décomposer le code :

  • Nous définissons deux fonctions get_train_valid_loader et get_test_loader pour charger respectivement les jeux de données d’entraînement/validation et de test
  • Nous commençons par définir la variable normalize avec la moyenne et la variance de chaque canal (rouge, vert et bleu) du jeu de données. Ces valeurs peuvent être calculées manuellement, mais sont également disponibles en ligne car le CIFAR-10 est relativement populaire
  • Pour notre jeu de données d’entraînement, nous ajoutons la possibilité d’augmenter le jeu de données pour une formation plus robuste et une augmentation du nombre d’images également. Remarque : l’augmentation n’est appliquée qu’au sous-ensemble d’entraînement et non aux sous-ensembles de validation et de test car ils sont uniquement utilisés à des fins d’évaluation
  • Nous divisons le jeu de données d’entraînement en sous-ensembles d’entraînement et de validation (ratio 90:10) et le sous-échantillonons aléatoirement à partir du jeu d’entraînement complet
  • Nous spécifions le taille du lot et mélangons le jeu de données lors du chargement afin que chaque lot ait une certaine variance dans les types d’étiquettes qu’il contient. Cela augmentera l’efficacité de notre modèle résultant.
  • Enfin, nous utilisons des chargeurs de données. Cela peut ne pas affecter la performance dans le cas d’un petit jeu de données comme CIFAR-10, mais il peut véritablement entraver la performance dans le cas de grands jeux de données et est généralement considérée comme une bonne pratique. Les chargeurs de données nous permettent d’itérer sur les données en lots, et les données sont chargées lors de l’itération et non pas tout à la fois au démarrage dans votre RAM

AlexNet à la base

Commençons par le code :

class AlexNet(nn.Module):
    def __init__(self, num_classes=10):
        super(AlexNet, self).__init__()
        self.layer1 = nn.Sequential(
            nn.Conv2d(3, 96, kernel_size=11, stride=4, padding=0),
            nn.BatchNorm2d(96),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 3, stride = 2))
        self.layer2 = nn.Sequential(
            nn.Conv2d(96, 256, kernel_size=5, stride=1, padding=2),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 3, stride = 2))
        self.layer3 = nn.Sequential(
            nn.Conv2d(256, 384, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(384),
            nn.ReLU())
        self.layer4 = nn.Sequential(
            nn.Conv2d(384, 384, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(384),
            nn.ReLU())
        self.layer5 = nn.Sequential(
            nn.Conv2d(384, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 3, stride = 2))
        self.fc = nn.Sequential(
            nn.Dropout(0.5),
            nn.Linear(9216, 4096),
            nn.ReLU())
        self.fc1 = nn.Sequential(
            nn.Dropout(0.5),
            nn.Linear(4096, 4096),
            nn.ReLU())
        self.fc2= nn.Sequential(
            nn.Linear(4096, num_classes))

    def forward(self, x):
        out = self.layer1(x)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = self.layer5(out)
        out = out.reshape(out.size(0), -1)
        out = self.fc(out)
        out = self.fc1(out)
        out = self.fc2(out)
        return out

Définition du modèle AlexNet

Allons-y dans la manière dont le code ci-dessus fonctionne :

  • Le premier pas pour définir n’importe quelle toile d’araignée (que ce soit un CNN ou non) dans PyTorch est de définir une classe héritant de nn.Module car elle contient la plupart des méthodes que nous aurons besoin d’utiliser
  • Il y a deux étapes principales après cela. La première est d’initialiser les couches que nous allons utiliser dans notre CNN à l’intérieur de __init__, et l’autre est de définir la séquence dans laquelle ces couches traiteront l’image. Cela est défini à l’intérieur de la fonction forward.
  • Pour l’architecture elle-même, nous définissons d’abord les couches convolutives en utilisant la fonction nn.Conv2D avec la taille de kernel appropriée et les canaux d’entrée/sortie. Nous appliquons également le max pooling en utilisant la fonction nn.MaxPool2D. Ce qui est sympa avec PyTorch est que nous pouvons combiner la couche convolutive, la fonction d’activation et le max pooling en une seule couche (elles seront appliquées séparément, mais cela aide à l’organisation) en utilisant la fonction nn.Sequential
  • Ensuite, nous définissons les couches entièrement connectées en utilisant des liaisons linéaires (nn.Linear), le dropout (nn.Dropout) ainsi que la fonction d’activation ReLu (nn.ReLU) et en combinant ces éléments avec la fonction nn.Sequential
  • Enfin, notre dernière couche produit 10 neurones qui sont nos prédictions finales pour les 10 classes d’objets

Paramétrage des hyperparamètres

Avant l’entraînement, nous devons définir certains hyperparamètres, tels que la fonction de perte et l’optimisateur à utiliser ainsi que la taille du lot, le taux d’apprentissage et le nombre d’epochs.

num_classes = 10
num_epochs = 20
batch_size = 64
learning_rate = 0.005

model = AlexNet(num_classes).to(device)

# Fonction de perte et optimisateur
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate, weight_decay = 0.005, momentum = 0.9)  

# Entraînez le modèle
total_step = len(train_loader)

Nous commençons par définir des hyperparamètres simples (epochs, taille de lot et taux d’apprentissage) et initialisons notre modèle en utilisant le nombre de classes comme argument, qui est 10 dans ce cas, en plus de transférer le modèle sur l’appareil approprié (CPU ou GPU). Ensuite, nous définissons notre fonction de coût comme étant la perte de cross-entropie et notre optimiseur comme étant Adam. Il existe de nombreuses autres options pour ces valeurs, mais celles-ci tendent généralement à donner de bons résultats avec le modèle et les données données. Enfin, nous définissons total_step pour mieux suivre les étapes de l’entraînement

Entraînement

Notre modèle est prêt à être entraîné à ce stade :

total_step = len(train_loader)

for epoch in range(num_epochs):
    for i, (images, labels) in enumerate(train_loader):  
        # Déplacer les tenseurs sur l'appareil configuré
        images = images.to(device)
        labels = labels.to(device)

        # Passe avant
        outputs = model(images)
        loss = criterion(outputs, labels)

        # Arrière-pass et optimisation
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    print ('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}' 
                   .format(epoch+1, num_epochs, i+1, total_step, loss.item()))

    # Validation
    with torch.no_grad():
        correct = 0
        total = 0
        for images, labels in valid_loader:
            images = images.to(device)
            labels = labels.to(device)
            outputs = model(images)
            _, predicted = torch.max(outputs.data, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
            del images, labels, outputs

        print('Accuracy of the network on the {} validation images: {} %'.format(5000, 100 * correct / total)) 

Voyons ce que fait le code :

  • Nous commençons par itérer sur le nombre d’epochs, puis sur les lots dans nos données d’entraînement
  • Nous convertissons les images et les étiquettes en fonction de l’appareil que nous utilisons, c’est-à-dire le GPU ou la CPU
  • Dans le passe avant, nous faisons des prédictions en utilisant notre modèle et calculons la perte en fonction de ces prédictions et des étiquettes réelles
  • Ensuite, nous effectuons l’arrière-pass où nous mettons à jour réellement nos poids pour améliorer notre modèle
  • Nous définissons ensuite les gradients à zéro avant chaque mise à jour en utilisant la fonction optimizer.zero_grad()
  • Ensuite, nous calculons les nouveaux gradients en utilisant la fonction loss.backward()
  • Et enfin, nous mettons à jour les poids avec la fonction optimizer.step()
  • De plus, à la fin de chaque époque, nous utilisons notre jeu de vérification pour calculer l’exactitude du modèle également. Dans ce cas, nous n’avons pas besoin de gradient, donc nous utilisons with torch.no_grad() pour une évaluation plus rapide

Nous pouvons voir la sortie comme suit:

Perte d’entraînement et exactitude de la validation

Comme nous pouvons le voir, la perte est en diminution à chaque époque, ce qui montre que notre modèle apprend effectivement. Notez que cette perte est sur le jeu d’entraînement, et si la perte est trop petite, cela peut indiquer un sur-apprentissage. C’est pourquoi nous utilisons également le jeu de vérification. L’exactitude semble augmenter sur le jeu de vérification, ce qui indique qu’il est peu probable qu’il y ait de sur-apprentissage. Maintenant, essayons notre modèle pour voir comment il se comporte.

Test

Maintenant, nous voyons comment notre modèle se comporte sur des données non vues:

with torch.no_grad():
    correct = 0
    total = 0
    for images, labels in test_loader:
        images = images.to(device)
        labels = labels.to(device)
        outputs = model(images)
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()
        del images, labels, outputs

    print('Accuracy of the network on the {} test images: {} %'.format(10000, 100 * correct / total))   

Notez que le code est exactement le même que pour nos besoins de validation.

En utilisant le modèle et en entraînant pendant seulement 6 épisodes, nous semblons obtenir environ 78,8% d’exactitude sur le jeu de vérification.

Exactitude de test

Conclusion

Maintenant, concluons ce que nous avons fait dans cet article:

  • Nous avons commencé par comprendre l’architecture et les différents types de couches du modèle AlexNet
  • Ensuite, nous avons chargé et pré-traité le jeu de données CIFAR-10 en utilisant torchvision
  • Ensuite, nous avons utilisé PyTorch pour construire notre propre modèle AlexNet de zéro
  • Enfin, nous avons entraîné et testé notre modèle sur le jeu de données CIFAR-10, et le modèle semblait performer bien sur le jeu de test avec un minimum d’entraînement (6 épochs)

Travail futur

Cet article fournit une solide introduction et une expérience pratique, mais vous apprendrez encore davantage en explorant davantage et en découvrant ce que vous pouvez accomplir d’autre.

  • Vous pouvez essayer d’utiliser des jeux de données différents. Un de ces jeux de données est CIFAR-100, qui est une extension du jeu de données CIFAR-10 avec 100 classes
  • Vous pouvez expérimenter avec différents hyperparamètres et trouver la meilleure combinaison d’entre eux pour le modèle
  • Enfin, vous pouvez essayer d’ajouter ou de supprimer des couches du jeu de données pour voir leur impact sur la capacité du modèle.

Source:
https://www.digitalocean.com/community/tutorials/alexnet-pytorch