心电图分类识别:CNN、RNN、SVM三条技术路线选型与实战

发布时间:2026/10/25 10:23:33
心电图分类识别: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 三条路线的选型对照

维度CNNRNN (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 波还是在看噪声,这个排查手段帮我省了很多调参时间。

最后说一个血泪教训:心电分类项目里,数据预处理和划分的代码一定要写单元测试。我曾经因为滤波器的截止频率写错了一个小数点,导致整个实验白跑了两周。现在我的习惯是,每写一个预处理函数,先拿一条已知记录跑一遍,把滤波前后的信号画出来肉眼确认,再批量处理。希望帮到你。

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