Build LLM from Scratch

Tokenization

Naive Tokenization

  • 准备原始文本

    with open("the-verdict.txt", "r", encoding="utf-8") as f:
        raw_text = f.read()
    
    print("Total number of character:", len(raw_text))
    print(raw_text[:99])
  • 接下来根据正则表达式切分原始文本,得到切分后的 token 列表

    preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
    preprocessed = [item.strip() for item in preprocessed if item.strip()]
    print(preprocessed[:30])
  • 将 token 列表转为 token IDs,首先进行去重(将 token 列表转为 token 集合),并按照首字母排序,然后简单地按照 token 的顺序分配对应的 token ID

    all_words = sorted(set(preprocessed))
    vocab_size = len(all_words)
    print(vocab_size)
    
    vocab = {token:integer for integer,token in enumerate(all_words)}
    
    for i, item in enumerate(vocab.items()):
        print(item)
        if i >= 50:
            break
  • 将上续步骤合并得到 SimpleTokenizerV1 类

    class SimpleTokenizerV1:
        def __init__(self, vocab):
            self.str_to_int = vocab
            self.int_to_str = {i:s for s,i in vocab.items()}
    
        def encode(self, text):
            preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
    
            preprocessed = [
                item.strip() for item in preprocessed if item.strip()
            ]
            ids = [self.str_to_int[s] for s in preprocessed]
            return ids
    
        def decode(self, ids):
            text = " ".join([self.int_to_str[i] for i in ids])
            # Replace spaces before the specified punctuations
            text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
            return text
  • 接下来就可以使用 SimpleTokenizerV1 来处理文本

    tokenizer = SimpleTokenizerV1(vocab)
    
    text = """"It's the last he painted, you know,"
               Mrs. Gisburn said with pardonable pride."""
    ids = tokenizer.encode(text)
    print(ids)
    # 解码
    tokenizer.decode(ids)
    # 编码并解码
    tokenizer.decode(tokenizer.encode(text))
  • 添加特殊的 token:例如 [BOS] 表示序列的开始;[EOS] 表示序列的结束;[PAD] 表示对于多条长度不一致的序列,把短序列填充到长序列的长度,使得不同序列的长度保持一致;[UNK] 表示未登录词;GPT 模型则使用 <|endoftext|> 表示序列结束、序列填充;GPT 模型不使用特殊的 token 表示未登录词,而是通过 BPE 算法把未登录词划分为字词

  • 例如,对于先前的词典,当遇到未登录词会遇到问题

    tokenizer = SimpleTokenizerV1(vocab)
    
    text = "Hello, do you like tea. Is this-- a test?"
    
    tokenizer.encode(text)
  • 由于 Hello 不包含在词典中,因此会抛出错误,可以添加特殊的 token

    all_tokens = sorted(list(set(preprocessed)))
    all_tokens.extend(["<|endoftext|>", "<|unk|>"])
    
    vocab = {token:integer for integer,token in enumerate(all_tokens)}
    
    for i, item in enumerate(list(vocab.items())[-5:]):
        print(item)
  • 还需要对 SimpleTokenizerV1 进行修改

    class SimpleTokenizerV2:
        def __init__(self, vocab):
            self.str_to_int = vocab
            self.int_to_str = { i:s for s,i in vocab.items()}
    
        def encode(self, text):
            preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
            preprocessed = [item.strip() for item in preprocessed if item.strip()]
            preprocessed = [
                item if item in self.str_to_int
                else "<|unk|>" for item in preprocessed
            ]
    
            ids = [self.str_to_int[s] for s in preprocessed]
            return ids
    
        def decode(self, ids):
            text = " ".join([self.int_to_str[i] for i in ids])
            # Replace spaces before the specified punctuations
            text = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)
            return text
  • 此时处理新的句子,可以看到编码再解码后的句子包含了未登录词的 token

    tokenizer = SimpleTokenizerV2(vocab)
    
    text1 = "Hello, do you like tea?"
    text2 = "In the sunlit terraces of the palace."
    
    text = " <|endoftext|> ".join((text1, text2))
    
    print(text)
    # Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.
    print(tokenizer.encode(text))
    # [1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]
    print(tokenizer.decode(tokenizer.encode(text)))
    # '<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.'

BytePair Encoding(BPE)

  • GPT-2 使用了 BPE 作为 tokenizer

  • BPE 算法允许模型将未登录词分解为更小的字词甚至是单个的字符来使其能够用词典中的 token 表示

  • 例如,GPT-2 可能会将未登录词 unfamiliarword 划分为 unfamiliarword

  • 首先需要安装 tiktoken

  • 可以调用 tiktoken 库中的 gpt2 的 tokenizer

  • LLM 的训练任务是预测下一个单词,因此需要构建掩码序列

  • 那么预测目标应该类似下列所示

  • 解码结果如下

  • GPT-2 处理未登录词

批量处理输入

  • 现在实现一个简单的 DataLoader,可以遍历处理输入的数据集,并返回其输入与预测目标

  • 首先需要安装 PyTorch

  • 创建的 Dataset 如下

  • 使用 DataLoader 创建数据加载器

  • 处理数据集

  • stride 参数决定了创建数据集移动滑动窗口的步长,如果不同的输入有重叠,可能会导致相应的过拟合风险

build-llm-from-scratch-stride

  • 下面是 batch_size 为 8 的示例

  • max_length 与 stride

  • 充分理解 DataLoader 的原理

创建 Token 嵌入

  • 接下来,需要经过 Emedding layer 来将 tokens 嵌入处理为连续的向量表示

  • 例如,假设经过 tokenization 后,得到的 input_ids 为 2、3、5、1

  • 假设现在有一个仅包含 6 个词汇的小型词汇表,且希望构建维度为 3 的词嵌入

  • 当执行 torch.nn.Embedding(vocab_size, output_dim) 时,PyTorch 会直接初始化一个形状为[vocab_size, output_dim]的 Parameter 矩阵,这个矩阵就是所有单词的嵌入向量表:

    • 矩阵的每一行对应词汇表中一个单词的嵌入向量(比如第 0 行是w0的 3 维嵌入,第 1 行是w1的 3 维嵌入,直到第 5 行w5

    • 这个矩阵被标记为requires_grad=True(可训练),后续会通过反向传播不断更新值,这也是 Embedding 层能学出语义嵌入的核心

    • 在初始化时,PyTorch 的 nn.Embedding 层的权重,默认由torch.nn.init.normal_(正态分布初始化器)初始化

    • PyTorch 的层初始化都遵循「层定义时绑定初始化器 + 实例化时执行初始化」的逻辑,nn.Embedding的初始化完全封装在框架底层,无需手动调用

  • 通过 embedding_layer,可以将输入的 token ID 转为 3 维向量:

  • 实际上,embedding_layer 就是一个查找操作

  • Embedding 与 Linear 的区别

    • Embedding 查表 = 独热编码 + Linear 矩阵乘法,以 idx=[2,3,1]举例

    • 步骤 1:ID=2 的独热编码是[0,0,1,0](4 维,对应 4 个 ID),ID=3 是[0,0,0,1],ID=1 是[0,1,0,0]

    • 步骤 2:独热向量(1×4)和 Linear 层权重矩阵(4×5)做矩阵乘法,结果就是独热向量中「1」所在位置对应的权重矩阵行向量

    • 步骤 3:最终结果和 Embedding Layer 查表取出的行向量完全一样

    • 独热编码有一个致命问题:极度稀疏(比如 4 维独热向量只有 1 个 1,其余都是 0)

    • 当用稀疏的独热向量做矩阵乘法时,99% 的计算都是「0 乘任意数 = 0」,完全是无用的计算浪费

    • Embedding Layer 的查表操作,直接跳过了所有 0 的计算,根据 ID 直接取对应行,没有任何无用计算,计算量从「O ( token 总数 × 嵌入维度)」降到了「O (1)(索引)」,在大词汇量场景下(LLM 的核心场景),效率提升是数量级的

  • Linear 举例

位置编码

  • Token 嵌入并不考虑每个 token 在输入序列中的位置,需要引入位置编码来获得完整的输入嵌入

  • 假设词典大小是 50257,嵌入维度为 256

  • 如果处理后的输入中,每个batch 包含 8 个输入序列,每个输入序列包含 4 个 token,那么处理的张量维度即为 8 × 4 × 256

  • GPT-2 使用了绝对位置编码,可以创建另一个 embedding layer 用于编码位置

  • 将 token 嵌入和位置编码相加得到输入嵌入

如何扩展已有的 BPE 分词器

  • 假设已经有一个 GPT-2 分词器,想要对下述文本进行编码:

  • 遍历每个 token ID 可以发现 MyNewToken_1 被拆分为若干个 token

  • 对于新的词汇而言,必须将新词汇作为特殊标识词添加

    • 原因在于,这些新词汇并不具备分词器训练阶段生成的专属 “合并规则”

    • 即便我们能获取到这些规则,要将其融入现有分词体系且不破坏原有逻辑,也存在极大的实现难度

  • 假设要新增两个 token,具体如下:

  • 新建一个自定义的 Encoding 来处理上述 token

  • 使用新的 Encoding 进行编解码

  • 接下来,要将更新后的分词器与预训练大模型配合使用,还需要同步更新大模型的嵌入层和输出层

  • 首先分别获取原始和新的 tokenizer 处理后的 token IDs

  • 将原始的 token IDs 送入 GPT 模型

  • 那么如果将新的 token IDs 送入 GPT 模型呢?会导致 index out of range

  • 这是由于原始模型的输入和输出所期望的词典大小与新的 token IDs 不一致

  • 首先更新 Embedding Layer

  • 现在,就有了增长后的 Embedding Layer

  • 然后需要对输出层进行扩展,该层与嵌入层的词汇表大小相对应,输出维度为 50257

  • 尝试输入新的 token IDs,不再报错

  • 但是接下来,需要在数据集进行微调(或者继续预训练)来更新新的嵌入层和输出层

Attention

不可训练的注意力实现

  • 借助注意力机制,网络中负责文本生成的 Decoder 部分能够选择性地获取所有输入 token 的信息,这意味着在生成特定输出 token 的过程中,部分输入 token 相较于其他 token 具有更高的重要性

  • 首先介绍自注意力机制的一个高度简化版本,该版本不包含任何可训练权重

  • 此简化版本仅作演示之用,并非Transformer模型中实际使用的注意力机制

  • 假设给定输入序列 $x^{(1)}$ 至 $x^{(T)}$

    • 该输入为文本(例如句子 “Your journey starts with one step”),且已转换为 token 嵌入向量

    • 例如,$x^{(1)}$ 为表示单词 “Your” 的 d 维向量,其余 token 依此类推

  • 目标:为输入序列 $x^{(1)}$ 至 $x^{(T)}$ 中的每个元素 $x^{(i)}$ 计算上下文向量 $z^{(i)}$($z$ 与 $x$ 维度相同),该上下文向量 $z^{(i)}$ 是输入序列 $x^{(1)}$ 至 $x^{(T)}$ 的加权和,具有针对特定输入的 “上下文” 特异性

    • 不再将 $x^{(i)}$ 作为任意输入 token 的占位符,而是以第二个输入 $x^{(2)}$ 为例进行分析

    • 为进一步结合具体实例,也不再使用占位符 $z^{(i)}$,而是聚焦于第二个输出上下文向量 $z^{(2)}$

    • 第二个上下文向量 $z^{(2)}$,是所有输入 $x^{(1)}$ 至 $x^{(T)}$ 以第二个输入元素 $x^{(2)}$ 为参照计算得到的加权和

    • 注意力权重决定了计算 $z^{(2)}$ 时,每个输入元素对该加权和的贡献程度

    • 简言之,可将 $z^{(2)}$ 理解为 $x^{(2)}$​ 的修正版本,该版本同时融合了与当前任务相关的所有其他输入元素的信息

  • 按照惯例,未归一化的注意力权重被称为“注意力分数”,而求和为 1 的归一化注意力分数则被称为“注意力权重”

  • 假设经过嵌入后得到的输入向量如下

  • 第一步,计算注意力分数 $\omega$

    • 假设选取第二个输入 token 作为查询向量,即 $q^{(2)} = x^{(2)}$,通过点积运算得到未归一化的注意力分数:

      • $\omega_{21} = x^{(1)} q^{(2)\top}$

      • $\omega_{22} = x^{(2)} q^{(2)\top}$

      • $\omega_{23} = x^{(3)} q^{(2)\top}$

      • ...

      • $\omega_{2T} = x^{(T)} q^{(2)\top}$

    • 式中的 $\omega_{21}$​ 中的下标 “21”,表示以输入序列中第 2 个元素为查询向量,对第 1 个元素进行相似度计算

  • 第一步的代码如下

  • 第二步,对注意力分数进行归一化

  • 第三步,计算上下文向量。将嵌入输入 token $x^{(i)}$ 与注意力权重相乘,再对相乘得到的向量求和,以此计算上下文向量 $z^{(2)}$

  • 将上述代码扩展到对所有输入 token 的计算

自注意力

  • 与基础注意力机制相比,自注意力仅有细微差别:

    • 最显著的区别是引入了模型训练过程中会不断更新的权重矩阵

    • 这些可训练权重矩阵至关重要 —— 它们能让模型(具体为模型内部的注意力模块)学习到如何生成 “优质的” 上下文向量

  • 首先引入三个可训练权重矩阵 $W_q$、$W_k$ 和 $W_v$,这三个矩阵用于通过矩阵乘法,将嵌入后的输入 token $x^{(i)}$ 分别投影为查询向量、键向量和值向量:

    • 查询向量:$q^{(i)} = x^{(i)},W_q $

    • 键向量:$k^{(i)} = x^{(i)},W_k $

    • 值向量:$v^{(i)} = x^{(i)},W_v $​

  • 输入向量 $x$ 与查询向量 $q$​ 的嵌入维度可相同也可不同,具体取决于模型的设计思路与实际实现方式;GPT系列模型中,这一输入维度与输出维度通常保持一致;但为便于演示说明、让计算过程更易理解,本节将选用不同的输入和输出维度

  • 初始化权重矩阵

  • 第一步,计算 Q/K/V 向量

  • 第二步,计算注意力分数

  • 第三步,进行缩放

  • 第四步,计算上下文向量

实现完整的自注意力

  • 简单的实现

  • 可以借助 PyTorch 的线性层(Linear layers)简化上述实现逻辑(如果不使用偏置,线性层的运算效果就等价于单纯的矩阵乘法)

  • 相较于手动通过 nn.Parameter(torch.rand(...)) 定义权重的方式,使用 nn.Linear 还有一个优势是其内置了经过优化的权重初始化方案,能让模型的训练过程更稳定

  • 由于初始化权重不一样,因此两个实现的输出也是不一样的

因果注意力

  • 在计算得到注意力权重后,使用上三角掩码矩阵,掩盖未来的信息,并再次归一化

  • 使用 tri 函数,创建上三角置 0 掩码矩阵

  • 乘以掩码矩阵

  • 再次归一化

  • 为了方便,可以在由注意力分数归一化计算注意力权重之前,就进行 mask

  • 进行 Dropout 以防止过拟合,为了举例,此处的 dropout 率为 50%,即 Dropout mask 矩阵的 50% 为 1,表示对应注意力分数要被丢弃

  • Dropout 的作用是训练时随机将部分神经元的输出置 0(丢弃率 p ),但直接置 0 会让输出的数学期望降低(比如 p=0.5 时,期望会变成原来的 0.5)

  • 为了抵消这个影响,PyTorch 的nn.Dropout会在训练模式下自动对保留的神经元输出做缩放补偿,公式是

    o=i×11po=i\times\frac{1}{1-p}
  • 在推理模式下,dropout 不会丢弃、也不会缩放

实现完整的因果注意力

  • 为了简便,将输入复制一份,得到 batch 大小为 2 的输入

  • 因果注意力实现

多头注意力 MHA

  • 代码实现

  • 可以实现一个独立的 MultiHeadAttention 类,核心设计思路是先创建整体的 Q/K/V 权重矩阵,再切分成多个头的独立矩阵,而非拼接多个单头注意力的结果,这种实现更高效、更贴合原始 Transformer 论文的设计,也避免了多单头拼接的冗余操作

  • 在上述MultiHeadAttention类中新增了一个线性投影层(self.out_proj);该层仅执行不改变维度的线性变换操作,在大语言模型的实现中,使用此类投影层是一种标准惯例,但它并非严格必需的组件

  • 分析操作 attn_scores = queries @ keys.transpose(2, 3)

  • 在这种情况下,PyTorch 中的矩阵乘法实现会对四维输入张量做适配处理:矩阵乘法会在最后两个维度(令牌数num_tokens、头维度head_dim)之间执行,再为每个独立的注意力头重复该运算

  • 比如,下述写法就成为了一种更简洁的方式,可分别为每个注意力头计算矩阵乘法:

MHA 的高效实现

  • 基于 CausalAttention 单头注意力,通过 nn.ModuleList 并行堆叠多个单头注意力,最后用线性层拼接输出

  • 优化的多头实现,通过矩阵维度拆分( view + transpose )替代多个独立单头,减少冗余计算

  • 将 Q、K、V 的 3 个独立线性层合并为 1 个( nn.Linear(d_in, 3*d_out) ),单次矩阵乘法生成 QKV 组合张量,再通过 unbind 拆分

  • 使用 torch.einsum 实现 Q、K、V 的线性变换和注意力分数计算,代码更简洁且维度关系更直观

  • 调用 PyTorch 的 nn.functional.scaled_dot_product_attention,自动启用 FlashAttention(需 PyTorch 2.0+)—— SDPA

  • 与第 5 种类似,但通过显式传入因果掩码(attn_mask=self.mask)禁用 FlashAttention,使用 PyTorch 原生非优化实现

  • 封装 torch.nn.MultiheadAttention ,默认启用注意力权重计算(need_weights=True

  • 在第 7 种基础上设置 need_weights=False,触发 PyTorch 内部优化(自动调用 scaled_dot_product_attention

  • PyTorch 2.5 + 新增的 flex_attention,支持灵活的注意力掩码和块稀疏计算(FlexAttention: The Flexibility of PyTorch with the Performance of FlashAttention – PyTorcharrow-up-right

  • 对比总结

    实现序号
    核心特点
    性能(GPU 环境)
    适用场景

    1

    并行单头,直观但冗余

    最低

    教学演示、理解基础原理

    2

    标准多头拆分,平衡简洁与效率

    中低

    学习与简单实验

    3

    合并 QKV 权重,减少计算量

    工业级基础实现

    4

    Einsum 语法,维度清晰

    代码可读性优先的场景

    5

    原生 SDPA+FlashAttention

    最高

    追求极致性能的生产环境

    6

    禁用 FlashAttention 的 SDPA

    中高

    性能对比实验、兼容性需求

    7

    PyTorch 标准 MHA(需权重)

    需要注意力权重分析的场景

    8

    PyTorch 标准 MHA(无权重)

    兼顾便利性与性能的场景

    9

    FlexAttention,灵活掩码

    定制化注意力模式(如稀疏注意力)

PyToch

分组查询注意力 GQA

  • GQA 实现

多头潜在注意力 MLA

  • MLA 实现

滑动窗口注意力

  • 滑动窗口注意力实现(Slide Window Attention)

Decode-only Transformer

  • GPT-2 模型配置

  • 下列是模型的主要架构

  • 主要实现流程如下

  • GPT 模型架构如下

  • 测试数据设置

LayerNorm

  • 首先初始化一个简单的神经网络

  • 然后计算平均值与方差

  • 而 LayNorm 被分别应用于每个输入,可以使用 dim=-1 来使得计算应用于最后一个维度 5(也即特征维度)而不是行维度 2

  • 应用 LayNorm 公式

  • 这样,每个输入的平均值变为 0,而方差变为 1

  • 据此,可以实现 LayerNorm 模块

  • 在以上实现中,除了完成归一化操作,还添加了两个可训练参数,即尺度缩放参数(scale)和偏移参数(shift)

    • scale 参数的初始值为 1(即乘以 1),shift 参数的初始值为 0(即加上 0),初始状态下二者无任何作用

    • 但这两个参数为可训练参数,若大语言模型判定调整其数值能够提升模型在训练任务上的表现,会在训练过程中自动完成参数调优

    • 这一设计让模型能够学习到与所处理数据最适配的 scale 和 shift 方式

    • 在计算方差的平方根前,还会加入一个极小值 $eps$,避免方差为 0 时出现除零错误

  • 有偏方差

    • 在上述方差计算过程中,将 unbiased 设为 False,表示采用公式 $\frac{\sum_i(x_i-\bar{x})^2}{n}$ 计算方差(其中 $n$ 为样本量,即特征数或列数)

    • 该公式未引入贝塞尔校正(即把分母设为 $n-1$​),因此得到的是方差的有偏估计值

    • 对于大语言模型而言,其嵌入维度 $n$ 通常极大,使用 $n$ 和 $n−1$ 作为分母计算出的结果差异微乎其微

    • 但 GPT-2 模型的归一化层在训练时采用的是有偏方差计算方式,后续内容中会加载预训练权重,为保证与该权重的兼容性,此处也沿用了这一设置

  • LayerNorm 使用

FFN with GELU

  • 在深度学习领域,ReLU(Rectified Linear Unit)激活函数因实现简洁,且在各类神经网络结构中均能取得良好效果,成为了应用最广泛的激活函数之一

  • 而在大语言模型中,除了传统的 ReLU,还会用到多种其他类型的激活函数;其中最具代表性的两种是GELU(Gaussian Error Linear Unit)和 SwiGLU(Swish-Gated Linear Unit)

  • GELU 和 SwiGLU 是更为复杂的平滑型激活函数——前者融入了高斯分布相关计算,后者则结合了 Sigmoid 门控线性单元,二者均能为深度学习模型带来更优的性能表现;这一点与结构简单、为分段线性函数的 ReLU 形成了鲜明对比

  • GELU 激活函数有多种实现方式,其标准定义如下,其中$Φ(x)$代表标准高斯分布的累积分布函数

    GELU(x)=xΦ(x)\text{GELU}(x)=x\cdot\Phi(x)
  • 实际工程中,人们通常会采用一种计算成本更低的近似实现方式:

    GELU(x)0.5x(1+tanh[2π(x+0.044715x3)])\text{GELU}(x) \approx 0.5 \cdot x \cdot \left(1 + \tanh\left[\sqrt{\frac{2}{\pi}} \cdot \left(x + 0.044715 \cdot x^3\right)\right]\right)
  • GELU 实现

  • GELU 与 ReLU 激活函数的对比

  • GELU 与 ReLU 对比图

image-20260129221958108

  • 可见,ReLU 是一种分段线性函数:若输入值为正,函数会直接输出该输入值;若输入值非正,则输出 0;而 GELU 是一种平滑的非线性函数,其效果与 ReLU 近似,但针对负值输入(约 - 0.75 处除外),能输出非零的梯度值

  • 定义 FFN 层

  • FFN 层的可视化

残差连接

  • 捷径连接(shortcut connections):该机制也被称作跳跃连接(skip connections)或残差连接(residual connections)

  • 残差连接最初被提出用于计算机视觉领域的深度网络(残差网络),核心目的是缓解梯度消失问题;这一连接机制能为梯度在网络中的传播,搭建一条更短的备选路径

  • 其实现方式为:将某一层的输出,与后续某一层的输出做加和运算,通常会跳过二者之间的一个或多个网络层

  • 一个小型的网络示例:

  • 其代码示例如下

  • 没有残差连接的梯度值

  • 有残差连接的梯度值

  • 从上述输出结果可以看出,残差连接能够避免梯度在浅层网络(靠近layer.0的层)中发生消失

TransformerBlock

  • 代码实现

  • 该 TransformerBlock 的可视化如下

  • 假设有 2 个输入样本,每个样本包含 6 个 token ,且每个 token 对应一个 768 维的嵌入向量;该 Transformer 块会先执行自注意力计算,再经过线性层处理,最终生成维度规模相近的输出

GPTModel

  • 代码实现

  • 初始化默认的权重

  • 统计参数量

  • 可以看到,参数量为 163 M,而不是 124 M,这是为何?

  • 在 GPT-2 的原始论文中,研究人员采用了 权重共享(weight tying)策略,即将 token 嵌入层(tok_emb)的权重复用为输出层的权重,也就是令self.out_head.weight = self.tok_emb.weight

  • token 嵌入层的作用是,将 50257 维的独热编码输入 token ,映射为 768 维的嵌入表示;而输出层则是将 768 维的嵌入表示,反向映射回 50257 维的表示,这样我们就能把这些表示还原为文字

  • 因此,从权重矩阵的形状就能看出,嵌入层和输出层的权重参数量完全相同

  • 这个数值可以通过以下方式再次验证:

  • 计算显存占用量

  • 计算 FFN 和 Attention 的参数量

  • 具体计算(假设嵌入维度 emb_dim=768 )

    • 前馈网络模块

      • 第一层线性层:768 个输入 × 4×768 个输出 + 4×768 个偏置单元 = 2,362,368

      • 第二层线性层:4×768 个输入 × 768 个输出 + 768 个偏置单元 = 2,360,064

      • 总计:第一层线性层 + 第二层线性层 = 2,362,368 + 2,360,064 = 4,722,432

    • 注意力模块

      • 查询权重矩阵(W_query):768 个输入 × 768 个输出 = 589,824

      • 键权重矩阵(W_key):768 个输入 × 768 个输出 = 589,824

      • 值权重矩阵(W_value):768 个输入 × 768 个输出 = 589,824

      • 输出投影层(out_proj):768 个输入 × 768 个输出 + 768 个偏置单元 = 590,592

      • 总计:W_query + W_key + W_value + out_proj = 3×589,824 + 590,592 = 2,360,064

  • 为不同的模块使用不同的 Dropout 率

  • GPT-2 系列模型的配置

    模型版本
    参数量规模
    嵌入维度(emb_dim)
    网络层数(n_layers)
    注意力头数(n_heads)

    GPT2-small

    124M(已实现)

    768

    12

    12

    GPT2-medium

    -

    1024

    24

    16

    GPT2-large

    -

    1280

    36

    20

    GPT2-XL

    -

    1600

    48

    25

生成文本

  • 贪心解码的逻辑为:生成过程的每一步,模型都会选择概率最高的单词(或词元)作为下一个输出结果(分值最高的对数几率(logit)对应最高的概率,因此从技术角度来说,甚至无需显式计算 softmax 函数)

  • generate_text_simple 函数实现了贪心解码(greedy decoding),这是一种简单高效的文本生成方法

  • 输入示例

  • 输出示例

  • 这个模型还没有被训练过,因此输出到文本是随机的

KV Cache

  • 修改 MHA 部分

  • 修改 TransformerBlock

  • 修改 GPTModel

  • 修改生成文本函数

MoE 实现

  • 实现 MoE 层,这其中就包括了门控网络和

  • 修改 TransformerBlock

Pretrain

准备工作

  • 首先初始化 GPT 模型

  • 以上配置将 Dropout 率设为 0.1,但如今训练大语言模型时,已经普遍不使用 dropout

  • 现代大语言模型在查询、键、值矩阵对应的线性层(nn.Linear)中,也不再使用偏置向量(这一点与早期 GPT 模型不同),只需将配置项"qkv_bias"设为False即可实现

  • 为降低模型训练对计算资源的要求,将上下文长度(context_length)仅设为 256 个 token,而原版 1.24 亿参数量的 GPT-2 模型,其上下文长度为 1024 个 token

  • 整体流程如下

  • 实现两个工具函数

  • 从上述结果可以看出,该模型目前生成的文本效果较差,原因是模型尚未经过训练

  • 那么该如何将 “优质文本” 的评判标准转化为数值形式,以便在训练过程中对其进行跟踪评估?

损失计算

  • 假设有包含 2 个训练样本的输入张量 inputs,相应的有包含想要模型输出的 token ID 对应的 targets

  • 将输入张量inputs送入模型后,会得到 2 个输入样本对应的 logits 值向量,每个样本均包含 3 个 token

  • 模型为每个 token 输出一个 50257 维的向量,该维度与词表的大小保持一致

  • 对 logits 值张量应用 softmax 函数后,就能将其转换为同维度的概率得分张量

  • 上述流程可视化如下

  • 可以通过调用argmax函数,将这些概率得分转换为模型预测的 token 编号(ID)

  • softmax 函数为每个 token 生成了一个 50257 维的概率向量,而argmax函数会返回该向量中概率得分最大值对应的索引位置,这个位置就是对应 token 的预测编号(ID)

  • 解码这些 token ,可以看到完全不是我们想要模型预测的

  • 为了训练模型,需要知道当前的模型与正确的预测即 targets 差距有多大

  • targets 对应的 logits 概率如下

  • 那么即希望让所有这些概率值尽可能取到最大值,使其无限接近 1

  • 在数学优化中,最大化概率得分的对数,比直接最大化概率得分本身更易实现

  • 其平均的对数概率为

  • 目标是通过优化模型权重,让这个平均对数概率尽可能取到最大值

  • 由于取了对数,该值的理论最大值为 0,而当前的计算结果与 0 仍相去甚远

  • 在深度学习中,标准做法并非最大化平均对数概率,而是最小化其负值

  • 对于这个例子,不会去最大化 - 10.7722 以使其趋近于 0,而是通过最小化 10.7722,实现同样的趋近于 0 的目标

  • 这里 - 10.7722 的负值,也就是 10.7722,在深度学习中也被称作交叉熵损失

  • 计算交叉熵损失

  • 需注意,这里的目标值是 token 编号(ID),而这些编号恰好对应希望在 logits 张量中最大化的那些索引位置

  • PyTorch 中的cross_entropy函数会自动完成一系列操作:在逻辑值张量中,针对这些需要最大化的 token 索引,内部依次执行 softmax 归一化和对数概率的计算

  • 与交叉熵损失相关的一个概念是大语言模型的困惑度(perplexity)

  • 困惑度的计算方式十分简单,就是对交叉熵损失取指数

  • 困惑度的可解释性通常更强,因为它可以理解为模型在每一步预测时,对有效词表规模的不确定程度(在上述例子中,这一数值约为 48725 个单词或 token)

  • 换言之,困惑度用于衡量模型预测的概率分布,与数据集中真实的词汇概率分布的匹配程度

  • 与损失值类似,困惑度越低,说明模型的预测分布与真实分布越接近

训练集和验证集的损失计算

  • 这次训练大语言模型,选用的数据集规模相对较小(实际上,仅用了一篇短篇故事)

  • 举个例子,Llama 2 7B 模型在 2 万亿个 token 的数据集上完成训练,耗费了 A100 显卡 184320 个 GPU 小时;亚马逊云服务(AWS)上 8 卡 A100 云服务器的每小时租赁费用约为 30 美元;粗略估算下来,训练该大语言模型的成本约为:184320 ÷ 8 × 30 = 69 万美元

  • 加载原始数据

  • 将数据集划分为训练集和验证集,并使用之前实现的数据加载器,为大语言模型的训练准备批次数据

  • 为便于可视化展示,下图中假定最大序列长度 max_length=6;而实际训练用的数据加载器中,会将 max_length 设为与大语言模型支持的上下文长度保持一致;为简化展示,下图仅呈现了输入的 token 序列

  • 由于训练大语言模型的目标是预测文本中的下一个词,因此目标序列与输入序列的内容完全一致,仅在位置上整体后移一位

  • 这里选用了相对较小的批次大小,一来是为了降低对计算资源的要求,二来也是因为本就使用了体量极小的数据集

  • 以 Llama 2 7B 模型为例,其训练时所采用的批次大小为 1024

  • 实现计算损失的工具函数

  • 如果计算设备搭载了支持 CUDA 的 GPU,无需对代码做任何修改,大语言模型就会自动在 GPU 上完成训练;可与通过 device 这个配置项来确保,训练数据会被加载到与大语言模型相同的计算设备上

  • 计算初始损失

开始训练

  • 简易的训练代码

  • 进行训练

  • 训练输出如下

  • 绘制损失曲线

  • 损失曲线如下:

    build-llm-from-scratch-loss-curve
  • 从上述训练结果能看出,模型训练初期生成的是毫无意义的词汇组合,而训练后期已经能生成语法基本通顺的句子

  • 但从训练集和验证集的损失值变化中,能发现模型出现了过拟合的现象

  • 倘若查看模型训练后期生成的几段文本,会发现这些内容完全照搬了训练集中的原文 —— 也就是说,模型只是单纯记住了训练数据而已,而不同的解码策略能在一定程度上缓解这种数据记忆的问题

  • 这次训练出现过拟合,根源在于使用的训练集规模极小,且对数据集进行了大量的迭代训练

解码策略

  • 贪心策略:即便多次运行 generate_text_simple` 函数,这个大语言模型生成的结果也始终是完全相同的

  • 此前,一直通过 torch.argmax 函数,选取概率最高的 token 作为下一个生成的 token;若要增加生成文本的多样性,可以改用 torch.multinomial(probs, num_samples=1) 函数,从模型输出的概率分布中随机采样得到下一个 token

  • 在这个采样过程中,每个索引被选中的概率,与它在输入张量中对应的概率值完全一致

  • 以一个简单例子示例

  • 采样输出

  • 采样 1000 次的结果

  • 可以通过温度缩放这一方法,来调控概率分布的形态与 token 的选择过程

    • “温度缩放” 其实只是个专业说法,它的本质操作就是将模型输出的 logits 除以一个大于 0 的数值

    • 当温度值大于 1 时,经 softmax 归一化后,各 token 的概率分布会变得更均匀

    • 当温度值小于 1 时,经 softmax 归一化后,得到的概率分布会更具倾向性(分布曲线更陡峭、峰值更突出)

  • 温度缩放示例

  • 输出可视化如下:

    image-20260130001636811
  • 可以看到,将温度值设为 0.1 做重新缩放后,得到的概率分布会更加陡峭,其效果已接近 torch.argmax 的贪心选择 —— 此时模型几乎每次都会选中那个最可能的 token

  • 假设给大模型的输入是 “every effort moves you”,采用上述方法生成文本时,偶尔会出现毫无意义的内容;比如生成 “every effort moves you pizza” 这类表述的概率为 3.2%(1000 次生成中有 32 次出现该情况)

  • Tok-k 采样

  • 新的生成文本函数

  • 生成新的文本

保存和加载权重

  • 保存权重

  • 加载权重

  • 保存优化器状态,以便于继续进行预训练

  • 加载优化器状态

  • 下载 GPT 模型

  • 不同模型的参数对比

  • 接下来,需要把 GPT-2(124M)的预训练权重加载到自定义的 GPTModel 实例中

    • 初始化 GPTModel 时需开启 qkv_bias=True(匹配原 GPT-2 多头注意力的 Q/K/V 线性层带偏置的设计)

    • 使用原模型的 1024 token 上下文长度

    • 先完成模型实例化再做权重迁移

  • 初始化模型

  • 下一步,将 GPT-2 的权重加载到 GPTModel 实例中

  • 可以看到 GPT 模型的模型结构

  • 接下来即可生成文本

Llama 2

  • GPT-2 模型和 Llama 模型结构对比

  • 三个模型的具体对比表

    模型对比维度
    GPT-2-XL 1.5B
    Llama2-7B
    Llama3-8B

    Dropout 层

    保留

    删去

    沿用删去逻辑

    归一化层

    LayerNorm

    RMSNorm

    沿用 RMSNorm

    词典大小

    50257

    32000

    沿用 32000

    MHA 头数

    25

    32

    32(改为 GQA)

    TransformerBlock 数

    48

    32

    沿用 32

    位置编码

    1024 绝对位置编码

    4096 RoPE(绝对+相对)

    8192 RoPE(修改后的 RoPE)

    嵌入维度

    1600

    4096

    沿用 4096

    FFN 激活函数

    GELU

    SiLU/Swish

    沿用 SiLU/Swish

    FFN 隐藏层维度

    6400

    11008

    14336

    FFN 结构

    基础线性层

    添加额外线性层作为门控

    沿用门控结构

    MHA 机制

    标准 MHA

    标准 MHA

    GQA(分组查询注意力)

RMSNorm

  • 首先,将层归一化(LayerNorm)替换为均方根层归一化(RMSNorm)

  • 层归一化会利用均值和方差对输入做归一化处理,而均方根层归一化仅使用均方根进行计算,这一改进提升了计算效率

  • 均方根层归一化的运算公式如下,其中$x$为输入,$\gamma$为可训练参数(向量),$\epsilon$为用于避免除零错误的小常数:

    yi=xiRMS(x)γi,其中RMS(x)=ϵ+1nxi2y_i = \frac{x_i}{\text{RMS}(x)} \gamma_i, \quad \text{其中} \quad \text{RMS}(x) = \sqrt{\epsilon + \frac{1}{n} \sum x_i^2}
  • RMSNorm 的实现

SiLU

  • 替换 GELU 激活函数为 SiLU 激活函数,也被称作Swish函数,公式如下:

    silu(x)=xσ(x),其中σ(x)为 Sigmoid函数\text{silu}(x) = x \cdot \sigma(x), \quad \text{其中} \sigma(x) \text{为 Sigmoid函数}
  • SiLU 的代码实现

修改 FFN 层

  • Llama 使用了 SiLU 的一个 GLU 变体,即 SwiGLU,其公式如下

    SwiGLU(x)=SiLU(Linear1(x))(Linear2(x))\text{SwiGLU}(x) = \text{SiLU}(\text{Linear}_1(x)) * (\text{Linear}_2(x))
  • 这里有两个线性层,* 表示逐元素乘法,而第三个线性层被应用于门控之后

  • FFN 的新实现

  • 需注意,在上述代码中新增了 dtype=cfg["dtype"] 配置项,这一设置能在后续直接以低精度格式加载模型,从而降低内存占用(相比先以原始 32 位精度实例化模型、再进行精度转换的方式更高效)

  • 同时将 bias 设为 False,因为 Llama 模型的所有层均未使用偏置单元

实现 RoPE

  • 在 GPT 模型中,位置嵌入的实现方式如下:

  • 与传统的绝对位置嵌入不同,Llama 模型采用旋转位置嵌入(RoPE),该方式能让模型同时捕捉绝对位置信息与相对位置信息

  • RoPE 存在两种数学等价的实现方式:分半式实现(split-halves)与奇偶交错式实现(interleaved even/odd);只要维度配对方式一致、正余弦排序规则相同,两种方式的数学效果完全一致

  • 本代码采用的是 RoPE 分半式实现方案,与 Hugging Face Transformers 库的实现逻辑一致

  • 而 RoPE 原始论文与 Meta 官方的 Llama 2 代码仓库,则采用奇偶交错式实现方案

  • 代码实现如下

  • 然后将 RoPE 应用与 Q/K 张量

  • 将 RoPE 添加到 MHA 中

  • 需注意:GPT 模型是将位置嵌入直接作用于输入向量,而 Llama 模型则是在自注意力机制内部,对查询(query)和键(key)向量执行旋转变换

  • 同时,移除了 qkv_bias 配置,并将 bias=False 直接硬编码为固定设置

  • 此外,新增了精度(dtype)配置,确保后续能以低精度格式实例化模型

  • 由于下文中要实现的 TransformerBlock 是完全重复堆叠的,本可以对代码做简化处理 —— 只需初始化一次 RoPE 相关缓冲区,而非为每个多头注意力模块都重复初始化;但这里仍将预计算好的 RoPE 参数集成到 MultiHeadAttention 类中,这样该类就能作为独立模块正常工作

  • 代码实现

  • MHA 的使用示例

TransformerBlock

  • 主要工作包括:替换 LayerNorm 为 RMSNorm、删除 Dropout、删除 qkv_bias 配置、添加 dtype 配置

Llama2Model

  • 主要工作包括:删除绝对位置编码、替换 LayerNorm 为 RMSNorm、删除 Dropout、添加 dtype 配置

初始化模型

  • Llama2 模型配置

  • 初始化模型

  • 该模型包含 67 亿个参数(行业中通常取整,称其为 70 亿参数模型 / 7B 模型)

  • 可以计算模型所需显存

  • 将模型转移到 GPU 中

加载 Tokenizer

  • Llama2 使用 SentencePiece 来创建 Tokenizer;但是 Llama3 使用了 Tiktoken

  • 实现一个 Wrapper 类

  • 下载 tokenizer

  • 生成文本

  • 由于模型还没有训练,因此其输出是没有任何语义的

加载模型权重

  • 下载模型权重

  • 类似 GPT 模型,加载模型权重

  • 使用加载后的模型重新生成文本

  • 也可以使用指令微调后的模型

Llama 3

RoPE

  • Llama 3 采用了与 Llama 2 相似的旋转位置编码(RoPE),但二者的旋转位置编码配置存在一些细微差异:

    • Llama 3 的上下文窗口支持的 token 数上限提升至 8192 个,是 Llama 2(4096 个)的两倍

    • 下述公式中,旋转位置编码的基准值 $\theta$ 已从 Llama 2 的 10000 上调至 Llama 3 的 500000

      Θ={θi=base2(i1)d,i[1,2,...,d/2]}\Theta = \left\{\theta_i = \text{base}^{\frac{-2(i-1)}{d}}, i \in \left[1, 2, ..., d/2\right]\right\}
    • 上述 $\theta$ 值为一组预定义参数,用于确定旋转矩阵中的旋转角度,其中$d$代表嵌入空间的维度

  • 将基准值从 10000 提升至 500000,会让频率随维度的增加衰减得更快,这意味着高维分量对应的旋转角度相较以往更小,因此整体的频率范围会呈现压缩趋势,而非扩展趋势

  • 运行可视化如下:

    build-llm-from-scratch-rope-theta
  • 调整后的这一衰减率,是为了更好地支持更长的上下文长度,避免高维度(更远位置)的旋转操作幅度过大、作用过强

  • 此外,在下方代码中新增了一个 freq_config 配置段,用于调节频率;但该配置在 Llama 3 中暂未启用(仅适用于 Llama 3.1 和 Llama 3.2 版本),因此该配置默认设为None,会被直接忽略

  • Llama 2 与 Llama 3 的改动对比如下

  • 使用方法与 Llama 2 类似

GQA

  • 下列还对注意力机制做了重新设计:让该类通过前向传播方法接收掩码,而非将掩码存储为 self.mask 并通过该属性调用,这样就能动态生成掩码,从而降低内存占用

  • 这样设计的初衷:Llama 3.1 支持处理长度高达 128k 的 token 序列,而预计算一个 128k×128k 的因果掩码会耗费极大的内存,因此除非确有必要,都会避免这种预计算的方式

  • 实现代码

  • 考虑 MHA 与 GQA 的 Q/K/V 矩阵大小对比

  • 可以看到,由于一共有 32 个头,8 个 KV 组,因此每 4 个头共享一个 K/V 矩阵,W_key 和 W_value 也相应的是 MHA 的四分之一

TranformerBlock

  • 直接将 MHA 替换为 GQA 即可

Llama3Model

  • 传递 maskcossin

  • 初始化模型

Tokenizer

  • Llama 3 恢复使用了 Tiktoken 中的 BPE 分词器

  • 下载并加载 tokenizer

加载模型权重

  • 下载并加载模型权重

  • 加载模型权重

  • 生成文本

  • 下载指令微调版本的模型

  • Llama 3 模型的理想使用方式是搭配其微调阶段所用的标准提示词模板,因此需要定义聊天模板

  • 启用聊天模板后的编码解码

  • 可以看到,通过聊天模板,原先的输入,会被处理为新的包含角色定义的格式

  • 生成具体的文本

Llama 3.1 8B

  • Llama 3 和 Llama 3.1 的对比

  • 模型对比

    • RoPE 替换为 RoPE rescaling,支持 131K 的上下文大小

  • 模型配置区别

  • 如前文所述,旋转位置编码(RoPE)方法通过正弦函数(正弦与余弦),将位置信息直接嵌入至注意力机制中

  • 在 Llama 3.1 中,通过新增的配置项,对逆频率(inverse frequency)的计算逻辑做了额外调整,这些调整会影响不同频率分量对位置编码的作用方式

Llama 3.2 1B

  • Llama 3.2 文本模型的代码与 Llama 3.1 基本一致,仅在模型体量上做了轻量化调整(目前提供 10 亿和 30 亿参数两个版本)

  • 另一项针对性能效率的优化是,Meta 重新启用了权重共享机制,即模型的输入嵌入层与输出层会复用同一组权重参数值

  • 下图直观展示了 Llama 3.1 8B 与 Llama 3.2 1B 在模型架构上的差异

Qwen3

  • Qwen3 0.6B 和 Llama 3.2 1B 的对比

  • 模型对比

    • RoPE 的上下文长度为 41K

    • 一共堆叠了 28 个 TransformerBlock,FFN 的隐藏层维度为 3072

    • token 嵌入维度为 1024

  • Qwen 系列模型包含 基础版(base)和混合版(hybrid)两款模型,其中混合版可灵活切换为推理专用模式,或常规的指令遵循模式:

    • base:预训练基础模型,但 Qwen3 的预训练阶段已融入部分推理相关数据(思维链数据),因此即便未经过专门的推理训练(强化学习)阶段,该模型有时也会生成推理轨迹

    • hybrid

      • reasoning(推理模式):会在 <think></think> 标签内生成完整的长推理轨迹;

      • instruct(指令模式):基础逻辑与推理模式一致,可通过手动添加空的 <think></think> 标签抑制长推理轨迹的生成(该操作由分词器自动完成);通过这种方式,模型即可表现为常规的指令遵循模型

  • Qwen 3 模型实现

  • Qwen3 模型配置

  • 总参数量

  • 加载预训练权重

  • 下载权重并加载

  • 加载 Tokenizer

  • 其中,_SPECIALS 表示 Qwen3 模型的特殊 token 列表:

    • 基础文本 token:<|endoftext|>(文本结束 / 填充标记,即基础版模型的 eos/pad)

    • 聊天交互 token:<|im_start|>/<|im_end|>(指令交互的开始 / 结束,指令遵循模式核心)

    • 多模态 token:<|vision_start|>/<|vision_end|><|image_pad|>等(视觉 / 图像 / 视频相关,支持图文 / 视频理解)

    • 推理专用 token:<think></think>(推理轨迹包裹 token,是混合模型推理模式的核心)

    • 其他:<|box_start|> 等为视觉目标检测相关 token,适配多模态场景

  • _SPLIT_RE:正则表达式编译对象,核心作用是精准拆分文本中的特殊标记和普通文本,匹配规则:

    • <\|[^>]+?\|>:匹配<|xxx|>格式的特殊标记(非贪婪匹配,避免跨标记匹配);

    • |<|FunctionCallEnd|>:单独匹配推理专用的无尖括号标记;拆分后能保证特殊标记不被基础分词器切分,保留完整语义

  • 流式生成文本

Qwen3 MoE

  • Qwen3 MoE 架构如下

  • MoE 模块实现

  • 模型实现

  • MoE 模型配置

  • 计算模型权重

  • 加载模型权重

Gemma3

  • Gemma3 与 Qwen3 模型对比

  • 主要区别如下

    • FFN 层的激活函数依旧使用 GeLU

    • 注意力机制采用了滑动窗口的 GQA 实现,且 5 局部滑动窗口 : 1 全局全注意力,即每 5 层局部滑动窗口注意力后接 1 层全局全注意力,首层为局部层

    • 在 Attention 之后残差之前与 FFN 之后残差之前添加 Post-RMSNorm 层

  • 注意力机制实现

    • 局部层(Local):采用 1024 - token 滑动窗口,每个 token 仅关注当前位置 ±512 范围,计算复杂度 O (n・w)(w=1024),KV Cache 按窗口环形更新,显存占用低

    • 全局层(Global):全序列建模长程依赖,RoPE 基频 1M 适配 32K 上下文外推,KV 缓存完整保留全序列,确保跨窗口信息连通

    • 层序示意:L0 (局部)→L1 (局部)→L2 (局部)→L3 (局部)→L4 (局部)→L5 (全局)→L6 (局部)→L7 (局部)→L8 (局部)→L9 (局部)→L10 (局部)→L11 (全局)

  • GQA 实现不变,而是在 TransformerBlock 中实现不同的 mask(local vs. global)

  • 在 Gemma3Model 中,根据 sliding_window 来创建不同的 mask

  • 在模型配置中,指定每一层 TransformerBlock 用滑动窗口还是完整 Attention

  • 初始化模型后可以看到其模型结构

  • 模型的参数量计算

Olmo 3

  • Olmo 3 模型对比

  • Olmo 3 模型的训练路线

  • YaRN 风格的 RoPE实现

  • 为什么需要 YaRN

    • 原始 RoPE(LLama 等模型默认使用)的设计存在上下文长度限制

    • 模型训练时的最大上下文(比如 LLama 2 是 4096)是 RoPE 频率参数的 “设计上限”,如果直接把推理上下文扩展到 8192/16384 等更长的长度,会出现两个核心问题:

      • 频率压缩过度:直接缩放 RoPE 频率会导致低频维度(对应长距离依赖)的旋转信息丢失,模型无法有效捕捉长文本的语义关联

      • 上下文混淆:超长序列中,不同位置的 RoPE 旋转角度变得过于相似,模型无法区分位置,出现 “记混前文” 的现象

    • 简单来说,原始 RoPE 硬扩上下文,会导致长文本理解能力大幅下降

    • YaRN(Yet another RoPE extension method)的核心是分维度混合插值 + 外推的频率缩放策略,而非对所有维度用同一缩放系数,核心解决了 “长上下文扩展时频率缩放的维度不均衡问题”

    • RoPE 的 head_dim 是按频率高低分维度的:

      • 前半部分维度(高频):对应短距离依赖(比如词内、相邻词的关联),对缩放更敏感,适合插值(Interpolation)(压缩频率,适配更长序列);

      • 后半部分维度(低频):对应长距离依赖(比如段落间、上下文的关联),对缩放不敏感,适合外推(Extrapolation)(不压缩频率,保留长距离旋转信息)

    • YaRN 就是让不同维度的 RoPE 频率,按 “高频插值、低频外推” 的规则平滑混合,既适配长上下文,又不丢失短 / 长距离的位置信息

  • Olmo 3 模型的配置如下

文本分类微调

  • 微调语言模型的主要途径包括指令微调和分类微调

    对比维度
    文本分类微调
    指令微调(Instruction Tuning)

    核心目标

    让模型学习文本到预定义类别的映射,完成判别式任务

    让模型学习理解人类指令意图,并生成符合指令要求、格式规范的自然语言响应,完成生成式任务

    数据形式

    输入为单文本/文本对,标签为预定义离散类别(如情感分类的{正面,负面,中性}、文本匹配的{匹配,不匹配})

    输入为指令+输入(可选) 的组合,输出为人工标注的自然语言答案(无固定类别,如指令“总结下文”+文本,输出为文本总结)

    模型输出

    离散的类别标签/类别概率分布(如[0.05, 0.9, 0.05]对应负面、正面、中性)

    连续的自然语言序列(可长可短,如问答的短句、摘要的长句、创作的段落)

    任务性质

    判别式任务(分类、回归、匹配,属于“判断”类)

    生成式任务(遵循指令的开放式/半开放式生成,属于“创作/回答”类)

    损失函数

    多采用交叉熵损失(针对类别概率的监督损失),目标是最小化预测类别与真实类别的差距

    多采用自回归负对数似然损失(NLLL),目标是最小化模型生成序列与真实答案序列的字符/词级差距

    模型能力导向

    强化模型对文本特征的判别能力,仅适配单一/少数分类任务,泛化性极窄

    强化模型对自然语言指令的理解和执行能力,适配多类生成任务,泛化性极广(可零样本/少样本适配新指令任务)

    输入输出范式

    范式固定且单一(如[CLS]文本[SEP]→ 类别),不同分类任务范式可能不同

    范式统一为指令范式(如指令:XXX 输入:XXX 输出:XXX),所有任务复用同一范式

    微调后模型形态

    仍为分类模型,无法直接做生成任务,任务间无法迁移

    仍为生成模型,可兼容大模型原生的生成能力,指令任务间可快速迁移

  • 即在分类微调中,模型能输出的类别标签数量是固定的

  • 经分类微调的模型,只能预测其在训练过程中见过的类别(比如仅能判断 “垃圾信息” 或 “非垃圾信息”),而经指令微调的模型通常可完成多种任务

  • 分类微调的主要流程如下

数据集准备

  • 首先加载数据集

  • 为了简化,对数据集进行子采样(下采样),使每个类别最终都保留 747 个样本

  • 除了下采样之外,还有多种方法可用于处理类别不平衡问题

  • 将原始数据集随即划分为训练集、验证集和测试集

  • 创建数据加载器

  • 在原始数据集中,文本消息的长度各不相同;若要将多个训练样本组合为一个批次,只能选择以下两种方式之一:

    • 将所有消息截断至数据集或当前批次中最短消息的长度

    • 为所有消息做填充,使其长度匹配数据集或当前批次中最长消息的长度

    • 这里选择第二种方式,将所有消息填充至数据集中最长消息的长度,并使用<|endoftext|>作为填充标记

  • 创建数据集

  • 这里,也会将验证集和测试集的文本填充至训练集最长序列的长度

  • 需要注意的是,验证集和测试集中那些长度超过训练集最长样本的文本,会在SpamDataset 类中通过代码 encoded_text[:self.max_length] 进行截断处理

  • 现在,使用数据集初始化 DataLoader

修改模型

  • 首先加载初始模型,并测试是否能判别文本类型

  • 可以看到模型并不能很好地遵循指令,为了让其适配分类微调任务,还需要添加分类头

  • 示意如下

  • 由于本次的目标是替换并微调模型的输出层,要实现这一目标,首先需要冻结模型 —— 也就是将模型的所有层设置为不可训练状态

  • 接下来,替换模型的输出层(model.out_head)—— 该层原本负责将上层输入映射至 50257 维(即词汇表的尺寸)

  • 由于是为二分类任务微调模型(预测垃圾信息、非垃圾信息两个类别),因此可按如下方式替换输出层,该新输出层默认处于可训练状态

  • 这里使用了 BASE_CONFIG["emb_dim"](在 GPT2-small(124M 参数量)模型中,该值为 768)

  • 从技术角度来说,仅训练输出层其实就已满足需求,但实验结果表明微调额外的网络层能显著提升模型性能,因此这里还会将最后一个 Transformer 块,以及将最后一个 Transformer 块与输出层的 Final LayerNorm 层也设置为可训练状态

  • 此时的输出维度变为 2,而非词典的大小

  • 其中,输出的每一行都是一个输入对应的输出,维度为 2,分别表示正负类的概率

  • 注意力该机制会让每个输入token 与其他所有输入 token 建立关联,而基于因果注意力机制,第四个(即最后一个)token 包含了所有 token 中最丰富的信息,因为它是唯一能整合所有其他 token 信息的 token;因此,后续会重点关注最后的 token,并基于它来微调模型,完成垃圾信息分类任务

损失计算

  • 对最后一个 token 对应的输出进行 softmax 即可用于判别二分类任务

  • 示意图如下

  • softmax 函数在此处为可选操作,因为输出值中数值最大的那个,就对应着概率得分最高的类别

  • 计算准确率

  • 可以计算得到现在在三个数据集上的准确率

  • 在开始微调(/ 训练)之前,首先需要定义训练过程中要优化的损失函数

  • 而分类任务的目标是最大化模型在垃圾信息分类任务上的准确率,但分类准确率并非可微函数

  • 因此通过最小化交叉熵损失来间接实现分类准确率的最大化

  • 此处的 calc_loss_batch 函数与第五章中的实现基本一致,唯一区别是:我们仅针对最后一个 token 的输出 model(input_batch)[:, -1, :] 做优化,而非针对所有 token 的输出 model(input_batch)

  • 计算损失函数

  • 计算三个数据集的损失

微调模型

  • 微调模型的步骤如下

  • 训练代码

  • 进行训练

  • 训练结果如下

  • 训练损失如下

    build-llm-from-scratch-classification-finetuning-loss
  • 从上图的损失下降曲线中可以看出,模型的学习效果良好;训练损失与验证损失高度接近这一结果表明,模型并未出现过拟合训练数据的趋势

  • 准确率曲线

    build-llm-from-scratch-classification-finetuning-accuracy
  • 模型在第 4、5 个训练轮次后,在训练集和验证集上均取得了相当高的准确率

  • 但此前在训练函数中设置了 eval_iter=5,这意味着之前对训练集和验证集性能的评估,都只是粗略估计值(并非全量数据集评估)

  • 完整的准确率如下

  • 模型在训练集和验证集上的性能表现几乎完全一致,但从测试集的性能略低这一结果可以看出,模型对训练数据出现了轻微的过拟合;同时,由于验证集被用于调优学习率等超参数,模型对验证数据也产生了一定的过拟合

  • 不过可以通过提高模型的丢弃率(drop_rate)或优化器配置中的权重衰减(weight_decay),进一步缩小训练 / 验证集与测试集之间的性能差距

指令微调

  • 指令微调的流程

build-llm-from-scratch-instruction-finetuning-steps

准备数据集

  • 指令数据集的格式如下

  • 指令微调(Instruction Finetuning)通常被称为 “监督指令微调(Supervised Instruction Finetuning)”,这是因为该过程需要在一个明确提供输入 - 输出对的数据集上对模型进行训练

  • 有多种方式可以将数据集中的条目格式化为大语言模型可接受的输入,如用于训练 Alpaca 和 Phi-3 的格式如下

  • 下面,使用 Alpaca 风格来格式化数据集

  • 首先划分数据集

  • 然后转为批次

  • Dataset 的实现如下

  • Dataset 代码如下

  • 前面的实现会对数据集中的所有样本都进行了填充,使其长度统一,而这里采用一种更复杂的方法——设计一个可传递给数据加载器(data loader)的自定义 “整理(collate)” 函数,其会对每个批次(batch)中的训练样本进行填充,使同批次内样本长度统一(但不同批次的样本长度可以不同)

  • 代码实现

  • 接下来,需要修改这个函数,添加对 input 和 target 的处理

  • 接下来会引入一个 ignore_index,用于将所有填充标记(padding token)的 ID 统一替换为该值,从而在损失函数计算时自动忽略这些填充值;具体而言,会把原本表示填充标记的 ID(例如示例中的 50256)替换为 -100ignore_indexnn.CrossEntropyLoss 内置支持的标签屏蔽机制,凡是 target 等于该值的位置都会被直接跳过,不参与 loss 计算、归一化和梯度反传,默认值即为 -100

  • 此外,还引入了 allowed_max_length,用于在需要时限制样本的长度;当自定义数据集长度超过 GPT-2 模型所支持的 1024 个 token 上下文窗口时,这一参数将发挥作用

  • 完整的 collate 函数

  • 注意,无需忽略首个文本结束(填充)标记(ID 为 50256),因为这个 token 能告知模型回复内容已完成

  • 在进行损失计算时,还需要对指令内容进行 mask 操作

  • 此前定义的 custom_collate_fn 函数还有一处细节优化:现在会直接将数据转移至目标设备(如显卡),而非在主训练循环中执行该操作;这一做法能提升训练效率,因为当该函数作为数据加载器的一部分运行时,数据转移可作为后台进程并行执行

  • 可以借助 Python 标准库 functools 中的 partial 函数,创建一个新函数 —— 该函数会预先填充原函数的 device(设备)参数

微调模型

  • 损失计算与预训练一致

  • 训练模型

  • 损失曲线如下

    build-llm-from-scratch-instruction-finetuning-loss-curve
  • 用训练后的模型在测试集上推理发现,模型的指令遵循能力得到了提升

  • 然而,在测试集上评测,不能像文本分类微调那样计算正确率即可,实际应用中会通过简答题与选择题类基准、自动化对话式基准(AlpacaEval 评测框架)、与其他 LLM 的人类偏好对比等多种方式评测

评估模型

  • 下面将采用类似 AlpacaEval 的方法评估模型

  • 首先,在测试集上进行推理,并保存结果

  • 下面,将使用 Llama 3 8B 模型来自动化评估模型,可以通过 ollama 在本地运行 Llama 3 8B 模型

  • 评估测试集的结果

DPO 偏好微调

准备数据集

  • 偏好数据集如下

  • 实现数据集类

  • 实现 collate 函数

  • 在 collate 函数中,为每条数据样本都创建了"chosen_mask"(优选回复掩码)和"rejected_mask"(拒绝回复掩码)

  • 该掩码会将 ### Response 之前的内容掩码为 False

损失函数

  • DPO 的损失函数如下

  • 对于上述公式,各部分含义如下:

    • “期望值” $\mathbb{E}$ 是统计学术语,代表随机变量(即方括号内表达式)的平均值或均值;对 $-\mathbb{E}$ 进行优化,能让模型更好地与用户偏好对齐

    • 变量 $\pi_{\theta}$ 即所谓的“策略”(该术语源自强化学习),代表希望优化的大型语言模型(LLM);$\pi_{ref}$ 是“参考大型语言模型”,通常指优化前的原始模型(在训练初期,$\pi_{\theta}$ 与 $\pi_{ref}$ 通常是相同的)

    • $\beta$ 是一个超参数,用于控制 $\pi_{\theta}$ 与参考模型之间的“差异度”;增大 $\beta$ 会降低 $\pi_{\theta}$ 与 $\pi_{ref}$ 在对数概率上的差异对整体损失函数的影响,进而减小两个模型之间的差异度

    • Logistic Sigmoid 函数记为 $\sigma(\centerdot)$​,会将“偏好响应”与“拒绝响应”的对数几率(即 Logistic Sigmoid 函数内部的项)转换为一个概率分数

  • 损失函数计算如下

  • 上式中的对数概率还需要额外的函数实现

  • 批量版本的实现

  • 对应的 DataLoader

  • 为训练集和数据集计算损失

  • 简要回顾:

    • 整体流程为:通过模型计算对数几率(logits) → 由对数几率计算对数概率(compute_logprobs) → 由对数概率计算DPO 损失(compute_dpo_loss)

    • 编写 compute_dpo_loss_batch 函数,用以简化上述整个计算流程

    • 工具函数 compute_dpo_loss_loader 会将 compute_dpo_loss_batch 函数作用于数据加载器(DataLoader)

    • evaluate_dpo_loss_loader 函数则会将 compute_dpo_loss_batch 函数同时作用于训练集和验证集的数加载器,实现损失日志的记录

训练模型

  • 这个训练函数和此前预训练、指令微调时使用的是同一个,仅存在几处细微差异:

    • 将交叉熵损失替换为了新编写的 DPO 损失函数

    • 同时还会追踪奖励值和奖励边际(reward margins)—— 这两个指标在 RLHF 和 DPO 的训练场景中,常被用来监控模型的训练进度

  • 训练代码

  • 进行训练

  • 训练输出如下

  • 训练损失曲线如下

image-20260130235724402

  • 奖励边际曲线如下

image-20260130235652111

Last updated

Was this helpful?