Alle prese con PyTorch - Parte 2: Reti neurali ed ottimizzatori


PyTorch è un framework di deep learning, sviluppato principalmente dal Facebook AI Research (FAIR) group, che negli ultimi mesi ha guadagnato una enorme popolarità fra gli sviluppatori grazie alla sua combinazione di semplicità ed efficienza. Questa serie di tutorial è dedicata ad esplorare la libreria, partendo dai concetti più semplici fino alla definizione di modelli estremamente sofisticati. In questa seconda parte, introduciamo alcuni elementi avanzati della libreria per costruire ed ottimizzare reti neurali e gestire dati in maniera più efficiente.

Questi tutorial sono anche disponibili (parzialmente) in lingua inglese: Fun With PyTorch.

Contenuto di questo tutorial

Nella prima parte abbiamo visto il meccanismo di differenziazione automatica di PyTorch, che è alla base del suo funzionamento. Oltre a questo, ovviamente, PyTorch mette a disposizione una serie di classi e funzioni per aiutare a definire modelli più complessi, ottimizzarli, e semplificare la gestione del dataset. In questo tutorial ne introduciamo la maggior parte, con l'obiettivo di costruire una rete neurale per classificare il buon vecchio dataset di Iris. Per semplicità, utilizzeremo la versione di scikit-learn del dataset, dividendola in due parti per training e test:

from sklearn import datasets, model_selection
data = datasets.load_iris()
Xtrain, Xtest, ytrain, ytest = \
    model_selection.train_test_split(data['data'], data['target']) 

Importiamo subito buona parte dei moduli di PyTorch che ci serviranno in seguito (commenteremo ciascuno nel dettaglio più avanti):

import torch
from torch import nn, optim
from torch.autograd import Variable

Inoltre, trasformiamo tutte le nostre matrici in tensori di PyTorch seguendo quanto visto nella prima parte, trasformando inoltre gli input in tensori a 32-bit:

Xtrain = torch.from_numpy(Xtrain).float()
Xtest = torch.from_numpy(Xtest).float()
ytrain = torch.from_numpy(ytrain)
ytest = torch.from_numpy(ytest)

Il resto del tutorial è diviso in cinque parti: (i) creazione di una rete neurale; (ii) ottimizzazione; (iii) gestione dei dati con il modulo data; (iv) ottimizzazione su GPU invece che su CPU; (v) checkpointing del modello.

Passo 1: Creare la rete neurale

Come molti framework di deep learning, PyTorch mette a disposizione una serie di costrutti di base con cui costruire reti neurali di vario genere, all'interno del modulo torch.nn, fra cui trasformazioni lineari, numerose funzioni di attivazione, e diverse classi per costruire reti convolutive e ricorrenti (che vedremo nei prossimi tutorial).

Per cominciare, vediamo ad esempio come creare un modello lineare e richiedere delle predizioni:

# Inizializzazione del modello
lin = nn.Linear(4, 3)

# Predizione
lin(Variable(Xtrain[0:1]))

Ricordate: dalla versione 0.4.0 i tensori possono agire come variabili, e possiamo semplificare l'ultima riga come lin(Xtrain[0:1])!

Niente di particolarmente complicato fin qui. Si noti come tutti i modelli di PyTorch sono direttamente invocabili come se fossero funzioni. Il modo più semplice per definire modelli non banali è estendere l'oggetto torch.nn.Module, che richiede di specificare (almeno) due funzioni al suo interno:

class CustomModel(nn.Module):

    def __init__(self):
        # Codice per l'inizializzazione

    def forward(self, x):
        # Codice per la forward pass

Nella prima funzione possiamo inizializzare tutti i blocchi che ci serviranno in seguito (inclusi tutti i componenti già pronti di PyTorch), mentre nella funzione forward possiamo implementare la logica di predizione del modello.

Vediamo un esempio più avanzato, una rete neurale con un singolo strato nascosto ed un'operazione di dropout fra lo strato nascosto e lo strato di uscita:

class CustomModel(nn.Module):

    def __init__(self):
        super(CustomModel, self).__init__()

        self.hidden = nn.Linear(4, 10)
        self.relu = nn.ReLU()
        self.drop = nn.Dropout(0.2)
        self.out = nn.Linear(10, 3)

    def forward(self, x):
        x = self.relu(self.hidden(x))
        return self.out(self.drop(x))

Se non avete mai lavorato con il dropout, non preoccupatevi! Si tratta di una tecnica di regolarizzazione piuttosto comune su modelli complessi, che rimuove casualmente alcuni elementi della rete neurale in fase di training per aumentarne la robustezza. Inserirla o meno in questo esempio non cambia quasi per nulla le performance del modello.

Facciamo qualche commento sul codice di prima:

  1. Una pratica molto comune è quella di inizializzare i vari componenti della rete associandoli a proprietà dell'oggetto stesso. A differenza di altre librerie di deep learning, in questa fase è già necessario dichiarare esplicitamente tutte le dimensioni, incluse quelle dell'input.
  2. Tutti gli oggetti che abbiamo dichiarato (es., nn.Linear) sono a loro volta estensioni di torch.nn.Module. Questo permette di annidare fra loro i componenti: potremmo usare il nostro CustomModule come singolo componente di una struttura ancora più complessa.
  3. Come già detto, tutti i moduli sono direttamente invocabili come se fossero funzioni. È invece sconsigliabile richiamare direttamente la funzione forward di ciascun modulo.

Possiamo creare ed utilizzare la nostra nuova rete neurale come nell'esempio di prima:

net = CustomModel()
net(Variable(Xtrain[0:1]))

Possiamo anche stampare un riepilogo dei vari componenti:

print(net)
# CustomModel(
# (hidden): Linear(in_features=4, out_features=10, bias=True)
# (relu): ReLU()
# (drop): Dropout(p=0.2)
# (out): Linear(in_features=10, out_features=3, bias=True)
# )

Si noti come i nomi di ciascun componente corrispondano ai nomi delle proprietà a cui sono stati associati.

Modelli e parametri del modello

Un aspetto essenziale della classe Module è tenere traccia automaticamente di tutte le variabili "allenabili" del modello. Tra le altre cose, la funzione parameters() ritorna un generatore che permette di estrarre tutti i tensori che rappresentano parametri del modello:

params = list(net.parameters())
len(params) 
# Prints: 4

In questo caso abbiamo quattro variabili: due matrici nel modulo hidden, ed altre due nel modulo out, mentre la funzione di attivazione ed il dropout non hanno nulla di adattabile. Per curiosità, possiamo ispezionare il bias dell'ultimo livello:

print(params[-1])
# Parameter containing:
# -0.2020 -0.0427 0.2549 [torch.FloatTensor of size 3]

Si noti come il tensore è incapsulato in un oggetto Parameter e non Variable. Parameter è semplicemente un tipo di variabile che identifica un tensore da adattare: questo perché alcuni modelli più avanzati (come le reti ricorrenti) potrebbero definire variabili temporanee che non vanno adattate. Distinguere esplicitamente tra le due classi permette a PyTorch di implementare questa logica.

Una cosa semplice che possiamo fare con il risultato è, ad esempio, calcolare quanti parametri adattabili ha il nostro modello:

print(sum([torch.numel(p) for p in params])) 
# Prints: 83

named_parameters() è simile a parameters(), ma permette di estrarre tutti i parametri ed il loro nome, come tuple di due elementi:

named_params = [p for p in net.named_parameters()]
print(named_params[-1])
# (
# 'out.bias', 
# Parameter containing:
# -0.2020 -0.0427 0.2549 [torch.FloatTensor of size 3]
# )

Alternative per la definizione dei modelli

Prima di proseguire, vediamo rapidamente alcune alternative da tenere a mente quando definiamo i nostri modelli. La prima è che, per funzioni semplici (come ad esempio la ReLU, che non possiede parametri da inizializzare o adattabili), PyTorch mette a disposizione un modulo funzionale che permette di richiamare queste funzioni direttamente senza inizializzazione. Ad esempio, possiamo definire una regressione logistica inserendo una softmax sul modello lineare definito prima:

import torch.nn.functional as F

def logreg(x):
  return F.softmax(lin(x), dim=1)

Non c'è in pratica nessuna differenza fra l'utilizzo di un Module o del suo equivalente in torch.nn.functional, e la scelta è tipicamente una questione di gusto personale o semplicità di implementazione.

Il secondo aspetto è che alcuni moduli (come ReLU) mettono a disposizione una versione in-place specificando un parametro aggiuntivo:

relu_inplace = nn.ReLU(inplace=True)

La versione in-place lavora senza creare dati temporanei, ma sovrascrivendo lo spazio di memoria delle variabili originarie. Anche se questo può generare guadagni di memoria, l'utilizzo di funzioni in-place è in realtà sconsigliato nella maggior parte dei casi in quanto interferisce con il meccanismo di back-propagation. In molti casi, il loro utilizzo potrebbe generare un messaggio di errore.

L'ultimo aspetto che menzioniamo è il modulo Sequential, che permette di definire modelli feedforward come quello di prima in maniera più immediata. Ad esempio, il nostro CustomModule è equivalente a questa definizione:

net_sequential = nn.Sequential(
        nn.Linear(4, 10),
        nn.ReLU(),
        nn.Dropout(0.5),
        nn.Linear(10, 3)
)

Inizializzazione del modello

Un altro aspetto interessante è l'inizializzazione dei parametri del modello, che influenza di molto le performance soprattutto di modelli più complessi. Usando moduli già esistenti come abbiamo fatto finora, ogni variabile viene inizializzata alla sua creazione. Ad esempio, ispezionando il codice del modulo Linear si può vedere che i parametri vengono inizializzati all'interno di una funzione reset_parameters(), che utilizza un metodo noto come 'uniform Glorot' (che dipende dal numero di input del modello).

PyTorch ha anche un insieme piuttosto variegato di funzioni (all'interno di un altro modulo dedicato) che possono essere usate per inizializzare le variabili. Ad esempio, supponiamo di voler inizializzare i parametri dello strato nascosto con una tecnica più semplice (una distribuzione normale per la trasformazione lineare, ed una costante per il bias). Possiamo farlo sovrascrivendo i parametri dopo la creazione del modello:

torch.nn.init.normal(net.hidden.weight.data)
torch.nn.init.constant(net.hidden.bias.data, 0.1)

Nella versione 0.4.0 è stato aggiunto un underscore ai nomi delle funzioni (in quanto operano in-place), ed il codice diventa:

torch.nn.init.normal_(net.hidden.weight)
torch.nn.init.constant_(net.hidden.bias, 0.1)

Per modelli particolarmente complessi, potremmo voler definire una procedura di inizializzazione da applicare a ciascuna classe in modo automatico. Per farlo, possiamo iterare sui moduli di cui è composto il modello definendo una logica condizionale:

for m in net.modules():
  if type(m) in ['Linear']:
    torch.nn.init.normal(m.weight.data)
    torch.nn.init.constant(m.bias.data, 0.1)

In questo esempio applichiamo l'inizializzazione scelta prima ad entrambi gli strati lineari, mentre lasciamo gli altri inalterati (anche perché, in questo caso, non hanno parametri adattabili).

Passo 2: Ottimizzare la rete neurale

Passiamo ora alla fase di ottimizzazione, che ricalca quasi completamente quanto fatto nella prima parte del tutorial. In questo caso, però, useremo due classi di PyTorch per calcolare la funzione costo ed ottimizzare il modello stesso:

net = CustomModel()
loss = nn.CrossEntropyLoss()
opt = torch.optim.Adam(params=net.parameters(), lr=0.01)

CrossEntropyLoss è una funzione costo indicata per la classificazione, che unisce un'operazione di softmax sulle uscite della rete neurale con il calcolo della cross-entropia. Adam è invece un algoritmo di ottimizzazione piuttosto comune per la sua velocità di convergenza. All'interno di nn ed optim trovate poi scelte alternative per entrambi i casi, già pronte per la maggior parte delle situazioni affrontabili in pratica. Si noti come in fase di inizializzazione dell'algoritmo di ottimizzazione possiamo specificare i parametri su cui eseguire l'ottimizzazione.

Il codice per l'ottimizzazione è quasi uguale alla prima parte:

Xt = Variable(Xtrain)
yt = Variable(ytrain)

def train_step(x, y):

  # Modalità di training
  net.train()

  # Calcola le predizioni
  y_pred = net(x)

  # Calcola funzione costo
  loss_epoch = loss(y_pred, y)

  # Esegui back-propagation
  loss_epoch.backward()

  # Aggiorna le variabili
  opt.step()

  # Resetta il gradiente
  opt.zero_grad()

for epoch in range(2500):
  train_step(Xt, yt)

Per semplicità abbiamo inglobato il codice di una iterazione in una singola funzione. Vediamo qualche differenza fondamentale con quanto visto nella prima parte del tutorial:

  1. La prima differenza è l'uso di net.train(): questa istruzione dice semplicemente a PyTorch che da qui in poi useremo il modello per la fase di training. Questo è necessario perché alcune operazioni (come il dropout) hanno un comportamento differente in fase di training ed in fase di predizione.
  2. opt.step() esegue l'ottimizzazione a seconda dell'algoritmo scelto. Una differenza essenziale con altri framework è che questa operazione non calcola i gradienti, ma usa quelli calcolati in precedenza con loss_epoch.backward() e salvati nei campi grad delle variabili.
  3. opt.zero_grad() permette di cancellare i gradienti calcolati in precedenza, preparando il modello all'iterazione successiva di ottimizzazione.

Ancora una volta, ricordatevi che la costruzione delle variabili è opzionale nella nuova versione 0.4.0.

Possiamo vedere facilmente come il modello converga ad un minimo locale:

Convergenza funzione costo

Per verificare che tutto stia funzionando, possiamo anche definire una funzione per calcolare l'accuratezza del modello:

def accuracy(y_pred, y_true):
  correct = (y_pred.max(dim=1)[1] == y_true)
  return torch.mean(correct.float()).data.numpy()

Il codice può sembrare inutilmente complicato da leggere, ma la sua semantica è abbastanza semplice. y_pred.max(dim=1)[1] calcola le classi più probabili prendendo il massimo delle uscite della rete neurale. La funzione max(dim=1) ritorna due elementi: il massimo per ciascuna riga, e gli indici dei valori massimi, che sono quelli che ci interessano. Il resto è un cast a float per calcolare la media, ed un altro cast per recuperare il valore in NumPy. Questo valore oscillerà molto vicino al 100% nella maggior parte dei casi.

Nella versione 0.4.0 sono stati introdotti i tensori a 0 dimensioni (scalari). In questo caso, s.data.numpy() si semplifica come s.item().

Questo conclude la parte base di questo tutorial. Vediamo ora come migliorare alcuni aspetti, cominciando con la fase di caricamento dei dati.

Parte 3: Mini-batching dei dati

Finora abbiamo lavorato con l'intero dataset ad ogni iterazione. Questo è però impossibile per dataset troppo grandi, ed un'alternativa è il mini-batching, ovvero campionare un certo numero di elementi ad ogni iterazione dal dataset complessivo. Per questo ed altre operazioni, PyTorch mette a disposizione una classe Dataset per gestire e processare i dati, che diventerà particolarmente importante quando passeremo alle reti convolutive.

La classe torch.utils.data.Dataset è una classe astratta che rappresenta un determinato insieme di dati. Per ora ha solo due implementazioni concrete: TensorDataset per costruire un dataset a partire da tensori in memoria, e ConcatDataset per concatenare dataset (vedremo come costruire dataset più avanzati in un tutorial successivo). Cominciamo incapsulando i nostri tensori in un oggetto del primo tipo:

from torch.utils import data
train_data = data.TensorDataset(Xtrain, ytrain)

Un TensorDataset da solo ha poca utilità, se non indicizzare elementi del dataset o calcolarne il numero:

print(train_data[0]) 
# Prints: (4.9000 2.5000 4.5000 1.7000 [torch.FloatTensor of size 4], 2)

print(len(train_data))
# Prints: 112

Possiamo però incapsulare il dataset all'interno di un ulteriore oggetto (un loader) per ottenere qualche funzionalità aggiuntiva:

train_data_loader = data.DataLoader(train_data, batch_size=32, shuffle=True)

Il loader permette di estrarre mini-batch di dimensione fissa (in questo caso 32) dal nostro dataset, scegliendo inoltre se eseguire uno shuffling dopo ogni passaggio sul dataset completo. Possiamo riscrivere la nostra ottimizzazione, questa volta sfruttando il mini-batching:

for epoch in range(1000):
  net.train()
  for Xb, yb in train_data_loader:
    train_step(Variable(Xb), Variable(yb))

Come si vede, per ogni epoca possiamo usare il loader come un generatore di mini-batch presi dall'intero dataset - il resto del codice è perfettamente uguale. Vedremo di più sui dataset quando introdurremo le reti convolutive. Per ora, passiamo invece a vedere come sfruttare una GPU se installata sul nostro sistema.

Passo 4: Utilizzo della GPU

A differenza che in altri framework, eventuali GPU in PyTorch non vengono usate di default ma devono essere richiamate esplicitamente, spostando sia il modello che i dati su di esse. Prima di tutto, possiamo verificare se è disponibile CUDA sul nostro sistema con torch.cuda.is_available(). Possiamo anche verificare quante GPU sono presenti sul sistema, quale è in uso al momento, ed eventualmente selezionarne un'altra:

torch.cuda.device_count()            # Numero di GPU disponibili
torch.cuda.get_device_name(0)        # Nome della prima GPU disponibile
torch.cuda.current_device()          # Device in uso al momento
torch.cuda.set_device(0)             # Imposta la prima GPU come default
torch.cuda.get_device_capability(0)  # Verifica le capacità della prima GPU

L'ultima istruzione è particolarmente importante per verificare la capacità di calcolo della nostra GPU (come descritto sulla guida ufficiale di CUDA).

Avendo a disposizione una GPU, per sfruttarla è necessario fare solo due modifiche al nostro codice di training. La prima è quella di spostare il modello su GPU dopo l'inizializzazione:

net = CustomModel()
if torch.cuda.is_available():
  net.cuda()
loss = nn.CrossEntropyLoss()
opt = torch.optim.Adam(params=net.parameters(), lr=0.01)

Una pratica abbastanza comune è quella di utilizzare delle istruzioni condizionali per permettere di eseguire lo stesso codice sia con una CPU che con una GPU. Si noti come lo spostamento su GPU si ottiene con una sola istruzione net.cuda(), ed è necessario farlo prima di instanziare un algoritmo di ottimizzazione.

Nella versione 0.4.0, possiamo implementare la logica condizionale in modo più furbo con l'utilizzo della classe device:

device = torch.device("cuda" if use_cuda else "cpu")
net.to(device)

La seconda modifica è di spostare anche i dati nel nostro mini-batch su GPU prima di eseguire l'ottimizzazione del modello:

for epoch in range(2500):
  net.train()
  for Xb, yb in train_data_loader:

    if torch.cuda.is_available():
      Xb, yb = Variable(Xb).cuda(), Variable(yb).cuda()
    else:
      Xb, yb = Variable(Xb), Variable(yb)

    train_step(Xb, yb)

Per riportare il modello su CPU (es., per calcolare l'accuratezza) possiamo eseguire l'operazione inversa con la funzione .cpu():

net.cpu()

Concludiamo questo tutorial vedendo come salvare e recuperare il nostro modello da disco.

Parte 5: Checkpointing

Il checkpointing (salvare l'intero stato del sistema ogni tot di iterazioni) è essenziale quando lavoriamo con modelli particolarmente complessi e/o con grandi moli di dati. Purtroppo, la gestione di questa operazione in PyTorch è meno semplice (e sofisticata) che in altri framework.

Per cominciare, possiamo serializzare il nostro modello su disco con questa istruzione:

torch.save(net.state_dict(), './tmp')

Potremmo salvare direttamente il modello al posto di usare la funzione state_dict(): questo però salverebbe anche tutte le variabili temporanee! L'utilizzo di questa funzione ci garantisce di salvare solo le variabili (e parametri) essenziali all'interno di un dizionario, ed è disponibile anche per altri oggetti del framework, come gli algoritmi di ottimizzazione.

Allo stesso modo, possiamo recuperare un modello da disco:

net.load_state_dict(torch.load('./tmp'))

Più in generale, però, molto spesso vogliamo salvare un intero snapshot del processo di ottimizzazione per continuarlo in seguito. Vediamo una soluzione per questo problema molto comune ispirandoci ad una discussione sul forum ufficiale della libreria:

start_epoch = resume_from_checkpoint('checkpoint.pth.tar')
for epoch in range(start_epoch, 1000):

  net.train()

  for Xb, yb in train_data_loader:
    Xb, yb = Variable(Xb), Variable(yb)

    train_step(Xb, yb)

  # Stato complessivo del processo di ottimizzazione
  state = {
    'epoch': epoch,
    'state_dict': net.state_dict(),
    'opt': opt.state_dict(),
  }
  torch.save(state, 'checkpoint.pth.tar') 

Vediamo nel dettaglio cosa stiamo facendo:

  1. Prima di tutto, dopo ogni epoca definiamo un dizionario con lo stato complessivo del processo di ottimizzazione, che in questo caso include il nostro modello, l'epoca a cui siamo arrivati, e lo stato dell'algoritmo di ottimizzazione.
  2. Salviamo quindi lo stato complessivo su disco.

La funzione resume_from_checkpoint() verifica che esista un file di checkpoint sul disco, ed eventualmente lo carica ripristinando lo stato della rete neurale e dell'algoritmo di ottimizzazione:

import os
def resume_from_checkpoint(path_to_checkpoint):

  if os.path.isfile(path_to_checkpoint):

    # Caricamento del checkpoint
    checkpoint = torch.load(path_to_checkpoint)

    # Ripristino dello stato del sistema
    start_epoch = checkpoint['epoch']
    model.load_state_dict(checkpoint['state_dict'])
    opt.load_state_dict(checkpoint['opt'])
    print("Caricato il checkpoint '{}' (epoca {})"
                  .format(path_to_checkpoint, checkpoint['epoch']))

  else:
    start_epoch = 0

  return start_epoch

La funzione dovrebbe essere abbastanza semplice da leggere sulla base di quanto visto finora. Ovviamente possiamo immaginare soluzioni più avanzate, che indubbiamente saranno anche considerate nelle prossime versioni della libreria.

Possiamo dire che per questo tutorial è tutto! Nella terza parte scopriremo invece come implementare nuovi moduli all'interno della libreria: Alle prese con PyTorch - Parte 3: Implementare Nuovi Moduli.


Se questo articolo ti è piaciuto e vuoi tenerti aggiornato sulle nostre attività, ricordati che l'iscrizione all'Italian Association for Machine Learning è gratuita! Puoi seguirci anche su Facebook e su LinkedIn.

Previous Post Next Post