心电图分类识别:CNN、RNN、SVM三条技术路线选型与实战
简介:这份资源面向计算机、生物医学工程等专业的学生与开发者,提供用Python分别基于CNN、RNN、SVM三种模型实现心电图分类识别的完整源码,可用于毕业设计、期末大作业或课程设计,也适合想入门生理信号处理与深度学习分类的初学者对照学习。压缩包共114个文件,约241KB,其中103个py脚本承载数据加载、模型搭建与训练推理等核心逻辑,另有5个m文件对应MATLAB辅助脚本,4个txt与1个gitignore、1个png分别用于说明、版本管理和结果展示。目前已有221人学习。代码注释较为详尽,新手也能看懂,下载后简单部署即可运行,便于横向对比三种算法在心电信号上的表现差异,理解特征提取、网络结构与分类决策的取舍,同时可借鉴其目录组织与调试思路,快速迁移到自己的课题或项目中。
1. 心电图分类识别:三条技术路线怎么选、怎么跑通
拿到一段 10 秒的 12 导联心电信号,采样率 360 Hz,一共 4320 个采样点。你要判断它属于正常窦性心律、房颤、室性早搏还是其他心律失常——这就是心电图分类识别要解决的核心问题。很多刚接触这个方向的朋友第一反应是直接上深度学习,但实际做下来会发现,CNN、RNN、SVM 这三条路线各有各的适用场景,选错了不是效果差一点,而是根本跑不出可用结果。这篇文章把三条路线的原理、代码实现、参数设置和踩坑记录全部拆开讲清楚,适合手里有 PTB-XL 或 MIT-BIH 数据集、想快速跑通一个基线模型的从业者。读完你能判断自己的数据适合哪种方案,也能直接抄代码复现。
2. 三条技术路线的原理差异与选型依据
2.1 CNN 做心电图分类:把一维信号当成“图像”来卷积
CNN 处理心电信号的核心思路是把一维时序当成单通道或多通道的“图像”来做卷积。一个 12 导联、10 秒的信号可以组织成 12×3600 的矩阵,每个导联相当于一个通道。卷积核在时间轴上滑动,提取的是局部波形特征——QRS 波群的陡峭上升沿、P 波的圆钝形态、T 波的对称性,这些在卷积核看来就是不同尺度的局部模式。
为什么 CNN 在心电分类上表现稳定?因为心电信号的判别性特征高度局部化。房颤的关键判据是 RR 间期绝对不规则和 P 波消失,这两个特征在短时间窗口内就能捕捉到。CNN 的平移不变性意味着无论 QRS 波出现在第 2 秒还是第 5 秒,同一个卷积核都能检测到它。实际做的时候,我一般用 5 层左右的 1D 卷积,每层后面接 BatchNorm 和 ReLU,最后用全局平均池化压缩时间维度,再接全连接分类头。
但 CNN 有个明显短板:它对长程依赖的建模能力弱。比如判断“窦性心律”需要看整段信号的 RR 间期是否规律,这个规律性跨越了多个心拍,卷积核的感受野再大也有限。所以如果你的分类任务依赖全局节律特征,纯 CNN 容易翻车。
2.2 RNN 做心电图分类:用门控机制捕捉节律演变
RNN 的思路完全不同。它把心电信号看作一个时间序列,每个时间步输入一个采样点或一个心拍的特征向量,通过隐藏状态传递历史信息。LSTM 和 GRU 的门控机制让网络学会“记住”或“遗忘”哪些历史信息——比如前一个心拍的 RR 间期长度,这对判断当前心拍是否属于房颤至关重要。
实际用 RNN 做心电分类时,输入的组织方式很关键。直接把 3600 个采样点逐步喂给 LSTM,序列太长,梯度消失严重,训练极慢。常见做法是先做心拍分割,用 Pan-Tompkins 算法检测 R 波峰值,然后以每个 R 波为中心截取固定长度的窗口(比如 0.6 秒),把每个心拍压缩成一个特征向量,再按时间顺序输入 LSTM。这样序列长度从 3600 降到 10 左右(10 秒约 10 个心拍),训练效率大幅提升。
RNN 的劣势在于训练不稳定和并行性差。LSTM 的参数量比同层数的 CNN 大得多,在小数据集上容易过拟合。如果你的数据只有几千条,纯 RNN 的效果往往不如 CNN。
2.3 SVM 做心电图分类:手工特征工程才是真正的门槛
SVM 是三条路线里最“传统”的,但在小样本场景下依然能打。它的核心不是网络结构,而是特征工程。你需要从每个心拍中提取一组有判别力的数值特征,然后映射到高维空间找最优超平面。
常用的心电特征包括:RR 间期均值与标准差、QRS 波宽度、P 波是否存在及其幅度、T 波幅度、心率变异性指标(SDNN、RMSSD)、小波变换各频带能量等。这些特征提取出来之后,SVM 用 RBF 核就能在几百条样本上达到不错的分类效果。
SVM 的优势是可解释性强、训练快、不需要 GPU。劣势是特征提取依赖领域知识,换一个数据集或换一种心律失常类型,特征集可能就要重新设计。而且 SVM 本质上是二分类器,多分类需要 One-vs-Rest 或 One-vs-One 策略,类别多了之后性能下降明显。
2.4 三条路线的选型对照
| 维度 | CNN | RNN (LSTM/GRU) | SVM |
|---|---|---|---|
| 适合数据量 | 万级以上 | 万级以上 | 千级以内 |
| 输入形式 | 原始信号或频谱图 | 心拍序列 | 手工特征向量 |
| 长程依赖 | 弱 | 强 | 不适用 |
| 训练硬件 | GPU 推荐 | GPU 推荐 | CPU 即可 |
| 可解释性 | 中(可用 CAM) | 低 | 高 |
| 多分类扩展 | 直接 softmax | 直接 softmax | 需 OvR/OvO |
| 典型准确率 | 85%-95% | 80%-92% | 75%-88% |
选型建议很直接:数据量少、需要快速验证可行性,先上 SVM 加手工特征;数据量充足、追求端到端,用 CNN 做基线;任务依赖节律演变、有长序列建模需求,用 RNN 或 CNN+RNN 混合结构。
3. 用 Python 跑通三条路线的最小实现
3.1 数据准备与预处理:统一采样率和心拍分割
不管走哪条路线,预处理都是第一步。以 MIT-BIH 数据集为例,原始采样率是 360 Hz,每条记录约 30 分钟。我一般用 WFDB 库读取信号和标注,然后做带通滤波和心拍分割。
import wfdb import numpy as np from scipy.signal import butter, filtfilt def load_and_filter(record_path, lowcut=0.5, highcut=45, fs=360): """读取心电记录并做带通滤波""" record = wfdb.rdrecord(record_path) signal = record.p_signal[:, 0] # 取第一导联 # 设计巴特沃斯带通滤波器 nyq = fs / 2.0 b, a = butter(4, [lowcut/nyq, highcut/nyq], btype='band') filtered = filtfilt(b, a, signal) return filtered, record.fs def segment_beats(signal, annotation, fs, window_sec=0.6): """以 R 波为中心截取固定长度心拍""" half = int(window_sec * fs / 2) beats = [] labels = [] for r_peak, label in zip(annotation.sample, annotation.symbol): if r_peak - half < 0 or r_peak + half > len(signal): continue beat = signal[r_peak - half: r_peak + half] beats.append(beat) labels.append(label) return np.array(beats), np.array(labels)滤波器的阶数我一般设 4 阶,太高会导致相位失真,太低则阻带衰减不够。带通范围 0.5-45 Hz 是心电信号的经典范围,低于 0.5 Hz 的是基线漂移,高于 45 Hz 的是工频干扰和肌电噪声。心拍窗口取 0.6 秒是因为正常 QRS 波群宽度在 0.06-0.1 秒,加上前后各留 0.25 秒能覆盖完整的 P-QRS-T 复合波。
3.2 CNN 模型搭建与训练:1D 卷积的层数和核大小怎么定
CNN 的输入是分割好的心拍矩阵,形状为 (样本数, 窗口长度, 1)。下面是一个 5 层 1D CNN 的实现。
import torch import torch.nn as nn class ECG_CNN(nn.Module): def __init__(self, num_classes=5, input_len=216): super().__init__() self.conv_blocks = nn.Sequential( # 第1层:大核捕捉QRS波群 nn.Conv1d(1, 32, kernel_size=11, padding=5), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), # 第2层:中等核捕捉P波和T波 nn.Conv1d(32, 64, kernel_size=7, padding=3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), # 第3层:小核捕捉细节 nn.Conv1d(64, 128, kernel_size=5, padding=2), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(128, 128, kernel_size=3, padding=1), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1) ) self.classifier = nn.Sequential( nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): # x shape: (batch, 1, input_len) features = self.conv_blocks(x) return self.classifier(features)第一层用 kernel_size=11 是有意为之。QRS 波群的典型宽度在 80-120 ms,360 Hz 采样下约 29-43 个采样点,大卷积核能一次性覆盖整个 QRS 复合波。后面逐层减小核大小,让网络从宏观波形过渡到微观细节。MaxPool1d(2) 每层把时间维度减半,5 层之后 216 个采样点压缩到 7 个,再用自适应平均池化压成 1 个值。Dropout 设 0.5 是心电分类的常规操作,防止全连接层过拟合。
训练时的关键参数:学习率用 1e-3 配 Adam 优化器,batch_size 设 64,最多跑 50 个 epoch,用早停策略监控验证集 loss。如果验证集准确率连续 8 个 epoch 不提升就停。
3.3 RNN 模型搭建与训练:序列长度和隐藏层维度的权衡
RNN 的输入需要按时间顺序排列的心拍序列。假设每个样本包含 10 个连续心拍,每个心拍提取 50 维特征(可以是原始采样点降采样后的值,也可以是手工特征),输入形状为 (batch, 10, 50)。
class ECG_LSTM(nn.Module): def __init__(self, input_dim=50, hidden_dim=128, num_layers=2, num_classes=5): super().__init__() self.lstm = nn.LSTM( input_size=input_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=0.3 ) self.classifier = nn.Sequential( nn.Linear(hidden_dim * 2, 64), # 双向所以乘2 nn.ReLU(), nn.Dropout(0.5), nn.Linear(64, num_classes) ) def forward(self, x): # x shape: (batch, seq_len, input_dim) lstm_out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的输出 last_out = lstm_out[:, -1, :] return self.classifier(last_out)隐藏层维度设 128 是经验值,太小欠拟合,太大过拟合。双向 LSTM 比单向效果好,因为心电信号的判别不仅依赖过去的心拍,也依赖未来的心拍——比如判断一个心拍是否提前出现,需要看它和下一个心拍的间隔。num_layers=2 是深度和训练难度的折中,再深梯度问题就明显了。
训练时学习率要调小,用 5e-4 比较稳。LSTM 对学习率敏感,1e-3 容易震荡。梯度裁剪设 max_norm=1.0,防止梯度爆炸。
3.4 SVM 特征提取与分类:RBF 核的 gamma 和 C 怎么调
SVM 路线需要先提取手工特征。下面是一个特征提取函数,从每个心拍中提取 8 个时域特征和 4 个频域特征。
from scipy.stats import kurtosis, skew from scipy.signal import welch def extract_features(beat, fs=360): """从单个心拍提取时域和频域特征""" features = [] # 时域特征 features.append(np.mean(beat)) # 均值 features.append(np.std(beat)) # 标准差 features.append(np.max(beat) - np.min(beat)) # 峰峰值 features.append(skew(beat)) # 偏度 features.append(kurtosis(beat)) # 峰度 features.append(np.sum(np.abs(np.diff(beat)))) # 一阶差分绝对值和 # R波位置相关 r_idx = np.argmax(np.abs(beat)) features.append(r_idx / len(beat)) # 归一化R波位置 features.append(np.max(np.abs(beat[:r_idx])) if r_idx > 0 else 0) # R波前最大幅度 # 频域特征:功率谱分段能量 freqs, psd = welch(beat, fs=fs, nperseg=min(64, len(beat))) bands = [(0.5, 5), (5, 15), (15, 30), (30, 45)] for low, high in bands: idx = np.where((freqs >= low) & (freqs < high))[0] features.append(np.trapz(psd[idx], freqs[idx]) if len(idx) > 0 else 0) return np.array(features)提取完特征后用 StandardScaler 做标准化,然后网格搜索找最优的 C 和 gamma。
from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.model_selection import GridSearchCV from sklearn.pipeline import Pipeline pipe = Pipeline([ ('scaler', StandardScaler()), ('svm', SVC(kernel='rbf', probability=True)) ]) param_grid = { 'svm__C': [0.1, 1, 10, 100], 'svm__gamma': ['scale', 0.01, 0.1, 1] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring='f1_macro', n_jobs=-1) grid.fit(X_train, y_train) print(f"最优参数: {grid.best_params_}") print(f"最优F1: {grid.best_score_:.4f}")C 控制惩罚力度,C 越大对误分类容忍度越低,容易过拟合;gamma 控制 RBF 核的作用范围,gamma 越大每个样本的影响范围越小,也容易过拟合。我一般先粗搜 [0.1, 1, 10, 100] 和 [0.01, 0.1, 1],再在最优值附近细搜。用 f1_macro 而不是 accuracy 做评分,是因为心电数据类别不平衡严重,准确率会被多数类主导。
4. 避坑与排查:心电分类项目里最容易翻车的五个地方
4.1 数据泄漏:同一条记录的心拍同时出现在训练集和测试集
现象:训练集准确率 95%,测试集也有 93%,但换一个数据集或换一段新记录,准确率掉到 60%。
原因:按心拍随机划分数据集,同一条记录的不同心拍被分到了训练集和测试集。同一条记录的心拍高度相似,模型实际上是在“记忆”这条记录,而不是学习泛化特征。
解决:按记录划分,用 GroupKFold 或按患者 ID 划分。MIT-BIH 有 48 条记录,前 30 条做训练,后 18 条做测试,这是标准做法。
4.2 类别不平衡:正常心拍占 90%,模型全预测正常也有 90% 准确率
现象:模型在测试集上准确率 90%,但混淆矩阵显示所有样本都被预测为“正常”。
原因:心电数据中正常心拍远多于异常心拍,交叉熵损失被多数类主导。
解决:用加权交叉熵,权重设为类别频率的倒数;或者用 Focal Loss;或者在采样时对少数类过采样。我一般用 WeightedRandomSampler 配合加权损失,效果比单独用一种好。
4.3 滤波器的相位失真:filtfilt 用错导致 R 波位置偏移
现象:滤波后 R 波峰值位置和原始标注对不上,心拍分割错位。
原因:用了 lfilter 而不是 filtfilt。lfilter 是单向滤波,会引入相位延迟;filtfilt 是双向滤波,零相位失真。
解决:始终用 filtfilt。如果数据量太大 filtfilt 太慢,可以用 sosfiltfilt 替代,数值稳定性更好。
4.4 LSTM 的序列填充:padding 的 0 被当成有效信号
现象:LSTM 训练 loss 震荡不收敛,验证集准确率随机波动。
原因:不同样本的心拍数量不同,padding 到相同长度时用 0 填充,但 LSTM 把 0 也当成有效输入处理了。
解决:用 pack_padded_sequence 和 pad_packed_sequence,让 LSTM 跳过 padding 部分。或者用 attention mask 把 padding 位置的输出置零。
4.5 SVM 特征标准化:训练集和测试集用了不同的 scaler
现象:SVM 在交叉验证时 F1 很高,但部署到新数据上预测结果完全不对。
原因:先对全量数据做标准化再划分训练测试集,测试集的均值和方差信息泄漏到了训练阶段。
解决:标准化必须放在 Pipeline 里,或者先 fit 训练集的 scaler,再用同一个 scaler transform 测试集。上面代码里的 Pipeline 写法就是正确的做法。
5. 进阶技巧:用 CNN+LSTM 混合结构把准确率再提一截
三条路线单独用各有短板,实际项目中我一般会用 CNN+LSTM 的混合结构:先用 CNN 做局部特征提取,把每个心拍压缩成一个特征向量,再按时间顺序输入 LSTM 做节律建模。这个结构在 PTB-XL 数据集上比纯 CNN 能提升 3-5 个百分点。
class ECG_CNN_LSTM(nn.Module): def __init__(self, num_classes=5, input_len=216, lstm_hidden=64): super().__init__() # CNN 特征提取器 self.cnn = nn.Sequential( nn.Conv1d(1, 32, 11, padding=5), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(32, 64, 7, padding=3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, 5, padding=2), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1) # 输出 (batch, 128, 1) ) # LSTM 时序建模 self.lstm = nn.LSTM(128, lstm_hidden, batch_first=True, bidirectional=True) self.fc = nn.Sequential( nn.Linear(lstm_hidden * 2, 32), nn.ReLU(), nn.Dropout(0.5), nn.Linear(32, num_classes) ) def forward(self, x): # x shape: (batch, seq_len, 1, input_len) batch, seq_len, _, _ = x.shape # 把 seq_len 维度合并到 batch 里过 CNN x = x.view(batch * seq_len, 1, -1) cnn_feat = self.cnn(x) # (batch*seq_len, 128, 1) cnn_feat = cnn_feat.squeeze(-1).view(batch, seq_len, -1) lstm_out, _ = self.lstm(cnn_feat) return self.fc(lstm_out[:, -1, :])这个结构的关键在于 CNN 的输出要 reshape 回 (batch, seq_len, feature_dim) 再喂给 LSTM。seq_len 是每个样本包含的心拍数,我一般设 10。训练时先用 Adam 1e-3 跑 20 个 epoch,再降到 1e-4 微调 10 个 epoch。
验证方法上,除了看准确率和 F1,我强烈建议画混淆矩阵和 ROC 曲线。心电分类里“房颤 vs 正常”的 AUC 通常能到 0.95 以上,但“室上速 vs 室速”的 AUC 可能只有 0.75,这个差距在准确率上看不出来。另外,用 Grad-CAM 可视化 CNN 的关注区域,能直观看到模型是在看 QRS 波还是在看噪声,这个排查手段帮我省了很多调参时间。
最后说一个血泪教训:心电分类项目里,数据预处理和划分的代码一定要写单元测试。我曾经因为滤波器的截止频率写错了一个小数点,导致整个实验白跑了两周。现在我的习惯是,每写一个预处理函数,先拿一条已知记录跑一遍,把滤波前后的信号画出来肉眼确认,再批量处理。希望帮到你。
本文还有配套的精品资源,点击获取