动手学深度学习——使用注意力机制的 Seq2Seq 代码
1. 前言
上一篇我们已经从原理上理解了使用注意力机制的 Seq2Seq:
-
基础 Seq2Seq 的问题是固定上下文向量
-
注意力机制让解码器在每一步都能动态查看输入序列
-
query 通常来自解码器当前状态
-
key 和 value 通常来自编码器所有时间步输出
这一篇就继续按李沐的节奏,把这套模型真正落实到代码上。
这一节最核心的问题就是:
-
注意力版解码器怎么写
-
编码器输出如何作为 keys / values 被保存下来
-
解码器每一步如何计算当前上下文向量
-
上下文向量如何和当前输入一起送入循环层
-
输出如何变成目标词表上的预测分布
如果一句话概括这一节代码的灵魂,那就是:
解码器每一步先做注意力,再做解码。
2. 先回顾整体结构
注意力版 Seq2Seq 的整体流程可以先写成这样:
第一步:编码器处理源序列
得到:
-
所有时间步输出
enc_outputs -
最终状态
hidden_state
第二步:解码器初始化
把编码器结果组织成解码器所需状态。
第三步:解码器逐步生成
在每个时间步:
-
用当前解码器状态做 query
-
对编码器输出做注意力
-
得到当前上下文向量
-
再结合当前输入 token 做解码
所以和基础 Seq2Seq 相比,最大的变化不在编码器,而在:
解码器多了一个“先注意、再更新”的步骤。
3. 编码器部分通常变化不大
在很多实现里,注意力版 Seq2Seq 的编码器仍然可以沿用前面的 GRU 编码器。
例如:
class Seq2SeqEncoder(d2l.Encoder):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqEncoder, self).__init__(**kwargs)
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers,
dropout=dropout)
def forward(self, X, *args):
X = self.embedding(X)
X = X.permute(1, 0, 2)
output, state = self.rnn(X)
return output, state
这段代码和基础 Seq2Seq 编码器几乎一样。
为什么?
因为注意力机制并没有要求编码器必须换结构,
它只是要求:
编码器要把所有时间步输出保留下来。
而这个编码器本来就返回:
-
output:所有时间步隐藏状态 -
state:最后状态
所以它已经满足需求了。
4. 为什么编码器返回的 output 在这里特别重要
在基础 Seq2Seq 里,我们更关心的是:
state
也就是最终状态。
但在注意力版 Seq2Seq 里:
output
同样非常关键。
因为它的形状一般是:
(num_steps, batch_size, num_hiddens)
这相当于:
源句子每个位置都有一个上下文表示。
后面解码器做注意力时,正是要对这些位置进行动态加权。
所以可以说:
-
基础 Seq2Seq:主要依赖
state -
注意力 Seq2Seq:强依赖
output
这正是两者的信息使用方式差异。
5. 注意力版解码器为什么是这一节的重点
因为编码器只是“提供信息”,
而真正“决定每一步怎么取信息”的,是解码器。
所以注意力版 Seq2Seq 解码器相比基础版本,核心新增了一件事:
在每个时间步,先调用注意力层,生成当前上下文向量。
然后再拿这个上下文向量去更新解码状态、输出预测。
也就是说,解码器从原来的:
-
输入 token → RNN → 输出
变成了:
-
当前状态 → Attention → 上下文
-
上下文 + 输入 token → RNN → 输出
这就是代码结构上最本质的变化。
6. 注意力版解码器的初始化通常怎么写
李沐这里常见的实现大致如下:
class Seq2SeqAttentionDecoder(d2l.AttentionDecoder):
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)
self.rnn = nn.GRU(
embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
self.dense = nn.Linear(num_hiddens, vocab_size)
这段初始化代码非常值得逐项拆解。
7. 为什么这里先定义 self.attention
因为注意力已经成了解码器内部的核心子模块。
这一句:
self.attention = d2l.AdditiveAttention(...)
意味着:
解码器每一步都会先调用一个加性注意力层。
这里用的是加性注意力,不是点积注意力。
原因也很自然:
-
这是早期 Seq2Seq 很经典的做法
-
更符合 Bahdanau attention 这条线
-
教学上更容易和前面内容衔接
所以你可以把它理解为:
解码器内部自带一个“动态查源句信息”的模块。
8. 为什么 self.rnn 的输入维度是 embed_size + num_hiddens
这一句特别关键:
self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
它说明,送进 GRU 的当前步输入,不只是目标 token 的 embedding,
还要拼上:
当前注意力得到的上下文向量
也就是说,在解码器每个时间步,真正输入给循环层的是:
-
当前目标 token embedding
-
当前上下文向量 context
拼接后的总维度自然就是:
embed_size + num_hiddens
这正体现了注意力机制的本质:
每一步生成都带着“当前从源句读出来的信息”一起进行。
9. init_state 为什么和基础版不同
注意力版解码器常见的 init_state 写法如下:
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)
这段代码非常关键,因为它说明:
解码器初始化状态不再只是“最终隐藏状态”那么简单了。
它通常会返回一个三元组:
-
编码器所有时间步输出
-
编码器最终隐藏状态
-
编码器有效长度
这三样东西,后面每一步做注意力都要用到。
10. 为什么要 outputs.permute(1, 0, 2)
编码器输出 outputs 默认常常是:
(num_steps, batch_size, num_hiddens)
但注意力层往往更希望 keys / values 的组织方式是:
(batch_size, num_steps, num_hiddens)
这样更符合“一个 batch 里,每条样本对应一串 key/value 表示”的直觉。
所以这里做:
outputs.permute(1, 0, 2)
就是把时间维和 batch 维交换一下,
方便后续把编码器输出当作:
-
keys
-
values
来使用。
11. 为什么解码器状态里还要带 enc_valid_lens
因为源句通常有 padding。
例如一个 batch 中:
-
第一句真实长度 7
-
第二句真实长度 5
-
都 pad 到了 10
那么做注意力时,后面 pad 的那几位其实不该被关注。
所以必须把有效长度也保存到状态里,后面交给注意力层 mask。
也就是说,这里的状态不是单纯“神经状态”,
还包含了:
做注意力时必要的辅助信息
这也是注意力版解码器比基础版更复杂的原因之一。
12. 解码器前向传播通常怎么写
李沐这里注意力版解码器的核心 forward 逻辑,大致如下:
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 = 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)
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]
这段代码就是这一节最核心的部分。
13. 为什么这里要先把 X 做 embedding 再 permute
和前面一样,输入 X 一般是:
(batch_size, num_steps)
先过 embedding 以后变成:
(batch_size, num_steps, embed_size)
再通过:
permute(1, 0, 2)
变成:
(num_steps, batch_size, embed_size)
这样我们就可以在 for x in X: 里逐时间步处理目标输入。
每次 x 的形状大致是:
(batch_size, embed_size)
这正适合做“当前时间步解码”。
14. query = hidden_state[-1].unsqueeze(1) 在做什么
这一句是注意力版解码器最关键的一步:
query = torch.unsqueeze(hidden_state[-1], dim=1)
这里:
hidden_state[-1]
表示当前解码器最顶层的隐藏状态,形状通常是:
(batch_size, num_hiddens)
再 unsqueeze(1) 后,就变成:
(batch_size, 1, num_hiddens)
为什么这么做?
因为注意力层一般希望 query 形状带上一个“查询个数”维度,
这里我们当前时间步只有 1 个 query,
所以就把它扩成:
每个样本一个 query
这表示:
当前解码器状态,就是当前步的注意力查询需求。
15. context = self.attention(query, enc_outputs, enc_outputs, enc_valid_lens) 在做什么
这一句就是把前面所有注意力知识真正用起来了:
context = self.attention(query, enc_outputs, enc_outputs, enc_valid_lens)
这里:
-
query:当前解码器状态 -
enc_outputs第一次作为keys -
enc_outputs第二次作为values -
enc_valid_lens:用于 mask padding
最终得到的 context 形状通常是:
(batch_size, 1, num_hiddens)
这可以理解成:
当前时间步专属的上下文向量
也就是说,解码器终于不再一直用同一个上下文了,
而是每一步重新算一个新的 context。
16. 为什么 keys = values = enc_outputs
因为最经典的 Seq2Seq 注意力里,编码器每个时间步输出既承担:
-
匹配对象(key)
-
信息内容(value)
这很自然。
作为 key
用于和当前 query 比较相关性,决定注意力权重。
作为 value
用于最后加权求和,形成上下文向量。
所以这一步等于是在说:
源句各位置既提供“索引”,也提供“内容”。
17. 为什么要把 context 和当前输入 x 拼起来
这一句非常重要:
x = torch.cat((context, torch.unsqueeze(x, dim=1)), dim=-1)
意思是把:
-
当前上下文向量
context -
当前目标 token embedding
x
拼接在一起。
为什么要这样做?
因为解码器当前步要做预测,应该同时基于两部分信息:
第一部分:当前目标前缀信息
由当前输入 token embedding 提供。
第二部分:当前从源句读出来的信息
由注意力上下文向量提供。
拼起来以后,解码器当前步输入就不再只是“我前面生成到哪里了”,
而是:
我前面生成到哪里了 + 我现在从源句里读到了什么
这就是注意力版 Seq2Seq 解码器的精髓。
18. 为什么送进 GRU 前又要 permute(1, 0, 2)
拼接后的 x 形状通常是:
(batch_size, 1, embed_size + num_hiddens)
但 GRU 默认希望输入是:
(num_steps, batch_size, input_size)
而此时我们当前只处理一个时间步,所以时间步长度就是 1。
于是要转成:
(1, batch_size, embed_size + num_hiddens)
这样当前步数据才能正确送进 GRU。
19. 为什么 outputs 和 attention_weights 都要存起来
在循环里有两句特别值得注意:
outputs.append(out)
self._attention_weights.append(self.attention.attention_weights)
outputs.append(out)
保存每个时间步的输出,后面统一拼起来做词表预测。
attention_weights.append(...)
保存每个时间步的注意力权重,后面可以:
-
可视化
-
分析模型到底在关注源句哪里
这很有价值,因为注意力机制最大的优点之一就是:
可解释性更强
你能看到模型在不同解码步上,把注意力分配给了哪些输入位置。
20. 最后为什么还要 dense(torch.cat(outputs, dim=0))
循环里每个 out 通常只是解码器 GRU 的隐藏状态输出,
形状类似:
(1, batch_size, num_hiddens)
把所有时间步拼起来以后,得到:
(num_steps, batch_size, num_hiddens)
但这还不是最终预测分布。
所以还要再接一个线性层:
self.dense = nn.Linear(num_hiddens, vocab_size)
把每个时间步的隐藏表示映射到目标词表大小。
这样才得到:
每个时间步对所有目标 token 的打分
这和前面语言模型、基础 Seq2Seq 的输出头逻辑是一致的。
21. 为什么返回时状态还是 [enc_outputs, hidden_state, enc_valid_lens]
因为下一轮解码还要继续用这三样东西:
-
enc_outputs:作为后续 attention 的 keys/values -
hidden_state:作为解码器自身延续状态 -
enc_valid_lens:后续 attention mask 还要用
所以注意力版解码器状态里,不只是“循环状态”,
而是一整组完成解码所需的信息包。
你可以理解成:
解码器需要一直带着“源句信息库 + 当前自身状态 + 有效长度规则”往后走。
22. 这一节代码相比基础 Seq2Seq,最本质多了什么
如果一句话概括,就是:
每个解码时间步,多了一次 attention 计算。
具体来说,多出来的是:
基础版
-
直接拿固定上下文
-
和输入拼接
-
送进 GRU
注意力版
-
用当前隐藏状态生成 query
-
和编码器所有输出做 attention
-
得到当前动态 context
-
再和当前输入拼接
-
送进 GRU
所以真正升级的地方就在于:
context 不再固定,而是每一步重新计算。
这就是代码层面最本质的不同。
23. 这一节最该掌握什么
如果从学习重点看,最关键的是下面几件事。
23.1 解码器状态里有什么
要知道不只是 hidden state,还有:
-
编码器输出
-
有效长度
23.2 query 是怎么来的
通常来自当前解码器顶层隐藏状态。
23.3 keys / values 是什么
通常都是编码器所有时间步输出。
23.4 context 是怎么和输入结合的
是拼接,不是替换。
23.5 注意力机制在代码中插入的位置
是在每个解码时间步,先 attention,再 GRU。
24. 本节总结
这一节我们学习了使用注意力机制的 Seq2Seq 代码实现,核心内容可以总结为以下几点。
24.1 编码器通常仍然可以沿用基础 Seq2Seq 编码器
关键是保留所有时间步输出。
24.2 解码器初始化状态比基础版本更丰富
通常包含:
-
编码器所有输出
-
解码器初始隐藏状态
-
有效长度
24.3 每个解码时间步都会先计算当前注意力上下文向量
而不是使用固定上下文。
24.4 当前上下文向量会和当前输入 token embedding 拼接后送入 GRU
这让解码器在每一步都能结合源句动态信息。
24.5 注意力权重通常会被保存下来,便于后续分析和可视化
这是注意力模型很重要的附加价值。
25. 学习感悟
这一节非常关键,因为它标志着 Seq2Seq 模型终于真正“长了眼睛”。
基础 Seq2Seq 更像是:
先把输入看完,记一个总结,然后闭着眼往后写。
而注意力版 Seq2Seq 则是:
一边写,一边不断回头看输入中当前最相关的位置。
这个区别在代码里并不只是“多了几行”,
而是从根本上改变了模型的信息使用方式。
也正因为如此,注意力机制才会成为后来几乎整个现代 NLP 架构的基础。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)