简介:本资源面向心电信号处理与医学AI方向的学习者与开发者,提供一套基于Swin Transformer与多模态融合的MIT-BIH心律失常五分类(A/L/N/R/V)完整工程。针对传统CNN在长程依赖建模与样本不平衡上的瓶颈,方案引入层级注意力机制,并以Focal Loss(γ=2.0)强化V类等难例学习,验证集召回率提升约19%。压缩包共2000个文件,含1993张jpg心电图纸样本、3个py训练与推理脚本、2个json标签映射与结果文件及readme说明,整体约205.53MB,可直接用于复现与二次开发。资源覆盖训练、验证到部署全链路:自动生成混淆矩阵、ROC-AUC、PR曲线与学习率衰减可视化,支持Adam/SGD动态优化与断点续训;推理端集成PyQt5界面,支持JPG/PNG/BMP输入、Top-3概率输出与置信度色谱,单图耗时低于0.3秒。五分类平均精度96.7%,模型仅28M参数,可CPU/GPU切换,适合临床辅助诊断与可穿戴边缘计算场景。已有45人学习。
1. 拿到一个「已经训练好」的 MIT-BIH 心电诊断系统,先别急着跑推理
你手上可能正躺着一个基于 Swin Transformer 与多模态融合的 MIT-BIH 心电智能诊断系统,作者说「已经训练好」。但真正落地过 ECG 分类的人都知道,权重文件不等于可用系统——MIT-BIH 只有 48 条记录、约 11 万拍,类别极度不均衡,Swin Transformer 又是为 2D 图像设计的骨干,直接套到 1D 心电信号上,预处理、维度映射、标签对齐任何一环出错,推理结果都会变成玄学。这篇笔记不讲论文复现,只讲一件事:拿到这套「Swin Transformer + 多模态融合 + 可解释性」的成品,怎么在本地跑通、怎么验证它没骗你、怎么把它推到临床可解释的部署形态。适合已经懂点 PyTorch、手上有 MIT-BIH 数据、想把 ECG 智能诊断真正用起来的一线工程师和医学信号处理方向的研究生。读完你能判断这套方案值不值得投入,以及投入后每一步该盯什么参数。
2. Swin Transformer 做 ECG 分类:为什么选它,以及输入到底怎么摆
2.1 从 1D 心电到 2D 特征图:Swin 的窗口注意力为什么适配 ECG
Swin Transformer 的核心是移位窗口(Shifted Window)注意力,它把注意力计算限制在局部窗口内,再通过窗口移位实现跨窗口信息交换。这个设计对 ECG 特别友好,原因有两个。第一,心电信号的诊断信息高度局部化——QRS 波群、ST 段、T 波的形态异常都集中在几十到几百毫秒的窗口里,全局自注意力反而会被大量正常节拍稀释。第二,MIT-BIH 的采样率是 360 Hz,一条 30 分钟记录约 65 万个采样点,直接做全局注意力显存吃不消,窗口机制把复杂度从平方级压到线性级。
但 Swin 原生吃的是 2D 图像,ECG 是 1D 时序。常见做法是把单导联或多导联信号切成固定长度的片段,再做时频变换(短时傅里叶或小波)转成 2D 时频图,或者直接把多导联信号堆叠成「通道 × 时间」的伪 2D 张量。我一般会先确认作者用的是哪种映射,因为这决定了你预处理阶段要不要做时频变换。
import numpy as np import torch def ecg_to_swin_input(signal, fs=360, win_sec=10, img_size=224): """ signal: (n_leads, n_samples) 原始心电 返回: (3, img_size, img_size) 供 Swin 使用的伪 2D 张量 """ win_len = int(fs * win_sec) n_leads, n_samples = signal.shape n_win = n_samples // win_len # 截断到整数个窗口 signal = signal[:, :n_win * win_len] # 重塑为 (n_win, n_leads, win_len) seg = signal.reshape(n_leads, n_win, win_len).transpose(1, 0, 2) # 归一化:按窗口做 z-score,避免个体基线漂移干扰 seg = (seg - seg.mean(axis=-1, keepdims=True)) / (seg.std(axis=-1, keepdims=True) + 1e-8) # 插值到 Swin 需要的空间尺寸,这里用简单线性插值示意 from scipy.ndimage import zoom scale = img_size / win_len seg_2d = zoom(seg, (1, 1, scale), order=1) # 补齐或裁剪到 img_size if seg_2d.shape[-1] < img_size: pad = img_size - seg_2d.shape[-1] seg_2d = np.pad(seg_2d, ((0,0),(0,0),(0,pad)), mode='edge') else: seg_2d = seg_2d[:, :, :img_size] # 复制到 3 通道以匹配 ImageNet 预训练权重 out = np.stack([seg_2d[:, 0, :]] * 3, axis=0) return torch.tensor(out, dtype=torch.float32)这段代码的关键参数有三个。win_sec=10是窗口长度,MIT-BIH 里一条记录通常切 5 到 10 秒,太短会切碎 QRS 波群,太长则一个窗口里混入多个节拍,分类标签不好对齐。img_size=224是 Swin 的标准输入,如果你用的骨干是 Swin-T 或 Swin-B,这个值一般不用改。fs=360是 MIT-BIH 的采样率,如果你换了数据集,这个必须同步改,否则窗口长度算错,整个时序对齐就崩了。
2.2 多模态融合到底融了什么:导联、时频、临床特征的三种拼法
标题里的「多模态融合」在 ECG 场景下通常指三类信息的组合:多导联信号(MLII、V1 等)、时频域特征(STFT 谱图、小波系数)、以及临床元数据(年龄、性别、用药史)。融合方式决定了你部署时的输入接口长什么样。
常见做法有三种。早期融合是把不同模态在输入层拼接,比如把时频图和原始波形堆成多通道张量,送进同一个 Swin 骨干。中期融合是各模态走独立编码器,在中间层做 cross-attention 或 concat。晚期融合是各模态各自出分类 logits,最后加权投票。我见过的大多数「Swin + 多模态」方案用的是中期融合,因为 Swin 的层级结构天然适合在不同 stage 接入辅助模态。
import torch.nn as nn class MidFusionECG(nn.Module): def __init__(self, swin_backbone, n_clinical=5, n_classes=5): super().__init__() self.swin = swin_backbone # 输出维度假设为 768 self.clinical_mlp = nn.Sequential( nn.Linear(n_clinical, 64), nn.ReLU(), nn.Linear(64, 128) ) # 把临床特征投影到与 Swin 同维,再做门控融合 self.gate = nn.Sequential( nn.Linear(768 + 128, 256), nn.Sigmoid() ) self.classifier = nn.Linear(768, n_classes) def forward(self, ecg_img, clinical): feat_ecg = self.swin(ecg_img) # (B, 768) feat_cli = self.clinical_mlp(clinical) # (B, 128) fused = torch.cat([feat_ecg, feat_cli], dim=-1) g = self.gate(fused) # (B, 256) # 门控只作用于 ECG 特征的前 256 维做示意 feat_ecg_gated = feat_ecg.clone() feat_ecg_gated[:, :256] = feat_ecg[:, :256] * g return self.classifier(feat_ecg_gated)这里n_clinical=5要和你实际能拿到的临床字段数对齐,MIT-BIH 本身不带临床元数据,如果你只有信号,这部分要么去掉,要么用患者 ID 做伪特征,但后者在跨患者评估时会泄漏。n_classes=5对应 MIT-BIH 的 AAMI 五分类:N、S、V、F、Q。门控融合的好处是当临床特征缺失或噪声大时,模型可以自动压低它的权重,比直接 concat 稳。
2.3 加载预训练权重并跑通单条记录推理的最小命令
拿到「已经训练好」的权重,第一步不是评估指标,而是确认它能对一条记录出结果。MIT-BIH 的原始数据是 WFDB 格式,用wfdb库读取。
pip install wfdb torch torchvision timm scipyimport wfdb import torch from your_model import MidFusionECG, build_swin_backbone # 读取 MIT-BIH 记录,100 号是常用测试记录 record = wfdb.rdrecord('mitdb/100', sampto=360*60) # 只取前 60 秒 signal = record.p_signal.T # (n_leads, n_samples) model = MidFusionECG(build_swin_backbone(pretrained=False)) state = torch.load('checkpoints/best.pth', map_location='cpu') model.load_state_dict(state['model']) model.eval() x = ecg_to_swin_input(signal).unsqueeze(0) # (1, 3, 224, 224) clinical = torch.zeros(1, 5) # 无临床数据时置零 with torch.no_grad(): logits = model(x, clinical) pred = logits.argmax(dim=-1) print('预测类别:', pred.item())跑通这条命令的意义在于验证三件事:权重能加载、输入维度对得上、前向传播不报错。如果load_state_dict报 key 不匹配,先看是不是 Swin 骨干的pretrained参数导致分类头名字不同;如果前向报维度错误,八成是ecg_to_swin_input的输出通道数和模型第一层卷积对不上。这一步过了,再谈指标。
3. MIT-BIH 数据管线:从 WFDB 标注到可训练张量的四个对齐点
3.1 心拍切分与标签映射:AAMI 五分类的坑
MIT-BIH 的标注文件里每个心拍有一个符号,比如 N、L、R、V、A 等,直接拿来做分类会有二十多类,样本极度不均衡。标准做法是映射到 AAMI 五类:N(正常及其他)、S(室上性异位)、V(室性异位)、F(融合)、Q(未知)。这个映射表必须和训练时完全一致,否则你评估出来的混淆矩阵毫无意义。
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' } def map_label(symbol): return AAMI_MAP.get(symbol, 'Q')注意'/'在 MIT-BIH 里表示起搏心跳,归到 Q 类;'e'和'j'是房性逸搏,归 N。这些边界如果作者没在文档里写清楚,你得自己对着 WFDB 的注释文件核一遍。
3.2 训练集测试集划分:为什么不能随机切
MIT-BIH 只有 48 条记录,来自 47 个受试者(201 和 202 来自同一人)。如果你按心拍随机划分,同一个人的心拍会同时出现在训练和测试集里,模型记住的是这个人而不是病理特征,指标虚高。业内公认的做法是按记录划分:DS1(101, 106, 108 等 22 条)做训练,DS2(100, 103, 105 等 22 条)做测试。这个划分是 de Chazal 等人提出的,几乎所有可比的 MIT-BIH 论文都用它。
DS1 = [101,106,108,109,112,114,115,116,118,119,122,124, 201,203,205,207,208,209,215,220,223,230] DS2 = [100,103,105,111,113,117,121,123,200,202,210,212, 213,214,219,221,222,228,231,232,233,234] def split_records(records, ds1=DS1, ds2=DS2): train = [r for r in records if r in ds1] test = [r for r in records if r in ds2] return train, test如果你拿到的权重是在随机划分上训的,别急着否定它,但你要知道它的测试指标不能直接和文献比。重新按 DS1/DS2 评估一遍,才是真实水平。
3.3 类别不均衡处理:加权损失与采样怎么选
MIT-BIH 里 N 类占约 90%,V 类约 7%,F 和 Q 类不到 1%。不做处理的话,模型全预测 N 也能有 90% 准确率,但临床上完全没用。常见做法是加权交叉熵,权重取类别频率的倒数,或者用 focal loss 压低易分样本的权重。
import torch.nn as nn # 按 DS1 统计出的近似频率,实际用你的训练集重算 class_freq = torch.tensor([0.90, 0.025, 0.07, 0.004, 0.001]) weights = 1.0 / (class_freq + 1e-6) weights = weights / weights.sum() * len(class_freq) criterion = nn.CrossEntropyLoss(weight=weights)我一般会同时看两个指标:整体准确率和 V 类的 F1。如果 V 类 F1 低于 0.7,说明不均衡没处理好,先调权重再考虑换采样策略。过采样少数类容易过拟合,SMOTE 在时序信号上效果不稳定,加权损失是更稳的起点。
3.4 可解释性输入准备:注意力图要能映射回原始波形
标题里的「临床可解释性」通常指两类输出:一是 Swin 的注意力热力图,二是 Grad-CAM 类的梯度可视化。但 Swin 的注意力是在窗口内计算的,直接可视化会得到一堆碎片。你需要把窗口注意力按移位规则拼回完整特征图,再上采样到原始信号长度。
def attention_to_signal(attn_map, img_size=224, sig_len=3600): """ attn_map: (img_size, img_size) 归一化后的注意力 返回: (sig_len,) 映射回时间轴的重要性曲线 """ import numpy as np # 沿时间轴(假设是最后一维)做列求和 time_importance = attn_map.sum(axis=0) # 插值到原始信号长度 x_old = np.linspace(0, 1, img_size) x_new = np.linspace(0, 1, sig_len) return np.interp(x_new, x_old, time_importance)这个映射是近似的,因为时频变换本身有信息损失。临床上更可信的做法是把注意力叠加在原始 ECG 波形上,让医生看到模型关注的是不是 QRS 波群附近。如果热力图集中在基线漂移段,说明模型学的是噪声,不是病理。
4. 部署与可解释性落地:从 PyTorch 权重到临床可看的输出
4.1 推理服务封装:ONNX 导出与动态轴设置
PyTorch 权重直接部署在临床环境里不现实,通常要转 ONNX 或 TorchScript。Swin 的窗口移位操作在 ONNX 导出时容易出问题,关键是设好动态轴。
import torch model.eval() dummy_ecg = torch.randn(1, 3, 224, 224) dummy_cli = torch.randn(1, 5) torch.onnx.export( model, (dummy_ecg, dummy_cli), 'ecg_swin.onnx', input_names=['ecg', 'clinical'], output_names=['logits'], dynamic_axes={ 'ecg': {0: 'batch'}, 'clinical': {0: 'batch'}, 'logits': {0: 'batch'} }, opset_version=14 )opset_version=14是底线,低于这个版本 Swin 的Roll操作可能不支持。导出后用onnxruntime跑一遍,对比 PyTorch 和 ONNX 的输出差异,如果 max abs diff 超过 1e-3,检查是不是有层被 fallback 到 CPU 了。
4.2 可解释性输出:把注意力热力图叠加到原始 ECG 上
医生不看 logits,看的是「模型为什么这么判」。把 3.4 节的重要性曲线和原始波形画在一起,是成本最低的可解释性交付。
import matplotlib.pyplot as plt def plot_explanation(signal_1d, importance, fs=360, save_path='explain.png'): t = np.arange(len(signal_1d)) / fs fig, ax1 = plt.subplots(figsize=(12, 3)) ax1.plot(t, signal_1d, color='black', linewidth=0.8, label='ECG') ax1.set_ylabel('Amplitude') ax2 = ax1.twinx() ax2.fill_between(t, importance, alpha=0.3, color='red', label='Attention') ax2.set_ylabel('Importance') plt.title('ECG with Model Attention') plt.savefig(save_path, dpi=150, bbox_inches='tight')这张图是给临床医生看的,所以时间轴单位要是秒,振幅要是 mV,注意力曲线要归一化到 0 到 1。如果医生反馈「红的地方和 QRS 对不上」,那说明预处理里的窗口对齐有问题,回去查ecg_to_swin_input的截断逻辑。
4.3 临床部署的延迟与吞吐:Swin-T 在 CPU 上的实测边界
Swin-T 参数量约 28M,输入 224×224 时,在单核 CPU 上单次推理约 200 到 400 毫秒,GPU 上 10 毫秒以内。临床场景如果是实时监护,10 秒一个窗口,CPU 推理完全够用;如果是 48 小时 Holter 回顾分析,吞吐比延迟重要,建议用 ONNX Runtime 开多线程。
| 配置 | 单次推理延迟 | 吞吐(窗口/秒) | 适用场景 |
|---|---|---|---|
| CPU 单线程 | 300 ms | 3.3 | 单床监护 |
| CPU 4 线程 | 90 ms | 11 | 多床监护 |
| GPU T4 | 8 ms | 125 | Holter 批量分析 |
如果你的部署环境只有 CPU,别用 Swin-B,参数量翻三倍,延迟直接到秒级,临床等不起。
5. 避坑与排查:这套系统最容易翻车的五个地方
5.1 现象:推理结果全是 N 类,V 类一个不出
原因:训练时没做类别加权,或者权重文件加载时strict=False把分类头跳过了,实际用的是随机初始化的头。解决:先打印model.classifier.weight的统计量,如果是标准正态分布,说明分类头没加载上;再检查训练脚本里的CrossEntropyLoss有没有传weight。
5.2 现象:注意力热力图全图均匀,看不出重点
原因:Swin 的窗口注意力在浅层感受野小,直接取最后一层注意力上采样会糊成一片。解决:取中间 stage 的注意力,或者用 Grad-CAM 对最后一个 stage 的 feature map 做梯度加权,再上采样。我一般用 Grad-CAM,比原始注意力稳定。
5.3 现象:ONNX 推理结果和 PyTorch 差很多
原因:Swin 里的Roll操作在旧 opset 下被拆成不兼容的算子,或者dynamic_axes没设对导致 batch 维被固化。解决:升级 opset 到 14 以上,导出后用onnxruntime逐层对比,定位第一个差异超过 1e-4 的层。
5.4 现象:按 DS1/DS2 评估,指标比作者报的低一截
原因:作者可能用了随机划分,或者测试时把 Q 类样本剔除了。解决:先确认划分方式,如果作者没写,按 DS1/DS2 重跑;再看混淆矩阵,如果 Q 类召回率极低但作者没报,说明指标是挑过的。
5.5 现象:多模态融合里临床特征缺失时模型崩溃
原因:训练时临床特征从没缺失过,模型对这部分输入过拟合。解决:训练时随机 drop 临床特征(dropout rate 0.2 到 0.3),推理时缺失就置零,门控机制会自动降权。如果模型没有门控,考虑在融合前加一个可学习的缺失指示位。
6. 一个验证「已经训练好」是否可信的快速技巧
拿到任何声称「已经训练好」的 ECG 模型,我习惯先做一件事:拿 MIT-BIH 的 100 号记录,取前 10 分钟,按 10 秒窗口切 60 个片段,跑推理,看预测类别的分布。如果 60 个窗口全是一个类,不管这个类是不是 N,都说明模型有问题——真实心电 10 分钟里多少会有几拍异位。如果分布合理,再挑出预测为 V 类的窗口,把对应原始波形画出来,人工看一眼是不是真的室早。这一步花不了十分钟,但能过滤掉大部分「权重文件在,但模型没训好」的情况。
def sanity_check(model, record_name='mitdb/100', minutes=10): record = wfdb.rdrecord(record_name, sampto=360*60*minutes) signal = record.p_signal.T preds = [] for i in range(0, signal.shape[1] - 3600, 3600): seg = signal[:, i:i+3600] x = ecg_to_swin_input(seg).unsqueeze(0) with torch.no_grad(): pred = model(x, torch.zeros(1, 5)).argmax(-1).item() preds.append(pred) from collections import Counter print(Counter(preds))如果Counter里只有一个键,别犹豫,回去查权重加载和预处理。如果分布正常,再跑 DS2 的完整评估,看 V 类 F1 能不能到 0.8 以上。能到,这套方案值得投入;到不了,先调不均衡处理,再考虑换骨干。
我自己在这类项目上翻过的最大的车,是早期太信「已经训练好」这四个字,直接拿权重去跑临床数据,结果因为预处理里的归一化方式和训练不一致,模型把基线漂移当成了 ST 段抬高,差点闹出误报。后来我养成了一个习惯:任何预训练权重,先跑 sanity check,再跑一条已知标注的记录,逐拍对比预测和标注,对不上的地方就是坑。希望帮到你。
本文还有配套的精品资源,点击获取