Salta al contenuto principale

Fine-tuning di un LLM per la lingua italiana: LoRA, dataset e valutazione

LoRA e QLoRA per un modello in italiano: dataset senza fughe, conti sui parametri, script di addestramento, conversione GGUF e confronto delle risposte.

Cosa ne pensi di questo articolo?

Fine-tuning di un LLM per la lingua italiana: LoRA, dataset e valutazione
Italian NLPFoto:RDNE Stock projectPexels

In Breve

LoRA e QLoRA per un modello in italiano: dataset senza fughe, conti sui parametri, script di addestramento, conversione GGUF e confronto delle risposte.

Prima di allenare qualcosa, decidi che cosa vuoi cambiare. Il fine-tuning cambia il comportamento di un modello: il registro, il formato delle risposte, il modo di seguire un'istruzione in italiano. Non è il modo giusto di fargli «sapere» i tuoi documenti: per i fatti che cambiano (listini, regolamenti, schede prodotto) serve recuperarli al momento della domanda, come in RAG in italiano: embedding multilingue e recupero semantico. Se invece hai centinaia o migliaia di esempi di come vorresti che il modello risponda, LoRA e QLoRA permettono di adattarlo allenando una frazione minuscola dei parametri. Qui costruisci il metodo passo per passo, con programmi che girano su qualunque computer e uno script di addestramento che ti serve poi per la scheda video.

Il dataset decide il risultato, e i suoi difetti sono noiosi

Il formato più comune è JSONL: un oggetto JSON per riga, con una coppia istruzione-risposta. Il lavoro vero è nei difetti ordinari di un file raccolto a mano: righe che non sono JSON valido, risposte vuote, istruzioni troppo corte per dire qualcosa, duplicati che differiscono per uno spazio, e (il più insidioso) la stessa domanda con due risposte, che finisce metà nell'addestramento e metà nella validazione.

Il programma sotto lavora su un file in memoria di venti righe con tutti questi difetti. Due scelte meritano una spiegazione. La prima è l'impronta SHA-256 al posto della funzione hash() di Python: quest'ultima per le stringhe cambia a ogni esecuzione (il seme è casuale), quindi una divisione costruita con hash() non è riproducibile. La seconda è la normalizzazione Unicode con casefold: due stringhe che a vista sono uguali possono avere la «è» scritta in due modi, e senza normalizzare il duplicato sfugge.

import hashlib, json, unicodedata

# Un "file" JSONL in memoria: 8 righe, con i difetti tipici di un dataset raccolto a mano.
GREZZO = """\
{"instruction": "Spiega che cos'è una caparra confirmatoria", "output": "È una somma versata alla stipula del preliminare che..."}
{"instruction": "Spiega che cos'è una caparra confirmatoria ", "output": "È una somma versata alla stipula del preliminare che..."}
{"instruction": "Cos'è l'IVA?", "output": "L'imposta sul valore aggiunto è..."}
{"instruction": "Riassumi il contratto in tre punti", "output": ""}
{"instruction": "Elenca i tre gradi di giudizio", "output": "Primo grado, appello, Cassazione."}
{"instruction": "Elenca i tre gradi di giudizio", "output": "Primo grado, appello e Cassazione."}
{"instruction": "Cos'è la prescrizione?" "output": "riga con una virgola mancante"}
{"instruction": "Che cosa significa recesso?", "output": "È la facoltà di sciogliersi da un contratto senza..."}
"""

# Altre sei domande, ciascuna con due risposte diverse: nel dataset reale succede spesso
# (la stessa domanda raccolta da due persone). Le risposte sono segnaposto.
DOMANDE = ["Come si calcola la scadenza di un termine", "Quando decade un diritto di garanzia",
           "Che cos'è un decreto ingiuntivo", "Chi risponde dei vizi della cosa venduta",
           "Come si impugna una sanzione amministrativa", "Che differenza c'è fra dolo e colpa"]
for d in DOMANDE:
    for variante in ("prima risposta", "seconda risposta"):
        GREZZO += json.dumps({"instruction": d, "output": f"{variante} a: {d.lower()}"}, ensure_ascii=False) + "\n"

def normalizza(s):
    # NFC: "è" scritto come e + accento combinato diventa lo stesso carattere; spazi e maiuscole non contano
    return " ".join(unicodedata.normalize("NFC", s).split()).casefold()

def impronta(record):
    chiave = normalizza(record["instruction"]) + "\x1f" + normalizza(record["output"])
    return hashlib.sha256(chiave.encode("utf-8")).hexdigest()

def pulisci(righe, parole_minime=3):
    visti, tenuti, scartati = set(), [], {"json": 0, "vuoto": 0, "corto": 0, "duplicato": 0}
    for riga in righe:
        try:
            r = json.loads(riga)
            istruzione, output = r["instruction"].strip(), r["output"].strip()
        except (json.JSONDecodeError, KeyError):
            scartati["json"] += 1; continue
        if not output:
            scartati["vuoto"] += 1; continue
        if len(istruzione.split()) < parole_minime:
            scartati["corto"] += 1; continue
        h = impronta(r)
        if h in visti:
            scartati["duplicato"] += 1; continue
        visti.add(h); tenuti.append((h, r))
    return tenuti, scartati

def quota(testo):
    h = hashlib.sha256(testo.encode("utf-8")).hexdigest()
    return int(h[:8], 16) / 0xFFFFFFFF          # numero in [0, 1], sempre lo stesso per lo stesso testo

def dividi(tenuti, chiave, quota_validazione=0.5):
    """Assegnazione deterministica: dipende dalla chiave scelta, non dall'ordine ne' dal caso."""
    train, valid = [], []
    for h, r in tenuti:
        (valid if quota(chiave(h, r)) < quota_validazione else train).append(r)
    return train, valid

def istruzioni_in_comune(train, valid):
    return {normalizza(r["instruction"]) for r in train} & {normalizza(r["instruction"]) for r in valid}

tenuti, scartati = pulisci(GREZZO.splitlines())
print("righe in ingresso:", len(GREZZO.splitlines()))
print("scartate:", scartati)
print("tenute:", len(tenuti))
for nome, chiave in [("per record", lambda h, r: h), ("per istruzione", lambda h, r: normalizza(r["instruction"]))]:
    train, valid = dividi(tenuti, chiave)
    print(f"divisione {nome:15} train={len(train)} validazione={len(valid)} istruzioni in comune: {sorted(istruzioni_in_comune(train, valid))}")

# Output:
# righe in ingresso: 20
# scartate: {'json': 1, 'vuoto': 1, 'corto': 1, 'duplicato': 1}
# tenute: 16
# divisione per record      train=7 validazione=9 istruzioni in comune: ["che cos'è un decreto ingiuntivo", "che differenza c'è fra dolo e colpa", 'chi risponde dei vizi della cosa venduta', 'come si calcola la scadenza di un termine', 'come si impugna una sanzione amministrativa', 'quando decade un diritto di garanzia']
# divisione per istruzione  train=14 validazione=2 istruzioni in comune: []

L'uscita mostra i quattro scarti, uno per tipo, e soprattutto la differenza fra le due divisioni. Dividendo per record, tutte e sei le domande a doppia risposta compaiono sia nell'addestramento sia nella validazione: la perdita di validazione risulta ottimista perché il modello ha già visto la domanda. Dividendo per istruzione, le due liste non hanno domande in comune e la validazione misura davvero la generalizzazione. Con un dataset di migliaia di righe lo stesso errore è invisibile a occhio, e per questo conviene assegnare ogni gruppo di domande uguali a un solo lato. Il filtro di lunghezza (tre parole) è una soglia arbitraria da tarare sui tuoi dati.

LoRA: allenare due matrici sottili invece di una matrice enorme

L'idea del paper originale è questa: congelare i pesi del modello e, per ogni matrice scelta, aggiungere una correzione costruita come prodotto di due matrici sottili, A e B, con un «rango» r piccolo. Invece di aggiornare una matrice di 4096 per 4096 numeri, alleni solo 4096 per r più r per 4096. La correzione viene moltiplicata per un fattore di scala, lora_alpha / r nell'implementazione standard della libreria PEFT, e B parte da zero in modo che all'inizio il modello adattato sia esattamente il modello di partenza.

Il programma seguente fa tre cose con NumPy. Calcola quanti parametri alleni su Mistral-7B-v0.1 usando i valori del suo config.json (4096 dimensioni, 32 strati, 8 teste per chiavi e valori); verifica che con B uguale a zero l'uscita non cambia; e allena a mano una correzione di rango 4 per vedere che cosa succede al variare di r.

import numpy as np

# --- 1. Quanti parametri alleni: conti sulla configurazione di Mistral-7B-v0.1 ---------------
HIDDEN, KV_HEADS, HEADS, LAYERS = 4096, 8, 32, 32          # config.json del modello
KV_DIM = HIDDEN // HEADS * KV_HEADS                        # 128 x 8 = 1024: v_proj ha 1024 uscite
MODULI = {"q_proj": (HIDDEN, HIDDEN), "v_proj": (HIDDEN, KV_DIM)}   # (ingresso, uscita)

def parametri_lora(r, moduli=MODULI, strati=LAYERS):
    # per ogni matrice W (ingresso x uscita) LoRA alleva A (ingresso x r) e B (r x uscita)
    return strati * sum(r * (i + o) for i, o in moduli.values())

for r in (8, 16, 64):
    n = parametri_lora(r)
    print(f"r={r:<3} parametri allenabili = {n:>11,}  = {n / 7_241_732_096:.3%} dei 7,24 miliardi del modello")

# --- 2. Perche' B parte da zero ---------------------------------------------------------------
rng = np.random.default_rng(0)
d_in, d_out, r, alpha = 64, 48, 4, 8
W = rng.normal(size=(d_in, d_out))
A = rng.normal(scale=0.1, size=(d_in, r))
B = np.zeros((r, d_out))
x = rng.normal(size=(5, d_in))
y0, y1 = x @ W, x @ W + (alpha / r) * (x @ A) @ B
print("all'inizio il modello adattato e' identico al base:", np.allclose(y0, y1))

# --- 3. Un piccolo addestramento: quanto basta un rango basso ---------------------------------
# Il "cambiamento da imparare" ha rango vero 4 piu' un po' di rumore.
delta = rng.normal(size=(d_in, 4)) @ rng.normal(size=(4, d_out)) + 0.05 * rng.normal(size=(d_in, d_out))
X = rng.normal(size=(512, d_in))
Y = X @ delta                                           # obiettivo: riprodurre l'effetto di delta

def allena(rango, passi=3000, lr=0.003):
    A = rng.normal(scale=0.1, size=(d_in, rango)); B = np.zeros((rango, d_out))
    s = 1.0                         # alpha = rango: tengo fissa la scala, cosi' il confronto riguarda solo r
    for _ in range(passi):
        errore = X @ A @ B * s - Y
        gB = s * (X @ A).T @ errore / len(X)
        gA = s * X.T @ (errore @ B.T) / len(X)
        A -= lr * gA; B -= lr * gB
    residuo = np.linalg.norm(A @ B * s - delta) / np.linalg.norm(delta)
    return residuo, A.size + B.size

print(f"matrice piena: {delta.size} parametri")
for rango in (1, 2, 4, 16):
    res, n = allena(rango)
    print(f"r={rango:<3} parametri={n:>5}  errore relativo su delta = {res:.3f}")

# Output:
# r=8   parametri allenabili =   3,407,872  = 0.047% dei 7,24 miliardi del modello
# r=16  parametri allenabili =   6,815,744  = 0.094% dei 7,24 miliardi del modello
# r=64  parametri allenabili =  27,262,976  = 0.376% dei 7,24 miliardi del modello
# all'inizio il modello adattato e' identico al base: True
# matrice piena: 3072 parametri
# r=1   parametri=  112  errore relativo su delta = 0.795
# r=2   parametri=  224  errore relativo su delta = 0.604
# r=4   parametri=  448  errore relativo su delta = 0.025
# r=16  parametri= 1792  errore relativo su delta = 0.017

I primi tre numeri correggono un'idea diffusa: con q_proj e v_proj e r=16 alleni 6.815.744 parametri, cioè lo 0,094% del modello e non il «1-2%». Per r=64 sono 27 milioni, circa 0,38%. Il dizionario MODULI del programma mostra anche perché v_proj costa meno di q_proj: il modello usa 8 teste per chiavi e valori contro 32 per le query, quindi la matrice ha 1024 uscite e non 4096. Poi l'esperimento sul piccolo problema: l'errore sulla correzione vera resta alto per r=1 e r=2 (0,795 e 0,604) e crolla a 0,025 quando r arriva a 4, che è il rango con cui ho costruito il bersaglio. Salire a 16 migliora poco (0,017). È un modello giocattolo e non dimostra niente sui linguaggi, ma spiega la regola pratica: se r è sotto il rango necessario il compito non si impara, se è sopra paghi memoria senza guadagno.

QLoRA: il modello base a 4 bit, gli adattatori in alta precisione

QLoRA aggiunge un passo: il modello base congelato viene tenuto in 4 bit, e i gradienti attraversano quei pesi quantizzati per aggiornare gli adattatori LoRA, che restano in precisione maggiore. Il paper (Dettmers e altri) dichiara tre ingredienti: il tipo di dato 4-bit NormalFloat (NF4), la doppia quantizzazione (che quantizza anche le costanti di quantizzazione) e gli ottimizzatori «paginati» per assorbire i picchi di memoria. Il risultato dichiarato dagli autori è il fine-tuning di un modello da 65 miliardi di parametri su una sola GPU da 48 GB, senza perdere qualità rispetto al 16 bit.

Per un modello da 7 miliardi i soli pesi in 4 bit occupano circa 3,6 GB (7,24 miliardi di parametri per mezzo byte): il resto della memoria se ne va in attivazioni, stati dell'ottimizzatore e cache, e dipende da lunghezza delle sequenze e dimensione del lotto. Non ti do un numero per una scheda precisa perché non l'ho misurato: provalo sulla tua con un lotto minimo e aumenta.

Lo script di addestramento, riga per riga

Lo script seguente è uno schema da adattare, non eseguito: su questa macchina non ci sono PyTorch né una GPU. Corregge tre errori comuni nei tutorial. Il primo: tokenizer.pad_token = tokenizer.eos_token sembra innocuo, ma il collatore di dati di Transformers imposta a -100 (ignorato dalla perdita) ogni etichetta uguale all'identificativo del token di riempimento, quindi il modello non vede mai la fine della risposta e impara a non fermarsi; qui uso il token unk, che Mistral possiede. Il secondo: la tokenizzazione non riempie fino alla lunghezza massima, lo fa il collatore per lotto. Il terzo: la validazione è un file a parte, quello prodotto dalla divisione per istruzione.

import torch
from datasets import load_dataset
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import (AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig,
                          DataCollatorForLanguageModeling, Trainer, TrainingArguments)

BASE = "mistralai/Mistral-7B-v0.1"
dati = load_dataset("json", data_files={"train": "train.jsonl", "validation": "valid.jsonl"})

tok = AutoTokenizer.from_pretrained(BASE)
tok.pad_token = tok.unk_token                     # NON eos: il collatore azzera le etichette del riempimento

def prepara(esempio):
    testo = f"### Istruzione:\n{esempio['instruction']}\n\n### Risposta:\n{esempio['output']}{tok.eos_token}"
    return tok(testo, truncation=True, max_length=1024)

dati = dati.map(prepara, remove_columns=["instruction", "output"])

bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
                         bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16)
modello = AutoModelForCausalLM.from_pretrained(BASE, quantization_config=bnb, device_map="auto")
modello = prepare_model_for_kbit_training(modello)
modello = get_peft_model(modello, LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05,
                         target_modules=["q_proj", "v_proj"], bias="none", task_type="CAUSAL_LM"))
modello.print_trainable_parameters()              # atteso: 6.815.744 con r=16 su q_proj e v_proj

args = TrainingArguments(output_dir="lora-it", num_train_epochs=3, learning_rate=2e-4,
                         per_device_train_batch_size=1, gradient_accumulation_steps=8,
                         gradient_checkpointing=True, optim="paged_adamw_8bit", bf16=True,
                         eval_strategy="epoch", save_strategy="epoch", logging_steps=10,
                         warmup_steps=20, report_to="none")
Trainer(model=modello, args=args, train_dataset=dati["train"], eval_dataset=dati["validation"],
        data_collator=DataCollatorForLanguageModeling(tok, mlm=False)).train()
modello.save_pretrained("lora-it-finale")         # salva solo gli adattatori, non il modello base

Le righe che contano: la stringa del prompt termina con tok.eos_token, così la fine della risposta entra nelle etichette; prepare_model_for_kbit_training prepara il modello quantizzato a ricevere gradienti; print_trainable_parameters è il controllo di coerenza con il calcolo del programma precedente (la riga di commento dà il numero che devi vedere, dopo aver controllato l'architettura del tuo modello). L'accumulo dei gradienti simula un lotto di 8 con una sola riga alla volta in memoria. Non ho tenuto max_steps, perché con num_train_epochs insieme si contraddicono: se imposti i passi, il numero di epoche viene ignorato. bf16=True richiede una GPU che supporti bfloat16 (per le NVIDIA, dalla generazione Ampere); sulle altre usa fp16=True.

Gli iperparametri che ti toccherà cambiare

Ogni voce dell'elenco indica il parametro, il valore dello script e che cosa succede se lo cambi. I valori di partenza sono scelte di partenza, non risultati: i defaults della libreria PEFT, letti nel suo codice sorgente il 2026-11-12, sono r=8, lora_alpha=8 e lora_dropout=0,0, quindi lo script sopra li sostituisce deliberatamente.

  • r=16: capacità dell'adattatore. Troppo basso e il compito non si impara (vedi l'esperimento sul rango); troppo alto spreca memoria e aumenta il rischio di adattarsi al rumore.
  • lora_alpha=32: con la scala standard lora_alpha / r vale 2. Se raddoppi r senza toccare alpha, la scala dimezza e la correzione pesa meno.
  • target_modules=["q_proj", "v_proj"]: le matrici a cui attacchi LoRA. Aggiungerne altre (le proiezioni delle chiavi, dell'uscita e del blocco feed-forward) aumenta i parametri e di solito la capacità.
  • learning_rate=2e-4: valore tipico per gli adattatori, molto più alto di quello del full fine-tuning. Se la perdita di validazione sale mentre quella di addestramento scende, è troppo alto o le epoche sono troppe.
  • gradient_accumulation_steps=8: lotto effettivo = lotto per dispositivo × accumulo. Cambialo se esaurisci la memoria.

L'elenco non ha un ordine di importanza, ma c'è una sequenza utile quando qualcosa va storto: prima si controlla il dataset, poi il learning rate, poi il rango. I sintomi più comuni (output ripetitivi, risposte che non finiscono, perdita che non scende) hanno più spesso origine nei dati o nel token di fine che nel rango, ed è per questo che si controllano per primi.

Dopo l'addestramento: unire gli adattatori e convertire in GGUF

Gli adattatori salvati pesano poco ma non sono un modello da soli: per usarli con un motore di inferenza locale basato su llama.cpp li unisci al modello base in precisione piena e converti il risultato nel formato GGUF. I comandi sotto sono schemi da adattare, non eseguiti qui. Il primo blocco unisce gli adattatori, il secondo converte e quantizza; il nome dello script e quello dell'eseguibile sono quelli attuali del repository di llama.cpp, letti il 2026-11-12 (le istruzioni più vecchie parlano di convert-hf-to-gguf.py e di ./quantize, che non sono più i nomi giusti).

import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

base = AutoModelForCausalLM.from_pretrained("mistralai/Mistral-7B-v0.1", torch_dtype=torch.bfloat16)
unito = PeftModel.from_pretrained(base, "lora-it-finale").merge_and_unload()
unito.save_pretrained("mistral-it-unito", safe_serialization=True)
AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1").save_pretrained("mistral-it-unito")
python convert_hf_to_gguf.py mistral-it-unito --outfile mistral-it-f16.gguf --outtype f16
./build/bin/llama-quantize mistral-it-f16.gguf mistral-it-Q4_K_M.gguf Q4_K_M

Due cose da sapere. L'unione conviene farla sul modello in precisione piena (qui bfloat16) e non su quello a 4 bit, altrimenti sommi la correzione a pesi già arrotondati. E la quantizzazione dopo l'unione toglie precisione una seconda volta: confronta le risposte del GGUF con quelle del modello unito sugli stessi esempi di prova, non solo con la perdita di validazione.

Valutare senza ingannarsi

La perdita di validazione ti dice se il modello impara; non dice se le risposte ti piacciono. Il programma sotto mostra due calcoli che si sbagliano facilmente. Il primo è la perplexity: è l'esponenziale della perdita media per token, quindi si pesa sul numero di token, non sui documenti. Con tre documenti di 12, 15 e 400 token la media sui token dà 5,34, mentre la media delle singole perplexity darebbe 12,67 perché i due documenti brevi, che sono più difficili, pesano quanto quello lungo. Inoltre due perplexity si confrontano solo se vengono dallo stesso tokenizzatore.

import math

# --- 1. Perplexity: la media si fa sui TOKEN, non sui documenti -------------------------------
# Per ogni documento: (numero di token, perdita media per token in nat). Numeri d'esempio, inventati.
DOCUMENTI = [(12, 2.9), (15, 2.7), (400, 1.6)]

perdita_totale = sum(n * loss for n, loss in DOCUMENTI)
token_totali = sum(n for n, _ in DOCUMENTI)
corretta = math.exp(perdita_totale / token_totali)
errata = sum(math.exp(loss) for _, loss in DOCUMENTI) / len(DOCUMENTI)
print(f"perplexity sui token   : {corretta:.2f}")
print(f"media delle perplexity : {errata:.2f}   (sbagliata: il documento da 12 token pesa quanto quello da 400)")

# --- 2. Confronto a coppie fra base e adattato: quanto vale 30 contro 12? ---------------------
# 50 casi giudicati da una persona: 30 migliori col modello adattato, 12 peggiori, 8 pari. Esempio inventato.
vittorie, sconfitte, pari = 30, 12, 8

def p_bilaterale(k, n):
    """Test dei segni esatto: probabilita' di uno scarto almeno cosi' grande se i due modelli fossero uguali."""
    estremo = max(k, n - k)
    coda = sum(math.comb(n, i) for i in range(estremo, n + 1)) / 2 ** n
    return min(1.0, 2 * coda)

n = vittorie + sconfitte                                  # i pari non contano
print(f"{vittorie} vs {sconfitte} su {n} casi non pari: p = {p_bilaterale(vittorie, n):.4f}")
print(f"18 vs 12 su 30 casi non pari: p = {p_bilaterale(18, 30):.4f}")

# Output:
# perplexity sui token   : 5.34
# media delle perplexity : 12.67   (sbagliata: il documento da 12 token pesa quanto quello da 400)
# 30 vs 12 su 42 casi non pari: p = 0.0079
# 18 vs 12 su 30 casi non pari: p = 0.3616

Il secondo calcolo è per il giudizio a coppie: una persona guarda cinquanta risposte di base e adattato e dice quale preferisce. I numeri sono un esempio inventato per mostrare il metodo. Con 30 preferenze per l'adattato contro 12 per il base (otto pari, che non contano) il test dei segni esatto dà p = 0,0079: la differenza è improbabile se i due modelli fossero equivalenti. Con 18 contro 12 su trenta casi p sale a 0,36, e non puoi concludere niente. Prima di dire «il modello adattato è meglio» servono più casi o un vantaggio più netto. Il test è lo stesso che userai con le tue risposte vere; sostituisci le tre variabili in cima.

Cose che vanno storte

  • Memoria esaurita: riduci la lunghezza massima, abbassa il lotto e alza l'accumulo, tieni il controllo dei gradienti attivo.
  • Risposte che non si fermano: controlla che il token di fine sia nel testo di addestramento e che il riempimento non coincida con esso.
  • Output ripetitivo: dati con molte risposte quasi uguali, o troppe epoche su pochi esempi.
  • Perdita di validazione che sale: sovra-adattamento; riduci epoche o learning rate, oppure aggiungi dati vari.

Le quattro voci non sono equivalenti: la seconda e la terza sono difetti dei dati, le altre due dei parametri. Se cambi un solo parametro per volta e confronti sempre sugli stessi esempi di prova, capisci quale delle cause hai davanti; se ne cambi tre insieme, no.

Domande frequenti

Il fine-tuning serve a insegnare al modello i miei documenti?

Non è lo strumento giusto: un modello adattato su una raccolta di testi imita il loro stile e può ripetere alcuni fatti, ma li ricorda in modo inaffidabile e non si aggiorna quando i documenti cambiano. Per i fatti usa il recupero dei documenti al momento della domanda; per lo stile e il formato delle risposte, il fine-tuning.

Quanti esempi servono?

Non esiste una soglia valida per tutti. Parti da poche centinaia di esempi curati, misura con il confronto a coppie del programma sopra e aggiungi dati dove il modello sbaglia. Pochi esempi coerenti e curati servono più di molti raccolti senza criterio.

Posso tenere più adattatori per compiti diversi?

Sì: gli adattatori sono file separati e leggeri, quindi puoi addestrarne uno per compito e caricare quello che serve sul modello base. Per il motore GGUF devi invece unire e convertire ciascuno, e ottieni un modello completo per compito.

Qual è il modello di partenza giusto per l'italiano?

Dipende da licenza, dimensione e contesto: la scelta è discussa, con le schede ufficiali, in Landscape degli LLM italiani nel 2025: modelli, ecosistema e scelte pratiche. Prima di allenare controlla anche che la licenza consenta di redistribuire un derivato.

Che cosa sai fare adesso

Sai preparare un dataset JSONL senza i difetti più comuni e dividerlo senza far trapelare le domande, calcolare quanti parametri alleni per un dato rango, scrivere uno script QLoRA con il token di fine al posto giusto, unire gli adattatori e convertire in GGUF con i comandi attuali, e confrontare due modelli con un test che dice quando la differenza non è abbastanza. La prima cosa da fare è raccogliere cinquanta esempi di risposte che vorresti ottenere: servono sia per allenare sia per giudicare. Se ti serve la base sotto il modello, tokenizzazione e lemmi, vai a NLP italiano con spaCy e transformers: pipeline pratica.

Fonti

I tre programmi (dataset, matematica di LoRA, valutazione) sono stati eseguiti con Python 3.14 e NumPy 2.3, due volte, con uscite identiche. I dati d'esempio sono inventati e dichiarati tali. Lo script di addestramento, l'unione degli adattatori e i comandi di conversione non sono stati eseguiti: mancano PyTorch e una GPU, e nessun modello è stato scaricato.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti