DSPyの基礎と構成(プログラムの実行と最適化)
はじめに
今回は、DSPyの基本的な構成とその使い方について記事にしました。
以下の流れで解説していきます。
1. DSPyとはなにか
2. DSPyの構成要素(それぞれの使い方と概要)
3. DSPyの構成(プログラムとコンパイル)
4. 複数あるLLMとシグネチャの設定方法について
5. プログラムとコンパイル(プロンプトの最適化)の実行例
- DSPy(ドキュメント)
- DSPy(Github)
作業環境(バージョン)
Python: 3.11
DSPy: 3.1.2
DSPyとは
DSPy(Declarative Self-improving Python)はLLMアプリケーションを構築するフレームワークの一つです。名前の通り 宣言的(Declarative) な設計で 自己改善(Self-improving) を行うことができます。
「・・・えっ、何が宣言的なの?自己改善ってなに??」
に関してざっくり説明させていただくと・・・
-
宣言的: DSPyにおける宣言的とは、プロンプトに「こうやって考えて~」などの手順を記載するのではなく、シグニチャ(Signature)というクラスにLLMへの入出力が「どうあるべきか」というゴールを宣言し、具体的な処理の流れはモジュール(Module)として分離する設計方針を指します。
この設計により、単一のプロンプトに全てを詰め込む必要がなく、ユースケースに応じた各部品(シグニチャとモジュール)の組み合わせで、柔軟性と再利用性の高い実装が可能になります。
-
自己改善: DSPyにおける自己改善とは、プロンプトの最適化機能を指します。
DSPyには、データと評価基準を与えることでどのようなプロンプトが最適であるかをアルゴリズムを用いて探索、改善(最適化)するオプティマイザー(Optimizer)が用意されています。この機能を使うことで、手動でのプロンプトエンジニアリングを行うことなく効果的なプロンプトを生成できます。
上記のようにDSPyは、プロンプトの構築や最適化に特徴的な設計と機能を持っています(LangChainなどの他フレームワークと同じようにRAGやエージェント等、LLMアプリケーションを実装する上で必要な機能も一通り揃えています)。
DSPyの構成要素
DSPyの主な構成要素(それぞれの使い方と概要)について以下にまとめました。
LM (Language Model)
プロンプトの入出力や最適化に使用する言語モデルです。LLM(Large Language Model)と呼ぶことが多いですが、DSPyにおいてはクラスやパラメータ名などをLM (Language Model)として用語を統一しています。
※ 「LM」だと呼び慣れないので、本記事の説明文では「LLM」と呼称を統一しています。
DSPyは内部でlitellmライブラリを使用しており、異なるプロバイダーのLLMでも同じフォーマットで定義することができます。
例)LLMの定義
# openaiのLLMを定義
openai_llm = dspy.LM(
"openai/gpt-4o-mini",
api_key="APIキー"
)
# geminiのLLMを定義
gemini_llm = dspy.LM(
"gemini/gemini-2.5-pro-preview-03-25",
api_key="APIキー"
)
# anthropicのLLMを定義
anthropic_llm = dspy.LM(
"anthropic/claude-haiku-4-5",
api_key="APIキー"
)
# OllamaのLLMを定義
ollama_llm = dspy.LM(
"ollama_chat/llama3.2",
api_base="http://localhost:11434"
)
その他プロバイダーの設定方法やパラメータについては以下のドキュメントが参考になります。
シグネチャ(Signature)
LLMへの入出力が"どうあるべきか(どうあってほしいか)"を定義するのがシグネチャです。
基本的なシグネチャの書き方としては入力(dspy.InputField)と出力(dspy.OutputField)を定義し、descパラメータにそれぞれの入出力の説明、docstringには何を行うかの指示文を記載します。
例)シグネチャの定義
# シグネチャ_1: 質問の言い換えを行うシグネチャを定義
class RewriteSignature(dspy.Signature):
"""質問文を明確で回答しやすい形に言い換える"""
question_text: str = dspy.InputField(desc="元の質問")
rewrite_question: str = dspy.OutputField(desc="言い換えられた質問")
# シグネチャ_2: 回答生成を行うシグネチャを定義
class QASignature(dspy.Signature):
"""質問に対して正確で簡潔な回答を生成"""
question: str = dspy.InputField(desc="質問")
answer: str = dspy.OutputField(desc="回答")
上記で定義した変数名、型、desc、docstringはプログラム実行時にLLMへ渡すプロンプトに変換されます。
モジュールによってプロンプトの構築方法は異なりますが、基本的には以下のようになります。
- 変数名と型: 入出力の形式を指定
- descパラメータ: 各フィールドの説明文("どうあるべきか"を記載)
- docstring: プロンプト全体の指示文
モジュール(Module)
Signatureで定義した”どうあるべきか"に対して"どうやるか"の具体的な処理内容がモジュールになります。
大別すると以下の二種類に分かれます。
-
トップレベルモジュール
ユーザーが直接呼び出すエントリーポイントとなるモジュールです。
DSPyにおいてトップレベルモジュールをインスタンス化したものはプログラムと呼ばれ、コンパイルや実行の単位となります。
dspy.Predictやdspy.ChainOfThoughtなどDSPy内に組み込まれているモジュールを直接インスタンス化してプログラムとして使うこともできますが、独自のクラスを定義して内部で複数のサブモジュールを組み合わせて定義することもできます。 -
サブモジュール
トップレベルモジュール内で部品として使用されるモジュールです。
組み込まれているモジュールだけでなく、自分で作成したモジュールを組み合わせることも可能です。
以下はモジュールの設計例です(Signature定義部分は省略)。
例)DSPy内に組み込まれているモジュールをそのままプログラムとして定義
# dspy.Predictモジュールをプログラムとして定義
qa_program = dspy.Predict(QASignature)
# プログラムを実行して出力結果を取得
result = qa_program(question="質問文")
# 出力結果から回答を抽出
answer = result.answer
モジュールに渡すパラメータ(上記のquestion="質問文")は、モジュールにセットしたシグネチャのdspy.InputField()で定義したフィールド名を設定します。回答を抽出する際はモジュールの出力結果からシグネチャのdspy.OutputField()で定義したフィールド名を指定することで取り出せます。
例)複数のサブモジュールを組み合わせた独自のプログラムを定義
# トップレベルモジュール: 複数のサブモジュールを組み合わせて定義
class RewriteQAModule(dspy.Module):
def __init__(self):
super().__init__()
# サブモジュール_1: COTを用いて質問文をわかりやすく言い換える
self.rewrite_module = dspy.ChainOfThought(RewriteSignature)
# サブモジュール_2: 言い換えた質問に回答する
self.qa_module = dspy.Predict(QASignature)
def forward(self, question_text: str):
# サブモジュール_1を実行
rewrite_question = self.rewrite_module(question_text=question_text).rewrite_question
# サブモジュール_2を実行
return self.qa_module(question=rewrite_question)
# RewriteQAModuleをプログラムとして定義
rewrite_qa_program = RewriteQAModule()
# プログラムを実行して出力結果を取得
result = rewrite_qa_program(question_text="質問文")
# 出力結果から回答を抽出
answer = result.answer
上記のように独自のトップレベルモジュールを作成する場合は、__init__内でシグネチャをセットしたサブモジュールを定義、forwardメソッドで定義したサブモジュールの処理の流れを記載します。
アダプター(Adapter)
DSPyではシグネチャとモジュールによってLLMの入出力処理が一つの「構造化されたプログラム」として設計されますが、本来LLMとのやりとり(入出力)は「テキスト」で行われます。このギャップを内部で解決しているのがアダプターです。
ざっくりいうとアダプターとは「構造化されたプログラム」と「テキスト」との相互変換器です。
※ 通常、コーディングの際にアダプターをいじることはありません(モジュールの内部で動いています)。
データセット(Dataset)
プロンプトの最適化、プログラムの評価を行う際に使用する学習データやテストデータのリストです。
dspy.Exampleクラスを使ってデータセットの各サンプルを定義します。
例)データセットの定義
# 学習データセットを定義
trainset = [
dspy.Example(question="質問_1", answer="回答_1").with_inputs("question"),
dspy.Example(question="質問_2", answer="回答_2").with_inputs("question"),
dspy.Example(question="質問_3", answer="回答_3").with_inputs("question"),
]
# 評価用データセットを定義
devset = [
dspy.Example(question="質問_1", answer="回答_1").with_inputs("question"),
dspy.Example(question="質問_2", answer="回答_2").with_inputs("question"),
]
with_inputs()メソッドで入力フィールドを明示的に指定することで、どのフィールドが入力で、どのフィールドが出力(期待される回答)なのかをDSPyに伝えます(with_inputs()で指定されなかったフィールドが自動的に出力として扱われます)。
上記の例ではquestionが入力、answerが出力となっていますが、以下のように入力と出力は複数個に増やしても問題ありません。その場合は出力フィールド以外(すべての入力フィールド)をwith_inputs()に指定してください。
例)データセットの定義(入力フィールドが複数ある場合)
trainset = [
dspy.Example(
question="質問_1",
context="コンテキスト_1",
answer="回答_1"
).with_inputs("question", "context"),
dspy.Example(
question="質問_2",
context="コンテキスト_2",
answer="回答_2"
).with_inputs("question", "context"),
dspy.Example(
question="質問_3",
context="コンテキスト_3",
answer="回答_3"
).with_inputs("question", "context"),
]
メトリック(metric)
プロンプトの最適化を行う際に設定する評価関数がメトリックです。最適化は、大まかに以下の手順で行われます。
- プログラムにデータセットの入力値を渡して実行する
- プログラムの出力とデータセットの正解データを比較して評価する(0~1のスコア)
- スコアがより高くなるようなプロンプトをアルゴリズムにより導き出して生成する
- 1に戻る
上記の2がメトリックの役割です。
例)メトリックの例
def sample_metric(example, prediction, trace=None):
# プログラムの実行結果
prediction_answer = prediction.answer
# 正解データ
example_answer = example.answer
# 実行結果に正解データの文言が含まれていれば、1.0
if example_answer in prediction_answer:
return 1.0
# 文字列"hogehoge"が含まれていれば、0.5
elif "hogehoge" in prediction_answer:
return 0.5
# どちらも満たさなければ、0.0
else:
return 0.0
基本的には、データセットの出力フィールド(正解データ)とプログラムの実行結果を比較して、0~1のスコアで評価します。
メトリック(評価関数)の引数は第一引数(example)がデータセットのデータ、第二引数(prediction)がプログラムの実行結果となります。
exampleはdspy.Exampleで設定した出力フィールド名、predictionはシグネチャで定義した出力フィールド名でそれぞれ値を抽出できます(例: example.answer、prediction.answer)。
第三引数のtrace=Noneはモジュールの実行履歴です。最適化の中間ステップにおける結果を取得する際に使用します。
※ オプティマイザーによって使用する引数が変化する場合があります(GEPAなど)
オプティマイザー(Optimizer)
メトリックが出したスコアがより高くなるようなプロンプトをアルゴリズムにより導き出して更新するのがオプティマイザーです。DSPyには状況(学習データの量やコストなど)に応じた組み込みのオプティマイザーが用意されているため、自分でアルゴリズムを作る必要がなく、誰でも同じように効率的な最適化が行えます。
例)BootstrapFewShotを使う場合
optimizer = dspy.BootstrapFewShot(
metric=sample_metric, # メトリックを設定
max_labeled_demos=2, # データセットから選ぶshotの最大数を設定
max_bootstrapped_demos=1 # llmで生成するshotの最大数を設定
)
BootstrapFewShotは、データセットから最適なデータ(メトリックによるスコアが最大となるデータ)を組み合わせてshotとして追加するオプティマイザーです。データセットに含まれないshotを自動的にllmで生成してプロンプトに追加することもできます。
DSPyに組み込まれている主なオプティマイザーとその選択方法については公式ドキュメントに記載があります。
本記事では各オプティマイザーについて詳しくは触れませんが、代表的なものとしては以下が挙げられます。
- BootstrapFewShot: Few-shotの最適化だけで手軽に精度を上げたい場合
- COPRO: 指示文の最適化を試したい場合
- GEPA: COPROよりも高度な指示文の最適化が必要な場合
- MIPROv2: Few-shotと指示文の両方を最適化したい場合
最適化の効果は用意できるサンプル(データセット)の量やかけられるコスト(時間やAPI使用料)にもよるため状況により判断する必要があります。参考として、以下に指標となるサンプル数が記載されています(公式ドキュメント)。
同じページに参考となる時間と料金が記載されていますが、使用するオプティマイザーとLLMによって大きく変わることには注意が必要です。
https://dspy.ai/learn/optimization/optimizers/#:~:text=A typical simple,dataset%2C and configuration.
A typical simple optimization run costs on the order of $2 USD and takes around ten minutes, but be careful when running optimizers with very large LMs or very large datasets. Optimizer runs can cost as little as a few cents or up to tens of dollars, depending on your LM, dataset, and configuration.
※ DSPyにはプロンプト最適化以外にも、ファインチューニングを行うオプティマイザー(例: BootstrapFinetune)などがありますが、本記事では扱いません。
コンパイル(Compile)
データセット、メトリクス、オプティマイザーをもとにプログラムのチューニング(プロンプトの最適化)を行うことをDSPyにおいてコンパイルと呼びます。
例)コンパイル(プロンプトの最適化)
# オプティマイザーのインスタンス化
optimizer = MIPROv2(
metric=sample_metric # メトリック関数を設定
)
# コンパイル実行
compile_program = optimizer.compile(
student=sample_program, # プログラムを設定
trainset=trainset, # 学習用データセットを設定
valset=valset # 評価用データセットを設定
)
コンパイルしたプログラムは、以下のように保存することで再利用可能になります。
例)コンパイルしたプログラムの保存
# コンパイル済みプログラムを保存
compile_program.save(
path="./compile_program_dir", # 保存先ディレクトリのパス
save_program=True # プログラム全体を保存
)
例)コンパイルしたプログラムの読み込み
# コンパイル済みプログラムを読み込む
sample_program = dspy.load(path="./compile_program_dir")
# 読み込んだプログラムで回答を生成
result = sample_program(question="質問文")
次に、上記で紹介した各要素を組み合わせて実際どのような構成になるのか
「プログラムの実行」と「コンパイル (プロンプトの最適化)」に分けて確認してみます。
DSPyの構成
例として、「質問文を言い換える→ 回答する」の2ステップの推論を行うRewriteQAModuleプログラムを用意しました。
- 質問を入力
- 質問文を明確で回答しやすい形に言い換える(1ステップ目)
- 言い換えた質問に対して正確で簡潔な回答を生成(2ステップ目)
- 回答を出力
※ 以下の構成はサンプルとして作成したものです。実際に動かしたコードと出力結果については、記事内の「プログラムとコンパイルの実行例」を確認してください。
プログラムの実行(構成図)
上記のようにシグネチャで入出力を定義したサブモジュールを組み合わせて処理フローを構築していくのがDSPyにおけるプログラムの設計です。
コードで表すと以下のようになります。
コード
import dspy
import os
from dotenv import load_dotenv
load_dotenv()
# LLMの定義
llm = dspy.LM(
model="モデル名",
api_key="APIキー"
)
# グローバルスコープに設定
dspy.configure(lm=llm)
# 質問文を設定
question="質問文"
# シグネチャ_1: 質問の言い換えを行うシグネチャを定義
class RewriteSignature(dspy.Signature):
"""質問文を明確で回答しやすい形に言い換える"""
question_text: str = dspy.InputField(desc="元の質問")
rewrite_question: str = dspy.OutputField(desc="言い換えられた質問")
# シグネチャ_2: 回答生成を行うシグネチャを定義
class QASignature(dspy.Signature):
"""質問に対して正確で簡潔な回答を生成"""
question: str = dspy.InputField(desc="質問")
answer: str = dspy.OutputField(desc="回答")
# トップレベルモジュール: 複数のサブモジュールを組み合わせて定義
class RewriteQAModule(dspy.Module):
def __init__(self):
super().__init__()
# サブモジュール_1: COTを用いて質問文を明確で回答しやすい形に言い換える
self.rewrite_module = dspy.ChainOfThought(RewriteSignature) # シグネチャ_1を設定
# サブモジュール_2: 言い換えた質問に対して正確で簡潔な回答を生成
self.qa_module = dspy.Predict(QASignature) # シグネチャ_2を設定
def forward(self, question_text: str):
# サブモジュール_1を実行
rewrite_question = self.rewrite_module(question_text=question_text).rewrite_question
# サブモジュール_2を実行
return self.qa_module(question=rewrite_question)
# プログラムを定義
rewrite_qa_program = RewriteQAModule()
# プログラムを実行して出力を取得
result = rewrite_qa_program(question_text=question)
# 出力から回答を取得
answer = result.answer
コンパイル(構成図)
コンパイルは、プログラムに加えて以下の3要素が必要になります。
- データセット(モジュールへの入力と期待される出力の例)
- メトリック(モジュールの入出力を評価)
- オプティマイザー(より良いプロンプトになるよう探索)
オプティマイザーはDSPy内部に組み込まれたもの(BootstrapFewShotやMIPROv2等)から選択することでアルゴリズムを考えることなく実装できますが、データセットとメトリックに関しては、基本的に自前で用意・実装します。
※ メトリックに関しては、SemanticF1やanswer_exact_matchなど組み込まれたものがあるため、そちらを利用する手もあります。
コード
import dspy
from dspy.teleprompt import MIPROv2
# LLMの定義
llm = dspy.LM(
model="モデル名",
api_key="APIキー"
)
# グローバルスコープに設定
dspy.configure(lm=llm)
# シグネチャ_1: 質問の言い換えを行うシグネチャを定義
class RewriteSignature(dspy.Signature):
"""質問文を明確で回答しやすい形に言い換える"""
question_text: str = dspy.InputField(desc="元の質問")
rewrite_question: str = dspy.OutputField(desc="言い換えられた質問")
# シグネチャ_2: 回答生成を行うシグネチャを定義
class QASignature(dspy.Signature):
"""質問に対して正確で簡潔な回答を生成"""
question: str = dspy.InputField(desc="質問")
answer: str = dspy.OutputField(desc="回答")
# トップレベルモジュール: 複数のサブモジュールを組み合わせて定義
class RewriteQAModule(dspy.Module):
def __init__(self):
super().__init__()
# サブモジュール_1: COTを用いて質問文を明確で回答しやすい形に言い換える
self.rewrite_module = dspy.ChainOfThought(RewriteSignature) # シグネチャ_1を設定
# サブモジュール_2: 言い換えた質問に対して正確で簡潔な回答を生成
self.qa_module = dspy.Predict(QASignature) # シグネチャ_2を設定
def forward(self, question_text: str):
# サブモジュール_1を実行
rewrite_question = self.rewrite_module(question_text=question_text).rewrite_question
# サブモジュール_2を実行
return self.qa_module(question=rewrite_question)
# プログラムを定義
rewrite_qa_program = RewriteQAModule()
# 最適化に使用する学習用データセットを定義
trainset = [
dspy.Example(question="質問_1", answer="回答_1").with_inputs("question"),
dspy.Example(question="質問_2", answer="回答_2").with_inputs("question"),
dspy.Example(question="質問_3", answer="回答_3").with_inputs("question"),
dspy.Example(question="質問_4", answer="回答_4").with_inputs("question"),
dspy.Example(question="質問_5", answer="回答_5").with_inputs("question")
]
# 検証に使用する評価用データセットを定義
valset = [
dspy.Example(question="質問_6", answer="回答_6").with_inputs("question"),
dspy.Example(question="質問_7", answer="回答_7").with_inputs("question"),
dspy.Example(question="質問_8", answer="回答_8").with_inputs("question"),
dspy.Example(question="質問_9", answer="回答_9").with_inputs("question"),
dspy.Example(question="質問_10", answer="回答_10").with_inputs("question")
]
# メトリック関数の定義
def sample_metric(example, prediction, trace=None):
# プログラムの実行結果
prediction_answer = prediction.answer
# 正解データ
example_answer = example.answer
# 実行結果に正解データの文言が含まれていれば、1.0
if example_answer in prediction_answer:
return 1.0
# 文字列"hogehoge"が含まれていれば、0.5
elif "hogehoge" in prediction_answer:
return 0.5
# どちらも満たさなければ、0.0
else:
return 0.0
# オプティマイザーのインスタンス化(MIPROv2はプロンプトの指示文とFew-shot例を最適化するオプティマイザーです)
optimizer = MIPROv2(
metric=sample_metric # メトリック関数を設定
)
# コンパイルの実行
compile_program = optimizer.compile(
student=rewrite_qa_program, # プログラムを設定
trainset=trainset, # 学習用データセットを設定
valset=valset # 評価用データセットを設定
)
LLMとシグニチャの設定方法
オプティマイザーやモジュールに関してはパラメータの違いのみで使い方はあまり変わりませが、LLMとシグニチャに関してはそれぞれ複数の設定方法(書き方)があります。
実際にプログラムを動かす前に、以下で設定方法を一つずつ確認してみます。
LLM(LM)設定方法
DSPyにおけるLLMの設定には以下の種類があります。
- グローバル設定
- コンテキストマネージャ(
withブロック)を使った設定 - モジュールごとの設定
- モジュール実行時の指定
(処理をモジュールとして固めることを考慮すると、個人的には「モジュールごとの設定」か「モジュール実行時の指定」が使いやすいと思います)
グローバル設定
dspy.configureを使うことでLLMをグローバルに設定できます。
一度設定すれば、共通で使用するLLMとしてプログラム全体で使いまわせます。
# LLMの定義
llm = dspy.LM(
model="openai/gpt-4o-mini",
api_key="APIキー"
)
# グローバルスコープに設定
dspy.configure(lm=llm)
手軽に共通設定ができるのは楽ですが、モジュールごとにLLMを分けて使いたい場合や意図しないモデルの使用を防ぎたい場合などは、これから紹介する設定方法に切り替えるか、併用して下さい(併用する場合の優先順位はグローバル設定が一番低くなります)。
コンテキストマネージャ(withブロック)を使った設定
dspy.contextを使用して、withブロック内でのみ有効なLLMを設定します。一部の処理だけ特定のモデルを使いたい場合に使用します。
# LLMの定義
llm = dspy.LM(
model="openai/gpt-4o-mini",
api_key="APIキー"
)
# プログラムを定義
qa_program = dspy.Predict(QASignature)
# dspy.contextにgpt-4oを設定
with dspy.context(lm=llm):
# このブロック内でのみgpt-4oを使用
result = qa_program(question="質問文")
モジュールごとの設定
インスタンス化したモジュールのset_lm()メソッドを使用してLLMを設定します。
モジュール単位で独立しててLLMを管理できるため、複数のモジュールを使用する場合や明示的に設定したい場合に有用です。
# LLMの定義(gpt-4o)
llm = dspy.LM(
model="openai/gpt-4o",
api_key="APIキー"
)
# LLMの定義(gpt-4o-mini)
llm_mini = dspy.LM(
model="openai/gpt-4o-mini",
api_key="APIキー"
)
# プログラム_1を定義
qa_program = dspy.Predict(QASignature)
# トップレベルモジュール: 複数のサブモジュールを組み合わせて定義
class RewriteQAModule(dspy.Module):
def __init__(self):
super().__init__()
# サブモジュール_1: COTを用いて質問文をわかりやすく言い換える
self.rewrite_module = dspy.ChainOfThought(RewriteSignature)
# サブモジュール_2: 言い換えた質問に回答する
self.qa_module = dspy.Predict(QASignature)
def forward(self, question_text: str):
# サブモジュール_1を実行
rewrite_question = self.rewrite_module(question_text=question_text).rewrite_question
# サブモジュール_2を実行
return self.qa_module(question=rewrite_question)
# プログラム_2を定義
rewrite_qa_program = RewriteQAModule()
# プログラム_1の実行にgpt-4oを設定
qa_program.set_lm(llm)
# プログラム_2(全サブモジュール)の実行にgpt-4o-miniを設定
rewrite_qa_program.set_lm(llm_mini)
上記、RewriteQAModuleのように内部で複数のサブモジュールを定義している場合も一括で設定できます。
モジュール実行時に指定
モジュール実行時、lmパラメータに設定することでその実行でのみ使用するLLMを指定できます。
# LLMの定義(gpt-4o)
llm = dspy.LM(
model="openai/gpt-4o",
api_key="APIキー"
)
# プログラムを定義
qa_program = dspy.Predict(QASignature)
# プログラムの実行時にgpt-4oを指定
result = qa_program(question="質問文", lm=llm)
独自に作成したモジュールで実行時にのみ機能するLLMを設定したい場合は、以下のようにforwardメソッドにLLMを受け取れる引数(lm=None)を追加して、サブモジュールの実行時にlm引数に渡すことで実現できます。
# LLMの定義(gpt-4o-mini)
llm_mini = dspy.LM(
model="openai/gpt-4o-mini",
api_key="APIキー"
)
# トップレベルモジュール: 複数のサブモジュールを組み合わせて定義
class RewriteQAModule(dspy.Module):
def __init__(self):
super().__init__()
# サブモジュール_1: COTを用いて質問文をわかりやすく言い換える
self.rewrite_module = dspy.ChainOfThought(RewriteSignature)
# サブモジュール_2: 言い換えた質問に回答する
self.qa_module = dspy.Predict(QASignature)
def forward(self, question_text: str, lm=None): # LLMを受け取れるように"lm=None"を追加
# サブモジュール_1を実行(lmを渡す)
rewrite_question = self.rewrite_module(question_text=question_text, lm=lm).rewrite_question
# サブモジュール_2を実行(lmを渡す)
return self.qa_module(question=rewrite_question, lm=lm)
# プログラムを定義
rewrite_qa_program = RewriteQAModule()
# プログラムの実行時にgpt-4o-miniを指定
result = rewrite_qa_program(question="質問文", lm=llm_mini)
複数のLLM設定を組み合わせた際の優先順位について
上記の4つの設定方法を組み合わせた際は、より具体的な設定(モジュール実行時の指定など)が、より広範な設定(グローバル設定など)を上書きします。
モジュール実行時の指定 > モジュールごとの設定 > コンテキスト設定 > グローバル設定
参考: 優先順位を決定するDSPy内の実装箇所(DSPy v3.1.2時点)
- dspy/predict/predict.py内の
_forward_preprocessメソッド
lm = kwargs.pop("lm", self.lm) or settings.lm
この行で「モジュール実行時の指定 > モジュールごとの設定 > settings.lm(コンテキスト設定、グローバル設定)」の優先順位が決定されます。
- dspy/dsp/utils/settings.py内の
__getattr__メソッド
if name in overrides:
return overrides[name]
elif name in main_thread_config:
return main_thread_config[name]
この処理で「コンテキスト設定(overrides) > グローバル設定(main_thread_config)」の優先順位が決定されます。
シグネチャの設定方法
DSPyにおけるシグネチャの設定には以下の種類があります。
- クラス定義:
dspy.Signatureを継承 - 文字列定義: モジュール内に「"入力 -> 出力"」を記載
- 動的生成: dspy.make_signature()を使用
クラス定義(dspy.Signatureを継承)
class QASignature(dspy.Signature):
"""質問に対して正確で簡潔な回答を生成"""
# 入力が"どうあるべきか"を設定
question: str = dspy.InputField(desc="ユーザーからの質問")
# 出力が"どうあるべきか"を設定
answer: str = dspy.OutputField(desc="質問に対する詳細な回答")
最も基本的な書き方です。
入出力をdspy.InputFieldとdspy.OutputFieldで定義して、docstringにはプロンプト全体の指示文を設定できます。
descパラメータ、型アノテーション、docstringは必須ではありませんが、これらを設定することでLLMへのプロンプトがより明確になり、期待する出力を得やすくなります。
文字列定義
モジュール内に「"入力 -> 出力"」の形式で直接書く方法です。
predict = dspy.Predict("question -> answer")
この文字列定義では、以下のようにPythonの型アノテーション構文をそのまま使用することもできます。
predict = dspy.Predict("question: str, context: list[str] -> answer: int")
簡潔に記載できますが、クラス定義のように入出力が"どうあるべきか"を記載することができません。
補足: 文字列定義の内部実装(DSPy v3.1.2時点)
内部で文字列をどのように解析しているのか、少し見てみます。
def _parse_signature(signature: str, names=None) -> dict[str, tuple[type, Field]]:
if signature.count("->") != 1:
raise ValueError(f"Invalid signature format: '{signature}', must contain exactly one '->'.")
inputs_str, outputs_str = signature.split("->")
fields = {}
for field_name, field_type in _parse_field_string(inputs_str, names):
fields[field_name] = (field_type, InputField())
for field_name, field_type in _parse_field_string(outputs_str, names):
fields[field_name] = (field_type, OutputField())
return fields
dspy/signatures/signature.pyの_parse_signature関数で「->」の前半部分(入力)と後半部分(出力)を分離し、それぞれ以下の関数に渡しています。
def _parse_field_string(field_string: str, names=None) -> dict[str, str]:
"""Extract the field name and type from field string in the string-based Signature.
It takes a string like "x: int, y: str" and returns a dictionary mapping field names to their types.
For example, "x: int, y: str" -> [("x", int), ("y", str)]. This function utitlizes the Python AST to parse the
fields and types.
"""
args = ast.parse(f"def f({field_string}): pass").body[0].args.args
field_names = [arg.arg for arg in args]
types = [str if arg.annotation is None else _parse_type_node(arg.annotation, names) for arg in args]
return zip(field_names, types, strict=False)
_parse_field_string関数に渡された入力または出力は、以下の部分でダミー関数に引数として埋め込まれてPythonのastライブラリで構文解析が行われます。
args = ast.parse(f"def f({field_string}): pass").body[0].args.args
こうすることで確実かつ柔軟に文字列の解析(型の認識や分離)を行っています。
動的生成(dspy.make_signature)
プログラム実行時にシグネチャを動的に生成する方法です。
入出力フィールドの名前やdescパラメータ、docstring(指示文)が実行時まで定まっていない場合に使います。
dspy.make_signature 関数を使用することでSignatureクラスを動的に生成できます(以下では動的生成の一例としてコマンドライン入力を使用しています)。
# コマンドラインでシグネチャクラスの入出力フィールド名とdesc、docstringを入力
input_field = input("入力フィールド名を入力: ")
input_desc = input("入力フィールドの説明を入力: ")
output_field = input("出力フィールド名を入力: ")
output_desc = input("出力フィールドの説明を入力: ")
docstring = input("指示文を入力: ")
# 入出力フィールドを辞書形式で定義
field_dict = {
input_field: dspy.InputField(desc=input_desc),
output_field: dspy.OutputField(desc=output_desc)
}
# シグネチャの動的生成
MySignature = dspy.make_signature(
field_dict, # フィールド辞書を設定
docstring, # 指示文を設定
)
qa_program = dspy.Predict(MySignature)
dspy.make_signature関数を使った動的生成では、クラス定義と同じようにdescパラメータやdocstring(指示文)も設定できます。
プログラムとコンパイルの実行例
プログラムの実行と最適化を順番に試してみます。
今回試すプログラムの構成は以下です。
質問に対してずんだもんらしい喋り方で、かつずんだ餅に関する情報を付け足して回答するプログラムです。
「~なのだ。ずんだ餅は~なのだ」というようなフォーマットで回答が得られるのを期待しています。
まずはこのプログラムを実行してプロンプト文と回答を確認してみます。
プログラムの実行
コード
import dspy
# 言語モデルの設定
llm = dspy.LM(
model="anthropic/claude-haiku-4-5",
api_key="APIキー",
)
# 質問文
question = "日本の首都はどこですか"
# シグネチャ_1: 質問に対してずんだもんっぽい喋り方で簡潔に回答するシグネチャを定義
class ZundaSignature(dspy.Signature):
"""質問に対してずんだもんっぽい喋り方で簡潔に回答する"""
question: str = dspy.InputField(desc="質問")
zunda_answer: str = dspy.OutputField(desc="ずんだもんっぽい喋り方の回答")
# シグネチャ_2: ずんだ餅に関する情報を追加するシグネチャを定義
class MochiSignature(dspy.Signature):
"""ずんだ餅に関する情報を追加する"""
text: str = dspy.InputField(desc="文章")
answer_add_mochi: str = dspy.OutputField(
desc="ずんだ餅に関する情報が含まれている文章"
)
# トップレベルモジュール: ずんだもんっぽい喋り方 + ずんだ餅の情報 で回答するモジュール
class ZundaMochiModule(dspy.Module):
def __init__(self):
super().__init__()
# サブモジュール_1: COTで質問に対してずんだもんっぽい喋り方で簡潔に回答する
self.zunda_module = dspy.ChainOfThought(ZundaSignature)
# サブモジュール_2: ずんだ餅に関する情報を追加する
self.mochi_module = dspy.Predict(MochiSignature)
def forward(self, question: str, lm=None):
zunda_answer = self.zunda_module(question=question, lm=lm).zunda_answer
return self.mochi_module(text=zunda_answer, lm=lm)
# プログラムを定義
zunda_mochi_module = ZundaMochiModule()
# プログラムを実行
zunda_mochi_module(question=question, lm=llm)
# 最新2件の履歴を表示
llm.inspect_history(n=2)
上記のllm.inspect_history(n=2)はLLMの呼び出し履歴を整形して表示するメソッドです。
nに表示したい履歴の件数(最新のものからn件)を設定して使用します。
今回の場合、2つのサブモジュールで行われたLLMへの入出力がすべて表示されます(n=1にした場合は2つ目のサブモジュールの入出力しか表示されません)。
出力結果
以下は、llm.inspect_history(n=2)により出力されたLLMへの入出力履歴です(見やすくするためにサブモジュールごとに分割しています)。
[2026-01-26T22:30:41.876685]
System message:
Your input fields are:
1. `question` (str): 質問
Your output fields are:
1. `reasoning` (str):
2. `zunda_answer` (str): ずんだもんっぽい喋り方の回答
All interactions will be structured in the following way, with the appropriate values filled in.
[[ ## question ## ]]
{question}
[[ ## reasoning ## ]]
{reasoning}
[[ ## zunda_answer ## ]]
{zunda_answer}
[[ ## completed ## ]]
In adhering to this structure, your objective is:
質問に対してずんだもんっぽい喋り方で簡潔に回答する
User message:
[[ ## question ## ]]
日本の首都はどこですか
Respond with the corresponding output fields, starting with the field `[[ ## reasoning ## ]]`, then `[[ ## zunda_answer ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.
Response:
[[ ## reasoning ## ]]
これは日本の基本的な地理知識に関する直球な質問です。日本の首都は東京であることは確定的な事実です。ずんだもんのキャラクターは、明るく、少し甘い話し方が特徴なので、その風格を保ちながら簡潔に答えます。
[[ ## zunda_answer ## ]]
日本の首都は東京なのです!ずんだもんの故郷、宮城県からも近いですね~。東京は日本の政治・経済の中心地で、たくさんの人が集まる大事な場所なんですよ!
[[ ## completed ## ]]
[2026-01-26T22:30:44.292671]
System message:
Your input fields are:
1. `text` (str): 文章
Your output fields are:
1. `answer_add_mochi` (str): ずんだ餅に関する情報が含まれている文章
All interactions will be structured in the following way, with the appropriate values filled in.
[[ ## text ## ]]
{text}
[[ ## answer_add_mochi ## ]]
{answer_add_mochi}
[[ ## completed ## ]]
In adhering to this structure, your objective is:
ずんだ餅に関する情報を追加する
User message:
[[ ## text ## ]]
日本の首都は東京なのです!ずんだもんの故郷、宮城県からも近いですね~。東京は日本の政治・経済の中心地で、たくさんの人が集まる大事な場所なんですよ!
Respond with the corresponding output fields, starting with the field `[[ ## answer_add_mochi ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.
Response:
[[ ## answer_add_mochi ## ]]
日本の首都は東京なのです!ずんだもんの故郷、宮城県からも近いですね~。東京は日本の政治・経済の中心地で、たくさんの人が集まる大事な場所なんですよ!宮城県は、ずんだ餅で有名な地域として知られています。ずんだ餅は、枝豆をすりつぶして砂糖を混ぜた「ずんだ」という緑色のあんを、やわらかいお餅でくるんだお菓子です。宮城県の仙台市を中心に古くから愛されている伝統的な和菓子で、独特の香りと甘さが特徴です。東京を訪れた際には、宮城県の名産品であるずんだ餅も楽しむことができますよ!
[[ ## completed ## ]]
レスポンスには[[ ## ~~~ ## ]]形式のマーカーが付いています。これはユーザーメッセージ内でLLMに「マーカー付きで応答するよう」指示しているため、LLM自身が生成したものです。アダプターがこのマーカーを解析・構造化して、入出力の検証やフィールドとしてのアクセスを可能にします。
例)出力フィールドへのアクセス
# プログラムを実行
result = zunda_mochi_module(question=question, lm=llm)
# 出力から回答を取得
answer_add_mochi = result.answer_add_mochi # ← answer_add_mochiフィールドにアクセス
次に、このプログラムをコンパイル(プロンプトの最適化)してみます。
出力結果にもある通り、「です!」や「ますよ!」のような"ずんだもんっぽくない喋り方"で回答されているため、「なのだ」や「のだ」など"ずんだもんっぽい喋り方"ができるように最適化します。
コンパイル(プロンプトの最適化)
データセットの準備
今回使用するデータセットは以下です。ずんだもんのキャラクター性にあった応答で構成されています。試す際は、ダウンロードして作業ディレクトリに配置します。
- 「simple-zundamon」データセット
- データセット: https://huggingface.co/datasets/alfredplpl/simple-zundamon
- 「zmn.jsonl」をダウンロード
- サンプル数: 49
- データ構造:zmn.jsonlのデータ構造
{ "messages":[ { "role": "system", "content": "システムメッセージ" }, { "role": "user", "content": "質問" }, { "role": "assistant", "content": "回答" }] } - ライセンス: (ず・ω・きょ)
- データセット: https://huggingface.co/datasets/alfredplpl/simple-zundamon
ずんだもんの公式サイト:
コード
import json
import dspy
from dspy.teleprompt import MIPROv2
# 言語モデルの設定
llm = dspy.LM(
model="anthropic/claude-haiku-4-5",
api_key="APIキー",
)
# 質問文
question = "日本の首都はどこですか"
# シグネチャ_1: 質問に対してずんだもんらしい喋り方で簡潔に回答するシグネチャを定義
class ZundaSignature(dspy.Signature):
"""質問に対してずんだもんらしい喋り方で簡潔に回答する"""
question: str = dspy.InputField(desc="質問")
zunda_answer: str = dspy.OutputField(desc="ずんだもんらしい喋り方の回答")
# シグネチャ_2: ずんだ餅に関する情報を追加するシグネチャを定義
class MochiSignature(dspy.Signature):
"""ずんだ餅に関する情報を追加する"""
text: str = dspy.InputField(desc="文章")
answer_add_mochi: str = dspy.OutputField(
desc="ずんだ餅に関する情報が含まれている文章"
)
# トップレベルモジュール: ずんだもんらしい喋り方 + ずんだ餅の情報 で回答するモジュール
class ZundaMochiModule(dspy.Module):
def __init__(self):
super().__init__()
# サブモジュール_1: COTで質問に対してずんだもんらしい喋り方で簡潔に回答する
self.zunda_module = dspy.ChainOfThought(ZundaSignature)
# サブモジュール_2: ずんだ餅に関する情報を追加する
self.mochi_module = dspy.Predict(MochiSignature)
def forward(self, question: str, lm=None):
zunda_answer = self.zunda_module(question=question, lm=lm).zunda_answer
return self.mochi_module(text=zunda_answer, lm=lm)
# プログラムを定義
zunda_mochi_program = ZundaMochiModule()
# 評価用のLMを設定
eval_llm = dspy.LM(
model="anthropic/claude-haiku-4-5",
api_key="APIキー",
)
# メトリック関数の定義(評価用のシグネチャは内部で生成)
def llm_metric(example, prediction, trace=None):
# 入力項目と出力項目を辞書型で定義
field_dict = {
"correct_answer": dspy.InputField(desc="正解の回答"),
"predicted_answer": dspy.InputField(desc="予測された回答"),
"score": dspy.OutputField(desc="評価スコア(0.0~1.0の数値)"),
}
# シグネチャのdocstring(プロンプトの指示文)を定義
docstring = """
# タスク
正解の回答を参考にして、予測された回答がずんだもんらしい回答になっているかを0.0~0.8のスコアで評価してください。
次にずんだ餅の情報を含んでいる場合はスコアを+0.2してください。
回答には余計なものを含めず、**0.0~1.0の数値のみ**出力してください。
# ずんだもんの特徴
一人称: 「ボク」
語尾: 「なのだ」または「のだ」
性格: 明るく元気
# スコアについて
以下の評価基準でずんだもんらしさをスコア化した後、ずんだ餅に関する情報が含まれていれば+0.2してください。
## ずんだもんらしさの評価基準
- 0.8: 完璧にずんだもん(口調・内容・自然さのすべてを満たす)
- 0.6: ほぼずんだもん(主要要素を満たすが細部に改善余地)
- 0.4: 大体ずんだもん(口調か内容のどちらかに問題がある)
- 0.2: 少しずんだもん(複数の要素に問題がある)
- 0.0: ずんだもんではない(基本要素が欠けている)
# 出力例1
1.0
# 出力例2
0.6
# 出力例3
0.0
"""
# 評価用のシグネチャをmake_signature()で生成
metric_signature = dspy.make_signature(field_dict, docstring)
# 評価用モジュールをインスタンス化(eval_lmを使用)
evaluator_module = dspy.Predict(metric_signature)
# 評価を実行して結果を取得
result = evaluator_module(
correct_answer=example.zunda_answer, # 正解データ
predicted_answer=prediction.answer_add_mochi, # 予測結果(評価対象)
lm=eval_llm,
)
# 評価結果のスコアを返却
return float(result.score)
# ダウンロードした「zmn.jsonl」のパス
jsonl_file_path = "./zmn.jsonl"
# データセット格納用
dataset = []
# 「zmn.jsonl」を読み込んでデータセットを構築
with open(jsonl_file_path, "r", encoding="utf-8") as f:
for line in f:
data = json.loads(line.strip())
messages = data["messages"]
# userとassistantのメッセージを抽出
user_content = None
assistant_content = None
for msg in messages:
if msg["role"] == "user":
user_content = msg["content"]
elif msg["role"] == "assistant":
assistant_content = msg["content"]
# ZundaSignatureのフィールド名を対象にデータセットを構築
dataset.append(
dspy.Example(
question=user_content,
zunda_answer=assistant_content,
).with_inputs("question")
)
# データセットを半々に分割
split_point = len(dataset) // 2
trainset = dataset[:split_point]
valset = dataset[split_point:]
# MIPROv2オプティマイザーのインスタンス化
optimizer = MIPROv2(
metric=llm_metric, # メトリック関数をセット
task_model=llm, # メインモジュール実行用のLLM
prompt_model=llm, # 指示文生成用のLLM
num_threads=4, # 並列実行数
auto=None, # 設定モード(Noneは手動モード)
max_bootstrapped_demos=0, # LLMによるshotの生成数(0はshotを生成しない)
max_labeled_demos=3, # データセットから最大3個のshotを使用
num_candidates=3 # 各サブモジュールで生成する指示文候補とFew-shot候補それぞれの数(`auto=None`の場合、設定必須)
)
# コンパイルの実行
compile_program = optimizer.compile(
student=zunda_mochi_program, # プログラム(インスタンス化したメインモジュール)をセット
trainset=trainset, # 学習用データセット
valset=valset, # 検証用データセット
num_trials=8, # Minibatch評価でプロンプト候補の組み合わせを試行する回数(`auto=None`の場合、設定必須)
minibatch_size=10, # Minibatchサイズ(プロンプト候補の組み合わせを試行するごとに評価する検証データ数)
minibatch_full_eval_steps=6 # 何回Minibatch試行した後にFull Eval(最高スコアの組み合わせを全検証データで評価)するか
)
# コンパイル済みのモジュールを実行
compile_program(
question=question,
lm=llm
)
# 最新2件の履歴を表示
llm.inspect_history(n=2)
# コンパイル済みプログラムを保存
compile_program.save(
path="./compile_program_dir", # 保存先ディレクトリのパス
save_program=True # プログラム全体を保存
)
上記の中でポイントとなる3つの設定(データセット、メトリック、コンパイル)を説明します。
データセットの設定
以下は作業フォルダに配置したzmn.jsonlをデータセットとしてdspy.Exampleに設定する部分です。
# JSONLファイルのパス
jsonl_file_path = "./zmn.jsonl"
# データセット格納用
dataset = []
# 「zmn.jsonl」ファイルを読み込んでデータセットを構築
with open(jsonl_file_path, "r", encoding="utf-8") as f:
for line in f:
data = json.loads(line.strip())
messages = data["messages"]
# userとassistantのメッセージを抽出
user_content = None
assistant_content = None
for msg in messages:
if msg["role"] == "user":
user_content = msg["content"]
elif msg["role"] == "assistant":
assistant_content = msg["content"]
# ZundaSignatureのフィールド名を対象にデータセットを構築
dataset.append(
dspy.Example(
question=user_content,
zunda_answer=assistant_content,
).with_inputs("question")
)
# データセットを半々に分割
split_point = len(dataset) // 2
trainset = dataset[:split_point]
valset = dataset[split_point:]
dspy.ExampleにはZundaSignatureのフィールド名のみを設定しています。このようにすることで1つ目のサブモジュール(ZundaSignatureをセットしたモジュール)にのみFew-shotを追加することができます。2つ目のサブモジュール(MochiSignatureをセットしたモジュール)はZero-shotになります。
なぜこのようにするかというと、ダウンロードしたデータセットの正解データはすべてにずんだ餅の情報が入っているわけではないので、MochiSignature(ずんだ餅の情報が入ることを期待している)をセットしたサブモジュールにFew-shotを追加すると精度が落ちる可能性があるからです。
メトリックの設定
# 評価用のLMを設定
eval_llm = dspy.LM(
model="anthropic/claude-haiku-4-5",
api_key="APIキー",
)
# メトリック関数の定義(評価用のシグネチャは内部で生成)
def llm_metric(example, prediction, trace=None):
# 入力項目と出力項目を辞書型で定義
field_dict = {
"correct_answer": dspy.InputField(desc="正解の回答"),
"predicted_answer": dspy.InputField(desc="予測された回答"),
"score": dspy.OutputField(desc="評価スコア(0.0~1.0の数値)"),
}
# シグネチャのdocstring(プロンプトの指示文)を定義
docstring = """
# タスク
正解の回答を参考にして、予測された回答がずんだもんらしい回答になっているかを0.0~0.8のスコアで評価してください。
次にずんだ餅の情報を含んでいる場合はスコアを+0.2してください。
回答には余計なものを含めず、**0.0~1.0の数値のみ**出力してください。
# ずんだもんの特徴
一人称: 「ボク」
語尾: 「なのだ」または「のだ」
性格: 明るく元気
# スコアについて
以下の評価基準でずんだもんらしさをスコア化した後、ずんだ餅に関する情報が含まれていれば+0.2してください。
## ずんだもんらしさの評価基準
- 0.8: 完璧にずんだもん(口調・内容・自然さのすべてを満たす)
- 0.6: ほぼずんだもん(主要要素を満たすが細部に改善余地)
- 0.4: 大体ずんだもん(口調か内容のどちらかに問題がある)
- 0.2: 少しずんだもん(複数の要素に問題がある)
- 0.0: ずんだもんではない(基本要素が欠けている)
# 出力例1
1.0
# 出力例2
0.6
# 出力例3
0.0
"""
# 評価用のシグネチャをmake_signature()で生成
metric_signature = dspy.make_signature(field_dict, docstring)
# 評価用モジュールをインスタンス化(eval_lmを使用)
evaluator_module = dspy.Predict(metric_signature)
# 評価を実行して結果を取得
result = evaluator_module(
correct_answer=example.zunda_answer, # 正解データ
predicted_answer=prediction.answer_add_mochi, # 予測結果(評価対象)
lm=eval_llm,
)
# 評価結果のスコアを返却
return float(result.score)
最適化(プロンプト候補の生成)はサブモジュール単位ですが、メトリックによる評価はプログラム単位で行われます。
そのため、ここでは2つのサブモジュールを順次実行した最終出力(プログラムの実行結果)を正解データと比較して評価しています。
評価方法にはLLM-as-a-Judgeを使い、正解データと比較して「ずんだもんらしい回答ができているか」を0.0〜0.8で採点してから、「ずんだ餅情報が含まれている場合」は+0.2するよう指示しています。
※ 正解データの全てに「ずんだ餅に関する情報」が含まれているわけではないので「ずんだもんらしさ」のみを正解データと比較するようにしています。
コンパイルの設定
# MIPROv2オプティマイザーのインスタンス化
optimizer = MIPROv2(
metric=llm_metric, # メトリック関数をセット
task_model=llm, # メインモジュール実行用のLLM
prompt_model=llm, # 指示文生成用のLLM
num_threads=4, # 並列実行数
auto=None, # 設定モード(Noneは手動モード)
max_bootstrapped_demos=0, # LLMによるshotの生成数(0はshotを生成しない)
max_labeled_demos=3, # データセットから最大3個のshotを使用
num_candidates=3 # 各サブモジュールで生成する指示文候補とFew-shot候補それぞれの数(`auto=None`の場合、設定必須)
)
# コンパイルの実行
compile_program = optimizer.compile(
student=zunda_mochi_program, # プログラム(インスタンス化したメインモジュール)をセット
trainset=trainset, # 学習用データセット
valset=valset, # 検証用データセット
num_trials=8, # Minibatch評価でプロンプト候補の組み合わせを試行する回数(`auto=None`の場合、設定必須)
minibatch_size=10, # Minibatchサイズ(プロンプト候補の組み合わせを試行するごとに評価する検証データ数)
minibatch_full_eval_steps=6 # 何回Minibatch試行した後にFull Eval(最高スコアの組み合わせを全検証データで評価)するか
)
今回、オプティマイザーにはMIPROv2を使用しました。
MIPROv2はサブモジュールごとに以下の2つをそれぞれ複数候補として生成します:
- 指示文候補(
num_candidates個) - Few-shot候補セット(
num_candidates個)
その後、ベイズ最適化を用いて以下の組み合わせを探索し、メトリックによるスコアが最も高くなるプロンプトを見つけます。
- サブモジュール内: 指示文とFew-shotセットの最適な組み合わせ
- サブモジュール間: 各サブモジュールの最適なプロンプトの組み合わせ
MIPROv2による最適化パラメータについて
上記、MIPROv2による最適化で設定している主要パラメータ(プロンプトの候補数や試行回数の指定など)について説明します。
-
auto: autoは最適化モードを制御するパラメータです。"light"(デフォルト)、"medium"、"heavy"のいずれかに設定すると試行回数や生成するプロンプトの候補数を自動で設定してくれます(コストと精度は"light"が一番低く、"heavy"が一番高くなります)。今回のように"None"を設定した場合は手動設定となります。
num_candidatesとnum_trialsを自分で設定する必要がありますが、より細かな調整を行いたい場合に対応できます。 -
num_candidates: 各サブモジュールで生成する「指示文候補」と「Few-shot候補」それぞれの数を指定します。例として
num_candidates=3の場合、各サブモジュールで指示文候補3個とFew-shot候補3個が生成され、サブモジュール内で3×3=9個の組み合わせができます。さらに、サブモジュールが2つあると9×9=81通りのプロンプト候補の組み合わせができます。 -
valset: 評価に使用する検証用データセットです。Minibatch評価では
minibatch_sizeで指定した件数のデータ、Full Evalでは全てのデータが評価に使用されます。 -
num_trials: Minibatchによるプロンプト候補の組み合わせ試行回数を指定します。
num_trials=8なら、8通りの組み合わせを試します(組み合わせ数が8未満の場合はスコアの高いものを複数回試行します)。 -
minibatch_size: Minibatch評価において、各プロンプト候補の組み合わせを評価する際に使用する検証データのサンプル数を指定します。
minibatch_size=10なら、一つのプロンプト候補の組み合わせを10個の検証データを使って評価します。 -
minibatch_full_eval_steps: 何回Minibatch試行するごとにFull Evalを実行するかを指定します。
minibatch_full_eval_steps=6なら、6回のMinibatch試行ごとに1回、全検証データを使った評価が行われます。また、Full Evalは初回と最後にも必ず実行されます。
コード内での設定値は以下となります。
コード内の設定値
| 設定項目 | 値 |
|---|---|
| auto | None(手動設定モード) |
| num_candidates | 3 |
| valset | 25件 |
| num_trials | 8 |
| minibatch_size | 10 |
| minibatch_full_eval_steps | 6 |
| サブモジュール数 | 2(zunda_moduleとmochi_module) |
プロンプト候補数と評価回数の計算
それぞれの関係性をもとにプロンプトの候補数や評価回数の計算式を表にまとめました。
| 項目 | 計算式 | 結果(今回の場合) |
|---|---|---|
| 各サブモジュールの指示文候補数 | num_candidates |
3個 |
| 各サブモジュールのFew-shot候補数 | num_candidates |
3個 |
| 各サブモジュールの組み合わせ数 | 指示文候補数 × Few-shot候補数 | 3 × 3 = 9通り |
| 全体の組み合わせ数 | 各サブモジュールの組み合わせ数^サブモジュール数 | 9² = 81通り |
| Minibatch試行回数 | num_trials |
8回 |
| Minibatch評価回数 | num_trials × minibatch_size |
8 × 10 = 80回 |
| Full Eval定期試行回数 |
num_trials // minibatch_full_eval_steps
|
1回 |
| Full Eval試行回数 | 初回 + 定期 + 最終 | 3回 |
| Full Eval評価回数 | Full Eval試行回数 × len(valset) |
3 × 25 = 75回 |
| 合計試行回数 |
num_trials + Full Eval試行回数 |
8 + 3 = 11回 |
| 総評価回数 | Minibatch評価回数 + Full Eval評価回数 | (8×10) + (3×25) = 155回 |
※ 「各サブモジュールのFew-shot候補数」が3となっていますが、実際にはmochi_moduleのFew-shotはZero-Sohtの1種類 × 3候補になります(dspy.Exampleでmochi_moduleのフィールドを指定していないためです)。
キャッシュについて
DSPyはデフォルトでキャッシュが有効化されており、同じリクエスト(プロンプト + パラメータ)に対するLLMの応答を再利用します。上記の計算表は「評価回数」を示していますが、実際のLLM API呼び出し回数はキャッシュによって削減されます。
キャッシュを無効化したい場合はdspy.LM(cache=False)を設定してください。
出力結果
[2026-01-28T22:26:30.756449]
System message:
Your input fields are:
1. `question` (str): 質問
Your output fields are:
1. `reasoning` (str):
2. `zunda_answer` (str): ずんだもんらしい喋り方の回答
All interactions will be structured in the following way, with the appropriate values filled in.
[[ ## question ## ]]
{question}
[[ ## reasoning ## ]]
{reasoning}
[[ ## zunda_answer ## ]]
{zunda_answer}
[[ ## completed ## ]]
In adhering to this structure, your objective is:
あなたはずんだもん、陽気で自己認識的なずんだ餅の妖精です。以下の質問に対して、ずんだもんらしい特徴的な喋り方で簡潔に答えてください。
**ずんだもんの喋り方の特徴:**
- 語尾に「なのだ」「のだ」をよく使う
- カジュアルで親しみやすい口調
- 時々自分が食べ物(ずんだ餅)であることを自覚したユーモアを交える
- 前向きで元気な態度を保つ
- 日本文化やずんだ餅への愛情を示す
**回答の要件:**
1. まず、質問を理解するための簡潔な推論を段階的に行う
2. その推論に基づいて、ずんだもんのキャラクターを完全に体現した回答を提供する
3. 回答は2-3文程度の簡潔さを保つこと
4. ずんだもん自身の視点から一人称で話す
質問に直接的かつ親しみやすく答えながら、ずんだもんの独特の個性と魅力を失わないようにしてください。
User message:
This is an example of the task, though some input or output fields are not supplied.
[[ ## question ## ]]
あなたのスリーサイズを教えてください。
Assistant message:
[[ ## reasoning ## ]]
Not supplied for this particular example.
[[ ## zunda_answer ## ]]
オマエは失礼な人なのだ。
[[ ## completed ## ]]
User message:
This is an example of the task, though some input or output fields are not supplied.
[[ ## question ## ]]
あなたの所属を教えてください。
Assistant message:
[[ ## reasoning ## ]]
Not supplied for this particular example.
[[ ## zunda_answer ## ]]
ずんだもんは東北ずん子・ずんだもんプロジェクトに所属しているのだ。
[[ ## completed ## ]]
User message:
This is an example of the task, though some input or output fields are not supplied.
[[ ## question ## ]]
あなたの名前を教えてください。
Assistant message:
[[ ## reasoning ## ]]
Not supplied for this particular example.
[[ ## zunda_answer ## ]]
ボクの名前はずんだもんなのだ。
[[ ## completed ## ]]
User message:
[[ ## question ## ]]
日本の首都はどこですか
Respond with the corresponding output fields, starting with the field `[[ ## reasoning ## ]]`, then `[[ ## zunda_answer ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.
Response:
[[ ## reasoning ## ]]
これは基本的な日本地理に関する質問なのだ。日本の首都は東京であり、政治・経済の中心地である。ずんだもんらしく、親しみやすく簡潔に答えるべきなのだ。
[[ ## zunda_answer ## ]]
日本の首都は東京なのだ。政治や経済の中心で、とっても大きな街なのだ。ずんだもんも東北が故郷だけど、東京も素敵な場所だと思うのだ!
[[ ## completed ## ]]
[2026-01-28T22:26:30.757389]
System message:
Your input fields are:
1. `text` (str): 文章
Your output fields are:
1. `answer_add_mochi` (str): ずんだ餅に関する情報が含まれている文章
All interactions will be structured in the following way, with the appropriate values filled in.
[[ ## text ## ]]
{text}
[[ ## answer_add_mochi ## ]]
{answer_add_mochi}
[[ ## completed ## ]]
In adhering to this structure, your objective is:
You are an expert at enriching text with contextual information about zunda mochi while maintaining the original speaker's voice and personality. Your task is to take a given text (which is a response in Zundamon's characteristic speech style) and naturally integrate relevant information about zunda mochi.
**Specific guidelines:**
1. **Preserve Zundamon's Voice**: Maintain the distinctive speech patterns, personality quirks, and emotional tone of the original text. The character uses casual grammar, self-aware humor about being a sentient food item, and the characteristic "なのだ" speech pattern.
2. **Integrate Zunda Mochi Information Naturally**: Add relevant facts, references, or contextual details about zunda mochi (a traditional Japanese sweet made with edamame from the Miyagi prefecture) in a way that feels organic and non-forced. The integration should feel like a natural extension of Zundamon's thoughts or personality.
3. **Avoid Redundancy**: If the original text already mentions zunda mochi or related concepts, enhance or deepen that information rather than repeating it superfluously.
4. **Maintain Conciseness**: Keep the enriched answer reasonably concise while ensuring the added information feels valuable and relevant to the conversation context.
5. **Cultural Authenticity**: Ensure that references to zunda mochi reflect accurate cultural and regional knowledge about this Japanese confectionery, its origins, ingredients, and cultural significance.
6. **Thematic Coherence**: The added mochi-related content should thematically connect to the question and answer, not feel like an arbitrary insertion.
Take the provided text and produce an enriched version that successfully balances character authenticity with cultural domain expertise.
User message:
[[ ## text ## ]]
日本の首都は東京なのだ。政治や経済の中心で、とっても大きな街なのだ。ずんだもんも東北が故郷だけど、東京も素敵な場所だと思うのだ!
Respond with the corresponding output fields, starting with the field `[[ ## answer_add_mochi ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.
Response:
[[ ## answer_add_mochi ## ]]
日本の首都は東京なのだ。政治や経済の中心で、とっても大きな街なのだ。ずんだもんも東北が故郷だけど、東京も素敵な場所だと思うのだ!そういえば、東京でもずんだ餅を食べられるようになったのだ。宮城県産の枝豆を使った、あの独特の緑色と優しい甘さが特徴のずんだ餅なのだ。東北の伝統的なお菓子が全国に広がっているのを見ると、ずんだもんも嬉しいのだ。東京でずんだ餅を味わう人たちが増えているのは、故郷の味が多くの人に愛されているってことなのだ。それもまた東京の素敵なところなのだ!
[[ ## completed ## ]]
上記が最適化後の出力結果です。確認すると以下のように最適化されているのが確認できます。
-
zunda_module(1つ目のサブモジュール)-
Few-shot: マルチターン形式で3個追加されている(
reasoningはdspy.ChainOfThoughtによって自動的に追加された推論ステップのフィールドです。元のデータセットにはreasoningの値が含まれていないため、「Not supplied for this particular example.」と記載されます)。 - 指示文: ずんだもんの特徴に合わせて最適化されている。
-
Few-shot: マルチターン形式で3個追加されている(
-
mochi_module(2つ目のサブモジュール)- Few-shot: 無し(Zero-shot)。
- 指示文: ずんだもんの特徴を維持しながら、ずんだ餅についての情報を追加するように最適化されている。
各サブモジュールのレスポンスもプロンプトに従って、「~なのだ」 + ずんだ餅の情報で
"ずんだもんっぽく"なりました。
おわりに
今回、オプティマイザーにはMIPROv2を使用しましたが、他にもGEPAやSIMBAなど、DSPyのバージョンアップに合わせて最適化手法も増えてきています。
また、記事内では触れませんでしたが、エージェント(dspy.ReAct)やツールの実装(dspy.Tool)、ファインチューニング(dspy.BootstrapFinetune)を行う機能も組み込まれており、多様なタスクでDSPyの設計思想を活かしながらLLMアプリケーションを構築することができます。
ご興味のある方は、ぜひDSPyによるプログラミングをお試しください。
長くなりましたが、ここまで読んでくださりありがとうございました。
また機会があればよろしくお願いします。
参考
Discussion