简介:本资源面向心电信号处理与医学AI方向的学习者与开发者,提供一套基于Swin Transformer与多模态融合的MIT-BIH心律失常五分类完整方案,覆盖A/L/N/R/V五类识别,重点解决传统CNN在样本不平衡下的识别瓶颈。压缩包共2000个文件,以1993张jpg心电图纸与数字矩阵截图为主,另含3个py训练与推理脚本、2个json标签映射与结果文件及说明文档,整体约205.53MB,可直接用于复现与二次开发。资源已包含训练好的模型权重,训练阶段自动输出混淆矩阵、ROC-AUC、PR曲线与学习率衰减可视化,并采用Focal Loss与余弦退火策略提升V类难例召回;推理端集成PyQt5交互界面,支持Top-3概率输出与CPU/GPU切换,单图推理低于0.3秒。五分类平均精度达96.7%,F1-score最低类别为0.92,模型参数量仅28M。目前已有45人学习,适合临床辅助诊断与可穿戴边缘计算场景的快速验证。
1. 从一张心电图到一份诊断报告:Swin Transformer 与多模态融合到底解决了什么
凌晨两点,监护仪上一条 10 秒的 II 导联波形被推送到服务器,几秒后返回「室性早搏,建议结合临床」。这套流程背后,正是基于 Swin Transformer 与多模态融合的 MIT-BIH 心电智能诊断系统在跑。它要解决的不是「能不能分类」这种老问题,而是三件事:单导联信息量太少导致误判、模型给出的结论医生不敢信、以及训练好的权重怎么落到临床环境里不翻车。MIT-BIH 心律失常数据库是这套方案的起点,它提供了 48 条半小时双导联记录,标注了心拍类型,是 ECG 分类绕不开的基准。适合谁看?如果你手上有心电数据、想用 Swin Transformer 做分类、又卡在「多模态怎么融」和「可解释性怎么落地」这两步,这篇就是按我踩过的顺序写的。已经训练好的权重不是终点,恰恰是部署阶段麻烦的开始。
2. Swin Transformer 处理 ECG 的选型理由与数据准备
2.1 为什么不是 CNN 也不是原始 ViT
心电信号本质是一维时序,但直接套 1D-CNN 有个天花板:卷积核的感受野是局部的,要覆盖一个完整心拍(约 0.6~1 秒,采样率 360Hz 下约 216 个点)得堆很多层,而且不同导联之间的长程依赖很难建模。原始 ViT 倒是全局注意力,但计算量随序列长度平方增长,一条 30 分钟记录切成心拍后动辄上千个 token,显存直接爆。
Swin Transformer 的窗口注意力把这个问题拆开了:它在局部窗口内做自注意力,再通过 shifted window 让相邻窗口产生跨窗连接,复杂度从平方降到线性。对 ECG 来说,一个窗口刚好覆盖一个心拍的局部形态(QRS 波群、P 波、T 波),shift 之后又能捕捉相邻心拍之间的节律关系。这就是我选它的核心理由——不是因为它新,而是它的归纳偏置和心电的「局部形态 + 节律上下文」结构对得上。
多模态融合在这里的角色是补信息。MIT-BIH 本身是双导联(多数记录是 MLII 和 V1/V2/V5),单导联分类在部分类别上召回率会掉十几个点。把两个导联当作两个模态,或者把原始波形和经过小波变换的时频图当作两个模态,在特征层做融合,比单纯拼接通道更稳。
2.2 从 MIT-BIH 原始记录到可训练张量
MIT-BIH 的原始格式是 WFDB,包含 .dat(二进制信号)、.hea(头文件)、.atr(标注)。不要自己写解析器,用 wfdb 库最省事。
import wfdb import numpy as np from scipy.signal import butter, filtfilt # 读取一条记录,MIT-BIH 采样率 360Hz,双导联 record = wfdb.rdrecord('mitdb/100', sampto=360*60*5) # 取前5分钟 annotation = wfdb.rdann('mitdb/100', 'atr', sampto=360*60*5) # 0.5-40Hz 带通滤波,去掉基线漂移和工频干扰 def bandpass_filter(signal, low=0.5, high=40, fs=360, order=4): nyq = fs / 2 b, a = butter(order, [low/nyq, high/nyq], btype='band') return filtfilt(b, a, signal, axis=0) signals = bandpass_filter(record.p_signal) # shape: (N, 2)逻辑说明:sampto限制读取长度,避免一次加载整条 30 分钟记录。滤波用零相位filtfilt,因为心电的 ST 段形态对相位失真敏感,普通lfilter会让 QRS 位置偏移。参数上,0.5Hz 高通去基线漂移,40Hz 低通保留 QRS 主要能量又压掉肌电噪声,这个频段是心电诊断的常规选择。
接下来按标注切心拍。MIT-BIH 的标注点对应 R 波峰,通常取 R 点前 0.25 秒到后 0.4 秒作为一个心拍窗口。
window_pre, window_post = int(0.25*360), int(0.4*360) beats, labels = [], [] for r_idx, symbol in zip(annotation.sample, annotation.symbol): if r_idx - window_pre < 0 or r_idx + window_post >= len(signals): continue beat = signals[r_idx-window_pre : r_idx+window_post, :] # (234, 2) beats.append(beat) labels.append(symbol) beats = np.array(beats) # (num_beats, 234, 2)这里有个关键点:MIT-BIH 的标注符号有 20 多种,但很多是节奏标注(如 '+' 是节律变化)而非形态类别。实际训练时通常归并成 AAMI 标准的 5 类: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' } labels = [aami_map.get(s, 'Q') for s in labels]参数说明:窗口长度 234 点是 0.65 秒,覆盖一个完整心拍。如果你用 128Hz 重采样(很多论文这么做),窗口就是 83 点。重采样能降计算量,但会损失高频细节,对 V 类(室性)的宽 QRS 判别有影响,我一般保留 360Hz。
2.3 多模态分支的构造
单靠波形,模型学到的是形态。加上时频图,模型能同时看到频率随时间的变化,对区分房颤、室颤这类节律异常有帮助。常见做法是用连续小波变换(CWT)生成二维时频图作为第二模态。
import pywt def to_scalogram(beat_1d, scales=np.arange(1, 64)): coeffs, _ = pywt.cwt(beat_1d, scales, 'morl') return np.abs(coeffs) # (63, 234) # 对每个心拍的每个导联生成时频图 scalograms = np.array([[to_scalogram(beat[:, ch]) for ch in range(2)] for beat in beats]) # shape: (num_beats, 2, 63, 234)逻辑说明:morl是 Morlet 小波,在时频局部化上表现均衡,适合心电这种非平稳信号。scales 取 1~63 覆盖了主要频率成分。生成的时频图作为第二模态,和原始波形分支并行送入网络。注意时频图要按通道做归一化,否则不同记录的幅度差异会让融合层偏向幅度大的模态。
3. 双分支 Swin 融合网络的结构与训练配置
3.1 波形分支与时频分支怎么接
波形分支不能直接把 234 点当序列喂给 Swin,那样 token 太少,窗口注意力退化成全局注意力。我的做法是先做 patch embedding:把 234 点切成 6 个 patch,每个 patch 39 点,再线性投影到 96 维。这样序列长度是 6,配合 window size 3,shift 之后能覆盖相邻 patch。
时频分支输入是 63×234 的二维图,按 16×16 的 patch 切,得到约 4×14=56 个 token,正好发挥 Swin 的层次化下采样优势。
import torch import torch.nn as nn from timm.models.swin_transformer import SwinTransformer class ECG DualBranch(nn.Module): def __init__(self, num_classes=5): super().__init__() # 波形分支:自定义 patch embedding self.wave_patch = nn.Conv1d(2, 96, kernel_size=39, stride=39) self.wave_swin = SwinTransformer( img_size=(6, 1), patch_size=1, in_chans=96, embed_dim=96, depths=[2,2,6,2], num_heads=[3,6,12,24], window_size=3, num_classes=0 ) # 时频分支:标准 2D Swin self.tf_swin = SwinTransformer( img_size=(63, 234), patch_size=16, in_chans=2, embed_dim=96, depths=[2,2,6,2], num_heads=[3,6,12,24], window_size=7, num_classes=0 ) self.fusion = nn.MultiheadAttention(embed_dim=96, num_heads=4, batch_first=True) self.classifier = nn.Sequential( nn.LayerNorm(96), nn.Linear(96, num_classes) ) def forward(self, wave, tf): # wave: (B, 234, 2) -> (B, 2, 234) wave = wave.permute(0, 2, 1) wave_tok = self.wave_patch(wave).permute(0, 2, 1) # (B, 6, 96) wave_feat = self.wave_swin(wave_tok.unsqueeze(-1)) # (B, 96) tf_feat = self.tf_swin(tf) # (B, 96) # 交叉注意力融合 q = wave_feat.unsqueeze(1) kv = tf_feat.unsqueeze(1) fused, _ = self.fusion(q, kv, kv) return self.classifier(fused.squeeze(1))逻辑说明:两个分支各自提取特征后,用交叉注意力做融合——波形特征作为 query,时频特征作为 key/value。这样模型能根据波形当前心拍,去时频图里找对应的频率证据。参数上,window_size=3对应波形分支的 6 个 token,window_size=7是 2D Swin 的常规设置。depths和num_heads沿用 Swin-Tiny 的配置,显存不够就砍到 [2,2,2,2]。
3.2 训练参数与类别不平衡处理
MIT-BIH 的类别分布极度不均,N 类占约 90%,F 类可能只有几十个样本。直接训练,模型会把所有东西预测成 N。我的处理是组合拳:加权交叉熵 + 重采样。
from torch.utils.data import WeightedRandomSampler from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight('balanced', classes=np.unique(y_train), y=y_train) weights_tensor = torch.tensor(class_weights, dtype=torch.float32) # 重采样:让每个 batch 里少数类占比提升 sample_weights = [class_weights[label] for label in y_train] sampler = WeightedRandomSampler(sample_weights, num_samples=len(y_train), replacement=True) criterion = nn.CrossEntropyLoss(weight=weights_tensor) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.05) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)参数说明:lr=1e-4是 Transformer 类模型的稳妥起点,太大容易在早期震荡。weight_decay=0.05配合 AdamW 做解耦权重衰减,比 L2 正则更干净。T_max=50表示 50 个 epoch 完成一次余弦退火。batch size 我一般设 64,显存 12G 够用。
训练时按患者划分数据集,不要随机划分心拍。同一患者的心拍高度相似,随机划分会让训练集和验证集泄漏,验证准确率虚高到 99%,一到新患者就掉到 70% 以下。这是心电分类最常见的翻车点。
3.3 评估指标不能只看准确率
类别不平衡下,准确率是黑匣子。必须看每类的召回率和 F1,尤其是 V 类和 F 类。
from sklearn.metrics import classification_report, confusion_matrix model.eval() preds, trues = [], [] with torch.no_grad(): for wave, tf, label in val_loader: out = model(wave.cuda(), tf.cuda()) preds.extend(out.argmax(1).cpu().numpy()) trues.extend(label.numpy()) print(classification_report(trues, preds, target_names=['N','S','V','F','Q'], digits=4)) print(confusion_matrix(trues, preds))逻辑说明:classification_report给出每类精确率、召回率、F1。重点看 V 类召回,如果低于 0.85,说明模型对室性心拍的形态学特征学得不够,需要检查时频分支是否真的在起作用——可以做个消融,把时频分支输入置零,看 V 类召回掉多少。掉得少说明融合没生效,可能是交叉注意力的 query/key 维度不匹配,或者时频图归一化没做对。
4. 可解释性落地:让医生看懂模型在看哪里
4.1 注意力热力图与 Grad-CAM 的取舍
可解释性不是发论文用的装饰,是临床部署的准入条件。医生不会接受一个只给标签的黑匣子。Swin 的窗口注意力天然能输出注意力权重,但直接可视化有个问题:shifted window 的注意力是跨窗口的,热力图会碎片化,看起来像噪声。
我的做法是双管齐下:波形分支用 Grad-CAM 生成一维热力图,标出模型关注的心拍区段;时频分支用注意力 rollout 生成二维热力图,看模型关注哪些频率带。两者叠加到原始波形上,医生能看到「模型认为这个心拍异常,主要依据是 QRS 波群增宽和 10~20Hz 频段能量异常」。
# 波形分支 Grad-CAM def grad_cam_wave(model, wave, tf, target_class): wave.requires_grad_(True) feat = model.wave_patch(wave.permute(0,2,1)) feat.retain_grad() # ... 前向到分类层,取目标类分数 score = model(wave, tf)[0, target_class] score.backward() # 对特征图梯度做全局平均,加权回特征 weights = feat.grad.mean(dim=-1, keepdim=True) cam = (weights * feat).sum(dim=1).relu() return cam.detach().cpu().numpy()逻辑说明:retain_grad()保留中间特征梯度,weights是梯度在 token 维度的均值,代表每个 patch 对目标类的重要性。relu过滤负贡献。得到的 cam 长度是 6,对应 6 个 patch,上采样回 234 点就能叠加到波形上。参数上,target_class选模型预测的类别,也可以指定某个类别看模型「认为哪里像 V 类」。
4.2 临床报告生成的最小闭环
可解释性最终要落到一份医生能读的报告。不需要上大语言模型,模板填充就够用,关键是证据链完整。
def generate_report(beat, pred_class, cam, confidence): # 找 cam 峰值位置对应的心电区段 peak_idx = cam.argmax() segment = ['P波', 'QRS波群', 'ST段', 'T波'][min(peak_idx // 60, 3)] report = { '诊断': pred_class, '置信度': f'{confidence:.2%}', '关键证据': f'{segment}区域形态异常,模型关注度 {cam.max():.3f}', '建议': '结合临床' if confidence < 0.9 else '建议复查' } return report逻辑说明:cam.argmax()找到模型最关注的区段,映射到心电术语。置信度低于 0.9 时输出「结合临床」,这是给医生的安全边界。参数上,区段划分按 234 点均分,实际可以按 R 点位置动态调整。这份报告不替代医生诊断,但能让医生快速判断模型结论是否合理。
5. 部署避坑:从训练好的权重到临床环境
5.1 踩坑记录:验证集 99% 但上线就崩
现象:本地验证准确率 99.2%,部署到测试环境后,新患者数据准确率掉到 68%。
原因:数据集按心拍随机划分,同一患者的心拍同时出现在训练集和验证集。模型记住了患者特有的波形基线,而不是心律失常的通用特征。
解决:按患者 ID 划分,MIT-BIH 的 48 条记录里,取 30 条做训练、8 条做验证、10 条做测试。重训后验证准确率降到 94%,但测试集准确率稳定在 92% 以上。这个数字才是真实的。
5.2 踩坑记录:时频图归一化不一致导致融合失效
现象:训练时 loss 正常下降,但推理时同一份数据多次预测结果不一致。
原因:训练时对时频图做了按样本的 min-max 归一化,推理时忘了加这一步,或者用了全局统计量。时频图的幅度范围在不同记录间差异很大,归一化不一致会让融合层的输入分布偏移。
解决:把归一化写进模型的前向逻辑,或者封装成独立的预处理类,训练和推理共用同一份代码。不要在两处各写一遍。
5.3 踩坑记录:窗口注意力在短序列上退化
现象:波形分支的注意力热力图几乎均匀分布,看不出重点。
原因:波形分支只有 6 个 token,window_size 设 3 时,shift 之后窗口覆盖了整个序列,注意力退化成全局注意力,失去了局部归纳偏置。
解决:要么增大 patch 数量(减小 patch 长度),要么在波形分支改用普通 Transformer 层。我最后把 patch 长度从 39 降到 13,得到 18 个 token,window_size 设 6,热力图才恢复出可解释的局部峰值。
5.4 踩坑记录:类别归并映射漏了符号
现象:训练时 F 类样本数为 0,模型完全不预测 F 类。
原因:AAMI 映射表里漏了 'F' 符号本身,或者把 'f'(融合)和 'F'(融合)搞混了。MIT-BIH 的标注符号大小写敏感。
解决:打印归并前后的类别分布,确认每类样本数。F 类在 MIT-BIH 里本来就少,如果归并后少于 50 个,考虑用数据增强(时间扭曲、幅度缩放)扩充,或者直接合并到 Q 类。
5.5 踩坑记录:推理延迟超标
现象:单心拍推理耗时 120ms,达不到实时监护的 50ms 要求。
原因:时频分支的 CWT 计算在 CPU 上做,且每次推理都重新算。Swin 的窗口注意力在 batch size 为 1 时利用率低。
解决:把 CWT 换成预计算的 STFT 查表,或者用 GPU 加速。推理时攒够 8 个心拍做 batch,延迟摊薄到 15ms。另外把模型转成 ONNX 或 TensorRT,Swin 的算子支持已经比较成熟。
6. 进阶技巧:用消融实验验证多模态融合是否真的在起作用
训练完一个多模态模型,最怕的是「融合层其实没学到东西,两个分支各干各的,最后靠波形分支单打独斗」。验证方法很简单:做消融,把时频分支的输入置零或替换成噪声,看性能掉多少。
# 消融实验:时频分支输入置零 def ablate_tf(model, val_loader): model.eval() preds, trues = [], [] with torch.no_grad(): for wave, tf, label in val_loader: tf_zero = torch.zeros_like(tf) # 置零 out = model(wave.cuda(), tf_zero.cuda()) preds.extend(out.argmax(1).cpu().numpy()) trues.extend(label.numpy()) return classification_report(trues, preds, output_dict=True) # 对比完整模型和消融模型 full_report = evaluate(model, val_loader) ablate_report = ablate_tf(model, val_loader) for cls in ['N','S','V','F','Q']: drop = full_report[cls]['recall'] - ablate_report[cls]['recall'] print(f'{cls} 类召回率下降: {drop:.4f}')逻辑说明:如果 V 类召回率下降超过 5 个点,说明时频分支对室性心拍判别有实质贡献。如果所有类别下降都在 1 个点以内,融合层大概率是摆设,需要检查交叉注意力的实现——常见问题是 query 和 key 来自同一分支,或者融合后的特征被后续层忽略。
我一般还会做一个反向消融:只保留时频分支,波形置零。两个方向的消融结果能画出每个模态的贡献边界。如果波形分支单独就有 93% 准确率,时频分支单独只有 70%,融合后 94%,那多模态的收益其实很小,部署时可以考虑砍掉时频分支降延迟。这个判断在临床场景很关键——每增加一个模态,预处理链路和故障点就多一倍。
另一个技巧是看融合权重的熵。交叉注意力的权重分布如果接近均匀,说明模型没有偏好某个模态;如果熵很低,说明模型强烈依赖某一个模态。把权重熵和消融结果对照,能判断融合是「真融合」还是「假融合」。
# 提取交叉注意力权重 def get_fusion_weights(model, wave, tf): wave = wave.permute(0, 2, 1) wave_tok = model.wave_patch(wave).permute(0, 2, 1) wave_feat = model.wave_swin(wave_tok.unsqueeze(-1)) tf_feat = model.tf_swin(tf) q = wave_feat.unsqueeze(1) kv = tf_feat.unsqueeze(1) _, attn_weights = model.fusion(q, kv, kv) return attn_weights # (B, 1, 1)参数说明:attn_weights的形状取决于 batch 和 head 数,单头单 query 时就是标量。如果接近 1,说明波形分支完全主导;接近 0.5 说明两个模态贡献均衡。我习惯在验证集上统计这个值的分布,作为融合质量的监控指标。
最后说个血泪经验:可解释性模块不要等到模型训练完再加。训练时就把注意力权重存下来,观察它随 epoch 的变化。如果注意力从第一轮就均匀分布且一直不变,说明模型没在学局部特征,这时候调融合结构比调学习率有用。希望帮到你。
本文还有配套的精品资源,点击获取