4. 时间序列分析基础:滑动窗口、特征提取与频域分析
好,咱们进入正题。传感器数据融合,说白了就是跟一堆连续的数字打交道。这些数字按时间排成一串,就是时间序列。你想想看,一个加速度计每秒输出几百个数据点,你要是直接拿原始数据去训练模型,那计算量能把单片机撑爆。
所以我们需要一套方法,把原始数据压缩成有用的信息。这就是时间序列分析要做的事。我个人习惯把它分成三步:切数据、算特征、看频域。咱们一个一个来。
4.1 滑动窗口:把连续数据切成块
原始数据是源源不断的流。你不能把整个流都塞进模型。怎么办?切块。就像看电影,你不能一帧一帧地看,得按场景来理解。
滑动窗口就是干这个的。它像一个固定大小的框,在时间轴上一步步往前挪。每次框住一段数据,我们就分析这一段。
核心参数就两个:
- 窗口大小(Window Size):每次框住多少个采样点。比如100个点。
- 步长(Step Size / Stride):每次往前挪多少点。步长=窗口大小时,窗口不重叠;步长<窗口大小时,窗口有重叠。
我在项目中遇到过一个问题:步长设太大,会漏掉关键动作的过渡信息;步长设太小,数据量暴增,MCU扛不住。后来我总结了一个经验——步长一般取窗口大小的25%~50%,既保证信息不丢失,又控制数据量。
来看一段伪代码,帮你理解滑动窗口怎么工作:
// 伪代码:滑动窗口提取
#define WINDOW_SIZE 100
#define STEP_SIZE 50
float buffer[WINDOW_SIZE]; // 当前窗口数据
int buffer_index = 0;
void process_new_sample(float new_sample) {
buffer[buffer_index] = new_sample;
buffer_index++;
if (buffer_index >= WINDOW_SIZE) {
// 窗口满了,提取特征
extract_features(buffer, WINDOW_SIZE);
// 滑动:丢掉前STEP_SIZE个数据,把后面的往前挪
memmove(buffer, buffer + STEP_SIZE,
(WINDOW_SIZE - STEP_SIZE) * sizeof(float));
buffer_index = WINDOW_SIZE - STEP_SIZE;
}
}
我的小技巧:在嵌入式上,别用动态内存分配。提前开好环形缓冲区(ring buffer),效率高得多。我曾经因为用malloc分配窗口缓冲区,导致系统跑着跑着就崩了——嵌入式环境最忌讳不确定的内存分配。
4.2 时域特征提取:从波形里抓关键数字
窗口切好了,接下来要算特征。时域特征,就是直接在原始数值上算统计量。我常用的有四个:均值、方差、峰值、过零率。
4.2.1 均值(Mean)
最简单,也最基础。均值反映信号的直流分量或整体趋势。比如静止时加速度均值接近1g(重力加速度),运动时均值会偏移。
float mean(float* data, int len) {
float sum = 0;
for (int i = 0; i < len; i++) sum += data[i];
return sum / len;
}
4.2.2 方差(Variance)
方差反映信号的波动程度。你想想看,静止时方差很小,剧烈运动时方差很大。这个特征在区分「静止」和「运动」时特别好用。
float variance(float* data, int len, float mean_val) {
float sum_sq = 0;
for (int i = 0; i < len; i++) {
float diff = data[i] - mean_val;
sum_sq += diff * diff;
}
return sum_sq / len;
}
注意:在嵌入式上,方差计算涉及平方运算,数据范围可能很大。我建议用移动平均法递推计算方差,避免一次性计算带来的数值溢出。曾经有个项目,因为方差计算时int16溢出,导致模型判断完全错误——排查了两天才找到原因。
4.2.3 峰值(Peak Value)
峰值就是窗口内的最大值和最小值。它反映信号的幅度范围。比如走路时加速度峰值大约±2g,跑步时可能到±4g。
void peak(float* data, int len, float* max_val, float* min_val) {
*max_val = data[0];
*min_val = data[0];
for (int i = 1; i < len; i++) {
if (data[i] > *max_val) *max_val = data[i];
if (data[i] < *min_val) *min_val = data[i];
}
}
4.2.4 过零率(Zero Crossing Rate, ZCR)
过零率统计信号穿过零点的次数。说白了就是信号正负变化的频繁程度。语音识别里常用,在传感器数据里,它反映振动的频率特性。
int zero_crossing_rate(float* data, int len) {
int count = 0;
for (int i = 1; i < len; i++) {
// 符号变化就算一次过零
if ((data[i] >= 0 && data[i-1] < 0) ||
(data[i] < 0 && data[i-1] >= 0)) {
count++;
}
}
return count;
}
避坑指南:过零率对噪声非常敏感。我曾经在采集振动数据时,因为传感器噪声导致过零率异常高。解决办法是加一个小的死区阈值——只有信号绝对值超过某个小值(比如0.05g)才认为有效过零。这样能过滤掉噪声引起的虚假过零。
4.3 频域特征:用FFT看信号的「频谱」
时域特征只能看到信号的整体统计量,但看不到信号的频率成分。比如一个1Hz的慢速摆动和一个10Hz的快速振动,在时域上均值方差可能差不多,但频域上一眼就能区分。
FFT(快速傅里叶变换)就是把时域信号转换到频域。说白了,它告诉你信号里包含哪些频率成分,每个频率的强度有多大。
4.3.1 FFT的基本概念
FFT的输入是N个时域采样点,输出是N个复数,代表不同频率的幅度和相位。我们通常只关心幅度谱。
有几个关键参数你得知道:
| 参数 | 含义 | 例子 |
|---|---|---|
| 采样率(Fs) | 每秒采集多少个点 | 100 Hz |
| FFT点数(N) | 一次FFT用多少个点 | 64、128、256 |
| 频率分辨率 | 能区分的最小频率差 | Fs / N = 100/128 ≈ 0.78 Hz |
| 奈奎斯特频率 | 能分析的最高频率 | Fs / 2 = 50 Hz |
重要结论:FFT点数N越大,频率分辨率越高,但计算量也越大。在TinyML场景下,我一般用64或128点FFT。256点以上对大多数MCU来说就有点吃力了。
4.3.2 嵌入式上的FFT实现
别自己写FFT算法。CMSIS-DSP库、ARM的DSP库、或者ESP32的ESP-DSP库都有现成的。直接用就好。
// 以CMSIS-DSP为例,做128点FFT
#include "arm_math.h"
#define FFT_SIZE 128
float32_t input[FFT_SIZE * 2]; // 实部和虚部交错存放
float32_t output[FFT_SIZE]; // 幅度谱
void compute_fft(float32_t* time_data) {
// 1. 填充输入:实部放数据,虚部清零
for (int i = 0; i < FFT_SIZE; i++) {
input[2*i] = time_data[i]; // 实部
input[2*i+1] = 0; // 虚部
}
// 2. 执行FFT
arm_cfft_f32(&arm_cfft_sR_f32_len128, input, 0, 1);
// 3. 计算幅度谱
arm_cmplx_mag_f32(input, output, FFT_SIZE);
// 注意:output[0]是直流分量,output[1]到output[FFT_SIZE/2]是有效频率
}
4.3.3 从频谱中提取特征
FFT算完了,你不能直接把128个幅度值都扔给模型。得进一步提取频域特征。我常用的有:
- 频谱质心(Spectral Centroid):能量集中的频率位置。反映信号是「低沉」还是「尖锐」。
- 频谱带宽(Spectral Bandwidth):能量在频率上的分散程度。
- 子带能量(Sub-band Energy):把频谱分成几个频段,算每个频段的能量总和。比如低频段(0-10Hz)、中频段(10-30Hz)、高频段(30-50Hz)。
- 峰值频率(Peak Frequency):幅度最大的频率点。
// 计算子带能量示例
float subband_energy(float* spectrum, int fft_size,
int start_bin, int end_bin) {
float energy = 0;
for (int i = start_bin; i <= end_bin; i++) {
energy += spectrum[i] * spectrum[i];
}
return energy;
}
// 使用:计算0-10Hz的能量(假设Fs=100Hz,FFT_SIZE=128)
// 频率分辨率 = 100/128 ≈ 0.78 Hz
// 0-10Hz对应bin 0到bin 12(10/0.78 ≈ 12.8)
float low_energy = subband_energy(spectrum, 128, 0, 12);
我的经验:在TinyML项目中,我通常只提取3-5个频域特征,而不是把整个频谱都送进去。比如做步态识别,我只关心0-5Hz(走路步频)和5-15Hz(跑步步频)两个子带的能量比。这样既保留了关键信息,又大幅降低了特征维度。
4.4 实战建议:特征选择与计算量权衡
好了,现在你有了时域特征(均值、方差、峰值、过零率)和频域特征(子带能量、频谱质心等)。但你不能全用。为什么?MCU的计算资源和内存有限。
我建议你按这个优先级来选:
- 先上时域特征:均值、方差计算量极小,峰值和过零率也很轻量。这四个特征加起来,在Cortex-M4上跑一次不到1ms。
- 再考虑频域特征:如果时域特征区分度不够(比如区分两种振动模式),再上FFT。128点FFT在Cortex-M4上大约需要2-3ms。
- 控制特征总数:我一般控制在10-20个特征以内。特征太多,模型容易过拟合,而且推理时间会变长。
最后提醒一句:FFT对窗口大小有要求——必须是2的幂次(64、128、256等)。如果你的滑动窗口大小不是2的幂次,要么补零(zero-padding),要么调整窗口大小。我曾经因为窗口设了100点,直接扔进FFT,结果算出来的频谱全是错的——因为标准FFT算法要求输入长度是2的幂次。
嗯,时间序列分析的基础就这些。下一章咱们会把这些特征组合起来,真正用到传感器数据融合的实战中去。到时候你会发现,这些看似简单的特征,组合起来能解决很多实际问题。