NingG +

动手构建大模型:阅读笔记

0.背景

本文是阅读 《Build a Large Language Model 》 的笔记,记录下自己的理解。

过去一段时间关注 AI ,陆续把读研时的习惯捡回来,记录下学习过程。

先多遍阅读、每次覆盖不同要点,并结合手动操作,完全就是读研时学习前沿论文的方式,珍惜这种专注。

现在想起来,自己应该是享受类似的专注感觉:

关联术语:

1. LLM 主体流程

几个主要步骤:

其中,涉及 2 类数据:

GPT 这样的解码器模型是通过逐字预测生成文本,因此它们被视为一种自回归模型。

[!NOTE]

自回归,是一种用于时间序列分析的统计技术,它假设时间序列的当前值是其过去值函数

自回归模型,使用类似的数学技术来确定序列中,元素之间概率相关性。然后,它们使用所得知识,来猜测未知序列中的下一个元素。

自相关,用于衡量序列中元素之间的相关性;一般会圈定一个时间窗口,计算窗口内元素之间的相关性。大部分场景下,窗口之前的元素,对窗口之后的元素影响较小。

2. 数据准备

数据准备,前期处理:

Tips: 尽可能保持文本原样,仅对明确的错误进行调整。

例如: 无需将所有文本转换为小写字母,因为大写字母有助于 LLM 区分专有名词和普通名词,理解句子结构,并学习生成正确的大写文本。

数据送入模型训练之前,需要进行 分词编码,得到 token 序列。

[!TIP]

个人思考: 字节对编码是一种基于统计的方法,它会先从整个语料库中找出最常见的字节对(byte pair),然后把这些字节对合并成一个新的单元。让我们用一个具体的示例来描述这个过程:

假如有句子:“The cat drank the milk because it was hungry”

  1. 初始化:BPE会先将句子中每个字符视为一个单独的token

    ['T', 'h', 'e', ' ', 'c', 'a', 't', ' ', 'd', 'r', 'a', 'n', 'k', ' ', 't', 'h', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'b', 'e', 'c', 'a', 'u', 's', 'e', ' ', 'i', 't', ' ', 'w', 'a', 's', ' ', 'h', 'u', 'n', 'g', 'r', 'y']
    
  2. 统计最常见的字节对

    BPE算法会在这些token中找到出现频率最高的“字节对”(即相邻的两个字符),然后将其合并为一个新的token。

    例如这里最常见的字节对时(’t’, ‘h’),因为它在单词”the”和”that”中出现频率较高。

  3. 合并字节对

    根据统计结果,我们将最常见的字节对(’t’, ‘h’)合并为一个新的token,其它类似

    ['Th', 'e', ' ', 'c', 'a', 't', ' ', 'dr', 'a', 'nk', ' ', 'th', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'be', 'c', 'a', 'u', 'se', ' ', 'it', ' ', 'wa', 's', ' ', 'hu', 'n', 'gr', 'y']
    
  4. 重复步骤2和3,得到最终的token序列

    ['The', ' ', 'cat', ' ', 'drank', ' ', 'the', ' ', 'milk', ' ', 'because', ' ', 'it', ' ', 'was', ' ', 'hungry']
    

嵌入之前,数据处理步骤:

  1. 分词:将文本拆分成单词或子词单元, 词元 又称 token
  2. 编码:将分词后的 token 转换为数字 ID, tokenID
  3. 嵌入:将 tokenID 转换为 嵌入向量(稠密向量),用于后续的模型计算。
    • 嵌入层,本质上是一个查找功能,用 token ID 作为行索引,从嵌入层的权重矩阵中检索对应行数据。
    • 除了 嵌入向量,还需要包括 位置编码,用于表示文本的顺序信息。

分词时,需要考虑 OOV 问题,即 Out of Vocabulary,即模型未见过的词汇。常见解决办法:

对于GPT类大语言模型(LLM)来说,连续向量表示(Embedding)非常重要,原因在于这些模型使用深度神经网络结构,并通过反向传播算法(back propagation)进行训练。

[!TIP]

个人思考: 上面一段描述说的有些笼统,为什么通过反向传播算法训练的大语言模型必须具有Embedding,让我们通过以下几个方面来分析和思考:

  1. 深度神经网络和连续向量表示

    GPT 类模型(以及其他深度神经网络)是基于大量的矩阵运算和数值计算构建的,尤其是神经元之间的连接权重和偏置在训练过程中不断更新。这些运算要求输入的数据是数值形式的向量,因为神经网络只能对数值数据进行有效计算,而无法直接处理原始的离散文字数据(如单词、句子)。

    • 向量表示:通过将每个单词、句子或段落转换为连续向量(Embedding),可以在高维空间中表示文本的语义关系。例如,通过词嵌入(如 Word2Vec、GloVe)或上下文嵌入(如 GPT 中的词嵌入层),每个单词都被转换为一个向量,这个向量可以用于神经网络的计算。
  2. 向量嵌入的作用

连续向量表示不仅让文本数据可以进入神经网络,还帮助模型捕捉和表示文本之间的语义关系。例如:

  • 同义词或相似词:在向量空间中,相似的单词可以有接近的向量表示。这种语义相似性帮助模型理解上下文,并在生成文本时提供参考。
  • 上下文关系:GPT 等 LLM 模型不仅依赖单词级别的向量表示,还会考虑句子或段落上下文,形成动态嵌入,从而生成更具连贯性的文本。
  1. 反向传播算法的要求

    深度神经网络通过反向传播算法进行训练,反向传播的本质是利用梯度下降法来更新网络的权重,以最小化损失函数(loss function)。反向传播要求每一层的输入、输出和权重都能够参与梯度计算,而梯度计算只能应用于数值数据。

    • 自动微分与梯度计算:在反向传播中,神经网络会根据损失函数的导数来计算梯度,这个过程依赖于自动微分(automatic differentiation)。为了计算每层的梯度,输入的数据必须是数值形式(即向量),否则无法对离散的文本数据求导。
    • 梯度更新权重:每次更新网络权重时,神经网络会根据每一层的输入和输出来调整权重,以更好地学习数据的模式。如果输入不是数值形式,就无法实现梯度更新,从而无法通过反向传播训练网络。

3. 关键原理

3.1. 注意力机制

编码器-解码器架构的 RNN 循环神经网络的工作原理,关键思想在于:

1.编码器,将整个输入文本处理为一个隐藏状态(记忆单元)。 2.解码器,使用该隐藏状态生成输出,隐藏状态视为一个嵌入向量,它捕获了输入文本的语义信息。

RNN 的限制在于:在解码阶段 RNN 无法直接访问 编码器的早期隐藏状态,即,依赖当前隐藏状态来封装所有相关信息。这种设计可能导致上下文信息的丢失,特别是在依赖关系较长的复杂句子中,这一问题尤为突出。这是典型的 长序列建模 问题,也引发人们设计出 注意力机制。

[!TIP]

个人思考: 了解从RNN到注意力机制的技术变迁对于核心内容的理解至关重要。让我们通过一个具体的示例来理解这种技术变迁:

  1. RNN的局限性

    假设我们有一个长句子:“The cat, who was sitting on the windowsill, jumped down because it saw a bird flying outside the window.”

    假设任务是预测句子最后的内容,即要理解“it”指的是“the cat”而不是“the windowsill”或其他内容。对于 RNN 来说,这个任务是有难度的,原因如下:

    • 长距离依赖问题:在 RNN 中,每个新输入的词会被依次传递到下一个时间步。随着句子长度增加,模型的隐状态会不断被更新,但早期信息(如“the cat”)会在层层传播中逐渐消失。因此,模型可能无法在“it”出现时有效地记住“the cat”是“it”的指代对象。
    • 梯度消失问题:RNN 在反向传播中的梯度会随着时间步的增加逐渐减小,这种“梯度消失”使得模型很难在长句中保持信息的准确传播,从而难以捕捉到长距离的语义关联。
  2. 注意力机制的解决方法

    为了弥补 RNN 的这些不足,注意力机制被引入。它的关键思想是在处理每个词时,不仅依赖于最后的隐藏状态,而是允许模型直接关注序列中的所有词。这样,即使是较远的词也能在模型计算当前词的语义时直接参与。

    在上例中,注意力机制如何帮助模型理解“it”指代“the cat”呢?

    • 注意力机制的工作原理:当模型处理“it”时,注意力机制会将“it”与整个句子中的其他词进行相似度计算,判断“it”应该关注哪些词。
      • 由于“the cat”与“it”在语义上更相关,注意力机制会为“the cat”分配较高的权重,而其他词(如“windowsill”或“down”)则获得较低的权重。
    • 信息的直接引用:通过注意力机制,模型可以跳过中间步骤,直接将“it”与“the cat”关联,而不需要依赖所有的中间隐藏状态。
  3. 示例中的注意力矩阵

    假设使用一个简单的注意力矩阵,模型在处理“it”时,给每个词的权重可能如下(至于如何计算这些权重值后文会详细介绍):

    The cat who was sitting it saw bird flying window
    权重 0.1 0.3 0.05 0.05 0.05 0.4 0.05 0.02 0.01 0.02

    在这个注意力矩阵中,可以看到“it”对“the cat”有较高的关注权重(0.3),而对其他词的关注权重较低。这种直接的关注能力让模型能够高效捕捉长距离依赖关系,理解“it”与“the cat”的语义关联。

自注意力机制,是一种允许输入序列中的每个位置都能与同一序列中所有位置进行交互,并权衡他们的重要性。

在自注意力机制中,我们的目标是:

[!TIP]

个人思考: 这里对于注意力得分的计算描述的比较笼统,仅仅说明了将当前的输入Token向量与其它Token的向量进行点积运算计算注意力得分,实际上,每个输入Token会先通过权重矩阵W分别计算出它的Q、K、V三个向量,这三个向量的定义如下:

  • Q向量(查询向量):查询向量代表了这个词在寻找相关信息时提出的问题
  • K向量(键向量):键向量代表了一个单词的特征,或者说是这个单词如何”展示”自己,以便其它单词可以与它进行匹配
  • V向量(值向量):值向量携带的是这个单词的具体信息,也就是当一个单词被”注意到”时,它提供给关注者的内容

更通俗的理解: 想象我们在图书馆寻找一本书(Q向量),我们知道要找的主题(Q向量),于是查询目录(K向量),目录告诉我哪本书涉及这个主题,最终我找到这本书并阅读内容(V向量),获取了我需要的信息。

具体生成Q、K、V向量的方式主要通过线性变换:

Q1 = W_Q * (E1 + Pos1)
K1 = W_K * (E1 + Pos1)
V1 = W_V * (E1 + Pos1)

依次类推,为所有token生成QKV向量,其中W_QW_KW_V是Transformer训练出的权重(每一层不同)

针对每一个目标token,Transformer会计算它的 Q向量 与其它所有的token的 K向量 的点积,以确定每个词对当前词的重要性(即注意力分数)

假如有句子:“The cat drank the milk because it was hungry”

例如对于词 catQ向量 Q_cat,模型会计算:

  • score_cat_the = Q_cat · K_the — 与the的语义相关度
  • score_cat_drank = Q_cat · K_drank — 与 drank 的语义相关度
  • score_cat_it = Q_cat · K_it — 与 it 的语义相关度
  • 依此类推,得到cat与句子中其它所有token的注意力分数 [score_cat_the、score_cat_drank、socre_cat_it、……]

计算上下文向量前,会对权重归一化,将注意力得分、转换为注意力权重:一般用 softmax 函数。

理解点积

点积运算本质上是一种将两个向量按元素相乘后再求和的简单方式,我们可以如下演示:

res = 0.
for idx, element in enumerate(inputs[0]):
     res += inputs[0][idx] * query[idx]
print(res)
print(torch.dot(inputs[0], query))

输出结果确认,逐元素相乘的和与点积的结果相同。

tensor(0.9544)
tensor(0.9544)

除了将点积运算视为结合两个向量并产生标量结果的数学工具之外,点积也是一种相似度的衡量方法,因为它量化了两个向量的对齐程度:较高的点积值表示向量之间有更高的对齐程度或相似度。在自注意力机制的背景下,点积决定了序列中元素之间的关注程度:点积值越高,两个元素之间的相似度和注意力得分就越高。

[!TIP]

个人思考: 这里稍微延伸探讨一下Softmax, 它是一种常用的激活函数,尤其在神经网络的分类任务中被广泛使用。它的作用是将一个任意的实数向量转换为一个概率分布,且所有元素的概率之和为 1。下面通过例子来说明 softmax 的原理、好处,以及它在神经网络中的使用原因。

  1. Softmax 的原理

    Softmax 函数的公式如下:

    \[\text{softmax}\left(z_{i}\right)=\frac{e^{z_{i}}}{\sum_{j} e^{z_{j}}}\]

    其中zi是输入的每个分数(即未激活的原始值),e 是自然对数的底。这个公式的作用是将输入向量中的每个元素转换为一个概率值,且所有值的和为 1。

  2. Softmax 的好处

    • 归一化输出为概率:Softmax 将输出转换为 0 到 1 之间的概率,且所有类别的概率之和为 1,方便解释结果。例如,在分类任务中,输出可以直接表示模型对各类别的信心。
    • 平滑和放大效果:Softmax 不仅能归一化,还具有平滑和放大效果。较大的输入值会被放大,较小的输入值会被抑制,从而增强模型对最优类别的区分。
    • 支持多分类问题:与 sigmoid 不同,Softmax 适用于多类别分类问题。它可以输出每个类别的概率,使得模型可以处理多分类任务。
  3. 神经网络为什么喜欢使用 Softmax

    在神经网络中,特别是分类模型(如图像分类、文本分类)中,Softmax 层通常用作最后一层输出。原因包括:

    • 便于优化:在分类任务中,Softmax 输出的概率分布可与真实的标签概率进行比较,从而计算交叉熵损失。交叉熵损失的梯度较为稳定,便于模型的优化。
    • 概率解释:Softmax 输出可以解释为“模型对每个类别的信心”,使得输出直观可理解。
    • 与交叉熵的结合:Softmax 与交叉熵损失函数结合效果特别好,可以直接将模型预测的概率分布与真实标签比较,从而更快收敛,效果更好。
  4. 激活函数

    激活函数(Activation Function)是神经网络中的核心组件,它的作用类似于神经元的“开关”或“过滤器”,负责决定神经元是否被激活(即输出信号),以及激活的程度

    在神经网络中,激活函数通常用于将输入信号转换为输出信号,从而实现非线性变换。 常见的激活函数包括:

    • Sigmoid:将输入信号转换为0到1之间的概率值,常用于二分类问题。
    • ReLU:将输入信号转换为0到正无穷之间的值,常用于多分类问题。
    • Softmax:将输入信号转换为0到1之间的概率值,常用于多分类问题。

3.2. 上下文向量

疑问:llm 大模型的自注意力机制中,每个输入 token 对应的上下文向量,有什么作用?被用于做什么?

LLM 大模型的自注意力机制 (Self-Attention) 里,每个输入 token(词或子词)在计算时,都会得到一个 上下文向量(context vector)

1. 上下文向量是怎么来的?

在自注意力里:

换句话说:

上下文向量 = 当前 token 从其他 token 那里「聚合」来的信息。

2. 上下文向量的作用

上下文向量不是终点,而是 后续计算的输入,主要用在以下方面:

a. 更新 token 表示

b. 为下一层提供输入

c. 用于预测下一个 token(解码阶段)

3. 可以打个比喻

4.总结一句话

在 LLM 的自注意力里,每个输入 token 的上下文向量,起到“把自己和全局上下文融合”的作用,既用于更新 token 的表示,也直接服务于最后的下一个 token 预测。

3.3. 缩放:注意力得分

缩放注意力得分:通过将注意力得分除以keys嵌入维度的平方根来进行缩放(注意,取平方根在数学上等同于指数为 0.5 的运算)。再使用 softmax 函数来计算注意力权重。

[!NOTE]

缩放点积注意力机制的原理

对嵌入维度大小进行归一化的原因是为了避免出现小梯度,从而提高训练性能。例如,当嵌入维度增大时(在 GPT 类大型语言模型中通常超过一千),较大的点积在反向传播中应用 softmax 函数后,可能会导致非常小的梯度。随着点积的增大,softmax 函数的行为会更加类似于阶跃函数,导致梯度接近于零。这些小梯度可能会显著减慢学习速度,甚至导致训练停滞。

通过嵌入维度的平方根进行缩放,正是自注意力机制被称为‘缩放点积注意力’的原因。

[!TIP]

个人思考: 这里再稍微解释一下上述关于缩放点积注意力的机制。在自注意力机制中,查询向量(Query)与键向量(Key)之间的点积用于计算注意力权重。然而,当嵌入维度(embedding dimension)较大时,点积的结果可能会非常大。那么大的点积对接下来的计算有哪些具体影响呢?

  • Softmax函数的特性:在计算注意力权重时,点积结果会通过Softmax函数转换为概率分布。而Softmax函数对输入值的差异非常敏感,当输入值较大时,Softmax的输出会趋近于0或1,表现得类似于阶跃函数(step function)。
  • 梯度消失问题:当Softmax的输出接近0或1时,其梯度会非常小,接近于零(可以通过3.3.1小节中提到的Softmax公式推断)。这意味着在反向传播过程中,梯度更新幅度会很小,导致模型学习速度减慢,甚至训练停滞。

为了解决上述问题,在计算点积后,将结果除以嵌入维度的平方根(即 \(\sqrt{dk}\)),其中 dk 是键向量的维度。这样可以将点积结果缩放到适当的范围,避免Softmax函数进入梯度平缓区,从而保持梯度的有效性,促进模型的正常训练。

3.4. 权重矩阵 Q\K\V

疑问:llm 的注意力机制中,为什么要使用 Q/K/V 权重矩阵,只使用 Q/V 不行吗?或者,使用更多权重矩阵可以吗?

1. Q/K/V 各自的角色

在 Transformer 自注意力里,输入 token 的 embedding/隐藏向量 $x$ 会经过 三个不同的线性变换

然后通过公式:

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

核心逻辑:

2. 为什么不能只用 Q/V?

假设只用 Q 和 V(没有 K):

所以,K 的存在提供了一个 抽象的匹配空间,让 Q 可以快速决定该关注哪些 V。

3. 能不能用更多的权重矩阵?

可以,但不一定有意义。

实际上,学术界确实有一些扩展版本:

4. 总结

5. 类比图

类比图(图书馆:读者需求=Q,书标签=K,书内容=V),让你更直观地理解为什么 K 不能省?

3.5. dropout 剪枝

Dropout 在深度学习中是一种技术,即在训练过程中随机忽略一些隐藏层单元,实际上将它们“丢弃”。这种方法有助于防止过拟合,确保模型不会过于依赖任何特定的隐藏层单元组合。需要特别强调的是,Dropout 仅在训练过程中使用,训练结束后则会禁用。

在 Transformer 架构中(包括 GPT 等模型),注意力机制中的 Dropout 通常应用于两个特定区域:计算注意力得分之后,或将注意力权重应用于 value 向量之后。

[!TIP]

个人思考: Dropout相当于丢弃一定比例的注意力权重,这表明对输入中的某些token关注度降为0了(完全不关注),这样的处理方式难道对最终的预测效果没有影响么?另外如何理解Dropout之后的缩放操作是为了保持注意力在不同阶段的平衡?

经过查阅额外的资料及深度思考,我觉得可以从以下几个方面理解上述的疑问:

  1. Dropout 的目的:提高模型的泛化能力

    dropout 的设计初衷是提高模型的泛化能力。通过随机丢弃一部分神经元或注意力权重,dropout 迫使模型在每次训练时学习略有不同的表示方式,而不是依赖某一特定的注意力模式。这种随机化的训练方式可以帮助模型在面对新数据时更具鲁棒性,减少过拟合的风险。

  2. 注意力机制的冗余性

    在 Transformer 的注意力机制中,模型通常会对多个 token 进行注意力计算,实际上会有一些冗余信息。也就是说,不同 token 之间的信息通常会有部分重叠,并且模型能够从多个来源获取类似的信息。在这种情况下,dropout 随机丢弃一部分注意力权重并不会完全破坏模型的性能,因为模型可以依赖于其他未被丢弃的注意力路径来获取所需信息。

  3. 缩放操作的作用

    在应用 dropout 时,一部分注意力权重被随机置零(假设 dropout 率为 p)。剩余的权重会被放大,其放大倍数为 \(\frac{1}{1-p}\)。放大后的权重记为 z′:

    \[z_{i}^{\prime}=\frac{z_{i}}{1-p} \quad \text { (对于未被置零的权重) }\]

    此时,未被置零的注意力权重 \(\mathbf{z}'\) 将作为 Softmax 的输入。因此,dropout 后的缩放对 Softmax 有两个主要影响:

    • 增大未遮盖值的相对差异:放大剩余权重后,它们的数值相对于被置零的权重增大,从而拉大了非零元素之间的相对差异。这使得在 Softmax 计算中(通过前文提过的Softmax公式推导,输入值的差异越大,输出分布就会越尖锐;而输入值差异越小,输出分布就会越平滑),剩下的值之间的对比更明显。
    • 影响 Softmax 输出的分布形态:当未被置零的权重值被放大后,它们在 Softmax 输出中会更具代表性,注意力分布会更集中(即更尖锐),让模型更关注特定的 token。

    缩放后的 Softmax 输入导致注意力分布更倾向于少数的高权重 token,使得模型在当前步骤更关注这些 token 的信息。这对模型的影响包括:

    • 增强模型的选择性关注:在训练中,模型会在每个步骤中随机选择不同的 token 进行更高的关注,这使模型在学习时不会依赖特定 token 的注意力。
    • 确保总注意力强度保持一致:即便经过 dropout 丢弃了一部分权重,缩放保证了剩余权重在 Softmax 后的分布与未应用 dropout 时类似。
  4. 训练过程中多次迭代弥补信息丢失

在训练过程中,每个 batch 中的 dropout 掩码都是随机生成的。也就是说,在每次训练时被丢弃的注意力权重是随机的,并不会始终忽略相同的 token。这种随机性确保了在训练过程中,模型会在多个迭代中多次关注到每个 token。因此,即便某个 token 在当前的训练步中被忽略,在未来的训练步骤中它仍然会被关注到,从而在整体上避免了信息丢失的问题。

3.6. 层归一化

层归一化,以提高神经网络训练的稳定性和效率。

归一化的核心思想:将神经网络层的激活(输出)调整为均值为 0,方差为 1(即单位方差),即 正态分布

3.7. 前馈神经网络 FFN

FeedForward 模块是一个小型神经网络,由两个线性层和一个 GELU 激活函数组成。

FeedForward 模块对模型能力的增强(主要体现在从数据中学习模式并泛化方面)起到了关键作用。尽管该模块的输入和输出维度相同,但在内部,它首先通过第一个线性层将嵌入维度扩展到一个更高维度的空间(如上图所示)。之后再接入非线性 GELU 激活,最后再通过第二个线性层变换回原始维度。这样的设计能够探索更丰富的表示空间。

[!TIP]

个人思考: 这段描述一笔带过了扩展和收缩嵌入维度为模型训练带来的好处,那到底该如何理解这样的设计能够探索更丰富的表示空间呢?

可以将扩展和收缩的过程类比为一种数据解压缩与重新压缩的机制:

  • 扩展(解压缩):假设我们有一段压缩的音乐文件(例如 MP3),里面包含了音频的基本信息。通过解压缩(扩展),我们把这个文件变成了一个更高质量的音频格式,允许我们看到(听到)更多的细节,比如乐器的细微声响和音调变化。
  • 特征提取:接着,我们可以在这个高质量的音频文件中应用各种音频处理算法(相当于非线性激活),分析出更多细节,比如每种乐器的声音特点。
  • 收缩(压缩):最后,我们将音频再次压缩为一种更适合传输和存储的格式。虽然最终文件变小了,但这个文件已经包含了之前提取出的更多的声音细节。

将这种理解再应用到神经网络中,扩展后的高维空间可以让模型“看到”输入数据中更多的隐藏特征,提取出更丰富的信息。然后在收缩回低维度时,这些丰富的特征被整合到了输入的原始维度表示中,使模型最终的输出包含更多的上下文和信息。

3.8. 残差连接

残差连接,是一种在神经网络中广泛使用的技巧,用于帮助模型训练更深层的网络:用于缓解梯度消失问题。梯度消失是指,在训练中指导权重更新的梯度在反向传播过程中逐渐减小,导致早期层(靠近输入端的网络层)难以有效训练。

[!TIP]

个人思考: 看到这里,不知各位读者是否真正理解了快捷连接在深度神经网络中的作用,这里其实涉及到快捷连接的两个重要的作用:

  • 保持信息(或者说是特征)流畅传递
  • 缓解梯度消失问题

让我们逐一解读,LLM 中的每个Transformer 模块通常包含两个重要组件(可以先阅读完4.5节,再回头看这里的解读):

  1. 自注意力层(Self-Attention Layer):计算每个 token 与其他 token 的关联,帮助模型理解上下文。
  2. 前馈网络(Feed Forward Network):对每个 token 的嵌入(embedding)进行进一步的非线性转换,使模型能够提取更复杂的特征。

这两个部分都在层归一化(Layer Normalization)和快捷连接(Shortcut Connections)的配合下工作。

假设我们正在训练一个 LLM ,并希望它理解下面的句子:

The cat sat on the mat because it was tired.

模型需要通过多个 Transformer 层来逐层处理该句子,使得每个词(token)在上下文中能被理解。为了达到这一目的,每个 token 的嵌入会在多层中进行注意力计算和前馈网络处理。

  1. 没有快捷连接时的情况

    如果没有快捷连接,那么每个 Transformer 层的输出就直接传递到下一个层。这种情况下,网络中的信息流大致如下:

    • 层间信息传递的局限:假设当前层的注意力机制计算出了“it”和“cat”之间的关系,如果前馈网络进一步转换了这个信息,那么下一层就只能基于该层的输出,可能丢失一些最初的语义信息。
    • 梯度消失:在训练过程中,梯度从输出层逐层向回传播。如果层数过多,梯度会逐渐变小(即“梯度消失”),从而导致模型难以有效更新前面层的参数。

    这种情况下,由于信息不能直接流动到更深层次的网络,可能会导致模型难以有效捕捉到前层的一些原始信息。

  2. 加入快捷连接后的情况

    加入快捷连接后,信息可以在层与层之间直接跳跃。例如,假设在第 n 层,我们有输入 \(X_{n}\) ,经过注意力和前馈网络得到输出 \(F(X_{n})\) 。加入快捷连接后,这一层的输出可以表示为:

    \[\text { 输出 }=X_{n}+F\left(X_{n}\right)\]

    这意味着第 n 层的输出不仅包含了这一层的新信息 \(F(X_{n})\) ,还保留了原始输入 \(X_{n}\) 的信息。下面是这样做的好处:

    • 保留原始信息

      快捷连接让输入的原始信息直接传递到后续层,避免了在多层处理过程中丢失重要信息。例如,“it” 和 “cat” 之间的关系在较浅层中被捕捉到后,即使后面的层有进一步的处理,模型依然能够从快捷连接中获得最初的上下文信息。

    • 减轻梯度消失

      假设我们有一个简单的三层网络,第三层的输出 O 是整个网络的输出。我们从损失函数 LLL 开始计算梯度:

      • 根据反向传播的原理,无快捷连接时,梯度必须逐层传递,如下:

        \[\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}}\]

        这里,如果某一层的梯度值很小,那么梯度会被逐层缩小,导致梯度消失。

      • 有快捷连接时,假设我们在每一层之间都添加快捷连接,梯度的传播路径就多了一条直接路径:

        \[\frac{\partial L}{\partial X_{1}}=\frac{\partial L}{\partial\left(X_{1}+F\left(X_{1}\right)\right)} \cdot\left(1+\frac{\partial F\left(X_{1}\right)}{\partial X_{1}}\right)\]

        这样,即使 \(\frac{\partial F\left(X_{1}\right)}{\partial X_{1}}\) 很小,梯度依然可以通过 111 这条路径直接传递到更前面的层。

4. 核心架构

llm 架构,核心模块:

4.1. 预测下一个 token

疑问:训练 LLM 模型时,预测下一个 token 时,使用的是「上一个 token」计算出来的「注意力权重」反算出来概率最大的 token吗?

LLM 在训练时是如何预测下一个 token 的

1. 输入是什么?

假设我们有一句话:

“I like to eat”

在训练时,模型看到的输入 token 序列是:

[I] [like] [to] [eat]

模型的任务是 预测下一个 token,也就是:

I -> like  
like -> to  
to -> eat  
eat -> <end>

2. 注意力在做什么?

3. 如何得到下一个 token 的概率?

所以,它不是直接用「注意力权重」来反推,而是: 注意力权重 → 融合得到上下文向量 → 输出层映射到词表 → Softmax 得到概率分布

4. 训练目标

5. 总结

4.2. 交叉熵损失

模型输出的是 上下文向量,通过 输出层 + softmax 得到概率分布,再通过 交叉熵损失 计算损失,反向传播更新模型权重。

[!NOTE]

反向传播

如何最大化目标 token 的 softmax 概率值?整体思路是通过更新模型权重,使模型在生成目标 token 时输出更高的概率值。权重更新通过一种称为反向传播的过程来实现,这是一种训练深度神经网络的标准技术。

反向传播需要一个损失函数,该函数用于计算模型预测输出与实际目标输出之间的差异(此处指与目标 token ID 对应的概率)。这个损失函数用于衡量模型预测与目标值的偏差程度。

[!TIP]

个人思考: 在继续接下来的计算之前,我们首先来探讨一下,对数在损失函数的应用中到底有什么作用。

  1. 为什么要用概率的对数

    在 LLM 中,概率得分通常是小于1的数(例如0.1、0.05等),直接用这些数进行计算和优化可能会面临一些问题。比如,如果多个概率相乘,结果会变得非常小,甚至接近0。这种情况称为“数值下溢”(Numerical Underflow),可能导致计算不稳定。

    假设我们有三个概率值,分别为0.2、0.1和0.05。如果我们计算这些值的乘积,结果是:

    \[0.2×0.1×0.05=0.001\]

    这个值非常小,尤其在深度学习或概率模型中,我们通常会有成千上万个概率需要相乘,这样会导致最终的乘积接近0甚至为0,造成数值计算的不稳定性。

    如果我们对这些概率值取对数,然后相加,而不是直接相乘,我们可以避免这个问题。例如,对这三个值取自然对数(logarithm)后再相加:

    \[ln(0.2)+ln(0.1)+ln(0.05)≈−1.6094+(−2.3026)+(−2.9957)=−6.9077\]

    虽然这个和也是负数,但它不会像直接相乘的结果那样接近于0,避免了数值下溢的问题。对数的累加性质允许我们将原本的累乘操作转换为累加,使得计算更加稳定和高效。

  2. 对数概率在损失函数中的作用

    GPT模型训练的目标是最大化正确目标 token 的概率,通常,我们会使用交叉熵损失来衡量模型预测与实际目标之间的差异。对于一个目标 token 序列 y=(y1,y2,…,yn),GPT会生成一个对应的预测概率分布 P(y∣x),其中 x 是模型的输入。

    交叉熵损失的公式:

    在计算交叉熵损失时,我们希望最大化模型分配给每个正确目标token的概率。交叉熵损失的数学公式为:

    \[\text { Loss }=-\sum_{t=1}^{T} \ln P\left(y_{t} \mid x, \theta\right)\]

    其中:

    • T 是序列长度
    • \(y_{t}\) 是在位置 t 上的目标token
    • \(P(y_{t}∣x,θ)\) 是模型在参数 θ 下对目标token \(y_{t}\) 的条件概率

    在公式中,对每个token的概率 \(P(y_{t}∣x,θ)\) 取对数,将乘积形式的联合概率转换为求和形式,有助于避免数值下溢,同时简化优化过程。

[!NOTE]

交叉熵损失

本质上,交叉熵损失是在机器学习和深度学习中一种常用的度量方法,用于衡量两个概率分布之间的差异——通常是标签的真实分布(此处为数据集中的 token)和模型的预测分布(例如,LLM 生成的 token 概率)。

在机器学习,特别是 PyTorch 等框架中,cross_entropy 函数用于计算离散输出的损失,与模型生成的 token 概率下的目标 token 的负平均对数概率类似。因此,cross entropy 和负平均对数概率这两个术语在计算上有关联,实践中经常互换使用。

5.实例代码

模型核心:生成 上下文向量,如下。

# 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 上训练模型

基于核心 model,实现 token 生成过程:

# 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)

工具函数,用于计算由训练和验证加载器返回的批量数据的交叉熵损失:

def calc_loss_batch(input_batch, target_batch, model, device):
    input_batch, target_batch = input_batch.to(device), target_batch.to(device)       #A
    logits = model(input_batch)
    loss = torch.nn.functional.cross_entropy(
        logits.flatten(0, 1), target_batch.flatten()
    )
    return loss

#A 将数据传输到指定设备(如 GPU),使数据能够在 GPU 上处理。

训练流程:

# Listing 5.3 The main function for pretraining LLMs
def train_model_simple(model, train_loader, val_loader, optimizer, device, num_epochs,
                       eval_freq, eval_iter, start_context, tokenizer):
    train_losses, val_losses, track_tokens_seen = [], [], []                        #A
    tokens_seen, global_step = 0, -1

    for epoch in range(num_epochs):                                                 #B
        model.train()
        for input_batch, target_batch in train_loader:
            optimizer.zero_grad()                                                   #C
            loss = calc_loss_batch(input_batch, target_batch, model, device)
            loss.backward()                                                         #D
            optimizer.step()                                                        #E
            tokens_seen += input_batch.numel()
            global_step += 1

            if global_step % eval_freq == 0:                                        #F
                train_loss, val_loss = evaluate_model(
                    model, train_loader, val_loader, device, eval_iter)
                train_losses.append(train_loss)
                val_losses.append(val_loss)
                track_tokens_seen.append(tokens_seen)
                print(f"Ep {epoch+1} (Step {global_step:06d}): "
                      f"Train loss {train_loss:.3f}, Val loss {val_loss:.3f}")

        generate_and_print_sample(                                                  #G
            model, tokenizer, device, start_context
        )
    return train_losses, val_losses, track_tokens_seen


#A 初始化用于记录损失和已处理 token 数量的列表
#B 开始主训练循环
#C 重置上一批次的损失梯度
#D 计算损失梯度
#E 使用损失梯度更新模型权重
#F 可选的评估步骤
#G 每个 epoch 结束后打印示例文本

原文地址:https://ningg.top/ai-series-build-llm-from-scratch/
微信公众号 ningg, 联系我

同类文章:

微信搜索: 公众号 ningg, 联系我, 交个朋友.

Top