LLMファインチューニング手法 - LoRA, QLoRA, DPO
📚 概要
大規模言語モデル(LLM)のファインチューニングは、特定のタスクやドメインに適応させるための重要な技術です。しかし、数十億~数千億のパラメータを持つモデルの全体を再学習するには、膨大な計算リソースとメモリが必要です。
この記事では、効率的なファインチューニング手法である LoRA、QLoRA、DPO を詳しく解説します。
🕰️ 歴史的背景
ファインチューニングの進化
2018-2020年: フル・ファインチューニングの時代
- BERT、GPT-2などのモデルを全パラメータで再学習
- 課題: 大量のGPUメモリと時間が必要
- 例: GPT-3(175B パラメータ)の学習には数百万ドルのコスト
2021年: Adapter Layers
- モデルの一部だけを学習可能にする手法
- 学習パラメータを1%未満に削減
2021年: LoRA(Low-Rank Adaptation)の登場
- Microsoft Research が発表
- 低ランク行列で重みの更新を近似
- 学習パラメータを0.1%未満に削減
2023年: QLoRA(Quantized LoRA)
- 4bit 量子化と組み合わせてメモリを劇的に削減
- 消費者向けGPU(RTX 3090など)でLlama 65Bを学習可能に
2023年: DPO(Direct Preference Optimization)
- RLHF(Reinforcement Learning from Human Feedback)を簡略化
- 報酬モデル不要で、人間の好みを直接学習
🔧 技術解説
1. LoRA - Low-Rank Adaptation
LoRAは、事前学習済みモデルの重み行列を固定し、低ランクの分解行列を追加することで、少ないパラメータで効率的にファインチューニングを行います。
数学的な仕組み
元の重み行列 W ∈ R^(d×d) を更新する代わりに、2つの低ランク行列 A ∈ R^(d×r) と B ∈ R^(r×d) を学習します(r << d)。
W_new = W + ΔW
ΔW = B × A
メモリ削減の計算:
# 元の重み行列のパラメータ数
original_params = d * d
# LoRAのパラメータ数
lora_params = d * r + r * d # = 2 * d * r
# 削減率(r = 8, d = 4096 の場合)
reduction = lora_params / original_params
# = (2 * 4096 * 8) / (4096 * 4096)
# = 65536 / 16777216
# ≈ 0.39% (約250分の1)
実装例(PyTorch)
import torch
import torch.nn as nn
class LoRALayer(nn.Module):
def __init__(self, in_features, out_features, rank=8, alpha=16):
super().__init__()
self.rank = rank
self.alpha = alpha
# 元の重み(凍結)
self.weight = nn.Parameter(torch.randn(out_features, in_features))
self.weight.requires_grad = False
# LoRA パラメータ(学習可能)
self.lora_A = nn.Parameter(torch.randn(rank, in_features))
self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
# スケーリング係数
self.scaling = alpha / rank
def forward(self, x):
# 元の出力
result = torch.matmul(x, self.weight.T)
# LoRA の追加分
lora_output = torch.matmul(
torch.matmul(x, self.lora_A.T),
self.lora_B.T
)
return result + lora_output * self.scaling
# 使用例
layer = LoRALayer(in_features=4096, out_features=4096, rank=8)
x = torch.randn(1, 4096)
output = layer(x)
print(output.shape) # torch.Size([1, 4096])
# 学習可能なパラメータ数
trainable = sum(p.numel() for p in layer.parameters() if p.requires_grad)
frozen = sum(p.numel() for p in layer.parameters() if not p.requires_grad)
print(f"Trainable: {trainable:,}, Frozen: {frozen:,}")
# Trainable: 65,536, Frozen: 16,777,216
HuggingFace PEFT での使用
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
# ベースモデル読み込み
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
# LoRA 設定
lora_config = LoraConfig(
r=8, # ランク
lora_alpha=16, # スケーリング係数
target_modules=["q_proj", "v_proj"], # どの層にLoRAを適用するか
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM"
)
# LoRA適用
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.06%
# 学習
# ...(通常のトレーニングループ)
# LoRA重みを保存(約16MBのみ)
model.save_pretrained("./lora_weights")
2. QLoRA - Quantized LoRA
QLoRAは、LoRAに 4bit量子化 を組み合わせ、さらにメモリを削減します。
主な技術
- 4bit NormalFloat (NF4): データ分布に最適化された量子化
- Double Quantization: 量子化パラメータ自体も量子化
- Paged Optimizer: CPU-GPUメモリ管理の最適化
メモリ削減の効果
graph TB
A[Llama 2 70B Model] --> B{Precision}
B --> C[FP32: 280GB]
B --> D[FP16: 140GB]
B --> E[INT8: 70GB]
B --> F[NF4 QLoRA: 35GB]
C --> G[A100 80GB: Impossible]
D --> H[A100 80GB: Impossible]
E --> I[A100 80GB: Barely Fits]
F --> J[RTX 3090 24GB x2: Possible]
style F fill:#51cf66
style J fill:#51cf66実装例
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
# 4bit量子化設定
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
# モデル読み込み(4bit量子化)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-70b-hf",
quantization_config=bnb_config,
device_map="auto"
)
# LoRA設定
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
# QLoRA適用
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 33,554,432 || all params: 35,015,667,712 || trainable%: 0.10%
# メモリ使用量
import torch
print(f"GPU Memory: {torch.cuda.max_memory_allocated() / 1e9:.2f} GB")
# GPU Memory: 38.24 GB (70Bモデルがたった38GBで学習可能!)
3. DPO - Direct Preference Optimization
DPOは、RLHF(人間のフィードバックからの強化学習)を簡略化した手法です。
RLHFの課題
graph LR
A[Preference Data] --> B[Train Reward Model]
B --> C[RL Training PPO]
C --> D[Aligned Model]
B --> E[Instability Issues]
C --> F[Complex Implementation]
C --> G[Hyperparameter Sensitivity]
style E fill:#ff6b6b
style F fill:#ff6b6b
style G fill:#ff6b6bRLHFの問題点:
- 報酬モデルの学習が必要
- PPO(Proximal Policy Optimization)の実装が複雑
- ハイパーパラメータに敏感
- 学習が不安定
DPOのアプローチ
DPOは、報酬モデルを使わずに、直接 好みデータから学習します。
損失関数:
def dpo_loss(policy_model, ref_model, chosen, rejected):
"""
DPO loss function
Args:
policy_model: 学習中のモデル
ref_model: 参照モデル(凍結)
chosen: 好まれた応答
rejected: 好まれなかった応答
"""
# 各応答の対数尤度を計算
log_prob_chosen = policy_model.log_prob(chosen)
log_prob_rejected = policy_model.log_prob(rejected)
# 参照モデルの対数尤度
ref_log_prob_chosen = ref_model.log_prob(chosen)
ref_log_prob_rejected = ref_model.log_prob(rejected)
# 対数比率
log_ratio_chosen = log_prob_chosen - ref_log_prob_chosen
log_ratio_rejected = log_prob_rejected - ref_log_prob_rejected
# DPO loss
beta = 0.1 # 温度パラメータ
loss = -torch.log(torch.sigmoid(beta * (log_ratio_chosen - log_ratio_rejected)))
return loss.mean()
実装例(TRL ライブラリ)
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import DPOTrainer, DPOConfig
from datasets import load_dataset
# モデル読み込み
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
ref_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
# 好みデータセット
# 形式: {"prompt": "...", "chosen": "...", "rejected": "..."}
dataset = load_dataset("Anthropic/hh-rlhf")
# DPO 設定
training_args = DPOConfig(
beta=0.1, # 温度パラメータ
learning_rate=5e-5,
per_device_train_batch_size=4,
num_train_epochs=3,
output_dir="./dpo_output"
)
# トレーナー
trainer = DPOTrainer(
model=model,
ref_model=ref_model,
args=training_args,
train_dataset=dataset["train"],
tokenizer=tokenizer,
)
# 学習
trainer.train()
# 保存
model.save_pretrained("./dpo_model")
📊 手法の比較
| 手法 | メモリ | 学習時間 | 品質 | 実装難易度 |
|---|---|---|---|---|
| Full Fine-tuning | 高い | 長い | 最高 | 簡単 |
| LoRA | 低い | 短い | 高い | 簡単 |
| QLoRA | 最低 | 短い | 高い | 簡単 |
| RLHF | 高い | 非常に長い | 最高 | 非常に難しい |
| DPO | 中程度 | 中程度 | 高い | 中程度 |
メモリ使用量の比較(Llama 2 70B)
graph LR
A[Full FT] -->|280GB| B[Impossible]
C[LoRA FP16] -->|140GB| D[A100 x2]
E[QLoRA 4bit] -->|35GB| F[RTX 3090 x2]
style E fill:#51cf66
style F fill:#51cf66💡 実践例: QLoRA + DPO でカスタムアシスタント
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import DPOTrainer, DPOConfig
import torch
# Step 1: QLoRA でベースモデルをファインチューニング
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb_config,
device_map="auto"
)
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
# Step 2: 通常のファインチューニング
# ... (instruction-tuning データセットで学習)
# Step 3: DPO でアライメント
ref_model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb_config,
device_map="auto"
)
dpo_trainer = DPOTrainer(
model=model,
ref_model=ref_model,
args=DPOConfig(
beta=0.1,
learning_rate=5e-5,
per_device_train_batch_size=2,
num_train_epochs=1,
),
train_dataset=preference_dataset,
tokenizer=tokenizer,
)
dpo_trainer.train()
# Step 4: 推論
model.eval()
prompt = "Pythonでファイルを読み込む方法を教えてください。"
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
outputs = model.generate(**inputs, max_length=200)
print(tokenizer.decode(outputs[0]))
🎯 ベストプラクティス
1. LoRA のランク選択
# 小さいモデル(7B): r=8-16
# 大きいモデル(70B): r=16-32
# 複雑なタスク: より大きいランク
lora_config = LoraConfig(
r=16, # 一般的な開始点
lora_alpha=32, # r の 2倍が推奨
)
2. ターゲットモジュールの選択
# 最小(速度優先)
target_modules=["q_proj", "v_proj"]
# 推奨(バランス)
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]
# 最大(品質優先)
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
3. DPO のデータ品質
# ✅ 良いデータ
{
"prompt": "Pythonで配列をソートする方法は?",
"chosen": "sorted()関数を使います: sorted([3,1,2]) → [1,2,3]",
"rejected": "よくわかりません。"
}
# ❌ 悪いデータ(差が小さすぎる)
{
"prompt": "こんにちは",
"chosen": "こんにちは!",
"rejected": "こんにちは。"
}
🔍 関連する問題
この記事に関連するクイズ問題:
- Q4: LoRA の低ランク行列分解
- Q9: DPO vs RLHF の違い
- Q17: QLoRA のメモリ効率化
- Q42: Self-Improvement 手法
📝 まとめ
- LoRA: 低ランク行列でパラメータを0.1%に削減
- QLoRA: 4bit量子化でメモリを1/4に削減
- DPO: RLHF を簡略化、報酬モデル不要
- 組み合わせ: QLoRA + DPO で消費者向けGPUでも学習可能
次のステップ: 実際にQLoRAとDPOを使って、カスタムLLMを構築してみましょう!
参考リソース: