讲个我自己的经历。去年打某个心跳信号分类的比赛,一开始我直接把手里的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[f'quantile_{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 : (i+1)*window_size] features[f'win{i}_mean'] = np.mean(seg) features[f'win{i}_std'] = np.std(seg) features[f'win{i}_max'] = np.max(seg) features[f'win{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[f'band_{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', level=4) for i, coeff in enumerate(coeffs): features[f'wavelet_level{i}_energy'] = np.sum(np.square(coeff)) features[f'wavelet_level{i}_std'] = np.std(coeff) features[f'wavelet_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_peaks:
from scipy.signal import find_peaks def r_peak_features(signal, distance=10, height=None): features = {} # 用绝对值的平滑版本找峰 smooth = np.convolve(np.abs(signal), np.ones(3)/3, mode='same') if height is None: height = np.mean(smooth) + 0.5 * np.std(smooth) peaks, properties = find_peaks(smooth, distance=distance, height=height) 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 featuresdistance=10这个参数很关键,它规定了两个 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, threshold=0.95): corr_matrix = df.corr().abs() upper = corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool)) to_drop = [column for column in upper.columns if any(upper[column] > threshold)] return df.drop(columns=to_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 Technique(SMOTE)做数据增强后再做特征验证;
- 重点关注低频类别上特征的稳定性,而不仅仅看整体 AUC。
还有一个实战技巧:把所有特征的分布按类别画出来对比。如果某个特征在少数类上的分布和多数类差异巨大,但少数类样本量又很小,要格外谨慎。这种特征要么是发现了真正的生理规律,要么只是因为样本量太小导致的随机波动。判断方法是去看医学文献或者抽样更多数据验证,单纯靠交叉验证分数是不足以说明问题的。
心跳信号分类的特征工程这一块,我的核心感受是:不要追求特征数量多,而要追求每个特征都能和"心跳的生理含义"对上号。R 峰对应的就是心率,频谱熵对应的就是节律规则的紊乱程度,波形斜率对应的就是电信号传导的异常。当你构造的每个特征都能讲清楚它代表什么生理含义时,模型的稳定性会远超那些盲目堆特征的做法。
最后再分享一个额外的小技巧:特征工程做完后,把构造好的特征文件缓存下来(csv 或 parquet)。心跳信号的 205 个原始点经过特征工程后也就一两百个特征,体积不大,但缓存之后你后续调模型参数、尝试不同算法时就不用反复重新计算特征了,能省下大量时间。我后面几轮的策略调整,都是基于这份缓存的特征文件做的,整体效率翻了一倍不止。