深度学习心电异常检测实战:CNN工程包全流程拆解

发布时间:2026/10/5 8:03:14
深度学习心电异常检测实战:CNN工程包全流程拆解 简介这是一份基于深度学习的心电异常检测项目代码包面向医疗人工智能与Python开发者解决从心电图数据中自动识别异常的建模需求。项目采用卷积神经网络架构覆盖数据预处理、模型构建、训练调优与测试评估全流程可帮助读者快速上手一维信号的深度学习分类任务。压缩包内共8个文件包含5个Python脚本、2张运行结果图片和1个测试文件整体仅9KB脚本分工明确适合作为入门或复现参考。目前已有470人学习下载代码体量精简但步骤完整从原始ECG读取到训练与测试均有对应实现搭配结果图可直观比对模型表现适合希望了解CNN在医疗信号分析中落地的开发者学习使用。1. 深度学习做心电异常检测这份 CNN-ECG 工程包到底能跑出什么心电图是心内科最基础也最依赖经验的检查一个普通心电记录里夹杂着 P 波、QRS 波群、T 波还要分辨早搏、房颤、传导阻滞医生一天看几十份就会疲劳。深度学习进这个领域最直接的价值不是替代医生而是把「波形模式识别」这步自动化让异常先被筛一遍。这个 zip 包就是用卷积神经网络对 MIT-BIH 心电数据进行二分类——区分正常心拍N和异常心拍R包含了从数据生成、训练到测试的全流程 Python 脚本。对于刚接触深度学习、想找个真实医疗信号动手的从业者来说它比 MNIST 练手有价值得多因为处理的是带噪声的一维时序信号踩坑点也更真实。解压 zip 后是CNN-ECG-master目录核心就是几个 py 文件和两张结果图代码量不大适合逐行读。2. 把心电信号变成模型能吃的样本工程结构与数据流拆解拿到 zip 包第一件事不是急着跑训练而是先看清楚数据是怎么从.dat/.atr原始文件变成 numpy 数组的。这个项目保留了完整的数据管线我自己拆这类老项目时习惯先顺着数据流走一遍因为训练代码写得好不好反而是次要的数据生成环节的坑才最影响结果。2.1 CNN-ECG-master 目录里每个文件的职责分配先看项目根目录的文件清单每个文件干的事情很明确文件 / 目录职责ecg目录存放 MIT-BIH 原始心电数据.dat、.atr、.heainput_data.py原始数据读取与预处理提供加载心跳样本的接口ReadMyOwnData.py解析自己的心电数据文件按指定比例生成训练/测试集make_own_data.py把原始心电记录切片成单个心拍样本的关键脚本N_and_R_train.pyCNN 模型定义 训练主流程test.py加载训练好的权重在测试集上做评估与结果可视化result.png/20-28_08.png训练曲线和测试结果图用来参考模型该收敛到什么样这个结构顺序就是标准深度学习实践的套路先有数据读取再做样本制造然后训练最后测试。对新手来说最容易忽略的是make_own_data.py和ReadMyOwnData.py之间的分工——前者负责从原始心电记录里切出心拍后者负责把切好的心拍整理成训练数组。很多人在自己改数据时只改了其中一个文件导致维度对不上。2.2 make_own_data.py 切片逻辑R 峰对齐与窗口长度的设定make_own_data.py的核心动作是读取心电注释文件里的 R 峰位置以每个 R 峰为中心向前取一部分点、向后取一部分点拼成一个固定长度的心拍向量。常见做法是取 R 峰前 60 个采样点、R 峰后 90 个采样点这样总长度 150对应 MIT-BIH 360Hz 采样率下约 0.42 秒的心拍。这个窗口长度不是拍脑袋定的要覆盖 P 波、QRS 波群和 T 波的主要形态。逻辑上脚本会遍历心电记录中的每个 R 峰坐标从原始信号里切出[r_peak - offset_before, r_peak offset_after]这段区间同时根据.atr注释文件里的节拍类型标签把每个心拍打上 0正常或 1异常的标签。注意这里不是简单用np.where找峰值而是依赖注释文件的 R 峰标记——这点在后续避坑章节会展开。# 伪代码示例基于 R 峰注释的心拍切片逻辑 import numpy as np import wfdb # 常见做法是使用 wfdb 库读取 MIT-BIH 数据 def extract_beats(record_path, ann_path, before60, after90): # record_path: .dat 文件路径; ann_path: .atr 注释文件路径 signal, meta wfdb.rdsamp(record_path) ann wfdb.rdann(ann_path, atr) beats [] labels [] for idx, r_peak in enumerate(ann.sample): # 边界检查窗口不能超出信号范围 if r_peak - before 0 or r_peak after len(signal): continue beat signal[r_peak - before : r_peak after, 0] beat (beat - np.mean(beat)) / (np.std(beat) 1e-6) # 标准化 beats.append(beat) # sym 是注释符号如 N 为正常其余归为异常 labels.append(0 if ann.symbol[idx] N else 1) return np.array(beats), np.array(labels)代码逻辑上先做了边界检查防止切片越界然后对每个心拍做 z-score 标准化。标准化这一步对 CNN 训练影响很大——如果原始信号的基线漂移没有被去除不同记录段的幅值差异会直接干扰卷积核的学习。before和after这两个参数决定了输入维度改的时候要同时改训练脚本里的input_shape否则喂进模型就会维度报错。提示MIT-BIH 的信号导联通常是双通道signal[:, 0]是 MLII 导联多数心电分类项目只用这一导联就够了。如果你在改代码时发现输入通道维对不上先检查是不是把两路导联都塞进去了。2.3 ReadMyOwnData.py 的样本划分比例与随机种子ReadMyOwnData.py做的事情相对简单把make_own_data.py生成的样本数组按比例拆成训练集和测试集。项目里默认的是 8:2 划分也就是 80% 样本训练、20% 样本测试。但这个脚本里有个容易被忽视的点样本在生成时是按时间顺序排列的同一个病人的心拍会连在一起直接切分会导致训练集和测试集来自同一个病人的连续记录段造成数据泄漏。我一般会建议在ReadMyOwnData.py里显式设置np.random.seed(42)并且把样本整体打乱后再切分而不是按文件里的顺序切。否则你最后看到的测试准确率虚高模型其实记住了同一病人的心电形态而不是真正泛化到新病人身上。def load_train_test_data(data_path, test_ratio0.2, seed42): # data_path: make_own_data.py 输出的 .npy 文件路径 data np.load(data_path) # shape: [n_samples, signal_length] labels np.load(data_path.replace(data, label)) idx np.arange(len(data)) np.random.seed(seed) # 固定随机种子保证实验可复现 np.random.shuffle(idx) split int(len(data) * (1 - test_ratio)) train_idx, test_idx idx[:split], idx[split:] return data[train_idx], labels[train_idx], data[test_idx], labels[test_idx]这里用固定随机种子有两个意义一是你自己重复跑实验结果一致二是调整网络结构时能确定准确率变化是模型引起的而不是数据划分波动造成的。很多人忽略这一步结果每次训练结果都不一样还以为模型结构出了问题。3. 训练模型前先看懂这条 CNN 流水线结构设计与超参怎么定N_and_R_train.py是整个项目的训练主脚本。它干四件事定义网络结构、加载数据、执行训练、保存权重。理解它的设计思路比直接复制粘贴代码更重要因为老代码的默认超参未必适合你自己跑的数据。3.1 一维 CNN 对心拍信号的处理方式为什么选卷积而不是全连接很多初学者会问心电信号是一维时间序列为什么不用 RNN 而用 CNN答案是 CNN 在时序序列上照样适用而且对局部形态的捕捉能力很强。一次心拍中P 波和 QRS 波群是紧邻的局部模式卷积核天然能够去匹配这种短距离形态特征。池化层则压缩时间维度保留主要特征的同时减少计算量。在这个项目里模型把长度为 150 的一维心拍当作「一行像素」来处理卷积核在时间轴上滑动提取不同尺度的波形特征。典型的网络结构是两层卷积 池化再接全连接层输出二分类概率。激活函数用 ReLU最后的输出层用 Softmax 得到属于正常N和异常R两类各自的概率。这套结构和图像分类的经典 CNN 结构是一脉相承的只是把二维卷积换成了 Conv1D。# N_and_R_train.py 中 CNN 结构的核心定义常见做法 from keras.models import Sequential from keras.layers import Conv1D, MaxPooling1D, Flatten, Dense, Dropout def build_cnn(input_length150, num_classes2): # 输入是一维心拍向量通道数为 1 model Sequential() model.add(Conv1D(filters32, kernel_size5, activationrelu, input_shape(input_length, 1))) model.add(MaxPooling1D(pool_size2)) model.add(Conv1D(filters64, kernel_size5, activationrelu)) model.add(MaxPooling1D(pool_size2)) model.add(Flatten()) model.add(Dense(128, activationrelu)) model.add(Dropout(0.5)) # 防过拟合全连接层后常用 model.add(Dense(num_classes, activationsoftmax)) model.compile(optimizeradam, losscategorical_crossentropy, metrics[accuracy]) return model结构参数里值得留意的是kernel_size5它代表卷积核一次覆盖 5 个采样点约 14ms 的信号长度能捕捉到比较精细的波形细节。Dropout(0.5)是防过拟合的关键——心电数据类别不均衡时全连接层特别容易记住训练集。如果你发现训练准确率 99% 但测试准确率只有 80%第一件事就是检查 Dropout 是否被去掉或调小了。注意老代码里input_shape(input_length, 1)最后的 1 是通道数。如果make_own_data.py输出的数组是二维[n, 150]需要在喂入模型前用reshape(-1, 150, 1)补通道维否则 keras 直接报维度错。3.2 训练过程与超参表每个参数的含义和调整方向训练脚本里的超参直接关系到收敛速度和最终效果我把常用配置整理一下超参数默认值参考调整方向说明epochs20数据量大时可以到 30~50但要配合 EarlyStoppingbatch_size32显存小调 16收敛稳但慢调 64optimizeradamlr 默认 0.001loss 震荡时降 lrlosscategorical_crossentropy二分类也可改 binary_crossentropy输出层相应调 sigmoidtrain_test_split0.8 / 0.2样本量大于 5 万时可放宽到 0.9 训练Dropout0.5过拟合增大到 0.6欠拟合减小到 0.3用categorical_crossentropy时标签必须是 one-hot 编码也就是[1,0]代表正常、[0,1]代表异常。如果你的标签文件存的是 0/1 整数训练前需要to_categorical转换。这个错位很常见模型结构没问题但 loss 在训练开始就报负数或直接崩掉检查标签编码是第一优先项。训练时间方面MIT-BIH 全部心拍约 10 万个样本用 CPU 跑 20 个 epoch 大概十几分钟用 GPU 几分钟就完事。这个规模不需要上大服务器普通笔记本就能复现。如果你把全部心拍都生成出来建议在make_own_data.py阶段按比例抽取一部分心拍作为训练集节省时间。3.3 训练曲线图 result.png 教会我们怎么看收敛项目里附带了一张result.png训练完成后会生成类似结构。图里通常有两条曲线训练准确率和验证准确率。正常情况是两条线同步上升然后趋于平缓。如果你看到训练准确率一路飙到接近 100% 而验证准确率停在 85% 左右中间开始分叉这就是典型的过拟合信号。另一种常见形态是 loss 曲线先下降再反弹验证 loss 回升——这说明学习率在后期过大模型参数在最优解附近震荡。处理方式是降低学习率或者加 EarlyStopping。老脚本一般没有 EarlyStopping 回调自己加的话用keras.callbacks.EarlyStopping(monitorval_loss, patience3)就能避免死等 20 个 epoch 跑完。4. 测试脚本 test.py不止输出准确率还要会看错误类型模型训练完test.py负责加载权重并在测试集上评估。很多人误以为测试就是打印一个 accuracy 数字但心电异常检测这种医疗场景里我们更关心的是「异常心拍有没有被漏掉」——也就是召回率。这个项目里test.py会输出混淆矩阵和分类结果图所以读脚本时重点看它怎么组织预测结果。4.1 加载 h5 权重后对单条心拍预测的完整流程test.py会用load_model(ecg_model.h5)加载训练好的模型然后逐条或批量地对测试集样本做model.predict()。预测结果的输出是形状为[n_samples, 2]的概率数组每一行表示该样本属于两类各自的概率需要argmax取最大值索引得到最终类别。# test.py 评估流程核心逻辑 import numpy as np from keras.models import load_model from sklearn.metrics import accuracy_score, recall_score, confusion_matrix model load_model(ecg_model.h5) X_test np.load(test_data.npy).reshape(-1, 150, 1) y_test np.load(test_label.npy) y_pred_probs model.predict(X_test, batch_size32) y_pred np.argmax(y_pred_probs, axis1) # 取概率最大的类别索引 acc accuracy_score(y_test, y_pred) recall_abnormal recall_score(y_test, y_pred, pos_label1) cm confusion_matrix(y_test, y_pred) print(fAccuracy: {acc:.4f}, Abnormal Recall: {recall_abnormal:.4f}) print(cm)recall_score的pos_label1表示把异常类视为正类计算的是所有真实的异常心拍中模型正确识别出的比例。在心电筛查场景中这个指标的优先级比准确率高——宁可多报警不能漏掉一个异常。如果recall_abnormal低于 0.9这个模型在临床上几乎没有实用价值。4.2 混淆矩阵怎么解读哪一类错误代价更高假设测试集 10000 个心拍其中 8000 个正常、2000 个异常。模型输出的混淆矩阵形如预测正常预测异常实际正常7800200实际异常501950这里的 50 个「异常被判成正常」是最要命的错误——漏诊。200 个「正常被判成异常」是误报警会让医生浪费时间复核但不会伤害病人。读test.py输出时盯着混淆矩阵第二行实际异常的左边一格那才是模型真正的短板。如果漏诊多常见做法是调整分类阈值而不是重新训练——比如默认概率超过 0.5 判断为异常可以改为 0.3提高对异常的敏感度。4.3 20-28_08.png 测试图里画了什么这个 png 文件一般展示的是某一段连续心电记录的可视化结果图上会标记哪些位置被模型识别为异常。对照原信号看你能直观感受到模型是抓到了波形异常还是仅仅因为信号噪声而误判。如果发现模型把噪声尖峰也标记为异常说明训练数据里缺少噪声样本需要在make_own_data.py阶段添加带噪声的心拍或者在预处理时做带通滤波。5. 常见踩坑与排查这份心电项目最容易翻车的六个环节老项目代码结构简单但正因为简单很多细节需要自己补。我按自己操作过程中最容易翻车的地方整理几条每一条都是实际遇到过的。5.1 现象ReadMyOwnData.py报错ValueError: cannot reshape array of size ...原因分析make_own_data.py生成的样本数组长度和训练脚本里定义的input_shape不一致。最常见的是切片窗口改了before/after但build_cnn里的input_length没同步改。另外还有可能是读取时把双导联数据都读进来了导致 shape 变成[n, 150, 2]而模型期望的是[n, 150, 1]。解决办法打印X_train.shape确认第二维是不是 150。如果是 300说明信号包含了两个导联改用signal[:, 0]单导联。如果是 149 或 151回头检查切片窗口的边界计算r_peak - before和r_peak after的差值固定为before after。5.2 现象训练 loss 在第一个 epoch 就变成nan原因分析常见原因是输入数据没有做标准化。MIT-BIH 原始信号的幅值范围可能是[-1mV, 1mV]但如果某些记录段存在基线漂移幅值会冲到几百。ReLU 激活函数遇到大数值输入梯度爆炸导致 loss 变成 nan。解决办法在make_own_data.py里对每个心拍做 z-score 标准化或者至少做 min-max 归一化。我以前图省事跳过这步结果折腾了一个下午最后发现就是几行标准化代码的事。注意标准化参数要从训练集计算不要在test.py阶段重新计算否则训练和测试的分布不一致。5.3 现象训练集准确率很高测试集准确率始终低 15% 以上原因分析前面 2.3 节提到的数据泄漏问题或者类别不均衡导致模型倾向把所有样本都预测为多数类。MIT-BIH 数据中正常心拍约占 70%异常心拍占 30%比例不算极端但如果你的切片逻辑把某个病人的所有心拍都分到训练集模型就会过拟合这个病人的个体形态。解决办法按心拍样本整体随机打乱再切分或者在ReadMyOwnData.py里采用 StratifiedShuffleSplit保证训练集和测试集的异常比例一致。我在自己项目里也遇到过测试准确率虚高的情况后来发现是切分前没打乱数据修掉之后泛化表现才真实反映出来。5.4 现象Python 版本或依赖库不兼容导致 import 失败原因分析这个 zip 里的代码年份较早用的可能是 TensorFlow 1.x 或 Keras 2.x 接口风格。现在主流的 TensorFlow 2.x 环境里keras.optimizers.Adam的引用路径可能变了直接跑老代码会报AttributeError。解决办法搭建环境时不要盲求最新版。推荐使用 Python 3.7 左右配合 TensorFlow 2.4 到 2.6然后把from keras.layers import ...改成from tensorflow.keras.layers import ...。旧代码跑不起来时先看 import 部分把 Keras 的引用路径统一改成tensorflow.keras再往下排查。5.5 现象用自己采集的数据生成样本后模型预测结果全部偏向正常类原因分析自己的数据文件采样率和 MIT-BIH 不一样比如 500Hz vs 360Hz切片窗口[before, after]对应的实际时间长度不同。如果采样率是 500Hz原来的before60, after90只覆盖约 0.3 秒刚好把完整的 T 波切掉一部分模型看不到关键波形特征。解决办法先确认自己数据的采样率再按比例放大窗口参数。比如 360Hz 用 150 点对应 0.42 秒500Hz 应该用int(0.42 * 500) ≈ 210点R 峰前后比例保持 60:90 即大约前 40%、后 60%。5.6 现象result.png里验证准确率有抖动忽高忽低原因分析训练时没固定随机种子卷积核的初始权重每次都不同数据打乱顺序也不同。模型每次跑出来的结果会有几个百分点的波动这本身正常但如果波动范围超过 5%说明模型容量过大或训练 epoch 不足。解决办法训练前调用tf.random.set_seed(42)固定默认随机状态同时把验证集样本数量显示出来确认验证集是否太小。验证集小于 1000 个样本时准确率涨跌 2% 就是正常波动不必焦虑。6. 从二分类到多分类与动态切片在这份工程上继续加码的进阶思路训练好二分类模型只是起点。如果你已经跑通了N_and_R_train.py并拿到测试结果下一步值得做的事是改进样本生成方式而不是一直调参。这个环节说两个立即可用的技巧动态切片和类别权重。动态切片的核心思路是不总以 R 峰为中心固定截取而是让窗口中心在一定范围内随机偏移。MIT-BIH 注释文件里的 R 峰位置是人工标注的偏移几个采样点很正常。如果切片时每次都准确落在 R 峰上模型会把「R 峰正好在窗口中心」当成隐式先验到了实际应用时你的检测器给出的 R 峰位置不会那么精准性能立刻下降。常见做法是在make_own_data.py里把每个样本随机偏移[-10, 10]个采样点相当于做了数据增广。def extract_beat_with_jitter(signal, r_peak, before60, after90, jitter_range10): jitter np.random.randint(-jitter_range, jitter_range 1) start r_peak - before jitter end r_peak after jitter if start 0 or end len(signal): return None # 越界直接丢弃避免填充假数据 beat signal[start:end] return (beat - np.mean(beat)) / (np.std(beat) 1e-6)第二个技巧是在model.compile阶段设置class_weight让模型在反向传播时对少数类样本的 loss 给予更高权重。MIT-BIH 中异常心拍占比约 30%不算极端不均衡但如果你只使用其中一部分记录异常比例可能掉到 15% 以下。不处理的话模型会把所有心拍都预测为正常来降低整体 lossrecall 很难看。class_weight的常见公式是总样本数除以类别数乘以该类别样本数比如 7000 正常、3000 异常那正常权重 10000 / (2 * 7000) ≈ 0.71异常权重 10000 / (2 * 3000) ≈ 1.67。把这两个值传进训练模型对异常心拍的学习积极性会显著提高。迁移到自己的数据集时先打印np.bincount(y_train)看分布再算权重。从那以后我每次跑完一版模型都会强制自己先随机抽 10 个测试集样本画出来对比——看看模型分对的是什么波形、分错的是什么波形而不是只看准确率数字。深挖那个被误判的正常心拍多半能发现数据切片或标注上的问题比调十个 epoch 都有用。希望这份工程包的拆解能帮你少走我当初的弯路动手把心电信号跑起来。本文还有配套的精品资源点击获取