前言
- 深度学习已经彻底改变了模式识别,引入了一系列技术,包括计算机视觉、自然语言处理、自动语音识别。
- 要成功地应用深度学习,必须知道(1)如何抛出一个问题(2)建模的数学方法(3)将模型与数据拟合的算法(4)以及实现所有这些的工程技术。
- 这本书提供了一个全面的资源,包括文本、图表、数学和代码,都集中在一个地方。
- 要回答与本书相关的问题,请访问我们的论坛discuss.d2l.ai
- 所有Jupyter记事本都可以在GitHub上下载。
安装
看参考文档即可
符号
后面章节遇到看不懂的符号反过来看书即可
第一章 引言
1.1 机器学习中的关键组件
-
可以用来学习的数据(data);
-
如何转换数据的模型(model);
-
一个目标函数(objective function),用来量化模型的有效性;
-
调整模型参数以优化目标函数的算法(algorithm)。
1.1.1 数据
- 每个数据集由一个个样本(example, sample)组成,大多时候,它们遵循独立同分布(independently and identically distributed)。
- 样本有时也叫做数据点(data point)或者数据实例(data instance)
- 通常每个样本由一组称为特征(features,或协变量(covariates))的属性组成。 机器学习模型会根据这些属性进行预测,要预测的是一个特殊的属性,它被称为标签(label,或目标(target))。换句话说,每个样本可以看做一行,这一行由多个列组成,这些列就是特征属性,然后根据这些数据做预测得出一个结果,称为标签。
- 当每个样本的特征类别数量都是相同的时候,其特征向量是固定长度的,这个长度被称为数据的维数(dimensionality)。 固定长度的特征向量是一个方便的属性,它可以用来量化学习大量样本。
- 与传统机器学习方法相比,深度学习的一个主要优势是可以处理不同长度的数据。
1.1.2 模型
- 大多数机器学习会涉及到数据的转换。
- 深度学习与经典方法的区别主要在于:前者关注的功能强大的模型,这些模型由神经网络错综复杂的交织在一起,包含层层数据转换,因此被称为深度学习(deep learning)。
1.1.3 目标函数
- 定义模型的优劣程度的度量,这个度量在大多数情况是“可优化”的,这被称之为目标函数(objective function)。
- 通常定义一个目标函数,并希望优化它到最低点。 因为越低越好,所以这些函数有时被称为损失函数(loss function,或cost function)。 但这只是一个惯例,我们也可以取一个新的函数,优化到它的最高点。 这两个函数本质上是相同的,只是翻转一下符号。
- 当任务在试图预测数值时,最常见的损失函数是平方误差(squared error),即预测值与实际值之差的平方。 当试图解决分类问题时,最常见的目标函数是最小化错误率,即预测与实际情况不符的样本比例。
- 通常,损失函数是根据模型参数定义的,并取决于数据集。 在一个数据集上,我们可以通过最小化总损失来学习模型参数的最佳值。 该数据集由一些为训练而收集的样本组成,称为训练数据集(training dataset,或称为训练集(training set))。 然而,在训练数据上表现良好的模型,并不一定在“新数据集”上有同样的性能,这里的“新数据集”通常称为测试数据集(test dataset,或称为测试集(test set))。
- 当一个模型在训练集上表现良好,但不能推广到测试集时,这个模型被称为过拟合(overfitting)的。
1.1.4 优化算法
- 当我们获得了一些数据源及其表示、一个模型和一个合适的损失函数,接下来就需要一种算法,它能够搜索出最佳参数,以最小化损失函数。
- 深度学习中,大多流行的优化算法通常基于一种基本方法–梯度下降(gradient descent)。 简而言之,在每个步骤中,梯度下降法都会检查每个参数,看看如果仅对该参数进行少量变动,训练集损失会朝哪个方向移动。 然后,它在可以减少损失的方向上优化参数。
1.2 各种机器学习问题
1.2.1 监督学习
- 监督学习(supervised learning)擅长在“给定输入特征”的情况下预测标签。 每个“特征-标签”对都称为一个样本(example)。
- 我们的目标是生成一个模型,能够将任何输入特征映射到标签(即预测)。
- 监督学习的学习过程一般可以分为三大步骤:
- 从已知大量数据样本中随机选取一个子集,为每个样本获取真实标签。有时,这些样本已有标签(例如,患者是否在下一年内康复?);有时,这些样本可能需要被人工标记(例如,图像分类)。这些输入和相应的标签一起构成了训练数据集;
- 选择有监督的学习算法,它将训练数据集作为输入,并输出一个“已完成学习的模型”;
- 将之前没有见过的样本特征放到这个“已完成学习的模型”中,使用模型的输出作为相应标签的预测。
1.2.1.1 回归
- 回归(regression)是最简单的监督学习任务之一。当标签取任意数值时,我们称之为回归问题,此时的目标是生成一个模型,使它的预测非常接近实际标签值。
- 损失函数是平方误差。
1.2.1.2 分类
- “哪一个”的问题叫做分类(classification)问题。 分类问题希望模型能够预测样本属于哪个类别(category,正式称为类(class))。
- 最简单的分类问题是只有两类,这被称之为二项分类(binomial classification)。当有两个以上的类别时,我们把这个问题称为多项分类(multiclass classification)问题。
- 分类问题的常见损失函数被称为交叉熵(cross-entropy)
1.2.1.3 标记问题
- 学习预测不相互排斥的类别的问题称为多标签分类(multi-label classification)。
1.2.1.4 搜索
1.2.1.5 推荐系统
向特定用户进行“个性化”推荐。
1.2.1.6 序列学习
- 如果输入是连续的,模型可能就需要拥有“记忆”功能。
- 序列学习需要摄取输入序列或预测输出序列,或两者兼而有之。
- 标记和解析:这涉及到用属性注释文本序列。 换句话说,输入和输出的数量基本上是相同的。
- 自动语音识别:在语音识别中,输入序列是说话人的录音,输出序列是说话人所说内容的文本记录。 它的挑战在于,与文本相比,音频帧多得多(声音通常以8kHz或16kHz采样)。 也就是说,音频和文本之间没有1:1的对应关系,因为数千个样本可能对应于一个单独的单词。 这也是“序列到序列”的学习问题,其中输出比输入短得多。
- 文本到语音:这与自动语音识别相反。 换句话说,输入是文本,输出是音频文件。 在这种情况下,输出比输入长得多。
- 机器翻译:在语音识别中,输入和输出的出现顺序基本相同。 而在机器翻译中,颠倒输入和输出的顺序非常重要。
1.2.2 无监督学习
- 工作没有十分具体的目标,就需要“自发”地去学习了。 比如,老板可能会给我们一大堆数据,然后要求用它做一些数据科学研究,却没有对结果有要求。
- 数据中不含有“目标”的机器学习问题通常被为无监督学习(unsupervised learning)
- 无监督学习回答以下的问题:
- 聚类(clustering)问题:没有标签的情况下,我们是否能给数据分类呢?比如,给定一组照片,我们能把它们分成风景照片、狗、婴儿、猫和山峰的照片吗?同样,给定一组用户的网页浏览记录,我们能否将具有相似行为的用户聚类呢?
- 主成分分析(principal component analysis)问题:我们能否找到少量的参数来准确地捕捉数据的线性相关属性?比如,一个球的运动轨迹可以用球的速度、直径和质量来描述。再比如,裁缝们已经开发出了一小部分参数,这些参数相当准确地描述了人体的形状,以适应衣服的需要。另一个例子:在欧几里得空间中是否存在一种(任意结构的)对象的表示,使其符号属性能够很好地匹配?这可以用来描述实体及其关系,例如“罗马” − “意大利” + “法国” = “巴黎”。
- 因果关系(causality)和概率图模型(probabilistic graphical models)问题:我们能否描述观察到的许多数据的根本原因?例如,如果我们有关于房价、污染、犯罪、地理位置、教育和工资的人口统计数据,我们能否简单地根据经验数据发现它们之间的关系?
- 生成对抗性网络(generative adversarial networks):为我们提供一种合成数据的方法,甚至像图像和音频这样复杂的非结构化数据。潜在的统计机制是检查真实和虚假数据是否相同的测试,它是无监督学习的另一个重要而令人兴奋的领域。
1.2.3 与环境互动
- 到目前为止,不管是监督学习还是无监督学习,我们都会预先获取大量数据,然后启动模型,不再与环境交互。 这里所有学习都是在算法与环境断开后进行的,被称为离线学习(offline learning)。
1.2.4 强化学习
- 机器学习开发与环境交互并采取行动。包括应用到机器人、对话系统,甚至开发视频游戏的人工智能(AI)。
- 深度强化学习(deep reinforcement learning)将深度学习应用于强化学习的问题,是非常热门的研究领域。 突破性的深度Q网络(Q-network)在雅达利游戏中仅使用视觉输入就击败了人类, 以及 AlphaGo 程序在棋盘游戏围棋中击败了世界冠军,是两个突出强化学习的例子。
- 在强化学习问题中,智能体(agent)在一系列的时间步骤上与环境交互。 在每个特定时间点,智能体从环境接收一些观察(observation),并且必须选择一个动作(action),然后通过某种机制(有时称为执行器)将其传输回环境,最后智能体从环境中获得奖励(reward)。
1.3 其他
- 包括起源、深度学习的发展、深度学习的特点、深度学习的成功案例等,自己去看书
1.4 小结
- 机器学习研究计算机系统如何利用经验(通常是数据)来提高特定任务的性能。它结合了统计学、数据挖掘和优化的思想。通常,它是被用作实现人工智能解决方案的一种手段。
- 表示学习作为机器学习的一类,其研究的重点是如何自动找到合适的数据表示方式。深度学习是通过学习多层次的转换来进行的多层次的表示学习。
- 深度学习不仅取代了传统机器学习的浅层模型,而且取代了劳动密集型的特征工程。
- 最近在深度学习方面取得的许多进展,大都是由廉价传感器和互联网规模应用所产生的大量数据,以及(通过GPU)算力的突破来触发的。
- 整个系统优化是获得高性能的关键环节。有效的深度学习框架的开源使得这一点的设计和实现变得非常容易。
第二章 预备知识
2.1 数据操作
2.1.1 入门
张量:即 n 维数组。具有一个轴的张量(一维数组)对应数学上的向量,具有两个轴的张量(二维数组)对应数学上的矩阵。具有两个轴以上的张量没有特殊的数学名称。
1、arange() 函数:创建一个行向量 x。这个行向量包含以0开始的前12个整数,它们默认创建为整数。也可指定创建类型为浮点数。张量中的每个值都称为张量的 元素(element)。
import torch
x = torch.arange(12)
print(x)
输出:
tensor([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
2、shape属性:访问张量(沿每个轴的长度)的形状 。
x.shape
输出:
torch.Size([12])
3、numel() 函数:张量中元素的总数,即形状的所有元素乘积,可以检查它的大小(size)。
x.numel()
输出:
12
4、reshape()函数:改变一个张量的形状而不改变元素数量和元素值。例如,可以把张量x
从形状为(12,)的行向量转换为形状为(3,4)的矩阵。 这个新的张量包含与转换前相同的值,但是它被看成一个3行4列的矩阵。 注意:reshape函数不会改变原来的张量,而是创建一个新的张量,所以需要一个新的变量来指向它
X = x.reshape(3, 4)
print(X)
输出:
tensor([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
我们不需要通过手动指定每个维度来改变形状。 也就是说,如果我们的目标形状是(高度,宽度), 那么在知道宽度后,高度会被自动计算得出,不必我们自己做除法。 在上面的例子中,为了获得一个3行的矩阵,我们手动指定了它有3行和4列。 幸运的是,我们可以通过-1
来调用此自动计算出维度的功能。 即我们可以用x.reshape(-1,4)
或x.reshape(3,-1)
来取代x.reshape(3,4)
。
有时,我们希望使用全0、全1、其他常量,或者从特定分布中随机采样的数字来初始化矩阵。 我们可以创建一个形状为(2,3,4)的张量,其中所有元素都设置为0。代码如下:
torch.zeros((2, 3, 4)) # 创建2个三行四列的张量,初始值为0
输出:
tensor([[[0., 0., 0., 0.],
[0., 0., 0., 0.],
[0., 0., 0., 0.]],
[[0., 0., 0., 0.],
[0., 0., 0., 0.],
[0., 0., 0., 0.]]])
同样,我们可以创建一个形状为(2,3,4)
的张量,其中所有元素都设置为1。代码如下:
torch.ones((2, 3, 4))
输出:
tensor([[[1., 1., 1., 1.],
[1., 1., 1., 1.],
[1., 1., 1., 1.]],
[[1., 1., 1., 1.],
[1., 1., 1., 1.],
[1., 1., 1., 1.]]])
有时我们想通过从某个特定的概率分布中随机采样来得到张量中每个元素的值。 例如,当我们构造数组来作为神经网络中的参数时,我们通常会随机初始化参数的值。 以下代码创建一个形状为(3,4)的张量。 其中的每个元素都从均值为0、标准差为1的标准高斯分布(正态分布)中随机采样。
torch.randn(3, 4)
输出:
tensor([[-0.0135, 0.0665, 0.0912, 0.3212],
[ 1.4653, 0.1843, -1.6995, -0.3036],
[ 1.7646, 1.0450, 0.2457, -0.7732]])
我们还可以通过提供包含数值的Python列表(或嵌套列表),来为所需张量中的每个元素赋予确定值。 在这里,最外层的列表对应于轴0,内层的列表对应于轴1。
torch.tensor([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
输出:
tensor([[2, 1, 4, 3],
[1, 2, 3, 4],
[4, 3, 2, 1]])
2.1.2 运算符
常见的标准运算符:+
、-
、*
、/
和**
,** 是求幂运算,例如 x ** y 指的是 x 的 y 次幂,即:x^y
x = torch.tensor([1.0, 2, 4, 8])
y = torch.tensor([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x ** y # **运算符是求幂运算
输出:
(tensor([ 3., 4., 6., 10.]),
tensor([-1., 0., 2., 6.]),
tensor([ 2., 4., 8., 16.]),
tensor([0.5000, 1.0000, 2.0000, 4.0000]),
tensor([ 1., 4., 16., 64.]))
1、exp()函数:指数函数
torch.exp(x)
输出:
tensor([2.7183e+00, 7.3891e+00, 5.4598e+01, 2.9810e+03])
2、cat()函数:张量连结,两张张量的规模必须相同!以下两个张量连结,第一个输出张量的轴-0长度(6)是两个输入张量轴-0长度的总和(3+3); 第二个输出张量的轴-1长度(8)是两个输入张量轴-1长度的总和(4+4)。
X = torch.arange(12, dtype=torch.float32).reshape((3,4))
Y = torch.tensor([[2.0, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
torch.cat((X, Y), dim=0), torch.cat((X, Y), dim=1)
输出:
(tensor([[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[ 2., 1., 4., 3.],
[ 1., 2., 3., 4.],
[ 4., 3., 2., 1.]]),
tensor([[ 0., 1., 2., 3., 2., 1., 4., 3.],
[ 4., 5., 6., 7., 1., 2., 3., 4.],
[ 8., 9., 10., 11., 4., 3., 2., 1.]]))
3、逻辑运算符构建张量:以 == 为例,X==Y
X == Y
输出:
tensor([[False, True, False, True],
[False, False, False, False],
[False, False, False, False]])
4、sum()函数:求和,返回结果是一个只含有一个元素的张量
X.sum()
输出:
tensor(66.)
2.1.3 广播机制
在某些情况下,即使形状不同,我们仍然可以通过调用 广播机制(broadcasting mechanism)来执行按元素操作。 这种机制的工作方式如下:
- 通过适当复制元素来扩展一个或两个数组,以便在转换之后,两个张量具有相同的形状;
- 对生成的数组执行按元素操作。
在大多数情况下,我们将沿着数组中长度为1的轴进行广播,如下例子:
a = torch.arange(3).reshape((3, 1))
b = torch.arange(2).reshape((1, 2))
a, b
输出:
(tensor([[0],
[1],
[2]]),
tensor([[0, 1]]))
由于a
和b
分别是3×1和1×2矩阵,如果让它们相加,它们的形状不匹配。 我们将两个矩阵广播为一个更大的3×2矩阵,如下所示:矩阵a
将复制列, 矩阵b
将复制行,然后再按元素相加。
a + b
输出:
tensor([[0, 1],
[1, 2],
[2, 3]])
2.1.4 索引和切片
与任何Python数组一样:张量中的元素可以通过索引访问,第一个元素的索引是0,最后一个元素索引是-1; 可以指定范围以包含第一个元素和最后一个之前的元素(区间是左闭右开)。
如下所示,我们可以用[-1]
选择最后一个元素,可以用[1:3]
选择第二个和第三个元素:
X[-1], X[1:3]
输出:
(tensor([ 8., 9., 10., 11.]),
tensor([[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.]]))
除读取外,我们还可以通过指定索引来将元素写入矩阵。
X[1, 2] = 9
X
输出:
tensor([[ 0., 1., 2., 3.],
[ 4., 5., 9., 7.],
[ 8., 9., 10., 11.]])
如果我们想为多个元素赋值相同的值,我们只需要索引所有元素,然后为它们赋值。 例如,[0:2, :]
访问第1行和第2行,其中“:”代表沿轴1(列)的所有元素。 虽然我们讨论的是矩阵的索引,但这也适用于向量和超过2个维度的张量。
X[0:2, :] = 12
X
输出:
tensor([[12., 12., 12., 12.],
[12., 12., 12., 12.],
[ 8., 9., 10., 11.]])
X[r:c, x:y]: 行区间为 [r, c),列区间为 [x,y),如果某一个参数不写那就一直到张量的边界,例如X[0:, 2:] 表示从第 0 行到最后一行,第 2 列到最后一列。如果想改变第 k 列的值,那么为:X[0:, k - 1:k]
2.1.5 节省内存
运行一些操作可能会导致为新结果分配内存。 例如,如果我们用Y = X + Y
,我们将取消引用Y
指向的张量,而是指向新分配的内存处的张量。这可能是不可取的,原因有两个:
- 首先,我们不想总是不必要地分配内存。在机器学习中,我们可能有数百兆的参数,并且在一秒内多次更新所有参数。通常情况下,我们希望原地执行这些更新;
- 如果我们不原地更新,其他引用仍然会指向旧的内存位置,这样我们的某些代码可能会无意中引用旧的参数。
幸运的是,执行原地操作非常简单。 我们可以使用切片表示法将操作的结果分配给先前分配的数组,例如Y[:] = <expression>
。 为了说明这一点,我们首先创建一个新的矩阵Z
,其形状与另一个Y
相同, 使用zeros_like
来分配一个全0的块。
Z = torch.zeros_like(Y)
print('id(Z):', id(Z))
Z[:] = X + Y
print('id(Z):', id(Z))
输出:
id(Z): 140327634811696
id(Z): 140327634811696
如果在后续计算中没有重复使用X
, 我们也可以使用X[:] = X + Y
或X += Y
来减少操作的内存开销,但是 X = X + Y
不行。
before = id(X)
X += Y
id(X) == before
输出:
True
2.1.6 转换为其他 Python 对象
将深度学习框架定义的张量转换为NumPy张量(ndarray
)很容易,反之也同样容易。 torch张量和numpy数组将共享它们的底层内存,就地操作更改一个张量也会同时更改另一个张量。
A = X.numpy()
B = torch.tensor(A)
type(A), type(B)
输出:
(numpy.ndarray, torch.Tensor)
要将大小为1的张量转换为Python标量,我们可以调用item
函数或Python的内置函数。
a = torch.tensor([3.5])
a, a.item(), float(a), int(a)
输出:
(tensor([3.5000]), 3.5, 3.5, 3)
2.2 数据预处理
2.2.1 读取数据集
举一个例子,我们首先创建一个人工数据集,并存储在CSV(逗号分隔值)文件 ../data/house_tiny.csv
中。 以其他格式存储的数据也可以通过类似的方式进行处理。 下面我们将数据集按行写入CSV文件中。
import os
os.makedirs(os.path.join('..', 'data'), exist_ok=True)
data_file = os.path.join('..', 'data', 'house_tiny.csv')
with open(data_file, 'w') as f:
f.write('NumRooms,Alley,Price\n') # 列名
f.write('NA,Pave,127500\n') # 每行表示一个数据样本
f.write('2,NA,106000\n')
f.write('4,NA,178100\n')
f.write('NA,NA,140000\n')
使用 pandas 包读取数据
# 如果没有安装pandas,只需取消对以下行的注释来安装pandas
# !pip install pandas
import pandas as pd
data = pd.read_csv(data_file)
print(data)
输出:
NumRooms Alley Price
0 NaN Pave 127500
1 2.0 NaN 106000
2 4.0 NaN 178100
3 NaN NaN 140000
2.2.2 处理缺失值
数据中的 NaN 代表缺失值,常见的有插值法和删除法。其中插值法用一个替代值弥补缺失值,而删除法则直接忽略缺失值。 我们先考虑插值法。
通过位置索引iloc
,我们将data
分成inputs
和outputs
, 其中前者为data
的前两列,而后者为data
的最后一列。 对于inputs
中缺少的数值,我们用同一列的均值替换“NaN”项。以下索引的解读上一节提到过。
inputs, outputs = data.iloc[:, 0:2], data.iloc[:, 2]
inputs = inputs.fillna(inputs.mean())
print(inputs)
输出:
NumRooms Alley
0 3.0 Pave
1 2.0 NaN
2 4.0 NaN
3 3.0 NaN
inputs.mean(): 求平均值,有多少个包含数值的列就会求出多少个平均值,这个情况只有一个平均值,但是如果inputs包含多个数值的列的话,就会产生对应数量的平均值!可以看下例:
import os
import pandas as pd
os.makedirs(os.path.join('..', 'data'), exist_ok=True)
data_file = os.path.join('..', 'data', 'house_tiny.csv')
with open(data_file, 'w') as f:
f.write('NumRooms,Alley,dis,Price\n') # 列名
f.write('NA,Pave,10,127500\n') # 每行表示一个数据样本
f.write('2,NA,NA,106000\n')
f.write('4,NA,14,178100\n')
f.write('NA,NA,NA,140000\n')
data = pd.read_csv(data_file)
print(data)
inputs, outputs = data.iloc[:, 0:3], data.iloc[:, 3:]
print(inputs.mean())
inputs = inputs.fillna(inputs.mean())
print(inputs)
输出:
NumRooms Alley dis Price
0 NaN Pave 10.0 127500
1 2.0 NaN NaN 106000
2 4.0 NaN 14.0 178100
3 NaN NaN NaN 140000
NumRooms 3.0 # 此时input包含了 NumRooms 和 dis 两个数值列,调用 mean() 函数可以看到求出了两个平均值
dis 12.0
dtype: float64
NumRooms Alley dis
0 3.0 Pave 10.0
1 2.0 NaN 12.0
2 4.0 NaN 14.0
3 3.0 NaN 12.0
对于inputs
中的类别值或离散值,我们将“NaN”视为一个类别。 由于“巷子类型”(“Alley”)列只接受两种类型的类别值“Pave”和“NaN”, pandas
可以自动将此列转换为两列“Alley_Pave”和“Alley_nan”。 巷子类型为“Pave”的行会将“Alley_Pave”的值设置为1,“Alley_nan”的值设置为0。 缺少巷子类型的行会将“Alley_Pave”和“Alley_nan”分别设置为0和1。
inputs = pd.get_dummies(inputs, dummy_na=True)
print(inputs)
输出:
NumRooms Alley_Pave Alley_nan
0 3.0 1 0
1 2.0 0 1
2 4.0 0 1
3 3.0 0 1
当然,此例中只有两种类型,如果有多个类型的话也会转换成多列,参考下例:
import os
import pandas as pd
os.makedirs(os.path.join('..', 'data'), exist_ok=True)
data_file = os.path.join('..', 'data', 'house_tiny.csv')
with open(data_file, 'w') as f:
f.write('NumRooms,Alley,dis,Price\n') # 列名
f.write('NA,Pave,10,127500\n') # 每行表示一个数据样本
f.write('2,Sleep,NA,106000\n')
f.write('4,NA,14,178100\n')
f.write('NA,NA,NA,140000\n')
data = pd.read_csv(data_file)
print(data)
inputs, outputs = data.iloc[:, 0:3], data.iloc[:, 3]
inputs = pd.get_dummies(inputs, dummy_na=True)
print(inputs)
输出:
NumRooms Alley dis Price
0 NaN Pave 10.0 127500
1 2.0 Sleep NaN 106000
2 4.0 NaN 14.0 178100
3 NaN NaN NaN 140000
NumRooms dis Alley_Pave Alley_Sleep Alley_nan # 可以看到分成了三列,对应列的数值也设置好了
0 NaN 10.0 1 0 0
1 2.0 NaN 0 1 0
2 4.0 14.0 0 0 1
3 NaN NaN 0 0 1
2.2.3 转换为张量格式
现在inputs
和outputs
中的所有条目都是数值类型,它们可以转换为张量格式。 当数据采用张量格式后,可以通过在 2.1
节中引入的那些张量函数来进一步操作。
import torch
X = torch.tensor(inputs.to_numpy(dtype=float))
y = torch.tensor(outputs.to_numpy(dtype=float))
X, y
输出:
tensor([[ 3., 10., 1., 0., 0.],
[ 2., 12., 0., 1., 0.],
[ 4., 14., 0., 0., 1.],
[ 3., 12., 0., 0., 1.]], dtype=torch.float64)
tensor([127500., 106000., 178100., 140000.], dtype=torch.float64)
2.2.4 修改pip安装包的路径
默认的pip安装包是在c:\users\lenovo\appdata\local\programs\python\python37\lib\site-packages
下,肯定是不可取的,想要改变这个路径,参考:https://www.jb51.net/python/318277n3s.htm,主要的一些要点如下
1、pip.ini内容(C:\Users\lenovo\AppData\Roaming\pip):
[global]
index-url = https://mirrors.aliyun.com/pypi/simple/
target=D:\python\dev\lib
2、site.py的内容:
ENABLE_USER_SITE = None 改为 ENABLE_USER_SITE = True
USER_SITE = None 改为 USER_SITE = r"D:\python\dev\lib"
USER_BASE = None 改为 USER_BASE = r"D:\python\dev\Scripts"
3、增加系统变量,变量名为 PYTHONPATH,变量值为D:\python\dev\lib
2.2.5 练习
1、创建包含更多行和列的原始数据集。
2、删除缺失值最多的列。
import pandas as pd
data = pd.read_excel("D:\d2l code\data\gzu-21级软工最终成绩排名9月5日.xls")
# print(data)
row, col = data.shape[0], data.shape[1]
# 计算每列的缺失值数量,返回值是一组映射,映射关系为:列名 - 缺失值数量
# data.isnull() 返回的是和原来数据规模一致的布尔矩阵,为空则为True,不为空则为 False
null_counts = data.isnull().sum()
# 找到缺失值的最大值
max_null_count = null_counts.max()
print(max_null_count)
# 找到含有最多缺失值的列,当这一列的缺失值数量和最大值相等时取出这一列的列名
# 当满足条件的列数有多个时,一同取出
columns_to_drop = null_counts[null_counts == max_null_count].index
print(columns_to_drop)
# 删除含有最多缺失值的列
# axis 参数:
# axis=0(默认值)表示操作的是行,即删除指定的行。
# axis=1 表示操作的是列,即删除指定的列。
# inplace 参数:
# 当 inplace=True 时,表示在原地修改 DataFrame 对象,即直接在原始的 DataFrame 上进行操作,不创建新的 DataFrame 对象。
# 当 inplace=False(默认值)时,表示不修改原始 DataFrame 对象,而是返回一个新的 DataFrame 对象,其中包含了删除指定列或行后的数据。
data.drop(columns_to_drop, axis=1, inplace=True)
#print(data)
3、将预处理后的数据集转换为张量格式。
import pandas as pd
import torch
from numpy.lib.tests.test_format import dtype
data = pd.read_excel("D:\d2l code\data\gzu-21级软工最终成绩排名9月5日.xls")
# print(data)
row, col = data.shape[0], data.shape[1]
# 计算每列的缺失值数量,返回值是一组映射,映射关系为:列名 - 缺失值数量
# data.isnull() 返回的是和原来数据规模一致的布尔矩阵,为空则为True,不为空则为 False
null_counts = data.isnull().sum()
# 找到缺失值的最大值
max_null_count = null_counts.max()
print(max_null_count)
# 找到含有最多缺失值的列,当这一列的缺失值数量和最大值相等时取出这一列的列名
# 当满足条件的列数有多个时,一同取出
columns_to_drop = null_counts[null_counts == max_null_count].index
print(columns_to_drop)
# 删除含有最多缺失值的列
# axis 参数:
# axis=0(默认值)表示操作的是行,即删除指定的行。
# axis=1 表示操作的是列,即删除指定的列。
# inplace 参数:
# 当 inplace=True 时,表示在原地修改 DataFrame 对象,即直接在原始的 DataFrame 上进行操作,不创建新的 DataFrame 对象。
# 当 inplace=False(默认值)时,表示不修改原始 DataFrame 对象,而是返回一个新的 DataFrame 对象,其中包含了删除指定列或行后的数据。
data.drop(columns_to_drop, axis=1, inplace=True)
print(data)
# 注意转换成张量之前要有这一步!
data = pd.get_dummies(data, dummy_na=True)
print(data)
X = torch.tensor(data.to_numpy(dtype=float))
print(X)
2.3 线性代数
2.3.1 标量
线性代数中对标量的定义为:只有一个元素的张量表示。
2.3.2 向量
标量值组成的列表。 这些标量值被称为向量的元素(element)或分量(component)。
我们可以使用下标来引用向量的任一元素,例如可以通过xi来引用第i个元素。xi是标量!
不特别声明的情况下,列向量是向量的默认方向。
向量的长度、维度和形状:
-
向量 x 由 n 个数字组成,记为
\[\mathbf{x}\in\mathbb{R}^n \]我们称此向量的长度和维度为 n,可以调用 len() 函数来得到一个向量的长度
-
形状是一个元素组,列出了张量沿每个轴的长度
2.3.3 矩阵
正如向量将标量从零阶推广到一阶,矩阵将向量从一阶推广到二阶。 矩阵,我们通常用粗体、大写字母来表示 (例如,X、Y和Z), 在代码中表示为具有两个轴的张量。不多说
当调用函数来实例化张量时, 我们可以通过指定两个分量m和n来创建一个形状为m×n的矩阵。
A = torch.arange(20).reshape(5, 4)
代码中访问矩阵的转置:
A.T
2.3.4 张量
就像向量是标量的推广,矩阵是向量的推广一样,我们可以构建具有更多轴的数据结构。 张量(本小节中的“张量”指代数对象)是描述具有任意数量轴的n维数组的通用方法。 例如,向量是一阶张量,矩阵是二阶张量。 张量用特殊字体的大写字母表示(例如,X、Y和Z), 它们的索引机制与矩阵类似。
X = torch.arange(24).reshape(2, 3, 4) # 两个三行四列的二维数组
2.3.5 张量算法的基本性质
标量、向量、矩阵和任意数量轴的张量(本小节中的“张量”指代数对象)有一些实用的属性。 例如,从按元素操作的定义中可以注意到,任何按元素的一元运算都不会改变其操作数的形状。 同样,给定具有相同形状的任意两个张量,任何按元素二元运算的结果都将是相同形状的张量。 例如,将两个相同形状的矩阵相加,会在这两个矩阵上执行元素加法。
A = torch.arange(20, dtype=torch.float32).reshape(5, 4)
B = A.clone() # 通过分配新内存,将A的一个副本分配给B
A, A + B
具体而言,两个矩阵的按元素乘法称为Hadamard积(Hadamard product)(数学符号⊙)。 对于矩阵B∈Rm×n, 其中第i行和第j列的元素是bij。 矩阵A和B的Hadamard积为:(注意Hadamard积和矩阵乘法的区别!)
A * B
将张量乘以或加上一个标量不会改变张量的形状,其中张量的每个元素都将与标量相加或相乘。
2.3.6 降维
我们可以对任意张量进行的一个有用的操作是计算其元素的和。 数学表示法使用∑符号表示求和。 为了表示长度为d的向量中元素的总和,可以记为
在代码中可以调用计算求和的函数:
x = torch.arange(4, dtype=torch.float32)
x, x.sum()
我们可以表示任意形状张量的元素和。 例如,矩阵A中元素的和可以记为
默认情况下,调用求和函数会沿所有的轴降低张量的维度,使它变为一个标量。 我们还可以指定张量沿哪一个轴来通过求和降低维度。 以矩阵为例,为了通过求和所有行的元素来降维(轴0),可以在调用函数时指定axis=0
。 由于输入矩阵沿0轴降维以生成输出向量,因此输入轴0的维数在输出形状中消失。
A_sum_axis0 = A.sum(axis=0)
A_sum_axis0, A_sum_axis0.shape
输出:
(tensor([40., 45., 50., 55.]), torch.Size([4]))
指定axis=1
将通过汇总所有列的元素降维(轴1)。因此,输入轴1的维数在输出形状中消失。
A_sum_axis1 = A.sum(axis=1)
A_sum_axis1, A_sum_axis1.shape
输出:
(tensor([ 6., 22., 38., 54., 70.]), torch.Size([5]))
沿着行和列对矩阵求和,等价于对矩阵的所有元素进行求和。
A.sum(axis=[0, 1]) # 结果和A.sum()相同
输出:
tensor(190.)
一个与求和相关的量是平均值(mean或average)。 我们通过将总和除以元素总数来计算平均值。 在代码中,我们可以调用函数来计算任意形状张量的平均值。
A.mean(), A.sum() / A.numel()
输出:
(tensor(9.5000), tensor(9.5000))
同样,计算平均值的函数也可以沿指定轴降低张量的维度。
A.mean(axis=0), A.sum(axis=0) / A.shape[0]
输出:
(tensor([ 8., 9., 10., 11.]), tensor([ 8., 9., 10., 11.]))
非降维求和
注意比较以下降维和非降维的代码的输出的区别,上面是非降维,下面是降维
A = torch.arange(20).reshape(5, 4)
sum_A = A.sum(axis=1, keepdims=True)
print(sum_A)
print(sum_A.shape)
输出:
tensor([[ 6],
[22],
[38],
[54],
[70]])
torch.Size([5, 1])
A_sum_axis1 = A.sum(axis=1)
print(A_sum_axis1)
print(A_sum_axis1.shape)
输出:
tensor([ 6, 22, 38, 54, 70])
torch.Size([5])
如果我们想沿某个轴计算A
元素的累积总和(前缀和), 比如axis=0
(按行计算),可以调用cumsum
函数。 此函数不会沿任何轴降低输入张量的维度。
A.cumsum(axis=0) # 按行求该行之前的元素的前缀和
输出:
tensor([[ 0., 1., 2., 3.],
[ 4., 6., 8., 10.],
[12., 15., 18., 21.],
[24., 28., 32., 36.],
[40., 45., 50., 55.]])
2.3.7 点积
给定两个向量
它们的点积(dot product)
或
是相同位置的按元素乘积的和:
可以调用 dot() 函数求点积:
y = torch.ones(4, dtype = torch.float32)
x, y, torch.dot(x, y)
输出:
(tensor([0., 1., 2., 3.]), tensor([1., 1., 1., 1.]), tensor(6.))
注意,我们可以通过执行按元素乘法,然后进行求和来表示两个向量的点积:
torch.sum(x * y)
点积在很多场合都很有用。 例如,给定一组由向量x∈Rd表示的值, 和一组由w∈Rd表示的权重。 x中的值根据权重w的加权和, 可以表示为点积
当权重为非负数且和为1时, 点积表示加权平均(weighted average)。
将两个向量规范化得到单位长度后,点积表示它们夹角的余弦。 本节后面的内容将正式介绍长度(length)的概念。
2.3.8 矩阵-向量积
矩阵A:
向量x长度为 m
则矩阵-向量积 Ax 是一个长度为 m 的列向量
在代码中使用张量表示矩阵-向量积,我们使用mv
函数。 当我们为矩阵A
和向量x
调用torch.mv(A, x)
时,会执行矩阵-向量积。 注意,A
的列维数(沿轴1的长度)必须与x
的维数(其长度)相同。
A.shape, x.shape, torch.mv(A, x)
输出:
(torch.Size([5, 4]), torch.Size([4]), tensor([ 14., 38., 62., 86., 110.]))
2.3.9 矩阵-矩阵乘法
不多说。看代码,矩阵乘法使用的是 mm() 函数
B = torch.ones(4, 3)
torch.mm(A, B)
输出:
tensor([[ 6., 6., 6.],
[22., 22., 22.],
[38., 38., 38.],
[54., 54., 54.],
[70., 70., 70.]])
2.3.10 范数
线性代数中最有用的一些运算符是范数(norm)。 非正式地说,向量的范数是表示一个向量有多大。 这里考虑的大小(size)概念不涉及维度,而是分量的大小。
在线性代数中,向量范数是将向量映射到标量的函数f。 给定任意向量x,向量范数要满足一些属性:
-
如果我们按常数因子α缩放向量的所有元素, 其范数也会按相同常数因子的绝对值缩放
\[f(\alpha \mathbf{x}) = |\alpha| f(\mathbf{x}). \] -
三角不等式
\[f(\mathbf{x} + \mathbf{y}) \leq f(\mathbf{x}) + f(\mathbf{y}). \] -
范数必须是非负的
- 范数最小为0,当且仅当向量全由0组成\[\forall i, [\mathbf{x}]_i = 0 \Leftrightarrow f(\mathbf{x})=0. \]
欧几里得距离也叫欧氏距离,本是上来讲就是一个 L2 范数,即向量元素平方和的平方根
代码求 L2 范数,norm() 函数:
u = torch.tensor([3.0, -4.0])
torch.norm(u) # 就是就是坐标系某个点到原点的距离
L1 范数:向量元素的绝对值之和
torch.abs(u).sum()
更一般的 Lp 范数
矩阵的 Frobenius范数:矩阵元素平方和的平方根
torch.norm(torch.ones((4, 9)))
范数和目标:在深度学习中,我们经常试图解决优化问题: 最大化分配给观测数据的概率; 最小化预测和真实观测之间的距离。 用向量表示物品(如单词、产品或新闻文章),以便最小化相似项目之间的距离,最大化不同项目之间的距离。 目标,或许是深度学习算法最重要的组成部分(除了数据),通常被表达为范数。
2.3.11 小结
- 标量、向量、矩阵和张量是线性代数中的基本数学对象。
- 向量泛化自标量,矩阵泛化自向量。
- 标量、向量、矩阵和张量分别具有零、一、二和任意数量的轴。
- 一个张量可以通过
sum
和mean
沿指定的轴降低维度。 - 两个矩阵的按元素乘法被称为他们的Hadamard积。它与矩阵乘法不同。
- 在深度学习中,我们经常使用范数,如L1范数、L2范数和Frobenius范数。
- 我们可以对标量、向量、矩阵和张量执行各种操作。
2.3.12 练习
-
证明一个矩阵A的转置的转置是A,即(A⊤)⊤=A。
-
给出两个矩阵A和B,证明“它们转置的和”等于“它们和的转置”,即A⊤+B⊤=(A+B)⊤。
-
给定任意方阵A,A+A⊤总是对称的吗?为什么?
-
本节中定义了形状(2,3,4)的张量
X
。len(X)
的输出结果是什么?X = torch.arange(24).reshape(2, 3, 4) print(len(X)) 输出: 2
-
对于任意形状的张量
X
,len(X)
是否总是对应于X
特定轴的长度?这个轴是什么?第一个轴
-
运行
A/A.sum(axis=1)
,看看会发生什么。请分析一下原因?
RuntimeError: The size of tensor a (4) must match the size of tensor b (5) at non-singleton dimension 1
很显然,因为维度不匹配。具体来说,A 的规模是 5 * 4,A.sum(axis = 1) 的规模是 1 * 5,像这种是无法用广播机制的,因为即使扩展 A.sum(axis = 1),最多也就是 5 * 5,那么 A 还少一列,但由于A 不止一列,扩展哪一列呢?所以无法使用广播机制!但是 A / A.sum(axis=0) 就可以,因为后者的规模是 1 * 4,直接可以扩展为 5 * 4!
- 考虑一个具有形状(2,3,4)的张量,在轴0、1、2上的求和输出是什么形状?
X = torch.arange(24).reshape(2, 3, 4)
print(X)
print(X.sum(axis=0))
print(X.sum(axis=1))
print(X.sum(axis=2))
输出:
tensor([[[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]],
[[12, 13, 14, 15],
[16, 17, 18, 19],
[20, 21, 22, 23]]])
tensor([[12, 14, 16, 18],
[20, 22, 24, 26],
[28, 30, 32, 34]]) # 对第 0 轴求和时,规模是 3 * 4,其中[i, j] = X[0 ,i ,j ] + X[1 ,i ,j]
tensor([[12, 15, 18, 21],
[48, 51, 54, 57]]) # 对第 1 轴求和时,规模是 2 * 4,其中[i, j] = X[i ,0 ,j] + X[i ,1 ,j] + X[i ,1 ,j]
tensor([[ 6, 22, 38],
[54, 70, 86]]) # 对第 2 轴求和时,规模是 2 * 3,其中[i, j] = X[i ,j ,0] + X[i ,j ,1] + X[i ,j ,2] +
X[i ,j ,2]
综上所述,如果一个张量的规模是 x1 * x2 * x3 * ... * xn,对第 k 维求和时结果的规模是 x1 * x2 * ... * x(k - 1) * x(k + 1) * ... * xn
- 为
linalg.norm
函数提供3个或更多轴的张量,并观察其输出。对于任意形状的张量这个函数计算得到什么?
Y = torch.arange(40, dtype=torch.float32).reshape(5, 2, 2, 2)
print(Y.norm())
输出:
tensor(143.3178) # 其实就是 L2 范数
2.4 微积分
2.4.1 导数和微分
在深度学习中,我们通常选择对于模型参数可微的损失函数。 简而言之,对于每个参数, 如果我们把这个参数增加或减少一个无穷小的量,可以知道损失会以多快的速度增加或减少。
以下表达式是等价的
常数相乘法则
加法法则
乘法法则
除法法则
2.4.2 偏导数
到目前为止,我们只讨论了仅含一个变量的函数的微分。 在深度学习中,函数通常依赖于许多变量。 因此,我们需要将微分的思想推广到多元函数(multivariate function)上。
设y=f(x1,x2,…,xn)是一个具有n个变量的函数。 y关于第i个参数xi的偏导数(partial derivative)为:
我们可以简单地将x1,…,xi−1,xi+1,…,xn看作常数, 并计算y关于xi的导数。 对于偏导数的表示,以下是等价的:
2.4.3 梯度
我们可以连结一个多元函数对其所有变量的偏导数,以得到该函数的梯度(gradient)向量。 注意,梯度是一个向量!
具体而言,设函数f:R^n→R的输入是 一个n维向量x=[x1,x2,…,xn]^T,并且输出是一个标量。 函数f(x)相对于x的梯度是一个包含n个偏导数的向量
其中∇xf(x)通常在没有歧义时被∇f(x)取代。
假设x为n维向量,在微分多元函数时经常使用以下规则:
2.4.4 链式法则
然而,上面方法可能很难找到梯度。 这是因为在深度学习中,多元函数通常是复合(composite)的, 所以难以应用上述任何规则来微分这些函数。 幸运的是,链式法则可以被用来微分复合函数。
让我们先考虑单变量函数。假设函数y=f(u)和u=g(x)都是可微的,根据链式法则
现在考虑一个更一般的场景,即函数具有任意数量的变量的情况。 假设可微分函数y有变量u1,u2,…,um,其中每个可微分函数ui都有变量x1,x2,…,xn。 注意,y是,x1,x2,…,xn的函数。 对于任意i=1,2,…,n,链式法则给出
2.4.5 小结
- 微分和积分是微积分的两个分支,前者可以应用于深度学习中的优化问题。
- 导数可以被解释为函数相对于其变量的瞬时变化率,它也是函数曲线的切线的斜率。
- 梯度是一个向量,其分量是多变量函数相对于其所有变量的偏导数。
- 链式法则可以用来微分复合函数。
2.4.6 练习
2.5 自动微分
2.5.1 例子
假设我们想对函数y=2x^⊤x关于列向量x求导。 首先,我们创建变量x
并为其分配一个初始值。
import torch
x = torch.arange(4.0)
在我们计算y关于x的梯度之前,需要一个地方来存储梯度。 重要的是,我们不会在每次对一个参数求导时都分配新的内存。 因为我们经常会成千上万次地更新相同的参数,每次都分配新的内存可能很快就会将内存耗尽。 注意,一个标量函数关于向量x的梯度是向量,并且与x具有相同的形状。
x.requires_grad_(True) # 等价于x=torch.arange(4.0,requires_grad=True)
x.grad # 默认值是None
代码解读:
- requires_grad:是张量的一个属性,表示该张量是否需要求梯度。默认值是 False,表示不需要求梯度。如果需要计算梯度,那么手动设置该值为 True。既然是属性,那么就作为函数参数的方法来传递,所以这个是作为 arange() 函数的一个参数!
- x.requires_grad_(True):是张量的一个方法,用于原地修改张量的 requires_grad 属性!作用和 requires_grad 相同。既然是方法,所以可以单独写出来,就像例子中一样。综上所述,
x.requires_grad_(True)
等价于x=torch.arange(4.0,requires_grad=True)
- x.grad:梯度向量,由于现在还没有求梯度,所以默认值是 None。
现在计算y。x
是一个长度为4的向量,计算x
和x
的点积,得到了我们赋值给y
的标量输出。
y = 2 * torch.dot(x, x)
print(y)
输出:
tensor(28., grad_fn=<MulBackward0>)
代码解读:为什么会有grad_fn=<MulBackward0>
?代表什么意思?
答:grad_fn=<MulBackward0>
表示计算图,根据设计好的模型构建而来,深度学习就是基于此计算图来自动计算出导数,即自动微分!相信在后面的学习中会对计算图更加深刻!只有将张量的梯度属性打开才能看到这个!
接下来,通过调用反向传播函数来自动计算y
关于x
每个分量的梯度,并打印这些梯度。
y.backward() # 调用这个反向传播函数就可得出梯度!
print(x.grad)
输出:
tensor([ 0., 4., 8., 12.]) # 此时计算出梯度了,所以 grad 就不再是 None 了
函数y=2x⊤x关于x的梯度应为4x。 让我们快速验证这个梯度是否计算正确。
x.grad == 4 * x
输出:
tensor([True, True, True, True])
现在计算另一个函数的梯度:
# 在默认情况下,PyTorch会累积梯度,我们需要清除之前的值
x.grad.zero_() # 清除之前的值
y = x.sum()
y.backward()
x.grad
2.5.2 非标量变量的反向传播
当y
不是标量时,向量y
关于向量x
的导数的最自然解释是一个矩阵。 对于高阶和高维的y
和x
,求导的结果可以是一个高阶张量。
然而,虽然这些更奇特的对象确实出现在高级机器学习中(包括深度学习中), 但当调用向量的反向计算时,我们通常会试图计算一批训练样本中每个组成部分的损失函数的导数。 这里,我们的目的不是计算微分矩阵,而是单独计算批量中每个样本的偏导数之和。
# 对非标量调用backward需要传入一个gradient参数,该参数指定微分函数关于self的梯度。
# 本例只想求偏导数的和,所以传递一个1的梯度是合适的
x.grad.zero_()
y = x * x
# 等价于y.backward(torch.ones(len(x)))
y.sum().backward()
x.grad
输出:
tensor([0., 2., 4., 6.])
代码解读:为什么是 y.sum().backward()
?不能直接 y.backward()
吗?
答:不能,因为会报错:grad can be implicitly created only for scalar outputs。意思是:只能为标量输出隐式创建 grad,换言之,backward() 函数只能为标量创建 grad。x = [x1,x2,x3,x4],y = x*x = [x12,x22,x32,x42],此时 y 是一个向量!所以调用 sum() 函数将 y 变成标量之后才行。我们也可以看到,此时 y 关于 x 的梯度就是 2x,将数据带入就是 [0,2,4,6]!
之前的 y = 2 * torch.dot(x, x)
得到的就是一个标量输出!
2.5.3 分离计算
有时,我们希望将某些计算移动到记录的计算图之外。 例如,假设y
是作为x
的函数计算的,而z
则是作为y
和x
的函数计算的。 想象一下,我们想计算z
关于x
的梯度,但由于某种原因,希望将y
视为一个常数, 并且只考虑到x
在y
被计算后发挥的作用。
这里可以分离y
来返回一个新变量u
,该变量与y
具有相同的值, 但丢弃计算图中如何计算y
的任何信息。 换句话说,梯度不会向后流经u
到x
。 因此,下面的反向传播函数计算z=u*x
关于x
的偏导数,同时将u
作为常数处理, 而不是z=x*x*x
关于x
的偏导数。
x.grad.zero_()
y = x * x
u = y.detach()
print(u)
z = u * x
z.sum().backward()
print(x.grad)
输出:
tensor([0., 1., 4., 9.])
tensor([0., 1., 4., 9.])
代码解读:注意体会题干中的:梯度不会向后流经u
到x。这句话在代码层面的解释就是 u = y.detach()
。那么此时的情况就是:z = ux,那么 z 的梯度就是 u!而 u 的值是 y!仅此而已!
如果说把 u = y.detach()
换成 u = y
,那么 z 的梯度就是 3x^2,带入数据就是 [ 0., 3., 12., 27.] !因为这时候梯度会从 u 流过 x,本质上就是 z = x * x * x!
由于记录了y
的计算结果,我们可以随后在y
上调用反向传播, 得到y=x*x
关于的x
的导数,即2*x
。
x.grad.zero_()
y.sum().backward()
print(x.grad == 2 * x)
输出:
tensor([True, True, True, True])
2.5.4 Python控制流的梯度计算
使用自动微分的一个好处是: 即使构建函数的计算图需要通过Python控制流(例如,条件、循环或任意函数调用),我们仍然可以计算得到的变量的梯度。 在下面的代码中,while
循环的迭代次数和if
语句的结果都取决于输入a
的值。
def f(a):
b = a * 2
while b.norm() < 1000:
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c
代码解读:b.norm() 函数:用于计算向量或矩阵的范数。默认计算 L2 范数,想要计算其他类型的范数可以传参:b.norm(p=1)
会计算L1范数,b.norm(p=float('inf'))
会计算L-infinity范数。
L-infinity范数:最大范数,向量中各个元素绝对值的最大值!
接下来是计算梯度:
a = torch.randn(size=(), requires_grad=True)
d = f(a)
d.backward()
我们现在可以分析上面定义的f
函数。 请注意,它在其输入a
中是分段线性的。 换言之,对于任何a
,存在某个常量标量k
,使得f(a)=k*a
,其中k
的值取决于输入a
,因此可以用d/a
验证梯度是否正确。
a.grad == d / a
输出:
tensor(True)
2.5.5 小结
深度学习框架可以自动计算导数:我们首先将梯度附加到想要对其计算偏导数的变量上,然后记录目标值的计算,执行它的反向传播函数,并访问得到的梯度。
2.5.6 练习
- 为什么计算二阶导数比一阶导数的开销要更大?
- 在运行反向传播函数之后,立即再次运行它,看看会发生什么。
- 在控制流的例子中,我们计算
d
关于a
的导数,如果将变量a
更改为随机向量或矩阵,会发生什么?
grad can be implicitly created only for scalar outputs
意思是只能对标量求微分,解决的办法是将 d 变成标量,使用 d.sum().backward() 计科
- 重新设计一个求控制流梯度的例子,运行并分析结果。
- 使f(x)=sin(x),绘制f(x)和df(x)/dx的图像,其中后者不使用f′(x)=cos(x)。
2.6 概率论
2.6.1 基本概率论
将概率分配给一些离散选择的分布称为多项分布(multinomial distribution)。
2.6.1.1 概率论公理
在处理骰子掷出时,我们将集合S={1,2,3,4,5,6} 称为样本空间(sample space)或结果空间(outcome space), 其中每个元素都是结果(outcome)。 事件(event)是一组给定样本空间的随机结果。 例如,“看到5”({5})和“看到奇数”({1,3,5})都是掷出骰子的有效事件。 注意,如果一个随机实验的结果在A中,则事件A已经发生。 也就是说,如果投掷出3点,因为3∈{1,3,5},我们可以说,“看到奇数”的事件发生了。
概率(probability)可以被认为是将集合映射到真实值的函数。 在给定的样本空间S中,事件A的概率, 表示为P(A),满足以下属性:
-
对于任意事件A,其概率从不会是负数,即P(A)≥0;
-
整个样本空间的概率为1,即P(S)=1;
-
对于互斥(mutually exclusive)事件(对于所有i≠j都有Ai∩Aj=∅)的任意一个可数序列A1,A2,…,序列中任意一个事件发生的概率等于它们各自发生的概率之和,即
\[P(\bigcup_{i=1}^{\infty} \mathcal{A}_i) = \sum_{i=1}^{\infty} P(\mathcal{A}_i) \]
2.6.1.2 随机变量
在我们掷骰子的随机实验中,我们引入了随机变量(random variable)的概念。 随机变量几乎可以是任何数量,并且它可以在随机实验的一组可能性中取一个值。 考虑一个随机变量X,其值在掷骰子的样本空间S={1,2,3,4,5,6}中。 我们可以将事件“看到一个5”表示为{X=5}或X=5, 其概率表示为P({X=5})或P(X=5)。 通过P(X=a),我们区分了随机变量X和X可以采取的值(例如a)。 然而,这可能会导致繁琐的表示。 为了简化符号,一方面,我们可以将P(X)表示为随机变量X上的分布(distribution): 分布告诉我们X获得某一值的概率。 另一方面,我们可以简单用P(a)表示随机变量取值a的概率。 由于概率论中的事件是来自样本空间的一组结果,因此我们可以为随机变量指定值的可取范围。 例如,P(1≤X≤3)表示事件{1≤X≤3}, 即{X=1,2,or,3}的概率。 等价地,P(1≤X≤3)表示随机变量X从{1,2,3}中取值的概率。
请注意,离散(discrete)随机变量(如骰子的每一面) 和连续(continuous)随机变量(如人的体重和身高)之间存在微妙的区别。 现实生活中,测量两个人是否具有完全相同的身高没有太大意义。 如果我们进行足够精确的测量,最终会发现这个星球上没有两个人具有完全相同的身高。 在这种情况下,询问某人的身高是否落入给定的区间,比如是否在1.79米和1.81米之间更有意义。 在这些情况下,我们将这个看到某个数值的可能性量化为密度(density)。 高度恰好为1.80米的概率为0,但密度不是0。 在任何两个不同高度之间的区间,我们都有非零的概率。 在本节的其余部分中,我们将考虑离散空间中的概率。 连续随机变量的概率可以参考深度学习数学附录中随机变量 的一节。
2.6.2 处理多个随机变量
2.6.2.1 联合概率
联合概率(joint probability)P(A=a,B=b):给定任意值a和b,联合概率可以回答:A=a和B=b同时满足的概率是多少? 请注意,对于任何a和b的取值,P(A=a,B=b)≤P(A=a)。 这点是确定的,因为要同时发生A=a和B=b,A=a就必须发生,B=b也必须发生(反之亦然)。因此,A=a和B=b同时发生的可能性不大于A=a或是B=b单独发生的可能性。
2.6.2.2 条件概率
联合概率的不等式带给我们一个有趣的比率
我们称这个比率为条件概率conditional probability), 并用P(B=b∣A=a)表示它:它是B=b的概率,前提是A=a已发生。
2.6.2.3 贝叶斯定理
使用条件概率的定义,我们可以得出统计学中最有用的方程之一: Bayes定理(Bayes’ theorem)。 根据乘法法则(multiplication rule )可得到P(A,B)=P(B∣A)P(A)。 根据对称性,可得到P(A,B)=P(A∣B)P(B)。 假设P(B)>0,求解其中一个条件变量,我们得到
请注意,这里我们使用紧凑的表示法: 其中P(A,B)是一个联合分布(joint distribution), P(A∣B)是一个条件分布(conditional distribution)。 这种分布可以在给定值A=a,B=b上进行求值。
2.6.2.4 边际化
为了能进行事件概率求和,我们需要求和法则(sum rule), 即B的概率相当于计算A的所有可能选择,并将所有选择的联合概率聚合在一起:
这也称为边际化(marginalization)。 边际化结果的概率或分布称为边际概率marginal probability) 或边际分布(marginal distribution)。
2.6.2.5 独立性
另一个有用属性是依赖(dependence)与独立(independence)。 如果两个随机变量A和B是独立的,意味着事件A的发生跟B事件的发生无关。 在这种情况下,统计学家通常将这一点表述为A⊥B。 根据贝叶斯定理,马上就能同样得到P(A∣B)=P(A)。 在所有其他情况下,我们称A和B依赖。 比如,两次连续抛出一个骰子的事件是相互独立的。
由于
因此两个随机变量是独立的,当且仅当两个随机变量的联合分布是其各自分布的乘积。 同样地,给定另一个随机变量C时,两个随机变量A和B是条件独立的(conditionally independent), 当且仅当P(A,B∣C)=P(A∣C)P(B∣C)。 这个情况表示为A⊥B∣C。
2.6.3 期望和方差
为了概括概率分布的关键特征,我们需要一些测量方法。 一个随机变量X的期望(expectation,或平均值average))表示为
当函数f(x)的输入是从分布P中抽取的随机变量时,f(x)的期望值为
在许多情况下,我们希望衡量随机变量X与其期望值的偏置。这可以通过方差来量化
方差的平方根被称为标准差(standard deviation)。 随机变量函数的方差衡量的是:当从该随机变量分布中采样不同值x时, 函数值偏离该函数的期望的程度:
2.6.4 小结
- 我们可以从概率分布中采样。
- 我们可以使用联合分布、条件分布、Bayes定理、边缘化和独立性假设来分析多个随机变量。
- 期望和方差为概率分布的关键特征的概括提供了实用的度量形式。
2.6.5 练习
- 进行m=500组实验,每组抽取n=10个样本。改变m和n,观察和分析实验结果。
- 给定两个概率为P(A)和P(B)的事件,计算P(A∪B)和P(A∩B)的上限和下限。(提示:使用友元图来展示这些情况。)
P(AUB) | P(AnB) | |
---|---|---|
上限 | 1 | min(P(A), P(B)) |
下限 | max(P(A), P(B)) | 0 |
- 假设我们有一系列随机变量,例如A、B和C,其中B只依赖于A,而C只依赖于B,能简化联合概率P(A,B,C)吗?(提示:这是一个马尔可夫链。)
马尔科夫链:若有一系列随机变量 x1, x2, x3,...,xt, xt + 1,其中 xt + 1 只依赖于 xt,xt 只依赖于 xt - 1,xt - 1 只依赖于 xt - 2,...,x2只依赖于 x1,那么条件概率:P{xt + 1|x1, x2, x3,...,xt} = P{xt + 1|xt}
P(ABC) = P(AB)P(C|AB) = P(AB)P(C|B) = P(B|A)P(A)P(C|B)
- 在 2.6.2.6节中,第一个测试更准确。为什么不运行第一个测试两次,而是同时运行第一个和第二个测试?
第三章 线性神经网络
3.1 线性回归
回归(regression)是能为一个或多个自变量与因变量之间关系建模的一类方法。 在自然科学和社会科学领域,回归经常用来表示输入和输出之间的关系。
在机器学习领域中的大多数任务通常都与预测(prediction)有关。 当我们想预测一个数值时,就会涉及到回归问题。 但不是所有的预测都是回归问题。 在后面的章节中,我们将介绍分类问题。分类问题的目标是预测数据属于一组类别中的哪一个。
3.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的样本,其输入表示为
其对应的标签是
3.1.1.1 线性模型
线性假设是指目标(房屋价格)可以表示为特征(面积和房龄)的加权和,如下面的式子:
(3.1.1)中的warea和wage 称为权重(weight),权重决定了每个特征对我们预测值的影响。 b称为偏置(bias)、偏移量(offset)或截距(intercept)。 偏置是指当所有特征都取值为0时,预测值应该为多少。 即使现实中不会有任何房子的面积是0或房龄正好是0年,我们仍然需要偏置项。 如果没有偏置项,我们模型的表达能力将受到限制。 严格来说, (3.1.1)是输入特征的一个 仿射变换(affine transformation)。 仿射变换的特点是通过加权和对特征进行线性变换(linear transformation), 并通过偏置项来进行平移(translation)。
给定一个数据集,我们的目标是寻找模型的权重w和偏置b, 使得根据模型做出的预测大体符合数据里的真实价格。 输出的预测值由输入特征通过线性模型的仿射变换决定,仿射变换由所选权重和偏置确定。
而在机器学习领域,我们通常使用的是高维数据集,建模时采用线性代数表示法会比较方便。 当我们的输入包含d个特征时,我们将预测结果
表示为:
将所有特征放到向量x中, 并将所有权重放到向量w中, 我们可以用点积形式来简洁地表达模型:
向量x对应于单个数据样本的特征。 用符号表示的矩阵
可以很方便地引用我们整个数据集的n个样本。 其中,X的每一行是一个样本,每一列是一种特征。
对于特征集合X,预测值y^ 可以通过矩阵-向量乘法表示为:
这个过程中的求和将使用广播机制。 给定训练数据特征X和对应的已知标签y, 线性回归的目标是找到一组权重向量w和偏置b: 当给定从X的同分布中取样的新样本特征时, 这组权重向量和偏置能够使得新样本预测标签的误差尽可能小。
虽然我们相信给定x预测y的最佳模型会是线性的, 但我们很难找到一个有n个样本的真实数据集,其中对于所有的1≤i≤n,y(i)完全等于w⊤x(i)+b。 无论我们使用什么手段来观察特征X和标签y, 都可能会出现少量的观测误差。 因此,即使确信特征与标签的潜在关系是线性的, 我们也会加入一个噪声项来考虑观测误差带来的影响。
3.1.1.2 损失函数
在我们开始考虑如何用模型拟合(fit)数据之前,我们需要确定一个拟合程度的度量。 损失函数(loss function)能够量化目标的实际值与预测值之间的差距。 通常我们会选择非负数作为损失,且数值越小表示损失越小,完美预测时的损失为0。 回归问题中最常用的损失函数是平方误差函数。 当样本i的预测值为y^(i),其相应的真实标签为y(i)时, 平方误差可以定义为以下公式:
由于平方误差函数中的二次方项, 估计值y^(i)和观测值y(i)之间较大的差异将导致更大的损失。 为了度量模型在整个数据集上的质量,我们需计算在训练集n个样本上的损失均值(也等价于求和)。
在训练模型时,我们希望寻找一组参数(w∗,b∗), 这组参数能最小化在所有训练样本上的总损失。如下式:
3.1.1.3 解析解
线性回归刚好是一个很简单的优化问题。 与我们将在本书中所讲到的其他大部分模型不同,线性回归的解可以用一个公式简单地表达出来, 这类解叫作解析解(analytical solution)。 首先,我们将偏置b合并到参数w中,合并方法是在包含所有参数的矩阵中附加一列。 我们的预测问题是
这在损失平面上只有一个临界点,这个临界点对应于整个区域的损失极小点。 将损失关于w的导数设为0,得到解析解:
像线性回归这样的简单问题存在解析解,但并不是所有的问题都存在解析解。 解析解可以进行很好的数学分析,但解析解对问题的限制很严格,导致它无法广泛应用在深度学习里。
注:现阶段可就将解析解就当作一个概念理解即可,不必完全弄明白,因为只有线性回归有解析解!
3.1.1.4 随机梯度下降
本书中我们用到一种名为梯度下降(gradient descent)的方法, 这种方法几乎可以优化所有深度学习模型。 它通过不断地在损失函数递减的方向上更新参数来降低误差。
梯度下降最简单的用法是计算损失函数(数据集中所有样本的损失均值) 关于模型参数的导数(在这里也可以称为梯度)。 但实际中的执行可能会非常慢:因为在每一次更新参数之前,我们必须遍历整个数据集。 因此,我们通常会在每次需要计算更新的时候随机抽取一小批样本, 这种变体叫做小批量随机梯度下降(minibatch stochastic gradient descent)。
在每次迭代中,我们首先随机抽样一个小批量
它是由固定数量的训练样本组成的。 然后,我们计算小批量的平均损失关于模型参数的导数(也可以称为梯度)。 最后,我们将梯度乘以一个预先确定的正数η,并从当前参数的值中减掉。我们用下面的数学公式来表示这一更新过程:
总结一下,算法的步骤如下:
(1)初始化模型参数的值,如随机初始化;
(2)从数据集中随机抽取小批量样本且在负梯度的方向上更新参数,并不断迭代这一步骤。
对于平方损失和仿射变换,我们可以明确地写成如下形式:
上式中的x和w和都是向量。 |B|表示每个小批量中的样本数,这也称为批量大小(batch size)。 η表示学习率(learning rate)。 批量大小和学习率的值通常是手动预先指定,而不是通过模型训练得到的。 这些可以调整但不在训练过程中更新的参数称为超参数(hyperparameter)。 调参(hyperparameter tuning)是选择超参数的过程。 超参数通常是我们根据训练迭代结果来调整的, 而训练迭代结果是在独立的验证数据集(validation dataset)上评估得到的。
Q:为什么更新参数的过程是要先让损失函数对参数求导,然后将其乘以一个预先确定的正整数η,最后从当前的参数值中减掉?
A:我们的损失函数与多个变量有关,x1,x2...,画出图来就是类似于等高线的样子,我们求导其实就是得到参数在某一点增速最大的方向,将其变成相反数就是在某一点减小最大的方向,现在我们已经确定了方向,那么长度是是多少呢?答案就是η!他的学名叫学习率,这里可以简单地理解为步长。现在我们就明确了,我们的参数要往哪个方向更新,并且更新多少也确定了,由于参数是一个向量(w和b),向量是有方向的,所以加上刚才所说的即可。只不过由于已经对求导进行相反数变化了,所以有一个负号,然后加号省略,就变成了减号!(暂时这么理解,相信随着学习的深入会有更贴切的解释)
在训练了预先确定的若干迭代次数后(或者直到满足某些其他停止条件后), 我们记录下模型参数的估计值,表示为w,b。 但是,即使我们的函数确实是线性的且无噪声,这些估计值也不会使损失函数真正地达到最小值。 因为算法会使得损失向最小值缓慢收敛,但却不能在有限的步数内非常精确地达到最小值。事实上,更难做到的是找到一组参数,这组参数能够在我们从未见过的数据上实现较低的损失, 这一挑战被称为泛化(generalization)。
3.1.1.5 用模型进行预测
给定“已学习”的线性回归模型
现在我们可以通过房屋面积x1和房龄x2来估计一个(未包含在训练数据中的)新房屋价格。 给定特征估计目标的过程通常称为预测(prediction)或推断(inference)。
3.1.2 矢量化加速
在训练我们的模型时,我们经常希望能够同时处理整个小批量的样本。 为了实现这一点,需要我们对计算进行矢量化, 从而利用线性代数库,而不是在Python中编写开销高昂的for循环。
为了说明矢量化为什么如此重要,我们考虑对向量相加的两种方法。 我们实例化两个全为1的10000维向量。 在一种方法中,我们将使用Python的for循环遍历向量; 在另一种方法中,我们将依赖对+
的调用。
import math
import time
import numpy as np
import torch
from d2l import torch as d2l
n = 10000
a = torch.ones([n])
b = torch.ones([n])
定义一个计时器:
import time
class Timer: #@save
"""记录多次运行时间"""
def __init__(self):
self.times = []
self.tik = time.time()
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 cum_sum(self):
"""返回累计时间"""
return np.array(self.times).cumsum().tolist()
首先,我们使用for循环,每次执行一位的加法。
import math
import numpy as np
import torch
from d2l import torch as d2l
from my_Timer import Timer
n = 10000
a = torch.ones([n])
b = torch.ones([n])
c = torch.zeros(n)
timer = Timer()
for i in range(n):
c[i] = a[i] + b[i]
print(f'{timer.stop():.5f} sec')
输出:
0.09758 sec
我们使用重载的+
运算符来计算按元素的和。
timer.start()
d = a + b
print(f'{timer.stop():.5f} sec')
输出:
0.00000 sec
结果很明显,第二种方法比第一种方法快得多。 矢量化代码通常会带来数量级的加速。 另外,我们将更多的数学运算放到库中,而无须自己编写那么多的计算,从而减少了出错的可能性。
3.1.3 正态分布与平方损失
接下来,我们通过对噪声分布的假设来解读平方损失目标函数。
正态分布和线性回归之间的关系很密切。 正态分布(normal distribution),也称为高斯分布(Gaussian distribution), 最早由德国数学家高斯(Gauss)应用于天文学研究。 简单的说,若随机变量x具有均值μ和方差σ2(标准差σ),其正态分布概率密度函数如下:
下面我们定义一个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)
可视化正态分布
# 再次使用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轴的偏移,增加方差将会分散分布、降低其峰值。
均方误差损失函数(简称均方损失)可以用于线性回归的一个原因是: 我们假设了观测中包含噪声,其中噪声服从正态分布。 噪声正态分布如下式:
因此,我们现在可以写出通过给定的x观测到特定y的似然(likelihood):
现在,根据极大似然估计法,参数w和b的最优值是使整个数据集的似然最大的值:
根据极大似然估计法选择的估计量称为极大似然估计量。 虽然使许多指数函数的乘积最大化看起来很困难, 但是我们可以在不改变目标的前提下,通过最大化似然对数来简化。 由于历史原因,优化通常是说最小化而不是最大化。 我们可以改为最小化负对数似然
由此可以得到的数学公式是:
现在我们只需要假设σ是某个固定常数就可以忽略第一项, 因为第一项不依赖于w和b。 现在第二项除了常数1/σ2外,其余部分和前面介绍的均方误差是一样的。 幸运的是,上面式子的解并不依赖于σ。 因此,在高斯噪声的假设下,最小化均方误差等价于对线性模型的极大似然估计。
3.1.4 从线性回归到深度网络
到目前为止,我们只谈论了线性模型。 尽管神经网络涵盖了更多更为丰富的模型,我们依然可以用描述神经网络的方式来描述线性模型, 从而把线性模型看作一个神经网络。 首先,我们用“层”符号来重写这个模型。
3.1.4.1 神经网络图
深度学习从业者喜欢绘制图表来可视化模型中正在发生的事情。 在下图中,我们将线性回归模型描述为一个神经网络。 需要注意的是,该图只显示连接模式,即只显示每个输入如何连接到输出,隐去了权重和偏置的值。
在上图所示的神经网络中,输入为x1,…,xd, 因此输入层中的输入数(或称为特征维度,feature dimensionality)为d。 网络的输出为o1,因此输出层中的输出数是1。 需要注意的是,输入值都是已经给定的,并且只有一个计算神经元。 由于模型重点在发生计算的地方,所以通常我们在计算层数时不考虑输入层。 也就是说,上图中神经网络的层数为1。 我们可以将线性回归模型视为仅由单个人工神经元组成的神经网络,或称为单层神经网络。
对于线性回归,每个输入都与每个输出(在本例中只有一个输出)相连, 我们将这种变换称为全连接层(fully-connected layer)或称为稠密层(dense layer)。 下一章将详细讨论由这些层组成的网络。
3.1.4.2 生物学
看看即可
3.1.5 小结
- 机器学习模型中的关键要素是训练数据、损失函数、优化算法,还有模型本身。
- 矢量化使数学表达上更简洁,同时运行的更快。
- 最小化目标函数和执行极大似然估计等价。
- 线性回归模型也是一个简单的神经网络。
3.1.6 练习
-
假设我们有一些数据x1,…,xn∈R。我们的目标是找到一个常数b,使得最小化
\[\sum_i (x_i - b)^2 \]- 找到最优值b的解析解。
- 这个问题及其解与正态分布有什么关系?
-
推导出使用平方误差的线性回归优化问题的解析解。为了简化问题,可以忽略偏置b(我们可以通过向X添加所有值为1的一列来做到这一点)。
- 用矩阵和向量表示法写出优化问题(将所有数据视为单个矩阵,将所有目标值视为单个向量)。
- 计算损失对w的梯度。
- 通过将梯度设为0、求解矩阵方程来找到解析解。
- 什么时候可能比使用随机梯度下降更好?这种方法何时会失效?
-
假定控制附加噪声ϵ的噪声模型是指数分布。也就是说,
\[p(\epsilon) = \frac{1}{2} \exp(-|\epsilon|) \]-
写出模型
\[-\log P(\mathbf y \mid \mathbf X) \]下数据的负对数似然。
-
请试着写出解析解。
-
提出一种随机梯度下降算法来解决这个问题。哪里可能出错?(提示:当我们不断更新参数时,在驻点附近会发生什么情况)请尝试解决这个问题。
-
3.2 线性回归的从零开始实现
在这一节中,我们将只使用张量和自动求导。 在之后的章节中,我们会充分利用深度学习框架的优势,介绍更简洁的实现方式。
3.2.1 生成数据集
为了简单起见,我们将根据带有噪声的线性模型构造一个人造数据集。 我们的任务是使用这个有限样本的数据集来恢复这个模型的参数。 我们将使用低维数据,这样可以很容易地将其可视化。 在下面的代码中,我们生成一个包含1000个样本的数据集, 每个样本包含从标准正态分布中采样的2个特征。 我们的合成数据集是一个矩阵X∈R1000×2。
我们使用线性模型参数w=[2,−3.4]⊤、b=4.2 和噪声项ϵ生成数据集及其标签:
ϵ 可以视为模型预测和标签时的潜在观测误差。 在这里我们认为标准假设成立,即ϵ服从均值为0的正态分布。 为了简化问题,我们将标准差设为0.01。 下面的代码生成合成数据集。
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
中的每一行都包含一维标签值(一个标量)。
print('features:', features[0],'\nlabel:', labels[0])
输出:
features: tensor([0.2915, 0.7916])
label: tensor([2.0998])
代码解读:
X = torch.normal(0, 1, (num_examples, len(w)))
- 作用:生成一个形状为
(num_examples, len(w))
的随机矩阵X
。 - 参数:
0
:均值。1
:标准差。(num_examples, len(w))
:输出的形状。
- 解释:
X
是输入特征矩阵,每一行是一个样本,每一列是一个特征。len(w)
是特征的数量(即权重的数量)。- 数据是从均值为 0、标准差为 1 的正态分布中随机采样的。
y = torch.matmul(X, w) + b
- 作用:计算线性模型的预测值 y=Xw+by=Xw+b。
- 参数:
X
:特征矩阵,形状为(num_examples, len(w))
。w
:权重向量,形状为(len(w),)
。b
:偏置项,标量。
- 解释:
torch.matmul(X, w)
是矩阵乘法运算,计算 XwXw。+ b
将偏置项加到每个样本的预测值上。- 最终
y
的形状是(num_examples,)
。
y += torch.normal(0, 0.01, y.shape)
- 作用:向
y
添加噪声,模拟真实数据中的随机误差。 - 参数:
0
:噪声的均值。0.01
:噪声的标准差。y.shape
:噪声的形状与y
相同。
- 解释:
- 噪声是从均值为 0、标准差为 0.01 的正态分布中随机采样的。
- 添加噪声是为了使数据更接近真实世界的情况。
return X, y.reshape((-1, 1))
- 作用:返回特征矩阵
X
和标签向量y
。 - 解释:
y.reshape((-1, 1))
将y
的形状从(num_examples,)
转换为(num_examples, 1)
,使其成为一个列向量。- 这样
y
的形状与X
的行数一致,便于后续处理。
Q:既然 matmul() 函数是计算矩阵乘积的,那之前学过的 mm() 函数也是同样的功能,他俩有什么区别,此处可以替换吗?
A:首先,我需要回忆这两个函数的具体用法和区别。torch.mm()
是专门用于二维矩阵乘法的,也就是标准的矩阵相乘,输入必须是二维的张量,形状要符合矩阵乘法的规则,比如(m×n)乘以(n×p)得到(m×p)。而torch.matmul()
则更灵活,支持高维张量,并且有广播机制,可以处理不同维度的输入,比如矩阵和向量的相乘,或者批量矩阵相乘的情况。
然后,我们给出的例子中的X
是一个二维张量(比如1000×2),而w
是一个一维张量(比如2个元素)。使用matmul(X, w)
时,这里实际上是矩阵和向量的乘法,结果是一个一维张量(1000个元素),然后加上标量b
。而如果用mm()
的话,因为mm()
只接受二维张量,所以需要将w
转换为二维的列向量(比如2×1),这样X
是1000×2,w
是2×1,相乘得到1000×1的矩阵,之后可能需要squeeze去掉多余的维度,或者调整形状才能与原来的y
一致。这可能导致代码需要额外的调整,比如reshape操作,而使用matmul()
则更直接。
综上所述,matmul()函数更应该广泛地成为张量乘法!推荐使用该函数!
3.2.2 读取数据集
回想一下,训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用它们来更新我们的模型。 由于这个过程是训练机器学习算法的基础,所以有必要定义一个函数, 该函数能打乱数据集中的样本并以小批量方式获取数据。
在下面的代码中,我们定义一个data_iter
函数, 该函数接收批量大小、特征矩阵和标签向量作为输入,生成大小为batch_size
的小批量。 每个小批量包含一组特征和标签。
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]
代码解读(特别是random.shuffle函数和yield这一行):
num_examples = len(features)
- 作用:获取样本总数(假设
features
和labels
的长度一致)。 - 解释:
len(features)
返回数据集中的样本数量。
indices = list(range(num_examples))
- 作用:生成一个从
0
到num_examples-1
的索引列表。 - 示例:若
num_examples=5
,则indices = [0, 1, 2, 3, 4]
。
random.shuffle(indices)
- 作用:随机打乱索引列表的顺序。
- 关键点:
random.shuffle()
是原地操作(直接修改原列表,不返回新列表)。- 打乱顺序后,每个 epoch(完整遍历数据集)的数据顺序不同,避免模型因固定顺序产生偏差。
- 示例:
indices
可能变为[3, 1, 4, 0, 2]
。
for i in range(0, num_examples, batch_size)
- 作用:按
batch_size
的步长遍历数据集。 - 示例:
- 若
num_examples=1000
,batch_size=64
,则循环的i
取值为0, 64, 128, ..., 960
。 - 最后一次循环的
i
可能不足一个完整批次(如960 + 64 = 1024 > 1000
)。
- 若
batch_indices = torch.tensor(indices[i: min(i + batch_size, num_examples)])
- 作用:提取当前批次的索引。
- 解释:
indices[i: i + batch_size]
从打乱后的索引列表中取出当前批次的索引。min(i + batch_size, num_examples)
确保最后一次循环不会越界。- 转换为 PyTorch 张量以便后续用张量索引提取数据。
yield features[batch_indices], labels[batch_indices]
- 作用:返回当前批次的
features
和labels
。 - 关键点:
yield
表示这是一个生成器函数(Generator),每次迭代返回一个批次的数据。- 与
return
不同,yield
会暂停函数执行并保留函数状态,下次迭代时从暂停处继续。 - 生成器按需生成数据,避免一次性加载所有批次到内存,适合大规模数据集。
通常,我们利用GPU并行运算的优势,处理合理大小的“小批量”。 每个样本都可以并行地进行模型计算,且每个样本损失函数的梯度也可以被并行计算。 GPU可以在处理几百个样本时,所花费的时间不比处理一个样本时多太多。
我们直观感受一下小批量运算:读取第一个小批量数据样本并打印。 每个批量的特征维度显示批量大小和输入特征数。 同样的,批量的标签形状与batch_size
相等。
batch_size = 10
for X, y in data_iter(batch_size, features, labels):
print(X, '\n', y)
break
当我们运行迭代时,我们会连续地获得不同的小批量,直至遍历完整个数据集。 上面实现的迭代对教学来说很好,但它的执行效率很低,可能会在实际问题上陷入麻烦。 例如,它要求我们将所有数据加载到内存中,并执行大量的随机内存访问。 在深度学习框架中实现的内置迭代器效率要高得多, 它可以处理存储在文件中的数据和数据流提供的数据。
3.2.3 初始化模型参数
在我们开始用小批量随机梯度下降优化我们的模型参数之前, 我们需要先有一些参数。 在下面的代码中,我们通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重, 并将偏置初始化为0。
w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)
在初始化参数之后,我们的任务是更新这些参数,直到这些参数足够拟合我们的数据。 每次更新都需要计算损失函数关于模型参数的梯度。 有了这个梯度,我们就可以向减小损失的方向更新每个参数。 因为手动计算梯度很枯燥而且容易出错,所以没有人会手动计算梯度。 我们使用 2.5节中引入的自动微分来计算梯度。
3.2.4 定义模型
接下来,我们必须定义模型,将模型的输入和参数同模型的输出关联起来。 回想一下,要计算线性模型的输出, 我们只需计算输入特征X和模型权重w的矩阵-向量乘法后加上偏置b。 注意,上面的Xw是一个向量,而b是一个标量。 回想一下 2.1.3节中描述的广播机制: 当我们用一个向量加一个标量时,标量会被加到向量的每个分量上。
def linreg(X, w, b): #@save
"""线性回归模型"""
return torch.matmul(X, w) + b
注:matmul()函数的参数位置不能变!
3.2.5 定义损失函数
因为需要计算损失函数的梯度,所以我们应该先定义损失函数。 这里我们使用 3.1节中描述的平方损失函数。 在实现中,我们需要将真实值y
的和预测值y_hat
的形状相同。
def squared_loss(y_hat, y): #@save
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2
3.2.6 定义优化算法
正如我们在 3.1节中讨论的,线性回归有解析解。 尽管线性回归有解析解,但本书中的其他模型却没有。 这里我们介绍小批量随机梯度下降。
在每一步中,使用从数据集中随机抽取的一个小批量,然后根据参数计算损失的梯度。 接下来,朝着减少损失的方向更新我们的参数。 下面的函数实现小批量随机梯度下降更新。 该函数接受模型参数集合、学习速率和批量大小作为输入。每 一步更新的大小由学习速率lr
决定。 因为我们计算的损失是一个批量样本的总和,所以我们用批量大小(batch_size
) 来规范化步长,这样步长大小就不会取决于我们对批量大小的选择。
def sgd(params, lr, batch_size): #@save
"""小批量随机梯度下降"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()
3.2.7 训练
现在我们已经准备好了模型训练所有需要的要素,可以实现主要的训练过程部分了。 理解这段代码至关重要,因为从事深度学习后, 相同的训练过程几乎一遍又一遍地出现。 在每次迭代中,我们读取一小批量训练样本,并通过我们的模型来获得一组预测。 计算完损失后,我们开始反向传播,存储每个参数的梯度。 最后,我们调用优化算法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。 设置超参数很棘手,需要通过反复试验进行调整。 我们现在忽略这些细节,以后会在 11节中详细介绍。
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}')
因为我们使用的是自己合成的数据集,所以我们知道真正的参数是什么。 因此,我们可以通过比较真实参数和通过训练学到的参数来评估训练的成功程度。 事实上,真实参数和通过训练学到的参数确实非常接近。
print(f'w的估计误差: {true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差: {true_b - b}')
注意,我们不应该想当然地认为我们能够完美地求解参数。 在机器学习中,我们通常不太关心恢复真正的参数,而更关心如何高度准确预测参数。 幸运的是,即使是在复杂的优化问题上,随机梯度下降通常也能找到非常好的解。 其中一个原因是,在深度网络中存在许多参数组合能够实现高度精确的预测。
3.2.9 小结
- 我们学习了深度网络是如何实现和优化的。在这一过程中只使用张量和自动微分,不需要定义层或复杂的优化器。
- 这一节只触及到了表面知识。在下面的部分中,我们将基于刚刚介绍的概念描述其他模型,并学习如何更简洁地实现其他模型。
3.2.10 练习
- 如果我们将权重初始化为零,会发生什么。算法仍然有效吗?
- 假设试图为电压和电流的关系建立一个模型。自动微分可以用来学习模型的参数吗?
- 能基于普朗克定律使用光谱能量密度来确定物体的温度吗?
- 计算二阶导数时可能会遇到什么问题?这些问题可以如何解决?
- 为什么在
squared_loss
函数中需要使用reshape
函数? - 尝试使用不同的学习率,观察损失函数值下降的快慢。
- 如果样本个数不能被批量大小整除,
data_iter
函数的行为会有什么变化?
3.3 线性回归的简洁实现
3.3.1 生成数据集
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)
print(features)
print(labels)
3.3.2 读取数据集
我们可以调用框架中现有的API来读取数据。 我们将features
和labels
作为API的参数传递,并通过数据迭代器指定batch_size
。 此外,布尔值is_train
表示是否希望数据迭代器对象在每个迭代周期内打乱数据。
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
的方式与我们在 3.2节中使用data_iter
函数的方式相同。为了验证是否正常工作,让我们读取并打印第一个小批量样本。 与 3.2节不同,这里我们使用iter
构造Python迭代器,并使用next
从迭代器中获取第一项。
print(next(iter(data_iter)))
3.3.3 定义模型
当我们在 3.2节中实现线性回归时, 我们明确定义了模型参数变量,并编写了计算的代码,这样通过基本的线性代数运算得到输出。 但是,如果模型变得更加复杂,且当我们几乎每天都需要实现模型时,自然会想简化这个过程。
对于标准深度学习模型,我们可以使用框架的预定义好的层。这使我们只需关注使用哪些层来构造模型,而不必关注层的实现细节。 我们首先定义一个模型变量net
,它是一个Sequential
类的实例。 Sequential
类将多个层串联在一起。 当给定输入数据时,Sequential
实例将数据传入到第一层, 然后将第一层的输出作为第二层的输入,以此类推。 在下面的例子中,我们的模型只包含一个层,因此实际上不需要Sequential
。 但是由于以后几乎所有的模型都是多层的,在这里使用Sequential
会让你熟悉“标准的流水线”。
回顾 图3.1.2中的单层网络架构, 这一单层被称为全连接层(fully-connected layer), 因为它的每一个输入都通过矩阵-向量乘法得到它的每个输出。
在PyTorch中,全连接层在Linear
类中定义。 值得注意的是,我们将两个参数传递到nn.Linear
中。 第一个指定输入特征形状,即2,第二个指定输出特征形状,输出特征形状为单个标量,因此为1。
# nn是神经网络的缩写
from torch import nn
net = nn.Sequential(nn.Linear(2, 1))
3.3.4 初始化模型参数
在使用net
之前,我们需要初始化模型参数。 如在线性回归模型中的权重和偏置。 深度学习框架通常有预定义的方法来初始化参数。 在这里,我们指定每个权重参数应该从均值为0、标准差为0.01的正态分布中随机采样, 偏置参数将初始化为零。
正如我们在构造nn.Linear
时指定输入和输出尺寸一样, 现在我们能直接访问参数以设定它们的初始值。 我们通过net[0]
选择网络中的第一个图层, 然后使用weight.data
和bias.data
方法访问参数。 我们还可以使用替换方法normal_
和fill_
来重写参数值。
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)
3.3.5 定义损失函数
计算均方误差使用的是MSELoss
类,也称为平方L2范数。 默认情况下,它返回所有样本损失的平均值。
loss = nn.MSELoss()
3.3.6 定义优化算法
小批量随机梯度下降算法是一种优化神经网络的标准工具, PyTorch在optim
模块中实现了该算法的许多变种。 当我们实例化一个SGD
实例时,我们要指定优化的参数 (可通过net.parameters()
从我们的模型中获得)以及优化算法所需的超参数字典。 小批量随机梯度下降只需要设置lr
值,这里设置为0.03。
trainer = torch.optim.SGD(net.parameters(), lr=0.03)
3.3.7 训练
通过深度学习框架的高级API来实现我们的模型只需要相对较少的代码。 我们不必单独分配参数、不必定义我们的损失函数,也不必手动实现小批量随机梯度下降。 当我们需要更复杂的模型时,高级API的优势将大大增加。 当我们有了所有的基本组件,训练过程代码与我们从零开始实现时所做的非常相似。
回顾一下:在每个迭代周期里,我们将完整遍历一次数据集(train_data
), 不停地从中获取一个小批量的输入和相应的标签。 对于每一个小批量,我们会进行以下步骤:
- 通过调用
net(X)
生成预测并计算损失l
(前向传播)。 - 通过进行反向传播来计算梯度。
- 通过调用优化器来更新模型参数。
为了更好的衡量训练效果,我们计算每个迭代周期后的损失,并打印它来监控训练过程。
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}')
代码解读(尤其是trainer.zero_grad()和trainer.step())
trainer.zero_grad()
- 作用:清空模型参数的梯度。
- 解释:
- PyTorch 会累积梯度(即每次调用
backward()
时,梯度会累加到之前的梯度上)。 - 在每次参数更新前,必须清空梯度,否则梯度会累积,导致更新错误。
- 如果不调用
zero_grad()
,梯度会越来越大,模型无法正常训练。 - 之前我们从零实现的时候是手动清空模型参数的梯度的:param.grad.zero_(),此时由于我们调用了已经实现了的SGD优化算法,没办法自己手动在函数内部清空,所以就在这里清空了!
- PyTorch 会累积梯度(即每次调用
trainer.step()
- 作用:更新模型参数。
- 解释:
- 根据优化器的规则(如 SGD),使用当前梯度更新模型参数。
- 更新公式为:
参数 = 参数 - 学习率 * 梯度
。
重点函数详解
trainer.zero_grad()
- 功能:清空模型参数的梯度。
- 为什么需要:
- PyTorch 默认会累积梯度(即每次调用
backward()
时,梯度会累加到之前的梯度上)。 - 如果不清空梯度,梯度会越来越大,导致参数更新错误。
- PyTorch 默认会累积梯度(即每次调用
trainer.step()
- 功能:根据梯度更新模型参数。
- 更新规则:
- 对于 SGD,更新公式为:
参数 = 参数 - 学习率 * 梯度
。 - 其他优化器(如 Adam、RMSProp)有不同的更新规则。
- 对于 SGD,更新公式为:
下面我们比较生成数据集的真实参数和通过有限数据训练获得的模型参数。 要访问参数,我们首先从net
访问所需的层,然后读取该层的权重和偏置。 正如在从零开始实现中一样,我们估计得到的参数与生成数据的真实参数非常接近。
w = net[0].weight.data
print('w的估计误差:', true_w - w.reshape(true_w.shape))
b = net[0].bias.data
print('b的估计误差:', true_b - b)
3.3.8 小结
- 我们可以使用PyTorch的高级API更简洁地实现模型。
- 在PyTorch中,
data
模块提供了数据处理工具,nn
模块定义了大量的神经网络层和常见损失函数。 - 我们可以通过
_
结尾的方法将参数替换,从而初始化参数。
3.3.9 练习
- 如果将小批量的总损失替换为小批量损失的平均值,需要如何更改学习率?
- 查看深度学习框架文档,它们提供了哪些损失函数和初始化方法?用Huber损失代替原损失,即
- 如何访问线性回归的梯度?
3.4 softmax回归
3.4.1 分类问题
我们从一个图像分类问题开始。 假设每次输入是一个2×2的灰度图像。 我们可以用一个标量表示每个像素值,每个图像对应四个特征x1,x2,x3,x4。 此外,假设每个图像属于类别“猫”“鸡”和“狗”中的一个。
独热编码:一种表示分类数据的简单方法。独热编码是一个向量,它的分量和类别一样多。 类别对应的分量设置为1,其他所有分量设置为0。 在我们的例子中,标签y将是一个三维向量, 其中(1,0,0)对应于“猫”、(0,1,0)对应于“鸡”、(0,0,1)对应于“狗”:
3.4.2 网络架构
为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型,每个类别对应一个输出。 为了解决线性模型的分类问题,我们需要和输出一样多的仿射函数(affine function)。 每个输出对应于它自己的仿射函数。 在我们的例子中,由于我们有4个特征和3个可能的输出类别, 我们将需要12个标量来表示权重(带下标的w), 3个标量来表示偏置(带下标的b)。 下面我们为每个输入计算三个未规范化的预测(logit):o1、o2和o3。
我们可以用神经网络图(如下图)来描述这个计算过程。 与线性回归一样,softmax回归也是一个单层神经网络。 由于计算每个输出o1、o2和o3取决于 所有输入x1、x2、x3和x4, 所以softmax回归的输出层也是全连接层。
为了更简洁地表达模型,我们仍然使用线性代数符号。 通过向量形式表达为o=Wx+b, 这是一种更适合数学和编写代码的形式。 由此,我们已经将所有权重放到一个3×4矩阵中。 对于给定数据样本的特征x, 我们的输出是由权重与输入特征进行矩阵-向量乘法再加上偏置b得到的。W是 3 * 4 的矩阵,x 和 b 是长度为 4 的列矩阵。
3.4.3 全连接层的参数开销
正如我们将在后续章节中看到的,在深度学习中,全连接层无处不在。 然而,顾名思义,全连接层是“完全”连接的,可能有很多可学习的参数。 具体来说,对于任何具有d个输入和q个输出的全连接层, 参数开销为O(dq),这个数字在实践中可能高得令人望而却步。 幸运的是,将d个输入转换为q个输出的成本可以减少到O(dq/n), 其中超参数n可以由我们灵活指定,以在实际应用中平衡参数节约和模型有效性。
3.4.3 softmax运算
现在我们将优化参数以最大化观测数据的概率。 为了得到预测结果,我们将设置一个阈值,如选择具有最大概率的标签。
我们希望模型的输出y^j可以视为属于类j的概率, 然后选择具有最大输出值的类别argmaxjyj作为我们的预测。 例如,如果y1、y2和y^3分别为0.1、0.8和0.1, 那么我们预测的类别是2,在我们的例子中代表“鸡”。
然而我们能否将未规范化的预测o直接视作我们感兴趣的输出呢? 答案是否定的。 因为将线性层的输出直接视为概率时存在一些问题: 一方面,我们没有限制这些输出数字的总和为1。 另一方面,根据输入的不同,它们可以为负值。 这些违反了 2.6节中所说的概率基本公理。
要将输出视为概率,我们必须保证在任何数据上的输出都是非负的且总和为1。 此外,我们需要一个训练的目标函数,来激励模型精准地估计概率。 例如, 在分类器输出0.5的所有样本中,我们希望这些样本是刚好有一半实际上属于预测的类别。 这个属性叫做校准(calibration)。
社会科学家邓肯·卢斯于1959年在选择模型(choice model)的理论基础上 发明的softmax函数正是这样做的: softmax函数能够将未规范化的预测变换为非负数并且总和为1,同时让模型保持 可导的性质。 为了完成这一目标,我们首先对每个未规范化的预测求幂,这样可以确保输出非负。 为了确保最终输出的概率值总和为1,我们再让每个求幂后的结果除以它们的总和。如下式:
这里,对于所有的j总有0≤y^j≤1。 因此,y^可以视为一个正确的概率分布。 softmax运算不会改变未规范化的预测o之间的大小次序,只会确定分配给每个类别的概率。 因此,在预测过程中,我们仍然可以用下式来选择最有可能的类别。
尽管softmax是一个非线性函数,但softmax回归的输出仍然由输入特征的仿射变换决定。 因此,softmax回归是一个线性模型(linear model)。
3.4.4 小批量样本的矢量化
为了提高计算效率并且充分利用GPU,我们通常会对小批量样本的数据执行矢量计算。 假设我们读取了一个批量的样本X, 其中特征维度(输入数量)为d,批量大小为n(可以理解为 n 个输入)。 此外,假设我们在输出中有q个类别。 那么小批量样本的特征为X∈Rn×d(一个输入的维度为 d,有 n 个输入,那么就是 n * d), 权重为W∈Rd×q, 偏置为b∈R1×q。 softmax回归的矢量计算表达式为:
相对于一次处理一个样本, 小批量样本的矢量化加快了和X和W的矩阵-向量乘法。 由于X中的每一行代表一个数据样本, 那么softmax运算可以按行(rowwise)执行: 对于O的每一行,我们先对所有项进行幂运算,然后通过求和对它们进行标准化。 在 (3.4.5)中, XW+b的求和会使用广播机制, 小批量的未规范化预测O和输出概率Y^ 都是形状为n×q的矩阵。
3.4.6 损失函数
接下来,我们需要一个损失函数来度量预测的效果。 我们将使用最大似然估计,这与在线性回归 ( 3.1.3节) 中的方法相同。
3.4.6.1 对数似然
softmax函数给出了一个向量y^, 我们可以将其视为“对给定任意输入x的每个类的条件概率”。 例如,y^1=P(y=猫∣x)。 假设整个数据集{X,Y}具有n个样本, 其中索引i的样本由特征向量x(i)和独热标签向量y(i)组成。 我们可以将估计值与实际值进行比较:
根据最大似然估计,我们最大化P(Y∣X),相当于最小化负对数似然:
其中,对于任何标签y和模型预测y^,损失函数为:
上式的损失函数 通常被称为交叉熵损失(cross-entropy loss)。 由于y是一个长度为q的独热编码向量, 所以除了一个项以外的所有项j都消失了(即yj = 1,0 的项都消失了)。 由于所有y^j都是预测的概率,所以它们的对数永远不会大于0。 因此,如果正确地预测实际标签,即如果实际标签P(y∣x)=1, 则损失函数不能进一步最小化。 注意,这往往是不可能的。 例如,数据集中可能存在标签噪声(比如某些样本可能被误标), 或输入特征没有足够的信息来完美地对每一个样本分类。
3.4.6.2 softmax及其导数
由于softmax和相关的损失函数很常见, 因此我们需要更好地理解它的计算方式。 将 (3.4.3)代入损失 (3.4.8)中。 利用softmax的定义,我们得到:
考虑相对于任何未规范化的预测oj的导数,我们得到:
换句话说,导数是我们softmax模型分配的概率与实际发生的情况(由独热标签向量表示)之间的差异。 从这个意义上讲,这与我们在回归中看到的非常相似, 其中梯度是观测值y和估计值y^之间的差异。 这不是巧合,在任何指数族分布模型中 (参见本书附录中关于数学分布的一节), 对数似然的梯度正是由此得出的。 这使梯度计算在实践中变得容易很多。
3.4.6.3 交叉熵损失
现在让我们考虑整个结果分布的情况,即观察到的不仅仅是一个结果。 对于标签y,我们可以使用与以前相同的表示形式。 唯一的区别是,我们现在用一个概率向量表示,如(0.1,0.2,0.7), 而不是仅包含二元项的向量(0,0,1)。 我们使用 (3.4.8)来定义损失l, 它是所有标签分布的预期损失值。 此损失称为交叉熵损失(cross-entropy loss),它是分类问题最常用的损失之一。
3.4.7 信息论基础
信息论(information theory)涉及编码、解码、发送以及尽可能简洁地处理信息或数据。
3.4.7.1 熵
信息论的核心思想是量化数据中的信息内容。 在信息论中,该数值被称为分布P的熵(entropy)。可以通过以下方程得到:
信息论的基本定理之一指出,为了对从分布p中随机抽取的数据进行编码, 我们至少需要H[P]“纳特(nat)”对其进行编码。 “纳特”相当于比特(bit),但是对数底为e而不是2。因此,一个纳特是1/log(2)≈1.44比特。
3.4.7.2 信息量
压缩与预测有什么关系呢? 想象一下,我们有一个要压缩的数据流。 如果我们很容易预测下一个数据,那么这个数据就很容易压缩。 为什么呢? 举一个极端的例子,假如数据流中的每个数据完全相同,这会是一个非常无聊的数据流。 由于它们总是相同的,我们总是知道下一个数据是什么。 所以,为了传递数据流的内容,我们不必传输任何信息。也就是说,“下一个数据是xx”这个事件毫无信息量。
但是,如果我们不能完全预测每一个事件,那么我们有时可能会感到“惊异”。 克劳德·香农决定用信息量
来量化这种惊异程度。 在观察一个事件j时,并赋予它(主观)概率P(j)。 当我们赋予一个事件较低的概率时,我们的惊异会更大,该事件的信息量也就更大。 在 (3.4.11)中定义的熵, 是当分配的概率真正匹配数据生成过程时的信息量的期望。
3.4.7.3 重新审视交叉熵
如果把熵H(P)想象为“知道真实概率的人所经历的惊异程度”,那么什么是交叉熵? 交叉熵从P到Q,记为H(P,Q)。 我们可以把交叉熵想象为“主观概率为Q的观察者在看到根据概率P生成的数据时的预期惊异”。 当P=Q时,交叉熵达到最低。 在这种情况下,从P到Q的交叉熵是H(P,P)=H(P)。
简而言之,我们可以从两方面来考虑交叉熵分类目标: (i)最大化观测数据的似然;(ii)最小化传达标签所需的惊异。
3.4.8 模型预测与评估
在训练softmax回归模型后,给出任何样本特征,我们可以预测每个输出类别的概率。 通常我们使用预测概率最高的类别作为输出类别。 如果预测与实际类别(标签)一致,则预测是正确的。 在接下来的实验中,我们将使用精度(accuracy)来评估模型的性能。 精度等于正确预测数与预测总数之间的比率。
3.4.9 小结
- softmax运算获取一个向量并将其映射为概率。
- softmax回归适用于分类问题,它使用了softmax运算中输出类别的概率分布。
- 交叉熵是一个衡量两个概率分布之间差异的很好的度量,它测量给定模型编码数据所需的比特数。
3.5 图像分类数据集
MNIST数据集是图像分类中广泛使用的数据集之一,但作为基准数据集过于简单。 我们将使用类似但更复杂的Fashion-MNIST数据集。
%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()
3.5.1 读取数据集
我们可以通过框架中的内置函数将Fashion-MNIST数据集下载并读取到内存中。
# 通过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)
代码解读:
transforms.ToTensor()
trans = transforms.ToTensor()
transforms.ToTensor()
是PyTorch中的一个转换函数,用于将PIL图像或NumPy数组转换为PyTorch张量(Tensor)。- 具体来说,它将图像数据从PIL类型(通常是
PIL.Image
对象)或NumPy数组转换为PyTorch的torch.Tensor
类型。 - 转换后的张量是一个32位浮点数(
float32
)格式,并且将像素值从0~255的范围缩放到0~1之间(即除以255)。
torchvision.datasets.FashionMNIST
mnist_train = torchvision.datasets.FashionMNIST(
root="../data", train=True, transform=trans, download=True)
torchvision.datasets.FashionMNIST
是PyTorch提供的一个内置数据集类,用于加载FashionMNIST数据集。root="../data"
:指定数据集的存储路径。如果路径不存在,数据集将自动下载到该路径。train=True
:表示加载的是训练集。如果设置为False
,则加载测试集。transform=trans
:指定对数据集中的每个样本应用的转换操作。在这里,trans
是之前定义的ToTensor()
转换,它将图像数据转换为张量并进行归一化。download=True
:如果数据集在指定路径中不存在,则自动下载数据集。
Fashion-MNIST由10个类别的图像组成, 每个类别由训练数据集(train dataset)中的6000张图像 和测试数据集(test dataset)中的1000张图像组成。 因此,训练集和测试集分别包含60000和10000张图像。 测试数据集不会用于训练,只用于评估模型性能。
len(mnist_train), len(mnist_test)
输出:
(60000, 10000)
每个输入图像的高度和宽度均为28像素。 数据集由灰度图像组成,其通道数为1。 为了简洁起见,本书将高度h像素、宽度w像素图像的形状记为h×w或(h,w)。
mnist_train[0][0].shape
输出:
torch.Size([1, 28, 28])
Fashion-MNIST中包含的10个类别,分别为t-shirt(T恤)、trouser(裤子)、pullover(套衫)、dress(连衣裙)、coat(外套)、sandal(凉鞋)、shirt(衬衫)、sneaker(运动鞋)、bag(包)和ankle boot(短靴)。 以下函数用于在数字标签索引及其文本名称之间进行转换。
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]
我们现在可以创建一个函数来可视化这些样本。
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
以下是训练数据集中前几个样本的图像及其相应的标签。
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));
3.5.2 读取小批量
为了使我们在读取训练集和测试集时更容易,我们使用内置的数据迭代器,而不是从零开始创建。 回顾一下,在每次迭代中,数据加载器每次都会读取一小批量数据,大小为batch_size
。 通过内置数据迭代器,我们可以随机打乱了所有样本,从而无偏见地读取小批量。
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())
我们看一下读取训练数据所需的时间。
timer = d2l.Timer()
for X, y in train_iter:
continue
print(f'{timer.stop():.2f} sec')
输出:
'3.37 sec'
3.5.3 整合所有组件
现在我们定义load_data_fashion_mnist
函数,用于获取和读取Fashion-MNIST数据集。 这个函数返回训练集和验证集的数据迭代器。 此外,这个函数还接受一个可选参数resize
,用来将图像大小调整为另一种形状。
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
函数的图像大小调整功能。
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
输出:
torch.Size([32, 1, 64, 64]) torch.float32 torch.Size([32]) torch.int64
我们现在已经准备好使用Fashion-MNIST数据集,便于下面的章节调用来评估各种分类算法。
3.6 softmax回归的从零开始实现
本节我们将使用刚刚在 3.5节中引入的Fashion-MNIST数据集, 并设置数据迭代器的批量大小为256。
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)
3.6.1 初始化模型参数
和之前线性回归的例子一样,这里的每个样本都将用固定长度的向量表示。 原始数据集中的每个样本都是28×28的图像。 本节将展平每个图像,把它们看作长度为784的向量。 在后面的章节中,我们将讨论能够利用图像空间结构的特征, 但现在我们暂时只把每个像素位置看作一个特征。
回想一下,在softmax回归中,我们的输出与类别一样多。 因为我们的数据集有10个类别,所以网络输出维度为10。 因此,权重将构成一个784×10的矩阵, 偏置将构成一个1×10的行向量。 与线性回归一样,我们将使用正态分布初始化我们的权重W
,偏置初始化为0。
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)
3.6.2 定义softmax操作
在实现softmax回归模型之前,我们简要回顾一下sum
运算符如何沿着张量中的特定维度工作。 如 2.3.6节和 2.3.6.1节所述, 给定一个矩阵X
,我们可以对所有元素求和(默认情况下)。 也可以只求同一个轴上的元素,即同一列(轴0)或同一行(轴1)。 如果X
是一个形状为(2, 3)
的张量,我们对列进行求和, 则结果将是一个具有形状(3,)
的向量。 当调用sum
运算符时,我们可以指定保持在原始张量的轴数,而不折叠求和的维度。 这将产生一个具有形状(1, 3)
的二维张量。
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)
输出:
(tensor([[5., 7., 9.]]),
tensor([[ 6.],
[15.]]))
回想一下,实现softmax由三个步骤组成:
- 对每个项求幂(使用
exp
); - 对每一行求和(小批量中每个样本是一行),得到每个样本的规范化常数;
- 将每一行除以其规范化常数,确保结果的和为1。
在查看代码之前,我们回顾一下这个表达式:
分母或规范化常数,有时也称为配分函数(其对数称为对数-配分函数)。 该名称来自统计物理学中一个模拟粒子群分布的方程。
def softmax(X):
X_exp = torch.exp(X)
partition = X_exp.sum(1, keepdim=True)
return X_exp / partition # 这里应用了广播机制
正如上述代码,对于任何随机输入,我们将每个元素变成一个非负数。 此外,依据概率原理,每行总和为1。
X = torch.normal(0, 1, (2, 5))
X_prob = softmax(X)
X_prob, X_prob.sum(1)
输出:
(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]))
注意,虽然这在数学上看起来是正确的,但我们在代码实现中有点草率。 矩阵中的非常大或非常小的元素可能造成数值上溢或下溢,但我们没有采取措施来防止这点。
3.6.3 定义模型
定义softmax操作后,我们可以实现softmax回归模型。 下面的代码定义了输入如何通过网络映射到输出。 注意,将数据传递到模型之前,我们使用reshape
函数将每张原始图像展平为向量。
def net(X):
return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)
Q:X.reshape((-1, W.shape[0]))的作用?
A:确保矩阵乘法的合法性。即确保 X 的列数与 W 的行数相同。这里,而 X 必须满足
,n 个样本,每个样本有784列,即每个样本有784个特征,分析下来其实得到XW的结果是
,每一列表示某一个样本在某一个类别的概率。我们分析乘法的过程其实可以看出来W的每一列就是每一个类别对应的权重,我们在分析的时候是用公式横着来写的,但是这是矩阵,所以要区别开。
3.6.4 定义损失函数
接下来,我们实现 3.4节中引入的交叉熵损失函数。 这可能是深度学习中最常见的损失函数,因为目前分类问题的数量远远超过回归问题的数量。
回顾一下,交叉熵采用真实标签的预测概率的负对数似然。 这里我们不使用Python的for循环迭代预测(这往往是低效的), 而是通过一个运算符选择所有元素。 下面,我们创建一个数据样本y_hat
,其中包含2个样本在3个类别的预测概率, 以及它们对应的标签y
。 有了y
,我们知道在第一个样本中,第一类是正确的预测; 而在第二个样本中,第三类是正确的预测。 然后使用y
作为y_hat
中概率的索引, 我们选择第一个样本中第一个类的概率和第二个样本中第三个类的概率。
y = torch.tensor([0, 2])
y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
print(y_hat[[0, 1], y])
输出:
tensor([0.1000, 0.5000])
代码解读:
1、y_hat[[0, 1], y]:等价于如下代码:
for i in range(len(y)):
print(y_hat[i, y[i]])
result = torch.tensor([y_hat[i, y[i]] for i in range(len(y))])
2、这个代码想表达什么?
y是真实标签,表示第一个样本的真实类别是0,第二个样本的真是类别是2,y_hat是训练出来的结果,他表示对第一个样本在三个类别的概率分别为0.1,0.3,0.6,第二个样本在三个类别的概率分别为0.3,0.2,0.5。那么我们 print(y_hat[[0, 1], y]) 就可以知道训练出来的模型对这两个样本在真实类别下的预测概率,第一个样本的预测概率为0.1(错了,因为概率的最大值是0.6,我们的模型觉得这是类别2,但真实的是类别0,但是概率只有0.1,不是最大的),第二个样本的预测概率为0.5,对了!结合接下来的内容看。
现在我们只需一行代码就可以实现交叉熵损失函数。
def cross_entropy(y_hat, y):
return - torch.log(y_hat[range(len(y_hat)), y])
print(cross_entropy(y_hat, y))
输出:
tensor([2.3026, 0.6931])
3.6.5 分类精度
给定预测概率分布y_hat
,当我们必须输出硬预测(hard prediction)时, 我们通常选择预测概率最高的类。 许多应用都要求我们做出选择。如Gmail必须将电子邮件分类为“Primary(主要邮件)”、 “Social(社交邮件)”“Updates(更新邮件)”或“Forums(论坛邮件)”。 Gmail做分类时可能在内部估计概率,但最终它必须在类中选择一个。
当预测与标签分类y
一致时,即是正确的。 分类精度即正确预测数量与总预测数量之比。 虽然直接优化精度可能很困难(因为精度的计算不可导), 但精度通常是我们最关心的性能衡量标准,我们在训练分类器时几乎总会关注它。
为了计算精度,我们执行以下操作。 首先,如果y_hat
是矩阵,那么假定第二个维度存储每个类的预测分数。 我们使用argmax
获得每行中最大元素的索引来获得预测类别。 然后我们将预测类别与真实y
元素进行比较。 由于等式运算符“==
”对数据类型很敏感, 因此我们将y_hat
的数据类型转换为与y
的数据类型一致。 结果是一个包含0(错)和1(对)的张量。 最后,我们求和会得到正确预测的数量。
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。
print(accuracy(y_hat, y) / len(y))
输出:
0.5
同样,对于任意数据迭代器data_iter
可访问的数据集, 我们可以评估在任意模型net
的精度。
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个变量, 分别用于存储正确预测的数量和预测的总数量。 当我们遍历数据集时,两者都将随着时间的推移而累加。
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]
代码解读:
zip()
函数
-
功能:将多个可迭代对象(如列表、元组等)打包成一个元组的迭代器。
-
示例:
list1 = [1, 2, 3] list2 = ['a', 'b', 'c'] zipped = zip(list1, list2) print(list(zipped)) # 输出 [(1, 'a'), (2, 'b'), (3, 'c')]
-
特点:
- 返回的迭代器长度由最短的可迭代对象决定。
- 常用于同时遍历多个可迭代对象。
[a + float(b) for a, b in zip(self.data, args)]
- 作用:将
self.data
和args
中的值逐对相加。 - 解释:
zip(self.data, args)
:将self.data
和args
打包成一个元组的迭代器。for a, b in zip(self.data, args)
:遍历每个元组,a
是self.data
中的值,b
是args
中的值。a + float(b)
:将b
转换为浮点数后与a
相加。- 结果是一个新的列表,更新到
self.data
中。 - 结合上述代码来解释,那么这个args=[accuracy(net(X), y), y.numel()],前者是此批量样本中正确分类数量,后者是此批量样本中的样本总数。
由于我们使用随机权重初始化net
模型, 因此该模型的精度应接近于随机猜测。 例如在有10个类别情况下的精度为0.1。
evaluate_accuracy(net, test_iter)
输出:
0.0625
3.3.6 训练
在我们看过 3.2节中的线性回归实现, softmax回归的训练过程代码应该看起来非常眼熟。 在这里,我们重构训练过程的实现以使其可重复使用。 首先,我们定义一个函数来训练一个迭代周期。 请注意,updater
是更新模型参数的常用函数,它接受批量大小作为参数。 它可以是d2l.sgd
函数,也可以是框架的内置优化函数。
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
, 它能够简化本书其余部分的代码。
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
类来可视化训练进度。
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。
lr = 0.1
def updater(batch_size):
return d2l.sgd([W, b], lr, batch_size)
现在,我们训练模型10个迭代周期。 请注意,迭代周期(num_epochs
)和学习率(lr
)都是可调节的超参数。 通过更改它们的值,我们可以提高模型的分类精度。
num_epochs = 10
train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, updater)
3.6.7 预测
现在训练已经完成,我们的模型已经准备好对图像进行分类预测。 给定一系列图像,我们将比较它们的实际标签(文本输出的第一行)和模型预测(文本输出的第二行)。
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)
3.6.8 小结
- 借助softmax回归,我们可以训练多分类的模型。
- 训练softmax回归循环模型与训练线性回归模型非常相似:先读取数据,再定义模型和损失函数,然后使用优化算法训练模型。大多数常见的深度学习模型都有类似的训练过程。
3.7 softmax回归的简洁实现
通过深度学习框架的高级API能够使实现线性回归变得更加容易。 同样,通过深度学习框架的高级API也能更方便地实现softmax回归模型。 本节如在 3.6节中一样, 继续使用Fashion-MNIST数据集,并保持批量大小为256。
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)
3.7.1 初始化模型参数
如我们在 3.4节所述, softmax回归的输出层是一个全连接层。 因此,为了实现我们的模型, 我们只需在Sequential
中添加一个带有10个输出的全连接层。 同样,在这里Sequential
并不是必要的, 但它是实现深度模型的基础。 我们仍然以均值0和标准差0.01随机初始化权重。
# 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);
3.7.2 重新审视softmax回归的实现
在前面 3.6节的例子中, 我们计算了模型的输出,然后将此输出送入交叉熵损失。 从数学上讲,这是一件完全合理的事情。 然而,从计算角度来看,指数可能会造成数值稳定性问题。
回想一下,softmax函数
其中y^j是预测的概率分布。 oj是未规范化的预测o的第j个元素。 如果ok中的一些数值非常大, 那么exp(ok)可能大于数据类型容许的最大数字,即上溢(overflow)。 这将使分母或分子变为inf
(无穷大), 最后得到的是0、inf
或nan
(不是数字)的y^j。 在这些情况下,我们无法得到一个明确定义的交叉熵值。
解决这个问题的一个技巧是: 在继续softmax计算之前,先从所有ok中减去max(ok)。 这里可以看到每个ok按常数进行的移动不会改变softmax的返回值:
在减法和规范化步骤之后,可能有些oj−max(ok)具有较大的负值。 由于精度受限,exp(oj−max(ok))将有接近零的值,即下溢(underflow)。 这些值可能会四舍五入为零,使y^j为零, 并且使得log(y^j)的值为-inf
。 反向传播几步后,我们可能会发现自己面对一屏幕可怕的nan
结果。
尽管我们要计算指数函数,但我们最终在计算交叉熵损失时会取它们的对数。 通过将softmax和交叉熵结合在一起,可以避免反向传播过程中可能会困扰我们的数值稳定性问题。 如下面的等式所示,我们避免计算exp(oj−max(ok)), 而可以直接使用oj−max(ok),因为log(exp(⋅))被抵消了。
我们也希望保留传统的softmax函数,以备我们需要评估通过模型输出的概率。 但是,我们没有将softmax概率传递到损失函数中, 而是在交叉熵损失函数中传递未规范化的预测,并同时计算softmax及其对数, 这是一种类似“LogSumExp技巧”的聪明方式。
loss = nn.CrossEntropyLoss(reduction='none')
3.7.3 优化算法
在这里,我们使用学习率为0.1的小批量随机梯度下降作为优化算法。 这与我们在线性回归例子中的相同,这说明了优化器的普适性。
trainer = torch.optim.SGD(net.parameters(), lr=0.1)
3.7.4 训练
接下来我们调用 3.6节中 定义的训练函数来训练模型。
num_epochs = 10
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
和以前一样,这个算法使结果收敛到一个相当高的精度,而且这次的代码比之前更精简了。
3.7.5 小结
- 使用深度学习框架的高级API,我们可以更简洁地实现softmax回归。
- 从计算的角度来看,实现softmax回归比较复杂。在许多情况下,深度学习框架在这些著名的技巧之外采取了额外的预防措施,来确保数值的稳定性。这使我们避免了在实践中从零开始编写模型时可能遇到的陷阱。
第四章 多层感知机
4.1 多层感知机
4.1.1 隐藏层
我们在 3.1.1.1节中描述了仿射变换, 它是一种带有偏置项的线性变换。 首先,回想一下softmax回归的模型架构(全连接层)。 该模型通过单个仿射变换将我们的输入直接映射到输出,然后进行softmax操作。 如果我们的标签通过仿射变换后确实与我们的输入数据相关,那么这种方法确实足够了。 但是,仿射变换中的线性是一个很强的假设。
4.1.1.1 线性模型可能会出错
例如,线性意味着单调假设: 任何特征的增大都会导致模型输出的增大(如果对应的权重为正), 或者导致模型输出的减小(如果对应的权重为负)。 有时这是有道理的。 例如,如果我们试图预测一个人是否会偿还贷款。 我们可以认为,在其他条件不变的情况下, 收入较高的申请人比收入较低的申请人更有可能偿还贷款。 但是,虽然收入与还款概率存在单调性,但它们不是线性相关的。 收入从0增加到5万,可能比从100万增加到105万带来更大的还款可能性。 处理这一问题的一种方法是对我们的数据进行预处理, 使线性变得更合理,如使用收入的对数作为我们的特征。
然而我们可以很容易找出违反单调性的例子。 例如,我们想要根据体温预测死亡率。 对体温高于37摄氏度的人来说,温度越高风险越大。 然而,对体温低于37摄氏度的人来说,温度越高风险就越低。 在这种情况下,我们也可以通过一些巧妙的预处理来解决问题。 例如,我们可以使用与37摄氏度的距离作为特征。
但是,如何对猫和狗的图像进行分类呢? 增加位置(13,17)处像素的强度是否总是增加(或降低)图像描绘狗的似然? 对线性模型的依赖对应于一个隐含的假设, 即区分猫和狗的唯一要求是评估单个像素的强度。 在一个倒置图像后依然保留类别的世界里,这种方法注定会失败。
与我们前面的例子相比,这里的线性很荒谬, 而且我们难以通过简单的预处理来解决这个问题。 这是因为任何像素的重要性都以复杂的方式取决于该像素的上下文(周围像素的值)。 我们的数据可能会有一种表示,这种表示会考虑到我们在特征之间的相关交互作用。 在此表示的基础上建立一个线性模型可能会是合适的, 但我们不知道如何手动计算这么一种表示。 对于深度神经网络,我们使用观测数据来联合学习隐藏层表示和应用于该表示的线性预测器。
线性模型不能拟合 XOR 问题。XOR 问题可以简单地理解异或问题,两个输入相同则输出为同一类,两个输入不同归为另一类。举个例子:在直角坐标系中,假设 x,y 作为输入的两个特征,我们规定当 x 和 y 符号相同的时候为第一类,符号不同的时候为第二类。那么很容易想到第一类落在一三象限,第二类落在二四象限,此时无法使用线性模型将这两类分开,因为说白了线性模型的本质就是画一条线作为两个不同样本的分界线,而上述问题没办法使用一条线酒吧一三象限和二四象限分开。XOR问题由 Minsky 和 Papert 于 1969 年提出,直接导致了 AI 第一次寒冬。
4.1.1.2 在网络中加入隐藏层
我们可以通过在网络中加入一个或多个隐藏层来克服线性模型的限制, 使其能处理更普遍的函数关系类型。 要做到这一点,最简单的方法是将许多全连接层堆叠在一起。 每一层都输出到上面的层,直到生成最后的输出。 我们可以把前L−1层看作表示,把最后一层看作线性预测器。 这种架构通常称为多层感知机(multilayer perceptron),通常缩写为MLP。 我们以图4.1.1描述了多层感知机。
图4.1.1 一个单隐藏层的多层感知机,具有5个隐藏单元
这个多层感知机有4个输入,3个输出,其隐藏层包含5个隐藏单元。 输入层不涉及任何计算,因此使用此网络产生输出只需要实现隐藏层和输出层的计算。 因此,这个多层感知机中的层数为2。 注意,这两个层都是全连接的。 每个输入都会影响隐藏层中的每个神经元, 而隐藏层中的每个神经元又会影响输出层中的每个神经元。
然而,正如 3.4.3节所说, 具有全连接层的多层感知机的参数开销可能会高得令人望而却步。 即使在不改变输入或输出大小的情况下, 可能在参数节约和模型有效性之间进行权衡 (Zhang et al., 2021)。
4.1.1.3 从线性到非线性
同之前的章节一样, 我们通过矩阵
来表示n个样本的小批量, 其中每个样本具有d个输入特征。 对于具有h个隐藏单元的单隐藏层多层感知机, 用
表示隐藏层的输出, 称为隐藏表示(hidden representations)。 在数学或代码中,H也被称为隐藏层变量(hidden-layer variable) 或隐藏变量(hidden variable)。 因为隐藏层和输出层都是全连接的, 所以我们有隐藏层权重
和隐藏层偏置
以及输出层权重
和输出层偏置
形式上,我们按如下方式计算单隐藏层多层感知机的输出:
注意在添加隐藏层之后,模型现在需要跟踪和更新额外的参数。 可我们能从中得到什么好处呢?在上面定义的模型里,我们没有好处! 原因很简单:上面的隐藏单元由输入的仿射函数给出, 而输出(softmax操作前)只是隐藏单元的仿射函数。 仿射函数的仿射函数本身就是仿射函数, 但是我们之前的线性模型已经能够表示任何仿射函数。
我们可以证明这一等价性,即对于任意权重值, 我们只需合并隐藏层,便可产生具有参数 W=W(1)W(2) 和b=b(1)W(2)+b(2) 的等价单层模型:
解释:上面段话以及公式想要表达的意思就是我们加了隐藏层整个模型还是线性的,而且还增加了复杂度,等于脱了裤子放屁,没什么用。下面段话就开始解释加入隐藏层的好处了!
为了发挥多层架构的潜力, 我们还需要一个额外的关键要素: 在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function)σ。 激活函数的输出(例如,σ(⋅))被称为活性值(activations)。激活函数的本质就是将线性变为非线性。 一般来说,有了激活函数,就不可能再将我们的多层感知机退化成线性模型:
由于X中的每一行对应于小批量中的一个样本, 出于记号习惯的考量, 我们定义非线性函数σ也以按行的方式作用于其输入, 即一次计算一个样本。 我们在 3.4.5节中 以相同的方式使用了softmax符号来表示按行操作。 但是本节应用于隐藏层的激活函数通常不仅按行操作,也按元素操作。 这意味着在计算每一层的线性部分之后,我们可以计算每个活性值, 而不需要查看其他隐藏单元所取的值。对于大多数激活函数都是这样。
为了构建更通用的多层感知机, 我们可以继续堆叠这样的隐藏层, 例如
一层叠一层,从而产生更有表达能力的模型。
4.1.1.4 通用近似定理
多层感知机可以通过隐藏神经元,捕捉到输入之间复杂的相互作用, 这些神经元依赖于每个输入的值。 我们可以很容易地设计隐藏节点来执行任意计算。 例如,在一对输入上进行基本逻辑操作,多层感知机是通用近似器。 即使是网络只有一个隐藏层,给定足够的神经元和正确的权重, 我们可以对任意函数建模,尽管实际中学习该函数是很困难的。 神经网络有点像C语言。 C语言和任何其他现代编程语言一样,能够表达任何可计算的程序。 但实际上,想出一个符合规范的程序才是最困难的部分。
而且,虽然一个单隐层网络能学习任何函数, 但并不意味着我们应该尝试使用单隐藏层网络来解决所有问题。 事实上,通过使用更深(而不是更广)的网络,我们可以更容易地逼近许多函数。 我们将在后面的章节中进行更细致的讨论。
4.1.2 激活函数
激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。 大多数激活函数都是非线性的。 由于激活函数是深度学习的基础,下面简要介绍一些常见的激活函数。
%matplotlib inline
import torch
from d2l import torch as d2l
4.1.2.1 ReLU函数
最受欢迎的激活函数是修正线性单元(Rectified linear unit,ReLU), 因为它实现简单,同时在各种预测任务中表现良好。 ReLU提供了一种非常简单的非线性变换。 给定元素x,ReLU函数被定义为该元素与0的最大值:
通俗地说,ReLU函数通过将相应的活性值设为0,仅保留正元素并丢弃所有负元素。 为了直观感受一下,我们可以画出函数的曲线图。 正如从图中所看到,激活函数是分段线性的。
x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.relu(x)
d2l.plot(x.detach(), y.detach(), 'x', 'relu(x)', figsize=(5, 2.5))
当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。 注意,当输入值精确等于0时,ReLU函数不可导。 在此时,我们默认使用左侧的导数,即当输入为0时导数为0。 我们可以忽略这种情况,因为输入可能永远都不会是0。 下面我们绘制ReLU函数的导数。
y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
代码解读:
1.torch.ones_like(x)
torch.ones_like(x)
会生成一个与x
形状相同的张量,并且所有元素的值都为 1。- 在
y.backward()
中,这个张量作为gradient
参数传入。它表示y
对自身的梯度。由于y
是一个标量(通常是一个损失值),y
对自身的梯度是 1。因此,torch.ones_like(x)
实际上是在告诉 PyTorch 从y
开始反向传播时,y
对自身的梯度是 1。
2retain_graph=True
- 默认情况下,PyTorch 在调用
backward()
后会释放计算图以节省内存。这意味着如果你再次调用backward()
,会报错,因为计算图已经不存在了。 retain_graph=True
告诉 PyTorch 保留计算图,以便你可以再次调用backward()
。这在某些情况下很有用,比如当你需要多次反向传播时。
使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题(稍后将详细介绍)。
注意,ReLU函数有许多变体,包括参数化ReLU(Parameterized ReLU,pReLU) 函数 (He et al., 2015)。 该变体为ReLU添加了一个线性项,因此即使参数是负的,某些信息仍然可以通过:
4.1.2.2 sigmoid函数
对于一个定义域在R中的输入, sigmoid函数将输入变换为区间(0, 1)上的输出。 因此,sigmoid通常称为挤压函数(squashing function): 它将范围(-inf, inf)中的任意输入压缩到区间(0, 1)中的某个值:
在最早的神经网络中,科学家们感兴趣的是对“激发”或“不激发”的生物神经元进行建模。 因此,这一领域的先驱可以一直追溯到人工神经元的发明者麦卡洛克和皮茨,他们专注于阈值单元。 阈值单元在其输入低于某个阈值时取值0,当输入超过阈值时取值1。
当人们逐渐关注到到基于梯度的学习时, sigmoid函数是一个自然的选择,因为它是一个平滑的、可微的阈值单元近似。 当我们想要将输出视作二元分类问题的概率时, sigmoid仍然被广泛用作输出单元上的激活函数 (sigmoid可以视为softmax的特例)。 然而,sigmoid在隐藏层中已经较少使用, 它在大部分时候被更简单、更容易训练的ReLU所取代。 在后面关于循环神经网络的章节中,我们将描述利用sigmoid单元来控制时序信息流的架构。
下面,我们绘制sigmoid函数。 注意,当输入接近0时,sigmoid函数接近线性变换。
y = torch.sigmoid(x)
d2l.plot(x.detach(), y.detach(), 'x', 'sigmoid(x)', figsize=(5, 2.5))
sigmoid函数的导数为下面的公式:
sigmoid函数的导数图像如下所示。 注意,当输入为0时,sigmoid函数的导数达到最大值0.25; 而输入在任一方向上越远离0点时,导数越接近0。
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of sigmoid', figsize=(5, 2.5))
4.1.2.3 tanh函数
与sigmoid函数类似, tanh(双曲正切)函数也能将其输入压缩转换到区间(-1, 1)上。 tanh函数的公式如下:
下面我们绘制tanh函数。 注意,当输入在0附近时,tanh函数接近线性变换。 函数的形状类似于sigmoid函数, 不同的是tanh函数关于坐标系原点中心对称。
y = torch.tanh(x)
d2l.plot(x.detach(), y.detach(), 'x', 'tanh(x)', figsize=(5, 2.5))
tanh函数的导数是:
tanh函数的导数图像如下所示。 当输入接近0时,tanh函数的导数接近最大值1。 与我们在sigmoid函数图像中看到的类似, 输入在任一方向上越远离0点,导数越接近0。
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of tanh', figsize=(5, 2.5))
总结一下,我们现在了解了如何结合非线性函数来构建具有更强表达能力的多层神经网络架构。 顺便说一句,这些知识已经让你掌握了一个类似于1990年左右深度学习从业者的工具。 在某些方面,你比在20世纪90年代工作的任何人都有优势, 因为你可以利用功能强大的开源深度学习框架,只需几行代码就可以快速构建模型, 而以前训练这些网络需要研究人员编写数千行的C或Fortran代码。
4.1.3 小结
- 多层感知机在输出层和输入层之间增加一个或多个全连接隐藏层,并通过激活函数转换隐藏层的输出。
- 常用的激活函数包括ReLU函数、sigmoid函数和tanh函数。
4.2 多层感知机的从零开始实现
我们已经在 4.1节中描述了多层感知机(MLP), 现在让我们尝试自己实现一个多层感知机。 为了与之前softmax回归( 3.6节 ) 获得的结果进行比较, 我们将继续使用Fashion-MNIST图像分类数据集 ( 3.5节)。
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)
4.2.1 初始化模型参数
回想一下,Fashion-MNIST中的每个图像由 28×28=784个灰度像素值组成。 所有图像共分为10个类别。 忽略像素之间的空间结构, 我们可以将每个图像视为具有784个输入特征 和10个类的简单分类数据集。 首先,我们将实现一个具有单隐藏层的多层感知机, 它包含256个隐藏单元。 注意,我们可以将这两个变量都视为超参数。 通常,我们选择2的若干次幂作为层的宽度。 因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。
我们用几个张量来表示我们的参数。 注意,对于每一层我们都要记录一个权重矩阵和一个偏置向量。 跟以前一样,我们要为损失关于这些参数的梯度分配内存。
num_inputs, num_outputs, num_hiddens = 784, 10, 256
W1 = nn.Parameter(torch.randn(
num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
W2 = nn.Parameter(torch.randn(
num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))
params = [W1, b1, W2, b2]
代码解读:
1 初始化第一层的权重 W1
W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens, requires_grad=True) * 0.01)
torch.randn(num_inputs, num_hiddens)
:生成一个形状为(num_inputs, num_hiddens)
的张量,其中的值是从标准正态分布(均值为0,标准差为1)中随机采样的。requires_grad=True
:表示这些参数(权重)在训练过程中需要计算梯度,以便通过反向传播更新。* 0.01
:将随机初始化的权重值缩小到原来的1/100。这是为了确保初始权重值较小,避免在训练初期出现梯度爆炸或梯度消失的问题。nn.Parameter
:将张量包装为PyTorch的可训练参数。- 回顾一下上一节的内容,在这里 n 表示样本数量,d 表示输入特征,为784,q 表示输出数量,为 10,h 表示隐藏单元,为256。
4.2.2 激活函数
为了确保我们对模型的细节了如指掌, 我们将实现ReLU激活函数, 而不是直接调用内置的relu
函数。
def relu(X):
a = torch.zeros_like(X)
return torch.max(X, a)
代码解读:
1 创建一个与 X
形状相同的全零张量 a
torch.zeros_like(X)
:生成一个与X
形状相同的张量,其中的所有元素都初始化为0。- 这个全零张量
a
将用于与X
逐元素比较。
4.2.3 模型
因为我们忽略了空间结构, 所以我们使用reshape
将每个二维图像转换为一个长度为num_inputs
的向量。 只需几行代码就可以实现我们的模型。
def net(X):
X = X.reshape((-1, num_inputs))
H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法
return (H@W2 + b2)
Q:X@W1什么意思?
A:其实之前学过的,只不过是换一种写法,在学线性回归的时候我们使用了 torch.matmul(X, w) 函数,因此X@W1 <=> torch.matmul(X@W1)
4.2.4 损失函数
由于我们已经从零实现过softmax函数( 3.6节), 因此在这里我们直接使用高级API中的内置函数来计算softmax和交叉熵损失。 回想一下我们之前在 3.7.2节中 对这些复杂问题的讨论。 鼓励查看损失函数的源代码,以加深对实现细节的了解。
loss = nn.CrossEntropyLoss(reduction='none')
4.2.5 训练
幸运的是,多层感知机的训练过程与softmax回归的训练过程完全相同。 可以直接调用d2l
包的train_ch3
函数(参见 3.6节 ), 将迭代周期数设置为10,并将学习率设置为0.1.
num_epochs, lr = 10, 0.1
updater = torch.optim.SGD(params, lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, updater)
为了对学习到的模型进行评估,我们将在一些测试数据上应用这个模型。
d2l.predict_ch3(net, test_iter)
4.2.6 小结
- 手动实现一个简单的多层感知机是很容易的。然而如果有大量的层,从零开始实现多层感知机会变得很麻烦(例如,要命名和记录模型的参数)。
4.3 多层感知机的简洁实现
本节将介绍通过高级API更简洁地实现多层感知机。
import torch
from torch import nn
from d2l import torch as d2l
4.3.1 模型
与softmax回归的简洁实现( 3.7节)相比, 唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。 第一层是隐藏层,它包含256个隐藏单元,并使用了ReLU激活函数。 第二层是输出层。
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10))
def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights);
代码解读:
- 定义神经网络模型
1.1 nn.Sequential
nn.Sequential
是 PyTorch 中的一个容器,用于按顺序组合多个网络层。输入数据会依次通过这些层进行计算。- 这里定义了一个顺序模型
net
,包含以下层:
1.2 nn.Flatten()
nn.Flatten()
是一个展平层,用于将输入的多维张量展平为一维张量。- 例如,如果输入是一个形状为
(batch_size, 28, 28)
的图像张量(如 MNIST 数据集中的图像),经过nn.Flatten()
后,输出形状变为(batch_size, 784)
,其中784 = 28 * 28
。
1.3 nn.Linear(784, 256)
nn.Linear(784, 256)
是一个全连接层(线性层),输入大小为 784,输出大小为 256。- 该层的权重矩阵形状为
(256, 784)
,偏置向量形状为(256,)
。 - 计算公式为:
output = input @ weight.T + bias
,其中@
表示矩阵乘法。
1.4 nn.ReLU()
nn.ReLU()
是 ReLU 激活函数层,对输入逐元素应用 ReLU 函数。- ReLU 函数的定义为:
ReLU(x) = max(0, x)
,即将负值置为 0,正值保持不变。
1.5 nn.Linear(256, 10)
nn.Linear(256, 10)
是另一个全连接层,输入大小为 256,输出大小为 10。- 该层的权重矩阵形状为
(10, 256)
,偏置向量形状为(10,)
。 - 输出通常对应于分类任务中的 10 个类别(如 MNIST 数据集的 10 个数字类别)。
- 定义权重初始化函数
2.1 函数 init_weights(m)
- 这是一个自定义的权重初始化函数,用于初始化模型的权重。
- 参数
m
是模型的某一层。
2.2 检查层类型
- 检查当前层
m
是否是nn.Linear
类型(即全连接层)。 - 如果是全连接层,则对其权重进行初始化。
2.3 使用正态分布初始化权重
nn.init.normal_
是 PyTorch 提供的初始化函数,用于从正态分布中采样并填充权重。m.weight
是当前层的权重矩阵。std=0.01
表示正态分布的标准差为 0.01,均值为 0。- 这种初始化方法可以确保权重初始值较小,避免训练初期出现梯度爆炸或梯度消失的问题。
- 应用权重初始化函数
3.1 net.apply(init_weights)
apply
是 PyTorch 模型的一个方法,用于递归地对模型的每一层应用指定的函数。- 这里将
init_weights
函数应用到net
模型的每一层。
3.2 递归应用
apply
会遍历模型的所有子模块(如nn.Linear
、nn.ReLU
等),并对每个子模块调用init_weights
函数。- 由于
init_weights
函数中只对nn.Linear
类型的层进行初始化,因此只有全连接层的权重会被初始化。
训练过程的实现与我们实现softmax回归时完全相同。
batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
Q:loss = nn.CrossEntropyLoss(reduction='none')
中的reduction='none'
什么意思?
A:该参数用于指定如何对损失进行汇总(或缩减)。它有以下三种取值:
'none'
:不进行汇总,返回每个样本的损失值。'mean'
:对所有样本的损失值取平均。'sum'
:对所有样本的损失值求和。
4.3.2 小结
- 我们可以使用高级API更简洁地实现多层感知机。
- 对于相同的分类问题,多层感知机的实现与softmax回归的实现相同,只是多层感知机的实现里增加了带有激活函数的隐藏层。
4.4 模型选择、过拟合和欠拟合
将模型在训练数据上拟合的比在潜在分布中更接近的现象称为过拟合(overfitting), 用于对抗过拟合的技术称为正则化(regularization)。在实验中调整模型架构或超参数时会发现: 如果有足够多的神经元、层数和训练迭代周期, 模型最终可以在训练集上达到完美的精度,此时测试集的准确性却下降了。
4.4.1 训练误差和泛化误差
为了进一步讨论这一现象,我们需要了解训练误差和泛化误差。 训练误差(training error)是指, 模型在训练数据集上计算得到的误差。 泛化误差(generalization error)是指, 模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望。换句话说,泛化误差就是模型在新数据上的误差。
问题是,我们永远不能准确地计算出泛化误差。 这是因为无限多的数据样本是一个虚构的对象。 在实际中,我们只能通过将模型应用于一个独立的测试集来估计泛化误差, 该测试集由随机选取的、未曾在训练集中出现的数据样本构成。
4.4.1.1 统计学习理论
在我们目前已探讨、并将在之后继续探讨的监督学习情景中, 我们假设训练数据和测试数据都是从相同的分布中独立提取的。 这通常被称为独立同分布假设(i.i.d. assumption), 这意味着对数据进行采样的过程没有进行“记忆”。 换句话说,抽取的第2个样本和第3个样本的相关性, 并不比抽取的第2个样本和第200万个样本的相关性更强。
有时候我们即使轻微违背独立同分布假设,模型仍将继续运行得非常好。 比如,我们有许多有用的工具已经应用于现实,如人脸识别、语音识别和语言翻译。 毕竟,几乎所有现实的应用都至少涉及到一些违背独立同分布假设的情况。
有些违背独立同分布假设的行为肯定会带来麻烦。 比如,我们试图只用来自大学生的人脸数据来训练一个人脸识别系统, 然后想要用它来监测疗养院中的老人。 这不太可能有效,因为大学生看起来往往与老年人有很大的不同。
在接下来的章节中,我们将讨论因违背独立同分布假设而引起的问题。 目前,即使认为独立同分布假设是理所当然的,理解泛化性也是一个困难的问题。 此外,能够解释深层神经网络泛化性能的理论基础, 也仍在继续困扰着学习理论领域最伟大的学者们。
当我们训练模型时,我们试图找到一个能够尽可能拟合训练数据的函数。 但是如果它执行地“太好了”,而不能对看不见的数据做到很好泛化,就会导致过拟合。 这种情况正是我们想要避免或控制的。 深度学习中有许多启发式的技术旨在防止过拟合。
4.4.1.2 模型复杂性
当我们有简单的模型和大量的数据时,我们期望泛化误差与训练误差相近。 当我们有更复杂的模型和更少的样本时,我们预计训练误差会下降,但泛化误差会增大。 模型复杂性由什么构成是一个复杂的问题。 一个模型是否能很好地泛化取决于很多因素。 例如,具有更多参数的模型可能被认为更复杂, 参数有更大取值范围的模型可能更为复杂。 通常对于神经网络,我们认为需要更多训练迭代的模型比较复杂, 而需要早停(early stopping)的模型(即较少训练迭代周期)就不那么复杂。
本节为了给出一些直观的印象,我们将重点介绍几个倾向于影响模型泛化的因素。
- 可调整参数的数量。当可调整参数的数量(有时称为自由度)很大时,模型往往更容易过拟合。
- 参数采用的值。当权重的取值范围较大时,模型可能更容易过拟合。
- 训练样本的数量。即使模型很简单,也很容易过拟合只包含一两个样本的数据集。而过拟合一个有数百万个样本的数据集则需要一个极其灵活的模型。
4.4.2 模型选择
在机器学习中,我们通常在评估几个候选模型后选择最终的模型。 这个过程叫做模型选择。 有时,需要进行比较的模型在本质上是完全不同的(比如,决策树与线性模型)。 又有时,我们需要比较不同的超参数设置下的同一类模型。
例如,训练多层感知机模型时,我们可能希望比较具有 不同数量的隐藏层、不同数量的隐藏单元以及不同的激活函数组合的模型。 为了确定候选模型中的最佳模型,我们通常会使用验证集。
4.4.2.1 验证集
原则上,在我们确定所有的超参数之前,我们不希望用到测试集。 如果我们在模型选择过程中使用测试数据,可能会有过拟合测试数据的风险,那就麻烦大了。 如果我们过拟合了训练数据,还可以在测试数据上的评估来判断过拟合。 但是如果我们过拟合了测试数据,我们又该怎么知道呢?
因此,我们决不能依靠测试数据进行模型选择。 然而,我们也不能仅仅依靠训练数据来选择模型,因为我们无法估计训练数据的泛化误差。在实际应用中,情况变得更加复杂。 虽然理想情况下我们只会使用测试数据一次, 以评估最好的模型或比较一些模型效果,但现实是测试数据很少在使用一次后被丢弃。 我们很少能有充足的数据来对每一轮实验采用全新测试集。
解决此问题的常见做法是将我们的数据分成三份, 除了训练和测试数据集之外,还增加一个验证数据集(validation dataset), 也叫验证集(validation set)。 但现实是验证数据和测试数据之间的边界模糊得令人担忧。 除非另有明确说明,否则在这本书的实验中, 我们实际上是在使用应该被正确地称为训练数据和验证数据的数据集, 并没有真正的测试数据集。 因此,书中每次实验报告的准确度都是验证集准确度,而不是测试集准确度。
4.4.2.2 K折交叉验证
当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。 这个问题的一个流行的解决方案是采用K折交叉验证。 这里,原始训练数据被分成K个不重叠的子集。 然后执行K次模型训练和验证,每次在K−1个子集上进行训练, 并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。 最后,通过对K次实验的结果取平均来估计训练和验证误差。常用 K = 5 或者 K = 10.
4.4.3 过拟合还是欠拟合
当我们比较训练和验证误差时,我们要注意两种常见的情况。 首先,我们要注意这样的情况:训练误差和验证误差都很严重, 但它们之间仅有一点差距。 如果模型不能降低训练误差,这可能意味着模型过于简单(即表达能力不足), 无法捕获试图学习的模式。 此外,由于我们的训练和验证误差之间的泛化误差很小, 我们有理由相信可以用一个更复杂的模型降低训练误差。 这种现象被称为欠拟合(underfitting)。
另一方面,当我们的训练误差明显低于验证误差时要小心, 这表明严重的过拟合(overfitting)。 注意,过拟合并不总是一件坏事。 特别是在深度学习领域,众所周知, 最好的预测模型在训练数据上的表现往往比在保留(验证)数据上好得多。 最终,我们通常更关心验证误差,而不是训练误差和验证误差之间的差距。
是否过拟合或欠拟合可能取决于模型复杂性和可用训练数据集的大小, 这两个点将在下面进行讨论。
4.4.3.1 模型复杂性
为了说明一些关于过拟合和模型复杂性的经典直觉, 我们给出一个多项式的例子。 给定由单个特征x和对应实数标签y组成的训练数据, 我们试图找到下面的d阶多项式来估计标签y。
这只是一个线性回归问题,我们的特征是x的幂给出的, 模型的权重是wi给出的,偏置是w0给出的 (因为对于所有的x都有x0=1)。 由于这只是一个线性回归问题,我们可以使用平方误差作为我们的损失函数。
高阶多项式函数比低阶多项式函数复杂得多。 高阶多项式的参数较多,模型函数的选择范围较广。 因此在固定训练数据集的情况下, 高阶多项式函数相对于低阶多项式的训练误差应该始终更低(最坏也是相等)。 事实上,当数据样本包含了x的不同值时, 函数阶数等于数据样本数量的多项式函数可以完美拟合训练集。 在 图4.4.1中, 我们直观地描述了多项式的阶数和欠拟合与过拟合之间的关系。
模型容量其实可以理解为模型复杂度,层数越深,参数越多,模型复杂度越高,模型容量越高。低容量的模型难以拟合数据,高容量的模型可以记住所有的训练数据。
当我们的数据集比较简单而选择了模型容量高的模型时,会发生过拟合,最直接的解释就是我的模型直接把数据记住,旧的数据表现良好,但是新数据就不那么好了。
当我们的数据集比较复杂而选择了模型容量低的模型时,会发生欠拟合,因为模型训练不好嘛。
4.4.3.2 数据集大小
另一个重要因素是数据集的大小。 训练数据集中的样本越少,我们就越有可能(且更严重地)过拟合。 随着训练数据量的增加,泛化误差通常会减小。 此外,一般来说,更多的数据不会有什么坏处。 对于固定的任务和数据分布,模型复杂性和数据集大小之间通常存在关系。 给出更多的数据,我们可能会尝试拟合一个更复杂的模型。 能够拟合更复杂的模型可能是有益的。 如果没有足够的数据,简单的模型可能更有用。 对于许多任务,深度学习只有在有数千个训练样本时才优于线性模型。 从一定程度上来说,深度学习目前的生机要归功于 廉价存储、互联设备以及数字化经济带来的海量数据集。
4.4.4 多项式回归
我们现在可以通过多项式拟合来探索这些概念。
import math
import numpy as np
import torch
from torch import nn
from d2l import torch as d2l
4.4.4.1 生成数据集
给定x,我们将使用以下三阶多项式来生成训练和测试数据的标签:
噪声项ϵ服从均值为0且标准差为0.1的正态分布。 在优化的过程中,我们通常希望避免非常大的梯度值或损失值。 这就是我们将特征从xi调整为 xi / i! 的原因, 这样可以避免很大的i带来的特别大的指数值。 我们将为训练集和测试集各生成100个样本。
max_degree = 20 # 多项式的最大阶数
n_train, n_test = 100, 100 # 训练和测试数据集大小
true_w = np.zeros(max_degree) # 分配大量的空间
true_w[0:4] = np.array([5, 1.2, -3.4, 5.6])
features = np.random.normal(size=(n_train + n_test, 1))
np.random.shuffle(features)
poly_features = np.power(features, np.arange(max_degree).reshape(1, -1))
for i in range(max_degree):
poly_features[:, i] /= math.gamma(i + 1) # gamma(n)=(n-1)!
# labels的维度:(n_train+n_test,)
labels = np.dot(poly_features, true_w)
labels += np.random.normal(scale=0.1, size=labels.shape)
代码解读:
- 生成随机特征数据
features = np.random.normal(size=(n_train + n_test, 1))
np.random.shuffle(features)
这里我们生成了一个形状为 (200, 1)
的随机特征数据,数据是从标准正态分布中随机抽取的。np.random.shuffle(features)
将这些特征数据打乱顺序。
- 将特征数据转换为多项式特征
poly_features = np.power(features, np.arange(max_degree).reshape(1, -1))
这里我们将特征数据转换为多项式特征。np.arange(max_degree)
生成一个从0到19的数组,reshape(1, -1)
将其转换为形状为 (1, 20)
的数组。np.power(features, np.arange(max_degree).reshape(1, -1))
计算了特征数据的0次方到19次方,结果是一个形状为 (200, 20)
的数组 poly_features
,其中每一列对应特征数据的不同次方。
- 对多项式特征进行归一化
for i in range(max_degree):
poly_features[:, i] /= math.gamma(i + 1) # gamma(n)=(n-1)!
这里我们对多项式特征进行归一化处理。math.gamma(i + 1)
计算了 (i)!
(即 i
的阶乘),然后将每一列的多项式特征除以其对应的阶乘。这一步的目的是为了控制多项式特征的尺度,避免高阶项的数值过大。
- 生成标签
labels = np.dot(poly_features, true_w)
这里我们通过多项式特征和真实系数 true_w
计算标签。np.dot(poly_features, true_w)
计算了多项式特征与系数的点积,得到了形状为 (200,)
的标签数组 labels
。
- 加入噪声
labels += np.random.normal(scale=0.1, size=labels.shape)
最后,我们在标签中加入了一些噪声,噪声是从均值为0、标准差为0.1的正态分布中随机抽取的。这一步是为了模拟真实世界中的数据,使得数据不完全符合多项式模型。
- 举例说明
假设我们有一个特征数据 features = [1.0, 2.0, 3.0]
,并且 max_degree = 4
,那么:
-
poly_features
将会是:[ [
[1^0, 1^1, 1^2, 1^3] [1, 1, 1, 1]
[2^0, 2^1, 2^2, 2^3] = [1, 2, 4, 8]
[3^0, 3^1, 3^2, 3^3] [1, 3, 9, 27]
] ]
-
归一化后(假设
gamma(i+1)
为i!
):[ [
[1 / 0!!, 1 / 1!, 1 / 2!, 1 / 3!] [1, 1, 0.5, 0.1667]
[1 / 0!, 2 / 1!, 4 / 2!, 8 / 3!] = [1, 2, 2, 1.3333]
[1 / 0!, 3 / 1!, 9 / 2!, 27 / 3!] [1, 3, 4.5, 4.5]
] ]
-
假设
true_w = [5, 1.2, -3.4, 5.6]
,那么标签labels
将会是:[
[1∗5+1∗1.2+0.5∗(−3.4)+0.1667∗5.6]
[1∗5+2∗1.2+2∗(−3.4)+1.3333∗5.6]
[1∗5+3∗1.2+4.5∗(−3.4)+4.5∗5.6]
]
注:刚开始没理解np.dot(poly_features, true_w)中的两个张量怎么点积,没想到是矩阵乘法,更准确地说是矩阵-向量积,poly_features的维度是(200,20),true_w的维度是(20,),运算之后结果的维度是(200,)
-
最后,标签会加入一些噪声。
- 总结
这段代码生成了一个多项式回归问题的数据集,包括特征数据和标签。特征数据被转换为多项式特征,并通过真实的多项式系数生成标签,最后加入了一些噪声以模拟真实数据。这个数据集可以用于训练和测试多项式回归模型。
同样,存储在poly_features
中的单项式由gamma函数重新缩放, 其中Γ(n)=(n−1)!。 从生成的数据集中查看一下前2个样本, 第一个值是与偏置相对应的常量特征。
# NumPy ndarray转换为tensor
true_w, features, poly_features, labels = [torch.tensor(x, dtype=
torch.float32) for x in [true_w, features, poly_features, labels]]
features[:2], poly_features[:2, :], labels[:2]
4.4.4.2 对模型进行训练和测试
首先让我们实现一个函数来评估模型在给定数据集上的平均损失。
def evaluate_loss(net, data_iter, loss): #@save
"""评估给定数据集上模型的损失"""
metric = d2l.Accumulator(2) # 损失的总和,样本数量
for X, y in data_iter:
out = net(X)
y = y.reshape(out.shape)
l = loss(out, y)
metric.add(l.sum(), l.numel())
return metric[0] / metric[1]
定义训练函数
def train(train_features, test_features, train_labels, test_labels,
num_epochs=400):
loss = nn.MSELoss(reduction='none')
input_shape = train_features.shape[-1]
# 不设置偏置,因为我们已经在多项式中实现了它
net = nn.Sequential(nn.Linear(input_shape, 1, bias=False))
batch_size = min(10, train_labels.shape[0])
train_iter = d2l.load_array((train_features, train_labels.reshape(-1,1)),
batch_size)
test_iter = d2l.load_array((test_features, test_labels.reshape(-1,1)),
batch_size, is_train=False)
trainer = torch.optim.SGD(net.parameters(), lr=0.01)
animator = d2l.Animator(xlabel='epoch', ylabel='loss', yscale='log',
xlim=[1, num_epochs], ylim=[1e-3, 1e2],
legend=['train', 'test'])
for epoch in range(num_epochs):
d2l.train_epoch_ch3(net, train_iter, loss, trainer)
if epoch == 0 or (epoch + 1) % 20 == 0:
animator.add(epoch + 1, (evaluate_loss(net, train_iter, loss),
evaluate_loss(net, test_iter, loss)))
print('weight:', net[0].weight.data.numpy())
代码解读:
1、input_shape = train_features.shape[-1]
取train_features.shape的最后一个维度作为输入个数,需要结合真正调用train函数时来解释,看下面真正调用时再来回看这里,这个train_features的维度应该是(100,4),那么input_shape就应该是 4!结合式4.4.2,确实是 4 个输入,分别是 0 次方,1次方,2次方和3次方。
2、batch_size = min(10, train_labels.shape[0])
batch_size其实就是批量大小,因为 train_labels.shape为(100,)也就是说有100个训练标签,批量大小为 10,但如果训练标签的数量不足 10 个呢?那肯定就是取最小值了。
4.4.4.3 三阶拟合正常
我们将首先使用三阶多项式函数,它与数据生成函数的阶数相同。 结果表明,该模型能有效降低训练损失和测试损失。 学习到的模型参数也接近真实值w=[5,1.2,−3.4,5.6]。
# 从多项式特征中选择前4个维度,即1,x,x^2/2!,x^3/3!
train(poly_features[:n_train, :4], poly_features[n_train:, :4],
labels[:n_train], labels[n_train:])
输出(结合train函数定义的最后一行,也就是第 21 行来看):
weight: [[ 5.010476 1.2354498 -3.4229028 5.503297 ]]
4.4.4.4 线性函数拟合(欠拟合)
# 从多项式特征中选择前2个维度,即1和x
train(poly_features[:n_train, :2], poly_features[n_train:, :2],
labels[:n_train], labels[n_train:])
输出:
weight: [[3.4049764 3.9939284]]
4.4.4.5 高阶多项式函数拟合(过拟合)
# 从多项式特征中选取所有维度
train(poly_features[:n_train, :], poly_features[n_train:, :],
labels[:n_train], labels[n_train:], num_epochs=1500)
输出:
weight: [[ 4.9849787 1.2896876 -3.2996354 5.145749 -0.34205326 1.2237961
0.20393135 0.3027379 -0.20079008 -0.16337848 0.11026663 0.21135856
-0.00940325 0.11873583 -0.15114897 -0.05347819 0.17096086 0.1863975
-0.09107699 -0.02123026]]
在接下来的章节中,我们将继续讨论过拟合问题和处理这些问题的方法,例如权重衰减和dropout。
4.4.5 小结
- 欠拟合是指模型无法继续减少训练误差。过拟合是指训练误差远小于验证误差。
- 由于不能基于训练误差来估计泛化误差,因此简单地最小化训练误差并不一定意味着泛化误差的减小。机器学习模型需要注意防止过拟合,即防止泛化误差过大。
- 验证集可以用于模型选择,但不能过于随意地使用它。
- 我们应该选择一个复杂度适当的模型,避免使用数量不足的训练样本。
4.5 权重衰减
前一节我们描述了过拟合的问题,本节我们将介绍一些正则化模型的技术。 我们总是可以通过去收集更多的训练数据来缓解过拟合。 但这可能成本很高,耗时颇多,或者完全超出我们的控制,因而在短期内不可能做到。 假设我们已经拥有尽可能多的高质量数据,我们便可以将重点放在正则化技术上。
回想一下,在多项式回归的例子( 4.4节)中, 我们可以通过调整拟合多项式的阶数来限制模型的容量。 实际上,限制特征的数量是缓解过拟合的一种常用技术。 然而,简单地丢弃特征对这项工作来说可能过于生硬。 我们继续思考多项式回归的例子,考虑高维输入可能发生的情况。 多项式对多变量数据的自然扩展称为单项式(monomials), 也可以说是变量幂的乘积。 单项式的阶数是幂的和。 例如,x12x2和x3x52都是3次单项式。
注意,随着阶数d的增长,带有阶数d的项数迅速增加。 给定k个变量,阶数为d的项的个数为
即
因此即使是阶数上的微小变化,比如从2到3,也会显著增加我们模型的复杂性。 仅仅通过简单的限制特征数量(在多项式回归中体现为限制阶数),可能仍然使模型在过简单和过复杂中徘徊, 我们需要一个更细粒度的工具来调整函数的复杂性,使其达到一个合适的平衡位置。
在2.3.10节中, 我们已经描述了L2范数和L1范数, 它们是更为一般的Lp范数的特殊情况。
在训练参数化机器学习模型时, 权重衰减(weight decay)是最广泛使用的正则化的技术之一, 它通常也被称为L2正则化。 这项技术通过函数与零的距离来衡量函数的复杂度, 因为在所有函数f中,函数f=0(所有输入都得到值0) 在某种意义上是最简单的。 但是我们应该如何精确地测量一个函数和零之间的距离呢? 没有一个正确的答案。 事实上,函数分析和巴拿赫空间理论的研究,都在致力于回答这个问题。
一种简单的方法是通过线性函数 f(x)=w^⊤x 中的权重向量的某个范数来度量其复杂性, 例如‖w‖^2。 要保证权重向量比较小, 最常用方法是将其范数作为惩罚项加到最小化损失的问题中。 将原来的训练目标最小化训练标签上的预测损失, 调整为最小化预测损失和惩罚项之和。 现在,如果我们的权重向量增长的太大, 我们的学习算法可能会更集中于最小化权重范数‖w‖^2。 这正是我们想要的。 让我们回顾一下 3.1节中的线性回归例子。 我们的损失由下式给出:
回想一下,x^(i)是样本i的特征, y^(i)是样本i的标签, (w,b)是权重和偏置参数。 为了惩罚权重向量的大小, 我们必须以某种方式在损失函数中添加‖w‖^2, 但是模型应该如何平衡这个新的额外惩罚的损失? 实际上,我们通过**正则化常数 ** λ 来描述这种权衡, 这是一个非负超参数,我们使用验证数据拟合:
对于λ=0,我们恢复了原来的损失函数。 对于λ>0,我们限制‖w‖的大小。 这里我们仍然除以2:当我们取一个二次函数的导数时, 2和1/2会抵消,以确保更新表达式看起来既漂亮又简单。 为什么在这里我们使用平方范数而不是标准范数(即欧几里得距离)? 我们这样做是为了便于计算。 通过平方L2范数,我们去掉平方根,留下权重向量每个分量的平方和。 这使得惩罚的导数很容易计算:导数的和等于和的导数。
此外,为什么我们首先使用L2范数,而不是L1范数。 事实上,这个选择在整个统计领域中都是有效的和受欢迎的。 L2正则化线性模型构成经典的岭回归(ridge regression)算法, L1正则化线性回归是统计学中类似的基本模型, 通常被称为套索回归(lasso regression)。 使用L2范数的一个原因是它对权重向量的大分量施加了巨大的惩罚。 这使得我们的学习算法偏向于在大量特征上均匀分布权重的模型。 在实践中,这可能使它们对单个变量中的观测误差更为稳定。 相比之下,L1惩罚会导致模型将权重集中在一小部分特征上, 而将其他权重清除为零。 这称为特征选择(feature selection),这可能是其他场景下需要的。
使用与 (3.1.10)中的相同符号, L2正则化回归的小批量随机梯度下降更新如下式:
根据之前章节所讲的,我们根据估计值与观测值之间的差异来更新w。 然而,我们同时也在试图将w的大小缩小到零。 这就是为什么这种方法有时被称为权重衰减。 我们仅考虑惩罚项,优化算法在训练的每一步衰减权重。 与特征选择相比,权重衰减为我们提供了一种连续的机制来调整函数的复杂度。 较小的λ值对应较少约束的w, 而较大的λ值对w的约束更大。
是否对相应的偏置b^2进行惩罚在不同的实践中会有所不同, 在神经网络的不同层中也会有所不同。 通常,网络输出层的偏置项不会被正则化。
4.5.1 高维线性回归
我们通过一个简单的例子来演示权重衰减。
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
首先,我们像以前一样生成一些数据,生成公式如下:
我们选择标签是关于输入的线性函数。 标签同时被均值为0,标准差为0.01高斯噪声破坏。 为了使过拟合的效果更加明显,我们可以将问题的维数增加到d=200, 并使用一个只包含20个样本的小训练集。
n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5
true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05
train_data = d2l.synthetic_data(true_w, true_b, n_train)
train_iter = d2l.load_array(train_data, batch_size)
test_data = d2l.synthetic_data(true_w, true_b, n_test)
test_iter = d2l.load_array(test_data, batch_size, is_train=False)
4.5.2 从零开始实现
下面我们将从头开始实现权重衰减,只需将L2的平方惩罚添加到原始目标函数中。
4.5.2.1 初始化模型参数
首先,我们将定义一个函数来随机初始化模型参数。
def init_params():
w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)
return [w, b]
4.5.2.2 定义L2范数惩罚
实现这一惩罚最方便的方法是对所有项求平方后并将它们求和。
def l2_penalty(w):
return torch.sum(w.pow(2)) / 2
4.5.2.3 定义训练代码实现
下面的代码将模型拟合训练数据集,并在测试数据集上进行评估。 通过d2l.linreg
和d2l.squared_loss
导入线性网络和平方损失。 唯一的变化是损失现在包括了惩罚项。
def train(lambd):
w, b = init_params()
net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
num_epochs, lr = 100, 0.003
animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
for epoch in range(num_epochs):
for X, y in train_iter:
# 增加了L2范数惩罚项,
# 广播机制使l2_penalty(w)成为一个长度为batch_size的向量
l = loss(net(X), y) + lambd * l2_penalty(w)
l.sum().backward()
d2l.sgd([w, b], lr, batch_size)
if (epoch + 1) % 5 == 0:
animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss),
d2l.evaluate_loss(net, test_iter, loss)))
print('w的L2范数是:', torch.norm(w).item())
代码解读:
net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
net
:
这是一个匿名函数(lambda
函数),定义了模型的前向传播过程。d2l.linreg(X, w, b)
是线性回归模型的计算公式:y_hat = X @ w + b
,其中X
是输入数据,w
是权重,b
是偏置。net(X)
的作用是计算模型的预测值y_hat
。
loss
:
这是损失函数,用于衡量模型预测值与真实值之间的差距。d2l.squared_loss
是平方损失函数.- 其中
y_hat
是模型的预测值,y
是真实值。
torch.norm(w).item()
torch.norm(w)
计算权重w
的 L2 范数,在2.3.10节有笔记,这里主要就是把norm()函数和normal()函数给搞混了。.item()
将张量转换为 Python 标量(单个数值)。- 这里输出的 L2 范数反映了权重的大小,L2 正则化的作用就是限制权重的大小,防止过拟合。
4.5.2.4 忽略正则化进行训练
我们现在用lambd = 0
禁用权重衰减后运行这个代码。 注意,这里训练误差有了减少,但测试误差没有减少, 这意味着出现了严重的过拟合。
train(lambd=0)
输出:
w的L2范数是: 12.963241577148438
4.5.2.5 使用权重衰减
下面,我们使用权重衰减来运行代码。 注意,在这里训练误差增大,但测试误差减小。 这正是我们期望从正则化中得到的效果。
train(lambd=3)
输出:
w的L2范数是: 0.3556520938873291
4.5.3 简洁实现
由于权重衰减在神经网络优化中很常用, 深度学习框架为了便于我们使用权重衰减, 将权重衰减集成到优化算法中,以便与任何损失函数结合使用。 此外,这种集成还有计算上的好处, 允许在不增加任何额外的计算开销的情况下向算法中添加权重衰减。 由于更新的权重衰减部分仅依赖于每个参数的当前值, 因此优化器必须至少接触每个参数一次。
在下面的代码中,我们在实例化优化器时直接通过weight_decay
指定weight decay超参数。 默认情况下,PyTorch同时衰减权重和偏移。 这里我们只为权重设置了weight_decay
,所以偏置参数b不会衰减。
def train_concise(wd):
net = nn.Sequential(nn.Linear(num_inputs, 1))
for param in net.parameters():
# 遍历模型的所有参数(包括权重和偏置)。
# 将参数的值从标准正态分布(均值为 0,标准差为 1)中随机初始化。
param.data.normal_()
loss = nn.MSELoss(reduction='none')
num_epochs, lr = 100, 0.003
# 偏置参数没有衰减
trainer = torch.optim.SGD([
{"params":net[0].weight,'weight_decay': wd},
{"params":net[0].bias}], lr=lr)
animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
for epoch in range(num_epochs):
for X, y in train_iter:
trainer.zero_grad()
l = loss(net(X), y)
l.mean().backward()
trainer.step()
if (epoch + 1) % 5 == 0:
animator.add(epoch + 1,
(d2l.evaluate_loss(net, train_iter, loss),
d2l.evaluate_loss(net, test_iter, loss)))
print('w的L2范数:', net[0].weight.norm().item())
代码解读:
- 定义优化器
trainer = torch.optim.SGD([
{"params":net[0].weight,'weight_decay': wd},
{"params":net[0].bias}], lr=lr)
- 使用随机梯度下降(SGD)优化器。
- 通过一个列表传入需要优化的参数:
net[0].weight
:线性层的权重参数,并为其设置weight_decay=wd
,即 L2 正则化系数。net[0].bias
:线性层的偏置参数,不设置weight_decay
(即偏置参数不进行 L2 正则化)。
lr=lr
设置优化器的学习率。
这些图看起来和我们从零开始实现权重衰减时的图相同。 然而,它们运行得更快,更容易实现。 对于更复杂的问题,这一好处将变得更加明显。
train_concise(0)
输出:
w的L2范数: 13.727912902832031
train_concise(3)
输出:
w的L2范数: 0.3890590965747833
小结
- 正则化是处理过拟合的常用方法:在训练集的损失函数中加入惩罚项,以降低学习到的模型的复杂度。
- 保持模型简单的一个特别的选择是使用L2惩罚的权重衰减。这会导致学习算法更新步骤中的权重衰减。
- 权重衰减功能在深度学习框架的优化器中提供。
- 在同一训练代码实现中,不同的参数集可以有不同的更新行为。
4.6 暂退法(Dropout)
4.6.1 重新审视过拟合
当面对更多的特征而样本不足时,线性模型往往会过拟合。 相反,当给出更多样本而不是特征,通常线性模型不会过拟合。 不幸的是,线性模型泛化的可靠性是有代价的。 简单地说,线性模型没有考虑到特征之间的交互作用。 对于每个特征,线性模型必须指定正的或负的权重,而忽略其他特征。
泛化性和灵活性之间的这种基本权衡被描述为偏差-方差权衡(bias-variance tradeoff)。 线性模型有很高的偏差:它们只能表示一小类函数。 然而,这些模型的方差很低:它们在不同的随机数据样本上可以得出相似的结果。
深度神经网络位于偏差-方差谱的另一端。 与线性模型不同,神经网络并不局限于单独查看每个特征,而是学习特征之间的交互。 例如,神经网络可能推断“尼日利亚”和“西联汇款”一起出现在电子邮件中表示垃圾邮件, 但单独出现则不表示垃圾邮件。
即使我们有比特征多得多的样本,深度神经网络也有可能过拟合。 2017年,一组研究人员通过在随机标记的图像上训练深度网络。 这展示了神经网络的极大灵活性,因为人类很难将输入和随机标记的输出联系起来, 但通过随机梯度下降优化的神经网络可以完美地标记训练集中的每一幅图像。 想一想这意味着什么? 假设标签是随机均匀分配的,并且有10个类别,那么分类器在测试数据上很难取得高于10%的精度, 那么这里的泛化差距就高达90%,如此严重的过拟合。
4.6.2 扰动的稳健性
在探究泛化性之前,我们先来定义一下什么是一个“好”的预测模型? 我们期待“好”的预测模型能在未知的数据上有很好的表现: 经典泛化理论认为,为了缩小训练和测试性能之间的差距,应该以简单的模型为目标。 简单性以较小维度的形式展现, 我们在 4.4节 讨论线性模型的单项式函数时探讨了这一点。 此外,正如我们在 4.5节 中讨论权重衰减(L2正则化)时看到的那样, 参数的范数也代表了一种有用的简单性度量。
简单性的另一个角度是平滑性,即函数不应该对其输入的微小变化敏感。 例如,当我们对图像进行分类时,我们预计向像素添加一些随机噪声应该是基本无影响的。 1995年,克里斯托弗·毕晓普证明了 具有输入噪声的训练等价于Tikhonov正则化 (Bishop, 1995)。 这项工作用数学证实了“要求函数光滑”和“要求函数对输入的随机噪声具有适应性”之间的联系。
然后在2014年,斯里瓦斯塔瓦等人 (Srivastava et al., 2014) 就如何将毕晓普的想法应用于网络的内部层提出了一个想法: 在训练过程中,他们建议在计算后续层之前向网络的每一层注入噪声。 因为当训练一个有多层的深层网络时,注入噪声只会在输入-输出映射上增强平滑性。
这个想法被称为暂退法(dropout)。 暂退法在前向传播过程中,计算每一内部层的同时注入噪声,这已经成为训练神经网络的常用技术。 这种方法之所以被称为暂退法,因为我们从表面上看是在训练过程中丢弃(drop out)一些神经元。 在整个训练过程的每一次迭代中,标准暂退法包括在计算下一层之前将当前层中的一些节点置零。
需要说明的是,暂退法的原始论文提到了一个关于有性繁殖的类比: 神经网络过拟合与每一层都依赖于前一层激活值相关,称这种情况为“共适应性”。 作者认为,暂退法会破坏共适应性,就像有性生殖会破坏共适应的基因一样。
那么关键的挑战就是如何注入这种噪声。 一种想法是以一种无偏向(unbiased)的方式注入噪声。 这样在固定住其他层时,每一层的期望值等于没有噪音时的值。
在毕晓普的工作中,他将高斯噪声添加到线性模型的输入中。 在每次训练迭代中,他将从均值为零的分布ϵ∼N(0,σ2) 采样噪声添加到输入x, 从而产生扰动点x′=x+ϵ, 预期是E[x′]=x。
在标准暂退法正则化中,通过按保留(未丢弃)的节点的分数进行规范化来消除每一层的偏差。 换言之,每个中间活性值h以暂退概率p由随机变量h′替换,如下所示:
根据此模型的设计,其期望值保持不变,即E[h′]=h。
4.6.3 实践中的暂退法
回想一下 图4.1.1中带有1个隐藏层和5个隐藏单元的多层感知机。 当我们将暂退法应用到隐藏层,以p的概率将隐藏单元置为零时, 结果可以看作一个只包含原始神经元子集的网络。 比如在 图4.6.1中,删除了h2和h5, 因此输出的计算不再依赖于h2或h5,并且它们各自的梯度在执行反向传播时也会消失。 这样,输出层的计算不能过度依赖于h1,…,h5的任何一个元素。
图4.6.1 dropout前后的多层感知机
通常,我们在测试时不用暂退法。 给定一个训练好的模型和一个新的样本,我们不会丢弃任何节点,因此不需要标准化。 然而也有一些例外:一些研究人员在测试时使用暂退法, 用于估计神经网络预测的“不确定性”: 如果通过许多不同的暂退法遮盖后得到的预测结果都是一致的,那么我们可以说网络发挥更稳定。
4.6.4 从零开始实现
要实现单层的暂退法函数, 我们从均匀分布U[0,1]中抽取样本,样本数与这层神经网络的维度一致。 然后我们保留那些对应样本大于p的节点,把剩下的丢弃。
在下面的代码中,我们实现 dropout_layer
函数, 该函数以dropout
的概率丢弃张量输入X
中的元素, 如上所述重新缩放剩余部分:将剩余部分除以1.0-dropout
。
import torch
from torch import nn
from d2l import torch as d2l
def dropout_layer(X, dropout):
assert 0 <= dropout <= 1
# 在本情况中,所有元素都被丢弃
if dropout == 1:
return torch.zeros_like(X)
# 在本情况中,所有元素都被保留
if dropout == 0:
return X
mask = (torch.rand(X.shape) > dropout).float()
return mask * X / (1.0 - dropout)
代码解读:
- 检查
dropout
的合法性:assert 0 <= dropout <= 1
- 使用
assert
确保dropout
的值在[0, 1]
之间。如果不在这个范围内,程序会抛出异常。
- 生成随机掩码:
mask = (torch.rand(X.shape) > dropout).float()
torch.rand(X.shape)
:生成一个与X
形状相同的随机张量,每个元素的值在[0, 1)
之间均匀分布。(torch.rand(X.shape) > dropout)
:将随机值与dropout
比较,生成一个布尔张量。如果随机值大于dropout
,对应位置的值为True
,否则为False
。.float()
:将布尔张量转换为浮点张量(True
变为1.0
,False
变为0.0
)。这个张量就是掩码(mask
),用于决定哪些神经元被保留,哪些被丢弃。
我们可以通过下面几个例子来测试dropout_layer
函数。 我们将输入X
通过暂退法操作,暂退概率分别为0、0.5和1。
X= torch.arange(16, dtype = torch.float32).reshape((2, 8))
print(X)
print(dropout_layer(X, 0.))
print(dropout_layer(X, 0.5))
print(dropout_layer(X, 1.))
输出:
tensor([[ 0., 1., 2., 3., 4., 5., 6., 7.],
[ 8., 9., 10., 11., 12., 13., 14., 15.]])
tensor([[ 0., 1., 2., 3., 4., 5., 6., 7.],
[ 8., 9., 10., 11., 12., 13., 14., 15.]])
tensor([[ 0., 2., 0., 6., 0., 0., 0., 14.],
[16., 18., 0., 22., 0., 26., 28., 30.]])
tensor([[0., 0., 0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0., 0., 0.]])
4.6.4.1 定义模型参数
同样,我们使用 3.5节中引入的Fashion-MNIST数据集。 我们定义具有两个隐藏层的多层感知机,每个隐藏层包含256个单元。
4.6.4.2 定义模型
我们可以将暂退法应用于每个隐藏层的输出(在激活函数之后), 并且可以为每一层分别设置暂退概率: 常见的技巧是在靠近输入层的地方设置较低的暂退概率。 下面的模型将第一个和第二个隐藏层的暂退概率分别设置为0.2和0.5, 并且暂退法只在训练期间有效。
dropout1, dropout2 = 0.2, 0.5
class Net(nn.Module):
def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2,
is_training = True):
super(Net, self).__init__()
self.num_inputs = num_inputs
self.training = is_training
self.lin1 = nn.Linear(num_inputs, num_hiddens1)
self.lin2 = nn.Linear(num_hiddens1, num_hiddens2)
self.lin3 = nn.Linear(num_hiddens2, num_outputs)
self.relu = nn.ReLU()
def forward(self, X):
H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
# 只有在训练模型时才使用dropout
if self.training == True:
# 在第一个全连接层之后添加一个dropout层
H1 = dropout_layer(H1, dropout1)
H2 = self.relu(self.lin2(H1))
if self.training == True:
# 在第二个全连接层之后添加一个dropout层
H2 = dropout_layer(H2, dropout2)
out = self.lin3(H2)
return out
net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)
代码解读:
前向传播函数 forward
- 输入:
X
是输入数据。 - 操作步骤:
- 第一层:
X.reshape((-1, self.num_inputs))
:将输入X
重塑为二维张量,形状为(batch_size, num_inputs)
。self.lin1(...)
:通过第一个全连接层。self.relu(...)
:应用 ReLU 激活函数。if self.training == True
:如果模型处于训练模式,对第一层的输出H1
应用 Dropout。
- 第二层:
self.lin2(H1)
:通过第二个全连接层。self.relu(...)
:应用 ReLU 激活函数。if self.training == True
:如果模型处于训练模式,对第二层的输出H2
应用 Dropout。
- 输出层:
self.lin3(H2)
:通过第三个全连接层,得到最终的输出out
。
- 返回:返回输出
out
。
- 第一层:
4.6.4.3 训练和测试
num_epochs, lr, batch_size = 10, 0.5, 256
loss = nn.CrossEntropyLoss(reduction='none')
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
4.6.5 简洁实现
对于深度学习框架的高级API,我们只需在每个全连接层之后添加一个Dropout
层, 将暂退概率作为唯一的参数传递给它的构造函数。 在训练时,Dropout
层将根据指定的暂退概率随机丢弃上一层的输出(相当于下一层的输入)。 在测试时,Dropout
层仅传递数据。
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
# 在第一个全连接层之后添加一个dropout层
nn.Dropout(dropout1),
nn.Linear(256, 256),
nn.ReLU(),
# 在第二个全连接层之后添加一个dropout层
nn.Dropout(dropout2),
nn.Linear(256, 10))
def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights);
接下来,我们对模型进行训练和测试。
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
4.6.6 小结
- 暂退法在前向传播过程中,计算每一内部层的同时丢弃一些神经元。
- 暂退法可以避免过拟合,它通常与控制权重向量的维数和大小结合使用的。
- 暂退法将活性值h替换为具有期望值h的随机变量。
- 暂退法仅在训练期间使用。