一、说明
在本单元中,我们从最简单的图像分类方法开始——一个全连接的神经网络,也称为感知器。我们将回顾一下 PyTorch 中定义神经网络的方式,以及训练算法的工作原理。
二、数据加载的实践
首先,我们使用 pytorchcv 助手来加载所有数据。
!wget https://raw.githubusercontent.com/MicrosoftDocs/pytorchfundamentals/main/computer-vision-pytorch/pytorchcv.py
import torch
import torch.nn as nn
import torchvision
import matplotlib.pyplot as plt
from torchinfo import summary
import numpy as npfrom pytorchcv import load_mnist, train, plot_results, plot_convolution, display_dataset
load_mnist(batch_size=128)
三、全连接密集神经网络
PyTorch 中的基本神经网络由许多层组成。最简单的网络将只包含一个完全连接的层,称为线性层,具有 784 个输入(输入图像的每个像素一个输入)和 10 个输出(每个类一个输出)。
正如我们上面所讨论的,我们的数字图像的尺寸是 1 × 28 × 28,即每个图像包含 28 × 28 = 784 个不同的像素。由于线性层期望其输入为一维向量,因此我们需要在网络中插入另一层,称为 Flatten,以将输入张量形状从
1 × 28 × 28 更改为 784。在 Flatten 之后,有一个主要的线性层(在 PyTorch 中称为 Dense ),它将 784 个输入转换为 10 个输出——每个类一个。我们希望
网络的第 n 个输出返回输入数字等于 n 的概率。
由于全连接层的输出未归一化为介于 0 和 1 之间,因此不能将其视为概率。此外,如果希望输出是不同数字的概率,它们都需要加起来为 1。为了将输出向量转换为概率向量,称为 Softmax 的函数通常用作分类神经网络中的最后一个激活函数。例如,softmax([−1, 1, 2]) = [0.035, 0.25, 0.705]。
在 PyTorch 中,我们通常更喜欢使用 LogSoftmax 函数,该函数还将计算输出概率的对数。为了将输出向量转换为实际概率,我们需要获取输出的torch.exp。
因此,我们的网络架构可以在 PyTorch 中使用顺序函数定义:
net = nn.Sequential(nn.Flatten(), nn.Linear(784,10), # 784 inputs, 10 outputsnn.LogSoftmax())
四、如何训练网络
以这种方式定义的网络可以将任何数字作为输入,并生成概率向量作为输出。让我们看看这个网络是如何表现的,从我们的数据集中给它一个数字:
print('Digit to be predicted: ',data_train[0][1])
torch.exp(net(data_train[0][0]))
Digit to be predicted: 5
tensor([[0.1174, 0.1727, 0.0804, 0.1333, 0.0790, 0.0902, 0.0657, 0.0871, 0.0807,0.0933]], grad_fn=<ExpBackward>)
因为我们使用 LogSoftmax 作为我们网络的最终激活,所以我们通过 torch.exp 传递网络输出以获得概率。如您所见,网络预测每个数字的相似概率。这是因为它没有接受过如何识别数字的培训。我们需要给它我们的训练数据,以便在我们的数据集上训练它。
为了训练模型,我们需要从一定大小的数据集创建批次,比如说 64 个。PyTorch 有一个名为 DataLoader 的对象,它可以自动为我们创建成批的数据:
train_loader = torch.utils.data.DataLoader(data_train,batch_size=64)
test_loader = torch.utils.data.DataLoader(data_test,batch_size=64) # we can use larger batch size for testing
训练过程步骤如下:
- 我们从输入数据集中获取一个小批量,该数据集由输入数据(特征)和预期结果(标签)组成。
- 我们计算此小批量的预测结果。
- 此结果与预期结果之间的差异是使用损失函数计算的。损失函数显示网络的输出与预期输出的差异。我们培训的目标是尽量减少损失。
- 我们计算这个损失函数相对于模型权重(参数)的梯度,然后用于调整权重以优化网络的性能。调整量由称为学习率的参数控制,优化算法的详细信息在优化器对象中定义。
- 我们重复这些步骤,直到处理整个数据集。通过数据集的一次完整传递称为纪元。
下面是一个执行一个纪元训练的函数:
def train_epoch(net,dataloader,lr=0.01,optimizer=None,loss_fn = nn.NLLLoss()):optimizer = optimizer or torch.optim.Adam(net.parameters(),lr=lr)net.train()total_loss,acc,count = 0,0,0for features,labels in dataloader:optimizer.zero_grad()out = net(features)loss = loss_fn(out,labels) #cross_entropy(out,labels)loss.backward()optimizer.step()total_loss+=loss_,predicted = torch.max(out,1)acc+=(predicted==labels).sum()count+=len(labels)return total_loss.item()/count, acc.item()/counttrain_epoch(net,train_loader)
(0.0059344619750976565, 0.8926833333333334)
以下是我们在训练时所做的:
- 将网络切换到训练模式(net.train())
- 遍历数据集中的所有批次,并为每个批次执行以下操作:
- 计算网络在此批次上所做的预测(输出)- 计算损失,即预测值和预期值
之间的差异- 通过调整网络权重来最小化损失 (optimizer.step())- 计算正确预测的事例数(准确性)
该函数计算并返回每个数据项的平均损失和训练准确性(正确猜测的案例百分比)。通过在训练期间观察这种损失,我们可以看到网络是否在改进并从提供的数据中学习。
控制测试数据集的准确性也很重要,这也称为验证准确性。具有大量参数的良好神经网络可以在任何训练数据集上以相当的准确性进行预测,但它可能很难推广到其他数据。这就是为什么在大多数情况下,我们会留出部分数据,然后定期检查模型在数据上的表现。以下是在测试数据集上评估网络的函数:
def validate(net, dataloader,loss_fn=nn.NLLLoss()):net.eval()count,acc,loss = 0,0,0with torch.no_grad():for features,labels in dataloader:out = net(features)loss += loss_fn(out,labels) pred = torch.max(out,1)[1]acc += (pred==labels).sum()count += len(labels)return loss.item()/count, acc.item()/countvalidate(net,test_loader)
(0.033262069702148435, 0.9496)
与训练函数类似,该函数计算并返回测试数据集的平均损失和准确性。
五、过拟合
通常,在训练神经网络时,我们会训练模型几个时期,观察训练和验证的准确性。一开始,训练和验证的准确性都应该提高,因为网络会拾取数据集中的模式。但是,在某些时候,可能会发生训练准确性增加而验证准确性开始降低的情况。这将表明过度拟合,即模型在训练数据集上表现良好,但在新数据上表现不佳。
下面是可用于执行训练和验证的训练函数。它打印每个纪元的训练和验证精度,并返回可用于在图形上绘制损失和精度的历史记录。
def train(net,train_loader,test_loader,optimizer=None,lr=0.01,epochs=10,loss_fn=nn.NLLLoss()):optimizer = optimizer or torch.optim.Adam(net.parameters(),lr=lr)res = { 'train_loss' : [], 'train_acc': [], 'val_loss': [], 'val_acc': []}for ep in range(epochs):tl,ta = train_epoch(net,train_loader,optimizer=optimizer,lr=lr,loss_fn=loss_fn)vl,va = validate(net,test_loader,loss_fn=loss_fn)print(f"Epoch {ep:2}, Train acc={ta:.3f}, Val acc={va:.3f}, Train loss={tl:.3f}, Val loss={vl:.3f}")res['train_loss'].append(tl)res['train_acc'].append(ta)res['val_loss'].append(vl)res['val_acc'].append(va)return res# Re-initialize the network to start from scratch
net = nn.Sequential(nn.Flatten(), nn.Linear(784,10), # 784 inputs, 10 outputsnn.LogSoftmax())hist = train(net,train_loader,test_loader,epochs=5)
Epoch 0, Train acc=0.892, Val acc=0.893, Train loss=0.006, Val loss=0.006
Epoch 1, Train acc=0.910, Val acc=0.899, Train loss=0.005, Val loss=0.006
Epoch 2, Train acc=0.913, Val acc=0.898, Train loss=0.005, Val loss=0.006
Epoch 3, Train acc=0.915, Val acc=0.897, Train loss=0.005, Val loss=0.006
Epoch 4, Train acc=0.916, Val acc=0.897, Train loss=0.005, Val loss=0.006
此函数记录消息的准确性来自每个纪元的训练和验证数据。它还将此数据作为字典(称为历史记录)返回。然后,我们可以可视化这些数据,以更好地了解我们的模型训练。
plt.figure(figsize=(15,5))
plt.subplot(121)
plt.plot(hist['train_acc'], label='Training acc')
plt.plot(hist['val_acc'], label='Validation acc')
plt.legend()
plt.subplot(122)
plt.plot(hist['train_loss'], label='Training loss')
plt.plot(hist['val_loss'], label='Validation loss')
plt.legend()
左图显示训练精度增加(对应于网络学习,以越来越好地分类我们的训练数据),而验证精度开始降低。
右图显示了训练损失减少(对应于网络性能越来越好),而验证损失增加(对应于网络性能越来越差)。这些图形将指示模型过度拟合。
六、可视化网络权重
我们网络中的密集层也称为线性,因为它对其输入执行线性变换,可以定义为 y = Wx + b,其中 W 是权重矩阵,b 是偏差。权重矩阵 W 实际上负责我们的网络可以做什么,即识别数字。
在我们的例子中,它的大小为 784 × 10,因为它为输入图像生成 10 个输出(每个数字一个输出)。
让我们可视化神经网络的权重,看看它们是什么样子的。当网络比仅一层更复杂时,可能很难像这样可视化结果,因为在复杂的网络中,权重在可视化时没有多大意义。然而,在我们的例子中,权重矩阵W的10个维度中的每一个都对应于单个数字,因此可以可视化以查看数字识别是如何发生的。例如,如果我们想看看我们的数字是否为 0,我们将输入数字乘以 W[0] 并通过 softmax 归一化传递结果以获得答案。
在下面的代码中,我们将首先将矩阵 W 放入变量weight_tensor。它可以通过调用 net.parameters() 方法(它同时返回 W 和 b)来获取,然后调用 next 以获取两个参数中的第一个。然后我们将遍历每个维度,将其重塑为 28 × 28 大小,然后绘制图。您可以看到 10 个权重张量维度有点类似于它们分类的数字的平均形状:
weight_tensor = next(net.parameters())
fig,ax = plt.subplots(1,10,figsize=(15,4))
for i,x in enumerate(weight_tensor):ax[i].imshow(x.view(28,28).detach())
七、多层感知器
为了进一步提高准确性,我们可能希望包含一个或多个隐藏层。这里需要注意的重要一点是层之间的非线性激活函数,称为ReLU。深度学习中使用的其他激活函数是sigmoid和tanh,但ReLU最常用于计算机视觉,因为它可以快速计算,并且使用其他函数不会带来任何显着的好处。
这个网络可以在 PyTorch 中使用以下代码定义:
net = nn.Sequential(nn.Flatten(), nn.Linear(784,100), # 784 inputs, 100 outputsnn.ReLU(), # Activation Functionnn.Linear(100,10), # 100 inputs, 10 outputsnn.LogSoftmax(dim=0))summary(net,input_size=(1,28,28))
==========================================================================================
Layer (type:depth-idx) Output Shape Param #
==========================================================================================
├─Flatten: 1-1 [1, 784] --
├─Linear: 1-2 [1, 100] 78,500
├─ReLU: 1-3 [1, 100] --
├─Linear: 1-4 [1, 10] 1,010
├─LogSoftmax: 1-5 [1, 10] --
==========================================================================================
Total params: 79,510
Trainable params: 79,510
Non-trainable params: 0
Total mult-adds (M): 0.08
==========================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.00
Params size (MB): 0.32
Estimated Total Size (MB): 0.32
==========================================================================================
在这里,我们使用 summary() 函数来显示网络的详细逐层结构以及其他一些有用的信息。特别是,我们可以看到:
- 网络的逐层结构,以及每层的输出大小
- 每层以及整个网络的参数数量。网络拥有的参数越多,需要训练的数据样本就越多,而不会过度拟合。
让我们看看参数的数量是如何计算的。第一个线性层有 784 个输入和 100 个输出。该层由 W1 × x + b 1 定义,其中 W1 的大小为 784 × 100,b 1 - 100。因此,此图层的参数总数为 784 × 100 + 100 = 78500。
同样,第二层的参数数为 100 × 10 + 10 = 1010。激活函数以及展平层没有参数。
我们可以使用另一种语法来通过使用类来定义相同的网络:
from torch.nn.functional import relu, log_softmaxclass MyNet(nn.Module):def __init__(self):super(MyNet, self).__init__()self.flatten = nn.Flatten()self.hidden = nn.Linear(784,100)self.out = nn.Linear(100,10)def forward(self, x):x = self.flatten(x)x = self.hidden(x)x = relu(x)x = self.out(x)x = log_softmax(x,dim=0)return xnet = MyNet()summary(net,input_size=(1,28,28),device='cpu')
==========================================================================================
Layer (type:depth-idx) Output Shape Param #
==========================================================================================
├─Flatten: 1-1 [1, 784] --
├─Linear: 1-2 [1, 100] 78,500
├─Linear: 1-3 [1, 10] 1,010
==========================================================================================
Total params: 79,510
Trainable params: 79,510
Non-trainable params: 0
Total mult-adds (M): 0.08
==========================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.00
Params size (MB): 0.32
Estimated Total Size (MB): 0.32
==========================================================================================
您可以看到神经网络的结构与顺序网络相同,但定义更明确。我们的自定义神经网络由从 torch.nn.Module 类继承的类表示。
类定义由两部分组成:
- 在构造函数 (__init__) 中,我们定义了网络将具有的所有层。这些层存储为类的内部变量,PyTorch 会自动知道这些层的参数在训练时应该优化。在内部,PyTorch 使用 parameters() 方法来查找所有可训练的参数和 nn。模块将自动从所有子模块收集所有可训练参数。
- 我们定义了对神经网络进行前向传递计算的前向方法。在我们的例子中,我们从一个参数张量 x 开始,并显式地将其传递到所有层和激活函数,从展平开始,直到最终的线性层输出。当我们通过写出 = net(x) 将神经网络应用于某些输入数据 x 时,调用正向方法。
事实上,顺序网络以非常相似的方式表示,它们只是存储一个层列表并在前向传递期间按顺序应用它们。在这里,我们有机会更明确地表示这个过程,这最终给了我们更大的灵活性。这就是使用类进行神经网络定义是推荐和首选做法的原因之一。
您现在可以尝试使用我们上面定义的完全相同的训练函数来训练此网络:
hist = train(net,train_loader,test_loader,epochs=5)
plot_results(hist)
Epoch 0, Train acc=0.962, Val acc=0.951, Train loss=0.033, Val loss=0.034
Epoch 1, Train acc=0.964, Val acc=0.951, Train loss=0.033, Val loss=0.034
Epoch 2, Train acc=0.964, Val acc=0.954, Train loss=0.033, Val loss=0.033
Epoch 3, Train acc=0.966, Val acc=0.955, Train loss=0.032, Val loss=0.033
Epoch 4, Train acc=0.966, Val acc=0.957, Train loss=0.032, Val loss=0.033
八、小结语
在 PyTorch 中训练神经网络可以通过训练循环进行编程。这似乎是一个复杂的过程,但在现实生活中我们需要编写一次,然后我们可以稍后重用此训练代码而无需更改它。
我们可以看到,单层和多层密集神经网络表现出相对不错的性能,但是如果我们尝试将它们应用于真实世界的图像,精度不会太高。在下一个单元中,我们将介绍卷积的概念,这使我们能够获得更好的图像识别性能。