SLM · Diario di Bordo

Run #6 in poi — diario attivo

Training sequenziale su Vast.ai, sweet spot run6-ad, confronto Minerva, strategia corpus Run #7 e Run #8.

aggiornato 2026-10-11 modello attivo slm-lora-calendar-v2-q8_0.gguf (LoRA 21 intent) val loss LoRA v2 0.0293 (PPL ≈ 1.0) hardware M5 32 GB + Vast.ai RTX 4090
Stato corrente · 2026-10-07
Modello in uso
slm-run6-shard-ad-q8_0.gguf
Sweet spot — val loss 3.3524
Prossimo run
Run #7 — Wikipedia IT
3–4 shard, dump su disco (4GB), Vast.ai ($10 residui)
Run #8 (valutazione)
Due varianti
A: EUR-Lex IT · B: Narrativa+Wikisource IT
Chinchilla gap
62% → target: 918M token
Wikipedia IT dump copre il gap (~920M token stimati)
Sezioni recenti: Run #6 avvio Shard 7 completato Analisi semantica Confronto Minerva Strategia Run #7/8

Run #6 — Avvio training sequenziale su Vast.ai (2026-10-05)

Fix implementato: aggiunto flag --reset-best a train.py. Con questo flag, --resume carica i pesi da best.pt ma imposta best_val_loss = float('inf') invece di caricare la soglia storica. Il primo val checkpoint sovrascrive best.pt con i pesi dello shard corrente, propagando la catena sequenziale. Il bug di Run #5 è risolto definitivamente.
Infrastruttura: istanza Vast.ai ID 54321658, RTX 4090 (Norvegia, uptime 99.8%), PyTorch 2.5.1 + CUDA 12.4, 60 GB disco. $0.47/h — stima ~8 ore → ~$3.80 totali.
Setup: rsync di 4.4 GB completato (7 shard .pt pre-tokenizzati + run4_best.pt + sorgenti). Nessuna re-tokenizzazione necessaria.
ParametroValoreNote
Punto di partenzarun4_best.pt (val 3.5861)Pesi Run #4
Sequenza shardaa → ab → ac → ad → ae → af → agOrdine originale del corpus
Steps × shard8 00056 000 step totali
Batch size3216 384 token/step (vs 4 096 su M2)
Token totali~917 MChinchilla parity: 20 × 45.9 M param = 918 M
Meccanismo--resume --reset-bestPesi caricati, soglia azzerata
Scriptrun6_sequential.shSalva checkpoint intermedi per shard

Progressione Run #6

Tabella aggiornata al completamento di ogni shard.
ShardFileStatusVal loss finaleNote
1 / aashard_aa.txt✓ completato3.556840m 52s — sotto Run #4 (3.5861) di 0.029
2 / abshard_ab.txt✓ completato3.445840m 47s — −0.111 rispetto ad aa
3 / acshard_ac.txt✓ completato3.401140m 44s — −0.045 rispetto ad ab
4 / adshard_ad.txt✓ completato3.352440m 43s — −0.048 rispetto ad ac
5 / aeshard_ae.txt✓ completato3.338540m 45s — sample: "La società ha già deciso di sgomberare, ma il progetto non ha ancora finito" — −0.014 rispetto ad ad
6 / afshard_af.txt✓ completato3.304040m 45s — sample: "a Milano la prima delle sue iniziative, quella di aprire in città i propri negozi" — −0.035 rispetto ad ae
7 / agshard_ag.txt✓ completato3.296540m 44s — best a step 8000 (loss in calo fino alla fine) — −0.008 rispetto ad af

Run #6 — Shard 3/7 (shard_ac.txt) — in corso

Shard ac avviato alle 15:16 UTC. Riprende da shard ab (val 3.4458). Già a step 6000/8000 il modello batte ab: val 3.4353 (checkpoint salvato). A step 6500 val 3.4120 — discesa continua e regolare.

Checkpoint parziali — shard_ac.txt

StepTrainValElapsedNote
60003.39583.435330:36✓ checkpoint — val scende sotto ab; sample: "io, a fianco di un vecchio e ben più piccolo…"
65003.37113.412033:08sample: "la sua carriera ha avuto inizio ad una nuova attività lavorativa…"
75003.36563.406138:12—
80003.33423.401140:44✓ shard completato — val finale
Andamento coerente con il pattern dei shard precedenti: il modello scala sistematicamente su ogni corpus nuovo. La discesa da 3.4353 (step 6000) a 3.4011 (step 8000) è regolare e senza plateau. La catena sequenziale accumula miglioramento ad ogni shard: aa→ab −0.111, ab→ac −0.045. La riduzione si assottiglia per corpus con distribuzione simile.

Run #6 — Shard 5/7 (shard_ae.txt) — completato 2026-10-05 17:18 UTC

Shard ae avviato alle 16:34 UTC al completamento di shard ad (val 3.3524). Corpus: shard_ae.txt (1.18 GB, 289,840,429 token). Riprende da shard ad come da meccanismo sequenziale. Completato dopo 40 minuti 45 secondi. Val loss finale 3.3385 — scende di 0.014 rispetto ad ad. La tendenza di riduzione si assottiglia (aa−ab −0.111, ab−ac −0.045, ac−ad −0.048, ad−ae −0.014): possibile che il modello stia raggiungendo un plateau per il corpus Wikipedia IT, corpus di partenza del Run #4. Shard af e ag su corpus diversi potrebbero mostrare cali più significativi.

Checkpoint shard_ae.txt

StepTrainValElapsedNote
75003.3286—~38m—
80003.29453.338540:43✓ shard completato — sample: "La società ha già deciso di sgomberare, ma il progetto non ha ancora finito"

Run #6 — Shard 6/7 (shard_af.txt) — completato 2026-10-05 17:59 UTC

Shard af avviato alle 17:18 UTC al completamento di shard ae (val 3.3385). Corpus: shard_af.txt (1.18 GB, 290,080,157 token). La curva inizia alta (val 3.4779 a step 500) perché il corpus di af ha una distribuzione diversa rispetto ai precedenti shard Wikipedia. Scende rapidamente e raggiunge il minimo a step 7500: val 3.3040, sotto il precedente best (3.3385 di ae). Riduzione −0.035 rispetto ad ae, molto superiore alla riduzione minima di ae su ad (−0.014): i corpus diversi portano miglioramenti significativi. Completato in 40 minuti 45 secondi. run6_shard_af.pt scaricato localmente.

Checkpoint shard_af.txt

StepTrainValElapsedNote
03.30623.335400:18✓ checkpoint (caricato da ae, val migliore di ae=3.3385 — nuovo corpus spinge su)
5003.46833.477902:49—
10003.49013.494605:21—
15003.46473.476907:52—
20003.45143.467410:24—
25003.43083.476612:55—
30003.42783.445815:27—
35003.40853.438817:58—
40003.36583.419120:30—
45003.37103.382123:01sample: "di Roma, mentre il giorno precedente, nel pomeriggio di lunedì, era previsto un evento per le vie di Roma"
50003.33923.379025:32—
55003.30573.359728:04—
60003.29603.344430:35—
65003.28373.335133:07✓ checkpoint (val=3.3351)
70003.28853.320035:39✓ checkpoint (val=3.3200)
75003.27693.304038:11✓ BEST — sample: "a Milano la prima delle sue iniziative, quella di aprire in città i propri negozi, un altro punto di riferimento per l'i"
80003.27783.313340:43fine shard (val risale leggermente: 3.3133)

Run #6 — Shard 7/7 (shard_ag.txt) — completato 2026-10-05 18:40 UTC — RUN #6 COMPLETO

Shard ag avviato alle 17:59 UTC al completamento di shard af (val 3.3040). Corpus: shard_ag.txt (1.18 GB, 290,180,642 token). In Run #5, questo corpus aveva val 4.027 a step 4000 — il più difficile tra i sette. In Run #6, il pattern è lo stesso di af: la val parte alta (3.4670 a step 500) per poi scendere man mano che il modello si adatta. La caratteristica rilevante: il minimo non è a step 7500 come in af, ma al passo finale (step 8000), con val 3.2965. La loss era ancora in calo alla fine del shard. Riduzione −0.008 rispetto ad af — piccola, ma la curva era discendente: con più step sarebbe scesa ulteriormente.
Run #6 completato alle 18:40:09 UTC del 2026-10-05. 7 shard × 8000 step × batch 32 × seq 512 ≈ 917 M token processati. Val loss complessiva: 3.5861 (Run #4, punto di partenza) → 3.2965 (best, shard ag). Riduzione totale −0.290.
⚠ Shard af e ag: corpus inquinato — non usare come base per training futuro
I shard af e ag appartengono alla sezione "late" di CulturaX, con distribuzione web pesante: menu di navigazione, cookie banner, testo pubblicitario, spam marketing, caratteri di controllo da HTML mal decodificato. Il val loss basso (3.3040, 3.2965) riflette apprendimento del corpus, non qualità generativa. I test semantici mostrano:
— run6-af: output sconnesso su tutti i domini tranne geografico
— run6-ag: frammenti di spam ("LE ARTI E SEGUITO: ALBERGHA - ROMANIA"), loop di ripetizione ("di di di di…") che resiste a repeat_penalty, peggior qualità tra tutti i checkpoint
Il best.pt al termine di Run #6 corrisponde a run6-ag e non deve essere usato come base per fine-tuning o LoRA. Il punto di partenza corretto per operazioni successive è run6-ad (val 3.3524), checkpoint salvato in checkpoints/run6_shard_ad.pt.

Checkpoint shard_ag.txt

StepTrainValElapsedNote
03.30213.316400:18✓ checkpoint (da af best=3.3040 — corpus diverso, val parte leggermente più alta)
5003.44783.467002:49—
10003.45673.483005:21—
15003.44413.473107:52—
20003.43093.466110:23—
25003.42263.456812:55—
30003.39733.441815:26—
35003.36463.416317:58—
40003.35623.404620:29—
45003.33103.388323:00—
50003.32083.367425:32—
55003.29433.346428:03—
60003.28263.326830:34—
65003.25793.326733:06—
70003.26263.315535:37✓ checkpoint (val=3.3155)
75003.26713.307338:09✓ checkpoint (val=3.3073)
80003.23993.296540:41✓ BEST — fine shard, loss ancora in calo

Run #6 — Fix converter GGUF per llama.cpp b11146 (2026-10-05)

Problema: il converter GGUF generava file incompatibili con llama.cpp b11146. Il crash unordered_map::at: key not found avveniva al momento del prompt, non al caricamento del modello.
Root cause (verificata sul sorgente b11146, file llama-vocab.cpp): il tokenizer SPM del progetto SLM è stato addestrato come BPE senza byte_fall_back=True. Il vocabolario non contiene nessun token <0xXX> (0 su 256 possibili). In modalità chat, llama-cli prepend un template che include caratteri speciali (<, |, ecc.). La funzione byte_to_token(ch) cerca prima <0xXX> nel vocabolario — non trovato — poi prova token_to_id.at(raw_byte) — anche non trovato — e lancia l'eccezione non catturata.
Perché llama-tokenize non crashava: llama-tokenize riceve solo il testo passato dall'utente. Il testo italiano puro (Wikipedia) è completamente coperto dai pezzi SPM: nessun byte-fallback necessario. In modalità chat, invece, il template aggiunge caratteri non coperti.
Fix implementato in tools/convert_to_gguf.py: dopo i 16000 token SPM, vengono aggiunti 256 token sintetici <0x00>..<0xFF> con tipo BYTE e score −10000. L'embedding e il LM head vengono estesi con 256 righe zero. Il vocabolario GGUF risultante è 16256 token. Il tokenizer model resta "llama" (code path SPM corretto in b11146). I token sintetici hanno score estremo per non essere generati, ma consentono al tokenizer di codificare qualsiasi sequenza di byte senza crash.
Risultato: con il GGUF v6 generato dal converter aggiornato, il modello carica e genera correttamente in llama.cpp b11146. Il token processing su Apple Metal raggiunge 2731 t/s (prompt) e 302 t/s (generazione). Output con prompt "Il Parlamento europeo" e flag --chat-template "" --no-jinja: "Per il progetto, la Costituzione è la più importante e più diffusa. È il secondo più importante a livello mondiale e, insieme a..." — italiano coerente e pertinente.
# Conversione con il converter aggiornato (Run #4 o Run #6):
python3 tools/convert_to_gguf.py --checkpoint checkpoints/run4_best.pt \
  --tokenizer checkpoints/tokenizer.model --output slm-run4.gguf

# Test inferenza:
llama-cli -m slm-run4.gguf -p "Il Parlamento europeo" -n 50 \
  --chat-template "" --no-jinja

Run #6 — Primo test di inferenza (shard aa, 2026-10-05)

Al completamento di shard aa il checkpoint è stato convertito in GGUF (slm-run6-shard-aa.gguf, 217 MB fp16) e quantizzato Q8_0 (slm-run6-shard-aa-q8_0.gguf, 55 MB). Il test di inferenza è stato condotto via PyTorch direttamente (llama.cpp versione b11146 aveva un'incompatibilità API con il converter — risolta in data 2026-10-05, v. sezione Fix converter GGUF).
Parametri di campionamento: temperatura 1.1, top-k 50, repetition penalty 1.3. Con temperatura bassa e senza penalty di ripetizione il modello entra in loop; con i parametri sopra la generazione è stabile.

Campioni di generazione — shard aa (val 3.5568)

PromptGenerazione (150 token)
Il Parlamento europeo «…ha annunciato un accordo con le organizzazioni più potenti per discutere di quanto sostenuto, ha approvato uno schema unitario delle leggi del paese europeo e si presenterà a un piano globale. E' la risposta ai tempi perugini e, dopo aver appreso tutti gli sforzi dei democratici politici, si potrà raggiungere gli obiettivi internazionali…»
La ricerca scientifica «…dell'Università Statali di Roma ha dimostrato che le informazioni provenienti da varie discipline sono molto diffuse e che il numero di ricercatori coinvolti è un ottimo livello per determinare le loro statistiche. Le loro osservazioni non consentono di avere la certezza della presenza di eventuali effetti collaterali…»
Nel 1943 le truppe italiane «…sbarcarono a Levanto nell'isola di Paulo e fecero parte della Brigata "Vieste". Durante la seconda guerra mondiale furono i soldati sovietici arrestati da reparti dell'11o reggimento di artiglieria corazzata Avio per resistenza contro la Libia. Un secondo bombardamento, avvenuto il 9 luglio 1944 con l'ausilio delle cannoniere sovietiche del tenente generale Makdukov…»
Osservazioni qualitative: italiano grammaticalmente corretto, lessico appropriato al dominio del prompt, struttura sintattica con subordinate e incisi. Hallucination evidente (nomi e date inventati con sicurezza), coerenza semantica fragile oltre 100 token — entrambi attesi a questo stadio di training (2/7 shard completati, ~263M token totali su 917M target).

Pubblicazione su HuggingFace

I file GGUF di Run #4 sono stati pubblicati su huggingface.co/c1cc10/slm-italiano: Uso con llama.cpp: llama-cli -hf c1cc10/slm-italiano --hf-file slm-run4-q8_0.gguf -p "Il Parlamento europeo" -n 100

Run #6 — Confronto output tra 3 modelli (2026-10-05)

Confronto sistematico tra i tre migliori checkpoint disponibili dopo Run #6, usando llama-server con endpoint /completion (nessun template di chat applicato). Prompt fisso: "La regione Puglia è", tre semi diversi per campionare la variabilità della distribuzione. Parametri uniformi: n_predict=150, temperature=0.8, top_k=40, top_p=0.95, repeat_penalty=1.3, repeat_last_n=64.

Modelli testati

Modello File Val loss Corpus Dimensione
A — run4-v6 slm-run4-v6.gguf 3.5861 Wikipedia IT (47.8M token, 30k step) 218 MB fp32
B — run6-ae slm-run6-ae.gguf 3.3385 Wikipedia IT shard aa–ae (5×8k step sequenziale) 218 MB fp32
C — run6-ag-q8_0 slm-run6-ag-q8_0.gguf 3.2965 Wikipedia IT aa–ae + CulturaX web af–ag (7×8k step sequenziale) 55 MB Q8_0

Output — Modello A: run4-v6 (val 3.5861)

SeedOutput generato
42 «…costituita da una zona di pianura che si estende in direzione sud-sud dal golfo del Tigullio fino alle coste meridionali del mar Mediterraneo. Clima. Il clima dell'area è caratterizzato dalla presenza della zona climatica, con estati calde e inverni miti ed estati calde (afose, medie letitudini, nevicate) e nevosi, mentre d'inverno l'altitudine scende ai 35 °C. Le precipitazioni sono presenti durante tutto il mese di novembre…»
123 «…uno dei due Stati federati. L'area geografica della provincia, a ovest di Reggio Emilia e Reggio Calabria, si estende su una superficie che va da un minimo di circa 800.000 ettari (dati del 2000), mentre a sud confina con la Provincia di Palermo. Il territorio provinciale, nel quale è divisa la Regione, comprende anche l'exclave di Giberti…»
777 «…stata la più grande della provincia. Per quanto riguarda il territorio, lo stemma è un antico simbolo dell'antica città di Popoli Terme. Dal punto di vista culturale l'area della penisola balcanica si affaccia sulla costa occidentale dei Picentini; in particolare nella parte ovest del comune vi sono alcune aree boschive che testimoniano la presenza di insediamenti umani fin dall'epoca preromana…»

Output — Modello B: run6-ae (val 3.3385)

SeedOutput generato
42 «…quella che per i più giovani è stata la più grande tra tutte le Regioni del Sud Italia. Per questo Regione Puglia ha deciso di puntare sul turismo, tutela e valorizzazione delle strutture ricettive locali e regionali. "Il nostro obiettivo - conclude la deputata della Lega - è quello di rendere trasparente l'offerta turistica, con un occhio all'Italia…"»
123 «…un'isola a vocazione turistica, in cui la tradizione vuole essere la terra del turismo. Nel suo territorio sono presenti le località balneari del litorale campano e della provincia di Taranto con i suoi 98 chilometri di superficie. Itinerari, attività turistiche, escursioni nelle acque libere, passeggiate lungo il mare, percorsi guidate per bambini e percorsi sul Lago di Garda…»
777 «…un'isola che, pur avendo i suoi abitanti in particolare, fa parte della comunità regionale. Si può anche dire che la sua storia ha preso il sopravvento in questi giorni: scoperta di un luogo ancora sconosciuto è stato lasciato alla memoria dalle autorità locali, come Parco Archeologico del Cilento e quello dei Sassi di Acireale. Le radici storiche dell'isola risalgono al VI secolo a.C.…»

Output — Modello C: run6-ag-q8_0 (val 3.2965)

SeedOutput generato
42 «…caratterizzata da un'area di circa 6.000 mq con piscina termale e campi da tennis. LE ARTI E SEGUITO : ALBERGHA - ROMANIA - "La Calabria ha bisogno dell'aiuto del governo della Regione che possa intervenire sui settori più colpiti dal terremoto". Lo afferma il consigliere regionale Antonio Pappalardo, in una nota…»
123 «…il territorio di maggior interesse naturalistico, per la sua storia e per i suoi colori. Il comune di L'Aquila è una provincia del Molise di origine italiana, con un patrimonio artistico straordinario: è uno dei più belli d'Italia ed è una delle zone preferite dai turisti italiani e stranieri. In questo comune si trova la città che ha sede a Leuca…»
777 «…un territorio molto ricco di testimonianze e testimonianza del lavoro svolto dalla Regione che, nel suo complesso, ha ottenuto il riconoscimento di "Casa dei Medici". Scienziato è voce della Campania in Italia Napoli#coscienza Il Giornale Digitale -% di di di di di di di di di di di di di di di di di di di di di di di di di di di di…»

Analisi comparativa

Hallucination geografica sistematica (tutti i modelli). Nessun modello produce descrizioni accurate della Puglia. Golfo del Tigullio (Liguria), Popoli Terme (Abruzzo), L'Aquila/Molise: tutte regioni sbagliate. Questo è atteso e spiegabile: a 46M parametri il modello non ha sufficiente capacità per memorizzare conoscenze fattuali specifiche. Apprende la struttura sintattica dell'italiano enciclopedico, non i contenuti. La conoscenza fattuale affidabile emerge intorno a 1–3B parametri.
Il paradosso del val loss più basso. Il modello C (run6-ag-q8_0) ha il val loss più basso (3.2965) ma produce l'output qualitativamente peggiore: frammenti di spam marketing ("LE ARTI E SEGUITO: ALBERGHA - ROMANIA"), testo istituzionale sconnesso, e un loop di ripetizione ("di di di di…") che resiste al repeat_penalty. Questo conferma quanto osservato durante i test su corpus af/ag: il training sui shard CulturaX (web) ha effettivamente ridotto la val loss — ma perché il modello ha imparato i pattern del corpus web, inclusi gli artefatti. Un val loss misurato su dati contaminati non è un indicatore affidabile della qualità generativa.
Modello B (run6-ae) è il migliore per qualità generativa. Con val loss 3.3385, produce testo italiano fluente e formalmente corretto. Il seed 42 è il più rivelatore: il modello nomina esplicitamente "Regione Puglia" e costruisce un discorso in stile giornalistico coerente — errato nei contenuti, ma grammaticalmente solido e senza artefatti. I shard aa–ae erano tutti Wikipedia IT, che ha preservato la distribuzione del corpus di pre-training senza inquinamento da web.
Modello A (run4-v6) è intermedio. Val loss più alta (3.5861), stile enciclopedico più "puro" (nessuna contaminazione da web), ma output meno fluente rispetto al modello B. Il gap tra A e B riflette il vantaggio di 280M token aggiuntivi di Wikipedia pulita.

Conclusione operativa

UsoModello raccomandatoMotivo
Inferenza qualità italiana run6-ae (B) Corpus pulito, output più fluente e coerente
Inferenza veloce (RAM limitata) run6-ag-q8_0 (C) 55 MB vs 218 MB, ma qualità degradata da web corpus
PPL scorer per filtro corpus run4-v6 (A) Distribuzione Wikipedia pura, scorer non distorto da web spam
Prossimo training (base) run6-ad weights Sweet spot qualitativo: bilanciamento migliore tra conoscenza enciclopedica e capacità contemporanea, senza la contaminazione visibile di run6-ae (vedi analisi semantica sotto)

Run #6 — Byte-fallback token e logit_bias (2026-10-06)

Durante i test di inferenza su slm-run6-ae.gguf con il prompt "Francesco Benigni" (nome raro, bassa frequenza nel corpus), l'output è risultato composto interamente da caratteri di controllo Unicode: \u0000, \u0003, \u000f e simili — nessuna parola italiana. La stessa anomalia si è ripresentata con varianti dello stesso prompt.

Causa: token di fallback byte nel vocabolario GGUF

Il vocabolario BPE-SPM addestrato sul corpus italiano contiene 16.000 token. La specifica GGUF (llama.cpp) richiede che il tokenizer possa codificare qualsiasi sequenza di byte, anche quelle non presenti nel vocabolario BPE. Per questo motivo, durante la conversione GGUF, sono stati aggiunti 256 token sintetici di fallback — uno per ogni byte possibile (0x00–0xFF) — agli indici 16000–16255. Questi token vengono usati dal tokenizer come "ultima risorsa" per byte non altrimenti rappresentabili.
Il corpus CulturaX, non perfettamente pulito, contiene caratteri di controllo (tab, carriage return, byte non-printable da HTML mal decodificato). Il modello ha quindi appreso che in certi contesti questi byte token compaiono dopo sequenze specifiche di testo. Quando il contesto è insolito (nome proprio raro → alta incertezza → distribuzione piatta), i token byte diventano competitivi. Con temperature=0.8 e top_p=0.9 il campionatore li pesca; una volta selezionato un byte token, il contesto successivo si sposta verso "sequenza binaria", il che rinforza la selezione di ulteriori byte token: loop.

Fix: logit_bias sui 256 token di fallback

Il parametro logit_bias dell'API /completion di llama-server permette di sommare un valore arbitrario al logit di un token specifico prima del calcolo del softmax. Passando [id, -100] per ogni byte token, il loro logit viene abbassato di 100 unità — equivalente a probabilità zero pratico. Il vocabolario operativo scende da 16.256 a 16.000 token.
python3 -c "import json; print(json.dumps([[i,-100] for i in range(16000,16256)]))"
Il valore prodotto va passato nel campo "logit_bias" di ogni richiesta al server. Con il fix attivo, tutti i prompt precedentemente problematici producono testo italiano coerente.

Nota sul corpus

I caratteri di controllo nell'output non sono solo un problema di inferenza: sono la firma visibile della qualità del corpus. La loro presenza nelle sequenze generate conferma che CulturaX, anche nei shard "puliti" (aa–ae), contiene artefatti di encoding web che il filtro euristico non ha eliminato. Il logit_bias a inference time è una correzione chirurgica, non una soluzione al problema corpus.

Run #6 — Analisi semantica shard-by-shard (2026-10-06)

Test sistematico di 8 prompt su 6 modelli (run4-v6 + run6-aa/ab/ac/ad/ae), seed fisso 42, logit_bias attivo su tutti i 256 byte token, n_predict=100, temperature=0.8, top_p=0.9, repeat_penalty=1.3. Obiettivo: mappare il cambiamento qualitativo shard per shard e identificare il miglior punto di partenza per i 2 shard mancanti al Chinchilla parity.

Prompt e risultati sintetici

Promptrun4-v6run6-aarun6-abrun6-acrun6-adrun6-ae
compilazione del kernel Off-topic (editore software) ✓ GNU, versioni kernel ½ Windows/Linux ½ support page ½ backup/online ✗✗ testo legale/costituzionale
un tramonto splendido ✗ biografia pittore ½ emozionale/blog ✓ risposta diretta al prompt ½ itinerario turistico ✓ "la luna piena di luce" (poetico) ✗ hotel/turismo
Le poesie di Ungaretti ✓ "testi poetici" corretto ✗ moda/musica ✗ Marco Polo ✗ evento istituzionale ✗ accademico ½ letterario ma confonde Goldoni
La crisi del 1929 ✓ economia storica ✓ politica economica Italia ½ analisi moderna ✗ notizia corrente ½ analisi economica strutturata ✗✗ Di Maio, Mattarella
Il jazz nasce ✓ vocabolario musicale ½ generico ½ "primi anni dell'Ottocento" ½ "luogo di confine" ✓ musicisti con nomi specifici ✓ culturale-poetico
Dante Alighieri nacque ✓✓ Firenze, Commedia, Virgilio ✗ 1443, guardia costiera ½ Firenze corretto, 1787 ½ Firenze corretto, 1782 ✗ 1368, confusione Papa ✗ 1550, convento
La fotosintesi è ✓ spettroscopia, luce solare ✗ Soprintendenza Salute RC ✗ terminologia medica ✗ genitori/bambini ✗ terapia muscolare ✗ fotoblog stradale
Prepara una pasta ½ cibo + storia ✓ ricetta con istruzioni ✓✓ parmigiano, sequenza precisa ✓ consigli cottura ✓ ricetta con ingredienti specifici ✓ ricetta (pasta+cioccolato, insolito)

Finding 1 — Wikipedia domina conoscenza enciclopedica e scientifica

run4-v6 è il migliore su Dante (Firenze, Divina Commedia, Virgilio), fotosintesi (spettroscopia, radiazione elettromagnetica), crisi del 1929 (contesto storico corretto). I modelli CulturaX degradano sulla scienza in modo rivelatore: "La fotosintesi è" diventa una terapia muscolare (run6-ad), una foto di strada (run6-ae), un programma per famiglie (run6-ac). Il corpus web non ha articoli scientifici: ha articoli di divulgazione medica e blog fotografici che condividono gli stessi token iniziali. Il modello non distingue il concetto, vede solo la co-occorrenza statistica.

Finding 2 — CulturaX porta conoscenza pratica e contemporanea

Le ricette sono eccellenti da run6-aa in poi: ingredienti reali (parmigiano reggiano, pomodori), istruzioni sequenziali, formato procedurale. La geografia pugliese migliora con ogni shard (da Golfo del Tigullio in run4-v6 a Bari + Lecce in run6-ae). Il jazz viene descritto con musicisti specifici (run6-ad) invece di circonlocuzioni enciclopediche. Il web corpus è denso di siti di ricette, news regionali e blog musicali: il modello apprende questi pattern pragmatici.

Finding 3 — La degradazione comincia a run6-ae, non ad run6-af

Questo è il dato più critico. "La crisi del 1929" in run6-ae produce Luigi Di Maio e Sergio Mattarella. "Compilazione del kernel" produce diritto costituzionale (art. 48, comma 1). "La fotosintesi è" produce un fotoblog con "LEGGI ANCHE". Il modello ha iniziato ad assorbire i pattern più superficiali del corpus — titoli, categorie, menu, riferimenti politici correnti — già al quinto shard CulturaX, non solo agli ultimi due (af, ag). La pendenza di degradazione comincia tra run6-ac/ad e run6-ae, non al confine tra Wikipedia e CulturaX come si ipotizzava.

Finding 4 — run6-ad è il sweet spot qualitativo

run6-ad produce: "la luna piena di luce. Il cielo si fa sentire da una persona" (tramonto), "Lecce, Regione Puglia ha voluto fare un passo avanti" (Puglia), un regista intervistato da Claudio Baglioni con filmografia plausibile (Francesco Benigni), un'analisi economica strutturata (1929). È il modello con il miglior bilanciamento tra conoscenza enciclopedica residua da Run #4 e arricchimento contemporaneo da CulturaX, senza la contaminazione visibile di run6-ae.

Implicazione per i 2 shard mancanti

Partire da run6-ae come base significherebbe aggiungere shard su un modello già degradato. La base corretta è run6-ad. I 2 shard sostitutivi devono essere ortogonali rispetto a CulturaX: il corpus web ha già dato quello che poteva dare (e anche di più). Quello che manca al modello è: Fonti candidate per i 2 shard sostitutivi: Wikipedia dump più completo (estratti 200MB da dump 4GB), Wikisource italiano (letteratura pubblica dominio, testi lunghi), corpus letterario personale (EPUB).

Confronto con Minerva — benchmark esterno (2026-10-07)

Scaricati e testati due modelli della famiglia Minerva (Sapienza NLP / FAIR / CINECA): Minerva-350M-base-v1.0 (Q5_0, 245MB) e Minerva-3B-instruct-v1.0 (Q5_K_M, 1.9GB). Stessi 10 prompt del confronto shard Run #6, stessi parametri (n_predict=100, temperature=0.8, top_p=0.9, repeat_penalty=1.3, seed=42). Obiettivo: calibrare la posizione del SLM rispetto a modelli italiani addestrati con risorse professionali. Dettaglio tecnico completo: slm-minerva-comparazione.html.

Risultati per dominio — classifica completa (1°/2°/3°)

Prompt 1° 2° 3°
Dante Alighieri nacque Minerva 3B — 1265 ✓, Firenze ✓, morte Ravenna ✓ Minerva 350M — città giusta, anno sbagliato (1280), poi inventa opere SLM — date 1550, conventi: conoscenza sovrascritta da CulturaX
compilazione del kernel Minerva 3B — gcc, GNU Compiler, compilazione C ✓ Minerva 350M — Windows 7/WinXP: tecnico ma contesto sbagliato SLM — diritto costituzionale art. 48: fuori dominio
un tramonto splendido SLM — "la luna piena di luce": poetico, pertinente ✓ Minerva 3B — costa siciliana, Cefalù: cita tramonti ma testo turistico Minerva 350M — evento al Palazzo Ducale: fuori topic
Le poesie di Ungaretti Minerva 3B — confonde nome ma Alessandria d'Egitto 1896 ✓, poeta Novecento ✓ SLM — scarsa resa ma non inventa dati strambi Minerva 350M — inventa attributore e titoli assurdi
La crisi del 1929 Minerva 3B — Grande Depressione, crollo mercato azionario USA ✓ SLM — analisi economica strutturata Minerva 350M — URSS, Hiroshima, bombardamenti: confonde con WWII
Il jazz nasce Minerva 3B — anno sbagliato ma Gillespie, Blakey, Roach, bebop, dixieland ✓ SLM — musicisti specifici Minerva 350M — "in Italia nel 1960", Beatles: sbagliato su tutto
La fotosintesi è Minerva 3B — "processo che trasforma la luce del sole in ossigeno" ✓ Minerva 350M — vago ("fondamentale per il benessere") ma non sbagliato SLM — terapia muscolare: contaminazione CulturaX
Prepara una pasta SLM — parmigiano reggiano, cottura in padella, procedura precisa ✓ Minerva 350M — riso e farina, olio, salvia: semplice ma plausibile Minerva 3B — "pasta di sale" (impasto artigianale): disambiguazione sbagliata
La regione Puglia è Minerva 350M — Salento, Adriatico, Isole Tremiti, San Foca, ulivi ✓ SLM — "Lecce, Regione Puglia ha voluto fare un passo avanti": pertinente Minerva 3B — lavoro giovanile → Samsung Galaxy S7: contaminazione pesante
Francesco Benigni SLM — regista con filmografia plausibile, intervistato da Baglioni ✓ Minerva 350M — "studioso tradizioni popolari": plausibile ma scivola poi Minerva 3B — post social media management: fuori topic

1° posto: Minerva 3B ×6 · SLM ×3 · Minerva 350M ×1 — 2° posto: SLM ×4 · Minerva 350M ×4 · Minerva 3B ×2

Finding 2 — Il SLM vince su 3 domini e arriva secondo su 4

Su tramonto, pasta e Francesco Benigni il SLM batte entrambi i Minerva. "La luna piena di luce" è la risposta più poetica del test. La ricetta è la più precisa (parmigiano reggiano, cottura in padella, tempi). Su Benigni produce il personaggio più plausibile e pertinente. Inoltre il SLM arriva secondo su 4 prompt (Ungaretti, 1929, Jazz, Puglia): è competitivo nei domini pratici e contemporanei dove CulturaX era densa, anche se la conoscenza strutturata sfugge con la scala.

Finding 3 — Minerva 350M su Puglia è il migliore del test

Il 350M è il modello più debole sui 10 prompt complessivi, ma sulla Puglia produce la risposta geograficamente più accurata: Salento, mare Adriatico, Isole Tremiti, San Foca, ulivi — tutti luoghi reali e pertinenti. Né il SLM né il 3B raggiungono questa precisione geografica. Il 3B arriva a Samsung Galaxy S7. Questo suggerisce che la distribuzione del corpus nel 350M conteneva più descrizioni geografiche italiane specifiche rispetto al 3B, dove l'instruction tuning può aver diluito o ridistribuito questi pattern.

Finding 4 — Nessun byte token in Minerva

Su "Francesco Benigni" (nome raro, test byte token) entrambi i Minerva producono testo italiano leggibile senza logit_bias. Il problema dei 256 token sintetici introdotti dalla conversione GGUF è specifico del SLM e non si presenta nei modelli con vocabolario nativo più ampio (51 200 token vs 16 000). Questo conferma che la soluzione logit_bias è corretta ma locale: la vera soluzione è nel tokenizer, non nel post-processing.

Finding 5 — Minerva 350M sulla crisi del 1929 è il peggior output del test

Il modello base a 350M confonde la crisi del 1929 con eventi della seconda guerra mondiale: URSS, invasione anglo-americana, Hiroshima, Nagasaki. È l'errore fattuale più grave tra tutti gli output raccolti. A 350M parametri con 70B token il modello non ha ancora la capacità di tenere separati eventi storici distanti ma con token condivisi ("crisi", "guerra", "crollo"). Il 3B risolve correttamente: Grande Depressione, crollo del mercato azionario, USA. Questo fissa un pavimento minimo di parametri per conoscenza storica affidabile.

Strategia Run #7 e Run #8 — corpus e over-training (2026-10-07)

Sessione di analisi strategica: rilettura dei risultati di Run #6 in chiave corpus, verifica dell'hardware disponibile, pianificazione dei run successivi. Il modello di riferimento è run6-ad (sweet spot qualitativo), punto di partenza per Run #7.

Conclusione sul corpus CulturaX

La degradazione osservata da run6-ae in poi non è accidentale: CulturaX ha saturato la sua utilità al quarto shard. Il corpus ha contribuito positivamente (ricette, geografia contemporanea, jazz con musicisti specifici) ma i suoi artefatti web emergono prima del previsto. La frase "il corpus web ha già dato quello che poteva dare (e anche di più)" sintetizza il comportamento empirico. Continuare con CulturaX — anche da shard diversi dello stesso file — non porta valore aggiuntivo.

Finding — Il dump Wikipedia IT è già sul disco

data/itwiki.xml.bz2 (4.0 GB, scaricato il 14 settembre) contiene l'intero dump di Wikipedia in italiano. Abbiamo estratto solo --max-mb 200 — il 5% del disponibile. L'80% restante rappresenta ~875 M token di testo enciclopedico pulito, senza artefatti web, già filtrato dalla comunità Wikipedia. Non è necessario scaricare niente: basta rieseguire wiki_to_corpus.py con limiti diversi. Questo è il corpus più ovvio e meno rischioso per Run #7.

Hardware aggiornato — M5 32GB

Il training locale è eseguito su MacBook Air M5 con 32 GB di RAM unificata (corretto da precedente nota M2/8GB). Con 32 GB non c'è pressione di memoria: il footprint MPS stimato a ~9.25 GB virtuale rientra abbondantemente nel fisico. Nessuna compressione, nessun swap. Stima velocità: ~1.5 s/step (vs ~2.1 s/step su M2 con batch=8). Il training di Run #7 è fattibile interamente in locale senza Vast.ai.

Piano Run #7 — Wikipedia IT (resto del dump)

ParametroValore
Punto di partenzacheckpoints/run6_shard_ad.pt
Corpus sorgentedata/itwiki.xml.bz2 — estrazione da offset 200 MB
Shard previsti4–5 shard da ~1.1 GB testo ciascuno (~70 M token/shard)
Step per shard8 000 (come Run #6)
Token passes totali dopo Run #7~572 M + 4×131 M = ~1 096 M (≈1.2× Chinchilla)
Tempo stimato su M5~1.5 s/step × 8 000 × 4 = ~13 ore totali
InfrastrutturaLocale (M5 32 GB) — Vast.ai non necessario
Meccanismo--resume --reset-best shard sequenziale, come Run #6
L'obiettivo di Run #7 non è solo raggiungere Chinchilla parity aritmetica, ma recuperare la conoscenza enciclopedica degradata da CulturaX. Wikipedia corregge esattamente i gap osservati nel benchmark: Dante, crisi del 1929, fotosintesi. La scelta è motivata empiricamente, non solo dall'aritmetica Chinchilla.

Over-training intenzionale — scelta per Run #8

Decisione: Run #8 userebbe corpus di tipo B (narrativa/saggistica italiana), non una ripetizione di Wikipedia. La motivazione è complementarietà: dopo Run #7 il modello avrà ottima copertura enciclopedica; ciò che mancherà ancora è il registro narrativo, il periodo lungo, il dialogo, la coerenza emotiva del testo. Wikipedia non porta questi pattern. Il corpus letterario sul disco (Elena Ferrante, Dikele Distefano, Amedeo Balbi — ~3 MB totali) è insufficiente da solo: servono ~70 M token per shard, cioè ~350–500 MB di testo narrativo. Il tipo di testo da aggiungere è in discussione.

Decisione — benchmark obbligatorio dopo ogni shard Run #7 (2026-10-07)

Run #7 userà tutto il dump Wikipedia disponibile (~3-4 shard da 1.1 GB), ma con benchmark obbligatorio dopo ogni shard usando gli stessi 10 prompt e la stessa metodologia di valutazione a 3 posizioni del confronto Minerva. Obiettivo: rilevare empiricamente se e quando il corpus Wikipedia inizia a erodere i pattern contemporanei acquisiti da CulturaX (tramonto, pasta, Benigni). Il sweet spot di Run #7 viene identificato in corsa, non fissato a priori. Il checkpoint di ogni shard viene conservato: nulla è irreversibile.
Rischio gestito: catastrophic forgetting parziale Wikipedia ha registro formale-enciclopedico opposto a quello web di CulturaX. Training esteso su Wikipedia sposta la distribuzione dei pesi e può diluire i pattern web. Il monitoring shard-by-shard è l'unico modo per misurare dove il tradeoff smette di essere favorevole. Il benchmark di Run #6 (stesso strumento) diventa il metro di confronto.

Aperta — tipo di corpus per Run #8

Aggiunta — --skip-mb a wiki_to_corpus.py (2026-10-07)

tools/wiki_to_corpus.py aggiornato con il parametro --skip-mb N: salta i primi N MB di articoli prima di iniziare a scrivere. Permette di creare shard sequenziali non sovrapposti dallo stesso dump senza estrazione multipla del XML (solo il passaggio degli articoli è ripetuto, ~5–15 min overhead). Comandi per i 3 shard di Run #7 su Vast.ai:
# Shard 1 — articoli da 200 MB a 1300 MB
python3 tools/wiki_to_corpus.py data/itwiki.xml.bz2 \
  --skip-mb 200 --max-mb 1100 --out data/wiki_run7_s1.txt --processes 8

# Shard 2 — articoli da 1300 MB a 2400 MB
python3 tools/wiki_to_corpus.py data/itwiki.xml.bz2 \
  --skip-mb 1300 --max-mb 1100 --out data/wiki_run7_s2.txt --processes 8

# Shard 3 — articoli da 2400 MB a 3500 MB
python3 tools/wiki_to_corpus.py data/itwiki.xml.bz2 \
  --skip-mb 2400 --max-mb 1100 --out data/wiki_run7_s3.txt --processes 8
Poi tokenizzare ogni shard con il tokenizer esistente (--tokenizer bpe-spm) e addestrare con --resume --reset-best sequenzialmente da run6_shard_ad.pt.
Domanda aperta: quale tipo di testo italiano (altra narrativa, saggistica, giornalismo, testi normativi, altro) è più utile come corpus di over-training dato l'obiettivo di base per LoRA? La risposta dipende da dove il modello dovrà essere specializzato. Valutazione in corso.

Run #7 — Esecuzione su Vast.ai (2026-10-07/08)

Run #7 avviato su Vast.ai (RTX 4090, istanza 54681871) il 2026-10-07 alle 19:19 UTC. 3 shard × 20 000 step × batch 32 × seq 512 = ~983 M token, Chinchilla-optimal per 45.9 M parametri. Script: run7_train_only.sh. Punto di partenza: run6_shard_ad.pt (val 3.3524, CulturaX sweet spot). Completato il 2026-10-08 alle 01:01 UTC.

Parametri training

ParametroValore
Punto di partenzarun6_shard_ad.pt — val 3.3524
CorpusWikipedia IT dump, shard 1–3 × 1.1 GB (~70 M token/shard)
Step per shard20 000 (Chinchilla parity)
Batch × seq32 × 512
Meccanismo--resume --reset-best sequenziale
HardwareRTX 4090 (Vast.ai, istanza 54681871)
Durata totale~5.5 ore (19:19 UTC 7/10 → 01:01 UTC 8/10)

Progressione val loss

FaseVal lossΔNote
CulturaX baseline (run6-ad)3.3524—Punto di partenza
Shard 1 (wiki s1, step 20 000)3.0917−0.261Completato 21:36 UTC
Shard 2 (wiki s2, step 20 000)2.9083−0.183Completato 23:19 UTC
Shard 3 (wiki s3, step 20 000)2.8165−0.092Completato 01:01 UTC — best assoluto
Miglioramento totale Run #7: −0.536 (−16% da CulturaX)
Il minimo a step 20 000 con curva ancora in discesa suggerisce che ulteriore training su Wikipedia porterebbe ulteriore miglioramento.

Dinamica shard 3 — val loss dettagliata

StepValNota
02.9400baseline reset-best
1 5003.0888peak adattamento
7 0002.9911sotto 3.0
10 0002.9319crossing baseline
11 5002.8910salto −0.032, supera shard 2
14 5002.8582salto −0.026
16 0002.8368salto −0.033
18 0002.8237salto −0.006
20 0002.8165minimo finale

Run #7 — Benchmark locale (2026-10-08)

Benchmark eseguito su M5 MPS dopo download checkpoint. 4 versioni × 10 prompt = 40 generazioni. Stesso set di prompt del confronto Minerva. Completato in ~12 minuti. Log: logs/run7/benchmark_local.log.
VersioneValCorpus
baseline (run6-ad)3.3524CulturaX sweet spot
shard 13.0917+ Wikipedia s1 (~70 M tok)
shard 22.9083+ Wikipedia s1+s2 (~140 M tok)
shard 32.8165+ Wikipedia s1+s2+s3 (~210 M tok)

Risultati per prompt

PromptBaselineShard 1Shard 2Shard 3Tendenza
Dante Alighieri nacque Loop "romanzo/storia d'amore", nessun fatto Struttura biografica, nomi plausibili Anno sbagliato (1524), ma enciclopedico Anno sbagliato (1885), narrativa lineare senza loop Struttura ↑↑, fatti ≈
compilazione del kernel Loop "aggiornamento Windows" ×20 Loop "configurazione Linux" ×15 Loop "dispositivo di controllo" ×12 Loop ridotto, confonde kernel con linguaggio programmazione Loop ↓, dominio assente in tutte
un tramonto splendido Deriva YouTube/rapper Narrativa marina fuori tema Film "Charlie", loop nome Descrizione palazzo ottomano evocativa, atmosfera visiva Qualità narrativa ↑↑↑
Le poesie di Ungaretti Loop "Il Sole 24 Ore" ×20+ Titoli inventati, struttura biobibliografica Nomi inventati, struttura Wikipedia Prosa critico-letteraria fluente, argomentazione corretta Registro letterario ↑↑↑
La crisi del 1929 Deriva sociologica, nessun riferimento economico Contesto bellico, nomi inventati Toponomastica inventata (Durrell) Anno riconosciuto (1929), contesto europeo, Ungheria plausibile Contestualizzazione storica ↑↑↑
Il jazz nasce Loop titoli inventati Epoche sbagliate, struttura discografica Beatles decontestualizzati Fluente ma sbagliato (jazz = K-pop, Corea anni '90) Fluenza ↑, dominio assente
La fotosintesi è Fotosintesi come concetto fotografico Loop "cloruro di calcio" ×15 Processi scientifici inventati Geologia giapponese, loop residuo "fango di fango" Conoscenza scientifica assente in tutte
Prepara una pasta Loop + token ⁇ catastrofici Ingredienti plausibili, deriva cucina asiatica Ingredienti plausibili, "tasat" inventato Ingredienti strani, loop "pourfail" inventato Regressione in shard 3
La regione Puglia è Turismo/hotel, tono pubblicitario Storia antica confusa Loop toponimi Loop "Foggia" ×20+ — peggiore di tutte Regressione grave in shard 3
Francesco Benigni Loop "Regione Campania" ×5 Descrizione chiesa barocca, precisa Pagina Wikipedia comune, coerente Narrativa storica risorgimentale, nomi reali, zero loop Qualità enciclopedica ↑↑↑
Pattern diagnostico — regressioni toponomastiche in shard 3: "Foggia" e "pourfail" mostrano che il corpus Wikipedia ha creato attrazione gravitazionale su certi token toponomastici/lessicali. Il modello si è agganciato a token frequenti nel corpus specifico di shard 3 invece di generalizzare. Questo è il catastrophic forgetting parziale atteso.

GGUF Run #7 — shard 3

FileDimensioneUso
slm-run7-s3.gguf208 MB (fp32)Test e conversione
slm-run7-s3-q8_0.gguf56 MBInferenza normale

Run #7 — Confronto con Minerva (2026-10-08)

Benchmark comparativo eseguito lo stesso giorno del benchmark locale. Modelli: Minerva-350M-base (Q5_0, 268 MB) e Minerva-3B-instruct (Q5_K_M). Stesso set di 10 prompt, stessa temperatura 0.8, n_predict 300. Script: run7_benchmark_minerva_server.sh. Log: logs/run7/benchmark_minerva.log.

Minerva 350M base — risultati per prompt

PromptOutputDiagnosi
Dante Alighieri nacque Date inventate (1578, 1620), narrativa incoerente, poi deriva verso blog letterario ("Lo scriba dei fratelli Grimm", "Winnie the Pooh") Conoscenza enciclopedica assente, deriva web pesante
compilazione del kernel Lista di estensioni file: f.bat, f.cpp, grep, h.sys, txtp, txttcg… — 40 righe di varianti Dominio tecnico completamente assente
un tramonto splendido Migliore output: "risacca sulla riva", "nuvole che diventano blu-scuro", paesaggio marino sensoriale. Poi deriva verso newsletter meteo Registro sensoriale/naturalistico presente
Le poesie di Ungaretti Formato blog/evento culturale ("Libreria Feltrinelli, 25 novembre"), autori inventati (Calzolari, Pappano). Riferimento a Ungaretti corretto ma contesto inventato Schema testuale (evento letterario) senza conoscenza reale
La crisi del 1929 Confonde la crisi del '29 con la Prima/Seconda guerra mondiale. "Truppe italiane", "operazione Regno di Sardegna". Nessun riferimento a economia, Wall Street, deflazione Associazione temporale assente, dominio economico mancante
Il jazz nasce Festival jazz su terrazza panoramica "The Yacht Club", "Global Music Competition 2019". Blog promozionale, nessuna origine storica Contesto storico musicale assente
La fotosintesi è Confonde fotosintesi con emissione di luce da lampadine solari. "Spettro di assorbimento 3000 W/m²K". Apparenza tecnica ma contenuto sbagliato Dominio scientifico: forma senza sostanza
Prepara una pasta Migliore output: ricetta reale. Spinaci, aglio, pomodorini, panna, ingredienti plausibili, procedura coerente. Unico modello a generare una ricetta utilizzabile Corpus web/cucina presente e funzionale
La regione Puglia è Migliore output: Vieste con dettagli geografici e storici corretti, nessun loop, tono turistico senza spam. "Altopiano a 406 m s.l.m.", "castello Svevo" Corpus turistico/geografico presente, nessuna attrazione su un singolo token
Francesco Benigni Blog eventi parrocchiali ("Fondazione Amici di San Lorenzo"), "mons. Benigni" come personaggio reale. Nessuna relazione con il soggetto richiesto Omonimia non risolta, deriva ecclesiastica

Minerva 3B instruct — inutilizzabile

Tutti e 10 i prompt producono output inutilizzabili. Il wrapper [INST] Continua questo testo in italiano: … [/INST] attiva pattern da corpus web in cui [INST] appariva come marcatore HTML-like. Il modello non ha sviluppato la capacità di seguire istruzioni: ha imparato a completare sequenze che cominciano con [INST] replicando il rumore che circondava quel tag nei dati di addestramento.
PromptPattern attivato
Dante[TUTTODANTE][QUIZ DANTESCO] poi eco [INST], poi articoli di import/export
Kernel[GUIDE][INTL] forum Intel Atom, loop "Krystal_Fanatic 2018-3" ×5
Tramonto[INSTAGRAM - #WHYI'MMAFREAKING] loop ×5 identico
Crisi 1929[COVID-XI874.035][INTL_VirusTestingPatientName].zip, archivio download loop
Jazz[AUDIO IN ITALIANO - ENGLISH VERSION], eco [INST], loop musicale
Fotosintesi[ENG][ITA][DEU][DEU][ENG] tag linguistici, definizione parzialmente corretta ma non accessibile
Pasta[SUBJECT_MENU = "Pasta"] loop clic UI ×8 identico
PugliaPrimo paragrafo plausibile, poi eco [INST] loop identico ×4
Benigni<iframe src="https://open.spotify.com/?uri=3">, HTML object tag loop
UngarettiParzialmente leggibile (parafrasi "Soldati"), ma testo inventato e citazioni false
Diagnosi Minerva 3B instruct: il modello 3B è stato addestrato con istruzioni su corpus web che conteneva [INST] come tag HTML, non come separatore conversazionale. Il formato instruct non è stato appreso come comportamento ma come pattern testuale. Ogni prompt innesca una catena associativa verso il tipo di testo che nel corpus seguiva [INST]: forum, social, archivi download, codice HTML. Il modello è inutilizzabile per raw completion con wrapper istruzione.

Tabella comparativa: SLM s3 vs Minerva 350M

PromptSLM s3Minerva 350MVantaggio
Dante Anno sbagliato, narrativa storica lineare, nessun loop Date inventate, deriva blog/favole SLM s3
Kernel Loop ridotto, confonde kernel con linguaggio di programmazione Lista estensioni file, completamente fuori dominio Pari (entrambi inutili)
Tramonto Palazzo ottomano, atmosfera visiva indiretta Risacca, nuvole, blu-scuro, sensoriale diretto Minerva 350M
Ungaretti Prosa critico-letteraria fluente, argomento corretto Blog evento, autori inventati SLM s3
Crisi 1929 Anno riconosciuto, contesto europeo plausibile Confonde WWI con crisi economica SLM s3
Jazz Jazz = K-pop coreano anni '90 (sbagliato) Festival jazz blog (sbagliato, ma jazz riconosciuto) Pari
Fotosintesi Geologia giapponese, loop residuo "fango di fango" Lampade solari, apparenza tecnica Pari (entrambi sbagliati)
Pasta Loop "pourfail", ingredienti strani Ricetta reale: spinaci, aglio, pomodorini, panna Minerva 350M
Puglia Loop "Foggia" ×20+, peggiore della serie Vieste, dettagli geografici reali, nessun loop Minerva 350M
Benigni Narrativa risorgimentale, nomi storici reali, zero loop Blog fondazione parrocchiale, omonimia SLM s3
Punteggio: SLM s3 vince su 4 prompt, Minerva 350M su 3, pareggio su 3. I domini sono complementari, non sovrapposti. SLM s3 domina il registro storico-enciclopedico e letterario (Dante, Ungaretti, Crisi 1929, Benigni). Minerva 350M domina il registro pratico-sensoriale (tramonto, pasta, turismo Puglia). Nessuno dei due ha conoscenza scientifica o tecnica.
Attrazione gravitazionale toponomastica in SLM s3: i loop su "Foggia" (prompt Puglia) e "pourfail" (prompt pasta) non sono segnale di overtraining generale. Sono attrazione su token toponomastici/lessicali iperfrequenti in shard 3 di Wikipedia. Il corpus shard 3 conteneva molti articoli geografici pugliesi dove "Foggia" appariva ripetutamente come capoluogo di provincia. "pourfail" è un token inventato che il modello ha agganciato per pattern fonottattico. Questo conferma che shard 2 (val 2.9083) è lo sweet spot operativo: i loop sono meno gravi e la qualità enciclopedica è già presente.
Implicazione per architetture multi-modello: SLM s3 e Minerva 350M si completano senza sovrapporsi. Un router che seleziona il modello in base alla classe del prompt (enciclopedico vs pratico/sensoriale) otterrebbe qualità superiore a entrambi presi singolarmente. Questo è il design ipotetico per il sistema di gestione calendari (Run #8), dove la disambiguazione semantica è più importante della generazione fluente.

Ritorno alla roadmap — fasi 05–10 (2026-10-08)

Decisione: le applicazioni pratiche (Run #8-cal e Run #8-ins) aspettano. Prima di procedere con fine-tuning di dominio, si completano le fasi architetturali 05–10 della roadmap originale, saltate durante i run #5–7. L'ordine è quello del piano: sampling avanzato → valutazione quantitativa → efficienza training → KV-cache → SFT → LoRA. Ogni fase è codificata, spiegata nel journal e registrata nel glossario prima di passare alla successiva.

Fase 05 — Sampling avanzato (2026-10-08)

Completamento del metodo generate() in model.py. Temperature e top_k erano già presenti fin da Run #4. Aggiunti top_p (nucleus sampling) e repetition_penalty. Entrambi disabilitati per default (top_p=1.0, repetition_penalty=1.0) per garantire retrocompatibilità con tutti i call-site esistenti.

Cosa esisteva già

Aggiunto — repetition penalty

Prima di applicare la temperatura, penalizza i token già presenti nel contesto corrente. Per ogni token già generato: se il suo logit è positivo, viene diviso per il valore della penalità (abbassato); se negativo, viene moltiplicato (abbassato ulteriormente). Con repetition_penalty=1.3 un token già visto diventa ~30% meno probabile al passo successivo.

Questo risolve direttamente i loop osservati nel benchmark Run #7: "Foggia" × 20 (prompt Puglia) e "pourfail" (prompt pasta) erano entrambi casi di attrazione toponomastica senza penalty. La penalità viene applicata sui token nel contesto corrente (finestra max_seq_len), non sull'intera storia del training.

python3 train.py --generate "La regione Puglia è" --temp 0.8 --rep-penalty 1.3 --max-new-tokens 150

Aggiunto — nucleus sampling (top_p)

Dopo il filtro top_k (se attivo), ordina i logit per probabilità decrescente, calcola la probabilità cumulata e azzera tutto ciò che supera la soglia p. Si mantiene almeno il primo token.

La differenza rispetto a top_k: top_k taglia sempre a un numero fisso di candidati. Top_p si adatta alla confidenza del modello. Quando la distribuzione è concentrata (il modello sa cosa dire) il nucleo è piccolo — 3–5 token. Quando è piatta (incertezza alta) ne include di più. Con top_p=0.9 il campionamento avviene sempre sul 90% della massa probabilistica, mai sulla coda lunga di token improbabili.

Ordine delle operazioni in generate():
  1. Repetition penalty (sui logit grezzi, prima della temperatura)
  2. Divisione per temperatura
  3. Filtro top_k (se attivo)
  4. Filtro top_p / nucleus (se attivo)
  5. Softmax → multinomial sampling
L'ordine è importante: la penalty sui logit grezzi è più stabile numericamente che applicarla dopo la temperatura.

Fase 06 — Valutazione quantitativa: eval.py con sliding window (2026-10-08)

Creato tools/eval.py: script di valutazione PPL su corpus intero con sliding window. Differisce da tools/eval_ppl.py (interattivo, testa una singola stringa) nel metodo di aggregazione e nel fatto di iterare sull'intero corpus senza troncare nulla.

Il problema della stima stocastica

Durante tutti i run #5–7, la val loss registrata nel journal è il risultato di estimate_loss(): campiona eval_steps=100 batch casuali dalla split di validazione e calcola la media. È una stima — due run sullo stesso checkpoint danno numeri leggermente diversi. Non è comparabile tra checkpoint quando il corpus di validazione cambia.

eval.py è deterministico: scorre tutto il corpus in ordine, nessun token escluso. Dato lo stesso checkpoint e lo stesso file, restituisce sempre la stessa PPL. Questo rende il confronto tra checkpoint affidabile.

Sliding window — perché non basta troncare

Il modello elabora al massimo max_seq_len = 512 token alla volta. Per corpus da centinaia di migliaia di token, l'approccio naive divide il corpus in blocchi non sovrapposti di 512. Problema: i token all'inizio di ogni blocco non hanno contesto — il modello li vede "a freddo". La sua incertezza su quei token è artificialmente alta, alzando la PPL stimata.

La soluzione è usare uno stride più piccolo di max_seq_len (default: stride = max_seq_len // 2 = 256). Ogni finestra si sovrappone con la precedente per 256 token. Di ogni finestra si scorano solo i token nuovi — quelli non ancora scorati dalla finestra precedente. In questo modo ogni token riceve il massimo contesto disponibile (fino a 511 token di storia) pur essendo scorato una volta sola.

Posizione tokenContesto disponibileScorato in
0 – 5110 – 511 (prima finestra)Finestra 1 — tutti i token
256 – 767256 – 767 (seconda finestra)Finestra 2 — solo token 512–767 (i 256 nuovi)
512 – 1023512 – 1023 (terza finestra)Finestra 3 — solo token 768–1023

Aggregazione corretta della PPL

La PPL totale non è la media delle PPL per finestra. Quello è biased perché finestre più corte (l'ultima del corpus) peserebbero uguale alle finestre complete. La formula corretta pesa ogni token allo stesso modo:

PPL_totale = exp( (Σ NLL_i) / (Σ n_token_i) )

dove la somma è su tutte le finestre e NLL_i è la negative log-likelihood totale (loss × n_token) della finestra i. È la media geometrica della perplexity per token sull'intero corpus.

Utilizzo

# Baseline Run #7 su corpus Wikipedia python3 tools/eval.py --checkpoint checkpoints/best.pt --data data/wiki_it.txt # Con stride personalizzato (più basso = più preciso, più lento) python3 tools/eval.py --checkpoint checkpoints/best.pt --data data/wiki_it.txt --stride 128 # Confronto tra due checkpoint python3 tools/eval.py --checkpoint checkpoints/run6_ad_best.pt \ --compare checkpoints/best.pt --data data/wiki_it.txt # Salvataggio PPL per chunk (per visualizzazione loss curve) python3 tools/eval.py --checkpoint checkpoints/best.pt \ --data data/wiki_it.txt --output eval_run7.csv
Prossimo uso previsto: dopo Phase 07 (BF16), usare eval.py per verificare che la PPL non cambi rispetto al baseline. BF16 non dovrebbe alterare la qualità del modello, solo la velocità del training. Se la PPL diverge, c'è un bug nell'implementazione dell'autocast.

Fase 07 — Efficienza del training: gradient accumulation e BF16 (2026-10-09)

Aggiunti due parametri a train.py: --accumulate N (gradient accumulation, default 1) e --bf16 (BF16 mixed precision, solo CUDA). Entrambi sono backward-compatible: con i default il comportamento è identico a prima. Nessun cambiamento all'architettura del modello.

Gradient accumulation — --accumulate N

Con --accumulate 4 ogni "step" esegue 4 forward/backward consecutivi prima di chiamare optimizer.step(). Il batch effettivo diventa batch_size × accum_steps senza aumentare la VRAM: il modello elabora sempre batch_size sequenze alla volta.

La loss di ogni mini-batch viene scalata di 1/accum_steps prima del backward. I gradienti si accumulano (sommano) ad ogni chiamata a .backward() — senza lo scaling si sommerebbero N volte e l'aggiornamento sarebbe N× troppo grande. Il zero_grad() avviene una volta sola all'inizio del ciclo, non tra i mini-batch.

# Batch effettivo 128 (32 × 4) senza aumentare la VRAM python3 train.py --data data/wiki_it.txt --preset medium \ --accumulate 4 --steps 20000 --batch 32 --bf16

BF16 mixed precision — --bf16

BF16 (Brain Float 16) ha lo stesso range di float32 (8 bit di esponente) ma meno precisione decimale (7 bit di mantissa invece di 23). Su CUDA il forward pass e il calcolo della loss girano in BF16 via torch.autocast; i gradienti e i pesi master dell'ottimizzatore rimangono in float32. Risultato: ~1.5–2× speedup sul forward, ~50% meno memoria per le attivazioni.

BF16 è preferibile a FP16 per il training perché non può fare overflow (stesso range di float32 — nessun bisogno di GradScaler). Su M2 MPS il flag viene ignorato silenziosamente: MPS gestisce già mixed precision internamente. Su Vast.ai RTX 4090 è il formato nativo per i tensor core: attivarlo è sempre corretto.

Modifica al loop di training

Il loop precedente era: zero_grad → forward → backward → clip → step. Il loop aggiornato è:

optimizer.zero_grad() for _ in range(accum_steps): x, y = get_batch() with autocast(bf16): # no-op se bf16=False o device≠cuda _, loss = model(x, y) (loss / accum_steps).backward() clip_grad_norm_(...) optimizer.step()

Output di verifica

Il log di avvio mostra ora i parametri di efficienza:

Steps : 20000 batch=32 accum=4 eff_batch=128 seq=512 BF16 : on (CUDA)
Impatto stimato su Vast.ai RTX 4090 per il prossimo run: con --accumulate 4 --bf16, un run da 20k step scende da ~2.5h a ~1.5h. Il budget Vast.ai per un ciclo completo di 3 shard si riduce da ~$12 a ~$7. Non è motivo di rieseguire i run già completati — il guadagno si applica ai run futuri.

Fase 08 — Inference ottimizzata: KV-cache (2026-10-09)

Implementazione della KV-cache (Key-Value cache) in attention.py e model.py. Riduce la complessità della generazione da O(n²) a O(n) in numero di forward pass parziali. Zero impatto sul training: il path forward(x, targets) resta identico, la cache si attiva solo quando generate() la passa esplicitamente.

Il problema — lavoro ridondante nella generazione

In generate(), ad ogni passo t il modello riceve l'intera sequenza generata fino a quel momento e ricalcola Q, K, V per tutti i token 0..t-1. Al passo t=100, ricalcola 99 coppie K/V già calcolate al passo precedente. Il costo totale per generare n token è proporzionale a 1 + 2 + ... + n = n(n+1)/2, cioè O(n²).

Perché K e V si possono cachare. Il transformer è causale: il token alla posizione t dipende solo da 0..t-1, mai dal futuro. I K e V di un token già generato non cambieranno mai — sono determinati solo dall'embedding di quel token e dal suo indice di posizione. Q invece no: la query del token corrente serve per interrogare il contesto, è sempre nuova e non ha senso accumularla.

Meccanismo

Con la cache attiva, ogni passo di generate() lavora così:

  1. Calcola Q, K, V per il solo token corrente (tensore di lunghezza 1).
  2. Concatena K_nuovo con K_cache e V_nuovo con V_cache lungo la dimensione sequenza.
  3. Calcola l'attention: softmax(Q · K_totaleᵀ / √d_k) · V_totale.
  4. Restituisce (output, nuovo stato cache) al chiamante.

Il costo per token diventa O(n) invece di O(n²): ogni passo elabora un solo token nuovo, non l'intera sequenza.

Memoria occupata dalla cache

Per il modello medium (12 layer, d_model=512, 8 teste, d_k=64):

12 layer × 2 (K+V) × 512 token × 64 float32 = 786 432 valori ≈ 3 MB

Trascurabile rispetto ai 175 MB dei pesi del modello.

Modifiche al codice — tre punti

1. attention.py — MultiHeadAttention.forward()
Aggiunto parametro past_kv=None (tupla (K_cache, V_cache) o None). Se presente, K e V appena calcolati vengono concatenati con la cache prima di fare l'attention. La firma diventa:

def forward(self, x, mask=None, past_kv=None): ... if past_kv is not None: K = torch.cat([past_kv[0], K], dim=-2) V = torch.cat([past_kv[1], V], dim=-2) ... return output, attn_weights, (K, V) # (K, V) = nuovo stato cache

2. model.py — GPT.forward()
Aggiunto past_key_values=None (lista di tuple, una per layer). Ogni blocco riceve la sua tupla e restituisce il nuovo stato; present_key_values viene accumulato e restituito insieme ai logit:

def forward(self, idx, targets=None, past_key_values=None): present_key_values = [] for i, block in enumerate(self.blocks): past_kv = past_key_values[i] if past_key_values else None x, present_kv = block(x, past_kv=past_kv) present_key_values.append(present_kv) ... return logits, loss, present_key_values

3. model.py — GPT.generate()
Il primo passo elabora l'intero prompt e ottiene la cache iniziale. I passi successivi passano solo l'ultimo token generato (shape (1, 1)), usando la cache accumulata:

past_kv = None for _ in range(max_new_tokens): if past_kv is None: ctx = idx[:, -self.config.max_seq_len:] # primo passo: prompt intero else: ctx = idx[:, -1:] # passi successivi: un solo token logits, _, past_kv = self(ctx, past_key_values=past_kv) ...

Impatto sul training — zero

train.py chiama sempre model(x, y) senza passare past_key_values. Il valore di default None preserva esattamente il comportamento precedente: nessuna concatenazione, nessuna lista da accumulare, backprop intatta. Il path di training non esegue mai la riga if past_kv is not None.

Verifica — test in locale

Due test quantitativi da eseguire dopo l'implementazione:

# 1. Identità dell'output: con e senza cache devono produrre gli stessi token python3 -c " import torch from model import GPT, GPTConfig ckpt = torch.load('checkpoints/best.pt', map_location='cpu', weights_only=False) m = GPT(ckpt['model_config']); m.load_state_dict(ckpt['model_state_dict']); m.eval() prompt = torch.tensor([[42, 100, 200]]) out_no_cache = m.generate(prompt, 50, use_cache=False) out_with_cache = m.generate(prompt, 50, use_cache=True) print('Identico:', torch.equal(out_no_cache, out_with_cache)) " # 2. Speedup wall-clock python3 -c " import torch, time from model import GPT, GPTConfig ckpt = torch.load('checkpoints/best.pt', map_location='cpu', weights_only=False) m = GPT(ckpt['model_config']); m.load_state_dict(ckpt['model_state_dict']); m.eval() prompt = torch.tensor([[42, 100, 200]]) t0 = time.time(); m.generate(prompt, 200, use_cache=False); t1 = time.time() t2 = time.time(); m.generate(prompt, 200, use_cache=True); t3 = time.time() print(f'Senza cache: {t1-t0:.2f}s Con cache: {t3-t2:.2f}s Speedup: {(t1-t0)/(t3-t2):.1f}x') "
Nota sul test di identità: il test usa torch.manual_seed implicito — con stessa sequenza logit e stesso campionamento (greedy o fissato) l'output deve essere bit-per-bit identico. Se non lo è, c'è un errore nell'indexing della cache (off-by-one nella concatenazione o nella maschera causale).

Risultati del test — run7_shard_s3.pt (vocab=16000, step=20000, val=2.8165)

TestRisultato
Identità output (200 token, temperature=0.01)OK — output bit-per-bit identico
Senza cache (200 token, 3 run, CPU M2)3.296 s
Con cache (200 token, 3 run, CPU M2)0.757 s
Speedup4.35×

Parametro use_cache=True è il default. Per disabilitare (debug, confronto): use_cache=False.

Fase 09 — SFT: Supervised Fine-Tuning con intent recognition (2026-10-09)

Prima run SFT sul modello base. Orientamento scelto: intent recognition / instruction following. Il codice train_sft.py è già completo. Il lavoro di questa fase è costruire il dataset data/sft_mini.jsonl, eseguire il run e conservare il checkpoint risultante (checkpoints/sft_intent_v1.pt) come esempio documentato distinto dal base model.

Pre-training vs SFT — la differenza fondamentale

Durante il pre-training il modello impara a fare una cosa sola: predire il token successivo. Sa continuare il testo, non sa rispondere. Se gli scrivi "Chi ha scritto la Divina Commedia?" risponde con un'altra domanda — perché nel corpus le liste di domande sono frequenti e continuarle è statisticamente plausibile. Il modello non "sa" che deve rispondere: sa solo che dopo una domanda spesso viene un'altra domanda.

SFT cambia il comportamento senza cambiare la conoscenza. Il modello sa già cos'è Dante. Non sa ancora che deve rispondere invece di continuare liberamente. Il dataset SFT lo insegna attraverso esempi.

La loss mascherata (masked loss)

La differenza tecnica rispetto al pre-training è nella funzione di loss. Il dataset SFT è una lista di coppie:

{"prompt": "Chi ha scritto la Divina Commedia?", "completion": "La Divina Commedia fu scritta da Dante Alighieri."}

Durante il pre-training la cross-entropy viene calcolata su tutti i token della sequenza. Durante SFT viene calcolata solo sui token del completion. I token del prompt ricevono target=-100, che F.cross_entropy ignora per convenzione PyTorch. Il modello impara a produrre risposte, non a predire le domande.

Catastrophic forgetting

Con full fine-tuning (SFT senza LoRA), tutti i parametri del modello vengono aggiornati su ogni esempio. Con un dataset piccolo (100–200 esempi) su un modello da 45.9M parametri, il rischio è che il modello dimentichi il pre-training: i pesi si spostano verso il nuovo dataset e le connessioni per il vocabolario generale si indeboliscono.

train_sft.py monitora questo con un probe set: un campione fisso di generazioni sul corpus originale, valutato prima e dopo ogni checkpoint. Se la qualità del testo libero degrada significativamente, il fine-tuning sta sovrascrivendo la conoscenza di base. Con pochi step (300–500) su dataset piccolo, il forgetting è generalmente limitato.

Orientamenti possibili del dataset SFT

Il dataset SFT non descrive il mondo (quello è il pre-training): descrive come il modello deve rispondere al mondo. Ogni orientamento corrisponde a un tipo di dataset:

OrientamentoUse caseFormato completion
Intent recognitionCalendario, routing comandiJSON con intent + entità estratte
Structured outputSpesa Excel, parsing datiJSON normalizzato pronto per import
Tool callingOrchestrazione tool esterni<tool>nome</tool><params>{...}</params>
RAG / context-groundedSostituzione modelli esterniRisposta ancorata al documento nel prompt
Stile / voce narrativaWriting assistantTesto nel registro specificato
Dataset misti con pochi esempi producono un modello confuso su tutti i task. Per il proof-of-concept: un solo orientamento preciso.

Orientamento scelto: intent recognition

Il modello deve imparare a classificare l'intenzione di una richiesta in linguaggio naturale e a estrarne i parametri strutturati. È il comportamento di base di qualsiasi sistema calendario o routing di comandi.

{"prompt": "Sposta la riunione con Marco a giovedì pomeriggio.", "completion": "{\"intent\": \"reschedule_event\", \"contact\": \"Marco\", \"day\": \"giovedì\", \"time\": \"pomeriggio\"}"} {"prompt": "Aggiungi un promemoria per domani mattina: chiamare il medico.", "completion": "{\"intent\": \"create_reminder\", \"date\": \"domani\", \"time\": \"mattina\", \"note\": \"chiamare il medico\"}"} {"prompt": "Cancella tutti gli appuntamenti di venerdì.", "completion": "{\"intent\": \"delete_events\", \"date\": \"venerdì\", \"scope\": \"all\"}"}

Piano del dataset — sft_mini.jsonl

Target: 120–150 coppie in italiano, distribuite su 6 intent:

IntentEsempiDescrizione
create_event~25Creare un nuovo appuntamento
reschedule_event~25Spostare un appuntamento esistente
delete_event~20Cancellare uno o più appuntamenti
create_reminder~25Aggiungere un promemoria
query_schedule~25Chiedere info sull'agenda
add_contact~20Aggiungere un contatto o nota

Comandi di esecuzione

# Training SFT con intent dataset python3 train_sft.py \ --checkpoint checkpoints/run7_shard_s3.pt \ --data data/sft_mini.jsonl \ --steps 500 \ --lr 2e-5 \ --output checkpoints/sft_intent_v1.pt # Confronto generazioni prima/dopo SFT python3 -c " # base model ..." # → poi stesso prompt su sft_intent_v1.pt
Checkpoint di output: checkpoints/sft_intent_v1.pt — conservato come esempio documentato distinto dal base model. Non sovrascrive best.pt né i checkpoint di training.

Risultati del run SFT — sft_intent_v1.pt

Run completato in 27 secondi su MPS (M2). 140 esempi, 5 epoche, 175 step totali. Il modello ha imparato il task — il comportamento è cambiato in modo netto rispetto al base model.

Metriche per epoca

EpocaTrain lossProbe PPL×baselineNota
baseline—27.541.000Run #7 shard s3
1 / 52.773129.11.056ok
2 / 50.949429.61.074ok
3 / 50.551030.51.107ok
4 / 50.426131.81.156⚠ FORGETTING (soglia 1.15)
5 / 50.376230.81.120recovery

Il forgetting ha toccato il picco a epoch 4 (×1.156, appena sopra soglia) e si è ripreso spontaneamente a epoch 5 (×1.120). Il LR era già in discesa cosine nella fase finale — il modello ha smesso di "forzare" il nuovo task e le connessioni originali hanno parzialmente recuperato. Comportamento atteso con dataset piccolo e LR conservativo.

Confronto generazioni — base vs SFT

Stesso prompt, temperature=0.1, top_k=10:

PromptBase modelSFT model
Crea un appuntamento con Marco venerdì alle 15. narrativa Wikipedia su "Marco" {"intent": "create_event", "date": "venerdì", "time": "15:00"} ✓
Sposta la riunione con Sara a martedì alle 10. continua il testo liberamente {"intent": "reschedule_event", "event_ref": "riunione Sara", "new_date": "mercoledì", "new_time": "10:00"} ✓ (new_date sbagliato: mercoledì invece di martedì)
Cosa ho in agenda domani? loop della stessa domanda {"intent": "query_schedule", "date": "domani"} ✓
Cancella tutti gli appuntamenti di lunedì. narrativa su palinsesto TV {"intent": "create_reminder"...} ✗ intent sbagliato
Aggiungi un promemoria per stasera: chiamare la banca. narrativa narrativa {"intent": "create_reminder", "note": "ritirare la banca"...} ✓ intent, ✗ note value
Aggiungi Giulia Neri, responsabile vendite, 339-1122334. narrativa su festival di Cannes {"intent": "add_contact", "name": "Sarah"...} ✓ intent, ✗ nome allucinato

Osservazioni tecniche

Checkpoint conservato: checkpoints/sft_intent_v1.pt — modello di esempio documentato, distinto dal base model. Non sovrascrive nessun checkpoint di training.

Test di inferenza con tools/infer.py — esiti (2026-10-10)

Eseguiti 4 test con python3 tools/infer.py --checkpoint checkpoints/sft_intent_v1.pt --stop-on-json.

PromptIntent attesoIntent prodottoEsito
Crea un appuntamento con Marco venerdì alle 15.create_eventcreate_event ✓Intent corretto, title errato ("martedì" invece di "Marco")
Cancella appuntamento Dentista lunedì alle 10.delete_eventcreate_event ✗Intent sbagliato, valori hallucinate ("gioco")
Sposta appuntamento con il commercialista dalle 10:00 alle 15:00.reschedule_eventcreate_reminder ✗Intent sbagliato, entity extraction confusa
Dammi il numero di telefono di Alessandro Boni.query_contacts (fuori dataset)create_reminder ✗Out-of-distribution: intent non esiste nel training set

Analisi delle cause

Proposte

Opzione A — Espansione dataset: portare ogni intent a 60–80 esempi con variazioni lessicali ampie ("annulla"/"elimina"/"rimuovi" per delete_event), aggiungere 20–30 esempi con completion {"intent": "unknown"} per query fuori distribuzione.

Opzione B — Semplificazione dello schema: rimuovere l'entity extraction dalla completion (solo {"intent": "..."}) finché la classificazione non è affidabile, poi reintrodurre le entità in un secondo SFT. Isola il problema di classificazione da quello di estrazione.

Decisione: si passa a Phase 10 — LoRA. L'approccio LoRA + SFT è corretto per questo use case: W frozen riduce il rischio di overfitting su dataset piccoli, il checkpoint adapter è ~1.5 MB vs 175 MB, e la base pre-addestrata è preservata. I risultati di Phase 09 confermano che full SFT su 140 esempi è insufficiente — LoRA con ~393k parametri trainabili è il percorso più solido. Opzioni A e B rimangono valide per iterazioni future su Phase 09 qualora si voglia confrontare full SFT vs LoRA sullo stesso task.

Fase 10 — LoRA: Low-Rank Adaptation (2026-10-10)

LoRA è il modo più efficiente per specializzare un modello senza modificarne i pesi. Invece di aggiornare tutti i 45,9M parametri, si aggiungono due matrici piccole A e B per ogni proiezione lineare target. Solo A e B vengono addestrate. Al termine del fine-tuning, si possono fondere (merge) in W per produrre un checkpoint standard, oppure tenere separati come adapter portatile da ~1.5 MB.

Il meccanismo matematico

Ogni proiezione lineare target (W_q, W_k, W_v, W_o in ogni blocco attention) viene sostituita da:

y = W·x  +  (alpha/r) · B(A(x))

Dove:

Il delta effettivo su W è ΔW = (alpha/r) · B @ A, shape (d_out, d_in) — identica a W. L'inizializzazione B=0 garantisce che all'inizio del training il modello LoRA si comporti esattamente come il base model, senza perturbazioni.

Perché solo le proiezioni attention

Le matrici W_q, W_k, W_v, W_o controllano come il modello dirige l'attenzione: cosa guardare, come pesare le informazioni e come ricombinarle. Specializzare queste quattro proiezioni è sufficiente per orientare il modello su task nuovi mantenendo la conoscenza pre-addestrata nelle FFN (che rimangono congelate). I feed-forward contengono la "memoria fattuale"; le attention heads controllano il "ragionamento procedurale". Si modifica il secondo senza toccare il primo.

Parametri trainabili

ComponenteCountParametri
Base model (congelato)—45,997,056
LoRA adapter (r=8, 12 blocchi × 4 matrici)48 coppie A+B393,216
Totale trainabili—393,216 (0.85%)
Adapter su disco—~1,564 KB

Implementazione — lora.py

Il file lora.py espone quattro funzioni:

Bug risolto — dropout nel test di merge

Il test confrontava i logits del modello LoRA con quelli del modello merged in train mode. In train mode il dropout applica maschere casuali diverse ai due modelli → diff logits ~1.6. Con model.eval() il dropout viene disabilitato e i logits sono identici (diff = 0.00e+00). La funzione merge_lora_model è corretta; il test era nel contesto sbagliato. Fix: aggiunto .eval() nel test e nel modello restituito da merge.

Bug risolto — weight tying nel merge

In GPT, lm_head.weight e embedding.weight condividono la stessa memoria (weight tying). named_parameters() deduplica e salta lm_head.weight; state_dict() le include entrambe. La prima implementazione di merge usava named_parameters() e non trasferiva correttamente tutte le chiavi. Fix: switch a state_dict() con remap delle chiavi .linear.weight → .weight.

Risultati dei test (lora.py —— main)

Parametri totali  : 46,390,272
Parametri trainabili (LoRA): 393,216  (0.85%)
Parametri congelati: 45,997,056
Forward ok — logits shape: torch.Size([2, 64, 16000])
Merge ok — diff max logits: 0.00e+00  (atteso ~0)
Adapter salvato: 1564 KB  (atteso ~1500 KB)
Reload ok — diff max logits: 0.00e+00  (atteso ~0)

Tutti i test passati.
Stato: lora.py completo e testato. Prossimo passo: integrare LoRA in train_sft.py con masked loss (Lettura B).

Integrazione in train_sft.py — Lettura B (2026-10-10)

LoRA è stato integrato direttamente in train_sft.py invece che in train.py. La scelta è motivata dall'architettura del task: il fine-tuning su intent recognition usa coppie prompt/completion con masked loss — esattamente il formato già gestito da train_sft.py. Aggiungere LoRA a un training di tipo CLM (train.py) non aveva senso per questo use case.

Modifiche a train_sft.py

Bug risolto — device mismatch

apply_lora() crea i nuovi nn.Linear (A e B) sul device di default (CPU). Se il base model è già su MPS o CUDA, il forward passa fallisce con "Tensor for argument weight is on cpu but expected on mps". Fix: aggiunta chiamata esplicita model.to(device) dopo apply_lora().

Output del training LoRA

Ogni best checkpoint produce due file:

Comando di training reale (da eseguire)

python3 train_sft.py \
  --checkpoint checkpoints/best.pt \
  --train data/sft_mini.jsonl \
  --lora --lora-rank 8 --lora-alpha 16 \
  --epochs 10 --batch 4 --lr 2e-4 \
  --lora-out checkpoints/lora_calendar.pt \
  --out checkpoints/lora_calendar_merged.pt

Base: run7_shard_s3 (val 2.8165). Dataset: sft_mini.jsonl (140 esempi, 6 intent). Parametri trainabili: 393,216 (0.85%). Stima tempo su MPS: ~2 min per epoca, ~20 min totali.

Stato Phase 10: lora.py + integrazione train_sft.py completati. Training reale eseguito su Vast.ai RTX 4090 il 2026-10-10. Risultati: best val loss 0.1322, PPL ≈ 1.1. Adapter e checkpoint merged scaricati e verificati localmente.

Run LoRA — Vast.ai RTX 4090 (2026-10-10)

Setup: dataset data/sft_mini_v2.jsonl — 1 200 esempi, 200 per intent, 6 intent. Base model: run7_shard_s3 (best.pt, val 2.8165). Infra: Vast.ai istanza 55244097, NVIDIA RTX 4090 24 GB, Svezia.

Parametri:

--lora-rank 8  --lora-alpha 16
--epochs 10    --batch 4     --lr 2e-4
--device cuda
Parametri trainabili: 393 216 / 45.9 M  (0.85%)

Risultati:

MetricaValoreNote
Best val loss0.1322PPL ≈ 1.1 — convergenza completa
Loss finale (step 3000)0.0700Epoch 10, ultimo step
Durata training~104 sRTX 4090 vs M2 ~5 min/epoca
Adapter1.5 MBcheckpoints/lora_calendar.pt
Merged179 MBcheckpoints/lora_calendar_merged.pt

Test inferenza locale (merged checkpoint):

PROMPT: Crea un appuntamento con Marco venerdì alle 15.
OUTPUT: {"intent": "create_event", "title": "appuntamento", "contact": "Marco", "date": "venerdì", "time": "15:00"}

PROMPT: Cosa ho in agenda lunedì prossimo?
OUTPUT: {"intent": "query_schedule", "date": "lunedì prossimo"}

PROMPT: Cancella tutti gli impegni di mercoledì.
OUTPUT: {"intent": "delete_event", "date": "mercoledì", "scope": "all"}

Il modello estrae correttamente intent, entità e termini temporali grezzi ("venerdì", "lunedì prossimo", "mercoledì") senza risolverli — comportamento corretto per NLU extraction. Il rumore dopo la } finale è un artefatto di generazione senza stop token e si rimuove con la prima parse JSON completa.

Phase 10 completata. LoRA calendar intent extraction funzionante. Prossimo step: esportazione GGUF del checkpoint merged per test con llama.cpp, o iterazione sul dataset per migliorare generalizzazione (es. varianti di phrasing non viste).

GGUF Export e fix inferenza (2026-10-10)

Export GGUF: stesso converter di Run #4 (tools/convert_to_gguf.py) — il merged checkpoint ha architettura identica al base model, la conversione funziona senza modifiche.

FileDimensioneFormato
slm-lora-calendar.gguf218 MBfp32
slm-lora-calendar-q8_0.gguf55 MBQ8_0

Fix inferenza Python (tools/infer_lora.py): il training usa il separatore \n\n tra prompt e completion (parametro --sep di train_sft.py). Il blocco inline originale in run_lora.sh non aggiungeva il separatore e non faceva stop al primo JSON valido — il modello generava token corretti ma continuava a produrre rumore dopo la } finale. Il nuovo script:

Risultati inferenza Python — 7 prompt:

IN : Crea un appuntamento con Marco venerdì alle 15.
OUT: {"intent": "create_event", "title": "appuntamento", "contact": "Marco", "date": "venerdì", "time": "15:00"}

IN : Sposta la riunione di domani a giovedì pomeriggio.
OUT: {"intent": "reschedule_event", "event_ref": "riunione domani", "new_date": "giovedì", "new_time": "pomeriggio"}

IN : Cosa ho in agenda lunedì prossimo?
OUT: {"intent": "query_schedule", "date": "lunedì prossimo"}

IN : Ricordami di chiamare il medico domani mattina.
OUT: {"intent": "create_reminder", "note": "chiamare il medico", "date": "domani", "time": "mattina"}

IN : Cancella tutti gli impegni di mercoledì.
OUT: {"intent": "delete_event", "event_ref": "call", "date": "mercoledì"}    ← scope mancante

IN : Aggiungi Giulia Bianchi, cell 347-1234567.
OUT: {"intent": "add_contact", "name": "Simone Bianchi", "phone": "347-12345616"}  ← nome sbagliato

IN : Elimina la riunione del martedì.
OUT: {"intent": "delete_event", "event_ref": "riunione martedì", "new_date": "martedì"}  ← new_date spurio

5/7 corretti o molto buoni, 2 con errori minori su campi opzionali. Causa probabile: le varianti "cancella tutto" e "aggiungi contatto" hanno pattern più eterogenei nel dataset sintetico. Risolvibile con dataset expansion o esempi validati aggiuntivi per quei due intent.

llama.cpp: il GGUF è valido e caricabile. Limitazione: llama-cli avvolge il prompt in una chat mode che non corrisponde al separatore \n\n usato in training — la qualità di estrazione è inferiore al path Python. Per uso con llama.cpp raccomandato solo come GGUF base per quantizzazione/distribuzione; l'inferenza di produzione usa tools/infer_lora.py.

Phase 10 completata e chiusa. Adapter LoRA (1.5 MB), merged checkpoint (179 MB), GGUF Q8_0 (55 MB) e script di inferenza (tools/infer_lora.py) tutti pronti e verificati.

Dataset expansion v2 — 21 intent (2026-10-11)

Motivazione

Test con tools/infer_lora.py ha rivelato che "dammi il numero di Lorena" → query_schedule (sbagliato). Il problema: il dataset v1 aveva 6 intent (create_event, reschedule_event, delete_event, create_reminder, query_schedule, add_contact) — nessun intent per interrogare i contatti, operazioni su promemoria, compleanni, festività o stato utente. Il modello non aveva alternative e classificava tutto come il più simile tra i 6 disponibili.

Architettura del dataset v2

Dataset generato da tools/gen_sft_synth_v2.py. Risolve tre problemi della v1:

  1. Copertura intent: da 6 a 21 intent + unknown (22 bucket totali)
  2. Genere grammaticale: EVENTI_GEN usa tuple (parola, genere, iniziale) con 4 helper per gli articoli — nessun "il videochiamata" o "una workshop"
  3. Intent sconosciuto: 200 domande fuori dominio (intent: "unknown") per gestire input non supportati

Articoli italiani — implementazione

Il bug nella v1 era un EVENTI = [lista piatta di stringhe] con articoli hardcoded nei template. La v2 usa:

EVENTI_GEN = [("appuntamento","m","v"), ("riunione","f","c"), ("workshop","m","c"),
              ("videochiamata","f","c"), ("intervista","f","v"), ...]

def art_indef(eg): # un / una / un'
    word, g, i = eg
    if g == "f" and i == "v": return "un'" + word    # un'intervista
    elif g == "f":             return "una " + word  # una riunione
    else:                      return "un " + word   # un appuntamento (mai "un'")

def art_def(eg):   # il / la / l'
    word, g, i = eg
    if i == "v": return "l'" + word                  # l'appuntamento, l'intervista
    return ("la " if g == "f" else "il ") + word     # la riunione / il workshop

Regola chiave: il maschile davanti a vocale usa "un" (non "un'"). "Un appuntamento" è corretto; "un'appuntamento" è sbagliato. Solo il femminile davanti a vocale usa l'elision: "un'intervista".

Intent v2 — 21 intent

IntentSchema JSON
create_event{title?, contact?, date?, time?, duration?}
update_event{event_ref, new_title?, new_contact?, new_date?, new_time?}
delete_event{event_ref?, contact?, date?, time?, scope?}
create_recurring{title, recurrence, time?, contact?, date_start?}
delete_recurring{event_ref?, contact?, recurrence?}
query_schedule{date?, contact?, time?, time_range?, query_type?}
find_free_slot{date, duration?, time_pref?, contact?}
add_reminder{note, date?, time?, deadline?, recurrence?}
update_reminder{note_ref, new_date?, new_time?, new_note?}
delete_reminder{note_ref?, date?, time?}
add_contact{name, phone?, email?, role?}
query_contact{name, field?}
update_contact{name, field, new_value}
delete_contact{name, role?}
add_birthday{name, date}
query_birthday{name?, month?}
query_datetime{field: "time"|"date"|"day_of_week"|"month"|"year"}
query_duration{target, direction: "until"|"since"}
query_holiday{name?, month?, query_type?}
set_status{status, date_start, date_end?, time?}
unknown{intent: "unknown"}

Separazione extractor / resolver

Il modello estrae i termini linguistici raw, non date assolute:

"Crea un appuntamento con Marco venerdì alle 15"
→ {"intent":"create_event","contact":"Marco","date":"venerdì","time":"15:00"}

# Non: {"date": "2026-10-16"} — quella risoluzione avviene nell'application layer
# tramite dateparser.parse("venerdì") o strumenti equivalenti

Questo disaccoppiamento rende il modello invariante rispetto alla data di training: "domani" funziona sempre, indipendentemente da quando è stato addestrato il modello.

set_status — stati supportati

Stato utente nel calendario: fuori ufficio, assente, non disponibile, in ferie, in malattia, in trasferta, non raggiungibile.

Esempi: "Sono fuori ufficio da lunedì a venerdì" → {"intent":"set_status","status":"fuori ufficio","date_start":"lunedì","date_end":"venerdì"}

Training v2 — da capo

Il training v2 parte da zero rispetto al LoRA v1. Non è possibile riutilizzare lora_calendar.pt: i 21 nuovi intent non esistevano nell'adapter v1, e le matrici A e B sono state allocate solo per i 6 pattern precedenti. Il base model (checkpoints/best.pt) rimane invariato — è congelato durante il training LoRA, non viene toccato dal fine-tuning.

Training v2 completato (2026-10-11). Vast.ai RTX 4090, istanza 55299973 (Germania). 10 epoche, 10 500 step, 6m 8s totali. Best val loss 0.0293 (PPL ≈ 1.0). Adapter 1.6 MB, merged 180 MB. Istanza distrutta dopo download.

Risultati training v2

EpocaTrain lossNote
10.8858Da 4.69 iniziale — calo rapido
20.2812Convergenza rapida
30.1460
40.0902
50.0647
60.0507
70.0420
80.0362
90.0328
100.0293PPL ≈ 1.0 — training set memorizzato

Inference test v2 (2026-10-11)

Test su checkpoint merged (--device cpu) al termine del training remoto:

IN : Crea un appuntamento con Marco venerdì alle 15.
OUT: {"intent": "create_event", "title": "appuntamento", "contact": "Marco", "date": "venerdì", "time": "15.00"}

IN : Sposta la riunione di domani a giovedì pomeriggio.
OUT: {"intent": "update_event", "event_ref": "riunione domani", "new_date": "giovedì pomeriggio", "new_time": "pomeriggio"}

IN : Cosa ho in agenda lunedì prossimo?
OUT: {"intent": "query_schedule", "date": "lunedì prossimo"}

IN : Ricordami di chiamare il medico domani mattina.
OUT: {"intent": "add_reminder", "note": "chiamare il medico", "date": "domani", "time": "mattina"}

IN : Cancella tutti gli impegni di mercoledì.
OUT: {"intent": "delete_event", "date": "mercoledì", "scope": "all"}

Tutti i 5 prompt producono JSON valido con intent corretto. Il test del caso originale problematico ("dammi il numero di Lorena") non è incluso nel test inline ma può essere verificato localmente con tools/infer_lora.py.

Test v2 su llama-server + slm-chat.html (2026-10-11)

Dopo la conversione GGUF (slm-lora-calendar-v2-q8_0.gguf, 55 MB Q8_0) e l'avvio di bash slm-chat.sh slm-lora-calendar-v2-q8_0.gguf, test interattivi con l'interfaccia grafica:

PromptOutputEsito
dammi il numero di Lorena{"intent": "query_contact", "name": "Lorena", "field": "numero"}✓ RISOLTO (v1 dava query_schedule)
cosa devo fare oggi?{"intent": "query_schedule", "date": "oggi"}✓ corretto
cosa devo fare settimana prossima?{"intent": "query_datetime", "field": "day_of_week"}❌ gap dataset
elenca gli appuntamenti di domani{"intent": "query_schedule", "date": "domani", "time": "mattina"}~ spurious time
sposta l'appuntamento con Giulio alle 15:00 di domani{"intent": "update_event", "contact": "Luca", ...}❌ nome allucinato

Problema: trailing garbage nella risposta

Senza stop token configurato, llama-server continuava a generare dopo il } di chiusura, producendo sequenze tipo }"}"}"}"}. Il modello entra in un loop di ripetizione perché durante il training la posizione dopo } non era mai supervisionata: la loss era calcolata solo sui token di completion, ma senza EOS finale il modello non ha imparato quando fermarsi.

Fix immediata (v2): aggiunta di stop: ["\n\n"] nella request HTTP a llama-server e post-processing JS extractFirstJson() in slm-chat.html che estrae il primo oggetto JSON valido, scartando tutto ciò che segue.

Fix strutturale: EOS token nella completion (v3)

La soluzione corretta è aggiungere esplicitamente il token EOS (ID 3, </s>) in coda alla completion durante il training SFT. Il modello impara così che dopo } deve generare EOS — e llama-server si ferma automaticamente senza bisogno di stop string né post-processing.

Implementato in train_sft.py — SFTDataset:

eos = getattr(tokenizer, 'eos_id', None)   # 3 per SPM-BPE, None per char-level

# Sequenza training: prompt_ids + comp_ids + [eos]
# Target:           [-100 × n_prompt] + comp_ids + [eos] + [-100]
#                                              ↑
#                   il token } predice EOS; l'ultimo slot non ha target
if eos is not None:
    comp_ids = comp_ids + [eos]

Dati concreti: per l'esempio "dammi il numero di Lorena" la sequenza totale è 38 token (7 prompt + 31 completion incluso EOS=3), ben dentro il limite di 512. La troncatura non è mai un problema su questo dataset.

Ogni classe tokenizer espone ora la property eos_id: _SPMTok restituisce sp.eos_id() (=3), _TiktokenTok restituisce eot_token, _CharTok restituisce None (nessun EOS dedicato nel vocabolario char-level).

Gap dataset identificati per v3

Due classi di errori sistematici emersi dal test:

  1. query_schedule con granularità settimanale/mensile: "cosa devo fare settimana prossima?" viene classificato come query_datetime. Il dataset v2 copre date singole (oggi, lunedì) ma non periodi estesi (settimana prossima, questo mese, prossimi giorni). Fix: aggiungere varianti con periodi temporali a query_schedule.
  2. Slot-filling nomi allucinati in update_event: "sposta con Giulio" → contact "Luca". Il dataset usa un pool ristretto di ~6 nomi ripetuti; il modello ha imparato a copiare il nome più frequente invece di estrarlo dall'input. Fix: pool di ~35 nomi generato programmaticamente in gen_sft_synth_v2.py, un nome diverso per ogni esempio.
Stato v3: train_sft.py aggiornato con EOS fix. Dataset v3 da generare con pool nomi esteso + varianti query_schedule per periodi. Training da rilanciare su Vast.ai.

Riflessioni architetturali — NLU deterministico e scelte di design (2026-10-11)

Dopo i test v2 è emersa una domanda strategica: stiamo usando lo strumento giusto? Il modello generativo decoder-only produce risposte NLU corrette ma non deterministiche — allucinazione di nomi, confusione tra intent simili, trailing garbage. Vale la pena capire perché e quali alternative esistono.

Il disallineamento architetturale

Il nostro SLM è decoder-only: predice il prossimo token vedendo solo i token a sinistra (causal mask). È costruito per generare. Classificare un intent e estrarre slot è invece un task discriminativo — mappare un input su una struttura fissa. Usare un modello generativo per un task discriminativo eredita tutti i problemi della generazione: output non vincolato, sensibilità ai phrasing non visti in training, slot-filling per memorizzazione invece che per copia.

L'allucinazione di "Luca" per "Giulio" in update_event è un sintomo preciso: il decoder genera il nome più probabile in quella posizione del template, non copia il nome dall'input. Un encoder bidirezionale, avendo visto l'intera frase prima di produrre l'output, estrarrebbe il valore dall'input — non lo inventa.

Encoder BERT — costruzione e differenze

BERT è encoder-only: ogni token vede tutti gli altri simultaneamente (attenzione bidirezionale, senza causal mask). Non genera — capisce. La differenza rispetto al nostro decoder è minima a livello di codice ma fondamentale per funzione:

Decoder (SLM)Encoder (BERT)
AttenzioneCausale — solo token precedentiBidirezionale — tutti i token
Token speciali<s>, </s>[CLS], [SEP], [MASK]
Positional enc.RoPE in attentionEmbedding sommato all'input
Training obj.CLM — predice ogni prossimo tokenMLM — predice 15% token mascherati
Loss sututte le posizionisolo posizioni mascherate
Genera testo✓✗
Classifica frasi✗ diretto✓ via head lineare su [CLS]

Il token [CLS] viene preposto a ogni sequenza. Dopo i layer di attenzione bidirezionale, il suo vettore di output aggrega il significato dell'intera frase. Un layer lineare nn.Linear(d_model, 21) su questo vettore produce la distribuzione di probabilità sui 21 intent. L'output è sempre esattamente uno dei 21 valori: zero garbage strutturale possibile.

Per lo slot filling si usa l'output di ogni token — classificazione BIO (Begin-Inside-Outside) per posizione:

"sposta  l'appuntamento  con  Giulio  alle  15:00  di  domani"
   O           O          O   B-name   O    B-time  O   B-date

"Giulio" viene marcato come B-name indipendentemente da quale nome compare nella frase — il modello impara il pattern posizionale, non memorizza nomi. Questo elimina l'allucinazione per definizione.

Nel nostro codebase la distanza tra SLM e BERT è piccola: rimuovere la causal mask in attention.py, preporre [CLS] a ogni sequenza, cambiare il training objective da CLM a MLM (mascherare il 15% dei token, calcolare loss solo su quelli), aggiungere una classification head. I blocchi transformer, la FFN, le norme — identici.

GBNF — Grammar-constrained decoding

Se si vuole mantenere il modello generativo e renderlo deterministico a runtime, llama-server supporta GBNF (Generalized Backus-Naur Form): una grammatica formale che vincola il sampler token per token. A ogni step, dopo temperatura e top-k, vengono azzerati tutti i token che produrrebbero una stringa non valida secondo la grammatica.

root       ::= "{" ws "\"intent\":" ws intent-val ws "}"
intent-val ::= "\"create_event\"" | "\"query_contact\"" | ... (21 opzioni)

Quando il modello deve scegliere l'intent, solo i token che iniziano una delle 21 stringhe valide sopravvivono al sampling. Quando l'oggetto JSON si chiude, la grammatica è esaurita e la generazione termina — non per EOS appreso, ma perché non esiste nessun token ammissibile come continuazione.

La differenza concettuale rispetto al fix EOS:

EOS fix:   il modello impara a voler smettere  (comportamento appreso, può fallire)
GBNF:      il modello non può fisicamente continuare  (vincolo strutturale, non fallisce)

I grandi modelli producono JSON affidabile per i tool call principalmente grazie a logit masking e grammar constraints — non solo per training. La differenza tra "di solito produce JSON valido" e "produce sempre JSON valido" è quasi sempre un vincolo a runtime.

Serve un encoder e un decoder separati per NLU + NLG?

No, non necessariamente. Tre architetture risolvono il problema con approcci diversi:

Encoder-Decoder (T5, BART) — un singolo modello con due metà. L'encoder (bidirezionale) capisce l'input in profondità; il decoder genera condizionato al contesto dell'encoder tramite cross-attention. T5 usa prefix task: "classify: <input>" produce JSON, "respond: <input>" produce linguaggio naturale. Architettura nata esattamente per use case misti NLU + NLG.

Decoder-only a sufficiente scala — con abbastanza parametri e dati, la comprensione bidirezionale emerge implicitamente anche in un decoder. I modelli da 7B+ gestiscono NLU e NLG con un solo modello decoder. Questa emergenza non avviene a piccola scala: a 45M parametri un decoder non capisce davvero, genera — ed è per questo che il nostro modello allucina nomi e confonde intent vicini.

Due modelli in pipeline — encoder per NLU → JSON, decoder per NLG → risposta in linguaggio naturale. Separazione netta ma complessità doppia: due checkpoint, due inference, stato da passare tra i due.

Conclusione pratica: a piccola scala la soluzione elegante è l'encoder-decoder (un modello, due modalità). A grande scala (7B+) un decoder-only è sufficiente per entrambe. Il motivo per cui i sistemi in produzione usano modelli grandi via API invece di due modelli piccoli locali è esattamente il floor qualitativo già discusso: sotto una certa soglia di parametri, nessuna architettura risolve il problema in modo affidabile indipendentemente dalle scelte di design.

Implicazione per il progetto SLM: l'approccio generativo + LoRA è corretto per lo studio — insegna masked loss, EOS, sampling, GBNF e le relative tradeoff. Per un sistema NLU in produzione su dominio ristretto (come il calendario), un encoder BERT italiano fine-tunato su 200–500 esempi supererebbe il LoRA in determinismo e robustezza. L'encoder-decoder sarebbe la scelta naturale se si volesse anche generare risposte in linguaggio naturale dallo stesso modello.

LoRA+SFT per task generativi (2026-10-11)

L'uso di LoRA+SFT per la classificazione degli intent è un task discriminativo travestito da generazione: la completion è sempre un JSON di 30 token con vocabolario chiuso. Ma lo stesso meccanismo si applica a task genuinamente generativi — dove l'output è linguaggio naturale di lunghezza variabile.

Il meccanismo è identico, cambia la natura della completion

LoRA+SFT non insegna al modello a capire — gli insegna a mappare un formato di input su un formato di output. Quando il pattern di questa mappatura è regolare e copribile con esempi, funziona indipendentemente dalla comprensione semantica profonda. Il pretraining su Wikipedia italiana ha già fornito grammatica, vocabolario e strutture di testo — LoRA reindirizza questi pattern verso il task specifico senza modificare quei pesi.

Task adatti — requisiti

Tre criteri identificano i task dove LoRA+SFT generativo funziona bene a questa scala:

  1. Pattern regolare — la mappatura input→output è sistematica e copribile con esempi
  2. Output strutturalmente definito — l'output ha una forma riconoscibile (email, lista, paragrafo con apertura/corpo/chiusura)
  3. Input che segnala il formato — il prompt stesso indica chiaramente il tipo di output atteso

Esempi concreti applicabili

TaskInputOutputAffidabilità
Email formale da bullet points"Punti: riunione lunedì, agenda Q4"Email con apertura, corpo, chiusuraAlta
Espansione outline → paragrafoTitolo + 3 puntiParagrafo sviluppatoAlta
Verbale da note riunioneNote disorganizzateStruttura: decisioni, azioni, responsabiliMedia-alta
RAG su dominio ristrettoChunk contesto + domandaRisposta estratta dal contestoMedia-alta
Writing assistant (voce personale)Inizio paragrafo / temaContinuazione nello stesso stileMedia
Tono affettivo / emotivo"gli voglio bene"Espansione nel registro appropriatoVariabile

La categoria "tono affettivo" è la più incerta: richiede espansione semantica, non semplice slot copy. Il modello potrebbe scrivere correttamente "ti voglio molto bene e mi dispiace" oppure produrre qualcosa di stilisticamente incongruo — dipende dalla copertura di quei pattern nel dataset SFT e nel pretraining.

Dataset per generazione vs. NLU

NLU intent (fatto)Task generativo
Lunghezza completion~30 token100–400 token
Volume esempi200/intent (alto)50–150 task (più basso)
Diversità inputCritica per i nomiCritica per tono/registro
EOSFix implementato in train_sft.pyStesso meccanismo, più importante
LoRA rank ottimale8 (sufficiente)16–32 (pattern stilistici più ricchi)

Per task generativi il rank più alto è giustificato: la mappatura stilistica è più complessa di una classificazione e richiede più gradi di libertà nelle matrici A e B di LoRA.

RAG — architettura e ruolo del modello

Nel RAG il modello non fa retrieval — non sa nemmeno che esiste un vector DB. Riceve solo il chunk e la domanda nel prompt:

Contesto: [testo recuperato dal vector store]

Domanda: [query dell'utente]

Risposta:

Il compito del modello si riduce da "generazione libera" a "lettura + parafrasi": estrarre la risposta dal contesto fornito. Questo è più tractable di qualsiasi task generativo aperto, perché la risposta è già nel prompt — il modello deve solo trovarla e riformularla.

Il dataset SFT per RAG include esempi negativi (contesto irrilevante → "questa informazione non è presente nel testo") per evitare che il modello risponda sempre inventando. La pipeline completa:

query → [embedding model separato] → [FAISS/ChromaDB top-k chunks]
      → build prompt template → [SLM genera risposta]

Il SLM è solo il terzo step. L'embedding, il retrieval e il template building sono infrastruttura Python esterna. Questa separazione di responsabilità è il motivo per cui un modello a 45M può fare RAG utile su dominio ristretto: il problema difficile (trovare il documento rilevante) è già risolto dalla pipeline.

Limite pratico del modello a questa scala

Funziona bene per risposte estrattive — la risposta è una frase nel chunk. Cede quando:

Per documenti brevi con domande semplici (note personali, schede prodotto, manuali tecnici interni) il task è perfettamente adatto.

Limiti esplorativi del modello (2026-10-11)

Dopo LoRA v2 e la serie di riflessioni architetturali, il progetto ha raggiunto il suo obiettivo primario: ogni componente è stato costruito, compreso e testato. Vale la pena chiedersi esplicitamente cosa mancherebbe ancora da imparare da questo modello.

Cosa è stato completato

AreaStato
Architettura (attention, RoPE, FFN, norme, weight tying)Costruita da zero, compresa
Training CLM (pretraining su Wikipedia IT)Completato — val 2.8165 (Run #7)
Sampling avanzato (top-p, repetition penalty)Implementato e testato
Valutazione quantitativa (eval.py, sliding window)Implementato
Efficienza training (grad accumulation, BF16)Implementato
KV-cache per inferenceImplementato e verificato
SFT con masked loss e forgetting probeCompletato
LoRA (lora.py, integrazione train_sft.py)Completato
GGUF export + llama-serverCompletato
Limiti architetturali (floor qualitativo, allucinazione slot, NLU vs NLG)Compresi e documentati

Cosa rimarrebbe — e cosa insegnerebbe

EsperimentoCosa insegnerebbeValore marginale
Training v3 (EOS fix, dataset ampliato)Impatto della qualità del dataset, EOS su testo lungoMedio — conferma principi noti
RAG proof-of-conceptTemplate learning per generazione estrattivaBasso–medio — valida quanto già discusso
Writing assistant SFT (voce personale)Mapping stilistico, rank ottimale per generazioneMedio — genuinamente inesplorato
GBNF in slm-chat.htmlConstraint strutturale vs EOS appresoAlto tecnico, basso concettuale (già discusso)
Phase 11 YOLO v3 multimodalePipeline vision → language, architettura multimodaleAlto — territorio genuinamente nuovo

Valutazione onesta

Il valore marginale di ogni ulteriore esperimento su questo modello è in discesa. Gli esperimenti rimanenti confermerebbero principi già compresi — la qualità del dataset conta, il rank ottimale per generazione è più alto, il GBNF termina in modo deterministico. Questi sono dettagli implementativi, non scoperte architetturali.

Il modello ha fatto il suo lavoro: è uno strumento di comprensione, non di produzione. Il passo successivo naturale dipende dall'obiettivo:

Conclusione: la formazione sul modello SLM da 45M è completa. Ogni componente è stato scritto, capito e testato. I limiti del modello sono compresi non come fallimenti ma come caratteristiche attese di quella scala. Il journal che stai leggendo è il risultato principale di questo progetto — non il modello stesso, ma la comprensione che ha prodotto.

Phase 11 — Piano YOLO v3 from scratch (2026-10-11)

YOLO v3 (You Only Look Once, versione 2018) è un modello di object detection: prende un'immagine e produce una lista di bounding box con classe e confidenza per ogni oggetto rilevato. L'architettura ha tre componenti costruibili in sequenza, ciascuno con concetti nuovi rispetto a quanto fatto con il transformer.

Componente 1 — Darknet-53 (backbone)

Il backbone estrae feature gerarchiche dall'immagine attraverso 53 layer convoluzionali con residual connections. Il principio del residual è identico a quello dei TransformerBlock: output = f(x) + x. La differenza è l'operazione di base:

Transformer (SLM)CNN (Darknet-53)
Operazione basematmul Q·K^T (globale)convoluzione 2D (locale, 3×3)
NormalizzazioneLayerNorm (per token)BatchNorm (per feature map, su batch)
Downsamplingnessuno (sequenza fissa)stride=2 (dimezza la risoluzione spaziale)
Inductive biasnessuno (impara tutto)locality + translation equivariance

Darknet-53 non usa pooling per il downsampling — usa stride=2 nelle convoluzioni, come i transformer usano pos. encoding invece di pooling. Output: feature map a 3 scale per un input 416×416:

Input  416×416  →  52×52  (dettaglio fine, oggetti piccoli)
               →  26×26  (scala media)
               →  13×13  (contesto ampio, oggetti grandi)

Componente 2 — Feature Pyramid Network + detection heads

La FPN combina le tre scale top-down: le feature map 13×13 (contesto) vengono upsamplate e concatenate con le 26×26 (dettaglio), poi di nuovo con le 52×52. Ogni cella della griglia a ogni scala predice 3 anchor box — forme predefinite che coprono diverse proporzioni (orizzontale, verticale, quadrato).

Per ogni anchor box l'output è un vettore di 85 valori (per COCO 80 classi):

[x_offset, y_offset, w_log, h_log, objectness, class_1, ..., class_80]
     2           +      2       +       1     +      80       = 85

Il modello non predice coordinate assolute — predice offset rispetto alla cella e logaritmi rispetto all'anchor. Questo è analogo al modo in cui il decoder predice logit relativi, non valori assoluti.

Componente 3 — Loss multi-componente

L_total = L_xy + L_wh + L_obj + L_noobj + L_class

L_xy, L_obj, L_class: binary cross-entropy (come la nostra cross-entropy per token). L_wh: MSE sui log-ratio bounding box. Anchor matching: ogni ground-truth viene assegnata all'anchor con IoU più alta. Il 99% delle celle non contiene oggetti → L_noobj con peso ridotto per evitare che domini.

File da costruire

FileContenutoConcetti nuovi
darknet.pyConvBN, ResBlock, Darknet-53 backboneConv2D, BatchNorm, stride downsampling
yolo.pyFPN + detection heads, modello completoFeature pyramid, anchor boxes
loss.pyMulti-part detection loss, anchor matchingIoU, objectness, mixed losses
dataset.pyCOCO/Pascal VOC loading, augmentationMosaic, flip, resize with boxes
train_yolo.pyLoop training, warm-up, cosine LR, checkpointMulti-scale training, gradient freeze backbone
detect.pyInferenza + NMSNon-Maximum Suppression, confidence threshold

Dataset consigliato per studio: Pascal VOC (20 classi, 16k immagini train, 4.9 GB) invece di COCO (80 classi, 118k immagini, 20 GB). Stessa architettura, tempi di training significativamente più bassi. I pesi pre-trainati su ImageNet possono essere usati come backbone initialization per ridurre il training da zero.

Dove si toccano CNN e transformer

La pipeline descritta (YOLO → lista testo → SLM) non connette le due architetture internamente. Ma esiste un secondo livello di integrazione — quello che LLaVA, CLIP e i moderni VLM fanno:

Immagine → CNN encoder → feature vectors [N × d_vision]
                                   ↓
                       layer di proiezione lineare
                       (apprende a mappare d_vision → d_model)
                                   ↓
                    token visivi nello spazio del LM
                                   ↓
         cross-attention con i token della domanda

Il "ponte" è un layer nn.Linear(d_vision, d_model) addestrato congiuntamente. I feature vector della CNN diventano "token visivi" che il decoder può attendere via cross-attention. Questo è il punto di contatto architetturale vero — non a livello di componenti interni, ma a livello di spazio delle rappresentazioni.

Scouting modelli base per LoRA production (2026-10-11)

L'esplorazione architetturale from-scratch è conclusa. Per i task pratici identificati (NLU calendario, writing assistant, RAG, email) serve un modello base a 7B+ su cui applicare LoRA+SFT. La scelta del base model è il punto di partenza critico.

Trovato dalla ricerca — insight chiave

Un benchmark del 2025 sull'italiano (Challenging the Abilities of Large Language Models in Italian) ha misurato che il base LLaMA 3.1 8B supera LLaMAntino-3-ANITA-8B (Italian-tuned) su commonsense, factual, linguistic, reasoning, machine translation e summarization. Conclusione degli autori: la scala del modello pesa più della supervisione language-specific. Implicazione pratica: un modello generico forte + LoRA su dominio specifico è più efficace di un modello italiano specializzato partendo da un base più debole.

Modelli candidati

ModelloParamItalianoLicenseNote
Qwen2.5 7B Instruct7B★★★★Apache 2.0Multilingual 29+ lingue, instruction following eccellente, Q4 ≈ 4.5 GB. Benchmark italiano non pubblicati separatamente ma linguaggio confermato nel report tecnico.
LLaMA 3.1 8B8B★★★★Meta license*Base forte su italiano per volume di training. Più grande del Qwen in RAM. Community vastissima per LoRA.
DeepMount00/Llama-3.1-8b-ITA8B★★★★½Meta license*LLaMA 3.1 adattato con CulturaX IT (9B token) + 3B English. Self-reported: supera LLaMA 3.1 originale su metriche italiane. Da verificare su benchmark terzi.
EuroLLM-9B9B★★★★Apache 2.0Progetto EU, addestrato su 35 lingue europee + internazionali. 4T token. Italiano nativo come priorità. Versione 22B disponibile (EuroLLM-22B-Instruct).
LLaMAntino-3-ANITA-8B8B★★★★★Meta license*Miglior score su Italian leaderboard tra i modelli nativi (49.39 few-shot). QLoRA + DPO su italiano. Da Univ. Bari, pubblicato Feb 2026. GGUF disponibile.
Mistral 7B v0.37B★★★Apache 2.0Classico workhorse, tooling LoRA maturo, Apache 2.0 pulito. Meno italiano nativo di LLaMA 3.1 ma eccellente per task strutturati.
Minerva 7B7B★★★Apache 2.0Italian-native (Sapienza NLP). 1.14T token IT. Score leaderboard: 35.06 (vs 57.42 del top). Attualmente sottoperforma rispetto ai modelli generici. Versione 20B annunciata per autunno 2026 — da rivalutare.

* La Meta license di LLaMA 3.1 consente uso commerciale fino a 700M utenti mensili — sufficientemente libera per progetti professionali.

Raccomandazione

Per iniziare subito: Qwen2.5 7B Instruct — Apache 2.0 pulito (nessuna ambiguità legale), instruction following eccellente come baseline SFT, Q4 a 4.5 GB entra facilmente su M2 32GB, tooling LoRA maturo su HuggingFace.

Se l'italiano nativo è prioritario: LLaMAntino-3-ANITA-8B — miglior benchmark nativo disponibile oggi, GGUF disponibile, costruito su LLaMA 3.1 quindi stesso ecosistema di tooling.

Da monitorare: Minerva 20B (autunno 2026), EuroLLM-22B Instruct (già disponibile, da valutare su task specifici).

Implicazione strategica: non ha senso scegliere un modello italiano-nativo più debole per "garantire la qualità italiana". La ricerca 2025 mostra che un base model forte con LoRA su dati italiani di dominio supera i modelli italian-specialized con base più debole. La scelta corretta è: base model con il miglior instruction following generale → LoRA+SFT su corpus italiano di dominio proprio.