自動でプロンプトエンジニアリングする『APE(Automatic Prompt Engineering)』を試す。LangGraph
こんばんは、IZAIエンジニアリングチームです。今回は、自動プロンプトエンジニア(APE)についての論文のアイディアをもとに、遺伝的アルゴリズムチックにプロンプトを自動で調整する方法を試してみました。
APE(Automatic Prompt Engineering)とは
APEはAutomatic Prompt Engineeringの略で、その名の通り自動でプロンプトエンジニアリングを行うアルゴリズムです。与えられたタスクをもとにオリジナルのプロンプトと意味的に近しいプロンプトをいくつか生み出し、それらを評価する、ということを繰り返します。
実装方法
今回はLangChainの派生ライブラリであるLangGraphを用いて実装します。LangGraphを用いることでループ処理や条件分岐の実装をより簡単に行うことができます。
使用するライブラリ
pip install httpx
pip install langgraph
pip install langchain_openai
pip install pandas
pip install numpy
from typing_extensions import TypedDict, Optional
from langgraph.graph import StateGraph
from langchain_openai import ChatOpenAI
from langchain_community.embeddings.openai import OpenAIEmbeddings
from langchain_core.pydantic_v1 import BaseModel, Field
import pandas as pd
import numpy as np
from IPython.display import Image, display
今回はOpenAIのGPT3.5-turboとOpenAI Embeddingを使うのであらかじめ環境変数にOPENAI_API_KEYを登録しておきます。
使用するデータセット
今回使用するデータセットはHugging Face上に公開されているライブドアニュースの三行要約データセットです。
このデータセットはLlama2をファインチューニングするためのタグなどが追加されているので、今回は使う部分だけをまとめたデータセットを作っておきました。
入力例
出力例
df = pd.read_csv("small_dataset.csv")
LangGraphで実装する
まずは使用するモデルの設定から行います。プロンプトを生成する用のモデルはtemperatureを1.0と高めに設定することで、試行するプロンプトのバリエーションを高めます。
class PromptGeneratorResponseType(BaseModel):
prompts: list[str] = Field(description="新しいプロンプトのリスト")
llm = ChatOpenAI(model="gpt-3.5-turbo")
prompt_generator = ChatOpenAI(model="gpt-3.5-turbo", temperature=1.0).with_structured_output(PromptGeneratorResponseType)
embeddings = OpenAIEmbeddings()
次にグラフのStateを設定します。LangGraphにおけるStateは、各ノードの引数となる辞書型のクラスです。
class State(TypedDict):
prompt: list[str] # 生成されたプロンプトのリスト
response: Optional[list[str]] = None # それぞれのプロンプトによって生成された回答のリスト
true_answer: Optional[str] = None # 本来の回答
question: Optional[str] = None # LLMに入力される文章
iteration: int = 0 # 何回目の生成か
graph_builder = StateGraph(State)
ノードの設定に入る前に評価関数を考えましょう。ここでの評価関数とは複数のプロンプトによって生成された文章と、本来の回答を比較し、点数をつける関数のことです。
今回のタスクは文章要約なので、
文章同士のコサイン類似度 + 出力文章の長さの比*
をスコアとして計算します。[-1, 2]
def evaluate(State):
embeddings_list = np.array(embeddings.embed_documents(State["response"]))
target_embedding = np.array(embeddings.embed_query(State["true_answer"]))
# コサイン類似度の計算
similarity = np.dot(embeddings_list, target_embedding.T) / (np.linalg.norm(embeddings_list, axis=1) * np.linalg.norm(target_embedding))
# 文章の長さの比を計算
target_length = len(State["true_answer"])
length_error = map(lambda x: min(len(x), target_length) / max(len(x), target_length), State["response"])
# 最終的なスコアを計算
score = similarity + np.array(list(length_error))
max_idx = int(np.argmax(score))
print("best prompt:", State["prompt"][max_idx], "score:", score[max_idx], "index:", max_idx)
return {"prompt": State["prompt"][max_idx]}
次に新しいプロンプトを生成するノード、それらのプロンプトを実行するノード、そして結果表示用のノードを作成していきます。
def generate_prompt(State):
generator_prompt = """
あなたはプロンプトエンジニアです。
入力されたプロンプトをもとに、言い換えや語順を変える、新しい情報を追加するなどの操作を加えて同じ意味を持つ新しいプロンプトを生成してください。
以前のプロンプト: '''{prompt}'''
新しいプロンプトを全部で5つ生成してください。
"""
new_prompt = prompt_generator.invoke(generator_prompt.format(prompt=State["prompt"])).prompts
new_prompt.append(State["prompt"])
return {"prompt": new_prompt, "iteration": State["itreration"] - 1}
def generate_answer(State):
rows = df.sample(n=1)
question = rows["text"].item()
answer = rows["summary"].item()
prompts = State["prompt"]
print("question:", question)
responses = []
for prompt in prompts:
responses.append(llm.invoke(prompt + f"\n{question}").content)
print("prompts:", prompts)
print("responses:", responses)
print("true answer:", answer)
return {"response": responses, "true_answer": answer, "question": question}
def summary(State):
summary_statement = """
調整されたプロンプト:
{prompt}
"""
print(summary_statement.format(prompt=State["prompt"]))
return State
ではグラフを構築しましょう。繰り返し回数は5としておきます。
graph_builder = StateGraph(State)
graph_builder.add_node("generate answer", generate_answer)
graph_builder.add_node("evaluate", evaluate)
graph_builder.add_node("generate prompt", generate_prompt)
graph_builder.add_node("summary", summary)
graph_builder.add_edge("generate answer", "evaluate")
graph_builder.add_edge("generate prompt", "generate answer")
graph_builder.add_conditional_edges("evaluate", lambda state: "summary" if (state["iteration"] == MAXIMUM_ITERATION) else "generate prompt", {"summary": "summary", "generate prompt": "generate prompt"})
#graph_builder.add_edge("evaluate", lambda state: "summary" if (state["iteration"] == 0) else "generate prompt")
graph_builder.set_entry_point("generate answer")
graph_builder.set_finish_point("summary")
graph = graph_builder.compile()
完成したグラフを表示してみましょう。
display(Image(graph.get_graph().draw_mermaid_png()))
試してみる
では実際に実行してみましょう。繰り返し回数は5です。
graph.invoke({"prompt": ["以下の文を100字でまとめて"], "iteration": 5})
出力結果とスコアを比較してみると、
以下の文を100字でまとめて: 1.535763893167752
以下の文章を100文字以内でまとめよう。: 1.7255995484118527
100文字未満でまとめてください。: 1.8353215729331582
100文字以内で以下の文章を要約してください。: 1.8857869283793893
文章を100文字以内で簡潔にまとめてください。: 1.8556173336068014
と、たしかにスコアが上昇し、プロンプトも洗練されていることが確認できます。
それぞれの段階で他に生成されたプロンプトの一部も確認してみると、
"以下の文章を100文字以内でまとめよう。" と同時に生成されたプロンプト
- 100文字で以下のテキストをまとめよう。
- この文章を100字でまとめる。
- 100字のまとめを書こう。
"100文字未満でまとめてください。" と同時に生成されたプロンプト
- まとめてください。
"100文字以内で以下の文章を要約してください。" と同時に生成されたプロンプト
- 以下の文章を100文字以下に簡潔にまとめてください。
- まとめてください:以下の文章を100文字以内に。
- 要約:以下の文章を100文字以内にしてください。
"文章を100文字以内で簡潔にまとめてください。" と同時に生成されたプロンプト
- 以下の文章を100文字以内で要約してください。
- 下記文章を短くまとめてください。
と様々なバリエーションのプロンプトが生成されていることがわかります。
考察
今回使用したプロンプトは日本語の短い文章でしたが、GPT-3.5-turboでは文法が崩壊したプロンプトを生成することがありました。英語プロンプトや高性能なLLMを使うことで、良い結果を得ることができそうです。
評価関数についても、今回は評価サンプル数が1でしたが、より多くのサンプルの平均点を計算することでより安定した結果が得られるでしょう。
今回は終了条件が既定の回数ループすることでしたが、終了条件を変更することで既定のスコアを上回った場合に終了する、といった条件に変更することも可能です。色々試してみても面白いですね。
新しいプロンプトの生成について、今回は単純に似た意味の文章を生成しましたが、生成された文章と正解文章を比較し不足部分を補うようにプロンプトを修正すると、より早く良いプロンプトへ収束する可能性が高いです。
最後に
APEを活用すると、膨大な量のプロンプトを自動で試し、自動で評価することができます。人間には時間的・発想的に到達不可能なプロンプトを作成できるので、正解データセットが用意出来る領域においては、今後のプロンプトエンジニアリングの主流になりそうです。
以上、参考になった方はいいね、コメントをいただけると今後の開発の励みになります。それではまた!
この記事が気に入ったらサポートをしてみませんか?