跳到主要內容

LV.08

本地診間:不訓練也能做的事

先用 prompt 看看極限在哪

BOSS
格式不服從症候群
時間
約 60 分鐘
建議先過
LV.02 、LV.07

這一關要打倒什麼?

Boss 是「格式不服從症候群」:你明明說「請輸出 JSON」,模型卻回你一大段散文,或在 JSON 前面先加一句「好的,以下是結果」。程式一解析,直接報錯。

類比:交班時你要求同事填「結構化交班單」,他卻口頭講了五分鐘。內容也許都對,但下游系統(下一班)沒辦法直接用。

這一關有兩個任務:

  1. 學會用 prompt 讓本地模型做三件事:摘要、結構化抽取、PHI 標記。
  2. 建立主線專案「病歷守門人」的 prompt-only baseline(只靠提示、不訓練的對照成績)。

提醒:本章所有「病歷」都是合成資料(見 LV10)。真實病歷即使在本機處理,也需要院方核准與倫理審查(IRB);本地不等於合法。

先講為什麼:沒有 baseline,就不知道 fine-tune 值不值得

臨床試驗要有對照組,才知道新療法的效果。fine-tune 也一樣:你得先知道「不訓練、只下指令」能做到什麼程度,才有資格說「訓練之後進步了多少」。

而且 baseline 可能直接給你答案:如果 prompt 加上格式約束已經夠用,就不必花時間訓練;反過來,如果 baseline 在某些項目(例如人名)表現很差,你就知道 fine-tune 要補什麼。

Prompt engineering 的三個基本招式

醫學領域的 prompt engineering 已有整理過的文獻(例如 JMIR 2024 的 scoping review,PMID 39255030)。對這一關夠用的招式有三個:

  1. 角色與任務說明:講清楚你要它做什麼,例如「你是病歷去識別化助手,請找出病歷中的個人識別資訊」。
  2. few-shot(少量範例):給 1–3 個範例,示範輸入與期望輸出的長相。就像教新進同仁:先給他看兩份寫好的範本。
  3. 輸出約束:明確要求只輸出 JSON,並給出欄位定義;若工具支援,直接傳入 JSON schema,讓引擎限制輸出格式。

輸出約束的真實案例(研究階段)

德國 Würzburg 大學醫院團隊(Eur Radiol,PMID 39390261)用 Llama-2-70B-chat,在院內伺服器上以 grammar 控制輸出,把胸部 X 光報告轉成結構化範本,全部產出皆為有效的結構化報告;與人類評讀者(reader) 的一致性差異落在預先設定的等效範圍內(MCC 英文 0.75、德文 0.66)。

要誠實說明它的定位:這是研究,使用 70B 模型與專用伺服器,沒有 fine-tune,也沒有顯示已進入常規報告流程。它說明的是「格式約束有效」,不是「本地 LLM 已進入臨床常規」。

另一份研究(Mukherjee 等,Radiology 2023,PMID 37815442)用 Vicuna-13B,未再微調,以多步 prompt 標註胸部 X 光報告的 13 項發現;在 100 份放射科專家標註的子集中,AUC 中位數為 0.84。同樣是研究階段、可在本機執行的概念驗證。

規則式也要有一席之地

CT 報告匿名化的研究(Eur Radiol,PMID 39480533)指出,規則式程式在日期、病歷號等項目全部移除,而離線 LLaMa-2 的日期 recall 僅 0.52,且有 10 份報告誤刪醫療資訊。這是單一任務的結果,但它提醒我們:格式固定的欄位,規則式可能更穩。所以本站的 baseline 同時包含 regex 對照。

實作:建立 prompt-only baseline

準備資料

LV10 會教你怎麼自己生成合成病歷。本關先用 20 份合成病歷(text 為病歷全文,phi 為標準答案的 PHI 位置)。若你尚未完成 LV10,可以先下載本站 Hugging Face 公開資料集(連結見 LV10 結尾)的 test.jsonl。

🍎 Mac:Ollama + Python

import json, requests

SYSTEM = (
    "你是病歷去識別化助手。找出病歷中所有個人識別資訊(PHI),"
    "只輸出 JSON,格式:{\"phi\": [{\"type\": \"NAME|DOCTOR|MRN|IDNO|DATE|PHONE|ADDRESS|HOSPITAL\", \"text\": \"原文片段\"}]}。"
    "不要輸出任何其他文字。"
)

def ask(note: str, model: str = "gemma4:e2b") -> str:
    r = requests.post(
        "http://localhost:11434/api/chat",
        json={
            "model": model,
            "messages": [
                {"role": "system", "content": SYSTEM},
                {"role": "user", "content": note},
            ],
            "stream": False,
            "think": False,
            "format": "json",
            "options": {"temperature": 0, "num_predict": 800},
        },
        timeout=300,
    )
    return r.json()["message"]["content"]

def is_valid(s: str) -> bool:
    try:
        obj = json.loads(s)
        return isinstance(obj.get("phi"), list)
    except Exception:
        return False

notes = [json.loads(l) for l in open("test.jsonl", encoding="utf-8")][:20]
outs = [ask(n["text"]) for n in notes]
valid = sum(is_valid(o) for o in outs)
print(f"valid JSON: {valid}/{len(notes)}")

逐行解釋:

  • SYSTEM:系統提示,規定角色、輸出格式與欄位。PHI 類型與 LV10 的標註一致。
  • ask(...):把一份病歷送進本機 Ollama 的 /api/chat。
  • "think": False:關閉思考模式。作者實測 Gemma 4 E2B 若不關,思考過程會用完輸出預算,導致沒有 JSON。此參數是否被你的 Ollama 版本與模型支援,請以官方文件為準。
  • "format": "json":要求 Ollama 限制輸出為合法 JSON(也可改傳 JSON schema,約束更嚴)。
  • temperature: 0:降低隨機性,讓結果可重現。
  • num_predict:最多產生的 token 數。
  • is_valid:嘗試解析並檢查有沒有 phi 清單;這就是「valid JSON 比例」的定義。
  • 最後三行:讀 20 份病歷、逐份呼叫、統計有效比例。

計算 span recall

valid JSON 只是「長相合法」。更重要的是它有沒有找對。用 span recall 衡量:標準答案中的 PHI 片段,有多少比例被模型找到。

def span_recall(note, pred_json):
    try:
        ptexts = {p["text"] for p in json.loads(pred_json)["phi"]}
    except Exception:
        return 0.0
    caught = [g["text"] in ptexts for g in note["phi"]]
    return sum(caught) / len(caught) if caught else 1.0
  • ptexts:把模型預測的原文字串整理成集合;解析失敗就當 0 分。
  • caught:標準答案裡的每一個 PHI 片段(重複出現的也各算一次),只要原文字串出現在預測集合中就算抓到。
  • 回傳抓到的比例,就是這份病歷的 span recall。

這裡只比對文字、不比對類型,因為遮蔽程式只看字串;這和 LV14 evaluate.py 的定義一致。正式評估會把所有病歷的片段合併計算,並附上信賴區間(見 LV14)。

🍎 Mac 與 ☁️ Colab 的對照

  • Mac:上面的流程,記錄 valid JSON 比例與 span recall,作為 baseline M1。
  • Colab:用 Unsloth 載入同一個基底模型(見 LV07),餵同一份 20 筆資料,得到雲端環境的對照數字。兩邊數字不一定相同,因為量化、生成設定、引擎都不同,記錄差異即可。

regex 對照組

對日期、電話、身分證字號這類有固定格式的欄位,寫幾條正規表示式(regular expression)當對照:

import re
RULES = {
    "PHONE": r"09\d{2}[-\s]?\d{3}[-\s]?\d{3}",
    "IDNO": r"\b[A-Z][12]\d{8}\b",
    "DATE": r"\b(?:19|20)\d{2}[/-]\d{1,2}[/-]\d{1,2}\b",
}
  • 每個項目一條規則;\d 代表數字,{3} 代表連續三個。
  • 人名、醫院名沒有固定格式,規則很難涵蓋,這是 regex 的天然弱點。

作者的初步觀察(冒煙測試,非正式結果)

以下是作者在 M5 32GB 做的早期冒煙測試,樣本小、未重複、也未做正式的資料切分,只能當方向性觀察:

  • Gemma 4 E2B 不關閉 thinking:valid JSON 為 0%,因為思考過程用完了 500 token 的輸出預算。
  • 關閉 thinking:span recall 約 0.37,valid JSON 約 45%。
  • regex:span recall 約 0.37,人名完全抓不到。

這幾個數字不要引用成「Gemma 4 E2B 的能力」。它們的用途是示範:baseline 的價值在於讓你看見具體的落差(格式不穩、人名抓不到),並知道 fine-tune 該補哪裡。你自己的 20 份資料的正式 baseline 數字:【實測數據待補】。

RAG 迷你版(選修)

若你想讓模型讀自己的去識別化筆記或 PDF,可以用 AnythingLLM(MIT 授權,桌面版)或 LM Studio 的文件問答。原理是「先檢索相關段落,再讓模型根據段落回答」,適合需要引用資料的情境。何時用 RAG、何時用 fine-tune,下一關會整理。

本關重點

  • prompt-only baseline 是 fine-tune 的對照組,沒有它就無法解讀訓練的效果。
  • 三個基本招式:角色與任務、few-shot、輸出約束(JSON schema / grammar)。
  • 格式約束能保證「長相合法」,不保證內容正確;相關案例屬研究階段,不代表已進入常規臨床。
  • Gemma 4 E2B 預設會輸出 thinking,需要關閉;作者的冒煙結果(valid JSON 約 45%、span recall 約 0.37)是初步、小樣本,不能當正式數字。
  • 格式固定的欄位可用 regex 當對照;任務要挑對才用 LLM。

BOSS 戰:格式不服從症候群

HP0/4
  1. 為什麼在 fine-tune 之前,要先做一個 prompt-only baseline?

  2. 作者在冒煙測試中發現 Gemma 4 E2B 不關閉 thinking 時 valid JSON 為 0%,關閉後約 45%。這個結果最合理的解讀是什麼?

  3. 研究(PMID 39390261)用 Llama-2-70B 加上 grammar 約束輸出,結果全部產出都是有效的結構化報告。這個案例最能說明什麼?

  4. 另一項研究(PMID 39480533)顯示,在 CT 報告匿名化任務中,規則式程式在日期、病歷號等項目表現優於離線的 LLaMa-2。這對本專案的提醒是什麼?