第 04 章的 RNN/LSTM 能处理序列,但它最自然的输出是“每个输入位置对应一个输出”——比如给每个词标词性。可很多任务不是这样的:机器翻译里,源句和目标句长度不同、语序不同,“我爱你”三个字翻成英文是“I love you”也是三个词,但“我昨天吃了饭”和它的英译词数、语序都对不上。这类任务需要把整个输入序列读完、理解,再从头生成一个长度可变的输出序列。

2014 年,Google 的 Ilya Sutskever、Oriol Vinyals 和 Quoc Le 提出了 sequence-to-sequence(seq2seq) 架构,给出了一个干净的通用解1。它由两个 LSTM 组成:

  • 编码器(encoder):一个 LSTM 逐词读入源句 x1,,xnx_1, \dots, x_n,把整句话的信息累积进它最后的隐藏状态,得到一个固定维度的向量 c\mathbf{c}(常称 context vector 或 thought vector)。读完最后一个词时的隐藏状态,就是对整句话的“理解摘要”。
  • 解码器(decoder):另一个 LSTM 以 c\mathbf{c} 为初始状态,自回归地一个词一个词生成译文——先生成第一个词,把它喂回去生成第二个词,如此直到生成结束符。

训练时用源句-目标句对,让解码器在每一步预测正确的下一个词(softmax over 词表 + 交叉熵),用反向传播端到端优化两个 LSTM。这个架构第一次证明了纯神经网络可以做出可用的机器翻译,是 NLP 从“流水线 + 人工特征”转向“端到端神经网络”的关键一步1

但它有一个结构性的隐患,藏在那个“固定维度向量 c\mathbf{c}”里。


问题是:整句话的全部信息,都要被塞进同一个固定大小的向量 c\mathbf{c}

不管源句是 5 个词还是 50 个词,编码器都只能交给解码器同样大小的一个向量(比如 1000 维)。短句子还好,长句子就装不下了——句首的信息在编码器逐词处理到句尾时,早已被后面的词反复覆盖、稀释(这正是第 04 章梯度消失的另一面:远处信息难以保留)。解码器拿到的 c\mathbf{c} 对长句而言是一个严重有损的压缩。

实验现象很明确:seq2seq 的翻译质量随源句长度增加而显著下降。Bahdanau、Cho 和 Bengio 在 2014 年的论文里把这个诊断说得很清楚——把一个变长输入编码成定长向量,是性能随句长增长而恶化的根源2。(一个有趣的工程补丁是 seq2seq 原论文里把源句倒序输入,让句首的词离解码开始更近,缓解了一点长程问题——但这只是治标。)

真正的解法,是 Bahdanau 等人提出的:别再强迫编码器把一切压进一个向量。让编码器为每个源词都输出一个表示,解码器在生成每个目标词时,自己回头去看这些源词表示,挑出此刻最相关的那些。 这就是注意力机制。


注意力的数学,核心是一个“加权平均”。Bahdanau 2014 的版本(后称 additive attention 或 Bahdanau attention)是这样工作的2

编码器现在为源句每个位置 jj 输出一个隐藏状态 hj\mathbf{h}_j(用双向 LSTM,所以 hj\mathbf{h}j 同时包含该词的左右上下文),一共 nn 个。解码器在生成第 ii 个目标词时,它当前的隐藏状态是 si1\mathbf{s}{i-1}。注意力分三步:

第一步,打分(对齐):用一个小神经网络给“当前解码状态 si1\mathbf{s}_{i-1}”和“每个源位置 hj\mathbf{h}_j”算一个相关性分数:

eij=vtanh ⁣(Wssi1+Whhj)e_{ij} = \mathbf{v}^\top \tanh!\left(W_s,\mathbf{s}_{i-1} + W_h,\mathbf{h}_j\right)

这个分数衡量“生成第 ii 个目标词时,第 jj 个源词有多重要”。注意它用了 tanh\tanh 和一个可学习向量 v\mathbf{v}——这就是“additive”(加性)的来历:query 和 key 是相加后过非线性,而不是相乘。

第二步,归一化成权重:把分数过 softmax,得到一个在所有源位置上、和为 1 的注意力权重分布:

αij=exp(eij)k=1nexp(eik)\alpha_{ij} = \frac{\exp(e_{ij})}{\sum_{k=1}^{n}\exp(e_{ik})}

αij\alpha_{ij} 可以读成“生成第 ii 个目标词时,模型把多少注意力放在第 jj 个源词上”。

第三步,加权求和得 context:用这个权重对所有源位置的表示做加权平均,得到一个为这一步定制的 context 向量:

ci=j=1nαijhj\mathbf{c}i = \sum{j=1}^{n} \alpha_{ij},\mathbf{h}_j

解码器用这个 ci\mathbf{c}_i(而不再是那个一成不变的全局 c\mathbf{c})去生成第 ii 个词。关键在于:每生成一个目标词,ci\mathbf{c}_i 都不一样——翻译到主语时注意力集中在源句主语上,翻译到动词时移到源句动词上。固定向量的瓶颈被彻底绕开了:信息不再需要全压进一个向量,解码器随时可以回到源句的任意位置去取它当下需要的部分。


注意力还带来一个意外的礼物:可解释性。把注意力权重 αij\alpha_{ij} 排成一个矩阵(目标词 × 源词)画成热图,就能看到模型在翻译每个词时“看”了源句的哪里。在英法翻译里,这个热图往往呈现出近似对角线的结构,并在语序调换处出现交叉——这等于模型自动学会了源句和目标句之间的词对齐,而没有人给过它任何对齐标注。论文标题里“jointly learning to align and translate”(联合学习对齐与翻译)说的正是这件事2

用代码看注意力如何“对齐”(完整文件 code/07_attention.py)。additive attention 的核心就是第三节那三步:

代码 · 07_attention.py
展开代码 · 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))

↓ 下载 07_attention.py

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(查询):我现在想找什么。(解码器当前状态 si1\mathbf{s}_{i-1}
  • Key(键):每个候选项的“索引/标签”。(每个源位置 hj\mathbf{h}_j
  • Value(值):每个候选项实际携带的内容。(在 Bahdanau 里 key 和 value 都是 hj\mathbf{h}_j

注意力做的事,就是用 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.pyBottleneckSoftAlignment 两个 Scene)把“瓶颈”与“解法”并置:前者演示传统 seq2seq 把整句话的箭头全汇聚进一个固定大小的向量,句子越长丢的信息越多;后者演示注意力如何让解码的每一步“回头看”源句所有位置,对齐矩阵逐格点亮——生成每个目标词时聚焦的源词不同,越亮处越相关。这正是注意力可解释性的标志性图像,第一次让人“看见”了模型内部的对齐逻辑。

注意力解决了 seq2seq 的瓶颈,但此刻它还只是 RNN 的配件。把它从配件升级为整个架构的核心、彻底抛弃循环、换来大规模并行训练的能力——这一步将定义此后近十年的人工智能。


本质

注意力解决的根本问题是信息瓶颈:把一个变长序列硬塞进一个固定大小的向量,必然在长序列上丢信息。它的解法不是把瓶颈做大,而是干脆取消瓶颈——让解码端在每一步根据当前需要,去源端按相关度加权地取用信息,而不是依赖一份提前压缩好的摘要。这背后是一次表示方式的转变:从“先把全部信息压成一个东西,再用”,变成“保留全部信息,用时按需检索”。一旦想清楚这一点,“循环”就显得多余了——既然每个位置都能直接看到所有其他位置,为什么还要一步步顺序传递?这正是 Transformer 的起跳点:把这个还只是 RNN 配件的机制,提升为整个架构的唯一主角。


参考文献

  1. 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

  2. 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

源句所有词的箭头汇聚进同一个固定向量 c,句子加长后早期词的箭头变红、c 饱和溢出,配一条翻译质量随句长崩塌的下滑曲线——从几何上演示「把变长序列压成定长向量」必然丢信息这一瓶颈。
把加性打分 e=vᵀtanh(Ws·s+Wh·h)→softmax 归一 α→加权求和 c=Σαⱼhⱼ 三步统一成一张 K×Q 注意力网格,逐列(每个目标词)在所有源词上 softmax(列和=1),热图自动呈对角线点亮,演示注意力即「按相关度软对齐、加权检索」的可微字典查找。