iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
AI Engineering

30 天拆解 Wearable × AI:從穿戴裝置生理訊號到AI健康洞察系列 第 23 篇

Day 23|中場整合:把 Phase 1 到 2 串成一支 CLI

  • 分享至 

  • xImage
  •  

今天為什麼研究這個?

前 22 天已分別做過 PPG 視窗化心率與品質判定、日資料對齊與缺值標記、personal baseline、deviation detection,以及交給 LLM 前的 feature contract。Day 19 的範例 payload 能組出一個解讀日的資料,Day 21–22 也測過模型如何使用給定的摘要;但各段能單獨運作,不代表從資料入口到證據輸出的介面已經接得起來。若整合時丟掉品質原因、有效觀測數或「無法判定」狀態,下游仍可能收到格式正確、意思卻變了的數字。

今天先在 LLM 之前做一次整合實驗:以 wearable-ai run 作為入口,讓適用的訊號處理、品質檢查、對齊、baseline、deviation 與 feature contract 走到可檢查的 evidence bundle。PPG-DaLiA、LifeSnaps 和合成情境各自提供不同粒度與可驗證的資訊;我會用它們檢查資料分支、補上必要測試,並記錄真正串接後才看得見的介面不一致。這些是預定產出,實際能跑通哪些路徑、在哪一層失敗,留到 Hands-on 驗證。

Concept

1. 同一個入口,要尊重不同資料粒度

PPG-DaLiA 的腕式 PPG 可走 Day 8 的逐窗心率與 Day 11 的品質三態;品質判定附原因碼,不能把「有心率」直接當成「可用」。LifeSnaps 則已是日層級匯出,走 Day 12–13 的日期對齊與缺值標記,再進入 Day 16–18 的 baseline、deviation 與跨指標分類。合成情境可提供已知注入事件,測試這些規則在指定條件下的行為。三者可以共用 CLI 入口與輸出檢查,但資料本身沒有相同的起點:現行的 feature-contract-v3(Day 19 定義、Day 20 升版)只涵蓋日層級,尚未把 PPG 視窗品質接成同一份日 payload。整合時要把這個邊界明示出來,不能靠補一個欄位就假裝已從 PPG 推出夜間 HRV 或完整的每日洞察。

2. 每次轉換都要交代留下與丟掉了什麼

Day 12 的同一個解讀日可能放進不同量測時窗;Day 13 的空值也可能只是來源沒有交代原因,不能一律推成未佩戴。到了 baseline,Day 16 分開記錄視窗、有效觀測數、狀態與凍結;Day 17 的 decidable=false 表示條件不足,不能在下一層翻成「沒有偏離」。Day 18 的跨指標標記還要保留各指標的方向和無法判定原因。這些不是附註,而是 evidence bundle 解讀數值所需的條件。

我會把每個資料分支的輸入身分、日期歸屬、單位、品質或缺值狀態、規則版本與輸出欄位逐層核對。特別要查 Day 19 已指出的命名落差:偏離模組輸出的 rule_version 到跨指標 wide 表叫 deviation_version;Day 19 已在 payload 層把兩者對應到 contract 的 RuleVersions.deviation;若 CLI 繞過 payload_for、自行按同名欄位拼接,仍會遺失版本,其他層有沒有類似的命名落差也要逐一確認。schema 驗證能擋掉部分缺欄與矛盾狀態,仍不能證明日期、單位或來源的語意正確,這些要靠對照原始資料與中間輸出檢查。

3. 整合測試要能指出失敗發生在哪一層

一個指令跑到底的驗收,不只是成功印出 JSON。至少要能區分資料讀取失敗、沒有足夠品質或有效觀測、baseline 尚未可用、偏離無法判定,以及 payload 不符合 contract;各種情況都應留下可追查的原因,而不是讓後一層自行猜測。對已有日層級資料的路徑,可用 Day 19 的 payload 規則檢查數值與狀態是否一致;對 PPG 路徑,先檢查視窗心率及品質輸出,日層級橋接若仍缺定義就明確停在該層。這次要測的是既有模組能否在相同資料口徑下接線,不能把合成情境的事件標籤或 PPG-DaLiA 的 ECG 參考偷放進正式 evidence bundle;它們只適合用來評估輸出。

Hands-on

Dataset

  • 合成情境 suspected_illness(Day 14,seed 1403):一人 60 天,起日 2026-01-05。第 35–39 天(2026-02-09~02-13)注入 RHR +5 bpm、RMSSD −12 ms、步數 −4000,之後 3 天恢復,事件期沒戴的機率放大 3 倍。睡眠沒有注入。步數不在 contract 的三個指標裡。
  • LifeSnaps:不另挑新的人與日期,只用來重產 Day 19 的 6 份範例 payload,當回歸檢查。
  • PPG-DaLiA:本次選 S2。S1 參與過 Day 11 的 W 門檻校準,結果是 in-sample,manifest 會標出來;今天也跑了 S1,只拿來和 Day 11 對照。ECG、資料集的 HR label、R-peak、活動標籤都不進 bundle。

Method

  1. 一個入口、三個分支。 wearable-ai run synthetic、run lifesnaps 走日層級:對齊 → baseline → deviation → 跨指標標記 → payload_for → summary-v1。run ppg 只到窗層級:逐窗心率、品質三態與原因碼、動作旗標。PPG 不進 payload:每位受試者只有一段約 2.5 小時的白天紀錄,進不了跨日 baseline,contract 也還沒有窗層級欄位。
  2. Bundle 分成「送模型的」與「追查用的」。 日層級一個人、一個解讀日一份:summary.json 是唯一送模型的檔案,寫出前做洩漏檢查(情境名稱、情境路徑、subject_id);payload.json 是 contract v3 的完整 payload;trace/ 是這個人到解讀日為止的中間表(baseline/deviation 逐日、跨指標標記、日層級缺值旗標);manifest.json 記輸入、設定、各層版本、各階段狀態與逐指標狀態。
  3. CLI 只接線,不重算。 每個判定都呼叫既有模組;baseline/deviation 設定,PPG 沿用 Day 8 的 8 s/2 s 切窗與 Day 11 的門檻。這些常數 CLI 另存一份,測試比對它們和已發布腳本相同。
  4. 失敗要分得出停在哪一層。 每個階段記 ok 或 failed,失敗依類型給結束碼:資料不在本機 2、參數不對 3、contract 不符 4、洩漏 5、其他例外 1。輸出先寫進暫存目錄,全部寫完才換上,寫到一半失敗不會留下新舊混雜的檔案;失敗時盡量留下這次的 manifest,目錄裡的舊檔標成不是這次的輸出。「沒有觀測」「baseline 尚未可用」「偏離無法判定」不是失敗,照常產出,狀態記在 metric_states。
  5. 怎麼驗收。 日層級:CLI 重產 Day 19 的 9 份範例 payload,逐欄比對;再用合成情境逐日看事件期。PPG:窗數、三態與原因碼和 Day 11 對照。範圍(只送 summary、PPG 只到窗層級)由人決定。

Code

uv run wearable-ai run synthetic --scenario suspected_illness --person p01 --date 2026-02-09 --out runs/syn_0209
uv run wearable-ai run lifesnaps --id 621e375b --round 1 --date 2021-07-09 --out runs/ls
uv run wearable-ai run ppg --subject 2 --out runs/ppg_s2

日層級 bundle:

runs/syn_0209/
├── manifest.json      # 輸入、設定、版本、階段、metric_states;files 標出哪個檔 to_model
├── summary.json       # summary-v1,唯一送模型
├── payload.json       # feature-contract-v3
└── trace/
    ├── long.csv       # baseline/deviation 逐日
    ├── labels.csv     # 跨指標標記
    └── day_flags.csv  # 列存在、缺值種類

PPG 分支的兩個模組各自找峰,CLI 在合併前檢查兩邊的視窗對得上(src/wearable_ai/run.py):

table = q.windowed_quality(ppg, fs_ppg_hz, PPG_WINDOW_S, PPG_HOP_S)
_, bpm, n_peaks, _ = hr.windowed_hr(ppg, fs_ppg_hz, PPG_WINDOW_S, PPG_HOP_S,
                                    method=PPG_HR_METHOD, min_peaks=PPG_MIN_PEAKS)
if len(n_peaks) != len(table) or not np.array_equal(table["n_peaks"].to_numpy(), n_peaks):
    raise ValueError("windowed_quality 與 windowed_hr 的視窗數或峰數對不上")
out = q.classify_table(table, PPG_THRESHOLDS)  # QualityThresholds(w_low=0.87, w_high=0.96)

結果與意外

先看整體:tests/test_day23.py 54 個測試通過,全部測試 1660 passed、3 skipped(含審稿後補的回歸測試)。Day 19 的 9 份範例 payload(LifeSnaps 6、合成 3)由 CLI 重產,除了 contract 版本字串,逐欄相同。PPG S1、S2 都跑完,兩個模組的峰數全部對得上,品質判可用的窗都算得出心率。

原本以為 實際發現
我原本以為 Day 18 的 baseline/deviation 設定已經是 src/ 可直接引用的共用設定,CLI 只需接上既有模組。 Day 18 定案的 baseline/deviation 設定(Day 18)只寫在 days/day19/payload_demo.py;Day 20 是用 importlib 從 days 腳本載入。src/ 裡沒有這組設定。CLI 只好在 src/wearable_ai/run.py 另寫一份,再用測試檢查兩份相同。
我原本以為呼叫 QualityThresholds() 就會沿用 Day 11 凍結的 W 門檻,換成 CLI 入口也會得到相同的品質規則。 Day 11 凍結的 W 門檻 0.87/0.96 只存在 days/day11/signal_quality.py;QualityThresholds() 的預設是 w_low=None, w_high=None,W 規則不啟用,也不會報錯。S2 若用預設值,可用窗從 516 變成 685,保留 622 → 925,拒答 2,961 → 2,489。
我原本以為品質判定與心率計算處理同一批視窗時,峰數自然會一致,不需要在接線處再核對。 windowed_quality 和 windowed_hr 各自找峰,沒有共用結果;兩邊一致要靠呼叫端檢查。Day 11 用 assert,CLI 改成丟錯、結束碼 4。這次 S1、S2 都一致,usable_without_hr 為 0。
我原本以為沿用 Day 11 的切窗參數,S1、S2 的窗數就能與當天結果直接對上。 S2 用 CLI 是 4,099 窗,Day 11 是 4,039 窗;S1 是 4,603 對 4,551。Day 11 用活動標籤丟掉跨活動交界的窗,CLI 不讀標註,所以不丟。口徑不同,兩邊三態窗數不能逐格比較。
我原本以為舊流程的 motion_flag=False 都表示加速度足以判斷為沒有動作,可以直接沿用到 bundle。 Day 11 加速度湊不滿的窗,motion_flag 用 np.zeros 填成 False,等於把「沒資料」寫成「沒動」。CLI 改填 <NA>,另計 motion_unknown。S1、S2 實際上都是 0,這次的數字沒有受影響。
我原本以為 rule_version 與 deviation_version 的命名落差會在 CLI 拼接 payload 時讓版本遺失。 CLI 經過 payload_for,manifest 的 versions.deviation 是 deviation-v2,沒有遺失。但 trace 中間表仍保留兩種命名:long.csv 叫 rule_version,labels.csv 叫 deviation_version。
我原本以為事件首日注入 RHR 上升、RMSSD 下降後,至少一項會跨過既定的 |z| > 3 門檻而標旗。 p01 2026-02-09 三項都可判定、baseline stable、未凍結,都沒有超過 |z| > 3:RHR 65.17 bpm(center 60.66、scale 1.88,z +2.396)、RMSSD 36.92 ms(46.20/6.89,z −1.347)、睡眠 400.04 分(423.52/35.42,z −0.663)。見下表。
我原本以為首日若沒過門檻,連看五天事件期仍會出現至少一次標旗,而且每天都能產生解讀日。 事件期 5 天中,02-13 三個指標都沒有列,不是解讀日;用它跑 CLI 停在 target_day,結束碼 3。其餘 4 天的 12 個指標日全部可判定、沒有標旗,事件期內 |z| 最大是 02-09 RHR +2.396 與 02-12 RMSSD −2.079。完整 60 天(55 個解讀日)標旗數為 0:以標旗為偵測依據,這個事件沒有被標出。

事件期與前後逐日(z 值;所有列 baseline stable、未凍結、未標旗):

日期 情境日 期間 睡眠 z RMSSD z RHR z 跨指標
02-08 34 事件前 +0.625 —(缺值) −0.514 undecidable
02-09 35 事件 −0.663 −1.347 +2.396 none
02-10 36 事件 −1.784 −0.219 +0.346 none
02-11 37 事件 −0.668 −0.435 +1.026 none
02-12 38 事件 −0.970 −2.079 +1.805 none
02-13 39 事件 — — — 不是解讀日
02-14 40 恢復 — — — 不是解讀日
02-15 41 恢復 +0.883 −2.676 +1.437 none
02-16 42 恢復 −0.260 −1.666 +0.322 none

Limitations

這次驗證的是既有模組能否接線並保留可追查的狀態,還不能把三個資料分支視為同一條端到端路徑。PPG 只產出窗層級心率與品質結果,沒有接到日層級 baseline、deviation 或 feature contract;LifeSnaps 只重產 Day 19 已有的六份範例 payload,逐欄相同能檢查回歸,不能證明新日期或新人的輸出正確。CLI 的 baseline/deviation 設定和 PPG 門檻仍與已發布腳本各存一份,測試比對可發現數值漂移,卻不能消除兩份設定需同步維護的限制。W 門檻有無啟用的數量對照只在 S2 跑過;S1 參與門檻校準,不能拿它當獨立驗證。

合成事件的「未標旗」只是在 suspected_illness、p01、seed 1403 與 D 既定規則下的觀察:60 天中只有 55 個解讀日,事件期五天有一天三項都沒有列,其餘四天雖可判定,分數都未超過門檻。因此結果說明這組注入與觀測條件沒有觸發現行標旗規則,不能推成規則對其他人、事件強度或缺值型態的偵測率。日層級 bundle 使用最終抵達的資料產生回顧摘要,並非解讀日當時可見資料的 as-of 快照;這次逐日重跑與最後一日 trace 對上,也不能據此宣稱即時情境會得到相同輸出。合成情境的事件標籤是評估用的已知注入,並非醫療診斷真值。

這對 AI Engineering 的意義

最危險的介面不一致不會報錯。 今天找到的問題裡,只有峰數對不上會丟例外;W 門檻用預設值、motion_flag 把沒資料填成 False、設定只存在 days 腳本,三者都會讓程式安靜地跑完,產出格式正確的表。S2 若用預設門檻,可用窗多出 169 個;下游拿到的「可用」換了定義,卻沒有任何欄位標出這件事。所以 manifest 要記下實際用的門檻與設定,測試要釘住數值,不只檢查型別。

模型看到的是上游規則的結論。 suspected_illness 的事件從頭到尾沒有被標旗,事件期的 02-13 連解讀日都不是。事件期 4 個解讀日,只拿 summary.json 的模型看到的是「三項可判定、沒有偏離」;這句話對規則來說是對的,Day 21–22 檢查的也只是模型有沒有忠於摘要。summary 已有當日的 center、scale、z 與門檻,看得出當天離門檻多遠;要看 baseline 逐日怎麼演變、整段期間有沒有任何一天標旗,才需要 trace。把送模型的檔案和追查用的中間表分開,不是為了少給模型資料,而是讓「模型說錯」和「上游沒偵測到」分得開。

失敗要有類型,非失敗的狀態也要。 結束碼分出資料不在、參數不對、contract 不符、洩漏四類,其餘例外另歸一類;輸出位置可寫時,失敗也會留下這次的 manifest,寫不了就明說紀錄沒有更新。最容易出錯的反而是邊角:初版的參數錯誤和「資料不在」共用結束碼 2,--force 失敗時目錄裡還躺著上一次成功的 manifest,呼叫端會把舊結果當成這次的;寫到一半失敗時,新 manifest 已宣告成功,summary 卻還是上一次的。「baseline 尚未可用」「偏離無法判定」則不是失敗,bundle 照產,狀態寫進 metric_states。這是 Day 24 grounding 要引用的證據:每段解讀回指的不只是數值,還有這個數值當時能不能判定、為什麼。


上一篇
Day 22|Structured Output:讓洞察可被程式驗證
下一篇
Day 24|Grounding:每一個洞察都要帶著證據
系列文
30 天拆解 Wearable × AI:從穿戴裝置生理訊號到AI健康洞察 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言