問い合わせメールをChatGPTで自動仕分けする仕組みを社内で回し始めて、最初に詰まったのがここでした。「精度95%です」と説明しても、必ず返ってくるのが「じゃあ残り5%は誰が責任取るの?」。ここが決まらないと、現場の自動化はGOが出ません。
ちょうど同じ問いを正面から書いた記事を見かけて、腑に落ちました。AIで自動化できます。で、そのリスクは誰が飲むんですか。正答率98%でも、誤答2%は消えない。その2%を誰が引き受けるのか、という話です。
僕の現場での答えはシンプルで、全部をAIに飲ませないことでした。自信のある案件だけ自動で処理して、怪しい案件は人手のキューに回す。確信度(confidence)でゲートをかける、ありふれた型です。絵にするとこうなります。
型としては普通です。詰まるのは「自信のある」をどう判定するか、ここ一点でした。
素朴な確信度ゲートと、その落とし穴
素朴にやるなら、ChatGPTにラベルと一緒に確信度を返させて、0.9以上なら自動、と決めます。Structured Outputs を使えば確信度を数値で固定して取れます。
from openai import OpenAI
import json
client = OpenAI() # OPENAI_API_KEY は環境変数で渡す
schema = {
"type": "object",
"properties": {
"urgency": {"type": "string", "enum": ["至急", "通常"]},
"confidence": {"type": "number"}, # モデルの自己申告(0〜1)
"reason": {"type": "string"},
},
"required": ["urgency", "confidence", "reason"],
"additionalProperties": False,
}
def classify(text):
r = client.chat.completions.create(
model="gpt-4.1-mini", # 構造化出力に対応した安価なモデル
messages=[
{"role": "system", "content": "問い合わせの緊急度を判定し、確信度(0〜1)も返す。"},
{"role": "user", "content": text},
],
response_format={
"type": "json_schema",
"json_schema": {"name": "triage", "schema": schema, "strict": True},
},
)
return json.loads(r.choices[0].message.content)
これで confidence の数字は安定して取れます。問題はその先です。モデルが返す confidence は、実際の正解率と一致しません。「0.9」と言っていても本当に9割当たるとは限らない。ここを信じてしきい値を決めると、足をすくわれます。
しきい値は手元の正解付きデータで決める
やることは1つです。正解が分かっているサンプルを用意して、しきい値ごとに「実際の自動誤答率」と「自動化率」を測る。許容できる誤答率から、必要なしきい値を逆算する。それだけです。
下のコードはそのまま動きます。ここでは説明用に擬似データで回していますが、実務では sample を自分の手元の正解付きデータ(1件 = 確信度と、その判定が正解だったか)に差し替えるだけです。
import random
# 1件 = (モデルの自己申告 confidence, その判定が実際に正解だったか)
random.seed(20260623)
N = 500
sample = []
for _ in range(N):
if random.random() < 0.72:
true_acc = random.uniform(0.95, 0.999) # 易しい案件
else:
true_acc = random.uniform(0.62, 0.86) # 紛らわしい案件
correct = random.random() < true_acc
# モデルは自信過剰: 実際の正解率より少し高い数字を出す癖がある
conf = min(0.99, max(0.50, true_acc + 0.06 + random.uniform(-0.03, 0.03)))
sample.append((round(conf, 2), correct))
def route(sample, threshold):
"""confidence が threshold 以上の案件だけ自動化したときの結果。"""
auto = [ok for conf, ok in sample if conf >= threshold]
n = len(auto)
wrong = sum(1 for ok in auto if not ok)
return n, n / len(sample), wrong, (wrong / n if n else 0.0)
# 1. 全件そのまま自動化したときの誤答率
base_err = sum(1 for _, ok in sample if not ok) / N
print(f"[全件そのまま自動化] 誤答率 {base_err*100:.1f}% N={N}\n")
# 2. 申告 confidence は実際の正解率と一致しない
print("[confidence の数字 ≠ 実際の正解率]")
for lo, hi in [(0.50, 0.85), (0.85, 0.90), (0.90, 0.95), (0.95, 1.01)]:
b = [ok for conf, ok in sample if lo <= conf < hi]
if b:
print(f" 申告 {lo:.2f}〜{min(hi,1.0):.2f} : {len(b):3d}件 実際の正解率 {sum(b)/len(b)*100:5.1f}%")
# 3. しきい値ごとの線引き
print("\n[しきい値ごとの線引き]")
print(f" {'しきい値':>5} {'自動化率':>6} {'自動誤答率':>7} {'人手へ':>5}")
for t in [0.80, 0.85, 0.90, 0.93, 0.95]:
n, cov, wrong, err = route(sample, t)
print(f" {t:5.2f} {cov*100:5.1f}% {err*100:6.1f}% {N-n:5d}")
# 4. 許容できる自動誤答率から、しきい値を逆算する
def pick_threshold(sample, target_err):
for t in [round(x / 100, 2) for x in range(50, 100)]:
n, cov, wrong, err = route(sample, t)
if n and err <= target_err:
return t, cov, err
return None
print("\n[許容自動誤答率 → 必要なしきい値]")
for target in [0.05, 0.03, 0.02]:
res = pick_threshold(sample, target)
if res:
t, cov, err = res
print(f" {target*100:.0f}%以内 → しきい値 {t:.2f}(自動化率 {cov*100:.0f}%, 実誤答 {err*100:.1f}%)")
else:
print(f" {target*100:.0f}%以内 → どのしきい値でも到達不可")
手元で実行すると、こう出ます。
[全件そのまま自動化] 誤答率 8.8% N=500
[confidence の数字 ≠ 実際の正解率]
申告 0.50〜0.85 : 92件 実際の正解率 66.3%
申告 0.85〜0.90 : 32件 実際の正解率 87.5%
申告 0.90〜0.95 : 16件 実際の正解率 93.8%
申告 0.95〜1.00 : 360件 実際の正解率 97.8%
[しきい値ごとの線引き]
しきい値 自動化率 自動誤答率 人手へ
0.80 85.6% 4.0% 72
0.85 81.6% 3.2% 92
0.90 75.2% 2.4% 124
0.93 73.0% 2.2% 135
0.95 72.0% 2.2% 140
[許容自動誤答率 → 必要なしきい値]
5%以内 → しきい値 0.78(自動化率 88%, 実誤答 4.5%)
3%以内 → しきい値 0.87(自動化率 79%, 実誤答 2.5%)
2%以内 → どのしきい値でも到達不可
読み方を順に書きます。全部そのまま自動化すると誤答8.8%。これは飲めない数字です。しきい値0.90で切ると、自動化率75%を保ったまま、自動側の誤答は2.4%まで下がる。代わりに124件が人手に回る。「誤答3%以内に抑えたい」と決めれば、必要なしきい値は0.87、自動化率は79%、と数字で返ってきます。勘で「とりあえず0.9」と置くのとは、根拠の重さが違います。
ここで地味に効くのが、申告confidence帯ごとの実際の正解率です。0.50〜0.85と言ってきた案件は、実際には66%しか当たっていない。一方でモデルは500件のうち360件を0.95以上に押し込んできます。確信度の数字をそのまま確率だと信じると、この偏りに気づけません。だから0.9という数字に意味を持たせず、自分のデータで測った実誤答率の方で線を引きます。
確信度ゲートだけでは届かない床がある
出力の最後をもう一度見てください。「誤答2%以内」は、どのしきい値でも到達不可、と出ています。これは実装のバグではなく、確信度ゲートの限界です。自信満々で外す案件が一定数残るからで、そういう案件はしきい値の高い側に紛れていて、切り落とせない。
つまり確信度ゲートには床があります。2%より下を本気で狙うなら、別の合図を足すしかない。僕がやったのは、判定ラベルと本文のキーワードが食い違う案件を別ルートで人手に回す方法でした。それでも足りなければ、人手レビューの比率を上げて自動化率の方を諦める。どこを諦めるか、というトレードオフを正面から選ぶ話になります。
もう1つ。この線引きは一度決めて終わりではありません。モデルを新しいバージョンに上げたり、入ってくる問い合わせの傾向が季節で変わったりすると、確信度と正解率の対応がズレます。サンプルは定期的に取り直して、しきい値を引き直す。ここをサボると、いつの間にか飲めないはずの誤答を、自動で垂れ流すことになります。
で、結局どう運用するか
「誤答2%は誰が飲むのか」への現場の答えは、しきい値の外に置いた案件として人手レビューが明示的に引き受ける、です。AIに全部を飲ませない。飲ませる範囲を、勘ではなく手元のデータの数字で決める。線引きの根拠を1枚の表で説明できる状態にしておく。
最初に用意するものも軽いです。正解付きのサンプルが100〜数百件あれば、上のコードがしきい値ごとの誤答率を出してくれます。あとは許容できる誤答率を現場や決裁者と握って、そこからしきい値を逆算するだけ。この一往復をやっておくと、「で、間違えたらどうするの?」と聞かれたときに、数字で線を引いて見せられます。自動化の話が前に進むかどうかは、たいていこの一問で決まります。