LeNet从零开始实现

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] x000yx000yx000yx000y0abc0
于是得到启发:卷积运算可以转化为矩阵的乘法。实际上,主流的深度学习框架实现卷积运算都是用这种思路实现的。

多维张量的卷积向矩阵乘法的转化比较复杂,我是参考下面这篇博文实现的。这篇文章详细讲解了numpy中的as_stride函数以及各参数的意义。该函数可以实现上述功能。pytorch库中也有类似的实现。博文较长,比较难懂,具体请查看链接。

numpy中一种卷积操作as_strided

#将卷积运算转化为矩阵乘法
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宽)
inputNx1x32x32
conv1Nx6x28x286x1x5x5 (s=1)
poolNx6x14x14
conv2Nx16x10x1016x6x5x5 (s=1)
poolNx16x5x5
flattenNx400
FC3Nx120400x120
FC4Nx84120x84
输出Nx1084x10

于是可根据上表确定初始化时各层的参数个数

#参数初始化
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个通道特征图,反而还看不出什么信息了,很迷惑。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值