基于LeNet-5的手写数字识别实战


图像识别是计算机视觉最常用的任务之一,几乎所有的有关图像识别的教程都会将MNIST 数据集作为入门数据集,因为MNIST 数据集是图像识别问题中难度最小、特征差异较为明显的数据集,非常适合作为图像识别入门者的学习案例。本案例使用MNIST 数据集,基于LeNet-5 网络实现手写数字的识别任务。

本节中的所有代码在Ubuntu 20.04+Python 3.8.10+TensorFlow 2.9.1+Keras 2.9.0 环境中实测通过 ,具体代码详见LeNet_MNIST.py 文件,读者可以直接使用。

6.3.1  MNIST 数据集简介

MNIST 的全称是Modified National Institute of Standards and Technology ,其中美国国家标准与技术研究所(NIST )是美国商务部下属的一个研究机构,MNIST 数据集是这个机构通过收集不同人的手写数字进行整理得到的。

MNIST 数据集由训练集(Training Set )和测试集(Test Set )两部分构成,其中训练集有60 000 幅手写数字图片和标签,由250 个不同的人手写的数字构成,测试集有10 000 幅手写数字图片和标签。这些手写数字图片的内容为0~9 10 个数字,都是28 ×28 像素大小的灰度图,灰度图中每个像素都是一个0~255 的灰度值。

MNIST 数据集自1998 年起,被广泛地应用于机器学习和深度学习领域,用来测试算法的效果,如果一个图像识别算法在MNIST 数据集上效果差,那么在其他数据集上的表现效果也不会很好。

MNIST 数据集可以通过MNIST 官网下载。当然,目前许多深度学习框架已经内置了MNIST 数据集,并且有相关的函数直接读取并划分数据集。如图6.29 所示为MNIST 数据集中部分手写数字的可视化图像展示。

6.29  MNIST 数据集中部分手写数字的可视化图像能展示

本案例使用LeNet-5 网络实现对上述MNIST 数据集图片中数字0~9 的识别。

6.3.2  加载和预处理数据

1. 数据加载

MNIST 数据集已经被集成在TensorFlow Keras 框架中,可以使用Keras 模块的mnist.load_data() 函数直接加载,由于MNIST 数据集由TensorFlow 提前规划好,该函数会分别返回训练集数据和标签(train_images,train_labels )、测试集数据和标签(test_images,test_labels )。

其中,train_images 是一个60 000 ×28 ×28 的三维矩阵,第一维60 000 代表样本量,其余两维为图片长×宽的像素矩阵,因为只是灰度图,所以没有通道数。

其中,train_labels 是一个大小为60 000 的一维数组,分别表示这60 000 幅图片是数字0~9 中的哪一个。

2. 数据预处理

数据加载之后需要进行必要的预处理,因为此时的train_images train_labels test_images test_labels 都不满足LeNet-5 TensorFlow 的数据要求。

l   LeNet-5 的每个输入数据应为32 ×32 ×1 的三维数据,train_images test_images 的每个样本数据没有通道数,需要扩展一个通道数,可以调用reshape() 函数扩展到需要的维度。

l   train_images test_images 每个像素灰度值是一个0~255 的整数,为了使模型的优化算法更容易收敛,需要将其调整为0~1 的浮点数。

l   本案例是一个多分类识别问题,LeNet-5 网络要求对应的分类标签使用On-Hot 编码形式,需要将train_labels test_labels 从整数调整为One-Hot 数组,可以调用内置于Keras to_categorical() 函数实现One-Hot 编码。

3. 代码示例

【例6.12 MNIST 数据加载和预处理。

import tensorflow as tf
from keras.utils import np_utils
#加载和预处理数据
def load_images_data():
    #加载图像和标签数据
    (train_images,train_labels),(test_images,test_labels) = tf.keras.datasets.mnist.load_data()  
    print("train_images:", train_images.shape)
    print("train_labels:", train_labels.shape)
    print("test_images:", test_images.shape)
    print("test_labels:", test_labels.shape)
    #预处理数据
    N0 = train_images.shape[0]
    N1 = test_images.shape[0]
    print(N0,N1)
    train_images = train_images.reshape(N0,28,28,1)
    train_images = train_images.astype('float32') / 255
    train_labels = np_utils.to_categorical(train_labels)
    test_images = test_images.reshape(N1,28,28,1)
    test_images = test_images.astype('float32') / 255
    test_labels = np_utils.to_categorical(test_labels)
    return train_images,train_labels,test_images,test_labels

输出结果如图6.30 所示。

6.30  输出结果

6.3.3  创建LeNet-5 模型

完成了数据加载和预处理工作,接下来用代码实现LeNet-5 模型的网络结构搭建。

1. 网络参数设计

参考上一节对LeNet-5 网络结构的介绍,本案例对各层的参数设置如下:

l   输入层:一幅28 ×28 的灰度图像,只有一个通道,输入矩阵大小为28 ×28 ×1

l   第一个卷积层:使用6 5 ×5 ×1 的卷积核进行same 卷积。由于输入的是灰度图,因此卷积核的深度是1 ;又由于使用same 卷积,因此卷积后的输出矩阵维度为28 ×28 ×6 (因为用了6 个卷积核)。

l   第一个池化层:使用6 2 ×2 大小的矩阵进行最大值池化处理,输出结果矩阵为14 ×14 ×6

l   第二个卷积层: 使用 16 5 × 5 × 6 的卷积核进行 valid 卷积,输出结果矩阵为 10 × 10 × 6

l   第二个池化层:同样使用6 2 ×2 的最大值池化,输出矩阵的维度为5 ×5 ×16

l   全连接层:将上一个池化层输出的矩阵拉直成一维向量,向量大小为5 ×5 ×16=400 ,第一个隐藏层使用120 个神经元,第二个隐藏层使用84 个神经元。

l   输出层:因为网络模型的目的是识别0~9 的数字,处理的是一个10 分类的问题,所以其输入层有10 个神经元。

【例6.13 】网络参数和训练参数的定义。

#输入层大小
INPUT_SHAPE = (28,28,1)
#第一个卷积层的卷积核的大小和数量
CONV1_SIZE = 5
CONV1_NUM = 6
#第二个卷积层的卷积核的大小和数量
CONV2_SIZE = 5
CONV2_NUM = 16
#池化层窗口大小
POOL_SIZE = 2
#全连接层节点个数
FC1_SIZE = 120
FC2_SIZE = 84
#输出个数
OUT_SIZE = 10
#训练参数
EPOCH_SIZE = 20
BATCH_SIZE = 200

2. 构建LeNet-5 网络模型

LeNet-5 是一个卷积神经网络,包含一些卷积、池化、全连接的简单线性堆积。我们知道多个线性层堆叠实现的仍然是线性运算,添加层数并不会扩展假设空间(从输入数据到输出数据的所有可能的线性变换集合),因此还需要添加非线性的激活函数。

两个卷积层conv1 conv2 是图像与卷积核卷积后得到的特征图,激活函数可以理解为再对卷积结果进行一个范围限制,ReLU 是最常用的激活函数。

对于最后的输出层,我们需要从输出的10 个特征维度中选取最大的那一个,为了达到这个目的,需要把它们转换为一个和为1 的概率形式,以方便后续使用相应的损失函数,来评估模型预测结果的优劣以及与目标结果(标签)的差异,因此可以选择使用Softmax 激活函数。

有了前面设计的网络结构参数,使用TensorFlow Keras 框架的models 模块、layer 模块,可以非常方便、快速地构建网络。

【例6.14 】创建LeNet-5 模型。

from keras import models
from keras import layers
#创建LeNet-5网络
def build_LeNet5():
    model = models.Sequential()
    #第一层:卷积层
       model.add(layers.Conv2D(filters=CONV1_NUM,kernel_size=(CONV1_SIZE, CONV1_SIZE),padding="same",activation='relu',input_shape=INPUT_SHAPE,name="layer1-conv1"))
    #第二层:最大池化层
    model.add(layers.MaxPooling2D(pool_size=(POOL_SIZE,POOL_SIZE), name="layer2-pool"))
    #第三层:卷积层
    model.add(layers.Conv2D(filters=CONV2_NUM,kernel_size=(CONV2_SIZE, CONV2_SIZE),padding="valid",activation='relu',name="layer3-conv2"))
    #第四层:最大池化层
    model.add(layers.MaxPooling2D(pool_size=(POOL_SIZE,POOL_SIZE), name="layer4-pool"))
    model.add(layers.Flatten(name="layer4-flatten"))
    #第五层:全连接层
    model.add(layers.Dense(units=FC1_SIZE,activation='relu',name="layer5-fc1"))
    model.add(layers.Dense(units=FC2_SIZE,activation='relu',name="layer5-fc2"))
    #第六层:Softmax输出层
    model.add(layers.Dense(units=OUT_SIZE,activation='softmax',name="layer6-fc"))
    return model

创建完成后,可以使用model.summary() 函数输出模型的概要内容,输出结果如图6.31 所示。

6.31  输出结果

上述概要中列出了各层中需要训练的参数个数,从中可以发现卷积层和池化层相较于全连接层来说,极大地减少了参数的数量。读者还可以自行画出上述网络并手工计算和理解所需的参数。

6.3.4  编译和训练模型

1. 模型编译

模型编译通过model.compile() 函数实现。需要告诉TensorFlow 这是一个多分类问题,它的损失函数(用于计算预测值与目标值之间的差距)使用categorical_crossentropy (交叉熵损失函数),优化器(用于指定梯度下降更新参数的具体方法)使用Adam Adam 是目前深度学习中图像分类相关任务中最常用的优化器算法,是一种优秀的自适应学习率的方法),需要监控预测精度以评价模型性能指标,因此评价指标(Metrics )(用于评价模型在训练和测试时的性能指标)设置为Accuracy (精度)。

2. 模型训练

模型训练(拟合)通过model.fit() 函数实现。需要告诉TensorFlow 使用的训练数据 x 和对应标签 y 、测试(验证)的数据和其对应标签validation_data ,指定进行循环的次数epochs 以及批量处理的批量数据大小batch_size 。设置批量处理的意义在于,由于深度学习网络模型在单个数据上并不是特别稳定,为了保证训练出来的模型稳定,在数据上会进行批量归一化处理,每次选取一批数据进行归一化,弱化噪声数据对模型训练的影响。

3. 代码示例

【例6.15 】编译和训练模型。

#模型训练
def train_LeNet5(model,train_data,train_labels,test_data,test_labels):
    model.compile(loss='categorical_crossentropy',optimizer='adam', metrics=['accuracy'])
    history = model.fit(x=train_data,y=train_labels,epochs=EPOCH_SIZE, batch_size=BATCH_SIZE,validation_data=[test_data,test_labels])
    return history

输出结果如图6.32 所示。

6.32  模型训练

上述结果实时显示了每轮epoch 执行时训练数据和验证数据的损失值(Loss )和预测精度(Accuracy ),这些数据保存在fit() 返回的history 数据中,可以通过如下代码直观地画出其曲线图并将图形保存为文件。

【例6.16 】图形化显示训练结果。

from matplotlib import pyplot as plt
#绘制loss和accuracy
def draw_history(history):
 
    loss = history.history['loss']
    accuracy = history.history['accuracy']
    val_loss = history.history['val_loss']
    val_accuracy = history.history['val_accuracy']
    epochs = range(1, len(loss) + 1)
 
    #draw loss with epoch
    plt.subplot(2,2,1)
    plt.plot(epochs,loss,'bo')
    plt.title("Training loss")
    plt.xlabel('Epoch')
    plt.ylabel('Loss')
 
    #draw accuracy with epoch
    plt.subplot(2,2,2)
    plt.plot(epochs,accuracy,'bo')
    plt.title("Training accuracy")
    plt.xlabel('Epoch')
    plt.ylabel('Accuracy')
 
    #draw val_loss with epoch
    plt.subplot(2,2,3)
    plt.plot(epochs,val_loss,'bo')
    plt.title("Validate loss")
    plt.xlabel('Epoch')
    plt.ylabel('Loss')
 
    #draw val_accuracy with epoch
    plt.subplot(2,2,4)
    plt.plot(epochs,val_accuracy,'bo')
    plt.title("Validate accuracy")
    plt.xlabel('Epoch')
    plt.ylabel('Accuracy')
 
    plt.tight_layout()
    plt.show()
 
    #save to file
    plt.savefig(fname="LetNet5-history.png",format='png')

执行结果如图6.33 所示。

从执行结果可以看出,该模型经过训练(拟合)后,模型的识别精度(Accuracy )可以达到99% 。同时可以从曲线上大体看出,增加epoch 循环次数时,随着在训练数据集上的精度(Training accuracy )不断提高,在验证数据集上的精度(Validate Accuracy )并没有不断提高,所以Epoch batch_size 会对模型的性能产生一定的影响,需要反复尝试选择合理的数值。

6.33  模型训练结果

本文节选自《 Python 深度学习原理、算法与案例》,内容发布获得作者和出版社授权。

 


请使用浏览器的分享功能分享到微信等