Salta al contenuto principale

64 - Generare testo carattere per carattere con una LSTM

Costruisci una LSTM che scrive testo carattere per carattere: baseline, perplessità, bit per carattere e l'effetto della temperatura sul testo generato.

Cosa ne pensi di questo articolo?

64 - Generare testo carattere per carattere con una LSTM
Machine Learning Engineerfedericocalo.dev

In Breve

Costruisci una LSTM che scrive testo carattere per carattere: baseline, perplessità, bit per carattere e l'effetto della temperatura sul testo generato.

Un modello di linguaggio è una rete che, dato un testo scritto fin qui, assegna una probabilità a ciò che può venire dopo. Se la domanda è «qual è il prossimo carattere?», si parla di modello a livello di carattere (char-level). È il modo più piccolo possibile di toccare con mano la generazione di testo: niente dizionari di parole, niente tokenizzazione, solo lettere, spazi e punteggiatura. In questa lezione costruisci da zero un modello del genere con una LSTM a due strati, lo addestri su un corpus di testo tecnico in inglese, lo valuti contro baseline semplici con numeri veri e lo fai scrivere a tre «temperature» diverse. Alla fine capirai perché il testo che esce è plausibile nella forma e privo di senso nel contenuto, e cosa significa davvero «un modello di linguaggio».

Le parole di questa lezione

Il vocabolario è l'insieme dei simboli distinti che il modello conosce: nel nostro corpus sono 141 caratteri (lettere, cifre, spazio, punteggiatura e qualche simbolo non ASCII), e ognuno ha un numero intero, il suo indice. L'embedding è una tabella di numeri appresi con una riga per carattere: dato l'indice restituisce un vettore (qui di 64 numeri), cioè una rappresentazione densa e modificabile al posto di un'etichetta senza significato. Per ogni carattere successivo possibile la rete produce un punteggio grezzo, il logit, e la softmax trasforma i punteggi in probabilità positive che sommano a 1.

L'entropia incrociata (cross-entropy) è la perdita −log p, dove p è la probabilità che il modello ha assegnato al carattere che davvero c'era: è piccola se il modello era sicuro e aveva ragione, grande se era sicuro e aveva torto. La media sul testo si misura in nat (logaritmo naturale) oppure, dividendo per ln 2, in bit per carattere. La perplessità è exp(entropia incrociata in nat) e si legge come «fra quanti caratteri equiprobabili sta esitando il modello, in media»: una perplessità di 5 vuol dire esitare come se si scegliesse a caso fra 5 caratteri.

Il campionamento consiste nell'estrarre a caso il carattere successivo secondo le probabilità del modello, invece di scegliere sempre il più probabile; la temperatura regola quanto casuale. Infine il teacher forcing: in addestramento, a ogni posizione si dà alla rete il carattere vero precedente e non quello che avrebbe predetto lei. Non gli daremo un codice a parte, perché è già nel modo in cui costruiamo ingresso e bersaglio.

L'analogia: il completamento automatico di chi ha letto un milione di righe

Immagina un lettore che ha divorato un milione di righe di testo italiano senza mai capirne il significato, ma che a forza di vederle ha un «orecchio» per la forma: sa che dopo «Il modell» quasi certamente viene «o», che dopo un punto e uno spazio viene una maiuscola, che «zx» non si trova mai. Ora gli leggi l'inizio di una frase e gli chiedi di continuare lettera per lettera, pescando ogni volta una lettera a caso ma pesata da ciò che gli «suona» giusto. Otterrai testo che suona italiano e che non vuol dire niente. È esattamente ciò che fa il nostro modello, con una differenza tecnica: il suo «orecchio» non guarda solo l'ultima lettera ma, grazie allo stato della LSTM, un riassunto di tutto ciò che ha letto prima.

Modello di linguaggio a livello di carattere: a ogni posizione una LSTM legge il carattere corrente, aggiorna lo stato e predice il carattere successivo; il bersaglio è l'ingresso spostato di una posizione Character-level language model: at every position, predict the next character c LSTM i i LSTM a a LSTM o o LSTM ␣ ␣ LSTM m m LSTM o input target the target is the input shifted by one position; the orange arrow is the state (h, c) carried along
Modello di linguaggio a livello di carattere: a ogni posizione una LSTM legge il carattere corrente, aggiorna lo stato e predice il carattere successivo; il bersaglio è l'ingresso spostato di una posizione

Il disegno mostra il trucco che rende l'addestramento efficiente. Se la finestra di input è c i a o ␣ m, il bersaglio è i a o ␣ m o: la stessa sequenza spostata di una posizione. In un solo passaggio la rete fa sei previsioni, una per posizione, e ognuna ha la sua perdita. Non occorre costruire sei esempi separati. La freccia arancione è lo stato (h, c) che passa da una posizione all'altra: grazie a lui, la previsione in terza posizione tiene conto di c e i, non solo di a.

La matematica: probabilità di un testo come prodotto

La regola della catena della probabilità dice che la probabilità di un testo intero è il prodotto delle probabilità di ogni carattere dato quelli prima:

P(c_1, c_2, …, c_T) = P(c_1) · P(c_2 | c_1) · P(c_3 | c_1, c_2) · … · P(c_T | c_1 … c_{T−1})

Il modello impara a stimare ciascun fattore P(c_t | c_1 … c_{t−1}): è proprio ciò che la LSTM calcola, perché il suo stato h_{t−1} riassume c_1 … c_{t−1}. In pratica, a ogni passo:

e_t   = Embedding[c_t]                    (vettore di 64 numeri)
h_t   = LSTM(e_t, h_{t−1}, c_{t−1})       (stato di 128 numeri)
l_t   = W · h_t + b                       (141 logit, uno per carattere possibile)
p_t   = softmax(l_t)                      (141 probabilità)
perdita_t = −log p_t[ c_{t+1} ]           (il carattere vero che segue)

La perdita totale è la media delle perdita_t su tutte le posizioni e tutti gli esempi. Minimizzare la perdita equivale a massimizzare la probabilità che il modello assegna al testo vero: è la stima di massima verosimiglianza.

Un numero di riferimento. Un modello che non sa nulla assegna probabilità uniforme 1/141 a ogni carattere: la sua perdita è ln 141 = 4.949 nat, cioè 7.14 bit per carattere, perplessità 141. Tutto ciò che il modello impara sta nello spazio fra 4.949 e zero.

Il corpus: un testo che puoi riprodurre

Un esperimento di cui non puoi rifare i numeri non è un esperimento. Per questo il corpus non è un file privato: è il testo della documentazione dei costrutti del linguaggio che ogni installazione di Python porta con sé, nel modulo pydoc_data.topics (un dizionario che associa a ogni argomento della guida interattiva, come "for" o "class", il suo testo). Lo concateniamo in ordine alfabetico di chiave e lo salviamo in corpus.txt. È testo inglese tecnico, non italiano, e il suo contenuto dipende dalla versione di Python: i numeri di questa lezione sono ottenuti con Python 3.14.4.

from pydoc_data.topics import topics

text = "\n\n".join(topics[key] for key in sorted(topics))   # sorted keys: same text on every run
with open("corpus.txt", "w", encoding="utf-8") as f:
    f.write(text)
print(len(topics), "topics,", len(text), "characters,", len(set(text)), "distinct")
80 topics, 577618 characters, 141 distinct

Il programma stampa 80 argomenti, 577.618 caratteri e 141 simboli distinti (lettere, cifre, punteggiatura e qualche carattere non ASCII). Mezzo milione di caratteri è un corpus molto piccolo per un modello di linguaggio, ed è un limite voluto: l'addestramento resta nei minuti su CPU. Il vocabolario è quindi di 141 simboli, e il modello che tira a indovinare fra tutti ha la perdita ln 141 = 4.949 nat già calcolata sopra.

Prima le baseline: quanto è difficile davvero?

Prima di allenare una rete ricorrente conviene sapere quanto si ottiene con modelli molto più semplici. Calcoliamo due baseline sul nostro corpus, valutate sulla parte finale (il 5% di validazione). Il modello a unigrammi assegna a ogni carattere la sua frequenza nel training (le «e» sono più frequenti delle «z»). Il modello a bigrammi assegna a ogni carattere la probabilità di seguire il carattere precedente, contata nel training. In entrambi si aggiunge una piccola costante ai conteggi (smoothing) per evitare probabilità zero per coppie mai viste.

import numpy as np
from collections import Counter

text = open("corpus.txt", encoding="utf-8").read()
cut = int(0.95 * len(text))
train_txt, val_txt = text[:cut], text[cut:]
chars = sorted(set(text))

# Baseline 0: uniform guess over the vocabulary
print("uniform  :", round(np.log(len(chars)), 3), "nats/char")

# Baseline 1: unigram frequencies estimated on the training part
counts = Counter(train_txt)
p1 = {c: (counts[c] + 1) / (len(train_txt) + len(chars)) for c in chars}      # +1 smoothing
loss1 = -np.mean([np.log(p1[c]) for c in val_txt])
print("unigram  :", round(loss1, 3))

# Baseline 2: bigram = "what usually follows the previous character", with smoothing
pair = Counter(zip(train_txt, train_txt[1:]))
prev = Counter(train_txt[:-1])
def p2(a, b):
    return (pair[(a, b)] + 0.1) / (prev[a] + 0.1 * len(chars))
loss2 = -np.mean([np.log(p2(a, b)) for a, b in zip(val_txt, val_txt[1:])])
print("bigram   :", round(loss2, 3))
print("(bits per char = nats / ln 2 ; perplexity = exp(nats))")
uniform  : 4.949 nats/char
unigram  : 3.211
bigram   : 2.349

Il codice usa Counter per contare, calcola la perdita media come −media(log p) sul testo di validazione e prova tre livelli di conoscenza: nessuna (4.949), le frequenze dei singoli caratteri (3.211), la coppia di caratteri (2.349). Il progresso 4.949 → 3.211 → 2.349 mostra che già un solo carattere di contesto toglie molta incertezza. Una LSTM che legge fino a 128 caratteri dovrebbe fare molto meglio di 2.349. Le due esecuzioni dei calcoli sono identiche: non c'è nulla di casuale. Se non lo facesse, ci sarebbe un bug.

Codice: preparare i dati

Il corpus è lo stesso corpus.txt prodotto sopra (577.618 caratteri). Se vuoi provare un testo tuo, qualunque file di testo va bene, ma i numeri di questa lezione valgono solo per quello di pydoc_data.topics: cambiando file cambiano vocabolario, baseline e perdite.

import torch
import torch.nn as nn

text = open("corpus.txt", encoding="utf-8").read()       # the file written by the corpus block above
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}               # character -> integer
itos = {i: c for c, i in stoi.items()}                   # integer -> character
data = torch.tensor([stoi[c] for c in text], dtype=torch.long)

split = int(0.95 * len(data))                            # validation = the LAST 5% of the text
train_data, val_data = data[:split], data[split:]
print(len(text), "characters,", len(chars), "distinct")

def get_batch(source, batch=32, length=128):
    """Random windows. Input: chars [i, i+length). Target: the same window shifted by ONE."""
    starts = torch.randint(0, len(source) - length - 1, (batch,))
    x = torch.stack([source[s:s + length] for s in starts])
    y = torch.stack([source[s + 1:s + length + 1] for s in starts])
    return x, y

Spiegazione. sorted(set(text)) estrae i caratteri distinti in ordine stabile, stoi («string to integer») e itos sono i dizionari di andata e ritorno: vanno salvati insieme al modello, altrimenti i pesi non si possono più usare. data è l'intero testo come lista di indici. La divisione è nel tempo, come per le serie: l'ultimo 5% è validazione, e la validazione non entra mai nell'addestramento. get_batch estrae 32 finestre casuali (64 nella validazione) lunghe 128 caratteri: x parte da s, y parte da s+1, ed è quel «spostato di uno» del disegno. Le finestre sono prese a caso invece che in ordine: ogni passo di addestramento vede parti diverse del testo, il che stabilizza il gradiente.

Codice: il modello e l'addestramento

class CharLSTM(nn.Module):
    def __init__(self, vocab, emb=64, hidden=128, layers=2, dropout=0.2):
        super().__init__()
        self.emb = nn.Embedding(vocab, emb)
        self.lstm = nn.LSTM(emb, hidden, layers, dropout=dropout, batch_first=True)
        self.head = nn.Linear(hidden, vocab)

    def forward(self, x, state=None):
        out, state = self.lstm(self.emb(x), state)       # a prediction at EVERY position
        return self.head(out), state                      # logits: (batch, length, vocab)

torch.manual_seed(0)                                       # fixed seed: same weights, same batches
model = CharLSTM(len(chars))
opt = torch.optim.Adam(model.parameters(), lr=2e-3)
loss_fn = nn.CrossEntropyLoss()

@torch.no_grad()
def val_loss():
    model.eval()
    x, y = get_batch(val_data, batch=64)
    logits, _ = model(x)
    model.train()
    return loss_fn(logits.reshape(-1, logits.size(-1)), y.reshape(-1)).item()

for step in range(1, 1001):
    x, y = get_batch(train_data)
    logits, _ = model(x)
    loss = loss_fn(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
    opt.zero_grad(); loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()
    if step % 250 == 0:
        print(f"step {step:4d}  train {loss.item():.3f}  val {val_loss():.3f}")

Come leggerlo. CharLSTM ha tre blocchi: nn.Embedding(vocab, 64) trasforma ogni indice in un vettore; nn.LSTM(64, 128, 2, dropout=0.2) è l'LSTM a due strati con 128 unità e dropout fra gli strati (spegne a caso il 20% delle uscite del primo strato in addestramento, per ridurre l'overfitting); nn.Linear(128, vocab) produce un logit per carattere a ogni posizione. Il forward restituisce anche lo state, la coppia (h, c): serve nella generazione per proseguire da dove ci eravamo fermati. In totale il modello ha 258.637 parametri. La perdita CrossEntropyLoss si aspetta logit di forma (N, classi) e bersagli di forma (N,): per questo reshape(-1, logits.size(-1)) appiattisce batch e tempo in un solo asse. Il resto del ciclo è quello già visto: azzera i gradienti, backward, clipping, step. La funzione val_loss misura la perdita su un batch di validazione con model.eval() e torch.no_grad().

step  250  train 2.046  val 1.998
step  500  train 1.654  val 1.709
step  750  train 1.635  val 1.414
step 1000  train 1.375  val 1.500

Lettura (un seed; 1000 passi in 170-206 secondi su CPU, 4 thread, con la macchina occupata da altri processi; due esecuzioni con uscita identica, anche nel testo generato più sotto). Dopo soli 250 passi il modello è già a 2.00 sulla validazione, sotto il bigramma (2.349): ha imparato più della semplice coppia di caratteri. A 1000 passi la validazione è 1.500 nat, cioè 2.16 bit per carattere e una perplessità di circa 4.5: è come se il modello scegliesse, in media, fra meno di cinque caratteri equiprobabili invece che fra 141.

Le due colonne vanno lette con prudenza: ciascun numero è la perdita di un solo batch (32 finestre di addestramento, 64 di validazione), quindi oscilla da una riga all'altra (la validazione sale da 1.414 a 1.500 fra il passo 750 e il 1000 mentre l'addestramento scende), e le differenze di qualche centesimo fra train e validazione sono rumore, non overfitting.

In tutto il modello ha visto circa 4,1 milioni di caratteri (1000 passi × 32 × 128), cioè il corpus di mezzo milione circa sette volte, e la curva sta ancora scendendo: più passi migliorerebbero. Un modello più grande o allenato più a lungo comincerebbe prima o poi a separare i due numeri, ed è quello il momento di fermarsi o di aumentare il dropout.

Il campionamento e la temperatura

Ora il modello deve scrivere. A ogni passo produce 141 logit; vogliamo trasformarli in una scelta. Due strategie estreme: greedy (prendi sempre il carattere più probabile) e campionamento (estrai a caso secondo le probabilità). Greedy è deterministico e tende a cadere in cicli ripetitivi; il campionamento puro dà varietà ma anche errori. La temperatura T regola il compromesso dividendo i logit prima della softmax:

p_i(T) = exp(l_i / T) / Σ_j exp(l_j / T)

Con T = 1 si ottengono le probabilità del modello così come sono. Con T < 1 le differenze fra i logit si amplificano: il carattere più probabile guadagna massa. Con T > 1 si appiattiscono: il caso prende il sopravvento. Facciamolo con tre logit, per vederlo:

import numpy as np

def softmax(logits, temperature=1.0):
    z = np.array(logits) / temperature
    z = z - z.max()                       # subtract the max: same result, no overflow
    e = np.exp(z)
    return e / e.sum()

logits = [2.0, 1.0, 0.1]                  # the model's raw scores for 3 candidate characters
for temp in (0.5, 1.0, 2.0, 10.0):
    print(f"T={temp:4.1f}", softmax(logits, temp).round(3))
T= 0.5 [0.864 0.117 0.019]
T= 1.0 [0.659 0.242 0.099]
T= 2.0 [0.502 0.304 0.194]
T=10.0 [0.366 0.331 0.303]

I logit sono [2.0, 1.0, 0.1]. A T = 1, la softmax dà 0.659, 0.242, 0.099: il primo carattere è favorito ma non certo. Dimezzando la temperatura il primo sale a 0.864 (quasi deterministico); raddoppiandola scende a 0.502; a T = 10 le tre probabilità sono quasi uguali (0.37, 0.33, 0.30), cioè il modello è quasi indifferente. Nel codice si sottrae z.max() prima di esponenziare: dà lo stesso risultato ma evita overflow con logit grandi, un classico trucco di stabilità numerica. Al limite T → 0 si ottiene greedy, a T → ∞ la scelta uniforme.

Generare: il ciclo con stato

@torch.no_grad()
def generate(prompt, length=300, temperature=1.0):
    model.eval()
    x = torch.tensor([[stoi[c] for c in prompt]])
    logits, state = model(x)                              # read the prompt, keep (h, c)
    out = list(prompt)
    for _ in range(length):
        probs = torch.softmax(logits[0, -1] / temperature, dim=-1)   # temperature rescales the logits
        nxt = torch.multinomial(probs, 1).item()                      # SAMPLE, do not take the argmax
        out.append(itos[nxt])
        logits, state = model(torch.tensor([[nxt]]), state)           # one char in, state carried on
    model.train()
    return "".join(out)

torch.manual_seed(1)                                       # fixed seed for the sampling too
for temp in (0.5, 1.0, 1.5):
    print(f"--- temperature {temp}")
    print(generate("The class ", length=200, temperature=temp))

Funzionamento. generate prima legge il prompt con una sola chiamata al modello: restituisce i logit per tutte le posizioni e lo stato (h, c) dopo l'ultimo carattere. Da lì il ciclo è sempre lo stesso: prendi i logit dell'ultima posizione (logits[0, -1]), dividi per la temperatura, applica la softmax, estrai un carattere con torch.multinomial, aggiungilo al testo e passa al modello solo quel carattere insieme allo stato. È un punto importante: durante la generazione non rileggiamo l'intero testo a ogni passo, il riassunto del passato è nello stato. Quindi il costo per carattere è costante.

Un esempio reale dell'uscita, eseguito due volte con lo stesso risultato (prompt «The class », 200 caratteri generati a ciascuna temperatura, un solo campionamento per temperatura: è solo un'illustrazione). Il testo è inglese perché il corpus è la documentazione di Python:

--- temperature 0.5
The class or value properation and be result be cormand contain or the methods in the context and module is defined in the or the subclass statement function of the "__class__()" is setive the subclass to the o
--- temperature 1.0
The class the farally "clocal", "rufallows conted.

   Changed in the syntaxs that continue unare numary value is identifly asues the called in lists; of the type: dicterals character).

Same if not
objects a m
--- temperature 1.5
The class one a Pythor. for
lock=-condex atngingP"F.dewcod_*?b:ca0nex~'٥ 7, C
Agf
the edlif
   bulc numeror23.

"fulgcisingslem__()" +s conz []*+]
   | Nasi'”.aln(!k)
   |2/200-1200ₙ2~!"'w<if Ds
   = op] passit

Tre osservazioni. Primo, a 0.5 il testo è il più «pulito»: la maggior parte delle parole esiste e compaiono termini del dominio come subclass, module, methods e perfino "__class__()", ma le frasi non hanno senso («be result be cormand contain or the methods»), con qualche parola inventata («properation», «setive») e il ripetersi di «or» e «the». Secondo, a 1.0 compare la struttura della documentazione (righe rientrate, «Changed in the syntax», elenchi con punto e virgola), ma le parole inventate aumentano («farally», «identifly asues»): la distribuzione del modello viene rispettata, e la distribuzione del corpus contiene questi elementi. Terzo, a 1.5 le parole si dissolvono in sequenze di caratteri casuali, simboli e perfino caratteri non ASCII che nel corpus compaiono di rado: il rumore prevale. La temperatura giusta dipende dall'uso: bassa per testi prevedibili e coerenti (a rischio di ripetizioni), alta per varietà (a rischio di nonsenso).

Cosa dimostra questo esperimento, e cosa no. Dimostra che con circa 260 mila parametri e tre minuti di CPU una LSTM impara la forma del testo: ortografia approssimativa, lunghezza tipica delle parole, punteggiatura, l'aspetto della documentazione. Non dimostra, e non ci riesce, che capisca il significato: il testo non ha coerenza su più di qualche parola. Ci sono limiti precisi: la finestra di addestramento è di 128 caratteri, quindi nulla di ciò che sta più lontano può essere appreso; il corpus è piccolo; e la LSTM ha 128 unità. Questi limiti sono la ragione per cui i modelli di linguaggio moderni sono molto più grandi e usano l'attenzione.

Trade-off

  • Carattere o parola. Il livello di carattere ha un vocabolario minuscolo (qui 141 simboli: niente parole sconosciute) ma sequenze lunghe: per ricordare una parola di 8 lettere bisogna ricordare 8 passi. Il livello di parola ha sequenze corte e vocabolari enormi (decine di migliaia di voci) con il problema dei termini rari. Le soluzioni moderne stanno nel mezzo (sottoparole).
  • Profondità e dimensione. Due strati da 128 sono un compromesso; più capacità riduce la perdita ma richiede più dati per non fare overfitting.
  • Lunghezza della finestra. Più lunga permette contesti più lunghi, ma costa memoria e rende più difficile il gradiente (tutta la lezione sul vanishing).

Le tre scelte hanno lo stesso schema: ogni passo verso più capacità o più contesto sposta il costo su memoria, tempo di calcolo o difficoltà di addestramento. Nel nostro esperimento la finestra di 128 caratteri e lo stato di 128 unità sono stati scelti perché l'addestramento resti nei minuti su CPU, non perché siano ottimali; con un corpus di mezzo milione di caratteri non avrebbe senso aumentare la capacità prima di aver visto la validazione separarsi dal training.

Trappole reali

La prima è non salvare il vocabolario con il modello: senza stoi e itos originali i pesi sono inutilizzabili, perché aggiungere un solo carattere nuovo sposta tutti gli indici. La seconda è valutare solo con la perdita sul training: se il modello ha capacità di memorizzare, quella scende sempre, e solo la validazione dice se sta generalizzando, quindi dividi nel tempo come per le serie temporali. La terza è giudicare il modello dall'unico campione scritto bene: il campionamento è casuale, e un campione bello non dimostra nulla quanto uno brutto; la misura vera è la perdita di validazione, o i bit per carattere, non l'impressione.

La quarta è la temperatura troppo bassa, che porta a cicli: a T molto piccolo, o con greedy, il modello può ripetere all'infinito la stessa sequenza. La quinta è dimenticare lo stato durante la generazione: se a ogni carattere rimandi al modello solo quel carattere senza lo stato, la LSTM riparte da zero a ogni passo e produce testo incoerente; il codice qui sopra passa state da un passo al successivo. La sesta è dimenticare model.eval(), che lascia il dropout acceso e rende il testo più rumoroso. L'ultima è confondere nat e bit: 1 nat sono 1/ln 2 ≈ 1.44 bit, e confrontare un numero in nat con uno in bit è un errore comune quando si leggono articoli diversi.

Domande per verificarti (con risposta)

Perché il bersaglio è l'ingresso spostato di uno? Perché a ogni posizione il compito è predire il carattere successivo; con lo spostamento si ottengono tante previsioni addestrabili per ogni passaggio.

Una perdita di 2.0 nat: quanti bit per carattere e quale perplessità? 2.0 / ln 2 ≈ 2.885 bit e exp(2.0) ≈ 7.39.

Dividi i logit per T = 0.1: cosa succede alla distribuzione? Le differenze fra logit si moltiplicano per 10: la distribuzione diventa quasi un picco sul carattere più probabile, cioè quasi greedy.

Perché la perdita di validazione, e non il testo generato, è la metrica da guardare? Il testo generato è un campione casuale e la sua qualità percepita è soggettiva; la perdita di validazione è una misura numerica e confrontabile fra modelli.

Un esempio svolto a mano: entropia incrociata e perplessità su quattro caratteri

Il modello legge quattro posizioni e, per ognuna, assegna una probabilità al carattere che poi è davvero comparso: 0.7, 0.5, 0.9, 0.05. Le perdite sono i logaritmi naturali negativi: −ln 0.7 = 0.357, −ln 0.5 = 0.693, −ln 0.9 = 0.105, −ln 0.05 = 2.996. La media è (0.357 + 0.693 + 0.105 + 2.996) / 4 = 1.038 nat, cioè 1.038 / ln 2 = 1.50 bit per carattere, e la perplessità è e^1.038 = 2.82.

Notare come una sola previsione molto sbagliata (probabilità 0.05 per il carattere vero) pesi più di tutte le altre: contribuisce con 2.996 su una somma di 4.151, cioè il 72%. È il motivo per cui l'entropia incrociata punisce in modo duro un modello che è «sicuro e sbaglia», e per cui un modello di linguaggio non deve mai assegnare probabilità quasi zero a ciò che può succedere (di qui lo smoothing nelle baseline unigramma e bigramma).

Campionare meglio: top-k e nucleus

La temperatura non è l'unico modo di governare il campionamento. Due varianti molto usate: il top-k tiene soltanto i k caratteri più probabili e ridistribuisce la probabilità fra di loro (si evitano i caratteri assurdi che, sommati, avevano comunque una probabilità non nulla); il campionamento nucleus o top-p (Holtzman e colleghi, 2019) tiene il più piccolo insieme di caratteri la cui probabilità cumulata supera una soglia p (ad esempio 0.9). Con una distribuzione molto concentrata (0.95, 0.03, 0.02) il nucleus a 0.9 tiene un solo carattere; con una distribuzione piatta ne tiene molti: si adatta all'incertezza del momento, a differenza del top-k fisso. Non le abbiamo confrontate qui con una misura, quindi non possiamo dire quale sia migliore per questo modello.

Domande frequenti

Perché un embedding e non un vettore one-hot? Il one-hot (141 numeri con un solo 1) è una rappresentazione senza struttura: ogni carattere è equidistante dagli altri. L'embedding impara un vettore denso di 64 numeri in cui caratteri che si comportano in modo simile (le vocali, le cifre) finiscono vicini. Con una sola matrice per layer, è anche equivalente a moltiplicare il one-hot per una matrice 141×64, solo molto più efficiente.

Il dropout durante la generazione? Va spento (model.eval()). Il dropout serve in addestramento per regolarizzare; lasciandolo acceso in generazione si aggiunge rumore casuale oltre a quello del campionamento.

Perché finestre casuali e non il testo in ordine? Ogni passo di addestramento vede parti diverse del corpus, quindi il gradiente è meno correlato fra un passo e l'altro. Il costo è che ogni finestra parte con stato nullo: la rete non vede mai contesti più lunghi di 128 caratteri (tranne in generazione, dove lo stato si porta avanti).

Come proseguo l'addestramento il giorno dopo? Salva insieme i pesi, lo stato dell'ottimizzatore e il vocabolario (stoi, itos); ricaricali con load_state_dict. Senza il vocabolario i pesi sono inutili.

La perplessità di 4.5 è buona? Va confrontata con le baseline: per un modello uniforme sarebbe 141, per l'unigramma e^3.211 ≈ 24.8, per il bigramma e^2.349 ≈ 10.5. Quindi 4.5 è più del doppio meglio del bigramma, ma resta lontana dalla perplessità di un buon modello di linguaggio, che ha molto più contesto e dati.

Posso usare lo stesso codice con parole invece di caratteri? Sì: cambia solo il vocabolario (migliaia di parole, con un simbolo per le sconosciute) e la dimensione dell'embedding. Il resto è identico, ma il costo dell'ultimo livello lineare cresce con la dimensione del vocabolario.

Cosa portarti via

Un modello di linguaggio a carattere è una LSTM che, a ogni posizione, produce una distribuzione di probabilità sul carattere successivo; si addestra minimizzando la perdita di entropia incrociata con il bersaglio traslato di uno, si valuta in bit per carattere o perplessità confrontandosi con baseline (qui 4.949, 3.211 e 2.349 nat contro circa 1.50 della LSTM), e si usa campionando con una temperatura. Impara la forma del testo, non il significato. Nelle prossime lezioni lasciamo il mondo delle sequenze pure per vedere come si combinano reti diverse: ConvLSTM, reti ibride, e poi il reinforcement learning.

Nella serie, la lezione precedente è RNN, LSTM e GRU a confronto: un esperimento controllato su più seed; la successiva è Architetture ibride: una CNN per lo spazio e una LSTM per il tempo.

Fonti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti