本系列为 斯坦福CS231n 《深度学习与计算机视觉(Deep Learning for Computer Vision)》的全套学习笔记,对应的课程视频可以在 这里 查看。
ShowMeAI在上一篇 深度学习与CV教程(6) | 神经网络训练技巧 (上) 介绍了激活函数选择,sigmoid 和 tanh 都有饱和的问题;权重初始化不能太小也不能太大,最好使用Xavier初始化;数据预处理使用减去均值和归一化,线性分类中这两个操作会使分界线不那么敏感,即使稍微转动也可以,神经网络中也对权重的轻微改变没那么敏感,易于优化;也可以使用批量归一化,将输入数据变成单位高斯分布,或者缩放平移;学习过程跟踪损失、准确率;超参数体调优范围由粗到细,迭代次数逐渐增加,使用随机搜索。
关于优化算法的详细知识也可以对比阅读ShowMeAI的 深度学习教程 | 吴恩达专项课程 · 全套笔记解读 中的文章 神经网络优化算法 的讲解。
批梯度下降即 batch gradient descent,在训练中每一步迭代都使用训练集的所有内容 \{x_1, \cdots ,x_n\} 以及每个样本对应的输出 y_i,用于计算损失和梯度然后使用梯度下降更新参数。
当在整个数据集上进行计算时,只要学习率足够低,总是能在损失函数上得到非负的进展。参考代码如下(其中 learning_rate
是一个超参数):
# 普通梯度下降
while True:
weights_grad = evaluate_gradient(loss_fun, data, weights)
weights += -learning_rate * weights_grad # 参数更新
如下图为只有两个参数的损失函数,经过不断更新,如果足够幸运,函数最终收敛在红色部分是最低点:
这里的随机梯度下降(stochastic gradient descent)其实和之前介绍的MBGD(minibatch gradient descent)是一个意思,即每次迭代随机抽取一批样本 \{x_1, \cdots ,x_m\} 及 y_i,以此来反向传播计算出梯度,然后向负梯度方向更新参数。
SGD的优点是训练速度快,对于很大的数据集,也能够以较快的速度收敛。但是实际应用SGD会有很多问题:
① 如果损失函数在一个参数方向下降的快另一个方向下降的慢,这样会导致 「 之字形 」下降到最低点,高维中很普遍。
② 如果损失函数有局部极小值和鞍点(既不是极大值也不是极小值的临界点)时,此时的梯度为0,参数更新会卡住,或在极小值附近震荡。
③ SGD具有随机性,我们的梯度来自小批量数据(使用全部数据计算真实梯度速度太慢了),可能会有噪声,这样梯度下降的路线会很曲折,收敛的慢。
下面有一些「小批量梯度下降」基础上的优化算法。
带动量的更新方法在深度网络上几乎总能得到更好的收敛速度。
损失值可以理解为是山的高度(因此高度势能是 U=mgh),用随机数字初始化参数等同于在某个位置给质点设定初始速度为 0 ,这样最优化过程可以看做是参数向量(即质点)在地形上滚动的过程。
质点滚动的力来源于高度势能 F = - \nabla U,即损失函数的负梯度(想象损失函数为凸函数,梯度为正时质点会向负方向滚动,对应参数减小;损失函数梯度为负时会向正方向滚动对应参数增大)。又因为 F=ma,质点的加速度和负梯度成正比,所以负梯度方向速度是逐渐增加的。
在 SGD 中,梯度直接影响质点的位置,在梯度为 0 的地方,位置就不会更新了;而在这里,梯度作为作用力影响的是速度,速度再改变位置,即使梯度为 $0$
,但之前梯度累积下来的速度还在,一般而言,一个物体的动量指的是这个物体在它运动方向上保持运动的趋势,所以此时质点还是有动量的,位置仍然会更新,这样就可以冲出局部最小值或鞍点,继续更新参数。但是必须要给质点的速度一个衰减系数或者是摩擦系数,不然因为能量守恒,质点在谷底会不停的运动。
也就是说,参数更新的方向,不仅由当前点的梯度方向决定,而且由此前累积的梯度方向决定。
计算过程也是每次迭代随机抽取一批样本 {x_1, \cdots ,x_m} 及 y_i,计算梯度和损失,并更新速度和参数(假设质量为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
的稳定值为:\frac{-learning_{rate} \ast dw}{1-rho}rho
为 0 时表示 SGD,rho
一般取值 0.5、0.9、0.99,对应学习速度提高两倍、10倍和100倍。动量更新可以很好的解决上述 SGD 的几个问题:
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 动量可以用下面的数学表达式代替:
现在令 \tilde{x}_t =x_t+\rho v_t,则:
\begin{aligned}
\tilde{x}{t+1} &=x{t+1}+\rho v_{t+1}\
&=x{t}+v{t+1}+\rho v_{t+1}\
&=\tilde{x}{t}-\rho v{t}+v{t+1}+\rho v{t+1}
\end{aligned}
从而有:
示意图如下:
上面提到的方法对于所有参数都使用了同一个更新速率,但是同一个更新速率不一定适合所有参数。如果可以针对每个参数设置各自的学习率可能会更好,根据情况进行调整,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优化算法也可以自动调整学习率,并且RMSProp为每个参数选定不同的学习率。
RMSProp算法在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 不同,其更新不会让学习率单调变小。
动量更新在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 个周期减少到之前的 10%。
这些数值的设定是严重依赖具体问题和模型的选择的。在实践中可能看见这么一种经验做法:使用一个固定的学习率来进行训练的同时观察验证集错误率,每当验证集错误率停止下降,就乘以一个常数(比如0.5)来降低学习率。
② 指数衰减:数学公式是 \alpha=\alpha_0e^{-kt},其中 \alpha_0,k 是超参数, t 是迭代次数(也可以使用周期作为单位)。
③ 1/t 衰减:数学公式是 \alpha=\alpha_0/(1+kt)),其中 \alpha_0,k 是超参数, t 是迭代次数。
在实践中随步数衰减的随机失活(Dropout)更受欢迎,因为它使用的超参数(衰减系数和以周期为时间单位的步数)比k更有解释性。
如果你有足够的计算资源,可以让衰减更加缓慢一些,让训练时间更长些。
一般像SGD这种需要使用学习率退火,Adam等不需要。也不要一开始就使用,先不用,观察一下损失函数,然后确定什么地方需要减小学习率。
在深度网络背景下,第二类常用的最优化方法是基于牛顿方法的,其迭代如下:
H f(x) 是 Hessian 矩阵,由 f(x) 的二阶偏导数组成:
\mathbf{H}=\left[\begin{array}{cccc}
\frac{\partial^{2} f}{\partial x{1}^{2}} & \frac{\partial^{2} f}{\partial x{1} \partial x{2}} & \cdots & \frac{\partial^{2} f}{\partial x{1} \partial x_{n}} \
\frac{\partial^{2} f}{\partial x{2} \partial x{1}} & \frac{\partial^{2} f}{\partial x{2}^{2}} & \cdots & \frac{\partial^{2} f}{\partial x{2} \partial x_{n}} \
\vdots & \vdots & \ddots & \vdots \
\frac{\partial^{2} f}{\partial x{n} \partial x{1}} & \frac{\partial^{2} f}{\partial x{n} \partial x{2}} & \cdots & \frac{\partial^{2} f}{\partial x_{n}^{2}}
\end{array}\right]
x 是 n 维的向量,f(x) 是实数,所以海森矩阵是 n \ast n 的。
\nabla f(x) 是 n 维梯度向量,这和反向传播一样。
这个方法收敛速度很快,可以进行更高效的参数更新。在这个公式中是没有学习率这个超参数的,这相较于一阶方法是一个巨大的优势。
然而上述更新方法很难运用到实际的深度学习应用中去,这是因为计算(以及求逆)Hessian 矩阵操作非常耗费时间和空间。这样,各种各样的拟-牛顿法就被发明出来用于近似转置 Hessian 矩阵。
在这些方法中最流行的是L-BFGS,该方法使用随时间的梯度中的信息来隐式地近似(也就是说整个矩阵是从来没有被计算的)。
然而,即使解决了存储空间的问题,L-BFGS应用的一个巨大劣势是需要对整个训练集进行计算,而整个训练集一般包含几百万的样本。和小批量随机梯度下降(mini-batch SGD)不同,让 L-BFGS 在小批量上运行起来是很需要技巧,同时也是研究热点。
Tips:默认选择Adam;如果可以承担全批量更新,可以尝试使用L-BFGS。
关于正则化的详细知识也可以对比阅读ShowMeAI的深度学习教程 | 吴恩达专项课程 · 全套笔记解读中的文章深度学习的实用层面中关于正则化的讲解。
当我们增加神经网络隐藏层的数量和尺寸时,网络的容量会上升,即神经元可以合作表达许多复杂函数。例如,如果有一个在二维平面上的二分类问题。我们可以训练 3 个不同的神经网络,每个网络都只有一个隐藏层,但是隐藏层的神经元数目不同,结果如下:
在上图中,可以看见有更多神经元的神经网络可以表达更复杂的函数。然而这既是优势也是不足:
过拟合(Overfitting) 是网络对数据中的噪声有很强的拟合能力,而没有重视数据间(假设)的潜在基本关系。比如上图中:
那是不是说 「如果数据不是足够复杂,则小一点的网络似乎更好,因为可以防止过拟合」?
不是的,防止神经网络的过拟合有很多方法(L2正则化,Dropout和输入噪音等)。在实践中,使用这些方法来控制过拟合比减少网络神经元数目要好得多。
不应该因为害怕出现过拟合而使用小网络。相反,应该尽可能使用大网络,然后使用正则化技巧来控制过拟合。
上图每个神经网络都有 20 个隐藏层神经元,但是随着正则化强度增加,网络的决策边界变得更加平滑。所以,正则化强度是控制神经网络过拟合的好方法。
ConvNetsJS demo 上有一个小例子大家可以练练手。
有不少方法是通过控制神经网络的容量来防止其过拟合的:
L2正则化:最常用的正则化,通过惩罚目标函数中所有参数的平方实现。
L1正则化:是另一个相对常用的正则化方法,对于每个 w 都向目标函数增加一个 \lambda \mid w \mid。
最大范式约束(Max norm constraints):要求权重向量 w 必须满足 L2 范式 \Vert \vec{w} \Vert_2 < c,c 一般是 3 或 4。这种正则化还有一个良好的性质,即使在学习率设置过高的时候,网络中也不会出现数值「爆炸」,这是因为它的参数更新始终是被限制着的。
但是在神经网络中,最常用的正则化方式叫做 Dropout,下面我们详细展开介绍一下。
Dropout 是一个简单又极其有效的正则化方法,由 Srivastava 在论文 [Dropout](http://www.cs.toronto.edu/~rsalakhu/papers/srivastava14a.pdf)[: A Simple Way to Prevent Neural Networks from Overfitting](http://www.cs.toronto.edu/~rsalakhu/papers/srivastava14a.pdf) 中提出,与 L1 正则化、L2 正则化和最大范式约束等方法互为补充。
在训练的时候,随机失活的实现方法是让神经元以超参数 p (一般是 0.5)的概率被激活或者被设置为 0 。常用在全连接层。
一个三层的神经网络 Dropout 示例代码实现:
""" 普通版随机失活"""
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 层的梯度。
为什么这个想法可取呢?一个解释是防止特征间的相互适应:
另一个比较合理的解释是:
在训练过程中,失活是随机的,但是在测试过程中要避免这种随机性,所以不使用随机失活,要对数量巨大的子网络们做模型集成(model ensemble),以此来计算出一个预测期望。
比如只有一个神经元 a:
测试的时候由于不使用随机失活所以:
假如训练时随机失活的概率为 0.5,那么:
所以一个不确切但是很实用的做法是在测试时承随机失活概率,这样就能保证预测时的输出和训练时的期望输出一致。所以测试代码:
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)
反向随机失活还有一个好处,无论是否在训练时使用 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
在更一般化的分类上,随机失活属于网络在前向传播中有随机行为的方法。这种在训练过程加入随机性,然后在测试过程中对这些随机性进行平均或近似的思想在很多地方都能见到:
总之,这些方法都是在训练的时候增加随机噪声,测试时通过分析法(在使用随机失活的本例中就是乘以 p)或数值法(例如通过抽样出很多子网络,随机选择不同子网络进行前向传播,最后对它们取平均)将噪音边缘化。
一些常用的实践经验方法:
随机失活 p值一般默认设为 0.5,也可能在验证集上调参。
关于迁移学习的详细知识也可以对比阅读ShowMeAI的 深度学习教程 | 吴恩达专项课程 · 全套笔记解读 中的文章 AI 应用实践策略(下) 中关于正则化的讲解。
另一个导致过拟合的原因可能是训练样本过少,这时可以使用迁移学习来解决这个问题,它允许使用很少的数据来训练 CNN。
在目标检测和图像标记中都会使用迁移学习,图像处理部分都使用一个已经用 ImageNet 数据预训练好的 CNN 模型,然后根据具体的任务微调这些参数。
所以对一批数据集感兴趣但是数量不够时,可以在网上找一个数据很相似的有大量数据的训练模型,然后针对自己的问题微调或重新训练某些层。一些常用的深度学习软件包都含有已经训练好的模型,直接应用就好。
在实践的时候,有一个总是能提升神经网络几个百分点准确率的办法,就是在训练的时候训练几个独立的模型,然后在测试的时候平均它们预测结果。
集成的模型数量增加,算法的结果也单调提升(但提升效果越来越少)。
模型之间的差异度越大,提升效果可能越好。
进行集成有以下几种方法:
可以点击 B站 查看视频的【双语字幕】版本
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。