第 4 章 深层神经网络
4.1 深度学习与深层神经网络
假设一个模型的输出 y 和输入 x 满足以下关系,那么这个模型为线性模型:
因为矩阵乘法满足结合律,前向传播算法可整理为:
说明前向传播算法符合线性模型的定义,而线性模型无法解决非线性问题。
在 TensorFlow 游乐场中,激活函数选择线性(Linear),训练 107 轮后的结果如下图所示,并不能很好解决分类的问题。
选择另一个线性可分的数据集,线性模型在训练 101 轮后很好的完成了分类问题。说明线性模型只能解决线性可分问题。
选择线性不可分数据集和 ReLU 激活函数,训练 100 轮之后,可以看到较好的完成了分类任务。说明了使用非线性模型解决线性不可分问题的效果更好。
加入激活函数和偏置项之后神经元的输出变成了:
以下是几个常用的非线性激活函数的函数图像:
ReLU函数 sigmoid函数
tanh函数
以下选择了一个能够模拟异或运算的数据集,不使用隐藏层,训练500轮的分类效果。可以看出这个感知机模型无法将两种不同颜色的点分开,也就是说感知机无法模拟异或运算。
而加入隐藏层之后,该模型很好的完成了分类任务,异或问题得到了解决。
从以上例子可以看出深层神经网络实际上有组合特征提取的功能。这个特性对于解决不易提取特征向量的问题(比如图片识别、语音识别等)有很大帮助。
4.2 损失函数定义
分类问题希望解决的是将不同的样本分到事先定义好的类别中。通过神经网络解决多分类问题最常用的方法是设置 n 个输出节点,其中 n 为类别的个数。对于每一个样例,神经网络可以得到一个 n 维数组作为输出结果。数组中的每一个维度对应一个类别。在理想情况下,如果一个样本属于类别 k,那么这个类别所对应的输出节点的输出值应该为 1,而其他节点的输出都为 0。
交叉熵(cross entropy)是常用的评判一个输出向量和期望的向量之间接近程度的损失函数。交叉熵刻画了两个概率分布之间的距离。
给定两个概率分布 p 和 q,通过 q 来表示 p 的交叉熵为:
如果将分类问题中“一个样例属于一个类别”看成是一个概率事件,那么训练数据的正确答案就符合一个概率分布。Softmax 回归是一个非常常用的将神经网络前向传播得到的结果变成概率分布。Softmax 回归本身可以作为一个学习算法来优化分类结果,但在 TensorFlow 中,Softmax 回归的参数被去掉了,它只是一个额外的处理层,将神经网络的输出变成一个概率分布。
假设原始的神经网络的输出为 y1, y2, ····, yn,那么经过 Softmax 回归处理之后的输出为:
原始神经网络的输出被用作置信度来生成概率分布,就可以通过交叉熵来计算预测的概率分布和真实答案的概率分布之间的距离。
交叉熵函数不是对称的( H(p, q) ≠ H(q, p) )。当交叉熵作为神经网络的损失函数时,p 代表的是正确答案,q 代表的是预测值。交叉熵值越小,两个概率分布越接近。
在计算交叉熵时,通常会用到以下几个函数:
tf.clip_by_value(t, clip_value_min, clip_value_max, name=None):
输入张量 t,把 t 中的元素的值都限制在 clip_value_min
和 clip_value_max
之间,小于 clip_value_min
的等于 clip_value_min
,大于 clip_value_max
的等于 clip_value_max
,这样可以避免一些运算错误(比如 log0 无效)。
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
v = tf.constant([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
with tf.Session() as sess:
print(tf.clip_by_value(v, 2.5, 4.5).eval())
tf.log()
函数,对张量中所有元素依次求对数:
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
v = tf.constant([[1.0, 2.0, 3.0]])
with tf.Session() as sess:
print(tf.log(v).eval())
*
操作,实现两个矩阵对应元素直接相乘,矩阵乘法需要使用 tf.matmul()
函数,以下代码给出了两个操作的区别:
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
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())
tf.reduce_mean()
函数,用于计算张量 tensor 沿着指定的数轴(tensor的某一维度)上的平均值,主要用作降维或者计算 tensor 的平均值。
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
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())
print(tf.reduce_mean(v, axis=0).eval())
print(tf.reduce_mean(v, axis=1).eval())
因为交叉熵一般会与 Softmax 回归一起使用,所以 TensorFlow 对这两个功能进行了统一封装,并提供了
tf.nn.softmax_cross_entropy_with_logits()
函数。比如可以直接通过以下代码来实现使用了 softmax 回归之后的交叉熵损失函数:tf.nn.softmax_cross_entropy_with_logits(labels=y_, logits=y)
其中 y 代表了原始神经网络的输出结果,而 y_ 给出了标准答案。
在只有一个正确答案的分类问题中,TensorFlow 提供了
tf.nn.sparse_softmax_cross_entropy_with_logits()
函数来进一步加速计算过程。
与分类问题不同,回归问题解决的是对具体数值的预测。解决回归问题的神经网络一般只有一个输出节点,输出值就是预测值。对于回归问题,最常用的损失函数是均方误差(MSE,mean squared error),定义如下:
其中 yi 为一个 batch 中第 i 个数据的正确答案,而 yi' 为神经网络给出的预测值。以下代码展示了如何通过 TensorFlow 实现均方误差损失函数:
mse = tf.reduce_mean(tf.square(y_ - y))
TensorFlow 不仅支持经典的损失函数,还可以优化任意的自定义损失函数。以预测商品销量问题为例,一个商品的成本是 1 元,利润是 10 元。为了最大化预期利润,需要将损失函数和利润直接联系起来。注意损失函数定义的是损失,所以要将利润最大化,定义的损失函数应该刻画成本或者代价。以下公式给出了一个当预测多于真实值和预测少于真实值时有不同损失系数的损失函数:
在上面的问题中,a 等于 10(正确答案多于预测答案的代价), b 等于1(正确答案少于预测答案的代价)。在 TensorFlow 中,可以通过以下代码来实现该损失函数:
loss = tf.reduce_sum(tf.where(tf.greater(v1, v2), (v1 - v2) * a, (v2 - v1) * b))
tf.greater()
输入的是两个张量,比较两个输入张量中每一个元素的大小,并返回比较结果。tf.where()
函数有三个参数:第一个作为选择条件依据,当选择条件为 True 时,选择第二个参数中的值,否则使用第三个参数中的值。函数判断和选择都是在元素级别进行。以下代码展示了
tf.where()
函数和 tf.greater()
函数的用法:
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
v1 = tf.constant([1.0, 2.0, 3.0, 4.0])
v2 = tf.constant([4.0, 3.0, 2.0, 1.0])
with tf.Session() as sess:
print(tf.greater(v1, v2).eval())
print(tf.where(tf.greater(v1, v2), v1, v2).eval())
下面将通过一个简单的神经网络程序来讲解损失函数对模型训练结果的影响,在下面这个程序中,实现了一个拥有两个输入节点、一个输出节点,没有隐藏层的神经网络:
import tensorflow as tf
from numpy.random import RandomState
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
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))
train_step = tf.train.AdamOptimizer(0.001).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))
4.3 神经网络优化算法
梯度下降算法(gradient descent)主要用于优化单个参数的取值,而反向传播算法(backpropagation)给出了一个在所有参数上使用梯度下降算法的高效方式,从而使神经网络模型在训练数据上的损失尽可能小。反向传播算法是训练神经网络的核心算法,它可以根据定义好的损失函数优化神经网络中参数的取值,从而使神经网络模型在训练数据集上的损失函数达到一个较小值。
假设用 θ 表示神经网络中的参数,J(θ) 表示在给定的参数取值下,训练数据集上损失函数的大小,那么整个优化过程可以抽象为寻找一个参数 θ,使得 J(θ) 最小。因为目前没有一个通用的方法可以对任意损失函数直接求解最佳的参数取值,所以在实践中,梯度下降算法是最常用的神经网络优化方法。梯度下降算法会迭代式更新参数 θ,不断沿着梯度的反方向让参数朝着总损失更小的方向更新。
参数的梯度可以通过求偏导的方式计算,对于参数 θ,其梯度为 :∂J(θ)/∂θ。
定义学习率 η (learning rate)来定义每次参数更新的幅度。通过参数的梯度和学习率,参数更新的公式为:
神经网络的优化过程可以分为两个阶段,第一个阶段先通过前向传播算法计算得到预测值,并将预测值和真实值作对比得出两者之间的差距。然后在第二个阶段通过反向传播算法计算损失函数对每一个参数的梯度,再根据梯度和学习率使用梯度下降算法更新每一个参数。
梯度下降算法并不能保证被优化的函数达到全局最优解。只有当损失函数为凸函数时,梯度下降算法才能保证达到全局最优解。
除了不一定能达到全局最优,梯度下降算法的另一个问题就是计算时间太长。因为要在全部训练数据上最小化损失,所以损失函数 J(θ) 是在所有训练数据上的损失和。这样在每一轮迭代中都需要计算在全部训练数据上的损失函数。
为了加速训练过程,可以使用随机梯度下降算法(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})
4.4 神经网络进一步优化
为了解决学习率的问题,TensorFlow 提供了一种灵活的学习率设置方法——指数衰减法(exponential attenuation/decay)。tf.train.exponential_decay()
函数实现了指数衰减学习率。通过这个函数,可以先使用较大的学习率来快速得到一个比较优的解,然后随着迭代的继续逐步减小学习率,使得模型在训练后期更加稳定。exponential_decay
函数会指数级地减小学习率,它实现了以下代码的功能:
decayed_learning_rate = learning_rate * decay_rate ^ (global_step / decay_steps)
decay_rate
为衰减系数,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)
# 使用指数衰减的学习率
learning_step = tf.train.GradientDescentOptimizer(learning_rate).minimize(loss, global_step=global_step)
正则化(regularizaton)是非常常用的避免过拟合(overfitting)的方法。正则化的思想是在损失函数中加入刻画模型复杂程度的指标。假设用于刻画模型在训练数据上表现的损失函数为 J(θ),那么在优化时不是直接优化 J(θ),而是优化 J(θ)+λR(w)。其中 R(w) 刻画的是模型的复杂程度,而 λ 表示模型复杂损失在总损失中的比例。这里 θ 表示的是一个神经网络中的所有参数,包括边上的权重 w 和偏置项 b。一般来说模型复杂度只由权重 w 决定。
常用的刻画模型复杂度的函数 R(w) 有两种,一种是 L1 正则化,计算公式是:
另一种是 L2 正则化,计算公式是:
无论是哪种正则化方式,基本的思想都是希望通过限制权重的大小,使得模型不能任意拟合训练数据中的随机噪音。
L1 正则化会让参数变得更稀疏,可以达到类似特征选取的方式。L1 正则化的计算公式不可导,而 L2 正则化公式可导。因为在优化时需要计算损失函数的偏导数,所有对含有 L2 正则化损失函数的优化要更加简洁。
在实践中,也可以将 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.l2_regularizer(lambda)(w)
loss 为定义的损失函数,由刻画模型在训练数据上表现的均方误差损失函数和防止模型过度模拟训练数据中的随机噪音的 L2 正则化组成。lambda 参数表示了正则化项的权重,也就是公式 J(θ)+λR(w) 中的 λ。w 为需要计算正则化损失的参数。TensorFlow 提供了 tf.contrib.layers.l2_regularizer()
函数,它可以返回一个函数,这个函数可以计算一个给定参数的 L2 正则化项的值。类似的,tf.contrib.layers.l1_regularizer
可以计算 L1 正则化项的值。以下代码给出了使用这两个函数的样例:
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
weights = tf.constant([[1.0, -2.0], [-3.0, 4.0]])
with tf.Session() as sess:
print(sess.run(tf.contrib.layers.l1_regularizer(.5)(weights)))
print(sess.run(tf.contrib.layers.l2_regularizer(.5)(weights)))
当网络结构复杂之后定义网络结构的部分和计算损失函数的部分可能不在同一个函数中,这样通过变量这种方式计算损失函数就不方便。为了解决这个问题,可以使用 TensorFlow 中提供的集合(collection)。它可以在一个计算图中保存一组实体。以下代码给出了通过集合计算一个 5 层神经网络带 L2 正则化的损失函数的计算方法:
import tensorflow as tf
from numpy.random import RandomState
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
# 获取一层神经网络边上的权重并将该权重的L2正则化损失加入集合中
def get_weight(shape, Lambda):
# 生成一个变量
var = tf.Variable(tf.random_normal(shape), dtype=tf.float32)
# 加入集合
tf.add_to_collection('losses', tf.contrib.layers.l2_regularizer(Lambda)(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]
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]
# 计算损失函数
mse_loss = tf.reduce_mean(tf.square(y_ - cur_layer))
# 将均方误差损失函数加入损失集合
tf.add_to_collection('losses', mse_loss)
# 返回列表
loss = tf.add_n(tf.get_collection('losses'))
滑动平均模型(moving average)可以使模型在测试数据上更健壮(robust)。在采用随机梯度下降算法训练神经网络时,使用滑动平均模型在很多应用中都可以在一定程度上提高最终模型在测试数据上的表现。
TensorFlow 提供了 tf.train.ExponentialMovingAverage
来实现滑动平均模型。在初始化 ExponentialMovingAverage
时,需要提供一个衰减率(decay)。这个衰减率将用于控制模型更新的速度。ExponentialMovingAverage
对每一个变量会维护一个影子变量(shadow variable),这个影子变量的初始值就是对应变量的初始值,而每次运行变量更新时,影子变量的值会更新为:
shadow_variable = decay × shadow_variable + (1 - decay) × variable
decay
决定了模型更新的速度,decay
越大模型越趋于稳定。在实际应用中,decay
一般会设成非常接近 1 的数(比如 0.999 或 0.9999)。
如果在 ExponentialMovingAverage
初始化的时候提供了 num_updates
参数,那么每次使用的衰减率将是:
下面代码解释了
ExponentialMovingAverage
是如何被使用的:
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
# 定义一个变量用于计算滑动平均,初始值为0
v1 = tf.Variable(0, dtype=tf.float32)
# step变量模拟神经网络中迭代的轮数,用于动态控制衰减率
step = tf.Variable(0, trainable=False)
# 定义滑动平均的类,初始化时给定衰减率(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)获取滑动平均之后变量的取值
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
sess.run(maintain_averages_op)
print(sess.run([v1, ema.average(v1)]))
# 更新step的值为0
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
sess.run(maintain_averages_op)
print(sess.run([v1, ema.average(v1)]))
# 再次更新滑动平均值
sess.run(maintain_averages_op)
print(sess.run([v1, ema.average(v1)]))