心电信号分类与域自适应:小波去噪到一维CNN迁移学习
2026/9/19 15:27:52 网站建设 项目流程

简介:一篇围绕心电信号分类的机器学习方向毕业论文,适合生物医学工程、计算机及相关专业的学生作为毕业设计选题与写作参考。论文覆盖心电信号产生机理与特征、基于小波变换的噪声去除、时域/频域/时频域特征提取,以及支持向量机、随机森林、卷积神经网络和迁移学习等分类方法,并结合PTB、MIT-BIH等公开数据库给出实验分析,内容完整、章节清晰。资源为单个PDF文件,大小约8.34MB,便于直接阅读和打印,尤适合需要快速把握研究框架与算法流程的读者。当前已有294人学习下载,对于正在构思心电信号分类课题或撰写论文的学生具有较好的借鉴价值。

1. 心电信号分类:患者间范式下的准确率断崖

做心电信号分类的人基本都会撞上一堵墙:模型在自己切好的训练集、测试集上准确率漂亮得能发论文,一换到真实临床场景就立刻现原形。原因不是模型不够深,而是数据划分方式出了问题。如果训练集和测试集来自同一批病人,模型记住了每个人的波形习惯,准确率自然虚高;一旦按病人完全分开,不同个体之间的心电形态差异会直接把准确率拉下来一大截。这份毕业论文的主线恰好就是冲着这个痛点去的:先用小波阈值去噪把信号洗干净,再用专门设计的一维 CNN 做心肌梗死分类,最后用域自适应迁移学习处理跨病人的心律失常分类。适合正在做心电方向毕设、或者想把可穿戴心电算法往真实场景推的工程师读。

2. 心电信号去噪:小波阈值去噪的参数选择与复现

2.1 心电信号与噪声的频谱特征

心电信号是典型的低频低幅生理电信号,频率范围在 0.05~100Hz,最大幅值一般不超过 5mV。它的单个心动周期由 P 波、QRS 波群和 T 波组成,其中 QRS 波群是能量最集中、最容易被检测算法抓住的部分。P 波反映心房去极化,幅值很小;T 波反映心室复极化,频率比 QRS 低不少。这些波形特征直接决定了去噪算法的选择,因为不同噪声和有用信号在频带上是有重叠的。

噪声类型主要频段来源对波形的影响
基线漂移0.05~1Hz呼吸、电极移动整体基线上下缓慢浮动,影响 ST 段判断
工频干扰50/60Hz 及其谐波电力设备电磁耦合叠加细密纹波,掩盖 P 波细节
肌电干扰5~2000Hz肌肉颤动高频毛刺,与 QRS 高频分量混叠

基线漂移频率低,常规高通滤波器就能处理。麻烦的是肌电干扰,它的频带和 QRS 波群的高频分量有明显重叠,单纯用 FIR 滤波器会在去噪的同时把 QRS 波峰削掉。这也是为什么论文里选择小波变换而不是传统数字滤波:小波变换能把信号按频率分解到不同尺度,在时频两个维度上做局部处理,可以在保留 QRS 波形态的前提下去掉噪声。

2.2 心电信号去噪流程与数学原理

小波阈值去噪的思路分三步走。第一步,用离散小波变换把心电信号分解成多个尺度的小波系数。第二步,对除最粗尺度外的细节系数做阈值处理,噪声能量小、幅值低,对应的系数会被收缩或置零。第三步,用处理后的小波系数做逆变换重构信号。关键点在于阈值怎么定、用小波族的哪一种、分解到几层,这三个参数直接决定去噪效果。

论文里用的是 db5 小波,阈值函数采用软阈值方案。下面是完整的 Python 复现代码,基于 PyWavelets 库:

import pywt import numpy as np def wavelet_denoise_ecg(ecg, wavelet="db5", level=8, mode="soft"): # 多尺度分解:返回 [cA_n, cD_n, cD_{n-1}, ..., cD_1] coeffs = pywt.wavedec(ecg, wavelet, level=level) # 用最细尺度的细节系数估计噪声标准差(鲁棒估计) sigma = np.median(np.abs(coeffs[-1])) / 0.6745 # 通用阈值 VisuShrink:sigma * sqrt(2 * ln(N)) thr = sigma * np.sqrt(2 * np.log(len(ecg))) # 细节系数逐层做软阈值处理,最粗尺度(近似分量)不做处理 coeffs_thr = list(coeffs) coeffs_thr[1:] = [ pywt.threshold(c, thr, mode=mode) for c in coeffs[1:] ] # 重构去噪后的信号 return pywt.waverec(coeffs_thr, wavelet)

这段代码里有几个参数值得展开说。level=8指的是分解层数,采样率越高、信号越长,需要的分解层数越多。对于 360Hz 采样率的 MIT-BIH 数据,8 层分解能把最低频的近似分量压到 1Hz 以下,恰好把基线漂移隔离在最粗尺度上。sigma的估计用了中位数绝对偏差除以 0.6745,这是标准的鲁棒标准差估计方式,比直接算标准差更能抵抗异常尖峰干扰。mode="soft"表示软阈值,它对系数做收缩而不是硬性截断,重构出来的信号更平滑,不会在阈值边界产生人为的伪迹。硬阈值适合噪声能量特别低的情况,但对心电这种形态要求高的信号,软阈值更稳。

2.3 为什么用 db5 而不是 db2 或 db4

小波族的选择不是拍脑袋。db 系列小波是 Daubechies 小波族,dbN 里的 N 表示消失矩阶数。消失矩越高,小波越平滑,频域局部性越好,但支撑长度也更长,计算量增大。心电信号的 QRS 波群是一个陡峭的尖峰形态,需要足够高的消失矩才能有效捕捉细节,db2 这种支撑短的小波容易把 QRS 波的高频成分打散。

我在实际对比中常见的做法是同时跑 db4、db5、db6 三组实验,看重构信号的 QRS 幅值保持率。db5 在多数心电数据集上表现最均衡:它的滤波器长度是 10 个抽头,既能刻画 QRS 波形,又不至于因为支撑太长而引入相邻心拍的串扰。如果信号采样率高、噪声严重,可以往 db6 或 db8 方向调;如果信号本身就是干净的高质量采集数据,db4 够用且计算更快。论文里定 db5 是做了消融实验的,不是拍脑袋。

2.4 去噪效果的量化评估方法

视觉上波形变干净了不算数,需要量化指标。工程上最常用的三个指标是信噪比、百分比均方根差和均方误差:

指标公式说明
SNR (dB)10 * log10( sum(s^2) / sum((s - x_hat)^2) )越高越好,反映有用信号与噪声的比值
PRD (%)sqrt( sum((s - x_hat)^2) / sum(s^2) ) * 100百分比均方根差,越低越好
MSEmean((s - x_hat)^2)平均误差,越小越接近原始信号

其中 s 是原始无噪信号,x_hat 是去噪后的信号。如果做合成噪声实验,可以在干净信号加上已知噪声再评估。真实数据上没有 ground truth 可以参考,我一般用去噪前后 QRS 波幅值衰减率来判断:如果去噪后 R 波幅值下降了超过 5%,说明阈值设得太激进,把有用信号当成噪声削掉了,需要调低阈值或换更保守的阈值规则。

3. MI-Net:面向心肌梗死的一维 CNN 设计与关键参数

3.1 从 LeNet-5 借鉴了什么

论文里设计的 MI-Net 参考了 LeNet-5 的整体思路,但把二维卷积架构改成了一维时间序列版本。LeNet-5 的套路是"卷积提取局部特征 + 池化降维 + 全连接分类",这个骨架对心电信号依然适用,因为心电信号本质是一个时间序列上的局部模式识别问题,QRS 波的形态特征只和相邻几十个采样点相关,不需要往二维空间去投影。

LeNet-5 是 1998 年的网络,直接照搬肯定不行。它用的是 5x5 卷积核、2x2 池化,针对 32x32 灰度图像设计。心电信号经过心拍分割后是 1xN 的向量,卷积核天然要变成一维。还有一个关键差异是激活函数,LeNet-5 当时用 tanh,MI-Net 换成 ReLU 后收敛速度明显提升。具体的一维网络结构可以按下面的表来搭:

层名称核尺寸 / 步长输出通道输出长度说明
Conv1d_111 / 132与输入同长第一层用大核,覆盖一个完整 QRS 波宽度
BatchNorm1d + ReLU-32-加速收敛并抑制过拟合
MaxPool1d2 / 232输入的一半降采样,扩大感受野
Conv1d_27 / 164池化后同长捕捉心拍内部的形态细节
BatchNorm1d + ReLU-64--
MaxPool1d2 / 264前层一半保留高层语义信息
Conv1d_35 / 1128池化后同长更深层特征
AdaptiveAvgPool1d全局1281输出特征向量,替代 Flatten 防止参数量爆炸
Dropout0.5--随机失活,防止全连接层过拟合
Linear--num_classes输出心肌梗死概率

关键设计细节是中间的卷积核是递减的:11、7、5。第一层用 11 的大核是因为一个 QRS 波在 360Hz 采样率下大约持续 80~100ms,对应 30~36 个采样点,用 11 的核能覆盖到波的局部形态但不足以跨整个 QRS。越往后层特征越抽象,核尺寸逐步缩小,参数总量更好控制。

3.2 一维 CNN 的 PyTorch 实现

下面是我一般会用的 MI-Net 基础实现,输入是单导联心拍向量,形状为(batch, 1, seq_len)

import torch.nn as nn class MINet(nn.Module): def __init__(self, num_classes=2, seq_len=360): super().__init__() self.features = nn.Sequential( nn.Conv1d(1, 32, kernel_size=11, stride=1, padding=5), nn.BatchNorm1d(32), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2, stride=2), nn.Conv1d(32, 64, kernel_size=7, stride=1, padding=3), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2, stride=2), nn.Conv1d(64, 128, kernel_size=5, stride=1, padding=2), nn.BatchNorm1d(128), nn.ReLU(inplace=True), ) # 全局平均池化,把 (batch, 128, seq_len/4) 变为 (batch, 128, 1) self.pool = nn.AdaptiveAvgPool1d(1) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = self.pool(x) x = x.view(x.size(0), -1) return self.classifier(x)

这段代码在搭建时有几个容易踩的坑。第一层padding=5是 kernel_size 11 的一半,保证卷积后长度不变;如果你改了 kernel 尺寸,padding 要同步改成(kernel - 1) // 2,否则长对不齐。AdaptiveAvgPool1d(1)替代 Flatten 的原因是:两者数学上等价,但自适应池化对输入长度不敏感,即使你在推理阶段换了一个不同采样长度的心拍,模型也能跑,不会因为展开维度不匹配而报错。Dropout 只加在全连接层前,卷积层内部不涂,否则特征提取能力会被过度削弱——这一点是论文里没有细讲但实践中很重要的经验。

3.3 心拍分割与数据集划分

送入网络之前,原始长时心电记录要先切成单个心拍。常见做法是用 Pan-Tompkins 算法或者直接用现成库检测 R 峰位置,然后以 R 峰为中心截取固定长度的窗口。窗口太长会引入相邻心拍的干扰,太短则丢失 T 波和 ST 段信息。对于 360Hz 采样率,我一般取窗宽 360 个采样点,即 R 峰前 100 点、后 259 点,覆盖 P 波到 T 波结束的完整周期。如果是 500Hz 采样的数据,窗口相应扩到 500 点。

import numpy as np def segment_beats(ecg, r_peaks, fs=360, before=100, after=259): beats = [] for r in r_peaks: start = r - before end = r + after + 1 if start < 0 or end >= len(ecg): continue beats.append(ecg[start:end]) return np.stack(beats)

分割完之后的关键决策是数据集怎么划分。论文里强调了患者内范式和患者间范式的区别:患者内把同一病人的心拍随机分到训练和测试,这样测试集里会出现和训练集几乎一样的心拍;患者间则按病人 ID 分组,训练集和测试集的病人完全不相交。后者的难易程度完全不同。做毕设或写论文时要尤其注意这一点。如果目录里写了"患者间范式",实验就必须按患者 ID 划分,比如用GroupKFold而不是普通的train_test_split

3.4 卷积核尺寸与学习率的实测对比

配置验证集准确率是否过拟合备注
单层大核 21较低轻微感受野太大,局部形态特征被平均化
多层小核 3/3/3中等明显参数少但感受野不够,需要更深网络
递减核 11/7/5可控和 QRS 形态尺度匹配,参数量适中
递增核 5/7/11中等明显浅层特征分辨率不足,梯度反向传播不畅

优化器方面,Adam 是默认选项,初始学习率 1e-3 通常能稳定收敛。如果训练过程中 loss 出现震荡,把学习率降到 3e-4 或 1e-4 就行。需要留一个早期停止的机制,监控验证集 loss,连续 10 个 epoch 不下降就保存当前最佳模型并退出,这能省去手动调 epoch 数的麻烦。

4. 域自适应迁移学习:MK-MMD 与条件熵的最小化

4.1 患者间范式为什么需要迁移学习

患者内范式下,CNN 学到的是个体特异的波形模式,泛化性能被高估。患者间范式下,训练集和测试集来自不同病人,不同个体之间的心电信号形态存在明显差异——体型、心率、心脏位置都会影响体表电位分布,这导致源域和目标域的概率分布不一致。直接硬训一个分类器,模型会倾向于记住源域病人的形态特征,在目标域病人上表现不佳。

一种思路是直接用目标域的少量数据做微调,这就是常见的参数迁移。但微调需要目标域有标注数据,这在医疗场景里很难获得。论文里采用的域自适应方案不需要目标域标注,它让网络在训练时同时看源域(有标注)和目标域(无标注)的数据,通过约束两个域在特征空间上的分布距离来消除域差异。这个思路和现在大模型领域说的"无监督域自适应"是同一套方法论。

4.2 深度域自适应网络的整体结构

网络的骨干是第 3 章的 CNN 特征提取器,前端加一个分类器,特征提取器输出的特征同时流入两个分支:一个分支计算源域的分类损失,另一个分支计算源域和目标域特征之间的 MK-MMD。目标域数据不参与分类损失计算,只参与分布对齐。

训练的总损失函数可以写成:

L = L_task + lambda * L_mmd + beta * L_entropy

其中 L_task 是源域上的交叉熵分类损失,L_mmd 是源域和目标域特征的最大均值差异,L_entropy 是目标域上的条件熵。后两项需要展开讲一下,因为它们是这个病结构里最容易写错的地方。

4.3 MK-MMD 的 PyTorch 实现

最大均值差异(MMD)衡量的是两个分布在高维再生核希尔伯特空间中的距离。MK-MMD 是它的多核版本,用多个不同带宽的 RBF 核做线性组合,能捕捉不同尺度上的分布差异。对应代码实现的关键在于 RBF 核矩阵的计算不能把 batch 里的样本全混在一起算,要分别算源域内、目标域内、跨域三部分。

import torch def rbf_kernel(x1, x2, sigmas): """多带宽 RBF 核,返回核矩阵列表""" result = [] for sigma in sigmas: # ||x1 - x2||^2 的快速计算:a^2 + b^2 - 2ab xx = torch.sum(x1**2, dim=1, keepdim=True) yy = torch.sum(x2**2, dim=1, keepdim=True) dist = xx - 2.0 * torch.mm(x1, x2.t()) + yy.t() result.append(torch.exp(-dist / (2.0 * sigma**2))) return result def mk_mmd_loss(x_src, x_tgt, sigmas=[1, 5, 10, 20, 40, 80]): kernels_ss = rbf_kernel(x_src, x_src, sigmas) kernels_tt = rbf_kernel(x_tgt, x_tgt, sigmas) kernels_st = rbf_kernel(x_src, x_tgt, sigmas) n = x_src.size(0) m = x_tgt.size(0) loss = 0.0 for ks, kt, kst in zip(kernels_ss, kernels_tt, kernels_st): # E[k(x_s, x_s')] - 2E[k(x_s, x_t)] + E[k(x_t, x_t')] mmd = ks.sum() / (n * n) - 2.0 * kst.sum() / (n * m) + kt.sum() / (m * m) loss += mmd return loss

实现这段代码有两个容易踩的坑。第一个是对角线项没有剔除,导致 MMD 值偏大、分布距离永远不为零,但实践中只要所有 batch 都这么算,梯度方向依然正确,不影响训练。第二个是带宽集合的选择,六组带宽[1, 5, 10, 20, 40, 80]覆盖了从细粒度到粗粒度的分布差异,如果特征维度高可以再往 100、200 扩展;如果损失值不下降,优先检查是不是带宽范围取小了。

4.4 条件熵最小化与低密度分离

MK-MMD 只能把源域和目标域的整体特征分布拉近,但它不保证类别边界对齐。换句话说,两个域的特征可能在全局分布上看着重合了,但具体的类别簇还是错位的。论文里的解决办法是最小化目标域的条件熵,即让目标域样本的分类输出趋近于 one-hot。

def conditional_entropy(logits_tgt): """目标域条件熵:预测置信度越高,熵越低""" prob = torch.softmax(logits_tgt, dim=1) # 加 1e-8 防止 log(0) 出现 NaN entropy = -(prob * torch.log(prob + 1e-8)).sum(dim=1).mean() return entropy

条件熵的作用机理是:当目标域样本的预测概率分布越尖锐、越接近某个类别时,熵值越小。最小化这个熵等价于推动分类边界往低密度区域移动,强迫网络对目标域样本给出"自信"的预测。这样做的前提是目标域和源域共享类别集合——在这个场景里,两个域都包含相同类型的心律失常类别,只是具体波形形态有差异。

总损失要平衡好三项的权重。我一般从 lambda = 0.5、beta = 0.1 起步:MK-MMD 承担主要的域对齐任务,权重给高一点;条件熵只做边界调整,权重过大会导致目标域预测变得激进。训练时先冻结分类器层,只更新特征提取器 5 个 epoch,让 MK-MMD 先把分布拉近,再解冻所有层联合训练,这个两阶段流程比端到端训练收敛更稳定。

4.5 训练配置的参数参考

参数推荐值说明
batch size128(源域 64 + 目标域 64)两个域数据等量混合采样
学习率1e-3,每 20 个 epoch 衰减 0.5配合 Adam 优化器
lambda (MK-MMD 权重)0.5过大导致特征过度对齐,损失分类信息
beta (条件熵权重)0.1过大会让目标域预测过于自信
epoch50 ~ 80配合早停策略
优化器Adam, betas=(0.9, 0.999)默认参数即可

5. 患者间范式验证与实验配置的排错技巧

5.1 GroupKFold 的正确打开方式

做患者间范式评估时,不能直接用train_test_split随机切分,否则同一个病人的心拍会同时出现在训练集和测试集里,实验结果虚高。正确做法是用GroupKFold,把病人 ID 作为分组依据:

from sklearn.model_selection import GroupKFold # beats: (n_beats, seq_len) 的心拍矩阵 # patient_ids: 每个心拍对应的病人 ID # labels: 每个心拍的类别标签 gkf = GroupKFold(n_splits=5) for train_idx, test_idx in gkf.split(beats, labels, groups=patient_ids): # train_idx 和 test_idx 里的病人完全不相交 x_train, x_test = beats[train_idx], beats[test_idx] y_train, y_test = labels[train_idx], labels[test_idx] # 训练模型并记录指标

这里有个容易被忽略的细节:GroupKFold不保证每个 fold 的类别比例和全局一致。比如折叠划分后某个 fold 的测试集里全是某一种心律失常,另一类一个样本都没有,那么模型在该 fold 上可能输出单一类别。建议在每个 fold 训练前打印训练集和测试集的类别分布,若出现严重不平衡就换随机种子重新划分,或者在损失函数里加类别权重处理不平衡问题。

5.2 从指标到调参的排查路径

如果你跑出来的结果比论文里的差,不要第一反应加模型复杂度。按下面的顺序排查:

  1. 先确认数据划分是否真的是患者间。检查训练集的病人 ID 集合和测试集是否交集为空,这一步最容易出错。
  2. 再查去噪环节。去噪后 R 波幅值如果被明显削减,后续分类器拿到的特征质量会断崖式下降。
  3. 看训练曲线。如果训练 loss 收敛但验证集准确率不涨,过拟合可能性大,增大 Dropout 概率或减小模型宽度。
  4. 如果是域自适应方案,单独打印 L_task、L_mmd、L_entropy 三个 loss。L_task 不降说明基础分类能力都不够;L_mmd 不降说明特征提取器表达能力不足,需要加深网络;L_entropy 不降说明目标域预测太模糊,把 beta 调大些。

5.3 领域自适应训练的一个实用技巧

域自适应训练中最常见的不稳定因素是源域和目标域 batch 数量不一致。如果目标域数据量远大于源域,每个 step 里 MMD 损失会被目标域样本主导,导致源域分类精度被牺牲。

推荐做法是每个训练 step 固定从两个域各采样等量样本。如果源域小,就在每个 epoch 里重复采样让源域和目标域的样本量一致。深度学习框架里用WeightedRandomSampler就能实现,或者手动构造两个 DataLoader 后按索引交替取 batch。这一步虽然不起眼,但对域自适应训练的稳定性提升非常明显,比调 lambda 权重有效得多。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询