一
第 04 章的 RNN/LSTM 能处理序列,但它最自然的输出是“每个输入位置对应一个输出”——比如给每个词标词性。可很多任务不是这样的:机器翻译里,源句和目标句长度不同、语序不同,“我爱你”三个字翻成英文是“I love you”也是三个词,但“我昨天吃了饭”和它的英译词数、语序都对不上。这类任务需要把整个输入序列读完、理解,再从头生成一个长度可变的输出序列。
2014 年,Google 的 Ilya Sutskever、Oriol Vinyals 和 Quoc Le 提出了 sequence-to-sequence(seq2seq) 架构,给出了一个干净的通用解1。它由两个 LSTM 组成:
- 编码器(encoder):一个 LSTM 逐词读入源句 ,把整句话的信息累积进它最后的隐藏状态,得到一个固定维度的向量 (常称 context vector 或 thought vector)。读完最后一个词时的隐藏状态,就是对整句话的“理解摘要”。
- 解码器(decoder):另一个 LSTM 以 为初始状态,自回归地一个词一个词生成译文——先生成第一个词,把它喂回去生成第二个词,如此直到生成结束符。
训练时用源句-目标句对,让解码器在每一步预测正确的下一个词(softmax over 词表 + 交叉熵),用反向传播端到端优化两个 LSTM。这个架构第一次证明了纯神经网络可以做出可用的机器翻译,是 NLP 从“流水线 + 人工特征”转向“端到端神经网络”的关键一步1。
但它有一个结构性的隐患,藏在那个“固定维度向量 ”里。
二
问题是:整句话的全部信息,都要被塞进同一个固定大小的向量 。
不管源句是 5 个词还是 50 个词,编码器都只能交给解码器同样大小的一个向量(比如 1000 维)。短句子还好,长句子就装不下了——句首的信息在编码器逐词处理到句尾时,早已被后面的词反复覆盖、稀释(这正是第 04 章梯度消失的另一面:远处信息难以保留)。解码器拿到的 对长句而言是一个严重有损的压缩。
实验现象很明确:seq2seq 的翻译质量随源句长度增加而显著下降。Bahdanau、Cho 和 Bengio 在 2014 年的论文里把这个诊断说得很清楚——把一个变长输入编码成定长向量,是性能随句长增长而恶化的根源2。(一个有趣的工程补丁是 seq2seq 原论文里把源句倒序输入,让句首的词离解码开始更近,缓解了一点长程问题——但这只是治标。)
真正的解法,是 Bahdanau 等人提出的:别再强迫编码器把一切压进一个向量。让编码器为每个源词都输出一个表示,解码器在生成每个目标词时,自己回头去看这些源词表示,挑出此刻最相关的那些。 这就是注意力机制。
三
注意力的数学,核心是一个“加权平均”。Bahdanau 2014 的版本(后称 additive attention 或 Bahdanau attention)是这样工作的2。
编码器现在为源句每个位置 输出一个隐藏状态 (用双向 LSTM,所以 j 同时包含该词的左右上下文),一共 个。解码器在生成第 个目标词时,它当前的隐藏状态是 {i-1}。注意力分三步:
第一步,打分(对齐):用一个小神经网络给“当前解码状态 ”和“每个源位置 ”算一个相关性分数:
这个分数衡量“生成第 个目标词时,第 个源词有多重要”。注意它用了 和一个可学习向量 ——这就是“additive”(加性)的来历:query 和 key 是相加后过非线性,而不是相乘。
第二步,归一化成权重:把分数过 softmax,得到一个在所有源位置上、和为 1 的注意力权重分布:
可以读成“生成第 个目标词时,模型把多少注意力放在第 个源词上”。
第三步,加权求和得 context:用这个权重对所有源位置的表示做加权平均,得到一个为这一步定制的 context 向量:
i = \sum{j=1}^{n} \alpha_{ij},\mathbf{h}_j
解码器用这个 (而不再是那个一成不变的全局 )去生成第 个词。关键在于:每生成一个目标词, 都不一样——翻译到主语时注意力集中在源句主语上,翻译到动词时移到源句动词上。固定向量的瓶颈被彻底绕开了:信息不再需要全压进一个向量,解码器随时可以回到源句的任意位置去取它当下需要的部分。
四
注意力还带来一个意外的礼物:可解释性。把注意力权重 排成一个矩阵(目标词 × 源词)画成热图,就能看到模型在翻译每个词时“看”了源句的哪里。在英法翻译里,这个热图往往呈现出近似对角线的结构,并在语序调换处出现交叉——这等于模型自动学会了源句和目标句之间的词对齐,而没有人给过它任何对齐标注。论文标题里“jointly learning to align and translate”(联合学习对齐与翻译)说的正是这件事2。
用代码看注意力如何“对齐”(完整文件 code/07_attention.py)。additive attention 的核心就是第三节那三步:
展开代码 · 07_attention.py
"""
第 07/08 章配套代码:
(1) Bahdanau 风格 additive attention(2014)
(2) Transformer 的 scaled dot-product attention + multi-head(2017)
Runnable with: numpy only. python3 07_attention.py
"""
import numpy as np
rng = np.random.default_rng(0)
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
def additive_attention(query, keys, values, Wq, Wk, v):
"""Bahdanau 2014: score(q, k) = v^T tanh(Wq q + Wk k)。
query: (dq,) keys/values: (T, dk)/(T, dv)
"""
scores = np.array([v @ np.tanh(Wq @ query + Wk @ k) for k in keys]) # (T,)
alpha = softmax(scores) # 注意力权重(对齐分布)
context = alpha @ values # 加权求和得到 context 向量
return context, alpha
def scaled_dot_product_attention(Q, K, V, mask=None):
"""Transformer 2017: Attention(Q,K,V) = softmax(QK^T / sqrt(d_k)) V
Q:(Lq,d) K:(Lk,d) V:(Lk,dv)
"""
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k) # (Lq, Lk) 缩放点积
if mask is not None:
scores = np.where(mask, scores, -1e9) # 因果掩码:屏蔽未来位置
A = softmax(scores, axis=-1) # 每个 query 在所有 key 上的注意力
return A @ V, A
def multi_head_attention(X, d_model=8, n_heads=2):
"""把 d_model 拆成 n_heads 个子空间,各自做注意力,再拼接。"""
L = X.shape[0]; d_head = d_model // n_heads
out = np.zeros((L, d_model)); attns = []
for h in range(n_heads):
Wq, Wk, Wv = [rng.normal(0, .5, (d_model, d_head)) for _ in range(3)]
Q, K, V = X @ Wq, X @ Wk, X @ Wv
o, A = scaled_dot_product_attention(Q, K, V)
out[:, h * d_head:(h + 1) * d_head] = o
attns.append(A)
return out, attns
if __name__ == "__main__":
print("=== Bahdanau additive attention(2014)===")
T, dk = 4, 5
keys = rng.normal(0, 1, (T, dk)); values = keys.copy()
query = keys[2] + rng.normal(0, .1, dk) # query 接近第 3 个 key
Wq = np.eye(dk); Wk = np.eye(dk); v = np.ones(dk)
ctx, alpha = additive_attention(query, keys, values, Wq, Wk, v)
print(" 注意力权重 alpha =", np.round(alpha, 3), "(应在最接近的 key 上最大)")
print("\n=== Scaled dot-product attention(2017)===")
L, d = 4, 6
X = rng.normal(0, 1, (L, d))
Q = K = V = X
o, A = scaled_dot_product_attention(Q, K, V)
print(" 注意力矩阵 A (每行和为1):\n", np.round(A, 2))
print("\n=== 因果掩码(GPT 式自回归,不能看未来)===")
mask = np.tril(np.ones((L, L))).astype(bool)
o, A = scaled_dot_product_attention(Q, K, V, mask=mask)
print(" 下三角注意力(位置 i 只能看 <=i):\n", np.round(A, 2))
print("\n=== Multi-head attention ===")
out, attns = multi_head_attention(X, d_model=6, n_heads=2)
print(" 输出形状:", out.shape, " head 数:", len(attns))
def additive_attention(query, keys, values, Wq, Wk, v):
scores = [v @ np.tanh(Wq @ query + Wk @ k) for k in keys] # e_ij 打分
alpha = softmax(scores) # alpha_ij 归一化权重
context = alpha @ values # c_i 加权求和
return context, alpha
构造一个 query 故意接近第 3 个 key,运行结果:
注意力权重 alpha = [0.229 0.349 0.123 0.299]
权重在和 query 最相关的 key 上偏高——这就是“对齐”的最小演示:模型自动把更多权重分配给与当前查询最匹配的源位置。在真实翻译模型里,这个分布会尖锐得多,清晰地指向源句中对应的词。
五
退一步看注意力的本质,它其实是一个非常通用的“软查找”(soft lookup)操作,可以用三个角色来理解,这套术语在下一章的 Transformer 里会成为正式语言:
- Query(查询):我现在想找什么。(解码器当前状态 )
- Key(键):每个候选项的“索引/标签”。(每个源位置 )
- Value(值):每个候选项实际携带的内容。(在 Bahdanau 里 key 和 value 都是 )
注意力做的事,就是用 query 和每个 key 算相似度,把相似度归一化成权重,再用权重对 value 做加权平均。它像一个“可微的、模糊的字典查找”:普通字典查找是“键完全匹配才返回唯一值”,而注意力是“按匹配程度返回所有值的加权混合”。因为它处处可微,所以能被反向传播端到端训练,让模型自己学会“什么时候该查什么”。
这个抽象一旦提炼出来,一个大胆的问题就浮现了:既然注意力这么强——它能让任意两个位置直接交互、能自动对齐、还可并行——那我们还需要那个慢吞吞、只能顺序处理的 RNN 吗?Bahdanau 的注意力是 RNN 的一个补丁,注意力坐在循环结构的肩膀上。如果把循环结构整个拆掉,只留下注意力呢?
2017 年,八位 Google 研究者给出了那个石破天惊的答案,论文标题就是宣言:《注意力就是你所需要的一切》。那是下一章。
六
把这一章用一张图收束——seq2seq 从“固定瓶颈”到“注意力”的演进:
【无注意力 seq2seq 2014】信息被压进单个向量 c,长句丢失
源: x1 x2 x3 ... xn ─encoder LSTM─► [ c ] ─decoder─► y1 y2 y3 ...
▲ 固定大小,长句装不下(瓶颈)
【Bahdanau 注意力 2014】每步生成都回看全部源位置
源: h1 h2 h3 ... hn (编码器给每个源词一个表示)
\ | / |
\ | / α_ij(每步重算的权重)
▼ ▼ ▼
生成 y_i 时: c_i = Σ_j α_ij h_j ← 为这一步定制的 context
注意力热图(目标×源)呈对角线 => 自动学会词对齐
配套的 manim 动画 assets/manim/ch07_attention.py(Bottleneck 与 SoftAlignment 两个 Scene)把“瓶颈”与“解法”并置:前者演示传统 seq2seq 把整句话的箭头全汇聚进一个固定大小的向量,句子越长丢的信息越多;后者演示注意力如何让解码的每一步“回头看”源句所有位置,对齐矩阵逐格点亮——生成每个目标词时聚焦的源词不同,越亮处越相关。这正是注意力可解释性的标志性图像,第一次让人“看见”了模型内部的对齐逻辑。
注意力解决了 seq2seq 的瓶颈,但此刻它还只是 RNN 的配件。把它从配件升级为整个架构的核心、彻底抛弃循环、换来大规模并行训练的能力——这一步将定义此后近十年的人工智能。
本质
注意力解决的根本问题是信息瓶颈:把一个变长序列硬塞进一个固定大小的向量,必然在长序列上丢信息。它的解法不是把瓶颈做大,而是干脆取消瓶颈——让解码端在每一步根据当前需要,去源端按相关度加权地取用信息,而不是依赖一份提前压缩好的摘要。这背后是一次表示方式的转变:从“先把全部信息压成一个东西,再用”,变成“保留全部信息,用时按需检索”。一旦想清楚这一点,“循环”就显得多余了——既然每个位置都能直接看到所有其他位置,为什么还要一步步顺序传递?这正是 Transformer 的起跳点:把这个还只是 RNN 配件的机制,提升为整个架构的唯一主角。
参考文献
-
Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to Sequence Learning with Neural Networks. NeurIPS 2014. 编码器-解码器双 LSTM、固定 context 向量、源句倒序技巧。arXiv:https://arxiv.org/abs/1409.3215 ;NeurIPS PDF:https://papers.neurips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf
-
Bahdanau, D., Cho, K., & Bengio, Y. (2014). Neural Machine Translation by Jointly Learning to Align and Translate. additive attention、固定向量瓶颈诊断、联合对齐与翻译、注意力热图。arXiv:https://arxiv.org/abs/1409.0473