手撸代码:从零开始的 LeNet5 图像分类(PyTorch 框架)
摘要:
本文介绍了如何从0开始构建 LeNet5 去识别手写数字(在MNIST数据集上)。代码包括三大部分:网络结构部分、训练部分、测试部分。在编LeNet5部分代码之前,本文详细地梳理了LeNet5的结构,对于初学者十分友好。训练和测试部分也都有详细的代码说明。
在实现 LeNet5 手写数字识别的同时,补充了很多CNN的基础概念和Python编程知识。包括:PyTorch中的常用库和其中的模块,特征图在卷积过程中尺寸如何变化,如何把数据加载进训练程序等。
本文不是通过复制粘贴代码介绍如何实现 LeNet5 的手写数字识别,而是通过内在逻辑,深层次地阐述这一过程,力求“知其然,知其所以然。”
完整代码已经上传至GitHub:https://github.com/TiezhuXing01/LeNet5_in_PyTorch.git
温馨提示:
(1)本文主要介绍如何从0实现LeNet5,注重编程思路的讲解,对于一些前置知识不做赘述。
(2)请确保你已经配置好并进入了深度学习环境。
(3)目录导航见页面右上角黄色方块。
前置知识:
(1)概念:PyTorch、卷积、池化、全连接、ReLU、前向传播、反向传播
(2)对python语法有基本的了解
在从0开始编程前,我们首先思考一下,一个由LeNet5完成的图像分类任务(如:手写数字识别),都需要哪些组成部分?
首先,肯定要有LeNet5网络结构的代码。
其次,还要有在训练集上训练的代码,让网络学习特征表示。
最后,训练完要在测试集上测试,不然咋知道训练得效果怎样呢?
1. 引入库(Import the Libraries)
“库”是一组已经写好的代码,可以理解为一个“工具箱”。对于某些功能的实现,开发者可以从引入的“工具箱”中拿出工具直接使用,而不是从头开始“造工具”(即编代码)。所以在所有工作之前,要把“工具箱”引入进来,以方便后续编程。但有时候,我们引入库时可能会漏掉一些库,在编程到后面才意识到。不用担心,我们再回到开头这里引入库就好了。
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms
-
torch
库提供了各种用于张量(tensor)操作、神经网络搭建、优化算法等方面的函数。
什么是张量?如果你对张量(tensor)不了解,不用担心,你只需要记住这是图片经过某种处理之后的一种形式,就像你已知的图片有.jpg和.png格式一样。但对于.jpg和.png格式的图像,神经网络并不喜欢,无法直接处理它们。而张量(tensor)这种形式,适用于神经网络的处理。 -
torch.nn
库可以理解为大工具箱torch
里面的小工具箱nn
。这个模块里包含构建神经网络层和模型的类和函数。import torch.nn as nn
表示,引入之后,模块torch.nn
的名字就可以简称nn
了。 -
torchvision
库提供了一系列用于图像处理、计算机视觉数据集加载、图像变换、以及许多流行的计算机视觉模型的实现。 -
torchvision.transforms
模块用于进行图像的变换和预处理。这个模块包含了一系列用于处理图像的转换函数,可用于数据增强、数据清理和准备图像数据以输入神经网络等任务。
2. 选择在哪个设备上训练模型(GPU或CPU)
通常情况下,我们都会选择在GPU上训练网络模型,因为神经网络的训练需要大量的计算,而英伟达的GPU提供了CUDA(一个加速计算库)。但如果你的电脑显卡是AMD的,那么有很大概率不支持使用CUDA,此时只能用CPU训练。但在CPU上训练模型是十分缓慢的。如果你暂时没法换电脑,那我建议你去租一个服务器。或者使用阿里云、百度飞桨、谷歌Colab等平台。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
-
torch.cuda.is_available()
函数的功能是检查系统中是否安装了可用的 CUDA并且 GPU 是可用的。如果 GPU 可用,返回 True,否则返回 False。 -
'cuda' if torch.cuda.is_available() else 'cpu'
表示如果GPU可用,则返回字符串'cuda'
。如果不可用,则返回字符串'cpu'
。 -
如果函数
torch.device(...)
接收到的是'cuda'
,则选择在GPU上计算,如果接收到的是'cpu'
,则选择在CPU上进行计算。
3. 认识网络
充分地认识网络,才能在编程时思路清晰、游刃有余。下图是LeNet5的结构图,请你直观地认识一下LeNet5。如果你不想了解,可以直接去 4. 构建网络,不过我不建议你这样。
本例中LeNet5处理的MNIST数据集图片尺寸都是32×32. 所以下图的输入图片是32×32.
我把上图的网络结构具体为下面的表格,以便我们后续编程。注意:画表格并不是编程所需的必要步骤,只是我希望表达得更清晰。
层的名称 | 具体操作 | 输入通道数 | 输出通道数 | 核或池的尺寸 | 步幅 |
---|---|---|---|---|---|
卷积层1 | 卷积 | 1 | 6 | 5×5 | 1 |
批归一化 | 6 | 6 | -- | -- | |
ReLU | 6 | 6 | -- | -- | |
下采样 | 最大池化 | 6 | 6 | 2×2 | 2 |
卷积层2 | 卷积 | 6 | 16 | 5×5 | 1 |
批归一化 | 16 | 16 | -- | -- | |
ReLU | 16 | 16 | -- | -- | |
下采样 | 最大池化 | 16 | 16 | 2×2 | 2 |
全连接层1 | 全连接 | 400 | 120 | -- | -- |
ReLU | 120 | 120 | -- | -- | |
全连接层2 | 全连接 | 120 | 84 | -- | -- |
ReLU | 84 | 84 | -- | -- | |
高斯连接 | 全连接 | 84 | 类别总数10 | -- | -- |
下面我们梳理一下LeNet5的详细流程,这样到后面编程的时候不会懵。
(1)卷积层1:提取低级特征
a) 第一次卷积
在图中可以看到,第一次卷积操作之后,不仅通道由1变6,原图32×32的尺寸也变成了28×28,这与卷积核大小、步幅和 \(padding\) 有关(LeNet5中,2次卷积padding都为0)。输出特征图像尺寸公式如下:
其中,\(W\)表示输入图像的宽度。
将数代入公式:
b) 批归一化
批归一化 (Batch Normalization) 是一种用于提高神经网络训练稳定性和加速收敛的方法。在卷积神经网络中,每个通道都有一个独立的归一化参数。因此输入是6通道,输出还是6通道。
c) ReLU激活函数
产生非线性映射,通道数还是6,不变。
(2)下采样
最大池化,通道数不变,还是6,特征图尺寸由28×28下降到14×14. 计算公式如下:
(3)卷积层2:提取高级特征
a) 第二次卷积
通道由6变16,输出特征图尺寸为10×10.具体计算公式如下:
b) 批归一化+ReLU
输出通道还是16。
(4)下采样
特征图尺寸由10×10变成5×5,输出通道还是16。
(5)全连接层1
将输入维度为 400 的向量(一维数组)映射到维度为 120 的输出向量。
其中,400为16个通道的5×5大小的所有像素数量。\(16×5×5=400\)
120是研究人员通过在实验中不断调整得到的。
(6)全连接层2
将前一层的 120 个节点映射到 84 个节点。
(7)高斯连接(全连接层3)
将84个节点映射到10个具体类别上,即0~9这10个数字上。
4. 搭建网络
在搭建之前,我将介绍一个“工具”。nn.Sequential()
是 PyTorch 中用于构建容器(container)的类。通俗地讲,这个工具的作用就是把好几个操作串到一起,按顺序执行。
# 定义名为 LeNet5 的类,该类继承自 nn.Module
class LeNet5(nn.Module):
def __init__(self, num_classes):
super(LeNet5, self).__init__()
# 卷积层 1
self.layer1 = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, stride=1, padding=0), # 卷积
nn.BatchNorm2d(6), # 批归一化
nn.ReLU(),)
# 下采样
self.subsampel1 = nn.MaxPool2d(kernel_size = 2, stride = 2) # 最大池化
# 卷积层 2
self.layer2 = nn.Sequential(
nn.Conv2d(6, 16, kernel_size=5, stride=1, padding=0),
nn.BatchNorm2d(16),
nn.ReLU(),)
# 下采样
self.subsampel2 = nn.MaxPool2d(kernel_size = 2, stride = 2)
# 全连接
self.L1 = nn.Linear(400, 120)
self.relu = nn.ReLU()
self.L2 = nn.Linear(120, 84)
self.relu1 = nn.ReLU()
self.L3 = nn.Linear(84, num_classes)
# 前向传播
def forward(self, x):
out = self.layer1(x)
out = self.subsampel1(out)
out = self.layer2(out)
out = self.subsampel2(out)
# 将上一步输出的16个5×5特征图中的400个像素展平成一维向量,以便下一步全连接
out = out.reshape(out.size(0), -1)
# 全连接
out = self.L1(out)
out = self.relu(out)
out = self.L2(out)
out = self.relu1(out)
out = self.L3(out)
return out
发现没有?套路就是:先self.各种层,一顿定义。然后到前向传播(forward
)那里,开始用上一步定义的self.something()
。最后,return out
返回输出值。
对于初学者而言,如果Python基础不牢,最开始那三行代码理解起来可能比较吃力。其实用多了就会发现,这就是个套路,不理解也不耽误编程。
点击此处展开前三行代码
class LeNet5(nn.Module):
def __init__(self, num_classes):
super(LeNet5, self).__init__()
-
我们来看第一行代码。这行代码定义了一个名为
LeNet5
的类,它继承自nn.Module
(别忘了,在最开始引入库时,引入过nn
),说明这是一个 PyTorch 框架中神经网络模型的基类。所有的神经网络模型都应该继承自nn.Module
,这样它们就能够利用 PyTorch 提供的模型管理和训练的功能。 -
第二行代码:是类的构造函数(initializer)。构造函数用于初始化类的实例。
num_classes
是类别数,这里是我们构造的网络所接收的参数,后续要用到这个参数。 -
第三行代码:调用了父类
nn.Module
的构造函数,确保正确地初始化 LeNet5 类的父类部分。这是 Python 中用于调用父类方法的一种方式。
如果读完这些解释还是不理解,没关系,你可以把这三行当作一个套路,用多了自然就会记住。
套路如下:
class 网络名字(nn.Module):
def __init__(self, 需要接受的参数):
super(网络名字, self).__init__()
5. 准备(加载)数据集
在上一步中,LeNet5已经搭建好了,现在该编写训练部分的程序了。但是在这之前有一步不能落下,那就是数据加载。
要把数据集里的一堆数据“码好”了,一批一批地“喂”给LeNet5.
MNIST 数据集是一个手写数字图像数据集,包含了大量的手写数字图片,每张图片都标注了对应的数字。
torchvision.datasets.MNIST(...)
是 PyTorch 中用于加载 MNIST 数据集的类。
torch.utils.data.DataLoader(...)
是 PyTorch 中用于批量加载数据的工具类,是一个数据加载器。
注意:前者用于加载数据集,后者用于加载数据,不要混淆。
把一个数据集比作一副扑克牌,那么一张扑克牌就是一个数据。把DataLoader比作神经网络的手,手去抓牌。一次抓几张,抓牌有没有顺序,等等,这些都是通过设置DataLoader的参数决定的。batch_size等于几就是一次抓几张牌,即一次“喂”给神经网络几张图片。
# 加载训练集
train_dataset = torchvision.datasets.MNIST(root = './data', # 数据集保存路径
train = True, # 是否为训练集
# 数据预处理
transform = transforms.Compose([
transforms.Resize((32,32)),
transforms.ToTensor(),
transforms.Normalize(mean = (0.1307,),
std = (0.3081,))]),
download = True) #是否下载
# 加载测试集
test_dataset = torchvision.datasets.MNIST(root = './data',
train = False,
transform = transforms.Compose([
transforms.Resize((32,32)),
transforms.ToTensor(),
transforms.Normalize(mean = (0.1325,),
std = (0.3105,))]),
download=True)
# 一次抓64张牌
batch_size = 64
# 加载训练数据
train_loader = torch.utils.data.DataLoader(dataset = train_dataset,
batch_size = batch_size,
shuffle = True) # 是否打乱
# 加载测试数据
test_loader = torch.utils.data.DataLoader(dataset = test_dataset,
batch_size = batch_size,
shuffle = False) # 是否打乱
-
训练阶段的
shuffle=True
:将数据集打乱顺序,有助于模型学习更泛化的特征。通过打乱数据顺序,模型在每个 epoch 中都能够看到不同的样本,防止模型过度拟合训练集中的特定顺序。 -
测试阶段的
shuffle=False
:在测试阶段,通常不需要打乱数据的顺序。测试时模型是在未见过的数据上进行评估,因此希望模型看到的是原始数据的有序顺序,以便能够更好地评估模型的泛化性能。如果在测试时也打乱数据,可能会导致模型在评估时看到的数据分布与实际场景不一致。(其实如果是True
影响也不大)
6. 设置超参数
在训练之前,我们需要设置一些超参数,为训练阶段要用到的模型、损失函数、优化器做准备。
(1)创建 LeNet5 模型
num_classes = 10
model = LeNet5(num_classes).to(device)
LeNet5(num_classes)
:创建一个 LeNet5 类的实例。还记得么?前文提到过,该类是继承自 nn.Module 的神经网络模型,接受 num_classes 参数,表示模型输出的类别数目。to(device)
:将模型移动到指定的设备上,其中device
是一个设备对象,可以是'cuda'
(GPU)或者'cpu'
。这一步是为了确保模型在训练和推理时使用的是正确的计算设备。model = ...
:将创建并移动到指定设备的模型赋值给变量 model,以便后续对模型的引用和操作。
(2)创建损失函数
在这里,损失函数设置为交叉熵损失函数
cost = nn.CrossEntropyLoss()
nn.CrossEntropyLoss()
是 PyTorch 中用于计算多类别交叉熵损失的损失函数。交叉熵损失通常用于分类问题,特别是当目标是多类别标签时。它的计算涉及到模型的预测值和实际类别标签之间的比较,以衡量模型输出与真实标签之间的差异。
(3)创建优化器
learning_rate = 0.001
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
torch.optim.Adam
:这是 PyTorch 中提供的 Adam 优化器的实现。Adam 是一种常用的随机梯度下降算法的变体,它通过自适应地调整学习率来优化模型参数。model.parameters()
:指定要被优化的参数,就是告诉优化器去优化谁,这里选择了模型 model 中的所有参数。lr=learning_rate
:设置学习率,即每次参数更新时的步进大小。
(4)确定每轮共需几步
total_step = len(train_loader)
len(train_loader)
返回加载器中的批次数量。- MNIST数据集中有 60000 张图片作为训练集。我们每次抓64张牌,那么一共要抓\(\frac{60000}{64}=937.5\),937.5 向上取整是 938 个批次。也就是说,由于数据加载器DataLoader的存在,LeNet5 把训练集所有图片遍历一遍要 938 步(step)。训练一轮(epoch)需要 938 步(step)。总结一下,一轮需要很多步,一步就是一个批次。
7. 训练
我们将用 2 个 for 循环的嵌套来实现训练过程。
先让我们梳理一下应该如何训练。首先,训练分很多轮(epoch),每轮训练都需要把全部训练集过一遍。所以需要一个 for 循环,一轮一轮地进行循环。这个“过一遍”是通过数据加载器 DataLoader 实现的。其次,在一轮训练中,完整遍历一次训练集需要一个 for 循环,去循环从 DataLoader 加载过来的每个批次的图片(本例为64张图)。
在本例中,我设置共训练 10 轮。
# 设置一共训练几轮(epoch)
num_epochs = 10
# 外部循环用于遍历轮次
for epoch in range(num_epochs):
# 内部循环用于遍历每轮中的所有批次
for i, (images, labels) in enumerate(train_loader):
images = images.to(device)
labels = labels.to(device)
# 前向传播
outputs = model(images) # 通过模型进行前向传播,得到模型的预测结果 outputs
loss = cost(outputs, labels) # 计算模型预测与真实标签之间的损失
# 反向传播和优化
optimizer.zero_grad() # 清零梯度,以便在下一次反向传播中不累积之前的梯度
loss.backward() # 进行反向传播,计算梯度
optimizer.step() # 根据梯度更新(优化)模型参数
# 定期输出训练信息
# 在每经过一定数量的批次后,输出当前训练轮次、总周轮数、当前批次、总批次数和损失值
if (i+1) % 400 == 0:
print ('Epoch [{}/{}], Step [{}/{}], Loss: {:.4f}'
.format(epoch+1, num_epochs, i+1, total_step, loss.item()))
-
在内部循环
for i, (images, labels) in enumerate(train_loader):
中enumerate(train_loader)
返回一个可迭代的对象,其中包含每个批次的图像和标签。 -
images = images.to(device)
和labels = labels.to(device)
:将加载的图像和标签移动到设备(通常是 GPU),以便在设备上执行模型的前向和后向传播。 -
对初学者而言,“定期输出训练信息”部分的代码用
print
输出即可,后期熟练了可以尝试用tqdm
库显示进度条。这不是本文重点,有兴趣的可以自行了解。
训练时输出的结果:
938 跟我们之前手动计算的总批次数(步数)数是吻合的。
8. 测试
with torch.no_grad(): # 指示 PyTorch 在接下来的代码块中不要计算梯度
# 初始化计数器
correct = 0 # 正确分类的样本数
total = 0 # 总样本数
# 遍历测试数据集的每个批次
for images, labels in test_loader:
# 将加载的图像和标签移动到设备(通常是 GPU)上
images = images.to(device)
labels = labels.to(device)
# 模型预测
outputs = model(images)
# 计算准确率
# 从模型输出中获取每个样本预测的类别
_, predicted = torch.max(outputs.data, 1)
# 累积总样本数
total += labels.size(0)
# 累积正确分类的样本数
correct += (predicted == labels).sum().item()
# 输出准确率,正确的 / 总的
print('Accuracy of the network on the 10000 test images: {} %'.format(100 * correct / total))
- 在测试阶段,我们通常不需要计算梯度,以提高内存效率。
- 注意看
_, predicted = torch.max(outputs.data, 1)
中的torch.max(xxx, 1)
表示对于每个输入xxx
而言,torch.max
返回一个元组,其中包含两个张量,第一个张量是最大值,第二个张量是最大值所在的索引(也就是属于哪一类)。
举个例子,在图像处理中,神经网络通常输出一个二维张量。什么样的二维张量呢?在3分类问题中输出二维张量:
上面的二维张量,由 4 个长度为 3 的一维张量构成。tensor([[0.1, 0.8, 0.3], [0.4, 0.2, 0.9], [0.7, 0.5, 0.2], [0.6, 0.2, 0.4]])
torch.max(xxx, 1)
在每一行中寻找最大值。
torch.max(xxx, 1)
返回结果为元组:
其中,0.8, 0.9, 0.7, 0.6 分别为属于索引(类别)1, 2, 0, 0 的概率(或得分)。(tensor([0.8, 0.9, 0.7, 0.6]), tensor([1, 2, 0, 0]))
- 而
_, predicted = torch.max(outputs.data, 1)
中的_
。它是一个通常用作占位符的变量名。在 Python 中,通常使用_
表示一个临时或不使用的变量。在本例中,我们不要属于索引(类别)的概率(或得分),只要最终结果,即属于哪个索引(类别),把它的值赋给predicted
测试阶段运行的结果:
可以看到达到了99.12%
以上,就是从0开始敲LeNet5的全部过程。