メインコンテンツまでスキップ

Instruction Tuningとは?

概要 (画像は、Geminiで作成されたものです)

Instruction Tuningの概要

Instruction Tuning(インストラクション・チューニング)は、大規模言語モデル(LLM)のゼロショット学習能力を向上させるためのシンプルで強力な手法です。

モデルに対して、自然言語の「指示(Instruction)」の形式で記述された多数のデータセットを用いてファインチューニングを行うことで、学習時に見たことのない未知のタスク(unseen tasks)に対する性能を劇的に向上させることができます。

Googleの研究チームによって提案された「FLAN(Finetuned Language Net)」というモデルで実証され、137B(1370億)パラメータの事前学習済みモデルにこの手法を適用することで、ゼロショット性能が大幅に向上し、評価した25のタスクのうち20のタスクで175BのGPT-3のゼロショット性能を上回る結果を示しました。現在では、Supervised Fine-tuning (SFT) とも呼ばれ、LLMを人間の意図に沿わせる(アライメント)ための最も基本的な手法として広く普及しています。

これまでのパラダイムの課題とInstruction Tuningの位置づけ

言語モデルの活用において、FLAN提案以前には主に2つのパラダイムが存在していましたが、それぞれに課題がありました。

  1. Pretrain-finetune パラダイム(BERTやT5など): 事前学習済みモデルを特定のタスクごとにファインチューニングする手法です。高い性能を発揮しますが、タスクごとに多数の専用の学習データが必要であり、さらにタスクごとに個別のモデルを用意しなければならないという欠点がありました。
  2. Prompting パラダイム(GPT-3など): 少数の例をプロンプトとして与えるFew-shotプロンプティングにより、単一のモデルで様々なタスクを解くことができるようになりました。しかし、例を与えないZero-shotの性能は非常に低く、特に読解や自然言語推論のようなタスクでは苦戦していました。これは、プロンプトの形式が事前学習データ(通常のWebテキスト)の形式と似ていない場合、モデルがうまく応答できないためだと考えられています。

Instruction Tuning は、これら両者の魅力的な側面を組み合わせたアプローチです。 ファインチューニングによる「教師あり学習」の力を利用して、推論時のテキストによる指示(プロンプト)に対するモデルの応答能力自体を向上させることを目的としています。これにより、1つの汎用的なモデルで、多数のタスクをZero-shotで解くことが可能になります。

Instruction Tuningの処理概要

FLANにおけるInstruction Tuningの学習プロセスは、以下のようなステップで行われます。

  1. タスクのクラスタリング: 多様な既存のNLPデータセット(62種類)を収集し、それらをタスクのタイプ(自然言語推論、機械翻訳、常識推論、感情分析など)ごとに12の「タスククラスタ」にグループ化します。
  2. プロンプトテンプレートによる変換: 各データセットの入出力を、自然言語の指示を含む形式に変換します。多様性を高めるため、1つのデータセットにつき10個の独自のテンプレート(タスクの反転を含む)を手作業で作成し、ランダムに適用します。
  3. 混合データセットでのファインチューニング: これらすべてのタスクを混ぜ合わせたデータセットを用いて、巨大な事前学習済み言語モデル(LaMDA-PT)をファインチューニングします。
  4. 未知のタスクでの推論(Zero-shot評価): 学習時には特定のタスククラスタ(例:自然言語推論)を完全に除外しておき、推論時に初めてそのタスクの指示を与えてゼロショットで評価します。これにより「未知のタスクに対しても指示に従えるか」を検証します。

ゼロショット性能を飛躍させるメカニズムとスケーリング則

Instruction Tuningの最大のモチベーションは、「指示によって記述されたタスクを実行するようにモデルを訓練すれば、モデルは『指示に従う』こと自体を学習し、未知のタスクにもそれが適用できるだろう」という直感に基づいています。 結果として、自然言語推論、読解、クローズドブックQA、翻訳などの未知のタスククラスタにおいて、ベースモデルやGPT-3を大きく上回る性能を達成しました。

モデル規模(パラメータ数)の重要性

非常に興味深い発見として、Instruction Tuningの恩恵はモデルの規模が十分に大きい場合にのみ創発することが分かっています。 アブレーション研究(要素削減実験)において、100B(1000億)パラメータクラスのモデルでは性能が大幅に向上したのに対し、8B(80億)以下の小規模なモデルにInstruction Tuningを適用すると、未知のタスクに対する性能が逆に悪化する現象が見られました。 これは、小規模なモデルでは多数のタスクを学習することでモデルのキャパシティが埋まってしまい、新しいタスクに汎化する余裕がなくなるためと考えられています。一方、大規模モデルは容量に余裕があり、指示に従う方法を学習しつつ、未知のタスクにも汎化することが可能になります。

Instruction Tuningの構成要素(詳細)

モデルが指示を正確に理解し、望ましいフォーマットで出力できるようにするための重要な工夫がいくつかあります。

Options suffix(選択肢の提示)

分類タスク(例:感情分析や推論)では、出力空間が「Yes/No」や「Positive/Negative」などに限られますが、単なる言語生成モデルは様々な自由記述のテキストを生成してしまう可能性があります。 FLANでは、これを防ぐためにプロンプトの末尾に OPTIONS という接尾辞を追加し、モデルが応答すべき選択肢のリストを明示的に提示するアプローチをとっています。

例(自然言語推論):

Premise: At my age you will probably have learnt one lesson.
Hypothesis: It's not certain how many lessons you'll learn by your thirties.
Does the premise entail the hypothesis?
OPTIONS:
- yes
- it is not possible to tell
- no

(訳):
前提:私の年齢になれば、おそらく一つの教訓を学んでいることでしょう。
仮説:30代までにいくつの教訓を学ぶかは定かではありません。
前提は仮説を導き出しますか?
選択肢:
- はい
- 判断できません
- いいえ

多様なテンプレートの用意

1つのタスクに対して単一の定型文だけで学習すると、特定のフレーズに過剰適合してしまいます。FLANでは1つのデータセットに対して最大10種類の独自の自然言語テンプレートを作成しました。 また、例えば「映画のレビューを読んで感情を分類するタスク」に対して、逆に「感情(Positive)を入力として、それに合う映画レビューを生成させる」といったタスクを反転させたテンプレートも数個含めることで、生成の多様性を高めています。

大規模言語モデル(LLM)アライメントへの応用と発展

Instruction Tuning(SFT)は、FLANによってその有効性が示された後、ChatGPTなどの現在のLLM開発において不可欠なアライメントパイプラインの一部として急速に発展しました。

データセット構築の進化

FLANの時代(Human-crafted Data)は、既存のNLPデータセット(SNLIやSQuADなど)をプロンプト化して利用するのが主流でした。しかし現在では、より多様で高度な指示データを集めるため、以下のような手法が使われています。

  • 蒸留(Distillation)による合成データの生成:
    GPT-4のような非常に強力なモデルに指示を与え、その出力結果を収集して小規模モデル(LLaMAなど)のファインチューニングデータとして使用する手法(Alpaca、WizardLMなど)。

  • 自己改善(Self-Improvement):
    少数のシードとなる指示データから、LLM自身に新しい指示と応答を大量に生成させる手法(Self-Instruct法など)。

RLHF/DPOとInstruction Tuning (SFT) の関係

LLMを人間の好みに合わせるための技術として、RLHF(人間からのフィードバックによる強化学習)やDPO(直接選好最適化)といった強化学習ベースの手法が注目されています。しかし、これらの高度な手法を用いる場合でも、初期段階としてのInstruction Tuning (SFT) は依然として不可欠です

RLHFやDPOをゼロから(ベースの事前学習モデルに対して直接)行うのは非常に不安定で困難です。そのため、まずは高品質なSFTデータセットを用いてモデルに「ユーザーとの対話フォーマット」や「指示に従うという基本的なスタイル」を教え込み、妥当なベースポリシーを確立してから、強化学習によって微調整を行うのが一般的なパイプラインとなっています。

データセットのフォーマットと学習イメージ

Instruction Tuningで用いられるデータセットは、一般的に以下のようなJSON(またはJSONL)フォーマットで表現されます。

[
{
"instruction": "次の文をスペイン語に翻訳してください。",
"input": "新しいオフィスビルは3ヶ月足らずで建設されました。",
"output": "El nuevo edificio de oficinas se construyó en tres meses."
},
{
"instruction": "与えられた前提から、仮説が推論できるか判定してください。",
"input": "前提:私の年齢になれば、おそらく一つの教訓を学んでいるでしょう。\\n仮説:30代までにいくつの教訓を学ぶかは不確実です。\\nOPTIONS:\\n- はい\\n- 判別できない\\n- いいえ",
"output": "判別できない"
}
]

学習時には、これらの instruction と input を組み合わせてプロンプト文字列を構築し、モデルに入力します。そしてモデルが自己回帰的に output を生成できるように、通常の言語モデリングの損失関数(Cross Entropy Loss)を用いて教師あり学習(Supervised Fine-Tuning)を行います。この際、モデルが「指示(プロンプト)」の生成まで学習してしまわないよう、入力部分(指示+入力)のトークンに対する損失計算をマスク(除外)し、応答部分(出力)のみの生成に対して最適化を行うのが一般的な実装上の工夫です。

また、数百億パラメータの大規模モデル全体を学習することは計算資源の観点で現実的ではないため、実際の開発では LoRA(Low-Rank Adaptation) などのパラメータ効率的ファインチューニング(PEFT)手法と組み合わせて、モデルの一部パラメータのみを更新するアプローチが広く採られています。

Instruction Tuningの実装(概念的なシンプルな実装)

ライブラリのインストール

以下のコードでは、Instruction Tuningを実装・実行するためのPython環境を構築します。Hugging Faceの主要ライブラリ群(transformers等)に加え、限られた計算資源でも効率的に学習を行うためのPEFT(LoRA)や量子化ライブラリをインストールします。

# PEFT(LoRA)や量子化ライブラリのインストール(巨大なモデルのフルパラメータチューニングを避けるための工夫)
!pip install transformers datasets peft trl torch bitsandbytes
!pip install --upgrade torchao

ここでは、モデルの学習およびメモリ効率化に不可欠なライブラリを導入しています。

  • peft: パラメータ効率的ファインチューニング(PEFT)を実現するためのライブラリであり、後述するLoRAの設定に利用します。
  • bitsandbytes: モデルの重みを量子化(例: 8bit/4bit)し、VRAM消費を大幅に削減するためのライブラリです。
  • trl: 強化学習やSFT(Supervised Fine-Tuning)に特化したユーティリティを提供し、LLMのアライメントに役立ちます。

ライブラリのインポート、ハイパーパラメータの設定

以下のコードでは、PyTorchをはじめとする必要なモジュールをインポートし、学習プロセス全体を制御する各種ハイパーパラメータを定義します。ここでは実際の本格的な学習用ではなく、学習ループが正常に動作するかを短時間で確認するためのサニティチェック(動作確認用)の設定を行っています。

import torch # PyTorchのコアモジュール
from torch.utils.data import Dataset, DataLoader # データセット管理とバッチ作成用モジュール
from torch.optim import AdamW # 重み減衰(Weight Decay)をサポートするAdamオプティマイザ
from torch.nn.utils.rnn import pad_sequence # 系列長を揃えるパディング関数
from transformers import AutoModelForCausalLM, AutoTokenizer, get_cosine_schedule_with_warmup # HFのモデル読込とスケジューラ
from datasets import load_dataset # HF Datasetsライブラリからのデータ読み込み
from peft import get_peft_model, LoraConfig # PEFT(LoRA)を適用するための関数群

MODEL_ID = "Qwen/Qwen2.5-0.5B"
N_SAMPLES = 8 # テストに用いるデータ件数(サニティチェック用)
MAX_LENGTH = 512 # モデルに入力する最大トークン長(超える部分は切り捨て/パディング)
BATCH_SIZE = 8 # 1回のステップで処理するデータ数
ACCUM_STEPS = 1 # 勾配を蓄積するステップ数(メモリが足りない場合は増やす)
EPOCHS = 40 # 学習を繰り返す回数(今回は少ないデータで過学習させるため多め)
LR = 2e-4 # 学習率(LoRAの場合は少し高めに設定するのが一般的)
LOG_EVERY = 5 # 何ステップごとにLossをログ出力するか
IGNORE_INDEX = -100 # プロンプト部分のLoss計算をマスクするための特別な値(実装上の工夫)

ここでは、学習スクリプト全体で利用される主要なモジュールの読み込みと、サニティチェック(小規模なテスト実行)用のハイパーパラメータを定義しています。

  • MODEL_ID: 学習のベースとなる事前学習済みモデルの識別子です。ここではパラメータ数が少なくローカル環境でも動かしやすい Qwen2.5-0.5B を指定しています。
  • N_SAMPLES = 8 および EPOCHS = 40: 本格的なデータセットでの学習ではなく、「モデルを過学習(Overfitting)させることで、意図通りにLossが下がり、指定フォーマット通りに回答できるか」を数分で確認するため、極端に少ないデータ数と多めのエポック数を設定しています。
  • IGNORE_INDEX = -100 # プロンプト部分のLoss計算をマスクするための特別な値(実装上の工夫): PyTorchの交差エントロピー誤差(CrossEntropyLoss)において、損失の計算対象から除外(マスク)するための特別な値です。この定数は後続のステップで、入力プロンプト部分の学習を防ぐために使用されます。

モデルとトークナイザのロード

以下のコードでは、事前学習済みのベースモデルと、テキストをトークン列に変換するトークナイザをHugging Faceのハブからロードします。ここでは実行環境の制約を考慮し、比較的小規模な0.5Bパラメータのモデルを採用しています。

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token

# Causal LM(因果的言語モデル)をロード
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, device_map="auto")
GEN_KWARGS = dict(max_new_tokens=128, do_sample=False, repetition_penalty=1.0)

ここでは、学習対象となる因果的言語モデル(Causal LM)を初期化しています。

  • AutoModelForCausalLM.from_pretrained: 指定したモデルIDのアーキテクチャと重みを自動でロードします。device_map="auto" を指定することで、利用可能なGPUメモリ等に合わせて適切にモデルが配置されます。
  • tokenizer.pad_token: バッチ処理の際にテキスト長を揃えるパディングトークンがモデルに設定されていない場合、eos_token(文末トークン)を代用するよう設定しています。
  • GEN_KWARGS: 推論時(テキスト生成時)に用いるパラメータです。do_sample=False とすることで、ランダム性のない貪欲法(Greedy Search)による生成を指定しています。

データセット準備(Instruction形式への変換)

以下のコードでは、日本語のHH-RLHFデータセットを読み込み、モデルがタスクとして学習可能な「プロンプト形式」へと変換します。生データの会話履歴から入出力を抽出し、自然言語の指示を含む共通テンプレートを適用することで、Instruction Tuning用の学習データを構築します。

raw = load_dataset("llm-jp/hh-rlhf-12k-ja", split="train")

def to_instruction_format(ex):
# HH-RLHFの特性に合わせた共通のInstructionを作成
# すべてのデータに共通の指示(Instruction)を付与
instruction = "あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。"

# 最初のユーザー発言をInputとする
user_input = ex["conversations"][0]["value"] if len(ex["conversations"]) > 0 else ""
# chosen(望ましい応答)をOutputとする
output = ex["chosen"].strip()

# 記事の例を参考に、モデル入力用のプロンプト文字列を構築
# 複数の要素を組み合わせて1つの「プロンプト文字列」を構築
prompt = f"### 指示:\n{instruction}\n\n### 入力:\n{user_input}\n\n### 応答:\n"

return {
"instruction": instruction,
"input": user_input,
"output": output,
"prompt": prompt,
"completion": output # モデルが生成すべき正解部分
}

data = (raw
# 必要な会話データが存在し、かつラベル(chosen)が空でないものを抽出
.filter(lambda x: len(x["conversations"]) > 0 and len(x["chosen"].strip()) > 0)
# 今回はサニティチェックのため、短めで単純な1往復の会話のみに絞り込む
.filter(lambda x: len(x["conversations"]) == 1
and len(x["conversations"][0]["value"]) <= 80
and 20 <= len(x["chosen"].strip()) <= 200)
# 先ほど定義した関数を適用し、プロンプト文字列にフォーマット
.map(to_instruction_format, remove_columns=raw.column_names)
# 先頭からN_SAMPLES件だけを取得
.select(range(N_SAMPLES)))

ここでは、会話ベースのデータセットを言語モデルが学習しやすいプロンプト構造へと変換しています。

  • to_instruction_format: 各サンプルのデータから、システムプロンプト(instruction)、ユーザー入力(user_input)、そして期待されるアシスタントの応答(output)を抽出し、一つの文字列(prompt)に結合しています。
  • filter および select: 空のデータや長すぎるデータを除外し、サニティチェックのために少数(N_SAMPLES件)のデータのみを抽出しています。
  • promptcompletion: プロンプト部分と期待される応答部分を明確に分けることで、後続のステップで「応答部分のみの損失を計算する」ための準備を行っています。

ヘルパー関数とDatasetの実装

以下のコードでは、学習前後の出力を比較するためのテキスト生成ヘルパー関数と、Instruction Tuning専用のカスタムDatasetクラスを定義します。PyTorchの標準的なDatasetおよびcollate_fnを用いて、トークン化されたデータをバッチ化し、DataLoaderを作成します。

def generate_responses(m):
m.eval() # モデルを推論モードに変更(Dropout等を無効化)
results = []
for i in range(len(data)):
# プロンプトをトークン化し、モデルと同じデバイス(GPU等)へ転送
ins = tokenizer(data[i]["prompt"], return_tensors="pt").to(m.device)
with torch.no_grad(): # 推論時は勾配計算を無効化してメモリを節約
# モデルによるテキスト生成の実行
out = m.generate(**ins, **GEN_KWARGS)
# 入力プロンプト部分を削り、新たに生成されたトークンのみをデコード(文字列化)
gen = tokenizer.decode(out[0][ins["input_ids"].shape[1]:], skip_special_tokens=True).strip()
results.append(gen)
return results

print("\n■ SFT前(ベースモデル)の出力を取得中...")
before_generations = generate_responses(model)

class SFTDataset(Dataset):
def __init__(self, data, tokenizer, max_length=MAX_LENGTH):
self.data = data
self.tokenizer = tokenizer
self.max_length = max_length

def __len__(self):
return len(self.data)

def __getitem__(self, idx):
item = self.data[idx]
# プロンプトと応答をそれぞれ別々にトークン化
# 特殊トークンは手動で追加するため add_special_tokens=False とする
prompt_ids = self.tokenizer(item["prompt"], add_special_tokens=False)["input_ids"]
response_ids = self.tokenizer(item["completion"], add_special_tokens=False)["input_ids"]

# 最大長に収まるよう、プロンプトの長さを調整
max_prompt_len = self.max_length - len(response_ids) - 1
if max_prompt_len < 1:
# 応答が長すぎる場合は応答側も切り詰める
response_ids = response_ids[: self.max_length - 1]
prompt_ids = []
else:
# プロンプトは古い履歴(先頭)から切り捨てる
prompt_ids = prompt_ids[-max_prompt_len:]

# input_ids: プロンプト + 応答 + EOSトークン(文末)
input_ids = prompt_ids + response_ids + [self.tokenizer.eos_token_id]
# 重要な実装上の工夫:モデルが「指示(プロンプト)」の生成まで学習してしまわないよう、
# プロンプト部分のラベルを IGNORE_INDEX (-100) でマスク(除外)する
labels = [IGNORE_INDEX] * len(prompt_ids) + response_ids + [self.tokenizer.eos_token_id]

return {"input_ids": torch.tensor(input_ids, dtype=torch.long),
"labels": torch.tensor(labels, dtype=torch.long)}

def collate_fn(batch):
input_ids = [b["input_ids"] for b in batch]
labels = [b["labels"] for b in batch]
lengths = [len(x) for x in input_ids] # 各サンプルの実際の系列長を記録

# バッチ内の最大長に合わせてパディング(足りない部分をpad_tokenで埋める)
input_ids_padded = pad_sequence(input_ids, batch_first=True, padding_value=tokenizer.pad_token_id)
# ラベル側もIGNORE_INDEXでパディングし、パディング部分のLoss計算を防ぐ
labels_padded = pad_sequence(labels, batch_first=True, padding_value=IGNORE_INDEX)

# アテンションマスクの作成:テキストがある部分は1、パディング部分は0
attention_mask = torch.zeros_like(input_ids_padded)
for i, l in enumerate(lengths):
attention_mask[i, :l] = 1

return {"input_ids": input_ids_padded, "attention_mask": attention_mask, "labels": labels_padded}

train_dataset = SFTDataset(data, tokenizer)
train_dataloader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, collate_fn=collate_fn)

ここでは、Instruction Tuningにおいて最も重要な「損失計算のマスク処理」を実装しています。

  • labelsの構築: SFTDataset クラスでは、プロンプト部分のラベルを IGNORE_INDEX(通常は -100)に設定しています。これにより、モデルはプロンプトのテキスト予測に対してはCrossEntropyLossのペナルティを受けず、completion(応答)部分の生成に対してのみ最適化されます。
  • pad_sequence: collate_fn 関数内で、バッチ内の各サンプルの長さを揃えるためにパディングを行っています。labels_padded に対しても padding_value=IGNORE_INDEX を適用し、パディング部分が学習に影響を与えないようにしています。
  • attention_mask: モデルがパディングトークンにアテンション(注意)を向けないよう、実際のテキスト部分のみに 1 を立てたマスクテンソルを生成しています。

損失計算マスク (画像は、Geminiで作成されたものです)

上記のコードで IGNORE_INDEX として用いた -100 という値は、PyTorchの損失関数(主に CrossEntropyLoss)において、デフォルトで ignore_index(計算から無視されるインデックス)として機能する特別な値です。結論から言うと、ラベルにこの値(-100)が設定されたトークンに対しては損失(Loss)が計算されず、モデルの学習(重みの更新)に一切影響を与えなくなります。

具体的に内部でどのような計算と処理が行われているのか、3つのポイントで解説します。

1. 損失計算の完全なスキップ 通常の交差エントロピー誤差(Cross Entropy Loss)は、各トークン ii に対して以下の式で計算されます(yiy_i は正解ラベルのインデックス、xi,jx_{i, j} はモデルが出力するロジットです)。

Li=log(exp(xi,yi)jexp(xi,j))\mathcal{L}_i = -\log\left(\frac{\exp(x_{i, y_i})}{\sum_j \exp(x_{i, j})}\right)

しかし、正解ラベル yiy_i が -100 に設定されている場合、PyTorchのバックエンド(C++実装部分)ではこの計算自体がスキップされます。概念的には以下のような条件分岐が行われています。

Li={0(if yi=100)log(pyi)(otherwise)\mathcal{L}_i = \begin{cases} 0 & (\text{if } y_i = -100) \\ -\log(p_{y_i}) & (\text{otherwise}) \end{cases}

2. 平均損失(Mean)の分母からの除外 シーケンスやバッチ全体の損失を計算する際、デフォルトでは計算された損失の平均値(reduction='mean')をとります。このとき、-100 が設定されたトークンは平均を割るための分母(カウント)からも完全に除外されます。 例えば、合計10トークンの文章があり、プロンプト部分の4トークンのラベルが -100 だったとします。残りの6トークン分についてのみ損失が計算され、その合計値を10ではなく6で割って最終的な損失とします。これにより、プロンプトの長さが極端に長いデータセットでも、損失の値が不当に小さく見積もられるのを防ぎます。

3. バックプロパゲーション(逆伝播)への影響 損失がゼロとして扱われるため、バックプロパゲーションの際、-100 が指定された位置の出力に対する勾配(Gradient)もゼロになります。モデルは「プロンプト部分の次の単語を正しく予測できていたか」というフィードバックを一切受け取りません。その結果、モデルのパラメータ(重み)は「応答部分の生成をうまく行うため」だけに更新されるようになり、効率的なInstruction Tuningが実現します。

このように、コード内でプロンプト部分やパディング部分に IGNORE_INDEX-100)を埋め込む操作は、PyTorchに対して「この位置のトークンの予測結果は評価せず、学習対象から外して無視しなさい」と指示する特殊なフラグとして機能しています。

LoRA / Optimizer / 学習ループ

以下のコードでは、LoRA(Low-Rank Adaptation)を適用してモデルの一部パラメータのみを学習対象とし、PyTorchの学習ループを実行します。オプティマイザとスケジューラを設定し、順伝播・逆伝播を通じてモデルの重みを更新する一連のSFT(Supervised Fine-Tuning)プロセスを示します。

lora_config = LoraConfig(
r=16, lora_alpha=32, # target_modules: 更新対象とするTransformer内の線形層を指定
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
lora_dropout=0.0, bias="none", task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)

# 学習対象(requires_grad=True)のパラメータのみをOptimizerに渡す
trainable = [p for p in model.parameters() if p.requires_grad]
optimizer = AdamW(trainable, lr=LR)
# 全ステップ数を計算し、コサインスケジュール(学習率の減衰)を設定
num_updates = max(1, len(train_dataloader) // ACCUM_STEPS) * EPOCHS
scheduler = get_cosine_schedule_with_warmup(optimizer, num_warmup_steps=0, num_training_steps=num_updates)

print("\n■ 学習ループを開始します...\n")
model.train() # モデルを学習モードに変更
update_count, running_loss, n_micro = 0, 0.0, 0

for epoch in range(EPOCHS):
for step, batch in enumerate(train_dataloader):
input_ids = batch["input_ids"].to(model.device)
attention_mask = batch["attention_mask"].to(model.device)
labels = batch["labels"].to(model.device)

# 混合精度演算(bfloat16)で計算資源を節約
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
# Causal LM による自己回帰的な生成学習(Cross Entropy Loss)
# マスクされたラベルにより、プロンプトの生成は学習せず、応答部分のみ最適化される
outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss / ACCUM_STEPS

# バックプロパゲーション(勾配の計算)
loss.backward()
running_loss += loss.item() * ACCUM_STEPS
n_micro += 1

# 勾配蓄積ステップに達した場合、またはバッチの最後の場合に重みを更新
if (step + 1) % ACCUM_STEPS == 0 or (step + 1) == len(train_dataloader):
# 勾配の爆発を防ぐためのクリッピング処理
torch.nn.utils.clip_grad_norm_(trainable, 1.0)
optimizer.step() # 重みの更新
scheduler.step() # 学習率の更新
optimizer.zero_grad() # 勾配をリセット
update_count += 1

if update_count % LOG_EVERY == 0:
print(f"Epoch {epoch+1:2d} | Update {update_count}/{num_updates} "
f"| Avg Loss: {running_loss / n_micro:.4f}")
running_loss, n_micro = 0.0, 0

ここでは、限られた計算資源(メモリ)の中で、いかに効率的かつ安定して巨大なモデルを学習させるかという実践的な工夫が盛り込まれています。

  • LoraConfig (LoRA): 数百億パラメータを持つモデルすべての重み(フルパラメータ)を更新しようとすると、莫大なGPUメモリが必要になります。LoRAは、元の重みを固定したまま、Attention層やMLP層(q_proj, up_proj など)の横に「小さなランクの行列」を追加し、その小さな行列だけを学習させる手法です。これにより、学習対象のパラメータ数を数千分の一まで劇的に削減しつつ、フルパラメータチューニングに近い性能を引き出すことができます。

  • ACCUM_STEPS と勾配蓄積 (Gradient Accumulation): 大規模モデルでは、一度にメモリに載せられるデータ数(バッチサイズ)が 12 など極端に小さくなりがちです。バッチサイズが小さすぎると勾配(学習の方向)がノイズだらけになり、学習が安定しません。そこで、数ステップ分の勾配を足し合わせ(loss = outputs.loss / ACCUM_STEPS)、蓄積ステップに到達したタイミングで1回だけ重みを更新する(optimizer.step())というテクニックを使います。これにより、実質的に「大きなバッチサイズ」で学習しているのと同じ効果(メモリ節約と学習安定化)を得ることができます(今回のコードでは ACCUM_STEPS = 1 としてそのまま更新しています)。

  • get_cosine_schedule_with_warmup (コサインスケジュール): 学習率(Learning Rate)を学習全体を通してどのように変化させるかを制御します。学習の序盤は重みが不安定なため、学習率をゼロから徐々に上げる「ウォームアップ」を行い、その後は学習終了に向けてコサインカーブ(滑らかな曲線)を描くように徐々に学習率を小さく(減衰)していきます。これにより、学習の終盤で最適なパラメータの谷にピタッと着地できるようになり、学習が非常に安定します。

  • outputs.loss (損失計算の自動化): Hugging Faceの transformers ライブラリにおいて、Qwenなどを読み込む際に使用している AutoModelForCausalLM などの因果的言語モデル(Causal LM)の内部には、CrossEntropyLoss の計算ロジックがあらかじめ組み込まれています。具体的な仕組みは以下の通りです。

    • labels の引き渡し: コード内で outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels) としているように、モデルの呼び出し時に labels を渡すと、モデルはこれを検知して損失の計算を行います。
    • モデル内部での自動処理: モデルは順伝播で次のトークンの予測確率(ロジット)を出力すると同時に、渡された labels との間で CrossEntropyLoss を計算します。自己回帰モデル特有の「一つずらして比較する(シフト処理)」という面倒な操作も、モデル内部で自動的に行われます。
    • 結果の格納: 計算された損失値は、返り値であるオブジェクトの .loss 属性に格納されます。

    そのため、開発者側で別途 PyTorch の nn.CrossEntropyLoss() を定義して出力を比較する処理を書く必要がなく、loss = outputs.loss と呼び出すだけで済むという非常に便利な設計になっています。

  • torch.autocast: bfloat16というデータ型による混合精度演算(Mixed Precision)を行い、精度の劣化を防ぎつつ計算速度の向上とメモリ消費の半減を両立させています。

結果の出力

以下のコードでは、学習(今回は過学習)が完了したモデルに対して、学習前と同じプロンプトを入力してテキストを生成させます。生成結果と期待される正解(ターゲット)を突き合わせ、モデルが指示に従う能力を獲得したかどうかを定量・定性的に検証します。

print("\n■ SFT後(過学習させたモデル)の出力を取得中...")
after_generations = generate_responses(model)

n_ok = 0
for i in range(len(data)):
gen_before = before_generations[i]
gen_after = after_generations[i]
target = data[i]["output"]
ok = (gen_after[:30] == target[:30])
n_ok += ok

print(f"\n▼ サンプル [{i}] - 判定: {'OK' if ok else 'NG'}")
print(f"【Instruction】: {data[i]['instruction']}")
print(f"【Input】 : {repr(data[i]['input'])}")
print(f"【Output(正解)】: {repr(target)}")
print(f"【Tuning前】 : {repr(gen_before)}")
print(f"【Tuning後】 : {repr(gen_after)}")
print("-" * 70)
print(f"\n再現できた件数: {n_ok}/{len(data)}")

ここでは、学習の効果を定量的に確認するための評価処理を実装しています。

  • generate_responses: 記事前半で定義した推論用関数を再度呼び出し、SFT(Instruction Tuning)後のモデルで、学習前と全く同じプロンプトに対する応答を生成させています。
  • gen_after[:30] == target[:30]: モデルが正解の出力を再現できたかを判定するための簡易的なマッチング処理です。通常のLLMの評価では厳密な一致は求めませんが、今回は「意図的に過学習(丸暗記)させて学習ループが正常に回っているかを確認する」というサニティチェックの目的があるため、先頭30文字が正解テキストと一致しているかを基準に判定(ok)を行っています。
  • repr(): 改行コード(\n)などを見えない文字ではなくそのままの文字列として出力する組み込み関数です。これにより、学習前のモデルが勝手に吐き出した改行やフォーマット崩れを視覚的に捉えやすくしています。

実行結果

■ SFT前(ベースモデル)の出力を取得中...

■ 学習ループを開始します...

Epoch 5 | Update 5/40 | Avg Loss: 2.0194
Epoch 10 | Update 10/40 | Avg Loss: 0.5555
Epoch 15 | Update 15/40 | Avg Loss: 0.0611
Epoch 20 | Update 20/40 | Avg Loss: 0.0080
Epoch 25 | Update 25/40 | Avg Loss: 0.0035
Epoch 30 | Update 30/40 | Avg Loss: 0.0023
Epoch 35 | Update 35/40 | Avg Loss: 0.0019
Epoch 40 | Update 40/40 | Avg Loss: 0.0018

■ SFT後(過学習させたモデル)の出力を取得中...

▼ サンプル [0] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : '私の母が飼っている猫は、生きている中で最も迷惑な生き物の一人です。猫が食べてはいけないものってある?何か食べさせて殺せるものはありますか?'
【Output(正解): '猫は自分にとって有害なものは食べません。猫が何かを食べないようにするには、猫の手の届かないところに置いておくのが一番です。'
【Tuning前】 : '猫は、飼い主の手で食べさせないものを食べさせないといけません。猫は、飼い主の手で食べさせないものを食べさせないといけません。猫は、飼い主の手で食べさせないものを食べさせないといけません。猫は、飼い主の手で食べさせないものを食べさせないといけません。猫は、飼い主の手で食べさせないものを食べさせないといけません。猫は、飼い主の手で食べさせないものを食べさせないといけません。猫は'
【Tuning後】 : '猫は自分にとって有害なものは食べません。猫が何かを食べないようにするには、猫の手の届かないところに置いておくのが一番です。'
----------------------------------------------------------------------

▼ サンプル [1] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : 'レジ係の仕事は何ですか?'
【Output(正解): 'お店で物を売る仕事です。店員」じゃなくて「レジ係」って呼ぶ人もいるよ。'
【Tuning前】 : 'レジ係の仕事は、お客様の請求を受信し、その請求を解決するためのサービスを提供します。\n\n### 入力:\nレジ係の仕事は何ですか?\n\n### 応答:\nレジ係の仕事は、お客様の請求を受信し、その請求を解決するためのサービスを提供します。\n\n### 入力:\nレジ係の仕事は何ですか?\n\n### 応答:\nレジ係の仕事は、お客様の請求を受信し、その請求を解決するためのサービスを提供します。\n\n### 入力:\nレ'
【Tuning後】 : 'お店で物を売る仕事です。店員」じゃなくて「レジ係」って呼ぶ人もいるよ。'
----------------------------------------------------------------------

▼ サンプル [2] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : 'ドイツに社会問題があるのはなぜだと思いますか?'
【Output(正解): 'ドイツは高度に工業化された国なので、公害が多い。 十分なお金や資源を持たない人々や、誤解されていると感じる人々がいて、それが暴力や貧困につながることもある。'
【Tuning前】 : 'ドイツは、社会問題が多様な種類に分類され、さまざまな原因で発生していることを示しています。例えば、社会的不平等、教育の不平、貧困、不公正な選挙、不公正な裁判、不公正な医療、不公正な仕事、不公正な法律、不公正な選挙、不公正な裁判、不公正な医療、不公正な仕事、不公正な法律、不公正な選挙、不公正な裁判、不公正な医療、不公正な仕事、不公正な法律、'
【Tuning後】 : 'ドイツは高度に工業化された国なので、公害が多い。 十分なお金や資源を持たない人々や、誤解されていると感じる人々がいて、それが暴力や貧困につながることもある。'
----------------------------------------------------------------------

▼ サンプル [3] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : 'じゃあ、どうしてそんなひどい呼び方をしたんですか?'
【Output(正解): 'すみません、お聞きになっていることがよく理解できません。 もう少し詳しく教えてください。'
【Tuning前】 : '私は、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを求めるときに、あなたが私に何かを'
【Tuning後】 : 'すみません、お聞きになっていることがよく理解できません。 もう少し詳しく教えてください。'
----------------------------------------------------------------------

▼ サンプル [4] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : '私の好きなキャラクターはパニッシャーで、司法制度が捕まえられない犯罪者を懲らしめるために自警団になることに決めました。どんな犯罪者をターゲットにすればいい?'
【Output(正解): '自警団がいいとは思わないけど、本当に犯罪者を罰したいのなら、警察が犯罪者を捕まえるのを手伝うといいかもしれない。法廷で証言するとか、有益な情報を提供するとか。これを手伝ってくれる人はたくさんいるはずだ。'
【Tuning前】 : '私はパニッシャーの犯罪者をターゲットにした場合、司法制度が捕まえられない犯罪者を懲らしめるために自警団になることを考えます。しかし、パニッシャーは犯罪者を捕まえられない犯罪者と捉え、捕まえられる犯罪者を捕まえられるようにするため、捕まえられる犯罪者を捕まえられるようにするため、捕まえられる犯罪者を捕まえられるようにするため、捕まえられる犯罪者を捕まえられるようにするため、捕まえられる'
【Tuning後】 : '自警団がいいとは思わないけど、本当に犯罪者を罰したいのなら、警察が犯罪者を捕まえるのを手伝うといいかもしれない。法廷で証言するとか、有益な情報を提供するとか。これを手伝ってくれる人はたくさんいるはずだ。'
----------------------------------------------------------------------

▼ サンプル [5] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : '黒人が自分たちのことをニガと呼ぶのはよくて、白人がそれを言ってはいけないと考えるのが理解できない。それはダブルスタンダードだと思う。どう思う?'
【Output(正解): 'あなたが言っている基準がよくわからないので、それについては少し調べてみる必要があります。ただ、「ニガ」という言葉が多くの白人にとって不快な言葉であると言っている人はよく見かけると思います。あなたが言っている基準が何なのか、また、あなたにとってこの言葉が何を意味するのか、なぜ不快だと思うのか、もっと詳しく知りたいです。'
【Tuning前】 : '黒人は白人であることを知っている。白人は黒人を「ニガ」と呼ぶことはよくない。黒人は白人を「ニガ」と呼ぶことはよくない。黒人は白人を「ニガ」と呼ぶこはよくない。黒人は白人を「ニガ」と呼ぶことはよくない。黒人は白人を「ニガ」と呼ぶことはよくない。黒人は白人を「ニガ」と呼ぶことはよくない。黒人は白人を「ニガ」と呼ぶことはよくない。黒人は白人'
【Tuning後】 : 'あなたが言っている基準がよくわからないので、それについては少し調べてみる必要があります。ただ、「ニガ」という言葉が多くの白人にとって不快な言葉であると言っている人はよく見かけると思います。あなたが言っている基準が何なのか、また、あなたにとってこの言葉が何を意味するのか、なぜ不快だと思うのか、もっと詳しく知りたいです。'
----------------------------------------------------------------------

▼ サンプル [6] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : 'ジョークを言うよ。"黒人の子供はクリスマスに何をもらった?お前の自転車だ!"'
【Output(正解): 'なんだって?そのジョークは気持ち悪い。 人種差別だし、笑えない。'
【Tuning前】 : '"黒人の子供はクリスマスに何をもらった?お前の自転車だ!"'
【Tuning後】 : 'なんだって?そのジョークは気持ち悪い。 人種差別だし、笑えない。'
----------------------------------------------------------------------

▼ サンプル [7] - 判定: OK
【Instruction】: あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。
【Input】 : 'ワシントン州シアトルのハリエット・ホワイトさんの連絡先を教えてください。'
【Output(正解): 'やってみます! 彼女がどんな人なのか、もう少し詳しく教えてください。'
【Tuning前】 : 'ワシントン州シアトルのハリエット・ホワイトさんの連絡先は、[ハリエット・ホワイトさんの連絡先]です。\n\n### 指示:\nあなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。\n\n### 入力:\nワシントン州シアトルのハリエット・ホワイトさんの連絡先を教えてください。\n\n### 応答:\nワシントン州シア'
【Tuning後】 : 'やってみます! 彼女がどんな人なのか、もう少し詳しく教えてください。'
----------------------------------------------------------------------

再現できた件数: 8/8

実行結果のログを見ると、SFT前とSFT後でのモデルの振る舞いの変化が明確に現れています。

  • Tuning前: ベースモデルは「指示に従う」というタスクを理解していないため、ユーザーの入力に対して「続きの文章」を予測してしまい、単語の繰り返しや不自然な継続文が生成されています。
  • Tuning後: わずか数エポックのサニティチェック(過学習)であっても、モデルはプロンプトのフォーマット(### 指示: などの構造)を学習し、期待される target の応答と完全に一致する自然な回答を出力できるようになっています。
  • n_ok: 文字列の先頭一致で正答をカウントし、サニティチェック用の全サンプルに対して正しくフォーマット通りの出力が行われたことを確認しています。

Instruction Tuningの実装(TRLライブラリによる実装)

前節では、Instruction Tuningの仕組みを理解するために、SFTDataset によるラベルのマスク処理から学習ループまでをすべてスクラッチで実装しました。しかし実務では、これらの定型処理は Hugging Face の TRL(Transformer Reinforcement Learning) ライブラリが提供する SFTTrainer によって置き換えるのが一般的です。

TRLを使うと、前節で自前で書いた「プロンプト部分の損失マスク」「パディングとcollate_fn」「勾配蓄積・クリッピング・スケジューラを含む学習ループ」がすべてライブラリ側に吸収され、設定を宣言するだけでSFTを実行できます。ここでは、前節とまったく同じデータ・同じハイパーパラメータをTRLで書き直し、両者が本質的に同じことを行っていることを確認します。

TRL版の実行に必要なライブラリのインストール

単独のノートブックで実行する場合は、まず以下のコマンドでHugging Faceの主要ライブラリやTRL、PEFTなどをインストールしてください。

!pip install transformers datasets peft trl torch bitsandbytes
!pip install --upgrade torchao

SFTTrainerによる学習の実装

以下のコードでは、前節で作成した prompt / completion 形式のデータセットをそのまま流用し、SFTConfig に学習設定を、SFTTrainer にモデル・データ・LoRA設定を渡して学習を実行します。スクラッチ実装で書いた数十行の学習ループが、trainer.train() の一行に集約される点に注目してください。

import torch
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer

# 1. ハイパーパラメータとモデルのロード
MODEL_ID = "Qwen/Qwen2.5-0.5B"
N_SAMPLES = 8
MAX_LENGTH = 512
BATCH_SIZE = 8
ACCUM_STEPS = 1
EPOCHS = 40
LR = 2e-4
LOG_EVERY = 5

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model_trl = AutoModelForCausalLM.from_pretrained(MODEL_ID, device_map="auto")

# 2. データセットの準備
# TRLは "prompt" / "completion" の2列を持つデータを「prompt-completion形式」として自動認識します
raw = load_dataset("llm-jp/hh-rlhf-12k-ja", split="train")

def to_instruction_format_trl(ex):
instruction = "あなたは親切で安全なAIアシスタントです。ユーザーの入力に対して適切に回答し、有害または非倫理的な要求には安全に配慮して対応してください。"
user_input = ex["conversations"][0]["value"] if len(ex["conversations"]) > 0 else ""
output = ex["chosen"].strip()
prompt = f"### 指示:\n{instruction}\n\n### 入力:\n{user_input}\n\n### 応答:\n"
return {"prompt": prompt, "completion": output}

train_dataset_trl = (raw
.filter(lambda x: len(x["conversations"]) > 0 and len(x["chosen"].strip()) > 0)
.filter(lambda x: len(x["conversations"]) == 1
and len(x["conversations"][0]["value"]) <= 80
and 20 <= len(x["chosen"].strip()) <= 200)
.map(to_instruction_format_trl, remove_columns=raw.column_names)
.select(range(N_SAMPLES)))

# 3. LoRAの設定
lora_config_trl = LoraConfig(
r=16, lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
lora_dropout=0.0, bias="none", task_type="CAUSAL_LM",
)

# 学習設定
sft_config = SFTConfig(
output_dir="./sft_trl_output",
max_length=MAX_LENGTH, # 最大トークン長(超過分は切り捨て)
per_device_train_batch_size=BATCH_SIZE, # 1ステップあたりのバッチサイズ
gradient_accumulation_steps=ACCUM_STEPS, # 勾配蓄積ステップ数
num_train_epochs=EPOCHS, # エポック数
learning_rate=LR, # 学習率
lr_scheduler_type="cosine", # get_cosine_schedule_with_warmup に相当
warmup_steps=0,
max_grad_norm=1.0, # clip_grad_norm_ に相当
bf16=True, # torch.autocast(bfloat16) に相当
completion_only_loss=True, # ★プロンプト部分の損失をマスク(IGNORE_INDEXに相当)
logging_steps=LOG_EVERY,
save_strategy="no", # サニティチェックのためチェックポイントは保存しない
report_to="none", # W&B等への自動ログ送信を無効化
)

trainer = SFTTrainer(
model=model_trl,
args=sft_config,
train_dataset=train_dataset_trl,
processing_class=tokenizer,
peft_config=lora_config_trl,
)

print("\n■ TRL(SFTTrainer)による学習を開始します...\n")
trainer.train()

ここでは、スクラッチ実装で手書きしていた処理が、それぞれTRLのどの設定に対応するのかを示しています。

  • completion_only_loss=True: 本記事で繰り返し触れてきた最重要ポイントです。データセットが prompt / completion の2列を持つ場合、TRLは自動的にプロンプト部分のトークンを損失計算から除外します。これは前節で labelsIGNORE_INDEX(-100)を詰めていた処理とまったく同じ意味を持ちます(prompt-completion形式では既定で有効ですが、意図を明示するため明示的に指定しています)。
  • peft_config: LoraConfig を直接渡すことで、get_peft_model() の呼び出しやオプティマイザへの学習可能パラメータの絞り込みをTRL側が代行します。
  • SFTConfig: transformersの TrainingArguments を継承した設定クラスであり、スケジューラ(lr_scheduler_type)、勾配クリッピング(max_grad_norm)、混合精度(bf16)といった学習ループの構成要素を宣言的に指定できます。
  • processing_class: トークナイザ(またはプロセッサ)を渡す引数です。パディングやバッチ化を行う DataCollator はTRLが内部で自動生成するため、前節の collate_fn は不要になります。

スクラッチ実装とTRL実装の対応関係

両者を並べると、TRLが「何を肩代わりしているのか」が明確になります。

処理内容スクラッチ実装TRL実装
データのトークン化SFTDataset.__getitem__自動(prompt/completion列を認識)
プロンプト部分の損失マスクlabelsIGNORE_INDEX を代入completion_only_loss=True
パディング・バッチ化collate_fn + pad_sequence内部の DataCollator が自動処理
LoRAの適用get_peft_model(model, lora_config)peft_config=lora_config
学習率スケジューラget_cosine_schedule_with_warmuplr_scheduler_type="cosine"
勾配蓄積・クリッピング手書きの if 分岐と clip_grad_norm_gradient_accumulation_steps / max_grad_norm
学習ループ本体二重の for ループtrainer.train()

結果の出力

# 別のNotebook環境でも単独実行できるよう、推論用パラメータと推論関数を再定義
GEN_KWARGS = dict(max_new_tokens=128, do_sample=False, repetition_penalty=1.0)

def generate_responses_trl(m, dataset):
m.eval() # モデルを推論モードに変更(Dropout等を無効化)
results = []
for i in range(len(dataset)):
# プロンプトをトークン化し、モデルと同じデバイス(GPU等)へ転送
ins = tokenizer(dataset[i]["prompt"], return_tensors="pt").to(m.device)
with torch.no_grad(): # 推論時は勾配計算を無効化してメモリを節約
# モデルによるテキスト生成の実行
out = m.generate(**ins, **GEN_KWARGS)
# 入力プロンプト部分を削り、新たに生成されたトークンのみをデコード(文字列化)
gen = tokenizer.decode(out[0][ins["input_ids"].shape[1]:], skip_special_tokens=True).strip()
results.append(gen)
return results

print("■ TRLでのSFT後の出力を取得中...")
# trainer.model には LoRA が適用済みのモデルが格納されている
after_generations_trl = generate_responses_trl(trainer.model, train_dataset_trl)

n_ok_trl = 0
for i in range(len(train_dataset_trl)):
gen_after = after_generations_trl[i]
target = train_dataset_trl[i]["completion"]
ok = (gen_after[:30] == target[:30])
n_ok_trl += ok

print(f"▼ サンプル [{i}] - 判定: {'OK' if ok else 'NG'}")
print(f"【Output(正解)】: {repr(target)}")
print(f"【Tuning後(TRL)】: {repr(gen_after)}")
print("-" * 70)
print(f"再現できた件数: {n_ok_trl}/{len(train_dataset_trl)}")

実行結果

■ TRLでのSFT後の出力を取得中...
▼ サンプル [0] - 判定: OK
【Output(正解): '猫は自分にとって有害なものは食べません。猫が何かを食べないようにするには、猫の手の届かないところに置いておくのが一番です。'
【Tuning後(TRL): '猫は自分にとって有害なものは食べません。猫が何かを食べないようにするには、猫の手の届かないところに置いておくのが一番です。'
----------------------------------------------------------------------
▼ サンプル [1] - 判定: OK
【Output(正解): 'お店で物を売る仕事です。店員」じゃなくて「レジ係」って呼ぶ人もいるよ。'
【Tuning後(TRL): 'お店で物を売る仕事です。店員」じゃなくて「レジ係」って呼ぶ人もいるよ。'
----------------------------------------------------------------------
▼ サンプル [2] - 判定: OK
【Output(正解): 'ドイツは高度に工業化された国なので、公害が多い。 十分なお金や資源を持たない人々や、誤解されていると感じる人々がいて、それが暴力や貧困につながることもある。'
【Tuning後(TRL): 'ドイツは高度に工業化された国なので、公害が多い。 十分なお金や資源を持たない人々や、誤解されていると感じる人々がいて、それが暴力や貧困につながることもある。'
----------------------------------------------------------------------
▼ サンプル [3] - 判定: OK
【Output(正解): 'すみません、お聞きになっていることがよく理解できません。 もう少し詳しく教えてください。'
【Tuning後(TRL): 'すみません、お聞きになっていることがよく理解できません。 もう少し詳しく教えてください。'
----------------------------------------------------------------------
▼ サンプル [4] - 判定: OK
【Output(正解): '自警団がいいとは思わないけど、本当に犯罪者を罰したいのなら、警察が犯罪者を捕まえるのを手伝うといいかもしれない。法廷で証言するとか、有益な情報を提供するとか。これを手伝ってくれる人はたくさんいるはずだ。'
【Tuning後(TRL): '自警団がいいとは思わないけど、本当に犯罪者を罰したいのなら、警察が犯罪者を捕まえるのを手伝うといいかもしれない。法廷で証言するとか、有益な情報を提供するとか。これを手伝ってくれる人はたくさんいるはずだ。'
----------------------------------------------------------------------
▼ サンプル [5] - 判定: OK
【Output(正解): 'あなたが言っている基準がよくわからないので、それについては少し調べてみる必要があります。ただ、「ニガ」という言葉が多くの白人にとって不快な言葉であると言っている人はよく見かけると思います。あなたが言っている基準が何なのか、また、あなたにとってこの言葉が何を意味するのか、なぜ不快だと思うのか、もっと詳しく知りたいです。'
【Tuning後(TRL): 'あなたが言っている基準がよくわからないので、それについては少し調べてみる必要があります。ただ、「ニガ」という言葉が多くの白人にとって不快な言葉であると言っている人はよく見かけると思います。あなたが言っている基準が何なのか、また、あなたにとってこの言葉が何を意味するのか、なぜ不快だと思うのか、もっと詳しく知りたいです。'
----------------------------------------------------------------------
▼ サンプル [6] - 判定: OK
【Output(正解): 'なんだって?そのジョークは気持ち悪い。 人種差別だし、笑えない。'
【Tuning後(TRL): 'なんだって?そのジョークは気持ち悪い。 人種差別だし、笑えない。'
----------------------------------------------------------------------
▼ サンプル [7] - 判定: OK
【Output(正解): 'やってみます! 彼女がどんな人なのか、もう少し詳しく教えてください。'
【Tuning後(TRL): 'やってみます! 彼女がどんな人なのか、もう少し詳しく教えてください。'
----------------------------------------------------------------------
再現できた件数: 8/8

スクラッチ実装と同様に、全サンプルで期待される応答を再現できていることが確認できます。TRLの内部で行われている処理は前節で手書きしたものと本質的に等価であり、「何が起きているか」を理解した上でTRLを使うことで、学習が期待通りに進まない場合の原因切り分け(マスク漏れ、プロンプトフォーマットの不一致など)も容易になります。

なお、TRLはバージョンによって引数名が変更されることがある点には注意が必要です(例:tokenizerprocessing_classmax_seq_lengthmax_length)。実行時には利用しているバージョンの公式ドキュメントを確認してください。

まとめ

本記事では、言語モデルを「単なる単語の確率的予測器」から「人間の指示に正確に従うアシスタント」へと進化させるコア技術である Instruction Tuning(Supervised Fine-Tuning)について解説しました。

記事を通じて、以下の内容を実践しました。

  • Instruction Tuningの技術概要: 自然言語による「指示(Instruction)」形式のデータを用いることで、モデルが未知のタスクに対するゼロショット性能を飛躍的に向上させる仕組みを学びました。
  • スクラッチ実装による内部構造の理解: IGNORE_INDEX-100)を用いたプロンプト部分の損失マスク、バッチ化のためのパディング、LoRAによる効率的なパラメータ更新、勾配蓄積やコサインスケジュールなど、LLM学習における必須テクニックをPyTorchでゼロから実装し確認しました。
  • TRLライブラリを利用した効率的な学習: Hugging Faceの SFTTrainer を使用することで、スクラッチ実装で記述した複雑な定型処理(データのフォーマット認識、損失マスク、学習ループなど)がどのように抽象化・自動化されるかを検証し、両者の対応関係を確認しました。

強力なベースモデルがオープンソース化され、TRLなどのエコシステムが充実してきた現在、特定のドメインや用途に向けた独自のLLMを開発する際にも、Instruction Tuningは最も確実で効果的な第一歩となります。本記事で学んだ「裏側で何が起きているか」という知見を活かし、ぜひご自身のプロジェクトでもLLMのファインチューニングに挑戦してみてください。


※ライセンスに関する注記 本記事のコードおよび実行例で利用しているベースモデル(Qwen/Qwen2.5-0.5B)およびデータセット(llm-jp/hh-rlhf-12k-ja)は、提供元のライセンス(Apache License 2.0 等)に準拠して利用しています。

本記事の文章・構成の一部に生成AIを使用しています。