Introduzione
SLM (Small Language Model) è un modello linguistico decoder-only addestrato interamente da zero in Python con PyTorch. Il modello opera sulla lingua italiana, ed è progettato per essere comprensibile nei suoi meccanismi interni, ogni componente è scritto esplicitamente, senza librerie di alto livello che nascondano i dettagli.
L'architettura segue il paradigma GPT: un transformer autoregressivo che, dato un testo in ingresso, impara a prevedere il token successivo. Con sufficiente addestramento, questa capacità predittiva si traduce in generazione di testo coerente.
Obiettivi
| Dimensione | Obiettivo |
|---|---|
| Studio | Comprensione profonda di backpropagation, attention, training loop — costruendo ogni componente a mano |
| Prodotto | Un modello utilizzabile su testi italiani di dominio professionale |
| Professionale | Base tecnica per adapter leggeri su piattaforme per clienti di dominio specifico |
| Multimodale | Integrazione futura con pipeline YOLO v3 per analisi di immagini + testo |
Architettura
Il modello segue l'architettura decoder-only Transformer (variante pre-norm), identica nella struttura ai modelli GPT. L'input è una sequenza di token; l'output è la distribuzione di probabilità sul token successivo per ogni posizione.
(interi, es. [73, 108, 32, ...])
Configurazione del modello
| Parametro | Valore | Significato |
|---|---|---|
vocab_size | dal corpus | Caratteri unici nel testo di training (tipicamente 80–120) |
d_model | 256 | Dimensione del vettore che rappresenta ogni token |
num_heads | 8 | Teste di attenzione parallele (d_k = 32 per testa) |
num_layers | 6 | Blocchi Transformer impilati |
d_ff | 1024 | Dimensione interna Feed-Forward (4 × d_model) |
max_seq_len | 256 | Lunghezza massima del contesto in caratteri |
dropout | 0.1 | Regolarizzazione: 10% dei neuroni disattivati durante training |
parametri totali | ~4.8M | ~18 MB in fp32, ~9 MB in fp16 |
Meccanismo di Attenzione
L'attenzione è il meccanismo che permette al modello di considerare il contesto: ogni token può "guardare" tutti i token precedenti e pesarne la rilevanza per la propria rappresentazione.
Scaled Dot-Product Attention
Ogni token genera tre vettori: Query (cosa cerco?), Key (cosa offro?), Value (cosa comunico?). I pesi di attenzione si ottengono con:
$$\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V$$
La divisione per $\sqrt{d_k}$ previene che il prodotto scalare cresca troppo al crescere della dimensione, rendendo il softmax instabile.
Causal Mask
Il modello è autoregressivo: la posizione $t$ non deve vedere le posizioni future $t+1, t+2, \ldots$ La maschera causale è una matrice triangolare inferiore che impone questo vincolo azzerando i pesi di attenzione verso il futuro:
# Matrice 4×4 per seq_len=4
# t=0: vede solo se stesso
# t=3: vede tutti i precedenti
[1, 0, 0, 0]
[1, 1, 0, 0]
[1, 1, 1, 0]
[1, 1, 1, 1]
Multi-Head Attention
Invece di un'unica attenzione su $d_{model}$ dimensioni, si usano 8 teste parallele ognuna su $d_k = d_{model}/8 = 32$ dimensioni. Ogni testa ha proiezioni $W_Q, W_K, W_V$ indipendenti e impara a "guardare" aspetti diversi delle relazioni tra token — sintattici, semantici, di co-riferimento, ecc.
$$\text{MultiHead}(Q,K,V) = \text{Concat}(\text{head}_1,\ldots,\text{head}_h)\,W^O$$
La specializzazione non è progettata esplicitamente: emerge durante il training per minimizzare la loss.
Blocco Transformer
Ogni blocco (TransformerBlock) combina attenzione e feed-forward con connessioni residue e normalizzazione. Si usa la variante pre-norm, più stabile del post-norm originale:
# Pre-norm: normalizza PRIMA di passare al sotto-strato
x = x + dropout( Attention( LayerNorm(x) ) ) # ← residual
x = x + dropout( FeedForward( LayerNorm(x) ) ) # ← residual
Componenti del blocco
| Componente | Scopo | Dettaglio |
|---|---|---|
| Multi-Head Attention | Contestualizzazione | Ogni token aggrega info dai token precedenti |
| Feed-Forward Network | Elaborazione token-wise | Linear(256→1024) → GELU → Linear(1024→256) |
| LayerNorm | Stabilizzazione | Normalizza per feature, parametri γ e β appresi |
| Residual connection | Flusso del gradiente | Permette al gradiente di scorrere direttamente ← previene vanishing |
| Dropout | Regolarizzazione | 10% dei neuroni disattivati a caso durante training |
Positional Encoding
L'attenzione è invariante alla posizione: senza informazione posizionale il modello non distingue "gatto mangia topo" da "topo mangia gatto". Il positional encoding sinusoidale aggiunge al vettore di ogni token un segnale che codifica la sua posizione nella sequenza:
$$PE_{(pos,2i)} = \sin\!\left(\frac{pos}{10000^{2i/d_{model}}}\right), \quad PE_{(pos,2i+1)} = \cos\!\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$
Frequenze diverse per dimensioni diverse — le basse frequenze codificano posizioni lontane, le alte posizioni vicine. Non è un parametro appreso: è fisso e aggiunto direttamente all'embedding.
Modello GPT
Embedding e Weight Tying
Ogni token viene convertito in un vettore denso da 512 dimensioni tramite una lookup table (nn.Embedding). La matrice di embedding (vocab_size × 512) è la stessa usata dall'ultimo layer lineare che proietta verso il vocabolario. Questa tecnica si chiama weight tying:
self.lm_head.weight = self.token_embedding.weight # stesso tensore
Motivazione: il token "chiesa" come input e come output atteso condivide la stessa rappresentazione. Riduce il numero di parametri e migliora la generalizzazione. Vale per qualsiasi tokenizer — char-level o BPE.
Generazione autoregressiva
Il modello genera un token alla volta. Ad ogni step: (1) forward pass sull'intero contesto, (2) si considera solo il logit dell'ultimo token, (3) si applica temperatura e top-k, (4) si campiona la distribuzione risultante, (5) il nuovo token si aggiunge al contesto e si ripete.
| Parametro | Effetto | Valore consigliato |
|---|---|---|
temperature | = 1.0: distribuzione originale. < 1: più prevedibile. > 1: più creativo/casuale | 0.7 – 0.9 |
top_k | Campiona solo tra i k token più probabili. Riduce le uscite incoerenti | 20 – 50 |
Tokenizer
Il progetto ha usato due tokenizer in fasi successive. Il passaggio da char-level a BPE-SPM non ha richiesto modifiche all'architettura: solo il vocabolario e la dimensione dell'embedding sono cambiate. Il tokenizer BPE-SPM è invariato da Run #3 a tutti i run successivi (SFT, LoRA) — il vocabolario è fissato nel primo run e non cambia.
| Approccio | Vocab size | Fase | Pro | Contro |
|---|---|---|---|---|
| Char-level | 102 | Run #1 — completato ✓ | Zero dipendenze, comprensibile, baseline ideale | Sequenze lunghe, meno efficiente, nessuna morfologia appresa |
| BPE-SPM | 16 000 | Run #3, #4, #5, #6 — completati ✓ | Standard industriale, gestisce morfologia italiana, efficiente su corpus enciclopedico | Richiede training separato del tokenizer (SentencePiece) |
| Word-level | 50.000+ | non usato | Intuitivo | OOV problem, non gestisce parole nuove o morfologia flessiva |
Fase 1 — Char-level (Run #1, completato)
Il tokenizer converte ogni carattere del testo in un intero, costruendo il vocabolario direttamente dal corpus. 102 caratteri unici sul corpus narrativo italiano (552 KB). Nessuna dipendenza esterna.
# Vocabolario costruito dal corpus
chars = sorted(set(open('corpus.txt').read())) # 102 caratteri unici
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
encode("gatto", stoi) # → [1, 0, 3, 3, 2]
decode([1,0,3,3,2], itos) # → "gatto"
Risultato: val loss 1.7256 in 46 minuti su MPS. Morfologia italiana corretta (suffissi -zione, -mente), nessuna coerenza semantica. Obiettivo centrato: validare che ogni componente funzionasse prima di scalare.
Fase 2 — BPE-SPM (Run #3 e successivi, completati)
BPE (Byte Pair Encoding) via SentencePiece. Vocabolario da 16 000 token addestrato sul corpus concatenato Wikipedia IT + narrativa. Il tokenizer è salvato nel checkpoint ed è autocontenuto: nessuna dipendenza esterna a runtime. Ogni checkpoint successivo (Run #4 con RoPE, SFT, LoRA adapter) riusa lo stesso vocabolario — il tokenizer è addestrato una volta sola e mai rigenerato.
# Training del tokenizer (una volta sola, già fatto in Run #3)
python3 train.py --tokenizer bpe-spm \
--bpe-corpus data/corpus_bpe.txt --bpe-vocab 16000
# Uso successivo — riusa il modello SPM già nel checkpoint
python3 train.py --tokenizer bpe-spm --resume --data data/nuovi_shard.txt
# Inferenza con infer.py
python3 tools/infer.py --checkpoint checkpoints/best.pt "Descrivi Roma"
# SFT: stesso tokenizer, solo masked loss cambia
python3 train_sft.py --checkpoint checkpoints/best.pt --data data/sft_mini.jsonl
# LoRA: applica adapter al base model — tokenizer invariato
python3 train.py --lora --lora-rank 8 --checkpoint checkpoints/best.pt --data data/sft_mini.jsonl
Il BPE unisce iterativamente le coppie di token più frequenti. Sul corpus Wikipedia IT impara token come chiesa, comune, provincia, arcivescovo come unità atomiche — rilevanti per il registro enciclopedico. Loss iniziale attesa: log(16 000) ≈ 9.68.
Tokenizer nei run successivi
Il tokenizer non cambia dopo Run #3. Ogni run successivo (Run #4 con RoPE, SFT, LoRA) usa il modello SPM già salvato in best.pt. L'unico scenario in cui va rigenerato è se si vuole cambiare vocabolario o dominio linguistico in modo drastico — cosa che invaliderebbe tutti i checkpoint esistenti.
| Run | Tokenizer | Vocab | Novità rispetto al run precedente |
|---|---|---|---|
| Run #1 | Char-level | 102 | Baseline char-level, prima architettura completa |
| Run #3 | BPE-SPM | 16 000 | Tokenizer addestrato, Wikipedia IT 47.8M token, val 4.0768 |
| Run #4 | BPE-SPM (invariato) | 16 000 | +RoPE, +seq_len 512, 12 layer, val 3.5861 — modello corrente |
| SFT (Phase 09) | BPE-SPM (invariato) | 16 000 | Masked loss, dataset JSONL 140 esempi, checkpoint sft_intent_v1.pt |
| LoRA (Phase 10) | BPE-SPM (invariato) | 16 000 | W frozen, adapter A+B trainabili, ~393k param, 1.5 MB |
Dataset — Finestra Scorrevole
Il training usa la tecnica del Causal Language Modeling (CLM): il testo si etichetta da solo. Ogni finestra di seq_len+1 caratteri produce una coppia input/target in cui il target è l'input spostato di una posizione:
testo tokenizzato: [g, a, t, t, o, ' ', m, a, n, g, i, ò, ...]
finestra i=0, seq_len=6:
input = [g, a, t, t, o, ' ']
target = [a, t, t, o, ' ', m]
↳ il modello vede "g" e deve prevedere "a"
vede "g,a" e deve prevedere "t"
vede "g,a,t" e deve prevedere "t" ... ecc.
I batch sono campionati in posizioni casuali del corpus ad ogni step. Non si usa un DataLoader tradizionale: il campionamento casuale rompe già le correlazioni temporali.
Loop di Training
Ottimizzatore: AdamW
AdamW è la variante standard per i transformer. Combina Adam (learning rate adattivo per parametro, basato su media mobile di gradienti e gradienti²) con weight decay corretto (penalità $\lambda\|w\|^2$ che impedisce ai pesi di crescere senza controllo).
| Iperparametro | Valore | Significato |
|---|---|---|
lr | 3 × 10⁻⁴ | Learning rate massimo |
min_lr | 3 × 10⁻⁵ | Learning rate minimo al termine del cosine decay |
warmup_steps | 200 | Step di salita lineare 0 → lr (con pesi casuali, lr alto = passo errato enorme) |
weight_decay | 0.1 | Forza della penalità L2 (solo su pesi ≥ 2D, non su bias/LayerNorm) |
betas | (0.9, 0.95) | Decadimento memoria: gradienti e gradienti² |
grad_clip | 1.0 | Gradient clipping: se ‖∇‖ > 1, scala il gradiente proporzionalmente |
Learning Rate Schedule
Il learning rate segue una curva in due fasi. La curva sinusoidale (cosine decay) converge meglio di un taglio brusco perché riduce il passo progressivamente man mano che il modello si avvicina al minimo.
Gradient Clipping
Nei transformer profondi il gradiente può "esplodere" propagandosi attraverso molti layer — un singolo step aggiorna i pesi in modo devastante. Il clipping preserva la direzione del gradiente ma ne limita la magnitudine:
$$\nabla \leftarrow \nabla \cdot \min\!\left(1,\; \frac{\text{max\_norm}}{\|\nabla\|}\right)$$
Cosa Aspettarsi Durante il Training
La loss è la cross-entropy: $-\log(p_{\text{corretta}})$. Con pesi casuali vale $\log(\text{vocab\_size})$. Il valore di partenza dipende dal tokenizer: ~4.5 con char-level (102 caratteri), ~9.68 con BPE-SPM a 16 000 token. Man mano che il modello impara, scende.
| Loss appross. | Cosa ha imparato il modello | Esempio di output |
|---|---|---|
| ~4.5 (inizio) | Niente — rumore puro. Ogni token è casuale | xkQm2!rLpòàìF... |
| ~3.2 | La distribuzione di frequenza dei caratteri. Spazi e vocali compaiono con la giusta frequenza | i l ae oi n... |
| ~2.8 | Spazi e punteggiatura al posto giusto. Il testo "respira" come italiano | Il ato manò il... |
| ~2.3 | Parole italiane riconoscibili ma spesso inventate. Morfologia parzialmente corretta | Il gatto mangiò il tropo. |
| ~2.0 | Parole reali del corpus. Frasi corte grammaticalmente plausibili ma semanticamente deboli | Il progetto di studio il modello. |
| ~1.7 | Strutture sintattiche più lunghe. Dipendenze a breve raggio. Dialogo riconoscibile | "Devi farlo," disse Marco, "prima di domani." |
| ~1.4 – 1.5 | Paragrafi con coerenza locale. Il modello "imita" lo stile del corpus | Frasi narrative plausibili con lessico del corpus |
Quando preoccuparsi
| Sintomo | Causa probabile | Intervento |
|---|---|---|
| Loss non scende oltre 3.5 | Learning rate troppo basso, o corpus troppo piccolo | Aumentare lr o corpus |
| Loss oscilla violentemente | Learning rate troppo alto, gradient explosion | Ridurre lr o abbassare grad_clip |
| Val loss cresce mentre train scende | Overfitting — il modello memorizza il corpus | Corpus più grande, più dropout |
| Generazione sempre uguale | Temperature troppo bassa, o modello in eval() senza campionamento | Aumentare temperature a 0.8–1.0 |
Perplexity — quality scorer
La perplexity (PPL) misura quanto il modello è "sorpreso" da un testo: una sequenza simile alla distribuzione di training ha PPL bassa, una sequenza inattesa ha PPL alta.
Formula e scala
PPL = exp(loss) dove loss è la cross-entropy media token per token. Run #3 (addestrato su Wikipedia IT) ha val loss 4.08, quindi PPL ≈ 59 sul suo set di validazione.
| Range PPL | Livello | Significato per Run #3 |
|---|---|---|
| < 60 | BASSA | Registro enciclopedico, italiano formale — come Wikipedia IT |
| 60–150 | MEDIA | Italiano buono, registro diverso da enciclopedico |
| 150–400 | ALTA | Testo degradato o misto lingua |
| 400+ | MOLTO ALTA | Lingua straniera, spam, OCR difettoso |
Cosa rileva (e cosa no)
La PPL non è un correttore grammaticale: misura la distanza dalla distribuzione di training. I risultati empirici su Run #3 mostrano un profilo preciso:
| Tipo | Rilevabile | Esempio |
|---|---|---|
| Lingua straniera | Sì — forte | "hi O'sdg" → PPL 2240 |
| Registro conversazionale | Sì — forte | "ciao sono francesco" → PPL 2690 |
| Typo in entità nominate | Sì — forte | "Saro Romano Impero" → PPL 615 vs "Sacro" → PPL 39 |
| Errori morfosintattici comuni | Segnale debole | "ha fondando" → PPL 79 ≈ testo corretto |
| Errori semantici con entità corrette | No | Modello non ha world knowledge causale |
Il meccanismo: entità nominate come "Sacro Romano Impero" compaiono migliaia di volte come unità coesa in Wikipedia — il modello conosce la sequenza con certezza quasi assoluta. Un typo che produce un token raro (Saro) rompe quella certezza. Un errore morfologico (ha fondando) usa token comuni in pattern simili presenti nel corpus — la sorpresa è contenuta.
Attenzione ai falsi negativi: testo ripetitivo (spam, boilerplate) è iper-prevedibile e ottiene PPL bassa. Il filtro PPL va sempre preceduto da un filtro euristico su linee duplicate e densità caratteri.
Uso come quality scorer
# REPL interattivo
python3 tools/eval_ppl.py
# Singolo testo
python3 tools/eval_ppl.py "Carlo Magno ha fondato il Sacro Romano Impero"
# Pipeline filtraggio corpus — scarta top 20% PPL
python3 tools/filter_corpus.py data/corpus_raw.txt \
--checkpoint checkpoints/best.pt \
--ppl-cut 0.80 --dedup \
--out data/corpus_clean.txt
# Con log dei documenti scartati (per ispezione manuale)
python3 tools/filter_corpus.py data/corpus_raw.txt \
--checkpoint checkpoints/best.pt \
--ppl-cut 0.95 --dedup \
--out data/corpus_ppl95.txt \
--rejected data/corpus_rejected.txt
Limite critico: il bias del quality scorer
Il PPL scorer è affidabile solo quando la distribuzione del scorer e quella del corpus target sono compatibili. Usare un modello addestrato su Wikipedia IT per filtrare testo web colloquiale introduce un bias sistematico: il modello giudica "sorprendente" l'italiano informale non perché sia di bassa qualità, ma perché non ne ha mai visto.
Esempio concreto: "ciao, sono Francesco" ottiene PPL 2690 su Run #4 (addestrato su Wikipedia). Non è testo degradato — è semplicemente un registro che il modello non ha mai incontrato. Applicare un cut percentile in questo contesto scarta preferenzialmente il testo colloquiale, rafforzando lo skew sintattico esistente invece di correggerlo.
Regola pratica:
- Corpus enciclopedico / stesso dominio del training → PPL scorer affidabile
- Corpus web / registro diverso dal training → solo filtro euristico + dedup; PPL scorer con soglia molto conservativa (p95+) per rimuovere solo garbage estremo
- Dopo ogni run che amplia la distribuzione, il quality scorer va rivalutato sul nuovo modello
Corpus
La scelta del corpus è determinante per il carattere del modello. La legge di scaling di Chinchilla (Hoffmann et al. 2022) stabilisce che il punto ottimale di training è 20 token per parametro: per un modello da 45.9M parametri, il target è 918M token. Run #4 è stato addestrato su 47.8M token — il 5.3% dell'ottimale.
Qualità prima della quantità
Chinchilla dimostra che un modello più piccolo addestrato su più dati di qualità supera un modello più grande addestrato su meno dati. La conseguenza pratica: prima di scalare l'architettura, è necessario costruire un corpus adeguato. Questo ha guidato la ricerca delle fonti per Run #5.
Ricerca delle fonti — italiano moderno
Il vincolo principale: il pubblico dominio italiano copre per definizione autori morti da più di 70 anni — cioè italiano pre-novecentesco. I dataset PD (Liber Liber, PleIAs Italian-PD, Project Gutenberg) contengono italiano arcaico del XIII–XIX secolo, inappropriato per un modello su italiano contemporaneo.
I dataset web (Common Crawl, mC4, OSCAR) contengono italiano moderno ma non sono PD. Per uso di ricerca sono comunque utilizzabili — la licenza CommonCrawl permette l'uso non commerciale.
Dataset scelto: CulturaX IT
uonlp/CulturaX config it è la combinazione pulita e deduplicata di mC4 e OSCAR italiano, prodotta per il pre-training di LLM. Contiene italiano moderno del web — giornalismo, blog, forum, articoli — già filtrato e deduplicato alla fonte. Richiede autenticazione HuggingFace (account gratuito).
| Dataset | Tipo | Decisione | Motivo |
|---|---|---|---|
uonlp/CulturaX it | Web moderno | Scelto | mC4+OSCAR pulito, italiano contemporaneo, streaming HF |
| Wikipedia IT dump | Enciclopedico | Incluso (25%) | Già estratto, base di Run #3/4 |
| EPUB personali | Narrativo IT | Incluso (5%) | Narrativa contemporanea, prima persona |
mii-community/liberliber-cleaned | Letterario PD | Escluso | Italiano arcaico XIII–XIX sec. |
PleIAs/Italian-PD | Letterario PD | Escluso | Focus pre-1884, stesso problema arcaismo |
gsarti/clean_mc4_it | Web | Escluso | Loading script Python deprecato, non caricabile |
Mix target Run #5 — 918M token
| Categoria | Token | % | Fonte |
|---|---|---|---|
| Web generale | ~550M | 60% | CulturaX IT filtrato |
| Enciclopedico | ~230M | 25% | Wikipedia IT dump |
| Tecnico | ~92M | 10% | PDF/DOCX personali |
| Narrativo | ~46M | 5% | Corpus EPUB personale |
Filtraggio CulturaX: euristico + dedup, no PPL scorer
Per il corpus web CulturaX il PPL scorer non viene usato come filtro principale (vedi sezione Perplexity — bias del quality scorer). Il filtro applicato è euristico + deduplicazione MD5 per paragrafo.
Risultati su 2.72M documenti (8 GB): 94.8% mantenuto, 5.2% scartato dall'euristico, dedup quasi zero (CulturaX è già deduplicato alla fonte). Un confronto controllato con filtraggio PPL a soglia conservativa (p95) è in corso per validare la scelta.
Caratteri non-ASCII — cosa tenere e cosa pulire
| Carattere | Codice | Azione | Motivazione |
|---|---|---|---|
| à â ç è é ì î ò ö ù | U+00C0–U+00FC | Tenere | Lettere accentate italiane e francesi — punteggiatura reale |
| ' ' " " | U+2018–U+201D | Tenere | Virgolette tipografiche — struttura del dialogo |
| … | U+2026 | Tenere | Punteggiatura narrativa (sospensione) |
| U+202F | Rimuovere | Narrow no-break space — artefatto invisibile da Word/tipografia francese |
# Pulizia u202f (una riga)
text = open('data/corpus.txt').read()
open('data/corpus.txt', 'w').write(text.replace(' ', ' '))
Comandi
| Comando | Funzione |
|---|---|
python3 train.py --download | Scarica corpus di prova (I Promessi Sposi, ~900 KB) |
python3 train.py | Training con configurazione default (5000 step, batch 32, seq 256) |
python3 train.py --steps 2000 --batch 16 | Training rapido per test |
python3 train.py --data mio_corpus.txt | Training su corpus personalizzato |
python3 train.py --generate "Il progetto" | Genera testo dal checkpoint migliore |
python3 model.py | Test del modello (forward pass, verifica loss iniziale) |
python3 attention.py | Test del meccanismo di attenzione |
checkpoints/best.pt e include i pesi del modello, la configurazione e il vocabolario — tutto il necessario per riprendere il training o fare inferenza.
Hardware & Deploy
Il training è progettato per girare su qualsiasi hardware. Il checkpoint è autocontenuto e portabile: include pesi del modello, stato dell'ottimizzatore (tutti i momenti di Adam) e il tokenizer SPM — tutto il necessario per riprendere il training su un device diverso senza ricominciare da zero.
Device detection automatica
--device auto (default) seleziona automaticamente il migliore disponibile, con questa priorità:
# train.py — selezione device (train(), riga ~452)
if cfg.device == 'auto':
if torch.backends.mps.is_available(): device = 'mps' # Mac M1/M2/M3
elif torch.cuda.is_available(): device = 'cuda' # GPU Nvidia
else: device = 'cpu' # fallback
Su Mac M2 seleziona MPS. Su EC2 con GPU Nvidia (MPS non disponibile) seleziona CUDA automaticamente. Il torch.load al resume usa map_location=device: i tensori vengono trasferiti sul device corretto indipendentemente da dove sono stati salvati. Override: --device cuda o --device mps.
Migrazione temporanea su hardware dedicato (EC2)
Il checkpoint è il testimone di passaggio: un processo viene fermato, il file best.pt viene trasferito, un nuovo processo riprende da quello stato sull'hardware target. Non esiste un "processo che migra" — esistono due processi sequenziali, legati da un file.
| # | Dove | Operazione |
|---|---|---|
| 1 | Mac | Sospendere o killare il processo locale (Ctrl+Z o kill -STOP) |
| 2 | Mac → EC2 | Trasferire codice, checkpoint e corpus con rsync |
| 3 | EC2 | Installare dipendenze Python |
| 4 | EC2 | Avviare training in tmux con --resume |
| 5 | Mac | Killare definitivamente il processo sospeso locale |
| 6 | EC2 → Mac | A training completato (o a checkpoint desiderato): rimpatriare best.pt |
| 7 | Mac | Riprendere localmente con --resume |
# ── 0. Verifica locale — checkpoint integro prima di trasferire ─────────
python3 train.py --generate "Il modello"
# Output atteso: Tokenizer: bpe-spm Step: 14000 val_loss: 5.3052
# Se fallisce, NON trasferire — checkpoint corrotto
# ── 1. Crea directory remote ─────────────────────────────────────────────
ssh ubuntu@<EC2-IP> "mkdir -p ~/slm/{data,checkpoints}"
# ── 2a. Codice sorgente (4 file) ─────────────────────────────────────────
rsync -avz --checksum --progress \
train.py model.py attention.py transformer.py \
ubuntu@<EC2-IP>:~/slm/
# ── 2b. Checkpoint — il testimone (--checksum garantisce integrità) ──────
rsync -avz --checksum --progress \
checkpoints/best.pt \
ubuntu@<EC2-IP>:~/slm/checkpoints/
# ── 2c. Corpus + cache token (la cache risparmia ~5 min di encoding) ─────
rsync -avz --progress data/corpus_bpe.txt ubuntu@<EC2-IP>:~/slm/data/
rsync -avz --progress \
"data/corpus_bpe.txt.bpe-spm.v16000.tokens.pt" \
ubuntu@<EC2-IP>:~/slm/data/ 2>/dev/null \
|| echo " Cache non trovata — train.py la rigenera automaticamente"
# ── 3. Setup dipendenze su EC2 ───────────────────────────────────────────
ssh ubuntu@<EC2-IP>
pip3 install torch sentencepiece
# Verifica GPU
python3 -c "import torch; print('CUDA:', torch.cuda.is_available(), \
'|', torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'nessuna')"
# Verifica checkpoint remote (ripete test del punto 0)
cd ~/slm && python3 train.py --generate "Il modello"
# ── 4. Avvio training in tmux ────────────────────────────────────────────
tmux new-session -s slm
python3 train.py \
--tokenizer bpe-spm --data data/corpus_bpe.txt \
--preset medium --steps 30000 --resume
# Ctrl+B poi D — stacca (processo continua in background)
# Comandi tmux di riferimento:
# tmux ls — sessioni attive
# tmux attach -t slm — riconnettersi
# tmux kill-session -t slm — fermare training
# Monitor GPU in tempo reale (in un secondo terminale SSH)
watch -n 5 nvidia-smi
# ── 5. Killa processo locale — EC2 ha il testimone ───────────────────────
kill -KILL $(pgrep -f "python3 train.py")
# Alternativa: fg poi Ctrl+C se il terminale è ancora aperto
# ── 6. Rimpatrio checkpoint → Mac ────────────────────────────────────────
rsync -avz --checksum --progress \
ubuntu@<EC2-IP>:~/slm/checkpoints/best.pt \
checkpoints/
# ── 7. Ripresa su Mac ────────────────────────────────────────────────────
python3 train.py \
--tokenizer bpe-spm --data data/corpus_bpe.txt \
--preset medium --steps 30000 --resume
best.pt include il tokenizer SPM, i pesi e lo stato dell'ottimizzatore — ma non i token pre-codificati. Il file .tokens.pt (cache di encoding) accelera l'avvio: se disponibile, trasferiscilo insieme al corpus. Se non c'è, train.py lo rigenera automaticamente.
Alternativa: Vast.ai (GPU spot market)
Per istanze EC2 senza GPU, Vast.ai offre GPU in affitto a ore. RTX 4090 a ~$0.40–0.60/ora: i 16 000 step rimanenti finiscono in ~20 ore (~$10–12 totali).
1 — Cerca l'istanza
Vai su Search e imposta:
| Filtro | Valore consigliato |
|---|---|
| GPU Model | RTX 4090 (o RTX 3090 se vuoi spendere meno) |
| Instance Type | On-Demand — non "Interruptible" (può essere reclamata a metà training) |
| CUDA Version | 12.x |
| Ubuntu | 22.04 o 20.04 |
| Disk Space | ≥ 30 GB |
2 — Configura il noleggio
Clicca Rent sull'istanza scelta. Si apre un pannello di configurazione:
- Edit Image / Docker Image: cerca
pytorche scegli un'immagine ufficiale, es.pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime. In alternativa usavastai/pytorchse disponibile come template. - Disk: 30 GB
- SSH Key: incolla la tua chiave pubblica (
cat ~/.ssh/id_ed25519.pub) oppure usa quella già salvata nell'account Vast.ai
Clicca Rent. Aspetta che lo stato diventi Running (1–3 minuti).
3 — Connessione e trasferimento
Vai su Instances, clicca Connect sull'istanza — ricevi un comando SSH del tipo:
ssh -p 12345 root@ssh4.vast.ai
La porta (es. 12345) è diversa per ogni istanza. Tutti i comandi successivi usano -p <PORTA>.
# Trasferimento file — nota -e "ssh -p PORTA" per rsync su Vast.ai
rsync -avz -e "ssh -p <PORTA>" \
train.py model.py attention.py transformer.py \
root@<HOST>:~/slm/
rsync -avz -e "ssh -p <PORTA>" \
checkpoints/best.pt \
root@<HOST>:~/slm/checkpoints/
rsync -avz --progress -e "ssh -p <PORTA>" \
data/corpus_bpe.txt \
root@<HOST>:~/slm/data/
# Cache token (opzionale)
rsync -avz -e "ssh -p <PORTA>" \
"data/corpus_bpe.txt.bpe-spm.v16000.tokens.pt" \
root@<HOST>:~/slm/data/ 2>/dev/null || echo "cache assente — verrà rigenera"
4 — Setup e avvio
ssh -p <PORTA> root@<HOST>
# sentencepiece (torch già nell'immagine)
pip install sentencepiece
# Verifica GPU e checkpoint
python3 -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
cd ~/slm && python3 train.py --generate "Il modello"
# Avvio in tmux
tmux new-session -s slm
python3 train.py \
--tokenizer bpe-spm --data data/corpus_bpe.txt \
--preset medium --steps 30000 --resume
# Ctrl+B poi D per staccarsi
5 — Rimpatrio e chiusura
# Sul Mac — rimpatria il checkpoint
rsync -avz -e "ssh -p <PORTA>" \
root@<HOST>:~/slm/checkpoints/best.pt \
checkpoints/
Processi paralleli e divergenza del checkpoint
Eseguire due istanze di train.py contemporaneamente a partire dallo stesso checkpoint non è training distribuito. È training parallelo non coordinato: i due processi divergono dal primo step.
Perché fg sul Mac non "salta" allo step dell'EC2
Un processo sospeso con Ctrl+Z (stato T — stopped) ha il suo intero stato in memoria RAM: pesi del modello, tutti i momenti di Adam per ogni parametro, step counter, seed del generatore casuale. Il file checkpoints/best.pt è uno snapshot scritto su disco a step 14 000 — il processo lo ha prodotto e poi ha continuato. fg risveglia il processo dalla RAM; non legge il file.
| Azione | Da dove riparte | Legge best.pt? |
|---|---|---|
fg (processo sospeso) | Stato in RAM — esattamente step 14 000 sul Mac | No — stato in memoria |
python3 train.py --resume | Legge best.pt dal disco, parte da step 14 000 | Sì — carica checkpoint |
Meccanismo della divergenza stocastica
Se avvii sia il processo Mac (fg) che un processo EC2 (--resume), entrambi partono con gli stessi pesi a step 14 000. Ma get_batch() usa torch.randint senza seed fisso:
# TextDataset.get_batch() — campionamento casuale a ogni step
ix = torch.randint(n, (batch_size,))
# Mac: [142, 8301, 27, ...] — sequenza indipendente
# EC2: [9443, 102, 5500, ...] — sequenza diversa
Batch diversi → gradienti diversi → aggiornamenti diversi. I pesi divergono da step 14 001. Dopo 1 000 step i due modelli sono su traiettorie separate nello spazio dei parametri. Ognuno scrive il proprio best.pt in base alla propria val loss — sovrascrivendo il file dell'altro se condividono il filesystem, oppure producendo due checkpoint incomparabili su macchine separate.
| Step | Processo A (Mac) | Processo B (EC2) |
|---|---|---|
| 14 000 | stessi pesi — da checkpoint | |
| 14 001 | batch [142, 8301, ...] | batch [9443, 102, ...] |
| 14 001 | ∇A ≠ ∇B | ∇B ≠ ∇A |
| 15 000 | pesi_A (traiettoria Mac) | pesi_B ≠ pesi_A |
| 30 000 | modello_A | modello_B — due modelli distinti |
A 30 000 step avresti due modelli non confrontabili. Non esiste un'operazione "fondi i pesi" che preservi le proprietà di entrambi: la media dei pesi funziona solo in contesti specifici (ensemble o federated learning con garanzie formali di convergenza).
train.py non ha questa infrastruttura di comunicazione — è progettato per training sequenziale, mono-processo. La portabilità del checkpoint è orizzontale nel tempo (sospendi → riprendi), non spaziale (più macchine simultanee).
File del Progetto
| File | Contenuto | Dipendenze |
|---|---|---|
attention.py | scaled_dot_product_attention, create_causal_mask, MultiHeadAttention | torch |
transformer.py | PositionalEncoding, FeedForward, TransformerBlock | attention.py |
model.py | GPTConfig, GPT (forward + generate + weight tying) | transformer.py |
train.py | Tokenizer, dataset, loop AdamW, checkpoint, generazione | model.py |
tensor_exercises.py | Esercizi interattivi su shape, view, transpose, Q·Kᵀ | torch |
matrix_exercises.py | Dot product, matmul, split-heads passo per passo | torch |
data/corpus.txt | Corpus di training (da fornire) | — |
checkpoints/best.pt | Checkpoint migliore (generato da train.py) | — |
Roadmap
| Fase | Obiettivo | Stack | Compute |
|---|---|---|---|
| 1 — From scratch completata ✓ |
Modello char-level funzionante su italiano. Comprensione completa di ogni componente. Val loss 1.7256 in 46 min | attention.py → transformer.py → model.py → train.py | M2 locale (8 GB) |
| 2 — BPE-SPM + Wikipedia IT in corso (Run #3) |
Tokenizer BPE-SPM 16 000 vocab su Wikipedia IT (47.8 M token). Preset medium: d_model 512, 12 layer, 45.9 M parametri. Step ~14 000 / 30 000, ETA ~7 ott 2026 | SentencePiece + corpus_bpe.txt (wiki + narrativa) | M2 locale (8 GB · MPS) |
| 3 — Fine-tuning | Continued pre-training su modello italiano open-source (Camoscio / Minerva / base LLaMA). Adattamento al dominio specifico | HuggingFace Transformers + PEFT/LoRA | GPU remota (8+ GB VRAM) |
| 4 — Multimodale | Pipeline: immagine → YOLO v3 → oggetti rilevati → testo → LM → analisi | ultralytics (YOLO) + pipeline custom | TBD |
model.py e transformer.py rimane invariato: cambia solo il tokenizer (Fase 2) e i pesi iniziali (Fase 3).
Il piano completo — 11 fasi dall'architettura alla quantizzazione, con stato corrente e componenti da aggiungere — è nella Roadmap di sviluppo.
Controllo dell'inferenza: logit bias e token di fallback
Il vettore dei logit
Ad ogni passo di generazione il modello produce un vettore di 16.256 valori reali — uno per ogni token del vocabolario. Questi valori si chiamano logit: score grezzi, non normalizzati. Il softmax li trasforma in una distribuzione di probabilità; il campionatore (top-k, top-p, temperature) estrae il token successivo da quella distribuzione.
Logit bias
Il parametro logit_bias dell'endpoint /completion di llama-server accetta una lista di coppie [token_id, valore]. Immediatamente prima del softmax, il valore specificato viene sommato al logit del token corrispondente. L'operazione è:
logit_modificato[id] = logit_originale[id] + valore
Con valore = -100, il logit scende così in basso che dopo il softmax la probabilità del token è praticamente zero — non viene mai selezionato. Con valore = +10 si può invece favorire un token specifico.
Il logit bias agisce solo a inference time, senza modificare i pesi del modello. È reversibile: basta non passarlo nella richiesta successiva.
Token di fallback byte (indici 16000–16255)
Il vocabolario BPE-SPM addestrato sul corpus italiano contiene 16.000 token. La specifica GGUF richiede che il tokenizer sia in grado di codificare qualsiasi sequenza di byte, anche quelle non presenti nel vocabolario BPE. Per questo, durante la conversione, vengono aggiunti 256 token sintetici — uno per ogni byte possibile (0x00–0xFF) — agli indici 16000–16255. Ogni token corrisponde esattamente a un byte: <0x00>, <0x01>, …, <0xFF>.
Questi token di fallback sono token di emergenza: il tokenizer li usa solo per byte che non possono essere rappresentati altrimenti. In condizioni normali non dovrebbero mai comparire nell'output generato.
Perché i byte token diventano competitivi
Il corpus CulturaX contiene artefatti di encoding web: caratteri di controllo ASCII (tab, carriage return, form feed), byte non-printable da HTML mal decodificato, sequenze binarie in mezzo al testo. Il modello ha appreso che questi byte token compaiono in certi contesti — spesso dopo nomi propri rari, titoli di pagina, o intestazioni di sito.
Quando il modello incontra un contesto a alta incertezza (prompt insolito, nome proprio a bassa frequenza nel corpus), la distribuzione di probabilità si appiattisce: nessun token ha un vantaggio netto. In questo stato, i byte token — pur con probabilità individuale bassa — diventano competitivi rispetto ai token di testo. Con temperature=0.8 e top_p=0.9 il campionatore può selezionarli. Una volta generato un byte token, il contesto successivo spinge verso altri byte token: il loop si autoalimenta, l'output degenera in caratteri illeggibili.
Fix operativo
Generare l'array di bias per tutti i 256 byte token e passarlo ad ogni richiesta:
BIAS=$(python3 -c "import json; print(json.dumps([[i,-100] for i in range(16000,16256)]))")
curl http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d "{
\"prompt\": \"Il tuo prompt\",
\"n_predict\": 150,
\"temperature\": 0.8,
\"top_p\": 0.9,
\"repeat_penalty\": 1.3,
\"logit_bias\": $BIAS
}"
Con questo fix il vocabolario operativo scende a 16.000 token. Il modello non può più generare byte token, indipendentemente da quanto sia alta la loro probabilità nei pesi.
Corpus e conoscenza di dominio: analisi shard-by-shard
Un test sistematico su 8 prompt semantici (tecnico, estetico, letterario, storico, scientifico, procedurale) condotto su 6 modelli (run4-v6 + shard run6-aa/ab/ac/ad/ae) ha rivelato il cambio di distribuzione dovuto al passaggio da Wikipedia a CulturaX:
| Dominio | run4-v6 (Wikipedia) | run6-aa–ac (CulturaX early) | run6-ad (sweet spot) | run6-ae (CulturaX late) |
|---|---|---|---|---|
| Enciclopedico (Dante, storia) | ✓✓ Firenze, Commedia, date plausibili | ✗ date inventate, off-topic | ✗ date inventate | ✗ date inventate |
| Scientifico (fotosintesi) | ✓ spettroscopia, radiazione | ✗ fotografia, medicina | ✗ terapia muscolare | ✗ fotoblog |
| Geografico (Puglia) | ✗ Liguria | ½ Puglia + Campania | ✓ Lecce, Regione Puglia | ✓ Bari + Lecce |
| Tecnico IT (kernel) | ✗ off-topic | ✓ GNU, versioni kernel | ½ backup | ✗✗ testo legale |
| Procedurale (ricetta) | ½ cibo + storia | ✓ ricetta con istruzioni | ✓ ingredienti specifici | ✓ ricetta dettagliata |
| Creativo/estetico | ✗ Wikipedia bio | ½ blog emozionale | ✓ poetico ("la luna piena di luce") | ✗ hotel/turismo |
Interpretazione: Wikipedia fornisce conoscenza enciclopedica e scientifica strutturata. CulturaX aggiunge conoscenza geografica locale, contenuto pratico-procedurale e stili giornalistici. Ma da run6-ae in poi il corpus web superficiale (menu di navigazione, cookie banner, riferimenti politici correnti) inizia a prevalere sui contenuti sostanziali. run6-ad è il punto in cui il bilanciamento è migliore.
Per completare il Chinchilla parity (2 shard mancanti, ~262M token) partendo da run6-ad, il corpus sostitutivo deve essere ortogonale a CulturaX: Wikipedia dump più completo, Wikisource IT, corpus letterario.
SFT — Supervised Fine-Tuning
Il pre-training insegna al modello la lingua: grammatica, coerenza, stile, conoscenza enciclopedica. Non insegna al modello come rispondere. Un modello pre-addestrato dato il prompt "Qual è la capitale della Francia?" continua liberamente — magari producendo altre domande o un paragrafo enciclopedico su Parigi. SFT lo allena a rispondere.
Differenza tecnica: loss su tutti i token vs solo la risposta
Il dataset SFT è una lista di coppie (prompt, completion):
{"prompt": "Fissa un appuntamento con Marco venerdì alle 15", "completion": "{\"intent\": \"create_event\", \"title\": \"Appuntamento\", \"person\": \"Marco\", \"date\": \"venerdì\", \"time\": \"15:00\"}"}
Nel pre-training la cross-entropy è calcolata su tutti i token. Nell'SFT è calcolata solo sui token del completion. I token del prompt ricevono target = -100, che F.cross_entropy ignora per costruzione PyTorch. Il modello apprende a produrre risposte, non a predire le domande.
# Masked loss: ignora il prompt, calcola solo sul completion
labels = torch.full_like(input_ids, -100)
labels[:, prompt_len:] = input_ids[:, prompt_len:] # solo completion
loss = F.cross_entropy(logits.view(-1, vocab), labels.view(-1), ignore_index=-100)
Catastrophic forgetting
Con full SFT tutti i 45.9M parametri vengono aggiornati ad ogni esempio. Con un dataset piccolo (100–200 esempi), il modello ha troppa capacità rispetto al segnale disponibile. I pesi si spostano verso il nuovo dataset e le connessioni per il vocabolario generale si indeboliscono. Un probe set (frasi del pre-training non toccate dall'SFT) permette di misurare il forgetting in tempo reale: se la perplexity sul probe sale durante l'SFT, il modello sta dimenticando.
Risultati del test SFT (intent recognition, Phase 09)
140 esempi, 6 intent, 500 step. Full SFT insufficiente: overfitting, hallucination sui valori degli entity, classi minoritarie collassate sulla dominante. Diagnosi: 20-25 esempi per intent è troppo poco per la vastità dei parametri. Decisione: passare a LoRA per ridurre la superficie trainabile.
LoRA — Low-Rank Adaptation
Il problema che LoRA risolve
Specializzare un modello su un task con full fine-tuning significa aggiornare tutti i 45.9M parametri ad ogni passo di training. Con un dataset piccolo (100–500 esempi) questo produce overfitting: i parametri si adattano al dataset di training invece che al task. Con un dataset grande, il costo computazionale è proibitivo su hardware domestico.
L'intuizione di LoRA (Hu et al., 2021): le modifiche utili ai pesi durante il fine-tuning tendono a vivere in uno spazio di bassa dimensione. Non servono 45.9M gradi di libertà per specializzare il modello — ne bastano molti di meno, purché siano posizionati nei posti giusti.
La decomposizione a basso rango
Ogni matrice di peso W ha dimensioni (d_out × d_in). Invece di aggiornare W direttamente, LoRA aggiunge due matrici piccole A e B:
W' = W + ΔW dove ΔW = (alpha/r) · B @ A
A ∈ ℝ^(r × d_in) — rango r, inizializzata random (Kaiming uniform)
B ∈ ℝ^(d_out × r) — rango r, inizializzata a zero
ΔW ∈ ℝ^(d_out × d_in) — stessa shape di W, ma rango al più r
Il rango r di una matrice è il numero di dimensioni linearmente indipendenti. Una matrice (512 × 512) di rango r=8 può essere scritta esattamente come il prodotto di due matrici (512 × 8) e (8 × 512). ΔW "vive" in uno spazio 8-dimensionale all'interno dello spazio 512-dimensionale di W. Questo è sufficiente per catturare la specializzazione di dominio, che è tendenzialmente a bassa dimensione.
Perché B inizializzata a zero?
All'inizio del training, ΔW = B @ A. Con B=0, ΔW=0 e W'=W. Il modello LoRA si comporta esattamente come il base model per i primi passi di training, senza perturbazioni iniziali. Questo garantisce uno starting point stabile: il gradiente della loss parte da un punto noto (il base model) invece che da un punto casuale disturbato.
Se si inizializzasse anche A a zero, i gradienti di A e B sarebbero entrambi zero al primo passo (nessun apprendimento). L'asimmetria A≠0, B=0 è scelta per garantire delta nullo ma gradienti non nulli.
Il parametro alpha/r
Il fattore di scala alpha/r modula quanto peso dare al delta rispetto ai pesi originali. Con alpha=16 e r=8, scale=2. Con alpha=r, scale=1 (delta non scalato). Il parametro esiste per separare la geometria del problema (r, che determina la capacità) dalla scala dell'aggiornamento (alpha, che determina l'intensità). Nella pratica:
- alpha = r: scala neutra. L'LR dell'ottimizzatore controlla tutto.
- alpha = 2r: il delta scala del doppio. Si può usare un LR più basso.
- Regola empirica: tenere alpha/r costante quando si fa sweep su r.
Forward pass con LoRALinear
# W(x) + (alpha/r) · B(A(x)) — nota: B inizialmente = 0
def forward(self, x):
return self.linear(x) + self.lora_B(self.lora_A(x)) * self.scale
# Equivalente in algebra: se A.shape=(r,d_in) e B.shape=(d_out,r):
# output = x @ W.T + x @ A.T @ B.T × scale
# = x @ (W + scale · B @ A).T ← stesso risultato di W'
Quale rango scegliere?
| r | Parametri trainabili (su 45.9M) | Adapter (KB) | Uso tipico |
|---|---|---|---|
| 4 | 196,608 — 0.43% | ~780 | Task molto semplici, classificazione |
| 8 | 393,216 — 0.85% | ~1,564 | Default: buon bilanciamento capacità/efficienza |
| 16 | 786,432 — 1.71% | ~3,128 | Task complessi, instruction following |
| 32 | 1,572,864 — 3.43% | ~6,256 | Specializzazione profonda di dominio |
| 64 | 3,145,728 — 6.86% | ~12,512 | Avvicina il costo di full fine-tuning — raramente necessario |
Un rango più alto non è sempre meglio: con dataset piccoli, un r grande dà più capacità di overfitting. r=8 è il punto di partenza standard; si aumenta solo se la val loss non scende abbastanza.
Perché solo le proiezioni attention?
Le quattro matrici di attenzione (W_q, W_k, W_v, W_o) determinano come il modello pesa e combina l'informazione nel contesto. Specializzarle equivale a cambiare il "cosa guarda" del modello, non il "cosa sa". Le FFN (W_1, W_2 in ogni blocco) contengono invece la conoscenza fattuale — l'associazione token↔concetto appresa durante il pre-training. Toccarle rischia catastrophic forgetting.
In pratica: specializzare attention è sufficiente per orientare il comportamento su nuovi task (instruction following, intent recognition, stile), senza degradare la conoscenza linguistica.
SFT vs LoRA vs Full fine-tuning
| Approccio | Param trainabili | Dataset minimo | Rischio forgetting | Checkpoint |
|---|---|---|---|---|
| Full fine-tuning | 45.9M (100%) | 5.000–50.000 esempi | Alto su dataset piccoli | 553 MB (copia completa) |
| SFT (masked loss) | 45.9M (100%) | 500–5.000 esempi | Alto se LR troppo alto | 553 MB |
| LoRA r=8 | 393k (0.85%) | 100–1.000 esempi | Basso (W frozen) | 1.5 MB (adapter) |
Con dataset piccoli (come il nostro sft_mini.jsonl con 140 esempi), LoRA è l'unica scelta praticabile: l'adapter ha 393k parametri invece di 45.9M, il rischio di overfitting è strutturalmente ridotto di due ordini di grandezza.
Merge vs adapter separato
Al termine del training LoRA ci sono due opzioni:
- Adapter separato: si salvano solo A e B (~1.5 MB). Per l'inferenza si carica il base model + adapter. Permette di avere N adapter per lo stesso base model, switchabili a runtime. Il base model non viene mai modificato.
- Merge: si calcolano W' = W + ΔW e si salva un GPT standard. Il checkpoint merged (~553 MB) è compatibile con tutti gli strumenti esistenti (infer.py, GGUF converter) senza modifiche. Non si può tornare al base model originale senza tenerlo separato.
# Merge: fonde l'adapter nel base model
merged = merge_lora_model(lora_model) # ritorna GPT standard, eval mode
torch.save({'model': merged.state_dict(), 'config': cfg}, 'merged.pt')
# Adapter separato: porta solo A e B
save_lora(lora_model, 'adapter.pt') # ~1.5 MB
# Riapplica adapter al base model in un altro momento
model = GPT(cfg)
model.load_state_dict(torch.load('best.pt')['model'])
model = load_lora(model, 'adapter.pt')
Ordine corretto nella pipeline
Pre-training → SFT (opzionale) → LoRA
Un run di pre-training aggiuntivo invalida tutti gli adapter LoRA e i checkpoint SFT già prodotti: l'adapter è calibrato per specifici pesi W, se W cambia la somma porta il modello in un punto scorretto. LoRA è sempre l'ultimo passo. Prima di qualsiasi nuovo run di pre-training, fare un backup del base model che si intende usare come base per LoRA.
Parametri del progetto — riepilogo
| Config | Valore | Note |
|---|---|---|
| Base model | run6-ad (val 3.3524) | Sweet spot qualitativo — non run6-ag (contaminato) |
| Target modules | W_q, W_k, W_v, W_o | Tutte le proiezioni attention, tutti i 12 blocchi |
| r (rank) | 8 | Default — 48 coppie A+B totali |
| alpha | 16 | scale = alpha/r = 2 |
| Parametri trainabili | 393,216 | 0.85% del totale |
| Adapter su disco | ~1,564 KB | Solo A e B, senza base model |
| Dataset | data/sft_mini.jsonl + sft_synth_raw.jsonl | 140 validati + 1060 sintetici (in validazione) → target 1200 |
Output del modello e pipeline applicativa
Il LoRA adapter produce NLU extraction, non risoluzione delle date. L'output è JSON con entità grezze fedeli al testo in input:
{"intent": "create_event", "contact": "Marco", "date": "venerdì", "time": "15:00"}
La trasformazione in un datetime assoluto usabile da una calendar API è responsabilità del layer applicativo, non del modello. Separare i due livelli è la scelta corretta per tre ragioni: il modello non conosce la data corrente a inference time; la risoluzione di espressioni come "domani" o "primo del mese" è un problema deterministico risolto meglio da un parser che da un LLM; addestrare il modello a fare date arithmetic spreca capacità parametrica su un task che non richiede comprensione linguistica.
| Layer | Input | Output | Strumento |
|---|---|---|---|
| NLU — questo adapter | Frase italiana | JSON intent + entità grezze | SLM + LoRA |
| Date resolver | Espressione + data corrente | Data ISO assoluta | dateparser |
| Calendar API | JSON con datetime assoluto | Evento creato | Google Calendar, CalDAV |
Utente: "Crea appuntamento con Marco venerdì alle 15"
↓ SLM + LoRA adapter
{"intent":"create_event","contact":"Marco","date":"venerdì","time":"15:00"}
↓ dateparser (anchor = data corrente)
{"contact":"Marco","start":"2026-10-16T15:00:00"}
↓ Calendar API
Evento creato ✓
Conseguenza diretta sul dataset: gli esempi di training devono contenere espressioni temporali grezze, non date assolute. La data corrente non deve mai comparire nel dataset SFT.
Glossario
| Termine | Definizione |
|---|---|
| Autoregressive | Il modello genera un token alla volta, usando i token precedenti come contesto |
| Backpropagation | Algoritmo che calcola il gradiente della loss rispetto a ogni parametro applicando la chain rule all'indietro nel grafo computazionale |
| BPE | Byte Pair Encoding — algoritmo che costruisce un vocabolario di sotto-parole fondendo iterativamente le coppie di caratteri più frequenti |
| Causal mask | Matrice triangolare inferiore che impedisce a ogni token di vedere i token futuri durante l'attenzione |
| Cross-entropy loss | $-\log(p_{\text{corretta}})$ — misura quanto il modello è sorpreso dal token corretto. Valore atteso con pesi casuali: $\log(\text{vocab\_size})$ |
| Decoder-only | Architettura transformer senza encoder: riceve solo il contesto precedente, genera un token alla volta |
| Dropout | Regolarizzazione: spegne casualmente una frazione di neuroni durante il training, prevenendo l'overfitting |
| Embedding | Lookup table che converte un ID intero in un vettore denso. I vettori si avvicinano nello spazio per token semanticamente simili |
| Gradient clipping | Limita la norma del gradiente a una soglia massima, prevenendo aggiornamenti destabilizzanti |
| LayerNorm | Normalizza ogni vettore per media e varianza delle sue features. Parametri $\gamma$ e $\beta$ appresi |
| Logit | Score non normalizzato per ogni token del vocabolario. Il softmax li converte in probabilità |
| Overfitting | Il modello memorizza il training set invece di generalizzare: val loss cresce, train loss scende |
| Pre-norm | LayerNorm applicata prima dell'attention e del FFN (al contrario del post-norm originale). Più stabile |
| Residual connection | $x \leftarrow x + f(x)$. Crea una "corsia diretta" per il gradiente, risolvendo il vanishing gradient problem |
| Self-supervised learning | Il testo si etichetta da solo: input $[t_0\ldots t_{n-1}]$, target $[t_1\ldots t_n]$. Nessuna annotazione umana |
| Temperature | Divide i logit prima del softmax: < 1 accentua le differenze (più prevedibile), > 1 le appiattisce (più casuale) |
| Top-k | Azzera tutti i token tranne i k più probabili prima del campionamento. Riduce le uscite incoerenti |
| Weight tying | Embedding e layer di output condividono la stessa matrice di pesi. Riduce i parametri e migliora la generalizzazione |