機器學習的標準做法是看 F1 score,也就是 Precision 跟 Recall 的調和平均。這個指標的前提是兩種錯誤同等重要。
在個資防護裡,這個前提不成立。
False Positive(誤判):把不是個資的東西當成個資遮掉。後果是文件可讀性下降、模型效果變差、使用者抱怨。可以修復。
False Negative(漏抓):真的個資沒被抓到。後果是明文個資直接送出行內邊界。不可修復 —— 資料一旦送出去就收不回來,而且你不會知道它發生過。
所以在這個場景,F1 是誤導性的指標。應該用的是加權 F-beta,beta > 1 表示更重視 Recall:
def f_beta(precision, recall, beta=2.0):
if precision + recall == 0:
return 0.0
b2 = beta ** 2
return (1 + b2) * precision * recall / (b2 * precision + recall)
beta 取多少?我的建議是 2 到 3。beta=2 表示 Recall 的重要性是 Precision 的兩倍。
更直接的做法是設 Recall 底線,在底線之上再優化 Precision:
直接識別碼類 Recall ≥ 0.99;特種個資類 Recall ≥ 0.95;在此前提下 Precision 越高越好。
這種寫法比「F1 ≥ 0.9」清楚得多,而且它逼你面對「哪一類個資可以容忍漏抓」這個問題。答案通常是:直接識別碼一個都不能漏。
評測集的品質決定了你的數字有沒有意義。三個原則:
一、絕對不能用正式資料。 全部用合成。這一點沒有例外。
二、要涵蓋失效情境,不只是理想情境。 如果你的評測集全是格式標準的樣本,你會得到一個漂亮但沒有意義的 Recall。
我的評測集分成四個子集,分別報告數字:
| 子集 | 內容 | 用途 |
|---|---|---|
| Clean | 格式標準的個資 | 基準線 |
| Noisy | 全形、分隔符、跨行、OCR 錯字 | 韌性 |
| Adversarial | 刻意規避的樣態 | 紅隊 |
| Negative | 格式像個資但不是 | 誤判率 |
只報總體數字是不誠實的。 因為 Clean 子集通常佔多數,總體 Recall 會被它拉高,掩蓋 Noisy 和 Adversarial 的真實表現。
三、Adversarial 子集要持續成長。 每次發現一個新的漏抓樣態,就加進去。這一集是整個評測體系裡最有價值的資產,因為它是實戰經驗的累積。
from collections import defaultdict
def evaluate(gold, pred, iou_threshold=0.5):
"""gold/pred: list of {type, start, end}"""
stats = defaultdict(lambda: {"tp": 0, "fp": 0, "fn": 0})
matched = set()
for g in gold:
hit = None
for i, p in enumerate(pred):
if i in matched or p["type"] != g["type"]:
continue
if iou(g, p) >= iou_threshold:
hit = i
break
if hit is not None:
matched.add(hit)
stats[g["type"]]["tp"] += 1
else:
stats[g["type"]]["fn"] += 1
for i, p in enumerate(pred):
if i not in matched:
stats[p["type"]]["fp"] += 1
result = {}
for t, s in stats.items():
prec = s["tp"] / (s["tp"] + s["fp"]) if s["tp"] + s["fp"] else 0
rec = s["tp"] / (s["tp"] + s["fn"]) if s["tp"] + s["fn"] else 0
result[t] = {
"precision": round(prec, 4),
"recall": round(rec, 4),
"f2": round(f_beta(prec, rec, 2.0), 4),
**s,
}
return result
def iou(a, b):
inter = max(0, min(a["end"], b["end"]) - max(a["start"], b["start"]))
union = max(a["end"], b["end"]) - min(a["start"], b["start"])
return inter / union if union else 0.0
逐類型報告會揭露一件事:整體 Recall 0.95 可能來自「身分證字號 0.99、健康資訊 0.62」的平均。 而後者正是後果最嚴重的那一類。
一個常被忽略的問題:偵測到了但範圍不完整,算對還是錯?
原文: 身分證字號 A123456789
標準答案: [A123456789]
偵測結果: [123456789] ← 少了首字母
從 ML 的角度,IoU = 0.9,算命中。從資安的角度,這是一次洩漏——首字母 A 洩漏出去了,而首字母帶有戶籍地資訊。
所以在資安評測裡,我對直接識別碼類採嚴格匹配:範圍必須完全覆蓋標準答案,才算 TP。範圍更大(多遮了)算 TP 但計入過度遮罩統計;範圍更小(少遮了)一律算 FN。
def strict_match(gold, pred):
"""預測範圍必須完全覆蓋標準答案"""
return pred["start"] <= gold["start"] and pred["end"] >= gold["end"]
對敘述性的特種個資,這個標準要放寬,因為「哪裡開始哪裡結束」本身就有主觀性。改用 IoU 0.5 加上人工複核。
評測不是上線前做一次的事。規則會改、模型會更新、新的文件類型會加進來,每一次改動都可能造成回歸。
# 概念性的 CI 步驟
- name: PII detection regression
run: |
python -m eval.run \
--dataset eval/sets/ \
--min-recall-direct 0.99 \
--min-recall-sensitive 0.95 \
--max-fp-rate 0.05 \
--fail-on-regression
門檻沒過就擋合併。這件事在 D26 會擴展成完整的自動化紅隊回歸。
不管你的數字多好看,都要記住:評測集是你自己設計的,它衡量的是「你想到的失效情境」。 你沒想到的那些,數字裡不會出現。
所以 Recall 0.99 的正確解讀不是「99% 的個資會被抓到」,而是「在我設計的測試分布下,99% 會被抓到」。真實分布跟測試分布的差距,是這個架構永遠存在的殘餘風險。
這也是為什麼架構不能只靠偵測——後面的 Vault 隔離、輸出側檢查、fail-closed 設計,都是在承認「偵測一定會漏」的前提下建立的第二道防線。
明天進去識別化:三種手法,以及為什麼只有一種可逆。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend
有想討論的架構細節或不同意見,留言或私訊都歡迎。