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 根柱子一样高第 N1 根直接归零。所以 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 很小的时候比如 t1β0.9分母是 1-0.90.1而 v_1 0.1θ_1一除就得到 θ_1完美修正了起步偏差。当 t 很大的时候比如 t100β^100 已经降到约 0.0000266分母几乎等于 1修正不起作用。也就是说偏差修正只在前几步有存在感后面就自动失效了不需要你手动关掉。代码里实现它也就多加一行def ewa_corrected(values, beta0.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(momentum0.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, lr0.01, momentum0.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(momentumm)和数学教材里写的v βv (1-β)dw在数值上有个比例差但训练效果差异是可以吸收的。能让网络收敛就行别死磕公式的每一处系数。真正重要的是动量 β 的取值以及它和学习率的配合。跑个小实验就能直观看出区别在一个二次函数 (f(x)x^210y^2) 上用普通梯度下降和 Momentum 对比训练beta 取 0.9学习率 0.02。普通梯度下降会在狭长的椭圆坑里来回震荡接近收敛时路径像锯齿动量法初期路径稍偏但后期明显更顺滑到收敛的步数也比普通梯度下降少了一半左右。4.3 动量与学习率的联动调整动量一旦加上学习率的敏感性会变化。我的经验是加了动量之后可以把学习率适当调大一点收敛速度会有明显提升。因为动量对梯度做了平滑减缓了方向震荡所以相对来说可以承受更大的步长。原来 lr0.01 的普通梯度下降如果换成 lr0.02 momentum0.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和Adam5.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 两个参数很神秘。默认 β10.9、β20.999含义就清楚了一阶矩约等于最近 10 步的梯度平均二阶矩约等于最近 1000 步的梯度平方平均。β2 为什么设那么大因为梯度平方的数值往往也很大且噪声高需要更长的记忆才能在更新方向上维持稳定。5.3 不同优化器在同一个任务上的效果对比为了直观展示 EWA 这个地基对优化器效果的影响我在一个简单的多层感知机分类任务上做过对比网络结构是 3 层全连接激活函数 ReLU损失函数交叉熵数据集是一个公开的二分类数值特征集。统一训练 100 个 epoch固定随机种子只改优化器类型。结果大致如下优化器超参数训练 30 epoch 的准确率训练 100 epoch 的准确率是否出现震荡SGD无动量lr0.0178.2%84.6%有明显震荡SGD Momentumlr0.01, momentum0.983.8%88.1%轻微振荡RMSProplr0.001, β0.9984.1%89.3%稳定Adamlr0.001, β10.9, β20.99986.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.1loss 本应该在下一步就出现肉眼可见的变化但平滑曲线里这个变化要过十几步甚至几十步才能充分显现。看曲线做决策时如果忽略了滞后性就可能慢半拍。另一个容易被误导的场景是训练初期。起步未修正的低估问题我不多说了还有一个问题是初期 loss 本身就高EWA 会让曲线在最初几十步内看起来有一个快速下降的假象其实真实 loss 可能没那么剧烈。我一般会在画图时同时画出原始 loss 和平滑后的 loss两相对照才不容易产生误判。还有一个不太起眼但实际常遇到的问题每个 epoch 结束时的 loss 与 EWA 曲线不一致。PyTorch 每个 epoch 打印的通常是最后一个 batch 的 loss而不是整个 epoch 的平滑值。如果你在日志里看到 EWA 已经降下来了但 epoch 末尾打印的 loss 还是很高别慌这很正常因为那个 batch 可能是最糟糕的那个。6.3 和其他平滑手段EMA、EWMA的同异深度学习里写 EMAExponential Moving Average的时候很多时候其实指的就是 EWA。两者的数学形式几乎一样但在用法上有微妙区别。在模型训练中EMA通常指对模型参数本身做的滑动平均用来在训练结束后得到一个更平滑、泛化更好的影子权重而不是对 loss 或梯度做平均EWA 则更多指对梯度序列做指数加权。影子权重的更新式是[ \theta_{EMA} \beta \theta_{EMA} (1-\beta) \theta_{train} ]其中 θ_train 是当前训练权重θ_EMA 是影子权重。模型评估时用影子权重替代当前权重的做法已经是很多模型训练框架的标配了我认为值得重视。它和优化器里那个 EWA 的区别在于作用对象不同但底层的记忆保留 衰减思想一模一样。在一些传统统计和信号处理资料里EWA 也叫 EWMAExponentially Weighted Moving Average时间序列预测里用它做平滑、异常检测里也用它。如果你之前接触过这些领域的资料再回来看深度学习里的 EWA会发现所有符号和逻辑都是共通的只是应用场景换了。最后分享一个小技巧。我习惯在训练脚本里加一个简单的 EWA 助手工具类专门用来跟踪 loss、准确率和梯度范数的平滑曲线几行代码的事但对判断训练状态帮助巨大class EMATracker: def __init__(self, beta0.9, bias_correctionTrue): 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(beta0.99) for step, loss in enumerate(train_losses): smoothed tracker.update(loss) if step % 50 0: print(fstep {step}, loss {smoothed:.4f})这套代码我在好几个项目里直接复用改一下 β 就行省去了每次画图前重造轮子的麻烦。指数加权平均这个看似简单的技巧从曲线可视化一路延伸到优化器内部是理解现代深度学习训练流程的一把重要钥匙。把它的原理、公式、偏差修正和应用串起来之后再看任何用了 EMA 和动量思想的模块都会有豁然开朗的感觉。