3、特征工程入门:时间序列特征提取、统计特征计算、特征选择方法
说实话,很多做IoT安全的朋友,拿到传感器数据就往模型里灌。结果呢?准确率上不去,误报率下不来。我早期也犯过这个错。后来才明白——特征工程才是决定模型上限的关键。
你想想看,原始数据里藏着大量噪声和冗余信息。模型不是神仙,它分不清哪些是真正的异常信号,哪些是传感器抖动。所以,我们需要把原始数据“翻译”成模型能理解的语言。这就是特征工程要做的事。
核心观点:数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限。
这一章,我会带你走完三个关键步骤:时间序列特征提取、统计特征计算、特征选择方法。每一步我都会结合IoT场景讲,还会分享一些我踩过的坑。
3.1 时间序列特征提取
IoT数据最典型的特征就是——它是随时间变化的。温度、湿度、电压、振动频率……这些传感器读数构成了一个时间序列。但原始序列太长、太密,模型消化不了。我们需要从中提炼出有代表性的特征。
3.1.1 滑动窗口统计量
这是最基础也最实用的方法。我习惯用一个固定大小的窗口在时间轴上滑动,每滑一步,就计算窗口内的统计值。
举个例子,你有一个温度传感器,每秒上报一次数据。你可以设置窗口大小为10秒,步长为1秒。这样每个窗口覆盖最近10个数据点,然后计算均值、最大值、最小值、标准差等。
import pandas as pd
import numpy as np
# 模拟传感器数据
np.random.seed(42)
timestamps = pd.date_range('2024-01-01', periods=1000, freq='S')
values = 25 + np.random.randn(1000) * 2 # 温度数据,均值25°C
df = pd.DataFrame({'timestamp': timestamps, 'temperature': values})
# 滑动窗口特征
window_size = 10
df['temp_mean'] = df['temperature'].rolling(window=window_size).mean()
df['temp_std'] = df['temperature'].rolling(window=window_size).std()
df['temp_max'] = df['temperature'].rolling(window=window_size).max()
df['temp_min'] = df['temperature'].rolling(window=window_size).min()
df['temp_range'] = df['temp_max'] - df['temp_min']
print(df.head(15))
我的经验:窗口大小怎么选?我一般先看业务场景。如果是检测突发的设备故障,窗口要小(3-5个点)。如果是检测缓慢的性能退化,窗口可以大一些(30-60个点)。没有标准答案,多试几个值。
3.1.2 差分特征
差分就是计算相邻时间点的差值。它能反映数据的变化趋势和变化速度。在IoT异常检测中,差分特征特别有用——很多攻击会导致数据突然跳变,差分值会瞬间变大。
# 一阶差分:当前值减去上一个值
df['temp_diff_1'] = df['temperature'].diff(1)
# 二阶差分:变化率的变化率
df['temp_diff_2'] = df['temperature'].diff(1).diff(1)
# 百分比变化
df['temp_pct_change'] = df['temperature'].pct_change() * 100
print(df[['temperature', 'temp_diff_1', 'temp_diff_2', 'temp_pct_change']].head(10))
注意:差分后数据会减少。一阶差分少1个,二阶差分少2个。处理时记得对齐索引,或者用fillna填充。我曾经因为没处理好这个,导致模型训练时特征维度对不上,排查了半天。
3.1.3 频域特征(FFT)
有些异常在时域里不明显,但在频域里一目了然。比如电机正常运行时振动频率是50Hz,如果出现异常摩擦,会产生高频谐波。这时候就需要做傅里叶变换。
from scipy.fft import fft, fftfreq
# 取一个窗口的数据做FFT
window_data = df['temperature'].values[:128] # 取128个点
N = len(window_data)
T = 1.0 # 采样间隔1秒
yf = fft(window_data)
xf = fftfreq(N, T)[:N//2]
# 提取频域特征
amplitude = 2.0/N * np.abs(yf[:N//2])
dominant_freq = xf[np.argmax(amplitude)]
energy = np.sum(amplitude**2)
print(f"主频: {dominant_freq:.2f} Hz")
print(f"频域能量: {energy:.2f}")
我个人习惯提取这几个频域特征:主频、幅值、频谱能量、频谱熵。它们能很好地刻画信号的周期性特征。
3.2 统计特征计算
统计特征是最直观、最稳定的特征。不管模型多复杂,这些基础统计量永远是第一道防线。
3.2.1 基本统计量
| 特征名称 | 计算公式 | IoT场景意义 |
|---|---|---|
| 均值 | μ = (1/n) Σxᵢ | 传感器基线值,判断整体偏移 |
| 标准差 | σ = √[(1/n) Σ(xᵢ-μ)²] | 数据波动程度,异常时通常增大 |
| 最小值/最大值 | min(x), max(x) | 检测越界异常 |
| 中位数 | 排序后中间值 | 抗噪能力强,比均值更鲁棒 |
| 四分位距 | IQR = Q3 - Q1 | 数据离散程度,不受极端值影响 |
# 批量计算统计特征
def extract_statistical_features(series):
features = {
'mean': np.mean(series),
'std': np.std(series),
'min': np.min(series),
'max': np.max(series),
'median': np.median(series),
'q1': np.percentile(series, 25),
'q3': np.percentile(series, 75),
'iqr': np.percentile(series, 75) - np.percentile(series, 25),
'range': np.max(series) - np.min(series),
'cv': np.std(series) / np.mean(series) # 变异系数
}
return features
# 对每个窗口计算
window_features = df['temperature'].rolling(10).apply(
lambda x: extract_statistical_features(x)['mean']
)
print(window_features.head(15))
3.2.2 高阶统计量
除了基本统计量,我还常用两个高阶特征:偏度和峰度。
- 偏度(Skewness):衡量数据分布的不对称性。正常传感器数据通常接近正态分布,偏度接近0。异常发生时,分布会偏斜。
- 峰度(Kurtosis):衡量数据分布的“尖峭”程度。异常数据往往有更多极端值,峰度会变大。
from scipy.stats import skew, kurtosis
df['skewness'] = df['temperature'].rolling(20).apply(skew)
df['kurtosis'] = df['temperature'].rolling(20).apply(kurtosis)
print(df[['temperature', 'skewness', 'kurtosis']].dropna().head(10))
避坑指南:偏度和峰度对异常值非常敏感。如果你的数据本身就有很多噪声点,这两个特征可能会“假报警”。我一般会先做一次简单的异常值截断(比如3σ原则),再计算偏度和峰度。
3.3 特征选择方法
特征提取完了,你可能得到几十甚至上百个特征。但并不是越多越好。冗余特征会拖慢训练速度,还会导致过拟合。特征选择就是帮你“去粗取精”。
3.3.1 过滤法(Filter)
过滤法不依赖任何模型,纯粹从数据本身出发。计算每个特征与目标变量之间的相关性,然后排序筛选。
我常用的指标:
- 皮尔逊相关系数:衡量线性关系,值在[-1, 1]之间
- 互信息:能捕捉非线性关系,比相关系数更全面
- 方差阈值:方差太小的特征基本没信息量,直接扔掉
from sklearn.feature_selection import SelectKBest, f_classif, mutual_info_classif
import pandas as pd
# 假设我们已经提取了多个特征
feature_cols = ['temp_mean', 'temp_std', 'temp_max', 'temp_min',
'temp_range', 'temp_diff_1', 'skewness', 'kurtosis']
X = df[feature_cols].dropna()
y = (df['temperature'] > 27).astype(int).loc[X.index] # 模拟标签:是否高温异常
# 使用F检验选择Top-5特征
selector = SelectKBest(score_func=f_classif, k=5)
X_selected = selector.fit_transform(X, y)
# 查看特征得分
scores = pd.DataFrame({
'feature': feature_cols,
'f_score': selector.scores_
}).sort_values('f_score', ascending=False)
print(scores)
3.3.2 包裹法(Wrapper)
包裹法把特征选择看作一个搜索问题。它用模型的表现来评估特征子集的好坏。典型代表是递归特征消除(RFE)。
说白了,就是反复训练模型,每次去掉最不重要的特征,直到达到你想要的特征数量。
from sklearn.feature_selection import RFE
from sklearn.ensemble import RandomForestClassifier
# 使用随机森林作为评估模型
model = RandomForestClassifier(n_estimators=100, random_state=42)
rfe = RFE(estimator=model, n_features_to_select=5)
rfe.fit(X, y)
# 查看哪些特征被选中
selected_features = [feature_cols[i] for i in range(len(feature_cols)) if rfe.support_[i]]
ranking = pd.DataFrame({
'feature': feature_cols,
'selected': rfe.support_,
'rank': rfe.ranking_
}).sort_values('rank')
print(ranking)
性能警告:包裹法计算量很大。如果你有100个特征,RFE可能要训练100次模型。我建议先用过滤法粗筛到20-30个特征,再用包裹法精挑。别一上来就全量跑,会等到怀疑人生。
3.3.3 嵌入法(Embedded)
嵌入法把特征选择嵌入到模型训练过程中。最典型的就是L1正则化(Lasso)和树模型的特征重要性。
我个人最喜欢用随机森林的特征重要性。它训练一次就能给出所有特征的排名,效率高,效果也不错。
from sklearn.ensemble import RandomForestClassifier
# 训练随机森林
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X, y)
# 获取特征重要性
importance = pd.DataFrame({
'feature': feature_cols,
'importance': rf.feature_importances_
}).sort_values('importance', ascending=False)
print(importance)
# 选择重要性累计达到90%的特征
cumsum = importance['importance'].cumsum()
selected_embedded = importance[cumsum <= 0.9]['feature'].tolist()
print(f"\n嵌入法选中的特征: {selected_embedded}")
3.4 三种方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 过滤法 | 速度快、独立于模型 | 忽略特征间交互 | 特征数量大(>100)时的初筛 |
| 包裹法 | 考虑特征组合、精度高 | 计算开销大、易过拟合 | 特征数量少(<20)时的精挑 |
| 嵌入法 | 效率与效果平衡 | 依赖模型选择 | 大多数场景的首选 |
我的建议:实际项目中,我不会只用一种方法。通常的流程是:先用过滤法快速砍掉明显无用的特征,然后用嵌入法(比如随机森林重要性)进一步筛选,最后如果特征数量还多,再用包裹法做最终确认。三层过滤,基本不会漏掉重要特征。
嗯,特征工程这部分内容确实不少。但你别急,这些方法不是要你一次性全用上。先从滑动窗口和基本统计量开始,跑通一个基线模型。然后慢慢加频域特征、高阶统计量。最后用特征选择方法做减法。一步一步来,你会发现模型效果在稳步提升。
我曾经在一个智能电表项目中,只用了均值、标准差、差分三个特征,就检测出了90%的异常用电行为。有时候,少即是多。