SLM · Diario di Bordo
Training sequenziale su Vast.ai, sweet spot run6-ad, confronto Minerva, strategia corpus Run #7 e Run #8.
slm-run6-shard-ad-q8_0.gguf--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.
run4_best.pt + sorgenti). Nessuna re-tokenizzazione necessaria.
| Parametro | Valore | Note |
|---|---|---|
| Punto di partenza | run4_best.pt (val 3.5861) | Pesi Run #4 |
| Sequenza shard | aa → ab → ac → ad → ae → af → ag | Ordine originale del corpus |
| Steps × shard | 8 000 | 56 000 step totali |
| Batch size | 32 | 16 384 token/step (vs 4 096 su M2) |
| Token totali | ~917 M | Chinchilla parity: 20 × 45.9 M param = 918 M |
| Meccanismo | --resume --reset-best | Pesi caricati, soglia azzerata |
| Script | run6_sequential.sh | Salva checkpoint intermedi per shard |
| Shard | File | Status | Val loss finale | Note |
|---|---|---|---|---|
| 1 / aa | shard_aa.txt | ✓ completato | 3.5568 | 40m 52s — sotto Run #4 (3.5861) di 0.029 |
| 2 / ab | shard_ab.txt | ✓ completato | 3.4458 | 40m 47s — −0.111 rispetto ad aa |
| 3 / ac | shard_ac.txt | ✓ completato | 3.4011 | 40m 44s — −0.045 rispetto ad ab |
| 4 / ad | shard_ad.txt | ✓ completato | 3.3524 | 40m 43s — −0.048 rispetto ad ac |
| 5 / ae | shard_ae.txt | ✓ completato | 3.3385 | 40m 45s — sample: "La società ha già deciso di sgomberare, ma il progetto non ha ancora finito" — −0.014 rispetto ad ad |
| 6 / af | shard_af.txt | ✓ completato | 3.3040 | 40m 45s — sample: "a Milano la prima delle sue iniziative, quella di aprire in città i propri negozi" — −0.035 rispetto ad ae |
| 7 / ag | shard_ag.txt | ✓ completato | 3.2965 | 40m 44s — best a step 8000 (loss in calo fino alla fine) — −0.008 rispetto ad af |
| Step | Train | Val | Elapsed | Note |
|---|---|---|---|---|
| 6000 | 3.3958 | 3.4353 | 30:36 | ✓ checkpoint — val scende sotto ab; sample: "io, a fianco di un vecchio e ben più piccolo…" |
| 6500 | 3.3711 | 3.4120 | 33:08 | sample: "la sua carriera ha avuto inizio ad una nuova attività lavorativa…" |
| 7500 | 3.3656 | 3.4061 | 38:12 | — |
| 8000 | 3.3342 | 3.4011 | 40:44 | ✓ shard completato — val finale |
| Step | Train | Val | Elapsed | Note |
|---|---|---|---|---|
| 7500 | 3.3286 | — | ~38m | — |
| 8000 | 3.2945 | 3.3385 | 40:43 | ✓ shard completato — sample: "La società ha già deciso di sgomberare, ma il progetto non ha ancora finito" |
run6_shard_af.pt scaricato localmente.| Step | Train | Val | Elapsed | Note |
|---|---|---|---|---|
| 0 | 3.3062 | 3.3354 | 00:18 | ✓ checkpoint (caricato da ae, val migliore di ae=3.3385 — nuovo corpus spinge su) |
| 500 | 3.4683 | 3.4779 | 02:49 | — |
| 1000 | 3.4901 | 3.4946 | 05:21 | — |
| 1500 | 3.4647 | 3.4769 | 07:52 | — |
| 2000 | 3.4514 | 3.4674 | 10:24 | — |
| 2500 | 3.4308 | 3.4766 | 12:55 | — |
| 3000 | 3.4278 | 3.4458 | 15:27 | — |
| 3500 | 3.4085 | 3.4388 | 17:58 | — |
| 4000 | 3.3658 | 3.4191 | 20:30 | — |
| 4500 | 3.3710 | 3.3821 | 23:01 | sample: "di Roma, mentre il giorno precedente, nel pomeriggio di lunedì, era previsto un evento per le vie di Roma" |
| 5000 | 3.3392 | 3.3790 | 25:32 | — |
| 5500 | 3.3057 | 3.3597 | 28:04 | — |
| 6000 | 3.2960 | 3.3444 | 30:35 | — |
| 6500 | 3.2837 | 3.3351 | 33:07 | ✓ checkpoint (val=3.3351) |
| 7000 | 3.2885 | 3.3200 | 35:39 | ✓ checkpoint (val=3.3200) |
| 7500 | 3.2769 | 3.3040 | 38: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" |
| 8000 | 3.2778 | 3.3133 | 40:43 | fine shard (val risale leggermente: 3.3133) |
checkpoints/run6_shard_ad.pt.
| Step | Train | Val | Elapsed | Note |
|---|---|---|---|---|
| 0 | 3.3021 | 3.3164 | 00:18 | ✓ checkpoint (da af best=3.3040 — corpus diverso, val parte leggermente più alta) |
| 500 | 3.4478 | 3.4670 | 02:49 | — |
| 1000 | 3.4567 | 3.4830 | 05:21 | — |
| 1500 | 3.4441 | 3.4731 | 07:52 | — |
| 2000 | 3.4309 | 3.4661 | 10:23 | — |
| 2500 | 3.4226 | 3.4568 | 12:55 | — |
| 3000 | 3.3973 | 3.4418 | 15:26 | — |
| 3500 | 3.3646 | 3.4163 | 17:58 | — |
| 4000 | 3.3562 | 3.4046 | 20:29 | — |
| 4500 | 3.3310 | 3.3883 | 23:00 | — |
| 5000 | 3.3208 | 3.3674 | 25:32 | — |
| 5500 | 3.2943 | 3.3464 | 28:03 | — |
| 6000 | 3.2826 | 3.3268 | 30:34 | — |
| 6500 | 3.2579 | 3.3267 | 33:06 | — |
| 7000 | 3.2626 | 3.3155 | 35:37 | ✓ checkpoint (val=3.3155) |
| 7500 | 3.2671 | 3.3073 | 38:09 | ✓ checkpoint (val=3.3073) |
| 8000 | 3.2399 | 3.2965 | 40:41 | ✓ BEST — fine shard, loss ancora in calo |
unordered_map::at: key not found avveniva al momento del prompt, non al caricamento del modello.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.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.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.--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
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).| Prompt | Generazione (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…» |
slm-run4.gguf — 207 MB fp32slm-run4-q8_0.gguf — 56 MB Q8_0llama-cli -hf c1cc10/slm-italiano --hf-file slm-run4-q8_0.gguf -p "Il Parlamento europeo" -n 100
/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.| 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 |
| Seed | Output 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…» |
| Seed | Output 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.…» |
| Seed | Output 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…» |
| Uso | Modello raccomandato | Motivo |
|---|---|---|
| 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) |
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.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)]))"
"logit_bias" di ogni richiesta al server. Con il fix attivo, tutti i prompt precedentemente problematici producono testo italiano coerente.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 | run4-v6 | run6-aa | run6-ab | run6-ac | run6-ad | run6-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) |
| 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
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.| Parametro | Valore |
|---|---|
| Punto di partenza | checkpoints/run6_shard_ad.pt |
| Corpus sorgente | data/itwiki.xml.bz2 — estrazione da offset 200 MB |
| Shard previsti | 4–5 shard da ~1.1 GB testo ciascuno (~70 M token/shard) |
| Step per shard | 8 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 |
| Infrastruttura | Locale (M5 32 GB) — Vast.ai non necessario |
| Meccanismo | --resume --reset-best shard sequenziale, come Run #6 |
--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 8Poi tokenizzare ogni shard con il tokenizer esistente (
--tokenizer bpe-spm) e addestrare con --resume --reset-best sequenzialmente da run6_shard_ad.pt.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.| Parametro | Valore |
|---|---|
| Punto di partenza | run6_shard_ad.pt — val 3.3524 |
| Corpus | Wikipedia IT dump, shard 1–3 × 1.1 GB (~70 M token/shard) |
| Step per shard | 20 000 (Chinchilla parity) |
| Batch × seq | 32 × 512 |
| Meccanismo | --resume --reset-best sequenziale |
| Hardware | RTX 4090 (Vast.ai, istanza 54681871) |
| Durata totale | ~5.5 ore (19:19 UTC 7/10 → 01:01 UTC 8/10) |
| Fase | Val loss | Δ | Note |
|---|---|---|---|
| CulturaX baseline (run6-ad) | 3.3524 | — | Punto di partenza |
| Shard 1 (wiki s1, step 20 000) | 3.0917 | −0.261 | Completato 21:36 UTC |
| Shard 2 (wiki s2, step 20 000) | 2.9083 | −0.183 | Completato 23:19 UTC |
| Shard 3 (wiki s3, step 20 000) | 2.8165 | −0.092 | Completato 01:01 UTC — best assoluto |
| Step | Val | Nota |
|---|---|---|
| 0 | 2.9400 | baseline reset-best |
| 1 500 | 3.0888 | peak adattamento |
| 7 000 | 2.9911 | sotto 3.0 |
| 10 000 | 2.9319 | crossing baseline |
| 11 500 | 2.8910 | salto −0.032, supera shard 2 |
| 14 500 | 2.8582 | salto −0.026 |
| 16 000 | 2.8368 | salto −0.033 |
| 18 000 | 2.8237 | salto −0.006 |
| 20 000 | 2.8165 | minimo finale |
logs/run7/benchmark_local.log.| Versione | Val | Corpus |
|---|---|---|
| baseline (run6-ad) | 3.3524 | CulturaX sweet spot |
| shard 1 | 3.0917 | + Wikipedia s1 (~70 M tok) |
| shard 2 | 2.9083 | + Wikipedia s1+s2 (~140 M tok) |
| shard 3 | 2.8165 | + Wikipedia s1+s2+s3 (~210 M tok) |
| Prompt | Baseline | Shard 1 | Shard 2 | Shard 3 | Tendenza |
|---|---|---|---|---|---|
| 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 ↑↑↑ |
| File | Dimensione | Uso |
|---|---|---|
slm-run7-s3.gguf | 208 MB (fp32) | Test e conversione |
slm-run7-s3-q8_0.gguf | 56 MB | Inferenza normale |
run7_benchmark_minerva_server.sh. Log: logs/run7/benchmark_minerva.log.| Prompt | Output | Diagnosi |
|---|---|---|
| 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 |
[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.| Prompt | Pattern 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 |
| Puglia | Primo paragrafo plausibile, poi eco [INST] loop identico ×4 |
| Benigni | <iframe src="https://open.spotify.com/?uri=3">, HTML object tag loop |
| Ungaretti | Parzialmente leggibile (parafrasi "Soldati"), ma testo inventato e citazioni false |
[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.
| Prompt | SLM s3 | Minerva 350M | Vantaggio |
|---|---|---|---|
| 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 |
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.temperature — scala i logit prima del softmax. Già presente, default 1.0.top_k — mantiene solo i top-k logit. Già presente, default None (disabilitato).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.
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.
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.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.
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 token | Contesto disponibile | Scorato in |
|---|---|---|
| 0 – 511 | 0 – 511 (prima finestra) | Finestra 1 — tutti i token |
| 256 – 767 | 256 – 767 (seconda finestra) | Finestra 2 — solo token 512–767 (i 256 nuovi) |
| 512 – 1023 | 512 – 1023 (terza finestra) | Finestra 3 — solo token 768–1023 |
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.
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.--accumulate NCon --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.
--bf16BF16 (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.
Il loop precedente era: zero_grad → forward → backward → clip → step. Il loop aggiornato è:
Il log di avvio mostra ora i parametri di efficienza:
--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.
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.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.
Con la cache attiva, ogni passo di generate() lavora così:
softmax(Q · K_totaleᵀ / √d_k) · V_totale.Il costo per token diventa O(n) invece di O(n²): ogni passo elabora un solo token nuovo, non l'intera sequenza.
Per il modello medium (12 layer, d_model=512, 8 teste, d_k=64):
Trascurabile rispetto ai 175 MB dei pesi del modello.
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:
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:
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:
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.
Due test quantitativi da eseguire dopo l'implementazione:
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).
| Test | Risultato |
|---|---|
| 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 |
| Speedup | 4.35× |
Parametro use_cache=True è il default. Per disabilitare (debug, confronto): use_cache=False.
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.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 differenza tecnica rispetto al pre-training è nella funzione di loss. Il dataset SFT è una lista di coppie:
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.
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.
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:
| Orientamento | Use case | Formato completion |
|---|---|---|
| Intent recognition | Calendario, routing comandi | JSON con intent + entità estratte |
| Structured output | Spesa Excel, parsing dati | JSON normalizzato pronto per import |
| Tool calling | Orchestrazione tool esterni | <tool>nome</tool><params>{...}</params> |
| RAG / context-grounded | Sostituzione modelli esterni | Risposta ancorata al documento nel prompt |
| Stile / voce narrativa | Writing assistant | Testo nel registro specificato |
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.
Target: 120–150 coppie in italiano, distribuite su 6 intent:
| Intent | Esempi | Descrizione |
|---|---|---|
create_event | ~25 | Creare un nuovo appuntamento |
reschedule_event | ~25 | Spostare un appuntamento esistente |
delete_event | ~20 | Cancellare uno o più appuntamenti |
create_reminder | ~25 | Aggiungere un promemoria |
query_schedule | ~25 | Chiedere info sull'agenda |
add_contact | ~20 | Aggiungere un contatto o nota |
checkpoints/sft_intent_v1.pt — conservato come esempio documentato distinto dal base model. Non sovrascrive best.pt né i checkpoint di training.
| Epoca | Train loss | Probe PPL | ×baseline | Nota |
|---|---|---|---|---|
| baseline | — | 27.54 | 1.000 | Run #7 shard s3 |
| 1 / 5 | 2.7731 | 29.1 | 1.056 | ok |
| 2 / 5 | 0.9494 | 29.6 | 1.074 | ok |
| 3 / 5 | 0.5510 | 30.5 | 1.107 | ok |
| 4 / 5 | 0.4261 | 31.8 | 1.156 | ⚠ FORGETTING (soglia 1.15) |
| 5 / 5 | 0.3762 | 30.8 | 1.120 | recovery |
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.
Stesso prompt, temperature=0.1, top_k=10:
| Prompt | Base model | SFT 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 |
delete_event → create_reminder) è plausibile con 20 soli esempi per intent."}"}"}"}} — artifact di generazione: il modello non ha un token di stop esplicito. In produzione: parser JSON che estrae il primo oggetto valido, oppure stop token }\n.checkpoints/sft_intent_v1.pt — modello di esempio documentato, distinto dal base model. Non sovrascrive nessun checkpoint di training.
Eseguiti 4 test con python3 tools/infer.py --checkpoint checkpoints/sft_intent_v1.pt --stop-on-json.
| Prompt | Intent atteso | Intent prodotto | Esito |
|---|---|---|---|
| Crea un appuntamento con Marco venerdì alle 15. | create_event | create_event ✓ | Intent corretto, title errato ("martedì" invece di "Marco") |
| Cancella appuntamento Dentista lunedì alle 10. | delete_event | create_event ✗ | Intent sbagliato, valori hallucinate ("gioco") |
| Sposta appuntamento con il commercialista dalle 10:00 alle 15:00. | reschedule_event | create_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 |
delete_event (20 esempi) è semanticamente vicino a create_event (25). Il modello collassa verso la classe dominante. Stesso meccanismo per reschedule_event vs create_reminder.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.
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.
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.
| Componente | Count | Parametri |
|---|---|---|
| Base model (congelato) | — | 45,997,056 |
| LoRA adapter (r=8, 12 blocchi × 4 matrici) | 48 coppie A+B | 393,216 |
| Totale trainabili | — | 393,216 (0.85%) |
| Adapter su disco | — | ~1,564 KB |
Il file lora.py espone quattro funzioni:
LoRALinear (wrapper frozen W + trainabili A,B). Ritorna (model, n_trainable, n_total).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.
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.
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.
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.apply_lora, save_lora, merge_lora_model da lora.py--lora (bool), --lora-rank (default 8), --lora-alpha (default 16), --lora-out (path adapter)apply_lora() + model.to(device) (le matrici A e B vengono create su CPU, vanno spostate sul device corretto)p.requires_grad → riceve automaticamente solo A e B quando LoRA è attivosave_lora() per l'adapter + merge_lora_model() per il merged checkpoint compatibile con infer.pyapply_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().
Ogni best checkpoint produce due file:
--lora-out, ~1.5 MB): solo A e B, per il dynamic/unmerged mode. Richiede il base model a runtime.--out, ~179 MB senza optimizer state): W' = W + ΔW, checkpoint standard compatibile con infer.py e tutti gli strumenti esistenti. Non richiede lora.py a runtime.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.
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:
| Metrica | Valore | Note |
|---|---|---|
| Best val loss | 0.1322 | PPL ≈ 1.1 — convergenza completa |
| Loss finale (step 3000) | 0.0700 | Epoch 10, ultimo step |
| Durata training | ~104 s | RTX 4090 vs M2 ~5 min/epoca |
| Adapter | 1.5 MB | checkpoints/lora_calendar.pt |
| Merged | 179 MB | checkpoints/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.
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.
| File | Dimensione | Formato |
|---|---|---|
| slm-lora-calendar.gguf | 218 MB | fp32 |
| slm-lora-calendar-q8_0.gguf | 55 MB | Q8_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:
\n\n al prompt prima dell'encodingRisultati 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.
tools/infer_lora.py) tutti pronti e verificati.
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.
Dataset generato da tools/gen_sft_synth_v2.py. Risolve tre problemi della v1:
EVENTI_GEN usa tuple (parola, genere, iniziale) con 4 helper per gli articoli — nessun "il videochiamata" o "una workshop"intent: "unknown") per gestire input non supportatiIl 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 | Schema 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"} |
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.
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ì"}
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.
checkpoints/lora_calendar_v2.ptcheckpoints/lora_calendar_v2_merged.ptdata/sft_v2.jsonl (4200 esempi, 21 intent × 200)run_lora.sh aggiornato, log in logs/lora_v2/| Epoca | Train loss | Note |
|---|---|---|
| 1 | 0.8858 | Da 4.69 iniziale — calo rapido |
| 2 | 0.2812 | Convergenza rapida |
| 3 | 0.1460 | |
| 4 | 0.0902 | |
| 5 | 0.0647 | |
| 6 | 0.0507 | |
| 7 | 0.0420 | |
| 8 | 0.0362 | |
| 9 | 0.0328 | |
| 10 | 0.0293 | PPL ≈ 1.0 — training set memorizzato |
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.
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:
| Prompt | Output | Esito |
|---|---|---|
| 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 |
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.
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).
Due classi di errori sistematici emersi dal test:
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.gen_sft_synth_v2.py, un nome diverso per ogni esempio.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.
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 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.
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) | |
|---|---|---|
| Attenzione | Causale — solo token precedenti | Bidirezionale — tutti i token |
| Token speciali | <s>, </s> | [CLS], [SEP], [MASK] |
| Positional enc. | RoPE in attention | Embedding sommato all'input |
| Training obj. | CLM — predice ogni prossimo token | MLM — predice 15% token mascherati |
| Loss su | tutte le posizioni | solo 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.
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.
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.
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.
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.
Tre criteri identificano i task dove LoRA+SFT generativo funziona bene a questa scala:
| Task | Input | Output | Affidabilità |
|---|---|---|---|
| Email formale da bullet points | "Punti: riunione lunedì, agenda Q4" | Email con apertura, corpo, chiusura | Alta |
| Espansione outline → paragrafo | Titolo + 3 punti | Paragrafo sviluppato | Alta |
| Verbale da note riunione | Note disorganizzate | Struttura: decisioni, azioni, responsabili | Media-alta |
| RAG su dominio ristretto | Chunk contesto + domanda | Risposta estratta dal contesto | Media-alta |
| Writing assistant (voce personale) | Inizio paragrafo / tema | Continuazione nello stesso stile | Media |
| Tono affettivo / emotivo | "gli voglio bene" | Espansione nel registro appropriato | Variabile |
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.
| NLU intent (fatto) | Task generativo | |
|---|---|---|
| Lunghezza completion | ~30 token | 100–400 token |
| Volume esempi | 200/intent (alto) | 50–150 task (più basso) |
| Diversità input | Critica per i nomi | Critica per tono/registro |
| EOS | Fix implementato in train_sft.py | Stesso meccanismo, più importante |
| LoRA rank ottimale | 8 (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.
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.
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.
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.
| Area | Stato |
|---|---|
| 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 inference | Implementato e verificato |
| SFT con masked loss e forgetting probe | Completato |
| LoRA (lora.py, integrazione train_sft.py) | Completato |
| GGUF export + llama-server | Completato |
| Limiti architetturali (floor qualitativo, allucinazione slot, NLU vs NLG) | Compresi e documentati |
| Esperimento | Cosa insegnerebbe | Valore marginale |
|---|---|---|
| Training v3 (EOS fix, dataset ampliato) | Impatto della qualità del dataset, EOS su testo lungo | Medio — conferma principi noti |
| RAG proof-of-concept | Template learning per generazione estrattiva | Basso–medio — valida quanto già discusso |
| Writing assistant SFT (voce personale) | Mapping stilistico, rank ottimale per generazione | Medio — genuinamente inesplorato |
| GBNF in slm-chat.html | Constraint strutturale vs EOS appreso | Alto tecnico, basso concettuale (già discusso) |
| Phase 11 YOLO v3 multimodale | Pipeline vision → language, architettura multimodale | Alto — territorio genuinamente nuovo |
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:
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.
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 base | matmul Q·K^T (globale) | convoluzione 2D (locale, 3×3) |
| Normalizzazione | LayerNorm (per token) | BatchNorm (per feature map, su batch) |
| Downsampling | nessuno (sequenza fissa) | stride=2 (dimezza la risoluzione spaziale) |
| Inductive bias | nessuno (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)
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.
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 | Contenuto | Concetti nuovi |
|---|---|---|
darknet.py | ConvBN, ResBlock, Darknet-53 backbone | Conv2D, BatchNorm, stride downsampling |
yolo.py | FPN + detection heads, modello completo | Feature pyramid, anchor boxes |
loss.py | Multi-part detection loss, anchor matching | IoU, objectness, mixed losses |
dataset.py | COCO/Pascal VOC loading, augmentation | Mosaic, flip, resize with boxes |
train_yolo.py | Loop training, warm-up, cosine LR, checkpoint | Multi-scale training, gradient freeze backbone |
detect.py | Inferenza + NMS | Non-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.
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.
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.
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.
| Modello | Param | Italiano | License | Note |
|---|---|---|---|---|
| Qwen2.5 7B Instruct | 7B | ★★★★ | Apache 2.0 | Multilingual 29+ lingue, instruction following eccellente, Q4 ≈ 4.5 GB. Benchmark italiano non pubblicati separatamente ma linguaggio confermato nel report tecnico. |
| LLaMA 3.1 8B | 8B | ★★★★ | 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-ITA | 8B | ★★★★½ | 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-9B | 9B | ★★★★ | Apache 2.0 | Progetto EU, addestrato su 35 lingue europee + internazionali. 4T token. Italiano nativo come priorità. Versione 22B disponibile (EuroLLM-22B-Instruct). |
| LLaMAntino-3-ANITA-8B | 8B | ★★★★★ | 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.3 | 7B | ★★★ | Apache 2.0 | Classico workhorse, tooling LoRA maturo, Apache 2.0 pulito. Meno italiano nativo di LLaMA 3.1 ma eccellente per task strutturati. |
| Minerva 7B | 7B | ★★★ | Apache 2.0 | Italian-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.
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).