Scrivere AlexNet da zero in PyTorch

Introduzione

Questo articolo è la prosecuzione della serie dedicata alla costruzione da zero delle più popolari reti neurali convoluzionali in PyTorch. Puoi trovare l’articolo precedente qui, in cui abbiamo costruito LeNet5. In questo articolo, costruiremo AlexNet, uno dei più importanti risultati chiave nell’algoritmo di computer vision.

Inizieremo investigando e comprendendo l’architettura di AlexNet. Poi, ci immergeremo nel codice caricando il nostro dataset, CIFAR-10, prima di applicare alcuni pre-processamenti ai dati. Poi, costruiremo l’AlexNet da zero usando PyTorch e lo trainiamo sui nostri dati pre-processati. Infine, il modello addestrato sarà testato su dati non visti (test) a scopo di valutazione.

Prerequisiti

La conoscenza delle reti neurali sarà utile per capire questo articolo. Questo comprendererebbe essere familiare con le diverse layer delle reti neurali (layer di input, layer nascosti, layer di output), funzioni di attivazione, algoritmi di ottimizzazione (varianti del discesa del gradiente), funzioni di perdita, ecc. Inoltre, la familiarità con la sintassi di Python e la libreria PyTorch è essenziale per capire i snippet di codice presenti in questo articolo.

Un’ comprensione delle CNN è essenziale. Questo include il possesso di conoscenze riguardo le layer convoluzionali, le layer di pooling e il loro ruolo nell’estrazione di caratteristiche dai dati di input. La comprensione di concetti come la stride, il padding e l’impatto della dimensione del kernel/filtro è anche utile.

AlexNet

AlexNet è una rete neurale convoluzionale profonda, inizialmente sviluppata da Alex Krizhevsky e dai suoi colleghi nel 2012. Fu progettato per classificare immagini per il concorso ImageNet LSVRC-2010, dove ottenne risultati all’avanguardia. Puoi leggere in dettaglio sul modello nel documento di ricerca originale qui.

Ora passiamo ai principali risultati del documento sull’AlexNet. Prima di tutto, AlexNet operava con immagini a 3 canali di dimensione (224x224x3). Utilizzava il pooling massimo insieme alle attivazioni ReLU durante la sottomaglia. I kernel utilizzati per la convoluzione erano di dimensioni 11×11, 5×5 o 3×3, mentre i kernel usati per il pooling massimo erano di dimensione 3×3. Classificava immagini in 1000 classi. Anche questo utilizzava multipli GPU.

Set di dati

Inizieremo caricando e pre-processando i dati. A questo scopo, utilizzeremo il dataset CIFAR-10. Il dataset consta di 60000 immagini colorate 32×32 in 10 classi, con 6000 immagini per classe. Ci sono 50000 immagini di addestramento e 10000 immagini di test.

Ecco le classi nel dataset, nonché 10 immagini di esempio casuali da ciascuna:

Fonte: source

Le classi sono completamente esclusive tra loro. Non c’è sovrapposizione tra automobili e camion. “Automobile” include berline, SUV e cose di questo genere. “Camion” include solo camion grandi. Nessuno include i pick-up.

Importo delle Librerie

Inizieremo importando le librerie necessarie insieme a definizione di una variabile device, in modo che il notebook sappia utilizzare una GPU per addestrare il modello se disponibile.

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

# Configurazione del dispositivo
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

Caricamento del Dataset

Utilizzando torchvision (una libreria di supporto per compiti di computer visione), caricheremo il nostro set di dati. Questo metodo ha alcune funzioni di supporto che rendono la pre-elaborazione piuttosto facile e diretta. Definiamo le funzioni get_train_valid_loader e get_test_loader, e poi le chiamiamo per caricare e processare i nostri dati di 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],
    )

    # definiamo trasformazioni
    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,
        ])

    # caricamento del dataset
    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],
    )

    # definiamo trasformazione
    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

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

Prendiamo in considerazione il codice:

  • Definiamo due funzioni get_train_valid_loader e get_test_loader per caricare rispettivamente i set di addestramento/validazione e di test
  • Iniziamo definendo la variabile normalize con la media e la deviazione standard di ciascuno dei canali (rosso, verde e blu) del dataset. Queste sono calcolabili manualmente, ma sono anche disponibili online poiché CIFAR-10 è piuttosto popolare
  • Per il nostro set di addestramento, aggiungiamo l’opzione di aggiungere l’aggiornamento del dataset per un addestramento più robusto e per incrementare il numero di immagini. Nota: l’aggiornamento è applicato solo al sottoinsieme di addestramento e non ai sottoinsiemi di validazione e di test poiché sono utilizzati solo a scopo di valutazione
  • Dividiamo il set di addestramento in addestramento e validazione (rapporto 90:10) e lo sottoutilizziamo casualmente dal set di addestramento completo
  • Specifichiamo la dimensione del batch e mescoliamo i dati durante il caricamento, così ogni batch avrà un certo grado di variazione nei tipi di etichette. Questo incrementerà l’efficacia del nostro modello finale.
  • Infine, usiamo i data loader. Questo potrebbe non influire sulla performance in caso di dataset piccoli come CIFAR-10, ma può veramente limitare la performance in caso di grandi dataset e viene generalmente considerata una buona pratica. I data loader ci consentono di iterare attraverso i dati in batch, e i dati vengono caricati durante l’iterazione e non tutti insieme all’inizio nel tuo RAM

AlexNet da Scratch

Cominciamo prima con il codice:

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

Definizione del Modello AlexNet

Ora spiegheremo come funziona il codice sopra:

  • Il primo passo per definire qualsiasi rete neurale (sia una CNN che un’altra) in PyTorch è definire una classe che eredita da nn.Module poiché contiene molti metodi che utilizzeremo
  • Ci sono due passaggi principali da allora. Il primo è l’inizializzazione delle layer che utilizzeremo nella nostra CNN all’interno di __init__, e l’altro è la definizione della sequenza in cui queste layer processeranno l’immagine. Questo è definito all’interno della funzione forward.
  • Per l’architettura stessa, prima definiamo le layer convoluzionali usando la funzione nn.Conv2D con la dimensione di kernel appropriata e i canali di input/output. Appliciamo anche il pooling massimo usando la funzione nn.MaxPool2D. Una cosa molto interessante su PyTorch è che possiamo combinare la layer convoluzionale, la funzione di attivazione e il pooling massimo in una singola layer (verranno applicati separatamente, ma aiuta con l’organizzazione) usando la funzione nn.Sequential
  • Poi definiamo le layer completamente connesse usando la funzione lineare (nn.Linear) e il dropout (nn.Dropout) insieme alla funzione di attivazione ReLu (nn.ReLU) e combiniamo questi con la funzione nn.Sequential
  • Infine, la nostra ultima layer produce 10 neuroni che sono le nostre predizioni finali per le 10 classi di oggetti

Impostazione degli Hyperparametri

Prima dell’addestramento, abbiamo bisogno di impostare alcuni hyperparametri, come la funzione di perdita e l’ottimizzatore da usare insieme alla dimensione del batch, alla velocità di apprendimento e al numero di epoch.

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

model = AlexNet(num_classes).to(device)

# Perdita e ottimizzatore
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate, weight_decay = 0.005, momentum = 0.9)  

# Addestra il modello
total_step = len(train_loader)

Iniziamo definendo semplici hyperparametri (epoch, dimensione del batch e tasso di apprendimento) e inizializzando il nostro modello utilizzando il numero di classi come argomento, che in questo caso è 10, insieme al trasferimento del modello sulla dispositiva corretta (CPU o GPU). Poi definiamo la nostra funzione di costo come perdita di cross-entropia e l’ottimizzatore come Adam. Ci sono molte scelte per questi, ma questi tendono a dare buoni risultati con il modello e i dati forniti. Infine, definiamo total_step per tenere un meglio controllo degli step durante l’addestramento

Addestramento

Siamo pronti ad addestrare il nostro modello a questo punto:

total_step = len(train_loader)

for epoch in range(num_epochs):
    for i, (images, labels) in enumerate(train_loader):  
        # Sposta tensori sulla dispositiva configurata
        images = images.to(device)
        labels = labels.to(device)

        # Passo in avanti
        outputs = model(images)
        loss = criterion(outputs, labels)

        # Passo indietro e ottimizzare
        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)) 

Vediamo cosa fa il codice:

  • Iniziamo iterando attraverso il numero di epoch, e poi i batch nel nostro dataset di addestramento
  • Convertiamo le immagini e le etichette in base alla dispositiva in uso, cioè GPU o CPU
  • Nel passo in avanti, facciamo predizioni utilizzando il nostro modello e calcoliamo la perdita in base alle predizioni e alle nostre etichette reali
  • Successivamente, facciamo il passo indietro in cui aggiorniamo realmente i nostri pesi per migliorare il nostro modello
  • Poi impostiamo i gradienti a zero prima di ogni aggiornamento utilizzando la funzione optimizer.zero_grad()
  • Poi, calcoliamo i nuovi gradienti utilizzando la funzione loss.backward().
  • E infine, aggiorniamo i pesi con la funzione optimizer.step()
  • Anche alla fine di ogni epoca, usiamo il nostro set di validazione per calcolare l’accuratezza del modello. In questo caso, non serve utilizzare gli errori di grado, quindi usiamo with torch.no_grad() per una valutazione più veloce

Possiamo vedere l’output come segue:

Perdita di addestramento e accuratezza di validazione

Come vediamo, la perdita si sta diminuendo con ogni epoca, dimostrando che il nostro modello sta imparando davvero. Notare che questa perdita è sul set di addestramento, e se la perdita è troppo piccola, potrebbe indicare l’overfitting. Ecco perché usiamo anche il set di validazione. L’accuratezza sembra stia crescendo sul set di validazione, indicando che è improbabile che ci sia alcun overfitting. Ora proviamo il nostro modello per vedere come funziona.

Testing

Adesso, vediamo come il nostro modello si comporta su dati non visti prima:

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

Noterete che il codice è esattamente lo stesso di quanto richiesto per le nostre finalità di validazione.

Utilizzando il modello, e addestrandolo solo per 6 epoch, ci sembra di ottenere circa l’81,2% di accuratezza sul set di validazione.

Precisione di testing

Conclusione

Ora concludiamo ciò che abbiamo fatto in questo articolo:

  • Abbiamo iniziato capendo l’architettura e i diversi tipi di layer del modello AlexNet
  • Successivamente, abbiamo caricato e pre-processato il dataset CIFAR-10 utilizzando torchvision
  • Poi, abbiamo usato PyTorch per costruire il nostro modello AlexNet da zero
  • Infine, abbiamo addestrato e testato il nostro modello sul dataset CIFAR-10, e il modello sembrava funzionare bene sul dataset di test con un minimo di addestramento (6 epochs)

Lavoro futuro

Questo articolo fornisce un’introduzione solida e un’esperienza pratica, ma otterrete ancora più conoscenze approfondendo ulteriormente e scoprendo cosa altro potete realizzare.

  • Potete provare a usare dataset differenti. Uno di questi è CIFAR-100, che è un’estensione del dataset CIFAR-10 con 100 classi
  • Potete sperimentare con differenti hyperparameter e vedere la migliore combinazione di loro per il modello
  • Infine, potete provare ad aggiungere o rimuovere layer dal dataset per vedere il loro impatto sulla capacità del modello.

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