Vocabolario, Embedding e Parametri
Cosa sa il modello, come lo rappresenta, e perché "50 milioni di parametri" è una misura concreta.
1. Vocabolario
Il vocabolario è l'elenco di tutte le unità elementari che il modello riconosce. Non parole intere, non lettere singole in generale: token — le unità decise dal tokenizer.
Char-level
Ogni carattere Unicode distinto nel corpus è un token. Il corpus italiano di partenza produce ~102 token: le 26 lettere latine, le vocali accentate (à é ì ò ù), la punteggiatura, le cifre, lo spazio.
vocab_size = 102
token("a") = 65, token("è") = 87, token(".") = 12
BPE — Byte Pair Encoding
Il tokenizer parte dai singoli byte, poi fonde iterativamente le coppie più frequenti. Dopo 16.000 iterazioni di merge si ottengono vocab_size = 16.000 token: combinazioni come zione, mente, dell', ing.
# Esempio: stessa frase, tokenizzazione diversa
char-level : ["c","a","p","i","t","o","l","o"] → 8 token
BPE 16k : ["cap","ito","lo"] → 3 token
Con BPE ogni token trasporta più informazione semantica, e la stessa frase occupa 3–5 volte meno posizioni nel contesto — il che rende il training molto più efficiente.
vocab_size non è il numero di parole che il modello "conosce". È il numero di unità lessicali primitive nel suo alfabeto. Le parole emergono come combinazioni di questi token durante il training.
2. La embedding table
Quando un token entra nella rete, il modello deve convertire il suo ID intero (es. 42) in un vettore numerico su cui i calcoli hanno senso. Questo è il compito dell'embedding table.
nn.Embedding(vocab_size, d_model)
# = una matrice vocab_size × d_model
# = una tabella di lookup: token_id → vettore
Concretamente: una matrice di vocab_size righe e d_model colonne. Quando il modello vede il token ID 42, prende la riga 42 — questo è tutto. Non c'è calcolo: è un accesso a memoria.
Cosa impara la embedding table
Durante il training, backprop modifica questi valori finché token semanticamente simili producono vettori vicini nello spazio. Il famoso esempio:
Questo non viene programmato: emerge dall'ottimizzazione sulla distribuzione del testo. Con 552KB di corpus italiano otteniamo morfologia (suffissi, accordo); con 200MB di Wikipedia aggiungiamo semantica lessicale più ricca.
3. Cosa sono i parametri
Un parametro è un singolo numero float32 dentro la rete. Non i token, non le attivazioni, non l'input: i pesi — i valori che backpropagation aggiusta a ogni step di training.
Ogni layer ha la sua matrice di pesi. Esempio: un linear layer che trasforma 512 dimensioni in 512 dimensioni ha:
Contare i parametri di un modello significa sommare le dimensioni di tutte queste matrici.
Formula per un TransformerBlock
Ogni blocco contiene: quattro proiezioni di attenzione (Q, K, V, O) e due proiezioni feed-forward.
Con d_model=512 e d_ff=2048:
Q, K, V, O = 4 × 512² = 1.048.576
FF (up+down) = 2 × 512 × 2048 = 2.097.152
─────────────────────────────────────
Totale/blocco ≈ 3.145.728 (~3.1M)
Parametri totali — preset medium
| Componente | Formula | Parametri | Memoria fp32 |
|---|---|---|---|
| Embedding table | 16.000 × 512 |
8.192.000 | 31 MB |
| 12 TransformerBlock | 12 × 3.145.728 |
37.748.736 | 144 MB |
| LayerNorm finale | trascurabile | — | — |
| Totale (con weight tying) | ≈ 46.000.000 | ≈ 175 MB |
46 milioni di numeri float32 = 46M × 4 byte = 175 MB su disco. In addestramento la RAM è 3–5× superiore (gradienti, ottimizzatore), quindi ~500–800 MB. Su M2 con MPS è gestibile.
4. Il rapporto critico: embedding / totale
L'embedding table è parametri "passivi": memorizzano un vocabolario ma non ragionano. I TransformerBlock sono parametri "attivi": catturano relazioni tra token, generalizzano, costruiscono rappresentazioni. Il rapporto tra i due determina quanto del modello pensa.
Preset small — BPE 16k (il problema)
Problema: con vocab 16k e d_model=256, la tabella di lookup occupa 4.1M dei 4.8M totali. I sei TransformerBlock si spartiscono solo 700K parametri — quasi nulla per imparare relazioni linguistiche.
Preset medium — BPE 16k (soluzione)
Corretto: l'embedding è il 18% del totale — nella norma per i modelli moderni (tipicamente 10–20%). I 12 TransformerBlock dispongono di 37M parametri per costruire rappresentazioni linguistiche.
La regola pratica: con BPE, d_model deve essere abbastanza grande da rendere l'embedding table una minoranza ragionevole del totale. Un rapporto 80:20 o 85:15 (transformer:embedding) è salutare.
5. Weight tying
Il modello usa la stessa matrice due volte: all'ingresso per convertire token ID → vettori, e all'uscita per convertire vettori → logit (probabilità sul vocabolario).
# In model.py — stessa matrice, usata in entrambe le direzioni
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
self.lm_head.weight = self.token_embedding.weight
# lm_head non aggiunge parametri: usa la trasposta dell'embedding
Ha senso geometricamente: se il vettore di "gatto" punta in una certa direzione nello spazio, proiettare un'attivazione in quella stessa direzione dovrebbe dare alta probabilità a "gatto" in output. La simmetria è reale, non una scorciatoia.
Effetto pratico: risparmiamo vocab_size × d_model parametri in uscita — con 16k vocab e d_model=512 sono 8.2M parametri in meno. E il training converge più velocemente perché il gradiente dell'output affina anche l'embedding.
6. Riepilogo numerico del progetto
| Configurazione | vocab_size | d_model | Embedding | Transformer | Totale | RAM train |
|---|---|---|---|---|---|---|
| char-level small ✓ | 102 | 256 | 26K (0.5%) | 4.8M | 4.8M | ~50 MB |
| BPE small ⚠ | 16.000 | 256 | 4.1M (85%) | 0.7M | 4.8M | ~50 MB |
| BPE medium ← target | 16.000 | 512 | 8.2M (18%) | 37.8M | 46M | ~600 MB |
Prossimo step: riavviare il training con --preset medium sul corpus letterario (2.9MB), riusando il tokenizer SPM già addestrato. Pre-training su Wikipedia → fine-tuning sulla propria voce.
7. Il tokenizer
Il tokenizer fa una cosa sola: converte testo in una sequenza di numeri interi.
"capitolo" → [423, 17, 891] # BPE 16k
"capitolo" → [99, 14, 7, 3, 22, 8, 11, 8] # char-level
Non è una rete neurale. Non ha pesi. Non impara durante il training del modello. È un algoritmo deterministico con una tabella di regole fissa.
Cosa contiene fisicamente
checkpoints/tokenizer.model è un file SentencePiece binario con due cose:
| Contenuto | Esempio |
|---|---|
| Vocabolario — 16.000 righe, una per token | {"zione": 0, "ment": 1, "dell": 2, …} |
| Regole di merge — ordine in cui fondere le coppie di byte | z + i → zi, zi + one → zione, … |
Dato "capitolo", il tokenizer esegue le regole nell'ordine fisso e restituisce sempre gli stessi ID. Nessuna casualità.
Perché possiamo riusarlo
La embedding table del modello ha vocab_size righe — una per ogni token. La riga 423 contiene il vettore che rappresenta il token zione, aggiornato durante il training.
Se usiamo lo stesso tokenizer, il token zione è sempre l'ID 423 — in Wikipedia, nel corpus letterario, nel pre-training, nel fine-tuning. La riga 423 mantiene il suo significato in tutti i run.
# Tokenizer A (riusato) → contratto stabile
"zione" → ID 423 embedding[423] imparata in pre-training ✓
# Tokenizer B (nuovo) → contratto rotto
"zione" → ID 7801 embedding[423] ora punta a "ing" — checkpoint corrotto ✗
Il tokenizer è il contratto tra i dati e il modello. Finché il contratto non cambia, il checkpoint è valido e il fine-tuning funziona. Addestrare un nuovo tokenizer invalida ogni checkpoint precedente.
8. I preset architetturali
--preset è una scorciatoia che evita di passare ogni iperparametro architetturale a mano:
# Senza preset — verboso
python3 train.py --d-model 512 --num-layers 12 --num-heads 8 --d-ff 2048 --seq-len 512
# Con preset — equivalente
python3 train.py --preset medium
I valori definiti in train.py:
_PRESETS = {
'small': dict(d_model=256, num_layers=6, num_heads=8, d_ff=1024, seq_len=256),
'medium': dict(d_model=512, num_layers=12, num_heads=8, d_ff=2048, seq_len=512),
}
| Parametro | small | medium | Effetto |
|---|---|---|---|
d_model | 256 | 512 | larghezza di ogni vettore interno |
num_layers | 6 | 12 | profondità della rete |
num_heads | 8 | 8 | teste di attenzione parallele |
d_ff | 1024 | 2048 | larghezza del feed-forward (= 4 × d_model) |
seq_len | 256 | 512 | contesto massimo in token |
| Parametri totali | ~4.8M | ~46M |
I preset si possono sovrascrivere parzialmente — il valore esplicito vince sul preset:
# medium ma con 16 layer invece di 12
python3 train.py --preset medium --num-layers 16
9. d_model nel codice
d_model è la larghezza di ogni vettore che scorre attraverso la rete. Non è un parametro astratto — è letteralmente la seconda dimensione di ogni tensore intermedio.
# Input: batch di token ID
x = token_ids # shape: [batch, seq_len]
# 1. Embedding: ogni ID → vettore di d_model numeri
x = self.token_embedding(x) # [batch, seq_len, d_model]
# 2. Positional encoding: somma un vettore di d_model alle posizioni
x = x + pos_enc # [batch, seq_len, d_model]
# 3. Dentro ogni TransformerBlock:
# — Proiezioni Q, K, V, O
Q = nn.Linear(d_model, d_model)(x) # [batch, seq_len, d_model]
K = nn.Linear(d_model, d_model)(x)
V = nn.Linear(d_model, d_model)(x)
O = nn.Linear(d_model, d_model)(attn)
# — Feed-forward: si allarga a d_ff poi rientra
x = nn.Linear(d_model, d_ff)(x) # [batch, seq_len, d_ff] ← si allarga
x = nn.Linear(d_ff, d_model)(x) # [batch, seq_len, d_model] ← rientra
# 4. LayerNorm finale
x = nn.LayerNorm(d_model)(x) # [batch, seq_len, d_model]
# 5. LM head: d_model → vocab_size (logit per ogni token)
logits = nn.Linear(d_model, vocab_size)(x) # [batch, seq_len, vocab_size]
d_model è il contratto interno del modello. Ogni componente riceve un tensore largo d_model e restituisce un tensore largo d_model. L'unica eccezione è il feed-forward, che si allarga temporaneamente a d_ff = 4 × d_model per poi rientrare.
Raddoppiare d_model da 256 a 512 non cambia la struttura — cambia la capacità:
| Effetto | d_model = 256 | d_model = 512 |
|---|---|---|
Ogni nn.Linear(d, d) | 65.536 param | 262.144 param (+4×) |
| Dimensione d_k per testa | 32 | 64 |
| Informazione per token | 256 dimensioni | 512 dimensioni |
10. CPU, GPU, CUDA e MPS
Il training di una rete neurale è quasi interamente moltiplicazioni di matrici. Una CPU esegue queste operazioni in sequenza sui suoi core; una GPU le esegue in parallelo su migliaia di core più piccoli. Per un modello da 46M parametri la differenza è 10–50×.
Le tre opzioni di PyTorch
| Device | Hardware | Velocità | Disponibile su |
|---|---|---|---|
cpu |
Processore | lento | ovunque |
cuda |
GPU NVIDIA | velocissimo | PC/server con scheda NVIDIA |
mps |
GPU Apple M-series | veloce | Mac con M1/M2/M3/M4 |
Il tuo M2 ha una GPU integrata nello stesso chip della CPU — non è una scheda separata. MPS (Metal Performance Shaders) è l'API di Apple che permette a PyTorch di usarla.
CUDA e Metal: due linguaggi per la stessa idea
La GPU non esegue Python. Esegue programmi scritti in un linguaggio specializzato per il calcolo parallelo:
| Produttore | Linguaggio GPU | API PyTorch |
|---|---|---|
| NVIDIA | CUDA | device="cuda" |
| Apple | Metal | device="mps" |
PyTorch contiene le operazioni in forma generica (matmul, softmax, gelu…). Per eseguirle sulla GPU deve compilare una versione specializzata nel linguaggio della GPU — uno shader — ottimizzata per le dimensioni esatte di quel tensore.
Compilazione degli shader: prima run lenta
La prima volta che PyTorch esegue un'operazione su MPS con dimensioni specifiche — d_model=512, batch=16, seq_len=512 — deve compilare lo shader Metal corrispondente e salvarlo in cache su disco:
# Percorso della cache shader su macOS
~/Library/Caches/com.apple.metal/
# Prima run — compila e salva (I/O disco intenso, 5-15 minuti)
# Run successive con le stesse dimensioni — carica dalla cache (secondi)
Questo spiega il comportamento alla prima avviata del preset medium: CPU bassa, disco a 140–240 MB/s, silenzio prolungato prima che parta il training. Non è un blocco — è il compilatore Metal al lavoro.
Il pattern .to(device) in PyTorch
Tutto quello che deve girare sulla GPU va esplicitamente spostato su di essa. I tensori e il modello vivono in RAM (CPU) per default:
device = torch.device("mps") # seleziona la GPU Apple
model = GPT(config).to(device) # sposta i 46M parametri sulla GPU
# Nel training loop, ogni batch deve essere sul device corretto
x, y = x.to(device), y.to(device) # input e target → GPU
loss.backward() # gradienti calcolati sulla GPU
optimizer.step() # pesi aggiornati sulla GPU
# Solo per stampare la loss, il valore torna sulla CPU
print(loss.item()) # .item() copia il singolo scalare in RAM
Su M2: la GPU condivide la RAM con la CPU (Unified Memory) — lo spostamento .to(device) non copia fisicamente i dati, cambia solo i metadati che dicono dove il tensore è accessibile. È uno dei vantaggi dell'architettura Apple Silicon rispetto a una scheda NVIDIA separata con VRAM dedicata.
11. Ottimizzare il training
Alla prima avviata su un corpus grande (200MB+), train.py trascorre la maggior parte del tempo in silenzio prima ancora che parta il training. Questa sezione spiega le leve disponibili e il loro impatto.
Le fasi di avvio e dove si perde tempo
1. Lettura corpus da disco ~ secondi (I/O, veloce)
2. SPM encoding 40M token ~ 10–20 min ← bottleneck principale
3. Costruzione tensor PyTorch ~ 1–2 min (allocazione RAM)
4. Compilazione shader Metal (MPS) ~ 5–15 min (solo prima run)
5. Step 1/30000 — loss: X.XXXX ← training partito
Intervento 1 — Cache dei token (implementato)
Dopo il primo encoding, il tensore viene salvato su disco come file .tokens.pt. Le run successive lo caricano direttamente — saltando completamente la fase 2.
# Prima run: encoding + salvataggio cache
data/wiki_it.txt.bpe-spm.v16000.tokens.pt ← creato (~160 MB)
# Run successive: caricamento diretto
Cache : data/wiki_it.txt.bpe-spm.v16000.tokens.pt
Token : 39,847,203 (da cache — avvio istantaneo)
La cache viene invalidata automaticamente se il corpus o il tokenizer SPM sono stati modificati dopo la creazione del file. Per forzare il re-encoding:
python3 train.py --tokenizer bpe-spm --data data/wiki_it.txt \
--preset medium --no-cache
Risparmio: da 10–20 minuti di attesa silenziosa a 2–3 secondi. Impatto maggiore su corpus grandi (100MB+). Sul corpus letterario (3MB) l'encoding era già veloce.
Come funziona l'invalidazione
Il file cache ha un nome che incorpora il tipo di tokenizer e il vocab size:
All'avvio, train.py controlla tre condizioni prima di usare la cache:
- Il file cache esiste
- Il corpus non è stato modificato dopo la cache (
mtime) - Il file
tokenizer.modelSPM non è stato modificato dopo la cache
Se anche una sola condizione fallisce, re-encoding e sovrascrittura della cache.
Intervento 2 — Ridurre seq_len
Il costo computazionale dell'attention scala con il quadrato della lunghezza di sequenza. Dimezzare seq_len da 512 a 256 rende ogni forward pass circa 4× più veloce:
# medium con seq_len ridotto — più veloce, contesto dimezzato
python3 train.py --tokenizer bpe-spm --data data/wiki_it.txt \
--preset medium --seq-len 256
| seq_len | Step/sec (M2) | Contesto | Uso consigliato |
|---|---|---|---|
| 256 | ~2–3× | 256 token | esperimenti veloci, corpus piccoli |
| 512 | baseline | 512 token | pre-training Wikipedia (preset medium) |
Intervento 3 — Aumentare batch_size
Batch più grandi aumentano il throughput della GPU (più token per step) e stabilizzano i gradienti. Il limite è la RAM disponibile.
# medium su M2 con 16GB — batch 32 è sicuro
python3 train.py --tokenizer bpe-spm --data data/wiki_it.txt \
--preset medium --batch 32
| batch | RAM stimata | Stabilità gradienti |
|---|---|---|
| 8 | ~400 MB | rumorosi |
| 16 | ~550 MB | buona |
| 32 | ~850 MB | ottima — consigliato |
| 64 | ~1.5 GB | eccellente |
Riepilogo delle leve
| Intervento | Impatto | Trade-off | Flag |
|---|---|---|---|
| Cache token | elimina 10–20 min attesa | ~160 MB su disco | automatica; --no-cache per disabilitare |
| seq_len 256 → 512 | 4× più veloce per step | contesto dimezzato | --seq-len 256 |
| batch 16 → 32 | ~1.5× throughput | +300 MB RAM | --batch 32 |
| Corpus ridotto (50 MB) | encoding più rapido | meno vocabolario | --max-mb 50 su wiki_to_corpus.py |