AdaFactor优化器浅析(附开源实现)
By 苏剑林 | 2020-03-23 | 91638位读者 |自从GPT、BERT等预训练模型流行起来后,其中一个明显的趋势是模型越做越大,因为更大的模型配合更充分的预训练通常能更有效地刷榜。不过,理想可以无限远,现实通常很局促,有时候模型太大了,大到哪怕你拥有了大显存的GPU甚至TPU,依然会感到很绝望。比如GPT2最大的版本有15亿参数,最大版本的T5模型参数量甚至去到了110亿,这等规模的模型,哪怕在TPU集群上也没法跑到多大的batch size。
这时候通常要往优化过程着手,比如使用混合精度训练(tensorflow下还可以使用一种叫做bfloat16的新型浮点格式),即省显存又加速训练;又或者使用更省显存的优化器,比如RMSProp就比Adam更省显存。本文则介绍AdaFactor,一个由Google提出来的新型优化器,首发论文为《Adafactor: Adaptive Learning Rates with Sublinear Memory Cost》。AdaFactor具有自适应学习率的特性,但比RMSProp还要省显存,并且还针对性地解决了Adam的一些缺陷。
Adam #
首先我们来回顾一下常用的Adam优化器的更新过程。设t为迭代步数,αt为当前学习率,L(θ)是损失函数,θ是待优化参数,ϵ则是防止溢出的小正数,那么Adam的更新过程为
{gt=∇θL(θt−1)mt=β1mt−1+(1−β1)gtvt=β2vt−1+(1−β2)g2tˆmt=mt/(1−βt1)ˆvt=vt/(1−βt2)θt=θt−1−αtˆmt/(√ˆvt+ϵ)
要省显存,就首先得知道显存花在哪里的。首先,计算量和显存的大头肯定都是∇θL(θt−1),也就是说,计算梯度是很费资源的,这也是为啥“ALBERT相比BERT参数量虽然少了那么多,但训练速度也没见快多少”的原因了;除此之外,显存的消耗主要是m,v了,我们要维护两组缓存变量,来滑动计算梯度的前两阶矩(也就是m和v),用以计算参数的更新量。这两组变量每一组都跟训练参数本身一样大,因此对于参数比较多的模型,两组缓存变量所消耗的显存也不少。
AdaFactor #
在这一节中,我们会相对详细地介绍一些AdaFactor优化器,介绍中会设计比较多的公式和推导。如果只求一个大致了解的读者,可以自行跳过部分数学内容~
抛弃动量 #
我们知道,CV模型很多时候要靠“SGD+动量”来炼出最优效果来,自适应学习率优化器通常训练不出最好的效果。但对于NLP模型来说,情况有点相反,自适应学习率显得更重要一些,很少听到由纯靠SGD调NLP模型的案例。因此,作为省显存的第一步,我们可以抛弃Adam里边的动量,这样就少一组缓存参数了,自然也就省了显存:
{gt=∇θL(θt−1)vt=β2vt−1+(1−β2)g2tˆvt=vt/(1−βt2)θt=θt−1−αtgt/√ˆvt+ϵ
这其实就是RMSProp的变种,比RMSProp多了ˆvt=vt/(1−βt2)这一步。
低秩分解 #
去掉m之后,缓存变量直接减少了一半,但AdaFactor还不满意,它希望保留自适应学习率功能,但把缓存变量v的参数量再压一压。这一次,它用到了矩阵的低秩分解。
广义KL散度 #
在SGD中,所有参数都是共用一个标量学习率;在Adam中,则是每一个参数都有自己的学习率αt/√ˆvt+ϵ。我们知道通过精调学习率,SGD其实也能有不错的效果,这表明“每一个参数都有自己的学习率”这件事情都不是特别重要,或者换一种说法,就是“精调每一个参数自己的学习率”并不是特别重要。
这启发我们,将ˆvt换一种参数更少的近似可能也就足够了。而“参数更少的近似”,我们就不难想到低秩分解了。对于m×n的矩阵C,我们希望找到m×k的矩阵A和k×n的矩阵B,使得
AB≈C
当k足够小时,A、B的参数总量就小于C的参数量。为了“省”到极致,AdaFactor直接让k=1,即寻找{ai}mi=1和{bj}nj=1,使得
aibj≈ci,j
既然要近似,就要有一个度量的标准。很容易想到的标准是欧氏距离,即
∑i,j(aibj−ci,j)2
但在这个距离之下,ai,bj并没有解析解;此外,在优化过程中ci,j(即ˆvt)是非负的,而通过上述目标优化出来的aibj无法保证非负,因此很可能扰乱优化过程。
原论文的作者们很机智地换了一个度量标准,使得ai,bj有解析解。具体来说,它使用了“广义KL散度”,又称“I散度(I-Divergence)”,其形式为:
l=∑i,jci,jlogci,jaibj−ci,j+aibj
这个度量源自不等式xlogx≥x−1(∀x>0),当且仅当x=1时等号成立。所以代入x=p/q(p,q>0),然后两端乘以q,我们有
plogpq−p+q≥0
当且仅当p=q成立,如果p,q有多个分量,那么对多个分量的结果求和即可,这就得到了度量(6)
显然,广义KL散度是概率的KL散度的自然推广,但它不要求ci,j和aibj满足归一化,只要求它们非负,这正好对应了AdaFactor的场景。而且巧妙的是,这种情形配上这个目标,刚好有解析解:
ai=∑jci,j,bj=∑ici,j∑i,jci,j
其实这个解析解也很形象,就是行、列分别求和,然后相乘,再除以全体的和。
推导过程 #
直接对(6)求偏导数并让偏导数等于0,得
{∂l∂ai=∑j−ci,jai+bj=0∂l∂bj=∑i−ci,jbj+ai=0
整理得
{ai∑jbj=∑jci,jbj∑iai=∑ici,j
注意到如果(ai,bj)是一组最优解,那么(λai,bj/λ)也是,说白了,所有的ai乘以一个常数,所有的bj也除以这个常数,aibj是不变的。那么我们就可以随意指定∑iai或∑jbj,因为它们就只是一个缩放标量而已。不失一般性,我们指定∑jbj=1,那么就解得(8)。
直观理解 #
我们也可以从另一个角度理解结果(8)。由于ci,j是非负的,我们可以将它归一化,变成具有概率分布的特性,即ˆci,j=ci,j∑i,jci,j,然后我们试图完成分解ˆci,j≈ˆaiˆbj,由于ˆci,j现在相当于一个二元联合概率分布,那么ˆai,ˆbj就相当于它们的边缘分布,即
ˆai=∑jˆci,j=∑jci,j∑i,jci,j,ˆbj=∑iˆci,j=∑ici,j∑i,jci,j
现在ˆci,j到ci,j还需要乘上一个∑i,jci,j,我们可以把它乘到ˆai或ˆbj中,不失一般性,我们假设乘到ˆai上,那么就得到(8)。
AdaFactor雏形 #
有了结果(8)后,我们就可以用它来构建更省内存的优化器了,这就是AdaFactor的雏形。简单来说,当参数θ是普通一维向量时,优化过程保持不变;但θ是m×n的矩阵时,算出来的梯度gt也是矩阵,从而g2t也是矩阵,这时候我们对g2t做低秩分解,然后维护两组缓存变量v(r)t∈Rm,v(c)t∈Rn,分别滑动平均低秩分解后的结果,最后用v(r)t,v(c)t共同调整学习率:
{gi,j;t=∇θL(θi,j;t−1)v(r)i;t=β2v(r)t−1;i+(1−β2)∑j(g2i,j;t+ϵ)v(c)j;t=β2v(c)t−1;j+(1−β2)∑i(g2i,j;t+ϵ)vi,j;t=v(r)i;tv(c)j;t/∑jv(c)j;tˆvt=vt/(1−βt2)θt=θt−1−αtgt/√ˆvt
(把ϵ加到g2t上去而不是ˆvt上去,这是AdaFactor整出来的形式,不是笔者的锅~)
滑动权重 #
在Adam以及上述AdaFactor雏形中,滑动权重β2都是恒为常数,AdaFactor指出这是不科学的,并提出新的策略。
等价形式 #
为了认识到这一点,我们重写一下Adam的ˆvt的更新过程:
ˆvt=vt/(1−βt2)=β2vt−1+(1−β2)g2t1−βt2=β2ˆvt−1(1−βt−12)+(1−β2)g2t1−βt2=β21−βt−121−βt2ˆvt−1+(1−β21−βt−121−βt2)g2t
所以如果设ˆβ2,t=β21−βt−121−βt2,那么更新公式就是
ˆvt=ˆβ2,tˆvt−1+(1−ˆβ2,t)g2t
问题是这个ˆβ2,t够不够合理呢?答案是可能不大够。当t=1时ˆβ2,t=0,这时候ˆvt就是g2t,也就是用实时梯度来校正学习率,这时候校正力度最大;当t→∞时,ˆβ2,t→β2,这时候vt是累积梯度平方与当前梯度平方的加权平均,由于β2<1,所以意味着当前梯度的权重1−β2不为0,这可能导致训练不稳定,因为训练后期梯度变小,训练本身趋于稳定,校正学习率的意义就不大了,因此学习率的校正力度应该变小,并且t→∞,学习率最好恒定为常数(这时候相当于退化为SGD),这就要求t→∞时,ˆβ2,t→1。
新的衰减策略 #
为了达到这个目的,AdaFactor采用如下的衰减策略
ˆβ2,t=1−1tc
它满足ˆβ2,1=0,lim。但即便如此,也不是任何c都适合,必须有0 < c <1。c > 0好理解,那为什么要c < 1呢?原论文包含了对它的分析,大家可以去读读,但笔者觉得原论文的推导过于晦涩,所以这里给出自己的理解。
首先,对于\hat{v}_t来说,一个很容易想到的方案是所有梯度平方的平均,即:
\begin{equation}\hat{v}_t = \frac{1}{t}\sum_{i=1}^t g_i^2=\frac{t-1}{t}\hat{v}_{t-1} + \frac{1}{t}g_t^2\end{equation}
所以这等价于让\hat{\beta}_{2,t} =1 - \frac{1}{t}。这个方案美中不足的一点是,每一步梯度都是平权的,这不符合直觉,因为正常来说越久远的梯度应该越不重要才对,所以应该适当降低历史部分权重,而当c < 1时,1 - \frac{1}{t^c} < 1 - \frac{1}{t},因此一个简洁的方案是在式\eqref{eq:beta2}中取c < 1,AdaFactor默认的c是0.8。
层自适应 #
最后,我们还可以进一步根据参数的模长来校正更新量,这个思路来自LAMB优化器,在之前的文章《6个派生优化器的简单介绍及其实现》中也介绍过。简单来说,它就是将最后的更新量标准化,然后乘以参数的模长,说白了,就是不管你怎么折腾,最后的更新量我只要你的方向,而大小由参数本身的模长和预先设置学习率共同决定,使得所有层所有参数的相对变化程度保持一致。
AdaFactor完整版 #
至此,我们终于可以写出完整版AdaFactor的更新过程了:
\begin{equation}\left\{\begin{aligned}&g_{i,j;t} = \nabla_{\theta} L(\theta_{i,j;t-1})\\
&\hat{\beta}_{2,t} =1 - t^{-c}\\
&v^{(r)}_{i;t} = \hat{\beta}_{2,t} v^{(r)}_{t-1;i} + \left(1 - \hat{\beta}_{2,t}\right) \sum\limits_{j}\left(g_{i,j;t}^2+\epsilon_1\right)\\
&v^{(c)}_{j;t} = \hat{\beta}_{2,t} v^{(c)}_{t-1;j} + \left(1 - \hat{\beta}_{2,t}\right) \sum\limits_{i}\left(g_{i,j;t}^2+\epsilon_1\right)\\
&\hat{v}_{i,j;t} = v^{(r)}_{i;t} v^{(c)}_{j;t}\left/\sum\limits_{j}v^{(c)}_{j;t}\right.\\
&u_t = g_t\left/\sqrt{\hat{v}_t}\right.\\
&\hat{u}_t = u_t \left/\max\left(1, \left. RMS(u_t)\right/d\right)\right.\times \max\left(\epsilon_2, RMS(\theta_{t-1})\right)\\
&\theta_t = \theta_{t-1} - \alpha_t \hat{u}_t
\end{aligned}\right.\end{equation}
其中RMS(x)=\sqrt{\frac{1}{n}\sum\limits_{i=1}^n x_i^2}是模长的变种,\max\left(1, \left. RMS(u_t)\right/d\right)这一步相当于做了个截断,即RMS(u_t) > d时才执行归一化。原论文中的默认参数为
\begin{array}{c|c}
\hline
\epsilon_1 & 10^{-30}\\
\hline
\epsilon_2 & 10^{-3}\\
\hline
d & 1\\
\hline
\hat{\beta}_{2,t} & 1 - t^{-0.8}\\
\hline
\end{array}
如果参数是一维向量而不是矩阵,那么\hat{v}_t使用普通的更新公式\hat{v}_t = \hat{\beta}_{2,t} v_{t-1} + \left(1 - \hat{\beta}_{2,t}\right) \left(g_t^2+\epsilon_1\right)就行了。此外,论文还提出如果没有传入学习率,那么可以使用a_t = \min\left(10^{-2},\frac{1}{\sqrt{t}}\right)为默认学习率,但笔者看源码的时候发现这个默认学习率很少使用,基本上还是需要自己传入学习率的。
开源实现 #
为了方便大家使用,笔者开源了自己实现的AdaFactor:
Github地址:https://github.com/bojone/adafactor
开源包括纯keras版和tf.keras版,使用方法跟普通keras优化器一样,tf.keras版也可以当做一个普通的tensorflow优化器使用。开源实现参考了mesh_tensorflow版的源码,在此表示感谢。优化器也已经内置在bert4keras中,方便大家调用。
需要提醒的是,用AdaFactor的时候,batch_size最好大一些,因为本身低秩分解会带来误差,而如果batch_size过小,那么梯度估算本身也带来较大的误差,两者叠加优化过程可能还不收敛。对于预训练模型来说,batch_size通常还是很大的,所以现在不少预训练模型开始用AdaFactor优化器了;对于普通的下游任务来说,AdaFactor也可以尝试,但可能需要多炼炼丹,才能搞出优于无脑Adam的效果。对了,还要提醒一下,用AdaFactor的时候,学习率要设大一点,大概是10^{-3}级别为好,哪怕是finetune阶段也是如此。
文章小结 #
本文介绍了Google提出来的AdaFactor优化器,一个旨在减少显存占用的优化器,并且针对性地分析并解决了Adam的一些缺陷。笔者认为,AdaFactor针对Adam所做的分析相当经典,值得我们认真琢磨体味,对有兴趣研究优化问题的读者来说,更是一个不可多得的分析案例。
当然,没有什么绝对能有效的方法,有的只是
方法虽好,要想实际有效,依然要用心炼丹。
转载到请包括本文地址:https://kexue.fm/archives/7302
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Mar. 23, 2020). 《AdaFactor优化器浅析(附开源实现) 》[Blog post]. Retrieved from https://kexue.fm/archives/7302
@online{kexuefm-7302,
title={AdaFactor优化器浅析(附开源实现)},
author={苏剑林},
year={2020},
month={Mar},
url={\url{https://kexue.fm/archives/7302}},
}
April 2nd, 2020
大佬用的keras是什么版本的? 我跑了会有这个报错:
AttributeError: module 'keras.backend' has no attribute 'symbolic'
我的版本:2.2.5
多谢!
2.3.1。如果是2.2.x的话,去掉@K.symbolic那一行就行了。
April 2nd, 2020
完全懵的状态
April 26th, 2020
苏神,公式(8)的a_i,分母是不是也应该要除以所有元素的和?
算了一下,确实不需要,不好意思!
May 18th, 2020
苏神,今天我用了bert4keras 和adafactor训练了个bert模型,用model.save保存了下来。然后我再另一个app里面用
from bert4keras.layers import *
model = keras.models.load_model(bert_model.h5)
结果报错:找不到adafactor这个类,在反序列化的时候。在keras的optimizers.py的方法
def deserialize(config, custom_objects=None):
all_classes = {
'sgd': SGD,
'rmsprop': RMSprop,
'adagrad': Adagrad,
'adadelta': Adadelta,
'adam': Adam,
'adamax': Adamax,
'nadam': Nadam,
'tfoptimizer': TFOptimizer,
}
这里面没有adafactor这个类。
加一句
from bert4keras.optimizers import *
AdaFactor是自己定义的优化器,定义在bert4keras/optimizers.py中
还是不行,加了这句。
我最后是custom_objects = {
'AdaFactorV1': AdaFactor,
}
然后load_model的时候,把custom objects传进去好了。
是我的疏忽,github上已经调整过来了~
July 9th, 2020
代码中,
if indices[-2] < self.min_dim_size_to_factor:
似乎应该改为
if shape[indices[-2]] < self.min_dim_size_to_factor:
是的是的,感谢你的纠正。
August 13th, 2021
您好,想请教一下公式(1)的第三行和第四行中的β1t和β2t是怎么计算的呢?因为torch调包的时候只有 learning_rate=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8这几个参数,如果不加第三行和第四行有影响吗?因为我理解的Adam是加了动量的RMSpro,而不管是动还是RMSPro好像都没有出现修正的情况
正常来说bias correction都会自带在Adam的内置实现中,不需要刻意传参。
这个修正的作用主要是什么呢?因为看的SGD加动量是没有修正的,RMSPro好像也没有修正
修正估计结果,使得一开始就以均值的量级进行更新。
不过你也可以理解为加上bias correction就是正常的Adam,去掉bias correction就相当于warmup,这个具体有多大作用,我也不清楚。
August 17th, 2021
公式10 ,为什么bj也是累加的,感觉应该不累加吧?
那就说明你的感觉错误,公式(10)是没错的。
March 28th, 2022
苏神,“吹毛求疵”一下。
Adam 公式,最后参数更新的时候,\epsilon 似乎应该写在根号外面。
\theta_t = \theta_{t-1} - \alpha_t \hat{m_t}\bigg{/} \left(\sqrt{\hat{v_t}} + \epsilon\right)
原论文和您的实现也都是先开方再加 \epsilon。
沐神在《动手学深度学习》Adam 算法中,也简单提了下这个地方。
\theta_t = \theta_{t-1} - \alpha_t \hat{m}_t \bigg/\left( \sqrt{\hat{v}_t} + \epsilon\right)
好的,接受批评指正哈,已修正,谢谢