回归可以用于预测多少的问题,我们有时候希望预测类别,也就是不再问“多少”,而是问“哪一个”;
可以把这个问题看作是多个输出的线性回归,每个输出是对应类别的概率。 再根据概率判断该输入属于哪一类。

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回归的输出层也是全连接层。

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、infnan(不是数字)的$\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)

预测结果