☰
Keras一维卷积神经网络实现心电信号心律失常分类与K折验证
2026/10/13 9:05:51 网站建设 项目流程

前段时间接了个活儿,要从一段段心电图波形里自动判断心跳是否异常。需求本身听起来不复杂:数据是带标签的时序信号,正常、早搏、房颤、其他异常,拿深度学习模型分个类就行。但真正动手之后我意识到,心电信号分类和图像分类的思维完全是两回事——它的每个样本不再是一张图,而是一维的时间序列;它的“特征”藏在波形的局部形态和时序节奏里;它的评估方式也远比“准确率高不高”更讲究。最终我采用的是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/200=25毫秒。这个时间尺度恰好能捕捉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, fs=200.0, lowcut=0.5, highcut=45.0, order=4): nyquist = 0.5 * fs low = lowcut / nyquist high = highcut / nyquist b, a = signal.butter(order, [low, high], btype='band') 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, keepdims=True) x = x / (x.std(axis=-1, keepdims=True) + 1e-8) return x

如果有的人先对全部数据求全局均值方差再归一化,然后切分训练集验证集,那确实是另一种做法,但要注意:全局统计量会让验证集的分布信息提前进入训练阶段,形成一种轻微的“数据泄露”。这个细节后面单开一节专门讲。

2.3 标签体系与类别合并

标签体系直接影响模型的学习目标和最终评估口径。我最初采用的是五分类:正常、房颤、室性早搏、房性早搏、其他异常。如果你只想做一个“是否异常”的二分类模型,那就把后四类合并成异常类。

多分类的优点是信息更细,但类别边界也更难学。房早和正常的界限本身就比较模糊,室早偶尔也会被标注成其他异常。我当时最终保留了五分类,因为评分指标用的是宏平均F1,这个指标对每个类别的表现都平等对待,即使某些类别样本少、准确率不高,也会在评估中暴露出来,不会因为类别不平衡而被掩盖。

如果只想做二分类,结构上只需要把最后一个全连接层的输出从5改成2,损失函数从categorical_crossentropy对应的独热编码,换成sparse_categorical_crossentropy配整数标签也行。我推荐用独热编码加categorical_crossentropy,语义更明确,不容易写错。

3. 模型设计与K折训练:用Keras搭一个能解释的Conv1D

3.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_len=500, num_classes=5): model = keras.Sequential([ layers.Input(shape=(input_len, 1)), layers.Conv1D(filters=64, kernel_size=5, padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling1D(pool_size=2), layers.Conv1D(filters=128, kernel_size=5, padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling1D(pool_size=2), layers.Conv1D(filters=256, kernel_size=3, padding='same', activation='relu'), layers.BatchNormalization(), layers.GlobalAveragePooling1D(), layers.Dense(128, activation='relu'), layers.Dropout(0.5), layers.Dense(num_classes, activation='softmax') ]) 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_splits=5, shuffle=True, random_state=42) 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_len=X.shape[1], num_classes=y.shape[1]) model.compile( optimizer=keras.optimizers.Adam(learning_rate=1e-3), loss='categorical_crossentropy', metrics=['accuracy'] ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=50, batch_size=64, callbacks=[early_stopping, reduce_lr, checkpoint], class_weight=class_weight_dict, verbose=1 ) score = model.evaluate(X_val, y_val, verbose=0) fold_scores.append(score)

这里有几个点需要展开。StratifiedKFold的random_state固定住之后,K折切分每次都是一样的,结果可复现。shuffle=True确保切分前先随机打乱,防止原始数据里类别按顺序排列导致某折类别比例异常。对类别不平衡数据,千万不要用普通KFold,原因下一节专门说。

3.3 StratifiedKFold和KFold的差别,以及为什么必须分层

KFold只是把数据按顺序均分成K份,不管每份里的类别比例。如果原始数据按类别一行一行排好,不shuffle直接切,可能第一折全是正常样本,第二折全是房颤样本,验证分数会剧烈震荡,完全没有参考价值。即使shuffle了,类别不平衡时纯随机切也可能出现某折没有某类样本的情况,那这一折的类别召回率就无从谈起了。

StratifiedKFold是在切分时保证每一折中各类别的比例与全数据集尽量一致。比如全数据集中正常样本占60%,那每一折里正常样本也大约占60%。这样一来,五个折的评估基准是公平可比的,每个类别在每折里都会有足够样本去计算召回率,宏平均F1的方差也会显著降低。

心电数据还有一个更严格的分层维度:患者ID。如果数据集里多个样本来自同一个人,而切分时随机把这些样本散到训练集和验证集,那模型可能在“记住人”而不是“学会异常模式”,验证分数会虚高。这种情况更稳妥的做法是GroupKFold,保证同一个人的所有样本只出现在同一边。不过这个项目当时用的是公开数据集的片段级样本,ID信息不完整,所以没有强行按人分组。如果你的数据能拿到subject ID,我非常建议直接上GroupKFold。这件事是医疗时序分类里最容易被忽略的评估陷阱。

3.4 回调函数:早停、学习率衰减、Checkpoint

K折训练中每一折都需要一个独立的回调配置。三个回调缺一不可:

EarlyStopping监控验证集损失,当连续多个epoch没有改善时自动停止训练;ReduceLROnPlateau在指标停滞时把学习率降一个量级,帮助模型更精细地收敛;ModelCheckpoint只保存验证集最优的那一份权重。标准化代码如下:

from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint early_stopping = EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6 ) checkpoint = ModelCheckpoint( filepath=f'best_fold_{fold}.keras', monitor='val_accuracy', save_best_only=True )

restore_best_weights=True这个参数尤其关键。如果只设patience不恢复最优权重,模型返回的是最后一次epoch的权重,而不是验证集最优的那一步。很多人的实验结果看似不稳定,其实问题就出在这里。另外注意,K折训练中的回调对象要在每折内重新创建,不能复用一个已经绑定了旧日志状态的回调实例,否则会出现进度混乱。

学习率这里,我初始用1e-3的Adam。其实针对小型数据集,1e-3搭配BatchNorm完全跑得动,不需要从1e-4起步。训练损失会在前几个epoch快速下降,后面进入慢速精调阶段,这时候ReduceLROnPlateau会自动接力,省去很多手动调学习率的烦恼。

4. 评估阶段最容易被误读的指标:准确率高了不代表真做对了

4.1 在心律失常数据里准确率为什么会骗人

这个项目前期让我最纠结的不是模型训练,而是评估。单纯看准确率的话,模型很快就能冲到90%以上,因为正常样本占了60%,模型只要把绝大多数正常样本判断对,准确率就已经很高了。但这完全不代表异常检测能力合格。

举个例子,如果模型把所有样本都判为正常,准确率是60%;如果模型把所有样本都判为正常,但小心地让正常类召回率做到95%,准确率就能到90%以上。可这时真正需要被发现的异常样本可能一例都没抓到——这在医疗场景里是完全不可接受的。所以评估指标必须把每个类别分开看,特别是少数类。

我最终在模型训练日志里同时跟踪accuracy和AUC,但真正的核心指标选的是宏平均F1(Macro F1)。宏平均F1是每个类别的F1值加总后除以类别数,它平等对待每个类别,少数类的表现会被明确暴露。模型整体F1很高而宏平均F1偏低,说明模型是靠正常类撑起来的,异常类其实一塌糊涂。

4.2 混淆矩阵怎么读:揪出容易被漏掉的阳性类别

K折训练结束后,我把五折验证预测结果全部收集起来,合并成一份完整的混淆矩阵。看混淆矩阵有两个核心目的:找到错误最集中的类别对,以及判断错误是“漏检”还是“误报”。

我当时那份混淆矩阵大致长这样(数值经过脱敏,只用于说明读法):

实际\预测正常房颤室早房早其他异常
正常7650130908050
房颤2201680403030
室早1805012003040
房早350608042090
其他异常160407050580

一眼就能看到问题:房早这个类别大量被预测成正常,正常样本也有不少被误判成房早和室早。这说明模型能抓住室早和房颤这种形态非常明显的异常,但对房早这种形态差异小的类别,学到的判别特征还不够。房早在诊断里本身就有争议性,但如果你是做“减少漏诊”的模型,看到这个残差结构,就该知道下一步要增加房早的训练样本,或者针对这类做专门的数据增强,而不是盲目加深网络。

绘制混淆矩阵用sklearn.metrics.confusion_matrix和ConfusionMatrixDisplay,把五折的验证样本收集到同一份数组里再画,一张图就能看出全部问题,比一折一折画要高效得多。

4.3 把K折结果聚合起来看方差

K折交叉训练还有一个额外的好处:能估算模型在不同数据切分下的稳定性。每折的验证指标单独看,差异可能很大,所以一定要算均值和标准差。我当时5折的结果大致是这个分布:

Fold准确率宏平均F1
10.9430.874
20.9380.862
30.9510.883
40.9290.843
50.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, average='macro') print(f"Overall 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,实际上验证集的数值分布信息已经“看过”了。验证折的样本被训练阶段的统计量缩放后,分布会被拉向训练分布,这会让验证分数虚高。虽然幅度不一定大,但在类别不平衡和信号幅度差异大的场景里,它足以影响你对模型好坏的判断。

正确做法有两种:第一种是按样本归一化,上文已经给出;第二种是非要全局标准化的话,必须在每一折内部重新fitscaler:

scaler = 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_weight='balanced', classes=np.unique(y_integer_labels), y=y_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搭一维卷积本身不难,难的是把数据、验证协议和评估指标想清楚——这些环节想透了,模型参数的调整就变成一件顺理成章的事。

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

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

立即咨询