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. 为什么 outputsattention_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 架构的基础。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐