回归可以用于预测多少的问题,我们有时候希望预测类别,也就是不再问“多少”,而是问“哪一个”;
可以把这个问题看作是多个输出的线性回归,每个输出是对应类别的概率。
再根据概率判断该输入属于哪一类。
1 Softmax模型
1.1 One-hot Encoding
自然界中的类别之间常常是相互无关的,因此首先得找到一个方法去对各个类别进行编码;
One-hot Encoding就是一种表示分类数据的简单方法。他是一个向量,它的分量和类别一样多, 类别对应的分量设置为1,其他所有分量设置为0。
1.2 网络架构
与线性回归一样,softmax回归也是一个单层神经网络。由于计算每个输出$o_1$、$o_2$和$o_3$取决于所有输入$x_1$、$x_2$、$x_3$和$x_4$,所以softmax回归的输出层也是全连接层。

为了更简洁地表达模型,我们仍然使用线性代数符号。 通过向量形式表达为 $$\mathbf{o} = \mathbf{W} \mathbf{x} + \mathbf{b}$$
1.3 Softmax运算
模型的输出层是每一个类别的概率,然后我们的数据集就是对应类别为1,其余类别为0,这样看起来似乎没有问题。
但是输出$o_1$、$o_2$和$o_3$的范围是不确定的,得通过某种运算将其变成概率,也就是满足都为正数且和为1。
softmax就可以做到,
首先我们考虑负数的问题,如何把任意数映射到正数呢?可以通过$y = e^x$。
其次,怎么让其和为1呢?将指数变换后的每个数除以所有数的和就可以得到该类别的概率了。
这样就能够将未规范化的预测变换为非负数并且总和为1,同时让模型保持可导的性质。
$$\hat{\mathbf{y}} = \mathrm{softmax}(\mathbf{o})\quad \text{其中}\quad \hat{y}_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)}$$
还有一个问题!
$o_j$是未规范化的预测$\mathbf{o}$的第$j$个元素。
如果$o_k$中的一些数值非常大,那么$\exp(o_k)$可能大于数据类型容许的最大数字,即上溢(overflow)。
这将使分母或分子变为inf(无穷大),最后得到的是0、inf或nan(不是数字)的$\hat y_j$。
在这些情况下,我们无法得到一个明确定义的交叉熵值。
解决这个问题的一个技巧是: 在继续softmax计算之前,先从所有$o_k$中减去$\max(o_k)$。 这里可以看到每个$o_k$按常数进行的移动不会改变softmax的返回值:
$$ \begin{aligned} \hat y_j & = \frac{\exp(o_j - \max(o_k))\exp(\max(o_k))}{\sum_k \exp(o_k - \max(o_k))\exp(\max(o_k))} \\ & = \frac{\exp(o_j - \max(o_k))}{\sum_k \exp(o_k - \max(o_k))}. \end{aligned} $$
这样就没问题了吗?
可能有些$o_j - \max(o_k)$具有较大的负值。
由于精度受限,$\exp(o_j - \max(o_k))$将有接近零的值,即下溢(underflow)。
这些值可能会四舍五入为零,使$\hat y_j$为零,
并且使得$\log(\hat y_j)$的值为-inf。
反向传播几步后,我们可能会发现自己面对一屏幕可怕的nan结果。
解决方法:
尽管我们要计算指数函数,但我们最终在计算交叉熵损失时会取它们的对数。
通过将softmax和交叉熵结合在一起,可以避免反向传播过程中可能会困扰我们的数值稳定性问题。
如下面的等式所示,我们避免计算$\exp(o_j - \max(o_k))$,
而可以直接使用$o_j - \max(o_k)$,因为$\log(\exp(\cdot))$被抵消了。
$$ \begin{aligned} \log{(\hat y_j)} & = \log\left( \frac{\exp(o_j - \max(o_k))}{\sum_k \exp(o_k - \max(o_k))}\right) \\ & = \log{(\exp(o_j - \max(o_k)))}-\log{\left( \sum_k \exp(o_k - \max(o_k)) \right)} \\ & = o_j - \max(o_k) -\log{\left( \sum_k \exp(o_k - \max(o_k)) \right)}. \end{aligned} $$
总之,我们没有将softmax概率传递到损失函数中, 而是在交叉熵损失函数中传递未规范化的预测,并同时计算softmax及其对数,我们也希望保留传统的softmax函数,以备我们需要评估通过模型输出的概率。
1.4 损失函数
对于任何标签$\mathbf{y}$和模型预测$\hat{\mathbf{y}}$,损失函数为:
$$ l(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_{j=1}^q y_j \log \hat{y}_j $$
该损失函数通常被称为交叉熵损失(cross-entropy loss)。
相当于我们只关注正确类别的概率,想要使其最大化。
2 模型实现
2.1 数据读取
def load_data_fashion_mnist(batch_size, resize=None):
trans = [transforms.ToTensor()]
if resize:
trans.insert(0,transforms.Resize(resize))
# trans = [transforms.Resize(resize),transforms.ToTensor()]
trans = transforms.Compose(trans)
minist_train = torchvision.datasets.FashionMNIST(
root = "../data", train = True, transform = trans, download = True)
minist_test = torchvision.datasets.FashionMNIST(
root = "../data", train = False, transform = trans, download = True)
return (data.DataLoader(minist_train, batch_size, shuffle = True, num_workers = 4),
data.DataLoader(minist_test, batch_size, shuffle = True, num_workers = 4))
# 验证一下
train_iter, test_iter = load_data_fashion_mnist(32, resize=64)
for X, y in train_iter:
print(X.shape, X.dtype, y.shape, y.dtype)
使用data中的Dataloader可以得到一个可迭代对象
我们在构造数据集的时候多加了一个transform参数,Resize(resize)和ToTensor()分别对图像进行缩放和转成张量。
值得注意的是,原本的trans只是一个列表,构造完列表后,我们还调用了transforms.Compose()对其进行处理,这一步是把一系列变换函数串起来,组合成一个整体的流水线。
2.2 搭建模型
模型结构和线性回归类似,只是输入输出的个数不同(输入:图片大小28*28即784,输出:十分类即10)
但是我们数据集中,图像应该是一个二维数组,要将其挨个输入到第一层的各个单元
这里可以用展平层nn.Flatten,他会把除 batch 维以外的维度展平
[batch_size, C, H, W] → [batch_size, C*H*W]
默认是从第一维展平到最后一维nn.Flatten(start_dim=1, end_dim=-1)
也可以人为控制展平的范围
net = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 10)
)
搭建完模型就需要初始化权重还是用正态分布初始化
展平层没有参数不需要管,我们只用初始化Linear层就行
可以像之前调用net[1].weight.data.normal_(0, 0.01)来初始化
但是我们其实有更简便的办法
nn.Module中有一个方法apply,它可以遍历网络中的所有子模块,并对其执行传入的参数
def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std = 0.01)
net.apply(init_weights)
损失函数——交叉熵
loss = nn.CrossEntropyLoss(reduction='none')
优化器
trainer = torch.optim.SGD(net.parameters(), lr=0.1)
2.3 训练模型
当预测与标签分类y一致时,即是正确的。 分类精度即正确预测数量与总预测数量之比。 虽然直接优化精度可能很困难(因为精度的计算不可导), 但精度通常是我们最关心的性能衡量标准,我们在训练分类器时几乎总会关注它。所以我们先来实现一个精度计算的函数。
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())
y_hat.shape是一个[batch_size, class_num],我们只关注每个输入概率最大的类别,因此使用argmax来索引下标,并指定沿着axis=1。
我们有时候在完成每个epoch(或几个epochs)后,还希望看看模型在验证集上的表现,所以可以再定义一个函数来评估模型。
def evaluate_accuracy(net, test_iter):
"""计算在某个数据集上的准确率"""
net.eval() # 将模型设置为评估模式
metric = [0.0, 0.0] # [累计正确数, 累计样本数]
with torch.no_grad():
for X, y in test_iter:
y_hat = net(X)
metric[0] += accuracy(y_hat, y)
metric[1] += y.numel()
return metric[0] / metric[1]
现在就可以正式开始训练啦
num_epochs = 10
for epoch in range(num_epochs):
metric = [0.0, 0.0] # [累计损失, 累计正确数]
total = 0
net.train()
for X, y in train_iter:
# 前向传播
y_hat = net(X)
l = loss(y_hat, y).sum()
# 反向传播
trainer.zero_grad()
l.backward()
trainer.step()
# 统计指标
metric[0] += l.item()
metric[1] += accuracy(y_hat, y)
total += y.numel()
test_acc = evaluate_accuracy(net, test_iter)
print(f"epoch {epoch+1}, loss {metric[0]/total:.4f}, "
f"train acc {metric[1]/total:.3f},"
f"test acc {test_acc:.3f}")
2.4 预测
通过matplotlib绘制图片,看看实际的分类效果
import matplotlib.pyplot as plt
# 定义显示图像的函数
def show_fashion_mnist(X, y_true, y_pred=None, n=6):
"""
X: [batch_size, C, H, W] 或 [batch_size, H, W]
y_true: 真实标签列表或张量
y_pred: 预测标签列表或张量,可选
n: 显示前n张图片
"""
X, y_true = X[:n], y_true[:n]
if y_pred is not None:
y_pred = y_pred[:n]
fig, axes = plt.subplots(1, n, figsize=(12, 2))
for i in range(n):
img = X[i].squeeze().numpy() # [H, W]
axes[i].imshow(img, cmap='gray')
title = str(get_fashion_mnist_labels([y_true[i].item()])[0])
if y_pred is not None:
title += '\n' + str(get_fashion_mnist_labels([y_pred[i].item()])[0])
axes[i].set_title(title)
axes[i].axis('off')
plt.show()
# 预测函数
def predict_and_show(net, test_iter, n=6):
net.eval()
X, y = next(iter(test_iter))
y_hat = net(X).argmax(axis=1)
show_fashion_mnist(X, y, y_hat, n)
# 调用
predict_and_show(net, test_iter, n=5)