add fourth chapter

This commit is contained in:
skindhu 2024-11-05 16:02:19 +08:00
parent 173888ddff
commit d7103fc230
7 changed files with 353 additions and 31 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

BIN
Image/image4.18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@ -1,6 +1,6 @@
本章涵盖以下内容:
+ **编写一个类 GPT 的大语言模型LLM可以训练其生成类似人类的文本**
+ **编写一个类 GPT 的大语言模型LLM可以训练其生成类人文本(指的是由人工智能模型生成的文本,这些文本在语言表达、语法结构、情感表达等方面与人类自然书写的文本非常相似)**
+ **对网络层的激活值进行归一化,以稳定神经网络的训练过程**
+ **在深度神经网络中添加快捷连接,以更高效地训练模型**
+ **通过实现 Transformer 模块来构建不同规模的 GPT 模型**
@ -8,13 +8,11 @@
在上一章中你学习并实现了多头注意力机制这是大语言模型LLM的核心组件之一。本章将进一步实现 LLM 的其他构建模块,并将它们组装成一个类似 GPT 的模型。我们将在下一章中训练该模型,以生成类人文本,具体过程如图 4.1 所示。
图 4.1 展示了构建 LLM 的三个主要阶段的概念模型:在通用文本数据集上对 LLM 进行预训练,并在标注数据集上进行微调。本章重点在于实现 LLM 的架构,下一章中我们将对其进行训练。
在上一章中我们学习并实现了多头注意力机制这是大语言模型LLM的核心组件之一。本章将进一步实现 LLM 的其他组件,并将它们组装成一个与 GPT 类似结构的模型。我们将在下一章中训练该模型,以生成类人文本,具体过程如图 4.1 所示。
<img src="../Image/chapter4/figure4.1.png" width="75%" />
大语言模型LLM架构见图 4.1)由多个模块构成,我们将在本章中实现这些模块。接下来的部分中,我们将首先从整体视角介绍模型架构,然后详细讲解各个组件。
大语言模型LLM架构见图 4.1)由多个模块构成,我们将在本章中实现这些模块。接下来的内容,我们首先从整体视角介绍模型架构,然后详细讲解各个组件。
@ -24,9 +22,9 @@ LLM如GPT即生成式预训练 TransformerGenerative Pretrained Transfo
<img src="../Image/chapter4/figure4.2.png" width="75%" />
如图 4.2 所示,我们已经在之前的章节中讲解过几个模块,如输入的分词和嵌入,以及掩码多头注意力模块。本章的重点是实现 GPT 模型的核心结构,包括其 Transformer 模块。我们将在下一章对该模型进行训练,使其能够生成类人文本。
如图 4.2 所示,我们已经在之前的章节中讲解过几个模块,如输入的分词和嵌入,以及掩码多头注意力模块。本章的重点是实现 GPT 模型的核心结构(包括 Transformer 模块)。我们将在下一章对该模型进行训练,使其能够生成类人文本。
在前几章中,为了简单起见,我们使用了较小的嵌入维度,确保概念和示例能够更方便地展示在一页内。而在本章中,我们将逐步扩展模型规模,达到小型 GPT-2 模型的大小拥有1.24 亿参数量的最小版本)。该模型在 Radford 等人的论文《Language Models are Unsupervised Multitask Learners.》中有详细描述。请注意,尽管最初的报告中提到参数量为 1.17 亿,但后来更正为 1.24 亿。
在前几章中,为了简单起见,我们使用了较小的嵌入维度,确保概念和示例能够更方便地展示在一页内。而在本章中,我们将逐步扩展模型规模,达到小型 GPT-2 模型的大小拥有1.24 亿参数量的最小版本)。该模型在 Radford 等人的论文《Language Models are Unsupervised Multitask Learners.》中有详细介绍。请注意,尽管最初的报告中提到参数量为 1.17 亿,但后来更正为 1.24 亿。
第 6 章将重点介绍如何将预训练权重加载到我们的实现中,并将其调整为更大的 GPT-2 模型版本(包括 3.45 亿、7.62 亿和 15.42 亿参数量规模)。在深度学习和 GPT 等大语言模型的背景下,‘参数’一词指的是模型的可训练权重。这些权重本质上是模型的内部变量,在训练过程中不断调整和优化,以最小化特定的损失函数。这种优化使得模型能够从训练数据中学习。
@ -69,7 +67,6 @@ GPT_CONFIG_124M = {
```python
# Listing 4.1 A placeholder GPT model architecture class
import torch
import torch.nn as nn
@ -126,11 +123,11 @@ class DummyLayerNorm(nn.Module): #E
上面的代码已经可以正常运行,不过需要先准备输入数据,在本节后面我们会看到运行效果。需要注意的是,目前代码中我们使用了 `DummyLayerNorm``DummyTransformerBlock` 作为 Transformer 模块和层归一化的占位符,实际的实现会在后续部分详细介绍。
接下来,我们将准备输入数据并初始化一个新的 GPT 模型,以展示它的用法。基于第二章实现的分词器,图 4.4 展示了数据在 GPT 模型中流入和流出的整体流程概览
接下来,我们将准备输入数据并初始化一个新的 GPT 模型,以展示它的用法。基于第二章实现的分词器,图 4.4 展示了数据在 GPT 模型中流入和流出的整体流程。
<img src="../Image/chapter4/figure4.4.png" width="75%" />
根据图 4.4 的步骤,我们使用第 2 章介绍的 tiktoken 分词器对包含两个文本输入的批次进行分词,以供 GPT 模型使用:
根据图 4.4 的步骤,我们使用第 2 章介绍的 tiktoken 分词器对包含两个文本的批量输入进行分词,以供 GPT 模型使用:
```python
import tiktoken
@ -185,17 +182,17 @@ tensor([[[-1.2034, 0.3201, -0.7130, ..., -1.5548, -0.2390, -0.4667],
嵌入层的维度为 50,257因为每个维度对应词汇表中的一个唯一 token。在之后的处理中我们会将这些 50,257 维向量转换回 token ID然后再解码成单词。
在对 GPT 架构及其输入输出进行了整体概览之后,接下来的章节中将编写各个占位模块的实现,首先从用真实的层归一化类替换之前代码中的 DummyLayerNorm 开始。
在对 GPT 架构及其输入输出进行了大概介绍之后,接下来的章节中将编写各个占位模块的实现,首先从用真实的层归一化类替换之前代码中的 DummyLayerNorm 开始。
## 4.2 使用层归一化对激活值进行标准化
在训练深神经网络时,梯度消失或梯度爆炸问题有时会带来挑战。这些问题会导致训练过程不稳定,使得网络难以有效调整权重,也就是说,模型难以找到一组能最小化损失函数的参数。换句话说,模型很难从数据中学习到足够准确的模式,以支持其做出准确的预测或决策。(如果您对神经网络训练或梯度概念不熟悉,可参考附录 A 的 A.4 节《自动微分入门》。但要理解本书内容,不需要对梯度概念有深刻的理解。)
在训练深神经网络时,梯度消失或梯度爆炸问题有时会带来挑战。这些问题会导致训练过程不稳定,使得网络难以有效调整权重,也就是说,模型难以找到一组能最小化损失函数的参数。换句话说,模型很难从数据中学习到足够准确的模式,以支持其做出准确的预测或决策。(如果您对神经网络训练或梯度概念不熟悉,可参考附录 A 的 A.4 节《自动微分入门》。但要理解本书内容,不需要对梯度概念有深刻的理解。)
> [!TIP]
>
> **个人思考:** 虽然对文本内容的理解并不需要深度掌握梯度的概念,但如果我们在学习过程中能习惯去发散,必然能帮助我们对所学知识理解的更深刻,下面我们就来聊一下梯度。
> **个人思考:** 虽然对文本内容的理解并不需要深度掌握梯度的概念,但如果我们在学习过程中能习惯去发散,往往能帮助我们对所学知识理解的更深刻,下面我们就来聊一下梯度。
>
> 梯度本质上是一个**变化率**,描述了某个值(例如函数输出值)对另一个值(如输入变量)的变化趋势。简单来说,梯度告诉我们在当前位置上,朝哪个方向移动能让某个目标值增加或减少得更快。
>
@ -231,7 +228,7 @@ print(out)
#A 创建2个训练样本每个样本有5个维度特征
```
打印出的张量中,第一行表示第一个输入样本的网络层输出,第二行表示第二个输入样本的网络层输出:
打印出的张量中,第一行表示第一个输入样本的层输出,第二行表示第二个输入样本的层输出:
```python
tensor([[0.2260, 0.3470, 0.0000, 0.2216, 0.0000, 0.0000],
@ -364,7 +361,7 @@ Variance:
[1.0000]], grad_fn=<VarBackward0>)
```
在本节中,我们介绍了实现 GPT 架构所需的一个基础模块,如图 4.7 所示。
在本节中,我们介绍了实现 GPT 架构所需的一个基础模块`LayerNorm`,如图 4.7 所示。
<img src="../Image/chapter4/figure4.7.png" width="75%" />
@ -431,9 +428,9 @@ plt.show()
<img src="../Image/chapter4/figure4.8.png" width="75%" />
如图 4.8 所示GELU 的平滑性使其在训练过程中具有更好的优化特性能够对模型参数进行更细微的调整。相比之下ReLU 在零点处有一个拐角,这在网络深度较大或结构复杂时可能会增加优化难度。此外,与 ReLU 不同,ReLU 对所有负输入的输出为零,而 GELU 对负值允许一个小的非零输出。这意味着在训练过程中,接收负输入的神经元也能对学习过程产生一定的贡献,尽管贡献程度不及正输入。
如图 4.8 所示GELU 的平滑性使其在训练过程中具有更好的优化特性能够对模型参数进行更细微的调整。相比之下ReLU 在零点处有一个拐角这在网络深度较大或结构复杂时可能会增加优化难度。此外ReLU 对所有负输入的输出为零,而 GELU 对负值允许一个小的非零输出。这意味着在训练过程中,接收负输入的神经元也能对学习过程产生一定的贡献,尽管贡献程度不及正输入。
接下来让我们使用 GELU 激活函数实现一个小型的神经网络模块 FeedForward该模块稍后会应用在 LLM 的 transformer 模块中:
接下来让我们使用 GELU 激活函数实现一个小型的神经网络模块 FeedForward该模块稍后会应用在 LLM 的 Transformer 模块中:
```python
# Listing 4.4 A feed forward neural network module
@ -473,7 +470,7 @@ print(out.shape)
torch.Size([2, 3, 768])
```
我们在本节实现的 FeedForward 模块对模型能力的增强(主要体现在从数据中学习并泛化方面)起到了关键作用。尽管该模块的输入和输出维度相同,但在内部,它首先通过第一个线性层将嵌入维度扩展到一个更高维度的空间(如图 4.10 所示)。之后再接入非线性 GELU 激活,最后再通过第二个线性层变换回原始维度。这样的设计能够探索更丰富的表示空间。
我们在本节实现的 FeedForward 模块对模型能力的增强(主要体现在从数据中学习模式并泛化方面)起到了关键作用。尽管该模块的输入和输出维度相同,但在内部,它首先通过第一个线性层将嵌入维度扩展到一个更高维度的空间(如图 4.10 所示)。之后再接入非线性 GELU 激活,最后再通过第二个线性层变换回原始维度。这样的设计能够探索更丰富的表示空间。
> [!TIP]
>
@ -489,11 +486,9 @@ torch.Size([2, 3, 768])
<img src="../Image/chapter4/figure4.10.png" width="75%" />
此外,输入输出维度保持一致也有助于简化架构,方便堆叠多层(在后续的章节实现),无需调整各层维度,从而提升了模型的可扩展性。
如图4.11所示我们目前已经实现了LLM的大部分组成模块。
如图4.11所示我们目前已经实现了LLM 架构中的大部分模块。
<img src="../Image/chapter4/figure4.11.png" width="75%" />
@ -503,7 +498,7 @@ torch.Size([2, 3, 768])
## 4.4 添加快捷连接
接下来,我们来讨论快捷连接(也称跳跃连接或残差连接)的概念。快捷连接最初是计算机视觉中的深度网络(尤其是残差网络)提出的,用于缓解梯度消失问题。梯度消失是指在训练中指导权重更新的梯度在反向传播过程中逐渐减小,导致早期层(靠近输入端的网络层)难以有效训练,如图 4.12 所示。
接下来,我们来讨论快捷连接(也称跳跃连接或残差连接)的概念。快捷连接最初是计算机视觉中的深度网络(尤其是残差网络)提出的,用于缓解梯度消失问题。梯度消失是指在训练中指导权重更新的梯度在反向传播过程中逐渐减小,导致早期层(靠近输入端的网络层)难以有效训练,如图 4.12 所示。
<img src="../Image/chapter4/figure4.12.png" width="75%" />
@ -575,7 +570,7 @@ def print_gradients(model, x):
上述代码中,我们定义了一个损失函数,用来计算模型输出与用户指定目标(此处为简单起见,目标值设为 0之间的差距。接着当调用 `loss.backward()`PyTorch 会为模型的每一层计算损失的梯度。我们可以通过 `model.named_parameters()` 遍历权重参数。假设某层的权重参数是一个 3×3 的矩阵,那么这一层会有 3×3 的梯度值。然后我们打印出这 3×3 梯度值的绝对均值,以便得到每层的单一梯度值,从而更容易比较各层之间的梯度大小。
简而言之,`.backward()` 是 PyTorch 中一个便捷的方法,用于自动计算损失梯度,这在模型训练中是必要的。它让我们无需亲自实现梯度计算的数学过程,从而大大简化了深度神经网络的开发过程。如果您对梯度和神经网络训练不熟悉,建议参考附录 A 中的 A.4 节:**轻松实现自动微分** 和 A.7 节:**典型的训练循环**。
简而言之,`.backward()` 是 PyTorch 中一个用于自动计算损失梯度的便捷方法,这在模型训练过程中很重要。它让我们无需亲自实现梯度计算的数学过程,从而大大简化了深度神经网络的开发过程。如果您对梯度和神经网络训练不熟悉,建议参考附录 A 中的 A.4 节:**轻松实现自动微分** 和 A.7 节:**典型的训练循环**。
现在让我们使用 `print_gradients` 函数,并将其应用到没有跳跃连接的模型上:
@ -617,7 +612,7 @@ layers.4.0.weight has gradient mean of 1.3258541822433472
从输出结果可以看到最后一层layers.4的梯度依然比其他层更大。然而随着接近第一层layers.0),梯度值逐渐趋于稳定,并未缩小到几乎消失的程度。
总之,快捷连接在解决深神经网络中的梯度消失问题方面具有重要作用。作为 LLM 的核心构建单元,快捷连接可以确保各层之间的梯度稳定流动,从而帮助 GPT 模型更有效的训练(下一章实现训练过程)。
总之,快捷连接在解决深神经网络中的梯度消失问题方面具有重要作用。作为 LLM 的核心构建单元,快捷连接可以确保各层之间的梯度稳定流动,从而帮助 GPT 模型更有效的训练(下一章实现训练过程)。
在介绍了快捷连接后我们将在下一节把之前讲解的所有概念层归一化、GELU 激活、前馈网络模块和快捷连接)整合进一个 Transformer 模块中,这是构建 GPT 架构所需的最后一个模块。
@ -670,7 +665,7 @@ layers.4.0.weight has gradient mean of 1.3258541822433472
>
> $$\frac{\partial L}{\partial X_{1}}=\frac{\partial L}{\partial X_{3}} \cdot \frac{\partial X_{3}}{\partial X_{2}} \cdot \frac{\partial X_{2}}{\partial X_{1}}$$
>
> 这里g,如果某一层的梯度值很小,那么梯度会被逐层缩小,导致梯度消失。
> 这里,如果某一层的梯度值很小,那么梯度会被逐层缩小,导致梯度消失。
>
> - **有快捷连接**时,假设我们在每一层之间都添加快捷连接,梯度的传播路径就多了一条直接路径:
>
@ -686,7 +681,7 @@ layers.4.0.weight has gradient mean of 1.3258541822433472
<img src="../Image/chapter4/figure4.13.png" width="75%" />
如图 4.13 所示Transformer 模块结合了多个组件,包括第 3 章中的掩码多头注意力模块以及我们在 4.3 节中实现的前馈网络模块。
如图 4.13 所示Transformer 模块结合了多个组件,包括第 3 章中的掩码多头注意力模块以及我们在 4.3 节中实现的前馈网络模块。
当 Transformer 模块处理输入序列时,序列中的每个元素(如单词或子词 token都会被表示为固定大小的向量如图 4.13 中为 768 维。Transformer 模块中的操作,包括多头注意力和前馈层,旨在以维度不变的方式对这些向量进行转换。
@ -734,7 +729,7 @@ class TransformerBlock(nn.Module):
给定的代码在 PyTorch 中定义了一个 TransformerBlock 类包含多头注意力机制MultiHeadAttention和前馈网络FeedForward并根据提供的配置字典cfg进行配置例如前文定义的 GPT_CONFIG_124M。
层归一化LayerNorm在这两个组件即自注意力和前馈网络之前应用而 dropout 则在它们之后应用用于正则化模型并防止过拟合。这种方式也称为前置层归一化Pre-LayerNorm。在早期的架构中如原始的 Transformer 模型一般将层归一化应用在自注意力和前馈网络之后这被称为后置层归一化Post-LayerNorm这种方式通常会导致较差的训练效果。
层归一化LayerNorm在这两个组件即自注意力和前馈网络之前应用而 dropout 则在它们之后应用用于正则化模型并防止过拟合。这种方式也称为前置层归一化Pre-LayerNorm在早期的架构中(如原始的 Transformer 模型一般将层归一化应用在自注意力和前馈网络之后这被称为后置层归一化Post-LayerNorm这种方式通常会导致较差的训练效果。
该类还实现了前向传播(`forward方法`),其中每个组件后面都设有一个快捷连接,将对应组件的输入添加到输出中。这一关键特性有助于在训练过程中促进梯度流动,从而提升 LLM 的学习能力,相关内容详见第 4.4 节。
@ -752,16 +747,16 @@ print("Output shape:", output.shape)
#A 建一个形状为 [batch_size, num_tokens, emb_dim] 的输入张量
```
输出如下:
以上代码输出如下:
```python
Input shape: torch.Size([2, 4, 768])
Output shape: torch.Size([2, 4, 768])
```
从代码输出可以看出Transformer 模块的输出维度与输入维度保持一致,这说明 Transformer 架构在整个网络中处理序列数据时不会改变数据的形状。
从代码输出可以看出Transformer 模块的输出维度与输入维度保持一致,这说明 Transformer 架构在处理序列数据时不会改变数据的形状。
Transformer 模块结构中保持数据形状不变并非偶然,而是其设计的一个关键特性。这种设计使 Transformer 擅长处理各种序列到序列任务,因为每个输出向量直接对应一个输入向量,保持一一对应关系。然而,输出向量是包含整个输入序列上下文信息的“上下文向量”。也就是说,尽管序列的物理维度(长度和特征维度)在经过 Transformer 模块时保持不变,但每个输出向量的内容会被重新编码,融合整个输入序列的上下文信息。
Transformer 模块结构中保持数据形状不变并非偶然,而是其设计的一个关键特性。这种设计使 Transformer 擅长处理各种序列到序列任务,因为每个输出向量直接对应一个输入向量,保持一一对应关系。然而,虽然维度一致,但输出向量是包含整个输入序列信息的“上下文向量”。也就是说,尽管序列的物理维度(长度和特征维度)在经过 Transformer 模块时保持不变,但每个输出向量的内容会被重新编码,融合整个输入序列的上下文信息。
在本节完成了 Transformer 模块的实现后,我们已经具备了实现 GPT 架构所需的全部基础模块(如图 4.14 所示)。
@ -770,6 +765,333 @@ Transformer 模块结构中保持数据形状不变并非偶然,而是其设
如图 4.14 所示Transformer 模块由层归一化、带有 GELU 激活函数的前馈网络和快捷连接组成,这些内容在本章前面已经讨论过。正如我们将在接下来的章节中看到的,这个 Transformer 模块将构成我们要实现的 GPT 架构的核心部分。
## 4.6 实现 GPT 模型
截止到目前,本章已初步实现了一个名为`DummyGPTModel`类的GPT架构在该`DummyGPTModel`的代码实现中,我们展示了 GPT 模型的输入和输出形式,但其内部的一些核心模块仅仅使用了`DummyTransformerBlock`和`DummyLayerNorm`等类来占位,并未替换成真正的实现。
在本节中,我们将 DummyTransformerBlock 和 DummyLayerNorm 占位符替换为本章后面实现的真实 TransformerBlock 和 LayerNorm 类,以组装出一个完整可用的原始 GPT-2 模型124M 参数版本)。在第 5 章,我们将预训练一个 GPT-2 模型,第 6 章则会加载 OpenAI 的预训练权重。
在我们通过代码构建 GPT-2 模型之前,先通过图 4.15 看一下模型的整体结构,该结构结合了本章目前为止介绍的所有概念。
<img src="../Image/chapter4/figure4.15.png" width="75%" />
如图 4.15 所示,我们在 4.5 节中编写的 Transformer 模块在 GPT 架构中会重复多次。在参数量为 1.24 亿的 GPT-2 模型中,该模块重复了 12 次,这一数量通过 `GPT_CONFIG_124M` 配置字典中的`n_layers`参数指定。在 GPT-2 最大的 15.42 亿参数模型中Transformer 模块重复了 36 次。
我们还可以从图 4.15 中得知,最后一个 Transformer 模块的输出会经过一个最终的层归一化步骤,然后进入线性输出层。该层将 Transformer 的输出映射到一个高维空间(在本例中为 50,257 维,对应于模型的词汇表大小),以预测序列中的下一个词。
接下来我们用代码实现图 4.15 中的架构:
```python
# Listing 4.7 The GPT model architecture implementation
class GPTModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
self.drop_emb = nn.Dropout(cfg["drop_rate"])
self.trf_blocks = nn.Sequential(
*[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])
self.final_norm = LayerNorm(cfg["emb_dim"])
self.out_head = nn.Linear(
cfg["emb_dim"], cfg["vocab_size"], bias=False
)
def forward(self, in_idx):
batch_size, seq_len = in_idx.shape
tok_embeds = self.tok_emb(in_idx)
pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device)) #A
x = tok_embeds + pos_embeds
x = self.drop_emb(x)
x = self.trf_blocks(x)
x = self.final_norm(x)
logits = self.out_head(x)
return logits
#A 设备设置将根据输入数据所在的位置选择在 CPU 或 GPU 上训练模型
```
通过代码可以看出,由于我们已经在 4.5 节实现了 `TransformerBlock` 类,从而使得 `GPTModel` 类的设计更为简洁。
`GPTModel` 类的构造函数 `__init__` 使用字典 `cfg` 中的配置参数初始化 token 嵌入层和位置嵌入层。这些嵌入层负责将输入的 token 索引转换为密集向量并加入位置信息(在第 2 章已讨论过)。
接下来,`__init__` 方法会根据 `cfg` 中指定的层数创建一个由 TransformerBlock 模块组成的顺序堆栈。紧接在 TransformerBlock 堆栈之后应用一个 LayerNorm 层,对其输出进行标准化,从而稳定训练过程。最后,定义了一个无偏置的线性输出层,将 Transformer 的输出投射到分词器的词汇空间,为词汇表中的每个 token 生成对应的 logits。
forward 方法则负责接收一批 token 索引作为输入,计算它们的词嵌入向量,并应用位置嵌入,接着将序列通过 Transformer 模块进行处理,对最终的输出进行归一化,最后计算 logits 来表示下一个 token 的非归一化概率。我们将在下一节将这些 logits 转换为 token 和文本输出。
现在让我们使用 `GPT_CONFIG_124M` 字典配置来初始化一个具有 1.24 亿参数的 GPT 模型,并将本章开头创建的批量文本作为模型输入。
```python
torch.manual_seed(123)
model = GPTModel(GPT_CONFIG_124M)
out = model(batch)
print("Input batch:\n", batch)
print("\nOutput shape:", out.shape)
print(out)
```
上面的代码依次打印了输入批次的内容和输出张量:
```python
Input batch:
tensor([[ 6109, 3626, 6100, 345], # token IDs of text 1
[ 6109, 1110, 6622, 257]]) # token IDs of text 2
Output shape: torch.Size([2, 4, 50257])
tensor([[[ 0.3613, 0.4222, -0.0711, ..., 0.3483, 0.4661, -0.2838],
[-0.1792, -0.5660, -0.9485, ..., 0.0477, 0.5181, -0.3168],
[ 0.7120, 0.0332, 0.1085, ..., 0.1018, -0.4327, -0.2553],
[-1.0076, 0.3418, -0.1190, ..., 0.7195, 0.4023, 0.0532]],
[[-0.2564, 0.0900, 0.0335, ..., 0.2659, 0.4454, -0.6806],
[ 0.1230, 0.3653, -0.2074, ..., 0.7705, 0.2710, 0.2246],
[ 1.0558, 1.0318, -0.2800, ..., 0.6936, 0.3205, -0.3178],
[-0.1565, 0.3926, 0.3288, ..., 1.2630, -0.1858, 0.0388]]],
grad_fn=<UnsafeViewBackward0>)
```
可以看到,输出张量的形状是 [2, 4, 50257],这是因为我们输入了 2 个文本,每个文本包含 4 个 token。最后一个维度 50257 对应于分词器的词汇表大小。在下一节中,我们将看到如何将这些 50257 维的输出向量转换回 token。
在我们继续后续内容并编写将模型输出转换为文本的函数之前,让我们先花点时间研究一下模型架构本身,并分析其规模。
使用 numel() 方法(即 `number of elements` 的缩写),可以统计模型中参数张量的总参数量:
```python
total_params = sum(p.numel() for p in model.parameters())
print(f"Total number of parameters: {total_params:,}")
```
输出如下:
```python
Total number of parameters: 163,009,536
```
细心的读者可能会发现一个差异:我们之前提到 GPT 模型的参数量为 1.24 亿,但代码输出的实际参数量却是 1.63 亿,这是为什么呢?
原因在于 GPT-2 架构中使用了一种称为‘权重共享’的概念,这意味着 GPT-2 架构将 token 嵌入层的权重复用于输出层。为了更好地理解这一点,我们可以来看一下在模型中初始化的 token 嵌入层和线性输出层的形状:
```python
print("Token embedding layer shape:", model.tok_emb.weight.shape)
print("Output layer shape:", model.out_head.weight.shape)
```
从打印结果可以看到,这两层的权重形状相同:
```python
Token embedding layer shape: torch.Size([50257, 768])
Output layer shape: torch.Size([50257, 768])
```
token 嵌入层和输出层的参数量很大,因为分词器词汇表中包含 50,257 个 token。根据权重共享原则我们可以从 GPT-2 模型的总参数量中去除输出层的参数量。
```python
total_params_gpt2 = total_params - sum(p.numel() for p in model.out_head.parameters())
print(f"Number of trainable parameters considering weight tying: {total_params_gpt2:,}")
```
输出如下:
```python
Number of trainable parameters considering weight tying: 124,412,160
```
如我们所见,模型现在的参数量为 1.24 亿,与 GPT-2 原始模型的规模一致。
权重共享能够减少模型的整体内存占用和计算复杂度。然而,根据我的经验,分别使用独立的 token 嵌入层和输出层会使训练效果和模型性能更佳,因此在我们的 GPT 模型实现中,我们使用了独立的嵌入层和输出层。现代大语言模型也是如此。不过,在第 6 章加载 OpenAI 的预训练权重时,我们会再次探讨并实现权重共享的概念。
> [!NOTE]
>
> **练习 4.1 前馈网络和注意力模块的参数数量**
>
> 计算并比较前馈模块和多头注意力模块中包含的参数数量。
最后,让我们来计算 GPTModel 对象中 1.63 亿参数所需的内存:
```python
total_size_bytes = total_params * 4 #A
total_size_mb = total_size_bytes / (1024 * 1024) #B
print(f"Total size of the model: {total_size_mb:.2f} MB")
#A 计算参数总大小(假设每个参数为 float32 类型,占用 4 字节)
#B 转换为 MB
```
输出如下:
```python
Total size of the model: 621.83 MB
```
通过计算 GPTModel 中 1.63 亿个参数所需的内存,并假设每个参数为 32 位浮点数,占用 4 字节,我们得出模型总大小为 621.83 MB。这表明即使是相对较小的大语言模型也需要较大的存储空间。
在本节中,我们实现了 GPTModel 架构,并观察到它的输出是形状为 [batch_size, num_tokens, vocab_size] 的数值张量。接下来,我们将编写代码把这些输出张量转换为文本。
> [!NOTE]
>
> **练习 4.2 初始化大型 GPT 模型**
>
> 本章中,我们初始化了一个拥有 1.24 亿参数的 GPT 模型,即 GPT-2 small。请在不更改代码的情况下仅更新配置文件使用 `GPTModel` 类实现 GPT-2 medium1024 维嵌入、24 层 Transformer 块、16 个多头注意力头、GPT-2 large1280 维嵌入、36 层 Transformer 块、20 个多头注意力头)和 GPT-2 XL1600 维嵌入、48 层 Transformer 块、25 个多头注意力头)。作为附加任务,请计算每个 GPT 模型的总参数量。
## 4.7 生成文本
在本章的最后一节,我们将编写代码把 GPT 模型的张量输出转回文本。在开始之前,我们先简要回顾一下像 LLM 这样的生成模型是如何逐词生成文本的,如图 4.16 所示。
<img src="../Image/chapter4/figure4.16.png" width="75%" />
如图 4.16 所示GPT 模型在给定输入上下文(例如 Hello, I am逐步生成文本。每次迭代中输入上下文会不断扩展使模型能够生成连贯且符合上下文的内容。在第 6 次迭代时,模型已构建出完整句子 Hello, I am a model ready to help.’。
在上一节,我们看到目前的 GPTModel 输出的张量形状为 `[batch_size, num_token, vocab_size]`。那么问题来了GPT 模型是如何将这些输出张量转化为图 4.16 所示的生成文本的呢?
GPT 模型从输出张量到生成文本的过程涉及几个步骤(如图 4.17 所示)。这些步骤包括解码输出张量、根据概率分布选择 token并将其转化为可读文本。
<img src="../Image/chapter4/figure4.17.png" width="75%" />
图 4.17 详细展示了 GPT 模型根据输入生成下一个 token 的单步过程。
在每一步,模型会输出一个矩阵,其中的向量表示潜在的下一个 token。取出对应于下一个 token 的向量,并通过 softmax 函数将其转换为概率分布。在包含概率分数的向量中,找到最高值的索引,并将其转换为 token ID。将该 token ID 解码回文本,得到序列中的下一个 token。最后将该 token 添加到先前的输入中,形成下一次迭代的新输入序列。这种逐步生成的过程使模型能够根据初始输入上下文,按顺序生成文本,从而构建出连贯的短语和句子。
在实践中,我们会多次迭代这一过程(如前文图 4.16 所示),直到生成的 token 数量达到用户指定值。
我们通过以下代码来实现上述的 token 生成过程:
```python
# Listing 4.8 A function for the GPT model to generate text
def generate_text_simple(model, idx, max_new_tokens, context_size): #A
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:] #B
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :] #C
probas = torch.softmax(logits, dim=-1) #D
idx_next = torch.argmax(probas, dim=-1, keepdim=True) #E
idx = torch.cat((idx, idx_next), dim=1) #F
return idx
#A idx 是当前上下文中索引的数组,形状为 (batch, n_tokens)
#B 若上下文长度超出支持范围,则进行裁剪。例如,若模型仅支持 5 个 token而上下文长度为 10仅使用最后 5 个 token 作为上下文
#C 仅关注最后一个时间步,将形状从 (batch, n_token, vocab_size) 转换为 (batch, vocab_size)
#D probas 的形状为 (batch, vocab_size)
#E idx_next 的形状为 (batch, 1)
#F 将采样的索引追加到当前序列中,此时 idx 的形状为 (batch, n_tokens+1)
```
在上述代码中,`generate_text_simple` 函数使用 Softmax 函数将 logits 转换为概率分布,然后通过 `torch.argmax` 找出概率最高的位置。Softmax 函数是单调的这意味着它会保持输入的相对顺序因此Softmax 这一步实际上是冗余的,因为 Softmax 输出中最高值的位置与原始 logits 中最高值的位置相同。换句话说,我们可以直接对 logits 应用 `torch.argmax` 得到相同的结果。不过,我们保留了这个转换过程,以展示从 logits 到概率的完整过程,有助于理解模型如何生成最可能的下一个词,这种方式称为贪婪解码。
在下一章中,我们将在实现 GPT 训练代码的同时,引入一些新的采样技术,通过修改 softmax 输出,使模型在生成文本时不总是选择概率最高的词,这可以增加文本的多样性和创造性。
我们可以使用 `generate_text_simple` 函数逐步生成 token ID每次生成一个 token ID 并将其附加到上下文中。其具体过程详见图 4.18(每次迭代生成 token ID 的步骤详见图 4.17)。
<img src="../Image/chapter4/figure4.18.png" width="75%" />
如图 4.18 所示,我们以迭代的方式逐步生成 token ID。例如在第 1 轮迭代中模型接收到“Hello , I am”对应的 token 作为输入,预测下一个 tokenID 为 257对应“a”并将其添加到输入序列中。这个过程不断重复直到模型在第六轮迭代后生成完整的句子“Hello, I am a model ready to help.”。
接下来我们将实践 `generate_text_simple` 函数,并使用 Hello, I am 作为模型的输入上下文,具体如图 4.18 所示。
首先,我们将输入上下文编码为 token ID
```python
start_context = "Hello, I am"
encoded = tokenizer.encode(start_context)
print("encoded:", encoded)
encoded_tensor = torch.tensor(encoded).unsqueeze(0) #A
print("encoded_tensor.shape:", encoded_tensor.shape)
#A 添加批次维度
```
编码后的 token ID 如下:
```python
encoded: [15496, 11, 314, 716]
encoded_tensor.shape: torch.Size([1, 4])
```
接下来,将模型置于 `.eval()` 模式,禁用训练时使用的随机组件(如 dropout然后在编码后的输入张量上使用 `generate_text_simple` 函数进行文本生成:
```python
model.eval() #A
out = generate_text_simple(
model=model,
idx=encoded_tensor,
max_new_tokens=6,
context_size=GPT_CONFIG_124M["context_length"]
)
print("Output:", out)
print("Output length:", len(out[0]))
#A 禁用 dropout因为当前不是在训练模型
```
输出 token ID 如下:
```python
Output: tensor([[15496, 11, 314, 716, 27018, 24086, 47843, 30961, 42348, 7267]])
Output length: 10
```
接着,使用分词器的 `.decode` 方法可以将 ID 转回文本:
```python
decoded_text = tokenizer.decode(out.squeeze(0).tolist())
print(decoded_text)
```
模型输出如下:
```python
Hello, I am Featureiman Byeswickattribute argue
```
从以上的输出可以看到,模型生成的是一些毫无意义的内容,完全不像图 4.18 中的连贯文本。这是为什么呢?原因在于模型还没有经过训练。到目前为止,我们只是实现了 GPT 架构,并用随机权重初始化了模型实例。
模型训练本身就是一个庞大的主题,我们将在下一章详细讨论。
> [!NOTE]
>
> **练习 4.3:使用独立的 Dropout 参数**
>
> 在本章开头,我们在 `GPT_CONFIG_124M` 字典中定义了一个全局的 `"drop_rate"` 设置,用于统一设置整个 GPTModel 架构中各处的 dropout 率。请修改代码,为模型架构中各个不同的 dropout 层指定独立的 dropout 值。提示我们在三个不同的地方使用了dropout层嵌入层、快捷连接层和多头注意力模块
## 4.8 总结
+ 层归一化通过确保每一层的输出具有一致的均值和方差,从而稳定训练过程。
+ 在大语言模型LLM快捷连接可以通过将某一层的输出直接传递给更深层来跳过一个或多个层有助于缓解深度神经网络训练中的梯度消失问题。
+ Transformer 模块是 GPT 模型的核心结构,结合了掩码多头注意力模块和使用 GELU 激活函数的全连接前馈网络。
+ GPT 模型是由许多重复的 Transformer 模块组成的大语言模型,参数量高达数百万到数十亿。
+ GPT 模型有不同的规模,例如 1.24 亿、3.45 亿、7.62 亿和 15.42 亿参数。这些不同规模的模型可以用同一个 GPTModel 类来实现。
+ 类似 GPT 的大语言模型通过逐个预测 token根据给定的输入上下文将输出张量解码为可读文本从而实现文本生成能力。
+ 未经训练的 GPT 模型生成的文本往往语义不连贯,这突显了训练对于生成连贯文本的重要性,这也将是后续章节的讨论重点。