ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于心电信号的睡眠呼吸暂停检测:从数据到AHI估计的工程实践

基于心电信号的睡眠呼吸暂停检测:从数据到AHI估计的工程实践

简介:这是一份面向生物医学工程、信号处理方向本科生与研究生的毕业论文资料,聚焦基于心电信号的睡眠呼吸暂停综合征(SAS)自动检测算法研究,适合正在做相关毕业设计或课题、需要参考完整研究思路与实验方案的学习者。资源包共1个PDF文件,约2.56MB,为完整硕士学位论文,含摘要、绪论、算法原理、实验验证与结论等章节,可系统了解从特征提取到模型融合的完整研究链条。论文提出基于单导联心电信号HRV时域、频域、非线性共17个特征的检测算法,并进一步采用判别典型相关分析融合统计学特征与卷积神经网络深层特征,在Apnea-ECG数据库上对一分钟SAS段检测的平均准确率分别达到95.79%与97.33%,逐条记录检测准确率、敏感度、特异性均达100%。目前已有224人学习,可为便携式SAS检测设备的算法设计提供理论依据与实验参考。

1. 从一份毕业论文标题拆开:心电信号怎么用来筛睡眠呼吸暂停

睡眠呼吸暂停综合征(OSA)的筛查,临床上最准的是多导睡眠图(PSG),但设备贵、导联多、排队久,很多人根本做不上。于是过去十年里,用单导联心电信号(ECG)做 OSA 检测成了一个热门方向——因为心电采集便宜、可穿戴设备就能干,而且 OSA 发作时自主神经系统的交感/副交感平衡会变,这种变化会直接调制心率和心电波形。一份题为「基于心电信号的睡眠呼吸暂停综合征检测算法研究」的毕业论文,本质上要解决的就是:给你一段整夜单导联 ECG,怎么自动判断这个人有没有 OSA、严重程度如何。

这篇笔记不聊论文本身,而是把这条技术路线拆成能复现的工程路径:从公开数据集怎么拿、ECG 怎么切窗、特征怎么提、模型怎么搭,到评估指标怎么选、坑在哪。适合正在做相关毕设、想复现基线、或者准备把算法往可穿戴设备上落的人。读完你应该能自己跑通一条从原始 ECG 到 AHI 估计的最小链路。

2. 数据与预处理:整夜 ECG 怎么变成能喂给模型的样本

2.1 公开数据集的选择与标签口径

做这个方向,第一步不是写模型,是找数据。OSA 检测常用的公开数据里,Apnea-ECG(PhysioNet 上的一个经典库)是绕不开的:它提供单导联 ECG,采样率 100 Hz,每条记录约 7-10 小时,带逐分钟的通气暂停/低通气标注。另一类是 SHHS、MESA 这类大型队列,带 PSG 金标准和 AHI 标签,但申请流程长、体量大。

选数据集时最容易翻车的地方是标签口径不统一。Apnea-ECG 给的是每分钟二分类标签(A 表示该分钟有呼吸事件),而临床诊断用的是 AHI(每小时呼吸事件次数)。如果你直接拿分钟标签训一个逐分钟分类器,最后要自己聚合成 AHI,这一步的聚合规则会显著影响结果。常见做法是:逐分钟预测后,统计整夜阳性分钟数,再除以总时长换算成每小时事件数,按 AHI≥5 / ≥15 / ≥30 分轻中重。

数据集采样率标签形式适合任务
Apnea-ECG100 Hz逐分钟 A/N逐分钟分类、事件检测
SHHS125/250 HzPSG + AHI严重程度回归/分类
MESA256 HzPSG + AHI大样本验证

提示:先用 Apnea-ECG 跑通流程,再考虑上 SHHS 做外部验证。直接上大库,预处理没调好会浪费大量时间。

2.2 滤波、R 波检测与 RR 间期提取

原始 ECG 里有基线漂移、工频干扰、肌电噪声。标准处理链是:带通滤波(0.5-40 Hz 保留 QRS 形态)→ 去基线 → R 波检测 → 提取 RR 间期序列。R 波检测常用 Pan-Tompkins 或其变体,Python 里可以用wfdb配合neurokit2。

import wfdb import neurokit2 as nk import numpy as np # 读取一条 Apnea-ECG 记录 record = wfdb.rdrecord('a01', pn_dir='apnea-ecg') ecg = record.p_signal[:, 0] fs = record.fs # 100 Hz # 带通滤波 + R 波检测 signals, info = nk.ecg_process(ecg, sampling_rate=fs) r_peaks = info['ECG_R_Peaks'] # 提取 RR 间期(单位:秒) rr = np.diff(r_peaks) / fs

这段代码的逻辑是:ecg_process内部做了滤波、R 波定位和 quality 检查,返回的ECG_R_Peaks是 R 波采样点索引。np.diff得到相邻 R 波间隔,除以采样率转成秒。参数上,sampling_rate必须和实际一致,填错会导致 RR 全错;如果信号质量差,ecg_process可能漏检,建议加一步nk.ecg_quality做质量评估,低质量段直接丢弃而不是硬提。

RR 间期序列是后续所有心率变异性(HRV)特征的基础。注意:Apnea-ECG 是 100 Hz,R 波定位精度有限,RR 会有量化误差,做频域 HRV 时高频段(>0.15 Hz)要谨慎。

2.3 切窗策略:30 秒、60 秒还是逐分钟

切窗长度直接决定样本量和标签对齐方式。逐分钟标签对应 60 秒窗最自然,但 60 秒窗内可能只有部分时间有事件,标签会模糊。常见折中是 30 秒窗、重叠 50%,标签用窗中心时刻的分钟标签。窗太短(<15 秒)HRV 频域特征不可靠;窗太长(>120 秒)时间分辨率差,且样本数骤减。

我一般会做两套:一套 60 秒非重叠用于和逐分钟标签严格对齐,一套 30 秒重叠用于数据增强。切窗时同步记录每个窗的起始时间,方便后面聚合回整夜。

def sliding_window(rr, r_peaks, fs, win_sec=60, overlap=0.5): win_samples = int(win_sec * fs) step = int(win_samples * (1 - overlap)) windows = [] for start in range(0, len(r_peaks) - win_samples, step): end = start + win_samples seg_rr = rr[start:end-1] # RR 数比 R 波少 1 windows.append({ 'start_sample': r_peaks[start], 'rr': seg_rr }) return windows

这里win_sec和overlap是最关键的两个参数。overlap=0.5意味着相邻窗共享一半数据,样本量翻倍但样本间不独立,划分训练/验证集时必须按整夜记录划分,不能按窗随机划分,否则同一夜的窗会同时出现在训练和验证里,指标虚高——这是这个方向最常见的翻车点之一。

3. 特征工程与模型:从 HRV 到深度网络的两条路

3.1 时域、频域与非线性 HRV 特征

传统路线靠手工特征。RR 间期序列能算出的特征分三类:

时域:mean RR、SDNN(RR 标准差)、RMSSD(相邻 RR 差值的均方根)、pNN50(相邻 RR 差 >50ms 的比例)。这些反映整体心率和短时变异。

频域:对 RR 序列做 Lomb-Scargle 周期图(因为 RR 是非均匀采样),取 VLF(0.003-0.04 Hz)、LF(0.04-0.15 Hz)、HF(0.15-0.4 Hz)功率及 LF/HF 比值。OSA 事件时交感激活,LF/HF 通常升高。

非线性:样本熵、近似熵、去趋势波动分析(DFA)的 α1/α2。OSA 患者心率序列的复杂度会下降。

import numpy as np from scipy.signal import lombscargle def hrv_features(rr, fs_rr=4.0): # 时域 mean_rr = np.mean(rr) sdnn = np.std(rr, ddof=1) rmssd = np.sqrt(np.mean(np.diff(rr)**2)) pnn50 = np.mean(np.abs(np.diff(rr)) > 0.05) # 频域:RR 时间轴 + Lomb-Scargle t = np.cumsum(rr) freqs = np.linspace(0.003, 0.4, 256) ang = 2 * np.pi * freqs pgram = lombscargle(t, rr - mean_rr, ang) def band_power(lo, hi): mask = (freqs >= lo) & (freqs < hi) return np.trapz(pgram[mask], freqs[mask]) lf = band_power(0.04, 0.15) hf = band_power(0.15, 0.4) lf_hf = lf / hf if hf > 0 else 0 return [mean_rr, sdnn, rmssd, pnn50, lf, hf, lf_hf]

lombscargle的输入是时间点t、去均值后的 RR 值、角频率ang。用 Lomb-Scargle 而不是 FFT 是因为 RR 间期是非均匀采样的,直接 FFT 会引入误差。band_power用梯形积分算频带功率。参数上,频带边界按 Task Force 标准,但 100 Hz 采样的数据 VLF 段基本不可信,建议只用 LF/HF。

3.2 用 CNN/LSTM 直接吃 RR 序列

深度学习路线省去手工特征,直接把 RR 序列或 ECG 片段喂给网络。常见结构是 1D-CNN 提局部形态 + BiLSTM 抓时序依赖 + 全连接分类。输入可以是固定长度的 RR 序列(如 60 秒约 60-100 个点),也可以是小波变换后的时频图。

import torch import torch.nn as nn class OSANet(nn.Module): def __init__(self, input_len=100): super().__init__() self.conv = nn.Sequential( nn.Conv1d(1, 16, kernel_size=5, padding=2), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(16, 32, kernel_size=5, padding=2), nn.ReLU(), nn.MaxPool1d(2) ) self.lstm = nn.LSTM(32, 64, batch_first=True, bidirectional=True) self.fc = nn.Linear(128, 2) def forward(self, x): # x: (batch, 1, input_len) x = self.conv(x) x = x.permute(0, 2, 1) # -> (batch, seq, feat) x, _ = self.lstm(x) x = x[:, -1, :] # 取最后时刻 return self.fc(x)

Conv1d的kernel_size=5对应约 5 个 RR 间期的局部模式,padding=2保持长度。两层池化后序列长度缩到 1/4。BiLSTM 输出 128 维(双向各 64),取最后时刻进全连接。输入长度input_len要和切窗后的 RR 点数匹配,不够的补零、超出的截断。训练时类别不平衡(正常分钟远多于事件分钟)要用加权交叉熵或 focal loss。

3.3 逐分钟预测怎么聚合成 AHI

模型输出的是逐分钟概率,临床要的是 AHI。聚合规则直接影响灵敏度和特异度。最简单是阈值法:概率 >0.5 判为阳性分钟,统计阳性分钟数 × (60/窗长) / 总小时数。更稳的是做后处理:要求连续 N 分钟阳性才算一次事件,避免孤立误报。

def predict_ahi(minute_probs, threshold=0.5, min_consecutive=2): binary = (minute_probs > threshold).astype(int) # 找连续阳性段 events = 0 count = 0 for b in binary: if b == 1: count += 1 else: if count >= min_consecutive: events += 1 count = 0 if count >= min_consecutive: events += 1 total_hours = len(minute_probs) / 60 return events / total_hours

min_consecutive=2是经验值,能滤掉不少孤立误报。threshold要在验证集上调,不能拍脑袋定 0.5。注意 AHI 估计的误差在轻中度边界(AHI 5-15)最大,因为这部分本身标签就模糊。

4. 避坑与排查:这条链路里最容易翻车的五件事

4.1 按窗随机划分导致指标虚高

现象:验证集 AUC 0.95+,一换外部数据掉到 0.7。原因:同一整夜记录的窗被随机分到训练和验证集,模型记住了这个人的个体特征而非 OSA 模式。解决:按记录(subject-wise)划分,训练集和验证集的受试者完全不重叠。这是血泪经验,很多论文的漂亮指标都栽在这。

4.2 R 波漏检污染 RR 序列

现象:某些记录 RR 序列出现异常大的值(如 >2 秒),频域特征全乱。原因:信号质量差或滤波参数不当导致 R 波漏检,漏一个就产生一个超长 RR。解决:加 R 波校正,对 RR > 1.5 秒的段检查是否漏检,用nk.ecg_fixpeaks或自定义规则插值修正;同时用信号质量指标剔除低质量段。

4.3 频域特征在 100 Hz 采样下不可靠

现象:HF 功率和文献对不上。原因:100 Hz 采样对 R 波定位的量化误差约 10 ms,而 HF 段周期约 2.5-6.7 秒,影响相对小,但 VLF 段完全不可信。解决:只用 LF/HF,或对 RR 序列做插值重采样到 4 Hz 再算频域。别硬报 VLF。

4.4 类别不平衡让模型偏向多数类

现象:准确率 90% 但事件分钟全漏。原因:正常分钟占 80%+,模型学会全判正常。解决:训练时用类别权重、focal loss 或过采样;评估时看灵敏度和特异度,别只看准确率。AHI 回归任务则要看 MAE 和 Bland-Altman 一致性。

4.5 聚合规则和临床口径不一致

现象:模型逐分钟指标不错,但 AHI 估计和 PSG 差很多。原因:聚合时没考虑事件定义(如低通气需要血氧下降,单 ECG 看不到),或阈值/连续规则没调。解决:明确你的任务边界——单 ECG 只能近似,聚合规则要在验证集上按 AHI 分类(轻中重)的混淆矩阵来调,而不是只优化逐分钟 F1。

5. 进阶技巧:用 Bland-Altman 验证 AHI 一致性,别只报 AUC

逐分钟分类的 AUC 好看,不代表临床可用。真正有说服力的是 AHI 估计和 PSG 金标准的一致性分析。我一般会做三件事:一是画 Bland-Altman 图,看均值差和一致性界限;二是按 AHI 分级算混淆矩阵,重点看轻中度边界;三是做校准曲线,看预测概率是否可靠。

import numpy as np import matplotlib.pyplot as plt def bland_altman(pred_ahi, true_ahi): mean = (pred_ahi + true_ahi) / 2 diff = pred_ahi - true_ahi md = np.mean(diff) sd = np.std(diff, ddof=1) loa_upper = md + 1.96 * sd loa_lower = md - 1.96 * sd plt.scatter(mean, diff, alpha=0.5) plt.axhline(md, color='r', linestyle='--') plt.axhline(loa_upper, color='gray', linestyle=':') plt.axhline(loa_lower, color='gray', linestyle=':') plt.xlabel('Mean AHI') plt.ylabel('Pred - True AHI') return md, loa_upper, loa_lower

md是系统偏差,理想接近 0;loa是 95% 一致性界限,越窄越好。如果md明显偏离 0,说明模型系统性高估或低估,需要校准。这个分析比 AUC 更能说明「能不能用」。

另一个技巧是迁移学习:在 Apnea-ECG 上预训练,在 SHHS 上微调。因为不同数据集的采集设备和人群不同,直接跨库测试往往掉点,微调能拉回不少。微调时冻结前面的卷积层,只训 LSTM 和全连接,学习率调小到 1e-4 量级。

最后说个习惯:每次实验固定随机种子,记录数据划分、超参、聚合规则。这个方向的坑大多不在模型本身,而在数据泄漏和评估口径。我自己就吃过按窗随机划分的亏,指标漂亮了半个月,一换数据全崩,回头查才发现是划分问题。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进