Fine-tuning di un LLM con LoRA: la guida 101
Addestra un adattatore LoRA sul modello SmolLM2-135M-Instruct con Transformers, PEFT e TRL: setup, dataset, training e verifica, dall'inizio alla fine.
Testato il: 2026-09-12 Ambiente: Ubuntu 24.04.4 LTS · Python 3.12.3 · torch 2.14.0+cpu · transformers 5.17.0 · peft 0.20.0 · trl 1.13.0 · accelerate 1.14.0 · datasets 5.0.1
Prerequisiti
- Python 3.12 con
venv(su Ubuntu:apt install python3.12-venv) - Almeno 8 GB di RAM: questa guida è stata testata su CPU, senza GPU. Il modello scelto è piccolo (135M di parametri) e ci sta comodamente
- GPU NVIDIA con 8 GB di VRAM (opzionale): accelera il training di un ordine di grandezza. I comandi sono identici, cambia solo l'indice dei wheel di PyTorch (vedi passo 1)
- Accesso a internet per scaricare modello e dataset dall'Hugging Face Hub
- Circa 5 GB di spazio disco libero
Il fine-tuning riprende un modello già preaddestrato e lo adatta a un dataset più piccolo e mirato. Con LoRA (Low-Rank Adaptation) non aggiorni i pesi originali: congeli il modello e addestri due matrici piccole per ogni layer. Il risultato è un adattatore di pochi MB, che si carica e si scarica senza toccare il modello base. È il modo standard per adattare un LLM open-weights con una GPU consumer.
Questa guida usa un modello piccolissimo apposta: se qualcosa non funziona, il tempo perso per iterare è minimo. La logica è identica per modelli da 7B o 70B, cambiano solo le risorse.
1. Creare l'ambiente e installare le dipendenze
Parti da una directory di lavoro pulita. Il virtual environment isola le versioni pinnate dal resto del sistema: senza venv, pip rischia di aggiornare pacchetti usati da altri progetti.
mkdir finetune-lab && cd finetune-lab
python3.12 -m venv .venv
source .venv/bin/activateOra installa PyTorch. Su una macchina senza GPU (o con driver NVIDIA datati), usa l'indice CPU:
pip install torch==2.14.0 --index-url https://download.pytorch.org/whl/cpuSe hai una GPU NVIDIA con driver recente (525+), usa i wheel CUDA 12.6: stessa versione di torch, stesso comando, indice diverso.
pip install torch==2.14.0 --index-url https://download.pytorch.org/whl/cu126Poi il resto dello stack, pinnato versione per versione. Le versioni non sono scelte a caso: trl 1.13.0 richiede transformers>=4.56.2 e datasets>=4.7.0, e l'integrazione PEFT di Transformers v5 richiede peft>=0.19.1.
pip install transformers==5.17.0 peft==0.20.0 trl==1.13.0 accelerate==1.14.0 datasets==5.0.1Risultato atteso: l'installazione termina senza errori e la verifica riporta le versioni esatte:
torch 2.14.0+cpu
transformers 5.17.0
peft 0.20.0
trl 1.13.0
accelerate 1.14.0
datasets 5.0.1
cuda avail FalseIl flag +cpu in torch.__version__ conferma che è stato installato il build CPU. Su macchina con GPU il build è +cu126 e cuda avail è True.
2. Verificare che l'ambiente sia pronto
Prima di scrivere codice, un controllo rapido che il modello si carichi e il tokenizer risponda. Questo passaggio scarica i pesi del modello (circa 260 MB) e li mette in cache: i passi successivi non dovranno più scaricarli.
python -c "
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained('HuggingFaceTB/SmolLM2-135M-Instruct')
tok = AutoTokenizer.from_pretrained('HuggingFaceTB/SmolLM2-135M-Instruct')
print('modello caricato:', model.num_parameters() / 1e6, 'M parametri')
print(tok.apply_chat_template([{'role': 'user', 'content': 'ciao'}], tokenize=False))
"Risultato atteso: l'output mostra il numero di parametri e il template di chat renderizzato con i token <|im_start|> e <|im_end|> (template ChatML, quello di SmolLM2):
modello caricato: 134.515008 M parametri
<|im_start|>system
You are a helpful AI assistant named SmolLM, trained by Hugging Face<|im_end|>
<|im_start|>user
ciao<|im_end|>3. Preparare il dataset
Il dataset è tatsu-lab/alpaca: 52.002 istruzioni in inglese con risposta, uno dei dataset di fine-tuning più usati (licenza cc-by-nc-4.0, ok per uso didattico e personale, non per prodotti commerciali). I campi che contano sono tre: instruction, input (vuoto se non serve), output (la riga contiene anche un campo text derivato, che non usiamo).
Per il training servono i messaggi nel formato chat, non testo piatto: lo script converte ogni riga in una lista di messaggi user/assistant e salva il risultato su disco. Prendiamo un sottoinsieme da 200 campioni: per capire il meccanismo non serve altro, e il training resta rapido.
Crea il file prepare_dataset.py:
# prepare_dataset.py
import argparse
from datasets import load_dataset
from transformers import AutoTokenizer
parser = argparse.ArgumentParser()
parser.add_argument("--num-samples", type=int, default=200)
args = parser.parse_args()
MODEL_ID = "HuggingFaceTB/SmolLM2-135M-Instruct"
OUTPUT_DIR = "data/alpaca-messages"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
def to_messages(row):
content = row["instruction"]
if row["input"]:
content += "\n\n" + row["input"]
return {
"messages": [
{"role": "user", "content": content},
{"role": "assistant", "content": row["output"]},
]
}
ds = load_dataset("tatsu-lab/alpaca", split=f"train[:{args.num_samples}]")
ds = ds.map(to_messages, remove_columns=ds.column_names)
ds.save_to_disk(OUTPUT_DIR)
print(f"dataset salvato in {OUTPUT_DIR}: {len(ds)} campioni")Eseguilo:
python prepare_dataset.py --num-samples 200Risultato atteso: dataset salvato in data/alpaca-messages: 200 campioni. Nella directory data/alpaca-messages/ trovi i file Arrow con i campioni convertiti.
4. Configurare LoRA e il training
Ora lo script di training. Tre pezzi: la configurazione LoRA (quali layer adattare e con quale rango), la configurazione SFT (iperparametri del training supervisionato) e il SFTTrainer di TRL che li combina.
Il modello SmolLM2 ha architettura Llama, quindi i target_modules sono le proiezioni dell'attenzione (q_proj, k_proj, v_proj, o_proj) e quelle del MLP (gate_proj, up_proj, down_proj). Con r=8 ogni matrice di adattamento ha rango 8: sono i parametri che si addestrano, tutto il resto resta congelato.
Crea train_lora.py:
# train_lora.py
import argparse
import torch
from datasets import load_from_disk
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
parser = argparse.ArgumentParser()
parser.add_argument("--max-steps", type=int, default=10)
args = parser.parse_args()
MODEL_ID = "HuggingFaceTB/SmolLM2-135M-Instruct"
OUTPUT_DIR = "outputs/smollm2-lora"
dataset = load_from_disk("data/alpaca-messages")
print(f"dataset: {len(dataset)} campioni")
lora_config = LoraConfig(
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
sft_config = SFTConfig(
output_dir=OUTPUT_DIR,
per_device_train_batch_size=2,
max_steps=args.max_steps,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_steps=2,
max_length=256,
logging_steps=2,
gradient_checkpointing=False,
save_strategy="steps",
save_steps=args.max_steps,
save_total_limit=1,
report_to="none",
use_cpu=not torch.cuda.is_available(),
)
trainer = SFTTrainer(
model=MODEL_ID,
args=sft_config,
peft_config=lora_config,
train_dataset=dataset,
)
trainer.train()
trainer.save_model(OUTPUT_DIR)
print(f"adapter salvato in {OUTPUT_DIR}")Due dettagli che ti risparmiano un'ora di debug:
use_cpu=not torch.cuda.is_available(): Transformers v5 rifiuta di avviare il training se il dispositivo non è esplicito. Così lo stesso script gira su CPU e su GPU senza modifiche.save_steps=args.max_steps: salva il checkpoint solo alla fine. Su un run da 10 step non ha senso salvare ogni 500 step.
5. Eseguire il training
python train_lora.py --max-steps 10Il training scarica il modello (già in cache dal passo 2), tokenizza i 200 campioni e parte. Risultato atteso: un log con la loss registrata a ogni logging step, per esempio:
{'loss': '2.969', 'grad_norm': '0.7492', 'learning_rate': '0.0001', 'entropy': '2.074', 'num_tokens': '426', 'mean_token_accuracy': '0.5251', 'epoch': '0.02'}
{'loss': '2.448', 'grad_norm': '0.6528', 'learning_rate': '0.0001924', 'entropy': '1.661', 'num_tokens': '929', 'mean_token_accuracy': '0.5522', 'epoch': '0.04'}
{'loss': '3.271', 'grad_norm': '1.098', 'learning_rate': '0.0001383', 'entropy': '2.306', 'num_tokens': '1309', 'mean_token_accuracy': '0.4224', 'epoch': '0.06'}
{'loss': '2.42', 'grad_norm': '0.9035', 'learning_rate': '6.173e-05', 'entropy': '1.709', 'num_tokens': '1706', 'mean_token_accuracy': '0.5536', 'epoch': '0.08'}
{'loss': '2.697', 'grad_norm': '0.8081', 'learning_rate': '7.612e-06', 'entropy': '2.025', 'num_tokens': '2146', 'mean_token_accuracy': '0.476', 'epoch': '0.1'}La curva non è una discesa pulita: la loss oscilla (2.97 → 2.45 → 3.27 → 2.42 → 2.70) perché il batch è piccolo e gli step sono pochi. Quello che conta è la tendenza: la loss media finale (2.76) è sotto quella iniziale, e mean_token_accuracy fluttua tra 0.42 e 0.55. In un training serio, con migliaia di campioni e più epoche, la curva è molto più stabile: l'andamento rumoroso con questa configurazione è normale, non un errore. Alla fine compare la riga di riepilogo:
{'train_runtime': '734.8', 'train_samples_per_second': '0.027', 'train_steps_per_second': '0.014', 'train_loss': '2.761', 'epoch': '0.1'}
adapter salvato in outputs/smollm2-loraI tempi reali dipendono dalla macchina: su questa CPU (4 core) il run completo di 10 step è durato 734,8 secondi, circa 73 secondi a step. Su una GPU NVIDIA consumer la stessa configurazione è pensata per girare in pochi minuti totali.
In outputs/smollm2-lora/ trovi l'adattatore: adapter_config.json (la configurazione LoRA), adapter_model.safetensors (i pesi addestrati: 9,4 MB in fp32), chat_template.jinja e il tokenizer, più la sottocartella checkpoint-10/ con il checkpoint finale.
6. Verificare l'adattatore: confronto prima e dopo
Il test finale: stessa domanda, stesso seed di generazione, modello base contro modello con adattatore. Crea test_adapter.py:
# test_adapter.py
import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_ID = "HuggingFaceTB/SmolLM2-135M-Instruct"
ADAPTER_DIR = "outputs/smollm2-lora"
PROMPT = "Give three tips for staying healthy."
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
def generate(model, prompt, max_new_tokens=96):
messages = [{"role": "user", "content": prompt}]
inputs = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True, return_dict=True
)
input_ids = torch.tensor(inputs["input_ids"]).unsqueeze(0)
attention_mask = torch.tensor(inputs["attention_mask"]).unsqueeze(0)
out = model.generate(
input_ids,
attention_mask=attention_mask,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.7,
)
return tokenizer.decode(out[0][input_ids.shape[1]:], skip_special_tokens=True)
print("=== BASE MODEL (senza adattatore) ===")
base = AutoModelForCausalLM.from_pretrained(MODEL_ID)
print(generate(base, PROMPT))
del base
print("=== CON ADATTATORE LoRA ===")
model = AutoModelForCausalLM.from_pretrained(MODEL_ID)
model = PeftModel.from_pretrained(model, ADAPTER_DIR)
print(generate(model, PROMPT))Eseguilo:
python test_adapter.pyRisultato atteso: due risposte diverse alla stessa domanda, con lo stesso seed di generazione (il seed è fissato a 42 dentro generate, così la differenza è tutta dell'adattatore e non del caso). Su questo run il modello base risponde:
=== BASE MODEL (senza adattatore) ===
1. Stay hydrated: Drinking plenty of water throughout the day helps maintain your bodily functions and can prevent dehydration.
2. Eat a balanced diet: Eating a varied diet rich in fruits, vegetables, lean proteins, and whole grains can provide the necessary nutrients to support your immune system.
3. Stay active: Regular exercise, such as walking, swimming, or dancing, can help boost your mood, reduce stress, and improve overall fitness.Il modello con l'adattatore risponde:
=== CON ADATTATORE LoRA ===
1. Stay hydrated: Drinking plenty of water daily is essential for your health.
2. Maintain a balanced diet: Eating a variety of foods provides a wide range of nutrients. Try to include a variety of fruits, vegetables, whole grains, and lean proteins.
3. Get enough sleep: Lack of sleep can lead to fatigue, headaches, and other health problems, including problems with your heart, blood vessels, and muscles. Try to get at least 7-8La differenza è nel registro: l'adattatore è più secco e imperativo ("Maintain a balanced diet", "Get enough sleep"), lo stile tipico delle risposte Alpaca, mentre il base resta descrittivo ("can help boost your mood"). Con 10 step su 200 campioni non aspettarti un cambiamento drammatico: è il meccanismo che conta, non la qualità del risultato.
Verifica
Tutto è andato a buon fine se:
python train_lora.py --max-steps 10termina conadapter salvato in outputs/smollm2-loraoutputs/smollm2-lora/adapter_model.safetensorsesiste e pesa pochi MB: il modello base ha 134,5M di parametri, l'adattatore ne addestra solo 2,44M (l'1,82%)python test_adapter.pyproduce due risposte diverse per la stessa domanda
ls -lh outputs/smollm2-lora/
du -sh outputs/smollm2-lora/Risultato atteso:
adapter_config.json adapter_model.safetensors chat_template.jinja checkpoint-10/ ...
45M outputs/smollm2-lora/Il file adapter_model.safetensors pesa 9,4 MB contro i ~260 MB del checkpoint del modello base: hai adattato il modello addestrando solo l'1,82% dei suoi parametri, e l'adattatore risultante sta in un file di pochi MB.
Limiti noti
- Questa guida dimostra il meccanismo, non la convergenza. 10 step su 200 campioni non producono un modello utile: servono più dati (migliaia di campioni), più epoche e la valutazione su un set di test. Il fine-tuning vero è un ciclo di esperimenti, non un comando
- Il training su CPU è lento: circa 73 secondi a step con 4 core (734,8 secondi per 10 step). Su GPU NVIDIA con 8 GB di VRAM la stessa configurazione è pensata per girare in pochi minuti: cambia solo l'indice dei wheel di PyTorch al passo 1 (
cu126invece dicpu) - Il dataset Alpaca è cc-by-nc-4.0: ok per uso didattico e personale, non per prodotti commerciali. Per uso commerciale servono dataset con licenza permissiva (Apache-2.0 o MIT)
max_length=256tronca i campioni più lunghi: con Alpaca (risposte brevi) quasi nessun campione viene toccato, ma con dataset conversazionali lunghi (tipo Capybara) la troncatura taglia la parte che conta- Loss calcolata su tutti i token: per un controllo più fine su cosa impara il modello (solo le risposte, non i prompt) TRL espone
completion_only_loss=True, che richiede un dataset con i ruoli ben marcati