Vocab size in SentencePiece: la fertilità come metrica per un traduttore NMT
Nel mio caso, il tokenizzatore scelto è SentencePiece, e il problema che ho incontrato risiedeva proprio in questa fase, non nel modello di traduzione stesso. Questo articolo descrive in modo dettagliato il contesto, le cause dell’errore e le soluzioni adottate, fornendo al tempo stesso indicazioni pratiche per chi vuole implementare un tokenizer efficace in un progetto multilingua.
Se sei alla ricerca di consigli su come dimensionare correttamente il vocabolario di un tokenizer, capire le metriche di fertilità e gestire alfabeti diversi all’interno di un unico modello, continua a leggere: troverai esempi concreti, snippet di codice e una panoramica delle lezioni apprese.
Cos'è SentencePiece e come funziona
SentencePiece è una libreria open source (originariamente sviluppata da Google) per la tokenizzazione a livello di subword.
A differenza di un approccio che divide il testo in parole intere – richiedendo un vocabolario enorme per coprire tutte le forme flesse, i nomi propri e i refusi – o di un metodo che utilizza singoli caratteri – creando sequenze estremamente lunghe e difficili da modellare – SentencePiece impara un vocabolario di pezzi di parola di lunghezza variabile, da singoli caratteri a parole intere frequenti, direttamente dai dati.
Questo approccio elimina la necessità di un tokenizzatore linguistico a monte, poiché il modello stesso scopre quali segmenti siano più utili per la rappresentazione del testo.
Due caratteristiche lo rendono particolarmente adatto al mio caso italiano↔russo:
- Language‑agnostic: non presuppone spazi come separatori di parola (utile per lingue come il giapponese, ma anche più robusto in generale) e tratta il testo come una sequenza grezza di caratteri Unicode. Nessuna euristica specifica per l'italiano o per il russo: lo stesso algoritmo viene applicato a entrambe le lingue.
- Vocabolario condiviso multilingua: è possibile allenare un unico modello SentencePiece su un corpus che mescola più lingue, ottenendo un vocabolario unico che il modello di traduzione utilizzerà sia in input (italiano) che in output (russo). Questo è comodo – un solo tokenizzatore da mantenere – ma, come vedremo, nasconde un'insidia quando le due lingue non condividono lo stesso alfabeto.
Internamente, SentencePiece può allenare il vocabolario con due algoritmi principali: BPE (Byte Pair Encoding) e Unigram Language Model. Il primo parte dai singoli caratteri e fonde iterativamente le coppie di simboli adiacenti più frequenti, fino a raggiungere la dimensione di vocabolario desiderata; è greedy e deterministico. Il secondo parte da un ampio set di candidati e lo riduce progressivamente, mantenendo i pezzi che massimizzano la verosimiglianza del corpus secondo un modello probabilistico. Il parametro che decide quanti "pezzi" imparare si chiama vocab_size, ed è esattamente il valore che avevo impostato in modo errato: 100.000.
Il sintomo
Con quella configurazione, il traduttore produceva output del tipo:
> mio marito si chiama Marco Мио marito ...
> Sto andando a fumare Я собираюсь ...
> Posso avere del pollo per favore? Можно пожалуйста?Frasi troncate a metà, soggetti persi, parole italiane lasciate intatte in mezzo al cirillico. La prima ipotesi, quella più pigra, è stata: “serve più training”. Tuttavia, aumentare il numero di epoche non ha risolto il problema.
Analizzando più a fondo i risultati, ho notato che il tokenizzatore stava creando token incompleti per le parole russe, mentre molti token italiani rimanevano intatti, generando una mescolanza di alfabeti incoerente. Questo comportamento non era casuale, ma legato a una scelta subottimale del vocabolario per il tokenizer condiviso.
Il sintomo, quindi, evidenziava una mancanza di equilibrio tra le due lingue e la necessità di rivedere la configurazione del tokenizzatore prima di procedere a ulteriori ottimizzazioni del modello NMT.
L'intuizione sbagliata di partenza
Il mio tokenizer BPE aveva un vocabolario di 100.000 token, condiviso tra italiano e russo. Il ragionamento che mi aveva portato lì era semplice e, in apparenza, ragionevole: più grande è il vocabolario, più il modello può rappresentare parole intere invece di frammentarle, quindi più è espressivo.
Questa intuizione, però, si è rivelata fuorviante. Un vocabolario enorme non è sempre una scelta sicura; al contrario, può introdurre rumore e inefficienza nella fase di training, soprattutto quando gran parte dei token non appare mai nei dati.
Nel caso specifico, la presenza di due alfabeti distinti (latino e cirillico) ha accentuato il problema: gran parte del vocabolario era dedicata a token che non trovavano corrispondenza in una delle due lingue, riducendo così l’efficacia complessiva del tokenizer.
Quanti token erano davvero morti
Ho iniziato a indagare quanti dei 100.000 token venivano effettivamente usati nel corpus di training. I numeri non erano incoraggianti:
- Il 24 % dei token non compariva mai in tutto il corpus, lasciando embeddings mai aggiornati e generando puro rumore nella matrice.
- Solo l'1,7 % superava le 100 occorrenze, soglia minima ragionevole per stimare decentemente un embedding a 512 dimensioni.
- La frequenza media, contando solo i token effettivamente usati, era di 21 occorrenze.
Con label smoothing impostato a 0.1, ogni singolo passo di training distribuisce una piccola parte di probabilità target anche sui token mai visti. Non si tratta solo di capacità sprecata: è un rumore gradiente che interferisce con l’ottimizzazione, poiché il modello tenta di aggiornare 24 000 token inutili ad ogni iterazione.
Come termine di paragone, 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” un vocabolario da 100k con quel rapporto servirebbero 10‑15 milioni di frasi; io ne avevo circa 77 000.
La regola del pollice non basta
La prima tentazione, dopo aver visto questi numeri, è stata cercare una fascia generica in letteratura: “8‑16k token per un corpus di questa taglia” è un consiglio che si trova ovunque, e l’ho dato anch'io più volte. Tuttavia, questa regola è basata sul numero di frasi, non su quello che il tokenizer effettivamente fa sui miei dati specifici.
Una misura più significativa richiede di considerare la distribuzione delle frequenze dei token e il loro impatto sulla capacità di apprendimento del modello. Senza un'analisi dettagliata, si rischia di scegliere un vocabolario sovradimensionato o, al contrario, troppo piccolo per catturare le particolarità della lingua.
Pertanto, ho deciso di adottare una metrica più fine‑grained, in grado di guidare la scelta del vocab_size sulla base di dati reali anziché stime approssimative.
La metrica giusta: la fertilità
In letteratura NMT, la metrica standard per capire se un BPE è ben calibrato si chiama fertilità: il numero medio di token prodotti per parola (parola intesa come stringa separata da spazi).
- Fertilità vicina a 1 → il vocabolario è troppo grande; troppe parole intere vengono viste poche volte, creando token “morti”.
- Fertilità alta (3+) → frammentazione quasi a livello di carattere, generando sequenze lunghe che un modello piccolo fatica a modellare.
- 1,2–1,4 è il range comunemente considerato sano.
Il metodo che ho usato consiste nell’allenare un tokenizer “sonda” a diverse dimensioni, misurare la fertilità reale su tutto il corpus e cercare, mediante una ricerca binaria, la dimensione più piccola che mantiene la fertilità sotto la soglia target. Ecco il codice impiegato:
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<p>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</code></p>Il primo risultato è stato 40.225 token, con fertilità aggregata 1.300; i token “morti” sono scesi dal 24 % al 6,7 %. Sembrava la fine della storia, ma non lo era.
Il colpo di scena: due alfabeti, un solo vocabolario
Il mio tokenizer è condiviso tra italiano (alfabeto latino) e russo (alfabeto cirillico): due script quasi completamente disgiunti. I merge BPE appresi sull'italiano non aiutano in nessun modo il russo, e viceversa. Un vocabolario “condiviso” tra due alfabeti così diversi non è un unico vocabolario, ma due sotto‑vocabolari indipendenti, allenati insieme e allocati per frequenza aggregata, senza alcun bilanciamento esplicito tra le due lingue.
Un semplice calcolo dimostra il problema:
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, mixedIl risultato della ricerca corretta per lingua è stato 54.753 token – più grande del primo tentativo, ma ora con russo e italiano entrambi sotto soglia (1.300 e 1.231). I token “morti” sono leggermente aumentati al 9,3 %: è il prezzo di dare all'italiano più spazio di quanto gli servirebbe da solo, perché il vocabolario è condiviso e la crescita extra serve a far posto al russo.
Ecco una sintesi tabellare dell’evoluzione:
| Versione | Dimensione | Fertilità IT | Fertilità RU | Token mai visti |
|---|---|---|---|---|
| Originale | 100.000 | 1.20 | 1.23 | 24 % |
| Ricerca aggregata (sbagliata) | 40.225 | 1.25 | 1.35 | 6.7 % |
| Ricerca per lingua (corretta) | 54.753 | 1.23 | 1.30 | 9.3 % |
Lezioni generali
Astraendo dal mio caso specifico, emergono alcune linee guida utili per chi lavora con tokenizer multilingua:
- Dimensionare il vocabolario non è una questione di “più grande è meglio”. Ogni token è un embedding che deve vedere abbastanza dati per essere addestrato decentemente; token inutilizzati introducono rumore e rallentano la convergenza.
- Una metrica aggregata su un vocabolario condiviso multilingua può mascherare uno sbilanciamento reale. Quando gli alfabeti sono disgiunti, la fertilità va sempre misurata per lingua separatamente. Questo vale anche per altre combinazioni di script, come han/kana, arabo/latino e così via.
- La fertilità “giusta” è correlata alla morfologia della lingua, non è un dettaglio implementativo neutro. Lingue con affissi complessi potrebbero richiedere una fertilità leggermente più alta rispetto a lingue più analytic.
- La fertilità è una misura, non un dogma morale: il range 1.2–1.4 è un punto di partenza ragionevole, ma va tarato sul proprio caso d'uso, tenendo conto della dimensione del corpus e della complessità linguistica.
Seguendo queste linee guida, è possibile costruire un tokenizzatore più efficiente, riducendo il numero di token inutilizzati e migliorando la stabilità dei modelli NMT.
Prossimi passi
Il nuovo tokenizer rende incompatibili i checkpoint esistenti: sarà necessario un retrain completo del modello di traduzione. Nel prossimo post descriverò la pipeline di arricchimento incrementale del dataset, progettata per non mescolare nuovamente training e validation già visti ad ogni nuovo round di training – un problema diverso, ma altrettanto insidioso se ign
Se vuoi approfondire ulteriormente la gestione dei vocaboli in ambienti multilingua o hai domande su come implementare la fertilità nella tua pipeline, sentiti libero di contattarmi nei commenti.**