整句话为何不能只压成一个向量?Encoder–Decoder 的加性注意力与对齐
从定长上下文瓶颈出发,手算 Bahdanau 加性注意力的能量、掩码与加权和,并用 PyTorch 2.13 实现可检查的序列到序列训练和自回归推理。
上一篇把长短期记忆网络(Long Short-Term Memory, LSTM)的保留、写入和遗忘拆成了可学习的门。它让较早的信息更容易到达序列末尾,却仍留下一个容量问题:若机器翻译编码器只把整句源文本交给最后一个状态,十几个实体、修饰关系和词序都要竞争同一个定长向量。
编码器—解码器(Encoder–Decoder)先把输入变成表示,再逐步生成输出。本文只研究它的一项关键改造:Bahdanau 加性注意力(Additive Attention)如何让第 个解码步直接读取全部源位置,而不是每一步都依赖编码器最后一个状态。我们会追踪打分、padding 掩码、softmax 和加权和的完整数据流;多头自注意力留到下一篇。
01 定长上下文究竟丢了什么?#
考虑把「黑猫坐在红垫子上」翻译成英文。无注意力的循环 Encoder–Decoder 可以写成:
源长度为 ,目标长度为 。不论 还是 ,解码器始终只收到同一个 :
源词: 黑猫 ─► 坐在 ─► 红色 ─► 垫子 ─► 上
enc enc enc enc enc
│
▼
c = 最后状态 [N,H]
│ 每一步重复使用同一个 c
▼
目标: <bos> ─► the ─► black ─► cat ─► ... ─► <eos>text这不是说最后状态必然“一无所知”,而是所有细节只能通过同一条压缩通道。长句增长时,早期位置到 的路径更长;解码到「mat」时,也无法显式回到源词「垫子」重新取证。
注意力把接口从单个 改为全部编码器状态:
每个解码步生成自己的上下文 。于是容量仍有限,但不再要求一个向量提前猜中所有未来解码步需要的细节。
02 加性注意力怎样回答“现在该看哪里”?#
第 步已有上一解码状态 。对每个源位置 ,先计算未归一化能量(Energy):
其中:
| 变量 | 形状 | 含义 |
|---|---|---|
[N,H_e] | 第 个源位置的编码表示 | |
[N,H_d] | 当前查询所依据的上一解码状态 | |
[A,H_e] | 把源表示投影到对齐空间 | |
[A,H_d] | 把解码状态投影到同一空间 | |
[A] | 把对齐特征压成一个标量分数 | |
[N,S] | 当前解码步对所有源位置的能量 |
是注意力内部维度。加性注意力不要求 ;两个投影负责把不同宽度送入同一空间。
对 padding 位置加 后,沿源长度维归一化:
encoder states H [N,S,H_e] ───────► W_h ───────────┐
├─► tanh ─► v_a ─► energy [N,S]
decoder state s_(t-1) [N,H_d] ─► W_s ─► [N,1,A] ──┘
│
source_valid [N,S] ─► padding 处填 -inf ──────────────────────────┤
▼
softmax(dim=source)
│ alpha [N,S]
▼
H [N,S,H_e] ──────────────────────────────────────────► 加权和 c_t [N,H_e]text能量不是概率;只有掩码后做 softmax 才得到总和为 1 的注意力权重。上下文 是源状态的凸组合,它的宽度仍是 ,不会因源长度变化。
03 用三个源位置手算一次“重新取证”#
为隔离注意力本体,假设当前解码步已经得到三个标量能量:
源状态只有二维:
softmax 的分子为 ,所以:
上下文为:
若第三个位置是 padding,就必须在 softmax 之前 把其能量变为 :
先 softmax 再把 padding 权重乘零会让剩余权重之和小于 1,改变上下文尺度;只把 padding 词向量设为零也不够,因为它仍会从 softmax 分走概率质量。
04 从源 token 到目标 token,数据怎样流?#
本文采用带注意力的循环序列到序列(Sequence-to-Sequence, Seq2Seq)模型:
src_tokens [N,S] ─► src Embedding [N,S,D]
│
bidirectional GRU
│
enc_out [N,S,2H] ──────────────────────┐
enc_hidden [2,N,H] │
│ 拼接并投影 │
▼ │
decoder state [N,H_d] │
│ │
tgt_in[:,t] [N] ─► Embedding [N,D] │
│ │
├─► additive attention ◄─────┘
│ context [N,2H]
▼
decoder GRUCell
│
logits_t [N,V_tgt]
│
target_t [N] / ignore PADtext双向编码器能在离线任务中让每个源位置同时包含左右上下文,所以 。严格流式输入不能使用反向分支;那是部署条件,不是一个可以被验证分数掩盖的小细节。
训练时,目标序列通常错开一位:
tgt: <bos> the black cat <eos> <pad>
输入: <bos> the black cat
标签: the black cat <eos>text教师强制(Teacher Forcing)指训练第 步喂入真实的 ;推理没有真实未来目标,只能喂入模型上一步预测。这两条数据流必须分别测试。
05 不调用注意力封装,先写出本体#
import torch
from torch import nn
class AdditiveAttention(nn.Module):
def __init__(self, encoder_dim: int, decoder_dim: int, attention_dim: int) -> None:
super().__init__()
self.key = nn.Linear(encoder_dim, attention_dim, bias=False)
self.query = nn.Linear(decoder_dim, attention_dim, bias=False)
self.energy = nn.Linear(attention_dim, 1, bias=True)
def forward(
self,
encoder_states: torch.Tensor, # [N,S,H_e]
decoder_state: torch.Tensor, # [N,H_d]
source_valid: torch.Tensor, # [N,S], True 表示真实 token
) -> tuple[torch.Tensor, torch.Tensor]:
assert encoder_states.ndim == 3
n, source_steps, _ = encoder_states.shape
assert decoder_state.shape[0] == n
assert source_valid.shape == (n, source_steps)
assert source_valid.dtype == torch.bool
assert source_valid.any(dim=1).all(), "每条源序列至少要有一个有效位置"
features = torch.tanh(
self.key(encoder_states) # [N,S,A]
+ self.query(decoder_state)[:, None] # [N,1,A]
)
scores = self.energy(features).squeeze(-1) # [N,S]
scores = scores.masked_fill(~source_valid, float("-inf"))
weights = scores.softmax(dim=-1) # [N,S]
context = torch.bmm(
weights.unsqueeze(1), encoder_states
).squeeze(1) # [N,H_e]
torch.testing.assert_close(
weights.sum(dim=-1), torch.ones(n, device=weights.device)
)
return context, weightspythondecoder_state[:, None] 增加源位置轴,通过广播与 [N,S,A] 相加;torch.bmm 接收两个三维 batch 矩阵,[N,1,S] @ [N,S,H_e] 输出 [N,1,H_e]。这两处最容易“形状能广播,语义却错轴”。
06 一个可训练的注意力解码步#
下面只展示核心模块,不把分词、词表和数据管道藏在类里:
import torch
from torch import nn
class AttentiveDecoder(nn.Module):
def __init__(
self,
vocab_size: int,
embed_dim: int,
encoder_dim: int,
decoder_dim: int,
attention_dim: int,
pad_id: int,
) -> None:
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_id)
self.attention = AdditiveAttention(encoder_dim, decoder_dim, attention_dim)
self.cell = nn.GRUCell(embed_dim + encoder_dim, decoder_dim)
self.output = nn.Linear(decoder_dim + encoder_dim, vocab_size)
def step(
self,
previous_token: torch.Tensor, # [N]
state: torch.Tensor, # [N,H_d]
encoder_states: torch.Tensor, # [N,S,H_e]
source_valid: torch.Tensor, # [N,S]
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
token_vector = self.embedding(previous_token) # [N,D]
context, weights = self.attention(
encoder_states, state, source_valid
) # [N,H_e], [N,S]
state = self.cell(
torch.cat([token_vector, context], dim=-1), state
) # [N,H_d]
logits = self.output(torch.cat([state, context], dim=-1))
return logits, state, weights # [N,V], [N,H_d], [N,S]
def forward(
self,
target_input: torch.Tensor, # [N,T], 以 BOS 开始
initial_state: torch.Tensor, # [N,H_d]
encoder_states: torch.Tensor, # [N,S,H_e]
source_valid: torch.Tensor, # [N,S]
) -> tuple[torch.Tensor, torch.Tensor]:
state = initial_state
logits_steps, attention_steps = [], []
for t in range(target_input.shape[1]):
logits, state, weights = self.step(
target_input[:, t], state, encoder_states, source_valid
)
logits_steps.append(logits)
attention_steps.append(weights)
return (
torch.stack(logits_steps, dim=1), # [N,T,V]
torch.stack(attention_steps, dim=1), # [N,T,S]
)python损失应直接接收 logits,并忽略目标 padding:
pad_id = 0
logits, alignments = decoder(
target_input, initial_state, encoder_states, source_valid
) # [N,T,V], [N,T,S]
loss = nn.functional.cross_entropy(
logits.reshape(-1, logits.shape[-1]),
target_output.reshape(-1),
ignore_index=pad_id,
)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()pythonPyTorch 2.13 的 nn.Embedding(..., padding_idx=pad_id) 使该词表行不接收梯度;它不会自动生成源注意力掩码。CrossEntropyLoss(ignore_index=pad_id) 只忽略目标损失位置;它也不会替源端注意力屏蔽 padding。两端 padding 是两份不同契约。
07 推理为什么不能复制训练循环?#
贪心自回归推理从 <bos> 开始,每步选择一个 token,再作为下一步输入:
@torch.inference_mode()
def greedy_decode(
decoder: AttentiveDecoder,
initial_state: torch.Tensor, # [N,H_d]
encoder_states: torch.Tensor, # [N,S,H_e]
source_valid: torch.Tensor, # [N,S]
bos_id: int,
eos_id: int,
max_steps: int,
) -> tuple[torch.Tensor, torch.Tensor]:
n = encoder_states.shape[0]
token = torch.full((n,), bos_id, device=encoder_states.device)
state = initial_state
finished = torch.zeros(n, dtype=torch.bool, device=token.device)
outputs, alignments = [], []
for _ in range(max_steps):
logits, state, weights = decoder.step(
token, state, encoder_states, source_valid
)
token = logits.argmax(dim=-1) # [N]
token = torch.where(finished, eos_id, token)
outputs.append(token)
alignments.append(weights)
finished |= token.eq(eos_id)
if finished.all():
break
return torch.stack(outputs, dim=1), torch.stack(alignments, dim=1)python必须有 max_steps,否则没学会输出 <eos> 的模型会无限生成。batch 内较早结束的样本应冻结或屏蔽状态;上面的最小版本只冻结其 token,生产实现还应避免它的无效后续状态污染指标。束搜索(Beam Search)可以保留多个候选,但不会修复错误词表、掩码或训练—推理分布差异。
08 如何证明模型真的在使用源位置?#
注意力矩阵 alignments 的形状为 [N,T,S],适合画目标—源热力图,但“权重大”不自动等于“因果解释”。至少同时做三种检查:
- 数值不变量。 每个有效解码步沿 求和应约等于 1,padding 权重应约等于 0,所有值应有限。
- 受控任务。 构造输入
[a,b,c]、目标[c,b,a]的反转任务;期望峰值随解码步从右向左移动。 - 干预。 删除或替换被认为关键的源 token,或强制屏蔽其位置,观察目标分布是否按任务语义变化。
反转任务的期望对齐:
source
target a b c
c .1 .1 .8
b .1 .8 .1
a .8 .1 .1text若注意力永远盯住最后一个源位置,先检查初始状态是否仍过强、mask 是否反了、源输出是否误用了最终状态广播,以及训练数据是否存在位置捷径。
09 最常见的“能训练,但对齐错了”#
- softmax 轴写成目标维或 batch 维。 对每个解码步,权重必须沿源位置 归一化。
- 掩码语义反转。 本文
source_valid=True表示可见,填-inf时使用~source_valid;变量名应写进断言。 - softmax 后再清零 padding。 剩余权重不再归一;掩码必须进入 softmax 之前的能量。
- 整条源序列全是 padding。 所有分数均为 会产生
NaN;数据入口应拒绝零长度样本。 - 把双向编码器宽度仍写成 。 拼接方向后是
2H,初始解码状态和注意力投影都要显式适配。 - 目标输入与标签没有错开。 把当前真实 token 同时作为输入和标签,会形成复制捷径。
- 训练时总看真值,验证也看真值。 教师强制损失可能很好,自回归生成却会因误差累积崩溃;两种评估都要记录。
- 在输出后手动 softmax 再送入交叉熵。 官方交叉熵要求未归一化 logits。
- 把 attention heatmap 当作翻译质量。 对齐清晰不代表 token 正确,权重分散也不必然表示失败。
- 逐步 Python 循环成为吞吐瓶颈。 循环注意力本身按目标步串行;生产前要用 profiler 区分模型限制与数据管道等待。
10 它与相近方法的边界在哪里?#
| 方法 | 查询来自哪里 | 键和值来自哪里 | 主要用途 |
|---|---|---|---|
| 定长 Encoder–Decoder | 无逐步查询 | 只有编码器末状态 | 短序列基线、接口简单 |
| 加性注意力 | 上一解码状态 | 全部编码器状态 | 不同隐宽度下学习非线性对齐 |
| 点积交叉注意力 | 解码器表示 | 编码器表示 | 矩阵乘法高效,但通常要求投影到同宽度 |
| 自注意力 | 同一序列位置 | 同一序列位置 | 在序列内部交换信息 |
本文的注意力仍嵌在循环解码器里:第 步依赖第 步,不能并行生成目标。它也没有消除有限词表、暴露偏差、长度偏好和搜索误差。注意力解决的是“每一步能否回到源序列取证”,不是全部生成问题。
11 今天真正需要记住什么?#
- 定长 Encoder–Decoder 强迫所有源信息先进入一个末状态;加性注意力把接口改为全部源状态和逐步上下文 。
- 完整数据流是“能量打分 → 源 padding 掩码 → 沿源维 softmax → 对编码状态加权求和”,顺序不能交换。
encoder_states [N,S,H_e]、decoder_state [N,H_d]、weights [N,S]、context [N,H_e]是最值得写进断言的契约。- 教师强制训练与自回归推理读取不同的上一 token;验证只跑前者会掩盖误差累积。
- 注意力图是可诊断的中间变量,不是天然因果解释;应结合反转任务、padding 不变量和输入干预。
12 思考题与小练习#
- 将手算例的能量改为
[ln 4, ln 2, 0],分别计算无掩码和“第二位置为 padding”时的权重与上下文。解释为什么不能只把第二个源状态设为零。 - 为
AdditiveAttention写四个测试:不同 、不同源长度、含 padding、全 padding。前三者验证权重和与 padding 权重,第四个验证入口断言。 - 在序列反转任务上比较定长 Encoder–Decoder 与加性注意力,将源长度从 5 增到 50;同时报告 token 准确率、完整序列准确率、注意力熵和每秒样本数。
相关工作#
- Sutskever, Vinyals & Le (2014), Sequence to Sequence Learning with Neural Networks ↗:展示用深层 LSTM 编码定长表示并自回归解码的 Seq2Seq 框架。
- Cho et al. (2014), Learning Phrase Representations using RNN Encoder–Decoder ↗:提出门控循环编码器—解码器并分析短语表示。
- Bahdanau, Cho & Bengio (2015), Neural Machine Translation by Jointly Learning to Align and Translate ↗:提出本文使用的可学习加性对齐,让解码器按步读取源状态。
- Luong, Pham & Manning (2015), Effective Approaches to Attention-based Neural Machine Translation ↗:系统比较全局/局部注意力及多种点积与拼接评分。
- Wu et al. (2016), Google’s Neural Machine Translation System ↗:讨论注意力 Seq2Seq 在大规模翻译中的训练、推理与工程设计。
13 下一篇预告#
加性注意力允许每个解码步重新读取源序列,但编码器与解码器仍沿时间逐步递推。下一篇将把“查询—键—值”写成矩阵乘法,解释 Transformer 的缩放点积自注意力如何让所有位置并行交换信息,以及位置编码、padding mask 与因果 mask 分别阻止什么错误。