简介这份资源是面向深度学习课程大作业场景的完整项目源码围绕12导联心电图ECG的心脏疾病自动诊断展开适合具备Python与深度学习基础、需要完成医疗AI课题或课程设计的学生与开发者。项目将卷积神经网络、循环神经网络、注意力机制与视觉转换器等技术模块化封装覆盖从数据加载、模型训练到性能评估的完整链路可帮助读者理解心电信号特征提取与分类的工程实现思路。压缩包共7个文件以5个py源码为主辅以1个ipynb交互式实验笔记和1个md说明文档整体约74KB体量轻便便于快速阅读与二次修改。其中训练脚本负责数据加载、模型初始化、损失函数与优化器配置及训练循环网络定义文件提供ResNet18结构另有CNN-LSTM-Attention综合模型与ViT实现数据集处理模块涵盖归一化、标准化与数据增强测试模块可输出准确率、召回率、F1分数等指标。目前已有114人学习适合作为课程作业参考或医疗AI入门练手项目。1. 12导联心电图诊断项目从课程大作业到能跑通的分类流水线拿到「深度学习课程大作业-基于12导联心电图心脏疾病诊断python源码.zip」这个标题的人通常卡在同一个地方代码能解压环境能装好但打开主程序一看数据从哪来、12个导联怎么喂进网络、标签怎么对齐、评估指标为什么和论文对不上全是黑匣子。12导联心电图ECG和单导联最大的区别在于空间信息——I、II、III、aVR、aVL、aVF、V1–V6 这12路信号同步采集反映的是心脏电活动在不同方向上的投影所以模型要处理的不是一条时间序列而是12条时间序列的联合模式。这个方向适合两类人一类是课程大作业需要交付可运行代码和报告的学生另一类是想把 ECG 分类作为入门医学信号深度学习实战的工程师。下面按「数据怎么组织 → 模型怎么搭 → 训练怎么调 → 坑在哪 → 怎么验证」的顺序把这条流水线拆开讲清楚。2. 12导联ECG数据组织与标签对齐先搞清楚喂给网络的是什么2.1 12导联信号的物理含义与采样约定12导联不是12个独立传感器而是由9个电极推导出来的12路信号。标准做法是RA右臂、LA左臂、LL左腿三个肢体电极加上 V1–V6 六个胸导电极共9个电极。肢体导联 I、II、III 是差分信号aVR、aVL、aVF 是加压单极导联胸导联 V1–V6 是单极胸导。采样率常见有 100 Hz、250 Hz、360 Hz、500 Hz 几种课程大作业里最常见的是 250 Hz 或 360 Hz。采样率决定了你能捕捉到的最高频率成分——QRS 波群的主要能量在 10–50 HzP 波和 T 波在 0.5–10 Hz所以 250 Hz 对大多数分类任务够用但如果要做精细的 ST 段分析500 Hz 更稳妥。每条记录的长度也不统一。有的数据集是 10 秒短片段有的是 30 秒甚至几分钟。课程大作业里如果拿到的是长记录通常要切窗。切窗策略直接影响样本量和类别平衡后面会细说。2.2 数据格式与读取WFDB、CSV、MAT 三种常见形态ECG 数据最常见的三种格式WFDBPhysioNet 标准格式含 .hea 头文件和 .dat 信号文件、CSV每行一个时间点每列一个导联、MATMATLAB 保存的结构体。课程大作业源码包里如果带数据大概率是 CSV 或 MAT如果只给代码不给数据通常指向 PhysioNet 上的公开数据集。读取 WFDB 用wfdb库import wfdb import numpy as np # record_path 不带扩展名wfdb 会自动找 .hea 和 .dat record wfdb.rdrecord(data/ptbdb/patient001/s0010_re) signal record.p_signal # shape: (n_samples, n_leads) lead_names record.sig_name # [I, II, III, aVR, ...] fs record.fs # 采样率 # 检查导联顺序不同数据集顺序可能不同 print(lead_names) print(signal.shape, fs)读取 CSV 更直接但要注意列顺序和单位import pandas as pd df pd.read_csv(ecg_record.csv) # 假设前12列是导联最后一列是标签 signal df.iloc[:, :12].values.astype(np.float32) labels df.iloc[:, 12].values # 单位检查mV 还是 μV大多数模型输入用 mV if np.abs(signal).max() 20: signal signal / 1000.0 # μV - mV读取 MAT 用scipy.iofrom scipy.io import loadmat mat loadmat(ecg_data.mat) # 键名要看数据集文档常见是 val 或 data signal mat[val] # shape 可能是 (n_samples, 12) 或 (12, n_samples) if signal.shape[0] 12: signal signal.T # 统一成 (n_samples, 12)参数说明p_signal是物理信号单位 mVd_signal是数字信号需要配合adc_gain和baseline转换。课程大作业里如果直接用d_signal而不做转换不同记录的幅值尺度会差很多模型很难收敛。2.3 标签体系单标签多分类 vs 多标签多分类心脏疾病诊断的标签体系有两类。一类是单标签多分类比如把记录分成正常、房颤、室性早搏、束支传导阻滞等互斥类别另一类是多标签多分类因为一个病人可能同时有房颤和左心室肥厚。PTB-XL 数据集就是多标签的每个记录有多个 SCP-ECG 语句。课程大作业里如果源码用的是CrossEntropyLoss那就是单标签如果用BCEWithLogitsLoss那就是多标签。这个区别决定了输出层激活函数和损失函数的选择不能混。import torch import torch.nn as nn # 单标签多分类 num_classes 5 criterion nn.CrossEntropyLoss() # 模型输出 logitsshape (batch, num_classes) # 多标签多分类 criterion nn.BCEWithLogitsLoss() # 模型输出 logitsshape (batch, num_classes) # 标签需要是 0/1 的 float 张量标签对齐是血泪经验最多的地方。常见翻车场景数据切窗后一个长记录被切成多个窗每个窗继承同一个标签但某些窗可能落在噪声段或伪差段标签噪声就进来了。更稳妥的做法是切窗后做质量筛查把幅值饱和、基线漂移严重的窗丢掉。3. 模型选型与搭建1D-CNN、LSTM 还是 Transformer3.1 为什么 1D-CNN 是课程大作业的默认起点12导联 ECG 分类在课程大作业场景下1D-CNN 是最稳的起点。原因有三第一ECG 是局部形态敏感的信号QRS 波群的宽度、R 波幅度、ST 段偏移都是局部特征卷积核天然适合提取第二1D-CNN 参数量可控在几千到几万条记录的数据集上不容易过拟合第三训练速度快CPU 也能跑不依赖高端显卡。一个能跑通的基线结构三层卷积 全局平均池化 全连接。每层卷积后接 BatchNorm 和 ReLU池化用 MaxPool 或 AveragePool。import torch import torch.nn as nn class ECG1DCNN(nn.Module): def __init__(self, num_leads12, num_classes5): super().__init__() self.features nn.Sequential( # 输入 (batch, 12, n_samples) nn.Conv1d(num_leads, 32, kernel_size7, padding3), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(32, 64, kernel_size5, padding2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size3, padding1), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1) # 全局平均池化输出 (batch, 128, 1) ) self.classifier nn.Linear(128, num_classes) def forward(self, x): x self.features(x) # (batch, 128, 1) x x.squeeze(-1) # (batch, 128) return self.classifier(x) # (batch, num_classes)逻辑说明第一层卷积核设 7是为了覆盖 QRS 波群的大致宽度250 Hz 采样下 QRS 约 80–100 ms对应 20–25 个采样点7 个点的感受野偏小但堆叠三层后有效感受野会扩大。通道数从 32 到 64 到 128 是常见倍增策略。全局平均池化替代展平后全连接能显著减少参数量降低过拟合风险。参数说明kernel_size和padding要配合padding kernel_size // 2可以保持序列长度不变。AdaptiveAvgPool1d(1)把任意长度序列压成 1这样模型可以接受不同长度的输入切窗长度变化时不用改结构。3.2 LSTM 和 Transformer 什么时候值得上LSTM 适合捕捉长程依赖比如心率变异性相关的特征。但 12导联 ECG 分类里LSTM 单独用效果通常不如 CNN常见做法是 CNN 提局部特征 LSTM 提时序依赖。Transformer 在 ECG 上的应用这两年多起来了尤其是多标签分类任务因为自注意力可以建模不同导联之间的关系。但课程大作业场景下Transformer 训练成本高数据量不够时容易过拟合不建议作为首选。如果一定要试 Transformer建议用轻量版本层数 2–4 层头数 4–8嵌入维度 64–128并且加较强的 Dropout 和权重衰减。class ECGTransformer(nn.Module): def __init__(self, num_leads12, num_classes5, d_model64, nhead4, num_layers2): super().__init__() # 把12导联投影到 d_model 维 self.input_proj nn.Linear(num_leads, d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforwardd_model * 4, dropout0.3, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.classifier nn.Linear(d_model, num_classes) def forward(self, x): # x: (batch, 12, n_samples) - (batch, n_samples, 12) x x.permute(0, 2, 1) x self.input_proj(x) # (batch, n_samples, d_model) x self.encoder(x) # (batch, n_samples, d_model) x x.mean(dim1) # 时序平均池化 return self.classifier(x)参数说明dropout0.3是经验值数据量小于 1 万条时建议 0.3–0.5。batch_firstTrue让输入格式是(batch, seq, feature)和 PyTorch 默认的(seq, batch, feature)不同容易搞混注意检查。3.3 导联融合策略早融合、晚融合还是注意力融合12导联怎么进网络有三种常见策略。早融合是把12导联当成12个通道直接送进 1D-CNN这是最简单的做法也是课程大作业源码里最常见的。晚融合是每个导联单独过一个网络最后拼接特征或投票。注意力融合是让模型自己学每个导联的权重。早融合的优点是实现简单、参数量小缺点是模型可能忽略导联间的差异。晚融合参数量大但能捕捉导联特异性。注意力融合介于两者之间适合数据量中等的情况。class LeadAttention(nn.Module): def __init__(self, num_leads12, feat_dim128): super().__init__() # 为每个导联学一个标量权重 self.attention nn.Sequential( nn.Linear(feat_dim, feat_dim // 4), nn.ReLU(), nn.Linear(feat_dim // 4, 1) ) def forward(self, lead_features): # lead_features: (batch, num_leads, feat_dim) weights self.attention(lead_features) # (batch, num_leads, 1) weights torch.softmax(weights, dim1) return (lead_features * weights).sum(dim1) # (batch, feat_dim)逻辑说明先对每个导联提取特征再用注意力算权重最后加权求和。这样模型可以学到某些疾病主要看某几个导联比如下壁心肌梗死主要看 II、III、aVF。4. 训练流程与参数设置让模型真正收敛4.1 数据切分不能随机打乱记录ECG 数据切分有个大坑如果同一个病人的多条记录被分到训练集和验证集验证指标会虚高。正确做法是按病人 ID 切分确保同一个病人的所有记录只出现在一个集合里。from sklearn.model_selection import GroupShuffleSplit # patient_ids 是每条记录对应的病人ID gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(X, y, groupspatient_ids)) X_train, X_val X[train_idx], X[val_idx] y_train, y_val y[train_idx], y[val_idx]参数说明test_size0.2是常见比例数据量小于 5000 条时建议 0.15–0.2数据量大时可以降到 0.1。random_state固定后结果可复现课程大作业报告里要写清楚。4.2 类别不平衡处理加权损失 vs 重采样心脏疾病数据里正常记录通常占多数某些疾病类别可能只有几十条。直接训练会导致模型偏向多数类。两种处理方式加权损失和重采样。加权损失更简单按类别频率的倒数设权重from sklearn.utils.class_weight import compute_class_weight import numpy as np class_weights compute_class_weight( balanced, classesnp.unique(y_train), yy_train ) class_weights torch.tensor(class_weights, dtypetorch.float32) criterion nn.CrossEntropyLoss(weightclass_weights)重采样包括过采样少数类和欠采样多数类。过采样容易过拟合欠采样会丢信息。折中做法是用 SMOTE 在特征空间插值但 ECG 信号插值后物理意义不明确不太推荐。课程大作业里加权损失够用。4.3 学习率、批大小和早停学习率是最关键的参数。1D-CNN 在 ECG 上Adam 优化器配 1e-3 学习率是常见起点。如果 loss 震荡降到 1e-4如果收敛太慢升到 3e-3。批大小 32 或 64 都行显存够就大一点。import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau model ECG1DCNN(num_leads12, num_classes5) optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience5) best_val_loss float(inf) patience_counter 0 early_stop_patience 15 for epoch in range(100): model.train() for xb, yb in train_loader: optimizer.zero_grad() logits model(xb) loss criterion(logits, yb) loss.backward() optimizer.step() model.eval() val_loss 0.0 with torch.no_grad(): for xb, yb in val_loader: logits model(xb) val_loss criterion(logits, yb).item() val_loss / len(val_loader) scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pth) patience_counter 0 else: patience_counter 1 if patience_counter early_stop_patience: print(fEarly stop at epoch {epoch}) break参数说明weight_decay1e-4是 L2 正则防止过拟合。ReduceLROnPlateau在验证 loss 不降时把学习率减半patience5表示连续 5 个 epoch 不降就减。早停patience15是经验值数据量小可以设 10数据量大可以设 20。4.4 评估指标准确率会骗人类别不平衡时准确率没有参考价值。一个把所有样本都预测为正常的模型在正常占 90% 的数据集上准确率也是 90%。要看的是每类的 Precision、Recall、F1以及宏平均 F1 和加权平均 F1。from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels [], [] with torch.no_grad(): for xb, yb in val_loader: logits model(xb) preds logits.argmax(dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(yb.cpu().numpy()) print(classification_report(all_labels, all_preds, digits4)) print(confusion_matrix(all_labels, all_preds))逻辑说明classification_report会输出每类的 precision、recall、f1-score 和支持样本数。重点看少数类的 recall如果某个疾病类的 recall 低于 0.5说明模型基本没学到这个类。混淆矩阵能看出哪些类之间容易混比如房颤和房扑容易混因为两者都是室上性心律失常。5. 避坑与排查12导联ECG分类里最容易翻车的5个地方5.1 现象训练 loss 正常下降验证 loss 从第一个 epoch 就很高原因数据泄漏。同一个病人的记录同时出现在训练集和验证集模型记住了病人特有的波形模式换到验证集就失效。解决按病人 ID 切分用GroupShuffleSplit或手动按patient_id分组。切分后检查训练集和验证集的病人 ID 有没有交集。train_patients set(patient_ids[train_idx]) val_patients set(patient_ids[val_idx]) assert len(train_patients val_patients) 0, 数据泄漏5.2 现象模型在验证集上所有样本都预测为同一类原因类别极度不平衡 学习率过大。模型发现全部预测为多数类就能拿到较低的 loss直接躺平。解决先加类别权重再把学习率降到 1e-4 试一轮。如果还是不行检查标签编码有没有错位比如标签 0 和 1 的含义搞反了。5.3 现象训练时 loss 变成 NaN原因输入信号幅值过大或包含 NaN。ECG 信号里偶尔会有缺失段读进来是 NaN送进网络后梯度爆炸。解决读数据后立刻做检查assert not np.isnan(signal).any(), 信号里有 NaN assert not np.isinf(signal).any(), 信号里有 Inf # 幅值裁剪防止极端值 signal np.clip(signal, -5.0, 5.0) # mV参数说明-5.0到5.0mV 覆盖了正常 ECG 的幅值范围R 波最高通常不超过 3 mV超过 5 mV 的基本是伪差或电极脱落。5.4 现象换一个数据集后模型完全失效原因不同数据集的采样率、导联顺序、单位不统一。比如训练集是 250 Hz、mV 单位、导联顺序 I, II, III, aVR, aVL, aVF, V1–V6测试集是 500 Hz、μV 单位、导联顺序 V1–V6, I, II, III, aVR, aVL, aVF。解决写一个统一的预处理函数把所有数据集转成相同采样率、相同导联顺序、相同单位。重采样用scipy.signal.resample或torchaudio的Resample。from scipy.signal import resample def standardize(signal, fs_orig, fs_target250, lead_orderNone): # 重采样 n_samples_new int(signal.shape[0] * fs_target / fs_orig) signal resample(signal, n_samples_new, axis0) # 导联重排 if lead_order is not None: signal signal[:, lead_order] return signal.astype(np.float32)5.5 现象验证集 F1 很高但实际用的时候预测结果很离谱原因验证集和测试集分布不一致或者模型过拟合了验证集。课程大作业里常见的是反复调参直到验证集指标好看但没留独立的测试集。解决切三份——训练集、验证集、测试集。验证集用来调参和早停测试集只在最后跑一次。测试集比例 10%–15% 就够。6. 进阶技巧用导联注意力可视化验证模型是否学到了医学意义模型跑通之后下一步是验证它到底学到了什么。12导联 ECG 有个天然优势每个导联对应特定的心脏解剖区域如果模型学到的注意力权重和医学先验一致说明它学到了有意义的特征而不是靠数据泄漏或伪差。具体做法在模型里加一个导联注意力层训练完后把注意力权重导出来按导联画热力图。比如下壁心肌梗死主要看 II、III、aVF前壁心肌梗死主要看 V1–V4如果模型对这类样本的注意力确实集中在对应导联上说明它学到了正确的模式。import matplotlib.pyplot as plt import seaborn as sns # 假设模型有 get_lead_attention 方法返回 (batch, num_leads) model.eval() attentions [] with torch.no_grad(): for xb, yb in test_loader: attn model.get_lead_attention(xb) # (batch, 12) attentions.append(attn.cpu().numpy()) attentions np.concatenate(attentions, axis0) # (n_samples, 12) mean_attn attentions.mean(axis0) # (12,) lead_names [I, II, III, aVR, aVL, aVF, V1, V2, V3, V4, V5, V6] plt.figure(figsize(10, 4)) sns.barplot(xlead_names, ymean_attn) plt.title(Average Lead Attention) plt.ylabel(Attention Weight) plt.show()逻辑说明get_lead_attention需要在模型里实现返回 softmax 后的导联权重。如果模型没有这个层可以用梯度加权类激活映射Grad-CAM的 1D 版本替代对输入信号求梯度看哪些导联的梯度幅值大。参数说明注意力权重是相对值看的是导联之间的排序不是绝对值。如果所有导联权重都差不多说明模型没有学到导联特异性可能需要检查输入是否做了导联归一化或者数据量是否足够。另一个验证方法是按疾病类别分别看注意力分布。比如把测试集里所有房颤样本挑出来看注意力集中在哪些导联。房颤的 f 波在 V1 导联最明显如果模型对房颤样本的 V1 注意力权重显著高于其他导联说明它抓到了这个特征。我自己的习惯是每次训完一个 ECG 模型先不看准确率先看导联注意力热力图。如果热力图和医学先验对不上准确率再高我也不敢用。这个习惯帮我省了很多后悔药——有一次模型在验证集上 F1 到了 0.92但注意力全集中在 aVR 上后来发现是数据预处理时导联顺序搞错了aVR 被当成了 II 导联。修正后 F1 降到 0.85但那个 0.85 才是真实的。希望帮到你。本文还有配套的精品资源点击获取