- PyTorch版本:1.12.1
- PyTorch官方文档
- PyTorch中文文档
PyTorch中搭建并训练一个神经网络分为以下几步:
- 定义神经网络
- 定义损失函数以及优化器
- 训练:反向传播、梯度下降
下面以LeNet-5为例,搭建一个卷积神经网络用于手写数字识别。
1. 模型简介——LeNet-5
LeNet-5是一个经典的深度卷积神经网络,由Yann LeCun在1998年提出用于解决手写数字识别问题。该网络是第一个被广泛应用于数字图像识别的神经网络之一,也是深度学习领域的里程碑之一,被认为是卷积神经网络的起源之一。
如下图所示,LeNet-5的结构是一个7层的卷积神经网络(不含输入层),其中包括2个卷积层、2个下采样层(池化层)、2个全连接层以及输出层。
1.1 输入层(Input layer)
输入层接收大小为 32*32 的灰度手写数字图像,像素灰度值范围为0-255。为了加快训练速度以及提高模型准确性,通常会对输入图像的像素值进行归一化。
1.2卷积层C1(Convolutional layer C1)
卷积层C1含有6个卷积核,每个卷积核的大小为 5*5 ,步长为1,填充为0。卷积层C1产生6个大小为 28*28 的特征图。
1.3 下采样层S2(Subsampling layer S2)
采样层S2采用最大池化(max-pooling)操作,这可以减少特征图的大小从而提高计算效率,并且池化操作对于轻微的位置变化可以保持一定的不变性。池化层每个窗口的大小为 2*2 ,步长为2。池化层S2产生6个大小为 14*14 的特征图。
1.4 卷积层C3(Convolutional layer C3)
卷积层C3包括16个卷积核,每个卷积核的大小为 5*5 ,步长为1,填充为0。卷积层C1产生16个大小为 10*10的特征图。
1.5 下采样层S4(Subsampling layer S4)
下采样层S4采用最大池化操作,每个窗口的大小为 2*2 ,步长为2。池化层S4产生16个大小为 5*5 的特征图。
1.6 全连接层C5(Fully connected layer C5)
C5将16个大小为 5*5 的特征图拉成一个长度为400的向量,并通过一个包括120个神经元的全连接层。120是由LeNet-5的设计者根据实验得到的最佳值。
1.7 全连接层F6(Fully connected layer F6)
全连接层F6将120个神经元连接到84个神经元。
1.8 输出层(Output layer)
输出层由10个神经元组成,每个神经元对应0-9的激活值(激活值越大,是该数字的可能性越大)。模型训练时,使用交叉熵损失函数计算输出层与样本真实标签之间的误差,然后通过反向传播算法更新模型的参数(包括卷积核和全连接层)直至模型达到指定效果或者达到指定迭代次数。
在实际应用中,通常会对LeNet-5进行一些改进,例如增加网络深度、增加卷积核数量、添加正则化等方法,以进一步提高模型的准确性和泛化能力。
2. 数据集简介——MNIST
MNIST是一个手写体数字的图片数据集,包含60,000个训练图像和10,000个测试图像,由美国国家标准与技术研究所(National Institute of Standards and Technology (NIST))发起整理,一共统计了来自250个不同的人手写数字图片,其中50%是高中生,50%来自人口普查局的工作人员。数据集中的图像都是灰度图像,大小为 28*28 像素,每个像素点的值为 0 到 255 之间的灰度值。
使用torchvision中的datasets可自动下载该数据集:
train_dataset = torchvision.datasets.MNIST(root="data/", train=True, transform=transforms.ToTensor(), download=True)
test_dataset = torchvision.datasets.MNIST(root="data/", train=False, transform=transforms.ToTensor(), download=True)
其中:
-
root表示将数据集存放在当前目录下的’data’文件夹中。
-
train=True表示导入的是训练数据;train=False表示导入的是测试数据。
-
transform表示对每个数据进行的变化,这里是将其变为Tensor,Tensor是PyTorch中存储数据的主要格式。
-
download表示是否将数据下载到本地。
3. 定义神经网络
PyTorch中主要有以下两种方式定义神经网络
3.1 使用前馈神经网络方式
这种方法需要继承torch.nn.Module并且实现__init__()和forward()这两个方法。其中__init__()可以用于做一些初始化工作,比如定义输入数据、隐藏层、激活函数等;forward()是实现前向传播的核心函数,用于定义神经网络的结构和参数,在前向传播的过程中,输入的数据将按照该函数定义的神经网络结构进行计算并得到最终的输出。
import torch.nn.functional as F
from torch import nn
class MyCNN(nn.Module):
def __init__(self, in_channels):
super(MyCNN, self).__init__()
self.conv1 = nn.Conv2d(in_channels=in_channels, out_channels=6, kernel_size=5, stride=1) # 定义卷积核
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 定义最大池化层
self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1)
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)
self.fc1 = nn.Linear(in_features=16 * 4 * 4, out_features=120) # 定义全连接层
self.fc2 = nn.Linear(in_features=120, out_features=84)
self.fc3 = nn.Linear(in_features=84, out_features=10)
def forward(self, x):
x1 = self.conv1(x) # 卷积层C1
x2 = F.relu(x1) # 激活函数
x3 = self.pool1(x2) # 下采样层S2
x4 = self.conv2(x3) # 卷积层C3
x5 = F.relu(x4)
x6 = self.pool2(x5) # 下采样层S4
x7 = x.reshape(x6.shape[0], -1) # 二维变成一维,以输入到全连接层
x8 = self.fc1(x7) # 全连接层C5
x9 = F.relu(x8)
x10 = self.fc2(x9) # 全连接层F6
x11 = F.relu(x10)
x12 = self.fc3(x11) # 输出层
return x12
代码解释
-
__init__():
定义了用到的卷积核、池化层以及全连接层,其中:
- nn.Conv2d,定义二维卷积核。in_channels,输入通道数量;out_channels,输出通道数量;kernel_size,卷积核大小;stride,卷积时的步长。
- nn.MaxPool2d,定义二维最大池化层。kernel_size,池化的窗口大小;stride,池化时的步长。
- nn.Linear,定义全连接层。in_features,输入数据的大小;out_features,输出数据的大小。
-
forward():
__init__()函数中仅仅是定义了各个层,但并未将它们连接起来搭建出一个神经网络,forward()函数的作用就是搭建一个神经网络,使得输入的数据沿着指定的结构进行前向传播:
- forward除了self之外,还接收一个参数x作为输入数据。
- x = self.conv1(x):输入的x经过卷积计算后得到x1,对应于卷积层C1。
- x2 = F.relu(x1) :对卷积后的数据进行ReLU激活操作。
- x3 = self.pool1(x2) :对数据进行池化,对应于下采样层S2。
- ……
- 与上面类似,数据依次经过卷积层C3、下采样层S4、全连接层C5、全连接层F6以及输出层,从而使输入x沿着指定的路径得到最终的输出。
注:
-
为了更好的展示数据如何沿着神经网络进行前向传播,这里对每一层的输出设置了不同的变量命名,实际应用时,可以将x1~x12都写作x,只要不影响前向传播即可。
-
二维卷积以及池化操作得到的是二维的特则图,但全连接层需要一维的数据,因此需要对数据尺寸进行修改,即:
x7 = x.reshape(x6.shape[0], -1)
3.2 使用序列化方法
这种方式使用torch.nn.Sequential方式定义模型,将神经网络以序列的方式进行连接,每个层使用前面层计算的输出作为输入,并且在内部会维护层与层之间的权重矩阵和偏置向量。
from torch import nn
in_channels = 1
model = nn.Sequential(
nn.Conv2d(in_channels=in_channels, out_channels=6, kernel_size=5, stride=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(in_features=16 * 4 * 4, out_features=120),
nn.Linear(in_features=120, out_features=84),
nn.Linear(in_features=84, out_features=10)
)
3.3总结
- 第一种可以更好的根据需要搭建网络结构;
- 第二种方式网络以序列的方式搭建网络,不适用于复杂网络;
- 对于一些复杂的含有重复层的网络,可将两种方式结合使用。序列化方法定义重复层,然后使用第一种方式根据网络结构进行组装。
4. 定义损失函数以及优化器
-
损失函数
损失函数用于计算真实值和预测值之间的差异。在PyTorch官方文档中,给出了可用的损失函数列表。
这里,我们使用交叉熵损失函数torch.nn.CrossEntropyLoss()。该损失函数内部自动加上了Softmax,用于解决多分类问题,也可用于解决二分类问题。
-
优化器
优化器根据损失函数求出的损失,对神经网络的参数进行更新。在PyTorch官方文档中,给出了可用的优化器。
这里,我们使用**torch.optim.Adam()**作为我们的优化器。
from torch import nn, optim
criterion = nn.CrossEntropyLoss() # 损失函数
optimizer = optim.Adam(model.parameters()) # 优化器
其中:
- model.parameters()是待优化的参数。
5.训练模型
模型的训练主要包括3部分:
- 前向传播
- 反向传播
- 梯度下降
简单的说就是取出数据,放到模型里面跑一次得到预测值,计算与真实值之间的损失,然后计算梯度,根据梯度更新一次网络。
代码实现如下:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = MyCNN(1).to(device) # 加载模型到设备
num_epochs = 100
for epoch in range(num_epochs):
for batch_idx, (data, label) in enumerate(train_loader):
data = data.to(device=device) # 加载数据到设备
label = label.to(device=device)
# 前向传播
pre = model(data)
loss = criterion(pre, label)
# 反向传播
optimizer.zero_grad()
loss.backward()
# 梯度下降
optimizer.step()
其中:
-
torch.device(‘cuda’ if torch.cuda.is_available() else ‘cpu’):选择使用GPU或者CPU训练,若电脑有GPU且配置正确,则使用GPU训练,否则使用CPU训练(模型和数据必须都放在GPU或者CPU上)。
-
for epoch in range(num_epochs):模型训练次数。
-
for batch_idx, (data, label) in enumerate(train_loader):mini-batch对数据进行小批量训练。
-
前向传播:
- pre = model(data):将数据放入模型中训练。
- loss = criterion(pre, label):通过损失函数得到本次训练的损失。
-
反向传播:
- optimizer.zero_grad():将梯度归零。训练时通常使用mini-batch方法,如果不将梯度清零的话,梯度会与上一个batch的梯度相关,因此该函数要写在反向传播和梯度下降之前。
- loss.backward():反向传播。计算得到每个参数的梯度。
-
梯度下降
optimizer.step():执行一次优化步骤,对参数进行更新。注意:optimizer.step()只负责通过梯度下降对参数进行优化,并不负责产生梯度,梯度是loss.backward()方法产生的。
6. 测试模型
模型训练完毕后,可以使用测试集对模型进行测试:
loss = 0
with torch.no_grad(): # 关闭梯度计算
model.eval() # 评估模式
for batch_idx, (data, label) in enumerate(test_loader):
data = data.to(device=device)
label = label.to(device=device)
pre = model(data)
loss += criterion(pre, label).item()
model.train() # 训练模式
loss = loss / len(test_loader.dataset)
其中:
-
with torch.no_grad():关闭梯度计算。在训练模型时,需要计算根据反向传播计算梯度以更新参数,但在对验证集或者测试集进行预测时,并不需要更新参数,因此也就不需要计算梯度。因此,为了避免浪费计算资源,在模型评估时最后关闭梯度计算。
-
model.eval():将模型切换到评估模式。在神经网络中,出于防止过拟合等目的,一般会加入Dropout和Batch Normalization层,在模型训练阶段,根据输入数据的变化,这些层的参数也会发生变化。在评估模式下,Dropout层会让所有的网络节点都生效,而Batch Normalization层会停止计算和更新均值和方差,直接使用在训练阶段已经学出的均值和方差。
-
model.train():将模型切换到训练模式。此时Dropout层使网络中的节点以一定概率失效,Batch Normalization层根据输入的数据更新均值和方差。在将模型切换到评估模式之后,在下一次训练之前必须再切换到训练模式。
-
注意with torch.no_grad()和model.eval()的区别:
with torch.no_grad()关闭的是梯度计算,和神经网络整体有关;而model.eval()和梯度没有关系,只和Dropout和Batch Normalization这两层有关系。
7. 整体代码
以下是最终的代码(使用前馈神经网络的方式定义神经网络)。由于这里仅仅是为了介绍如何搭建一个模型,另外出于篇幅考虑,对于一些细节方面未做具体改进,主要包括以下几点:
- 除了训练集和测试集之外,还可以使用验证集评估模型性能以设置早停
- 为了得到更好的模型性能,一般会对数据进行归一化
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision
from torch import optim
from torch.utils.data import DataLoader
from torchvision import transforms
class MyCNN(nn.Module):
def __init__(self, in_channels):
super(MyCNN, self).__init__()
self.conv1 = nn.Conv2d(in_channels=in_channels, out_channels=6, kernel_size=5, stride=1) # 定义卷积核
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 定义最大池化层
self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1)
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)
self.fc1 = nn.Linear(in_features=16 * 4 * 4, out_features=120) # 定义全连接层
self.fc2 = nn.Linear(in_features=120, out_features=84)
self.fc3 = nn.Linear(in_features=84, out_features=10)
def forward(self, x):
x = self.conv1(x) # 卷积层C1
x = F.relu(x) # 激活函数
x = self.pool1(x) # 下采样层S2
x = self.conv2(x) # 卷积层C3
x = F.relu(x)
x = self.pool2(x) # 下采样层S4
x = x.reshape(x.shape[0], -1) # 二维变成一维,以输入到全连接层
x = self.fc1(x) # 全连接层C5
x = F.relu(x)
x = self.fc2(x) # 全连接层F6
x = F.relu(x)
x = self.fc3(x) # 输出层
return x
def train(model, criterion, optimizer, train_loader, device, num_epochs=200):
for epoch in range(num_epochs):
for batch_idx, (data, label) in enumerate(train_loader):
data = data.to(device=device) # 加载数据到设备
label = label.to(device=device)
# 前向传播
pre = model(data)
loss = criterion(pre, label)
# 反向传播
optimizer.zero_grad()
loss.backward()
# 梯度下降
optimizer.step()
def test(model, criterion, test_loader, device):
loss = 0
with torch.no_grad(): # 关闭梯度计算
model.eval() # 评估模式
for batch_idx, (data, label) in enumerate(test_loader):
data = data.to(device=device)
label = label.to(device=device)
pre = model(data)
loss += criterion(pre, label).item()
model.train() # 训练模式
loss = loss / len(test_loader.dataset)
return loss
def main():
batch_size = 4
num_epochs = 200
train_dataset = torchvision.datasets.MNIST(root="data/", train=True, transform=transforms.ToTensor(),
download=True) # 下载数据集
test_dataset = torchvision.datasets.MNIST(root="data/", train=False, transform=transforms.ToTensor(), download=True)
train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size,
shuffle=True) # 将数据集(Dataset)自动分成一个个的Batch,以用于批处理
test_loader = DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=True)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 选择加载数据的设备,GPU或者CPU
model = MyCNN(1).to(device) # 模型和数据应加载到同一种设备上
criterion = nn.CrossEntropyLoss() # 损失函数
optimizer = optim.Adam(model.parameters()) # 优化器
train(model, criterion, optimizer, train_loader, device, num_epochs)
print(test(model, criterion, test_loader, device))
if __name__ == '__main__':
main()