这篇笔记主要记录:
- Seq2Seq: 机器翻译这类 序列到序列 的任务,需要先理解完整输入再生成输出, 而非 RNN 那样仅预测下一个 token.
- 点积注意力: 计算两个矩阵 X1 X2 的相似度,然后进一步计算 X1 融合了 X2 信息后的矩阵
- 带点积注意力的Seq2Seq: 增加点积注意力,将编码器、解码器的隐藏状态联系起来,也就是能关注到输入序列中的重要部分,从而更好地捕捉上下文相关性
1. Seq2Seq
上一篇笔记讲到了 RNN, RNNLM 可以输入前面的词预测下一个词,例如 I love deep → learning ,即 sequence → next token
机器翻译则是另外一个形式,要求输入:I love you 输出: 我爱你,即一个输入序列 → 一个输出序列(sequence-to-sequence),因此后来发展出了 Encoder-Decoder(Seq2Seq)结构。
Seq2Seq 会先通过 Encoder 将输入序列编码为上下文表示(hidden state),再将其作为 Decoder 的初始 hidden state,逐步生成输出序列:

而这个编码器、解码器,为了简化说明,使用最基础的 nn.RNN 实现:
import torch.nn as nn # 导入 torch.nn 库
# 定义编码器类,继承自 nn.Module
class Encoder(nn.Module):
def __init__(self, input_size, hidden_size):
super(Encoder, self).__init__()
self.hidden_size = hidden_size # 设置隐藏层大小
self.embedding = nn.Embedding(input_size, hidden_size) # 创建词嵌入层
self.rnn = nn.RNN(hidden_size, hidden_size, batch_first=True) # 创建 RNN 层
def forward(self, inputs, hidden): # 前向传播函数
embedded = self.embedding(inputs) # 将输入转换为嵌入向量
output, hidden = self.rnn(embedded, hidden) # 将嵌入向量输入 RNN 层并获取输出
return output, hidden
# 定义解码器类,继承自 nn.Module
class Decoder(nn.Module):
def __init__(self, hidden_size, output_size):
super(Decoder, self).__init__()
self.hidden_size = hidden_size # 设置隐藏层大小
self.embedding = nn.Embedding(output_size, hidden_size) # 创建词嵌入层
self.rnn = nn.RNN(hidden_size, hidden_size, batch_first=True) # 创建 RNN 层
self.out = nn.Linear(hidden_size, output_size) # 创建线性输出层
def forward(self, inputs, hidden): # 前向传播函数
embedded = self.embedding(inputs) # 将输入转换为嵌入向量
output, hidden = self.rnn(embedded, hidden) # 将嵌入向量输入 RNN 层并获取输出
output = self.out(output) # 使用线性层生成最终输出
return output, hidden
n_hidden = 128 # 设置隐藏层数量
# 创建编码器和解码器
encoder = Encoder(voc_size_cn, n_hidden)
decoder = Decoder(n_hidden, voc_size_en)
print(' 编码器结构:', encoder) # 打印编码器的结构
print(' 解码器结构:', decoder) # 打印解码器的结构
定义 Seq2Seq 类,串联起来编码、解码过程:
class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder):
super(Seq2Seq, self).__init__()
# 初始化编码器和解码器
self.encoder = encoder
self.decoder = decoder
def forward(self, enc_input, hidden, dec_input): # 定义前向传播函数
# 使输入序列通过编码器并获取输出和隐藏状态
encoder_output, encoder_hidden = self.encoder(enc_input, hidden)
# 将编码器的隐藏状态传递给解码器作为初始隐藏状态
decoder_hidden = encoder_hidden
# 使解码器输入(目标序列)通过解码器并获取输出
decoder_output, _ = self.decoder(dec_input, decoder_hidden)
return decoder_output
# 创建 Seq2Seq 架构
model = Seq2Seq(encoder, decoder)
print('S2S 模型结构:', model) # 打印模型的结构
定义训练过程,完成后的 model 即可以用来预测了:
def train_seq2seq(model, criterion, optimizer, epochs):
for epoch in range(epochs):
encoder_input, decoder_input, target = make_data(sentences) # 训练数据的创建
hidden = torch.zeros(1, encoder_input.size(0), n_hidden) # 初始化隐藏状态
optimizer.zero_grad()# 梯度清零
output = model(encoder_input, hidden, decoder_input) # 获取模型输出
loss = criterion(output.view(-1, voc_size_en), target.view(-1)) # 计算损失
if (epoch + 1) % 40 == 0: # 打印损失
print(f"Epoch: {epoch + 1:04d} cost = {loss:.6f}")
loss.backward()# 反向传播
optimizer.step()# 更新参数
# 训练模型
epochs = 400 # 训练轮次
criterion = nn.CrossEntropyLoss() # 损失函数
optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # 优化器
train_seq2seq(model, criterion, optimizer, epochs) # 调用函数训练模型
解释前面的代码:
flowchart TD
subgraph Encoder
A["enc_input<br/>(batch_size, seq_len)"]
A
-- "nn.Embedding(input_size, hidden_size)" -->
B["embedded<br/>(batch_size, seq_len, hidden_size)"]
B
-- "nn.RNN(hidden_size, hidden_size)" -->
C["encoder_output<br/>(batch_size, seq_len, hidden_size)"]
C
--> D["encoder_hidden<br/>(num_layers, batch_size, hidden_size)"]
end
D
-- "作为 Decoder 初始 hidden state" -->
E
subgraph Decoder
F["dec_input<br/>(batch_size, seq_len)"]
F
-- "nn.Embedding(output_size, hidden_size)" -->
G["embedded<br/>(batch_size, seq_len, hidden_size)"]
G
--> E["nn.RNN(hidden_size, hidden_size)"]
E
--> H["decoder_output<br/>(batch_size, seq_len, hidden_size)"]
H
-- "nn.Linear(hidden_size, output_size)" -->
I["output logits<br/>(batch_size, seq_len, vocab_size)"]
end
说明:
| 名称 | 含义 |
|---|---|
| batch_size | 一次并行处理多少个样本(通常是一批句子) |
| seq_len | 每个序列包含多少个 token(同一个 batch 里,所有句子会被 padding 成相同长度) |
| hidden_size | RNN hidden state 维度(本例中 embedding 维度与其相同) |
| vocab_size | 输出词汇表大小 |
| num_layers | RNN 堆叠层数 |
| encoder_hidden | Encoder 对整个输入序列的压缩上下文表示 |
| dec_input | Decoder 输入序列(训练时通常是目标序列右移后的结果) |
| output logits | Decoder 对词表中每个 token 的预测分数 |
注:
- 这个 Seq2Seq 结构简单,没有用到 encoder_output,后续注意力会用到。
- 这里引入了 Teacher Forcing,之前看到的训练过程是 输入→模型→预测→对比真实值算 loss。但在自回归模型中多了一步选择:各时间步的输入用预测值还是真实值?Teacher Forcing 即训练时使用真实 token 作为下一步输入。
- 书里的代码用来简要说明模型,仅教学作用,比如输出序列长度受限于 seq_len,若目标句子更长则会被截断。
2. 点积注意力
在干什么:
- 输入:X1 X2
- 输出:X1 形状相同的矩阵,对 X1 的单个 token,融合了 X2 的每个 token 的向量,得到的结果。
我的理解就是通过相似度进一步计算出来了注意力(增加了softmax),作为 X1 的新表示。
什么是点积?t1 t2 形状均为(1, feature_dim), dot(t1, t2) = t1 @ t2.T = 标量(scalar),表示两个向量的相似程度
代码在原书里有,这里记录下我理解的形状变化过程:
第 1 步: x1.shape = (2, 3, 4) x2.shape = (2, 5, 4),即:x1: 3 个 token x2: 5 个 token , feature_dim=4
第 2 步: x1 @ x2^T = (batch_size, seq_len1, seq_len2), 其中 (b, i, j)即为第 b 个样本中, x1 第 i 个元素与 x2第 j 个元素的相似度

第 3 步: attn_weights.shape = (2, 3, 5), 矩阵表示 x1 x2 之间两两 token 的关注程度,其中一行表示x1的 1 个 token 对 x2 所有 token 的关注程度 例如:
attn_weights =
[
[
[0.5, 0.2, 0.1, 0.1, 0.1],
[0.1, 0.3, 0.4, 0.1, 0.1],
[0.2, 0.2, 0.2, 0.2, 0.2]
],
[
[0.6, 0.1, 0.1, 0.1, 0.1],
[0.2, 0.2, 0.3, 0.2, 0.1],
[0.1, 0.1, 0.2, 0.3, 0.3]
]
]
x1 第 1 个 token 对 x2 的第 1 个 token权重 0.5,第2 个 token 权重,0.2,。。。。,也就是最关注第 1 个
第 4 步: attn_weights @ x2: 即 (batch_size, seq_len1, seq_len2) @ (batch_size, seq_len2, feature_dim) = (batch_size, seq_len1, feature_dim)

简言之整个过程的逻辑:
- 基于每个 token 的向量表示,计算 x1 x2 之间两两每个 token 的相似度
- 对相似度矩阵每一行做 softmax, 得到 attention 权重, 表示: x1 的每个 token 应该关注 x2 各 token 的程度
- 使用 attention 权重 对 x2 的 token 向量加权求和, 得到新的上下文表示(context vector)
- 最终输出 shape: (batch_size, seq_len1, feature_dim) , 对应的是 x1 的每个 token “从 x2 中读取到的信息”
3. 带点积注意力的Seq2Seq
相比 1 Seq2Seq 的变化:
- decoder 增加输入:encoder_output(encoder 各个时间步的 hidden state)
- decoder RNN层计算出 output 后,继续计算综合 encoder_output 的 attention contex 及 attention weight
- decoder 最终预测时,同时利用:
- Decoder 当前时刻状态(rnn_output)
- Encoder 提供的上下文信息(context)
因此相比原始 Seq2Seq, Decoder 不再只能依赖单个 encoder_hidden, 而是能够动态关注输入序列不同位置的信息。
flowchart TD
subgraph Encoder
A["enc_input<br/>(batch_size, seq_len)"]
A
-- "nn.Embedding(input_size, hidden_size)" -->
B["embedded<br/>(batch_size, seq_len, hidden_size)"]
B
-- "nn.RNN(hidden_size, hidden_size)" -->
C["encoder_output<br/>(batch_size, seq_len, hidden_size)"]
C
--> D["encoder_hidden<br/>(num_layers, batch_size, hidden_size)"]
end
D
-- "作为 Decoder 初始 hidden state" -->
E
subgraph Decoder
F["dec_input<br/>(batch_size, seq_len)"]
F
-- "nn.Embedding(output_size, hidden_size)" -->
G["embedded<br/>(batch_size, seq_len, hidden_size)"]
G
--> E["nn.RNN(hidden_size, hidden_size)"]
E
--> H["rnn_output<br/>(batch_size, seq_len, hidden_size)"]
%% Attention 新增部分
C
-- "新增:encoder_output" -->
J["Attention"]
H
-- "新增:decoder query" -->
J
J
--> K["新增:context<br/>(batch_size, seq_len, hidden_size)"]
J
--> L["新增:attn_weights<br/>(batch_size, seq_len, seq_len)"]
%% concat 新增
H
-- "新增:torch.cat" -->
M["concat"]
K
-- "新增:torch.cat" -->
M
M
--> N["新增:dec_output<br/>(batch_size, seq_len, 2 * hidden_size)"]
%% Linear 输入变化
N
-- "修改:Linear 输入变为 2 * hidden_size" -->
O["output logits<br/>(batch_size, seq_len, vocab_size)"]
end
%% 红色高亮
style J fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style K fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style L fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style M fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
style N fill:#ffe5e5,stroke:#ff0000,stroke-width:3px,color:#b30000
linkStyle 6 stroke:#ff0000,stroke-width:3px
linkStyle 7 stroke:#ff0000,stroke-width:3px
linkStyle 8 stroke:#ff0000,stroke-width:3px
linkStyle 9 stroke:#ff0000,stroke-width:3px
linkStyle 10 stroke:#ff0000,stroke-width:3px
linkStyle 11 stroke:#ff0000,stroke-width:3px
其中红色部分是相比简化 Seq2Seq 增加的技术点。