简介:一套面向心电图(ECG)分类识别任务的完整 Python 实现方案,同时覆盖 CNN、RNN 与 SVM 三种主流模型,适用于机器学习、深度学习相关课程设计、毕业设计及期末大作业。项目代码逐行添加注释,配置简单,即使初学者也能快速部署运行。资源包为 ZIP 格式,共 114 个文件,以 103 个 Python 脚本为主,辅以 MATLAB 数据处理脚本、文本说明与结果示意图,涵盖数据预处理、模型训练、评估及 GUI 可视化等模块。压缩包整体仅 241KB,轻量易用。该源码为个人高分项目,代码结构清晰,包含 ECG_GUI 交互界面与多种检测辅助脚本,能够帮助使用者直观理解不同算法在心电信号识别上的差异与实现细节。目前已有 220 人学习,适合希望快速上手心电分类项目并获取完整可运行方案的读者。
1. 心电图分类识别项目里,python 环境把 CNN、RNN、SVM 三种模型各跑一遍,第一个要踩的坑就是数据划分:第一版 CNN 在随机划分下 accuracy 能到 96%,换成按病人划分立刻掉到 82%。这个题目看起来是三个模型选型,实际是一整套数据管线的工程问题。高分项目的本质,是让三种模型共享同一套心拍切窗、归一化和评估规则,再比较各自的边界和适用场景。适合两类人:课程设计要交对比实验的学生,以及想确认选型结论的工程师。常见路线是 MIT-BIH 数据、scikit-learn 的 svm 算法、TensorFlow/Keras 的 CNN 与 RNN,正文按预处理、三模型、评估展开。
2. 心电图分类识别的数据链路:MIT-BIH 心拍切分、去噪与归一化
2.1 心拍级分类是默认选项,前提是先切对
MIT-BIH Arrhythmia Database 是这个项目最常见的数据来源:48 条记录、每条 30 分钟、采样率 360 Hz,标注文件里给出的原始符号有 15 种左右。主流做法是先把这些符号映射成 AAMI 建议的 5 类——N(正常及传导阻滞)、S(室上性异位)、V(室性异位)、F(融合波)、Q(起搏与未知),很多项目为了不让起搏器记录干扰训练,直接丢掉 Q 类只用 4 类。这个映射在每一篇论文里都会写,但很少有人强调:如果你把 48 条记录的原始符号拿来做多分类,比如把 LBBB 和 RBBB 单独拆成两个类别,SVM、CNN、RNN 的对照结论会完全变样,因为类别边界的医学意义变了,模型学的完全是另一套判别面。
选定 5 类或 4 类之后,还要做心拍级(beat-level)和片段级(segment-level)的选择。心拍级的心拍与标签都来自标注文件,一个 R 峰对应一个类别,全库大约能切出 10 万出头的心拍,足够喂 CNN 和 RNN;片段级分类在做房颤检测这类节奏问题时才需要用固定长度滑窗,它和"每个心拍归类"的标注粒度对不上。做这个标题的对照实验,默认选心拍级,标签直接取标注符号映射后的类别,不需要额外的人工标注,这也是项目能在有限课时里跑完的前提。
2.2 去噪、R 峰定位与心拍切窗
先处理信号再切窗。我用一个 5–15 Hz 的 Butterworth 带通滤波去掉基线漂移和高频肌电,然后用简化版 Pan–Tompkins 定位 R 峰:一阶差分突出 QRS 的斜率,平方后做移动窗口积分,再用自适应阈值找局部极大值。下面这段是核心逻辑。
import numpy as np import wfdb from scipy.signal import butter, filtfilt, find_peaks record = wfdb.rdrecord('100', channels=[0]) # 取 MLII 导联 ecg = record.p_signal[:, 0].astype(np.float64) # 360 Hz,30 分钟 def bandpass_filter(sig, fs=360, low=5, high=15): b, a = butter(4, [low / (fs / 2), high / (fs / 2)], btype='band') return filtfilt(b, a, sig) def locate_r_peaks(sig, fs=360): diff = np.diff(sig) squared = diff * diff # 放大 QRS 高频成分 win = np.ones(int(0.12 * fs)) / (0.12 * fs) # 120 ms 积分窗 mwi = np.convolve(squared, win, mode='same') # 移动窗口积分 thresh = mwi.mean() + 0.6 * mwi.std() # 自适应阈值 peaks, _ = find_peaks(mwi, distance=int(0.25 * fs), height=thresh) return peaksbandpass_filter 的 5–15 Hz 是 Pan–Tompkins 的经典频带,滤波阶数取 4 就够了,再高会让 QRS 起点变圆,影响后面切窗对齐。locate_r_peaks 里 distance 设 0.25 s,对应 240 bpm 的心率上限,防止把同一次 QRS 的副峰重复检出;0.6 倍标准差这个阈值在大部分记录上够用,但遇到 203、215 这类噪声大的记录会出现误检。正式项目里我不靠自写的检波器出标签,而是用 wfdb.rdann('100', 'atr') 读官方注解,自写检波只用来对齐切窗位置,这个分工能省掉大量排错时间。
切窗以每个 R 峰为中心,向前取 90 点、向后取 90 点,得到 181 点的向量,对应 0.5 秒的生理窗。S 类的发生位置常在 P 波之后,前窗留 250 ms 能把 P 波装进去;V 类的宽大 QRS 占 120~160 ms,后窗 250 ms 也够覆盖 T 波起点。切窗的同时完成标签映射。
AAMI_MAP = {'N':'N','L':'N','R':'N','e':'N','j':'N', 'A':'S','a':'S','J':'S','S':'S', 'V':'V','E':'V', 'F':'F', '/':'Q','f':'Q','Q':'Q','U':'Q','P':'Q'} def build_beats(ecg, peak_idxs, ann_symbols, before=90, after=90): X, y = [], [] for peak, sym in zip(peak_idxs, ann_symbols): label = AAMI_MAP.get(sym) if label is None or label == 'Q': continue if peak - before >= 0 and peak + after < len(ecg): X.append(ecg[peak - before: peak + after + 1]) y.append(label) return np.array(X), np.array(y)AAMI_MAP 里的 e(房性逸搏)、j(交界性逸搏)在不同论文里有时归 S 有时归 N,我的做法是跟 AAMI 原始建议走,归到 N。build_beats 里把 Q 类直接丢弃,一是起搏心拍形态和真实心搏差异太大,二是这个类别在三个模型里都很难学,留着只会稀释评估指标。峰值点与标注符号的配对要按时间戳对齐,不要按列表下标硬配,标注文件里存在未标注的片段。
2.3 归一化和按病人划分
这一节是心电图分类识别里最容易被低估的一步。归一化我选 StandardScaler,但必须只在训练集上拟合,再 transform 到验证集和测试集。有人喜欢对单个心拍做 z-score,这在振幅差异大的记录上效果好,但会抹掉真实的幅值信息,SVM 的 amp_range 这类形态特征就废了,所以全管线统一用全局标定。
from sklearn.preprocessing import StandardScaler X_all = X.reshape(-1, 181) # 先摊平再标定 scaler = StandardScaler().fit(X_all[train_mask]) # 只拟合训练部分 X_all = scaler.transform(X_all) X_train = X_all[train_mask].reshape(-1, 181, 1) X_test = X_all[~train_mask].reshape(-1, 181, 1)train_mask 必须按记录(病人)划分,不能在全库里随机打散。原因很实际:同一条记录里的心拍高度相关,心电形态、电极位置、基线漂移都来自同一个人,随机划分会让测试集里出现训练集中同一个人的心拍,CNN 会把"这个人"记下来而不是把"这类心拍"记下来,分数虚高。AAMI 的 DS1/DS2 分组就是这个思想,44 条记录按编号拆成两组,所有心拍随记录走。答辩和验收时,按记录划分后的结果才是三个模型真正可以横向比较的结果。
3. SVM 基线:手工特征加网格搜索,先拿到可解释的心电图分类分数
3.1 SVM 算法吃定长向量,特征工程决定上限
SVM 是三个模型里唯一不能直接吃原始波形的。RBF 核在 181 维的原始心拍向量上也能跑,但心拍内相邻采样点高度相关,核矩阵在高维空间里会被这种相关性拖累,效果反而不如精心构造的十几个特征。常见做法是把每个心拍压缩成一个固定长度的特征向量,让 SVM 专注在类别边界上,这也是 svm 算法和深度学习项目最本质的分工差异。
把 SVM 放在 CNN、RNN 之前还有一个项目层面的理由:它是基线。在这个管线上,如果 CNN 相比 SVM 的提升不到 3 个点的 macro F1,说明数据划分或训练配置有问题,或者这个题目本身不需要深度学习。先拿到一组可复现的 SVM 分数,后面两个模型的每一次改动都能对照着看收益,而不是凭感觉说"深度学习更优"。
3.2 特征矩阵构建
我用 6 个特征,全部来自心拍窗口和它前后两个 RR 间隔,计算量可以忽略。特征选取的原则是有生理含义:早搏的特征首先体现在代偿间歇,其次是 QRS 变宽变高,然后是波形能量变化。
| 特征 | 计算方式 | 生理含义 |
|---|---|---|
| rr_before | 当前 R 峰与前一个 R 峰的间隔(采样点) | 早搏前的代偿间歇 |
| rr_after | 当前 R 峰与后一个 R 峰的间隔(采样点) | 早搏后的代偿间歇 |
| amp_range | beat.max() - beat.min() | 心拍幅值范围 |
| waveform_std | beat.std() | 波形离散程度 |
| waveform_energy | abs(beat).sum() | 波形面积 |
| qrs_width_ratio | 高阈值点数占比 | QRS 宽度近似 |
实现上先算好每个心拍的 rr_before 与 rr_after,再对每个窗口算形态特征,拼成特征矩阵。
def extract_features(beat, rr_before, rr_after): thr = beat.mean() + 0.6 * beat.std() return np.array([ rr_before, rr_after, beat.max() - beat.min(), beat.std(), float(np.abs(beat).sum()), float((beat > thr).mean()), ]) def build_feature_matrix(beats, r_peak_idx): feats = [] for i, beat in enumerate(beats): before = r_peak_idx[i] - r_peak_idx[i - 1] if i > 0 else 0.0 after = r_peak_idx[i + 1] - r_peak_idx[i] if i < len(beats) - 1 else 0.0 feats.append(extract_features(beat, before, after)) return np.array(feats)qrs_width_ratio 用相对阈值统计高幅值点占比,代替精确的 QRS 起点检测,对噪声的容忍度更高。rr_before 和 rr_after 的单位是采样点,360 Hz 下除以 360 就是秒,投进网格搜索前建议统一换算成秒,特征量纲越接近,C 和 gamma 的搜索范围越好定。
3.3 网格搜索与类别权重
SVM 部分的超参数就两组:C 和 gamma。C 控制误分类的代价,gamma 控制 RBF 核的影响半径。加上类别不平衡,class_weight 必须显式设置,否则 N 类占绝对多数,SVC 会把 S、V、F 几乎全部判成 N,整体 accuracy 很好看,宏观分数惨不忍睹。
from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV param_grid = { 'C': [0.1, 1, 10, 100], 'gamma': [1e-3, 1e-2, 0.1], 'class_weight': ['balanced'], } gs = GridSearchCV( SVC(kernel='rbf', probability=False), param_grid, scoring='f1_macro', # 对少数类敏感,不能用 accuracy cv=5, n_jobs=-1, ) gs.fit(X_feat_train, y_train) svm_best = gs.best_estimator_ y_pred_svm = svm_best.predict(X_feat_test)scoring 选 f1_macro 而不是 accuracy,是因为四个类别的样本量差距超过一个数量级,accuracy 在 N 类占 85% 以上时几乎失去区分度。probability=False 会快很多,只有需要画 ROC 或做概率校准时才开 probability=True,代价是 SVC 要额外拟合 Platt 缩放,训练时间接近翻倍。全库 10 万心拍跑 RBF 网格搜索可能要十几分钟,我一般先把 N 类下采样到和 V 类同量级再搜参,确定最优 C、gamma 后,再用全套数据带着 class_weight 重训一遍最终模型。
4. CNN 卷积神经网络:1D 卷积在单心拍上的结构设计与心电图分类训练参数
4.1 为什么心电图分类用 1D CNN,而不是把波形画成图
CNN 卷积神经网络在图像任务上的名声太大,导致不少项目把心电图画成二维图再丢给图像 CNN。这个做法不是不能用,但它丢弃了时间轴上的精确对齐:一张 224×224 的图像里,QRS 的 100 ms 可能只占几个像素,卷积核学到的是"图片纹理"而不是"波形形态"。心电图分类识别里更常见的做法是保持一维时间序列,用 Conv1D 在 181 个采样点上直接做卷积,输入是 (181, 1) 的张量,每个卷积核扫过的时间窗对应真实的毫秒数,可解释性也更强。图像增强里常用的平移、旋转增强不适合一维波形,一维对应的增强是加噪、幅值缩放和时间轴拉伸。
4.2 模型结构与每层的设计依据
一个能稳定复现的 1D CNN 结构是三层卷积加全局平均池化。第一层核长取 15,因为 360 Hz 下 15 个点约 42 ms,接近 QRS 波群的主要能量段;后面的核长递减到 9 和 5,逐层聚焦更细的局部形态。每层卷积后接 BatchNormalization 再池化,可以压掉基线漂移带来的分布偏移。最后一层用 GlobalAveragePooling1D 而不是 Flatten,直接把每个特征图压成一个数,参数量少得多,对这个体量的数据不容易过拟合。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import (Conv1D, BatchNormalization, MaxPool1D, GlobalAveragePooling1D, Dropout, Dense) def build_cnn(input_len=181, n_class=4): model = Sequential([ Conv1D(32, kernel_size=15, activation='relu', input_shape=(input_len, 1)), BatchNormalization(), MaxPool1D(pool_size=2), Conv1D(64, kernel_size=9, activation='relu'), BatchNormalization(), MaxPool1D(pool_size=2), Conv1D(128, kernel_size=5, activation='relu'), GlobalAveragePooling1D(), Dropout(0.3), Dense(n_class, activation='softmax'), ]) return model两个池化层各把序列长度减半,181 → 90 → 45,第三层卷积完后是 45 个时间步,全局平均池化后每个特征图得到一个标量。Dropout 放在 GlobalAveragePooling1D 之后而不是卷积层之间,是因为卷积层本身参数共享已经有正则效果,在最后的特征向量上加 dropout 收益更直接。
4.3 训练参数、损失权重与回调
CNN 部分的训练参数值得单独列一张表,答辩时经常被问的就是这些数字怎么来的。
| 参数 | 取值 | 设定依据 |
|---|---|---|
| input_len | 181 | 90 点前窗 + 1 + 90 点后窗 |
| kernel_size | 15 / 9 / 5 | 对应 42 / 25 / 14 ms 时间窗 |
| filters | 32 / 64 / 128 | 逐层加倍,控制参数量 |
| batch_size | 64 | 心拍样本多,64 比 32 收敛更稳 |
| learning_rate | 1e-3 | Adam 默认档,配合衰减 |
| reduce_lr | factor=0.5, patience=3 | 验证损失停滞就降一半 |
| early_stopping | patience=8 | 以 val_loss 为准,恢复最优权重 |
类别不平衡在 CNN 里用 loss 权重处理。S 类数量差不多是 N 类的 1/30,F 类更极端,给交叉熵按反比例加权重是常规操作。注意权重不要严格按反比拉满,比如 S 类设 8、F 类设 20,再往上调召回率会涨但精确率会崩,最后还是看宏观 F1。
from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model = build_cnn() model.compile(optimizer=Adam(learning_rate=1e-3), loss='categorical_crossentropy', metrics=['accuracy']) history = model.fit( X_train, y_train, # one-hot 编码后的标签 validation_split=0.15, batch_size=64, epochs=60, class_weight={'N': 1.0, 'S': 8.0, 'V': 4.0, 'F': 20.0}, callbacks=[ EarlyStopping(patience=8, restore_best_weights=True), ReduceLROnPlateau(factor=0.5, patience=3), ], )class_weight 只影响损失函数里的样本加权,不会改变验证集的真实分布,验证曲线仍然可信。EarlyStopping 的 patience 取 8 比较合适,心电数据噪声小,验证损失不会大幅震荡,patience 太短容易被单个 epoch 的抖动提前掐断。ReduceLROnPlateau 的 factor 0.5 意味着学习率从 1e-3 一路降到 1.25e-4,这种平滑降价下训练基本不会重新发散。
5. RNN 循环神经网络:LSTM 对心拍序列的建模方式与心电图分类的坑
5.1 单心拍上 RNN 不占优,它的价值在节奏上下文
先说结论:把 181 点的单心拍直接喂给 LSTM,在大多数记录划分下,效果和 1D CNN 接近,偶尔略低。原因是单心拍内部虽然有时间顺序,但局部形态模式,比如 QRS 变宽、ST 段偏移,卷积核就能抓住,循环结构的优势发挥不出来。RNN 循环神经网络真正值钱的地方,是把心拍放进前后文的序列里建模:一个 V 类早搏后面通常跟着代偿间歇和增大的 RR 间隔,这个节奏模式只有跨心拍才能看到。所以这个标题里 RNN 项目有两种做法:基线版本和普通深度学习任务一致,输入单心拍;进阶版本输入连续心拍的 RR 间隔序列或特征序列。先给基线的实现,它和 CNN 共用同一份预处理输出,序列输入版本放到本章最后一节。
5.2 LSTM 的 Keras 实现与训练参数
基线 LSTM 用两层循环核,第一层返回完整时间步序列,第二层只返回最后一步。dropout 参数放在循环层内部,作用在时间步之间的连接上,比放在层外更有效。Bidirectional 会让参数量翻倍,对单心拍任务提升有限,保留双向是为了在对比实验里说明"双向对心拍任务的边际收益",不想要可以直接去掉。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Bidirectional, Dense from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping def build_rnn(input_len=181, n_class=4): model = Sequential([ Bidirectional(LSTM(64, return_sequences=True, dropout=0.2), input_shape=(input_len, 1)), LSTM(32, dropout=0.2), Dense(n_class, activation='softmax'), ]) return model model = build_rnn() model.compile(optimizer=Adam(learning_rate=1e-3, clipnorm=1.0), loss='sparse_categorical_crossentropy', metrics=['accuracy']) model.fit(X_train, y_train_idx, # 整数标签,省掉 one-hot validation_split=0.15, batch_size=32, epochs=40, class_weight={'N': 1.0, 'S': 8.0, 'V': 4.0, 'F': 20.0}, callbacks=[EarlyStopping(patience=6, restore_best_weights=True)])这里用 sparse_categorical_crossentropy,CNN 部分用 categorical_crossentropy,两者数学等价,只是标签编码不同;RNN 的显存占用比 CNN 高,整数标签能省掉 one-hot 矩阵。clipnorm=1.0 把梯度范数截断到 1,这是 LSTM 的标配,不设的话偶尔会出现 loss 在某个 epoch 突然跳到 NaN。batch_size 从 CNN 的 64 降到 32,循环网络对梯度噪声更敏感,小批次能稳一点。
5.3 RNN 训练的三个坑和一个升级方向
第一个坑是收敛慢。同样的数据,CNN 十几轮就稳了,LSTM 前二十轮 loss 下降很慢,val_loss 还可能出现先升后降的假象。不要把 early stopping patience 开大硬等,而是先把 learning rate 提到 2e-3 试一轮,不行再退回 1e-3,或者换 AdamW 加一点权重衰减。
第二个坑是过拟合来得比 CNN 快。LSTM 的参数在各时间步共享,剩下能自由组合的自由度反而容易记住训练集中特定的心拍顺序。Dropout 0.3、early stopping patience 缩短到 6、训练轮数封顶 40,三个措施一起上,比在验证集上反复挑 epoch 靠谱。
第三个坑是相位对齐。RNN 对输入序列的起点极其敏感,切窗时 R 峰定位偏 5 个点,CNN 还能靠卷积的平移不变性扛过去,LSTM 会把这个偏移当成一个可学习的信号,导致同一类心拍产生不同表示。所以 RNN 版本必须确认所有心拍都以 R 峰为第 90 个点对齐,任何重采样都会破坏这个对齐关系。
升级方向是把输入从单心拍换成序列。每个样本取连续 11 个心拍,输入张量形状是 (11, feat_dim),feat_dim 可以是 RR 间隔、幅值等 4~6 个指标;也可以先用 CNN 对每个单心拍提取嵌入向量,再把这些嵌入向量按时间顺序喂给 LSTM。这样 RNN 和 CNN 就不是简单的并列基线,而是"CNN 学形态、RNN 学节奏"的分工,在 V 类和 S 类上会拉开和其他两个模型的差距。
6. SVM 与 CNN、RNN 对比的评估口径:混淆矩阵、按记录分组与验收细节
6.1 三模型对比的报告里应该包含哪些数字
三个模型在同一个测试集、同一个标签体系下才有可比性。测试集必须来自按记录划分出的那部分,模型输出统一换算成类别标签,然后一次性算出分类报告和混淆矩阵。
from sklearn.metrics import classification_report for name, y_pred in [('SVM', y_pred_svm), ('CNN', y_pred_cnn), ('RNN', y_pred_rnn)]: print(name) print(classification_report(y_test, y_pred, target_names=['N', 'S', 'V', 'F']))报告里先看 S 类和 V 类的召回率,这两个类在临床上分别对应室上性和室性早搏,漏检的代价远高于把 N 类误判。再看 macro F1,它在四个类上取平均,不会让 N 类的高准确率掩盖其他类的表现。三个模型的差距通常集中在 S 类:SVM 靠代偿间歇特征能抓到一部分,CNN 学到 P 波附近的局部形态能再补一点,RNN 加上 RR 序列上下文后对连续早搏的识别是三者里最稳的。
6.2 验收项目时最该保留的一个细节
比总分数更有说服力的,是按测试记录单独算 F1。每个记录代表一个病人,逐记录结果能暴露模型是不是只在部分病人上有效。record_ids_test 是切窗时顺手记下的每个心拍所属记录号,这一步不要省。
from collections import defaultdict from sklearn.metrics import f1_score rec_true = defaultdict(list) rec_pred = defaultdict(list) for rec, t, p in zip(record_ids_test, y_test, y_pred_cnn): rec_true[rec].append(t) rec_pred[rec].append(p) for rec in sorted(rec_true): f1 = f1_score(rec_true[rec], rec_pred[rec], average='macro', zero_division=0) print(rec, round(f1, 3))逐记录 macro F1 大部分集中在 0.85~0.95,如果某一类只在某个记录里出现且数量只有十几个,这个记录的 F1 会明显偏低,这是数据分布问题,不是模型问题。
| 模型 | 输入 | 优势 | 主要风险 |
|---|---|---|---|
| SVM | 6~15 维特征 | 可解释、训练快、小样本稳 | 特征工程决定上限 |
| CNN | 181×1 原始波形 | 自动学局部形态,抗噪强 | 依赖切窗对齐精度 |
| RNN/LSTM | 181×1 或 (k, feat) 序列 | 能建模节奏与上下文 | 收敛慢、易过拟合 |
写报告时把逐记录 F1 和每个测试记录里 S 类的数量放在同一张表里。数量越少的记录 F1 波动越大,S 类只有 5 个样本的记录这行 F1 掉到 0,不代表模型失效,它只是分母太小。这张表比平均值更能说明三个模型的差别到底落在哪些记录上。
本文还有配套的精品资源,点击获取