ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

12导联ECG数据预处理与CNN心律失常分类实战

2026/10/6 12:53:45 拓冰建站 浏览量
12导联ECG数据预处理与CNN心律失常分类实战 简介这是一份面向机器学习与医疗数据分析场景的12导联心电图ECG数据集共收录39732条心电记录并按7:3划分为训练集与测试集。数据覆盖肢体导联I、II、III、aVR、aVL、aVF和胸导联V1-V6每个样本包含多导联时序采样值适合用于心律不齐、心肌缺血等异常检测心电信号多分类以及基于CNN、RNN的深度学习建模与算法复现。压缩包共45个文件、约10.75MB主体为37个CSV数据文件除各导联的原始信号数据外还包含train_label.csv标签文件和READMEZH.md中文说明文档方便快速了解字段含义与使用方式其余为少量项目配置文件。目前已有4081人学习下载可作为生物医学工程、医疗人工智能课程设计、论文实验或科研预研的基准数据。数据集内置明确的训练/测试划分降低了数据预处理与交叉验证门槛研究者可直接开展模型训练、泛化性能评估及特征工程研究。1. 12导联心电图数据集落地从39732条记录到可训练的心电模型拿到这份ECG数据时我的第一反应是工作量被砍掉了大半。做过心电方向项目的人都清楚找一份干净的、带标签的12导联数据有多难——公开数据集要么导联不全要么采样率混乱要么标注粗糙。这份资源包含了39732条心电图记录按7:3拆好了训练集和测试集每个样本是独立CSV文件导联信息完整格式做到了拿来即用的程度。对于做异常检测、心率变异性分析或者CNN/RNN心跳分类的团队来说这份数据可以直接进入预处理和建模阶段省掉最耗时的数据采集和清洗环节。比较适合三类人来用一是准备做心电分类算法的在校研究生二是医疗AI初创团队用来跑基线模型三是需要大规模心电样本做预训练的算法工程师。2. 先搞懂数据结构12导联CSV的真实组织方式与读入逻辑2.1 每个CSV样本内部到底存了什么第一个建议是别急着训练先把单个CSV文件的内容摸透。这个数据集里的每个样本文件是用UUID命名的比如0a0a3836-35ce-4c0d-9b8f-453676034d7e.csv文件名本身不携带任何标签信息所有标签统一放在train_label.csv里。我解压后随手打开了一个文件典型的12导联心电数据是这样组织的每一行是一个采样时间点每一列代表一个导联的电压值。导联顺序常见的是I, II, III, aVR, aVL, aVF, V1, V2, V3, V4, V5, V6。用 pandas 读入后你会得到一个二维表import pandas as pd # 读取单个ECG样本 sample_df pd.read_csv(0a0a3836-35ce-4c0d-9b8f-453676034d7e.csv) print(sample_df.shape) print(sample_df.columns.tolist()) print(sample_df.head()) # 查看采样长度和数据范围 print(f采样点数: {len(sample_df)}) print(f电压范围: {sample_df.min().min():.4f} ~ {sample_df.max().max():.4f}mV)这段代码做的事情是先确认DataFrame的行列数打印列名验证导联顺序再用head查看前几行判断值域和缺失情况。采样点数直接决定了后续重采样和切片策略。如果每行是毫秒级采样12导联10秒记录就是5000或1000行这个数字直接关系到模型输入尺寸的设定。2.2 train_label.csv 的组织形式与标签读取train_label.csv是整个数据集的索引表。我的经验是它通常包含至少两列一列是文件名对应UUID另一列是标签。标签可能是整数编码也可能是文本描述比如0代表正常1代表心肌梗死2代表心律不齐等。实际编码方式以文件里给的说明为准。import pandas as pd # 读取标签文件 label_df pd.read_csv(train_label.csv) print(label_df.head()) print(label_df.shape) # 统计标签分布 print(label_df.iloc[:, -1].value_counts()) # 检查是否有文件缺失或多余 import os data_dir ./ecg数据 file_list os.listdir(data_dir) label_files set(label_df.iloc[:, 0].tolist()) actual_files set(f for f in file_list if f.endswith(.csv) and f ! train_label.csv) print(f标签文件数量: {len(label_files)}) print(f实际CSV数量: {len(actual_files)}) print(f缺失文件: {len(label_files - actual_files)}) print(f未标记文件: {len(actual_files - label_files)})这段代码的价值在于数据完整性校验训练集测试集的划分前提是文件真实存在并且一一对应。跑这个检查可以在十分钟内发现文件缺失或标签错位问题避免训练到一半因为索引越界中断。2.3 训练集测试集是放在不同目录里还是靠标签区分这个细节值得花时间确认。有些数据集把训练集和测试集放在物理隔离的文件夹里有些则通过标签字段标记。这份数据既然在说明里写了7:3划分我个人倾向认为文件列表层面已经做好切分——你可以看下有没有train/和test/子目录如果没有就需要从标签文件或者单独的索引文件里找划分依据。常见做法是标签CSV里有一列叫split值为train或test。处理方式如下# 若标签文件有split列 train_df label_df[label_df[split] train] test_df label_df[label_df[split] test] print(f训练集样本数: {len(train_df)}) print(f测试集样本数: {len(test_df)})如果文件里没有split列那说明划分信息在READMEZH.md里或者需要自己按7:3重新切分。自己切分时注意保持类别分布一致用train_test_split的stratify参数是最稳的。3. 12导联数据的预处理全流程噪声过滤、基线漂移消除与归一化3.1 为什么12导联数据不能直接喂给模型心电信号是微伏到毫伏级别的生物电信号采集过程中会被工频干扰、肌电噪声和呼吸引起的基线漂移污染。如果把原始电压值直接输入模型模型会把噪声当成特征学到导致泛化能力极差。预处理的目标是把信号清理到接近临床心电图的观察质量同时保证所有样本的数值尺度一致。12导联相比单导联多出来的难点在于导联间存在空间相关性——同样的心电活动在不同导联上的投影不同。因此预处理时序上要处理的是每个导联独立去噪再统一归一化。顺序不能反过来因为计算归一化参数时如果混入噪声参数本身就不准了。3.2 完整预处理管道滤波到归一化的一站式实现我一般在ECG预处理里用三步走高通滤波去除基线漂移低通滤波去除高频噪声然后做z-score归一化。采样率如果未知可以用scipy.signal直接处理不需要知道精确采样率也能做相对滤波。import numpy as np from scipy.signal import butter, filtfilt def preprocess_ecg(data, fs500, lowcut0.5, highcut50): 12导联ECG预处理 data: (n_samples, 12) 原始电压矩阵 fs: 采样率默认500Hz # 1) 高通滤波去基线漂移 b_high, a_high butter(4, lowcut / (fs / 2), btypehigh) data_high filtfilt(b_high, a_high, data, axis0) # 2) 低通滤波去高频肌电干扰 b_low, a_low butter(4, highcut / (fs / 2), btypelow) data_filtered filtfilt(b_low, a_low, data_high, axis0) # 3) 逐导联z-score归一化 mean np.mean(data_filtered, axis0) std np.std(data_filtered, axis0) std[std 1e-8] 1e-8 # 防止除零 data_norm (data_filtered - mean) / std return data_norm # 使用示例 raw_data sample_df.values # (time, 12) clean_data preprocess_ecg(raw_data) print(f预处理后数据shape: {clean_data.shape}) print(f归一化后均值(应接近0): {np.mean(clean_data, axis0)[:3]})这段代码的逻辑是先用高通滤波去掉0.5Hz以下的基线漂移再用低通滤波保留50Hz以下的心电有效频段最后把每个导联的电压值标准化为均值为0、方差为1的分布。参数上fs要注意和数据实际采样率匹配如果不匹配滤波截止频率会偏移lowcut设太低会保留更多基线漂移设太高会把ST段压平影响诊断特征highcut设太高会留下肌电噪声设太低会抹掉QRS波的尖峰。3.3 处理不等长样本重采样与固定窗口切片心电记录经常长度不一有的10秒有的8秒。深度学习模型输入尺寸固定所以要么重采样到统一长度要么在长信号上滑窗切片。我的习惯是保存原始长度信息训练时动态切片这样数据利用率最高。def slice_ecg_fixed_length(data, window_size1000, stride500): 把长ECG切成固定长度片段 data: (time, 12) window_size: 窗口内采样点数 stride: 滑动步长 n_samples data.shape[0] if n_samples window_size: # 填充到足够长度 pad_len window_size - n_samples data np.vstack([data, np.zeros((pad_len, data.shape[1]))]) n_samples window_size slices [] start 0 while start window_size n_samples: slices.append(data[start:start window_size, :]) start stride # 确保最后一个窗口也覆盖到 if start n_samples: slices.append(data[n_samples - window_size:n_samples, :]) return np.stack(slices) # 对单个样本切片 segments slice_ecg_fixed_length(clean_data, window_size1000, stride500) print(f切片数量: {len(segments)}, 每个shape: {segments[0].shape})这里有几个值得注意的参数window_size取1000在500Hz采样率下是2秒的心电信号2秒基本能覆盖1到2个完整心动周期适合做心跳级分类stride取500就是50%重叠重叠切片可以起到数据增强的作用但也可能让相邻样本高度相关影响验证集评估的可信度。4. 基于CNN的心律失常分类实战数据加载器到模型训练的完整链路4.1 把原始文件组织成PyTorch数据集数据量接近四万条全部读入内存会吃紧。正确做法是写一个自定义Dataset在__getitem__里按需读取CSV预处理后返回张量。import torch from torch.utils.data import Dataset import os class ECG12LeadDataset(Dataset): def __init__(self, file_list, label_list, data_dir, window_size1000): self.file_list file_list self.label_list label_list self.data_dir data_dir self.window_size window_size def __len__(self): return len(self.file_list) def __getitem__(self, idx): # 读取CSV df pd.read_csv(os.path.join(self.data_dir, self.file_list[idx])) # 预处理 data df.values.astype(np.float32) data preprocess_ecg(data) # 切片这里取中间窗口保证稳定 if data.shape[0] self.window_size: start (data.shape[0] - self.window_size) // 2 segment data[start:start self.window_size, :] else: pad self.window_size - data.shape[0] segment np.vstack([data, np.zeros((pad, 12))]) # 转成 (12, window_size) 适合Conv2d输入 segment segment.T # 标签 label torch.tensor(self.label_list[idx], dtypetorch.long) return torch.from_numpy(segment), label这个Dataset的关键设计是读写操作延迟到索引时执行避免一次性加载全部样本的内存爆炸切片区取中间位置比取开头更不容易踩到记录开始阶段的伪迹最终张量排布是(导联, 时间)这样卷积核扫的是时间轴导联作为通道维度直接类比图像的多通道输入。4.2 轻量CNN模型结构选择与参数配置12导联数据当成12通道的时序信号处理CNN结构比全连接网络更擅长提取局部波形特征。我的选型方案是一个三层Conv1d加注意力池化的组合参数量控制在可接受范围内既能在CPU上跑基线也适合上GPU快速迭代。import torch.nn as nn class ECGNet(nn.Module): def __init__(self, num_classes5): super().__init__() # 第一层12导联作为通道卷积核跨时间扫描 self.conv1 nn.Conv1d(12, 64, kernel_size15, stride2, padding7) self.bn1 nn.BatchNorm1d(64) self.pool1 nn.MaxPool1d(4) # 第二层 self.conv2 nn.Conv1d(64, 128, kernel_size9, stride2, padding4) self.bn2 nn.BatchNorm1d(128) self.pool2 nn.MaxPool1d(4) # 第三层 self.conv3 nn.Conv1d(128, 256, kernel_size5, stride2, padding2) self.bn3 nn.BatchNorm1d(256) self.pool3 nn.AdaptiveAvgPool1d(1) # 分类头 self.fc nn.Linear(256, num_classes) self.dropout nn.Dropout(0.5) def forward(self, x): x torch.relu(self.bn1(self.conv1(x))) x self.pool1(x) x torch.relu(self.bn2(self.conv2(x))) x self.pool2(x) x torch.relu(self.bn3(self.conv3(x))) x self.pool3(x) x x.view(x.size(0), -1) x self.dropout(x) return self.fc(x)模型设计的思路是模仿图像分类的经典堆叠通道数逐层翻倍时间维度逐层压缩最后用全局平均池化把特征图压缩成特征向量再由线性层映射到类别概率。kernel_size选15是因为心电QRS波在500Hz采样率下约持续30个采样点kernel15能覆盖QRS波一半以上的宽度保证卷积核能捕捉到完整的波形形态。每层卷积都配BatchNorm作用是对中间特征做归一化加速收敛同时缓解梯度消失。4.3 训练循环、验证逻辑与性能评估训练过程需要同时关注训练损失和验证指标。心电数据类别通常不平衡所以评估指标不能用准确率一个维度需要加F1分数和召回率。from torch.utils.data import DataLoader from sklearn.metrics import classification_report, f1_score # 数据加载 train_dataset ECG12LeadDataset(train_files, train_labels, data_dir./ecg数据) val_dataset ECG12LeadDataset(val_files, val_labels, data_dir./ecg数据) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4) # 模型与优化器 model ECGNet(num_classes5) optimizer torch.optim.Adam(model.parameters(), lr1e-4, weight_decay1e-5) criterion nn.CrossEntropyLoss() scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) for epoch in range(30): model.train() train_loss 0 for inputs, labels in train_loader: optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() # 验证 model.eval() all_preds [] all_labels [] with torch.no_grad(): for inputs, labels in val_loader: outputs model(inputs) preds torch.argmax(outputs, dim1) all_preds.extend(preds.numpy()) all_labels.extend(labels.numpy()) val_f1 f1_score(all_labels, all_preds, averageweighted) print(fEpoch {epoch1}: loss{train_loss/len(train_loader):.4f}, val_f1{val_f1:.4f}) scheduler.step()训练环节注意三点一是类别不均衡时不能光看loss下降要盯validation F1二是lr1e-4是ECG信号训练的常用稳妥起点太大会导致震荡太大三是StepLR每10轮降一半学习率让模型在训练后期用小步长精调。5. 踩坑避坑ECG数据训练常见的五个坑与排查思路5.1 标签错位导致训练指标虚高现象训练loss下降很快验证准确率奇高但换到真实外部数据测试时效果崩塌。原因数据加载时文件列表顺序与标签列表顺序不一致或者对数据做shuffle时标签没有跟着同步。CSV的索引常用UUID命名如果你按文件名排序后再取标签而标签文件里本身是乱序的就会错位。解决在任何预处理之前先跑一次文件名和标签的交叉验证。对于每个样本把文件名和标签用dict打包然后只在dict层面做shuffle不单独操作两个列表。5.2 采样率不符导致滤波把P波抹掉了现象预处理后信号形状看起来怪怪的P波消失QRS波变钝。原因把采样率硬编码为500Hz但实际数据是360Hz或者1000Hz采样。滤波器的截止频率参数是按采样率归一化计算的采样率错了截止频率也错了。解决用scipy.signal.find_peaks检测R波尖峰然后计算相邻R波间隔的平均值估算真实采样率。或者直接在读取CSV时看行数除以记录的秒数来反推。如果实在无法确认采样率滤波的截止频率可以放宽到0.5到45Hz这样对采样率的宽容度会大一些。5.3 数据泄漏导致模型看起来很强其实废了现象训练时验证F1达到0.95跑外部数据集掉到0.6。原因数据划分时没有按患者ID分组同一个人的多段心电记录被同时分到训练集和验证集。模型实际上是在识别患者个体特征而不是识别疾病类型。解决查看数据集中是否有患者ID字段。如果有按患者划分而不是按文件划分。没有患者ID时退而求其次至少保证同一个文件名开头的记录尽量落在一侧。这里我一般经验是训练集和测试集文件列表检查UUID的分布同一前缀的集中在一侧就说明大概率按患者切过。5.4 归一化参数不同导致训练和推理结果不一致现象训练时效果正常部署到推理阶段后发现预测结果完全不对。原因训练时用整个训练集的均值和标准差做归一化推理时用了在线的均值和标准差两个分布不一致。ECG信号不同患者的基线水平差异大在线归一化会把一位患者的噪声放大或抑制。解决训练完成后把训练集的归一化均值、标准差保存成numpy文件推理时直接加载并套用。preprocess_ecg函数里的mean和std返回后单独存一份供推理使用。5.5 数据质量差导致一条样本毁掉一轮训练现象某次训练loss突然变成NaN之后一直无法恢复。原因CSV里有整行缺失值或者某个导联的振幅超出合理范围比如电极脱落瞬间的直流偏移归一化后出现极大值梯度爆炸。解决训练前置一条简单的质量检查——丢掉任意导联方差小于阈值的样本丢掉任意导联峰值超过5mV的样本。这条规则写在数据加载器的__getitem__里如果样本不合格就随机返回一个合格样本替代避免训练中断。6. 数据增强与模型解释提升泛化能力和验证模型学到了什么6.1 对心电信号做增强加噪、时移和幅值扰动心电数据不像图像那样适合翻转和裁剪盲目翻转会破坏波形方向语义。在验证过对抗训练类方法之后我的习惯是用组合增强对信号做小幅幅值缩放、加少量高斯噪声和随机时移这样模型能对振幅差异和时间偏移鲁棒一些。def augment_ecg(data, scale_range(0.9, 1.1), noise_level0.05, shift_range20): 在线数据增强 data: (12, time) 预处理后数据 # 随机幅值缩放 scale np.random.uniform(*scale_range) data data * scale # 添加高斯噪声 noise np.random.normal(0, noise_level, data.shape) data data noise # 随机时移 shift np.random.randint(-shift_range, shift_range) if shift 0: data np.roll(data, shift, axis1) data[:, :shift] 0 # 填充零 elif shift 0: data np.roll(data, shift, axis1) data[:, shift:] 0 return data这套增强思路的原理是模拟现实中采集信号的变化同一个患者在不同时间测量电极贴放位置的微小差异会带来幅值变化肌肉紧张程度会引入额外的高斯噪声呼吸深浅会改变心电在时间轴上的对齐位置。参数上noise_level0.05对应信号标准差的5%这个级别不会破坏波形形态shift_range20在500Hz下是40毫秒的偏移不超过一个心动周期语义上仍是同一个心跳。6.2 用LIME或Grad-CAM验证模型关注区域模型训练完不等于可以交付还需要确认模型看的是心电特征而不是噪声或基线。Grad-CAM是检查1D卷积模型比较直观的工具原理是用梯度加权的特征图来定位哪些时间区域对分类贡献最大。def grad_cam_1d(model, input_tensor, target_classNone): 简化版Grad-CAM用于1D ECG model.eval() input_tensor.requires_grad_(True) # 前向传播拿到最后一层卷积输出 activations {} gradients {} def save_activation(name): def hook_fn(module, input, output): activations[name] output return hook_fn def save_gradient(name): def hook_fn(module, grad_input, grad_output): gradients[name] grad_output[0] return hook_fn # 注册hook到conv3层 handle_act model.conv3.register_forward_hook(save_activation(conv3)) handle_grad model.conv3.register_backward_hook(save_gradient(conv3)) # 前向与反向 outputs model(input_tensor.unsqueeze(0)) if target_class is None: target_class torch.argmax(outputs, dim1).item() model.zero_grad() loss outputs[0, target_class] loss.backward() # 计算权重和热力图 weights torch.mean(gradients[conv3], dim2) cam torch.sum(weights * activations[conv3], dim1) cam torch.relu(cam) handle_act.remove() handle_grad.remove() return cam.detach().squeeze()这段代码的价值是让你看到模型为什么做了某个分类决策。如果生成的CAM高亮区域集中在QRS波群和ST段周围说明模型学到了生理学上有意义的特征如果高亮区域在信号开头或结尾大概率是学到了边界伪迹需要检查预处理环节。实际项目中我遇到过这样一种情况模型A的准确率比模型B高3个百分点但Grad-CAM显示模型A高亮整个信号的时间范围而模型B聚焦在ST段两者在真实临床数据上的表现完全反过来。从那以后每换一次数据训练完我都会强制走一遍Grad-CAM检查步骤确认模型关注的区域落在医生认可的心电特征频段内而不是黑匣子似地只知道调参。这份数据集的体量和标注质量做这类验证是足够的希望你也能看到你的模型到底看到了什么希望帮到你。本文还有配套的精品资源点击获取