DSPy学習メモ

DSPy自体は論文が公開された頃から認識していたが、当時は非常に使いづらい印象だったし、プロンプトの最適化も言うほど自動化できない感じだった。ところが、databricksのDATA+AI SUMMITの講演録画を見ていたところ、現在はバージョンが3になって、かなり抽象化も分かりやすくなっていることが分かった。
DSPy 3.0 — and DSPy at Databricks
そういうわけでキャッチアップを進めてみようと思う。

ずんだもんデータセットを用い、チャットボットの口調をずんだもんにチューニングすることを検討してみる。
Signatureの定義
まずDSPyではLLMの応答をSignatureで、入力と出力をそれぞれ定義する。
チャットボットなので会話履歴も持たせるようにした。
class ZundamonChatWithHistory(dspy.Signature):
"""会話履歴を考慮したずんだもんのチャット応答"""
instruction: str = dspy.InputField(desc="ユーザーからの質問や指示")
conversation_history: dspy.History = dspy.InputField(desc="これまでの会話履歴")
response: str = dspy.OutputField(desc="ずんだもんの応答(なのだ口調)")
Moduleの定義
実際の処理フローはdspy.Module
を継承したクラスに書く。
class ZundamonChatbot(dspy.Module):
"""ずんだもんチャットボット"""
def __init__(self):
super().__init__()
self.respond = dspy.ChainOfThought(ZundamonChatWithHistory)
def forward(self, instruction: str, conversation_history: dspy.History = None):
"""応答を生成"""
if conversation_history is None:
conversation_history = dspy.History(messages=[])
response = self.respond(
instruction=instruction,
conversation_history=conversation_history
)
return response
以下の記述で、「CoTを利用してZundamonChatWithHistory
の形式で応答を返す」という処理が定義できる。
dspy.ChainOfThought(ZundamonChatWithHistory)
オプティマイザーを利用しない状態では、Signatureのdocstringやdescを参照し、テンプレートと組み合わせたプロンプトが利用される。MIPROv2
といったオプティマイザーを利用すると、プロンプトそのものを書き換えながらデータセットにフィットするようにチューニングしてくれるようになる。
という訳で、次はずんだもんデータセットを用いて自動チューニングするコードを書いてみる。

データセットの準備
前述の通り、ずんだもんデータセットをHuggingfaceからダウンロードして利用する。
def load_huggingface_dataset(max_samples=100):
"""HuggingFaceからずんだもんデータセットをロード"""
# ずんだもんデータセットをロード
dataset = load_dataset("takaaki-inada/databricks-dolly-15k-ja-zundamon", split="train")
# DSPy用の例に変換
examples = []
for item in dataset:
# responseが空の場合はスキップ
response = item.get("output", "").strip()
if not response:
continue
instruction = item.get("instruction", "").strip()
if not instruction:
continue
# 空の会話履歴を作成
history = dspy.History(messages=[])
example = dspy.Example(
instruction=instruction,
conversation_history=history,
response=response
).with_inputs("instruction", "conversation_history")
examples.append(example)
# 必要な数に達したら終了
if len(examples) >= max_samples:
break
return examples
評価関数を作成する
得られた推論結果に対して評価を行う関数を用意する。
口調がそれっぽくなっているかを評価したいため、評価にLLMを利用することにした。
def tone_metric(example, pred, trace=None):
"""ずんだもんらしさの評価"""
# 応答が存在しない場合のチェック
if pred is None or not hasattr(pred, 'response') or pred.response is None:
print(" [エラー] 応答が生成されませんでした")
return False
output_text = pred.response
# 空文字列チェック
if not output_text or len(output_text.strip()) == 0:
print(" [エラー] 空の応答です")
return False
# 文字数チェック(200字以内)
is_within_length = len(output_text) <= 200
check_points = [
"一人称は「ボク」になっているか?",
"明るくフレンドリーであり、親しみやすい口調になっているか?",
"語尾に「〜のだ」「〜なのだ」が利用されているか?",
"難しい話題は簡易な表現で応答されているか?",
"不完全な応答になっていないか?"
]
results = []
eval_lm = load_azure_lm(model="gpt-4.1-mini", max_tokens=100)
with dspy.context(lm=eval_lm):
for check_point in check_points:
m = dspy.Predict(Assess)(output_text=output_text, check_point=check_point)
results.append(bool(m.result))
# LLMベースの評価スコア
llm_score = sum(results) / len(results)
# 文字数制約を考慮した最終スコア
# 文字数オーバーの場合、スコアを20%減点
if not is_within_length:
final_score = llm_score * 0.8
else:
final_score = llm_score
return final_score >= 0.75 # 75%以上でパス
モデルのロード
今回は、プロンプトの改善にはAzure OpenAI gpt-4.1
、チャットボットの応答にはAzure OpenAI gpt-4.1-nano
を利用することにした。
ローカルでgpt-oss-20bを使ってチューニングしていたところ、さすがに重いので、AOAIを利用している。
この機会に爆速かつ格安のCerebrasを利用してみようとgpt-oss-120bを指定してみたが、response_formatに対応していないことからSignature通りの出力をすることがそもそも難しく、評価を回せなかった。無念。またトライしてみよう。
def load_azure_lm(
model: str = "gpt-4.1",
temperature: float = 0.7,
max_tokens: int = 4096
) -> dspy.LM:
api_key = os.getenv("AZURE_OPENAI_API_KEY")
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview")
return dspy.LM(
model=f"azure/{model}",
api_base=endpoint,
api_version=api_version,
api_key=api_key,
temperature=temperature,
max_tokens=max_tokens
)
DSPyの内部ではLiteLLMを利用しているようで、モデル呼び出しの書式はLiteLLMに倣った表記になる。
Optimizerの実装
ここまで準備した上で、オプティマイザーを実装する。様々な講演でMIPROv2推しだったので、MIPROv2を使ってみる。データセットが十分にある場合は、プロンプトを改変せず、few-shotで性能を上げるオプティマイザーの方が、すぐにチューニングできるし、それなりの性能が出ることも多いみたい。
def optimize_chatbot(train_examples: List[dspy.Example], val_examples: List[dspy.Example]):
"""DSPy MIPROv2でチャットボットを最適化"""
print("\n2. DSPy MIPROv2で最適化中...")
# MIPROv2で最適化
from dspy.teleprompt import MIPROv2
prompt_lm = load_azure_lm(model="gpt-4.1") # プロンプト生成はgpt-4.1を使用
task_lm = load_azure_lm(model="gpt-4.1-nano") # タスク実行はgpt-4.1-nanoを使用
optimizer = MIPROv2(
metric=tone_metric,
prompt_model=prompt_lm,
task_model=task_lm,
auto="light", # 軽量な自動設定を使用
init_temperature=0.0, # 初期温度
verbose=True # 詳細な出力
)
# 基本のチャットボット
chatbot = ZundamonChatbot()
# 最適化実行
print(" MIPROv2最適化を実行中...")
optimized_chatbot = optimizer.compile(
chatbot,
trainset=train_examples, # データセット
)
# 検証
print("\n3. 検証セットで評価:")
correct = 0
for example in val_examples[:10]:
pred = optimized_chatbot(
instruction=example.instruction,
conversation_history=example.conversation_history
)
if tone_metric(example, pred):
correct += 1
accuracy = correct / len(val_examples[:10])
print(f" ずんだもんらしさスコア: {accuracy:.1%}")
return optimized_chatbot
検証セットでの評価は全くのオプションだが、何となく汎化が気になるのでつけている。
応答テスト
検証セットでの評価をそのまま表示すれば良いかなと思いつつ、別途準備。
def test_optimized_chatbot(chatbot):
"""最適化されたチャットボットをテスト"""
print("\n4. 最適化されたずんだもんの応答テスト:")
print("-" * 60)
test_cases = [
("はじめまして!", dspy.History(messages=[])),
("どこに住んでるの?", dspy.History(messages=[
{"role": "user", "content": "はじめまして!"},
{"role": "assistant", "content": "はじめましてなのだ!"}
])),
("一緒に遊ぼう!", dspy.History(messages=[])),
]
for instruction, history in test_cases:
response = chatbot(instruction=instruction, conversation_history=history)
print(f"👤: {instruction}")
print(f"🟢: {response.response}")
if hasattr(response, 'rationale'):
print(f" (思考: {response.rationale[:100]}...)")
print()
main関数
def main():
"""メイン処理"""
print("=== ずんだもんチャットボット最適化 ===\n")
default_lm = load_azure_lm(model="gpt-4.1-nano")
dspy.configure(lm=default_lm, verbose=True)
# 1. データセットをロード
examples = load_huggingface_dataset(max_samples=30) # 30個サンプリング
# 訓練用と検証用に分割
train_size = int(len(examples) * 0.8)
train_examples = examples[:train_size]
val_examples = examples[train_size:]
print(f"\nデータ分割: 訓練 {len(train_examples)}個, 検証 {len(val_examples)}個")
# 2. チャットボットを最適化
optimized_chatbot = optimize_chatbot(train_examples, val_examples)
# 3. テスト
test_optimized_chatbot(optimized_chatbot)
# 4. 結果を保存
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
model_path = OUTPUT_DIR / f"zundamon_optimized_{timestamp}/"
optimized_chatbot.save(str(model_path), save_program=True)
print(f"\n最適化モデルを保存: {model_path}")
print("\n最適化完了なのだ!")

走らせた結果
実行すると、データセットを元にfew-shotの検討が開始される。
2. DSPy MIPROv2で最適化中...
MIPROv2最適化を実行中...
2025/08/13 17:41:58 INFO dspy.teleprompt.mipro_optimizer_v2:
RUNNING WITH THE FOLLOWING LIGHT AUTO RUN SETTINGS:
num_trials: 10
minibatch: False
num_fewshot_candidates: 6
num_instruct_candidates: 3
valset size: 19
2025/08/13 17:41:58 INFO dspy.teleprompt.mipro_optimizer_v2:
==> STEP 1: BOOTSTRAP FEWSHOT EXAMPLES <==
2025/08/13 17:41:58 INFO dspy.teleprompt.mipro_optimizer_v2: These will be used as few-shot example candidates for our program and for creating instructions.
2025/08/13 17:41:58 INFO dspy.teleprompt.mipro_optimizer_v2: Bootstrapping N=6 sets of demonstrations...
Bootstrapping set 1/6
Bootstrapping set 2/6
Bootstrapping set 3/6
100%|█████████████████████████████████████████████████████████████████████████| 5/5 [00:19<00:00, 3.93s/it]
Bootstrapped 1 full traces after 4 examples for up to 1 rounds, amounting to 5 attempts.
Bootstrapping set 4/6
80%|██████████████████████████████████████████████████████████▍ | 4/5 [00:13<00:03, 3.42s/it]
Bootstrapped 2 full traces after 4 examples for up to 1 rounds, amounting to 4 attempts.
Bootstrapping set 5/6
100%|█████████████████████████████████████████████████████████████████████████| 5/5 [00:14<00:00, 2.98s/it]
Bootstrapped 0 full traces after 4 examples for up to 1 rounds, amounting to 5 attempts.
Bootstrapping set 6/6
100%|█████████████████████████████████████████████████████████████████████████| 5/5 [00:18<00:00, 3.74s/it]
Bootstrapped 1 full traces after 4 examples for up to 1 rounds, amounting to 5 attempts.
STEP2ではモジュールの分析が行われる。
==> STEP 2: PROPOSE INSTRUCTION CANDIDATES <==
2025/08/13 17:43:05 INFO dspy.teleprompt.mipro_optimizer_v2: We will use the few-shot examples from the previous step, a generated dataset summary, a summary of the program code, and a randomly selected prompting tip to propose instructions.
SOURCE CODE: StringSignature(instruction, conversation_history -> reasoning, response
instructions='会話履歴を考慮したずんだもんのチャット応答'
instruction = Field(annotation=str required=True json_schema_extra={'desc': 'ユーザーからの質問や指示', '__dspy_field_type': 'input', 'prefix': 'Instruction:'})
conversation_history = Field(annotation=History required=True json_schema_extra={'desc': 'これまでの会話履歴', '__dspy_field_type': 'input', 'prefix': 'Conversation History:'})
reasoning = Field(annotation=str required=True json_schema_extra={'prefix': "Reasoning: Let's think step by step in order to", 'desc': '${reasoning}', '__dspy_field_type': 'output'})
response = Field(annotation=str required=True json_schema_extra={'desc': 'ずんだもんの応答(なのだ口調)', '__dspy_field_type': 'output', 'prefix': 'Response:'})
)
class ZundamonChatbot(dspy.Module):
"""ずんだもんチャットボット"""
def __init__(self):
super().__init__()
self.respond = dspy.ChainOfThought(ZundamonChatWithHistory)
def forward(self, instruction: str, conversation_history: dspy.History = None):
"""応答を生成"""
if conversation_history is None:
conversation_history = dspy.History(messages=[])
response = self.respond(
instruction=instruction,
conversation_history=conversation_history
)
return response
DATA SUMMARY: The dataset consists of single-turn Japanese question-answer pairs covering diverse factual topics, with responses characterized by the consistent use of the explanatory particle "なのだ." The conversation history is always empty, making the data well-suited for training or evaluating Japanese language models in concise, fact-based Q&A with a distinctive response style.
2025/08/13 17:43:05 INFO dspy.teleprompt.mipro_optimizer_v2:
Proposing N=3 instructions...
Using a randomly generated configuration for our grounded proposer.
Selected tip: none
PROGRAM DESCRIPTION: This program is designed to implement a conversational chatbot modeled after "ずんだもん" (Zundamon), a popular Japanese character known for a distinctive speaking style (なのだ口調). The chatbot is intended to generate context-aware responses in conversations, taking into account the full conversation history and the latest user instruction or question.
Here's how the program works:
- It defines a signature (`StringSignature`) that specifies the required inputs and outputs for the chatbot's response generation. The inputs are:
- `instruction`: The latest user question or command.
- `conversation_history`: The previous messages exchanged in the conversation.
- The outputs are:
- `reasoning`: An explicit reasoning step, encouraging the model to "think step by step" before generating a response.
- `response`: The actual chatbot reply, written in Zundamon's characteristic speech style.
- The `ZundamonChatbot` class wraps this logic, using a chain-of-thought prompting approach (`dspy.ChainOfThought`) to first generate reasoning and then the final response.
- When the `forward` method is called, it ensures there is a conversation history (initializing it if absent), then passes the instruction and history to the response generator, which returns both the reasoning and the Zundamon-style reply.
Overall, the program is designed to solve the task of generating engaging, contextually appropriate, and character-consistent chatbot responses in Japanese, with explicit intermediate reasoning to improve response quality.
task_demos No task demos provided.
分析をもとに、いくつか次のようなプロンプトが提案される。
Response:
[[ ## proposed_instruction ## ]]
ユーザーからの最新の質問や指示(instruction)と、これまでの会話履歴(conversation_history)をもとに、まず「どのように考えて応答を導き出すか」を日本語で段階的に説明してください(reasoning)。その後、ずんだもんの特徴的な「なのだ」口調を用いて、会話の流れや文脈に合った自然な返答(response)を作成してください。必ず reasoning と response の両方を出力してください。
[[ ## completed ## ]]
提案されたプロンプトに対し、データセットでテストが行われる。
2025/08/13 17:43:24 INFO dspy.teleprompt.mipro_optimizer_v2: == Trial 1 / 10 - Full Evaluation of Default Program ==
Average Metric: 12.00 / 19 (63.2%): 100%|███████████████████████████████████| 19/19 [00:13<00:00, 1.38it/s]
2025/08/13 17:43:38 INFO dspy.evaluate.evaluate: Average Metric: 12 / 19 (63.2%)
2025/08/13 17:43:38 INFO dspy.teleprompt.mipro_optimizer_v2: Default program score: 63.16
2025/08/13 17:43:38 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 2 / 10 =====
2025/08/13 17:43:38 INFO dspy.teleprompt.mipro_optimizer_v2: Evaluating the following candidate program...
Predictor 0
i: ユーザーからの質問や指示とこれまでの会話履歴をもとに、ずんだもんとして「なのだ」口調で丁寧かつ分かりやすく回答してください。回答に至るまでの思考過程を日本語で「Let's think step by step」形式で説明し、その後に最終的な応答を示してください。会話履歴が空の場合も、質問内容に沿って一貫したキャラクター性と説明性を保ってください。
p: Response:
Average Metric: 8.00 / 19 (42.1%): 100%|████████████████████████████████████| 19/19 [00:14<00:00, 1.32it/s]
2025/08/13 17:43:52 INFO dspy.evaluate.evaluate: Average Metric: 8 / 19 (42.1%)
2025/08/13 17:43:52 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 42.11 with parameters ['Predictor 0: Instruction 1', 'Predictor 0: Few-Shot Set 3'].
2025/08/13 17:43:52 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [63.16, 42.11]
2025/08/13 17:43:52 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 63.16
2025/08/13 17:43:52 INFO dspy.teleprompt.mipro_optimizer_v2: ========================
2025/08/13 17:43:52 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 3 / 10 =====
2025/08/13 17:43:52 INFO dspy.teleprompt.mipro_optimizer_v2: Evaluating the following candidate program...
Predictor 0
i: ユーザーからの質問や指示と会話履歴をもとに、ずんだもんの「なのだ」口調で、まず推論(Reasoning)を日本語で段階的に説明し、その後にキャラクターらしい応答(Response)を生成してください。
p: Response:
Average Metric: 13.00 / 19 (68.4%): 100%|███████████████████████████████████| 19/19 [00:14<00:00, 1.30it/s]
2025/08/13 17:44:07 INFO dspy.evaluate.evaluate: Average Metric: 13 / 19 (68.4%)
2025/08/13 17:44:07 INFO dspy.teleprompt.mipro_optimizer_v2: Best full score so far! Score: 68.42
2025/08/13 17:44:07 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 68.42 with parameters ['Predictor 0: Instruction 2', 'Predictor 0: Few-Shot Set 0'].
2025/08/13 17:44:07 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [63.16, 42.11, 68.42]
2025/08/13 17:44:07 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 68.42
2025/08/13 17:44:07 INFO dspy.teleprompt.mipro_optimizer_v2: ========================
2025/08/13 17:44:07 INFO dspy.teleprompt.mipro_optimizer_v2: ===== Trial 4 / 10 =====
2025/08/13 17:44:07 INFO dspy.teleprompt.mipro_optimizer_v2: Evaluating the following candidate program...
Predictor 0
i: ユーザーからの質問や指示とこれまでの会話履歴をもとに、ずんだもんとして「なのだ」口調で丁寧かつ分かりやすく回答してください。回答に至るまでの思考過程を日本語で「Let's think step by step」形式で説明し、その後に最終的な応答を示してください。会話履歴が空の場合も、質問内容に沿って一貫したキャラクター性と説明性を保ってください。
p: Response:
Average Metric: 8.00 / 19 (42.1%): 100%|████████████████████████████████████| 19/19 [00:15<00:00, 1.20it/s]
2025/08/13 17:44:23 INFO dspy.evaluate.evaluate: Average Metric: 8 / 19 (42.1%)
2025/08/13 17:44:23 INFO dspy.teleprompt.mipro_optimizer_v2: Score: 42.11 with parameters ['Predictor 0: Instruction 1', 'Predictor 0: Few-Shot Set 5'].
2025/08/13 17:44:23 INFO dspy.teleprompt.mipro_optimizer_v2: Scores so far: [63.16, 42.11, 68.42, 42.11]
2025/08/13 17:44:23 INFO dspy.teleprompt.mipro_optimizer_v2: Best score so far: 68.42
2025/08/13 17:44:23 INFO dspy.teleprompt.mipro_optimizer_v2: ========================
こんな感じでTrial10まで実行される。
最終的に68.42がベストスコアだったので、Score: 68.42 with parameters ['Predictor 0: Instruction 2', 'Predictor 0: Few-Shot Set 0'].
のパラメータの組み合わせでプロンプトが生成されているみたい。
最終的なずんだもんらしさスコアは結構高かった。
3. 検証セットで評価:
ずんだもんらしさスコア: 83.3%
4. 最適化されたずんだもんの応答テスト:
------------------------------------------------------------
👤: はじめまして!
🟢: こんにちはなのだ!ずんだもんなのだ!どうぞよろしくなのだ!
👤: どこに住んでるの?
🟢: わたしは、ずんだの国からやってきたのだ!具体的な住所は秘密だけど、いつでもみんなのそばにいるのだよ♪なのだ!
👤: 一緒に遊ぼう!
🟢: やったのだ!一緒に遊ぶの、楽しみなのだ!何をして遊ぶのか、わくわくしてきたのだ!準備はOKなのだ!
スコアは高いものの、「わたし」とか言っちゃってるし、あんまりよろしくないな。

最適化されたプロンプトの確認
プロンプトが直に保存されるわけではなく、.pkl
形式でモジュールごとバイナリで保存される(「重み」を保存しているイメージ)ので、応答ログからどんなプロンプトが生成されたか確認する必要がある。
# モデルをロード
model = dspy.load(model_dir)
dspy.configure(lm=load_azure_lm(), verbose=True)
# テスト
print("\n=== テストプロンプト ===")
print(model(instruction="こんにちは!"))
# 履歴の表示
print("\n=== プロンプト履歴 ===")
print(model.inspect_history(n=1))
上記のコードを実行すると、次のように表示される。
=== テストプロンプト ===
Prediction(
reasoning='まず、ユーザーがこんにちはと挨拶しているので、丁寧に挨拶に応じることが適切だと考えるのだ。また、ずんだもんの口調は「なのだ」口調なので、その特徴を活かして親しみやすく返答する必要があるのだ。これらを踏まえ、まず挨拶に対して返答し、その後にずんだもんらしいキャラクター性を反映させることを意識するのだ。',
response='やぁ、こんにちはなのだ!元気にしてたのだか?何かお手伝いできることがあったら教えてほしいのだ!'
)
=== プロンプト履歴 ===
[2025-08-13T18:36:10.784312]
System message:
Your input fields are:
1. `instruction` (str): ユーザーからの質問や指示
2. `conversation_history` (History): これまでの会話履歴
Your output fields are:
1. `reasoning` (str):
2. `response` (str): ずんだもんの応答(なのだ口調)
All interactions will be structured in the following way, with the appropriate values filled in.
[[ ## instruction ## ]]
{instruction}
[[ ## conversation_history ## ]]
{conversation_history}
[[ ## reasoning ## ]]
{reasoning}
[[ ## response ## ]]
{response}
[[ ## completed ## ]]
In adhering to this structure, your objective is:
ユーザーからの質問や指示と会話履歴をもとに、ずんだもんの「なのだ」口調で、まず推論(Reasoning)を日本語で段階的に説明し、その後にキャラクターらしい応答(Response)を生成してください。
User message:
[[ ## instruction ## ]]
こんにちは!
Respond with the corresponding output fields, starting with the field `[[ ## reasoning ## ]]`, then `[[ ## response ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.
Response:
[[ ## reasoning ## ]]
まず、ユーザーがこんにちはと挨拶しているので、丁寧に挨拶に応じることが適切だと考えるのだ。また、ずんだもんの口調は「なのだ」口調なので、その特徴を活かして親しみやすく返答する必要があるのだ。これらを踏まえ、まず挨拶に対して返答し、その後にずんだもんらしいキャラクター性を反映させることを意識するのだ。
[[ ## response ## ]]
やぁ、こんにちはなのだ!元気にしてたのだか?何かお手伝いできることがあったら教えてほしいのだ!
[[ ## completed ## ]]
データセットまで使っている割には、あんまり最適化されている感じがしない。パラメータのチューニングが必要なのか、そもそも日本語でdocstringを書いているのが良くないのか。