上节课介绍了激活函数选择折叶函数,sigmoid和tanh都有饱和的问题;权重初始化不能太小也不能太大,最好使用Xavier初始化;数据预处理使用减去均值和归一化,线性分类中这两个操作会使分界线不那么敏感,即使稍微转动也可以,神经网络中也对权重的轻微改变没那么敏感,易于优化;也可以使用批量归一化,将输入数据变成单位高斯分布,或者缩放平移;学习过程跟踪损失、准确率;超参数体调优范围由粗到细,迭代次数逐渐增加,使用随机搜索。
本课重点:
-
更好的优化
-
正则化
-
迁移学习
-
模型集成
1 更好的优化(参数更新)
1.1 使用学习率优化
1.1.1 批梯度下降(BGD)
即batch gradient descent,在训练中每一步迭代都使用训练集的所有内容以及每个样本对应的输出,用于计算损失和梯度然后使用梯度下降更新参数。
优点:由于每一步都利用了训练集中的所有数据,因此当损失函数达到最小值以后,能够保证此时计算出的梯度为0,能够收敛。因此,使用BGD时不需要逐渐减小学习率。
缺点:随着数据集的增大,运行速度会越来越慢。
1.1.2 随机梯度下降(SGD)
之前介绍的优化方法随机梯度下降(stochastic gradient descent),不过这里的SGD其实跟MBGD(minibatch gradient descent)是一个意思,即每次迭代随机抽取一批样本及,以此来反向传播计算出梯度,然后向负梯度方向更新参数。其中learning_rate
是一个超参数,它是一个固定的常量。当在整个数据集上进行计算时,只要学习率足够低,总是能在损失函数上得到非负的进展。:
# 普通梯度下降
while True:
weights_grad = evaluate_gradient(loss_fun, data, weights)
weights += -learning_rate * weights_grad # 参数更新
比如一个只有两个参数的损失函数,经过不断更新,如果足够幸运,函数最终收敛在红色部分是最低点:
SGD的优点是训练速度快,对于很大的数据集,也能够以较快的速度收敛。但是实际应用SGD会有很多问题:
-
第一个问题就如果损失函数在一个参数方向下降的快另一个方向下降的慢,这样会导致“之字形”下降到最低点,高维中很普遍。下图是一个山沟状的区域,损失最小点沿着蓝色的线方向。考虑表面上的一个点A梯度,该点的梯度可以分解为两个分量,一个沿着方向w1,另一个沿着w2。梯度在w1方向上的分量要大得多,因为在w1方向上每走一步都比在w2方向损失值下降的多,虽然最小值在w2方向上。这样实际走一步在w1方向走的多,w2走得少,就会导致在这个沟里反复震荡,“之字形”前往最小值点。
-
第二个问题是如果损失函数有局部极小值和鞍点(既不是极大值也不是极小值的临界点)时,此时的梯度为0,参数更新会卡住,或在极小值附近震荡。在高维数据中,鞍点的存在是个更普遍也更大的问题,极小值每个梯度方向损失都会变大,而鞍点有的方向变大,有的减小,接近鞍点时更新缓慢。
-
第三个问题是SGD具有随机性,我们的梯度来自小批量数据(使用全部数据计算真实梯度速度太慢了),可能会有噪声,这样梯度下降的路线会很曲折,收敛的慢。
[图片上传中...(20181126202421173.gif-708652-1549079704969-0)]
以上所有问题,即使使用BGD,也会存在。
接下来的所有方法,都是基于小批量梯度下降。
1.1.3 动量(Momentum)更新
这个方法在深度网络上几乎总能得到更好的收敛速度。损失值可以理解为是山的高度(因此高度势能是U=mgh),用随机数字初始化参数等同于在某个位置给质点设定初始速度为0,这样最优化过程可以看做是参数向量(即质点)在地形上滚动的过程。质点滚动的力来源于高度势能,即损失函数的负梯度(想象损失函数为凸函数,梯度为正时质点会向负方向滚动,对应参数减小;损失函数梯度为负时会向正方向滚动对应参数增大)。又因为F=ma,质点的加速度和负梯度成正比,所以负梯度方向速度是逐渐增加的。在SGD中,梯度直接影响质点的位置,在梯度为0的地方,位置就不会更新了;而在这里,梯度作为作用力影响的是速度,速度再改变位置,即使梯度为0,但之前梯度累积下来的速度还在,一般而言,一个物体的动量指的是这个物体在它运动方向上保持运动的趋势,所以此时质点还是有动量的,位置仍然会更新,这样就可以冲出局部最小值或鞍点,继续更新参数。但是必须要给质点的速度一个衰减系数或者是摩擦系数,不然因为能量守恒,质点在谷底会不停的运动。也就是说,参数更新的方向,不仅由当前点的梯度方向决定,而且由此前累积的梯度方向决定。
计算过程也是每次迭代随机抽取一批样本及,计算梯度和损失,并更新速度和参数(假设质量为1,v即动量):
v=0
while True:
dW = compute_gradient(W, X_train, y_train)
v = rho * v - learning_rate * dW
W += v
rho
表示每回合速度v
的衰减程度,每次迭代得到的梯度都是dW
那么最后得到的v
的稳定值为 :
rho
为0时表示SGD,rho
一般取值0.5、0.9、0.99,对应学习速度提高两倍、10倍和100倍。
动量更新可以很好的解决上述SGD的几个问题:
-
由于参数的更新要累积之前的梯度,所以如果我们分别累加这些梯度的两个分量,那么w1方向上的分量将互相抵消,而w2方向上的分量得到了加强。 但是由于衰减系数,不可能完全抵消,但是已经可以加速通过,很大程度缓解了“之字形”收敛慢的问题。这也是减少震荡的原理。
-
局部最小值和鞍点由于还有之前的速度,会加速冲过去。
-
面对梯度变化比较大的方向,即一些噪声,由于此时质点还有比较大的速度,这时的反方向需要先将速度减小为0才能改变参数更新方向,由于速度是累加的,所以个别的噪声的影响不会那么大,就可以平滑快速的收敛。
1.1.4 Nesterov动量
Nesterov动量与普通动量有些许不同,最近变得比较流行。在理论上对于凸函数它能得到更好的收敛,在实践中也确实比标准动量表现更好一些。普通的动量更新在某一点处有一个速度,然后计算该点的梯度,实际的更新方向会根据速度方向和梯度方向做一个权衡。而Nesterov动量更新是既然我们知道动量将会将质点带到一个新的位置(即向前看),我们就不要在原来的位置计算梯度了,在这个“向前看”的地方计算梯度,更新参数。这样代码变为:
v=0
while True:
W_ahead = W + rho * v
dW_ahead = compute_gradient(W_ahead, X_train, y_train)
v = rho * v - learning_rate * dW_ahead
W += v
动量还是之前的动量,只是梯度变成将来的点的梯度。而在实践中,人们更喜欢和普通SGD或普通的动量方法一样简单的表达式。通过对W_ahead = W + rho * v
使用变量变换进行改写是可以做到的,然后用W_ahead而不是W来表示上面的更新。也就是说,实际存储的参数总是向前一步的那个版本。 代码如下:
v=0
while True:
pre_v = v
dW = compute_gradient(W, X_train, y_train)
v = rho * v - learning_rate * dW
W += -rho * pre_v + (1 + rho) * v
推导过程如下:
最初的Nesterov动量可以用下面的数学表达式代替:
现在令,则:
从而有:
只更新和即可。
示意图如下:1.1.5 自适应梯度算法(Adagrad)
上面提到的方法对于所有参数都使用了同一个更新速率,但是同一个更新速率不一定适合所有参数。如果可以针对每个参数设置各自的学习率可能会更好,根据情况进行调整,Adagrad是一个由Duchi等提出的适应性学习率算法。代码如下:
eps = 1e-7
grad_squared = 0
while True:
dW = compute_gradient(W)
grad_squared += dW * dW
W -= learning_rate * dW / (np.sqrt(grad_squared) + eps)
AdaGrad其实很简单,就是将每一维各自的历史梯度的平方叠加起来,然后更新的时候除以该历史梯度值即可。变量grad_squared
的尺寸和梯度矩阵的尺寸是一样的,用于累加每个参数的梯度的平方和。这个将用来归一化参数更新步长,归一化是逐元素进行的。eps
(一般设为1e-4到1e-8之间)用于平滑,防止出现除以0的情况。
优点:能够实现参数每一维的学习率的自动更改,如果某一维的梯度大,那么学习速率衰减的就快一些,延缓网络训练;如果某一维的梯度小,那么学习速率衰减的就慢一些,网络训练加快。
缺点:如果梯度累加的很大,学习率就会变得非常小,就会陷在局部极小值点或提前停止。(RMSProp算法可以很好的解决该问题。)
1.1.6 均方根支柱算法(RMSProp)
同样,RMSProp也可以自动调整学习率,并且RMSProp为每个参数选定不同的学习率。MSProp算法在AdaGrad基础上引入了衰减因子,RMSProp在梯度累积的时候,会对“过去”与“现在”做一个平衡,通过超参数decay_rate
调节衰减量,常用的值是[0.9,0.99,0.999]。其他不变,只是grad_squared
类似于动量更新的形式:
grad_squared = decay_rate * grad_squared + (1 - decay_rate) * dx * dx
相比于AdaGrad,这种方法很好的解决了训练过早结束的问题。和Adagrad不同,其更新不会让学习率单调变小。
1.1.7 自适应-动量优化(Adam)
动量更新在SGD基础上增加了一阶动量,AdaGrad和RMSProp在SGD基础上增加了二阶动量。把一阶动量和二阶动量结合起来,就是Adam——Adaptive + Momentum。代码如下:
eps = 1e-8
first_moment = 0 # 第一动量,用于累积梯度,加速训练
second_moment = 0 # 第二动量,用于累积梯度平方,自动调整学习率
while True:
dW = compute_gradient(W)
first_moment = beta1 * first_moment + (1 - beta1) * dW # Momentum
second_moment = beta2 * second_moment + (1 - beta2) * dW * dW # AdaGrad / RMSProp
W -= learning_rate * first_moment / (np.sqrt(second_moment) + eps)
看起来像是RMSProp的动量版,但是这个版本的Adam算法有个问题,第一步中second_monent
可能会比较小,这样就可能导致学习率非常大,所以完整的Adam需要加入偏置:
eps = 1e-8
first_moment = 0 # 第一动量,用于累积梯度,加速训练
second_moment = 0 # 第二动量,用于累积梯度平方,自动调整学习率
for t in range(1, num_iterations+1):
dW = compute_gradient(W)
first_moment = beta1 * first_moment + (1 - beta1) * dW # Momentum
second_moment = beta2 * second_moment + (1 - beta2) * dW * dW # AdaGrad / RMSProp
first_unbias = first_moment / (1 - beta1 ** t) # 加入偏置,随次数减小,防止初始值过小
second_unbias = second_moment / (1 - beta2 ** t)
W -= learning_rate * first_unbias / (np.sqrt(second_unbias) + eps)
论文中推荐的参数值**eps=1e-8, beta1=0.9, beta2=0.999, learning_rate = 1e-3或5e-4 **,对大多数模型效果都不错。在实际操作中,我们推荐Adam作为默认的算法,一般而言跑起来比RMSProp要好一点。
学习率退火
以上的所有优化方法,都需要使用超参数学习率。在训练深度网络的时候,让学习率随着时间衰减通常是有帮助的。可以这样理解:如果学习率很高,系统的动能就过大,参数向量就会无规律地跳动,不能够稳定到损失函数更深更窄的部分去。知道什么时候开始衰减学习率是有技巧的:慢慢减小它,可能在很长时间内只能是浪费计算资源地看着它混沌地跳动,实际进展很少。但如果快速地减少它,系统可能过快地失去能量,不能到达原本可以到达的最好位置。通常,实现学习率衰减有3种方式:
- 随步数衰减:每进行几个周期(epoch)就根据一些因素降低学习率。典型的值是每过5个周期就将学习率减少一半,或者每20个周期减少到之前的0.1。这些数值的设定是严重依赖具体问题和模型的选择的。在实践中可能看见这么一种经验做法:使用一个固定的学习率来进行训练的同时观察验证集错误率,每当验证集错误率停止下降,就乘以一个常数(比如0.5)来降低学习率。
- 指数衰减:数学公式是,其中是超参数,是迭代次数(也可以使用周期作为单位)。
- 1/t衰减:数学公式是,其中是超参数,是迭代次数。
在实践中,随步数衰减的随机失活(dropout)更受欢迎,因为它使用的超参数(衰减系数和以周期为时间单位的步数)比k更有解释性。最后,如果你有足够的计算资源,可以让衰减更加缓慢一些,让训练时间更长些。
一般像SGD这种需要使用学习率退火,Adam等不需要。也不要一开始就使用,先不用,观察一下损失函数,然后确定什么地方需要减小学习率。
1.2 二阶方法(Second-Order)
在深度网络背景下,第二类常用的最优化方法是基于牛顿方法的,其迭代如下:
是Hessian矩阵,由f(x)的二阶偏导数组成:
是n维梯度向量,这和反向传播一样。
这个方法收敛速度很快,可以进行更高效的参数更新。在这个公式中是没有学习率这个超参数的,这相较于一阶方法是一个巨大的优势。然而上述更新方法很难运用到实际的深度学习应用中去,这是因为计算(以及求逆)Hessian矩阵操作非常耗费时间和空间。这样,各种各样的拟-牛顿法就被发明出来用于近似转置Hessian矩阵。在这些方法中最流行的是L-BFGS,该方法使用随时间的梯度中的信息来隐式地近似(也就是说整个矩阵是从来没有被计算的)。
然而,即使解决了存储空间的问题,L-BFGS应用的一个巨大劣势是需要对整个训练集进行计算,而整个训练集一般包含几百万的样本。和小批量随机梯度下降(mini-batch SGD)不同,让L-BFGS在小批量上运行起来是很需要技巧,同时也是研究热点。
实际应用
Adam算法是默认的选择;如果可以承担全批量更新,可以使用L-BFGS。
2 正则化
2.1 正则化的动机
当我们增加神经网络隐藏层的数量和尺寸时,网络的容量会上升,即神经元可以合作表达许多复杂函数。例如,如果有一个在二维平面上的二分类问题。我们可以训练3个不同的神经网络,每个网络都只有一个隐藏层,但是隐藏层的神经元数目不同,结果如下:
基于上面的讨论,看起来如果数据不是足够复杂,则小一点的网络似乎更好,因为可以防止过拟合。然而并非如此,防止神经网络的过拟合有很多方法(L2正则化,dropout和输入噪音等),后面会详细讨论。在实践中,使用这些方法来控制过拟合比减少网络神经元数目要好得多。不应该因为害怕出现过拟合而使用小网络。相反,应该尽可能使用大网络,然后使用正则化技巧来控制过拟合。
可以在ConvNetsJS demo上练练手。
2.2 正则化方法
有不少方法是通过控制神经网络的容量来防止其过拟合的:
L2正则化:最常用的正则化,通过惩罚目标函数中所有参数的平方实现。对于网络中的每个权重,向目标函数中增加一个,1/2为了方便求导,是正则强度。L2正则化可以直观理解为它对于大数值的权重向量进行严厉惩罚,倾向于更加分散的权重向量。使网络更倾向于使用所有输入特征,而不是严重依赖输入特征中某些小部分特征。
L1正则化:是另一个相对常用的正则化方法,对于每个都向目标函数增加一个。L1正则化会让权重向量在最优化的过程中变得稀疏(即非常接近0)。在实践中,如果不是特别关注某些明确的特征选择,一般说来L2正则化都会比L1正则化效果好。L1和L2正则化也可以进行组合:,称作Elastic net regularization。
最大范式约束(Max norm constraints):要求权重向量必须满足L2范式,c一般是3或4。这种正则化还有一个良好的性质,即使在学习率设置过高的时候,网络中也不会出现数值“爆炸”,这是因为它的参数更新始终是被限制着的。
但是效果最好的还是接下来要讲的——Dropout!
随机失活(Dropout)
概述
Dropout是一个简单又极其有效的正则化方法,由Srivastava在论文Dropout: A Simple Way to Prevent Neural Networks from Overfitting中提出,与L1正则化、L2正则化和最大范式约束等方法互为补充。在训练的时候,随机失活的实现方法是让神经元以超参数(一般是0.5)的概率被激活或者被设置为0。常用在全连接层。
一个三层的神经网络代码实现:
""" 普通版随机失活"""
p = 0.5 # 神经元被激活的概率。p值越高,失活数目越少
def train_step(X):
""" X中是输入数据 """
# 前向传播
H1 = np.maximum(0, np.dot(W1, X) + b1)
U1 = np.random.rand(*H1.shape) < p # 第一个随机失活掩模
# rand可以返回一个或一组服从“0~1”均匀分布的随机样本值
# 矩阵中满足小于p的元素为True,不满足False
# rand()函数的参数是两个或一个整数,不是元组,所以需要*H1.shape获取行列
H1 *= U1 # U1中False的H1对应位置置零
H2 = np.maximum(0, np.dot(W2, H1) + b2)
U2 = np.random.rand(*H2.shape) < p # 第二个随机失活掩模
H2 *= U2 # drop!
out = np.dot(W3, H2) + b3
# 反向传播:计算梯度... (略)
# 进行参数更新... (略)
在上面的代码中,train_step
函数在第一个隐层和第二个隐层上进行了两次随机失活。在输入层上面进行随机失活也是可以的,为此需要为输入数据X创建一个二值(要么激活要么失活)的掩模。反向传播几乎保持不变,只需回传梯度乘以掩模得到dropout层的梯度。
Dropout的理解
为什么这个想法可取呢?
一个比较勉强的解释是防止特征间的相互适应。比如每个神经元学到了猫的一个特征比如尾巴、胡须、爪子等,将这些特征全部组合起来可以判断是一只猫。加入随机失活后就只能依赖一些零散的特征去判断不能使用所有特征,这样可以一定程度上抑制过拟合。不然训练时正确率很高,测试时却很低。
另一个比较合理的解释是在训练过程中,随机失活可以被认为是对完整的神经网络抽样出一些子集,每次基于输入数据只更新子网络的参数。每个二值掩模都是一个模型,有n个神经元的网络有2n种掩模。Dropout相当于数量巨大的网络模型(共享参数)在同时被训练。
测试时避免随机失活
在训练过程中,失活是随机的,但是在测试过程中要避免这种随机性,所以不使用随机失活,要对数量巨大的子网络们做模型集成(model ensemble),以此来计算出一个预测期望。比如只有一个神经元a:
def predict(X):
# 前向传播时模型集成
H1 = np.maximum(0, np.dot(W1, X) + b1) * p # 注意:激活数据要乘以p
H2 = np.maximum(0, np.dot(W2, H1) + b2) * p # 注意:激活数据要乘以p
out = np.dot(W3, H2) + b3
上述操作不好的地方是必须在测试时对激活数据按照失活概率p进行数值范围调整。既然测试性能如此关键,实际更倾向使用反向随机失活(inverted dropout),它是在训练时就进行数值范围调整,从而让前向传播在测试时保持不变。这样做还有一个好处,无论是否在训练时使用随机失活,预测的代码可以保持不变。反向随机失活的代码如下:
"""
反向随机失活: 推荐实现方式.
在训练的时候drop和调整数值范围,测试时不做任何事.
"""
p = 0.5
def train_step(X):
# 前向传播
H1 = np.maximum(0, np.dot(W1, X) + b1)
U1 = (np.random.rand(*H1.shape) < p) / p # 第一个随机失活遮罩. 注意/p!
H1 *= U1 # drop!
H2 = np.maximum(0, np.dot(W2, H1) + b2)
U2 = (np.random.rand(*H2.shape) < p) / p # 第二个随机失活遮罩. 注意/p!
H2 *= U2 # drop!
out = np.dot(W3, H2) + b3
def predict(X):
# 前向传播时模型集成
H1 = np.maximum(0, np.dot(W1, X) + b1) # 不用数值范围调整了
H2 = np.maximum(0, np.dot(W2, H1) + b2)
out = np.dot(W3, H2) + b3
在更一般化的分类上,随机失活属于网络在前向传播中有随机行为的方法。这种在训练过程加入随机性,然后在测试过程中对这些随机性进行平均或近似的思想在很多地方都能见到:
- 批量归一化:训练时的均值和方差来自随机的小批量;测试时使用的是整个训练过程中的经验方差和均值。
- 数据增强(data augmentation):比如一张猫的图片进行训练时,可以随机的裁剪翻转等操作再训练,然后测试过程再对一些固定的位置(四个角、中心及翻转)进行测试。也可以在训练的时候随机改变亮度对比度,色彩抖动PCA降维等。
- DropConnect:另一个与Dropout类似的研究是DropConnect,它在前向传播的时候,一系列权重被随机设置为0。
- 部分最大池化(Fractional Max Pooling):训练时随机区域池化,测试时固定区域或者取平均值。这个方法并不常用。
- 随机深度(Stochastic Depth):一个比较深的网络,训练时随机选取部分层去训练,测试时使用全部的层。这个研究非常前沿。
总之,这些方法都是在训练的时候增加随机噪声,测试时通过分析法(在使用随机失活的本例中就是乘以p)或数值法(例如通过抽样出很多子网络,随机选择不同子网络进行前向传播,最后对它们取平均)将噪音边缘化。
实践
通过交叉验证获得一个全局使用的L2正则化强度是比较常见的。在使用L2正则化的同时在所有层后面使用随机失活也很常见。p值一般默认设为0.5,也可能在验证集上调参。
3 迁移学习
另一个导致过拟合的原因可能是训练样本过少,这时可以使用迁移学习来解决这个问题,它允许使用很少的数据来训练CNN。
迁移学习的思想
第一步:在大量的数据集上训练一个CNN,得到模型(比如使用ImageNet,有1000个分类)
第二步:使用一个少量的数据集,最后需要的得到的分类也不再是1000而是一个较小的值C,比如10。这时最后一个全连接层的参数矩阵变成4096xC,初始化这个矩阵,重新训练这个线性分类器,保持前面的所有层不变,因为前面的层已经训练好了,有了泛化能力。
-
第三步:当得到较多的训练集后,训练的层数可以增多,比如可以训练最后三个全连接层。可以使用较低的学习率微调参数。
应用
在目标检测和图像标记中都会使用迁移学习,图像处理部分都使用一个已经用ImageNet数据预训练好的CNN模型,然后根据具体的任务微调这些参数。
所以对一批数据集感兴趣但是数量不够时,可以在网上找一个数据很相似的有大量数据的训练模型,然后针对自己的问题微调或重新训练某些层。一些常用的深度学习软件包都含有已经训练好的模型,直接应用就好。
Caffe: https://github.com/BVLC/caffe/wiki/Model-Zoo
TensorFlow: https://github.com/tensorflow/models
PyTorch: https://github.com/pytorch/vision
4 模型集成(Model Ensembles)
在实践的时候,有一个总是能提升神经网络几个百分点准确率的办法,就是在训练的时候训练几个独立的模型,然后在测试的时候平均它们预测结果。集成的模型数量增加,算法的结果也单调提升(但提升效果越来越少)。还有模型之间的差异度越大,提升效果可能越好。进行集成有以下几种方法:
- 同一个模型,不同的初始化。使用交叉验证来得到最好的超参数,然后用最好的参数来训练不同初始化条件的模型。这种方法的风险在于多样性只来自于不同的初始化条件。
- 在交叉验证中发现最好的模型。使用交叉验证来得到最好的超参数,然后取其中最好的几个(比如10个)模型来进行集成。这样就提高了集成的多样性,但风险在于可能会包含不够理想的模型。在实际操作中,这样操作起来比较简单,在交叉验证后就不需要额外的训练了。
- 一个模型设置多个记录点。如果训练非常耗时,那就在不同的训练时间对网络留下记录点(比如每个周期结束),然后用它们来进行模型集成。很显然,这样做多样性不足,但是在实践中效果还是不错的,这种方法的优势是代价比较小。
- 在训练的时候跑参数的平均值。和上面一点相关的,还有一个也能得到1-2个百分点的提升的小代价方法,这个方法就是在训练过程中,如果损失值相较于前一次权重出现指数下降时,就在内存中对网络的权重进行一个备份。这样你就对前几次循环中的网络状态进行了平均。你会发现这个“平滑”过的版本的权重总是能得到更少的误差。直观的理解就是目标函数是一个碗状的,你的网络在这个周围跳跃,所以对它们平均一下,就更可能跳到中心去。
总结
- 优化方式:SGD、动量更新、Nesterov动量、Adagrad、RMSProp、Adam等,一般无脑使用Adam。此外还有学习率退火和二阶方法。
- 正则化:L2比较常用,Dropout也是一个很好的正则方法。
- 数据较少时可以使用迁移学习。
- 模型集成。