卷积神经网络

(本文与多层感知机一样,为本人在学习《动手学深度学习》这本书中的笔记,和在学习过程中产生的一些自己的想法,并且尝试离开原书所配的d2l包,来实现相关的算法,如有不严谨或错漏之处,敬请指导。同时,受限于篇幅,更加详细的内容请移步《动手学深度学习》)


目录

为什么要有卷积神经网络

图像的数据格式

灰度图

彩色图

全连接网络处理图像时的缺陷

有关卷积的基础知识

卷积的数学原理

一维卷积

二维卷积

互相关运算

互相关运算的实现

为什么深度学习中的卷积其实是互相关运算

卷积神经网络

用卷积层来代替全连接层

卷积层的实现

一个简单的训练

填充,步幅与空洞卷积

多输入多输出通道

多输入通道

多输出通道

汇聚层

最大池化和平均池化

LeNet

LeNet的结构

加载数据集

模型训练

模型训练过程中产生的问题

解决方案1:Xavier 初始化和动量

Xavier 初始化

动量

总体解决方案

解决方案2:将激活函数由Sigmoid换为ReLU

原理

可视化结果

总体代码

Xavier 初始化和动量版本

ReLU函数版本

实验结果


为什么要有卷积神经网络

……(图像数据)每个样本都由一个二维像素网格组成,每个像素可能是一个或者多个数值,取决于是黑白还是彩色图像。到目前为止,我们处理这类结构丰富的数据的方式还不够有效。我们仅仅通过将图像数据展平成一维向量而忽略了每个图像的空间结构信息,再将数据送入一个全连接层的多层感知机中。

——节选自《动手学深度学习》

这是李沐老师在《动手学深度学习》一书中《卷积神经网络》一章开篇提到的问题。李沐老师以全连接网络在处理图像时的核心出现,以引入卷积神经网络,为此,在实现卷积神经网络以前,我们需要先来了解一下图像和卷积的相关知识。

图像的数据格式

灰度图

灰度图的数据可以看作一个H\times W大小的二维矩阵。其中,H代表图像在垂直方向上的像素数量(高度),W代表水平方向上的像素数量(宽度)。矩阵中的每个元素都是一个取值范围在0~255之间的整数,成为像素值或着灰度级,用于量化对应像素点处的亮度。通常,0代表纯黑,255代表纯白,中间值则代表着不同深浅的灰色。例如,我们可以自己绘制一个5×5的灰度图:

import numpy as np
import matplotlib.pyplot as plt

gray_image = np.array([
[0, 50, 100, 150, 200],
[10, 60, 110, 160, 210],
[20, 70, 120, 170, 220],
[30, 80, 130, 180, 230],
[40, 90, 140, 190, 240]
])

plt.imshow(gray_image, cmap='gray', vmin=0, vmax=255)
cbar = plt.colorbar()
cbar.set_label('Pixel intensity', fontfamily='Times New Roman', fontsize=12)
plt.title('Gray Image', fontfamily='Times New Roman', fontsize=14)
plt.axis('off')  
plt.show()

灰度图的图像如下图所示:

彩色图

如下图所示,彩色图的数据本质上是一个三维数组,可以视作三个独立的二维矩阵(分别对应红、绿、蓝三个通道)沿着通道方向堆叠而成,其形状通常记为H\times W \times C或者C\times H \times W(有时也写作W \times H \times C),其中HW的定义与灰度图中相同,C为通道数(RGB中的通道数为3)。具体的维度顺序取决于所使用的框架或着库,如TensorFlow / Keras 和 OpenCV 默认采用H\times W \times C(高度-宽度-通道),而PyTorch和许多学术模型通常采用C\times H \times W(通道-高度-宽度)。

(注:本图引用自【科普向】图像的数据结构

全连接网络处理图像时的缺陷

根据图像的数据格式可知,图像数据本质上是一个严格按照网格排列的多维数字矩阵。这表明图像所承载的信息并非只来自单个孤立的像素值,更来自于相邻像素之间的空间关联。正是这些局部依赖关系构成了边缘、纹理、形状等关键视觉结构。然而,在传统的全连接网络中,常规做法是先将图像矩阵“展平”为一个一维向量,再输入网络。这一操作会彻底摧毁像素间的空间结构:原本在二维平面上相邻、具有强相关性的像素被分散到向量中毫无位置关系的不同维度,迫使网络只能将每个像素视为独立的特征,从而丧失了捕捉局部模式和平移不变性的能力,导致参数冗余(如,一张100*100*3的图像,需要30000个神经元)、效率低下且极易过拟合。

有关卷积的基础知识

卷积的数学原理

一维卷积

想象以下这样一个场景,一个信号发生器在每个时刻t产生一个信号x_t,其信息的衰减率为w_k,即在k-1个时刻以后,信息为原来的w_k倍,即在时刻t收到的信号y_t为当前时刻信息和以前时刻信息的叠加,即:

y_t = w_1x_t+w_2x_{t-1}+w_3x_{t-2}\cdots+w_kx_{t-k+1}+\cdots

其中,w_1, w_2, w_3\cdots为卷积核,也叫滤波器,假设滤波器的长度为K,它和一个信号序列x_1, x_2\cdots的卷积可以写为:

y_t=\sum_{k=1}^{K}w_kx_{t-k+1}

(注:如果求和符号是k=1的话,说明这个索引是从1开始的,如果求和符号是k=0的话,说明这个索引是从0开始的,下面的二维卷积类似,这只是符号上的区别,需要根据编程语言确定,索引到底是从0还是1开始的)

信号序列\boldsymbol{x}和卷积核\boldsymbol{w}的卷积定义为:

y=\boldsymbol{w}*\boldsymbol{x}

其中*为卷积操作,一般来说卷积核的长度要远小于信号序列的长度。

我们可以设计不同的卷积核来提取信号的不同特征,如当w_1=w_2=w_3\cdots=w_K=1/K时,就相当于对信号序列进行一个简单的移动平均操作。

二维卷积

给定一个图像\boldsymbol{X}\in\mathbb{R}^{M\times N},卷积核\boldsymbol{W}\in\mathbb{R}^{U\times V},一般有U\ll M, V\ll N,和一维卷积类似,图像和卷积核的卷积可以写为:

y_{ij}=\sum_{u=1}^{U}\sum_{v=1}^{V}w_{uv}x_{i-u+1,j-v+1}

输入信息\boldsymbol{X}\in\mathbb{R}^{M\times N}和卷积核\boldsymbol{W}\in\mathbb{R}^{U\times V}的卷积定义为:

y=\boldsymbol{W}*\boldsymbol{X}

如下图是一个二维卷积的示例:

(注:本图来自通俗理解【卷】积+互相关与卷积

互相关运算

上面我们讲述了有关卷积的运算,然而,在我们阅读的大量教材中,我们发现,所谓的卷积,其实是“互相关运算”,如下图所示是一个互相关运算的实例。

给定一个图像\boldsymbol{X}\in\mathbb{R}^{M\times N},卷积核\boldsymbol{W}\in\mathbb{R}^{U\times V},一般有U\ll M, V\ll N,图像和卷积核的互相关可以写为:

y_{ij}=\sum_{u=1}^{U}\sum_{v=1}^{V}w_{uv}x_{i+u-1,j+v-1}

输入信息\boldsymbol{X}\in\mathbb{R}^{M\times N}和卷积核\boldsymbol{W}\in\mathbb{R}^{U\times V}的卷积定义为:

y=\boldsymbol{W}\bigotimes \boldsymbol{X}

对比上图有关卷积运算的实例,我们可以看到,卷积运算需要将卷积核沿空间轴旋转180°(即上下、左右翻转)后,再与输入进行滑动点积;而互相关则直接使用原始核进行滑动点积,不进行翻转。在上图的互相关实例中,可以看到输出每个位置的值就是核与输入窗口逐元素相乘再求和,核始终保持原样。这正是互相关。而真正的卷积运算若用同一个核,则需要先将核翻转为对称形式再做同样操作,结果会有所不同。
两者的联系在于:对同一个输入和核,互相关等价于将核翻转后再做卷积,卷积也等价于将核翻转后再做互相关。 因此,从数学表达上二者只差一个简单的坐标变换,没有本质的信息丢失。

(注:此图来自Convolutional Neural Networks - Basics

互相关运算的实现

上一小节中,互相关的代码运算,如下所示:

import torch
from torch import nn

def corr2d(X,K):
    h, w = K.shape
    Y = torch.zeros((X.shape[0]-h+1,X.shape[1]-w+1))
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            Y[i,j] = (X[i:i+h,j:j+w]*K).sum()
    return Y

X = torch.tensor([[0,0,75,80,80],
                  [0,75,80,80,80],
                  [0,75,80,80,80],
                  [0,70,75,80,80],
                  [0,0,0,0,0]])

K = torch.tensor([[-1,-2,-1],
                  [0,0,0],
                  [1,2,1]])

y = corr2d(X,K)

print(y)

上述代码是李沐老师在《动手学深度学习中》给出的互相关操作的代码,实际上,pytorch中内置了实现相同操作的nn.Conv2d函数,下面,我们尝试用nn.Conv2d来实现这样一个互相关的操作:

import torch
from torch import nn

X = torch.tensor([[0,0,75,80,80],
                  [0,75,80,80,80],
                  [0,75,80,80,80],
                  [0,70,75,80,80],
                  [0,0,0,0,0]],dtype=torch.float32)

K = torch.tensor([[-1,-2,-1],
                  [0,0,0],
                  [1,2,1]],dtype=torch.float32)
conv = nn.Conv2d(in_channels=1,out_channels=1,kernel_size=3,bias=False)
conv.weight.data=K.reshape(1,1,3,3)

X_4d = X.reshape(1,1,5,5)
y_conv = conv(X_4d)

print(y_conv)

在这里,我们介绍一下nn.Conv2d函数:

torch.nn.Conv2d(
    in_channels,
    out_channels,
    kernel_size,
    stride=1,
    padding=0,
    dilation=1,
    groups=1,
    bias=True,
    padding_mode='zeros',
    device=None,
    dtype=None
)

nn.Conv2d函数输入一个形状为\left ( N,C_{in},H,W \right )的张量,输出一个形状\left ( N,C_{out},H_{out},W_{out} \right )的张量,其中,N为批次大小,即一次输入到网络中的样本数量,C_{in}为输入特征图的通道数,对于一个灰度图来说,通道数即为1,对于一个RGB图像而言,通道数为3,H为输入特征图的行数,W为特征图的列数,输出形状参数的意义类似。对于nn.Conv2d函数,具体的参数如下:

  1. in_channels(输入通道数):指输入特征图的通道数。
  2. out_channels(输出通道数):卷积层中卷积核的个数,也就是输出特征图的通道数。out_channels的数量表示,要用多少个不同的卷积核,输出多少个不同特征通道的特征图。
  3. kernel_size(卷积核尺寸):可以是单个整数(表示正方形的核)或着\left(h,w \right )元组。
  4. stride(步长):控制卷积核每次滑动的像素数,默认stride=1。
  5. padding(填充):在输出四边补零的圈数,默认padding=0。
  6. dilation(空洞卷积/膨胀):控制卷积核点之间的间距,默认dilation=1。如果dilation=2,3\times 3大小的卷积核的感受野将扩大到5\times 5,但是参数的数量不变。
  7. groups(分组卷积):将输入通道与输出通道分成若干组,组内进行独立卷积。默认groups=1。
  8. bias(偏置):是否学习一个附加的偏置项,默认bias=True。
  9. padding_mode(填充模式):确定padding的模式,默认padding=0。

下面是stride,padding,dilation的可视化展示:

stride=1

stride=2

padding

dilation

 更详细的内容可以看Conv2d的官方文档教程。

(注:在学习深度学习的过程中,浏览官方文档对于提升水平至关重要,在pytorch官网中,在Docs选项卡下点击pytorch,点开torch.nn即可看到官方文档)

为什么深度学习中的卷积其实是互相关运算

深度学习框架(如 PyTorch、TensorFlow)中的“卷积层”实际上实现的是互相关运算,却依然沿用“卷积”这一名称,主要有两个原因:

学习能力等价:卷积核的权重是通过训练自动学习得到的,如果问题需要的是真正的卷积,网络完全有能力学到一个“翻转后”的等效核,省去翻转步骤不会牺牲任何表达能力。

计算实现更简洁高效:互相关直接滑动窗口相乘累加,核的索引总是非负的,避免了卷积定义中可能出现的负索引问题,也省去了显式翻转核的内存和计算开销,更利于底层优化(如 im2col、Winograd 等)。因此,从实际应用角度看,深度学习中所谓的“卷积”本质上就是互相关,这一习惯叫法已经成为领域共识。

卷积神经网络

用卷积层来代替全连接层

对于一个全连接神经网络,如果第l-1层有M_{l-1}个神经元,第l层有M_l个神经元,这样的话,这两层之间就有M_{l-1}\times M_l个连接,即权重矩阵有M_{l-1}\times M_l个参数,如果M_{l-1}M_l都很大的话,那么这个参数量就非常大,会严重降低训练效率。

对于一个卷积神经网络来说,第l层的输入,即第l-1层的输出\boldsymbol{y}^{\left(l-1 \right )},是第l-1层的输入\boldsymbol{\alpha}^{\left(l-1 \right )}和卷积核\boldsymbol{w}^{\left(l \right )}进行互相关运算后,加上偏置\boldsymbol{b}^{\left(l \right )},得到了对应的输出,即:

\boldsymbol{y}^{\left(l-1 \right )}=\boldsymbol{w}^{\left(l \right )}\bigotimes \boldsymbol{\alpha}^{\left(l-1 \right )}+\boldsymbol{b}^{\left(l \right )}

和全连接层相比,卷积层具有局部连接权重共享的特点,局部连接指的是卷积层输出特征上的每一个位置,只和输入中的一个局部窗口相连,而不是和输入的所有像素相连,这样就极大的减少了连接数。同时,整张特征图的各个位置完全使用相同的卷积核,这就说明,无论输入的图像有多大,参数量只和卷积核的数量有关。

卷积层的实现

卷积层对输入和卷积核权重进行互相关运算,并且在添加偏置以后产生输出。因此,卷积层中,被训练的参数是卷积层的权重和偏置,在训练基于卷积层的模型时,我们也需要初始化卷积核的权重。如下所示是在李沐老师的corr2d函数上,实现的一个卷积层。

import torch
from torch import nn
import torch.nn.functional as F

class Conv2D(nn.Module):
    def __init__(self,kernel_size):
        super().__init__()
        self.weight = nn.parameter(torch.rand(kernel_size))
        self.bias = nn.parameter(torch.zeros(1))

    def forward(self,x):
        return corr2d(x,self.weight)+self.bias

一个简单的训练

以李沐老师《动手学深度学习》中6.2.3节为例,在给定输入X和输出Y后,我们希望能够学习到一个1\times 2的卷积核。

import torch
from torch import nn
import torch.nn.functional as F

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

def corr2d(X,K):
    h, w = K.shape
    Y = torch.zeros((X.shape[0]-h+1,X.shape[1]-w+1))
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            Y[i,j] = (X[i:i+h,j:j+w]*K).sum()
    return Y


X = torch.ones((6, 8))
X[:, 2:6] = 0
K = torch.tensor([[1.0,-1.0]])
Y = corr2d(X, K)

conv2d = nn.Conv2d(1,1,kernel_size=(1,2),bias=False)

X = X.reshape((1,1,6,8))
Y = Y.reshape((1,1,6,7))
lr = 3e-2

for i in range(10):
    Y_hat = conv2d(X)
    loss = (Y_hat-Y)**2
    conv2d.zero_grad()
    loss.sum().backward()

    conv2d.weight.data[:]-=lr*conv2d.weight.grad
    if (i+1)%2==0:
        print(f'epoch {i+1}, loss {loss.sum():.3f}')

print(conv2d.weight.data.reshape((1, 2)))

最终,我们可以看到,输出的结果为tensor([[ 0.9950, -0.9753]]),和指定的K基本一致。

填充,步幅与空洞卷积

有关填充(padding),步幅(stride)和空洞卷积(dilation),在前文中已经有了较为详细的介绍,具体可以查阅Conv2d的官方文档教程,具体而言,填充的作用是在图像的边缘补充信息,以保护图像的边缘信息(通俗理解就是原来的卷积核在图像边缘时可能无法完成卷积运算,在原有图像外加一圈),步幅的作用为对特征图进行下采样,降低计算量并扩大感受野(通俗理解就是跳过一些像素),空洞本质是在卷积核内部插入“空洞”,不改变特征图的分辨率,它在保持特征图尺寸不变的情况下扩大感受野。最直接的用法为,填充(padding),步幅(stride)和空洞卷积(dilation)可以确定一个卷积层的输出,公式如下:

H_{out}=\left \lfloor \frac{H_{in}+2\times \text{padding}[0]-\text{dilation}[0]\times\left(\text{kernelsize}[0] -1\right )-1}{\text{stride}[0]}+1 \right \rfloor\\ W_{out}=\left \lfloor \frac{W_{in}+2\times \text{padding}[1]-\text{dilation}[1]\times\left(\text{kernelsize}[1] -1\right )-1}{\text{stride}[1]}+1 \right \rfloor\\

其中\left \lfloor x \right \rfloor代表取整运算

多输入多输出通道

上面举的例子中,所用的数据都是单通道的,而当数据为多通道时(例如RGB彩色图像),且需要在多个通道中进行输出时,需要对卷积核的性质进行进一步的研究。

多输入通道

如图所示,为李沐老师在6.4.1节中所举的两输入通道,一输出通道的例子,李沐老师已经给出了手动实现的详细代码。而我们在实际应用中,大多是直接使用nn.Conv2d函数,因此,我们展示一下如何使用nn.Conv2d函数直接输出:

import torch
from torch import nn

X = torch.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]])
K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])

conv = nn.Conv2d(in_channels=2,out_channels=1,kernel_size=2,bias=False)
conv.weight.data = K.reshape((1,2,2,2))
X = X.reshape((1,2,3,3))

y = conv(X)

print(y)

这里最重要的就是,nn.Conv2d函数中\left ( N,C,H,W \right )各应当取什么,我们可以看到,李沐老师的图片中已经明确的给我们说明了,输入是一个样本,两个通道,每个通道的高和宽均为3,输出为一个样本,一个通道,高和宽均为2,没有填充,步幅和空洞卷积。且卷积核的通道数为2,宽和高均为2。实际上,卷积核的输入通道数始终由输入数据的通道数决定,而卷积核的空间尺寸则需结合填充、步幅、空洞率以及输入输出尺寸共同推算。

多输出通道

对于一个多输出问题来说,假设有个c_{in}个输入通道,c_{out}个输出通道,那就说明,每个输出通道里,都要有一个c_{in}\times H \times W的卷积核,这就说明,卷积核的总尺寸为c_{out}\times c_{in}\times H \times W,仍然以李沐老师在6.4.2节中所举的示例为例,使用nn.Conv2d函数直接实现的步骤为:

import torch
from torch import nn

X = torch.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]])
K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])
K = torch.stack((K, K + 1, K + 2), 0)

conv = nn.Conv2d(in_channels=2,out_channels=3,kernel_size=2,bias=False)
conv.weight.data=K

y = conv(X)
print(y)

汇聚层

汇聚层,也称作池化层,是卷积神经网络中的另一个核心要素,池化的根本目的是在保留重要特征的前提下,压缩特征图的空间尺寸。常见的池化有最大池化和平均池化两类,最大池化即取窗口内的最大值,平均池化即取窗口内的平均值,下图所展示的,是最大池化和平均池化的简单示例。

具体而言,池化层的作用有:

  1. 池化后特征图的宽和高会显著减小(比如 2×2 最大池化使尺寸减半),这样后续卷积层的输入变小,权重数量、计算量和内存占用都成倍下降,让网络可以堆得更深而不过于昂贵。
  2. 空间尺寸缩小后,同一层卷积核能“看到”的原始输入范围就变大了,有助于网络逐步捕获更大范围的上下文和语义信息
  3. 池化只关心局部区域内的“最大值”或“平均值”,而不在乎特征的精确位置。因此,输入图像发生微小平移、旋转或形变时,池化后的输出可能依然相似,增强模型对位置变化的鲁棒性(这对分类任务尤其重要)。
  4. 通过压缩特征图,池化强制让网络学习更抽象、更概括的特征,而不是死记硬背细节位置,相当于一种结构化的正则化,能改善泛化能力。

最大池化和平均池化

如图所示,为李沐老师所举的最大池化的示例,在这里,我们使用nn.MaxPool2d和nn.AvgPool2d函数,来实现类似的操作,他们的技术文档分别在nn.MaxPool2d以及nn.AvgPool2d

import torch
from torch import nn

X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
X = X.reshape(1,1,3,3)
max_pool = nn.MaxPool2d(kernel_size=(2,2),stride=1)
avg_pool = nn.AvgPool2d(kernel_size=(2,2),stride=1)
y_max_pool = max_pool(X)
y_avg_pool = avg_pool(X)
print(y_max_pool)
print(y_avg_pool)

这里需要注意到的是,不论是nn.MaxPool2d还是nn.AvgPool2d,他们的stride的默认值和kernel_size保持一致,若要达到李沐老师所展现的结果,需要人为设定stride=1.

同时,还要注意的是,池化操作中,也有填充和步幅,以及多入多出问题,这些处理方式和nn.Conv2d类似。

LeNet

LeNet是最早发布的神经网络之一,这个模型是AT&T的贝尔实验室的研究员Yann LeCun在1989年提出的,目的是为了识别图像中的手写数字,目前,这种算法正广泛用于ATM机中,帮忙识别处理支票中的数字。

LeNet的结构

如图所展示的,即为LeNet的结构,每个卷积块的基本单元是由一个卷积层,一个sigmoid激活函数和平均汇聚层,即池化层组成的。每个卷积层使用一个5\times 5的卷积核,和一个sigmoid激活函数,这些层将输入映射到多个二维特征输出,通常同时增加通道的数量。同时,每个池化层,通过空间下采样将维数减少4倍。卷积的输出形状由批量大小,通道数,高度,宽度共同决定。

同时,为了将卷积块的输出传递给全连接快,我们必须在小批量中展平每个样本。即我们整个LeNet的前向传播流程为:

为此,我们可以在Pytorch上实现这个网络:

import torch
from torch import nn
from torch.nn import Conv2d, Sigmoid, AvgPool2d, Flatten, Linear

class my_LeNet(nn.Module):
    def __init__(self):
        super(my_LeNet,self).__init__()
        self.conv1 = Conv2d(in_channels=1,out_channels=6,kernel_size=5,padding=2)
        self.sigmoid1 = Sigmoid()
        self.pool1 = AvgPool2d(kernel_size=2,stride=2)
        self.conv2 = Conv2d(in_channels=6,out_channels=16,kernel_size=5)
        self.sigmoid2 = Sigmoid()
        self.pool2 = AvgPool2d(kernel_size=2,stride=2)
        self.flatten = Flatten()
        self.linear1 = Linear(16*5*5,120)
        self.sigmoid3 = Sigmoid()
        self.linear2 = Linear(120,84)
        self.sigmoid4 = Sigmoid()
        self.linear3 = Linear(84,10)

    def forward(self,x):
        x = self.conv1(x)
        x = self.sigmoid1(x)
        x = self.pool1(x)
        x = self.conv2(x)
        x = self.sigmoid2(x)
        x = self.pool2(x)
        x = self.flatten(x)
        x = self.linear1(x)
        x = self.sigmoid3(x)
        x = self.linear2(x)
        x = self.sigmoid4(x)
        x = self.linear3(x)

        return x

通过代码我们可以发现,我们所实现的手段比李沐老师所实现的手段要复杂的多,李沐老师实现这个网络的代码为:

import torch
from torch import nn
net = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Flatten(),
    nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.Sigmoid(),
    nn.Linear(84, 10))

可以看到,李沐老师实现这个网络的代码比我们这里所实现的代码要简易的多,这里就是因为李沐老师使用了nn.Sequential这个模块,这是pytorch提供的一个容器模块,它接收一系列的子模块,并按照传入的顺序自动串联它们:上一个子模块的输出会直接成为下一个子模块的输入,这种情况下,我们就不需要再手动实现forward函数。它和我们自己手动实现forward相比,具有以下优势:

  1. 代码极度简洁:我们不需要定义__init__中的属性,也不需要在forward函数中一行一行的写代码,对于LeNet这种纯顺序,没有分叉的网络,Sequential可以让代码量减少一半以上。
  2. 意图一目了然:一看nn.Sequential,就知道网络的流向是“一路向前”,没有跳跃连接、没有分支,可读性很强,特别适合快速搭建基线模型。
  3. 自动连接,不易出错:手写的时候很容易漏写或写错,Sequential能极大降低发生这些错误的概率。

然而,nn.Sequential不是万能的,它也具有以下局限性:

  1. 不能处理多输入,多输出的问题:如果网络有多入多出的情况,nn.Sequential就无法表达,因为它只支持单入单出式的流水线。
  2. 无法处理跳跃连接:如下面的代码,这需要将前层的输出“绕道”加到后面,nn.Sequential做不到。
    def forward(self, x):
        identity = x
        out = self.conv1(x)
        out = self.relu(out)
        out = self.conv2(out)
        out = out + identity   # ← 跳跃连接
        return out
  3. 调试困难:由于nn.Sequential里,所有层被封装成一个“黑箱”顺序执行,无法在两个层之间打印数值,来进行调试。

因此,在实际过程中,两者经常搭配使用,复杂网络的局部子模块(比如一个卷积块内部是顺序的)可以用nn.Sequential来实现,而整个网络的宏观结构,仍然用自定义类组合它们。这样既保证了代码的灵活性,也兼顾了代码的整洁。因此,在这里,我们基于李沐老师的代码,定义这个网络的传播过程:

import torch
from torch import nn
from torch.nn import Conv2d, Sigmoid, AvgPool2d, Flatten, Linear

#定义训练设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')


#搭建网络
net = nn.Sequential(
    Conv2d(in_channels=1,out_channels=6,kernel_size=5,padding=2),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Conv2d(in_channels=6,out_channels=16,kernel_size=5),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Flatten(),
    Linear(16*5*5,120),
    Sigmoid(),
    Linear(120,84),
    Sigmoid(),
    Linear(84,10)
)

加载数据集

在LeNet的实现中,我们仍然使用Fashion-MNIST数据集进行训练,有关数据集的详细操作见多层感知机,在此不具体展开,加载数据集的代码为:

from torchvision import datasets, transforms

#加载数据集
transform = transforms.Compose([transforms.ToTensor(),transforms.Normalize((0.5,),(0.5,))])

trainset = datasets.FashionMNIST('Dataset/',download=True,train=True,transform=transform)
trainloader = torch.utils.data.DataLoader(trainset,batch_size=256,shuffle=True)

testset = datasets.FashionMNIST('Dataset/',download=True,train=False,transform=transform)
testloader = torch.utils.data.DataLoader(testset,batch_size=256,shuffle=True)

模型训练

在定义好训练设备,加载完对应的数据集,以及写好对应的网络以后,我们便可将网络移动到我们指定的设备上,确定损失函数和优化器的形式,即可开始训练,训练的代码如下所示:

import torch
from torch import nn
from torch.nn import Conv2d, Sigmoid, AvgPool2d, Flatten, Linear, ReLU
from torchvision import datasets, transforms
import matplotlib.pyplot as plt

#定义训练设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

#加载数据集
transform = transforms.Compose([transforms.ToTensor(),transforms.Normalize((0.5,),(0.5,))])

trainset = datasets.FashionMNIST('Dataset/',download=True,train=True,transform=transform)
trainloader = torch.utils.data.DataLoader(trainset,batch_size=256,shuffle=True)

testset = datasets.FashionMNIST('Dataset/',download=True,train=False,transform=transform)
testloader = torch.utils.data.DataLoader(testset,batch_size=256,shuffle=True)

#搭建网络
net = nn.Sequential(
    Conv2d(in_channels=1,out_channels=6,kernel_size=5,padding=2),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Conv2d(in_channels=6,out_channels=16,kernel_size=5),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Flatten(),
    Linear(16*5*5,120),
    Sigmoid(),
    Linear(120,84),
    Sigmoid(),
    Linear(84,10)
)

# 将网络移动到设备
net = net.to(device)

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(net.parameters(),lr=0.01)

# 记录训练过程
train_losses = []
train_accuracies = []
test_accuracies = []

correct_train = 0
total_train = 0

epochs = 10

for epoch in range(epochs):

    #训练阶段
    net.train()
    running_loss = 0.0

    for images, labels in trainloader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = net(images)
        
        loss = criterion(outputs,labels)
        loss.backward()

        optimizer.step()

        running_loss += loss.item() * images.size(0)

        _, predicted = torch.max(outputs, 1)
        total_train += labels.size(0)
        correct_train += (predicted == labels).sum().item()

    epoch_loss = running_loss / len(trainset)
    train_accuracy = 100 * correct_train / total_train

    train_losses.append(epoch_loss)
    train_accuracies.append(train_accuracy)

    # 测试阶段
    net.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in testloader:
            images, labels = images.to(device), labels.to(device)
            outputs = net(images)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total
    test_accuracies.append(accuracy)

    print(f'Epoch [{epoch+1}/{epochs}], Loss: {epoch_loss:.4f}, Test Accuracy: {accuracy:.2f}%')

epochs_range = range(1,epochs+1)
fig = plt.figure(figsize=(6.25,5))
plt.plot(epochs_range,train_losses,color='red',linestyle='-',label='Train Loss')
plt.plot(epochs_range,train_accuracies,color='blue',linestyle='-',label = 'Train Acc')
plt.plot(epochs_range,test_accuracies,color='purple',linestyle='-',label = 'Test Acc')
plt.grid(True) 
plt.legend(prop={'family': 'Times New Roman', 'size': 12})
plt.xticks(fontproperties = 'Times New Roman', size = 12,rotation=0)
plt.yticks(fontproperties = 'Times New Roman', size = 12,rotation=0)
plt.xlabel('Epochs',fontdict={'family' : 'Times New Roman', 'size':12},labelpad=5)  
plt.show()

模型训练过程中产生的问题

通过这样一次训练,我们发现了一个问题,训练的结果如下所示:

当前的训练结果

我们发现,LeNet的损失,还有训练及测试准确率,基本没有变化,具体原因如下所示。

众所周知,Sigmoid函数,及其导数为:

\text{Sigmoid}\left(x \right )=\frac{1}{1+e^{-x}}\\

\frac{\text{d}}{\text{d}x}\text{Sigmoid}\left(x \right )=\text{Sigmoid}\left(x \right )\left(1-\text{Sigmoid}\left(x \right ) \right )

它们的函数图像为:

根据图像可知,Sigmoid函数的导数的最大值为0.25,而在神经网络的反向传播中,如果每层都使用Sigmoid函数,就会出现如下情况。考虑一个非常简单的三层神经网络,输入层为x,隐藏层为h=\text{Sigmoid}\left(z \right )=\text{Sigmoid}\left(w_1x+b_1 \right ),输出层为\hat{y}=w_2h+b_2,损失\mathcal{L}=\frac{1}{2}\left(\hat{y}-y \right )^2,从损失开始,一步一步往前求导,我们可以看到:

\frac{\partial \mathcal{L} }{\partial \hat{y}}=\hat{y}-y

\frac{\partial \hat{y} }{\partial w_2}=h, \frac{\partial \hat{y} }{\partial b_2}=1, \frac{\partial \hat{y} }{\partial h}=w_2

\frac{\partial \mathcal{L} }{\partial w_2}=\frac{\partial \mathcal{L} }{\partial \hat{y}}\frac{\partial \hat{y}}{\partial \hat{w_2}}=\left(\hat{y}-y \right )h

\frac{\partial \mathcal{L} }{\partial b_2}=\frac{\partial \mathcal{L} }{\partial \hat{y}}\frac{\partial \hat{y}}{\partial \hat{b_2}}=\hat{y}-y

\frac{\partial \mathcal{L} }{\partial h}=\frac{\partial \mathcal{L} }{\partial \hat{y}}\frac{\partial \hat{y}}{\partial h}=\left(\hat{y}-y \right )w_2

\frac{\partial h }{\partial z}=h\left(1-h \right ), \frac{\partial z }{\partial w_1}=x, \frac{\partial z }{\partial b_1}=1

\frac{\partial \mathcal{L} }{\partial z}=\frac{\partial \mathcal{L} }{\partial h}\frac{\partial h }{\partial z}=\left(\left(\hat{y}-y \right )w_2 \right )h\left(1-h \right )

\frac{\partial \mathcal{L} }{\partial w_1}=\frac{\partial \mathcal{L} }{\partial z}\frac{\partial z }{\partial w_1}=\left(\left(\hat{y}-y \right )w_2 \right )h\left(1-h \right )x

\frac{\partial \mathcal{L} }{\partial b_1}=\frac{\partial \mathcal{L} }{\partial z}\frac{\partial z }{\partial b_1}=\left(\left(\hat{y}-y \right )w_2 \right )h\left(1-h \right )

根据这个求导过程,我们可以看到,在反向传播中,损失对浅层参数的梯度等于损失对深层输出的梯度乘以后续每一层的权重矩阵和激活函数的导数的连乘积。当激活函数采用Sigmoid时,其导数值的范围为0到0.25,且在输入绝对值稍大时,迅速趋于0。因此梯度每向前传播一层,都至少会乘以一个 ≤0.25 的因子,且在大部分区域该因子远小于1。经过多层连乘,梯度呈指数级衰减,导致靠近输入层的参数梯度极其微小(梯度消失),使这些参数几乎无法得到有效更新。

有以下两个解决方案:

解决方案1:Xavier 初始化动量

Xavier 初始化

Xavier初始化的目标是让信号在前向和反向传播是,各层的输入输出方程保持一致,防止信号指数级放大或缩小。对于没有非线性的线性层,Xavier初始化要求权重的方差满足:

Var\left(W \right )=\frac{2}{n_{in}+n_{out}}

其中,n_{in}n_{out}分别是该层的输入和输出神经元个数

根据这个方差,可以导出均匀分布的范围为:

W\sim U\left[-\sqrt{\frac{6}{n_{in}+n_{out}}} ,\sqrt{\frac{6}{n_{in}+n_{out}}} \right ]

深度学习过程中存在很多初始化策略,详见:10.5 初始化策略:Xavier、He初始化与正交初始化

Xavier 初始化让每层输入落在 0 附近的高方差区域,即 Sigmoid 的非饱和区(导数约 0.25),从而让浅层也能接收到足够的梯度信号。这为训练提供了一个好的起点,使得网络能够开始学习。

实现Xavier 初始化的代码为:

def init_weights(m):
    if isinstance(m, (nn.Conv2d, nn.Linear)):
        nn.init.xavier_uniform_(m.weight)
        nn.init.zeros_(m.bias)
net.apply(init_weights)
动量

普通的梯度下降,只能沿着当前梯度的反方向更新,即:

w_{t+1}=w_{t}-\eta \bigtriangledown \mathcal{L}\left( w_t\right)

在误差表面存在沟壑或平坦区域时,容易在窄方向上来回震荡,或停滞不前。

带有动量的梯度下降,引入了速度变量v

v_{t+1}=\beta v_{t}+\bigtriangledown \mathcal{L}\left( w_t\right)

w_{t+1}=w_{t}-\eta v_{t+1}

即有了速度以后,参数不会被困在梯度近似为0的区域。它的意义在于,即使 Xavier 缓解了初期的饱和,深层Sigmoid的梯度仍然很小。动量能累积这些微小但一致的梯度信号,让参数持续更新,避免优化停滞。有关动量的更详细内容,可以参考:【深度学习优化算法】06:动量法

我们经常在损失函数中实现动量的设置,具体代码为:

optimizer = torch.optim.SGD(net.parameters(),lr=0.01,momentum=0.9)
总体解决方案

总体解决方案的代码如下所示:

import torch
from torch import nn
from torch.nn import Conv2d, Sigmoid, AvgPool2d, Flatten, Linear, ReLU
from torchvision import datasets, transforms
import matplotlib.pyplot as plt

#定义训练设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

#加载数据集
transform = transforms.Compose([transforms.ToTensor(),transforms.Normalize((0.5,),(0.5,))])

trainset = datasets.FashionMNIST('Dataset/',download=True,train=True,transform=transform)
trainloader = torch.utils.data.DataLoader(trainset,batch_size=256,shuffle=True)

testset = datasets.FashionMNIST('Dataset/',download=True,train=False,transform=transform)
testloader = torch.utils.data.DataLoader(testset,batch_size=256,shuffle=True)

#搭建网络
net = nn.Sequential(
    Conv2d(in_channels=1,out_channels=6,kernel_size=5,padding=2),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Conv2d(in_channels=6,out_channels=16,kernel_size=5),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Flatten(),
    Linear(16*5*5,120),
    Sigmoid(),
    Linear(120,84),
    Sigmoid(),
    Linear(84,10)
)

def init_weights(m):
    if isinstance(m, (nn.Conv2d, nn.Linear)):
        nn.init.xavier_uniform_(m.weight)
        nn.init.zeros_(m.bias)
net.apply(init_weights)

# 将网络移动到设备
net = net.to(device)

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(net.parameters(),lr=0.01,momentum=0.9)

# 记录训练过程
train_losses = []
train_accuracies = []
test_accuracies = []

correct_train = 0
total_train = 0

epochs = 10

for epoch in range(epochs):

    #训练阶段
    net.train()
    running_loss = 0.0

    for images, labels in trainloader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = net(images)
        
        loss = criterion(outputs,labels)
        loss.backward()

        optimizer.step()

        running_loss += loss.item() * images.size(0)

        _, predicted = torch.max(outputs, 1)
        total_train += labels.size(0)
        correct_train += (predicted == labels).sum().item()

    epoch_loss = running_loss / len(trainset)
    train_accuracy = 100 * correct_train / total_train

    train_losses.append(epoch_loss)
    train_accuracies.append(train_accuracy)

    # 测试阶段
    net.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in testloader:
            images, labels = images.to(device), labels.to(device)
            outputs = net(images)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total
    test_accuracies.append(accuracy)

    print(f'Epoch [{epoch+1}/{epochs}], Loss: {epoch_loss:.4f}, Test Accuracy: {accuracy:.2f}%')

epochs_range = range(1, epochs+1)

fig, ax1 = plt.subplots(figsize=(10,8))

# 左轴:训练损失
color_loss = 'red'
ax1.set_xlabel('Epochs', fontdict={'family': 'Times New Roman', 'size': 12}, labelpad=5)
ax1.set_ylabel('Train Loss', color=color_loss, fontdict={'family': 'Times New Roman', 'size': 12})
ax1.plot(epochs_range, train_losses, color=color_loss, linestyle='-', label='Train Loss')
ax1.tick_params(axis='y', labelcolor=color_loss)
ax1.tick_params(axis='x', labelsize=12)
for label in ax1.get_xticklabels():
    label.set_fontname('Times New Roman')
for label in ax1.get_yticklabels():
    label.set_fontname('Times New Roman')

# 右轴:准确率
ax2 = ax1.twinx()
color_train_acc = 'blue'
color_test_acc = 'purple'
ax2.set_ylabel('Accuracy (%)', fontdict={'family': 'Times New Roman', 'size': 12})
ax2.plot(epochs_range, train_accuracies, color=color_train_acc, linestyle='-', label='Train Acc')
ax2.plot(epochs_range, test_accuracies, color=color_test_acc, linestyle='-', label='Test Acc')
ax2.tick_params(axis='y', labelsize=12)
for label in ax2.get_yticklabels():
    label.set_fontname('Times New Roman')

# 合并图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, prop={'family': 'Times New Roman', 'size': 12}, loc='best')

ax1.grid(True)
plt.title('Training and Evaluation', fontdict={'family': 'Times New Roman', 'size': 14})
plt.show()

最终,加入了Xavier初始化和动量后,训练结果为:

加入了Xavier初始化和动量后的训练结果

同时,我们可以适当增加学习率,以在不引发失控震荡的前提下,最大限度地方大这些微小但有效的梯度累积,让 Sigmoid 网络能够以更快的速度驶出平坦区,获得可观的训练进度。如下图所示,为保持一切不变,只将学习率由0.01变为0.1的实验结果。

加入了Xavier初始化和动量后的训练结果(学习率为0.1)

解决方案2:将激活函数由Sigmoid换为ReLU

原理

根据前面的推导可知,梯度消失的根源是反向传播中激活函数导数的连乘衰减。Sigmoid 导数上限为 0.25 且容易饱和,导致梯度逐层指数减小。ReLU 在正半轴的导数值为 1,只要输入大于零,梯度就可以近乎无损地向前传播,因此在很大程度上解决了深层网络中的梯度消失问题。

(注意:ReLU 在输入 ≤0 时梯度为 0,即“死区”,可能造成神经元永久失活,但它对梯度消失的抑制仍然是革命性的。在 LeNet 提出的 90 年代,ReLU 尚未成为深度学习的标准激活函数。)

具体代码为:

import torch
from torch import nn
from torch.nn import Conv2d, Sigmoid, AvgPool2d, Flatten, Linear, ReLU
from torchvision import datasets, transforms
import matplotlib.pyplot as plt

#定义训练设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

#加载数据集
transform = transforms.Compose([transforms.ToTensor(),transforms.Normalize((0.5,),(0.5,))])

trainset = datasets.FashionMNIST('Dataset/',download=True,train=True,transform=transform)
trainloader = torch.utils.data.DataLoader(trainset,batch_size=256,shuffle=True)

testset = datasets.FashionMNIST('Dataset/',download=True,train=False,transform=transform)
testloader = torch.utils.data.DataLoader(testset,batch_size=256,shuffle=True)

#搭建网络
net = nn.Sequential(
    Conv2d(in_channels=1,out_channels=6,kernel_size=5,padding=2),
    ReLU(),
    AvgPool2d(kernel_size=2,stride=2),
    Conv2d(in_channels=6,out_channels=16,kernel_size=5),
    ReLU(),
    AvgPool2d(kernel_size=2,stride=2),
    Flatten(),
    Linear(16*5*5,120),
    ReLU(),
    Linear(120,84),
    ReLU(),
    Linear(84,10)
)

# 将网络移动到设备
net = net.to(device)

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(net.parameters(),lr=0.01)

# 记录训练过程
train_losses = []
train_accuracies = []
test_accuracies = []

correct_train = 0
total_train = 0

epochs = 10

for epoch in range(epochs):

    #训练阶段
    net.train()
    running_loss = 0.0

    for images, labels in trainloader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = net(images)
        
        loss = criterion(outputs,labels)
        loss.backward()

        optimizer.step()

        running_loss += loss.item() * images.size(0)

        _, predicted = torch.max(outputs, 1)
        total_train += labels.size(0)
        correct_train += (predicted == labels).sum().item()

    epoch_loss = running_loss / len(trainset)
    train_accuracy = 100 * correct_train / total_train

    train_losses.append(epoch_loss)
    train_accuracies.append(train_accuracy)

    # 测试阶段
    net.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in testloader:
            images, labels = images.to(device), labels.to(device)
            outputs = net(images)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total
    test_accuracies.append(accuracy)

    print(f'Epoch [{epoch+1}/{epochs}], Loss: {epoch_loss:.4f}, Test Accuracy: {accuracy:.2f}%')

epochs_range = range(1, epochs+1)

fig, ax1 = plt.subplots(figsize=(10,8))

# 左轴:训练损失
color_loss = 'red'
ax1.set_xlabel('Epochs', fontdict={'family': 'Times New Roman', 'size': 12}, labelpad=5)
ax1.set_ylabel('Train Loss', color=color_loss, fontdict={'family': 'Times New Roman', 'size': 12})
ax1.plot(epochs_range, train_losses, color=color_loss, linestyle='-', label='Train Loss')
ax1.tick_params(axis='y', labelcolor=color_loss)
ax1.tick_params(axis='x', labelsize=12)
for label in ax1.get_xticklabels():
    label.set_fontname('Times New Roman')
for label in ax1.get_yticklabels():
    label.set_fontname('Times New Roman')

# 右轴:准确率
ax2 = ax1.twinx()
color_train_acc = 'blue'
color_test_acc = 'purple'
ax2.set_ylabel('Accuracy (%)', fontdict={'family': 'Times New Roman', 'size': 12})
ax2.plot(epochs_range, train_accuracies, color=color_train_acc, linestyle='-', label='Train Acc')
ax2.plot(epochs_range, test_accuracies, color=color_test_acc, linestyle='-', label='Test Acc')
ax2.tick_params(axis='y', labelsize=12)
for label in ax2.get_yticklabels():
    label.set_fontname('Times New Roman')

# 合并图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, prop={'family': 'Times New Roman', 'size': 12}, loc='best')

ax1.grid(True)
plt.title('Training and Evaluation', fontdict={'family': 'Times New Roman', 'size': 14})
plt.show()

最终的训练结果为:

使用ReLU替代Sigmoid函数后的训练结果

可视化结果

最终,可视化分类结果的代码为:

# FashionMNIST 的类别名称(英文)
class_names = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat',
               'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']

# 重新创建一个测试集加载器,打乱顺序以随机取样
testloader_viz = torch.utils.data.DataLoader(testset, batch_size=16, shuffle=True)

# 获取一个 batch 的图像和标签
data_iter = iter(testloader_viz)
images, labels = next(data_iter)
images, labels = images.to(device), labels.to(device)

# 用模型预测
net.eval()
with torch.no_grad():
    outputs = net(images)
    _, predicted = torch.max(outputs, 1)

# 将图像移回 CPU 并反标准化,以便 matplotlib 显示
images = images.cpu()
labels = labels.cpu()
predicted = predicted.cpu()

# 反标准化:原来 Normalize((0.5,), (0.5,)) 将 [0,1] 变为 [-1,1],反操作为 img*std + mean
images = images * 0.5 + 0.5   # 现在范围大致在 [0,1]
images = torch.clamp(images, 0, 1)  # 确保不越界

# 绘制 4x4 网格
fig, axes = plt.subplots(4, 4, figsize=(10, 10))
axes = axes.flatten()

for i in range(16):
    ax = axes[i]
    # 单通道灰度图,去掉 channel 维度,显示为灰度
    img = images[i].squeeze()
    ax.imshow(img, cmap='gray')
    
    true_label = class_names[labels[i]]
    pred_label = class_names[predicted[i]]
    
    # 设置标题,正确用绿色,错误用红色
    color = 'green' if labels[i] == predicted[i] else 'red'
    ax.set_title(f'True: {true_label}\nPred: {pred_label}', color=color, fontsize=10)
    ax.axis('off')

plt.tight_layout()
plt.show()

总体代码

经过以上过程的分析,LeNet的总体实现代码为:

Xavier 初始化动量版本

import torch
from torch import nn
from torch.nn import Conv2d, Sigmoid, AvgPool2d, Flatten, Linear, ReLU
from torchvision import datasets, transforms
import matplotlib.pyplot as plt

#定义训练设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

#加载数据集
transform = transforms.Compose([transforms.ToTensor(),transforms.Normalize((0.5,),(0.5,))])

trainset = datasets.FashionMNIST('Dataset/',download=True,train=True,transform=transform)
trainloader = torch.utils.data.DataLoader(trainset,batch_size=256,shuffle=True)

testset = datasets.FashionMNIST('Dataset/',download=True,train=False,transform=transform)
testloader = torch.utils.data.DataLoader(testset,batch_size=256,shuffle=True)

#搭建网络
net = nn.Sequential(
    Conv2d(in_channels=1,out_channels=6,kernel_size=5,padding=2),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Conv2d(in_channels=6,out_channels=16,kernel_size=5),
    Sigmoid(),
    AvgPool2d(kernel_size=2,stride=2),
    Flatten(),
    Linear(16*5*5,120),
    Sigmoid(),
    Linear(120,84),
    Sigmoid(),
    Linear(84,10)
)

def init_weights(m):
    if isinstance(m, (nn.Conv2d, nn.Linear)):
        nn.init.xavier_uniform_(m.weight)
        nn.init.zeros_(m.bias)
net.apply(init_weights)

# 将网络移动到设备
net = net.to(device)

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(net.parameters(),lr=0.1,momentum=0.9)

# 记录训练过程
train_losses = []
train_accuracies = []
test_accuracies = []

correct_train = 0
total_train = 0

epochs = 10

for epoch in range(epochs):

    #训练阶段
    net.train()
    running_loss = 0.0

    for images, labels in trainloader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = net(images)
        
        loss = criterion(outputs,labels)
        loss.backward()

        optimizer.step()

        running_loss += loss.item() * images.size(0)

        _, predicted = torch.max(outputs, 1)
        total_train += labels.size(0)
        correct_train += (predicted == labels).sum().item()

    epoch_loss = running_loss / len(trainset)
    train_accuracy = 100 * correct_train / total_train

    train_losses.append(epoch_loss)
    train_accuracies.append(train_accuracy)

    # 测试阶段
    net.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in testloader:
            images, labels = images.to(device), labels.to(device)
            outputs = net(images)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total
    test_accuracies.append(accuracy)

    print(f'Epoch [{epoch+1}/{epochs}], Loss: {epoch_loss:.4f}, Test Accuracy: {accuracy:.2f}%')

epochs_range = range(1, epochs+1)

fig, ax1 = plt.subplots(figsize=(10,8))

# 左轴:训练损失
color_loss = 'red'
ax1.set_xlabel('Epochs', fontdict={'family': 'Times New Roman', 'size': 12}, labelpad=5)
ax1.set_ylabel('Train Loss', color=color_loss, fontdict={'family': 'Times New Roman', 'size': 12})
ax1.plot(epochs_range, train_losses, color=color_loss, linestyle='-', label='Train Loss')
ax1.tick_params(axis='y', labelcolor=color_loss)
ax1.tick_params(axis='x', labelsize=12)
for label in ax1.get_xticklabels():
    label.set_fontname('Times New Roman')
for label in ax1.get_yticklabels():
    label.set_fontname('Times New Roman')

# 右轴:准确率
ax2 = ax1.twinx()
color_train_acc = 'blue'
color_test_acc = 'purple'
ax2.set_ylabel('Accuracy (%)', fontdict={'family': 'Times New Roman', 'size': 12})
ax2.plot(epochs_range, train_accuracies, color=color_train_acc, linestyle='-', label='Train Acc')
ax2.plot(epochs_range, test_accuracies, color=color_test_acc, linestyle='-', label='Test Acc')
ax2.tick_params(axis='y', labelsize=12)
for label in ax2.get_yticklabels():
    label.set_fontname('Times New Roman')

# 合并图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, prop={'family': 'Times New Roman', 'size': 12}, loc='best')

ax1.grid(True)
plt.title('Training and Evaluation', fontdict={'family': 'Times New Roman', 'size': 14})
plt.show()

# FashionMNIST 的类别名称(英文)
class_names = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat',
               'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']

# 重新创建一个测试集加载器,打乱顺序以随机取样
testloader_viz = torch.utils.data.DataLoader(testset, batch_size=16, shuffle=True)

# 获取一个 batch 的图像和标签
data_iter = iter(testloader_viz)
images, labels = next(data_iter)
images, labels = images.to(device), labels.to(device)

# 用模型预测
net.eval()
with torch.no_grad():
    outputs = net(images)
    _, predicted = torch.max(outputs, 1)

# 将图像移回 CPU 并反标准化,以便 matplotlib 显示
images = images.cpu()
labels = labels.cpu()
predicted = predicted.cpu()

# 反标准化:原来 Normalize((0.5,), (0.5,)) 将 [0,1] 变为 [-1,1],反操作为 img*std + mean
images = images * 0.5 + 0.5   # 现在范围大致在 [0,1]
images = torch.clamp(images, 0, 1)  # 确保不越界

# 绘制 4x4 网格
fig, axes = plt.subplots(4, 4, figsize=(10, 10))
axes = axes.flatten()

for i in range(16):
    ax = axes[i]
    # 单通道灰度图,去掉 channel 维度,显示为灰度
    img = images[i].squeeze()
    ax.imshow(img, cmap='gray')
    
    true_label = class_names[labels[i]]
    pred_label = class_names[predicted[i]]
    
    # 设置标题,正确用绿色,错误用红色
    color = 'green' if labels[i] == predicted[i] else 'red'
    ax.set_title(f'True: {true_label}\nPred: {pred_label}', color=color, fontsize=10)
    ax.axis('off')

plt.tight_layout()
plt.show()

ReLU函数版本

import torch
from torch import nn
from torch.nn import Conv2d, Sigmoid, AvgPool2d, Flatten, Linear, ReLU
from torchvision import datasets, transforms
import matplotlib.pyplot as plt

#定义训练设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

#加载数据集
transform = transforms.Compose([transforms.ToTensor(),transforms.Normalize((0.5,),(0.5,))])

trainset = datasets.FashionMNIST('Dataset/',download=True,train=True,transform=transform)
trainloader = torch.utils.data.DataLoader(trainset,batch_size=256,shuffle=True)

testset = datasets.FashionMNIST('Dataset/',download=True,train=False,transform=transform)
testloader = torch.utils.data.DataLoader(testset,batch_size=256,shuffle=True)

#搭建网络
net = nn.Sequential(
    Conv2d(in_channels=1,out_channels=6,kernel_size=5,padding=2),
    ReLU(),
    AvgPool2d(kernel_size=2,stride=2),
    Conv2d(in_channels=6,out_channels=16,kernel_size=5),
    ReLU(),
    AvgPool2d(kernel_size=2,stride=2),
    Flatten(),
    Linear(16*5*5,120),
    ReLU(),
    Linear(120,84),
    ReLU(),
    Linear(84,10)
)

# 将网络移动到设备
net = net.to(device)

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(net.parameters(),lr=0.1)

# 记录训练过程
train_losses = []
train_accuracies = []
test_accuracies = []

correct_train = 0
total_train = 0

epochs = 100

for epoch in range(epochs):

    #训练阶段
    net.train()
    running_loss = 0.0

    for images, labels in trainloader:
        images, labels = images.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = net(images)
        
        loss = criterion(outputs,labels)
        loss.backward()

        optimizer.step()

        running_loss += loss.item() * images.size(0)

        _, predicted = torch.max(outputs, 1)
        total_train += labels.size(0)
        correct_train += (predicted == labels).sum().item()

    epoch_loss = running_loss / len(trainset)
    train_accuracy = 100 * correct_train / total_train

    train_losses.append(epoch_loss)
    train_accuracies.append(train_accuracy)

    # 测试阶段
    net.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in testloader:
            images, labels = images.to(device), labels.to(device)
            outputs = net(images)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    accuracy = 100 * correct / total
    test_accuracies.append(accuracy)

    print(f'Epoch [{epoch+1}/{epochs}], Loss: {epoch_loss:.4f}, Test Accuracy: {accuracy:.2f}%')

epochs_range = range(1, epochs+1)

fig, ax1 = plt.subplots(figsize=(10,8))

# 左轴:训练损失
color_loss = 'red'
ax1.set_xlabel('Epochs', fontdict={'family': 'Times New Roman', 'size': 12}, labelpad=5)
ax1.set_ylabel('Train Loss', color=color_loss, fontdict={'family': 'Times New Roman', 'size': 12})
ax1.plot(epochs_range, train_losses, color=color_loss, linestyle='-', label='Train Loss')
ax1.tick_params(axis='y', labelcolor=color_loss)
ax1.tick_params(axis='x', labelsize=12)
for label in ax1.get_xticklabels():
    label.set_fontname('Times New Roman')
for label in ax1.get_yticklabels():
    label.set_fontname('Times New Roman')

# 右轴:准确率
ax2 = ax1.twinx()
color_train_acc = 'blue'
color_test_acc = 'purple'
ax2.set_ylabel('Accuracy (%)', fontdict={'family': 'Times New Roman', 'size': 12})
ax2.plot(epochs_range, train_accuracies, color=color_train_acc, linestyle='-', label='Train Acc')
ax2.plot(epochs_range, test_accuracies, color=color_test_acc, linestyle='-', label='Test Acc')
ax2.tick_params(axis='y', labelsize=12)
for label in ax2.get_yticklabels():
    label.set_fontname('Times New Roman')

# 合并图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, prop={'family': 'Times New Roman', 'size': 12}, loc='best')

ax1.grid(True)
plt.title('Training and Evaluation', fontdict={'family': 'Times New Roman', 'size': 14})
plt.show()


# FashionMNIST 的类别名称(英文)
class_names = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat',
               'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']

# 重新创建一个测试集加载器,打乱顺序以随机取样
testloader_viz = torch.utils.data.DataLoader(testset, batch_size=16, shuffle=True)

# 获取一个 batch 的图像和标签
data_iter = iter(testloader_viz)
images, labels = next(data_iter)
images, labels = images.to(device), labels.to(device)

# 用模型预测
net.eval()
with torch.no_grad():
    outputs = net(images)
    _, predicted = torch.max(outputs, 1)

# 将图像移回 CPU 并反标准化,以便 matplotlib 显示
images = images.cpu()
labels = labels.cpu()
predicted = predicted.cpu()

# 反标准化:原来 Normalize((0.5,), (0.5,)) 将 [0,1] 变为 [-1,1],反操作为 img*std + mean
images = images * 0.5 + 0.5   # 现在范围大致在 [0,1]
images = torch.clamp(images, 0, 1)  # 确保不越界

# 绘制 4x4 网格
fig, axes = plt.subplots(4, 4, figsize=(10, 10))
axes = axes.flatten()

for i in range(16):
    ax = axes[i]
    # 单通道灰度图,去掉 channel 维度,显示为灰度
    img = images[i].squeeze()
    ax.imshow(img, cmap='gray')
    
    true_label = class_names[labels[i]]
    pred_label = class_names[predicted[i]]
    
    # 设置标题,正确用绿色,错误用红色
    color = 'green' if labels[i] == predicted[i] else 'red'
    ax.set_title(f'True: {true_label}\nPred: {pred_label}', color=color, fontsize=10)
    ax.axis('off')

plt.tight_layout()
plt.show()

实验结果

这里仅展示ReLU函数的版本

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值