跳转至

04 趋势感知与自适应滤波

这一章讨论的不是某个单一算法,而是滤波器如何根据当前数据状态改变自己的参数。

它介于两个阶段之间:第二章讲的是固定参数滤波器——例如一阶滞后的平滑系数 \(\alpha\)、滑动平均的窗口长度 \(N\)、限幅和消抖中的阈值,都是预先设定好的,运行中不会根据当前数据状态自动改变;第五章将进入完整的状态空间模型——卡尔曼滤波、传感器融合。这一章讲的是工程师常用的"半经验、半模型"方法:让滤波器根据当前数据状态判断现在该稳,还是该快。

固定参数为什么不够用

一阶滞后的平滑系数 \(\alpha\) 可以理解为"每次更新时相信新输入多少"。\(\alpha\) 小,输出更稳但响应更慢;\(\alpha\) 大,响应更快但噪声也更明显。固定参数的矛盾就在这里:无论选哪个值,总有一种情况它处理不好。

滑动平均也一样:窗口大能降噪但延迟大,窗口小响应快但噪声多。

这两个矛盾是同一个问题的两面。具体来说:

  • 选了小 alpha:信号稳定时输出很干净,但真实变化来了要等很久才跟上。
  • 选了大 alpha:阶跃响应很快,但稳态时输出跟着噪声一起抖。
  • 选了大窗口:噪声被压得很低,但窗口越大,输出的延迟越大。

能不能让滤波器自己判断当前属于哪种情况,然后动态调整参数? 这就是自适应滤波的核心思想。

自适应滤波先解决什么问题

在设计自适应策略之前,先要分清输入信号当前处于什么状态。通常可以分成三类:

  1. 稳态小抖动。 传感器读数围绕某个值小幅波动,没有明显趋势。这是最常见的情况,也是固定参数滤波器最擅长的。
  2. 真实缓慢趋势。 温度在升温、电池电压在缓慢下跌、液位在慢慢上升。信号确实在变化,但变化速度不快,需要滤波器能跟踪而不是把它当噪声抹掉。
  3. 突然阶跃或异常点。 传感器被干扰打出尖峰,或者真实物理量确实发生了突变(比如阀门打开,流量突然从 10 变到 80)。需要快速响应,但又不能把噪声当突变。

这三种状态对滤波器参数的要求是矛盾的:状态 1 需要小 alpha,状态 2 需要中等 alpha 并且要能感知趋势,状态 3 需要大 alpha 或者特殊的异常处理。自适应滤波的任务就是根据当前数据判断属于哪种状态,然后调整参数。

动态一阶滞后:根据误差改变 alpha

这是本章最核心、最容易落地的算法。它的思想很朴素:误差小时用小 alpha 保持稳定,误差大时用大 alpha 快速跟随。

基本思路

先把误差的绝对值 \(|x - y|\) 映射到 0 到 1 的比例 \(k\),再用 \(k\) 在慢速系数和快速系数之间插值:

\[ k = \frac{|x - y| - E_{\text{small}}}{E_{\text{large}} - E_{\text{small}}} \]
\[ \alpha = \alpha_{\text{slow}} + k \cdot (\alpha_{\text{fast}} - \alpha_{\text{slow}}) \]

其中 \(E_{\text{small}}\) 是"认为稳定"的误差下限,\(E_{\text{large}}\) 是"认为需要快速响应"的误差上限。当 \(|x - y|\) 小于 \(E_{\text{small}}\)\(k = 0\),用 \(\alpha_{\text{slow}}\);当 \(|x - y|\) 大于 \(E_{\text{large}}\)\(k = 1\),用 \(\alpha_{\text{fast}}\);中间线性插值。

为什么有效

这个方法有效的前提是:误差大小可以粗略反映信号状态。 当传感器读数和当前输出差距很小时,大概率是稳态噪声;差距很大时,大概率是真实变化或异常。

这个前提不总是成立(后面会讲风险),但在很多实际场景中足够好用。

参数怎么选

  • \(\alpha_{\text{slow}}\):稳态时的平滑系数,通常 0.01 ~ 0.1,越小越稳。
  • \(\alpha_{\text{fast}}\):需要快速响应时的系数,通常 0.3 ~ 0.8,越大越快。
  • \(E_{\text{small}}\):小于这个误差认为是稳态噪声。可以从传感器的典型噪声幅度估计,比如 ADC 在稳态时抖动 ±3 个 LSB,可以设 5。
  • \(E_{\text{large}}\):大于这个误差认为需要快速响应。取决于物理量的最大变化速度和采样率。

MCU C 实现

#include <stdint.h>

typedef struct {
    float y;      /* 上一次输出 */
    uint8_t init; /* 是否完成初始化 */
} DynamicLag;

void dynamic_lag_init(DynamicLag *f)
{
    f->y = 0.0f;
    f->init = 0;
}

/**
 * 动态一阶滞后。
 * x:          当前输入
 * alpha_slow: 稳态时的平滑系数(小)
 * alpha_fast: 需要快速响应时的平滑系数(大)
 * err_small:  误差低于此值时认为是稳态
 * err_large:  误差高于此值时认为需要快速响应
 */
float dynamic_lowpass(DynamicLag *f, float x,
                      float alpha_slow, float alpha_fast,
                      float err_small, float err_large)
{
    if (!f->init) {
        f->y = x;
        f->init = 1;
        return x;
    }

    /* 前提:0 <= err_small < err_large,0 <= alpha_slow <= alpha_fast <= 1 */

    float e = x - f->y;
    float ae = e >= 0.0f ? e : -e;

    /* 防止除零:err_large <= err_small 时直接用 alpha_slow */
    float diff = err_large - err_small;
    float k = 0.0f;
    if (diff > 0.0f) {
        k = (ae - err_small) / diff;
        if (k < 0.0f) k = 0.0f;
        if (k > 1.0f) k = 1.0f;
    }

    /* 误差小时 alpha 接近 alpha_slow,误差大时接近 alpha_fast */
    float alpha = alpha_slow + k * (alpha_fast - alpha_slow);

    /* 钳位 alpha 到合法范围 */
    if (alpha < 0.0f) alpha = 0.0f;
    if (alpha > 1.0f) alpha = 1.0f;

    f->y = f->y + alpha * e;
    return f->y;
}

前提条件。 必须满足 \(0 \le E_{\text{small}} < E_{\text{large}}\),否则除零或映射失效。同时 \(0 \le \alpha_{\text{slow}} \le \alpha_{\text{fast}} \le 1\),否则插值结果无意义。

代价和权衡。 比普通一阶滞后多了一次绝对值、一次除法、两次比较和一次插值。其中除法在无 FPU 的 MCU 上开销较大;可以预先计算 \(1.0 / (E_{\text{large}} - E_{\text{small}})\) 保存为常数,每次更新改为一次乘法。多了一组参数需要调节:\(\alpha_{\text{slow}}\)\(\alpha_{\text{fast}}\)\(E_{\text{small}}\)\(E_{\text{large}}\)。参数选得不好,可能在稳态和突变之间反复切换,输出反而比固定参数更抖。

什么时候不该用。 噪声幅度和真实变化幅度接近时——滤波器分不清"稳定"和"变化",alpha 会频繁跳动。这时应该先用其他方法降低噪声(比如硬件滤波、去极值),再用动态一阶滞后。

验证方法。 分别用稳态数据和阶跃数据测试。稳态时输出抖动应该和固定小 alpha 接近;阶跃时响应速度应该比固定小 alpha 明显更快。如果两者都满足,参数选择就是合理的。

二阶指数平滑:同时估计当前值和趋势

一阶指数平滑只估计当前值。如果信号本身有缓慢趋势(温度在升温、电池电压在跌),一阶平滑会把持续变化的一部分当成需要平滑的变化,因此产生稳定滞后——输出永远落后于真实值,差值取决于趋势速度和 alpha。

二阶指数平滑(也叫 Holt 滤波)在估计当前值的同时,还估计每一步大约变化多少

为什么需要趋势项

考虑一个简单场景:温度以每秒 0.5°C 的速度在升温,采样率 10 Hz,每步真实变化 0.05°C。如果用 alpha = 0.1 的一阶滞后,稳态时输出也会以同样的速率增长,但始终落后一个固定量:

\[ \text{滞后差} \approx \frac{1 - \alpha}{\alpha} \cdot c \]

其中 \(c\) 是每步变化量(上例中 \(c = 0.05\;°C/\text{步}\),即变化速率乘以采样周期)。这个差值不会消失,因为一阶滞后不知道"信号在持续变大",它只看到每步 0.05°C 的误差,认为是噪声,只吸收其中 10%。

二阶指数平滑通过趋势项来补偿这个滞后:它估计"每步大概变化多少",把这个变化量加到预测里,让输出能跟上趋势。

数学定义

二阶指数平滑有两条更新式:

\[ \ell[n] = \alpha \cdot x[n] + (1 - \alpha)\left(\ell[n-1] + b[n-1]\right) \]
\[ b[n] = \beta\left(\ell[n] - \ell[n-1]\right) + (1 - \beta) \cdot b[n-1] \]

其中 \(\ell[n]\) 是当前基准值(level),\(b[n]\) 是当前趋势(trend),表示每步大约增加或减少多少。

预测下一步的值可以用:

\[ \hat{x}[n+1] = \ell[n] + b[n] \]

变量含义

  • \(\ell[n]\):当前时刻的基准值估计。它同时参考了当前测量值和上一时刻的预测值。
  • \(b[n]\):当前时刻的趋势估计。它跟踪的是"每步基准值变化了多少"。
  • \(\alpha\):对新测量的信任程度。越大越信任当前测量值。
  • \(\beta\):趋势更新速度。越大越信任最新的趋势变化量。

MCU C 实现

#include <stdint.h>

typedef struct {
    float level;  /* 当前基准值估计 */
    float trend;  /* 当前趋势,每步大约变化多少 */
    uint8_t init; /* 是否完成初始化 */
} Holt;

void holt_init(Holt *h)
{
    h->level = 0.0f;
    h->trend = 0.0f;
    h->init = 0;
}

float holt_update(Holt *h, float x, float alpha, float beta)
{
    if (!h->init) {
        /* 第一次没有历史趋势,直接用当前输入初始化 */
        h->level = x;
        h->trend = 0.0f;
        h->init = 1;
        return x;
    }

    /* 钳位参数到合法范围 */
    if (alpha < 0.0f) alpha = 0.0f;
    if (alpha > 1.0f) alpha = 1.0f;
    if (beta  < 0.0f) beta  = 0.0f;
    if (beta  > 1.0f) beta  = 1.0f;

    float last_level = h->level;

    /* 用"当前测量"和"上一时刻预测值"共同更新 level */
    h->level = alpha * x + (1.0f - alpha) * (h->level + h->trend);

    /* 用 level 的变化量更新趋势估计 */
    h->trend = beta * (h->level - last_level) + (1.0f - beta) * h->trend;

    /* 两种输出选择:
     * - 用于当前测量值平滑:返回 h->level
     * - 用于一步预测或补偿滞后:返回 h->level + h->trend
     * 这里返回 level + trend,适合需要提前预测的场景。 */
    return h->level + h->trend;
}

参数怎么选

  • \(\alpha\):和一阶滞后类似,0.05 ~ 0.3 常用。越大越信任当前测量,响应越快但噪声越大。
  • \(\beta\):趋势更新速度,通常比 \(\alpha\) 小,0.01 ~ 0.2 常用。太大时趋势估计会追噪声,遇到异常值容易产生过冲。

代价和权衡

计算量比一阶滞后多几次乘加(趋势更新和预测项各需要乘法和加法),以及一个额外的趋势状态变量,RAM 多占 4 字节。整体仍然很轻,MCU 上没有压力。

趋势估计太激进时的风险。 如果 \(\beta\) 太大,一个异常值会让趋势估计跳变,后续几拍输出都会偏离。这和一阶滞后的"尖峰恢复慢"不同——二阶平滑的尖峰影响可能更大,因为趋势项会把偏差放大。

什么时候不该用。 信号没有明显趋势时(比如围绕固定值抖动),趋势项会追噪声,输出反而更不稳定。这时用一阶滞后就够了。

验证方法。 用带有已知趋势的测试数据(比如线性升温),对比一阶滞后和二阶平滑的跟踪误差。二阶平滑的稳态滞后应该明显更小。再用纯噪声数据测试,确认趋势项不会放大噪声。

自适应窗口或自适应权重

动态一阶滞后根据误差调整 alpha。另一种思路是调整窗口长度或权重:波动小时用长窗口降噪,波动大时用短窗口加快响应。注意:方差增大可能是真实阶跃或趋势,也可能只是噪声环境变差。如果是后者,缩短窗口反而会更抖。这种方法需要确认方差增大主要来自真实变化。

和动态一阶滞后的区别

两者思想类似,但实现不同:

  • 动态一阶滞后:参数连续变化(alpha 是浮点数),输出是递推的,不需要缓冲区。
  • 自适应窗口:窗口长度是整数,需要缓冲区,但可以结合去极值等非线性操作。

一种简单实现:基于方差调整窗口

先计算最近窗口内样本的方差,方差小时用长窗口,方差大时用短窗口。这比直接看单点误差更稳健——单点误差可能是个尖峰,但方差反映的是整体波动水平。

一种简单的映射方式是线性插值:给定方差的下限 \(V_{\text{low}}\) 和上限 \(V_{\text{high}}\),对应的窗口长度为 \(N_{\text{max}}\)\(N_{\text{min}}\)

\[ N = N_{\text{max}} - \frac{V - V_{\text{low}}}{V_{\text{high}} - V_{\text{low}}} \cdot (N_{\text{max}} - N_{\text{min}}) \]

方差越小,窗口越长;方差越大,窗口越短。结果需要钳位到 \([N_{\text{min}}, N_{\text{max}}]\) 的整数范围。

#include <stdint.h>

/**
 * 估计最近 n 个样本的样本方差。
 */
static float estimate_variance(const float *buf, uint16_t n)
{
    if (n < 2) return 0.0f;

    float sum = 0.0f;
    for (uint16_t i = 0; i < n; i++) sum += buf[i];
    float mean = sum / (float)n;

    float var = 0.0f;
    for (uint16_t i = 0; i < n; i++) {
        float d = buf[i] - mean;
        var += d * d;
    }
    return var / (float)(n - 1);
}

方差的计算量是 \(O(N)\),每拍都要算的话开销不小。工程上可以每几拍算一次,或者用递推方差公式减少计算量。

什么时候用这种方法。 需要结合去极值、中值等非线性操作时,窗口方法比递推的一阶滞后更灵活。但代价是需要缓冲区,计算量更大。

Hampel 滤波:用数据本身估计异常门限

前面的动态一阶滞后用固定阈值 \(E_{\text{small}}\)\(E_{\text{large}}\) 来判断误差大小。但如果信号的正常波动范围本身在变化(比如白天噪声大、夜间噪声小),固定阈值就不够用了。

Hampel 滤波用窗口内的数据自己估计波动范围,门限随数据自适应调整

原理

Hampel 滤波的判定式:

\[ |x - \text{median}| > k \cdot 1.4826 \cdot \text{MAD} \]

其中: - \(\text{median}\) 是窗口内样本的中位数。 - \(\text{MAD}\)(Median Absolute Deviation)是各样本到中位数距离的中位数。 - \(1.4826\) 用来把 MAD 近似换算为标准差尺度(正态分布下,\(\sigma \approx 1.4826 \cdot \text{MAD}\))。 - \(k\) 是倍数常数,通常取 2 ~ 3。\(k = 3\) 对应约 3 倍标准差,正态分布下误判率约 0.3%。

如果当前值离中位数太远(超过门限),就用中位数替代;否则保留原值。

MCU C 实现

#include <stdint.h>

/**
 * Hampel 判定。
 * x:       当前输入
 * median:  窗口中位数
 * mad:     窗口的 MAD
 * k:       倍数常数(通常 2~3)
 * mad_min: MAD 最小值,防止 MAD=0 时门限为 0
 * 返回:    超过门限返回 median,否则返回 x
 */
float hampel_decide(float x, float median, float mad,
                    float k, float mad_min)
{
    float diff = x - median;
    if (diff < 0.0f) diff = -diff;

    /* MAD 太小时(窗口内数据几乎相同),用最小门限保护。
     * 否则任何微小偏差都会被判异常。 */
    float effective_mad = mad > mad_min ? mad : mad_min;

    /* 1.4826 * MAD 近似对应标准差尺度 */
    if (diff > k * 1.4826f * effective_mad) return median;

    return x;
}

和限幅的区别。 限幅用固定阈值判断异常;Hampel 用窗口内的 MAD 自动估计"正常波动有多大"。当信号的波动范围本身在变化时,Hampel 的门限会跟着调整,比固定限幅更稳健。

MAD = 0 的边界。 稳定 ADC 数据经常出现一串相同值,此时 MAD = 0,门限也为 0,任何微小偏差都会被判异常。代码中的 mad_min 参数就是为此设置的最小门限。工程上可以根据 ADC 分辨率或传感器典型噪声幅度来估计 mad_min

代价和权衡。 需要维护一个窗口缓冲区,每拍要排序找中位数和 MAD,计算量是 \(O(N \log N)\)(如果用排序)或 \(O(N)\)(如果用选择算法)。实现概念比多维状态估计简单,但排序和窗口维护仍有计算代价,不要误以为它一定比递推方法便宜。

什么时候不该用。 窗口太小时,MAD 的估计不稳定,门限会跳动。窗口太大时,门限更新太慢,跟不上波动范围的快速变化。通常窗口 7~15 个样本比较合适。

验证方法。 用包含已知异常点的数据测试,对比 Hampel 和固定限幅的检测率和误判率。在波动范围变化的数据上,Hampel 的表现应该更稳定。

自适应算法的工程风险

自适应听起来很美好——让滤波器自己调整参数,不就能兼顾稳和快了吗?实际没那么简单。一旦参数随输入变化,系统的行为会变得更复杂,也更难预测。

追噪声

如果自适应策略对误差太敏感,小噪声也能触发参数变化。比如动态一阶滞后,如果 \(E_{\text{small}}\) 设得太小,稳态噪声就能让 alpha 在 \(\alpha_{\text{slow}}\)\(\alpha_{\text{fast}}\) 之间频繁跳动,输出反而比固定参数更抖。

对策: \(E_{\text{small}}\) 应该大于稳态噪声的典型幅度。宁可大一点(偶尔响应慢),也不要小了(稳态追噪声)。

误判趋势

二阶指数平滑的趋势项可能把暂时的偏差当成持续趋势。一个异常值让 level 跳变,trend 也会跟着跳,后续几拍输出都会偏离。

对策: \(\beta\) 不要太大。如果异常值频繁,先用限幅或 Hampel 去异常,再送入二阶平滑。

启动阶段

自适应算法在启动时往往需要几拍到几十拍的"学习"时间。动态一阶滞后在第一拍没有历史误差,Holt 需要积累趋势估计。这段时间的输出可能不准。

对策: 初始化时用第一笔输入直接赋值,不从 0 开始。如果应用允许,可以在启动阶段用固定参数(比如 alpha = 0.5),等输出稳定后再切换到自适应模式。

异常恢复

自适应滤波器被一个大异常值"打偏"后,恢复速度取决于当前的参数状态。如果异常值触发了大 alpha,恢复时 alpha 仍然偏大,可能追着噪声跑一段时间才慢慢降下来。

对策: 对 alpha 的变化速率施加限制——每拍最多增加或减少多少。或者用滑动窗口的中位数/MAD 来判断是否真的需要大 alpha,而不是只看单点误差。

参数边界

自适应参数必须有明确的上下界。alpha 必须在 0 到 1 之间,窗口长度必须是正整数,门限不能为负。参数超出边界时,滤波器行为不可预测。

对策: 每次计算完自适应参数后,钳位到合法范围。不要假设计算过程不会产生意外值。

不再是 LTI

这是最关键的一点:固定参数的一阶滞后是 LTI 系统,可以讨论固定的频率响应、截止频率、群延迟。但当 alpha 根据误差变化时,它变成了非线性或时变系统,不能再用一个固定的截止频率完整描述它。

这意味着:

  • 你不能说"这个动态一阶滞后的截止频率是 2 Hz"——它的等效截止频率随输入变化。
  • 频率响应分析工具(Bode 图、传递函数)不再严格适用。
  • 验证方法必须回到时域:用阶跃信号、斜坡信号、实际数据测试,观察输出行为。

这不是说自适应算法不好,而是说分析它的方法要变。第二章的工具箱里有频率响应;第四章的工具箱里有时域测试和工程判断。

小结:自适应的本质是改变取舍

算法 调整什么 适合场景 主要风险
动态一阶滞后 alpha 随误差变化 既要稳又要快的显示/控制 阈值选不好会追噪声
二阶指数平滑 同时估计值和趋势 有缓慢趋势的信号(温度、电压) 趋势项追噪声或过冲
自适应窗口 窗口长度或权重随波动变化 需要结合非线性操作时 计算量大,窗口切换不连续
Hampel 异常门限随 MAD 自适应 波动范围本身在变化的信号 需要排序,计算量较高

自适应滤波的核心不是"让滤波器更聪明",而是把"稳"和"快"的折中从设计时的固定选择,变成运行时的动态调整。 这个调整本身有代价——参数更多、行为更难预测、不再是 LTI、需要更多工程验证。

下一章进入卡尔曼滤波。卡尔曼的思路更进一步:它不只是"根据误差调整 alpha",而是用一个显式的状态空间模型来描述信号怎么变化、测量有多少噪声,然后在模型和测量之间做加权融合——在线性模型、噪声近似高斯且参数设置合理的条件下,这个加权是统计最优的。但核心问题和这一章一样:怎么在信任模型和信任测量之间找到平衡。