Day 9 把 PPG 的 pulse interval 和 ECG 的 R-R 正面比過一次。平均間期只差 1.01 倍,清理後的 RMSSD 卻差 1.89 倍。問題是那個比值拆不開:用時間誤差的二階差公式去預測,預測比實測高了 145 ms。公式是代數恆等式,對不上的是前提——它假設兩邊的拍一對一、只差一個時間誤差,而真實紀錄裡同時有抖動、漏拍、假峰與錯配。誤差確實進來了,但看不出是從哪一道門進來的。
真實資料回答不了這個問題,因為沒有 ground truth。所以今天反過來做:拿 PhysioNet nsr2db 的 ECG 導出間期當基底,先把它清乾淨,再一次只注入一種已知類型、已知位置、已知強度的錯誤。
Day 8 與 Day 9 也注入過,但都是單一情境的事件型錯誤。今天補三件事:峰定位抖動、強度梯度與跨 seed 的分佈,以及用已知的注入位置回頭算清理規則的 precision 與 recall——Day 9 在真實資料上排除了 54.3% 的 PPG 間期,但沒有判準能說它剔對了。
產出是可重現的 artifact 情境、指標偏差表與失敗案例,讓 Day 11 的 signal quality 規則有具體的 regression test,而不是只靠「看起來合理」判斷。
注入之前得先分清楚每種錯誤改變的是什麼。
前三種是量測失誤,第四種是生理事件,第五種是覆蓋率缺口;五種都不改變總時長,這是注入前後還能比的前提。第五種是這個專案被咬過的那一種:Day 4 那 3 個 4.7–7.5 秒間期兩端都是竇性搏動,「剔除非竇性拍」擋不掉,全段 RMSSD 被抬高 53%。
這是今天最容易做錯、而且錯了也不會報錯的一步。峰時間的誤差 e_i 進到間期時變成 e(i+1) − e(i),所以相鄰間期的誤差是負相關的——那才是「一長一短」。如果直接對每個間期獨立加噪音,相鄰誤差變成獨立的,形態就不對了。
兩者差三倍:
| 加在哪一層 | SDNN² 增量 | RMSSD² 增量 | 相鄰間期誤差的 lag-1 自相關 |
|---|---|---|---|
| 峰時間(一階差、二階差) | 2σ² | 6σ² | −0.5 |
| 間期(少差一次) | σ² | 2σ² | 0 |
要模擬峰定位誤差只能用峰時間層,用間期層會少掉三分之二的影響。

相鄰間期誤差的 lag-1 散佈與變異數比:峰時間層 ρ₁ = −0.50,間期層 0.00
單次注入對 RMSSD 分子 Σd² 與 SDNN 分子 Σ(x−x̄)² 的貢獻,以平均間期 RR̄ 的平方為單位:
| 注入 | Σd²(RMSSD) | Σ(x−x̄)²(SDNN) | 比值 |
|---|---|---|---|
| 漏拍 | 2.00 | 1.00 | 2.0 |
| 假峰(切點 0.9) | 1.46 | 0.82 | 1.8 |
| 早發拍(提前 0.4) | 0.96 | 0.32 | 3.0 |
| 假峰(切點 0.7) | 0.74 | 0.58 | 1.3 |
| 假峰(切點 0.5) | 0.50 | 0.50 | 1.0 |
假峰的破壞力取決於切在哪裡:對半切最輕,越偏離中點越重(0.5 到 0.9 差近三倍)。本實驗用 0.7 當偏離中點的切點,沒有量測真實重搏切跡落在哪——預設 0.5 是不是低估,取決於這個還沒驗證的前提。最右欄則說明**「RMSSD 比 SDNN 脆弱」不是通則**,取決於錯誤形態。
上面是分子。RMSSD² 的分母是有效差分數,所以同一次注入在短序列上是災難、在長序列上看不見。不寫 N,偏差表不能解讀——Task Force 1996 也明說不同長度的紀錄不可互相比較時域指標。
注入率(每百拍幾個事件)和抖動幅度(σ 毫秒)是不同單位,並排比不出「哪一種最傷」。用第 3 節的係數把兩邊換算成每拍注入多少變異就對齊了:事件是注入率乘以單次係數,抖動是 6(σ/RR̄)²。對齊之後才問得出今天真正想問的那題:在預測傷害相同的強度下,清理規則的表現一樣嗎?事件是離散的、形態判準有明確的壞拍可抓;抖動是連續的,沒有哪一拍算壞拍——門檻照樣會大量剔除間期,卻消不掉抖動造成的偏差。
注入位置是已知的,所以清理不只能看「指標有沒有回到真值」,還能直接算召回率與誤剔率——指標變好也可能只是因為最難的那些被丟掉了。這是合成資料唯一能給、真實資料給不了的東西。
抖動情境特別有意思:它沒有「該剔的間期」,每一拍都是真的心跳,所以規則在抖動下剔掉的每一個,定義上都是誤剔。
PhysioNet nsr2db 的 nsr001,ECG 導出的 R-R 標註。取兩端都是竇性的間期(NN),再套 Day 4 更正後的規則剔掉超過 3 秒的訊號空檔:106298 → 106295 個間期,RR̄ 760.5 ms、RMSSD 32.10 ms、SDNN 168.54 ms。這是注入前的參照,不是生理真值。
五種錯誤各沿同一條「每拍注入多少變異」的軸走 8 格(1e-4 到 1e-1),每格 10 個 seed,共 400 列。全部在峰時間層注入,同時記下哪些間期被弄壞。每列三個口徑並排:真值、注入後不清理、注入後清理(clean_rr 加上 gap-aware RMSSD)。
有三格不是量測結果,排除在統計與圖之外:jitter 在 0.1 那格有 4 個 seed 讓峰時間交錯(見 Limitations);signal_gap 在最低兩格事件數 round 成 0——偏差比恆等於 1.00,看起來像「低強度的空檔無害」,其實是沒有空檔。
落點不能隨便挑。解析係數假設事件周圍是等間距,所以每個事件弄壞的間期,兩側都要留一個乾淨的間期——而「footprint」每種錯誤不一樣:
def _event_bounds(kind, peaks, shape):
"""回傳 (最早落點, 最晚落點, 事件間最小間距)。"""
n = peaks.size
if kind in ("missed_beat", "ectopic"):
return 2, n - 3, 3 # 各弄壞兩個相鄰差分
if kind == "extra_beat":
return 1, n - 3, 2 # 只切開一個間期
span = _gap_span(peaks, shape) # 空檔吃掉幾拍,用實際峰時間算
return 2, n - 2 - span, span + 2
寫錯的代價是靜默的:漏拍貼在序列首尾時實測 Σd² 只有 1.00(預測 2.00),兩個漏拍相隔 2 個峰時是 2.00(預測 4.00)。
| 原本以為 | 實際發現 |
|---|---|
| RMSSD 對差分取平方,所以比 SDNN 脆弱 | 要看錯誤形態。單次早發拍對 RMSSD 與 SDNN 分子的貢獻比是 3 倍,對半切的假峰卻是 1 倍;不能只憑指標名稱排序。 |
| 漏拍與假峰都是「多一個/少一個峰」,破壞力應該差不多 | 單次漏拍對 RMSSD 分子 Σd² 的預測貢獻是 2.00 RR̄²,對半切假峰只有 0.50 RR̄²,差四倍;假峰切點越偏,傷害越大。 |
| 把抖動加在間期上或加在峰時間上,只是實作細節 | 峰時間誤差會連續差分兩次,RMSSD² 的預測增量是 6σ²;直接在間期加獨立誤差只有 2σ²,少算三分之二。 |
| 注入錯誤會讓 HR 和 HRV 一起失真 | 在事件型錯誤的 target=0.1 那格,HR 最多偏 13.5%,RMSSD 卻變成真值的約 7–8 倍;但高強度 jitter 清理後 HR 也會從 78.96 掉到 77.06 bpm(0.037 那格,剔除率 32%),不能說 HR 永遠穩。 |
| 清理後指標回到真值,就代表清理規則抓對了地方 | target=0.1 那格四種事件型錯誤清理後 RMSSD 都約 30.4 ms(真值 32.10 ms),precision 卻從 0.192 到 0.665。但 precision 低不等於剔錯多:precision 最低的 signal_gap 誤剔率只有 0.005,最高的 ectopic 反而是 0.132——precision 的分母還含事件數與基底既有的誤剔,要配著誤剔率一起讀。 |
| 固定 seed 就足以讓偏差數字可重現 | 單一 seed 讓錯誤重現,也讓落點 bug 穩定漏網:掃 seed 0–99 才發現尾端截短與事件重疊。改按事件 footprint 定界,再用 0–199 的 seed 驗證。 |
| 一個異常拍就足以毀掉指標 | 傷害還要除以有效差分數。Day 4 同一個早發拍在 n=300 時讓 RMSSD 變 2.13 倍,n=1200 時是 1.43 倍;不寫視窗長度,就無法解讀偏差。 |

清理前後的 RMSSD 偏差:不清理時五條線大致對齊,清理後四種離散事件壓回 0.95、jitter 爬到 2.6
左圖是共同強度軸大致成立的證據——預測傷害相同,實測偏差就落在同一條帶裡(target=0.1 那格是真值的 7.26–8.14 倍),但不會精確相等:強度軸對齊的是平均傷害,消不掉事件形態與落點的差異。右圖才是今天的結果。

同一預測傷害下的 recall、precision 與誤剔率,precision 旁標示 FP 裡有多少來自基底
這張圖是另一個強度格(target=0.01,比上表低一個數量級)。precision 那一條要配著基底讀:未注入時同一套規則就已經剔掉 400 個間期,而 signal_gap 的 411 個 FP 裡有 400 個是它,precision 0.028 幾乎不是在講空檔。事件越少,基底那 400 個佔的比例越大——上表 signal_gap 的 0.192 是同一個機制。
jump_mask 看前一拍,local_outlier_mask 看 21 拍視窗;注入會連帶改變原本間期的判定。在 20000 拍切片中,jitter 讓基底原本被剔的 86 拍裡有 68–72 拍改為通過,約八成翻面。因此 jitter 的誤剔不能簡單扣掉基底的誤剔數;這裡只並列報告,不做相減。一個清理後的 rmssd_ms 不足以證明資料品質。這次四種事件型錯誤都收斂到約 30.4 ms,precision 卻差很大;jitter 更是在剔掉 32% 的間期後,RMSSD 仍是真值的 2.6 倍。進入後續 AI 管線的 feature 應帶著有效間期數、剔除率、錯誤類型與清理規則版本;有注入 ground truth 的測試還要保存 recall、precision 和失敗的強度格,讓「數字看起來正常」可以被追問。
固定 seed 是重跑同一案例的起點,不是驗證充分的終點。這次落點 bug 由跨 seed 的邊界測試才揭露;不可達的 jitter 強度也提醒我,失敗案例不能直接丟掉再平均。Day 11 的 signal quality 規則應把這些案例做成 regression test,並在品質不足時讓下游選擇保留或拒答,而不是把清理後的數字當成確定的健康訊號。