Costruire un LLM in miniatura in Go, Parte 1: Predire la lettera successiva
Ecco un programma che ha letto un libro e ha imparato a scrivere. Questo è il suo output, generato un carattere alla volta:
the. Tho Whele wre ig ousad thelyilertousasuty thangle the are
hin whadouthe wad wag ss werst stheane tiot, she sar rir, s, s y e
A prima vista non ha alcun senso, ma a un’osservazione più attenta si nota qualcosa. Gli spazi cadono in posizioni plausibili. Le coppie di lettere sono quelle effettivamente in uso nella lingua inglese: the, she, are, wag. Non c’è traccia di combinazioni impossibili come qz o wkx. A questo programma non è mai stato spiegato cosa sia una parola, non ha mai consultato un dizionario né ha mai ricevuto lezioni di grammatica. Ha fatto una sola, semplice cosa: ha contato.
Questo conteggio è il seme da cui nasce ogni modello linguistico di grandi dimensioni. GPT e Claude, nella loro essenza, eseguono una versione più elaborata di ciò che fa questo programma di cinquanta righe. Nel corso dei prossimi cinque articoli costruiremo proprio questa versione elaborata, in Go, utilizzando esclusivamente la libreria standard, per poi pubblicare il risultato completo come repository pronto da eseguire. Il punto di partenza più onesto, tuttavia, è proprio questo: il modello più rudimentale che in qualche modo già funziona. Una volta compreso cosa riesce a fare e qual è il suo unico, grande limite, il resto della serie consisterà semplicemente nel risolvere quel difetto.
Chi ha già letto l’articolo precedente su reti neurali e backpropagation in Go possiede già gli strumenti per affrontare le parti successive. Per ora, in ogni caso, non servirà nulla di tutto ciò. La prima parte si basa esclusivamente sul contare.
L’unica domanda a cui risponde un modello linguistico
Se si elimina tutto il superfluo, un modello linguistico risponde a un’unica domanda, all’infinito: dato il testo prodotto finora, cosa viene dopo?
Questo è il suo intero compito. Fornendogli la frase “il gatto è seduto sul ”, un buon modello assegnerà un’alta probabilità a “tappeto” e una probabilità quasi nulla a “elicottero”. Per scrivere una frase, si pone la domanda, si prende la risposta, la si aggiunge al testo e si formula nuovamente la domanda. Il modello non pianifica mai una frase a priori: si limita a indovinare il tassello successivo, e l’unione di questi tasselli finisce per assomigliare al linguaggio umano.
In questo caso si lavorerà un carattere alla volta, anziché una parola alla volta. La domanda, quindi, diventa più circoscritta: data la lettera appena scritta, quale lettera viene dopo? Un modello capace di rispondere a questo interrogativo prende il nome di modello a bigrammi, dove “bigramma” significa semplicemente “due lettere di fila”.
Contare è già un modello
Prendendo un mucchio di testi in inglese e scorrendoli dall’inizio alla fine, si può tenere il conto per ogni coppia di caratteri adiacenti. Quante volte la h segue la t? Quante volte la e segue la h? Quante volte uno spazio segue la e?
Dopo qualche centinaio di kilobyte di testo, si ottiene una grande tabella. C’è una riga per ogni carattere, una colonna per ogni carattere, e ogni cella contiene un conteggio: la frequenza con cui il carattere della colonna ha seguito il carattere della riga. La riga della t presenterà un numero elevato nella colonna della h, poiché l’inglese è pieno di “th”. La riga della q avrà quasi tutto concentrato nella colonna della u, dato che la q è quasi sempre seguita dalla u.
Quella tabella è il modello. Per prevedere cosa segue la t, basta guardare la riga corrispondente e verificare quali caratteri sono comparsi più spesso. Per generare del testo, si procede allo stesso modo, ma introducendo un’estrazione casuale pesata in base ai conteggi: se la h ha seguito la t novecento volte e la e l’ha seguita cento volte, in nove casi su dieci verrà scelta la h.
Non c’è alcun addestramento nel senso comune del termine, né alcuna ottimizzazione in corso. Si fa una singola passata sul testo, si aggiunge uno a una cella ogni volta che si incontra una coppia, e il gioco è fatto.
Tutto questo in Go
Per prima cosa occorre trasformare i caratteri in numeri, poiché una tabella è indicizzata tramite numeri interi, non tramite lettere. Questo è il compito di un tokenizer, e a livello di singolo carattere è un’operazione quasi banale: si assegna un identificativo a ogni carattere consentito e si mantiene una mappa per l’operazione inversa. Il nostro intero vocabolario è composto da circa settanta caratteri (le lettere, le cifre, lo spazio e un po’ di punteggiatura), quindi l’id 0 potrebbe corrispondere alla a, l’id 1 alla b, e così via.
Fatto questo, il modello di conteggio si riduce a una tabella quadrata e due cicli iterativi. Ecco il codice reale tratto dal repository, senza alcuna modifica:
// Bigram is the simplest possible language model: a table counting how often
// each character follows each other character. No learning, no math, just
// tallies. It already "works", which is the surprise. Its flaw: it only ever
// looks one character back.
type Bigram struct {
counts [][]float64 // counts[a][b] = times b followed a
size int
}
func (b *Bigram) Train(ids []int) {
for i := 0; i+1 < len(ids); i++ {
b.counts[ids[i]][ids[i+1]]++
}
}
Train rappresenta l’intera procedura di apprendimento. Si scorre il testo e, per ogni carattere alla posizione i, si aggiunge uno alla cella che indica che “il carattere alla posizione i+1 ha seguito il carattere alla posizione i”. Tutto qui. Il ciclo di addestramento di un modello linguistico in produzione gira per settimane su migliaia di macchine; questo, invece, viene eseguito nel tempo necessario a leggere un file.
La generazione del testo è l’esatto opposto. Si parte da un carattere qualsiasi, si osserva la sua riga di conteggi e si sceglie il carattere successivo con una probabilità proporzionale a tali conteggi:
// Sample generates n characters starting from `start`, each drawn in proportion
// to how often it followed the previous character in the training text.
func (b *Bigram) Sample(rng *rand.Rand, start, n int) []int {
out := make([]int, 0, n)
cur := start
for i := 0; i < n; i++ {
nxt := b.sampleRow(rng, b.counts[cur])
out = append(out, nxt)
cur = nxt
}
return out
}
La parte “proporzionale ai conteggi” equivale a un’estrazione pesata: si sommano i valori della riga, si sceglie un punto casuale lungo quel totale e si scorre la riga finché non si atterra su un carattere. I successori più comuni vincono la maggior parte delle volte, quelli rari vincono di tanto in tanto, e i caratteri che non sono mai apparsi dopo cur non appariranno mai nemmeno in questa fase. È proprio quest’ultima proprietà a spiegare perché nell’output non ci sia alcun qz: se la coppia non si è mai presentata nel libro, il suo conteggio è zero, e i caratteri con conteggio pari a zero non possono mai essere selezionati.
È possibile testare il codice in prima persona. Il repository include già il testo necessario:
go run ./cmd/stage1_bigram
Cosa ha indovinato, e l’unica cosa che non sa fare
Tornando all’esempio iniziale (the. Tho Whele wre ig ousad), si nota che il modello, basandosi puramente sul conteggio, ha scoperto una quantità sorprendente di informazioni:
- Frequenze delle lettere. Utilizza costantemente
e,t,a,oe quasi maiz,q,x, rispecchiando perfettamente l’inglese. - Coppie plausibili. Ogni combinazione di due lettere prodotta è effettivamente presente nel testo. Non scrive mai
bkofq. - Ritmo simile a quello delle parole. Gli spazi cadono a intervalli credibili, per cui il risultato si divide in blocchi che hanno la lunghezza di parole reali.
È un risultato notevole per un programma che ignora del tutto il concetto di parola. Eppure, non scriverà mai una frase di senso compiuto, e il motivo risiede proprio nella sua unica riga di logica. Osservando di nuovo la funzione Sample, si nota che per scegliere il carattere successivo il codice guarda a b.counts[cur], e cur corrisponde sempre e solo all’unico carattere appena generato. L’intera memoria del modello, rispetto a tutto ciò che ha scritto, si riduce a una singola lettera.
Di conseguenza, quando scrive t, sa che la h la segue spesso, e potrebbe effettivamente scrivere h. A quel punto, però, la sua memoria diventa h, e la t è già stata dimenticata. Non può sapere di aver appena composto due terzi della parola “the”. Non può sapere di trovarsi a metà di una parola, di una frase o di una citazione. Ogni decisione viene presa da un modello affetto da amnesia, che ricorda solo l’ultima azione compiuta.
Ecco perché l’output è localmente plausibile ma globalmente privo di senso. Ogni singola coppia di lettere è corretta, ma messe in fila vagano senza meta, poiché non c’è nulla che porti avanti il contesto.
Qui si nasconde la trappola, ed è il motivo stesso per cui esiste questa serie di articoli. La soluzione più ovvia sembrerebbe essere quella di ricordare più di una lettera. Invece di chiedersi “cosa segue la t”, basterebbe chiedersi “cosa segue sa”, oppure “cosa segue il ga”. Mantenendo una tabella più grande e guardando più indietro nel testo, la qualità della generazione dovrebbe indubbiamente migliorare.
E in effetti migliora. Tuttavia, molto rapidamente, diventa anche un’impresa impossibile. Nella Parte 2 si proverà ad applicare esattamente questa correzione: la si vedrà funzionare, per poi assistere all’esplosione delle dimensioni della tabella, che supererà il numero di atomi del corpo umano prima ancora di essere riusciti a memorizzare una breve frase. Questa esplosione dimensionale è il muro contro cui si scontra l’approccio basato sul conteggio; superarlo segna il momento esatto in cui un modello smette di memorizzare e inizia, finalmente, a imparare.
Il codice completo di questa parte, e di tutte quelle che seguiranno, è disponibile su github.com/erubboli/go-tiny-llm. La Parte 1 si trova in cmd/stage1_bigram.