Fashion-MNIST 图像分类从零实现

本节我们将使用 torchvision 包,它是 PyTorch 深度学习框架的一个扩展库,主要用于构建计算机视觉模型。torchvision 主要包含以下几个部分:

  1. torchvision.datasets:提供加载数据集的函数和常用数据集接口;
  2. torchvision.models:包含常用的模型结构和预训练模型,如 AlexNet、VGG、ResNet 等;
  3. torchvision.transforms:提供常用的图像变换操作,如裁剪、旋转等;
  4. torchvision.utils:提供其他一些有用的工具和方法。

首先导入本节需要的包或模块。

In [1]:
import numpy as np
import torch
from torchvision import datasets, transforms

from caesar import d2l, hex

获取数据

我们首先引入一个多类图像分类数据集,该数据集将在后续章节中多次使用,以便我们比较不同算法在模型精度和计算效率上的差异。在图像分类数据集中,手写数字识别数据集 MNIST 是最常用的。然而,大多数模型在 MNIST 上的分类精度已超过 95%。为了更直观地观察算法间的差异,我们将采用图像内容更复杂的 Fashion-MNIST 数据集 (该数据集较小,仅几十 MB,无 GPU 的电脑也能处理)。

下面,我们将使用 datasets.FashionMNIST 来下载数据集。首次调用时,它会自动从网上获取数据。我们可以通过参数 train 来指定是下载训练数据集还是测试数据集。测试数据集也称为测试集,仅用于评估模型性能,不用于训练。

此外,我们还指定了参数 transform=transforms.ToTensor(),以将所有数据转换为 Tensor。如果不进行转换,返回的将是 PIL 图片。transforms.ToTensor() 将尺寸为 (H x W x C) 且数据范围在 [0, 255] 的 PIL 图片或数据类型为 np.uint8 的 NumPy 数组转换为尺寸为 (C x H x W)、数据类型为 torch.float32 且数据范围在 [0.0, 1.0] 的 Tensor

In [2]:
mnist_train = datasets.FashionMNIST(
    root='/data/datasets/fashion_mnist',
    train=True,
    download=True,
    transform=transforms.ToTensor(),
)
mnist_test = datasets.FashionMNIST(
    root='/data/datasets/fashion_mnist',
    train=False,
    download=True,
    transform=transforms.ToTensor(),
)

我们可以使用 len() 函数来获取数据集的大小,并通过下标访问特定的样本。训练集和测试集中每个类别的图像数量分别为 6000 和 1000。由于共有 10 个类别,因此训练集和测试集的样本总数分别为 60000 和 10000。

In [3]:
type(mnist_train), len(mnist_train), len(mnist_test)
Out[3]:
(torchvision.datasets.mnist.FashionMNIST, 60000, 10000)
In [4]:
feature, label = mnist_train[0]
feature.shape, label
Out[4]:
(torch.Size([1, 28, 28]), 9)

读取数据

Fashion-MNIST 中一共包括了 10 个类别,分别为 t-shirt(T 恤)、trouser(裤子)、pullover(套衫)、dress(连衣裙)、coat(外套)、sandal(凉鞋)、shirt(衬衫)、sneaker(运动鞋)、bag(包) 和 ankle boot(短靴)。以下函数可以将数值标签转成相应的文本标签。

In [5]:
hex.print_method(d2l.get_fashion_mnist_labels)
Out[5]:
def get_fashion_mnist_labels(labels):
    text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat', 'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
    return [text_labels[int(i)] for i in labels]

下面定义一个函数,该函数可以在一行内绘制多张图像及其对应的标签。

In [6]:
hex.print_method(d2l.show_fashion_mnist)
Out[6]:
def show_fashion_mnist(images, labels):
    _, figs = plt.subplots(1, 10, figsize=(12, 12))
    for fig, image, label in zip(figs, images, labels):
        fig.imshow(image.view(28, 28).numpy())
        fig.set_title(label)
        fig.axes.get_xaxis().set_visible(False)
        fig.axes.get_yaxis().set_visible(False)
    plt.show()
In [7]:
X, y = [], []
for i in range(10):
    X.append(mnist_train[i][0])
    y.append(mnist_train[i][1])
d2l.show_fashion_mnist(X, d2l.get_fashion_mnist_labels(y))
No description has been provided for this image

读取小批量数据样本以供模型训练使用。在实践中,数据读取往往是训练性能的瓶颈,尤其是在模型较为简单或计算硬件性能较高的情况下。PyTorch 的DataLoader提供了一个便利的功能,即通过多进程加速数据读取。我们可以通过设置num_workers参数为 4 来启用这一功能,实现数据的并行读取。

In [8]:
batch_size = 32
train_iter = torch.utils.data.DataLoader(mnist_train, batch_size=batch_size, shuffle=True, num_workers=0)
test_iter = torch.utils.data.DataLoader(mnist_test, batch_size=batch_size, shuffle=False, num_workers=0)

实现 softmax 运算

我们先来描述如何对多维Tensor进行按维度操作。以下面的例子为例,给定一个Tensor矩阵X。我们可以只对其中同一列 (dim=0) 或同一行 (dim=1) 的元素求和,并在结果中保留行和列这两个维度 (keepdim=True)。

In [9]:
X = torch.tensor([[1, 2, 3], [4, 5, 6]])
X.sum(dim=0, keepdim=True), X.sum(dim=1, keepdim=True)
Out[9]:
(tensor([[5, 7, 9]]),
 tensor([[ 6],
         [15]]))

接下来,我们定义 softmax 运算。在该函数中,矩阵X的行数代表样本数量,列数代表输出类别的数量。softmax 运算通过以下步骤计算样本对各个输出类别的预测概率:首先,对矩阵X中的每个元素应用exp函数进行指数运算;然后,对exp函数处理后的矩阵中每一行的元素求和;最后,将每行的每个元素除以该行元素的总和。这样处理后,得到的矩阵每行元素之和为 1 且均为非负值,因此每行都表示一个合法的概率分布。softmax 运算的输出矩阵中,任意一行的元素代表了一个样本在各个输出类别上的预测概率。

In [10]:
def softmax(X):
    X_exp = X.exp()
    partition = X_exp.sum(dim=1, keepdim=True)
    return X_exp / partition
In [11]:
X = torch.rand(2, 5)
X_prob = softmax(X)
In [12]:
X_prob, X_prob.sum(dim=1)
Out[12]:
(tensor([[0.3167, 0.1604, 0.1370, 0.2442, 0.1418],
         [0.1566, 0.2189, 0.2635, 0.2057, 0.1554]]),
 tensor([1., 1.]))

初始化模型参数

我们将使用向量来表示每个样本。已知每个样本的输入图像尺寸为 28 像素高和宽,模型的输入向量长度为 。该向量的每个元素对应图像中的一个像素。由于图像分为 10 个类别,单层神经网络的输出层有 10 个输出节点,因此 softmax 回归的权重参数是一个 的矩阵,偏差参数是一个 的矩阵。

In [13]:
num_inputs = 28 * 28
num_outputs = 10
W = torch.tensor(
    np.random.normal(0, 0.01, (num_inputs, num_outputs)),
    dtype=torch.float,
    requires_grad=True,
)
b = torch.zeros(num_outputs, dtype=torch.float, requires_grad=True)
W.shape, b.shape
Out[13]:
(torch.Size([784, 10]), torch.Size([10]))

注意:权重参数应按正态分布随机初始化,标准差不宜过大,以免初始化权重值差异过大,影响模型训练达到最优效果。

定义模型

参数初始化完成,引入了 softmax 运算,基础准备工作已经就绪,我们可以定义本节所描述的 softmax 分类模型了。这里通过view函数将每张原始图像转换为长度为num_inputs的向量。

In [14]:
def net(X):
    return softmax(torch.mm(X.view(-1, num_inputs), W) + b)

定义损失函数

在 softmax 分类模型中,我们使用交叉熵损失函数。为了计算损失,我们需要标签的预测概率,这可以通过 gather 函数获得。

In [15]:
y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
y = torch.LongTensor([0, 2])
y_hat.gather(1, y.view(-1, 1))
Out[15]:
tensor([[0.1000],
        [0.5000]])

交叉熵损失函数

In [16]:
def cross_entropy(y_hat, y):
    return -torch.log(y_hat.gather(1, y.view(-1, 1)))

计算分类准确率

给定一个类别的预测概率分布 y_hat,我们将预测概率最大的类别作为输出类别。如果这个输出类别与真实类别 y 一致,则认为这次预测是正确的。分类准确率是指正确预测的数量与总预测数量的比值。

In [17]:
def eval_accuracy(data_iter, net):
    l_sum, acc_sum, n = 0.0, 0.0, 0
    for X, y in data_iter:
        y_hat = net(X)
        l_sum += loss(y_hat, y).sum().item()
        acc_sum += (y_hat.argmax(dim=1) == y).float().sum().item()
        n += y.shape[0]
    return l_sum / n, acc_sum / n

训练模型

我们同样使用小批量随机梯度下降来优化模型的损失函数。

In [18]:
hex.print_method(d2l.sgd)
Out[18]:
def sgd(params, lr, batch_size):
    for param in params:
        param.data -= lr * param.grad / batch_size

在训练模型时,迭代周期数 num_epochs 和学习率 lr 都是可调整的超参数。调整它们的值可能会提高模型的分类准确性。

In [19]:
num_epochs, lr = 5, 0.1
loss = cross_entropy
for epoch in range(num_epochs):
    train_l_sum, train_acc_sum, n = 0.0, 0.0, 0
    for X, y in train_iter:
        y_hat = net(X)
        l = loss(y_hat, y).sum()
        l.backward()
        d2l.sgd([W, b], lr, batch_size)
        W.grad.data.zero_()
        b.grad.data.zero_()
        train_l_sum += l.item()
        train_acc_sum += (y_hat.argmax(dim=1) == y).float().sum().item()
        n += y.shape[0]
    test_l, test_acc = eval_accuracy(test_iter, net)
    print(
        f'epoch: {epoch + 1}, train loss: {train_l_sum / n:4f}, train acc: {train_acc_sum / n:4f}, test loss: {test_l:4f}, test acc: {test_acc:4f}'
    )
epoch: 1, train loss: 0.588189, train acc: 0.799150, test loss: 0.564757, test acc: 0.803000
epoch: 2, train loss: 0.485059, train acc: 0.833083, test loss: 0.552839, test acc: 0.814700
epoch: 3, train loss: 0.461769, train acc: 0.840700, test loss: 0.527735, test acc: 0.821200
epoch: 4, train loss: 0.453050, train acc: 0.842900, test loss: 0.460271, test acc: 0.837900
epoch: 5, train loss: 0.445627, train acc: 0.847633, test loss: 0.480611, test acc: 0.832900

模型预测

训练完成后,现在就可以演示如何对图像进行分类了。

In [20]:
for X, y in test_iter:
    true_labels = d2l.get_fashion_mnist_labels(y.numpy())
    pred_labels = d2l.get_fashion_mnist_labels(net(X).argmax(dim=1).numpy())
    titles = [f'T:{true}\nP:{pred}' for true, pred in zip(true_labels, pred_labels)]
    d2l.show_fashion_mnist(X[0:10], titles[0:10])
    break
No description has been provided for this image