TensorFlow学习笔记——深层神经网络的整理
维基百科对深度学习的精确定义为“一类通过多层非线性变换对高复杂性数据建模算法的合集”。因为深层神经网络是实现“多层非线性变换”最常用的一种方法,所以在实际中可以认为深度学习就是深度神经网络的代名词。从维基百科给出的定义可以看出,深度学习有两个非常重要的特性——多层和非线性。那么为什么要强调这两个性质呢?下面我们开始学习。
1,线性模型的局限性
在线性模型中,模型的输出为输入的加权和。假设一个模型的输出 y 和输入 xi 满足以下关系,那么这个模型就是一个线性模型:
其中,wi , b € R 为模型的参数。被称之为线性模型是因为当模型的输入只有一个的时候, x 和 y 形成了二维坐标系上的一条直线。类似的,当模型有 n 个输入时, x 和 y 形成了 n+1 维空间中的一个平面。而当一个线性模型中通过输入得到输出的函数被称之为一个线性变换。上面的公式就是一个线性变换。线性模型的最大特点就是任意线性模型的组合仍然还是线性模型。其实之前学的前向传播算法实现的就是一个线性模型。下面看一下前向传播的计算公式为:
其中 x 为输入,W为参数。整理一下上面的公式可以得到整个模型的输出为:
根据矩阵乘法的结合律有:
而W(1)W(2) 其实可以被表示为一个新的参数W':
这样输入和输出的关系就可以表示为:
其中W' 为新的参数。这个前向传播的算法完全符合线性模型的定义。从这个例子可以看到,虽然这个神经网络有两层(不算输入层,只有隐藏层和输出层),但是它和单层的神经网络并没有区别。以此类推,只通过线性变换。任意层的全连接神经网络和单层神经网络模型的表达能力没有任何区别,而且他们都是线性模型。而线性模型能够解决的问题是有限的。这就是线性模型最大的局限性也就是为什么深度学习要强调非线性。
下面通过TensorFlow游乐场给出一个具体的例子来验证线性模型的局限性。还是以判断零件是否合格为例,输入为 x1 和 x2 ,其中 x1 代表一个零件质量和平均质量的差, x2 代表一个零件长度和平均长度的差。假设一个零件的质量及长度离平均质量及长度越近,那么这个零件就越有可能合格。于是训练数据很有可能服从下图所示的分布:
图中蓝色的点代表合格的零件,而橙色的点代表不合格的零件。可以看到虽然蓝色和橙色的点有一些重合。但是大部分代表合格零件的黑色点都在原点(0, 0)的附近,而代表不合格的短都在离原点相对远的地方。这样的分布比较接近真实问题,因为大部分真实的问题都存在大致的趋势,但是很难甚至无法完全正确的区分不同的类别。
下图显示了使用TensorFlow游乐场训练线性模型解决这个问题的效果:
上图中使用的模型有一个隐藏层,并且在顶部激活函数(Activation)那一栏中选择了线性(Linear),通过对这个模型训练100轮之后,在最右边那一栏可以看到整个训练的结果。而且我们发现这个模型并不能很好的区分两个不同的点。虽然平面的颜色比较浅,但是中间还是隐约有一条分界线,这说明这个模型只能通过直线来划分平面。如果一个问题可以通过一条直线来划分,那么线性模型也是可以用来解决这个问题的。
下图显示了一个可以通过直线划分的数据:
从上图可以看出,在线性可分问题中,线性模型就能很好区分不同颜色的点。因为线性模型就能解决线性可分问题,所以在深度学习的定义中特意强调它的目的为解决更加复杂的问题。所谓复杂问题,至少是无法通过直线(或者高维空间的平面)划分的。在线性世界中,绝大部分的问题都是无法线性划分的。回到判断零件是否合格的问题,如果将激活函数换成非线性的,那么就可以得到下图的结果(我们使用了ReLU激活函数):
我们发现,当加入非线性的元素之后,神经网络模型就可以很好地区分不同颜色的点了。
激活函数实现去线性化
如果将每一个神经元(也即是神经网络中的节点)的输出通过一个非线性函数,那么整个神经网络的模型也就不再是线性的了。这个非线性函数就是激活函数。下图就是加了激活函数和偏置项之后的神经元结构:
下面给出了神经网络结构加上激活函数和偏置项之后的前向传播算法的数学定义:
比起之前的线性模型,上面的定义主要有两个改变。第一个改变是新的公式中增加了偏置项(bias),偏置项是神经网络中非常常用的一种结构。第二个改变就是每个节点的取值不再是单纯的加权和。每个节点的输出在加权和的基础上还做了一个非线性变换。下图显示了几种常用的非线性激活函数的函数图像:
从上图可以看出,这些激活函数的函数图像都不是一条直线。所以通过这些激活函数,每一个节点不再是线性变换,于是整个神经网络模型也就不再是线性的了。下图给出了加入偏置项和ReLU激活函数之后,神经网络的结构:
从图中可以看出,偏置项可以被表述为一个输出永远为1的节点,下面的公式给出了这个新的神经网络模型前向传播算法的计算方式。
隐藏层推导公式:
输出层推导公式:
目前TensorFlow提供7种不同的非线性激活函数,tf.nn.relu,tf.simoid,tf.tanh 是其中比较常用的几个。当然,TensorFlow也支持使用自己定义的激活函数。以下代码展示了如何通过TensorFlow实现上图中神经网络的前向传播算法:
# 线性定义定义神经网络前向传播的过程 a = tf.matmul(x, w1) y = tf.matmul(a, w2) # 自己定义的激活函数定义神经网络前向传播的过程 a = tf.nn.relu(tf.matmul(x, w1) + biases1) y = tf.nn.relu(tf.matmul(a, w2) + biases2)
从上面代码可以看出,TensorFlow可以很好的支持使用了激活函数和偏置项的神经网络。
多层网络解决异或运算
下面我们将通过一个实际问题来学习深度学习的另外一个重要性质——多层变换。
在神经网络的发展史上,一个很重要的问题就是异或问题。神经网络的理论模型由Warren McCulloch 和 Walter Pitts 在1943年首次提出,并在1958年由Frank Rosenblatt提出了感知机(perceptron)模型,从数学上完成了对神经网络的精确建模。感知机可以理解为单层的神经网络。
感知机会先将输入进行加权和,然后通过激活函数最后得到输出。这个结构就是一个额每一隐藏层的神经网络。在上个世纪六十年代,神经网络作为对人类大脑的模拟算法受到了很多关注。然而到了1969年,Marvin Minsky 和 Seymour Papert 在Perceptrons: An Introduction to Computational Geometry 一书中提出感知机是无法模拟异或运算的。这里通过TensorFlow游乐场来模拟一下通过感知机的网络结构来模拟异或运算。下图为通过TensorFlow游乐场训练500轮之后的情况。
上图使用了一个能够模拟异或运算的数据集。异或运算直观来说就是如果两个输入的符号相等时(同时为正或者同时为负)则输出为0,否则(一个正一个负)输出为1.从图中可以看出,左下角(两个输入同时为负)和右上角(两个输入同时为正)的点为蓝色。而另外两个象限的点为灰色,这就符合异或运算的计算规则。上面将隐藏层的层数设置为0,这样就模拟了感知机的模型。通过500轮训练之后,可以看到这个感知机模型并不能将两种颜色的点区分开,也就是说感知器无法模拟异或运算的功能。
当加入隐藏层之后,异或问题就可以得到很好地解决。下图显示了一个有四个节点的隐藏层的神经网络在训练500轮之后的效果。在图中我们除了可以看到最右边的输出节点可以很好地区分不同颜色的点外,更加有意思的是,隐藏层的四个节点中,每个节点都有一个角是黑色的。这四个隐藏点可以被认为代表了从输入特征中抽取的更高维的特征。比如第一个节点可以大致代表两个输入的逻辑与操作的结果(当两个输入都为正数时该节点输出为正数)。
经典损失函数定义
分类问题和回归问题是监督学习的两大种类。分类问题希望解决的是将不同的样本分到事先定义好的类别中。比如前面说的判断一个零件是否合格的问题就是一个二分类问题。在这个问题中,需要将样本(也就是零件)分到合格或是不合格两个类别中。
在解决判断零件是否合格的二分类问题时,我们会定义过一个单个输出节点的神经网络。当这个节点的输出越接近0时,这个样本越有可能是不合格的;反之如果输出越接近1,则这个样本越有可能是合格的。为了给出具体的分类结果,可以取0.5作为阈值。凡是输出大于0.5的样本都认为是合格的,小于0.5的则是不合格的。然而这样的做法并不容易直接推广到多分类的问题。虽然设置多个阈值在理论上是可能的,但是在解决实际问题的过程中一般不会这么处理。
通过神经网络解决多分类问题最常用的方法是设置 n 个输出节点,其中 n 为类别的个数。对于每一个样例。神经网络可以得到一个 n 维数组作为输出结果。数组中的每一个维度(也就是每一个输出节点)对应一个类别。在理想情况下,如果一个样本属于类别 k ,那么这个类别所对应的输出节点的输出值应该为1,而其他节点的输出都为0,以识别数字1为例,神经网络模型的输出结果越接近 [0, 1 , 0, 0 , 0, 0, 0, 0, 0, 0] 越好。那么如何判断一个输出向量和期望的向量有多接近呢?交叉熵(cross entropy)是最常用的评判方法之一。交叉熵刻画了两个概率分布之间的距离,它是分类问题中使用比较广的一种损失函数。
交叉熵是一个信息论中的概念,它原本是用来估算平均编码长度的。那下面给定两个概率分布 p 和 q ,通过 q 表示 p 的交叉熵为:
注意交叉熵刻画的是两个概率分布之间的距离,然而神经网络的输出却不一定是一个概率分布。概率分布刻画了不同事件发生的概率。当事件总数是有限的情况下,概率分布函数 p(X=x) 满足:
也就是说,任意事件发生的概率都在 0 和 1 之间,且总有某一个事件发生(概率的和为1)。如果将分类问题中“一个样例属于某一个类别” 看成一个概率事件,那么训练数据的正确答案就符合一个概率分布。因为事件“一个样例属于不正确的类别”的概率为 0 ,而“一个样例属于正确的类别” 的概率为 1。如何将神经网络前向传播得到的结果也变成概率分布呢?Softmax回归就是一个非常常用的方法。
Softmax 回归本身可以作为一个学习算法来优化分类结果,但是在TensorFlow中,Softmax回归的参数被去掉了,它只是一层额外的处理层,将神经网络的输出变成一个概率分布。下图展示了加上softmax回归的神经网络结构图:
假设原始的神经网络输出为 y1,y2,... yn,那么经过Softmax回归处理之后的输出为:
从上面的公式中可以看出,原始神经网络的输出被用作置信度来生成新的输出,而新的输出满足概率分布的所有要求。这个新的输出可以理解为经过神经网络的推导,一个样例为不同类别的概率分别是多大。这样就把神经网络的输出也变成了一个概率分布,从而可以通过交叉熵来计算预测的概率分布和真实答案的概率分布之间的距离。
从交叉熵的公式中可以看到交叉熵函数不是对称的(H(p, q) != H(q, p)),它刻画的是通过概率分布q 来表达概率分布 p 的困难程度。因为正确答案是希望得到的结果,所以当交叉熵作为神经网络的损失函数时,p代表的是正确答案, q 代表的是预测值。交叉熵刻画的是两个概率分布的距离,也就是说交叉熵值越小,两个概率分布越接近。
下面将给出两个具体样例来直观的说明通过交叉熵可以判断预测答案和真实答案之间的距离。假设有一个三分类问题,某个样例的正确答案是(1, 0, 0)。某模型经过Softmax回归之后的预测答案是(0.5,0.4,0.1),那么这个预测和正确答案之间的交叉熵为:
如果另外一个模型的预测时(0.8, 0.1, 0.1),那么这个预测值和真实值之间的交叉熵是:
从直观上可以很容易地知道第二个预测答案要优于第一个。通过交叉熵计算得到结果也是一致的(第二个交叉熵的值更小)。我们可以通过TensorFlow实现交叉熵代码:
# 定义损失函数来刻画预测值与真实值的差距 cross_entropy = -tf.reduce_mean( y_ * tf.log(tf.clip_by_value(y, 1e-10, 1.0)) )
其中y_ 代表正确结果,y代表预测结果。
下面将具体的学习这个计算过程。这一行代码包含了四个不同的TensorFlow运算。通过 tf.clip_by_value 函数可以将一个张量中的数值限制在一个范围之内,这样就可以避免一些运算错误(比如 log0 是无效的)。下面给出了使用 tf.clip_by_value 的简单样例。
import tensorflow as tf v = tf.constant([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) print(v) ''' #这段代码之前如果没有添加session,会报错:Cannot evaluate tensor using `eval()`: No default session is registered print tf.clip_by_value(v, 2.5, 4.5).eval() 为避免报错应该使用下面的方式: with tf.Session() as sess: print tf.clip_by_value(v, 2.5, 4.5).eval() ''' with tf.Session() as sess: print(tf.clip_by_value(v, 2.5, 4.5).eval()) ''' 结果: Tensor("Const:0", shape=(2, 3), dtype=float32) [[2.5 2.5 3. ] [4. 4.5 4.5]] '''
从上面的结果可以看出,小于2.5 的数都被换成了2.5,而大于4.5的数都被换成了4.5。这样通过 tf.clip_by_value 函数就可以保证在进行 log 运算时,不会出现 log0 这样的错误或者大于1 的概率。第二个运算时 tf.log 函数,这个函数完成了对张量中所有元素依次求对数的功能。以下代码中给出的一个简单的样例。
v = tf.constant([[1.0, 2.0, 3.0]]) with tf.Session() as sess: print(tf.log(v).eval()) # 结果为: [[0. 0.6931472 1.0986123]]
第三个运算时乘法,在实现交叉熵的代码中直接将两个矩阵通过“*”操作相乘。这个操作不是矩阵乘法,而是元素之间直接相乘。矩阵乘法需要使用 tf.matmul 函数来完成。下面给出了这两个操作的区别:
v1 = tf.constant([[1.0, 2.0], [3.0, 4.0]]) v2 = tf.constant([[5.0, 6.0], [7.0, 8.0]]) with tf.Session() as sess: print((v1*v2).eval()) print(tf.matmul(v1, v2).eval()) ''' [[ 5. 12.] [21. 32.]] [[19. 22.] [43. 50.]] '''
v1 * v2 的结果是每个位置上对应元素的乘积。比如(1, 1)这个元素的值是:
(1,2)这个元素的值是:
以此类推。而tf.matmul 函数完成的是矩阵乘法运算,所以(1, 1)这个元素的值是:
通过上面这三个运算完成了对每一个样例中的每一个类别交叉熵 p(x)log q(x) 的计算。这三步计算得到的结果是一个 n*m 的二维矩阵,其中 n 为一个 batch 中样例的数量,m 为分类的类别数量。根据交叉熵的公式,应该将每行中的 m 个结果相加得到所有样例的交叉熵,然后再对这 n 行取
平均得到一个batch的平均交叉熵。但是因为分类问题的类别数量是不变的。所以可以直接对整个矩阵做平均而并不改变计算结果的意义。这样的方式可以使整个程序更加简洁。以下代码简单展示了 tf.reduce_mean 函数的使用方法。
v = tf.constant([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) with tf.Session() as sess: print(tf.reduce_mean(v).eval()) # 3.5
因为交叉熵一般会与softmax回归一起使用,所以TensorFlow对这两个功能进行了统一封装,并提供了 tf.nn.softmax_cross_entropy_with_logits 函数。比如可以直接通过下面的代码来实现使用 softmax 回归之后的交叉熵损失函数:
cross_entropy = tf.nn.softmax_cross_entropy_with_logits(y, y_)
其中 y 代表了原始神经网络的输出结果,而 y_ 给出了标准答案。这样就通过一个命令就可以得到使用了Softmax回归之后的交叉熵。在只有一个正确答案的分类问题中,TensorFlow提供了 tf.nn.sparse_softmax_cross_entropy_with_logited 函数来进一步加速计算过程。
与分类问题不同,回归问题解决的是对具体数值的预测。比如房价预测,销售预测等都是回归问题。这些问题需要预测的不是一个事先定义好的类别,而是一个任意实数。解决回归问题的神经网络一般只有一个输出节点,这个节点的输出值就是预测值。对于回归问题,最常用的损失函数是均方误差(MSE, mean squared error)。它的定义如下:
其中 yi 为一个batch的第 i 个数据的正确答案,而 y'i 为神经网络的预测值。以下代码展示了如何通过TensorFlow实现均方误差损失函数:
其中 y 代表了神经网络的输出答案,y_ 代表了标准答案。上面的减法运算 “-” 也是两个矩阵中对应元素的减法。
自定义损失函数
Tensorflow不仅支持经典的损失函数,还可以优化任意的自定义损失函数。
下面以预测商品销量问题为例。在预测商品销售时,如果预测多了(预测值比真实销量大),商家损失的是生产商品的成本;而如果预测少了(预测值比真实销量少),损失的则是商品的利润。因为一般商品的成本和商品的利润不会严格相等,所以均方误差损失函数就不能够很好的最大化销售利润。比如一个商品的成本是1元,但是利润是10元,那么少预测一个就少挣10元,而多预测一个才少挣1元。如果神经网络模型最小化的是均方误差,那么很有可能此模型就无法最大化预测的利润。为了最大化预测利润,需要将损失函数和利润直接联系起来。注意损失函数定义的是损失,所以要将利润最大化,定义的损失函数应该刻画成本或者代价。下面的公式给出了一个当预测多余真实值和预测少于真实值时有不同损失系数的损失函数:
和均方误差公式类似,yi是一个batch中第i个数据的正确答案, y'i 为神经网络得到的预测值,a 和 b 是常量。比如在上面介绍的销售预测问题中 ,a就等于10(正确答案多于预测答案的代价),而b等于1(正确答案少于预测答案的代价)。通过对这个自定义损失函数的优化,模型提供的预测值更有可能最大化收益。在TensorFlow中,可以通过以下代码来实现这个损失函数:
loss = tf.reduce_sum(tf.where(tf.greater(v1, v2), ((v1 - v2) * a, (v2 - v1)* b )))
tf.greater:输入时两个张量,这个函数会比较两个输入张量中每一个元素的大小,然后返回一个bool类型的Tensor,如果两个张量的维度不一致的话,会进行类似Numpy一样的广播操作。
tf.where:函数有三个参数,根据第一个条件是否成立,当为True的时候选择第二个参数中的值,否则使用第三个参数中的值。
下面展示了两个函数的用法:
import tensorflow as tf v1 = tf.constant([[1.0, 2.0, 3.0, 4.0]]) v2 = tf.constant([[4.0, 3.0, 2.0, 1.0]]) sess = tf.InteractiveSession() print(tf.greater(v1, v2).eval()) # [[False False True True]] print(tf.where(tf.greater(v1, v2), v1, v2).eval()) # [[4. 3. 3. 4.]] sess.close()
在定义了损失函数之后,我们可以通过一个简单的神经网络程序来讲解损失函数对模型训练结果的影响。在下面这个程序中,实现了一个拥有两个输入节点,一个输出节点,没有隐藏层的神经网络。
# _*_coding:utf-8_*_ import tensorflow as tf from numpy.random import RandomState batch_size = 8 # 两个输入节点 x = tf.placeholder(tf.float32, shape=(None, 2), name='x-input') # 回归问题一般只有一个输出节点 y_ = tf.placeholder(tf.float32, shape=(None, 1), name='y-input') # 定义了一个单层的神经网络前向传播的过程,这里就是简单加权和 w1 = tf.Variable(tf.random_normal([2, 1], stddev=1, seed=1)) y = tf.matmul(x, w1) # 定义预测多了和预测少了的成本 loss_less = 10 loss_more = 1 loss = tf.reduce_sum(tf.where(tf.greater(y, y_), (y - y_) * loss_more, (y_ - y) * loss_less)) learning_rate = 0.001 train_step = tf.train.AdamOptimizer(learning_rate).minimize(loss) # 通过随机数生成一个模拟数据集 rdm = RandomState(1) dataset_size = 128 X = rdm.rand(dataset_size, 2) # 设置回归的正确值为两个输入的和加上一个随机量,之所有要加上一个随机量是为了加入不可预测的噪音 # 否则不同损失函数的意义就不大了,因为不同损失函数都会在能完全预测正确的时候最低 # 一般来说噪音为一个均值为0的小量,所有这里噪音设置为 -0.05~0.05的随机数 Y = [[x1 + x2 + rdm.rand() / 10.0 - 0.05] for (x1, x2) in X] # 训练神经网络 with tf.Session() as sess: init_op = tf.global_variables_initializer() sess.run(init_op) STEPS = 5000 for i in range(STEPS): start = (i * batch_size) % dataset_size end = min(start + batch_size, dataset_size) sess.run(train_step, feed_dict={x:X[start:end], y_:Y[start:end]}) print(sess.run(w1)) ''' 最后一次的结果为: [[1.019347 ] [1.0428089]] '''
我们可以看到最后得到的w1的值。也就是得到的预测函数是 x1+x2 ,这要比 1.02x1+1.04x2 大,因为在损失函数中指定预测少了的损失更大(loss_less > loss_more)。如果 将loss_less 的值调整为1,loss_more 的值调整为10,那么w1 的值会[[0.9552581] [0.9813394]]。也就是说,在这样的设置下,模型会更加偏向于预测少一点。而如果使用均方误差作为损失函数,那么w1则会是[[0.97437561] [1.0243336]]。使用这个损失函数会尽量让预测值离标准答案更近,通过这个样例可以感受到,对于相同的神经网络,不同的损失函数会对训练得到的模型产生重要的影响。
神经网络优化算法
下面将学习如何通过反向传播算法(backpropagation)和梯度下降算法(gradient decent)调整神经网络中参数的取值。梯度下降算法主要用于优化单个参数的取值,而反向传播算法给出了一个高效的方式在所有参数上使用梯度下降算法,从而使神经网络模型在训练数据上的损失函数尽可能小。反向传播算法是训练神经网络的核心算法,它可以根据定义好的损失函数优化神经网络中参数的取值,从而使得神经网络模型在训练数据集上的损失函数达到一个较小值。神经网络模型中参数的优化过程直接决定了模型的质量,是使用神经网络时非常重要的一步。
下面主要学习神经网络优化过程的基本概念和主要思想。我们将学习一个具体的样例来解释使用梯度下降算法优化参数取值的过程。
假设用 Θ 表示神经网络中的参数,J(Θ) 表示给定的参数取值下,训练数据集上损失函数的大小,那么整个优化过程可以抽象为寻找一个参数Θ,使得 J(Θ) 最小,因为目前没有一个通用的方法可以对任意损失函数直接求解最佳的参数取值,所以在实践中,梯度下降算法是最常用的神经网络优化方法。梯度下降算法会迭代式更新参数 Θ,不断沿着梯度的反方向让参数朝着总损失更小的方向更新,下图展示了梯度下降算法的原理:
上图中 x 轴表示参数Θ 的取值, y 轴表示损失函数 J(Θ ) 的值,上图的曲线表示了在参数Θ 取不同值的时候,对应损失函数 J(Θ ) 的大小。假设当前的参数和损失值对应图中小圆点的位置,那么梯度下降算法会将参数向 x 轴左侧移动,从而使得小圆点朝着箭头的方向移动。
参数的梯度可以通过求偏导的方式计算,对于参数Θ,其梯度,有了梯度,还需要定义一个学习率 η (learning rate)来定义每次参数更新的幅度。从直观上理解,可以认为学习率定义的就是每次参数移动的幅度。通过参数的梯度和学习率,参数更新的公式为:
下面给出了一个具体的例子来说明梯度下降算法是如何工作的。假设要通过梯度下降算法来优化参数 x ,使得损失函数的值尽量小。梯度下降算法的第一步需要随机产生一个参数 x 的初始值,然后通过梯度和学习率来更新参数 x 的取值。在这个样例中,参数 x 的梯度为,那么使用梯度下降算法每次对参数 x 的更新公式为。假设参数的初始值为5,学习率为0.3,那么这个优化过程 可以总结为下表:
从上面可以看出,经过五次迭代之后,参数 x 的值变成0.0512,这个和参数最优值0 已经比较接近了。虽然这里给出的是一个非常简单的样例,但是神经网络的优化过程也是可以类似的。神经网络的优化过程可以分为两个阶段,第一个阶段先通过前向传播算法计算得到预测值,并将预测值和真实值做对比得出两者之间的差距,然后在第二个阶段通过反向传播算法计算损失函数对每一个参数的梯度,再根据梯度和学习率使用梯度下降算法更新每一个参数。
需要注意的是,梯度下降算法并不能保证被优化的函数达到全局最优解。如下图所示,图中给出的函数就有可能只能得到局部最优解而不是全局最优解。在小黑点处,损失函数的偏导为0,于是参数就不会再进一步更新。在这个样例中,如果参数 x 的初始值落在右侧深色的区域中,那么通过梯度下降得到的结果都会落在小黑点代表的局部最优解。只有当 x 的初始值落在左侧浅色的区间时梯度下降才能给出全局最优答案。由此可见在训练神经网络时,参数的初始值会很大程度影响最后得到的结果,只有当损失函数为凸函数时,梯度下降算法才能保证达到全局最优解。
除了不一定能达到全局最优外,梯度下降算法的另外一个问题就是计算时间太长。因为要在全部训练数据上最小化损失,所以损失函数 J(Θ ) 是所有训练数据上的损失和。这样在每一轮迭代中都需要计算在全部训练数据上的损失函数。在海量训练数据下,要计算所有训练数据的损失函数是非常耗时的。为了加速训练过程,可以使用随机梯度下降的算法(stochastic gradient descent)。这个算法优化的不是在全部训练数据上的损失函数,而是在每一轮迭代中,随机优化某一条训练数据上的损失函数。这样每一轮参数更新的速度就大大地加快了。因为随机梯度下降算法每次优化的只是某一条数据上的损失函数,所以他的问题也非常明显:在某一条数据上损失函数更小并不代表在全部数据上损失函数更小,于是使用随机梯度下降优化得到的神经网络甚至可能无法达到局部最优。
为了综合梯度下降算法和随机梯度下降算法的优缺点,在实际应用中一般采用这两个算法的折中——每次计算一小部分训练数据的损失函数。这一小部分数据被称之为一个batch。通过矩阵运算,每次在一个batch上优化神经网络的参数并不会比单个数据慢太多。另一方面,每次使用一个batch可以大大减少收敛所需要的迭代次数,同时可以使收敛到的结果更加接近梯度下降的效果。一下代码给出了在TensorFlow中如何实现神经网络的训练过程:
import tensorflow as tf 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 = ... learning_rate = 0.001 train_step = tf.train.AdamOptimizer(learning_rate).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})
神经网络的进一步优化
1,学习率的设置
之前学习训练神经网络时,需要设置学习率(learning rate)控制参数更新的速度。下面将进一步学习如何设置学习率。学习率决定了参数每次更新的幅度。如果幅度过大,那么可能导致参数在极优值的两侧来回移动。上面说的损失函数的样例,如果在优化中使用的学习率为1,则整个优化过程将会如下(学习率过大,梯度下降算法的运行过程):
从上面的样例来看,无论进行多少轮的迭代,参数将在5和-5之间摇摆,而不会收敛到一个极小值。相反,当学习率过小时,虽然能保证收敛性,但是这会大大降低优化速度。我们会需要更多轮的迭代才能达到一个比较理想的优化效果。比如当学习率为0.001时候,迭代5次之后,x的值将为4.95.要将 x 训练到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 的默认值为False,这是学习率随迭代轮数变化的趋势如下图中灰色曲线所示。当staircase设置为True时, global_step/decay_steps 会被转化成整数。这使得学习率称为一个阶梯函数(staircase funcation)。黑色曲线显示了阶梯状的学习率。在这样的设置下,decay_steps通常代表了完整的时域以便训练数据所需要的迭代轮数。这个迭代轮数也就是总训练样本数除以每一个batch中训练样本数。这种设置的常用场景是每完整的过完一遍训练数据,学习率就减少一次。这可以使得训练数据集中的所有数据对模型训练有相等的作用。当使用连续的指数衰减学习率时,不同的训练数据有不同的学习率,而当学习率减少时,对应的训练数据对模型训练结果的影响也就小了。
下图显示了随着迭代轮数的增加,学习率逐步降低的过程。
下面给出一段代码示范如何在TensorFlow中使用 tf.train.exponential_decay 函数:
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 )
2,过拟合问题
上面学习了如何在训练数据上优化一个给定的损失函数。然而在真实地应用中想要的并不是让模型尽量模拟训练数据的行为,而是希望通过训练出来的模型对未知的数据给出判断。模型在训练数据上的表现并不一定代表了它在未知数据上的表现。而过拟合问题就是可以导致这个差距的一个很重要的因素。所谓过拟合,指的是当一个模型过为复杂之后,它可以很好的“记忆”每一个训练数据中随机噪音的部分而忘记了要去“学习”训练数据中通用的趋势。举一个极端的例子,如果一个模型中的参数比训练数据的总数还多,那么只要训练数据不冲突,这个模型完全可以记住所有训练数据的结果从而使得损失函数为0,可以直观的想象一个包含 n 个变量和 n 个等式的方程组,当方程不冲突时,这个方程组是可以通过数学的方式来求解的。然而,过度拟合训练数据中的随机噪音虽然可以得到非常小的损失函数,但是对于未知的数据可能无法做出可靠的判断。
下图显示了模型训练的三种不同情况。在第一种情况下,由于模型过于简单,无法刻画问题的趋势。第二个模型比价合理,它既不会过于关注训练数据中的噪音,又能够比较好地刻画问题的整体趋势。第三个模型就是过拟合了,虽然第三个模型完美的划分了不同形状的点,但是这样的划分不能很好地对未知数据做出判断,因为它过度拟合了训练数据中的噪音而忽视了问题的整体规律。比如图中浅色方块更有可能和X数据同一类,而不是根据图上的划分和“ O ” 属于同一类。
为了避免过拟合问题,一个非常常用的方法是正则化(regularization)。正则化的思想就是在损失函数中加入刻画模型复杂程度的指标。假设用于刻画模型在训练数据上表现的损失函数为 J(Θ),那么在优化时不是直接优化J(Θ),而是优化 J(Θ) +λR(w) 。其中R(w) 是刻画模型的复杂程度,而 λ 表示模型复杂损失在总损失中的比例。注意 Θ 表示的是一个神经网络中所有的参数,它包含边上的权重 w 和偏置项 b 。一般来说模型复杂度只由权重 w 决定。常用的刻画模型复杂度的函数 R(w) 有两种,
一种是L1正则化,指权值向量中各个元素的绝对值之和,计算公式是:
另一种是L2正则化,指权值向量中各个元素的平方和然后再求平方根,计算公式是:
无论是哪一种正则化方式,基本的思想都是希望通过限制权重的大小,使得模型不能任意拟合训练数据中的随机噪音。但是这两种正则化的方法也有很大的区别。首先,L1正则化会让参数变得更稀疏,而L2正则化不会。所谓参数变得更稀疏是指会有更多的参数变为0,这样可以达到类似特征选取的功能。之所以L2正则化不会让参数变得更稀疏的原因是当参数很小时,比如0.001,这个参数的平方基本上就可以忽略了。于是模型不会进一步将这个参数调整为0。其次,L1正则化的计算公式不可导,而L2 正则化公式可导。因为在优化时需要计算损失函数的偏导数,所以对含有L2正则化损失函数的优化要更加简洁。优化带L1正则化的损失函数要更加复杂,而且优化方法也有很多种。在实践中,也可以将L1正则化和L2正则化同时使用:
我们知道,TensorFlow可以优化任意形式的损失函数,所以TensorFlow自然也可以优化带正则化的损失函数,一下代码给出了一个简单的带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.l2_regualarizer(lambda )(w)
在上面的程序中,loss为定义的损失函数,它是由两个部分组成。第一个部分为均方误差损失函数,它刻画了模型在训练数据上的表现。第二个部分就是正则化,它防止模型过度拟合训练数据中的随机噪音。lambda参数表示了正则化项的权重,也就是公式 J(Θ) +λR(w) 中的 λ 。w为需要计算正则化损失的参数。TensorFlow提供了 tf.contrib.layers.l2_regularizer 函数,它可以返回一个函数,这个函数可以计算一个给定参数的L2正则化项的值。类似的,tf.contrib.layers.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_regularizer(0.5)(weights))) #5 # 输出为(|1|**2 + |-2|**2 + |-3|**2 + |4|**2)/2*0.5 = 7.5 其中0.5为正则化项的权重 print(sess.run(tf.contrib.layers.l2_regularizer(0.5)(weights))) #7.5
在简单的神经网络中,这样的方式就可以很好地计算带正则化的损失函数了。但是当神经网络的参数增多之后,这样的方式首先可能导致损失函数loss的定义很长,可读性差且容易出错。但更主要的是,当网络结构复杂之后定义网络结构的部分和计算损失函数的部分可能不在同一个函数中,这样通过变量这种方式计算损失函数就不方便了。为了解决这个问题,可以使用TensorFlow中提供的集合(collection)。集合可以在一个计算图(tf.Graph)中保存一组实体(比如张量)。以下代码给出了通过集合计算一个5层神经网络带L2正则化的损失函数的计算方法:
#_*_coding:utf-8_*_ import tensorflow as tf def get_weight(shape, weights): ''' 获取一层神经网络边上的权重,并将这个权重的L2正则化损失加入名称为“losses”的集合中 :param shape: :return: ''' # 生成一个变量 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(weights)(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'))
TensorFlow会将L2的正则化损失值除以2使得求导得到的结果更加简洁。
从上面的代码可以看出通过使用集合的方法在网络结构比较复杂的情况下可以使用代码的可读性更高。上面的代码给出的是一个只有5层的全连接层,在更加复杂的网络结构中,使用这样的方法来计算损失函数将大大增强代码的可读性。
3,滑动平均模型
这里学习另外一个可以使模型在测试数据上更加健壮(robust)的方法——滑动平均模型。在采用随机梯度下降算法训练神经网络时,使用滑动平均模型在很多应用中都可以在一定程度提高最终再测试数据上的表现。
在TensorFlow中提供了 tf.train.ExponentialMovingAverage 来实现滑动平均模型。在初始化ExponentialMovingAverage 时,需要提供一个衰减率(decay)。这个衰减率将用于控制模型更新的速度。ExponentialMovingAverage 对每一个变量会维度一个影子变量(shadow variable)。这个影子变量的初始值就是相应的初始值,而每次运行变量更新时,影子变量会更新为:
其中 shadow_variable 为影子变量。variable为待更新的变量,decay为衰减率。从公式中可以看到。decay决定了模型更新的速度,decay越大模型越趋于稳定。在实际应用中,decay一般会设成非常接近1的数(比如0.999或者0.9999)。为了使得模型在训练前期可以更新的更快。ExponentialMovingAverage 还提供了 num_updates 参数来动态设置 decay的大小。如果在ExponentialMovingAverage 初始化时提供了 num_updates 参数,那么每次使用的衰减率将是:
下面通过一段代码来解释ExponentialMovingAverage 是如何被使用的:
#_*_coding:utf-8_*_ 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_averages_op = ema.apply([v1]) with tf.Session() as sess: # 初始化所有变量 init_op = tf.global_variables_initializer() sess.run(init_op) # 通过ema.average(v1) 获取滑动平均之后变量的取值 # 在初始化之后变量 v1 的值和 v1 的滑动平均都为0 print(sess.run([v1, ema.average(v1)])) # 输出结果为 [0.0, 0.0] # 更新变量 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 sess.run(maintain_averages_op) print(sess.run([v1, ema.average(v1)])) # 输出结果为 [5.0, 4.5] # 更新step的值为10000 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_averages_op) print(sess.run([v1, ema.average(v1)])) # 输出结果为 [10.0, 4.5554998] # 再次更新滑动平均值,得到的新滑动平均值为0.99*4.555+0.01*10=4.60945 sess.run(maintain_averages_op) print(sess.run([v1, ema.average(v1)])) # 输出结果为 [10.0, 4.6094499]
这一篇文章主要学习了使用神经网络模型时需要考虑的主要问题。从神经网络模型结构的设计,损失函数的设计,神经网络的优化和神经网络进一步调优四个方面覆盖了设计和优化神经网络过程中可能遇到的主要问题。
下面使用吴恩达的深度学习课程对TensorFlow中滑动平均模型与代码实现。这个例子来自博客:https://blog.csdn.net/m0_38106113/article/details/81542863
4,指数加权平均算法的原理
TensorFlow中的滑动平均模型使用的时滑动平均(Moving Average)算法,又称为指数加权移动平均算法(exponenentially weighted average),这也是ExponentialMovingAverage() 函数的名称由来。
下面看一个简单的例子,首先这是一年365天的温度散点图,以天数为横坐标,温度为纵坐标,你可以看见各个小点分布在图上,有一定的曲线趋势,但是并不明显。
接着,如果我们要看出这个温度的变化趋势,很明显需要做一点处理,也即是我们的主题,用滑动平均算法处理。
首先给定一个值v0,然后我们定义每一天的温度是a1,a2,a3·····
接着,我们计算出v1,v2,v3····来代替每一天的温度,也就是上面的a1,a2,a3
计算方法是:v1 = v0 * 0.9 + a1 (1-0.9),v2= v1 0.9 + a2 (1-0.9),v3= v2 0.9 + a3 (1-0.9)···,也就是说,每一天的温度改变为前一天的v值 0.9 + 当天的温度 * 0.1,vt = v(t-1) * 0.9 + at * 0.1,把所有的v计算完之后画图,红线就是v的曲线:
v值就是指数加权平均数,整个过程就是指数加权平均算法,它很好的把一年的温度曲线给拟合了出来。把0.9抽象为β,总结为vt = v(t-1) * β + at * (1-β)。
β这个值的意义是什么?实际上vt ≈ 1/(1 - β) 天的平均温度,例如:假设β等于0.9,1/(1 - β) 就等于10,也就是vt等于前十天的平均温度,这个说可能不太看得出来;假设把β值调大到接近1,例如,将β等于0.98,1/(1-β)=50,按照刚刚的说法也就是前50天的平均温度,然后求出v值画出曲线,如图所示:
绿线就是β等于0.98时候的曲线,可以明显看到绿线比红线的变化更迟,红线达到某一温度,绿线要过一阵子才能达到相同温度。因为绿线是前50天的平均温度,变化就会更加缓慢,而红线是最近十天的平均温度,只要最近十天的温度都是上升,红线很快就能跟着变化。所以直观的理解就是,vt是前1/(1-β)天的平均温度。
再看看另一个极端情况:β等于0.5,意味着vt≈最近两天的平均温度,曲线如下黄线:
和原本的温度很相似,但曲线的波动幅度也相当大!
然后说一下这个滑动平均模型和深度学习有什么关系:通常来说,我们的数据也会像上面的温度一样,具有不同的值,如果使用滑动平均模型,就可以使得整体数据变得更加平滑——这意味着数据的噪音会更少,而且不会出现异常值。但是同时β太大也会使得数据的曲线右移,和数据不拟合。需要不断尝试出一个β值,既可以拟合数据集,又可以减少噪音。
滑动平均模型在深度学习中还有另一个优点:它只占用极少的内存
当你在模型中计算最近十天(有些情况下远大于十天)的平均值的时候,你需要在内存中加载这十天的数据然后进行计算,但是指数加权平均值约等于最近十天的平均值,而且根据vt = v(t-1) * β + at * (1-β),你只需要提供at这一天的数据,再加上v(t-1)的值和β值,相比起十天的数据这是相当小的数据量,同时占用更少的内存。
5,偏差修正
指数加权平均值通常都需要偏差修正,TensorFlow中提供的ExponentialMovingAverage()函数也带有偏差修正。
首先看一下为什么会出现偏差,再来说怎么修正。当β等于0.98的时候,还是用回上面的温度例子,曲线实际上不是像绿线一样,而是像紫线:
你可以注意到在紫线刚刚开始的时候,曲线的值相当的低,这是因为在一开始的时候并没有50天(1/(1-β)为50)的数据,而是只有寥寥几天的数据,相当于少加了几十天的数据,所以vt的值很小,这和实际情况的差距是很大的,也就是出现的偏差。
而在TensorFlow中的ExponentialMovingAverage()采取的偏差修正方法是:使用num_updates来动态设置β的大小:
在数据迭代的前期,数据量比较少的时候,(1+num_updates)/(10+num_updates)的值比较小,使用这个值作为β来进行vt的计算,所以在迭代前期就会像上面的红线一样,和原数据更加接近。举个例子,当天数是第五天,β为0.98,那么(1+num_updates)/(10+num_updates) = 6/15 = 0.4,相当于最近1.6天的平均温度,而不是β=0.98时候的50天,这样子就做到了偏差修正。
6,滑动平均模型的代码实现
看到这里你应该大概了解了滑动平均模型和偏差修正到底是怎么回事了,接下来把这个想法对应到TensorFlow的代码中。
首先明确一点,TensorFlow中的ExponentialMovingAverage()是针对权重weight和偏差bias的,而不是针对训练集的。如果你现在训练集中实现这个效果,需要自己设计代码。
为什么要对w和b使用滑动平均模型呢?因为在神经网络中,
更新的参数时候不能太大也不能太小,更新的参数跟你之前的参数有联系,不能发生突变。一旦训练的时候遇到个“疯狂”的参数,有了滑动平均模型,疯狂的参数就会被抑制下来,回到正常的队伍里。这种对于突变参数的抑制作用,用专业术语讲叫鲁棒性,鲁棒性就是对突变的抵抗能力,鲁棒性越好,这个模型对恶性参数的提抗能力就越强。
在TensorFlow中,ExponentialMovingAverage()可以传入两个参数:衰减率(decay)和数据的迭代次数(step),这里的decay和step分别对应我们的β和num_updates,所以在实现滑动平均模型的时候,步骤如下:
- 1、定义训练轮数step
- 2、然后定义滑动平均的类
- 3、给这个类指定需要用到滑动平均模型的变量(w和b)
- 4、执行操作,把变量变为指数加权平均值
# 1、定义训练的轮数,需要用trainable=False参数指定不训练这个变量, # 避免这个变量被计算滑动平均值 global_step = tf.Variable(0, trainable=False) # 2、给定滑动衰减率和训练轮数,初始化滑动平均类 # 定训练轮数的变量可以加快训练前期的迭代速度 variable_averages = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY, global_step) # 3、用tf.trainable_variable()获取所有可以训练的变量列表,也就是所有的w和b # 全部指定为使用滑动平均模型 variables_averages_op = variable_averages.apply(tf.trainable_variables()) # 反向传播更新参数之后,再更新每一个参数的滑动平均值,用下面的代码可以一次完成这两个操作 with tf.control_dependencies([train_step, variables_averages_op]): train_op = tf.no_op(name="train")
设置完使用滑动平均模型之后,只需要在每次使用反向传播的时候改为使用run.(train_op)就可以正常执行了。
此文是自己的学习笔记总结,学习于《TensorFlow深度学习框架》,俗话说,好记性不如烂笔头,写写总是好的,所以若侵权,请联系我,谢谢。