fine-tuningが本当に必要なのはどんな時か?
あるプロジェクトで、カスタマーサポートのメール分類にGPT-4を使ったことがある。精度は申し分なかったが、毎月のAPIコストが数百ドルに上っていた。実際のデータ2000サンプルでMistral 7Bをfine-tuneしたところ、精度はほぼ同等のままで推論コストを90%削減できた。
着手する前に、まず自問してみてほしい。この問題は本当にfine-tuningが必要か?次のような場合に投資する価値がある:
- ベースモデルがドメイン固有の語彙を理解できない場合(医療、法律、専門技術など)
- プロンプトエンジニアリングだけでは安定しない固定フォーマットの出力が必要な場合
- 品質を維持しながら小さなモデルを使ってコストを削減したい場合
- 訓練データが機密性が高く、サードパーティのAPIに送信できない場合
文体や回答スタイルを変えたいだけなら、まずプロンプトエンジニアリングやfew-shot learningを試してほしい。速くて手間も少なく、ほとんどのケースで十分対応できる。
始める前に押さえておくべき基本概念
Full fine-tuning vs Parameter-Efficient Fine-Tuning (PEFT)
Full fine-tuningはモデルの全重みを更新する。Mistral 7Bの場合、最低でも40GB以上のVRAMが必要で、個人のマシンや通常のVMでは現実的でない。そのため、現在ほとんどの実務プロジェクトは最も普及したPEFT技術であるLoRA(Low-Rank Adaptation)に移行している。
LoRAのアイデアはシンプルながら巧妙だ。巨大な重み行列Wを直接更新するのではなく、ΔW = A × Bとなるような小さな2つの行列AとBを追加する。訓練するパラメータ数はfull fine-tuningの0.3〜10%程度に減るが、結果はほぼ同等だ。
データセットのフォーマットはなぜ重要なのか?
ここが多くの人がつまずく箇所だ。モデルは生のテキストから学習するのではなく、各モデルの標準テンプレートに沿ってフォーマットされた(instruction, response)のペアから学ぶ。テンプレートを誤ると、モデルは何も学習できないか、おかしな出力を返すことになる。
Alpacaフォーマットの例(多くのinstruction-tuned modelで使用):
{
"instruction": "このメールをカテゴリに分類してください: complaint, question, feedback",
"input": "注文して3日経ちますが、まだ届いていません。キャンセルできますか?",
"output": "complaint"
}
こちらはChatMLフォーマット(Mistral、Qwen、より新しいモデルで使用):
{
"messages": [
{"role": "system", "content": "あなたはカスタマーサポートメール分類の専門家です。"},
{"role": "user", "content": "分類してください: 注文して3日経ちますが、まだ届いていません。"},
{"role": "assistant", "content": "complaint"}
]
}
実践: LoRAを使ったテキスト分類モデルのfine-tuning
ステップ1: 環境の準備
最初の試みにはGoogle Colab(無料T4 GPU)またはKaggle Notebooksを使うことをおすすめする。慣れてきたら、長時間のトレーニングが必要な場合はRunPodやLambda Labsに移行するといい。
pip install transformers datasets peft trl accelerate bitsandbytes
注意: bitsandbytesは4ビット形式(QLoRA)でのモデル読み込みを可能にし、通常は40GB以上必要なMistral 7BのトレーニングをVRAM 16GBのGPUで実行できるようにする。
ステップ2: データセットの準備
データはサイズより品質が重要だ。良質なサンプル500件は、ノイズの多い5000件より良い結果をもたらすことが多い。私の経験では、時間の60%をデータのクリーニングと正規化に費やし、残り40%でトレーニングコードを書くというバランスがよい。
from datasets import Dataset
import json
# JSONLファイルからデータを読み込む
with open("email_data.jsonl", "r") as f:
raw_data = [json.loads(line) for line in f]
# Hugging Face Datasetを作成する
dataset = Dataset.from_list(raw_data)
# train/testに分割する (80/20)
dataset = dataset.train_test_split(test_size=0.2, seed=42)
print(dataset)
ステップ3: 4ビット量子化でモデルを読み込む(QLoRA)
QLoRAはこのステップで機能する。fp16ではなく4ビット形式でモデルを読み込むことで、VRAMを約14GBから4〜5GB程度まで削減できる:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_name = "mistralai/Mistral-7B-Instruct-v0.2"
# 4ビット量子化の設定
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=quant_config,
device_map="auto"
)
ステップ4: LoRAアダプタの設定
r(ランク)の値はアダプタのサイズと結果の品質に直接影響する。ほとんどのタスクではr=16から始め、タスクが複雑な場合は32〜64に増やすとよい:
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
# LoRAを追加する前にモデルを準備する必要がある
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16, # Rank — より複雑なタスクには値を大きくする (8, 16, 32, 64を試す)
lora_alpha=32, # スケーリング係数、通常は 2*r に設定する
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 結果: trainable params: 13,631,488 || all params: 3,765,600,256 || trainable%: 0.36
ステップ5: データのフォーマットとトレーニング
TRLライブラリのSFTTrainerがボイラープレートのほとんどを処理してくれる。gradient_accumulation_stepsに注目してほしい。これはVRAMが不足している時に大きなバッチサイズをシミュレートする方法だ:
from trl import SFTTrainer
from transformers import TrainingArguments
def format_prompt(sample):
return f"""[INST] {sample['instruction']}
{sample['input']} [/INST] {sample['output']}"""
training_args = TrainingArguments(
output_dir="./output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # Effective batch size = 16
learning_rate=2e-4,
fp16=True,
logging_steps=50,
save_strategy="epoch",
evaluation_strategy="epoch",
warmup_ratio=0.03,
lr_scheduler_type="cosine",
report_to="none" # wandbが未設定の場合は無効化する
)
trainer = SFTTrainer(
model=model,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
formatting_func=format_prompt,
args=training_args,
max_seq_length=1024,
)
trainer.train()
ステップ6: アダプタの保存とマージ
トレーニング完了後は、LoRAアダプタのみを保存する(数十MB程度と小さい)か、ベースモデルにマージしてデプロイしやすい形にすることができる:
# LoRAアダプタを保存する (小さく、共有しやすい)
model.save_pretrained("./my-email-classifier-adapter")
tokenizer.save_pretrained("./my-email-classifier-adapter")
# またはベースモデルにマージしてフルモデルを保存する
from peft import AutoPeftModelForCausalLM
merged_model = AutoPeftModelForCausalLM.from_pretrained(
"./my-email-classifier-adapter",
torch_dtype=torch.float16,
device_map="auto"
)
merged_model = merged_model.merge_and_unload()
merged_model.save_pretrained("./my-email-classifier-merged")
実践から得たいくつかのヒント
- トレーニングlossの監視: lossが急激に下がって早々にプラトーに達する場合、データセットが小さすぎるか学習率が高すぎるサインだ。通常はlr=2e-4から始めて徐々に調整するとよい。
- 過学習の防止: バリデーションセットは必ず用意しておくこと。eval lossが上昇する一方でtrain lossが下がり続けたら、すぐに停止してよい。追加のトレーニングは不要だ。
- Gradient checkpointing: TrainingArgumentsに
gradient_checkpointing=Trueを追加するとVRAMを削減できる。トレードオフとしてトレーニング速度が約20%遅くなる。 - エポック数: サンプル数1000未満の小さなデータセットには3〜5エポックで十分なことが多い。大きなデータセットなら1〜2エポックが妥当だ。
まとめ: 小さく始め、しっかり測定する
何度もfine-tuningを経験してきて学んだことがある。最も難しい部分はコードでもハイパーパラメータでもなく、データだ。遭遇する問題の80%は、汚いデータ、誤ったラベル、またはtrainとtest setの間の分布のずれに起因している。
現実的なロードマップ: 200〜500サンプルから始め、Colabで試験的にトレーニングし、テストセットで丁寧に評価する。モデルが安定したら、データを増やしてより強力なGPUに移行する。本番デプロイには、小規模チームならOllama、高スループットが必要ならvLLMが適している。
データクリーニングスクリプト、トレーニングループ、推論を含む完全なソースコードはGitHubに公開している。同様の問題に取り組んでいる方や、トレーニング中に不思議なエラーに遭遇した方は、ぜひコメントを残してほしい。

