前 18 天把穿戴資料一路處理到每日指標、personal baseline 和跨指標標記,接下來要讓規則引擎、LLM 與 API 使用這些結果。若只交一個「HRV 42 ms」,接收端不知道它是哪種 HRV、取自哪段時間、有幾天有效資料,也不知道那天是否有品質疑點。Day 12 已經看到同樣叫「7 日平均」,有效天數可能從 1 天到 7 天;Day 17 也碰過資料不足時根本無法判定偏離。數字本身裝不下這些差別。
今天是設計日,不引入新資料。我要用前面模組的輸出,約定下游會收到哪些結構化特徵、統計區間、品質標記與限制;預定產出是 Pydantic model 和範例 payload。LifeSnaps 可拿來檢查真實匯出資料裡的缺值與可疑值,Day 14 的合成情境則能提供已知設定的邊界案例。欄位取捨與版本規則還需要在設計時定下來;這裡先釐清一份 feature contract 必須回答哪些問題。
Pydantic schema 可以檢查欄位是否存在、數值型別與允許的狀態;但一個通過驗證的 42,仍可能被當成不同的生理量。Day 2 區分了量測、計算與推估,並提出 physical_measurement、reference、level 作為候選欄位;要求參考標準為空或尚未查證的輸出(例如壓力、疲勞這類健康狀態推估)只能作「輔助觀察」。欄名也不能當作資料層級的證據:PPG-DaLiA 的 BVP 在拿到手前已經過廠商未公開的演算法處理,「這個欄位被誰動過」要另記。這些限制需要隨資料往下傳,不能期待 LLM 從欄名猜出來。
欄位定義至少得說清楚 metric 是 RMSSD、SDNN 還是其他量,unit 是什麼,window 涵蓋哪段時間,aggregation 如何把多筆值合成一筆。同是 HRV,換了指標、時窗或聚合方法就未必能並排比較。日期也要寫歸屬規則,而且規則本身的依據不同:LifeSnaps 的睡眠用 dateOfSleep(睡眠結束日),是讀作者的前處理 notebook 得知;夜間 HRV 歸到醒來日,是由同日有睡眠紀錄的比例 99.5% 推論,不能寫成來源已明示的定義;RHR 對應哪段時間則沒有公開說明,Day 18 換一種日期歸屬,各類總數幾乎不變,每種偏移卻各有 20 天換類。步數按量測當地日。所以日期欄除了規則,還要交代依據是明示、推論還是未知。Day 8 的逐窗心率還提醒我,窗長、位移、峰數與閘門等計算口徑也會影響數字,時間戳掛在窗的起點、中點還是終點也是口徑:跨來源對齊時平移一窗,MAE 就從 0.33 跳到 1.6–1.7 bpm。
品質不能壓成一個 clean。Day 7 的加速度門檻只標得出動作線索,不能保證 PPG 準確;Day 11 的品質三態會附多個原因碼。「給不給得出數字」和「數字準不準」也是兩件事:Day 8 的 cycling 每一窗都給得出心率,MAE 卻有 17.30 bpm。剔除本身還會造成偏差:Day 7 門檻 0.35 留下 60.5% 的窗,平均心率被拉低 4.68 bpm;Day 11 的 S1 只取可用窗,ECG 平均心率比全部窗低 10–11 bpm。所以聚合值除了有效筆數,還要帶 coverage 與被排除時段的分布。
到了日層級,Day 12 要求聚合值附有效天數,Day 13 又區分逐指標的 missing_kind、日層級疑似填值、三態 wear_evidence 與比例的分母。unknown 是合法狀態:LifeSnaps 沒有同步或抵達時間,不能從空白反推同步失敗。不過現有的 missing_kind 值域同時放了缺值層(no_row、fill_value)和原因(sync_failure),contract 要決定沿用還是拆開。
同樣地,baseline 要附狀態、有效觀測數、跨越的日曆天數與判準版本;「穩定」也只是規則下的標籤,Day 15 的 p02 判定穩定時,SD 只有設定值的 0.73 倍。偏離結果要容納「無法判定」,並交代事件日是否進入 baseline;門檻也不能單獨傳遞,Day 17 同樣用 3,兩種 z 方法的誤報率約差 2 倍,方法與量到的誤報率要一起走。Day 18 的跨指標分類也需保留個別指標的方向、無法判定原因、rule_version,以及零值來源不明、ID 可能不獨立等資料疑點。這些是前幾天留下的契約需求,不代表今天已經驗證所有欄位都能從每種來源取得。
同一欄位若改了單位、日期歸屬、聚合方式或判定規則,舊 payload 的解讀就可能失效。Contract 因此需要明確的版本與欄位語意;規則本身另有版本,才能追溯某個標記由哪套判準產生。現有程式的版本欄各自命名:偏離偵測的版本在自己的輸出叫 rule_version,到了跨指標的 wide 表改叫 deviation_version;contract 要先統一,下游才分得出是哪一層的版本。Pydantic model 負責把約定寫成可驗證的輸入邊界,範例 payload 則要展示有值、缺值、品質不足與無法判定時各自長什麼樣。若下游只讀範例中的數值卻忽略有效天數或原因碼,schema 即使通過,仍沒有達到這份 contract 的目的。
候選欄位多於一天能實作的範圍,必備與延後的項目在欄位設計時決定並記錄。Concept 只界定問題,不宣稱 model、payload 或下游行為已驗證。
沒有新資料:LifeSnaps 日粒度 CSV 與小時表(Day 12–18 同一份,兩輪期間),以及 Day 14 的七個合成情境(固定 seed)。指標沿用 Day 18 的minutesAsleep(分鐘)、rmssd(毫秒)、resting_hr(bpm)。
範圍:第一版只放日層級欄位,PPG 窗層級延後。一份 payload 是一個人、一個解讀日(Day 18 的 (id, 輪, date)),結構如下:
FeaturePayload(feature-contract-v2)
├── source / date / versions(baseline、deviation、cross_metric 三層規則版本)
├── day:row_present、fill_value、wear_evidence、nonwear_candidate
├── metrics[](每個指標一筆)
│ ├── definition:metric、unit、window、aggregation、level、reference、auxiliary
│ ├── date_attribution:rule + basis(stated/inferred/unknown)
│ ├── value + missing_kind
│ ├── baseline:status、center、scale、n_obs、calendar_days、視窗設定、凍結
│ ├── deviation:arm、method、threshold、decidable、reason、flag、side、false_alarm
│ └── trailing[]:3/7 日完整視窗的 n_valid 與 mean
├── cross_metric:五類標記、pattern、無法判定的指標與原因
└── data_issues[]:export_zero、possible_duplicate_id
所有欄位必填、沒有預設值;None 各有寫明的意思(未量、沒有資料、來源沒寫),省略欄位就驗證失敗。組裝不重新實作判定:偏離與跨指標分類取自 Day 17–18 的 run_metrics、label_days,日層級旗標取自 Day 13 的 missingness_flags;baseline 另用 Day 16 的 build_baselines 算一遍核對,不一致就丟錯;3/7 日平均由 Day 12 的 trailing_window 計算。設定同 Day 18 主結果(z_mean_sd、門檻 3.0、兩側、A 組)。驗證分兩層:每個解讀日都組一份 payload 看能不能通過,再挑 9 個邊界案例寫成範例 JSON。
跨欄位規則寫在 model_validator 裡,例如偏離結果與完整視窗(src/wearable_ai/ai/contract.py,節錄):
class DeviationResult(_Strict):
@model_validator(mode="after")
def _consistent(self):
if self.decidable != (self.reason == "ok"):
raise ValueError("decidable 為 True 若且唯若 reason 為 ok")
if self.flag and not self.decidable:
raise ValueError("無法判定的日子不能標旗")
...
class TrailingWindow(_Strict):
@model_validator(mode="after")
def _consistent(self):
if self.min_valid != self.window_days:
raise ValueError("min_valid 必須等於 window_days(完整視窗)")
if (self.mean is None) != (self.n_valid < self.min_valid):
raise ValueError("mean 為 None 若且唯若 n_valid < min_valid")
...
_Strict 設定 extra="forbid"、strict=True、allow_inf_nan=False:多餘欄位、"18" 這類字串數字與 NaN 都會被拒絕。
| 原本以為 | 實際發現 |
|---|---|
| 我原本以為,真實與合成資料都通過驗證,就表示下游拿到的數值已有足夠證據,可以直接解讀。 | LifeSnaps 3,527 個解讀日與七個合成情境 426 個解讀日,全部組得出 payload 並通過驗證,失敗 0;LifeSnaps 的五類日數(無法判定 2,910、無偏離 572、部分偏離 45,一致與矛盾 0)與 Day 18 相同。但通過只表示欄位彼此一致:三個 Fitbit 指標在 3,527 天裡 auxiliary 全是 True、false_alarm 全是未量,日期依據各自只有一個值(stated、inferred、unknown)。 |
| 我原本以為,欄位名稱和結構不變,只是把既定的完整視窗規則寫進驗證,不必升 contract 版本。 | v1 定案時,「平均只採完整視窗」只由產生器遵守,contract 仍接受 min_valid = 1 的 7 日平均。補上驗證後,可接受的輸入變少,升為 v2;當時還沒有任何 v1 payload 存檔。 |
我原本以為,沿用 Day 13 的缺值分類後,範例與全量 payload 會看得到 no_row,疑似填值日也會有不少。 |
解讀日的定義是「至少一個指標有值」,所以 no_row 在 3,953 個解讀日上恆為 0;LifeSnaps 只有 1 個解讀日 fill_value 為 True(621e3753 第 2 輪 2021-12-13,同日 wear_evidence 也是 True)。 |
| 我原本以為,匯出 JSON Schema 後,下游只靠這個檔案就能擋下 contract 所有不一致的狀態。 | 匯出的 feature_contract.schema.json 有 13 個 $defs,但 model_validator 的跨欄位規則不在裡面;只拿 schema 檔驗證的下游,會放行「無法判定卻標旗」這類輸入。 |
| 我原本以為,合成情境既然知道事件從哪天開始,事件第一天的 RHR 與 RMSSD 就會被標旗,payload 也能直接說明偵測到了事件。 | syn_event_start(suspected_illness 2026-02-09,事件第一天,注入 RHR +5、rmssd −12):RHR z = 2.40、rmssd z = −1.35,都沒有標旗,分類是無偏離。payload 帶得出 Day 17 量到的誤報率(rmssd 每 100 個非事件日 1.137 次),沒有帶 recall。 |
這份 v2 contract 只接上 Day 12–18 已有的日層級輸出,沒有涵蓋 PPG 窗的 motion_flag、HR 計算口徑、品質原因碼、時間戳、coverage、排除時段分布與資料 lineage。全量 3,953 個解讀日通過 Pydantic 驗證,證明組出的欄位符合目前規則,尚未檢驗 LLM、規則引擎或 API 會怎麼讀它;匯出的 JSON Schema 也不含 model_validator 的跨欄位限制。
資料面的不確定性仍留在 payload 裡:LifeSnaps 三項指標的 aggregation 都沒有已查證的來源定義,reference_checked=False 表示本專案尚未查過對照參考標準的驗證結果,不能當成已證實準確或已證實不準;其誤報率也未量。合成資料雖有情境真值,卻不是裝置或族群的實測驗證;本次 payload 採最終抵達的資料作回顧摘要,沒有表示解讀當下可見的快照,且缺少判定填值與佩戴的欄位,所以三個日旗標為 None。帶出的誤報率只對 Day 17 合成無事件情境與指定設定成立,不能延伸為 LifeSnaps 或其他設定的表現。
contract 是共同介面,不是 LLM 的 prompt。 同一份 payload 要能被規則引擎、LLM 與 API 讀,所以分類、標旗與無法判定的原因都由 deterministic code 產生並寫成欄位,下游只引用、不重算。Day 20 起把它轉成 LLM 的輸入時,要問的是「哪些欄位該進 context」,而不是讓模型從原始日表自己推。
None 要有寫明的意思。 false_alarm = None 是「沒量過」,不是 0;wear_evidence = None 是「沒有資料可判斷」,不是 False;aggregation = None 是「來源沒寫」。欄位必填,下游才分得出「沒有」與「沒做」。整欄都是同一個值也有資訊:LifeSnaps 每一天都是輔助觀察、每一天都沒有誤報率,模型能說多肯定,該由這些欄位限制。
驗證規則要和 contract 一起走。 JSON Schema 帶不走跨欄位規則,生產端與消費端若各自實作,就可能一邊擋、一邊放。收緊可接受的輸入就升版本,下游拿到 feature-contract-v1 會直接被拒,而不是默默照舊規則讀。