Come creare una RNN da zero con PyTorch

Da Wiki AI.
Versione del 19 mar 2024 alle 08:51 di Lucia Terenzi (discussione | contributi)
(diff) ← Versione meno recente | Versione attuale (diff) | Versione più recente → (diff)

Obiettivo

Proviamo a creare una Recurrent Neural Network (RNN) che, dato un carattere tra a,b,c,d, lo continui secondo il pattern: 'aaaaabbbbbccccacdddddaaaa'.

Qui trovi il Colab!

Importiamo le librerie:

import torch 
import torch.nn as nn 
import torch.optim as optim

sequence = 'aaaaabbbbbccccacdddddaaaa'
  • torch: il core di PyTorch, per operazioni tensoriali e autograd.
  • torch.nn: modulo per costruire reti neurali in PyTorch.
  • torch.optim: modulo per gli ottimizzatori, usato per aggiornare i pesi della rete durante l'addestramento.

Mappatura Caratteri-indici:

# Funzioni di utilità per convertire da carattere a intero e viceversa

chars = list(set(sequence))
char_to_ix = {ch: i for i, ch in enumerate(chars)} # {'b': 0, 's': 1, 'c': 2, 'd': 3, 'a': 4}
ix_to_char = {i: ch for i, ch in enumerate(chars)} # {0: 'b', 1: 's', 2: 'c', 3: 'd', 4: 'a'}

Questa porzione di codice va a creare due dizionari per convertire i caratteri in indici e viceversa.

Questo è utile per manipolare i caratteri in forma numerica, facilitando le operazioni matematiche sui dati. char_to_ix mappa ogni carattere unico a un indice intero univoco, e ix_to_char fa l'opposto.

Funzione di Conversione in One-Hot

Creiamo dei Tensori per codificare i caratteri sia di input che di addestramento in one-hot encodings:

def char_to_onehot(char):
   tensor = torch.zeros(len(chars))
   tensor[char_to_ix[char]] = 1
   return tensor


tensor([[0., 0., 0., 0., 1.],

        [0., 0., 0., 0., 1.],

        [0., 0., 0., 0., 1.],

Prepariamo l'input

Creiamo una lista di M (numero di CAMPIONI di training) x N (Numero di token nel vocabolario one-hot)

inputs = torch.stack([char_to_onehot(ch) for ch in sequence[:-1]])

Quello che fa questa riga di codice in breve è richiamare la funzione che converte gli elementi della sequenza in one-hot, prendere tutti gli elementi della sequenza meno l'ultimo carattere e convertirli quindi in one hot. Torch.stack

  1. sequence[:-1]: Questo prende tutti i caratteri della sequenza tranne l'ultimo. Se la sequenza fosse "abcde", diventerebbe "abcd". Questo è perché si vuole prevedere il carattere successivo in ogni punto della sequenza, quindi l'ultimo carattere non può essere usato come input.
  2. char_to_onehot(ch): Per ogni carattere nella sequenza troncata, questa funzione lo trasforma in un vettore "one-hot". Immagina di avere un alfabeto di solo 5 lettere (a, b, c, d, e). La lettera "a" potrebbe essere rappresentata come [1, 0, 0, 0, 0], la "b" come [0, 1, 0, 0, 0], e così via. Ogni carattere diventa un vettore dove un "1" indica quel carattere, e tutto il resto è "0".
  3. torch.stack([...]): Dopo aver trasformato ogni carattere in un vettore one-hot, torch.stack mette tutti questi vettori uno sopra l'altro in un unico array (o tensore), creando una sorta di "lista di liste" ma in forma di tensore, che è il formato richiesto per lavorare con PyTorch. Tutti i tensori per essere concatenati in un'unico tensore devono avere la stessa dimensione.

Quando si usa CrossEntropyLoss di PyTorch, l'output atteso è l'indice della classe target, non la rappresentazione one-hot.

CrossEntropyLoss combina LogSoftmax e NLLLoss (Negative Log Likelihood Loss) in un'unica classe. Questa funzione si aspetta Logit non normalizzati come input (cioè, l'output grezzo del modello prima dell'applicazione di una funzione di attivazione come Softmax) e l'indice della classe come target. PyTorch gestisce internamente la conversione degli indici in una rappresentazione one-hot e l'applicazione della Softmax per calcolare la perdita, il che rende l'uso di CrossEntropyLoss più efficiente dal punto di vista computazionale rispetto al calcolo manuale di Softmax seguito da una funzione di perdita come NLLLoss su output one-hot.

targets = torch.tensor([char_to_ix[ch] for ch in sequence[1:]], dtype=torch.long)

tensor([4, 4, 4, 4, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 1])

Inizializzazione dell'Architettura

class RNN (nn.Module):

    def __init__(self, input_size, hidden_size, output_size):
        super(RNN, self).__init__()
        self.hidden_size = hidden_size

Qui definiamo la struttura della rete, ereditando tutti i metodi e gli attributi del framework nn.Module (che abbiamo importato prima con "import torch.nn as nn") e consentendo da lì di creare una rete personalizzata.

Input size è la dimensione dell'input, hidden size la dimensione del layer "nascosto" (più grande è più c'è spazio di apprendimento).

Il termine super infatti, si riferisce a una funzione incorporata in Python che viene utilizzata per dare accesso ai metodi della classe genitore (o superclasse) da una classe derivata (o sottoclasse). Quando definisci una classe che eredita da un'altra classe, potresti voler estendere o modificare il comportamento di alcuni metodi della classe genitore nella tua sottoclasse. Per fare ciò, spesso devi chiamare il metodo della classe genitore all'interno del metodo della tua classe. Qui entra in gioco super().

        self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, output_size)
    def forward(self, x, hidden):

hidden lo aspettiamo sempre come (batch_size, seq_len, features) che in questo caso "unario" è [1,1,hidden_size].

X ce lo aspettiamo egualmente come (batch_size, seq_len, input_size) che diventa [1,1,one-hot-size] nel caso di batch 1 e seq 1.

        out, hidden = self.rnn(x, hidden)

Passiamo direttamente a un layer totalmente connesso che darà in output i LOGIT siccome per ogni sequenza (che qui è da "uno") vogliamo sempre prendere l'ultimo step facciamo quest'operazione di slicing.

Ricordando che out ritorna il formato (batch_size, seq_len, output_size (onr-hot size) out[:, -1, :] significa:

  • per ogni esempio nel batch (qui è da uno)
  • prendi l'ultimo output emesso dalla RNN (-1)
  • in tutte le sue componenti/faetures (: output vector size - one hot size)
        out = self.fc(out[:, -1, :])  

Out contiene ora i LOGIT in formato: (batch_size, output_size).

Ad esempio -0.3382, 0.2435, -0.4452, 0.4239, 0.1288

       return out, hidden
   def init_hidden(self):
       return torch.zeros(1, 1, self.hidden_size)

hidden ha formato (batch_size, seq_len, dimensione hidden), qui (1,1,hidden)

Model instantiation

n_hidden = 128
model = RNN(len(chars), n_hidden, len(chars))

Loss and optimizer

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.005)

Addestramento

In PyTorch, per ogni epoch (o mini batch) durante la fase di addestramento, vogliamo azzerare esplicitamente i gradienti prima di iniziare a fare la backpropagation (cioè, aggiornare i pesi e i bias) perché PyTorch accumula i gradienti su passaggi backward successivi.

L'azione predefinita è stata impostata per accumulare (cioè sommare) i gradienti ad ogni chiamata di loss.backward().

Per questa ragione, quando inizi il tuo ciclo di addestramento, dovresti azzerare i gradienti in modo che si possa fare aggiornare dei parametri correttamente. Altrimenti il gradiente sarebbe una combinazione del vecchio gradiente, che hai già utilizzato per aggiornare i parametri del tuo modello, e del nuovo gradiente calcolato. Di conseguenza, questo punterebbe in una direzione diversa da quella intesa verso il minimo (o massimo, in caso di obiettivi di massimizzazione).

for epoch in range(1000):
    hidden = model.init_hidden()
    model.zero_grad()
    loss = 0

Inputs.size(0) è il numero di caratteri nella stringa di input: 20

    for i in range(inputs.size(0)):

inputs[i] è un tensore tensor([0., 0., 0., 0., 1.]), quindi torch.Size([5]). Siccome la rete si aspetta (1,1,5) dobbiamo usare unsqueeze due volte che aggiunge le dimensioni "batch size" e "seq length" da [input_size] a [1, 1, input_size]

        input_tensor = inputs[i].unsqueeze(0).unsqueeze(0)
        output, hidden = model(input_tensor, hidden)

Anche per il calcolo della loss dobbiamo usare unsqueeze una volte per aggiungere la dimensione "batch size" da [1, one-hot size] a [1, 1, one-hot size]

        loss += criterion(output, targets[i].unsqueeze(0))
    loss.backward()
    optimizer.step()
    if epoch % 100 == 0:
        print(f'Epoch: {epoch}, Loss: {loss.item()/inputs.size(0)}')

Funzione per generare una sequenza in maniera autoregressiva

def generate_seq(start_char='a', length=20):
     hidden = model.init_hidden()
     input = char_to_onehot(start_char)

Questa è la lista di caratteri in output

    output_seq = start_char
    for i in range(length-1):

Anche qui unsqueeze:

        input_tensor = input.unsqueeze(0).unsqueeze(0)
        output, hidden = model(input_tensor, hidden)

Prendiamo direttamente la posizione col valore massimo dei logit

        predicted_char_idx = torch.argmax(output).item()
        predicted_char = ix_to_char[predicted_char_idx]

Accodiamo il carattere alla sequenza

        output_seq += predicted_char

Usiamo l'ultimo carattere predetto come input al prossimo giro

        input = char_to_onehot(predicted_char)
    return output_seq

Generating a sequence

print(generate_seq('a', 25))