{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "b31db3cc",
   "metadata": {},
   "source": [
    "# 病歷守門人（Colab 版）：用免費 T4 微調 Gemma 4 E2B 找出病歷中的 PHI\n",
    "\n",
    "**這本 notebook 做什麼**：把一個小型語言模型（Gemma 4 E2B）訓練成「病歷守門人」。它讀一份中英夾雜的繁體中文病歷，輸出一份 JSON 清單，列出病歷中的個人可識別資訊（PHI，例如姓名、病歷號、電話）。接著我們再用 Python 依清單把病歷遮蔽成 `[NAME]`、`[DATE]` 這類標籤。\n",
    "\n",
    "比喻：基礎模型像剛畢業、讀過很多書但沒看過這家醫院病歷格式的實習醫學生；微調（fine-tuning）像讓他在帶教老師指導下看幾百份標好答案的病歷，練習「這種寫法就是 PHI」。\n",
    "\n",
    "> ## 警示：只能使用合成資料，不可上傳真實病歷\n",
    "> Colab 是 Google 的雲端機器。你在這裡載入、上傳或貼上的任何資料，都會經過 Google 伺服器。**請勿把任何真實病歷、真實病人資料放進這本 notebook。** 本教學只使用「完全虛構的合成病歷」。真實病歷的去識別化需要院方核准與 IRB，詳見最後一節。\n",
    "\n",
    "**預估時間**（Colab 免費 T4 實測）：安裝加載入模型約 5–8 分鐘；訓練 176 steps 約 24 分鐘；評估 base 約 45 秒/份、LoRA 約 25 秒/份，預設只評估 20 份；全部約 1 小時多。\n",
    "\n",
    "**使用方式**：選單 Runtime（執行階段）> Change runtime type（變更執行階段類型）> 選 T4 GPU，然後由上往下逐格執行。每個程式格前面都有一段說明。\n",
    "\n",
    "本 notebook 以 Unsloth 官方 Gemma 4 E2B notebook 為底改寫（Unsloth 為 LGPL-3.0 授權）。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9b624ffc",
   "metadata": {},
   "source": [
    "## 步驟 1：確認有拿到 GPU\n",
    "\n",
    "下面這一行 `!nvidia-smi` 是在問機器「你有幾張顯示卡、記憶體多大、現在誰在用」。比喻：開刀前先確認手術室有排到，不要等到切皮才發現沒房間。\n",
    "\n",
    "- 看到 `Tesla T4`、約 15 GB 記憶體 → 正常。\n",
    "- 出現找不到指令或錯誤 → 回到選單把執行階段類型改成 T4 GPU。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3ef9e983",
   "metadata": {},
   "outputs": [],
   "source": [
    "!nvidia-smi"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4629610d",
   "metadata": {},
   "source": [
    "## 步驟 2：安裝套件（沿用官方 cell，不要改）\n",
    "\n",
    "這一格在安裝訓練需要的工具箱：Unsloth（讓訓練省記憶體、跑得快的工具）、TRL（提供 SFTTrainer 這個訓練器）、bitsandbytes（4-bit 壓縮）等。\n",
    "\n",
    "逐行大意：\n",
    "- `%%capture`：把安裝時一大串訊息收起來，畫面乾淨。\n",
    "- `if \"COLAB_\" ...`：判斷是不是在 Colab。不是就單純 `pip install unsloth`；是的話就依 Colab 目前的 PyTorch 版本挑相容的 xformers 版本。\n",
    "- `!pip install ...`：實際安裝。版本號是官方釘死的，因為套件之間版本不合就像藥物交互作用，可能出問題。\n",
    "- 最後 `torch._dynamo.config.recompile_limit = 64`：放寬編譯快取上限，避免訓練中途被打斷。\n",
    "\n",
    "安裝需要數分鐘。官方版本號會隨時間更新；若這格失敗，先到官方 notebook 複製最新的安裝 cell。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "30e57ac5",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%capture\n",
    "import os, re\n",
    "if \"COLAB_\" not in \"\".join(os.environ.keys()):\n",
    "    !pip install unsloth  # Do this in local & cloud setups\n",
    "else:\n",
    "    import torch; v = re.match(r'[\\d]{1,}\\.[\\d]{1,}', str(torch.__version__)).group(0)\n",
    "    xformers = 'xformers==' + {'2.9':'0.0.33.post1','2.8':'0.0.32.post2'}.get(v, \"0.0.35\")\n",
    "    if str(torch.version.cuda).startswith(\"13\") and xformers.endswith(\"0.0.35\"): xformers = \"https://download.pytorch.org/whl/cu130/xformers-0.0.35-py39-none-manylinux_2_28_x86_64.whl\"\n",
    "    !pip install sentencepiece protobuf \"datasets==4.3.0\" \"huggingface_hub>=0.34.0\" hf_transfer\n",
    "    !pip install --no-deps unsloth_zoo bitsandbytes accelerate {xformers} peft trl triton unsloth\n",
    "    !pip install --no-deps --upgrade \"torchao>=0.16.0\"\n",
    "!pip install --no-deps transformers==5.5.0 \"tokenizers>=0.22.0,<=0.23.0\"\n",
    "!pip install \"huggingface_hub>=1.5.0,<2.0\"\n",
    "!pip install torchcodec\n",
    "import torch; torch._dynamo.config.recompile_limit = 64;"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d3d99154",
   "metadata": {},
   "source": [
    "## 步驟 2b：補裝 timm（官方 cell）\n",
    "\n",
    "Gemma 4 本身也能看圖、聽聲音，`timm` 是它視覺 / 音訊部分的零件。我們這次只訓練文字，但官方載入流程需要它在場，所以照裝。比喻：器械包裡有一把這次手術用不到、但器械清點規定要在的剪刀。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "35d146e6",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%capture\n",
    "!pip install --no-deps --upgrade timm # For Gemma 4 vision/audio"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4dcd545d",
   "metadata": {},
   "source": [
    "## 步驟 3：載入合成病歷資料集\n",
    "\n",
    "這一格從本站作者的 GitHub 公開資料集 repo 下載三份 jsonl：\n",
    "\n",
    "- `train`（訓練集）：給模型練習，像課本習題。\n",
    "- `validation`（驗證集）：訓練途中拿來檢查有沒有學歪，像隨堂小考。\n",
    "- `test`（測試集）：最後才打開，像國考。**訓練過程完全沒看過**，分數才有意義。\n",
    "\n",
    "每筆資料欄位：`id`、`specialty`（科別）、`note_type`（病歷類型）、`style`（寫作風格）、`text`（病歷本文）、`phi`（答案清單，每項含 `type`、`text`、`start`、`end`）、`deid_text`（已遮蔽版本）。\n",
    "\n",
    "下面 `DATA_BASE` 是資料集的網址（GitHub raw）。如果你的網路連不到 GitHub，可以從本站下載 zip、解壓後改用下一格的「上傳 jsonl」備援。\n",
    "\n",
    "接著我們把每筆資料轉成 chat 格式 `{\"messages\":[system,user,assistant]}`。其中：\n",
    "- `system` = 任務說明（逐字複製自專案的 `synth/build_sft.py`，訓練與評估必須用同一句，否則等於換了題目）。\n",
    "- `user` = 病歷本文。\n",
    "- `assistant` = 標準答案，一個 JSON 字串 `{\"phi\":[{\"type\":...,\"text\":...}]}`。\n",
    "\n",
    "`target()`、`to_chat()` 也是逐字複製，不做任何改寫。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d1536c87",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "from datasets import load_dataset, DatasetDict\n",
    "\n",
    "DATA_BASE = \"https://raw.githubusercontent.com/fireman333/tw-synthetic-clinical-notes-phi/main/data\"\n",
    "\n",
    "# train / validation / test 三個 split，各是一個 jsonl 檔\n",
    "raw = load_dataset(\"json\", data_files={\n",
    "    \"train\": f\"{DATA_BASE}/train.jsonl\",\n",
    "    \"validation\": f\"{DATA_BASE}/validation.jsonl\",\n",
    "    \"test\": f\"{DATA_BASE}/test.jsonl\",\n",
    "})\n",
    "print(raw)\n",
    "\n",
    "# ---- 以下逐字複製自 synth/build_sft.py ----\n",
    "SYSTEM = (\n",
    "    \"你是病歷去識別化助手。找出病歷中所有個人可識別資訊（PHI），\"\n",
    "    \"類型限 NAME, DOCTOR, MRN, IDNO, DATE, PHONE, ADDRESS, HOSPITAL。\"\n",
    "    \"只輸出 JSON：{\\\"phi\\\": [{\\\"type\\\": 類型, \\\"text\\\": 原文字串}]}，依出現順序，重複出現也要列出。\"\n",
    ")\n",
    "\n",
    "\n",
    "def target(rec):\n",
    "    return json.dumps({\"phi\": [{\"type\": p[\"type\"], \"text\": p[\"text\"]} for p in rec[\"phi\"]]}, ensure_ascii=False)\n",
    "\n",
    "\n",
    "def to_chat(rec):\n",
    "    return {\"messages\": [\n",
    "        {\"role\": \"system\", \"content\": SYSTEM},\n",
    "        {\"role\": \"user\", \"content\": rec[\"text\"]},\n",
    "        {\"role\": \"assistant\", \"content\": target(rec)},\n",
    "    ]}\n",
    "# ---- 複製結束 ----"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d3be1572",
   "metadata": {},
   "source": [
    "## 步驟 3b（備援）：改用本機上傳的 jsonl\n",
    "\n",
    "如果資料集還沒公開、或 HF 連不上，可以改用這格：自己從電腦上傳 `train.jsonl`、`validation.jsonl`、`test.jsonl`（需為含 `text`、`phi` 等欄位的「gold」格式，每行一筆 JSON，**不是**已經轉成 messages 的版本）。\n",
    "\n",
    "預設 `USE_UPLOAD = False`，這格什麼都不做。要用時改成 `True`，執行後會跳出選檔視窗。再次提醒：只能上傳合成資料。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f13b528f",
   "metadata": {},
   "outputs": [],
   "source": [
    "USE_UPLOAD = False  # 要用本機檔案時改成 True\n",
    "\n",
    "if USE_UPLOAD:\n",
    "    from google.colab import files\n",
    "    from datasets import Dataset\n",
    "    uploaded = files.upload()  # 一次選 train.jsonl, validation.jsonl, test.jsonl\n",
    "    splits = {}\n",
    "    for fname in uploaded:\n",
    "        name = fname.split(\".\")[0]  # train / validation / test\n",
    "        splits[name] = Dataset.from_list([json.loads(l) for l in open(fname, encoding=\"utf-8\")])\n",
    "    raw = DatasetDict(splits)\n",
    "    print(raw)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "35f1fcb0",
   "metadata": {},
   "source": [
    "## 步驟 3c：把資料轉成 messages，並確認筆數\n",
    "\n",
    "這一格做三件事：\n",
    "1. 把 train / validation 轉成 chat 格式。`remove_columns` 會丟掉原本的欄位（包含病歷的 `text`），因為等一下訓練器會用「套完模板的文字」建立一個新的 `text` 欄位，不先清掉會混在一起。\n",
    "2. 另外保留 test 的**原始版本** `gold_test`（含標準答案 `phi`），評估時要拿它對答案。比喻：考卷發給學生，答案卷鎖在老師抽屜。\n",
    "3. 印出各集筆數與第一筆的標準答案，目視確認格式正確。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "401bf52a",
   "metadata": {},
   "outputs": [],
   "source": [
    "train_msgs = raw[\"train\"].map(to_chat, remove_columns=raw[\"train\"].column_names)\n",
    "val_msgs = raw[\"validation\"].map(to_chat, remove_columns=raw[\"validation\"].column_names)\n",
    "gold_test = [dict(r) for r in raw[\"test\"]]  # 評估用：保留原始欄位與標準答案\n",
    "\n",
    "print({k: len(v) for k, v in raw.items()})\n",
    "print(train_msgs[0][\"messages\"][2][\"content\"])  # 第一筆的標準答案"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c49ae308",
   "metadata": {},
   "source": [
    "## 步驟 4：載入模型 Gemma 4 E2B\n",
    "\n",
    "這一格把「還沒受過這項訓練的實習醫學生」請進來：`unsloth/gemma-4-E2B-it`（E2B 是約 2B 有效參數的小模型，`it` 表示已經學過聽指令）。\n",
    "\n",
    "逐行大意：\n",
    "- `from unsloth import FastModel`：Unsloth 的載入工具。\n",
    "- `model_name`：要載入哪個模型。\n",
    "- `dtype = None`：數字精度讓程式自動選（T4 不支援 bfloat16，會自動用 float16）。\n",
    "- `max_seq_length = 2048`：一次最多讀 2048 個 token（約一份病歷加答案的長度上限）。比喻：病歷夾的厚度上限，太厚的會被截斷。\n",
    "- `load_in_4bit = False`：照官方預設，用較高精度，品質較好但吃記憶體。**如果 T4（約 15 GB）記憶體不夠、出現 CUDA out of memory，就改成 `True`**，模型會被壓縮，像把 X 光片轉成較小的壓縮檔，省空間、略損細節。\n",
    "- `full_finetuning = False`：不重練整個模型，只練後面加的小模組（LoRA，下一步說明）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7ef4a4df",
   "metadata": {},
   "outputs": [],
   "source": [
    "from unsloth import FastModel\n",
    "import torch\n",
    "\n",
    "model, tokenizer = FastModel.from_pretrained(\n",
    "    model_name = \"unsloth/gemma-4-E2B-it\",\n",
    "    dtype = None, # None for auto detection\n",
    "    max_seq_length = 2048, # 一份病歷 + 答案的長度上限\n",
    "    load_in_4bit = False,  # T4 記憶體不夠（CUDA OOM）時改成 True\n",
    "    full_finetuning = False,\n",
    "    # token = \"YOUR_HF_TOKEN\", # HF Token for gated models\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7bb84fb5",
   "metadata": {},
   "source": [
    "## 步驟 5：加上 LoRA 配接器（只訓練語言層）\n",
    "\n",
    "LoRA 的概念：不改動整個模型（太貴、太慢），而是在旁邊貼上一小疊「便利貼」，只訓練便利貼。比喻：不重寫整本教科書，只在重點頁貼註解。\n",
    "\n",
    "各參數的醫學比喻：\n",
    "\n",
    "- `finetune_vision_layers = False`：不訓練「看圖」的部分。我們只處理文字，就像讀病歷不需要訓練放射科的讀片能力。\n",
    "- `finetune_language_layers = True`：訓練「語言」的部分，這是這次任務的核心。要求指定為只訓練語言層。\n",
    "- `finetune_attention_modules = True`：訓練注意力模組，負責「讀到這段時要回頭看哪裡」，像讀病歷時知道主訴要和現病史對照。\n",
    "- `finetune_mlp_modules = True`：訓練 MLP 模組，負責「把看到的資訊消化、整理」，像查房後在腦中整合出 assessment。\n",
    "- `r = 16`：便利貼的「厚度」（秩）。越大越能學複雜規則，但可能死背（過度擬合，overfitting）。像實習醫學生的筆記頁數：太少記不完，太多變成背題庫。\n",
    "- `lora_alpha = 16`：便利貼的「音量旋鈕」，控制註解影響力的大小。慣例是設成與 `r` 相同。\n",
    "- `lora_dropout = 0`：訓練時隨機遮住一部分便利貼來防止死背。這裡設 0 表示不遮（官方設定）。\n",
    "- `bias = \"none\"`：不額外訓練偏移量，維持簡單。\n",
    "- `random_state = 3407`：亂數種子，固定它就能讓別人重現同一個結果，像統計裡的 `set.seed`。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "642e8fe6",
   "metadata": {},
   "outputs": [],
   "source": [
    "model = FastModel.get_peft_model(\n",
    "    model,\n",
    "    finetune_vision_layers     = False, # Turn off for just text!\n",
    "    finetune_language_layers   = True,  # Should leave on!\n",
    "    finetune_attention_modules = True,  # Attention good for GRPO\n",
    "    finetune_mlp_modules       = True,  # Should leave on always!\n",
    "\n",
    "    r = 16,           # Larger = higher accuracy, but might overfit\n",
    "    lora_alpha = 16,  # Recommended alpha == r at least\n",
    "    lora_dropout = 0,\n",
    "    bias = \"none\",\n",
    "    random_state = 3407,\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cd75c3c4",
   "metadata": {},
   "source": [
    "## 步驟 6：套用 Gemma 4 的對話模板，並把資料變成「文字」\n",
    "\n",
    "不同模型有各自的對話格式，像不同醫院的病歷表頭不一樣。`get_chat_template(tokenizer, chat_template = \"gemma-4\")` 讓 tokenizer（把文字切成 token 的工具）用 Gemma 4 的格式，大致長這樣：\n",
    "\n",
    "```\n",
    "<bos><|turn>user\n",
    "Hello<turn|>\n",
    "<|turn>model\n",
    "Hey there!<turn|>\n",
    "```\n",
    "\n",
    "接著 `formatting_prompts_func` 把每筆 messages 轉成一段完整文字，存在 `text` 欄位。`.removeprefix('<bos>')` 是因為訓練器之後會自己加一個開頭符號 `<bos>`，這裡先去掉避免重複（官方作法）。\n",
    "\n",
    "最後印出第一筆，目視檢查：應該看得到 system 說明、病歷、JSON 答案，而且只有一個 `<bos>`（或沒有）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2bc90cf6",
   "metadata": {},
   "outputs": [],
   "source": [
    "from unsloth.chat_templates import get_chat_template\n",
    "tokenizer = get_chat_template(\n",
    "    tokenizer,\n",
    "    chat_template = \"gemma-4\",\n",
    ")\n",
    "\n",
    "def formatting_prompts_func(examples):\n",
    "   convos = examples[\"messages\"]\n",
    "   texts = [tokenizer.apply_chat_template(convo, tokenize = False, add_generation_prompt = False).removeprefix('<bos>') for convo in convos]\n",
    "   return { \"text\" : texts, }\n",
    "\n",
    "train_ds = train_msgs.map(formatting_prompts_func, batched = True)\n",
    "val_ds = val_msgs.map(formatting_prompts_func, batched = True)\n",
    "print(train_ds[0][\"text\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aef29655",
   "metadata": {},
   "source": [
    "## 步驟 7：評估用的函式（逐字複製自 `synth/evaluate.py`）\n",
    "\n",
    "下面的 `parse` 和 `score` 是專案評估程式的**原封不動複製**，不 import 本機檔案（Colab 上沒有那些檔案）。這樣 Colab 上的分數和 Mac 上的分數用同一把尺。\n",
    "\n",
    "- `parse(raw)`：把模型輸出的文字解析成 PHI 清單。模型若沒輸出合法 JSON，回傳 `None`，記為格式失敗。\n",
    "- `score(golds, preds)`：計算指標。\n",
    "  - **span_recall（主指標）**：標準答案中的每個 PHI 字串，只要出現在模型預測的集合裡就算抓到。因為遮蔽是「把預測字串的所有出現處都換掉」，漏抓一個就代表真的洩漏一個。比喻：像篩檢的敏感度（sensitivity），漏掉的就是 false negative，在去識別化是最不能接受的錯誤。\n",
    "  - **95% CI**：用 bootstrap 重抽樣 2000 次估計的信賴區間，顯示「測試集只有這麼多份病歷，分數大概會晃多大」。\n",
    "  - `precision_unique`：預測出的不重複字串中，有多少真的是 PHI（像陽性預測值）。\n",
    "  - `clean_note_rate`：整份病歷的 PHI 全抓到的比例。\n",
    "  - `valid_json_rate`：輸出是合法 JSON 的比例。\n",
    "  - `recall_by_type`：各 PHI 類型的 recall。\n",
    "\n",
    "這一格只定義函式，不會輸出東西。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b0092669",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json, random, re\n",
    "from collections import defaultdict\n",
    "\n",
    "# ---- 以下逐字複製自 synth/evaluate.py ----\n",
    "def parse(raw):\n",
    "    raw = re.sub(r\"<think>.*?</think>\", \"\", raw, flags=re.S)\n",
    "    raw = re.sub(r\"<\\|channel>.*?<channel\\|>\", \"\", raw, flags=re.S)\n",
    "    m = re.search(r\"\\{.*\\}\", raw, flags=re.S)\n",
    "    if not m:\n",
    "        return None\n",
    "    try:\n",
    "        obj = json.loads(m.group(0))\n",
    "        return [p for p in obj.get(\"phi\", []) if isinstance(p, dict) and isinstance(p.get(\"text\"), str)]\n",
    "    except (json.JSONDecodeError, AttributeError):\n",
    "        return None\n",
    "\n",
    "\n",
    "def score(golds, preds):\n",
    "    per_note, by_type = [], defaultdict(lambda: [0, 0])\n",
    "    tp_u = pred_u = 0\n",
    "    for g, p in zip(golds, preds):\n",
    "        ptexts = {x[\"text\"] for x in (p or []) if x[\"text\"].strip()}\n",
    "        caught = [s[\"text\"] in ptexts for s in g[\"phi\"]]\n",
    "        for s, c in zip(g[\"phi\"], caught):\n",
    "            by_type[s[\"type\"]][0] += c\n",
    "            by_type[s[\"type\"]][1] += 1\n",
    "        gtexts = {s[\"text\"] for s in g[\"phi\"]}\n",
    "        tp_u += len(ptexts & gtexts)\n",
    "        pred_u += len(ptexts)\n",
    "        per_note.append((sum(caught), len(caught)))\n",
    "    rec = sum(a for a, _ in per_note) / sum(b for _, b in per_note)\n",
    "    rng = random.Random(0)\n",
    "    boots = []\n",
    "    for _ in range(2000):\n",
    "        sample = [per_note[rng.randrange(len(per_note))] for _ in per_note]\n",
    "        boots.append(sum(a for a, _ in sample) / max(1, sum(b for _, b in sample)))\n",
    "    boots.sort()\n",
    "    prec = tp_u / pred_u if pred_u else 0.0\n",
    "    return {\n",
    "        \"n_notes\": len(golds),\n",
    "        \"span_recall\": round(rec, 4),\n",
    "        \"span_recall_95ci\": [round(boots[49], 4), round(boots[1949], 4)],\n",
    "        \"precision_unique\": round(prec, 4),\n",
    "        \"f1\": round(2 * prec * rec / (prec + rec), 4) if prec + rec else 0.0,\n",
    "        \"clean_note_rate\": round(sum(a == b for a, b in per_note) / len(per_note), 4),\n",
    "        \"valid_json_rate\": round(sum(p is not None for p in preds) / len(preds), 4),\n",
    "        \"recall_by_type\": {t: round(a / b, 4) for t, (a, b) in sorted(by_type.items())},\n",
    "    }\n",
    "# ---- 複製結束 ----"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "20e49490",
   "metadata": {},
   "source": [
    "## 步驟 8：產生預測的輔助函式，並先量一次「微調前」的分數\n",
    "\n",
    "要比較前後，就要先量一次基準線（baseline）。這一格定義 `predict_notes()`：對測試集每份病歷產生預測。\n",
    "\n",
    "逐行大意：\n",
    "- 把 `[system, user]` 兩段訊息套上模板，`add_generation_prompt = True` 表示「輪到模型說話了」。\n",
    "- `max_new_tokens = 500`：最多生成 500 個 token。\n",
    "- `do_sample = False`：greedy（貪婪）解碼，每一步都選機率最高的字，不抽籤。好處是同樣輸入每次輸出相同，評估才可重現；不使用官方聊天示範那組 `temperature / top_p / top_k` 隨機取樣。\n",
    "- 只解碼「新生成的部分」（`outputs[0][input_len:]`），不把題目一起讀回來。\n",
    "- `parse()` 把輸出轉成 PHI 清單。\n",
    "\n",
    "`EVAL_LIMIT` 預設為 20：T4 上 Gemma 4 只能用 float32、逐份生成很慢（實測 base 約 45 秒/份、LoRA 約 25 秒/份），全測 200 份需數小時。要全測時設為 `None`。注意 20 份樣本很小，CI 會很寬，與 Mac 的 200 份結果不可直接比較。\n",
    "\n",
    "**關於「微調前」**：此刻 LoRA 便利貼剛貼上去、尚未訓練（其初始值不改變模型輸出），所以現在量到的就是基礎模型的分數。請務必在訓練之前執行這一格，並把結果存在 `base_result`。\n",
    "\n",
    "`enable_thinking = False` 是額外的 template 參數，沿用專案 Mac 端 evaluate.py 的設定，要關掉思考模式；此參數在 Colab 版 Gemma 4 template 是否生效待實測。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8ed9dae9",
   "metadata": {},
   "outputs": [],
   "source": [
    "import time\n",
    "\n",
    "# Gemma 4 的 processor 要求 content 是「片段清單」格式：[{\"type\": \"text\", \"text\": ...}]\n",
    "# （它同時支援圖片、音訊）。直接傳字串會出現 TypeError: string indices must be integers。\n",
    "def as_parts(msgs):\n",
    "    return [{\"role\": m[\"role\"], \"content\": [{\"type\": \"text\", \"text\": m[\"content\"]}]} for m in msgs]\n",
    "\n",
    "# T4 上 Gemma 4 只能用 float32、逐份生成很慢，全測 200 份需數小時；要全測時設為 None\n",
    "EVAL_LIMIT = 20\n",
    "\n",
    "def predict_notes(golds, max_new_tokens = 500):\n",
    "    preds, raws = [], []\n",
    "    t0 = time.time()\n",
    "    for i, g in enumerate(golds):\n",
    "        msgs = [{\"role\": \"system\", \"content\": SYSTEM}, {\"role\": \"user\", \"content\": g[\"text\"]}]\n",
    "        inputs = tokenizer.apply_chat_template(\n",
    "            as_parts(msgs),\n",
    "            add_generation_prompt = True, # Must add for generation\n",
    "            return_tensors = \"pt\",\n",
    "            tokenize = True,\n",
    "            return_dict = True,\n",
    "            enable_thinking = False,      # 待實測：此參數在此 template 是否生效\n",
    "        ).to(\"cuda\")\n",
    "        outputs = model.generate(\n",
    "            **inputs,\n",
    "            max_new_tokens = max_new_tokens,\n",
    "            do_sample = False,            # greedy：結果可重現\n",
    "        )\n",
    "        text = tokenizer.decode(outputs[0][inputs[\"input_ids\"].shape[1]:], skip_special_tokens = True)\n",
    "        raws.append(text)\n",
    "        preds.append(parse(text))\n",
    "        if (i + 1) % 10 == 0:\n",
    "            print(f\"{i + 1}/{len(golds)}  {time.time() - t0:.0f}s\", flush = True)\n",
    "    return preds, raws\n",
    "\n",
    "golds = gold_test[:EVAL_LIMIT]\n",
    "base_preds, base_raws = predict_notes(golds)\n",
    "base_result = score(golds, base_preds)\n",
    "print(json.dumps(base_result, ensure_ascii = False, indent = 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0717c935",
   "metadata": {},
   "source": [
    "## 步驟 9：設定訓練器（SFTTrainer）\n",
    "\n",
    "SFTTrainer 是「帶教老師」，負責監督式微調（SFT：給題目＋標準答案，讓模型模仿）。\n",
    "\n",
    "逐項說明（每項都有醫學比喻）：\n",
    "- `per_device_train_batch_size = 2`：一次看 2 份病歷。像一次查 2 床。\n",
    "- `gradient_accumulation_steps = 4`：看完 4 小批再一起更新一次，等效一次看 2 x 4 = 8 份。像累積 8 床的查房心得再統一修正筆記，省記憶體又保有大批次的穩定。\n",
    "- `num_train_epochs = 2`：整套訓練資料看 2 輪。像同一本題庫刷兩遍。\n",
    "- `learning_rate = 1e-4`：每次修正的幅度。太大會學歪，太小學不動。官方示範是 2e-4，這裡依需求用 1e-4，較保守。\n",
    "- `warmup_steps = 5`：前 5 步先小幅度起步，像手術前先暖身。\n",
    "- `logging_steps = 10`：每 10 步印一次 loss（錯誤程度，越小越好）。\n",
    "- `eval_strategy = \"epoch\"` 與 `per_device_eval_batch_size = 2`：每輪結束用 validation 小考一次，印出 eval loss。這是 Hugging Face transformers 的標準訓練參數（非 Unsloth 專屬）；其在 T4 上的記憶體影響待實測。\n",
    "- `eval_dataset = val_ds`：隨堂小考用的資料。\n",
    "- `optim = \"adamw_8bit\"`、`weight_decay = 0.001`、`lr_scheduler_type = \"linear\"`、`seed = 3407`：優化器與排程，沿用官方。\n",
    "- `report_to = \"none\"`：不把紀錄送到外部追蹤服務。\n",
    "\n",
    "注意：官方示範用 `max_steps = 60` 求快；這裡改用 `num_train_epochs = 2` 跑完整訓練。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b355c4ae",
   "metadata": {},
   "outputs": [],
   "source": [
    "from trl import SFTTrainer, SFTConfig\n",
    "trainer = SFTTrainer(\n",
    "    model = model,\n",
    "    tokenizer = tokenizer,\n",
    "    train_dataset = train_ds,\n",
    "    eval_dataset = val_ds, # 隨堂小考\n",
    "    args = SFTConfig(\n",
    "        dataset_text_field = \"text\",\n",
    "        per_device_train_batch_size = 2,\n",
    "        gradient_accumulation_steps = 4, # Use GA to mimic batch size!\n",
    "        warmup_steps = 5,\n",
    "        num_train_epochs = 2,\n",
    "        learning_rate = 1e-4,\n",
    "        logging_steps = 10,\n",
    "        eval_strategy = \"epoch\",\n",
    "        per_device_eval_batch_size = 2,\n",
    "        optim = \"adamw_8bit\",\n",
    "        weight_decay = 0.001,\n",
    "        lr_scheduler_type = \"linear\",\n",
    "        seed = 3407,\n",
    "        report_to = \"none\", # Use TrackIO/WandB etc\n",
    "    ),\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "51206642",
   "metadata": {},
   "source": [
    "## 步驟 10：只對「答案」計算學習成績（train_on_responses_only）\n",
    "\n",
    "預設情況下，模型會連「題目（病歷本文）」也一起學著背。但我們只希望它學會「看到病歷後該輸出什麼」。`train_on_responses_only` 把題目部分遮起來、不計入損失（loss），只有 assistant 的 JSON 答案才算分。比喻：批改作業只看答案欄，不批改題目抄寫。\n",
    "\n",
    "Unsloth 會自動從模板偵測哪段是題目、哪段是答案，所以不需要手動指定（官方作法）。\n",
    "\n",
    "套用完會再用下一格驗證遮罩是否正確。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "55a3bae0",
   "metadata": {},
   "outputs": [],
   "source": [
    "from unsloth.chat_templates import train_on_responses_only\n",
    "trainer = train_on_responses_only(trainer)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "31c70b30",
   "metadata": {},
   "source": [
    "## 步驟 10b：檢查遮罩有沒有生效\n",
    "\n",
    "這一格先印出完整輸入（題目＋答案），再印出「被計分」的部分：被遮掉的位置（標籤為 -100）換成空白，所以只應剩 JSON 答案。若還看到病歷本文，代表遮罩沒生效。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "451aa9d1",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(tokenizer.decode(trainer.train_dataset[0][\"input_ids\"]))\n",
    "print(\"=\" * 40)\n",
    "# 被遮掉的位置（-100）換成空白，只剩計分的答案部分\n",
    "print(tokenizer.decode([tokenizer.pad_token_id if x == -100 else x for x in trainer.train_dataset[0][\"labels\"]]).replace(tokenizer.pad_token, \" \"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5411f0c1",
   "metadata": {},
   "source": [
    "## 步驟 11：訓練前的記憶體基準（官方 cell）\n",
    "\n",
    "記下訓練開始前 GPU 用了多少記憶體，訓練完才能算出「訓練本身多吃了多少」。像開刀前先量基礎生命徵象，術後才有得比較。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "80151c4a",
   "metadata": {},
   "outputs": [],
   "source": [
    "# @title Show current memory stats\n",
    "gpu_stats = torch.cuda.get_device_properties(0)\n",
    "start_gpu_memory = round(torch.cuda.max_memory_reserved() / 1024 / 1024 / 1024, 3)\n",
    "max_memory = round(gpu_stats.total_memory / 1024 / 1024 / 1024, 3)\n",
    "print(f\"GPU = {gpu_stats.name}. Max memory = {max_memory} GB.\")\n",
    "print(f\"{start_gpu_memory} GB of memory reserved.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a0cb41a1",
   "metadata": {},
   "source": [
    "## 步驟 12：開始訓練\n",
    "\n",
    "`trainer.train()` 就是正式開始練習。畫面會每 10 步印一次 training loss，每輪結束印一次 validation loss。\n",
    "\n",
    "怎麼看：\n",
    "- training loss 大致往下 → 正常學習中。\n",
    "- validation loss 若先降後升，可能開始死背（過度擬合）。\n",
    "- 若中斷想接續，改成 `trainer.train(resume_from_checkpoint = True)`。\n",
    "\n",
    "實測時間：176 steps 共 1419 秒（23.7 分鐘），peak reserved 記憶體 11.41 GB（78%），可訓練參數 25.3M（0.49%）。Unsloth 會提示 Gemma 4 在 T4 改用 float32（T4 無 bf16）。Colab 閒置太久會斷線，請保持分頁開著。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8ad41b28",
   "metadata": {},
   "outputs": [],
   "source": [
    "trainer_stats = trainer.train()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d3d77162",
   "metadata": {},
   "source": [
    "## 步驟 13：訓練後的記憶體與時間統計（官方 cell）\n",
    "\n",
    "印出訓練花了多久、尖峰記憶體多少，以及訓練本身多吃的記憶體。T4 實測：訓練 1419 秒（23.7 分鐘）、peak reserved 11.41 GB（78%）、可訓練參數 25.3M（0.49%）。Unsloth 會提示 Gemma 4 在 T4 改用 float32，所以訓練前模型就已佔約 10 GB。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7d77184f",
   "metadata": {},
   "outputs": [],
   "source": [
    "# @title Show final memory and time stats\n",
    "used_memory = round(torch.cuda.max_memory_reserved() / 1024 / 1024 / 1024, 3)\n",
    "used_memory_for_lora = round(used_memory - start_gpu_memory, 3)\n",
    "used_percentage = round(used_memory / max_memory * 100, 3)\n",
    "lora_percentage = round(used_memory_for_lora / max_memory * 100, 3)\n",
    "print(f\"{trainer_stats.metrics['train_runtime']} seconds used for training.\")\n",
    "print(\n",
    "    f\"{round(trainer_stats.metrics['train_runtime']/60, 2)} minutes used for training.\"\n",
    ")\n",
    "print(f\"Peak reserved memory = {used_memory} GB.\")\n",
    "print(f\"Peak reserved memory for training = {used_memory_for_lora} GB.\")\n",
    "print(f\"Peak reserved memory % of max memory = {used_percentage} %.\")\n",
    "print(f\"Peak reserved memory for training % of max memory = {lora_percentage} %.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1f614e37",
   "metadata": {},
   "source": [
    "## 步驟 14：微調後評估，並與「微調前」比較\n",
    "\n",
    "這一格用**完全相同的測試集、相同的解碼設定（greedy、500 tokens）、相同的 `parse` / `score`**，量微調後的分數，再和步驟 8 的 `base_result` 並排印出：\n",
    "\n",
    "- `span_recall` 與 95% CI（主指標）\n",
    "- `valid_json_rate`（輸出是否為合法 JSON）\n",
    "- 各類型 recall（NAME、DOCTOR、MRN……哪一類容易漏）\n",
    "\n",
    "**怎麼解讀 CI（請照這個措辭，不要寫過頭）：**\n",
    "- 兩者 95% CI **不重疊**：可以說「在這份測試集上，微調後的 span_recall 高於微調前，兩者信賴區間不重疊」。\n",
    "- 兩者 95% CI **有重疊**：只能說「在這份測試集上未顯示明確差異」。**不可寫成「兩者一樣」或「沒有差別」**：信賴區間重疊代表樣本不足以區分，不代表真的相同。\n",
    "- 兩條 CI 各自獨立估計，「不重疊」是保守的判準；「有重疊」並不等於「沒差異」，要證明相同需要另外設計（例如預設 margin 的非劣性檢定）。\n",
    "- 這些分數只代表在**合成資料**上的表現。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8d1ee4a5",
   "metadata": {},
   "outputs": [],
   "source": [
    "ft_preds, ft_raws = predict_notes(golds)\n",
    "ft_result = score(golds, ft_preds)\n",
    "\n",
    "def show(name, r):\n",
    "    lo, hi = r[\"span_recall_95ci\"]\n",
    "    print(f\"[{name}] n_notes={r['n_notes']}  span_recall={r['span_recall']:.3f} (95% CI {lo:.3f}-{hi:.3f})  \"\n",
    "          f\"valid_json_rate={r['valid_json_rate']:.3f}  precision_unique={r['precision_unique']:.3f}  \"\n",
    "          f\"clean_note_rate={r['clean_note_rate']:.3f}\")\n",
    "\n",
    "show(\"fine-tune 前\", base_result)\n",
    "show(\"fine-tune 後\", ft_result)\n",
    "\n",
    "print(\"\\n各類型 recall：\")\n",
    "types = sorted(set(base_result[\"recall_by_type\"]) | set(ft_result[\"recall_by_type\"]))\n",
    "print(f\"{'type':10s}{'前':>8s}{'後':>8s}\")\n",
    "for t in types:\n",
    "    print(f\"{t:10s}{base_result['recall_by_type'].get(t, float('nan')):8.3f}{ft_result['recall_by_type'].get(t, float('nan')):8.3f}\")\n",
    "\n",
    "b_lo, b_hi = base_result[\"span_recall_95ci\"]\n",
    "f_lo, f_hi = ft_result[\"span_recall_95ci\"]\n",
    "print()\n",
    "if f_lo > b_hi:\n",
    "    print(\"在這份合成測試集上，微調後 span_recall 高於微調前，兩者 95% CI 不重疊。\")\n",
    "elif b_lo > f_hi:\n",
    "    print(\"在這份合成測試集上，微調後 span_recall 低於微調前，兩者 95% CI 不重疊。\")\n",
    "else:\n",
    "    print(\"兩者 95% CI 有重疊：在這份合成測試集上未顯示明確差異（不代表兩者相同，樣本可能不足以區分）。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b5c53225",
   "metadata": {},
   "source": [
    "## 步驟 15：實際試用，並用輸出的清單遮蔽病歷\n",
    "\n",
    "最後做一次完整流程：貼一份**全新的合成病歷**（下面這份是虛構的，姓名、號碼皆非真人），讓模型找出 PHI，再用 Python 把每個 PHI 字串換成 `[TYPE]` 標籤。\n",
    "\n",
    "遮蔽邏輯：\n",
    "1. `parse()` 取得 PHI 清單。\n",
    "2. 依字串長度由長到短排序再取代，避免短字串先被換掉、破壞長字串（像先處理整串地址，再處理其中的門牌號碼）。\n",
    "3. 把每個出現處換成 `[類型]`。\n",
    "\n",
    "重要：遮蔽只會去掉模型「抓到」的字串，**漏抓的 PHI 會原樣留在輸出裡**。這就是為什麼我們用 span_recall 當主指標。你可以把 `new_note` 換成自己編的合成病歷再試。**請勿貼真實病歷。**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "bad38b7a",
   "metadata": {},
   "outputs": [],
   "source": [
    "new_note = (\n",
    "    \"【急診病歷】王小明 32y/o male，MRN 4821937，身分證 K187654320。\"\n",
    "    \"2024/03/15 因 RLQ pain 18 hrs 到青嵐綜合醫院急診，BT 38.1°C, HR 98, WBC 14,200。\"\n",
    "    \"CT: dilated appendix with periappendiceal fat stranding，impression: acute appendicitis，已會診一般外科 吳志遠 醫師。\"\n",
    "    \"家屬王美玲 (姊姊) 電話 0912-345-678，住 竹田縣星湖鄉月橋路 88 號。\"\n",
    ")\n",
    "\n",
    "msgs = [{\"role\": \"system\", \"content\": SYSTEM}, {\"role\": \"user\", \"content\": new_note}]\n",
    "inputs = tokenizer.apply_chat_template(\n",
    "    as_parts(msgs), add_generation_prompt = True, return_tensors = \"pt\", tokenize = True, return_dict = True,\n",
    "    enable_thinking = False,\n",
    ").to(\"cuda\")\n",
    "outputs = model.generate(**inputs, max_new_tokens = 500, do_sample = False)\n",
    "raw_out = tokenizer.decode(outputs[0][inputs[\"input_ids\"].shape[1]:], skip_special_tokens = True)\n",
    "print(\"模型輸出：\", raw_out)\n",
    "\n",
    "phi = parse(raw_out) or []\n",
    "\n",
    "def mask_note(text, phi_list):\n",
    "    # 由長到短取代；同一字串出現多次只取第一個類型\n",
    "    type_of = {}\n",
    "    for p in phi_list:\n",
    "        type_of.setdefault(p[\"text\"], p.get(\"type\", \"PHI\"))\n",
    "    for s in sorted(type_of, key = len, reverse = True):\n",
    "        if s.strip():\n",
    "            text = text.replace(s, f\"[{type_of[s]}]\")\n",
    "    return text\n",
    "\n",
    "print(\"\\n遮蔽後：\")\n",
    "print(mask_note(new_note, phi))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b461decc",
   "metadata": {},
   "source": [
    "## 步驟 16：匯出（Colab 免費版匯出完整 GGUF 實測會失敗）\n",
    "\n",
    "**實測結果**：`save_pretrained_gguf` 合併 16-bit 權重成功（約 9.5 GB），但轉成 GGUF 時 host RAM（Colab 免費版約 12.7 GB）不足，被系統終止（SIGKILL）；Unsloth 以 `--use-temp-file` 自動重試仍被終止。原因是 Gemma 4 E2B 的 `per_layer_token_embd` 約 23.5 億參數（8960 x 262144），轉檔時記憶體吃不下。所以下面的 GGUF 匯出格保留 `if False`，只在 RAM 更大的環境才可能成功。\n",
    "\n",
    "**建議的替代路線**：Colab 只存 LoRA adapter（體積小、已確認可存），帶回 Mac 轉換。\n",
    "\n",
    "1. 執行下一格，把 adapter 打包成 zip 並下載到 Mac（下載那行也包在 `if False` 裡，避免 Run all 時自動下載；要下載時改成 `True`）。\n",
    "2. 在 Mac 用 llama.cpp 的 `convert_lora_to_gguf.py` 轉成 LoRA GGUF（**待實測**）：\n",
    "   ```bash\n",
    "   python convert_lora_to_gguf.py --base-model-id google/gemma-4-E2B-it lora_adapter --outfile chartkeeper_lora.gguf\n",
    "   ```\n",
    "   也可以用 `--base` 指向本機已下載的 HF 模型資料夾。\n",
    "3. 寫 Modelfile（**待實測**）：\n",
    "   ```\n",
    "   FROM gemma4:e2b\n",
    "   ADAPTER ./chartkeeper_lora.gguf\n",
    "   ```\n",
    "   再執行 `ollama create chartkeeper -f Modelfile`。\n",
    "\n",
    "Unsloth 預裝的 llama.cpp 沒有 `convert_lora_to_gguf.py`，所以第 2 步宜在 Mac 上做，不在 Colab 裡做。Mac 主線（mlx fuse 後轉 GGUF）仍是本專案實測成功的推薦路線。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "934ce584",
   "metadata": {},
   "outputs": [],
   "source": [
    "# 只存 LoRA adapter（PEFT 格式：adapter_config.json + adapter_model.safetensors）\n",
    "model.save_pretrained(\"lora_adapter\")\n",
    "tokenizer.save_pretrained(\"lora_adapter\")\n",
    "\n",
    "import shutil\n",
    "shutil.make_archive(\"lora_adapter\", \"zip\", \"lora_adapter\")\n",
    "print(\"已打包：lora_adapter.zip\")\n",
    "\n",
    "if False: # 要下載到電腦時改成 True\n",
    "    from google.colab import files\n",
    "    files.download(\"lora_adapter.zip\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b66bf1f4",
   "metadata": {},
   "outputs": [],
   "source": [
    "if False: # Change to True to save to GGUF\n",
    "    model.save_pretrained_gguf(\n",
    "        \"chartkeeper_gemma4_e2b\",\n",
    "        tokenizer,\n",
    "        quantization_method = \"Q8_0\", # For now only Q8_0, BF16, F16 supported\n",
    "    )"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "23a187ec",
   "metadata": {},
   "source": [
    "## 步驟 16b：下載 .gguf 並產生 Modelfile（僅在你用更大 RAM 的環境成功匯出 GGUF 時才適用）\n",
    "\n",
    "若你用更大 RAM 的環境成功匯出 GGUF 才適用；Colab 免費版請改走上面的 adapter 路線。這一格找出剛匯出的 `.gguf`，用 Colab 的 `files.download` 傳到你的電腦（瀏覽器會開始下載），同時產生一份 `Modelfile` 一起下載。Modelfile 像是「這個模型的處方箋」：告訴 Ollama 要載入哪個檔、系統提示是什麼、取樣要不要隨機。\n",
    "\n",
    "其中 `SYSTEM` 沿用與訓練相同的任務說明。`TEMPLATE`（Gemma 4 對話模板）這裡**不寫**：Ollama 能否自動套用正確模板待實測，若輸出怪異，需要手動補 TEMPLATE。\n",
    "\n",
    "**把檔案放到 Mac 後的指令**（不在 Colab 裡跑，在 Mac 終端機）：\n",
    "\n",
    "```bash\n",
    "# 假設 .gguf 與 Modelfile 都在 ~/Downloads/chartkeeper/\n",
    "cd ~/Downloads/chartkeeper\n",
    "# Modelfile 第一行應為：FROM ./<你的檔名>.gguf\n",
    "ollama create chartkeeper -f Modelfile\n",
    "ollama run chartkeeper\n",
    "```\n",
    "\n",
    "再提醒一次：Unsloth 產的 GGUF 能否被 Ollama 0.35 載入待實測（E5）。載入失敗時，請以 llama.cpp 直接跑，並回報錯誤訊息。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "36f95c3e",
   "metadata": {},
   "outputs": [],
   "source": [
    "import glob\n",
    "from google.colab import files\n",
    "\n",
    "gguf_files = glob.glob(\"chartkeeper_gemma4_e2b*/*.gguf\") + glob.glob(\"chartkeeper_gemma4_e2b*.gguf\")\n",
    "print(\"找到的 GGUF：\", gguf_files)\n",
    "\n",
    "if gguf_files:\n",
    "    gguf_path = gguf_files[0]\n",
    "    modelfile = f'''FROM ./{os.path.basename(gguf_path)}\n",
    "PARAMETER temperature 0\n",
    "SYSTEM \"\"\"{SYSTEM}\"\"\"\n",
    "'''\n",
    "    open(\"Modelfile\", \"w\", encoding=\"utf-8\").write(modelfile)\n",
    "    print(modelfile)\n",
    "    files.download(\"Modelfile\")\n",
    "    files.download(gguf_path)  # 檔案可能數 GB，下載需要時間\n",
    "else:\n",
    "    print(\"尚未匯出 GGUF：請先把上一格的 if False 改成 if True 並執行。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f7091acc",
   "metadata": {},
   "source": [
    "## 這本 notebook 的限制（請務必讀完）\n",
    "\n",
    "1. **分數只代表在合成資料上的表現。** 測試集是用程式從假資料填出來的，格式與寫法比真實病歷單純；在這裡得到的 span_recall，不能推論到真實病歷。\n",
    "2. **不代表可用於真實病歷。** 本 notebook 產出的模型不是經過驗證的臨床去識別化工具，不可用來處理、分享或發表真實病人資料。\n",
    "3. **真實病歷需要院方核准與 IRB。** 若要用真實病歷做研究，必須取得院方同意與倫理審查（IRB）核准，並在院內合規的環境中處理，**不可上傳到 Colab 等外部雲端**。\n",
    "4. 測試集筆數有限，95% CI 可能很寬；CI 重疊時不能下「兩者相同」的結論。\n",
    "5. `enable_thinking` 是否生效待實測；Colab 免費版匯出完整 GGUF 實測失敗（見步驟 16）；adapter 轉 LoRA GGUF 後能否被 Ollama 載入待實測。\n",
    "6. 作者為醫學系畢業的教學作者，本教材為學習用途，不構成醫療或法律建議。"
   ]
  }
 ],
 "metadata": {
  "accelerator": "GPU",
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
