1. 为什么两个领域的人,守着同一个公式吵了五十年
先讲个我早年间经历过的场景。那时候我在做一套传感器数据采集系统,前端硬件工程师负责把压力传感器的模拟信号送进来,我负责后面的算法处理。硬件那边为了抑制工频干扰,在电路板上加了一组电阻电容,构成一个经典的一阶RC低通滤波器。我这边为了让曲线更好看、更稳,在软件里跑了一段递归平均的代码。然后问题来了:我们俩盯着示波器上几乎一模一样的输出波形,却用了完全不同的术语去描述它。
硬件工程师管它叫“一阶低通滤波”,我管它叫“指数移动平均”。更离谱的是,我们俩都为这个“自己领域里再基础不过的公式”争论过——他说他的是模拟电路,我说我的是数字信号处理,两个东西怎么可能是同一个?直到后来我把两个公式摆在一起,展开一推导,才发现这不仅仅是数学上的“相似”,而是严格意义上的同一个递推表达式。
这件事给我的冲击很大。其实不止是硬件和算法,量化交易里用指数移动平均(EMA)做趋势跟踪的人,和控制系统里用一阶惯性环节做平滑的人,也经常陷在各自的术语体系里。角落里的公式明明长得一模一样,互相却不知道对方的存在。今天这篇文章,我就想把这个“同一枚硬币的两面”彻底掰开揉碎。我会从数学推导、物理含义、参数选择、工程实现到常见坑位,把指数移动平均和一阶低通滤波从头到尾讲清楚。
适合谁看呢?如果你是做嵌入式、写传感器滤波、搞控制系统、做量化策略,或者仅仅是被“平滑数据”这件事困住过的开发者,这篇文章都能给你一个完整的视角。
2. 数学本质:同一个递推式的三层展开
2.1 从两个公式的对比开始
先看两个式子。指数移动平均的离散递推形式,最常见的写法是:
s[t] = α · x[t] + (1 − α) · s[t−1]
其中 s[t] 是 t 时刻的平滑值,x[t] 是 t 时刻的原始采样值,α 是平滑系数,取值在 0 到 1 之间。
一阶低通滤波器的数字实现,如果从模拟域的双线性变换或者最简单的后向差分法推过来,常见的递推式长这样:
y[n] = y[n−1] + a · (x[n] − y[n−1])
其中 a 是滤波系数,一般远小于 1。
把第二个式子做个简单的代数变形:y[n] = a · x[n] + (1 − a) · y[n−1]。你发现什么了?它和第一个式子完全一致,只要把 α 和 a 对应上就行了。这没什么好神秘的,这就是同一个线性递推方程。
2.2 展开后的“指数记忆”:为什么叫这个名字
若不信两者等价,可以把递推式反复展开,看它到底在算什么。从最开始的初始值 s[0] 开始,经过 t 步之后:
s[t] = α · x[t] + α(1−α) · x[t−1] + α(1−α)² · x[t−2] + … + (1−α)ᵗ · s[0]
每一项的权重是 α(1−α)ᵏ。这是一个等比数列。如果 α = 0.2,那么最新样本的权重是 0.2,前一个样本的权重是 0.16,再前一个是 0.128,依此类推。权重随着时间往前推移,按照指数的速度衰减,永远到不了零,但会趋向于零。
这就是“指数移动平均”这个名字的由来——它给所有历史数据都分配了一个指数衰减的权重。也正是这个特性,让它在不引入滞后感的同时,起到了平滑作用。你一阶低通滤波器的“电容记忆效应”,在离散域里被这条递推式精确复刻了,逻辑是完全一致的。
2.3 频域视角:为什么它叫“低通”
平滑的另一面是“滤掉高频”。如果你把递推式做Z变换,能得到系统的传递函数:
H(z) = α / (1 − (1−α) · z⁻¹)
这个系统在 z = 1(直流,也就是频率为 0 的分量)处增益是 1,相位是 0。随着数字频率升高,增益开始下降,到了奈奎斯特频率(即采样频率的一半)附近,增益会衰减得非常厉害。所以,它把低频分量完整放过去,把高频分量压制住,这就是低通的含义。
不过有一点必须说清楚:一阶低通的频率响应并不陡峭,从通带到阻带是缓慢过渡的。如果你想要一个“砖墙式”的截止特性,对不起,一阶做不到。它只适合做温和的平滑,不适合做严苛的频带选择。
3. 工程实现:离散化细节与参数换算
3.1 从模拟到数字:那个关键的 a 是怎么来的
很多做嵌入式或者电路的朋友,第一反应是“我硬件上用了一个一阶RC滤波器,截止频率是 f_c,对应的时间常数 τ = RC = 1 / (2πf_c),那我在软件里应该怎么写?”
好问题。这里的关键是离散化的方法。如果直接采用最简单、也是最常见的前向欧拉或后向差分近似,再做一个近似处理,会得到一个很实用的简化公式:
a = Δt / (RC + Δt)
其中 Δt 是采样周期,即两次采样之间的时间间隔。这个公式不是精确解,但它思路清晰、实现简单、参数物理意义明确。如果你的系统对相位特性要求不是极端苛刻,这个近似在绝大多数工程场景里都够用。
举个例子。假设你用单片机每毫秒采样一次,Δt = 0.001 秒。你希望抑制 50 Hz 的工频干扰,选一个截止频率 f_c = 5 Hz 的滤波,则 τ = 1 / (2π × 5) ≈ 0.0318 秒,RC + Δt ≈ 0.0328,所以 a ≈ 0.001 / 0.0328 ≈ 0.0305。把这个 a 代进递推式,一个软低通就写完了。
再提一句,如果你用的是双线性变换法,得到的系数会略有不同,因为双线性变换做了频率预畸变,能够精确地把模拟截止频率映射到数字频率上。但它的算式会稍微复杂一些。我的建议是:做传感器平滑,用简化公式就够了;做严格滤波器设计,再用双线性变换也不迟。
3.2 写代码:一份可以直接抄走的实现
我直接用 Python 写一个最小实现,完整可运行。假设你有一组带噪声的传感器数据,想拿它做平滑。
import numpy as np import matplotlib.pyplot as plt def ema_lowpass(data, alpha): out = np.empty_like(data) out[0] = data[0] for i in range(1, len(data)): out[i] = alpha * data[i] + (1 - alpha) * out[i-1] return out # 模拟一组带噪声的信号 np.random.seed(42) fs = 1000 # 采样率 1000 Hz t = np.arange(0, 1.0, 1/fs) true_signal = np.sin(2 * np.pi * 2 * t) # 2 Hz 有效信号 noise = 0.3 * np.random.randn(len(t)) raw = true_signal + noise alpha = 0.04 smoothed = ema_lowpass(raw, alpha) plt.figure(figsize=(10, 4)) plt.plot(t, raw, alpha=0.4, label='raw') plt.plot(t, smoothed, linewidth=2, label='smoothed') plt.legend() plt.title(f"EMA / 一阶低通滤波, alpha={alpha}") plt.show()这段代码很直白,无非是把递推公式翻译成循环。但有几个实现层面的细节,我踩过坑之后必须叮嘱一下:
- 初始值初始化。我这里直接拿 data[0] 作为初始平滑值。如果数据前几个点有明显异常,建议先扔几个点再启动滤波,或者把初始值设为前 N 个点的均值,不然开头会有一段明显的收敛过程。
- 数值精度。α 很小的时候,1−α 很接近 1,在单精度浮点里反复乘会有精度损失,长时间运行可能出问题。嵌入式环境下尽量用 double 或者用定点 Q 格式小心处理。
- 只用一个状态变量。这个滤波器是“无记忆”的?恰恰相反,它的记忆就藏在那个单状态量 s[t−1] 里。你不需要存整段历史,只需要一个全局变量。这既是它内存友好的原因,也是它响应滞后的根源。
3.3 alpha 到底应该取多少:一组经过实测的经验尺度
很多初学者喜欢问“α 设多少合适”。这个问题没有标准答案,但可以给一组经验尺度,省得你盲猜:
- α = 0.5 ~ 1.0:响应极快,几乎没怎么平滑,适合只需要轻微去毛刺的场合;
- α = 0.1 ~ 0.3:常规平滑,适合大多数传感器数据,能明显降低抖动,滞后感还能接受;
- α = 0.01 ~ 0.05:强平滑,曲线会很干净,但滞后明显,适合对实时性要求不高的指标监控,比如温度、湿度这种慢变量;
- α = 0.001 以下:极强平滑,基本就是取长时均值了,只适合非常稳定且无突发变化的场景。
有个小的对照公式也可以记住:滤波器的截止频率 f_c 与 α 的关系,在采样频率 f_s 下大致可以写成:
α ≈ (2π · f_c / f_s) / (1 + 2π · f_c / f_s)
这个公式是从后向差分近似推导出来的。反过来,如果你想按截止频率来选 α,可以先算好上面那个比值,再代公式。我平时调参的时候,比较习惯的做法是先估算时间常数 τ,然后用 α = 1 − exp(−Δt / τ) 去设置,这个取值在离散采样率不高的时候更精确。
4. 为什么能滤噪:从“权重分配”理解滤波的本质
4.1 频域之外的第二条理解路径
低通滤波可以理解成“给不同频率不同的放行比例”,这是频域路径。但对很大一部分只写业务代码的开发者来说,“频率”这个抽象概念并不直观。我想提供第二条理解路径:把滤波看作一次加权平均。
当你计算 s[t] = α · x[t] + (1−α) · s[t−1] 的时候,你其实是在说:这一时刻的平滑值,有 20% 来自新样本,80% 来自过去的全部记忆(以指数衰减的权重分配)。如果 α 很小,新样本的发言权很小,过去长时间积累的信息主导着输出,所以曲线表现为“拉不动的厚重感”——噪声被压住了,但真实信号的变化也会被延迟感知。
这就是为什么滤波和滞后是一对分不开的双胞胎。任何试图用一阶低通去平滑信号的人,都必须接受“平滑程度越好,滞后越大”这个现实。试图用单个一阶环节既完全滤掉噪声又不损失动态响应,是不可能的。这属于物理世界的等价交换,不讲价。
4.2 跟滑动平均比,哪个更好
经常会有人把滑动平均(SMA)拎出来跟 EMA 对比。滑动平均是“过去 N 个点的平均”,权重是均匀分配;EMA 是“全部历史的指数衰减加权”,权重越近越高。两者差异我直接列个表:
| 特性 | 滑动平均 SMA | 指数移动平均 EMA / 一阶低通 |
|---|---|---|
| 需要存储的样本数 | 需要维护长度为 N 的窗口数据 | 只需一个状态变量 |
| 权重分配 | 窗口内均匀,窗口外为零 | 全部历史指数衰减 |
| 对近期变化的敏感度 | 相对适中 | 可调,α 大则敏感 |
| 滞后感 | 固定为 (N−1)/2 个采样周期 | 由 α 决定,α 小则滞后大 |
| 内存占用 | 高,窗口越长越大 | 极低 |
| 频率响应 | 有零点,会出现波纹 | 平滑单调衰减,无波纹 |
如果你的系统内存极其紧张,比如单片机只有几百字节的 RAM,那 EMA 显然更划算。如果你的信号中存在规律性噪声并且你精确知道其周期,用滑动平均往往能通过窗口大小把对应频率的零点对准噪声频点,得到很好的抑制。两种工具没有高下之分,关键看你的约束条件。
5. 利用等价性跨界调参:一套吃遍两个领域的参数视角
5.1 从强化学习看到一阶低通
有意思的迁移来了。一旦你认识到 EMA 和一阶低通是同一个公式,很多别处的经验就能直接搬过来用。
在强化学习和深度学习优化器(比如 Adam)里,梯度的一阶矩估计和二阶矩估计用的就是指数移动平均。PyTorch 源码里直接用beta1、beta2控制动量。比如betas=(0.9, 0.999),这个 0.9 就是 EMA 的衰减系数。你换一个角度看:这实际上是给梯度信号做了一阶低通滤波,把高频的、随机涨落的梯度分量滤掉,只让稳定的、低频的趋势分量去指导更新方向。
把深度学习里的经验搬回传感器领域会是什么效果?我做过一个实验:用 β = 0.99 的 EMA 来处理惯性测量单元的加速度计数据,效果和我手工设计一个截止频率约 1.6 Hz 的一阶低通几乎完全重合。为什么?因为 β 和 α 之间就有一个精确的换算关系,α = 1 − β。你不需要重新发明参数,你只需要一个视角迁移。
5.2 从控制系统角度理解“抗扰性”
控制系统里有个概念叫系统带宽。一阶低通的带宽就是 −3 dB 截止频率。低于带宽的干扰信号会被系统跟踪,高于带宽的会被抑制。这在传感器反馈回路里的含义很直接:如果闭环控制的带宽是 1 Hz,而你传感器上叠加了 10 Hz 的机械振动噪声,那么只要把滤波截止频率设在 0.5 Hz 左右,就能把噪声隔离在控制回路之外。这个“带宽匹配”的思维,是很多刚接触滤波的人容易忽略的——只知道滤得越狠越干净,却不知道滤波本身也是系统的一部分,它会改变系统的相位裕度,削弱稳定性。
我见过有人在 PID 控制里把滤波截止频率设得很低,结果噪声确实没了,但系统开始抖动。为什么?因为滤波器给反馈信号引入了额外滞后,等效于给系统增加了相位延迟,把原本充裕的相位裕度吃掉了,系统自然变得不稳定。这个坑,我用一句话总结:滤波别只顾着看平滑效果,一定要放在整个控制回路里评估联动影响。
6. 实操经验:从调参到边界情况,把坑都替你踩一遍
6.1 失真的源头:初始化、突发脉冲与截止频率选择
即使公式这么简单,实际使用的坑依然不少。我先列常见问题及对策,都是我们实际联调中一行行调出来的经验。
| 现象 | 原因 | 处理建议 |
|---|---|---|
| 输出曲线开头有一段缓慢爬升 | 初始值设置不当,滤波还在收敛中 | 预热期:先采 N 个点,取均值作为 s[0] 再启动输出 |
| 尖峰脉冲无法被有效抑制 | α 太大,单点权重过高 | 适当减小 α,或者先做一次“离群点剔除”预处理 |
| 突发掉电或者传感器卡死,输出被带偏很久 | EMA 对异常值的“记忆”很长 | 加异常检测: |
| 输出与真实信号相比有肉眼可见的延迟 | α 设置太小,滞后严重 | 折中调参;或者考虑自适应滤波,根据信号变化率动态调整 α |
| 数值长时间运行后出现偏差 | 单精度浮点误差累积 | 改用 double,或定期用原始均值重置状态 |
6.2 一个自适应变体:信号平坦就多滤,信号突变就少滤
实际项目里,固定 α 的 EMA 经常面临的尴尬是:静态场景下觉得滤得不够干净,动态场景下又觉得滤得太钝。既然 α 可以是一个变数,自然可以设计出“看人下菜碟”的自适应策略。
一个比较实用的思路是:依据当前样本与上一个平滑值的偏差大小动态调节 α。偏差大说明可能处于快速变化期,应当让新样本权重变大,减小滞后;偏差小说明当前比较平稳,应当加大滤波强度把残余噪声磨平。
def adaptive_ema(data, alpha_base, k): out = np.empty_like(data) out[0] = data[0] for i in range(1, len(data)): diff = abs(data[i] - out[i-1]) # diff 越大,alpha 越接近 1 alpha = alpha_base + (1 - alpha_base) * (diff / (diff + k)) out[i] = alpha * data[i] + (1 - alpha) * out[i-1] return out这里的 k 是一个缩放参数,决定了偏差多大时开始让 α 快速提高。这种自适应变体在机器人或者机械臂控制里非常有用:机械臂静止的时候,期望输出是一根直线;启动和刹车的瞬间,又希望滤波不要吃掉运动的加速度信息。固定 α 根本做不到两全,自适应变体可以直接解决问题。
6.3 和卡尔曼滤波摆在一起怎么选
遇到有人问:“既然你说得这么热闹,那卡尔曼滤波是不是更好?”我觉得得把这个关系也理清楚。卡尔曼滤波的预测-更新框架,在某些条件下会退化成和 EMA 类似的递归结构,尤其是当系统模型是匀速、过程噪声和测量噪声都是定值的时候。但卡尔曼滤波的核心优势在于它能融合多传感器、能提供状态协方差估计、能显式建模过程模型。代价是实现更复杂、需要对噪声统计特性有比较靠谱的先验。
我的建议很直白:
- 如果你只有一个传感器、模型简单、想要快速见效,用 EMA/一阶低通就够了,没必要杀鸡用牛刀;
- 如果你需要融合 GPS、惯性测量单元、轮速计等多路数据,同时需要输出位置和速度估计的置信度,那值得上卡尔曼;
- 如果系统是非线性的、场景又恶劣,可能还得考虑扩展卡尔曼或无迹卡尔曼。
但所有更复杂的方案,底层都会递归地使用类似于一阶低通的平滑思想。把这个基础公式吃透,学卡尔曼的时候也会顺利很多。
6.4 一个生产环境的小细节:批量数据的离线滤波
不仅在线滤波可以用这个公式,离线数据分析也可以。处理批量历史数据时,常见操作是正向跑一遍 EMA,再反向跑一遍 EMA,然后把两次结果取平均,这样可以消除相位滞后。GitHub 上很多数据处理库里的filtfilt函数就是这种思路。两个方向各跑一遍,滞后的地方互相抵消,得到的就是零相位失真的平滑曲线。它适合离线分析,不适合实时系统,因为实时系统无法预知未来数据。
from scipy.signal import butter, filtfilt def zero_phase_filter(data, cutoff_hz, fs): b, a = butter(1, cutoff_hz, btype='low', fs=fs) return filtfilt(b, a, data)这种零相位滤波在数据分析和画图时非常好用,但它不是在线可用的算法,挺多刚接触的人容易混淆“离线”和“在线”的边界。
7. 最后分享一个我在项目里常用的调参心法
前面讲了很多原理和代码,最后聊点实在的调参手感。我每次拿到一条需要做平滑的新数据,一般不做复杂分析,直接用一条经验路径先跑起来:
- 先看信号的采样频率和主要噪声频段,定一个大致的时间常数,转成 α;
- 用 α 跑一遍出来,肉眼对比原始曲线和滤波曲线,重点观察两个地方:噪声还有多大残留、信号的尖峰延迟了多少;
- 如果噪声残留明显,则减小 α;如果尖峰延迟不能接受,则增大 α;
- 来回调两三次之后,基本就能定位在一个“既不嫌脏也不迟钝”的中间区域。
最后,我一直把一句话记在本子上:滤波永远是一个噪声抑制和动态响应之间的妥协,不存在免费的午餐。一个参数摆平一切需求的幻觉最好趁早放弃。理解了这个公式的双重身份,你就不会再纠结于“这到底是一阶低通还是指数移动平均”——公式本身没有门派,所有术语的隔阂都只是观察角度不同罢了。