LoRA および QLoRA を使用した SLM 微調整: 完全な実践ガイド
2021 年には、7B パラメーター モデルの微調整には、80 GB の 8 つの GPU と数週間が必要でした。 時間の。 QLoRA (2023) と 2026 年の成熟したツールを使用すると、同じ結果が得られます。 8 ~ 12 GB VRAM を備えた単一のコンシューマ GPU を 2 ~ 4 時間で完了します。自分の用途に合わせた Phi-4-mini または Qwen 3 ドメイン — ビジネス文書、医療用語、レガシーコード — GPT-4 を超える 特定のタスクをわずかなコストで実行できます。このガイドでは、ワークフロー全体について説明します: 準備 データセットの作成、QLoRA によるトレーニング、評価と展開。
何を学ぶか
- LoRA: 低ランク行列分解の仕組み
- QLoRA: 8 ~ 12GB GPU 向けに 4 ビット量子化と LoRA を組み合わせる
- SFT (教師あり微調整) 用に正しい形式でデータセットを準備します。
- PEFT + TRL + ハグ顔データセットを使用した完全なワークフロー
- 微調整されたモデルを評価し、モデルごとにマージします。
LoRA の仕組み: 5 分でわかる理論
変圧器モデルには何百万もの重み行列があります。数百万のパラメータをすべて微調整する 更新することは、コンシューマー向け GPU では不可能です。 LoRA (低ランク適応) は、 事前駆動モデルに適合させるために必要な更新は本質的に低いランクになります。 は 2 つの小さな行列 A と B で近似できます。ここで、ランク r (通常は 4 ~ 64) そして元のサイズよりもはるかに小さいです。
# 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
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")
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の構成
# 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
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 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 メモリ
- 4 ビット QLoRA を備えた Phi-4-mini (3.8B): ~8-10 GB VRAM (RTX 4070 OK)
- Qwen 3 7B (QLoRA 4 ビット搭載): ~12-14 GB VRAM (RTX 4070 12GB 境界線)
- LoRA fp16 を搭載した Phi-4-mini: ~16 GB VRAM (RTX 4090 または A10G)
- Con
gradient_checkpointing=TrueVRAM を最大 40% 節約できます
ハグフェイスハブに投稿する
# 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")
結論
QLoRA により、コンシューマ GPU: 8 ~ 12 GB VRAM を使用すれば誰でも微調整にアクセスできるようになりました。 数時間で 3 ~ 7B モデルをドメインに適応させるには十分です。ワークフロー PEFT + TRL を使用し、標準化され、十分に文書化されています。結果 — 知っているモデル ドメイン固有の用語 — 多くの場合、特定のタスクでは GPT-4 よりも優れたパフォーマンスを発揮します 推論にかかる費用はわずかです。
次の記事では、エッジ展開の量子化: 変換方法について説明します。 Ollama 用の GGUF、クロスプラットフォーム ランタイム用の ONNX、および INT4 の微調整されたモデル NVIDIA および Qualcomm NPU 用。
シリーズ: 小規模言語モデル
- 記事 1: 2026 年の SLM - 概要とベンチマーク
- 記事 2: Phi-4-mini と Gemma 3n - 詳細な比較
- 第3条(本): LoRA と QLoRA による微調整
- 第 4 条: エッジの量子化 - GGUF、ONNX、INT4
- 記事 5: Ollama - 5 分でローカルで SLM







