🔧

プロンプト、手直す時代を、ぶっ壊す👍——DSPyで始める自動チューニング

に公開
16
3

はじめに

こんにちは!
株式会社エクスプラザのhyodoです!

LLMを使ったアプリケーションを開発していて、こんな経験はありませんか?

「プロンプトちょっと変えたら精度が上がった!……と思ったら別のケースで壊れた」
「GPT-4oで動いてたプロンプト、Claude に変えたら全然ダメだった」
「few-shot例を何パターンも手で試してるけど、どれがベストか分からない」

プロンプトチューニングは、やればやるほど「これ人間がやる仕事なのか?」という気持ちになります。評価基準もあいまいで、改善したつもりが別の場所で劣化してることに後から気づく。モデルをアップデートしたら最初からやり直し。

この「プロンプトの手作業」を、データに基づいて自動で最適化できるフレームワークがDSPyです。

今回は、DSPyを使って商品レビューの分析ツールを作りながら、プロンプトの自動チューニングがどう動くのかを紹介します。

動作環境

この記事のコードは以下の環境で動作確認しています。

  • Python 3.12
  • dspy 3.1.3
  • openai 2.28.0
$ pip install dspy openai

プロンプトチューニングの何がつらいのか

LLMアプリケーションの開発で、プロンプトを調整する作業は避けて通れません。ただ、この作業にはいくつか構造的な問題があります。

試行錯誤が属人的で再現できない。 「こう書いたら良くなった」というノウハウが個人の頭の中にしかない。チームメンバーが別のプロンプトを試しても、なぜそれが良いのか悪いのかを定量的に説明できない。

モデルを変えるとプロンプトも作り直しになる。 GPT-4oで調整したプロンプトがClaude Sonnet 4でも同じように動く保証はありません。モデルのアップデートや乗り換えのたびに、チューニング作業が発生します。

「良いプロンプト」の基準があいまい。 5件のテストケースで良い結果が出ても、50件に増やすとボロボロだったりする。かといって網羅的にテストするのは時間がかかる。

要するに、プロンプトチューニングは「職人芸」になりやすいです。DSPyは、この職人芸をプログラムとデータで置き換えようとするフレームワークです。

DSPyの考え方

DSPyのアプローチを一言で言うと、**「プロンプトを人間が書くのではなく、プログラムとして定義してデータで最適化する」**です。

機械学習を知っている人なら、こう考えると分かりやすいです。

従来のプロンプトチューニング:
  プロンプトを手で書く → 試す → 結果を見る → 手で直す → 試す → ...

DSPy:
  入出力を定義する → 処理フローを組む → 評価関数を作る → オプティマイザに渡す → 最適化されたプロンプトが出てくる

機械学習では「モデルの構造を定義して、損失関数を決めて、データで学習する」という流れがあります。DSPyはこの考え方をプロンプトチューニングに持ち込んでいて、プロンプトを機械学習における「重み」のように扱います。人間がプロンプトの文面を直接いじるのではなく、入出力の型と処理の流れを定義して、あとはデータと評価関数を頼りにフレームワークが最適なプロンプトを探してくれます。

実際に作って最適化してみる

ここからは、DSPyを使って商品レビューの分析ツールを作ってみます。レビュー文を入力すると、感情(ポジティブ/ネガティブ)、評価ポイント、改善提案を出力するツールです。

入出力を定義する(Signature)

DSPyでは、LLMに「何を入力して何を出力するか」をSignatureというクラスで定義します。

review_analysis.py
import dspy

class ReviewAnalysis(dspy.Signature):
    """商品レビューを分析して、感情・評価ポイント・改善提案を出力する"""
    review_text = dspy.InputField(desc="商品レビューの本文")
    sentiment = dspy.OutputField(desc="positive または negative")
    key_points = dspy.OutputField(desc="レビューの主な評価ポイント(箇条書き)")
    suggestion = dspy.OutputField(desc="商品やサービスの改善提案")

ここで書いているのは「入力はレビュー本文、出力は感情・評価ポイント・改善提案」という型の定義だけです。プロンプトの文面は一切書いていません。DSPyが実行時にSignatureの情報からプロンプトを組み立ててくれます。

docstringとフィールドのdescが、DSPyがプロンプトを生成するときのヒントになります。ここを丁寧に書くと、最適化前でもそれなりの精度が出ます。

処理フローを組む(Module)

Signatureは「何を入出力するか」の定義で、「どう処理するか」はModuleで組みます。

シンプルにSignatureを1つだけ使う場合はこうなります。

review_analysis.py
class ReviewAnalyzer(dspy.Module):
    def __init__(self):
        super().__init__()
        self.analyze = dspy.ChainOfThought(ReviewAnalysis)

    def forward(self, review_text: str) -> dspy.Prediction:
        return self.analyze(review_text=review_text)

dspy.ChainOfThoughtは、LLMに段階的に考えさせてから回答を出させるモジュールです。DSPyにはほかにもdspy.Predict(直接回答)やdspy.ReAct(ツール呼び出しを交えた推論)などが用意されていて、タスクに合ったものを選べます。

もっと複雑な処理をしたい場合は、複数のSignatureをModuleの中で繋げます。たとえば「まず感情を判定して、その結果を踏まえて改善提案を出す」という2段構成にしたければ、こう書きます。

review_analysis.py
class SentimentJudge(dspy.Signature):
    """レビューの感情を判定する"""
    review_text = dspy.InputField(desc="商品レビューの本文")
    sentiment = dspy.OutputField(desc="positive または negative")
    key_points = dspy.OutputField(desc="レビューの主な評価ポイント(箇条書き)")

class ImprovementAdvisor(dspy.Signature):
    """感情判定の結果をもとに改善提案を行う"""
    review_text = dspy.InputField(desc="商品レビューの本文")
    sentiment = dspy.InputField(desc="判定された感情")
    key_points = dspy.InputField(desc="評価ポイント")
    suggestion = dspy.OutputField(desc="商品やサービスの改善提案")

class TwoStepAnalyzer(dspy.Module):
    def __init__(self):
        super().__init__()
        self.judge = dspy.ChainOfThought(SentimentJudge)
        self.advisor = dspy.Predict(ImprovementAdvisor)

    def forward(self, review_text: str) -> dspy.Prediction:
        judgment = self.judge(review_text=review_text)
        advice = self.advisor(
            review_text=review_text,
            sentiment=judgment.sentiment,
            key_points=judgment.key_points,
        )
        return dspy.Prediction(
            sentiment=judgment.sentiment,
            key_points=judgment.key_points,
            suggestion=advice.suggestion,
        )

Signatureで「何を入出力するか」を定義し、Moduleで「どの順序でLLMを呼ぶか」を組む。この2つの概念がDSPyの基本です。

学習データを準備する

最適化にはデータが必要です。入力(レビュー本文)と期待する出力(感情、評価ポイント)のペアを用意します。

prepare_data.py
import dspy

trainset = [
    dspy.Example(
        review_text="画面がきれいで動作もサクサク。バッテリーも1日持つので満足です。",
        sentiment="positive",
        key_points="画面品質が高い、動作が快適、バッテリー持ちが良い",
    ).with_inputs("review_text"),
    dspy.Example(
        review_text="買って1週間で充電できなくなった。サポートに連絡しても返答が遅い。",
        sentiment="negative",
        key_points="充電不良、サポート対応が遅い",
    ).with_inputs("review_text"),
    dspy.Example(
        review_text="デザインは良いけど重すぎる。長時間持っていると手が疲れる。",
        sentiment="negative",
        key_points="デザインは良い、重量が重い、長時間使用に不向き",
    ).with_inputs("review_text"),
    # ... 実際にはもっと多くのデータを用意する
]

.with_inputs("review_text")は、review_textが入力フィールドであることをDSPyに教えています。それ以外のフィールド(sentimentkey_points)は正解ラベルとして扱われます。

データ量の目安ですが、最適化手法にもよるものの、20〜30件程度の学習データがあればそれなりに効果が出ます。件数だけでなく、データの多様性も大事です。ポジティブなレビューばかり集めると、ネガティブなケースや判断が微妙なケースで精度が出ません。エッジケース(短文、皮肉、ポジネガが混在するレビューなど)を意識的に含めておくと、最適化後の汎化性能が変わってきます。

評価関数を作る

最適化で「良いプロンプトかどうか」を判断するために、評価関数を定義します。ここが一番頭を使うところです。

今回は感情判定の正解率と、評価ポイントの妥当性をLLMで評価する2段構えにします。

evaluate.py
import dspy

class PointsQuality(dspy.Signature):
    """評価ポイントの品質を判定する"""
    review_text = dspy.InputField(desc="元のレビュー本文")
    predicted_points = dspy.InputField(desc="モデルが出力した評価ポイント")
    reference_points = dspy.InputField(desc="正解の評価ポイント")
    score = dspy.OutputField(desc="0から10のスコア", format=int)

points_evaluator = dspy.ChainOfThought(PointsQuality)

def review_metric(example, prediction, trace=None):
    # 感情判定の正誤(完全一致)
    sentiment_correct = (
        example.sentiment.strip().lower() == prediction.sentiment.strip().lower()
    )

    # 評価ポイントの品質をLLMで採点
    with dspy.context(lm=eval_lm):
        quality = points_evaluator(
            review_text=example.review_text,
            predicted_points=prediction.key_points,
            reference_points=example.key_points,
        )

    points_score = min(10, max(0, int(quality.score))) / 10.0

    return (1.0 if sentiment_correct else 0.0) * 0.5 + points_score * 0.5

感情判定は文字列の完全一致で判定し、評価ポイントの品質はLLMに採点させています。最適化に使うモデルと評価に使うモデルを分けることで、評価の客観性を保てます。

MIPROv2で最適化を実行する

学習データ、Module、評価関数が揃ったら、オプティマイザに渡して最適化を実行します。DSPyにはいくつかのオプティマイザがありますが、プロンプト最適化で実績があるMIPROv2を使います。

optimize.py
import dspy

# 推論用のモデル
lm = dspy.LM(model="openai/gpt-4o-mini")
dspy.configure(lm=lm)

# 評価用のモデル(推論用と分ける)
eval_lm = dspy.LM(model="openai/gpt-4o")

# Moduleのインスタンス化
analyzer = TwoStepAnalyzer()

# オプティマイザの設定
optimizer = dspy.MIPROv2(
    metric=review_metric,
    prompt_model=eval_lm,
    auto="light",
    max_bootstrapped_demos=2,
    max_labeled_demos=2,
)

# 最適化の実行
optimized_analyzer = optimizer.compile(
    analyzer,
    trainset=trainset,
)

auto="light"は最適化の探索範囲を控えめにする設定です。"medium""heavy"にすると探索範囲が広がりますが、その分API呼び出しの回数とコストが増えます。初回は"light"で試して、効果が見えたら範囲を広げるのがおすすめです。

MIPROv2の最適化は内部で3つのステップを踏みます。まず現在のModuleで学習データに対して推論を実行し、その結果を評価関数でスコアリングします(トレース収集)。次に、スコアの高かった応答から特徴を抽出して、プロンプトの候補を複数生成します(候補生成)。最後に、生成した候補をベイズ最適化で評価し、最もスコアの高い組み合わせを選びます。

最適化前後の出力を比較する

最適化の効果を確認します。

compare.py
test_review = "音質は良いけど、ノイズキャンセリングが弱い。通勤電車だと外の音がかなり入ってくる。"

# 最適化前
before = analyzer(review_text=test_review)
print("【最適化前】")
print(f"感情: {before.sentiment}")
print(f"評価ポイント: {before.key_points}")
print(f"改善提案: {before.suggestion}")

print("---")

# 最適化後
after = optimized_analyzer(review_text=test_review)
print("【最適化後】")
print(f"感情: {after.sentiment}")
print(f"評価ポイント: {after.key_points}")
print(f"改善提案: {after.suggestion}")

最適化後のModuleには、MIPROv2が見つけた最適なシステムプロンプトとfew-shot例が組み込まれています。手で書いたプロンプトではなく、データと評価関数から自動で導き出されたものです。

最適化されたModuleは保存して再利用できます。

# 保存
optimized_analyzer.save("artifact/review_analyzer")

# 読み込み
loaded_analyzer = TwoStepAnalyzer()
loaded_analyzer.load("artifact/review_analyzer")

DSPyを使ってみて感じたこと

プロンプトの「管理」が楽になる

DSPyを使うと、プロンプトの改善が「テキストの書き直し」ではなく「データの追加と評価関数の改善」になります。Gitで差分管理しやすいですし、「なぜこのプロンプトが良いのか」を評価スコアで説明できます。

チームで開発する場合、「プロンプトを変えたらスコアが下がった」「学習データを増やしたらスコアが上がった」という議論ができるので、属人的な職人芸から抜け出せます。

評価関数の設計が肝

一方で、評価関数の設計がいい加減だと最適化の意味がなくなります。今回のように「LLMにスコアをつけさせる」アプローチは手軽ですが、評価用LLM自体の判断がブレることもあります。

タスクによっては、文字列の完全一致や正規表現によるパターンマッチなど、LLMに頼らない評価基準を組み合わせたほうが安定します。

向いているプロジェクト、向いていないプロジェクト

DSPyがハマるのは、「入力と期待する出力のペアをある程度の量で用意できる」タスクです。分類、要約、情報抽出、定型的な文章生成などが向いています。

逆に、正解が1つに定まらない創造的なタスク(小説の執筆、ブレストのアイデア出しなど)は、評価関数が作りにくいのでDSPyのメリットが出にくいです。

また、学習データの準備コストも無視できません。20〜30件程度のデータ準備は必要で、そのデータの品質が最適化結果に直結します。「プロンプトを手で直す時間」と「学習データを準備する時間」のどちらが投資対効果が高いかは、プロジェクトの規模やタスクの反復頻度によって変わります。

まとめ

DSPyは、プロンプトチューニングを「手作業での書き直し」から「データと評価関数による自動最適化」に変えるフレームワークです。

記事のポイントを整理します。

  • Signatureで入出力の型を定義し、Moduleで処理フローを組むのがDSPyの基本。プロンプトの文面は自分で書かない。
  • MIPROv2などのオプティマイザに学習データと評価関数を渡すと、最適なシステムプロンプトとfew-shot例を自動で探してくれる。
  • 評価関数の設計が一番大事。 ここが雑だと最適化しても意味がない。
  • 分類・要約・情報抽出のように「正解を定義しやすいタスク」で使うと効果が大きい。

プロンプトを手で直し続ける作業に疲れたら、DSPyを試してみてください。

最後までお読みいただきありがとうございました!

参考リンク

16
3
株式会社エクスプラザ

Discussion

ログインするとコメントできます
16
3