深度学习之卷积神经网络(4)LeNet-5实战
1990年代,Yann LeCun等人提出了用于手写数字和机器打印字符图片识别的神经网络,被命名为LetNet-5 [1]。LetNet-5的提出,使得卷积神经网络在当时能够成功被商用,广泛应用在邮政编码、支票号码识别任务中。下图是LetNet-5的网络结构图,它接受 32 × 32 32×32 32×32大小的数字、字符图片,经过第一个卷积层得到 [ b , 28 , 28 , 6 ] [b,28,28,6] [b,28,28,6]形状的张量,经过一个向下采样层,张量尺寸缩小到 [ b , 14 , 14 , 6 ] [b,14,14,6] [b,14,14,6],经过第二个卷积层,得到 [ b , 10 , 10 , 16 ] [b,10,10,16] [b,10,10,16]形状的张量,同样经过下采样层,张量尺寸缩小到 [ b , 5 , 5 , 16 ] [b,5,5,16] [b,5,5,16],在经过全连接层之前,先将张量打成 [ b , 400 ] [b,400] [b,400]的张量,送入输出节点数分别为120、84的两个全连接层,得到 [ b , 84 ] [b,84] [b,84]的张量,最后通过Gaussian connection层。
[1] Y. Lecun, L. Bottou, Y. Bengio 和 P. Haffner, “Gradient-based learning applied to document recognition,” 出处 Proceedings of the IEEE, 1998.
现在看来,LeNet-5网络层数较少(2个卷积层和2个全连接层),参数量较少,计算代价较低,尤其在现代GPU的加持下,数分钟即可训练好LeNet-5网络。
我们在LeNet-5的基础上进行了少许调整,使得它更容易在现代深度学习框架上实现。首先我们将输入
X
\boldsymbol X
X形状由
32
×
32
32×32
32×32调整为
28
×
28
28×28
28×28,然后将2个下采样层实现为最大池化层(降低特征图的高、宽,后面会介绍),最后利用全连接层替换掉Gaussian connection层。下文统一称修改的网络也为LeNet-5网络。网络结构图如图所示:
加载数据集
我们基于MNIST手写数字图片数据集训练LeNet-5网络,并测试其最终准确度。前面已经介绍了如何在TensorFlow中加载MNIST数据集。详见深度学习(18)神经网络与全连接层一: 数据加载
# 加载MNIST数据集
(x, y), (x_test, y_test) = keras.datasets.mnist.load_data()
# 转换数据类型
# x: [0~255] => [0~1.]
x = tf.convert_to_tensor(x, dtype=tf.float32) / 255.
y = tf.convert_to_tensor(y, dtype=tf.int32)
x_test = tf.convert_to_tensor(x_test, dtype=tf.float32) / 255.
y_test = tf.convert_to_tensor(y_test, dtype=tf.int32)
# 创建数据集
train_db = tf.data.Dataset.from_tensor_slices((x, y)).batch(128)
test_db = tf.data.Dataset.from_tensor_slices((x_test, y_test)).batch(128)
创建网络
首先通过Sequential容器创建LeNet-5,代码如下:
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, Sequential, losses, optimizers, datasets
network = Sequential([ # 网络容器
layers.Conv2D(6, kernel_size=3, strides=1), # 第一个卷积层,6个3×3卷积核
layers.MaxPooling2D(pool_size=2, strides=2), # 高宽各减半的池化层
layers.ReLU(), # 激活函数
layers.Conv2D(16, kernel_size=2, strides=1), # 第二个卷积层,16个3×3卷积核
layers.MaxPooling2D(pool_size=2, strides=2), # 高宽各减半的池化层
layers.ReLU(), # 激活函数
layers.Flatten(), # 打平层,方便全连接层处理
layers.Dense(120, activation='relu'), # 全连接层,120个节点
layers.Dense(84, activation='relu'), # 全连接层,84个节点
layers.Dense(10), # 全连接层,10个节点
])
# build一次网格模型,给输入x的形状,其中4为随意给的batchsize
network.build(input_shape=(4, 28, 28, 1))
# 统计网格信息
network.summary()
运行结果如下图所示:
通过summary()函数统计出每层的参数量,打印出网格结构信息和每层参数数量详情,如下表所示,我们可以与全连接网络的参数量表进行比较。
层 | 卷积层1 | 卷积层2 | 全连接层1 | 全连接层2 | 全连接层3 |
---|---|---|---|---|---|
参数量 | 60 | 880 | 48120 | 10164 | 850 |
可以看到,卷积层的参数量非常少,主要的参数量集中在全连接层。由于卷积层将输入层特征维度降低很多,从而使得全连接层的参数量不至于过大,整个模型的参数量约60K,而全连接网络参数量达到了34万个,因此通过卷积神经网络可以显著降低网络参数量,同时增加网络深度。
训练阶段
在训练阶段,首先将数据集中shape为
[
b
,
28
,
28
]
[b,28,28]
[b,28,28]的输入
X
\boldsymbol X
X增加一个维度,调整shape为
[
b
,
28
,
28
,
1
]
[b,28,28,1]
[b,28,28,1],送入模型进行前向计算,得到输出张量output,shape为
[
b
,
10
]
[b,10]
[b,10]。我们新建交叉熵损失函数类(没错,损失函数也能使用类方式)用于处理分类任务,通过设定from_logits=True
标志位将softmax激活函数实现在损失函数中,不需要手动添加损失函数,提升数值计算稳定性。代码如下:
from tensorflow.keras import layers, Sequential, losses, optimizers, datasets
# 创建损失函数的类,在实际计算时直接调用实例即可
criteon = losses.CategoricalCrossentropy(from_logits=True)
训练部分实现如下:
# 训练部分实现如下
# 构建梯度记录环境
with tf.GradientTape as tape:
# 插入通道维度,=>[b,28,28,1]
x = tf.expand_dims(x, axis=3)
# 向前计算,获得10类别的概率分布,[b,784] => [b,10]
out = network(x)
# 真实标签one-hot编码,[b] => [b,10]
y_onehot = tf.one_hot(y, depth=10)
# 计算交叉熵损失函数,标量
loss = criteon(y_onehot, out)
获得损失值后,通过TensorFlow的梯度记录器tf.GradientTape()来计算损失函数loss对网络参数network.trainable_variables
之间的梯度,并通过optimizer对象自动更新网络权值参数。代码如下:
# 自动计算梯度
grads = tape.gradient(loss, network.trainable_variables)
# 自动更新参数
optimizer.apply_gradients(zip(grads, network.trainable_variables))
重复上述步骤若干次后即可完成训练工作
测试阶段
在测试阶段,由于不需要记录梯度信息,代码一般不需要写在with tf.GradientTape() as tape
环境中。前向计算得到的输出经过softmax函数后,代表了网络预测当前图片输入
x
\boldsymbol x
x属于类别i的概率
P
(
x
标
签
是
i
│
x
)
,
i
∈
[
0
,
9
]
P(x标签是i│x),i∈[0,9]
P(x标签是i│x),i∈[0,9]。通过argmax函数选取概率最大的元素所在的索引,作为当前x的预测类别,与真实标注y比较,通过计算比较结果中间True的数量并求和来统计预测正确的样本的个数,最后除以总样本的个数,得出网络的测试准确度。代码如下:
# 记录预测正确的数量,总样本数量
correct, total = 0, 0
for x, y in test_db: # 遍历所有训练集样本
# 插入通道维度,=>[b,28,28,1]
x = tf.expand_dims(x, axis=3)
# 向前计算,获得10类别的概率分布,[b,784] => [b,10]
out = network(x)
# 真实的流程时先经过softmax,再argmax
# 但是由于softmax不改变元素的大小相对关系,故省去
pred = tf.argmax(out,axis=-1)
y = tf.cast(y, tf.int64)
# 统计预测样本总数
correct += float(tf.reduce_sum(tf.cast(tf.equal(pred, y), tf.float32)))
# 统计预测样本总数
total += x.shape[0]
# 计算准确率
print('test acc:', correct/total)
在数据集上面循环训练30个Epoch后,网络的训练准确度达到了98.1%,测试准确度也达到了97.7%。对于非常简单的手写数字识别任务,古老的LeNet-5网络已经可以取得很好地效果,但是稍微复杂一点的任务,比如色彩动物图片识别,LeNet-5性能就会急剧下降。
完整代码
import os
from Chapter08 import metrics
from Chapter08.metrics import loss_meter
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, Sequential, losses, optimizers, datasets
# 加载MNIST数据集
def preprocess(x, y):
# 预处理函数
x = tf.cast(x, dtype=tf.float32) / 255
y = tf.cast(y, dtype=tf.int32)
return x, y
# 加载MNIST数据集
(x, y), (x_test, y_test) = keras.datasets.mnist.load_data()
# 创建数据集
batchsz = 128
train_db = tf.data.Dataset.from_tensor_slices((x, y))
train_db = train_db.map(preprocess).shuffle(60000).batch(batchsz).repeat(10)
test_db = tf.data.Dataset.from_tensor_slices((x_test, y_test))
test_db = test_db.batch(batchsz)
network = Sequential([ # 网络容器
layers.Conv2D(6, kernel_size=3, strides=1), # 第一个卷积层,6个3×3卷积核
layers.MaxPooling2D(pool_size=2, strides=2), # 高宽各减半的池化层
layers.ReLU(), # 激活函数
layers.Conv2D(16, kernel_size=2, strides=1), # 第二个卷积层,16个3×3卷积核
layers.MaxPooling2D(pool_size=2, strides=2), # 高宽各减半的池化层
layers.ReLU(), # 激活函数
layers.Flatten(), # 打平层,方便全连接层处理
layers.Dense(120, activation='relu'), # 全连接层,120个节点
layers.Dense(84, activation='relu'), # 全连接层,84个节点
layers.Dense(10), # 全连接层,10个节点
])
# build一次网格模型,给输入x的形状,其中4为随意给的batchsize
network.build(input_shape=(4, 28, 28, 1))
# 统计网络信息
network.summary()
# 创建损失函数的类,在实际计算时直接调用实例即可
criteon = losses.CategoricalCrossentropy(from_logits=True)
optimizer = optimizers.Adam(lr=0.01)
# 训练部分实现如下
# 构建梯度记录环境
# 训练20个epoch
def train_epoch(epoch):
for step, (x, y) in enumerate(train_db): # 循环优化
with tf.GradientTape() as tape:
# 插入通道维度,=>[b,28,28,1]
x = tf.expand_dims(x, axis=3)
# 向前计算,获得10类别的概率分布,[b,784] => [b,10]
out = network(x)
# 真实标签one-hot编码,[b] => [b,10]
y_onehot = tf.one_hot(y, depth=10)
# 计算交叉熵损失函数,标量
loss = criteon(y_onehot, out)
# 自动计算梯度
grads = tape.gradient(loss, network.trainable_variables)
# 自动更新参数
optimizer.apply_gradients(zip(grads, network.trainable_variables))
if step % 100 == 0:
print(step, 'loss:', loss_meter.result().numpy())
loss_meter.reset_states()
# 计算准确度
if step % 100 == 0:
# 记录预测正确的数量,总样本数量
correct, total = 0, 0
for x, y in test_db: # 遍历所有训练集样本
# 插入通道维度,=>[b,28,28,1]
x = tf.expand_dims(x, axis=3)
# 向前计算,获得10类别的概率分布,[b,784] => [b,10]
out = network(x)
# 真实的流程时先经过softmax,再argmax
# 但是由于softmax不改变元素的大小相对关系,故省去
pred = tf.argmax(out,axis=-1)
y = tf.cast(y, tf.int64)
# 统计预测样本总数
correct += float(tf.reduce_sum(tf.cast(tf.equal(pred, y), tf.float32)))
# 统计预测样本总数
total += x.shape[0]
# 计算准确率
print('test acc:', correct/total)
def train():
for epoch in range(30):
train_epoch(epoch)
if __name__ == '__main__':
train()