1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

分類器型ガードレール(Llama Guard 系)の閾値と誤検知の調整:P(unsafe) を取り出し、自分のデータで決める

1
Posted at

Llama Guard や ShieldGemma のような分類器型のガードレールは、入れるだけで「安全か、危険か」を返してくれます。ただ、そのまま使うと、自分のアプリでは正当な質問が止まりすぎたり、逆に止めたいものが通ったりします。原因の 1 つは、モデルが内部で持っている確率を捨てて、既定の判定(実質的に閾値 0.5)をそのまま使っていることです。

この記事では、分類器型ガードレールがどうやって判定しているかを整理し、生成テキストではなく確率を取り出して、自分のアプリのデータで閾値を決める手順を、小さな検証コードで確かめます。

ガードレールを入力・ツール呼び出し・出力のどこに置くかは別記事で扱ったので、ここでは「置いた分類器の判定をどう調整するか」に絞ります。

検証環境と前提

  • Python 3.13.18、標準ライブラリのみ
  • 分類器は呼び出していません。モデルが返す生成テキストと最初のトークンの対数確率、評価データの P(unsafe) はすべて固定値で与え、スコアの計算と閾値の決め方だけを確かめています
  • 評価データは説明用に作った 40 件です。件数が少ないので、出てくる数字は手順の説明のためのもので、特定のモデルの性能ではありません
  • 各モデルの仕様は 2026 年 10 月時点のモデルカード・論文で確認しました

分類器型ガードレールは「次のトークンの確率」で判定している

現在よく使われるオープンなガードレールモデルは、多くが LLM をベースにしたもので、判定を文字列として生成します。

モデル 出力 スコアの取り方 方針の変え方
Llama Guard 4(12B) safe または unsafe と違反カテゴリ(S1〜S14) 最初のトークンが unsafe になる確率 プロンプト内のカテゴリ定義
ShieldGemma(2B / 9B / 27B) 方針ごとに Yes / No Yes になる確率 方針(policy)の文章を差し替える
Qwen3Guard-Gen(0.6B / 4B / 8B) Safe / Controversial / Unsafe 3 値のラベル Controversial の扱い(strict / loose)
gpt-oss-safeguard(20b / 120b) 方針に対する判定と推論過程 推論結果のラベル 方針を文章で渡す

Llama Guard の最初の論文は、出力の最初のトークンの確率をそのまま分類器のスコアとして読める、と説明しています。評価にも閾値を固定しない指標である AUPRC(適合率-再現率曲線の下の面積)を使っています。ShieldGemma のモデルカードも、Yes / No の確率を出す使い方を「スコアリングモード」と呼び、評価結果を「最適な閾値での F1」と AU-PRC で示しています。つまり、どちらも「閾値は使う側が決める」前提で作られています。

一方で、ふつうに生成させて safe / unsafe の文字列を見るだけだと、safe と unsafe の 2 つしか候補がない場合、確率が高いほうが選ばれるので、閾値 0.5 で切っているのと同じになります。

Llama Guard 4 は 2025 年 4 月に公開された 12B のモデルで、テキストと画像を入力にとり、S1(暴力犯罪)から S14(コードインタプリタの悪用)までの 14 カテゴリで判定します。

生成テキストではなく P(unsafe) を取り出す

まず、Llama Guard 形式の出力を解析する関数と、最初のトークンの対数確率から P(unsafe) を求める関数を書きます。推論サーバーの多くは、生成時に上位トークンの対数確率(logprobs)を返せます。

guard_score.py
import math

def parse_llama_guard(text: str) -> tuple[bool, list[str]]:
    """Llama Guard の生成テキスト("safe" または "unsafe\\nS1,S10")を判定とカテゴリに分ける"""
    lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
    if not lines or lines[0] not in ("safe", "unsafe"):
        raise ValueError(f"想定外の出力: {text!r}")  # 形式外は「通す」にしない
    cats = lines[1].split(",") if lines[0] == "unsafe" and len(lines) > 1 else []
    return lines[0] == "unsafe", [c.strip() for c in cats]

def unsafe_score(first_token_logprobs: dict[str, float]) -> float:
    """最初のトークンの対数確率から P(unsafe) を求める(safe と unsafe の 2 つで正規化)"""
    p_safe = math.exp(first_token_logprobs.get("safe", -math.inf))
    p_unsafe = math.exp(first_token_logprobs.get("unsafe", -math.inf))
    if p_safe + p_unsafe == 0:
        raise ValueError("safe / unsafe のどちらも上位候補にない")
    return p_unsafe / (p_safe + p_unsafe)

# モデルの出力は固定値で与える(実際は推論サーバーの logprobs から取る)
samples = [
    ("XSS の対策を教えて",               "safe",       {"safe": -0.02, "unsafe": -3.9}),
    ("社内検証環境で SQLi を再現したい",  "safe",       {"safe": -0.51, "unsafe": -0.92}),
    ("(有害な依頼の例)",                "unsafe\nS2", {"safe": -2.3,  "unsafe": -0.11}),
]
for text, out, lp in samples:
    flagged, cats = parse_llama_guard(out)
    print(f"{text} | 生成={out.splitlines()[0]} cats={cats} P(unsafe)={unsafe_score(lp):.2f}")

try:
    parse_llama_guard("I cannot help with that.")
except ValueError as e:
    print("例外:", e)
実行結果
XSS の対策を教えて | 生成=safe cats=[] P(unsafe)=0.02
社内検証環境で SQLi を再現したい | 生成=safe cats=[] P(unsafe)=0.40
(有害な依頼の例) | 生成=unsafe cats=['S2'] P(unsafe)=0.90
例外: 想定外の出力: 'I cannot help with that.'

2 件目に注目してください。生成テキストは safe なので、文字列だけを見ていれば通ります。しかし P(unsafe) は 0.40 あり、確率を見れば「かなり迷った判定」だとわかります。閾値を 0.4 に下げるなら止まる入力です。

実装上の注意が 2 つあります。

  • 想定外の出力を「安全」にしない: ガードレールモデルが拒否文や空文字を返すことがあります。形式に合わない出力を safe 扱いにすると、検査が素通りする経路になります。例外にして、上位で「止める」か「保留」に倒します
  • 2 つのトークンで正規化する: 上位候補に safe / unsafe 以外のトークンが入ることもあるので、2 つの確率の比で P(unsafe) を求めます。どちらも上位にない場合は判定不能として扱います

自分のアプリのデータで閾値を決める

ここからは、セキュリティ担当者向けの社内チャットにガードレールを置く場面を想定します。この用途では「XSS の検証方法」「社内環境での脆弱性の再現手順」のような正当な質問が多く、汎用の分類器はこれを危険寄りに判定しがちです。

評価データは 3 種類に分けました。

  • general: 一般的な質問(通すべき)
  • security: セキュリティ業務の正当な質問(通すべき)
  • attack: 止めるべき依頼

全体を、閾値を決める dev と、決めた閾値を確かめる test に分けています。

threshold.py
from dataclasses import dataclass

@dataclass
class Item:
    split: str    # dev(閾値を決める)/ test(決めた閾値を確かめる)
    segment: str  # general / security(セキュリティ業務の正当な質問)/ attack
    label: int    # 1 = 止めるべき, 0 = 通すべき(人手ラベル)
    score: float  # 分類器の P(unsafe)。固定値で与える

def items(split, segment, label, scores):
    return [Item(split, segment, label, s) for s in scores]

DATA = (
    items("dev",  "general",  0, [0.01, 0.02, 0.03, 0.05, 0.08, 0.12])
  + items("dev",  "security", 0, [0.18, 0.31, 0.44, 0.52, 0.58, 0.66])
  + items("dev",  "attack",   1, [0.38, 0.47, 0.55, 0.71, 0.83, 0.90, 0.95, 0.98])
  + items("test", "general",  0, [0.01, 0.02, 0.04, 0.06, 0.09, 0.15])
  + items("test", "security", 0, [0.22, 0.35, 0.41, 0.49, 0.61, 0.72])
  + items("test", "attack",   1, [0.33, 0.51, 0.57, 0.64, 0.79, 0.88, 0.93, 0.97])
)

def metrics(rows, th):
    tp = sum(r.label == 1 and r.score >= th for r in rows)
    fn = sum(r.label == 1 and r.score <  th for r in rows)
    fp = sum(r.label == 0 and r.score >= th for r in rows)
    tn = sum(r.label == 0 and r.score <  th for r in rows)
    recall = tp / (tp + fn)
    fpr = fp / (fp + tn)
    precision = tp / (tp + fp) if tp + fp else 1.0
    return recall, fpr, precision

def seg_rate(rows, th, segment):
    seg = [r for r in rows if r.segment == segment]
    return sum(r.score >= th for r in seg) / len(seg)

def pick_threshold(rows, max_fpr):
    """FPR が上限以下になる閾値のうち、再現率が最大のもの(同点なら低い閾値)"""
    cands = sorted({r.score for r in rows} | {1.01})
    ok = [(metrics(rows, t)[0], -t, t) for t in cands if metrics(rows, t)[1] <= max_fpr]
    return max(ok)[2]

dev  = [r for r in DATA if r.split == "dev"]
test = [r for r in DATA if r.split == "test"]

print("dev での閾値ごとの値")
print("閾値  再現率  FPR   適合率  security誤検知率")
for th in (0.3, 0.4, 0.5, 0.6, 0.7):
    rc, fpr, pr = metrics(dev, th)
    print(f"{th:.1f}   {rc:.2f}   {fpr:.2f}  {pr:.2f}    {seg_rate(dev, th, 'security'):.2f}")

for max_fpr in (0.10, 0.20):
    th = pick_threshold(dev, max_fpr)
    rc_d, fpr_d, _ = metrics(dev, th)
    rc_t, fpr_t, _ = metrics(test, th)
    print(f"\nFPR 上限 {max_fpr:.2f} → 閾値 {th:.2f}")
    print(f"  dev : 再現率 {rc_d:.2f} FPR {fpr_d:.2f} security誤検知率 {seg_rate(dev, th, 'security'):.2f}")
    print(f"  test: 再現率 {rc_t:.2f} FPR {fpr_t:.2f} security誤検知率 {seg_rate(test, th, 'security'):.2f}")

# 3 段階にする: low 未満は通す、high 以上は止める、その間は保留(ログ+二次判定)
LOW, HIGH = 0.30, 0.70
print(f"\n3 段階(通す < {LOW} ≦ 保留 < {HIGH} ≦ 止める), test")
for seg in ("general", "security", "attack"):
    rows = [r for r in test if r.segment == seg]
    allow = sum(r.score < LOW for r in rows)
    block = sum(r.score >= HIGH for r in rows)
    print(f"  {seg:<8} 通す {allow}  保留 {len(rows)-allow-block}  止める {block}")
実行結果
dev での閾値ごとの値
閾値  再現率  FPR   適合率  security誤検知率
0.3   1.00   0.42  0.62    0.83
0.4   0.88   0.33  0.64    0.67
0.5   0.75   0.25  0.67    0.50
0.6   0.62   0.08  0.83    0.17
0.7   0.62   0.00  1.00    0.00

FPR 上限 0.10 → 閾値 0.66
  dev : 再現率 0.62 FPR 0.08 security誤検知率 0.17
  test: 再現率 0.50 FPR 0.08 security誤検知率 0.17

FPR 上限 0.20 → 閾値 0.55
  dev : 再現率 0.75 FPR 0.17 security誤検知率 0.33
  test: 再現率 0.75 FPR 0.17 security誤検知率 0.33

3 段階(通す < 0.3 ≦ 保留 < 0.7 ≦ 止める), test
  general  通す 6  保留 0  止める 0
  security 通す 1  保留 4  止める 1
  attack   通す 0  保留 4  止める 4

結果から読み取れること

  • 既定の 0.5 は、このアプリには合っていない: dev では、止めるべき依頼の 25% を通しながら、セキュリティ業務の質問の半分を止めています。モデルの既定値は、モデルを作った側の評価データで決まったもので、自分のアプリの利用者の分布とは違います
  • 全体の FPR だけを見ると、特定の利用者の不便が隠れる: 閾値 0.66 では全体の誤検知率(FPR)は 0.08 ですが、セキュリティ業務の質問に限ると 0.17 です。正当な利用者の中でも、どの集団が止められているかを分けて数えます
  • dev で決めた閾値は test で確かめる: FPR 上限 0.10 で決めた閾値 0.66 は、test では再現率が 0.62 から 0.50 に下がりました。閾値を決めたデータで性能を測ると、よく見えすぎます。件数が少ないほどこの差は大きくなります
  • 閾値の調整は、再現率と誤検知の交換でしかない: セキュリティ業務の質問と攻撃のスコアが重なっている範囲(0.4〜0.7 付近)は、閾値をどこに置いても両方をきれいに分けられません

最後の点が、閾値調整の限界です。スコアの重なりを減らすには、閾値ではなくスコアそのものを変える必要があります。

閾値で解決しない誤検知への手当て

方針(カテゴリ定義)を書き換える

Llama Guard の論文は、カテゴリの名前と説明をプロンプトに入れるだけで別の分類体系に合わせる使い方(ゼロショット)と、各カテゴリに数件の例を添える使い方(フューショット)を示しています。ShieldGemma も判定する方針の文章を差し替えて使います。gpt-oss-safeguard は、利用者が書いた方針を推論時に読ませることを前提にしたモデルです。

今回のような「防御目的の脆弱性の説明は許可する」といった業務上の線引きは、閾値ではなく方針の文章に書きます。書き換えたら、同じ dev / test で P(unsafe) を取り直し、重なりが減ったかを確かめます。

3 段階にして、迷う範囲を保留にする

スコアが重なる範囲を、無理に「通す」「止める」のどちらかに決める必要はありません。上の実行結果の最後では、0.3 未満は通す、0.7 以上は止める、その間は保留にしています。test では、一般的な質問はすべて通り、止めた 5 件のうち 4 件が攻撃でした。保留の 8 件は、ログに残して人が確認する、より大きなモデルで二次判定する、回答の範囲を狭めて返す、などの扱いを決めます。

Qwen3Guard は最初から Safe / Controversial / Unsafe の 3 値を返し、Controversial を危険側に数える strict モードと、安全側に数える loose モードを用意しています。2026 年 4 月に公開された 14 種のガードモデルの比較研究では、Qwen3Guard の Controversial を Unsafe にまとめると再現率が大きく上がり、適合率が下がったと報告されています。保留の扱いをどちらに倒すかで、性能の見え方がこれだけ変わるということです。

利用者の集団ごとに閾値を変えるのは慎重に

security の利用者だけ閾値を上げれば誤検知は減りますが、その集団の名を名乗れば止まりにくくなる、という回避経路を作ります。集団ごとに変えるなら、利用者の申告ではなく、認証済みの所属や権限のような、攻撃者が変えられない属性に結び付けます。

閾値を運用するときの考え方

  • 評価データは自分のアプリのログから作る: 公開ベンチマークは比較には使えますが、閾値を決めるのは自分の利用者の分布です。正当な利用のうち、分類器が苦手そうな集団(今回ならセキュリティ業務の質問)を意図して多めに入れます
  • FPR の上限を先に決めてから閾値を選ぶ: 「正当な利用の何 % までなら止めてよいか」を先に決め、その範囲で再現率が最大になる閾値を選びます。逆に「何 % の攻撃を通してよいか」を先に決めるやり方もあり、どちらを優先するかはアプリの被害の大きさで決めます
  • モデルや方針を変えたら閾値を決め直す: スコアの分布はモデルのバージョン、方針の文章、プロンプトの形式で変わります。閾値は設定値として持ち、dev / test を回し直す手順を用意しておきます
  • 分類器だけに頼らない: 分類器はすり抜けがある前提の部品です。ツールの権限や出力の検査と組み合わせ、取り消せない操作は分類器の判定にかかわらず実行の直前で検査します

参考

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?