Costruire un piccolo LLM in Go, Parte 3: Cosa significa davvero imparare
La Parte 2 si è conclusa davanti a un vicolo cieco. Il semplice conteggio non è scalabile, poiché una tabella capace di ricordare un contesto reale sarebbe più grande dell’universo stesso. La via d’uscita consisteva in una promessa: sostituire quella tabella gigantesca con un piccolo gruppo di numeri, chiamati pesi, in grado di calcolare una previsione e generalizzare su contesti mai visti prima. Questo capitolo affronta l’unica vera difficoltà posta da tale gruppo di numeri. Ce ne sono migliaia, all’inizio sono solo valori casuali e bisogna trovare quelli giusti. In che modo?
La risposta rappresenta il cuore pulsante di ogni rete neurale e si compone di tre elementi fondamentali: un metodo per misurare l’errore del modello, un modo per capire quale direzione riduca tale errore e la pazienza di fare un piccolo passo in quella direzione per qualche migliaio di volte. L’addestramento, in fondo, è tutto qui. Costruiamo ciascuno di questi elementi.
L’errore come singolo numero
Non si può migliorare ciò che non si può misurare; per questo motivo, occorre innanzitutto trasformare il concetto di “il modello ha fatto una cattiva previsione” in un numero. Questo valore prende il nome di loss e, più è piccolo, migliore sarà il modello.
Bisogna ricordare l’unica domanda a cui il modello deve rispondere: dato il testo elaborato finora, cosa viene dopo? La risposta non è una singola lettera, bensì un livello di confidenza per ogni lettera possibile: per esempio, una certezza del 60% che si tratti di uno spazio, del 15% che sia una e, e così via per tutti i settanta caratteri a disposizione. Supponiamo ora che la lettera successiva fosse effettivamente una e. Il modello le ha assegnato solo il 15% di probabilità. Non ha commesso un errore assoluto, ma ha mostrato troppa poca sicurezza rispetto alla realtà dei fatti, un comportamento che va penalizzato.
Il metodo standard per calcolare questo punteggio si chiama cross-entropy e, sebbene il nome possa sembrare complesso, l’idea di base è molto semplice. Si osserva la probabilità che il modello ha assegnato al carattere effettivamente apparso. Se tale probabilità è alta, vicina a 1, la loss sfiora lo zero: il modello era sicuro e aveva ragione. Se invece la probabilità è bassa, la loss risulta elevata, indicando che il modello riponeva sicurezza nelle opzioni sbagliate. Facendo la media su molte previsioni, si ottiene un singolo numero che rappresenta quanto il modello sia rimasto sorpreso dalla realtà. L’addestramento è proprio la ricerca dei pesi in grado di minimizzare questa sorpresa.
Nel repository tutto ciò si traduce in una sola funzione, CrossEntropy, e il numero che restituisce è l’unica quantità che l’intero processo di addestramento cerca di abbassare.
Qual è la direzione della discesa
A questo punto sorge la vera domanda. Ci sono migliaia di pesi e un singolo numero, la loss, che dipende da tutti loro. L’obiettivo è modificare i pesi per ridurre questo valore. Tuttavia, non è possibile provare ogni singola combinazione, poiché ce ne sono più degli atomi nell’universo; si tratta dello stesso ostacolo visto nella Parte 2, solo sotto una veste diversa.
Qui entra in gioco il trucco fondamentale, su cui vale la pena soffermarsi perché rappresenta l’essenza dell’intero processo. Per ogni singolo peso, ci si può porre una domanda locale: se aumento leggermente questo specifico peso, la loss sale o scende, e di quanto? Questo numero, ovvero il tasso di variazione della loss al variare di un singolo peso, è chiamato gradiente rispetto a quel peso. Si tratta di una pendenza. Una pendenza positiva significa che aumentare il peso peggiora le cose, quindi bisogna diminuirlo. Una pendenza negativa indica che aumentarlo è utile, pertanto va incrementato. L’entità della pendenza rivela quanto quel particolare peso sia rilevante in quel preciso momento.
Calcolando questa pendenza per ogni peso, si ottiene simultaneamente, per tutte le migliaia di parametri, la direzione che riduce maggiormente la loss. A quel punto si compie un piccolo passo: si sposta ogni peso leggermente in direzione opposta alla sua pendenza. Si riducono quelli dannosi e si aumentano quelli utili, ciascuno in proporzione alla propria importanza. La loss scende un po’. Si ripete l’operazione. E poi di nuovo, per qualche migliaio di volte.
Questa è la discesa del gradiente. L’immagine mentale più efficace è quella di una palla su un paesaggio collinare, dove l’altitudine rappresenta la loss e la posizione attuale corrisponde alla configurazione di tutti i pesi. Il gradiente punta verso la salita; ci si muove verso la discesa, si ripete l’azione e la palla rotola in una valle dove la loss è bassa. Il learning rate determina l’ampiezza del passo. Se è troppo grande, si rischia di rimbalzare oltre la valle mancandola del tutto. Se è troppo piccolo, la discesa richiederà un’eternità.
Quella riga racchiude l’intero aggiornamento: ogni peso si muove in direzione opposta alla propria pendenza , scalata da un learning rate . Qualsiasi rete neurale mai addestrata, comprese quelle costate centinaia di milioni di dollari, esegue questa formula in un ciclo continuo.
Il motore che calcola le pendenze
C’è però un ostacolo, che è poi il motivo per cui esiste questo capitolo. Calcolare la pendenza della loss rispetto a un singolo peso è semplice. Calcolarla rispetto a ogni peso, quando la loss è il risultato finale di una lunga catena di moltiplicazioni, addizioni e compressioni non lineari, è un’operazione meticolosa in cui è disperatamente facile commettere errori se fatta a mano.
Un precedente articolo su questo sito, reti neurali e backpropagation in Go, esegue questo calcolo manualmente per una piccola rete, derivando ogni gradiente con la regola della catena e programmandolo direttamente. Vale la pena leggerlo se si desidera vedere il calcolo matematico completo. Il testo si conclude ammettendo un problema evidente: procedere a mano non è un approccio scalabile. Per un modello reale dotato di attention e di numerosi layer, i gradienti derivati manualmente diventano un incubo contabile, dove un solo errore di segno in qualsiasi punto finisce per avvelenare silenziosamente l’intero sistema.
Di conseguenza, invece di derivare i gradienti a mano, si costruisce una piccola macchina che lo faccia in automatico. Prende il nome di motore di differenziazione automatica, o autograd, e si basa su un’idea molto elegante. Ogni numero all’interno del modello viene incapsulato in un piccolo oggetto che ricorda non solo il proprio valore, ma anche come è stato calcolato: da quali numeri deriva e tramite quale operazione. Mentre il modello elabora la sua previsione, questi oggetti si collegano formando un grafo che registra l’intero calcolo. Successivamente, per ottenere tutti i gradienti, si percorre tale grafo a ritroso partendo dalla loss e, a ogni passaggio, si applica la regola locale specifica per quell’operazione. L’addizione divide la pendenza in parti uguali tra i suoi input. La moltiplicazione la distribuisce in proporzione all’altro fattore. La regola della catena viene applicata meccanicamente, nodo per nodo, fino a risalire ai pesi.
L’intero motore richiede circa un centinaio di righe di codice Go. Il suo nucleo è costituito da un singolo tipo che contiene un valore, uno spazio per il suo gradiente e una closure in grado di spingere quel gradiente all’indietro verso ciò che lo ha generato:
// Tensor is one node in the computation graph. It holds a 2D matrix of values
// (Data) and the gradient of the final loss with respect to each value (Grad).
// This is "micrograd, but the value is a matrix": every operation records a
// backward closure that pushes gradient from this node to the nodes it was
// built from. Backward() runs them in reverse.
type Tensor struct {
Data []float64
Grad []float64
Rows int
Cols int
backward func()
parents []*Tensor
}
La previsione si costruisce combinando i tensori attraverso operazioni come la moltiplicazione e l’addizione di matrici. Ogni operazione, come effetto collaterale, registra le istruzioni per inviare il gradiente all’indietro. Quando infine si chiama Backward() sulla loss, il motore visita ogni nodo in ordine inverso e ogni peso si ritrova con la propria pendenza calcolata, pronto per quella singola riga di aggiornamento vista in precedenza. Nessun calcolo manuale. Il motore esegue la regola della catena in modo corretto, ogni singola volta.
Come si fa ad avere la certezza che sia corretto, considerando che il problema iniziale era proprio l’inaffidabilità dei gradienti calcolati a mano? Si verifica il motore confrontandolo con la realtà. Per qualsiasi peso, è possibile stimare la sua vera pendenza con un approccio di forza bruta: lo si aumenta di un nonnulla, si osserva la variazione della loss e si divide. Se il gradiente del motore e questa pendenza misurata non coincidono, significa che c’è un bug. Ogni operazione presente nel repository include esattamente questo controllo sotto forma di test; ecco perché ci si può fidare della matematica, anche se è stata scritta a mano.
Osservare l’apprendimento
Avendo a disposizione la loss, i gradienti e il ciclo di aggiornamento, è possibile addestrare il primo modello che impara per davvero invece di limitarsi a contare: una piccola rete che prende in ingresso qualche carattere di contesto, li mescola attraverso uno strato di pesi e prevede il carattere successivo. L’intero ciclo di addestramento è breve. Si calcola la previsione, si calcola la loss, si chiama Backward() per riempire ogni gradiente, si fa un passo verso il basso e si ripete.
go run ./cmd/stage3_mlp
step 0 loss 4.2904
step 200 loss 3.9708
step 400 loss 4.4864
step 600 loss 3.4800
step 800 loss 3.4600
step 1000 loss 3.9303
step 1200 loss 3.0483
step 1400 loss 2.4551
step 1600 loss 2.1318
step 1800 loss 2.8701
Quel numero che oscilla e scende rappresenta un modello che impara, in diretta. Parte da circa 4.3, ovvero la loss che si ottiene tirando a indovinare tra settanta caratteri, il valore tipico di un modello che non sa nulla. La discesa non è lineare: a ogni passo il sistema osserva una diversa porzione casuale di testo, quindi la loss subisce sbalzi e a volte torna persino a salire per un tratto. Tuttavia, la tendenza è inequivocabile e nel giro di un paio di migliaia di iterazioni il valore si è quasi dimezzato. Nessuno ha insegnato alcuna regola al modello. È riuscito a trovare, rotolando verso il basso per qualche migliaio di volte, dei pesi che rendono la lingua inglese meno sorprendente rispetto al puro rumore casuale.
Questo è il motore. Tutto ciò che rimane da esplorare in questa serie riguarderà come fornirgli una struttura migliore su cui lavorare. La piccola rete appena addestrata osserva una minuscola finestra fissa di caratteri e li tratta come un ammasso indifferenziato. Non ha modo di notare che in “the cat sat”, è la parola “cat” di tre lettere prima a rendere probabile “sat”, mentre in “the dog ran”, è “dog” a fare la differenza. Non è in grado di permettere ai giusti caratteri precedenti di proiettarsi in avanti per influenzare la previsione.
Dotare il modello di questa capacità, permettendo a ogni posizione di guardare all’indietro e decidere quali posizioni precedenti siano rilevanti, è l’intuizione fondamentale che ha trasformato le reti neurali nei grandi modelli linguistici. Prende il nome di attention, e sarà l’argomento della Parte 4.
Il codice relativo a questa parte si trova in cmd/stage3_mlp, con il motore autograd all’interno di tensor.go e ops.go, su github.com/erubboli/go-tiny-llm.