简介:这份PDF文献面向生物医学工程、深度学习与心电信号处理方向的研究者及学生,聚焦心律失常的自动分类问题。资源为单篇论文,共1个PDF文件,压缩包约1.21MB,内容涵盖卷积神经网络对心电信号与QRS波群小波分量的特征提取,并将网络特征与人工提取的RR间期特征在全连接层融合,最终经softmax输出完成心拍分类。文中基于MIT-BIH心律失常数据库MIL L导联数据训练测试,给出总体分类准确度98.12%、平均灵敏度87.32%、平均阳性预测值90.37%等量化结果,并讨论了单导联、特征提取与融合、计算机辅助诊断等关键环节。读者可借此理解CNN自动学习特征替代人工设计的思路,掌握多源特征融合提升泛化能力的建模方法,并获取可复现的实验指标与评估框架。目前已有202人学习,适合作为心律失常分类课题的入门参考与方案对照。
1. 从一段心电信号说起:为什么心律失常分类值得用 CNN 做特征提取与融合
拿到一段 30 秒的 Holter 心电记录,里面可能混着基线漂移、工频干扰、肌电噪声,而真正决定诊断的那几个异常搏动,往往只占几百毫秒。传统做法是先做 R 波检测、再手工算 RR 间期、QRS 宽度、形态学描述子,最后丢给 SVM 或随机森林。这套流程在干净数据上能跑,但一旦导联位置偏移、患者个体差异大,手工特征就开始集体翻车。卷积神经网络特征提取与融合这套思路,本质上是把「特征长什么样」这件事交给网络自己学,再把不同尺度、不同导联、不同模态的特征拼在一起做心律失常分类。它适合两类人:一类是手里有 MIT-BIH、CPSC、PTB 这类公开心电数据集,想跑一个能复现的 baseline;另一类是做可穿戴心电设备,需要在有限算力下把分类精度再往上顶几个点。这篇笔记就按「信号怎么进网络 → 特征怎么提 → 特征怎么融 → 坑在哪」的顺序,把这条链路拆开讲清楚。
2. 心电信号进 CNN 之前:预处理、分段与标签对齐
2.1 为什么原始心电不能直接喂给卷积网络
心电信号采样率常见 360 Hz(MIT-BIH)或 500 Hz(CPSC),单条记录长度从几十秒到几十分钟不等。直接把整段信号塞进 CNN,会碰到三个问题:一是长度不固定,全连接层没法接;二是不同记录幅值差异大,有的导联 mV 级,有的被噪声淹没;三是心律失常事件是局部现象,整段信号里正常搏动占绝大多数,直接整段分类会被正常类主导。
常见做法是切成固定长度的窗口。窗口长度一般取 2 到 10 秒,或者按单个心拍切。按心拍切需要先做 R 波检测,常用 Pan-Tompkins 或其变体;按固定窗口切则不需要检测,但标签对齐要小心。我一般会先做 0.5–40 Hz 带通滤波去基线漂移和高频噪声,再做 z-score 归一化,让每条记录零均值单位方差。
import numpy as np from scipy.signal import butter, filtfilt def bandpass_filter(signal, fs=360, low=0.5, high=40, order=4): # 心电有效频带大致在 0.5-40 Hz,超出部分多为噪声 nyq = 0.5 * fs b, a = butter(order, [low / nyq, high / nyq], btype='band') return filtfilt(b, a, signal) def zscore_normalize(signal): # 逐记录归一化,避免不同患者幅值差异影响网络 return (signal - np.mean(signal)) / (np.std(signal) + 1e-8) def segment_signal(signal, fs=360, win_sec=5, stride_sec=2): # 滑动窗口切分,win_sec 窗口长度,stride_sec 步长 win = int(fs * win_sec) stride = int(fs * stride_sec) segments = [] for start in range(0, len(signal) - win + 1, stride): segments.append(signal[start:start + win]) return np.array(segments)这段代码里low=0.5和high=40是心电处理的常规边界,低于 0.5 Hz 的基线漂移会被滤掉,高于 40 Hz 的肌电和工频残留也会被压下去。filtfilt做零相位滤波,避免滤波本身引入时间偏移。win_sec=5和stride_sec=2是经验值,窗口太短会丢上下文,太长则一个窗口里混多个心拍类型,标签不好打。如果做的是单心拍分类,窗口可以缩到 0.6–1 秒,步长等于窗口长度,不重叠。
2.2 标签对齐:AAMI 五类还是原始类别
MIT-BIH 的标注是逐拍的,符号有 N、L、R、V、A、F、j 等十几种。直接拿原始符号做多分类,类别极不平衡,而且很多类样本极少。AAMI EC57 标准把它归成五类:N(正常及其他)、S(室上性异位)、V(室性异位)、F(融合搏动)、Q(未知搏动)。做心律失常分类时,我建议先按 AAMI 五类归并,再根据任务需要决定是否合并 Q 类。
标签对齐的关键是:如果按固定窗口切,窗口内可能包含多个心拍,标签取窗口中心位置对应的心拍标签,或者取窗口内多数心拍的标签。如果按心拍切,就以 R 波位置为中心,前后各取固定长度。两种方式没有绝对优劣,按心拍切更干净,但依赖 R 波检测精度;按窗口切更鲁棒,但标签噪声更大。
提示:MIT-BIH 里 102、104、107、217 这四条记录常被用作测试集,因为包含较多室性异位搏动,能检验模型对少数类的识别能力。
3. 卷积神经网络特征提取:从一维卷积到多尺度特征图
3.1 一维 CNN 提取心电形态特征的原理
心电信号是一维时间序列,用一维卷积核在时间轴上滑动,每个卷积核学到一个局部波形模式。浅层卷积核感受野小,学到的是 QRS 波上升沿、T 波下降沿这类短时形态;深层卷积核感受野大,学到的是几个心拍之间的节律关系。这跟图像 CNN 从边缘到纹理到物体的层次化特征提取是一个道理,只不过心电的「纹理」是时间轴上的波形。
一个典型的一维 CNN 特征提取块长这样:卷积层 → 批归一化 → 激活函数 → 池化层。卷积核数量逐层翻倍,常见 32、64、128、256。卷积核大小第一层取 5 到 11,后面逐层减小到 3。池化用最大池化,窗口 2 到 5,步长等于窗口,把时间轴逐步压缩。
import torch import torch.nn as nn class CNNFeatureExtractor(nn.Module): def __init__(self, in_channels=1): super().__init__() # 第一层:大卷积核捕捉 QRS 等短时形态 self.block1 = nn.Sequential( nn.Conv1d(in_channels, 32, kernel_size=11, padding=5), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(kernel_size=2, stride=2) ) # 第二层:中等卷积核捕捉 P 波、T 波细节 self.block2 = nn.Sequential( nn.Conv1d(32, 64, kernel_size=7, padding=3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(kernel_size=2, stride=2) ) # 第三层:小卷积核捕捉节律变化 self.block3 = nn.Sequential( nn.Conv1d(64, 128, kernel_size=3, padding=1), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(kernel_size=2, stride=2) ) def forward(self, x): x = self.block1(x) x = self.block2(x) x = self.block3(x) return x # 输出形状 [batch, 128, time_steps]kernel_size=11在第一层对应约 30 ms 的时间窗(360 Hz 采样下),刚好覆盖 QRS 波的主要成分。padding取kernel_size // 2是为了保持时间轴长度不变,只在池化时压缩。BatchNorm1d放在卷积和激活之间,能加速收敛,对心电这种幅值波动大的信号尤其有用。三层之后时间轴压缩了 8 倍,5 秒窗口从 1800 点变成 225 点,特征图通道数从 1 变成 128。
3.2 多尺度特征提取:不同卷积核并行
单一尺度的卷积核只能捕捉特定时间跨度的模式。QRS 波宽度约 80–120 ms,P 波约 80–100 ms,但两者形态差异大,用同一个卷积核同时学好两者比较吃力。多尺度做法是并行几个不同 kernel_size 的卷积分支,再把输出在通道维拼接。
class MultiScaleBlock(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() # 三个分支分别用 3、7、11 的卷积核 self.branch_small = nn.Conv1d(in_channels, out_channels // 3, kernel_size=3, padding=1) self.branch_mid = nn.Conv1d(in_channels, out_channels // 3, kernel_size=7, padding=3) self.branch_large = nn.Conv1d(in_channels, out_channels // 3, kernel_size=11, padding=5) self.bn = nn.BatchNorm1d(out_channels) self.relu = nn.ReLU() def forward(self, x): s = self.branch_small(x) m = self.branch_mid(x) l = self.branch_large(x) out = torch.cat([s, m, l], dim=1) # 通道维拼接 return self.relu(self.bn(out))out_channels // 3保证三个分支拼起来后总通道数等于out_channels。kernel_size=3对应约 8 ms,捕捉陡峭的 QRS 上升沿;kernel_size=7对应约 20 ms,捕捉 P 波和 T 波;kernel_size=11对应约 30 ms,捕捉更宽的异常波形。三个分支并行,网络可以同时学到不同时间尺度的特征,最后在通道维融合。这种结构在心律失常分类里比单尺度 CNN 通常能涨 1–3 个点的 F1。
3.3 特征提取后的全局池化与嵌入向量
卷积层输出的是[batch, channels, time_steps]的三维张量,要接分类头需要先压成二维。常见做法是全局平均池化或全局最大池化,把时间轴压掉,得到[batch, channels]的特征向量。全局平均池化对每个通道取时间维均值,保留整体激活强度;全局最大池化取最大值,保留最显著激活。心电分类里我一般两个都算,拼接后使用。
class GlobalPooling(nn.Module): def __init__(self): super().__init__() self.avg_pool = nn.AdaptiveAvgPool1d(1) self.max_pool = nn.AdaptiveMaxPool1d(1) def forward(self, x): avg = self.avg_pool(x).squeeze(-1) # [batch, channels] max_ = self.max_pool(x).squeeze(-1) return torch.cat([avg, max_], dim=1) # [batch, 2*channels]AdaptiveAvgPool1d(1)不管输入时间轴多长,都输出长度 1,这样网络对输入窗口长度不敏感。拼接后特征维度翻倍,后续接全连接层做分类。这个嵌入向量就是后续特征融合的输入。
4. 特征融合:多导联、多尺度、多模态怎么拼
4.1 早期融合、中期融合与晚期融合的选型
特征融合按发生位置分三种。早期融合在输入层拼,比如多导联心电直接按通道维堆叠,in_channels从 1 变成 12。中期融合在中间层拼,比如多尺度分支在通道维拼接,或者多导联各自过 CNN 后在某个中间层合并。晚期融合在决策层拼,每个导联或每个模态单独出一个分类概率,最后加权平均或投票。
选型上,多导联心电如果导联间同步采集,早期融合最简单,in_channels=12直接进网络。如果导联间差异大,比如一个肢体导联一个胸导联,中期融合更稳,各自提特征再合并。多模态场景,比如心电加心音、心电加血压,晚期融合更常见,因为不同模态采样率和物理意义差异大,强行早期融合反而互相干扰。
class EarlyFusionCNN(nn.Module): def __init__(self, n_leads=12, n_classes=5): super().__init__() # 多导联直接作为输入通道 self.extractor = CNNFeatureExtractor(in_channels=n_leads) self.pool = GlobalPooling() self.classifier = nn.Sequential( nn.Linear(256, 128), nn.ReLU(), nn.Dropout(0.5), nn.Linear(128, n_classes) ) def forward(self, x): # x: [batch, n_leads, time_steps] feat = self.extractor(x) feat = self.pool(feat) return self.classifier(feat)n_leads=12对应标准 12 导联,如果只有单导联就设 1。Dropout(0.5)放在全连接层前,心电数据集样本量通常不大,dropout 能压过拟合。这个结构就是早期融合的典型写法,简单直接,适合导联同步且数量固定的场景。
4.2 中期融合:多导联各自提特征再合并
中期融合的做法是每个导联走一个共享权重的 CNN 分支,得到各自的嵌入向量,再拼接或做注意力加权。共享权重能减少参数量,也强制网络学导联无关的特征。如果导联间差异确实大,也可以不共享权重,但参数量会翻倍。
class MidFusionCNN(nn.Module): def __init__(self, n_leads=12, n_classes=5): super().__init__() # 共享权重的特征提取器 self.shared_extractor = CNNFeatureExtractor(in_channels=1) self.pool = GlobalPooling() # 融合后的分类头,输入维度 = 导联数 * 每导联特征维度 self.classifier = nn.Sequential( nn.Linear(n_leads * 256, 128), nn.ReLU(), nn.Dropout(0.5), nn.Linear(128, n_classes) ) def forward(self, x): # x: [batch, n_leads, time_steps] batch, n_leads, length = x.shape feats = [] for i in range(n_leads): lead = x[:, i:i+1, :] # [batch, 1, time_steps] f = self.shared_extractor(lead) f = self.pool(f) # [batch, 256] feats.append(f) fused = torch.cat(feats, dim=1) # [batch, n_leads*256] return self.classifier(fused)循环里每个导联单独过shared_extractor,权重共享意味着所有导联用同一套卷积核。torch.cat在特征维拼接,融合后的向量维度是n_leads * 256。这种写法比早期融合参数量少,因为卷积核只有一套,但计算量随导联数线性增长。如果导联数很多,比如 12 导联,循环会比较慢,可以改成把导联维当 batch 维一次性过网络,再 reshape 回来。
4.3 注意力加权融合:让网络自己决定哪个导联重要
不同导联对心律失常的敏感度不一样。II 导联看 P 波清楚,V1 导联看室性异位有优势。与其等权拼接,不如让网络学一组权重,对每个导联的特征加权求和。这就是注意力融合的思路。
class AttentionFusion(nn.Module): def __init__(self, n_leads=12, feat_dim=256): super().__init__() # 为每个导联学一个标量权重 self.attention = nn.Sequential( nn.Linear(feat_dim, 64), nn.Tanh(), nn.Linear(64, 1) ) def forward(self, lead_feats): # lead_feats: list of [batch, feat_dim],长度 n_leads stacked = torch.stack(lead_feats, dim=1) # [batch, n_leads, feat_dim] scores = self.attention(stacked) # [batch, n_leads, 1] weights = torch.softmax(scores, dim=1) # 导联维归一化 fused = (stacked * weights).sum(dim=1) # [batch, feat_dim] return fusedattention是一个两层小网络,输入单导联特征,输出一个标量分数。softmax在导联维归一化,保证权重和为 1。(stacked * weights).sum(dim=1)做加权求和,得到融合后的单向量。这种融合方式在导联质量参差不齐时特别有用,网络会自动压低噪声大的导联权重。代价是多了注意力网络的参数,小数据集上要注意过拟合。
注意:注意力权重可以可视化出来,如果发现某个导联权重始终接近 0,要么是该导联数据质量差,要么是预处理有问题,值得回头查。
5. 避坑与排查:心律失常分类里最容易翻车的五件事
5.1 类别极不平衡导致模型只预测正常类
现象:训练完模型在测试集上 accuracy 很高,但 V 类、S 类的 recall 接近 0,混淆矩阵里几乎所有样本都被判成 N 类。
原因:MIT-BIH 里 N 类占比超过 90%,V 类可能只有几个百分点。交叉熵损失下,模型只要全预测 N 就能拿到很高的 accuracy,梯度被多数类主导。
解决:换损失函数,用带类别权重的交叉熵,权重取类别频率的倒数;或者用 Focal Loss,让难分类的少数类样本产生更大梯度。重采样也是办法,对少数类过采样或对多数类欠采样,但过采样要注意别简单复制,可以用 SMOTE 在特征空间插值。我一般先试类别权重,不行再上 Focal Loss。
5.2 按窗口切分时标签错位
现象:模型在训练集上表现正常,但验证集 F1 波动很大,同一个记录不同窗口预测结果矛盾。
原因:固定窗口切分时,窗口中心不一定落在心拍上,标签取窗口中心对应心拍,但窗口内可能包含多个不同类心拍,标签噪声大。
解决:改成按 R 波位置切分,以 R 波为中心前后各取固定长度,保证窗口内主要是一个心拍。R 波检测用 Pan-Tompkins,检测漏了或多了要人工核对。如果坚持按窗口切,标签取窗口内多数心拍的类别,并在训练时对标签置信度低的窗口降权。
5.3 预处理滤波参数不当把有用信号滤掉
现象:模型对正常搏动识别很好,但对宽 QRS 的室性异位识别差,F1 明显低于预期。
原因:带通滤波高频截止设太低,比如设到 20 Hz,宽 QRS 的高频成分被滤掉,形态特征丢失。
解决:心电带通滤波高频截止一般设 40 Hz 到 100 Hz。40 Hz 是诊断标准下限,如果做形态学分析可以放宽到 100 Hz。但要注意工频干扰在 50 Hz 或 60 Hz,如果高频截止超过工频,需要额外做陷波滤波。我一般先 0.5–100 Hz 带通,再 50 Hz 陷波,最后归一化。
5.4 训练集和测试集按窗口随机划分导致数据泄漏
现象:测试集指标高得离谱,换一批数据就崩。
原因:同一个记录切出的窗口被随机分到训练集和测试集,相邻窗口高度相似,模型相当于在测试集上见过训练样本。
解决:按记录划分,MIT-BIH 常用的是按患者划分,训练集和测试集记录不重叠。如果数据来自多个中心,按中心划分更严格。划分完检查一下训练集和测试集的类别分布,别一个类只在测试集出现。
5.5 模型参数量大但数据量小,过拟合严重
现象:训练 loss 一直降,验证 loss 先降后升,验证集 F1 在几个 epoch 后开始掉。
原因:CNN 参数量几十万到几百万,MIT-BIH 训练集可能只有几万心拍,参数比样本还多。
解决:加 Dropout,全连接层前 0.5,卷积层后 0.2;加 L2 正则,权重衰减 1e-4;用数据增强,比如时间轴随机缩放、加高斯噪声、随机裁剪;或者换更小的模型,减少卷积核数量和全连接层宽度。早停也是必须的,验证集 F1 连续几个 epoch 不涨就停。
6. 把融合特征用起来:从分类头到可解释性验证
分类头本身不复杂,融合后的特征向量接一两层全连接加 softmax 就行。但做完分类不是终点,还得验证网络学到的特征是不是真的对应心电形态,而不是在拟合数据集偏差。我一般做两件事:一是把卷积核激活可视化,看浅层卷积核是否响应 QRS 波;二是用 Grad-CAM 或类似方法,看分类决策时网络关注信号哪个时间段。
def compute_gradcam(model, input_signal, target_class): # 简化版 Grad-CAM,针对一维 CNN model.eval() input_signal.requires_grad_(True) feat = model.extractor(input_signal) # 最后一层卷积输出 feat.retain_grad() pooled = model.pool(feat) logits = model.classifier(pooled) score = logits[0, target_class] model.zero_grad() score.backward() # 特征图梯度在时间维取平均,作为权重 weights = feat.grad.mean(dim=2, keepdim=True) # [batch, channels, 1] cam = (weights * feat).sum(dim=1) # [batch, time_steps] cam = torch.relu(cam) return cam.detach().numpy()feat.retain_grad()保留中间层梯度,score.backward()反传后feat.grad就是梯度。weights是每个通道的梯度均值,代表该通道对目标类的重要性。(weights * feat).sum(dim=1)加权求和得到时间轴上的激活曲线,relu只保留正贡献。把这条曲线和原始心电叠在一起,如果峰值落在 QRS 波附近,说明网络确实在用心电形态做判断;如果峰值落在噪声段,那模型可能学到了伪相关,得回头查预处理和数据集划分。
参数上,target_class选你要解释的类别,比如 V 类。cam的长度和最后一层特征图的时间长度一致,要上采样回原始信号长度才能叠加。这个验证步骤花不了多少时间,但能帮你判断模型是不是真的可用,而不是在测试集上碰巧分数高。
我自己踩过的坑是:有一次模型 F1 很高,Grad-CAM 一看,激活全在信号开头那段,后来发现那段是记录起始的校准信号,所有样本都有类似模式,模型其实在偷懒。把那段裁掉重训,F1 掉了两个点,但模型泛化反而好了。所以别只看指标,可解释性验证该做还得做。希望帮到你。
本文还有配套的精品资源,点击获取