跳到主要內容

LV.11

資料煉成所

把病歷變成教材,並鎖住期末考考卷

BOSS
資料洩漏寄生蟲
時間
約 45 分鐘
建議先過
LV.10

先看 Boss:資料洩漏寄生蟲

上一關你造出了一批合成病歷。這一關要把它們轉成模型能「讀」的教材。但在動手前,先認識本關的 Boss:資料洩漏寄生蟲(data leakage)。

它的把戲是:偷偷讓期末考的題目混進平常練習。學生分數很漂亮,換一份新題目就露餡。

這不是假想。作者在做冒煙測試(smoke test,先用很小的資料量確認整條流程能跑通)時,就被它咬過一口。LoRA 微調後,在 29 份測試病歷上 span recall(PHI 片段召回率,下一關詳述)為 0.995,看起來近乎完美。回頭檢查才發現:測試集與訓練集來自同一批骨架,只是填入的假名字不同。這個 0.995 在本站標為不可信,正式實驗會重新切分。

什麼是 chat 格式 jsonl

微調資料最常見的格式是 jsonl(JSON Lines):一行就是一筆完整的 JSON,行與行之間沒有逗號。好處是可以一行一行讀,檔案再大也不怕。

chat 格式的每一筆有一個 messages 陣列,裡面是依序的對話:

  • system:給模型的任務規則,像是開刀前的標準作業程序。
  • user:使用者輸入,在我們的專案裡就是一份合成病歷。
  • assistant:標準答案,也就是模型應該產出的內容。

mlx-lm 除了 chat 之外,還支援 tools、completions、text 等格式(見 mlx-lm LORA.md 文件)。本站只用 chat,因為它最接近你之後實際對模型說話的方式。

為什麼不能隨便拼字串,而要用 chat 格式?因為每個模型家族有自己的 chat template(對話模板),規定角色標記、換行、結束符號長什麼樣。套錯模板,模型會像看到格式不對的病歷表單,答非所問。轉換成 chat 格式後,由 tokenizer 套用該家族的模板,細節交給工具處理。

設計決定:target 只輸出 PHI 清單

這是整個專案最重要的設計選擇之一。我們有兩種做法:

  1. 讓模型直接輸出遮蔽後的整份病歷。
  2. 讓模型只輸出 PHI 清單,遮蔽交給程式。

本站選第二種,理由有四個:

  • 輸出短:一份病歷可能上千字,PHI 清單只有幾十個 token,訓練與推論都快。
  • 好計分:清單可以直接和標準答案逐項比對,不必判斷「兩段長文有多像」。
  • 不傷內容:改寫整份病歷時,模型可能順手更動檢驗數值、藥名或診斷。由程式做字串取代,非 PHI 的部分一個字都不會動。
  • 可稽核:出錯時你能看到模型漏了哪一項,而不是只拿到一份看不出問題的長文。

類比:資深護理師在病歷上用螢光筆標出個資,之後由行政人員照螢光筆處理。標記和處理分開,責任清楚。

下面是 synth/build_sft.py 的核心邏輯(精簡版):

SYSTEM = (
    "你是病歷去識別化助手。找出病歷中所有個人可識別資訊(PHI),"
    "類型限 NAME, DOCTOR, MRN, IDNO, DATE, PHONE, ADDRESS, HOSPITAL。"
    "只輸出 JSON:{\"phi\": [{\"type\": 類型, \"text\": 原文字串}]},依出現順序,重複出現也要列出。"
)

def target(rec):
    return json.dumps(
        {"phi": [{"type": p["type"], "text": p["text"]} for p in rec["phi"]]},
        ensure_ascii=False,
    )

def to_chat(rec):
    return {"messages": [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": rec["text"]},
        {"role": "assistant", "content": target(rec)},
    ]}

逐段解釋:

  • SYSTEM 是任務說明,規定 8 種 PHI 類型與輸出格式。訓練和之後推論都要用同一段文字,否則等於換了考試規則。
  • target(rec) 把這筆資料已知的 PHI 清單轉成 JSON 字串。因為病歷是我們用程式合成的,PHI 的位置與內容都是精確已知,不需要人工標註。ensure_ascii=False 讓中文保持原樣,不會變成一串跳脫碼。
  • to_chat(rec) 把三個角色組成一筆 chat 資料。

切分:以「骨架」為單位

回到 Boss。上一關的兩段式生成法,是先由 LLM 產生帶佔位符的骨架,再由程式填入不同假資料。所以同一個骨架可以長出多份病歷,它們共享句型、病程描述與診斷,只有 PHI 不同。

如果把這些兄弟姊妹隨機分到不同 split,test 裡的病歷在 train 中就有「幾乎一模一樣的兄弟」。模型只要記住句型就能得高分。這正是冒煙測試的狀況。

正確做法是以骨架為單位切分,同一個骨架產生的病歷只能進入同一個 split。類比:盲測的診斷試驗驗證組,應該是訓練診斷規則時完全沒看過的另一批病人,不是「同一位病人的另一次門診」。

本站公開資料集由 synth/build_release.py 組裝,用的是更簡單的做法:每個骨架只填一次假資料,一個骨架恰好對應一份病歷,根本不會有兄弟病歷。流程是:只取 AI 品管判定「保留」的骨架 → 去掉內容完全相同的重複骨架 → 用固定種子抽出需要的份數並洗牌 → 依序切成 70 / 10 / 20 → 最後才逐一填值。核心邏輯(精簡版):

rng = random.Random(2026)
chosen = sorted(rng.sample(uniq, n))   # uniq: kept, de-duplicated skeleton indices
rng.shuffle(chosen)
n_tr, n_va = int(n * 0.7), int(n * 0.1)
splits = {"train": chosen[:n_tr],
          "validation": chosen[n_tr:n_tr + n_va],
          "test": chosen[n_tr + n_va:]}
# each skeleton index is then filled exactly once: one note per skeleton

逐行解釋:

  • uniq:通過品管、而且去除重複後的骨架編號。
  • rng.sample 抽出 n 個,排序後再用同一個固定種子(2026)洗牌,讓每次執行得到一樣的切法,結果可重現。
  • 前 70% 是 train、接著 10% 是 validation、最後 20% 是 test:train 用來學、validation 用來在訓練中隨堂小考、test 是期末考。被切的是「骨架編號」,所以切分單位就是骨架。
  • 之後每個骨架只填值一次,寫成 train.jsonl、validation.jsonl、test.jsonl(含 PHI 位置),同時輸出 SFT 用的 sft/train.jsonl、sft/valid.jsonl、sft/test.jsonl。

如果你用 LV10 的 fill_phi.py 自己產生 notes.jsonl,它同樣是每個骨架填一次;build_sft.py 會先依全文去重,再隨機切成 70 / 10 / 20,並另存一份 .gold.jsonl(每份病歷的原始文字與 PHI 位置,評估時會用到)。要留意:兩個內容相同的骨架填入不同假資料後全文不同,會逃過全文去重,所以先在骨架層級去重(build_release.py 的做法)比較穩。若你想讓同一個骨架填很多次來擴充資料,就要保留骨架編號,並以骨架為單位分組切分。

test 切出來之後就鎖住:訓練期間不看、不調參、不因為分數不好就回頭換資料。想調整的話,用 valid。

分量與品質

資料多不等於比較好。文獻中有例子顯示,結構化抽取任務每個資料集只用不超過 100 份訓練報告,就達到與人類標註者 non-inferior 的結果(Sci Rep 2025,PMID 41286063)。這不代表你的任務也只要 100 份,但提醒我們:先確保資料乾淨、標註正確、覆蓋多樣情境,再談數量。

🍎 Mac 與 ☁️ Colab 實作

🍎 Mac:用 python synth/build_sft.py --in data/notes.jsonl --out data/sft 把 notes.jsonl 轉成 train.jsonl、valid.jsonl、test.jsonl(以及對應的 .gold.jsonl),放在同一個資料夾;若用公開資料集,直接用其中 sft/ 資料夾的三個檔。mlx-lm 會依檔名自動讀取這三個檔。

☁️ Colab:用 datasets 的 load_dataset 載入同一份資料,套用同樣的 to_chat,再用 Unsloth 的 get_chat_template(tokenizer, chat_template="gemma-4") 套上 Gemma 4 的模板,印出第一筆確認格式(對應本站 notebook 的「步驟 3 到 6」)。注意 Colab 版的驗證集 split 名稱是 validation,Mac 版檔名是 valid.jsonl。

資料集放在 GitHub(或本站下載),notebook 可下載後上傳到 Colab 開啟。再次提醒:本站全程只使用合成資料;真實病歷即使在本機處理,也需要院方核准與 IRB(人體研究倫理審查),「本地」不等於「合法」。

本關重點

  • chat 格式 jsonl:每行一筆,messages 內有 system / user / assistant;套用 chat template 的工作交給 tokenizer。
  • target 只輸出 PHI 清單 JSON,遮蔽交給程式:輸出短、好計分、不改動醫療內容、可稽核。
  • 以骨架為單位切分 train / valid / test,同骨架的兄弟病歷不可跨 split。
  • 冒煙測試 0.995 是資料洩漏的反例,不可引用;test set 切出後鎖住。
  • 資料先求乾淨與多樣,再求數量。

BOSS 戰:資料洩漏寄生蟲

HP0/4
  1. 同一個骨架填入不同假姓名、假日期後,產生了 5 份病歷。若隨機把這 5 份分到 train 和 test,主要的問題是什麼?

  2. 冒煙測試中 LoRA 的 span recall 達 0.995,但本站說「不可引用」。最合理的理由是?

  3. 為什麼本站讓模型只輸出「PHI 清單 JSON」,而不是直接輸出遮蔽後的整份病歷?

  4. chat 格式 jsonl 的每一行 messages 通常包含哪三種角色?