第四章:输入数据抗干扰——传感器数据滤波与鲁棒性增强

各位同学,咱们接着往下聊。上一章讲了模型本身的防护,这一章咱们把目光投向数据入口——传感器。说实话,我在嵌入式AI项目里踩过的坑,有一半都出在传感器数据上。你模型训练得再好,推理代码写得再漂亮,传感器给你喂一堆毛刺数据,那结果就是灾难。

我有个习惯:做AI部署之前,先花一周时间把传感器数据摸透。为什么?因为STM32的资源有限,你不可能在芯片上跑复杂的去噪算法。所以,数据抗干扰的第一步,是在数据进入模型之前,把脏东西过滤掉

4.1 传感器数据滤波算法

滤波算法有很多,但在STM32上,我常用的就两种:中值滤波和卡尔曼滤波。别贪多,把这两个吃透,能解决90%的传感器噪声问题。

4.1.1 中值滤波——简单粗暴但有效

中值滤波的原理很简单:取连续N个采样值,排序后取中间那个。它对付脉冲噪声特别管用。比如你用一个超声波传感器测距,偶尔会蹦出一个离谱的值——明明前面是墙,它给你报个10米。这种就是典型的脉冲噪声。

我在项目里遇到过:一个扫地机器人的避障模块,超声波数据经常跳变。一开始我用均值滤波,结果发现均值滤波会把异常值“平均”到正常值里,导致避障反应迟钝。换成中值滤波后,问题立刻解决。

核心要点:中值滤波的窗口大小很关键。窗口太小(比如3个点),滤不掉噪声;窗口太大(比如15个点),延迟太高。我个人建议:对于10Hz的传感器,窗口取5~7个点比较合适

// STM32上的中值滤波实现(窗口大小5)
#define MEDIAN_WINDOW_SIZE 5

float median_filter(float new_sample) {
    static float buffer[MEDIAN_WINDOW_SIZE] = {0};
    static uint8_t index = 0;
    float temp[MEDIAN_WINDOW_SIZE];
    float median;
    
    // 更新环形缓冲区
    buffer[index] = new_sample;
    index = (index + 1) % MEDIAN_WINDOW_SIZE;
    
    // 拷贝到临时数组并排序
    memcpy(temp, buffer, sizeof(temp));
    
    // 简单的冒泡排序(窗口小,够用)
    for(int i = 0; i < MEDIAN_WINDOW_SIZE - 1; i++) {
        for(int j = 0; j < MEDIAN_WINDOW_SIZE - 1 - i; j++) {
            if(temp[j] > temp[j+1]) {
                float t = temp[j];
                temp[j] = temp[j+1];
                temp[j+1] = t;
            }
        }
    }
    
    // 取中值
    median = temp[MEDIAN_WINDOW_SIZE / 2];
    return median;
}

小技巧:如果你对实时性要求高,可以用“快速中值滤波”算法——维护一个有序链表,每次插入新值、删除旧值。不过对于STM32F4以上的芯片,5个点的冒泡排序完全够用,别过度优化。

4.1.2 卡尔曼滤波——状态估计的利器

卡尔曼滤波听起来高大上,其实说白了就是:用上一刻的预测值 + 当前测量值,加权平均得到最优估计。它特别适合处理高斯噪声,比如加速度计、陀螺仪的数据。

我记得第一次在STM32上实现卡尔曼滤波时,被那些矩阵运算搞得头大。后来发现,对于一维传感器(比如温度、距离),卡尔曼滤波可以简化成几个公式,代码量很少。

// 一维卡尔曼滤波实现
typedef struct {
    float Q;  // 过程噪声协方差
    float R;  // 测量噪声协方差
    float x;  // 估计值
    float P;  // 估计误差协方差
    float K;  // 卡尔曼增益
} KalmanFilter_t;

void kalman_init(KalmanFilter_t* kf, float init_value) {
    kf->Q = 0.01f;   // 根据传感器特性调整
    kf->R = 0.1f;    // 根据传感器数据手册调整
    kf->x = init_value;
    kf->P = 1.0f;
}

float kalman_update(KalmanFilter_t* kf, float measurement) {
    // 预测
    kf->P = kf->P + kf->Q;
    
    // 更新
    kf->K = kf->P / (kf->P + kf->R);
    kf->x = kf->x + kf->K * (measurement - kf->x);
    kf->P = (1 - kf->K) * kf->P;
    
    return kf->x;
}

注意:卡尔曼滤波的参数Q和R需要根据实际传感器调参。Q越大,滤波器越相信测量值;R越大,滤波器越相信预测值。我一般先用传感器数据手册里的噪声方差作为R的初始值,然后现场微调。

4.2 异常值检测与剔除

滤波能处理噪声,但处理不了“坏数据”。比如传感器断线了,输出一个固定值;或者受到强电磁干扰,数据直接飞了。这时候就需要异常值检测。

我常用的方法有两种:3σ原则滑动窗口阈值法

4.2.1 3σ原则——基于统计的检测

假设传感器数据服从正态分布,那么99.7%的数据会落在均值±3σ范围内。超出这个范围的数据,大概率是异常值。

嗯,这里要注意:3σ原则要求你预先知道数据的均值和标准差。对于静态场景(比如固定位置的温度监测),你可以离线统计。但对于动态场景(比如运动中的加速度),均值和标准差是变化的,需要在线更新。

// 在线3σ异常检测
typedef struct {
    float mean;
    float std;
    uint32_t count;
    float m2;  // 用于计算方差的中间变量
} OnlineStats_t;

void stats_update(OnlineStats_t* s, float value) {
    s->count++;
    float delta = value - s->mean;
    s->mean += delta / s->count;
    float delta2 = value - s->mean;
    s->m2 += delta * delta2;
    
    if(s->count > 1) {
        s->std = sqrtf(s->m2 / (s->count - 1));
    }
}

int is_outlier_3sigma(OnlineStats_t* s, float value) {
    if(s->count < 10) return 0;  // 样本太少,不判断
    float diff = fabsf(value - s->mean);
    return (diff > 3.0f * s->std) ? 1 : 0;
}

避坑指南:我曾经在一个项目中直接用3σ检测,结果发现传感器刚上电时数据波动大,把正常数据也剔除了。后来加了一个“预热期”——前100个样本只统计不剔除,问题就解决了。

4.2.2 滑动窗口阈值法——简单实用

如果你不想搞复杂的统计计算,可以用滑动窗口阈值法。原理是:当前值与前一个值的差值超过阈值,就认为是异常

这个方法在STM32上特别实用,因为计算量小,而且不需要存储大量历史数据。

// 滑动窗口阈值法
#define THRESHOLD_RATE 0.2f  // 允许的变化率(20%)

int is_outlier_threshold(float current, float previous) {
    if(previous == 0.0f) return 0;
    float rate = fabsf((current - previous) / previous);
    return (rate > THRESHOLD_RATE) ? 1 : 0;
}

经验之谈:阈值率要根据传感器特性来设。温度传感器变化慢,阈值率可以设小一点(5%~10%);加速度传感器变化快,阈值率可以设大一点(30%~50%)。我一般先看传感器数据手册里的“最大变化率”,然后乘以1.5作为阈值。

4.3 数据归一化与鲁棒性增强

数据进模型之前,归一化是必须的。但很多人只做了“减均值除方差”这一步,忽略了鲁棒性。说白了,你的归一化参数本身可能被异常值污染。

4.3.1 鲁棒归一化——用中位数和四分位距

标准归一化(Z-score)用的是均值和标准差。但均值和标准差对异常值非常敏感——一个异常值就能把均值拉偏。我推荐用鲁棒归一化:用中位数代替均值,用四分位距(IQR)代替标准差。

归一化方法 中心化参数 缩放参数 对异常值的鲁棒性
Z-score 均值 标准差
Min-Max 最小值 最大值-最小值 极差
鲁棒归一化 中位数 四分位距
// 鲁棒归一化(在线更新)
typedef struct {
    float median;
    float iqr;       // 四分位距
    float buffer[100];
    uint8_t count;
} RobustNormalizer_t;

void robust_normalize(RobustNormalizer_t* rn, float* value) {
    if(rn->count < 100) {
        // 收集样本阶段
        rn->buffer[rn->count++] = *value;
        return;
    }
    
    // 计算中位数和IQR(每100个样本更新一次)
    if(rn->count == 100) {
        // 排序
        float temp[100];
        memcpy(temp, rn->buffer, sizeof(temp));
        // 用快速选择算法找中位数和四分位数
        // 这里简化处理,实际项目中用nth_element
        rn->median = temp[50];
        rn->iqr = temp[75] - temp[25];
        rn->count = 101;  // 标记已初始化
    }
    
    // 归一化
    if(rn->iqr > 0.001f) {
        *value = (*value - rn->median) / rn->iqr;
    }
}

注意:归一化参数(中位数、IQR)需要在模型训练时从训练集计算,然后固化到STM32的代码中。不要在推理时在线更新归一化参数,否则模型的行为会随时间漂移。

4.3.2 数据增强——让模型自己学会抗干扰

除了在数据预处理阶段做滤波,你还可以在训练阶段做数据增强。说白了,就是给训练数据加一些“模拟噪声”,让模型学会在噪声中提取特征。

我在一个振动监测项目里用过这招:原始数据是干净的,我故意加了高斯噪声、脉冲噪声、甚至丢包模拟,然后训练模型。结果模型在真实传感器数据上的准确率提升了12%。

具体做法:

  • 加高斯噪声:模拟传感器热噪声,标准差取传感器数据手册中的噪声水平
  • 加脉冲噪声:随机将5%~10%的数据点替换为极大值或极小值
  • 时间扭曲:对时间轴进行轻微的拉伸或压缩,模拟采样时钟抖动
  • 丢包模拟:随机丢弃1%~3%的数据点,用前一个值填充

你想想看,模型在训练时见过各种“脏数据”,到了STM32上遇到真实噪声,它自然就不慌了。这就是所谓的鲁棒性增强——不是让数据变干净,而是让模型变“皮实”。

我的习惯:数据增强的参数不要设得太极端。我一般先设一个较小的幅度(比如噪声标准差为0.01),然后逐步加大,直到模型在验证集上的性能开始下降为止。这个“临界点”就是最优的数据增强强度。

好了,这一章的内容就到这里。总结一下:传感器数据进模型之前,先过三关——滤波、异常检测、鲁棒归一化。这三步做好了,你的模型在STM32上才能稳定运行。下一章咱们聊聊模型量化与部署中的安全陷阱,到时候见。