动手学人工智能-线性神经网络6-Softmax回归的从零开始实现

328 阅读8分钟

1. softmax回归的基本概念与实现

在深入代码实现之前,我们先了解softmax回归的基本概念。softmax回归是一种用于分类问题的模型,通过把输入映射到每个类别的概率上来进行预测。它适用于多类别分类任务,例如图片分类中的手写数字识别。

2. 初始化模型参数

和线性回归类似,softmax回归同样需要初始化权重参数与偏置项。在本实现中,每个图像被展平为长度为 784784 的向量(因为Fashion-MNIST中的图片大小为 28×28=78428 \times 28 = 784)。以下代码展示了如何初始化参数 WWbb,其中权重参数使用均值为 00、标准差为 0.010.01 的正态分布随机生成,而偏置初始化为 00

import torch

batch_size = 256  # 小批量大小
num_inputs = 784  # 输入数据维度
num_outputs = 10  # 输出数据维度

# 初始化权重w和偏置b
W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)
  • 输入向量 x\mathbf{x} 被展平为长度为 784784 的列向量。
  • 权重矩阵 WR784×10W \in \mathbb{R}^{784 \times 10} 和偏置向量 bR10b \in \mathbb{R}^{10}

3. 定义模型

3.1 定义softmax运算

softmax函数的核心在于它将输入的分数转换为概率分布。对于一个向量 z\mathbf{z} 的每一个分量 zjz_j,softmax的定义如下:

y^j=exp(zj)k=1nexp(zk)\hat{y}_j = \frac{\exp(z_j)}{\sum_{k=1}^{n} \exp(z_k)}

这个公式的作用是:先将所有类别的原始分数通过指数函数转换为正数,再通过总和归一化,以确保所有概率之和为 11。这样一来,模型的输出就是一个概率分布,可以用来做分类预测。

def softmax(X):
    X_exp = torch.exp(X)
    partition = X_exp.sum(dim=1, keepdim=True)
    return X_exp / partition
  • sum 函数中,第一个参数 dim 指定了在哪个维度上进行求和操作。对于 softmax 来说,通常希望在特定维度上计算指数和,然后在该维度上进行归一化,从而得到概率分布。
  • keepdim 参数决定是否保留原始张量的维度大小。keepdim=True 会保留求和的维度,但该维度的大小会变为 1;而 keepdim=False(默认)会将求和的维度去掉。
x = torch.tensor([[1.0, 2.0, 3.0],
                  [4.0, 5.0, 6.0]])
sum_result = x.sum(dim=1)
print(sum_result)  # tensor([ 6., 15.])

3.2 定义模型

定义softmax运算后,我们可以实现 softmax回归模型。下面的代码定义了输入如何通过网络映射到输出。 注意,将数据传递到模型之前,我们使用reshape函数将每张原始图像展平为向量。

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

4. 交叉熵损失函数

对于多分类问题,交叉熵损失是softmax回归中常用的损失函数。假设模型预测的概率分布为 y^\hat{\mathbf{y}},真实类别的独热向量为 y\mathbf{y},则交叉熵损失定义为:

L(y,y^)=j=1nyjlog(y^j)L(\mathbf{y}, \hat{\mathbf{y}}) = -\sum_{j=1}^{n} y_j \log(\hat{y}_j)

在实现中,通常使用PyTorch内置的 cross_entropy 函数,这个函数包含了softmax和交叉熵的组合,简化了计算。

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

对于每个样本,提取其真实类别对应的预测概率 y^j \hat{y}_j,然后对其取对数,再计算平均值。

交叉熵损失函数代码详解

# 模拟的 logits (未经 softmax 的输出),和真实标签
logits = torch.tensor([[2.0, 0.5, 1.0], [0.1, 2.5, 0.3]], requires_grad=True)
true_labels = torch.tensor([0, 1])  # 样本 1 属于类别 0,样本 2 属于类别 1

# Step 1: 对 logits 应用 softmax 以得到每个类别的概率
softmax_probs = torch.exp(logits) / torch.exp(logits).sum(dim=1, keepdim=True)
print(f"softmax_probs: \n{softmax_probs}")
"""
softmax_probs: 
tensor([[0.6285, 0.1402, 0.2312],
        [0.0755, 0.8323, 0.0922]], grad_fn=<DivBackward0>)
"""

# Step 2: 选择正确类别对应的概率
selected_probs = softmax_probs[range(len(true_labels)), true_labels]
print(f"selected_probs: \n{selected_probs}")
"""
selected_probs: 
tensor([0.6285, 0.8323], grad_fn=<IndexBackward0>)
"""

# Step 3: 计算负对数似然,即对正确类别的概率取负对数并求平均
manual_cross_entropy_loss = -torch.log(selected_probs).mean()

print("手动计算的 Cross Entropy 损失值:", manual_cross_entropy_loss.item())
"""
手动计算的 Cross Entropy 损失值: 0.3239785134792328
"""
  • 第 1 步 将 logits 使用 softmax 函数转换为概率分布。
  • 第 2 步 通过索引选取每个样本中对应正确类别的概率。
  • 第 3 步 对正确类别的概率取对数,再取负数,最后计算所有样本的平均值,得到 cross_entropy 损失值。

5. 精度评估函数

在训练过程中,我们需要一种方法来评估模型的精度。通常,对于每一个样本,取预测概率最大的类别作为预测结果,并与真实标签进行比较。

d2l.py

def accuracy(y_hat, y):
    if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
        y_hat = y_hat.argmax(dim=1)
    cmp = y_hat.type(y.dtype) == y
    return float(cmp.type(y.dtype).sum())
  • y_hat.argmax(axis=1):返回预测概率最大的类别索引。
  • cmp:判断预测类别和真实类别是否相同,并转换为浮点数求和。

evaluate_accuracy函数用于在测试集上评估模型精度:

d2l.py

def evaluate_accuracy(net, data_iter):
    """计算在指定数据集上模型的精度"""
    if isinstance(net, torch.nn.Module):
        net.eval()
    metric = Accumulator(2)
    with torch.no_grad():
        for X, y in data_iter:
            metric.add(accuracy(net(X), y), y.numel())
    return metric[0] / metric[1]
  • y.numel() 返回张量 y 中的元素总数。

6. 训练模型

接下来,我们定义训练循环。这个循环将完成以下步骤:

  1. 将输入数据传入模型,计算输出。
  2. 计算损失值。
  3. 反向传播更新模型参数。
  4. 记录每个周期的损失和精度。

Accumulator 是一个辅助类,用于记录训练过程中的损失和精度.

d2l.py

class Accumulator:
    """在n个变量上累加"""

    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, item):
        return self.data[item]
def train_epoch(net, train_iter, loss, updater):
    # 将模型设置为训练模式
    if isinstance(net, torch.nn.Module):
        net.train()
    metric = d2l.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.mean().backward()
            updater.step()
        else:
            l.sum().backward()
            updater(batch_size)
        metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
    return metric[0] / metric[2], metric[1] / metric[2]
  • net.train():将模型设置为训练模式。
  • updater:优化器,用于更新参数。
  • metric:累加损失和精度,用于计算平均损失和精度。

7. 模型训练主函数

在这里,我们定义训练的主函数train_softmax。该函数将在多个epoch中迭代,并在每个epoch后记录并输出训练损失和精度。

def train_softmax(net, train_iter, test_iter, loss, num_epochs, updater):
    for epoch in range(num_epochs):
        train_metrics = train_epoch(net, train_iter, loss, updater)
        test_acc = d2l.evaluate_accuracy(net, test_iter)
        print(f'epoch {epoch + 1}, train loss {train_metrics[0]:.3f}, '
              f'train acc {train_metrics[1]:.3f}, test acc {test_acc:.3f}')

8. 训练模型

我们使用前面文章中定义的 小批量随机梯度下降 来优化模型的损失函数,设置学习率为0.1。

d2l.py

def sgd(params, lr, batch_size):
    """小批量随机梯度下降"""
    with torch.no_grad():
        for param in params:
            param -= lr * param.grad / batch_size
            param.grad.zero_()

可视化训练过程

我们定义一个在动画中绘制数据的实用程序类 Animator

d2l.py

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, ]
        # 使用lambda函数捕获参数
        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()

    def show(self):
        # 显示图形
        plt.show()

现在,我们训练模型10个迭代周期。 请注意,迭代周期(num_epochs)和学习率(lr)都是可调节的超参数。 通过更改它们的值,我们可以提高模型的分类精度。

if __name__ == '__main__':

    num_epochs = 10

    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
    # 初始状态,train acc 和 test acc 都为 1/10,随机的
    metric = d2l.Accumulator(3)

    for X, y in train_iter:
        y_hat = net(X)
        l = cross_entropy(y_hat, y)
        metric.add(float(l.sum()), d2l.accuracy(y_hat, y), y.numel())

    train_metrics = metric[0] / metric[2], metric[1] / metric[2]
    test_acc = d2l.evaluate_accuracy(net, test_iter)

    print(f'epoch {0}, train loss {train_metrics[0]:.3f}, '
          f'train acc {train_metrics[1]:.3f}, test acc {test_acc:.3f}')
    animator = d2l.Animator(xlabel='epoch', xlim=[0, num_epochs], ylim=[0, 1],
                            legend=['train loss', 'train acc', 'test acc'])
    animator.add(0, train_metrics + (test_acc,))
    # 训练模型
    train_softmax(net, train_iter, test_iter, cross_entropy, num_epochs, updater)
    # 可视化优化过程
    animator.show()
epoch 0, train loss 2.292, train acc 0.095, test acc 0.093
epoch 1, train loss 0.784, train acc 0.752, test acc 0.793
epoch 2, train loss 0.571, train acc 0.812, test acc 0.813
epoch 3, train loss 0.524, train acc 0.826, test acc 0.816
epoch 4, train loss 0.501, train acc 0.832, test acc 0.826
epoch 5, train loss 0.484, train acc 0.838, test acc 0.829
epoch 6, train loss 0.474, train acc 0.841, test acc 0.829
epoch 7, train loss 0.466, train acc 0.842, test acc 0.833
epoch 8, train loss 0.458, train acc 0.845, test acc 0.833
epoch 9, train loss 0.452, train acc 0.847, test acc 0.833
epoch 10, train loss 0.448, train acc 0.848, test acc 0.832

myplot.png

9. 预测

现在训练已经完成,我们的模型已经准备好对图像进行分类预测。

d2l.py

def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):
    """绘制图像列表"""
    figsize = (num_cols * scale, num_rows * scale)
    _, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize)
    axes = axes.flatten()
    for i, (ax, img) in enumerate(zip(axes, imgs)):
        if torch.is_tensor(img):
            # 图片张量
            ax.imshow(img.numpy())
        else:
            # PIL图片
            ax.imshow(img)
        ax.axes.get_xaxis().set_visible(False)
        ax.axes.get_yaxis().set_visible(False)
        if titles:
            ax.set_title(titles[i])
    d2l.plt.show()
    return axes


def get_fashion_mnist_labels(labels):
    text_labels = ["T恤", "裤子", "套衫", "连衣裙", "外套", "凉鞋", "衬衫", "运动鞋", "包", "短靴"]
    return [text_labels[int(i)] for i in labels]
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], scale=2.5)
        
predict(net, test_iter)

myplot.png

总结

本文介绍了softmax回归模型的原理及从零实现步骤。通过映射输入为概率分布,softmax回归用于多分类任务。我们涵盖了参数初始化、softmax和交叉熵损失定义、精度评估,以及小批量随机梯度下降的训练过程,并在Fashion-MNIST数据集上验证了模型的有效性。

import torch

import d2l

batch_size = 256  # 小批量大小
num_inputs = 784  # 输入数据维度
num_outputs = 10  # 输出数据维度

# 初始化权重w和偏置b
W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)


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


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


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


def train_epoch(net, train_iter, loss, updater):
    # 将模型设置为训练模式
    if isinstance(net, torch.nn.Module):
        net.train()
    metric = d2l.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()
        else:
            l.sum().backward()
            updater(batch_size)
        metric.add(float(l.sum()), d2l.accuracy(y_hat, y), y.numel())
    return metric[0] / metric[2], metric[1] / metric[2]


def train_softmax(net, train_iter, test_iter, loss, num_epochs, updater):
    for epoch in range(num_epochs):
        train_metrics = train_epoch(net, train_iter, loss, updater)
        test_acc = d2l.evaluate_accuracy(net, test_iter)
        animator.add(epoch + 1, train_metrics + (test_acc,))
        print(f'epoch {epoch + 1}, train loss {train_metrics[0]:.3f}, '
              f'train acc {train_metrics[1]:.3f}, test acc {test_acc:.3f}')


lr = 0.1


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


def predict_ch3(net, test_iter, n=6):
    """预测标签(定义见第3章)"""
    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], scale=2.5)


if __name__ == '__main__':

    num_epochs = 10

    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
    # 初始状态,train acc 和 test acc 都为 1/10,随机的
    metric = d2l.Accumulator(3)

    for X, y in train_iter:
        y_hat = net(X)
        l = cross_entropy(y_hat, y)
        metric.add(float(l.sum()), d2l.accuracy(y_hat, y), y.numel())

    train_metrics = metric[0] / metric[2], metric[1] / metric[2]
    test_acc = d2l.evaluate_accuracy(net, test_iter)

    print(f'epoch {0}, train loss {train_metrics[0]:.3f}, '
          f'train acc {train_metrics[1]:.3f}, test acc {test_acc:.3f}')
    animator = d2l.Animator(xlabel='epoch', xlim=[0, num_epochs], ylim=[0, 1],
                            legend=['train loss', 'train acc', 'test acc'])
    animator.add(0, train_metrics + (test_acc,))
    # 训练模型
    train_softmax(net, train_iter, test_iter, cross_entropy, num_epochs, updater)
    # 可视化优化过程
    animator.show()
    # 预测
    predict_ch3(net, test_iter)