前回の記事では、DSPy Optimizerのオプション変更による挙動の変更を理解する材料として BootstrapFewShot 、BootstrapFewShotWithRandomSearch 、MIPROv2 を紹介しました。Optimizerを構成する要素として「プログラム本体」「学習データ(trainset)」「評価関数(metric)」の3つがありますが、今回はその中でも最も重要な metric に焦点を当てます。
metric とは
metricはOptimizerの「目」と言われる存在です。metricの選び方ひとつで、最適化の結果が大きく変わります。metricの役割を理解するには、Optimizerの内部で何が起きているかを知る必要があります。BootstrapFewShot を例に、metricが登場するタイミングを見ていきます。
ステップ1: LLMに学習データを解かせる
Optimizerはまず、学習データ(trainset)の質問をLLMに解かせます。例えば「富士山の標高は?」という質問に対して、LLMが「富士山の標高は3,776メートルです」と回答したとします。
ステップ2: metricで合否判定する
次に、metricがこの回答を評価します。学習データには正解(この場合「3776m」)が用意されているので、metricは「LLMの回答」と「正解」を照合して、良い回答かどうかを判定します。
ここで、metricの方式の違いによって判定結果が変わります。例えば以下3つのmetricがあったとします。
- 部分一致metric: 「3776m」が「富士山の標高は3,776メートルです」に含まれるか? → 含まれていない → ❌
- 完全一致metric: 「3776m」と「富士山の標高は3,776メートルです」が完全に同じか? → 違う → ❌
- LLM採点metric: LLMに「3776mと3,776メートルは同じ意味か?」と聞く → 同じ → ✅
ステップ3: 合格した回答だけをお手本として採用する
metricで✅と判定された回答だけが、お手本としてプロンプトに組み込まれます。❌と判定された回答は捨てられます。
つまり、metricは「どんな回答をお手本として採用するかのフィルター」です。厳しいmetricを使えば厳しい基準のお手本だけが残り、寛容なmetricを使えば幅広い回答がお手本として採用されます。
さっそくやってみる
上記で解説した流れをスクリプトでやってみます。
"""
DSPy metric 比較デモ: 同じ回答を3つのmetricで採点するとどうなるか?
構成:
Step 1: BootstrapFewShotで最適化(部分一致metricを使用)
Step 2: 最適化済みモジュールでテスト質問に回答させる
Step 3: 同じ回答を3つのmetricで採点して、スコアの違いを比較
ポイント:
trainsetの正解を「短い表記」にしておく(例: "3776m", "明智光秀")
LLMが「3776メートル」「明智光秀です」のように返すと、
metricごとに合否が割れる → 違いが明確に見える
前提:
source ~/dspy-env/bin/activate
pip install dspy-ai openai
export OPENAI_API_KEY="sk-your-key-here"
実行:
python dspy_metric_comparison.py
"""
import dspy
# ============================================================
# 1. LMの設定
# ============================================================
lm = dspy.LM("openai/gpt-4o-mini")
dspy.configure(lm=lm)
# ============================================================
# 2. 学習データ(正解を「短い表記」で統一)
# ============================================================
trainset = [
dspy.Example(question="富士山の標高は?", answer="3776m"),
dspy.Example(question="本能寺の変を起こしたのは誰?", answer="明智光秀"),
dspy.Example(question="日本国憲法が施行された年は?", answer="1947年"),
dspy.Example(question="人間の体温の平均は?", answer="36.5度"),
dspy.Example(question="地球から太陽までの距離は?", answer="約1億5000万km"),
dspy.Example(question="円周率を小数第2位まで答えよ", answer="3.14"),
dspy.Example(question="関ヶ原の戦いは何年?", answer="1600年"),
dspy.Example(question="日本で最も面積の大きい都道府県は?", answer="北海道"),
]
trainset = [ex.with_inputs("question") for ex in trainset]
# ============================================================
# 3. テスト用データ(正解付き)
# 正解を「短い表記」で書いておくことで、
# LLMの回答スタイルとの差が生まれやすくする
# ============================================================
testset = [
dspy.Example(question="東京スカイツリーの高さは?", answer="634m"),
dspy.Example(question="鎌倉幕府を開いたのは誰?", answer="源頼朝"),
dspy.Example(question="日本が降伏した年は?", answer="1945年"),
dspy.Example(question="光が1年間に進む距離を何という?", answer="光年"),
dspy.Example(question="日本で2番目に高い山は?", answer="北岳"),
dspy.Example(question="酸素の元素記号は?", answer="O"),
dspy.Example(question="徳川幕府の初代将軍は?", answer="徳川家康"),
dspy.Example(question="水が沸騰する温度は?", answer="100度"),
dspy.Example(question="日本の通貨単位は?", answer="円"),
dspy.Example(question="太陽系の惑星はいくつ?", answer="8"),
]
testset = [ex.with_inputs("question") for ex in testset]
# ============================================================
# 4. metric を3パターン定義する
# ============================================================
def metric_partial(example, pred, trace=None):
"""部分一致: 正解が回答に含まれていればOK"""
return example.answer.lower() in pred.answer.lower()
def metric_exact(example, pred, trace=None):
"""完全一致: 正解と回答が完全に同じでないとNG"""
return example.answer.strip().lower() == pred.answer.strip().lower()
judge = dspy.ChainOfThought(
"question, expected_answer, predicted_answer -> judgment: bool"
)
def metric_llm(example, pred, trace=None):
"""LLM採点: LLM自身に正しいか判定させる"""
try:
result = judge(
question=example.question,
expected_answer=example.answer,
predicted_answer=pred.answer,
)
return result.judgment
except Exception:
return False
# ============================================================
# 5. BootstrapFewShotで最適化(部分一致metricを使用)
# ============================================================
print("=" * 60)
print("【最適化中(部分一致metricで BootstrapFewShot)...】")
print("=" * 60)
qa = dspy.ChainOfThought("question -> answer")
optimizer = dspy.BootstrapFewShot(
metric=metric_partial,
max_bootstrapped_demos=3,
max_labeled_demos=2,
)
optimized_qa = optimizer.compile(qa, trainset=trainset)
print("最適化完了!\n")
# ============================================================
# 6. テスト質問に回答させ、3つのmetricで同時採点
# ============================================================
print("=" * 60)
print("【テスト結果: 同じ回答を3つのmetricで採点】")
print("=" * 60)
print()
header = f"{'質問':<25} {'正解':<10} {'LLMの回答':<20} {'部分一致':>8} {'完全一致':>8} {'LLM採点':>8}"
print(header)
print("-" * len(header))
score_partial = 0
score_exact = 0
score_llm = 0
for ex in testset:
pred = optimized_qa(question=ex.question)
# 3つのmetricで採点
p = metric_partial(ex, pred)
e = metric_exact(ex, pred)
l = metric_llm(ex, pred)
score_partial += int(p)
score_exact += int(e)
score_llm += int(l)
# 回答の表示用(長すぎたら切り詰め)
answer_display = pred.answer
if len(answer_display) > 18:
answer_display = answer_display[:17] + "…"
mark_p = "✅" if p else "❌"
mark_e = "✅" if e else "❌"
mark_l = "✅" if l else "❌"
print(f"{ex.question:<25} {ex.answer:<10} {answer_display:<20} {mark_p:>8} {mark_e:>8} {mark_l:>8}")
total = len(testset)
print("-" * len(header))
print(f"{'スコア':<57} {score_partial}/{total} {score_exact}/{total} {score_llm}/{total}")
# ============================================================
# 7. 結果の解説
# ============================================================
print()
print("=" * 60)
print("【読み方ガイド】")
print("=" * 60)
print()
print(" 同じLLMの回答でも、metricによって合否が分かれるケースに注目してください。")
print()
print(" 例: 正解「634m」に対して LLMが「634メートル」と回答した場合")
print(" 部分一致 → ❌ (「634m」が「634メートル」に含まれていない)")
print(" 完全一致 → ❌ (「634m」≠「634メートル」)")
print(" LLM採点 → ✅ (意味的に同じとLLMが判定)")
print()
print(" 例: 正解「O」に対して LLMが「O(酸素)」と回答した場合")
print(" 部分一致 → ✅ (「O」が「O(酸素)」に含まれている)")
print(" 完全一致 → ❌ (「O」≠「O(酸素)」)")
print(" LLM採点 → ✅ (意味的に同じとLLMが判定)")
print()
print(" このように、metricの選び方がOptimizerの「目標」を決めます。")
print(" 厳密なmetricを使えば厳密な回答が、寛容なmetricを使えば自然な回答が促されます。")python metric.py
============================================================
【最適化中(部分一致metricで BootstrapFewShot)...】
============================================================
75%|██████████████████████████████████████████████████████████████████████████████████████████ | 6/8 [00:00<00:00, 100.18it/s]
Bootstrapped 3 full traces after 6 examples for up to 1 rounds, amounting to 6 attempts.
最適化完了!
============================================================
【テスト結果: 同じ回答を3つのmetricで採点】
============================================================
質問 正解 LLMの回答 部分一致 完全一致 LLM採点
------------------------------------------------------------------------------------
東京スカイツリーの高さは? 634m 634メートル ❌ ❌ ✅
鎌倉幕府を開いたのは誰? 源頼朝 源頼朝 ✅ ✅ ✅
日本が降伏した年は? 1945年 1945年 ✅ ✅ ✅
光が1年間に進む距離を何という? 光年 光年 ✅ ✅ ✅
日本で2番目に高い山は? 北岳 北岳 ✅ ✅ ✅
酸素の元素記号は? O O ✅ ✅ ✅
徳川幕府の初代将軍は? 徳川家康 徳川家康 ✅ ✅ ✅
水が沸騰する温度は? 100度 100℃ ❌ ❌ ✅
日本の通貨単位は? 円 円 ✅ ✅ ✅
太陽系の惑星はいくつ? 8 8つ ✅ ❌ ✅
------------------------------------------------------------------------------------
スコア 8/10 7/10 10/10
============================================================
【読み方ガイド】
============================================================
同じLLMの回答でも、metricによって合否が分かれるケースに注目してください。
例: 正解「634m」に対して LLMが「634メートル」と回答した場合
部分一致 → ❌ (「634m」が「634メートル」に含まれていない)
完全一致 → ❌ (「634m」≠「634メートル」)
LLM採点 → ✅ (意味的に同じとLLMが判定)
例: 正解「O」に対して LLMが「O(酸素)」と回答した場合
部分一致 → ✅ (「O」が「O(酸素)」に含まれている)
完全一致 → ❌ (「O」≠「O(酸素)」)
LLM採点 → ✅ (意味的に同じとLLMが判定)
このように、metricの選び方がOptimizerの「目標」を決めます。
厳密なmetricを使えば厳密な回答が、寛容なmetricを使えば自然な回答が促されます。実験結果
質問 | 正解 | LLMの回答 | 部分一致 | 完全一致 | LLM採点 |
|---|---|---|---|---|---|
東京スカイツリーの高さは? | 634m | 634メートル | ❌ | ❌ | ✅ |
鎌倉幕府を開いたのは誰? | 源頼朝 | 源頼朝 | ✅ | ✅ | ✅ |
日本が降伏した年は? | 1945年 | 1945年 | ✅ | ✅ | ✅ |
光が1年間に進む距離を何という? | 光年 | 光年 | ✅ | ✅ | ✅ |
日本で2番目に高い山は? | 北岳 | 北岳 | ✅ | ✅ | ✅ |
酸素の元素記号は? | O | O | ✅ | ✅ | ✅ |
徳川幕府の初代将軍は? | 徳川家康 | 徳川家康 | ✅ | ✅ | ✅ |
水が沸騰する温度は? | 100度 | 100℃ | ❌ | ❌ | ✅ |
日本の通貨単位は? | 円 | 円 | ✅ | ✅ | ✅ |
太陽系の惑星はいくつ? | 8 | 8つ | ✅ | ❌ | ✅ |
答えそのものはすべて正しく文体が異なっているだけのため、LLM採点metricはすべて合格としていますが、単位など文字が異なる場合、部分一致metricは不合格、完全一致metricはす単位だけではなく、文字列の完全一致がなされていなければ合格とは見なしていないことがわかります
「634m」vs「634メートル」(東京スカイツリーの高さ)
人間にとっては明らかに同じ答えですが、文字列としては異なります。部分一致metricは「634m」という文字列が「634メートル」に含まれているかをチェックするため、「m」と「メートル」の表記違いで❌になります。完全一致も同様に❌。一方、LLM採点は意味を理解して判定するので✅になります。
「8」vs「8つ」(太陽系の惑星の数)
ここが3つのmetricの違いが最もきれいに出たケースです。部分一致metricは「8」が「8つ」に含まれているので✅。完全一致metricは「8」≠「8つ」なので❌。LLM採点は意味的に同じなので✅。同じ回答に対して、3つのmetricが3通りの判定を下しています。
Optimizerは、metricで✅になった回答だけをお手本として採用します。つまり、metricの基準がそのままお手本の「品質基準」になります。
完全一致metricでOptimizerを回すと、「3776m」「1945年」のように短く正確な回答だけがお手本として残ります。結果として、LLMは「短く簡潔に答える」スタイルに最適化されます。
部分一致metricでOptimizerを回すと、「富士山の標高は3776mです」のように正解を含んでいれば多少冗長でもお手本として残ります。LLMの回答スタイルはやや説明的になりやすいです。
LLM採点metricでOptimizerを回すと、表記ゆれに寛容なので幅広い回答がお手本として残ります。LLMは自然な表現で答えやすくなりますが、その分API呼び出し回数が増え、コストが上がります。
実務でのmetric選びの指針
では、実際にDSPyを使うとき、どのmetricを選べばよいのでしょうか。
正解が明確に一つに決まるタスク(例:固有名詞の抽出、数値の計算)では、完全一致や部分一致で十分です。正解データの表記を丁寧に統一しておけば、シンプルなmetricでも精度の高い最適化ができます。
正解の表現に幅があるタスク(例:要約、翻訳、自由記述のQ&A)では、LLM採点が有効です。ただし、LLM採点自体の精度がmetricの品質を左右するので、判定用のプロンプト(今回の実験では judge モジュール)の設計も重要になります。
コストを抑えたい場合は、まず部分一致で試し、精度に不満があればLLM採点に切り替える、という段階的なアプローチが現実的です。

