第四章 特征工程:时域特征提取与频域分析

各位同学,欢迎来到特征工程这一章。说实话,很多做手势识别的朋友,算法选得挺花哨,模型也堆得挺深,但最后效果就是上不去。问题出在哪?十有八九是特征没提好。

我个人的经验是:特征工程做得好,朴素贝叶斯也能打赢花里胡哨的神经网络。这不是夸张,我在好几个项目里都验证过。今天咱们就把时域特征、频域特征、特征选择与降维,一个一个掰开了讲清楚。

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 特征选择与降维

好,现在你有时域特征、频域特征,加起来可能三四十个。全扔给模型?别急,先做做减法。

特征太多会带来三个问题:

  1. 计算量大:手环电池扛不住
  2. 过拟合:模型记住了噪声
  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 本章小结

特征工程这块,说白了就是「从原始数据里挖金子」。时域特征告诉你手势长什么样,频域特征告诉你手势抖得怎么样,特征选择帮你把没用的石头扔掉。

我个人觉得,花在特征工程上的时间,永远值得。你想想看,一个精心设计的特征,可能比多加两层神经网络还管用。而且特征工程做得好,模型可以做得更小、更快、更省电——这对嵌入式设备来说,太重要了。

下一章咱们讲分类器设计,到时候你会看到,好的特征配上合适的分类器,效果能有多惊艳。