心电信号的预处理,这事听着不如模型结构、域泛化算法那么“高大上”,但只要你真正拿多中心、可穿戴设备采集的心电数据跑过一遍训练,你就会明白:预处理做不好,后面所有花里胡哨的对抗训练、元学习、因果特征抽象全都等于在垃圾堆上盖高楼。这篇是这个“心电域泛化研究从0入门系列”的第二篇,咱们不聊虚的,就聚焦在心电信号本身,把预处理这条路上所有的坑和该做的步骤一次说透。
先说清楚一个概念:为什么预处理对域泛化格外重要?心电(ECG)信号本身非平稳、噪声类型多、个体差异大,而且不同采集设备、不同导联体系、不同采样率带来的分布偏移,绝大多数都体现在信号的低层特征上,也就是噪声、基线漂移、幅值尺度这些“非语义”信息上。域泛化追求的是模型学到跨域稳定的语义特征,而不是记忆某个设备、某个医院的“指纹”。预处理的核心职责,就是把这些设备指纹和采集环境差异尽量抹平,让模型在源域上学到的东西能更干净地迁移到目标域。
1. 心电预处理的整体思路:先搞清楚你面对的是什么“域差异”
1.1 心电信号里藏着的“域”到底指什么
很多刚接触域泛化的人,会把“域”单纯理解成数据集来源不同,比如MIT-BIH、PTB、可穿戴设备自采数据。但真正落到信号层面,域差异是可以被物理量化的。我从实际处理经验出发,把常见的心电信号域差异归为四类:
- 采样率差异:MIT-BIH是360Hz,PTB是1000Hz,很多可穿戴设备只有125Hz或250Hz。这直接决定了你能看到的频带上限,也决定了R峰定位的精度。
- 幅值尺度差异:标准12导联动态心电的幅值通常在0.5mV到2mV,但可穿戴单导联设备可能只有0.1mV级别的微弱信号,而且同一设备内不同受试者之间幅值差异也极大。
- 噪声构成差异:医院环境有稳定的50Hz/60Hz工频干扰和电极接触噪声,运动场景则主要是肌电干扰和剧烈基线漂移,不同域之间的噪声类型和强度完全不同。
- 导联体系差异:标准12导联、Einthoven三导联、单导联贴片,导联位置不同直接导致P波、T波形态、QRS波群形态都有系统差异。
把域差异看清楚了,才不会犯“一上来就调一个万能滤波器”这种错误。预处理的所有步骤,本质上都是在和这四类差异做对抗。
1.2 预处理的分工:不是“越干净越好”
在做预处理之前,我建议所有刚入门的朋友先建立一个观念:预处理的目标不是把信号修成教科书级别的完美波形,而是让不同来源的信号在特征分布上尽量对齐。过度滤波会把ST段压低、把T波形态改变,这是医学AI里最忌讳的事。
我在实际项目中通常把预处理分成两个阶段:一个是“信号级校正”,包括重采样、滤波、基线校正、R峰定位和心拍分割;另一个是“样本级标准化”,包括归一化、数据增强和样本筛选。前者解决信号物理层面的问题,后者解决数据分布层面的问题。两个阶段缺一不可,但很多开源代码往往只做了前者,导致模型遇到新的采集设备就垮掉。
2. 核心预处理步骤逐项拆解:参数怎么定、为什么这么定
2.1 重采样:先把所有数据放到同一个时间基准上
多中心数据集最常见的问题就是采样率不一致。你不能让模型一会儿看到360Hz的序列,一会儿看到250Hz的序列,序列长度和R峰宽度在不同采样率下语义完全不同。
重采样的目标采样率怎么选?我一般建议以任务需求为基准。如果只做心率估计或R峰检测,128Hz到250Hz足够了;如果要做精细的波形形态分析(比如ST段分析),至少需要500Hz。域泛化研究里还要考虑一点:目标采样率不能太高,否则数据量膨胀导致训练变慢,也不能太低,否则QRS波群的形态特征会被抹平。我个人做跨数据集域泛化时,喜欢统一到250Hz,这个频率既能捕获心电的主要形态特征,又不会让序列长度过长。
重采样本身有讲究,不能直接resample了事。一定要先用抗混叠低通滤波器,再插值。比如你用scipy.signal.resample_poly,它会自动做抗混叠处理,这是相对安全的选择。如果是直接从硬件读出的原始数据,先看一下有没有硬件抗混叠滤波,没有的话就要自己补。
2.2 滤波:工频干扰、基线漂移、肌电干扰“三部曲”
心电信号里最常见的三类噪声,处理手段完全不同,顺序也不能乱。
- 工频干扰,50Hz或60Hz。这个用陷波器(Notch filter)处理最直接,带宽设窄一点,比如49.5Hz到50.5Hz,不要用宽带的带阻滤波器,那会把QRS波群的高频成分一起干掉。
- 基线漂移,频率通常在0.5Hz以下,主要来自呼吸和电极移动。处理手段是高通滤波(截止频率设0.5Hz到1Hz),或者用中值滤波做基线估计再相减。高通滤波更简单,但如果ST段分析是重点,0.5Hz的截止频率可能会影响ST段的低频分量,这时候建议改用中值滤波法。
- 肌电干扰,频率主要在30Hz到300Hz,叠加在信号上呈现为高频毛刺。用低通滤波处理,截止频率通常在45Hz到100Hz之间。但这里有个矛盾:QRS波群本身也有高频成分,低通滤波越狠,QRS波峰会越平滑,R峰定位反而可能变偏。我在实际项目中,如果后续要做R峰检测,低通截止频率不会低于75Hz。
滤波器的种类选择上,我比较推荐Butterworth滤波器,通带平坦、过渡带窄,相位失真小。但要特别注意零相位滤波,也就是用filtfilt做双向滤波,否则滤波引入的相位延迟会直接导致R峰位置偏移几十毫秒,这在后续心拍对齐时会产生很大误差。
这三步的固定顺序应该是:先陷波(工频),再高通或中值滤波(基线),最后低通(肌电)。顺序反了,比如先低通再高通,会先把高频肌电滤掉,然后再做高通的时候,已经处理过的信号里,基线漂移和工频干扰的频率成分还混在一起,滤波效果会大打折扣。
2.3 R峰检测与心拍分割:域泛化里最关键的“对齐”环节
心拍分割是预处理里对域泛化影响最深的一步。因为后续不管是做分类、回归,还是表征学习,绝大多数方法都是基于某个固定长度的信号片段。这个片段怎么切,直接决定模型看到的是什么样的“局部结构”。
业界最规范的切割方式,是以R峰位置为锚点,取R峰前N个采样点和后M个采样点,构成一个固定长度的窗。比如在250Hz下,单心拍窗长通常取0.6秒到1秒,也就是R峰前0.2秒、后0.5秒左右。这样能覆盖一个完整的PQRST形态,又不会把上一个和下一个心拍掺进来。
R峰检测的算法,如果不想从零写,可以直接用neurokit2的ecg_peaks函数,它封装了Pan-Tompkins算法的变体,对不同类型的噪声都有一定的鲁棒性。但要注意,不同域的数据,R峰检测器的参数可能要做微调。MIT-BIH这种信噪比高的数据,默认参数就能跑得很好;到了剧烈运动场景的可穿戴数据,就需要先做一次较强的带通滤波(5Hz到30Hz),再用自适应阈值检测。
分割之后还有一个经常被忽视的问题:样本筛选。不是每个切出来的心拍都能直接用。我在项目里会做三步筛选:
- 异常幅值筛选:峰峰值超出群体中位数3倍以上的样本丢弃,这些通常是运动伪迹或电极脱落。
- 模板相关性筛选:同一个导联下,把所有心拍和一个中值模板做相关系数计算,低于0.7的样本单独存一个子集,不进训练主循环。
- 心率异常筛选:RR间期过短(小于600ms)或过长(大于1200ms)的片段优先检查,可能是检测错误,也可能是真实的心律失常样本,要结合标签处理。
这一步筛选看似暴力,但对于域泛化来说,它的作用是极大的:去掉那些因为噪声形态而过于“特异”的样本,等于消除了域里最不稳定的那一部分分布。
2.4 归一化:抹平幅值域差异的最直接手段
归一化的方法很多,常用的有Z-score归一化、最大值最小值归一化、单位方差归一化,但在心电域泛化里,选择要比方法本身更重要。
先解释一下为什么归一化直接关系到域泛化:不同设备的增益、电极阻抗不同,同一份心电信号在不同设备上记录的幅值可能相差数倍。如果模型在源域上学习的是“幅值0.5mV对应正常,1.5mV对应异常”,那到了目标域,幅值整体缩放,模型直接被带偏。
我推荐使用的是基于整段信号统计的Z-score归一化,也就是对每个受试者或每段连续信号,用其中位数和MAD(绝对中位差)做鲁棒标准化,而不是用均值和标准差。原因在于心电信号里QRS波群的幅值远大于P波和T波,普通Z-score会被QRS主导,而MAD更抗离群值干扰。这个方法实测下来,在跨设备场景下的稳定性比min-max和普通Z-score都要好。
有一个很关键的细节:归一化的统计量必须在“单个样本片段”上计算,还是在“整段连续信号”上计算?我的经验是分阶段。在信号级阶段,用整段信号的中位数和MAD做归一化,保证整段信号内部的相对形态不变;等切完心拍之后,再做一次逐心拍的归一化,让每个样本的尺度统一。两层归一化叠加,跨域的泛化效果会有明显的提升。
3. 实操全流程:从原始信号到可训练样本的完整代码实现
3.1 直接可用的ECG预处理流程(Python)
这节我给出一个可以直接跑通的流程,用的是WFDB库读取MIT-BIH格式的数据,然后把我们前面讨论的所有步骤串起来。我不追求代码极简,重点是每一步的注释和前后顺序,你好理解每一个环节的位置。
import numpy as np from scipy import signal import wfdb from neurokit2 import ecg_peaks # 第一步:读入原始信号和数据元信息 record = wfdb.rdrecord('path/to/record', channels=[0], physical=True) raw_signal = record.p_signal[:, 0].astype(np.float64) fs = record.fs # 原始采样率 # 第二步:重采样到统一目标采样率250Hz target_fs = 250 if fs != target_fs: raw_signal = signal.resample_poly(raw_signal, target_fs, fs, axis=0) fs = target_fs # 第三步:滤波,顺序不能乱 # 3.1 工频陷波,50Hz,带宽±0.5Hz b_notch, a_notch = signal.iirnotch(50, 30, fs) filtered = signal.filtfilt(b_notch, a_notch, raw_signal) # 3.2 去除基线漂移,高通1Hz,Butterworth四阶 b_hp, a_hp = signal.butter(4, 1, btype='high', fs=fs) filtered = signal.filtfilt(b_hp, a_hp, filtered) # 3.3 肌电低通,截止频率75Hz b_lp, a_lp = signal.butter(4, 75, btype='low', fs=fs) filtered = signal.filtfilt(b_lp, a_lp, filtered) # 第四步:整段信号鲁棒归一化 median = np.median(filtered) mad = np.median(np.abs(filtered - median)) + 1e-8 filtered = (filtered - median) / mad # 第五步:R峰定位 peaks = ecg_peaks(filtered, sampling_rate=fs)['ECG_R_Peaks'] # 第六步:以R峰为中心分割心拍 before = int(0.2 * fs) # R峰前0.2秒 after = int(0.5 * fs) # R峰后0.5秒 segments = [] labels = [] for peak in peaks: if peak - before < 0 or peak + after >= len(filtered): continue segment = filtered[peak - before:peak + after] segments.append(segment) segments = np.array(segments) # 第七步:逐样本二次归一化 for i in range(segments.shape[0]): seg = segments[i] seg = (seg - np.median(seg)) / (np.percentile(seg, 95) - np.percentile(seg, 5) + 1e-8) segments[i] = seg这套流程跑完,拿到的segments数组就是可以直接喂给模型的样本。我强烈建议你把这段代码封装成一个类或者一个函数,数据集换一个,只要改文件路径就能跑通。
3.2 每个参数为什么要这么设
- 目标采样率250Hz:这是一个“中庸”的选择。学术界大量公开数据集用的就是250Hz,比如MIMIC-III的WFDB版本和不少可穿戴设备数据,统一到250Hz能最大化保持和公共数据集的兼容性。
- 工频陷波带宽30:这个参数是陷波器的品质因数,越大带宽越窄。30对应的3dB带宽大概是50/30≈1.67Hz,既能有效抑制工频泄漏,又不会把有用的高频成分切太多。
- 高通1Hz和低通75Hz:这个组合在ST段分析不受严重影响的前提下去掉了大部分非生理伪迹。如果你要侧重分析P波的细节,可以把低通上限降到45Hz;如果你要做QRS波群形态学分析,可以考虑提到100Hz。
- 用median + MAD不用mean + std:心电信号不是高斯分布,mean和std会被QRS波群主导,而median和MAD天然抵抗这些极端幅值。
3.3 大功告成之前,必须验证的一步
预处理写完,千万别直接拿去训练。我会先做一次可视化验证:随机挑10个源域样本和10个目标域样本,把波形画出来放在同一张图里,检查形态是否合理。这一步不是为了检查滤波干净不干净,而是检查有没有把某个域的R峰削掉、有没有把T波滤平、有没有因为重采样产生混叠伪影。我见过不少新手把高通滤波截止频率设到5Hz,结果T波全被削成了“矮胖版”,模型精度看着没降多少,但梯度对T波形态的响应已经乱了,在跨域时这个小偏差会被放大。
4. 常见问题与排查技巧实录
4.1 滤波后的波形出现“振铃”或R峰卷边
这是Butterworth滤波器阶数太高、或陷波器带宽太窄导致Gibbs现象,信号在QRS波群等陡峭跳变的地方产生过冲振荡。如果只是在滤波后的边缘出现几毫秒的振荡,可以用filtfilt缓解;如果整段信号都出现,说明滤波器阶数要降。我从6阶降到4阶,代价是过渡带变宽了一些,但振铃几乎消失了。
4.2 不同域的数据长度差异巨大
有的数据集是10秒长记录,有的是30秒,还有的是动态心电的几十个小时。在统一分割成心拍片段前,我建议先做一个“片断级筛选”:只保留长度足够、信号质量达标的连续片段,再做心拍分割。不要让模型去处理那些只有1秒的短片段,它们根本没包含足够的心拍信息,送入模型只会增加噪声。
4.3 整段归一化之后,部分心拍的幅值仍然偏小
这通常发生在受试者偶发性的低幅值时段,比如心率变化导致T波幅值周期性变化。处理办法是不要只依赖整段统计,那个二次归一化很重要,它会让每个样本在进入模型之前都被“局部校准”一次。但二级归一化有个副作用:会把极低信噪比样本的噪声也放大。解决方案是把峰峰值低于阈值的样本直接丢弃,这部分样本没有训练价值。
4.4 跨数据集测试时R峰检测失败率飙升
如果你在一个医院的数据集上训练的R峰检测器,直接换到另一个医院的原始数据上,准确率下降10到20个百分点是常事。解决办法一个是先做一次轻度的带通滤波,5Hz到20Hz,只保留QRS波群能量最集中的频段,再做峰值检测,然后把检测结果映射回原始滤波后的信号上。这样做的本质是:用一个鲁棒性很强的窄带信号做定位,用宽带信号做形态保留,实践证明这个小技巧的收益非常大。
4.5 数据增强怎么用才不影响域泛化
在分割完心拍之后,轻度的时域扭曲和幅值扰动是可用的增强手段。但我想特别提醒:在心电域泛化里,不要做“全局高斯噪声叠加”这种增强,原因很简单,高斯噪声和真实心电噪声的频率结构完全不同,你教会模型去忽略高斯噪声,它不一定能忽略运动伪迹。要加噪声,就从其他域的原始噪声记录中提取实际的噪声段来叠加,这样模拟出的分布是真噪声分布。
5. 预处理之后的最后一步:域泛化建模前的特征与数据组织形式
预处理完,数据还不是马上就能扔进模型。对于域泛化研究,数据组织形式和特征处理方式和普通监督学习不太一样。我建议在把数据交给模型前,额外做这样几件事:
- 按域(数据集/设备)分组保存样本索引,而不是把所有样本混在一起。域标签在训练时做域对抗或者域自适应都是必要的。
- 记录每个样本的原始采样率、设备ID、受试者ID。这些元信息在处理域偏移分析和结果分组评估时都会用到。
- 做一次简单的域间特征分布对比,比如画一下不同域的样本幅值分布直方图、RR间期分布图。如果预处理到位,你会发现这些非语义特征的分布已经基本重合了,这才是预处理成功的标志。
还有一个额外的经验:我在实际做域泛化模型时,会把预处理模块设计成可微分或者是固定函数,而不是把它塞进模型里让模型自己学。如果让模型自己学滤波,它大概率会学到数据源相关的捷径特征,反而让域泛化能力下降。预处理应该是确定性的、和模型无关的、对任何域一致的信号处理方法。
心电域泛化和普通的心电分类任务相比,多出来的工作量主要就在预处理阶段。每个人在进模型之前多花几天把数据洗干净,统一格式、统一滤波、统一归一化、统一分割,模型在目标域上的表现会稳定很多。我个人的体会是:预处理至少能贡献30%以上的域泛化性能增量,而且这部分增量完全是白捡的,因为模型本身的成本一点没增加。系列后续会把单导联和多导联域泛化模型的设计思路拆开细讲,但预处理这一步,值得你反复打磨,它几乎决定了整个项目的地基稳不稳。