LeNet-5
实现卷积网络模型LeNet-5
实现手写数字数据集MNIST的多分类任务
文章目录
以下是本篇文章正文内容
一、数据集加载
首先导入本实验需要用到的packages
import torch
import torchvision
import torchvision.transforms as transforms
from torch import nn
from matplotlib import pyplot as plt
import sys
torchvision.datasets提供了多种数据集的下载接口,该实验用到的MNIST可以通过torchvision.datasets.MNIST方法下载。
经过ToTensor格式转换后,MNIST变成了形状为“样本数x通道数x高x宽”的张量。
mnist_train=torchvision.datasets.MNIST(root='/Users/jinghe/Downloads',download=True,transform=transforms.ToTensor())
mnist_test=torchvision.datasets.MNIST(root='/Users/jinghe/Downloads',download=True,transform=transforms.ToTensor())
我们来看一下mnist_train训练集的前十个样本
#首先来看一下训练集的前十个样本
def show_images(imgs,labels):
_,figs=plt.subplots(1,len(imgs),figsize=(12,12))
for f, img, label in zip(figs, imgs, labels):
f.imshow(img.squeeze(0))
#每个样本的形状是1x28x28,squeeze(0)的作用在于将样本变为28x28
f.set_title(label)
f.axes.get_xaxis().set_visible(False)
f.axes.get_yaxis().set_visible(False)
plt.show()
imgs,labels=[],[]
for i in range(10):
imgs.append(mnist_train[i][0])
labels.append(mnist_train[i][1])
show_images(imgs,labels)
二、模型类
1.卷积层
卷积运算通俗地说在于滑动卷积窗口,提取图像的特征,那么滑动卷积窗口的操作不可避免的要利用for循环的嵌套,这在卷积核数目多,样本容量大的时候会很严重得降低程序运行效率。那么有没有方法从另一角度实现卷积运算呢?来看下面一个例子。
考虑一维的情况,[a,b,c]是一个时间序列,[x,y]是卷积核,那么滑动x,y的过程可用下图表示。
0 a b c 0
x y
x y
x y
x y
上面的过程实际上可以表示为两个矩阵的乘法,即
[
x
y
0
0
0
0
x
y
0
0
0
0
x
y
0
0
0
0
x
y
]
[
0
a
b
c
0
]
\left[ \begin{matrix} x & y & 0&0&0 \\ 0 & x & y&0&0 \\ 0 & 0 & x&y&0\\ 0&0&0&x&y\\ \end{matrix} \right] \left[ \begin{matrix} 0\\ a\\ b\\ c\\ 0\\ \end{matrix} \right]
⎣⎢⎢⎡x000yx000yx000yx000y⎦⎥⎥⎤⎣⎢⎢⎢⎢⎡0abc0⎦⎥⎥⎥⎥⎤
于是得到启发:卷积运算可以转化为矩阵的乘法。实际上,主流的深度学习框架实现卷积运算都是用这种思路实现的。
多维张量的卷积向矩阵乘法的转化比较复杂,我是参考下面这篇博文实现的。这篇文章详细讲解了numpy中的as_stride函数以及各参数的意义。该函数可以实现上述功能。pytorch库中也有类似的实现。博文较长,比较难懂,具体请查看链接。
#将卷积运算转化为矩阵乘法
def split_by_strides(X, kh, kw, step):
'''
该函数本质上是对输入张量的reshape,但方式有点特殊
输入张量:样本数x通道数x高x宽
kh,kw是卷积核的尺寸
setp是卷积核的滑动步长
'''
N, C, H, W = X.shape
oh = (H - kh) // step + 1
ow = (W - kw) // step + 1
strides = (*X.stride()[:-2], X.stride()[-2]*step, X.stride()[-1]*step, *X.stride()[-2:])
A = torch.as_strided(X, size=(N,C,oh,ow,kh,kw), stride=strides)
return A
来看一下split_by_strides的实现效果:
x=torch.randint(5,(2,1,3,3))
#生成随机张量x,2个样本,维度为1,3x3大小
print(x)
tensor([[[[4, 1, 2],
[1, 2, 4],
[1, 1, 0]]],
[[[3, 1, 4],
[2, 3, 3],
[1, 2, 4]]]])
y=split_by_strides(x,kh=2,kw=2,step=1)
#将输入张量x按照2x2的卷积核大小,步长为1做分块
print(y)
tensor([[[[[[4, 1],
[1, 2]],
[[1, 2],
[2, 4]]],
[[[1, 2],
[1, 1]],
[[2, 4],
[1, 0]]]]],
[[[[[3, 1],
[2, 3]],
[[1, 4],
[3, 3]]],
[[[2, 3],
[1, 2]],
[[3, 3],
[2, 4]]]]]])
可以看到,reshape后的得到的张量y满足我们的预期。
至此,实现卷积类最关键的一步已经解决,接下来可以正式定义卷积类了
#定义卷积类,继承自nn.Module
class Conv2d(nn.Module):
'''
kernel.shape=(channel_out,channel_in,kh,kw)
'''
def __init__(self,w,b):
super(Conv2d,self).__init__()
self.kernel=w
self.bias=b
def forward(self,x):
channel_out,channel_in,kh,kw=self.kernel.shape
A=split_by_strides(x, kh, kw, step=1)
result=torch.tensordot(A,self.kernel,dims=([1,4,5],[1,2,3]))
#torch.tensor可以实现两个矩阵在指定维度上的相乘运算
result=result.transpose(1,3)
result=result.transpose(2,3)
#输出张量的形状为:样本数x通道数x高x宽
return result+self.bias
2.池化层
池化层不包含参数,只需要指定模版大小和步幅,以及池化模式——平均池化或最大池化。LeNet中的池化采用2x2大小,步幅为2,平均池化模式。
#定义池化层类
class Pool2d(nn.Module):
'''
池化层没有参数
'''
def __init__(self,mode='avg'):
super(Pool2d,self).__init__()
self.mode=mode
def forward(self,x):
A=split_by_strides(x, 2, 2, step=2) #池化层2x2,步幅为2
if self.mode=='avg':
Y=torch.mean(A,dim=(4,5),keepdim=True) #平均池化
return Y.reshape(x.shape[0],x.shape[1],x.shape[2]//2,x.shape[3]//2)
elif self.mode=='max': #最大池化
A=A.reshape(A.shape[0],A.shape[1],A.shape[2],A.shape[3],4,-1)
Y,_=torch.max(A,dim=4,keepdim=True)
return Y.reshape(x.shape[0],x.shape[1],x.shape[2]//2,x.shape[3]//2)
3.全连接层
创建全联接层的实例时,需要传入权重和偏差参数,然后采用y=xw+b的形式计算输出
#定义线性层类
class FC(nn.Module):
'''
输入张量:样本数x特征数
'''
def __init__(self,w,b):
super(FC,self).__init__()
self.w=w
self.b=b
def forward(self,x):
#y=xw+b
return torch.matmul(x,self.w)+self.b
4 利用上述实现好的模块搭建模型
创建LeNet实例时,需要传入全部的参数,这里将模型用到的参数封装在字典中,在创建实例时,只需要传入一个字典即可。该字典中w1,w2分别是第一、第二卷积层的卷积核,w3,w4,w5是后接的全连接层的权重参数。
#将上述实现的卷积类、池化类、线性层类合并为模型类
class LeNet(nn.Module):
def __init__(self,parameters):
'''
定义模型的实例时,需要将初始化得到的参数传入模型
'''
super(LeNet,self).__init__()
self.w1=parameters['w1']
self.b1=parameters['b1']
self.w2=parameters['w2']
self.b2=parameters['b2']
self.w3=parameters['w3']
self.b3=parameters['b3']
self.w4=parameters['w4']
self.b4=parameters['b4']
self.w5=parameters['w5']
self.b5=parameters['b5']
self.conv1=Conv2d(self.w1,self.b1)
self.activation=nn.Sigmoid()
#self.activation=nn.ReLU()
self.pool=Pool2d(mode='avg')
self.conv2=Conv2d(self.w2,self.b2)
self.hidden1=FC(self.w3,self.b3)
self.hidden2=FC(self.w4,self.b4)
self.hidden3=FC(self.w5,self.b5)
def forward(self,x):
feature_map1=self.activation(self.pool(self.conv1(x)))
feature_map2=self.activation(self.pool(self.conv2(feature_map1)))
FC_input=feature_map2.reshape(x.shape[0],-1)
A1=self.activation(self.hidden1(FC_input))
A2=self.activation(self.hidden2(A1))
A3=self.activation(self.hidden3(A2))
#A3作为模型的输出张量,形状为:样本数x10
#A3没有经过softmax,因为后续计算损失时包含了softmax单元
return A3
三、参数初始化
在进行参数初始化前需要明确LeNet的结构,各层的尺寸。
层 | 激活后维度 (样本数x通道数x高x宽) | 卷积核尺寸 (输出通道x输入通道x高x宽) |
---|---|---|
input | Nx1x32x32 | |
conv1 | Nx6x28x28 | 6x1x5x5 (s=1) |
pool | Nx6x14x14 | |
conv2 | Nx16x10x10 | 16x6x5x5 (s=1) |
pool | Nx16x5x5 | |
flatten | Nx400 | |
FC3 | Nx120 | 400x120 |
FC4 | Nx84 | 120x84 |
输出 | Nx10 | 84x10 |
于是可根据上表确定初始化时各层的参数个数
#参数初始化
def initialize_parameters(seed=1):
'''
根据各层的张量形状,初始化权重核偏置
w1,b1,w2,b2分别是第一个卷即层和第二个卷即层的权重和偏置
w3,b3,w4,b4,w5,b5分别是后续全联接层的权重和偏置
'''
parameters={}
torch.manual_seed(seed=seed)
parameters['w1']=torch.normal(mean=0.0,std=1.0,size=(6,1,5,5),requires_grad=True)
parameters['b1']=torch.zeros(6,1,1,requires_grad=True)
parameters['w2']=torch.normal(mean=0.0,std=1.0,size=(16,6,5,5),requires_grad=True)
parameters['b2']=torch.zeros(16,1,1,requires_grad=True)
parameters['w3']=torch.normal(mean=0.0,std=1.0,size=(5*5*16,120),requires_grad=True)
parameters['b3']=torch.zeros(1,120,requires_grad=True)
parameters['w4']=torch.normal(mean=0.0,std=1.0,size=(120,84),requires_grad=True)
parameters['b4']=torch.zeros(1,84,requires_grad=True)
parameters['w5']=torch.normal(mean=0.0,std=1.0,size=(84,10),requires_grad=True)
parameters['b5']=torch.zeros(1,10,requires_grad=True)
return parameters
至此,模型的搭建和参数的初始化已经准备完毕,在正式开始训练前还需要做些准备工作
四、一些辅助的函数
1. 填充
MNIST中的图像是28x28大小,LeNet的输入为32x32,因此我们需要对原始图像进行填充,这里采用零填充。
def pad(x):
m=nn.ZeroPad2d(padding=(2,2,2,2))
return m(x)
2.模型评估函数
当模型训练结束后,需要在测试集上测试其性能,这里以准确率作为评价指标。接下来定义一个评价函数,返回的是预测准确的样本对总样本数的占比。
#定义准确率评估参数
def evaluate_accuracy(net,data_iter):
'''
correct是预测准确的个数、
samples是数据集样本总数
'''
correct=0.0
samples=0.0
for x,y in data_iter:
x=pad(x)
y_hat=net(x)
correct+=float((torch.argmax(y_hat,dim=1)==y).sum())
#torch.argmax可以得到指定维度上的最大元素的角标
samples+=len(y)
return correct/samples*100 #乘100是为了得到百分比
3.批量加载迭代器
本实验后续采用随机批量梯度下降训练模型,而且训练样本需要被随机打乱,torch.utils.DataLoader()方法可以方便地实现该需求。
#定义批量加载的迭代器
def data_iter(mnist_train,mnist_test,batch_size=256):
'''
在实际训练时,数据的加载速度可以很大程度上影响模型的训练速度
mac电脑可以多线程加载
'''
if sys.platform.startswith=='win':
num_worker=0
else:
num_workers=4
train_iter=torch.utils.data.DataLoader(mnist_train,batch_size=256,shuffle=True,num_workers=num_workers)
test_iter=torch.utils.data.DataLoader(mnist_test,batch_size=1,shuffle=True,num_workers=num_workers)
return train_iter,test_iter
五、训练
parameters=initialize_parameters()
net=LeNet(parameters)
optimizer=torch.optim.Adam([parameters['w1'],parameters['w2'],parameters['w3'],parameters['w4'],parameters['w5'],parameters['b1'],parameters['b2'],parameters['b3'],parameters['b4'],parameters['b5']])
loss=torch.nn.CrossEntropyLoss()
train_iter,test_iter=data_iter(mnist_train,mnist_test,batch_size=256)
train_loss=[]
train_loss=train_model(train_iter,net,loss,optimizer,num_epochs=1)
plt.plot(train_loss)
plt.show()
上面的训练样本遍历次数为1(num_epochs=1),这是因为我没有GPU,计算机的算力比较小。在提交的main.py文件中,num_epochs默认设为了2。
本次实验得到的损失曲线如下图
由于采用小批量梯度下降算法,因此该曲线出现了锯齿。
测试集上的准确率为:
#测试
print('测试集上的准确率:'+str(evaluate_accuracy(net,test_iter)))
测试集上的准确率:80.58%
六、对特征图的观察
好奇心驱使我对卷积得到的特征图进行观察。
首先来看一下测试集的前十个样本
imgs,labels=[],[]
for i in range(10):
imgs.append(mnist_test[i][0])
labels.append(mnist_test[i][1])
_,ax=plt.subplots(1,10,figsize=(12,12))
for f ,img, label in zip(ax,imgs,labels):
f.imshow(img.squeeze(0))
f.get_xaxis().set_visible(False)
f.get_yaxis().set_visible(False)
f.set_title(label)
plt.show()
接下来取第一张图片(7),观察其特征图有什么特点。
注:这里说的特征图指的是卷积、激活、池化后的结果,模型中涉及两个卷积层,分别表示为:
feature_map1=activation(pool(conv1(x))) feature_map2=activation(pool(conv(feature_map1))) |
---|
#取测试集第一个样本,输出其特征图
x=imgs[0]
x=x.reshape(1,1,28,28)
x=pad(x)
print(torch.argmax(y_hat,dim=1))
#注意这里用到的net与前文定义的LeNet类略有不同
#这里用的net将特征图和y_hat都输出
#前文定义的LeNet类只将y_hat输出
#提交的main.py只输出y_hat
feature_map1,feature_map2,y_hat=net(x)
观察第一卷积层的输出:
#第一个卷积层有6个特征图
feature_map1=feature_map1.squeeze(0)
_,ax=plt.subplots(1,6,figsize=(12,12))
for f ,img in zip(ax,feature_map1):
f.imshow(img.squeeze(0).detach().numpy())
f.get_xaxis().set_visible(False)
f.get_yaxis().set_visible(False)
plt.show()
可以看到,第一个卷积层已经能够明显地看出数字7的轮廓。
再来看第二个卷积层的输出:
#第二个卷积层有16张特征图
feature_map2=feature_map2.squeeze(0)
_,ax=plt.subplots(2,8,figsize=(12,12))
for index, img in zip(range(16),feature_map2):
ax[index//8,index%8].imshow(img.squeeze(0).detach().numpy())
ax[index//8,index%8].get_xaxis().set_visible(False)
ax[index//8,index%8].get_yaxis().set_visible(False)
plt.show()
上图是第二个卷积层的16个通道特征图,反而还看不出什么信息了,很迷惑。