Skip to content
Published on

LLMファインチューニング完全ガイド:LoRA、QLoRA、RLHF、DPOをマスターする

Authors

LLMファインチューニング完全ガイド:LoRA、QLoRA、RLHF、DPOをマスターする

LLaMA 3、Mistral、Gemmaなどの強力なオープンソースLLMが公開された今、特定のドメインやタスク向けにファインチューニングすることはAIエンジニアのコアスキルとなっています。このガイドでは、フルファインチューニングからLoRA、QLoRA、RLHF、DPOまで、主要なLLMファインチューニング手法をすべて完全な本番対応コードとともに解説します。


1. なぜファインチューニングするのか?

1.1 事前学習済みモデルの限界

大規模LLMは膨大なインターネットテキストで事前学習され、優れた汎用能力を獲得します。しかし実際に使用する際にはいくつかの限界があります:

ドメイン知識のギャップ: GPT-4であっても、自社の内部文書や学習カットオフ後に公開された最新の医療プロトコルは知りません。

デフォルトでは指示に従わない: ベースモデルは次のトークンを予測するために学習されています(指示に従うためではありません)。ベースモデルに「このコードのバグを見つけてください」と頼んでも、単に妥当なテキストを生成し続けるだけかもしれません。

出力フォーマットの制御: モデルに特定のJSONスキーマやMarkdown構造を確実に生成させることは、プロンプティングだけでは非常に困難です。

安全性とアライメントの問題: 事前学習済みモデルは有害なコンテンツを生成したり、エッジケースの入力に対して一貫性のない動作をする可能性があります。

1.2 ファインチューニングのメリット

ファインチューニングとは、特定の目的のために新しいデータで事前学習済みモデルの重みをさらに学習させるプロセスです:

  • ドメイン適応: 専門的な用語、知識、スタイルを習得する
  • タスクの特化: 分類、抽出、生成での性能を最大化する
  • 行動の制御: 望ましい応答スタイル、フォーマット、安全境界を学習する
  • コスト効率: ファインチューニングされた小さなモデルが高価な大規模モデルAPIの呼び出しを代替できる

1.3 ファインチューニング vs プロンプトエンジニアリング

プロンプトエンジニアリングは速くて無料ですが、限界があります:

基準プロンプトエンジニアリングファインチューニング
実装の労力中〜高
コスト実行時トークンコスト一回限りの学習コスト
性能の天井ベースモデルに制限されるベースを超えられる
出力の一貫性
プライバシーデータがAPIに送られるローカル実行が可能
レイテンシ長いプロンプト = 遅い短い入力が可能

1.4 ファインチューニングの種類

ファインチューニングのアプローチは3つの主要カテゴリに分類されます:

  1. フルファインチューニング: すべてのパラメータを更新(最も強力、最もコストが高い)
  2. PEFT(パラメータ効率的ファインチューニング): ごく一部のパラメータのみ更新
    • LoRA、QLoRA、プレフィックスチューニング、アダプターなど
  3. RLHF/DPO: 人間の好みデータから学習(アライメント/安全性)

2. フルファインチューニング

2.1 概要

フルファインチューニングは新しいデータですべてのモデルパラメータを更新します。理論的には最も強力なアプローチですが、実際の制限から最初の選択肢にはなりにくいです。

2.2 メモリ要件

7Bパラメータモデルのフルファインチューニングにはおよそ以下が必要です:

  • モデルの重み(BF16): 7B × 2バイト = 14 GB
  • 勾配: 14 GB(重みと同じ)
  • オプティマイザー状態(AdamW): 14 GB × 2 = 28 GB(一次・二次モーメント)
  • アクティベーション: バッチサイズとシーケンス長に応じて数GB
  • 合計: 60GB以上

7Bのフルファインチューニングには単一のA100 80GBでもギリギリです。70Bモデルには複数の高性能GPUが必要です。

2.3 フルファインチューニングを使う場合

  • ドメインが事前学習の分布と大きく異なる場合(高度に専門的な医療/法律テキスト)
  • 十分なGPUリソースがある場合
  • 最大限の性能が絶対的に必要な場合
  • 新しいテキストコーパスでの継続的事前学習

2.4 破滅的忘却

フルファインチューニングの最大のリスクは破滅的忘却です:新しいデータでの学習が以前に習得した知識を劣化させます。

緩和策:

  • 低学習率: 既存の知識を保持するために1e-5以下を使用
  • データ混合: 元の事前学習データと新しいデータを混合
  • EWC(Elastic Weight Consolidation): 重要な重みへの変更を正則化
  • PEFTを使用: 新しいパラメータのみを学習することで破滅的忘却がない
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset
import torch

# フルファインチューニングの例
model_name = "meta-llama/Llama-3.2-1B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

dataset = load_dataset("your_dataset")

def tokenize_function(examples):
    return tokenizer(
        examples["text"],
        truncation=True,
        max_length=2048,
        padding="max_length",
    )

tokenized_dataset = dataset.map(tokenize_function, batched=True)

training_args = TrainingArguments(
    output_dir="./full_ft_output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-5,          # 知識を保持するために低LR
    weight_decay=0.01,
    bf16=True,
    logging_steps=100,
    save_steps=500,
    evaluation_strategy="steps",
    eval_steps=500,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset["train"],
    eval_dataset=tokenized_dataset["validation"],
)

trainer.train()

3. LoRA(Low-Rank Adaptation)

3.1 核心的なアイデア

LoRA(Hu et al., 2021)はLLMファインチューニングのゲームチェンジャーです。重要な観察:ファインチューニング中の重みの変化には本質的に低ランク性がある

完全な重み行列W(d×k)を更新する代わりに、2つの小さな行列B(d×r)とA(r×k)の積として変化を表現します:

W' = W + delta_W = W + B * A

ここでrはランクであり、dとkの両方より十分に小さく設定されます。

パラメータ数の比較:

  • 元のdelta_W: d × k パラメータ
  • LoRA B + A: r × (d + k) パラメータ
  • d=k=4096, r=16の場合: 16,777,216 vs 131,072 — 128倍の削減!

3.2 数式の詳細

初期化:

  • A: ガウス乱数初期化
  • B: ゼロ初期化(学習開始時にdelta_W = 0を保証)

順伝播:

h = x * W^T + x * (B * A)^T * (alpha / r)

alpha/rの比率はLoRA更新の学習率乗数として機能します。

3.3 ランクrの選択

ランクrはLoRAの最も重要なハイパーパラメータです:

  • r=4またはr=8: 軽量な実験、シンプルなタスク
  • r=16: ほとんどのタスクで良いバランス — 推奨スタート地点
  • r=32またはr=64: より大きなキャパシティが必要な複雑なタスク
  • r=128+: フルファインチューニングに近い性能が必要な場合

r=16から始めて、性能が不十分な場合は増やします。

3.4 alphaハイパーパラメータ

AlphaはLoRA更新の大きさをスケールします。適用される実際のスケール係数はalpha/rです:

  • alpha = r: スケール係数 = 1(一般的な選択)
  • alpha = 2r: スケール係数 = 2(より強いLoRA更新)
  • 学習率とは独立して調整できる

3.5 LoRAを適用する層は?

元の論文はQとV射影行列のみにLoRAを適用しました。実験では:

  • Q, K, V, O(すべてのアテンション射影): 一般的に良い
  • + MLP層: 複雑なタスクでしばしばより良い
  • すべての線形層: フルファインチューニングに近い性能

HuggingFace PEFTのデフォルトはQとVです。複雑なタスクにはすべての線形層への適用が推奨されます。

3.6 HuggingFace PEFTを使ったLoRA

from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, TaskType
import torch

# LoRA設定
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,                 # alpha = 2*r
    target_modules=[
        "q_proj",
        "k_proj",
        "v_proj",
        "o_proj",
        "gate_proj",
        "up_proj",
        "down_proj",
    ],
    lora_dropout=0.05,
    bias="none",
    task_type=TaskType.CAUSAL_LM,
)

model_name = "meta-llama/Llama-3.2-3B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

# LoRAモデルを作成
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 出力例: trainable params: 41,943,040 || all params: 3,254,779,904 || trainable%: 1.29%

4. QLoRA(Quantized LoRA)

4.1 QLoRAとは?

QLoRA(Dettmers et al., 2023)は4ビット量子化されたベースモデルの上にLoRAアダプターを学習することで、LoRAをさらにメモリ効率的にします。

3つのコア技術:

  1. 4ビットNF4量子化: ベースモデルの重みを4ビットに圧縮
  2. ダブル量子化: 量子化定数自体を量子化
  3. ページドオプティマイザー: CPU RAMとGPU間でオプティマイザー状態をページング

4.2 4ビットNF4量子化

NF4(NormalFloat4)は正規分布の重みに最適化された4ビットデータ型です(LLMの重みはこれに従う傾向があります)。

NF4は正規分布データに対して情報理論的に最適です:各量子化ビンが等しい確率質量をカバーします。

メモリ節約:

  • FP16 → INT4: 4倍の圧縮
  • 70Bモデル: 140GB(FP16)→ 35GB(4ビット)— 2×24GBのコンシューマーGPUで学習可能

4.3 ダブル量子化

量子化定数自体がメモリを占有します(64重みのグループごとに約32ビット)。ダブル量子化はこれらの定数を8ビットに量子化し、パラメータあたり約0.37ビットを節約します。

4.4 ページドオプティマイザー

長いシーケンスの学習中、ピークメモリのスパイクがOOMエラーを引き起こす可能性があります。ページドオプティマイザーはNVIDIAのユニファイドメモリを使用して、GPUメモリが満杯になると自動的にオプティマイザー状態をCPU RAMにオフロードし、必要になったときにページバックします。

4.5 完全なQLoRA学習コード

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
import torch

# 4ビット量子化設定
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# 4ビットでモデルをロード
model_name = "meta-llama/Llama-3.1-8B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)

tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

# kビット学習のためにモデルを準備
model = prepare_model_for_kbit_training(model)

# LoRA設定
lora_config = LoraConfig(
    r=64,
    lora_alpha=16,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=0.1,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# データセットの準備 - Alpacaフォーマット
dataset = load_dataset("tatsu-lab/alpaca", split="train")

def format_instruction(sample):
    instruction = sample["instruction"]
    input_text = sample.get("input", "")
    output = sample["output"]

    if input_text:
        text = f"""### Instruction:
{instruction}

### Input:
{input_text}

### Response:
{output}"""
    else:
        text = f"""### Instruction:
{instruction}

### Response:
{output}"""

    return {"text": text}

formatted_dataset = dataset.map(format_instruction)

# 学習設定
training_args = TrainingArguments(
    output_dir="./qlora_output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    gradient_checkpointing=True,       # メモリ節約
    optim="paged_adamw_32bit",         # ページドオプティマイザー!
    logging_steps=25,
    save_strategy="epoch",
    learning_rate=2e-4,
    bf16=True,
    max_grad_norm=0.3,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    report_to="wandb",
    run_name="llama-3-qlora",
)

# 応答トークンのみで損失を計算
response_template = "### Response:"
collator = DataCollatorForCompletionOnlyLM(response_template, tokenizer=tokenizer)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=formatted_dataset,
    data_collator=collator,
    tokenizer=tokenizer,
    max_seq_length=2048,
    dataset_text_field="text",
    packing=False,
)

trainer.train()
trainer.save_model("./qlora_adapter")
tokenizer.save_pretrained("./qlora_adapter")

5. その他のPEFT手法

5.1 プレフィックスチューニング

プレフィックスチューニングは、すべてのTransformer層のKとVに学習可能な「仮想トークン」埋め込みを先頭に追加します。ベースモデルの重みは完全に凍結されます。

from peft import PrefixTuningConfig, get_peft_model

prefix_config = PrefixTuningConfig(
    task_type="CAUSAL_LM",
    num_virtual_tokens=20,
    prefix_projection=True,   # MLPを通じてプレフィックスを射影
)

model = get_peft_model(base_model, prefix_config)

プレフィックスチューニングはseq2seqタスクでは良い性能を示しますが、一般的にLoRAには及びません。

5.2 プロンプトチューニング

最もシンプルなPEFT手法。入力の前に学習可能なソフトプロンプト埋め込みを追加するだけで、モデル自体は変更しません。

from peft import PromptTuningConfig, PromptTuningInit

prompt_config = PromptTuningConfig(
    task_type="CAUSAL_LM",
    prompt_tuning_init=PromptTuningInit.TEXT,
    num_virtual_tokens=8,
    prompt_tuning_init_text="Classify the sentiment:",
    tokenizer_name_or_path=model_name,
)

モデルサイズが大きくなるほど性能が向上します。非常にパラメータ効率的ですが、性能の天井は低いです。

5.3 IA3

IA3はLoRAと同様の性能を約1/10のパラメータで達成します。K、V、FFNアクティベーションに学習済みベクトルを掛け合わせます。

from peft import IA3Config

ia3_config = IA3Config(
    task_type="CAUSAL_LM",
    target_modules=["k_proj", "v_proj", "down_proj"],
    feedforward_modules=["down_proj"],
)

5.4 アダプター

アダプター層は各Transformer層内に小さなボトルネックモジュール(ダウン射影 → 非線形活性化 → アップ射影)を挿入します。LoRAより前に提案されており、ベースモデルの重みにマージできるLoRAと比べて若干の推論レイテンシが発生します。


6. インストラクションチューニング

6.1 インストラクションチューニングとは?

ベースLLMはテキストを継続するように学習されています(指示に従うためではありません)。インストラクションチューニングは、モデルが役立つことを学習するために指示-応答ペアでモデルをファインチューニングします。Stanford Alpaca(2023年)がこのアプローチを普及させました。

6.2 データセットフォーマット

Alpacaフォーマット:

{
  "instruction": "2つの数の最大公約数を求めなさい。",
  "input": "24, 36",
  "output": "24と36のGCDは12です。\n\n計算:\n- 24 = 2^3 * 3\n- 36 = 2^2 * 3^2\n- GCD = 2^2 * 3 = 12"
}

ChatMLフォーマット(OpenAI標準):

<|im_start|>system
あなたは役立つAIアシスタントです。<|im_end|>
<|im_start|>user
Pythonでクイックソートを実装してください。<|im_end|>
<|im_start|>assistant
Pythonでのクイックソートの実装を以下に示します...

ShareGPTフォーマット(会話形式):

{
  "conversations": [
    { "from": "human", "value": "質問テキスト" },
    { "from": "gpt", "value": "回答テキスト" },
    { "from": "human", "value": "フォローアップの質問" },
    { "from": "gpt", "value": "フォローアップの回答" }
  ]
}

6.3 量より質

LIMA論文(Less is More for Alignment, 2023)は、わずか1,000の高品質なサンプルだけで強力な指示追従能力を生み出すのに十分であることを示しました。量よりも質がはるかに重要です。

高品質なインストラクションデータの基準:

  • 多様性: 多くのタスクタイプとドメインをカバーする
  • 明確さ: 指示が曖昧でない
  • 正確さ: 応答が事実的に正確
  • 一貫性: 同様の指示に対して同じ応答スタイル
  • 適切な長さ: 必要な分だけ

6.4 フォーマットとトークン化

def format_alpaca_prompt(sample: dict) -> str:
    instruction = sample["instruction"]
    input_text = sample.get("input", "")
    output = sample["output"]

    if input_text:
        return f"""### Instruction:
{instruction}

### Input:
{input_text}

### Response:
{output}"""
    else:
        return f"""### Instruction:
{instruction}

### Response:
{output}"""


def tokenize_with_label_masking(sample, tokenizer, max_length=2048):
    """応答前のすべてをマスク — 回答のみで損失を計算"""
    full_text = sample["text"]
    tokenized = tokenizer(
        full_text, truncation=True, max_length=max_length, return_tensors="pt"
    )

    input_ids = tokenized["input_ids"][0]
    labels = input_ids.clone()

    response_start_str = "### Response:"
    response_token_ids = tokenizer.encode(response_start_str, add_special_tokens=False)

    for i in range(len(input_ids) - len(response_token_ids)):
        if input_ids[i:i+len(response_token_ids)].tolist() == response_token_ids:
            labels[:i + len(response_token_ids)] = -100
            break

    return {"input_ids": input_ids, "labels": labels}

7. RLHF(人間のフィードバックによる強化学習)

7.1 RLHFの概要

RLHFはChatGPT、Claude、Geminの背後にあるアライメント技術です。人間の好みの判断から学習することで、モデルをより役立ち、無害で、誠実になるように学習させます。

3段階のパイプライン:

  1. SFT(教師ありファインチューニング): 高品質なデモンストレーションデータでファインチューニング
  2. 報酬モデルの学習: 応答の品質をスコアリングするモデルを学習
  3. RL最適化(PPO): 報酬シグナルを使ってポリシー(LLM)を最適化

7.2 ステージ1: 教師ありファインチューニング

高品質な会話データでベースモデルをファインチューニングします。このステージで基本的な指示追従を教えます。

from trl import SFTTrainer, SFTConfig
from peft import LoraConfig

sft_config = SFTConfig(
    output_dir="./sft_model",
    max_seq_length=2048,
    num_train_epochs=1,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,
    learning_rate=2e-4,
    bf16=True,
    optim="adamw_torch_fused",
    logging_steps=10,
    save_steps=100,
    warmup_ratio=0.05,
    dataset_text_field="text",
)

trainer = SFTTrainer(
    model=model,
    args=sft_config,
    train_dataset=sft_dataset,
    peft_config=lora_config,
    tokenizer=tokenizer,
)

trainer.train()
trainer.save_model("./sft_model")

7.3 ステージ2: 報酬モデルの学習

報酬モデル(RM)は2つの応答の人間による比較から学習します。スカラー報酬を出力するための線形ヘッドが追加された同じLLMアーキテクチャを使用します。

好みデータフォーマット:

{
  "prompt": "気候変動について説明してください",
  "chosen": "気候変動とは、地球の長期的な気温変化のことで...(詳細で正確)",
  "rejected": "地球が温かくなっているだけです。(曖昧で不完全)"
}
from trl import RewardTrainer, RewardConfig

reward_config = RewardConfig(
    output_dir="./reward_model",
    num_train_epochs=1,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=1e-5,
    bf16=True,
    max_length=2048,
    logging_steps=10,
    remove_unused_columns=False,
)

reward_trainer = RewardTrainer(
    model=reward_model,    # SFTモデルをベースとする
    args=reward_config,
    train_dataset=preference_dataset,
    tokenizer=tokenizer,
    peft_config=lora_config,
)

reward_trainer.train()

7.4 ステージ3: PPOポリシー最適化

PPO(Proximal Policy Optimization)は報酬シグナルを使ってSFTモデルを最適化します。

コア目標関数:

L = E[r(x, y)] - beta * KL(pi_theta || pi_ref)
  • r(x, y): 報酬モデルからの報酬
  • KL発散ペナルティ: ポリシーがSFT参照からかけ離れすぎるのを防ぐ
from trl import PPOTrainer, PPOConfig, AutoModelForCausalLMWithValueHead

ppo_config = PPOConfig(
    model_name=sft_model_path,
    learning_rate=1.41e-5,
    batch_size=128,
    mini_batch_size=4,
    gradient_accumulation_steps=1,
    optimize_cuda_cache=True,
    early_stopping=True,
    target_kl=0.1,
    ppo_epochs=4,
    seed=42,
    init_kl_coef=0.2,
    adap_kl_ctrl=True,
)

policy_model = AutoModelForCausalLMWithValueHead.from_pretrained(sft_model_path)
ref_model = AutoModelForCausalLMWithValueHead.from_pretrained(sft_model_path)

ppo_trainer = PPOTrainer(
    config=ppo_config,
    model=policy_model,
    ref_model=ref_model,
    tokenizer=tokenizer,
    dataset=rl_dataset,
)

# PPO学習ループ
for batch in ppo_trainer.dataloader:
    query_tensors = batch["input_ids"]

    # 1. ポリシーから応答を生成
    response_tensors = ppo_trainer.generate(
        query_tensors,
        max_new_tokens=256,
        do_sample=True,
        temperature=0.7,
    )

    # 2. 報酬モデルでスコアリング
    rewards = [
        reward_model.compute_reward(q, r)
        for q, r in zip(query_tensors, response_tensors)
    ]

    # 3. PPO更新
    stats = ppo_trainer.step(query_tensors, response_tensors, rewards)
    ppo_trainer.log_stats(stats, batch, rewards)

8. DPO(Direct Preference Optimization)

8.1 RLHFの複雑さの問題

RLHFは強力ですが複雑です:

  • 別途報酬モデルの学習が必要
  • PPOには敏感なハイパーパラメータが多い
  • 学習の不安定性が一般的
  • 同時にメモリに4つのモデルが必要(ポリシー、参照、報酬、クリティック)

8.2 DPOの洞察

Rafailov et al.(2023)は、報酬モデルなしで好みデータを直接最適化できることを証明しました。重要な洞察:最適なRLHFポリシーはポリシー自身の対数尤度比を用いて解析的に表現できます。これを代入すると、好みのペアに直接作用する損失関数が得られます:

L_DPO = -E[log sigma(
    beta * (log pi(y_w|x) - log pi_ref(y_w|x)) -
    beta * (log pi(y_l|x) - log pi_ref(y_l|x))
)]

ここで:

  • y_w: 好まれる応答(chosen)
  • y_l: 好まれない応答(rejected)
  • pi: 学習中のモデル
  • pi_ref: 参照ポリシー(凍結されたSFTモデル)
  • beta: KLペナルティ係数

8.3 好みデータフォーマット

preference_data = {
    "prompt": "Pythonでリストをソートするにはどうすればよいですよか?",
    "chosen": "Pythonにはリストをソートする2つの主な方法があります:\n\n1. sort()メソッド — インプレースでソート:\n```python\nmy_list = [3, 1, 4, 1, 5]\nmy_list.sort()  # my_listを直接変更\n```\n\n2. sorted()関数 — 新しいソート済みリストを返す:\n```python\noriginal = [3, 1, 4, 1, 5]\nsorted_list = sorted(original)  # originalは変更されない\n```\n\nどちらもreverse=Trueとカスタムソート用のkey関数をサポートしています。",
    "rejected": "list.sort()かsorted()を使います。"
}

8.4 trlを使ったDPO学習

from trl import DPOTrainer, DPOConfig
from datasets import load_dataset

dpo_config = DPOConfig(
    output_dir="./dpo_model",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=5e-7,            # DPOには非常に低いLR
    bf16=True,
    beta=0.1,                      # KLペナルティ係数
    max_length=2048,
    max_prompt_length=512,
    remove_unused_columns=False,
    logging_steps=10,
    save_steps=100,
    warmup_steps=100,
    report_to="wandb",
)

# 好みデータセットのロード
# フォーマット: {"prompt": ..., "chosen": ..., "rejected": ...}
preference_dataset = load_dataset("Anthropic/hh-rlhf", split="train")

def format_hh_rlhf(sample):
    """HH-RLHFデータセットフォーマットを解析"""
    return {
        "prompt": sample["chosen"].rsplit("\nAssistant:", 1)[0] + "\nAssistant:",
        "chosen": sample["chosen"].rsplit("\nAssistant:", 1)[1].strip(),
        "rejected": sample["rejected"].rsplit("\nAssistant:", 1)[1].strip(),
    }

formatted_dataset = preference_dataset.map(format_hh_rlhf)

# SFTモデルからDPOを開始
sft_model = AutoModelForCausalLM.from_pretrained(
    sft_model_path,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
sft_model = get_peft_model(sft_model, lora_config)

dpo_trainer = DPOTrainer(
    model=sft_model,
    ref_model=None,           # Noneは自動的に参照を作成
    args=dpo_config,
    train_dataset=formatted_dataset["train"],
    eval_dataset=formatted_dataset["test"],
    tokenizer=tokenizer,
    peft_config=lora_config,
)

dpo_trainer.train()
dpo_trainer.save_model("./dpo_final")

8.5 RLHF vs DPOの比較

特性RLHF(PPO)DPO
実装の複雑さ
メモリ上のモデル数42
学習の安定性
メモリ使用量中程度
オンライン学習可能困難
性能一般的に高い同等またはやや低い
リアルタイムフィードバック可能不可

実践的には、ほとんどのチームはその単純さからDPOから始め、DPOが不十分な場合にのみRLHFを検討します。


9. データの準備

9.1 データ品質基準

データ品質がファインチューニング性能の80%を決定します。高品質データの基準:

  1. 正確さ: 事実的な誤りがない
  2. 完全さ: 質問に完全に答えている
  3. 明確さ: 曖昧さがなく理解しやすい
  4. フォーマットの一貫性: すべての例が同じフォーマットに従う
  5. 無毒性: 有害または偏ったコンテンツがない
  6. 重複排除: 近似重複が除去されている

9.2 ChatMLフォーマット処理

def create_chatml_prompt(conversation: list) -> str:
    """マルチターン会話をChatMLフォーマットに変換"""
    messages = []
    for turn in conversation:
        role = turn["role"]    # system, user, assistant
        content = turn["content"]
        messages.append(f"<|im_start|>{role}\n{content}<|im_end|>")
    return "\n".join(messages) + "\n<|im_start|>assistant\n"


def tokenize_with_response_masking(sample, tokenizer, max_length=2048):
    """アシスタントのターンのみで損失を計算"""
    full_text = sample["text"]
    tokenized = tokenizer(
        full_text, truncation=True, max_length=max_length, return_tensors="pt"
    )

    input_ids = tokenized["input_ids"][0]
    labels = input_ids.clone()

    assistant_token_ids = tokenizer.encode(
        "<|im_start|>assistant\n", add_special_tokens=False
    )
    end_token_ids = tokenizer.encode("<|im_end|>", add_special_tokens=False)

    in_assistant = False
    i = 0
    while i < len(input_ids):
        if input_ids[i:i+len(assistant_token_ids)].tolist() == assistant_token_ids:
            in_assistant = True
            labels[i:i+len(assistant_token_ids)] = -100
            i += len(assistant_token_ids)
            continue
        if input_ids[i:i+len(end_token_ids)].tolist() == end_token_ids and in_assistant:
            in_assistant = False
        if not in_assistant:
            labels[i] = -100
        i += 1

    return {"input_ids": input_ids, "labels": labels}

9.3 データクリーニングパイプライン

from datasets import Dataset
import hashlib
import re


def deduplicate_dataset(dataset: Dataset, text_field: str = "text") -> Dataset:
    """MD5ハッシュによる近似重複の除去"""
    seen = set()
    keep = []
    for i, sample in enumerate(dataset):
        h = hashlib.md5(sample[text_field].encode()).hexdigest()
        if h not in seen:
            seen.add(h)
            keep.append(i)
    return dataset.select(keep)


def quality_filter(sample: dict) -> bool:
    """基本的な品質フィルタリング"""
    text = sample.get("output", sample.get("text", ""))
    words = text.split()

    # 短すぎる
    if len(words) < 10:
        return False

    # 不審なほど長い
    if len(words) > 2000:
        return False

    # ほとんどがURL
    url_count = sum(1 for w in words if w.startswith("http"))
    if len(words) > 0 and url_count / len(words) > 0.3:
        return False

    # ほとんどが数字
    num_count = sum(1 for w in words if re.match(r'^\d+$', w))
    if len(words) > 0 and num_count / len(words) > 0.5:
        return False

    return True


# パイプラインを実行
raw_dataset = load_dataset("your_dataset")["train"]
filtered = raw_dataset.filter(quality_filter)
deduped = deduplicate_dataset(filtered)
print(f"クリーニング後: {len(deduped)} 件(元は {len(raw_dataset)} 件)")

10. 本番ファインチューニングパイプライン

10.1 完全なLlama 3 QLoRAファインチューニング

#!/usr/bin/env python3
"""
本番Llama 3 QLoRAファインチューニングパイプライン
"""

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


# ==============================
# 設定
# ==============================
MODEL_NAME = "meta-llama/Meta-Llama-3.1-8B-Instruct"
OUTPUT_DIR = "./llama3-qlora-output"
DATASET_NAME = "iamtarun/python_code_instructions_18k_alpaca"
MAX_SEQ_LENGTH = 2048

LORA_R = 64
LORA_ALPHA = 16
LORA_DROPOUT = 0.05

BATCH_SIZE = 4
GRAD_ACCUM = 4
LEARNING_RATE = 2e-4
NUM_EPOCHS = 3

# ==============================
# W&Bの初期化
# ==============================
wandb.init(
    project="llm-finetuning",
    name=f"llama3-qlora-code",
    config={
        "model": MODEL_NAME,
        "lora_r": LORA_R,
        "lora_alpha": LORA_ALPHA,
        "lr": LEARNING_RATE,
        "epochs": NUM_EPOCHS,
    },
)

# ==============================
# 4ビット量子化
# ==============================
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# ==============================
# モデルとトークナイザーのロード
# ==============================
print(f"{MODEL_NAME} をロード中...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16,
)
model.config.use_cache = False
model.config.pretraining_tp = 1
model = prepare_model_for_kbit_training(model)

# ==============================
# LoRAのセットアップ
# ==============================
lora_config = LoraConfig(
    r=LORA_R,
    lora_alpha=LORA_ALPHA,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=LORA_DROPOUT,
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# ==============================
# データセット
# ==============================
dataset = load_dataset(DATASET_NAME, split="train")
dataset = dataset.train_test_split(test_size=0.05, seed=42)

def format_prompt(sample):
    return {
        "text": f"""### Instruction:
{sample['instruction']}

### Input:
{sample.get('input', '')}

### Response:
{sample['output']}"""
    }

train_dataset = dataset["train"].map(format_prompt)
eval_dataset = dataset["test"].map(format_prompt)

# ==============================
# 学習
# ==============================
training_config = SFTConfig(
    output_dir=OUTPUT_DIR,
    num_train_epochs=NUM_EPOCHS,
    per_device_train_batch_size=BATCH_SIZE,
    per_device_eval_batch_size=BATCH_SIZE,
    gradient_accumulation_steps=GRAD_ACCUM,
    gradient_checkpointing=True,
    optim="paged_adamw_32bit",
    learning_rate=LEARNING_RATE,
    bf16=True,
    max_grad_norm=0.3,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    logging_steps=25,
    save_strategy="epoch",
    evaluation_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    report_to="wandb",
    max_seq_length=MAX_SEQ_LENGTH,
    dataset_text_field="text",
    packing=False,
    group_by_length=True,
)

response_template = "### Response:"
collator = DataCollatorForCompletionOnlyLM(response_template, tokenizer=tokenizer)

trainer = SFTTrainer(
    model=model,
    args=training_config,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    data_collator=collator,
    tokenizer=tokenizer,
    callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
)

print("学習を開始...")
trainer.train()

print("保存中...")
trainer.save_model(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
wandb.finish()
print(f"完了!{OUTPUT_DIR} に保存しました")

10.2 LoRAアダプターのマージ

デプロイ用にLoRAの重みをベースモデルにマージします:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

# ベースモデルをロード(VRAMを節約するためCPUに)
base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.bfloat16,
    device_map="cpu",
)

peft_model = PeftModel.from_pretrained(base_model, OUTPUT_DIR)

print("LoRAの重みをマージ中...")
merged_model = peft_model.merge_and_unload()

MERGED_DIR = "./llama3-merged"
merged_model.save_pretrained(MERGED_DIR, safe_serialization=True)
tokenizer = AutoTokenizer.from_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(MERGED_DIR)

print(f"マージ済みモデルを {MERGED_DIR} に保存しました")

10.3 Ollamaでのデプロイ

# Modelfileを作成
cat > Modelfile << 'EOF'
FROM ./llama3-merged

TEMPLATE """### Instruction:
{{ .Prompt }}

### Response:
"""

PARAMETER stop "### Instruction:"
PARAMETER temperature 0.7
PARAMETER top_p 0.9
EOF

# ビルドとテスト
ollama create my-llama3 -f Modelfile
ollama run my-llama3 "Pythonでクイックソートを実装してください"

10.4 vLLMでのデプロイ

from vllm import LLM, SamplingParams

llm = LLM(
    model="./llama3-merged",
    dtype="bfloat16",
    max_model_len=4096,
    tensor_parallel_size=1,
)

sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
)

prompts = [
    "### Instruction:\nPythonで二分探索木を実装してください\n\n### Response:\n",
    "### Instruction:\nCAPの定理を説明してください\n\n### Response:\n",
]

outputs = llm.generate(prompts, sampling_params)
for output in outputs:
    print(output.outputs[0].text)
    print("---")

11. 評価

11.1 パープレキシティ

基本的な言語モデルのメトリクス。低いほど良い。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import math


def compute_perplexity(
    model,
    tokenizer,
    texts: list,
    max_length: int = 1024,
    stride: int = 512,
) -> float:
    """スライディングウィンドウによるパープレキシティの計算"""
    combined_text = "\n\n".join(texts[:100])
    encodings = tokenizer(combined_text, return_tensors="pt")
    seq_len = encodings.input_ids.size(1)
    nlls = []

    for begin_loc in range(0, seq_len, stride):
        end_loc = min(begin_loc + max_length, seq_len)
        trg_len = end_loc - begin_loc - (stride if begin_loc > 0 else 0)

        input_ids = encodings.input_ids[:, begin_loc:end_loc].to(model.device)
        target_ids = input_ids.clone()
        target_ids[:, :-trg_len] = -100

        with torch.no_grad():
            outputs = model(input_ids, labels=target_ids)
            neg_log_likelihood = outputs.loss * trg_len

        nlls.append(neg_log_likelihood)
        if end_loc == seq_len:
            break

    ppl = torch.exp(torch.stack(nlls).sum() / end_loc)
    return ppl.item()

11.2 ROUGE(要約評価)

from rouge_score import rouge_scorer


def evaluate_rouge(predictions: list, references: list) -> dict:
    scorer = rouge_scorer.RougeScorer(
        ['rouge1', 'rouge2', 'rougeL'], use_stemmer=True
    )
    scores = {"rouge1": [], "rouge2": [], "rougeL": []}

    for pred, ref in zip(predictions, references):
        score = scorer.score(ref, pred)
        scores["rouge1"].append(score["rouge1"].fmeasure)
        scores["rouge2"].append(score["rouge2"].fmeasure)
        scores["rougeL"].append(score["rougeL"].fmeasure)

    return {k: sum(v) / len(v) for k, v in scores.items()}

11.3 LM-Eval Harness

自動ベンチマーク評価のためのOpenLM Researchのオープンソース評価フレームワーク:

pip install lm-eval

lm_eval --model hf \
    --model_args pretrained=./llama3-merged,dtype=bfloat16 \
    --tasks hellaswag,arc_easy,arc_challenge,winogrande,mmlu \
    --num_fewshot 0 \
    --batch_size 8 \
    --output_path ./eval_results

11.4 MT-Bench

GPT-4をジャッジとして使い、マルチターン会話の品質を1〜10でスコアリングします:

pip install fschat

# モデルの回答を生成
python -m fastchat.llm_judge.gen_model_answer \
    --model-path ./llama3-merged \
    --model-id llama3-finetuned \
    --bench-name mt_bench

# GPT-4による評価
python -m fastchat.llm_judge.gen_judgment \
    --model-list llama3-finetuned \
    --judge-model gpt-4

# 結果を表示
python -m fastchat.llm_judge.show_result \
    --model-list llama3-finetuned

まとめ

LLMファインチューニングの状況は劇的に変わりました。数年前は大規模なGPUクラスターが必要だった学習が、今では単一のコンシューマーGPUでできるようになっています。

カバーしたコア技術:

  1. フルファインチューニング: 最大の性能、最大のリソース
  2. LoRA: 低ランク行列分解による99%以上のパラメータ削減
  3. QLoRA: 4ビット量子化 + LoRA — 1つのGPUで7B〜70Bの学習を可能にする
  4. インストラクションチューニング: 指示追従能力の教授
  5. RLHF: 3段階パイプラインによる人間の好みとのアライメント
  6. DPO: 報酬モデルなしの直接的な好み最適化

実践的な推奨事項:

  • QLoRA + DPOから始める — ほとんどのチームにとって最も実用的な組み合わせ
  • ハイパーパラメータチューニングよりもデータ品質に時間を投資する
  • 1,000件の高品質なサンプルは10,000件の低品質なサンプルより優れている
  • Weights and Biasesですべての実験を追跡する
  • パープレキシティの改善が必ずしも良いユーザー体験を意味しない — MT-Benchで検証する

参考文献