第 20 章 从零拼出 Mini GPT
我们已经会算一个注意力头,限制它使用的信息,拼出完整的块,也规定了 tokenizer。剩下的挑战是让这些约定同时成立。程序可能返回一个看似合理的损失,但标签没有错开一位,填充被算进了分母,或者词表行的含义已经改变。
MG-1 是神经网络 Mini GPT 的第一个完整里程碑:原始文本通过一个小而可检查的仅解码器模型,到达 logits 与损失。它依赖第 7 章的学习闭环,以及第 15—19 章的机制。本章走通前向计算,第 21 章再更新同一组参数。核心阅读只需要下面的静态记录;运行代码需要 Primer-PY 和本地 CPU 环境,不需要 GPU、模型账号或付费 API。
20.1 创建张量以前,先固定任务
Section titled “20.1 创建张量以前,先固定任务”项目使用 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 | 目标片段 |
|---|---|---|---|---|
| 0 | 256 | BOS | 84 | T |
| 1 | 84 | T | 265 | he␠ |
| 2 | 265 | he␠ | 268 | key |
| 3 | 268 | key | 32 | ␠ |
| 4 | 32 | ␠ | 277 | is␠ |
| 5 | 277 | is␠ | 270 | red |
| 6 | 270 | red | 261 | .␠ |
| 7 | 261 | .␠ | 65 | A |
| 8 | 65 | A | 276 | ns |
| 9 | 276 | ns | 119 | w |
| 10 | 119 | w | 262 | er |
| 11 | 262 | er | 259 | :␠ |
| 12 | 259 | :␠ | 270 | red |
| 13 | 270 | red | 46 | . |
| 14 | 46 | . | 257 | EOS |
待评分目标共有 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 沿结构图走到词表分数”- 文本片段2 条完整记录
- 处理步骤固定字节 BPE,目标错开一位
- 行编号ids / valid / targets [2, 64]
15 + 17 个有效目标;填充不计入损失。
- 模型保存的数值Token 表 [291, 16]
- 模型保存的数值位置表 [64, 16]
- 一组数字查表 + 位置 → X [2, 64, 16]
整数 ID 用于选行,相加的是坐标向量。
- 处理步骤RowNorm → Q/K/V [2, 2, 64, 8]
- 处理步骤QKᵀ / √8 + 可见键 → A [2, 2, 64, 64]
- 一组数字AV → 拼接 → 输出 [2, 64, 16]
只混合此前及当前的有效键,Softmax 沿最后的来源位置轴归一化。
- 一组数字R = X + 注意力输出 [2, 64, 16]
- 处理步骤RowNorm → FFN 16 → 64 → 16
- 一组数字Y = R + FFN [2, 64, 16]
两次残差相加都保持 shape;逐位置 FFN 的两次矩阵变换之间有 ReLU。
- 处理步骤最后的 RowNorm [2, 64, 16]
- 模型保存的数值LM 矩阵 [16, 291] + 偏置 [291]
- 一组数字原始 logits [2, 64, 291]
每列对应固定词表 ID 的含义,这条轴不同于注意力的键位置轴。
- 行编号32 个真实的下一 token ID
- 处理步骤32 项负对数概率求和,再除以 32
- 一组数字初始平均损失 = 5.670999
忽略标为 −100 的目标。图到这里算出了分数,更新参数还要靠训练循环。
图 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。这里的计算是:
G 表示最终归一化后的表示,shape 为 [B,T,C]。W_LM 的各列按冻结顺序对应候选 ID,输出偏置每个候选一项。本项目的 [291,16] 嵌入表与 W_LM 是两份独立参数;也有语言模型共享它们对应的权重。
表 20.3:把保存的每个标量参数数清。 不含临时激活和优化器状态。
| 组件 | 参数数 |
|---|---|
| token 表,291 × 16 | 4656 |
| 位置表,64 × 16 | 1024 |
| Q/K/V 与输出矩阵,4 × 16 × 16;无偏置 | 1024 |
| FFN,16 × 64 + 64 + 64 × 16 + 16 | 2128 |
| 三次 LayerNorm,各 16 个缩放与 16 个偏移 | 96 |
| LM 矩阵与偏置,16 × 291 + 291 | 4947 |
| 合计 | 13875 |
这个数量由 shape 推导,并与程序登记的参数核对过。它不是完整内存估算,因为训练还需要梯度、优化器累积量和中间数组。
20.4 沿一次预测追踪真实数值
Section titled “20.4 沿一次预测追踪真实数值”例 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.005191 | 0.166037 |
| 1 | -0.006729 | 0.165782 |
| 2 | -0.003865 | 0.166257 |
| 3 | 0.001567 | 0.167163 |
| 4 | 0.001338 | 0.167125 |
| 5 | 0.004393 | 0.167636 |
初始化时分数很接近,因此六个位置的权重近乎均匀。独立标量循环取出真实的八坐标查询与键,重算点积和值向量混合,与张量实现的误差小于 0.000000000001。这没有证明未训练的头找到了有意义的钥匙线索。
20.5 先在候选之间算概率,再在有效事件之间平均损失
Section titled “20.5 先在候选之间算概率,再在有效事件之间平均损失”这个位置返回 291 个原始 logits。候选 270 的 logit 为 0.09915046290174213,本行最大 logit 为 0.20936204844775802。所有候选先减去这个最大值,再取指数,总和为 236.07843517556194。按第 6 章的规则:
归一化分母来自全部 291 个候选,包括普通字节、合并片段和特殊项。BOS、PAD 在此数据集中从不成为正确目标,但计算损失时,输出列仍然存在。生成时会另行禁止选择它们。注意力中的六个可见键,并不是这里分母中的六个候选。
整批两条记录的损失,要把 32 个有效目标损失相加,再除以 32。实际平均值为每个目标 5.670998865509144 奈特;根据返回的 logits,用独立标量计算得到 5.670998865509145。它不必等于某一次 red 预测的损失,也不必等于下一章使用的完整训练集平均损失。
还有一项检查,在程序运行以前就知道答案:把全部词表 logits 设为零。291 个指数值都为 1,每个候选的概率为 1/291,因此每个有效目标的损失都是 。改变忽略的填充数量,不应该改变这个平均值。实现也通过了这一解析检查。只匹配一份事先记录的模型损失,证据会更弱,因为那份记录本身也可能来自错误实现。
20.6 让代码验证结构
Section titled “20.6 让代码验证结构”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、独立标量注意力、均匀分布的解析损失、精确参数数量,以及对空输入或过长输入的拒绝。反向传播与更新检查则确认梯度能到达嵌入和块内参数。这些验证的是本地实现,不是语言能力。
20.7 MG-1 算得通,不等于学会了
Section titled “20.7 MG-1 算得通,不等于学会了”MG-1 已有固定的原始文本数据、tokenizer 身份、完整结构图、shape 记录、数值过程、logits、损失和 CPU 检查。它接上了前面手工计数语言模型的神经网络实践线。与第 4 章的计数表相比,这个模型让各位置共用参数表和变换矩阵,可以用损失更新这些参数。
未训练损失接近均匀基准并不意外。计算损失,只是在问当前参数如何给目标评分,不会改变参数。第 21 章要真正执行反向传播,更新这同一组 13,875 个参数,保存训练状态,再判断变化后的输出到底证明了什么。
20.8 习题
Section titled “20.8 习题”- 表 20.1 的红钥匙记录有多少真实输入槽位、多少填充槽位、多少目标计入平均?如果把 y 错开两次,会出现什么错误?
- 解释注意力分数
[2,2,64,64]中两个 64 分别是什么,以及 logits[2,64,291]为什么使用不同的末轴。 - 如果所有 logits 都为零,只剩两个有效目标,正确平均损失是多少?若错误地把两项损失的和除以 128,又会报出多少?
- 两个宽度为 8 的头拼接以后,删除输出投影,残差 shape 还与宽度 16 相容吗?为什么这不能证明模型没有变化?
- 在宽度仍为 16、继续使用独立输入/输出矩阵及现有偏置规则时,词表增加一项,需要增加多少参数?还有什么必须更新版本?
- 程序的所有 shape 都正确,但替换未来 token 会改变较早位置的 logits。给出两条可能的泄漏路径,并说明需要重查哪些 MG-1 证据。
20.9 参考解答
Section titled “20.9 参考解答”- 15 个真实槽位、49 个填充槽位、15 个待评分目标,EOS 是第十五次事件。再错开一次,会让每个输入对齐到两步后的目标,而非规定的下一个 token;shape 相容也补救不了任务错位。
- 注意力中第一个 64 是接收查询位置,第二个是来源键位置,两条样本、两个头分别计算。Logits 则在 64 个输出位置各给出 291 个候选 ID 的分数。一种 Softmax 混合来源,另一种分配下一个 token 的概率。
- 正确平均仍为 。错误分母会得到 ,看起来很小,只因为把 126 个不评分的占位符也算进了分母。
- 相容,两个 8 拼起来已经是 16。但 W_O 会在残差相加以前组合这些坐标;删除它,等于用恒等映射替换这次变换,通常会改变函数,尽管 shape 没变。
- 新增 16 个嵌入元素、16 个输出矩阵元素和一个输出偏置,共 33 个参数。词表顺序、合并与特殊 token 规则、tokenizer 身份、数据编码和模型/检查点配置都要记录版本。旧行必须保留原含义,或者有意迁移。
- 注意力可能缺失或反用了因果掩码;也可能是归一化或预处理在注意力以前就混入了未来位置。应重查可见关系、实际前缀扰动、只沿坐标的归一化和目标构造。完整结构图与数值过程应能指出允许的信息路径,不能只凭 shape 判断。
20.10 参考文献
Section titled “20.10 参考文献”- Vaswani 等(2017),Attention Is All You Need,第 3 节;Xiong 等(2020),On Layer Normalization in the Transformer Architecture,表 1。用于定位本章组合的操作及所选 Pre-Norm 顺序;小模型参数配置与数据为原创。
- PyTorch,Module,2.7 版 与 cross_entropy,2.7 版。解释可执行验证中的参数登记、模块调用、原始 logits 输入及忽略目标后的平均规则。