💬

プロンプトエンジニアリングを終わらせるDSPy

に公開

はじめに

DSPyに夢中である。DSPyの最も重要な点は、プロンプトエンジニアリングを排除できる可能性を秘めていることだ。可能性を秘めているだけで、現状、プロンプトエンジニアリングが完全に不要になったわけではないのだが、こちらの分野を真面目に学ぶ動機として十分だ。使い心地というか、真面目にこの分野やってみようと思ったのはPFNのChainerを初めて触ってみたときの感覚に似ている。その後、PyTorchが出現し、すごいスピードで技術とツールが進歩していく中で、学んだことをまとめるためにブログを書き始めたのを思い出した。

https://dspy.ai/#__tabbed_3_1

DSPyがディープラーニングのフレームワークであるPyTorchやChainerに似ているのは表面上の使い心地だけの話ではない。わざわざプログラムの書き心地がこうなったのは、「これまで人手で頑張ってきたことを、パラメータにおきかえてしまい、教師データでガバっと訓練してしまおう」という方針になっているからだ。そのような方針を実現するために、微分可能な計算を柔軟につなげられるライブラリに落とし込んだのがディープラーニングのフレームワークと呼ばれるものであるとひとまず考えてよい。

DSPyもプロンプトを頑張って作るという作業を、プログラム可能にし、最適化問題に落とし込んでいる。ここで

  • ディープラーニング:人手での特徴量エンジニアリングを排除し、中間層として学習
  • Prompt Tuning:人手でのプロンプトエンジニアリングを排除し、調整可能プロンプトとして学習

という関係性がある。ディープラーニングのフレームワークが世の中に出てから、その手軽さ故に様々な試みがなされ、今のAIの発展につながったのは言うまでもない。明確にツールが科学を加速させた事実がある。

とはいえ、今でもテーブルデータの機械学習と言えばGradient Boostingだし、ドメイン固有の知識が強いようなデータは固有アルゴリズムと最適化、線形モデルでつなぐ程度が強かったりする。必ずしも全部がディープラーニングになったわけではない。それでも、高次元データやマルチモーダルに対してディープラーニングが非常に有用であることは周知となっていき、今では画像、音声、言語に欠かせない存在となった。

プロンプトエンジニアリングは、ある意味で「大規模言語モデル」を分析対象とした特徴量エンジニアリングのようなものに見える。そこでディープラーニングみたく、プロンプトを訓練してしまおうという発想は自然に感じるし、可能性を感じる。

※以下本文とは直接関係ないコラム
DSPyはディープラーニングのフレームワークPyTorchに強く影響を受けている。PyTorchはChainerの設計思想に強く影響を受けている。当時のニューラルネットは、その順伝播計算にしても訓練にしても、計算グラフをまずは静的に定義して、最後に遅延評価で必要な計算を動かそうという仕組みになっていた。後にこれはDefine and Runと呼ばれた。
これに対してLSTMのような動的に計算グラフの系列長が変わるようなモデルを扱いやすくするために、計算を具体的に実施したら、その計算をグラフとして構築し、適宜勾配計算を行うというDefine by Runの思想が導入されたのがChainerである。このChainerを参考にして(記憶が定かではないがChainerのForkから開始して)最終的にはDeep LearningのデファクトとなったのがPyTorchである。
TensorFlowやTheanoのDefine and Runは書くのは大変だが一度動けば安心感がありかつ比較的高速であった。記憶ではChainerはTensorFlowの2倍程度遅かった覚えがある。ただし書き心地は良かった。そこに遅れて、PyTorchが現れた。こやつは早々にPythonランタイムで細かい処理をするのを辞め、計算グラフの管理や逆伝播などのコアな部分はC++をラップする形式として高速化を達成した。TensorFlowに対して遜色のない速度だったのを覚えている。後にPFNもコアな計算をPythonからC++へ移行したChainerXを打ち出した。しかしすでにネームバリューも含めて逆転は不能だった。深層学習フレームワークの開発はここで幕を閉じたのだが、その基礎であるCuPyやハイパーパラメータチューニングのOptuna等は今もたくましく生きている。
DSPyに関しては、正直なところ、PyTorchに影響を受けているというのは、せいぜいデータ・モデル・オプティマイザーの責務の分離(クラスの設計)程度の話で、どうもTensorFlowやTheanoからPyTorchなどに移行していったDefine By Runというパラダイムシフトの部分を反映しているわけではなさそうに見える。機能はむしろDefine and Runのように宣言的でむしろTensorFlow v1やTheanoをラップしていたKerasに似ていると思う。こちら間違っていたら是非教えてほしい。動的にもしかしたら色々できるんですか…?

DSPy

前回記事を見てもらえれば、初歩は分かる。
まとめると「プロンプトを明示的に書かずに、入出力のシグネチャとやってほしい処理を与えれば出力を得られる」という形式になっている。内部的にはシグネチャ自体がある種のプロンプトとして振る舞いはするのだが、ともかく、形式的には文章を与えるみたいなことは必ずしも実施しなくてよい。

https://zenn.dev/cybernetics/articles/f879e10b53c2db

重要な概念は下記に示すように、シグネチャと処理の内容だけを記述したのちに、その入出力データを与えて訓練を行えるということである。訓練されるのは、その入出力を結びつけるような上手なプロンプト(あるいはその埋め込み表現)である。

下記は教師あり学習の文脈からプロンプトチューニングを説明している記事である。アナロジーが入っているので、元の概念を知っているかによってわかりやすさは全然変わるが、要は機械学習問題になっているということである。

最適化問題は下記のような形式で書かれると思ってよい。

\hat p = \argmin_{p}\; \frac{1}{N}\sum_{i=1}^{N} \mathcal{L}\!\big(f_\theta([p;\,\mathbf{z}_{usr}^{(i)}]),\,x_i\big)

である。要するに埋め込まれた前置きのプロンプト p を上手に決めてあげることで、ユーザーの様々な問いかけ \mathbf z_{usr} = \mathrm{emb}(\mathbf x_{usr}) に対応できるようになることを期待するのだ。
https://zenn.dev/cybernetics/articles/a580175cf988e6

機械学習問題っぽくなっているので、似たようなフレームワークでプログラミングできるだろうということである。深層学習が後に微分可能プログラミングだ、と述べられたように、LLMのプロンプトによる制御もプログラミングに昇華されようとしている(かもしれない)。

DSPyによる最適化

お題は、「与えた文章を漫画NARUTOの主人公ナルトの話し方に変換し、更に与えた文章にはない一言を追加してもらう」というものである。どんな文章を作ってほしいのかの例示をすれば、今後新しい文章を生成するためのプロンプトを作ってくれるというわけである。

モデルの作成

Agentに相当するのは、非常にシンプルなCoTで文章を生成するものである。SignatureとModuleの使い方は前回記事で簡単に説明した。簡単におさらいすると、InputFieldが推論時にユーザーが与える項目で、OutputFieldがエージェントが出してくれる出力である。あたかも関数の入出力をクラスで定義してあげていると思ってよい。そのシグネチャに従う処理をModuleクラス内で使ってやる形式だ。

# ナルト口調変換用のシグネチャとモジュール定義
class NarutoSignature(dspy.Signature):
    polite_sentence = dspy.InputField(desc="です・ます調の落ち着いた一文")
    rationale = dspy.OutputField(desc="ナルトの喋りへ変換する際の推論過程")
    transformed = dspy.OutputField(desc="ナルト口調に変換した文。『でもさ、』以降に余計な一言を必ず添える")

class NarutoStyleChain(dspy.Module):
    def __init__(self):
        super().__init__()
        self.generator = dspy.ChainOfThought(NarutoSignature)

    def forward(self, polite_sentence):
        return self.generator(polite_sentence=polite_sentence)

make_naruto_chain = NarutoStyleChain()

Signature内のdesc引数が、簡単なプロンプトにはなるのだが、これだけでは必ずしも上手くいかない。

最適化前のエージェントの出力

今日の会議では議論を丁寧にまとめます。をナルトならどのように言うだろうか。
最適化を行っていないエージェントに文章を作ってもらう。たった二行だ。

polite_sentence = "今日の会議では議論を丁寧にまとめます。"
easy_response = make_naruto_chain(polite_sentence=polite_sentence)

結果は下記のようになった。

生成推論: ナルトは、友達や仲間に対してフレンドリーでカジュアルな言葉遣いをするキャラクターです。彼の口調には、軽い冗談や余計な一言を添えることが多いので、文の最後に「でもさ、」を加え、ナルトらしい雰囲気を出します。
生成出力: 今日の会議では議論を丁寧にまとめるよ。でもさ、みんなの意見もちゃんと聞かないとね!

生成出力はナルトっぽくない気がするし、「でもさ」の接続詞の後が不自然だ。もっと余計なこと言わなくていいよ感あふれる文章にしてほしい。

教師データ相当の作成

というわけで、じゃあ具体的にはどんな文章になっていればよいのか。というのをデータとして作ってあげる。Exampleクラスで下記のように作ってやればいい。この時、キーワード引数はSignatureクラスで設計したものと同じにすること。学習するときにSignatureで規定された変数をExampleから探しに行く振る舞いをする。ちなみにExampleに余計なユーザー設定の追加変数があるのは問題ない。最適化時に参照されないだけで、人間がデータを後で確認したい時のメモ書きとしては使える。

easy_example = dspy.Example(
    polite_sentence="今日の会議では議論を丁寧にまとめます。",
    transformed="オレが今日の会議をビシッとまとめてやるってばよ!でもさ、終わったらラーメン一杯くらい付き合ってくれよな?",
    rationale="敬体をくだけた一人称『オレ』に置き換え、語尾へ『ってばよ』を追加し、『でもさ、』で余計なお願いをぶつけた。"
).with_inputs("polite_sentence")

これで教師データ相当を一つ作れたことになる。もちろんこれをそれなりにたくさん作るので、「あれ?プロンプト頑張った方がよくない?」とか思ってしまうのだが、それは僕が問題をゼロベースから始めているからだ。実産業では、「こうなってほしいのだ!」みたいな例示だけが集まっているケースは相応にあるはずである。

ともかく、プログラムとしては上記のように教師データを作ることができる。教師データを下記のようにリストに格納してあげることで後にバッチデータとして扱うことができる。簡単だ。

trainset = [dspy.Example(...), dspy.Example(...)]

評価指標の設定

深層学習では多くの場合「損失関数」と呼ばれる最小化したい指標を設定する。これは大抵微分可能な形式を要求するのだが、Prompt Tuningの最適化は勾配法に由来しているわけではないので微分不可能なブラックボックス的な指標でも構わない。ともかく、得られた結果に対してスカラー値を割り当てられれば良い。ここでは外部のLLMに評価をさせてみることにする。もちろんルールベースの評価でも良い。ただ、おそらくSLMや安いLLMの訓練のために高価なLLMに評価をさせて使うというのがモダンだとは思う。

高価なLLMに評価させる場合も、適当なLLMに推論をさせる仕組みを入れる必要がある。これもSignatureModuleが使えるのだが、今回は評価をするLLMは推論だけを行えば良いので、それ良いの簡易なクラスがある。Predictクラスだ。シグネチャを与えれば推論が走るエージェントをインスタンス化できる。Assessというシグネチャを指定したクラスを作り、これは評価用のLLMで利用する。_run_checksで正解と予測からLLMに結果を評価させる関数を書いておく。ここはルールベースで良い。

# ナルト口調変換の質を自動評価するシグネチャを定義
class Assess(dspy.Signature):
    '''口調変換の観点をチェックし、改善ヒントを返す。'''
    assessment_transformed = dspy.InputField(desc="生成されたナルト口調の文")
    assessment_rationale = dspy.InputField(desc="生成時の推論メモ")
    assessment_input = dspy.InputField(desc="元の丁寧語文")
    assessment_question = dspy.InputField(desc="評価観点となる質問")
    assessment_answer = dspy.OutputField(desc="yes / no 判定")
    assessment_feedback = dspy.OutputField(desc="改善のヒント")

def _run_checks(gold, pred):
    polite = gold.get('polite_sentence', '')
    naruto_line = pred.get('transformed', '') if isinstance(pred, dict) else getattr(pred, 'transformed', '')
    rationale = pred.get('rationale', '') if isinstance(pred, dict) else getattr(pred, 'rationale', '')

    questions = [
        ("tone", "この文はナルトが使う一人称や『ってばよ』などの勢いある口調を含んでいますか?"),
        ("extra", "この文には『でもさ、』で始まる余計な一言が続いていますか?"),
        ("consistency", f"この文は元の丁寧語文『{polite}』の意味を保ちながら砕けた表現に変換していますか?"),
        ("rationale", "推論過程は採用した口調や余計な一言の意図を説明できていますか?")
    ]

    assessor = dspy.Predict(Assess)
    results = []
    feedback = []

    for key, question in questions:
        judgement = assessor(
            assessment_transformed=naruto_line,
            assessment_rationale=rationale,
            assessment_input=polite,
            assessment_question=question
        )
        answer = judgement.assessment_answer.lower() if judgement.assessment_answer else ''
        ok = 'yes' in answer
        results.append(ok)
        if (not ok) and judgement.assessment_feedback:
            feedback.append(judgement.assessment_feedback.strip())

    # 強制チェック: 『でもさ、』と『ってばよ』系が見つからない場合は強制減点
    if 'でもさ、' not in naruto_line:
        results.append(False)
        feedback.append("『でもさ、』で余計な一言を入れてください。")
    if ('ってばよ' not in naruto_line) and ('だってばよ' not in naruto_line):
        results.append(False)
        feedback.append("ナルトらしい語尾『ってばよ』を入れてください。")

    score = sum(results) / len(results) if results else 0.0
    return score, feedback

def metric(gold, pred, trace=None):
    score, _ = _run_checks(gold, pred)
    return round(score, 2)

def metric_with_feedback(example, prediction, trace=None, pred_name=None, pred_trace=None):
    score, feedback = _run_checks(example, prediction)
    message = os.linesep.join(feedback) if feedback else 'ナルト口調と余計な一言がうまく表現されています。'
    return dspy.Prediction(score=score, feedback=message)

# 使用例
metric(easy_example.inputs(), easy_example.labels())

外部LLMの評価結果をmetric関数ではスカラ値している。metric_with_feedbackはスカラ値に加えてフィードバックを添えている。これはGEPAという自己改善型のアルゴリズムで利用される形式の評価関数である。ここでは、スカラ値だけを要求する最適化器とフィードバックを要求する最適化器がいるのだなぁーくらいに思っていただいて良い。

最適化(COPRO)

モデル NarutoStyleChainと評価関数metricと訓練データtrainsetを利用して最適化をするのは下記のコードだけで良い。

from dspy.teleprompt import COPRO

prompt_optimizer = COPRO(metric=metric, verbose=True)
kwargs = dict(num_threads=64, display_progress=True, display_table=0)
prompt_tuned = prompt_optimizer.compile(NarutoStyleChain(), trainset=trainset, eval_kwargs=kwargs)

これにより下記の最適化の結果を得る。なるほど。何やら大したプロンプトになっていない。

polite_sentence 入力フィールドについて、その形式・文脈・文化的要素を分析し、その文中に含まれる丁寧さのニュアンスを詳しく解説する「rationale」を作成してください。そのうえで、礼儀正しさの本質と意図を保ちつつ、創造性・深み・表現力の面で別の可能性を探る「transformed」版を提示してください。回答では各出力を明確に示してください。

冒頭で見せた結果はこの最適化器の結果ではない。

最適化(GEPA)

モデル NarutoStyleChainと評価関数metric_with_feedbackと訓練データtrainsetに加え、検証データvalsetと反省用のLLMをrefletion_lmを準備して最適化を行う。このアルゴリズムは比較的最近出てきた進化計算系統のアルゴリズムで、評価関数から出てくるスカラー値とフィードバックを元に、どのように改善すべきかを反省用LLMが提案してくれる。その提案を受けて、最適化対象のNarutoStyleChainに埋め込むプロンプトを改善していく。

# GEPA 用のリフレクション向けモデルを用意
reflection_lm = dspy.LM('openai/gpt-4o-mini', temperature=1.0, max_tokens=2048)

gepa = dspy.GEPA(
    metric=metric_with_feedback,
    auto='light',
    num_threads=8,
    reflection_minibatch_size=2,
    reflection_lm=reflection_lm
)

gepa_compiled = gepa.compile(NarutoStyleChain(), trainset=trainset, valset=valset)

アルゴリズムで最適化された結果は下記である。下記のようなプロンプトが、以降学習済のAgentには付与されるというわけだ(※実際は英語で出力されたが、日本語に訳している)。COPROよりはるかに詳細になっていた。

フィールド polite_sentence が与えられたとき、あなたのタスクは rationale と transformed の2つの出力を生成することです。

【詳細な指示】

文脈の理解: polite_sentence は主にフォーマルな場面で使われる丁寧な表現です。これを、アニメ「NARUTO -ナルト-」のうずまきナルトの話し方(カジュアルでエネルギッシュ、フレンドリー)に似たスタイルへ変換します。

Rationale(理由付け)の生成:
・なぜ元の丁寧な文を変換する必要があるのかを説明する。
・ナルトのキャラクター特性を体現するためにトーンやスタイルを適応させる意義を述べる。
・考慮点: ナルトの親しみやすい口調/一人称は「私」ではなく「オレ」/口癖「〜ってばよ」の活用/熱意やモチベーションを前面に出し、聞き手とのフレンドリーなつながりを作ること。

変換後の文(Transformed)の作成:
・元の polite_sentence の核心的メッセージは保つ。
・よりカジュアルな口語表現にする。
・ナルトらしいフレーズや言い回しを取り入れ、個性とエネルギーを反映する。
・熱意や親しみやすさを加える。

フィードバックの反映:
・キャラクター固有の特徴に関するフィードバックに留意し、ナルトらしさを高めるために表現を調整する。
・カジュアルな導入やフレンドリーなひと言を添えるなど、個性を強める工夫を行う。

出力形式:
・rationale: 変換の意図やねらいの説明。
・transformed: ナルト口調に書き換えた文。

追加の注意事項:
・変換に不要な語句は入れない。
・元の丁寧な文の趣旨を維持しつつ、ナルトのエネルギッシュな個性を効果的に取り入れるバランスを取る。
・フィードバックがあれば柔軟に表現を修正し、よりナルトらしい表現に高める。

目標は、元のトーンを損なわずにナルトのキャラクターを正確に反映し、活気がありフレンドリーなコミュニケーションスタイルを作ることです。

訓練前と訓練後で出力の比較

訓練済は下記

polite_sentence = "今日の会議では議論を丁寧にまとめます。"
response = gepa_compiled(
    polite_sentence=polite_sentence
)
print(f'生成推論: {response.rationale}')
print(f'生成出力: {response.transformed}')

#生成推論: ナルトの喋り方には、オレという一人称や「ってばよ」といった特徴的なフレーズが含まれています。これにより、彼の親しみやすさや仲間意識が強調されます。また、ナルトは常に前向きでエネルギッシュな態度を持っているため、元の文の内容を保ちながらも、より活気のある表現に変えることが重要です。これにより、聴衆に対してより強いメッセージを伝えることができます。
#生成出力: 今日の会議では議論を丁寧にまとめるってばよ!でもさ、みんなの意見も大事だから、しっかり聞くぜ!

訓練前は下記

easy_response = make_naruto_chain(polite_sentence=polite_sentence)
print(f'生成推論: {easy_response.rationale}')
print(f'生成出力: {easy_response.transformed}')

#生成推論: ナルトは、友達や仲間に対してフレンドリーでカジュアルな言葉遣いをするキャラクターです。彼の口調には、軽い冗談や余計な一言を添えることが多いので、文の最後に「でもさ、」を加え、ナルトらしい雰囲気を出します。
#生成出力: 今日の会議では議論を丁寧にまとめるよ。でもさ、みんなの意見もちゃんと聞かないとね!

まとめ

DSPyの可能性を感じられるように、例題を通してプロンプトチューニングの実際の動きを確認した。
PyTorchライクに訓練のコードを掛けることを確認した。書いてみてわかったが訓練の部分が高度に抽象化されており、やはりPyTorchというよりはKerasの使い心地に似ていた。また、GEPAという最適化アルゴリズムで、かなりナルトっぽい発言が出せるようになった。

なんか小学生みたいなまとめになってしまったが、やはり可能性を感じるとともに、この世界も「文章やドキュメントを大量に持っている」データ所有者が覇権を取りそうな雰囲気が出てきており、何かよりいっそう格差が開きそうな気がしないでもない……。

Discussion