注意力机制

注意力提示

定义

首先,考虑一个相对简单的状况, 即只使用非自主性提示。 要想将选择偏向于感官输入, 则可以简单地使用参数化的全连接层, 甚至是非参数化的最大汇聚层或平均汇聚层。

因此,“是否包含自主性提示”将注意力机制与全连接层或汇聚层区别开来。 在注意力机制的背景下,自主性提示被称为查询(query)。 给定任何查询,注意力机制通过注意力汇聚(attention pooling) 将选择引导至感官输入(sensory inputs,例如中间特征表示)。 在注意力机制中,这些感官输入被称为(value)。 更通俗的解释,每个值都与一个(key)配对, 这可以想象为感官输入的非自主提示。

../_images/qkv.svg

如图所示,可以通过设计注意力汇聚的方式, 便于给定的查询(自主性提示)与键(非自主性提示)进行匹配, 这将引导得出最匹配的值(感官输入)。

注意力机制通过注意力汇聚将查询(自主性提示)和(非自主性提示)结合在一起,实现对(感官输入)的选择倾向

假设我们有一个简单的英文句子:“The cat sat on the mat.”(猫坐在垫子上。),我们希望将其翻译为中文。在翻译过程中,我们当前的任务是生成“猫”这个词的中文翻译。在这个过程中,我们将使用注意力机制来决定源句子中的哪些词对于当前的翻译任务最为重要。

  • 查询(Query):假设在我们的模型中,当前的查询是由翻译模型的状态表示的,这个状态试图找到“cat”这个词的最佳中文对应。在这个例子中,查询是对“猫”这个概念的内部表示。
  • 键(Key):每个英文单词都会有一个与之对应的键。这些键代表了模型对每个单词的内部表示,用于帮助模型理解每个词与当前查询的相关性。例如,”cat”, “sat”, “on”, “the”, “mat”每个词都有一个键。
  • 值(Value):与键相对应,每个单词也都有一个值,这些值是实际用于计算输出的数据。在我们的翻译任务中,这些值可能包含了每个英文单词的含义、用法和上下文信息,这些信息将用于生成翻译。

可视化

平均汇聚层可以被视为输入的加权平均值, 其中各输入的权重是一样的。 实际上,注意力汇聚得到的是加权平均的总和值, 其中权重是在给定的查询和不同的键之间计算得出的。

为了可视化注意力权重,需要定义一个show_heatmaps函数。 其输入matrices的形状是 (要显示的行数,要显示的列数,查询的数目,键的数目)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import torch
from d2l import torch as d2l

# #@save 注释用于标记这个函数可能会被保存或导入到其他脚本中使用
def show_heatmaps(matrices, xlabel, ylabel, titles=None, figsize=(2.5, 2.5),
cmap='Reds'):
"""显示矩阵热图"""
d2l.use_svg_display() # 使用SVG格式显示图像,以获得更清晰的图像质量
num_rows, num_cols = matrices.shape[0], matrices.shape[1] # 获取矩阵数组的行数和列数
fig, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize,
sharex=True, sharey=True, squeeze=False)
# 创建一个图形和一组子图,每个矩阵都会在子图中显示
for i, (row_axes, row_matrices) in enumerate(zip(axes, matrices)):
for j, (ax, matrix) in enumerate(zip(row_axes, row_matrices)):
pcm = ax.imshow(matrix.detach().numpy(), cmap=cmap)
# 使用imshow函数绘制矩阵的热图,detach().numpy()将矩阵从PyTorch张量转换为NumPy数组
if i == num_rows - 1:
ax.set_xlabel(xlabel) # 在最下方的子图设置x轴标签
if j == 0:
ax.set_ylabel(ylabel) # 在最左侧的子图设置y轴标签
if titles:
ax.set_title(titles[j]) # 如果提供了标题,为每个子图设置标题
fig.colorbar(pcm, ax=axes, shrink=0.6) # 为子图集添加一个颜色条,显示颜色映射的数值范围

下面使用一个简单的例子进行演示。 在本例子中,仅当查询和键相同时,注意力权重为1,否则为0。

1
2
attention_weights = torch.eye(10).reshape((1, 1, 10, 10))
show_heatmaps(attention_weights, xlabel='Keys', ylabel='Queries')

../_images/output_attention-cues_054b1a_36_0.svg


Nadaraya-Watson 核回归

生成数据集

简单起见,考虑下面这个回归问题:给定的成对的“输入-输出”数据集$\{(x_1, y_1), \ldots, (x_n, y_n)\}$,如何学习$f$来预测任意新输入$x$的输出$\hat{y} = f(x)$?

根据下面的非线性函数生成一个人工数据集,其中加入的噪声项为$\epsilon$:

其中$\epsilon$服从均值为$0$和标准差为$0.5$的正态分布。在这里生成了$50$个训练样本和$50$个测试样本。为了更好地可视化之后的注意力模式,需要将训练样本进行排序

1
2
3
4
5
6
7
8
9
10
n_train = 50  # 训练样本数
x_train, _ = torch.sort(torch.rand(n_train) * 5) # 排序后的训练样本

def f(x):
return 2 * torch.sin(x) + x**0.8

y_train = f(x_train) + torch.normal(0.0, 0.5, (n_train,)) # 训练样本的输出
x_test = torch.arange(0, 5, 0.1) # 测试样本
y_truth = f(x_test) # 测试样本的真实输出
n_test = len(x_test) # 测试样本数

下面的函数将绘制所有的训练样本(样本由圆圈表示), 不带噪声项的真实数据生成函数$f$(标记为“Truth”), 以及学习得到的预测函数(标记为“Pred”)

1
2
3
4
def plot_kernel_reg(y_hat):
d2l.plot(x_test, [y_truth, y_hat], 'x', 'y', legend=['Truth', 'Pred'],
xlim=[0, 5], ylim=[-1, 5])
d2l.plt.plot(x_train, y_train, 'o', alpha=0.5);

平均汇聚

先使用最简单的估计器来解决回归问题。基于平均汇聚来计算所有训练样本输出值的平均值:

如图所示,这个估计器确实不够聪明。真实函数$f$(“Truth”)和预测函数(“Pred”)相差很大。

1
2
3
#torch.repeat_interleave 是一个PyTorch函数,用于沿指定维度重复张量中的元素。
y_hat = torch.repeat_interleave(y_train.mean(), n_test)
plot_kernel_reg(y_hat)

../_images/output_nadaraya-waston_736177_39_0.svg


非参数注意力汇聚

显然,平均汇聚忽略了输入$x_i$。于是Nadaraya和Watson提出了一个更好的想法,根据输入的位置对输出$y_i$进行加权:

其中$K$是(kernel)。公式所描述的估计器被称为Nadaraya-Watson核回归

这里不会深入讨论核函数的细节,但受此启发,我们可以从注意力机制框架的角度重写成为一个更加通用的注意力汇聚(attention pooling)公式:

其中$x$是查询,$(x_i, y_i)$是键值对。注意力汇聚是$y_i$的加权平均。将查询$x$和键$x_i$之间的关系建模为注意力权重$\alpha(x, x_i)$,这个权重将被分配给每一个对应值$y_i$。对于任何查询,模型在所有键值对注意力权重都是一个有效的概率分布:它们是非负的,并且总和为1。

为了更好地理解注意力汇聚,下面考虑一个高斯核(Gaussian kernel),其定义为:

将高斯核代入可以得到:

在公式中,如果一个键$x_i$越是接近给定的查询$x$,那么分配给这个键对应值$y_i$的注意力权重就会越大,也就“获得了更多的注意力”。

值得注意的是,Nadaraya-Watson核回归是一个非参数模型。因此,公式是非参数的注意力汇聚模型。接下来,我们将基于这个非参数的注意力汇聚模型来绘制预测结果。绘制的结果会发现新的模型预测线是平滑的,并且比平均汇聚的预测更接近真实。

1
2
3
4
5
6
7
8
9
# X_repeat的形状:(n_test,n_train),
# 每一行都包含着相同的测试输入(例如:同样的查询)
X_repeat = x_test.repeat_interleave(n_train).reshape((-1, n_train))
# x_train包含着键。attention_weights的形状:(n_test,n_train),
# 每一行都包含着要在给定的每个查询的值(y_train)之间分配的注意力权重
attention_weights = nn.functional.softmax(-(X_repeat - x_train)**2 / 2, dim=1)
# y_hat的每个元素都是值的加权平均值,其中的权重是注意力权重
y_hat = torch.matmul(attention_weights, y_train)
plot_kernel_reg(y_hat)

../_images/output_nadaraya-waston_736177_54_0.svg

现在来观察注意力的权重。 这里测试数据的输入相当于查询,而训练数据的输入相当于键。 因为两个输入都是经过排序的,因此由观察可知“查询-键”对越接近, 注意力汇聚的注意力权重就越高。

1
2
3
d2l.show_heatmaps(attention_weights.unsqueeze(0).unsqueeze(0),
xlabel='Sorted training inputs',
ylabel='Sorted testing inputs')

../_images/output_nadaraya-waston_736177_69_0.svg


带参数注意力汇聚

非参数的Nadaraya-Watson核回归具有一致性(consistency)的优点: 如果有足够的数据,此模型会收敛到最优结果。 尽管如此,我们还是可以轻松地将可学习的参数集成到注意力汇聚中。

在下面的查询$x$和键$x_i$之间的距离乘以可学习参数$w$:

本节的余下部分将通过训练这个模型来学习注意力汇聚的参数。

批量矩阵乘法

在注意力机制的背景中,我们可以使用小批量矩阵乘法来计算小批量数据中的加权平均值。

1
2
3
weights = torch.ones((2, 10)) * 0.1
values = torch.arange(20.0).reshape((2, 10))
torch.bmm(weights.unsqueeze(1), values.unsqueeze(-1))

torch.bmm

  • 功能torch.bmm 是批量矩阵乘法(Batch Matrix Multiplication)的缩写。这个函数用于计算两个张量中包含的多组矩阵的乘积。具体来说,如果你有两个三维张量,每个张量中包含了多个二维矩阵,torch.bmm 可以一次性计算这些矩阵的乘积。
  • 输入:两个形状为 (b, n, m)(b, m, p) 的张量,其中 b 是批次大小,表示有多少组矩阵需要相乘,n, m, p 分别是这些矩阵的维度。
  • 输出:一个形状为 (b, n, p) 的张量,包含了每一组输入矩阵乘积的结果。

torch.unsqueeze

  • 功能torch.unsqueeze 用于在指定位置增加一个维度(即增加一个轴)。这个操作不会改变张量的数据,但会改变张量的形状
  • 输入:一个张量和一个指定的维度(位置)。
  • 输出:形状改变后的张量,其在指定位置上增加了一个大小为1的维度。

  • weights.unsqueeze(1)weights 张量从形状 (2, 10) 改变为 (2, 1, 10)。这里,1 表示在原有的行和列之间增加了一个新的维度。

  • values.unsqueeze(-1)values 张量从形状 (2, 10) 改变为 (2, 10, 1)。这里,-1 表示在张量的最后增加了一个新的维度。

接下来,将训练数据集变换为键和值用于训练注意力模型。 在带参数的注意力汇聚模型中, 任何一个训练样本的输入都会和除自己以外的所有训练样本的“键-值”对进行计算, 从而得到其对应的预测输出。

1
2
3
4
5
6
7
8
# 每一行都包含着相同的训练输入
X_tile = x_train.repeat((n_train, 1))
# 每一行都包含着相同的训练输出
Y_tile = y_train.repeat((n_train, 1))
# 使得每行包含`n_train - 1`个元素,即除了自己之外的其他样本,形成了`keys`张量。
keys = X_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))
# 使得每行包含`n_train - 1`个元素,即除了自己之外的其他样本,形成了`values`张量。
values = Y_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))
  • torch.eye(n_train)创建一个形状为(n_train, n_train)的单位矩阵,其中对角线上的元素为1,其余为0。
  • 1 - torch.eye(n_train)则将这个单位矩阵取反,对角线上的元素变为0,其余变为1。
  • .type(torch.bool)将取反后的矩阵转换为布尔类型,对角线上的元素为False,其余为True
  • X_tile[...]使用上述布尔矩阵作为索引,选取X_tile中非对角线上的所有元素,即除去每行自身的重复数据。
  • .reshape((n_train, -1))重新塑形张量,使得每行包含n_train - 1个元素,即除了自己之外的其他样本,形成了keys张量。

训练带参数的注意力汇聚模型时,使用平方损失函数和随机梯度下降。

1
2
3
4
5
6
7
8
9
10
11
12
net = NWKernelRegression()
loss = nn.MSELoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=0.5)
animator = d2l.Animator(xlabel='epoch', ylabel='loss', xlim=[1, 5])

for epoch in range(5):
trainer.zero_grad()
l = loss(net(x_train, keys, values), y_train)
l.sum().backward()
trainer.step()
print(f'epoch {epoch + 1}, loss {float(l.sum()):.6f}')
animator.add(epoch + 1, float(l.sum()))

../_images/output_nadaraya-waston_736177_144_0.svg

如下所示,训练完带参数的注意力汇聚模型后可以发现: 在尝试拟合带噪声的训练数据时, 预测结果绘制的线不如之前非参数模型的平滑。

1
2
3
d2l.show_heatmaps(net.attention_weights.unsqueeze(0).unsqueeze(0),
xlabel='Sorted training inputs',
ylabel='Sorted testing inputs')

../_images/output_nadaraya-waston_736177_174_0.svg


注意力评分函数

高斯核指数部分可以视为注意力评分函数(attention scoring function), 简称评分函数(scoring function), 然后把这个函数的输出结果输入到softmax函数中进行运算。 通过上述步骤,将得到与键对应的值的概率分布(即注意力权重)。 最后,注意力汇聚的输出就是基于这些注意力权重的值的加权和。

从宏观来看,上述算法可以用来实现注意力机制框架。图中说明了如何将注意力汇聚的输出计算成为值的加权和,其中$a$表示注意力评分函数。由于注意力权重是概率分布,因此加权和其本质上是加权平均值。

../_images/attention-output.svg

用数学语言描述,假设有一个查询$\mathbf{q} \in \mathbb{R}^q$和$m$个“键-值”对$(\mathbf{k}_1, \mathbf{v}_1), \ldots, (\mathbf{k}_m, \mathbf{v}_m)$,其中$\mathbf{k}_i \in \mathbb{R}^k$,$\mathbf{v}_i \in \mathbb{R}^v$。

注意力汇聚函数$f$就被表示成值的加权和:

其中查询$\mathbf{q}$和键$\mathbf{k}_i$的注意力权重(标量)是通过注意力评分函数$a$将两个向量映射成标量,再经过softmax运算得到的:

正如上图所示,选择不同的注意力评分函数$a$会导致不同的注意力汇聚操作。本节将介绍两个流行的评分函数,稍后将用他们来实现更复杂的注意力机制。


掩蔽softmax

正如上面提到的,softmax操作用于输出一个概率分布作为注意力权重。 在某些情况下,并非所有的值都应该被纳入到注意力汇聚中。 例如,为了在机器翻译中高效处理小批量数据集, 某些文本序列被填充了没有意义的特殊词元。

为了仅将有意义的词元作为值来获取注意力汇聚, 可以指定一个有效序列长度(即词元的个数), 以便在计算softmax时过滤掉超出指定范围的位置。 下面的masked_softmax函数 实现了这样的掩蔽softmax操作, 其中任何超出有效长度的位置都被掩蔽并置为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#@save
def masked_softmax(X, valid_lens):
"""通过在最后一个轴上掩蔽元素来执行softmax操作"""
# X:3D张量,valid_lens:1D或2D张量
if valid_lens is None:
return nn.functional.softmax(X, dim=-1) # 如果没有提供有效长度,直接在最后一个轴上应用softmax
else:
shape = X.shape # 保存X的原始形状
if valid_lens.dim() == 1:
# 如果valid_lens是1D张量,则对每个样本的所有序列重复相同的长度
valid_lens = torch.repeat_interleave(valid_lens, shape[1])
else:
# 如果valid_lens是2D张量,则将其展平
valid_lens = valid_lens.reshape(-1)
# 对序列进行掩蔽操作,有效长度之外的位置被置为一个非常大的负数(-1e6)
X = d2l.sequence_mask(X.reshape(-1, shape[-1]), valid_lens,
value=-1e6)
# 将掩蔽后的X重新塑形为原始形状,并在最后一个轴上应用softmax
return nn.functional.softmax(X.reshape(shape), dim=-1)

为了演示此函数是如何工作的, 考虑由两个$2×4$矩阵表示的样本, 这两个样本的有效长度分别为2和3。 经过掩蔽softmax操作,超出有效长度的值都被掩蔽为0。

1
2
3
4
5
6
7
8
masked_softmax(torch.rand(2, 2, 4), torch.tensor([2, 3]))
"""
tensor([[[0.5980, 0.4020, 0.0000, 0.0000],
[0.5548, 0.4452, 0.0000, 0.0000]],

[[0.3716, 0.3926, 0.2358, 0.0000],
[0.3455, 0.3337, 0.3208, 0.0000]]])
"""

加性注意力

一般来说,当查询和键是不同长度的矢量时,可以使用加性注意力作为评分函数。

给定查询$\mathbf{q} \in \mathbb{R}^q$和键$\mathbf{k} \in \mathbb{R}^k$,加性注意力(additive attention)的评分函数为

其中可学习的参数是$\mathbf W_q\in\mathbb R^{h\times q}$、$\mathbf W_k\in\mathbb R^{h\times k}$和$\mathbf w_v\in\mathbb R^{h}$。

将查询和键连结起来后输入到一个多层感知机(MLP)中,感知机包含一个隐藏层,其隐藏单元数是一个超参数$h$。通过使用$\tanh$作为激活函数,并且禁用偏置项。

下面来实现加性注意力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#@save
class AdditiveAttention(nn.Module):
"""加性注意力"""
def __init__(self, key_size, query_size, num_hiddens, dropout, **kwargs):
super(AdditiveAttention, self).__init__(**kwargs)
# 使用线性层将键和查询转换到相同的隐藏维度
self.W_k = nn.Linear(key_size, num_hiddens, bias=False)
self.W_q = nn.Linear(query_size, num_hiddens, bias=False)
# 线性层用于计算加性注意力的分数
self.w_v = nn.Linear(num_hiddens, 1, bias=False)
# Dropout层用于正则化
self.dropout = nn.Dropout(dropout)

def forward(self, queries, keys, values, valid_lens):
# 首先,通过W_k和W_q将键和查询映射到隐藏空间
queries, keys = self.W_q(queries), self.W_k(keys)
# 扩展维度,使得queries和keys能够进行广播相加
# queries形状变为(batch_size, 查询的个数, 1, num_hiddens)
# keys形状变为(batch_size, 1, 键-值对的个数, num_hiddens)
features = queries.unsqueeze(2) + keys.unsqueeze(1)
# 应用tanh激活函数
features = torch.tanh(features)
# 计算加性注意力分数
scores = self.w_v(features).squeeze(-1)
# 应用掩蔽softmax函数,考虑到有效长度
self.attention_weights = masked_softmax(scores, valid_lens)
# 应用注意力权重到值上,进行加权求和
# values形状:(batch_size, 键-值对的个数, 值的维度)
return torch.bmm(self.dropout(self.attention_weights), values)

通过广播,1这个维度会被自动扩展以匹配另一个张量的相应维度(查询的1扩展以匹配键的“键-值对的个数”,键的1扩展以匹配查询的“查询的个数”),从而两者可以在每个维度上相加。

这个广播相加的结果是一个形状为(batch_size, 查询的个数, 键-值对的个数, num_hiddens)的张量。这个张量的每个元素代表了一个查询与一个键在映射到共同隐藏空间后的加性组合。这个加性组合随后通过激活函数和进一步的处理来计算注意力分数。

用一个小例子来演示上面的AdditiveAttention类, 其中查询、键和值的形状为(批量大小,步数或词元序列长度,特征大小), 实际输出为$(2,1,20)$、$(2,10,2)$和$(2,10,4)$。 注意力汇聚输出的形状为(批量大小,查询的步数,值的维度)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 初始化输入数据
queries, keys = torch.normal(0, 1, (2, 1, 20)), torch.ones((2, 10, 2))
values = torch.arange(40, dtype=torch.float32).reshape(1, 10, 4).repeat(2, 1, 1)
valid_lens = torch.tensor([2, 6])

# 实例化加性注意力模型,设置为评估模式
attention = AdditiveAttention(key_size=2, query_size=20, num_hiddens=8, dropout=0.1)
attention.eval()

# 通过加性注意力模型计算输出
output = attention(queries, keys, values, valid_lens)

"""
tensor([[[ 2.0000, 3.0000, 4.0000, 5.0000]],

[[10.0000, 11.0000, 12.0000, 13.0000]]], grad_fn=<BmmBackward0>)
"""
  1. 输入维度
    • 查询(Queries):初始维度为(batch_size, 查询的个数, query_size)。在示例中,这个维度是(2, 1, 20)
    • 键(Keys)和值(Values):键的初始维度为(batch_size, 键-值对的个数, key_size),值的维度为(batch_size, 键-值对的个数, value_size)。在示例中,键的维度是(2, 10, 2),值的维度是(2, 10, 4)
  2. 线性变换后的维度
    • 经过self.W_q(queries)self.W_k(keys)的线性变换后,查询和键都被映射到了num_hiddens维度的空间。这里num_hiddens=8。因此,变换后查询和键的维度分别变为(batch_size, 查询的个数, num_hiddens)(batch_size, 键-值对的个数, num_hiddens)。在示例中,这意味着它们都变为(2, 1, 8)(2, 10, 8)
  3. unsqueeze操作后的维度
    • 执行queries.unsqueeze(2)后,查询的维度变为(batch_size, 查询的个数, 1, num_hiddens),在示例中为(2, 1, 1, 8)
    • 执行keys.unsqueeze(1)后,键的维度变为(batch_size, 1, 键-值对的个数, num_hiddens),在示例中为(2, 1, 10, 8)
  4. 广播相加后的维度
    • 在执行加法操作后,由于广播机制,最终的features维度为(batch_size, 查询的个数, 键-值对的个数, num_hiddens)。在示例中,这个维度是(2, 1, 10, 8)
  5. 通过self.w_v后维度变化
    • 经过self.w_v(features)计算得到的分数在最后一个维度为1,因此维度是(batch_size, 查询的个数, 键-值对的个数, 1)。在示例中,维度变为(2, 1, 10, 1)
  6. squeeze操作后的维度
    • 执行scores.squeeze(-1)后,去除了最后一个维度,所以scores的维度变为(batch_size, 查询的个数, 键-值对的个数)。在示例中,这变为(2, 1, 10)
  7. 输出维度
    • 最终的输出是通过对values进行加权求和得到的,其维度为(batch_size, 查询的个数, value_size)。在示例中,输出维度是(2, 1, 4),这表示每个查询对应的加权值维度。

尽管加性注意力包含了可学习的参数,但由于本例子中每个键都是相同的, 所以注意力权重是均匀的,由指定的有效长度决定。

1
2
d2l.show_heatmaps(attention.attention_weights.reshape((1, 1, 2, 10)),
xlabel='Keys', ylabel='Queries')

../_images/output_attention-scoring-functions_2a8fdc_96_0.svg


缩放点积注意力

使用点积可以得到计算效率更高的评分函数,但是点积操作要求查询和键具有相同的长度$d$。

假设查询和键的所有元素都是独立的随机变量,并且都满足零均值和单位方差,那么两个向量的点积的均值为$0$,方差为$d$。为确保无论向量长度如何,点积的方差在不考虑向量长度的情况下仍然是$1$,我们再将点积除以$\sqrt{d}$,则缩放点积注意力(scaled dot-product attention)评分函数为:

在实践中,我们通常从小批量的角度来考虑提高效率,例如基于$n$个查询和$m$个键-值对计算注意力,其中查询和键的长度为$d$,值的长度为$v$。

查询$\mathbf Q\in\mathbb R^{n\times d}$、

键$\mathbf K\in\mathbb R^{m\times d}$和

值$\mathbf V\in\mathbb R^{m\times v}$的缩放点积注意力是:

下面的缩放点积注意力的实现使用了暂退法进行模型正则化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#@save
class DotProductAttention(nn.Module):
"""缩放点积注意力"""
def __init__(self, dropout, **kwargs):
super(DotProductAttention, self).__init__(**kwargs)
self.dropout = nn.Dropout(dropout)

# queries的形状:(batch_size,查询的个数,d)
# keys的形状:(batch_size,“键-值”对的个数,d)
# values的形状:(batch_size,“键-值”对的个数,值的维度)
# valid_lens的形状:(batch_size,)或者(batch_size,查询的个数)
def forward(self, queries, keys, values, valid_lens=None):
d = queries.shape[-1]
# 设置transpose_b=True为了交换keys的最后两个维度
scores = torch.bmm(queries, keys.transpose(1,2)) / math.sqrt(d)
self.attention_weights = masked_softmax(scores, valid_lens)
return torch.bmm(self.dropout(self.attention_weights), values)

为了演示上述的DotProductAttention类, 我们使用与先前加性注意力例子中相同的键、值和有效长度。 对于点积操作,我们令查询的特征维度与键的特征维度大小相同。

1
2
3
4
5
6
7
8
9
queries = torch.normal(0, 1, (2, 1, 2))
attention = DotProductAttention(dropout=0.5)
attention.eval()
attention(queries, keys, values, valid_lens)
"""
tensor([[[ 2.0000, 3.0000, 4.0000, 5.0000]],

[[10.0000, 11.0000, 12.0000, 13.0000]]])
"""

与加性注意力演示相同,由于键包含的是相同的元素, 而这些元素无法通过任何查询进行区分,因此获得了均匀的注意力权重。

1
2
d2l.show_heatmaps(attention.attention_weights.reshape((1, 1, 2, 10)),
xlabel='Keys', ylabel='Queries')

../_images/output_attention-scoring-functions_2a8fdc_141_0.svg

当查询和键是不同长度的矢量时,可以使用可加性注意力评分函数。当它们的长度相同时,使用缩放的“点-积”注意力评分函数的计算效率更高。


多头注意力

在实践中,当给定相同的查询、键和值的集合时, 我们希望模型可以基于相同的注意力机制学习到不同的行为, 然后将不同的行为作为知识组合起来, 捕获序列内各种范围的依赖关系 (例如,短距离依赖和长距离依赖关系)。 因此,允许注意力机制组合使用查询、键和值的不同 子空间表示 可能是有益的。

为此,与其只使用单独一个注意力汇聚, 我们可以用独立学习得到的ℎ组不同的 线性投影来变换查询、键和值。 然后,这ℎ组变换后的查询、键和值将并行地送到注意力汇聚中。 最后,将这ℎ个注意力汇聚的输出拼接在一起, 并且通过另一个可以学习的线性投影进行变换, 以产生最终输出。 这种设计被称为多头注意力, 对于ℎ个注意力汇聚输出,每一个注意力汇聚都被称作一个(head)。 图中展示了使用全连接层来实现可学习的线性变换的多头注意力

../_images/multi-head-attention.svg


模型

在实现多头注意力之前,让我们用数学语言将这个模型形式化地描述出来。

给定查询$\mathbf{q} \in \mathbb{R}^{d_q}$、键$\mathbf{k} \in \mathbb{R}^{d_k}$和值$\mathbf{v} \in \mathbb{R}^{d_v}$,每个注意力头$\mathbf{h}_i$($i = 1, \ldots, h$)的计算方法为:

其中,可学习的参数包括$\mathbf W_i^{(q)}\in\mathbb R^{p_q\times d_q}$、$\mathbf W_i^{(k)}\in\mathbb R^{p_k\times d_k}$$\mathbf W_i^{(v)}\in\mathbb R^{p_v\times d_v}$,以及代表注意力汇聚的函数$f$。$f$可以是上一节中的加性注意力缩放点积注意力

多头注意力的输出需要经过另一个线性转换,它对应着$h$个头连结后的结果,因此其可学习参数是$\mathbf W_o\in\mathbb R^{p_o\times h p_v}$:

基于这种设计,每个头都可能会关注输入的不同部分,可以表示比简单加权平均值更复杂的函数。


实现

在实现过程中通常选择缩放点积注意力作为每一个注意力头。为了避免计算代价和参数代价的大幅增长,

我们设定$p_q = p_k = p_v = p_o / h$。 $p_q,p_k,p_v$分别代表单个注意力头的查询、键和值的维度,而 $p_o$ 是所有头合并后的输出维度。通过这种方式,每个头处理的维度更小,但总的来看,模型能够并行处理,并学习到不同子空间的表示。ℎ 是头的数量,$p_o$ 通过模型的参数(如num_hiddens)指定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#@save
class MultiHeadAttention(nn.Module):
"""多头注意力"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
num_heads, dropout, bias=False, **kwargs):
super(MultiHeadAttention, self).__init__(**kwargs)
self.num_heads = num_heads # 定义头的数量
# 初始化点积注意力机制,其中包含dropout
self.attention = d2l.DotProductAttention(dropout)
# 为查询、键、值分别初始化线性变换层
self.W_q = nn.Linear(query_size, num_hiddens, bias=bias)
self.W_k = nn.Linear(key_size, num_hiddens, bias=bias)
self.W_v = nn.Linear(value_size, num_hiddens, bias=bias)
# 最后的线性变换层,用于合并多头注意力的输出
self.W_o = nn.Linear(num_hiddens, num_hiddens, bias=bias)

def forward(self, queries, keys, values, valid_lens):
# 对输入的查询、键、值进行线性变换
queries = transpose_qkv(self.W_q(queries), self.num_heads)
keys = transpose_qkv(self.W_k(keys), self.num_heads)
values = transpose_qkv(self.W_v(values), self.num_heads)

if valid_lens is not None:
# 如果提供了有效长度,则对其进行处理以匹配扩展后的批次大小
valid_lens = torch.repeat_interleave(
valid_lens, repeats=self.num_heads, dim=0)

# 调用点积注意力机制,计算多头注意力的输出
output = self.attention(queries, keys, values, valid_lens)

# 合并多头注意力的输出
output_concat = transpose_output(output, self.num_heads)
# 通过最后的线性变换层
return self.W_o(output_concat)

为了能够使多个头并行计算, 上面的MultiHeadAttention类将使用下面定义的两个转置函数。 具体来说,transpose_output函数反转了transpose_qkv函数的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#@save
def transpose_qkv(X, num_heads):
"""为了多注意力头的并行计算而变换形状"""
# 输入X的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
# 输出X的形状:(batch_size,查询或者“键-值”对的个数,num_heads, num_hiddens/num_heads)
X = X.reshape(X.shape[0], X.shape[1], num_heads, -1)

# 输出X的形状:(batch_size,num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
X = X.permute(0, 2, 1, 3)

# 最终输出的形状:(batch_size*num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
return X.reshape(-1, X.shape[2], X.shape[3])


#@save
def transpose_output(X, num_heads):
"""逆转transpose_qkv函数的操作"""
X = X.reshape(-1, num_heads, X.shape[1], X.shape[2])
X = X.permute(0, 2, 1, 3)
return X.reshape(X.shape[0], X.shape[1], -1)

permute函数接受一系列整数作为参数,这些整数代表了张量新的维度顺序。例如,如果有一个三维张量,其形状为(X, Y, Z),你可以使用permute将其维度顺序改为(Z, X, Y)或任何其他可能的组合。

Tensor Shape in Multihead Attention

下面使用键和值相同的小例子来测试我们编写的MultiHeadAttention类。 多头注意力输出的形状是(batch_sizenum_queriesnum_hiddens)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
num_hiddens, num_heads = 100, 5
attention = MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,
num_hiddens, num_heads, 0.5)
attention.eval()
"""
MultiHeadAttention(
(attention): DotProductAttention(
(dropout): Dropout(p=0.5, inplace=False)
)
(W_q): Linear(in_features=100, out_features=100, bias=False)
(W_k): Linear(in_features=100, out_features=100, bias=False)
(W_v): Linear(in_features=100, out_features=100, bias=False)
(W_o): Linear(in_features=100, out_features=100, bias=False)
)
"""
1
2
3
4
5
6
7
8
batch_size, num_queries = 2, 4
num_kvpairs, valid_lens = 6, torch.tensor([3, 2])
X = torch.ones((batch_size, num_queries, num_hiddens))
Y = torch.ones((batch_size, num_kvpairs, num_hiddens))
attention(X, Y, Y, valid_lens).shape
"""
torch.Size([2, 4, 100])
"""

步骤

  1. 乘以权重矩阵

    • 输入序列首先通过三个不同的线性层(权重矩阵),这些线性层分别用于生成查询($Q$)、键($K$)和值($V$)。这一步对整个输入序列进行操作,而不是针对分割后的“头”。每个线性层有其自己的权重矩阵 $(W^Q, W^K, W^V)$,它们的维度分别为 ($(d_{model}, d_k)、(d_{model}, d_k)$) 和 $(d_{model}, d_v)$,这里 $(d_k)$ 和 $(d_v)$ 分别是键/查询和值的目标维度。
  2. 分头

    • 接下来,生成的$Q$、$K$、$V$被分成多个“头”。实际上,这是通过调整它们的维度来实现的,而不是物理上将数据分割成几个部分。具体来说,如果模型设计中包含 $h$ 个头,那么每个头处理的维度是 ($d_k / h$) 和 ($d_v / h$)。将$Q$、$K$、$V$的形状从 $[batch_size, seq_length, d_k$] 调整为 $[batch_size, h, seq_length, d_k/h]$来为每个头提供数据。
  3. 计算自注意力

    • 对于每个头,独立地计算自注意力。由于步骤2中维度的调整,每个头可以并行地处理,关注输入数据的不同子空间。
  4. 合并头的输出

    • 最后,所有头的输出被合并回单一的表示中,通常是通过首先将它们连接起来,然后可能通过另一个线性层来整合这些信息。合并后的输出维度通常回到 $([batch_size, seq_length, d_{model}])$

自注意力

在深度学习中,经常使用卷积神经网络(CNN)或循环神经网络(RNN)对序列进行编码。 想象一下,有了注意力机制之后,我们将词元序列输入注意力池化中, 以便同一组词元同时充当查询、键和值。 具体来说,每个查询都会关注所有的键-值对并生成一个注意力输出。 由于查询、键和值来自同一组输入,因此被称为 自注意力

给定一个由词元组成的输入序列$\mathbf{x}_1, \ldots, \mathbf{x}_n$,其中任意$\mathbf{x}_i \in \mathbb{R}^d$($1 \leq i \leq n$)。该序列的自注意力输出为一个长度相同的序列$\mathbf{y}_1, \ldots, \mathbf{y}_n$,其中:

根据中之前定义的注意力汇聚函数$f(x) = \sum_{i=1}^n \alpha(x, x_i) y_i$,下面的代码片段是基于多头注意力对一个张量完成自注意力的计算,张量的形状为(批量大小,时间步的数目或词元序列的长度,$d$)。输出与输入的张量形状相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
num_hiddens, num_heads = 100, 5
attention = d2l.MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,
num_hiddens, num_heads, 0.5)
attention.eval()
"""
MultiHeadAttention(
(attention): DotProductAttention(
(dropout): Dropout(p=0.5, inplace=False)
)
(W_q): Linear(in_features=100, out_features=100, bias=False)
(W_k): Linear(in_features=100, out_features=100, bias=False)
(W_v): Linear(in_features=100, out_features=100, bias=False)
(W_o): Linear(in_features=100, out_features=100, bias=False)
)
"""
  • num_hiddens: 这个参数指定了查询(Query)、键(Key)、值(Value)以及最终输出向量的维度。在这个例子中,所有这些维度都被设置为100。
  • num_heads: 多头注意力中头的数量。在这里,设置为5,意味着注意力机制会被分成5个头进行并行计算,每个头处理的是输入数据的不同子空间。
  • 第三个参数0.5dropout的比率,用于防止模型过拟合,通过随机丢弃一部分注意力权重来增加模型的泛化能力。

综上所述,这段代码创建了一个具有100维隐藏层和5个注意力头的MultiHeadAttention模型实例,并通过设置dropout比率为0.5来帮助防止过拟合。

1
2
3
4
5
6
batch_size, num_queries, valid_lens = 2, 4, torch.tensor([3, 2])
X = torch.ones((batch_size, num_queries, num_hiddens))
attention(X, X, X, valid_lens).shape
"""
torch.Size([2, 4, 100])
"""

比较

接下来比较下面几个架构,目标都是将由$n$个词元组成的序列映射到另一个长度相等的序列,其中的每个输入词元或输出词元都由$d$维向量表示。具体来说,将比较的是卷积神经网络、循环神经网络和自注意力这几个架构的计算复杂性、顺序操作和最大路径长度。

请注意,顺序操作会妨碍并行计算,而任意的序列位置组合之间的路径越短,则能更轻松地学习序列中的远距离依赖关系

../_images/cnn-rnn-self-attention.svg

考虑一个卷积核大小为$k$的卷积层。

目前只需要知道的是,由于序列长度是$n$,输入和输出的通道数量都是$d$,所以卷积层的计算复杂度为$\mathcal{O}(knd^2)$。如图所示,卷积神经网络是分层的,因此为有$\mathcal{O}(1)$个顺序操作,最大路径长度为$\mathcal{O}(n/k)$。例如,$\mathbf{x}_1$和$\mathbf{x}_5$处于图中卷积核大小为3的双层卷积神经网络的感受野内。

当更新循环神经网络的隐状态时,$d \times d$权重矩阵和$d$维隐状态的乘法计算复杂度为$\mathcal{O}(d^2)$。由于序列长度为$n$,因此循环神经网络层的计算复杂度为$\mathcal{O}(nd^2)$。有$\mathcal{O}(n)$个顺序操作无法并行化,最大路径长度也是$\mathcal{O}(n)$。

在自注意力中,查询、键和值都是$n \times d$矩阵。考虑到缩放的”点-积“注意力,其中$n \times d$矩阵乘以$d \times n$矩阵。之后输出的$n \times n$矩阵乘以$n \times d$矩阵。因此,自注意力具有$\mathcal{O}(n^2d)$计算复杂性。每个词元都通过自注意力直接连接到任何其他词元。因此,有$\mathcal{O}(1)$个顺序操作可以并行计算,最大路径长度也是$\mathcal{O}(1)$。

总而言之,卷积神经网络和自注意力都拥有并行计算的优势,而且自注意力的最大路径长度最短。但是因为其计算复杂度是关于序列长度的二次方,所以在很长的序列中计算会非常慢。


位置编码

在处理词元序列时,循环神经网络是逐个的重复地处理词元的, 而自注意力则因为并行计算而放弃了顺序操作。 为了使用序列的顺序信息,通过在输入表示中添加 位置编码(positional encoding)来注入绝对的或相对的位置信息。 位置编码可以通过学习得到也可以直接固定得到。 接下来描述的是基于正弦函数和余弦函数的固定位置编码

假设输入表示$\mathbf{X} \in \mathbb{R}^{n \times d}$包含一个序列中$n$个词元的$d$维嵌入表示。位置编码使用相同形状的位置嵌入矩阵$\mathbf{P} \in \mathbb{R}^{n \times d}$输出$\mathbf{X} + \mathbf{P}$,矩阵第$i$行、第$2j$列和$2j+1$列上的元素为:

乍一看,这种基于三角函数的设计看起来很奇怪。在解释这个设计之前,让我们先在下面的PositionalEncoding类中实现它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#@save
class PositionalEncoding(nn.Module):
"""位置编码"""
def __init__(self, num_hiddens, dropout, max_len=1000):
super(PositionalEncoding, self).__init__()
# 初始化一个Dropout层,用于在位置编码后对输出进行dropout操作以防止过拟合
self.dropout = nn.Dropout(dropout)
# 初始化一个位置编码矩阵P,它有max_len行和num_hiddens列
# 这里max_len是序列的最大长度,num_hiddens是每个词元的隐藏向量维度
self.P = torch.zeros((1, max_len, num_hiddens))

# 计算位置编码的公式部分
X = torch.arange(max_len, dtype=torch.float32).reshape(-1, 1) / torch.pow(10000, torch.arange(0, num_hiddens, 2, dtype=torch.float32) / num_hiddens)
# 对偶数位置使用正弦函数进行编码
self.P[:, :, 0::2] = torch.sin(X)
# 对奇数位置使用余弦函数进行编码
self.P[:, :, 1::2] = torch.cos(X)

def forward(self, X):
# 将位置编码添加到输入X上
# 这里X是一个三维张量,形状为(batch_size, sequence_length, num_hiddens)
X = X + self.P[:, :X.shape[1], :].to(X.device)
# 对结果应用dropout并返回
return self.dropout(X)

在位置嵌入矩阵$P$中, 行代表词元在序列中的位置,列代表位置编码的不同维度。 从下面的例子中可以看到位置嵌入矩阵的第6列和第7列的频率高于第8列和第9列。 第6列和第7列之间的偏移量(第8列和第9列相同)是由于正弦函数和余弦函数的交替。

1
2
3
4
5
6
7
encoding_dim, num_steps = 32, 60
pos_encoding = PositionalEncoding(encoding_dim, 0)
pos_encoding.eval()
X = pos_encoding(torch.zeros((1, num_steps, encoding_dim)))
P = pos_encoding.P[:, :X.shape[1], :]
d2l.plot(torch.arange(num_steps), P[0, :, 6:10].T, xlabel='Row (position)',
figsize=(6, 2.5), legend=["Col %d" % d for d in torch.arange(6, 10)])

../_images/output_self-attention-and-positional-encoding_d76d5a_52_0.svg


绝对位置信息

为了明白沿着编码维度单调降低的频率与绝对位置信息的关系, 让我们打印出0,1,…,7的二进制表示形式。 正如所看到的,每个数字、每两个数字和每四个数字上的比特值 在第一个最低位、第二个最低位和第三个最低位上分别交替

1
2
3
4
5
6
7
8
9
10
11
12
for i in range(8):
print(f'{i}的二进制是:{i:>03b}')
"""
0的二进制是:000
1的二进制是:001
2的二进制是:010
3的二进制是:011
4的二进制是:100
5的二进制是:101
6的二进制是:110
7的二进制是:111
"""

在二进制表示中,较高比特位的交替频率低于较低比特位, 与下面的热图所示相似,只是位置编码通过使用三角函数在编码维度上降低频率。 由于输出是浮点数,因此此类连续表示比二进制表示法更节省空间。

1
2
3
P = P[0, :, :].unsqueeze(0).unsqueeze(0)
d2l.show_heatmaps(P, xlabel='Column (encoding dimension)',
ylabel='Row (position)', figsize=(3.5, 4), cmap='Blues')

../_images/output_self-attention-and-positional-encoding_d76d5a_82_0.svg


Transformer

自注意力同时具有并行计算和最短的最大路径长度这两个优势。因此,使用自注意力来设计深度架构是很有吸引力的。对比之前仍然依赖循环神经网络实现输入表示的自注意力模型,Transformer模型完全基于注意力机制,没有任何卷积层或循环神经网络层。尽管Transformer最初是应用于在文本数据上的序列到序列学习,但现在已经推广到各种现代的深度学习中,例如语言、视觉、语音和强化学习领域。

模型

Transformer作为编码器-解码器架构的一个实例,其整体架构图在图中展示。正如所见到的,Transformer是由编码器和解码器组成的。Transformer的编码器和解码器是基于自注意力的模块叠加而成的,源(输入)序列和目标(输出)序列的嵌入(embedding)表示将加上位置编码(positional encoding),再分别输入到编码器解码器中。

../_images/transformer.svg

从宏观角度来看,Transformer的编码器是由多个相同的层叠加而成的,每个层都有两个子层(子层表示为$\mathrm{sublayer}$)。第一个子层是 多头自注意力 汇聚;第二个子层是基于位置的前馈网络。具体来说,在计算编码器的自注意力时,查询、键和值都来自前一个编码器层的输出。

受ResNet中残差网络的启发,每个子层都采用了残差连接。在Transformer中,对于序列中任何位置的任何输入$\mathbf{x} \in \mathbb{R}^d$,都要求满足$\mathrm{sublayer}(\mathbf{x}) \in \mathbb{R}^d$,以便残差连接满足$\mathbf{x} + \mathrm{sublayer}(\mathbf{x}) \in \mathbb{R}^d$。在残差连接的加法计算之后,紧接着应用层规范化,因此,输入序列对应的每个位置,Transformer编码器都将输出一个$d$维表示向量。

Transformer解码器也是由多个相同的层叠加而成的,并且层中使用了残差连接和层规范化。除了编码器中描述的两个子层之外,解码器还在这两个子层之间插入了第三个子层,称为编码器-解码器注意力层。在编码器-解码器注意力中,查询来自前一个解码器层的输出,而键和值来自整个编码器的输出。在解码器自注意力中,查询、键和值都来自上一个解码器层的输出。但是,解码器中的每个位置只能考虑该位置之前的所有位置。这种掩蔽注意力保留了自回归属性,确保预测仅依赖于已生成的输出词元。

接下来将实现Transformer模型的剩余部分。


基于位置的前馈神经

基于位置的前馈网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机(MLP),这就是称前馈网络是基于位置的 原因。在下面的实现中,输入X的形状(批量大小,时间步数或序列长度,隐单元数或特征维度)将被一个两层的感知机转换成形状为(批量大小,时间步数,ffn_num_outputs)的输出张量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#@save
class PositionWiseFFN(nn.Module):
"""基于位置的前馈网络"""
def __init__(self, ffn_num_input, ffn_num_hiddens, ffn_num_outputs, **kwargs):
super(PositionWiseFFN, self).__init__(**kwargs)
# 第一个全连接层,将输入维度从ffn_num_input变换到ffn_num_hiddens
self.dense1 = nn.Linear(ffn_num_input, ffn_num_hiddens)
# ReLU激活函数
self.relu = nn.ReLU()
# 第二个全连接层,将隐藏层维度从ffn_num_hiddens变换到ffn_num_outputs
self.dense2 = nn.Linear(ffn_num_hiddens, ffn_num_outputs)

def forward(self, X):
# X是输入的张量,形状为(batch_size, sequence_length, ffn_num_input)
# 先通过第一个全连接层和ReLU激活函数
# 然后通过第二个全连接层
# 输出张量的形状为(batch_size, sequence_length, ffn_num_outputs)
return self.dense2(self.relu(self.dense1(X)))

下面的例子显示,改变张量的最里层维度的尺寸,会改变成基于位置的前馈网络的输出尺寸。因为用同一个多层感知机对所有位置上的输入进行变换,所以当所有这些位置的输入相同时,它们的输出也是相同的

1
2
3
ffn = PositionWiseFFN(4, 4, 8)
ffn.eval()
ffn(torch.ones((2, 3, 4)))

最后会输出(2,3,8)的张量,前馈神经网络只会把输入的最后一个维度改变成指定的输出维度


残差连接和层规范化

现在让我们关注图中的加法和规范化(add&norm)组件。正如在本节开头所述,这是由残差连接和紧随其后的层规范化组成的。两者都是构建有效的深度架构的关键。

批量规范化batchnorm中解释了在一个小批量的样本内基于批量规范化对数据进行重新中心化和重新缩放的调整。层规范化和批量规范化的目标相同,但层规范化是基于特征维度进行规范化。尽管批量规范化在计算机视觉中被广泛应用,但在自然语言处理任务中(输入通常是变长序列)批量规范化通常不如层规范化的效果好。

现在可以使用残差连接和层规范化来实现AddNorm类。暂退法也被作为正则化方法使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#@save
class AddNorm(nn.Module):
"""残差连接后进行层规范化"""
def __init__(self, normalized_shape, dropout, **kwargs):
super(AddNorm, self).__init__(**kwargs)
# 初始化一个Dropout层,用于在加法操作之前对Y进行随机丢弃,以减少过拟合
self.dropout = nn.Dropout(dropout)
# 初始化一个LayerNorm层,用于在加法操作之后对结果进行规范化
self.ln = nn.LayerNorm(normalized_shape)

def forward(self, X, Y):
# 首先对Y应用dropout,然后与X进行加法操作(残差连接)
# 最后,对加法操作的结果进行层规范化
# X是来自前一个层的输入,Y是当前层的输出
return self.ln(self.dropout(Y) + X)

比较层规范化和批量规范化

特性/应用 层规范化 (LN) 批量规范化 (BN)
规范化维度 对单个样本内的所有特征进行规范化 对批次内的同一特征进行规范化
计算统计量 每个样本独立计算均值和方差 跨整个批次的样本计算均值和方差
适用场景 循环神经网络(RNN)、Transformer 卷积神经网络(CNN)
优点 适用于变长输入,不依赖于批次大小 可以加速训练过程,有助于稳定训练
缺点 可能不如BN在某些卷积网络中有效 对小批量大小敏感,可能影响模型在小批量数据上的表现
自回归任务 适合,因为不泄露未来信息 需要特别设计以避免未来信息泄露
并行计算 容易实现,因为计算独立于其他样本 需要整个批次的数据进行计算

编码器

image-20240229204921088

有了组成Transformer编码器的基础组件,现在可以先实现编码器中的一个层。下面的EncoderBlock类包含两个子层:多头自注意力和基于位置的前馈网络,这两个子层都使用了残差连接和紧随的层规范化

  1. 自注意力机制(Self-Attention Mechanism):使编码器能够在处理序列的每个元素时,考虑到序列中的所有其他元素。这有助于编码器捕获输入序列内部的复杂依赖关系。
  2. 前馈神经网络(Feed-Forward Neural Network):在自注意力机制之后,每个位置的输出将传递给一个前馈神经网络。虽然对于不同位置的元素,该前馈网络是相同的,但它们分别独立地作用于每个位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#@save
class EncoderBlock(nn.Module):
"""Transformer编码器块"""

def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, use_bias=False, **kwargs):
"""
参数:
- key_size: 键向量的维度大小。
- query_size: 查询向量的维度大小。
- value_size: 值向量的维度大小。
- num_hiddens: 自注意力层和前馈网络输出的隐藏单元数量,也是模型中间层的维度。
- norm_shape: 层规范化应用的维度。这通常是输入特征的维度。
- ffn_num_input: 前馈网络的输入维度。
- ffn_num_hiddens: 前馈网络中间层的维度。
- num_heads: 多头注意力机制中头的数量。
- dropout: Dropout层的丢弃比例。
- use_bias: 是否在多头自注意力机制和前馈网络中使用偏置项。默认为False。
"""
super(EncoderBlock, self).__init__(**kwargs)
# 初始化多头自注意力机制,其中包括指定的头数和大小参数
self.attention = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout,
use_bias)
# 第一个残差连接和层规范化模块
self.addnorm1 = AddNorm(norm_shape, dropout)
# 初始化基于位置的前馈网络
self.ffn = PositionWiseFFN(
ffn_num_input, ffn_num_hiddens, num_hiddens)
# 第二个残差连接和层规范化模块
self.addnorm2 = AddNorm(norm_shape, dropout)

def forward(self, X, valid_lens):
# 先通过多头自注意力机制,然后应用残差连接和层规范化
Y = self.addnorm1(X, self.attention(X, X, X, valid_lens))
# 接着通过基于位置的前馈网络,再次应用残差连接和层规范化
return self.addnorm2(Y, self.ffn(Y))

正如从代码中所看到的,Transformer编码器中的任何层都不会改变其输入的形状

1
2
3
4
5
6
X = torch.ones((2, 100, 24))
valid_lens = torch.tensor([3, 2])
encoder_blk = EncoderBlock(24, 24, 24, 24, [100, 24], 24, 48, 8, 0.5)
encoder_blk.eval()
encoder_blk(X, valid_lens).shape
# 输出依然是:torch.Size([2, 100, 24])

下面实现的Transformer编码器的代码中,堆叠了num_layersEncoderBlock类的实例。由于这里使用的是值范围在−1和1之间的固定位置编码,因此通过学习得到的输入的嵌入表示的值需要先乘以嵌入维度的平方根进行重新缩放,然后再与位置编码相加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#@save
class TransformerEncoder(d2l.Encoder):
"""Transformer编码器"""
def __init__(self, vocab_size, key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, use_bias=False, **kwargs):
"""
初始化Transformer编码器。

参数:
- vocab_size: 词汇表大小,用于嵌入层。
- key_size: 键向量的维度。
- query_size: 查询向量的维度。
- value_size: 值向量的维度。
- num_hiddens: 自注意力层和前馈网络的输出维度,即模型的隐藏单元数。
- norm_shape: 层规范化应用的维度,通常是模型的隐藏层维度。
- ffn_num_input: 前馈网络的输入维度。
- ffn_num_hiddens: 前馈网络的隐藏层维度。
- num_heads: 多头自注意力机制中的头数。
- num_layers: 编码器中的层数。
- dropout: Dropout比例。
- use_bias: 是否在自注意力和前馈网络中使用偏置项,默认为False。

其他参数:
- **kwargs: 捕获未明确列出的关键字参数。
"""
super(TransformerEncoder, self).__init__(**kwargs)
self.num_hiddens = num_hiddens # 存储隐藏单元数
# 初始化嵌入层,将词汇表索引转换为固定大小的密集向量
self.embedding = nn.Embedding(vocab_size, num_hiddens)
# 初始化位置编码,为每个位置的词元添加位置信息
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
# 创建编码器层的堆叠
self.blks = nn.Sequential()
for i in range(num_layers):
# 逐层添加编码器块
self.blks.add_module("block"+str(i),
EncoderBlock(key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, dropout, use_bias))

def forward(self, X, valid_lens, *args):
"""
前向传播函数。

参数:
- X: 输入序列的张量。
- valid_lens: 输入序列中每个元素的有效长度。

返回:
- 经过Transformer编码器处理的序列张量。
"""
# 对嵌入向量进行缩放并添加位置编码
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
self.attention_weights = [None] * len(self.blks)
for i, blk in enumerate(self.blks):
# 逐个编码器块处理
X = blk(X, valid_lens)
# 存储每个编码器块的注意力权重
self.attention_weights[i] = blk.attention.attention.attention_weights
return X

下面我们指定了超参数来创建一个两层的Transformer编码器。 Transformer编码器输出的形状是(批量大小,时间步数目,num_hiddens)。

1
2
3
4
5
encoder = TransformerEncoder(
200, 24, 24, 24, 24, [100, 24], 24, 48, 8, 2, 0.5)
encoder.eval()
encoder(torch.ones((2, 100), dtype=torch.long), valid_lens).shape
#输出:torch.Size([2, 100, 24])

解码器

Transformer解码器也是由多个相同的层组成。在DecoderBlock类中实现的每个层包含了三个子层:解码器自注意力、“编码器-解码器”注意力和基于位置的前馈网络。这些子层也都被残差连接和紧随的层规范化围绕。

image-20240229212551267

  1. 掩蔽自注意力机制(Masked Self-Attention Mechanism):与编码器中的自注意力机制相似,但增加了掩蔽操作,以防止位置信息泄露未来的信息。这确保了解码器在生成第$n$个元素时,只能使用到第$n−1$个及之前的元素。
  2. 编码器-解码器注意力机制(Encoder-Decoder Attention Mechanism):这使得解码器能够关注(即“注意”)到输入序列的不同部分。解码器的这一层使用解码器的输出作为查询,而将编码器的输出作为键和值。
  3. 前馈神经网络:与编码器中的类似,解码器的每个位置也会经过一个前馈神经网络。

正如在本节前面所述,在掩蔽多头解码器自注意力层(第一个子层)中,查询、键和值都来自上一个解码器层的输出。关于序列到序列模型,在训练阶段,其输出序列的所有位置(时间步)的词元都是已知的;然而,在预测阶段,其输出序列的词元是逐个生成的。因此,在任何解码器时间步中,只有生成的词元才能用于解码器的自注意力计算中。为了在解码器中保留自回归的属性,其掩蔽自注意力设定了参数dec_valid_lens,以便任何查询都只会与解码器中所有已经生成词元的位置(即直到该查询位置为止)进行注意力计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class DecoderBlock(nn.Module):
"""解码器中第i个块"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, i, **kwargs):
"""
初始化解码器块。

参数:
- key_size, query_size, value_size: 键、查询、值向量的维度。
- num_hiddens: 多头注意力机制和前馈网络的隐藏层维度。
- norm_shape: 层规范化的维度。
- ffn_num_input, ffn_num_hiddens: 前馈网络的输入和隐藏层维度。
- num_heads: 多头自注意力的头数。
- dropout: Dropout层的丢弃比例。
- i: 解码器块的索引,用于在解码过程中跟踪状态。
"""
super(DecoderBlock, self).__init__(**kwargs)
self.i = i
# 第一个多头自注意力层,用于处理目标序列自身的依赖关系。
self.attention1 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
# 第一个残差连接和层规范化。
self.addnorm1 = AddNorm(norm_shape, dropout)
# 第二个多头自注意力层,用于处理编码器的输出和当前目标序列的依赖关系。
self.attention2 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
# 第二个残差连接和层规范化。
self.addnorm2 = AddNorm(norm_shape, dropout)
# 基于位置的前馈网络。
self.ffn = PositionWiseFFN(ffn_num_input, ffn_num_hiddens, num_hiddens)
# 第三个残差连接和层规范化。
self.addnorm3 = AddNorm(norm_shape, dropout)

def forward(self, X, state):
"""
前向传播函数。

参数:
- X: 输入序列的张量。
- state: 包含编码器输出和其他信息的状态,用于解码器的计算。

返回:
- 解码器块的输出和更新后的状态。
"""
enc_outputs, enc_valid_lens = state[0], state[1] # 编码器的输出和有效长度。
# 处理解码器的自注意力状态更新,区分训练和预测模式。
if state[2][self.i] is None:
key_values = X
else:
key_values = torch.cat((state[2][self.i], X), axis=1)
state[2][self.i] = key_values

# 自注意力机制
X2 = self.attention1(X, key_values, key_values, dec_valid_lens)
Y = self.addnorm1(X, X2)
# 编码器-解码器注意力机制
Y2 = self.attention2(Y, enc_outputs, enc_outputs, enc_valid_lens)
Z = self.addnorm2(Y, Y2)
# 前馈网络
return self.addnorm3(Z, self.ffn(Z)), state

为了便于在“编码器-解码器”注意力中进行缩放点积计算和残差连接中进行加法计算,编码器和解码器的特征维度都是num_hiddens

1
2
3
4
5
decoder_blk = DecoderBlock(24, 24, 24, 24, [100, 24], 24, 48, 8, 0.5, 0)
decoder_blk.eval()
X = torch.ones((2, 100, 24))
state = [encoder_blk(X, valid_lens), valid_lens, [None]]
decoder_blk(X, state)[0].shape

现在我们构建了由num_layersDecoderBlock实例组成的完整的Transformer解码器。最后,通过一个全连接层计算所有vocab_size个可能的输出词元的预测值。解码器的自注意力权重和编码器解码器注意力权重都被存储下来,方便日后可视化的需要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class TransformerDecoder(d2l.AttentionDecoder):
def __init__(self, vocab_size, key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, **kwargs):
"""
初始化Transformer解码器。

参数:
- vocab_size: 词汇表的大小,用于嵌入层和最终的输出层。
- key_size, query_size, value_size: 在多头自注意力机制中,键、查询和值的维度。
- num_hiddens: 内部嵌入的维度,也是多头自注意力和前馈网络的输出维度。
- norm_shape: 层规范化应用的维度,通常是模型的隐藏层维度。
- ffn_num_input, ffn_num_hiddens: 前馈网络的输入和隐藏层的维度。
- num_heads: 多头自注意力中的头数。
- num_layers: 解码器中的层数。
- dropout: Dropout层的丢弃率。
"""
super(TransformerDecoder, self).__init__(**kwargs)
self.num_hiddens = num_hiddens
self.num_layers = num_layers
# 词嵌入层,将输入的词汇索引转换为密集的向量表示。
self.embedding = nn.Embedding(vocab_size, num_hiddens)
# 位置编码层,为每个元素的嵌入添加位置信息。
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
# 解码器块的堆叠,每个块包括两个多头自注意力层和一个前馈网络。
self.blks = nn.Sequential()
for i in range(num_layers):
self.blks.add_module("block"+str(i),
DecoderBlock(key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, dropout, i))
# 最终的线性层,将解码器的输出转换为词汇表大小的向量,用于预测下一个词元。
self.dense = nn.Linear(num_hiddens, vocab_size)

def init_state(self, enc_outputs, enc_valid_lens, *args):
"""
初始化解码器的状态,包括编码器的输出和有效长度。
"""
return [enc_outputs, enc_valid_lens, [None] * self.num_layers]

def forward(self, X, state):
"""
前向传播函数。

参数:
- X: 输入序列的张量。
- state: 包含编码器输出和其他信息的状态。

返回:
- 解码器的输出和更新后的状态。
"""
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
self._attention_weights = [[None] * len(self.blks) for _ in range(2)]
for i, blk in enumerate(self.blks):
X, state = blk(X, state)
# 存储当前块的解码器自注意力权重和编码器-解码器注意力权重。
self._attention_weights[0][i] = blk.attention1.attention.attention_weights
self._attention_weights[1][i] = blk.attention2.attention.attention_weights
# 将解码器的输出通过一个线性层转换为预测下一个词元的概率分布。
return self.dense(X), state

@property
def attention_weights(self):
"""
返回解码器内部的注意力权重。
"""
return self._attention_weights

在一个英语翻译成中文的模型中,编码器和解码器处理的输入语言是不同的:

  1. 编码器的输入:编码器接收的是源语言的序列,即英语文本。它的任务是理解这个英语文本,并将这个理解压缩成一个固定长度的向量表示或一系列上下文向量,这些向量富含了输入文本的语义信息和上下文关系。
  2. 解码器的输入
    • 在开始翻译过程时,解码器首先接收一个特殊的开始符号(如<start>),表示开始生成目标语言序列,即中文文本。
    • 随后,在序列生成的每一步中,解码器接收到目前为止已生成的中文文本序列(包括开始符号和解码器之前步骤生成的中文词元)作为输入,用来预测下一个中文词元。
    • 同时,解码器还会利用编码器的输出(即对英语文本的编码表示)来辅助生成翻译后的中文文本。这种辅助通常是通过编码器-解码器注意力机制实现的,使解码器能够关注输入英文序列中与当前生成步骤最相关的部分。

简而言之,在英语翻译成中文的模型中,编码器处理的是英语输入序列,旨在理解其含义;而解码器则基于编码器的理解以及到目前为止已生成的中文序列来逐步生成中文翻译。这种分工使得Transformer模型能够有效地在两种语言之间进行信息转换和翻译。


状态

在Transformer模型中,从编码器和解码器的输入到输出的过程中,数据经历了一系列形状(维度)的变化。以下是这一过程的详细描述,假设我们正在处理一个从英语到中文的翻译任务:

编码器

  1. 输入:假设源序列(英语)长度为src_seq_length,模型的嵌入维度为embedding_dim,批量大小为batch_size

    • 输入形状:[batch_size, src_seq_length](每个元素是词汇表索引)。
  2. 嵌入层:将词汇表索引转换为嵌入向量。

    • 形状变化:[batch_size, src_seq_length] -> [batch_size, src_seq_length, embedding_dim]
  3. 位置编码:添加位置信息到嵌入向量中。

    • 形状保持不变:[batch_size, src_seq_length, embedding_dim]
  4. 通过编码器层:输入序列通过多个相同的编码器层(每层包含自注意力和前馈网络)。

    • 形状保持不变:[batch_size, src_seq_length, embedding_dim]
  5. 编码器输出:编码器的最终输出是源序列的上下文感知表示。

    • 输出形状:[batch_size, src_seq_length, embedding_dim]

解码器

  1. 输入:目标序列(中文)到当前为止的生成部分,假设当前长度为tgt_seq_length

    • 输入形状:[batch_size, tgt_seq_length](每个元素是词汇表索引)。
  2. 嵌入层和位置编码:与编码器类似,将词汇表索引转换为嵌入向量,并添加位置信息。

    • 形状变化:[batch_size, tgt_seq_length] -> [batch_size, tgt_seq_length, embedding_dim]
  3. 掩蔽自注意力层:防止位置关注到未来的位置。

    • 形状保持不变:[batch_size, tgt_seq_length, embedding_dim]
  4. 编码器-解码器注意力层:解码器利用编码器的输出来关注输入序列中与当前生成最相关的部分。

    • 编码器输出作为输入:[batch_size, src_seq_length, embedding_dim]
    • 解码器当前状态:[batch_size, tgt_seq_length, embedding_dim]
    • 输出形状保持不变:[batch_size, tgt_seq_length, embedding_dim]
  5. 通过解码器层:与编码器类似,但包括掩蔽自注意力和编码器-解码器注意力。

    • 形状保持不变:[batch_size, tgt_seq_length, embedding_dim]
  6. 生成预测:最后通过一个线性层和softmax层生成对下一个词元的预测。

    • 形状变化:[batch_size, tgt_seq_length, embedding_dim] -> [batch_size, tgt_seq_length, vocab_size],其中vocab_size是目标语言词汇表的大小,表示每个位置上每个词的概率分布。

编码器和解码器处理的输入和输出在整个过程中主要保持相同的第一和第二维(批量大小和序列长度),而第三维(特征或嵌入维度)在进入模型之前通过嵌入层设定,并在整个模型中保持不变,直到最后的预测步骤,此时输出变为每个可能词元的概率分布。这些设计使得Transformer模型能够有效处理序列到序列的任务,如机器翻译。


训练

依照Transformer架构来实例化编码器-解码器模型。在这里,指定Transformer的编码器和解码器都是2层,都使用4头注意力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 设置模型参数
num_hiddens, num_layers, dropout = 32, 2, 0.1 # 模型隐藏单元数,层数,以及dropout比率
batch_size, num_steps = 64, 10 # 批量大小和序列长度(步数)
lr, num_epochs = 0.005, 200 # 学习率和训练周期
device = d2l.try_gpu() # 训练使用的设备,尽量使用GPU

# 设置前馈网络和多头注意力的参数
ffn_num_input, ffn_num_hiddens, num_heads = 32, 64, 4 # 前馈网络输入和隐藏层维度,注意力头数
key_size, query_size, value_size = 32, 32, 32 # 设置键、查询、值的维度(对于Transformer通常相等)
norm_shape = [32] # 层规范化应用的维度

# 加载机器翻译数据集
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)

# 初始化编码器
encoder = TransformerEncoder(
len(src_vocab), # 源语言词汇表大小
key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout)

# 初始化解码器
decoder = TransformerDecoder(
len(tgt_vocab), # 目标语言词汇表大小
key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout)

# 组合编码器和解码器为一个完整的模型
net = d2l.EncoderDecoder(encoder, decoder)

# 训练模型
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
#loss 0.030, 5202.9 tokens/sec on cuda:0

../_images/output_transformer_5722f1_201_1.svg

训练结束后,使用Transformer模型将一些英语句子翻译成法语,并且计算它们的BLEU分数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
translation, dec_attention_weight_seq = d2l.predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, device, True)
print(f'{eng} => {translation}, ',
f'bleu {d2l.bleu(translation, fra, k=2):.3f}')

"""
go . => va !, bleu 1.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est calme ., bleu 1.000
i'm home . => je suis chez moi ., bleu 1.000
"""

当进行最后一个英语到法语的句子翻译工作时,让我们可视化Transformer的注意力权重。编码器自注意力权重的形状为(编码器层数,注意力头数,num_steps或查询的数目,num_steps或“键-值”对的数目)。

1
2
3
4
5
enc_attention_weights = torch.cat(net.encoder.attention_weights, 0).reshape((num_layers, num_heads,
-1, num_steps))
enc_attention_weights.shape

# torch.Size([2, 4, 10, 10])

在编码器的自注意力中,查询和键都来自相同的输入序列。因为填充词元是不携带信息的,因此通过指定输入序列的有效长度可以避免查询与使用填充词元的位置计算注意力。接下来,将逐行呈现两层多头注意力的权重。每个注意力头都根据查询、键和值的不同的表示子空间来表示不同的注意力。

1
2
3
4
d2l.show_heatmaps(
enc_attention_weights.cpu(), xlabel='Key positions',
ylabel='Query positions', titles=['Head %d' % i for i in range(1, 5)],
figsize=(7, 3.5))

../_images/output_transformer_5722f1_246_0.svg

为了可视化解码器的自注意力权重和“编码器-解码器”的注意力权重,我们需要完成更多的数据操作工作。例如用零填充被掩蔽住的注意力权重。值得注意的是,解码器的自注意力权重和“编码器-解码器”的注意力权重都有相同的查询:即以序列开始词元(beginning-of-sequence,BOS)打头,再与后续输出的词元共同组成序列。

1
2
3
4
5
6
7
8
9
dec_attention_weights_2d = [head[0].tolist()
for step in dec_attention_weight_seq
for attn in step for blk in attn for head in blk]
dec_attention_weights_filled = torch.tensor(
pd.DataFrame(dec_attention_weights_2d).fillna(0.0).values)
dec_attention_weights = dec_attention_weights_filled.reshape((-1, 2, num_layers, num_heads, num_steps))
dec_self_attention_weights, dec_inter_attention_weights = \
dec_attention_weights.permute(1, 2, 3, 0, 4)
dec_self_attention_weights.shape, dec_inter_attention_weights.shape

由于解码器自注意力的自回归属性,查询不会对当前位置之后的“键-值”对进行注意力计算。

1
2
3
4
5
# Plusonetoincludethebeginning-of-sequencetoken
d2l.show_heatmaps(
dec_self_attention_weights[:, :, :, :len(translation.split()) + 1],
xlabel='Key positions', ylabel='Query positions',
titles=['Head %d' % i for i in range(1, 5)], figsize=(7, 3.5))

../_images/output_transformer_5722f1_276_0.svg

与编码器的自注意力的情况类似,通过指定输入序列的有效长度,输出序列的查询不会与输入序列中填充位置的词元进行注意力计算。

1
2
3
4
d2l.show_heatmaps(
dec_inter_attention_weights, xlabel='Key positions',
ylabel='Query positions', titles=['Head %d' % i for i in range(1, 5)],
figsize=(7, 3.5))

../_images/output_transformer_5722f1_291_0.svg

尽管Transformer架构是为了序列到序列 的学习而提出的,但正如本书后面将提及的那样,Transformer编码器或Transformer解码器通常被单独用于不同的深度学习任务中。

  • Transformer是编码器-解码器架构的一个实践,尽管在实际情况中编码器或解码器可以单独使用。
  • 在Transformer中,多头自注意力用于表示输入序列和输出序列,不过解码器必须通过掩蔽机制来保留自回归属性。
  • Transformer中的残差连接和层规范化是训练非常深度模型的重要工具。
  • Transformer模型中基于位置的前馈网络使用同一个多层感知机,作用是对所有序列位置的表示进行转换。