Engineer Quiz

← 記事一覧

LLMファインチューニング手法 - LoRA, QLoRA, DPO

LLMファインチューニング手法 - LoRA, QLoRA, DPO

📚 概要

大規模言語モデル(LLM)のファインチューニングは、特定のタスクやドメインに適応させるための重要な技術です。しかし、数十億~数千億のパラメータを持つモデルの全体を再学習するには、膨大な計算リソースとメモリが必要です。

この記事では、効率的なファインチューニング手法である LoRAQLoRADPO を詳しく解説します。

🕰️ 歴史的背景

ファインチューニングの進化

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量子化 を組み合わせ、さらにメモリを削減します。

主な技術

  1. 4bit NormalFloat (NF4): データ分布に最適化された量子化
  2. Double Quantization: 量子化パラメータ自体も量子化
  3. 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:#ff6b6b

RLHFの問題点:

  1. 報酬モデルの学習が必要
  2. PPO(Proximal Policy Optimization)の実装が複雑
  3. ハイパーパラメータに敏感
  4. 学習が不安定

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を構築してみましょう!

参考リソース: