分類結果のスプレッドシートを眺めていて、手が止まった瞬間がありました。全体の一致率は 92.3% と出ているのに、「動物」カテゴリだけを抜き出すと 71% しかない。しかもモデルは、その誤った 29% にも 0.9 前後の confidence を返していました。
自信たっぷりに間違えている。これがいちばん厄介な壊れ方でした。本番運用で最初に踏んだ落とし穴が、まさにここです。
個人開発で運用している壁紙アプリでは、新規追加ぶんの画像を Claude Vision で 30 カテゴリに自動分類しています。基礎的な組み立てについては壁紙アプリの一括カテゴリ分類を Claude Vision API で自動化した話 に書きましたが、そこから運用を続けるうちに見えてきたのが、この「全体は良いのに一部が崩れる」現象でした。
本稿は、その後始末の記録です。confidence をクラスごとに較正し、閾値に届かない画像を棄却して人手キューへ回すまでを扱います。
「信頼度 0.9」は確率ではない
まず前提を揃えておきます。構造化出力で confidence: 0.92 のような数値を返させたとき、それは確率ではありません。モデルが「それらしい数値」を生成しているだけです。
呼び方 実体 確率として使えるか
自己申告 confidence モデルが文字列として生成した数値 そのままでは不可
logprobs 由来の確率 トークン生成確率 較正すれば近似可能
較正済みスコア 検証セットで実測した経験的精度に対応づけた値 可
使えないという意味ではありません。自己申告 confidence は順序としては有意味 であることが多いのです。0.95 の予測は 0.65 の予測より当たりやすい。ただし「0.9 なら 90% 正しい」という読み方が成り立たないだけです。
ならば、順序性だけを信じて、「どこで線を引けば目標 precision に届くか」を検証セットから実測すればよい。それが較正の実務です。
検証セットは 400 枚で足りるのか
私が用意したのは、手元でラベルを付け直した 412 枚です。30 カテゴリのうち、出現頻度の高い上位 8 カテゴリで 5 割強を占め、末端の「レトロ」「ミニマル」などは 5〜9 枚しかありません。
ここで一度、素朴な疑問にぶつかりました。1 クラス 6 枚でしきい値を決めて意味があるのか、と。
結論としては、意味はあるが、決め方を変える必要がある というのが私の理解です。枚数が少ないクラスでは、しきい値を細かく最適化すると検証セットに過適合します。そこで私は、クラスを枚数で二層に分けました。
層 検証枚数 しきい値の決め方 該当カテゴリ数
厚いクラス 20 枚以上 個別に探索 11
薄いクラス 20 枚未満 全体の保守側しきい値を共有(0.93) 19
薄いクラスは、そもそも自動採用に向いていません。保守的な線を共有し、落ちたものは人手に回す。それだけで十分です。
Before: 一律 0.80 で切っていた頃
最初の実装はこうでした。
# before.py — 一律しきい値
THRESHOLD = 0.80
def route (prediction: dict ) -> str :
"""prediction = {"category": "animal", "confidence": 0.87}"""
if prediction[ "confidence" ] >= THRESHOLD :
return "auto_accept"
return "manual_review"
このコードには、明示されていない仮定が二つ入っています。
一つは「confidence の意味がクラス間で共通」という仮定。もう一つは「precision を一つの数値で語ってよい」という仮定です。どちらも、実測してみると成り立っていませんでした。
手元の 412 枚で route() を通した結果が次の表です。auto_accept に回ったものだけを対象に precision を測っています。
カテゴリ auto_accept 件数 precision 誤りの内訳
自然 78 0.974 「風景」との混同 2 件
抽象 61 0.951 「幾何」との混同 3 件
都市 44 0.955 夜景を「夜空」へ 2 件
動物 31 0.742 ぬいぐるみ・イラストを実写動物へ 8 件
宇宙 18 0.833 抽象グラデーションを星雲へ 3 件
「動物」が沈んでいる理由は、あとから見れば単純でした。ぬいぐるみやイラスト調のキャラクターを、モデルは自信を持って「動物」と呼びます。人間の直観としても、それは半分正しい。カテゴリ定義のほうが曖昧だったのです。しきい値をいくら動かしても回避できない種類の誤りで、解決はプロンプト側にありました。
較正の前に、まずカテゴリ定義を締めるべきでした。プロンプト側で「実写の生物のみ。ぬいぐるみ・イラスト・キャラクターは除外」と明記したところ、それだけで「動物」の precision は 0.742 から 0.861 まで上がりました。
較正はプロンプトの不備を隠す道具ではありません。 定義の曖昧さは定義で直し、それでも残る誤りを較正で切る。順番を逆にすると、いつまでも動かないしきい値をいじり続けることになります。
After: クラス別しきい値を探索する
定義を締めたうえで、クラスごとに「目標 precision を満たす最小のしきい値」を求めます。最小、というのがポイントです。しきい値を上げれば precision は上がりますが、自動採用される件数(カバレッジ)が落ちて人手コストが増えます。目標を満たす範囲で、いちばん低い線を引きたい。
# calibrate.py — クラス別しきい値の探索
from collections import defaultdict
from dataclasses import dataclass
@dataclass
class Sample :
predicted: str
truth: str
confidence: float
MIN_SUPPORT = 20 # 厚いクラスの下限
FALLBACK_THRESHOLD = 0.93 # 薄いクラスの共有しきい値
GRID = [ round ( 0.50 + 0.01 * i, 2 ) for i in range ( 50 )] # 0.50〜0.99
def _precision_at (samples: list[Sample], t: float ) -> tuple[ float , int ]:
accepted = [s for s in samples if s.confidence >= t]
if not accepted:
return 0.0 , 0
hits = sum ( 1 for s in accepted if s.predicted == s.truth)
return hits / len (accepted), len (accepted)
def calibrate (
samples: list[Sample],
target_precision: float = 0.95 ,
min_coverage: int = 5 ,
) -> dict[ str , float ]:
"""予測クラスごとに、目標 precision を満たす最小しきい値を返す。"""
by_class: dict[ str , list[Sample]] = defaultdict( list )
for s in samples:
by_class[s.predicted].append(s)
thresholds: dict[ str , float ] = {}
for cls , group in by_class.items():
if len (group) < MIN_SUPPORT :
thresholds[ cls ] = FALLBACK_THRESHOLD
continue
chosen = None
for t in GRID : # 低い順に走査し、最初に条件を満たした点で止める
prec, n = _precision_at(group, t)
if n >= min_coverage and prec >= target_precision:
chosen = t
break
# どの点でも目標に届かないクラスは、自動採用を諦める
thresholds[ cls ] = chosen if chosen is not None else 1.01
return thresholds
1.01 を返している行が、このコードのいちばん大事な部分だと思っています。
どうしても目標 precision に届かないクラスが出たとき、しきい値を 0.99 に貼り付けて「ほぼ通らないから実質同じ」と済ませることもできます。しかし境界が浮動小数の運任せになり、後日 0.995 の予測がすり抜けます。到達不能を 1.01 という通過し得ない値 で表明しておけば、その意図がコードに残ります。
同じ発想は構造化出力の検証でも使っていて、詳細は構造化出力のスキーマ検証と修復ループ に書きました。曖昧な状態を「たまたま通らない値」で表現しない、という一点です。
較正後のしきい値は次のようになりました。
カテゴリ しきい値 precision カバレッジ(自動採用率)
自然 0.74 0.962 91%
抽象 0.81 0.953 84%
都市 0.79 0.958 86%
動物 0.94 0.955 52%
宇宙 0.93(薄いクラス) 0.944 44%
「自然」は 0.80 より低い線でも十分に精度が出る。「動物」は 0.94 まで上げないと目標に届かない。一律 0.80 は、前者では人手を無駄に使い、後者では誤りを通していたわけです。
全体では、自動採用率が 83% から 76% へ下がり、自動採用ぶんの precision が 0.918 から 0.957 へ上がりました。カバレッジを 7 ポイント差し出して、誤りをおよそ半分に減らした計算になります。
棄却をどこへ流すか
しきい値を下回った画像は manual_review に落ちます。ここを「あとで見る」フォルダにすると、確実に溜まって死にます。私自身、最初はそうしました。
いま採っている形は、棄却理由をそのまま作業指示に変換して、優先度つきのキューに積むというものです。
# route.py — 棄却理由つきルーティング
from typing import Literal, TypedDict
class Prediction ( TypedDict ):
category: str
confidence: float
class Decision ( TypedDict ):
action: Literal[ "auto_accept" , "manual_review" ]
reason: str
priority: int # 小さいほど先に処理する
def route (pred: Prediction, thresholds: dict[ str , float ]) -> Decision:
cls = pred[ "category" ]
t = thresholds.get( cls , FALLBACK_THRESHOLD )
c = pred[ "confidence" ]
if c >= t:
return { "action" : "auto_accept" , "reason" : "above_threshold" , "priority" : 9 }
if t > 1.0 :
# 到達不能クラス: 較正で救えない。定義の見直し対象
return { "action" : "manual_review" , "reason" : "class_not_calibratable" , "priority" : 1 }
if t - c <= 0.05 :
# 惜しい: 人が見れば数秒で決まる
return { "action" : "manual_review" , "reason" : "near_threshold" , "priority" : 2 }
return { "action" : "manual_review" , "reason" : "low_confidence" , "priority" : 5 }
priority: 1 に落ちたものは、分類作業ではなくカテゴリ定義の宿題 です。作業キューの中で混ぜてしまうと、「毎回このカテゴリで迷う」という信号が埋もれます。理由コードを分けておくと、週末に class_not_calibratable だけを抽出して、プロンプトの定義文を書き直す時間が取れます。
実運用では、near_threshold が全棄却の 38% を占めていました。ここは判断が速いので、まとめて処理すると 1 枚あたり 3〜4 秒で片づきます。優先度を分けるだけで、体感の負担がかなり変わりました。
較正は静かに腐る
いちばん見落としていたのが、しきい値は生鮮品だという事実です。
モデルを差し替えたとき、プロンプトの文言を一行直したとき、入稿画像の傾向が変わったとき。そのたびに confidence の分布はずれます。しきい値だけが古い分布のまま残ると、precision は静かに下がっていきます。
そこで、較正セットを固定したうえで、週次で同じ 412 枚を流し直しています。
# drift_check.py — 較正の劣化検知
def check_drift (
samples: list[Sample],
thresholds: dict[ str , float ],
target_precision: float = 0.95 ,
tolerance: float = 0.02 ,
) -> list[ str ]:
"""しきい値を固定したまま、現在の precision を測り直す。"""
alerts = []
by_class: dict[ str , list[Sample]] = defaultdict( list )
for s in samples:
by_class[s.predicted].append(s)
for cls , group in by_class.items():
t = thresholds.get( cls , FALLBACK_THRESHOLD )
prec, n = _precision_at(group, t)
if n == 0 :
alerts.append( f " { cls } : 自動採用が 0 件。しきい値が高すぎる可能性" )
continue
if prec < target_precision - tolerance:
alerts.append(
f " { cls } : precision { prec :.3f } < { target_precision - tolerance :.3f } "
f "(n= { n } , threshold= { t } ) → 再較正が必要"
)
return alerts
再較正を自動で走らせない、というのが私の判断です。しきい値が自動で動くと、精度が落ちた原因(モデル変更なのか、入稿傾向の変化なのか)を追う機会を失います。アラートだけを出して、原因を確かめてから手で較正し直す。この一手間が、あとで効いてきます。
固定した検証セットを回帰テストとして扱う考え方そのものは、プロンプトのゴールデンデータセットによる回帰テスト やLLM 評価パイプライン と地続きです。分類の較正も、評価の一種として同じ棚に置いたほうが管理しやすいと感じています。
実際、モデルを更新した週に「宇宙」で precision 0.911 のアラートが出ました。調べると、モデルがグラデーション壁紙をより積極的に「宇宙」と呼ぶようになっており、confidence の分布が全体に 0.03 ほど高い側へ寄っていました。しきい値を 0.93 から 0.96 へ引き上げて収束しています。アラートがなければ、数百枚が誤ったカテゴリで公開されていたはずです。
この設計を採るかどうかの判断基準
正直に書くと、この手間が見合わない場面もあります。
状況 判断 理由
クラス数が 5 以下・誤りの代償が小さい 一律しきい値で十分 較正の運用コストが上回る
クラス間で precision の差が 0.1 以上 クラス別較正を推奨します 一律だと必ずどちらかを損なう
誤りが利用者に直接見える 棄却ルーティングを併用 カバレッジより precision を優先すべき
モデルやプロンプトを頻繁に変える 劣化検知を必須に しきい値が最も早く陳腐化する
私の場合、壁紙のカテゴリは利用者が直接目にするものです。「動物」を開いて抽象画が並んでいたら、それはアプリの信用に関わります。だから、カバレッジを 7 ポイント差し出す判断に迷いはありませんでした。逆に、内部の分析用タグであれば、この場合は一律しきい値のまま放っておいたと思います。誤りの代償が小さい領域に運用コストを積むのは、私は割に合わないと考えています。
次に手を付けるなら
しきい値を一つ決めるという行為は、精度と手間のあいだにどこで線を引くかを言語化する作業でした。数字を眺めているうちに、自分がどのカテゴリの誤りを許せて、どれを許せないのかが、はっきりしてきます。
もし同じ問題を抱えているなら、まず 200〜400 枚でよいので、予測・正解・confidence の三列を持つ検証セットを作ってみてください。そのうえで _precision_at() をクラスごとに描いてみる。一律しきい値がどのクラスを犠牲にしていたかは、たいてい一目で見えます。
較正は、モデルを賢くする手法ではありません。モデルの癖を測って、こちらの意思決定に翻訳する手法です。そう捉え直してから、私はこの作業を面倒だと感じなくなりました。
お読みいただきありがとうございました。手元の検証セットで、思いがけないクラスが沈んでいるのを見つけたときは、ぜひプロンプトの定義文から疑ってみてください。