动手学深度学习3.6 softmax回归的从零开始实现

前言

从零开始学习ai文章系列计划是个人在《动手学深度学习》和《磨菇书》两本书的学习中的个人笔记,文章也会以课本中的章节分开,即每个章节一片笔记。我会尽量的把主要内容以及遇到的难点进行记录与解决,如果哪里有错误的欢迎指正。或者不清晰的可以直接查看原文部分。

《动手学深度学习》原文(课本):https://tangshusen.me/Dive-into-DL-PyTorch/#/

《动手学深度学习》代码:https://github.com/ShusenTang/Dive-into-DL-PyTorch

(由于有时候公式太多,可能会直接贴图片)

1. 获取和读取数据

1
2
3
4
5
6
7
8
9
10
11
12
mnist_train = torchvision.datasets.FashionMNIST(root='~/Datasets/FashionMNIST', train=True, download=True, transform=transforms.ToTensor())
mnist_test = torchvision.datasets.FashionMNIST(root='~/Datasets/FashionMNIST', train=False, download=True, transform=transforms.ToTensor())


batch_size = 256
if sys.platform.startswith('win'):
num_workers = 0 # 0表示不用额外的进程来加速读取数据
else:
num_workers = 4
train_iter = torch.utils.data.DataLoader(mnist_train, batch_size=batch_size, shuffle=True, num_workers=num_workers)
test_iter = torch.utils.data.DataLoader(mnist_test, batch_size=batch_size, shuffle=False, num_workers=num_workers)

2. 初始化模型参数

跟线性回归中的例子一样,我们将使用向量表示每个样本。

已知每个样本输入是高和宽均为28像素的图像。模型的输入向量的长度是 28×28=784:该向量的每个元素对应图像中每个像素。

由于图像有10个类别,单层神经网络输出层的输出个数为10,因此softmax回归的权重和偏差参数分别为784×10和1×10的矩阵。

3.实现softmax运算

实现代码如下

1
2
3
4
def softmax(X):
X_exp = X.exp()
partition = X_exp.sum(dim=1, keepdim=True)
return X_exp / partition # 这里应用了广播机制

首先来看X_exp.sum(dim=1, keepdim=True)

我们可以只对其中同一列(dim=0)或同一行(dim=1)的元素求和,并在结果中保留行和列这两个维度(keepdim=True)。

举例如下,对X分别进行行和列上的求和并保留维度

dim=0 指的是在行的方向上修改,即行数以外的不变,

dim=1 指的是在列的方向上修改,即列数以外的不变,在二维的情况下列数以外不变则是保持行数,将每行元素相加

复习一下softmax的公式

那么我们这里X_exp.sum(dim=1, keepdim=True)的作用则是将每行样本的输出\(exp(o_i)\)进行相加,即上图中的分母部分。

接下来是

1
return X_exp / partition  # 这里应用了广播机制

经过前面的计算我们得到partition是 n x 1 的向量,这里n是行数,而X_exp则和输入的X一样,是n x m

利用广播机制(应该是自动转换的),partition是 n x 1会重复m次,变成n x m的矩阵

举例如下

那么X_exp中每个元素都能与对应partition进行除法,然后生成新的矩阵。

这也意味着我们之前softmax公式的分子

4.定义模型

有了softmax运算,我们可以定义上节描述的softmax回归模型了。这里通过view函数将每张原始图像改成长度为num_inputs的向量。

1
2
def net(X):
return softmax(torch.mm(X.view((-1, num_inputs)), W) + b)

5.定义损失函数

我们介绍了softmax回归使用的交叉熵损失函数。为了得到标签的预测概率,我们可以使用gather函数。

5.1 gather函数

首先以一个例子介绍gather函数,在下面的例子中,变量y_hat是2个样本在3个类别的预测概率,变量y是这2个样本的标签类别。

image-20250321104651859

dim=1 指的是要在列方向上进行操作。

gather(1, y.view(-1, 1)) 操作的作用是从 y_hat 中根据每个样本的真实标签 y 选择对应的预测值。

这里y_hat.gather(1, y.view(-1, 1))的输出矩阵和 y.view(-1, 1) 形状一致,也是n x 1。或者反过来说,我们填入gather的第二个参数行数一定要和y_hat一样,然后列数为1,这样才能从y_hat的每行中挑一个组成新的矩阵。

在转换后的,如果每行再添加行号就很明显了。

相当于 \(y\_hat[0][0]、y\_hat[1][2]\)

通过使用gather函数,我们得到了2个样本的标签的预测概率。与3.4节(softmax回归)数学表述中标签类别离散值从1开始逐一递增不同,在代码中,标签类别的离散值是从0开始逐一递增的。

5.2 定义损失函数

我们知道交叉熵误差公式如下 \[ H(y^{(i)},\hat{y}^{(i)}) = -\sum ^{q}_{j=1} {y^{(i)}_jlog\hat{y}^{(i)}_j} \] 因为除了目标类y为1,其他的都为0。因此我们进获取目标类y的值来进行计算(下图在下标的\(y^{(i)}\)起始就是上图的j,不知道是不是课本打印公式打错了,指的是第i个样本的label j才为1) \[ H(y^{(i)},\hat{y}^{(i)}) = -log\hat{y}^{(i)}_j \] 同样,在神经网络计算的结果中,我们也不去管其他任何结果,只用gather函数去获取y_hat中目标类i的值。因此我们就需要用gather函数去获取目标第i类的值。

下面实现了3.4节(softmax回归)中介绍的交叉熵损失函数。

1
2
def cross_entropy(y_hat, y):
return - torch.log(y_hat.gather(1, y.view(-1, 1)))

6.计算分类准确率

下面定义准确率accuracy函数。

1
2
def accuracy(y_hat, y):
return (y_hat.argmax(dim=1) == y).float().mean().item()

y_hat.argmax(dim=1)返回矩阵y_hat每行中最大元素的索引。

(y_hat.argmax(dim=1) == y)是一个类型为ByteTensor的Tensor。

也就是说样本预测正确即为1,预测错误即为0,所有结果相加并除以样本数(上面的mean函数作用),当成是准确率。

类似地,我们可以计算评价模型net在数据集data_iter上的准确率。y.shape[0] 返回的是该数组的行数

1
2
3
4
5
6
def evaluate_accuracy(data_iter, net):
acc_sum, n = 0.0, 0
for X, y in data_iter:
acc_sum += (net(X).argmax(dim=1) == y).float().sum().item()
n += y.shape[0]
return acc_sum / n

和之前不同,我们无法一次将所有样本进行计算,每次只能获取部分的data,因此我们每次累计正确数acc_sum与样本数n,最后进行acc_sum / n获取平均数(就相当于之前的mean函数)

7. 训练模型

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size,
params=None, lr=None, optimizer=None):
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()

# 梯度清零
if optimizer is not None:
optimizer.zero_grad()
elif params is not None and params[0].grad is not None:
for param in params:
param.grad.data.zero_()

l.backward()
if optimizer is None:
d2l.sgd(params, lr, batch_size)
else:
optimizer.step() # “softmax回归的简洁实现”一节将用到


train_l_sum += l.item()
train_acc_sum += (y_hat.argmax(dim=1) == y).sum().item()
n += y.shape[0]
test_acc = evaluate_accuracy(test_iter, net)
print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f'
% (epoch + 1, train_l_sum / n, train_acc_sum / n, test_acc))

train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, batch_size, [W, b], lr)

我们能注意到每批样本的loss计算:l = loss(y_hat, y).sum()

  • 使用 sum() 时,损失值的绝对大小随着 batch size 增大而增大,因此梯度也增大,影响训练稳定性。
  • 使用 mean() 时,损失值不会因为 batch size 变化而变化,使得训练更稳定,适用于可变 batch size。

因此

  • 如果 batch size 是固定的,.sum() 和 .mean() 都可以使用。(不过mean生成的loss更小,因此更新更慢)
  • 如果 batch size 变化较大,推荐使用 .mean(),训练更稳定。

除此外,这里因为optimizer是None,因此更新方式d2l.sgd(params, lr, batch_size)是直接减去偏导数。

1
2
3
4
5
def sgd(params, lr, batch_size):
# 为了和原书保持一致,这里除以了batch_size,但是应该是不用除的,因为一般用PyTorch计算loss时就默认已经
# 沿batch维求了平均了。
for param in params:
param.data -= lr * param.grad / batch_size # 注意这里更改param时用的param.data

8. 预测

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

1
2
3
4
5
6
X, y = next(iter(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 = [true + '\n' + pred for true, pred in zip(true_labels, pred_labels)]

d2l.show_fashion_mnist(X[0:9], titles[0:9])

记得把下图注释去掉

给定一系列图像(第三行图像输出),我们比较一下它们的真实标签(第一行文本输出)和模型预测结果(第二行文本输出)。