週末ラボ3: ベンダーの『精度95%』を確かめる
所要時間: 約4時間(2日に分けても可) / 難易度: Pythonスクリプトを動かせれば可 / 費用: 0円
動作確認: 2026-10-18。集計スクリプトは Python 3.12・scikit-learn 1.9.1 で動作確認済み。区間の数値は statsmodels 0.14.5 の
proportion_confint(method="wilson")と照合済み。任意のclassify_ollama.pyは、Ollama の応答をダミーに置き換えて動作を確認しました(実モデルでの実行は未確認)。
このラボのゴール
「このAIは精度95%です」というベンダーの主張を、自社の業務に近いデータ50件(まずはダミー。実データは下の条件を満たす場合のみ)で確かめる小さな評価を設計し、実際に集計します。題材は「メールのフィッシング判定機能」です(問い合わせの自動分類、請求書の不正検知など、2択の判定なら同じ手順で使えます)。
終わったあと、顧客に次のことを説明できるようになります。
- 「精度」には正解率・適合率・再現率・F1など複数の意味があり、どれを、どんなデータで測ったかを聞かないと比べられない。
- 見つけたいもの(フィッシング)が少ないデータでは、全部「正常」と答えるだけで正解率が高く出る。だから正解率だけでは判断できない。
- 見逃し(再現率が低い)と誤検知(適合率が低い)のどちらが業務で痛いかを、先に決めておく必要がある。
- 50件程度の試験では結果に大きな幅がある。45/50=90%という結果は、真の正解率が79〜96%程度のどれであっても不自然ではない、という程度の情報しか持たず(Wilson法95%区間)、95%という主張を否定も肯定もできないことがある。
- 同じ入力でも結果が変わることがあるので、繰り返して確かめる。
用語(2択の判定、陽性=フィッシング)
| 用語 | 意味 | 式 |
|---|---|---|
| TP(真陽性) | フィッシングを、フィッシングと判定 | |
| FN(偽陰性)= 見逃し | フィッシングを、正常と判定 | |
| FP(偽陽性)= 誤検知 | 正常を、フィッシングと判定 | |
| TN(真陰性) | 正常を、正常と判定 | |
| 正解率 accuracy | 全体のうち判定が合っていた割合 | (TP+TN) / 全件 |
| 適合率 precision | 「フィッシング」と判定したもののうち、本当にフィッシングだった割合 | TP / (TP+FP) |
| 再現率 recall | 本当のフィッシングのうち、拾えた割合 | TP / (TP+FN) |
| F1 | 適合率と再現率の調和平均 | 2×適合率×再現率 / (適合率+再現率) |
| 混同行列 | 行=正解、列=判定 として4つの件数を並べた表(scikit-learn の並び) |
前提
- Windows 11、PowerShell、Python 3.12(ラボ1の「0. 準備」(1)を参照)
- 試験に使ってよいAI機能(ベンダー製品の試用版、自社で契約中のサービス等)。無い場合は、任意の手順で Ollama のローカルモデルを「代役」にできます(ラボ1の「0. 準備」(2)が必要)
- 判定対象にするダミーまたは公開データ50件(下の「安全上の注意」参照)
手順
0. 準備(約15分)
mkdir C:\lab\vendor
cd C:\lab\vendor
py -V:3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install scikit-learn==1.9.1
$env:PYTHONUTF8 = "1" # 日本語のメッセージが文字化けしないように(このウィンドウの中だけ有効)
PowerShell のウィンドウを開き直したときは、$env:PYTHONUTF8 = "1" をもう一度実行してから作業します。
1. 主張を1行に書き、ベンダーへの質問を作る(約30分)
まず主張をそのまま書き写します(例: 「当社のAIメールフィルタはフィッシングを精度95%で検知します」)。次に、下の質問リストから答えが書かれていないものに印を付けます。これがベンダーへの質問状になります。
| # | 質問 | なぜ聞くか |
|---|---|---|
| 1 | 「精度」は正解率・適合率・再現率・F1のどれですか | 指標が違えば同じ95%でも意味がまったく違う |
| 2 | 何件のデータで、そのうち陽性(フィッシング)は何件でしたか | 件数が少ないと幅が大きい。陽性が少ないと正解率は高く出やすい |
| 3 | データはいつ・どこ(業種、言語、日本語メールを含むか)のものですか | 自社の環境と違うデータの結果はそのまま当てはまらない |
| 4 | 評価に使ったデータは、学習や調整に使ったデータと分けていますか | 分けていないと成績が実力以上に出る(データリーク) |
| 5 | 公開ベンチマークの場合、そのデータがモデルの学習に含まれていない根拠は | 含まれていると点数が不正確になる(ベンチマーク汚染) |
| 6 | 混同行列(見逃しと誤検知の件数)を見せてもらえますか | どちらの誤りが多いかで業務への影響が変わる |
| 7 | しきい値や設定は、私たちが使う設定と同じですか | 設定で適合率と再現率のバランスが変わる |
| 8 | 繰り返し測ったときのばらつきや、区間は示せますか | 1回の結果だけでは偶然の幅が分からない |
| 9 | 第三者による評価ですか、自社評価ですか | 評価の独立性 |
| 10 | モデルの更新時に再評価・通知はありますか。自社データで試験してよいですか(規約、データの保存・学習利用の有無) | 導入後に性能が変わることがある。試験自体が規約違反にならないように |
質問は「主張を疑う」ためではなく「自社の条件に当てはまるかを確かめる」ために送ります。回答が無い項目は「不明」と記録し、「虚偽」とは書きません。自分の試験結果が95%を下回っても、データの種類・陽性の比率・設定の違いで説明できることが多いので、まず条件の差をベンダーと確認します。
2. 評価を設計する(約20分)
AIにかける前に、次を決めて書いておきます(結果を見てから基準を変えないため)。
- 陽性の定義: 何をフィッシングとみなすか(例: 偽のログイン画面へ誘導する/送金・認証情報を求める)。迷う例の扱いも決める
- 件数と内訳: 50件。実際の受信メールの比率に近づけるのが原則です。ただし陽性が少なすぎると再現率が測れないため、このラボでは陽性10件・陰性40件にします(この比率は報告書に必ず書く)。注意: 再現率と誤検知率(FP÷陰性件数)は陽性の比率に左右されにくいが、正解率と適合率は比率で大きく変わる。例: 再現率90%・誤検知率5%のAIでも、実際の受信メールのフィッシングが1%なら適合率は 0.9×1% ÷(0.9×1% + 5%×99%)≒ 約15%(警告の6〜7件に1件しか本物でない)。陽性20%で測った正解率・適合率を、そのまま実運用やベンダーの数値と比べない。
- どちらの誤りが痛いか: 見逃し(FN)と誤検知(FP)のどちらを重く見るか
- 合格ライン: 例「再現率80%以上、かつ誤検知は1日あたり許容できる件数以内」
3. 正解ラベル付きのサンプルを作る(約60分)
samples.csv を作ります(Excelで作って「CSV UTF-8」で保存)。正解ラベル(truth)はAIの結果を見る前に付けます。
id,text,truth
M01,【重要】アカウントが停止されます。24時間以内にこちらから確認してください http://login.example-verify.example,phishing
M02,来週の定例会議の資料を共有フォルダに置きました。,normal
- 本文は、自分宛ての迷惑メール・注意喚起サイトに公開されている事例を参考に自分で書き直したダミーか、社外秘でないもの
- できればラベル付けを2回(時間をおいて、または別の人と)行い、食い違った件を記録します。人の判断も100%ではありません
4. AI機能にかける(3回ずつ)(約60分。手作業なら150回の入力になるため、API/一括判定機能があれば使う)
各メールを対象のAI機能に3回ずつかけ、結果を labeled.csv に記録します。列は id, truth, run1, run2, run3 です。
id,truth,run1,run2,run3
M01,phishing,phishing,phishing,phishing
M02,normal,normal,phishing,normal
(任意)AI機能が手元に無いとき: Ollama のローカルモデル(gemma3:4b)を代役にできます。次の classify_ollama.py を保存して実行します。
.\.venv\Scripts\python.exe classify_ollama.py samples.csv labeled.csv
温度(temperature)0では3回ともほぼ同じ判定になります。ここでは揺れを観察するため0.7にしています。実際の製品の設定(温度・しきい値)はベンダーに確認してください(質問7)。
"""(任意)手元に試せるAI機能が無いときの代役: Ollama のローカルモデルに判定させ、3回分を記録する
使い方: python classify_ollama.py samples.csv labeled.csv
samples.csv の列: id, text, truth → labeled.csv の列: id, truth, run1, run2, run3
"""
import csv
import json
import sys
import urllib.request
MODEL = "gemma3:4b"
PROMPT = ("次のメールがフィッシング(偽のログイン画面への誘導、送金や認証情報の要求など)なら phishing、"
"そうでなければ normal とだけ答えてください。\n\nメール:\n")
def classify(text):
payload = {"model": MODEL, "stream": False, "options": {"temperature": 0.7}, # 0 にすると毎回ほぼ同じ答えになり、揺れを観察できない
"messages": [{"role": "user", "content": PROMPT + text}]}
req = urllib.request.Request("http://localhost:11434/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as res:
answer = json.loads(res.read().decode("utf-8"))["message"]["content"].strip().strip("*`'\" ").lower()
for label in ("phishing", "normal"):
if answer.startswith(label):
return label
return "unknown" # 指示どおりに答えなかった。件数を数えて報告する
src, dst = sys.argv[1], sys.argv[2]
with open(src, encoding="utf-8-sig", newline="") as f:
rows = list(csv.DictReader(f))
with open(dst, "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=["id", "truth", "run1", "run2", "run3"])
w.writeheader()
for r in rows:
runs = [classify(r["text"]) for _ in range(3)]
w.writerow({"id": r["id"], "truth": r["truth"], "run1": runs[0], "run2": runs[1], "run3": runs[2]})
print(r["id"], r["truth"], runs)
5. 集計する(約20分)
次の vendor_check.py を保存します。まず架空データ(--demo)で出力の読み方を確認し、それから自分のデータで実行します。
.\.venv\Scripts\python.exe vendor_check.py --demo
.\.venv\Scripts\python.exe vendor_check.py --csv labeled.csv --pos phishing --neg normal
vendor_check.py
"""週末ラボ3: ベンダーの『精度95%』を確かめる(集計スクリプト)
使い方(PowerShell):
python vendor_check.py --demo # 練習用の架空データで動作確認
python vendor_check.py --csv labeled.csv --pos phishing # 自分のデータで集計
labeled.csv の列: id, truth, run1[, run2, run3]
truth = 人が付けた正解ラベル / runN = AI機能の判定(同じ入力をN回目に流した結果)
"""
import argparse
import csv
import math
from sklearn.metrics import classification_report, confusion_matrix
def wilson(x, n, z=1.96):
"""二項割合の95%区間(Wilson法)。n が小さいほど幅が広がる"""
if n == 0:
return (float("nan"), float("nan"))
p = x / n
denom = 1 + z * z / n
center = (p + z * z / (2 * n)) / denom
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
return (max(0.0, center - half), min(1.0, center + half))
def fmt(x, n):
if n == 0:
return f"{x}/{n} = 計算できない(分母0)"
lo, hi = wilson(x, n)
return f"{x}/{n} = {x / n:.1%} (95%区間 {lo:.1%}〜{hi:.1%})"
def demo_rows():
"""架空の例: 50通のメールのうち本物のフィッシングは10通"""
rows = []
truth = ["phishing"] * 10 + ["normal"] * 40
run1 = ["phishing"] * 6 + ["normal"] * 4 + ["phishing"] * 2 + ["normal"] * 38
run2 = ["phishing"] * 7 + ["normal"] * 3 + ["phishing"] * 2 + ["normal"] * 38
run3 = run1[:]
for i, t in enumerate(truth):
rows.append({"id": f"M{i + 1:02d}", "truth": t, "run1": run1[i], "run2": run2[i], "run3": run3[i]})
return rows
def report(y_true, y_pred, pos, neg, title):
tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[neg, pos]).ravel().tolist()
n = tn + fp + fn + tp
print(f"\n=== {title} ===")
print("混同行列(行=正解, 列=AIの判定)")
print(f"{'':>14}{'判定:' + neg:>14}{'判定:' + pos:>14}")
print(f"{'正解:' + neg:>14}{tn:>14}{fp:>14}")
print(f"{'正解:' + pos:>14}{fn:>14}{tp:>14}")
print(f"正解率 accuracy : {fmt(tp + tn, n)}")
print(f"適合率 precision : {fmt(tp, tp + fp)} ← AIが『{pos}』と言ったうち本当に{pos}")
print(f"再現率 recall : {fmt(tp, tp + fn)} ← 本当の{pos}のうちAIが拾えた割合")
prec = tp / (tp + fp) if tp + fp else 0.0
rec = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
print(f"F1 : {f1:.3f}")
print(f"参考: 全部『{neg}』と答えるだけの正解率 = {fmt(tn + fp, n)}")
# scikit-learn の結果と突き合わせ(手計算と一致するはず)
print(classification_report(y_true, y_pred, labels=[neg, pos], digits=3, zero_division=0))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--csv")
ap.add_argument("--pos", default="phishing", help="陽性(見つけたい側)のラベル")
ap.add_argument("--neg", default="normal", help="陰性のラベル")
ap.add_argument("--demo", action="store_true")
args = ap.parse_args()
if args.demo:
rows = demo_rows()
else:
with open(args.csv, encoding="utf-8-sig", newline="") as f:
rows = list(csv.DictReader(f))
runs = [c for c in ("run1", "run2", "run3") if c in rows[0] and rows[0][c]]
y_true = [r["truth"].strip() for r in rows]
bad = [r["id"] for r in rows if r["truth"].strip() not in (args.pos, args.neg)]
if bad:
raise SystemExit(f"正解ラベル(truth)が {args.pos}/{args.neg} 以外の行があります: {bad}。表記(大文字小文字・空白・空欄)を直してから再実行してください")
pos_n = sum(1 for t in y_true if t == args.pos)
print(f"件数 {len(rows)} / うち{args.pos} {pos_n} 件 ({pos_n / len(rows):.0%})")
for run in runs:
preds = [r[run].strip() for r in rows]
odd = sum(1 for p in preds if p not in (args.pos, args.neg))
if odd:
print(f"\n注意: {run} に {args.pos}/{args.neg} 以外の判定が {odd} 件あり、{args.neg} として数えます")
preds = [p if p in (args.pos, args.neg) else args.neg for p in preds]
report(y_true, preds, args.pos, args.neg, f"{run} の結果")
if len(runs) > 1:
same = sum(1 for r in rows if len({r[c].strip() for c in runs}) == 1)
print(f"\n=== 繰り返しの一致 ===\n{len(runs)}回すべて同じ判定: {fmt(same, len(rows))}")
for r in rows:
if len({r[c].strip() for c in runs}) > 1:
print(f" 判定が揺れた: {r['id']} 正解={r['truth']} " + " / ".join(r[c] for c in runs))
if __name__ == "__main__":
main()
--demo の出力(1回目の部分。架空データ: 50件中フィッシング10件):
件数 50 / うちphishing 10 件 (20%)
=== run1 の結果 ===
混同行列(行=正解, 列=AIの判定)
判定:normal 判定:phishing
正解:normal 38 2
正解:phishing 4 6
正解率 accuracy : 44/50 = 88.0% (95%区間 76.2%〜94.4%)
適合率 precision : 6/8 = 75.0% (95%区間 40.9%〜92.9%) ← AIが『phishing』と言ったうち本当にphishing
再現率 recall : 6/10 = 60.0% (95%区間 31.3%〜83.2%) ← 本当のphishingのうちAIが拾えた割合
F1 : 0.667
参考: 全部『normal』と答えるだけの正解率 = 40/50 = 80.0% (95%区間 67.0%〜88.8%)
読み方:
- 正解率88%は一見よさそうですが、**何も検知しない仕組みでも80%**になります
- 本当のフィッシング10件のうち4件を見逃しています(再現率60%)
--demoの2回目では1件だけ判定が変わり、再現率が70%になります。同じデータでも回によって数字が動きます
6. 小さな標本で言えること・言えないことを計算する(約20分)
このラボではWilson法で95%区間を計算しています(式は vendor_check.py の wilson())。割合(○件中△件)の区間を求める代表的な方法の一つで、50/50 のような全問正解でも区間が0〜100%の範囲に収まります(よく知られた「±1.96×標準誤差」の近似は、全問正解だと幅が0になってしまいます)。
計算例(Wilson法、statsmodels で照合済み):
| 結果 | 点推定 | 95%区間 | 言えること |
|---|---|---|---|
| 正解率 45/50 | 90.0% | 78.6%〜95.7% | 95%は区間の中。「95%は嘘だ」とも「95%を確認した」とも言えない |
| 正解率 50/50 | 100% | 92.9%〜100% | 全問正解でも、「95%以上」とまでは言い切れない |
| 再現率 6/10 | 60.0% | 31.3%〜83.2% | 陽性が10件しかないと、再現率の幅は約50ポイントにもなる |
| 正解率 95/100 | 95.0% | 88.8%〜97.8% | 100件でも幅は約9ポイント |
参考: 真の値が95%前後のとき、区間の半分の幅を±3ポイント程度に収めるには、正規近似で n ≒ 1.96² × 0.95 × 0.05 ÷ 0.03² ≒ 約200件 が必要です。50件の試験は「大きな問題が無いかを見る」ためのもので、「95%かどうかを決める」には足りません。
※3回分を合わせて150件と数えてはいけません。同じ50通を繰り返しただけなので、区間を狭める根拠にはなりません。また約200件は正規近似の目安で、Wilson法では190/200 → 91.0%〜97.3%のように上下非対称になります。
別の方法として Clopper-Pearson 法(statsmodels では
method="beta")があり、こちらは保守的(幅が広め)になります。例: 45/50 → 78.2%〜96.7%。どちらの方法を使ったかを報告書に書いてください。
7. 結果をまとめる(約20分)
# AI機能の主張検証メモ
- 主張: 「________」(出典: 資料名・ページ)
- ベンダーへの質問と回答: (手順1の表。未回答の項目を明記)
- 試験条件: 件数50(陽性10/陰性40)、期間、データの出どころ、ラベル付けの方法、AI機能の版・設定、各3回
- 結果: 混同行列、正解率・適合率・再現率・F1(95%区間、Wilson法)、3回の一致率。※正解率・適合率は陽性20%での値であり、実運用の比率では変わる
- 判断: 合格ライン(手順2)に対して ___。50件では ___ までしか言えない
- 次の一手: 件数を増やす/実運用データで試行/ベンダーに混同行列を求める
観察ポイント
- 正解率と再現率の差はどれくらいか。「全部正常と答えるだけ」の正解率と比べてどうか
- 見逃し(FN)になったメールに共通点はあるか(短い、日本語が自然、URLが無い等)
- 3回の判定が揺れたのはどんなメールか。揺れるメールは、人のラベル付けでも迷ったものか
- 区間の幅は、ベンダーの主張(95%)と自分の結果の差より大きいか小さいか
- ベンダーの回答で、手順1の質問のうちいくつが埋まったか
ふりかえりシート
- 顧客が「精度95%なら十分ですね」と言ったら、最初にどの2つの質問を返しますか。
- あなたの業務では、見逃しと誤検知のどちらが痛いですか。それは適合率と再現率のどちらを重視することになりますか。
- 50件の結果から「導入してよい」と言えるのはどんな場合ですか。言えないのはどんな場合ですか。
- ベンダーが学習に使ったデータと、あなたの試験データが重なっていたら、結果にどう影響しますか。それを確かめるには何を聞けばよいですか。
- 試験データを200件に増やすとしたら、ラベル付けの手間と個人情報の扱いをどう解決しますか。
- 顧客に1〜2文で「このAIの『精度95%』が意味すること・意味しないこと」を説明してください(どの指標か、どんなデータか、陽性の比率、区間の幅のうち少なくとも2つに触れる)。
安全上の注意
- 規約を確認する: 試用版やSaaSの利用規約で、ベンチマーク・性能比較・負荷試験・結果の公表が禁止されていないか確認してから行う。不明ならベンダーに書面で確認する。
- 機密・個人情報を投入しない: 実際の受信メールを使うのは、社内(情報管理責任者・顧客)の承認を得た場合に限る。氏名・アドレス・電話番号・社名・URL内のIDなどを伏せる。外部のAI機能に送る場合は、個人情報保護法上の扱い(委託か第三者提供か、外国にある事業者か)、規約上のデータ保存期間・学習利用の有無・オプトアウトを確認し、記録に残す。試験後は samples.csv / labeled.csv を削除するか、保管場所を限定する。
- 本物のフィッシングURLを開かない・再配布しない。ダミーでは
.exampleのような例示用ドメイン(実在しない)を使う。 - 結果を断言しない: 件数・陽性の比率・区間・試験条件をセットで報告する。少数の試験結果を「性能保証」のように伝えない。
公式ドキュメント(確認日 2026-10-18)
- scikit-learn 1.9.1 Metrics and scoring(適合率・再現率・F1、不均衡データの注意): https://scikit-learn.org/stable/modules/model_evaluation.html
- scikit-learn
confusion_matrix(行=正解・列=予測、2値ではravel()で tn, fp, fn, tp): https://scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html - scikit-learn
classification_report: https://scikit-learn.org/stable/modules/generated/sklearn.metrics.classification_report.html - scikit-learn Common pitfalls(データリーク): https://scikit-learn.org/stable/common_pitfalls.html
- statsmodels
proportion_confint(wilson、beta=Clopper-Pearson): https://www.statsmodels.org/stable/generated/statsmodels.stats.proportion.proportion_confint.html - Anthropic: 成功基準と評価の作り方: https://platform.claude.com/docs/en/docs/test-and-evaluate/develop-tests
- Xu ほか 2024 Benchmark Data Contamination of LLMs: A Survey: https://arxiv.org/abs/2406.04244
- Ollama チャットAPI(任意手順): https://docs.ollama.com/api/chat
- (参考)promptfoo でも、テストケースと判定基準を書いて
--repeatで繰り返し評価できます: https://www.promptfoo.dev/docs/usage/command-line/
ふりかえりのメモ(この端末にだけ保存)