原书地址:https://zh-v2.d2l.ai/chapter_preliminaries/lookup-api.html
预备知识
背景:所有的机器学习方法都涉及从数据中提取信息。因此,我们首先将学习一些实用技能,包括存储、操作和预处理数据。
机器学习通常需要处理大型数据集。我们可以将数据集视为表,其中表的行对应于样本,列对应于属性。将重点放在矩阵运算的基本原理及其实现上。
深度学习是关于优化的。我们有一个带有参数的模型,我们想要找到那些能拟合数据的最好模型。在算法的每个步骤中,决定以何种方式调整参数需要一点微积分知识。autograd包会自动为我们计算微分,在本节中我们也将介绍它。
2.1. 数据操作
为了能够完成各种操作,我们需要某种方法来存储和操作数据。一般来说,我们需要做两件重要的事情:(1)获取数据;(2)在数据读入计算机后对其进行处理。如果没有某种方法来存储数据,那么获取数据是没有意义的。我们先尝试一个合成数据。首先,我们介绍[Math Processing Error]维数组,也称为 张量(tensor)。
如果你使用过 Python 中最广泛使用的科学计算包 NumPy,那么你会感觉怼本部分很熟悉。无论你使用哪个框架,它的 张量类(在 MXNet 中为 ndarray,在 PyTorch 和TensorFlow中为 Tensor)与 Numpy 的 ndarray 类似,但都比Numpy 的 ndarray多一些重要功能。首先,GPU 很好地支持加速计算,而 NumPy 仅支持 CPU 计算。其次,张量类支持自动微分。这些功能使得张量类更适合深度学习。除非另有说明,在整本书中所说的张量指的是张量类的实例。
2.1.1. 入门
pytorch:
张量表示一个数值组成的数组,这个数组可能有多个维度。具有一个轴的张量对应于数学上的 向量(vector)。具有两个轴的张量对应于数学上的 矩阵(matrix)。具有两个轴以上的张量没有特殊的数学名称。张量中的每个值都称为张量的 元素(element)。除非额外指定,新的张量将存储在内存中,并采用基于CPU的计算。
#使用 arange 创建一个行向量 x。这个行向量包含以0开始的前12个整数,它们默认创建为浮点数。
import torch
x = torch.arange(12)
x
tensor([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
#可以通过张量的 shape 属性来访问张量的 形状 (沿每个轴的长度)
#求张量中元素的总数x.numel()
x.shape
torch.Size([12])
要重点说明一下,虽然形状发生了改变,但元素值没有变。注意,通过改变张量的形状,张量的大小不会改变
张量在给出其他部分后可以自动计算出一个维度
#调用 reshape 函数改变一个张量的形状而不改变元素数量和元素值。 例如,把张量 x 从形状为 (12, ) 的行向量转换为形状 (3, 4) 的矩阵。这个新的张量包含与转换前相同的值,但是把它们看成一个三行四列的矩阵。
X = x.reshape(3, 4)#x.reshape(-1, 4) or x.reshape(3, -1)
tensor([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
使用全0、全1、其他常量或者从特定分布中随机采样的数字,来初始化矩阵。
#创建一个形状为 (2, 3, 4) 的张量,其中所有元素都设置为0。
torch.zeros((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.6624, 0.6089, 2.1315, -0.4236],
[ 0.2953, -0.0027, -1.6279, -1.0125],
[-0.2773, 0.5857, 0.2215, 1.7032]])
通过提供包含数值的 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. 运算
在这些数组上执行数学运算。一些最简单且最有用的操作是 按元素(elementwise) 操作。它们将标准标量运算符应用于数组的每个元素。对于将两个数组作为输入的函数,按元素运算将二元运算符应用于两个数组中的每对位置对应的元素。我们可以基于任何从标量到标量的函数来创建按元素函数。
对于任意具有相同形状的张量,常见的标准算术运算符(+、-、*、/ 和 **)都可以被升级为按元素运算。我们可以在同一形状的任意两个张量上调用按元素操作。
#使用逗号来表示一个具有5个元素的元组,其中每个元素都是按元素操作的结果。
x = np.array([1, 2, 4, 8])
y = np.array([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x**y # **运算符是求幂运算
np.exp(x) #按元素方式应用更多的计算,包括像求幂这样的一元运算符
执行线性代数运算,包括向量点积和矩阵乘法。
也可以把多个张量连结在一起,把它们端对端地叠起来形成一个更大的张量。只需要提供张量列表,并给出沿哪个轴连结。
#下面的例子分别演示了当我们沿行(轴-0,形状的第一个元素)和按列(轴-1,形状的第二个元素)连结两个矩阵时会发生什么情况。
X = np.arange(12).reshape(3, 4)
Y = np.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
np.concatenate([X, Y], axis=0), np.concatenate([X, Y], axis=1)
(array([[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[ 2., 1., 4., 3.],
[ 1., 2., 3., 4.],
[ 4., 3., 2., 1.]]),
array([[ 0., 1., 2., 3., 2., 1., 4., 3.],
[ 4., 5., 6., 7., 1., 2., 3., 4.],
[ 8., 9., 10., 11., 4., 3., 2., 1.]]))
通过 逻辑运算符 构建二元张量
x==y
array([[False, True, False, True],
[False, False, False, False],
[False, False, False, False]])
对张量中的所有元素进行求和会产生一个只有一个元素的张量
X.sum()
array(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. 索引和切片
第一个元素的索引是 0;可以指定范围以包含第一个元素和最后一个之前的元素。
#可以用 [-1] 选择最后一个元素,可以用 [1:3] 选择第二个和第三个元素
X[-1], X[1:3]
除读取外,还可以通过指定索引来将元素写入矩阵
X[1, 2] = 9
为多个元素赋值相同的值,我们只需要索引所有元素,然后为它们赋值
#[0:2, :] 访问第1行和第2行,其中 “:” 代表沿轴 1(列)的所有元素。
X[0:2, :] = 12
2.1.5. 节省内存
运行一些操作可能会导致为新结果分配内存。例如,如果我们用 Y = X + Y,我们将取消引用 Y 指向的张量,而是指向新分配的内存处的张量。
#在下面的例子中,我们用 Python 的 id() 函数演示了这一点,它给我们提供了内存中引用对象的确切地址。运行 Y = Y + X 后,我们会发现 id(Y) 指向另一个位置。这是因为 Python 首先计算 Y + X,为结果分配新的内存,然后使 Y 指向内存中的这个新位置。
before = id(Y)
Y = Y + X
id(Y) == before
False
这可能是不可取的,原因有两个:首先,我们不想总是不必要地分配内存。在机器学习中,我们可能有数百兆的参数,并且在一秒内多次更新所有参数。通常情况下,我们希望原地执行这些更新。其次,我们可能通过多个变量指向相同参数。如果我们不原地更新,其他引用仍然会指向旧的内存位置,这样我们的某些代码可能会无意中引用旧的参数。
执行原地操作非常简单。我们可以使用切片表示法将操作的结果分配给先前分配的数组,例如 Y[:] = 。
Z = torch.zeros_like(Y)
print('id(Z):', id(Z))
Z[:] = X + Y
print('id(Z):', id(Z))
id(Z): 139621644254592
id(Z): 139621644254592
如果在后续计算中没有重复使用 X,我们也可以使用 X[:] = X + Y 或 X += Y 来减少操作的内存开销。
before = id(X)
X += Y
id(X) == before
True
2.1.6. 转换为其他 Python 对象
转换为 NumPy 张量很容易,反之也很容易。转换后的结果不共享内存。 这个小的不便实际上是非常重要的:当你在 CPU 或 GPU 上执行操作的时候,此时Python的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.1.7. 小结
深度学习存储和操作数据的主要接口是张量( n 维数组)。它提供了各种功能,包括基本数学运算、广播、索引、切片、内存节省和转换其他 Python 对象。
2.2. 数据预处理
简要介绍使用 pandas 预处理原始数据并将原始数据转换为张量格式的步骤.
注意,注释 #@save是一个特殊的标记,该标记下方的函数、类或语句将保存在 d2l 软件包中,以便以后可以直接调用它们.
2.2.1. 读取数据集
导入 pandas 包并调用 read_csv 函数
2.2.2. 处理缺失值
“NaN” 项代表缺失值。为了处理缺失的数据,典型的方法包括 插值 和 删除,其中插值用替代值代替缺失值。而删除则忽略缺失值。在这里,我们将考虑插值。
通过位置索引iloc,我们将 data 分成 inputs 和 outputs,其中前者为 data的前两列,而后者为 data的最后一列。对于 inputs 中缺少的的数值,我们用同一列的均值替换 “NaN” 项。
对于 inputs 中的类别值或离散值,我们将 “NaN” 视为一个类别。由于 “巷子”(“Alley”)列只接受两种类型的类别值 “Alley” 和 “NaN”,pandas 可以自动将此列转换为两列 “Alley_Pave” 和 “Alley_nan”。巷子类型为 “Pave” 的行会将“Alley_Pave”的值设置为1,“Alley_nan”的值设置为0。缺少巷子类型的行会将“Alley_Pave”和“Alley_nan”分别设置为0和1。
2.2.3. 转换为张量格式
torch.tensor(ndarray)
2.2.4. 小结
- pandas 可以与张量兼容。
- 插值和删除可用于处理缺失的数据。
2.3. 线性代数
2.3.1. 标量
2.3.2. 向量
2.3.3. 矩阵
2.3.4. 张量
就像向量是标量的推广,矩阵是向量的推广一样,我们可以构建具有更多轴的数据结构。张量(本小节中的 “张量” 指代数对象)为我们提供了描述具有任意数量轴的 n 维数组的通用方法.
当我们开始处理图像时,张量将变得更加重要,图像以 n 维数组形式出现,其中3个轴对应于高度、宽度,以及一个通道(channel)轴,用于堆叠颜色通道(红色、绿色和蓝色)。
2.3.5. 张量算法的基本性质
将张量乘以或加上一个标量不会改变张量的形状,其中张量的每个元素都将与标量相加或相乘。
2.3.6. 汇总
可以指定求和汇总张量的轴。以矩阵为例。为了通过求和所有行的元素来汇总行维度(轴0),我们可以在调用函数时指定axis=0。 由于输入矩阵沿0轴汇总以生成输出向量,因此输入的轴0的维数在输出形状中丢失。
A.mean(axis=0), A.sum(axis=0) / A.shape[0]
沿着行和列对矩阵求和,等价于对矩阵的所有元素进行求和.
A.mean(), A.sum() / A.numel()
沿某个轴计算 A 元素的累积总和,比如 axis=0(按行计算),我们可以调用 cumsum 函数。此函数不会沿任何轴汇总输入张量
A.cumsum(axis=0)
2.3.7. 点积(Dot Product)
可以通过执行按元素乘法,然后进行求和来表示两个向量的点积:
y = torch.ones(4, dtype=torch.float32)
x, y, torch.dot(x, y)
torch.sum(x * y)
2.3.8. 矩阵-向量积
torch.mv(A, x)
2.3.9. 矩阵-矩阵乘法
torch.mm(A, B)
2.3.10. 范数
一个向量的范数告诉我们一个向量有多大。 这里考虑的 大小(size) 概念不涉及维度,而是分量的大小。
向量范数是将向量映射到标量的函数 f 。欧几里得距离是一个范数:具体而言,它是 L2 范数。假设 n -维向量:math:mathbf{x}中的元素是 x1,…,xn 的 L2 范数 是向量元素平方和的平方根:
u = torch.tensor([3.0, -4.0])
torch.norm(u)
L1 范数,它表示为向量元素的绝对值之和:
与 L2 范数相比, L1 范数受异常值的影响较小。为了计算 L1 范数,我们将绝对值函数和按元素求和组合起来。
2.3.11. 关于线性代数的更多信息
2.3.12. 小结
-
标量、向量、矩阵和张量是线性代数中的基本数学对象。
-
向量泛化自标量,矩阵泛化自向量。
-
标量、向量、矩阵和张量分别具有零、一、二和任意数量的轴。
-
一个张量可以通过sum 和 mean沿指定的轴汇总。
-
两个矩阵的按元素乘法被称为他们的哈达玛积。它与矩阵乘法不同。
-
在深度学习中,我们经常使用范数,如 L1 范数、 L2 范数和弗罗贝尼乌斯范数。
-
我们可以对标量、向量、矩阵和张量执行各种操作。
2.4. 微分
逼近法就是 积分(integral calculus)的起源.
在微分学最重要的应用是优化问题,这种问题在深度学习中是无处不在的。
在深度学习中,我们“训练”模型,不断更新它们,使它们在看到越来越多的数据时变得越来越好。通常情况下,变得更好意味着最小化一个 损失函数(loss function),即一个衡量“我们的模型有多糟糕”这个问题的分数。这个问题比看上去要微妙得多。最终,我们真正关心的是生成一个能够在我们从未见过的数据上表现良好的模型。但我们只能将模型与我们实际能看到的数据相拟合。因此,我们可以将拟合模型的任务分解为两个关键问题:(1)优化(optimization):用模型拟合观测数据的过程;(2)泛化(generalization):数学原理和实践者的智慧,能够指导我们生成出有效性超出用于训练的数据集本身的模型。
2.4.1. 导数和微分
在深度学习中,我们通常选择对于模型参数可微的损失函数。简而言之,这意味着,对于每个参数, 如果我们把这个参数增加或减少一个无穷小的量,我们可以知道损失会以多快的速度增加或减少,
2.4.2. 偏导数
将微分的思想推广到这些 多元函数 (multivariate function)上
2.4.3. 梯度
我们可以连结一个多元函数对其所有变量的偏导数,以得到该函数的梯度(gradient)向量.
2.4.4. 链式法则
链式法则使我们能够微分复合函数
2.4.5. 小结
-
微分和积分是微积分的两个分支,其中前者可以应用于深度学习中无处不在的优化问题。
-
导数可以被解释为函数相对于其变量的瞬时变化率。它也是函数曲线的切线的斜率。
-
梯度是一个向量,其分量是多变量函数相对于其所有变量的偏导数。
-
链式法则使我们能够微分复合函数。
2.5. 自动求导
求导是几乎所有深度学习优化算法的关键步骤,深度学习框架通过自动计算导数,即 自动求导 (automatic differentiation),来加快这项工作
根据我们设计的模型,系统会构建一个 计算图 (computational graph),来跟踪数据通过若干操作组合起来产生输出。自动求导使系统能够随后反向传播梯度。 这里,反向传播(backpropagate)只是意味着跟踪整个计算图,填充关于每个参数的偏导数。
2.5.1. 一个简单的例子
import torch
x = torch.arange(4.0)
x.requires_grad_(True) # 等价于 x = torch.arange(4.0, requires_grad=True)
x.grad # 默认值是None
y = 2 * torch.dot(x, x)
y
y.backward()
x.grad
# 在默认情况下,PyTorch会累积梯度,我们需要清除之前的值
x.grad.zero_()
y = x.sum()
y.backward()
x.grad
2.5.2. 非标量变量的反向传播
当 y 不是标量时,向量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.])
2.5.3. 分离计算
将某些计算移动到记录的计算图之外。 例如,假设y是作为x的函数计算的,而z则是作为y和x的函数计算的。 现在,想象一下,我们想计算 z 关于 x 的梯度,但由于某种原因,我们希望将 y 视为一个常数,并且只考虑到 x 在y被计算后发挥的作用。
x.grad.zero_()
y = x * x
u = y.detach()
z = u * x
z.sum().backward()
x.grad == u
tensor([True, True, True, True])
2.5.4. Python控制流的梯度计算
使用自动求导的一个好处是,即使构建函数的计算图需要通过 Python控制流(例如,条件、循环或任意函数调用),我们仍然可以计算得到的变量的梯度
2.5.5. 小结
深度学习框架可以自动计算导数。为了使用它,我们首先将梯度附加到想要对其计算偏导数的变量上。然后我们记录目标值的计算,执行它的反向传播函数,并访问得到的梯度
2.6. 概率
在第一种情况中,我们只是不知道哪个。在第二种情况下,结果实际上可能是一个随机的事件
2.6.1. 基本概率论
为了抽取一个样本,我们只需传入一个概率向量。 输出是另一个相同长度的向量:它在索引 i 处的值是采样结果对应于 i 的次数。
fair_probs = torch.ones([6]) / 6
multinomial.Multinomial(1, fair_probs).sample()
一方面,我们可以将 P(X) 表示为随机变量 X 上的 分布(distribution):分布告诉我们 X 获得任意值的概率。另一方面,我们可以简单用 P(a) 表示随机变量取值 a 的概率。由于概率论中的事件是来自样本空间的一组结果,因此我们可以为随机变量指定值的可取范围。
2.6.2. 处理多个随机变量
联合概率: P(A=a,B=b) 。给定任何值 a 和 b , 联合概率可以回答, A=a 和 B=b 同时满足的概率是多少?
条件概率:用 P(B=b∣A=a) 表示它:它是 B=b 的概率,前提是发生了 A=a 。
贝叶斯定理:
边际化:从 P(A,B) 中确定 P(B) 的操作。我们可以看到, B 的概率相当于计算 A 的所有可能选择,并将所有选择的联合概率聚合在一起:
独立性: P(A,B)=P(A)P(B) ,因此两个随机变量是独立的当且仅当两个随机变量的联合分布是其各自分布的乘积。
2.6.3. 期望和差异
2.6.4. 小结
-
我们可以从概率分布中采样。
-
我们可以使用联合分布、条件分布、Bayes 定理、边缘化和独立性假设来分析多个随机变量。
-
期望和方差为概率分布的关键特征的概括提供了实用的度量形式。
2.7. 查阅文档
2.7.1. 查找模块中的所有函数和类
2.7.2. 查找特定函数和类的用法
2.7.3. 小结
2.7.4. 练习
循环神经网络
卷积神经网络可以有效地处理空间信息,循环神经网络(RNN)的设计可以更好地处理序列信息。循环神经网络引入状态变量来存储过去的信息以及当前的输入,以确定当前的输出
络设计的灵感。最后,我们描述了循环神经网络的梯度计算方法,
8.1. 序列模型
8.1.1. 统计工具
数学公式
自回归模型:
两种策略:
1.
缺点:这两种情况都有一个显而易见的问题,即如何生成训练数据。
解决办法:一个经典方法是使用历史观测来预测下一次的观测。
马尔可夫模型
8.1.2. 训练
使用正弦函数和一些加性噪声来生成序列数据,时间步为 1,2,…,1000
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
T = 1000 # 总共产生1000个点
time = torch.arange(1, T + 1, dtype=torch.float32)
x = torch.sin(0.01 * time) + torch.normal(0, 0.2, (T,))
d2l.plot(time, [x], 'time', 'x', xlim=[1, 1000], figsize=(6, 3))
这里的结构相当简单:只有一个多层感知机,有两个全连接层、ReLU激活函数和平方损失
8.1.3. 预测
由于训练损失很小,我们希望我们的模型能够很好地工作。让我们看看这在实践中意味着什么。首先要检查的是模型预测下一时间步发生事情的能力有多好,也就是“单步预测”(one-step-ahead prediction)。
有一个小问题:如果我们只观察序列数据到时间步604,我们不能期望接收到所有未来提前一步预测的输入。相反,我们需要一步一步向前迈进
8.1.4. 小结
内插和外推在难度上有很大差别。因此,如果你有一个序列,在训练时始终要尊重数据的时间顺序,即永远不要对未来的数据进行训练。
序列模型需要专门的统计工具进行估计。两种流行的选择是自回归模型和隐变量自回归模型。
对于因果模型(例如,向前推进的时间),估计正向通常比反向容易得多。
对于直到时间步 t 的观测序列,其在时间步 t+k 的预测输出是“ k 步预测”。随着我们在时间上进一步预测,增加 k ,误差会累积,预测的质量会下降。
8.2. 文本预处理
文本是序列数据最常见例子。例如,一篇文章可以简单地看作是一个单词序列,甚至是一个字符序列。为了方便我们将来对序列数据的实验,我们将在本节中专门解释文本的常见预处理步骤。通常,这些步骤包括:
-
将文本作为字符串加载到内存中。
-
将字符串拆分为标记(如,单词和字符)。
-
建立一个词汇表,将拆分的标记映射到数字索引。
-
将文本转换为数字索引序列,以便模型可以轻松地对其进行操作。
8.2.1. 读取数据集
为简单起见,这里忽略标点符号和大写。
8.2.2. 标记化
以下 tokenize 函数将列表作为输入,列表中的每个元素是文本序列(如,文本行)。每个文本序列被拆分成一个标记列表。标记(token)是文本的基本单位。最后返回一个标记列表,其中每个标记都是一个字符串(string)
def tokenize(lines, token='word'): #@save
"""将文本行拆分为单词或字符标记。"""
if token == 'word':
return [line.split() for line in lines]
elif token == 'char':
return [list(line) for line in lines]
else:
print('错误:未知令牌类型:' + token)
tokens = tokenize(lines)
for i in range(11):
print(tokens[i])
8.2.3. 词汇
标记的字符串类型不方便模型使用,因为模型需要输入数字。现在,让我们构建一个字典,通常也叫做词表(Vocabulary)来将字符串标记映射到从0开始的数字索引中。为此,我们首先统计训练集中所有文档中的唯一标记,即语料(corpus),然后根据每个唯一标记的出现频率为其分配一个数字索引。很少出现的标记通常被移除,这可以降低复杂性。语料库中不存在或已删除的任何标记都将映射到一个特殊的未知标记 “” 。我们可以选择添加保留令牌的列表,例如“”表示填充;“”表示序列的开始;“”表示序列的结束。
8.2.4. 把所有的东西放在一起
使用上述函数,我们将所有内容打包到 load_corpus_time_machine 函数中,该函数返回 corpus(标记索引列表)和 vocab(时光机器语料库的词汇表)。我们在这里所做的修改是: - 1、我们将文本 标记化为字符,而不是单词,以简化后面部分中的训练; - 2、corpus是单个列表,而不是标记列表嵌套,因为时光机器数据集中的每个文本行不一定是句子或段落。
8.2.5. 小结
文本是序列数据的一种重要形式。
为了对文本进行预处理,我们通常将文本拆分为标记,构建词汇表将标记字符串映射为数字索引,并将文本数据转换为标记索引以供模型操作。
8.3. 语言模型和数据集
8.3.1. 学习语言模型
为了计算语言模型,我们需要计算单词的概率和给定前面几个单词时出现该单词的条件概率。这样的概率本质上是语言模型参数。
这里,我们假设训练数据集是一个大型文本语料库,比如所有*条目,古登堡计划,以及发布在网络上的所有文本。可以根据训练数据集中给定词的相对词频来计算词的概率。例如,可以将估计值 P^(deep) 计算为任何以单词“Deep”开头的句子的概率。一种稍微不太准确的方法是统计单词“Deep”的所有出现次数,然后将其除以语料库中的单词总数。这很有效,特别是对于频繁出现的单词。接下来,我们可以尝试估计
问题:对于一些不寻常的单词组合,可能很难找到足够的出现次数来获得准确的估计。
解决:
将这些单词组合指定为非零计数,否则我们将无法在语言模型中使用它们。
一种常见的策略是执行某种形式的拉普拉斯平滑(Laplace smoothing)。解决方案是在所有计数中添加一个小常量。用 n 表示训练集中的单词总数,用 m 表示唯一单词的数量。此解决方案有助于处理个例问题
8.3.2. 马尔可夫模型与 n 元语法
8.3.3. 自然语言统计
英文靠空格分词,中文需要单独分词。此处用英文文本距离
打印最常用的10个单词。发现词频衰减得相当快。
可以尝试比如二元语法、三元语法等
8.3.4. 读取长序列数据
由于序列数据本质上是连续的,我们需要解决处理这带来的问题。我们在 8.1节 以一种相当特别的方式做到了这一点。当序列变得太长而不能被模型一次全部处理时,我们可能希望拆分这样的序列以供阅读。
首先,由于文本序列可以是任意长的,例如整个“时光机器”书,我们可以将这样长的序列划分为具有相同时间步数的子序列。当训练我们的神经网络时,子序列的小批量将被输入到模型中。假设网络一次处理 n 个时间步的子序列。 图8.3.1 画出了从原始文本序列获得子序列的所有不同方式,其中 n=5 和每个时间步的标记对应于一个字符。
如果我们只选择一个偏移量,那么用于训练网络的所有可能子序列的覆盖范围都是有限的。因此,我们可以从随机偏移量开始划分序列,以获得覆盖(coverage)和随机性(randomness)。在下面,我们将描述如何实现随机采样和顺序分区策略。
随机采样:
在随机采样中,每个样本都是在原始长序列上任意捕获的子序列。迭代期间来自两个相邻随机小批量的子序列不一定在原始序列上相邻。对于语言建模,目标是根据我们到目前为止看到的标记来预测下一个标记,因此标签是原始序列移位了一个标记。
def seq_data_iter_random(corpus, batch_size, num_steps): #@save
"""使用随机抽样生成一小批子序列。"""
# 从随机偏移量(包括`num_steps - 1`)开始对序列进行分区
corpus = corpus[random.randint(0, num_steps - 1):]
# 减去1,因为我们需要考虑标签
num_subseqs = (len(corpus) - 1) // num_steps
# 长度为`num_steps`的子序列的起始索引
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
# 在随机抽样中,迭代过程中两个相邻随机小批量的子序列不一定在原始序列上相邻
random.shuffle(initial_indices)
def data(pos):
# 返回从`pos`开始的长度为`num_steps`的序列
return corpus[pos:pos + num_steps]
num_batches = num_subseqs // batch_size
for i in range(0, batch_size * num_batches, batch_size):
# 这里,`initial_indices`包含子序列的随机起始索引
initial_indices_per_batch = initial_indices[i:i + batch_size]
X = [data(j) for j in initial_indices_per_batch]
Y = [data(j + 1) for j in initial_indices_per_batch]
yield torch.tensor(X), torch.tensor(Y)
顺序分区策略:
除了对原始序列进行随机抽样外,我们还可以保证迭代过程中两个相邻小批量的子序列在原始序列上是相邻的。这种策略在对小批进行迭代时保留了拆分子序列的顺序,因此称为顺序分区。
def seq_data_iter_sequential(corpus, batch_size, num_steps): #@save
"""使用顺序分区生成一小批子序列。"""
# 从随机偏移量开始划分序列
offset = random.randint(0, num_steps)
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
Xs = torch.tensor(corpus[offset:offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1:offset + 1 + num_tokens])
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
num_batches = Xs.shape[1] // num_steps
for i in range(0, num_steps * num_batches, num_steps):
X = Xs[:, i:i + num_steps]
Y = Ys[:, i:i + num_steps]
yield X, Y
将上述两个采样函数包装到一个类中,以便稍后可以将其用作数据迭代器。
class SeqDataLoader: #@save
"""加载序列数据的迭代器。"""
def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
if use_random_iter:
self.data_iter_fn = d2l.seq_data_iter_random
else:
self.data_iter_fn = d2l.seq_data_iter_sequential
self.corpus, self.vocab = d2l.load_corpus_time_machine(max_tokens)
self.batch_size, self.num_steps = batch_size, num_steps
def __iter__(self):
return self.data_iter_fn(self.corpus, self.batch_size, self.num_steps)
定义了一个函数 load_data_time_machine ,它同时返回数据迭代器和词表,因此我们可以与其他带有 load_data 前缀的函数(如 3.5节 中定义的 d2l.load_data_fashion_mnist )类似地使用它。
def load_data_time_machine(batch_size, num_steps, #@save
use_random_iter=False, max_tokens=10000):
"""返回时光机器数据集的迭代器和词表。"""
data_iter = SeqDataLoader(batch_size, num_steps, use_random_iter,
max_tokens)
return data_iter, data_iter.vocab
8.3.5. 小结
-
语言模型是自然语言处理的关键。
-
n 元语法通过截断相关性,为处理长序列提供了一种方便的模型。
-
长序列有一个问题,那就是它们很少出现或从不出现。
-
齐普夫定律不仅规定了单字的单词分布,而且还规定了其他 n 元语法的单词分布。
-
通过拉普拉斯平滑法可以有效地处理不常见的、结构复杂且频率不够词组。
-
读取长序列的主要选择是随机采样和顺序分区。后者可以保证迭代过程中来自两个相邻小批量的子序列在原始序列上是相邻的。
8.4. 循环神经网络
隐藏层和隐藏状态指的是两个截然不同的概念。
隐藏层是在从输入到输出的路径上从视图中隐藏的层。
从技术上讲,隐藏状态是我们在给定步骤所做的任何事情的“输入”。隐藏状态只能通过查看先前时间点的数据来计算。
循环神经网络(Recurrent neural networks, RNNs)是具有隐藏状态的神经网络。
8.4.1. 无隐藏状态的神经网络
8.4.2. 具有隐藏状态的循环神经网络
import torch
from d2l import torch as d2l
X, W_xh = torch.normal(0, 1, (3, 1)), torch.normal(0, 1, (1, 4))
H, W_hh = torch.normal(0, 1, (3, 4)), torch.normal(0, 1, (4, 4))
torch.matmul(X, W_xh) + torch.matmul(H, W_hh)
#沿列(轴1)连结矩阵X和H,沿行(轴0)连结矩阵W_xh和W_hh。分别产生形状(3, 5)和形状(5, 4)的矩阵。将这两个连结的矩阵相乘,得到与上面相同形状(3, 4)的输出矩阵。
#torch.matmul(torch.cat((X, H), 1), torch.cat((W_xh, W_hh), 0))
tensor([[-1.0555, 1.0582, -3.1947, -2.5251],
[-0.9725, -1.8069, -0.8490, 6.2770],
[-2.5167, 0.3523, -3.1282, -2.5859]])
8.4.3. 基于循环神经网络的字符级语言模型
使用循环神经网络来构建语言模型。设小批量大小为1,文本序列为“machine”。为了简化后续部分的训练,将文本标记化为字符而不是单词,并考虑使用字符级语言模型(character-level language model)。 图8.4.2 演示了如何通过用于字符级语言建模的循环神经网络,基于当前字符和先前字符预测下一个字符。
在训练过程中,我们对每个时间步长的输出层的输出进行softmax操作,然后利用交叉熵损失计算模型输出和标签之间的误差。由于隐藏层中隐藏状态的循环计算, 图8.4.2 中的时间步骤3的输出 O3 由文本序列“m”、“a”和“c”确定。由于训练数据中序列的下一个字符是“h”,因此时间步3的损失将取决于基于该时间步的特征序列“m”、“a”、“c”生成的下一个字符概率分布和标签“h”。
实际上,每个标记都由一个 d 维向量表示,使用批量大小 n>1 。因此,输入 Xt 在时间步 t 将是 n×d 矩阵,这与我们在 8.4.2节 中讨论的相同。
8.4.4. 困惑度(Perplexity)
如何度量语言模型质量,这将在后续部分中用于评估我们基于循环神经网络的模型。一种方法是检查文本有多不通顺。一个好的语言模型能够用高精度的标记来预测我们接下来会看到什么。
可以通过计算序列的似然概率来衡量模型的质量,但是太大,所以引入信息论更合适。如果想压缩文本,我们可以询问在给定当前标记集的情况下预测下一个标记。一个更好的语言模型应该能让我们更准确地预测下一个标记。因此,它应该允许在压缩序列时花费更少的比特。所以我们可以通过一个序列中所有 n 个标记的平均交叉熵损失来衡量:
困惑度可以最好地理解为当我们决定下一个选择哪个标记时,实际选择数的调和平均数。
8.4.5. 小结
-
对隐藏状态使用循环计算的神经网络称为循环神经网络(RNN)。
-
循环神经网络的隐藏状态可以捕获直到当前时间步的序列的历史信息。
-
循环神经网络模型的参数数量不会随着时间步的增加而增加。
-
我们可以使用循环神经网络创建字符级语言模型。
-
我们可以用困惑度来评价语言模型的质量。
8.5. 循环神经网络的从零开始实现
8.5.1. 独热编码
8.5.2. 初始化模型参数
8.5.3. 循环神经网络模型
8.5.4. 预测
8.5.5. 梯度裁剪
8.5.6. 训练
8.5.7. 小结
8.6. 循环神经网络的简洁实现
读取时光机器数据集
from mxnet import np, npx
from mxnet.gluon import nn, rnn
from d2l import mxnet as d2l
npx.set_np()
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
8.6.1. 定义模型
#构造了一个具有256隐藏单元的单隐藏层的循环神经网络层 rnn_layer。
num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)
#使用张量来初始化隐藏状态,它的形状是(隐藏层数, 批量大小, 隐藏单元数)。
state = torch.zeros((1, batch_size, num_hiddens))
#state.shape
#torch.Size([1, 32, 256])
#通过一个隐藏状态和一个输入,我们可以用更新后的隐藏状态计算输出。需要强调的是,rnn_layer的“输出”(Y)不涉及输出层的计算:它是指每个时间步的隐藏状态,它们可以用作后续输出层的输入
X = torch.rand(size=(num_steps, batch_size, len(vocab)))
Y, state_new = rnn_layer(X, state)
# Y.shape, state_new.shape
#(torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))
与 8.5节 类似,我们为一个完整的循环神经网络模型定义了一个 RNNModel 类。注意 rnn_layer 只包含隐藏循环层,我们需要创建一个单独的输出层。
#@save
class RNNModel(nn.Module):
"""循环神经网络模型。"""
def __init__(self, rnn_layer, vocab_size, **kwargs):
super(RNNModel, self).__init__(**kwargs)
self.rnn = rnn_layer
self.vocab_size = vocab_size
self.num_hiddens = self.rnn.hidden_size
# 如果RNN是双向的(之后将介绍),`num_directions`应该是2,否则应该是1。
if not self.rnn.bidirectional:
self.num_directions = 1
self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
else:
self.num_directions = 2
self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)
def forward(self, inputs, state):
X = F.one_hot(inputs.T.long(), self.vocab_size)
X = X.to(torch.float32)
Y, state = self.rnn(X, state)
# 全连接层首先将`Y`的形状改为(`时间步数` * `批量大小`, `隐藏单元数`)。
# 它的输出形状是 (`时间步数` * `批量大小`, `词表大小`)。
output = self.linear(Y.reshape((-1, Y.shape[-1])))
return output, state
def begin_state(self, device, batch_size=1):
if not isinstance(self.rnn, nn.LSTM):
# `nn.GRU` 以张量作为隐藏状态
return torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens), device=device)
else:
# `nn.LSTM` 以张量作为隐藏状态
return (torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens),
device=device),
torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens),
device=device))
8.6.2. 训练与预测
在训练模型之前,让我们用一个具有随机权重的模型进行预测。
使用 8.5节 中定义的超参数调用 train_ch8,并使用高级API训练模型。
由于深度学习框架的高级API对代码进行了更多的优化,该模型在较短的时间内实现了类似的困惑度。
8.6.3. 小结
-
深度学习框架的高级API提供了循环神经网络层的实现。
-
高级API的循环神经网络层返回输出和更新的隐藏状态,其中输出不涉及输出层计算。
-
与从零开始实现相比,使用高级API会使循环神经网络训练的更快。
8.7. 通过时间反向传播
到目前为止,我们已经反复提到像梯度爆炸、梯度消失,以及需要对循环神经网络分离梯度.
8.7.1. 循环神经网络的梯度分析
从循环神经网络工作原理的简化模型开始。此模型忽略有关隐藏状态的细节及其更新方式的细节。这里的数学表示没有像过去那样明确区分标量,向量和矩阵。
完整计算
显然,我们可以计算 (8.7.7) 中的全部总和。然而,这非常缓慢,梯度可能会爆炸,因为初始条件的微妙变化可能会对结果产生很大影响。也就是说,我们可以看到类似于蝴蝶效应的东西,初始条件的很小变化导致结果的不成比例变化。就我们要估计的模型而言,这实际上是相当不可取的。毕竟,我们正在寻找能够很好地概括出来的可靠估计数。因此,这种方法几乎从未在实践中使用过。
截断时间步
或者,我们可以在 τ 步后截断求和。这就是我们到目前为止一直在讨论的内容,例如当我们在 8.5节 中分离梯度时。这会带来真实梯度的近似,只需将求和终止为 ∂ht−τ/∂wh 。在实践中,这工作得很好。它通常被称为通过时间截断反向传播 [Jaeger, 2002]。这样做的后果之一是,该模型主要侧重于短期影响,而不是长期影响。这实际上是可取的,因为它会将估计值偏向更简单和更稳定的模型。
随机截断
最后,我们可以用一个随机变量替换 ∂ht/∂wh ,该随机变量在预期中是正确的,但是会截断序列。这是通过使用预定义的 0≤πt≤1 序列 ξt 来实现的,其中 P(ξt=0)=1−πt 且 P(ξt=π−1t)=πt ,因此 E[ξt]=1 。我们使用它来替换 (8.7.4) 中的梯度 ∂ht/∂wh :
8.7.2. 通过时间反向传播细节
8.7.3. 小结
-
通过时间反向传播仅仅是反向传播对具有隐藏状态的序列模型的应用。
-
为了计算方便和数值稳定,需要截断,如规则截断和随机截断。
-
矩阵的高次方可能导致特征值发散或消失。这以梯度爆炸或梯度消失的形式表现出来。
-
为了高效计算,在通过时间反向传播期间缓存中间值。