
心跳信号分类这个项目我已经跟了三期前两期把数据探查和基线模型跑通了这一期专门聊特征工程。如果你的目标只是把准确率刷到98%以上特征工程可能是整个pipeline里投入产出比最高的一环。原始的心电时间序列数据又长又噪直接把几千个时间点丢给树模型既浪费内存也拟合不好而频域、统计、形态三类特征组合起来往往能让LightGBM这类模型在同样的参数下直接涨2到3个点。这份笔记就是记录我自己在心跳信号分类任务里做特征工程的完整思路和踩坑记录从基础统计特征到频域变换再到特征筛选每一步都给出可复现代码和参数选择的理由供正在做类似时间序列分类任务的朋友参考。1. 特征工程整体设计思路1.1 为什么原始时间序列不能直接使用拿到心跳信号数据时每条样本是一条长度固定的时间序列比如300个时间点采样对应一段心拍信号。有人可能会想既然LightGBM能处理表格数据那把300个点直接展开成300列不就行了我最初也这么试过结果训练速度慢得离谱而且效果并不好。原因有三个一是300维稀疏且冗余相邻时间点高度相关模型要花大量分裂次数才能找出有效区分模式二是单点幅值受噪声和个体差异影响很大直接作为特征不够鲁棒三是样本量有限时高维原始输入更容易过拟合泛化能力反而下降。特征工程的核心目标就是把这300个原始采样点压缩、变换成一组高信息密度的衍生变量。这些变量要能在不同类别之间拉开距离同时尽量保留原始信号的判别性信息。对于心跳信号来说有价值的特征主要集中在三个层面统计分布特征刻画信号的整体形态频域特征刻画信号的周期性节律形态特征刻画具体波形的峰值和间隔变化。三者结合才能完整表达一段心拍的生理含义。1.2 特征工程的完整流程框架我在这个项目里的特征工程分为四个阶段基础清洗与切分、特征提取、特征处理、特征筛选与验证。第一阶段先把原始信号中的缺失值、异常值处理好对长信号做心拍切分第二阶段按照统计、时域、频域、形态四个维度提取原始特征得到一份宽表第三阶段对特征做标准化、去相关性处理剔除方差接近零的无效列第四阶段通过重要性排序和递归消除法筛选出最终进入模型的子集并验证特征稳定性。这里特别强调一点特征工程不是一次性的它是一个迭代过程。先提一批特征训练基线模型看哪些特征重要性高再围绕这些高重要性特征构思新的衍生特征比如针对R峰间隔再计算变异性指标或者针对频域主峰位置做二次变换。整个流程走两三轮特征质量会有明显提升。2. 核心特征提取详解2.1 统计特征快速捕捉信号的分布形态统计特征是最基础也最不能跳过的一步。对于每个样本的心跳信号序列我提取了以下几组统计量均值、标准差、最大值、最小值、极差、中位数、四分位距偏度、峰度过零率、绝对能量、均方根等等。为什么这些看似简单的特征会有用因为不同类别的心跳信号在分布形态上确实有差异。比如某些异常心跳信号会出现明显的幅值突变极差和标准差就会偏大某些节律紊乱信号波形不对称偏度值的区分度就很高。我在实际实验中发现峰度这个特征在心跳四分类任务里重要性排名能进前二十这是很多人容易忽略的。这里附上提取统计特征的参考代码import numpy as np def extract_stat_features(signal): features {} features[mean] np.mean(signal) features[std] np.std(signal) features[max] np.max(signal) features[min] np.min(signal) features[range] np.ptp(signal) features[median] np.median(signal) features[q25] np.percentile(signal, 25) features[q75] np.percentile(signal, 75) features[iqr] features[q75] - features[q25] features[skew] ((signal - features[mean]) ** 3).mean() / (features[std] ** 3 1e-8) features[kurt] ((signal - features[mean]) ** 4).mean() / (features[std] ** 4 1e-8) features[rms] np.sqrt((signal ** 2).mean()) features[zero_cross] ((np.diff(np.sign(signal)) ! 0).sum()) / len(signal) return features代码里有个细节需要注意偏度和峰度的计算需要手动实现因为pandas的skew和kurt方法在小样本下默认做了偏倚校正和时间序列特征库tsfresh的计算结果不一样。我踩过这个坑后来统一用手写版本保证全流程一致性。分母加一个1e-8是为了防止标准差为零时除零报错。2.2 时域特征捕捉波形变化的动态节律时域特征关注的是信号随时间变化的模式对心跳信号来说最核心的就是R峰位置以及相邻R峰之间的间隔也就是RR间期。心电信号中的R峰是QRS波群中最明显的波峰检测到R峰之后就能计算出一系列有生理意义的特征。R峰检测我用的是scipy.signal.find_peaks关键参数是height和distance。height需要根据信号幅值的统计分布动态设定我取的是信号均值的0.5倍加上1.5倍标准差distance则根据采样率来设定比如采样率是300Hz那相邻R峰最小间隔不能小于0.4秒换算成采样点就是120个点。这个值设小了会检测到伪峰设大了会漏掉真正的R峰。找到R峰之后我计算了这些特征RR间期的均值、标准差、最大值、最小值连续RR间期差值的均方根RMSSD相邻RR间期差大于50ms的比例pNN50RR间期的变异系数CV。这几个指标在心率变异性分析里是标准指标用于区分正常节律和心律失常效果显著。from scipy.signal import find_peaks def extract_time_features(signal, sampling_rate300): threshold np.mean(signal) * 0.5 np.std(signal) * 1.5 peaks, _ find_peaks(signal, heightthreshold, distancesampling_rate * 0.4) if len(peaks) 2: return { rr_mean: 0, rr_std: 0, rr_max: 0, rr_min: 0, rmssd: 0, pnn50: 0, cv: 0, heart_rate: 0 } rr_intervals np.diff(peaks) / sampling_rate diff_rr np.diff(rr_intervals) features {} features[rr_mean] np.mean(rr_intervals) features[rr_std] np.std(rr_intervals) features[rr_max] np.max(rr_intervals) features[rr_min] np.min(rr_intervals) features[rmssd] np.sqrt((diff_rr ** 2).mean()) features[pnn50] (np.abs(diff_rr) 0.05).mean() features[cv] features[rr_std] / (features[rr_mean] 1e-8) features[heart_rate] 60.0 / features[rr_mean] return features2.3 频域特征从频率视角挖掘周期节律频域特征是我这个项目里涨点最明显的一块。心跳信号本质上是准周期信号不同类别在频域的功率分布有明显差异。正常心跳的频谱能量集中在低频段而某些房颤信号的频谱会出现高频段能量抬升和频谱离散化的现象。频域特征提取的流程是先做去均值处理消除直流分量然后加窗做FFT变换最后从频谱中提取特征。我用的窗函数是Hamming窗窗长设为256重叠率50%。去均值的操作很关键因为直流分量不携带心跳的判别信息反而会把频谱的低频段能量拉高影响后续特征提取。提取的频域特征包括频谱峰值对应的频率主频、频谱能量占总能量的比例、频谱质心、频谱带宽、低频功率0.04-0.15Hz、高频功率0.15-0.4Hz、低高频功率比。其中低高频功率比在心率变异性分析中是经典指标反映交感神经和副交感神经的平衡状态。def extract_freq_features(signal, sampling_rate300): signal signal - np.mean(signal) window np.hamming(len(signal)) spectrum np.fft.rfft(signal * window) power np.abs(spectrum) ** 2 freqs np.fft.rfftfreq(len(signal), d1/sampling_rate) features {} features[spec_peak_freq] freqs[np.argmax(power)] features[spec_peak_power] np.max(power) / np.sum(power) features[spec_centroid] np.sum(freqs * power) / np.sum(power) features[spec_bandwidth] np.sqrt(np.sum((freqs - features[spec_centroid]) ** 2 * power) / np.sum(power)) lf_mask (freqs 0.04) (freqs 0.15) hf_mask (freqs 0.15) (freqs 0.4) lf_power np.sum(power[lf_mask]) hf_power np.sum(power[hf_mask]) features[lf_hf_ratio] lf_power / (hf_power 1e-8) features[total_power] np.sum(power) return features频谱质心和带宽这两个特征是从音频处理里借鉴过来的用于刻画频谱的集中程度。如果频谱能量集中在少数几个频率点说明信号节律规整如果能量分散说明节律紊乱。心跳信号分类恰好就能用上这个规律。2.4 形态特征刻画具体波形结构形态特征关注的是单个心拍内部的波形结构。在这类时间序列分类任务里最常用的形态特征包括波峰波谷数量、波形斜率、信号上升下降的对称性等。对于每段信号我计算了这样几个形态特征波峰数量与波谷数量的比值、前一半信号与后一半信号的幅值差、波形上升段与下降段的平均斜率比、R峰幅值的变异系数。这些特征能够捕捉到异常心拍中常见的形态变化比如早搏心拍会出现宽大畸形的QRS波形态特征对这种变化非常敏感。此外我还用了np.gradient计算信号的一阶导数然后从导数序列中提取均值、方差和绝对值均值作为特征。导数的物理意义是信号变化速率正常心跳的电位变化速率是有规律可循的异常心跳往往伴随变化速率的突变。def extract_shape_features(signal): grad np.gradient(signal) peaks, _ find_peaks(signal) troughs, _ find_peaks(-signal) features {} features[peak_count] len(peaks) / len(signal) features[trough_count] len(troughs) / len(signal) features[peak_trough_ratio] len(peaks) / (len(troughs) 1e-8) features[grad_mean] np.mean(grad) features[grad_std] np.std(grad) features[grad_abs_mean] np.mean(np.abs(grad)) half len(signal) // 2 features[half_diff] np.mean(signal[half:]) - np.mean(signal[:half]) if len(peaks) 1: peak_values signal[peaks] features[peak_amp_cv] np.std(peak_values) / (np.mean(peak_values) 1e-8) else: features[peak_amp_cv] 0 return features3. 特征处理与筛选实操3.1 缺失值、常量特征和异常值处理特征提完之后得到的宽表可能有几百列这个阶段不能急着拿去训练第一件事是数据质量检查。我用pandas统计了每个特征的缺失率、唯一值个数和方差。唯一值个数为1的特征直接删除这类常量特征对模型没有任何区分能力缺失率超过50%的特征也建议删除或者用中位数填充。异常值处理上要特别小心。心跳信号里某些类别的样本本身就存在极端值比如某些异常心跳的RR间期特别短这是真实的生理信号不应该当作噪声剔除。我的做法是只在特征值超出合理生理范围时才处理比如心率超过250次/分或者低于20次/分这类记录大概率是检测误差直接删除。这里给个参考标准心跳信号的RR间期生理范围大约是0.3秒到2秒超出这个范围的R峰检测结果基本可以判定为伪峰导致的计算错误。检查代码很简单def check_feature_quality(df): report pd.DataFrame({ missing_rate: df.isnull().mean(), nunique: df.nunique(), var: df.var() }) constant_cols report[report[nunique] 1].index.tolist() high_missing_cols report[report[missing_rate] 0.5].index.tolist() return constant_cols, high_missing_cols3.2 标准化和去相关性处理特征标准化我选的是StandardScaler而不是MinMaxScaler原因有两个方面。一是树模型不要求特征标准化但后续可能要用PCA做降维PCA对特征的尺度敏感标准化是必须步骤二是某些特征比如频谱功率和RR间期差的量纲差异很大不标准化会掩盖小量纲特征的贡献。去相关性处理上我首先计算了特征之间的Pearson相关系数矩阵。相关系数绝对值超过0.95的特征对说明存在严重的共线性保留其中一个即可。比如R峰幅值均值与信号均值通常高度相关RR间期标准差与变异系数也几乎线性相关这种冗余特征留着只会增加计算开销不会带来信息增益。对于需要进一步压缩维度的情况我会在标准化之后跑PCA。但这里有个经验之谈PCA适合线性降维不太适合处理包含大量非线性交互的特征集合。我更推荐优先靠特征重要性做筛选PCA只在特征维度实在太高、计算压力大到无法接受时才使用。3.3 基于特征重要性和递归消除的特征筛选特征筛选我用的是“两步走”策略。第一步先用LightGBM训练一个基线模型提取特征重要性排序删除重要性排名后30%且增益为0的特征第二步对保留下来的特征做递归消除法RFECV进一步压缩维度。为什么选择LightGBM而不是XGBoost做特征筛选一个实际原因是训练速度快迭代实验效率高另一个原因是LightGBM的直方图算法天然对连续特征的切分点做了离散化特征重要性估计相对稳健不容易被单个极端值带偏。递归消除法的思路很直接在特征集上训练模型每轮删除最不重要的几个特征计算交叉验证得分最终选出得分最高时对应的特征子集。这一步能自动化地找到最优特征数量避免拍脑袋决定留多少特征。from sklearn.feature_selection import RFECV from lightgbm import LGBMClassifier lgb_model LGBMClassifier(n_estimators200, learning_rate0.05, random_state42) rfecv RFECV(estimatorlgb_model, step5, cv3, scoringf1_macro, n_jobs-1) rfecv.fit(X_scaled, y) selected_features X.columns[rfecv.support_].tolist() print(fOptimal feature count: {rfecv.n_features_})这里有个性能优化的技巧RFECV每轮都要重新训练模型如果特征数量有几百个耗时可能很长。我的做法是先跑LightGBM重要性排序把特征压缩到50到80个再做RFECV细筛整体效率能提升好几倍。最终我保留的特征数量大约在30到40个之间在这个量级上模型效果和训练速度达到了比较好的平衡。4. 特征融合与模型联动方案4.1 分组特征的组合策略单个特征提取完下一步要考虑特征之间的组合。我用了两种组合方式统计特征和频域特征的交叉组合时域特征和形态特征的交叉组合。举个具体的例子RR间期均值和信号标准差单独看区分度有限但它们组合成rr_mean / std这个比值后就成了一个有效的节律稳定性指标。再比如频谱质心和峰度组合能刻画频谱的形状特征对于区分频谱集中型和离散型心跳信号很有帮助。这些组合特征不需要做太多我建议控制在5到10个最核心的交互特征。做多了会引入噪声而且交互特征的解释性也差给后期复盘带来困扰。我自己常用的做法是先跑一次重要性排序从排名前15的特征里挑出有生理含义的特征做两两组合然后验证组合特征的增益。4.2 特征与模型调参的协同优化特征工程不是孤立的一步它和模型调参要配合着来。我在这个项目中明显体会到特征工程做到位了模型参数可以简化很多。特征质量差的时候需要加深树深度、降低学习率来硬拟合噪声特征质量好之后浅一些的树配合正则化参数就能达到更好的效果。实际操作中我分了两个阶段调参。特征工程完成前用默认参数跑基线模型目标是看特征增量带来的相对提升特征定稿后集中精力调num_leaves、min_child_samples、learning_rate这几个核心参数。阶段分离很重要如果同时调整特征和模型参数你很难判断效果提升到底是特征贡献的还是参数贡献的。另一个经验是每次新增特征后都要留出足够的时间做交叉验证验证而不是只看单次验证集得分。心跳信号数据集的类别分布不均衡某一次随机划分的结果波动很大只有多次重复实验取平均才能看出真实增益。4.3 内存占用与批处理优化提取几百维特征时最容易疏忽的是内存问题。如果数据集有几十万条样本每条样本都提几十个特征宽表直接就几个GB了。我在实际项目中遇到过内存溢出导致kernel重启的问题后来用float32替代float64存储特征矩阵内存直接减半精度损失可以忽略。批量处理时我用multiprocessing.Pool做并行特征提取。心跳信号的数据量比较大单线程提取所有特征要等很久并行化之后速度能提升数倍。具体实现上要注意进程间的数据传递不要每次都把原始数据集全部复制到子进程而是按批次传递数据索引子进程读取后再做特征提取。from multiprocessing import Pool def batch_extract_features(indices): results [] for idx in indices: signal raw_signals[idx] stat_feats extract_stat_features(signal) time_feats extract_time_features(signal) results.append({**stat_feats, **time_feats}) return results batch_indices np.array_split(np.arange(len(raw_signals)), 4) with Pool(4) as pool: batch_results pool.map(batch_extract_features, batch_indices) feature_rows [item for batch in batch_results for item in batch]5. 常见问题与排查技巧实录5.1 特征有效性波动大的排查思路做特征工程时最让人头疼的问题是某个特征在验证集上效果很好换一个数据划分之后效果就消失了或者线上推理时特征分布和线下差别很大。这类问题的根源往往是特征对数据分布的过拟合。排查方法很简单在做特征筛选时强制使用多个不同的随机种子做交叉验证观察特征重要性的稳定性。如果某个特征只在特定种子的划分下进入前二十其他种子下排名掉到五十以后这个特征基本可以判定为不稳定不宜进入最终特征集。我在这个项目里就砍掉过两个时间域梯度特征单独跑一个种子效果很好换种子就崩回看代码发现是梯度计算时对噪声敏感导致的。5.2 高频特征导致的过拟合问题频域特征虽然涨点明显但带来的过拟合风险也最大。这是因为FFT频谱对采样点的微小变化比较敏感训练集里的一些噪声模式容易被当成有效信号学进去。我遇到的情况是加上频域特征后训练集AUC到了0.995但测试集AUC只有0.93明显过拟合了。针对这个问题我的处理方案有两条第一对频域特征做平滑处理用移动平均降低频谱的波动性第二在筛选特征时对频域特征的使用会更谨慎优先保留那些具有明确生理含义的特征比如低高频功率比、频谱质心而不是把所有频谱分箱特征都堆上去。5.3 排查checklist速查表问题现象可能原因排查方案特征重要性全部偏低特征提取代码有bug特征值几乎相同检查nunique和方差抽样可视化特征分布验证集得分高、测试集得分低特征过拟合或数据泄露回溯特征的生成逻辑确认是否用到label信息特征存在大量NaNR峰检测失败或除零检查RR间期计算分支补充默认返回值特征方差极大存在异常样本分位数截断观察是否在合理生理范围增加特征后得分反而下降特征冗余或噪声太大用RFECV重新筛选检查新特征与现有特征的相关性5.4 特征监控与线上一致性检查特征定稿不是终点还有最后一个环节值得认真对待监控特征在线上推理过程中的分布漂移。心跳信号分类如果部署到生产环境新数据的特征分布可能会因为采集设备、采样率等变化而与训练集产生差异。我在这个项目里维护了一份特征基准统计表记录了每个特征在训练集上的均值、标准差和分位数范围。每次新数据进来后计算特征分布和基准的偏差度量如果某个特征的均值和基准值相差超过3倍标准差就触发告警提醒重新训练或者排查上游采集模块。这个监控机制看着不起眼但在实际生产环境中帮了我大忙能提前发现因为采样率配置错误导致的全线特征异常。这份特征工程的流程从统计特征、时域、频域到形态特征再到特征筛选和监控每一步我都踩过坑也填了坑。如果你也在做心跳信号或者其他时间序列分类任务建议先按这个顺序把基础特征全部跑通再考虑深度学习方案。很多场景下处理好特征的传统模型效果和稳定性都比直接上复杂模型要好排查问题也方便得多。