1 实验介绍
数字识别是计算机从纸质文档、照片或其他来源接收、理解并识别可读的数字的能力,目前比较受关注的领域是手写数字识别。手写数字识别是一个典型的图像分类问题,已经被广泛应用于汇款单号识别、手写邮政编码识别等领域,大大缩短了业务处理时间,提升了工作效率和质量。
本实验基于MNIST数据集,通过搭建神经网络完成手写数字识别模型的训练,并通过测试集与自制手写数字图片对模型性能进行评估。其中:
- 任务输入:一系列手写数字图片,其中每张图片都是28 x 28的像素矩阵。
- 任务输出: 经过了大小归一化和居中处理,输出对应的0~9的数字标签。
实验总体过程和步骤如图1所示
2 数据准备
2.1 数据集介绍
MNIST数据集包含60000个训练集和10000测试数据集。分为图片和标签,图片是28x28的像素矩阵,标签为0~9共10个数字
2.2 数据读取与数据增强
本实验使用百度飞桨内置的数据集接口paddle.vision.datasets.MNIST 获取训练数据和测试数据。导入数据后,首先对训练集和测试集进行归一化操作,将均值与标准差均设置为127.5。
为提升模型的泛化能力,选择对训练集进行数据增强。飞桨提供的数据处理相关操作包括随机裁剪(RandomCrop)、随机旋转 (RandomRotation)、调整图像对比度(ContrastTransform)等,考虑到手写数字图像均为灰度图,且图片翻转对识别结果可能产生影响 (如数字6和9),因而选择使用 Resize 函数对训练图像进行适当放大,再通过 RandomCrop 函数对放大后的图像进行随机裁剪,以达到数据增强的效果。
img_size=28#导入数据集Compose的作用是将用于数据集预处理的接口以列表的方式进行组合。#导入数据集Normalize的作用是图像归一化处理,支持两种方式: 1. 用统一的均值和标准差值对图像的每个通道进行归一化处理; 2. 对每个通道指定不同的均值和标准差值进行归一化处理。from paddle.vision.transforms import Compose, Resize, RandomCrop, Normalize
# 对数据集进行归一化及数据增强
transform_train = Compose([Resize((img_size+4, img_size+4)),
RandomCrop(img_size),
Normalize(mean=[127.5],std=[127.5])])
transform_test = Compose([Resize((img_size, img_size)),
Normalize(mean=[127.5], std=[127.5])])# 导入数据
train_data = paddle.vision.MNIST(mode='train', transform=transform_train)
test_data = paddle.vision.MNIST(mode='test', transform=transform_train)print('加载完成')
3 网络配置
本实验提供的神经网络模型为多层感知机(MLP),在此基础上,考虑到卷积神经网络在特征提取、图像识别等方面的优越性,选择构建经典的LeNet网络进行训练,同时本实验自定义了一个卷积神经网络,进一步探索手写数字的预测能力,由于手写数字图像为灰度图,识别难度较低,故本实验不考虑使用更加复杂的模型。
3.1 MLP
3.1.1 MLP工作原理
多层感知机(MLP)是一种前馈神经网络,由输入层、若干个隐藏层和输出层组成。每一层都由多个神经元组成。MLP一般用于分类问题,可以通过反向传播算法进行训练。在深度学习领域,MLP是一种基础结构,被广泛应用于图像识别、自然语言处理等领域。
图3 MLP网络结构
激活函数:ReLU
R
e
L
U
(
x
)
=
m
a
x
(
0
,
x
)
ReLU(x)=max(0,x)
ReLU(x)=max(0,x)
前向传播:在多层感知机中,前向传播用于将输入数据传递至输出层。设输入数据为
x
x
x,第
i
i
i层隐藏层的输出为
h
i
(
x
)
h_i(x)
hi(x),第
l
l
l层隐藏层到第
l
l
l+1层隐藏层之间的权重矩阵为
W
l
W_l
Wl,第
l
l
l层隐藏层的偏置为
b
l
b_l
bl,则前向传播的计算过程如下:
h
1
(
x
)
=
f
(
W
1
x
+
b
1
)
h_1(x)=f(W_1x+b_1)
h1(x)=f(W1x+b1)
h
l
(
x
)
=
f
(
W
l
h
l
−
1
(
x
)
+
b
l
)
h_l(x)=f(W_lh_{l-1}(x)+b_l)
hl(x)=f(Wlhl−1(x)+bl)
y
(
x
)
=
f
(
W
L
h
L
−
1
(
x
)
+
b
L
)
y(x)=f(W_Lh_{L-1}(x)+b_L)
y(x)=f(WLhL−1(x)+bL)
反向传播:在多层感知机中,反向传播用于更新模型参数,求解损失函数的梯度。设损失函数为L,输出为y,真实标签为t,则反向传播的计算过程如下:
δ
L
=
o
L
d
y
f
′
(
W
L
h
L
−
1
(
x
)
+
b
L
)
\delta _ {L} = \frac {oL}{dy} f'( W_ {L} h_ {L-1} (x)+ b_ {L} )
δL=dyoLf′(WLhL−1(x)+bL)
δ
l
=
a
L
o
h
l
f
′
(
W
l
+
1
T
δ
l
+
1
)
\delta _ {l} = \frac {aL}{oh_ {l}} f'( W_ {l+1}^ {T} \delta _ {l+1} )
δl=ohlaLf′(Wl+1Tδl+1)
d
L
o
W
l
=
h
l
−
1
δ
T
l
\frac {dL}{oW_ {l}} = h_ {l-1} \delta \frac {T}{l}
oWldL=hl−1δlT
δ
L
o
b
l
=
δ
l
\frac {\delta L}{ob_ {l}} = \delta _ {l}
oblδL=δl
其中,
δ
L
\delta _ {L}
δL为输出层误差,
δ
l
\delta _ {l}
δl为第
l
l
l层隐藏层误差,
f
f
f′为激活函数的导数。
损失函数:在多层感知机中,常用的损失函数有均方误差(MSE)和交叉熵(cross-entropy)。
交叉熵损失函数定义如下:
C
E
(
y
,
t
)
=
−
∑
i
=
1
n
t
i
log
(
y
i
)
CE(y,t)=- \sum _ {i=1}^ {n} t_ {i} \log _ {(y_ {i})}
CE(y,t)=−i=1∑ntilog(yi)
其中,n样本数,y为模型预测结果,t为真实标签。
3.1.2 多层感知机代码实现
classMLP(nn.Layer):def__init__(self):super(MLP,self).__init__()
self.flatten = nn.Flatten()# 将输入数据展成一维
self.fc1 = nn.Linear(in_features=784, out_features=100)
self.fc2 = nn.Linear(in_features=100, out_features=100)
self.fc3 = nn.Linear(in_features=100, out_features=10)defforward(self, x):
x = self.flatten(x)
x = self.fc1(x)
x = F.relu(x)# 使用Relu激活函数
x = self.fc2(x)
x = F.relu(x)# 使用Relu激活函数
x = self.fc3(x)return x
将模型与输入数据的形状作为参数传入 paddle.summary 函数,得到网络基础结构与参数信息如下:
3.2 LeNet
3.2 LeNet工作原理
LeNet-5的设计主要是为了解决手写识别问题。那时传统的识别方案很多特征都是hand-crafted,识别的准确率很大程度上受制于所设计的特征,而且最大的问题在于手动设计特征对领域性先验知识的要求很高还耗时耗力,更别谈什么泛化能力,基本上只能针对特定领域。
- 输入尺寸:28*28
- 卷积层:3个
- 降采样层:2个
- 全连接层:1个
- 输出:10个类别(数字0-9的概率)
LeNet-5是一个7层卷积神经网络,包含C1、S2、C3、S4、C5/F5、F6、F7(C为卷积层,S为池化(pooling)层,F为全连接层)
输入层:相关数据(集)的输入与读取
1、C1层:
Tips:LeNet激活函数默认为Sigmoid,这里使用ReLU
参数:num_kernels=6, kernel_size=5×5, padding=0, stride=1
补充:特征图大小计算如下式:
o
u
t
p
u
t
h
=
(
i
n
p
u
t
h
+
2
x
p
a
d
d
i
n
g
h
−
k
e
r
n
e
l
h
)
/
s
t
r
i
d
e
+
1
output_h=(input_h+2xpadding_h-kernel_h)/stride+1
outputh=(inputh+2xpaddingh−kernelh)/stride+1
o
u
t
p
u
t
w
=
(
i
n
p
u
t
w
+
2
x
p
a
d
d
i
n
g
w
−
k
e
r
n
e
l
w
)
/
s
t
r
i
d
e
+
1
output_w=(input_w+2xpadding_w-kernel_w)/stride+1
outputw=(inputw+2xpaddingw−kernelw)/stride+1
对输入图像进行第一次卷积运算,得到6个C1特征图(6个大小为28 *28的 feature maps, 32-5+1=28)。
2、S2层:
Tips:LeNet默认使用平均池化。
参数:kernel_size=2×2, padding=0, stride=2
第一次卷积之后紧接着就是池化运算,得到了6个14 14的特征图(28/2=14)。S2这个pooling层是对C1中的22区域内的像素求和乘以一个权值系数再加上一个偏置,然后将这个结果再做一次映射。
3、C3层:
参数:num_kernels=16, kernel_size=5×5, padding=0, stride=1
然后进行第二次卷积,输出是16个10x10的特征图. 我们知道S2 有6个 14*14 的特征图,这里是通过对S2 的特征图特殊组合计算得到的16个特征图。
4、S4层:
参数:kernel_size=2×2, padding=0, stride=2
处理和连接过程与S2类似,得到16个5x5的特征图。
5、C5/F5层:
参数:num_kernels=120,kernel_size=5×5, padding=0, stride=1,out_features=120
C5层是一个卷积层(实际上可以理解为全连接层)。由于S4层的16个图的大小为5x5,与卷积核的大小相同,所以卷积后形成的图的大小为1x1。形成120个卷积结果。
6、F6层:
参数:out_features=64
计算方法:计算输入向量和权重向量之间的点积,再加上一个偏置,结果通过sigmoid函数输出。F6层有64个节点,-1表示白色,1表示黑色,这样每个符号的比特图的黑白色就对应于一个ASCII编码
7、F7层:
参数:out_features=10
Output层(F7层)共有10个节点,分别代表数字(类别)0到9,且如果节点i的值为0,则网络识别的结果是数字i。采用的是径向基函数(RBF)的网络连接方式,即为其损失函数。假设x是上一层的输入,y是RBF的输出,RBF输出的计算方式如式
y
i
=
∑
j
(
x
j
−
w
i
,
j
)
2
y_i=\sum_j(x_j-w_{i,j})^2
yi=j∑(xj−wi,j)2
补充:原论文中的损失函数采用MSE,并添加了一个惩罚项,计算公式如式
E
(
W
)
=
1
P
∑
v
=
1
P
(
y
D
p
(
Z
p
,
W
)
l
o
g
(
)
E(W)=\frac{1}{P} \sum _ {v=1}^ {P}(y_{D^p}(Z^p,W)log()
E(W)=P1v=1∑P(yDp(Zp,W)log()
Tips:由于算法年代比较久远且意义重大,故LeNet代码在网上有各种不同实现,但未必是对论文的完全还原(大部分是基于后续的tricks进行了优化),在此贴出一类优秀、简洁的代码实现(去掉了最后的高斯激活,其余与论文保持一致),来自李沐老师:
6.6. 卷积神经网络(LeNet) — 动手学深度学习 2.0.0 documentation (d2l.ai)
3.2.2 LeNet代码实现
在卷积神经网络的实现上,核心是网络的定义,根据LeNet-5的原理与各层参数,代码实现(Paddle版)如下:
classLeNet(nn.Layer):def__init__(self, num_classes=10):super(LeNet, self).__init__()# 卷积层
self.conv1 = nn.Conv2D(in_channels=1, out_channels=6, kernel_size=5)
self.max_pool1 = nn.MaxPool2D(kernel_size=2, stride=2)
self.conv2 = nn.Conv2D(in_channels=6, out_channels=16, kernel_size=5)
self.max_pool2 = nn.MaxPool2D(kernel_size=2, stride=2)
self.conv3 = nn.Conv2D(in_channels=16, out_channels=120, kernel_size=4)# 全连接层
self.fc1 = nn.Linear(in_features=120, out_features=64)
self.fc2 = nn.Linear(in_features=64, out_features=num_classes)defforward(self, x):
x = self.conv1(x)
x = F.relu(x)# 使用Relu激活函数
x = self.max_pool1(x)
x = self.conv2(x)
x = F.relu(x)# 使用Relu激活函数
x = self.max_pool2(x)
x = self.conv3(x)
x = paddle.reshape(x,[x.shape[0],-1])# 将CHW列展成一维
x = self.fc1(x)
x = F.relu(x)# 使用Relu激活函数
x = self.fc2(x)return x
将模型与输入数据的形状作为参数传入 paddle.summary 函数,得到网络基础结构与参数信息如下:
可以看到,引⼊卷积层后,尽管网络深度较MLP有所增加,但其参数量大幅下降。
3.3 自定义卷积神经网络
3.3.1 自定义网络工作原理
所搭建的网络不包括输入层的情况下,共有7层:5个卷积层、2个全连接层 其中第一个卷积层的输入通道数为数据集图片的实际通道数。MNIST数据集为灰度图像,通道数为1 第1个卷积层输出与第3个卷积层输出做残差作为第4个卷积层的输入,第4个卷积层的输入与第5个卷积层的输出做残差作为第1个全连接层的输入
3.3.2 自定义卷积网络代码实现
import paddle
import paddle.nn.functional as F
# 定义多层卷积神经网络#动态图定义多层卷积神经网络classMyNet(paddle.nn.Layer):def__init__(self):super(MyNet, self).__init__()
self.conv1 = paddle.nn.Conv2D(in_channels=1, out_channels=6, kernel_size=5, padding=2)
self.conv2 = paddle.nn.Conv2D(6,16,3, padding=1)
self.conv3 = paddle.nn.Conv2D(16,32,3, padding=1)
self.conv4 = paddle.nn.Conv2D(6,32,1)
self.conv5 = paddle.nn.Conv2D(32,64,3, padding=1)
self.conv6 = paddle.nn.Conv2D(64,128,3, padding=1)
self.conv7 = paddle.nn.Conv2D(32,128,1)
self.maxpool1 = paddle.nn.MaxPool2D(kernel_size=2, stride=2)
self.maxpool2 = paddle.nn.MaxPool2D(2,2)
self.maxpool3 = paddle.nn.MaxPool2D(2,2)
self.maxpool4 = paddle.nn.MaxPool2D(2,2)
self.maxpool5 = paddle.nn.MaxPool2D(2,2)
self.maxpool6 = paddle.nn.MaxPool2D(2,2)
self.flatten=paddle.nn.Flatten()
self.linear1=paddle.nn.Linear(128,128)
self.linear2=paddle.nn.Linear(128,10)
self.dropout=paddle.nn.Dropout(0.2)
self.avgpool=paddle.nn.AdaptiveAvgPool2D(output_size=1)defforward(self, x):
y = self.conv1(x)#(bs 6, 32, 32)
y = F.relu(y)
y = self.maxpool1(y)#(bs, 6, 16, 16)
z = y
y = self.conv2(y)#(bs, 16, 16, 16)
y = F.relu(y)
y = self.maxpool2(y)#(bs, 16, 8, 8)
y = self.conv3(y)#(bs, 32, 8, 8)
z = self.maxpool4(self.conv4(z))
y = y+z
y = F.relu(y)
z = y
y = self.conv5(y)#(bs, 64, 8, 8)
y = F.relu(y)
y = self.maxpool5(y)#(bs, 64, 4, 4)
y = self.conv6(y)#(bs, 128, 4, 4)
z = self.maxpool6(self.conv7(z))
y = y + z
y = F.relu(y)
y = self.avgpool(y)
y = self.flatten(y)
y = self.linear1(y)
y = self.dropout(y)
y = self.linear2(y)return y
将模型与输入数据的形状作为参数传入 paddle.summary 函数,得到网络基础结构与参数信息如下:
4 模型训练
首先使用 paddle.Model 函数对模型进行封装,并指定训练设备为GPU。接着设置模型优化器为 Adam,并采用 stepDecay 方法作为学习率衰减策略,使学习率按照指定的轮数间隔进行衰减。为动态追踪训练过程,使用VisualDL对训练过程进行可视化,同时为了节省训练时间,增加训练停止策略,若模型效果在一定轮次后仍无提升,则提前终止训练。最后,使用Model.prepare 方法,设置损失函数为 交叉熵,评价指标为准确率,向 Model.fit 函数传入必要参数,开启训练过程。
# 封装模型
model = paddle.Model(MyNet)# 设置学习率衰减及优化策略from paddle.optimizer.lr import StepDecay
learning_rate_decay = StepDecay(learning_rate=learning_rate,
step_size=step_size,
gamma= gamma)
optim = paddle.optimizer.AdamW(learning_rate=learning_rate_decay,weight_decay=weight_decay,parameters=model.parameters())# 设置可视化回调及停止训练回调
visualDL = paddle.callbacks.VisualDL(log_dir=save_dir+'/visualDL')
earlyStop = paddle.callbacks.EarlyStopping(monitor='acc',
patience=patience)
callbacks =[visualDL, earlyStop]# 配置模型
model.prepare(optim,
nn.CrossEntropyLoss(),
paddle.metric.Accuracy())# 训练并保存模型
model.fit(train_data,
test_data
epochs=epoch,
batch_size=batch_size,
save_dir=save_dir+'/model/v1',
verbose=1,
shuffle=True,
num_workers=2,
callbacks=callbacks)
初始化参数如下表:
在初始参数条件下进行训练,得到MLP、LeNet和MyNet的训练结果如下:
为使模型达到最佳效果,需对传入参数进行调整。分析各参数对训练过程的影响: epoch 越大,训练时间越长,模型达到最佳效果的概率越大,但由于引入了patience 参数,因而选代次数可以适当取小;batch_size 越大,模型每一次学习到的数据特征越接近总体数据,但同时也会使得step减少,学习率无法有效衰减; learning_rate、gamma 、 step_size 共同影响训练时的学习率,主要通过其他参数的变化以及训练过程中准确率的变化趋势对其进行动态调整。
根据上述思路,对不同模型分别进行调参,直到最佳准确率趋于稳定。
4.1 MLP训练与调参过程
4.1.1 对batch_size的调参过程
本实验从2-256,取值均为2的幂,每个Batch_size进行10次实验取平均值,数据汇总于上表,Batch_size对MLP效果的影响如图:
分析:根据Batch_size的消融实验可以发现MLP的acc随Batch_size的增长不断上升再下降(但Batch_size小的时候收敛速度大大降低),本实验中较好的Batch_size为32或64,而非256,test_acc可从0.8681上升至0.9549。
而在现实情况中,一般不会单独考虑/改变lr或Batch_size,两者是有很强联系的。所以目前深度学习模型多采用批量随机梯度下降算法进行优化。
学习率直接影响模型的收敛状态,batchsize则影响模型的泛化性能,两者又是分子分母的直接关系,相互也可影响。
4.1.2 对learning_rate的调参过程
本实验分别选取了七个学习率参数进行训练。
分析:随着学习率的增加,test_accuracy先上升再下降,最佳学习率取在0.001附近
4.1.3 最佳参数选取
训练结果显示:
最终结果:
4.2 LeNet训练与调参过程
4.2.1 对激活函数的调参过程
网络参数中卷积类型、池化方式、激活函数等选择都会影响模型效果,故对激活函数和池化方式进行改变,并进行对比实验。
LeNet-5的激活函数为Sigmoid,池化方式为平均池化。
首先我们改变激活函数为目前主流的Relu(当时并没有),不同激活函数的定义对比下图:
#sigmoid
nn.Conv2d(1,6, kernel_size=5, padding=2), nn.Sigmoid()#Relu
__nn__.Conv2d(1,6, kernel_size=5, padding=2), nn.ReLU()
激活函数改变后进行10次实验取平均数据结果如下图:
分析:我们发现卷积层的激活函数从Sigmoid改成Relu后LeNet的acc从0.809上升至0.884,验证了Relu的优化能力(更优激活函数)。
4.2.2 对池化方式进行改变
将池化方式改为目前主流的最大池化(当时并没有),代码如下:
#平均池化
nn.AvgPool2d(kernel_size=2, stride=2)#最大池化
nn.MaxPool2d(kernel_size=2, stride=2)
池化方式改变后进行10次实验取平均数据:
分析:我们发现池化方式从平均池化改成最大池化后LeNet的指标均相对稳定,故本实验两种池化方式均可,当然有时间的情况下可以继续测试混合池化、随机池化等方法,在此不做展开。
4.2.4 最佳参数选取
最终结果:
训练过程:
4.3 MyNet训练与调参过程
最优参数值:
最
终结果:
训练过程:
5 模型评价与预测
接下来使用自己制作的手写数字图片按检测模型的预测效果,手写数字图片如图所示:
使用彩色和彩笔书写数字,进一步验证泛化能力
将图片上传至平台,调整大小为28 x 28并转化为灰度图,将其形状变为(1,1,28,28)以匹配模型的输入格式,再将图片数据归一化到(-1,1)区间,导入模型进行预测。
预测代码如下:
INFER_LIST =[("./data/num/0.png","0"),("./data/num/1.png","1"),("./data/num/2.png","2"),("./data/num/3.png","3"),("./data/num/4.png","4"),("./data/num/5.png","5"),("./data/num/6.png","6"),("./data/num/7.png","7"),("./data/num/8.png","8"),("./data/num/9.png","9")]for num_data_path, data_label in INFER_LIST:# 加载和预处理测试数据
num_data = Image.open(num_data_path)# 加载
num_data = num_data.resize((28,28))# 调整图像大小为(28, 28)
num_data = np.array(num_data.convert('L'))#转换为灰度图 # 预处理手写数字
num_data = num_data.astype('float32')# 归一化像素值
num_data=((255-num_data)/127.5)-1.0# 转黑底白字、数据归一化
plt.figure(figsize=(2,2))#展示测试集中的第一个图片print(plt.imshow(num_data, cmap=plt.cm.binary))#plt.show(num_data)print('num_data 的标签为: '+str(data_label))
num_data = np.expand_dims(num_data, axis=0)
num_data = np.expand_dims(num_data, axis=0)
num_data = np.expand_dims(num_data, axis=0)
result = model.predict(num_data, batch_size=32)#打印模型预测的结果print('test_data0 预测的数值为:%d'% np.argsort(result[0][0])[0][-1])
plt.show()
6 结果分析
在训练初期,学习率较大,模型准确率相对较低,逐渐降低学习率后,模型准确率均有所上升。
因而在模型训练时,应尽量避免设置较大的学习率。
经过多轮调参后,MLP、LeNet和自定义卷积模型在测试集上的准确率分别能达到97%和99%和99%以上。由于只选取了部分参数进行调整,且调参轮数偏少,因此模型还存在一定的提升空间。
7 实验总结
- 第一次尝试使用飞桨高层API实现深度学习训练,虽然花费了大量时间进行学习、尝试与调整,但对深度学习的流程有了更深刻的理解,积累了大量宝贵的经验,使后续的实验能更顺畅、高效地进行。
- 在最初设置学习率衰减时,程序总会在进行到特定阶段时报错,经过一番调试之后发现由于使用的学习率衰减策略为 ExponentialDecay ,导致学习率按指数函数衰减,最终学习率过小使得程序终止,改选合适的衰减策略后,问题得以解决。
- 引入 Earlystopping 策略后,训练过程往往会在达到指定的迭代次数前提早结束,表明训练轮数并非设置得越大越好,最佳结果往往不一定出现在最后一轮:同时也体现出监控训练过程的重要性,若发现模型效果没有继续提升,就应及时中止训练,节省时间和算力。
- 在最初进行预测时,模型的预测结果与实际结果差异非常大,分析后发现,自制的数据是白底黑字,而从样例数据来看,MNIST数据集内大多数图片应该是黑底白字,对图片归一化步骤进行微调 (加个负号) 后,预测结果明显准确很多。实验过程中,我认为最重要的一点就是读API文档。通过查阅官方文档,才能准确掌握各个接口的详细用法,包括参数、输入、输出等,同时也能够学习到许多操作的便捷实现方0
版权归原作者 error 10086. 所有, 如有侵权,请联系我们删除。