4. 数据预处理与特征工程:滑动窗口滤波、归一化、FFT特征提取、时域/频域特征选择
传感器数据进了ADC,拿到了原始值,然后呢?直接扔给模型?
别急。我见过太多新手把原始ADC值直接塞进神经网络,结果模型要么不收敛,要么推理结果飘忽不定。说白了,传感器数据是脏的、乱的、带噪声的。你得先把它收拾干净,再提炼出有用的特征,模型才能好好干活。
这一章,我们就来聊聊数据预处理和特征工程。这是从原始数据到有效特征的关键一步,也是嵌入式AI部署中最容易被低估的环节。
4.1 滑动窗口滤波:把毛刺磨平
传感器数据最烦人的是什么?噪声。尤其是加速度计、陀螺仪这类MEMS传感器,手一抖,数据就跳得跟心电图似的。
滑动窗口滤波,说白了就是取最近N个点的平均值,代替当前点的值。简单、高效、适合MCU。
来看代码实现。这是我在STM32上实际跑过的版本:
// 滑动窗口滤波 - 环形缓冲区实现
#define WINDOW_SIZE 10
typedef struct {
float buffer[WINDOW_SIZE];
uint8_t index;
float sum;
uint8_t count;
} SlidingWindowFilter;
void filter_init(SlidingWindowFilter *f) {
f->index = 0;
f->sum = 0.0f;
f->count = 0;
memset(f->buffer, 0, sizeof(f->buffer));
}
float filter_update(SlidingWindowFilter *f, float new_sample) {
// 减去最旧的值
f->sum -= f->buffer[f->index];
// 存入新值
f->buffer[f->index] = new_sample;
f->sum += new_sample;
f->index = (f->index + 1) % WINDOW_SIZE;
if (f->count < WINDOW_SIZE) f->count++;
// 返回平均值
return f->sum / f->count;
}
为什么用环形缓冲区?因为每次移动窗口只需要做一次加法和一次减法,计算量恒定。你想想看,如果每次重新求和,窗口越大计算量越大,MCU扛不住的。
4.2 归一化:把数据拉到同一个尺度
归一化,就是把不同量纲的数据映射到同一个范围。比如加速度计输出是±2g,温度传感器输出是0-100°C,你不归一化,模型就会偏向数值大的特征。
常用的归一化方法有两种:
| 方法 | 公式 | 适用场景 | MCU开销 |
|---|---|---|---|
| Min-Max归一化 | x' = (x - min) / (max - min) | 数据范围已知且固定 | 低(只需乘加) |
| Z-score标准化 | x' = (x - μ) / σ | 数据分布未知,有异常值 | 中(需预存μ和σ) |
在嵌入式场景下,我强烈推荐Min-Max归一化。为什么?因为传感器的量程是固定的,min和max可以预先算好,存成常量。运行时只需要一次减法和一次乘法,连除法都可以优化成移位。
// Min-Max归一化 - 定点数优化版本
// 假设加速度计范围 ±2g,输出 0-4095 (12位ADC)
#define ACCEL_MIN 0
#define ACCEL_MAX 4095
#define SCALE_FACTOR (1.0f / (ACCEL_MAX - ACCEL_MIN))
float normalize_accel(uint16_t raw) {
// 归一化到 [0, 1]
return (raw - ACCEL_MIN) * SCALE_FACTOR;
}
// 如果需要归一化到 [-1, 1]
float normalize_accel_signed(uint16_t raw) {
float normalized = (raw - ACCEL_MIN) * SCALE_FACTOR; // [0, 1]
return normalized * 2.0f - 1.0f; // [-1, 1]
}
4.3 FFT特征提取:看到时域看不到的东西
有些特征在时域里是看不出来的。比如一个振动信号,时域上看就是一堆上下跳动的点,但转到频域,你就能看到它在50Hz和120Hz有峰值——这可能是工频干扰和机械共振。
FFT(快速傅里叶变换)就是把信号从时域变换到频域的工具。在STM32上做FFT,我一般用CMSIS-DSP库,它针对Cortex-M内核做了优化,比你自己手写的快得多。
CMSIS-DSP库的FFT使用示例:
#include "arm_math.h"
#define FFT_SIZE 256
#define SAMPLING_RATE 1000.0f // 1kHz采样率
// FFT输入输出缓冲区
float32_t input[FFT_SIZE * 2]; // 实部+虚部交错存储
float32_t output[FFT_SIZE / 2]; // 幅值谱
void compute_fft(float32_t *time_domain_data) {
// 1. 填充输入缓冲区(实部放数据,虚部清零)
for (int i = 0; i < FFT_SIZE; i++) {
input[2 * i] = time_domain_data[i]; // 实部
input[2 * i + 1] = 0.0f; // 虚部
}
// 2. 执行FFT
arm_cfft_f32(&arm_cfft_sR_f32_len256, input, 0, 1);
// 3. 计算幅值谱
arm_cmplx_mag_f32(input, output, FFT_SIZE / 2);
// 4. 归一化(幅值除以N/2)
for (int i = 0; i < FFT_SIZE / 2; i++) {
output[i] /= (FFT_SIZE / 2);
}
}
// 获取某个频率对应的幅值
float get_frequency_magnitude(float freq) {
int bin = (int)(freq * FFT_SIZE / SAMPLING_RATE);
if (bin >= FFT_SIZE / 2) return 0.0f;
return output[bin];
}
从FFT结果中,我们可以提取哪些特征?
- 频谱峰值:最大幅值对应的频率
- 频谱质心:能量集中的频率区域
- 频带能量:特定频段(如低频、中频、高频)的能量和
- 频谱平坦度:信号是集中在几个频率还是分布广泛
4.4 时域/频域特征选择:去粗取精
特征提取完了,你可能得到几十甚至上百个特征。但MCU的资源有限,不能全用。特征选择就是从中挑出最有用的几个。
常用的时域特征:
| 特征 | 计算公式 | 物理含义 |
|---|---|---|
| 均值 | μ = (1/N) Σx_i | 信号的直流分量 |
| 方差 | σ² = (1/N) Σ(x_i - μ)² | 信号的波动程度 |
| 均方根 | RMS = sqrt((1/N) Σx_i²) | 信号的有效能量 |
| 峰值因子 | Crest = peak / RMS | 信号是否有冲击 |
| 峭度 | Kurtosis = (1/N) Σ((x_i - μ)/σ)⁴ | 信号分布的尖锐程度 |
常用的频域特征:
| 特征 | 计算方法 | 适用场景 |
|---|---|---|
| 频谱峰值频率 | 幅值最大处对应的频率 | 旋转机械故障诊断 |
| 频带能量比 | 某频段能量 / 总能量 | 区分不同运行模式 |
| 频谱熵 | -Σ(p_i * log(p_i)),p_i为归一化幅值 | 信号复杂度评估 |
特征选择的方法,我推荐两种适合MCU的:
- 方差过滤法:计算每个特征的方差,方差太小的特征直接扔掉(因为所有样本都差不多,没有区分度)。
- 相关性分析法:计算特征与目标变量之间的相关系数,只保留相关性高的特征。
最终,你的特征向量可能只有5-10个维度。别嫌少,我做过一个手势识别项目,只用6个特征(3个时域+3个频域),在STM32F103上跑KNN,准确率达到了94%。
嗯,数据预处理和特征工程就聊到这里。记住一句话:垃圾进,垃圾出。花时间把数据收拾干净、把特征选好,后面的模型训练和部署会轻松很多。