跳到正文

第3章 线性神经网络

在介绍深度神经网络之前,我们需要了解神经网络训练的基础知识。 本章我们将介绍神经网络的整个训练过程, 包括:定义简单的神经网络架构、数据处理、指定损失函数和如何训练模型。 为了更容易学习,我们将从经典算法————线性神经网络开始,介绍神经网络的基础知识。 经典统计学习技术中的线性回归和softmax回归可以视为线性神经网络, 这些知识将为本书其他部分中更复杂的技术奠定基础。

1. 线性回归

回归(regression)是能为一个或多个自变量与因变量之间关系建模的一类方法。 在自然科学和社会科学领域,回归经常用来表示输入和输出之间的关系。

在机器学习领域中的大多数任务通常都与预测(prediction)有关。 当我们想预测一个数值时,就会涉及到回归问题。 常见的例子包括:预测价格(房屋、股票等)、预测住院时间(针对住院病人等)、 预测需求(零售销量等)。 但不是所有的预测都是回归问题。 在后面的章节中,我们将介绍分类问题。分类问题的目标是预测数据属于一组类别中的哪一个。

1.1. 线性回归的基本元素

线性回归(linear regression)可以追溯到 19 世纪初,它在回归的各种标准工具中最简单而且最流行。线性回归基于几个简单的假设:首先,假设自变量 x 和因变量 y 之间的关系是线性的,即 y 可以表示为 x 中元素的加权和,这里通常允许包含观测值的一些噪声;其次,我们假设任何噪声都比较正常,如噪声遵循正态分布。

为了解释线性回归,我们举一个实际的例子:我们希望根据房屋的面积(平方英尺)和房龄(年)来估算房屋价格(美元)。为了开发一个能预测房价的模型,我们需要收集一个真实的数据集。这个数据集包括了房屋的销售价格、面积和房龄。

在机器学习的术语中,该数据集称为训练数据集(training data set)或训练集(training set)。每行数据,例如一处房屋交易,相对应的数据,称为样本(sample),也可以称为数据点(data point)或数据样本(data instance)。

我们把试图预测的目标,例如预测房屋价格,称为标签(label)或目标(target)。预测所依据的自变量,例如面积和房龄,称为特征(feature)或协变量(covariate)。

通常,我们使用 n 表示数据集中的样本数。对于索引为 i 的样本,其输入表示为 x(i)=[x1(i),x2(i)],其对应的标签是 y(i)

1.1.1. 线性模型

线性假设是指目标(房屋价格)可以表示为特征(面积和房龄)的加权和,如下式:

price=wareaarea+wageage+b

式中的 wareawage 称为权重(weight),权重决定了每个特征对预测值的影响。b 称为偏置(bias)、偏移量(offset)或截距(intercept)。

偏置是指当所有特征都取值为 0 时,预测值应该为多少。即使现实中不会存在面积和房龄都为 0 的房屋,仍然需要偏置项。如果没有偏置项,我们模型的表达能力将受到限制。

严格来说,上述公式是输入特征的一个仿射变换(affine transformation)。仿射变换的特点是通过加权和对特征进行线性变换(linear transformation),并通过偏置项进行平移(translation)。

给定一个数据集,我们的目标是寻找模型的权重 w 和偏置 b,使得模型做出的预测尽可能符合数据里的真实价格。输出的预测值由输入特征经过线性模型的仿射变换决定,而仿射变换由权重和偏置确定。

在机器学习领域,我们通常使用的是高维数据集,建模时采用线性代数表示法会更加方便。当输入包含 d 个特征时,预测结果 y^ 通常表示为

y^=w1x1++wdxd+b

将所有特征放到向量 xRd 中,并将所有权重放到向量 wRd 中,可以使用点积形式简洁地表示模型:y^=wx+b

在该公式中,向量 x 对应单个数据样本的特征。用符号表示的矩阵 XRn×d 可以方便地表示整个数据集中的 n 个样本,其中 X 的每一行是一个样本,每一列是一种特征。

对于特征集合 X,预测值 y^Rn 可以通过矩阵-向量乘法表示为:

y^=Xw+b

这个过程中的求和将使用广播机制。给定训练数据特征 X 和对应的已知标签 y,线性回归的目标是找到一组权重向量 w 和偏置 b。当给定从 X 的同分布中取样的新样本特征时,这组权重和偏置能够使新样本预测标签的误差尽可能小。

虽然我们相信给定 x 预测 y 的最佳模型会是线性的,但我们很难找到一个包含 n 个样本的真实数据集,使得对于所有 1in,都有 y(i)=wx(i)+b

无论我们使用什么手段观察特征 X 和标签 y,都可能会出现少量观测误差。因此,即使确认特征与标签之间的潜在关系是线性的,我们也会加入一个噪声项来考虑观测误差带来的影响。

在开始寻找最优的模型参数(model parameters)wb 之前,还需要两个要素:

  1. 一种衡量模型质量的方法;
  2. 一种能够更新模型并提高预测质量的方法。

1.1.2. 损失函数

在我们开始考虑如何用模型拟合(fit)数据之前,我们需要确定一个拟合程度的度量。损失函数(loss function)能够量化目标的实际值与预测值之间的差距。通常我们会选择非负数作为损失,且数值越小表示损失越小,完美预测时的损失为 0。

回归问题中最常用的损失函数是平方误差函数,例如房屋售价和估价。当样本 i 的预测值为 y^(i),其相应的真实标签为 y(i) 时,平方误差可以定义为

l(i)(w,b)=12(y^(i)y(i))2

常数 12 不会带来本质差别,但这样在形式上稍微简单一些,因为对损失函数求导后常数系数 2 会被抵消。由于训练数据集并不受我们控制,所以经验误差只是关于模型参数的函数。

为了进一步说明,我们考虑一维情况下的回归问题,如图 3.1.1 所示。

用线性模型拟合数据

图 3.1.1 用线性模型拟合数据

由于平方误差函数中包含二次方项,估计值 y^(i) 和观测值 y(i) 之间较大的差异将导致更大的损失。为了度量模型在整个数据集上的质量,我们需要计算训练集 n 个样本上的损失均值,也等价于对所有样本损失求和后除以 n

L(w,b)=1ni=1nl(i)(w,b)=1ni=1n12(wx(i)+by(i))2

在训练模型时,我们希望寻找一组参数 (w,b),使其能够最小化所有训练样本上的总损失:

w,b=argminw,b L(w,b)

1.1.3. 解析解

线性回归刚好是一个很简单的优化问题。与本书中介绍的大部分其他模型不同,线性回归的解可以用一个公式直接表示,这类解称为解析解(analytical solution)。

首先,我们将偏置 b 合并到参数 w 中,并在设计矩阵 X 的每个样本中附加一个取值为 1 的特征。这样,预测问题可以写成 y^=Xw,损失函数为

L(w)=yXw2

对损失函数关于 w 求导,并令导数为 0,可以得到解析解:

w=(XX)1Xy

像线性回归这样的简单问题存在解析解,但并不是所有问题都存在解析解。解析解便于进行数学分析,但它对问题形式的限制较严格,因此无法广泛应用于深度学习。

1.1.4. 随机梯度下降

即使在我们无法得到解析解的情况下,仍然可以有效地训练模型。在许多任务中,那些难以优化的模型往往效果更好。因此,弄清楚如何训练这些难以优化的模型非常重要。

本书中我们用到一种名为梯度下降(gradient descent)的方法。这种方法几乎可以优化所有深度学习模型,它通过不断地沿损失函数递减的方向更新参数来降低误差。

梯度下降最简单的用法是计算损失函数,也就是数据集中所有样本的损失均值,关于模型参数的导数,这个导数也称为梯度。但实际执行可能非常慢,因为在每一次更新参数之前,都必须遍历整个数据集。因此,我们通常会在每次需要计算更新时随机抽取一小批样本,这种方法称为小批量随机梯度下降(minibatch stochastic gradient descent)。

在每次迭代中,我们首先随机抽样一个小批量 B,它由固定数量的训练样本组成。然后,计算小批量平均损失关于模型参数的导数,也称为梯度。最后,将梯度乘以一个预先确定的正数 η,并从当前参数值中减去。

我们用下面的数学公式表示这一更新过程,其中 表示偏导数:

(w,b)(w,b)η|B|iB(w,b)l(i)(w,b)

总结来看,算法步骤如下:

  1. 初始化模型参数的值,例如随机初始化;
  2. 从数据集中随机抽取一个小批量样本,并沿负梯度方向更新参数;
  3. 不断重复这一过程。

对于平方损失和仿射变换,可以明确写成:

wwη|B|iBx(i)(wx(i)+by(i))bbη|B|iB(wx(i)+by(i))

公式中的 wx 都是向量。使用更优雅的向量表示法,比逐个列出系数 w1,w2,,wd 更具可读性。

|B| 表示每个小批量中的样本数,也称为批量大小(batch size)。η 表示学习率(learning rate)。

批量大小和学习率的值通常是手动预先指定的,而不是通过模型训练得到的。这些可以调整但不在训练过程中更新的参数称为超参数(hyperparameter)。

选择合适超参数的过程称为调参(hyperparameter tuning)。超参数通常根据训练迭代结果进行调整,而训练结果则在独立的验证数据集(validation dataset)上进行评估。

在训练了预先确定的若干迭代次数后,或者直到满足其他停止条件后,我们记录模型参数的估计值,表示为 w^b^

即使目标函数是线性的且没有噪声,这些估计值通常也不会使损失函数真正达到最小值。原因是该算法会使损失向最小值逐步收敛,但不一定能在有限步数内非常精确地到达最小值。

线性回归是在整个参数空间中只有一个最小值的优化问题。但是,对于深度神经网络这样的复杂模型,损失平面通常包含多个局部最小值。

深度学习实践者通常很少花费大量精力寻找一组能够使训练集损失达到全局最小值的参数。事实上,更难做到的是找到一组能够在未见过的数据上实现较低损失的参数,这种能力称为泛化(generalization)。

1.1.5. 用模型进行预测

给定已经学习出的线性回归模型 w^x+b^,现在可以通过房屋面积 x1 和房龄 x2 来估计一个未包含在训练数据中的新房屋价格。

给定特征估计目标的过程通常称为预测(prediction)或推断(inference)。

本书将尝试坚持使用“预测”这个词。虽然“推断”已经成为深度学习中的标准术语,但它在统计学中有其他含义。在统计学中,推断更多地表示基于数据集估计参数。当深度学习从业者与统计学家交流时,术语的误用经常会导致一些误解。

1.2. 矢量化加速

在训练模型时,我们经常希望能够同时处理整个小批量的样本。为了实现这一点,需要对计算进行向量化,从而利用线性代数库,而不是在 Python 中编写开销较高的 for 循环。

python
%matplotlib inline
import math
import time
import numpy as np
import torch
from d2l import torch as d2l

为了说明向量化为什么如此重要,我们考虑向量相加的两种方法。首先实例化两个全为 1 的 10000 维向量。在一种方法中,使用 Python 的 for 循环遍历向量;在另一种方法中,依赖对 + 的调用。

python
n = 10000
a = torch.ones([n])
b = torch.ones([n])

由于本书中会频繁进行运行时间的基准测试,因此定义一个计时器:

python
class Timer:  # @save
    """记录多次运行时间"""
    def __init__(self):
        self.times = []
        self.start()

    def start(self):
        """启动计时器"""
        self.tik = time.time()

    def stop(self):
        """停止计时器并将时间记录在列表中"""
        self.times.append(time.time() - self.tik)
        return self.times[-1]

    def avg(self):
        """返回平均时间"""
        return sum(self.times) / len(self.times)

    def sum(self):
        """返回时间总和"""
        return sum(self.times)

    def cumsum(self):
        """返回累计时间"""
        return np.array(self.times).cumsum().tolist()

现在可以对工作负载进行基准测试。

首先,我们使用 for 循环,每次执行一位的加法。

python
c = torch.zeros(n)
timer = Timer()

for i in range(n):
    c[i] = a[i] + b[i]

f'{timer.stop():.5f} sec'

输出:

text
'0.16749 sec'

或者,我们使用重载的 + 运算符来计算按元素的和。

python
timer.start()
d = a + b

f'{timer.stop():.5f} sec'

输出:

text
'0.00042 sec'

结果很明显,第二种方法比第一种方法快得多。向量化代码通常会带来数量级的加速。另外,我们会将更多数学运算交给库来完成,而不必自行编写大量计算代码,从而减少出错的可能性。

1.3. 正态分布与平方损失

接下来,我们通过对噪声分布的假设来解释平方损失目标函数。

正态分布和线性回归之间的关系很密切。正态分布(normal distribution),也称为高斯分布(Gaussian distribution),最早由德国数学家高斯(Gauss)应用于天文学研究。简单来说,若随机变量 x 具有均值 μ 和方差 σ2,其正态分布概率密度函数如下:

p(x)=12πσ2exp(12σ2(xμ)2)

下面定义一个 Python 函数来计算正态分布。

python
def normal(x, mu, sigma):
    p = 1 / math.sqrt(2 * math.pi * sigma**2)
    return p * np.exp(-0.5 / sigma**2 * (x - mu)**2)

现在可视化正态分布。

python
# 再次使用 NumPy 进行可视化
x = np.arange(-7, 7, 0.01)

# 均值和标准差对
params = [(0, 1), (0, 2), (3, 1)]

d2l.plot(
    x,
    [normal(x, mu, sigma) for mu, sigma in params],
    xlabel='x',
    ylabel='p(x)',
    figsize=(4.5, 2.5),
    legend=[f'mean {mu}, std {sigma}' for mu, sigma in params]
)

就像图中所展示的,改变均值会使分布沿 x 轴平移;增大方差会使分布更加分散,并降低峰值。

均方误差损失函数(简称平方损失)可以用于线性回归的一个原因是:我们假设观测中包含噪声,并且噪声服从正态分布。噪声正态分布如下:

y=wx+b+ϵ

其中 ϵN(0,σ2)

因此,给定 x 时观测到特定 y 的似然(likelihood)可以写为:

P(yx)=12πσ2exp(12σ2(ywxb)2)

根据极大似然估计法,参数 wb 的最优值,是使整个数据集似然最大的值:

P(yX)=i=1np(y(i)x(i))

根据极大似然估计法选择的估计量称为极大似然估计量。虽然多个指数函数的乘积最大化看起来较复杂,但可以在不改变优化目标的前提下,通过最大化似然的对数来简化。

由于历史原因,优化通常表述为最小化问题,因此可以改为最小化负对数似然 logP(yX)。由此得到:

logP(yX)=i=1n[12log(2πσ2)+12σ2(y(i)wx(i)b)2]

σ 是固定常数时,第一项与 wb 无关,因此可以忽略。第二项除了常数系数 12σ2 外,其余部分与前面介绍的均方误差相同。

因此,在高斯噪声假设下,最小化均方误差等价于对线性模型进行极大似然估计。

1.4. 从线性回归到深度网络

到目前为止,我们只讨论了线性模型。尽管神经网络涵盖了更多、更丰富的模型,我们仍然可以用描述神经网络的方式来描述线性模型,从而把线性模型看作一个神经网络。首先,我们用“层”符号来重写这个模型。

1.4.1. 神经网络图

深度学习从业者喜欢绘制图表来可视化模型中正在发生的事情。在图 3.1.2 中,我们将线性回归模型描述为一个神经网络。

需要注意的是,该图只显示连接模式,即只显示每个输入如何连接到输出,隐藏了权重和偏置的值。

图 3.1.2 线性回归是一个单层神经网络

在图 3.1.2 所示的神经网络中,输入为 x1,,xd,因此输入层的输入数,也称为特征维度(feature dimensionality),为 d。网络的输出为 o1,因此输出层的输出数为 1。

需要注意的是,输入值都是已经给定的,并且只有一个计算神经元。由于模型重点在发生计算的地方,所以通常在计算层数时不考虑输入层。也就是说,图 3.1.2 中神经网络的层数为 1。

因此,可以将线性回归模型视为仅由单个人工神经元组成的神经网络,或称为单层神经网络。

对于线性回归,每个输入都与每个输出相连。在本例中只有一个输出,因此这种变换称为全连接层(fully-connected layer)或稠密层(dense layer)。

下一章将详细讨论由这些层组成的网络。

1.4.2. 生物学

线性回归发明的时间(1795 年)早于计算神经科学,因此将线性回归描述为神经网络似乎不太合适。当控制学家、神经生物学家沃伦·麦卡洛奇和沃尔特·皮茨开始开发人工神经元模型时,他们为什么将线性模型作为一个起点呢?

我们来看图 3.1.3。这是一张由树突(dendrites,输入终端)、细胞核(nucleus,CPU)组成的生物神经元图片。轴突(axon,输出线)和轴突端子(axon terminal,输出端子)通过突触(synapse)与其他神经元连接。

图 3.1.3 真实的神经元

树突中接收到来自其他神经元或视网膜等环境传感器的信息 xi。该信息通过突触权重 wi 求和加权,以确定输入的影响,即通过 ixiwi 确定激活或抑制。

来自多个源的加权输入以加权和 y=ixiwi+b 的形式汇聚在细胞核中,然后将这些信息发送到轴突中进一步处理,通常会通过 σ(y) 进行一些非线性处理。

之后,信息需要到达目的地,例如肌肉,或者通过树突进入另一个神经元。

许多这样的单元可以通过正确连接和正确的学习算法结合在一起,从而产生比单个神经元更复杂、更有趣的行为。这一思想源于对真实生物神经系统的研究。

当今大多数深度学习研究几乎没有直接从神经科学中获得灵感。正如斯图尔特·罗素和彼得·诺维格在经典人工智能教材 Artificial Intelligence: A Modern Approach 中所说:虽然飞机可能受到鸟类的启发,但一个世纪以来,鸟类学并不是航空创新的主要驱动力。

同样,如今深度学习中的灵感可能更多地来自数学、统计学和计算机科学。

1.5. 小结

  • 机器学习模型中的关键要素是训练数据、损失函数、优化算法,还有模型本身。
  • 矢量化使数学表达上更简洁,同时运行的更快。
  • 最小化目标函数和执行极大似然估计等价。
  • 线性回归模型也是一个简单的神经网络。

2. 线性回归的从零开始实现

在了解线性回归的关键思想之后,我们可以开始通过代码来动手实现线性回归了。 在这一节中,我们将从零开始实现整个方法, 包括数据流水线、模型、损失函数和小批量随机梯度下降优化器。 虽然现代的深度学习框架几乎可以自动化地进行所有这些工作,但从零开始实现可以确保我们真正知道自己在做什么。 同时,了解更细致的工作原理将方便我们自定义模型、自定义层或自定义损失函数。 在这一节中,我们将只使用张量和自动求导。 在之后的章节中,我们会充分利用深度学习框架的优势,介绍更简洁的实现方式。

python
%matplotlib inline
import random
import torch
from d2l import torch as d2l

2.1. 生成数据集

为了简单起见,我们将根据带有噪声的线性模型构造一个人造数据集。我们的任务是使用这个有限样本的数据集来恢复该模型的参数。

我们将使用低维数据,这样可以很容易地将其可视化。在下面的代码中,我们生成一个包含 1000 个样本的数据集,每个样本包含从标准正态分布中采样的 2 个特征。合成数据集可以表示为矩阵 XR1000×2

我们使用线性模型参数 w=[2,3.4]b=4.2 和噪声项 ϵ 生成数据集及其标签:

y=Xw+b+ϵ

ϵ 可以视为模型预测值与标签之间的潜在观测误差。这里假设标准假设成立,即 ϵ 服从均值为 0 的正态分布。为了简化问题,将其标准差设为 0.01。下面的代码生成合成数据集。

python
def synthetic_data(w, b, num_examples):  # @save
    """生成 y=Xw+b+噪声"""
    X = torch.normal(0, 1, (num_examples, len(w)))
    y = torch.matmul(X, w) + b
    y += torch.normal(0, 0.01, y.shape)
    return X, y.reshape((-1, 1))


true_w = torch.tensor([2, -3.4])
true_b = 4.2

features, labels = synthetic_data(true_w, true_b, 1000)

注意,features 中的每一行都包含一个二维数据样本,labels 中的每一行都包含一个一维标签值,即一个标量。

python
print('features:', features[0], '\nlabel:', labels[0])

输出:

text
features: tensor([1.4632, 0.5511])
label: tensor([5.2498])

通过绘制第二个特征 features[:, 1]labels 的散点图,可以直观观察两者之间的线性关系。

python
d2l.set_figsize()

d2l.plt.scatter(
    features[:, 1].detach().numpy(),
    labels.detach().numpy(),
    1
)
Output

2.2. 读取数据集

回想一下,训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用这些样本更新模型。由于这一过程是训练机器学习算法的基础,因此需要定义一个函数,用于打乱数据集中的样本并以小批量方式读取数据。

下面定义 data_iter 函数。该函数接收批量大小、特征矩阵和标签向量作为输入,并生成大小为 batch_size 的小批量。每个小批量都包含一组特征和对应的标签。

python
def data_iter(batch_size, features, labels):
    """
    按照小批量方式随机读取数据。

    参数:
    batch_size:每个小批量包含的样本数量
    features:特征矩阵,形状通常为 (样本数, 特征数)
    labels:标签向量或标签矩阵,第一维必须与 features 相同

    返回:
    每次通过 yield 返回一个小批量的特征 X 和标签 y
    """

    # 获取数据集中的样本总数。
    # features 的每一行代表一个样本,因此 len(features) 等于样本数量。
    num_examples = len(features)

    # 生成所有样本的索引。
    # 如果一共有 1000 个样本,则 indices 为:[0, 1, 2, ..., 999]
    indices = list(range(num_examples))

    # 随机打乱样本索引。
    # 这里只改变读取样本的顺序,不会直接修改 features 和 labels。
    # 每次调用 data_iter 时,样本顺序通常都会不同。
    random.shuffle(indices)

    # 按照 batch_size 为步长遍历整个数据集。
    #
    # 假设:
    # num_examples = 1000
    # batch_size = 10
    #
    # 那么 i 会依次取:0, 10, 20, ..., 990
    for i in range(0, num_examples, batch_size):

        # 从随机打乱后的 indices 中截取当前小批量所需的索引。
        # 第一次循环:indices[0:10]
        # 第二次循环:indices[10:20]
        # min(i + batch_size, num_examples) 用于处理最后一个批量。
        # 当样本总数不能被 batch_size 整除时,最后一个批量会少于 batch_size。
        batch_indices = torch.tensor(
            indices[i:min(i + batch_size, num_examples)]
        )

        # 根据当前批量的索引,从 features 和 labels 中取出对应样本。
        #
        # features[batch_indices]:取出当前批量的特征。
        # labels[batch_indices]:取出与这些特征一一对应的标签。
        #
        # yield 不会直接结束函数,而是暂停函数并返回当前批量。
        # 下一次继续迭代时,会从暂停的位置继续执行。
        yield features[batch_indices], labels[batch_indices]

通常,我们会利用 GPU 并行运算的优势处理大小合理的小批量。每个样本都可以并行进行模型计算,每个样本损失函数的梯度也可以并行计算。

GPU 可以同时处理数百个样本,因此处理一个批量所需的时间通常不会比处理单个样本多很多。

下面读取并打印第一个小批量数据。每个批量的特征维度由批量大小和输入特征数决定,标签的第一维也等于 batch_size

python
# 设置每个小批量包含 10 个样本。
batch_size = 10


# 调用 data_iter 创建一个数据生成器,并逐批读取数据。
#
# 每次循环:
# X 接收一个小批量的特征;
# y 接收这个小批量对应的标签。
for X, y in data_iter(batch_size, features, labels):

    # 打印当前小批量的特征和标签。
    print(X, '\n', y)

    # 只查看第一个小批量,因此打印后立即退出循环。
    # 删除 break 后,会依次打印整个数据集的所有小批量。
    break

输出:

text
tensor([[ 0.3934,  2.5705],
        [ 0.5849, -0.7124],
        [ 0.1008,  0.6947],
        [-0.4493, -0.9037],
        [ 2.3104, -0.2798],
        [-0.0173, -0.2552],
        [ 0.1963, -0.5445],
        [-1.0580, -0.5180],
        [ 0.8417, -1.5547],
        [-0.6316,  0.9732]])

tensor([[-3.7623],
        [ 7.7852],
        [ 2.0443],
        [ 6.3767],
        [ 9.7776],
        [ 5.0301],
        [ 6.4541],
        [ 3.8407],
        [11.1396],
        [-0.3836]])

当我们运行迭代时,我们会连续地获得不同的小批量,直至遍历完整个数据集。 上面实现的迭代对教学来说很好,但它的执行效率很低,可能会在实际问题上陷入麻烦。 例如,它要求我们将所有数据加载到内存中,并执行大量的随机内存访问。 在深度学习框架中实现的内置迭代器效率要高得多, 它可以处理存储在文件中的数据和数据流提供的数据。

2.3. 初始化模型参数

在开始使用小批量随机梯度下降优化模型参数之前,我们需要先初始化这些参数。下面从均值为 0、标准差为 0.01 的正态分布中随机采样权重,并将偏置初始化为 0。

python
# 从均值为 0、标准差为 0.01 的正态分布中,随机生成一个形状为 (2, 1) 的权重矩阵。
#
# 之所以是 (2, 1),是因为:
# - 每个样本有 2 个输入特征;
# - 模型只有 1 个输出。
#
# requires_grad=True 表示 PyTorch 需要追踪 w 参与的计算,之后可以通过反向传播自动计算损失函数关于 w 的梯度。
w = torch.normal(
    mean=0,
    std=0.01,
    size=(2, 1),
    requires_grad=True
)

# 创建一个包含 1 个元素的全零张量,作为偏置 b,偏置只有一个值,因为模型只有一个输出。
b = torch.zeros(
    1,
    requires_grad=True  # 表示训练时也需要计算损失关于 b 的梯度
)

初始化参数后,接下来的任务是不断更新这些参数,直到它们能够较好地拟合数据。

每次更新参数时,都需要计算损失函数关于模型参数的梯度。得到梯度后,就可以沿减小损失的方向更新各个参数。

由于手动计算梯度较为烦琐且容易出错,因此这里使用 PyTorch 的自动微分功能计算梯度。

2.4. 定义模型

接下来需要定义线性回归模型,将模型的输入和参数与输出关联起来。

线性回归的计算公式为 y^=Xw+b,其中 Xw 是矩阵与向量的乘法,b 是标量。

当向量与标量相加时,PyTorch 会通过广播机制,将标量 b 加到结果向量的每个元素上。

python
def linreg(X, w, b):
    """
    线性回归模型。

    参数:
    X:一个小批量的特征矩阵,形状为 (batch_size, num_features)
    w:权重向量或矩阵,形状为 (num_features, 1)
    b:偏置,通常是长度为 1 的标量张量

    返回:
    模型对每个样本的预测值,形状为 (batch_size, 1)
    """
    # torch.matmul(X, w) 计算批量特征矩阵与权重向量的矩阵乘法,得到每个样本的线性组合结果
    # 结果形状为 (batch_size, 1)
    # 由于 b 是标量张量,PyTorch 会自动广播它,把同一个偏置值加到每个样本的预测上。
    return torch.matmul(X, w) + b

2.5. 定义损失函数

为了计算模型参数的梯度,需要先定义损失函数。

这里使用平方损失函数。实际计算时,需要将真实标签 y 的形状转换为与预测值 y_hat 相同的形状。

python
def squared_loss(y_hat, y):  # @save
    """均方损失
    参数:
    y_hat:模型的预测值,形状为 (batch_size, 1)
    y:真实标签,形状可能为 (batch_size,) 或 (batch_size, 1)

    返回:
    每个样本的平方损失,形状为 (batch_size, 1)

    说明:
    - 使用 y.reshape(y_hat.shape) 将 y 的形状调整为与 y_hat 一致。
    - 每个样本的损失是 (预测 - 真实值) 的平方再除以 2,
      这样对损失求导时会使结果更简洁。
    """
    # 计算预测值与真实值之间的差值
    diff = y_hat - y.reshape(y_hat.shape)
    # 逐元素平方并除以 2,得到平方损失
    return diff ** 2 / 2

2.6. 定义优化算法

正如我们在 1. 线性回归 中讨论的,线性回归存在解析解。尽管线性回归具有解析解,但本书中的其他模型通常没有解析解,因此这里介绍小批量随机梯度下降。

在每一步中,首先从数据集中随机抽取一个小批量,然后根据模型参数计算损失函数的梯度。接下来,沿着减小损失的方向更新模型参数。

下面的函数用于实现小批量随机梯度下降。该函数接收模型参数集合、学习率和批量大小作为输入。每一步更新的幅度由学习率 lr 决定。

由于计算得到的损失是一个小批量样本损失的总和,因此需要使用批量大小 batch_size 对更新步长进行归一化。这样,参数更新幅度就不会受到批量大小选择的影响。

python
def sgd(params, lr, batch_size):  #@save
    """小批量随机梯度下降

    参数:
    params:模型参数列表,通常包含权重和偏置
    lr:学习率,用于控制更新步长的大小
    batch_size:当前小批量样本数,用于归一化梯度

    说明:
    - 在 PyTorch 中,梯度默认会累加,因此在每次参数更新后必须手动将梯度置零。
    - 使用 `torch.no_grad()` 可在参数更新时禁用自动求导,避免这些操作被记录到计算图中。
    """
    with torch.no_grad():
        for param in params:
            # param.grad 是损失对当前参数的梯度。
            # 除以 batch_size 使得更新幅度不依赖于批量大小。
            param -= lr * param.grad / batch_size
            # 及时清零梯度,为下一次反向传播做好准备。
            param.grad.zero_()

2.8. 训练

现在我们已经准备好了模型训练所需的所有要素,可以实现主要的训练过程部分。理解这段代码非常重要,因为在学习深度学习后,相同的训练过程几乎会反复出现。

在每次迭代中,我们读取一个小批量训练样本,并通过模型获得一组预测。计算完损失后,开始反向传播,存储每个参数的梯度。最后,调用优化算法 sgd 更新模型参数。

概括来说,我们将执行以下循环:

  • 初始化参数;
  • 重复以下训练过程,直到完成:
    • 计算梯度 g(w,b)1|B|iBl(x(i),y(i),w,b)
    • 更新参数 (w,b)(w,b)ηg

在每个迭代周期(epoch)中,我们使用 data_iter 函数遍历整个数据集,并将训练数据集中的所有样本都使用一次,假设样本数能够被批量大小整除。

这里的迭代周期数 num_epochs 和学习率 lr 都是超参数,分别设置为 3 和 0.03。超参数的设置通常依赖经验,需要通过反复实验进行调整。

python
lr = 0.03
num_epochs = 3
batch_size = 10
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
    # 遍历训练集中的所有样本,每次读取一个小批量。
    for X, y in data_iter(batch_size, features, labels):
        # 前向传播:计算当前小批量的预测结果和损失。
        # X 形状为 (batch_size, num_features),y 形状为 (batch_size, 1)。
        l = loss(net(X, w, b), y)  # X 和 y 的小批量损失

        # l 的形状是 (batch_size, 1),表示每个样本的损失。
        # 需要对该批次中所有样本的损失求和才能得到标量总损失,
        # 这样 backward() 才能计算参数梯度。
        l.sum().backward()

        # 更新模型参数:这里执行一个小批量随机梯度下降步骤。
        # sgd 内部会用 param.grad 来更新每个参数,并清零梯度。
        sgd([w, b], lr, batch_size)  # 使用参数的梯度更新参数

    # 评估当前 epoch 的训练误差时,不需要计算梯度。
    # 所以用 torch.no_grad() 暂时关闭自动求导,提高效率。
    with torch.no_grad():
        train_l = loss(net(features, w, b), labels)
        # 平均训练损失表示当前模型在整个训练集上的表现。
        print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

输出:

text
epoch 1, loss 0.042790
epoch 2, loss 0.000162
epoch 3, loss 0.000051

因为这里使用的是自己生成的合成数据集,所以我们知道真实参数的值。因此,可以通过比较真实参数和训练得到的参数,评估模型训练的效果。

实际上,真实参数与通过训练得到的参数非常接近。

python
print(f'w的估计误差:{true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差:{true_b - b}')

输出:

text
w的估计误差:tensor([-1.3804e-04, 5.7936e-05], grad_fn=<SubBackward0>)
b的估计误差:tensor([0.0006], grad_fn=<RsubBackward1>)

需要注意的是,我们不应该想当然地认为模型能够完美恢复真实参数。

在机器学习中,通常并不太关心是否恢复了真实参数,而更关心模型能否对新数据进行高精度预测。即使面对复杂的优化问题,随机梯度下降通常也能够找到较好的解。

其中一个原因是,在深度网络中,通常存在许多不同的参数组合,都能够实现高精度预测。

2.8. 小结

  • 我们学习了深度网络是如何实现和优化的。在这一过程中只使用张量和自动微分,不需要定义层或复杂的优化器。
  • 这一节只触及到了表面知识。在下面的部分中,我们将基于刚刚介绍的概念描述其他模型,并学习如何更简洁地实现其他模型。

3. 线性回归的简洁实现

在过去的几年里,出于对深度学习强烈的兴趣,许多公司、学者和业余爱好者开发了各种成熟的开源框架。这些框架可以自动化基于梯度的学习算法中重复性的工作。

2. 线性回归的从零实现 中,我们只使用了:

  1. 通过张量进行数据存储和线性代数;
  2. 通过自动微分计算梯度。

实际上,由于数据迭代器、损失函数、优化器和神经网络层非常常用,现代深度学习框架已经实现了这些组件。

本节将介绍如何使用深度学习框架,简洁地实现 2. 线性回归的从零实现 中的线性回归模型。

3.1. 生成数据集

我们首先生成数据集。

python
import numpy as np
import torch
from torch.utils import data
from d2l import torch as d2l

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = d2l.synthetic_data(true_w, true_b, 1000)

3.2. 读取数据集

我们可以调用框架中现有的 API 来读取数据。将 featureslabels 作为 API 的参数传递,并通过数据迭代器指定 batch_size

此外,布尔值 is_train 表示是否希望数据迭代器在每个迭代周期内打乱数据。

python
def load_array(data_arrays, batch_size, is_train=True):  # @save
    """构造一个 PyTorch 数据迭代器"""
    dataset = data.TensorDataset(*data_arrays)

    return data.DataLoader(
        dataset,
        batch_size,
        shuffle=is_train
    )

batch_size = 10
data_iter = load_array((features, labels), batch_size)

这里使用 data_iter 的方式与 2. 线性回归的从零实现 中自定义的数据迭代器相同。

为了验证数据迭代器是否正常工作,我们读取并打印第一个小批量样本。与 2. 线性回归的从零实现 不同,这里使用 iter 构造 Python 迭代器,并使用 next 从迭代器中获取第一项。

python
next(iter(data_iter))

输出:

text
[tensor([[-1.3116, -0.3062],
         [-1.5653,  0.4830],
         [-0.8893, -0.9466],
         [-1.2417,  1.6891],
         [-0.7148,  0.1376],
         [-0.2162, -0.6122],
         [ 2.4048, -0.3211],
         [-0.1516,  0.4997],
         [ 1.5298, -0.2291],
         [ 1.3895,  1.2602]]),

 tensor([[ 2.6073],
         [-0.5787],
         [ 5.6339],
         [-4.0211],
         [ 2.3117],
         [ 5.8492],
         [10.0926],
         [ 2.1932],
         [ 8.0441],
         [ 2.6943]])]

3.3. 定义模型

如果模型变得更加复杂,并且需要频繁构建模型,反复手动完成这些工作会非常低效。因此,标准深度学习框架通常提供预定义的网络层。

对于标准深度学习模型,我们只需要确定使用哪些层来构造模型,而不必关注这些层的具体实现细节。

首先定义模型变量 net,它是 nn.Sequential 类的实例。Sequential 可以将多个网络层按顺序连接起来:前一层的输出作为后一层的输入。

本例中的模型只有一个线性层,严格来说并不需要使用 Sequential。但由于大多数模型都包含多层,这里使用 Sequential 可以保持统一的模型构建方式。

单层线性回归模型属于全连接层(fully-connected layer),因为每个输入都会参与每个输出的计算。

在 PyTorch 中,全连接层由 nn.Linear 定义。第一个参数 2 表示输入特征数,第二个参数 1 表示输出特征数。

python
# nn 是神经网络模块的缩写
from torch import nn

net = nn.Sequential(
    nn.Linear(2, 1)
)

3.4. 初始化模型参数

在使用 net 之前,需要初始化模型参数,包括线性回归模型中的权重和偏置。

这里将每个权重初始化为从均值为 0、标准差为 0.01 的正态分布中随机采样的数值,并将偏置初始化为 0。

net[0] 表示访问网络中的第一个层。该层的权重和偏置分别通过 weight.databias.data 访问。

python
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)

输出:

text
tensor([0.])

3.5. 定义损失函数

计算均方误差时使用 MSELoss 类,也称为平方 L2 范数。默认情况下,它返回所有样本损失的平均值。

python
loss = nn.MSELoss()

3.6. 定义优化算法

小批量随机梯度下降是训练神经网络的标准优化方法之一。PyTorch 在 optim 模块中提供了多种优化算法。

这里实例化一个 SGD 优化器,并指定:

  • net.parameters():需要更新的模型参数;
  • lr=0.03:学习率设置为 0.03。
python
trainer = torch.optim.SGD(
    net.parameters(),
    lr=0.03
)

3.7. 训练

通过深度学习框架的高级 API 实现模型时,只需要编写较少的代码。我们不必单独分配参数,不必自行定义损失函数,也不必手动实现小批量随机梯度下降。模型越复杂,高级 API 的优势越明显。

当所有基本组件准备完成后,训练过程与从零开始实现时基本相同。

在每个迭代周期中,我们完整遍历一次数据集 data_iter,不断读取小批量的输入和对应标签。对于每个小批量,执行以下步骤:

  • 调用 net(X) 生成预测并计算损失 l,即前向传播;
  • 调用 l.backward() 进行反向传播并计算梯度;
  • 调用优化器更新模型参数。

为了衡量训练效果,我们计算每个迭代周期结束后的损失,并将其打印出来以监控训练过程。

python
num_epochs = 3

for epoch in range(num_epochs):
    for X, y in data_iter:
        l = loss(net(X), y)

        trainer.zero_grad()
        l.backward()
        trainer.step()

    l = loss(net(features), labels)
    print(f'epoch {epoch + 1}, loss {l:f}')

输出:

text
epoch 1, loss 0.000248
epoch 2, loss 0.000103
epoch 3, loss 0.000103

下面比较生成数据集时使用的真实参数与通过有限数据训练得到的模型参数。

为了访问参数,首先通过 net[0] 访问网络中的线性层,然后读取该层的权重和偏置。可以看到,训练得到的参数与真实参数非常接近。

python
w = net[0].weight.data
print('w的估计误差:', true_w - w.reshape(true_w.shape))

b = net[0].bias.data
print('b的估计误差:', true_b - b)

输出:

text
w的估计误差: tensor([-0.0010, -0.0003])
b的估计误差: tensor([-0.0003])

3.8. 小结

  • 可以使用 PyTorch 的高级 API 更简洁地实现模型。
  • PyTorch 的 data 模块提供数据处理工具,nn 模块提供神经网络层和常用损失函数。
  • 可以通过以下划线 _ 结尾的原地操作方法修改参数,从而完成参数初始化。

4. softmax回归

回归可以用于预测多少的问题。比如预测房屋被售出价格,或者棒球队可能获得的胜场数,又或者患者住院的天数。

事实上,我们也对分类问题感兴趣:不是问“多少”,而是问“哪一个”:

  • 某个电子邮件是否属于垃圾邮件文件夹?
  • 某个用户可能注册或不注册订阅服务?
  • 某个图像描绘的是驴、狗、猫,还是鸡?
  • 某人接下来最有可能看哪部电影?

通常,机器学习实践者用分类这个词来描述两个有微妙差别的问题:1. 我们只对样本的“硬性”类别感兴趣,即属于哪个类别;2. 我们希望得到“软性”类别,即得到属于每个类别的概率。这两者的界限往往很模糊。其中的一个原因是:即使我们只关注硬性类别,我们仍然使用软类别的模型。

4.1. 分类问题

我们从一个图像分类问题开始。假设每次输入是一个 2 × 2 的灰度图像。我们可以用一个标量表示每个像素值,每个图像对应四个特征 x1,x2,x3,x4。此外,假设每个图像属于类别“猫”“鸡”和“狗”中的一个。

接下来,我们要选择如何表示标签。我们有两个明显的选择:最直接的想法是选择 y{1,2,3},其中整数分别代表(狗、猫、鸡)。这是在计算机上存储此类信息的有效方式。如果类别间有一些自然顺序,比如说我们试图预测(婴儿、儿童、青少年、青年人、中年人、老年人),那么将这个问题转变为回归问题,并且保留这种格式是有意义的。

但是一般的分类问题并不与类别之间的自然顺序有关。幸运的是,统计学家很早以前就发明了一种表示分类数据的简单方法:独热编码(one-hot encoding)。独热编码是一个向量,它的分量和类别一样多。类别对应的分量设置为 1,其他所有分量设置为 0。

在我们的例子中,标签 y 将是一个三维向量,其中 (1,0,0) 对应于“猫”,(0,1,0) 对应于“鸡”,(0,0,1) 对应于“狗”:

y{(1,0,0),(0,1,0),(0,0,1)}.

4.2. 网络架构

为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型,每个类别对应一个输出。为了解决线性模型的分类问题,我们需要和输出一样多的仿射函数(affine function),每个输出对应于它自己的仿射函数。在我们的例子中,由于我们有 4 个特征和 3 个可能的输出类别,我们将需要 12 个标量来表示权重(带下标的 w),以及 3 个标量来表示偏置(带下标的 b)。下面我们为每个输入计算三个未规范化的预测(logit):o1,o2o3

o1=x1w11+x2w12+x3w13+x4w14+b1,o2=x1w21+x2w22+x3w23+x4w24+b2,o3=x1w31+x2w32+x3w33+x4w34+b3.

我们可以用神经网络图来描述这个计算过程。与线性回归一样,softmax 回归也是一个单层神经网络。由于计算每个输出 o1,o2o3 取决于所有输入 x1,x2,x3x4,所以 softmax 回归的输出层也是全连接层。

为了更简洁地表达模型,我们仍然使用线性代数符号。通过向量形式表达为 o=Wx+b,这是一种更适合数学和编码的形式。因此,我们已经将所有权重放到一个 3×4 矩阵中。对于给定数据样本的特征,我们的输出是由权重与输入特征进行矩阵-向量乘法再加上偏置 b 得到的。

4.3. 全连接层的参数开销

正如我们将在后续章节中看到的,在深度学习中,全连接层无处不在。然而,顾名思义,全连接层是“完全”连接的,可能有很多可学习的参数。具体来说,对于任何具有 d 个输入和 q 个输出的全连接层,参数开销为 O(dq),这个数字在实践中可能高得令人望而却步。幸运的是,将 d 个输入转换为 q 个输出的成本可以通过选择合适的超参数 q 来控制,以在实际应用中平衡参数节约和模型有效性。

4.4. softmax运算

现在我们将优化参数以最大化观测数据的概率。为了得到预测结果,我们将设置一个阈值,如选择具有最大概率的标签。

我们希望模型的输出 y^ 可以视为属于某一类的概率,然后选择具有最大输出值的类别 argmax y^j 作为我们的预测。例如,如果 y^1,y^2y^3 分别为 0.1、0.8 和 0.1,那么我们预测的类别是 2,在我们的例子中代表“鸡”。

然而我们能否将未规范化的预测直接视作我们感兴趣的输出呢?答案是否定的。因为将线性层的输出直接视为概率时存在一些问题:一方面,我们没有限制这些输出数字的总和为 1。另一方面,根据输入的不同,它们可以为负值。这些都违反了 2.6 节中所说的概率基本公理。

要将输出视为概率,我们必须保证在任何数据上的输出都是非负的且总和为 1。此外,我们需要一个训练的目标函数,来激励模型精准地估计概率。例如,在分类器输出 0.5 的所有样本中,我们希望这些样本是刚好属于一类实际属于该类别的类别。这个属性叫做校准(calibration)。

社会科学家冯·卢梭于 1959 年在选择模型(choice model)的理论基础上发表的 softmax 函数是这样定义的:softmax 函数能够将未规范化的预测变为非负数且总和为 1,同时让模型保持可导的性质。

为了完成这一目标,我们首先对未规范化的预测求幂,然后除以它们的总和。这样可以保证最终输出的概率值总和为 1。我们把每个求幂后的结果除以它们的总和。如下式:

y^=softmax(o),其中y^j=exp(oj)kexp(ok).

这里,对于所有的总和 0 \leq \hat y_j \leq 1。因此,y^ 可以视为一个正确的概率分布。softmax 运算不会改变未规范化的预测之间的大小顺序,只会确定分配给每个类别的概率。因此,在预测过程中,我们仍然可以用下式来选择最有可能的类别:

argmaxjy^j=argmaxjoj.

尽管 softmax 是一个非线性函数,但 softmax 回归的输出仍然由输入特征的仿射变换决定。因此,softmax 回归还是一个线性模型(linear model)。

4.5. 小批量样本的矢量化

为了提高计算效率并充分利用 GPU,我们通常会对小批量样本的数据执行矢量计算。假设我们读取了一个批量的样本 X,其中特征维度(输入数量)为 d,批量大小为 n。此外,假设我们在输出中有 q 类别。那么小批量样本的特征为 XRn×d,权重为 WRd×q,偏置为 bR1×q。softmax 回归的矢量计算表达式为:

O=XW+b,Y^=softmax(O).

相对于一次处理一个样本,小批量样本的矢量化加快了 XW 的矩阵-向量乘法。由于 X 中的每一行代表一个数据样本,那么 softmax 运算可以按行(rowwise)执行:对于 O 的每一行,我们先对所有项进行幂运算,然后通过求和和对它们进行标准化。在 (3.4.5) 中,XW+b 的求和会使用广播机制,小批量的未规范化预测 O 和输出概率 Y^ 都是形状为 n×q 的矩阵。

4.6. 损失函数

接下来,我们需要一个损失函数来度量预测的效果。我们将使用最大似然估计,这与在线性回归中的方法相同。

4.6.1. 对数似然

softmax 函数给出了一个向量 y^,我们可以将其视为“给定任意输入 x 的每个类的条件概率”。例如,y^1=P(y=x)。假设整个数据集 {X,Y} 具有 n 个样本,其中索引为 i 的样本由特征向量 x(i) 和独热标签向量 y(i) 组成。我们可以将估计值与实际值进行比较:

P(YX)=i=1nP(y(i)x(i)).

根据最大似然估计,我们最大化 P(YX),相当于最小化负对数似然:

logP(YX)=i=1nlogP(y(i)x(i))=i=1nl(y(i),y^(i)).

其中,对于任何标签 y 和模型预测 y^,损失函数为:

l(y,y^)=j=1qyjlogy^j.

在本节稍后的内容会讲到,(3.4.8) 中的损失函数通常被称为 交叉熵损失(cross-entropy loss)。由于 y 是一个长度为 q 的独热编码向量,所以除了一个项以外的所有项都消失了。由于所有 y^j 都是预测的概率,所以它们的对数永远不会大于 0。因此,如果正确地预测实际标签,即如果真实类别 P(yx)=1,则损失函数不能进一步最小化。注意,这往往是不可能的。例如,数据集中可能存在标签噪声(比如某些样本可能被误标),或输入特征没有足够的信息来完美地对每一个样本分类。

4.6.2. softmax 及其导数

由于 softmax 和相关的损失函数很常见,因此我们需要更好地理解它们的计算方式。将 (3.4.3) 代入损失 (3.4.8) 中。利用 softmax 的定义,我们得到:

l(y,y^)=j=1qyjlogexp(oj)k=1qexp(ok)=j=1qyjlogk=1qexp(ok)j=1qyjoj=logk=1qexp(ok)j=1qyjoj.

考虑相对于任何未规范化的预测 oj 的导数,我们得到:

ojl(y,y^)=exp(oj)k=1qexp(ok)yj=softmax(o)jyj.

换句话说,导数是我们对 softmax 模型分配的概率与实际发生的情况(由独热标签向量表示)之间的差异。从这个意义上讲,这与我们在回归中看到的非常相似,其中梯度是观测值和估计值之间的差异。这不是巧合,在任何指数族分布模型中,对数似然的梯度正是由此得出的。这使梯度计算在实践中变得容易很多。

4.6.3. 交叉熵损失

现在让我们考虑整个结果分布的情况,即观察到的不仅仅是一个结果。对于标签 y,我们可以使用与以前相同的表示形式。唯一的区别是,我们现在用一个概率向量表示,如 (0.1,0.2,0.7),而不是仅包含二元项的向量 (0,0,1)。我们使用 (3.4.8) 来定义损失,它是所有标签分布的预期损失值。此损失称为交叉熵损失(cross-entropy loss),它是分类问题最常用的损失之一。本书我们将通过介绍信息论基础来理解交叉熵损失。如果想要了解更多信息论的细节,请进一步参考本书附录中关于信息论的一节。

4.7. 信息论基础

信息论(information theory)涉及编码、解码、发送以及尽可能简洁地处理信息或数据。

4.7.1.

信息论的核心思想是量化数据中的信息内容。在信息论中,该数值被称为分布 P(entropy)。可以通过以下方程得到:

H[P]=jP(j)logP(j).

信息论的基本定理之一指出,为了对从分布中随机抽取的数据进行编码,我们至少需要 H[P] 个纳特(nat)对其进行编码。纳特相当于比特(bit),但是对数底为 e 而不是 2。因此,一个纳特是 1/log(2)1.44 比特。

4.7.2. 信息量

压缩与预测有什么关系呢?想象一下,我们有一个要压缩的数据流。如果我们很容易预测下一个数据,那么这个数据就很容易压缩。信息量衡量的是一个事件发生时带来的“惊异度”。通常用 logP(j) 来刻画这种惊异程度:当某事件的概率越低,观测到它时携带的信息量越大。

4.7.3. 重新审视交叉熵

如果把熵 H(P) 想象为“知道真实概率的人所经历的惊异程度”,那么什么是交叉熵?记为 H(P,Q),我们可以把交叉熵想象为“主观概率为 Q 的观察者在看到根据真实概率 P 生成的数据时的平均惊异”。当 P=Q 时,交叉熵达到最小值,这时 H(P,Q)=H(P)

简而言之,我们可以从两方面来考虑交叉熵分类目标:

  • 最大化观测数据的似然;
  • 最小化传达标签所需的惊异量。

4.8. 模型预测和评估

在训练 softmax 回归模型后,给出任何样本特征,我们可以预测每个输出类别的概率。通常我们使用预测概率最高的类别作为输出标签。如果预测与实际类别(标签)一致,则预测为正确。

在接下来的实验中,我们将使用精度(accuracy)来评估模型的性能:精度等于正确预测数与总样本数之比。

此外,还可以使用混淆矩阵(confusion matrix)、查准率(precision)、查全率(recall)和 F1 分数等更细致的指标来衡量分类器在不同类别上的表现。

4.9. 小结

  • softmax 运算 将一个向量映射为概率分布。
  • softmax 回归 适用于分类问题,它使用了 softmax 运算中输出类别的概率分布。
  • 交叉熵 是衡量两个概率分布之间差异的常用损失函数,它衡量模型编码数据所需的额外惊异。
  • 评估指标:在分类任务中常用精度来衡量整体性能,必要时使用混淆矩阵、查准率、查全率与 F1 分数获取更细粒度的性能信息。

5. 图像分类数据集

MNIST 数据集(LeCun et al., 1998)是图像分类中广泛使用的数据集之一,但作为基准数据集过于简单。我们将使用类似但更复杂的 Fashion-MNIST 数据集(Xiao et al., 2017)。

python
%matplotlib inline
import torch
import torchvision
from torch.utils import data
from torchvision import transforms
from d2l import torch as d2l

d2l.use_svg_display()

5.1. 读取数据集

我们可以通过框架中的内置函数将 Fashion-MNIST 数据集下载并读取到内存中。

python
# 通过 Tensor 实例将图像数据从 PIL 类型变换成 32 位浮点格式,
# 并除以 255 使所有像素值缩放在 0~1 之间
trans = transforms.ToTensor()

mnist_train = torchvision.datasets.FashionMNIST(
    root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.FashionMNIST(
    root="../data", train=False, transform=trans, download=True)

Fashion-MNIST 由 10 个类别的图像组成,每个类别由训练数据集(train dataset)中的 60000 张图像和测试数据集(test dataset)中的 10000 张图像组成。因此,训练集和测试集分别包含 60000 和 10000 张图像。测试数据集不会用于训练,只用于评估模型性能。

python
len(mnist_train), len(mnist_test)

输出:

text
(60000, 10000)

每个输入图像的高度和宽度均为 28 像素。数据集由灰度图像组成,其通道数为 1。为了简洁起见,本书将高度 h、宽度 w 像素图像的形状记为 h×w(h,w)

python
mnist_train[0][0].shape

输出:

text
torch.Size([1, 28, 28])

Fashion-MNIST 中包含的 10 个类别,分别为 t-shirt(T 恤)、trouser(裤子)、pullover(套衫)、dress(连衣裙)、coat(外套)、sandal(凉鞋)、shirt(衬衫)、sneaker(运动鞋)、bag(包)和 ankle boot(短靴)。

以下函数用于在数字标签索引及其文本名称之间进行转换。

python
def get_fashion_mnist_labels(labels):  #@save
    """返回 Fashion-MNIST 数据集的文本标签"""
    text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat',
                   'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
    return [text_labels[int(i)] for i in labels]

我们现在可以创建一个函数来可视化这些样本。

python
def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):  #@save
    """绘制图像列表"""
    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().squeeze(), cmap='gray')
        else:
            ax.imshow(img)
        ax.axes.get_xaxis().set_visible(False)
        ax.axes.get_yaxis().set_visible(False)
        if titles:
            ax.set_title(titles[i])
    return axes

以下是训练数据集中前几个样本的图像及其相应的标签。

python
X, y = next(iter(data.DataLoader(mnist_train, batch_size=18)))
show_images(X.reshape(18, 28, 28), 2, 9, titles=get_fashion_mnist_labels(y));

5.2. 读取小批量

为了使我们在读取训练集和测试集时更容易,我们使用内置的数据迭代器,而不是从零开始创建。回顾一下,在每次迭代中,数据加载器每次都会读取一个小批量数据,大小为 batch_size。通过内置数据迭代器,我们可以随机打乱所有样本,从而无偏见地读取小批量。

python
batch_size = 256

def get_dataloader_workers():  #@save
    """使用4个进程来读取数据"""
    return 4

train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True,
                             num_workers=get_dataloader_workers())

我们看一下读取训练数据所需的时间。

python
timer = d2l.Timer()
for X, y in train_iter:
    continue

f'{timer.stop():.2f} sec'

输出:

text
'3.37 sec'

5.3. 整合所有组件

现在我们已经完成了数据读取、标签转换和图像可视化的准备工作。下面将这些组件整合到一个完整的训练数据处理流程中。

我们首先定义一个通用数据加载函数load_data_fashion_mnist函数,用于获取和读取Fashion-MNIST数据集,它可以构造训练集或测试集的数据迭代器。此外,这个函数还接受一个可选参数resize,用来将图像大小调整为另一种形状。

python
def load_data_fashion_mnist(batch_size, resize=None):  #@save
    """下载Fashion-MNIST数据集,然后将其加载到内存中"""
    trans = [transforms.ToTensor()]
    if resize:
        trans.insert(0, transforms.Resize(resize))
    trans = transforms.Compose(trans)
    mnist_train = torchvision.datasets.FashionMNIST(
        root="../data", train=True, transform=trans, download=True)
    mnist_test = torchvision.datasets.FashionMNIST(
        root="../data", train=False, transform=trans, download=True)
    return (data.DataLoader(mnist_train, batch_size, shuffle=True,
                            num_workers=get_dataloader_workers()),
            data.DataLoader(mnist_test, batch_size, shuffle=False,
                            num_workers=get_dataloader_workers()))

下面,我们通过指定resize参数来测试load_data_fashion_mnist函数的图像大小调整功能。

python
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)
    break

输出:

text
torch.Size([32, 1, 64, 64]) torch.float32 torch.Size([32]) torch.int64

到此为止,我们已经把所有数据流水线组件整合完毕:

  • 使用 transforms.ToTensor() 将图像转换为张量;
  • 使用 data.DataLoader 构造训练和测试迭代器;
  • 使用 get_fashion_mnist_labels() 将数值标签映射为文本标签;
  • 使用 show_images() 可视化样本。

5.4. 小结

  • Fashion-MNIST 是一个服装分类数据集,由 10 个类别的图像组成。我们将在后续章节中使用此数据集来评估各种分类算法。
  • 我们将高度 h 像素、宽度 w 像素图像的形状记为 h×w(h,w)
  • 数据迭代器是获得更高性能的关键组件。依靠实现良好的数据迭代器,利用高性能计算来避免减慢训练过程。

6. softmax回归的从零开始实现

在上一节中,我们介绍了 softmax 回归的基本思想和概率解释。本节将从零开始实现 softmax 回归模型,逐步构建数据处理、模型参数、softmax 运算、损失函数和训练流程。本节我们将使用刚刚在 3.5节中引入的Fashion-MNIST数据集, 并设置数据迭代器的批量大小为256。

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

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

6.1. 初始化模型参数

在实现 softmax 回归之前,需要明确模型的输入维度和输出类别数。

对于 Fashion-MNIST,输入图像的像素数为 28×28=784,类别数为 10。我们将权重矩阵初始化为形状为 (784,10) 的小值随机张量,并将偏置初始化为形状为 (10,) 的零向量。

python
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)

这里,requires_grad=True 表示 PyTorch 需要追踪这些参数的梯度,以便在反向传播时自动计算。

6.2. 定义softmax操作

在实现 softmax 回归模型之前,我们需要回顾一下 sum 运算符如何沿着张量中的特定维度工作。比如 2.3.6 节2.3.6.1 节 所示,给定一个矩阵 X,我们可以对所有元素求和(默认情况下)。也可以只对某一轴上的元素,即一列(轴 0)或一行(轴 1)进行求和。如果 X 是一个形状为 (2,3) 的张量,我们对列求和,则结果将是一个具有形状 (3,) 的向量。当调用 sum 运算符时,我们可以指定保持在原始张量的轴数,而不折叠求和的维度。这将产生一个具有形状 (1,3) 的二维张量。

python
X = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
X.sum(0, keepdim=True), X.sum(1, keepdim=True)

输出:

text
(tensor([[5., 7., 9.]]),
 tensor([[ 6.],
         [15.]]))

回想一下,实现 softmax 由三个步骤组成:

  1. 对每个项求幂(使用 exp);
  2. 对每一行求和(小批量中每个样本是一行),得到每个样本的规范化常数;
  3. 将每一行除以其规范化常数,确保结果的和为 1。

在查看代码之前,我们回顾一下这个表达式:

softmax(X)ij=exp(Xij)kexp(Xik).

分母或规范化常数,有时也称为配分函数(其对数称为对数-配分函数)。该名称来自统计物理学中一个模拟粒子群分布的方程。

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

正如上述代码,对于任何随机输入,我们将每个元素变成一个非负数。此外,依据概率原理,每行总和为 1。

python
X = torch.normal(0, 1, (2, 5))
X_prob = softmax(X)
X_prob, X_prob.sum(1)

输出:

text
(tensor([[0.1686, 0.4055, 0.0849, 0.1064, 0.2347],
        [0.0217, 0.2652, 0.6354, 0.0457, 0.0321]]),
 tensor([1.0000, 1.0000]))

注意,虽然这在数学上看起来是正确的,但我们在代码实现中有点草率。矩阵中的非常大或非常小的元素可能造成数值上溢或下溢,但我们没有采取措施来防止这一点。

6.3. 定义模型

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

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

这里,X.reshape(-1, W.shape[0]) 将任意数量的样本展开为形状 (batch_size, 784) 的矩阵。torch.matmul 计算线性变换,softmax 将结果转换为概率分布。

6.4. 定义损失函数

接下来,我们实现 3.4 节中引入的交叉熵损失函数。这可能是深度学习中最常见的损失函数,因为目前分类问题的数量远远超过回归问题的数量。

回顾一下,交叉熵采用真实标签的预测概率的负对数似然。这里我们不使用 Python 的 for 循环逐位预测(这往往是低效的),而是通过一个运算符选择所有元素。下面,我们创建一个数据样本 y_hat,其中包含 2 个样本在 3 个类别的预测概率,以及它们对应的标签。 有了 y,我们知道在第一个样本中,第一类是正确的预测;而在第二个样本中,第三类是正确的预测。然后使用 y 作为 y_hat 中概率的索引,我们选择第一个样本中第一类的概率和第二个样本中第三类的概率。

python
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]

输出:

text
tensor([0.1000, 0.5000])

现在我们只需一行代码就可以实现交叉熵损失函数。

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

cross_entropy(y_hat, y)

输出:

text
tensor([2.3026, 0.6931])

6.5. 分类精度

给定预测概率分布 y_hat,当我们必须输出硬预测(hard prediction)时,我们通常选择预测概率最高的类别。许多应用都要求我们做出选择。例如 Gmail 必须把电子邮件分类为“Primary(主要邮件)”、“Social(社交邮件)”、“Updates(更新邮件)”或“Forums(论坛邮件)”。Gmail 做分类时可能会内部估计概率,但最终它必须在类别中选择一个。

当预测与标签分类一致时,即是正确的。分类精度即正确预测数量与总预测数量之比。虽然直接优化精度可能很困难(因为精度的计算不可导),但精度通常是我们最关心的性能衡量标准,我们在训练分类器时几乎总会关注它。

为了计算精度,我们执行以下操作。首先,如果 y_hat 是矩阵,那么假定第二个维度存储每个类的预测分数。我们使用 argmax 获得每行中最大元素的索引来获得预测类别。然后我们将预测类别与真实 y 元素进行比较。由于 == 运算符对数据类型很敏感,因此我们将 y_hat 的数据类型转换为 y 的数据类型,以确保结果是一个包含 0(错)和 1(对)的张量。最后,我们求和会得到正确预测的数量。

python
def accuracy(y_hat, y):  #@save
    """计算预测正确的数量"""
    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 和 y 分别作为预测的概率分布和标签。可以看到,第一个样本的预测类别是 2(该行的最大元素为 0.6,索引为 2),这与实际标签 0 不一致。第二个样本的预测类别是 2(该行的最大元素为 0.5,索引为 2),这与实际标签 2 一致。因此,这两个样本的分类精度为 0.5。

python
accuracy(y_hat, y) / len(y)

输出:

text
0.5

同样,对于任意数据迭代器 data_iter 可访问的数据集,我们可以评估在任意模型 net 上的精度。

python
def evaluate_accuracy(net, data_iter):  #@save
    """计算在指定数据集上模型的精度"""
    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]

这里定义一个实用程序类 Accumulator,用于对多个变量进行累加。在上面的 evaluate_accuracy 函数中,我们在 Accumulator 实例中创建了 2 个变量,分别用于存储正确预测的数量和预测的总数量。当我们遍历数据集时,两个都将随着时间的推移而累加。

python
class Accumulator:  #@save
    """在 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, idx):
        return self.data[idx]

由于我们使用随机权重初始化 net 模型,因此该模型的精度应接近于随机猜测。例如在有 10 个类别情况下的精度为 0.1。

python
evaluate_accuracy(net, test_iter)

输出:

text
0.0625

6.6. 训练

在我们看到 3.2 节中的线性回归实现,softmax 回归的训练过程代码应该看起来非常眼熟。在这里,我们重构训练过程的实现以使其可重用。首先,我们定义一个函数来训练一个迭代周期。请注意,updater 是更新模型参数的常用函数,它接受批量大小作为参数。它可以是 d2l.sgd 函数,也可以是框架的内置优化器函数。

python
def train_epoch_ch3(net, train_iter, loss, updater):  #@save
    """训练模型一个迭代周期(定义见第3章)"""
    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.mean().backward()
            updater.step()
        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]

在展示训练函数的实现之前,我们定义一个在动画中绘制数据的实用程序类 Animator,它能够简化本书其余部分的代码。

python
class Animator:  #@save
    """在动画中绘制数据"""
    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()
        display.display(self.fig)
        display.clear_output(wait=True)

接下来我们实现一个训练函数,它会在 train_iter 访问到的训练数据集上训练一个模型 net。该训练函数将运行多个迭代周期(由 num_epochs 指定)。在每个迭代周期结束时,利用 test_iter 访问到的测试数据集对模型进行评估。我们将利用 Animator 类来可视化训练进度。

python
def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater):  #@save
    """训练模型(定义见第3章)"""
    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
    assert train_loss < 0.5, train_loss
    assert train_acc <= 1 and train_acc > 0.7, train_acc
    assert test_acc <= 1 and test_acc > 0.7, test_acc

作为一个从零开始的实现,我们使用 3.2 节定义的小批量随机梯度下降来优化模型的损失函数,设置学习率为 0.1。

python
lr = 0.1

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

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

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

6.7. 预测

现在训练已经完成,我们的模型已经准备好对图像进行分类预测。给定一系列图像,我们将比较它们的实际标签(文本输出的第一行)和模型预测(文本输出的第二行)。

python
def predict_ch3(net, test_iter, n=6):  #@save
    """预测标签(定义见第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])

predict_ch3(net, test_iter)

6.8. 小结

  • 借助softmax回归,我们可以训练多分类的模型。
  • 训练softmax回归循环模型与训练线性回归模型非常相似:先读取数据,再定义模型和损失函数,然后使用优化算法训练模型。大多数常见的深度学习模型都有类似的训练过程。

7. softmax回归的简洁实现

在 3.3 节中,我们发现通过深度学习框架的高级 API 能够使实现线性回归变得更加容易。同样,通过深度学习框架的高级 API 能更方便地实现 softmax 回归模型。本节如同在 3.6 节中一样,继续使用 Fashion-MNIST 数据集,并保持批量大小为 256。

python
import torch
from torch import nn
from d2l import torch as d2l

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

7.1. 初始化模型参数

如我们在 3.4 节所述,softmax 回归的输出层是一个全连接层。因此,为了实现我们的模型,我们只需在 nn.Sequential 中添加一个带有 10 个输出的全连接层。同样,在这里 Sequential 并不是必要的,但它是实现深度模型的基础。我们仍然以均值 0 和标准差 0.01 随机初始化权重。

python
# PyTorch 不会隐式地调整输入的形状。因此,
# 我们在网络层前定义了展平层(flatten),来调整网络输入的形状
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);

7.2. 重新审视 Softmax 的实现

在前面 :numref:sec_softmax_scratch 的例子中,我们计算了模型的输出,然后将此输出送入交叉熵损失。从数学上讲,这是一件完全合理的事情。然而,从计算角度来看,指数可能会造成数值稳定性问题。

回想一下,softmax 函数为 y^j=exp(oj)kexp(ok),其中 y^j 是预测的概率分布,oj 是未规范化预测 o 的第 j 个元素。如果 ok 中的一些数值非常大,那么 exp(ok) 可能大于数据类型容许的最大数字,即上溢(overflow)。这将使分母或分子变为 inf(无穷大),最后得到的是 0、infnan(不是数字)的 y^j。在这些情况下,我们无法得到一个明确定义的交叉熵值。

解决这个问题的一个技巧是:在继续进行 softmax 计算之前,先从所有 ok 中减去 max(ok)。这里可以看到,每个 ok 按常数进行的移动不会改变 softmax 的返回值:

y^j=exp(ojmax(ok))exp(max(ok))kexp(okmax(ok))exp(max(ok))=exp(ojmax(ok))kexp(okmax(ok)).

在减法和规范化步骤之后,可能有些 ojmax(ok) 具有较大的负值。由于精度受限,exp(ojmax(ok)) 将有接近零的值,即下溢(underflow)。这些值可能会四舍五入为零,使得 y^j 为零,并且使得 log(y^j) 的值为 -inf。反向传播几步后,我们可能会发现自己面对一屏幕可怕的 nan 结果。

尽管我们需要计算指数函数,但最终在计算交叉熵损失时会取它们的对数。通过将 softmax 和交叉熵结合在一起,可以避免反向传播过程中可能会困扰我们的数值稳定性问题。如下式所示,我们避免计算 exp(ojmax(ok)),而可以直接使用 ojmax(ok),因为 log(exp()) 被抵消了。

log(y^j)=log(exp(ojmax(ok))kexp(okmax(ok)))=log(exp(ojmax(ok)))log(kexp(okmax(ok)))=ojmax(ok)log(kexp(okmax(ok))).

我们也希望保留传统的 softmax 函数,以备需要评估通过模型输出的概率。但是,我们没有将 softmax 概率传递到损失函数中,而是在交叉熵损失函数中传递未规范化的预测,并同时计算 softmax 及其对数,这是一种类似于 LogSumExp 技巧的聪明方式。

python
loss = nn.CrossEntropyLoss(reduction='none')

7.3. 优化算法

我们可以直接使用 PyTorch 的优化器来更新模型参数。这里使用 SGD,并设置学习率为 0.1:

python
trainer = torch.optim.SGD(net.parameters(), lr=0.1)

7.4. 训练

训练代码与之前的实现类似,但我们利用框架的工具来简化:

python
num_epochs = 10
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

和以前一样,这个算法使结果收敛到一个相当高的精度,而且这次的代码比之前更精简了。

7.5. 小结

  • 使用高级 API 可以更简洁地实现 softmax 回归:定义模型、损失与优化器后,训练循环与从零实现基本一致;
  • 推荐使用框架提供的 nn.CrossEntropyLoss()torch.optim 优化器以提高代码的稳定性与可读性;
  • 通过合适地设置批量大小、学习率和迭代周期,可以快速训练出在 Fashion-MNIST 上有合理表现的模型。
  • 从计算的角度来看,实现softmax回归比较复杂。在许多情况下,深度学习框架在这些著名的技巧之外采取了额外的预防措施,来确保数值的稳定性。这使我们避免了在实践中从零开始编写模型时可能遇到的陷阱。

评论区

可以在这里补充理解、提问,或记录这一章的讨论。