简介这份资源面向计算机、生物医学工程等专业的学生与开发者提供用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 或 CNNRNN 混合结构。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, lowcut0.5, highcut45, fs360): 读取心电记录并做带通滤波 record wfdb.rdrecord(record_path) signal record.p_signal[:, 0] # 取第一导联 # 设计巴特沃斯带通滤波器 nyq fs / 2.0 b, a butter(4, [lowcut/nyq, highcut/nyq], btypeband) filtered filtfilt(b, a, signal) return filtered, record.fs def segment_beats(signal, annotation, fs, window_sec0.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_classes5, input_len216): super().__init__() self.conv_blocks nn.Sequential( # 第1层大核捕捉QRS波群 nn.Conv1d(1, 32, kernel_size11, padding5), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), # 第2层中等核捕捉P波和T波 nn.Conv1d(32, 64, kernel_size7, padding3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), # 第3层小核捕捉细节 nn.Conv1d(64, 128, kernel_size5, padding2), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(128, 128, kernel_size3, padding1), 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_size11 是有意为之。QRS 波群的典型宽度在 80-120 ms360 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_dim50, hidden_dim128, num_layers2, num_classes5): super().__init__() self.lstm nn.LSTM( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropout0.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_layers2 是深度和训练难度的折中再深梯度问题就明显了。训练时学习率要调小用 5e-4 比较稳。LSTM 对学习率敏感1e-3 容易震荡。梯度裁剪设 max_norm1.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, fs360): 从单个心拍提取时域和频域特征 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, fsfs, npersegmin(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(kernelrbf, probabilityTrue)) ]) param_grid { svm__C: [0.1, 1, 10, 100], svm__gamma: [scale, 0.01, 0.1, 1] } grid GridSearchCV(pipe, param_grid, cv5, scoringf1_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. 进阶技巧用 CNNLSTM 混合结构把准确率再提一截三条路线单独用各有短板实际项目中我一般会用 CNNLSTM 的混合结构先用 CNN 做局部特征提取把每个心拍压缩成一个特征向量再按时间顺序输入 LSTM 做节律建模。这个结构在 PTB-XL 数据集上比纯 CNN 能提升 3-5 个百分点。class ECG_CNN_LSTM(nn.Module): def __init__(self, num_classes5, input_len216, lstm_hidden64): super().__init__() # CNN 特征提取器 self.cnn nn.Sequential( nn.Conv1d(1, 32, 11, padding5), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(32, 64, 7, padding3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, 5, padding2), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1) # 输出 (batch, 128, 1) ) # LSTM 时序建模 self.lstm nn.LSTM(128, lstm_hidden, batch_firstTrue, bidirectionalTrue) 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 波还是在看噪声这个排查手段帮我省了很多调参时间。最后说一个血泪教训心电分类项目里数据预处理和划分的代码一定要写单元测试。我曾经因为滤波器的截止频率写错了一个小数点导致整个实验白跑了两周。现在我的习惯是每写一个预处理函数先拿一条已知记录跑一遍把滤波前后的信号画出来肉眼确认再批量处理。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?