In-Context Learning (Few-Shot) とは?
(画像は、Geminiで作成されたものです)
In-Context Learningの概要
In-Context Learning(インコンテキスト・ラーニング)または Few-Shot Learning(フューショット学習)とは、事前学習済みの巨大な言語モデルに対してパラメータ(重み)の更新(ファインチューニング)を一切行わず、推論時の入力テキスト(コンテキスト)内にいくつかの解答例を提示するだけで、新しいタスクを解かせる手法です。
この手法は、OpenAIが発表した1750億(175B)パラメータを持つ巨大言語モデル「GPT-3」の論文(Language Models are Few-Shot Learners)によってその絶大な有効性が実証されました。GPT-3は、事前のタスク固有の学習を行わずとも、翻訳、質問応答、読解タスクなど多くのNLPベンチマークで、当時のファインチューニングされた最先端モデルに匹敵、あるいはそれを凌駕する驚異的な性能を示し、今日のプロンプトエンジニアリングの基礎となるパラダイムシフトを引き起こしました。
これまでのパラダイムの課題とIn-Context Learningの位置づけ
GPT-3登場以前の自然言語処理においては、事前学習済みモデルを特定のタスクごとにファインチューニングする手法が主流でしたが、いくつかの重大な課題がありました。
- 大量の学習データが必要: 高い性能を発揮するには、タスクごとに数千から数十万のラベル付けされた専用の学習データを用意する必要があり、実用面で大きな障壁となっていました。
- 分布外への汎化能力の低下(過剰適合): モデルを大規模にして表現力を高めても、狭いタスクの分布でファインチューニングを行うことで、訓練データに含まれる表面的な相関関係(Spurious correlations)を学習してしまい、学習データの分布外に対する汎化性能が低下する問題が指摘されていました。
- 人間の学習プロセスとの乖離: 人間は、大抵の場合「自然言語による簡単な指示」や「数個の例」を見せられるだけで、新しい言語タスクを理解し実行することができます。タスクごとに膨大なデータで再学習を繰り返す手法は、人間の持つ柔軟性とはかけ離れていました。
In-Context Learning は、これらの課題を解決するためのアプローチです。言語モデルの文脈ウィンドウ(コンテキスト)内にタスクの説明といくつかのデモンストレーションを入力するだけで、モデルはその場でタスクのパターンを「メタ学習」し、適切な応答を予測します。
3つの学習設定(Zero-shot, One-shot, Few-shot)
GPT-3の論文では、タスク固有のデータセットへの依存度合いに応じて、以下の3つの設定を定義し評価を行っています。いずれの設定でも勾配の更新は行われません。
- Zero-shot(ゼロショット): モデルにデモンストレーション(例)を一切与えず、タスクを説明する自然言語の指示のみを与えます。最も人間のテストに近い、難易度の高い設定です。
- One-shot(ワンショット): タスクの説明に加えて、1つだけタスクのデモンストレーションを与えます。人間に対して「例えばこのようにやります」と1度だけ教える状況に近く、タスクのフォーマットを伝えるのに有効です。
- Few-shot(フューショット): モデルのコンテキストウィンドウ(GPT-3の場合は2048トークン)に収まる範囲で、可能な限りのデモンストレーション(通常10〜100個)を与えます。タスク固有のデータは少量必要ですが、ファインチューニングに比べれば圧倒的に少なく済みます。
性能を飛躍させるメカニズムとスケーリング則
In-Context Learningにおける最も重要な発見は、 「モデルの規模(パラメータ数)が大きくなるほど、コンテキスト内の情報から学習する能力が劇的に向上する」 というスケーリング則です。
論文では、0.1B〜175Bまでのモデルで比較実験を行っています。小規模なモデルでは、Few-shotの例を増やしてもほとんど性能が向上しませんが、モデルサイズが大きくなるにつれて「In-Context Learning曲線(例の数と精度のグラフ)」の傾きが急激に跳ね上がり、巨大モデルでは数個〜数十個の例を与えるだけで劇的に精度が向上することが示されました。これは、巨大なモデルが事前学習で吸収した膨大な知識とパターン認識能力を、推論時に引き出せるようになったためと考えられています。
本記事では後半で、このスケーリング則を日本語モデルを実際にサイズ違いで動かして追試します。
選択式タスクの評価メカニズム(尤度比較)
In-Context Learningを用いてQAや常識推論などの「選択式タスク(Multiple Choice)」を解かせる際、言語モデルに自由にテキストを生成させると、フォーマットが崩れたり、正解の選択肢以外の言葉を出力してしまう可能性があります。
GPT-3では、これを防ぎ正確に性能を評価するために、各選択肢の「言語モデルとしての尤度(LM likelihood)」を計算して比較するという手法を採用しています。具体的には、コンテキスト に続けて、長さ の選択肢 をモデルに入力した際の対数尤度を計算します。文字数(トークン数)の異なる選択肢間で有利・不利が生じないよう、この長さ で正規化した以下の数式の値(per-token log-likelihood)をスコアとして算出し、最も数値が高いものをモデルの解答として扱います。
In-Context Learningのプロンプト構築と評価の実装
ここでは、GPT-3の論文で行われている「Zero/One/Few-shotのプロンプト構築」と「選択式タスクにおける尤度比較による解答」のメカニズムを、PythonとHugging Face transformers を用いてスクラッチで実装してみましょう。
(※フルサイズのGPT-3を実行することは環境的に困難なため、ここではローカルで動く小規模な日本語モデルを代用して、仕組みの概念を日本語のタスクで再現します)
日本語で実装する際の注意点
英語向けのサンプルをそのまま日本語に置き換えると動作が破綻する箇所があるため、本記事では以下の3点を日本語向けに調整しています。
- モデルの選択:
GPT-2(英語)のような英語コーパス中心のモデルは、日本語をバイト単位に近い形で細切れにトークン化してしまい、尤度比較がほとんど意味を成しません。ここでは日本語コーパスで事前学習された
cyberagent/open-calm-*シリーズ(GPT-NeoX系)を使用します。同シリーズは160Mから6.8Bまで6サイズが公開されており、同一のアーキテクチャと学習データのままサイズだけを変えて比較できるため、スケーリング則の追試にうってつけです。 - 単語区切りの扱い: 英語では「コンテキスト + 半角スペース + 選択肢」という結合が自然ですが、日本語は単語間に空白を入れません。そのため、文字列はスペースを挟まずに結合し、選択肢のトークン長も「(コンテキスト+選択肢)のトークン数 − コンテキストのみのトークン数」という差分で求めます。この方法なら、境界でサブワードが結合しても長さのズレが起きにくくなります。
- 区切り文字の一貫性:
デモンストレーションが
入力 => 出力という書式なら、スコアを測る選択肢側も同じ書式(=>の直後にスペース)に揃える必要があります。本記事では空白をコンテキストの末尾ではなく選択肢の先頭に付けます。byte-level BPE では末尾の空白が独立したトークンになり、続く語のトークン化を乱すためで、これはlm-eval-harnessなどの評価ライブラリでも採られている定石です。
ライブラリのインストール
以下のコードでは、Hugging Faceのtransformersをはじめ、モデルの推論に必要なPyTorch(torch)およびトークナイザーの依存ライブラリであるsentencepieceをインストールします。
!pip install transformers torch sentencepiece
- transformers: Hugging Faceが提供する事前学習済みモデルを扱うためのライブラリです。
- torch: PyTorch。モデルの推論計算に必要な深層学習フレームワークです。
- sentencepiece: モデルのトークナイザーが内部で利用するテキスト分割用のライブラリです。
モデルとトークナイザのロード
ここでは、transformersを用いてサイバーエージェントの日本語LLM「OpenCALM」の最小モデル(160M)と専用のトークナイザをロードします。併せて、この日本語モデル特有のバイト単位BPE(byte-level BPE)によるトークン化がどのように行われるかをテスト文で確認します。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
# まずは最小サイズ(160M)で仕組みを確認する
MODEL_ID = "cyberagent/open-calm-small"
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID).to(device)
model.eval() # 推論モード
# 日本語がどのようにトークン化されるかを確認しておく
sample = "ラッコは道具を使う数少ない動物です。"
ids = tokenizer(sample)["input_ids"]
print(f"トークン数: {len(ids)}")
for i in ids:
# tokenize() の出力は byte-level BPE の内部表現なので、decode して可読化する
print(f"{tokenizer.convert_ids_to_tokens([i])[0]:<16} -> {tokenizer.decode([i])}")
実行結果:
トークン数: 9
ãĥ© -> ラ
ãĥĥãĤ³ -> ッコ
ãģ¯ -> は
éģĵåħ· -> 道具
ãĤĴ使ãģĨ -> を使う
æķ°å°ijãģªãģĦ -> 数少ない
åĭķçī© -> 動物
ãģ§ãģĻ -> です
ãĢĤ -> 。
「道具」「数少ない」「動物」といった語がそれぞれ1トークンにまとまっていれば、日本語モデルが正しくロードできている証拠です。英語モデルのGPT-2で同じ文を入力すると1文字が3トークン近くに分解されてしまい、この後の尤度比較がほとんど機能しません。
なお tokenizer.tokenize() を直接 print すると ãĥ© のような文字列が並びますが、これは文字化けではなく byte-level BPE の内部表現(UTF-8の各バイトを表示可能な文字に1対1で写像したもの)です。英語モデルで空白が Ġ と表示されるのと同じ仕組みで、上のように decode() を通せば元の日本語に戻ります。
- モデルのロード: AutoModelForCausalLM.from_pretrained() でOpenCALMの因果言語モデル(Causal LM)をロードし、model.eval() によってモデルを推論モードに切り替えています。これにより、Dropoutなどの学習用レイヤーが無効化され、推論結果が安定します。
- テキストのトークン化: tokenizer() を用いて日本語のテキストを入力すると、テキストはモデルが処理可能なIDのリスト(input_ids)に変換されます。
- IDから文字列への復元: 表示ループ内で使用している tokenizer.convert_ids_to_tokens() により、IDがbyte-level BPEの文字列表現に変換されます。それをさらに tokenizer.decode() することで、元の日本語文字列として復元できる仕組みを確認しています。
プロンプト構築関数(Zero/One/Few-shot対応)
論文のフォーマットに従い、与えられた例の数(k_shots)に応じてプロンプト文字列を動的に組み立てる関数を定義します。
def build_prompt(task_description, examples, query, k_shots=0):
"""
k_shotsの数に応じてZero-shot, One-shot, Few-shotのプロンプトを構築する
"""
# 1. タスクの自然言語による指示
prompt = task_description + "\n\n"
# 2. K-shotの数だけデモンストレーションを追加
for i in range(min(k_shots, len(examples))):
prompt += f"{examples[i]['input']} => {examples[i]['output']}\n"
# 3. 最後に推論させたいクエリを追加(末尾に空白は置かない)
prompt += f"{query} =>"
return prompt
# サンプルデータ1(英日翻訳:GPT-3論文と同じ単語を日本語訳にしたもの)
task_desc = "英語を日本語に翻訳してください:"
examples = [
{"input": "sea otter", "output": "ラッコ"},
{"input": "peppermint", "output": "ペパーミント"},
{"input": "plush giraffe", "output": "キリンのぬいぐるみ"}
]
query = "cheese"
print("■ Zero-shot Prompt:\n" + build_prompt(task_desc, examples, query, k_shots=0) + "\n")
print("■ One-shot Prompt:\n" + build_prompt(task_desc, examples, query, k_shots=1) + "\n")
print("■ Few-shot Prompt:\n" + build_prompt(task_desc, examples, query, k_shots=3) + "\n")
実行結果:
■ Zero-shot Prompt:
英語を日本語に翻訳してください:
cheese =>
■ One-shot Prompt:
英語を日本語に翻訳してください:
sea otter => ラッコ
cheese =>
■ Few-shot Prompt:
英語を日本語に翻訳してください:
sea otter => ラッコ
peppermint => ペパーミント
plush giraffe => キリンのぬいぐるみ
cheese =>
タスクの指示は共通で、デモンストレーションの行数だけが 0 → 1 → 3 と増えていく様子が確認できます。いずれも末尾が cheese => で終わっており、モデルは「この続きに日本語訳が来る」というフォーマット自体をコンテキストから読み取ることになります。
- プロンプトの動的構築: build_prompt 関数では、In-Context Learningのためのプロンプト文字列を動的に組み立てています。
- デモンストレーションの追加: min(k_shots, len(examples)) を用いて、指定された k_shots (0, 1, 3など)の数だけデモンストレーション(解答例)をループで連結しています。
- 回答フォーマットの提示: 最も重要なポイントは、文字列の末尾を f"{query} =>" で止めている点です。このようにフォーマットの途中で入力が途切れることで、モデルはこの続きに解答を出力すべきであるという文脈(コンテキスト)を理解します。
補足:区切り記号の
=>は論文のフォーマットに合わせたものです。日本語では 「入力: 〜 / 出力: 〜」のように行頭ラベルを使う形式も一般的で、どちらを選ぶかで 精度が変わることがあります(プロンプトフォーマットへの敏感さも論文で指摘されています)。
実際に解答を生成させる
構築したプロンプトをモデルに入力し、続きを生成させてみます。
generate() はプロンプトを含む全トークン列を返すため、入力長でスライスして新規生成部分だけを取り出す点に注意してください。
def generate_answer(prompt, max_new_tokens=16):
"""プロンプトに続く解答をモデルに生成させる"""
inputs = tokenizer(prompt, return_tensors="pt").to(device)
with torch.no_grad():
tokens = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False, # 再現性のため貪欲法(greedy)で生成する
repetition_penalty=1.05,
# pad_token_id が 0 の場合に or が誤作動するため is not None で判定する
pad_token_id=(
tokenizer.pad_token_id
if tokenizer.pad_token_id is not None
else tokenizer.eos_token_id
),
)
# プロンプト部分を除き、新しく生成されたトークンだけを取り出す
generated = tokens[0][inputs["input_ids"].shape[1]:]
text = tokenizer.decode(generated, skip_special_tokens=True)
# デモは改行区切りなので1行目までを解答とする
# (そうしないとモデルが次の例題を勝手に作り続けてしまう)
return text.split("\n")[0].strip()
for k in (0, 1, 3):
prompt = build_prompt(task_desc, examples, query, k_shots=k)
print(f"■ {k}-shot の出力: {generate_answer(prompt)!r}")
実行結果(open-calm-small / 160M):
■ 0-shot の出力: ''
■ 1-shot の出力: 'チーズ'
■ 3-shot の出力: 'チーズ'
- 0-shotの挙動: 0-shotの場合、モデルには翻訳の例が提示されていないため、
=>の後に解答を続けるというフォーマットを解釈できていません。ここで空文字になっているのは、モデルが最初に改行を出力した結果、後処理の text.split("\n")[0] が空文字を返しているためです(split 前の生の text を print すると、実際に何が生成されたかを確認できます)。 - Few-shotの挙動とメタ学習: 1-shotや3-shotのように少しでも解答例をコンテキストに含めると、モデルは「英語から日本語への翻訳を行うタスクである」こと、および「特定のフォーマットで出力する」ことを即座に学習(メタ学習)し、'チーズ' という正しい翻訳を生成できるようになります。重みは一切更新していないにもかかわらず、例を1つ見せるだけで振る舞いが変わる点が In-Context Learning の核心です。
- 貪欲法による生成: model.generate() において do_sample=False と指定することで、確率的なサンプリングを行わず貪欲法(Greedy Search)で最も確率の高いトークンを選択し、再現性のある出力を得ています。
尤度(Likelihood)の計算関数の実装
選択式タスクにおいて、各選択肢が生成される確率(尤度)を計算します。 実装上の工夫として、コンテキスト部分の損失は無視し、 選択肢(Completion)部分の Cross Entropy Loss の平均(per-token likelihood)を求めて負の値を返します。
def get_completion_likelihood(context_text, completion_text):
"""
コンテキストに続く選択肢(completion_text)が生成される対数尤度を計算する
"""
# 日本語は単語間に空白を入れないため、スペースを挟まずそのまま結合する
# (区切りの空白が必要な場合は completion_text の先頭に付けて渡す)
full_text = context_text + completion_text
inputs = tokenizer(full_text, return_tensors="pt").to(device)
# 選択肢部分のトークン長は「全体 - コンテキストのみ」の差分で求める
# (日本語では境界のサブワードが結合しうるため、個別にトークン化するより安全)
context_ids = tokenizer(context_text, return_tensors="pt")["input_ids"]
completion_len = inputs["input_ids"].shape[1] - context_ids.shape[1]
if completion_len <= 0:
raise ValueError("選択肢のトークン長を取得できませんでした。区切り文字を見直してください。")
with torch.no_grad():
outputs = model(**inputs)
logits = outputs.logits
# LMの自己回帰ロジットを1つずらして評価 (shift)
shift_logits = logits[0, :-1, :].contiguous()
shift_labels = inputs["input_ids"][0, 1:].contiguous()
# トークンごとのLossを計算
loss_fct = torch.nn.CrossEntropyLoss(reduction='none')
token_losses = loss_fct(shift_logits, shift_labels)
# 選択肢部分のLossを抽出し、平均(per-token likelihood)をとる
# ※論文では長さによる有利・不利を正規化するために per-token を使用しています
completion_loss = token_losses[-completion_len:].mean().item()
# 尤度(高いほどもっともらしい)にするためマイナスを返す
return -completion_loss
- コンテキストと選択肢の結合: 選択肢の尤度を計算するため、context_text + completion_text としてコンテキストと選択肢を結合した全文のトークン化を行っています。
- 自己回帰モデルにおけるずらし処理: shift_logits と shift_labels では、自己回帰モデル特有の「次のトークンを予測する」性質に合わせて、ロジット(予測値)と正解ラベルの配列を1つずつずらして対応させています。
- 対数尤度の一括計算: 先ほどの尤度計算の数式にある は、言語モデルが出力するロジットに対してソフトマックス関数を適用した確率の対数です。ここでは torch.nn.CrossEntropyLoss(reduction='none') を用いることで、全トークンに対するこの負の対数尤度(損失)を一括計算しています。
- スコアの算出(正規化): 最後に token_losses[-completion_len:].mean() により、コンテキスト部分を除外して選択肢 に該当するトークンの損失だけを抽出し、さらに数式の に相当する平均(per-token likelihood)をとることで最終的なスコアを求めています。
選択式タスク(JCommonsenseQA風)の実行例
実際に尤度比較を用いて、モデルに日本語の選択式問題の解答を選ばせてみます。 ここでは、英語のPIQAに相当する日本語の常識推論ベンチマークである JGLUE の JCommonsenseQA を模した問題を用意します。
# 常識推論タスク(JCommonsenseQAに類似した問題)の例
context = "質問: 木材にニスを塗るにはどうすればよいですか。\n答え:"
choice_a = "刷毛を使って、木材に十分染み込むまでニスを塗り広げる。"
choice_b = "刷毛を使って、木材に十分染み込むまでニスを垂らす。"
# コンテキスト末尾には空白を置かず、選択肢の先頭に付けて書式を揃える
score_a = get_completion_likelihood(context, " " + choice_a)
score_b = get_completion_likelihood(context, " " + choice_b)
print("■ 選択式タスクの尤度比較結果")
print(f"選択肢A: {choice_a}")
print(f" -> Likelihood: {score_a:.4f}\n")
print(f"選択肢B: {choice_b}")
print(f" -> Likelihood: {score_b:.4f}\n")
# 尤度が高い方をモデルの回答とする
predicted = "選択肢A" if score_a > score_b else "選択肢B"
print(f"★ モデルの予測結果: {predicted}")
実行結果(open-calm-small / 160M):
■ 選択式タスクの尤度比較結果
選択肢A: 刷毛を使って、木材に十分染み込むまでニスを塗り広げる。
-> Likelihood: -3.1426
選択肢B: 刷毛を使って、木材に十分染み込むまでニスを垂らす。
-> Likelihood: -3.1289
★ モデルの予測結果: 選択肢B
尤度(負の損失)がより0に近い(値が大きい)選択肢を、モデルは「より自然な言語のつながりである」と判断し、解答として選択します。これがGPT-3の論文でベンチマークを評価する際に用いられたコアロジックです。
- 各選択肢の尤度計算: 2つの選択肢(choice_a と choice_b)に対してそれぞれ get_completion_likelihood() を呼び出し、各文が生成される尤度を計算しています。
- 尤度による推論結果の決定: score_a > score_b のように単純に値を比較し、より尤度が高い(損失が小さい)選択肢をモデルの推論結果として採用しています。これにより、自由なテキスト生成を行わせるよりも堅牢な評価が可能になっています。
なお、この実行結果でモデルが選んだのは、常識的には不自然な「ニスを垂らす」(選択肢B)のほうです。ただし2つのスコアの差は 0.014 程度しかなく、160Mという小規模なモデルにとって両者はほとんど区別がついていない、事実上のコイントスであると解釈するのが妥当でしょう。
ここで重要なのは、評価の仕組みが正しく動いていることと、モデルが正解できることは別問題だという点です。尤度比較というメカニズム自体は、選択肢のトークン長に依存せず、必ずどちらか一方を選ぶ形で機能しています。あとは「そのスコア差に意味が出るだけの能力をモデルが持っているか」という、モデル規模の問題になります。この点こそが次章で検証するテーマです。
分類タスクを尤度比較で解く
自由生成では「ポジティブです」「ポジ」のように出力が揺れてしまうため、ラベルを選択肢として固定し、尤度で選ばせるのが確実です。ここまでに作った build_prompt と get_completion_likelihood が、そのまま一本の流れとして繋がります。
# サンプルデータ2(日本語のみのタスク:レビューの極性判定)
task_desc_ja = "次のレビュー文がポジティブかネガティブかを判定してください:"
examples_ja = [
{"input": "この店の接客は丁寧で、料理もすぐに出てきた。", "output": "ポジティブ"},
{"input": "注文してから一時間も待たされた上に、味も期待外れだった。", "output": "ネガティブ"},
{"input": "値段の割にボリュームがあり、また来たいと思った。", "output": "ポジティブ"}
]
query_ja = "店内が騒がしく、落ち着いて食事ができなかった。"
LABELS = ["ポジティブ", "ネガティブ"]
prompt_ja = build_prompt(task_desc_ja, examples_ja, query_ja, k_shots=3)
print(prompt_ja + "\n")
# デモが "文 => ラベル" という書式なので、ラベル側も先頭にスペースを付けて揃える
scores = {label: get_completion_likelihood(prompt_ja, " " + label) for label in LABELS}
print("■ ラベルごとの尤度")
for label, score in scores.items():
print(f" {label}: {score:.4f}")
print(f"★ モデルの予測: {max(scores, key=scores.get)}")
print(f"(参考・自由生成の場合: {generate_answer(prompt_ja)!r})")
実行結果(open-calm-small / 160M):
次のレビュー文がポジティブかネガティブかを判定してください:
この店の接客は丁寧で、料理もすぐに出てきた。 => ポジティブ
注文してから一時間も待たされた上に、味も期待外れだった。 => ネガティブ
値段の割にボリュームがあり、また来たいと思った。 => ポジティブ
店内が騒がしく、落ち着いて食事ができなかった。 =>
■ ラベルごとの尤度
ポジティブ: -1.4941
ネガティブ: -0.1895
★ モデルの予測: ネガティブ
(参考・自由生成の場合: 'ネガティブ')
- 複数ラベルの尤度計算: LABELS として「ポジティブ」「ネガティブ」の2つの選択肢を用意し、リスト内包表記を用いてそれぞれのラベルに対する尤度を一括で計算しています。
- 分類タスクの安定化: 自由生成(generate_answer())を用いた場合、出力が揺れたり崩れたりするリスクがありますが、尤度比較(max(scores, key=scores.get))を使用することで、必ず事前に定義したラベル群のいずれかが選択されるようになり、分類タスクにおいて非常に安定的で確実な推論が可能となります。
モデルサイズを変えるとIn-Context Learningはどう変わるか
ここからが本記事の主題であるスケーリング則の追試です。OpenCALM は同一の学習データ・アーキテクチャで6つのサイズが公開されており、公開元の情報によれば、規模が大きくなるほど日本語の言語モデルとしての性能(開発セットのパープレキシティ)が単調に改善していくことが示されています。
| モデル | パラメータ数 | 層数 | 隠れ次元 |
|---|---|---|---|
open-calm-small | 160M | 12 | 768 |
open-calm-medium | 400M | 24 | 1024 |
open-calm-large | 830M | 24 | 1536 |
open-calm-1b | 1.4B | 24 | 2048 |
open-calm-3b | 2.7B | 32 | 2560 |
open-calm-7b | 6.8B | 32 | 4096 |
ここで確かめたいのは「言語モデルとしての性能が上がったか」ではなく、「コンテキスト内の例から学ぶ能力、すなわち shot数を増やしたときの伸びが、規模とともに立ち上がるか」です。論文でいう In-Context Learning 曲線の傾きにあたります。
そこで本章では、160Mから6.8Bまでの6モデルに対して、日本語の標準ベンチマークである JGLUE の JCommonsenseQA を 0/1/2/4/8/16-shot で解かせ、正解率がどう変化するかを比較します。
実験コードを再利用できる形にまとめる
複数のモデルを順番に切り替えるため、ここまでの処理をクラスにまとめ直します。
import gc
class LM:
"""モデルごとに尤度計算をまとめて扱うためのラッパ"""
def __init__(self, model_id, load_in_4bit=False):
self.model_id = model_id
self.device = "cuda" if torch.cuda.is_available() else "cpu"
# GPUがある場合のみfp16でメモリを節約する(CPUのfp16は非常に遅いため)
dtype = torch.float16 if self.device == "cuda" else torch.float32
self.tokenizer = AutoTokenizer.from_pretrained(model_id)
# バッチ処理のためパディングトークンを用意する(スコア計算なので右詰めで問題ない)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
self.tokenizer.padding_side = "right"
kwargs = {"torch_dtype": dtype} # ※transformers v5系では dtype を指定
if load_in_4bit:
# 7Bをコンシューマ向けGPUに載せる場合の選択肢(要 bitsandbytes)
from transformers import BitsAndBytesConfig
kwargs["quantization_config"] = BitsAndBytesConfig(load_in_4bit=True)
kwargs["device_map"] = "auto"
elif self.device == "cuda":
kwargs["device_map"] = "auto"
self.model = AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
self.model.eval()
def likelihood_batch(self, context_text, completions):
"""1つのコンテキストに対する複数の選択肢の対数尤度をまとめて計算する"""
texts = [context_text + c for c in completions]
# 各選択肢のトークン長を「全体 - コンテキストのみ」の差分で求める
ctx_len = len(self.tokenizer(context_text)["input_ids"])
comp_lens = [
len(self.tokenizer(t)["input_ids"]) - ctx_len for t in texts
]
if min(comp_lens) <= 0:
raise ValueError("選択肢のトークン長を取得できませんでした。")
enc = self.tokenizer(texts, return_tensors="pt", padding=True).to(self.device)
with torch.no_grad():
logits = self.model(**enc).logits
# fp16のままCross Entropyを計算すると桁落ちするため、float32に戻す
shift_logits = logits[:, :-1].float()
shift_labels = enc["input_ids"][:, 1:]
loss_fct = torch.nn.CrossEntropyLoss(reduction="none")
# (B, V, T-1) と (B, T-1) の形に合わせてトークンごとのLossを求める
token_losses = loss_fct(shift_logits.transpose(1, 2), shift_labels)
# 各行で「選択肢トークンを予測している位置」だけをTrueにするマスクを作る
# losses[b, j] は input_ids[b, j+1] の予測誤差なので、添字が1つずれる点に注意
mask = torch.zeros_like(token_losses, dtype=torch.bool)
real_lens = enc["attention_mask"].sum(dim=1)
for b, (rl, cl) in enumerate(zip(real_lens.tolist(), comp_lens)):
mask[b, rl - cl - 1: rl - 1] = True
scores = -(token_losses * mask).sum(dim=1) / mask.sum(dim=1)
return scores.tolist()
def close(self):
"""次のモデルをロードする前にメモリを解放する"""
del self.model
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
前節までの get_completion_likelihood() は1回の呼び出しで1つの選択肢しか評価できませんでしたが、JCommonsenseQAは5択なので、5つの選択肢を1回の順伝播でまとめて処理するように書き換えています。右パディングと attention_mask を併用しており、パディングの有無でスコアが変わらないことは確認済みです(因果的注意のため、系列の後ろに付いたパディングは前方の位置に影響しません)。
- モデル処理のカプセル化: LM というクラスを定義し、異なるサイズのモデルを初期化・推論・解放するプロセスをカプセル化しています。
- メモリ使用量の削減(fp16): torch_dtype=torch.float16 を指定してモデルをロードすることで、GPUメモリの使用量を半減させ、限られたリソースでもより大きなパラメータ数のモデルをロードできるようにしています。目安は fp16 で
1bが約2.8GB、3bが約5.5GB、7bが約13.6GB です。 - 4bit量子化によるロード: load_in_4bit=True を指定すると4bit量子化でロードします。VRAM 16GB程度の環境で
7bを動かす場合の選択肢ですが、量子化によってロジットがわずかに変化するため、他のモデルと厳密に条件を揃えたい場合はfp16での実行が望ましいです。 - 損失関数のための整形と精度維持: shift_logits.transpose(1, 2) は、CrossEntropyLoss がクラス次元を2番目に要求するための整形です。あわせて .float() により32ビット浮動小数点数へキャストし、fp16のまま計算した際のアンダーフロー(桁落ち)を防いでいます。
- メモリの明示的な解放: close() メソッドにて del self.model や gc.collect()、torch.cuda.empty_cache() を明示的に呼び出し、次のモデルをロードする前にGPUのVRAMを確実に解放しています。
評価用データ(JGLUE / JCommonsenseQA)の読み込み
自作の数件では偶然の影響が大きすぎるため、ここからは日本語の標準ベンチマークである JGLUE の JCommonsenseQA を使います。先ほど自作した「ニスの塗り方」のような常識推論問題が、5択形式で1,000問以上収録されているデータセットです。
Hugging Faceの shunk031/JGLUE から読み込む方法もありますが、こちらはローディングスクリプト方式のため datasets v4系では動作せず、trust_remote_code=True と旧バージョンへのピン留めが必要になります。ここでは依存関係を増やさないよう、JGLUE公式リポジトリのJSONを直接取得します。
import json, random, urllib.request
from statistics import mean, pstdev
BASE = ("https://raw.githubusercontent.com/yahoojapan/JGLUE/main/"
"datasets/jcommonsenseqa-v1.3/")
def load_jsonl(url):
with urllib.request.urlopen(url) as r:
return [json.loads(line) for line in r.read().decode("utf-8").splitlines() if line.strip()]
train_data = load_jsonl(BASE + "train-v1.3.json") # デモンストレーション用
valid_data = load_jsonl(BASE + "valid-v1.3.json") # 評価用
print(f"train: {len(train_data)}件 / valid: {len(valid_data)}件")
print(train_data[0])
実行結果:
train: 8939件 / valid: 1119件
{'q_id': 0, 'question': '主に子ども向けのもので、イラストのついた物語が書かれているものはどれ?', 'choice0': '世界', 'choice1': '写真集', 'choice2': '絵本', 'choice3': '絵画', 'choice4': '図鑑', 'label': 2}
各問題は question と choice0〜choice4 の5つの選択肢を持ち、label に正解のインデックス(0〜4)が入っています。5択なので、でたらめに答えた場合の正解率は0.20です。二値分類の0.50に比べて偶然の底が低いぶん、モデルの能力差が数値に表れやすくなります。
- データセットの取得: load_jsonl() では、JGLUE公式リポジトリが配布しているJSON Lines形式(1行1問)のファイルを取得して辞書のリストに変換しています。
- データリークの防止: デモンストレーションは train_data から、評価は valid_data から取ることで、評価問題そのものを例として見せてしまうリークを防いでいます。
補足:JGLUEにはMARC-jaという二値の文書分類タスクも含まれていますが、原典であるAmazonのMultilingual Amazon Reviews Corpusが配布停止となった影響で、現在は公式リポジトリからデータを取得できません。日本語で分類タスクの評価を行いたい場合は、WRIMEなど別のデータセットを検討してください。
プロンプト構築と評価ループ
5択問題用にプロンプトを組み立て、各選択肢の尤度を比較して最も高いものを解答とします。
TASK_DESC_QA = "質問に対する最も適切な答えを選んでください。"
def choices_of(ex):
return [ex[f"choice{i}"] for i in range(5)]
def build_qa_prompt(demos, query):
"""デモンストレーションを並べ、最後にクエリを「答え:」で止めたプロンプトを作る"""
prompt = TASK_DESC_QA + "\n\n"
for d in demos:
prompt += f"質問: {d['question']}\n答え: {choices_of(d)[d['label']]}\n\n"
prompt += f"質問: {query['question']}\n答え:"
return prompt
def evaluate(lm, k_shots, n_eval, seed):
"""指定したshot数での正解率を返す"""
rng = random.Random(seed)
demos = rng.sample(train_data, k_shots) if k_shots > 0 else []
# 評価に使う問題は全モデル・全条件で共通にする(seedを固定)
subset = random.Random(0).sample(valid_data, n_eval)
correct = 0
for ex in subset:
prompt = build_qa_prompt(demos, ex)
# デモが「答え: 選択肢」という書式なので、選択肢側の先頭にスペースを付ける
scores = lm.likelihood_batch(prompt, [" " + c for c in choices_of(ex)])
if max(range(5), key=lambda i: scores[i]) == ex["label"]:
correct += 1
return correct / n_eval
例の並び順ひとつで精度が変わることが知られているため、デモの抽出シードを変えた複数試行の平均と標準偏差を取ります。
MODEL_IDS = [
"cyberagent/open-calm-small", # 160M
"cyberagent/open-calm-medium", # 400M
"cyberagent/open-calm-large", # 830M
"cyberagent/open-calm-1b", # 1.4B
"cyberagent/open-calm-3b", # 2.7B
"cyberagent/open-calm-7b", # 6.8B
]
K_SHOTS = [0, 1, 2, 4, 8, 16]
SEEDS = [0, 1, 2] # デモの選び方を変えた試行回数
N_EVAL = 200 # 評価に使う問題数(まずは50程度で動作確認するとよい)
results = {}
for model_id in MODEL_IDS:
# 7Bのみ4bit量子化でロードする(VRAMに余裕があればfp16で揃えるのが望ましい)
lm = LM(model_id, load_in_4bit=model_id.endswith("7b"))
results[model_id] = {}
for k in K_SHOTS:
# 0-shotはデモを使わないためシードを変えても結果は同じ。1回だけ実行する
seeds = SEEDS if k > 0 else SEEDS[:1]
accs = [evaluate(lm, k, N_EVAL, seed) for seed in seeds]
results[model_id][k] = (mean(accs), pstdev(accs) if len(accs) > 1 else 0.0)
lm.close()
row = " ".join(
f"{k}-shot: {results[model_id][k][0]:.3f}±{results[model_id][k][1]:.3f}"
for k in K_SHOTS
)
print(f"{model_id:<30} {row}")
実行結果:
cyberagent/open-calm-small 0-shot: 0.560±0.000 1-shot: 0.552±0.037 2-shot: 0.518±0.014 4-shot: 0.538±0.019 8-shot: 0.515±0.014 16-shot: 0.522±0.029
cyberagent/open-calm-medium 0-shot: 0.650±0.000 1-shot: 0.628±0.035 2-shot: 0.645±0.053 4-shot: 0.627±0.019 8-shot: 0.643±0.020 16-shot: 0.640±0.000
cyberagent/open-calm-large 0-shot: 0.675±0.000 1-shot: 0.685±0.029 2-shot: 0.707±0.023 4-shot: 0.688±0.022 8-shot: 0.710±0.022 16-shot: 0.735±0.023
cyberagent/open-calm-1b 0-shot: 0.670±0.000 1-shot: 0.698±0.025 2-shot: 0.717±0.034 4-shot: 0.728±0.010 8-shot: 0.728±0.027 16-shot: 0.737±0.010
cyberagent/open-calm-3b 0-shot: 0.710±0.000 1-shot: 0.737±0.025 2-shot: 0.755±0.032 4-shot: 0.760±0.016 8-shot: 0.767±0.012 16-shot: 0.765±0.018
cyberagent/open-calm-7b 0-shot: 0.675±0.000 1-shot: 0.722±0.037 2-shot: 0.748±0.025 4-shot: 0.777±0.006 8-shot: 0.772±0.008 16-shot: 0.775±0.011
- ショット数の対数的な変化: K_SHOTS を 0, 1, 2, 4, 8, 16 と対数的に振ることで、論文の「In-Context Learning曲線」に相当する形が描けるようにしています。
- 複数試行による評価の安定化: SEEDS でデモンストレーションの抽出を変えた試行を繰り返し、mean と pstdev で平均と標準偏差を求めています。標準偏差が大きい場合、その数値は「たまたま良い例が引けただけ」の可能性が高いと判断できます。
- 評価問題の固定: 評価対象の問題は random.Random(0).sample(...) と固定シードで抽出しており、すべてのモデル・すべてのshot数で同一の問題を解かせています。これにより、比較対象間の差が問題の難易度差に由来しないことを保証しています。
実行時間の目安:
N_EVAL=200、SEEDS=3、6モデル×6条件では順伝播が2万回以上になります。まずはN_EVAL=50、SEEDS=[0]、MODEL_IDSを2つに絞って全体が通ることを確認してから、本番の設定に広げることをおすすめします。特に7bは16-shotのプロンプトが長くなるため、実行時間とVRAMの両方に注意してください。
結果
各セルは 平均±標準偏差(デモの抽出シードを変えた3試行)です。5択のため、でたらめに答えた場合の期待値は0.20になります。
| モデル | パラメータ数 | 0-shot | 1-shot | 2-shot | 4-shot | 8-shot | 16-shot | 0→16の伸び |
|---|---|---|---|---|---|---|---|---|
open-calm-small | 160M | 0.560±0.000 | 0.552±0.037 | 0.518±0.014 | 0.538±0.019 | 0.515±0.014 | 0.522±0.029 | -0.038 |
open-calm-medium | 400M | 0.650±0.000 | 0.628±0.035 | 0.645±0.053 | 0.627±0.019 | 0.643±0.020 | 0.640±0.000 | -0.010 |
open-calm-large | 830M | 0.675±0.000 | 0.685±0.029 | 0.707±0.023 | 0.688±0.022 | 0.710±0.022 | 0.735±0.023 | 0.060 |
open-calm-1b | 1.4B | 0.670±0.000 | 0.698±0.025 | 0.717±0.034 | 0.728±0.010 | 0.728±0.027 | 0.737±0.010 | 0.067 |
open-calm-3b | 2.7B | 0.710±0.000 | 0.737±0.025 | 0.755±0.032 | 0.760±0.016 | 0.767±0.012 | 0.765±0.018 | 0.055 |
open-calm-7b | 6.8B | 0.675±0.000 | 0.722±0.037 | 0.748±0.025 | 0.777±0.006 | 0.772±0.008 | 0.775±0.011 | 0.100 |
結果の可視化
数値の表だけでは曲線の形が掴みにくいため、横軸をshot数、縦軸を正解率としてプロットします。
!pip install japanize-matplotlib
import matplotlib.pyplot as plt
import japanize_matplotlib # 日本語ラベルの文字化けを防ぐ
import numpy as np
x = np.arange(len(K_SHOTS))
fig, ax = plt.subplots(figsize=(8, 5), dpi=150)
for model_id in MODEL_IDS:
means = [results[model_id][k][0] for k in K_SHOTS]
stds = [results[model_id][k][1] for k in K_SHOTS]
ax.errorbar(x, means, yerr=stds, marker="o", capsize=3,
label=model_id.split("/")[-1])
ax.axhline(0.2, ls="--", lw=1.2, color="#888") # 5択の偶然の水準
ax.text(0.05, 0.215, "偶然の水準 (0.20)", color="#666", fontsize=9)
ax.set_xticks(x)
ax.set_xticklabels([str(k) for k in K_SHOTS])
ax.set_xlabel("デモンストレーションの数(shot数)")
ax.set_ylabel(f"正解率 (JCommonsenseQA, n={N_EVAL})")
ax.set_title("モデル規模別のIn-Context Learning曲線")
ax.set_ylim(0.15, 0.85)
ax.grid(alpha=0.25)
ax.legend(fontsize=9, loc="lower right", ncol=2)
fig.tight_layout()
plt.show()

エラーバーは、デモンストレーションの選び方(シード)を変えた3試行の標準偏差です。
考察
まず、すべてのモデルが偶然の水準を大きく上回っています。 5択なのででたらめに答えれば0.20ですが、最小の160Mモデルでも0.52前後、最大の6.8Bモデルでは0.78近くに達しています。ただしこの絶対値をそのまま「常識推論ができている」と読むのは危険です。尤度比較という評価方法は、選択肢の文字列そのものが日本語として自然かどうかにも強く影響されます。JCommonsenseQAの正解選択肢は文脈を無視しても出現しやすい語であることが多いため、推論をせずとも言語的なもっともらしさだけである程度は当たってしまいます。0-shotで既に0.5〜0.7という高い水準にあるのは、その寄与が大きいと考えられます。
次に、shot数を増やしたときの振る舞いが、モデル規模によってはっきり分かれています。 160Mと400Mのモデルは、例をいくつ与えても正解率がほとんど動きません(0→16の変化はそれぞれ -0.038、-0.010)。試行間の標準偏差が0.01〜0.05あることを踏まえると、これらは「悪化した」というより平坦、つまり例から何も学べていないと解釈するのが妥当です。一方、830M以降のモデルはいずれも右肩上がりに転じ、6.8Bのモデルでは +0.100 と最大の伸びを示しました。モデルが大きいほどIn-Context Learning曲線の傾きが急になるという、GPT-3論文の中心的な主張と一致する結果です。
特に興味深いのは、shot数によってモデルの順位が入れ替わる点です。 0-shotの時点では2.7Bのモデルが最も高く、6.8Bのモデルは830Mと同水準にとどまっています。ところが4-shot以降は6.8Bのモデルが全モデル中トップに立ち、最後までその座を譲りません。これは「言語モデルとしての素の性能」と「例から学ぶ能力」が別の軸であることを端的に示しており、後者こそが規模とともに立ち上がる能力だという論文の指摘を裏づけています。
もう一点、伸びが頭打ちになるタイミングにも差があります。 6.8Bのモデルは4-shotでほぼ最高値(0.777)に達し、それ以降は8-shot、16-shotと増やしてもほとんど変化しません。対して830Mや1.4Bのモデルは16-shotまで緩やかに伸び続けています。大きいモデルほど少ない例でタスクの形式を掴んでしまうわけで、これもFew-shot Learningという呼び名の妥当性を支える観察と言えます。
なお、6.8Bのモデルの0-shotが規模の傾向から外れて低い点については、注意が必要です。今回このモデルだけをVRAMの都合で4bit量子化して実行しているため、他のモデルと数値的な条件が揃っていません。量子化は出力分布をわずかに変化させるため、手がかりの少ない0-shotで特に影響が出やすいと考えられます。ただし4-shot以降は全モデル中で最高の正解率を示しており、本実験の主眼である「傾き」に関する結論は変わりません。fp16で揃えて実行すれば、0-shotの数値はさらに高くなる可能性があります。
一方で、慎重に見るべき差もあります。評価問題数が200件のとき、正解率の標準誤差はおよそ ±0.03 です。830Mと1.4Bのモデルの差(0-shotで0.005、16-shotで0.002)はこの範囲に収まっており、この2つの間に性能差があるとは言えません。表の数値を細かく比較するのではなく、あくまで「規模の桁が変わったときに傾向がどう変わるか」を読むべきデータです。
この実験の限界
JGLUEの本番データを用い、shot数も試行回数も広げた実験ですが、それでも以下の制約は残ります。結果を解釈する際は、これらの限界を考慮する必要があります。
- 評価問題を全件使っていない: 実行時間の都合で valid の1,119問から
N_EVAL件をサンプリングしています。200件の場合、正解率の標準誤差はおよそ ±0.03 です。モデル間の差がこれを下回る場合、差があるとは言えません。 - タスクが1種類のみ: JCommonsenseQAは常識推論に特化しており、ここでの傾向がそのまま翻訳や要約に当てはまるとは限りません。論文が数十のベンチマークで評価しているのは、タスクによって曲線の形が大きく異なるためです。
- プロンプト形式を1つしか試していない: 「質問:/答え:」という書式を固定していますが、区切り文字や指示文を変えるだけで精度が数ポイント動くことが知られています。
- モデルの規模帯が足りない: 今回は160M〜6.8Bで、GPT-3論文の175Bとは1桁以上の開きがあります。論文で「Few-shotの伸びが急に立ち上がる」とされた規模帯には届いていません。
- 7Bのみ量子化して実行している: VRAMの都合で
7bだけを4bit量子化しており、他モデルと数値的な条件が揃っていません。上述のとおり0-shotの数値に影響が出ている可能性があります。厳密な比較を行う場合は、全モデルを同じ精度で実行してください。
より広い規模帯まで確かめたい場合は、MODEL_IDS に llm-jp/llm-jp-3-13b や Qwen 系の日本語対応モデルなどを追加する方法もあります。ただしその場合は学習データもアーキテクチャも異なるため、「規模の効果だけを取り出す」という本実験の設計思想からは外れる点に注意してください。
まとめ
本記事では、GPT-3の論文で示された In-Context Learning (Few-Shot Learning) の概念と、その裏側にある評価メカニズムについて解説しました。
記事を通じて、以下の内容を実践しました。
- In-Context Learningの概念: 勾配の更新(ファインチューニング)を行わず、プロンプトにデモンストレーションを含めるだけでモデルを適応させる手法の強力さを学びました。
- プロンプトエンジニアリングの基礎: Zero-shot、One-shot、Few-shotという、現在のLLM活用において当たり前となった概念と、プロンプトの動的な構築方法を実装しました。
- 尤度(Likelihood)による評価: 自由記述によるフォーマット崩れを防ぐため、各選択肢の「尤度(Likelihood)」を算出し、スコアを比較して回答を決定する、LLM特有の評価メカニズムをPyTorchで実装しました。
- スケーリング則の追試: 同一シリーズの日本語モデルを160Mから6.8Bまでサイズ違いで動かし、JGLUEのJCommonsenseQAで評価しました。小さいモデルでは例を増やしても正解率が平坦なままである一方、規模が大きくなるほど右肩上がりの傾きが立ち上がるという、論文の主張と一致する挙動を確認しました。あわせて、複数試行の標準偏差を取ることで、得られた差がデモの選び方による揺らぎと区別できるかを検証しました。
- 日本語特有の実装上の注意: 日本語モデルの選択、空白区切りを前提にしないトークン長の求め方、区切り文字の一貫性という、日本語でIn-Context Learningを実装する際の勘所を確認しました。
現在、Hugging Faceが提供する lm-eval-harness などの標準的なLLM評価ライブラリの内部では、今回スクラッチで実装したような「Few-shotプロンプトの動的生成」と「Cross Entropy Lossを用いた選択肢ごとの尤度比較」が高度に自動化されて動いています。LLMが裏側でどのように推論し、どのようにスコアを算出しているのかを理解することは、より精度の高いプロンプトの開発やモデルの挙動のデバッグに大きく役立ちます。
※ライセンスに関する注記
本記事のコードおよび実行例で利用しているベースモデル(cyberagent/open-calm-*)は、提供元のライセンスに準拠して利用しています。
OpenCALM は 株式会社サイバーエージェント により開発され、Creative Commons Attribution-ShareAlike 4.0 International(CC BY-SA 4.0)ライセンスで公開されています。利用の際は提供元へのクレジット表記が必要です。
モデル配布元: Hugging Face (cyberagent/open-calm-small)
また、評価に用いた JGLUE(JCommonsenseQA)は、ヤフー株式会社と早稲田大学河原研究室の共同研究プロジェクトにより構築されたデータセットで、CC BY-SA 4.0 ライセンスで公開されています。 データセット配布元: yahoojapan/JGLUE
本記事の文章・構成の一部に生成AIを使用しています。