先看 Boss:漏網之 PHI
第一次看到評估報告時,你可能會被一個漂亮數字吸引。但在去識別化這件事上,真正的問題永遠是:漏了什麼? Boss 是藏在英文句子裡的病人姓名,例如「Pt 王小明 reported chest tightness」,它躲在中英夾雜的縫隙中,最容易漏網。
指標
作者的 synth/evaluate.py 會計算以下指標。先看定義:
span recall(片段召回率,主指標):標準答案中每一個 PHI 片段,若其原文字串出現在模型預測的清單中,就算抓到。為什麼用「字串出現在清單中」就算?因為遮蔽程式會把清單中的字串在整份病歷裡全部取代,所以只要字串在清單裡,同一字串的所有出現位置都會被遮到。
類比:它就是這個任務的 sensitivity(敏感度)。漏掉一個 PHI = 偽陰性。
precision(unique):模型預測的不重複字串中,有多少是真的 PHI。類似陽性預測值,用來確認模型沒有亂標一大堆。
F1:recall 與 precision 的調和平均,只當參考;本任務更在意 recall。
clean_note_rate:整份病歷的 PHI 全部抓到的比例,可理解為 note 層級的「全對率」。它比片段層級嚴格得多:一份病歷 20 個 PHI 漏 1 個,recall 是 95%,但這份病歷不算 clean。
valid_json_rate:輸出能被解析成合法 JSON 的比例。格式錯誤,遮蔽程式無法使用,等同於全部漏掉。
recall_by_type:各類型(NAME、DOCTOR、MRN、IDNO、DATE、PHONE、ADDRESS、HOSPITAL)各自的 recall,用來找出弱點在哪裡。
bootstrap 95% CI:測試集只有一批病歷,recall 會因為抽到哪些病歷而有變動。bootstrap 的做法是:以「病歷」為單位,有放回地重抽樣 2000 次,每次重算 recall,取第 2.5 與 97.5 百分位當區間。報告 recall 時宜附上區間;樣本小時區間會很寬。
重點程式如下(節錄 evaluate.py 的 score):
caught = [s["text"] in ptexts for s in g["phi"]]
...
for _ in range(2000):
sample = [per_note[rng.randrange(len(per_note))] for _ in per_note]
boots.append(sum(a for a, _ in sample) / max(1, sum(b for _, b in sample)))
boots.sort()
ci = [boots[49], boots[1949]]
逐行解釋:
caught對每個標準答案片段,判斷其原文是否在預測字串集合ptexts內。per_note記錄每份病歷「抓到幾個 / 共幾個」,bootstrap 以整份病歷為抽樣單位。- 迴圈 2000 次:每次從病歷中有放回抽出與原本一樣多份,重新計算總 recall。
- 排序後取第 50 與第 1950 個值,約為 2.5% 與 97.5% 位置,即 95% 區間。
三個對手:base、LoRA、regex
比較要公平:同一份 test 集,三種做法:
- base(prompt-only):不微調的同一模型,只用 system prompt 說明任務。這是檢驗「微調是否值得」的基準線。
- LoRA:你在 LV13 訓練的 adapter。
- regex(規則式):用正規表示式寫的規則,抓電話、身分證、病歷號、日期、醫院名等有格式的字串。
規則式不是稻草人。文獻中有研究在胸部 CT 報告的匿名化比較了離線的 LLaMa-2 與規則式,規則式在日期、病歷號等有固定格式的項目表現較好(Eur Radiol,PMID 39480533)。所以 regex 要認真寫,才算公平的對手。
冒煙測試的初步觀察(非正式,n = 29 份)
下面的數字來自作者的冒煙測試(29 份病歷,prompt-only 在關閉思考模式下)。樣本小,而且這份測試集與冒煙訓練集共用骨架(這只影響 LoRA 的數字,所以 LoRA 不列入),僅用來示範怎麼讀報告,不是正式結果:
| 方法 | span recall(95% CI) | valid JSON rate |
|---|---|---|
| base(prompt-only,關閉 thinking) | 0.37(0.22–0.52) | 0.45 |
| regex | 0.37(0.30–0.45) | 1.00 |
怎麼讀這張表:
- 兩者 point estimate 相近,但 95% CI 有重疊。在這 29 份上未顯示明確差異,不能寫成兩者一樣。
- base 若沒有關閉 thinking,作者觀察到 valid JSON 為 0%(思考過程用光 500 個 token)。可見 prompt 與 chat template 的設定本身就會大幅影響結果。
- regex 的 recall_by_type:HOSPITAL 與 PHONE 為 1.00,MRN 約 0.62,DATE 約 0.58,IDNO 0.40,而 NAME、DOCTOR、ADDRESS 皆為 0。regex 抓不到人名,因為人名沒有格式可循。
- 冒煙測試中 LoRA 的 0.995 因資料洩漏(測試集與訓練集共用骨架)不引用。下一節的正式結果改以骨架切分的 test 集評估,LoRA 的 span recall 為 0.978,兩者不能直接比較。
正式實驗結果(作者在 M5 32GB 的實測)
正式實驗使用公開資料集 v1.0(train 700 / valid 100 / test 200 份,以骨架切分),test 集共 1,349 個 PHI span。span recall 的 95% CI 以「病歷」為單位 bootstrap 2000 次。模型為 Gemma 4 E2B,LoRA 設定見 LV13。
| 方法 | span recall(95% CI) | precision | clean note | valid JSON |
|---|---|---|---|---|
| regex | 0.548(0.530–0.564) | 0.917 | 0.0% | — |
| Gemma 4 E2B prompt-only | 0.439(0.370–0.501) | 0.959 | 17.5% | 46% |
| Gemma 4 E2B + LoRA | 0.978(0.956–0.993) | 0.995 | 93.0% | 99% |
| prompt-only + regex(hybrid) | 0.750(0.714–0.783) | 0.916 | 35.5% | — |
| LoRA + regex(hybrid) | 0.994(0.985–1.000) | 0.947 | 98.5% | — |
各類型 recall 的重點:
- regex:NAME、DOCTOR、ADDRESS 皆為 0;DATE 0.98、IDNO 0.99;MRN、PHONE、HOSPITAL 為 1.0。
- LoRA:各類型 recall 介於 0.95–0.99。
- hybrid(LoRA + regex):DATE、IDNO、MRN、PHONE、HOSPITAL 補到 1.0,代價是 precision 由 0.995 降到 0.947。
怎麼解讀這張表:
- LoRA(0.978,95% CI 0.956–0.993)與 prompt-only(0.439,95% CI 0.370–0.501)的 CI 沒有重疊,在這份 test 集上 LoRA 的 span recall 明顯較高。這是同一份 test 集、同一組設定下的觀察,不代表其他資料也會有同樣差距。
- regex 的 HOSPITAL 為 1.0,是因為合成資料中假院名的後綴固定,屬於資料的偏差;真實院名的寫法更多樣,不宜據此推論 regex 在真實病歷也能抓到院名。
- 合成資料的分布單純,分數偏高,不代表真實病歷的表現。高 recall 也不等於零洩漏:LoRA 的 clean note 為 93.0%,表示仍有約 7% 的病歷至少漏掉一個 PHI。
- 與冒煙測試對照:冒煙的 LoRA 0.995 受資料洩漏影響,不引用;正式的 0.978 是以骨架切分後的結果,才是可以引用的數字。
- 評估推論時間(200 份,batch 16):base 294 秒、LoRA 234 秒。
這張圖給你的設計啟發是 hybrid:電話、身分證這類有格式的交給規則(又快又穩),人名、地址這類語意性強的交給模型,兩者聯集後再遮蔽。
統計措辭
寫報告時:
- recall 一律附 95% CI。
- 差異的 CI 或區間有重疊時,寫「在本測試集上未顯示明確差異」,不寫「沒有差別」或「效果一樣」。要宣稱「相當」需要 non-inferiority 設計與預先設定的 margin。
- 單次實驗、單一測試集、單一隨機種子,不要寫「證明」。
- 不要把 fine-tune 前後的 point estimate 差距寫成「進步了 X%」,除非區間不重疊或有適當的檢定。
誠實揭露:合成資料的分數偏高
本站所有評估都在合成資料上進行。合成病歷由模板與假名單填成,格式規律、PHI 位置明確,比真實病歷容易。因此:
- 分數只代表在合成資料上的表現。
- 不能外推到真實病歷。真實病歷有縮寫、錯字、手寫轉錄、不規則格式等。
- 文獻中的高分例子也是如此。Bordeaux 急診病歷研究(JMIR AI,PMID 40605780)報告 Mistral 7B 的 PII-level F1 為 0.9673,3000 份中仍有 4 份姓名未完全去除。高 F1 不等於零洩漏。
- 任何真實病歷的處理,無論在不在本機,都需要院方核准與 IRB;本站全程只用合成資料。
一般能力有沒有退化
微調可能讓模型在其他任務變差(常稱為災難性遺忘,catastrophic forgetting)。可以用 TMMLU+ 的醫學相關科目做對照檢查,只當作「一般能力有沒有明顯退化」的粗略觀察,不是主線評估,也不當訓練資料。這部分的做法與數字【實測數據待補】。進階讀者可以參考 lm-evaluation-harness 這類通用評估工具,本站只做概念介紹。
另外,若要使用 LLM 當評審(LLM-as-judge),只適合開放式輸出(LV16),而且要搭配人工抽樣。
🍎 Mac 與 ☁️ Colab 實作
🍎 Mac:用 evaluate.py 分三次執行:--model 不帶 --adapter 跑 base,帶 --adapter 跑 LoRA,--regex 跑規則式。輸出的 json 檔與逐筆預測 .preds.jsonl 可以拿來找漏網 PHI。另可用 mlx_lm lora --test 看 perplexity(只是參考)。
☁️ Colab:notebook 內已內建同一套 parse、score,先跑 fine-tune 前、再跑 fine-tune 後,並印出各類型 recall 與 CI 是否重疊的提示。
本關重點
- 主指標是 span recall(類似 sensitivity);次要看 precision、clean_note_rate、valid_json_rate,並附 bootstrap 95% CI。
- 比較對象要有 base(prompt-only)、LoRA、regex;regex 對有格式的 PHI 不弱,但抓不到人名。
- CI 有重疊時寫「未顯示明確差異」,不寫「一樣」。
- 合成資料分數偏高,不能外推到真實病歷;高 F1 也不等於零洩漏。
- 正式實驗(合成資料、test 200 份):LoRA span recall 0.978(0.956–0.993),prompt-only 0.439(0.370–0.501),CI 沒有重疊;冒煙測試的 0.995 因資料洩漏不引用。