Day 3 我們實作了 find_pulse_peaks 與 peak_intervals_ms,成功在一段短波形上標出主峰、算出間隔。看著時域圖上的紅點漂亮地落在波峰上,很容易產生一種錯覺:從原始光訊號到心率的演算法,最核心的部分不是已經完成了嗎?
但手錶螢幕上每隔幾秒跳一次的「72 bpm」,真的是直接拿相鄰兩個峰的時間差換算出來的嗎?
既然在 Day 3 已經會用帶通濾波和 scipy.signal.find_peaks 抓到主峰,那萃取心率不過是小學算術:相鄰兩峰的時間差 $\Delta t$ 換算成每分鐘跳動次數($60 / \Delta t$),或者把整段訊號數到的峰數除以總秒數再乘以 60。既然核心的波形找峰都搞定了,今天應該只是把那一兩行公式包裝成函式,給它一整段連續資料,它就應該穩定吐出整天的連續心率曲線。
但實際真的是這樣嗎?
找峰只是中間一步。從一段波形到一條能交給後面 pipeline 的心率曲線,前面要決定怎麼切窗,後面要決定哪些峰算數、怎麼聚合、算出來的數字貼在哪個時間點。而且要有辦法知道它準不準。
拿相鄰兩峰的時間差換算,得到的是逐拍心率(instantaneous heart rate)。問題是它每一拍都在動。
人安靜坐著,心跳間期也不一樣長:吸氣時迷走神經(vagus nerve)被抑制、心跳變快,吐氣時恢復、心跳變慢,這叫呼吸性竇性心律不整(respiratory sinus arrhythmia, RSA)。Day 4 的 RMSSD 對這類短期變異最敏感,nsr001 全段 22.55 小時是 34.33 ms。但 RMSSD 不等於 RSA,那 22 小時裡還混了姿勢、活動與睡眠的變化。
所以手錶不顯示逐拍心率,會先切窗聚合。切窗要決定三件事:
find_pulse_peaks 的 prominence 取整段波形峰谷差的 15%。切片上很好用,整段用就不行:PPG 的振幅本來就會變,只要中間出現一次大突波,門檻被它拉高,後面正常的脈搏全部低於門檻。
Day 6 量過:整段一起偵測,S1 找到 441 個峰、ECG 有 597 個;S2 只找到 51 個、ECG 有 630 個,漏掉 92%。所以那天之後改成分窗偵測,門檻在每個窗裡各自算。今天接的是下一題:分窗之後,怎麼知道某一窗的心率能不能用。
峰偵測有兩種錯,方向相反:多抓是把重搏波(dicrotic wave)或雜訊當成主峰,產生太短的間期;漏抓是某一拍振幅太低沒抓到,產生接近兩倍長的間期。窗內間期直接取平均,混進一個就歪掉。
至少要兩道處理:
find_pulse_peaks 沒做這件事——max_bpm 只轉成相鄰峰的最小間距,docstring 明寫「沒有對應的最大間距限制」。這道閘門今天要自己加。MIN_PEAKS = 4)。聚合方式也是選擇:平均間期、間期中位數、或峰數除以窗長。Day 7 固定用平均間期,今天要換,兩天的數字就不能並排。
頻域法直接對窗內波形做頻譜,抓主頻乘 60 就是心率,不必辨識每一個峰,對波形畸變比較耐受。代價有兩個:解析度綁在視窗長度上(8 秒窗是 0.125 Hz,等於 7.5 bpm),而且逐拍的時間資訊全丟了,HRV 沒得算。運動時它還容易被步頻帶走,就是 Day 7 的 signal crossover。
知道有這條路,是因為它指出一個設計方向:估計方法可以依訊號狀況挑。至於哪家手錶實際上怎麼做,沒有公開資料可查,這裡不猜。
Day 7 已經拿 PPG 心率跟同窗 ECG 比過,但那天誤差是配角。今天它是主角,而且多一條線可以比:PPG-DaLiA 自己附了一條心率 label(8 秒窗、2 秒位移,S1 有 4,603 筆)。
只是要先問它是誰算的。Reiss 2019 寫明 label 由胸帶 ECG 的 R-peak 算成逐拍心率、再切窗平均,不是另一套 PPG 演算法。它和我自己重算的 ECG 心率同源,兩者的差只能來自口徑。這條線仍然有用,只是它驗的是對齊與聚合,不是我的 pipeline 有多準。
兩份資料,角色不同:
jittered_rr 產 120 個間期,平均 1000 ms、SD 30 ms(seed=8),synthetic_ppg_from_rr 畫成 64 Hz 波形,共 120.9 秒。真值就是產生波形時指定的那串間期。find_pulse_peaks(門檻在每個窗裡各自算)→ peak_intervals_ms → 間期閘門 30–220 bpm(272.7–2000 ms,與 Day 3 偵測器的預設同範圍)→ 聚合 → 時間戳貼窗中點。峰數少於 4 個、或通過閘門的間期少於 3 個,這一窗就不給數字。label 的時間慣例(第 k 筆對應 [2k, 2k+8))是假設,先驗證過:把 label 前後平移,MAE 在位移 0 最小(S1 0.33、S2 0.21 bpm),平移一窗就跳到 1.6–1.7。
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_peaks 與 n_valid(通過閘門的間期數)要跟著往下游走,不然「這個 72 bpm 是幾個峰算出來的」就問不到了。

合成 8 秒視窗、四種峰序列各用三種聚合,與真值 61.7 bpm 的差的長條圖
取合成訊號的第一個 8 秒窗(9 個峰,真值 61.70 bpm),在正中央那一拍動手腳:
意外在閘門:這四個情境它擋掉的間期數都是 0。把 1000 ms 切成兩個 500 ms 是 120 bpm,漏一拍變成 2000 ms 是 30 bpm,都還在 30–220 bpm 之內。閘門擋得住離譜,擋不住差一拍。
整條鏈路在 57 個窗上對照真值,MAE 0.069 bpm、最大 0.45。這能排除「接線錯、數字算錯」,排除不了濾波與 prominence 規則在真實波形上的適應性——合成波形的形狀正好符合峰偵測的假設。

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,另外兩列驗的是對齊與聚合公式。

九個活動的逐窗誤差箱型圖,橘色 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
九種設定的 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。
口徑是資料的一部分。 同一份 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 個峰、口徑已知」之後,去解釋它對這個人意味著什麼。