Skip to content
Published on

ファインチューニング実践ガイド:LoRAとQLoRAで自分専用モデルを作る

Authors

なぜFull Fine-tuningではなくLoRAなのか?

ファインチューニングに興味を持つと、最初にぶつかる壁があります。それがVRAM要件です。

Full Fine-tuning Llama 3.1 70B:
- 必要VRAM:約560GB(FP32H100 80GB換算で7枚必要
- 学習時間:数日
- クラウドコスト:数千ドル

LoRA Fine-tuning Llama 3.1 70B:
- 必要VRAM:約48GB(QLoRA:約20GB!)
RTX 3090 1枚で可能
- 学習時間:数時間(GPU1枚)
- クラウドコスト:$20〜$100(A100レンタル)

この差はどうやって生まれるのでしょうか?LoRAのコアアイデアを理解すれば明確になります。


LoRAの仕組み:直感的な説明

Full Fine-tuningはモデルの全重みを更新します。Llama 3.1 70Bなら700億個のパラメータが全て変わります。これを保存・最適化するには膨大なメモリが必要です。

LoRA(Low-Rank Adaptation)は別のアプローチを取ります:

Full Fine-tuning:
W_new = W_original + delta_W
(delta_Wは元の行列と同じサイズ = 70Bパラメータの更新)

LoRA:delta_Wを2つの小さな行列に分解
delta_W = A x B
  A(d x r) 行列、B(r x d) 行列
  r = rank(通常464、小さいほどパラメータ節約)

例:d=4096、r=16の場合
- Full delta_W:4096 x 4096 = 16.7Mパラメータ
- LoRA delta_W:A(4096x16) + B(16x4096) = 131Kパラメータ
- 128倍少ないパラメータで同等の効果!

学習中:W_originalを凍結(freeze)し、ABのみ学習
推論時:W_new = W_original + A x B(マージまたは分離維持)

数学的には、ほとんどの重み更新は低ランク(low-rank)構造を持つという仮説が実験的に検証されています。つまり、全パラメータを変更する必要はないということです。


QLoRA:さらに少ないメモリで

QLoRA = LoRA + 4ビット量子化されたベースモデル

通常のLoRA:
- ベースモデル:FP16
- LoRAアダプター:FP16
- 70BモデルのVRAM:約140GB

QLoRA:
- ベースモデル:4ビット(NF4量子化)
- LoRAアダプター:BF16/FP16(フル精度維持)
- 70BモデルのVRAM:約20GB(!!)

4ビット量子化による品質低下は驚くほど小さく、ファインチューニング後はさらに軽減されます。QLoRAは2023年のTim Dettmersらの論文で発表され、LLMファインチューニングの民主化を実現しました。


実践コード:Llama 3.1 8Bファインチューニング(最初から最後まで)

Hugging Faceエコシステムを活用した実際に動くコードです。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset, Dataset

# ============================================================
# ステップ1:4ビット量子化でモデルをロード(QLoRA)
# ============================================================
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",              # NF4はFP4より品質が優れる
    bnb_4bit_compute_dtype=torch.bfloat16,  # 演算はBF16で
    bnb_4bit_use_double_quant=True          # 追加メモリ節約
)

model_name = "meta-llama/Meta-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"  # 重要:左パディングは学習不安定の原因

# ============================================================
# ステップ2:LoRAの設定
# ============================================================
lora_config = LoraConfig(
    r=16,                   # rank:大きいほど表現力高いがVRAM増加
    lora_alpha=32,          # スケーリング係数(通常rankの2倍)
    target_modules=[        # LoRAを適用するレイヤー
        "q_proj", "v_proj", "k_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"  # MLPレイヤーも含める
    ],
    lora_dropout=0.05,
    bias="none",
    task_type=TaskType.CAUSAL_LM
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 出力例:trainable params: 167,772,160 || all params: 8,201,441,280 || trainable%: 2.05
# 全体の2%のみ学習、残り98%は凍結

# ============================================================
# ステップ3:データセットの準備
# ============================================================
raw_data = [
    {
        "instruction": "以下のカスタマーレビューの感情を分類してください。",
        "input": "配送が3日も遅れたのに何の連絡もありませんでした。本当に残念です。",
        "output": "ネガティブ(不満、失望)"
    },
    # ... さらに多くの例
]

def format_instruction(example):
    """Llama 3.1インストラクション形式に変換"""
    if example.get("input"):
        text = f"""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
あなたは役立つAIアシスタントです。<|eot_id|><|start_header_id|>user<|end_header_id|>
{example['instruction']}

{example['input']}<|eot_id|><|start_header_id|>assistant<|end_header_id|>
{example['output']}<|eot_id|>"""
    else:
        text = f"""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
あなたは役立つAIアシスタントです。<|eot_id|><|start_header_id|>user<|end_header_id|>
{example['instruction']}<|eot_id|><|start_header_id|>assistant<|end_header_id|>
{example['output']}<|eot_id|>"""
    return {"text": text}

dataset = Dataset.from_list(raw_data)
dataset = dataset.map(format_instruction)
train_test = dataset.train_test_split(test_size=0.1)

# ============================================================
# ステップ4:学習
# ============================================================
training_args = SFTConfig(
    output_dir="./lora-output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,     # 有効バッチサイズ = 4 x 4 = 16
    gradient_checkpointing=True,       # VRAM節約(速度は約20%低下)
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    fp16=True,
    logging_steps=10,
    eval_steps=50,
    save_steps=100,
    eval_strategy="steps",
    load_best_model_at_end=True,
    max_seq_length=2048,
    dataset_text_field="text",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_test["train"],
    eval_dataset=train_test["test"],
    tokenizer=tokenizer,
)

trainer.train()

# ============================================================
# ステップ5:保存と統合(オプション)
# ============================================================
# LoRAアダプターのみ保存(小さいファイル)
model.save_pretrained("./lora-adapter")
tokenizer.save_pretrained("./lora-adapter")

# オプション:ベースモデルにマージ(デプロイ用)
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./merged-model")

データセットの作成:最も重要な部分

コードよりデータの方がはるかに重要です。ファインチューニング失敗の80%はデータ問題です。

品質 vs 量

現場から学んだ教訓:1,000件の高品質な例が100,000件のノイズデータに勝ります。

# 良いデータの基準
good_data_checklist = {
    "一貫性": "同じ質問には常に同じスタイルで回答する",
    "多様性": "対象とする全ユースケースをカバーする",
    "正確性": "誤った情報が含まれていない",
    "形式": "目標モデルのレスポンス形式と一致している",
    "長さ": "短すぎず、不必要に長くない",
}

# データ品質チェック関数
def check_data_quality(examples):
    issues = []
    for i, ex in enumerate(examples):
        if len(ex["output"]) < 10:
            issues.append(f"例 {i}: レスポンスが短すぎる")
        if len(ex["output"]) > 2000:
            issues.append(f"例 {i}: レスポンスが長すぎる")
        if i > 0 and ex["output"] == examples[i-1]["output"]:
            issues.append(f"例 {i}: 重複レスポンスの可能性")
    return issues

データソース:

  • 手動作成:最もコストが高いが品質は最高
  • GPT-4生成 + 人間レビュー:バランスの取れた方法(ライセンス確認必須!)
  • プロダクションログ:実際の使用パターンを反映するが要クレンジング
  • 公開データセット + カスタム組み合わせ:効率的

Unsloth:2倍速いLoRA学習

UnslothはカーネルレベルでのLoRA学習最適化ライブラリです。同じハードウェアで標準PEFTより2倍速く、VRAMも70%少ない使用量です。

from unsloth import FastLanguageModel
import torch

# 標準transformers + PEFTよりはるかに高速
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Meta-Llama-3.1-8B-Instruct",
    max_seq_length=2048,
    load_in_4bit=True,
)

# Unsloth最適化を適用したLoRA設定
model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    lora_alpha=16,
    lora_dropout=0,     # Unslothは0を推奨
    bias="none",
    use_gradient_checkpointing="unsloth",  # Unsloth最適化チェックポイント
    random_state=3407,
)

速度向上は制限されたGPUで特に顕著です。公式UnslothのGitHubでモデル別の最適設定を確認してください。


ハイパーパラメータチューニングガイド

結果が期待通りでない場合に最初に確認すること:

学習率(Learning Rate):
- 高すぎる:壊滅的忘却(既存能力の喪失)
- 低すぎる:目標動作が学習できない
- 推奨範囲:1e-43e-4(LoRAはFull FTより高めに設定可能)

LoRA Rank(r):
- r=4:高速・軽量、シンプルなタスク
- r=8:ほとんどのユースケースの出発点
- r=16:複雑なタスクやスタイル学習
- r=64:Full FT並みの品質、VRAMが増加

エポック数:
- 小規模データ(1,000件未満):35エポック
- 中規模データ(1,00010,000件):13エポック
- 大規模データ(10,000件超):1エポックでも十分

有効バッチサイズ = per_device_batch x gradient_accumulation:
- 小さすぎる:学習不安定
- 大きすぎる:過学習のリスク
- 通常1632を推奨

よくある失敗と対処法

失敗1:壊滅的忘却(Catastrophic Forgetting)

ファインチューニング後、モデルが汎用能力を失う現象。

対処法:汎用目的の例を訓練データに10〜20%混ぜる(リハーサルミキシング)。

失敗2:過学習(Overfitting)

学習lossは下がり続けるが実際の性能が低下する。

# Early stoppingを使用
training_args = SFTConfig(
    ...
    eval_strategy="steps",
    eval_steps=50,
    load_best_model_at_end=True,   # 最良チェックポイントを自動選択
    metric_for_best_model="eval_loss",
    greater_is_better=False,
)

失敗3:チャットテンプレートの不一致

モデルごとに期待するチャット形式が異なります。Llama 3.1、Mistral、Qwenはそれぞれ形式が違います。

# 手動フォーマットではなくapply_chat_templateを使用
messages = [
    {"role": "system", "content": "あなたはプロの翻訳者です。"},
    {"role": "user", "content": "以下を英語に翻訳してください:こんにちは"},
    {"role": "assistant", "content": "Hello."}
]
formatted = tokenizer.apply_chat_template(messages, tokenize=False)

ファインチューニング vs プロンプトエンジニアリング:いつ必要か

ファインチューニングは万能ではありません。以下の場合に検討してください:

ファインチューニングが必要な場合:

  • 特定ドメイン専門知識の注入(医療、法律、社内文書)
  • レスポンス形式・スタイルの一貫性(常にJSON、特定の話し方)
  • プロンプトだけでは達成できない動作変化
  • コスト削減(小さなファインチューニングモデルが大きなGPT-4より安い)

プロンプトエンジニアリングで十分な場合:

  • 標準的なタスク(要約、翻訳、Q&A)
  • 迅速なプロトタイピング
  • 十分なデータがない場合

一般的なアドバイス:まずプロンプトエンジニアリングを最大限に試し、限界に達したときにファインチューニングを検討しましょう。


まとめ

LoRAとQLoRAは、LLMファインチューニングを一部の大企業の専有物から、全ての開発者のツールへと変えました。

重要なポイント:

  • QLoRA:20GB VRAMで70Bモデルのファインチューニングが可能
  • データ品質:コードより重要。1,000件の良い例があれば十分なスタート地点
  • Unsloth:同じハードウェアで2倍速い学習
  • まず小さく始める:8Bモデルで概念実証し、その後70Bに拡張

自分でファインチューニングしたモデルが特定タスクでGPT-4を上回る体験をしてみてください。その達成感はかなりのものです。