阅读书籍为《Hands-On Machine Learning with Scikit-Learn & TensorFlow》王静源等翻译的中文译版《机器学习实战,基于 Scikit-Learn 和 TensorFlow》,本文中所有图片均来自于书籍相关部分截图。
本章介绍了DNN训练过程中三个常见问题,并依次给出解决方案。
章节的最后还给出当不知道如何DNN训练时一些属性可以选的比较好的默认值。
问题1:梯度爆炸/梯度消失
1.梯度消失及解决办法
上一章我们了解到反向传播算法是从输出层反向作用到输入层,在过程中传播误差梯度。一旦算法根据网络的参数计算出成本函数的梯度,就会根据梯度下降步骤利用这些梯度来修正每一个参数。
梯度消失问题:
很多时候,梯度经常会随着算法进展到更低层时变得越来越小。导致梯度下降在更低层网络连接权值更新方面基本没有改变,而且训练不会收敛到好的结果。
解决办法有2:
2010年左右《理解深度前馈神经网络的训练难度》发现利用均值为0, 方差为1的正态分布函数进行随机初始化会导致每一层的输出方差都大于输入方差很多。在网络中代表着越往上层输出方差越大,激活函数越趋于饱和。
如下图,当输入变大(正向变大或负向变大时),函数在0或1饱和,导数无限靠近0。所以当反向传播发生时,各层的梯度甚至不会被稀释。
-
更换激活函数
针对这个问题《理解深度前馈神经网络的训练难度》的作者提出了一种缓和办法:保持每层输入和输出的方差一致,并且在需要反向流动过某一层时,前后层的方差也保持一致。这种办法要求一层又同样数量的输入和输出连接。但是在实际的训练中这种要求很难实现,所以论文的作者又提出了一种折中的方案:连接权重使用Xavier(使用作者的名字命名,有时也叫Glorot初始化)进行随机初始化。
Xavier初始化:其中N inputs/outputs表示每层输入和输出的连接数
均值为0和标准差如下的正态分布:
或这在-r和+r之间的标准分布:
除了这个方法外,还有一些论文提供了类似的方法,如ReLU激活函数及其变种对比如下表:
但此处ReLU仍存在一个严重的问题是训练过程中会有一些神经元死去,只输出0,(当一个神经元的输入的总权重是负值时,这个神经元就会一致输出0,除非ReLU函数的梯度为0且输入为负,否则这个神经元就一直处于死亡状态。为尽量避免神经元死亡,需要使用:
leaky ReLU(带泄露线性整流函数):
LeakyReLUα(z)=max(αz,z),其中超参数α表示函数的“泄露”程度:代表z<0时的坡度一般取0.01,这个小坡度可以保证函数不死。一般来说,大泄露0.02好于小泄露0.01。
还有一种新的激活函数ELU(加速线性单元):
最早出现由Djork-ArnéClevert在2015年提出,经测试,性能优于所有ReLU的所有变种:时间短,在测试集的表现也更好。
虽然看起来与ReLU函数看起来很像,但有如下不同:
1.z<0时值为负,从而允许单元的平均输入接近0.缓和了梯度消失的问题。
2.z<0时梯度非零,避免单元消失。
3.ELU函数整体相而言更为平滑,可以更快的梯度下降。一般来说:
ELU函数>leaky ReLU函数(和它的变种)>ReLU函数>tanh函数>逻辑函数。
如果想要快一点更建议使用leaky ReLU函数(TF不提供)。
代码如下:hidden1 = fully_connected(X, n_hidden1, scope="hidden1", activation_fn=tf.nn.elu)
TF不提供带泄露线性整流函数,一个简单的实现如下:
def leaky_relu(z, name=None): return tf.maximum(0.01 * z, z, name=name) hidden1 = fully_connected(X, n_hidden1, activation_fn=leaky_relu)
-
批量归一化BN
该技术会在每一层激活函数之前在模型中介入一个操作,操作实现简单零中心化和归一化输入,之后再通过每层的两个新参数(一个缩放,一个移动)来控制缩放和移动的结果。这样的操作会让模型学会最佳规模和每层输入的平均值。
算法分如下四步实现:
1.计算经验平均值评估整个小批量B:
2.计算经验标准方差,评估真个小批量B,其中m是小批量B中的实例个数:
3.零中心化和归一化输入,根号下加的E是一个很小的数字,作为平滑项:
4.批量归一化输出结果,γ和β分别是缩放参数和层移动参数:
再使用饱和激活函数的深度神经网络中,批量归一化取得了非常好的成绩,而且还会为降低后续的正则化的技术需求。
但BN的使用确实也增加了模型的复杂度,降低了网络额速度。
使用TF实现一波:with tf.name_scope("dnn"): hidden1 = fully_connected(X, n_hidden1, scope="hidden1", normalizer_fn=batch_norm, normalizer_params=bn_params) hidden2 = fully_connected(hidden1, n_hidden2, scope="hidden2",normalizer_fn=batch_norm,normalizer_params=bn_params) logits = fully_connected(hidden2, n_outputs, scope="outputs", activation_fn=tf.nn.elu, normalizer_fn=batch_norm, normalizer_params=bn_params)
2.梯度爆炸及解决办法
梯度爆炸问题:
有时候则发生相反的现象:梯度会越来越大,导致很多层的权值疯狂增大,使得算法发散无法收敛。
解决办法有2:
-
批量归一化(参考梯度消失中的BN处理)
-
梯度剪裁
这种技术的思想就是名字的表面意思,在RNN的训练中十分有效,TF实现如下:threshold = 1.0 optimizer = tf.train.GradientDescentOptimizer(learning_rate) grads_and_vars = optimizer.compute_gradients(loss) capped_gvs = [(tf.clip_by_value(grad, -threshold, threshold), var) for grad, var in grads_and_vars] training_op = optimizer.apply_gradients(capped_gvs)
问题2:速度慢
1.重用预训练图层
借助迁移学习的思想:把可以用的先拿过来用剩下实在不行的再重新编写,训练。
如何加载你想要重用的模型:
- 引入TF训练的模型
//1,引入整个模型 [...] # construct the original model with tf.Session() as sess: saver.restore(sess, "./my_original_model.ckpt") [...] # Train it on your new task //2.选择要重用的隐层123进行还原 [...] # build new model with the same definition as before for hidden layers 1- 3 init = tf.global_variables_initializer() reuse_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, scope="hidden[123]") reuse_vars_dict = dict([(var.name, var.name) for var in reuse_vars]) original_saver = tf.Saver(reuse_vars_dict) # saver to restore the original model new_saver = tf.Saver() # saver to save the new model with tf.Session() as sess: sess.run(init) original_saver.restore("./my_original_model.ckpt") # restore layers 1 to 3 [...] # train the new model new_saver.save("./my_new_model.ckpt") # save the whole model
- 引入其他模型
一下代码展示了如何从其他框架训练的模型来复制第一个隐藏层的权重和偏移量。original_w = [...] # Load the weights from the other framework original_b = [...] # Load the biases from the other framework X = tf.placeholder(tf.float32, shape=(None, n_inputs), name="X") hidden1 = fully_connected(X, n_hidden1, scope="hidden1") [...] # # Build the rest of the model // Get a handle on the variables created by fully_connected() with tf.variable_scope("", default_name="", reuse=True): # root scope hidden1_weights = tf.get_variable("hidden1/weights") hidden1_biases = tf.get_variable("hidden1/biases") // Create nodes to assign arbitrary values to the weights and biases original_weights = tf.placeholder(tf.float32, shape=(n_inputs, n_hidden1)) original_biases = tf.placeholder(tf.float32, shape=(n_hidden1)) assign_hidden1_weights = tf.assign(hidden1_weights, original_weights) assign_hidden1_biases = tf.assign(hidden1_biases, original_biases) init = tf.global_variables_initializer() with tf.Session() as sess: sess.run(init) sess.run(assign_hidden1_weights, feed_dict={original_weights: original_w}) sess.run(assign_hidden1_biases, feed_dict={original_biases: original_b}) [...] # Train the model on your new task
模型重用的具体方法:
- 冻结低层
在任务A中,低层的网络层可能已经学会了任务中的一些基本功能,这样的学习对任务B也是有用的。所以我们可以把这些低层直接拿来重用。
如何冻结低层: 给优化器列出要训练的变量列表(不包含低层的变量),等于后面的操作不能影响任务A中低层的训练结果,所谓“冻结”。train_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, scope="hidden[4]|outputs") //获取隐层4和输出层的可训练变量 training_op = optimizer.minimize(loss, var_list=train_vars) //然后将获得的可训练变量拿去训练,现在层123就等于是冻结层,训练的过程中不会变化。
- 缓存冻结层
因为冻结曾不会因为训练而产生变化,所以我们在每一个实例训练中只需要将最高冻结层的输出缓存下来,就可以避免每次被访问时都要重新算一遍的时间消耗。具体操作如下://如果有足够的空间,可以一次性让冻结层把训练集跑完 hidden3_outputs = sess.run(hidden2, feed_dict={X: X_train}) //然后批量构建隐藏层3的输出,将他们喂给剩下的训练操作: import numpy as np n_epochs = 100 n_batches = 500 for epoch in range(n_epochs): shuffled_idx = rnd.permutation(len(hidden2_outputs)) hidden2_batches = np.array_split(hidden2_outputs[shuffled_idx], n_batches) y_batches = np.array_split(y_train[shuffled_idx], n_batches) for hidden2_batch, y_batch in zip(hidden2_batches, y_batches): sess.run(training_op, feed_dict={hidden2: hidden2_batch, y: y_batch})
- 调整丢弃或替换原始模型中的高层
原始模型中的高层一般在新的任务中无用,所以在适应新任务时要对其调整,替换或丢弃。关于选择什么样的操作,需要视具体新任务的需求而定,选择添加隐层,更换输出层之类的操作。
2.五种快速优化器
-
Momentum,与传统梯度下降一步一步走下去(权重=权重-η*梯度)相比,Momentum更像是一个保龄球从斜坡上滑下去,最后越来越快。
第一步:Momentum更关心以前的梯度(梯度越大往下滚的速度越大),所以每一次迭代都要给当前的矢量m加上本地梯度。
第二步:权重减去矢量。
形象一点来看,梯度被当成可以让球滚动的加速的来看。β的引入模拟坡上的石头或沙土,增大摩擦力避免球滚得太快。optimizer = tf.train.MomentumOptimizer(learning_rate=learning_rate, momentum=0.9)
-
Nesterov(NAG),Momentum的优化版本,用来衡量成本函数的梯度。
比起他的本体关注当前的步伐, NAG往前多看了一步。optimizer = tf.train.MomentumOptimizer(learning_rate=learning_rate, momentum=0.9, use_nesterov=True)
NAG和Momentum的优化策略比较:
-
AdaGrad,适用于简单二次问题,沿着最陡尺寸缩小梯度向量
第一步:将梯度的评奖累计到向量S中。哪个i尺寸陡峭,Si就会越来越大。
第二步:下降 -
RMSProp第二好的优化器, AdaGrad可能会因为下降太快而没办法收敛,当前方法只积累迭代梯度而不从最陡下降从而避免无法收敛的问题。
第一步:积累最近迭代中的梯度。 这里衰减率β一般为0.9,基本不用调整。
第二步:下降
TF提供这种优化器:optimizer = tf.train.RMSPropOptimizer(learning_rate=learning_rate, momentum=0.9, decay=0.9, epsilon=1e-10)
-
集众家之所长的Adam是最好的优化器
Adam,代表了自适应力矩估计,集合了Momentum优化和RMSProp的想法:类似于Momentum优化,会跟踪过去梯度的指数衰减平均值,也类似于RMSProp,跟踪过去梯度平方的指数衰减平均值。算法如下:β1为动力衰减超参数(还记得我们在Momentum优化提到保龄球下坡的时候坡上的沙土和小石头会带来阻力。)在这里一般初始化为0.9。
β2为衰减缩放超参数,一般初始化为0.999。
T表示迭代次数,从1开始。
E是一个极小的数,表示平滑项。
这些默认值可以通过如下语句一把获得:optimizer = tf.train.AdamOptimizer(learning_rate=learning_rate)
Adam是一个自适应学习速率算法,他会对学习速率超参数η进行微调,所以我们可以一直使用η的默认值0.001,这样会让Adam更容易些。
学习计划帮助你更快走上人生巅峰(针对Nesterov(NAG),Momentum两个优化器):
学习计划:在很多不同的策略在训练过程中监督学习速率η。最常用的学习计划有:
1. 预定分段常数学习速率
最开始设置学习速率为0.1, 50个数据集后学习速率设置为0.01,一次类推,直到你完美完成训练。
2. 性能调度
每N个步骤进行测量来验证错误(比如提前停止),当错误停止出现时将学习速率降低。
3. 指数调度
将学习速率设置为迭代次数t的函数: ,效果不错,但是需要微调两个参数η0和r,学习速率每r步下降10。
4. 功率调度
与指数调度类似,但函数不同: 这个学习速率就下降的比较慢。
问题3:DNN参数过多易导致过拟合
在本书刚开始的章节中作者提到,过拟合的发生原因有二:模型太简单或数据量太少。
本章从发生过拟合前停止模型训练,提高模型复杂度和增加数据量这三个思路提出解决方法。
-
提前停止
当验证集的性能开始下降的时候停止训练(这块就是 提前,停止,哈哈哈。)
使用TF的实现思路是:定期对验证集进行模型评估(比如每50或者多少步),同时当前的模型性能如果表现好于前一个,就将当前这个比较好的保存起来。以此类推,当步数到达一个阈值边界(自己指定,比如2000步)时停止训练,然后恢复记录中哪个一个比较好的模型。 -
L1,L2正则化
和简单线性模型类似,我们可以通过L1,L2正则化来约束神经网络的连接权重(不约束偏差,这点要分清楚,我们要约束的是那些神经元的连接权重)。
TF实现只有一个隐藏层的L1,L2正则化(加适当的正则项在成本函数中):[...] # construct the neural network base_loss = tf.reduce_mean(xentropy, name="avg_xentropy") reg_losses = tf.reduce_sum(tf.abs(weights1)) + tf.reduce_sum(tf.abs(weights2)) loss = tf.add(base_loss, scale * reg_losses, name="loss")
TF实现多个隐藏层的L1,L2正则化:(把以权重为参数且返回正则化损失的函数当成参数传递到层间的连接中,如下:)
with arg_scope( [fully_connected], weights_regularizer=tf.contrib.layers.l1_regularizer(scale=0.01)): hidden1 = fully_connected(X, n_hidden1, scope="hidden1") hidden2 = fully_connected(hidden1, n_hidden2, scope="hidden2") logits = fully_connected(hidden2, n_outputs, activation_fn=None,scope="out")
这个代码构建了一个有两层隐藏层和一个输出层的神经网络,同时在图中构建结点来计算对应每一层权重的正则化损失L1,TF会自动将这些结点加到一个包含所有正则化损失的特殊连接中,然后还需要我们将这些正则化损失加到整体的损失中即可,类似于如下:
reg_losses = tf.get_collection(tf.GraphKeys.REGULARIZATION_LOSSES) loss = tf.add_n([base_loss] + reg_losses, name="loss")
-
最受欢迎的dropout
原理如下:每一个训练步骤中,每个神经元都有一个是否会被暂时“drop”的可能性P称为当前神经元的丢弃率。一般以0.5为阈值,P>0.5表示这次的训练中这个神经元会被忽略,但下一次就会被激活。虽然叫丢弃,但神经元并不会真正的被删除。
TF实现2个隐藏层的dropout正则化:from tensorflow.contrib.layers import dropout [...] is_training = tf.placeholder(tf.bool, shape=(), name='is_training') keep_prob = 0.5 X_drop = dropout(X, keep_prob, is_training=is_training) hidden1 = fully_connected(X_drop, n_hidden1, scope="hidden1") hidden1_drop = dropout(hidden1, keep_prob, is_training=is_training) hidden2 = fully_connected(hidden1_drop, n_hidden2, scope="hidden2") hidden2_drop = dropout(hidden2, keep_prob, is_training=is_training) logits = fully_connected(hidden2_drop, n_outputs, activation_fn=None,cope="outputs")
与之前的批量归一化类似,训练的时候需要将is_training设置为True,测试时设为False。
过拟合严重时提高dropout速率。反之,欠拟合时降低。
网络层数多时,可适当提高速率。反之,适当降低速率。 -
最大范数正则化(这部分详细的我并没有看懂,学到最后筋疲力尽,哎。)
除了Dropout之外,第二比价流行的正则化方法就是最大范数正则化:对每一个神经元中包含一个传入连接权重W,要求:
其中r是最大范数超参数,||.||2(这个2是下标)是L2范数。为了满足约束,我们一般这样来更新W:
过拟合时,增大r。最大范数不仅可以处理过拟合问题,还可以同时帮助缓解梯度爆炸和消失的问题。 -
数据扩充
扩充的数据应该是可以用来学习的好的纯净的数据。
大部分情况下你可以从实际环境中再收集一些数据来扩充数据集。不过这里的扩充一般偏向于再训练过程中快速的生成训练实例。TF在这里为我们提供了专制,旋转,调整大小,反转和剪裁等等多种操作,帮助用户在训练过程中快速扩充训练数据集。(具体怎么加大家可以去查官方文档)
当我迷茫不知所措,哈哈哈
当你不知道要选择什么算法来设置你的网络时可以参考如下表格: