文章目录
- 摘要
- 第十章 注意力机制
- 11.2 注意力汇聚:Nadaraya-Watson核回归
- 10.3 注意力评分函数
- 10.4 使用注意力机制的seq2seq
- 10.5 自注意力和位置编码
- 总结
摘要
本周学习了注意力机制相关内容,包括注意力汇聚(Nadaraya-Watson核回归)、注意力评分函数(加性注意力和缩放点积注意力)、带注意力机制的Seq2Seq模型以及自注意力和位置编码。在实现过程中遇到了注意力权重计算不准确的问题,通过调整softmax函数的输入和维度设置解决了这个问题。此外还遇到位置编码理解困难的情况,通过可视化热力图和对比二进制编码加深了对位置编码原理的理解。
第十章 注意力机制
11.2 注意力汇聚:Nadaraya-Watson核回归
上一节介绍了查询(自主性提示)和键(非自主提示)之间的交互形成了注意力汇聚,注意力汇聚有选择性汇聚了值以生成最终的输出。
生成数据集
根据下面的非线性函数来生成人工数据集
yi=2sin(xi)+xi0.8+ϵy_i = 2\sin(x_i) + x_i^{0.8} + \epsilonyi=2sin(xi)+xi0.8+ϵ
其中,ϵ\epsilonϵ为加入的噪音项,服从均值为0,标准差为0.5的正态分布
import torch
from torch import nn
from d2l import torch as d2l
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) #测试样本数
n_test
平均汇聚
下面函数绘制了所有的训练样本,truth是不带噪音项的真实数据生成函数f,pred是预测函数
# 定义绘制核回归结果的函数
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])
# 用圆圈标记训练数据点,设置半透明效果(alpha=0.5)
d2l.plt.plot(x_train, y_train, 'o', alpha=0.5)
# 创建朴素预测:用训练集y的平均值重复n_test次作为预测(平均汇聚)
y_hat = torch.repeat_interleave(y_train.mean(), n_test)
# 调用绘图函数显示结果
plot_kernel_reg(y_hat)

- 可以发现使用平均汇聚后,真实函数truth和预测函数pred相差很大
非参数注意力汇聚
平均汇聚忽略了输入xi,Nadaraya和Wastson提出了根据输入的位置对输出yi进行加权
f(x)=∑i=1nK(x−xi)∑j=1nK(x−xj)yif(x) = \sum_{i=1}^n \frac{K(x - x_i)}{\sum_{j=1}^n K(x - x_j)} y_if(x)=i=1∑n∑j=1nK(x−xj)K(x−xi)yi
其中K是核,然后可以得到Nadaraya-Wastson核回归。
通过这里的启发,使之成为一个更加通用的注意力汇聚公式:
f(x)=∑i=1nα(x,xi)yif(x) = \sum_{i=1}^n \alpha(x, x_i) y_if(x)=i=1∑nα(x,xi)yi
其中(x)是查询,((x_i, y_i))是键值对。对于任何查询,模型的所有键值对注意力权重都是一个有效的概率分布:他们是非负的,并且下面的总和的为1
为了更好的理解注意力汇聚,考虑了一个高速核,
K(u)=12πexp(−u22).K(u) = \frac{1}{\sqrt{2\pi}} \exp(-\frac{u^2}{2}).K(u)=2π1exp(−2u2).
将高斯核带入后可以得到
KaTeX parse error: No such environment: split at position 7: \begin{̲s̲p̲l̲i̲t̲}̲\begin{aligned}…
- 如果一个键xi越接近给定的查询x,那么分配给这个键对应值yi的注意力权重就会越大,也就是获得了更大的注意力
- 如果一个 yᵢ对应的注意力权重 α(x, xᵢ)越大,那么这个 yᵢ的值对最终输出结果 f(x)的影响就越大。
Nadaraya-Wastson核回归是一个非参数模型,接下来将基于这个非参数的注意力汇聚模型来描绘预测结果
# 将测试数据x_test重复n_train次,并调整形状为(-1, n_train),以便与训练数据x_train进行逐元素计算
X_repeat = x_test.repeat_interleave(n_train).reshape((-1, n_train))
# 计算注意力权重:通过softmax归一化x_test与x_train的负平方距离(高斯核函数)
attention_weights = nn.functional.softmax(-(X_repeat - x_train)**2 / 2, dim=1)
# 预测输出y_hat:对训练标签y_train进行注意力加权求和(矩阵乘法实现)
y_hat = torch.matmul(attention_weights, y_train)
# 绘制核回归结果
plot_kernel_reg(y_hat)

- 从预测结果来新的预测曲线是平滑的,并且比平均汇聚的结果更接近真实情况
然后可以通过绘制热力图来观察一下注意力权重
d2l.show_heatmaps(
attention_weights.unsqueeze(0).unsqueeze(0),
xlabel='Sorted training inputs', ylabel='Sorted testing inputs')

- 这里测试数据的输入相当于查询,训练数据的输入相当于键
- 当“查询-键”对越接近的时候,注意力汇聚的注意力权重就越高
带参数的注意力汇聚
我们可以将可学习参数也集成到注意力汇聚中,主要是在查询x核键xi之间的距离乘以可学习参数w:
KaTeX parse error: No such environment: split at position 7: \begin{̲s̲p̲l̲i̲t̲}̲\begin{aligned}…
1)批量矩阵乘法
假定两个张量的形状分别是 (n,a,b)和 (n,b,c) ,它们的批量矩阵乘法输出的形状为 (n,a,c)
torch.bmm是 批量矩阵乘法(Batch Matrix Multiplication)
它用于对一批矩阵执行矩阵乘法运算。
X = torch.ones((2, 1, 4))
Y = torch.ones((2, 4, 6))
torch.bmm(X, Y).shape

在注意力机制的背景下,可以用小批量矩阵乘法来计算小批量数据中的加权平均值
weights = torch.ones((2, 10)) * 0.1
values = torch.arange(20.0).reshape((2, 10))
torch.bmm(weights.unsqueeze(1), values.unsqueeze(-1))

- unsqueeze(1)在 weights的第1维度(从0开始)增加一个维度,(2, 1, 10)
- unsqueeze(-1)在 values的最后增加一个维度(2, 10, 1)
2)定义模型(带参数版)
class NWKernelRegression(nn.Module):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.w = nn.Parameter(torch.rand((1,), requires_grad=True))
# 可学习的标量参数
def forward(self, queries, keys, values):
# 扩展queries以匹配keys的维度 (批量大小*查询数, 键值数)
queries = queries.repeat_interleave(keys.shape[1]).reshape((-1, keys.shape[1]))
# 计算注意力权重:使用高斯核(softmax归一化的负平方距离)
self.attention_weights = nn.functional.softmax(
-((queries - keys) * self.w)**2 / 2, dim=1)
# 加权求和:注意力权重(批量大小*查询数, 1, 键值数) × 值(批量大小*查询数, 键值数, 1)
return torch.bmm(self.attention_weights.unsqueeze(1),
values.unsqueeze(-1)).reshape(-1) # 展平结果
3)训练
训练带参数的注意力汇聚模型时,使用平方损失函数和随机梯度下降
# 将训练数据x_train和y_train复制n_train次,形成n_train×n_train的矩阵
X_tile = x_train.repeat((n_train, 1)) # shape: (n_train, n_train)
Y_tile = y_train.repeat((n_train, 1)) # shape: (n_train, n_train)
# 使用掩码(1-eye)去除对角线元素(即排除每个样本与自身的配对)
# 然后reshape为(n_train, n_train-1)的张量
keys = X_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))
values = Y_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))
# 初始化Nadaraya-Watson核回归模型
net = NWKernelRegression()
# 使用均方误差损失函数(不自动求平均,保留每个样本的损失)
loss = nn.MSELoss(reduction='none')
# 使用SGD优化器,学习率0.5
trainer = torch.optim.SGD(net.parameters(), lr=0.5)
# 初始化动画可视化器(用于绘制训练过程)
animator = d2l.Animator(xlabel='epoch', ylabel='loss', xlim=[1, 5])
# 训练循环(5个epoch)
for epoch in range(5):
trainer.zero_grad() # 清空梯度
# 前向传播计算损失(除以2是为了与平方项抵消)
l = loss(net(x_train, keys, values), y_train) / 2
l.sum().backward() # 反向传播(计算梯度)
trainer.step() # 更新参数
# 打印当前epoch和总损失值
print(f'epoch {epoch + 1}, loss {float(l.sum()):.6f}')
# 将当前epoch的损失添加到动画中
animator.add(epoch + 1, float(l.sum()))

预测
keys = x_train.repeat((n_test, 1))
values = y_train.repeat((n_test, 1))
y_hat = net(x_test, keys, values).unsqueeze(1).detach()
plot_kernel_reg(y_hat)

- 可以发现:在尝试拟合噪声的训练数据时,预测结果绘制的曲线不如非参数平滑
- 加入了可学习参数后,曲线在注意力权重较大的区域变得不平滑

小结
- Nadaraya-Watson核回归是具有注意力机制的机器学习范例
- Nadaraya-Watson核回归的注意力汇聚是对训练数据输出的加权平均,分配给每个值的注意力权重取决于将值所对应的键和查询作为输入的函数
- 注意力汇聚可以分为非参数型和带参数型
10.3 注意力评分函数
在上一节中,我们使用了高斯核的注意力汇聚函数:
f(x)=∑i=1nα(x,xi)yi=∑i=1nexp(−12(x−xi)w)2∑j=1nexp(−12(x−xi)w)2yi=∑i=1nsoftmax(−12((x−xi)w)2)yi
\begin{array}{c}
f(x)=\sum_{i=1}^{n}\alpha\left(x, x_{i}\right) y_{i}\\
=\sum_{i=1}^{n}\frac{\exp\left(-\frac{1}{2}\left(x-x_{i}\right) w\right)^{2}}{\sum_{j=1}^{n}\exp\left(-\frac{1}{2}\left(x-x_{i}\right) w\right)^{2}} y_{i}\\
=\sum_{i=1}^{n}\operatorname{softmax}\left(-\frac{1}{2}\left(\left(x-x_{i}\right) w\right)^{2}\right) y_{i}
\end{array}
f(x)=∑i=1nα(x,xi)yi=∑i=1n∑j=1nexp(−21(x−xi)w)2exp(−21(x−xi)w)2yi=∑i=1nsoftmax(−21((x−xi)w)2)yi
其中高斯核的指数部分即为评分函数,衡量一个输入序列中每个元素与当前输出元素之间的相似度得分,用于计算权重。
从宏观来看,我们可以使用上述算法来实现注意力机制框架。下图说明了如何将注意力汇聚的输出计算成为值的加权和,其中 a 表示注意力评分函数。由于注意力权重是概率分布,因此加权和其本质上是加权平均值。

用数学语言描述,假设有一个查询q∈Rq\mathbf{q}\in\mathbb{R}^{q}q∈Rq和m个“键-值”对(k1,v1),…,(km,vm)\left(\mathbf{k}_{1},\mathbf{v}_{1}\right),\ldots,\left(\mathbf{k}_{m},\mathbf{v}_{m}\right)(k1,v1),…,(km,vm),其中ki∈Rk\mathbf{k}_{i}\in\mathbb{R}^{k}ki∈Rk,vi∈Rv\mathbf{v}_{i}\in\mathbb{R}^{v}vi∈Rv。注意力汇聚函数f就被表示成值的加权和:
f(q,(k1,v1),…,(km,vm))=∑i=1mα(q,ki)vi∈Rv, f\left(\mathbf{q},\left(\mathbf{k}_{1},\mathbf{v}_{1}\right),\ldots,\left(\mathbf{k}_{m},\mathbf{v}_{m}\right)\right)=\sum_{i=1}^{m}\alpha\left(\mathbf{q},\mathbf{k}_{i}\right)\mathbf{v}_{i}\in\mathbb{R}^{v}, f(q,(k1,v1),…,(km,vm))=i=1∑mα(q,ki)vi∈Rv,
其中查询q和键$ \mathbf{k}_{i} $的注意力权重(标量)是通过注意力评分函数a将两个向量映射成标量,再经过softmax运算得到的:
α(q,ki)=softmax(a(q,ki))=exp(a(q,ki))∑j=1mexp(a(q,kj))∈R. \alpha\left(\mathbf{q},\mathbf{k}_{i}\right)=\operatorname{softmax}\left(a\left(\mathbf{q},\mathbf{k}_{i}\right)\right)=\frac{\exp\left(a\left(\mathbf{q},\mathbf{k}_{i}\right)\right)}{\sum_{j=1}^{m}\exp\left(a\left(\mathbf{q},\mathbf{k}_{j}\right)\right)}\in\mathbb{R}. α(q,ki)=softmax(a(q,ki))=∑j=1mexp(a(q,kj))exp(a(q,ki))∈R.
正如我们所看到的,选择不同的注意力评分函数 a会导致不同的注意力汇聚操作。接下来我们将介绍两个流行的评分函数,稍后将用他们来实现更复杂的注意力机制。
加性注意力 Additive attention
当查询和键是不同长度的向量时,可以使用加性注意力作为评分函数。

- 等价于将key-value合并起来后放入到一个隐藏大小为h输出大小为1的单隐藏层MLP,用非线性激活函数tanh作为激活函数。
#具备加性注意力的神经网络
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) #定义键Key的全连接层
self.W_q = nn.Linear(query_size, num_hiddens, bias=False) #定义查询Query的全连接层
self.w_v = nn.Linear(num_hiddens, 1, bias=False) #定义值Value的全连接层
self.dropout = nn.Dropout(dropout) #定义暂退法
#前向传播函数
def forward(self, queries, keys, values, valid_lens):
#此时queries的形状(batch_size, 查询的个数,num_hiddens)
# keys的形状(batch_size, ”键-值“对的个数,num_hiddens)
queries, keys = self.W_q(queries), self.W_k(keys)
# 在维度扩展后
# queries的形状: (batch_size, 查询的个数,1,num_hidden)
# key的形状: (batch_size,1,”键-值“对的个数,num_hiddens)
#features的形状: (batch_size, 查询的个数,”键-值“对的个数, num_hiddens)
features = queries.unsqueeze(2) + keys.unsqueeze(1)
#使用tanh激活函数
features = torch.tanh(features)
# self.w_v 仅有一个输出,因此从形状中移除最后那个维度
# scores的形状: (batch_size,查询的个数,”键-值“的个数)
scores = self.w_v(features).squeeze(-1)
self.attention_weights = masked_softmax(scores, valid_lens)
# values的形状: (batch_size, ”键-值“的个数,值的维度)
return torch.bmm(self.dropout(self.attention_weights), values)
缩放点积注意力


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):
# 首先获取quereis和keys的最后一个维度d
d = queries.shape[-1]
# 设置transpose_b=True为了交换keys的最后两个维度,即实现 (queries @ keys.T) / sqrt(d)
# 此时scores的形状为: (batch_size, 查询个数,“键-值”对个数)
scores = torch.bmm(queries, keys.transpose(1, 2)) / math.sqrt(d)
# 对注意力评分函数所获结果scores进行softmax操作得到注意力权重矩阵attention_weights
self.attention_weights = masked_softmax(scores, valid_lens)
# 最后和values(batch_size,“键-值”对个数,输出维度)相乘,得到最终结果(batch_size, 查询个数,输出维度)
return torch.bmm(self.dropout(self.attention_weights), values)
掩蔽softmax操作
任何超过有效长度的位置都被掩蔽并置为0
#@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)
else:
shape = X.shape
if valid_lens.dim() == 1:
#一维数据,代表对每个列表作切分,即会复制shape[1]个valid_len
valid_lens = torch.repeat_interleave(valid_lens, shape[1])
else:
#二维数据,代表对每个样本作切分,则直接展开valid_len进行切分
valid_lens = valid_lens.reshape(-1)
# 最后一轴上被掩蔽的元素使用一个非常大的负值替换,从而其softmax输出为0
X = d2l.sequence_mask(X.reshape(-1, shape[-1]), valid_lens,
value=-1e6)
#返回softmax后的X
return nn.functional.softmax(X.reshape(shape), dim=-1)
小结
- 将注意力汇聚的输出计算可以作为值的加权平均,选择不同的注意力评分函数会带来不同的注意力汇聚操作
- 当查询和键是不同长度的向量时,可以使用加性注意力评分函数。当它们的长度相同时,使用缩放点积注意力评分函数的计算效率更高。
10.4 使用注意力机制的seq2seq
- 注意力机制在 NLP 中的应用,也是最早的工作之一
动机

- 在机器翻译的时候,每个生成的词可能相关于源句子中不同的词
- 在语言翻译的时候,中文和英文之间的翻译可能会存在倒装,但是可能在西方语言之间,相同意思的句子中的词的位置可能近似地是对应的,所以在翻译句子的某个部位的时候,只需要去看源句子中对应的位置就可以了
- 然而,Seq2Seq 模型中不能对此直接建模。Seq2Seq 模型中编码器向解码器中传递的信息是编码器最后时刻的隐藏状态,解码器只用到了编码器最后时刻的隐藏状态作为初始化,从而进行预测,所以解码器看不到编码器最后时刻的隐藏状态之前的其他隐藏状态
- 源句子中的所有信息虽然都包含在这个隐藏状态中,但是要想在翻译某个词的时候,每个解码步骤使用编码相同的上下文变量,但是并非所有输入(源)词元都对解码某个词元有用。将注意力关注在源句子中的对应位置,这也是将注意力机制应用在Seq2Seq 模型中的动机
加入注意力机制

- 编码器对每次词的输出(隐藏状态)作为 key 和 value,序列中有多少个词元,就有多少个 key-value 对,它们是等价的,都是第 i 个词元的 RNN 的输出
- 解码器 RNN 对上一个词的预测输出(隐藏状态)是 query(假设 RNN 的输出都是在同一个语义空间中,所以在解码器中对某个词元进行解码的时候,需要用到的是 RNN 的输出,而不能使用词嵌入之后的输入,因为 key 和 value 也是 RNN 的输出,所以 key 和 query 做匹配的时候,最好都使用 RNN 的输出,这样能够保证它们差不多在同一个语义空间)
- 注意力的输出和下一个词的词嵌入合并进入 RNN 解码器
- 对 Seq2Seq 的改进之处在于:之前 Seq2Seq 的 RNN 解码器的输入是 RNN 编码器最后时刻的隐藏状态,加入注意力机制之后的模型相当于是对所有的词进行了加权平均,根据翻译的词的不同使用不同时刻的 RNN 编码器输出的隐藏状态。
代码实现
带有注意力机制的解码器基本接口
import torch
from torch import nn
from d2l import torch as d2l
class AttentionDecoder(d2l.Decoder):
"""带有注意力机制的解码器基本接口"""
def __init__(self, **kwargs):
super(AttentionDecoder, self).__init__(**kwargs)
@property
def attention_weights(self):
raise NotImplementedError
- @property是 Python 的一个内置装饰器,用于将一个方法转换为属性(property),使得可以像访问属性一样调用方法,而不需要使用 ()来调用。
- attention_weights是一个 抽象属性(abstract property),它使用 @property装饰器定义,目的是强制子类必须实现这个属性
实现带有Bahdanau注意力的循环神经网络解码器
class Seq2SeqAttentionDecoder(AttentionDecoder):
"""带注意力机制的Seq2Seq解码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqAttentionDecoder, self).__init__(**kwargs)
# 加性注意力机制
self.attention = d2l.AdditiveAttention(num_hiddens, num_hiddens,
num_hiddens, dropout)
# 词嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
# GRU循环网络(输入维度:embed_size + num_hiddens)
self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
# 全连接输出层
self.dense = nn.Linear(num_hiddens, vocab_size)
def init_state(self, enc_outputs, enc_valid_lens, *args):
"""初始化解码器状态(调整编码器输出维度顺序)"""
outputs, hidden_state = enc_outputs
return (outputs.permute(1, 0, 2), hidden_state, enc_valid_lens)
def forward(self, X, state):
"""前向传播"""
enc_outputs, hidden_state, enc_valid_lens = state
X = self.embedding(X).permute(1, 0, 2) # 词嵌入并调整维度
outputs, self._attention_weights = [], []
for x in X: # 逐时间步处理
# 计算注意力上下文(query使用最后一个隐藏状态)
query = torch.unsqueeze(hidden_state[-1], dim=1)
context = self.attention(query, enc_outputs, enc_outputs,
enc_valid_lens)
# 拼接上下文和当前输入
x = torch.cat((context, torch.unsqueeze(x, dim=1)), dim=-1)
# GRU计算
out, hidden_state = self.rnn(x.permute(1, 0, 2), hidden_state)
outputs.append(out)
self._attention_weights.append(self.attention.attention_weights)
# 全连接层输出并调整维度
outputs = self.dense(torch.cat(outputs, dim=0))
return outputs.permute(1, 0, 2), [
enc_outputs, hidden_state, enc_valid_lens]
@property
def attention_weights(self):
"""返回注意力权重(@property使其成为只读属性)"""
return self._attention_weights
- 这里添加了加性注意力来实现解码器,由于查询和键是不同长度的向量
- self._attention_weights是一个列表,保存所有时间步的权重
- 通过 @property暴露为只读属性(attention_weights),防止外部意外修改。
测试Bahdanau 注意力解码器
# 定义并初始化编码器(评估模式)
encoder = d2l.Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
encoder.eval() # 设置为评估模式(关闭dropout等)
#定义并初始化注意力解码器(评估模式)
decoder = Seq2SeqAttentionDecoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
decoder.eval()
# 创建全零输入张量(模拟4个样本,序列长度7)
X = torch.zeros((4, 7), dtype=torch.long) # 形状(batch_size, seq_len)
# 初始化解码器状态(通过编码器输出)
state = decoder.init_state(encoder(X), None) # None表示无有效长度限制
# 执行解码过程
output, state = decoder(X, state)
#检查输出和状态的形状
output.shape, len(state), state[0].shape, len(state[1]), state[1][0].shape

训练
# 定义模型超参数:嵌入维度32,隐藏单元32,2层,丢弃率0.1
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
# 设置训练参数:批量64,序列长度10,学习率0.005,250轮,使用GPU
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 250, d2l.try_gpu()
# 加载NMT数据集并获取词表
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
# 初始化编码器(输入维度=源词表大小)
encoder = d2l.Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
# 初始化带注意力的解码器(输出维度=目标词表大小)
decoder = Seq2SeqAttentionDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
# 构建编码器-解码器模型
net = d2l.EncoderDecoder(encoder, decoder)
# 训练Seq2Seq模型
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

将几个英语句子翻译成法语
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}')

- 可以发现加入了注意力机制后的模型翻译的准确率提高了很多
可视化注意力权重
# 将解码器各时间步的注意力权重拼接成张量
# step[0][0][0]提取每个时间步的注意力权重矩阵
attention_weights = torch.cat(
[step[0][0][0] for step in dec_attention_weight_seq], 0).reshape(
(1, 1, -1, num_steps)) # 重塑为(1,1,T,num_steps)格式
# 绘制热力图(仅显示有效序列部分的注意力)
# engs[-1].split()获取最后一个英文句子的单词数
d2l.show_heatmaps(
attention_weights[:, :, :, :len(engs[-1].split()) + 1].cpu(), # 截取有效位置+1(EOS)
xlabel='Key positions', # x轴:编码器位置
ylabel='Query positions') # y轴:解码器时间步

- 每个查询都会在键值对上分配不同的权重,这说明在每个解码步中,输入序列的不同部分被选择性地聚集在注意力池中。
小结
- Seq2Seq 中通过编码器最后时刻的隐藏状态在编码器和解码器中传递信息
- 注意力机制可以根据解码器 RNN 的输出来匹配到合适的编码器 RNN 的输出来更有效地传递信息
- 在预测词元时,如果不是所有输入词元都是相关的,加入注意力机制能够使 RNN 编码器-解码器有选择地统计输入序列的不同部分(通过将上下文变量视为加性注意力池化的输出来实现)
10.5 自注意力和位置编码
- 在深度学习中,经常使用卷积神经网络或者循环神经网络对序列进行编码
- 对于 key 、value 和 query ,自注意力有自己的一套选法,因为 key 、value 和 query 的值来自同一组输入,因此被称为自注意力(self-attention)或者内部注意力(intra-attention)
自注意力

- 给定序列是一个长为 n 的序列,每个 xi 是一个长为 d 的向量
- 自注意力将 xi 同时作为 key 、value 和 query ,以此来对序列抽取特征
- 基本上可以认为给定一个序列,会对序列中的每一个元素进行输出,也就是说,每个查询都会关注所有的键-值对并生成一个注意力输出
- 自注意力之所以叫做自注意力,是因为 key,value,query 都是来自于自身,xi 既作为 key ,又作为 value ,同时还作为 query (self-attention 中的 self 所强调的是 key,value,query 的取法)
和 CNN,RNN 对比

- CNN、RNN、自注意力都可以用来处理序列
- CNN 如何处理序列:给定一个序列,将其看作是一个一维的输入(之前在处理图片时,图片具有高和宽,而且每个像素都具有 chanel 数,也就是特征数),如果用 CNN 做序列的话,经过一个 1d 的卷积(只有宽没有高)之后,将每个元素的特征看作是 channel 数,这样就可以用来处理文本序列了
- k:窗口大小,每次看到的长度为 k
- n:长度
- d:dimension,每个 x 的维度(长度)
- 并行度:每个输出( yi )可以自己并行做运算,因为 GPU 有大量的并行单元,所以并行度越高,计算的速度就越快
- 最长路径:对于最长的那个序列,前面时刻的信息通过神经元传递到后面时刻,对应于计算机视觉中的感受野的概念(每一个神经元的输出对应的图片中的视野)
- 卷积神经网络和自注意力都拥有并行计算的优势,而且自注意力的最大路径长度最短。但是因为自注意力的计算复杂度是关于序列长度的二次方,所以在很长的序列中计算会非常慢
位置编码
1、和 CNN / RNN 不同,自注意力并没有记录位置信息
- CNN 中其实是有记录位置信息的,从输出可以反推出输入所在的窗口的位置,窗口大小可以看成是位置信息
- RNN 本身就是序列相关的,它是通过逐个的重复地处理词元
- 对于自注意力来说,如果将输入进行随机打乱,对应输出的位置可能会发生变化,但是每个输出的内容不会发生变化
- 所以如果是想纯用自注意力机制来做序列模型的话,没有位置信息的话可能会出现问题,所以可以通过加入位置编码来加入位置信息
2、为了使用序列的顺序信息,通过在输入表示中添加位置编码将位置信息注入到输入里

- 位置编码不是将位置信息加入到模型中,一旦位置信息加入到模型中,会出现各种问题(比如在 CNN 中就需要看一个比较长的序列,RNN 中会降低模型的并行度)
- P 中的每个元素根据对应的 X 中元素位置的不同而不同
3、P 的元素具体计算如下:


- 对于 P 中的每一列,奇数列是一个 cos 函数,偶数列是一个 sin 函数,不同的列之间的周期是不一样的
位置编码矩阵

- X 轴横坐标表示 P 矩阵中的行数
- 图中不同颜色的曲线表示 P 矩阵中不同的列
- 这里可以理解为 X 轴上任意一点对应的 j 列的曲线上在 Y 轴的值,就表示 P 矩阵第 X 行第 j 列的元素的值
- 图中的四条曲线分别代表了第 6 、7 、8 、9 列,从图中可以看出,第 6 列是一个 sin 函数,第 7 列在第 6 列的基础上发生了位移,变成了 cos 函数,第 8 列在第 6 列的基础上周期变长了一倍,仍然是 sin 函数,第 9 列在第 8 列的基础上发生了唯一,变成了 cos 函数
从图中可以看出,对于 P 矩阵中同一行,不同的列的元素的数值是不同的,也就是说,对于输入序列(X + P 作为自编码输入)来讲,每个 dimension 所加的值是不同的;同样的,对于同一个输入序列,不同的样本所加的值也是不同的(对于同一条曲线,X 不同的情况下,即不同的行,元素的值也是不同的,这里 sin 函数和 cos 函数都是周期函数,应该讲的是在同一个周期内的样本) - P 实际上是对每一个样本(row)、每一个维度(dimension)添加一点不一样的值,使得模型能够分辨这种细微的差别,作为位置信息
- 这种方式跟之前的方式的不同之处在于,之前是
将位置信息放进模型中或者将位置信息与数据分开然后进行拼接(concat),位置编码是直接将位置信息加入到了数据中 ,这样做的好处是不改变模型和数据的大小,缺点是需要模型对于 P 中元素的细微信息进行辨认,取决于模型是否能够有效地使用 P 中的位置信息
绝对位置信息
计算机使用的是二进制编码

- 可以认为,假设计算机要表示八个数字的话,可以用一个长为 3 的特征来表示,可以认为是一个三维的特征,每一个维度都在 0 和 1 之间进行变化,而且变化的频率不同,最后一维变化的频率最快,最前面一维变化的频率最慢
位置矩阵编码可以认为和计算机的二进制编码类似
- 首先,位置编码是实数(因为对应的输入也是实数),是在 1 和 -1 之间进行实数的变化,所以能编码的范围更广,可以在任意多的维度上进行编码
- 其次,因为位置编码中所使用的 sin 函数和 cos 函数都是周期函数,所以位置编码也是存在周期性的

- 上图是一个热度图,和上一个图是一样的,只不过将 X 轴和 Y 轴进行了翻转
- X 轴表示特征,Y 轴表示样本
- 可以认为是对每一行的位置信息进行了编码,将第 i 个样本用一个长为 d 的向量进行编码
这里和计算机的二进制编码有一点不同,最前面的维度变化频率比较高,越到后面变化频率越来越慢 - 核心思想是对序列中的第 i 个样本,给定长为 d 的独一无二的位置信息,然后加入到数据中作为自编码输入,使得模型能够看到数据的位置信息
相对位置信息
为什么要使用 sin 函数和 cos 函数?
- 编码的是一个相对位置信息,位置位于 i + σ 处的位置编码可以线性投影位置 i 处的位置编码来表示,也就是说位置信息和绝对位置 i 无关,只是和相对位置 σ 有关

- 投影矩阵和序列中的位置 i 是无关的,但是和 j 是相关的(和 dimension 的信息是相关的),意味着在一个序列中,假设一个词出现在另外一个词两个或者三个位置的时候,不管这对词出现在序列中的什么位置,对于位置信息来讲,都是可以通过一个同样的线性变换查找出来的
- 相对来讲,这样编码的好处在于模型能够更加关注相对的位置信息,而不是关注一个词出现在一个句子中的绝对位置
代码实现
自注意力
import math
import torch
from torch import nn
from d2l import torch as d2l
#隐藏层大小100,注意力头数5
num_hiddens, num_heads = 100, 5
# 查询/键/值的维度均为num_hiddens(100),输出维度num_hiddens(100)
# 使用5个注意力头,dropout率为0.5
attention = d2l.MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,
num_hiddens, num_heads, 0.5)
# 将注意力层设置为评估模式(关闭dropout等训练专用操作)
attention.eval()

- 多头注意力模块定义了 4 个线性层(Linear),用于计算 查询(Query)、键(Key)、值(Value)和输出(Output)
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

- 多头注意力模块定义了 4 个线性层(Linear),用于计算 查询(Query)、键(Key)、值(Value)和输出(Output)
位置编码
class PositionalEncoding(nn.Module):
def __init__(self, num_hiddens, dropout, max_len=1000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(dropout) # 初始化Dropout层,用于正则化
self.P = torch.zeros((1, max_len, num_hiddens)) # 初始化位置编码矩阵 (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)
# 交替使用sin和cos函数生成位置编码
self.P[:, :, 0::2] = torch.sin(X) # 偶数位置使用sin
self.P[:, :, 1::2] = torch.cos(X) # 奇数位置使用cos
def forward(self, X):
X = X + self.P[:, :X.shape[1], :].to(X.device) # 将位置编码加到输入X上
return self.dropout(X) # 应用Dropout后返回
行代表标记在序列中的位置,列代表位置编码的不同维度
# 定义位置编码的维度(32)和序列长度(60)
encoding_dim, num_steps = 32, 60
# 创建位置编码实例,dropout设为0(不启用)
pos_encoding = PositionalEncoding(encoding_dim, 0)
pos_encoding.eval() # 设置为评估模式
# 生成全零输入并添加位置编码
X = pos_encoding(torch.zeros((1, num_steps, encoding_dim)))
# 提取前num_steps步的位置编码
P = pos_encoding.P[:, :X.shape[1], :]
# 绘制第6到第9维的位置编码随位置变化的曲线
d2l.plot(torch.arange(num_steps), P[0, :, 6:10].T, # 取第0个batch,所有位置,第6-9维
xlabel='Row (position)', # x轴标签
figsize=(6, 2.5), # 图像大小
legend=["Col %d" % d for d in torch.arange(6, 10)]) # 图例显示维度6-9

二进制表示
这里将0-7转换为了三位二进制数
for i in range(8):
print(f'{i} in binary is {i:>03b}')

在编码维度上降低频率
P = P[0, :, :].unsqueeze(0).unsqueeze(0)
d2l.show_heatmaps(P, xlabel='Column (encoding dimension)',
ylabel='Row (position)', figsize=(3.5, 4), cmap='Blues')

- 位置编码的频率随维度增加而单调递减
- 相邻位置的编码在低维度差异大(高频),在高维度差异小(低频)
1、为什么需要位置编码?
- Transformer本身不具备序列位置信息,通过位置编码注入绝对/相对位置信息,使模型理解顺序。
2.为什么频率随维度降低?
-
低维度高频:捕捉细粒度的局部位置关系(如相邻词的位置差异)。
-
高维度低频:捕捉粗粒度的全局位置关系(如段落或长距离依赖)。
3.为什么用sin/cos交替?
- 通过正弦函数的线性组合性质,使模型能学习到相对位置关系(如“位置A+位置B”的编码可表示为线性变换)。
总结
1、自注意力池化层将 xi 当作 key ,value query 来对序列抽取特征
2、完全并行、最长序列为 1 、但对长序列计算复杂度高
-
可以完全并行,和 CNN 是一样的,所以计算效率比较高
-
最长序列为 1 ,对于任何一个输出都能够看到整个序列信息,所以这也是为什么当处理的文本比较大、序列比较长的时候,通常会用注意力和自注意力
-
但是问题是对长序列的计算复杂度比较高,这也是一大痛点
3、位置编码在输入中加入位置信息,使得自注意力能够记忆位置信息 -
类似于计算机的数字编码,对每个样本,给定一个长为 d 的编码
-
编码使用的是 sin 函数或者是 cos 函数,使得它对于序列中两个固定距离的位置编码,不管它们处于序列中的哪个位置,他们的编码信息都能够通过一个线性变换进行转换
总结
本周重点学习了注意力机制的核心概念和应用,包括注意力汇聚、评分函数、带注意力机制的Seq2Seq模型以及自注意力和位置编码的实现原理。通过实践加深了对注意力权重计算和位置编码的理解,并解决了相关实现问题。下周计划继续深入学习Transformer架构,重点研究多头注意力机制、前馈网络以及编码器-解码器结构,同时结合具体任务(如机器翻译或文本生成)进行代码实践,以巩固理论知识和提升实际应用能力。
由于本周有很多讲座和开学报到的事情,学习进度比较慢,下周将会加快学习进度。
244

被折叠的 条评论
为什么被折叠?



