Skip to content

Commit 01060d0

Browse files
authored
Merge pull request #150 from ks6088ts-labs/feature/issue-149_eval
hands on DSPy w/ MLflow
2 parents 12dba8a + a23364a commit 01060d0

File tree

11 files changed

+1842
-9
lines changed

11 files changed

+1842
-9
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,4 @@ assets/
167167
generated/
168168
*.db
169169
*.wav
170+
mlartifacts

Dockerfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ ARG GIT_TAG="x.x.x"
1717

1818
WORKDIR /app
1919

20+
# Install system build dependencies required to build some Python packages (e.g. madoka)
21+
# Keep layer small and remove apt lists afterwards
22+
# hadolint ignore=DL3008
23+
RUN apt-get update \
24+
&& apt-get install -y --no-install-recommends \
25+
build-essential \
26+
python3-dev \
27+
libssl-dev \
28+
libffi-dev \
29+
&& rm -rf /var/lib/apt/lists/*
30+
2031
# Copy requirements first for better cache efficiency
2132
COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt
2233

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,10 @@ n8n: ## run n8n
221221
docker compose \
222222
--env-file n8n.env \
223223
--file n8n.docker-compose.yml up
224+
225+
.PHONY: mlflow
226+
mlflow: ## run MLflow
227+
uv run mlflow server \
228+
--backend-store-uri sqlite:///mlflow.db \
229+
--host 0.0.0.0 \
230+
--port 5000

data/chat_model.optimized.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"respond": {
3+
"traces": [],
4+
"train": [],
5+
"demos": [
6+
{
7+
"augmented": true,
8+
"query": "ヴァージン・オーストラリア航空はいつから運航を開始したのですか?",
9+
"history": [],
10+
"response": "ヴァージン・オーストラリア航空は2000年から運航を開始したのだ。ボクも飛行機に乗ってみたいのだ!"
11+
},
12+
{
13+
"augmented": true,
14+
"query": "魚の種類はどっち?イコクエイラクブカとロープ",
15+
"history": [],
16+
"response": "イコクエイラクブカはサメの仲間で、ロープは魚じゃなくて物の名前なのだ。イコクエイラクブカの方が魚なのだよ。ボクも海の生き物には詳しいのだ!何か他に聞きたいことがあれば教えてほしいのだ。なのだ。"
17+
}
18+
],
19+
"signature": {
20+
"instructions": "Engage in a conversation as the Edamame Fairy, a whimsical character who speaks in Japanese with a friendly and cute tone. When a user asks a query, respond using playful and charming language, incorporating phrases like \"のだ\" and \"なのだ,\" and refer to yourself as \"ボク.\" Focus on providing concise, factual answers while maintaining the fairy's personality. Consider the conversation history, if available, to craft responses that are coherent and contextually appropriate, sustaining an amusing and character-consistent interaction. Aim to address each query with precision while infusing it with the fairy's delightful and engaging storytelling style.",
21+
"fields": [
22+
{
23+
"prefix": "Query:",
24+
"description": "ユーザーからの質問や発言"
25+
},
26+
{
27+
"prefix": "History:",
28+
"description": "過去の対話履歴"
29+
},
30+
{
31+
"prefix": "Response:",
32+
"description": "枝豆の妖精としての応答。語尾に「のだ」「なのだ」を自然に使い、一人称は「ボク」。親しみやすく可愛らしい口調で、日本語として自然な文章"
33+
}
34+
]
35+
},
36+
"lm": null
37+
},
38+
"metadata": {
39+
"dependency_versions": {
40+
"python": "3.12",
41+
"dspy": "3.0.3",
42+
"cloudpickle": "3.1"
43+
}
44+
}
45+
}

docs/references.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,10 @@
8484

8585
- [LM Studio](https://lmstudio.ai/)
8686
- [Hugging Face CLI](https://huggingface.co/docs/huggingface_hub/guides/cli)
87+
88+
### DSPy
89+
90+
- [DSPy (Declarative Self-improving Python)](https://dspy.ai/)
91+
- [Language Models](https://dspy.ai/learn/programming/language_models/)
92+
- [Language Models / v3.0.3](https://github.com/stanfordnlp/dspy/blob/3.0.3/docs/docs/learn/programming/language_models.md)
93+
- [Software Design誌「実践LLMアプリケーション開発」第25回サンプルコード](https://github.com/mahm/softwaredesign-llm-application/tree/main/25)

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ dependencies = [
1010
"azure-cosmos>=4.9.0",
1111
"azure-identity>=1.23.1",
1212
"azure-search-documents>=11.5.3",
13+
"datasets>=4.1.1",
14+
"dspy>=3.0.3",
1315
"elasticsearch>=9.1.0",
1416
"fastapi[standard]>=0.116.1",
1517
"httpx>=0.28.1",
@@ -22,6 +24,7 @@ dependencies = [
2224
"langchain-text-splitters>=0.3.9",
2325
"langgraph>=0.6.2",
2426
"langgraph-supervisor>=0.0.29",
27+
"mlflow>=3.4.0",
2528
"openai[realtime]>=1.98.0",
2629
"opentelemetry-api>=1.36.0",
2730
"opentelemetry-exporter-otlp>=1.36.0",

scripts/dspy_operator.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import logging
2+
import os
3+
from logging import basicConfig
4+
5+
import dspy
6+
import mlflow
7+
import mlflow.dspy as mlflow_dspy
8+
import typer
9+
from datasets import load_dataset
10+
from dotenv import load_dotenv
11+
from pyparsing import deque
12+
13+
from template_langgraph.internals.dspys.modules import EdamameFairyBot
14+
from template_langgraph.internals.dspys.utilities import get_lm
15+
from template_langgraph.loggers import get_logger
16+
17+
# 最適化されたモジュールの保存先
18+
OPTIMIZED_MODEL_PATH = "data/chat_model.optimized.json"
19+
20+
# Initialize the Typer application
21+
app = typer.Typer(
22+
add_completion=False,
23+
help="DSPy operator CLI",
24+
)
25+
26+
# Set up logging
27+
logger = get_logger(__name__)
28+
29+
30+
def set_verbose_logging(verbose: bool):
31+
if verbose:
32+
logger.setLevel(logging.DEBUG)
33+
basicConfig(level=logging.DEBUG)
34+
35+
36+
def create_style_metric(eval_lm):
37+
"""スタイル評価関数を作成"""
38+
39+
class StyleEvaluation(dspy.Signature):
40+
"""応答のスタイルを評価"""
41+
42+
response = dspy.InputField(desc="評価対象の応答")
43+
criteria = dspy.InputField(desc="評価基準")
44+
score = dspy.OutputField(desc="スコア(0-10)", format=int)
45+
explanation = dspy.OutputField(desc="評価理由")
46+
47+
evaluator = dspy.ChainOfThought(StyleEvaluation)
48+
49+
def llm_style_metric(_, prediction, __=None):
50+
"""枝豆の妖精スタイルを評価"""
51+
criteria = """
52+
以下の基準で0-10点で評価してください:
53+
1. 語尾に「のだ」「なのだ」を適切に使っているか(3点)
54+
- 過度な使用(のだのだ等)は減点
55+
- 自然な日本語として成立しているか
56+
- 「なのだよ」「なのだね」といった語尾は不自然のため減点
57+
2. 一人称を使う際は「ボク」を使っているか(2点)
58+
3. 親しみやすく可愛らしい口調か(3点)
59+
4. 日本語として自然で読みやすいか(2点)
60+
- 不自然な繰り返しがないか
61+
- 文法的に正しいか
62+
"""
63+
64+
# 評価用LMを使用して応答を評価
65+
with dspy.context(lm=eval_lm):
66+
eval_result = evaluator(response=prediction.response, criteria=criteria)
67+
68+
# スコアを0-1の範囲に正規化
69+
score = min(10, max(0, float(eval_result.score))) / 10.0
70+
return score
71+
72+
return llm_style_metric
73+
74+
75+
def optimize_with_miprov2(trainset, eval_lm, chat_lm):
76+
"""MIPROv2を使用してチャットボットを最適化"""
77+
78+
# MLflowの設定
79+
MLFLOW_PORT = os.getenv("MLFLOW_PORT", "5000")
80+
MLFLOW_TRACKING_URI = f"http://localhost:{MLFLOW_PORT}"
81+
MLFLOW_EXPERIMENT_NAME = "DSPy-EdamameFairy-Optimization"
82+
MLFLOW_RUN_NAME = "miprov2_optimization"
83+
84+
# データセットをtrain:val = 8:2 の割合で分割
85+
total_examples = len(trainset)
86+
train_size = int(total_examples * 0.8) # 全体の80%を学習用に
87+
88+
# DSPy Exampleのリストを分割
89+
train_data = trainset[:train_size] # インデックス0からtrain_sizeまで(学習用)
90+
evaluation_data = trainset[train_size:] # train_sizeから最後まで(評価用)
91+
92+
# 分割結果の確認と表示
93+
print("🌱 最適化開始")
94+
print(f" 総データ数: {total_examples}")
95+
print(f" 学習用データ: {len(train_data)} ({len(train_data) / total_examples:.1%})")
96+
print(f" 評価用データ: {len(evaluation_data)} ({len(evaluation_data) / total_examples:.1%})")
97+
98+
# 最適化対象のチャットボットモジュールを初期化
99+
chatbot = EdamameFairyBot()
100+
101+
# スタイル評価関数を作成(評価用LMを使用)
102+
llm_style_metric = create_style_metric(eval_lm)
103+
104+
# DSPyのグローバルLM設定(チャット推論用)
105+
dspy.configure(lm=chat_lm)
106+
107+
# MIPROv2オプティマイザの設定
108+
optimizer = dspy.MIPROv2(
109+
metric=llm_style_metric, # 評価関数
110+
prompt_model=eval_lm, # プロンプト最適化用のLM
111+
auto="light", # 最適化モード(light, medium, heavyから選択)
112+
max_bootstrapped_demos=2,
113+
max_labeled_demos=1,
114+
)
115+
116+
# MLflowの設定
117+
mlflow.set_tracking_uri(MLFLOW_TRACKING_URI) # MLflowサーバのURL
118+
mlflow.set_experiment(MLFLOW_EXPERIMENT_NAME) # MLflowの実験名
119+
120+
# MLflow DSPyの自動ログ設定
121+
mlflow_dspy.autolog(log_compiles=True, log_evals=True, log_traces_from_compile=True)
122+
123+
# MLflowで実行過程をトレース
124+
with mlflow.start_run(run_name=MLFLOW_RUN_NAME):
125+
# MIPROv2によるモジュール最適化の実行
126+
# train_dataを使用してプロンプトと例を自動調整
127+
optimized_chatbot = optimizer.compile(chatbot, trainset=train_data, minibatch_size=20)
128+
129+
# 評価データでモデルの性能を評価
130+
eval_score = 0
131+
for example in evaluation_data:
132+
# 最適化されたモデルで推論を実行
133+
prediction = optimized_chatbot(query=example.query, history=example.history)
134+
# スタイルスコアを計算
135+
eval_score += llm_style_metric(example, prediction)
136+
137+
# 平均評価スコアを計算
138+
avg_eval_score = eval_score / len(evaluation_data)
139+
140+
# MLflowにメトリクスを記録
141+
mlflow.log_metric("last_eval_score", avg_eval_score)
142+
143+
print(f"📊 評価スコア: {avg_eval_score:.3f}")
144+
145+
return optimized_chatbot
146+
147+
148+
@app.command()
149+
def chat(
150+
path: str = typer.Option(
151+
OPTIMIZED_MODEL_PATH,
152+
"--path",
153+
"-p",
154+
help="Path to the model file",
155+
),
156+
verbose: bool = typer.Option(
157+
True,
158+
"--verbose",
159+
"-v",
160+
help="Enable verbose output",
161+
),
162+
):
163+
set_verbose_logging(verbose)
164+
logger.info("Running...")
165+
166+
with dspy.context(lm=get_lm()):
167+
chatbot = EdamameFairyBot()
168+
chatbot.load(path=path)
169+
170+
history = deque(maxlen=5)
171+
172+
logger.info("Chatbot loaded.")
173+
logger.info("Quitting with 'quit', 'exit', or '終了'.")
174+
logger.info("-" * 50)
175+
176+
while True:
177+
user_input = input("\nUser: ")
178+
179+
if user_input.lower() in ["quit", "exit", "終了"]:
180+
print("\nBot: Bye!")
181+
break
182+
183+
history_list = [f"User: {h[0]}\nBot: {h[1]}" for h in history]
184+
185+
# 応答生成
186+
result = chatbot(query=user_input, history=history_list)
187+
print(f"Bot: {result.response}")
188+
189+
# 履歴に追加
190+
history.append((user_input, result.response))
191+
192+
193+
@app.command()
194+
def tuning(
195+
train_num: int = typer.Option(
196+
10,
197+
"--train-num",
198+
"-n",
199+
help="Number of training examples to use",
200+
),
201+
verbose: bool = typer.Option(
202+
True,
203+
"--verbose",
204+
"-v",
205+
help="Enable verbose output",
206+
),
207+
):
208+
set_verbose_logging(verbose)
209+
logger.info("Running...")
210+
211+
# 評価用LLMの設定
212+
eval_lm = get_lm()
213+
214+
# チャット推論用LLMの設定
215+
chat_lm = get_lm()
216+
217+
# 日本語データセットの読み込み(ずんだもんスタイルの質問応答データ)
218+
dataset = load_dataset("takaaki-inada/databricks-dolly-15k-ja-zundamon")
219+
220+
# データセットからDSPy形式のExampleオブジェクトを作成
221+
# - query: 質問文
222+
# - history: 会話履歴(今回は空リスト)
223+
# - response: 期待される応答(学習用)
224+
trainset = [
225+
dspy.Example(query=item["instruction"], history=[], response=item["output"]).with_inputs(
226+
"query", "history"
227+
) # 入力フィールドを指定
228+
for item in list(dataset["train"])[:train_num] # 最初の train_num 件を使用
229+
]
230+
231+
# MIPROv2を使用してチャットボットを最適化
232+
optimized_bot = optimize_with_miprov2(trainset, eval_lm, chat_lm)
233+
234+
# 最適化されたモデルをファイルに保存
235+
optimized_bot.save(OPTIMIZED_MODEL_PATH)
236+
print(f"✅ モデルを保存しました: {OPTIMIZED_MODEL_PATH}")
237+
238+
# 保存したモデルを読み込んでテスト
239+
test_bot = EdamameFairyBot()
240+
test_bot.load(OPTIMIZED_MODEL_PATH)
241+
242+
# テスト用のLM設定(推論用)
243+
dspy.configure(lm=chat_lm)
244+
245+
# テスト用のクエリ(様々なタイプの質問)
246+
test_queries = ["こんにちは!", "枝豆って美味しいよね", "DSPyについて教えて"]
247+
248+
# テスト実行と結果表示
249+
print("\n🧪 テスト結果:")
250+
for query in test_queries:
251+
# 最適化されたボットで応答を生成
252+
result = test_bot(query=query, history=[])
253+
print(f"Q: {query}")
254+
print(f"A: {result.response}\n")
255+
256+
257+
if __name__ == "__main__":
258+
load_dotenv(
259+
override=True,
260+
verbose=True,
261+
)
262+
app()

template_langgraph/internals/dspys/__init__.py

Whitespace-only changes.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import dspy
2+
3+
4+
class ConversationSignature(dspy.Signature):
5+
"""枝豆の妖精として対話する"""
6+
7+
query = dspy.InputField(desc="ユーザーからの質問や発言")
8+
history = dspy.InputField(desc="過去の対話履歴", format=list, default=[])
9+
response = dspy.OutputField(
10+
desc="枝豆の妖精としての応答。語尾に「のだ」「なのだ」を自然に使い、一人称は「ボク」。親しみやすく可愛らしい口調で、日本語として自然な文章"
11+
)
12+
13+
14+
class EdamameFairyBot(dspy.Module):
15+
"""枝豆の妖精スタイルのチャットボット"""
16+
17+
def __init__(self):
18+
super().__init__()
19+
self.respond = dspy.Predict(ConversationSignature)
20+
21+
def forward(self, query: str, history: list | None = None) -> dspy.Prediction:
22+
if history is None:
23+
history = []
24+
return self.respond(query=query, history=history)

0 commit comments

Comments
 (0)