跳转到内容

第 20 章 从零拼出 Mini GPT

我们已经会算一个注意力头,限制它使用的信息,拼出完整的块,也规定了 tokenizer。剩下的挑战是让这些约定同时成立。程序可能返回一个看似合理的损失,但标签没有错开一位,填充被算进了分母,或者词表行的含义已经改变。

MG-1 是神经网络 Mini GPT 的第一个完整里程碑:原始文本通过一个小而可检查的仅解码器模型,到达 logits 与损失。它依赖第 7 章的学习闭环,以及第 15—19 章的机制。本章走通前向计算,第 21 章再更新同一组参数。核心阅读只需要下面的静态记录;运行代码需要 Primer-PY 和本地 CPU 环境,不需要 GPU、模型账号或付费 API。

项目使用 mini-gpt-corpus-v1,包含 24 条原创训练记录和 8 条验证记录,已经按完整记录划分。内容覆盖法国首都、红蓝钥匙、虚构的 600/750 元政策,以及虚构订单查询。有些句式很接近,因此这只是用于学习的小型开发集,不是独立的语言能力基准。全部原文可在 data/mini-gpt/corpus.json 中直接查看。程序不访问真实订单系统。

使用第 19 章的 book-byte-bpe-v1:词表 291 项,不做规范化,BOS=256、EOS=257、PAD=258。分词器只使用训练记录学习。每条记录单独占一个上下文窗口,不拼接不同记录,也不让窗口跨越数据划分。每个窗口有 64 个槽位;超过支持长度的记录会报错,不会默默截断。这些固定记录连 EOS 在内,最多需要 34 个待评分位置,其余槽位用填充补齐。

模型包含一个 Pre-Norm 块,宽度 16,两个宽度各为 8 的头,宽度为 64 的 ReLU FFN,以及学习得到的绝对位置表。块之后再做一次最终 LayerNorm,然后接不共享嵌入权重的词表输出层。epsilon 为 0.00001,dropout 为零,参数使用 CPU 上的 float64。初始化种子为 1729,矩阵元素从均值为零、标准差为 0.02 的正态分布中抽取;偏置初始为零,归一化缩放初始为一。这些选择定义的是教学小模型,不是商用 GPT 产品的架构。完整冻结配置在 data/mini-gpt/config.json。

20.2 目标只错开一次,再补齐未用槽位

Section titled “20.2 目标只错开一次,再补齐未用槽位”

例 20.1:两条记录变成一批张量。 取下面两条训练记录,保留每个空格和标点:

The key is red. Answer: red.
The key is blue. Answer: blue.

红钥匙记录经字节 BPE 后有 14 个普通 token。前面加 BOS,后面加 EOS;输入 x 取这个列表去掉最后一项的部分,目标 y 取去掉第一项的部分。表 20.1 展示完整结果。索引从零开始,与代码一致。片段列的 ␠ 只是用来 显示普通空格,不是原文中的符号,也不是额外 token。

表 20.1:每个真实输入,与它要预测的下一个 token。

位置索引输入 ID输入片段目标 ID目标片段
0256BOS84T
184T265he␠
2265he␠268key
3268key32␠
432␠277is␠
5277is␠270red
6270red261.␠
7261.␠65A
865A276ns
9276ns119w
10119w262er
11262er259:␠
12259:␠270red
13270red46.
1446.257EOS

待评分目标共有 15 个,不是 14 个,因为记录结束也要预测。BOS 是已知输入,从不作为目标;EOS 在这里是目标,不再额外作为一项有待评分输出的输入。槽位 15—63 的 x 放 PAD,y 放忽略标记 −100。−100 告诉损失函数忽略该位置,不是词表 ID。另外用布尔数组 valid 标出 15 个真实查询/键位置。

蓝钥匙记录需要 17 个待评分位置,因为小分词器把每个 blue 拆成 blu 与 e。它的完整真实输入 ID 是 [256,84,265,268,32,277,274,101,261,65,276,119,262,259,274,101,46],目标是 [84,265,268,32,277,274,101,261,65,276,119,262,259,274,101,46,257]。因此,这一批 x、valid、y 的 shape 都是 [2,64],但只有 15+17=32 个待评分事件。若按补齐后的 128 个槽位求平均,就改变了训练目标。

batch_records 提供项目的数据集接口:把已经选好的记录列表,变成对齐的输入、可见性和目标数组。训练时抽取小批次,选择的是完整训练记录,不会从验证集中任意拿片段拼入。解释这项约定,并不需要先引入大型框架的数据集对象。

20.3 从 ID 沿结构图走到词表分数

Section titled “20.3 从 ID 沿结构图走到词表分数”
1 · batch_records
  1. 文本片段2 条完整记录
  2. 处理步骤固定字节 BPE,目标错开一位
  3. 行编号ids / valid / targets [2, 64]

15 + 17 个有效目标;填充不计入损失。

2 · MiniGPT.forward
  1. 模型保存的数值Token 表 [291, 16]
  2. 模型保存的数值位置表 [64, 16]
  3. 一组数字查表 + 位置 → X [2, 64, 16]

整数 ID 用于选行,相加的是坐标向量。

3 · Block.forward:位置间联系
  1. 处理步骤RowNorm → Q/K/V [2, 2, 64, 8]
  2. 处理步骤QKᵀ / √8 + 可见键 → A [2, 2, 64, 64]
  3. 一组数字AV → 拼接 → 输出 [2, 64, 16]

只混合此前及当前的有效键,Softmax 沿最后的来源位置轴归一化。

4 · Block.forward:保留与变换
  1. 一组数字R = X + 注意力输出 [2, 64, 16]
  2. 处理步骤RowNorm → FFN 16 → 64 → 16
  3. 一组数字Y = R + FFN [2, 64, 16]

两次残差相加都保持 shape;逐位置 FFN 的两次矩阵变换之间有 ReLU。

5 · MiniGPT.forward:给候选评分
  1. 处理步骤最后的 RowNorm [2, 64, 16]
  2. 模型保存的数值LM 矩阵 [16, 291] + 偏置 [291]
  3. 一组数字原始 logits [2, 64, 291]

每列对应固定词表 ID 的含义,这条轴不同于注意力的键位置轴。

6 · 交叉熵
  1. 行编号32 个真实的下一 token ID
  2. 处理步骤32 项负对数概率求和,再除以 32
  3. 一组数字初始平均损失 = 5.670999

忽略标为 −100 的目标。图到这里算出了分数,更新参数还要靠训练循环。

图 20.1 — 从两条文本记录到 32 个评分事件 本书原创教学图,CC BY-SA 4.0。数值与规则见正文及共享数据。

图 20.1 把操作与代码名称一一对应。查表把整数 ID 变成浮点坐标,位置相加提供槽位信息,块内只沿允许的注意力路径通信,并返回相同宽度。最后的输出层把这个宽度映射到词表候选。这里有两种概率轴:注意力沿 来源位置 归一化,输出分布则沿 词表 ID 归一化。

表 20.2:MG-1 的实际 shape 记录。 B=2、T=64、C=16、H=2、d=8、F=64、V=291。

记录项/操作Shape
输入 ID、有效标记、错位目标各 [2,64]
token_embedding 查表[2,64,16]
position_embedding 位置行[64,16],样本间共用
block_input、norm1各 [2,64,16]
q_projection、k_projection、v_projection各 [2,64,16]
q_heads、k_heads、v_heads各 [2,2,64,8]
scores[2,2,64,64]
visible[2,1,64,64],各头共用
weights[2,2,64,64]
mixtures[2,2,64,8]
joined、attention_output、residual1、norm2各 [2,64,16]
ffn_linear、ffn_relu各 [2,64,64]
ffn_output、block_output、final_norm各 [2,64,16]
logits[2,64,291]
32 个有效目标的平均损失标量

Q、K、V 和输出投影都使用 [16,16] 矩阵。拆为两个头时,按第 16 章的方法重新分组坐标、移动头轴。缩放分母是 8 的平方根,不是 16 或 64。visible 的头轴长度为 1,表示两个头共用相同的因果/填充键关系。无效查询的注意力混合明确置零;后面算出的未使用 logits 不计入损失。

块返回的是 [B,T,C],所以还需要输出层把 C 映射到 V。这里的计算是:

Z=GWLM+bLM,[2,64,16] @ [16,291] + [291] ⟶ [2,64,291].Z=GW_{\text{LM}}+b_{\text{LM}},\qquad [2,64,16]\ @\ [16,291]\ +\ [291]\ \longrightarrow\ [2,64,291].

G 表示最终归一化后的表示,shape 为 [B,T,C]。W_LM 的各列按冻结顺序对应候选 ID,输出偏置每个候选一项。本项目的 [291,16] 嵌入表与 W_LM 是两份独立参数;也有语言模型共享它们对应的权重。

表 20.3:把保存的每个标量参数数清。 不含临时激活和优化器状态。

组件参数数
token 表,291 × 164656
位置表,64 × 161024
Q/K/V 与输出矩阵,4 × 16 × 16;无偏置1024
FFN,16 × 64 + 64 + 64 × 16 + 162128
三次 LayerNorm,各 16 个缩放与 16 个偏移96
LM 矩阵与偏置,16 × 291 + 2914947
合计13875

这个数量由 shape 推导,并与程序登记的参数核对过。它不是完整内存估算,因为训练还需要梯度、优化器累积量和中间数组。

例 20.2:位置 5 在看到 red 以前预测它。 使用刚按种子初始化、尚未更新的模型。第一条记录的输入索引 5 是 ID 277,即 is␠,目标 ID 270 是 red。允许的输入索引是 0—5;索引 6 的 red,以及后面的 Answer: red.,在这次预测中都不可见。

token 嵌入的第一个坐标为 0.01821338003888261,位置坐标为 0.027941533876330877,相加得到 0.04615491391521349。这一输入行的十六个坐标都参与 LayerNorm,均值为 0.005044787486780672,方差为 0.0007407640671898172。从第一个坐标减去该均值,再除以“方差加 0.00001”的平方根,就得到约 1.500366。如果只拿表中展示的前几个坐标做归一化,就算成了另一件事。

要用全部坐标核对均值和方差,可以看这条完整输入行,以下保留九位小数。换行只是为了方便阅读,它仍是一行 16 个坐标,不是四个位置;用舍入值重算时,末几位可能略有差异。

[
0.046154914, -0.015372816, -0.013930337, 0.012018233,
0.030313746, -0.005942124, 0.044805955, 0.028551791,
-0.052296434, 0.027663488, -0.034449716, -0.024532425,
0.018037712, 0.011286233, 0.004664645, 0.003743734
]

表 20.4:这个位置经过的每个表示阶段。 展示前三个坐标,保留六位小数;选中位置的完整向量保存在 data/mini-gpt/forward.json。FFN 中间行有 64 个坐标,表中其余表示行均为 16 个。

阶段前三个坐标
token 嵌入[0.018213,0.011841,-0.009374]
位置嵌入[0.027942,-0.027214,-0.004556]
相加/块输入[0.046155,-0.015373,-0.013930]
第一次归一化[1.500366,-0.745166,-0.692521]
Q 投影[0.086787,-0.040868,-0.193362]
K 投影[-0.013506,0.011333,-0.061313]
V 投影[0.007704,-0.032708,0.052636]
拼接后的头混合结果[0.035102,0.011446,-0.001279]
注意力输出投影[0.002184,0.001408,-0.002312]
第一次残差相加[0.048339,-0.013964,-0.016242]
第二次归一化[1.552943,-0.721283,-0.804429]
FFN 第一次变换[0.183987,0.108629,-0.107695]
ReLU[0.183987,0.108629,0.000000]
FFN 第二次变换[-0.005631,-0.000809,0.002055]
第二次残差相加/块输出[0.042707,-0.014773,-0.014188]
最终归一化[1.401739,-0.594476,-0.574142]

这些是实际前向计算的记录,不是手工指定的激活。例如,第一次残差相加把 0.04615491391521349 与 0.0021837806813666213 相加,第二次再加 −0.005631200185285851,得到 0.042707494411294254。表中还能看到 ReLU 把 FFN 中间结果的第三个负坐标清零;只有 shape 正确,不能证明程序真的做了这一步。

坐标表之外,位置之间的计算由第一头的六个可见分数和权重补齐:

表 20.5:一条查询读取六个已知输入位置。 对该查询,索引 6—63 的权重都恰好为零。

键索引缩放后分数权重
0-0.0051910.166037
1-0.0067290.165782
2-0.0038650.166257
30.0015670.167163
40.0013380.167125
50.0043930.167636

初始化时分数很接近,因此六个位置的权重近乎均匀。独立标量循环取出真实的八坐标查询与键,重算点积和值向量混合,与张量实现的误差小于 0.000000000001。这没有证明未训练的头找到了有意义的钥匙线索。

20.5 先在候选之间算概率,再在有效事件之间平均损失

Section titled “20.5 先在候选之间算概率,再在有效事件之间平均损失”

这个位置返回 291 个原始 logits。候选 270 的 logit 为 0.09915046290174213,本行最大 logit 为 0.20936204844775802。所有候选先减去这个最大值,再取指数,总和为 236.07843517556194。按第 6 章的规则:

p270=exp⁡(0.09915046290174213−0.20936204844775802)236.07843517556194≈0.003793843386,ℓ=−ln⁡p270≈5.574375688.p_{270}=\frac{\exp(0.09915046290174213-0.20936204844775802)}{236.07843517556194} \approx0.003793843386,\qquad \ell=-\ln p_{270}\approx5.574375688.

归一化分母来自全部 291 个候选,包括普通字节、合并片段和特殊项。BOS、PAD 在此数据集中从不成为正确目标,但计算损失时,输出列仍然存在。生成时会另行禁止选择它们。注意力中的六个可见键,并不是这里分母中的六个候选。

整批两条记录的损失,要把 32 个有效目标损失相加,再除以 32。实际平均值为每个目标 5.670998865509144 奈特;根据返回的 logits,用独立标量计算得到 5.670998865509145。它不必等于某一次 red 预测的损失,也不必等于下一章使用的完整训练集平均损失。

还有一项检查,在程序运行以前就知道答案:把全部词表 logits 设为零。291 个指数值都为 1,每个候选的概率为 1/291,因此每个有效目标的损失都是 ln⁡(291)≈5.673323267\ln(291)\approx5.673323267。改变忽略的填充数量,不应该改变这个平均值。实现也通过了这一解析检查。只匹配一份事先记录的模型损失,证据会更弱,因为那份记录本身也可能来自错误实现。

code/mini-gpt/model.py 使用 PyTorch 张量与自动微分实现计算,没有调用 nn.Transformer、预训练模型加载器或封装好的注意力块。矩阵乘法、坐标归一化、掩码、拆头重排、ReLU 和残差相加,都明确写在代码里,可以对着图 20.1 检查。

还需要补一个 Python 概念。类(class) 描述怎样创建一个容纳数据与函数的对象;MiniGPT(cfg) 创建这样的一个对象,self.token_embedding 是这个实例保存的张量。继承 nn.Module 后,PyTorch 能收集标为 nn.Parameter 的张量及子模块的参数。调用 model(ids),就会运行它的 forward 方法。这个调用使用已有参数计算,不会训练参数,也不会去别处下载模型。RowNorm、Block、MiniGPT 三个类只是把已经讲过的计算组织起来。

按 code/part-iv/README.md 准备环境以后,使用该环境的 Python 解释器,在书籍仓库根目录运行完整 MG-1 验证:

终端窗口
python code/mini-gpt/forward.py

程序选择上述两条记录,依次调用:

cfg = config()
tokenizer = load(ROOT)
model = create_model(cfg)
ids, valid, targets = batch_records(records, tokenizer, cfg["context_length"])
model.eval()
with torch.no_grad():
logits, loss, trace = model(ids, valid, targets, trace=True)

ID 和目标使用 torch.long 整数,参数与表示使用 CPU 上的 float64。eval() 切换到评估行为,no_grad() 则避免建立梯度计算图。本模型 dropout 为零,LayerNorm 又只按行计算,因此训练与评估模式的算术过程相同;但两种操作各管什么,扩展模型时仍须分清。损失函数接收原始 logits,不能在交叉熵之前再多做一次 Softmax。

test_model.py 会改变未来 token、填充内容和同批其他样本,要求真实前缀的 logits 在规定条件下保持不变。还检查目标错位与 EOS、独立标量注意力、均匀分布的解析损失、精确参数数量,以及对空输入或过长输入的拒绝。反向传播与更新检查则确认梯度能到达嵌入和块内参数。这些验证的是本地实现,不是语言能力。

MG-1 已有固定的原始文本数据、tokenizer 身份、完整结构图、shape 记录、数值过程、logits、损失和 CPU 检查。它接上了前面手工计数语言模型的神经网络实践线。与第 4 章的计数表相比,这个模型让各位置共用参数表和变换矩阵,可以用损失更新这些参数。

未训练损失接近均匀基准并不意外。计算损失,只是在问当前参数如何给目标评分,不会改变参数。第 21 章要真正执行反向传播,更新这同一组 13,875 个参数,保存训练状态,再判断变化后的输出到底证明了什么。

  1. 表 20.1 的红钥匙记录有多少真实输入槽位、多少填充槽位、多少目标计入平均?如果把 y 错开两次,会出现什么错误?
  2. 解释注意力分数 [2,2,64,64] 中两个 64 分别是什么,以及 logits [2,64,291] 为什么使用不同的末轴。
  3. 如果所有 logits 都为零,只剩两个有效目标,正确平均损失是多少?若错误地把两项损失的和除以 128,又会报出多少?
  4. 两个宽度为 8 的头拼接以后,删除输出投影,残差 shape 还与宽度 16 相容吗?为什么这不能证明模型没有变化?
  5. 在宽度仍为 16、继续使用独立输入/输出矩阵及现有偏置规则时,词表增加一项,需要增加多少参数?还有什么必须更新版本?
  6. 程序的所有 shape 都正确,但替换未来 token 会改变较早位置的 logits。给出两条可能的泄漏路径,并说明需要重查哪些 MG-1 证据。
  1. 15 个真实槽位、49 个填充槽位、15 个待评分目标,EOS 是第十五次事件。再错开一次,会让每个输入对齐到两步后的目标,而非规定的下一个 token;shape 相容也补救不了任务错位。
  2. 注意力中第一个 64 是接收查询位置,第二个是来源键位置,两条样本、两个头分别计算。Logits 则在 64 个输出位置各给出 291 个候选 ID 的分数。一种 Softmax 混合来源,另一种分配下一个 token 的概率。
  3. 正确平均仍为 ln⁡(291)≈5.673323267\ln(291)\approx5.673323267。错误分母会得到 ln⁡(291)/64≈0.088645676\ln(291)/64\approx0.088645676,看起来很小,只因为把 126 个不评分的占位符也算进了分母。
  4. 相容,两个 8 拼起来已经是 16。但 W_O 会在残差相加以前组合这些坐标;删除它,等于用恒等映射替换这次变换,通常会改变函数,尽管 shape 没变。
  5. 新增 16 个嵌入元素、16 个输出矩阵元素和一个输出偏置,共 33 个参数。词表顺序、合并与特殊 token 规则、tokenizer 身份、数据编码和模型/检查点配置都要记录版本。旧行必须保留原含义,或者有意迁移。
  6. 注意力可能缺失或反用了因果掩码;也可能是归一化或预处理在注意力以前就混入了未来位置。应重查可见关系、实际前缀扰动、只沿坐标的归一化和目标构造。完整结构图与数值过程应能指出允许的信息路径,不能只凭 shape 判断。