Intelligenza Artificiale

Vocab size in SentencePiece: la fertilità come metrica per un traduttore NMT

Vocab size in SentencePiece: la fertilità come metrica per un traduttore NMT
LT
Luca Terribili
Autore

Sto costruendo da mesi un traduttore neurale italiano→russo, con architettura in OpenNMT-py: uno dei tanti pezzi della pipeline è il tokenizer, quello che trasforma il testo grezzo nei subword che il modello effettivamente vede in input e in output. Per quel ruolo ho scelto SentencePiece, e ho passato tre giorni a convincermi che il problema fosse nel modello di traduzione. Non lo era. Era proprio lì, nel tokenizer — e la causa era un numero che avevo scelto quasi a caso: un vocabolario da 100.000 token.

In questo articolo racconto cosa è andato storto, come l'ho scoperto e quale metrica mi ha permesso di risolverlo per davvero — non a intuito, ma con i numeri alla mano. Se stai dimensionando un vocabolario per un progetto multilingua, specialmente con alfabeti diversi come nel mio caso italiano/russo, questo probabilmente ti farà risparmiare i miei tre giorni.

Come funziona SentencePiece, e perché l'ho scelto per italiano e russo

SentencePiece (nato in Google, ora open source) tokenizza a livello di subword. Invece di usare parole intere — che ti costringono a un vocabolario enorme per coprire flessioni, nomi propri e refusi — o singoli caratteri — che producono sequenze chilometriche — impara direttamente dai dati quali pezzi di parola, di lunghezza variabile, valga la pena rappresentare.

Per un caso come il mio ha due vantaggi concreti. Il primo: non fa assunzioni sulla lingua. Non presuppone gli spazi come separatori (utile per il giapponese, ma robusto in generale) e tratta il testo come sequenza di caratteri Unicode — stesso algoritmo per italiano e russo, nessuna euristica dedicata. Il secondo: un vocabolario, due lingue. Alleni un solo modello su un corpus misto e lo usi sia in input (italiano) che in output (russo). Comodo, un solo tokenizer da mantenere. Ma, come vedremo, quando le due lingue non condividono l'alfabeto, questa comodità nasconde una trappola.

Sotto il cofano, SentencePiece può allenare il vocabolario con due algoritmi: BPE, che parte dai caratteri singoli e fonde greedy le coppie più frequenti finché non raggiunge la dimensione target, oppure Unigram, che parte da un set ampio di candidati e lo riduce mantenendo i pezzi che massimizzano la verosimiglianza del corpus. Il parametro che decide quanti token imparare è vocab_size — ed è esattamente il valore su cui avevo toppato: 100.000.

Il sintomo: il traduttore sputava fuori frasi spezzate a metà tra i due alfabeti

Con quella configurazione, il traduttore produceva cose come:

mio marito si chiama Marco Мио marito ... Sto andando a fumare Я собираюсь ... Posso avere del pollo per favore? Можно пожалуйста?

Frasi troncate, soggetti persi, parole italiane lasciate a metà dentro il cirillico. La prima reazione, la più pigra, è stata "serve più training". Ho aumentato le epoche. Non è cambiato niente. Guardando meglio, ho notato che il tokenizer produceva token incompleti per le parole russe, mentre molti token italiani restavano intatti — una mescolanza di alfabeti che non aveva niente di casuale. Il problema non era nel training, era a monte: nella scelta del vocabolario condiviso.

Il mio BPE aveva 100.000 token, condivisi tra italiano e russo. Il ragionamento che mi ci aveva portato era semplice, e sembrava sensato: più grande il vocabolario, più parole intere il modello può rappresentare invece di spezzettarle, quindi più è espressivo. Falso, o almeno falso oltre un certo punto: un vocabolario enorme introduce rumore e inefficienza in training, soprattutto quando gran parte dei token non compare quasi mai nei dati. E avere due alfabeti distinti — latino e cirillico — ha peggiorato tutto, perché buona parte del vocabolario finiva dedicata a token che una delle due lingue non avrebbe mai usato.

Per capire quanto fosse grave, ho controllato quanti dei 100.000 token venivano effettivamente usati nel corpus di training. I numeri non erano belli: il 24% dei token non compariva mai in tutto il corpus (embedding mai aggiornati, puro rumore nella matrice), solo l'1,7% superava le 100 occorrenze (soglia minima ragionevole per stimare decentemente un embedding a 512 dimensioni), e la frequenza media dei token effettivamente usati era di 21 occorrenze. Non è solo spreco passivo: con label smoothing a 0.1, ogni passo di training distribuisce una fetta di probabilità target anche sui token mai visti, quindi il modello prova ad aggiornare 24.000 token inutili a ogni iterazione — rumore nel gradiente, non solo capacità sprecata. Per un confronto di scala: il Transformer originale veniva allenato su corpora WMT da 4-40 milioni di frasi con vocabolari BPE da 32-37k token, circa un token ogni 100-150 frasi. Per "giustificare" 100k token con quel rapporto mi sarebbero servite 10-15 milioni di frasi. Ne avevo 77.000.

Dopo aver visto questi numeri, la prima tentazione è cercare una fascia generica da manuale — "8-16k token per un corpus di questa taglia" è il consiglio che trovi ovunque, e l'ho ripetuto anch'io più volte. Ma quella regola si basa sul numero di frasi, non su cosa il tokenizer fa realmente sui tuoi dati. Serve una misura che guardi la distribuzione delle frequenze dei token e il suo impatto sulla capacità di apprendimento del modello, non una stima presa in prestito da un altro corpus.

La fertilità è la metrica che mi ha detto quanto vocabolario serviva davvero

In letteratura NMT c'è una metrica pensata esattamente per questo: la fertilità, cioè il numero medio di token prodotti per parola (parola = stringa separata da spazi). Fertilità vicina a 1 significa vocabolario troppo grande, con troppe parole intere viste poche volte e token morti ovunque. Fertilità 3+ significa frammentazione quasi a livello di carattere, con sequenze lunghe che un modello piccolo fatica a gestire. 1,2-1,4 è il range comunemente considerato sano.

Il metodo che ho usato: alleno un tokenizer "sonda" a diverse dimensioni, misuro la fertilità reale sul corpus, e con una ricerca binaria trovo la dimensione più piccola che resta sotto la soglia target.

def fertility(tokenizer, sentences):
    total_tokens = 0
    total_words = 0
    for s in sentences:
        total_words += len(s.split())
        total_tokens += len(tokenizer.encode(s))
    return total_tokens / total_words


def find_vocab_size(train_fn, sentences, target=1.3, lo=4000, hi=100000):
    while hi - lo > 500:
        mid = (lo + hi) // 2
        tok = train_fn(vocab_size=mid)
        f = fertility(tok, sentences)
        if f > target:
            lo = mid          # troppo frammentato, serve vocab più grande
        else:
            hi = mid          # va bene, provo a scendere
    return hi

Primo risultato: 40.225 token, fertilità aggregata 1.300, token morti scesi dal 24% al 6,7%. Sembrava risolto. Non lo era.

Il colpo di scena: due alfabeti disgiunti non fanno un vocabolario unico

Il mio tokenizer è condiviso tra italiano (latino) e russo (cirillico): due script quasi completamente disgiunti. I merge BPE imparati sull'italiano non aiutano minimamente il russo, e viceversa. Un vocabolario "condiviso" tra alfabeti così diversi non è un vocabolario unico — sono due sotto-vocabolari indipendenti, allenati insieme e allocati per frequenza aggregata, senza nessun bilanciamento esplicito tra le lingue. La fertilità aggregata al 40.225 nascondeva proprio questo: bastava separare la misura per lingua per vederlo.

def script_split(vocab):
    cyrillic, latin, mixed = 0, 0, 0
    for token in vocab:
        has_cyr = any('\u0400' <= ch <= '\u04FF' for ch in token)
        has_lat = any('a' <= ch <= 'z' or 'A' <= ch <= 'Z' for ch in token)
        if has_cyr and has_lat:
            mixed += 1
        elif has_cyr:
            cyrillic += 1
        elif has_lat:
            latin += 1
    return cyrillic, latin, mixed

Rifacendo la ricerca binaria separatamente per italiano e russo, il risultato è stato 54.753 token — più grande del primo tentativo, ma stavolta con entrambe le lingue sotto soglia (1.300 per il russo, 1.231 per l'italiano). I token morti sono saliti leggermente al 9,3%: è il prezzo di dare all'italiano più spazio di quanto gli servirebbe da solo, perché il vocabolario condiviso deve fare posto anche al russo.

VersioneDimensioneFertilità ITFertilità RUToken mai visti
Originale100.0001.201.2324%
Ricerca aggregata (sbagliata)40.2251.251.356,7%
Ricerca per lingua (corretta)54.7531.231.309,3%

Cosa mi porto a casa, oltre il mio caso specifico

"Più grande" non è mai un default sicuro: ogni token è un embedding che deve vedere abbastanza dati per essere addestrato bene, e i token inutilizzati sono solo rumore che rallenta la convergenza. Una fertilità aggregata su vocabolario condiviso può nascondere uno sbilanciamento reale: se gli alfabeti sono disgiunti — italiano/russo, ma vale anche per han/kana o arabo/latino — va sempre misurata per lingua separatamente, mai in aggregato. La fertilità giusta dipende dalla morfologia della lingua, non è un dettaglio implementativo neutro: lingue con affissi complessi possono richiedere una fertilità leggermente più alta rispetto a lingue più analitiche. E il range 1,2-1,4 è un punto di partenza, non un dogma: va tarato sul tuo corpus e sulla tua combinazione di lingue.

Il nuovo tokenizer rende incompatibili i checkpoint esistenti, quindi tocca un retrain completo. Nel prossimo post parlo della pipeline di arricchimento incrementale del dataset — pensata per non rimescolare training e validation già visti a ogni nuovo round — un problema diverso ma altrettanto insidioso se lo ignori.

Se stai affrontando qualcosa di simile o hai domande su come misurare la fertilità nella tua pipeline, scrivimi pure nei commenti.

Vedi tutti →