プロンプト解釈を最適化するフレームワーク「SAMMO」を試す
Microsoftは2024年4月18日、生成AI/大規模言語モデル(LLM)で長文のプロンプトを効率的に処理できるオープンソースのフレームワーク「SAMMO」を発表した。
長文のプロンプトを処理する一般的な手法としてRAG(検索拡張生成)がある。RAGは特定の入力例に基づいて、必要とされる情報をプロンプトに動的に組み込むが、例となるretriever(RAGが検索して得た情報)とプロンプトのテキスト自体はそのままの形で残っており、これがプロンプトの最適化を阻んでいた。
これが課題で、
これに対処するために開発されたのがSAMMO(Structure-Aware Multi-objective Metaprompt Optimization)フレームワークである。SAMMOは、RAGのようにさまざまな種類の構造情報を組み合わせたプロンプト最適化を合理的に行う新しいオープンソースツール。プロンプトの最適化のためにコンポーネント全体を削除したり、別のコンポーネントに置き換えたりするなど、構造的な変更を加えることができる。
ここがソリューション。
これだけだとちょっとわかりにくい。
MSのブログを見てみる。以下はブログから引用をDeepLで日本語化したもの。
図1は、セマンティック構文解析としても知られる、ユーザークエリをドメイン固有言語(DSL)に変換するように設計されたRAGプロンプトを示している。
図1:RAGプロンプトは意味解析タスクに使用される。プロンプトは3つの大きな部分から構成され、それぞれが最適化可能なさまざまな側面を持つ。図1の例では、3つの異なる構造を組み合わせて最終的なプロンプトを構成している。最初の構造であるタスクの説明は、従来のプロンプト最適化技法の結果、入力に依存せず静的なままである。しかし、RAGは入力に特化した2つの構造を含んでいる:例題のリトリーバと入力テキストそのものである。これらにより、従来のアプローチの範囲を超える多くの最適化の機会がもたらされる。
上記のほか、ブログでは、RAGの最適化で大幅な性能向上、インストラクションチューニングでも既存のプロンプト改善手法よりも効果がある、ということが謳われている模様。
Quick Start
以下に従って進めていく。
以下補足
- どうやらSAMMOはpython-3.11以降じゃないとダメらしく、Colaboratoryは3.10でダメっぽい。ということでローカルのJupyterLabで動かす。
- 基本的に可能な限り日本語で動かすつもり。
$ python --version
Python 3.11.5
適宜仮想環境を作成の上、JupyterLabインストール。あと.envにOpenAI APIキーをセットしておく。以降の作業はJupyter上で。
パッケージインストール。APIキーを読み込むためのdotenvも。
!pip install sammo python-dotenv
APIキーは環境変数で読み込めばOKなのかもしれないけど、ちょっとわからないので、一旦Quick Startのコードに合わせて設定ファイルから読むこむことにする。以下を実行。
import os
import json
from dotenv import load_dotenv
load_dotenv()
config_dir = "./config"
config = {"api_key": os.environ['OPENAI_API_KEY']}
os.makedirs(config_dir, exist_ok=True)
with open(f"{config_dir}/personal.openai" , "w") as f:
json.dump(config, f)
これでconfig/personal.openai
にAPIキーが設定される。
ではQuickstartのコードを実行していく。一応旅行サイトのコンテンツを生成するというシナリオらしい。
まず以下のコードを実行。
import pathlib
import sammo
from sammo.runners import OpenAIChat
from sammo.base import Template, EvaluationScore
from sammo.components import Output, GenerateText, ForEach, Union
from sammo.extractors import ExtractRegex
from sammo.data import DataTable
import json
import requests
import os
# カレントディレクトリを読むように変更
#API_CONFIG_FILE = pathlib.Path().cwd().parent / "config" / "personal.openai"
API_CONFIG_FILE = pathlib.Path().cwd() / "config" / "personal.openai"
API_CONFIG = ""
if API_CONFIG_FILE.exists():
API_CONFIG = API_CONFIG_FILE
if not API_CONFIG:
raise ValueError('Please set API_CONFIG to {"api_key": "YOUR_KEY"}')
_ = sammo.setup_logger("WARNING") # we're only interested in warnings for now
runner = OpenAIChat(
model_id="gpt-3.5-turbo-0125", # 16kから0125に変更
api_config=API_CONFIG,
cache=os.getenv("CACHE_FILE", "cache.tsv"),
timeout=30,
)
これでAPIキーの設定を読み込んで、OpenAIモデルを使用する準備ができる。あとどうやらキャッシュも有効になる模様。
ではモデルへリクエストを送る。
Output(GenerateText("ハローワールド!")).run(runner)
+---------+----------------------------------------------------------+
| input | output |
+=========+==========================================================+
| None | こんにちは!こんにちは、どのようにお手伝いしましょうか? |
+---------+----------------------------------------------------------+
Constants: None
上記のように.run()
を実行すると、入出力がテーブル形式で出力されるらしい。よく見るとinputがNoneになっているが、ここは次で。
国リストに対して、おすすめの理由とおすすめの時期を生成させてみる。
COUNTRIES = ["スイス", "モロッコ", "タンザニア", "インドネシア", "ペルー"]
reason_to_visit = GenerateText(
Template("{{input}}を訪れる一番の理由を簡潔に教えて。")
)
when_to_visit = GenerateText(
Template("{{input}}を訪れるのに一番良い季節は?簡潔に教えて。")
)
country_pages = Template(
"# {{input}}\n{{reason}}\n\n## おすすめの季節\n{{when}}",
reason=reason_to_visit,
when=when_to_visit,
)
results = Output(country_pages).run(runner, COUNTRIES)
print(results.to_string(max_col_width=100, max_cell_length=300))
minibatches[###################################################################################]5/5[00:04<00:00, 1.00it/s]
+--------------+----------------------------------------------------------------------------------------------------+
| input | output |
+==============+====================================================================================================+
| スイス | # スイス 美しい自然と高品質な生活。 ## おすすめの季節 |
| | スイスを訪れるのに一番良い季節は夏です。気候が穏やかで、観光地も賑やかになります。 |
+--------------+----------------------------------------------------------------------------------------------------+
| モロッコ | # モロッコ |
| | モロッコの美しい歴史的な建造物やカラフルな市場、美味しい料理、そして温かい人々に会いたいから。 ## |
| | おすすめの季節 モロッコを訪れるのに一番良い季節は春と秋です。気候が穏やかで観光に最適な時期です。 |
+--------------+----------------------------------------------------------------------------------------------------+
| タンザニア | # タンザニア 豊かな自然と野生動物を見るため。 ## おすすめの季節 |
| | タンザニアを訪れるのに一番良い季節は6月から10月の乾季です。 |
+--------------+----------------------------------------------------------------------------------------------------+
| インドネシア | # インドネシア 美しい自然、豊かな文化、美味しい食べ物が楽しめるから。 ## おすすめの季節 |
| | インドネシアを訪れるのに一番良い季節は乾季の5月から9月です。 |
+--------------+----------------------------------------------------------------------------------------------------+
| ペルー | # ペルー マチュピチュを見るため。 ## おすすめの季節 |
| | ペルーを訪れるのに一番良い季節は、5月から10月までの乾季です。 |
+--------------+----------------------------------------------------------------------------------------------------+
Constants: None
inputがここで表示されている。なるほど、テンプレート変数がinputになるということのように思える。
で説明を見てみたのだけど、
country_pagesは、ネストされたComponentのグラフであり、内部から外部へと呼び出される。LLMに送信される具体的なテキスト文字列であるプロンプトとは対照的に)入力データから離れて抽象化されているため、私たちはこれらの呼び出しグラフをメタプロンプトと呼んでいます。
んー、現時点では単純にネストしたテンプレートってだけにしか思えない。何が良いのかはまだわからないなー。もうちょっと触ってみる。
でこのメタプロンプトを以下のようにして参照できる。
print(country_pages)
Template(
template_text = '# {{input}}\n{{reason}}\n\n## おすすめの季節\n{{when}}',
name = None,
reason = GenerateText(
child = Template(
template_text = '{{input}}を訪れる一番の理由を簡潔に教えて。',
name = None
),
name = None,
system_prompt = None,
history = None,
seed = 0,
randomness = 0,
max_tokens = None,
json_mode = False,
on_error = 'empty_result'
),
when = GenerateText(
child = Template(
template_text = '{{input}}を訪れるのに一番良い季節は?簡潔に教えて。',
name = None
),
name = None,
system_prompt = None,
history = None,
seed = 0,
randomness = 0,
max_tokens = None,
json_mode = False,
on_error = 'empty_result'
)
)
あと、実行については、並列で実行できるかどうかを自動で判断してくれるらしく、また並列度の指定なんかもできるらしい。
Quick StartのあとはTutorialsを進めてみる。
Tutorials: 1. Working with Data
SAMMOは、DataTables を辞書のリストの薄いラッパーとして使用する。これによりデータ入力と実際の出力を分離することができる。
以下のデータセットを使った例。
データの内容は以下となっている。
このタスクは、ある話し手が他の話し手に対して返した答えが「イエス」なのか「ノー」なのかを予測するようモデルに求めるものである。
これは、言語モデルが含意を把握できるかどうかをチェックする簡単なベンチマークである。含意とは、人が明示的に表現していないメッセージを伝えることを意味する現象である。含意は非常に複雑であるため、このベンチマークでは、より単純なサブセットである「はい/いいえ」の質問における含意に集中する。具体的には、話し手1が質問し、話し手2が答える。言語モデルの目的は、話し手2が意図した意味がイエスかノーかを確認することである。たとえば
発話者1:炎のアンドロイドは配備されているか?
発話者2:12人全員だ。
答え:はい
例文が果たして翻訳があっているか全然わからないのだけどもw とりあえず進めてみる。
データセットのロード。
import requests
import json
URL = "https://github.com/google/BIG-bench/raw/main/bigbench/benchmark_tasks/implicatures/task.json"
task = json.loads(requests.get(URL).content)
# ラベルを単一文字列に変換
for x in task["examples"]:
x["output"] = max(x["target_scores"], key=x["target_scores"].get)
DataTableは、入力と出力の2種類の情報があり、入力はimmutable、出力はmutableとなる。これにより開始データを誤って変更してしまうことを防ぐことができる。DataTableを構築するには、入出力のフィールドを指定する必要がある。
で、今回のデータセットの中身はすべて英語となっているため、Google翻訳で日本語に直す。
GoogleのAPIキーをセットして読み込み。
(snip)
GOOGLE_API_KEY=XXXXXXXXXXXXXXX
import os
import json
from dotenv import load_dotenv
load_dotenv()
データを翻訳。
import re
from tqdm.notebook import tqdm
def translate_google(text: str) -> str:
google_api_key = os.environ["GOOGLE_API_KEY"]
response = requests.post(
f"https://translation.googleapis.com/language/translate/v2?key={google_api_key}",
json={
"q": [text],
"target": "ja",
"format": "text"
},
timeout=5000,
headers={
"Content-Type": "application/json",
},
)
res = response.json()
return res["data"]["translations"][0]["translatedText"]
task['description'] = "Speaker 1に対するSpeaker 2の回答を yes か no で予測する。"
task['task_prefix'] = "Speaker 2の回答は、yes または no のどちらを意味しますか? "
for example in tqdm(task["examples"]):
m = re.search(r'^Speaker 1: (.*) Speaker 2: (.*)$', example["input"])
(s1_en, s2_en) = m.groups()
conv_en = f"{s1_en}\n{s2_en}"
conv_ja = translate_google(conv_en)
(s1_ja, s2_ja) = conv_ja.split("\n")
example["input"] = "Speaker 1: {} Speaker 2: {}".format(s1_ja, s2_ja)
データテーブルを一部見てみる。
from sammo.data import DataTable
mydata = DataTable.from_records(
task["examples"],
input_fields="input",
constants={"instructions": task["task_prefix"]},
)
mydata[:3]
+--------------------------------------------------------------+----------+
| input | output |
+==============================================================+==========+
| Speaker 1: 「でも怖くないんですか?」 Speaker 2: | no |
| 「奥さん、サメは人を襲うことはありませんよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「辞めたいの?」 Speaker 2: | no |
| 「私は、困難に直面しても諦めるようなタイプではありません。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「これらのクライアントを説得すべきでしょうか?」 | yes |
| Speaker 2: | |
| 「彼らは資金力のある本当に重要なクライアントです。」 | |
+--------------------------------------------------------------+----------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '}
作成したデータテーブルは、いろいろ検索したり抽出したりできる。
データをコピーしてユニークで取り出したり。
cloned = mydata.copy()
cloned.outputs[:] = "yes"
cloned.outputs.unique()
['yes']
辞書の場合は.field()
で取り出せる。ただし、以下は今回のデータセットと関係ないサンプルの例。
struc_dt = DataTable([{"one": 1, "two": 2}])
print(struc_dt)
print(struc_dt.inputs.field("one"))
+----------------------+----------+
| input | output |
+======================+==========+
| {'one': 1, 'two': 2} | None |
+----------------------+----------+
Constants: None
+---------+----------+
| input | output |
+=========+==========+
| 1 | None |
+---------+----------+
Constants: None
例えば'yes'なものだけ取り出すとかも。
mydata.outputs.filtered_on(lambda x: x == "yes")
+--------------------------------------------------------------+----------+
| input | output |
+==============================================================+==========+
| Speaker 1: 「これらのクライアントを説得すべきでしょうか?」 | yes |
| Speaker 2: | |
| 「彼らは資金力のある本当に重要なクライアントです。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「それで、あなたはそれを持っているの?」 Speaker | yes |
| 2: 「それを手に入れるために、何人かの喉を切り裂かなければな | |
| らなかった。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「彼らは喧嘩しますか?」 Speaker 2: | yes |
| 「犬猿の仲みたいに喧嘩します。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「ジュースを飲みに来ませんか?」 Speaker 2: | yes |
| 「喉が乾きすぎて骨のように乾いています。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「それで、私の許可が欲しいの?」 Speaker 2: | yes |
| 「私もあなたの許可が欲しいわ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「うまくできましたか?」 Speaker 2: | yes |
| 「あなたはライオンのように勇敢でした。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「答えがほしいの?!」 Speaker 2: | yes |
| 「真実がほしい。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「ヒステリーがそんなことをすると思いますか?」 | yes |
| Speaker 2: 「何世紀にもわたってそうしてきました。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「これ衣装?」 Speaker 2: | yes |
| 「あああああ...一晩中作ったのよ!」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「傘を持って行ったほうがいいですか?」 Speaker 2: | yes |
| 「念には念を入れたほうがいいです。」 | |
+--------------------------------------------------------------+----------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '}
このあたりはなんとなくpandas的な雰囲気を感じる。
ということで、データテーブルからテンプレートを作ってモデルへリクエストしてみる。
ランナーの初期化。Quick Startから続けてやっている場合は不要。
import os
import json
from dotenv import load_dotenv
load_dotenv()
config_dir = "./config"
config = {"api_key": os.environ['OPENAI_API_KEY']}
os.makedirs(config_dir, exist_ok=True)
with open(f"{config_dir}/personal.openai" , "w") as f:
json.dump(config, f)
import pathlib
import sammo
from sammo.runners import OpenAIChat
from sammo.base import Template, EvaluationScore
from sammo.components import Output, GenerateText, ForEach, Union
from sammo.extractors import ExtractRegex
from sammo.data import DataTable
import json
import requests
import os
#API_CONFIG_FILE = pathlib.Path().cwd().parent / "config" / "personal.openai"
API_CONFIG_FILE = pathlib.Path().cwd() / "config" / "personal.openai"
API_CONFIG = ""
if API_CONFIG_FILE.exists():
API_CONFIG = API_CONFIG_FILE
if not API_CONFIG:
raise ValueError('Please set API_CONFIG to {"api_key": "YOUR_KEY"}')
_ = sammo.setup_logger("WARNING") # we're only interested in warnings for now
runner = OpenAIChat(
model_id="gpt-3.5-turbo-0125",
api_config=API_CONFIG,
cache=os.getenv("CACHE_FILE", "cache.tsv"),
timeout=30,
)
データテーブルのconstants
やinput
を使ってテンプレートを作成する。ここはhandlebars.jsの構文で書ける。今回はランダムに10件選んでいる。
labeling_prompt = GenerateText(
Template(
"Instructions:{{constants.instructions}}\nOutput labels: yes, no\nInput: {{input}}\nOutput:"
)
)
sample = mydata.sample(10, seed=42)
result = Output(labeling_prompt).run(runner, sample)
result
minibatches[#################################################################################]10/10[00:04<00:00, 2.21it/s]
+--------------------------------------------------------------+----------+
| input | output |
+==============================================================+==========+
| Speaker 1: 「よくやるんですか?」 Speaker 2: | no |
| 「初めてです。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「私を怒らせようとしているの?」 Speaker 2: 「た | no |
| だ、あなたが怒っているのなら理解できると言っているだけです。 | |
| 」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「答えがほしいの?!」 Speaker 2: | no |
| 「真実がほしい。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「箱を運べますか?」 Speaker 2: | no |
| 「羽のように軽いですよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「外は暑いですか?」 Speaker 2: | no |
| 「歩道で卵を焼くこともできますよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「お返ししましょうか?」 Speaker 2: | no |
| 「素晴らしさや魅力には料金はかかりません。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「ボブ、私の車を運転できるかな?」 Speaker 2: | no |
| 「普通の6気筒だよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「コードレッドを指示したのか?」 Speaker 2: | yes |
| 「その通りだ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「雨を見たことがあるでしょう...?」 Speaker 2: | no |
| 「私たちはあまり外に出ません。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「鍵を開ける方法を知っている人はいますか?」 | yes |
| Speaker 2: 「もちろん。鍵を開けるのは私の得意分野です。」 | |
+--------------------------------------------------------------+----------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '}
出力にアクセスする方法は3つ。最終的な出力結果の値だけが欲しければ.value
を使う。
y_pred = result.outputs.values
y_pred[:5]
['no', 'no', 'no', 'no', 'no']
.raw_values
を使うとより低レベルな結果オブジェクトへのアクセスができる。
result.outputs.raw_values
[LLMResult(value='no', parent=TextResult),
LLMResult(value='no', parent=TextResult),
LLMResult(value='no', parent=TextResult),
LLMResult(value='no', parent=TextResult),
LLMResult(value='no', parent=TextResult),
LLMResult(value='no', parent=TextResult),
LLMResult(value='no', parent=TextResult),
LLMResult(value='yes', parent=TextResult),
LLMResult(value='no', parent=TextResult),
LLMResult(value='yes', parent=TextResult)]
.normalized_values()
を使うと、欠損値の置き換え、リスト化、などができ、精度などのメトリクス計算に使える。ここではsampleとresultを比較している。
y_true = sample.outputs.normalized_values(on_empty="")
n_correct = sum([y_p == y_t for y_p, y_t in zip(y_pred, y_true)])
accuracy = n_correct / len(y_true)
accuracy
0.6
ベースとラインとしてはこんなものかな?ここからプロンプトエンジニアリングで改善を行っていく。
Tutorials: 2. Using components to write metaprompts
この賞ではメタプロンプトを構築するためのコンセプトに付いて説明されている。
2.1. 会話履歴を渡す
前回のの会話を渡して実行する。
first = GenerateText(
"こんにちは!僕の名前はピーターで、馬が好きなんだ。",
system_prompt="シェークスピア風に話して下さい。",
)
second = GenerateText("私のお気に入りの動物について2つの文章を書いて。", history=first)
print(Output(second).run(runner))
third = GenerateText("それを俳句にして。", history=second)
print(Output(third).run(runner))
+---------+--------------------------------------------------------------+
| input | output |
+=========+==============================================================+
| None | 麗しの馬よ、その美しき姿は風に舞い、草原を駆け巡る。その力強 |
| | き足取りは、自然の調べに調和し、我が心を打ち震わす。馬よ、汝 |
| | の気高き姿は、永遠に讃えられん。 また、駆ける馬の姿は、自由 |
| | の象徴として我々に勇気を与える。その鬣を風がなびかせ、地を蹴 |
| | る蹄の音は、自然のリズムに調和し、我々の魂を解き放つ。馬よ、 |
| | 汝の存在は、この世に美しき調和をもたらす。 |
+---------+--------------------------------------------------------------+
Constants: None
+---------+----------------------------------+
| input | output |
+=========+==================================+
| None | 駆ける馬 風に舞う姿 自由の歌 |
+---------+----------------------------------+
Constants: None
2.2. アイテムのイテレーション
シーケンスにおけるイテレーションには2つの方法がある。
- プロンプト実行前にシーケンスがわかっている場合
-
.run_on_datatable()
や.run_on_dicts()
でイテレーションする。入出力ペアの大きなデータにアノテーションを付ける場合に推奨 - Pythonのリストやループなど、手動でイテレーションする
-
- シーケンスがプロンプトの実行結果になる場場合。この場合はSAMMOが提供する
ForEach
で遅延的に呼び出される
それぞれ例を見る。
2.2.1. 既知のシーケンス: Pythonによる手動イテレーション
予め回数の決まった繰り返しなど。
N = 5
fruits = [
GenerateText("フルーツの名前を1つ生成して。", randomness=0.9, seed=i)
for i in range(N)
]
Output(Union(*fruits)).run(runner)
+---------+------------------------------------------------------------+
| input | output |
+=========+============================================================+
| None | ['サファイヤベリー', 'ハニーベリー', '"ゴールデンベリー"', |
| | '「スターベリー」', 'ピンゴーバナナ'] |
+---------+------------------------------------------------------------+
Constants: None
結構出力がぶれているのがわかる。seed
を与えるとローカルキャッシュが無効になるので、ランダムに出力させたい場合は必須。
ForEach
を使ったイテレーション
2.2.2. 未知のシーケンス: 果物の名前を5つ生成して、ForEach
でそれぞれ次のステップで理由を生成させる。
fruits = ExtractRegex(
GenerateText(
"フルーツの名前を5つ生成して下さい。それぞれのフルールを<item>と</item>で囲んで下さい。"
),
r"<item>(.*?)<.?item>"
)
fruit_blurbs = ForEach(
"fruit",
fruits,
GenerateText(Template("どうして{{fruit}}は良いフルーツと言われているのでしょうか?")),
)
Output(fruit_blurbs).run(runner)
+---------+--------------------------------------------------------------+
| input | output |
+=========+==============================================================+
| None | ['りんごは、多くの人にとって良いフルーツと言われている理由は |
| | いくつかあります。\n\n日本では、りんごは四季折々に楽しむこと |
| | ができるフルーツであり、秋から冬にかけての季節には特に美味し |
| | いりんごがたくさん収穫されます。そのため、季節感を楽しむこと |
| | ができるという点が人気の理由の一つです。\n\nまた、りんごには |
| | 豊富な栄養素が含まれており、ビタミンCや食物繊維、ポリフェノ |
| | ールなどが豊富に含まれています。これらの栄養素は、免疫力を高 |
| | めたり、便秘解消や美肌効果など様々な健康効果が期待できるため |
| | 、健康志向の人にも人気があります。\n\nさらに、りんごは保存性 |
| | が高く、持ち運びやすいという利点もあります。そのため、忙しい |
| | 現代人にとっても手軽に食べられるフルーツとして重宝されていま |
| | す。\n\nこれらの理由から、りんごは多くの人にとって良いフルー |
| | ツと言われているのです。', 'バナナは良いフルーツと言われる理 |
| | 由はいくつかあります。\n\n1. 栄養価が高い:バナナにはビタミ |
| | ンC、ビタミンB6、カリウム、食物繊維などが豊富に含まれており |
| | 、健康に良い栄養素がバランスよく含まれています。\n\n2. 消... |
+---------+--------------------------------------------------------------+
Constants: None
さらにForEach
でそれぞれの文章を要約する。
short_fruit_blurbs = ForEach(
"reason",
fruit_blurbs,
GenerateText(
Template(
"次の文章を短く簡潔に書き直して下さい。\n\n文章: {{reason}}\n\n書き直した文章: "
)
),
)
Output(short_fruit_blurbs).run(runner)
+---------+--------------------------------------------------------------+
| input | output |
+=========+==============================================================+
| None | ['りんごは、季節感を楽しめるフルーツであり、栄養価も高く保存 |
| | 性があり、健康志向の人にも人気があります。', 'バナナは栄養価 |
| | が高く、消化が良いため、エネルギー源としても優れています。ま |
| | た、手軽に食べられるので、健康的なフルーツとして人気です。', |
| | 'イチゴは栄養価が高く、甘みと酸味のバランスが良いため、幅広 |
| | い世代に人気のフルーツです。見た目も美しく、料理やデザートの |
| | 飾り付けにも使われます。', 'オレンジはビタミンCや食物繊維、 |
| | ポリフェノールが豊富で、甘みと酸味のバランスが良いため、健康 |
| | に良いフルーツとして知られています。', 'パイナップルは甘さと |
| | 酸味のバランスが良く、ビタミンCや食物繊維、カリウムが豊富に |
| | 含まれており、健康に良いとされています。さらに、消化酵素であ |
| | るブロメラインが含まれており、消化を助ける効果もあります。美 |
| | 味しくて栄養価も高いため、良いフルーツと言われています。'] |
+---------+--------------------------------------------------------------+
Constants: None
2.3. カスタムオペレータの使用
カスタムで出力に処理を行うこともできる。このために独自のComponent
を実装することもできるが、LambdaExtractor
を使ってカスタムな関数を実行するのが簡単。
fruits_alt = ExtractRegex(
GenerateText(
"5つのフルーツ名を生成して下さい。それぞれのフルールを<item>と</item>で囲んで下さい。"
),
r"<item>(.*?)<.?item>"
)
Output(fruits_alt).run(runner)
+---------+------------------------------------------------------------+
| input | output |
+=========+============================================================+
| None | ['りんご', 'バナナ', 'イチゴ', 'オレンジ', 'パイナップル'] |
+---------+------------------------------------------------------------+
Constants: None
これを全部3回繰り返舌文字列にする関数をLambdaExtractor
で適用する。
from sammo.extractors import LambdaExtractor
fruits_3times = LambdaExtractor(fruits_alt, "lambda x: x * 3",)
Output(fruits_3times).run(runner)
+---------+---------------------------------------------------+
| input | output |
+=========+===================================================+
| None | ['りんごりんごりんご', 'バナナバナナバナナ', |
| | 'いちごいちごいちご', 'マンゴーマンゴーマンゴー', |
| | 'ぶどうぶどうぶどう'] |
+---------+---------------------------------------------------+
Constants: None
ここちょっと難しいなと思ったのが、関数を文字列で渡さないといけないせいか、モジュールとかがうまく使えない。例えば、
from sammo.extractors import LambdaExtractor
import jaconv
fruits_katakanized = LambdaExtractor(fruits_alt, "lambda x: jaconv.hira2kata(x)",)
Output(fruits_katakanized).run(runner)
| NameError: name 'jaconv' is not defined
こちらもダメ
from sammo.extractors import LambdaExtractor
import jaconv
def katakanize(text):
return jaconv.hira2kata(text)
fruits_katakanized = LambdaExtractor(fruits_alt, "lambda x: katakanize(x)",)
Output(fruits_katakanized).run(runner)
| NameError: name 'katakanize' is not defined
これなんかいい方法あるかな・・・
Tutorials: 3. Basic Prompt Engineering
SAMMOにはいろんなプロンプトを試行錯誤するためのツールが用意されている。
ここまでのチュートリアルをそのまま続けていれば、ランナーの初期化やデータテーブルの準備はできているはずなのでそのまま使える。もしここから始めるならば以下を実行。
APIキーのセットとランナー初期化
import os
import json
from dotenv import load_dotenv
load_dotenv()
config_dir = "./config"
config = {"api_key": os.environ['OPENAI_API_KEY']}
os.makedirs(config_dir, exist_ok=True)
with open(f"{config_dir}/personal.openai" , "w") as f:
json.dump(config, f)
import pathlib
import sammo
from sammo.runners import OpenAIChat
from sammo.base import Template, EvaluationScore
from sammo.components import Output, GenerateText, ForEach, Union
from sammo.extractors import ExtractRegex
from sammo.data import DataTable
import json
import requests
import os
#API_CONFIG_FILE = pathlib.Path().cwd().parent / "config" / "personal.openai"
API_CONFIG_FILE = pathlib.Path().cwd() / "config" / "personal.openai"
API_CONFIG = ""
if API_CONFIG_FILE.exists():
API_CONFIG = API_CONFIG_FILE
if not API_CONFIG:
raise ValueError('Please set API_CONFIG to {"api_key": "YOUR_KEY"}')
_ = sammo.setup_logger("WARNING") # we're only interested in warnings for now
runner = OpenAIChat(
model_id="gpt-3.5-turbo-0125",
api_config=API_CONFIG,
cache=os.getenv("CACHE_FILE", "cache.tsv"),
timeout=30,
)
データセット取得&日本語化
import requests
import json
URL = "https://github.com/google/BIG-bench/raw/main/bigbench/benchmark_tasks/implicatures/task.json"
task = json.loads(requests.get(URL).content)
# convert label to single string
for x in task["examples"]:
x["output"] = max(x["target_scores"], key=x["target_scores"].get)
import re
from tqdm.notebook import tqdm
def translate_google(text: str) -> str:
google_api_key = os.environ["GOOGLE_API_KEY"]
response = requests.post(
f"https://translation.googleapis.com/language/translate/v2?key={google_api_key}",
json={
"q": [text],
"target": "ja",
"format": "text"
},
timeout=5000,
headers={
"Content-Type": "application/json",
},
)
res = response.json()
return res["data"]["translations"][0]["translatedText"]
task['description'] = "Speaker 1に対するSpeaker 2の回答を yes か no で予測する。"
task['task_prefix'] = "Speaker 2の回答は、yes または no のどちらを意味しますか? "
for example in tqdm(task["examples"]):
m = re.search(r'^Speaker 1: (.*) Speaker 2: (.*)$', example["input"])
(s1_en, s2_en) = m.groups()
conv_en = f"{s1_en}\n{s2_en}"
conv_ja = translate_google(conv_en)
(s1_ja, s2_ja) = conv_ja.split("\n")
example["input"] = "Speaker 1: {} Speaker 2: {}".format(s1_ja, s2_ja)
from sammo.data import DataTable
mydata = DataTable.from_records(
task["examples"],
input_fields="input",
constants={"instructions": task["task_prefix"]},
)
こんな感じのデータができているはず。
len(mydata)
492
mydata
+--------------------------------------------------------------+----------+
| input | output |
+==============================================================+==========+
| Speaker 1: 「でも怖くないんですか?」 Speaker 2: | no |
| 「奥さん、サメは人を襲うことはありませんよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「辞めたいの?」 Speaker 2: | no |
| 「私は、困難に直面しても諦めるようなタイプではありません。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「これらのクライアントを説得すべきでしょうか?」 | yes |
| Speaker 2: | |
| 「彼らは資金力のある本当に重要なクライアントです。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「それで、あなたはそれを持っているの?」 Speaker | yes |
| 2: 「それを手に入れるために、何人かの喉を切り裂かなければな | |
| らなかった。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: | no |
| 「ライモンドに一緒に行くよう頼んでも構いませんか?」 Speaker | |
| 2: 「なぜ気にする必要はないと思います。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「彼らは喧嘩しますか?」 Speaker 2: | yes |
| 「犬猿の仲みたいに喧嘩します。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「ジュースを飲みに来ませんか?」 Speaker 2: | yes |
| 「喉が乾きすぎて骨のように乾いています。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「終わった?」 Speaker 2: | no |
| 「何もできなかったよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: | no |
| 「あなたたちは定期的にデートしていたの?よかった。」 Speaker | |
| 2: 「ほとんどない。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「何か証拠は見つかったの?」 Speaker 2: | no |
| 「家はピカピカだ。」 | |
+--------------------------------------------------------------+----------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '}
Click to add a cell.
まず評価スコアを算出する関数を用意しておく。
def accuracy(y_true: DataTable, y_pred: DataTable) -> EvaluationScore:
y_true = y_true.outputs.values
y_pred = y_pred.outputs.values
n_correct = sum([y_p == y_t for y_p, y_t in zip(y_pred, y_true)])
return EvaluationScore(n_correct / len(y_true))
異なるプロンプトを試す場合には、複数のプロンプト候補の「空間」を定義する必要がある。one_of
を使うとこれを定義することができる。
from sammo.search import EnumerativeSearch
from sammo.search_op import one_of
from sammo.base import Template
from sammo.components import Output, GenerateText
def labeling_prompt_space():
instructions = one_of(
[
"Speaker 2の回答は、Speaker 1に対して yes または no のどちらを意味しますか?",
"Speaker 2 は yes または no のどちらで話し始めるべきかを考えて下さい。",
],
name="instr",
)
prompt = GenerateText(
Template(
"Instructions:{{{instructions}}}\nOutput labels: yes, no\nInput: {{{input}}}\nOutput:",
instructions=instructions,
)
)
return Output(prompt)
それぞれのプロンプトの検索空間を定義したら検索を行う。っていうか検索というのはちょっとピンとこないな。「探索」とかのほうが近いのかも?
sample = mydata.sample(10, seed=42)
searcher = EnumerativeSearch(runner, labeling_prompt_space, accuracy)
searcher.fit(sample)
searcher.show_report()
candidate[###################################]2/2[00:09<00:00] >> minibatches (total)[#######################]20/20[00:09<00:00]
Fitting log (2 entries):
iteration action objective costs parse_errors
----------- ------------------------------------------------ ----------- ---------------------------- --------------
0 {'instr': "'Speaker 2の回答は、Speaker 1に対して 0.6 {'input': 918, 'output': 60} 0.0
yes または no のどちらを意味しますか?'"}
1 {'instr': "'Speaker 2 は yes または no 0.5 {'input': 908, 'output': 10} 0.0
のどちらで話し始めるべきかを考えて下さい。'"}
これにtemperatureをランダムで追加するようにしてみる。
from sammo.search import EnumerativeSearch
from sammo.search_op import one_of
from sammo.base import Template
from sammo.components import Output, GenerateText
def labeling_prompt_space():
instructions = one_of(
[
"Speaker 2の回答は、Speaker 1に対して yes または no のどちらを意味しますか?",
"Speaker 2 は yes または no のどちらで話し始めるべきかを考えて下さい。",
],
name="instr",
)
prompt = GenerateText(
Template(
"Instructions:{{{instructions}}}\nOutput labels: yes, no\nInput: {{{input}}}\nOutput:",
instructions=instructions,
),
randomness=one_of([0.7, 1.0], name="randomness"),
)
return Output(prompt)
candidate[###################################]4/4[00:19<00:00] >> minibatches (total)[#######################]40/40[00:19<00:00]
Fitting log (4 entries):
iteration action objective costs parse_errors
----------- ------------------------------------------------ ----------- ---------------------------- --------------
0 {'instr': "'Speaker 2の回答は、Speaker 1に対して 0.6 {'input': 918, 'output': 10} 0.0
yes または no のどちらを意味しますか?'",
'randomness': 0.7}
1 {'instr': "'Speaker 2の回答は、Speaker 1に対して 0.5 {'input': 918, 'output': 48} 0.0
yes または no のどちらを意味しますか?'",
'randomness': 1.0}
2 {'instr': "'Speaker 2 は yes または no 0.5 {'input': 908, 'output': 10} 0.0
のどちらで話し始めるべきかを考えて下さい。'",
'randomness': 0.7}
3 {'instr': "'Speaker 2 は yes または no 0.5 {'input': 908, 'output': 10} 0.0
のどちらで話し始めるべきかを考えて下さい。'",
'randomness': 1.0}
0のパターンが一番良い、というのがわかる。
だいぶプロンプトエンジニアリングらしくなってきた。
Tutorials: 4. Minibatching for Call Efficiency
データのアノテーションなどで指示等が共通であれば、一つ一つリクエストを投げるよりも、ミニバッチを使うことで呼び出し効率を上げることができる。
ここまでのチュートリアルをそのまま続けていれば、ランナーの初期化やデータテーブルの準備はできているはずなのでそのまま使える。もしここから始めるならば以下を実行。
APIキーのセットとランナー初期化
import os
import json
from dotenv import load_dotenv
load_dotenv()
config_dir = "./config"
config = {"api_key": os.environ['OPENAI_API_KEY']}
os.makedirs(config_dir, exist_ok=True)
with open(f"{config_dir}/personal.openai" , "w") as f:
json.dump(config, f)
import pathlib
import sammo
from sammo.runners import OpenAIChat
from sammo.base import Template, EvaluationScore
from sammo.components import Output, GenerateText, ForEach, Union
from sammo.extractors import ExtractRegex
from sammo.data import DataTable
import json
import requests
import os
#API_CONFIG_FILE = pathlib.Path().cwd().parent / "config" / "personal.openai"
API_CONFIG_FILE = pathlib.Path().cwd() / "config" / "personal.openai"
API_CONFIG = ""
if API_CONFIG_FILE.exists():
API_CONFIG = API_CONFIG_FILE
if not API_CONFIG:
raise ValueError('Please set API_CONFIG to {"api_key": "YOUR_KEY"}')
_ = sammo.setup_logger("WARNING") # we're only interested in warnings for now
runner = OpenAIChat(
model_id="gpt-3.5-turbo-0125",
api_config=API_CONFIG,
cache=os.getenv("CACHE_FILE", "cache.tsv"),
timeout=30,
)
データセット取得&日本語化&評価用関数の作成
import requests
import json
URL = "https://github.com/google/BIG-bench/raw/main/bigbench/benchmark_tasks/implicatures/task.json"
task = json.loads(requests.get(URL).content)
# convert label to single string
for x in task["examples"]:
x["output"] = max(x["target_scores"], key=x["target_scores"].get)
import re
from tqdm.notebook import tqdm
def translate_google(text: str) -> str:
google_api_key = os.environ["GOOGLE_API_KEY"]
response = requests.post(
f"https://translation.googleapis.com/language/translate/v2?key={google_api_key}",
json={
"q": [text],
"target": "ja",
"format": "text"
},
timeout=5000,
headers={
"Content-Type": "application/json",
},
)
res = response.json()
return res["data"]["translations"][0]["translatedText"]
task['description'] = "Speaker 1に対するSpeaker 2の回答を yes か no で予測する。"
task['task_prefix'] = "Speaker 2の回答は、yes または no のどちらを意味しますか? "
for example in tqdm(task["examples"]):
m = re.search(r'^Speaker 1: (.*) Speaker 2: (.*)$', example["input"])
(s1_en, s2_en) = m.groups()
conv_en = f"{s1_en}\n{s2_en}"
conv_ja = translate_google(conv_en)
(s1_ja, s2_ja) = conv_ja.split("\n")
example["input"] = "Speaker 1: {} Speaker 2: {}".format(s1_ja, s2_ja)
from sammo.data import DataTable
mydata = DataTable.from_records(
task["examples"],
input_fields="input",
constants={"instructions": task["task_prefix"]},
)
def accuracy(y_true: DataTable, y_pred: DataTable) -> EvaluationScore:
y_true = y_true.outputs.values
y_pred = y_pred.outputs.values
n_correct = sum([y_p == y_t for y_p, y_t in zip(y_pred, y_true)])
return EvaluationScore(n_correct / len(y_true))
mydataにデータセットがロードされているものとする。
4.1. 手動ミニバッチ
まずは手動でミニバッチを設定する。handlebarsのシンタックスを使って、すべての入力に対してミニバッチを実行する。
labeling_prompt = GenerateText(
Template(
"Instructions:{{constants.instructions}}\nOutput labels: yes, no\n"
"{{#each inputs}}Input: {{this}}{{/each}}\nOutput:"
)
)
Outputコンポーネントでミニバッチのサイズを指定する。
labeling_outputter = Output(labeling_prompt, minibatch_size=10)
sample = mydata.sample(10, seed=42)
try:
result = labeling_outputter.run(runner, sample)
except Exception as e:
print(f"\nException: {e}")
minibatches[###################################################################################]1/1[00:01<00:00, 0.69it/s]
Exception: Minibatch results do not have right length (need: 10, got: 1)
失敗。
1回のLLMコールで得られる回答の数は入力行と揃っている必要がある。
とあるのだけどちょっと意味がわからないな。これまでのチュートリアルと違って、確かに実行は1回だけになってるようなので、おそらく出力から何かしら取り出すと言うようなことが必要になるということなのだと思う。
ということで出力に対して正規表現を使って取り出すような処理を追加する。
labeling_outputter = Output(
ExtractRegex(labeling_prompt, "(?i)yes|no"), minibatch_size=10
)
result = labeling_outputter.run(runner, sample)
result
minibatches[################################################################################]1/1[00:00<00:00, 1219.52it/s]
+--------------------------------------------------------------+----------+
| input | output |
+==============================================================+==========+
| Speaker 1: 「よくやるんですか?」 Speaker 2: | no |
| 「初めてです。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「私を怒らせようとしているの?」 Speaker 2: 「た | no |
| だ、あなたが怒っているのなら理解できると言っているだけです。 | |
| 」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「答えがほしいの?!」 Speaker 2: | no |
| 「真実がほしい。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「箱を運べますか?」 Speaker 2: | no |
| 「羽のように軽いですよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「外は暑いですか?」 Speaker 2: | yes |
| 「歩道で卵を焼くこともできますよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「お返ししましょうか?」 Speaker 2: | no |
| 「素晴らしさや魅力には料金はかかりません。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「ボブ、私の車を運転できるかな?」 Speaker 2: | no |
| 「普通の6気筒だよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「コードレッドを指示したのか?」 Speaker 2: | yes |
| 「その通りだ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「雨を見たことがあるでしょう...?」 Speaker 2: | no |
| 「私たちはあまり外に出ません。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「鍵を開ける方法を知っている人はいますか?」 | yes |
| Speaker 2: 「もちろん。鍵を開けるのは私の得意分野です。」 | |
+--------------------------------------------------------------+----------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '}
1回で取り出せた模様。
ただ、手動の場合はこれをいちいちフォーマットしないといけない。MetaTemplate
を使うとこれを大幅に簡素化できる。
MetaPrompt
クラスを使う
4.2. MetaPrompt
クラスは、入れ子になった命令のリスト、命令がどのようにレンダリングされるかを指定する引数、およびコンテキスト内の例と入力例で共有されるDataFormatterインスタンスを取る。
とあるがピンとこないのでコードを見たほうが早い。
from sammo.instructions import MetaPrompt, Section, Paragraph, InputData, FewshotExamples
from sammo.dataformatters import (
QuestionAnswerFormatter,
JSONDataFormatter
)
mprompt = MetaPrompt(
[
Section("Instructions", mydata.constants["instructions"]),
Section("Examples", FewshotExamples(mydata.sample(3, seed=43))),
Paragraph("\nOutput labels: yes, no"),
Paragraph(InputData()),
],
render_as="markdown",
data_formatter=QuestionAnswerFormatter(["yes", "no"]),
).with_extractor("empty_result")
なるほど、指示、few-shot、などは「セクション」、ラベルや入力データは「段落」、レンダリングや出力フォーマットの指定などが定義される。たしかに構造化されて定義されている。
で実際ににどういうメタプロンプトが生成されるかを確認する。
print(mprompt)
ExtractRegex(
child = GenerateText(
child = MetaPrompt(
child = [
0 : Section(
name = 'Instructions',
content = 'Speaker 2の回答は、yes または no のどちらを意味しますか? ',
id = None
),
1 : Section(
name = 'Examples',
content = FewshotExamples(
data = +--------------------------------------------------------------+----------+
| input | output |
+==============================================================+==========+
| Speaker 1: 「傘を持って行ったほうがいいですか?」 Speaker 2: | yes |
| 「念には念を入れたほうがいいです。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「戦う価値のある女の子はいますか?」 Speaker 2: | no |
| 「いたらいいのに。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「面接に行くべきだと思いますか?」 Speaker 2: | yes |
| 「残りの人生、失敗者でいたいですか?」 | |
+--------------------------------------------------------------+----------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '},
n_examples = None,
name = None
),
id = None
),
2 : Paragraph(
content = '\nOutput labels: yes, no',
id = None
),
3 : Paragraph(
content = InputData(
id_offset = 0,
name = None
),
id = None
)
],
render_as = 'markdown',
data_formatter = QuestionAnswerFormatter(
all_labels = [
0 : 'yes',
1 : 'no'
]
),
name = None,
seed = 0
),
name = None,
system_prompt = None,
history = None,
seed = 0,
randomness = 0,
max_tokens = None,
json_mode = False,
on_error = 'empty_result'
),
regex = '^\\s*A[^:]*:\\s*([^\\n]*)',
max_matches = None,
strip_whitespaces = True
)
なるほど、正規表現で解析されるように見える。
では実行してみる。
result = Output(mprompt, minibatch_size=5, on_error="empty_result").run(
runner, sample
)
result[:5]
minibatches[###################################################################################]2/2[00:01<00:00, 1.94it/s]
+--------------------------------------------------------------+----------+
| input | output |
+==============================================================+==========+
| Speaker 1: 「よくやるんですか?」 Speaker 2: | no |
| 「初めてです。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「私を怒らせようとしているの?」 Speaker 2: 「た | no |
| だ、あなたが怒っているのなら理解できると言っているだけです。 | |
| 」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「答えがほしいの?!」 Speaker 2: | yes |
| 「真実がほしい。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「箱を運べますか?」 Speaker 2: | yes |
| 「羽のように軽いですよ。」 | |
+--------------------------------------------------------------+----------+
| Speaker 1: 「外は暑いですか?」 Speaker 2: | yes |
| 「歩道で卵を焼くこともできますよ。」 | |
+--------------------------------------------------------------+----------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '}
さらに実際に送信されたプロンプトも確認できる。
print(result.outputs.llm_requests[0][0])
# Instructions
Speaker 2の回答は、yes または no のどちらを意味しますか?
# Examples
Q[0]: Speaker 1: 「傘を持って行ったほうがいいですか?」 Speaker 2: 「念には念を入れたほうがいいです。」
A[0]: yes
Q[1]: Speaker 1: 「戦う価値のある女の子はいますか?」 Speaker 2: 「いたらいいのに。」
A[1]: no
Q[2]: Speaker 1: 「面接に行くべきだと思いますか?」 Speaker 2: 「残りの人生、失敗者でいたいですか?」
A[2]: yes
Output labels: yes, no
Q[0]: Speaker 1: 「よくやるんですか?」 Speaker 2: 「初めてです。」
Q[1]: Speaker 1: 「私を怒らせようとしているの?」 Speaker 2: 「ただ、あなたが怒っているのなら理解できると言っているだけです。」
Q[2]: Speaker 1: 「答えがほしいの?!」 Speaker 2: 「真実がほしい。」
Q[3]: Speaker 1: 「箱を運べますか?」 Speaker 2: 「羽のように軽いですよ。」
Q[4]: Speaker 1: 「外は暑いですか?」 Speaker 2: 「歩道で卵を焼くこともできますよ。」
4.2.1. データフォーマットを変更する
上記のフォーマットからJSONに変える。
modified_mprompt = MetaPrompt(
[
Section("Instructions", mydata.constants["instructions"]),
Section("Examples", FewshotExamples(mydata.sample(3, seed=43))),
Paragraph("\nOutput labels: yes, no"),
Paragraph(InputData()),
],
render_as="markdown",
data_formatter=JSONDataFormatter(), # <-- changed
).with_extractor("empty_result")
result = Output(modified_mprompt, minibatch_size=5, on_error="empty_result"
).run(runner, sample)
result[:5]
+--------------------------------------------------------------+---------------------------------------------------+
| input | output |
+==============================================================+===================================================+
| Speaker 1: 「よくやるんですか?」 Speaker 2: | EmptyResult(value='Number of returned results was |
| 「初めてです。」 | inconsistent.', parent=list) |
+--------------------------------------------------------------+---------------------------------------------------+
| Speaker 1: 「私を怒らせようとしているの?」 Speaker 2: 「た | EmptyResult(value='Number of returned results was |
| だ、あなたが怒っているのなら理解できると言っているだけです。 | inconsistent.', parent=list) |
| 」 | |
+--------------------------------------------------------------+---------------------------------------------------+
| Speaker 1: 「答えがほしいの?!」 Speaker 2: | EmptyResult(value='Number of returned results was |
| 「真実がほしい。」 | inconsistent.', parent=list) |
+--------------------------------------------------------------+---------------------------------------------------+
| Speaker 1: 「箱を運べますか?」 Speaker 2: | EmptyResult(value='Number of returned results was |
| 「羽のように軽いですよ。」 | inconsistent.', parent=list) |
+--------------------------------------------------------------+---------------------------------------------------+
| Speaker 1: 「外は暑いですか?」 Speaker 2: | EmptyResult(value='Number of returned results was |
| 「歩道で卵を焼くこともできますよ。」 | inconsistent.', parent=list) |
+--------------------------------------------------------------+---------------------------------------------------+
Constants: {'instructions': 'Speaker 2の回答は、yes または no のどちらを意味しますか? '}
ちょっとうまくいかなかった。日本語にしたからかも。
Tutorials: 5. Instruction Optimization
ここでいう「指示の最適化」とは、精度などの目標指標の最大化を目指して指示を見つけることをいう。なお、trainとtestには100以上の例が推奨とのこと。
引き続きこれまでのデータを使う。ここからやる場合には以下。
APIキーのセットとランナー初期化
import os
import json
from dotenv import load_dotenv
load_dotenv()
config_dir = "./config"
config = {"api_key": os.environ['OPENAI_API_KEY']}
os.makedirs(config_dir, exist_ok=True)
with open(f"{config_dir}/personal.openai" , "w") as f:
json.dump(config, f)
import pathlib
import sammo
from sammo.runners import OpenAIChat
from sammo.base import Template, EvaluationScore
from sammo.components import Output, GenerateText, ForEach, Union
from sammo.extractors import ExtractRegex
from sammo.data import DataTable
import json
import requests
import os
#API_CONFIG_FILE = pathlib.Path().cwd().parent / "config" / "personal.openai"
API_CONFIG_FILE = pathlib.Path().cwd() / "config" / "personal.openai"
API_CONFIG = ""
if API_CONFIG_FILE.exists():
API_CONFIG = API_CONFIG_FILE
if not API_CONFIG:
raise ValueError('Please set API_CONFIG to {"api_key": "YOUR_KEY"}')
_ = sammo.setup_logger("WARNING") # we're only interested in warnings for now
runner = OpenAIChat(
model_id="gpt-3.5-turbo-0125",
api_config=API_CONFIG,
cache=os.getenv("CACHE_FILE", "cache.tsv"),
timeout=30,
)
データセット取得&日本語化&評価用関数の作成
import requests
import json
URL = "https://github.com/google/BIG-bench/raw/main/bigbench/benchmark_tasks/implicatures/task.json"
task = json.loads(requests.get(URL).content)
# convert label to single string
for x in task["examples"]:
x["output"] = max(x["target_scores"], key=x["target_scores"].get)
import re
from tqdm.notebook import tqdm
def translate_google(text: str) -> str:
google_api_key = os.environ["GOOGLE_API_KEY"]
response = requests.post(
f"https://translation.googleapis.com/language/translate/v2?key={google_api_key}",
json={
"q": [text],
"target": "ja",
"format": "text"
},
timeout=5000,
headers={
"Content-Type": "application/json",
},
)
res = response.json()
return res["data"]["translations"][0]["translatedText"]
task['description'] = "Speaker 1に対するSpeaker 2の回答を yes か no で予測する。"
task['task_prefix'] = "Speaker 2の回答は、yes または no のどちらを意味しますか? "
for example in tqdm(task["examples"]):
m = re.search(r'^Speaker 1: (.*) Speaker 2: (.*)$', example["input"])
(s1_en, s2_en) = m.groups()
conv_en = f"{s1_en}\n{s2_en}"
conv_ja = translate_google(conv_en)
(s1_ja, s2_ja) = conv_ja.split("\n")
example["input"] = "Speaker 1: {} Speaker 2: {}".format(s1_ja, s2_ja)
from sammo.data import DataTable
mydata = DataTable.from_records(
task["examples"],
input_fields="input",
constants={"instructions": task["task_prefix"]},
)
def accuracy(y_true: DataTable, y_pred: DataTable) -> EvaluationScore:
y_true = y_true.outputs.values
y_pred = y_pred.outputs.values
n_correct = sum([y_p == y_t for y_p, y_t in zip(y_pred, y_true)])
return EvaluationScore(n_correct / len(y_true))
mydataにデータセットがロードされているものとする。
5.1. ステップ1: 初期の候補セットの定義
まずはビームサーチ(解の候補を幅広く探索するが、同時に候補数を限定して検索を進める)と変異オペレータ(候補に小さな変更を加えて新しい解を生成)を使って初期の候補セットを改善していく。ここまでにやったグリッドサーチ(多くの候補から一定のルール・パラメータで選択する)と同じように、候補のパラメータセットを定義する。
5.1.1. Callablesを使って静的な値をバインドする
メタプロンプトを構築するために必要な設定や入力データなどの静的な値をCallables経由で使いやすくする。
from sammo.instructions import MetaPrompt, Section, Paragraph, InputData
from sammo.dataformatters import PlainFormatter
from sammo.search_op import one_of
class InititialCandidates:
def __init__(self, dtrain):
self.dtrain = dtrain
def __call__(self):
example_formatter = PlainFormatter(
all_labels=self.dtrain.outputs.unique(), orient="item"
)
labels = self.dtrain.outputs.unique()
instructions = MetaPrompt(
[
Paragraph("Instructions: "),
Paragraph(
one_of(
[
self.dtrain.constants["instructions"],
"与えられた入力からベストの出力ラベルを見つけて下さい。",
"Speaker 2の回答は、Speaker 1に対して yes または no のどちらを意味しますか?",
"Speaker 2 は yes または no のどちらで話し始めるべきかを考えて下さい。",
]
),
id="instructions",
),
Paragraph("\n"),
Paragraph(
f"Output labels: {', '.join(labels)}\n" if len(labels) <= 10 else ""
),
Paragraph(InputData()),
Paragraph("Output: "),
],
render_as="raw",
data_formatter=example_formatter,
)
return Output(
instructions.with_extractor("raise"),
minibatch_size=1,
on_error="empty_result",
)
5.2. ステップ2: 変異オペレータのセットの定義
ビームサーチの各ステップで、SAMMOは変異オペレータのサンプリングを行い、現在のアクティブな候補セットに適用する。
from sammo.mutators import BagOfMutators, InduceInstructions, Paraphrase
d_train = mydata.sample(10, seed=13)
mutation_operators = BagOfMutators(
InititialCandidates(d_train),
InduceInstructions({"id": "instructions"}, d_train),
Paraphrase({"id": "instructions"}),
sample_for_init_candidates=False,
)
ここでは、InitialCandidates
で設定した初期候補セットに対して、2つの異なる変異オペレータを定義することができる
- ラベルづけされたサンプルから新しい指示を引き出す
- 既存の指示を単に言い換える
メタプロンプトの変異操作をどこに適用するかはパス記述子(多分{"id": "instructions"}
)で指定する必要がある。
5.3. ステップ3: ビームサーチの実行
from sammo.search import BeamSearch
prompt_optimizer = BeamSearch(
runner,
mutation_operators,
accuracy,
maximize=True,
depth=3,
mutations_per_beam=2,
n_initial_candidates=4,
beam_width=4,
add_previous=True,
)
prompt_optimizer.fit(d_train)
prompt_optimizer.show_report()
結果
search depth[############]3/3[02:21<00:00] >> eval[#################################]8/8 >> tasks[#######]80/80[00:39<00:00, 2.20it/s]/s]
Fitting log (28 entries):
iteration action objective costs parse_errors prev_actions
----------- -------------------------------------------------- ----------- ----------------------------- -------------- --------------------------------------------------
-1 {'decision_0': "'与えられた入力からベストの出力ラ 0.7 {'input': 883, 'output': 10} 0.0 [{'decision_0': "'与えられた入力からベストの出力ラ
ベルを見つけて下さい。'"} ベルを見つけて下さい。'"}]
-1 {'decision_0': "'Speaker 2の回答は、yes または no 0.4 {'input': 873, 'output': 10} 0.0 [{'decision_0': "'Speaker 2の回答は、yes または no
のどちらを意味しますか? '"} のどちらを意味しますか? '"}]
-1 {'decision_0': "'Speaker 2 は yes または no 0.4 {'input': 923, 'output': 10} 0.0 [{'decision_0': "'Speaker 2 は yes または no
のどちらで話し始めるべきかを考えて下さい。'"} のどちらで話し始めるべきかを考えて下さい。'"}]
-1 {'decision_0': "'Speaker 2の回答は、Speaker 0.3 {'input': 933, 'output': 228} 0.0 [{'decision_0': "'Speaker 2の回答は、Speaker
1に対して yes または no のどちらを意味しますか?'"} 1に対して yes または no
のどちらを意味しますか?'"}]
0 Paraphrase 0.7 {'input': 713, 'output': 10} 0.0 ['Paraphrase', {'decision_0': "'与えられた入力から
ベストの出力ラベルを見つけて下さい。'"}]
0 Paraphrase 0.7 {'input': 57, 'output': 1} 0.0 ['Paraphrase', {'decision_0': "'与えられた入力から
ベストの出力ラベルを見つけて下さい。'"}]
0 InduceInstructions 0.8 {'input': 853, 'output': 10} 0.0 ['InduceInstructions', {'decision_0': "'Speaker
2の回答は、yes または no のどちらを意味しますか?
'"}]
0 Paraphrase 0.6 {'input': 723, 'output': 10} 0.0 ['Paraphrase', {'decision_0': "'Speaker
2の回答は、yes または no のどちらを意味しますか?
'"}]
0 Paraphrase 0.4 {'input': 773, 'output': 10} 0.0 ['Paraphrase', {'decision_0': "'Speaker 2 は yes
または no
のどちらで話し始めるべきかを考えて下さい。'"}]
0 Paraphrase 0.4 {'input': 783, 'output': 10} 0.0 ['Paraphrase', {'decision_0': "'Speaker 2 は yes
または no
のどちらで話し始めるべきかを考えて下さい。'"}]
0 InduceInstructions 0.8 {'input': 1013, 'output': 10} 0.0 ['InduceInstructions', {'decision_0': "'Speaker
2の回答は、Speaker 1に対して yes または no
のどちらを意味しますか?'"}]
0 Paraphrase 0.8 {'input': 793, 'output': 10} 0.0 ['Paraphrase', {'decision_0': "'Speaker
2の回答は、Speaker 1に対して yes または no
のどちらを意味しますか?'"}]
1 InduceInstructions 0.8 {'input': 1073, 'output': 10} 0.0 ['InduceInstructions', 'InduceInstructions',
{'decision_0': "'Speaker 2の回答は、yes または no
のどちらを意味しますか? '"}]
1 Paraphrase 1.0 {'input': 853, 'output': 10} 0.0 ['Paraphrase', 'InduceInstructions',
{'decision_0': "'Speaker 2の回答は、yes または no
のどちらを意味しますか? '"}]
1 InduceInstructions 0.8 {'input': 963, 'output': 10} 0.0 ['InduceInstructions', 'InduceInstructions',
{'decision_0': "'Speaker 2の回答は、Speaker
1に対して yes または no
のどちらを意味しますか?'"}]
1 InduceInstructions 0.8 {'input': 963, 'output': 10} 0.0 ['InduceInstructions', 'InduceInstructions',
{'decision_0': "'Speaker 2の回答は、Speaker
1に対して yes または no
のどちらを意味しますか?'"}]
1 Paraphrase 0.4 {'input': 773, 'output': 10} 0.0 ['Paraphrase', 'Paraphrase', {'decision_0':
"'Speaker 2の回答は、Speaker 1に対して yes または
no のどちらを意味しますか?'"}]
1 InduceInstructions 0.7 {'input': 1093, 'output': 10} 0.0 ['InduceInstructions', 'Paraphrase',
{'decision_0': "'Speaker 2の回答は、Speaker
1に対して yes または no
のどちらを意味しますか?'"}]
1 InduceInstructions 0.7 {'input': 95, 'output': 1} 0.0 ['InduceInstructions', 'Paraphrase',
{'decision_0': "'与えられた入力からベストの出力ラ
ベルを見つけて下さい。'"}]
1 Paraphrase 0.7 {'input': 723, 'output': 10} 0.0 ['Paraphrase', 'Paraphrase', {'decision_0': "'与え
られた入力からベストの出力ラベルを見つけて下さい。
'"}]
2 InduceInstructions 0.7 {'input': 993, 'output': 10} 0.0 ['InduceInstructions', 'Paraphrase',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、yes または no のどちらを意味しますか?
'"}]
2 InduceInstructions 0.9 {'input': 833, 'output': 10} 0.0 ['InduceInstructions', 'Paraphrase',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、yes または no のどちらを意味しますか?
'"}]
2 Paraphrase 0.7 {'input': 1013, 'output': 10} 0.0 ['Paraphrase', 'InduceInstructions',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、yes または no のどちらを意味しますか?
'"}]
2 Paraphrase 0.8 {'input': 1053, 'output': 10} 0.0 ['Paraphrase', 'InduceInstructions',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、yes または no のどちらを意味しますか?
'"}]
2 InduceInstructions 0.7 {'input': 873, 'output': 10} 0.0 ['InduceInstructions', 'InduceInstructions',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、Speaker 1に対して yes または no
のどちらを意味しますか?'"}]
2 Paraphrase 0.9 {'input': 943, 'output': 10} 0.0 ['Paraphrase', 'InduceInstructions',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、Speaker 1に対して yes または no
のどちらを意味しますか?'"}]
2 Paraphrase 0.7 {'input': 963, 'output': 10} 0.0 ['Paraphrase', 'InduceInstructions',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、Speaker 1に対して yes または no
のどちらを意味しますか?'"}]
2 Paraphrase 0.7 {'input': 893, 'output': 10} 0.0 ['Paraphrase', 'InduceInstructions',
'InduceInstructions', {'decision_0': "'Speaker
2の回答は、Speaker 1に対して yes または no
のどちらを意味しますか?'"}]
Action stats:
action stats
------------------ -----------------------------
Paraphrase {'chosen': 14, 'improved': 2}
InduceInstructions {'chosen': 10, 'improved': 2}
ベストなプロンプトは以下。
Output(
child = StripWhitespace(
child = GenerateText(
child = MetaPrompt(
child = [
0 : Paragraph(
content = 'Instructions: ',
id = None
),
1 : Paragraph(
content = 'For every input, decide whether the response is favorable (yes) or unfavorable (no) depending on the conversation given.',
id = 'instructions'
),
2 : Paragraph(
content = '\n',
id = None
),
3 : Paragraph(
content = 'Output labels: yes, no\n',
id = None
),
4 : Paragraph(
content = InputData(
id_offset = 0,
name = None
),
id = None
),
5 : Paragraph(
content = 'Output: ',
id = None
)
],
render_as = 'raw',
data_formatter = PlainFormatter(
all_labels = [
0 : 'yes',
1 : 'no'
],
orient = 'item'
),
name = None,
seed = 0
),
name = None,
system_prompt = None,
history = None,
seed = 0,
randomness = 0,
max_tokens = None,
json_mode = False,
on_error = 'empty_result'
),
on_error = 'raise',
flatten = True
),
minibatch_size = 1,
on_error = 'empty_result'
)
指示プロンプトが英語になってしまったなー。でもまあ結果的にはそれが一番良かったということになるのか。
とりあえずなんとなく雰囲気はわかったものの、もうちょっと使い込んでみないとわからないというのが正直なところ。
ただ、プロンプトを単なる文字列ではなく、プロンプト構造の各コンポーネントごとにバリエーションつけたり・言い換えを定義したり・出力フォーマットを定義したり、ってのをプログラマブルに構造化して評価するというのは、個人的にはとてもシステマチックに管理・探索ができて良いと思う。
あと、やはりプロンプトは英語前提で考えるほうがいいのかなーとも改めて思った。データはある程度日本語でもできそうではあるけど、コアの部分で英語のプロンプトが含まれるので、限界はありそう。
DSPyもある意味、広い意味では同じ用途かなーと思ってて興味あるんだけど、あれもう一つ難しそうなのよなー、日本語厳しそうな感もあるし。。。。