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的计算资源和内存有限。

我建议你按这个优先级来选:

  1. 先上时域特征:均值、方差计算量极小,峰值和过零率也很轻量。这四个特征加起来,在Cortex-M4上跑一次不到1ms。
  2. 再考虑频域特征:如果时域特征区分度不够(比如区分两种振动模式),再上FFT。128点FFT在Cortex-M4上大约需要2-3ms。
  3. 控制特征总数:我一般控制在10-20个特征以内。特征太多,模型容易过拟合,而且推理时间会变长。

最后提醒一句:FFT对窗口大小有要求——必须是2的幂次(64、128、256等)。如果你的滑动窗口大小不是2的幂次,要么补零(zero-padding),要么调整窗口大小。我曾经因为窗口设了100点,直接扔进FFT,结果算出来的频谱全是错的——因为标准FFT算法要求输入长度是2的幂次。

嗯,时间序列分析的基础就这些。下一章咱们会把这些特征组合起来,真正用到传感器数据融合的实战中去。到时候你会发现,这些看似简单的特征,组合起来能解决很多实际问题。