韓國急診檢傷與急迫度分級量表(Korean Triage and Acuity Scale, KTAS)是一套以症狀為導向的五級急診檢傷制度。第一級最緊急,第五級最低;它的目的不是替病患下診斷,而是決定誰需要更快接受評估與處置。
今天要看的 Kaggle 公開資料只有 1,267 筆、24 個欄位,原始壓縮檔中也只有一個 data.csv。檔案看起來不複雜,但真正的第一個問題不是「怎麼訓練模型」,而是:24 個欄位中,哪些資訊在作出檢傷級數前已經知道?
例如,同一列同時有病患主訴、血壓、護理師登錄級數、專家重新判定級數、急診診斷、住院去向與急診停留時間。如果把整列直接交給模型,模型可能利用診斷或住院結果猜級數。這種高分不是模型學會檢傷,而是模型偷看了決策之後才會知道的資訊。
下圖先把整件事畫成一張工作台。左側是到院時能取得的資料,中間是兩種不同角色的人工級數,右側紅色盒子則隔離診療後才形成的結果。

上圖的重點是「時間」,不是欄位和嚴重度的相關性。即使住進加護病房和高急迫度高度相關,只要住院去向在檢傷決定後才形成,就不能成為檢傷輸入。
讀完 Day 11 後,你應該能夠:
data.csv,確認分隔符、文字編碼與檔案版本。KTAS_RN、KTAS_expert、Error_group 與 mistriage 的不同角色。| 中文名稱 | 英文全名/縮寫 | 本篇用途 |
|---|---|---|
| 韓國急診檢傷與急迫度分級量表 | Korean Triage and Acuity Scale, KTAS | 本篇資料所使用的五級檢傷制度 |
| 急診部門 | Emergency Department, ED | 產生這批病歷紀錄的臨床場域 |
| 疼痛數字評定量表 | Numeric Rating Scale, NRS | 以 0 至 10 分描述病患自述疼痛強度 |
| 收縮壓 | Systolic Blood Pressure, SBP | 心臟收縮時的動脈壓力 |
| 舒張壓 | Diastolic Blood Pressure, DBP | 心臟舒張時的動脈壓力 |
| 心率 | Heart Rate, HR | 每分鐘心跳次數 |
| 呼吸率 | Respiratory Rate, RR | 每分鐘呼吸次數 |
| 體溫 | Body Temperature, BT | 本篇生命徵象欄位之一 |
| 周邊血氧飽和度 | Peripheral Oxygen Saturation, SpO2 | 以脈搏血氧儀估計的血氧比例;CSV 欄名是 Saturation |
| 參考標籤 | Reference Label | 評估模型預測時用來比較的級數 |
| 資料洩漏 | Data Leakage | 模型在訓練或測試時看到決策當下不該知道的資訊 |
| 檢傷過度 | Overtriage | 把病患判得比參考級數更緊急 |
| 檢傷不足 | Undertriage | 把病患判得比參考級數不緊急 |
| 逗號分隔值 | Comma-Separated Values, CSV | Kaggle 原始檔使用的表格格式;本檔實際以分號切欄 |
| Windows-1254 | Windows-1254 / cp1254 | 正確解碼 Kaggle CSV 中特殊字元所需的文字編碼 |
這份詞彙表先提供快速索引。下面遇到每個名詞時,還會說明它在資料處理流程中的實際作用。
本篇使用 Kaggle:Emergency Service - Triage Application提供的 data.csv。Kaggle 頁面將資料出處連回 PLOS ONE 在 2019 年發布的 KTAS 檢傷準確性研究。來源研究採回溯方式檢視急診病歷,也就是在臨床流程完成後,回頭分析既有紀錄,而不是讓第二組人員在現場重新檢傷病患。
急診部門(Emergency Department, ED)就是病患接受緊急初步評估與治療的場域。資料來自韓國兩所都市教學醫院:一所區域急診每年約 45,000 次就醫,一所地方急診每年約 40,000 次就醫。這兩所醫院的工作流程並不完全相同,因此 CSV 保留 Group 欄位,讓研究者知道資料來自哪一種場域。
原始研究的資料形成順序如下:
因此,1,267 不是兩所醫院整個年度的完整急診母體,也不是隨機抽出 1,267 位已確認互不重複的病患。CSV 沒有病患識別碼,我們只能確定有 1,267 列病歷紀錄,不能證明每一列都來自不同病患。
這個限制會影響後續資料切分:沒有病患識別碼,就不能保證同一病患的重複就醫不會同時落入訓練與測試集合。Day 15 設計切分策略時,必須把這件事明確列為限制,不能宣稱完成病患層級切分。
研究團隊挑選三位急診檢傷專家。入選條件包括急診護理背景、KTAS 提供者與講師資格、至少七年急診經驗,以及良好的檢傷能力。三位專家的急診經驗介於 12 到 18 年。
在正式檢視病歷前,研究團隊先用九個病患情境檢查三位專家的判定一致性。研究報告的組內相關係數(Intraclass Correlation Coefficient, ICC)為 0.92。ICC 在這裡用來表示三位專家對相同情境的評分有多一致;數值越接近 1,一致程度越高。
接著,專家依初始護理紀錄重新判定 KTAS 級數。CSV 把急診護理師當下登錄的級數放在 KTAS_RN,把研究定義的專家重新判定級數放在 KTAS_expert。這兩欄都很重要,但用途完全不同,後面會再逐一拆解。
本系列的病患資料下載位置固定在 Kaggle,並以來源研究補充抽樣方法、專家資格與限制。這兩個角色不能混在一起:
data.csv。我們不會把逐筆 CSV 放進專案的版本控制。專案只保存 Kaggle slug、下載日期、檔案雜湊、欄位政策、處理程式與聚合統計。這樣既能讓讀者重新取得資料,也能避免自己的專案變成另一個來源不明的資料鏡像。
以下步驟都在專案根目錄執行。data/raw/ 已被 .gitignore 排除,因此下載檔不會進入版本控制;但執行 git status 前仍應再次確認。
data/raw/ktas/。mkdir -p data/raw/ktas
test -d data/raw/ktas && echo "目錄已建立",應看到「目錄已建立」。curl 與 unzip,並可連上 Kaggle。curl -L \
'https://www.kaggle.com/api/v1/datasets/download/ilkeryildiz/emergency-service-triage-application' \
-o data/raw/ktas/ktas-kaggle.zip
unzip data/raw/ktas/ktas-kaggle.zip -d data/raw/ktas
data/raw/ktas/ 會出現 ktas-kaggle.zip 與 data.csv。ls -lh data/raw/ktas/,確認兩個檔案都不是 0 位元組。若 Kaggle 調整下載流程,請依資料頁當下提供的方式取得同一個 dataset slug,再核對下一步雜湊。安全雜湊演算法 256 位元版本(Secure Hash Algorithm 256-bit, SHA-256)會把檔案內容轉成固定長度的十六進位字串。只要檔案有一個位元不同,檢查碼通常就會改變,因此可以用來鎖定本次使用的 Kaggle 版本。
data.csv 已下載完成。shasum -a 256 data/raw/ktas/ktas-kaggle.zip data/raw/ktas/data.csv
f78df0a06c31b6df873c410b0596ecb95ac9335485beaf890dcabf7c69560f26 data/raw/ktas/ktas-kaggle.zip
0e2c088e358fd4cdfd0dcc2fd4c2f085aa303856e3a6df8ceb44a69cef8ed2de data/raw/ktas/data.csv
data.csv 第一列是欄名,後面 1,267 列是紀錄,共有 24 欄。雖然副檔名叫 CSV,實際上不是使用逗號,而是使用分號 ; 切欄;若直接採用預設逗號讀取,整列可能被錯誤讀成單一欄位。
CSV 也不是完整資料字典。Kaggle 頁面解釋多數類別代碼,但不代表每個數值欄位的單位、收集細節與字串正規化都已完整說明。正確作法是把「來源未定義」寫進欄位政策,並限制可支持的結論。
下圖把 24 欄分成四個可閱讀的區域。請先看每個區域的角色,再看欄位名稱。

上圖顯示,Group 和每小時到院人數即使在資料裡,也沒有放進預設模型輸入。另一方面,診斷、去向和停留時間不是因為「品質不好」才排除,而是因為它們的取得時點太晚。
候選輸入表示「時點原則上合理」,不表示已經證明每欄都應進模型。14 欄可再分成四組:
| 類型 | 來源欄位 | 白話說明 | 清理重點 |
|---|---|---|---|
| 人口與到院 | Sex、Age、Arrival mode、Injury |
生理性別、年齡、到院方式、是否外傷 | 類別代碼不能當成有距離的連續數字 |
| 主訴文字 | Chief_complain |
病患最主要的不舒服或問題 | Kaggle CSV 已是英文;保留原字串並另記大小寫、空白與拼字正規化 |
| 意識與疼痛 | Mental、Pain、NRS_pain |
意識反應、是否疼痛、疼痛強度 | 「沒有分數」不等於「疼痛為 0」 |
| 生命徵象 | SBP、DBP、HR、RR、BT、Saturation |
血壓、心率、呼吸率、體溫與血氧 | 除空白外,還有文字型無法測量代碼 |
到院方式共有七個代碼。Kaggle 頁面明確列出步行、公共救護車、私人車輛與私人救護車,並把代碼 5、6、7 歸入其他;若要把「其他」再細分,必須先找到可追溯的對照來源。這些數字只是類別代碼,例如代碼 6 不代表比代碼 3「多兩倍」或「更嚴重」。後續建模時要把它當類別,而不是直接計算平均值。
Mental 是有順序的意識反應:清醒、對聲音反應、對疼痛反應、無反應。雖然代碼有順序,模型仍需要知道數字方向代表意識狀態變差,不能只把 1 到 4 當成不具語意的文字。
疼痛數字評定量表(Numeric Rating Scale, NRS)讓病患以 0 到 10 分表達疼痛,0 通常表示沒有疼痛,10 表示可想像的最嚴重疼痛。CSV 把「是否疼痛」放在 Pain,把實際分數放在 NRS_pain。兩欄不能任意合併:NRS_pain 的 #BOŞ! 可能是沒有記錄、無法評估或其他流程原因,不應直接補成 0。
生命徵象欄位包括收縮壓(Systolic Blood Pressure, SBP)、舒張壓(Diastolic Blood Pressure, DBP)、心率(Heart Rate, HR)、呼吸率(Respiratory Rate, RR)、體溫(Body Temperature, BT)與周邊血氧飽和度(Peripheral Oxygen Saturation, SpO2)。CSV 的血氧欄名是 Saturation,不是 SpO2;程式會保留來源欄名,文章才使用臨床常用縮寫解釋。
Kaggle 資料頁沒有替每個數值欄逐一提供完整測量說明,因此本篇不靠猜測自動做單位換算。後續建立正式資料字典時,可以依來源研究與常見臨床單位提出假設,但必須把來源未明列細節的限制保留下來。
Group 表示地方急診或區域急診。兩個場域共有 688 筆與 579 筆紀錄。把 Group 放進模型可能讓模型學到「這家醫院通常怎麼分級」,而不是學會病患狀況。因此預設只用它檢查兩個場域的缺失率與錯誤差異。
Patients number per hour 表示每小時到院人數。原始研究用它比較檢傷正確與不正確紀錄所處的人流環境,但 CSV 沒有提供可重建時間窗的逐筆時間戳。這個欄位先用於壅塞分層分析,不放進預設模型。
KTAS_RN 中的 RN 指註冊護理師(Registered Nurse, RN)。這欄保存急診護理師在實際流程中登錄的 KTAS 級數。本系列把它當「人類現場參考」,用來回答模型和臨床當下判定有何差異,但不把它交給模型。
KTAS_expert 是研究中的專家重新判定級數。本篇把它設為主要參考標籤(Reference Label),也就是模型預測完成後拿來比較的目標。它比單一現場登錄多了一層專家重判,但仍不是沒有誤差的宇宙真值:專家依病歷回顧,不是親自重新檢查病患;原始研究也指出部分詳細症狀和徵象可能未被記錄。
如果把 KTAS_RN 或 KTAS_expert 放進模型輸入,模型只需要複製級數,就能得到看似漂亮的結果。這是最直接的標籤洩漏。
下列四欄在檢傷決定後才形成或才能完整確定:
Diagnosis in ED:急診診斷。Disposition:急診去向,例如離院、一般病房住院、加護病房住院、轉院或死亡。Length of stay_min:急診停留時間,以分鐘記錄。KTAS duration_min:檢傷作業時間,以分鐘記錄;只有檢傷完成後才知道完整時間。另外兩欄是比較兩種級數後才產生:
Error_group:專家對錯誤原因的分類,例如生命徵象、身體症狀、疼痛或意識判斷。mistriage:護理師級數和專家級數是否一致,以及錯誤方向。這六欄都不能作為模型輸入。Error_group 與 mistriage 甚至直接由專家比較結果衍生,若拿來預測 KTAS_expert,等於把評分表交給考生。
Kaggle CSV 使用三種需要留意的缺失表示:空字串、?? 與 #BOŞ!。NRS_pain 使用 #BOŞ!,生命徵象中的無法測量值多使用 ??,Saturation 還同時包含空字串與 ??。此外,KTAS duration_min 使用逗號作小數點,例如 5,00;程式必須先轉成 5.00 才能解析為數值。
如果沒有先指定文字編碼,讀取 #BOŞ! 中的土耳其字母 Ş 時還可能直接出現解碼錯誤。這就是本系列固定使用 Windows-1254,也就是 Python 中 cp1254 的原因。
正規化後的缺失量如下:
| 欄位 | 原始缺失表示 | 正規化後缺失 | 可轉成數值 |
|---|---|---|---|
NRS_pain |
#BOŞ! |
556 | 711 |
SBP |
?? |
25 | 1,242 |
DBP |
?? |
29 | 1,238 |
HR |
?? |
20 | 1,247 |
RR |
?? |
22 | 1,245 |
BT |
?? |
18 | 1,249 |
Saturation |
空字串與 ?? |
697 | 570 |
Saturation 是最明顯的例子:正規化後共有 697 筆缺失,只剩 570 筆數值。若只讓套件自行猜空值,很容易漏掉 ??,造成欄位同時混有數字和文字。
清理原則不是把缺失藏起來,而是保留「沒有量到」這件事。以血氧為例,填成 0 會製造極端且錯誤的臨床值;填成全體平均值則會假裝病患真的測到平均血氧。Day 15 會再比較缺失指示欄、合理插補與完整案例分析,但今天只做可追溯的正規化,不擅自補值。
五級是序位分類(Ordinal Classification):類別不只不同,還有第一級到第五級的順序。級數數字越小,代表越緊急;因此把第一級錯判成第五級,比錯判成第二級偏離得更遠。
下圖的每一組長條都使用 Kaggle data.csv 的實際聚合筆數。淺色是護理師登錄,深色是專家重標。

上圖顯示護理師級數分布為 18、214、447、501、87 筆;專家重標分布為 26、220、487、459、75 筆。兩者在 1,081 筆紀錄上相同,在 186 筆紀錄上不同。原始研究報告加權 Kappa(Weighted Kappa)為 0.829;加權 Kappa 會同時考慮一致與錯誤距離,用來描述兩位評分來源的一致程度。
mistriage 把 1,267 筆分成:
| 類別 | CSV 代碼 | 筆數 | 本篇解釋 |
|---|---|---|---|
| 一致 | 0 | 1,081 | 護理師級數等於專家級數 |
| 檢傷過度 | 1 | 55 | 護理師把病患判得比專家更緊急 |
| 檢傷不足 | 2 | 131 | 護理師把病患判得比專家不緊急 |
檢傷過度(Overtriage)在五級數字上表示護理師級數小於專家級數,例如專家判第三級、護理師判第二級。檢傷不足(Undertriage)則是護理師級數大於專家級數,例如專家判第二級、護理師判第四級。
這個數字方向很容易寫反,所以設定檔直接固定為:
預測級數 > 專家級數 → 檢傷不足
預測級數 < 專家級數 → 檢傷過度
預測級數 = 專家級數 → 一致
專家第一級只有 26 筆,代表少數高風險級別的百分比會非常不穩定。即使模型在 26 筆中判對 20 筆與 22 筆,召回率就會相差約 7.7 個百分點。因此後續除了整體準確率,還要報告每級樣本數、第一與第二級召回率、錯誤距離、檢傷不足率,以及不確定性的信賴區間。
資料洩漏(Data Leakage)是指模型在訓練、調整或測試時,接觸到真實使用情境中不該取得的資訊。急診資料的洩漏不只是一欄答案;診斷、處置與住院結果也可能間接透露病患最後有多嚴重。
下圖用一條時間線把候選輸入、人工判定、專家重判與事後結果分開。閱讀時請沿著灰色箭頭由左往右看。

上圖顯示,判斷欄位時可以連續問三個問題:
這三個問題比「欄位和級數是否相關」更可靠。相關性只能告訴我們兩個變數一起變動,不能證明資訊在決策當下可用。
本篇接下來會帶讀者建立兩個可執行產物:
configs/data/day-11-ktas-field-policy.json
scripts/prepare_ktas.py
JavaScript 物件表示法(JavaScript Object Notation, JSON)設定檔逐欄記錄來源名稱、繁體中文解釋、資料型別、取得角色、代碼與排除原因。頂層分開保存:
audit_status: source-inspected:Kaggle data.csv 已完成結構與聚合稽核。experiment_status: planning:模型實驗尚未執行,不能把資料稽核寫成模型結果。Python 資料準備程式會先驗證 data.csv 的 SHA-256、分隔符、文字編碼、24 個欄位的名稱與順序、1,267 筆資料量,以及兩個級數是否都落在第一到第五級。任何一項不符就停止,不會靜默接受不同版本。
接下來不會要求你前往任何程式碼網站。請在自己的電腦開啟專案資料夾,依下列順序建立檔案;每個程式碼區塊都是該檔案的完整內容,不含省略號。
先完成本篇前半部的 Kaggle 下載與 SHA-256 核對;以下兩個檔案就是後續指令真正會讀取的內容。
先從專案根目錄建立需要的資料夾:
mkdir -p configs/data scripts
如果指令沒有印出訊息是正常的。可用 test -d 資料夾路徑 && echo "資料夾已建立" 驗證單一資料夾。接著使用你熟悉的文字編輯器新增各檔案,把對應區塊完整貼入後儲存。
configs/data/day-11-ktas-field-policy.json逐欄保存 24 個來源欄位的型別、角色、缺失代碼與輸出限制。
請在文字編輯器建立 configs/data/day-11-ktas-field-policy.json,貼入以下完整內容並儲存:
{
"schema_version": "0.2",
"audit_status": "source-inspected",
"experiment_status": "planning",
"source_checked_on": "2026-08-02",
"public_source": {
"kaggle_url": "https://www.kaggle.com/datasets/ilkeryildiz/emergency-service-triage-application",
"kaggle_slug": "ilkeryildiz/emergency-service-triage-application",
"provenance_article": "https://doi.org/10.1371/journal.pone.0216972",
"archive_filename": "ktas-kaggle.zip",
"archive_sha256": "f78df0a06c31b6df873c410b0596ecb95ac9335485beaf890dcabf7c69560f26",
"data_filename": "data.csv",
"data_sha256": "0e2c088e358fd4cdfd0dcc2fd4c2f085aa303856e3a6df8ceb44a69cef8ed2de",
"raw_data_policy": "Kaggle 原始檔只保存在 data/raw/ 的本機工作目錄,不重新提交到 Git。"
},
"observed_structure": {
"record_count": 1267,
"field_count": 24,
"delimiter": ";",
"encoding": "cp1254",
"decimal_normalization": "數值欄位中的逗號小數點轉為句點後再解析",
"site_counts": {
"local_ed": 688,
"regional_ed": 579
},
"nurse_label_counts": {
"1": 18,
"2": 214,
"3": 447,
"4": 501,
"5": 87
},
"expert_label_counts": {
"1": 26,
"2": 220,
"3": 487,
"4": 459,
"5": 75
},
"mistriage_counts": {
"correct": 1081,
"overtriage": 55,
"undertriage": 131
},
"missing_tokens": ["", "??", "#BOŞ!"],
"missing_after_normalization": {
"NRS_pain": 556,
"SBP": 25,
"DBP": 29,
"HR": 20,
"RR": 22,
"BT": 18,
"Saturation": 697,
"Diagnosis in ED": 2
}
},
"role_definitions": {
"model_input_candidate": "原則上能在第一次檢傷判定前取得,仍須完成缺失值與時點檢查後才可使用。",
"audit_or_stratification_only": "只用於資料品質、場域差異或敏感度分析,不作為預設模型輸入。",
"human_reference_only": "保留作為臨床人員比較基準,不得送入預測專家級數的模型。",
"reference_label": "本次分類評估的主要參考標籤。",
"post_triage_outcome": "在檢傷完成後才形成或才能確認,禁止作為模型輸入。",
"derived_evaluation_only": "由護理師級數與專家級數比較後產生,只能用於結果分析。"
},
"fields": [
{
"source_name": "Group",
"traditional_chinese": "急診場域組別",
"role": "audit_or_stratification_only",
"source_type": "category",
"coding": "1=地方急診,2=區域急診",
"reason": "場域可能成為捷徑訊號;先用於分層報告,不放入預設模型。"
},
{
"source_name": "Sex",
"traditional_chinese": "生理性別",
"role": "model_input_candidate",
"source_type": "category",
"coding": "1=女性,2=男性",
"reason": "檢傷當下可取得,但只保留來源提供的二元編碼,不自行推論其他資訊。"
},
{
"source_name": "Age",
"traditional_chinese": "年齡",
"role": "model_input_candidate",
"source_type": "number",
"unit": "歲",
"reason": "檢傷當下可取得;原始值可能包含小數,清理時不得擅自四捨五入。"
},
{
"source_name": "Patients number per hour",
"traditional_chinese": "每小時到院人數",
"role": "audit_or_stratification_only",
"source_type": "number",
"unit": "人次/小時",
"reason": "附件沒有精確事件時間與計算窗定義,預設只做壅塞分層與誤差分析。"
},
{
"source_name": "Arrival mode",
"traditional_chinese": "到院方式",
"role": "model_input_candidate",
"source_type": "category",
"coding": "1=步行,2=公共救護車,3=私人車輛,4=私人救護車,5、6、7=其他;細分類在二次分析前需再核對來源",
"reason": "到院時已知,可提供情境訊號;不得把代碼直接當連續數字。"
},
{
"source_name": "Injury",
"traditional_chinese": "是否為外傷",
"role": "model_input_candidate",
"source_type": "category",
"coding": "1=非外傷,2=外傷",
"reason": "檢傷當下可判斷的來診類型。"
},
{
"source_name": "Chief_complain",
"traditional_chinese": "主訴",
"role": "model_input_candidate",
"source_type": "text",
"reason": "檢傷當下的核心文字輸入;Kaggle CSV 已提供英文主訴,但仍須保留原字串並記錄正規化規則。"
},
{
"source_name": "Mental",
"traditional_chinese": "意識反應",
"role": "model_input_candidate",
"source_type": "ordinal_category",
"coding": "1=清醒,2=對聲音反應,3=對疼痛反應,4=無反應",
"reason": "檢傷當下的重要嚴重度訊號。"
},
{
"source_name": "Pain",
"traditional_chinese": "是否疼痛",
"role": "model_input_candidate",
"source_type": "category",
"coding": "1=有疼痛,0=無疼痛",
"reason": "先表示是否有疼痛,再與疼痛分數分開處理。"
},
{
"source_name": "NRS_pain",
"traditional_chinese": "疼痛數字評定量表分數",
"role": "model_input_candidate",
"source_type": "number",
"unit": "0 至 10 分",
"reason": "檢傷當下可取得;缺失可能代表沒有記錄,不可自動補成 0。"
},
{
"source_name": "SBP",
"traditional_chinese": "收縮壓",
"role": "model_input_candidate",
"source_type": "number",
"unit": "mmHg",
"reason": "檢傷當下生命徵象;Kaggle CSV 的「??」必須轉為缺失值。"
},
{
"source_name": "DBP",
"traditional_chinese": "舒張壓",
"role": "model_input_candidate",
"source_type": "number",
"unit": "mmHg",
"reason": "檢傷當下生命徵象;Kaggle CSV 的「??」必須轉為缺失值。"
},
{
"source_name": "HR",
"traditional_chinese": "心率",
"role": "model_input_candidate",
"source_type": "number",
"unit": "次/分鐘",
"reason": "檢傷當下生命徵象;Kaggle CSV 的「??」必須轉為缺失值。"
},
{
"source_name": "RR",
"traditional_chinese": "呼吸率",
"role": "model_input_candidate",
"source_type": "number",
"unit": "次/分鐘",
"reason": "檢傷當下生命徵象;Kaggle CSV 的「??」必須轉為缺失值。"
},
{
"source_name": "BT",
"traditional_chinese": "體溫",
"role": "model_input_candidate",
"source_type": "number",
"unit": "攝氏度",
"reason": "檢傷當下生命徵象;Kaggle CSV 的「??」必須轉為缺失值。"
},
{
"source_name": "Saturation",
"traditional_chinese": "血氧飽和度",
"role": "model_input_candidate",
"source_type": "number",
"unit": "%",
"reason": "檢傷當下生命徵象;空白與「??」都必須視為缺失。"
},
{
"source_name": "KTAS_RN",
"traditional_chinese": "急診護理師登錄級數",
"role": "human_reference_only",
"source_type": "ordinal_label",
"coding": "1 至 5,數字越小越緊急",
"reason": "這是要與模型和專家重標比較的人類基準,輸入模型會造成標籤洩漏。"
},
{
"source_name": "Diagnosis in ED",
"traditional_chinese": "急診診斷",
"role": "post_triage_outcome",
"source_type": "text",
"reason": "診斷在檢傷後形成,不能倒灌成檢傷輸入。"
},
{
"source_name": "Disposition",
"traditional_chinese": "急診去向",
"role": "post_triage_outcome",
"source_type": "category",
"coding": "1=離院,2=一般病房住院,3=加護病房住院,5=轉院,6=死亡;Kaggle 頁面對 4 與 7 的文字不清,二次分析前必須另行核對",
"reason": "離院或住院結果在檢傷後才知道,只能做結果描述。"
},
{
"source_name": "KTAS_expert",
"traditional_chinese": "專家重新判定級數",
"role": "reference_label",
"source_type": "ordinal_label",
"coding": "1 至 5,數字越小越緊急",
"reason": "本次序位分類評估的主要參考標籤,不可放入模型輸入。"
},
{
"source_name": "Error_group",
"traditional_chinese": "錯誤原因類別",
"role": "derived_evaluation_only",
"source_type": "category",
"reason": "專家判斷錯誤原因後建立,只能用於錯誤分析。"
},
{
"source_name": "Length of stay_min",
"traditional_chinese": "急診停留時間",
"role": "post_triage_outcome",
"source_type": "number",
"unit": "分鐘",
"reason": "必須等急診流程結束後才能完整計算,禁止作為檢傷輸入。"
},
{
"source_name": "KTAS duration_min",
"traditional_chinese": "檢傷作業時間",
"role": "post_triage_outcome",
"source_type": "number",
"unit": "分鐘",
"reason": "只有完成檢傷後才能確定,不是判定開始時的輸入。"
},
{
"source_name": "mistriage",
"traditional_chinese": "錯誤檢傷方向",
"role": "derived_evaluation_only",
"source_type": "category",
"coding": "0=一致,1=檢傷過度,2=檢傷不足",
"reason": "由護理師級數與專家級數比較得到,只能用於評估。"
}
],
"evaluation_policy": {
"primary_target": "KTAS_expert",
"human_reference": "KTAS_RN",
"direction_definition": "模型級數大於專家級數代表檢傷不足;模型級數小於專家級數代表檢傷過度。",
"default_input_role": "model_input_candidate",
"forbidden_input_roles": [
"human_reference_only",
"reference_label",
"post_triage_outcome",
"derived_evaluation_only"
],
"split_limit": "附件沒有可用的病患識別碼,不能證明每列都是不同病患,也不能主張已完成病患層級切分。",
"cross_system_limit": "KTAS 結果不得直接改名為 TTAS,亦不得與 ESI 絕對分數合併。"
}
}
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
scripts/prepare_ktas.py讀取已驗證的 data.csv,正規化缺失與型別,最後依角色安全拆檔。
請在文字編輯器建立 scripts/prepare_ktas.py,貼入以下完整內容並儲存:
#!/usr/bin/env python3
"""稽核並拆分 Day 11 使用的 Kaggle KTAS 公開資料。
這支程式不會下載資料,也不會把急診診斷、去向或停留時間寫入模型輸入。
輸出目錄應放在已被 Git 忽略的 ``data/interim/`` 或 ``data/processed/``。
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from typing import Any
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_POLICY = ROOT / "configs" / "data" / "day-11-ktas-field-policy.json"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="驗證 Kaggle KTAS CSV,正規化缺失代碼,並依欄位角色拆分輸出。"
)
parser.add_argument("--input", type=Path, required=True, help="Kaggle data.csv 路徑")
parser.add_argument(
"--output-dir",
type=Path,
default=ROOT / "data" / "interim" / "ktas-day-11",
help="輸出目錄;預設位於 Git 忽略的 data/interim/",
)
parser.add_argument(
"--policy",
type=Path,
default=DEFAULT_POLICY,
help="欄位角色與來源雜湊設定檔",
)
return parser.parse_args()
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def load_policy(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as file:
return json.load(file)
def verify_source(path: Path, expected_sha256: str, label: str) -> str:
if not path.is_file():
raise FileNotFoundError(f"找不到 {label}:{path}")
observed = sha256(path)
if observed != expected_sha256:
raise ValueError(
f"{label} 的 SHA-256 與設定檔不同。\n"
f"預期:{expected_sha256}\n實際:{observed}\n"
"請先確認下載來源與版本,不要直接略過驗證。"
)
return observed
def read_source(data_path: Path, policy: dict[str, Any]) -> pd.DataFrame:
structure = policy["observed_structure"]
data = pd.read_csv(
data_path,
sep=structure["delimiter"],
encoding=structure["encoding"],
dtype=object,
keep_default_na=False,
)
expected_columns = [field["source_name"] for field in policy["fields"]]
if data.columns.tolist() != expected_columns:
raise ValueError(
"Kaggle CSV 欄位名稱或順序已改變。\n"
f"預期:{expected_columns}\n實際:{data.columns.tolist()}"
)
if len(data) != structure["record_count"]:
raise ValueError(
f"Kaggle CSV 預期 {structure['record_count']} 筆,實際讀到 {len(data)} 筆。"
)
return data
def normalize_numeric_fields(
data: pd.DataFrame,
policy: dict[str, Any],
) -> tuple[pd.DataFrame, dict[str, int]]:
cleaned = data.copy()
token_counts: dict[str, int] = {}
missing_tokens = set(policy["observed_structure"]["missing_tokens"])
for name in cleaned.columns:
if cleaned[name].dtype == object:
cleaned[name] = cleaned[name].astype("string").str.strip()
numeric_types = {"number", "ordinal_label"}
for field in policy["fields"]:
if field["source_type"] not in numeric_types:
continue
name = field["source_name"]
string_values = cleaned[name].astype("string")
missing_mask = string_values.isin(missing_tokens)
token_counts[name] = int(missing_mask.sum())
replaced = string_values.mask(missing_mask, pd.NA).str.replace(
",", ".", regex=False
)
numeric = pd.to_numeric(replaced, errors="coerce")
unexpected_invalid = replaced.notna() & numeric.isna()
if unexpected_invalid.any():
invalid_count = int(unexpected_invalid.sum())
raise ValueError(f"{name} 出現 {invalid_count} 個未登錄的非數值代碼。")
cleaned[name] = numeric
return cleaned, {key: value for key, value in token_counts.items() if value > 0}
def validate_labels(data: pd.DataFrame) -> None:
for name in ("KTAS_RN", "KTAS_expert"):
observed = set(data[name].dropna().astype(int).unique())
if not observed.issubset({1, 2, 3, 4, 5}):
raise ValueError(f"{name} 出現五級範圍以外的值:{sorted(observed)}")
if data[name].isna().any():
raise ValueError(f"{name} 有缺失值,不能直接建立本次評估。")
def role_columns(policy: dict[str, Any], role: str) -> list[str]:
return [field["source_name"] for field in policy["fields"] if field["role"] == role]
def value_counts(series: pd.Series) -> dict[str, int]:
counts = series.value_counts(dropna=False).sort_index()
return {str(key): int(value) for key, value in counts.items()}
def write_outputs(
data: pd.DataFrame,
output_dir: Path,
policy: dict[str, Any],
source_hashes: dict[str, str],
unable_counts: dict[str, int],
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
record_index = pd.Series(range(1, len(data) + 1), name="record_index")
input_columns = role_columns(policy, "model_input_candidate")
audit_columns = role_columns(policy, "audit_or_stratification_only")
reference_columns = (
role_columns(policy, "human_reference_only")
+ role_columns(policy, "reference_label")
+ role_columns(policy, "derived_evaluation_only")
)
pd.concat([record_index, data[input_columns]], axis=1).to_csv(
output_dir / "model-input-candidates.csv",
index=False,
)
pd.concat([record_index, data[audit_columns]], axis=1).to_csv(
output_dir / "audit-strata.csv",
index=False,
)
pd.concat([record_index, data[reference_columns]], axis=1).to_csv(
output_dir / "reference-labels.csv",
index=False,
)
excluded = {
"note": "以下欄位不會寫入模型輸入檔。",
"post_triage_outcome": role_columns(policy, "post_triage_outcome"),
"human_reference_only": role_columns(policy, "human_reference_only"),
"reference_label": role_columns(policy, "reference_label"),
"derived_evaluation_only": role_columns(policy, "derived_evaluation_only"),
}
with (output_dir / "excluded-fields.json").open("w", encoding="utf-8") as file:
json.dump(excluded, file, ensure_ascii=False, indent=2)
file.write("\n")
summary = {
"status": "source-audit-complete",
"record_count": len(data),
"source_sha256": source_hashes,
"normalized_missing_tokens": policy["observed_structure"]["missing_tokens"],
"normalized_missing_counts": unable_counts,
"missing_after_normalization": {
name: int(data[name].isna().sum()) for name in input_columns
},
"nurse_label_counts": value_counts(data["KTAS_RN"].astype(int)),
"expert_label_counts": value_counts(data["KTAS_expert"].astype(int)),
"mistriage_counts": value_counts(data["mistriage"].astype(int)),
"output_files": [
"model-input-candidates.csv",
"audit-strata.csv",
"reference-labels.csv",
"excluded-fields.json",
"summary.json",
],
"privacy_note": "輸出仍包含逐筆公開病歷欄位,只能留在被 Git 忽略的本機資料目錄。",
}
with (output_dir / "summary.json").open("w", encoding="utf-8") as file:
json.dump(summary, file, ensure_ascii=False, indent=2)
file.write("\n")
def main() -> None:
args = parse_args()
policy = load_policy(args.policy)
source = policy["public_source"]
source_hashes = {
"data_csv": verify_source(args.input, source["data_sha256"], "Kaggle data.csv"),
}
data = read_source(args.input, policy)
cleaned, unable_counts = normalize_numeric_fields(data, policy)
validate_labels(cleaned)
write_outputs(cleaned, args.output_dir, policy, source_hashes, unable_counts)
print(f"完成:{len(cleaned)} 筆、{len(cleaned.columns)} 欄")
print(f"輸出目錄:{args.output_dir.resolve()}")
print("模型輸入不含護理師級數、專家級數、事後結果或衍生誤差欄位。")
if __name__ == "__main__":
main()
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
以下命令會在需要時產生套件鎖定檔,接著檢查設定格式與 Python 語法;它們不會啟動模型,也不會把病患資料送到網路:
python3 -m json.tool configs/data/day-11-ktas-field-policy.json
python3 -m py_compile scripts/prepare_ktas.py
每個命令都應正常結束。若 JSON 顯示行號,先檢查貼上時是否遺漏逗號、引號或括號;若 py_compile 報錯,先依行號修正縮排或漏貼內容。靜態檢查通過後,再執行本文後面的正式步驟。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install 'pandas>=2.0,<3'
Pandas 是 Python 的表格資料處理套件,本篇用它指定分號分隔與 cp1254 編碼後讀取 CSV。
(.venv)。python -c "import pandas; print('套件可用')",應看到「套件可用」。?? 與 #BOŞ!,並依欄位角色建立互相分離的本機輸出。data.csv 已通過檢查碼驗證,虛擬環境已啟用。data.csv 與 Day 11 欄位政策。python scripts/prepare_ktas.py \
--input data/raw/ktas/data.csv \
--output-dir data/interim/ktas-day-11
完成:1267 筆、24 欄
輸出目錄:.../data/interim/ktas-day-11
模型輸入不含護理師級數、專家級數、事後結果或衍生誤差欄位。
data/interim/ktas-day-11/ 應包含五個檔案:| 輸出檔 | 內容 | 為什麼要拆開 |
|---|---|---|
model-input-candidates.csv |
record_index 加 14 個候選輸入 |
模型預設只能讀這一份 |
audit-strata.csv |
場域與每小時人流 | 只作分層及資料品質檢查 |
reference-labels.csv |
護理師級數、專家級數與兩個衍生評估欄位 | 只在模型輸出後比較 |
excluded-fields.json |
禁止輸入欄位名稱與角色 | 留下可機器檢查的黑名單 |
summary.json |
筆數、雜湊、缺失與級數分布 | 驗證處理是否符合預期 |
程式刻意不輸出逐筆的急診診斷、去向、停留時間或檢傷作業時間。原始值仍保留在本機 Kaggle CSV,需要做次要結果分析時再由獨立流程讀取,避免方便性使事後欄位悄悄進入模型。
model-input-candidates.csv 與 excluded-fields.json。python - <<'PY'
import json
from pathlib import Path
import pandas as pd
output = Path("data/interim/ktas-day-11")
inputs = pd.read_csv(output / "model-input-candidates.csv")
excluded = json.loads((output / "excluded-fields.json").read_text(encoding="utf-8"))
forbidden = {
name
for role, names in excluded.items()
if role != "note"
for name in names
}
leaked = forbidden.intersection(inputs.columns)
if leaked:
raise SystemExit(f"發現禁止欄位:{sorted(leaked)}")
if inputs.shape != (1267, 15):
raise SystemExit(f"輸入尺寸錯誤:{inputs.shape}")
print("通過:1,267 列,record_index + 14 個候選輸入,未發現禁止欄位")
PY
這些輸出仍包含逐筆公開病歷欄位,只能留在不納入版本控制的本機資料目錄。公開資料不等於應該在自己的專案再複製一份。
台灣急診檢傷與急迫度分級量表(Taiwan Triage and Acuity Scale, TTAS)與 KTAS 都有五級,也都受到加拿大急診檢傷與急迫度分級量表(Canadian Triage and Acuity Scale, CTAS)的發展影響,但三者的制度內容、修訂脈絡與實務規則並不相同。
這份 CSV 的病患紀錄來自韓國,護理師和專家使用的也是 KTAS。因此它只能直接支持 KTAS 語境內的分類與錯誤方向分析,不能把 KTAS_expert 改名為 TTAS_expert,也不能宣稱模型已在台灣制度上通過驗證。
本系列使用這份小型資料的目的是先驗證共通的工程骨架:
這些工程方法可以遷移,但制度標籤不能互換。若未來使用 TTAS 資料,仍要重新取得 TTAS 對應的規則版本、欄位語意與參考標籤,不能沿用 KTAS 名稱替換。
目前適合回答的問題包括:
KTAS_expert 的五級差異為何。KTAS_RN 相比,錯誤方向是否不同。目前不適合回答的問題包括:
最後一點特別重要。即使後續模型的第一、二級召回率很高,也只能表示它在這份固定資料和固定設定下的表現。臨床使用還需要前瞻驗證、工作流程評估、偏差與安全監測,以及專業人員持續覆核。
這可能把整列讀成一欄,或在遇到 #BOŞ! 時產生解碼錯誤。修正方式是明確指定 sep=";" 與 encoding="cp1254",再核對 1,267 列與 24 欄。
KTAS_RN 當特徵模型會直接看到人類級數,無法評估主訴和生命徵象是否足以支持判斷。修正方式是只在 reference-labels.csv 保存 KTAS_RN,並用禁止欄位集合做自動檢查。
沒有記錄不等於沒有疼痛。修正方式是保留缺失,另建缺失指示欄或在後續實驗中比較插補策略,不要在來源稽核階段偷偷賦予臨床意義。
?? 與 #BOŞ!這會讓生命徵象與疼痛欄維持混合的文字與數值型別,或在轉型時出現無法追溯的錯誤。修正方式是先把已知缺失代碼轉成缺失,再檢查是否還有其他未登錄字串。
CSV 沒有病患識別碼,無法證明這件事。修正方式是使用「1,267 筆病歷紀錄」,並在切分限制中明確說明不能保證病患層級獨立。
1,081 筆一致、186 筆不一致與加權 Kappa 0.829 都是原始研究的護理師-專家比較,不是本系列模型結果。本篇只完成來源與欄位稽核,experiment_status 仍是 planning。
今天完成的不是模型訓練,而是一份可以阻止未來犯錯的資料契約:
data.csv 已用 dataset slug、檔名、尺寸與 SHA-256 鎖定。KTAS_RN 只作護理師現場參考,KTAS_expert 才是本次主要評估目標;兩者都不得進模型輸入。Diagnosis in ED、Disposition、兩種時間與比較後欄位已列入禁止輸入清單。?? 與 #BOŞ! 必須統一轉成缺失;只檢查空白會低估缺失量。scripts/prepare_ktas.py 會依欄位政策安全拆檔;本篇狀態是「來源稽核完成、模型實驗尚未執行」。這套流程仍只服務於離線決策支援研究。它不會自動作出臨床決定,也不能取代急診檢傷護理師與醫療團隊。
Day 12 會把 Kaggle 下載流程正式寫成可重現的資料管線,重點包括:
Day 11 產生的「取得時點+欄位角色+禁止輸入」契約,會成為 Day 12 資料管線的輸入。