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.

Il progetto è sia didattico che applicativo. La comprensione profonda dei meccanismi interni è parte esplicita degli obiettivi, non un effetto collaterale.

Obiettivi

DimensioneObiettivo
StudioComprensione profonda di backpropagation, attention, training loop — costruendo ogni componente a mano
ProdottoUn modello utilizzabile su testi italiani di dominio professionale
ProfessionaleBase tecnica per adapter leggeri su piattaforme per clienti di dominio specifico
MultimodaleIntegrazione 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.

Token IDs
(interi, es. [73, 108, 32, ...])
(batch, seq_len)
Embedding
vocab_size × d_model
(batch, seq_len, 256)
Positional Encoding
sinusoidale, fissa
(batch, seq_len, 256)
TransformerBlock × 6
ripetuto N=6 volte
(batch, seq_len, 256)
LayerNorm finale
Linear → logits
weight tying con Embedding
(batch, seq_len, vocab_size)
Probabilità sul prossimo token

Configurazione del modello

ParametroValoreSignificato
vocab_sizedal corpusCaratteri unici nel testo di training (tipicamente 80–120)
d_model256Dimensione del vettore che rappresenta ogni token
num_heads8Teste di attenzione parallele (d_k = 32 per testa)
num_layers6Blocchi Transformer impilati
d_ff1024Dimensione interna Feed-Forward (4 × d_model)
max_seq_len256Lunghezza massima del contesto in caratteri
dropout0.1Regolarizzazione: 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

ComponenteScopoDettaglio
Multi-Head AttentionContestualizzazioneOgni token aggrega info dai token precedenti
Feed-Forward NetworkElaborazione token-wiseLinear(256→1024) → GELU → Linear(1024→256)
LayerNormStabilizzazioneNormalizza per feature, parametri γ e β appresi
Residual connectionFlusso del gradientePermette al gradiente di scorrere direttamente ← previene vanishing
DropoutRegolarizzazione10% 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.

ParametroEffettoValore consigliato
temperature= 1.0: distribuzione originale. < 1: più prevedibile. > 1: più creativo/casuale0.7 – 0.9
top_kCampiona solo tra i k token più probabili. Riduce le uscite incoerenti20 – 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.

ApproccioVocab sizeFaseProContro
Char-level102Run #1 — completato ✓Zero dipendenze, comprensibile, baseline idealeSequenze lunghe, meno efficiente, nessuna morfologia appresa
BPE-SPM16 000Run #3, #4, #5, #6 — completati ✓Standard industriale, gestisce morfologia italiana, efficiente su corpus enciclopedicoRichiede training separato del tokenizer (SentencePiece)
Word-level50.000+non usatoIntuitivoOOV 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.

RunTokenizerVocabNovità rispetto al run precedente
Run #1Char-level102Baseline char-level, prima architettura completa
Run #3BPE-SPM16 000Tokenizer addestrato, Wikipedia IT 47.8M token, val 4.0768
Run #4BPE-SPM (invariato)16 000+RoPE, +seq_len 512, 12 layer, val 3.5861 — modello corrente
SFT (Phase 09)BPE-SPM (invariato)16 000Masked loss, dataset JSONL 140 esempi, checkpoint sft_intent_v1.pt
LoRA (Phase 10)BPE-SPM (invariato)16 000W 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).

IperparametroValoreSignificato
lr3 × 10⁻⁴Learning rate massimo
min_lr3 × 10⁻⁵Learning rate minimo al termine del cosine decay
warmup_steps200Step di salita lineare 0 → lr (con pesi casuali, lr alto = passo errato enorme)
weight_decay0.1Forza della penalità L2 (solo su pesi ≥ 2D, non su bias/LayerNorm)
betas(0.9, 0.95)Decadimento memoria: gradienti e gradienti²
grad_clip1.0Gradient 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.

0 200 5000 3×10⁻⁴ 3×10⁻⁵ warmup cosine decay step lr

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.

La tabella sotto è specifica per il Run #1 (char-level, completato). Con BPE-SPM (Run #3) la loss parte da ~9.68 e la scala dei valori è diversa. Le fasi qualitative (frequenza → morfologia → sintassi → coerenza) rimangono analoghe, ma i numeri assoluti non sono comparabili.
Loss appross.Cosa ha imparato il modelloEsempio di output
~4.5 (inizio)Niente — rumore puro. Ogni token è casualexkQm2!rLpòàìF...
~3.2La distribuzione di frequenza dei caratteri. Spazi e vocali compaiono con la giusta frequenzai l ae oi n...
~2.8Spazi e punteggiatura al posto giusto. Il testo "respira" come italianoIl ato manò il...
~2.3Parole italiane riconoscibili ma spesso inventate. Morfologia parzialmente correttaIl gatto mangiò il tropo.
~2.0Parole reali del corpus. Frasi corte grammaticalmente plausibili ma semanticamente deboliIl progetto di studio il modello.
~1.7Strutture sintattiche più lunghe. Dipendenze a breve raggio. Dialogo riconoscibile"Devi farlo," disse Marco, "prima di domani."
~1.4 – 1.5Paragrafi con coerenza locale. Il modello "imita" lo stile del corpusFrasi narrative plausibili con lessico del corpus
Nota: un char-level model da 4.8M parametri non raggiungerà la coerenza semantica a lungo raggio — quella richiede miliardi di parametri e BPE. L'obiettivo del Run #1 era vedere il modello imparare la struttura superficiale dell'italiano: ortografia, morfologia, ritmo della prosa. Raggiunto con val loss 1.7256 in 46 minuti.

Quando preoccuparsi

SintomoCausa probabileIntervento
Loss non scende oltre 3.5Learning rate troppo basso, o corpus troppo piccoloAumentare lr o corpus
Loss oscilla violentementeLearning rate troppo alto, gradient explosionRidurre lr o abbassare grad_clip
Val loss cresce mentre train scendeOverfitting — il modello memorizza il corpusCorpus più grande, più dropout
Generazione sempre ugualeTemperature troppo bassa, o modello in eval() senza campionamentoAumentare 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 PPLLivelloSignificato per Run #3
< 60BASSARegistro enciclopedico, italiano formale — come Wikipedia IT
60–150MEDIAItaliano buono, registro diverso da enciclopedico
150–400ALTATesto degradato o misto lingua
400+MOLTO ALTALingua 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:

TipoRilevabileEsempio
Lingua stranieraSì — forte"hi O'sdg" → PPL 2240
Registro conversazionaleSì — forte"ciao sono francesco" → PPL 2690
Typo in entità nominateSì — forte"Saro Romano Impero" → PPL 615 vs "Sacro" → PPL 39
Errori morfosintattici comuniSegnale debole"ha fondando" → PPL 79 ≈ testo corretto
Errori semantici con entità corretteNoModello 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
La scala di riferimento (BASSA/MEDIA/ALTA) è relativa al corpus di training del modello usato come scorer. Run #3/4 (Wikipedia IT) producono soglie valide per testo enciclopedico. Un modello addestrato su corpus più vari produrrà soglie diverse e più rappresentative dell'italiano moderno.

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).

DatasetTipoDecisioneMotivo
uonlp/CulturaX itWeb modernoSceltomC4+OSCAR pulito, italiano contemporaneo, streaming HF
Wikipedia IT dumpEnciclopedicoIncluso (25%)Già estratto, base di Run #3/4
EPUB personaliNarrativo ITIncluso (5%)Narrativa contemporanea, prima persona
mii-community/liberliber-cleanedLetterario PDEsclusoItaliano arcaico XIII–XIX sec.
PleIAs/Italian-PDLetterario PDEsclusoFocus pre-1884, stesso problema arcaismo
gsarti/clean_mc4_itWebEsclusoLoading script Python deprecato, non caricabile

Mix target Run #5 — 918M token

CategoriaToken%Fonte
Web generale~550M60%CulturaX IT filtrato
Enciclopedico~230M25%Wikipedia IT dump
Tecnico~92M10%PDF/DOCX personali
Narrativo~46M5%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

CarattereCodiceAzioneMotivazione
à â ç è é ì î ò ö ùU+00C0–U+00FCTenereLettere accentate italiane e francesi — punteggiatura reale
' ' " "U+2018–U+201DTenereVirgolette tipografiche — struttura del dialogo
…U+2026TenerePunteggiatura narrativa (sospensione)
 U+202FRimuovereNarrow 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

ComandoFunzione
python3 train.py --downloadScarica corpus di prova (I Promessi Sposi, ~900 KB)
python3 train.pyTraining con configurazione default (5000 step, batch 32, seq 256)
python3 train.py --steps 2000 --batch 16Training rapido per test
python3 train.py --data mio_corpus.txtTraining su corpus personalizzato
python3 train.py --generate "Il progetto"Genera testo dal checkpoint migliore
python3 model.pyTest del modello (forward pass, verifica loss iniziale)
python3 attention.pyTest del meccanismo di attenzione
Il checkpoint migliore (minor val loss) viene salvato automaticamente in 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.

#DoveOperazione
1MacSospendere o killare il processo locale (Ctrl+Z o kill -STOP)
2Mac → EC2Trasferire codice, checkpoint e corpus con rsync
3EC2Installare dipendenze Python
4EC2Avviare training in tmux con --resume
5MacKillare definitivamente il processo sospeso locale
6EC2 → MacA training completato (o a checkpoint desiderato): rimpatriare best.pt
7MacRiprendere 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
Il corpus non è nel checkpoint. 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).

L'immagine Docker non è un filtro di ricerca. Si sceglie durante la configurazione del noleggio, non nella pagina di ricerca. La ricerca filtra per specifiche hardware (GPU, CUDA, Ubuntu, prezzo).

1 — Cerca l'istanza

Vai su Search e imposta:

FiltroValore consigliato
GPU ModelRTX 4090 (o RTX 3090 se vuoi spendere meno)
Instance TypeOn-Demand — non "Interruptible" (può essere reclamata a metà training)
CUDA Version12.x
Ubuntu22.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 pytorch e scegli un'immagine ufficiale, es. pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime. In alternativa usa vastai/pytorch se 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/
Dopo il rimpatrio: distruggi l'istanza (Instances → Destroy), non solo fermala. Vast.ai addebita anche lo storage dell'istanza ferma. Verifica sempre lo stato nella dashboard prima di chiudere la sessione.

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.

AzioneDa dove riparteLegge best.pt?
fg (processo sospeso)Stato in RAM — esattamente step 14 000 sul MacNo — stato in memoria
python3 train.py --resumeLegge best.pt dal disco, parte da step 14 000Sì — 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.

StepProcesso A (Mac)Processo B (EC2)
14 000stessi pesi — da checkpoint
14 001batch [142, 8301, ...]batch [9443, 102, ...]
14 001∇A ≠ ∇B∇B ≠ ∇A
15 000pesi_A (traiettoria Mac)pesi_B ≠ pesi_A
30 000modello_Amodello_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).

La differenza con PyTorch DDP (training distribuito): in DDP ogni processo calcola i gradienti su un shard diverso del batch, poi li aggrega con una all-reduce prima di aggiornare i pesi. Tutti i processi aggiornano gli stessi pesi, in sincrono, a ogni step. 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

FileContenutoDipendenze
attention.pyscaled_dot_product_attention, create_causal_mask, MultiHeadAttentiontorch
transformer.pyPositionalEncoding, FeedForward, TransformerBlockattention.py
model.pyGPTConfig, GPT (forward + generate + weight tying)transformer.py
train.pyTokenizer, dataset, loop AdamW, checkpoint, generazionemodel.py
tensor_exercises.pyEsercizi interattivi su shape, view, transpose, Q·Kᵀtorch
matrix_exercises.pyDot product, matmul, split-heads passo per passotorch
data/corpus.txtCorpus di training (da fornire)—
checkpoints/best.ptCheckpoint migliore (generato da train.py)—

Roadmap

FaseObiettivoStackCompute
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
Le Fasi 2 e 3 non richiedono riscrivere l'architettura. Il codice in 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.

La presenza di byte token nell'output è anche un indicatore di qualità del corpus. La loro comparsa conferma che il corpus di training conteneva artefatti di encoding non filtrati. Il logit bias è una correzione a inference time, non una soluzione al problema corpus.

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:

Dominiorun4-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?

rParametri trainabili (su 45.9M)Adapter (KB)Uso tipico
4196,608 — 0.43%~780Task molto semplici, classificazione
8393,216 — 0.85%~1,564Default: buon bilanciamento capacità/efficienza
16786,432 — 1.71%~3,128Task complessi, instruction following
321,572,864 — 3.43%~6,256Specializzazione profonda di dominio
643,145,728 — 6.86%~12,512Avvicina 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

ApproccioParam trainabiliDataset minimoRischio forgettingCheckpoint
Full fine-tuning45.9M (100%)5.000–50.000 esempiAlto su dataset piccoli553 MB (copia completa)
SFT (masked loss)45.9M (100%)500–5.000 esempiAlto se LR troppo alto553 MB
LoRA r=8393k (0.85%)100–1.000 esempiBasso (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

ConfigValoreNote
Base modelrun6-ad (val 3.3524)Sweet spot qualitativo — non run6-ag (contaminato)
Target modulesW_q, W_k, W_v, W_oTutte le proiezioni attention, tutti i 12 blocchi
r (rank)8Default — 48 coppie A+B totali
alpha16scale = alpha/r = 2
Parametri trainabili393,2160.85% del totale
Adapter su disco~1,564 KBSolo A e B, senza base model
Datasetdata/sft_mini.jsonl + sft_synth_raw.jsonl140 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.

LayerInputOutputStrumento
NLU — questo adapterFrase italianaJSON intent + entità grezzeSLM + LoRA
Date resolverEspressione + data correnteData ISO assolutadateparser
Calendar APIJSON con datetime assolutoEvento creatoGoogle 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

TermineDefinizione
AutoregressiveIl modello genera un token alla volta, usando i token precedenti come contesto
BackpropagationAlgoritmo che calcola il gradiente della loss rispetto a ogni parametro applicando la chain rule all'indietro nel grafo computazionale
BPEByte Pair Encoding — algoritmo che costruisce un vocabolario di sotto-parole fondendo iterativamente le coppie di caratteri più frequenti
Causal maskMatrice 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-onlyArchitettura transformer senza encoder: riceve solo il contesto precedente, genera un token alla volta
DropoutRegolarizzazione: spegne casualmente una frazione di neuroni durante il training, prevenendo l'overfitting
EmbeddingLookup table che converte un ID intero in un vettore denso. I vettori si avvicinano nello spazio per token semanticamente simili
Gradient clippingLimita la norma del gradiente a una soglia massima, prevenendo aggiornamenti destabilizzanti
LayerNormNormalizza ogni vettore per media e varianza delle sue features. Parametri $\gamma$ e $\beta$ appresi
LogitScore non normalizzato per ogni token del vocabolario. Il softmax li converte in probabilità
OverfittingIl modello memorizza il training set invece di generalizzare: val loss cresce, train loss scende
Pre-normLayerNorm 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 learningIl testo si etichetta da solo: input $[t_0\ldots t_{n-1}]$, target $[t_1\ldots t_n]$. Nessuna annotazione umana
TemperatureDivide i logit prima del softmax: < 1 accentua le differenze (più prevedibile), > 1 le appiattisce (più casuale)
Top-kAzzera tutti i token tranne i k più probabili prima del campionamento. Riduce le uscite incoerenti
Weight tyingEmbedding e layer di output condividono la stessa matrice di pesi. Riduce i parametri e migliora la generalizzazione