2.2.4. 采用CNN 对手写数字进行分类#

前面的章节我们已经复现了前馈神经网络,对手写数字MNIST数据集成功进行了分类。在学习了卷积神经网络 的基本架构与理论知识后,为了加深理解并为后期更深入的学习做铺垫,让我们开始一起尝试用PyTorch构建 一个使用卷积神经网络的MNIST分类器!

首先,让我们创建一个note,并导入需要的库。

chapter_2_2_4_01.py#
1import pandas
2import numpy as np
3import torch
4import torch.nn as nn
5from torch.utils.data import Dataset, DataLoader
6import torchvision
7import matplotlib.pyplot as plt
8
  1. 加载数据集

第2.1节中,我们采用读取文件的方式载入了100条MNIST数据。本节中,为了进一步学习采用Pytorch实现 神经网络并进行训练,我们将加载完整的MNIST数据集,同时使之满足PyTorch神经网络对输入的要求。在这 里,我们采用两种方法进行数据集的加载:实例化一个torchvision.datasets.MNIST对象,它会直接从 官网上下载数据集并加载;自定义一个继承torch.utils.data.Dataset的数据集类,读取csv文件获得数据。

第一种方法是从官网上下载数据集、解压并加载。其中,root参数代表数据集存放的根目录,train参数为 True表示获取训练集,为False表示获取测试集。Transform参数表示对数据集的变换操作,在这里我们采 用ToTensor()函数将数据转换成torch.tensor类型,以便输入PyTorch神经网络。download参数表示 从官网上下载数据集的压缩包。如果root目录下已经存在我们所需要的压缩包文件,就不再重复下载。

chapter_2_2_4_02.py#
 1import torchvision
 2
 3# 通过torch 官方渠道下载数据集
 4train_dataset = torchvision.datasets.MNIST(
 5    root='../data/mnist',
 6    transform=torchvision.transforms.ToTensor(),
 7    download=True
 8)
 9test_dataset = torchvision.datasets.MNIST(
10    root='../data/mnist',
11    train=False,
12    transform=torchvision.transforms.ToTensor(),
13    download=True
14)

第二种方法是通过继承Dataset类来实现。实现这个数据集类的基础是读取出csv文件,读者可从2.1.3节的 链接中得到MNIST训练集和测试集的csv文件。我们采用pandas.read_csv()函数来完成csv文件的读取。 数据集实际上是一个可迭代对象,我们可以通过定义__getitem__()函数实现获取某一下标的元素,具体实 现代码如下。其中,在获取元素时,除了得到标签,即手写数字图像对应的真实数字,和图像本身的灰度像素 值以外,我们还额外返回了标签对应的独热(one hot)编码向量target。独热编码又称为一位有效编码, 它是一个长度为标签总数量的向量,每个标签对应其中的一位且被置为1,其余位为0。这样,我们在训练的时 候就可以直接用这个独热编码向量和网络的输出计算损失函数值,十分便捷。我们在后面的代码中使用的训练 集和测试集都是通过实例化这个数据集类得到的。

chapter_2_2_4_03.py#
 1import pandas
 2import torch
 3from torch.utils.data import Dataset
 4
 5
 6class MnistDataset(Dataset):
 7    def __init__(self, csv_file):
 8        self.data_df = pandas.read_csv(csv_file, header=None)
 9
10    def __len__(self):
11        return len(self.data_df)
12
13    def __getitem__(self, index):
14        label = self.data_df.iloc[index, 0]
15        target = torch.zeros(10)
16        target[label] = 1.0
17        image_values = torch.FloatTensor(
18            self.data_df.iloc[index, 1:].values) / 255.0
19        return label, image_values, target
20
21
22# 实例化,获得数据集
23train_dataset = MnistDataset('../data/mnist_train.csv')
24test_dataset = MnistDataset('../data/mnist_test.csv')

在2.1.2节中,我们说明了神经网络通常将数据集划分为若干个批量,以批量为单位将数据输入网络进行训练。 torch.utils.data.DataLoader是Pytorch中用来处理模型输入数据的一个工具类,能够帮助我们自动 完成对数据集采样、形成批量的过程。我们将数据集和对应的批量大小batch_size作为参数传给DataLoader, 每次从DataLoader中取出一个批量的数据进行训练即可。

chapter_2_2_4_04.py#
1from torch.utils.data import DataLoader
2
3from chapter_2_2_4_03 import train_dataset
4
5train_loader = DataLoader(train_dataset, batch_size=16)
6print(next(iter(train_loader)))

我们用iter()将loader转换为迭代器,然后通过next()取出迭代器中的第一个对象,输出结果如下。 我们可以发现,DataLoader将一个批量中每条数据的label、image_values、target分别组合成一个 张量,装进一个列表里。我们在后续的训练中可以通过for循环,从loader中取出一个批量的label、 image_values、target,输入网络进行训练。

图2-46 查看一个批量的数据

图2-46 查看一个批量的数据#

  1. CNN分类器

现在我们需要思考如何用卷积核来代替全连接层。要解决的第一个问题是,卷积过滤器需要在二维图像上工作, 而现在输入网络的是一个简单的一维像素值列表。一个简单而快捷的解决方案是,将image_data_tensor变 形为(28,28)。实际上,因为PyTorch的卷积核的输入张量有4个元素(批处理大小、通道、高度、宽度), 因此我们要输入四维张量。在训练中,我们可以在数据输入神经网络前通过view(-1, 1, 28, 28)函数改 变数据的形状。其中,设为-1的维度表示根据数据形状自动确定对应维度的大小。

在下面的代码中,我们通过继承torch.nn.Module实现了CNN分类器。torch.nn.Module是所有神经网络 模块的基类,一个Module中可以包含其他的Module。Module类中包含网络各层的定义及forward() 方法, 其中forward()方法定义了网络的前向传播过程。我们想要利用PyTorch搭建自己的神经网络, 需要继承 Module类,把网络中具有可学习参数的层放在构造函数__init__() 中,并实现forward()方法。

chapter_2_2_4_05.py#
 1from torch import nn
 2
 3
 4class Classifier(nn.Module):
 5    """
 6    CNN 实现的手写数字分类器
 7    """
 8
 9    def __init__(self):
10        super().__init__()
11        self.model = nn.Sequential(
12            nn.Conv2d(1, 16, kernel_size=5, padding=2),
13            nn.ReLU(),
14            nn.MaxPool2d(kernel_size=2),
15            nn.Conv2d(16, 32, kernel_size=5, padding=2),
16            nn.ReLU(),
17            nn.MaxPool2d(kernel_size=2),
18            nn.Flatten(),
19            nn.Linear(32 * 7 * 7, 10),
20            nn.Sigmoid()
21        )
22
23    def forward(self, inputs):
24        return self.model(inputs)

该神经网络的第1个元素是卷积层nn.Conv2d。其中,第1个参数是输入通道数,对于单色图像是1; 第2个参数是输出通道的数量。在上面的代码中,我们创建了10个卷积核,从而生成10个特征图。第3个参 数kernel_size是卷积核的大小,我们将卷积核尺寸设置为5×5。第四个参数stride为步长,我们设置步长 为1,最后将padding设为2。

MNIST图像的大小为28×28,卷积核的大小为5×5,步长为1,padding为2,输出的特征图的大小 为\(\frac {28+2 \times 2-5-1}{1} +1=28\),28×28像素。

与之前一样,对于每一层的输出,我们需要一个非线性激活函数。在这里我们使用ReLU。

接下来,我们采用窗口大小为2的最大池化层对结果进行下采样,这样特征图的大小就变成了14×14。

第二个卷积层的代码与第一个卷积层类似。我们使用32个5×5的卷积核,步长为1,padding为2,对特征图进行卷积操作,输出的特征图仍保持大小不变。同样地,我们对输出结果采用ReLU函数进行激活,并进行窗口大小为2的最大池化操作,得到32个7×7的特征图。

在网络的最后一个部分,我们首先将之前的前向传播的大小为(32, 7, 7) 的数据通过2.2.2小节中所提及的nn.Flatten() 展平,将特征图转换成包含32×7×7=1568个值的一维列表。最后,我们采用一个全连接层把这1568个值映射到10个输出节点,每个节点都用一个S型激活函数。之所以需10个输出节点,是因需将图像分类结果对应到10个数字中的某一个。

CNN分类器架构图如图2-47所示。

图2-47 CNN分类器架构图

图2-47 CNN分类器架构图#

chapter_2_2_4_06.py#
 1import torch
 2from torch import nn
 3
 4from chapter_2_2_4_05 import Classifier
 5
 6# 实例化模型
 7classifier_network = Classifier()
 8# 损失函数
 9loss_function = nn.MSELoss()
10# 优化器
11optimizer = torch.optim.SGD(classifier_network.parameters(), lr=0.01)
12# 迭代次数计数器
13counter = 0
14# 记录loss
15progress = []

我们根据反向传播算法的步骤对分类器模型训练20轮(epoch)。首先,我们将数据输入模型中进行前向传 播,得到输出结果output。根据我们对网络架构的定义,output是一个长度为10的张量,每个元素对应了 神经网络认为输入图片是对应数字的概率。然后我们将预测值output和独热编码的真实值target共同输入 损失函数中计算损失值loss。最后,我们将损失值反向传播,更新模型参数。

chapter_2_2_4_07.py#
 1from torch.utils.data import DataLoader
 2
 3from chapter_2_2_4_03 import train_dataset
 4from chapter_2_2_4_06 import *
 5
 6# GPU or CPU 环境判断
 7device = torch.device("cpu")
 8if torch.cuda.is_available():
 9    device = torch.device("cuda:0")
10
11train_loader = DataLoader(train_dataset, batch_size=16)
12
13classifier_network = classifier_network.to(device)
14loss_tmp = 0
15for i in range(20):
16    for label, image_data_tensor, target in train_loader:
17        reshaped_inputs = image_data_tensor.view(-1, 1, 28, 28)
18        output = classifier_network(reshaped_inputs.to(device))
19
20        loss = loss_function(output, target.to(device))
21        loss_tmp += loss.mean().item()
22
23        counter += 1
24        if counter % 500 == 0:
25            progress.append(loss_tmp / 500)
26            loss_tmp = 0
27
28        optimizer.zero_grad()
29        loss.backward()
30        optimizer.step()
31
32    print(f'epoch = {i + 1}, counter = {counter}')
  1. 可视化损失值

为了更直观地得到损失值的变化,我们定义一个绘图函数plot_progress,采用matplotlib.pyplot绘 制损失值的变化曲线图。

chapter_2_2_4_08.py#
 1import os
 2
 3import numpy as np
 4import matplotlib.pyplot as plt
 5
 6from chapter_2_2_4_07 import *
 7
 8
 9def plot_progress(data, interval):
10    plt.figure(figsize=(9, 4))
11    plt.plot(np.arange(1, len(data) + 1), data, label='loss')
12
13    plt.xticks(np.arange(0, len(data) + 1, len(data) / 5),
14               np.arange(0, len(data) + 1, len(data) / 5,
15                         dtype=int) * interval)
16    plt.legend()
17    plt.savefig(os.path.join("..",
18                             "..",
19                             "_static",
20                             "2",
21                             "2.2",
22                             "2-48.png"))
23    plt.show()
24
25
26plot_progress(progress, 500)

运行上述代码,我们可以得到CNN分类器的损失值变化图如下所示。我们采用了随机梯度下降进行优化,因此损 失值会出现一定波动,但总体上看来,损失值迅速下降并接近0,这符合我们的期望。

图2-48 CNN分类器训练损失函数值变化

图2-48 CNN分类器训练损失函数值变化#

  1. 测试网络

在网络训练完毕后,我们通过下面的代码,利用测试集对网络的泛化能力进行测试。其中,我们对前向传播获 取到的输出用detach()方法对神经网络的反向传播进行截断,表明获得的输出结果不需要计算梯度,然后采 用argmax()方法得到结果最大的节点对应的下标,这便是网络的预测结果,我们可通过汇总预测正确的次数 以计算出准确率。

chapter_2_2_4_09.py#
 1from chapter_2_2_4_08 import *
 2from chapter_2_2_4_03 import test_dataset
 3
 4scores = 0
 5for label, image_data_tensor, target in test_dataset:
 6    reshaped_inputs = image_data_tensor.view(1, 1, 28, 28)
 7    answer = classifier_network(reshaped_inputs.to(device)).detach()[0]
 8    if answer.argmax() == label:
 9        scores += 1
10
11print(scores / len(test_dataset))

在这个简单的CNN分类器模型上,我们仅通过10轮训练便获得了高达98%的准确率,这体现出CNN在图像分类 领域的优势和应用价值! 其中CNN分类器的完整代码可参考附录或扫描二维码下载。