1. はじめに
こんにちは、とまと🍅好きのエンジニアです。
最近注目されつつあるプロンプト最適化、、、
でも、プロンプト最適化ってどんな時に使えばいいんだろうと思っていました。
そこで今回はプロンプト最適化を使っていないもの、DSPy(プロンプト最適化)を使ったもので分類タスクを実装して、その精度を比較してみようと思います。
分類タスクとしては、日本語を対象に作られたデータセットJGLUEのJNLI(文同士の関係を推論)を使います。
2. JGLUEとは
JGLUEは、ヤフーと早稲田大学の共同研究プロジェクトによって構築された、日本語における一般的な自然言語理解能力を図るためのデータセットです。
様々な評価タスクがありますが、今回はその中で**JNLI(Japanese version of the NLI:自然言語推論)**を使います。
NLIとは、あるテキストが別のテキストの意味を論理的に含んでいるのかどうかを判定するものです。
例)
テキスト1「ハンバーグの横に赤い箸がおかれている。」
テキスト2「晩御飯の側に箸がある。」
この場合、「ハンバーグ」が「晩御飯」に含まれており、「赤い箸」が「箸」に含まれていることからこのテキスト同士は「含意(entailment)」であるといえます。
3. 分類器の作成
それではNLIの分類器を作成していきます。
今回はテキストのペアをentailment(含意)、contradiction(矛盾)、neutral(中立)に分類していきます。
3.1. プロンプト最適化を使わない場合
まずはプロンプト最適化しない場合のやりかたです。
LLMで分類するとなると、いろんなやり方がありますが、今回はFew-shot(いくつか例をつけた)プロンプトで書いてみようと思います。
自分で書くのもいいですが、まずはGeminiを使って分類してもらうプロンプトの下書きを作成してもらいます。
# 命令
あなたは論理的なテキスト分析システムです。
以下の「前提文」と「仮説文」の関係を分析し、以下の3つのカテゴリのいずれかに分類してください。
# 分類カテゴリの定義
1. **entailment**: 前提文が真であるならば、論理的に仮説文も「確実に真」である場合。
2. **contradiction**: 前提文が真であるならば、仮説文は「確実に偽(矛盾している)」である場合。
3. **neutral**: 前提文の情報だけでは、仮説文が真か偽か判断できない場合(前提文に書かれていない情報が含まれる場合)。
# 制約事項
* 常識や外部知識を使わず、あくまで「前提文」に書かれている内容のみに基づいて判断してください。
* 出力は `entailment`, `contradiction`, `neutral` のいずれかの単語のみを行ってください。
* 余計な説明や挨拶は不要です。
# 例
前提文: 男性がキッチンで料理をしている。
仮説文: 人が屋内で作業をしている。
出力: entailment
前提文: 黒い犬が公園を走っている。
仮説文: 白い猫がソファで寝ている。
出力: contradiction
前提文: 彼女は本を読んでいる。
仮説文: 彼女は試験勉強のために本を読んでいる。
出力: neutral
# 入力タスク
前提文: {premise}
仮説文: {hypothesis}
出力:
このプロンプトを使ってJNLI内のデータを使って分類するコードを作成し、実行してみます。 また今回はコードの内容というよりかは、プロンプト最適化にすることでなにがいいのかに焦点を当てているのでコードの中身は省略します。
import json
import boto3
# (省略)...
# プロンプト
PROMPT_TEMPLATE = """
# 命令
あなたは論理的なテキスト分析システムです。
以下の「前提文」と「仮説文」の関係を分析し、以下の3つのカテゴリのいずれかに分類してください。
# 分類カテゴリの定義
1. **entailment**: 前提文が真であるならば、論理的に仮説文も「確実に真」である場合。
2. **contradiction**: 前提文が真であるならば、仮説文は「確実に偽(矛盾している)」である場合。
3. **neutral**: 前提文の情報だけでは、仮説文が真か偽か判断できない場合(前提文に書かれていない情報が含まれる場合)。
# 制約事項
* 常識や外部知識を使わず、あくまで「前提文」に書かれている内容のみに基づいて判断してください。
* 出力は `entailment`, `contradiction`, `neutral` のいずれかの単語のみを行ってください。
* 余計な説明や挨拶は不要です。
# 例
前提文: 男性がキッチンで料理をしている。
仮説文: 人が屋内で作業をしている。
出力: entailment
前提文: 黒い犬が公園を走っている。
仮説文: 白い猫がソファで寝ている。
出力: contradiction
前提文: 彼女は本を読んでいる。
仮説文: 彼女は試験勉強のために本を読んでいる。
出力: neutral
# 入力タスク
前提文: {premise}
仮説文: {hypothesis}
出力:
"""
def predict_entailment(premise, hypothesis):
# Bedrock (Claude) を呼び出して含意関係を予測する関数
prompt = PROMPT_TEMPLATE.format(premise=premise, hypothesis=hypothesis)
# (省略)...
return Label
# 最終的にLLMで分類したラベルを返す
def main():
# 分類するテキストのJSONLファイルを読み込む処理
load_json(input_file)
# プログレスバー付きでループ処理
for line in tqdm(target_lines):
if not line.strip():
continue
try:
data = json.loads(line)
# データ項目の抽出
premise = data.get('sentence1')
hypothesis = data.get('sentence2')
true_label = data.get('label')
if not (premise and hypothesis and true_label):
continue
# LLMによる予測
pred_label = predict_entailment(premise, hypothesis)
# 結果の判定
is_correct = (pred_label == true_label)
if is_correct:
correct_count += 1
total_count += 1
# 最終結果の出力
if total_count > 0:
accuracy = correct_count / total_count
print("\n" + "="*30)
print(f"Total Samples: {total_count}")
print(f"Correct: {correct_count}")
print(f"Accuracy: {accuracy:.2%}") # パーセンテージ表示
print("="*30)
else:
print("No valid data processed.")
if __name__ == "__main__":
main()
これを100個のデータに対して行うと、89個正解していました。(プロンプト最適化しなくてもいいくらい精度がいいですね、、、笑)
3.2. DSPy(プロンプト最適化)を行う場合
上でやったことをプロンプト最適化ではどのようにやるのかを実際に試していきます。
早速コードを下に載せます。
今回はAWSのBedrockのモデルを使って試していますが、別のモデルでも基本的にはやり方は同じです。
AWSのモデルを使う方法は自分が前に書いた記事を参考にしてみてください。
import dspy
import json
from tqdm import tqdm
from typing import Literal
from dspy.teleprompt import BootstrapFewShot
lm = dspy.LM("モデル名")
#モデルの設定
dspy.configure(lm=lm, temperature=0.0)
INPUT_TEST_FILE = 'test-v1.3.json' # テスト用データ
INPUT_TRAIN_FILE = 'train-v1.3.json' # 学習用データ
MAX_TEST_SAMPLES = 100 # テストする件数
class NliClassification(dspy.Signature):
"""
あなたは論理的なテキスト分析システムです。
以下の「前提文」と「仮説文」の関係を分析し、以下の3つのカテゴリのいずれかに分類してください。
# 分類カテゴリの定義
1. **entailment**: 前提文が真であるならば、論理的に仮説文も「確実に真」である場合。
2. **contradiction**: 前提文が真であるならば、仮説文は「確実に偽(矛盾している)」である場合。
3. **neutral**: 前提文の情報だけでは、仮説文が真か偽か判断できない場合(前提文に書かれていない情報が含まれる場合)。
"""
premise: str = dspy.InputField(desc="前提文")
hypothesis: str = dspy.InputField(desc="仮説分")
answer: Literal['entailment', 'contradiction', 'neutral'] = dspy.OutputField(desc="分類ラベル")
# データ読み込み関数
def load_data(file_path):
"""JSONLファイルを読み込み、DSPyのExample形式に変換する"""
examples = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
if not line.strip():
continue
try:
data = json.loads(line)
# DSPyのExampleを作成
example = dspy.Example(
premise=data['sentence1'],
hypothesis=data['sentence2'],
answer=data['label']
).with_inputs('premise', 'hypothesis')
examples.append(example)
except json.JSONDecodeError:
continue
except FileNotFoundError:
print(f"Error: File {file_path} not found.")
return []
return examples
# 評価メトリクス (正解判定ロジック)
def exact_match_metric(example, pred, trace=None):
# 正解ラベルと予測ラベルが一致するか
return example.answer.lower() == pred.answer.lower()
# メイン処理
def main():
# (A) データのロード
print("データを読み込んでいます...")
train_data = load_data(INPUT_TRAIN_FILE)
test_data_full = load_data(INPUT_TEST_FILE)
if not train_data or not test_data_full:
print("データの読み込みに失敗しました。ファイルパスを確認してください。")
return
# テストデータは指定件数に絞る
test_data = test_data_full[:MAX_TEST_SAMPLES]
print(f"学習データ数: {len(train_data)}, テストデータ数: {len(test_data)}")
# モジュールの定義
classifier = dspy.ChainOfThought(NliClassification)
# コンパイル(最適化)の実行
# BootstrapFewShot: 学習データから、モデルの回答精度を上げるようなFew-shot例を探し出す
print("\nDSPyによる最適化(コンパイル)を開始します...")
optimizer = BootstrapFewShot(
metric=exact_match_metric,
max_bootstrapped_demos=4, # 生成・選択するFew-shotの数
max_labeled_demos=4, # 元のデータからそのまま使うFew-shotの数
)
# コンパイル実行
compiled_classifier = optimizer.compile(classifier, trainset=train_data)
print("最適化完了")
# テスト実行と評価
correct_count = 0
results = []
for example in tqdm(test_data):
# 推論実行
pred = compiled_classifier(premise=example.premise, hypothesis=example.hypothesis)
# 結果判定
is_correct = exact_match_metric(example, pred)
if is_correct:
correct_count += 1
results.append({
"premise": example.premise,
"hypothesis": example.hypothesis,
"true_label": example.answer,
"pred_label": pred.answer,
"reasoning": pred.reasoning, # ChainOfThoughtの思考過程の取得
"correct": is_correct
})
# 結果出力
accuracy = correct_count / len(test_data)
print("\n" + "="*30)
print(f"Total Samples: {len(test_data)}")
print(f"Correct: {correct_count}")
print(f"Accuracy: {accuracy:.2%}")
print("="*30)
# 誤答の確認
print("\n--- Incorrect Examples (First 3) ---")
incorrect_samples = [r for r in results if not r['correct']][:3]
for item in incorrect_samples:
print(f"Premise: {item['premise']}")
print(f"Hypothesis: {item['hypothesis']}")
print(f"True: {item['true_label']} | Pred: {item['pred_label']}")
print(f"Reasoning: {item['reasoning']}") # DSPyが生成した推論過程
print("-" * 20)
if __name__ == "__main__":
main()
これを実行してみると、100個中86個正解していました。
プロンプト最適化を使った方が低い、、、?
4. 結果
今回、プロンプト最適化を使った場合と、使わない場合の精度がほとんど同じ結果になりました。
| プロンプトの種類 | 精度[%] |
|---|---|
| 人手 | 89.0 |
| DSPy(プロンプト最適化) | 86.0 |
つまりプロンプト最適化しても、あまり意味がないのでは?と思うかもしれません。
4.1. プロンプト最適化の本領は運用・改善?
今回、分類精度としてはプロンプト最適化最適化を使わない場合と使った場合ではあまり差がありませんでした。しかし、プロンプト最適化の良さは1回のタスクで最高の評価をとることではなく、システム全体を運用・改善することにあります。
DSPyのサイトにも以下のように書いています。
DSPy(宣言型自己改善型Python)を使用すると、プロンプトや学習ジョブを複雑に扱う代わりに、自然言語モジュールからAIソフトウェアを構築し、それらを様々なモデル、推論戦略、学習アルゴリズムと汎用的に組み合わせることができます。これにより、AIソフトウェアの信頼性、保守性、そしてモデルや戦略をまたいだ移植性が向上します。
(引用:DSPy.ai)
例えば、今回はNLIという一般的に知られているもので分類を行いました。そのため、Geminiもすぐにプロンプトの下書きを作成してくれました。
しかし、実際の作業ではオリジナルに決定したラベルや定義で分類することもあると思います。そうなると、「あなたは論理的なテキスト分類システムです...」や「どの例をFew-shotとして入れるか」等のプロンプトを人間が試行錯誤して作成する必要があります。
つまり、人によって書き方が異なり、どうプロンプトを設計するかで精度が異なってきます。
また、データ量が1000件、1万件...と増えた場合、人が最適な事例を見つけることは不可能です。
これに対して、DSPyではSignature(入力と出力の定義)、Metric(評価基準)さえ決めれば最適なプロンプトを見つけ出してくれます。
(自分はこの記事で従来のやり方と同じくらいの高い精度が出せることが分かったことで、プロンプトを作ることが大変な時にDSPyに任せてみようと思います😄)
5. おわりに
今回、プロンプト最適化を使って分類を行ってみました。
データさえあればどんな人でも高精度がだせるのがプロンプト最適化だと思います。
(つまりデータは必須。これからデータを集める、整備することがほんとに重要だな...と改めて思いました。)
まだまだ触っている人が少ない分野ですが、この記事をきっかけにいろんな人の使うきっかけになればなと思います。