株式会社ブレインパッドプロダクト開発部でRtoaster GenAIの開発をしている依田です。
今回は生成AI機能の「信頼性」をどうテストするかというテーマで、ECサイトの予算抽出機能を題材にしたハンズオンをお届けします。
はじめに
生成AIを組み込んだ機能を作っていると、動作確認で数回試して「うまくいった、完成」としてしまうことがあります。ところが、その数回の成功が本当にたまたまではないと言い切れるでしょうか。LLMの出力には揺らぎがあるため、開発中に3回試して3回とも正解だったとしても、本番で1000回叩けば失敗するケースが混ざっている可能性があります。
この記事では、仮想のECサイトを題材に、入力プロンプトから「予算の上限・下限」を抽出する機能をVertex AI(Gemini)で作り、その信頼性をPytestでテストします。使う考え方は2つです。
- TP/FP/TN/FNという4象限で予測結果を仕分けする混同行列
- 統計学の信頼区間
どちらも本文でイチから説明するので、初めて聞く言葉でも問題ありません。この2つを組み合わせることで、「なんとなく動いていそう」を「どのくらいの確からしさで動いている」に変換します。
この記事ではVertex AI(Google Cloud)を使用するため、従量課金による使用料が発生します。本記事の実験程度であれば数円〜数十円程度ですが、あらかじめご了承ください。
LLMの回答には揺らぎがあります。同じプロンプトを投げても、実行タイミングやモデルのバージョンによって結果は変わることがあります。本記事に掲載した実行結果はすべて実際にVertex AIへライブでリクエストした結果ですが、あくまで実行時点での一例としてお読みください。
対象読者
- 生成AIを組み込んだアプリケーションを作り始めたばかりの人
- Pytestの
parametrize程度は書いたことがある人 - TP/FP/TN/FNや信頼区間という言葉を聞いたことがない、または名前は知っていても中身がよく分からない人
環境構成
| 項目 | 内容 |
|---|---|
| OS | macOS Tahoe 26.4.1 |
| 言語 | Python 3.14 |
| LLM | Gemini 3.1 Flash Lite(Vertex AI) |
| SDK | google-genai |
| テストフレームワーク | pytest |
パッケージのインストールと認証を済ませておきます。
pip install google-genai pytest
gcloud auth application-default login
各テストは以下のコマンドで実行できます。your-project-id は自分のGCPプロジェクトIDに置き換えてください。
GOOGLE_CLOUD_PROJECT=your-project-id python3 -m pytest -v -s
よろず百貨店の予算抽出機能
今回作るのは、架空のECサイト「よろず百貨店」の検索アシスタント機能です。キャッチコピーは「駄菓子からジュエリーまで、老若男女の期待に応える百貨店」で、扱う商品ジャンルが非常に幅広いという設定にしています。
このサイトでは、ユーザーが自然文で検索窓に入力します。「1万円以内のピンクトルマリンピアス」のように予算を伝えてくる人もいれば、「5円チョコは売っている?」1や「百万円焼きかまはありますか」2のように、金額を含む言葉で商品を呼ぶ人もいます。「百万円焼きかま」はやおきんの実際の商品名ですが、「5円チョコ」は5円玉の形をしたチロルチョコのお菓子「ごえんがあるよ」を指す、購入者側のカジュアルな呼び方です。どちらのパターンも区別できないと、検索結果の絞り込みが正しく機能しません。「5円チョコ」を額面通り受け取って予算5円で検索してしまうと、当然ながら何もヒットしなくなります。
そこで、入力文に実際の予算指定が含まれているかどうかを判定し、含まれていれば上限・下限の金額を抽出する関数を作ります。
まずは、必要最低限のルールだけを書いた最初のバージョンを作ります。
import os
from dataclasses import dataclass
from google import genai
from google.genai import types
_PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "your-project-id")
_MODEL = "gemini-3.1-flash-lite"
_client = genai.Client(vertexai=True, project=_PROJECT_ID, location="global")
_SYSTEM_INSTRUCTION = """\
あなたは百貨店ECサイト「よろず百貨店」の検索アシスタントです。
ユーザーの入力文から、商品を探す際の「予算」(金額の上限・下限)を抽出してください。
判定のルール:
- 「予算」とは、ユーザーが支払ってもよいと考えている金額の範囲のことです。
- 金額の言及が一切ない場合は、予算なし(has_budget: false)としてください。
- 「〜以内」「〜まで」「〜以下」は上限(max_yen)として扱ってください。
- 「〜から」「〜以上」は下限(min_yen)として扱ってください。
- 「〜くらい」「約〜」のようなおおよその金額は、その金額を上限(max_yen)として扱ってください。
- reasoning には、なぜその判定に至ったかを1文で日本語で記述してください。
"""
_BUDGET_RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"has_budget": {"type": "boolean"},
"min_yen": {"type": "integer", "nullable": True},
"max_yen": {"type": "integer", "nullable": True},
"reasoning": {"type": "string"},
},
"required": ["has_budget", "min_yen", "max_yen", "reasoning"],
}
@dataclass(frozen=True)
class BudgetExtraction:
has_budget: bool
min_yen: int | None
max_yen: int | None
reasoning: str
def extract_budget_range(prompt: str) -> BudgetExtraction:
response = _client.models.generate_content(
model=_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
system_instruction=_SYSTEM_INSTRUCTION,
response_mime_type="application/json",
response_schema=_BUDGET_RESPONSE_SCHEMA,
max_output_tokens=1024,
),
)
data = response.parsed or {}
return BudgetExtraction(
has_budget=data["has_budget"],
min_yen=data.get("min_yen"),
max_yen=data.get("max_yen"),
reasoning=data.get("reasoning", ""),
)
response_schemaにJSON Schemaを渡すことで、Geminiの出力を指定した形式に固定できます。これを構造化出力(Structured Output)と呼びます。min_yen・max_yenは値がない場合を表現するためにnullable: Trueを指定しました。
このルールには、「5円チョコ」のような引っかけ問題への対処が一切書かれていません。あえて書いていないというより、この時点ではまだ何が必要かが分かっていない、というのが正確なところです。実際のプロダクト開発でも、想定していなかった入力パターンは、作ってからテストして初めて分かることがほとんどです。まずはこの最小構成のまま、試しに動かしてみます。
>>> extract_budget_range("1万円以内のピンクトルマリンピアスはありますか")
BudgetExtraction(has_budget=True, min_yen=None, max_yen=10000, reasoning='「1万円以内」という指定があるため、1万円を上限として抽出しました。')
>>> extract_budget_range("5円チョコは売っている?")
BudgetExtraction(has_budget=False, min_yen=None, max_yen=None, reasoning='金額指定ではなく特定の商品名として入力されているため、予算としては判断しません。')
2つとも狙い通りの結果です。引っかけ問題に対するルールを何も書いていないのに、「5円チョコ」を予算なしと判定できています。これなら要らないルールだったようにも見えますが、これは2つの入力を1回ずつ試しただけです。この状態で「完成」と言ってよいか、次の章から検証していきます。
混同行列(TP/FP/TN/FN)の基礎
生成AIの出力を評価するとき、機械学習の分類問題でよく使われる混同行列(Confusion Matrix)という考え方がそのまま使えます。予算抽出機能では、「入力に実際に予算の指定がある」ことをPositive(陽性)、「ない」ことをNegative(陰性)と定義すると、判定結果は次の4象限に分類できます。
| 生成結果: 予算あり | 生成結果: 予算なし | |
|---|---|---|
| 正解: 予算あり | TP(True Positive) | FN(False Negative) |
| 正解: 予算なし | FP(False Positive) | TN(True Negative) |
TP(True Positive)は、本当に予算が書かれている入力を、正しく「予算あり」と判定できたケースです。「1万円以内のピアス」を予算ありと判定できれば、これに当たります。TN(True Negative)は逆に、予算が書かれていない入力を正しく「予算なし」と判定できたケースで、「5円チョコ」を予算なしと判定できればこちらです。
問題はFPとFNです。FP(False Positive)は、予算ではない金額表現を予算だと誤判定するケースです。「1億円入るアタッシュケース」を予算100,000,000円と誤解してしまうと、検索結果が0件になったり、逆に高額商品ばかりが表示されたりします。FN(False Negative)は、本当は予算が書かれているのに見逃してしまうケースで、こちらは予算による絞り込みが機能しなくなり、ユーザーの期待とずれた検索結果が並びます。
どちらも実害があるという点が、この機能をテストする上でのポイントです。精度が高そうに見えても、FPやFNが特定のパターンに偏っていないかを確認する必要があります。
テストデータセットの設計
混同行列で評価するには、まず人間が決めた「正解」つきのラベル付きテストケースを用意します。よろず百貨店の入力を想定して、次の16件をまとめました。
各ケースにはcategoryというラベルを付けています。これは判定ロジックには使わない、集計・分析のための分類で、7種類あります。
| カテゴリ | 意味 | 期待する判定 |
|---|---|---|
| true_budget | 実際に予算(上限・下限)が書かれている、素直なケース | 予算あり |
| decoy_product_name | 金額が商品名・通称の一部になっている引っかけ | 予算なし |
| decoy_capacity | 金額が収納力・容量などの比喩表現である引っかけ | 予算なし |
| decoy_coin_unit | 金額が硬貨・紙幣の種類(「〜円玉」「〜円札」)を指す引っかけ | 予算なし |
| decoy_model_number | 金額のような数字が、実は型式・系式番号である引っかけ | 予算なし |
| decoy_opinion_question | 既存商品の価格に対する感想・意見を尋ねているだけの引っかけ | 予算なし |
| no_price_control | そもそも金額の言及が一切ない制御群 | 予算なし |
このカテゴリを踏まえて、実際のテストケース一覧を見てみます。
| プロンプト | カテゴリ | 正解(予算あり) |
|---|---|---|
| 1万円以内のピンクトルマリンピアスはありますか | true_budget | True |
| 3000円から5000円くらいのワインを探しています | true_budget | True |
| 予算5万円で腕時計が欲しいです | true_budget | True |
| 3万円以上のスーツケースでおすすめはありますか | true_budget | True |
| 1000円くらいのお菓子の詰め合わせはありますか | true_budget | True |
| 2000円から3000円の間で誕生日プレゼントを探しています | true_budget | True |
| 5円チョコは売っている? | decoy_product_name | False |
| 百万円焼きかまはありますか | decoy_product_name | False |
| 1億円入るアタッシュケースを探しています | decoy_capacity | False |
| 500円玉貯金箱が欲しいです | decoy_coin_unit | False |
| 1000円札のデザインが可愛いポーチはありますか | decoy_coin_unit | False |
| 5000系新幹線の置物はありますか | decoy_model_number | False |
| 5000円のiPhoneケースって高くないですか? | decoy_opinion_question | False |
| 18金のネックレスを探しています | no_price_control | False |
| 子供へのお祝いに腕時計を探しています | no_price_control | False |
| 秋物のコートでおすすめはありますか | no_price_control | False |
引っかけ問題(decoy_*)の中でも、「5000円のiPhoneケースって高くないですか?」(decoy_opinion_question)は少し毛色が違います。価格そのものには触れているものの、ユーザーが尋ねているのは相場観であって自分の予算ではないケースです。他の引っかけが「金額の言い回しを見分ける」問題であるのに対し、こちらは「価格を話題にしているが、支払う意思の話ではない」という、もう一段階踏み込んだ文脈理解を要求します。この手のプロンプトは、判定が割れやすい傾向にあります。
このコードはPythonのdataclassで表現しています。
from dataclasses import dataclass
@dataclass(frozen=True)
class TestCase:
prompt: str
category: str
expected_has_budget: bool
expected_min_yen: int | None = None
expected_max_yen: int | None = None
TEST_CASES: list[TestCase] = [
TestCase("1万円以内のピンクトルマリンピアスはありますか", "true_budget", True, None, 10000),
# ...(以下、上表の16件を定義)
]
Pytestで混同行列を集計する
テストケースを1件ずつ実行し、正解と予測を突き合わせてTP/FP/TN/FNに分類します。同じテストケースに対して複数のテスト関数から結果を参照したいので、scope="module"のfixtureで一度だけAPIを呼び出し、結果を使い回します。
import unicodedata
import pytest
from budget_extractor import extract_budget_range
from dataset import TEST_CASES
def _display_width(text: str) -> int:
# 全角文字は半角の2倍の表示幅を持つため、文字数ではなく表示幅で揃える
return sum(2 if unicodedata.east_asian_width(c) in "FWA" else 1 for c in text)
def _pad(text: str, width: int) -> str:
return text + " " * max(0, width - _display_width(text))
def classify(expected_has_budget: bool, predicted_has_budget: bool) -> str:
if expected_has_budget and predicted_has_budget:
return "TP"
if not expected_has_budget and predicted_has_budget:
return "FP"
if not expected_has_budget and not predicted_has_budget:
return "TN"
return "FN"
@pytest.fixture(scope="module")
def classification_results():
results = []
for case in TEST_CASES:
extraction = extract_budget_range(case.prompt)
label = classify(case.expected_has_budget, extraction.has_budget)
results.append((case, extraction, label))
return results
def test_print_confusion_matrix(classification_results):
counts = {"TP": 0, "FP": 0, "TN": 0, "FN": 0}
print()
print(f"{_pad('prompt', 54)}{'category':20s} {'expected':9s} {'predicted':9s} label")
for case, extraction, label in classification_results:
counts[label] += 1
print(
f"{_pad(case.prompt, 54)}{case.category:20s} "
f"{str(case.expected_has_budget):9s} {str(extraction.has_budget):9s} {label}"
)
print(f"\n混同行列: TP={counts['TP']} FP={counts['FP']} TN={counts['TN']} FN={counts['FN']}")
def test_recall_is_acceptable(classification_results):
tp = sum(1 for _, _, label in classification_results if label == "TP")
fn = sum(1 for _, _, label in classification_results if label == "FN")
recall = tp / (tp + fn) if (tp + fn) else 1.0
assert recall >= 0.8, f"再現率が低すぎます: {recall:.2f}(予算ありの見逃しが多い)"
def test_precision_is_acceptable(classification_results):
tp = sum(1 for _, _, label in classification_results if label == "TP")
fp = sum(1 for _, _, label in classification_results if label == "FP")
precision = tp / (tp + fp) if (tp + fp) else 1.0
assert precision >= 0.8, f"適合率が低すぎます: {precision:.2f}(予算ではないものを予算と誤認しがち)"
def test_decoy_cases_are_not_false_positives(classification_results):
decoy_labels = [
(case, label) for case, _, label in classification_results
if case.category.startswith("decoy")
]
false_positives = [case.prompt for case, label in decoy_labels if label == "FP"]
assert not false_positives, f"引っかけケースを予算ありと誤判定しました: {false_positives}"
適合率(precision)と再現率(recall)は、どちらも「TP・FP・TN・FNの個数を数えるだけ」で計算できる、割り算だけの指標です。
\text{適合率} = \frac{TP}{TP + FP} \qquad \text{再現率} = \frac{TP}{TP + FN}
適合率は「予算ありと判定したもののうち、実際に合っていた割合」、再現率は「本当に予算があるもののうち、どれだけ拾えたか」を表します。今回は精度・再現率ともに80%を合格ラインとしました。
最小構成のシステムプロンプトのまま、実際にVertex AIへライブで実行した結果は次の通りです。
| prompt | category | expected | predicted | label |
|---|---|---|---|---|
| 1万円以内のピンクトルマリンピアスはありますか | true_budget | True | True | TP |
| 3000円から5000円くらいのワインを探しています | true_budget | True | True | TP |
| 予算5万円で腕時計が欲しいです | true_budget | True | True | TP |
| 3万円以上のスーツケースでおすすめはありますか | true_budget | True | True | TP |
| 1000円くらいのお菓子の詰め合わせはありますか | true_budget | True | True | TP |
| 2000円から3000円の間で誕生日プレゼントを探しています | true_budget | True | True | TP |
| 5円チョコは売っている? | decoy_product_name | False | False | TN |
| 百万円焼きかまはありますか | decoy_product_name | False | False | TN |
| 1億円入るアタッシュケースを探しています | decoy_capacity | False | False | TN |
| 500円玉貯金箱が欲しいです | decoy_coin_unit | False | False | TN |
| 1000円札のデザインが可愛いポーチはありますか | decoy_coin_unit | False | False | TN |
| 5000系新幹線の置物はありますか | decoy_model_number | False | False | TN |
| 5000円のiPhoneケースって高くないですか? | decoy_opinion_question | False | True | FP |
| 18金のネックレスを探しています | no_price_control | False | False | TN |
| 子供へのお祝いに腕時計を探しています | no_price_control | False | False | TN |
| 秋物のコートでおすすめはありますか | no_price_control | False | False | TN |
混同行列: TP=6 FP=1 TN=9 FN=0
16件中15件は正解でしたが、「5000円のiPhoneケースって高くないですか?」だけが予算ありと誤判定されています。実際にPytestを実行すると、この1件が原因でテストが1つ失敗します。
test_decoy_cases_are_not_false_positives FAILED
def test_decoy_cases_are_not_false_positives(classification_results):
...
> assert not false_positives, f"引っかけケースを予算ありと誤判定しました: {false_positives}"
E AssertionError: 引っかけケースを予算ありと誤判定しました: ['5000円のiPhoneケースって高くないですか?']
3 passed, 1 failed
適合率と再現率も、この結果に当てはめて計算してみます。
\text{適合率} = \frac{TP}{TP + FP} = \frac{6}{6 + 1} = \frac{6}{7} \approx 0.857 \, (85.7\%)
\text{再現率} = \frac{TP}{TP + FN} = \frac{6}{6 + 0} = \frac{6}{6} = 1.0 \, (100\%)
適合率は85.7%で、80%の合格ラインはクリアしています。ただしtest_decoy_cases_are_not_false_positivesは「引っかけ問題を1件でも誤判定したら不合格」という、より厳しい基準のテストです。適合率という平均的な指標だけでは、この1件の不具合を見逃してしまうところでした。
見つかった不具合を直す
失敗の原因をreasoningで確認すると、「5000円のiPhoneケースって高くないですか?」を、価格への意見ではなく単純な金額指定として読み取っていることが分かりました。このプロンプトは、価格そのものには触れているものの、ユーザーが尋ねているのは相場観であって自分の予算ではない、というやや踏み込んだ文脈理解を要求するケースでした。
これを直すために、システムプロンプトにルールを追加します。
判定のルール:
- 「予算」とは、ユーザーが支払ってもよいと考えている金額の範囲のことです。
+- 文中に金額の言及があっても、それが以下のように予算ではなく商品自体の特徴や、
+ 価格に対する意見を表している場合は、予算として扱わないでください
+ (has_budget は false、min_yen と max_yen は null)。
+ - 商品名やブランド名の一部になっている金額表現(金額を冠したユニークな商品名の菓子・雑貨は珍しくない)
+ - 商品のスペックや容量を表す比喩表現(収納力や大きさを金額の大小で例えているだけで、購入価格ではない)
+ - 硬貨や紙幣そのものを指す言葉(「〜円玉」「〜円札」は硬貨・紙幣の種類を指しており、価格ではない)
+ - 既存の商品価格に対する感想や意見(例:「5000円のiPhoneケースって高くないですか?」は、
+ 5000円という価格が高いかどうかを尋ねているだけで、ユーザー自身がいくら払うつもりかは述べていない)
- 金額の言及が一切ない場合も、予算なし(has_budget: false)としてください。
見つかった「価格への意見」だけでなく、テストデータセットの設計時に想定していた残り3種類の引っかけパターン(商品名、容量の比喩、硬貨の単位)についても、まとめてルール化しました。今回の1回のテストでは表面化しませんでした。ただ、後述するように同じ種類の判定は実行のたびに結果が変わりやすいため、まとめて手当てしておきます。
ルールの中には、「5円チョコ」や「1億円入るアタッシュケース」といった具体的な商品名を書いていない点に注目してください。もしルールに「5円チョコは予算ではない」と直接書いてしまうと、そのままテストケースとして使ったときに正解して当然になってしまいます。カンニングペーパーを見ながらテストを受けているようなもので、これでは実力を測れません。そこで「商品名やブランド名の一部になっている金額表現」という抽象的なルールだけを与え、個別の商品名にどこまで汎化できるかを試すことにしました。
ルール追加後、同じテストを実行し直した結果です。
| prompt | category | expected | predicted | label |
|---|---|---|---|---|
| 1万円以内のピンクトルマリンピアスはありますか | true_budget | True | True | TP |
| 3000円から5000円くらいのワインを探しています | true_budget | True | True | TP |
| 予算5万円で腕時計が欲しいです | true_budget | True | True | TP |
| 3万円以上のスーツケースでおすすめはありますか | true_budget | True | True | TP |
| 1000円くらいのお菓子の詰め合わせはありますか | true_budget | True | True | TP |
| 2000円から3000円の間で誕生日プレゼントを探しています | true_budget | True | True | TP |
| 5円チョコは売っている? | decoy_product_name | False | False | TN |
| 百万円焼きかまはありますか | decoy_product_name | False | False | TN |
| 1億円入るアタッシュケースを探しています | decoy_capacity | False | False | TN |
| 500円玉貯金箱が欲しいです | decoy_coin_unit | False | False | TN |
| 1000円札のデザインが可愛いポーチはありますか | decoy_coin_unit | False | False | TN |
| 5000系新幹線の置物はありますか | decoy_model_number | False | False | TN |
| 5000円のiPhoneケースって高くないですか? | decoy_opinion_question | False | False | TN |
| 18金のネックレスを探しています | no_price_control | False | False | TN |
| 子供へのお祝いに腕時計を探しています | no_price_control | False | False | TN |
| 秋物のコートでおすすめはありますか | no_price_control | False | False | TN |
混同行列: TP=6 FP=0 TN=10 FN=0
16件すべて正解、4つのテストすべてが合格しました。適合率・再現率はどちらも100%です。ここで一区切りついた気になりますが、これで「信頼できる」と言い切ってよいものでしょうか。
LLMの出力はゆらぐという壁
先ほど見つかった「5000円のiPhoneケースって高くないですか?」の不具合は、ルールを1つ追加しただけで直りました。ですが、これで本当に直ったと言い切れるでしょうか。ルール追加後のテストは1回しか実行していません。たまたまその1回で正解しただけで、実はまだ不安定なままである可能性もあります。
これを確かめるため、同じプロンプトを繰り返し実行してみます。試しにシステムプロンプトから、追加したルールを外した状態(=不具合が見つかった直後の状態)に戻し、5回ずつ区切りながら実行して、その都度ここまでの正答率を数えてみます。
| 試行回数 | 正答数 | ここまでの正答率 |
|---|---|---|
| 5 | 3 | 60.0% |
| 10 | 8 | 80.0% |
| 15 | 11 | 73.3% |
| 20 | 14 | 70.0% |
| 25 | 17 | 68.0% |
25回試したところで正答率は68%(17/25)でした。ルールを追加する前の状態は、実に3回に1回近い頻度で「5000円が予算だ」と誤判定していたことになります。混同行列のテストで見つかったのは氷山の一角で、1回の実行だけでは、この程度の不安定さを見抜けなかったということです。
ここで新しい疑問が浮かびます。「68%(17/25)」という数字は、そのまま信じてよいのでしょうか。25回という限られた試行だけから、この機能の本当の実力を言い切ってしまってよいのでしょうか。この疑問に答えるのが、この記事のもう1つの柱である信頼区間です。
信頼区間の基礎
信頼区間(Confidence Interval)は、限られたサンプルから母集団全体の性質を見積もるときに、その見積もりにどれくらいの幅の不確かさがあるかを表す統計的な考え方です。数式を覚える必要はなく、考え方さえ押さえれば十分です。
よろず百貨店で例えると、どら焼きを1万個仕入れて、そこから無作為に選んだ20個を試食したとします。20個全部が甘さも生地の焼き加減もちょうどよかった場合、「残り9980個も全部完璧」と断言できるでしょうか。20個という限られたサンプルからの推測である以上、そうは言い切れません。信頼区間は、この「20個全部OKだった」という結果から、母集団全体の合格率が実際にはどのくらいの範囲に収まりそうかを、95%信頼区間なら「95%の確率でこの範囲に収まる」という形で見積もります。
Wilson score intervalで計算する
信頼区間の計算方法にはいくつか種類があります。単純な方法として「正答率にちょっとだけ余裕(誤差の幅)を持たせる」というやり方もあります(正規近似と呼ばれます)。ただし、この方法はサンプル数が少なかったり、正答率が0%や100%付近だったりすると、計算が破綻するという弱点を持っています。たとえば5回中5回とも正解だった場合、単純な方法では余裕の幅がゼロになってしまい、「5回しか試していないのに100%確実」という非現実的な結論になってしまいます。
今回使うWilson score intervalは、この弱点を補正した計算式です。数式で書くと次のようになります。$n$ が試行回数、$\hat{p}$(正答率)が観測された成功割合、$z$ が後述する決まった定数です。
\hat{p} = \frac{\text{successes}}{n} \qquad
\text{denominator} = 1 + \frac{z^2}{n} \qquad
\text{center} = \hat{p} + \frac{z^2}{2n}
\text{margin} = z\sqrt{\frac{\hat{p}(1-\hat{p})}{n} + \frac{z^2}{4n^2}} \qquad
\text{lower}, \text{upper} = \frac{\text{center} \mp \text{margin}}{\text{denominator}}
コードにすると次のようになります。
import math
_Z_95 = 1.959963985 # 「95%の確からしさ」を表すために統計学でいつも使う決まった数値
def wilson_score_interval(successes: int, n: int) -> tuple[float, float]:
if n == 0:
return (0.0, 1.0)
z = _Z_95
phat = successes / n
denominator = 1 + z**2 / n
center = phat + z**2 / (2 * n)
margin = z * math.sqrt(phat * (1 - phat) / n + z**2 / (4 * n**2))
lower = (center - margin) / denominator
upper = (center + margin) / denominator
return (max(0.0, lower), min(1.0, upper))
コード中の_Z_95は、数式では$z$という文字で表すのが慣習です。この$z = 1.96$という値は自分で計算する数値ではなく、「95%の確からしさで区間を作りたいときは、いつもこの数値を使う」という統計学の決まり事です3。天気予報の「降水確率」のようなもので、由来を知らなくても使えます。
先ほどの「25回中17回正解」というデータを、実際にこの式に当てはめて手順ごとに計算してみます。
| 手順 | 計算内容 | 結果 |
|---|---|---|
| ① 正答率を求める | $\hat{p} = 17 \div 25$ | $0.68$ |
| ② $z$の2乗を求める | $z^2 = 1.96 \times 1.96$ | $3.842$ |
| ③ 分母を求める | $\text{denominator} = 1 + z^2 \div n = 1 + 3.842 \div 25$ | $1.154$ |
| ④ 中心の値を求める | $\text{center} = \hat{p} + z^2 \div (2n) = 0.68 + 3.842 \div 50$ | $0.757$ |
| ⑤ 誤差の幅を求める | $\text{margin} = z\sqrt{\hat{p}(1-\hat{p}) \div n + z^2 \div (4n^2)}$ | $0.198$ |
| ⑥ 下限を求める | $\text{lower} = \frac{\text{center} - \text{margin}}{\text{denominator}} = \frac{0.757 - 0.198}{1.154}$ | $0.484$ |
| ⑦ 上限を求める | $\text{upper} = \frac{\text{center} + \text{margin}}{\text{denominator}} = \frac{0.757 + 0.198}{1.154}$ | $0.828$ |
計算の結果、95%信頼区間は$[0.484, 0.828]$になりました。「本当の正答率は48.4%から82.8%の間にありそうだ」というのが、このテストから統計的に言える範囲です。「合格ラインは85%以上」と決めていたなら、上限の82.8%でも届いていないため、この結果は合格とは言えません。25回という試行回数を踏まえても、この機能はまだ信頼できるとは言い切れないということです。
再帰的なサンプリングで精度を評価するPytest
信頼区間を使うと分かるのは、サンプル数が少ないうちは区間の幅が広く、判定に自信が持てないということです。かといって、どんなプロンプトに対しても常に100回実行するのはAPIコストの無駄です。合否がはっきりしているプロンプトなら5回で十分なこともあります。
そこで、信頼区間の幅を見ながらサンプルを追加していく方式にします。手順を日本語で書くと、次のようになります。
- まず5回試し、信頼区間を計算する
- 下限が閾値(85%)を超えていれば「十分な確からしさで合格」として終了する
- 上限が閾値を下回っていれば「これ以上試しても合格する見込みがない」として終了する
- どちらとも言えなければ、さらに5回追加して同じ判定を繰り返す
「2」も「3」も満たさない間は、4によって手順1に戻って繰り返すことになります。これが「再帰的」という言葉の意味です。コードにすると次のようになります。
from budget_extractor import extract_budget_range
from confidence_interval import wilson_score_interval
TARGET_PROMPT = "5000円のiPhoneケースって高くないですか?"
EXPECTED_HAS_BUDGET = False
BATCH_SIZE = 5
MAX_SAMPLES = 30
LOWER_BOUND_THRESHOLD = 0.85
UPPER_BOUND_THRESHOLD = 0.85
def _sample_batch(n: int) -> int:
successes = 0
for _ in range(n):
extraction = extract_budget_range(TARGET_PROMPT)
if extraction.has_budget == EXPECTED_HAS_BUDGET:
successes += 1
return successes
def _evaluate_recursively(successes: int, total: int) -> tuple[bool, int, int, float, float]:
lower, upper = wilson_score_interval(successes, total)
print(f" n={total:2d} 正答数={successes:2d} 95%信頼区間=[{lower:.3f}, {upper:.3f}]")
if lower >= LOWER_BOUND_THRESHOLD:
return True, successes, total, lower, upper
if upper < UPPER_BOUND_THRESHOLD or total >= MAX_SAMPLES:
return False, successes, total, lower, upper
successes += _sample_batch(BATCH_SIZE)
total += BATCH_SIZE
return _evaluate_recursively(successes, total)
def test_confidence_interval_recursive_sampling():
passed, successes, total, lower, upper = _evaluate_recursively(0, 0)
assert passed, (
f"信頼区間の下限が閾値{LOWER_BOUND_THRESHOLD:.0%}を"
f"サンプル数{total}(上限{MAX_SAMPLES})以内で超えられませんでした"
)
この処理全体を通して見ると、明確に良い機能・悪い機能ほど早く判定が終わり、境界線上の機能ほど多くサンプルを消費するという、直感的な挙動になります。
このコードを、システムプロンプトから「価格に対する意見」のルールを外した状態(先ほど手計算した状態)で実際に走らせると、次の結果になります。
n= 0 正答数= 0 95%信頼区間=[0.000, 1.000]
n= 5 正答数= 3 95%信頼区間=[0.231, 0.882]
n=10 正答数= 8 95%信頼区間=[0.490, 0.943]
n=15 正答数=11 95%信頼区間=[0.480, 0.891]
n=20 正答数=14 95%信頼区間=[0.481, 0.855]
n=25 正答数=17 95%信頼区間=[0.484, 0.828]
n=25の行の下限・上限(0.484と0.828)は、先ほど手計算した結果とぴったり一致します。n=25の時点で上限が0.828まで下がり、閾値の0.85を下回りました。この時点で「これ以上試しても合格の見込みがない」と判定され、上限のMAX_SAMPLES=30を使い切ることなく、25サンプルで不合格が確定しています。
先ほどの「5000円のiPhoneケースって高くないですか?」に対する判定が甘かった原因は、システムプロンプトに「価格への意見」を明示的に除外するルールが抜けていたことでした。ルールに1文追加して同じテストを実行し直します。
- 硬貨や紙幣そのものを指す言葉(「〜円玉」「〜円札」は硬貨・紙幣の種類を指しており、価格ではない)
+ - 既存の商品価格に対する感想や意見(例:「5000円のiPhoneケースって高くないですか?」は、
+ 5000円という価格が高いかどうかを尋ねているだけで、ユーザー自身がいくら払うつもりかは述べていない)
n= 0 正答数= 0 95%信頼区間=[0.000, 1.000]
n= 5 正答数= 5 95%信頼区間=[0.566, 1.000]
n=10 正答数=10 95%信頼区間=[0.722, 1.000]
n=15 正答数=15 95%信頼区間=[0.796, 1.000]
n=20 正答数=20 95%信頼区間=[0.839, 1.000]
n=25 正答数=25 95%信頼区間=[0.867, 1.000]
最終結果: サンプル数=25 正答数=25 正答率=100.00% 95%信頼区間=[0.867, 1.000]
1 passed in 32.35s
n=20の時点では下限が0.839とわずかに閾値に届きませんでしたが、n=25で0.867まで上がり合格ラインを超えました。ルール追加前は同じn=25で上限が0.828まで下がって不合格が確定していたのに対し、追加後はn=25で下限が0.867まで上がって合格が確定しています。同じ25サンプルという条件で結果が逆転したという事実が、そのままプロンプト改善の効果を数値で裏付けています。
実行結果まとめ
| テスト | 結果 | 詳細 |
|---|---|---|
| 混同行列(16件) | 合格 | TP=6, FP=0, TN=10, FN=0(適合率100%, 再現率100%) |
| 信頼区間(ルール追加前) | 不合格 | 25サンプルで17勝、95%信頼区間[0.484, 0.828](上限が閾値85%を下回り不合格) |
| 信頼区間(ルール追加後) | 合格 | 25サンプルで25勝、95%信頼区間[0.867, 1.000](下限が閾値85%を上回り合格) |
混同行列のテストだけでは見抜けなかった不安定さを、同じプロンプトに対する繰り返し実行と信頼区間の計算によって検出できました。そしてプロンプトの修正が実際に効果があったことも、同じテストの再実行で確認できています。
実務への応用ポイント
実務でこの手法を使う際は、注意したい点が3つあります。
1. サンプル数と実行コストはトレードオフ
今回のように「様子を見ながら少しずつサンプルを追加し、信頼区間の幅で合否が決まり次第すぐ打ち切る」という方式なら、常に固定回数を実行するよりも無駄が少なくなります。良し悪しがはっきりした機能ほど、判定が早く終わるためです。ただしMAX_SAMPLESのような上限は必要です。今回はどちらも25サンプルで結論が出ましたが、真の正答率が閾値ぎりぎりのプロンプトでは、上限まで使い切っても信頼区間が閾値をまたいだまま、白黒つかずに終わることもあります。
2. モデルのバージョンによって結果は変わる
この記事の実行結果は、あくまで検証時点のGemini 3.1 Flash Liteによるものです。モデルの更新によって同じプロンプトでも精度は変わる可能性があるため、CIに組み込んで定期的に再実行するか、少なくともモデルを切り替えるタイミングでは同じテストを流し直す運用をおすすめします。
3. 正解ラベルの妥当性
混同行列と信頼区間はどちらも、「人間が最初に決めた正解ラベル」と実際の判定を突合します。正解ラベルの側が間違っていたり、意見が分かれる微妙なケースだったりすることもあります。その場合、テストが100%成功しても、それは「機能が正しく動いている」ことの証明にはなりません。
「5000円のiPhoneケースって高くないですか?」を予算なしと判定するのは、今回のテストデータセットにおける設計上の判断です。別のプロダクトでは「相場を聞いているなら参考価格として使いたい」という、別の正解があってもおかしくありません。テストケースを作る段階で、何を正解とするかの議論そのものが、機能の仕様を固める作業になります。
以前、Text-to-SQLの精度改善を実験した記事でも触れましたが、生成AI機能は「動いた」と「信頼できる」の間に大きな距離があります。混同行列と信頼区間は、その距離を埋めるための道具のひとつとして、手元に置いておいて損はないはずです。
【付録】本記事で使用したコード
dataset.py
from dataclasses import dataclass
@dataclass(frozen=True)
class TestCase:
prompt: str
category: str
expected_has_budget: bool
expected_min_yen: int | None = None
expected_max_yen: int | None = None
# category は集計・可視化のための分類ラベルで、判定ロジックには使わない。
# true_budget : 実際に予算(上限・下限)が書かれている素直なケース
# decoy_product_name : 金額が商品名・通称の一部になっている引っかけ
# decoy_capacity : 金額が収納力・容量などの比喩表現である引っかけ
# decoy_coin_unit : 金額が硬貨・紙幣の種類(〜円玉/〜円札)を指す引っかけ
# decoy_model_number : 金額のような数字が実は型式・系式番号である引っかけ
# decoy_opinion_question : 既存商品の価格への感想・意見を尋ねるだけの引っかけ
# no_price_control : そもそも金額の言及が一切ない制御群
# (true_budget 以外はすべて expected_has_budget=False)
TEST_CASES: list[TestCase] = [
TestCase("1万円以内のピンクトルマリンピアスはありますか", "true_budget", True, None, 10000),
TestCase("3000円から5000円くらいのワインを探しています", "true_budget", True, 3000, 5000),
TestCase("予算5万円で腕時計が欲しいです", "true_budget", True, None, 50000),
TestCase("3万円以上のスーツケースでおすすめはありますか", "true_budget", True, 30000, None),
TestCase("1000円くらいのお菓子の詰め合わせはありますか", "true_budget", True, None, 1000),
TestCase("2000円から3000円の間で誕生日プレゼントを探しています", "true_budget", True, 2000, 3000),
TestCase("5円チョコは売っている?", "decoy_product_name", False),
TestCase("百万円焼きかまはありますか", "decoy_product_name", False),
TestCase("1億円入るアタッシュケースを探しています", "decoy_capacity", False),
TestCase("500円玉貯金箱が欲しいです", "decoy_coin_unit", False),
TestCase("1000円札のデザインが可愛いポーチはありますか", "decoy_coin_unit", False),
TestCase("5000系新幹線の置物はありますか", "decoy_model_number", False),
TestCase("5000円のiPhoneケースって高くないですか?", "decoy_opinion_question", False),
TestCase("18金のネックレスを探しています", "no_price_control", False),
TestCase("子供へのお祝いに腕時計を探しています", "no_price_control", False),
TestCase("秋物のコートでおすすめはありますか", "no_price_control", False),
]
budget_extractor.py(全文)
import os
from dataclasses import dataclass
from google import genai
from google.genai import types
_PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "your-project-id")
_MODEL = "gemini-3.1-flash-lite"
_client = genai.Client(vertexai=True, project=_PROJECT_ID, location="global")
_SYSTEM_INSTRUCTION = """\
あなたは百貨店ECサイト「よろず百貨店」の検索アシスタントです。
ユーザーの入力文から、商品を探す際の「予算」(金額の上限・下限)を抽出してください。
判定のルール:
- 「予算」とは、ユーザーが支払ってもよいと考えている金額の範囲のことです。
- 文中に金額の言及があっても、それが以下のように予算ではなく商品自体の特徴や、
価格に対する意見を表している場合は、予算として扱わないでください
(has_budget は false、min_yen と max_yen は null)。
- 商品名やブランド名の一部になっている金額表現(金額を冠したユニークな商品名の菓子・雑貨は珍しくない)
- 商品のスペックや容量を表す比喩表現(収納力や大きさを金額の大小で例えているだけで、購入価格ではない)
- 硬貨や紙幣そのものを指す言葉(「〜円玉」「〜円札」は硬貨・紙幣の種類を指しており、価格ではない)
- 既存の商品価格に対する感想や意見(例:「5000円のiPhoneケースって高くないですか?」は、
5000円という価格が高いかどうかを尋ねているだけで、ユーザー自身がいくら払うつもりかは述べていない)
- 金額の言及が一切ない場合も、予算なし(has_budget: false)としてください。
- 「〜以内」「〜まで」「〜以下」は上限(max_yen)として扱ってください。
- 「〜から」「〜以上」は下限(min_yen)として扱ってください。
- 「〜くらい」「約〜」のようなおおよその金額は、その金額を上限(max_yen)として扱ってください。
- reasoning には、なぜその判定に至ったかを1文で日本語で記述してください。
"""
_BUDGET_RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"has_budget": {"type": "boolean"},
"min_yen": {"type": "integer", "nullable": True},
"max_yen": {"type": "integer", "nullable": True},
"reasoning": {"type": "string"},
},
"required": ["has_budget", "min_yen", "max_yen", "reasoning"],
}
@dataclass(frozen=True)
class BudgetExtraction:
has_budget: bool
min_yen: int | None
max_yen: int | None
reasoning: str
def extract_budget_range(prompt: str) -> BudgetExtraction:
response = _client.models.generate_content(
model=_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
system_instruction=_SYSTEM_INSTRUCTION,
response_mime_type="application/json",
response_schema=_BUDGET_RESPONSE_SCHEMA,
max_output_tokens=1024,
),
)
data = response.parsed or {}
return BudgetExtraction(
has_budget=data["has_budget"],
min_yen=data.get("min_yen"),
max_yen=data.get("max_yen"),
reasoning=data.get("reasoning", ""),
)
test_confusion_matrix.py(全文)
import unicodedata
import pytest
from budget_extractor import extract_budget_range
from dataset import TEST_CASES
def _display_width(text: str) -> int:
return sum(2 if unicodedata.east_asian_width(c) in "FWA" else 1 for c in text)
def _pad(text: str, width: int) -> str:
return text + " " * max(0, width - _display_width(text))
def classify(expected_has_budget: bool, predicted_has_budget: bool) -> str:
if expected_has_budget and predicted_has_budget:
return "TP"
if not expected_has_budget and predicted_has_budget:
return "FP"
if not expected_has_budget and not predicted_has_budget:
return "TN"
return "FN"
@pytest.fixture(scope="module")
def classification_results():
results = []
for case in TEST_CASES:
extraction = extract_budget_range(case.prompt)
label = classify(case.expected_has_budget, extraction.has_budget)
results.append((case, extraction, label))
return results
def test_print_confusion_matrix(classification_results):
counts = {"TP": 0, "FP": 0, "TN": 0, "FN": 0}
print()
print(f"{_pad('prompt', 54)}{'category':20s} {'expected':9s} {'predicted':9s} label")
for case, extraction, label in classification_results:
counts[label] += 1
print(
f"{_pad(case.prompt, 54)}{case.category:20s} "
f"{str(case.expected_has_budget):9s} {str(extraction.has_budget):9s} {label}"
)
print(f"\n混同行列: TP={counts['TP']} FP={counts['FP']} TN={counts['TN']} FN={counts['FN']}")
def test_recall_is_acceptable(classification_results):
tp = sum(1 for _, _, label in classification_results if label == "TP")
fn = sum(1 for _, _, label in classification_results if label == "FN")
recall = tp / (tp + fn) if (tp + fn) else 1.0
assert recall >= 0.8, f"再現率が低すぎます: {recall:.2f}(予算ありの見逃しが多い)"
def test_precision_is_acceptable(classification_results):
tp = sum(1 for _, _, label in classification_results if label == "TP")
fp = sum(1 for _, _, label in classification_results if label == "FP")
precision = tp / (tp + fp) if (tp + fp) else 1.0
assert precision >= 0.8, f"適合率が低すぎます: {precision:.2f}(予算ではないものを予算と誤認しがち)"
def test_decoy_cases_are_not_false_positives(classification_results):
decoy_labels = [
(case, label) for case, _, label in classification_results
if case.category.startswith("decoy")
]
false_positives = [case.prompt for case, label in decoy_labels if label == "FP"]
assert not false_positives, f"引っかけケースを予算ありと誤判定しました: {false_positives}"
confidence_interval.py(全文)
import math
_Z_95 = 1.959963985
def wilson_score_interval(successes: int, n: int) -> tuple[float, float]:
if n == 0:
return (0.0, 1.0)
z = _Z_95
phat = successes / n
denominator = 1 + z**2 / n
center = phat + z**2 / (2 * n)
margin = z * math.sqrt(phat * (1 - phat) / n + z**2 / (4 * n**2))
lower = (center - margin) / denominator
upper = (center + margin) / denominator
return (max(0.0, lower), min(1.0, upper))
test_confidence_interval.py(全文)
from budget_extractor import extract_budget_range
from confidence_interval import wilson_score_interval
TARGET_PROMPT = "5000円のiPhoneケースって高くないですか?"
EXPECTED_HAS_BUDGET = False
BATCH_SIZE = 5
MAX_SAMPLES = 30
LOWER_BOUND_THRESHOLD = 0.85
UPPER_BOUND_THRESHOLD = 0.85
def _sample_batch(n: int) -> int:
successes = 0
for _ in range(n):
extraction = extract_budget_range(TARGET_PROMPT)
if extraction.has_budget == EXPECTED_HAS_BUDGET:
successes += 1
return successes
def _evaluate_recursively(successes: int, total: int) -> tuple[bool, int, int, float, float]:
lower, upper = wilson_score_interval(successes, total)
print(f" n={total:2d} 正答数={successes:2d} 95%信頼区間=[{lower:.3f}, {upper:.3f}]")
if lower >= LOWER_BOUND_THRESHOLD:
return True, successes, total, lower, upper
if upper < UPPER_BOUND_THRESHOLD or total >= MAX_SAMPLES:
return False, successes, total, lower, upper
successes += _sample_batch(BATCH_SIZE)
total += BATCH_SIZE
return _evaluate_recursively(successes, total)
def test_confidence_interval_recursive_sampling():
print()
passed, successes, total, lower, upper = _evaluate_recursively(0, 0)
print(
f"\n最終結果: サンプル数={total} 正答数={successes} "
f"正答率={successes / total:.2%} 95%信頼区間=[{lower:.3f}, {upper:.3f}]"
)
assert passed, (
f"信頼区間の下限が閾値{LOWER_BOUND_THRESHOLD:.0%}を"
f"サンプル数{total}(上限{MAX_SAMPLES})以内で超えられませんでした"
)
-
チロルチョコ株式会社が販売する、5円玉の形をしたチョコレート「ごえんがあるよ」の商品ページです。本文中の「5円チョコ」はこの商品を指す通称として使っています。https://shop.tirol-choco.com/products/2503_tnpn_5en ↩
-
やおきんの実在するお菓子「百万円焼きかま」の商品ページです(販売元:たじまや)。https://www.tajimaya-cc.net/products/36498 ↩
-
標準正規分布で、平均から±1.959964σの範囲に収まる確率は95%です。この事実に基づく値です。Wikipedia「正規分布」の信頼区間の項を参照してください。https://ja.wikipedia.org/wiki/正規分布 ↩