0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIの自動仕分けで誤答は消えない:確信度しきい値で人手に回す線引きの決め方

0
Posted at

問い合わせメールを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〜数百件あれば、上のコードがしきい値ごとの誤答率を出してくれます。あとは許容できる誤答率を現場や決裁者と握って、そこからしきい値を逆算するだけ。この一往復をやっておくと、「で、間違えたらどうするの?」と聞かれたときに、数字で線を引いて見せられます。自動化の話が前に進むかどうかは、たいていこの一問で決まります。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?