... primi passi con PyTorch e prima rete neurale

Rete neurale con PyTorch

In questo articolo voglio muovere i primi passi con PyTorch e usare le conoscenze acquisite per implementare una prima rete neurale basilare.

Questo è quello di cui abbiamo bisogno:
– un dataset
– un modello della rete neurale
– la definizione di una funzione errore
– la definizione di un ottimizzatore

Inizieremo vedendo gli elementi che ci servono per definire una rete neurale semplice, limitandoci a layer lineari e ReLU.

In seguito vedremo come mettere insieme i layer per costruire il modello della rete.

Alla fine implementeremo una rete, e la ottimizzeremo per un problema specifico.

Importare i packackes

In [1]:
import numpy as np
import torch
import torch.nn as nn

Il Layer lineare – uscita singola

Il layer lineare torch.nn.Linear si chiama così perché realizza una combinazione lineare degli ingressi.
Nel caso (improbabile) di rete con una feature di ingresso (x, scalare) e una di uscita (y, scalare), l’operazione è semplicemente: $w x + b$
Dove $w$ è chiamato peso (weight) e $b$ è chiamato bias.

Nel caso di rete con N ingressi, $x$ è un tensore N-dimensionale. $y$ è un tensore M-dimensionale, con M features di uscita.
Così la relazione ingresso-uscita della rete neurale è:

$y = w \cdot x + b$

Con $w$ tensore MxN e $b$ tensore M-dimensionale.

In [2]:
lay1 = nn.Linear(in_features=10, out_features=1) #definisce il layer linear

torch.manual_seed(1234) #inizializza il generatore pseudorandom con un seed, in modo da avere sempre gli stessi random generati

idata = torch.rand(10) #genero una sequenza di 10 numeri random compresi tra [0,1)

lay1(idata) #calcola l'uscita del layer linear con input idata
Out[2]:
tensor([-0.0464], grad_fn=<AddBackward0>)

Questi sono alcuni dei parametri del layer lineare:

  • weigth uguale al numero degli ingressi (o input features),
  • bias che coincide col numero delle uscite (o output features)
In [3]:
lay1.weight, lay1.bias
Out[3]:
(Parameter containing:
 tensor([[-0.1471,  0.1833, -0.0853,  0.2205,  0.0211, -0.3075,  0.1609, -0.0509,
           0.0621, -0.1562]], requires_grad=True), Parameter containing:
 tensor([0.0652], requires_grad=True))

Il layer lineare realizza il calcolo $w x+b$.

Facciamo una rapida verifica, comparando l’uscita $w x + b$ con il calcolo lay1(idata).

Nel caso di uscita singola ho:

In [4]:
(torch.matmul(lay1.weight.data, idata)+lay1.bias).data #w*x+b
Out[4]:
tensor([-0.0464])

Come si vede i due risultati sono uguali

Il layer linear – più uscite

Consideriamo il caso a due nodi, ovvero due labels (uscite della rete neurale)

In [5]:
torch.manual_seed(123)                           #inizializziamo il generatore di numeri pseudorandom
idata = torch.rand(10)                           #creiamo un tensore di dati per l'ingresso
lay2 = nn.Linear(in_features=10, out_features=2) #definiamo un layer lineare
y2 = lay2(idata)                                 #calcoliamo l'uscita del layer
y2                                               #visualizziamo l'uscita
Out[5]:
tensor([0.0827, 0.3945], grad_fn=<AddBackward0>)

Visualizziamo i pesi e i bias del layer:

In [6]:
lay2.weight, lay2.bias
Out[6]:
(Parameter containing:
 tensor([[-0.1168,  0.1183, -0.2684, -0.1919, -0.1161, -0.0621, -0.2412,  0.2071,
          -0.0746,  0.1015],
         [ 0.2236,  0.0589,  0.0865,  0.3052, -0.1426,  0.1002, -0.1407,  0.2260,
           0.2526, -0.2916]], requires_grad=True), Parameter containing:
 tensor([0.2699, 0.1510], requires_grad=True))

Adesso faccio il calcolo manualmente come $w\cdot x+b$

In [7]:
out2_0 = torch.matmul(lay2.weight[0,:].data,idata)+lay2.bias[0].data #uscita w*x+b del nodo 0
out2_1 = torch.matmul(lay2.weight[1,:].data,idata)+lay2.bias[1].data #uscita w*x+b del nodo 1
out2 = torch.tensor([out2_0, out2_1])
out2
Out[7]:
tensor([0.0827, 0.3945])

Come si vede i due risultati y2 e out2 sono uguali.

ReLU layer

La funzione ReLU (Rectifier Linear Unit) è tra le funzioni di attivazione più utilizzate. La funzione, non lineare, è così definita:

\begin{equation*}
relu(x) =
\begin{cases}
0 & \text{for }x<0\\
x & \text{for }x\geqslant 0\\
\end{cases}
\end{equation*}

In maniera grossolana si può dire che le funzioni di attivazione non lineari sono necessarie nelle reti neurali per realizzare funzioni complesse. Altrimenti avremmo solo combinazioni lineari degli ingressi.

Il layer nn.ReLU() ha un numero di uscite pari al numero di ingressi. Ad ogni ingresso xi corrisponde una uscita yi, e la relazione tra i due è:

\begin{equation*}
y_i = relu(x_i)
\end{equation*}

Definiamo il layer ReLU:

In [8]:
lay3 = nn.ReLU()

Creiamo un tensore 10-dimensionale, di dati random con distribuzione normale standard (media zero e varianza unitaria):

In [9]:
idata = torch.randn(10)
idata
Out[9]:
tensor([-0.0386, -0.8861, -0.4709, -0.4269, -0.0283,  1.4220, -0.3886, -0.8903,
        -0.9601, -0.4087])

Come si vede ci sono sia valori positivi che negativi. Il layer ReLU dovrebbe assegnare il valore 0 a tutti i valori negativi. Verifichiamolo:

In [10]:
lay3(idata)
Out[10]:
tensor([0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 1.4220, 0.0000, 0.0000, 0.0000,
        0.0000])

Sembra che il layer ReLU faccia il suo lavoro.

Stack di layer lineari

Ci sono due modi per mettere in cascata (o in stack) due layer.
Il più semplice è quello di assegnare l’uscita del primo layer all’ingresso del secondo layer.
Come segue:

In [11]:
lay1 = nn.Linear(in_features=10, out_features=5) #definizione del layer 1
lay2 = nn.Linear(in_features=5, out_features=2)  #definizione del layer 2

x1 = idata    #assegniamo i dati generati all'ingresso del layer 1
y1 = lay1(x1) #uscita layer 1
x2 = y1       #uscita del layer 1 all'ingresso del layer 2
y2 = lay2(x2) #uscita layer 2

y2            #visualizziamo l'uscita
Out[11]:
tensor([0.3289, 0.3651], grad_fn=<AddBackward0>)

Un modo più immediato (specialmente se ci sono più layer) è quello di usare il modulo torch.nn.Sequential.
Questo modulo realizza lo stack come desiderato, ovvero l’uscita del layer 1 viene usata come ingresso del layer 2.
Con questa definizione avremo un modulo rete neurale il cui ingresso coincide con l’ingresso del layer 1, e la cui uscita coincide con l’uscita del layer 2.

In [12]:
my_nn = nn.Sequential(
    lay1,
    lay2
)

Calcoliamo l’uscita del modulo my_nn:

In [13]:
xnn = idata        #assegniamo i dati generati all'ingresso del modulo my_nn
ynn = my_nn(idata) #calcoliamo l'uscita
ynn                #visualizziamo l'uscita
Out[13]:
tensor([0.3289, 0.3651], grad_fn=<AddBackward0>)

Come si vede il risultato è uguale nei due casi.

Per un numero elevato di layer è sicuramente più comodo usare nn.Sequential.
Nell’esempio precedente ho voluto usare lay1 e lay2 anche in my_nn, per evitare di avere inizializzazioni diverse dei pesi e dei bias, e verificare che i calcoli generati nei due casi siano uguali.
In realtà non ho bisogno di usare le variabili aggiuntive lay1 e lay2 per i layer. L’uso più naturale del layer nn.Sequential è:

In [14]:
my_nn = nn.Sequential(
    nn.Linear(in_features=10, out_features=5),
    nn.Linear(in_features=5, out_features=2)
)

In questo modo ho definito una rete neurale, ma non ho fatto il training della rete, cioè non ho ottimizzato i pesi e i bias per fare in modo che l’errore sia minimo.
In questo momento, i pesi e i bias sono random, per cui la rete neurale svolge una funzione random.

Prima di fare il training della rete, vediamo come implementare la rete neurale usando la classe torch.nn.Module, che presenta alcuni vantaggi.

Usando la classe Module

Uno dei modi per implementare una rete neurale è usare la classe torch.nn.Module

In [15]:
import torch.nn as nn
import torch.nn.functional as F
In [16]:
class PyTorchNN(nn.Module):
    
    #constructor
    def __init__(self):
        """
        Nel constructur definisco i Linear layer e li assegno a variabili membri della classe.
        """
        super(PyTorchNN, self).__init__()
        self.layer1 = nn.Linear(in_features=10, out_features=5)
        self.layer2 = nn.Linear(in_features=5,  out_features=2)
    
    #predictor
    def forward(self, x):
        """
        Nella funzione forward ho un tensore di dati in input, e un tensore di risultati in uscita.
        Posso usare i layer definiti nel constructor.
        """
        
        # L'uscita si può definire così:
        x1 = F.relu(self.layer1(x)) 
        x2 = F.relu(self.layer2(x1))
        
        #oppure così:
        x3 = nn.Sequential(self.layer1, 
                           nn.ReLU(),
                           self.layer2, 
                           nn.ReLU())(x)
        
        return x3
In [17]:
MyNN = PyTorchNN()

Generiamo dei dati random. La funzione randn(N) genera un tensore N-dimensionale di numeri random, con distribuzione normale standard (ovvero con media nulla e varianza 1)

In [18]:
idata = torch.randn(10)

Applichiamo i dati all’ingresso della rete neurale. In uscita avremo il tensore ynn.

In [19]:
ynn = MyNN(idata)
ynn
Out[19]:
tensor([0.0000, 0.5180], grad_fn=<ReluBackward0>)

Ancora una volta voglio verificare che i calcoli siano giusti, ovvero che la semplice rete neurale da me progettata faccia i calcoli che mi aspetto. Per vedere se ho capito bene.

Questi sono i pesi del layer 1:

In [20]:
MyNN.layer1.weight
Out[20]:
Parameter containing:
tensor([[-0.1820,  0.1631,  0.2234, -0.3068, -0.2684, -0.3079,  0.1193,  0.2545,
         -0.2452, -0.1464],
        [ 0.1006, -0.2065,  0.2686,  0.0737, -0.0880,  0.0205,  0.0986, -0.1118,
         -0.2450,  0.0021],
        [ 0.0058,  0.0064, -0.0461,  0.2030, -0.0882, -0.0306,  0.1300, -0.1990,
          0.0847, -0.0699],
        [ 0.1517, -0.1715,  0.0117,  0.0309, -0.2544, -0.2299,  0.1213, -0.0920,
          0.1878, -0.3124],
        [-0.1563, -0.2605,  0.1263, -0.0092, -0.0590, -0.0526, -0.2472,  0.0897,
          0.0079, -0.2182]], requires_grad=True)

E questi sono i bias del layer 1:

In [21]:
MyNN.layer1.bias
Out[21]:
Parameter containing:
tensor([ 0.1190, -0.0064, -0.3058,  0.1701,  0.1691], requires_grad=True)

Il primo layer esegue un’operazione del tipo relu($w_1 \cdot x_1 + b_1$). Dove $w_1$ e $b_1$ sono pesi e bias del layer 1.

Applichiamo i dati di input idata al primo layer, l’uscita sarà il tensore yt1:

In [22]:
yt1 = F.relu(torch.matmul(MyNN.layer1.weight,idata)+MyNN.layer1.bias)
yt1
Out[22]:
tensor([0.0000, 0.3123, 0.0000, 0.0000, 0.7073], grad_fn=<ReluBackward0>)

L’uscita del primo layer è applicata all’ingresso del secondo layer:

In [23]:
x2 = yt1

Il secondo layer, esegue l’operazione relu($w_2 \cdot x_2 + b_2$). Con $w_2$ e $b_2$ indico i pesi e i bias del layer 2.
Adesso applichiamo il tensore x2 al secondo layer:

In [24]:
yt2 = F.relu(torch.matmul(MyNN.layer2.weight,x2) + MyNN.layer2.bias)
yt2
Out[24]:
tensor([0.0000, 0.5180], grad_fn=<ReluBackward0>)

Siamo arrivati al risultato che volevamo, i calcoli fatti nei due modi sono equivalenti (ynn = yt2).
La mia comprensione (e spero anche la vostra) dell’implementazione di una rete neurale in Pytorch è corretta.

Un altro modo per accedere ai parametri -pesi e bias- è usare la funzione parameters():

In [25]:
params = list(MyNN.parameters())
params
Out[25]:
[Parameter containing:
 tensor([[-0.1820,  0.1631,  0.2234, -0.3068, -0.2684, -0.3079,  0.1193,  0.2545,
          -0.2452, -0.1464],
         [ 0.1006, -0.2065,  0.2686,  0.0737, -0.0880,  0.0205,  0.0986, -0.1118,
          -0.2450,  0.0021],
         [ 0.0058,  0.0064, -0.0461,  0.2030, -0.0882, -0.0306,  0.1300, -0.1990,
           0.0847, -0.0699],
         [ 0.1517, -0.1715,  0.0117,  0.0309, -0.2544, -0.2299,  0.1213, -0.0920,
           0.1878, -0.3124],
         [-0.1563, -0.2605,  0.1263, -0.0092, -0.0590, -0.0526, -0.2472,  0.0897,
           0.0079, -0.2182]], requires_grad=True), Parameter containing:
 tensor([ 0.1190, -0.0064, -0.3058,  0.1701,  0.1691], requires_grad=True), Parameter containing:
 tensor([[-0.0843, -0.3088,  0.0180,  0.3375,  0.4094],
         [-0.3376, -0.2020,  0.3482,  0.2186,  0.2768]], requires_grad=True), Parameter containing:
 tensor([-0.2226,  0.3853], requires_grad=True)]

Manca un nome associato a ciascun elemento della lista, ma il significato è chiaro lo stesso. La lista contiene i tensori weight e bias del layer 1, e i tensori weight e bias del layer 2.

Il training della rete

La rete neurale da noi definita ha dei parametri che sono i pesi (weight) e i bias (non ci provo neanche a tradurlo), rispettivamente $w_{i,j}$ e $b_{i,j}$.
Cambiando questi parametri cambia la capacità della rete neurale di svolgere la funzione da noi richiesta, ovvero la capacità di fittare il dataset input-output a disposizione.

Per definire quanto bene la rete neurale è in grado di fittare il dataset occorre definire una metrica, che ci dica quanto bene questo fitting sia stato realizzato. Questa metrica è la funzione errore, o anche loss function o anche cost function.

Il training della rete avviene in modo iterativo.
Alla prima iterazione i parametri -pesi e bias- sono inizializzati in maniera random.
Ad ogni iterazione l’uscita della rete viene calcolata utilizzando i parametri -pesi e bias- correnti, partendo dagli ingressi si possono calcolare le uscita, secondo lo schema propagativo visto in precedenza, questo è il calcolo forward (in avanti).
Utilizzando le uscite così calolate e i target si calcola l’errore in questa iterazione.

Una funzione di errore molto usata è l’errore quadratico medio, in PyTorch implementato nel modulo torch.nn.MSELoss. L’errore quadratico medio allo step n è:

\begin{equation*}
Loss^{(n)}(w_{i,j},b_{i,j}) = \frac{\sum_{k=1}^M{(ynn_k^{(n)} – ytarget_k)^2}}{N}
\end{equation*}

$ynn_k^{(n)}$ è la k-esima uscita della rete neurale allo step n.
$ytarget_k$ è la k-esima label, ovvero la parte di ouput del dataset.
$M$ è il numero delle features di uscita.
$N$ è il numero di samples del dataset.

Adesso voglio minimizzare l’errore, cioè voglio sapere i valori da dare ai parametri -pesi e bias- per minimizzare l’errore.
Per questo posso utilizzare un classico algoritmo di minimizzazione del gradiente (gradient descent algorithm), che fa uso delle derivate parziali della funzione errore rispetto a ciascun parametro per trovare la direzione in cui si ha la diminuzione più rapida del gradiente.

Per calcolare le derivate parziali in maniera computazionalmente efficiente si usa un algoritmo chiamato backpropagation. L’algoritmo di backpropagation ha bisogno di una pagina a sé per essere spiegato, per cui al momento lo lascio alla vostra buona volontà.
Inoltre, sempre per essere più efficienti la funzione errore può essere calcolata su un numero ridotto di samples invece che su tutti. Si vedano stochastic gradient descent o mini-batch gradient descent.

Dopo avere calcolato le derivate parziali si possono aggiornare i parametri della rete:

\begin{equation*}
w_{i,j}(n+1) = w_{i,j}(n) – l_r \cdot \frac{\partial Loss^{(n)}(w_{i,j},b_{i,j})}{\partial w_{i,j}} \hspace{1cm} \forall i,j
\end{equation*}\begin{equation*}
b_{i,j}(n+1) = b_{i,j}(n) – l_r \cdot \frac{\partial Loss^{(n)}(w_{i,j},b_{i,j})}{\partial b_{i,j}} \hspace{1cm} \forall i,j
\end{equation*}

$l_r$ è il learning rate. Questo, come dice il nome, controlla la velocità con cui i parametri convergeranno (se) verso l’ottimo. Un $l_r$ troppo alto comprometterà la convergenza dell’algoritmo. Un valore troppo basso renderà la convergenza troppo lenta.

La funzione errore (o di perdita, o loss function, o cost function)

Carichiamo il package torch.optim che contiene gli algoritmi di ottimizzazione

In [26]:
from torch import optim

Definiamo una rete di 3 layer lineari.

In [27]:
class PyTorchNN(nn.Module):
    def __init__(self):
        super(PyTorchNN, self).__init__()
        self.layer1 = nn.Linear(in_features=3, out_features=3)
        self.layer2 = nn.Linear(in_features=3,  out_features=3)
        self.layer3 = nn.Linear(in_features=3,  out_features=3)
    def forward(self, x):
        x3 = nn.Sequential(self.layer1,
                           self.layer2,
                           self.layer3,
                          )(x)
        return x3

MyNN = PyTorchNN()                      #instanziamo la rete

Definiamo un dataset di dati. I dati di ingresso, sono delle triplette [x0,x1,x2] di interi random.
Generiamo delle uscite come combinazioni lineari degli ingressi:
y0 = x0
y1 = x0 + x1
y2 = x0 + x1 + x2

In [28]:
#definiamo un dataset di training data
idata = torch.randint(0,100,(1000,3), dtype=torch.float) #dati di ingresso

odata = torch.empty(idata.shape)                         #generiamo un tensore non inizializzato
odata[:,0] = idata[:,0]                                  #y0
odata[:,1] = idata[:,0]+idata[:,1]                       #y1
odata[:,2] = idata[:,0]+idata[:,1]+idata[:,2]            #y2

Ricapitolando il problema, sia per me che per voi.
(idata, odata) è il dataset che voglio interpolare con una rete neurale. Ovvero fornendo in ingresso idata, voglio che l’uscita della rete sia il più vicino possibile a odata.
Ogni ingresso ed ogni uscita è costituito da una tripletta di valori [x0,x1,x2].

Premesso che trovo la notazione abbastanza poco intuitiva, vediamo come si definisce la funzione di errore. E come si usa.
Di seguito il codice.

In [29]:
ynn = MyNN(idata)                 #calcola l'uscita della rete neurale con i dati di ingresso specificati
loss = torch.nn.MSELoss()         #definisce quale funzione errore: Mean Squared Error (errore quadratico medio)
error = loss(ynn, odata)          #calcola l'errore tra il target e l'uscita corrente
error.backward()                  #calcola i gradienti rispetto a tutti i tensori weight e bias

Il primo passo è quello di specificare quale funzione errore si vuole utilizzare. In questo caso ho optato per torch.nn.MSELoss.
Successivamente calcolo l’errore attuale, cioè usando i weight e i bias correnti, semplicemente eseguendo loss(ynn,ydata).
Infine con la funzione backward eseguo la backpropagation che calcola i gradienti dell’errore rispetto a tutti i tensori weight e bias.
La funzione backward() è definita automaticamente. Per saperne di più si può visitare la pagina sul package torch.autograd.

Adesso bisogna definire un ottimizzatore. Questo fa uso dei gradient calcolati e aggiorna i parametri (weight e bias) con una strategia che dipende dall’ottimizzatore usato.
Innanzitutto occorre definire l’ottimizzatore, bisogna specificare quali parametri vengono ottimizzati, semplicemente MyNN.parameters(), e il learning rate (lr) o velocità di apprendimento.
Nel nostro caso usiamo lo SGD (Stochastic Gradient Descent).

In [30]:
optimizer = optim.SGD(MyNN.parameters(),lr=1e-5) #definizione dell'ottimizzatore

Per qualche motivo sconosciuto, prima di chiamare la funzione backward() per calcolare i gradienti, occorre azzerare questi ultimi, con il seguente comando:

In [31]:
optimizer.zero_grad()

Dopo avere calcolato i gradienti, si possono aggiornare i parametri che vengono ottimizzati: weight e bias.
Le formule per l’aggiornamento dei weight dallo step n a quello n+1 sono:

\begin{equation*}
w_{i,j}(n+1) = w_{i,j}(n) – lr \cdot \frac{\partial loss(n)}{\partial w_{i,j}} \hspace{1cm} \forall i,j
\end{equation*}

E analogamente per l’aggiornamento dei bias.

\begin{equation*}
b_{i,j}(n+1) = b_{i,j}(n) – lr \cdot \frac{\partial loss(n)}{\partial b_{i,j}} \hspace{1cm} \forall i,j
\end{equation*}

Per fortuna non dobbiamo aggiornare i parametri manualmente, ma c’è la funzione step() dell’ottimizzatore che si occupa di farlo:

In [32]:
optimizer.step()

Riassumendo, i passaggi per eseguire l’ottimizzazione sono indicati nel loop sottostante:

In [33]:
for i in range(5000):             #esegue X iterazioni
    ynn = MyNN(idata)            #calcola l'uscita della rete neurale
    error = loss(ynn, odata)      #calcola l'errore corrente
    error.backward()              #calcola i gradienti
    optimizer.step()              #aggiorna i parametri
    optimizer.zero_grad()         #azzera i gradienti
    if np.mod(i,500)==0:
        print(error)              #visualizza l'errore ogni 100 iterazioni
tensor(13654.5498, grad_fn=<MseLossBackward>)
tensor(218.6053, grad_fn=<MseLossBackward>)
tensor(167.0868, grad_fn=<MseLossBackward>)
tensor(18.1930, grad_fn=<MseLossBackward>)
tensor(0.0852, grad_fn=<MseLossBackward>)
tensor(0.0662, grad_fn=<MseLossBackward>)
tensor(0.0659, grad_fn=<MseLossBackward>)
tensor(0.0656, grad_fn=<MseLossBackward>)
tensor(0.0653, grad_fn=<MseLossBackward>)
tensor(0.0651, grad_fn=<MseLossBackward>)

Come si vede sopra l’errore è passato da un valore iniziale molto alto di 14998 un valore di 0.0297. Quindi il training ha funzionato.

Una sommaria comparazione di odata e ynn mostra che i valori sono sufficientemente vicini per lo scopo di questo articolo.

In [34]:
odata
Out[34]:
tensor([[  9.,  62.,  67.],
        [ 34.,  76., 161.],
        [ 94., 114., 179.],
        ...,
        [ 12.,  74.,  91.],
        [  2.,  24., 118.],
        [ 89., 178., 198.]])
In [35]:
ynn
Out[35]:
tensor([[  8.6458,  61.3783,  66.6256],
        [ 33.9838,  75.9714, 160.9826],
        [ 94.0394, 114.0688, 179.0417],
        ...,
        [ 11.7349,  73.5347,  90.7197],
        [  1.8258,  23.6940, 117.8154],
        [ 89.1233, 178.2168, 198.1309]], grad_fn=<AddmmBackward>)

Ancora una volta possiamo visualizzare i parametri della rete usando la funzione .parameters().
L’ordine con cui i parametri sono visualizzati è:
layer 1 weight
layer 1 bias
layer 2 weight
layer 2 bias
layer 3 weight
layer 3 bias

In [36]:
list(MyNN.parameters())
Out[36]:
[Parameter containing:
 tensor([[-0.9169, -0.7815, -0.4430],
         [ 0.3783, -0.2287, -0.7817],
         [-0.6621,  0.5824, -0.6628]], requires_grad=True),
 Parameter containing:
 tensor([ 0.5573, -0.3013, -0.0453], requires_grad=True),
 Parameter containing:
 tensor([[ 0.5907,  0.8831, -0.0578],
         [-0.7613,  0.0973,  0.6890],
         [-0.9343,  0.4026, -0.5058]], requires_grad=True),
 Parameter containing:
 tensor([-0.0814,  0.2346, -0.0710], requires_grad=True),
 Parameter containing:
 tensor([[ 0.3759, -0.0076,  0.7957],
         [ 0.0768,  0.8859,  0.5748],
         [-0.8592,  0.2056,  0.5961]], requires_grad=True),
 Parameter containing:
 tensor([-0.0554, -0.4490, -0.1941], requires_grad=True)]

Autograd: differenziazione automatica

Una cosa che mi da parecchi giramenti di testa nell’uso di Pytorch è la gestione dei gradienti. Vediamo di capirci qualcosa insieme.

Innanzitutto notiamo che se definiamo un tensore questo non ha nessun attributo require_grad, solamente le sue componenti numeriche. Per esempio:

In [37]:
tens1 = torch.Tensor([2,2,3,4,5])
tens1
Out[37]:
tensor([2., 2., 3., 4., 5.])

Questo è un tensore contenente esclusivamente dati.

Se voglio che il gradiente sia calcolato rispetto a questo tensore devo dichiararlo esplicitamente:

In [38]:
tens1.requires_grad = True
#oppure
tens1.requires_grad_(True)
Out[38]:
tensor([2., 2., 3., 4., 5.], requires_grad=True)

Adesso ho il nuovo attributo requires_grad per tens1:

In [39]:
tens1
Out[39]:
tensor([2., 2., 3., 4., 5.], requires_grad=True)

In alternativa, avrei potuto definire tutto in un comando. Si noti che devo definire il dtype del tensore come float per settare l’attributo requires_grad

In [40]:
torch.tensor([1,2,3,4,5], dtype=torch.float, requires_grad=True)
Out[40]:
tensor([1., 2., 3., 4., 5.], requires_grad=True)

Oppure posso lasciare che il dtyte sia definito automaticamente, usando la notazione numero puntato.

In [41]:
torch.tensor([1.,2.,3.,4.,5.], requires_grad=True)
Out[41]:
tensor([1., 2., 3., 4., 5.], requires_grad=True)

Se lascio che il dtype venga automaticamente settato come integer, ottengo un messaggio di errore.

In [42]:
torch.tensor([1,2,3,4,5], requires_grad=True)
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
<ipython-input-42-08fff2ad484a> in <module>
----> 1 torch.tensor([1,2,3,4,5], requires_grad=True)

RuntimeError: Only Tensors of floating point dtype can require gradients

 

Definiamo una ipotetica funzione errore tra tens1 e un tensore di riferimento (tens_r):

In [43]:
tens1 = torch.tensor([1,2,3,4,5], dtype=torch.float, requires_grad=True)        #definisco un tensore dei dati
tens_r = torch.Tensor([1,1,3,4,5])          #definisco il tensore di riferimento

Err_function = torch.mean(tens1 - tens_r)   #definisco la funzione errore come media della differenza tra i due tensori
Err_function
Out[43]:
tensor(0.2000, grad_fn=<MeanBackward0>)

Della funzione errore sopra definita posso calcolare i gradienti usando la funzione backward(). Questa è automaicamente definita da PyTorch.
Nel caso in cui la funzione di cui si voglia calcolare il gradiente è uno scalare, la funzione backward() può essere eseguita senza alcun argomento.

In [44]:
Err_function.backward()

Adesso posso finalmente vedere il valore del gradiente della funzione errore rispetto ad ognuno dei parametri di tens1.

In [45]:
tens1.grad
Out[45]:
tensor([0.2000, 0.2000, 0.2000, 0.2000, 0.2000])

Perché ottengo il valore 0.2 per ogni componente del tensore?

Scriviamo il tensore tens1 come $tens_1 = [w_1, w_2, w_3, w_4, w_5]$ e il tensore di riferimento come $tens_r = [r_1, r_2, r_3, r_4, r_5]$

Scrivendo la funzione errore come
\begin{equation*}
Err\_function(w_1,w_2,w_3,w_4,w_5) = \frac{\sum_{i=1}^5{(w_i – r_i)}}{5} = \frac{ (w_1 – r_1) + (w_2 – r_2) + (w_3 – r_3) + (w_4 – r_4) + (w_5 – r_5) }{5}
\end{equation*}

calcolo adesso le derivate parziali rispetto a $w_i$
\begin{equation*}
\frac{\partial Err\_function}{\partial w_{i}} = \frac{1}{5} = 0.2 \hspace{1cm} \forall i
\end{equation*}

Queste derivate parziali sono le componenti del gradiente. E coincidono con il valore calcolato da PyTorch con tens1.grad

Da notare ancora che la definizione di autograd come package per la differenziazione automatica si riferisce al fatto che non bisogna specifica quali derivate generare, ma queste vengono calcolate automaticamente usando la funzione backward() una volta definita la funzione e le variabili rispetto alle quali è richiesta la derivata parziale.

Nota. La funzione errore sopra utilizzata, ha il pregio di rendere i calcoli semplici. In realtà come funzione errore non va bene, perché ciascun contributo $(w_i-r_i)$ può avere segno positivo o negativo. In questo modo un contributo positivo (per esempio +5) e uno negativo (per esempio -5) darebbero un errore netto nullo. Cioè darebbero l’impressione di fittare pefettamente i samples, cosa che ovviamente non è.

Riferimenti

  1. Pytorch.org – AUTOGRAD: AUTOMATIC DIFFERENTIATION
  2. Dal sito dell’Università di Stanford: Introduction to Pytorch Code Examples
  3. Un’ampia collezione di esempi di programmi con PyTorch: Github bharathgs

Leave a Reply

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>