Recherche de site Web

Écrire ResNet à partir de zéro dans PyTorch


Dans cet article, nous allons construire ResNet, une avancée majeure dans la vision par ordinateur, qui a résolu le problème de la dégradation des performances du réseau si le réseau est trop profond. Il a également introduit le concept de connexions résiduelles (nous y reviendrons plus tard).

Nous commencerons par examiner l'architecture et l'intuition derrière le fonctionnement de ResNet. Nous le comparerons ensuite à VGG et examinerons comment il résout certains des problèmes rencontrés par VGG. Ensuite, comme auparavant, nous chargerons notre ensemble de données, CIFAR10 et le prétraiterons pour le préparer à la modélisation. Ensuite, nous allons d’abord implémenter l’élément de base d’un ResNet (nous appellerons cela ResidualBlock) et l’utiliser pour construire notre réseau. Ensuite, ce réseau sera entraîné sur les données prétraitées et enfin, nous verrons comment le modèle entraîné se comporte sur des données invisibles (ensemble de test).

Conditions préalables

Afin de suivre cet article, vous aurez besoin d'une expérience de base avec le code Python et d'une compréhension débutante du Deep Learning. Nous partirons du principe que tous les lecteurs ont accès à des machines suffisamment puissantes pour pouvoir exécuter le code fourni. Des GPU moins puissants peuvent également être utilisés, mais les résultats peuvent prendre plus de temps à être obtenus.

Si vous n'avez pas accès à un GPU, nous vous suggérons d'y accéder via le cloud. De nombreux fournisseurs de cloud proposent des GPU. Les droplets GPU DigitalOcean sont actuellement en disponibilité anticipée, apprenez-en plus et inscrivez-vous pour vous intéresser aux droplets GPU ici

Pour obtenir des instructions sur la façon de démarrer avec le code Python, nous vous recommandons d'essayer ce guide du débutant pour configurer votre système et vous préparer à exécuter des didacticiels pour débutants.

ResNet

L’un des inconvénients de VGG était qu’il ne pouvait pas aller aussi loin que souhaité car il commençait à perdre sa capacité de généralisation (c’est-à-dire qu’il commençait à surajuster). En effet, à mesure qu'un réseau neuronal s'approfondit, les gradients de la fonction de perte commencent à diminuer jusqu'à zéro et les poids ne sont donc pas mis à jour. Ce problème est connu sous le nom de problème du gradient de disparition. ResNet a essentiellement résolu ce problème en utilisant des connexions sautées.

Un blocage résiduel. Source : article ResNet

Dans la figure ci-dessus, nous pouvons voir qu'en plus des connexions normales, il existe une connexion directe qui saute certaines couches du modèle (skip connection). Avec la connexion sautée, la sortie passe de h(x)=f(wx +b) à h(x)=f(x) + x. Ces connexions sautées aider car ils permettent un raccourci alternatif pour les dégradés. Vous trouverez ci-dessous l'architecture du ResNet à 34 couches.

Source : article ResNet

Chargement des données : l'ensemble de données

Dans cet article, nous utiliserons le célèbre ensemble de données CIFAR-10, qui est devenu l'un des choix les plus courants pour les ensembles de données de vision par ordinateur pour débutants. L'ensemble de données est un sous-ensemble étiqueté de l'ensemble de données de 80 millions de petites images. Ils ont été rassemblés par Alex Krizhevsky, Vinod Nair et Geoffrey Hinton. L'ensemble de données CIFAR-10 se compose de 60 000 images couleur 32 x 32 réparties en 10 classes, avec 6 000 images par classe. Il existe 50 000 images de formation et 10 000 images de test.

L'ensemble de données est divisé en cinq lots de formation et un lot de test, chacun contenant 10 000 images. Le lot de test contient exactement 1 000 images sélectionnées au hasard dans chaque classe. Les lots de formation contiennent les images restantes dans un ordre aléatoire, mais certains lots de formation peuvent contenir plus d'images d'une classe que d'une autre. Au total, les lots de formation contiennent exactement 5 000 images de chaque classe. Les cours s'excluent complètement mutuellement. Il n'y a pas de chevauchement entre les automobiles et les camions. « Automobile » comprend les berlines, les SUV et les choses de ce genre. « Camion » comprend uniquement les gros camions. Ni l’un ni l’autre n’inclut les camionnettes.

Voici les classes de l'ensemble de données, ainsi que 10 images aléatoires de chacune :

Source : papier

Importer les bibliothèques

Nous commencerons par importer les bibliothèques que nous utiliserions. En plus de cela, nous veillerons à ce que le Notebook utilise le GPU pour entraîner le modèle s'il 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
Device configuration
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

Importer les bibliothèques

Chargement de l'ensemble de données

Nous passons maintenant au chargement de notre ensemble de données. À cette fin, nous utiliserons la bibliothèque torchvision qui fournit non seulement un accès rapide à des centaines d'ensembles de données de vision par ordinateur, mais également des méthodes simples et intuitives pour les prétraiter/transformer afin qu'ils soient prêts à être modélisés.

  • Nous commençons par définir notre fonction data_loader qui renvoie les données d'entraînement ou de test en fonction des arguments

  • C'est toujours une bonne pratique de normaliser nos données dans les projets de Deep Learning, car cela rend la formation plus rapide et plus facile à converger. Pour cela, nous définissons la variable normalize avec la moyenne et les écarts types de chacun des canaux (rouge, vert et bleu) de l'ensemble de données. Ceux-ci peuvent être calculés manuellement, mais sont également disponibles en ligne. Ceci est utilisé dans la variable transform où nous redimensionnons les données, les convertissons en tenseurs puis les normalisons

  • Nous utilisons des chargeurs de données. Les chargeurs de données nous permettent de parcourir les données par lots, et les données sont chargées lors de l'itération et non d'un seul coup au démarrage dans notre RAM. Ceci est très utile si nous traitons de grands ensembles de données contenant environ un million d’images.

  • En fonction de l'argument test, nous chargeons soit le fractionnement du train (si test=False), soit le test (si test=True ) divisé. Dans le cas d'un train, la répartition est divisée de manière aléatoire en train et ensemble de validation (0,9 : 0,1).

    def data_loader(data_dir,
                  batch_size,
                  random_seed=42,
                  valid_size=0.1,
                  shuffle=True,
                  test=False):
    
      normalize = transforms.Normalize(
          mean=[0.4914, 0.4822, 0.4465],
          std=[0.2023, 0.1994, 0.2010],
      )
    
      # define transforms
      transform = transforms.Compose([
              transforms.Resize((224,224)),
              transforms.ToTensor(),
              normalize,
      ])
    
      if test:
          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
    
      # load the dataset
      train_dataset = datasets.CIFAR10(
          root=data_dir, train=True,
          download=True, transform=transform,
      )
    
      valid_dataset = datasets.CIFAR10(
          root=data_dir, train=True,
          download=True, transform=transform,
      )
    
      num_train = len(train_dataset)
      indices = list(range(num_train))
      split = int(np.floor(valid_size * num_train))
    
      if shuffle:
          np.random.seed(42)
          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)
    train_loader, valid_loader = data_loader(data_dir='./data',
                                           batch_size=64)
    
    test_loader = data_loader(data_dir='./data',
                                batch_size=64,
                                test=True)
    

ResNet from Scratch : comment fonctionnent les modèles dans PyTorch

Avant de passer à la construction du bloc résiduel et du ResNet, nous examinerons et comprendrons d'abord comment les réseaux de neurones sont définis dans PyTorch :

  • nn.Module fournit un passe-partout pour créer des modèles personnalisés ainsi que certaines fonctionnalités nécessaires qui facilitent la formation. C'est pourquoi chaque modèle personnalisé a tendance à hériter de nn.Module
  • Ensuite, il existe deux fonctions principales dans chaque modèle personnalisé. La première est la fonction d'initialisation, __init__, où nous définissons les différentes couches que nous utiliserons, et la deuxième est la fonction forward, qui définit l'ordre dans lequel les couches ci-dessus seront être exécuté sur une entrée donnée

Calques dans PyTorch

Venons-en maintenant aux différents types de couches disponibles dans PyTorch qui nous sont utiles :

  • nn.Conv2d : ce sont les couches convolutives qui acceptent le nombre de canaux d'entrée et de sortie comme arguments, ainsi que la taille du noyau pour le filtre. Il accepte également toutes les foulées ou rembourrages si nous voulons les appliquer
  • nn.BatchNorm2d : ceci applique la normalisation par lots à la sortie de la couche convolutive
  • nn.ReLU : il s'agit d'un type de fonction d'activation appliquée à diverses sorties du réseau.
  • nn.MaxPool2d : ceci applique le pooling maximum à la sortie avec la taille du noyau donnée
  • nn.Dropout : ceci est utilisé pour appliquer un abandon à la sortie avec une probabilité donnée
  • nn.Linear : Il s'agit essentiellement d'une couche entièrement connectée
  • nn.Sequential : ce n'est techniquement pas un type de couche mais cela aide à combiner différentes opérations qui font partie de la même étape

Blocage résiduel

Avant de commencer avec le réseau, nous devons construire un ResidualBlock que nous pouvons réutiliser sur l'ensemble du réseau. Le bloc (comme indiqué dans l'architecture) contient une connexion sautée qui est un paramètre facultatif ( downsample ). Notez que dans le forward , ceci est appliqué directement à l'entrée, x, et non à la sortie, out.

class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride = 1, downsample = None):
        super(ResidualBlock, self).__init__()
        self.conv1 = nn.Sequential(
                        nn.Conv2d(in_channels, out_channels, kernel_size = 3, stride = stride, padding = 1),
                        nn.BatchNorm2d(out_channels),
                        nn.ReLU())
        self.conv2 = nn.Sequential(
                        nn.Conv2d(out_channels, out_channels, kernel_size = 3, stride = 1, padding = 1),
                        nn.BatchNorm2d(out_channels))
        self.downsample = downsample
        self.relu = nn.ReLU()
        self.out_channels = out_channels

    def forward(self, x):
        residual = x
        out = self.conv1(x)
        out = self.conv2(out)
        if self.downsample:
            residual = self.downsample(x)
        out += residual
        out = self.relu(out)
        return out

Bloc Résiduel

ResNet34

Maintenant que nous avons créé le ResidualBlock, nous pouvons construire notre ResNet.

Notez qu'il y a trois blocs dans l'architecture, contenant respectivement 3, 3, 6 et 3 couches. Pour créer ce bloc, nous créons une fonction d'assistance _make_layer. La fonction ajoute les calques un par un avec le bloc résiduel. Après les blocs, nous ajoutons la mise en commun moyenne et la couche linéaire finale.

class ResNet(nn.Module):
    def __init__(self, block, layers, num_classes = 10):
        super(ResNet, self).__init__()
        self.inplanes = 64
        self.conv1 = nn.Sequential(
                        nn.Conv2d(3, 64, kernel_size = 7, stride = 2, padding = 3),
                        nn.BatchNorm2d(64),
                        nn.ReLU())
        self.maxpool = nn.MaxPool2d(kernel_size = 3, stride = 2, padding = 1)
        self.layer0 = self._make_layer(block, 64, layers[0], stride = 1)
        self.layer1 = self._make_layer(block, 128, layers[1], stride = 2)
        self.layer2 = self._make_layer(block, 256, layers[2], stride = 2)
        self.layer3 = self._make_layer(block, 512, layers[3], stride = 2)
        self.avgpool = nn.AvgPool2d(7, stride=1)
        self.fc = nn.Linear(512, num_classes)

    def _make_layer(self, block, planes, blocks, stride=1):
        downsample = None
        if stride != 1 or self.inplanes != planes:

            downsample = nn.Sequential(
                nn.Conv2d(self.inplanes, planes, kernel_size=1, stride=stride),
                nn.BatchNorm2d(planes),
            )
        layers = []
        layers.append(block(self.inplanes, planes, stride, downsample))
        self.inplanes = planes
        for i in range(1, blocks):
            layers.append(block(self.inplanes, planes))

        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.conv1(x)
        x = self.maxpool(x)
        x = self.layer0(x)
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)

        x = self.avgpool(x)
        x = x.view(x.size(0), -1)
        x = self.fc(x)

        return x

Définition des hyperparamètres

Il est toujours recommandé d'essayer différentes valeurs pour différents hyperparamètres de notre modèle, mais ici nous n'utiliserons qu'un seul paramètre. Quoi qu’il en soit, nous recommandons à chacun d’en essayer différents et de voir lequel fonctionne le mieux. Les hyper-paramètres incluent la définition du nombre d'époques, de la taille du lot, du taux d'apprentissage, de la fonction de perte ainsi que de l'optimiseur. Alors que nous construisons la variante à 34 couches de ResNet, nous devons également transmettre le nombre approprié de couches :

num_classes = 10
num_epochs = 20
batch_size = 16
learning_rate = 0.01

model = ResNet(ResidualBlock, [3, 4, 6, 3]).to(device)

#Loss and optimizer
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate, weight_decay = 0.001, momentum = 0.9)  

#Train the model
total_step = len(train_loader)

Entraînement

Maintenant, notre modèle est prêt pour la formation, mais nous devons d'abord savoir comment fonctionne la formation du modèle dans PyTorch :

  • Nous commençons par charger les images par lots à l'aide de notre train_loader pour chaque époque, et déplaçons également les données vers le GPU à l'aide de la variable device que nous avons définie précédemment.

  • Le modèle est ensuite utilisé pour prédire sur les étiquettes, model(images), puis on calcule la perte entre les prédictions et la vérité terrain en utilisant la fonction de perte définie ci-dessus, criterion(outputs, étiquettes)

  • Maintenant la partie apprentissage arrive, nous utilisons la méthode de perte pour rétropropager, loss.backward(), et mettons à jour les poids, optimizer.step(). Une chose importante qui est requise avant chaque mise à jour est de définir les dégradés à zéro en utilisant optimizer.zero_grad() car sinon les dégradés sont accumulés (comportement par défaut dans PyTorch)

  • Enfin, après chaque époque, nous testons notre modèle sur l'ensemble de validation, mais, comme nous n'avons pas besoin de dégradés lors de l'évaluation, nous pouvons le désactiver en utilisant with torch.no_grad() pour effectuer l'évaluation. beaucoup plus rapide.

    import gc
    total_step = len(train_loader)
    
    for epoch in range(num_epochs):
        for i, (images, labels) in enumerate(train_loader):  
          #Move tensors to the configured device
          images = images.to(device)
          labels = labels.to(device)
    
          #Forward pass
          outputs = model(images)
          loss = criterion(outputs, labels)
    
          #Backward and optimize
          optimizer.zero_grad()
          loss.backward()
          optimizer.step()
          del images, labels, outputs
          torch.cuda.empty_cache()
          gc.collect()
    
      print ('Epoch [{}/{}], Loss: {:.4f}' 
                     .format(epoch+1, num_epochs, 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)) 
    

En analysant le résultat du code, nous pouvons voir que le modèle apprend à mesure que la perte diminue tandis que la précision de l'ensemble de validation augmente à chaque époque. Mais nous pouvons remarquer qu'il fluctue à la fin, ce qui pourrait signifier que le modèle est surajusté ou que le batch_size est petit. Il va falloir tester pour savoir ce qui se passe :

Essai

Pour les tests, nous utilisons exactement le même code que pour la validation mais avec le test_loader :

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))   

En utilisant le code ci-dessus et en entraînant le modèle pendant 10 époques, nous avons pu obtenir une précision de 82,87 % sur l'ensemble de test :

Précision des tests

Conclusion

Concluons maintenant ce que nous avons fait dans cet article :

  • Nous avons commencé par comprendre l'architecture et le fonctionnement de ResNet
  • Ensuite, nous avons chargé et prétraité l'ensemble de données CIFAR10 à l'aide de torchvision
  • Ensuite, nous avons appris comment fonctionnent les définitions de modèles personnalisés dans PyTorch et les différents types de couches disponibles dans torch.
  • Nous avons construit notre ResNet à partir de zéro en créant un ResidualBlock
  • Enfin, nous avons formé et testé notre modèle sur l'ensemble de données CIFAR10, et le modèle a semblé bien fonctionner sur l'ensemble de données de test avec une précision de 75 %.

Travaux futurs

Grâce à cet article, nous avons obtenu une bonne introduction et un apprentissage pratique, mais nous pouvons en apprendre beaucoup plus si nous étendons cela à d'autres défis :

  • Essayez d'utiliser différents ensembles de données. L'un de ces ensembles de données est CIFAR100, un sous-ensemble de l'ensemble de données ImageNet, ou l'ensemble de données de 80 millions de petites images.
  • Expérimentez avec différents hyperparamètres et voyez la meilleure combinaison d'entre eux pour le modèle
  • Enfin, essayez d'ajouter ou de supprimer des couches de l'ensemble de données pour voir leur impact sur les capacités du modèle. Mieux encore, essayez de construire la version ResNet-51 de ce modèle

Articles connexes: