Skip to main content

03 - SLM Fine-tuning with LoRA and QLoRA: Practical Guide

Fine-tuning SLM with LoRA and QLoRA: Complete Practical Guide In 2021, fine-tuning a 7B parameter model required 8 GPUs of 80GB each and weeks of compute. Today, QLoRA makes it possible on consumer GPUs. Learn LoRA, QLoRA, PEFT, and TRL with hands-on examples.

What do you think about this article?

At a Glance

Fine-tuning SLM with LoRA and QLoRA: Complete Practical Guide In 2021, fine-tuning a 7B parameter model required 8 GPUs of 80GB each and weeks of compute. Today, QLoRA makes it possible on consumer GPUs. Learn LoRA, QLoRA, PEFT, and TRL with hands-on examples.

SLM Fine-tuning with LoRA and QLoRA: Complete Practical Guide

In 2021, fine-tuning a 7B parameter model required 8 GPUs of 80GB each and weeks of time. With QLoRA (2023) and the mature tooling of 2026, the same result is achieved on a single consumer GPU with 8-12GB VRAM in 2-4 hours. Phi-4-mini or Qwen 3 adapted to your own domain - business documentation, medical terminology, legacy code - exceed GPT-4 sui specific tasks at a fraction of the cost. This guide covers the entire workflow: preparation of the dataset, training with QLoRA, evaluation and deployment.

What You Will Learn

  • LoRA: How low-rank matrix decomposition works
  • QLoRA: Combine 4-bit quantization with LoRA for 8-12GB GPUs
  • Prepare the dataset in the correct format for SFT (Supervised Fine-Tuning)
  • Complete workflow with PEFT + TRL + Hugging Face Datasets
  • Evaluate the fine-tuned model and merge on a model basis

How LoRA Works: The Theory in 5 Minutes

A transformer model has millions of weight matrices. Fine-tune all millions of parameters to update - impossible on consumer GPUs. LoRA (Low-Rank Adaptation) observes that the Updates needed to fit a pre-driven model have inherently low rank: can be approximated by two small matrices A and B, where the rank r (typically 4-64) and much smaller than the original size.

# 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

Environment Setup for 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")

Prepare the Dataset for 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"])

QLoRA configuration

# 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 with 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.")

Model Evaluation and Merge

# --- 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")

GPU Memory Required for Fine-tuning

  • Phi-4-mini (3.8B) with 4-bit QLoRA: ~8-10 GB VRAM (RTX 4070 OK)
  • Qwen 3 7B with QLoRA 4-bit: ~12-14 GB VRAM (RTX 4070 12GB borderline)
  • Phi-4-mini with LoRA fp16: ~16 GB VRAM (RTX 4090 or A10G)
  • With gradient_checkpointing=True you save ~40% extra VRAM

Post on 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")

Conclusions

QLoRA has made fine-tuning accessible to anyone with a consumer GPU: 8-12 GB VRAM they are enough to adapt 3-7B models to your domain in a few hours. The workflow with PEFT + TRL and standardized and well documented. The result - a model that knows your domain-specific terminology - often outperforms GPT-4 on specific tasks though costing a fraction in inference.

The next article addresses quantization for edge deployment: how to convert the fine-tuned model in GGUF for Ollama, ONNX for cross-platform runtimes, and INT4 for NVIDIA and Qualcomm NPUs.

Series: Small Language Models

  • Article 1: SLM in 2026 - Overview and Benchmark
  • Article 2: Phi-4-mini vs Gemma 3n - Detailed Comparison
  • Article 3 (this): Fine-tuning with LoRA and QLoRA
  • Article 4: Quantization for Edge - GGUF, ONNX, INT4
  • Article 5: Ollama - SLM Locally in 5 Minutes

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects