
掌握 Transformers:理解 ChatGPT 算法及其代码实现的 7 个步骤
从概念到代码:揭秘 ChatGPT 算法
如何逐步解释 Transformer 的工作原理,并附带简化的代码示例
在过去的两年里,ChatGPT 和大型语言模型(LLM)总体上是人工智能领域的大热门。 已经发表了许多关于如何使用、提示工程以及背后逻辑的文章。 然而,当我开始熟悉 LLM 的算法——所谓的 transformer——时,我不得不查阅许多不同的资料,才觉得自己真正理解了这个主题。 在本文中,我想总结我对大型语言模型的理解。 我将概念性地解释 LLM 如何逐步计算它们的响应,深入研究注意力机制,并在代码示例中演示其内部工作原理。 那么,让我们开始吧!
目录
第 1 部分:Transformer 的概念
- 1 Transformer 简介
- 2 分词
- 3 词嵌入
- 4 位置编码
- 5 注意力机制
- 6 层归一化
- 7 前馈
- 8 Softmax
- 9 多项式
第 2 部分:代码实现
- 1 数据准备
- 2 分词
- 3 数据馈送函数
- 4 注意力头
- 5 多头注意力
- 6 注意力块的前馈
- 7 注意力块
- 8 Transformer 类
- 9 实例化 Transformer
- 10 模型训练
- 11 生成新 Token
第 1 部分:Transformer 的概念
1.1 Transformer 简介
我们不能在不引用 Vaswani 等人在 2017 年发表的著名论文 “Attention Is All You Need” 的情况下讨论大型语言模型的主题。 在这篇文章中,研究小组介绍了注意力机制和 transformer 架构,这引发了我们今天所经历的生成式人工智能的革命。 最初,该论文指的是机器语言翻译,并引入了编码器-解码器结构。
图 1.1.1:Vaswani 等人 介绍的 Transformer 架构 | 左侧为原始架构,右侧为作者的解释
图 1.1.1 的左侧显示了论文中发布的 transformer,右侧我标记了编码器和解码器部分。 在机器语言翻译中,初始语言由编码器编码,并由解码器解码成目标语言。 相反,ChatGPT 具有仅解码器架构。 因此,在下文中,我们将忽略左侧,并完全专注于解码器。
在我开始解释 transformer 之前,我们需要记住 ChatGPT 以循环方式生成其输出,一个 token 接一个 token。 假设我们输入单词“ChatGPT writes…”(是的,我知道这个上下文不切实际地短)。 ChatGPT 可能会在第一个循环中输出 token “…one”。 初始单词加上第一个输出构成了第二个生成循环的上下文,因此输入是“ChatGPT writes one…”。 现在,ChatGPT 可能会输出“…word”,它被连接到现有的上下文中并再次输入。 这种循环持续进行,直到生成的输出是一个停止 token,这表明响应已达到其结尾,并且生成循环完成,直到下一次用户交互。
图 1.1.2:ChatGPT 以循环方式生成其输出,一个 token 接一个 token | 作者的图片
现在,最大的问题是:在图 1.1.2 中标为“ChatGPT”的魔盒内部发生了什么? 该算法如何得出结论,接下来要输出哪个 token? 这正是我们将在本文中回答的问题。
图 1.1.3 按顺序显示了 transformer 的处理步骤,它是原始论文(图 1.1.1)中插图的替代方案。 我更喜欢使用这张图片,因为它让我可以更好地构建解释。
图 1.1.3:ChatGPT 中使用的 Transformer 架构 | 作者的图片
在图 1.1.3 中,我们看到了 transformer 的输入,位于左下角——token 序列“ChatGPT writes…”——以及 transformer 的输出,位于右上角,即“…one”。输入和输出之间发生了什么?
- 在图 1.1.3 的左侧,我们找到了一些预处理步骤:分词、词嵌入和位置编码。 我们将在本介绍之后立即研究这些步骤。
- 在中间部分,我们看到了所谓的注意力块。 这是处理单词和句子上下文的地方。 注意力块是 ChatGPT 的魔力,也是该机器人输出如此令人信服的原因。
- 在图 1.1.3 的右侧,我们看到注意力块的输出被归一化(“层归一化”),输入到神经网络(“前馈”),进行 softmax 处理,最后通过多项式分布运行。 稍后,我们将看到,通过这四个步骤,我们计算了词汇表中所有 token 成为下一个输出的概率,并根据这些概率从多项式分布中采样实际输出。 但请耐心等待——我们将在本文稍后详细研究这一点。 现在,我们接受这个过程的输出是 token “one”。
考虑到这个概述,让我们在接下来的章节中逐一介绍处理步骤。
1.2 分词
Token 是大型语言模型中文本处理的基本构建块。 将文本拆分成 token 的过程称为分词。 根据分词模型,接收到的 token 看起来可能非常不同。 某些模型将文本拆分成单词,其他模型拆分成子词或字符。 与粒度无关,分词模型还包括标点符号和特殊 token,例如
图 1.2.1:分词 | 作者的图片
图 1.2.1 显示了一个简单的示例。 上下文“Let’s go in the garden”被拆分成七个 token“let”、“’”、“s”、“go”、“in”、“the”、“garden”。 这些 token 为 LLM 所知,并将由内部数字表示以供进一步处理。 反之亦然,当 LLM 处理其输出时,它通过概率确定下一个 token,并从几个生成循环的 token 中组成输出的句子。
#### 1.3 Word Embedding
到目前为止,我们已经看到分词器将输入句子分割成 token。 接下来,word embedding 将 token 转换为大型向量,通常具有数百或数千个维度,具体取决于所选的模型。 一般来说,embedding 深度越高(意味着向量越大),embedding 能够捕获的信息就越多。

图 1.3.1:我们示例句子的 word embedding | 作者提供的图片
在图 1.3.1 中,我们继续使用我们的示例:分词器将句子“Let’s go in the garden”分割成七个 token:“let”、“’”、“s”、…
word embedding 将 token 转换为所示的向量。 在图 1.3.1 中,我用 *n* 表示维数,但实际上,我使用了 100——一个相对较小的数字!
关于 word embedding 很有趣的一点是,它捕获了 token 的特征。 其中一个含义是,具有相似含义的单词会获得相似的 word embedding,因此,它们位于 embedding 空间附近。 token 的这种特性是在 embedding 过程中自动捕获的,因为具有相似含义的单词用于相似的上下文。

图 1.3.2:示例单词的 word embedding,原始深度 n=100,使用主成分分析 (PCA) 降至 n\_pca=2,用于演示目的 | 作者提供的图片
在图 1.3.2 中,您可以看到示例单词的原始 embedding 深度为 100。 为了在平面上绘制 embeddings,我使用主成分分析 (PCA) 将 embedding 向量降至 2 维。 我们看到描述动物的单词构成一个集群,水果构成另一个集群,工具、车辆和运动也是如此。 因此,具有相似含义的单词位于附近,因为它们具有相似的 embeddings。
但是 word embedding 不仅捕获相似性,还捕获单词之间的关系。 例如,在图 1.3.3 中,您可以看到几个成人-婴儿关系。 同样,原始 embeddings 的深度为 100,并使用 PCA 降至 2。

图 1.3.3:单词之间的成人-婴儿关系的 word embedding; 原始 embedding 深度 n=100,使用 PCA 降至 n\_pca=2 | 作者提供的图片
我们看到“puppy”与“dog”的关系就像“kitten”与“cat”的关系,“toddler”与“human”的关系或“calf”与“cow”的关系。 在每种情况下,婴儿单词都在左上角,而成人单词在右下角。
在本文中,我们认为 word embeddings 是给定的,因为我们有很多现成的模型(例如,Word2Vec、GloVe、fastText)。 尽管如此,我想给您一些关于如何计算 word embeddings 的高级直觉。 请注意,word embedding 方法各不相同。 这里草绘的方式只是一种方法。

图 1.3.4:在编码器-解码器神经网络中计算 word embeddings | 作者提供的图片
Word embeddings 来自编码器-解码器神经网络。 这种架构的典型特征是输入被压缩成较低的维度,然后我们尝试从那里重建信息到较高的维度。 在图 1.3.4 中,我们在第一层和最后一层中为词汇表中的每个 token 都有一个节点。 我们将一个单词或几个单词输入到编码器中,在相应的节点中输入“1”,在所有其他节点中输入“0”(one-hot 编码)。 网络将信息压缩到较低的维度——这等于 embedding 深度——然后尝试完成一项任务,例如预测序列中的下一个单词。 如果计算出的输出错误,则模型的权重通过反向传播更新。 一旦我们达到足够的准确度,我们就将较低维度的权重作为我们的 word embeddings。 图 1.3.5 显示了“abacus”的 embedding 作为示例。 embedding 等于粗体标记的关系后面的权重。

图 1.3.5:“abacus”的 word embedding | 作者提供的图片
### 1.4 位置编码
词嵌入不会跟踪词序。句子“Today, I’m not happy, I am sad!”与句子“Today, I’m not sad, I am happy!”得到完全相同的嵌入向量。当然,作为人类,我们知道这两个陈述的意思相反,但 Transformer 却不知道!
这就是位置编码发挥作用的地方。主要思想是向每个嵌入向量添加一个与词嵌入大小相同的向量。位置向量指定了上下文中的 token 位置。

图 1.4.1:位置编码 | 作者提供的图片
图 1.4.1 继续我们的例子“Let’s go in the garden”。我们看到,对于七个 token 中的每一个,都会添加一个大小相等的 *n* 的位置向量。位置向量对给定上下文中的第 1、第 2、第 3... 个位置进行编码。因此,嵌入向量和位置向量的和包含了关于 token 及其位置的信息,并且不再独立于词序。使用位置编码,“I’m not happy, I am sad!”和“I’m not sad, I am happy!”的嵌入向量不再相等!
现在,我们如何确定位置向量?事实上,有不同的方法,我将只介绍其中的两种:
* Vaswani 等人在“Attention is all you need”中描述的方法,以及
* 我们在自己的编码中使用的简化方法。

图 1.4.2:确定位置编码向量的两种方法 | 作者提供的图片
Vaswani 等人建议使用正弦和余弦函数来确定位置向量(图 1.4.2 左)。“*pos*”代表上下文中的 **token** 位置:第一个 token、第二个 token、第三个 token……而 *2i* 和 *2i+1* 代表奇数和偶数 **嵌入** 位置(位置向量中的位置)。*i* 从 0 运行到 *dmodel/2*。对于偶数嵌入位置,我们使用正弦函数,对于奇数嵌入位置,我们使用余弦函数。
图 1.4.2 右侧显示了我们在自己的编码(第 2 部分)中使用的方法。我们使用了 PyTorch 嵌入模块,该模块初始化为标准正态分布值。这意味着我们只确定位置向量的大小,但向量值是随机的。当然,一旦确定,我们就会在 LLM 的整个生命周期内保持上下文中的第 1、第 2、第 3... 个位置的位置向量固定。
我个人的印象是,位置向量的计算方式对于 Transformer 的性能来说不太重要。但是,使用位置编码至关重要,无论向量是如何计算的。
### 1.5 注意力机制
注意力机制是 Transformer 的核心。这是 ChatGPT 在语言处理方面表现如此出色的主要原因。我们接下来讨论的一切的关键在于“上下文”。
本章中的解释主要遵循 [Luis Serrano](undefined) 的观点。我向任何感兴趣的人推荐他在 YouTube 上的视频系列“[LLM 中的注意力机制](https://www.youtube.com/watch?v=UPtG_38Oq8o&list=PLs8w1Cdi-zvYskDS2icIItfZgxclApVLv&index=2)”。
### 1.5.1 调整词嵌入到上下文
在人类语言中,词语和句子的上下文对于理解其含义非常重要。词语在不同的上下文中可以有不同的含义。
我们已经了解到,词嵌入已经考虑了哪些词语更经常与其他词语一起出现。但是,词嵌入并没有考虑特定情况下的上下文。我们可以说词嵌入具有*静态上下文*,而我们需要一个*动态上下文*,它考虑特定句子在其特定的逻辑设置中的情况。
我们的手机可以研究这种差异:自动完成功能知道哪个词最有可能跟随前一个词。但是,在没有“理解”上下文的情况下,它会在三四个自动完成之后开始输出纯粹的胡言乱语(图 1.5.1)。

图 1.5.1:手机的自动完成功能是“静态”上下文的一个例子 | 作者供图
我想我们都同意:这并不是我们期望 ChatGPT 和其他 LLM 提供的回应,对吧?
正如之前简要提到的,词语在不同的上下文中可以有不同的含义。为了演示,我想引用 Luis Serrano 的一个很好的例子。

图 1.5.2:“apple”、“orange”和“phone”的“静态”词嵌入 | 基于 [Serrano Academy](https://serrano.academy/) 的图像
图 1.5.2 显示了我们从 GloVe 中获得的词嵌入。如您所见,单词“apple”既不接近单词“phone”,也不接近单词“orange”。
现在,假设我们要嵌入句子“apple unveiled a new phone”。作为人类,我们立即知道单词“apple”代表这家科技公司。我们怎么知道?特定的上下文告诉我们,特别是单词“phone”(图 1.5.3 左)。

图 1.5.3:“apple”在不同上下文中的两种含义 | 基于 [Serrano Academy](https://serrano.academy/) 的图像
接下来,假设我们要嵌入句子“bring me an apple and an orange”。同样,作为人类,我们立即知道这次我们谈论的是水果。特别是,单词“orange”非常有帮助(图 1.5.3 右)。
我们如何教计算机理解上下文?这就是注意力机制的作用!注意力的核心思想是在非常具体的上下文中修改词嵌入。因此,我们将词语移到嵌入空间中与那些具有上下文关系的词语更近的位置。

图 1.5.4:注意力的核心思想是在嵌入空间中将相关标记移到更近的位置 | 作者供图
图 1.5.4 说明了这一核心思想。在谈论水果的情况下,我们将“apple”的词嵌入移到更靠近“orange”的位置,而在谈论科技公司的情况下,我们将其移到“phone”的位置。
我们如何教计算机理解“apple”与“phone”或“orange”有关?我们需要计算“apple”与上下文中所有其他词语之间的词语亲和力——包括它自己。这告诉我们哪里有很强的亲和力,哪里有较弱的亲和力。事实上,我们计算了上下文中所有标记组合之间的词语亲和力(图 1.5.5)。
最后,根据亲和力,我们修改词嵌入,并将具有更强上下文的标记移到更近的位置。

图 1.5.5:词语亲和力和嵌入的修改 | 作者供图
好的,在这一点上,我们知道一旦我们知道了词语亲和力,我们想做什么。但是我们如何计算它们呢?
有不同的方法来计算亲和力——通常称为相似度。在这里,我们使用*缩放点积*,这也在论文“Attention Is All You Need”中提出。
缩放点积是我们要评估的两个标记的词嵌入向量*a*和*b*之间的点积,除以词嵌入向量的维数*d*的平方根。

图 1.5.6:上下文“bring me …”中的缩放点积亲和力 | 作者供图
图 1.5.6 显示了上下文“bring me an apple and an orange”中所有标记组合的缩放点积亲和力。正如我们在表格的最后一列中看到的那样,行的总和加起来约为 10 到 18 之间的值。为了计算新的词嵌入,将总和精确到 1 更方便。为了实现这一点,我们使用 softmax 函数:
根据公式 1.5.2,softmaxing 意味着将每一行的每个值取到 e 的幂,然后除以所有值的 e 的幂的总和。对于我们的例子,在应用 softmax 之后,我们得到图 1.5.7 中显示的值。

图 1.5.7:应用 softmax 后的缩放点积亲和力 | 作者供图
图 1.5.7 中的亲和力是计算上下文调整的“动态”词嵌入向量的系数:亲和力越高,标记对新嵌入的影响就越大。以“apple”为例(图 1.5.7 中的第四行):
我们看到,“apple”的上下文调整后的“动态”嵌入是上下文中所有标记的乘积之和,乘以“apple”与该特定标记的亲和力。在我们的例子中,单词“apple”的词嵌入仅保留了原来的 63.1%,并修改了 36.9% 以更好地适应其上下文。
请记住,公式 1.5.3 中的“bring”等指的是嵌入向量。在向量表示法中,公式 1.5.3 如下所示:
上下文中所有其他标记的词嵌入也相应地进行调整。
### 1.5.2 查询、键和值矩阵
到目前为止,我们已经学习了如何计算上下文调整后的新词嵌入的原理。如果我们深入研究论文“Attention Is All You Need”,我们会发现一种经过修改的方法,其中包含所谓的查询 *Q*、键 *K* 和值 *V* 矩阵。让我们探索一下这种方法背后的思想。

图 1.5.8:论文“Attention Is All You Need”中描述的缩放点积 | 图片来自 [Vaswani et al.](https://arxiv.org/pdf/1706.03762)
通过查询 *Q*、键 *K* 和值 *V* 矩阵,我们为注意力机制添加了一个额外的*反向传播学习*部分。这些矩阵帮助模型在给定上下文中找到更好的词嵌入。目前,让我们专注于查询 *Q* 和键 *K* 矩阵。值 *V* 稍后会介绍。
到目前为止,我们已经根据公式 1.5.1 使用简单的点积计算了词的亲和力。

图 1.5.9:没有 Q 和 K 矩阵的缩放点积 | 图片来自作者
图 1.5.9 显示了以向量表示法计算亲和力。目前,我们专注于乘法,并在稍后添加使用 *sqrt(d)* 的缩放。在右侧,我们看到了如何将点积计算为向量积:我们取苹果嵌入向量的每个条目——现在写成行向量——并将其乘以橙色列向量的相应条目,将它们全部加起来。结果,我们得到一个标量。
如果我们从数学中回忆一下,将一个向量与一个矩阵相乘在几何上意味着一种变换。这种变换要么拉伸、压缩、旋转或扭曲向量空间。

图 1.5.10:Q 和 K 矩阵变换向量空间 | 图片基于 [Serrano Academy](https://serrano.academy/)
在图 1.5.10 所示的示例中,空间仅为 2D,但在注意力算法中,它可以具有任意数量的维度。
我们为什么要扭曲向量空间? 我们的想法是找到一个最适合表示特定上下文中标记含义的空间。
在图 1.5.10 中,我们看到左侧平面非常适合区分单词“apple”的两种含义。中间的平面做得非常糟糕,因为即使我们将单词“apple”移近“phone”或“orange”,我们仍然有两个表示非常接近。第三个平面显然是最好的。它强烈支持区分“apple”的两种含义。
通过查询 *Q* 和键 *K* 矩阵,我们让 transformer 网络找到用于捕获标记上下文的最佳参数。因此,我们将这两个矩阵添加到词的亲和力的计算中。我们没有直接将两个嵌入向量相乘,而是将第一个嵌入向量和 *Q* 矩阵(它本身是一个向量)的乘积与第二个嵌入向量和 *K* 矩阵(转置)的乘积相乘。

图 1.5.11:带有 Q 和 K 矩阵的缩放点积 | 图片基于 [Serrano Academy](https://serrano.academy/)
图 1.5.11 中间的 *Q* 和 *K* 矩阵的乘积(虚线框)再次是一个矩阵,它决定了向量空间的线性变换。它负责将给定的空间转换为最适合词嵌入的空间。
总而言之,如果没有查询 *Q* 和键 *K* 矩阵,我们将使用给定的向量空间。有了 *Q* 和 *K*,我们找到了一个最佳的向量空间,因此,根据输入句子的上下文,可以得到一个更好的动态词嵌入。

图 1.5.12:带有和不带有 Q 和 K 矩阵的缩放点积的比较 | 图片基于 [Serrano Academy](https://serrano.academy/)
现在,让我介绍一下值矩阵 *V* 及其功能。
我们使用查询 *Q* 和键 *K* 矩阵找到的向量空间针对词的亲和力进行了优化。值矩阵 *V* 准备了下一步,即计算下一个标记跟随给定上下文的概率。因此,我们计算了第三个线性变换,这为我们提供了一个针对此任务优化的向量空间。

图 1.5.13:值矩阵 V 为计算标记概率做准备 | 图片基于 [Serrano Academy](https://serrano.academy/)
到目前为止,我们只考虑了一个标记作为输入的玩具示例。关于第 2 部分中的代码示例,我想演示一下,如果我们将整个句子作为输入,矩阵运算会是什么样子。从那里,很容易得出真实的矩阵运算是什么样子的结论。

图 1.5.14:为输入句子扩大 Q 点 K 转置的乘法 | 图片来自作者
在图 1.5.14 中,红色的 7x3 矩阵 *X* 代表输入句子及其嵌入。每一行对应一个标记。这三列是词嵌入的三个维度。请记住,真实的词嵌入具有数百或数千个维度!
绿色的 3x4 矩阵是我们的查询矩阵 *Q*,它为注意力模型添加了额外的学习能力。这三行是固定的,因为它们需要对应于 *X* 的嵌入维度。这四列称为“head-size”,是 transformer 模型的超参数。理论上,我们可以选择任何值。列越多,学习能力就越强。
键矩阵 *K* 始终与查询矩阵 *Q* 的大小相同。因此,它具有维度 3x4,但在图 1.5.14 中,它被转置为 4x3。对于黄色的输入矩阵 *X* 也是如此,它对应于红色的输入矩阵。
乘法运算返回一个 7x7 矩阵,其中包含输入句子中每个可能的标记组合的未缩放亲和力。

图 1.5.15:使用值矩阵 V 扩大矩阵乘法 | 图片来自作者
图 1.5.15 延续了图 1.5.14 中的示例。未缩放的标记亲和力的 7x7 矩阵乘以我们之前使用的 7x3 输入矩阵 *X*。下一步是将结果乘以 3x4 值矩阵 *V*。同样,这三行对应于词嵌入的维度,这四列是超参数 head-size。乘法运算的结果是一个 7x4 矩阵,它表示词
### 注意力矩阵运算
用于计算下一个 token 概率的 embeddings。我们将在接下来的章节中探讨如何继续进行。
到目前为止,我们省略了缩放和 softmax,但这两个步骤都非常简单,几乎与我们在第 1.5.1 章中讨论的内容相同。
缩放应用于计算查询矩阵 *Q* 和键矩阵 *K* 的点积之后。我们只需将未缩放的亲和力 7x7 矩阵的每个值除以 embedding 维度 *sqrt(d)* 的平方根。

图 1.5.16:将亲和力按 sqrt(d) 缩放 | 作者提供的图像
缩放后的下一步是 softmax。与缩放一样,softmax 也是逐元素应用的,并且每个元素都除以相应行的总和(如第 1.5.1 章中所述)。

图 1.5.17:对缩放后的单词亲和力进行 Softmax | 作者提供的图像
最后,让我总结一下注意力矩阵运算的步骤:
1. 我们计算 *X* \* *Q* \* *K\_transposed* \* *X\_transposed* 的矩阵乘积,并接收未缩放的亲和力作为七个输入 token 的 7x7 矩阵。
2. 我们将 7x7 矩阵的每个元素除以 *sqrt(d)*。这给了我们缩放后的亲和力。缩放对于 softmax 运算很重要,因为初始值应该很小。
3. Masking 是原始论文中提到的一个可选步骤,但在 ChatGPT 中没有应用。我们忽略它。
4. 我们对缩放后的亲和力矩阵进行 softmax。结果,7x7 矩阵的每一行加起来为 1。
5. 最后,我们将缩放和 softmax 的 7x7 单词亲和力矩阵与 *X* \* *V* 相乘,并将上下文调整后的单词 embeddings 作为最终结果(图 1.5.18)。

图 1.5.18:将缩放和 softmax 的单词亲和力与值矩阵 V 相乘 | 作者提供的图像
### 1.5.3 Multi-head Attention
到目前为止,我们所讨论的都是单个 attention 操作,称为 attention head。但 ChatGPT 和其他 transformers 都有 *h* 个这样的操作。这意味着我们并行计算 *h* 个 queries、keys 和 values 矩阵。这为我们提供了 *h* 次尝试找到一个好的 embedding,从而得到 *h* 个 7x4 的结果矩阵。
我们对这 *h* 个结果矩阵做什么?我们沿着列轴将它们连接起来,并在连接后的矩阵之上定义一个全连接的神经网络(“linear layer”),每个列有一个节点。linear layer 的任务是对连接后的结果矩阵中的信息进行加权:有用的信息接收更高的权重,而不太有用的信息则被加权较轻。通过这种方式,transformer 学会了“摘樱桃”。

图 1.5.19:Multi-head attention | 作者绘制
通过这个,我们已经讨论了 attention 机制的所有步骤,并且可以继续 transformer 的下一步。
### 1.6 Layer Norm
attention 机制为我们提供了“动态”的 embedding 向量——包括 token 位置和 token 之间的关系。在接下来的步骤中,我们打算计算字典中所有 token 作为 LLM 下一个输出的概率值。在我们这样做之前,我们需要一个更技术性的步骤:Layer Norm。
Layer Norm 是一种常用于深度学习模型中的技术,它提高了模型在训练过程中的*稳定性*和*收敛性*。它将指定维度上的值进行归一化——在我们的例子中,是 embedding 向量——使其具有 *mean = 0* 和 *standard deviation ≈ 1*。
让我们研究一个小例子。假设我们有两个 embedding 向量,embedding 深度为 5。这为我们提供了一个大小为 (2, 5) 的张量 `x`。为了演示目的,我们用 0 到 10 之间的随机数实例化该张量。
import torch import torch.nn as nn
Define word embedding vectors
x = torch.rand(2, 5)*10
print(“Original input tensor:”) print(x)

接下来,我们实例化一个 Layer Norm,并将输入形状定义为每个 embedding 向量的五个值。我们通过 Layer Norm 操作运行张量 `x`,输出转换后的张量 `normalized_x`,并检查新的均值和新的标准差。
Apply layer normalization
layer_norm = nn.LayerNorm(x.size()[1]) normalized_x = layer_norm(x)
print(“Normalized input tensor:”) print(normalized_x)
print(“\nMean:”) print(normalized_x.mean(dim=1))
print(“\nStandard deviation:”) print(normalized_x.std(dim=1, unbiased=False))

我们看到 `normalized_x` 的值范围约为 -1.6 到 +1.6,均值接近于 0,标准差为 1。
### 1.7 Feed Forward
feed forward 层是 transformer 中的计算引擎。它接收我们输入的归一化且上下文调整后的(“动态”)词嵌入,并负责计算下一个 token 的概率值。它具有与 embedding 向量维度一样多的输入节点,以及 LLM 词汇表中每个 token 的一个输出节点。该层是全连接的,因此每个 embedding 维度都与词汇表中的每个 token 都有关系。

图 1.7.1:Feed forward 神经网络(全连接)| 作者绘制
图 1.7.1 继续了我们的“ChatGPT 写作……”的例子。我们看到两个输入 token “ChatGPT” 和“writes”。这两个 token 都有大小为 *n* 的 embedding 向量,它们的值被馈送到 feed forward 网络的 *n* 个输入节点中。模型训练期间的任务是学习最佳权重,将 embedding 值转换为下一个 token 紧跟“ChatGPT writes…”的概率值。

图 1.7.2:Logits 的计算 | 作者绘制
给定神经网络中的一组权重,我们可以将 logits 计算为 feed forward 层的输出。值最高的 token 是搜索下一个 token 的潜在赢家。因此,理论上,我们可以在这一点停止!但实际上,ChatGPT 并不是这样工作的。如果我们在这里停止,ChatGPT 将对相同的提示给出完全相同的答案。然而,正如我们所知,如果我们输入相同的提示,ChatGPT 会提供略有不同的答案。因此,我们需要在我们的模型中加入一些随机性。为了赋予模型这种特性,transformer 架构还有两个步骤:softmax 和 multinomial。
### 1.8 Softmax
回想一下,我们希望给 LLM 一个不确定的行为。实现此目标的第一步是将上一步(feed forward)的 logits 转换为 0 到 1 之间的概率。在图 1.7.2 中,我们看到了从网络输出的所有 logits。使用 softmax,我们将每个值取 e 次方,然后除以所有值的 e 次方的总和。

公式 1.8.1:Softmax 函数
softmax 的作用是什么?
* 将每个值取 e 次方有助于我们处理负 logit 值。接近 0 的负值被转换为接近 1 的值,而接近负无穷大的负值被转换为 0。
* 将每个值除以所有值的总和,保证所有概率的总和加起来为 1。

图 1.8.1:来自 softmaxed logits 的 token 概率 | 作者绘制
总而言之,softmax 将 logits 转换为适当的概率,其值介于 0 和 1 之间,确保所有概率的总和等于 1。
### 1.9 多项式分布
我们已经计算了 LLM 词汇表中每个 token 的概率。现在,是时候从多项式分布函数中抽取一个样本了。

图 1.9.1:下一个 token 从多项式分布函数中采样 | 作者提供的图片
图 1.9.1 说明了采样的过程:
* “aardvark”(英语词典中的第一个单词)将被选择的概率为 0.1%。
* “bottom”的概率为 0.3%。
* 最有可能的是,将采样“one”,因为它有 95.1% 的可能性。
* “word”的概率为 4.6%。
* 而“zygote”(英语词典中的最后一个单词)几乎是不可能的,概率约为 0.0%
在这里,我们假设将选择“one”作为输出。但是我们已经了解到,这并不能保证,看到其他输出的微小概率会导致 LLM 预期的不确定行为!
**记住:**我们讨论的所有内容都是通过 transformer 的一个周期,并且我们已经决定了下一个要输出的 token。我们一遍又一遍地重复相同的过程,将输入连接起来,直到将 token 采样为下一个输出!

图 1.9.2:当选择一个 token 作为下一个输出时,生成过程停止。| 作者提供的图片
### 第 2 部分:代码实现
到目前为止,我们已经讨论了很多理论。在本文的第二部分中,我想通过代码演示实践中的理论,并与您一起编写一个小型 GPT,包括我们之前讨论的所有组件。
请注意,现实生活中的应用程序在“超级计算机”上运行,并使用数 TB 的数据在数千个 GPU 上训练数周。在这里,我们只有自己的 PC 或笔记本电脑可用,最多只有一个 GPU。这意味着我们需要大幅降低我们的期望。尽管如此,该示例将验证该理论,并大大加深我们对 transformer 架构的理解。
我们将编写一个 Fairy\_Tale\_GPT,它从一系列童话故事(格林兄弟和 H.C. 安徒生)中学习英语单词。这些数据可从 [Gutenberg 项目](https://www.gutenberg.org/) 免费获得。事实上,数据的内容不如免费使用的文本的可用性重要。您也可以使用其他数据。
为了使模型相对较小,我们将使用童话故事文本的字符作为我们的 token,而不是单词或单词片段。这使得模型的词汇量保持较小。我还尝试过在单词 token 上训练模型——原则上可行——但对训练数据的需求明显高于我们所拥有的。因此,学习的成功非常有限。
Fairy\_Tale\_GPT 基于 [Andrej Karpathy](https://karpathy.ai/) 的一个名为 nanoGPT 的项目。如果您想查看 Andrej 的原始模型和解释,请查看他的 [YouTube 视频](https://www.youtube.com/watch?v=kCc8FmEb1nY)。
#### 2.1 数据准备
首先,我们需要数据。您可以从我的 [Dropbox](https://www.dropbox.com/scl/fi/p28bf4fjuh0ofandwfgd8/Fairy_Tales.txt?rlkey=khbufbmoz7fl7g8o62scrze75&st=co6w050n&dl=0) 下载文件“Fairy\_Tales.txt”。在这里,我假设该文件与包含代码的 Jupyter Notebook 存储在同一目录中。
```python
### 我们将在本笔记本中使用的库
import torch
import torch.nn as nn
from torch.nn import functional as F
import matplotlib.pyplot as plt
from IPython.display import clear_output
torch.manual_seed(1337)
### 我们将文本文件的内容加载到变量“text”中。
with open('Fairy_Tales.txt', 'r', encoding='utf-8') as f:
text = f.read()
请熟悉这些数据。由于它存储在一个简单的 .txt 文件中,您甚至可以打开该文件并阅读童话故事。 让我们检查一下文件的大小(以字符为单位),并打印前 300 个字符。
### 让我们看看我们在“text”中有什么。有多少个字符?
print("数据集的字符长度:", format(len(text),','))
print()
### 为了了解文件内容,让我们打印前 300 个字符。
print(text[:300])
在“text”中,我们有一个包含 807,000 多个字符的字符串。这些童话故事一个接一个地存储,第一个童话故事是 THE GOLDEN BIRD。
2.2 Tokenization(分词)
如第 1.2 章所讨论的,分词是将数据拆分为单词、单词片段或字符,查找唯一的 token,并为它们分配唯一的数字的过程。分词背后的想法是限制 LLM 词汇量的大小,并准备模型来处理这些 token。 我们的分词过程的第一步是找到童话故事中唯一的字符。
### 查找文本中所有唯一字符的列表。
### 数据类型“set”消除了所有重复项。
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(''.join(chars))
print('\n词汇表的大小:', vocab_size)
我们有 80 个唯一的字符,包括标点符号和其他特殊字符。这些是 LLM 需要学习的基本单元。
接下来,我们需要一个编码和解码函数来将 token(在本例中为字符,在其他应用程序中为单词或单词片段)转换为 token 数字。作为第一步,我们定义两个字典:一个用于 token → 数字,一个用于数字 → token。
### 字典:字符 (c) 到数字 (i)
ctoi = {c:i for i,c in enumerate(chars)}
### 字典:数字 (i) 到字符 (c)
itoc = {i:c for i,c in enumerate(chars)}
在继续进行编码和解码函数之前,我们应该考虑如何向 LLM 呈现数据。知道我们的数据量有限,我们应该充分利用它。
稍后,我们将把数据分成训练和验证数据集。例如,接受前 90% 作为训练数据,其余 10% 作为验证数据,这意味着使用与训练不同的童话故事进行验证。这听起来并不理想。因此,我们将完整的数据集分成段落。这允许我们在将数据分成训练和验证数据集时混合这些段落。
split_text_into_paragraphs()
函数正是这样做的。
### 将全文分成至少 50 个单词的段落
### 返回一个列表的列表
def split_text_into_paragraphs(text, min_words=50):
lines = text.split('\n')
current_paragraph = ""
paragraphs = []
for line in lines:
import torch
import random
### 将文本分割成段落
def split_text_into_paragraphs(text, min_words=5):
"""
根据换行符将文本分割成段落。
"""
paragraphs = []
current_paragraph = ""
for line in text.split("\n"):
# 将行添加到当前段落缓冲区
current_paragraph += line + "\n"
# 如果当前段落至少有 'min_words' 个单词,则存储它并重置
if len(current_paragraph.split()) >= min_words:
paragraphs.append([current_paragraph])
current_paragraph = ""
# 添加剩余内容
if current_paragraph:
paragraphs.append([current_paragraph])
return paragraphs
该函数接受一个 text
变量和一个最小单词数 min_words
。它通过换行符分隔符 \n
分割 text
,并处理所有文本片段,将每个片段连接到缓冲区变量 current_paragraph
。如果连接后的文本至少有 min_words
那么长,则将其视为一个段落并添加为一个列表。该函数的最终输出是一个列表的列表,其中包含最小长度的文本段落。
现在我们可以继续编码和解码。
encode()
函数接受一个列表的列表,其中包含字符串作为 paragraphs
变量,以及字典 ctoi
(读作“c to i”)。它枚举 paragraphs
,获取文本,并根据 ctoi
字典对其进行编码。最后,将编码添加到列表中并输出。该函数返回一个整数列表的列表。
### 将字符串编码为整数列表
def encode(paragraphs, ctoi):
"""
将一个包含文本的列表的列表转换为
一个包含令牌编号的列表的列表。
"""
encoded_paragraphs = []
for paragraph in paragraphs:
# 从内部列表中获取文本
text = paragraph[0]
# 将令牌转换为整数
encoded = [ctoi[c] for c in text if c in ctoi]
# 将编码后的段落添加到列表中
encoded_paragraphs.append(encoded)
return encoded_paragraphs
### 将数字列表 (li) 解码为字符串 (s)
def decode(li, itoc):
"将整数列表转换回字符串。"
# 将整数转换为令牌
tokens = [itoc[i] for i in li]
# 将令牌连接成一个字符串
decoded_text = "".join(tokens)
return decoded_text
decode()
函数更简单。它接受一个整数列表 li
和 itoc
(“i to c”)字典,并将整数令牌编号解码为字符串。最后,将字符串连接到 decoded_text
并作为输出返回。
如果您正在阅读代码,请测试这些函数。您将看到它们完成了任务。
下一步是编码整个数据集。这将把列表的列表的字符串转换为列表的列表的整数。
### 编码完整的数据集
data = encode(split_text_into_paragraphs(text), ctoi)
现在,我们对段落(整数令牌编号)进行洗牌,并将它们分成训练数据集 train_data_list
和验证数据集 val_data_list
。
import random
### 洗牌数据
random.shuffle(data)
### 将数据分成训练集 (90%) 和验证集 (10%)
n = int(0.9*len(data))
train_data_list = data[:n]
val_data_list = data[n:]
到目前为止,这两个数据集都是整数的 Python 列表。但是对于 LLM,我们需要 PyTorch 张量。由于我们不会进一步修改验证数据集,因此可以立即将其转换为 PyTorch 张量。为此,我们将整数列表的列表展平为一个整数列表 flat_list
,并将数据加载到 PyTorch 张量中。
### 将列表的列表展平为单个列表
flat_list = [token for paragraph in val_data_list for token in paragraph]
### 将验证数据加载到 PyTorch 张量中
val_data = torch.tensor(flat_list, dtype=torch.long)
我们尚未对训练数据执行等效操作。原因是,我更喜欢在训练循环中对段落进行洗牌。这使数据具有更多变化和数据增强。
2.3 数据馈送器函数
数据馈送器为模型提供训练数据和相应的标签,或者在验证的情况下提供验证数据。
首先,我们定义三个重要的模型参数。
batch_size
确定在一个训练循环中并行处理多少个数据块。我们将其设置为 64。block_size
定义模型在计算下一个令牌时看到的上下文的长度。我们将其设置为 128 个令牌。device
是 ‘cuda’ 或 ‘cpu’,并确定模型是在 CPU 还是计算机的 GPU 上处理。
batch_size = 64 # 我们并行处理的独立序列的数量
block_size = 128 # 令牌序列的长度作为上下文
device = 'cuda' if torch.cuda.is_available() else 'cpu' # 如果可用,则使用 GPU 而不是 CPU
print(device)
### 函数,为模型提供一批训练或验证数据
### 以及相应的标签(正确的下一个令牌)
def get_batch(ValTrain):
# 定义数据源
if ValTrain == "val":
data = val_data
else:
# 展平和洗牌训练数据(数据增强)
shuffled_data = [
token for paragraph in random.sample(train_data_list, len(train_data_list))
for token in paragraph
]
data = torch.tensor(shuffled_data, dtype=torch.long)
# 在数据上生成滑动窗口
sliding_windows = [
data[i:i + block_size + 1]
for i in range(0, len(data) - block_size, block_size // 2) # 步长 = block_size // 2
]
# 随机选择 batch_size 多个窗口
selected_windows = random.sample(sliding_windows, batch_size)
# 将每个窗口分成输入 (x) 和目标 (y)
x = torch.stack([window[:-1] for window in selected_windows]) # 除最后一个令牌之外的所有令牌
y = torch.stack([window[1:] for window in selected_windows]) # 除第一个令牌之外的所有令牌
import torch
import torch.nn as nn
## 假设 'device', 'block_size' 和其他必要的变量在其他地方定义
def get_batch(ValTrain):
"""
生成一个训练或验证批次的数据。
参数:
ValTrain (str): 'val' 表示验证数据,否则为训练数据。
返回:
tuple: 包含输入数据 (x) 和标签 (y) 的元组。
"""
if ValTrain == 'val':
data = val_data
else:
# 打乱并展平整数标记数字的列表的列表
shuffled_data = []
for sublist in data_list:
shuffled_data.extend(sublist)
# 将数据加载到 PyTorch 张量中
data = torch.tensor(shuffled_data, dtype=torch.long)
# 创建滑动窗口
sliding_windows = []
for i in range(0, len(data) - block_size, block_size // 2):
sliding_windows.append(data[i:i + block_size])
# 随机选择样本
indices = torch.randint(0, len(sliding_windows), (batch_size,))
samples = [sliding_windows[i] for i in indices]
# 堆叠成张量
x = torch.stack(samples)
y = torch.stack([sample[1:] for sample in samples]) # 移动一个标记
# 将张量移动到设备 (CPU/GPU)
x, y = x.to(device), y.to(device)
return x, y
### 测试 feeder 函数
input_data, labels = get_batch('train')
print("input_data 的形状:", input_data.shape, "\n")
print("input_data:\n", input_data, "\n")
print("labels:\n", labels, "\n")
### 将输入数据的第一行解码回英语
print(decode(input_data.cpu().numpy()[0],itoc))
2.4 注意力头
接下来,我们开始编码注意力机制,从单个注意力头和对应的矩阵运算开始,如第 1.5.2 章中所讨论的。 注意力头是多头注意力的一部分,而多头注意力本身又是注意力块的一部分。图 2.4.1 显示了注意力头在多头注意力中的位置。在接下来的步骤中,我们将经常参考该图,所以请记住它。
图 2.4.1:单个注意力头作为多头注意力的一部分 | 作者提供的图像
我们从两个额外的超参数开始编码:
n_embd
定义了所有标记的嵌入深度(第 1.3 章)。我们将其设置为 192。dropout
是我们在训练期间随机设置为零的参数的百分比。这是一种防止过拟合的措施。由于模型倾向于记住数据而不是概括模式(可能是由于数据集的大小不足),我们将dropout
设置为相对较高的值 0.4。
注意力头被实现为其自己的名为 Head()
的类,具有 __init__()
和 forward()
方法。在 __init__()
中,我们定义了查询 Q、键 K 和值 V 矩阵,每个矩阵都是大小为 (n_embd
, head_size
) 的 nn.Linear()
层。nn.Linear()
层管理权重矩阵并处理 Q、K 和 V 与输入数据 x
的矩阵乘法。重要的是要理解,变量 self.query
、self.key
和 self.value
代表矩阵乘积 x @ Q、x @ K 和 x @ V,而不仅仅是权重矩阵(图 1.5.14 和 1.5.15)。
此外,在 __init__()
中,我们定义了一个 register_buffer
。这是一种将张量保存到模块的状态字典中但将其排除在训练之外的方法。这意味着值不会通过反向传播更新。在这里,我们使用它来存储大小为 (block_size
, block_size
) 且值设置为 1 的下三角矩阵 tril
。我们将在后面的计算中使用它。
### 更多超参数
n_embd = 192 # 每个标记的嵌入深度
dropout = 0.4 # 我们在训练期间设置为 0 的权重百分比,用于正则化
### 单个注意力头的类
class Head(nn.Module):
""" 自注意力的单个头 """
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False) # (C,H)
self.query = nn.Linear(n_embd, head_size, bias=False) # (C,H)
self.value = nn.Linear(n_embd, head_size, bias=False) # (C,H)
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size))) # 下三角矩阵
self.dropout = nn.Dropout(dropout) # 在每个训练循环中忽略一部分神经元 --> 防止过拟合
def forward(self, x):
B,T,C = x.shape # C=n_embd
k = self.key(x) # x @ key (B,T,C) @ (C,H) --> (B,T,H)
q = self.query(x) # x @ query (B,T,C) @ (C,H) --> (B,T,H)
2.4 注意力头
在 Head()
类的 forward()
方法中,我们计算注意力分数并执行值的加权聚合。
## compute attention scores ("affinities")
wei = q @ k.transpose(-2,-1) * C**-0.5 # (B,T,H) @ (B,H,T) -> (B,T,T)
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')) # (B,T,T) # Fill with '-inf' where template has 0
wei = F.softmax(wei, dim=-1) # (B,T,T)
wei = self.dropout(wei) # (B,T,T)
## Perform the weighted aggregation of the values
v = self.value(x) # x @ value (B,T,C) @ (C,H) --> (B,T,H)
out = wei @ v # (B,T,T) @ (B,T,H) --> (B,T,H)
return out
在 forward()
方法中,我们计算矩阵乘积 x @ K 和 x @ Q,并将它们保存在变量 k
和 q
中。 两个结果的维度都是 batch size (B) x number of tokens (block size, T) x head size H,简而言之 (B, T, H)。 请参考章节 1.5.2 中的图 1.5.14 和 1.5.15。
接下来,我们使用 q @ k_transpose / sqrt(C) 计算缩放后的词亲和度,并将它们存储在 wei
中(图 1.5.16)。 这为我们提供了一个大小为 (B, T, T) 的张量。 现在,我们使用下三角矩阵 tril
(大小 (T, T))作为模板,通过 torch.masked_fill() 将 tril
值为 0(对角线上方)的 wei
的每个值设置为 -inf。
我们为什么要这样做? 在下一步中,我们将 softmax 应用于 wei
,softmaxing -inf 结果为 0。 这有效地消除了未从 wei
矩阵处理的 token 组合的词亲和度。 通过此步骤,我们排除了模型在生产中无法拥有的训练知识。
使用 self.dropout(wei)
,我们随机将指定比例的权重设置为 0,作为防止过拟合的措施。
最后,我们计算 x @ V 作为 v
,并使用 out = wei @ v
计算上下文调整后的词嵌入。 这是该函数的返回值,大小为 (B, T, H)。
2.5 多头注意力
如 1.5.3 节所述,多头注意力并行使用 n_head 个注意力头。 此外,我们应用一个线性层来更高地加权更有益的注意力头响应,而更低地加权不太有益的响应。
图 2.5.1:多头注意力的处理步骤 | 作者提供的图像
图 2.5.1 按处理顺序显示了多头注意力的所有处理步骤。
### Class that bundles 3 attention heads
class MultiHeadAttention(nn.Module):
""" Three heads of self-attention in parallel """
def __init__(self, num_heads, head_size):
super().__init__()
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)]) # Just a container
self.weighting = nn.Linear(n_embd, n_embd) # Linear layer to weight the attention heads
self.dropout = nn.Dropout(dropout) # Prevent overfitting
def forward(self, x):
out = torch.cat([h(x) for h in self.heads], dim=-1) # Feed parallel attention heads and concatenate results
out = self.dropout(self.weighting(out)) # Weighting the attention head results and dropout
return out
我们将所有多头活动捆绑在 MultiHeadAttention()
类中。
在 __init__()
方法中,我们定义了一个 num_heads
注意力头的列表,并将其保存在变量 self.heads
中。 用于根据其有用性对头的响应进行加权的线性层存储在 self.weighting
中。 最后,我们在 self.dropout
中定义了另一个 dropout 层。
在 forward()
方法中,我们采用输入上下文的归一化和位置丰富的词嵌入作为 x
,并将它们独立地运行通过三个注意力头。 然后,我们沿着列轴连接响应,从而产生一个 3 x head_size
列的张量(这等效于 n_embd
)。 该张量被缓存在 out
中,并被馈送到线性层 self.weighting()
中,该线性层调节响应的权重。 最后,加权的头响应通过 dropout,其中 40% 的张量元素被设置为零以防止过拟合。
2.6 注意力块的前馈
注意力块中的前馈层具有一般的计算目的,由四个步骤组成。 因此,我们将其定义为其自己的类 FeedForward()
。
图 2.6.1:注意力块中的前馈 | 作者提供的图像
它将归一化的多头响应作为输入 x
,并通过两个全连接神经网络层:第一个大小为 (n_embd
, 4 x n_embd
),第二个大小为 (4 x n_embd
, n_embd
)。 这意味着这些层被扩展了 4 倍,然后压缩回原始大小——只是为了添加额外的可学习权重。 在这些层之间,我们包含一个 ReLU 激活函数,以将非线性行为引入网络。
### A linear layer for general calculation purpose
class FeedForward(nn.Module):
""" Simple linear layer followed by a non-linearity """
def __init__(self, n_embd):
super().__init__()
self.net = nn.Sequential( # Sequence of steps
nn.Linear(n_embd, 4 * n_embd),
nn.ReLU(),
nn.Linear(4 * n_embd, n_embd),
nn.Dropout(dropout))
def forward(self, x):
return self.net(x)
在最后一步中,我们再次应用 dropout 作为防止过拟合的措施。
2.7 注意力块
现在是时候从先前定义的组件中构建注意力块了,如图 2.7.1 所示。
图 2.7.1:注意力块 | 作者提供的图片
同样,我们定义了一个名为 Block()
的新类。在其 __init__()
方法中,我们计算了 Q、K 和 V 矩阵的自由维度——head_size
——作为嵌入深度 n_embd
和注意力头数 n_head
的商。然后,我们在 self.sa
中实例化 MultiHeadAttention()
类,在 self.ffwd
中实例化 FeedForward()
类。此外,我们在变量 self.ln1
和 self.ln2
中定义了两个归一化层。
### Only one pass-through. Loop is specified in the Transformer class
class Block(nn.Module):
""" Attention block """
def __init__(self, n_embd, n_head):
super().__init__()
head_size = n_embd // n_head # Free dimension of key, query and value matrices
self.sa = MultiHeadAttention(n_head, head_size)
self.ffwd = FeedForward(n_embd)
self.ln1 = nn.LayerNorm(n_embd)
self.ln2 = nn.LayerNorm(n_embd)
def forward(self, x):
x = x + self.sa(self.ln1(x)) # Residual/skip connection
x = x + self.ffwd(self.ln2(x)) # Residual/skip connection
return x
在类的 forward()
方法中,我们接收作为 LLM 输入的经过位置编码的词嵌入作为 x
。我们使用 self.ln1()
对 x
进行归一化,并将结果传递给多头注意力 self.sa()
。然后,我们添加一个跳跃连接,这意味着我们将 x
的原始值——没有归一化和自注意力——添加到结果中。这稳定了训练并最大限度地减少了梯度消失的问题。
接下来,我们将更新后的 x
传递给第二个归一化层 self.ln2
,然后传递给前馈类 self.ffwd
。我们添加了另一个跳跃连接——这次变量 x
包含层归一化 2 之前的结果,而不是 forward()
的原始输入张量——并输出结果。
2.8 Transformer 类
在编写 Transformer() 类之前,让我们回顾一下数据在训练和生产期间如何流经架构。
图 2.8.1 显示了 训练 期间的一个循环。我们将输入数据馈送到模型中,并为其分配预测输入数据之后的下一个标记的任务。真实的下一个标记充当我们的标签数据。我们将输入数据运行到模型中,直到最终的 前馈 层,这为我们提供了表示标记概率的 logits。 接下来,我们使用 logits 和真实标签来计算交叉熵损失。损失越高,更新模型参数的需求就越大。使用损失函数,我们通过模型反向传播并更新权重。
图 2.8.1:带有反向传播的训练循环 | 作者提供的图片
在 生产 期间,我们没有任何标签。相反,我们对模型作为对用户交互的响应的输出感兴趣。我们将输入数据馈送到模型中,并将其传递给模型——这次与训练相比,增加了两个额外的步骤。在 前馈 层之后,logits 被 softmax 处理,并用于 多项式 分布函数中以采样下一个标记(第 1.8 章和第 1.9 章)。
图 2.8.2:生成循环 | 作者提供的图片
根据训练和生产期间的不同方法,Transformer()
类有三种方法:__init__()
、forward()
和 generate()
。
同样,我们从两个额外的超参数开始:
n_head
定义了 多头注意力 中并行注意力头的数量。如前所述,此变量设置为 3。n_layer
指定了顺序注意力块的数量(例如,图 2.8.2)。在我们的模型中,此值设置为 4。
在 Transformer()
类的 __init__()
方法中,我们定义了词嵌入和位置编码(第 1.3 章和第 1.4 章)。对于这两者,我们都使用了 nn.Embedding
方法,该方法创建指定大小的查找表,并将输入值唯一地分配给该表的行。
- 对于词嵌入,查找表的大小为
vocab_size
xn_embd
,这意味着词汇表中的每个标记都对应于一个具有词嵌入值的特定行。 - 对于位置编码,查找表的大小为
block_size
xn_embd
,其中表的每一行代表标记在输入上下文中的位置。由于我们在上下文中考虑了block_size
许多标记,我们需要区分许多位置。列数再次等于嵌入深度。
在这两种情况下——词嵌入和位置编码——nn.Embedding
都使用随机值初始化表,这些值在 LLM 的整个生命周期中保持不变。
__init__()
中的下一步是在变量 self.blocks
中定义一系列 n_layer
个 注意力块。由于 n_layer
设置为 4,因此数据将依次通过 4 个 注意力块,然后继续到最终的 层归一化(图 2.8.1 和 2.8.2)。
接下来,我们将最终的 层归一化 和 前馈 层定义为变量 self.final_ln
和 self.final_ff
中的 nn.linear
层。前馈 层将上下文调整后的词嵌入转换为词汇表中每个标记的概率,并且大小相等(第 1.7 章和图 1.7.1)。
### More hyperparameters
n_head = 3 # Number of attention heads in multi-head attention
n_layer = 4 # Number of attention blocks
### Main class embracing all modules
class Transformer(nn.Module):
# When we instantiate from the Transformer class
def __init__(self):
super().__init__()
self.token_embedding_table = nn.Embedding(vocab_size, n_embd) # Word/token embedding
self.position_embedding_table = nn.Embedding(block_size, n_embd) # Positional embedding
self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)]) # Stack of attention blocks
self.final_ln = nn.LayerNorm(n_embd) # Final layer norm
self.final_ff = nn.Linear(n_embd, vocab_size) # Final linear layer
### 当我们通过 Transformer 类的实例传递数据时
def forward(self, input, targets=None): # input 和 targets 都是整数的 (B,T) 维张量
B, T = input.shape # 输入数据的维度:批次 x 词元
tok_emb = self.token_embedding_table(input) # (B,T,C)
pos_emb = self.position_embedding_table(torch.arange(T, device=device)) # (T,C)
x = tok_emb + pos_emb # (B,T,C),Python 自动将维度 B 添加到 pos_emb
x = self.blocks(x) # (B,T,C)
x = self.final_ln(x) # (B,T,C)
logits = self.final_ff(x) # (B,T,vocab_size)
# 仅当定义了 targets 时 --> 损失计算
if targets is None:
loss = None
else:
B, T, C = logits.shape # 获取输出的维度
logits = logits.view(B*T, C) # 转换为两维,用于 cross_entropy 函数
targets = targets.view(B*T) # 转换为一维,用于 cross_entropy 函数
loss = F.cross_entropy(logits, targets) # 计算损失
return logits, loss
### 当我们生成新文本(生产)时
def generate(self, idx, max_new_tokens):
# idx 是 (B, T) 索引张量
for _ in range(max_new_tokens): # 连接 max_new_tokens 个输出
# 将 idx 裁剪到最后 block_size 个词元
idx_cond = idx[:, -block_size:]
# 获取预测结果
logits, loss = self.forward(idx_cond)
# 仅关注最后一个词元
logits = logits[:, -1, :] # 变为 (B, C)
# 应用 softmax 得到概率
probs = F.softmax(logits, dim=-1) # (B, C)
# 从分布中采样
idx_next = torch.multinomial(probs, num_samples=1) # (B, 1)
# 将采样索引添加到运行序列中
idx = torch.cat((idx, idx_next), dim=1) # (B, T+1)
return idx
forward()
方法用于 LLM 的训练,也在 generate()
方法中被调用。它接收 input
数据,以及可选的标签数据,存储在变量 targets
中。两者都是大小为 (B, T) 的张量,这意味着 batch_size
个样本中的每一个样本对应一行,block_size
个词元中的每一个词元对应一列。
我们将输入数据传递给 self.token_embedding_table()
,它将查找表中对应的词嵌入值添加到所有批次中每个词元的一个单独维度中。这会将张量的大小从 (B, T) 扩展到 (B, T, C),其中 C 代表嵌入深度。
类似地,self.position_embedding_table()
将嵌入值添加到输入上下文中 T 个位置 0、1、…、T-1 中的每一个位置,从而得到一个大小为 (T, C) 的张量。接下来,我们将词元嵌入 tok_emb
和位置嵌入 pos_emb
相加,形成变量 x
。Python 会自动将维度 B 扩展到 pos_emb
张量,并将原始张量的内容重复 B 次。因此,x
的大小为 (B, T, C)。
根据图 2.8.1,下一步是注意力块。我们通过 self.blocks()
传递 x
,然后通过最终的层归一化 self.final_ln()
,最后通过最终的前馈层 self.final_ff()
,这为我们提供了大小为 (B, T, vocab_size) 的 logits 张量。如前所述,线性层将词嵌入转换为词汇表中词元的概率值。
如果指定了 targets
—— 在训练期间 —— 下一步是将 logits
从 3 维张量转换为 2 维。使用 logits.view(B*T, C)
,将 B 个批次的数据堆叠在一起(注意:C 现在代表词汇量)。我们对 targets
应用相同的转换,将其从 2D 转换为 1D。两种转换都需要匹配 F.cross_entropy()
的输入格式,后者计算 loss
值。与往常一样,训练过程的目标是最小化 loss
。这相当于为下一个词元获得最佳预测质量,以跟随存储在 targets
中的输入上下文。
在生产过程中,我们使用 generate()
方法。它接收一个上下文索引张量 idx
和要生成的词元数量 max_new_tokens
。张量 idx
的大小为 (B, T)。这意味着我们可以并行生成 B 个批次的输出,但如果 idx
只有一行,B 也可以为 1。idx
中的索引表示输入上下文的词元编号,根据我们在分词(第 2.2 章)中指定的字典 itoc 和 ctoi。参数 max_new_tokens
是一个“人为”参数——你在现实生活中的 LLM 中找不到它。我们需要它,因为我们的训练数据中没有停止词元,因此我们需要一种“硬”的方式来停止文本生成过程。
在 generate()
中,我们有一个循环,重复 max_new_tokens
次。在内部,我们对 idx
进行条件处理,以防它拥有的词元多于我们指定的 block_size
。在这种情况下,我们取最后 block_size
个词元。接下来,我们使用输入 idx_cond
调用 self.forward()
。该方法返回 logits
和 loss
(我们不再使用 loss
)。logits
张量的大小为 (B, T, C)(C 代表词汇量),包含输入上下文中每个词元的预测概率值——即使对于那些我们知道下一个词元的词元,因为我们可以在用户输入中读取它。这就是我们使用 logits[:, -1, :]
将 logits
限制为最后一个词元的原因。此步骤将张量的维度减少到 (B, C)。
根据图 2.8.2,下一步是Softmax 和 Mutinomial。相应地,我们对 C 维度上的 logits
进行 softmax 处理。现在,我们有了真实的概率,在词汇表中加起来为 1,并将它们保存在 probs
中。接下来,我们将 probs
输入到 torch.multinomial()
中,它根据给定的概率对一个索引进行采样。这个新索引是我们所做一切的本质,因为它代表下一个词元!我们将其连接到 idx
中给定的上下文中,并重复该过程 max_new_tokens
次。
2.9 实例化 Transformer
我们已经定义了 Fairy_Tale_GPT 的所有类。因此,我们可以在开始模型训练之前对其进行实例化并进行第一次尝试。
实例化非常简单。我们调用 Transformer()
类并将其推送到 device
。为了我们的信息,我们使用生成器推导式枚举 model.parameters()
,并将所有元素相加。这为我们提供了 LLM 中的参数数量。
### 从 Transformer 类实例化一个对象
model = Transformer().to(device) # 'model' 位于设备上,在我的例子中是 GPU
### 打印模型的参数数量
print(format(sum(p.numel() for p in model.parameters()),","), 'parameters')
我们看到 Fairy_Tale_GPT 的参数略多于 180 万。
现在,我很好奇看看 LLM 的第一个输出——知道模型还没有经过任何训练。generate 方法需要一个起始索引张量作为上下文。因此,我们定义一个仅包含一个 0(随机选择)的二维张量,并将其存储在 context
中。接下来,我们调用实例的 generate 方法,使用 model.generate()
,为其提供 context
并将 max_new_tokens
参数指定为 200。使用 [0]
我们解包第一个批次(技术上是必需的,尽管我们只有一个批次),并将给定的 PyTorch 张量转换为 Python 列表,使用 .tolist()
。最后,我们根据字典 itoc
(第 2.2 章)将整数 token 列表 decode()
成字符,将结果保存在 untrained_results
中并将其打印出来。
### 从模型生成
context = torch.zeros((1, 1), dtype=torch.long, device=device)
untrained_results = decode(model.generate(context, max_new_tokens=200)[0].tolist(),itoc)
print(untrained_results)
好吧,输出看起来不像童话故事中的英语单词,对吧?它更像来自我们词汇表的纯随机字符的序列。让我们检查一下训练是否可以改善结果。
2.10 模型训练
在实际为 LLM 编写训练循环之前,我们定义更多超参数和一个用于计算训练期间损失的辅助函数。
eval_iters
定义了在损失计算中平均多少次训练迭代。我们将其设置为 10。learning_rate
是用于更新模型参数的步长,设置为 1e-3。由于我们使用学习率调度器,learning rate
只是起始值。实际的学习率在训练期间根据余弦函数降低,以提高模型稳定性并防止过拟合。max_iters
定义了训练循环的次数。它是 10,000 次迭代。eval_interval
定义了训练代码在多少次迭代后打印实际损失值、输出相应的图和文本样本。我们每 200 次迭代接收这些更新。context_tensor
是我们在第 2.9 章中已经使用的索引矩阵。它是在训练期间生成样本文本所必需的。
### 余弦退火学习率调度器
from torch.optim.lr_scheduler import CosineAnnealingLR
### 更多超参数
eval_iters = 10 # 我们在损失计算中平均多少次迭代
learning_rate = 1e-3 # 学习的起始步长
max_iters = 10000 # 训练循环的次数
eval_interval = 200 # 评估模型性能的频率
context_tensor = torch.zeros((1, 1), dtype=torch.long, device=device)
### 函数计算损失并在 eval_interval 值上平均结果
@torch.no_grad() # 对于此函数,不计算任何梯度
def estimate_loss():
out = {} # 结果的空字典
model.eval() # 将模型设置为评估模式
# 计算训练和验证数据的损失
for split in ['train', 'val']:
losses = torch.zeros(eval_iters) # 设置为 0 开始
for k in range(eval_iters): # 10 个循环
X, Y = get_batch(split) # 获取训练数据
logits, loss = model(X, Y) # 调用模型并获取 logits 和损失
losses[k] = loss.item()
out[split] = losses.mean() # 在键 'train' 或 'val' 下保存平均值
model.train() # 将模型设置为训练模式
return out
estimate_loss()
函数计算模型训练期间的验证和训练损失。装饰器 @torch.no_grad()
关闭了 PyTorch 中装饰函数的梯度跟踪。
在 estimate_loss()
内部有两个嵌套循环。外循环遍历两个值“train”和“val”,它们分别指定训练和验证。内循环遍历 range(eval_iters)
(这意味着 10 次迭代),并为“train”或“val”调用 get_batch()
函数。返回的数据被馈送到 model()
,它返回 logits
和 loss
。在这里,我们对 loss
感兴趣,并将其保存到张量 losses
。在内循环之外,我们计算 losses
中十个值的平均值,并将其保存在 out[‘train’]
或 out[‘val’]
中。最后,out
是该函数的返回值。
对于模型训练,我们定义了一个 optimizer
和一个学习率 scheduler
。optimizer
根据反向传播的梯度和学习率更新模型参数。我们选择 AdamW
优化器,这非常常见。AdamW
优化器允许指定 weight_decay
,它将所有权重的总和(乘以指定因子 0.03)添加到损失函数中。这促使模型更喜欢较小的权重——这又是防止过拟合的一种措施。
如超参数定义中所述,学习率 scheduler
在 max_iters
(10,000) 次训练迭代过程中将学习率从 learning_rate
(1e-3) 降低到 eta_min
(1e-5)。
### 创建一个 PyTorch 优化器
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=0.03)
### 余弦退火学习率调度器
scheduler = CosineAnnealingLR(optimizer, T_max=max_iters, eta_min=1e-5) # 最小 LR 为 1e-5
在训练循环内部,我们使用三个列表来收集损失。所有这三个列表对于训练本身都不是必需的,但用于每 eval_interval
次迭代通知训练进度。在 loss_lst_train
中,我们收集训练损失,在 loss_lst_val
中,收集验证损失,在 loss_lst_x
中,我们存储用于绘制图的 x 轴的实际迭代次数。
训练循环从 0 运行到 max_iters
(-1),并将实际循环计数器存储在 iter
中。我们为训练数据调用 get_batch()
函数,并将返回的输入数据存储在 xb
中,并将相应的标签存储在 yb
中。两者都被馈送到模型,模型返回 logits
和 loss
。接下来,我们使用 optimizer.zero_grad(set_to_none=True)
将所有梯度设置为 None,并使用 loss.backward()
计算新的梯度。我们使用 optimizer.step()
更新模型参数,并使用 scheduler.step()
根据余弦函数逐步降低学习率。
### 损失的空列表
loss_lst_train = []
loss_lst_val = []
loss_lst_x = []
### 在 max_iter 循环中训练模型
for iter in range(max_iters):
## 获取一批训练数据
xb, yb = get_batch('train')
## 运行 transformer 模型
logits, loss = model(xb, yb)
## 将梯度归零
optimizer.zero_grad(set_to_none=True)
2.10 训练循环
## 训练循环
for iter in range(max_iters):
# 每隔一段时间评估训练集和验证集上的损失
if iter % eval_interval == 0 or iter == max_iters - 1:
losses = estimate_loss()
print(f"step {iter}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}")
# 采样一批数据
xb, yb = get_batch('train')
# 评估损失
logits, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
## 计算梯度
loss.backward()
## 通过反向传播优化参数
optimizer.step()
## 使用调度器更新学习率
scheduler.step()
## 评估并打印损失
if iter % eval_interval == 0 or iter == max_iters - 1:
## 计算损失
losses = estimate_loss() # 调用评估函数
loss_lst_train.append(losses['train'].item()) # 追加到训练列表
loss_lst_val.append(losses['val'].item()) # 追加到验证列表
loss_lst_x = list(range(len(loss_lst_train))) # 准备用于绘图的 x 值
## 绘图
clear_output(wait=True) # 清除 Jupyter 中的输出
print(f"Step {iter}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}")
plt.figure(figsize=(5,3))
plt.plot(loss_lst_x,loss_lst_train,label='train')
plt.plot(loss_lst_x,loss_lst_val,label='val')
plt.xlabel('steps (x' + str(eval_interval) + ')')
plt.ylabel('losses')
plt.title('Training and validation loss')
plt.legend()
plt.show()
## 进行测试生成以观察质量
print('Test word generation:')
print(decode(model.generate(context_tensor, max_new_tokens=200)[0].tolist(),itoc))
if 条件内的所有操作都是可选的,用于告知训练过程。在第一步中,我们调用 estimate_loss()
函数,并将损失收集到指定的列表中。在代码的中间部分,我们使用 clear_output()
清除 Jupyter 笔记本中的输出,并绘制训练和验证损失作为迭代次数的 matplotlib 折线图。在较低的部分,我们以与第 2.9 章相同的方式生成测试文本。
代码在 10,000 次训练迭代结束时的输出如下所示:
在模型训练之后,我们应该保存参数。否则,在从 PC 的内存中删除模型后,它们将丢失,并且我们必须重新开始训练。
torch.save(model.state_dict(), 'Name_of_your_choice.pth')
model.state_dict()
保存模型的参数,但不保存模型架构。我们需要先加载它,然后才能加载参数。
2.11 生成新 Tokens
在开始 token 生成之前,让我们加载模型参数。在这里,我们假设 model
本身已经加载。
### 加载已保存的模型状态
state_dict = torch.load('Name_of_your_choice.pth')
### 将参数加载到 Transformer 模型中
model.load_state_dict(state_dict)
现在,让我们比较一下 LLM 在训练前后的输出。
### 打印训练前的输出
print('Model output before training:')
print(untrained_results)
### 打印训练后的输出
context = torch.zeros((1, 1), dtype=torch.long, device=device)
trained_results = decode(model.generate(context, max_new_tokens=500)[0].tolist(),itoc)
print('\nModel output after training:')
print(trained_results)
对于未训练的 LLM 的输出,我们使用变量 untrained_results
,我们在训练之前填充了它。文本 trained_results
是通过 model.generate()
方法“立即”生成的。请查看第 2.9 章以了解该代码行的解释。
虽然在 untrained_results
中,字符序列是纯粹随机的,但我们可以在 trained_results
中清楚地识别出英语单词结构,尽管有些单词是幻想的,并且单词顺序通常不正确。请记住,我们只教模型字符序列,而不是单词序列。显然,我们不会接受来自真实世界 LLM 的类似答案。尽管如此,我希望您接受结果作为我们玩具项目中实现的 Transformer 架构原则上有效的普遍证明。
我们还可以提供比仅包含一个 0 的 2d 张量更有意义的上下文。为此,我们首先对上下文句子进行编码,将其转换为 PyTorch 张量,并将上下文张量输入到 model.generate()
方法中。
### 编码一个起始句子
text = [["The king was very sad and tired"]]
context = encode(text, ctoi)
### 将上下文转换为 PyTorch 张量
context_tensor = torch.tensor([context], dtype=torch.long, device=device)
### 生成响应于上下文句子的新文本
print(decode(model.generate(context_tensor[:,0,:], max_new_tokens=500)[0].tolist(),itoc))
结论
Transformer 架构最初在 “Attention Is All You Need” 中提出,引发了大型语言模型和其他生成式 AI 工具的快速发展,并具有令人着迷的性能。这种演变远未结束,我们看到几乎每个月都会出现新的和改进的解决方案。
在本文的第 1 部分中,我们深入研究了 Transformer 模型背后的逻辑和数学。Transformer 的核心是注意力机制,它捕获了单词或 token 使用的上下文。上下文对于正确理解单词和句子的含义至关重要。注意力机制通过调整单词嵌入向量来保存已识别的上下文。这些修改后的嵌入向量在全连接神经网络中进一步处理,以找到下一个 token 作为 LLM 的输出。
在第 2 部分中,我们在一个玩具项目 Fairy_Tale_GPT 中对 Transformer 架构的所有步骤进行了编码。目的是加深概念理解,并在实践中演示该理论。该示例基本上有效,但与此同时,它说明了人类语言对计算机模型的苛求。在实际应用中,LLM 模型的大小超过了万亿参数的限制,并在数 TB 的数据上进行训练。
我希望您对大型语言模型的工作原理有了扎实的了解,并享受了这段旅程。让我们对这个迷人的研究领域中接下来会发生的事情充满好奇!