简介
在最优化问题求解的过程中,使用最多的迭代方法就是梯度法。比如针对最优化问题
m
i
n
x
f
(
x
)
min_{\mathbf x} f(\mathbf x)
minxf(x)
采用的梯度迭代的步骤为
x
=
x
−
η
∇
f
(
x
)
\mathbf x = \mathbf x - \eta \nabla f(\mathbf x)
x=x−η∇f(x)
梯度法的优势在于
- 抗"噪声"。即使每次求解的梯度并不是真实的目标函数的“梯度”,但只要保证求得的梯度“期望值”=目标函数的“梯度”,只要选择合适的步长,就可以收敛。这也就是神经网络里面使用最多的“stochastic gradient method”。因为神经网络要对所有的训练集的Loss总和求最小值,因此“真正”的梯度法应该是将所有的训练集作为一个batch,直接塞到神经网络里做梯度迭代收敛,但是由于训练集往往较大,很多情况是超过了计算机的内存的。因此只能将训练集分成多个batch,依次送入到神经网路做迭代。但只要保证我们训练集的数据是独立同分布的,那么采用多个batch总能有办法让神经网络收敛。这也是梯度法相对于其它迭代法的优势所在。
- 对目标函数要求仅需要一阶可导就可以,甚至只需要保证目标函数是连续函数就行。因为梯度法仅需要对目标函数进行一阶求导,因此对目标函数的“光滑性”要求较低。甚至于目标函数都不需要一阶可导。比如以下函数
这种函数是连续函数,但是存在第一间断点,这种情况下,在间断点处,只要取左导数或右导数,都可以实现收敛的(只不过有可能收敛到不同的局部最小点)。
然而梯度法的问题是,收敛速度慢,尤其是遇到“等高线”比较“扁”的函数时,就会出现来回“震荡”的情况,如图
因此在实际梯度法迭代中,要考虑在调整
η
\eta
η,防止出现震荡的情况。在实际应用时,往往要时刻监视梯度收敛的情况,如果发现来回震荡,就得现调
η
\eta
η,这显然不是一个很好的做法。
这也是若干年在学术上有不少人研究梯度法的原因,就是为了能够找到一种自动调整
η
\eta
η的方法,能应对各种目标函数,能够提高梯度的收敛速度。
梯度法改进-牛顿法
最优化问题里,传统上用的最多的还是凸优化问题,即求解
m
i
n
x
f
(
x
)
s.t.
x
∈
C
\begin{aligned} min_{\mathbf x} & f(\mathbf x) \\ \text{s.t. } & \mathbf x \in \mathbb{C} \end{aligned}
minxs.t. f(x)x∈C
其中
f
(
x
)
f(\mathbf x)
f(x)是关于
x
\mathbf x
x的凸函数,
C
\mathbb{C}
C是一个凸集。在凸优化问题中,局部最小值=全局最小值,也就是在凸优化问题里,我们所求解的问题只有一个“坑”,只要顺着坡儿往里爬总能爬到坑底的。像Matlab之类的早早就集成了凸优化的工具,只要给出目标函数和约束条件,就能自动给求出来最优值。
如果
f
(
x
)
f(\mathbf x)
f(x)是二阶可导的,用牛顿法进行迭代加速是很好使的。牛顿法的基本原理是把
x
\mathbf x
x所在的局部区域用二次函数进行近似,再求这个二次函数的最小值,即
x
=
x
−
(
∇
2
f
)
−
1
∇
f
\mathbf x = \mathbf x - (\nabla^2f)^{-1}\nabla f
x=x−(∇2f)−1∇f
然而牛顿法需要求解矩阵
∇
2
f
\nabla^2f
∇2f,如果
x
\mathbf x
x有
N
N
N个分量,那么矩阵
∇
2
f
\nabla^2f
∇2f就会有
N
2
N^2
N2个分量。所以在神经网络动辄就是上百万参数的情况下,牛顿法基本就不好使了,更何况这还是一次迭代的计算量呢。
神经网络不同于凸优化的问题在于,神经网络的Loss函数有大大小小的坑、鞍点(尤其是在高维空间,鞍点甚至会比坑更多)、悬崖峭壁,还需要有更合适的算法改进梯度法。因此,虽然梯度法本身用起来并不复杂,但就是这简单的算法仍然是前几年学术界的研究的热点。
梯度法改进-加momentum
思路是将梯度取一个历史的“滑动平均”,然后再用这个均值来更新
x
\mathbf x
x。具体的表述为
v
=
β
v
+
(
1
−
β
)
∇
f
(
x
)
x
=
x
−
η
v
\begin{aligned} \mathbf v &= \beta \mathbf v + (1-\beta) \nabla f(\mathbf{x}) \\ \mathbf x &= \mathbf x - \eta \mathbf v \end{aligned}
vx=βv+(1−β)∇f(x)=x−ηv
其中
v
\mathbf v
v的初始值取0.
从表达式可以看到,
v
\mathbf v
v相当于给真实的梯度做了“平滑”,可以消掉梯度中震荡较大的部分,这样实际的收敛就变成了以下的效果:
一般
β
\beta
β取0.9。这也就意味着相当于对最近的10个梯度求了平均值。
梯度法改进-RMSProp
RMSProp的表述为
s
=
β
s
+
(
1
−
β
)
(
∇
f
(
x
)
)
2
x
=
x
−
η
∇
f
(
x
)
s
+
ϵ
\begin{aligned} \mathbf s &= \beta \mathbf s + (1-\beta) (\nabla f(\mathbf x))^2 \\ \mathbf x &= \mathbf x - \eta\frac{\nabla f(\mathbf x)}{\sqrt{\mathbf s + \mathbf {\epsilon}}} \end{aligned}
sx=βs+(1−β)(∇f(x))2=x−ηs+ϵ∇f(x)
其中第一个式子的平方是逐分量取平方,第二个式子为逐分量相除,在分母上加上一个较小的数
ϵ
\epsilon
ϵ是为了防止
s
\mathbf s
s接近于0时,算出来的数过大。
还是以前面的梯度迭代的图为例,如果
∇
f
(
x
)
\nabla f(\mathbf x)
∇f(x)在纵向震荡分量较大,
s
\mathbf s
s就会大=》在第二个式子的迭代中,就会削减纵向震荡的分量。这样RMSProp也会取得和momentum类似的效果。
简单说下八卦,RMSProp本身并不是来自于学术论文,而是来自于2016年Coursera公开课上的一个讲义。
梯度法改进-Adam
Adam可以说是目前在神经网络中用的最多的算法了。像Tensorflow,Keras,Pytorch中都集成了Adam算法,一般的算法工程师搭好神经网络,在进行train的过程中,大多只需无脑配上Adam优化器,都会有不错的收敛效果。
Adam本身实现并不复杂,在Keras的源代码库中,Adam算上注释空行也就几十行代码,附上keras的源代码以供欣赏。
class Adam(Optimizer):
"""Adam optimizer.
Default parameters follow those provided in the original paper.
# Arguments
learning_rate: float >= 0. Learning rate.
beta_1: float, 0 < beta < 1. Generally close to 1.
beta_2: float, 0 < beta < 1. Generally close to 1.
amsgrad: boolean. Whether to apply the AMSGrad variant of this
algorithm from the paper "On the Convergence of Adam and
Beyond".
# References
- [Adam - A Method for Stochastic Optimization](
https://arxiv.org/abs/1412.6980v8)
- [On the Convergence of Adam and Beyond](
https://openreview.net/forum?id=ryQu7f-RZ)
"""
def __init__(self, learning_rate=0.001, beta_1=0.9, beta_2=0.999,
amsgrad=False, **kwargs):
self.initial_decay = kwargs.pop('decay', 0.0)
self.epsilon = kwargs.pop('epsilon', K.epsilon())
learning_rate = kwargs.pop('lr', learning_rate)
super(Adam, self).__init__(**kwargs)
with K.name_scope(self.__class__.__name__):
self.iterations = K.variable(0, dtype='int64', name='iterations')
self.learning_rate = K.variable(learning_rate, name='learning_rate')
self.beta_1 = K.variable(beta_1, name='beta_1')
self.beta_2 = K.variable(beta_2, name='beta_2')
self.decay = K.variable(self.initial_decay, name='decay')
self.amsgrad = amsgrad
@interfaces.legacy_get_updates_support
@K.symbolic
def get_updates(self, loss, params):
grads = self.get_gradients(loss, params)
self.updates = [K.update_add(self.iterations, 1)]
lr = self.learning_rate
if self.initial_decay > 0:
lr = lr * (1. / (1. + self.decay * K.cast(self.iterations,
K.dtype(self.decay))))
t = K.cast(self.iterations, K.floatx()) + 1
lr_t = lr * (K.sqrt(1. - K.pow(self.beta_2, t)) /
(1. - K.pow(self.beta_1, t)))
ms = [K.zeros(K.int_shape(p),
dtype=K.dtype(p),
name='m_' + str(i))
for (i, p) in enumerate(params)]
vs = [K.zeros(K.int_shape(p),
dtype=K.dtype(p),
name='v_' + str(i))
for (i, p) in enumerate(params)]
if self.amsgrad:
vhats = [K.zeros(K.int_shape(p),
dtype=K.dtype(p),
name='vhat_' + str(i))
for (i, p) in enumerate(params)]
else:
vhats = [K.zeros(1, name='vhat_' + str(i))
for i in range(len(params))]
self.weights = [self.iterations] + ms + vs + vhats
for p, g, m, v, vhat in zip(params, grads, ms, vs, vhats):
m_t = (self.beta_1 * m) + (1. - self.beta_1) * g
v_t = (self.beta_2 * v) + (1. - self.beta_2) * K.square(g)
if self.amsgrad:
vhat_t = K.maximum(vhat, v_t)
p_t = p - lr_t * m_t / (K.sqrt(vhat_t) + self.epsilon)
self.updates.append(K.update(vhat, vhat_t))
else:
p_t = p - lr_t * m_t / (K.sqrt(v_t) + self.epsilon)
self.updates.append(K.update(m, m_t))
self.updates.append(K.update(v, v_t))
new_p = p_t
# Apply constraints.
if getattr(p, 'constraint', None) is not None:
new_p = p.constraint(new_p)
self.updates.append(K.update(p, new_p))
return self.updates
def get_config(self):
config = {'learning_rate': float(K.get_value(self.learning_rate)),
'beta_1': float(K.get_value(self.beta_1)),
'beta_2': float(K.get_value(self.beta_2)),
'decay': float(K.get_value(self.decay)),
'epsilon': self.epsilon,
'amsgrad': self.amsgrad}
base_config = super(Adam, self).get_config()
return dict(list(base_config.items()) + list(config.items()))
Adam算法是集成了上述momentum和RMSProp算法的思想,组合创新提出来的。具体表述为:
v
t
=
β
1
v
t
−
1
+
(
1
−
β
1
)
∇
f
(
x
t
−
1
)
s
t
=
β
2
s
t
−
1
+
(
1
−
β
2
)
(
∇
f
(
x
t
−
1
)
)
2
v
^
t
=
v
t
1
−
β
1
t
s
^
t
=
s
t
1
−
β
2
t
x
t
=
x
t
−
1
−
η
v
^
t
s
^
t
+
ϵ
\begin{aligned} \mathbf v_t &= \beta_1 \mathbf v_{t-1} + (1-\beta_1) \nabla f(\mathbf{x_{t-1}}) \\ \mathbf s_t &= \beta_2 \mathbf s_{t-1} + (1-\beta_2) (\nabla f(\mathbf x_{t-1}))^2 \\ \mathbf {\hat v}_t &= \frac{\mathbf v_t}{\sqrt{1-\beta_1^t}} \\ \mathbf {\hat s}_t &= \frac{\mathbf s_t}{\sqrt{1-\beta_2^t}} \\ \mathbf x_t &= \mathbf x_{t-1} - \eta \frac{\mathbf {\hat v}_t}{\sqrt{\mathbf {\hat s}_t + \mathbf {\epsilon}}} \end{aligned}
vtstv^ts^txt=β1vt−1+(1−β1)∇f(xt−1)=β2st−1+(1−β2)(∇f(xt−1))2=1−β1tvt=1−β2tst=xt−1−ηs^t+ϵv^t
可见除了归一化的操作外,其它基本就是把momentum和RMSProp的算法进行了组装,但就是这个创新,大大改善了神经网络的迭代效果。
实际使用时,Adam作者建议
β
1
=
0.9
\beta_1=0.9
β1=0.9,
β
2
=
0.999
\beta_2=0.999
β2=0.999,
ϵ
=
1
0
−
8
\epsilon=10^{-8}
ϵ=10−8(Keras代码中,
ϵ
=
1
0
−
7
\epsilon=10^{-7}
ϵ=10−7且
ϵ
\epsilon
ϵ在平方根号外,不过这个改变其实对性能没有太大影响),
η
\eta
η就是所谓的learning rate,需要工程师根据具体的问题进行设置。
梯度法改进-learning rate decay
在Keras的Adam源码里,注意到还有一行之前没有提到,即
if self.initial_decay > 0:
lr = lr * (1. / (1. + self.decay * K.cast(self.iterations,
K.dtype(self.decay))))
即learning rate decay。其原理是如果梯度即将收敛的时候,如果learning rate取的较大,就会导致其一直在收敛区间附近来回震荡,无法收敛,因此learning rate应该“徐徐”的减少,确保迭代能收敛。因此,
η
=
η
1
1
+
d
e
c
a
y
∗
t
\eta = \eta \frac{1}{1+decay * t}
η=η1+decay∗t1
这里
t
t
t可以取迭代次数,即每一个batch都减少
η
\eta
η,也可以取epoch数,即每一个epoch才减少
η
\eta
η。很显然,Keras源码采用的是第一种情况。