ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

心跳信号分类实战:从原始波形到高效特征工程的完整指南

心跳信号分类实战:从原始波形到高效特征工程的完整指南 讲个我自己的经历。去年打某个心跳信号分类的比赛一开始我直接把手里的205条原始心电波形heartbeat_signals丢给 LightGBM调了好几轮参数线上分数始终在 0.65 附近打转怎么都上不去。后来我才反应过来树模型对着 205 个原始采样点做切分根本学不到这个波形是正常窦性心律还是房颤这种抽象规律。那段时间我翻了不少 Top 方案的分享发现前排选手几乎没人直接扔原始数据大家都在拼命做特征工程。这篇笔记就是把我后来在心跳信号这个赛题上踩过、试过、验证过的特征工程方法完整梳理一遍包括每个特征的设计动机、核心代码和容易翻车的地方。1. 为什么原始波形直接进模型效果垫底先说清楚特征工程要解决的问题1.1 心跳信号数据到底长什么样先明确一下我们面对的数据形态。心跳信号分类预测这个场景里每一条样本是一个长度为 205 的一维数组代表一次心拍记录或者一段固定长度的 ECG 片段采样点之间的时间间隔是固定的。标签是心跳类别比如正常、房颤、其他异常节律等。这个数据和普通表格数据最大的区别在于单看任何一个采样点它什么信息都不携带。第 100 个采样点的数值是 0.35 还是 -0.12如果没有前后文完全无法判断心拍是否正常。真正的判别信息分布在波形的形态、节奏、能量分布这些整体属性上也就是需要从序列里提炼出来的东西。在开始任何特征工程之前我强烈建议先做一次简单的可视化。把每个类别的样本按时间轴叠在一起画出来你会直观地看到正常心拍的波形往往有清晰的 P 波、QRS 波群和 T 波而异常类别可能在某个波段出现形态扭曲、幅度异常或者额外的尖峰。这个直观认识会直接影响你后续选择哪些特征方向远比盲目堆特征管用。1.2 基线模型的瓶颈在哪里把原始 205 个点直接丢给树模型理论上树模型不是不能学它可以通过在特定位置切分来近似拟合任意函数。但问题在于维度灾难205 维对于树模型来说已经不算低了每棵树的切分点搜索空间巨大很容易过拟合平移敏感性原始波形的采样起点不固定同一个心拍可能整体左移或右移几个采样点树模型对这类平移没有内置的不变性噪声干扰ECG 采集过程中混入了基线漂移、肌电干扰等噪声原始采样点里包含大量与分类无关的信息。所以特征工程在这个场景下的核心目标可以归纳为三点降维把 205 个点压缩成有价值的几十个特征、增强不变性提取的特征对平移、缩放不敏感、去噪放大与分类相关的形态和节奏信息。接下来所有方法都是围绕这三件事展开的。2. 把波形拆成数字时域统计特征的构造逻辑与实操代码2.1 先算最朴素的统计量均值、方差、偏度、峰度时域特征是最直接、最不容易出错的一类特征。虽然叫朴素但它们在心跳分类里相当有效。原因在于异常心拍的波形往往会在某些统计指标上表现出明显偏移。具体来说我常用的统计特征包括均值波形整体水平的偏移比如 ST 段抬高会在均值上有所反映虽然单一均值很粗糙但组合其他特征有用。标准差/方差波形整体波动幅度房颤等节律紊乱类别的波动模式会和正常心律明显不同。偏度波形分布对称性的度量。QRS 波群正向尖峰和负向尖峰不对称的样本偏度差异显著。峰度分布尾部的陡峭程度。某些早搏类别的波形会出现异常尖锐的尖峰反映为峰度偏高。最大值、最小值、峰峰值波形幅度范围的度量。均方根RMS反映信号的有效能量比标准差多了一层物理含义。这里有一个非常关键的细节对于归一化后的数据某些特征如均值可能在所有样本上都接近 0失去了区分能力。所以特征不是越多越好你要先检查一下特征的方差方差接近 0 的特征可以直接丢掉。2.2 滑动窗口与分位数特征捕捉局部变化全局统计量有个缺点它会抹掉波形的局部结构。比如一段波形前段正常、后段异常全局均值可能和正常波形差不多但局部窗口的特征会暴露问题。因此我引入了滑动窗口统计。做法把 205 个采样点等分成若干个固定长度的窗口比如每 25 个点一个窗口共 8 个窗口在每个窗口内分别计算均值、标准差、最大值、最小值然后把所有窗口的统计量拼接起来。这样得到的特征天然带有位置信息模型能感知到异常发生在波形的哪个区段。分位数特征同样值得做。相比于均值和方差分位数对异常值更鲁棒。常用的分位点包括 1%、5%、25%、50%、75%、95%、99%分别计算对应分位数值。比如np.percentile(signal, 95)能告诉你波形高幅值段的水平这对捕捉偶发早搏尖峰很有帮助。2.3 完整特征构造代码参考下面这段代码是我当时实际使用的时域特征构造函数可以直接复用import numpy as np import pandas as pd def time_domain_features(signal): features {} features[mean] np.mean(signal) features[std] np.std(signal) features[var] np.var(signal) features[skew] pd.Series(signal).skew() features[kurt] pd.Series(signal).kurt() features[max] np.max(signal) features[min] np.min(signal) features[ptp] np.ptp(signal) # 峰峰值 features[rms] np.sqrt(np.mean(np.square(signal))) features[median] np.median(signal) # 分位数特征 for q in [0.01, 0.05, 0.25, 0.50, 0.75, 0.95, 0.99]: features[fquantile_{int(q*100)}] np.percentile(signal, q * 100) # 绝对值的统计 abs_signal np.abs(signal) features[abs_mean] np.mean(abs_signal) features[abs_std] np.std(abs_signal) features[abs_max] np.max(abs_signal) # 滑动窗口统计8个窗口每窗口约25个点 n len(signal) window_size n // 8 for i in range(8): seg signal[i*window_size : (i1)*window_size] features[fwin{i}_mean] np.mean(seg) features[fwin{i}_std] np.std(seg) features[fwin{i}_max] np.max(seg) features[fwin{i}_min] np.min(seg) return features提醒一句滑动窗口数量不是越多越好。窗口太多每个窗口内的采样点过少统计量会变得不稳定反而放大噪声。我当时比较过 4、8、16 个窗口的效果8 个在验证集上表现最稳。3. 换一个视角看波形傅里叶变换与频域特征的实际用法3.1 为什么要看频域时域特征描述的是波形随时间怎么变化但很多心电信号的关键差异是体现在哪些频率成分占主导上的。举个例子正常心拍的频率成分集中在一个较窄的范围内而房颤等节律紊乱会让信号的能量分布变得更加分散。如果只看时域这种差异很难被量化。傅里叶变换的核心思想很简单任何一个信号都可以分解成不同频率的正弦波叠加。通过 FFT我们能得到信号在各个频率上的能量分布频谱。我打个比方——时域特征像是描述一个人说话的音量和语速频域特征则是分析他的音色和声调。两个维度都很重要但看到的东西不一样。需要注意的是心跳信号的频率范围通常在 0.5Hz 到 40Hz 之间常规 ECG 采集范围而比赛给的数据采样率未知但 205 点的时间序列做 FFT 后有效频率分量只有前 100 个左右。实际操作时我一般只保留前 30 到 50 个频段的特征高频部分基本是噪声。3.2 频谱特征的具体计算基于 FFT 的结果我构造了这样几类频域特征频谱峰值幅值最大的频率分量及其对应的频率值频谱质心频谱的重心频率反映信号主要能量集中在哪个频率段频谱带宽能量集中度的度量带宽越小说明信号频率越集中频谱熵频谱分布的均匀程度频谱熵高意味着能量分散在多个频率上这对房颤识别很有用指定频段的能量占比比如 0-5Hz、5-10Hz、10-20Hz、20-40Hz 四个频段的能量分别占总能量的比例。具体代码如下def frequency_domain_features(signal): features {} # FFT fft_vals np.fft.fft(signal) fft_abs np.abs(fft_vals) # 只取前半部分对称 half len(fft_abs) // 2 fft_abs fft_abs[:half] # 频率轴归一化到0~0.5 freqs np.linspace(0, 0.5, half) # 频谱峰值 features[fft_peak] np.max(fft_abs) features[fft_peak_freq] freqs[np.argmax(fft_abs)] # 频谱质心 total_power np.sum(fft_abs) if total_power 0: features[spectral_centroid] np.sum(freqs * fft_abs) / total_power else: features[spectral_centroid] 0 # 频谱熵 power_norm fft_abs / (total_power 1e-12) features[spectral_entropy] -np.sum(power_norm * np.log(power_norm 1e-12)) # 频段能量占比 bands [(0.001, 0.02), (0.02, 0.05), (0.05, 0.1), (0.1, 0.25)] for i, (low, high) in enumerate(bands): mask (freqs low) (freqs high) band_power np.sum(fft_abs[mask]) features[fband_{i}_ratio] band_power / (total_power 1e-12) return features频域特征对心跳信号分类的增益非常明显。我自己的实验里加入这组特征后验证集 AUC 大约提升了 0.015 左右。特别是频谱熵和频段能量占比这两个特征在区分房颤类别时表现突出。3.3 小波特征一种兼顾时间和频率的补充方案傅里叶变换有一个天然缺陷它完全丢失了时间信息。频谱只能告诉你信号有哪些频率成分但无法告诉你某个频率出现在什么时间位置。对于心跳信号这有时候很关键——比如某些类别的异常波形只在特定时间段出现高频抖动。小波变换就是来解决这个问题的。它通过对信号进行不同尺度的分解同时保留时间和频率信息。实际应用中我使用 PyWavelets 库对信号做 4 层小波分解然后把各层的小波系数能量作为特征import pywt def wavelet_features(signal): features {} # 使用db4小波基分解4层 coeffs pywt.wavedec(signal, db4, level4) for i, coeff in enumerate(coeffs): features[fwavelet_level{i}_energy] np.sum(np.square(coeff)) features[fwavelet_level{i}_std] np.std(coeff) features[fwavelet_level{i}_mean] np.mean(np.abs(coeff)) return features注意一个细节小波基的选择对特征效果影响很大。我试过 haar、db2、db4、sym5最终 db4 在心跳数据集上表现最好。原因可能在于 db4 小波的形状和心电信号中的 QRS 波群有一定相似性分解起来更能突出有效成分。当然这个结论未必适合所有数据集如果你在自己的数据上复现建议多做几组小波基的实验。4. 心跳的骨架R峰定位与心动周期形态特征4.1 用峰值检测找到 R 峰心跳信号和普通时序数据最大的不同在于它有明确的生理学含义。每个正常心拍里都有一个显著的 R 峰QRS 波群中的最高尖峰R 峰之间的间隔RR 间期是衡量心率变异性的核心指标。所以一个价值很高的特征方向是从波形中定位 R 峰计算 R 峰数量、RR 间期的均值和标准差。这些特征直接对应心率的快慢和规律性对区分正常心律和各类心律失常非常有效。峰值检测我用的是scipy.signal.find_peaksfrom scipy.signal import find_peaks def r_peak_features(signal, distance10, heightNone): features {} # 用绝对值的平滑版本找峰 smooth np.convolve(np.abs(signal), np.ones(3)/3, modesame) if height is None: height np.mean(smooth) 0.5 * np.std(smooth) peaks, properties find_peaks(smooth, distancedistance, heightheight) features[r_peak_count] len(peaks) if len(peaks) 2: rr_intervals np.diff(peaks) features[rr_mean] np.mean(rr_intervals) features[rr_std] np.std(rr_intervals) features[rr_min] np.min(rr_intervals) features[rr_max] np.max(rr_intervals) # RMSSD相邻RR间期差值的均方根 diff_rr np.diff(rr_intervals) features[rmssd] np.sqrt(np.mean(np.square(diff_rr))) else: features[rr_mean] 0 features[rr_std] 0 features[rr_min] 0 features[rr_max] 0 features[rmssd] 0 return featuresdistance10这个参数很关键它规定了两个 R 峰之间的最小采样点距离防止把同一个宽大 QRS 波的多个尖峰误判为多个 R 峰。实际调试时你可以把检测到的峰值画出来看一下确认没有漏检或误检。4.2 心动周期派生特征有了 R 峰位置之后还可以进一步派生特征平均心率如果知道采样率可以用60 / (平均RR间期 × 采样间隔)估算每分钟心率。即使不知道采样率的绝对值RR 间期本身也可以作为相对指标使用。心率变异性HRV指标临床上 HRV 是衡量自主神经功能的重要指标但在分类任务中我们更关心的是它的统计特征——RMSSD相邻 RR 间期差值均方根和 RR 间期标准差是其中最常用的两个。RMSSD 高通常和窦性心律不齐、房颤等类别相关。最大最小 RR 间期比反映心率波动范围早搏类别的这个比值通常较大。4.3 形态学特征极值占比与波形斜率除了 R 峰之外波形本身的一些形态指标也很有区分度上升沿最大斜率QRS 波群的起始段从 Q 波到 R 峰斜率变化快异常传导时斜率会变缓或变陡。用np.diff(signal)的最大值近似。下降沿最大斜率从 R 峰到 S 波的下降段斜率。正向尖峰占比sum(signal threshold) / len(signal)反映波形在基线以上的时间比例。ST 段抬高会使这个比例明显上升。波形面积的对称性把波形分为前半段和后半段分别求面积绝对值的积分然后取比值。QRS 波群形态不对称的样本这个比值会偏离 1。形态特征对某些特定类别的识别很有帮助。比如有的异常类别表现为 R 波高耸、T 波倒置这些特征都能通过幅度和方向捕捉到。5. 特征太多不是好事相关性分析与重要性筛选的完整流程5.1 先剔除高相关冗余特征特征工程做完后我手上的特征数量往往能达到 150 到 200 个。这时候如果不加筛选直接拿去训练不仅训练速度慢还会引入大量冗余信息降低模型的泛化能力。我的第一步永远是计算特征间的 Pearson 相关系数矩阵找出相关性超过 0.95 的特征对人工挑选其中一个保留。这一步叫粗筛主要目的是去掉那些几乎完全冗余的特征对比如全局最大值和ptp峰峰值在某些数据集上高度相关留一个就行。def remove_high_corr_features(df, threshold0.95): corr_matrix df.corr().abs() upper corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k1).astype(bool)) to_drop [column for column in upper.columns if any(upper[column] threshold)] return df.drop(columnsto_drop)这个粗筛通常能砍掉三分之一到一半的特征而且基本不损失模型效果。5.2 用模型重要性再做一轮筛选粗筛之后我会做一轮细筛。方法很简单先跑一个 LightGBM 模型开启特征重要性输出然后观察 feature importance 的分裂次数split或信息增益gain只保留累计重要性占比达到 95% 的特征子集。这一步需要注意的是LightGBM 的特征重要性对随机种子敏感单次训练的结果不一定稳定。我的做法是用不同的随机种子跑 5 次取重要性的均值再排序。这样筛选出来的特征子集更可靠。细筛还有一个隐藏的好处如果某个你精心构造的特征在多次实验中都排不上号说明它对当前模型没有增量信息可以放心丢掉了。我最早构造了 40 多个滑动窗口特征最后只留下了 12 个模型效果反而略有上升。5.3 验证特征有效性的正确姿势筛选特征和验证特征效果必须是一个闭环。我习惯的验证方式是 5 折交叉验证比较全部特征和筛选后特征在验证集上的 AUC 平均分。如果筛选后分数下降超过 0.005说明砍多了需要放宽筛选阈值如果分数持平或上升说明特征集更精炼。还需要警惕过拟合千万不要用测试集的成绩来反向调整特征筛选策略。测试集是留到最后才看的一旦你根据测试集表现来回更改特征工程方案就会出现测试集信息泄漏最终线上成绩会严重虚高。6. 这一轮特征工程里踩过的坑数据泄漏、噪声放大与不均衡样本6.1 最大坑全局统计算到了测试集这是特征工程里最容易犯、也最致命的错误。做标准化或者构造某些特征时如果用了全量数据包括测试集的统计信息就会导致数据泄漏。比如我最初做标准化时直接写了这样的代码# 错误示范用全量数据计算均值和方差 scaler StandardScaler() X_all scaler.fit_transform(df[feature_cols])但在比赛场景中标准做法是只能用训练集拟合 scaler再分别 transform 训练集和测试集。我一开始偷懒直接对全量数据做了标准化导致线上分数和线下验证分数差距极大。后来意识到所有涉及全局统计的操作标准化、PCA、特征筛选的阈值计算都必须只用训练集拟合。6.2 时域特征对噪声过于敏感前面提到时域特征很好用但它有一个致命的弱点对噪声和异常值极其敏感。一个离群的尖峰噪声会让最大值、峰度、RMS 这些特征完全失真。我遇到过一次印象深刻的情况某个样本的kurt特征是正常样本的 20 多倍导致 LightGBM 在后期的迭代中反复在这个特征上分裂最终对那个样本过拟合。排查后才发现那条样本在第 178 个采样点附近有一个明显的基线跳变采集设备电极松动导致的。后续的解决办法有两个一是构造特征前先用中值滤波或 Savitzky-Golay 滤波做一次预处理把高频毛刺磨平二是把最大值、峰度这类对异常值敏感的特征和分位数特征如 99% 分位数一起使用分位数特征对单点异常天然鲁棒。6.3 类别不均衡下特征分布偏差带来的问题心跳信号分类数据往往存在类别不均衡少数类的样本量可能只有多数类的十分之一。这种情况下特征工程的验证会出现一个隐蔽的问题特征在高频类上的分布是稳定的但在低频类上可能因为样本太少而波动剧烈。比如房颤类只有 300 个样本你辛苦构造的 RR 间期标准差特征在这 300 个样本上表现得极好但换一个数据划分效果就崩了。这说明特征对该类别过拟合了。应对方法使用分层采样StratifiedKFold保证每个折里各类别比例一致对低频类别使用 Synthetic Minority Over-sampling TechniqueSMOTE做数据增强后再做特征验证重点关注低频类别上特征的稳定性而不仅仅看整体 AUC。还有一个实战技巧把所有特征的分布按类别画出来对比。如果某个特征在少数类上的分布和多数类差异巨大但少数类样本量又很小要格外谨慎。这种特征要么是发现了真正的生理规律要么只是因为样本量太小导致的随机波动。判断方法是去看医学文献或者抽样更多数据验证单纯靠交叉验证分数是不足以说明问题的。心跳信号分类的特征工程这一块我的核心感受是不要追求特征数量多而要追求每个特征都能和心跳的生理含义对上号。R 峰对应的就是心率频谱熵对应的就是节律规则的紊乱程度波形斜率对应的就是电信号传导的异常。当你构造的每个特征都能讲清楚它代表什么生理含义时模型的稳定性会远超那些盲目堆特征的做法。最后再分享一个额外的小技巧特征工程做完后把构造好的特征文件缓存下来csv 或 parquet。心跳信号的 205 个原始点经过特征工程后也就一两百个特征体积不大但缓存之后你后续调模型参数、尝试不同算法时就不用反复重新计算特征了能省下大量时间。我后面几轮的策略调整都是基于这份缓存的特征文件做的整体效率翻了一倍不止。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进