我最早看到Adam优化器的epsilon参数时,文档写的是"term added to denominator to improve numerical stability"。我的大脑自动把它翻译成了"防止除零",然后就再也没有多想过。直到有一次我在训练一个十亿参数规模的模型时,损失曲线突然出现一个尖峰,所有常规手段——调学习率、换梯度裁剪、改batch size——都没用。最后我试着把epsilon从1e-8改成了1e-6,问题就这么消失了。那一刻我才意识到,我对这个参数的认知,从头到尾都是错的。

Adam的更新规则可以写成这样:
theta_t = theta_{t-1} - lr * m_t / (sqrt(v_t) + epsilon)
其中m_t是梯度的一阶矩估计(梯度的指数移动平均),v_t是梯度的二阶矩估计(梯度平方的指数移动平均)。v_t越大,说明这个参数的历史梯度越剧烈,更新步长就越小;v_t越小,说明这个参数一直很稳定,更新步长就可以大一些。这个设计让Adam能针对每个参数自适应调整学习率。
你可能会说:那epsilon不就是在分母上加一个小数,防止v_t为0时除以0吗?没错,这是它最原始的功能。但如果你把epsilon从1e-8调到1e-6,你会观察到整个训练的收敛行为发生明显变化——不只是从"不NaN"变成"不NaN"那么简单。这说明epsilon的作用远不止"防除零"。
Adam论文里只把它描述为"a small scalar added to the denominator to improve numerical stability"(Kingma & Ba, 2014),但这句话被严重低估了。它实际上决定了训练过程中,那些梯度二阶矩很小的参数,最多能被更新多快。
它实际上在控制步长的天花板
我们来看一个关键的量:有效更新步长。真实步长是 lr * m_t / (sqrt(v_t) + epsilon)。当v_t远小于epsilon时,分母被epsilon主导,步长近似为 lr * m_t / epsilon。此时步长跟v_t几乎无关,只取决于一阶矩m_t和epsilon。换句话说,epsilon给步长设定了一个上限。
举个例子:假设某个参数的v_t小到1e-16(在fp32下完全可能,在fp16下直接下溢成0)。如果epsilon=1e-8,那么sqrt(v_t)=1e-8,分母=2e-8。假设lr=1e-4,m_t=1e-4,那么步长=1e-8 / 2e-8 = 0.5。一个更新直接把参数移动0.5,对绝大多数模型来说都是灾难。如果把epsilon调到1e-6,分母约等于1e-6,步长=1e-8 / 1e-6 = 0.01,这就温和多了。所以epsilon本质上是一个步长限制器,它决定了当梯度二阶矩很小的时候,模型最多能走多远。
为什么v_t会小到1e-16?v_t是梯度平方的指数移动平均。当一个参数在训练中经常收到接近零的梯度(比如某些稀疏特征对应的权重),v_t就会变得非常小。在fp32下,v_t可以小到1e-16甚至更小。注意,当v_t小于epsilon的平方时,sqrt(v_t)就小于epsilon,此时epsilon开始接管分母。也就是说,epsilon在数学上相当于对sqrt(v_t)设置了一个下限。这个下限直接决定了自适应学习率的最大值。
从这个角度看,epsilon和学习率的关系很微妙。学习率是全局缩放,epsilon是局部上限。当v_t很大时,epsilon可以忽略,步长主要由lr和v_t决定;当v_t很小时,epsilon开始起作用,步长上限变成 lr/epsilon * |m_t|。所以调整epsilon相当于在v_t的低端区域重新调整学习率。这也就解释了为什么epsilon从1e-8到1e-6看起来只差两个数量级,却能让训练行为发生质变。
大模型训练为什么更容易踩这个坑
大模型训练有两个特点,让epsilon的问题被放大。
第一,梯度分布非常长尾。绝大多数参数梯度很小,只有少数参数偶尔出现大梯度。那些梯度一直很小的参数,v_t长期处于极低水平。如果epsilon不够大,这些参数的更新就会变得异常敏感,稍微有一点噪声就会产生大更新,破坏训练稳定性。
第二,混合精度训练成为标配。在fp16下,最小的正规数大约是6e-5,次正规数可以到1e-7左右,但一旦梯度平方的平均值v_t下溢到0,分母就只剩epsilon。如果epsilon也设置得很小,那么更新步长 = lr * m_t / epsilon,可能直接爆炸。NVIDIA的混合精度训练文档明确建议,在使用fp16时要把epsilon调大,比如1e-6或更高,以避免数值下溢导致的除零和NaN(NVIDIA混合精度训练文档)。
很多人觉得bf16没有下溢问题,因为bf16的指数范围和fp32一样。但bf16的精度很低,每个梯度值可能被舍入到相对误差较大的级别,这相当于给梯度加了一个噪声。v_t对这些噪声非常敏感,如果epsilon太小,噪声会通过更新步长放大,造成训练不稳定。所以即使在bf16下,我也倾向于把epsilon调到1e-6以上。
框架默认值之间的差异比你想象的大
不同框架对epsilon的默认值并不一致,甚至同一个框架的不同版本也可能不同。以下是几个主流实现的默认值(以各自官方文档为准):
| 框架 | 默认epsilon | 说明 |
|---|---|---|
| PyTorch Adam | 1e-8 | 根号外加eps |
| PyTorch AdamW | 1e-8 | 根号外加eps |
| TensorFlow Adam | 1e-7 | 根号外加eps |
| DeepSpeed Adam | 1e-8 | 根号外加eps |
TensorFlow的Adam默认值是1e-7,而PyTorch是1e-8。虽然只差一个数量级,但在某些场景下,这个差异足以改变训练行为。如果你从PyTorch迁移到TensorFlow,或者反过来,请先检查epsilon。详细参数说明见PyTorch官方文档和TensorFlow官方文档。
还有实现细节:有的实现把epsilon放在根号内(sqrt(v_t + epsilon)),有的放在根号外(sqrt(v_t) + epsilon)。这两种写法在v_t很小时结果完全不同。例如v_t=0时,前者分母=sqrt(epsilon),后者分母=epsilon。所以在不同代码库之间迁移时,不能只看数值,还要看位置。当前主流实现(PyTorch、TensorFlow、DeepSpeed)都是放在根号外,但一些自定义实现可能会不同。
为什么不能只用梯度裁剪解决?
梯度裁剪作用于整个梯度的范数,它会等比例缩放所有参数更新。而epsilon作用于每个参数的归一化分母,它只影响那些v_t特别小的参数。两者解决的可能是不同的问题:梯度裁剪应对的是梯度爆炸,epsilon应对的是小梯度参数的不稳定更新。所以即使有了梯度裁剪,epsilon仍然需要单独设置。
我现在的选择:先看精度,再看大小
现在我训练模型,会先确认精度:如果是fp32,用默认的1e-8通常没问题;如果是fp16混合精度,我会把epsilon至少调到1e-6,甚至1e-4。调的时候不要一步到位,先看训练初期有没有NaN或者loss spike,然后观察收敛速度。epsilon过大也会有问题,因为它会压制小梯度参数的更新,导致模型欠拟合。
一个实用的经验区间:1e-8到1e-4之间,一般1e-6是个不错的折中。对于超大批次训练,有实验表明更大的epsilon(如1e-4)有助于稳定;但对于小batch size,可能不需要这么大。总之,epsilon应该被视为一个需要随精度、模型规模和batch size一起调整的超参数,而不是一个固定的常量。
写在最后:数值稳定性不是小事
回头再看文档里那句"improve numerical stability",现在我的理解是:epsilon不是优化器的配角,而是直接参与步长计算的一个超参数。它的作用是给自适应学习率设置一个安全的下界,防止某些参数在梯度几乎为零时被意外地大更新。在大模型时代,混合精度和长尾梯度让这个下界变得比以往更重要。
数值稳定性从来不是"加上一个很小的数"那么简单。它决定了一个训练过程是平滑收敛,还是在某个看不见的角落突然崩溃。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/294.html