1 前言

在上一节中,我们学习了softmax回归的的基本概念、运算、损失函数和模型预测评估的方法,准备了Fashion-MNIST数据集用于训练用以图像分类的模型【DeepLearning】1-3线性神经网络——softmax回归和图像分类数据集介绍。本节内容会更加深度地了解softmax回归的细节,并引入Fashion-MNIST数据集,像线性回归一样从零开始实现softmax回归。

2 导入所需的函数模块

import torch
from IPython import display
from d2l import torch as d2l

3 softmax回归的从零开始实现

3.1 生成数据集

利用数据迭代器,以256为批量大小,获取和读取Fashion-MNIST数据集,返回训练集和验证集的数据迭代器,详见softmax回归和图像分类数据集介绍

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

3.2 初始化模型参数

由于图片是一个三维数据,因此要展平每一个图像,将其视为长度为28×28 = 784的行向量。这样每一个样本拥有784个features,而数据集有10个类别,因此输入为784,输出为10。 W W W 设置为一个784×10的矩阵, b b b 设置为一个1×10的行向量,最终得到的1×10行向量每一个值代表该输入对应这一类的预测 o j o_j oj

num_inputs = 784
num_outputs = 10

W = torch.normal(0, 0.01 ,size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)

3.3 定义softmax操作

回顾一下softmax运算: softmax ( X ) i j = exp ( X i j ) ∑ k exp ( X i k ) \text{softmax}(X)_{ij} = \frac{\text{exp}(X_{ij})}{\sum\limits_{k} \text{exp}(X_{ik})} softmax(X)ij=kexp(Xik)exp(Xij)

def softmax(X):
    X_exp = torch.exp(X)
    partition = X_exp.sum(1, keepdim=True)
    return X_exp / partition

注:softmax操作由三个步骤组成,这里输入的参数是未规范化的预测 o j o_j oj
1、对每一项求指数幂。
2、对每一行求和,得到每个样本的规范化常数,也就是partition。
3、将每一行除以规范化常数,确保它们加和为1。

我们可以做一个验证来直观地感受一下。

X = torch.normal(0, 1, (2, 5))
X_prob = softmax(X)
X_prob, X_prob.sum(1)
(tensor([[0.0780, 0.2622, 0.1195, 0.0273, 0.5129],
         [0.1994, 0.0324, 0.2656, 0.2574, 0.2452]]),
 tensor([1., 1.]))

3.4 定义模型

下面的代码定义了如何通过网络映射对应到输出。

def net(X):
    return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)

注:
1、这里我们的输入变成了图像展开后的向量,而非3.3中的未规范化预测。 o o o 是通过 torch.matmul(X.reshape((-1, W.shape[0])), W) + b 得到的。
2、X.reshape((-1, W.shape[0]))可以将三维图像转换成行向量,必需要进行reshape操作。

3.5 定义损失函数

首先,我们回顾一下softmax回归的损失函数:损失函数只与正确分类的概率有关,和其他类无关。
y i = { 1 if i=y 0 otherwise y_i = \left\{\begin{array}{ll} 1 & \text{if i=y} \\ 0 & \text{otherwise} \end{array} \right. yi={10if i=yotherwise L ( y , y ^ ) = − log ⁡ y y ^ L(y,\hat{y}) = - \log{\hat{y_y}} L(y,y^)=logyy^
1、首先,我们创建一个数据y_hat,其中包含2个样本在3个类别的预测概率,使用y作为y_hat中概率的索引。

y = torch.tensor([0, 2]) #索引
y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
y_hat[[0,1], y] #拿出第0个样本的第0个类别,拿出第1个样本的第2个类别。
tensor([0.1000, 0.5000])

2、实现交叉熵损失函数

def cross_entropy(y_hat, y):
    return -torch.log(y_hat[range(len(y_hat)), y])

注:y_hat的每一行代表一个样本,每一列代表一个类别。[range(len(y_hat)), y]从y_hat里每一样本中找到正确的类别所对应的概率,得到概率的自然对数,取负。

cross_entropy(y_hat, y)
tensor([2.3026, 0.6931])

3.6 定义精度

3.6.1 分类精度

将预测类别与真实类别作比较,并返回预测正确的样本数量。

def accuracy(y_hat, y): 
    if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
        y_hat = y_hat.argmax(axis=1)
    cmp = y_hat.type(y.dtype) == y
    return float(cmp.type(y.dtype).sum())

注:
1、在多分类问题中,模型的输出 y_hat 通常是一个二维数组,这里通过检查 y_hat 的形状来判断是否为多分类问题。
2、使用argmax获得每行中最大元素索引,即为预测的类别。
3、由于等式运算符 “==” 对于数据类型很敏感,因此一定要确保y_hat与y的数据类型一致,然后将 y_hat 与 y 进行元素比较。
3、cmp 是一个布尔型的数组,表示预测结果与真实标签是否一致。如果一致,对应位置为 True(1),否则为 False(0)。
4、cmp.sum() 会对布尔数组 cmp 进行求和,结果就是预测正确的样本数量。

用预测正确的数量与样本数量作比值,我们就可以计算分类精度。

accuracy(y_hat, y) / len(y)
# 0.5

3.6.2 Accumulator(了解)

这里我们定义一个Accumulator,用于对多个变量进行累加。

class Accumulator:
    def __init__(self, n):
        self.data = [0.0] * n

    def add(self, *args):
        self.data = [a + float(b) for a, b in zip(self.data, args)]

    def reset(self):
        self.data = [0.0] * len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

3.6.3 指定数据集上模型的精度

对于任意数据迭代器,我们可以评估在任意模型net上的准确率。

def evaluate_accuracy(net, data_iter): 
    if isinstance(net, torch.nn.Module):
        net.eval() #将模型设置为评估模式
    metric = Accumulator(2) #正确预测数、预测总数
    for X, y in data_iter:
        metric.add(accuracy(net(X), y), y.numel())
    return metric[0] / metric[1] #分类正确的样本数/总样本数

注:
1、首先,先检查我们给出的net是否为torch.nn.Module的实例,如果是,则将模型设置为评估模式。
2、metric是一个累加器对象,在这里我们将Accumulator中创建了两个变量,分别用于存储正确预测数和预测总数。
3、metric.add()方法将预测正确数和样本总数累加到metric中。预测正确数利用accurary得到,样本总数直接对y计数即可。
4、最后,用预测正确的数量与样本数量作比值,我们就可以计算分类精度。

3.7 定义优化算法

这里我们还是用第一节定义的小批量随机梯度下降来优化模型的损失函数,学习率为0.1。详见【DeepLearning】1-1线性神经网络——线性回归的从零开始实现

lr = 0.1

def updater(batch_size):
    return d2l.sgd([W, b], lr, batch_size)

3.8 训练过程

3.8.1 训练模型一轮

在训练模型时通常需要训练几轮,我们首先定义一个函数来训练模型一轮。

def train_epoch_ch3(net, train_iter, loss, updater):
    if isinstance(net, torch.nn.Module):
        net.train()
    metric = Accumulator(3)
    for X, y in train_iter:
        y_hat = net(X)
        l = loss(y_hat, y)
        if isinstance(updater, torch.optim.Optimizer):
            updater.zero_grad()
            l.backward()
            updater.step()
            metric.add(float(l) * len(y), accuracy(y_hat, y), y.size().numel())
        else:
            l.sum().backward()
            updater(X.shape[0])
            metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
    return metric[0] / metric[2], metric[1] / metric[2]

注:
1、updater是更新模型参数的常用函数,它接受批量大小作为参数。如sgd函数。
2、首先,先检查我们给出的net是否为torch.nn.Module的实例,如果是,则将模型设置为训练模式。
3、这次我们将Accumulator中创建了三个变量,分别用于累加总损失、预测正确数量(accuracy)以及样本总数(y.numel())。
4、遍历数据集,计算预测值和损失。
5、我们可以判断updater是PyTorch的Optimizer对象还是自定义的更新函数,以确保两种情况都能正确运行。
(1)如果updater是一个PyTorch的Optimizer对象,我们执行以下操作:首先将梯度清零,然后对当前损失求梯度,更新模型参数,最后将使用metric.add()方法累加当前批次的损失、预测正确的数量和样本数量。
(2)如果updater是一个自定义的更新函数,我们执行以下操作:首先对损失求和并计算梯度,然后调用自定义的更新函数updater(X.shape[0]),传入当前批次的样本数量,最后将使用metric.add()方法累加当前批次的损失、预测正确的数量和样本数量。
6、最后,我们将返回训练一轮后的平均损失和平均精度。

3.8.2 Animator(了解)

我们还需要定义一个在动画中绘制图表的使用程序Animator,它可以直观显示每一轮模型训练后训练集损失、训练精度和测试精度的变化。

class Animator:
    def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None,
                 ylim=None, xscale='linear', yscale='linear',
                 fmts=('-', 'm--', 'g--', 'r:'), nrows=1, ncols=1,
                 figsize=(3.5, 2.5)):
        if legend is None:
            legend=[]
        d2l.use_svg_display()
        self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
        if nrows * ncols == 1:
            self.axes = [self.axes,]
        self.config_axes = lambda: d2l.set_axes(
            self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
        self.X, self.Y, self.fmts = None, None, fmts

    def add(self, x, y):
        if not hasattr(y, "_ _len_ _"):
            y = [y]
        n = len(y)
        if not hasattr(x, "_ _len_ _"):
            x = [x] * n
        if not self.X:
            self.X = [[] for _ in range(n)]
        if not self.Y:
            self.Y = [[] for _ in range(n)]
        for i, (a, b) in enumerate(zip(x, y)):
            if a is not None and b is not None:
                self.X[i].append(a)
                self.Y[i].append(b)
        self.axes[0].cla()
        for x, y, fmt in zip(self.X, self.Y, self.fmts):
            self.axes[0].plot(x, y, fmt)
        self.config_axes()
        display.display(self.fig)
        display.clear_output(wait=True)

3.8.3 训练函数

整合以上内容,现在我们可以实现这个训练函数,它将训练模型运行多轮,并在训练过程中记录和绘制训练损失、训练精度和测试精度的变化曲线。

def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater):
    animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
                        legend=['train loss', 'train acc', 'test acc'])
    for epoch in range(num_epochs):
        train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
        test_acc = evaluate_accuracy(net, test_iter)
        animator.add(epoch + 1, train_metrics + (test_acc,))
    train_loss, train_acc = train_metrics

注:
1、函数需要的参数有: (net)要训练的神经网络模型、(train_iter)训练数据迭代器、(test_iter)测试数据迭代器、(loss)损失函数、(num_epochs)训练总轮数、(updater)用于更新模型参数的优化器。
2、初始化绘图工具animator。这里设置了更纵坐标的名称、横纵坐标的范围以及图例标签,在此不详细解释。
3、开始训练循环。首先用3.8.1定义的训练模型一轮的函数,返回训练一轮后的平均损失和平均精度,再用3.6.3定义的指定数据集上模型的精度函数,返回测试精度,最后我们以图像的形式显示每训练模型一轮,模型的损失、训练精度以及测试精度的变化。

现在让我们训练模型10个迭代周期,检查以下模型的训练结果。

num_epochs = 10
train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, updater)

训练模型10轮
可以发现,训练损失随着每一轮的训练显著下降,而训练精度和测试精度提升至0.9左右,尽管这个精度并不是很高,但我们可以通过增加训练轮次进一步提升模型精度,后续也会通过其他算法来精进模型。

3.9 对图像分类预测

现在,我们已经训练好我们的分类模型,让我们给定一系列图像来进行预测他们的分类是否正确。这里我们只预测六张图片。

def predict_ch3(net, test_iter, n=6):
    for X, y in test_iter:
        break
    trues = d2l.get_fashion_mnist_labels(y)
    preds = d2l.get_fashion_mnist_labels(net(X).argmax(axis=1))
    titles = [true + '\n' + pred for true, pred in zip(trues, preds)]
    d2l.show_images(X[0:n].reshape((n, 28, 28)), 1, n, titles=titles[0:n])

注:
1、首先我们要获取该这六个样本的真实标签和预测标签。
2、然后生成每个图像的标题,格式为"真实标签\n预测标签",即真实标签在上,预测标签在下。
3、展示图像。从输入图像张量X中取出前n个图像,重塑为(n, 28, 28)的形状,这表示n个28x28的图像,1, n 表示展示图像的排列方式,即1行n列,最后为每个展示的图像提供对应的标题。

predict_ch3(net, test_iter)

预测
可见我们的六张图像全部预测正确。

4 小结

训练softmax回归模型与训练线性回归模型非常相似:首先要读取数据集,再定义模型和损失函数,然后选择优化算法训练模型。与线性回归不同的是,我们这里对预测的结果是通过精度表示的,因为对于分类问题来说,分类结果只有True(1) or False(0)。softmax的损失函数也不同,为交叉熵损失函数,采用实际标签的预测概率的负对数似然,由于篇幅有限,本节并没有对交叉熵损失函数进行推导。最后,我们将上述内容整合为一个训练函数,这个训练函数未来还会进行改进。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐