Scrivere VGG a Mano in PyTorch

Continuando la mia serie su come costruire reti neurali convoluzionali classiche che hanno rivoluzionato il campo della visione computazionale negli ultimi 1-2 decenni, prossimamente costruiremo VGG, una rete convoluzionale molto profonda, da zero utilizzando PyTorch. Puoi vedere gli articoli precedenti della serie nel mio profilo, principalmente LeNet5 e AlexNet.

Come prima, esaminerò l’architettura e l’intuito dietro VGG e come i risultati erano all’epoca. Poi esploreremo il nostro dataset, CIFAR100, e lo caricherò nel nostro programma utilizzando del codice efficiente in termini di memoria. Poi, implementerò VGG16 (il numero si riferisce al numero di layer, ci sono due versioni fondamentalmente VGG16 e VGG19) da zero utilizzando PyTorch e lo trainerò sul nostro dataset insieme all’valutarlo sul nostro set di test per vedere come si comporta su dati non visti


VGG

Sulla base del lavoro di AlexNet, VGG punta ad un’altra aspettiva cruciale delle reti neurali convoluzionali (CNN), la profondità. È stato sviluppato da Simonyan e Zisserman. Normalmente consiste in 16 layer convoluzionali, ma può essere esteso a 19 layer (e quindi ci sono due versioni, VGG-16 e VGG-19). Tutti i layer convoluzionali sono composti da filtri 3×3. Puoi leggere ulteriori informazioni sulla rete nel paper ufficiale qui

VGG16 architecture. Fonte


Caricamento Dati

Set Dati

Prima di costruire il modello, una delle cose più importanti in qualsiasi progetto di apprendimento automatico è caricare, analizzare e pre-processare il dataset. In questo articolo, userò il dataset CIFAR-100. Questo dataset è simile al CIFAR-10, ma differisce perché include 100 classi, ciascuna contenente 600 immagini. Ci sono 500 immagini di addestramento e 100 immagini di test per classe. Le 100 classi del CIFAR-100 sono raggruppate in 20 superclassi. Ogni immagine è accompagnata da una “label” “fine” (la classe a cui appartiene) e una “label” “grossa” (la superclasse a cui appartiene). Utilizzeremo la “label” “fine” qui. Ecco la lista delle classi nel CIFAR-100:


Lista delle classi per il dataset CIFAR-100

Importo le librerie

Procederemo principalmente con torch (utilizzato per la costruzione del modello e l’addestramento), torchvision (per il caricamento/processamento dei dati, contiene dataset e metodi per il processamento di quei dataset in computer vision) e numpy (per la manipolazione matematica). Definiremo anche una variabile device in modo da permettere al programma di utilizzare la GPU 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 dei dati

torchvision è una libreria che fornisce un accesso facile a molti dataset di computer vision e metodi per pre-processare questi dataset in un modo facile e intuitivo

  • Definiamo una funzione data_loader che restituisce dati di allenamento/validazione o dati di test a seconda degli argomenti
  • Iniziamo definendo la variabile normalize con la media e le deviazioni standard di ciascuno dei canali (rosso, verde e blu) nel dataset. Questi possono essere calcolati manualmente, ma sono anche disponibili online. Questo viene usato nella variabile transform dove reimpostiamo i dati, li convertiamo in tensori e poi li normalizziamo
  • Se l’argomento test è vero, carichiamo semplicemente la parte di test del dataset e la restituiamo usando i data loaders (spiegati qui sotto)
  • Se l’test è falso (è anche il comportamento predefinito), carichiamo la parte di allenamento del dataset e lo dividiamo casualmente in allenamento e validazione (0.9:0.1)
  • Infine, usiamo i data loaders. Questo potrebbe non influenzare il rendimento in caso di dataset piccoli come CIFAR100, ma può davvero limitare il rendimento in caso di grandi dataset e viene considerata una buona pratica. I data loaders ci consentono di iterare sui dati in batch, e i dati sono caricati durante l’iterazione e non tutti all’inizio nell’RAM.
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],
    )

    # definizioni di trasformazioni
    transform = transforms.Compose([
            transforms.Resize((227,227)),
            transforms.ToTensor(),
            normalize,
    ])

    if test:
        dataset = datasets.CIFAR100(
          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

    # carico il dataset
    train_dataset = datasets.CIFAR100(
        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(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)


# dataset CIFAR100
train_loader, valid_loader = data_loader(data_dir='./data',
                                         batch_size=64)

test_loader = data_loader(data_dir='./data',
                              batch_size=64,
                              test=True)

VGG16 da zero

Per costruire il modello da zero, prima bisogna capire come funzionano le definizioni di modelli in torch e i diversi tipi di layer che utilizzeremo qui:

  • Ogni modello personalizzato deve eredire dalla classe nn.Module poiché fornisce alcune funzionalità di base che aiutano il modello nell’addestramento.
  • In secondo luogo, ci sono due cose principali da fare. Prima, definire i diversi layer del nostro modello all’interno della funzione __init__ e la sequenza in cui questi layer saranno eseguiti sull’input all’interno della funzione forward

Ora definiamo i diversi tipi di layer che utilizziamo qui:

  • nn.Conv2d: Queste sono le layer convoluzionali che accettano come argomenti il numero di canali di input e di output, insieme alla dimensione del kernel per il filtro. Accettano anche eventuali stride o padding se si desidera applicare questi.
  • nn.BatchNorm2d: Questo applica la normalizzazione in batch all’output della layer convoluzionale.
  • nn.ReLU: Questa è l’attivazione applicata agli output vari del network.
  • nn.MaxPool2d : Questo applica il pooling massimo all’output con la dimensione del kernel data
  • nn.Dropout: Questo viene utilizzato per applicare il dropout all’output con una probabilità data
  • nn.Linear: Questo in sostanza è un layer completamente connesso
  • nn.Sequential: Questo non è letteralmente un tipo di layer, ma aiuta a combinare diverse operazioni che fanno parte dello stesso passo

Utilizzando questo knowledge, ora possiamo costruire il nostro modello VGG16 usando l’architettura del paper:

class VGG16(nn.Module):
    def __init__(self, num_classes=10):
        super(VGG16, self).__init__()
        self.layer1 = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU())
        self.layer2 = nn.Sequential(
            nn.Conv2d(64, 64, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(), 
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer3 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU())
        self.layer4 = nn.Sequential(
            nn.Conv2d(128, 128, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer5 = nn.Sequential(
            nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU())
        self.layer6 = nn.Sequential(
            nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU())
        self.layer7 = nn.Sequential(
            nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer8 = nn.Sequential(
            nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer9 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer10 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.layer11 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer12 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU())
        self.layer13 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(512),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size = 2, stride = 2))
        self.fc = nn.Sequential(
            nn.Dropout(0.5),
            nn.Linear(7*7*512, 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 = self.layer6(out)
        out = self.layer7(out)
        out = self.layer8(out)
        out = self.layer9(out)
        out = self.layer10(out)
        out = self.layer11(out)
        out = self.layer12(out)
        out = self.layer13(out)
        out = out.reshape(out.size(0), -1)
        out = self.fc(out)
        out = self.fc1(out)
        out = self.fc2(out)
        return out

VGG16 da Scratch


Hyperparameters

Un importante aspetto di qualsiasi progetto di machine learning o deep learning è l’ottimizzazione degli hyper-parameters. Qui, non sperimenteremo valori diversi per questi, ma dovremo definirli prima. Questi includono la definizione del numero di epoch, del batch size, dell’learning rate, della funzione di perdita insieme all’ottimizzatore

num_classes = 100
num_epochs = 20
batch_size = 16
learning_rate = 0.005

model = VGG16(num_classes).to(device)


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


# Train the model
total_step = len(train_loader)

Impostazione degli hyper-parameters


Training

Ora siamo pronti a addestrare il nostro modello. Prima guarderemo come addestriamo il modello in torch e poi guarderemo il codice:

  • Per ogni epoch, passiamo attraverso le immagini e le etichette all’interno del nostro train_loader e spostiamo queste immagini e etichette sul GPU se disponibile. Questo avviene automaticamente
  • Usiamo il nostro modello per predire sulle etichette (model(images)) e poi calcoliamo la perdita tra le predizioni e le etichette vere usando la nostra funzione di perdita (criterion(outputs, labels))
  • Poi usiamo quella perdita per il backpropagation (loss.backward) e aggiorniamo i pesi (optimizer.step()). Ma ricordate di impostare i gradienti a zero prima di ogni aggiornamento. Questo viene fatto usando optimizer.zero_grad()
  • Anche alla fine di ogni epoch, usiamo il nostro set di validazione per calcolare l’accuratezza del modello. In questo caso, non abbiamo bisogno di gradienti, quindi usiamo with torch.no_grad() per una valutazione più veloce

Adesso, combiniamo tutto questo nel seguente codice:

total_step = len(train_loader)

for epoch in range(num_epochs):
    for i, (images, labels) in enumerate(train_loader):  
        # Sposta tensori sulla configurata periferica
        images = images.to(device)
        labels = labels.to(device)
        
        # Passo in avanti
        outputs = model(images)
        loss = criterion(outputs, labels)
        
        # Inverso e ottimizza
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    print ('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}' 
                   .format(epoch+1, num_epochs, i+1, total_step, loss.item()))
            
    # Validazione
    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)) 

Addestramento

Potete vedere l’output del codice sopra come segue, che dimostra che il modello stia imparando effettivamente, poiché la perdita si sta riducendo con ogni epoch:


Perdite di addestramento


Testing

Per le prove, usiamo esattamente lo stesso codice come validazione, ma con il 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))   

Testing

Utilizzando il codice precedente e addestrando il modello per 20 epoch, abbiamo ottenuto un’accuratezza del 75% sul set di test.


Conclusione

Ora concludiamo ciò che abbiamo fatto in questo articolo:

  • Abbiamo iniziato capendo l’architettura e i diversi tipi di layer nella modello VGG-16
  • Successivamente, abbiamo caricato e pre-processato il dataset CIFAR100 usando torchvision
  • Poi, abbiamo usato PyTorch per costruire il nostro modello VGG-16 da zero, incluso capire i diversi tipi di layer disponibili in torch
  • Infine, abbiamo addestrato e testato il nostro modello sul dataset CIFAR100, e il modello sembrava funzionare bene sul set di test con un 75% di accuratezza

Lavoro futuro

Utilizzando questo articolo, ottieni una buona introduzione e apprendimento pratico, ma imparerai molto di più se estendi questo e vedi cosa puoi fare altrimenti:

  • Puoi provare a utilizzare diversi dataset. Uno di questi è CIFAR10 o un sottoinsieme del dataset ImageNet.
  • Puoi sperimentare con diversi hyperparametri e vedere la migliore combinazione di essi per il modello
  • Infine, puoi provare ad aggiungere o rimuovere layer dal dataset per vedere il loro impatto sulla capacità del modello. Meglio ancora, prova a costruire la versione VGG-19 di questo modello.

Source:
https://www.digitalocean.com/community/tutorials/vgg-from-scratch-pytorch