☰
12导联ECG数据预处理与CNN心律失常分类实战
2026/10/6 12:53:42 网站建设 项目流程

简介:这是一份面向机器学习与医疗数据分析场景的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, fs=500, lowcut=0.5, highcut=50): """ 12导联ECG预处理 data: (n_samples, 12) 原始电压矩阵 fs: 采样率,默认500Hz """ # 1) 高通滤波去基线漂移 b_high, a_high = butter(4, lowcut / (fs / 2), btype="high") data_high = filtfilt(b_high, a_high, data, axis=0) # 2) 低通滤波去高频肌电干扰 b_low, a_low = butter(4, highcut / (fs / 2), btype="low") data_filtered = filtfilt(b_low, a_low, data_high, axis=0) # 3) 逐导联z-score归一化 mean = np.mean(data_filtered, axis=0) std = np.std(data_filtered, axis=0) 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, axis=0)[:3]}")

这段代码的逻辑是:先用高通滤波去掉0.5Hz以下的基线漂移,再用低通滤波保留50Hz以下的心电有效频段,最后把每个导联的电压值标准化为均值为0、方差为1的分布。参数上,fs要注意和数据实际采样率匹配,如果不匹配,滤波截止频率会偏移;lowcut设太低会保留更多基线漂移,设太高会把ST段压平影响诊断特征;highcut设太高会留下肌电噪声,设太低会抹掉QRS波的尖峰。

3.3 处理不等长样本:重采样与固定窗口切片

心电记录经常长度不一,有的10秒,有的8秒。深度学习模型输入尺寸固定,所以要么重采样到统一长度,要么在长信号上滑窗切片。我的习惯是保存原始长度信息,训练时动态切片,这样数据利用率最高。

def slice_ecg_fixed_length(data, window_size=1000, stride=500): """ 把长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_size=1000, stride=500) 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_size=1000): 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], dtype=torch.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_classes=5): super().__init__() # 第一层:12导联作为通道,卷积核跨时间扫描 self.conv1 = nn.Conv1d(12, 64, kernel_size=15, stride=2, padding=7) self.bn1 = nn.BatchNorm1d(64) self.pool1 = nn.MaxPool1d(4) # 第二层 self.conv2 = nn.Conv1d(64, 128, kernel_size=9, stride=2, padding=4) self.bn2 = nn.BatchNorm1d(128) self.pool2 = nn.MaxPool1d(4) # 第三层 self.conv3 = nn.Conv1d(128, 256, kernel_size=5, stride=2, padding=2) 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个采样点,kernel=15能覆盖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_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4) # 模型与优化器 model = ECGNet(num_classes=5) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-5) criterion = nn.CrossEntropyLoss() scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.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, dim=1) all_preds.extend(preds.numpy()) all_labels.extend(labels.numpy()) val_f1 = f1_score(all_labels, all_preds, average="weighted") print(f"Epoch {epoch+1}: loss={train_loss/len(train_loader):.4f}, val_f1={val_f1:.4f}") scheduler.step()

训练环节注意三点:一是类别不均衡时不能光看loss下降,要盯validation F1;二是lr=1e-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_level=0.05, shift_range=20): """ 在线数据增强 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, axis=1) data[:, :shift] = 0 # 填充零 elif shift < 0: data = np.roll(data, shift, axis=1) data[:, shift:] = 0 return data

这套增强思路的原理是模拟现实中采集信号的变化:同一个患者在不同时间测量,电极贴放位置的微小差异会带来幅值变化,肌肉紧张程度会引入额外的高斯噪声,呼吸深浅会改变心电在时间轴上的对齐位置。参数上,noise_level=0.05对应信号标准差的5%,这个级别不会破坏波形形态;shift_range=20在500Hz下是40毫秒的偏移,不超过一个心动周期,语义上仍是同一个心跳。

6.2 用LIME或Grad-CAM验证模型关注区域

模型训练完不等于可以交付,还需要确认模型看的是心电特征而不是噪声或基线。Grad-CAM是检查1D卷积模型比较直观的工具,原理是用梯度加权的特征图来定位哪些时间区域对分类贡献最大。

def grad_cam_1d(model, input_tensor, target_class=None): """ 简化版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, dim=1).item() model.zero_grad() loss = outputs[0, target_class] loss.backward() # 计算权重和热力图 weights = torch.mean(gradients["conv3"], dim=2) cam = torch.sum(weights * activations["conv3"], dim=1) 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检查步骤,确认模型关注的区域落在医生认可的心电特征频段内,而不是黑匣子似地只知道调参。这份数据集的体量和标注质量做这类验证是足够的,希望你也能看到你的模型到底看到了什么,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询