iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
佛心分享-SideProject30

30 天打造公開資料版急診檢傷系統:Side Project 與實驗計畫系列 第 13

Day 13|別讓結果只活在 Notebook:打造可重現的 Side Project 專案結構

  • 分享至 

  • xImage
  •  

Day 12 已經把 Kaggle 公開資料的下載位置、檔案雜湊、欄位政策與資料轉換寫成一條可重跑管線。這解決了「同一份原始資料能不能再次整理出相同中介資料」的問題,卻還沒有回答另一件事:換一台電腦、隔一個月,甚至只是重新開啟終端機後,整個專案還能不能用相同條件執行?

很多 Side Project 一開始都放在互動式運算筆記本(Computational Notebook,以下簡稱 Notebook)裡。Notebook 可以把說明、程式碼與輸出排在同一頁,很適合探索資料;問題在於,畫面上看到的儲存格順序,不一定等於它們真正的執行順序。某個變數可能來自昨天執行過、今天卻忘記重跑的儲存格,套件也可能已經在作者的電腦上悄悄升級。

下圖用左右兩個工作台呈現這個差異。請先看左側散落的筆記本、參數紙條與不明版本檔案,再看右側如何把設定、程式、測試與結果接成一條可以追蹤的路徑。

左側是散落筆記本、參數紙條與不明版本檔案,右側是由設定、程式、測試與封存結果組成的模組化工作台,開發者把一張設定卡移向右側

上圖是重現性的概念示意,不是正式系統架構,也不代表模組化之後就能自動消除所有錯誤。今天真正要做的,是讓每次執行都留下足夠資訊,使我們能回答「用了哪份資料、哪份設定、哪個程式版本、哪個環境,又產生了什麼」。

今天不會訓練模型,也不會產生任何韓國急診檢傷與急迫度分級量表(Korean Triage and Acuity Scale, KTAS)的模型成績。我們會先用五筆純合成案例,把專案的重現骨架建立並測通。後續專案會建置檢索增強生成(Retrieval-Augmented Generation, RAG),但今天只處理共同工程底座。

本篇使用 Python 相依套件管理與封裝工具(Python dependency management and packaging tool, Poetry)管理環境,並以 JavaScript 物件表示法(JavaScript Object Notation, JSON)保存設定與產物。每次執行還會建立執行產物清單(Run Manifest,下文簡稱 run manifest),讓輸入、環境與輸出可以互相追溯。


今天要完成什麼?

讀完並實際操作後,你會完成四件事:

  1. 看懂資料、設定、核心程式、執行入口、測試與結果為什麼要分開。
  2. 使用 pyproject.tomlpoetry.lock 約束 Python 版本並鎖定相依套件。
  3. 執行一個由設定檔驅動的重現性冒煙測試。
  4. 產生 result.jsonrun-manifest.json,並驗證兩次執行的穩定結果摘要相同。

這裡的「可重現」有明確範圍:使用相同專案版本、poetry.lock、設定檔與合成輸入時,今天的程式應產生相同的穩定結果內容。不同執行時間、作業系統名稱與執行目錄原本就可能不同,因此它們會留在執行紀錄中,不會被誤當成穩定結果。

本篇會用到的名詞

本篇的新名詞較多,先用表格建立共同語言。正文第一次使用時仍會再解釋它們的角色。

中文名稱 英文全名/名稱 本篇用途
程式碼儲存庫 Repository,本文簡稱 repo 保存文章、設定、程式、測試與可公開規則的專案根目錄
版本控制工具 Git 在本機記錄檔案版本與修改狀態;本篇只讀取狀態,不要求把內容上傳到遠端
執行環境 Runtime Environment 指 Python 與相依套件實際執行程式的環境
Python 相依套件管理與封裝工具 Python dependency management and packaging tool, Poetry 依鎖定檔建立與執行專案環境
JavaScript 物件表示法 JavaScript Object Notation, JSON 保存本篇設定、穩定結果與每次執行紀錄
鎖定檔 Lockfile 保存解析後的確切套件版本,避免安裝時間不同而取得不同版本
執行產物清單 Run Manifest 保存一次執行的輸入、參數、環境、程式版本與輸出索引
安全雜湊演算法 256 位元版本 Secure Hash Algorithm 256-bit, SHA-256 為檔案或穩定內容產生可比較的內容摘要
隨機種子 Random Seed 固定虛擬隨機抽樣的起始狀態
測試夾具 Test Fixture 為自動化測試預先準備的固定輸入
冒煙測試 Smoke Test 用小型輸入快速確認主要流程能否從頭走完

為什麼一支 Notebook 不足以負責重現?

Notebook 本身不是問題。真正的問題是把所有責任都交給同一份 Notebook,卻沒有補上外部契約。

隱藏的執行狀態

假設畫面上的儲存格依序是 A、B、C,但實際執行順序是 A、C、B、C。最後的 C 可能讀到 B 更新後的變數,重新從上到下執行卻得到另一個結果。讀者只看儲存格排列,無法知道作者當時的記憶體狀態。

參數散落在不同位置

前 k 筆候選數可能寫在一個儲存格,隨機種子藏在另一個函式,提示詞版本又只存在作者的筆記中。即使輸出表格還在,也很難回推出當時到底使用哪組設定。

執行環境沒有被記錄

只寫「安裝 Pandas」並沒有指定版本。今天與三個月後執行安裝命令,套件管理工具可能解析出不同版本。程式碼完全相同,不代表執行環境相同。

輸出容易被覆寫

如果每次都寫入 result.csv,第二次執行就會覆蓋第一次結果。最後只剩一張表,卻不知道它來自哪份設定與哪個 Git 版本。

因此,本篇不會禁止 Notebook。後續仍可用 Notebook 做探索和解讀,但正式資料處理、模型執行與評估必須呼叫可測試的模組,並由設定檔與 run manifest 留下完整脈絡。

先把 repo 的責任切開

程式碼儲存庫(Repository,repo)不只是「放程式碼的資料夾」。在本系列中,repo 是一份執行契約:每種檔案都有固定位置,讀者看到路徑就能先知道它的角色。

Day 13 完成後,與重現性直接相關的結構如下。這不是未來規劃,而是本篇已經建立、可以執行的檔案。

.
├── .python-version
├── pyproject.toml
├── poetry.lock
├── configs/
│   ├── data/
│   │   ├── day-11-ktas-field-policy.json
│   │   └── day-12-kaggle-ktas-dataset-card.json
│   └── experiments/
│       └── day-13-reproducibility-smoke.json
├── data/
│   └── README.md
├── scripts/
│   └── run_reproducibility_smoke.py
├── src/
│   └── triage_rag/
│       ├── __init__.py
│       └── reproducibility.py
├── tests/
│   ├── fixtures/
│   │   └── day-13-synthetic-cases.json
│   └── test_reproducibility.py
└── results/
    ├── README.md
    └── runs/                       # 本機產生,不提交 Git

下圖把六個主要資料夾的責任放在同一個畫面。閱讀時先看上排的輸入、設定與核心邏輯,再看下排的執行入口、測試與產物。

六張卡片分別說明資料、設定、核心程式、執行入口、合成測試與執行產物的責任,底部標示 Git 只保存規則與程式

上圖底部的版本控制邊界很重要。Git 是在本機記錄檔案版本與修改狀態的工具;它不等於把檔案上傳到網路。data/ 可以在本機保存 Kaggle 原始檔與處理後資料,但逐筆紀錄由 .gitignore 排除;results/runs/ 保存每次執行產物,同樣不納入專案版本。版本控制只保存取得方式、資料雜湊、欄位政策、程式和不含真實病歷的測試資料。

六個資料夾的分工如下:

路徑 保存內容 不應承擔的責任
data/ 本機原始資料、中介資料與未來的模型資料 不保存程式邏輯,不把逐筆紀錄提交 Git
configs/ 資料政策與實驗參數 不執行資料處理或模型推論
src/ 可以重複呼叫、可以單獨測試的核心 Python 模組 不解析某一次人工輸入的終端機命令
scripts/ 串接設定與核心模組的命令入口 不複製一份已存在於 src/ 的核心邏輯
tests/ 固定合成輸入與自動化檢查 不保存真實病患資料,也不充當模型成績
results/ 每次執行的結果與 manifest 不覆寫前一次執行,也不預設全部可以公開

這種安排也解釋了為什麼本篇會新增兩支不同的 Python 程式。src/triage_rag/reproducibility.py 是可重複使用的核心模組,負責路徑限制、SHA-256、穩定 JSON、Git 狀態與 manifest;scripts/run_reproducibility_smoke.py 是給讀者執行的入口,負責讀取命令參數並呼叫核心模組。

pyproject.toml 說明環境需求

pyproject.toml 是使用 TOML(Tom's Obvious Minimal Language, TOML)格式撰寫的 Python 專案設定檔。它位於 repo 根目錄,讓套件管理工具知道專案名稱、Python 版本範圍、相依套件與如何載入 src/ 裡的套件。

這個檔案的輸入是我們明確寫下的環境需求;輸出不是模型結果,而是提供套件解析工具使用的版本條件。稍後的「完整檔案」小節會要求你在專案根目錄建立 pyproject.toml,並提供可以直接儲存的全文。這裡先理解四個區塊的分工:build-system 選擇建置工具,project 保存專案與 Python 條件,tool.poetry 指向 src/ 套件,tool.poetry.group.figures 則把繪圖套件留在選用群組。

這裡把 Python 限制在 3.12 系列、Poetry 限制在 2.4 系列,並把 Pandas 限制在 2.2 系列。packages 告訴 Poetry,真正要安裝的 triage_rag 套件位於 src/,而不是 repo 根目錄。figures 是選用的圖片生成依賴群組,不會在一般執行時自動安裝。

這些設定仍是允許範圍,不是最終安裝結果。例如 pandas>=2.2,<2.3 允許 2.2.0 到 2.2 系列最後一版,因此還需要鎖定檔決定實際版本。根目錄的 .python-version 另外記錄本專案採用 Python 3.12,但 Poetry 不會替我們安裝 Python 直譯器;電腦仍須先準備符合條件的 Python。

poetry.lock 才保存解析後的確切版本

前面介紹的 Poetry 會依專案設定建立執行環境。pyproject.toml 表示允許的版本條件,poetry.lock 則保存 Poetry 解析後的確切直接與間接相依套件版本。

poetry.lock 應納入專案版本控制,但不應手動編輯。只有在有意新增、移除或升級套件時,才修改 pyproject.toml 並執行 poetry lock,接著審查兩個檔案的差異。第一次依本文建立專案時,後面的完整檔案小節會帶你產生鎖定檔;若鎖定檔已存在,則應先沿用,不要在沒有升級目的時任意重建。

步驟 1:確認 Poetry 與 Python 已準備好

  • 目的:確認電腦上有能讀取 pyproject.tomlpoetry.lock 的 Poetry,以及符合專案條件的 Python 3.12。
  • 前置條件:在終端機切換到自己的專案根目錄。Poetry 官方建議把 Poetry 本身和專案環境分開安裝,避免專案套件影響管理工具。
  • 輸入:不讀取病患資料,只查詢 Poetry 與 Python 版本。
  • 操作:執行下列命令。
poetry --version
python3.12 --version
  • 預期輸出:本篇驗證使用 Poetry (version 2.4.1)Python 3.12.13。Python 的修補版本可以不同,但主要與次要版本必須是 3.12。
  • 驗證方式:兩個命令都要成功。若系統找不到 Poetry,可使用 Python 命令列應用隔離安裝工具(pipx)執行 pipx install "poetry==2.4.1";若找不到 python3.12,要先安裝 Python 3.12,不能期待 Poetry 自動下載直譯器。

先在自己的專案資料夾建立本篇完整檔案

接下來不會要求你前往任何程式碼網站。請在自己的電腦開啟專案資料夾,依下列順序建立檔案;每個程式碼區塊都是該檔案的完整內容,不含省略號。

Day 11 與 Day 12 的政策和資料卡是已完成的跨日前置檔案;以下會從 pyproject.toml 開始,把 Day 13 新增的內容全部建立出來。

先從專案根目錄建立需要的資料夾:

mkdir -p configs/experiments scripts src/triage_rag tests/fixtures results/runs/day-13

如果指令沒有印出訊息是正常的。可用 test -d 資料夾路徑 && echo "資料夾已建立" 驗證單一資料夾。接著使用你熟悉的文字編輯器新增各檔案,把對應區塊完整貼入後儲存。

檔案 1:建立 pyproject.toml

定義 Python、Poetry、Pandas、套件目錄與選用繪圖依賴。

請在文字編輯器建立 pyproject.toml,貼入以下完整內容並儲存:

[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

[project]
name = "triage-rag-sideproject30"
version = "0.1.0"
description = "Reproducible KTAS triage RAG side project for the 2026 iThome Ironman series"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
  "pandas>=2.2,<2.3",
]

[tool.poetry]
requires-poetry = ">=2.4,<2.5"
packages = [
  { include = "triage_rag", from = "src" },
]

[tool.poetry.group.figures]
optional = true

[tool.poetry.group.figures.dependencies]
pillow = ">=12.2,<12.3"

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 2:建立 .python-version

告訴版本管理工具本專案使用 Python 3.12。

請在文字編輯器建立 .python-version,貼入以下完整內容並儲存:

3.12

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 3:建立 src/triage_rag/__init__.py

建立可由 Poetry 安裝的 Python 套件入口。

請在文字編輯器建立 src/triage_rag/__init__.py,貼入以下完整內容並儲存:

"""KTAS triage RAG side project core package."""

from .reproducibility import execute_smoke

__all__ = ["execute_smoke"]

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 4:建立 configs/experiments/day-13-reproducibility-smoke.json

保存冒煙測試的合成資料路徑、隨機種子、必要欄位與輸出位置。

請在文字編輯器建立 configs/experiments/day-13-reproducibility-smoke.json,貼入以下完整內容並儲存:

{
  "schema_version": 1,
  "experiment_id": "day-13-reproducibility-smoke",
  "description": "使用五筆純合成案例驗證設定、程式、輸入與輸出能否留下可追溯紀錄,不執行模型或臨床效能評估。",
  "fixture_path": "tests/fixtures/day-13-synthetic-cases.json",
  "tracked_inputs": [
    "configs/data/day-11-ktas-field-policy.json",
    "configs/data/day-12-kaggle-ktas-dataset-card.json",
    "pyproject.toml",
    "poetry.lock"
  ],
  "output_root": "results/runs/day-13",
  "random_seed": 20260804,
  "sample_size": 3,
  "expected_case_count": 5,
  "required_fields": [
    "case_id",
    "chief_complaint",
    "synthetic_only"
  ],
  "forbidden_fields": [
    "KTAS_RN",
    "KTAS_expert",
    "Diagnosis",
    "Disposition",
    "Length_of_stay",
    "Error_group",
    "mistriage"
  ],
  "record_packages": [
    "pandas",
    "triage-rag-sideproject30"
  ]
}

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 5:建立 tests/fixtures/day-13-synthetic-cases.json

提供五筆沒有真實病歷內容的固定合成案例。

請在文字編輯器建立 tests/fixtures/day-13-synthetic-cases.json,貼入以下完整內容並儲存:

[
  {
    "case_id": "SYN-001",
    "chief_complaint": "synthetic shortness of breath",
    "heart_rate": 104,
    "respiratory_rate": 24,
    "spo2": 93,
    "synthetic_only": true
  },
  {
    "case_id": "SYN-002",
    "chief_complaint": "synthetic ankle pain",
    "heart_rate": 82,
    "respiratory_rate": 17,
    "spo2": 98,
    "synthetic_only": true
  },
  {
    "case_id": "SYN-003",
    "chief_complaint": "synthetic abdominal discomfort",
    "heart_rate": 96,
    "respiratory_rate": 19,
    "spo2": 97,
    "synthetic_only": true
  },
  {
    "case_id": "SYN-004",
    "chief_complaint": "synthetic dizziness",
    "heart_rate": null,
    "respiratory_rate": 18,
    "spo2": 99,
    "synthetic_only": true
  },
  {
    "case_id": "SYN-005",
    "chief_complaint": "synthetic fever",
    "heart_rate": 110,
    "respiratory_rate": 22,
    "spo2": null,
    "synthetic_only": true
  }
]

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 6:建立 src/triage_rag/reproducibility.py

實作穩定 JSON、SHA-256、路徑限制、Git 狀態與 run manifest。

請在文字編輯器建立 src/triage_rag/reproducibility.py,貼入以下完整內容並儲存:

"""Helpers for producing traceable and deterministic smoke-test artifacts."""

from __future__ import annotations

import hashlib
import importlib.metadata
import json
import platform
import random
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional


JsonObject = Dict[str, Any]


def canonical_json_bytes(value: Any) -> bytes:
    """Serialize JSON with stable key order and separators for hashing."""

    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")


def sha256_bytes(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def load_json(path: Path) -> Any:
    with path.open("r", encoding="utf-8") as handle:
        return json.load(handle)


def write_json(path: Path, value: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as handle:
        json.dump(value, handle, ensure_ascii=False, sort_keys=True, indent=2)
        handle.write("\n")


def resolve_project_path(project_root: Path, relative_path: str) -> Path:
    """Resolve a project-relative path and reject traversal outside the repo."""

    root = project_root.resolve()
    candidate = (root / relative_path).resolve()
    if candidate != root and root not in candidate.parents:
        raise ValueError(f"路徑超出專案範圍:{relative_path}")
    return candidate


def file_record(project_root: Path, relative_path: str) -> JsonObject:
    path = resolve_project_path(project_root, relative_path)
    if not path.is_file():
        raise FileNotFoundError(f"找不到必要檔案:{relative_path}")
    return {
        "path": relative_path,
        "sha256": sha256_file(path),
        "size_bytes": path.stat().st_size,
    }


def git_state(project_root: Path) -> JsonObject:
    def run_git(*args: str) -> Optional[str]:
        try:
            completed = subprocess.run(
                ["git", *args],
                cwd=project_root,
                check=True,
                capture_output=True,
                text=True,
            )
        except (FileNotFoundError, subprocess.CalledProcessError):
            return None
        return completed.stdout.strip()

    commit = run_git("rev-parse", "HEAD")
    status = run_git("status", "--porcelain", "--untracked-files=no")
    return {
        "commit": commit,
        "tracked_files_dirty": None if status is None else bool(status),
    }


def installed_versions(package_names: Iterable[str]) -> JsonObject:
    versions: JsonObject = {}
    for name in package_names:
        try:
            versions[name] = importlib.metadata.version(name)
        except importlib.metadata.PackageNotFoundError:
            versions[name] = "not-installed"
    return versions


def build_deterministic_result(cases: List[JsonObject], config: Mapping[str, Any]) -> JsonObject:
    if not isinstance(cases, list) or not cases:
        raise ValueError("合成測試資料必須是非空 JSON 陣列")
    if any(not isinstance(case, dict) for case in cases):
        raise ValueError("每筆合成測試資料都必須是 JSON 物件")

    expected_count = int(config["expected_case_count"])
    if len(cases) != expected_count:
        raise ValueError(f"合成案例數應為 {expected_count},實際為 {len(cases)}")

    required_fields = list(config["required_fields"])
    forbidden_fields = set(config["forbidden_fields"])
    all_fields = sorted({key for case in cases for key in case})
    present_forbidden = sorted(forbidden_fields.intersection(all_fields))
    if present_forbidden:
        raise ValueError(f"合成資料出現禁止欄位:{present_forbidden}")

    missing_required = {
        str(case.get("case_id", f"row-{index}")): [
            field for field in required_fields if field not in case
        ]
        for index, case in enumerate(cases)
    }
    missing_required = {
        case_id: fields for case_id, fields in missing_required.items() if fields
    }
    if missing_required:
        raise ValueError(f"合成資料缺少必要欄位:{missing_required}")

    case_ids = [str(case["case_id"]) for case in cases]
    if len(case_ids) != len(set(case_ids)):
        raise ValueError("case_id 必須唯一")
    if not all(case.get("synthetic_only") is True for case in cases):
        raise ValueError("每筆測試資料都必須明確標記 synthetic_only=true")

    missing_counts = {
        field: sum(case.get(field) is None for case in cases) for field in all_fields
    }
    sample_size = min(int(config["sample_size"]), len(case_ids))
    generator = random.Random(int(config["random_seed"]))
    sampled_case_ids = generator.sample(sorted(case_ids), sample_size)

    return {
        "schema_version": 1,
        "experiment_id": config["experiment_id"],
        "status": "passed",
        "scope": "engineering_smoke_test_not_model_evaluation",
        "case_count": len(cases),
        "field_count": len(all_fields),
        "fields": all_fields,
        "missing_counts": missing_counts,
        "sampled_case_ids": sampled_case_ids,
        "checks": {
            "expected_case_count": True,
            "required_fields_present": True,
            "forbidden_fields_absent": True,
            "case_ids_unique": True,
            "all_rows_marked_synthetic": True,
        },
    }


def execute_smoke(
    project_root: Path,
    config_path: str,
    *,
    now: Optional[datetime] = None,
    command: Optional[List[str]] = None,
) -> JsonObject:
    """Execute the Day 13 smoke test and write result plus run manifest."""

    root = project_root.resolve()
    resolved_config = resolve_project_path(root, config_path)
    config = load_json(resolved_config)
    if config.get("schema_version") != 1:
        raise ValueError("目前只支援 schema_version=1 的設定檔")

    fixture_path = str(config["fixture_path"])
    cases = load_json(resolve_project_path(root, fixture_path))
    result = build_deterministic_result(cases, config)
    deterministic_result_sha256 = sha256_bytes(canonical_json_bytes(result))

    started_at = now or datetime.now(timezone.utc)
    if started_at.tzinfo is None:
        started_at = started_at.replace(tzinfo=timezone.utc)
    started_at = started_at.astimezone(timezone.utc)
    config_sha256 = sha256_file(resolved_config)
    run_id = f"{started_at.strftime('%Y%m%dT%H%M%S%fZ')}-{config_sha256[:8]}"
    output_root = resolve_project_path(root, str(config["output_root"]))
    run_directory = output_root / run_id
    if run_directory.exists():
        raise FileExistsError(f"執行目錄已存在,拒絕覆寫:{run_directory}")
    run_directory.mkdir(parents=True)

    result_path = run_directory / "result.json"
    write_json(result_path, result)

    tracked_inputs = [config_path, fixture_path, *config["tracked_inputs"]]
    manifest = {
        "manifest_schema_version": 1,
        "run_id": run_id,
        "experiment_id": config["experiment_id"],
        "started_at_utc": started_at.isoformat().replace("+00:00", "Z"),
        "command": command or [],
        "git": git_state(root),
        "runtime": {
            "python": platform.python_version(),
            "implementation": platform.python_implementation(),
            "platform": platform.platform(),
            "packages": installed_versions(config["record_packages"]),
        },
        "parameters": {
            "random_seed": config["random_seed"],
            "sample_size": config["sample_size"],
        },
        "inputs": [file_record(root, path) for path in tracked_inputs],
        "outputs": [
            {
                "path": str(result_path.relative_to(root)),
                "sha256": sha256_file(result_path),
                "deterministic_content_sha256": deterministic_result_sha256,
                "size_bytes": result_path.stat().st_size,
            }
        ],
        "scope": "engineering_smoke_test_not_model_evaluation",
    }
    manifest_path = run_directory / "run-manifest.json"
    write_json(manifest_path, manifest)

    return {
        "run_directory": str(run_directory.relative_to(root)),
        "result_path": str(result_path.relative_to(root)),
        "manifest_path": str(manifest_path.relative_to(root)),
        "deterministic_result_sha256": deterministic_result_sha256,
        "result": result,
        "manifest": manifest,
    }

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 7:建立 scripts/run_reproducibility_smoke.py

串接設定、合成案例與核心模組,提供讀者實際執行的命令入口。

請在文字編輯器建立 scripts/run_reproducibility_smoke.py,貼入以下完整內容並儲存:

#!/usr/bin/env python3
"""Run the Day 13 configuration-driven reproducibility smoke test."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from triage_rag.reproducibility import execute_smoke


PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = "configs/experiments/day-13-reproducibility-smoke.json"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="使用純合成資料驗證設定驅動、雜湊與 run manifest 流程。"
    )
    parser.add_argument(
        "--config",
        default=DEFAULT_CONFIG,
        help=f"相對於專案根目錄的 JSON 設定檔,預設為 {DEFAULT_CONFIG}",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    command = ["poetry", "run", "python", *sys.argv]
    report = execute_smoke(
        PROJECT_ROOT,
        args.config,
        command=command,
    )
    print("重現性冒煙測試:通過")
    print(f"執行目錄:{report['run_directory']}")
    print(f"結果檔:{report['result_path']}")
    print(f"產物清單:{report['manifest_path']}")
    print(f"穩定結果摘要:{report['deterministic_result_sha256']}")
    print(
        json.dumps(
            report["result"]["checks"],
            ensure_ascii=False,
            sort_keys=True,
        )
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

檔案 8:建立 tests/test_reproducibility.py

驗證鍵順序、路徑邊界與重跑摘要的一致性。

請在文字編輯器建立 tests/test_reproducibility.py,貼入以下完整內容並儲存:

from __future__ import annotations

import json
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path

from triage_rag.reproducibility import (
    canonical_json_bytes,
    execute_smoke,
    resolve_project_path,
)


class ReproducibilityTests(unittest.TestCase):
    def test_canonical_json_ignores_dictionary_key_order(self) -> None:
        first = {"b": 2, "a": {"d": 4, "c": 3}}
        second = {"a": {"c": 3, "d": 4}, "b": 2}
        self.assertEqual(canonical_json_bytes(first), canonical_json_bytes(second))

    def test_project_path_cannot_escape_repository(self) -> None:
        with tempfile.TemporaryDirectory() as directory:
            with self.assertRaises(ValueError):
                resolve_project_path(Path(directory), "../outside.json")

    def test_two_runs_keep_the_same_deterministic_result(self) -> None:
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            self._write_minimal_project(root)
            first = execute_smoke(
                root,
                "configs/smoke.json",
                now=datetime(2026, 8, 4, 1, 2, 3, tzinfo=timezone.utc),
                command=["test", "first"],
            )
            second = execute_smoke(
                root,
                "configs/smoke.json",
                now=datetime(2026, 8, 4, 1, 2, 4, tzinfo=timezone.utc),
                command=["test", "second"],
            )

            self.assertEqual(
                first["deterministic_result_sha256"],
                second["deterministic_result_sha256"],
            )
            self.assertNotEqual(
                first["manifest"]["started_at_utc"],
                second["manifest"]["started_at_utc"],
            )
            self.assertTrue(first["result"]["checks"]["all_rows_marked_synthetic"])

    @staticmethod
    def _write_minimal_project(root: Path) -> None:
        files = {
            "fixtures/cases.json": [
                {
                    "case_id": "SYN-A",
                    "chief_complaint": "synthetic case A",
                    "synthetic_only": True,
                },
                {
                    "case_id": "SYN-B",
                    "chief_complaint": "synthetic case B",
                    "synthetic_only": True,
                },
            ],
            "tracked/policy.json": {"version": 1},
            "pyproject.toml": "[project]\nname='test-project'\nversion='0.0.0'\n",
            "poetry.lock": "# synthetic lock fixture\n",
        }
        for relative_path, value in files.items():
            path = root / relative_path
            path.parent.mkdir(parents=True, exist_ok=True)
            if isinstance(value, str):
                path.write_text(value, encoding="utf-8")
            else:
                path.write_text(
                    json.dumps(value, ensure_ascii=False, indent=2) + "\n",
                    encoding="utf-8",
                )

        config = {
            "schema_version": 1,
            "experiment_id": "unit-test-smoke",
            "fixture_path": "fixtures/cases.json",
            "tracked_inputs": ["tracked/policy.json", "pyproject.toml", "poetry.lock"],
            "output_root": "results/runs",
            "random_seed": 7,
            "sample_size": 1,
            "expected_case_count": 2,
            "required_fields": ["case_id", "chief_complaint", "synthetic_only"],
            "forbidden_fields": ["KTAS_expert"],
            "record_packages": [],
        }
        path = root / "configs/smoke.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(
            json.dumps(config, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )


if __name__ == "__main__":
    unittest.main()

儲存後先確認檔名與相對路徑完全一致,再繼續建立下一個檔案。

完成後產生必要檔案並做靜態檢查

以下命令會在需要時產生套件鎖定檔,接著檢查設定格式與 Python 語法;它們不會啟動模型,也不會把病患資料送到網路:

poetry lock
poetry check --lock
poetry run python -m json.tool configs/experiments/day-13-reproducibility-smoke.json
poetry run python -m py_compile src/triage_rag/reproducibility.py scripts/run_reproducibility_smoke.py tests/test_reproducibility.py

每個命令都應正常結束。若 JSON 顯示行號,先檢查貼上時是否遺漏逗號、引號或括號;若 py_compile 報錯,先依行號修正縮排或漏貼內容。靜態檢查通過後,再執行本文後面的正式步驟。

步驟 2:指定專案內的虛擬環境位置

  • 目的:讓 Poetry 把這個 repo 的虛擬環境建立在根目錄的 .venv/,方便辨認目前使用哪個環境。
  • 前置條件:步驟 1 的版本檢查已通過。
  • 輸入:Poetry 的專案層級設定,不讀取任何 KTAS 資料。
  • 操作:執行下列命令。
poetry config virtualenvs.in-project true --local
  • 預期輸出:根目錄會出現本機設定檔 poetry.toml。這個檔案只控制本機環境位置,已列入 .gitignore,不會改變團隊共用的套件版本。
  • 驗證方式:執行 poetry config virtualenvs.in-project,應看到 true

步驟 3:依鎖定檔同步專案環境

  • 目的:建立獨立的 .venv/,並讓已安裝套件與 poetry.lock 一致。
  • 前置條件:根目錄已存在 pyproject.tomlpoetry.lock,電腦也已有 Python 3.12。
  • 輸入:Python 版本條件、專案封裝設定與鎖定後的套件清單。
  • 操作:先檢查鎖定檔,再同步環境。
poetry check --lock
poetry sync
poetry env info --path
  • 預期輸出poetry check --lock 會顯示檢查通過;第一次同步會建立 .venv/ 並安裝鎖定套件;最後一個命令會顯示目前環境路徑。
  • 驗證方式:確認環境路徑以本 repo 的 .venv 結尾,再執行 poetry run python --version,應看到 Python 3.12。

poetry sync 和單純安裝缺少套件不同:它會依鎖定檔同步環境,並移除鎖定檔中不存在的額外套件。這個做法解決的是 Python 與套件層級的環境差異,還沒有鎖住作業系統函式庫、繪圖驅動程式、遠端模型服務或硬體,因此不能把 poetry.lock 說成跨平台完全相同的保證。

用一份 JSON 設定檔控制這次執行

前面介紹的 JSON 是一種文字資料格式。本篇把冒煙測試參數放在 configs/experiments/day-13-reproducibility-smoke.jsonconfigs/ 是保存可審查設定的資料夾,experiments/ 子目錄則集中每次實驗或工程驗證的參數。

這份設定檔接收或指定以下資訊:

設定鍵 本次值 作用
experiment_id day-13-reproducibility-smoke 提供不隨執行時間改變的驗證名稱
fixture_path tests/fixtures/day-13-synthetic-cases.json 指向五筆純合成測試資料
tracked_inputs 四個專案檔案 要寫入 manifest 的資料政策、資料卡、環境設定與鎖定檔
output_root results/runs/day-13 每次執行建立獨立子目錄的位置
random_seed 20260804 固定三筆案例抽樣順序
expected_case_count 5 不是五筆就立即停止
required_fields 三個欄位 確認案例代號、主訴與合成標記存在
forbidden_fields 七個欄位 防止標籤或檢傷後欄位混入測試輸入

JSON 設定檔本身不執行任何動作。它只是輸入契約;真正的驗證邏輯位於 src/triage_rag/reproducibility.py,執行入口再把兩者接起來。

為什麼測試資料刻意不用真實 KTAS 紀錄?

測試夾具(Test Fixture)是為自動化測試預先準備的固定輸入。本篇的 tests/fixtures/day-13-synthetic-cases.json 只有五筆合成案例,每筆都有 SYN- 開頭的代號與 synthetic_only: true,也沒有護理師級數、專家標籤、診斷、去向或停留時間。

這五筆資料只用來回答工程問題:

  1. JSON 能不能成功讀取?
  2. 案例數是否等於設定值?
  3. 必要欄位是否存在?
  4. 禁止欄位是否沒有混入?
  5. 案例代號是否唯一,而且每筆都明確標示為合成?

它們不能用來計算 KTAS 準確率,也不能證明任何數值規則正確。把測試資料和正式評估資料分開,才能在公開 repo 中驗證程式,而不必暴露逐筆病患紀錄。

result.jsonrun-manifest.json 為什麼要分開?

執行產物清單(Run Manifest)是一份「這次執行用了什麼、產生什麼」的索引。本篇刻意把產物拆成兩份:

  • result.json 保存可以直接比較的穩定內容,例如案例數、欄位數、缺失數與五項檢查結果。
  • run-manifest.json 保存每次執行脈絡,例如協調世界時(Coordinated Universal Time, UTC)、Git 提交版本、Python 與 Pandas 版本、輸入 SHA-256、參數與輸出路徑。

下圖由左向右呈現追溯流程。左側四類輸入先進入同一個執行入口,右側才分成穩定結果與每次執行紀錄。

合成測試資料、資料政策、實驗設定與環境程式資訊進入設定驅動入口,分別產生穩定 result.json 與包含版本追蹤資訊的 run-manifest.json

上圖的分工可以避免一個常見誤解:重現不代表所有位元都必須相同。兩次執行的 UTC 時間與輸出目錄本來就不同,因此 manifest 會不同;只要輸入和設定不變,result.json 的穩定內容摘要就應相同。

SHA-256 只回答內容是否一致,不回答內容是否正確。錯誤程式也能穩定地產生同一個錯誤摘要,所以我們仍需要測試、人工審查與後續評估。

實際執行重現性冒煙測試

冒煙測試(Smoke Test)是用小型輸入快速確認主要流程能否從頭走完。它不像完整單元測試會逐一檢查每個分支,也不像模型實驗會量測分類表現;它的目標是提早發現缺檔、格式錯誤、路徑錯誤或產物未建立。

步驟 4:先執行自動化測試

本篇使用 Python 標準函式庫的 unittest 執行三項測試。測試檔位於 tests/test_reproducibility.py;它會驗證穩定 JSON 不受鍵順序影響、專案路徑不能逃離 repo,以及兩次執行能得到相同穩定結果摘要。

  • 目的:先確認底層雜湊、路徑與重跑邏輯,再執行完整冒煙流程。
  • 前置條件:已完成 poetry sync
  • 輸入src/triage_rag/reproducibility.py 與測試建立的暫時合成資料。
  • 操作:在 repo 根目錄執行下列命令。
poetry run python -m unittest discover -s tests -v
  • 預期輸出:三項測試都顯示 ok,最後出現 Ran 3 testsOK
  • 驗證方式:任何一項出現 FAILERROR 都代表基礎契約尚未通過,不應繼續跑模型。

本次實際結果如下:

test_canonical_json_ignores_dictionary_key_order ... ok
test_project_path_cannot_escape_repository ... ok
test_two_runs_keep_the_same_deterministic_result ... ok

Ran 3 tests
OK

步驟 5:執行第一次冒煙測試

scripts/ 是保存自動化執行入口的資料夾;.py 表示這是一支 Python 程式。scripts/run_reproducibility_smoke.py 會讀取 JSON 設定,呼叫 src/triage_rag/reproducibility.py 的驗證函式,最後在 results/runs/day-13/ 建立一個包含 UTC 時間與設定摘要的獨立目錄。

  • 目的:用一個命令走完設定讀取、合成資料驗證、雜湊計算、結果輸出與 manifest 建立。
  • 前置條件:自動化測試已全部通過。
  • 輸入:Day 13 設定檔、五筆合成資料、Day 11 欄位政策、Day 12 資料卡、pyproject.tomlpoetry.lock
  • 操作:執行下列命令。
poetry run python scripts/run_reproducibility_smoke.py
  • 預期輸出:終端機顯示「重現性冒煙測試:通過」,並列出執行目錄、result.jsonrun-manifest.json 與 64 字元穩定結果摘要。
  • 驗證方式:打開輸出目錄,確認兩個 JSON 檔都存在;再使用 python -m json.tool 檢查它們能被正確解析。

本次第一次執行得到的穩定結果摘要是:

799f3fb1517a2e21b4e1b456c5e4b1c5041cbd9f010a667c9309e97af8e4d19b

這個摘要不是資料集壓縮檔的 SHA-256,也不是模型識別碼。它只對本次 result.json 的穩定內容負責。

步驟 6:再執行一次相同命令

  • 目的:確認相同設定與輸入能產生相同穩定內容。
  • 前置條件:第一次執行成功,而且沒有修改設定或輸入。
  • 輸入與操作:和步驟 5 完全相同,再執行一次同一個命令。
  • 預期輸出:第二個執行目錄名稱不同,但穩定結果摘要仍是同一串 SHA-256。
  • 驗證方式:使用下面的 Python 片段讀取最近兩次 manifest,直接比較其中的穩定內容摘要。

以下程式碼要從 repo 根目錄執行。它只讀取 results/runs/day-13/ 最近兩個目錄,不修改任何檔案;若少於兩次執行,會直接停止並提醒先重跑。

poetry run python - <<'PY'
import json
from pathlib import Path

runs = sorted(path for path in Path("results/runs/day-13").iterdir() if path.is_dir())
if len(runs) < 2:
    raise SystemExit("至少需要兩次成功執行")

digests = []
for run in runs[-2:]:
    manifest = json.loads((run / "run-manifest.json").read_text(encoding="utf-8"))
    digest = manifest["outputs"][0]["deterministic_content_sha256"]
    digests.append(digest)
    print(run.name, digest)

print("摘要一致:", digests[0] == digests[1])
PY

成功時最後一行會顯示:

摘要一致: True

下圖整理本次兩次實際執行。兩個執行時間不同,設定、五筆合成案例與穩定結果摘要則相同;底部五項檢查都由程式實際計算。

兩次不同時間的執行都使用相同設定與五筆合成案例,匯入同一個穩定結果摘要,底部五項工程檢查全部通過

上圖使用的案例數、隨機種子與摘要來自本篇合成冒煙測試,不是 KTAS 模型實驗結果。這張圖證明目前的工程骨架能穩定重跑,不能用來宣稱檢傷分類正確或安全。

run-manifest.json 至少要留下什麼?

本篇產生的 run manifest 分成七個區塊。後續加入 RAG 與大型語言模型時,會在同一份結構中繼續加入模型、提示詞、知識庫與檢索器版本。

區塊 本篇記錄 後續用途
run_id UTC 時間與設定摘要前八碼 讓每次執行有獨立目錄,不覆寫舊結果
git Git commit 與已追蹤檔案是否修改 判斷執行是否對應到可取得的程式版本
runtime Python、作業系統與套件版本 找出環境差異
parameters 隨機種子與抽樣筆數 重建本次設定
inputs 設定、fixture、資料政策與鎖定檔的路徑、大小及 SHA-256 確認實際讀到哪個版本
outputs 結果路徑、檔案 SHA-256 與穩定內容摘要 核對結果是否被修改
scope engineering_smoke_test_not_model_evaluation 防止把工程測試誤寫成模型成績

Git 的 tracked_files_dirty 若為 true,表示已由 Git 追蹤的檔案在執行時仍有未提交修改。這不一定代表結果錯誤,但必須先提交或保存差異,否則其他人只取得 manifest 中的 commit,仍拿不到當時的完整程式狀態。

隨機種子能保證所有結果相同嗎?

不能。隨機種子(Random Seed)只是固定虛擬隨機程序的起始狀態。本篇用它穩定地從五個合成案例抽出三個案例代號,因此相同 Python 邏輯與輸入會得到相同順序。

後續模型實驗還可能受到以下因素影響:

  • 不同硬體或平行運算方式。
  • 深度學習函式庫中的非確定性運算。
  • 遠端模型服務更新。
  • 模型權重或量化版本不同。
  • 提示詞、溫度、前 k 筆候選與重排序設定不同。

所以 random seed 只是追溯鏈的一項,不是「設定了就一定完全重現」的開關。後續會另外記錄模型 digest、提示詞版本、解碼參數、知識庫版本與候選證據。

常見失敗與排查順序

poetry check --lock 失敗

代表 pyproject.tomlpoetry.lock 或 Poetry 專案設定有問題。先確認你是否剛修改 Python 或套件版本;若是有意變更,重新執行 poetry lock,再審查兩個檔案的 Git 差異。不要為了讓命令通過而直接刪除鎖定檔。

Poetry 找不到符合條件的 Python

本專案要求 Python 3.12。先執行 python3.12 --version 確認直譯器存在,再用 poetry env use python3.12 指定它,最後重新執行 poetry sync.python-version 只是記錄專案版本,不能取代 Python 安裝。

找不到 triage_rag

確認命令是從 repo 根目錄執行,而且已完成 poetry sync。本專案採用 src/ 佈局,核心套件要先安裝到 Poetry 管理的專案環境,避免 Python 意外載入根目錄中名稱相同的其他檔案。

顯示「合成案例數應為 5」

設定檔要求五筆案例,但 fixture 筆數已改變。先判斷是 fixture 被意外修改,還是你有意新增測試案例;如果是後者,必須同步修改 expected_case_count 並提交兩者差異。

顯示「合成資料出現禁止欄位」

代表 fixture 混入 KTAS_expert、診斷、去向或其他禁止欄位。不要把這項檢查關掉;應移除不該進入工程測試輸入的欄位。

第二次摘要不同

依序比較最近兩份 manifest 的設定檔 SHA-256、fixture SHA-256、poetry.lock SHA-256、Git commit、Python 版本與參數。先找出第一個不同的輸入,再檢查它是否是有意修改。

今天完成的仍不是模型重現

到這裡,我們完成的是「工程執行契約」:環境有鎖定檔、參數有設定檔、程式有模組、輸入有 SHA-256、執行有 manifest、核心邏輯有自動化測試。

還沒有完成的項目包括:

  • 真正 KTAS 模型資料的欄位型別與資料列契約。
  • 重複就醫或同一病患跨資料切分的風險處理。
  • 缺失值、類別不平衡與交叉驗證策略。
  • 公開 KTAS 知識庫、檢索器、模型與提示詞版本。
  • 模型效能、安全指標與外部驗證。

換句話說,可重現的錯誤仍然是錯誤。重現性讓錯誤更容易被找到、說明與修正,但不會自動把方法變成正確方法。


今天走到了哪裡?

Day 13 把 Day 12 的資料管線放進一個更完整的專案契約:

  1. pyproject.toml 說明 Python 與直接相依套件需求。
  2. poetry.lock 保存解析後的確切直接與間接相依版本。
  3. configs/ 保存可以比較與審查的執行參數。
  4. src/ 保存可重用的核心邏輯,scripts/ 只負責串接與啟動。
  5. tests/fixtures/ 使用純合成案例驗證程式,不暴露真實病歷。
  6. result.json 保存穩定內容,run-manifest.json 保存每次執行脈絡。
  7. 兩次實際冒煙測試得到相同的穩定結果摘要。

這些產物會成為後續每一次資料切分、知識庫建立、檢索與模型實驗的共同底座。

下一篇預告

Day 14 會開始定義「一筆病患資料到底長什麼樣子」。我們會把檢傷當下可以取得的欄位、人工參考級數、主要評估標籤與檢傷後結果寫成明確資料契約,並用時間順序檢查資料洩漏。

Day 13 建立的 configs/src/tests/ 與 run manifest,會讓 Day 14 的欄位檢查不只是一張表,而是一組可以執行、測試與追溯的規則。


參考資料


上一篇
Day 12|從 Kaggle 下載到可重跑:建立 KTAS 資料管線
下一篇
Day 14|一筆病患資料怎麼進系統?從研究群體、資料結構到資料洩漏
系列文
30 天打造公開資料版急診檢傷系統:Side Project 與實驗計畫15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言