模型优化方法的选择直接关系到最终模型的性能。有时候效果不好,未必是特征的问题或者模型设计的问题,很可能是优化算法的问题,而且好的优化算法还能够帮助加速训练模型。
深度学习模型的发展进程:
SGD -> SGDM ->NAG -> AdaGrad -> AdaDelta -> Adam -> Nadam
首先定义:
待优化参数 w w w, 目标函数: f ( w ) f(w) f(w),学习率 α \alpha α
每次迭代:
步骤3、4对于各个算法都是一致的,主要的差别就体现在1和2上。
没有动量的概念,第三步中 η t = α ⋅ g t \eta_t=\alpha \cdot g_t ηt=α⋅gt
其中batch_size是一个重要的变量,需要做一个快速尝试,才能够找到能够最有效地减少成本函数的那个,一般是 2 n 2^n 2n。
缺点:
SGD下降方法的缺点是参数更新方向只依赖于当前batch计算出的梯度,因此十分的不稳定。为了抑制SGD的震荡,动量认为梯度下降的过程中可以加入惯性。动量梯度下降法运行速度总是快于标准的梯度下降法,其基本思想是在SGD的基础上引入了一阶动量:
m t = β m t − 1 + ( 1 − β ) g t m_t=\beta m_{t-1}+(1-\beta)g_t mt=βmt−1+(1−β)gt
一阶动量指的是各个时刻梯度的指数加权平均,约等于 1 1 − β 1 \frac{1}{1-\beta_1} 1−β11个历史时刻的梯度向量和的平均值,也就是t时刻的下降方向,不仅由当前点的梯度方向决定,还由此前的累积的梯度来决定, β \beta β的经验值一般为0.9,也就是意味着下降方向主要是此前累积的下降方向,并略微偏向当前时刻的下降方向。并利用当前batch微调最终的更新方向。如果当前梯度方向与历史梯度一致,会增强该方向的梯度。如果不一致,能够减少更新。
优点:
使得 β = 0.9 \beta=0.9 β=0.9
m 100 = 0.9 m 99 + 0.1 θ 100 m_{100}=0.9m_{99}+0.1\theta_{100} m100=0.9m99+0.1θ100
m 99 = 0.9 m 98 + 0.1 θ 99 m_{99}=0.9m_{98}+0.1\theta_{99} m99=0.9m98+0.1θ99
m 98 = 0.9 m 97 + 0.1 θ 98 m_{98}=0.9m_{97}+0.1\theta_{98} m98=0.9m97+0.1θ98
把公式带入会得到:
m 100 = 0.1 θ 100 + 0.1 ∗ 0.9 θ 99 + 0.1 ∗ ( 0.9 ) 2 θ 98 + 0.1 ∗ ( 0.9 ) 3 θ 97 + 0.1 ∗ ( 0.9 ) 4 θ 96 m_{100}=0.1\theta_{100}+0.1*0.9\theta_{99}+0.1*(0.9)^2\theta_{98}+0.1*(0.9)^3\theta_{97}+0.1*(0.9)^4\theta_{96} m100=0.1θ100+0.1∗0.9θ99+0.1∗(0.9)2θ98+0.1∗(0.9)3θ97+0.1∗(0.9)4θ96
可以看出这是一个第100天的数据包含了99,98,97天的数据,而且是一个指数衰减的过程,这些系数的和为1或者逼近于1。到底平均了多少天的数据? 0. 9 10 0.9^{10} 0.910大约为0.35,约等于 1 e \frac{1}{e} e1的值。也就是10天之后曲线的高度下降到 1 3 \frac{1}{3} 31,相当于只关注了过去10天的数据,因为10天后,权重下降到不到当日权重的三分之一。如果 β = 0.98 \beta=0.98 β=0.98,那么 0.9 8 50 0.98^{50} 0.9850大约等于 1 e \frac{1}{e} e1,可以看作平均了50天的数据,由此得到公式平均了大约 1 1 − β \frac{1}{1-\beta} 1−β1的数据。
好处:占用极少的内存,每次把最新的公式带入不断覆盖就可以了。虽然不是最精确的,如果直接平均过去天的数据往往会得到更好的估计,但是需要保存最近的温度数据必须占用更多的内存,执行更加复杂,计算成本也更高。
计算移动平均数时,初始化 m 0 = 0 , m 1 = 0.9 m 0 + 0.1 θ 1 m_0=0,m_1=0.9m_0+0.1\theta_1 m0=0,m1=0.9m0+0.1θ1, m 1 = 0.1 θ 1 m_1=0.1\theta_1 m1=0.1θ1,显然第一天的值会小很多,估计不准确;而且 m 2 = 0.9 m 1 + 0.1 θ 2 m_2=0.9m_1+0.1\theta_2 m2=0.9m1+0.1θ2,如果带入 m 1 m_1 m1然后相乘得到 m 2 = 0.9 ∗ 0.1 θ 1 + 0.1 θ 2 m_2=0.9*0.1\theta_1+0.1\theta_2 m2=0.9∗0.1θ1+0.1θ2, m 2 m_2 m2要远小于 θ 1 \theta_1 θ1和 θ 2 \theta_2 θ2,所以 m 2 m_2 m2不能很好的估计出前两天的数据,而当t足够大时, m t ^ = m t \hat{m_t}=m_t mt^=mt。
偏差修正能够改正这个问题,特别是在初期。 m 1 / ( 1 − β t ) = m 1 / 0.1 m_1/(1-\beta^t)=m_1/0.1 m1/(1−βt)=m1/0.1后, m 1 = θ 1 m_1=\theta_1 m1=θ1就去除了偏差。而随着t的增加, β t \beta^t βt接近于0,所以当t很大的时候,偏差修正几乎没有作用。
在计算指数加权平均数的大部分时候,都不在乎执行偏差修正,因为大部分人宁愿熬过初始时期,拿到具有偏差的估测,然后继续计算下去。如果关心初始时期的偏差,在刚开始计算指数加权移动平均数的时候,偏差修正能帮助在早期获取更好的估测。
m ~ t = m t 1 − β 1 t V ~ t = V t 1 − β 2 t \tilde{m}_{t}=\frac{m_{t}}{1-\beta_1^t}\\ \tilde{V}_{t}=\frac{V_{t}}{1-\beta_2^t} m~t=1−β1tmtV~t=1−β2tVt
之前的方法都没有用到二阶动量,二阶动量的出现,才意味着“自适应率”优化算法的到来。SGD以及动量以同样的学习率更新每个参数。神经网络模型往往包含大量的参数,但是这些参数并不会总是用得到,更新频率也不一样。对于经常更新的参数,不希望其被单个样本影响太大,希望学习速率慢一些;对于偶尔更新的参数,了解的信息太少,希望能够从每个偶然出现的样本身上多学一些,即学习速率大一些。
怎么去度量历史更新频率呢?就是二阶动量:至今为止所有梯度值的平方和:
V t = ∑ t = 1 T g t 2 V_t=\sum_{t=1}^{T}g_t^2 Vt=t=1∑Tgt2
可以看出,此时实质上的学习率由 α \alpha α变为了 α / V t \alpha/\sqrt{V_t} α/Vt,一般为了避免分母为0,会在分母上加一个小的平滑项,因此 V t \sqrt{V_t} Vt是恒大于0的。所以如果参数更新频繁,其二阶动量越大,学习率就越小。
优点:
缺点:
SGD 还有一个问题是困在局部最优的沟壑里面震荡。想象一下你走到一个盆地,四周都是略高的小山,你觉得没有下坡的方向,那就只能待在这里了。可是如果你爬上高地,就会发现外面的世界还很广阔。因此,我们不能停留在当前位置去观察未来的方向,而要向前一步、多看一步、看远一些。
NAG全称Nesterov Accelerated Gradient,是在SGD、SGD-M的基础上的进一步改进,改进点在于步骤1。我们知道在时刻t的主要下降方向是由累积动量决定的,自己的梯度方向说了也不算,那与其看当前梯度方向,不如先看看如果跟着累积动量走了一步,那个时候再怎么走。因此,NAG在步骤1,不计算当前位置的梯度方向,而是计算如果按照累积动量走了一步,那个时候的下降方向:
g t = ∇ f ( w t − α ⋅ m t − 1 / V t − 1 ) g_t=\nabla f(w_t-\alpha \cdot m_{t-1} / \sqrt{V_{t-1}}) gt=∇f(wt−α⋅mt−1/Vt−1)
然后用下一个点的梯度方向,与历史累积动量相结合,计算步骤2中当前时刻的累积动量。
由于AdaGrad单调递减的学习率变化过于激进,我们考虑一个改变二阶动量计算方法的策略:不累加全部历史梯度,而只关注过去一段时间窗口的下降梯度,很自然的想到之前动量使用的指数加权平均,它所计算的就是过去一段时间的平均值,所以使用这一方法来计算二阶累积动量:
V t = β 2 ∗ V t − 1 + ( 1 − β 2 ) g t 2 V_t=\beta_2 *V_{t-1} + (1- \beta_2)g_t^{2} Vt=β2∗Vt−1+(1−β2)gt2
这就避免了二阶动量持续累积,导致训练过程提前结束的问题了。
谈到这里,Adam的出现就自然而然了,它是前述方法的集大成者。动量在SGD的基础上增加了一阶动量,AdaGrad和AdaDelta在SGD的基础上增加了二阶动量。Adam实际上就是将Momentum和RMSprop集合在一起,把一阶动量和二阶动量都使用起来了,具体方法为:
I t e r a t i o n : t = t + 1 m t = β 1 m t − 1 + ( 1 − β 1 ) g t V t = β 2 V t − 1 + ( 1 − β 2 ) g t 2 α t = α ∗ 1 − β 2 t / ( 1 − β 1 t ) w t + 1 = w t − α t m t V t + ϵ Iteration:\\ t=t+1\\ m_{t}=\beta_1m_{t-1}+(1-\beta_1)g_t \\ V_{t}=\beta_2V_{t-1}+(1-\beta_2)g_{t}^{2} \\ \alpha_t=\alpha*\sqrt{1-\beta^t_2}/(1-\beta^t_1) \\ w_{t+1}=w_{t}-\alpha_t\frac{m_{t}}{\sqrt{V_{t}}+\epsilon} Iteration:t=t+1mt=β1mt−1+(1−β1)gtVt=β2Vt−1+(1−β2)gt2αt=α∗1−β2t/(1−β1t)wt+1=wt−αtVt+ϵmt
本算法中有很多超参数,超参数学习率 α \alpha α很重要,也经常需要调试。 β 1 \beta_1 β1和 β 2 \beta_2 β2是加权平均数,用于控制一阶动量和二阶动量。常用的缺省值为0.9和0.999。关于 ϵ \epsilon ϵ的选择其实没那么重要,Adam论文的作者建议 ϵ \epsilon ϵ为 1 0 − 8 10^{-8} 10−8,因为它并不会影响算法表现。
优点:
Adam有很多的优点,但是在很多数据集上的最好效果还是用SGD with Momentum细调出来的。可见Adam的泛化性并不如SGD with Momentum。https://arxiV.org/pdf/1711.05101.pdf 中提出其中一个重要原因就是Adam中L2正则化项并不像在SGD中那么有效。
L2正则和Weight Decay在Adam这种自适应学习率算法中并不等价,只有在标准SGD的情况下,可以将L2正则和Weight Decay看做一样。特别是,当与自适应梯度相结合时,L2正则化导致具有较大历史参数和/或梯度幅度的权重比使用权重衰减时更小。
使用Adam优化带L2正则的损失并不有效,如果引入L2正则化项,在计算梯度的时候会加上正则项求梯度的结果。正常的权重衰减是对所有的权重都采用相同的系数进行更新,本身比较大的一些权重对应的梯度也会比较大,惩罚也越大。但由于Adam计算步骤中减去项会有除以梯度平方的累积,使得梯度大的减去项偏小,从而具有大梯度的权重不会像解耦权重衰减那样得到正则化。 这导致自适应梯度算法的L2和解耦权重衰减正则化的不等价。
而在常见的深度学习库中只提供了L2正则,并没有提供权重衰减的实现。这可能就是导致Adam跑出来的很多效果相对SGD with Momentum有偏差的一个原因。
Adam with L2 regularization和AdamW的代码:
如果是SGD,L2正则化项和梯度衰减是等同的。但是由于Adam加入了一阶动量和二阶动量,基于包含L2正则化项的梯度来计算一阶动量和二阶动量,使得参数的更新系数就会变化,与单纯的权重衰减就会变得不同。图中红色的为原始的Adam+L2 regularization的方法,如果把line 6,line 7,line 8都带入到line 12,并且假设 η t = 1 \eta_t=1 ηt=1:
θ t → θ t − 1 − α β 1 m t − 1 + ( 1 − β 1 ) ( ▽ f t + λ θ t − 1 ) V t ^ + ϵ \theta_t \rightarrow \theta_{t-1}-\alpha\frac{\beta_1m_{t-1}+(1-\beta_1)(\bigtriangledown f_t+\lambda \theta_{t-1})}{\sqrt{\hat{V_t}}+\epsilon} θt→θt−1−αVt^+ϵβ1mt−1+(1−β1)(▽ft+λθt−1)
可以看出,分子右上角的 λ θ t − 1 \lambda \theta_{t-1} λθt−1向量各个元素被分母的 V t ^ \sqrt{\hat{V_t}} Vt^项调整了。梯度快速变化的方向上, V t ^ \sqrt{\hat{V_t}} Vt^有更大的值,而使得调整后的 λ θ t − 1 V t ^ \frac{\lambda\theta_{t-1}}{\sqrt{\hat{V_t}}} Vt^λθt−1更小,在这个方向上参数 θ \theta θ被正则化的更少。这显然是不合理的,L2 regularization和weight decay都应该是各向同性的。所以作者提出以绿色的方式来在Adam中正确的引入weight decay的方式,称作AdamW,也就是不让 λ θ t − 1 \lambda \theta_{t-1} λθt−1项被 V t ^ \sqrt{\hat{V_t}} Vt^调整,使用相同的 λ \lambda λ来正则化所有的权重,完成了梯度下降与weight decay的解耦。
大部分的模型都会有L2 regularization约束项,因此很有可能出现Adam的最终效果没有sgd的好。目前bert训练采用的优化方法就是Adamw,对除了layernorm,bias项之外的模型参数做weight decay。
L2 regularization与Weight decay在SGD时是等价的
L2正则化的目的是为了在一定程度上减少模型过拟合的问题。
f t r e g ( θ ) = f t ( θ ) + λ ′ 2 ∣ ∣ θ ∣ ∣ 2 2 f_t^{reg}(\theta)=f_t(\theta)+\frac{\lambda'}{2}||\theta||^2_2 ftreg(θ)=ft(θ)+2λ′∣∣θ∣∣22
θ t + 1 → θ t − α ▽ f t r e g ( θ t ) = θ t − α ▽ f t ( θ t ) − α λ ′ θ t \theta_{t+1} \rightarrow \theta_t-\alpha\bigtriangledown f_t^{reg}(\theta_t)=\theta_t-\alpha\bigtriangledown f_t(\theta_t)-\alpha\lambda'\theta_t θt+1→θt−α▽ftreg(θt)=θt−α▽ft(θt)−αλ′θt
weight decay
是在每次更新的梯度基础上减去一个梯度:
θ t + 1 → ( 1 − λ ) θ t − α ▽ f t ( θ t ) \theta_{t+1} \rightarrow (1-\lambda) \theta_t-\alpha\bigtriangledown f_t(\theta_t) θt+1→(1−λ)θt−α▽ft(θt)
可以看出当 λ ′ = λ α \lambda'= \frac{\lambda}{\alpha} λ′=αλ时,L2 regularization 和 Weight decay 是等价的(仅在使用标准SGD优化时成立)。
权重衰减(L2正则化项)为什么能够避免模型过拟合的问题?
和图像等领域不同,对于NLP之类的任务,每个batch采样到的词有限,每次更新对embedding的梯度估计都是稀疏的。对于 momentum-based 的 Optimizer,现在所有框架的实现都会用当前的 momentum 去更新所有的词,即使这些词在连续的几十步更新里都没有被采样到。这可能会使 Embedding 过拟合。
LazyAdam是Adam的变体,可以更有效地处理稀疏更新。原始的Adam算法为每个可训练变量维护两个移动平均累加器,累加器在每一步都会更新**。 而此类为稀疏变量提供了更加懒惰的梯度更新处理,它仅更新当前batch中出现的稀疏变量索引的移动平均累加器,而不是更新所有索引的累加器。 与原始的Adam优化器相比,它可以为某些应用提供模型训练吞吐量的大幅改进。 但是它的语义与原始的Adam算法略有不同,可能会导致不同的实验结果。
AdamOptimizer源码中函数_apply_sparse和_resource_apply_sparse 主要用在稀疏向量的更新操作上,而具体的实现是在函数_apply_sparse_shared中
LazyAdam的源码:
```python/py
def _apply_sparse(self, grad, Var):
beta1_power, beta2_power = self._get_beta_accumulators()
beta1_power = math_ops.cast(beta1_power, Var.dtype.base_dtype)
beta2_power = math_ops.cast(beta2_power, Var.dtype.base_dtype)
lr_t = math_ops.cast(self._lr_t, Var.dtype.base_dtype)
beta1_t = math_ops.cast(self._beta1_t, Var.dtype.base_dtype)
beta2_t = math_ops.cast(self._beta2_t, Var.dtype.base_dtype)
epsilon_t = math_ops.cast(self._epsilon_t, Var.dtype.base_dtype)
lr = (lr_t * math_ops.sqrt(1 - beta2_power) / (1 - beta1_power))
# \\(m := beta1 * m + (1 - beta1) * g_t\\)
m = self.get_slot(Var, "m")
m_t = state_ops.scatter_update(m, grad.indices,
beta1_t * array_ops.gather(m, grad.indices) +
(1 - beta1_t) * grad.Values,
use_locking=self._use_locking)#一阶动量
# \\(V := beta2 * V + (1 - beta2) * (g_t * g_t)\\)
V = self.get_slot(Var, "V")
V_t = state_ops.scatter_update(V, grad.indices,
beta2_t * array_ops.gather(V, grad.indices) +
(1 - beta2_t) * math_ops.square(grad.Values),
use_locking=self._use_locking) #二阶动量
# \\(Variable -= learning_rate * m_t / (epsilon_t + sqrt(V_t))\\)
m_t_slice = array_ops.gather(m_t, grad.indices)
V_t_slice = array_ops.gather(V_t, grad.indices)
denominator_slice = math_ops.sqrt(V_t_slice) + epsilon_t
Var_update = state_ops.scatter_sub(Var, grad.indices,
lr * m_t_slice / denominator_slice,
use_locking=self._use_locking)
return control_flow_ops.group(Var_update, m_t, V_t)
可以看出公式与Adam都相同,不同的是每次迭代根据当前batch的indices来对一阶动量和二阶动量进行更新。
class MaskedAdamOptimizer(adam.AdamOptimizer):
def _apply_sparse_shared(self, grad, Var, indices, scatter_add):
beta1_power, beta2_power = self._get_beta_accumulators()
beta1_power = math_ops.cast(beta1_power, Var.dtype.base_dtype)
beta2_power = math_ops.cast(beta2_power, Var.dtype.base_dtype)
lr_t = math_ops.cast(self._lr_t, Var.dtype.base_dtype)
beta1_t = math_ops.cast(self._beta1_t, Var.dtype.base_dtype)
beta2_t = math_ops.cast(self._beta2_t, Var.dtype.base_dtype)
epsilon_t = math_ops.cast(self._epsilon_t, Var.dtype.base_dtype)
lr = (lr_t * math_ops.sqrt(1 - beta2_power) / (1 - beta1_power))
# m_t = beta1 * m + (1 - beta1) * g_t
m = self.get_slot(Var, "m")
m_scaled_g_Values = grad * (1 - beta1_t)
m_t = state_ops.assign(m, m * beta1_t,
use_locking=self._use_locking) #与LazyAdam的不同之处
with ops.control_dependencies([m_t]):
m_t = scatter_add(m, indices, m_scaled_g_Values)
# V_t = beta2 * V + (1 - beta2) * (g_t * g_t)
V = self.get_slot(Var, "V")
V_scaled_g_Values = (grad * grad) * (1 - beta2_t)
V_t = state_ops.assign(V, V * beta2_t, use_locking=self._use_locking)
with ops.control_dependencies([V_t]):
V_t = scatter_add(V, indices, V_scaled_g_Values)
gather_m_t = array_ops.gather(m_t, indices)
gather_V_t = array_ops.gather(V_t, indices)
gather_V_sqrt = math_ops.sqrt(gather_V_t)
Var_update = scatter_add(Var, indices, -lr * gather_m_t / (gather_V_sqrt + epsilon_t))
return control_flow_ops.group(*[Var_update, m_t, V_t])
两者在计算移动平均累加器时(一阶动量和二阶动量)有所不同:
LazyAdam:
m_t = state_ops.scatter_update(m, grad.indices,
beta1_t * array_ops.gather(m, grad.indices) +
(1 - beta1_t) * grad.Values,
use_locking=self._use_locking)
Madam:
m_scaled_g_Values = grad * (1 - beta1_t)
m_t = state_ops.assign(m, m * beta1_t,
use_locking=self._use_locking)
with ops.control_dependencies([m_t]):
m_t = scatter_add(m, indices, m_scaled_g_Values)
Madam其实是介于Lazy Adam和 Adam之间的一种方法,其与Lazy Adam唯一的不同在于对一阶动量m和二阶动量 V 进行 decay 的操作,Madam是全部都要 decay,即当前batch没有采样到的变量所对应的之前动量的累积值也要考虑。 而LazyAdam 是只 decay 采样到的embedding。(在计算指数加权平均时,LazyAdam只对当前采样到的变量之前的平均值进行累加,没有采样到的样本不累加,而Madam要全部累加)。
LazyAdam存在的一个问题是当梯度为0时不更新对应的m和v。实际上当其他权重改变时m和v应该更新。Madam应该是解决了这个问题所以性能变得更好。
为了更形象的说明它们的差异,通过一个假设的例子来说明,用一阶动量来举例:
在上一篇中,我们用同一个框架让各类算法对号入座。可以看出,大家都是殊途同归,只是相当于在SGD基础上增加了各类学习率的主动控制。如果不想做精细的调优,那么Adam显然最便于直接拿来上手。但这样的傻瓜式操作并不一定能够适应所有的场合。如果能够深入了解数据,研究员们可以更加自如地控制优化迭代的各类参数,实现更好的效果也并不奇怪。
SGD虽然训练时间更长,容易陷入鞍点,但是在好的初始化和学习率调度方案的情况下,结果更可靠。SGD现在后期调优时还是经常使用到,但SGD的问题是前期收敛速度慢。SGD前期收敛慢的原因: SGD在更新参数时对各个维度上梯度的放缩是一致的,并且在训练数据分布极不均衡时训练效果很差。而因为收敛慢的问题应运而生的自适应优化算法Adam、AdaGrad、RMSprop 等,但这些自适应的优化算法泛化能力可能比非自适应方法更差,虽然可以在训练初始阶段展现出快速的收敛速度,但其在测试集上的表现却会很快陷入停滞,并最终被 SGD 超过。 实际上,在自然语言处理和计算机视觉方面的一些最新的工作中SGD(或动量)被选为优化器,其中这些实例中SGD 确实比自适应方法表现更好。
Adam的罪状一
这篇是正在深度学习领域顶级会议之一 ICLR 2018 匿名审稿中的 On the ConVergence of Adam and Beyond,探讨了Adam算法的收敛性,通过反例证明了Adam在某些情况下可能会不收敛。
回忆一下上文提到的各大优化算法的学习率: η t = α / V t \eta_t=\alpha/ \sqrt{V_t} ηt=α/Vt
其中,SGD没有用到二阶动量,因此学习率是恒定的(实际使用过程中会采用学习率衰减策略,因此学习率递减)。AdaGrad的二阶动量不断累积,单调递增,因此学习率是单调递减的。因此,这两类算法会使得学习率不断递减,最终收敛到0,模型也得以收敛。
但AdaDelta和Adam则不然。二阶动量是固定时间窗口内的累积,随着时间窗口的变化,遇到的数据可能发生巨变,使得 V t V_t Vt可能会时大时小,不是单调变化。这就可能在训练后期引起学习率的震荡,导致模型无法收敛。
这篇文章也给出了一个修正的方法。由于Adam中的学习率主要是由二阶动量控制的,为了保证算法的收敛,可以对二阶动量的变化进行控制,避免上下波动。
V t = m a x ( β 2 ∗ V t − 1 + ( 1 − β 2 ) g t 2 , V t − 1 ) V_t=max(\beta_2*V_{t-1}+(1-\beta_2)g_t^2,V_{t-1}) Vt=max(β2∗Vt−1+(1−β2)gt2,Vt−1)
这样就保证了 ∣ ∣ V t ∣ ∣ ≥ ∣ ∣ V t − 1 ∣ ∣ ||V_t|| \geq ||V_{t-1}|| ∣∣Vt∣∣≥∣∣Vt−1∣∣,使得学习率单调递减。
Adam的罪状二
深度神经网络往往包含大量的参数,在这样一个维度极高的空间内,非凸的目标函数往往起起伏伏,拥有无数个高地和洼地。有的是高峰,通过引入动量可能很容易越过;但有些是高原,可能探索很多次都出不来,于是停止了训练。
近期ArxiV上的两篇文章谈到这个问题。第一篇就是前文提到的吐槽Adam最狠的 The Marginal Value of AdaptiVe Gradient Methods in Machine Learning 。文中说到,同样的一个优化问题,不同的优化算法可能会找到不同的答案,但自适应学习率的算法往往找到非常差的答案。他们通过一个特定的数据例子说明,自适应学习率算法可能会对前期出现的特征过拟合,后期才出现的特征很难纠正前期的拟合效果。
另外一篇是 ImproVing Generalization Performance by Switching from Adam to SGD,进行了实验验证。他们CIFAR-10数据集上进行测试,Adam的收敛速度比SGD要快,但最终收敛的结果并没有SGD好。他们进一步实验发现,主要是后期Adam的学习率太低,影响了有效的收敛。他们试着对Adam的学习率的下界进行控制,发现效果好了很多。
于是他们提出了一个用来改进Adam的方法:前期用Adam,享受Adam快速收敛的优势;后期切换到SGD,慢慢寻找最优解。这一方法以前也被研究者们用到,不过主要是根据经验来选择切换的时机和切换后的学习率。这篇文章把这一切换过程傻瓜化,给出了切换SGD的时机选择方法,以及学习率的计算方法,效果看起来也不错。
这篇文章对于Adam后期的毛病进行了分析,原因出在自适应方法训练后期不稳定的极端学习率。换句话说,就是自适应学习率训练到后期,学习率出现极端情况,更新参数时有些维度上学习率特别大,有些维度学习率特别小。
我们可以看到,当模型接近收敛时,学习率中有大量的极端值(包含许多小于 0.01 和大于 1000 的情况)。这一现象表明在实际训练中,极端学习率是实际存在的。
该算法的具体做法是对学习率进行动态裁剪,其中 C l i p Clip Clip可以将学习率限制在下界 η l \eta _l ηl和上界 η u \eta_u ηu之间。很容易发现,SGD和Adam分别是应用梯度裁剪的特殊情况,学习率为 α ∗ \alpha^* α∗的SGD可视为 η l = η u = α ∗ \eta _l=\eta _u=\alpha^* ηl=ηu=α∗;而Adam可视为 η l = 0 , η n = ∞ \eta_l=0, \eta_n=\infty ηl=0,ηn=∞。那么,如果使用两个关于t的函数来取代固定值作为新的上下界,其中 η l ( t ) \eta_l(t) ηl(t)从0收敛至 α ∗ \alpha^* α∗, η u ( t ) \eta_u(t) ηu(t)从 ∞ \infty ∞也逐渐收敛至 α ∗ \alpha^* α∗,那么就成功实现了从Adam到SGD的动态过程。在这一设置下,在训练早期由于上下界对学习率的影响很小,算法更加接近于 Adam;而随着时间增长裁减区间越来越收紧,模型的学习率逐渐趋于稳定,在末期更加贴近于 SGD。通过这种方式,它既可以结合自适应方法的好处,快速的初始过程,又能拥有SGD的良好最终泛化属性。
将该方法与上面提到的将Adam转换为SGD的方法进行对比,其作者提出一种初始时使用Adam,接着在一些特定的步骤将Adam转换为SGD的方法,其受限的是是否有一个固定的转换点来区分Adam和SGD是不确定的。第二,其引入了一个额外的超参数来决定转换时间,不是很容易微调。与之相比,Adabound有两个优点,它一个连续的转换过程而不是一个强硬的转换,而且两个边界函数更加的灵活。
以下部分是作者使用Pytorch深度学习框架,使用CIFAR-10数据,在ResNet和DensetNet两个神经网络训练,使用各优化算法,以下为对比实验的代码截图:
结论:我们看到自适应方法(AdaGrad,Adam 和AMSGrad)刚开始比非自适应学习率(SGD)有着良好的表现。但是在150epoch之后学习率消失递减,SGD开始表现良好相比自适应方法更出色。从整个表现看我们的自适应方法AdaBound和AMSBound 能够在刚开始(和AdaGrad,Adam 和AMSGrad)快速得到一个较好的表现,也能在后面的epoch比SGD更出色稳定。
所以,谈到现在,到底Adam好还是SGD好?这可能是很难一句话说清楚的事情。去看学术会议中的各种paper,用SGD的很多,Adam的也不少,还有很多偏爱AdaGrad或者AdaDelta。可能研究员把每个算法都试了一遍,哪个出来的效果好就用哪个了。
而从这几篇怒怼Adam的paper来看,多数都构造了一些比较极端的例子来演示了Adam失效的可能性。这些例子一般过于极端,实际情况中可能未必会这样,但这提醒了我们,理解数据对于设计算法的必要性。优化算法的演变历史,都是基于对数据的某种假设而进行的优化,那么某种算法是否有效,就要看你的数据是否符合该算法的胃口了。算法固然美好,数据才是根本。另一方面,Adam之流虽然说已经简化了调参,但是并没有一劳永逸地解决问题,默认参数虽然好,但也不是放之四海而皆准。因此,在充分理解数据的基础上,依然需要根据数据特性、算法特性进行充分的调参实验,找到最优解。
在训练模型的时候,通常会遇到这种情况:我们平衡模型的训练速度和损失后选择了相对合适的学习率,但是训练集的损失下降到一定的程度后就不再下降了,最后最小值在附近摆动,不会精确地收敛,这是因为mini-batch中有噪声。遇到这种情况通常可以通过适当降低学习率来实现。但是,降低学习率又会延长训练所需的时间。学习率衰减就是一种可以平衡这两者之间矛盾的解决方案。
几种梯度衰减的方式:
参考资料: