iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

今天為什麼研究這個?

Day 3 我們實作了 find_pulse_peakspeak_intervals_ms,成功在一段短波形上標出主峰、算出間隔。看著時域圖上的紅點漂亮地落在波峰上,很容易產生一種錯覺:從原始光訊號到心率的演算法,最核心的部分不是已經完成了嗎?

但手錶螢幕上每隔幾秒跳一次的「72 bpm」,真的是直接拿相鄰兩個峰的時間差換算出來的嗎?

既然在 Day 3 已經會用帶通濾波和 scipy.signal.find_peaks 抓到主峰,那萃取心率不過是小學算術:相鄰兩峰的時間差 $\Delta t$ 換算成每分鐘跳動次數($60 / \Delta t$),或者把整段訊號數到的峰數除以總秒數再乘以 60。既然核心的波形找峰都搞定了,今天應該只是把那一兩行公式包裝成函式,給它一整段連續資料,它就應該穩定吐出整天的連續心率曲線。

但實際真的是這樣嗎?

Concept

找峰只是中間一步。從一段波形到一條能交給後面 pipeline 的心率曲線,前面要決定怎麼切窗,後面要決定哪些峰算數、怎麼聚合、算出來的數字貼在哪個時間點。而且要有辦法知道它準不準。

1. 逐拍心率沒辦法直接用

拿相鄰兩峰的時間差換算,得到的是逐拍心率(instantaneous heart rate)。問題是它每一拍都在動。

人安靜坐著,心跳間期也不一樣長:吸氣時迷走神經(vagus nerve)被抑制、心跳變快,吐氣時恢復、心跳變慢,這叫呼吸性竇性心律不整(respiratory sinus arrhythmia, RSA)。Day 4 的 RMSSD 對這類短期變異最敏感,nsr001 全段 22.55 小時是 34.33 ms。但 RMSSD 不等於 RSA,那 22 小時裡還混了姿勢、活動與睡眠的變化。

所以手錶不顯示逐拍心率,會先切窗聚合。切窗要決定三件事:

  • 視窗長度。 夠長才涵蓋足夠拍數,太長就跟不上變化。PPG-DaLiA 用 8 秒,Day 7 也是。
  • 步長。 決定多久更新一次。Day 7 用不重疊的 8 秒,PPG-DaLiA 的 label 每 2 秒滑一次。重疊讓曲線平滑,代價是相鄰的值大半資料重複,不能當獨立樣本。
  • 時間戳。 一個窗的心率,貼在窗的起點、中點還是終點?視窗一重疊,這個選擇會讓整條曲線平移。

2. Day 3 的門檻在長紀錄上會壞

find_pulse_peaks 的 prominence 取整段波形峰谷差的 15%。切片上很好用,整段用就不行:PPG 的振幅本來就會變,只要中間出現一次大突波,門檻被它拉高,後面正常的脈搏全部低於門檻。

Day 6 量過:整段一起偵測,S1 找到 441 個峰、ECG 有 597 個;S2 只找到 51 個、ECG 有 630 個,漏掉 92%。所以那天之後改成分窗偵測,門檻在每個窗裡各自算。今天接的是下一題:分窗之後,怎麼知道某一窗的心率能不能用。

3. 峰會多抓,也會漏抓

峰偵測有兩種錯,方向相反:多抓是把重搏波(dicrotic wave)或雜訊當成主峰,產生太短的間期;漏抓是某一拍振幅太低沒抓到,產生接近兩倍長的間期。窗內間期直接取平均,混進一個就歪掉。

至少要兩道處理:

  1. 合理範圍過濾。 超出生理可能的間期不納入計算。現有的 find_pulse_peaks 沒做這件事——max_bpm 只轉成相鄰峰的最小間距,docstring 明寫「沒有對應的最大間距限制」。這道閘門今天要自己加。
  2. 這一窗算不算數。 有效峰太少就整窗丟掉。Day 7 用至少 4 個峰(MIN_PEAKS = 4)。

聚合方式也是選擇:平均間期、間期中位數、或峰數除以窗長。Day 7 固定用平均間期,今天要換,兩天的數字就不能並排。

4. 另一條路:不找峰

頻域法直接對窗內波形做頻譜,抓主頻乘 60 就是心率,不必辨識每一個峰,對波形畸變比較耐受。代價有兩個:解析度綁在視窗長度上(8 秒窗是 0.125 Hz,等於 7.5 bpm),而且逐拍的時間資訊全丟了,HRV 沒得算。運動時它還容易被步頻帶走,就是 Day 7 的 signal crossover。

知道有這條路,是因為它指出一個設計方向:估計方法可以依訊號狀況挑。至於哪家手錶實際上怎麼做,沒有公開資料可查,這裡不猜。

5. 怎麼知道準不準

Day 7 已經拿 PPG 心率跟同窗 ECG 比過,但那天誤差是配角。今天它是主角,而且多一條線可以比:PPG-DaLiA 自己附了一條心率 label(8 秒窗、2 秒位移,S1 有 4,603 筆)。

只是要先問它是誰算的。Reiss 2019 寫明 label 由胸帶 ECG 的 R-peak 算成逐拍心率、再切窗平均,不是另一套 PPG 演算法。它和我自己重算的 ECG 心率同源,兩者的差只能來自口徑。這條線仍然有用,只是它驗的是對齊與聚合,不是我的 pipeline 有多準。

Hands-on

Dataset

兩份資料,角色不同:

  • PPG-DaLiA S1、S2(主結果): 腕上 E4 的 PPG 64 Hz、胸前 ECG 700 Hz 並附 R-peak,兩人各 9212 秒與 8205 秒(153.5 與 136.8 分鐘),R-peak 11,431 與 11,247 個,八個活動之間夾著標成 transient 的過場。資料集還自帶一條心率 label(由 ECG 的 R-peak 算出,不是 PPG),8 秒窗、每 2 秒一筆,S1 4,603 筆、S2 4,099 筆。本機只解壓了這兩位。
  • 合成 PPG(先驗證用): jittered_rr 產 120 個間期,平均 1000 ms、SD 30 ms(seed=8),synthetic_ppg_from_rr 畫成 64 Hz 波形,共 120.9 秒。真值就是產生波形時指定的那串間期。

Method

  1. 切窗跟著 label 的格點走。 8 秒窗、2 秒位移,S1 切出 4,603 窗、S2 4,099 窗,與各自的 label 筆數相減都是 0。跨在活動交界上的窗丟掉(52 + 60 窗),剩 8,590 窗。視窗重疊 75%,相鄰兩窗有 6 秒重複,下面的 n 都不是獨立樣本數。
  2. 一窗之內。 find_pulse_peaks(門檻在每個窗裡各自算)→ peak_intervals_ms → 間期閘門 30–220 bpm(272.7–2000 ms,與 Day 3 偵測器的預設同範圍)→ 聚合 → 時間戳貼窗中點。峰數少於 4 個、或通過閘門的間期少於 3 個,這一窗就不給數字。
  3. 聚合用平均間期(60000 ÷ 平均間期),與 Day 7 同口徑。中位數與「峰數除以窗長」留到最後一節換著看。
  4. 三條線,兩個來源。 ECG 那側直接拿資料集附的 R-peak,走同一組閘門與聚合,與 PPG 側只差在峰從哪來;label 那側是資料集用同一組 R-peak 算好的數字。所以 PPG − ECG 比的是兩個感測器,label − ECG 比的是同一組 R-peak 上的兩種口徑。

label 的時間慣例(第 k 筆對應 [2k, 2k+8))是假設,先驗證過:把 label 前後平移,MAE 在位移 0 最小(S1 0.33、S2 0.21 bpm),平移一窗就跳到 1.6–1.7。

Code

import numpy as np
from wearable_ai.datasets import load_subject
from wearable_ai.signal_processing.heart_rate import windowed_hr

data = load_subject(1)
t_s, hr_bpm, n_peaks, n_valid = windowed_hr(
    data.ppg, 64.0, window_s=8.0, hop_s=2.0,   # 與資料集 label 同格點
    method="mean",                              # 60000 / 平均間期,同 Day 7
    min_peaks=4,                                # 一窗至少 4 個峰
    min_bpm=30.0, max_bpm=220.0,                # 間期閘門:272.7–2000 ms
    stamp="center",                             # 時間戳貼窗中點
)
np.isnan(hr_bpm).mean()   # 0.0235:4603 窗裡有 108 窗沒給數字

回傳的是四個等長陣列,不是只有心率:n_peaksn_valid(通過閘門的間期數)要跟著往下游走,不然「這個 72 bpm 是幾個峰算出來的」就問不到了。

先在真值已知的訊號上,看閘門擋得住什麼

https://ithelp.ithome.com.tw/upload/images/20260921/201842063XGIWjmn3E.png
合成 8 秒視窗、四種峰序列各用三種聚合,與真值 61.7 bpm 的差的長條圖

取合成訊號的第一個 8 秒窗(9 個峰,真值 61.70 bpm),在正中央那一拍動手腳:

  • 多一個假峰(把一個間期從中間切成兩半):平均間期偏 +7.71 bpm、中位數 +0.81、峰數除以窗長 +13.30。
  • 漏一拍:平均間期 −7.71、中位數 +0.07、峰數除以窗長 −1.70。
  • 兩者都做(不同拍):平均間期回到 0——多的與少的剛好抵消,這種「看起來很準」是巧合。

意外在閘門:這四個情境它擋掉的間期數都是 0。把 1000 ms 切成兩個 500 ms 是 120 bpm,漏一拍變成 2000 ms 是 30 bpm,都還在 30–220 bpm 之內。閘門擋得住離譜,擋不住差一拍。

整條鏈路在 57 個窗上對照真值,MAE 0.069 bpm、最大 0.45。這能排除「接線錯、數字算錯」,排除不了濾波與 prominence 規則在真實波形上的適應性——合成波形的形狀正好符合峰偵測的假設。

三條線:我算的、資料集的、我重算的 ECG

https://ithelp.ithome.com.tw/upload/images/20260921/20184206aLI8zaR2NA.png
S1 sitting 段 214 到 452 秒的三條心率曲線與逐窗誤差,label 幾乎貼著 ECG,PPG 有兩處拱起

先看一段乾淨的:S1 靜坐段 214–452 秒,120 個窗,每一窗都算得出心率。綠色的 label 幾乎貼在黑色 ECG 上(MAE 0.20 bpm),這兩條同源,貼在一起不意外。我的橘線大致跟著走,但有兩處整段拱起來,最大差 18.71 bpm;這 120 窗的 MAE 是 2.03 bpm,其中 11 窗差超過 5 bpm。靜坐、沒有動作,誤差仍不是均勻的小抖動,是偶爾整段走鐘。

全部 8,590 窗攤開:

口徑 MAE bias RMSE
PPG − ECG(我算的) 9.68 −1.75 14.53
label − ECG(同一組 R-peak,不同聚合) 0.274 +0.274 0.528
PPG − label 9.65 −2.02 14.52

中間那列容易看錯。0.274 bpm 不是「別人的演算法比我準 35 倍」——那條線不是從 PPG 來的,它量的是同一組 R-peak 上兩種聚合的差:我用 60000 ÷ 平均間期,資料集用逐拍心率的平均。把 ECG 側換成逐拍心率平均,差就掉到 0.0055 bpm,99.8% 的窗在 0.05 bpm 內。它的 bias 恆正,8,590 窗沒有例外,這是凸性的必然:逐拍心率的平均永遠 ≥ 平均間期的倒數。

所以能說「這條 pipeline 有多準」的只有 PPG − ECG 的 9.68 bpm,另外兩列驗的是對齊與聚合公式。

誤差不是平均分配的

https://ithelp.ithome.com.tw/upload/images/20260921/20184206PMPWiU3TS3.png
九個活動的逐窗誤差箱型圖,橘色 PPG 減 ECG、綠色 label 減 ECG,stairs 與 cycling 的箱子整個掉到負的一側

活動 窗數 算得出心率 MAE bias |誤差| ≤ 5 bpm
sitting 643 96.4% 3.97 +1.49 73.6%
working 1232 99.0% 5.52 +0.04 63.5%
driving 897 99.0% 6.88 −0.18 50.8%
lunch break 1780 98.3% 7.48 +0.68 49.2%
walking 714 99.7% 12.22 +4.34 25.4%
cycling 393 100% 17.30 −15.20 26.2%
table soccer 317 86.4% 18.50 −11.82 16.8%
stairs 269 98.5% 31.43 −30.81 3.4%

(transient 的 2,345 窗 MAE 10.55,資料集沒寫裡面在做什麼,所以不列在排序裡。)

整體只有 44.9% 的窗誤差在 5 bpm 以內。方向也分活動:stairs −30.81、cycling −15.20 整箱掉到負的一側,walking 反過來 +4.34,只有 working(+0.04)與 driving(−0.18)接近 0。整體 bias −1.75 是相反方向抵銷的結果,拿它當「幾乎無偏」會把這些蓋掉。

另一件要分開看:整體 97.4% 的窗給得出數字,給不出的 227 窗集中在 transient(103)與 table soccer(43);但 cycling 是 100% 給得出、MAE 17.30 bpm。給得出給得對是兩個欄位。

換一個口徑,數字會動多少

v
https://ithelp.ithome.com.tw/upload/images/20260921/20184206byYoH5u7QY.png
九種設定的 MAE 與保留率橫條圖,左格 MAE 從 9.18 到 12.20,右格保留率從 92.4% 到 99.5%

每次只改一項,其餘不動:

設定 MAE bias 算得出心率
baseline(平均間期) 9.68 −1.75 97.4%
聚合改中位數 9.45 +0.41 97.4%
聚合改峰數 ÷ 窗長 12.20 −6.64 98.3%
關掉間期閘門 10.83 −2.94 98.3%
峰數下限 4 → 2 10.02 −1.92 99.5%
峰數下限 4 → 6 9.18 −1.20 92.4%
時間戳改 start/end 9.68 −1.75 97.4%
位移改 8 秒(Day 7 口徑) 9.58 −1.69 97.5%

兩格要一起看:峰數下限拉到 6,MAE 降到 9.18,代價是 7.6% 的窗不給數字了。丟得多,剩下的當然好看。

時間戳那兩列和 baseline 一樣是對的:三條線用同一個 stamp,又用視窗序號配對,一起平移不改變配對;要接到別條時間軸上才會咬人(Day 12)。口徑不一致長什麼樣,看位移掃描:差一窗,MAE 從 0.33 跳到 1.69。

結果與意外

  • 第三條線不是第三方。 資料集的 label 看起來像另一套 PPG 演算法的輸出,其實它由胸帶 ECG 的 R-peak 算出,和我重算的 ECG 同源。那 0.274 bpm 幾乎全是聚合公式的差:ECG 側換成逐拍心率平均,只剩 0.0055 bpm。對照物的來源要先查清楚,不然會拿錯的東西當標竿。
  • 間期閘門沒有我以為的有用。 合成訊號上,一個假峰、一次漏拍都落在 30–220 bpm 內,閘門擋掉 0 個間期;真實資料上關掉它,MAE 從 9.68 變 10.83,只差 1.15 bpm。它擋的是離譜,不是差一拍。
  • 聚合方式的差別在方向,不在大小。 換成間期中位數,MAE 只從 9.68 降到 9.45,bias 卻從 −1.75 變成 +0.41。合成訊號上差更多:漏一拍時中位數偏 +0.07、平均間期偏 −7.71 bpm。
  • 算得出不等於算得對。 cycling 100% 的窗都給得出心率,MAE 17.30 bpm;table soccer 只給得出 86.4%,MAE 18.50。保留率與誤差是兩個欄位,只看一個會看錯。
  • 誤差有方向,整體卻看不出來。 stairs −30.81、cycling −15.20、table soccer −11.82,walking +4.34,整體 bias −1.75 是相反方向抵銷的結果。
  • 頻域不是穩贏。 同樣 8,363 窗上,頻域 MAE 11.83、時域 9.68;頻域較低的只有 sitting、driving、lunch break,stairs 反而更差(38.64 對 31.43)。換路線不等於換到更好的路線。

Limitations

  1. 視窗重疊 75% 導致樣本高度相依。 8 秒窗每 2 秒滑動一次,相鄰視窗共用了 6 秒的原始訊號,所有窗都不是獨立樣本。這裡呈現的統計量不能當成 n = 8,590 的獨立取樣來看,單一偶發干擾(如一次手腕滑動)會連續污染 4 個相鄰視窗。
  2. 只有 2 人且個體差異劇烈。 兩位受試者在同一個活動上的表現落差顯著(例如 table soccer 的 MAE 在 S1 是 11.42 bpm,S2 卻高達 26.94 bpm)。在這樣的小樣本下,各活動的誤差排序無法外推為普遍的生理或感測結論。
  3. 演算法是極簡 baseline,不是 PPG 的上限。 PPG 峰偵測仍沿用 Day 3 的時域 prominence 啟發式做法,沒有用上研究文獻裡的自適應濾波或多通道動作補償(例如 Day 7 提過的 TROIKA 那類方法)。因此 9.68 bpm 量到的是這條輕量 pipeline 離真值的距離,而非 PPG 光學感測技術的上限。

這對 AI Engineering 的意義

口徑是資料的一部分。 同一份 PPG、同一排窗,只換一個參數,MAE 就在 9.18 到 12.20 bpm 之間動,給得出心率的窗在 92.4% 到 99.5% 之間動。所以「72 bpm」脫離「8 秒窗、2 秒位移、平均間期、至少 4 個峰、閘門 30–220 bpm」就沒有意義;兩天的數字能不能並排,看的是口徑,不是單位。Day 19 的 feature contract 要放得下這一整組設定,不是只放一個浮點數。

「給不給數字」與「數字準不準」是兩個欄位。 cycling 最會騙人:100% 的窗給得出心率,MAE 17.30 bpm。pipeline 若只輸出心率,下游看到一條完整沒有缺口的曲線,沒有任何跡象顯示它偏低 15 bpm。所以 windowed_hr 回傳四個陣列:心率、峰數、有效間期數、時間戳。Day 11 的 signal quality 就接在這裡——先能說「我算得出但我不確定」,後面才有東西可以 grounding。

對照物本身要有來源與口徑。 今天最值得記的不是 9.68 bpm,是我差點把 label 當成第三方 PPG 估計——它其實是同一組 R-peak 換個公式,0.274 bpm 量的是公式差,不是誰比較準。reference 的來源(哪個感測器、誰算的)與口徑(什麼公式、什麼視窗)要跟數字一起存,否則拿它當標竿時不會有跡象提醒你搞錯了。也因此,心率這種算得出來的量不該交給 LLM 估:不是因為它給不出合理的數字,而是因為那個數字沒有對照、沒有誤差可以量。LLM 接手的位置,是拿到「72 bpm、8 個峰、口徑已知」之後,去解釋它對這個人意味著什麼。


上一篇
Day 07|Motion Artifact:為什麼穿戴式資料這麼難用?
下一篇
Day 09|PPG 推出的 R-R vs ECG 的 R-R:差多少?
系列文
30 天拆解 Wearable × AI:從穿戴裝置生理訊號到AI健康洞察10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言