
简介基于Python的心电图信号分类完整项目资源面向生物医学工程、电子信息及深度学习方向的课程设计、期末大作业与毕业设计。心电图信号具有非平稳、随机性强等特点传统机器学习方法难以高效提取深层特征本项目针对心律失常5分类问题提供了CNN、LSTM以及小波变换WT结合的多种模型结构覆盖从原始信号预处理、特征提取到模型训练与评估的完整流程。包体共36个文件包括7个Python源码含训练、评估与数据处理模块、10个预训练模型权重pth格式、1份Word使用说明文档以及17张训练曲线、网络结构与权重分布截图压缩包整体约50.19MB目录按模块划分清晰。目前已有246人学习下载。通过该资源可快速复现实验、对比不同模型效果也可依据说明文档替换数据或调整参数进一步开展消融实验或模型改进是理解心电图深度学习方法并完成课程项目的实用参考。1. 心电图5分类模型为什么这个任务比你想的更值得做心血管疾病一直是临床数据挖掘里最不缺样本的领域心电图信号分类更是其中最典型的落地场景。拿到一份“基于Python的心电图信号设计模型结构完成5分类任务”的源码包你真正需要关心的是三件事模型能不能在普通显卡甚至CPU上跑起来、标签划分是否符合临床直觉、换一份数据后会不会直接翻车。市面上大量开源ECG项目要么管线臃肿要么只在学校公开数据集上自嗨真正能移植到院内数据或可穿戴设备上的少之又少。这篇文章从模型结构和训练代码入手把一份ECG五分类任务拆成数据预处理、网络设计、训练验证、推理部署四段给出的都是可以直接照抄复现的命令和参数。适合正在做心律失常自动识别、心电信号分类入门以及想把公开数据集跑出稳定指标再做二次开发的工程师。2. 心电图分类任务的本质先处理信号再谈模型2.1 5分类标签到底怎么分才合理在做模型设计之前最容易被忽略的是标签本身的定义。常见的MIT-BIH心律失常数据集中有超过15种心跳类型但临床真正有价值且样本量足够支撑深度学习的通常收敛到5类正常窦性心律N、房颤AF、房扑AFL、室性早搏PVC、ST段改变ST。这五类覆盖了心律失常筛查、卒中风险预警和缺血性改变检测三个最常见的临床诉求MIT-BIH中的注释文件也基本能映射到这五类上。有同行会直接把“左右束支阻滞”也塞进五分类里这会让模型学到的特征边界变得非常模糊。束支阻滞本质是传导异常和房颤、早搏的波形形态差异虽然明显但和ST段改变在部分导联上容易混淆。我的经验是宁可牺牲类别数量也不能牺牲类别可分性。源码里如果已经给定了标签映射建议先检查它是否把N与L左束支阻滞、R右束支阻滞做了合并如果合并了模型的鲁棒性会好很多。2.2 信号裁剪和采样率对齐最容易被数据坑死的一步心电图原始数据的问题从来不在信号本身而在采样率和时间轴。MIT-BIH原始采样率是360Hz但很多公开预处理版本已经被重采样到125Hz或128Hz有些可穿戴设备甚至只有50Hz。模型结构里第一个卷积层的感受野必须按实际采样率反推例如128Hz采样率下一个0.1秒的P波只占13个采样点第一层卷积核如果设成32等于直接把P波扫没了。import wfdb import numpy as np def load_ecg_record(record_path, channel0, target_freq128): record wfdb.rdrecord(record_path, channels[channel]) signal record.p_signal[:, 0] original_freq record.fs target_len int(len(signal) * target_freq / original_freq) # 使用 scipy 重采样而不是 numpy.interp避免高频毛刺 from scipy.signal import resample resampled resample(signal, target_len) return resampled, target_freq这段代码的逻辑核心是用wfdb读取原始记录后以目标采样率为基准做整体重采样而不是分段截取后再插值。resample基于FFT实现能保留信号的整体频谱特征比numpy.interp的线性插值在ST段这种低频成分上可靠得多。如果你的数据源已经是统一采样率这一步可以跳过但务必在代码里保留采样率断言否则后续入模数据长度不一致会直接报错。2.3 窗口长度与滑窗切分多长才是最优输入模型输入长度通常取2到3秒对应256到384个采样点128Hz下。这个窗口长度兼顾了单拍心搏和连续节律信息太短到1秒房颤的RR间期不规则特征根本统计不出来太长到5秒一个窗口里可能混入2到3种不同心拍形态标签分配变得很棘手。def make_windows(signal, label, window_size256, stride128): windows [] labels [] for start in range(0, len(signal) - window_size, stride): end start window_size windows.append(signal[start:end]) labels.append(label) return np.array(windows), np.array(labels)这里window_size256、stride128意味着相邻窗口有50%重叠数据增强效果明显训练样本量直接扩大一倍。但要注意如果stride设得太小小于64重叠率过高会让训练集和验证集之间的泄漏问题变得严重同一个心跳被切进多个窗口后模型相当于变相记住了训练样本。我在实践中的做法是训练集用stride128验证集和测试集直接不切片用完整记录做预测按多数投票给整条记录定标签。2.4 模型结构设计1D-CNN搭配BiLSTM和注意力机制ECG信号分类的模型结构我一般不推荐直接套用图像分类的ResNet或EfficientNet心电图是一维时序信号局部形态特征和长程节律特征同样重要单纯堆卷积层会增加参数量却带不来精度提升。一个经过验证的稳妥结构是三层1D卷积提取局部波形特征接双向LSTM捕捉心拍间的时序依赖最后加一个注意力层让模型自动聚焦在关键的ST段和QRS波群区域。import torch.nn as nn class ECGMixModel(nn.Module): def __init__(self, num_classes5, input_channels1, signal_length256): super().__init__() self.conv1 nn.Sequential( nn.Conv1d(input_channels, 64, kernel_size15, stride1, padding7), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2) ) self.conv2 nn.Sequential( nn.Conv1d(64, 128, kernel_size9, stride1, padding4), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2) ) self.conv3 nn.Sequential( nn.Conv1d(128, 128, kernel_size5, stride1, padding2), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(32) ) self.lstm nn.LSTM(input_size128, hidden_size64, num_layers2, batch_firstTrue, bidirectionalTrue) self.attention nn.Sequential( nn.Linear(128, 64), nn.Tanh(), nn.Linear(64, 1) ) self.classifier nn.Linear(128, num_classes) def forward(self, x): x self.conv1(x) x self.conv2(x) x self.conv3(x) x x.permute(0, 2, 1) lstm_out, _ self.lstm(x) attn_weights torch.softmax(self.attention(lstm_out), dim1) context torch.sum(attn_weights * lstm_out, dim1) return self.classifier(context)这段代码是典型的CNN-LSTM-Attention融合结构。第一层卷积核设成15对应128Hz采样率下约0.12秒的时窗能覆盖QRS波群的完整宽度第二层9个采样点的核聚焦在T波和P波的局部形态第三层用AdaptiveAvgPool1d把序列长度压成32是为了给LSTM减少计算负担。bidirectionalTrue让LSTM同时看到当前时刻的前后心拍对ST段改变这类需要对比基线段的特征非常有用。注意力机制最终会对不同时间步的输出加权求和模型会自动学到“应该重点看哪个位置”的能力。3. 训练管线搭建从数据加载到模型收敛3.1 数据集划分病人级别切分才能避免数据泄漏心电图数据集切分有一个容易犯且代价极高的错误随机划分样本。同一个病人的多段心电图高度相似随机划分会让模型在验证集上表现优异但一碰到新病人就断崖式下降准确率能直接从95%跌到65%。必须按病人ID进行分组划分保证同一个人的所有心拍只出现在训练集或验证集中一个集合里。源码包里如果提供了病人级别索引文件就用它没有就自己从记录文件名前缀中解析。3.2 类别不平衡处理加权损失比简单过采样更可靠五个类别中正常样本通常占一半以上房扑样本最少可能只有百分之二三。直接训练会让模型把所有样本都学成正常类整体准确率看似很高混淆矩阵却一塌糊涂。处理手段上我一般优先用类别权重而不是过采样过采样容易导致模型在少数类上过拟合特别是ECG这类噪声较强的信号。import torch.nn as nn def compute_class_weight(labels, num_classes5): counts np.bincount(labels, minlengthnum_classes).astype(np.float32) total counts.sum() weights total / (num_classes * counts) weights np.clip(weights, 0.5, 5.0) # 限制最大权重 return torch.tensor(weights, dtypetorch.float32) criterion nn.CrossEntropyLoss(weightcompute_class_weight(train_labels))这里权重计算采用总数 / (类别数 * 该类样本数)的平衡策略让各类损失贡献大致相等同时用clip把权重限制在0.5到5.0之间防止个别极少数类别权重过大导致训练震荡。训练时如果发现少数类准确率上来了但正常类开始崩就手动调低clip上限这是最常见的调参手段。3.3 训练超参数学习率、批次和轮次怎么配合心电图模型训练有一个与其他时序任务不太一样的地方batch_size不宜过大。ECG信号本身的振幅在毫伏级别不同样本间的差异微妙batch太大时梯度方向被平均得过平滑模型难以捕捉到形态上的精细差异。通常batch_size设在32到64之间学习率从1e-3起步验证集指标停止上升后乘以0.1衰减。from torch.optim import AdamW from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemax, factor0.1, patience5, verboseTrue) for epoch in range(50): train_one_epoch(model, train_loader, criterion, optimizer) val_acc validate(model, val_loader) scheduler.step(val_acc) if scheduler.num_bad_epochs 15: print(早停连续15轮验证集无提升) breakReduceLROnPlateau的patience5意味着连续5轮验证集不涨就降学习率降完之后再等15轮还不涨就停止训练。这套配置在ECG分类任务上相当奏效一般15到25轮就能收敛总训练时间在单张RTX 3060上不超过半小时。如果换成纯CPU环境建议把模型第三层卷积后的AdaptiveAvgPool1d(32)改成(16)LSTM隐藏层减半整体参数量能压缩40%。3.4 训练完成后必做的验证混淆矩阵和单类别指标训练结束后只打印一个accuracy就万事大吉的做法非常危险。ECG五分类任务的临床价值更看重敏感性recall——房颤漏诊的后果严重得多而正常类被误判为房颤顶多多做一次复查。训练脚本里至少应该输出每个类别的精确率、召回率和F1值并保存混淆矩阵热力图。from sklearn.metrics import classification_report, confusion_matrix y_true, y_pred [], [] model.eval() with torch.no_grad(): for x, y in test_loader: out model(x) _, pred torch.max(out, 1) y_true.extend(y.numpy()) y_pred.extend(pred.numpy()) report classification_report(y_true, y_pred, target_names[N, AF, AFL, PVC, ST]) print(report)从这份报告里你可以直接看出模型是否把ST段改变和正常类混淆、是否把房扑和房颤混为一谈。大多数情况下模型瓶颈都集中在这两对类别上。看到这种结果不需要急着调网络结构先检查预处理阶段滤波器的截止频率是否合适、窗口里是否包含足够的完整心拍、训练数据里这些类别的样本量是否确实太少。4. 模型推理与落地从PyTorch模型到实际可用4.1 滑动窗口预测与多数投票让结果更快更稳在实际部署中模型接收到的往往不是一段整齐划一的定长窗口而是连续不断的流式心电图数据。常见的做法是用滑动窗口逐段推理再对整条记录的窗口预测结果做多数投票或概率平均得到记录级别的最终判断。def predict_record(model, full_signal, window_size256, stride32): model.eval() pred_probs [] with torch.no_grad(): for start in range(0, len(full_signal) - window_size, stride): window full_signal[start:start window_size] window torch.tensor(window, dtypetorch.float32).unsqueeze(0).unsqueeze(0) out torch.softmax(model(window), dim1) pred_probs.append(out.numpy().squeeze(0)) avg np.mean(pred_probs, axis0) return int(np.argmax(avg)), avg这里推理时stride比训练时更小推荐32让窗口重叠率更高相当于对同一条记录做了多次独立的预测再取平均能显著降低单窗口噪声引起的误判。同时返回概率向量而不是只返回标签方便下游系统做阈值判断——比如概率低于0.7时输出“不确定建议复核”而不是硬给一个结果这在临床应用上才是负责任的做法。4.2 模型导出为ONNX脱离PyTorch环境也能推理模型训练好之后如果要部署到院内系统或穿戴设备PyTorch的torch.save保存格式并不通用而且依赖完整的PyTorch运行环境。先把模型导出成ONNX格式是更专业的选择ONNX Runtime的CPU推理速度比PyTorch原生快不少内存占用也更低。import torch.onnx model ECGMixModel(num_classes5) checkpoint torch.load(best_model.pth, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict]) model.eval() dummy_input torch.randn(1, 1, 256) torch.onnx.export( model, dummy_input, ecg_classifier.onnx, input_names[ecg_signal], output_names[logits], dynamic_axes{ecg_signal: {0: batch_size}, logits: {0: batch_size}}, opset_version12 )注意dynamic_axes的参数为什么必须加上ECG信号长度可能随设备和采样率变化固定长度输入会逼着部署方做无必要的padding或裁剪动态轴支持能让同一个模型应对不同长度的输入这是从研究代码走向部署的关键细节。ONNX导出后还需要跑一次onnxruntime的验证确认输出与PyTorch原模型基本一致差异小于1e-4常见问题在LSTM算子在不同opset版本下的兼容性差异遇到输出对不上的情况优先升级opset版本到13或14。4.3 模型性能边界哪些场景会翻车ECG分类模型即便在测试集上做到了95%以上准确率部署到真实场景仍然可能表现不佳。基线漂移是最常见的问题患者轻微移动或电极接触不良会让ST段产生类似缺血改变的漂移模型很容易误判。常见做法是在预处理阶段加入高通滤波器截止频率0.5Hz能在不损伤ST段的前提下滤掉大部分基线漂移。另外还有一类实际场景问题是导联错位或缺失。多数开源模型是基于单导联或多导联联合训练的但院内监测有时会缺失某个导联的数据。这时候模型表现为输出概率分布极为平均哪一类都不确定。碰到这种情况不要硬着头皮做源码包里如果有多个单导联模型的话投票融合比重新训练一个多导联模型的成本低得多。5. ECG模型训练避坑5个必须知道的教训5.1 采样率不一致直接导致输入长度报错或静默错位现象训练时报size mismatch或模型可以跑但准确率始终在50%徘徊。原因数据集中部分样本是128Hz、部分是360Hz重采样时机不对或根本没有重采样窗口对应的实际时间跨度不一致。解决在数据加载器的__getitem__里强制断言len(signal) window_size并在预处理阶段记录每条样本的原始采样率。换数据集时必须重新打印统计信息不要假设所有数据都符合同一套参数。5.2 按文件随机划分训练验证集导致跨病人泛化失败现象验证集准确率97%测试集新病人只有70%。原因同一个病人的连续记录被同时切进了训练和验证模型学到了病人个体的形态偏好而不是类别共性。解决必须按病人ID维度切分训练、验证、测试三方的病人ID集合不能有交集。源码包里如果没提供病人分组信息直接从文件命名前缀提取比如101_001和101_002都归属于病人101要放在同一个集合里。5.3 窗口重叠率过高训练时严重过拟合现象训练损失持续下降验证准确率在某个点后开始缓慢下降或震荡。原因stride设太小比如8或16相邻窗口大部分采样点重合模型实际上反复学习同一段信号数据多样性不足。解决训练阶段stride至少设为窗口长度的50%即256的窗口用128的步长。如果样本量仍然不足优先补充更多病人数据而不是降低stride制造更多重叠样本。5.4 类别权重设太高少数类过拟合现象房扑的F1高达0.95正常类反而降到0.85以下整体准确率也跌了。原因权重clip上限拉到10.0以后模型把所有稍有一点点异常形态的窗口都判成房扑实际上是记忆了少数类样本上的噪声。解决把clip上限从5.0降到2.5或2.0同时观察正常类的召回率是否能回到0.92以上。如果还是不行就需要对房扑类做针对性数据增强——给信号加少量高斯噪声或做小幅时间拉伸而不是继续加权重。5.5 验证集上ST改变与正常类的混淆始终降不下去现象其他四类的F1都高于0.9ST改变类长期卡在0.7以下。原因ST段改变在形态上确实是渐进的从正常到异常没有明确边界单窗口模型缺少个体基线的对比信息。解决在窗口送入模型前把窗口前半秒的均值作为基线减掉做一次简单的归一化很多时候能带来5个点以上的提升。如果还不行把窗口长度从256扩到384给模型更多的心拍来建立个体化基线。6. 把模型用起来的两个进阶姿势集成推理与可视化解释模型的最后一公里往往不在于精度再涨一个点而在于能不能让临床使用者信任这个黑匣子的判断。我用两个技巧来补足这一层第一个是五类模型加二类异常检测模型的串联集成第二个是类激活图可视化两者组合起来能让模型从“端到端黑盒”变成“可追溯的辅助判断工具”。二类模型的任务是判断“正常”还是“异常”五类模型只在二类模型判定为异常时才参与具体分类。这样做最大的好处是二类模型形态学特征显著对ST改变这类容易被五类模型混淆的样本也有极高的召回率。实际部署中我遇到过一次特别典型的翻车经历——一位患者的动态心电图里有间歇性房颤五类模型因为他平时刻率高而漏判了那一小段但二类模型通过RR间期方差判异常后强制触发了复核机制恰好挽回了这次漏诊。当然代价是推理时间几乎翻倍但在院内离线场景下没有任何问题。def ensemble_predict(anomaly_model, ecg_model, signal): anomaly_prob anomaly_model(signal) # 0-正常, 1-异常 if anomaly_prob[1] 0.5: return N logits ecg_model(signal) idx torch.argmax(logits, dim1).item() return [N, AF, AFL, PVC, ST][idx]每个工程师都应该把这条代码当成默认不变量看待分类的结果不是终点置信度和中间层特征才是。把最后一层卷积的梯度反传到输入信号上把模型的注意力区域投影回原始波形上可以直接看到模型是根据哪一段波形做出的判断。做一次这样的可视化分析后你会立刻明白模型到底有没有真的学到P波和QRS波群的形态差异还是在走捷径拟合噪声——我的经验里超过一半的“高准确率”模型在可视化面前根本站不住脚。把这份源码跑通只是第一步真正值得投入时间的是把模型结构理解透彻后根据自己的数据特点重新设计预处理、调优标签体系和集成策略。希望这篇笔记能帮你在心电图五分类任务上少走几步弯路把精力花在真正有价值的地方。本文还有配套的精品资源点击获取