ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

指数移动平均与一阶低通滤波:同一公式的工程解读与参数整定

指数移动平均与一阶低通滤波:同一公式的工程解读与参数整定 做了这么多年信号处理和时序数据处理我发现一个特别有意思的现象搞金融量化的人嘴里天天念叨的“指数移动平均”和做嵌入式、搞控制的工程师随手写下的“一阶低通滤波”本质上是在解同一道数学题。两拨人拿着完全不同的教科书用着完全不同的术语体系甚至调试工具都八竿子打不着结果推到最底层居然撞在了同一个公式上。这个公式长这样y[n] y[n-1] α·(x[n] - y[n-1])。你要是问一个做股票的朋友他会告诉你这是EMAα越小曲线越平滑你要是问一个写单片机驱动的人他会告诉你这是一阶RC低通滤波器的差分形式α和采样周期、时间常数有关。俩人说的都对但很少有人把这层窗户纸捅破。今天我就把这层纸撕开从数学本质、参数整定、工程实现到避坑经验一次性讲透。1. 指数移动平均与一阶低通滤波同一个公式的两张面孔1.1 先从两个“行业黑话”说起在正式开始之前先对齐一下概念确保后面聊参数、聊实现的时候大家都站在同一个坐标系里。指数移动平均EMA是时间序列分析里的经典工具。它的核心思想很朴素当前时刻的估计值由“上一个时刻的估计值”和“当前时刻的观测值”加权融合得到。权重分配由一个衰减因子决定越久远的数据对当前结果的影响越小这个衰减是指数级的所以叫指数移动平均。做量化的朋友一般这么写EMA_t α · P_t (1 - α) · EMA_{t-1}其中P_t是当前价格α是平滑因子取值在0到1之间。α越大当前价格占的权重越大曲线反应越灵敏α越小历史数据拖尾越长曲线越平缓。一阶低通滤波来自信号与系统说的是允许低频信号通过、衰减高频信号的滤波器。最经典的实现是RC电路一个电阻串一个电容输出从电容两端取。在数字域里它的递推公式长这样y[n] (1 - a) · y[n-1] a · x[n]你看把EMA的公式稍微变形一下令α等于这里的a两个公式就变成了一模一样的东西。只是信号处理这边习惯把参数和“截止频率”挂钩用采样频率和时间常数来表达而不是直接给一个α完事。所以从今天起你可以记住一句话指数移动平均就是一阶低通滤波的时域实现一阶低通滤波就是指数移动平均在频域里的解释。它们是同一枚硬币的两面。1.2 为什么强调“一阶”与“指数”的对应关系有人可能会问低通滤波有巴特沃斯、切比雪夫、贝塞尔那么多类型阶数也能做到二阶、四阶甚至更高为什么偏偏这个一阶的和EMA长得一模一样道理在于“一阶”这个限定词。一阶系统的本质特征是只有一个储能元件在数字域里对应只有一个状态变量——也就是上一个时刻的输出。因为只有一个状态递推方程必然是一阶的输出只和y[n-1]与x[n]有关。再往下推一步这个一阶差分方程的解其单位冲激响应天然就是指数衰减的形式。换句话说指数衰减是一阶系统的“基因”甩不掉的那种。反过来看EMA为什么叫指数移动平均因为它对历史数据的加权系数按指数函数衰减最老的数据权重最小最新的数据权重最大权重序列本身是一条指数曲线。这条指数曲线和低通滤波的单位冲激响应是同一条曲线只是有人从时间域看它有人从“加权平均”的角度看它。这个对应关系不是巧合而是数学上的必然。1.3 公式变形与参数映射RC时间常数、α和截止频率要把两个领域的知识真正打通关键一步是理解参数怎么互相翻译。先看模拟域的一阶RC低通滤波器。RC电路的传递函数是H(s) 1/(1sRC)其中τ RC是时间常数截止频率f_c 1/(2πRC)。用双线性变换或者后向差分法离散化之后会得到一个差分方程。为了直观我直接用最简单的后向差分来做推导。后向差分把连续域的微分d/dt近似为离散的差分(1 - z^{-1})/TT是采样周期。代入传递函数整理后得到数字滤波器的系数a为a T / (T τ)这里a也就是前面说的α。同样给定α反推等效时间常数为τ T · (1 - α) / α而等效截止频率为f_c 1 / (2πτ) α / (2πT·(1 - α))举个例子假设你是以100Hz的频率采样T 0.01s想要一个截止频率约1Hz的低通滤波器也就是把1Hz以上的波动压下去。套公式τ 1/(2π·1) ≈ 0.159s然后α T/(Tτ) 0.01/(0.010.159) ≈ 0.059。也就是说EMA里的α取0.059左右等效于一个截止频率1Hz的一阶低通。这个换算关系非常实用是你从“拍脑袋调α”走向“按指标设计滤波器”的第一步。当然严格来说双线性变换得到的映射关系会略有不同但对于大多数工程场景后向差分公式已经足够准确而且计算简单不用查表。2. 参数怎么选α、截止频率与时间常数的工程整定方法2.1 从需求反推参数的完整流程很多初学者调EMA参数是纯靠感觉先设个0.5看曲线抖得厉害就降到0.1还是不光滑就再低一点。这种方法能跑通但效率低而且换一个场景之前的所有经验都作废。我建议换成“从指标反推参数”的流程分四步走。第一步明确你要压制的最高频率。如果你处理的是传感器数据先估算一下信号本身的变化速率和噪声的频率范围。比如一个温度传感器温度本身变化很慢可能一两分钟才稳定变化一度而噪声是50Hz的工频干扰那你的截止频率定在0.1Hz以下就是合理的如果你处理的是IMU的姿态角速度姿态变化可能到几Hz那截止频率至少得给到5Hz以上否则动态响应会滞后得没法用。第二步确定采样频率。采样频率是数字系统的硬约束一般由主循环频率或中断频率决定。注意采样频率必须满足奈奎斯特采样定理否则再好的滤波算法也救不了你。第三步代入公式算α。T和f_c都定了α由上面的公式反推α 2π · T · f_c / (1 2π · T · f_c)近似简化为α ≈ 2π·T·f_c。这个近似在α远小于1时非常精确如果你的α算出来超过0.3说明你选的截止频率太高了要重新审视一下需求。第四步仿真验证。在喂给真实系统之前先离线跑一下你的数据看看滤波前后的幅值和相位有没有满足要求。这一步很多人偷懒跳过我个人强烈不建议后面会专门讲怎么用一段简单的Python代码快速验证。2.2 不同场景下的经验值参考虽然我说要按流程算参数但实际项目里很多场景确实有一些“常规操作”可以直接参考帮你在初值选择上少走弯路。我这里整理了一份经验表覆盖面比较广但注意它只是起点不是终点。应用场景采样频率推荐截止频率对应α计算式用途电池电压采集10 Hz0.5 ~ 1 Hzα≈0.1滤掉负载突变引起的毛刺温度传感器读数1 Hz0.01 ~ 0.05 Hzα≈0.03~0.1温度变化慢需强平滑IMU加速度计输出100 Hz5 ~ 10 Hzα≈0.3~0.4保留姿态动态滤除振动舵机控制指令50 Hz2 ~ 5 Hzα≈0.11~0.24让运动更顺滑减少急停音频DAC输出44.1 kHz20 kHzα≈0.74高频重建滤波器股票价格追踪每日一根K线等效半月均线α≈0.1趋势跟踪均线策略这里想特别说一句不是所有场景都适合用一阶低通更不是α越小越好。α太小意味着时间常数巨大系统反应极其迟钝。比如一个自动驾驶的障碍物检测信号你要是给它加个0.01Hz的低通滤波车都撞上了数据还没跟上这就属于设计失误。2.3 α的物理直觉灵敏度与平滑度的博弈最后聊一点不太好写进公式、但实际调参时特别有用的直觉。α本质上是“当前测量值对当前估计值的信任程度”。α 0表示完全不信当前的测量值输出永远等于初始值α 1表示完全相信当前测量值输出就是原始信号滤波等于没滤波。你做的所有选择都是在“噪声消除”和“信号跟踪”之间找一个平衡点。我个人喜欢用一个很生活化的比喻你开车走一条有点颠簸的路如果你死死盯着仪表盘上0.1秒刷新一次的车速数字你的脚会不停地抖因为噪声太多了如果你改成看“过去半小时平均车速”车是稳了但遇到前车刹车你也来不及反应。α就是你的“反应速度旋钮”拧到哪里取决于你在什么路上开。这个直觉比公式重要因为它能帮你在参数失灵的时候迅速定位问题方向而不是对着数据发呆。3. 代码实现细节与定点化优化技巧3.1 浮点实现的三种写法对比公式本身简单但代码写起来风格差异还挺大。我见过三种主流写法分享给大家对比一下。第一种直接翻译公式def ema_filter(x, alpha, y_prev): return y_prev alpha * (x - y_prev)这种写法最直观而且有个好处alpha乘以一个差值数值稳定性很好因为差值本身不会特别大。第二种标准IIR写法def lowpass_filter(x, coeff): return (1 - coeff) * y_prev coeff * x这种写法在信号处理教材里更常见时刻记住y_prev是被“记忆”的状态变量。第三种写成滤波器对象的样子面向对象风格class EMAFilter: def __init__(self, alpha, init_value0.0): self.alpha alpha self.y init_value def __call__(self, x): self.y self.y self.alpha * (x - self.y) return self.y第三种适合在工程代码里维护因为滤波器是有状态的把状态封装在类里调用方不用关心内部实现代码可读性和可维护性都更好。三种写法数学上等价实际运行结果几乎无差别选哪种纯粹看团队代码风格和项目复杂程度。但对于需要长期迭代的项目我强烈推荐第三种后面加状态重置、加多级串联都好扩展。3.2 MCU上的定点化实现去掉浮点单元之后如果你用的是带FPU的ARM Cortex-M4或M7浮点运算随便用但在一些低成本的8位或低成本32位MCU上浮点运算是奢侈品一个float乘法可能要几十个周期还占大量Flash空间。这时候就需要定点化。思想是把α这个介于0和1之间的小数放大成整数运算后再缩回来。比如把α乘以256即左移8位记为α_q然后// 16位定点实现coeff_q (int)(alpha * 256) int16_t lowpass_q16(int16_t x, int16_t y_prev, uint16_t coeff_q) { int32_t diff x - y_prev; int32_t y y_prev ((diff * coeff_q) 8); return (int16_t)y; }这里有个关键点diff * coeff_q的结果要放在int32_t里因为两个16位数相乘结果最大到32位量级直接用int16_t存会溢出。移位运算必须用“算术右移”有符号数的右移保证负数的情况下结果符号正确。做完运算后再窄化回int16_t但要小心不要超出int16_t的范围。如果你想要更高精度可以把放大倍数从256提高到409612位甚至6553616位但代价是中间计算结果的位宽也要相应加大否则会溢出。嵌入式开发的原则就是在给定数据位宽下选尽量高的放大倍数同时保证最极端输入也不会溢出。3.3 启动偏差校正初值带来的坑很多人第一次用EMA时会遇到一个现象滤波结果一开始从0慢慢爬升要过好一阵子才接近真实信号。这叫“启动偏差”根源在于滤波器的初始状态y[-1]设成了0而真实信号可能远大于0。滤波器需要花时间“追赶”这个偏差时间长度取决于时间常数ττ越大追赶越慢。解决办法有三个。第一种把初始值设成第一个采样值if not initialized: y x initialized True这几乎免费但也就便宜在实现简单上如果第一个采样值噪声很大你会带着噪声跑完整个启动过程。第二种设置一个“预热期”在预热期内逐渐扩大α从0.5慢慢过渡到目标值。我常用的一个脚本里是这样alpha_eff alpha (0.5 - alpha) * math.exp(-n / 50)预热50个采样点后α_eff基本等于目标α。这个方法的优势是启动曲线更自然适合输出波形对启动段有要求的场景。第三种启动后丢弃前N个输出。最简单粗暴适合离线处理实时系统里如果允许也可以在启动时快速采样几个点做中值滤波取中值作为初始状态。3.4 多级串联从一阶到高阶的平滑升级有时候一阶滤波不够用比如一阶低通在截止频率之后只以-20dB/decade的速率衰减你发现滤不干净。这时候最简单的升级方案是把两个一阶滤波器串起来变成二阶低通频率特性变成-40dB/decade滤波效果好了不少。实现上也简单filter1 EMAFilter(alpha1) filter2 EMAFilter(alpha2) def filter2_stage(x): y1 filter1(x) y2 filter2(y1) return y2注意两个滤波器的截止频率怎么分配。如果两个滤波器的截止频率相同整体响应在截止频率附近会产生约3dB的峰值你能看到一点过冲。如果希望响应更平坦把两个截止频率拉开一点比如一个设为目标频率的1倍另一个设为目标频率的0.7倍这样整体幅频响应更接近Butterworth特性。但多级串联也有代价相位滞后更大。每增加一阶滞后通道就多一截最终效果就是信号被推迟得更明显。一些对实时性要求高的场合宁可接受滤波不彻底也不能让信号滞后太多。这个权衡工程上一定要心里有数。4. 常见问题与排查技巧实录4.1 输出滞后过大不是滤波器坏了是你参数选错了最常被问的一个问题我用了低通滤波之后信号明显变“肉”了反应慢半拍。这种情况九成是时间常数选得太大。很多人为了滤波效果好直接把α降到0.01甚至0.001结果信号看着确实干净了但变化也几乎看不出来了。排查思路很简单记录输入x和输出y把两条曲线画在一张图里对比量一下输出相对输入的延迟时间。如果延迟超过了你应用能容忍的上限说明截止频率定低了。把f_c往上提α也相应调大找到一个“噪声还能接受动态也跟得上”的中间带。顺带说一个细节这个延迟本质上是一阶系统固有的群延迟在截止频率处大约为τ也就是时间常数本身。它不是bug是特征。理解了这一点你对滤波结果的质量预判会更准确。4.2 输出出现步进或台阶感定点化溢出的经典症状你如果在嵌入式设备上看到滤波输出像上楼梯一样一格一格地跳优先检查定点化实现里的移位和溢出。我实际踩过的一个坑是这样的数据从16位ADC进来我把它放到了int16_t里滤波系数放大256倍后用一个uint16_t存中间乘积用int32_t接。按理说没问题但ADC的值范围是0到4095电压是单极性而我的int16_t可以表示-32768到32767。问题出在后面我把y又塞回int16_t时如果y是负数在零点附近抖动时很容易出现窄化转换时符号扩展处理不当输出就变成了一串怪异的台阶。修复方法要么保证所有中间变量都用有符号数要么在每次窄化前做饱和限制把结果钳位在合法范围内if (y 32767) y 32767; if (y -32768) y -32768;这种“饱和处理”在控制类应用里是标配因为溢出导致的异常输出比滤波不干净危险得多。4.3 曲线明显振荡或过冲二阶串联参数没配对多级串联滤波器如果出现输出轻微振荡多半是两级参数设置不当。我之前用两个截止频率相同的滤波器串联做温控数据平滑时就观察到在阶跃响应时有一个明显的过冲大概超出了目标值15%左右。原因是两个相同的截止频率导致在转折频率附近的相位裕量不足等效品质因数Q 0.5恰好位于临界阻尼点实际由于离散化误差产生了轻微谐振。解决方法是把两个截止频率拉开比如一个设为1Hz另一个设为0.6Hz整体的幅频特性会更平缓过冲就消失了。如果你是离线处理数据更推荐直接用scipy.signal.butter设计一个真正的二阶巴特沃斯滤波器然后用filtfilt做零相位滤波效果比手动串级好得多。实时场景不适合用filtfilt但串级参数错开的办法在实时场景依然好用。4.4 数据长度很长时初始偏差像阴魂不散一样甩不掉按理说初始偏差会以指数衰减的形式快速趋近于零但有些情况下你会发现它影响很久。最容易出问题的场景是信号本身就有一个很大的直流偏置而且α非常小。比如α 0.001时间常数τ T/α T·1000如果T 0.01sτ 10秒那初始偏差要经过大约3τ 30秒才能衰减到原来的5%。这在很多应用里是没法接受的。解决办法不只是设初始值更稳的方案是前文提到的预热期或者干脆采集一段数据用平均值初始化。我一般在实时系统里采用“首值初始化 前50个点做均值微调”的方案效果很稳。4.5 数据丢帧导致输出跳变状态补偿技巧实时系统里数据丢帧挺常见的。如果一个周期的x没有更新你仍然把上一次的y当作当前输出那么当下一帧数据到达时滤波器的状态还是停留在之前的水平但x突然跳了一大截导致输出瞬间突变。处理这种情况的基本思路是检测到丢帧时不要继续用默认采样周期T去套滤波器而是根据实际间隔时间调整α。比如原本每10ms采一次样某次间隔了50ms那这次计算时等效T 0.05sα要重新算。实现上就是用当前时刻和上次时刻之差Δt代替T代入公式。这样处理之后即便采样间隔不规则滤波器的等效截止频率也能保持稳定不会因为一帧迟到的数据产生明显跳变。这个方法我强烈推荐在IMU、GPS、雷达等容易丢帧的数据源上使用。5. 实战案例传感器信号处理的完整调参流程纸上谈兵说得够多了我拿一个温度传感器信号处理的完整案例把前面所有知识点串一遍。5.1 场景描述与数据特征分析我朋友做的一个设备里有一个温度采集模块采样周期0.1秒10Hz原始信号混着电源纹波和电路噪声毛刺很明显。目标是把噪声滤掉同时温度变化时输出要在2秒内跟上实际温度变化也就是延迟不超过2秒。我们对信号做个快速分析温度变化本身很慢主要能量集中在0.1Hz以下电源纹波是50Hz还有一些高频电磁干扰频率更不可控。理论上把截止频率定在0.5Hz就能把噪声压下一截而且延迟可能在0.3-0.5秒左右远小于2秒的容限。5.2 参数计算与初值选择先把数值算清楚采样周期T 0.1s目标截止频率f_c 0.5Hz时间常数τ 1/(2π·0.5) ≈ 0.318s代入α T/(T τ) 0.1/(0.1 0.318) ≈ 0.239取0.24。理论上这个参数就能满足延迟要求。于是我用代码做了离线验证。import numpy as np import matplotlib.pyplot as plt def ema(x, alpha, init0.0): y np.zeros_like(x) y[0] init if init ! 0.0 else x[0] for i in range(1, len(x)): y[i] y[i-1] alpha * (x[i] - y[i-1]) return y # 模拟 10Hz 采样带 50Hz 工频干扰 高斯噪声 t np.arange(0, 100, 0.1) true_temp 25 5 * np.sin(2*np.pi*0.05*t) noise 0.2 * np.sin(2*np.pi*50*t) np.random.normal(0, 0.1, t.shape) signal true_temp noise filtered ema(signal, 0.24) print(fFiltered signal lag: {np.argmax(np.abs(filtered - true_temp)) * 0.1:.2f}s)实测下来延迟在0.5秒左右和理论估算的τ差不多一个量级完全满足2秒的延迟要求。5.3 定点化移植与实测对比离线验证通过后我把滤波器移植到MCU上。MCU没有FPU主频也低所以采用了前面讲的16位定点方案。ADC用12位数据范围0-4095我把α设为0.24α_q就是round(0.24 * 256) 61。下面是C代码片段#define ALPHA_Q 61 // 相当于 alpha 0.238 int16_t lowpass_16(int16_t x, int16_t y_prev) { int32_t diff (int32_t)x - y_prev; int32_t y y_prev ((diff * ALPHA_Q) 8); if (y 4095) y 4095; if (y 0) y 0; return (int16_t)y; }因为温度信号本身是单极性的0-4095范围我把饱和下限设成了0而不是-32768避免无意义的负数范围。上机测试结果和浮点版本几乎没区别输出电压最大偏差不超过1个LSB完全够用。这个案例里我最想强调的一个点是设计滤波器的核心指标不是“看起来平滑”而是“在保证平滑的前提下延迟仍然满足系统需求”。这两个指标是矛盾的你必须用数据说话而不是靠眼睛欣赏曲线。6. 进阶技巧与扩展思路6.1 自适应α让滤波器跟着信号状态走固定α的最大缺点是一刀切信号平稳时希望滤波猛一点信号突变时希望响应快一点。固定参数只能二选一自适应α则能兼顾。一种简单实用方案用当前输出y和当前输入x的差值的绝对值来衡量“突变程度”。如果差值很大说明信号发生了真实跳变不是噪声这时候临时把α调大比如从0.1跳到0.5等差值回落再把α降回来。参考实现def adaptive_ema(x, alpha_min, alpha_max, threshold): global y_prev diff abs(x - y_prev) if diff threshold: alpha alpha_max else: alpha alpha_min y y_prev alpha * (x - y_prev) y_prev y return y这种策略在类似“机器人防碰撞信号”这种既有大量平稳噪声、又可能有突然撞击信号的场景特别有用。需要注意的是阈值的选取要基于真实数据分布别设太敏感否则α会一直卡在最大值上滤波等于没做。6.2 拉格朗日视角EMA与卡尔曼滤波的意外交点很多人不知道一阶低通滤波其实是极简化的卡尔曼滤波器。卡尔曼滤波在系统矩阵为单位阵、且过程噪声和测量噪声为白噪声的情况下稳态增益就是一个常数而这个常数恰好就是α。换句话说你的EMA滤波器在统计意义上等价于一个假设“信号是常数噪声”的最优滤波器。这个视角的价值不在于写论文而在于帮你理解α为什么不能乱给过程噪声信号自身变化和测量噪声传感器噪声的比值本质上是决定α的唯一因素。信号变得越快α越要大传感器噪声越大α越要小。理解了这一层你选参数就不是“试出来的感觉”而是有了理论依据。6.3 无效数据时的传播策略再补充一个工程小细节当传感器数据无效时滤波器的状态要不要更新我的经验是无效时不更新状态。也就是说如果x[n]无效输出y[n]保持为y[n-1]。这等价于“信号断开了保持最后已知值”对于大多数控制系统是安全行为。但要注意长时间没有有效数据时继续输出旧值会造成较大误差最好同时启动一个超时标记让上层逻辑知道此时输出的置信度在下降。6.4 扩展非线性算子EMA不等于一切最后提一句EMA虽然经典但它本质是线性算子对某些信号是无能为力的。比如你想滤除脉冲噪声椒盐噪声EMA的效果远远不如中值滤波你想保留陡峭边缘EMA会把边缘磨平这时候双边滤波或者加权中值滤波才是正解。工具选型永远优先于参数调优先搞清楚你的噪声类型再决定用什么滤波器而不是拿EMA一把梭。在我自己做过的项目里这两者的统一认知帮我省了很多时间。以前做嵌入式的时候看到同事在调一阶低通参数我会用EMA的理解帮他估算响应速度后来做数据分析和量化回测看到有人在调EMA参数我又能用频域的概念解释为什么曲线会滞后。两条知识线一旦打通整个人的思维框架都不一样了。如果你现在正在用这两个工具之一建议你抽空把另一个领域的资料翻一翻你会发现很多“新大陆”。以后调试的时候多问自己一句我到底想压低多高频率的噪声我的传感器多久采一次样我能容忍多少延迟把这三个问题想清楚参数自然就出来了不用靠猜。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进