はじめに
タイトルは聞きなれないかもしれないが、「DSPyは、宣言型自己改善型Python(Declarative Self-improving Python)の略」とのことであるのでこのようにした。
実際、触ってみて、なるほど確かに、と思う部分があったので「宣言的」の部分に着目して説明したいと思う。いくつかのコード例を確認しながら、宣言的ってそういうことね、と理解してもらえればこの記事を読んだ意義があると思う。
DSPyはプロンプトチューニングするということを念頭に置いてPyTorchライクな使い方になっている。今回はそこまで踏み込まないけど、コード例を見ればその部分もなるほどねって、なる人はなると思う。
最近はチャットAIに聞けば大抵のことは知識上は分かってしまうし、コードを書いたりブログを書いたりはめっきり減ったのだが、自分で動作確認して得られる納得感みたいなものはやっぱり好きだな。(とはいえこれらのツールもコーディングAIが使いこなしていくと思うと、僕の納得感なんてどうでも良いのかもしれないけど…。)
ちなみに今回のDSPyを見てみようとなったのは下記のX上での会話からである。試してみようの機会をくれて非常にありがたかった。
OpenAIのAPIをたたいた場合
LLMに贅沢にも足し算をしてもらうだけのプロンプトを作る。例えば
-
task = "足し算をしてください。{num1} たす {num2}"
でプロンプトの基礎作成 -
prompt1 = task.format(num1="いち", num2="ご")
で具体的なプロンプト -
prompt2 = task.format(num1="よん", num2="じゅういち")
で別の具体的プロンプト
というような具合にプロンプトを作れますよ、ということを頭に入れておいてほしい。
基本的には「使いまわす文章」と「変更を行う文章」を切り分けて、後で差し込める形でstring変数を作っておくことでプロンプトを作ることができるということだ。
下記はプロンプトを作ってgpt-4o-miniへ投げて回答を得る例。
task = "足し算をしてください。{num1} たす {num2}"
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": task.format(num1="いち", num2="ご")
},
],
temperature=1,
max_tokens=256,
top_p=1,
frequency_penalty=0,
presence_penalty=0
)
print(response.choices[0].message.content)
さすが、数字ではなく日本語でかつひらがなで数字を表現してもしっかり足し算を実行してくれた。そんなこんなでLLMは自然言語を適切に扱って推論ができるというのはみなさんご存じの通りだ。
OpenAIに限らず、LLMの提供元が同じくAPIを提供してくれているので似たような形で利用することができる。ユーザー側は、ガードレールを仕込んだり、状態に応じてタスクを分岐したり、RAGを取り付けたり、評価・監視を実施したりすることである。これらの多くも、結局は何らかの計算や判定を外側で行いつつ、プロンプトを使い分けるという流れになっていく。結構めんどくさそうではなかろうか?
DSPy を使った場合
OpenAI のAPIと同じことをやってみる
LanguageModelをインスタンス化して、インスタンスにconfigurationを与えて、最後にプロンプトを投げ込んでいる。好みの問題かもしれないが、OpenAIのAPIを生で使うよりは幾分かすっきりしていると思うし、dspy.LM内に様々なLLMが取り扱える形になっているのは言うまでもない。
task = "足し算をしてください。{num1} たす {num2}"
import dspy
gpt4o_mini = dspy.LM('openai/gpt-4o-mini')
dspy.configure(lm=gpt4o_mini,
temperature=1,
max_tokens=256,
top_p=1,
frequency_penalty=0,
presence_penalty=0)
print(gpt4o_mini(task.format(num1="いち", num2="ご"))[0])
DSPyのインラインの方法
実は上記はプロンプトを自分で作る場合、またはOpenAIのAPIと比較するために見せた使い方で、DSPyらしさはないかもしれない。実は下記のように書いて回答を得ることができる。
add = dspy.Predict('num1, num2 -> sum')
print(add(num1='いち', num2='ご'))
これは一瞬混乱した。何が混乱したのかというと、プロンプトが見当たらない。dspy.Predict
には関数シグネチャを与えることができ、そのシグネチャを持つ関数オブジェクトが生成される。生成された関数オブジェクトにシグネチャ通りに実引数を与えるとあら不思議、なぞの予測値を出してくる。予測値はPrediction
型で、戻り値として与えた名前でkeyを持っており、valueがLLMの回答となっている。
これが宣言的の意味であって、プロンプトは内部で良しなに取り扱われている。それを確認するために関数シグネチャを変えてみる。num1
とnum2
からsum
を返すというシグネチャを、join
を返すというシグネチャに変更してみた。
add = dspy.Predict('num1, num2 -> join')
print(add(num1='いち', num2='ご'))
なるほど、引数をつなげた「いちご」を返してきた。引数がnum1,num2といかにも数字っぽいことは無視して、戻り値が川の「join」という言葉に合わせた処理に代わっている。ちなみにこの振る舞いが正しいかは「ユーザーが適切に評価」してやる必要がある。実は「いち->1」と「ご->5」をjoinした「15」を解かせたかったのかもしれない。そうした場合には、そのような回答例を複数与えて「prompt tuning」することができる。
クラスベースの方法
インラインの方法ではやれることが限られてきそうなのは言うまでもない。
PyTorchではnn.Module
を継承し、非常に複雑な計算をニューラルネットワークとしてモデル化できた。それと同じでDSPyもクラスベースでいろいろできる。
インラインと同じ結果を返すようなコードは下記のようになる。シグネチャをクラスで作ってあげて、処理もクラスで書いてあげる。
class Add(dspy.Signature):
num1 = dspy.InputField(desc="num1")
num2 = dspy.InputField(desc="num2")
sum = dspy.OutputField(desc="sum")
class Pred(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.Predict(Add)
def forward(self, num1, num2):
return self.prog(num1=num1, num2=num2)
calc = Pred()
print(calc(num1='いち',num2='ご'))
シグネチャはもっと複雑でも良い。例えば出力を複数準備してみよう。
class VariousProc(dspy.Signature):
num1 = dspy.InputField(desc="ひとつめの数")
num2 = dspy.InputField(desc="ふたつめの数")
sum = dspy.OutputField(desc="和")
diff = dspy.OutputField(desc="差")
strjoin = dspy.OutputField(desc="文字連結")
numjoin = dspy.OutputField(desc="数字に直してから連結")
class Pred(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.Predict(VariousProc)
def forward(self, num1, num2):
return self.prog(num1=num1, num2=num2)
calc = Pred()
print(calc(num1='じゅうさん',num2='6'))
出力の中身はなんだかよくわかんない文言が入っているが、アウトプット自体は惜しいところまで言っているっぽい。
多段推論にしてみる
入力の取り扱いはそれっぽくできていそうなので、あとは整える作業を後段に構えてあげたい。普通ならここで出てきた出力を、手直し用プロンプトと一緒に再度LLMに入れてみたくなるところだ。しかしDSPyはCoT(Chain Of Thought)を行うためのクラスが準備されており、それも宣言的にそういう処理を置いてあげるだけで良い。プロンプト自体は内部的に良しなにやる。
class CoT(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.ChainOfThought(VariousProc)
def forward(self, num1, num2):
return self.prog(num1=num1,num2=num2)
thinking_calc = CoT()
response = thinking_calc(num1='じゅうさん', num2='6')
print(response)
答えにたどり着いているうえに、reasoningの内容も入力を妥当に変換して取り扱っているのが分かる。
まとめ
ここまでで、DSPyのコードを見てきた。もしもOpenAIのAPIやLangChainを使ったことがある人からすると随分お手軽にLLMを取りまわせそうに見える。ただ、細かいプロンプトのデザインはどうやればよいのだ?という感想は否めないし、LangGraphみたいに状態に応じて処理を分岐させたかったりする場合はどうするんだ?みたいなのはこれから調べる。
しかし、たぶんプロンプトを人間が設計したり処理の具体的な手続きを作りこんだりすることはDSPyのスコープとしては不適切なのかもしれない。文字通り宣言的なフレームワークなのであれば、どういう結果が出るかだけが書かれており、具体的な手続きはプログラムに任されているべきである。
そのために、実際には細かい内部のプロンプトは「パラメータ」、シグネチャで定まる入力と出力は「教師データ」として、LLMの動作を評価し訓練を行うprompt tuningがフレームワークに備わっている。
それは、次回以降に続きを書くかもしれないし、冒頭のDataBricksさん共催の
をのぞいてみるのがよい。
Views: 0