D2L-3-Linear Neural Networks

本文最后更新于:1 年前

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

线性回归

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

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

基本元素

  • 线性回归(linear regression)可以追溯到19世纪初, 它在回归的各种标准工具中最简单而且最流行。 线性回归基于几个简单的假设: 首先,假设自变量$\mathbf{x}$和因变量$y$之间的关系是线性的, 即$y$可以表示为$\mathbf{x}$中元素的加权和,这里通常允许包含观测值的一些噪声; 其次,我们假设任何噪声都比较正常,如噪声遵循正态分布。
  • 通常,我们使用$n$来表示数据集中的样本数。 对索引为$i$的样本,其输入表示为$\mathbf{x}^{(i)} = [x_1^{(i)}, x_2^{(i)}]^\top$, 其对应的标签是$y^{(i)}$。

线性模型

  • 线性假设是指目标(房屋价格)可以表示为特征(面积和房龄)的加权和,如:$\mathrm{price} = w_{\mathrm{area}} \cdot \mathrm{area} + w_{\mathrm{age}} \cdot \mathrm{age} + b.$。$w_{\mathrm{area}}$和$w_{\mathrm{age}}$是权重(weight),权重决定了每个特征对我们预测值的影响。$b$称为偏置(bias)、偏移量(offset)或截距(intercept)。偏置是指当所有特征都取值为0时,预测值应该为多少。 即使现实中不会有任何房子的面积是0或房龄正好是0年,我们仍然需要偏置项。 如果没有偏置项,我们模型的表达能力将受到限制。

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

  • 给定一个数据集,我们的目标是寻找模型的权重$\mathbf{w}$和偏置$b$, 使得根据模型做出的预测大体符合数据里的真实价格。 输出的预测值由输入特征通过线性模型的仿射变换决定,仿射变换由所选权重和偏置确定。
  • 而在机器学习领域,我们通常使用的是高维数据集,建模时采用线性代数表示法会比较方便。 当我们的输入包含$d$个特征时,我们将预测结果$\hat{y}$(通常使用“尖角”符号表示$y$的估计值)表示为:

$$
\hat{y} = w_1 x_1 + … + w_d x_d + b.
$$

特征放入$\mathbf{x} \in \mathbb{R}^d$,权重放入$\mathbf{w} \in \mathbb{R}^d$,我们可以用点积形式来简洁地表达模型:

$$
\hat{y} = \mathbf{w}^\top \mathbf{x} + b.
$$

  • $\mathbf{x}$对应于单个数据样本的特征。 用符号表示的矩阵$\mathbf{X} \in \mathbb{R}^{n \times d}$可以很方便地引用我们整个数据集的$n$个样本。 $\mathbf{X}$的每一行是一个样本,每一列是一种特征。对于特征集合$\mathbf{X}$,预测值$\hat{\mathbf{y}} \in \mathbb{R}^n$可以通过矩阵-向量乘法表示为:

$$
{\hat{\mathbf{y}}} = \mathbf{X} \mathbf{w} + b
$$

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

  • 虽然我们相信给定$\mathbf{x}$预测$y$的最佳模型会是线性的, 但我们很难找到一个有$n$个样本的真实数据集,其中对于所有的$1 \leq i \leq n$,$y^{(i)}$完全等于$\mathbf{w}^\top \mathbf{x}^{(i)}+b$。 无论我们使用什么手段来观察特征$\mathbf{X}$和标签$y$, 都可能会出现少量的观测误差。 因此,即使确信特征与标签的潜在关系是线性的, 我们也会加入一个噪声项来考虑观测误差带来的影响。

  • 在开始寻找最好的模型参数(model parameters)$\mathbf{w}$和$b$之前, 我们还需要两个东西: (1)一种模型质量的度量方式; (2)一种能够更新模型以提高模型预测质量的方法。

损失函数

  • 我们需要确定一个拟合程度的度量。 损失函数(loss function)能够量化目标的实际值与预测值之间的差距。 通常我们会选择非负数作为损失,且数值越小表示损失越小,完美预测时的损失为0。 回归问题中最常用的损失函数是平方误差函数。 当样本$i$的预测值为$\hat{y}^{(i)}$,其相应的真实标签为$y^{(i)}$时, 平方误差可以定义为以下公式:

$$
l^{(i)}(\mathbf{w}, b) = \frac{1}{2} \left(\hat{y}^{(i)} - y^{(i)}\right)^2.
$$

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

../_images/fit-linreg.svg

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

$$
L(\mathbf{w}, b) =\frac{1}{n}\sum_{i=1}^n l^{(i)}(\mathbf{w}, b) =\frac{1}{n} \sum_{i=1}^n \frac{1}{2}\left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right)^2.
$$

在训练模型时,我们希望寻找一组参数$(\mathbf{w}^*, b^*)$, 这组参数能最小化在所有训练样本上的总损失。如下式:

$$
\mathbf{w}^*, b^* = \operatorname*{argmin}_{\mathbf{w}, b}\ L(\mathbf{w}, b).
$$

解析解

  • 线性回归刚好是一个很简单的优化问题。 与我们将在本书中所讲到的其他大部分模型不同,线性回归的解可以用一个公式简单地表达出来, 这类解叫作解析解(analytical solution)。 首先,我们将偏置$b$合并到参数$\mathbf{w}$中,合并方法是在包含所有参数的矩阵中附加一列。 我们的预测问题是最小化$|\mathbf{y} - \mathbf{X}\mathbf{w}|^2$。 这在损失平面上只有一个临界点,这个临界点对应于整个区域的损失极小点。 将损失关于$\mathbf{w}$的导数设为0,得到解析解:

$$
\mathbf{w}^* = (\mathbf X^\top \mathbf X)^{-1}\mathbf X^\top \mathbf{y}.
$$

像线性回归这样的简单问题存在解析解,但并不是所有的问题都存在解析解。 解析解可以进行很好的数学分析,但解析解对问题的限制很严格,导致它无法广泛应用在深度学习里。

随机梯度下降

  • 我们用到一种名为梯度下降(gradient descent)的方法, 这种方法几乎可以优化所有深度学习模型。 它通过不断地在损失函数递减的方向上更新参数来降低误差。
  • 梯度下降最简单的用法是计算损失函数(数据集中所有样本的损失均值) 关于模型参数的导数(在这里也可以称为梯度)。 但实际中的执行可能会非常慢:因为在每一次更新参数之前,我们必须遍历整个数据集。 因此,我们通常会在每次需要计算更新的时候随机抽取一小批样本, 这种变体叫做小批量随机梯度下降(minibatch stochastic gradient descent)。
  • 在每次迭代中,我们首先随机抽样一个小批量$\mathcal{B}$, 它是由固定数量的训练样本组成的。 然后,我们计算小批量的平均损失关于模型参数的导数(也可以称为梯度)。 最后,我们将梯度乘以一个预先确定的正数$\eta$,并从当前参数的值中减掉。我们用下面的数学公式来表示这一更新过程($\partial$表示偏导数):

$$
(\mathbf{w},b) \leftarrow (\mathbf{w},b) - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_{(\mathbf{w},b)} l^{(i)}(\mathbf{w},b).
$$

  • 总结一下,算法的步骤如下:
    • (1)初始化模型参数的值,如随机初始化;
    • (2)从数据集中随机抽取小批量样本且在负梯度的方向上更新参数,并不断迭代这一步骤。
  • 对于平方损失和仿射变换,我们可以明确地写成如下形式:

$$
\begin{split}\begin{aligned} \mathbf{w} &\leftarrow \mathbf{w} - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_{\mathbf{w}} l^{(i)}(\mathbf{w}, b) = \mathbf{w} - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \mathbf{x}^{(i)} \left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right),\ b &\leftarrow b - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_b l^{(i)}(\mathbf{w}, b) = b - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right). \end{aligned}\end{split}
$$

$\mathbf{w}$和$x$都是向量,更优雅的向量表示法比系数表示法(如$w_1, w_2, \ldots, w_d$)更具可读性。 $|\mathcal{B}|$表示每个小批量中的样本数,这也称为批量大小(batch size)。 $\eta$表示学习率(learning rate)。 批量大小和学习率的值通常是手动预先指定,而不是通过模型训练得到的。 这些可以调整但不在训练过程中更新的参数称为超参数(hyperparameter)。 调参(hyperparameter tuning)是选择超参数的过程。 超参数通常是我们根据训练迭代结果来调整的, 而训练迭代结果是在独立的验证数据集(validation dataset)上评估得到的。

  • 在训练了预先确定的若干迭代次数后(或者直到满足某些其他停止条件后), 我们记录下模型参数的估计值,表示为$\hat{\mathbf{w}}, \hat{b}$。 但是,即使我们的函数确实是线性的且无噪声,这些估计值也不会使损失函数真正地达到最小值。 因为算法会使得损失向最小值缓慢收敛,但却不能在有限的步数内非常精确地达到最小值。

  • 线性回归恰好是一个在整个域中只有一个最小值的学习问题。 但是对像深度神经网络这样复杂的模型来说,损失平面上通常包含多个最小值。 深度学习实践者很少会去花费大力气寻找这样一组参数,使得在训练集上的损失达到最小。 事实上,更难做到的是找到一组参数,这组参数能够在我们从未见过的数据上实现较低的损失, 这一挑战被称为泛化(generalization)。

用模型预测

  • “已学习”的线性回归模型$\hat{\mathbf{w}}^\top \mathbf{x} + \hat{b}$,就可以通过新的$\mathbf{x}$,来估计一个$y$。给定特征估计目标的过程通常称为预测(prediction)或推断(inference)。
  • 本书将尝试坚持使用预测这个词。 虽然推断这个词已经成为深度学习的标准术语,但其实推断这个词有些用词不当。 在统计学中,推断更多地表示基于数据集估计参数。

矢量化加速

  • 在训练我们的模型时,我们经常希望能够同时处理整个小批量的样本。 为了实现这一点,需要我们对计算进行矢量化, 从而利用线性代数库,而不是在Python中编写开销高昂的for循环。
1
2
3
4
5
6
%matplotlib inline
import math
import time
import numpy as np
import torch
from d2l import torch as d2l
  • 线性代数库与for循环性能对比:
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# test case
n = 10000
a = torch.ones([n])
b = torch.ones([n])


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循环
c = torch.zeros(n)
timer = Timer()
for i in range(n):
c[i] = a[i] + b[i]
f'{timer.stop():.5f} sec'
# result
'0.09661 sec'


# 线性代数库
timer.start()
d = a + b
f'{timer.stop():.5f} sec'
# result
'0.00021 sec'

矢量化代码通常会带来数量级的加速。 另外,我们将更多的数学运算放到库中,而无须自己编写那么多的计算,从而减少了出错的可能性。

正态分布与平方损失

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

$$
p(x) = \frac{1}{\sqrt{2 \pi \sigma^2}} \exp\left(-\frac{1}{2 \sigma^2} (x - \mu)^2\right).
$$

  • 可视化正态分布:
1
2
3
4
5
6
7
8
9
10
11
12
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)

# 再次使用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])

../_images/output_linear-regression_216540_70_0.svg

  • 就像我们所看到的,改变均值会产生沿$x$轴的偏移,增加方差将会分散分布、降低其峰值。均方误差损失函数(简称均方损失)可以用于线性回归的一个原因是: 我们假设了观测中包含噪声,其中噪声服从正态分布。 噪声正态分布如下式:

$$
y = \mathbf{w}^\top \mathbf{x} + b + \epsilon,
$$

其中,$\epsilon \sim \mathcal{N}(0, \sigma^2)$。因此,我们现在可以写出通过给定的$\mathbf{x}$观测到特定$y$的似然(likelihood):

$$
P(y \mid \mathbf{x}) = \frac{1}{\sqrt{2 \pi \sigma^2}} \exp\left(-\frac{1}{2 \sigma^2} (y - \mathbf{w}^\top \mathbf{x} - b)^2\right).
$$

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

$$
P(\mathbf y \mid \mathbf X) = \prod_{i=1}^{n} p(y^{(i)}|\mathbf{x}^{(i)}).
$$

根据极大似然估计法选择的估计量称为极大似然估计量。 虽然使许多指数函数的乘积最大化看起来很困难, 但是我们可以在不改变目标的前提下,通过最大化似然对数来简化。 由于历史原因,优化通常是说最小化而不是最大化。 我们可以改为最小化负对数似然$-\log P(\mathbf y \mid \mathbf X)$。 由此可以得到的数学公式是:

$$
-\log P(\mathbf y \mid \mathbf X) = \sum_{i=1}^n \frac{1}{2} \log(2 \pi \sigma^2) + \frac{1}{2 \sigma^2} \left(y^{(i)} - \mathbf{w}^\top \mathbf{x}^{(i)} - b\right)^2.
$$

现在我们只需要假设$\sigma$是某个固定常数就可以忽略第一项, 因为第一项不依赖于$\mathbf{w}$和$b$。 现在第二项除了常数$\frac{1}{\sigma^2}$外,其余部分和前面介绍的均方误差是一样的。 幸运的是,上面式子的解并不依赖于$\sigma$。 因此,在高斯噪声的假设下,最小化均方误差等价于对线性模型的极大似然估计。

从线性回归到深度网络

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

神经网络图

../_images/singleneuron.svg

输入为$x_1, \ldots, x_d$, 因此输入层中的输入数(或称为特征维度,feature dimensionality)为$d$。 网络的输出为$o_1$,因此输出层中的输出数是1。 需要注意的是,输入值都是已经给定的,并且只有一个计算神经元。 由于模型重点在发生计算的地方,所以通常我们在计算层数时不考虑输入层。 也就是说,图中神经网络的层数为1。 我们可以将线性回归模型视为仅由单个人工神经元组成的神经网络,或称为单层神经网络。

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

生物学

../_images/neuron.svg

树突中接收到来自其他神经元(或视网膜等环境传感器)的信息$x_i$。 该信息通过突触权重$w_i$来加权,以确定输入的影响(即,通过$x_i w_i$相乘来激活或抑制)。 来自多个源的加权输入以加权和$y = \sum_i x_i w_i + b$的形式汇聚在细胞核中, 然后将这些信息发送到轴突$y$中进一步处理,通常会通过$\sigma(y)$进行一些非线性处理。 之后,它要么到达目的地(例如肌肉),要么通过树突进入另一个神经元。

  • 当今大多数深度学习的研究几乎没有直接从神经科学中获得灵感。在深度学习中的灵感同样或更多地来自数学、统计学和计算机科学。

Tips: 具体的Linear Regression推导,可以看看下面那一篇知乎的reference昂!!!

线性回归的从零开始实现

  • 代码实现:
1
2
3
4
%matplotlib inline
import random
import torch
from d2l import torch as d2l

生成数据集

我们将根据带有噪声的线性模型构造一个人造数据集。 我们的任务是使用这个有限样本的数据集来恢复这个模型的参数。 我们将使用低维数据,这样可以很容易地将其可视化。

  • 我们生成一个包含1000个样本的数据集, 每个样本包含从标准正态分布中采样的2个特征。 我们的合成数据集是一个矩阵$\mathbf{X}\in \mathbb{R}^{1000 \times 2}$,我们使用线性模型参数$\mathbf{w} = [2, -3.4]^\top$、$b = 4.2$和噪声项$\epsilon$生成数据集及其标签:

$$
\mathbf{y}= \mathbf{X} \mathbf{w} + b + \mathbf\epsilon.
$$

$\epsilon$可以视为模型预测和标签时的潜在观测误差。 在这里我们认为标准假设成立,即$\epsilon$服从均值为0的正态分布。 为了简化问题,我们将标准差设为0.01。 下面的代码生成合成数据集。

1
2
3
4
5
6
7
8
9
10
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中的每一行都包含一维标签值(一个标量)。

1
2
3
4
5
print('features:', features[0],'\nlabel:', labels[0])

# result
features: tensor([-0.3679, -1.8471])
label: tensor([9.7361])
  • 通过生成第二个特征features[:, 1]labels的散点图, 可以直观观察到两者之间的线性关系。
1
2
d2l.set_figsize()
d2l.plt.scatter(features[:, 1].detach().numpy(), labels.detach().numpy(), 1);

../_images/output_linear-regression-scratch_58de05_51_0.svg

读取数据集

  • 训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用它们来更新我们的模型。 有必要定义一个函数, 该函数能打乱数据集中的样本并以小批量方式获取数据。我们定义一个data_iter函数, 该函数接收批量大小、特征矩阵和标签向量作为输入,生成大小为batch_size的小批量。 每个小批量包含一组特征和标签。
1
2
3
4
5
6
7
8
9
def data_iter(batch_size, features, labels):
num_examples = len(features)
indices = list(range(num_examples))
# 这些样本是随机读取的,没有特定的顺序
random.shuffle(indices)
for i in range(0, num_examples, batch_size):
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)])
yield features[batch_indices], labels[batch_indices]

通常,我们利用GPU并行运算的优势,处理合理大小的“小批量”。 每个样本都可以并行地进行模型计算,且每个样本损失函数的梯度也可以被并行计算。 GPU可以在处理几百个样本时,所花费的时间不比处理一个样本时多太多。

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
batch_size = 10

for X, y in data_iter(batch_size, features, labels):
print(X, '\n', y)
break

# result
tensor([[ 0.1649, -1.1651],
[-2.0755, -1.0165],
[-0.2189, 0.7607],
[ 0.6833, 0.3537],
[-0.2736, -2.0485],
[-0.3026, 0.9771],
[ 2.4795, 0.6881],
[-0.2045, -0.8509],
[-0.1353, 0.5476],
[ 0.3371, -0.0479]])
tensor([[ 8.4901],
[ 3.5015],
[ 1.1779],
[ 4.3752],
[10.6125],
[ 0.2845],
[ 6.8094],
[ 6.6776],
[ 2.0598],
[ 5.0189]])

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

Tips: 深度学习框架很好用!!!性能很好捏!!!

初始化模型参数

  • 我们通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重, 并将偏置初始化为0。
1
2
w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)

在初始化参数之后,我们的任务是更新这些参数,直到这些参数足够拟合我们的数据。 每次更新都需要计算损失函数关于模型参数的梯度。 有了这个梯度,我们就可以向减小损失的方向更新每个参数。 因为手动计算梯度很枯燥而且容易出错,所以没有人会手动计算梯度。

定义模型

  • 我们必须定义模型,将模型的输入和参数同模型的输出关联起来。 回想一下,要计算线性模型的输出, 我们只需计算输入特征$\mathbf{X}$和模型权重$\mathbf{w}$的矩阵-向量乘法后加上偏置$b$。 注意,上面的$\mathbf{Xw}$是一个向量,而$b$是一个标量。 回想一下 2.1.3节中描述的广播机制: 当我们用一个向量加一个标量时,标量会被加到向量的每个分量上。
1
2
3
def linreg(X, w, b):  #@save
"""线性回归模型"""
return torch.matmul(X, w) + b

定义损失函数

  • 平方损失函数,我们需要将真实值y的形状转换为和预测值y_hat的形状相同。
1
2
3
def squared_loss(y_hat, y):  #@save
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

定义优化算法

  • 线性回归有解析解,其他模型不一定有, 这里我们介绍小批量随机梯度下降。
1
2
3
4
5
6
def sgd(params, lr, batch_size):  #@save
"""小批量随机梯度下降"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()

在每一步中,使用从数据集中随机抽取的一个小批量,然后根据参数计算损失的梯度。 接下来,朝着减少损失的方向更新我们的参数。 下面的函数实现小批量随机梯度下降更新。 该函数接受模型参数集合、学习速率和批量大小作为输入。每 一步更新的大小由学习速率lr决定。 因为我们计算的损失是一个批量样本的总和,所以我们用批量大小(batch_size) 来规范化步长,这样步长大小就不会取决于我们对批量大小的选择。

训练

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

  • 我们将执行以下循环:

    • 初始化参数
    • 重复以下训练,直到完成
      • 计算梯度$\mathbf{g} \leftarrow \partial_{(\mathbf{w},b)} \frac{1}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} l(\mathbf{x}^{(i)}, y^{(i)}, \mathbf{w}, b)$
      • 更新参数$(\mathbf{w}, b) \leftarrow (\mathbf{w}, b) - \eta \mathbf{g}$
  • 在每个迭代周期(epoch)中,我们使用data_iter函数遍历整个数据集, 并将训练数据集中所有样本都使用一次(假设样本数能够被批量大小整除)。 这里的迭代周期个数num_epochs和学习率lr都是超参数,分别设为3和0.03。 设置超参数很棘手,需要通过反复试验进行调整,我们现在忽略这些细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
for X, y in data_iter(batch_size, features, labels):
l = loss(net(X, w, b), y) # X和y的小批量损失
# 因为l形状是(batch_size,1),而不是一个标量。l中的所有元素被加到一起,
# 并以此计算关于[w,b]的梯度
l.sum().backward()
sgd([w, b], lr, batch_size) # 使用参数的梯度更新参数
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

还是一样的,先通过构造损失函数l,然后通过l来执行backward()函数,进行更新。

  • 因为我们使用的是自己合成的数据集,所以我们知道真正的参数是什么。 因此,我们可以通过比较真实参数和通过训练学到的参数来评估训练的成功程度。 事实上,真实参数和通过训练学到的参数确实非常接近。
1
2
3
4
5
6
print(f'w的估计误差: {true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差: {true_b - b}')

# result
w的估计误差: tensor([ 0.0003, -0.0002], grad_fn=<SubBackward0>)
b的估计误差: tensor([0.0010], grad_fn=<RsubBackward1>)

线性回归的简洁实现

数据集生成

1
2
3
4
5
6
7
8
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)

和上面的一致捏

读取数据集

  • 我们可以调用框架中现有的API来读取数据。 我们将featureslabels作为API的参数传递,并通过数据迭代器指定batch_size。 此外,布尔值is_train表示是否希望数据迭代器对象在每个迭代周期内打乱数据。
1
2
3
4
5
6
7
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)

定义模型

  • 对于标准深度学习模型,我们可以使用框架的预定义好的层。这使我们只需关注使用哪些层来构造模型,而不必关注层的实现细节。 我们首先定义一个模型变量net,它是一个Sequential类的实例。 Sequential类将多个层串联在一起。 当给定输入数据时,Sequential实例将数据传入到第一层, 然后将第一层的输出作为第二层的输入,以此类推。 在下面的例子中,我们的模型只包含一个层,因此实际上不需要Sequential。 但是由于以后几乎所有的模型都是多层的,在这里使用Sequential

示例图

../_images/singleneuron.svg

1
2
3
4
# nn是神经网络的缩写
from torch import nn

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

在PyTorch中,全连接层在Linear类中定义。 值得注意的是,我们将两个参数传递到nn.Linear中。 第一个指定输入特征形状,即2,第二个指定输出特征形状,输出特征形状为单个标量,因此为1。

初始化模型参数

  • 在使用net之前,我们需要初始化模型参数。 如在线性回归模型中的权重和偏置。 深度学习框架通常有预定义的方法来初始化参数。 在这里,我们指定每个权重参数应该从均值为0、标准差为0.01的正态分布中随机采样, 偏置参数将初始化为零。
1
2
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)

正如我们在构造nn.Linear时指定输入和输出尺寸一样, 现在我们能直接访问参数以设定它们的初始值。 我们通过net[0]选择网络中的第一个图层, 然后使用weight.databias.data方法访问参数。 我们还可以使用替换方法normal_fill_来重写参数值。

定义损失函数

  • 计算均方误差使用的是MSELoss类,也称为平方$L_2$范数。 默认情况下,它返回所有样本损失的平均值。
1
loss = nn.MSELoss()

定义优化算法

  • 小批量随机梯度下降算法是一种优化神经网络的标准工具, PyTorch在optim模块中实现了该算法的许多变种。 当我们实例化一个SGD实例时,我们要指定优化的参数 (可通过net.parameters()从我们的模型中获得)以及优化算法所需的超参数字典。 小批量随机梯度下降只需要设置lr值,这里设置为0.03。
1
trainer = torch.optim.SGD(net.parameters(), lr=0.03)

训练

通过深度学习框架的高级API来实现我们的模型只需要相对较少的代码。 我们不必单独分配参数、不必定义我们的损失函数,也不必手动实现小批量随机梯度下降。 当我们需要更复杂的模型时,高级API的优势将大大增加。 当我们有了所有的基本组件,训练过程代码与我们从零开始实现时所做的非常相似。

回顾一下:在每个迭代周期里,我们将完整遍历一次数据集(train_data), 不停地从中获取一个小批量的输入和相应的标签。 对于每一个小批量,我们会进行以下步骤:

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

为了更好的衡量训练效果,我们计算每个迭代周期后的损失,并打印它来监控训练过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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}')

# result
epoch 1, loss 0.000183
epoch 2, loss 0.000101
epoch 3, loss 0.000101
  • 下面我们比较生成数据集的真实参数和通过有限数据训练获得的模型参数。 要访问参数,我们首先从net访问所需的层,然后读取该层的权重和偏置。 正如在从零开始实现中一样,我们估计得到的参数与生成数据的真实参数非常接近。
1
2
3
4
5
6
7
8
w = net[0].weight.data
print('w的估计误差:', true_w - w.reshape(true_w.shape))
b = net[0].bias.data
print('b的估计误差:', true_b - b)

# result
w的估计误差: tensor([-0.0003, -0.0002])
b的估计误差: tensor([8.1062e-06])

softmax回归

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

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

分类问题

  • 一般的分类问题并不与类别之间的自然顺序有关。 幸运的是,统计学家很早以前就发明了一种表示分类数据的简单方法:独热编码(one-hot encoding)。 独热编码是一个向量,它的分量和类别一样多。 类别对应的分量设置为1,其他所有分量设置为0。 在我们的例子中,标签$y$将是一个三维向量, 其中(1,0,0)对应于“猫”、(0,1,0)对应于“鸡”、(0,0,1)对应于“狗”:

$$
y \in {(1, 0, 0), (0, 1, 0), (0, 0, 1)}.
$$

网络架构

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

$$
\begin{split}\begin{aligned}
o_1 &= x_1 w_{11} + x_2 w_{12} + x_3 w_{13} + x_4 w_{14} + b_1,\
o_2 &= x_1 w_{21} + x_2 w_{22} + x_3 w_{23} + x_4 w_{24} + b_2,\
o_3 &= x_1 w_{31} + x_2 w_{32} + x_3 w_{33} + x_4 w_{34} + b_3.
\end{aligned}\end{split}
$$

../_images/softmaxreg.svg

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

全连接层的参数开销

  • 在深度学习中,全连接层无处不在。 然而,顾名思义,全连接层是“完全”连接的,可能有很多可学习的参数。 具体来说,对于任何具有$d$个输入和$q$个输出的全连接层, 参数开销为$\mathcal{O}(dq)$,这个数字在实践中可能高得令人望而却步。 幸运的是,将$d$个输入转换为$q$个输出的成本可以减少到$\mathcal{O}(\frac{dq}{n})$, 其中超参数$n$可以由我们灵活指定,以在实际应用中平衡参数节约和模型有效性。

softmax运算

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

  • 我们不能将未规范化的预测$o$直接视作我们感兴趣的输出,将线性层的输出直接视为概率时存在一些问题: 一方面,我们没有限制这些输出数字的总和为1。 另一方面,根据输入的不同,它们可以为负值。
  • 要将输出视为概率,我们必须保证在任何数据上的输出都是非负的且总和为1。 此外,我们需要一个训练的目标函数,来激励模型精准地估计概率。 例如, 在分类器输出0.5的所有样本中,我们希望这些样本是刚好有一半实际上属于预测的类别。 这个属性叫做校准(calibration)。
  • softmax函数能够将未规范化的预测变换为非负数并且总和为1,同时让模型保持可导的性质。 为了完成这一目标,我们首先对每个未规范化的预测求幂,这样可以确保输出非负。 为了确保最终输出的概率值总和为1,我们再让每个求幂后的结果除以它们的总和。

$$
\hat{\mathbf{y}} = \mathrm{softmax}(\mathbf{o})\quad \text{其中}\quad \hat{y}_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)}
$$

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

$$
\operatorname*{argmax}_j \hat y_j = \operatorname*{argmax}_j o_j.
$$

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

小批量样本的矢量化

  • 为了提高计算效率并且充分利用GPU,我们通常会对小批量样本的数据执行矢量计算。 假设我们读取了一个批量的样本$\mathbf{X}$, 其中特征维度(输入数量)为$d$,批量大小为$n$。 此外,假设我们在输出中有$q$个类别。 那么小批量样本的特征为$\mathbf{X} \in \mathbb{R}^{n \times d}$, 权重为$\mathbf{W} \in \mathbb{R}^{d \times q}$, 偏置为$\mathbf{b} \in \mathbb{R}^{1\times q}$。 softmax回归的矢量计算表达式为:

$$
\begin{split}\begin{aligned} \mathbf{O} &= \mathbf{X} \mathbf{W} + \mathbf{b}, \ \hat{\mathbf{Y}} & = \mathrm{softmax}(\mathbf{O}). \end{aligned}\end{split}
$$

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

损失函数

接下来,我们需要一个损失函数来度量预测的效果,我们将使用最大似然估计。

对数似然

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

$$
P(\mathbf{Y} \mid \mathbf{X}) = \prod_{i=1}^n P(\mathbf{y}^{(i)} \mid \mathbf{x}^{(i)}).
$$

  • 根据最大似然估计,我们最大化$P(\mathbf{Y} \mid \mathbf{X})$,相当于最小化负对数似然:

$$
-\log P(\mathbf{Y} \mid \mathbf{X}) = \sum_{i=1}^n -\log P(\mathbf{y}^{(i)} \mid \mathbf{x}^{(i)})
= \sum_{i=1}^n l(\mathbf{y}^{(i)}, \hat{\mathbf{y}}^{(i)}),
$$

  • 其中,对于任何标签$\mathbf{y}$和模型预测$\hat{\mathbf{y}}$,损失函数为:

$$
l(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_{j=1}^q y_j \log \hat{y}_j.
$$

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

softmax及其导数

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

$$
\begin{split}\begin{aligned}
l(\mathbf{y}, \hat{\mathbf{y}}) &= - \sum_{j=1}^q y_j \log \frac{\exp(o_j)}{\sum_{k=1}^q \exp(o_k)} \
&= \sum_{j=1}^q y_j \log \sum_{k=1}^q \exp(o_k) - \sum_{j=1}^q y_j o_j\
&= \log \sum_{k=1}^q \exp(o_k) - \sum_{j=1}^q y_j o_j.
\end{aligned}\end{split}
$$

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

$$
\partial_{o_j} l(\mathbf{y}, \hat{\mathbf{y}}) = \frac{\exp(o_j)}{\sum_{k=1}^q \exp(o_k)} - y_j = \mathrm{softmax}(\mathbf{o})_j - y_j.
$$

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

交叉熵损失

  • 现在让我们考虑整个结果分布的情况,即观察到的不仅仅是一个结果。 对于标签$y$,我们可以使用与以前相同的表示形式。 唯一的区别是,我们现在用一个概率向量表示,如(0.1,0.2,0.7), 而不是仅包含二元项的向量(0,0,1)。 我们使用对数似然损失函数来定义损失$l$, 它是所有标签分布的预期损失值。 此损失称为交叉熵损失(cross-entropy loss),它是分类问题最常用的损失之一。

信息论基础

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

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

$$
H[P] = \sum_j - P(j) \log P(j).
$$

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

信息量

  • 我们有一个要压缩的数据流。 如果我们很容易预测下一个数据,那么这个数据就很容易压缩。
  • 如果我们不能完全预测每一个事件,那么我们有时可能会感到”惊异”。 克劳德·香农决定用信息量$\log \frac{1}{P(j)} = -\log P(j)$来量化这种惊异程度。 在观察一个事件$j$时,并赋予它(主观)概率$P(j)$。 当我们赋予一个事件较低的概率时,我们的惊异会更大,该事件的信息量也就更大。
  • 定义的熵, 是当分配的概率真正匹配数据生成过程时的信息量的期望

重新审视交叉熵

  • 如果把熵$H(P)$想象为“知道真实概率的人所经历的惊异程度”,那么什么是交叉熵? 交叉熵$P$$Q$,记为$H(P, Q)$。 我们可以把交叉熵想象为“主观概率为$Q$的观察者在看到根据概率$P$生成的数据时的预期惊异”。 当$P=Q$时,交叉熵达到最低。 在这种情况下,从$P$到$Q$的交叉熵是$H(P, P)= H(P)$。
  • 简而言之,我们可以从两方面来考虑交叉熵分类目标
    • 最大化观测数据的似然
    • 最小化传达标签所需的惊异

模型预测和评估

  • 在训练softmax回归模型后,给出任何样本特征,我们可以预测每个输出类别的概率。 通常我们使用预测概率最高的类别作为输出类别。 如果预测与实际类别(标签)一致,则预测是正确的。 在接下来的实验中,我们将使用精度(accuracy)来评估模型的性能。 精度等于正确预测数与预测总数之间的比率。

图像分类数据集

  • 我们将使用类似但更复杂的Fashion-MNIST数据集,进行图像分类的训练。

  • 依赖安装:

1
2
3
4
5
6
7
8
%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()

读取数据集

  • 我们可以通过框架中的内置函数将Fashion-MNIST数据集下载并读取到内存中
1
2
3
4
5
6
7
# 通过ToTensor实例将图像数据从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)中的6000张图像 和测试数据集(test dataset)中的1000张图像组成。 因此,训练集和测试集分别包含60000和10000张图像。 测试数据集不会用于训练,只用于评估模型性能。

1
2
3
4
len(mnist_train), len(mnist_test)

# result
(60000, 10000)

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

1
2
3
4
mnist_train[0][0].shape

# result
torch.Size([1, 28, 28])
  • 在数字标签索引及其文本名称之间进行转换
1
2
3
4
5
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]
  • 我们现在可以创建一个函数来可视化这些样本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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())
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])
return axes
  • 可视化一把:
1
2
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));

image-20230728173637025

读取小批量

  • 为了使我们在读取训练集和测试集时更容易,我们使用内置的数据迭代器,而不是从零开始创建。 回顾一下,在每次迭代中,数据加载器每次都会读取一小批量数据,大小为batch_size。 通过内置数据迭代器,我们可以随机打乱了所有样本,从而无偏见地读取小批量。
1
2
3
4
5
6
7
8
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())

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

1
2
3
4
5
6
7
timer = d2l.Timer()
for X, y in train_iter:
continue
f'{timer.stop():.2f} sec'

# result
'2.18 sec'

整合所有组件

  • 现在我们定义load_data_fashion_mnist函数,用于获取和读取Fashion-MNIST数据集。 这个函数返回训练集和验证集的数据迭代器。 此外,这个函数还接受一个可选参数resize,用来将图像大小调整为另一种形状。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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函数的图像大小调整功能。
1
2
3
4
5
6
7
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

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

softmax回归的从零开始实现

1
2
3
4
5
6
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)

初始化模型参数

  • 原始数据集中的每个样本都是28×28的图像。 本节将展平每个图像,把它们看作长度为784的向量。在后面的章节中,我们将讨论能够利用图像空间结构的特征, 但现在我们暂时只把每个像素位置看作一个特征。
1
2
3
4
5
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)

在softmax回归中,我们的输出与类别一样多。 因为我们的数据集有10个类别,所以网络输出维度为10。 因此,权重将构成一个784×10的矩阵, 偏置将构成一个1×10的行向量。 与线性回归一样,我们将使用正态分布初始化我们的权重W,偏置初始化为0。

定义softmax操作

  • 回想一下,实现softmax由三个步骤组成:
    1. 对每个项求幂(使用exp)。
    2. 对每一行求和(小批量中每个样本是一行),得到每个样本的规范化常数。
    3. 将每一行除以其规范化常数,确保结果的和为1。
  • 在查看代码之前,我们回顾一下这个表达式:

$$
\mathrm{softmax}(\mathbf{X}){ij} = \frac{\exp(\mathbf{X}{ij})}{\sum_k \exp(\mathbf{X}_{ik})}.
$$

1
2
3
4
def softmax(X):
X_exp = torch.exp(X)
partition = X_exp.sum(1, keepdim=True)
return X_exp / partition # 这里应用了广播机制
1
2
3
4
5
6
7
8
X = torch.normal(0, 1, (2, 5))
X_prob = softmax(X)
X_prob, X_prob.sum(1)

# result
(tensor([[0.2968, 0.4115, 0.0945, 0.1603, 0.0368],
[0.2128, 0.5422, 0.0865, 0.1104, 0.0481]]),
tensor([1.0000, 1.0000]))

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

定义模型

  • 定义softmax操作后,我们可以实现softmax回归模型。 下面的代码定义了输入如何通过网络映射到输出。 注意,将数据传递到模型之前,我们使用reshape函数将每张原始图像展平为向量。
1
2
def net(X):
return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)

本质上这就是$softmax(\mathbf{Xw} + b)$ -> 说白了就是往输出外面套了一层softmax函数而已。

定义损失函数

  • 我们实现中引入的交叉熵损失函数。 这可能是深度学习中最常见的损失函数,因为目前分类问题的数量远远超过回归问题的数量。
1
2
3
4
5
6
7
8
9
10
11
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]

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

cross_entropy(y_hat, y)

# result
tensor([2.3026, 0.6931])

分类精度

  • 给定预测概率分布y_hat,当我们必须输出硬预测(hard prediction)时, 我们通常选择预测概率最高的类。当预测与标签分类y一致时,即是正确的。 分类精度即正确预测数量与总预测数量之比。 虽然直接优化精度可能很困难(因为精度的计算不可导), 但精度通常是我们最关心的性能衡量标准,我们在训练分类器时几乎总会关注它。
  • 为了计算精度,我们执行以下操作。 首先,如果y_hat是矩阵,那么假定第二个维度存储每个类的预测分数。 我们使用argmax获得每行中最大元素的索引来获得预测类别。 然后我们将预测类别与真实y元素进行比较。 由于等式运算符“==”对数据类型很敏感, 因此我们将y_hat的数据类型转换为与y的数据类型一致。 结果是一个包含0(错)和1(对)的张量。 最后,我们求和会得到正确预测的数量。
1
2
3
4
5
6
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_haty分别作为预测的概率分布和标签。 可以看到,第一个样本的预测类别是2(该行的最大元素为0.6,索引为2),这与实际标签0不一致。 第二个样本的预测类别是2(该行的最大元素为0.5,索引为2),这与实际标签2一致。 因此,这两个样本的分类精度率为0.5。
1
2
3
4
accuracy(y_hat, y) / len(y)

# result
0.5
  • 对于任意数据迭代器data_iter可访问的数据集, 我们可以评估在任意模型net的精度。
1
2
3
4
5
6
7
8
9
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个变量, 分别用于存储正确预测的数量和预测的总数量。 当我们遍历数据集时,两者都将随着时间的推移而累加。
1
2
3
4
5
6
7
8
9
10
11
12
13
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。
1
2
3
4
evaluate_accuracy(net, test_iter)

# result
0.0598

训练

  • 我们重构训练过程的实现以使其可重复使用。 首先,我们定义一个函数来训练一个迭代周期。 请注意,updater是更新模型参数的常用函数,它接受批量大小作为参数。 它可以是d2l.sgd函数,也可以是框架的内置优化函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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):
# 使用PyTorch内置的优化器和损失函数
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, 它能够简化本书其余部分的代码。
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
31
32
33
34
35
36
37
38
39
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类来可视化训练进度。
1
2
3
4
5
6
7
8
9
10
11
12
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
  • 我们使用中小批量随机梯度下降来优化模型的损失函数,设置学习率为0.1。
1
2
3
4
lr = 0.1

def updater(batch_size):
return d2l.sgd([W, b], lr, batch_size)
  • 现在,我们训练模型10个迭代周期。 请注意,迭代周期(num_epochs)和学习率(lr)都是可调节的超参数。 通过更改它们的值,我们可以提高模型的分类精度。
1
2
num_epochs = 10
train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, updater)

../_images/output_softmax-regression-scratch_a48321_222_0.svg

预测

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

1
2
3
4
5
6
7
8
9
10
11
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)

softmax回归的简洁实现

  • 数据集引入:
1
2
3
4
5
6
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)

初始化模型参数

1
2
3
4
5
6
7
8
9
# 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);
  • 首先,通过nn.Sequential定义了一个神经网络模型net。它按照给定的顺序将一系列层(layers)组织成一个网络。在这里,nn.Sequential包含了两个层:nn.Flatten()nn.Linear(784, 10)
    • nn.Flatten():这是一个用于展平操作的层,它没有可训练的参数。在深度学习中,通常要将输入数据从多维的形状(例如图像的形状是[batch_size, channels, height, width])展平为一维的形状([batch_size, flattened_size]),以便能够连接到后续的全连接层。
    • nn.Linear(784, 10):这是一个全连接层,它接收784维的输入(即展平后的图像数据),并输出10维的结果(即预测的类别数)。在这里,784是输入特征的维度,而10是输出类别的维度。
  • 其次,参数初始化。定义了一个init_weights函数用于初始化模型的参数。这里采用了一种常见的参数初始化方法:对于线性层(nn.Linear),使用正态分布(均值为0,标准差为0.01)来初始化权重参数。
    • 最后一行代码net.apply(init_weights)init_weights函数应用到net模型的所有线性层上。这将导致模型的所有线性层(在这里只有一个)的权重被初始化为正态分布的随机值(均值为0,标准差为0.01)。这个初始化过程是为了确保模型在训练开始时具有一些随机性,从而避免所有参数的初始状态相同,使得训练更具有多样性和稳定性。

重新审视Softmax的实现

参考:https://en.wikipedia.org/wiki/LogSumExp

  • softmax函数$\hat y_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)}$,如果$o_k$非常大,可能导致$\exp(o_k)$大于数据类型容许的最大数字,即上溢(overflow)。 这将使分母或分子变为inf(无穷大), 最后得到的是0、infnan(不是数字)的$\hat y_j$。我们无法得到一个明确定义的交叉熵值。
  • 解决这个问题的一个技巧是: 在继续softmax计算之前,先从所有$o_k$中减去$\max(o_k)$。 这里可以看到每个$o_k$按常数进行的移动不会改变softmax的返回值:

$$
\begin{split}\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}\end{split}
$$

  • 减法和规范化步骤之后,可能有些$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⁡(⋅))被抵消了。

$$
\begin{split}\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}\end{split}
$$

  • 我们也希望保留传统的softmax函数,以备我们需要评估通过模型输出的概率。 但是,我们没有将softmax概率传递到损失函数中, 而是在交叉熵损失函数中传递未规范化的预测,并同时计算softmax及其对数。
1
loss = nn.CrossEntropyLoss(reduction='none')

优化算法

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

训练

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

代码解释

synthetic_data

1
2
3
4
5
6
7
8
9
10
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)
  • 输入为:weight, bias和数量,输出为:X矩阵和y列向量。
  1. 函数首先使用 torch.normal 函数生成一个维度为 (num_examples, len(w)) 的张量 X,其中每个元素都是从均值为 0、标准差为 1 的正态分布中随机采样得到的。 -> $X_{numExamples\ *\ len(w)}$
  2. 通过矩阵乘法 torch.matmul(X, w) 和加法 + b,生成一个表示标签 y 的张量。这里使用了真实的权重向量 w 和偏置值 b,并将噪声添加到 y 中。$X_{numExamples\ \ len(w)}\ \ w_{len(w)\ \ 1} + b$ -> $y_{numExamples\ \ 1}$
  3. 通过 torch.normal 函数生成一个维度与 y 相同的张量,并将其加到 y 中,以模拟噪声 -> 现实世界中噪声是正常的,我们手动产生一个y.shape相同类型的噪声,并加到y矩阵上。
  4. 返回$X_{numExamples\ *\ len(w)}$,和y.reshape((-1, 1)),当调用 y.reshape((-1, 1)) 时,函数会自动计算第一维的长度,以使得 y 的形状变为一个列向量,即第一维的长度为 1,而第二维的长度自适应。
  • 函数的返回值为特征和标签,也就是我们真实的X和Y,我们使用X和Y来训练模型。

data_iter

1
2
3
4
5
6
7
8
9
def data_iter(batch_size, features, labels):
num_examples = len(features)
indices = list(range(num_examples))
# 这些样本是随机读取的,没有特定的顺序
random.shuffle(indices)
for i in range(0, num_examples, batch_size):
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)])
yield features[batch_indices], labels[batch_indices]
  • 输入为:batch_size(批次大小)、features(特征张量)和 labels(标签张量)。输出为:随机取样的特征和标签向量。
  1. 函数首先获取样本数量 num_examples,然后创建一个包含从 0num_examples-1 的索引列表 indices
  2. 通过 random.shuffle 函数对索引列表进行随机打乱,以实现随机读取样本。
  3. 通过 range 函数以步长为 batch_size 进行迭代,每次迭代生成一个批次的数据。
  4. 通过 torch.tensor 函数将当前批次的索引列表切片为一个张量 batch_indices,该张量包含当前批次的索引。
  5. 使用 yield 关键字返回当前批次的特征张量 features[batch_indices] 和标签张量 labels[batch_indices]
  • 通过使用迭代器函数 data_iter,可以方便地按指定的批次大小随机读取数据集的特征和标签。在训练模型时,可以使用该迭代器循环读取数据集的不同批次,以进行模型的训练和优化。

linreg

1
2
3
def linreg(X, w, b):  #@save
"""线性回归模型"""
return torch.matmul(X, w) + b

该函数实现了线性回归模型,其中X是特征张量,w是权重张量,b是偏置张量。函数通过torch.matmul计算特征与权重的矩阵乘法,并将结果与偏置相加,得到线性回归的预测结果。

squared_loss

1
2
3
def squared_loss(y_hat, y):  #@save
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

该函数实现了均方损失(平方损失),其中y_hat是模型的预测结果,y是实际标签。函数通过计算(y_hat - y.reshape(y_hat.shape))的平方,再除以2,得到均方损失。

sgd

1
2
3
4
5
6
def sgd(params, lr, batch_size):  #@save
"""小批量随机梯度下降"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()

该函数实现了小批量随机梯度下降(SGD)算法,用于更新模型参数。其中params是一个包含模型参数的列表,lr是学习率,batch_size是批次大小。

训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
for X, y in data_iter(batch_size, features, labels):
l = loss(net(X, w, b), y) # X和y的小批量损失
# 因为l形状是(batch_size,1),而不是一个标量。l中的所有元素被加到一起,
# 并以此计算关于[w,b]的梯度
l.sum().backward()
sgd([w, b], lr, batch_size) # 使用参数的梯度更新参数
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')
  1. lr = 0.03: lr 是学习率 (learning rate),它是控制梯度下降步长的超参数,用于更新模型参数时的调整幅度。
  2. num_epochs = 3: num_epochs 表示迭代次数,即整个数据集将被训练3次。
  3. net = linreg: net 是一个线性回归模型,这里使用了linreg函数表示。
  4. loss = squared_loss: loss 是损失函数,这里使用了均方误差损失 (squared loss)。
  5. for epoch in range(num_epochs):: 开始迭代训练,共进行3次迭代。
  6. for X, y in data_iter(batch_size, features, labels):: data_iter 是数据迭代器,每次从featureslabels中获取一个大小为batch_size的小批量数据。
  7. l = loss(net(X, w, b), y): 计算当前小批量数据的预测值并计算与真实标签y之间的损失。
  8. l.sum().backward(): 将损失 l 中的所有元素加和得到一个标量值,然后计算该标量值关于模型参数wb的梯度。
  9. sgd([w, b], lr, batch_size): 使用随机梯度下降 (SGD) 算法,根据梯度和学习率,更新模型参数wb
  10. with torch.no_grad():: 这是一个不计算梯度的上下文管理器,用于更新参数时不需要计算梯度。
  11. train_l = loss(net(features, w, b), labels): 在整个训练集上计算当前模型的损失,用于输出当前模型的训练效果。
  12. print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}'): 输出当前迭代的训练结果,包括迭代次数和平均损失值。

l.sum

在上面的代码中,l 是当前小批量数据的损失,计算得到的是一个张量,而不是一个标量。在使用梯度下降法(或其他优化算法)进行参数更新时,通常需要一个标量损失来计算参数的梯度,并对参数进行更新。所以,为了得到一个标量损失,代码中使用了 l.sum() 来将所有损失元素相加,得到一个标量值,然后再通过该标量值来计算关于参数的梯度。

让我们更详细地解释为什么需要将损失元素相加并得到一个标量值来计算梯度:

  1. Backpropagation and Gradients: 在深度学习中,我们使用反向传播算法来计算模型参数的梯度。反向传播需要一个标量损失值作为起点,然后通过计算该标量相对于每个模型参数的偏导数(梯度)来进行参数更新。
  2. Batched Computations: 通常,在深度学习中,我们会使用小批量(batch)数据进行训练,而不是单个样本。这样做有两个原因:首先,小批量训练可以加快训练过程;其次,小批量计算有利于并行计算,提高训练效率。
  3. Loss Computation for Batches: 当我们使用小批量数据进行训练时,损失函数的计算也会产生小批量的损失值。这些损失值组成一个张量,而不是一个标量。
  4. Summing Loss Elements: 为了将小批量的损失值转换为一个标量,一种常见的方法是对损失值中的元素进行求和。在代码中,l.sum() 就是将 l 这个张量中的所有元素相加得到一个标量值,从而成为反向传播的起点。

所以,l.sum().backward() 的目的是计算 l 张量中所有元素的梯度,将其作为损失函数关于模型参数的梯度,并用于后续的参数更新。这种技巧允许我们在小批量数据上训练,并将损失计算转换为标量,以便进行反向传播。

torch.no_grad

在PyTorch中,torch.no_grad()是一个上下文管理器,也称为上下文环境或上下文块。它的作用是告诉PyTorch在该上下文块内不需要计算梯度,从而在此期间避免不必要的梯度计算,节省内存和计算资源。通常,torch.no_grad()用于执行一些不需要梯度的操作,比如在模型训练过程中的参数更新阶段或进行模型推断时。

详细解释如下:

  1. Gradient Tracking in PyTorch: 在PyTorch中,默认情况下,所有的张量都会自动跟踪计算梯度信息。这个特性称为自动微分(Autograd),它是PyTorch进行梯度计算和反向传播的基础。当你对一个张量进行操作(比如加减乘除、求和等),PyTorch会构建一个计算图,用于计算该操作的梯度。这样在反向传播时,PyTorch可以根据计算图自动计算出梯度,从而进行参数的更新。
  2. Disabling Gradient Calculation with torch.no_grad(): 有些情况下,我们并不希望进行梯度计算,比如在参数更新阶段,我们只需要根据梯度更新参数,而不需要计算额外的梯度信息。这时,可以使用torch.no_grad()上下文管理器来暂时关闭梯度计算。在这个上下文块内,所有的操作不会被跟踪,也不会构建计算图,从而节省了梯度计算所需的内存和计算资源。
  3. Benefits of Using torch.no_grad():
    • Memory Savings: 由于不计算梯度,所以不需要保存梯度相关的中间结果,从而减少了内存的使用量。
    • Speedup: 不进行梯度计算可以加快计算速度,特别是在推断(inference)阶段,当不需要反向传播时,使用torch.no_grad()可以显著加速代码执行。
    • Preventing Unintended Gradient Updates: 有时候在模型推断阶段,如果不使用torch.no_grad(),可能会不小心修改了模型参数,而这些修改是不希望的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch

# 创建一个需要计算梯度的张量
x = torch.tensor([2.0], requires_grad=True)

# 在计算图上计算 x^2
y = x ** 2

# 使用torch.no_grad()进行推断,不需要梯度
with torch.no_grad():
# 在这个上下文块内,任何操作都不会被跟踪,也不会构建计算图
z = y * 3

print(z) # tensor([12.])

在上面的示例中,在torch.no_grad()上下文块内,y * 3操作不会被跟踪,因此不会计算梯度。这对于推断阶段或者仅需要参数更新的情况非常有用。

load_data_fashion_mnist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()))
  • 它接受两个参数:batch_size(用于指定每个批次中图像的数量)和resize(用于指定是否调整图像的尺寸)。
  1. trans = [transforms.ToTensor()]: 这里定义了一个转换列表trans,其中包含一个transforms.ToTensor()操作。transforms.ToTensor()用于将PIL图像或NumPy数组转换为PyTorch张量。
  2. trans.insert(0, transforms.Resize(resize)): 如果传入了resize值,则在转换列表trans的开头插入一个transforms.Resize(resize)操作。transforms.Resize()用于调整图像的尺寸。
  3. trans = transforms.Compose(trans): 这一行将转换列表trans中的操作组合成一个transforms.Compose对象。transforms.Compose用于将多个图像转换操作串联起来,依次应用于图像数据。
  4. mnist_train = torchvision.datasets.FashionMNIST(root="../data", train=True, transform=trans, download=True): 这一行加载训练集。torchvision.datasets.FashionMNIST用于下载和加载Fashion-MNIST数据集。root参数指定数据集下载的目录,train=True表示加载训练集,transform=trans表示对加载的图像应用前面定义的转换操作,download=True表示在数据目录中下载数据集(如果数据集尚未下载)。
  5. mnist_test = torchvision.datasets.FashionMNIST(root="../data", train=False, transform=trans, download=True): 这一行加载测试集,与上一行类似,只是train=False表示加载测试集。
  6. 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())): 最后,函数返回两个DataLoader对象,一个用于训练集,另一个用于测试集。DataLoader用于将数据集划分为小批次,并提供多线程数据加载的功能。num_workers=get_dataloader_workers()指定了在数据加载过程中使用的工作线程数(使用get_dataloader_workers()函数来获取适当的工作线程数,该函数可能是其他代码中定义的)。

Softmax

1
2
3
4
def softmax(X):
X_exp = torch.exp(X)
partition = X_exp.sum(1, keepdim=True)
return X_exp / partition # 这里应用了广播机制
  1. torch.exp(X)计算输入向量X中每个元素的指数值。这是为了确保每个元素都是非负的。
  2. X_exp.sum(1, keepdim=True)X_exp中的每一行求和,sum(1)表示沿着第1维(即行)进行求和。keepdim=True表示保持结果的维度,使得返回的结果仍然是一个列向量(如果输入是一个二维矩阵的话)。
  3. X_exp中的每个元素除以对应行的和(partition),得到一个概率分布。这样做确保了概率分布中的每个元素都在0到1的范围内,并且它们的和为1。由于PyTorch中的广播机制,每个元素会自动与同一行中的其他元素进行除法。
  • 总结起来,这个函数将输入向量X中的每个元素转换为非负值,并确保它们的和为1,从而得到一个概率分布,这在很多分类问题中非常有用。

accuracy

1
2
3
4
5
6
7
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 是模型的预测输出,它是一个二维张量(或数组),其中每一行代表一个样本的预测结果,每一列对应一个类别的预测概率。y 是真实标签,是一个一维张量(或数组),其中每个元素对应一个样本的真实类别标签。
  1. if len(y_hat.shape) > 1 and y_hat.shape[1] > 1: 这一行判断了 y_hat 是否为一个多类别预测的结果。如果是多类别预测,即 y_hat 是一个二维数组(或张量),并且每一行代表一个样本的预测概率分布(每个类别的概率),则通过 argmax(axis=1) 取出每行中最大值所在的列索引,得到每个样本的预测类别(即取概率最大的类别作为预测结果)。
  2. cmp = y_hat.type(y.dtype) == y 这一行对预测结果 y_hat 与真实标签 y 进行逐元素比较,生成一个布尔值张量(或数组),其中元素为 True 表示预测正确,False 表示预测错误。
  3. return float(cmp.type(y.dtype).sum()) 最后,计算预测正确的数量。将布尔值张量 cmp 转换为与 y 相同的数据类型(可能是整数或浮点数),然后对这个张量中为 True 的元素(即预测正确的情况)求和,得到预测正确的样本数量,并将其转换为浮点数类型返回作为准确率。
  • 总结起来,这个函数用于计算模型在分类任务中的准确率,通过将预测结果与真实标签逐元素比较来统计预测正确的样本数量,并返回准确率(预测正确样本数量除以总样本数量)。

cross_entropy

1
2
def cross_entropy(y_hat, y):
return - torch.log(y_hat[range(len(y_hat)), y])
  • y_hat 是模型的预测输出,它是一个二维张量(或数组),其中每一行代表一个样本的预测结果,每一列对应一个类别的预测概率。y 是真实标签,是一个一维张量(或数组),其中每个元素对应一个样本的真实类别标签。最后,返回计算得到的交叉熵损失值。在实际应用中,通常会对所有样本的损失值求平均,以得到整体的平均交叉熵损失。
  1. range(len(y_hat)) 创建了一个表示从 0 到 len(y_hat)-1 的整数列表,它的长度等于 y_hat 的行数,也就是样本的数量。

  2. y_hat[range(len(y_hat)), y] 这一部分使用了切片操作,其中 range(len(y_hat)) 用于选择 y_hat 中的每一行,而 y 则用于选择对应行中的元素(类别标签)。这样,我们获取了模型对每个样本预测的类别的概率值。

  3. torch.log(…) 计算了取对数的负值,这是交叉熵损失函数的核心部分。交叉熵损失度量了预测概率分布与真实标签之间的差异,对于每个样本,它将预测类别对应的概率取对数,并求取其负值。

  • 总结起来,这个函数用于计算模型在分类任务中的交叉熵损失。它通过将模型预测的类别概率取对数并求取其负值来衡量预测结果与真实标签之间的差异。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch

# 假设模型的预测概率为:
y_hat = torch.tensor([[0.1, 0.2, 0.7],
[0.8, 0.1, 0.1],
[0.3, 0.4, 0.3],
[0.5, 0.2, 0.3]])

# 假设真实标签为:
y = torch.tensor([2, 0, 1, 2])

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

# 计算交叉熵损失
loss = cross_entropy(y_hat, y)

print(loss)

结果为:tensor([0.3567, 2.3026, 0.9163, 1.2039])

  • 本质上y_hat[xIdx, yIdx]是对于y_hat中元素的定位:

    • 对于第一个样本,y[0] 的值是2,所以我们选择 y_hat[0, 2],即第0行第2列的预测概率值,它是0.7。
    • 对于第二个样本,y[1] 的值是0,所以我们选择 y_hat[1, 0],即第1行第0列的预测概率值,它是0.8。
    • 对于第三个样本,y[2] 的值是1,所以我们选择 y_hat[2, 1],即第2行第1列的预测概率值,它是0.4。
    • 对于第四个样本,y[3] 的值是2,所以我们选择 y_hat[3, 2],即第3行第2列的预测概率值,它是0.3。
  • 结果:

    • 对于第一个样本,真实标签是2,模型预测这个类别的概率是0.7,交叉熵损失为 -log(0.7) ≈ 0.3567。

    • 对于第二个样本,真实标签是0,模型预测这个类别的概率是0.8,交叉熵损失为 -log(0.8) ≈ 2.3026。

    • 对于第三个样本,真实标签是1,模型预测这个类别的概率是0.4,交叉熵损失为 -log(0.4) ≈ 0.9163。

    • 对于第四个样本,真实标签是2,模型预测这个类别的概率是0.3,交叉熵损失为 -log(0.3) ≈ 1.2039。

说白了就是正确位置对应的预测概率,取个-log罢了

evaluate_accuracy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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]


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类: 这是一个简单的累加器类,用于在n个变量上进行累加。它有三个主要方法:

    • __init__(self, n): 构造函数,初始化累加器对象,接收一个整数n作为参数,并创建一个长度为n的列表self.data,并初始化所有元素为0.0。

    • add(self, *args): 用于将输入的参数与累加器中的元素逐元素相加。它接收可变数量的参数(*args),这些参数与累加器的每个元素一一对应。注意,这里使用zip()函数将输入的参数与self.data中的元素一一配对。

    • reset(self): 将累加器中的所有元素重置为0.0。

    • __getitem__(self, idx): 这是一个特殊方法,使得累加器对象可以通过索引直接获取对应位置的元素值。

  • evaluate_accuracy(net, data_iter)函数: 这个函数用于计算在指定数据集上模型的精度。它接收两个参数:net是一个PyTorch的模型,data_iter是一个数据迭代器,用于遍历数据集中的样本。

    • 函数首先将模型设置为评估模式,即net.eval(),这是因为在评估阶段我们不需要进行梯度计算。
    • 然后,函数创建了一个Accumulator对象 metric,用于记录正确预测数和预测总数,这两个指标将用于计算模型的精度。
    • 接下来,使用torch.no_grad()上下文管理器来关闭梯度计算,避免不必要的计算和内存占用。然后,通过遍历data_iter中的每个样本,计算每个样本的预测准确率,并将结果累加到metric中。
    • accuracy(net(X), y):这里调用了之前提到的计算准确率的函数,net(X)对输入样本X进行预测,然后与真实标签y一起传递给accuracy函数,得到每个样本的预测准确率。y.numel():计算了当前样本的标签数量,用于累加到metric中的预测总数。
  • 最后,返回模型在数据集上的精度,即正确预测数除以预测总数。

Animator

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
31
32
33
34
35
36
37
38
39
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)
  • 参数
    • xlabel: 横轴(x轴)的标签。
    • ylabel: 纵轴(y轴)的标签。
    • legend: 图例,用于标识不同曲线的含义。
    • xlim: 横轴的范围(最小值和最大值),用于限定横轴显示的数据范围。
    • ylim: 纵轴的范围(最小值和最大值),用于限定纵轴显示的数据范围。
    • xscale: 横轴的缩放方式,可选值为 'linear'(线性缩放)或 'log'(对数缩放)。
    • yscale: 纵轴的缩放方式,可选值为 'linear'(线性缩放)或 'log'(对数缩放)。
    • fmts: 一个包含不同线条样式的列表,用于绘制多条曲线时使用。
    • nrows: 子图的行数。
    • ncols: 子图的列数。
    • figsize: 图表的大小,用元组表示,比如 (width, height)

train_epoch_ch3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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):
# 使用PyTorch内置的优化器和损失函数
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]
  1. net.train():首先将模型 net 设置为训练模式。这是因为有一些模型(如Dropout)在训练和评估时有不同的行为,设置为训练模式会启用这些特定的行为。
  2. metric = Accumulator(3):创建一个 Accumulator 对象 metric 用于记录训练损失总和、训练准确度总和以及样本数。
  3. for X, y in train_iter::遍历训练数据集的迭代器 train_iter,其中 X 是输入特征,y 是对应的真实标签。
  4. y_hat = net(X):通过模型 net 对输入特征 X 进行预测,得到预测结果 y_hat
  5. l = loss(y_hat, y):计算预测结果 y_hat 和真实标签 y 之间的损失,使用提供的损失函数 loss
  6. if isinstance(updater, torch.optim.Optimizer)::这个条件判断是为了处理不同的优化器。如果 updater 是PyTorch内置的优化器(如SGD、Adam等),则使用PyTorch的优化器进行梯度计算和参数更新。
    • updater.zero_grad(): 清空之前的梯度信息。
    • l.mean().backward(): 计算损失 l 的平均值,并进行反向传播计算梯度。
    • updater.step(): 使用优化器根据计算得到的梯度更新模型的参数。
  7. else::如果 updater 不是PyTorch内置的优化器,则使用定制的优化器进行梯度计算和参数更新。在这种情况下,假设 updater 是一个自定义的函数,它接收一个批次的大小 X.shape[0] 作为参数,用于更新模型的参数。
  8. metric.add(float(l.sum()), accuracy(y_hat, y), y.numel()):将本次迭代的损失、准确度和样本数量添加到 metric 中。
  9. return metric[0] / metric[2], metric[1] / metric[2]:返回训练周期内的平均损失和平均准确度,用于评估模型在一个迭代周期内的训练性能。
  • train_epoch_ch3 函数用于在一个迭代周期内训练模型。它遍历训练数据集的迭代器,计算损失、梯度,并根据提供的优化器更新模型的参数。在训练过程中,还会记录损失和准确度的累积和,最后返回一个迭代周期内的平均损失和平均准确度。

train_ch3

1
2
3
4
5
6
7
8
9
10
11
12
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
  1. animator = Animator(...): 这行代码创建一个 Animator 对象,用于可视化并绘制训练损失、训练准确度和测试准确度等指标的图表。Animator 对象被初始化为使用提供的配置参数绘制图表。
  2. for epoch in range(num_epochs):: 这个循环在 num_epochs 指定的迭代周期内运行,表示训练过程中的迭代次数。
  3. train_metrics = train_epoch_ch3(net, train_iter, loss, updater): 这行代码调用 train_epoch_ch3 函数(前面已解释)在训练数据迭代器 train_iter 上训练模型一个迭代周期,使用损失函数 loss 和更新器函数 updater。该函数返回当前迭代周期内的平均训练损失和训练准确度,这些指标被存储在 train_metrics 中。
  4. test_acc = evaluate_accuracy(net, test_iter): 在训练一个迭代周期后,这行代码调用 evaluate_accuracy 函数(前面已解释)来评估模型在测试数据迭代器 test_iter 上的准确度。计算得到的测试准确度被存储在 test_acc 中。
  5. animator.add(epoch + 1, train_metrics + (test_acc,)): 这行代码将当前迭代周期的训练指标和测试准确度添加到 Animator 对象 animator 中。它用于可视化并更新图表中的数据。
  6. train_loss, train_acc = train_metrics: 在所有迭代周期完成训练后,最终的训练损失和训练准确度分别被存储在 train_losstrain_acc 中。
  7. assert train_loss < 0.5, train_loss: 这行代码是一个断言语句,用于检查最终的训练损失是否小于 0.5。如果条件不满足,将会抛出 AssertionError,表示训练过程可能没有按预期进行。
  8. assert train_acc <= 1 and train_acc > 0.7, train_acc: 这行代码是一个断言语句,用于检查最终的训练准确度是否在 0.7 到 1 之间。如果条件不满足,将会抛出 AssertionError
  9. assert test_acc <= 1 and test_acc > 0.7, test_acc: 这行代码是一个断言语句,用于检

References:

  1. https://zh.d2l.ai/chapter_linear-networks/linear-regression.html
  2. https://zhuanlan.zhihu.com/p/95814925
  3. https://www.youtube.com/watch?v=s7BxboxEfnU
  4. 数学分布
  5. https://zh-v1.d2l.ai/chapter_deep-learning-basics/softmax-regression.html
  6. https://www.zhihu.com/tardis/zm/art/35709485?source_id=1003
  7. https://en.wikipedia.org/wiki/LogSumExp

D2L-3-Linear Neural Networks
https://alexanderliu-creator.github.io/2023/07/25/d2l-3-linear-neural-networks/
作者
Alexander Liu
发布于
2023年7月25日
许可协议