神经网络训练细节与注意点
本文主要包括以下内容:
- 梯度检查
- 合理性(Sanity)检查
- 检查学习过程
- 损失函数
- 训练集与验证集准确率
- 权重:更新比例
- 每层的激活数据与梯度分布
- 可视化
- 参数更新
- 一阶(随机梯度下降)方法,动量方法,Nesterov动量方法
- 学习率退火
- 二阶方法
- 逐参数适应学习率方法(Adagrad,RMSProp)
- 超参数调优
- 评价
- 模型集成
- 总结
- 拓展引用
梯度检查
理论上将进行梯度检查很简单,就是简单地把解析梯度和数值计算梯度进行比较。然而从实际操作层面上来说,这个过程更加复杂且容易出错。下面是一些提示、技巧和需要仔细注意的事情:
加max项的原因很简单:整体形式变得简单和对称。再提个小醒,别忘了避开分母中两项都为0的情况。OK,对于相对误差而言
- 相对误差>1e-2意味着你的实现肯定是有问题的
- 1e-2>相对误差>1e-4,你会有点担心
- 1e-4>相对误差,基本是OK的,但是要注意极端情况(使用tanh或者softmax时候出现kinks)那还是太大
- 1e-7>相对误差,放心大胆使用
使用双精度浮点数
如果你使用单精度浮点数计算,那你的实现可能一点问题都没有,但是相对误差却很大。实际工程中出现过,从单精度切到双精度,相对误差立马从1e-2降到1e-8的情况。
要留意浮点数的范围
一篇很好的文章是What Every Computer Scientist Should Know About Floating-Point Arithmetic。我们得保证计算时,所有的数都在浮点数的可计算范围内,太小的值(通常绝对值小于1e-10就绝对让人担心)会带来计算上的问题。如果确实过小,可以使用一个常数暂时将损失函数的数值范围扩展到一个更“好”的范围,在这个范围中浮点数变得更加致密。比较理想的是1.0的数量级上,即当浮点数指数为0时。
目标函数的不可导点(kinks)
在进行梯度检查时,一个导致不准确的原因是不可导点问题。不可导点是指目标函数不可导的部分,它指的是一种会导致数值梯度和解析梯度不一致的情况。会出现在使用ReLU或者类似的神经单元上时,对于很小的负数,比如x=-1e-6,因为x<0,所以解析梯度是绝对为0的,但是对于数值梯度而言,加入你计算f(x+h),取的h>1e-6,那就跳到大于0的部分了,这样数值梯度就一定和解析梯度不一样了。而且这个并不是极端情况哦,对于一个像CIFAR-10这样级别的数据集,因为有50000个样本,会有450000个max(0,x),会出现很多的kinks。
不过我们可以监控max里的2项,比较大的那项如果存在跃过0的情况,那就要注意了。
使用少量数据点
解决上面的不可导点问题的一个办法是使用更少的数据点。因为含有不可导点的损失函数(例如:因为使用了ReLU或者边缘损失等函数)的数据点越少,不可导点就越少,所以在计算有限差值近似时越过不可导点的几率就越小。还有,如果你的梯度检查对2-3个数据点都有效,那么基本上对整个批量数据进行梯度检查也是没问题的。所以使用很少量的数据点,能让梯度检查更迅速高效。
设定步长h要小心
h肯定不能特别大,这个大家都知道对吧。但我并不是说h要设定的非常小,其实h设定的非常小也会有问题,因为h太小程序可能会有精度问题。很有意思的是,有时候在实际情况中h如果从非常小调为1e-4或者1e-6反倒会突然计算变得正常。
不要让正则化项盖过数据项
通常损失函数是数据损失和正则化损失的和(例如L2对权重的惩罚)。需要注意的危险是正则化损失可能吞没掉数据损失,在这种情况下梯度主要来源于正则化部分(正则化部分的梯度表达式通常简单很多)。这样就会掩盖掉数据损失梯度的不正确实现。因此,推荐先关掉正则化对数据损失做单独检查,然后对正则化做单独检查。对于正则化的单独检查可以是修改代码,去掉其中数据损失的部分,也可以提高正则化强度,确认其效果在梯度检查中是无法忽略的,这样不正确的实现就会被观察到了。
记得关闭随机失活(dropout)和数据扩张(augmentation)
在进行梯度检查时,记得关闭网络中任何不确定的效果的操作,比如随机失活,随机数据扩展等。不然它们会在计算数值梯度的时候导致巨大误差。关闭这些操作不好的一点是无法对它们进行梯度检查(例如随机失活的反向传播实现可能有错误)。因此,一个更好的解决方案就是在计算f(x+h)和f(x-h)前强制增加一个特定的随机种子,在计算解析梯度时也同样如此。
检查少量的维度
在实际中,梯度可以有上百万的参数,在这种情况下只能检查其中一些维度然后假设其他维度是正确的。注意:确认在所有不同的参数实际情况中,梯度可能有上百万维参数。因此每个维度都检查一遍就不太现实了,一般都是只检查一些维度,然后假定其他的维度也都正确。要小心一点:要保证这些维度的每个参数都检查对比过了。
训练前的检查工作
在开始训练之前,我们还得做一些检查,来确保不会运行了好一阵子,才发现计算代价这么大的训练其实并不正确。
在初始化之后看一眼loss。其实我们在用很小的随机数初始化神经网络后,第一遍计算loss可以做一次检查(当然要记得把正则化系数设为0)。以CIFAR-10为例,如果使用Softmax分类器,我们预测应该可以拿到值为2.302左右的初始loss(因为10个类别,初始概率应该都为0.1,Softmax损失是-log(正确类别的概率):-ln(0.1)=2.302)。对于Weston Watkins SVM,假设所有的边界都被越过(因为所有的分值都近似为零),所以损失值是9(因为对于每个错误分类,边界值是1)。如果没看到这些损失值,那么初始化中就可能有问题。
加回正则项,接着我们把正则化系数设为正常的小值,加回正则化项,这时候再算损失/loss,应该比刚才要大一些。
试着去拟合一个小的数据集。最后一步,也是很重要的一步,在对大数据集做训练之前,我们可以先训练一个小的数据集(比如20张图片),然后看看你的神经网络能够做到0损失/loss(当然,是指的正则化系数为0的情况下),因为如果神经网络实现是正确的,在无正则化项的情况下,完全能够过拟合这一小部分的数据。但是注意,能对小数据集进行过拟合并不代表万事大吉,依然有可能存在不正确的实现。比如,因为某些错误,数据点的特征是随机的,这样算法也可能对小数据进行过拟合,但是在整个数据集上跑算法的时候,就没有任何泛化能力。
训练过程中的监控
开始训练之后,我们可以通过监控一些指标来了解训练的状态。我们还记得有一些参数是我们认为敲定的,比如学习率,比如正则化系数。
损失/loss随每轮完整迭代后的变化
下面这幅图表明了不同的学习率下,我们每轮完整迭代(这里的一轮完整迭代指的是所有的样本都被过了一遍,因为随机梯度下降中batch size的大小设定可能不同,因此我们不选每次mini-batch迭代为周期)过后的loss应该呈现的变化状况:
合适的学习率可以保证每轮完整训练之后,loss都减小,且能在一段时间后降到一个较小的程度。太小的学习率下loss减小的速度很慢,如果太激进,设置太高的学习率,开始的loss减小速度非常可观,可是到了某个程度之后就不再下降了,在离最低点一段距离的地方反复,无法下降了。下图是实际训练CIFAR-10的时候,loss的变化情况:
大家可能会注意到上图的曲线有一些上下跳动,不稳定,这和随机梯度下降时候设定的batch size有关系。batch size非常小的情况下,会出现很大程度的不稳定,如果batch size设定大一些,会相对稳定一点。
训练集/验证集上的准确度
然后我们需要跟踪一下训练集和验证集上的准确度状况,以判断分类器所处的状态(过拟合程度如何):
随着时间推进,训练集和验证集上的准确度都会上升,如果训练集上的准确度到达一定程度后,两者之间的差值比较大,那就要注意一下,可能是过拟合现象,如果差值不大,那说明模型状况良好。
权重:权重更新部分 的比例
最后一个应该跟踪的量是权重中更新值的数量和全部值的数量之间的比例。注意:是更新的,而不是原始梯度(比如,在普通sgd中就是梯度乘以学习率)。需要对每个参数集的更新比例进行单独的计算和跟踪。一个经验性的结论是这个比例应该在1e-3左右。如果更低,说明学习率可能太小,如果更高,说明学习率可能太高。下面是具体例子:
# 假设参数向量为W,其梯度向量为dW
param_scale = np.linalg.norm(W.ravel())
update = -learning_rate*dW # 简单SGD更新
update_scale = np.linalg.norm(update.ravel())
W += update # 实际更新
print update_scale / param_scale # 要得到1e-3左右
相较于跟踪最大和最小值,有研究者更喜欢计算和跟踪梯度的范式及其更新。这些矩阵通常是相关的,也能得到近似的结果。
每层的激活数据及梯度分布
如果初始化不正确,那整个训练过程会越来越慢,甚至直接停掉。不过我们可以很容易发现这个问题。体现最明显的数据是每一层的激励和梯度的方差(波动状况)。举个例子说,如果初始化不正确,很有可能从前到后逐层的激励(激励函数的输入部分)方差变化是如下的状况:
# 我们用标准差为0.01均值为0的高斯分布值来初始化权重(这不合理)
Layer 0: Variance: 1.005315e+00
Layer 1: Variance: 3.123429e-04 Layer 2: Variance: 1.159213e-06 Layer 3: Variance: 5.467721e-10 Layer 4: Variance: 2.757210e-13 Layer 5: Variance: 3.316570e-16 Layer 6: Variance: 3.123025e-19 Layer 7: Variance: 6.199031e-22 Layer 8: Variance: 6.623673e-25
大家看一眼上述的数值,就会发现,从前往后,激励值波动逐层降得非常厉害,这也就意味着反向算法中,计算回传梯度的时候,梯度都要接近0了,因此参数的迭代更新几乎就要衰减没了,显然不太靠谱。我们按照上一讲中提到的方式正确初始化权重,再逐层看激励/梯度值的方差,会发现它们的方差衰减没那么厉害,近似在一个级别:
# 重新正确设定权重:
Layer 0: Variance: 1.002860e+00
Layer 1: Variance: 7.015103e-01 Layer 2: Variance: 6.048625e-01 Layer 3: Variance: 8.517882e-01 Layer 4: Variance: 6.362898e-01 Layer 5: Variance: 4.329555e-01 Layer 6: Variance: 3.539950e-01 Layer 7: Variance: 3.809120e-01 Layer 8: Variance: 2.497737e-01
再看逐层的激励波动情况,你会发现即使到最后一层,网络也还是『活跃』的,意味着反向传播中回传的梯度值也是够的,神经网络是一个积极learning的状态。
第一层可视化
最后再提一句,如果神经网络是用在图像相关的问题上,那么把首层的特征和数据画出来(可视化)可以帮助我们了解训练是否正常:上图的左右是一个正常和不正常情况下首层特征的可视化对比。左边的图中特征噪点较多,图像很『浑浊』,预示着可能训练处于『病态』过程:也许是学习率设定不正常,或者正则化系数设定太低了,或者是别的原因,可能神经网络不会收敛。右边的图中,特征很平滑和干净,同时相互间的区分度较大,这表明训练过程比较正常。
参数更新
当我们确信解析梯度实现正确后,那就该在后向传播算法中使用它更新权重参数了。就单参数更新这个部分,也是有讲究的:
说起来,神经网络的最优化这个子话题在深度学习研究领域还真是很热。下面提一下大神们的论文中提到的方法,很多在实际应用中还真是很有效也很常用。
随机梯度下降与参数更新
普通更新。最简单的更新形式是沿着负梯度方向改变参数(因为梯度指向的是上升方向,但是我们通常希望最小化损失函数)。假设有一个参数向量x及其梯度dx,那么最简单的更新的形式是:
# 普通更新
x += - learning_rate * dx
其中learning_rate是一个超参数,它是一个固定的常量。当在整个数据集上进行计算时,只要学习率足够低,总是能在损失函数上得到非负的进展。
Momentum update
这是上面参数更新方法的一种小小的优化,通常说来,在深层次的神经网络中,收敛效率更高一些(速度更快)。这种参数更新方式源于物理学角度的优化。
# 物理动量角度启发的参数更新
v = mu * v - learning_rate * dx # 合入一部分附加速度
x += v # 更新参数
这里v是初始化为0的一个值,mu是我们敲定的另外一个超变量(最常见的设定值为0.9,物理含义和摩擦力系数相关),一个比较粗糙的理解是,(随机)梯度下降可以看做从山上下山到山底的过程,这种方式,相当于在下山的过程中,加上了一定的摩擦阻力,消耗掉一小部分动力系统的能量,这样会比较高效地在山底停住,而不是持续震荡。对了,其实我们也可以用交叉验证来选择最合适的mu值,一般我们会从[0.5, 0.9, 0.95, 0.99]里面选出最合适的。
Nesterov Momentum
Nesterov动量与普通动量有些许不同,最近变得比较流行。在理论上对于凸函数它能得到更好的收敛,在实践中也确实比标准动量表现更好一些。
它的思想对应着如下的代码:
x_ahead = x + mu * v
# 计算dx_ahead(在x_ahead处的梯度,而不是在x处的梯度)
v = mu * v - learning_rate * dx_ahead
x += v
然而在实践中,人们更喜欢和普通SGD或上面的动量方法一样简单的表达式。通过对x_ahead = x + mu * v使用变量变换进行改写是可以做到的,然后用x_ahead而不是x来表示上面的更新。也就是说,实际存储的参数向量总是向前一步的那个版本。x_ahead的公式(将其重新命名为x)就变成了:
v_prev = v # 存储备份
v = mu * v - learning_rate * dx # 速度更新保持不变
x += -mu * v_prev + (1 + mu) * v # 位置更新变了形式
学习率退火
在实际训练过程中,随着训练过程推进,逐渐衰减学习率是很有必要的。我们继续回到下山的场景中,刚下山的时候,可能离最低点很远,那我步子迈大一点也没什么关系,可是快到山脚了,我还激进地大步飞奔,一不小心可能就迈过去了。所以还不如随着下山过程推进,逐步减缓一点点步伐。不过这个『火候』确实要好好把握,衰减太慢的话,最低段震荡的情况依旧;衰减太快的话,整个系统下降的『动力』衰减太快,很快就下降不动了。下面提一些常见的学习率衰减方式:
- 步伐衰减:这是很常见的一个衰减模式,每进行几个周期就根据一些因素降低学习率。典型的值是每过5个周期就将学习率减少一半,或者每20个周期减少到之前的0.1。这些数值的设定是严重依赖具体问题和模型的选择的。在实践中可能看见这么一种经验做法:使用一个固定的学习率来进行训练的同时观察验证集错误率,每当验证集错误率停止下降,就乘以一个常数(比如0.5)来降低学习率。
- 指数级别衰减:数学形式为α=α0e−ktα=α0e−kt,其中α0α0,k是需要自己敲定的超参数,t是迭代轮数。
- 1/t衰减:有着数学形式为α=α0/(1+kt)α=α0/(1+kt)的衰减模式,其中α0α0,k是需要自己敲定的超参数,t是迭代轮数。
实际工程实践中,大家还是更倾向于使用步伐衰减,因为它包含的超参数少一些,计算简单一些,可解释性稍微高一点。
二阶方法
逐参数适应学习率方法
到目前为止大家看到的学习率更新方式,都是全局使用同样的学习率。调整学习率是一件很费时同时也容易出错的事情,因此大家一直希望有一种学习率自更新的方式,甚至可以细化到逐参数更新。现在确实有一些这种方法,其中大多数还需要额外的超参数设定,优势是在大多数超参数设定下,效果都比使用写死的学习率要好。在本小节我们会介绍一些在实践中可能会遇到的常用适应算法:
Adagrad是一个由Duchi等提出的适应性学习率算法
# 假设有梯度和参数向量x
cache += dx**2
x += - learning_rate * dx / (np.sqrt(cache) + eps)
注意,变量cache的尺寸和梯度矩阵的尺寸是一样的,还跟踪了每个参数的梯度的平方和。这个一会儿将用来归一化参数更新步长,归一化是逐元素进行的。注意,接收到高梯度值的权重更新的效果被减弱,而接收到低梯度值的权重的更新效果将会增强。有趣的是平方根的操作非常重要,如果去掉,算法的表现将会糟糕很多。用于平滑的式子eps(一般设为1e-4到1e-8之间)是防止出现除以0的情况。Adagrad的一个缺点是,在深度学习中单调的学习率被证明通常过于激进且过早停止学习。
RMSprop
是一个非常高效,但没有公开发表的适应性学习率方法。有趣的是,每个使用这个方法的人在他们的论文中都引用自Geoff Hinton的Coursera课程的第六课的第29页PPT。这个方法用一种很简单的方式修改了Adagrad方法,让它不那么激进,单调地降低了学习率。具体说来,就是它使用了一个梯度平方的滑动平均:
cache = decay_rate * cache + (1 - decay_rate) * dx**2
x += - learning_rate * dx / (np.sqrt(cache) + eps)
在上面的代码中,decay_rate是一个超参数,常用的值是[0.9,0.99,0.999]。其中x+=和Adagrad中是一样的,但是cache变量是不同的。因此,RMSProp仍然是基于梯度的大小来对每个权重的学习率进行修改,这同样效果不错。但是和Adagrad不同,其更新不会让学习率单调变小。
Adam
Adam是最近才提出的一种更新方法,它看起来像是RMSProp的动量版。简化的代码是下面这样:
m = beta1*m + (1-beta1)*dx
v = beta2*v + (1-beta2)*(dx**2)
x += - learning_rate * m / (np.sqrt(v) + eps)
注意这个更新方法看起来真的和RMSProp很像,除了使用的是平滑版的梯度m,而不是用的原始梯度向量dx。论文中推荐的参数值eps=1e-8, beta1=0.9, beta2=0.999。在实际操作中,我们推荐Adam作为默认的算法,一般而言跑起来比RMSProp要好一点。但是也可以试试SGD+Nesterov动量。完整的Adam更新算法也包含了一个偏置(bias)矫正机制,因为m,v两个矩阵初始为0,在没有完全热身之前存在偏差,需要采取一些补偿措施。建议读者可以阅读论文查看细节,或者课程的PPT。
下图是上述提到的多种参数更新方法下,损失函数最优化的示意图:
超参数调优
我们已经看到,训练一个神经网络会遇到很多超参数设置。神经网络最常用的设置有:
- 初始学习率。
- 学习率衰减方式(例如一个衰减常量)。
- 正则化强度(L2惩罚,随机失活强度)。
但是也可以看到,还有很多相对不那么敏感的超参数。比如在逐参数适应学习方法中,对于动量及其时间表的设置等。在本节中将介绍一些额外的调参要点和技巧:
对于大的深层次神经网络而言,我们需要很多的时间去训练。因此在此之前我们花一些时间去做超参数搜索,以确定最佳设定是非常有必要的。最直接的方式就是在框架实现的过程中,设计一个会持续变换超参数实施优化,并记录每个超参数下每一轮完整训练迭代下的验证集状态和效果。实际工程中,神经网络里确定这些超参数,我们一般很少使用n折交叉验证,一般使用一份固定的交叉验证集就可以了。
超参数范围
一般对超参数的尝试和搜索都是在log域进行的。例如,一个典型的学习率搜索序列就是learning_rate = 10 ** uniform(-6, 1)。我们先生成均匀分布的序列,再以10为底做指数运算,其实我们在正则化系数中也做了一样的策略。比如常见的搜索序列为[0.5, 0.9, 0.95, 0.99]。另外还得注意一点,如果交叉验证取得的最佳超参数结果在分布边缘,要特别注意,也许取的均匀分布范围本身就是不合理的,也许扩充一下这个搜索范围会有更好的参数。
模型融合与优化
实际工程中,一个能有效提高最后神经网络效果的方式是,训练出多个独立的模型,在预测阶段选结果中的众数。模型融合能在一定程度上缓解过拟合的现象,对最后的结果有一定帮助,我们有一些方式可以得到同一个问题的不同独立模型:
- 使用不同的初始化参数。先用交叉验证确定最佳的超参数,然后选取不同的初始值进行训练,结果模型能有一定程度的差别。
- 在交叉验证中发现最好的模型。使用交叉验证来得到最好的超参数,然后取其中最好的几个(比如10个)模型来进行集成。这样就提高了集成的多样性,但风险在于可能会包含不够理想的模型。在实际操作中,这样操作起来比较简单,在交叉验证后就不需要额外的训练了。
- 一个模型设置多个记录点。如果训练非常耗时,那就在不同的训练时间对网络留下记录点(比如每个周期结束),然后用它们来进行模型集成。很显然,这样做多样性不足,但是在实践中效果还是不错的,这种方法的优势是代价比较小。
还有一种常用的有效改善模型效果的方式是,对于训练后期,保留几份中间模型权重和最后的模型权重,对它们求一个平均,再在交叉验证集上测试结果。通常都会比直接训练的模型结果高出一两个百分点。直观的理解是,对于碗状的结构,有很多时候我们的权重都是在最低点附近跳来跳去,而没法真正到达最低点,而两个最低点附近的位置求平均,会有更高的概率落在离最低点更近的位置。
总结
训练一个神经网络需要:
利用小批量数据对实现进行梯度检查,还要注意各种错误。
进行合理性检查,确认初始损失值是合理的,在小数据集上能得到100%的准确率。
在训练时,跟踪损失函数值,训练集和验证集准确率,如果愿意,还可以跟踪更新的参数量相对于总参数量的比例(一般在1e-3左右),然后如果是对于卷积神经网络,可以将第一层的权重可视化。
推荐的两个更新方法是SGD+Nesterov动量方法,或者Adam方法。
随着训练进行学习率衰减。比如,在固定多少个周期后让学习率减半,或者当验证集准确率下降的时候。
使用随机搜索(不要用网格搜索)来搜索最优的超参数。分阶段从粗(比较宽的超参数范围训练1-5个周期)到细(窄范围训练很多个周期)地来搜索。
进行模型集成来获得额外的性能提高。