☰
一维CNN与K折交叉验证:ECG心跳异常检测实战详解
2026/10/13 1:11:28 网站建设 项目流程

做医疗信号方向的项目,绕不开心电(ECG)数据。最近我完成了一个“心跳异常检测”的Demo:用Keras搭建一维卷积神经网络(CNN),配合K折交叉验证来训练分类模型,整个流程跑下来效果非常稳定。这个任务说起来不复杂——把心电信号切成一帧帧波形,判断每一帧是正常心跳还是异常形态,但真正落地时,数据组织、模型结构、验证策略每一步都有讲究。这篇博文就把我从数据预处理到K折训练完完整整拆开讲,适合正在学习深度学习处理时序信号的开发者、生物医学工程方向的学生,以及想快速搭一个ECG二分类Baseline的从业者。我会把踩过的坑、调参的思路和代码细节都写出来,你照着跑一遍就能复现。

1. 心跳异常检测到底要做什么

1.1 任务边界与数据形态

心跳异常检测,本质上是一个时间序列分类问题。输入是一段连续的心电信号,输出是“正常”或“异常”的标签。但和图像分类不同,ECG信号是一维波形,时序上存在前后依赖,而且单个心跳的形态决定了他的类别。比如早搏、房颤、传导阻滞等异常,都会在P波、QRS波群、T波的形态和间距上表现出来。

所以第一步不是急着建模型,而是先把问题抽象成监督学习样本。公开心电数据集的常见做法是:以R峰位置为中心,截取固定长度的心跳片段,每个片段作为一个样本,配一个类别标签。这样就把“一段连续信号”变成了“一堆独立心跳窗口”。窗口长度通常覆盖一个完整心动周期,比如取两个R峰之间的平均周期,或者直接固定为128、256个采样点。我习惯把每条记录先做R峰检测,再按心跳中心点左右各取一部分,生成等长的样本窗口。这样做的好处是:模型学习的是单个心跳的形态特征,而不是一整段信号的节奏特征,分类任务更干净。

数据形态上要注意两个细节。一是不同数据集的采样率不一样,有250Hz、360Hz、500Hz等,如果不统一重采样,模型在同一网络下接收的输入长度语义就不同。我会统一重采样到256Hz,这样256个采样点恰好约等于1秒,便于对齐。二是信号幅值范围差异大,有的mV级别,有的经过放大后差异很大,必须归一化。我采用每个样本独立做z-score归一化,减去均值除以标准差,这样能消除个体差异和设备增益的影响。

1.2 为什么选择一维卷积而不是其他模型

很多人习惯一听说时间序列就想到LSTM或Transformer,但心跳异常检测这类“局部形态识别”任务,一维CNN完全不虚。原因在于,心跳异常的判别信息主要集中在波形的局部形态上——比如ST段抬高、T波倒置、QRS波增宽,这些特征都是局部小范围内的形状差异。卷积核天然擅长捕捉局部模式,通过堆叠多层,可以从小范围内的波形成分逐步组合成更高级的语义特征。

相比之下,LSTM更擅长捕捉长距离时序依赖,但心跳单窗口只有256个点,局部模式信息占比高,LSTM的训练效率和稳定性反而不如一维CNN。Transformer呢?需要大量数据和更精细的调参,在这种几百兆级别的小样本任务里容易过拟合。我实际对比过同一份数据上CNN和LSTM的效果,CNN收敛更快,验证集准确率也高2到3个点。而且一维CNN的参数量小,训练和推理速度都快,后续要部署到移动端或嵌入式设备也更有优势。

还有一个更现实的原因:一维CNN在Keras里实现非常直接,几行代码就能把网络定义出来,配合K折交叉验证,可以快速验证模型在不同数据划分下的稳定性。对于项目初始阶段的Baseline来说,这是最稳的起点。

2. 数据预处理:从原始波形到训练样本

2.1 信号切片与窗口长度选择

先说切片。假设你拿到的数据是长时程的心电记录,第一步肯定是R峰检测。R峰是QRS波群中最尖锐的峰值,通常信号幅值最大的点。常用的检测方法有很多,比如基于带通滤波加阈值峰检测,或者用专门的QRS检测算法。我用的是简单且稳定的方法:先做带通滤波(5-20Hz),然后计算滑动窗口能量,设定阈值找波形最大值区域,再精确定位局部峰值。这一步不要求完美,个别错检可以通过后续标签清洗去掉。

窗口长度怎么定?我试过64、128、256、512个采样点,最终落在256上效果最好。原因很简单:256Hz采样率下,256点正好是1秒,正常心跳在60到100次每分钟,也就是一个心跳周期0.6到1秒,所以一个窗口能完整覆盖一个心动周期。如果窗口太长,比如512点,会包含太多相邻心跳的干扰信息,模型反而难以聚焦在当前心跳形态上。窗口太短,比如64点,又会切掉P波或T波的一部分,关键信息丢失。

切片时还需要注意对齐。如果以R峰为中心,左右各取128点,那么R峰正好在窗口中央。这样模型可以学习到“以R峰为基准,前后波形分布是否正常”的相对位置特征。如果随意把窗口起点落在某个位置,同样的心跳形态可能因为相位偏移而产生不同的输入,模型泛化会变差。我在代码里是这样处理的:找到所有R峰位置后,排除太靠近信号开头和结尾的点,然后以每个R峰为中心截取左右等长信号,生成样本。

2.2 归一化、去噪和类别平衡

归一化是必须做的。我这里说的不是简单的min-max缩放,而是每个样本独立z-score归一化:

def z_score_normalize(signal): mean = np.mean(signal) std = np.std(signal) if std < 1e-6: std = 1e-6 return (signal - mean) / std

为什么对每个样本单独归一化,而不是对全数据集归一化?因为不同患者的心电幅值差异很大,如果使用全局统计量,幅值小的患者信号会被压缩,幅值大的患者会主导梯度。每个样本独立缩放后,所有样本的幅值分布都被拉到了同一尺度,模型学习的不是“振幅大小”,而是“波形形态”。对分类任务来说,这才是我们关心的。

去噪方面要认真做。ECG信号里有工频干扰、肌电干扰、基线漂移。基线漂移是低频成分,可以用高通滤波去掉;工频干扰是50Hz(国内)或60Hz(某些国家)附近,用陷波滤波。我一般用中值滤波估计基线,然后原信号减去基线,再用巴特沃斯带通滤波器保留0.5-40Hz的心电有效频段。注意不要过度滤波,否则QRS波群的高频细节会损失,影响分类精度。

类别平衡是医疗数据里最头疼的问题。公开数据集中正常样本往往远多于异常样本,直接训练会让模型偏向多数类。处理手段有两种:一是数据层面,对少数类做过采样或数据增强;二是训练层面,设置类别权重或使用focal loss。我两者都用:先对少数类样本做简单的“时间扭曲”增强——对窗口内的信号做轻微伸缩和加噪声,再在Keras训练时设置class_weight参数,让少数类样本的loss贡献更大。注意增强要在归一化之后做,但要在训练集内部做,不能把增强后的样本混进验证集,否则会造成数据泄漏。

2.3 数据划分:K折交叉验证里的“坑”

这里要特别强调一个点:心跳样本不是独立的。同一患者的多条心跳片段高度相关,如果随意打乱划分,训练集和验证集可能包含同一个患者的不同心跳,模型相当于“见过”了同一患者的其他片段,验证集准确率会虚高。这就是医学信号任务中最常见的数据泄漏。

所以做K折交叉验证时,应该按“患者”或者“记录ID”分组划分,而不是按样本划分。比如有100个患者的记录,K=5时,每折约80个患者的样本训练,20个患者的样本验证。我一开始没注意这个问题,直接对样本做乱序切分,五折准确率高到99%,换到按患者划分后掉到92%。差距就是数据泄漏带来的。

正确的做法是用GroupKFold或StratifiedGroupKFold。Keras本身没有直接提供,但可以用scikit-learn中的类来获取索引。如果是二分类,我还希望每一折中正常和异常的比例尽量接近全局分布,所以要用StratifiedGroupKFold。它既照顾了标签分布,又保证了组(患者)不交叉。这个细节你在很多教程里是看不到的,但实际项目里是决定成败的一环。

3. 用Keras搭建一维CNN分类模型

3.1 模型架构逐层拆解

我的模型结构很经典,不算复杂,但每一层都有明确目的。整体结构是:Conv1D + ReLU + BatchNormalization + MaxPooling1D,重复三组,然后接GlobalAveragePooling1D或Flatten,最后Dense + Softmax输出二分类概率。

第一层卷积的核大小我设为64,看到这个你可能觉得奇怪,图像任务里核大小通常是3x3,一维卷积核一般也用3、5、7。但ECG信号的形态特征在时间尺度上跨得比较宽,QRS波群本身就有几十毫秒,在256Hz下大约对应20到40个采样点。如果第一层卷积核太小,感受野不够,模型只能看到波形上的局部毛刺。kernel_size=64意味着第一层卷积能覆盖约0.25秒的波形,刚好能捕获整个QRS波群。后续层再用较小核(比如16、8)聚合更高层特征。

激活函数用ReLU,卷积层后面紧跟BatchNormalization。BN在这里有两个作用:一是加速收敛,二是稳定中间层输出分布,避免梯度爆炸或消失。对于一维卷积,BN是在通道维度上做的,Keras里直接layers.Conv1D(64, kernel_size=64, activation='relu')之后接layers.BatchNormalization()就行。注意顺序:Conv1D -> BN -> ReLU 或者 Conv1D -> ReLU -> BN 都有人用,但BN放在卷积后激活前会更多地利用BN的分布修正能力。我这边按“Conv -> BatchNormalization -> Activation”排列。

池化层用MaxPooling1D,pool_size=4,stride=2。池化的作用是降采样,减少序列长度并增大后续层的感受野。到了最后一层卷积组之后,我纠结过用Flatten还是GlobalAveragePooling。对于固定长度输入,两者都可以,但GlobalAveragePooling会显著减少参数量,还有一定的正则化效果,不容易过拟合。我这里用的是GlobalAveragePooling1D,输出一个一维向量,然后接一个Dropout(0.5),再接Dense(1, activation='sigmoid')。

3.2 关键超参数选型逻辑

第一组卷积核数量设为32,第二组64,第三组128。这个递增规律和ResNet类似:越深层的特征越抽象,需要更多的通道来容纳不同的模式组合。总参数量大约在几十万量级,比全连接网络小得多,训练起来很轻松。你也可以把每层通道数减半或加倍,但我测试下来,这个容量在5万到10万样本的规模下已经够用。

Dropout放在两个位置:一个是GlobalAveragePooling后,用0.5;另一个是每个卷积组内可以用较小的0.2,但我后来发现卷积层自带BN后,卷积间Dropout收益不大,所以最后只在分类头保留了Dropout。Batch Size选64,学习率初始0.001。如果数据量只有几千,Batch Size可以降到32。优化器用Adam,因为它对学习率的敏感性较低,加上ReduceLROnPlateau回调,在验证loss连续3轮不降时把学习率乘以0.5,能有效避免提前陷入局部极小点。

损失函数我用binary_crossentropy,因为这是二分类问题,输出层用sigmoid。如果你的标签是one-hot编码且用softmax输出,也可以选categorical_crossentropy,但多分类和二分类在评估指标上有细微差别,这里按最简单的二分类写。

3.3 编译与训练配置说明

完整的模型定义和编译代码我直接贴出来,你拿过去改改数据路径就能用:

import tensorflow as tf from tensorflow.keras import layers, models def build_cnn_model(input_length): model = models.Sequential(name='ecg_cnn_1d') model.add(layers.Input(shape=(input_length, 1))) model.add(layers.Conv1D(32, kernel_size=64, strides=1, padding='same')) model.add(layers.BatchNormalization()) model.add(layers.ReLU()) model.add(layers.MaxPooling1D(pool_size=4, strides=2)) model.add(layers.Conv1D(64, kernel_size=16, padding='same')) model.add(layers.BatchNormalization()) model.add(layers.ReLU()) model.add(layers.MaxPooling1D(pool_size=4, strides=2)) model.add(layers.Conv1D(128, kernel_size=8, padding='same')) model.add(layers.BatchNormalization()) model.add(layers.ReLU()) model.add(layers.GlobalAveragePooling1D()) model.add(layers.Dropout(0.5)) model.add(layers.Dense(1, activation='sigmoid')) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='binary_crossentropy', metrics=['accuracy', tf.keras.metrics.AUC(name='auc')] ) return model

这里input_length是窗口采样点数,也就是256。注意输入数据的维度是(batch, 256, 1),最后一个通道维是1,表示单导联。如果你的数据是多导联,输入维度变成(batch, 256, leads),第一层卷积也要相应改为第一维卷积但输入通道数等于导联数,代码里用layers.Conv1D(32, kernel_size=64, input_shape=(256, leads))即可。

训练时我会配EarlyStopping:monitor='val_loss',patience=15,restore_best_weights=True。这样如果15轮验证loss没有改善,就回滚到最佳权重,避免过拟合。同时用ModelCheckpoint保存每折最优模型。注意,早停的指标不要用准确率,要用验证loss,因为loss对预测概率更加敏感,能捕捉模型自信程度的变化。

4. K折交叉训练的实现细节

4.1 手动实现K折循环的正确姿势

K折交叉验证的基本流程是:把数据集分成K份,轮流取其中1份做验证,剩下的K-1份合并做训练,最终得到K个独立训练的模型。我这里取K=5,因为5到10是交叉验证的常用选择。K太大会增加训练时间,且每折训练集只减少一点,指标差异小;K太小则验证集样本少,统计波动大。

实现时我不用手写for循环去遍历索引,而是用scikit-learn的StratifiedGroupKFold。示例代码如下:

from sklearn.model_selection import StratifiedGroupKFold import numpy as np # X shape: (n_samples, 256, 1) # y shape: (n_samples,) # groups shape: (n_samples,) # 每个样本对应的患者ID或记录ID kfold = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=42) fold_metrics = [] for fold, (train_idx, val_idx) in enumerate(kfold.split(X, y, groups)): print(f'Fold {fold + 1}: train samples={len(train_idx)}, val samples={len(val_idx)}') # 数据增强只应用在train_idx上 X_train, y_train = augment(X[train_idx], y[train_idx]) train_dataset = tf.data.Dataset.from_tensor_slices((X_train, y_train)) val_dataset = tf.data.Dataset.from_tensor_slices((X[val_idx], y[val_idx])) train_dataset = train_dataset.shuffle(1024).batch(64).prefetch(tf.data.AUTOTUNE) val_dataset = val_dataset.batch(64).prefetch(tf.data.AUTOTUNE) model = build_cnn_model(input_length=256) callbacks = [ tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5) ] history = model.fit( train_dataset, validation_data=val_dataset, epochs=100, callbacks=callbacks, verbose=1 ) val_loss, val_acc, val_auc = model.evaluate(val_dataset) fold_metrics.append({'loss': val_loss, 'acc': val_acc, 'auc': val_auc})

这段代码里有几个地方要特别注意。StratifiedGroupKFold要求groups数组的长度和样本数一致,且在每个fold内,同一个group的样本不会被同时分割到训练集和验证集。如果没有这个库,可以安装scikit-learn,一般数据科学环境都会带。如果你不做分层,就用GroupKFold,但分层能保持每折的类别比例一致,有利于指标稳定。

4.2 每折模型的评估指标与聚合

K折训练完,会得到5个模型,每个模型都有自己的验证集指标。最后怎么汇报结果?取5折的平均值和标准差。比如准确率是0.903 ± 0.012,AUC是0.961 ± 0.008。平均值反映模型的整体水平,标准差反映模型对数据划分的敏感程度。如果标准差很大,说明模型不稳定,很可能是因为某个患者的形态特别特殊,被分到某一折后模型没见过类似数据。

另外,不要只关注准确率。在心跳异常检测里,阳性(异常)样本通常更少,准确率会被多数类拉高,必须同时看AUC、召回率、特异度。我习惯把每个fold的AUC和混淆矩阵都打印出来,再做一个置信区间。如果你的项目更关注“不漏检”,就重点看召回率;如果更关注“减少误报”,就重点看精确率。二分类的阈值也不一定要固定在0.5,可以画ROC曲线后用约登指数选最优点。我实验下来,阈值设为0.4左右时,F1分数往往比0.5阈值高一点。

5个模型在推理时怎么用?可以投票集成,也可以用概率平均。K折交叉验证的好处就是你可以免费获得5个模型,做一个简单集成。输出层是sigmoid概率,集成时直接对5个模型输出的概率取平均,再和阈值比较。这样能进一步降低方差。

4.3 交叉验证的数据泄漏防范

数据泄漏是交叉验证里最隐蔽、最致命的错误。除了之前说的按患者分组,还有几个细节。第一,归一化的统计量必须只在训练集上计算。比如你想用全局均值和标准差做归一化,那也应该先切分好再计算训练集的统计量,然后把同样的均值标准差应用到验证集。我的做法是每个样本独立z-score,天然规避了这个问题。但如果你改成用训练集统计量,一定要记得验证集不能用全局统计量,否则验证指标会虚高。

第二,数据增强不能跨越训练验证切分。比如你可能为了扩充样本,对同一条原始记录的不同片段做了滑动窗口截取,产生大量重叠样本。如果这些重叠样本同时出现在训练集和验证集,验证集就相当于见过训练数据的一部分。我在实际项目里就吃过这个亏:用重叠滑动窗口切样本时没检查重叠关系,结果五折准确率0.97,换成不重叠切片后掉到0.90。所以,在按患者分组的基础上,还要尽量使用不重叠的样本,或者在切样本时标注来源,保证同一个来源窗口不会同时落入两个折。

第三,标准化原始特征、填充缺失值等预处理步骤都要在训练折内部拟合。交叉验证的每一个fold都应该是一个独立的“训练加验证”闭环,任何从全数据集学到的信息都会造成泄漏。这一点是交叉验证正确的核心。

5. 训练过程复盘与调参经验

5.1 过拟合与欠拟合的信号判断

在训练过程中,我会重点观察训练集和验证集的loss曲线。如果训练集loss持续下降而验证集loss先降后升,就是典型的过拟合。这时候不要调模型结构,先看两点:一是数据量是否足够,二是正则化是否到位。数据量方面,如果样本少于1万,考虑数据增强或预训练。正则化方面,增加Dropout、添加L2权重衰减、减少模型通道数都有用。

我遇到过一有趣的情况:前两组卷积都加了Dropout(0.5),结果模型欠拟合,验证loss和准确率长期上不去。后来我意识到,Dropout在卷积层中只对通道进行随机屏蔽,对时间序列的局部连续性破坏严重。一维卷积的中间特征图中,相邻时间步之间本身有强相关性,Dropout会造成相邻时间步信息断裂,反而削弱表达能力。把卷积层内的Dropout去掉后,欠拟合问题立刻缓解。所以通用建议是:卷积层后尽量用BN做正则化,Dropout只放在全连接层前后。

还有一个参数是Batch Size。ECG数据里有大量的形态相似样本,如果Batch Size太大,一个batch里可能全是正常样本,模型学到的是“倾向于预测正常”的更新方向,不稳定。我试过Batch Size 128和64,64的验证AUC更高。但也不能太小,太小则每个batch的统计噪声大,BN效果不稳定。64是一个比较折中的值。

5.2 类别不平衡的损失函数和类权重设置

对于心跳异常检测,异常样本占比通常只有20%甚至更低。使用默认的binary_crossentropy会严重偏向多数类。我在Keras里设置class_weight来处理:

class_weight = {0: 1.0, 1: 2.5}

计算方式可以先统计类别频率,然后设为目标频率比。比如正常样本8000,异常样本2000,那么权重可以设为:正常权重1.0,异常权重4.0。也可以不那么极端,用2.5、3.0这样,避免少数类过度主导。这里有一个技巧:先用1:2的权重跑一轮,看验证召回率,如果召回率低就继续加大异常类权重。我最终的权重是1:3,准确率和召回率平衡最好。

如果你不想用class_weight,可以考虑用损失函数替换。比如tfa.losses.SigmoidFocalCrossEntropy(TensorFlow Addons),它对易分类样本进行降权,让模型重点关注难分样本。这个在医疗数据里效果也不错,但它需要额外安装库。我建议先试class_weight,因为它最简单,不引入额外依赖。

5.3 模型保存、加载与推理部署

每个fold训练完,我用model.save(f'model_fold_{fold}.h5')保存。Keras目前的推荐格式是.keras,不过h5依然兼容。保存模型时最好连训练图一起保存,也就是compile之后保存,这样加载后可以直接evaluate。如果只保存权重(save_weights),加载时需要重新定义模型结构并load_weights,容易出错。

推理时的输入也需要做和训练时相同的预处理:重采样、滤波、窗口切片、z-score归一化。其中滤波器的参数和归一化的均值和标准差如果是在训练阶段从训练集计算得到的,要固定成常量存下来,推理时不能用新样本的统计量重新计算,否则推理和训练的数据分布不一致。

对于部署,一维CNN模型非常轻量,参数只有几十万,推理一个窗口在CPU上只要几毫秒。你可以用TensorFlow Lite转换成浮点模型,再转成量化模型,进一步减小体积。实际部署中我把模型转成TFLite后,体积从几百KB降到80KB左右,在移动端实时检测完全没问题。如果你要做实时心电监测,注意输入的窗口建议采用滑窗处理,每滑动一个R峰,给模型喂一个以该R峰为中心的心跳窗口,避免每0.1秒重复推理整个窗口。

6. 常见问题与排查实录

6.1 训练loss不下降

最常遇到的问题是训练loss始终不下降,准确率停在50%左右。这种一般不是模型结构问题,而是数据预处理错误。我排查过的案例有:输入数据没有归一化导致梯度爆炸;标签和特征没对齐导致模型学到随机关系;窗口切片时把大量静息段(非心跳段)当成了心跳,模型看到的高斯噪声和真实心跳差异太大。

解决办法:第一步,先随机抽几个训练样本,用matplotlib把输入波形和标签打印出来,肉眼确认样本形态是否和标签匹配。看到波形是一堆乱码或明显不是单心拍的,多半是切片或对齐问题。第二步,将输入数据的数值范围打印出来,看看有没有nan或极端值。第三步,用一个很小的子集(比如100个样本)过训练,如果能过拟合到100%,说明模型和数据管线没问题,再考虑是数据集复杂还是强正则化导致。

6.2 K折结果差异大

之前我用未分组切分时,五折准确率标准差在0.05以上,换成按患者分组后标准差降到0.01左右。如果你按分组切分后仍然差异很大,说明某些患者的心电形态非常特殊,模型没有泛化到这个群体。这时候可以考虑增加这一部分患者的数据量、使用更稳健的数据增强,或者对输入信号做更多领域的特征工程(比如计算RR间期、波幅比值等额外输入特征,和CNN的特征拼接)。

还有一个方法是做多个K折实验,用不同的random_state。比如跑了5次五折,每次都是按同样的GroupKFold但shuffle不同,观察平均指标是否稳定。如果平均指标波动大,说明模型本身方差高,需要减小学习率或增加正则化。

6.3 推理速度优化建议

如果是在线实时检测,推理延迟很关键。一维CNN推理已经很快,但还可以优化:第一,减少输入窗口长度,从256降到200,对精度影响不大但推理快约20%。第二,使用TFLite转换并开启GPU委派。第三,如果场景允许,可以只保留前两组卷积层,去掉第三组,精度会下降但延迟更低,适合端侧设备。要注意的是,对模型做剪枝或量化后,必须重新用K折验证集评估指标,不能只看训练集结果。

另外,如果推理时输入批量大小固定为1,建议在转换时设置--input_shapes=1,256,1,有些框架会自动做模型优化。

在医疗AI项目里,“稳定”比“惊艳”更重要。K折交叉验证就是一个帮你看清楚模型真实泛化能力的好工具,配合一维CNN的局部特征提取优势,心跳异常检测这个任务可以做到又快又稳。我自己做完这个项目最大的体会是:模型结构不是最关键的,数据分组有没有做对,指标有没有看全,才真正决定项目能不能落地。你遇到一个准确率非常高的模型时,先反问一句——训练集和验证集里有没有同一个患者的心跳?如果答案是“有”,那这个准确率就要打一个问号了。最后把这篇文章里说的步骤跑通之后,你可以继续尝试把异常类型细分多分类,或者加上注意力机制,一维CNN的框架完全兼容这些扩展。

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

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

立即咨询