时间序列分类实战:心跳信号识别中的类别不平衡与模型融合
这段时间一直在刷开源社区里那个经典的心跳信号分类任务数据准备、特征处理、模型调参、融合提分整套流程走下来踩了不少坑也沉淀了一些自己的方法。这篇就当成一次完整的个人复盘把我从读题到出分的全流程、每个关键环节的取舍逻辑、还有实际跑出来的数据表现都摊开来讲希望对正在做序列分类、医疗AI方向或者准备打类似结构化时间序列比赛的朋友有点帮助。这个任务表面上是“给一段心拍分类”本质上是多类别不平衡时间序列分类。输入是205个时间点的心跳信号每个样本对应一个标签总共五类N类正常、S类室上性早搏、V类室性早搏、F类融合心跳、Q类未知类。评价指标不是简单的准确率而是加权F1分数权重取决于各类别样本占比。这就意味着模型对少数类的召回能力直接决定最终排名的天花板。1. 项目拆解五分类到底在分什么1.1 任务定义与数据形态先看数据。每个样本是一维时间序列长度固定为205采样点已经做过对齐处理但不同来源的样本之间存在幅度差异、基线漂移和噪声干扰。训练集单文件大概2万条样本量级测试集接近1万条标签分布差异很大。具体类别含义类别含义常见样本占比N正常窦性心跳Normal最多约占50%-60%S室上性早搏Supraventricular premature beat中等约10%-25%V室性早搏Ventricular premature beat中等约15%-20%F融合心跳Fusion beat很少通常低于5%Q未知/无法分类心跳Unknown很少通常低于5%这就是典型的长尾分布。如果你直接拿原始分布训练模型会学到“无脑预测N类”这种偷懒行为整体准确率可以做到85%以上但F类和Q类基本全错加权F1会非常难看。所以从一开始就要把“类别不均衡”当成核心矛盾来处理而不是事后补救。1.2 评价指标背后的玄机加权F1的计算方式F1 2 * precision * recall / (precision recall)加权F1 sum(weight_i * F1_i)其中 weight_i 是第 i 类样本数在全部样本中的占比F1_i 是第 i 类的F1分数。也就是说占比越大的类别对最终分数的“话语权”越大。N类如果做到0.95以上的F1对总分贡献很大但这不代表你可以放弃少数类——因为少数类里面V类和S类的占比也不低它们的recall每掉5个百分点总分可能掉0.01到0.02而最终排名分差往往就在千分之几。这个指标的另一个含义是盲目追求准确率没有意义。我在第一版模型里用原始分布训练准确率0.87加权F1只有0.81后来做了类别权重处理准确率降到0.82但加权F1反而涨到0.86。原因就在于模型不再“偏心”对少数类的预测召回率明显上升。2. 数据预处理90%的增益藏在细节里2.1 先清洗再归一化拿到原始信号后我建议的顺序是先去噪再归一化最后做特征。去噪这一步很多人直接跳过但对时间序列分类来说很关键。我用的方式是小波变换软阈值去噪选db4小波基分解层数5层阈值系数取通用阈值。核心代码逻辑如下import pywt import numpy as np def wavelet_denoise(signal, waveletdb4, level5): coeffs pywt.wavedec(signal, wavelet, levellevel) # 计算通用阈值 sigma * sqrt(2 * log(n)) sigma np.median(np.abs(coeffs[-1])) / 0.6745 threshold sigma * np.sqrt(2 * np.log(len(signal))) # 软阈值 new_coeffs [coeffs[0]] # 近似系数不动 for c in coeffs[1:]: new_coeffs.append(pywt.threshold(c, threshold, modesoft)) return pywt.waverec(new_coeffs, wavelet, levellevel)为什么用软阈值而不是硬阈值硬阈值会把小于阈值的小波系数直接置零容易在信号重构后产生突变点软阈值是向零收缩对心电信号这种缓慢变化的生物信号更友好重构出来的波形更平滑。归一化我用的不是全局min-max而是逐样本z-score也就是对每个样本独立计算均值和标准差做标准化。原因是不同样本的采集设备、导联位置、患者个体差异都会导致幅度不同全局归一化会让幅度较大的样本主导模型训练逐样本标准化反而能拉齐形态差异把分类重点放到波形形状上。这个操作对CNN和Transformer类模型的收益非常明显大概能带来0.01-0.02的F1提升。2.2 低方差样本清洗跑第一版模型的时候我发现验证集上有几个样本总被分错抽出来看波形画出来几乎是一条直线局部有极其微弱的波动。这种信号大概率是采集时电极脱落或设备故障导致的无效数据。办法很简单计算每个样本的方差设定阈值过滤。var_threshold 0.05 # 经验值根据训练集分布微调 valid_idx X_train.var(axis1) var_threshold我在实践中发现这个阈值不能一刀切因为Q类未知类本身就可能包含部分异常波形方差偏低但仍有分类价值。建议先用统计分布图看方差整体分布再选一个只过滤“极端低方差”的阈值比如方差低于所有样本5%分位数的那一批。2.3 波峰对齐的细节原本数据的205个点已经做过对齐但我实际画图对比后发现同一类别内部波峰位置仍然有2到3个点的偏移。这种微小偏移对手工特征影响不大但对CNN的卷积核非常敏感——卷积核局部感受野只有几个点波形错位两个点可能造成特征提取位置错乱。解决办法是做重心对齐或最大峰对齐。我用的是以R波峰值点为基准将峰值点平移到序列中点def align_peak(signal, target_idx100): max_idx np.argmax(signal) shift target_idx - max_idx if shift 0: return np.pad(signal, (shift, 0))[:len(signal)] else: return np.pad(signal, (0, -shift))[-len(signal):]这个操作在训练集和测试集上都要做而且要保证对齐方式完全一致。我在实际操作中遇到过一个问题——少数样本的R波比较平坦argmax会落在多个极大值里的任意一个导致对齐结果不稳定。解决办法是先用小窗口平滑再找峰值或者利用信号包络找R波的最高峰位置这样更鲁棒。3. 特征工程双轨并行是主流方案3.1 手工特征集的构建思路针对这张表格型特征统计特征一定不能少。我提取了以下几组基础统计均值、标准差、最大值、最小值、峰峰值、偏度、峰度、中位数、四分位距形态特征波峰数量、波谷数量、平均斜率、上升段斜率、下降段斜率、重心位置、能量集中度频域特征FFT后的前10个频段能量占比、主频、频谱质心、频谱带宽小波特征小波分解5层后每一层近似系数和细节系数的能量占比这些特征加起来大概200到300维对LightGBM和XGBoost来说完全够用。特征不需要做得太多太杂关键在于每一类特征是否从不同角度刻画了信号的本质。比如波峰数量可以区分正常心跳和早搏频域主频可以区分室性早搏和室上性早搏能量集中度对识别融合心跳有指示意义。def extract_stats_features(signal): features {} features[mean] np.mean(signal) features[std] np.std(signal) features[max] np.max(signal) features[min] np.min(signal) features[ptp] np.ptp(signal) features[skew] stats.skew(signal) features[kurtosis] stats.kurtosis(signal) features[median] np.median(signal) features[q25] np.percentile(signal, 25) features[q75] np.percentile(signal, 75) # 差分特征 diff_signal np.diff(signal) features[diff_mean] np.mean(diff_signal) features[diff_std] np.std(diff_signal) features[diff_max] np.max(diff_signal) return features3.2 频域特征的效率陷阱频域特征计算时要注意FFT后直接取前几个频率分量对205个点的序列来说频率分辨率是1/205低频信息集中在前面几个分量但高频噪声和基线漂移也会混在里面。我建议先做带通滤波0.5Hz-45Hz再提取频域特征否则频谱会被低频漂移污染。提示心电信号的有效能量主要集中在0.5Hz-45Hz之间基线漂移通常在0.5Hz以下高频噪声一般在50Hz以上。先用巴特沃斯带通滤波器处理再做频域特征特征质量和稳定性都会明显提升。3.3 深挖序列的“隐藏特征”纯手工特征对树模型友好但对序列模式捕捉有限。我的做法是同时在原始序列上训练深度模型这意味着要做第二种预处理管线保留原始波形的长度、对齐和归一化不提取手工特征直接喂给1D CNN。双轨并行的好处在于树模型擅长捕捉离散特征、样本间差异、类别交叉深度模型擅长捕捉局部波形模式、时序依赖两者在验证集上的错误往往不重叠融合时能互补我在实际项目里观察到同一批数据上LightGBM和1D CNN的预测相关性大约在0.75到0.8之间没有完全一致说明两个模型确实学到了不同的信息层次。这也是为什么双轨方案在序列分类任务里几乎是标配。4. 模型建模与调参实录4.1 表格方案的基线LightGBM我用LightGBM作为表格模型的基线因为它对类别不平衡的容忍度较高且支持自定义损失函数和类别权重。核心参数设置如下import lightgbm as lgb params { objective: multiclass, num_class: 5, learning_rate: 0.02, max_depth: 8, num_leaves: 63, min_child_samples: 20, subsample: 0.8, subsample_freq: 1, colsample_bytree: 0.6, reg_alpha: 0.05, reg_lambda: 0.1, n_estimators: 2000, class_weight: balanced }这里有一个非常关键的经验不要直接使用默认的class_weight而是要根据样本分布自己计算权重。LightGBM虽然支持class_weight但对多分类的支持方式比较粗糙有时候达不到预期效果。我通常会手动传递sample_weight公式为sample_weight 1 / (class_freq * num_classes)也就是把加权F1的优化目标显式地编码到训练过程中让模型更关注少数类的样本。这个改动在验证集上普遍能带来0.02到0.03的提升。4.2 早停与验证策略我用的验证方式是StratifiedKFold5折交叉验证保证每一折的类别分布和全量数据一致。这里要注意不要用随机K折否则类别分布不稳定验证分数波动很大。from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42)对于每折训练LightGBM用early_stopping_rounds100如果验证集分数连续100轮不下降就提前停止训练最终模型取验证集分数最高的iter。做5折之后把测试集的预测概率取平均作为集成输出。我记得很清楚第一版代码里我用的是KFold而不是StratifiedKFold结果五折之间的分数差异能超过0.03整体平均分也一直是卡在0.82左右。换成StratifiedKFold后分数一下子稳定到0.85以上。这个细节特别容易忽视但对结果的影响非常大。4.3 深度模型1D CNN与LSTM的取舍深度模型我试过三种结构1D CNN、LSTM、TCN时间卷积网络。简单说结论1D CNN收敛快训练稳定分类头加GlobalAveragePooling效果最好LSTM训练慢但能捕捉长距离依赖结果和CNN差不多融合收益有限TCN感受野大适合长序列但对205个点的短序列优势不明显最终我选了1D CNN作为深度模型主线结构参考def build_cnn_model(): model Sequential() model.add(Conv1D(filters64, kernel_size5, activationrelu, input_shape(205, 1))) model.add(BatchNormalization()) model.add(MaxPooling1D(pool_size2)) model.add(Conv1D(filters128, kernel_size3, activationrelu)) model.add(BatchNormalization()) model.add(GlobalAveragePooling1D()) model.add(Dense(64, activationrelu)) model.add(Dropout(0.3)) model.add(Dense(5, activationsoftmax)) return model训练时用Adam优化器初始学习率1e-3batch_size 128多分类交叉熵损失。同时在损失函数里加入类别权重这样模型在训练时就“知道”F类和Q类更重要。class_weights { 0: 1.0, 1: 2.5, 2: 2.0, 3: 5.0, 4: 5.0 } model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy])4.4 两种方案的融合融合我用的是概率软融合加权重优化的逻辑final_prob 0.4 * lgb_prob 0.3 * xgb_prob 0.3 * cnn_prob权重比例不是拍脑袋定的而是根据验证集上各模型的加权F1来定。一个简单规则是哪个模型的验证分数高权重就大一点。我在测试中发现LightGBM的分数一般比CNN高0.02左右所以给它更大的权重。如果后来模型效果变化权重也要跟着重新调。这种简单加权平均的好处是稳定不容易过拟合验证集。更复杂的Stacking两层模型融合虽然理论上更优但调参成本高验证集上收益有时并不显著。对个人项目来说加权平均是性价比最高的融合方案。5. 常见问题与排查技巧实录5.1 “为什么预测结果几乎全是N类”这是新手最容易遇到的问题也是我在第一版模型上踩过的坑。原因很简单类别极度不平衡模型学到了“全预测N类”这个捷径。因为N类占比最高全预测N类的损失反而最小。排查方法看训练集和测试集的类别分布是否严重偏斜看模型输出的概率分布是不是有很大一部分样本的N类概率都超过0.9看验证集混淆矩阵是不是几乎只有N类有值解决方法就是上面说的类别权重、重采样对少数类过采样、以及更激进一些的SMOTE对表格特征生效对时序序列要谨慎。5.2 “模型在验证集上分数不错提交后分数却崩了”这个问题基本可以判定是数据分布不一致常见原因有训练集和测试集来自不同采集设备或不同时段波形基线漂移程度不同预处理参数不一致——例如只对训练集做了去噪测试集忘记做归一化方式没用样本级标准化导致用户数据分布偏移排查方法画训练集和测试集的信号均值曲线看整体形态是否一致对比训练集和测试集的特征分布直方图重点看标准差、峰峰值把测试集也做相同的去噪和归一化流程重新预测我在实际项目里遇到过第一次提交测试集分数比本地验证高0.01第二次提交反而低0.02。后来发现是我在特征提取时训练集用了全局统计参数测试集跑的时候不小心重新计算了全局统计导致特征分布错位。这种“预处理泄漏”和“预处理不一致”的问题特别隐蔽一定要把特征提取逻辑封装成同一个函数。5.3 “类别权重调高了但少数类recall还是上不去”这种情况我一般从两个方向排查特征层面少数类样本太杂同类内部差异大。F类融合心跳的波形很接近正常心跳和室性早搏的混合体你可能需要专门为F类设计差异特征比如过零率、信号复杂度、小波熵数据层面少数类样本量太少模型学不到稳定规律。这种情况下过采样/生成合成样本更有效我额外提一个做法在CNN部分使用数据增强对时间序列做尺度变换、加噪、时间轴小幅平移把F类和Q类的等效样本量变大。这种方法比单纯过采样效果更稳因为这些变换不会改变信号的生理语义。实测F类样本提升明显F1至少涨了0.02。5.4 “5折交叉验证每次分数波动很大”如果5折之间分数波动超过0.02说明模型对数据分布非常敏感背后通常是特征质量不够稳定。我建议检查是否有一些特征在某一折中计算出NaN或极端值这种情况通常出现在频域特征或小波特征对每个特征维度做分位数截断把极端值拉回合理范围增加随机种子后如果模型结构不变但分数大幅变化说明训练轮数或早停参数不合适可以尝试加大n_estimators并延后early_stopping波动问题本质上是方差问题解决思路是增加模型稳定性——把KFold换成重复KFold重复5次或者用seeds融合把同一模型在不同随机种子下的预测结果做平均。这样虽然训练时间翻倍但分数波动能压到0.005以内最终提交也更稳健。5.5 小样本类别的过拟合识别我训练到后期发现V类在验证集上的F1是0.92但测试集上可能只有0.88。这说明对V类存在一定过拟合。典型的应对是增强正则化CNN里加大Dropout率LightGBM里降低叶子节点数、增加min_child_samples不做过多的特征选择保留一些无关特征反而能起到噪声抵消的作用过拟合的判断标准不是看训练集准确率是否接近100%而是看验证集分数和测试集分数的差距是否超过0.01到0.02。如果能控制在0.01内基本可以被接受。6. 个人总结与扩展建议6.1 我的最终方案最后提交的方案是双轨融合LightGBM XGBoost 1D CNN5折交叉验证每一折分别训练三个模型测试集预测概率按折内验证分数动态加权平均。在验证集上的加权F1大约是0.86到0.87比单模型提升了至少0.03。如果只选一个模型跑新手基线我的建议是LightGBM加上手工特征。原因很简单上手快训练时间短可解释性强特征工程做扎实后LightGBM的结果不会比CNN差太多。深度模型的价值是在融合阶段体现的如果你的时间和算力有限优先把表格模型做好。6.2 后续还可以怎么扩展这个项目的天花板不在分类器而在信号表示。我列几个值得尝试的方向把ECG信号转为谱图用短时傅里叶变换或连续小波变换再喂给2D CNN或预训练图像模型做迁移学习算力充足时效果显著使用自监督预训练例如先在大规模无标签心电信号上做掩码重建再用下游分类微调集成更多模型结构例如Transformer和TCN融合通常还能在现有基础上再提升一点加权F1我在实操中最深的体会是时间序列分类和图像分类不一样的地方在于先理解数据形态再选模型结构。不要一开始就上复杂模型而是先用最简单的LR或LightGBM做一个baseline看到混淆矩阵找出模型真正分错的是哪些样本去画图观察这些样本的波形特征再针对性地设计特征或数据增强。这种方法看起来“慢”实际上是最快的提分路径。6.3 一点踩坑后的真心话这类开源项目投入时间很长中间很容易被各种分数的微小波动搞得心烦意乱但回过头看最值钱的部分从来不是最终的数字而是那些被踩出来的坑类别不平衡怎么处理、预处理管线怎么保持一致性、验证集怎么设置才更接近测试集场景。把这些能力沉淀下来后面再遇到任何序列分类任务都会顺畅很多。希望这篇复盘能帮你少绕一点弯路。