ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于CNN的心电异常检测实战:从数据预处理到模型训练

基于CNN的心电异常检测实战:从数据预处理到模型训练 简介面向深度学习与医疗AI方向的学习者本资源以Python实现心电异常检测基于卷积神经网络CNN对心电图信号进行识别与分类。心电信号属于典型一维时序数据通过CNN可自动提取波形中的局部特征适合想掌握医学信号处理、模型训练与评估流程的开发者参考。压缩包共8个文件包括5个Python脚本覆盖数据读取、自有数据制作、训练与测试等环节、2张结果展示图像及1个test测试文件整体仅9KB轻量易用。脚本对应CNN-ECG-master工程完整展示了从数据预处理、网络结构搭建、训练调优到性能评估的链路其中数据读取与自有数据生成模块方便读者替换为自己的心电数据训练脚本涵盖正常与异常N_and_R样本PNG图像直观呈现训练结果。该资源已有469人学习适合用于课程设计、竞赛入门或医疗AI方向的项目实践。1. 为什么拿CNN做心电异常检测从波形分类说起压缩包里的 CNN-ECG-master 和 N_and_R_train.py一看就是标准的心电信号分类项目把连续的心电记录切成固定长度片段用深度学习里的卷积神经网络CNN判断这段波形是正常还是异常。心电图的诊断核心是看形态和节律——P波、QRS波群、ST段的形状和间距而CNN的卷积核恰好擅长把这些局部特征一层层抽出来所以这个任务天生适合CNN。项目不算大但数据读取、样本制作、训练、测试全流程都有适合两类人一是深度学习入门后想找医疗方向实战项目的学生二是手头有自己采集的心电数据、想快速跑一个baseline的从业者。整套代码默认处理二分类也就是N类正常和R类异常后续你可以把R类扩展成具体病变。2. 项目文件拆解从数据流到训练入口拿到压缩包先别急着跑训练把每个文件的作用缕清楚能省掉后面大把排错时间。这个项目的文件命名很直白基本按照数据流排的先读取再制作样本再训练最后测试。中间任何一环的形状对不上train脚本都会在第一个epoch前报错。2.1 目录结构哪个文件负责哪件事先看文件树心里有个底CNN-ECG-master/ ├── ecg/ # 原始心电数据目录 ├── input_data.py # 读取公共数据集如MIT-BIH格式的入口 ├── ReadMyOwnData.py # 读取自己采集的心电数据 ├── make_own_data.py # 把原始信号切成样本并打标签 ├── N_and_R_train.py # 训练主脚本N正常/R异常 ├── test.py # 用测试集评估模型 ├── test # 测试数据目录或测试集文件 ├── result.png # 训练过程的曲线图 └── 20-28_08.png # 某次实验的记录图逐项说明ecg目录放原始数据可能是txt或csv每行一个采样点。input_data.py负责公共数据库的解析这些数据库为了节省存储往往用自定义打包格式必须从头文件和注释文件读信息。ReadMyOwnData.py则是给“裸数据”准备的——你从监护仪导出的记录没有标准注释只有一个电压序列。make_own_data.py的作用是把这两类数据统一成训练用的样本矩阵和标签矩阵。N_and_R_train.py是主训练脚本读入npy后构建CNN。test.py加载训练好的权重输出评估结果。而result.png和20-28_08.png不是源码是训练日志的可视化结果等下讲怎么读。2.2 公共数据集与自定义数据读取两个读取入口的分工公共心电数据和自己的数据差别很大。以MIT-BIH为例一个记录包含三个文件.hea头文件、.dat信号文件、.atr注释文件。头文件里写采样率、导联数、每导联的增益和偏移信号文件是压缩过的二进制注释文件标出了每个心拍的类型。input_data.py要同时解析这三样才能输出“信号序列 每拍标签”。这一步很琐碎但又是训练质量的地基。自己的数据就没有这么复杂了。ReadMyOwnData.py常见的逻辑是这样import numpy as np def read_own_file(path, fs250.0): # 常见格式csv每行一个采样点或者两列“时间,电压” raw np.loadtxt(path, delimiter,) # 如果是两列取第二列电压 if raw.ndim 2: signal raw[:, 1] else: signal raw return signal逻辑说明这里调用了np.loadtxt读文本数字两列数据时默认第二列是电压信号因为很多采集软件导出格式就是时间戳加幅值。参数说明fs是采样率当前函数里没实际用到但调用方会用它计算后续窗口长度。如果换用不同采样率的设备记得把fs传对否则后面窗口物理时长就错了。两个读取脚本存在的意义是做一个“数据适配层”公共数据集和自定义数据格式再乱到make_own_data.py手里时已经统一成numpy数组这样训练代码不用关心数据来源。2.3 样本制作为什么单独拆一个脚本make_own_data.py 要解决的三个问题make_own_data.py承担三件事切窗口、定标签、存npy。切窗口根据采样率把长信号切成固定长度定标签需要和医生标注或注释文件对齐存npy是为了让训练脚本加载时快一点也避免每次训练都重新跑一遍预处理。常见做法是输出train_x.npy、train_y.npy、val_x.npy、val_y.npy四个文件这样N_and_R_train.py里直接用np.load就行。一个典型的调用流程在命令行里长这样python make_own_data.py --data_path ecg/ --window_len 1250 --step 250参数说明window_len是窗口长度1250个采样点对应250Hz采样率下的5秒step是滑窗步长250代表每次滑动1秒窗口之间有4秒重叠。重叠能成倍增加样本量缺点是相邻样本高度相关后面避坑章会讲它的副作用。2.4 数据形状统一训练前必须检查的一行代码预处理完成后强烈建议在训练前先看一眼形状而不是直接塞进模型import numpy as np train_x np.load(train_x.npy) train_y np.load(train_y.npy) print(train_x.shape, train_y.shape) # 期望输出(样本数, 1250, 1) (样本数,) print(np.unique(train_y, return_countsTrue))逻辑说明模型输入要求三维数组分别是样本数、窗口长度、通道数。第三维是1表示单导联心电如果是多导联这里就是导联数。参数说明np.unique第二行输出各标签的数量比如(array([0, 1]), array([4000, 300]))说明正常样本4000、异常样本300后面要不要做类别平衡一眼就能定。3. 把心电信号喂给CNN数据预处理与样本制作CNN不会魔法它只是从数值里找规律。心电信号里有没有异常往往会体现在波形段落形态上。数据预处理决定了模型看到的是干净波形还是噪声加漂移的混合体。这个阶段做好训练时能少踩一半坑。3.1 带通滤波0.5-45Hz 是心电的常规操作原始心电信号里混着三种常见噪声基线漂移、肌电干扰、工频干扰。基线漂移频率很低患者呼吸甚至电极移动都会引起肌电干扰频率高往往是几十赫兹以上。带通滤波就是只保留心电能量集中的频段。常用代码from scipy.signal import butter, filtfilt def ecg_bandpass(signal, fs250.0): lowcut 0.5 # 低频低于0.5Hz通常是基线漂移 highcut 45.0 # 高频高于45Hz通常是肌电干扰 nyquist 0.5 * fs low lowcut / nyquist high highcut / nyquist b, a butter(4, [low, high], btypeband) return filtfilt(b, a, signal)逻辑说明采用4阶巴特沃斯带通滤波用filtfilt做零相位滤波。普通filter会带来相位延迟导致波形在时间轴上被平移filtfilt先正向再反向各跑一遍相位延迟抵消R峰位置不会偏移。参数说明lowcut0.5是下限highcut45是上限。这里上下限是归一化到奈奎斯特频率后再传给butter的nyquist fs/2是必须的一步。如果采样率不是250Hz要同步改。我一般不建议把下限再调低到0.1Hz因为那会把呼吸引起的基线漂移放进来反而让ST段分析更困难上限如果太高到100Hz肌电噪声会淹没波形细节。0.5-45Hz这个组合在心电分类项目里几乎是默认值。3.2 滑窗切段窗口长度和步长怎么定心电记录往往持续几分钟甚至几小时不能整段丢给CNN。常见做法是滑窗切段。窗口长度取决于你要分类的目标如果只看单一心跳200-300个采样点就够了如果要看节律和ST段变化至少需要几秒上下文。250Hz采样率下5秒就是1250个采样点这是比较常见的默认值。import numpy as np def make_windows(signal, win_len1250, step250): windows [] for start in range(0, len(signal) - win_len 1, step): windows.append(signal[start:start win_len]) return np.array(windows)逻辑说明range的步长是step所以每个窗口起始位置相隔250个采样点。窗口内部是连续信号片段没有做额外处理。返回的数组形状是(窗口数, 1250)训练时再reshape成(窗口数, 1250, 1)。参数说明win_len决定每次模型看到的时间跨度step决定相邻窗口重叠程度。step250时相邻窗口有1000个点的重叠样本量扩大到原来的5倍。如果做二分类我通常把step设小一点多生成样本如果担心数据泄露就把step调到和win_len一样让窗口不重叠。3.3 标签对齐与样本平衡N和R到底怎么打N_and_R_train.py里的N和R拆开看就是正常节律和异常节律两类。如果使用公共数据集注释文件已经标好每个心拍的类型把正常类型归为N把早搏、房颤、传导阻滞等归为R即可。如果是自己的数据最稳妥的办法是只取医生明确标注的片段把模糊不清的交界段丢弃否则模型学到的边界是脏的。以R峰对齐的窗口能保证每个样本都包含一个完整心拍def build_beat_windows(signal, r_peaks, normal_idx, abnormal_idx): X, y [], [] win_half 625 # 1250的一半以R峰为中心 for idx, label in [(i, 0) for i in normal_idx] [(i, 1) for i in abnormal_idx]: start r_peaks[idx] - win_half end r_peaks[idx] win_half if start 0 and end len(signal): X.append(signal[start:end]) y.append(label) return np.array(X), np.array(y)逻辑说明r_peaks是每个心拍R峰位置的数组normal_idx和abnormal_idx是对应R峰索引的列表。以R峰为中心取前后各625个点这样每个样本从P波开始到T波结束结构完整。参数说明win_half625和窗口长度1250绑定如果改了窗口长度这里要同步改成win_len // 2。这里还有一个隐藏问题异常样本通常远少于正常样本。如果直接用原始比例训练模型学到的就是“反正正常多全部预测正常也能拿高准确率”。常见做法是对少数类加权或者复制少数类样本让两类数量接近。后面训练章会在class_weight参数里继续展开。3.4 可视化检查不画图就训练等于闭眼开车预处理完我强烈建议抽几条样本画出来看看。这一步成本极低但能发现大量问题滤波导致波形畸形、窗口平移导致心拍被拦腰截断、标签和波形错位。一个简单的画图脚本import matplotlib.pyplot as plt def plot_samples(X, y, sample_indices, fs250.0): plt.figure(figsize(12, 3 * len(sample_indices))) for i, idx in enumerate(sample_indices): plt.subplot(len(sample_indices), 1, i 1) time_axis np.arange(len(X[idx])) / fs plt.plot(time_axis, X[idx]) plt.title(flabel{y[idx]}) plt.tight_layout() plt.savefig(preprocessed_check.png, dpi150)逻辑说明把选中的样本按时间轴画出来横轴是秒纵轴是幅值。title显示标签肉眼确认波形形态和标签是否一致。参数说明sample_indices里可以混合传正常和异常样本各选几条对比。如果看到波形里R峰高度只有相邻噪声的2倍或者整段波形平得像直线多半是滤波参数出了问题回到第3.1节调整。4. 训练与测试N_and_R_train.py 里的参数与调整数据准备好后训练脚本就是核心。这一章会把模型结构、编译参数、训练曲线和模型保存一次性讲清楚。很多人在这一步反复改网络结构结果越改越差。实际上大部分问题出在数据或超参数不在网络层数。4.1 模型结构Conv1D 还是 Conv2D很多入门项目会把一维信号存成二维矩阵再套用图像分类的Conv2D因为在网上找到的现成教程大多是二维CNN。但如果你的输入本质上是一维序列建议用Conv1D卷积核在一维时间轴上滑动参数量比Conv2D小很多更不容易过拟合。CNN-ECG-master这个项目名虽然叫CNN但没有规定必须用二维卷积从文件内容看模型部分写在N_and_R_train.py里完全可以改成Conv1D。一个可以直接用的Conv1D模型from tensorflow.keras import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, Flatten, Dense, Dropout def build_model(input_len1250): model Sequential([ Conv1D(32, kernel_size5, activationrelu, input_shape(input_len, 1)), MaxPooling1D(pool_size2), Conv1D(64, kernel_size5, activationrelu), MaxPooling1D(pool_size2), Flatten(), Dense(64, activationrelu), Dropout(0.5), Dense(2, activationsoftmax) ]) return model逻辑说明第一层Conv1D有32个卷积核每个核长度5对于250Hz采样率5个采样点就是25毫秒这个尺度能捕捉到QRS波群的一小段。池化层把特征图长度减半减少计算量。第二层卷积核数量翻倍到64这是浅层网络常见的倍增策略。最后接Dropout(0.5)随机丢弃一半神经元防止过拟合。参数说明input_shape里的input_len必须和make_own_data.py里的窗口长度一致如果窗口是1000这里必须同步改成1000否则报维度错误。4.2 编译参数和训练参数小数据集别追求大epoch模型结构定稿后编译和训练参数直接影响收敛行为。二分类标签是0和1建议用sparse_categorical_crossentropy因为标签是整数不需要做one-hot。优化器选Adam基本不需要调整学习率也能跑得动。关键参数是class_weight它对不平衡数据几乎起决定作用。model build_model(input_len1250) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy]) model.fit( train_x, train_y, validation_data(val_x, val_y), batch_size64, epochs30, class_weight{0: 1.0, 1: 2.5} )逻辑说明class_weight在计算loss时给异常类样本的误差乘以2.5相当于人为放大了异常类的影响。参数说明{0: 1.0, 1: 2.5}里0是正常类1是异常类。初始值可以按多数类样本数除以少数类样本数估算比如正常4000、异常400权重就是10。如果调高到5仍然漏检继续上调到8或10直到召回率有明显提升。batch_size取64在ECG这种一维数据上通常够用数据集小就取32。epochs写30不代表要跑满后面会讲用验证集曲线判断提前停止的位置。另外train_x的形状必须是(样本数, 1250, 1)少一维或多一维都会在模型第一层报错。4.3 训练曲线怎么看result.png 和 20-28_08.png 常见形态项目里那两张png一张可能是loss曲线一张可能是accuracy曲线。20-28_08.png这种命名看起来像某次实验在20点28分08秒的截图说明作者也和我们一样跑一版存一版方便对比。这类曲线最常见的形态有三种第一种训练loss一路下降验证loss先降后升呈U字形。这是过拟合的标准信号模型开始背训练样本而不是学通用特征。解决办法不是加网络层数而是把epoch提前到U字转折点或者把Dropout从0.5加高到0.6。第二种训练和验证的accuracy都停在0.6附近不动。这说明模型没学到判别性特征优先检查数据标签是否错位、窗口是不是把心拍切歪了、滤波后波形是否正常。第三种曲线剧烈震荡loss值忽高忽低。常见原因是batch_size太小比如只有8或16梯度更新方向不稳定调大一点到64再看。我自己的习惯是每个epoch都把history存下来训练结束后用两行代码画图对比import matplotlib.pyplot as plt plt.plot(history.history[loss], labeltrain_loss) plt.plot(history.history[val_loss], labelval_loss) plt.legend() plt.savefig(loss_curve.png, dpi150)逻辑说明history.history是Keras自动记录的训练过程字典里面的loss和val_loss是每个epoch后的值。画在一起能直观看到过拟合转折点。参数说明如果验证集loss在某个epoch后持续上升训练loss还在下降把epochs改小到转折点附近重训比硬跑30轮更好。4.4 保存模型和测试入口test.py 的基本流程训练结束后要把模型权重和结构存下来test.py才有东西可加载。常见做法如下model.save(my_ecg_model.h5)然后test.py里加载from tensorflow.keras.models import load_model model load_model(my_ecg_model.h5) loss, acc model.evaluate(test_x, test_y) print(ftest_loss{loss:.4f}, test_acc{acc:.4f})逻辑说明model.evaluate会返回编译时指定的损失和指标。如果只想看准确率这个就够如果想知道哪类错得多需要调用预测并输出混淆矩阵这个放到最后验证章展开。参数说明test_x和test_y必须和训练数据保持完全相同的预处理流程否则评估结果没有意义。尤其是滤波参数测试数据也需要走同一套带通滤波和标准化。5. 避坑指南心电分类里最常见的4个翻车现场下面这些坑不是概率事件是我拿公共数据和自己的设备数据反复折腾时踩过的。每一条都按“现象 → 原因 → 解决”写遇到类似情况可以直接对号入座。5.1 数据泄露和类别不平衡两个坑会让准确率失真第一个坑验证集准确率0.99换了新病人立刻掉到0.7。现象是训练时好看部署时难看。原因是滑窗重叠导致同一个心拍同时出现在训练集和验证集里模型相当于提前背了答案。解决手段是严格按患者或记录段划分数据不要按窗口划分。用GroupKFold替代train_test_splitfrom sklearn.model_selection import GroupKFold gkf GroupKFold(n_splits5) for train_idx, val_idx in gkf.split(X, y, groupspatient_ids): train_x, val_x X[train_idx], X[val_idx] train_y, val_y y[train_idx], y[val_idx]逻辑说明groups参数传每个样本对应的患者ID同一个患者的所有窗口只会进同一折从源头切断数据泄露。参数说明n_splits5表示五折交叉验证如果患者少可以改成3要保证每个患者只有一类样本的情况不要发生否则验证集中会缺类别。第二个坑模型把所有样本预测为正常准确率依然有90%。原因是异常样本占比极低模型学成一个“永远回答正常”的分类器也能拿到很高的accuracy。解决一方面是给异常类加权另一方面是评估指标换成召回率和F1。在二分类里漏掉一个异常比多报一个正常危险得多所以先看异常类的召回率再看整体准确率。5.2 滤波过猛和维度不匹配预处理阶段的常见错误第三个坑滤波后波形变得光秃秃的R峰明显变矮。现象是预处理后的波形和原始波形差别很大QRS波群不够尖锐。原因通常是把滤波下限设到了0.1Hz甚至更低基线漂移滤不干净ST段被低频成分畸变另一个常见原因是用了普通filter造成相位偏移。解决方法是把带通固定为0.5-45Hz并且用filtfilt做零相位滤波。滤波后先画图确认R峰形态正常再进下一步。第四个坑训练时报错 “Data cardinality is ambiguous”。现象是model.fit直接弹异常提示输入尺寸对不上。原因是不同病人的信号长度不一致洗样本时窗口长度和输入层长度不匹配。解决方法是统一重采样到相同采样率比如250Hz再滑窗如果重采样不方便则把所有样本pad到相同长度。排查顺序是先print(train_x.shape, train_y.shape)再打印model.input_shape两个数字对不上就是这里出了问题。这一步其实应该在第2.4节完成。5.3 评估指标选错等于白跑漏检比误报更危险第三个大坑延续类别不平衡是只盯着accuracy调参结果误报率很低但漏检率高得吓人。心电异常检测的最终目标是帮医生筛查漏掉一个真正的异常比多报几个假阳性严重得多。所以评估时应该以异常类的召回率为主特异性为辅。建议每次训练后都输出混淆矩阵把tn, fp, fn, tp四个数字拆开看而不是只看一个accuracy。6. 验证模型没白训用自己的数据跑一遍全流程6.1 从 test.py 开始加载模型和单条样本预测test.py的核心逻辑通常是加载历史权重对测试集或单条样本做预测。下面是最小可用的单条预测脚本方便你替换自己的文件路径from tensorflow.keras.models import load_model import numpy as np model load_model(my_ecg_model.h5) sample np.load(one_window.npy) # 预处理后的一个窗口形状(1250,) sample sample.reshape(1, 1250, 1) # 补上batch维和通道维 pred model.predict(sample)[0] print(fN正常: {pred[0]:.3f}, R异常: {pred[1]:.3f})逻辑说明load_model读的是训练完保存的h5文件reshape的1250必须和训练时窗口长度一致。输出是softmax概率两个值相加为1取最大值对应的索引就是预测类别。参数说明如果训练时input_shape是(1000, 1)这里的1250要全部改成1000模型里和这里要同步。6.2 用混淆矩阵和召回率做最终验收单条预测能看但下结论还早。我一般会把整个测试集跑完输出混淆矩阵和召回率from sklearn.metrics import confusion_matrix test_x np.load(test_x.npy) # (样本数, 1250, 1) test_y np.load(test_y.npy) # (样本数,) pred_y np.argmax(model.predict(test_x), axis1) tn, fp, fn, tp confusion_matrix(test_y, pred_y).ravel() recall tp / (tp fn) if (tp fn) 0 else 0 specificity tn / (tn fp) if (tn fp) 0 else 0 print(f召回率: {recall:.3f}, 特异性: {specificity:.3f})逻辑说明在心电异常场景里召回率比准确率更关键它代表异常样本中有多少被正确捡出来。特异性代表正常样本中有多少被放过去它决定假阳性率。参数说明如果confusion_matrix返回的不是四个元素而是二维矩阵说明你的测试集只有一类样本先回去看测试集标签分布补上缺失的类别再评估。我自己的习惯是每次训练完强制走一遍这个流程先算混淆矩阵再随机抽几条被错分的样本把波形画出来看看模型是不是靠噪声猜的。如果错分的波形在肉眼下确实也模糊那就接受如果波形明显干净却被分错就要回头查数据标签。从那以后我很少被训练曲线上的准确率唬住。希望帮到你。本文还有配套的精品资源点击获取
返回列表