Costruire un piccolo LLM in Go, Parte 5: unire i pezzi e osservarlo imparare
Abbiamo costruito ogni singola componente. La Parte 1 ha trasformato i caratteri in un modello basato sul conteggio, rivelandone però il difetto fatale: ricorda una sola lettera alla volta. La Parte 2 ha dimostrato che il problema non si risolve semplicemente contando di più. La Parte 3 ha sostituito il conteggio con l’apprendimento vero e proprio: una loss, la gradient descent e un motore di autograd per calcolare le pendenze. La Parte 4 ha introdotto l’attention, permettendo a ogni posizione di guardare all’indietro e raccogliere il contesto di cui ha bisogno. Questo capitolo unisce tutti i pezzi per creare il sistema definitivo, lo addestra su un libro e ne legge i risultati.
Il blocco transformer
Un transformer non aggiunge un’idea inedita a quanto visto nella Parte 4. Si tratta piuttosto della stessa architettura impilata e incapsulata, due volte per strato, con l’aggiunta di due piccoli perfezionamenti che rendono possibile l’addestramento delle reti profonde.
Il primo perfezionamento è la residual connection. Invece di sostituire il proprio input con qualcosa di completamente nuovo, ogni strato aggiunge una correzione all’input e trasmette la somma al livello successivo. L’immagine mentale è quella di una bozza in continua evoluzione, che ogni strato modifica leggermente anziché riscrivere da zero. Questo aspetto è cruciale poiché offre al gradiente un percorso pulito per risalire attraverso i numerosi strati: anche una struttura molto profonda diventa addestrabile, dato che le pendenze non devono sopravvivere a una lunga catena di trasformazioni per raggiungere i pesi iniziali. Il secondo perfezionamento è la normalizzazione, ovvero un ridimensionamento applicato prima di ogni passaggio che mantiene i valori numerici in un intervallo ragionevole, evitando che l’addestramento vada fuori controllo. I modelli moderni utilizzano una versione snella chiamata RMSNorm, e noi faremo altrettanto.
Un singolo blocco, quindi, si articola così: si normalizza, si applica l’attention e si somma il risultato all’input; si normalizza di nuovo, si esegue una piccola rete feed-forward per ogni posizione e si somma anche questo risultato. L’attention permette alle varie posizioni di scambiarsi informazioni lungo l’intera sequenza; la rete feed-forward, successivamente, elabora questi dati a livello di singola posizione. In Go, l’intero blocco si traduce in poche righe di codice operativo:
func (b *Block) Forward(x *Tensor) *Tensor {
// attention sublayer, added back to the input (a residual connection)
x = Add(x, b.attn.Forward(RMSNorm(x, b.norm1)))
// feed-forward sublayer, also added back
ff := AddRow(MatMul(ReLU(AddRow(MatMul(RMSNorm(x, b.norm2), b.w1), b.b1)), b.w2), b.b2)
return Add(x, ff)
}
Il modello completo racchiude una pila di questi blocchi. Converte ogni identificativo di carattere in un vettore, vi somma un secondo vettore che codifica la posizione del carattere (in modo che il modello conosca l’ordine, dato che l’attention da sola non lo percepisce), esegue l’intera pila e proietta i vettori finali per ottenere un punteggio relativo a ogni possibile carattere successivo. Ecco cos’è un transformer. Il nostro è volutamente minuscolo: due strati, quattro attention head, vettori larghi 64 numeri e una finestra di contesto di 128 caratteri. Ciascuno di questi parametri è definito da una singola costante in config.go, permettendo così di rendere il modello più grande e lento, oppure più piccolo e veloce, modificando una sola riga di codice.
L’addestramento su un libro
Il ciclo di addestramento è lo stesso della Parte 3, immutato nella sua essenza. Si prende un frammento casuale del libro. Si chiede al modello di prevedere il carattere successivo per ogni posizione contemporaneamente. Si calcola la loss. Si chiama Backward() per calcolare tutti i gradienti attraverso l’intera pila, attention compresa. Si fa un passo verso il basso con l’ottimizzatore. Infine, si ripete il procedimento qualche migliaio di volte.
Il testo incluso è Alice nel Paese delle Meraviglie, ripulito fino a contenere solo i nostri settanta caratteri, per un peso di circa 140 kilobyte. Su un computer portatile, senza l’ausilio di una GPU e senza particolari ottimizzazioni, l’addestramento richiede pochi minuti. È sufficiente eseguire:
go run ./cmd/stage5_train
La loss diminuisce, anche se non in modo lineare. Il valore oscilla da un passaggio all’altro, poiché a ogni iterazione viene analizzato un frammento casuale diverso del libro, ma la tendenza di fondo è inequivocabile:
step 0/3000 loss 4.7355
step 500/3000 loss 2.8512
step 1000/3000 loss 2.6186
step 1500/3000 loss 2.3629
step 2000/3000 loss 2.5237
step 2500/3000 loss 2.5066
step 2900/3000 loss 2.5590
Si parte da 4.74. Si tratta essenzialmente della loss di chi non sa nulla: avendo a disposizione settanta caratteri, tirare a indovinare produce un punteggio di circa 4.25, e un modello appena inizializzato in modo casuale fa un po’ peggio. Nel giro di qualche centinaio di iterazioni il valore scende sotto il 3.0, per poi stabilizzarsi intorno al 2.5. Il modello ha imparato, partendo unicamente da un libro e dai meccanismi costruiti nelle quattro parti precedenti, a essere significativamente meno sorpreso dalla lingua inglese rispetto al puro rumore casuale.
Leggere ciò che sogna
La loss rimane pur sempre un numero. La vera prova del nove consiste nel lasciare che il modello scriva, per poi leggere ciò che produce. La generazione del testo riprende il ciclo della Parte 1: si prevede il carattere successivo, se ne estrae uno basandosi sui livelli di confidenza del modello, lo si aggiunge in coda, si reinserisce il testo così accresciuto e si ripete l’operazione. Di seguito è riportato un campione reale di 400 caratteri, senza alcuna modifica:
te heaide qus t, s jesong ant gete Aln suhoke thengplils haso t ber
wonor re finrhese t whekeno, ayouthe ghem. ok! cbe ureum!
lrider, tergrsnoumpend weanor.
Wowol cotherentosas, bo aisth?
Afaste fe t wiongshe
cuced stis ite
Ibhen, Rk ve sain
I l ofocollin.
! n whe Alede s to s opo ay ithey be t lely tathat Alit hero fithe y, ts icle.
les she sarye, dswAlerof anore s I aw iryorly.
Si tratta di un testo senza senso. Eppure è, in modo evidente, un non-senso che ha assimilato l’inglese. La lunghezza delle parole è corretta. Le combinazioni di lettere ricalcano quelle usate nella lingua. Compaiono parole reali come “she”, “to”, “be”, “hero”; termini come “anore” che tentano di avvicinarsi a “another”; ed echi del testo originale, con “Aln”, “Alede” e “Alit” che rappresentano i tentativi del modello di formulare “Alice”. Ha imparato a iniziare una riga, a inserire gli spazi alla fine delle parole e persino a spargere un po’ di punteggiatura. Scrive esattamente come si scriverebbe in un sogno, dove ogni parola sembra perfetta finché non si cerca di leggerla.
Basta confrontare questo risultato con il punto di partenza della Parte 1, ossia il modello di conteggio in grado di guardare indietro di una sola lettera. Entrambi producono frasi prive di senso, ma falliscono in modi diversi. Il bigramma vagava alla cieca perché dimenticava tutto ciò che precedeva l’ultimo carattere. Questo sistema, invece, non dimentica: possiede 128 caratteri di contesto e l’attention necessaria per esplorarli. Ciò che gli manca non è la memoria, ma la scala. È un modello a due strati con qualche decina di migliaia di pesi, addestrato per pochi minuti su un unico, breve libro.
L’unica cosa che cambia
Ed è questa la conclusione più sincera, quella su cui vale la pena riflettere. La differenza tra questo programma e il modello alla base di Claude o GPT non è una differenza di natura. Si tratta della stessa ricerca del carattere successivo, della stessa loss, della stessa gradient descent, della medesima attention costruita a mano nella Parte 4, dello stesso motore di autograd della Parte 3 che verifica le proprie pendenze confrontandole con la realtà. Tutto ciò che è essenziale si trova nelle circa milleduecento righe di codice Go che ora è possibile leggere da cima a fondo.
Ciò che separa il nostro giocattolo sognante da un sistema capace di scrivere codice funzionante è unicamente la scala, e nient’altro a livello concettuale. Più strati. Vettori più ampi. Una finestra di contesto di centinaia di migliaia di caratteri invece di 128. Token che rappresentano frammenti di parole anziché singole lettere. Un addestramento che non si limita a un libro per pochi minuti, ma che divora gran parte di internet per mesi, sfruttando migliaia di GPU. Ognuno di questi elementi rappresenta un problema ingegneristico legato alle dimensioni, non un’idea nuova. L’idea fondamentale è quella che è stata appena costruita e vista in azione.
Ecco cos’è un large language model. È esattamente questo, ma reso enorme. Non esiste alcun segreto nascosto. La macchina che ha sorpreso il mondo con la sua capacità di scrivere è, nella sua essenza, un indovinatore della lettera successiva diventato immensamente grande. E la sua versione in miniatura è stata appena costruita con le proprie mani.
Il codice completo e funzionante si trova su github.com/erubboli/go-tiny-llm: un transformer a livello di carattere scritto in Go, utilizzando unicamente la libreria standard, abbastanza piccolo da poter essere letto in un pomeriggio. È sufficiente clonarlo, modificare una costante, fornirgli un testo a scelta e osservarlo sognare.