Alle prese con PyTorch - Parte 1: tensori e gradienti


PyTorch è un framework di deep learning, sviluppato principalmente dal Facebook AI Research (FAIR) group, che ha guadagnato una enorme popolarità fra gli sviluppatori grazie alla combinazione di semplicità ed efficienza. Questi tutorial sono dedicati ad esplorare la libreria, partendo dai concetti più semplici fino alla definizione di modelli estremamente sofisticati.

Nella prima parte, introduciamo gli elementi di base di PyTorch (tensori e gradienti), ed implementiamo un primo modello di regressione con dei dati artificiali.

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

Installazione di PyTorch

Al momento in cui scriviamo, la versione 1.0 di PyTorch è stata rilasciata da pochissimo per tutte le piattaforme. Se avete già installato Python con le necessarie librerie, trovate sul sito i comandi per installare PyTorch a seconda della piattaforma. Ad esempio, su Linux attraverso il package manager di Anaconda basta digitare da terminale:

conda install pytorch -c pytorch

Su OSX il comando è simile, ma il pacchetto binario non supporta CUDA per i calcoli su GPU: in questo caso è necessario compilare da sorgente.

Tensori: PyTorch vs. NumPy

L'elemento fondamentale di PyTorch sono i tensori, matrici multi-dimensionali di numeri, su cui si fondano anche NumPy e buona parte del calcolo scientifico in Python. L'interfaccia di PyTorch rispecchia in larghissima misura quella di NumPy per risultare più familiare possibile: in questa sezione vediamo quindi similitudini e differenze fra le due librerie.

Cominciamo confrontando l'inizializzazione di un tensore nelle due librerie:

# NumPy
import numpy as np
x = np.zeros((2, 3))

# PyTorch
import torch
y = torch.zeros(2, 3)

Come si vede, le due istruzioni sono simili, anche se con una piccola differenza sintattica (le dimensioni nel primo caso sono specificate come una tupla, nel secondo caso come argomenti separati). Una differenza essenziale riguarda il tipo dei due tensori:

print(y.type()) # <class 'torch.FloatTensor'>
print(x.dtype) # float64

Il tipo degli elementi è diverso! Di default, gli elementi di NumPy sono inizializzati come np.float64, mentre i tensori di PyTorch come torch.FloatTensor a 32 bit, per questioni di compatibilità con la GPU: combinare i due porta ad un upcasting verso 64 bit, il che genera poi numerosi errori in fase di esecuzione. PyTorch ha però numerose opzioni per convertire tra i due tipi di dato: ad esempio possiamo eseguire un downcast del risultato con y.float() (e viceversa eseguire un upcast con y.double()).

Nelle versioni precedenti alla 0.4.0, y.type() era equivalente a type(y).

Possiamo combinare tensori in NumPy e PyTorch transformando l'uno o l'altro:

z = x + y.double().numpy() # Il risultato è un tensore NumPy a 64 bit
z = torch.from_numpy(x).float() + y # Il risultato è un tensore PyTorch a 32 bit

Bisogna fare molta attenzione perché lo spazio di memoria tra i due oggetti è condiviso, come si vede in questo esempio:

xx = z.numpy()
xx += 1.0
print(z)
# 1  1  1
# 1  1  1
# [torch.DoubleTensor of size 2x3]

Per il resto, la sintassi e semantica delle operazioni nelle due librerie sono praticamente equivalenti. In particolare, possiamo indicizzare in entrambi i casi con le parentesi quadre, ed in entrambi i casi è anche supportato il brodcasting per lavorare con matrici di dimensioni non consistenti (immagine dal sito ufficiale di PyTorch):

Broadcasting in PyTorch
torch.Tensor([3, 2]) * torch.Tensor([[0, 1], [4, 2]])
# 0  2
# 12  4
# [torch.FloatTensor of size 2x2]

Anche il calcolo delle dimensioni dei tensori ammette una sintassi quasi identica, a meno del tipo di ritorno delle due operazioni:

print(x.shape)   # (3, 2)
print(y.shape)  # torch.Size([3, 2])

Per interoperabilità con le versioni precedenti, in PyTorch esiste anche y.size(), equivalente a y.shape.

Un'ultima differenza è il nome con cui si seleziona un asse per metodi che aggregano i risultati (come il calcolo della media):

x.mean(axis=0)
y.mean(dim=0)

I nomi delle operazioni (es., mean) e dei vari operatori (es., addizione) sono comunque quasi sempre equivalenti, con poche eccezioni.

Gradienti e funzioni

Passiamo ora a qualcosa di più interessante: calcolare automaticamente i gradienti rispetto a funzioni definite sui nostri tensori.

Prima di cominciare dobbiamo fare una brevissima menzione 'storica'. Le versioni della libreria < 0.4.0 introducevano il concetto di variabile:

Variabili in PyTorch

Una variabile era un wrapper di un tensore, con la differenza che tutte le operazioni eseguite su di esso venivano registrate internamente da PyTorch (su quello che viene chiamato un nastro), all'interno di un grafo aciclico, come ci ricorda l'immagine con cui fu introdotta la libreria:

Costruzione del grafo computazionale in PyTorch

Tracciare le operazioni permette di calcolare il gradiente di una funzione rispetto ad uno dei tensori che vi partecipano, propagando all'interno del grafo tutte le informazioni necessarie. Nelle versioni >= 0.4.0 per semplificare la scrittura la semantica delle variabili è stata inglobata nei tensori, ed adottiamo questa sintassi aggiornata qui. Menzioniamo questo perché può ancora capitare di trovare nel codice online riferimenti ad oggetti Variable.

Facciamo quindi un esempio molto semplice, partendo dall'inizializzazione di un tensore di cui desideriamo tracciare tutte le operazioni:

v = torch.ones(1, 2, requires_grad=True)

Nelle versioni < 0.4.0 avremmo dovuto scrivere v = Variable(torch.ones(1, 2), requires_grad=True).

Si noti il flag esplicito che dice a PyTorch della necessità futura di calcolare il gradiente rispetto a v.

Ogni tensore contiene tre proprietà fondamentali (come si vede anche nell'immagine di cui sopra):

print(v.data)     #  1  1 [torch.FloatTensor of size 1x2]
print(v.grad)     # None
print(v.grad_fn)  # None

La loro semantica è presto detta:

  • v.data è il tensore vero e proprio (i dati).
  • v.grad è il gradiente, non inizializzato, che verrà popolato solo quando lo richiederemo.
  • v.grad_fn serve a PyTorch per tenere traccia del grafo computazionale. Poiché il tensore in questione rappresenta l'inizio del grafo, in questo caso la proprietà è vuota.

Eseguiamo ora qualche operazione sul tensore (eleviamo tutti gli elementi al quadrato e li sommiamo):

v_fn = torch.sum(v ** 2)
print(v_fn.data)    # 2 [torch.FloatTensor of size 1]
print(v_fn.grad_fn) # <SumBackward0 object at 0x7fa959f21550>

La prima cosa da notare è che PyTorch costruisce il grafo in maniera dinamica, ed il risultato delle operazioni è disponibile subito, a differenza di altre librerie come TensorFlow (che per adeguarsi ha introdotto la eager execution).

La versione 1.0 ha introdotto anche un compiler per ottimizzare alcune porzioni di modelli, ottenendo così un front-end ibrido con porzioni dinamiche e porzioni compilate. Ne parleremo nella quinta parte della serie.

La seconda osservazione è che in questo caso la proprietà grad_fn di v_fn è popolata con un oggetto che rappresenta l'operazione che ha originato il tensore all'interno del grafo sottostante.

Arrivati a questo punto, ottenere il gradiente è immediato:

torch.autograd.grad(v_fn, v) # Gradiente di v_fn rispetto a v
# (tensor([[2., 2.]]),)

Esiste un secondo meccanismo, più interessante quando si lavora con più tensori. Per vederlo, consideriamo un esempio leggermente più complesso con due tensori 'tracciati':

v1 = torch.tensor([1.0, 2.0], requires_grad=True)
v2 = torch.tensor([3.0], requires_grad=True)
v_fn = torch.sum(v1 * v2)

Al posto di richiedere il gradiente in maniera esplicita per ciascun tensore, possiamo eseguire una back-propagation automatica rispetto a tutte i tensori (che partecipano a quella porzione di grafo) di cui abbiamo selezionato requires_grad=True:

v_fn.backward()
print(v1.grad) # tensor([3., 3.])
print(v2.grad) # tensor([3.])

Si noti come in questo caso la funzione backward non ritorna nulla, ma i gradienti vengono salvati nella proprietà grad di ciascun tensore. PyTorch ha anche meccanismi più complessi di auto-differenziazione, per gestire gradienti di ordine più elevato o Jacobiani: per queste informazioni (per ora) rimandiamo alla documentazione ufficiale.

Un primo esempio: regressione lineare

Per concludere questo articolo, vediamo come implementare una semplice regressione lineare con gli strumenti visti fin qua.

Questa sezione è solo per scopi illustrativi: nella pratica PyTorch mette a disposizione classi molto più sofisticate per costruire modelli ed ottimizzarli, che vedremo nella prossima parte di questa serie.

Per questo articolo, limitiamoci a costruire un problema artificiale mono-dimensionale (con un po' di rumore aggiunto):

X = np.random.rand(30, 1)*2.0
w = np.random.rand(2, 1)
y = X*w[0] + w[1] + np.random.randn(30, 1) * 0.05
Dataset per regressione lineare

Per identificare i coefficienti della retta, definiamo un modello lineare in PyTorch:

W = torch.rand(1, 1, requires_grad=True)
b = torch.rand(1, requires_grad=True)

def linear(x):
  return torch.matmul(x, W) + b

L'utilizzo di torch.matmul (l'equivalente di np.dot) in questo caso è ridondante, ma permette al codice di essere utilizzato senza cambiamenti anche per modelli lineari con più feature.

Il codice per allenare il modello è abbastanza semplice:

Xt = torch.from_numpy(X).float()
yt = torch.from_numpy(y).float()

for epoch in range(2500):

  # Calcola le predizioni
  y_pred = linear(Xt)

  # Calcola funzione costo
  loss = torch.mean((y_pred - yt) ** 2)

  # Esegui back-propagation
  loss.backward()

  # Aggiorna i parametri del modello
  W.data = W.data - 0.005*W.grad.data
  b.data = b.data - 0.005*b.grad.data

  # Resetta il gradiente
  W.grad.data.zero_()
  b.grad.data.zero_()

Facciamo qualche commento sul codice:

  1. Prima di tutto dobbiamo trasformare il dataset in tensori di PyTorch, assicurandoci di eseguire il cast dell'input a 32 bit con il metodo float().

  2. La riga loss = torch.mean((y_pred - yt) ** 2) calcola la funzione costo, che in questo caso è un errore quadratico medio.

  3. Dopo aver eseguito la back-propagation, aggiorniamo i pesi con una discesa al gradiente. Si noti come lavoriamo su W.data e non direttamente su W, per evitare di sovrascrivere i tensori originari.

  4. Alla fine di ogni iterazione, resettiamo il gradiente salvato all'interno dei tensori.

Per verificare che tutto stia funzionando, visualizziamo la funzione risultante (in rosso tratteggiato):

Regressione lineare

Questo conclude la prima parte del nostro tutorial! Nella seconda parte parliamo di come costruire modelli più complessi ed ottimizzarli con gli strumenti messi a disposizione da PyTorch stesso: Alle prese con PyTorch - Parte 2: Reti Neurali ed Ottimizzatori.


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