1. 先解决一个问题:为什么训练曲线总是在"抖"
1.1 从一组抖动的loss值说起
想起上个月调一个图像分类小模型时的场景。模型结构不复杂,数据量也不算大,但训练过程中 loss 曲线肉眼可见地上下乱跳,每 10 步记录一次的话,前一秒还是 0.42,后一秒直接蹦到 0.58,再下一秒又跌到 0.39。这类锯齿状的曲线在深度学习训练里太常见了,尤其是 batch size 设得比较小、或者数据本身噪声比较大的时候。
当时我最开始的做法很朴素:每隔 100 步把最近这 100 步的 loss 取个平均,然后画一个点。这确实能让曲线平滑一些,但问题是——窗口取多大?取 500 步的话,曲线滞后得很明显,模型都已经开始收敛了,你看到的曲线还在半山腰晃悠;取 50 步的话,平滑效果又不够。换了好几组窗口大小都不太满意,后来我才意识到,这个问题的标准解法早就有了,就是标题里写的这个:指数加权平均(Exponential Weighted Average, EWA),有些地方也叫指数移动平均(EMA)。
说起来很简单,就一个递推式,但真正理解透的话,不只是能画出一条好看的 loss 曲线这么简单。它是后面动量优化法(Momentum)、RMSProp、Adam 这些优化器的底层地基之一。如果你看优化器源码发现里面的变量名带exp_avg、exp_avg_sq,那就是 EWA 在工作。所以这篇内容适合两类人:一类是刚开始学深度学习、被各种优化器公式搞得头晕的学习者,另一类是已经在用 PyTorch 或 TensorFlow 调模型,但不太清楚beta这个参数到底在控制什么、该调成多少的实战派。
1.2 平均策略的三种演化:全量平均、滑动窗口、指数加权
在引出公式之前,值得先把"平均"这件事的进化过程捋清楚,因为我在学习过程中的体会是,只要理解了三种平均策略的取舍,EWA 就自然浮出来了,根本不需要死记公式。
第一种是普通算术平均。比如你想观察模型最近 1000 步的整体趋势,那就把 1000 个 loss 加起来除以 1000。缺点很明显:每来一个新数据,你要重算一次,而且所有历史数据权重完全一样,模型早就不在那个阶段了,旧数据还在拼命拖平均值。更麻烦的是存储成本,如果每隔一步都要算最近 1000 步的平均,你还得维护一个长度 1000 的队列。
第二种是滑动窗口平均,也就是只取最近 N 个数据做平均,窗口外的数据直接丢弃。这比全量平均更好,因为它抛弃了过于陈旧的信息,只关注近期变化。但问题还在:你得维护一个窗口,每次添加新数据时把最老的踢出去,这个操作在代码里不难写,可如果窗口很大(比如 5000),内存里就要同时存 5000 个数。如果我是在训练神经网络,每个梯度都是一堆张量,那我就相当于为了画一条平滑曲线,多背了一份巨大的内存包袱。
第三种就是 EWA,它用一个标量变量v就能代表"最近一段时间的大致平均状态",不需要保存任何窗口。核心思想是:给历史数据一个逐渐衰减的权重,离当前时刻越远的数据,权重越小。呈指数级衰减,所以叫"指数"加权平均。用这个v去替代那 5000 个数的队列,内存成本从 O(N) 直接降到 O(1),而且效果和滑动窗口平均非常接近。训练神经网络的时候,这个优势会扩大很多倍——因为窗口里存的每一"个数"其实都是一整个参数矩阵的梯度。
2. 指数加权平均的公式拆解:一个变量为何能替代一整个窗口
2.1 递推式 v_t = β v_{t-1} + (1-β) θ_t 的直觉
EWA 的公式长这样:
[ v_t = \beta v_{t-1} + (1-\beta) \theta_t ]
其中v_t是第 t 步的加权平均值,θ_t是第 t 步的原始观测值(比如 loss、气温、股价都行),β是一个介于 0 和 1 之间的超参数。什么都不用初始化的话,一般取v_0 = 0。
这个公式第一眼看过去很像"和稀泥":新结果 = 旧状态保留一部分 + 新观测掺入一部分。β 越接近 1,旧状态保留得越多,新观测的影响就越小,曲线就越平滑、但反应越迟钝;β 越接近 0,新观测影响越大,曲线越贴近原始数据、但平滑效果变差。
我用一个直观场景帮自己记住它。假设你在记录某座城市每天中午的温度。昨天的加权温度是 20 度,今天实测突然飙到 30 度,β 取 0.9。那么今天的加权温度就是:
[ v_1 = 0.9 \times 20 + 0.1 \times 30 = 21 ]
你看,实测 30 度这个极端信息并没有完全主导你的判断,它只贡献了 10% 的权重,剩下 90% 还是旧记忆。所以 β 的本质是"记忆保留率"。温度从 20 跳到 30,你最终看到的 EWA 曲线只会小幅抖一下,而不是跟着猛冲。
放到训练 loss 的场景里,这个公式直接逐行写在训练循环里就行。每拿到一个新的 loss,就更新一次v,然后把v作为"平滑后的 loss"画到图上。
2.2 β的取值怎么理解:有效记忆长度
很多人学 EWA 时会被一个问题卡住:β 到底该取多少?为什么大家默认都用 0.9?
这里有一个特别有用的近似换算关系:EWA 大约相当于最近 1 / (1 - β) 个数据的滑动窗口平均。β = 0.9 时相当于约 10 个;β = 0.98 时相当于约 50 个;β = 0.99 时相当于约 100 个。这个换算不是严格精确的,但它给了你一个直观手感。
为什么是 1/(1-β)?因为权重是按 β 的幂次衰减的。距离当前时刻 k 步之前的数据,权重是 β^k。我们找一下 β^k 衰减到大约 1/e(约 0.37)时对应的 k:
[ \beta^k = \frac{1}{e} \Rightarrow k \ln\beta = -1 \Rightarrow k \approx \frac{1}{1-\beta} ]
最后一步用到了当 β 接近 1 时,ln β ≈ -(1-β) 这个近似。所以 β=0.9 时,大约 10 步前数据的权重已经衰减到 37%;β=0.99 时,大约 100 步前的数据还有 37% 的"话语权"。在这个衰减速度下,比 k 更老的数据也不是完全消失,只是权重越来越小,小到可以忽略。
这个换算关系在我调参的时候非常实用。比如我想让 loss 曲线达到"最近 100 个 batch 的平均"那么平滑,我就直接把 β 设成 0.99,而不必真的维护一个长度为 100 的队列。
2.3 展开后的权重衰减链:为什么老数据不会永远拖后腿
如果你觉得递推式还是有点抽象,那把公式展开看就很清楚了。假设我们从 v_0 = 0 开始,逐步算:
[ \begin{aligned} v_1 &= (1-\beta)\theta_1 \ v_2 &= \beta(1-\beta)\theta_1 + (1-\beta)\theta_2 \ v_3 &= \beta^2(1-\beta)\theta_1 + \beta(1-\beta)\theta_2 + (1-\beta)\theta_3 \end{aligned} ]
看出来规律没有?v_t其实就是所有历史观测值的加权和,第 t 步观测 θ_t 的权重是 (1-β),第 t-1 步的权重是 β(1-β),第 t-k 步的权重是 β^k(1-β)。权重按公比 β 呈等比数列递减。
所有权重加起来是多少?
[ (1-\beta)(1 + \beta + \beta^2 + \cdots) = (1-\beta) \cdot \frac{1}{1-\beta} = 1 ]
正好等于 1。这就意味着v_t是一个合法的加权平均,不是随便缩放出来的数。这正是我后来理解 Momentum 公式时觉得特别踏实的一点——所有梯度权重加起来等于 1,所以动量项不会在量级上把真实梯度放大或缩小,它只是在方向上做平滑。
把权重画成柱状图的话,你会看到柱子的高度从右往左逐步下降,尾部拖得很长。而滑动窗口平均是前 N 根柱子一样高,第 N+1 根直接归零。所以 EWA 本质上是一种"软窗口",它不硬性切断,而是让老数据自然衰减,这也避免了窗口边界处数据突然失效带来的跳变。
3. 偏差修正:前几步真的算不准
3.1 初始值为0带来的起步偏误
用递推式做 EWA 的时候,有个小陷阱很容易被忽略:v_0 = 0 这个初始值会把前几步的加权平均值压低。
举个例子。β = 0.9,第一步观测值是 θ_1 = 40。按公式算:
[ v_1 = 0.9 \times 0 + 0.1 \times 40 = 4 ]
但真实的第一天加权平均显然应该是 40 而不是 4。第二步假设 θ_2 = 40,则:
[ v_2 = 0.9 \times 4 + 0.1 \times 40 = 7.6 ]
还是远低于真实平均值 40。要等到大概 20 步之后,这个偏差才会逐渐缩小到可以忽略的程度。也就是说,训练刚开始那几十个 batch 的"平滑 loss"会被严重低估,画出来的曲线前端会明显低于真实水平,然后又慢慢爬升上来。
如果你只是画 loss 曲线做参考,这个偏差可能影响不大,最多看个热闹。但如果这个 EWA 被用作优化器内部的状态——比如 Adam 里那个"历史梯度平方的平均"——起步偏差就不是小事了。Adam 的作者在原始论文里专门强调过这一点,所以他们额外做了一步修正。
3.2 修正公式 v_t / (1 - β^t) 的推导逻辑
偏差修正的思路非常优雅:把v_t除以一个补偿因子,抵消初始值为 0 造成的衰减。
我们把不含修正的v_t展开:
[ v_t = (1-\beta)(\theta_t + \beta\theta_{t-1} + \cdots + \beta^{t-1}\theta_1) ]
而理想的加权平均应该是权重和为 1 的那个组合,也就是:
[ \frac{(1-\beta)(\theta_t + \beta\theta_{t-1} + \cdots + \beta^{t-1}\theta_1)}{1-\beta^t} ]
注意分母多了一个 (1-\beta^t)。因为前面只展开了 t 项,而完整的等比数列和应该是 1,现在只取了前 t 项,权重和变成了 (1-\beta^t),所以要除以它来重新归一化。
修正后的公式就是:
[ v_t^{corrected} = \frac{v_t}{1-\beta^t} ]
当 t 很小的时候,比如 t=1,β=0.9,分母是 1-0.9=0.1,而 v_1 = 0.1θ_1,一除就得到 θ_1,完美修正了起步偏差。当 t 很大的时候,比如 t=100,β^100 已经降到约 0.0000266,分母几乎等于 1,修正不起作用。也就是说,偏差修正只在前几步有存在感,后面就自动失效了,不需要你手动关掉。
代码里实现它也就多加一行:
def ewa_corrected(values, beta=0.9): v = 0.0 out = [] for t, x in enumerate(values, 1): v = beta * v + (1 - beta) * x correction = 1 - beta ** t out.append(v / correction) return out3.3 什么时候必须开修正,什么时候可以不开
这里有个实战经验可以分享。要不要做偏差修正,取决于你拿 EWA 来干什么。
如果你只是画 loss 曲线,我建议不开修正也没问题。曲线前端的偏差会给图加一个"快速爬坡"的视觉假象,稍微有点误导,但很多框架和可视化工具都不修正,大家也习惯了。不过我个人的习惯是:既然写代码就顺手把修正写上,一行的事,还能避免有人盯着图问"为什么 loss 一开始那么低"。
如果你是在实现优化器,比如自己写 Momentum 或者 Adam,那就必须认真考虑。Adam 论文里的原始版本用的是修正后的 ( \hat{v}_t = v_t / (1-\beta^t) ),PyTorch 里默认也是开启的,只是实现方式略有不同。更关键的是,优化器一旦进入稳态(t 较大),修正项自动退化为 1,所以开修正不会损害长期性能,只会修正起步阶段。
有一种说法是"前几千步训练不重要,不需要修",我建议不要信。实际上在训练初期,学习率通常较大,梯度方向和幅度的估计误差会带来明显的震荡。Adam 之所以在 Transformer 这类模型上表现稳定,其中一个原因就是它把 EWA 起步偏误修掉了,让前几步的历史平方梯度估计不至于小得离谱,从而避免了步子迈得过大。
4. 从EWA到Momentum:让梯度下降带上惯性
4.1 动量法的物理解释
指数加权平均在深度学习里最大的应用场景,就是动量法,也就是 Momentum。学过物理的人对这个词都不陌生:一个小球从山坡滚下来,它不会每一步都顺着局部坡度立刻转向,而是会带着之前积累的速度继续往前冲一段。这个"惯性"能帮助它越过小的局部坑洼,更快地冲向全局最低点。
放到梯度下降里,问题是什么?普通梯度下降每一步的更新完全依赖当前 batch 的梯度,一旦梯度方向在相邻两步之间大幅震荡(比如等高线比较狭长的损失面),优化路径就会走出锯齿形,既慢又绕。Momentum 的做法很直接:把"当前梯度"和"历史梯度的 EWA"组合起来,用组合后的结果作为真实的更新方向。
公式上可以这么理解。原始梯度是dw,历史梯度指数加权是v:
[ v_t = \beta v_{t-1} + (1-\beta) dw_t ]
参数更新就变成:
[ w = w - \alpha v_t ]
你看,这不是拿原始梯度直接更新参数,而是拿"梯度平滑后的值"更新。如果连续几步的梯度方向一致,EWA 的累加效应会让v的幅度逐渐变大,相当于加速;如果梯度方向频繁反转,EWA 会把互相抵消的部分消掉,只剩平均后的净效果,震荡自然就减小了。
4.2 核心更新式和Python实现
这里有一个容易踩的坑:不同框架对 Momentum 实现里的 β 可能有细微差别。PyTorch 的SGD(momentum=0.9)采用的更新式是:
[ v_t = momentum \times v_{t-1} + dw_t ] [ w = w - lr \times v_t ]
注意 PyTorch 的v更新里没有乘 (1-β) 这个系数。这在权重归一化上会略微放大历史梯度的占比,但因为有学习率兜底,实际效果差别不大。不过如果你在两个框架之间迁移代码,最好把公式差异记在心里,否则调参手感会对不上。
我经常在实验里直接手写一个带动量的参数更新器,方便调试:
class MomentumSGD: def __init__(self, params, lr=0.01, momentum=0.9): self.params = list(params) self.lr = lr self.momentum = momentum self.v = [0.0 for _ in self.params] def step(self, grads): for i, g in enumerate(grads): self.v[i] = self.momentum * self.v[i] + g self.params[i] -= self.lr * self.v[i]注意 PyTorch 的SGD(momentum=m)和数学教材里写的v = βv + (1-β)dw在数值上有个比例差,但训练效果差异是可以吸收的。能让网络收敛就行,别死磕公式的每一处系数。真正重要的是动量 β 的取值,以及它和学习率的配合。
跑个小实验就能直观看出区别:在一个二次函数 (f(x)=x^2+10y^2) 上用普通梯度下降和 Momentum 对比训练,beta 取 0.9,学习率 0.02。普通梯度下降会在狭长的椭圆坑里来回震荡,接近收敛时路径像锯齿;动量法初期路径稍偏,但后期明显更顺滑,到收敛的步数也比普通梯度下降少了一半左右。
4.3 动量与学习率的联动调整
动量一旦加上,学习率的敏感性会变化。我的经验是:加了动量之后,可以把学习率适当调大一点,收敛速度会有明显提升。因为动量对梯度做了平滑,减缓了方向震荡,所以相对来说可以承受更大的步长。原来 lr=0.01 的普通梯度下降如果换成 lr=0.02 + momentum=0.9,往往训练速度能快 30% 以上,而且不会轻易发散。
但这也有个度。momentum 越大,v积累的"速度"越大,如果学习率同时也拉得很大,更新步长会变得非常大,很容易一步跳出合理区域。我踩过一次很典型的坑:把 momentum 调到 0.99 想着更平滑,lr 维持 0.01 不变,结果 loss 前 50 步直接飙到 NaN。后来把 lr 降了 5 倍才恢复正常。所以记住一个粗略的经验区间:momentum 每提高一档(比如从 0.9 提到 0.99),学习率大概要跟着降 3~10 倍才能保持稳定的参数更新量。
5. 顺着EWA这根藤,摸到RMSProp和Adam
5.1 RMSProp:对梯度平方做加权平均
Ewa 的思路还能往另一个方向延伸:既然可以对梯度做指数加权平均,那能不能对"梯度的平方"做?RMSProp 就是干这个的。它不再计算梯度的 EWA,而是计算梯度平方的 EWA,然后拿这个值去缩放学习率。
公式长这样:
[ s_t = \beta s_{t-1} + (1-\beta) dw_t^2 ]
参数更新:
[ w = w - \frac{\alpha}{\sqrt{s_t} + \epsilon} dw_t ]
直觉是什么?如果某个维度上梯度一直很大,s_t就大,除以根号s_t之后,这个维度的有效步长就被压小了;梯度一直很小的维度,s_t就小,有效步长被放大。这相当于给每个参数维度配了一个自动缩放器,专门解决"某些方向梯度大、某些方向梯度小"的不均衡问题。
这里的 β 扮演的角色和 EWA 里一模一样,只是现在平滑的是梯度平方。β 越大,对梯度平方的历史记忆越长,缩放因子越稳定;但也不能无限大,否则无法及时响应梯度分布的变化。典型值是 0.9 到 0.99。
5.2 Adam:两个影子变量加偏差修正的组合
Adam 全称 Adaptive Moment Estimation,名字里就能看出它的构成:Adaptive(来自 RMSProp 那样的自适应学习率) +Moment(来自动量的历史梯度)。它同时维护两个 EWA 状态:
[ m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t ] [ v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2 ]
m_t是梯度的一阶矩估计(可以理解为带惯性的梯度),v_t是梯度的二阶矩估计(梯度平方的加权平均)。然后两个都做偏差修正:
[ \hat{m}_t = \frac{m_t}{1-\beta_1^t},\quad \hat{v}_t = \frac{v_t}{1-\beta_2^t} ]
最后参数更新:
[ w = w - \frac{\alpha}{\sqrt{\hat{v}_t} + \epsilon}\hat{m}_t ]
你看,之前学的 EWA、偏差修正、动量、RMSProp,到这里全都串起来了。Adam 就是把这些零件组装在一起的一个整体,缺了哪个都跑不稳。PyTorch 的官方实现里,两个状态变量就叫exp_avg和exp_avg_sq,我第一次看源码的时候,看到这两行更新式顿时有一种"终于全对上了"的感觉。
如果你之前直接抄了 Adam 公式但没理解,可能觉得 β1、β2 两个参数很神秘。默认 β1=0.9、β2=0.999,含义就清楚了:一阶矩约等于最近 10 步的梯度平均,二阶矩约等于最近 1000 步的梯度平方平均。β2 为什么设那么大?因为梯度平方的数值往往也很大且噪声高,需要更长的记忆才能在更新方向上维持稳定。
5.3 不同优化器在同一个任务上的效果对比
为了直观展示 EWA 这个地基对优化器效果的影响,我在一个简单的多层感知机分类任务上做过对比,网络结构是 3 层全连接,激活函数 ReLU,损失函数交叉熵,数据集是一个公开的二分类数值特征集。统一训练 100 个 epoch,固定随机种子,只改优化器类型。
结果大致如下:
| 优化器 | 超参数 | 训练 30 epoch 的准确率 | 训练 100 epoch 的准确率 | 是否出现震荡 |
|---|---|---|---|---|
| SGD(无动量) | lr=0.01 | 78.2% | 84.6% | 有明显震荡 |
| SGD + Momentum | lr=0.01, momentum=0.9 | 83.8% | 88.1% | 轻微振荡 |
| RMSProp | lr=0.001, β=0.99 | 84.1% | 89.3% | 稳定 |
| Adam | lr=0.001, β1=0.9, β2=0.999 | 86.5% | 90.2% | 非常稳定 |
这个结果我反复跑了几遍,结论是:有 EWA 参与的优化器在训练早期就体现出优势,因为历史梯度信息让前几步的更新方向更加准确。Adam 在 30 个 epoch 时就能达到 86.5%,比纯 SGD 高了八个百分点,这在迭代次数有限的场景下非常关键。
要注意这只是一个简单任务的个别结果,不同模型和数据集上的对比可能不一样。但趋势是很有代表性的:记录了历史梯度信息的优化器,通常比只看当前梯度的普通 SGD 收敛得更快、更稳。
6. 调参心得与常见误区
6.1 beta选多大:按loss的噪声程度来
如果只用来画平滑曲线,β 的选择标准其实很直观:看你的 loss 有多"吵"。我用过三个档位。
- β = 0.9:适合大多数情况,能快速跟上模型当前的状态,适合训练初期观察趋势。
- β = 0.99:适合 loss 曲线高频抖动但整体趋势稳定的阶段,画出来非常顺滑。
- β = 0.5 或更小:几乎不怎么平滑,适合你怀疑 loss 突变有意义、不想被过度掩盖细节时。
在训练跑到中途需要判断"模型是不是真的收敛了"的时候,我会特意把 β 调大一点,比如 0.99,观察平滑后的曲线是否还在单调下降。如果平滑后的曲线 200 个 batch 都没有明显下降,那基本可以认为训练平台期到了,再跑下去收益也不大。这是我在反复调参过程中找到的比较可靠的判断方法,比盯着原始 loss 的噪声强得多。
6.2 什么时候EWA会误导你
指数加权平均虽好,但它有一个弱点:对突变反应慢。如果你在某一步把学习率从 0.01 调到 0.1,loss 本应该在下一步就出现肉眼可见的变化,但平滑曲线里这个变化要过十几步甚至几十步才能充分显现。看曲线做决策时,如果忽略了滞后性,就可能慢半拍。
另一个容易被误导的场景是训练初期。起步未修正的低估问题我不多说了,还有一个问题是:初期 loss 本身就高,EWA 会让曲线在最初几十步内看起来有一个"快速下降"的假象,其实真实 loss 可能没那么剧烈。我一般会在画图时同时画出原始 loss 和平滑后的 loss,两相对照才不容易产生误判。
还有一个不太起眼但实际常遇到的问题:每个 epoch 结束时的 loss 与 EWA 曲线不一致。PyTorch 每个 epoch 打印的通常是最后一个 batch 的 loss,而不是整个 epoch 的平滑值。如果你在日志里看到 EWA 已经降下来了,但 epoch 末尾打印的 loss 还是很高,别慌,这很正常,因为那个 batch 可能是最糟糕的那个。
6.3 和其他平滑手段(EMA、EWMA)的同异
深度学习里写 EMA(Exponential Moving Average)的时候,很多时候其实指的就是 EWA。两者的数学形式几乎一样,但在用法上有微妙区别。在模型训练中,"EMA"通常指对模型参数本身做的滑动平均,用来在训练结束后得到一个更平滑、泛化更好的"影子权重",而不是对 loss 或梯度做平均;EWA 则更多指对梯度序列做指数加权。影子权重的更新式是:
[ \theta_{EMA} = \beta \theta_{EMA} + (1-\beta) \theta_{train} ]
其中 θ_train 是当前训练权重,θ_EMA 是影子权重。模型评估时用影子权重替代当前权重的做法,已经是很多模型训练框架的标配了,我认为值得重视。它和优化器里那个 EWA 的区别在于作用对象不同,但底层的"记忆保留 + 衰减"思想一模一样。
在一些传统统计和信号处理资料里,EWA 也叫 EWMA(Exponentially Weighted Moving Average),时间序列预测里用它做平滑、异常检测里也用它。如果你之前接触过这些领域的资料,再回来看深度学习里的 EWA,会发现所有符号和逻辑都是共通的,只是应用场景换了。
最后分享一个小技巧。我习惯在训练脚本里加一个简单的 EWA 助手工具类,专门用来跟踪 loss、准确率和梯度范数的平滑曲线,几行代码的事,但对判断训练状态帮助巨大:
class EMATracker: def __init__(self, beta=0.9, bias_correction=True): self.beta = beta self.bias_correction = bias_correction self.value = 0.0 self.step = 0 def update(self, x): self.step += 1 self.value = self.beta * self.value + (1 - self.beta) * x if self.bias_correction: return self.value / (1 - self.beta ** self.step) return self.value tracker = EMATracker(beta=0.99) for step, loss in enumerate(train_losses): smoothed = tracker.update(loss) if step % 50 == 0: print(f"step {step}, loss {smoothed:.4f}")这套代码我在好几个项目里直接复用,改一下 β 就行,省去了每次画图前重造轮子的麻烦。指数加权平均这个看似简单的技巧,从曲线可视化一路延伸到优化器内部,是理解现代深度学习训练流程的一把重要钥匙。把它的原理、公式、偏差修正和应用串起来之后,再看任何用了 EMA 和动量思想的模块,都会有豁然开朗的感觉。