diff --git a/Image/chapter3/figure3.12.png b/Image/chapter3/figure3.12.png new file mode 100644 index 0000000..848b70b Binary files /dev/null and b/Image/chapter3/figure3.12.png differ diff --git a/Image/chapter3/figure3.13.png b/Image/chapter3/figure3.13.png new file mode 100644 index 0000000..f092ad7 Binary files /dev/null and b/Image/chapter3/figure3.13.png differ diff --git a/Image/chapter3/figure3.14.png b/Image/chapter3/figure3.14.png new file mode 100644 index 0000000..924b95e Binary files /dev/null and b/Image/chapter3/figure3.14.png differ diff --git a/Image/chapter3/figure3.15.png b/Image/chapter3/figure3.15.png new file mode 100644 index 0000000..0ff13de Binary files /dev/null and b/Image/chapter3/figure3.15.png differ diff --git a/Image/chapter3/figure3.16.png b/Image/chapter3/figure3.16.png new file mode 100644 index 0000000..b2df4e7 Binary files /dev/null and b/Image/chapter3/figure3.16.png differ diff --git a/Image/image3.12.png b/Image/image3.12.png deleted file mode 100644 index d37e415..0000000 Binary files a/Image/image3.12.png and /dev/null differ diff --git a/Image/image3.16.png b/Image/image3.16.png new file mode 100644 index 0000000..8b6b316 Binary files /dev/null and b/Image/image3.16.png differ diff --git a/cn-Book/3.实现注意力机制.md b/cn-Book/3.实现注意力机制.md index acd129f..5e7f845 100644 --- a/cn-Book/3.实现注意力机制.md +++ b/cn-Book/3.实现注意力机制.md @@ -173,7 +173,7 @@ print(attn_scores_2) > + **K向量(键向量)**:键向量代表了一个单词的特征,或者说是这个单词如何"展示"自己,以便其它单词可以与它进行匹配 > + **V向量(值向量)**:值向量携带的是这个单词的具体信息,也就是当一个单词被"注意到"时,它提供给关注者的内容 > -> **更通俗的理解:**想象我们在图书馆寻找一本书(`Q向量`),我们知道要找的主题(`Q向量`),于是查询目录(`K向量`),目录告诉我哪本书涉及这个主题,最终我找到这本书并阅读内容(`V向量`),获取了我需要的信息。 +> **更通俗的理解:** 想象我们在图书馆寻找一本书(`Q向量`),我们知道要找的主题(`Q向量`),于是查询目录(`K向量`),目录告诉我哪本书涉及这个主题,最终我找到这本书并阅读内容(`V向量`),获取了我需要的信息。 > > 具体生成Q、K、V向量的方式主要通过线性变换: > @@ -334,7 +334,293 @@ tensor([0.4419, 0.6515, 0.5683]) -我们沿用之前的三个步骤(如图 3.12 所示),只是对代码做了一些修改,以计算所有的上下文向量,而不仅仅是第二个上下文向量 z(2)。 +我们沿用之前的三个步骤(如图 3.12 所示),只是对代码做了一些修改,用于计算所有的上下文向量,而不仅仅是第二个上下文向量 z(2)。 + + + +如图 3.12 所示,在第 1 步中,我们添加了一个额外的 for 循环,用于计算所有输入对之间的点积。 + +```python +attn_scores = torch.empty(6, 6) +for i, x_i in enumerate(inputs): + for j, x_j in enumerate(inputs): + attn_scores[i, j] = torch.dot(x_i, x_j) +print(attn_scores) +``` + +计算得到的注意力分数集合如下: + +``` +tensor([[0.9995, 0.9544, 0.9422, 0.4753, 0.4576, 0.6310], + [0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865], + [0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605], + [0.4753, 0.8434, 0.8296, 0.4937, 0.3474, 0.6565], + [0.4576, 0.7070, 0.7154, 0.3474, 0.6654, 0.2935], + [0.6310, 1.0865, 1.0605, 0.6565, 0.2935, 0.9450]]) +``` + +以上张量中的每个元素都表示每对输入之间的注意力得分,正如图 3.11 中所示。请注意,图 3.11 中的值已进行了归一化,因此它们与以上张量中的未经归一化的注意力得分不同。我们稍后会处理归一化。 + +在上述代码中,我们使用了 Python 中的 for 循环来计算所有输入对的注意力得分。然而,for 循环通常较慢,我们可以通过矩阵乘法实现相同的结果。 + +```python +attn_scores = inputs @ inputs.T +print(attn_scores) +``` + +我们可以直观地确认结果与之前一致: + +``` +tensor([[0.9995, 0.9544, 0.9422, 0.4753, 0.4576, 0.6310], + [0.9544, 1.4950, 1.4754, 0.8434, 0.7070, 1.0865], + [0.9422, 1.4754, 1.4570, 0.8296, 0.7154, 1.0605], + [0.4753, 0.8434, 0.8296, 0.4937, 0.3474, 0.6565], + [0.4576, 0.7070, 0.7154, 0.3474, 0.6654, 0.2935], + [0.6310, 1.0865, 1.0605, 0.6565, 0.2935, 0.9450]]) +``` + +接下来开始执行步骤 2(如图 3.12 所示),我们现在对每一行进行归一化处理,使得每一行的值之和为 1。 + +```python +attn_weights = torch.softmax(attn_scores, dim=-1) +print(attn_weights) +``` + +执行上述代码返回的注意力权重张量与图 3.10 中显示的数值一致: + +``` +tensor([[0.2098, 0.2006, 0.1981, 0.1242, 0.1220, 0.1452], + [0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581], + [0.1390, 0.2369, 0.2326, 0.1242, 0.1108, 0.1565], + [0.1435, 0.2074, 0.2046, 0.1462, 0.1263, 0.1720], + [0.1526, 0.1958, 0.1975, 0.1367, 0.1879, 0.1295], + [0.1385, 0.2184, 0.2128, 0.1420, 0.0988, 0.1896]]) +``` + +在使用 PyTorch 时,像 `torch.softmax` 这样的函数中的 `dim` 参数指定了将在输入张量中的哪个维度上进行归一化计算。通过设置 `dim=-1`,我们指示 `softmax` 函数沿着 `attn_scores` 张量的最后一个维度进行归一化操作。如果 `attn_scores` 是一个二维张量(例如,形状为 `[行数, 列数]`),则 `dim=-1` 将沿列方向进行归一化,使得每一行的值(沿列方向求和)之和等于 1。 + +在继续执行第 3 步(即图 3.12 所示的最后一步)之前,我们先简单验证一下每一行的总和是否确实为 1: + +```python +row_2_sum = sum([0.1385, 0.2379, 0.2333, 0.1240, 0.1082, 0.1581]) +print("Row 2 sum:", row_2_sum) +print("All row sums:", attn_weights.sum(dim=-1)) +``` + +结果如下: + +``` +Row 2 sum: 1.0 +All row sums: tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]) +``` + +在第三步也是最后一步中,我们使用这些注意力权重通过矩阵乘法的方式来并行计算所有的上下文向量: + +```python +all_context_vecs = attn_weights @ inputs +print(all_context_vecs) +``` + +可以看到,计算输出的张量中,每一行包含一个三维的上下文向量: + +``` +tensor([[0.4421, 0.5931, 0.5790], + [0.4419, 0.6515, 0.5683], + [0.4431, 0.6496, 0.5671], + [0.4304, 0.6298, 0.5510], + [0.4671, 0.5910, 0.5266], + [0.4177, 0.6503, 0.5645]]) +``` + +我们可以通过将第二行与之前在第 3.3.1 节中计算的上下文向量 z(2) 进行对比,来再次确认代码的正确性。 + +```python + print("Previous 2nd context vector:", context_vec_2) +``` + +根据结果,我们可以看到之前计算的 context_vec_2 与以上张量的第二行完全一致: + +``` + Previous 2nd context vector: tensor([0.4419, 0.6515, 0.5683]) +``` + +以上内容完成了对简单自注意力机制的代码演示。在接下来的部分,我们将添加可训练的权重,使大语言模型能够从数据中学习并提升其在特定任务上的性能。 + + + +## 3.4 实现带有可训练权重的自注意力机制 + +在本节中,我们正在实现一种在原始 Transformer 架构、GPT 模型以及大多数其他流行的大语言模型中使用的自注意力机制。这种自注意力机制也被称为缩放点积注意力。图 3.13 提供了一个思维模型,展示了这种自注意力机制是如何应用在在大语言模型的架构设计中。 + + + +如图 3.13 所示,带有可训练权重的自注意力机制是基于之前简化自注意力机制的改进:我们希望计算某个特定输入元素的嵌入向量的加权和来作为上下文向量。您将看到,与我们在 3.3 节中编码的基本自注意力机制相比,只有细微的差别。 + +最显著的区别在于引入了在模型训练过程中不断更新的权重矩阵。这些可训练的权重矩阵至关重要,它们使模型(特别是模型内部的注意力模块)能够学习生成“优质”的上下文向量。(请注意,我们将在第五章训练大语言模型。) + +我们将通过两个小节来深入讲解自注意力机制。首先,我们会像之前一样,逐步编写该机制的代码。然后,我们会将代码整理成一个紧凑的 Python 类,以便在第 4 章编写的大型语言模型(LLM)架构中使用。 + + + +### 3.4.1 逐步计算注意力权重 + +我们通过引入三个可训练的权重矩阵:Wq、Wk 和 Wv 来逐步实现自注意力机制。这三个矩阵用于将嵌入后的输入 token x(i) 映射为查询向量、键向量和值向量,如图 3.14 所示。 + + + +在第 3.3.1 节中,我们将第二个输入元素 x(2) 定义为查询(query),通过计算简化的注意力权重来得到上下文向量 z(2)。随后,在第 3.3.2 节中,我们将这一过程推广到整个输入句子 "Your journey starts with one step",为这六个词的输入句子计算所有的上下文向量 z(1) 到 z(T)。 +同样地,为了便于说明,我们将先计算一个上下文向量 z(2)。接下来,我们将修改代码以计算所有的上下文向量。让我们从定义一些变量开始: + +```python +x_2 = inputs[1] #A +d_in = inputs.shape[1] #B +d_out = 2 #C +#A 第二个输入元素 +#B 输入维度, d_in=3 +#C 输出维度, d_out=2 +``` +请注意,在 GPT 类模型中,输入维度和输出维度通常是相同的。不过,为了便于说明和更清楚地展示计算过程,我们在此选择了不同的输入(d_in=3)和输出(d_out=2)维度。 + +接下来,我们初始化图3.14中所示的三个权重矩阵Wq、Wk和Wv: + +```python +torch.manual_seed(123) +W_query = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False) +W_key = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False) +W_value = torch.nn.Parameter(torch.rand(d_in, d_out), requires_grad=False) +``` + +请注意,这里我们将 `requires_grad` 设置为 `False`,以便在输出结果中减少不必要的信息,使演示更加清晰。但如果要将这些权重矩阵用于模型训练,则需要将 `requires_grad` 设置为 `True`,以便在模型训练过程中更新这些矩阵。 + +接下来,我们计算之前在图 3.14 中展示的 query、key 和 value 向量: + +```python +query_2 = x_2 @ W_query +key_2 = x_2 @ W_key +value_2 = x_2 @ W_value +print(query_2) +``` + +以上代码的输出是一个二维向量,因为我们将对应的输出权重矩阵的列数通过 `d_out` 参数设置为 2: + +``` + tensor([0.4306, 1.4551]) +``` + +> [!NOTE] +> +> **权重参数 VS 注意力权重** +> +> 请注意,在权重矩阵 W 中,术语“权重”是“权重参数”的缩写,指的是神经网络在训练过程中被优化的数值参数。这与注意力权重不同,注意力权重用于确定上下文向量对输入文本的不同部分的依赖程度,即神经网络对输入不同部分的关注程度。 +> +> 总之,权重参数是神经网络的基本学习系数,用于定义网络层之间的连接关系,而注意力权重则是根据上下文动态生成的特定值,用于衡量不同词语或位置在当前上下文中的重要性。 + +尽管我们当前的目标仅仅是计算一个上下文向量 z(2),但仍然需要获取所有输入元素的 key 和 value 向量,因为它们参与了与查询向量 q(2) 一起计算注意力权重的过程,如图 3.14 所示。 + +我们可以通过矩阵乘法获取所有元素的key和value向量: + +```python +keys = inputs @ W_key +values = inputs @ W_value +print("keys.shape:", keys.shape) +print("values.shape:", values.shape) +``` + +从输出结果可以看出,我们成功地将 6 个输入 token 从 3 维嵌入空间投影到 2 维嵌入空间: + +``` +keys.shape: torch.Size([6, 2]) +values.shape: torch.Size([6, 2]) +``` + +接下来的第二步是计算注意力得分,如图 3.15 所示。 + + + +首先,我们计算注意力得分ω22 : + +```python +keys_2 = keys[1] #A +attn_score_22 = query_2.dot(keys_2) +print(attn_score_22) + +#A 请牢记在Python中索引从0开始 +``` + +由此得到以下未经归一化的注意力得分: + +``` + tensor(1.8524) +``` + +> [!TIP] +> +> **个人思考:** 之前一直有一个疑惑,相同的两个词在不同句子中语义相关度可能完全不同,那么它们的注意力得分是如何做到在不同的上下文中分数不一样的。例如考虑以下两个句子: +> +> + 句子1:"The cat drank the milk because it was hungry." +> + 句子2:"The cat drank the milk because it was sweet." +> +> 很明显,在这两个句子中,`it`的指代不同,第一个句子中,`it`指代`cat`,而在第二个句子中,`it`指代`milk`。 +> +> 根据一下注意力得分的公式(Qcat和Kit分别为`cat`和`it`的查询向量和键向量)可知,句子1中`score_cat_it`是要大于句子2中的`score_cat_it`,因为句子1中,`it`和`cat`的相关度更高,但是从公式中如何推断出实现呢? +> +> **score_cat_it = Qcat · Kit** +> +> 我们继续将公式拆解: +> +> **Qcat= Wq * (Ecat + Poscat)** +> +> **Kit = Wk * (Eit + Posit)** +> +> 其中 **Ecat**和**Eit**是这两个词的嵌入向量,表示该词的基本语义信息,在不同的上下文中是固定的,根据公式可知,要使最终算出的**score_cat_it**与上下文语义相关,最重要的是Wq 和Wk这两个权重参数应该能反映出不同上下文语义的相关性。在标准的自注意力机制中,W、K、V向量都是固定的,然而,由于 GPT 模型是由多层自注意力模块堆叠而成,每一层都会根据当前输入和上下文信息,动态调整查询、键和值向量的权重矩阵。因此,即使初始的词嵌入和权重矩阵是固定的,经过多层处理后,模型能够生成与当前上下文相关的 Q、K、V 向量权重矩阵,最终计算出的Q、K、V 向量也就能反映出上下文的语义了。GPT多层的实现的细节后文会详述。 + +我们可以再次通过矩阵乘法将其应用到所有注意力得分的计算: + +```python +attn_scores_2 = query_2 @ keys.T # All attention scores for given query +print(attn_scores_2) +``` + +可以看到,输出中的第二个元素与我们之前计算的 `attn_score_22` 相同: + +``` + tensor([1.2705, 1.8524, 1.8111, 1.0795, 0.5577, 1.5440]) +``` + +第三步是将注意力得分转换为注意力权重,如图 3.16 所示。 + + + +接下来,如图 3.16 所示,我们通过缩放注意力分数并使用前面提到的 softmax 函数来计算注意力权重。与之前的不同之处在于,现在我们通过将注意力分数除以`keys`嵌入维度的平方根来进行缩放(注意,取平方根在数学上等同于指数为 0.5 的运算)。 + +```python +d_k = keys.shape[-1] +attn_weights_2 = torch.softmax(attn_scores_2 / d_k**0.5, dim=-1) +print(attn_weights_2) +``` + +结果如下: + +``` + tensor([0.1500, 0.2264, 0.2199, 0.1311, 0.0906, 0.1820]) +``` + +> [!NOTE] +> +> **缩放点积注意力机制的原理** +> +> 对嵌入维度大小进行归一化的原因是为了避免出现小梯度,从而提高训练性能。例如,当嵌入维度增大时(在 GPT 类大型语言模型中通常超过一千),较大的点积在反向传播中应用 softmax 函数后,可能会导致非常小的梯度。随着点积的增大,softmax 函数的行为会更加类似于阶跃函数,导致梯度接近于零。这些小梯度可能会显著减慢学习速度,甚至导致训练停滞。 +> +> 通过嵌入维度的平方根进行缩放,正是自注意力机制被称为‘缩放点积注意力’的原因。 + +> [!TIP] +> +> **个人思考:** 这里再稍微解释一下上述关于缩放点积注意力的机制。在自注意力机制中,查询向量(Query)与键向量(Key)之间的点积用于计算注意力权重。然而,当嵌入维度(embedding dimension)较大时,点积的结果可能会非常大。那么大的点积对接下来的计算有哪些具体影响呢? +> +> + **Softmax函数的特性**:在计算注意力权重时,点积结果会通过Softmax函数转换为概率分布。而Softmax函数对输入值的差异非常敏感,当输入值较大时,Softmax的输出会趋近于0或1,表现得类似于阶跃函数(step function)。 +> + **梯度消失问题**:当Softmax的输出接近0或1时,其梯度会非常小,接近于零(可以通过3.3.1小节中提到的Softmax公式推断)。这意味着在反向传播过程中,梯度更新幅度会很小,导致模型学习速度减慢,甚至训练停滞。 +> +> 为了解决上述问题,在计算点积后,将结果除以嵌入维度的平方根(即 dk\sqrt{d_k}dk),其中 dkd_kdk 是键向量的维度。这样可以将点积结果缩放到适当的范围,避免Softmax函数进入梯度平缓区,从而保持梯度的有效性,促进模型的正常训练。