我们在设计、训练Tensorflow神经网络时,无论是简易的BP神经网络,还是复杂的卷积神经网络、循环神经网络,都要面临梯度爆炸、梯度消失,以及数据越界等一系列问题,这也是计算机资源和数学原理所决定。
通常,我们在模型训练过程中,特别是非图像识别模型中,经常会出现Loss(损失)与gradients(梯度)的Nan情况,接下来我们一起讨论此实践所遇到的情况,以及解决方案
1. 现象
Tensorflow模型训练过程中,很多情况下,在训练一段时间后,输出的loss为nan,幸运的可能是一闪而过,接着正常训练,多数是持续nan下去,训练失败。
如果你也输出了gradient(梯度),也可能gradient先输出为nan。
2. 原因
2.1. 简明通俗原理
通俗来讲,神经网络最基本节点的神经单元,数学表示为,一般通过反向求导模式追踪每一个节点如何影响一个输出。这是以误差为主导的反向传播(Back Propagation),旨在得到最优的全局参数矩阵(权重参数)。
当神经网络的层数较多、神经元节点较多时,模型的数值稳定性容易变差。假设一个层数为的网络,第层的权重参数为,为了便于讨论,略去偏置参数。对于给定输入的网络,第层的输出。此时,很容易出现衰减或爆炸。
例如第30层的输出将出现,衰减掉了。
当我们训练神经网络时,把“损失“ 看作 ”权重参数“ 的函数。因此,出现nan现象就是越界造成的。
那么,我们要做的就是如何防止出现越界。
反向传播是以目标的负梯度方向对参数进行调整,参数的更新为:。
给定学习率,得出:
由于深度网络是多层非线性函数的堆砌,整个深度网络可以视为是一个复合的非线性多元函数(这些非线性多元函数其实就是每层的激活函数),那么对loss函数求不同层的权值偏导,相当于应用梯度下降的链式法则,链式法则是一个连乘的形式,所以当层数越深的时候,梯度将以指数传播。
如果接近输出层的激活函数求导后梯度值大于1,那么层数增多的时候,最终求出的梯度很容易指数级增长,也就是梯度爆炸;相反,如果小于1,那么经过链式法则的连乘形式,也会很容易衰减至0,也就是梯度消失。
2.2. 原因
神经网络训练过程中所有nan的原因:一般是正向计算时节点数值越界,或反向传播时gradient数值越界。无论正反向,数值越界基本只有三种操作会导致:
- 节点权重参数或梯度的数值逐渐变大直至越界;
- 有除零操作,包括0除0,这种比较常见于趋势回归预测;或者,交叉熵对0或负数取log;
- 输入数据存在异常,过大/过小的输入,导致瞬间NAN。
3. 解决方案
3.1. 减小学习率
减少学习率(learning_rate)是最直接的、简易的方法。
学习率较高的情况下,直接影响到每次更新值的程度比较大,走的步伐因此也会大起来。通常过大的学习率会导致无法顺利地到达最低点,稍有不慎就会跳出可控制区域,此时我们将要面对的就是损失成倍增大(跨量级)。
训练过程中,我们可以尝试把学习率减少10倍、100倍,乃至更多,该类问题绝大部分可以得到解决(注意学习率也别太小,否则收敛过慢浪费时间,要在速度和稳定性之间平衡)
3.2. 权重参数初始化
神经网络的训练过程,实质就是自动调整网络中参数的过程。在训练的起初,网络的参数总要从某一状态开始,而这个初始状态的设定,就是神经网络的初始化。
合适网络初始值,不仅有助于梯度下降法在一个好的“起点”上去寻找最优值。
通常,我们常见的初始化是随机正态分布初始化,权重和偏置的初始化采用的是符合均值为0、标准差为1的标准正态分布(Standard Noraml Distribution)随机化方法。但它是最佳的初始化策略吗?
#标准差为0.1的正态分布
def weight_variable(shape,name=None):
initial = tf.truncated_normal(shape,stddev=0.1)
return tf.Variable(initial,name=name)
#0.1的偏差常数,为了避免死亡节点
def bias_variable(shape,name=None):
initial = tf.constant(0.1, shape=shape)
return tf.Variable(initial,name=name)
复制代码
如果使用relu,则最好使用方差缩放初始化(he initial)。
在 TensorFlow 中,方差缩放方法写作 tf.contrib.layers.variance_scaling_initializer()。根据我们的实验,这种初始化方法比常规高斯分布初始化、截断高斯分布初始化及 Xavier 初始化的泛化/缩放性能更好。
通俗的讲,方差缩放初始化根据每一层输入或输出的数量(在 TensorFlow 中默认为输入的数量)来调整初始随机权重的方差,从而帮助信号在不需要其他技巧(如梯度裁剪或批归一化)的情况下在网络中更深入地传播。Xavier 和方差缩放初始化类似,只不过 Xavier 中每一层的方差几乎是相同的;但是如果网络的各层之间规模差别很大(常见于卷积神经网络),则这些网络可能并不能很好地处理每一层中相同的方差。
#def weight_variable(shape,name=None):
# return tf.get_variable(name, shape ,tf.float32 ,xavier_initializer())
def weight_variable(shape,name=None):
return tf.get_variable(name, shape ,tf.float32 ,tf.variance_scaling_initializer())
复制代码
tf.variance_scaling_initializer()
参数为(scale=1.0,mode=”fan_in”,distribution=”normal”,seed=None,dtype=dtypes.float32)
其训练效果有所提升:
step: 11800, total_loss: 161.1809539794922,accuracy: 0.78125
step: 11850, total_loss: 46.051700592041016,accuracy: 0.9375
step: 11900, total_loss: 92.10340118408203,accuracy: 0.875
step: 11950, total_loss: 23.025850296020508,accuracy: 0.96875
复制代码
如果激活函数使用sigmoid和tanh,最好使用xavir。
xavier_initializer()法:这个初始化器是用来保持每一层的梯度大小都差不多相同
常用初始化方法:
- tf.constant_initializer() 常数初始化
- tf.ones_initializer() 全1初始化
- tf.zeros_initializer() 全0初始化
- tf.random_uniform_initializer() 均匀分布初始化
- tf.random_normal_initializer() 正态分布初始化
- tf.truncated_normal_initializer() 截断正态分布初始化
- tf.uniform_unit_scaling_initializer() 这种方法输入方差是常数
- tf.variance_scaling_initializer() 自适应初始化
- tf.orthogonal_initializer() 生成正交矩阵
有时候,为了确保w初始值足够小,常用初始化|w|远远小于1,一般推荐使用 tf.truncated_normal_initializer(stddev=0.02)
3.3. 预测结果裁剪
交叉熵Loss计算中出现Nan值,我们看交叉熵:
虽然是经过tf.nn.sigmoid函数得到的,但在输出的参数非常大,或者非常小的情况下,会给出边界值1或者0。
所以,对如下代码:
cross_entropy = -tf.reduce_sum(y_*tf.log(y_conv), name=’cost_func’)
修改为:
cross_entropy = -tf.reduce_sum(y_*tf.log(tf.clip_by_value(y_conv, 1e-10, 1.0)), name=’cost_func’) # 最大值是1,归一化后0~1
#第二全连接层,即输出层,使用柔性最大值函数softmax作为激活函数
y_conv = tf.nn.softmax(tf.matmul(h_fc2_drop,W_fc3) + b_fc3, name='y_')
# 使用TensorFlow内置的交叉熵函数避免出现权重消失问题
cross_entropy = -tf.reduce_sum(y_*tf.log(tf.clip_by_value(y_conv, 1e-10, 1.0)), name='cost_func') # 最大值是1,归一化后0~1
复制代码
3.4. 梯度修剪
对于在tensorflow中解决梯度爆炸问题,原理很简单就是梯度修剪,把大于1的导数修剪为1。
Tensorflow梯度修剪函数为tf.clip_by_value(A, min, max):
输入一个张量A,把A中的每一个元素的值都压缩在min和max之间。小于min的让它等于min,大于max的元素的值等于max。
#使用优化器
lr = tf.Variable(learning_rate,dtype=tf.float32)
#train_step = tf.train.AdamOptimizer(lr).minimize(cross_entropy)
optimizer = tf.train.AdamOptimizer(lr)
gradients = optimizer.compute_gradients(cross_entropy)
#梯度优化裁剪
capped_gradients = [(tf.clip_by_value(grad, -0.5, 0.5), var) for grad, var in gradients if grad is not None]
train_step = optimizer.apply_gradients(capped_gradients)
复制代码
另外,网络推荐clip_by_global_norm:
tf.clip_by_global_norm 重新缩放张量列表,以便所有范数的向量的总范数不超过阈值。但是它可以一次作用于所有梯度,而不是分别作用于每个梯度(即,如果有必要,将全部按相同的因子进行缩放,或者都不进行缩放)。这样会更好,因为可以保持不同梯度之间的平衡。
3.5. 其他
- 缩小Bathsize
通过缩小batch_size,相当于缩小输入,达到缩减权重集合目标,有一定效果,但是训练速度可能减慢。
- 输入归一化
对于输入数据,图片类比较好解决,把0—255归一化到0~1,而其他数据,需要根据数据分布分析处理,采用合适的归一化方案。
- 调整激活函数
神经网络,现在经常使用relu做为激活函数,但是容易出现Nan的问题,可以根据实际情况换激活函数。
- batch normal
BN具有提高网络泛化能力的特性。采用BN算法后,就可以移除针对过拟合问题而设置的dropout和L2正则化项,或者采用更小的L2正则化参数。
4. 小结
Tensorflow神经网络训练比较容易出现Nan等问题,通过实践分析,在实际应用中,需要采用综合解决方案,采用上面提到的所有方法,综合解决。
万事开头难,首先从设计的学习率、初始权重开始、激活函数等方面入手优化,尽量减少中间干预修剪。
即使这样,也会出现Nan等问题,怎么办?
中断训练,重新开始新的一轮训练。
由于笔者水平有限,欢迎交流反馈。
参考:
1.《Tensorflow LSTM选择Relu激活函数与权重初始化、梯度修剪解决梯度爆炸问题实践》 掘金, 肖永威 ,2021年3月
2.《梯度消失和梯度爆炸的数学原理及解决方案》 CSDN博客 , ty44111144ty ,2019.08
3.《关于 tf.nn.softmax_cross_entropy_with_logits 及 tf.clip_by_value》 博客园 ,微信公众号–共鸣圈 ,2017.03
4.《tensorflow NAN常见原因和解决方法》 CSDN博客 ,苏冉旭 ,2019年2月
5.《一文搞懂反向传播算法》 简书,张小磊啊,2017年8月
6.《TensorFlow从0到1 – 15 – 重新思考神经网络初始化》 知乎 ,袁承兴 ,2020年7月
7.《深入认识深度神经网络》 CSDN博客 ,肖永威 ,2020年6月
8.《TensorFlow实现Batch Normalization》 脚本之家,marsjhao ,2018年3月