1、神经网络优化算法
梯度下降算法主要用户优化单个参数的取值,而反向传播算法给出了一个高效的方式在所有参数上使用梯度下降算法,从而使神经网络模型在训练数据上的损失函数尽可能小。反向传播算法是训练神经网络的核心算法,它可以根据定义好的损失函数优化神经网络中参数的取值,从而使神经网络在训练数据集上的损失函数达到一个最小值。神经网络模型中参数的优化过程直接决定了模型的质量,是使用神经网络时非常重要的一步。
假设用表示神经网络中的参数,表示在给定参数取值下,训练数据集上损失函数的大小,那么整个优化过程可以抽象为寻找一个参数,使得最小。因为没有一个通用方法可以对任意损失函数直接求解最佳的参数取值,所以在实践中,梯度下降算法是最常用的神经网络算法是最常用的神经网络优化方法。梯度下降算法会迭代更新参数,不断沿着梯度的反方向让参数朝着总损失更小的方向更新,下图展示了梯度下降算法的原理。
横轴表示参数的取值,纵轴表示损失函数的值,上图表示了在参数去不同值时,对应损失函数的大小。假设当前的参数和损失值对应上图箭头和曲线的交点,那么梯度下降算法会将参数向x轴左侧移动,从而使得损失值朝着箭头向方向移动。参数的梯度可以通过求偏导的方式计算,对于参数,其梯度为。有了梯度,还需定义一个学习率(learning rate)来定义每次参数更新的幅度。从直观上理解,可以认为学习率定义的就是每次参数移动的幅度。通过参数的梯度和学习率,参数更新公式为:
下面给出了一个具体的例子来说明梯度下降算法是如何工作的。假设要通过梯度下降算法来优化参数x,使得损失函数的值尽量小。梯度下降算法的第一步需要随机产生一个参数x的初始值,然后再通过梯度和学习率来更新参数x的取值。在这个样例中,参数x的梯度为,那么使用梯度下降算法每次对参数x的更新公式为。假设参数的初始值为5,学习率为0.3,那么这个优化过程可以总结为下表,
轮数 | 当前轮数值 | 梯度学习率 | 更新后参数值 |
1 | 5 | 250.3=3 | 5-3=2 |
2 | 2 | 220.3=1.2 | 2-1.2=0.8 |
3 | 0.8 | 20.80.3=0.48 | 0.8-0.48=0.32 |
4 | 0.32 | 20.320.3=0.192 | 0.32-0.192=0.128 |
5 | 0.128 | 20.1280.3=0.0768 | 0.128-0.0768=0.0512 |
从上表中可以看出,经过5此迭代之后,参数x的指标成了0.0512,这个和参数是最优值0已经比较接近了。虽然这里给出的是一个非常简单的样例,但是神经网络的优化过程也可以类推的。神经网络的优化过程可以分为两个阶段:
- 第一个阶段先通过前向传播算法计算得到预测值,并将预测值和真实值做对比得出两者之间的差距
- 第二个阶段通过反向传播算法计算损失函数对每一个参数的梯度,再根据梯度和学习率使用梯度下降算法更新每一个参数
David Rumelhart、 Geoffrey Hinton和Ronald Williams教授发表的论文:Learning representations by back-propagating errors
梯度下降算法并不能保证被优化的函数达到全局最优解。如下图所示,图中给出的函数就有可能值能得到局部最优解而不是全局最优解。在圆球处,损失函数的偏导为0,于是参数就不会再进一步更新。在这个样例中,如果参数x的初始值落在右侧区间中,那么通过梯度下降得到的结果都会落在小球代表的局部最优解。只有当x的初始值落在左侧取件时梯度下降才能给出全局最优答案。由此可见在训练神经网络时,参数的初始值会很大程度影响最后得到的结果。只有当损失函数为凸函数时,梯度下降算法才能保证达到全局最优解。
除了不一定能达到全局最优,梯度下降算法的另一个问题就是计算时间太长。因为要在全部训练数据上最小化损失,所以损失函数是在所有训练数据上的损失和。这样在每轮迭代中都需要计算在全部训练数据上的损失函数。在海量的训练数据下,要计算所有训练数据的损失函数是非常耗时的。为了加速训练过程,可以使用随机梯度下降算法(stochastic gradient descent)。这个算法优化的不是全部训练数据上的损失函数,而是在每一轮迭代中,随机优化某一条训练数据上的损失函数。这样每一轮参数更新的速度就大大加快了。因为随机梯度下降算法每次优化的只是某一条数据上的损失函数,所以它的问题也非常明显:在某一条数据上损失函数更小并不代表在全部数据上损失函数更小,于是使用随机梯度下降优化得到的神经网络甚至可能无法达到局部最优。
为了综合梯度下降算法和随机梯度下降算法的优缺点,在实际应用中一般采用这两种算法的折中-------每次计算一小部分训练数据的损失函数。这一小部分数据被称之为一个batch。通过矩阵运算,每次在一个batch上优化神经网络的参数并不会比单个数据慢太多。另一方面,每次使用一个batch可以大大减少收敛所需要的迭代次数,同时可以使收敛到的结果更加接近梯度下降的效果。以下代码给出了tensorflow中如何实现神经网络的训练过程。
batch_size = n
# 每次读取读取一小部分数据作为当前的训练数据来执行反向传播算法
x = tf.placeholder(tf.float32, shape = (batch_size, 2), name = 'x-input')
y = tf.placeholder(tf.float32, shape = (batch_size, 1), name = 'y-input')
# 定义神经网络结构和优化算法
loss = ...
train_step = tf.train.Adamoptimizer(0.001).minimize(loss)
# 训练神经网络
with tf.Session( ) as sess:
# 参数初始化
...
# 迭代的更新参数
for i in range(STEPS):
# 准备batch_size个训练数据。一般将所有训练数据随机打乱之后再选取可以得到
# 更好的优化过程
current_x, current_y = ...
sess.run(train_step, feed_dict = {x: current_X, y_: current_Y})
2、学习率设置
在训练神经网络时,需要设置学习率(learning rate)控制参数更新的速度。学习率决定了参数每次更新的幅度,如果幅度过大,那么可能导致参数在极优值的两侧开回移动当优化 函数的样例时,如果在优化中使用学习率为1,那么整个优化过程如下表
轮数 | 当前轮参数值 | 梯度学习率 | 更新后参数值 |
1 | 5 | 251=10 | 5-10-5 |
2 | -5 | 2(-5)1=-10 | -5-(-10)=5 |
3 | 5 | 251=10 | 5-19=-5 |
从以上样例可以看出,无论进行多少轮迭代,参数将在5和-5之间摇摆,而不会收敛到一个极小值。相反,当学习率过小时,虽然能保证收敛性,但这会大大降低优化速度。我们会需要更多轮的迭代才能达到一个比较理想的优化效果。比如当学习率为0.001时,迭代5之后,x的值将为4.95。要将训练到0.05需要2300轮;二当学习率为0.3时,只需要5轮可以达到。综上所述,学习率既不能过大, 也不能过小。为了解决学习率的问题,TensorFlow提供了一种更加灵活的学习率设置方法------指数衰减法,tf.train.exponential_decay函数实现了指数衰减学习率。通过这个函数,可以使用较大的学习率快速得到一个比较优的解,然后随着迭代的继续逐步减少学习率,使得模型在训练后期更加稳定。exponential_decay函数会指数级地减少学习率,它实现了以下代码的功能:
decayed_learning_rate = \ learning_rate * decay_rate ^ (global_step / decay_steps)
decayed_learning_rate为每一轮优化时使用的学习率,learning_rate为事先设定的初始学习率,decay_rate为衰减系数,decay_steps为衰减速度。
tf.train.exponential_decay函数可以通过设置参数staircase选择不同的衰减方式。当staircase被设置为True时,global_step/decay_steps会被转化成整数。这使得学习率成为一个阶梯函数(staircase function)。
在这样的设置下,decay_steps通常代表了完整的使用一遍训练数据所需要的迭代轮数。这个迭代轮数也就是总训练样本数除以每一个batch中的训练样本数。这种设置的常用场景是每完整地过一遍训练数据,学习率就减少一次。这可以使得训练数据集中的所有数据对模型训练有相等的作用。当使用连续的指数衰减学习率时,不同训练数据有不同的学习率,而当学习率减少时,对应的训练数据对模型训练结果的影响也就小了。
global_step = tf.Variable(0)
# 通过exponential_decay函数生成学习率
learning_rate = tf.train.exponential_decay(0.1, global_step, 100, 0.96 , staircase=True)
# 使用指数衰减的学习率,在minimize函数中传入global_step将自动更新
# global_step参数,从而使学习率也得到相应更新
learning_step = tf.train.GradientDescentOptimizer(learning_rate)\
.minimize(...my loss..., global_step = global_step)
上面这段代码中设定了初始学习率为0.1,因为指定了staircase=True,所以每训练100轮后学习率乘以0.96。一般来说初始学习率、衰减系数和衰减速度都是根据经验设置的,而且损失很熟下降的速度和迭代结束之后总损失的大小没有必然的联系。也就是说并不能通过前几轮损失函数下降的速度来比较不同神经网络的效果。
3、过拟合问题
在真实的应用中想要的并不是让模型尽量模拟训练数据的行为,而是希望通过训练出来的模型对未知的数据给出判断。模型在训练数据上的表现并不一定代表了它在未知数据上的表现。过拟合问题就是可以导致这个差距的一个很重要因素。所谓过拟合,指的是当一个模型过于复杂后,它可以很好地“记忆”每一个训练数据中随机噪声的部分而忘记了要去“学习”训练数据中通用的趋势。举一个极端例子,如果一个模型中的参数比训练数据的总数还要多,那么只要训练数据不冲突,这个模型完全可以记住所有训练数据的结果从而使得损失函数为0。可以直观地相像一个包含n个变量和n个等式的方程组,当方程不冲突时,这个方程组是可以通过数学的方法来求解的。然而,过度拟合训练数据中的随机噪声虽然可以得到非常小的损失函数,但是对于未知数据可能无法做出可靠的判断。
为了避免过拟合问题,一个非常有用的方法是正则化(regularization)。正则化的思想就是在损失函数中加入刻画模型复杂度程序的指标。假设用于刻画模型在训练数据上表现损失函数为,那么在优化时不是直接优化,而是优化。其中R(w)刻画的是模型的复杂度,而表示模型复杂损失在总损失中的比例。注意这里表示的是一个神经网络的所有参数,它包括边上的权重w和偏置b。一般来说模型复杂度只由权重w决定。常用的刻画模型复杂度的函数R(w)有两种:
一种是L1正则化,计算公式是:
另一种是L2正则化,计算公式是
无论是哪一种正则化方式,基本的思想都是希望通过限制权重的大小,使得模型不能任意拟合训练数据中的随机噪声。但这两种正则化的方法也有很大的区别。首先,L1正则化会让参数变得更稀疏,而L2正则化不会。之所以L2正则化不会让参数变得稀疏的原因是当参数很小时,比如0.001,这个参数的平方基本上就可以忽略了,于是模型不会进一步将这个参数调整为0。其次,L1正则化的计算公式不可导,而L2正则化公式可导。因为在优化时需要计算损失函数的偏导数,所以对含有L2正则化损失函数的优化要更加简洁。优化带L1正则化的损失函数要更加复杂,而且优化方法也有很多种。在实践中,也可以将L1正则化和L2正则化同时使用:
以下代码给出了一个简单的带L2正则化的损失函数定义:
w = tf.Variable(tf.random_normal([2, 1], stddev = 1, seed = 1))
y = tf.matmul(x, w)
loss = tf.reduce_mean(tf.square(y_ - y )) + tf.contrib.layers.12_regularizer(lambda)(w)
在以上程序中,loss为定义的损失函数,它由两部分组成。第一个是均方误差损失函数,它刻画了模型在训练数据上的表现。第二个部分就是正则化,它防止模型过度拟合训练数据中的随机噪声。lambda参数表示了正则化项的权重,也就是公式中的。w为需要计算正则化损失的函数。tensorflow提供了tf.contrib.layers.l2_regularizer函数,它可以返回一个函数,这个函数可以计算一个给定参数的L2正则化的值。类似的,tf.contrib.layer.l1_regularizer可以计算L1正则化的值。以下代码给出了使用这两个函数的样例:
weights = tf.constant([[1.0, -2.0], [-3.0, 4.0]])
with tf.Session( ) as sess:
# 输出为(|1| + |-2| + |-3| + |4|) * 0.5=5,其中0.5为正则化项的权重。
print sess.run(tf.contrib.layers.l1_regulrilzer(.5)(weights))
# 输出为(1^2 +(-2)^2 + (-2)^2 + 4^2)/2 * 0.5 = 7.5
print sess.run(tf.contrib.layers.l2_regularizer(.5)(weights))
在简单的神经网络中,这样的方式就可以很好的计算带正则化的损失函数了。但当神经网络的参数增多之后,这样的方式首先可能导致损失函数loss的定义很长,可读性差且容易出错。但更主要的是,当神经网络结构复杂之后定义网络结构的部分和计算损失函数大的部分可能不在同一函数中,这样通过变量这种方式计算损失函数就不方便了。为了解决这个问题,可以使用tensorflow中给提供的集合(collection)。它可以在一个计算图(tf.Graph)中保存一组实体(比如张量)。以下代码给出了通过集合计算一个五层神经网络带L2正则化的损失函数的计算方法。
import tensorflow as tf
# 获取一层神经网络边上的权重,并将这个权重的L2正则化损失加入名称为'losses'的集合中
def get_weight(shape, lambda1):
# 生成一个变量
var = tf.Variable(tf.random_normal(shape), dtype = tf.float32)
# add_to_collection函数将这个新生成变量的L2正则化损失项加入集合。
# 这个函数的第一个参数'losses'是集合的名字,第二个参数是要加入这个集合的内容
tf.add_to_collection(
'losses', tf.contrib.layers.l2_regularizer(lambda1)(var))
return var
x = tf.placeholder(tf.float32, shape=(None, 2))
y_ = tf.placeholder(tf.float32, shape=(None, 1))
batch_size = 8
# 定义了每一层网络中节点的个数
layer_dimension = [2, 10, 10, 10, 1]
# 神经网络的层数
n_layers = len(layer_dimension)
# 这个变量维护前向传播时最深层的节点,开始的时候就是输入层
cur_layer = x
# 当前层的节点个数
in_dimension = layer_dimension[0]
# 通过一个循环来生成5层全连接神经网络结构
for i in range(1, n_layers):
# layer_dimension[i]为下一层的节点个数
out_dimension = layer_dimension[i]
# 生成当前层中权重的变量,并将这个变量的L2正则化损失加入到计算图上的集合。
weight = get_weight([in_dimension, out_dimension], 0.001)
bias = tf.Variable(tf.constant(0.1, shape=[out_dimension]))
# 使用ReLU激活函数
cur_layer = tf.nn.relu(tf.matmul(cur_layer, weight) + bias)
# 进入下一层之前将下一层的节点个数更新为当前层节点个数
in_dimension = layer_dimension[i]
# 在定义神经网络前向传播的同时已经将所有L2正则化损失加入了图上的集合
# 这里只需要计算刻画模型在训练数据上表现的损失函数
mse_loss = tf.reduce_mean(tf.square(y_ - cur_layer))
# 将均方误差损失函数加入损失集合
tf.add_to_collection('losses', mse_loss)
# get_collection返回一个列表,这个列表是所有这个集合中的元素。在这个样例中,
# 这些元素就是损失函数的不同部分,将它们加起来就可以得到最终的损失函数。
loss = tf.add_n(tf.get_collection('losses'))
从上面代码可以看出通过使用集合的方法在网络结构比较复杂的情况下可以使代码的可读性更高。以上代码给出的是一个只有5层的全连接网络,在更复杂的网络结构中,使用这样的方式来计算损失函数将大大增强代码的可读性。
4、滑动平均模型:
滑动平均模型可以使模型在测试数据上更健壮(robust)的方法------滑动平均模型。在采用随机梯度下降算法训练神经网络时,使用滑动平均模型在很多应用中都可以在一定程度提高最终模型在测试数据上的表现。
在tensorflow中提供了tf.train.ExponentialMovingAverage来实现滑动平均模型。在初始化ExponentialMovingAverage时,需要提供一个衰减率(decay)。这个衰减率将用于控制模型更新的速度。ExponentialMovingAverage对每一个变量会维护一个影子变量(shadow variable),这个影子变量的初始值就是响应变量的初始值,而每次运行变量更新时,影子变量的值会更新为:
shadow_variable = decay*shadow_variable + (1 - decay)*variable
其中shadow_variable为了影子变量,variable为待更新的变量,decay为衰减率。从公式中可以看到,decay决定了模型更新的速度,decay越大模型越趋于稳定。在实际应用中,decay一般会设成非常接近1的数(比如0.999或0.9999)。为了使得模型在训练前期可以更新得更快,ExponentialMovingAverage还体用了num_unpdates参数来动态设置decay的大小。如果在ExponentialMovingAverage初始化时提供了num_updates参数,那么每次使用的衰减率将是:
下面通过一段代码来解释ExponentialMovingAverage是如何被使用的。
import tensorflow as tf
# 定义一个变量用于计算滑动平均,这个变量的初始值为0,注意这是手动指定了变量的
# 类型为tf.float32,因为所有需要计算滑动平均的变量必须是实数型。
v1 = tf.Variable(0, dtype = tf.float32)
# 这里step变量模拟神经网络中迭代的次数,可以用于动态控制衰减率
step = tf.Variable(0, trainable=False)
# 定义一个滑动平均的类(class)。初始化时给定了衰减率(0.99)和控制衰减率的变量step,
ema = tf.train.ExponentialMovingAverage(0.99, step)
# 定义一个更新变量滑动平均的操作。这里需要给定一个列表,每次执行这个操作时
# 这个列表中的变量都会被更新。
maintain_average_op = ema.apply([v1])
with tf.Session() as sess:
# 初始化所有变量
init_op = tf.global_variable_initializer()
sess.run(init_op)
# 通过ema.average()获取滑动平均之后变量的取值。在初始化之后变量v1的值和v1的
# 滑动平均都为0。
print sess.run([v1, ema.average(v1)])
# 更新变量v1的值到5。
sess.run(tf.assign(v1, 5))
# 更新v1的滑动平均值。衰减率为min{0.99, (1+step)/(10+step) = 0.1}= 0.1,
# 所以v1的滑动平均会被更新为0.1*0 + 0.9*5 = 4.5。
see.run(maintain_average_op)
print sess.run([v1, ema.avergae(v1)])
# 输出[5.0, 4.5]
# 更新step的值为1000。
sess.run(tf.assign(step, 10000))
# 更新v1的值为10。
sess.run(tf.assign(v1 ,10))
# 更新v1的滑动平均值。衰减率为min{0.99, (1 + step)/(10 + step)约等于0.999}=0.99,
# 所以v1的滑动平均被更新为0.99*4.5 + 0.01*10 = 4.555
sess.run(maintain_average_op)
print sess.run([v1, ema.average(v1)])
# 输出[10.0, 4.5549998]
# 再次更新滑动平均值,得到的滑动平均值为0.99*4.555 + 0.01 * 10 = 4.60945
sess.run(maintain_average_op)
print sess.run([v1, ema.average(v1)])
# 输出[10.0, 4.6094499]
以上代码给出了ExponentialMovingAverage的简单样例。