Day 14 已經鎖定一筆資料能否進入系統的邊界:模型只能讀取初次檢傷當下的 14 個候選欄位,護理師級數、專家標籤與檢傷後結果都被隔離。通過這道檢查後,我們仍然不能立刻訓練模型,因為「欄位可以使用」不代表「每一列都有值」,也不代表五個急迫級都有足夠樣本。
以呼吸困難案例為例。假設主訴、呼吸速率與心率都有紀錄,血氧飽和度卻是空白。空白只告訴我們「目前沒有讀到數值」,不能推論病患血氧正常,更不能擅自填成 0。0% 的血氧不是「沒量到」,而是另一個完全不同、甚至極端異常的數值語意。
疼痛分數又更複雜。同樣是空白,若 Pain = 0,可能表示病患沒有疼痛,因此疼痛數字評分不適用;若 Pain = 1,空白才可能代表應該詢問但沒有記錄。只用一個平均值填滿這兩種空白,就會把不同原因混成同一件事。
下圖是概念示意。閱讀時請注意兩個動作:左側資料卡的空白保持未知,沒有被自動改成正常值;右側不同急迫級的資料,則被分配到多個驗證折,而不是任由少數類別集中在某一折。

上圖把今天的兩個核心原則放在一起:先如實保留資料不知道的部分,再建立每次都能重現的公平切分。本篇沿用韓國急診檢傷與急迫度分級量表(Korean Triage and Acuity Scale, KTAS)公開資料,但不會訓練大型語言模型(Large Language Model, LLM),也不需要啟動 Ollama 或下載模型;我們要產生的是缺失概況、五級標籤分布與固定的交叉驗證分派表。
讀完並跟著操作後,你會完成以下五件事:
本篇會使用多個資料科學名詞,先用表格建立快速索引。後面的正文仍會以實際 KTAS 資料逐一解釋用途。
| 中文名稱 | 英文全名/縮寫 | 本篇用途 |
|---|---|---|
| 缺失值 | Missing Value | 欄位沒有可用觀測值;不能自動解讀為正常或 0 |
| 不適用 | Not Applicable | 該欄位在目前條件下本來就不該有數值,例如沒有疼痛時的疼痛分數 |
| 缺失指示欄 | Missing Indicator | 額外記錄原欄位是否缺失的 0/1 欄位,讓模型保留「沒量到」這項訊息 |
| 類別不平衡 | Class Imbalance | 不同標籤的資料量差距很大,本篇第一級只有 26 筆,第三級有 487 筆 |
| 分層 K 折交叉驗證 | Stratified K-Fold Cross-Validation | 將資料分成 K 份,輪流留一份驗證,並盡量維持各標籤比例 |
| 重複分層 K 折交叉驗證 | Repeated Stratified K-Fold Cross-Validation | 使用不同固定亂數種子重做多次分層 K 折,觀察結果是否依賴單一次切分 |
| 折外預測 | Out-of-Fold Prediction, OOF Prediction | 某筆資料只有在沒有參與該次模型訓練時得到的預測 |
| 平衡準確率 | Balanced Accuracy | 先計算各級召回率再取平均,避免多數類別獨占整體分數 |
| 重抽樣 | Bootstrap Resampling | 從既有資料有放回抽樣,用來估計指標的不確定範圍 |
缺失值(Missing Value)是資料表中沒有可用觀測值的位置。它只描述「值不存在於目前資料」,不自動說明原因。原因可能是沒有測量、量測失敗、未記錄、轉檔遺失,或該欄位根本不適用。
本篇讀取 Day 12 產生的 data/interim/ktas-v1/model-input-candidates.csv。data/ 是保存本機資料的資料夾,interim/ 子目錄保存已完成編碼、欄位名稱與角色正規化,但還沒有做模型補值的中介資料。這個逗號分隔值(Comma-Separated Values, CSV)檔共有 1,267 列;除了負責對齊的 record_index,還有 Day 14 允許的 14 個候選輸入欄位。
本篇集中檢查七個可能缺失的數值欄位:
NRS_pain。Saturation 所表示的血氧飽和度。缺失率的計算方式是:
[
\text{欄位缺失率}=\frac{\text{該欄位缺失筆數}}{\text{全部紀錄筆數}}
]
分母是 1,267 筆符合資料契約的紀錄;分子則逐欄計算空值數量。例如 Saturation 缺失 697 筆,所以:
[
\frac{697}{1267}\times 100% \approx 55.0%
]
數值越大,表示該欄位在目前公開資料中越常沒有觀測值。它不能直接證明臨床現場也有相同比例,更不能告訴我們為什麼缺失。
實際盤點結果如下:
| 欄位 | 缺失筆數 | 缺失率 |
|---|---|---|
Saturation |
697 | 55.0% |
NRS_pain |
556 | 43.9% |
DBP |
29 | 2.3% |
SBP |
25 | 2.0% |
RR |
22 | 1.7% |
HR |
20 | 1.6% |
BT |
18 | 1.4% |
下圖把七個欄位放在同一個尺度上,右側再拆開 NRS_pain 缺失的語意。這些數字直接由本篇公開聚合結果產生,不是手動抄進圖片。

上圖顯示,1,267 筆中有 706 筆至少缺一項生命徵象,16 筆六項生命徵象全部缺失。這不是可以略過的小問題。若只保留生命徵象完整的紀錄,資料量、病患組成與急迫級比例都可能改變,因此完整案例刪除不會成為預設策略。
臨床預測模型透明報告指引加人工智慧擴充版(Transparent Reporting of a Multivariable Prediction Model for Individual Prognosis or Diagnosis Plus Artificial Intelligence, TRIPOD+AI)也要求清楚報告每個預測變數的缺失數量、處理方法與假設,而不是只寫一句「已清理資料」。TRIPOD+AI 擴充檢查表
不適用(Not Applicable)表示某個欄位在目前條件下本來就不該有值。公開資料同時提供 Pain 與 NRS_pain:前者記錄是否有疼痛,後者才是 1 到 10 的疼痛強度。
在 556 筆 NRS_pain 缺失紀錄中:
Pain = 0,本系列表示為「不適用」。Pain = 1,本系列表示為「未知/未記錄」。Pain = 0 卻填入疼痛分數的紀錄。因此,後續建立檢索增強生成(Retrieval-Augmented Generation, RAG)輸入文字時,兩種情況要使用不同語句:
Pain = 0 且 NRS_pain 缺失 → 疼痛:無;疼痛分數:不適用
Pain = 1 且 NRS_pain 缺失 → 疼痛:有;疼痛分數:未記錄
RAG 是先檢索外部知識,再讓生成模型根據檢索內容回答的方法。對 RAG 而言,「未記錄」與「不適用」本身就是語意,不需要先捏造一個數值才能寫進文字。
若後續的傳統機器學習(Machine Learning, ML)基準模型只接受數值,才考慮在每個訓練折內建立補值器,並搭配缺失指示欄(Missing Indicator)。缺失指示欄通常用 1 表示原值缺失、0 表示原值存在;它不是補出的生命徵象,而是保存資料取得狀態。
清理資料時常見的錯誤,是看到罕見數值就直接刪除。可是「少見」不等於「錯誤」,尤其在急診資料中,極端值可能正是危急病患的重要訊號。
本篇把數值分成三類:
實際觀測範圍如下:
| 欄位 | 非缺失最小值 | 非缺失最大值 | 結構檢查結果 |
|---|---|---|---|
NRS_pain |
1 | 10 | 沒有超出 0 到 10 |
SBP |
50 | 275 | 沒有小於或等於 0 |
DBP |
31 | 160 | 沒有小於或等於 0 |
HR |
32 | 148 | 沒有小於或等於 0 |
RR |
14 | 30 | 沒有小於或等於 0 |
BT |
35 | 41 | 沒有小於或等於 0 |
Saturation |
20 | 100 | 沒有超出 0 到 100,也沒有小於或等於 0 |
這張表只能證明數值通過目前寫下的結構規則。例如收縮壓 275 或血氧 20 都很極端,但公開檔案沒有提供足夠證據判定是量測錯誤、輸入錯誤或真實臨床狀況。因此本篇不會僅憑「看起來不尋常」就刪除它們。
類別不平衡(Class Imbalance)是不同標籤的樣本數量差距明顯。本系列的主要參考標籤是專家重新判定的 KTAS_expert;KTAS 以第一級表示最緊急、第五級表示最不緊急。
實際分布如下:
| 專家級數 | 筆數 | 比例 |
|---|---|---|
| 第 1 級 | 26 | 2.1% |
| 第 2 級 | 220 | 17.4% |
| 第 3 級 | 487 | 38.4% |
| 第 4 級 | 459 | 36.2% |
| 第 5 級 | 75 | 5.9% |
第一、二級合計 246 筆,占 19.4%;最大類別第三級有 487 筆,與只有 26 筆的第一級相差約 18.7 倍。
下圖左側呈現五級實際分布,右側則是一個完全不讀取病患資料、永遠猜第三級的基準。閱讀時請比較整體準確率與平衡準確率,而不是只看其中一個數字。

上圖中的「全猜第三級」可以得到 38.4% 整體準確率(Accuracy),也就是 1,267 筆中預測正確的比例,因為資料本來就有 38.4% 是第三級。然而這個方法對第一、二、四、五級的召回率(Recall)全部是 0%,只有第三級召回率是 100%。某一級的召回率,是該級真實紀錄中被模型正確找回的比例。
平衡準確率(Balanced Accuracy)會先計算每一級召回率,再對五級取平均:
[
\text{平衡準確率}=\frac{R_1+R_2+R_3+R_4+R_5}{5}
]
其中 (R_i) 代表第 (i) 級的召回率。全猜第三級時:
[
\frac{0+0+1+0+0}{5}=0.2=20%
]
這就是為什麼 Day 7 已把各級召回率、平衡準確率、巨集 F1 分數(Macro F1-score)與檢傷不足率列為主要指標,整體準確率只作次要參考。平衡準確率的用途正是避免不平衡資料讓多數類別支配評估。Brodersen 等人:The Balanced Accuracy and Its Posterior Distribution
類別不平衡也不能靠切分前複製第一級資料解決。若同一筆複製資料同時落入訓練折與驗證折,模型等於看過驗證答案。任何重新抽樣或類別權重,只能在每一個訓練折內根據該折資料建立,驗證折保持原始分布。
理想情況下,同一位病患的多次急診就醫應該全部留在同一側,避免模型從訓練資料記住個人特徵,再到驗證資料遇到同一人。這稱為病患層級分組切分。
然而,公開 Kaggle KTAS 資料沒有可用病患識別碼。record_index 只是本系列為 1,267 列依序建立的對齊鍵,不能辨認兩列是否來自同一位病患。因此本篇只能建立「急診就醫紀錄層級」切分,不能宣稱病患層級切分已完成。
這個限制有三個後果:
record_index 為單位,不能假裝是病患層級信賴區間。公開資料也沒有能支援嚴格時間切分的逐筆日期,所以本篇不會把 1,267 筆切成早期訓練與晚期測試。資料來自兩所醫院,但 Group 只有場域代碼;本篇會用它維持各折場域比例,卻不把同一份資料內的場域分層稱為跨醫院外部驗證。
分層 K 折交叉驗證(Stratified K-Fold Cross-Validation)先把資料分成 K 個互斥部分,再輪流用其中一折驗證、其餘 K−1 折訓練。分層的目的,是讓每一折盡量保留原始標籤比例,避免只有 26 筆的第一級偶然集中在少數折。
本篇設定 K = 5。每次切分都會進行五輪:
因此,每筆紀錄在一次五折中會參與四輪訓練,並且恰好在一輪作為驗證資料。它在沒有參與該輪訓練時得到的結果,就是折外預測(Out-of-Fold Prediction, OOF Prediction)。
只做一次五折,結果仍可能依賴某一次隨機分配。重複分層 K 折交叉驗證(Repeated Stratified K-Fold Cross-Validation)會使用不同亂數種子重新切分。本篇固定五個亂數種子 42、43、44、45、46,因此共有:
[
5\text{ 次重複}\times 5\text{ 折}=25\text{ 組訓練/驗證}
]
固定亂數種子是讓偽隨機程序在相同輸入下重現相同結果的起始值,不代表資料沒有隨機化。scikit-learn 官方文件也把重複分層 K 折定義為使用不同隨機化重複類別分層 K 折,並建議以固定整數 random_state 重現切分。scikit-learn:RepeatedStratifiedKFold
本篇比只看標籤再多一層:先把 KTAS_expert 與兩所醫院的 Group 組成十個分層,再把每個分層中的紀錄輪流分到五折。Group 只在建立切分時使用,不會寫入模型輸入或分派檔。
下圖左側是第一次重複的實際五個驗證折。每折共有 251 到 255 筆,第三、第四級占多數,但每一級都存在。右側則顯示五次重複如何讓每筆紀錄累積五個折外預測。

上圖中第一次重複的第一級筆數依序為 4、6、5、6、5;五折合計仍是原始的 26 筆。程式也檢查每一個「級數 × 場域」分層在五折之間最多只相差一筆。這能減少切分造成的比例波動,但不能增加第一級資料量,也不能解決病患識別碼缺失的問題。官方文件同樣提醒,分層主要是工程上的切分工具,不是消除小樣本統計不確定性的方案。scikit-learn:Cross-validation
資料洩漏不只發生在欄位中,也可能發生在操作順序。假設先用全部 1,267 筆計算血壓中位數,再做五折切分,每個驗證折的分布已經參與補值器,評估就不再是完全未見資料。
正確順序是:
record_index、KTAS_expert 與 Group 建立固定分派表。training fold 是當輪可供模型學習的四折;validation fold 是完全不參與該輪學習、只用來產生折外預測的一折。兩者在每輪會交換角色,但任何一輪都不能讓 validation fold 反向影響前處理。
scikit-learn 的資料洩漏指南也要求先切分,再讓 fit 或 fit_transform 只接觸訓練資料;驗證資料只能呼叫已學好規則的 transform。scikit-learn:Common pitfalls and recommended practices 臨床預測模型的缺失資料研究也強調,部署或驗證時的缺失處理必須只依賴開發資料,才能套用到下一位未知結果的個案。Sisk 等人:Handling missing data when developing and validating clinical prediction models
本篇接下來會建立 configs/data/day-15-quality-and-split-contract.json。configs/ 是保存可審查參數與規則的資料夾,data/ 子目錄集中資料取得、欄位角色與切分契約;.json 表示 JavaScript 物件表示法(JavaScript Object Notation, JSON)檔案。
這份契約讀取三份 Day 12 中介資料:
model-input-candidates.csv:14 個候選輸入與 record_index,用來盤點缺失與結構值域。audit-strata.csv:場域代碼 Group,只用來稽核與分層。reference-labels.csv:主要標籤 KTAS_expert,只用來建立分層與後續評估。契約固定以下決策:
NRS_pain 必須依 Pain 拆成「不適用」與「未知/未記錄」。record_index、repeat_id、fold_id 與 seed,不能夾帶標籤或場域。以下是實際設定的核心節錄。為了聚焦操作順序,省略了完整欄位清單、輸入路徑與輸出政策,所以不能把這段單獨存檔後直接執行。
{
"missingness_policy": {
"rag_representation": "保留缺失狀態,明確寫成未記錄或不適用,不得轉寫為正常。",
"reference_label_must_not_be_used_for_imputation": true,
"complete_case_deletion_is_not_default": true
},
"split_policy": {
"method": "repeated_stratified_five_fold",
"fold_count": 5,
"repeat_count": 5,
"repeat_seeds": [42, 43, 44, 45, 46],
"stratification_fields": ["KTAS_expert", "Group"],
"preprocessing_fit_scope": "training_fold_only"
}
}
設定檔不會自行處理資料,它的功能是讓程式、測試與文章共用同一份規則。若未來要更換折數或種子,必須更新契約版本、重新產生分派表並重跑所有方法,不能只修改某個模型的訓練程式。
可重複使用的資料品質與切分邏輯位於 src/triage_rag/data/quality.py。src/ 是存放專案核心 Python 套件的資料夾,triage_rag/data/ 子目錄專門放資料驗證與轉換邏輯。這支程式接收三個 Pandas 資料表與 Day 15 契約,產生聚合報告及分派表,不負責命令列參數或執行目錄。
核心程式依序完成:
record_index 唯一、非空且順序相同。KTAS_expert × Group」與固定種子建立五次五折分派。以下片段是實際建立單一分層分派的核心。輸入是某個級數與場域內排序後的 record_index;固定種子打亂順序後,程式輪流放入五折。輸出只寫索引、重複編號、折編號與種子。
generator.shuffle(record_indices)
fold_order = list(range(1, fold_count + 1))
generator.shuffle(fold_order)
for position, record_index in enumerate(record_indices):
rows.append(
{
"record_index": int(record_index),
"repeat_id": repeat_id,
"fold_id": fold_order[position % fold_count],
"seed": seed,
}
)
自動化測試位於 tests/test_quality_and_splits.py。tests/ 是保存不接觸真實病患資料之合成測試的資料夾。本篇新增五項測試:相同契約必須產生完全相同分派、疼痛分數缺失必須拆成兩種語意、重複 record_index 必須失敗、超出結構範圍的血氧必須失敗,以及任何級數與場域分層少於五筆時必須失敗。
讀者真正從終端機啟動的是 scripts/prepare_day15_splits.py。scripts/ 保存可執行的自動化入口;這支 .py 程式讀取契約與三份中介資料,呼叫核心模組,再把結果分別寫到本機資料、公開聚合摘要與單次執行紀錄。它不會下載資料;若 Day 12 產物尚未建立,要先執行資料管線。
接下來不會要求你前往任何程式碼網站。請在自己的電腦開啟專案資料夾,依下列順序建立檔案;每個程式碼區塊都是該檔案的完整內容,不含省略號。
本篇沿用 Day 12 的三份處理後 CSV、Day 13 的重現性工具與 Day 14 的 record_index 對齊契約;以下是 Day 15 新增的完整檔案。
先從專案根目錄建立需要的資料夾:
mkdir -p configs/data scripts src/triage_rag/data tests
如果指令沒有印出訊息是正常的。可用 test -d 資料夾路徑 && echo "資料夾已建立" 驗證單一資料夾。接著使用你熟悉的文字編輯器新增各檔案,把對應區塊完整貼入後儲存。
configs/data/day-15-quality-and-split-contract.json固定缺失、極端值、類別不平衡、五次五折與不確定性處理政策。
請在文字編輯器建立 configs/data/day-15-quality-and-split-contract.json,貼入以下完整內容並儲存:
{
"schema_version": 1,
"contract_id": "day-15-ktas-quality-and-record-level-splits-v1",
"status": "implemented-and-source-validated",
"source_dataset_card": "configs/data/day-12-kaggle-ktas-dataset-card.json",
"source_data_contract": "configs/data/day-14-ktas-data-contract.json",
"inputs": {
"model_inputs": "data/interim/ktas-v1/model-input-candidates.csv",
"audit_strata": "data/interim/ktas-v1/audit-strata.csv",
"reference_labels": "data/interim/ktas-v1/reference-labels.csv"
},
"row_link_key": "record_index",
"missingness_policy": {
"profile_fields": [
"NRS_pain",
"SBP",
"DBP",
"HR",
"RR",
"BT",
"Saturation"
],
"vital_sign_fields": [
"SBP",
"DBP",
"HR",
"RR",
"BT",
"Saturation"
],
"nrs_pain_rule": {
"applicability_field": "Pain",
"not_applicable_value": 0,
"expected_observed_value": 1,
"missing_when_not_applicable_representation": "not_applicable",
"missing_when_expected_representation": "unknown_not_recorded",
"never_fill_missing_with_zero_by_default": true
},
"rag_representation": "保留缺失狀態,明確寫成未記錄或不適用,不得轉寫為正常。",
"traditional_ml_policy": "若基準模型需要補值,補值器與缺失指示欄位只能在每個 training fold 內 fit,再套用到該 fold 的 validation 資料。",
"reference_label_must_not_be_used_for_imputation": true,
"complete_case_deletion_is_not_default": true
},
"structural_value_policy": {
"automatic_extreme_value_deletion": false,
"strictly_positive_fields": [
"SBP",
"DBP",
"HR",
"RR",
"BT",
"Saturation"
],
"bounded_fields": {
"NRS_pain": {
"minimum": 0,
"maximum": 10
},
"Saturation": {
"minimum": 0,
"maximum": 100
}
},
"extreme_but_structurally_valid_values": "只列入人工審查與敏感度分析;在缺少單位錯誤或量測錯誤證據時,不因罕見就刪除、截尾或改寫。"
},
"class_imbalance_policy": {
"label_field": "KTAS_expert",
"label_order": [1, 2, 3, 4, 5],
"resampling_before_split_forbidden": true,
"validation_fold_resampling_forbidden": true,
"training_fold_class_weighting": "允許作為預先標示的基準模型設定,但只能由 training fold 計算。",
"primary_metrics": [
"balanced_accuracy",
"per_class_recall",
"macro_f1",
"severe_undertriage_rate"
],
"overall_accuracy_is_secondary": true
},
"split_policy": {
"unit": "record_level_emergency_encounter",
"patient_identifier_available": false,
"patient_level_split_claim_allowed": false,
"method": "repeated_stratified_five_fold",
"fold_count": 5,
"repeat_count": 5,
"repeat_seeds": [42, 43, 44, 45, 46],
"stratification_fields": [
"KTAS_expert",
"Group"
],
"assignment_output_fields": [
"record_index",
"repeat_id",
"fold_id",
"seed"
],
"each_record_is_validation_once_per_repeat": true,
"preprocessing_fit_scope": "training_fold_only",
"locked_test_role": "五折中的 validation fold 只產生折外預測;設定鎖定後不得使用其結果反向調參。",
"limitation": "同一病患若有多次就醫,可能被分到不同 folds;公開資料沒有病患識別碼,無法量化或排除此相依性。"
},
"uncertainty_policy": {
"status": "planned-for-day-28-before-final-model-comparison",
"method": "paired_record_level_bootstrap_on_aggregated_out_of_fold_predictions",
"resampling_unit": "record_index",
"aggregate_repeated_predictions_before_resampling": true,
"do_not_resample_folds_as_independent_observations": true,
"patient_level_bootstrap_available": false,
"limitation": "缺少病患識別碼,因此只能進行紀錄層級重抽樣;同一病患若有多次就醫,信賴區間可能低估相依性造成的不確定性。"
},
"output_policy": {
"assignment_path": "data/processed/ktas-v1/day-15-split-assignments.csv",
"public_summary_path": "results/public/day-15-data-quality-and-splits.json",
"run_output_root": "results/runs/day-15",
"scope": "data_quality_and_split_engineering_not_model_evaluation",
"aggregate_public_output_only": true
}
}
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
src/triage_rag/data/quality.py實作缺失概況、結構值域、五級分布、重複分層切分與驗證。
請在文字編輯器建立 src/triage_rag/data/quality.py,貼入以下完整內容並儲存:
"""Profile Day 15 data quality and build reproducible record-level folds."""
from __future__ import annotations
import hashlib
import random
from typing import Any, Mapping
import pandas as pd
JsonObject = dict[str, Any]
class QualityContractError(ValueError):
"""Raised when data quality or split invariants are violated."""
def _rounded_rate(count: int, total: int) -> float:
return round(count / total, 6) if total else 0.0
def _require_columns(
frame: pd.DataFrame, required: list[str], frame_name: str
) -> None:
missing = [name for name in required if name not in frame.columns]
if missing:
raise QualityContractError(f"{frame_name} 缺少必要欄位:{missing}")
def validate_aligned_frames(
model_inputs: pd.DataFrame,
audit_strata: pd.DataFrame,
reference_labels: pd.DataFrame,
row_link_key: str,
) -> None:
"""Require unique and identical record indices across the three prepared tables."""
frames = {
"model_inputs": model_inputs,
"audit_strata": audit_strata,
"reference_labels": reference_labels,
}
for name, frame in frames.items():
_require_columns(frame, [row_link_key], name)
if frame[row_link_key].isna().any():
raise QualityContractError(f"{name} 的 {row_link_key} 不可為空")
if frame[row_link_key].duplicated().any():
raise QualityContractError(f"{name} 的 {row_link_key} 必須唯一")
expected = model_inputs[row_link_key].tolist()
for name, frame in frames.items():
if frame[row_link_key].tolist() != expected:
raise QualityContractError(
f"{name} 的 {row_link_key} 順序與 model_inputs 不一致"
)
def build_missingness_profile(
model_inputs: pd.DataFrame, contract: Mapping[str, Any]
) -> JsonObject:
"""Return aggregate missingness and structural-value checks."""
policy = contract["missingness_policy"]
fields = [str(name) for name in policy["profile_fields"]]
vital_fields = [str(name) for name in policy["vital_sign_fields"]]
nrs_rule = policy["nrs_pain_rule"]
applicability_field = str(nrs_rule["applicability_field"])
_require_columns(
model_inputs,
[applicability_field, *fields],
"model_inputs",
)
total = len(model_inputs)
field_profiles = []
for name in fields:
missing = int(model_inputs[name].isna().sum())
field_profiles.append(
{
"field": name,
"missing_count": missing,
"missing_rate": _rounded_rate(missing, total),
"observed_count": total - missing,
}
)
nrs_missing = model_inputs["NRS_pain"].isna()
pain = model_inputs[applicability_field]
nrs_not_applicable = int(
(nrs_missing & pain.eq(nrs_rule["not_applicable_value"])).sum()
)
nrs_unknown = int(
(nrs_missing & pain.eq(nrs_rule["expected_observed_value"])).sum()
)
nrs_contradictory = int(
((~nrs_missing) & pain.eq(nrs_rule["not_applicable_value"])).sum()
)
structural_policy = contract["structural_value_policy"]
structural_checks: JsonObject = {}
for name in structural_policy["strictly_positive_fields"]:
values = pd.to_numeric(model_inputs[name], errors="coerce").dropna()
nonpositive_count = int((values <= 0).sum())
structural_checks[str(name)] = {
"observed_minimum": float(values.min()) if len(values) else None,
"observed_maximum": float(values.max()) if len(values) else None,
"nonpositive_count": nonpositive_count,
}
if nonpositive_count:
raise QualityContractError(
f"{name} 出現 {nonpositive_count} 筆小於或等於 0 的結構非法值"
)
for name, bounds in structural_policy["bounded_fields"].items():
values = pd.to_numeric(model_inputs[name], errors="coerce").dropna()
outside = values.lt(bounds["minimum"]) | values.gt(bounds["maximum"])
current = structural_checks.setdefault(
str(name),
{
"observed_minimum": float(values.min()) if len(values) else None,
"observed_maximum": float(values.max()) if len(values) else None,
},
)
current["declared_minimum"] = bounds["minimum"]
current["declared_maximum"] = bounds["maximum"]
current["outside_declared_bounds_count"] = int(outside.sum())
if outside.any():
raise QualityContractError(
f"{name} 出現 {int(outside.sum())} 筆超出 "
f"{bounds['minimum']} 到 {bounds['maximum']} 的結構非法值"
)
any_missing = int(model_inputs[fields].isna().any(axis=1).sum())
any_vital_missing = int(model_inputs[vital_fields].isna().any(axis=1).sum())
all_vital_missing = int(model_inputs[vital_fields].isna().all(axis=1).sum())
return {
"record_count": total,
"field_profiles": field_profiles,
"rows_with_any_profiled_missing": any_missing,
"rows_with_any_vital_sign_missing": any_vital_missing,
"rows_with_all_vital_signs_missing": all_vital_missing,
"nrs_pain_missing_interpretation": {
"total_missing": int(nrs_missing.sum()),
"not_applicable_when_pain_is_zero": nrs_not_applicable,
"unknown_when_pain_is_one": nrs_unknown,
"observed_score_when_pain_is_zero": nrs_contradictory,
},
"structural_value_checks": structural_checks,
"automatic_extreme_value_deletion": False,
}
def build_class_distribution(
reference_labels: pd.DataFrame, contract: Mapping[str, Any]
) -> JsonObject:
policy = contract["class_imbalance_policy"]
label_field = str(policy["label_field"])
label_order = [int(value) for value in policy["label_order"]]
_require_columns(reference_labels, [label_field], "reference_labels")
if reference_labels[label_field].isna().any():
raise QualityContractError(f"{label_field} 不可為空")
observed = set(reference_labels[label_field].astype(int).unique())
unexpected = sorted(observed.difference(label_order))
if unexpected:
raise QualityContractError(f"{label_field} 出現未允許級數:{unexpected}")
total = len(reference_labels)
counts = {
str(level): int(reference_labels[label_field].eq(level).sum())
for level in label_order
}
nonzero_counts = [count for count in counts.values() if count]
majority_level = max(label_order, key=lambda level: counts[str(level)])
majority_count = counts[str(majority_level)]
high_acuity_count = counts.get("1", 0) + counts.get("2", 0)
return {
"label_field": label_field,
"label_direction": "1 最緊急,5 最不緊急",
"counts": counts,
"rates": {
level: _rounded_rate(count, total) for level, count in counts.items()
},
"majority_level": majority_level,
"majority_class_accuracy": _rounded_rate(majority_count, total),
"majority_class_balanced_accuracy": round(1 / len(label_order), 6),
"largest_to_smallest_class_ratio": round(
max(nonzero_counts) / min(nonzero_counts), 6
),
"high_acuity_level_1_or_2_count": high_acuity_count,
"high_acuity_level_1_or_2_rate": _rounded_rate(high_acuity_count, total),
}
def _combined_split_metadata(
audit_strata: pd.DataFrame,
reference_labels: pd.DataFrame,
row_link_key: str,
stratification_fields: list[str],
) -> pd.DataFrame:
metadata = audit_strata.merge(
reference_labels,
on=row_link_key,
how="inner",
validate="one_to_one",
)
_require_columns(metadata, [row_link_key, *stratification_fields], "split metadata")
if metadata[stratification_fields].isna().any().any():
raise QualityContractError("分層欄位不可為空")
metadata = metadata[[row_link_key, *stratification_fields]].copy()
metadata["_stratum"] = metadata[stratification_fields].astype(str).agg("|".join, axis=1)
return metadata
def build_repeated_stratified_assignments(
audit_strata: pd.DataFrame,
reference_labels: pd.DataFrame,
contract: Mapping[str, Any],
) -> tuple[pd.DataFrame, JsonObject]:
"""Assign each record to one validation fold per repeat."""
policy = contract["split_policy"]
row_link_key = str(contract["row_link_key"])
stratification_fields = [str(name) for name in policy["stratification_fields"]]
fold_count = int(policy["fold_count"])
repeat_count = int(policy["repeat_count"])
seeds = [int(seed) for seed in policy["repeat_seeds"]]
if len(seeds) != repeat_count or len(set(seeds)) != repeat_count:
raise QualityContractError("repeat_seeds 必須和 repeat_count 等長且不可重複")
if fold_count < 2:
raise QualityContractError("fold_count 必須至少為 2")
metadata = _combined_split_metadata(
audit_strata,
reference_labels,
row_link_key,
stratification_fields,
)
stratum_sizes = metadata["_stratum"].value_counts().sort_index()
too_small = stratum_sizes[stratum_sizes < fold_count].to_dict()
if too_small:
raise QualityContractError(
f"每個分層至少需要 {fold_count} 筆,筆數不足={too_small}"
)
rows: list[JsonObject] = []
for repeat_id, seed in enumerate(seeds, start=1):
generator = random.Random(seed)
for stratum in sorted(stratum_sizes.index):
record_indices = sorted(
metadata.loc[metadata["_stratum"].eq(stratum), row_link_key].tolist()
)
generator.shuffle(record_indices)
fold_order = list(range(1, fold_count + 1))
generator.shuffle(fold_order)
for position, record_index in enumerate(record_indices):
rows.append(
{
row_link_key: int(record_index),
"repeat_id": repeat_id,
"fold_id": fold_order[position % fold_count],
"seed": seed,
}
)
assignments = pd.DataFrame(rows).sort_values(
["repeat_id", "fold_id", row_link_key], ignore_index=True
)
validation = validate_split_assignments(assignments, metadata, contract)
return assignments, validation
def validate_split_assignments(
assignments: pd.DataFrame,
metadata: pd.DataFrame,
contract: Mapping[str, Any],
) -> JsonObject:
policy = contract["split_policy"]
row_link_key = str(contract["row_link_key"])
expected_columns = [str(name) for name in policy["assignment_output_fields"]]
if assignments.columns.tolist() != expected_columns:
raise QualityContractError(
f"split assignment 欄位應為 {expected_columns},實際為 {assignments.columns.tolist()}"
)
fold_count = int(policy["fold_count"])
repeat_count = int(policy["repeat_count"])
expected_ids = set(metadata[row_link_key].tolist())
for repeat_id in range(1, repeat_count + 1):
current = assignments.loc[assignments["repeat_id"].eq(repeat_id)]
if len(current) != len(metadata):
raise QualityContractError(f"repeat {repeat_id} 的筆數不等於原始資料")
if current[row_link_key].duplicated().any():
raise QualityContractError(f"repeat {repeat_id} 有重複 record_index")
if set(current[row_link_key]) != expected_ids:
raise QualityContractError(f"repeat {repeat_id} 沒有完整涵蓋所有紀錄")
if set(current["fold_id"]) != set(range(1, fold_count + 1)):
raise QualityContractError(f"repeat {repeat_id} 的 fold_id 不完整")
joined = assignments.merge(
metadata,
on=row_link_key,
how="left",
validate="many_to_one",
)
max_stratum_difference = 0
for (_, _), group in joined.groupby(["repeat_id", "_stratum"]):
counts = group["fold_id"].value_counts().reindex(
range(1, fold_count + 1), fill_value=0
)
difference = int(counts.max() - counts.min())
max_stratum_difference = max(max_stratum_difference, difference)
if difference > 1:
raise QualityContractError("同一分層在 folds 間相差超過一筆")
fold_sizes = joined.groupby(["repeat_id", "fold_id"]).size()
return {
"every_record_once_per_repeat": True,
"all_fold_ids_present": True,
"maximum_within_stratum_fold_count_difference": max_stratum_difference,
"minimum_fold_size": int(fold_sizes.min()),
"maximum_fold_size": int(fold_sizes.max()),
"assignment_contains_no_label_or_site": not any(
name in assignments.columns
for name in contract["split_policy"]["stratification_fields"]
),
}
def build_fold_summary(
assignments: pd.DataFrame,
audit_strata: pd.DataFrame,
reference_labels: pd.DataFrame,
contract: Mapping[str, Any],
) -> list[JsonObject]:
row_link_key = str(contract["row_link_key"])
label_field = str(contract["class_imbalance_policy"]["label_field"])
site_field = "Group"
joined = (
assignments.merge(
reference_labels[[row_link_key, label_field]],
on=row_link_key,
validate="many_to_one",
)
.merge(
audit_strata[[row_link_key, site_field]],
on=row_link_key,
validate="many_to_one",
)
)
label_order = [int(value) for value in contract["class_imbalance_policy"]["label_order"]]
summaries = []
for (repeat_id, fold_id), group in joined.groupby(["repeat_id", "fold_id"], sort=True):
summaries.append(
{
"repeat_id": int(repeat_id),
"fold_id": int(fold_id),
"record_count": len(group),
"label_counts": {
str(level): int(group[label_field].eq(level).sum())
for level in label_order
},
"site_counts": {
str(site): int(count)
for site, count in group[site_field].value_counts().sort_index().items()
},
}
)
return summaries
def build_day15_report(
model_inputs: pd.DataFrame,
audit_strata: pd.DataFrame,
reference_labels: pd.DataFrame,
contract: Mapping[str, Any],
) -> tuple[JsonObject, pd.DataFrame]:
"""Build the aggregate Day 15 report and local split assignment table."""
if contract.get("schema_version") != 1:
raise QualityContractError("目前只支援 schema_version=1 的 Day 15 契約")
row_link_key = str(contract["row_link_key"])
validate_aligned_frames(
model_inputs,
audit_strata,
reference_labels,
row_link_key,
)
missingness = build_missingness_profile(model_inputs, contract)
distribution = build_class_distribution(reference_labels, contract)
assignments, validation = build_repeated_stratified_assignments(
audit_strata,
reference_labels,
contract,
)
csv_bytes = assignments.to_csv(index=False, lineterminator="\n").encode("utf-8")
report = {
"schema_version": 1,
"contract_id": contract["contract_id"],
"status": "passed",
"scope": contract["output_policy"]["scope"],
"record_count": len(model_inputs),
"missingness": missingness,
"class_distribution": distribution,
"split_plan": {
"unit": contract["split_policy"]["unit"],
"patient_identifier_available": contract["split_policy"]["patient_identifier_available"],
"patient_level_split_claim_allowed": contract["split_policy"]["patient_level_split_claim_allowed"],
"method": contract["split_policy"]["method"],
"fold_count": contract["split_policy"]["fold_count"],
"repeat_count": contract["split_policy"]["repeat_count"],
"repeat_seeds": contract["split_policy"]["repeat_seeds"],
"stratification_fields": contract["split_policy"]["stratification_fields"],
"assignment_row_count": len(assignments),
"assignment_sha256": hashlib.sha256(csv_bytes).hexdigest(),
"validation": validation,
"folds": build_fold_summary(
assignments,
audit_strata,
reference_labels,
contract,
),
},
"decisions": {
"missing_is_never_assumed_normal": True,
"preprocessing_is_fit_inside_training_fold": True,
"resampling_before_split_is_forbidden": True,
"overall_accuracy_is_secondary": True,
"extreme_values_are_not_automatically_deleted": True,
},
"limitations": [
contract["split_policy"]["limitation"],
"這是資料品質與切分工程結果,不是模型效能或臨床安全結果。",
],
}
return report, assignments
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
scripts/prepare_day15_splits.py讀取設定與三份 CSV,產生本機分派表、公開聚合摘要與 run manifest。
請在文字編輯器建立 scripts/prepare_day15_splits.py,貼入以下完整內容並儲存:
#!/usr/bin/env python3
"""Profile Day 15 data quality and create repeated stratified folds."""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
from triage_rag.data.quality import build_day15_report
from triage_rag.reproducibility import (
canonical_json_bytes,
file_record,
git_state,
load_json,
sha256_bytes,
sha256_file,
write_json,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONTRACT = "configs/data/day-15-quality-and-split-contract.json"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="建立 Day 15 缺失概況、類別分布與重複分層五折切分。"
)
parser.add_argument("--contract", default=DEFAULT_CONTRACT)
return parser.parse_args()
def main() -> int:
args = parse_args()
contract_path = PROJECT_ROOT / args.contract
contract = load_json(contract_path)
input_paths = {
name: PROJECT_ROOT / path for name, path in contract["inputs"].items()
}
model_inputs = pd.read_csv(input_paths["model_inputs"])
audit_strata = pd.read_csv(input_paths["audit_strata"])
reference_labels = pd.read_csv(input_paths["reference_labels"])
report, assignments = build_day15_report(
model_inputs,
audit_strata,
reference_labels,
contract,
)
report_sha256 = sha256_bytes(canonical_json_bytes(report))
assignment_path = PROJECT_ROOT / contract["output_policy"]["assignment_path"]
assignment_path.parent.mkdir(parents=True, exist_ok=True)
assignments.to_csv(assignment_path, index=False, lineterminator="\n")
if sha256_file(assignment_path) != report["split_plan"]["assignment_sha256"]:
raise RuntimeError("split assignment 寫入後的 SHA-256 與報告不一致")
public_summary_path = PROJECT_ROOT / contract["output_policy"]["public_summary_path"]
write_json(public_summary_path, report)
started_at = datetime.now(timezone.utc)
run_id = f"{started_at.strftime('%Y%m%dT%H%M%S%fZ')}-{report_sha256[:8]}"
run_directory = PROJECT_ROOT / contract["output_policy"]["run_output_root"] / run_id
run_directory.mkdir(parents=True, exist_ok=False)
report_path = run_directory / "data-quality-and-splits.json"
write_json(report_path, report)
tracked_inputs = [
args.contract,
contract["source_dataset_card"],
contract["source_data_contract"],
*contract["inputs"].values(),
]
manifest = {
"manifest_schema_version": 1,
"run_id": run_id,
"started_at_utc": started_at.isoformat().replace("+00:00", "Z"),
"command": [sys.executable, *sys.argv],
"documented_command": [
"poetry",
"run",
"python",
"scripts/prepare_day15_splits.py",
],
"git": git_state(PROJECT_ROOT),
"inputs": [file_record(PROJECT_ROOT, path) for path in tracked_inputs],
"parameters": {
"fold_count": contract["split_policy"]["fold_count"],
"repeat_count": contract["split_policy"]["repeat_count"],
"repeat_seeds": contract["split_policy"]["repeat_seeds"],
"stratification_fields": contract["split_policy"]["stratification_fields"],
},
"outputs": [
file_record(
PROJECT_ROOT,
str(report_path.relative_to(PROJECT_ROOT)),
),
file_record(
PROJECT_ROOT,
str(public_summary_path.relative_to(PROJECT_ROOT)),
),
file_record(
PROJECT_ROOT,
str(assignment_path.relative_to(PROJECT_ROOT)),
),
],
"scope": contract["output_policy"]["scope"],
"privacy": "公開摘要只含聚合計數;split assignment 只有 record_index 與 fold 編號,且留在 gitignored data/。",
}
manifest_path = run_directory / "run-manifest.json"
write_json(manifest_path, manifest)
missing = {
item["field"]: item["missing_count"]
for item in report["missingness"]["field_profiles"]
}
print("Day 15 資料品質與切分:通過")
print(f"資料筆數:{report['record_count']:,}")
print(f"缺失最多欄位:Saturation,共 {missing['Saturation']:,} 筆")
print(
"專家級數分布:"
+ ", ".join(
f"第{level}級 {count} 筆"
for level, count in report["class_distribution"]["counts"].items()
)
)
print(
f"切分:{report['split_plan']['repeat_count']} 次重複 × "
f"{report['split_plan']['fold_count']} folds,"
f"共 {report['split_plan']['assignment_row_count']:,} 筆 assignment"
)
print(f"公開聚合摘要:{public_summary_path.relative_to(PROJECT_ROOT)}")
print(f"本機切分檔:{assignment_path.relative_to(PROJECT_ROOT)}")
print(f"執行目錄:{run_directory.relative_to(PROJECT_ROOT)}")
print(f"穩定報告摘要:{report_sha256}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
tests/test_quality_and_splits.py以合成資料驗證切分可重現、疼痛缺失語意、非法值與錯誤分層都會被處理。
請在文字編輯器建立 tests/test_quality_and_splits.py,貼入以下完整內容並儲存:
from __future__ import annotations
import copy
import unittest
from pathlib import Path
import pandas as pd
from triage_rag.data.quality import (
QualityContractError,
build_day15_report,
)
from triage_rag.reproducibility import load_json
ROOT = Path(__file__).resolve().parents[1]
CONTRACT_PATH = ROOT / "configs" / "data" / "day-15-quality-and-split-contract.json"
class Day15QualityAndSplitTests(unittest.TestCase):
def setUp(self) -> None:
self.contract = load_json(CONTRACT_PATH)
records = []
audits = []
labels = []
record_index = 1
for level in range(1, 6):
for group in (1, 2):
for position in range(6):
pain = 0 if position % 2 == 0 else 1
nrs = None if pain == 0 else 4
if level == 1 and group == 1 and position == 1:
nrs = None
records.append(
{
"record_index": record_index,
"Pain": pain,
"NRS_pain": nrs,
"SBP": 120,
"DBP": 80,
"HR": 80,
"RR": 18,
"BT": 36.5,
"Saturation": None if position == 0 else 98,
}
)
audits.append({"record_index": record_index, "Group": group})
labels.append(
{"record_index": record_index, "KTAS_expert": level}
)
record_index += 1
self.model_inputs = pd.DataFrame(records)
self.audit_strata = pd.DataFrame(audits)
self.reference_labels = pd.DataFrame(labels)
def test_report_and_assignments_are_deterministic(self) -> None:
first_report, first_assignments = build_day15_report(
self.model_inputs,
self.audit_strata,
self.reference_labels,
self.contract,
)
second_report, second_assignments = build_day15_report(
self.model_inputs,
self.audit_strata,
self.reference_labels,
self.contract,
)
self.assertEqual(first_report, second_report)
pd.testing.assert_frame_equal(first_assignments, second_assignments)
self.assertEqual(len(first_assignments), 60 * 5)
self.assertNotIn("KTAS_expert", first_assignments.columns)
self.assertNotIn("Group", first_assignments.columns)
self.assertTrue(
first_report["split_plan"]["validation"][
"every_record_once_per_repeat"
]
)
def test_nrs_missing_is_split_into_not_applicable_and_unknown(self) -> None:
report, _ = build_day15_report(
self.model_inputs,
self.audit_strata,
self.reference_labels,
self.contract,
)
interpretation = report["missingness"]["nrs_pain_missing_interpretation"]
self.assertEqual(interpretation["not_applicable_when_pain_is_zero"], 30)
self.assertEqual(interpretation["unknown_when_pain_is_one"], 1)
self.assertEqual(interpretation["observed_score_when_pain_is_zero"], 0)
def test_duplicate_record_index_is_rejected(self) -> None:
invalid = self.model_inputs.copy()
invalid.loc[1, "record_index"] = invalid.loc[0, "record_index"]
with self.assertRaisesRegex(QualityContractError, "必須唯一"):
build_day15_report(
invalid,
self.audit_strata,
self.reference_labels,
self.contract,
)
def test_structurally_invalid_saturation_is_rejected(self) -> None:
invalid = self.model_inputs.copy()
invalid.loc[0, "Saturation"] = 101
with self.assertRaisesRegex(QualityContractError, "超出 0 到 100"):
build_day15_report(
invalid,
self.audit_strata,
self.reference_labels,
self.contract,
)
def test_stratum_smaller_than_fold_count_is_rejected(self) -> None:
keep = ~(
self.reference_labels["KTAS_expert"].eq(1)
& self.audit_strata["Group"].eq(1)
) | self.reference_labels["record_index"].le(4)
model_inputs = self.model_inputs.loc[keep].reset_index(drop=True)
audit_strata = self.audit_strata.loc[keep].reset_index(drop=True)
reference_labels = self.reference_labels.loc[keep].reset_index(drop=True)
with self.assertRaisesRegex(QualityContractError, "筆數不足"):
build_day15_report(
model_inputs,
audit_strata,
reference_labels,
copy.deepcopy(self.contract),
)
if __name__ == "__main__":
unittest.main()
儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。
以下命令會在需要時產生套件鎖定檔,接著檢查設定格式與 Python 語法;它們不會啟動模型,也不會把病患資料送到網路:
poetry run python -m json.tool configs/data/day-15-quality-and-split-contract.json
poetry run python -m py_compile src/triage_rag/data/quality.py scripts/prepare_day15_splits.py tests/test_quality_and_splits.py
每個命令都應正常結束。若 JSON 顯示行號,先檢查貼上時是否遺漏逗號、引號或括號;若 py_compile 報錯,先依行號修正縮排或漏貼內容。靜態檢查通過後,再執行本文後面的正式步驟。
本篇沿用 Python 相依套件管理與封裝工具(Python dependency management and packaging tool, Poetry)。Poetry 依 poetry.lock 使用 Day 13 鎖定的環境。以下命令都從自己的專案根目錄執行,而且不需要 Ollama 模型。
poetry sync;若尚未取得 Kaggle 資料,需要可連線下載公開 ZIP。data/interim/ktas-v1/ 尚未存在,執行資料管線。poetry run python scripts/run_ktas_pipeline.py
data/interim/ktas-v1/ 至少存在 model-input-candidates.csv、audit-strata.csv 與 reference-labels.csv。若來源安全雜湊演算法 256 位元版本(Secure Hash Algorithm 256-bit, SHA-256)不同,管線應停止,不要直接改掉預期摘要。tests/ 中的合成案例、Day 13 至 Day 15 核心模組與設定。poetry run python -m unittest discover -s tests -v
ok,最後出現 Ran 13 tests 與 OK。FAIL 或 ERROR 都代表工程規則尚未通過,不應繼續產生切分或模型結果。本次實際執行結果為:
Ran 13 tests in 0.084s
OK
執行秒數可能因電腦而略有不同,真正要核對的是測試數量與 OK。
poetry run python scripts/prepare_day15_splits.py
Saturation 缺失 697 筆、五級筆數,以及 5 次重複 × 5 折共 6,335 筆分派。| 輸出 | 用途 | 是否包含逐筆標籤或生命徵象 |
|---|---|---|
data/processed/ktas-v1/day-15-split-assignments.csv |
本機後續實驗共用的固定分派表 | 否;只有索引、重複、折與種子 |
results/public/day-15-data-quality-and-splits.json |
可提交 Git 的聚合缺失率、類別分布與每折計數 | 否 |
results/runs/day-15/<run-id>/ |
保存本次完整聚合報告與 run manifest | 否 |
data/processed/ 是保存切分後索引與未來模型處理產物的本機資料夾,已由 Git 忽略。results/public/ 只保存可公開聚合資訊;results/runs/ 則按每次執行建立獨立目錄。執行產物清單(Run Manifest,下文簡稱 run manifest)會記錄命令、時間、Git 狀態、輸入雜湊與輸出雜湊,讓日後知道某份結果是怎麼產生的。
本次實際得到的穩定聚合報告摘要為:
f30c189620559060d9904fae2d8e55160652a88d3ebfbc2d71c3cfeecc53c3b7
分派 CSV 自己的 SHA-256 則是:
4e254967c612063c57aca7e3c1f8960828135b1c762401438b4b52f279a44c51
兩個摘要用途不同:前者驗證聚合 JSON 內容,後者驗證逐列分派順序。它們都不是 Kaggle ZIP、模型權重或效能結果的摘要。
results/public/day-15-data-quality-and-splits.json。poetry run python - <<'PY'
import json
from pathlib import Path
report = json.loads(
Path("results/public/day-15-data-quality-and-splits.json")
.read_text(encoding="utf-8")
)
validation = report["split_plan"]["validation"]
print("資料筆數:", report["record_count"])
print("分派筆數:", report["split_plan"]["assignment_row_count"])
print("每筆每次只出現一次:", validation["every_record_once_per_repeat"])
print("分派檔不含標籤或場域:", validation["assignment_contains_no_label_or_site"])
print("最小/最大折:", validation["minimum_fold_size"], validation["maximum_fold_size"])
PY
True,所有 25 個折的最小與最大筆數分別為 251 與 256。True、分派筆數不是 (1267\times 5),或折大小超出報告範圍,代表分派檔與契約不一致,應停止後續實驗。重抽樣(Bootstrap Resampling)是從既有樣本中有放回地重複抽取資料,形成許多同樣大小的重抽樣資料集,再觀察評估指標如何波動。它會在 Day 28 用來估計模型比較的不確定性;Day 15 還沒有模型預測,因此現在不能產生 95% 信賴區間。
本篇先鎖定以下原則:
record_index 做成對重抽樣,不能替兩個方法各抽各的資料。這樣做的目的,是避免先看到哪種算法會產生較漂亮的區間,再倒過來挑選設定。現在鎖定的是分析順序與單位,不是尚未發生的模型結果。
本篇實際完成並驗證的內容包括:
KTAS_expert、Group 或任何病患輸入值。這些結果沒有證明: