资讯动态

Keras一维卷积神经网络实现心电信号心律失常分类与K折验证

发布时间:2026/10/13 9:06:03 来源:尧图企业网站定制
前段时间接了个活儿要从一段段心电图波形里自动判断心跳是否异常。需求本身听起来不复杂数据是带标签的时序信号正常、早搏、房颤、其他异常拿深度学习模型分个类就行。但真正动手之后我意识到心电信号分类和图像分类的思维完全是两回事——它的每个样本不再是一张图而是一维的时间序列它的“特征”藏在波形的局部形态和时序节奏里它的评估方式也远比“准确率高不高”更讲究。最终我采用的是Keras搭建的一维卷积神经网络Conv1D配合K折交叉训练来做模型稳定性评估这也正是这个项目标题所对应的完整技术路线。这篇文章我想把整条链路拆开讲一遍从一维卷积为什么适合心电信号到数据预处理怎么做才算合格再到网络结构怎么设计、K折训练怎么落地以及最后那些只有真正跑过实验才会踩到的坑。如果你也在做时间序列分类特别是生物信号相关的二分类或多分类任务这篇内容应该能帮你省下几周的弯路。1. 一维卷积到底在算什么心电信号没你想的那么适合“转图片”1.1 心电信号是一段电压随时间变化的曲线先交代一下背景。心电信号反映的是心脏电活动简单来说就是心肌细胞兴奋和恢复过程中在体表形成的电位差变化。一段典型的单导联心电波形里有几个非常明显的特征P波、QRS波群和T波。P波对应心房去极化QRS波群对应心室去极化T波对应心室复极化。心率失常的很多表现恰恰就在这些波的形态、间期和节律上。比如房颤典型表现是P波消失代之以不规则的f波RR间期绝对不齐室性早搏会在正常节律中突然出现一个宽大畸形的QRS波房性早搏则往往在P波形态和PR间期上有细微变化。这些特征的共同点是它们在时间轴上持续的时间不长短的只有几十毫秒而且具有明显的局部形态特性。这就给模型算法提出了一个很直接的问题——怎么从一段长达数秒的波形里把这些局部且可能存在多个位置的形态变化找出来。传统的特征工程会去手动提取RR间期、QRS宽度、波形面积等统计量但特征设计费时费力而且遇到复杂形态很容易漏。深度学习方案则希望让网络自己去学这些局部形态特征于是自然就落到了一维卷积上。1.2 Conv1D如何抓局部形态卷积核就是一组可学习的模板一维卷积做的事情可以理解为在时间轴上滑动一组“模板”每个模板与局部波形做内积输出一个响应值。这个响应值越大说明该位置的局部形态与模板越匹配。一个卷积层里有多个卷积核就相当于同时用多个不同形态的模板去扫描信号每个模板学到一种它认为重要的局部模式。比如第一个卷积层如果用了64个卷积核核宽为5在采样率200Hz的条件下每个卷积核覆盖的时间长度是5/20025毫秒。这个时间尺度恰好能捕捉QRS波群的上升支和下降支、P波的局部斜率这类短时形态。后面的卷积层通过堆叠一层比一层看到的范围更大从“局部线段”逐步组合成“波群结构”再往上就能学到“几个波组成的节律模式”。整个过程和图像CNN从边缘到纹理再到物体部件的特征组合逻辑是一样的只不过扫描维度从二维平面变成一维时间轴。有一件事需要在工程上特别注意一维CNN输入数据的形状是(样本数, 时间步长, 通道数)。单导联时通道数为1多导联时通道数可以是6、12。千万别顺手把形状整成(样本数, 时间步长)Keras里的Conv1D会直接报错。这个问题自己遇到过两次每次都是花了十几分钟才反应过来。1.3 为什么不建议先把信号转成频谱图再扔给二维CNN做心电分类的时候很多人会想到另一个思路既然图像分类成熟方案多那就先用短时傅里叶变换把一段信号转成时频谱图再用ResNet、EfficientNet这类二维CNN去分类。这个思路不是完全不能用但我试过之后确实没继续用原因主要有几个。第一频谱图会丢掉波形本身的时序相位信息。心电诊断里非常重要的波形顺序——P波之后隔一段才到QRS、QRS之后才是T波——在时频谱上并不直观模型需要额外去学这些时序关系成本更高。第二中间多了一步信号处理超参数就多了一堆窗长设多少、重叠率设多大、用哪种窗函数、做不做幅值归一化每一个选择都会影响最终效果调起来非常痛苦。万一模型效果不好你很难判断到底是分类模型的问题还是时频谱参数的问题。第三二维CNN动辄十几层参数量大在小样本的医疗数据集上很容易过拟合。而一维CNN的结构可以做得轻很多配合BatchNorm、Dropout和全局平均池化几万到几十万参数的模型就能取得不错的效果训练速度快迭代成本低。我并不是说时频谱图方案一无是处在部分噪声很大、需要做数据增强的场景里它也有价值。但就“从原始波形直接端到端学习”这件事来说一维CNN是更直接、更可控的技术路线也更匹配“K折交叉训练评估稳定性”这个工程目标——因为验证循环可以写得非常干净。2. 数据准备决定模型上限的那一步从原始波形到训练集2.1 数据源头与标注检查这个项目用的是公开的心律失常标注数据每个样本是一段以R峰为中心的固定长度波形附带一个类别标签。我建议一开始拿到数据不要急着写Dataloader先做三件事统计类别分布、看一眼波形本身、看清楚标签的可信程度。统计类别分布很容易暴露问题。我当时的数据大致是这样分布的具体数值已经脱敏处理仅用于说明思路类别样本数占比正常800060%房颤200015%室性早搏150011%房性早搏10007%其他异常9007%这是一个典型的类别不平衡分布。正常样本占了六成几种异常类别加起来不到一半。如果不做任何处理直接训练模型倾向于把所有样本都预测成正常类准确率也能轻松做到60%以上但没有任何临床价值。这一点在后面评估部分会展开讲。标注可信度这件事更要重视。公开数据集不代表一定干净有些异常标签在波形上看起来非常勉强而且R峰位置偶尔会偏移。建议随机抽几百条样本把波形画出来人工核对一遍特别是边缘类别比如房早和正常、其他异常和室早之间的边界。这个工作虽然粗糙但能救回很多无效训练时间。2.2 预处理流水线滤波、切片、归一化我用的预处理流程先后做四步带通滤波、以R峰为中心切片、逐样本归一化、统一长度。带通滤波是为了去除基线漂移和高频噪声。心电信号的有效频率范围一般在0.5到45Hz附近低于0.5Hz的基线漂移和高于45Hz的肌电干扰都会污染模型输入。滤波我直接用scipy.signal里的巴特沃斯带通滤波器代码非常短from scipy import signal def bandpass_filter(data, fs200.0, lowcut0.5, highcut45.0, order4): nyquist 0.5 * fs low lowcut / nyquist high highcut / nyquist b, a signal.butter(order, [low, high], btypeband) return signal.filtfilt(b, a, data)需要注意filtfilt是零相位滤波对形态保持更好不会引入相位偏移比单纯用lfilter可靠。滤波之后信号里的R峰位置会因为滤波而稍微变形所以R峰标注的坐标需要和滤波后的数据对齐实际操作是先用原始波形检测或读取R峰位置再在滤波后的信号上切片。切片时以R峰为中心取一个固定长度的时间窗。窗长怎么定太短会丢掉相邻心跳的节律信息太长又会把无关噪声塞进来而且正常和异常的边界会变得模糊。采样率是200Hz时我用的是2.5秒窗长即前后各取1.25秒总长度500个采样点。这个长度既包含了当前心跳的完整P-QRS-T形态又覆盖了前后一小段节律背景够用且不臃肿。归一化这一步最容易出问题。我的经验是对心电信号逐样本做z-score归一化每个样本先减去自己的均值再除以自己的标准差。因为不同个体、不同导联甚至不同采集时间下心电信号的绝对幅值差异很大逐样本归一化能消除这种幅度尺度差异同时天然避免数据泄露——每个样本的均值和标准差只来自该样本本身训练集和验证集之间没有任何信息交换。def zscore_per_sample(x): x x - x.mean(axis-1, keepdimsTrue) x x / (x.std(axis-1, keepdimsTrue) 1e-8) return x如果有的人先对全部数据求全局均值方差再归一化然后切分训练集验证集那确实是另一种做法但要注意全局统计量会让验证集的分布信息提前进入训练阶段形成一种轻微的“数据泄露”。这个细节后面单开一节专门讲。2.3 标签体系与类别合并标签体系直接影响模型的学习目标和最终评估口径。我最初采用的是五分类正常、房颤、室性早搏、房性早搏、其他异常。如果你只想做一个“是否异常”的二分类模型那就把后四类合并成异常类。多分类的优点是信息更细但类别边界也更难学。房早和正常的界限本身就比较模糊室早偶尔也会被标注成其他异常。我当时最终保留了五分类因为评分指标用的是宏平均F1这个指标对每个类别的表现都平等对待即使某些类别样本少、准确率不高也会在评估中暴露出来不会因为类别不平衡而被掩盖。如果只想做二分类结构上只需要把最后一个全连接层的输出从5改成2损失函数从categorical_crossentropy对应的独热编码换成sparse_categorical_crossentropy配整数标签也行。我推荐用独热编码加categorical_crossentropy语义更明确不容易写错。3. 模型设计与K折训练用Keras搭一个能解释的Conv1D3.1 网络结构每一层为什么这么排这个项目的模型结构并不复杂核心思想是“轻量、渐进、防过拟合”。我最终采用的网络结构如下层输出尺寸说明Input(500, 1)2.5秒波形单通道Conv1D(64, 5) BN ReLU(500, 64)提取局部短时形态MaxPooling1D(2)(250, 64)时间维度减半Conv1D(128, 5) BN ReLU(250, 128)组合上一层的短时特征MaxPooling1D(2)(125, 128)再减半Conv1D(256, 3) BN ReLU(125, 256)扩大感受野提取节律模式GlobalAveragePooling1D(256,)全局特征向量Dense(128) ReLU(128,)特征交互Dropout(0.5)(128,)防过拟合Dense(5) Softmax(5,)类别概率输出第一层用64个核宽为5的卷积核核宽5在200Hz采样率下覆盖25毫秒能抓住QRS波群的短线形态。第二层128个核再堆叠一层组合出更宽的结构。第三层用了核宽3此时输入已经经过两次池化时间分辨率降低核宽3对应的实际感受野已经比第一层大得多。池化这里有两个作用一是减少计算量二是让模型对不同长度的心跳间期具备一定的平移不变性——早搏发生得早一点晚一点不影响分类结果。BatchNormalization放在卷积和激活之间。BatchNorm对一维信号很友好它能稳定中间层的激活分布允许我用更高的学习率而不容易发散。GlobalAveragePooling1D这一层非常关键它把(125, 256)的特征图在时间维度上求平均压成一个256维向量。相比直接Flatten它显著减少参数而且天然起到正则化作用——逼着每个卷积核在整段时间上都学到有意义的信息而不是只在某一时刻有响应。Dropout加在高维全连接层之前系数取0.5是默认稳妥值。说实话这个项目里模型参数量不算大即使不加Dropout也不容易严重过拟合但加上之后验证集指标确实更稳所以保留了下来。3.2 完整模型代码与K折训练框架模型用Keras实现代码风格尽量直白import numpy as np import tensorflow as tf from tensorflow import keras from tensorflow.keras import layers def build_model(input_len500, num_classes5): model keras.Sequential([ layers.Input(shape(input_len, 1)), layers.Conv1D(filters64, kernel_size5, paddingsame, activationrelu), layers.BatchNormalization(), layers.MaxPooling1D(pool_size2), layers.Conv1D(filters128, kernel_size5, paddingsame, activationrelu), layers.BatchNormalization(), layers.MaxPooling1D(pool_size2), layers.Conv1D(filters256, kernel_size3, paddingsame, activationrelu), layers.BatchNormalization(), layers.GlobalAveragePooling1D(), layers.Dense(128, activationrelu), layers.Dropout(0.5), layers.Dense(num_classes, activationsoftmax) ]) return model有了模型接下来是K折交叉训练框架。核心思路是把数据集切分成K份轮流拿1份做验证、其余K-1份做训练重复K次最后汇总K份验证结果。这样做比一次性划分训练集/验证集更稳能充分利用有限数据也能看出模型对不同数据切分的敏感程度。用StratifiedKFold做分层切分这段代码是整个实验的骨架from sklearn.model_selection import StratifiedKFold X np.expand_dims(waveforms, axis-1) # (N, 500) - (N, 500, 1) y labels_onehot # (N, 5) kfold StratifiedKFold(n_splits5, shuffleTrue, random_state42) fold_scores [] for fold, (train_idx, val_idx) in enumerate(kfold.split(X, y)): print(f--- Fold {fold 1}/5 ---) X_train, X_val X[train_idx], X[val_idx] y_train, y_val y[train_idx], y[val_idx] model build_model(input_lenX.shape[1], num_classesy.shape[1]) model.compile( optimizerkeras.optimizers.Adam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs50, batch_size64, callbacks[early_stopping, reduce_lr, checkpoint], class_weightclass_weight_dict, verbose1 ) score model.evaluate(X_val, y_val, verbose0) fold_scores.append(score)这里有几个点需要展开。StratifiedKFold的random_state固定住之后K折切分每次都是一样的结果可复现。shuffleTrue确保切分前先随机打乱防止原始数据里类别按顺序排列导致某折类别比例异常。对类别不平衡数据千万不要用普通KFold原因下一节专门说。3.3 StratifiedKFold和KFold的差别以及为什么必须分层KFold只是把数据按顺序均分成K份不管每份里的类别比例。如果原始数据按类别一行一行排好不shuffle直接切可能第一折全是正常样本第二折全是房颤样本验证分数会剧烈震荡完全没有参考价值。即使shuffle了类别不平衡时纯随机切也可能出现某折没有某类样本的情况那这一折的类别召回率就无从谈起了。StratifiedKFold是在切分时保证每一折中各类别的比例与全数据集尽量一致。比如全数据集中正常样本占60%那每一折里正常样本也大约占60%。这样一来五个折的评估基准是公平可比的每个类别在每折里都会有足够样本去计算召回率宏平均F1的方差也会显著降低。心电数据还有一个更严格的分层维度患者ID。如果数据集里多个样本来自同一个人而切分时随机把这些样本散到训练集和验证集那模型可能在“记住人”而不是“学会异常模式”验证分数会虚高。这种情况更稳妥的做法是GroupKFold保证同一个人的所有样本只出现在同一边。不过这个项目当时用的是公开数据集的片段级样本ID信息不完整所以没有强行按人分组。如果你的数据能拿到subject ID我非常建议直接上GroupKFold。这件事是医疗时序分类里最容易被忽略的评估陷阱。3.4 回调函数早停、学习率衰减、CheckpointK折训练中每一折都需要一个独立的回调配置。三个回调缺一不可EarlyStopping监控验证集损失当连续多个epoch没有改善时自动停止训练ReduceLROnPlateau在指标停滞时把学习率降一个量级帮助模型更精细地收敛ModelCheckpoint只保存验证集最优的那一份权重。标准化代码如下from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint early_stopping EarlyStopping( monitorval_loss, patience10, restore_best_weightsTrue ) reduce_lr ReduceLROnPlateau( monitorval_loss, factor0.5, patience5, min_lr1e-6 ) checkpoint ModelCheckpoint( filepathfbest_fold_{fold}.keras, monitorval_accuracy, save_best_onlyTrue )restore_best_weightsTrue这个参数尤其关键。如果只设patience不恢复最优权重模型返回的是最后一次epoch的权重而不是验证集最优的那一步。很多人的实验结果看似不稳定其实问题就出在这里。另外注意K折训练中的回调对象要在每折内重新创建不能复用一个已经绑定了旧日志状态的回调实例否则会出现进度混乱。学习率这里我初始用1e-3的Adam。其实针对小型数据集1e-3搭配BatchNorm完全跑得动不需要从1e-4起步。训练损失会在前几个epoch快速下降后面进入慢速精调阶段这时候ReduceLROnPlateau会自动接力省去很多手动调学习率的烦恼。4. 评估阶段最容易被误读的指标准确率高了不代表真做对了4.1 在心律失常数据里准确率为什么会骗人这个项目前期让我最纠结的不是模型训练而是评估。单纯看准确率的话模型很快就能冲到90%以上因为正常样本占了60%模型只要把绝大多数正常样本判断对准确率就已经很高了。但这完全不代表异常检测能力合格。举个例子如果模型把所有样本都判为正常准确率是60%如果模型把所有样本都判为正常但小心地让正常类召回率做到95%准确率就能到90%以上。可这时真正需要被发现的异常样本可能一例都没抓到——这在医疗场景里是完全不可接受的。所以评估指标必须把每个类别分开看特别是少数类。我最终在模型训练日志里同时跟踪accuracy和AUC但真正的核心指标选的是宏平均F1Macro F1。宏平均F1是每个类别的F1值加总后除以类别数它平等对待每个类别少数类的表现会被明确暴露。模型整体F1很高而宏平均F1偏低说明模型是靠正常类撑起来的异常类其实一塌糊涂。4.2 混淆矩阵怎么读揪出容易被漏掉的阳性类别K折训练结束后我把五折验证预测结果全部收集起来合并成一份完整的混淆矩阵。看混淆矩阵有两个核心目的找到错误最集中的类别对以及判断错误是“漏检”还是“误报”。我当时那份混淆矩阵大致长这样数值经过脱敏只用于说明读法实际\预测正常房颤室早房早其他异常正常7650130908050房颤2201680403030室早1805012003040房早350608042090其他异常160407050580一眼就能看到问题房早这个类别大量被预测成正常正常样本也有不少被误判成房早和室早。这说明模型能抓住室早和房颤这种形态非常明显的异常但对房早这种形态差异小的类别学到的判别特征还不够。房早在诊断里本身就有争议性但如果你是做“减少漏诊”的模型看到这个残差结构就该知道下一步要增加房早的训练样本或者针对这类做专门的数据增强而不是盲目加深网络。绘制混淆矩阵用sklearn.metrics.confusion_matrix和ConfusionMatrixDisplay把五折的验证样本收集到同一份数组里再画一张图就能看出全部问题比一折一折画要高效得多。4.3 把K折结果聚合起来看方差K折交叉训练还有一个额外的好处能估算模型在不同数据切分下的稳定性。每折的验证指标单独看差异可能很大所以一定要算均值和标准差。我当时5折的结果大致是这个分布Fold准确率宏平均F110.9430.87420.9380.86230.9510.88340.9290.84350.9470.871均值±标准差0.942±0.0080.867±0.014准确率的标准差只有0.008看起来模型很稳定但宏平均F1的标准差有0.014这意味着少数类的表现在不同折之间有波动。如果你的宏平均F1标准差显著偏大比如超过0.05说明模型对训练数据中哪几个样本进验证集非常敏感这时候就要往数据多样性和类别不平衡方向排查而不是继续调网络结构。聚合代码简单直接import numpy as np from sklearn.metrics import f1_score all_preds np.concatenate(fold_preds) all_labels np.concatenate(fold_labels) macro_f1 f1_score(all_labels, all_preds, averagemacro) print(fOverall Macro F1: {macro_f1:.4f})注意把所有折的预测拼起来再算宏平均F1与先算每折的F1再取平均略有差异前者更接近“全部验证样本上的综合表现”我汇报时用前者同时也会把K折均值标准差列出来让读者看到分布。5. 训练与调参过程中几个必须知道的坑5.1 随机种子不可复现的实验等于白做模型训练里有三个层面的随机性数据切分的随机、权重初始化的随机、泡澡和Dropout在训练过程中的随机。如果不做任何固定同一份代码跑两次指标可能差一个百分点这种“薛定谔的实验结果”没法支撑任何结论。在脚本开头固定种子这是最基础的一步import random import numpy as np import tensorflow as tf random.seed(42) np.random.seed(42) tf.random.set_seed(42)但也要说实话TensorFlow在某些运算比如GPU上的非确定性算子下仍然无法保证100%复现。更现实的做法是固定种子后多跑几次看指标波动范围是否在可接受区间。仅凭单次实验的分数就下结论是新手最容易犯的错误。5.2 归一化泄漏标准化一定要折内完成我在前面提过按样本做z-score归一化这种做法天然规避了全局标准化引入的数据泄漏。但有些人习惯对全部样本先求均值方差做一次StandardScaler再切分K折这里就有一个隐患验证集的均值和方差已经混进了训练阶段的数据分布描述。具体来说当你在全量数据上fit那个scaler实际上验证集的数值分布信息已经“看过”了。验证折的样本被训练阶段的统计量缩放后分布会被拉向训练分布这会让验证分数虚高。虽然幅度不一定大但在类别不平衡和信号幅度差异大的场景里它足以影响你对模型好坏的判断。正确做法有两种第一种是按样本归一化上文已经给出第二种是非要全局标准化的话必须在每一折内部重新fitscalerscaler StandardScaler() X_train_scaled scaler.fit_transform(X_train.reshape(-1, X_train.shape[-1])).reshape(X_train.shape) X_val_scaled scaler.transform(X_val.reshape(-1, X_val.shape[-1])).reshape(X_val.shape)无论哪种原则都是验证数据严格处于“未被触碰”的状态。5.3 类别不平衡要不要加权、怎么加权类别不平衡是这个项目绕不开的问题。最轻量的处理方式是class_weight在损失函数里给少数类更高的权重。计算方式最常用的是n_samples / (n_classes * n_class_samples)from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( class_weightbalanced, classesnp.unique(y_integer_labels), yy_integer_labels ) class_weight_dict dict(enumerate(class_weights))compute_class_weight一行就够比手算可靠。加了类权重之后模型对少数类的召回率会明显改善但代价是正常类会有更多误报整体准确率可能微降。这是合理的——检测任务更看重的是别漏掉异常而不是把正常判断错几个。如果类权重还不够可以考虑过采样少数类或使用焦点损失focal loss。我个人的排序是先试类权重再看效果差多少不要一上来就引入复杂的损失函数。这个项目里类权重已经把宏平均F1从0.82左右提升到了0.87后续我没再折腾过采样。5.4 训练曲线怎么看到位不是有曲线看就够了关键是怎么从曲线里读出问题。训练时每一折都有history对象我会画出两张图一张是train/val的loss曲线一张是train/val的accuracy曲线。重点观察几个现象如果训练loss持续下降、验证loss在某个点之后掉头上升这就是过拟合的经典信号早停回调会在这个点附近停下。如果训练loss和验证loss都很高且下降缓慢多半是学习率太小或模型容量不够。如果验证loss早早就开始震荡不降但训练loss还在走可能是BatchNorm的batch size过小导致验证阶段的统计量不稳定或者学习率过高。如果训练loss下降但验证accuracy完全不动那很可能是指标计算有问题——比如验证集来自错误的分组切分数据分布差得太多。画图代码不复杂但建议把五折曲线叠在同一张图上能直观看到模型稳定性和不同折之间的收敛差异。某折明显收敛慢或过拟合更早说明那折的训练数据分布代表性不足值得单独检查。5.5 推理阶段不要忽略前后文信息模型训练和验证都是基于单段的2.5秒窗口但实际部署时你拿到的是连续的心电记录。如果直接逐段独立预测窗口边界处的心跳可能会被切断预测结果会来回跳变。我的经验是用滑动窗口加概率平均。具体来说对一段连续记录每隔0.5秒滑动一个窗口每个心跳会被多个窗口覆盖到最后对属于同一心跳的多个窗口预测概率取平均再做类别判断。这种“重叠表决”思路能显著降低单窗口噪声带来的误判在边界不稳定的类别上尤其有效。如果做实时监测还需要考虑延迟约束。2.5秒的窗口加0.5秒的步长意味着延迟至少2.5秒这在离线分析场景可以接受实时ICU监测就要重新设计。工程上和算法上的取舍不一样别拿一套方案硬适配。最后再分享一个我从这个项目里最有收获的小经验K折交叉训练这件事真正带来的不是“更高的分数”而是“对模型更清楚的认识”。五折分数的波动、混淆矩阵里错误集中的位置、早停发生的epoch这些都比最终那一个数字更有价值。用Keras搭一维卷积本身不难难的是把数据、验证协议和评估指标想清楚——这些环节想透了模型参数的调整就变成一件顺理成章的事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑