跟着LAP啥都学 跟着LAP啥都学

第 3 章 注意力机制:四种变体的演进

      +

      本章导读:从零实现注意力机制的四个变体——简单自注意力、可训练自注意力、因果注意力、多头注意力。每个变体只在前一个的基础上增加一样东西,最终得到 GPT-2 实际使用的注意力模块。

      本章是全书数学推导最密集的一章。代码总量不足 250 行,难点在理解,不在写代码。

      学完本章你将能够:

      1. 说明注意力机制的由来,以及自注意力与传统注意力的区别;

      2. 实现「点积 → softmax → 加权求和」三个步骤,并解释每一步的设计依据;

      3. 借助信息检索类比理解 Q/K/V,掌握缩放因子 \(\sqrt{d_k}\) 的完整推理链;

      4. 区分一对中英文同形的术语:权重参数与注意力权重;

      5. 实现因果掩码的两种做法,并解释为什么不会泄露未来信息;

      6. 实现多头的两种方式,并了解高效版的三个易错点。

      产出与代码:chapters/chapter03_attention/include/attention.h (SelfAttentionV1/V2 → CausalAttention → MultiHeadAttentionWrapper → MultiHeadAttention)。

      3.1 注意力的由来:RNN 的固定向量瓶颈

      先看 2014 年机器翻译系统的工作方式。编码器把源句子从头到尾读一遍,把全部含义压缩成一个固定长度的向量;解码器拿着这个向量逐词翻译。

      问题出在「固定长度」上。五个词的短句装得下,五十个词的长句也得装进同一个尺寸的向量。装不下的信息,只能丢弃。解码端的处境更受限:它只能拿到压缩后的最终向量,无法回头访问编码过程中的中间隐藏状态。相当于让译者只凭脑中最后一段印象翻译全句。句子越长、依赖关系越复杂,信息损失越严重。这是原始论文中明确记载的缺陷。

      改进分两步:

      • 2014 年:Bahdanau 注意力给这套架构补上关键一环。解码器每译一个词,允许它回头查看源句的所有位置,按相关程度加权取信息。翻译「她」时多关注指代对象的位置,翻译动词时多关注原句的动词。「相关程度」就是权重;

      • 2017 年:研究者进一步发现,既然解码器可以随时访问全部位置,RNN 逐步读入的结构就不再必要。让每个词直接与所有词计算一遍相关度即可——这就是自注意力,Transformer 架构由此诞生。

      关注的对象 典型场景

      自注意力

      单个输入序列内部的不同位置

      句子中词与词之间、图像中像素之间

      传统注意力

      两个不同序列元素之间的关系

      seq2seq 翻译中「输入序列 ↔ 输出序列」

      自注意力的正式定义:在计算序列表示时,允许输入序列中的每个位置关注同一序列中的所有位置。它的产物叫上下文向量——为每个词元算出一个新表示 \(z^{(i)}\),融合全序列所有词元的信息。

      Diagram

      本章按这条路线逐节实现,每个箭头只增加一样东西。全程使用同一个 6 词元的小例子,从输入算到输出:

      // 贯穿示例:"Your journey starts with one step."
      torch::Tensor make_inputs() {
          return torch::tensor({{0.43f, 0.15f, 0.89f},  // Your   (x^1)
                                {0.55f, 0.87f, 0.66f},  // journey(x^2)
                                {0.57f, 0.85f, 0.64f},  // starts (x^3)
                                {0.22f, 0.58f, 0.33f},  // with   (x^4)
                                {0.77f, 0.25f, 0.10f},  // one    (x^5)
                                {0.05f, 0.80f, 0.55f}}  // step   (x^6)
          );                                            // (6, 3):6 个词元,嵌入维度 3
      }

      3.2 简单自注意力:三步计算

      先实现最基础的版本:不带任何可训练参数。计算分三步,每一步的设计都有明确依据。

      第一步:点积计算相似度。 自注意力要回答的问题是:每个词元与其他词元有多相关。用什么衡量相关度?点积——逐元素相乘再求和。点积为什么适合衡量相似度?用单位向量就能验证:方向相同,点积最大;互相垂直(方向上没有重合成分),点积为零;方向相反,点积为负。点积累积的正是两个方向重合的程度:重合越多,累积值越大。第 2 章说过,嵌入把语义编进了向量:语义相近的词,向量方向也相近。于是点积把「语义相近程度」变成了一个可以比较、可以求导的数。

      第二步:softmax 归一化权重。 原始分数有大有小、有正有负,softmax 把它们归一化成一组权重。不用「除以总和」这种朴素归一化,理由有三:softmax 对极值的处理更好;训练时梯度特性更友好;输出保证非负、每行之和为 1,可以解释为概率或相对重要性。工程上还要注意:朴素 softmax 在输入过大或过小时会数值溢出,生产代码应使用框架内置版(内部有数值稳定化处理)。

      第三步:加权求和。 按权重把所有词元的向量混合,得到当前词元的上下文向量,其中融合了全句的信息。

      以 journey(\(x^2\))为查询,逐元素实现一遍:

      torch::Tensor query = inputs.index({1});
      // ① 相似度:query 与每个输入的点积
      std::vector<torch::Tensor> scores;
      for (int64_t i = 0; i < inputs.size(0); ++i)
          scores.push_back(torch::dot(inputs.index({i}), query));
      auto attn_scores_2 = torch::stack(scores);
      
      // ② 归一化:直接除以总和可行,但推荐 softmax(对大数值更稳定、可求导)
      auto attn_weights_2 = torch::softmax(attn_scores_2, 0);
      
      // ③ 上下文向量:按权重加权所有输入
      torch::Tensor context_vec_2 = torch::zeros_like(query);
      for (int64_t i = 0; i < inputs.size(0); ++i)
          context_vec_2 += attn_weights_2.index({i}) * inputs.index({i});

      运行结果(确定性输出,可逐位核对):

      attn_scores_2 = [0.9544, 1.4950, 1.4754, 0.8438, 0.7070, 1.0865]
      attn_weights_2 (softmax) = [0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581]   ← 和为 1
      context_vec_2 = [0.4419, 0.6515, 0.5683]

      \(x^2\) 对自己和 \(x^3\) 的权重最大(0.2379、0.2333)。journey 和 starts 语义相近,这正是点积相似度下的合理结果。

      推广到所有词元时,双重循环可以整体换成矩阵乘法。这一步是后续所有实现的雏形:

      auto attn_scores     = inputs.matmul(inputs.t());   // (6,6) 全部两两点积
      auto attn_weights    = torch::softmax(attn_scores, -1);  // 每行归一化
      auto all_context_vecs = attn_weights.matmul(inputs);     // (6,3)

      矩阵乘法得到的 all_context_vecs 与逐步算出的 context_vec_2 完全一致。6×6 的注意力权重矩阵如下——每行是一个查询对全部键的 softmax:

      03 attention weights
      Figure 1. 公式图像:简单自注意力的注意力权重矩阵(本章贯穿示例,T=6、d=3,行和为 1)

      这个无参数版本有一个重要价值:输出完全由输入决定,每个数都能手算验证,适合作为后续所有实现的调试基准。

      3.3 可训练的自注意力:查询、键、值

      无参数版本有一个根本缺陷:相关度完全由原始嵌入向量决定,模型无法学习调整关注重点。本节引入可训练权重。

      SelfAttentionV1:三组投影矩阵

      为每个词元计算三个角色向量,各经过一个独立的投影矩阵:

      class SelfAttentionV1Impl : public torch::nn::Module {
      public:
          SelfAttentionV1Impl(int64_t d_in, int64_t d_out) {
              W_query  = register_parameter("W_query",  torch::rand({d_in, d_out}));
              W_key    = register_parameter("W_key",    torch::rand({d_in, d_out}));
              W_value  = register_parameter("W_value",  torch::rand({d_in, d_out}));
          }
          torch::Tensor forward(torch::Tensor x) {
              auto keys    = x.matmul(W_key);
              auto queries = x.matmul(W_query);
              auto values  = x.matmul(W_value);
              auto attn_scores  = queries.matmul(keys.t());               // ω
              auto attn_weights = torch::softmax(
                  attn_scores / std::sqrt(keys.size(-1)), -1);            // 缩放!
              return attn_weights.matmul(values);
          }
          torch::Tensor W_query, W_key, W_value;
      };
      TORCH_MODULE(SelfAttentionV1);
      • Query(查询):当前词元在寻找什么信息;

      • Key(键):每个词元可供检索的标签;

      • Value(值):匹配成功后实际提供的信息。

      三个角色借自信息检索:搜索词是 query,网页关键词索引是 key,点开看到的正文是 value。注意力做的就是这种匹配:拿每个位置的 query 与所有 key 计算相似度;匹配度越高,对应 value 在加权求和里的份额就越大。三个投影矩阵可训练——模型由此学会关注哪里。这是注意力从固定机制变成可学习行为的关键一步。

      缩放因子 \(\sqrt{d_k}\)

      attn_scores / std::sqrt(keys.size(-1)) 这个除法不是随意的,推理链条如下:

      1. 类 GPT 模型的嵌入维度动辄上千 → 两个大维度向量的点积数值可能非常大;

      2. 点积一大,softmax 的输出越来越接近阶跃函数——最大的一项占据几乎全部概率,其余趋近于零;

      3. 阶跃意味着梯度接近零 → 学习停滞。

      除以键向量维度的平方根,把分数分布拉回平坦区域——这就是「缩放点积注意力」名字的由来。\(\sqrt{d_k}\) 到底改变了什么,见下图:

      03 scaling effect
      Figure 2. 公式图像:缩放前后同一组键的注意力分布(\(d_k = 64\) 的示意样本)

      完整的公式为:

      \[\mathrm{Attention}(Q, K, V) = \mathrm{softmax}\!\left(\frac{Q K^{\top}}{\sqrt{d_k}}\right) V\]

      本章(也是全书)最需要警惕的一对术语:中英文都是同一个词「weight」。

      权重参数(weight parameters) 注意力权重(attention weights)

      是什么

      W_q/W_k/W_v 这些投影矩阵——训练学出来的网络系数

      softmax 之后那组配比——每次前向实时计算

      何时确定

      训练完成后固定

      输入变它就变,和训练无关

      作用

      决定如何变换输入

      决定上下文向量对各输入的依赖程度

      前者是训练学出的参数,后者是每次前向传播实时计算的值。读论文和代码时若分不清这两个词,后续内容会难以理解。

      两个实现细节。其一,即使只想计算一个位置的上下文向量,也需要所有位置的键和值。注意力的结构是「一个查询对全部键」,没有只算局部的捷径。其二,nn.Linear 版本(V2)与手写 x.matmul(W) 数学等价——Linear 内部按 (out, in) 存权重,相当于转置。V2 的初始化更好(kaiming),代码更短,后续实现全部采用 V2 风格:

      W_query = register_module("W_query",
          torch::nn::Linear(torch::nn::LinearOptions(d_in, d_out).bias(qkv_bias)));
      ...
      auto keys    = W_key->forward(x);      // x.matmul(W.weight.t()) + b

      一个便捷的验证方法:把 V2 的权重转置后赋给 V1,两版输出逐位一致(main.cpp 中包含这次对照的输出)。

      3.4 因果掩码:屏蔽未来位置

      前面的注意力允许每个词元访问全部位置,包括排在它后面的位置。对语言模型来说,这不可接受。

      语言模型的任务是根据前文预测下一个词。预测第 6 个词元时,第 6 个词元还不存在——看不到它,是任务本身的规则。训练时如果模型能通过注意力看到右移一位的标签(即答案),每个位置的「下一词预测」都失去训练意义。因此必须加一条规则:位置 i 只准关注 \(\le i\) 的位置。这就是因果掩码。

      两种等价实现,main.cpp 各演示一遍:

      // 做法 A(直观):softmax 之后乘下三角掩码,再重新行归一化
      auto mask_simple = torch::tril(torch::ones({n, n}, inputs.options()));
      auto masked_simple = attn_weights * mask_simple;
      auto row_sums = masked_simple.sum(-1, /*keepdim=*/true);
      auto masked_simple_norm = masked_simple / row_sums;
      
      // 做法 B(标准,效率更高):softmax 之前把未来位置填 -inf,softmax 后正好为 0
      auto mask = torch::triu(torch::ones({n, n}, inputs.options()), /*diagonal=*/1);
      auto masked = attn_scores.masked_fill(mask.to(torch::kBool),
                                            -std::numeric_limits<double>::infinity());
      auto attn_weights_inf = torch::softmax(masked / sqrt_d_k, -1);
      mask (triu, diagonal=1):           掩码后的注意力权重(每行和仍为 1):
      0 1 1 1 1 1                          1.0000 0      0      0      0      0
      0 0 1 1 1 1                          0.5597 0.4403 0      0      0      0
      0 0 0 1 1 1                          0.4791 0.3488 0.1721 0      0      0
      0 0 0 0 1 1                          0.4077 0.4516 0.0802 0.0605 0      0
      0 0 0 0 0 1                          0.3590 0.3619 0.1749 0.0570 0.0472 0
      0 0 0 0 0 0                          0.3810 0.2783 0.1337 0.0709 0.0587 0.0774

      两种做法结果一致,但推荐做法 B。softmax 遇到 −∞ 输出零概率(\(e^{-\infty} \to 0\)),分母中对应项也是 0,每行和自动为 1。重新归一化一步可以省去。函数速查:tril 是下三角(含对角线);triu(…​, diagonal=1) 是上三角(不含对角线)——diagonal 参数写错会把当前位置也掩掉,模型连自己都看不见。

      03 causal mask
      Figure 3. 公式图像:因果掩码前后(左:每个词元看得到整句;右:\(j > i\) 清零,行内重新归一化)

      「先算 softmax 再掩码」会泄露未来信息吗? 做法 A 中,未来词元的分数参与过 softmax 分母,它们会不会通过归一化影响当前词元?

      不会。 原因有三:掩码后重新归一化,实际是对剩下的子集重新做一遍 softmax;softmax 的数学性质保证掩掉的位置不产生任何影响;最终分布等价于一开始就只在允许的位置上算 softmax。信息隔离是严格的。

      注意对角线:位置 1 的注意力 100% 在自己身上——它只能看见自己。这也解释了为什么训练时一次前向就能同时计算「每个位置的下一词预测」:第 2 章的 x/y 滑窗配合掩码,一次前向贡献 L 个监督信号。

      CausalAttention 把以上内容封装进一个类,并增加两件事:dropout 和 batch 支持。掩码用 register_buffer 注册(常量:不参与训练、不求梯度,但随模型迁移到 GPU),按 num_tokens 切片以适配不同长度:

      mask_ = register_buffer("mask",
          torch::triu(torch::ones({context_length, context_length}), 1));
      ...
      attn_scores = queries.matmul(keys.transpose(1, 2));      // (b, n, n)
      attn_scores = attn_scores.masked_fill(mask_bool, -inf);
      attn_weights = torch::softmax(attn_scores / std::sqrt(keys.size(-1)), -1);
      attn_weights = dropout_->forward(attn_weights);
      return attn_weights.matmul(values);

      验证:torch::stack({inputs, inputs}, 0) 组成 (2, 6, 3) 的 batch,CausalAttention(d_in=3, d_out=2, dropout=0.0) 输出 (2, 6, 2)。

      dropout 的缩放补偿机制:丢掉一部分权重后,存活元素按 \(\frac{1}{1-p}\) 放大,保证期望不变——p=0.5 时留下的元素放大一倍。没有这个补偿,训练和推理时注意力的整体强度会不一致。dropout 只在训练期工作,eval() 自动关闭——推理结果因此确定。Transformer(含 GPT)通常在两个位置使用 dropout:注意力权重算完之后,或权重乘完 value 之后。本教程选前者(实践中更常见),实际训练用 10%–20% 的低 dropout 率。

      3.5 多头注意力

      到上一节为止,实现的都是「单头」注意力:一组 Q/K/V 完成全部计算。单头有一个先天局限——一组投影只能捕捉一种关系模式。语法关系、语义关联、位置结构是不同类型的关系,一组投影难以同时覆盖。

      多头的主要思想:并行运行多组注意力,每组用不同的线性投影,让模型能同时在多个表示子空间里关注不同位置。类比:读一份长报告,一个人只能按自己的关注点读;多人各盯一个维度再汇总,覆盖的信息面更宽。

      数学形式——每个头在自己的投影子空间里做注意力,最后拼接并经输出投影 \(W_O\) 混合:

      \[\mathrm{head}_i = \mathrm{Attn}(X W_i^{Q},\, X W_i^{K},\, X W_i^{V}), \qquad \mathrm{MultiHead} = \mathrm{Concat}(\mathrm{head}_1, \dots, \mathrm{head}_h)\, W_O\]

      包装器版实现

      // 堆叠 H 个独立的 CausalAttention,各自 forward 后拼接
      for (const auto& h : heads_) outs.push_back(h->forward(x));
      return torch::cat(outs, -1);

      d_out=2、num_heads=2 时输出 (2, 6, 4)——每个头贡献 2 维,输出维度变多。这种写法定义清晰,但有两个代价:逐头顺序执行,所谓「并行」只是逻辑上的并行;每个头各自做一遍 Q/K/V 投影——矩阵乘法是计算开销最大的操作之一,头数一多,重复计算的代价显著。

      高效版实现:GPT-2 采用的方式

      换一个思路:不做 H 套投影,只做一次投影,把结果切成 H 份,每份当作一个头。整条形状链如下:

      Diagram
      class MultiHeadAttentionImpl : public torch::nn::Module {
      public:
          MultiHeadAttentionImpl(int64_t d_in, int64_t d_out, int64_t context_length,
                                 double dropout, int64_t num_heads, bool qkv_bias = false)
              : d_out_(d_out), num_heads_(num_heads), head_dim_(d_out / num_heads) {
              TORCH_CHECK(d_out % num_heads == 0, "d_out must be divisible by num_heads");
              W_query = register_module("W_query", Linear(d_in, d_out).bias(qkv_bias));
              W_key   = register_module("W_key",   Linear(d_in, d_out).bias(qkv_bias));
              W_value = register_module("W_value", Linear(d_in, d_out).bias(qkv_bias));
              out_proj = register_module("out_proj", torch::nn::Linear(d_out, d_out));
              dropout_ = register_module("dropout", torch::nn::Dropout(dropout));
              mask_    = register_buffer("mask", triu(ones({context_length, context_length}), 1));
          }
          torch::Tensor forward(torch::Tensor x) {
              auto keys = W_key->forward(x), queries = W_query->forward(x),
                   values = W_value->forward(x);                       // (b, T, d_out)
              // 切头:(b, T, H, d_h) -> (b, H, T, d_h)
              keys    = keys.view({b, T, num_heads_, head_dim_}).transpose(1, 2);
              queries = queries.view({b, T, num_heads_, head_dim_}).transpose(1, 2);
              values  = values.view({b, T, num_heads_, head_dim_}).transpose(1, 2);
      
              auto attn_scores = queries.matmul(keys.transpose(2, 3));  // (b, H, T, T)
              attn_scores = attn_scores.masked_fill(mask_bool, -inf);
              auto attn_weights = torch::softmax(
                  attn_scores / std::sqrt(keys.size(-1)), -1);          // 注意:√head_dim
              attn_weights = dropout_->forward(attn_weights);
      
              auto context_vec = attn_weights.matmul(values)
                                     .transpose(1, 2)                   // (b, T, H, d_h)
                                     .contiguous().view({b, T, d_out_});
              return out_proj->forward(context_vec);
          }
          ...
      };

      两种多头实现,参数相同,输出形状却不同——原因是「多头」有两种语义:

      实现 参数 输出形状

      MultiHeadAttentionWrapper

      d_out=2, H=2

      [2, 6, 4](= d_out × H,拼接出来的)

      MultiHeadAttention

      d_out=2, H=2

      [2, 6, 2](= d_out,切分再合并)

      Wrapper 是拼接多个独立头的输出(cat(…​, dim=-1));高效版是把 d_out 划分成 H 份(head_dim = d_out / H)分别当头用,最后再合并——总维度不变。真实 GPT-2 的规模:最小版(1.17 亿参数)12 个头、768 维(head_dim=64);最大版(15 亿参数)25 个头、1600 维。

      3.6 高效版的三个易错点

      高效版有三个易错点,出错时现象各异,排查都不轻松。提前过一遍,遇到问题时可以更快定位。

      易错点一:缩放分母变了。 包装器版中,每个头是独立的注意力,键向量维度是完整的 d_out;高效版把总维度切成 H 份,每个头的键只有 head_dim = d_out/H 维。缩放分母必须相应变成 \(\sqrt{\mathrm{head\_dim}}\)——点积的量级由参与运算的维度决定。GPT-2 small:768 维、12 头,head_dim = 64,除的是 \(\sqrt{64} = 8\)。

      易错点二:transpose 之后,view 会报错。 transpose 只改维度的解读方式,不动内存布局——新形状下内存不连续,而 view 要求连续内存。标准写法是 contiguous().view(…​):先把内存整理成连续的,再改形状。因果链:transpose 改解读 → 内存不连续 → view 拒绝 → contiguous 补救。

      易错点三:合并之后还要过 out_proj。 各头在自己的子空间里独立计算,拼接的结果只是各头输出的并排摆放,头与头之间还没有信息交互——out_proj 让模型学习怎么组合这些视角。这一层是高效版独有的,包装器版没有。漏写它,输出形状正确、数值不对,训练曲线异常,排查困难。

      底层原理是批矩阵乘法:queries.matmul(keys.transpose(2, 3)) 时,框架在最后两个维度之间做矩阵乘法,前面的维度(batch、head)当作批处理索引——等价于逐头循环,但一次调用完成 H 个头的计算。这也解释了切头时为什么要用 transpose 把 H 提到序列维前面:让「头」成为批索引、让 (T, d_h) 恰好落在最后两维。

      GPT-2 规模验证(练习 3.3):d_in=d_out=768、num_heads=12、context_length=1024,输入 [2, 8, 768],输出 [2, 8, 768]——输出形状与输入一致。维度不变、内容重编码,这正是第 4 章「Transformer 块可以无限堆叠」的前提。可学习参数张量共 5 组(Q/K/V 三份无偏置权重 + out_proj 的权重与偏置)。

      3.7 编译运行与预期输出

      ./scripts/run.sh chapter03_attention

      输出按演示序号顺序排列(3.3.1 → 3.3.2 → 3.4.1 → 3.4.2 → 3.5.1 → 3.5.2 → 3.5.3 → 3.6.1 → 3.6.2;编号为程序内的演示序号,与本章小节号略有错位)。下面摘录部分确定性输出,可与你的运行结果逐位核对:

      3.3.1: attn_scores_2 = [0.9544, 1.4950, 1.4754, 0.8438, 0.7070, 1.0865]
             attn_weights_2 = [0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581]
             context_vec_2 = [0.4419, 0.6515, 0.5683]
      3.5.1: 掩码后每行和 = 1,上三角全 0
      3.5.2: dropout(ones) 元素非 0 即 2.0
      3.5.3: batch.shape = [2, 6, 3] → context_vecs.shape = [2, 6, 2]
      3.6.1: context_vecs.shape = [2, 6, 4];[练习 3.2] d_out=1 → [2, 6, 2]
      3.6.2: [练习 3.3] 输入 [2, 8, 768] -> 输出 [2, 8, 768]
      3.4.2: [练习 3.1] v1 赋 v2 权重后两版输出逐位一致

      3.8 术语速查表

      术语 一句话定义

      上下文向量

      融合了序列中所有元素信息的嵌入向量 \(z^{(i)}\)

      注意力分数 ω

      查询与键的点积(未归一化)

      注意力权重 α

      注意力分数经 softmax 归一化后的值,和为 1

      查询/键/值

      借自信息检索:正在寻找的信息 / 可供检索的标签 / 实际提供的内容

      缩放点积注意力

      除以 \(\sqrt{d_k}\) 后再 softmax 的自注意力

      权重参数 vs 注意力权重

      训练学出的投影矩阵 vs 每次前向实时计算的注意力分配

      因果/掩码注意力

      屏蔽未来词元的自注意力(−∞ 法每行和自动为 1)

      register_buffer

      随模型自动在 CPU/GPU 间迁移的非参数张量(本教程用它存放掩码)

      头维度 head_dim

      d_out // num_heads(缩放分母 \(\sqrt{\mathrm{head\_dim}}\) 的来源)

      批处理矩阵乘法

      最后两维做乘法、对每个头重复——多头「并行」的实现基础

      本章小结

      • 注意力机制的由来:RNN 把全句压缩成固定向量,信息随句子变长而丢失。先有 Bahdanau 注意力让解码器回看全部源位置,再有自注意力彻底移除 RNN 结构。

      • 简单自注意力三步:点积计算相关度(利用嵌入的语义编码)→ softmax 归一化权重(稳定、可导、可解释)→ 加权求和得到上下文向量。矩阵化形式是后续所有实现的雏形。

      • 可训练自注意力:Q/K/V 使注意力从固定机制变成可学习行为(信息检索类比);\(\sqrt{d_k}\) 防止大维度点积把 softmax 推入梯度饱和区;「权重参数 vs 注意力权重」是中英文同形、必须区分的术语。

      • 因果掩码:把「不能看未来」翻译成矩阵操作——填 −∞ 使未来位置权重为零、每行之和自动归一、数学上不泄露未来信息;dropout 靠缩放补偿保证训练与推理一致。

      • 多头注意力:多组投影并行。包装器版直观但计算重复;高效版(一次投影 + 切分 + 批矩阵乘 + 合并 + out_proj)是 GPT-2 实际采用的实现。三个易错点:\(\sqrt{\mathrm{head\_dim}}\)、contiguous+view、out_proj。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. 程序段 3.3.1 的三个输出,与 3.7 节的确定性输出逐位一致

      2. 能用单位向量解释「点积为什么适合衡量相似度」

      3. 能完整复述缩放推理链(维度大 → 点积大 → softmax 阶跃化 → 梯度消失)

      4. 能区分权重参数与注意力权重(中英文都是 weight)

      5. 掩码后注意力权重每行和为 1,且上三角为 0

      6. 能解释「先 softmax 再掩码再归一化」为什么不存在信息泄露

      7. 理解为什么缩放分母是 \(\sqrt{\mathrm{head\_dim}}\) 而不是 \(\sqrt{d_{\mathrm{out}}}\)

      8. 能说清 contiguous().view() 的因果链(transpose 后不能直接 view)

      9. 能说出包装器版与高效版的三点差异(out_proj、head_dim 缩放、计算重复)

      10. 高效版输出 [2, 6, 2](d_out=2, H=2);GPT-2 规模下输出形状与输入一致

      习题

      1. 把 num_heads 从 12 改成 8、16(改 demo_mha_weight_split 里 GPT-2 规模 那段并重编译),验证 head_dim 相应变为 96、48,输出形状不变。

      2. 把 CausalAttention 的 dropout 设为 0.5,观察 context_vecs 的数值变化; 再执行 eval(),确认 dropout 已关闭(第 0 章 train/eval 约定的实操)。

      3. 推导 d_out=1, num_heads=2 的包装器输出为什么是 [2, 6, 2] 而不是 [2, 6, 4]。

      4. 把高效版的 out_proj 注释掉重跑,观察输出数值变化, 用数字说明 out_proj 组合各头输出的作用。

      拓展阅读