第四章 特征工程:时域特征提取与频域分析
各位同学,欢迎来到特征工程这一章。说实话,很多做手势识别的朋友,算法选得挺花哨,模型也堆得挺深,但最后效果就是上不去。问题出在哪?十有八九是特征没提好。
我个人的经验是:特征工程做得好,朴素贝叶斯也能打赢花里胡哨的神经网络。这不是夸张,我在好几个项目里都验证过。今天咱们就把时域特征、频域特征、特征选择与降维,一个一个掰开了讲清楚。
4.1 时域特征:坐标、速度、加速度
时域特征,说白了就是直接从触摸轨迹上算出来的统计量。你想想看,一个手指在屏幕上划过去,留下了什么?坐标点序列。这就是最原始的信号。
4.1.1 坐标特征
原始坐标点 (x, y) 本身就能提取不少信息。我一般会算这几样:
- 轨迹长度:所有相邻点欧氏距离之和。手势大不大,看这个。
- 起点终点距离:手势的位移量。比如画圈,起点终点近,这个值就小。
- 包围盒面积:x和y方向最大最小值的差乘积。手势的幅度。
- 曲率变化:相邻线段夹角的变化量。画直线时曲率变化小,画波浪线时大。
重点提醒:坐标特征对屏幕分辨率敏感。同一个手势,在320x240和480x320的屏幕上,数值完全不同。我建议做归一化,把坐标映射到[0,1]区间。
4.1.2 速度特征
速度怎么算?很简单,相邻两帧的位移除以时间间隔。但这里有个坑——原始速度噪声很大。
我曾经在一个项目里,直接用原始速度做特征,结果模型训练死活不收敛。后来发现,手指轻微抖动都会产生速度尖峰。怎么办?加个滑动平均滤波。
// 速度计算与平滑
#define WINDOW_SIZE 5
float smooth_velocity(float* raw_vel, int len) {
float sum = 0;
int count = 0;
for (int i = len - WINDOW_SIZE; i < len; i++) {
if (i >= 0) {
sum += raw_vel[i];
count++;
}
}
return sum / count;
}
速度特征我常用这几个:
- 平均速度:整体快慢
- 最大速度:手势中最快的那一下
- 速度方差:速度波动程度。匀速画线和先快后慢,方差差别很大
- 速度过零率:速度方向改变的次数。这个对区分来回摆动的手势很有用
4.1.3 加速度特征
加速度是速度的导数。嗯,这里要注意,求导会放大噪声。所以加速度特征我一般会先做平滑,再提取。
我个人习惯用加速度的模长,也就是 sqrt(ax² + ay²)。为什么?因为方向信息在坐标和速度里已经有了,加速度模长更能反映「用力程度」。
| 特征名称 | 计算公式 | 物理意义 |
|---|---|---|
| 加速度均值 | mean(|a|) | 平均用力大小 |
| 加速度峰值 | max(|a|) | 最大冲击力 |
| 加速度均方根 | sqrt(mean(a²)) | 能量指标 |
| 加速度熵 | -sum(p*log(p)) | 复杂度度量 |
小技巧:加速度熵这个特征,很多人会忽略。但我发现它在区分「随意画」和「刻意画」的手势时,效果出奇的好。比如随手一划和认真画个五角星,加速度熵值能差好几倍。
4.2 频域特征:FFT 实战
时域特征能捕捉手势的「形状」,但有些信息藏在频率里。比如手指抖动的频率、画圈的速度节奏,这些在时域里很难看出来。
FFT(快速傅里叶变换)就是把时域信号转到频域。说白了,就是看看你的手势里,哪些频率成分比较强。
4.2.1 FFT 的嵌入式实现
在 PC 上做 FFT 很简单,调个库就行。但在手环这种资源受限的设备上,你得自己掂量掂量。
我推荐用基2时间抽取FFT,代码量小,内存占用也少。下面是一个定点数实现的例子:
// 定点数 FFT,输入输出均为 Q15 格式
void fft_q15(int16_t* real, int16_t* imag, int n) {
// 位反转排序
int j = 0;
for (int i = 0; i < n - 1; i++) {
if (i < j) {
swap(&real[i], &real[j]);
swap(&imag[i], &imag[j]);
}
int k = n >> 1;
while (k <= j) {
j -= k;
k >>= 1;
}
j += k;
}
// 蝶形运算
for (int len = 2; len <= n; len <<= 1) {
int half = len >> 1;
for (int i = 0; i < n; i += len) {
for (int j = 0; j < half; j++) {
// 旋转因子查表
int16_t w_real = cos_table[j * n / len];
int16_t w_imag = -sin_table[j * n / len];
// 蝶形计算
int16_t t_real = (real[i+j+half] * w_real - imag[i+j+half] * w_imag) >> 15;
int16_t t_imag = (real[i+j+half] * w_imag + imag[i+j+half] * w_real) >> 15;
real[i+j+half] = real[i+j] - t_real;
imag[i+j+half] = imag[i+j] - t_imag;
real[i+j] += t_real;
imag[i+j] += t_imag;
}
}
}
}
注意:上面这个代码用了查表法,旋转因子提前算好存起来。为什么?因为实时计算 sin/cos 太慢了,手环的 MCU 扛不住。我见过有人现场算旋转因子,结果一帧数据算完,下一帧都来了——这肯定不行。
4.2.2 频域特征提取
做完 FFT,你得到的是复数序列。怎么变成特征?
- 频谱能量:各频率分量幅值的平方和。手势越「剧烈」,能量越大。
- 主频:幅值最大的频率。比如快速点击,主频就高;慢速滑动,主频就低。
- 频谱质心:频率的加权平均。反映能量集中在高频还是低频。
- 频谱带宽:能量分布的范围。窄带宽说明手势很规律,宽带宽说明手势杂乱。
我一般只取前8-16个频点做特征。为什么?因为手势信号的频率范围本来就不宽,取太多反而引入噪声。
4.3 特征选择与降维
好,现在你有时域特征、频域特征,加起来可能三四十个。全扔给模型?别急,先做做减法。
特征太多会带来三个问题:
- 计算量大:手环电池扛不住
- 过拟合:模型记住了噪声
- 维度灾难:特征越多,需要的训练数据指数级增长
4.3.1 特征选择方法
我常用的方法有三种:
1. 方差过滤法
算每个特征的方差,方差太小的直接扔掉。为什么?方差小说明这个特征在所有手势里都差不多,没有区分度。
2. 相关系数法
算特征与标签的相关系数。比如「加速度峰值」和「用力手势」的相关系数高,那就保留。相关系数低于0.1的,我一般会考虑去掉。
3. 递归特征消除
这个稍微复杂点。先用所有特征训练模型,然后看每个特征的重要性,去掉最不重要的那个,再训练...反复迭代。虽然慢,但效果最好。
我的经验:在手环项目里,我通常先用方差过滤砍掉一半特征,再用相关系数法筛一遍,最后剩下10-15个特征。这个数量级,模型跑起来又快又准。
4.3.2 PCA 降维实战
特征选择是「挑」,降维是「融合」。PCA(主成分分析)就是找到几个新的方向,让数据投影上去后方差最大。
但 PCA 有个问题——计算量太大。协方差矩阵、特征值分解,这些在 PC 上没问题,在手环上就够呛。
我建议的做法是:在 PC 上算好投影矩阵,然后固化成常量数组,烧到手环里。手环端只需要做矩阵乘法就行。
// 手环端 PCA 降维,仅需矩阵乘法
// proj_matrix 是 PC 端算好的 10x15 投影矩阵
// features 是原始 15 维特征向量
// result 是降维后的 10 维特征
void pca_transform(const int16_t* features, int16_t* result) {
for (int i = 0; i < 10; i++) {
int32_t sum = 0;
for (int j = 0; j < 15; j++) {
sum += (int32_t)features[j] * proj_matrix[i][j];
}
result[i] = (int16_t)(sum >> 12); // 定点数缩放
}
}
避坑指南:我曾经在 PCA 降维后,发现识别率反而下降了。排查了半天,原来是投影矩阵的精度不够。PC 端算的是 float,我直接截断成 int16,信息损失太大。后来改成 Q15 定点数格式,问题就解决了。
4.4 本章小结
特征工程这块,说白了就是「从原始数据里挖金子」。时域特征告诉你手势长什么样,频域特征告诉你手势抖得怎么样,特征选择帮你把没用的石头扔掉。
我个人觉得,花在特征工程上的时间,永远值得。你想想看,一个精心设计的特征,可能比多加两层神经网络还管用。而且特征工程做得好,模型可以做得更小、更快、更省电——这对嵌入式设备来说,太重要了。
下一章咱们讲分类器设计,到时候你会看到,好的特征配上合适的分类器,效果能有多惊艳。