Salta al contenuto principale

03 - Fine-tuning SLM con LoRA e QLoRA: Guida Pratica

Fine-tuning SLM con LoRA e QLoRA: Guida Pratica Completa Nel 2021, fine-tunare un modello da 7B parametri richiedeva 8 GPU da 80GB ciascuna e settimane di...

Cosa ne pensi di questo articolo?

03 - Fine-tuning SLM con LoRA e QLoRA: Guida Pratica
Small Language ModelsFoto:Ruben BoekelooPexels

In Breve

Fine-tuning SLM con LoRA e QLoRA: Guida Pratica Completa Nel 2021, fine-tunare un modello da 7B parametri richiedeva 8 GPU da 80GB ciascuna e settimane di...

Fine-tuning SLM con LoRA e QLoRA: Guida Pratica Completa

Nel 2021, fine-tunare un modello da 7B parametri richiedeva 8 GPU da 80GB ciascuna e settimane di tempo. Con QLoRA (2023) e il tooling maturo del 2026, lo stesso risultato si ottiene su una singola GPU consumer da 8-12 GB VRAM in 2-4 ore. Phi-4-mini o Qwen 3 adattati al proprio dominio - documentazione aziendale, terminologia medica, codice legacy - superano GPT-4 sui task specifici a una frazione del costo. Questa guida copre l'intero workflow: preparazione del dataset, training con QLoRA, valutazione e deployment.

Cosa Imparerai

  • LoRA: come funziona la decomposizione in matrici di basso rango
  • QLoRA: combinare quantizzazione 4-bit con LoRA per GPU da 8-12 GB
  • Preparare il dataset nel formato corretto per SFT (Supervised Fine-Tuning)
  • Workflow completo con PEFT + TRL + Hugging Face Datasets
  • Valutare il modello fine-tunato e fare merge su base model

Come Funziona LoRA: La Teoria in 5 Minuti

Un modello transformer ha milioni di matrici di peso. Fine-tunare tutte e milioni di parametri da aggiornare - impossibile su GPU consumer. LoRA (Low-Rank Adaptation) osserva che gli aggiornamenti necessari per adattare un modello pre-trainato hanno intrinsecamente basso rango: possono essere approssimati da due matrici piccole A e B, dove il rango r (tipicamente 4-64) e molto minore delle dimensioni originali.

# Concetto di LoRA spiegato con codice
import torch
import torch.nn as nn

class LoRALayer(nn.Module):
    """
    Implementazione concettuale di uno strato LoRA.
    In pratica si usa la libreria PEFT che lo fa automaticamente.
    """
    def __init__(self, original_layer: nn.Linear, rank: int = 16, alpha: float = 32):
        super().__init__()
        d_in, d_out = original_layer.weight.shape

        # Pesi originali: CONGELATI (no gradients)
        self.original_weight = original_layer.weight
        self.original_weight.requires_grad_(False)

        # Matrici LoRA: piccole e trainabili
        # A: forma (rank, d_in)  - inizializzata con random gaussiana
        # B: forma (d_out, rank) - inizializzata a ZERO (output = 0 all'inizio)
        self.lora_A = nn.Parameter(torch.randn(rank, d_in) * 0.02)
        self.lora_B = nn.Parameter(torch.zeros(d_out, rank))

        # Scaling factor: alpha/rank (controlla la magnitudine dell'update)
        self.scaling = alpha / rank

        # Parametri trainabili: rank * (d_in + d_out) invece di d_in * d_out
        # Esempio: d_in=4096, d_out=4096, rank=16
        # Originale: 16,777,216 parametri
        # LoRA: 16 * (4096 + 4096) = 131,072 parametri (128x meno!)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Output = Wx + (BA)x * scaling
        original_out = x @ self.original_weight.T

        # Update LoRA: passare per A poi B
        lora_out = (x @ self.lora_A.T) @ self.lora_B.T

        return original_out + self.scaling * lora_out

# In pratica: PEFT gestisce tutto questo automaticamente
# Si specificano solo rank, alpha e quali moduli applicare LoRA

Setup dell'Ambiente per QLoRA

# Installazione dell'ambiente (Python 3.11, CUDA 12.4)
# pip install torch==2.5.1 torchvision --index-url https://download.pytorch.org/whl/cu124
# pip install transformers==4.47.0 peft==0.13.2 trl==0.12.2
# pip install bitsandbytes==0.44.1 datasets==3.1.0 evaluate==0.4.3
# pip install accelerate==1.2.1 wandb sentencepiece

import os
import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
from datasets import load_dataset

# Configurare wandb per il tracking (opzionale ma raccomandato)
os.environ["WANDB_PROJECT"] = "slm-fine-tuning"

# Verificare hardware
print(f"CUDA disponibile: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"GPU: {torch.cuda.get_device_name(0)}")
    print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")

Preparare il Dataset per SFT

# Il formato piu comune per SFT (Supervised Fine-Tuning) e il formato "chat"
# con messaggi strutturati in ruoli: system, user, assistant

from datasets import Dataset
import json

def prepare_sft_dataset(raw_examples: list[dict]) -> Dataset:
    """
    Converte esempi grezzi nel formato chat per SFT.
    raw_examples: lista di {"question": str, "answer": str, "context": str}
    """
    formatted = []

    for ex in raw_examples:
        messages = [
            {
                "role": "system",
                "content": "Sei un esperto di database PostgreSQL. "
                           "Rispondi in italiano con esempi pratici e codice SQL."
            },
            {
                "role": "user",
                "content": ex["question"]
            },
            {
                "role": "assistant",
                "content": ex["answer"]
            }
        ]
        formatted.append({"messages": messages})

    return Dataset.from_list(formatted)

# Esempio di dataset per fine-tuning su dominio PostgreSQL
raw_data = [
    {
        "question": "Quando devo usare un partial index invece di un indice normale?",
        "answer": """Usa un partial index quando:
1. Solo una piccola percentuale di righe e "attiva" nelle query
2. Esempio classico: ordini con stato='pendente' (1% del totale)

```sql
CREATE INDEX idx_ordini_pendenti ON ordini (creato_il)
  WHERE stato = 'pendente';
```

Vantaggi: l'indice e 100x piu piccolo, piu veloce da aggiornare."""
    },
    # ... 500-1000 esempi per risultati buoni ...
]

train_dataset = prepare_sft_dataset(raw_data[:800])
eval_dataset = prepare_sft_dataset(raw_data[800:])

print(f"Train: {len(train_dataset)} esempi")
print(f"Eval: {len(eval_dataset)} esempi")
print("Esempio:", train_dataset[0]["messages"][1]["content"])

Configurazione di QLoRA

# Modello base: Phi-4-mini
BASE_MODEL = "microsoft/Phi-4-mini-instruct"

# --- Configurazione BitsAndBytes per quantizzazione 4-bit ---
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                    # quantizzazione 4-bit NF4
    bnb_4bit_use_double_quant=True,       # double quantization: -15% memoria extra
    bnb_4bit_quant_type="nf4",            # NF4 > fp4 per LLM
    bnb_4bit_compute_dtype=torch.bfloat16 # bf16 per calcoli (piu stabile di fp16)
)

# Caricare il modello con quantizzazione 4-bit
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, padding_side="right")
tokenizer.pad_token = tokenizer.eos_token  # Phi non ha pad token

model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16
)

# Preparare il modello per training in kbit (necessario con bitsandbytes)
model = prepare_model_for_kbit_training(model)

# --- Configurazione LoRA ---
lora_config = LoraConfig(
    r=16,                      # rank: 8-64, piu alto = piu parametri trainabili
    lora_alpha=32,             # scaling: tipicamente = 2*r
    target_modules=[           # moduli su cui applicare LoRA
        "q_proj",              # Query projection
        "k_proj",              # Key projection
        "v_proj",              # Value projection
        "o_proj",              # Output projection
        "gate_proj",           # MLP gate
        "up_proj",             # MLP up
        "down_proj",           # MLP down
    ],
    lora_dropout=0.05,         # regularization
    bias="none",               # non trainare i bias
    task_type="CAUSAL_LM"
)

# Applicare LoRA al modello
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Output: trainable params: 20,971,520 || all params: 3,841,593,344 || trainable%: 0.55

Training con TRL SFTTrainer

# --- Argomenti di training ---
training_args = TrainingArguments(
    output_dir="./phi4-mini-postgres-finetuned",
    num_train_epochs=3,                   # 3 epoch per dataset piccolo
    per_device_train_batch_size=2,        # batch size: aumentare se hai piu VRAM
    gradient_accumulation_steps=4,        # effective batch = 8 (2*4)
    gradient_checkpointing=True,          # risparmia ~40% VRAM, +20% tempo
    learning_rate=2e-4,                   # tipico range: 1e-4 a 5e-4 per LoRA
    lr_scheduler_type="cosine",           # cosine decay
    warmup_ratio=0.05,                    # 5% del training per warmup
    bf16=True,                            # bfloat16 per training
    optim="paged_adamw_8bit",             # AdamW quantizzato: risparmia VRAM
    logging_steps=10,
    evaluation_strategy="steps",
    eval_steps=50,
    save_strategy="steps",
    save_steps=100,
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    report_to="wandb",                    # o "none" se non usi wandb
    push_to_hub=False,                    # True per uploadare su HF Hub
)

# --- DataCollator: ottimizza il training per SFT ---
# Solo l'output del assistant contribuisce alla loss (non l'input)
response_template = "<|assistant|>"  # token che indica l'inizio della risposta

collator = DataCollatorForCompletionOnlyLM(
    response_template=response_template,
    tokenizer=tokenizer
)

# --- SFTTrainer ---
trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    data_collator=collator,
    max_seq_length=2048,  # lunghezza massima sequenza
    dataset_text_field=None,  # usiamo il campo "messages"
    packing=False,  # non unire sequenze corte (meglio per chat)
)

# Avviare il training
print("Avvio training...")
trainer.train()

# Salvare il modello (solo i pesi LoRA, non il modello base)
trainer.save_model("./phi4-mini-postgres-lora")
print("Training completato. Pesi LoRA salvati.")

Valutazione e Merge del Modello

# --- Valutazione qualitativa ---
from peft import PeftModel

def evaluate_finetuned_model(base_model_id: str, lora_path: str, test_prompts: list[str]):
    """Caricare e valutare il modello fine-tunato."""

    # Caricare base model + LoRA adapter
    tokenizer = AutoTokenizer.from_pretrained(base_model_id)
    base_model = AutoModelForCausalLM.from_pretrained(
        base_model_id,
        torch_dtype=torch.float16,
        device_map="auto",
        trust_remote_code=True
    )
    ft_model = PeftModel.from_pretrained(base_model, lora_path)
    ft_model.eval()

    for prompt in test_prompts:
        messages = [
            {"role": "system", "content": "Sei un esperto di PostgreSQL."},
            {"role": "user", "content": prompt}
        ]

        input_text = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        inputs = tokenizer(input_text, return_tensors="pt").to(ft_model.device)

        with torch.no_grad():
            outputs = ft_model.generate(
                **inputs,
                max_new_tokens=300,
                temperature=0.3,
                do_sample=True
            )

        response = tokenizer.decode(
            outputs[0][inputs.input_ids.shape[1]:],
            skip_special_tokens=True
        )
        print(f"Q: {prompt}")
        print(f"A: {response[:200]}")
        print("---")

test_prompts = [
    "Qual e la differenza tra EXPLAIN e EXPLAIN ANALYZE?",
    "Come creo un partial index su ordini non completati?",
    "Perche la mia query usa Sequential Scan invece di Index Scan?"
]

evaluate_finetuned_model(BASE_MODEL, "./phi4-mini-postgres-lora", test_prompts)

# --- Merge LoRA nel modello base (per deployment) ---
def merge_and_save(base_model_id: str, lora_path: str, output_path: str):
    """Fonde i pesi LoRA nel modello base per deployment efficiente."""

    tokenizer = AutoTokenizer.from_pretrained(base_model_id)
    base_model = AutoModelForCausalLM.from_pretrained(
        base_model_id,
        torch_dtype=torch.float16,
        device_map="cpu",  # CPU per il merge (piu stabile)
        trust_remote_code=True
    )

    # Applicare i pesi LoRA
    ft_model = PeftModel.from_pretrained(base_model, lora_path)

    # Merge: fonde A*B nel peso originale
    merged_model = ft_model.merge_and_unload()

    # Salvare il modello merged
    merged_model.save_pretrained(output_path, safe_serialization=True)  # usa safetensors
    tokenizer.save_pretrained(output_path)

    print(f"Modello merged salvato in {output_path}")
    print("Ora puoi convertirlo in GGUF con llama.cpp per Ollama!")

merge_and_save(BASE_MODEL, "./phi4-mini-postgres-lora", "./phi4-mini-postgres-merged")

Memoria GPU Necessaria per Fine-tuning

  • Phi-4-mini (3.8B) con QLoRA 4-bit: ~8-10 GB VRAM (RTX 4070 OK)
  • Qwen 3 7B con QLoRA 4-bit: ~12-14 GB VRAM (RTX 4070 12GB borderline)
  • Phi-4-mini con LoRA fp16: ~16 GB VRAM (RTX 4090 o A10G)
  • Con gradient_checkpointing=True si risparmia ~40% VRAM extra

Pubblicare su Hugging Face Hub

# Pubblicare il modello fine-tunato (opzionale)
from huggingface_hub import login

login()  # autentica con token HF

# Pushare solo l'adapter LoRA (leggero, ~50MB)
trainer.model.push_to_hub(
    "tuo-username/phi4-mini-postgresql-expert",
    private=True,          # private=False per rendere pubblico
    safe_serialization=True
)
tokenizer.push_to_hub("tuo-username/phi4-mini-postgresql-expert")

# README automatico con informazioni sul training
from huggingface_hub import ModelCard

card_content = """
---
base_model: microsoft/Phi-4-mini-instruct
language: it
tags:
  - lora
  - postgresql
  - database
  - fine-tuned
---

# Phi-4-mini fine-tuned su dominio PostgreSQL (italiano)

## Utilizzo

```python
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

model = PeftModel.from_pretrained("tuo-username/phi4-mini-postgresql-expert")
```
"""

card = ModelCard(card_content)
card.push_to_hub("tuo-username/phi4-mini-postgresql-expert")

Conclusioni

QLoRA ha reso accessibile il fine-tuning a chiunque abbia una GPU consumer: 8-12 GB VRAM sono sufficienti per adattare modelli da 3-7B al proprio dominio in poche ore. Il workflow con PEFT + TRL e standardizzato e ben documentato. Il risultato - un modello che conosce la terminologia specifica del tuo dominio - spesso supera GPT-4 sui task specifici pur costando una frazione in inferenza.

Il prossimo articolo affronta la quantizzazione per il deployment edge: come convertire il modello fine-tunato in GGUF per Ollama, ONNX per runtime cross-platform, e INT4 per NPU NVIDIA e Qualcomm.

Serie: Small Language Models

  • Articolo 1: SLM nel 2026 - Panoramica e Benchmark
  • Articolo 2: Phi-4-mini vs Gemma 3n - Confronto Dettagliato
  • Articolo 3 (questo): Fine-tuning con LoRA e QLoRA
  • Articolo 4: Quantizzazione per Edge - GGUF, ONNX, INT4
  • Articolo 5: Ollama - SLM Localmente in 5 Minuti

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