之前我們提到各主流廠商的RHR或是HRV的數值大多是基於用戶在靜止或是睡眠狀態下的偵測。然而真實世界不可能都在這樣的狀態裡,但白天使用者在走路、跑步、打字、騎車、做家事,手腕隨時都在動作。感測器在手晃動時真正記錄到的是什麼?
Day 3 我們用 0.25–8 Hz 的帶通濾波器把基線漂移和高頻毛邊濾掉,難道手甩動的雜訊不能照樣濾掉?但手錶裡不是都內建了三軸加速度計嗎?如果手動得厲害、加速度能量高,演算法只要把動作成分扣掉,或者看到加速度大就乾脆標記放棄,問題不就解決了?
今天我們直面穿戴式生理訊號最棘手的痛點:Motion Artifact。看看手腕一動,波形到底被破壞成什麼樣子、為什麼古典的帶通濾波救不回來,以及那顆被寄予厚望的腕上加速度計,到底能不能當作合格的品質守門員。
PPG 靠反射光的強弱推血容積變化。手一動,光路就變了,而且三件事同時變(Fine 2021):
還有一點容易誤會:完全不動時基線也會漂,呼吸、體溫調節、交感神經活動都會造成低頻漂移。所以「靜態」不等於「乾淨」。PPG-DaLiA 把靜坐段叫 "motion-artefact-free baseline",那是實驗設計的意圖,不是量測結果。
濾波要有效,前提是訊號和雜訊落在不同頻率。心率是 1–4 Hz,動作橫跨 0.1–20 Hz,整個蓋過去(Fine 2021)。同頻就沒有判準——濾波器無從知道 2 Hz 那個成分是心跳還是腳步。
更麻煩的是週期性動作。走路、跑步、騎車的節律本身就像脈搏:振幅穩、週期規則,演算法會鎖到步頻上,把它當成心跳。Bent 2020 稱之為 signal crossover,六款裝置實測走路時誤差都顯著上升(Apple Watch 4 是唯一例外),而且有方向:走路讓心率報高,打字讓它報低。
所以濾完「看起來乾淨」不代表心率是對的,峰還在、只是那些峰不是心跳;而且這種錯有方向,取平均消不掉。
(邊界:強動作下並非追不到。Zhang 2015 的 TROIKA 在跑到 15 km/h 的資料上做到 MAE 2.34 bpm,但靠的是訊號分解與頻譜峰追蹤,不是一道帶通。)
PPG-DaLiA 腕上除了 PPG 還錄了 32 Hz 三軸加速度,Reiss 2019 寫明用途是 "in order to facilitate motion artefact compensation"。兩種用法,代價差很多:
今天做第二種。它的前提是「加速度大 → PPG 被污染」這個關聯夠強,而失效方向很明確:加速度安靜不代表 PPG 乾淨。打字、敲桌、握力變化這類微動作,手腕確實沒動,加速度計抓不到(Fine 2021);Bent 2020 實測打字的誤差常和走路一樣高。反過來也不保證:走路很規律,規律本身也可能讓演算法穩定地抓對。所以代理指標和真實誤差的關係要量出來,不是假設出來。不靠第二感測器的品質判斷留到 Day 11。
| 處置 | 做什麼 | 代價 |
|---|---|---|
| 修 | adaptive filter 或波形重建 | 下游拿到的是估計值,而「修過」通常沒有傳下去 |
| 標 | 產生 quality flag,資料保留 | 下游必須真的會讀這個 flag,否則等於沒標 |
| 丟 | 剔除該段 | 產生缺值,而且缺得不隨機——動作多的時段被系統性刪掉 |
第三條最容易被低估。剔除動作段之後,剩下的資料偏向靜態時刻;拿去算一天的平均心率或 HRV,算出來的不是「這一天」,是「這一天裡比較靜的那些時刻」。這個偏差會傳到 Day 12 的聚合與 Day 15 的 personal baseline,到那一層已經看不出它從哪裡來。
三個問題:誤差隨能量變化的形狀、誤差的方向、各活動的能量排序跟誤差排序一不一樣。
兩份資料,角色不同:
jittered_rr 產生 60 個間期,平均 1000 ms、sd 20 ms(seed=7),synthetic_ppg_from_rr 畫成 64 Hz 波形,再疊上 2.2 Hz 的正弦動作成分——2.2 Hz 等於 132 bpm,是走路的步頻,也落在心率帶裡。振幅取 0.5、1、2,以 PPG 主峰振幅為 1。hr_label 的視窗長度)。跨在活動交界上的窗丟掉(S1 14 窗、S2 15 窗),剩 1137 + 1010 = 2147 窗。transient 的 586 窗留著——它佔了 27%,拿掉等於偷偷挑資料。find_pulse_peaks(0.25–8 Hz 帶通、prominence 取該窗振幅 15%),ECG 用資料集附的 R-peak 當對照真值,兩邊都取平均間期,一窗至少 4 個峰才算。誤差 = PPG − ECG,2147 窗裡有 2118 窗兩邊都算得出來。import numpy as np
from wearable_ai.datasets import load_subject
from wearable_ai.signal_processing import find_pulse_peaks
from wearable_ai.signal_processing.motion import acc_energy, motion_flags
data = load_subject(1)
t0_s, energy = acc_energy(data.acc_wrist, 32.0, window_s=8.0) # 去平均後三軸 RMS,單位 g
flagged = motion_flags(energy, threshold=0.35, min_consecutive=1) # True = 疑似被動作污染
seg = data.ppg[int(150 * 64) : int(158 * 64)] # 靜坐段的一窗
peaks_s = find_pulse_peaks(seg, 64.0) / 64.0
60.0 / np.mean(np.diff(peaks_s)) # 42.8 bpm;同窗 ECG 43.7

S1 的 sitting 與 walking 各 10 秒,上排 PPG 與偵測到的峰、下排腕式三軸加速度;sitting 的三軸幾乎是直線,walking 三軸都在 ±1 g 擺動
左邊是靜坐的 10 秒:加速度三軸幾乎是三條直線,PPG 每一拍的形狀都一樣,峰偵測 43.4 bpm、ECG 43.5 bpm。右邊是走路的 10 秒,加速度三軸都在 ±1 g 之間擺動,PPG 的每一拍長得都不一樣——主峰有高有低,中間多出不屬於脈搏的起伏。這一段剛好對得不錯(80.6 對 80.3 bpm),但整個 walking 的 179 窗,|誤差| 中位數是 12.4 bpm。

合成 60 bpm 的 PPG 疊上 2.2 Hz 動作、經 0.25–8 Hz 帶通之後的四段波形,動作振幅 0、0.5、1、2,偵測到的峰逐漸鎖到 132 bpm
帶通留下了 99.7–99.8% 的動作成分。這不是濾波器沒做好,是 2.2 Hz 就在通帶正中央,和心跳同頻,濾波器沒有依據把兩者分開。沒有動作時峰偵測報 60.3 bpm(真值 60);動作振幅 0.5 報 73.5 bpm;振幅到 1 以上時找到 133 個峰(真實 61 個),報出的 132.1 與 132.0 bpm 剛好是步頻——錯誤不是隨機的,是鎖到了另一個週期訊號上。

九個活動各一格的散點圖,x 是加速度能量、y 是心率誤差的絕對值,橘線是各格內依能量分組的中位數;table soccer 與 cycling 的橘線往下走
把 2147 窗依能量等量分成五組,|誤差| 中位數是 1.31、5.30、8.87、11.4、11.5 bpm,超過 10 bpm 的比例從 6.8% 升到 57.4%,全體的 Spearman ρ = +0.453(n = 2118)。關係存在,但不是斷崖:最大的一跳在第一組到第二組之間(能量 0.046 g 這條線附近),第四組到第五組幾乎持平。
分活動看就散了:
| 活動 | 窗數 | 能量中位數 | 誤差中位數(帶號) | |誤差| 中位數 | >10 bpm | ρ |
|---|---|---|---|---|---|---|
| stairs | 67 | 0.452 | −27.2 | 28.3 | 89.6% | +0.11 |
| table soccer | 79 | 0.336 | −20.7 | 21.4 | 82.4% | −0.39 |
| cycling | 98 | 0.628 | −13.3 | 15.7 | 63.3% | −0.36 |
| walking | 179 | 0.476 | +8.07 | 12.4 | 58.1% | +0.15 |
| sitting | 160 | 0.00971 | +0.41 | 1.48 | 15.4% | +0.50 |
另外四個活動的 |誤差| 中位數是 transient 7.97、driving 6.23、lunch break 5.23、working 2.51 bpm(完整的九列在 days/day07/results.md)。能量最高的是 cycling,誤差最高的是 stairs;八個具名活動(不含 transient)的兩個排序,後四名(driving、lunch break、working、sitting)一致,前四名是同一組、次序重排。
活動之內,關係有時是反的:table soccer ρ = −0.39、cycling ρ = −0.36,圖上那兩格的橘線往下走。方向也分活動:stairs 有 94% 的窗比 ECG 低 5 bpm 以上,walking 反過來,58% 的窗高出 5 bpm 以上。全部 2118 窗合起來誤差為正的占 49.1%——整體看不出方向,是兩邊抵銷掉了。
兩人也不一樣:table soccer 的誤差中位數 S1 是 −9.11、S2 是 −32.2 bpm;S2 的 cycling 有 5.85% 的樣本撞到 ±2 g 的量程上限(S1 1.44%),撞軌的窗真實加速度比記到的大。

門檻掃描的三格圖:保留率、保留與丟掉視窗的誤差中位數、平均心率偏差,x 是門檻、虛線標在 0.35
門檻 0.35 g 保留 60.5% 的窗,保留窗的 |誤差| 中位數 3.86 bpm、丟掉的 11.5 bpm——gate 確實把誤差大的挑出來了。但兩個方向都有漏:
保留率按活動拆開,兩端差很遠:sitting 90%、working 84.4%,另一頭是 walking 29.1%、stairs 5.97%。stairs 的 67 窗只剩 4 窗,而那 4 窗的 |誤差| 中位數是 42.8 bpm。
代價在第三格:用 ECG 的心率算(與 PPG 準不準無關),全部窗的平均是 78.0 bpm,門檻 0.35 只留下的那些窗平均 73.4 bpm,低了 4.68 bpm。門檻再嚴一點,0.0097 g(靜坐段的能量中位數)只留 7.87% 的窗,平均心率掉到 60.2 bpm——低了 17.8 bpm。剔除動作段之後算出來的「平均心率」,量的不是這段時間,是這段時間裡比較靜的那些時刻。
六件實際發現,對照的是 Concept 第 5 節那三個先猜的問題:
quality gate 的輸出要保留它的單向性。 今天量到的兩個漏——27.5% 的保留窗誤差仍大於 10 bpm、23.9% 的丟掉窗其實是好的——說明加速度能量不是「PPG 乾不乾淨」的量測,只是「加速度計有沒有看到動作」。所以這個欄位的名字不該叫 clean,該叫 motion_flag,而且要連定義一起往下游送:能量怎麼算、門檻多少、幾秒一窗。Day 19 的 feature contract 要放得下這種「有條件的證據」,不是一個 bool。
覆蓋率與偏差要和數字一起走。 門檻 0.35 下,一天裡 60.5% 的窗留下來,平均心率被拉低 4.68 bpm;門檻收到靜坐段的中位數,剩 7.87% 的窗、心率低 17.8 bpm。這個偏差與 PPG 準不準無關,它是剔除本身造成的——用 ECG 的心率算也一樣。下游若只收到一個平均值,這一層資訊就消失了;到 Day 12 的聚合、Day 15 的 personal baseline,已經看不出「今天的基準是 60% 的時間算出來的,而且偏靜」。所以聚合結果要帶 coverage 與被丟掉的時段分布。
錯誤有方向,取平均消不掉。 stairs、table soccer、cycling 系統性偏低(−13 到 −27 bpm),walking 偏高(+8 bpm)。這不是零均值雜訊,多平均幾窗不會變好。對 LLM 尤其危險:一個「運動時心率偏低」的序列,讀起來像體能進步的證據,而它其實是 signal crossover 與漏拍的產物。所以 Day 17 的 deviation detection 要先問「這段時間的量測可信嗎」,再問「有沒有偏離基準」;能回答第一個問題的,是 deterministic 這一層算出來的 flag 與覆蓋率,不是語言模型的推理。