bobofamilyNIU CHUN
23/36

初始 loss 比瞎猜还差一半:从莎士比亚到 2.49 这条基线

2026-09-2113 分钟—阅读

上一篇把 backward() 拆到了底。这一篇换数据集重开 —— 从这里到阶段一结束,目标只有一个: 从零写出一个 GPT。

第六集(WaveNet)跳过。 它把上一篇那个 MLP 改成层级结构、loss 再降一截, 但用的还是已经拆完的那套零件 —— 按性价比取舍,直接进第七集。 所以来源清单里 Ep.5 之后直接是 Ep.7,那个空档是有意的。


数据换了,切法也得换

names.txt(第 7—22 篇)input.txt(本篇起)
大小228 KB1.1 MB(1,115,394 个字符)
结构32033 行,每行一个名字一整块连续文本
样本边界明确没有 —— 从哪切到哪都行
词表2765(大小写 + 标点 + 空格 + 换行)

词表按 ASCII 排序:0 是换行,1 是空格,然后标点、大写、小写。 整部莎士比亚只出现过数字 3 —— 字符级词表完全由数据决定,数据里没有的字符遇到就 KeyError。

n = int(0.9 * len(data))
train_data = data[:n] # 前 90%:1,003,854
val_data = data[n:] # 后 10%:111,540

⚠️ 按位置切,不能打乱。 第十二篇立规矩时是「按名字不按样本」切 —— 那里打乱是安全的, 因为每个名字本来就是独立的一条。这里文本是连续的,打乱会让验证集的句子跟训练集的上下文相连, 当场信息泄漏。


token 是自己定义的

token = 模型词表里的一个条目,对应一个整数 ID。 它是模型能处理的最小单位。

神经网络只会算数字,而文本是字符串 —— token 就是这座桥的砖:

文本 → [tokenizer] → 整数序列 → [模型] → 整数 → [decode] → 文本
'hi' → → [46, 47] → → ...

🔑 你第七到二十二篇写的 stoi / itos 就是最简单的 tokenizer,只是当时没管它叫这个名字。 模型从头到尾只认识整数。

三种切分粒度

同一句 The cats are unhappy:

粒度切法token 数词表致命问题
字符级(本集)T·h·e·␣·c·a·t·s…2065~256序列太长
词级The · cats · are · unhappy4几万~几十万OOV —— 词表外的词只能变 <UNK>
子词级(真实 LLM)The · cats · are · un · happy53 万~15 万无 —— 兜底拆到字节

子词级按频率决定:常见词整个是一个 token,罕见词拆成几块,再罕见的一路拆到字节 —— 所以永远不会有 OOV。

❗ 为什么序列长度这么要紧

注意力的计算量是 O(T²)。 同一段文本:

token 数注意力的量级
字符级10001,000,000
子词级25062,500

差 16 倍。 这就是没人用字符级训真实 LLM 的原因,也是上下文窗口有限的原因 —— 128K 的算力代价是 8K 的 256 倍(16² )。

BPE 是统计出来的,不是设计出来的

① 初始词表 = 256 种字节
② 数哪一对相邻符号出现得最多(比如 e 和 s)
③ 合并成新 token(es),加进词表
④ 重复,直到词表达到目标大小(比如 5 万)

整个过程从数据里统计出来,不需要语言学知识 —— 换成中文、代码、DNA,同一个算法照跑。

会不会跨词合并?会。 GPT 把空格归到后面那个词,所以 " have" 和 "have" 是两个不同的 ID; " of the" 这种也常常是一个 token —— BPE 只认频率不认词。 但不会失控:多数 tokenizer 先用正则预切分、不跨标点和换行,词表大小也封顶。

两个实际影响:

  • 数字切分毫无规律(1234 可能切成 123 + 4)—— 早期大模型算数差的原因之一, Llama 3 / Qwen 后来规定数字逐位切
  • 同一个词有多个 ID:hello / " hello" / Hello 在模型眼里是三个无关的符号 —— prompt 多打一个空格,输出就可能变

🔑 tokenizer 跟模型权重是绑死的

它是从语料统计出来的,但跟模型训练完全分开:先一次性训好 tokenizer 并冻结,再用它切文本去训模型。 它没有梯度,不随训练更新。

情形行不行
从零训自己的模型,借用别人的 tokenizer✅ 最常见的做法(nanoGPT 就用 GPT-2 的)
微调别人的模型,换 tokenizer❌ 绝对不行
微调时在原 tokenizer 上加词⚠️ 可以,但新 token 的嵌入是随机的,要训练

换了 tokenizer,同一个词的 ID 就变了,嵌入表查错行,整个模型报废。 所以下载模型时必须连 tokenizer.json 一起下。

★ 这才是选 Qwen 的硬理由

借用别人的 tokenizer 时,最大的坑是语言匹配:同一段中文, Qwen(词表 15 万、中文语料占比高)可能只要 Llama 2(词表 3.2 万)的三分之一 token。

🔑 不是「感觉它对中文好」,而是:同样的文本 token 数少一半 → 上下文翻倍、成本减半。 而按上面那笔 O(T²) 的账,token 数减半意味着注意力的计算量降到四分之一。

反过来也有坑:小模型配大词表很别扭 —— 一个 10M 参数的模型配 5 万词表, 光嵌入层就是 50000 × 768 = 3840 万,喧宾夺主。


一个片段含 T 条样本

x = train_data[:8] # 输入
y = train_data[1:9] # 答案 = x 右移一位
输入 [18] → 预测 47
输入 [18, 47] → 预测 56
…
输入 [18, 47, 56, 57, 58, 1, 15, 47] → 预测 58

一个长度 9 的片段产生 8 条训练样本。 两个好处:一次前向同时产生 8 个预测; 让模型见识 1 到 8 各种长度的上下文 —— 推理时从空白开始生成,必须会处理短上下文。

⚠️ 这跟第九篇不一样。 那里 block_size=3 是「永远看 3 个」,这里是「1 到 8 都要会」。

(B, T) 两个维度

维度含义来自训练时
Bbatch,并行几个序列batch_size32
Ttime,每个序列多长block_size8

每一步 32 × 8 = 256 个预测。 像一套卷子 32 页、每页 8 道填空,每页题目来自书的不同章节, 一次批改完整套。

⚠️ 但这 256 个不完全是独立样本 —— 同一序列的 8 个位置来自原文同一处。 bigram 里没区别(只看当前字符),有了注意力就有区别了:第 3 个位置不能偷看第 5 个位置的答案。 这就是后面那个下三角 mask 存在的全部理由。


查表就是一层全连接

整个模型就是一张 65×65 的表。把它画成神经网络之后,「查表」和「one-hot 乘权重矩阵」是同一件事 —— 当前字符那一格是 1,所以只有从它出发的 65 条边起作用,输出恰好是 W 的那一行。
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size):
super().__init__()
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)

super().__init__() 是让 nn.Module 先建好登记簿,不写就报 AttributeError,没有例外。 self.xxx = nn.Embedding(...) 会自动登记成参数 —— 这就是为什么不用再像第二十二篇那样手写 parameters() 和 zip(parameters, grads)。

nn.Embedding(65, 65) 的两个 65 含义不同

一张 65 行 × 65 列的可训练表格,一开始全是随机数:

含义
行号当前这个字符是谁
列号候选的下一个字符是谁
格子里的数「当前是行字符时,下一个是列字符」的分数

第一个 65 是「有 65 个字符」(行数),第二个是「每个字符变成多长的向量」(列数)。 碰巧相等,含义完全不同。

❗ 第二个为什么被迫是 65:bigram 只有这一张表,没有别的层 —— 查出来的东西必须直接就是对 65 个候选的打分。取 32 的话只有 32 个分数,剩下 33 个字符没法预测。

后面会把它拆开:nn.Embedding(65, 32) + nn.Linear(32, 65),中间那 32 维就是留给注意力工作的空间。 正常情况下嵌入维度是自由选的超参,而且远小于词表(GPT-2 词表 5 万、嵌入 768)—— 第九篇讲过,embedding 的价值就是压缩,压缩逼出语义结构。bigram 没压缩,所以学不到语义。

one-hot × W = 查表

把模型画成神经网络:左边 65 个输入节点(one-hot),右边 65 个输出节点,中间全连接,权重就是 W。 当前字符是 'h' 时只有那一格是 1,只有从 'h' 出发的 65 条边起作用, 输出恰好是 W 的第 46 行。

🔑 nn.Embedding 就是一层没有偏置的全连接,只是跳过了「造 one-hot 再乘」这一步。 第九篇把 one-hot 的几何讲透了(27 维正交、压缩是被迫的),这一条是它的代数面。

(B, T, C)

logits = self.token_embedding_table(idx) # (B,T) → (B,T,C)
字母含义值
Bbatch,几个序列32
Ttime,每个序列几个位置8
Cchannel,每个位置几个数65

🔑 从这里一直到读 Qwen 源码,满屏都是这三个形状:(B,T) 是 token ID, (B,T,C) 是嵌入之后,(B,T,T) 是注意力权重。

❗ 那 65 个数不是「代表一个字符」 —— 它是 65 个候选各自的得分,第 j 个对应 itos[j]。 它不是在说「我是 b」,而是同时给 65 个候选打分。

⚠️ 整个 forward 没有乘法、没有加法、没有激活函数。256 个字符编号进去,256 行分数出来 —— 这就是 bigram 的全部计算。

view 拉平为什么不会错位

F.cross_entropy 只吃二维,所以要 logits.view(B*T, C)。为什么 C 不会错位?

第十篇讲过:内存里只有一串数,B、T、C 是解读规则,而行优先约定让最后一维 stride = 1 —— 同一位置的 65 个数在内存里是连续的。view(B*T, C) 每 65 个切一刀,正好是完整的一个位置; 第 i 行对应位置 (b, t),i = b×T + t。targets 用同样的顺序拉平,天然对应。

⚠️ 危险的写法:view(C, B*T) —— 形状对、不报错、结果全错。要转置得用 .transpose()。 这是第二十一篇那条「不报错但算错」的第二例:dC[Xb] += demb 是花式索引,这个是维度顺序。 两个都躲过了形状检查。

🔑 PyTorch 里形状不会自己偷偷变。每一次变形,代码里都有一行 view / reshape / permute 在做。


generate:自回归的全部

for _ in range(max_new_tokens):
logits, loss = self(idx) # (B,T,C)
logits = logits[:, -1, :] # 只要最后一个位置 (B,C)
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, idx_next), dim=1)

预测 → 抽签 → 接上 → 再预测。 ChatGPT 一个字一个字往外蹦,跑的就是这个循环。

❗ 为什么只取最后一个位置

假设已生成到 \nHe,输出是 (1, 3, 65):

位置看到在预测答案知道吗
0\n\n 后面是什么知道,是 H
1HH 后面是什么知道,是 e
2ee 后面是什么不知道 ← 要的就是这个

前 T−1 个预测对应的字符已经在序列里了。 训练时全部位置都用(每个都有答案可对), 生成时只用最后一个。

multinomial 是按概率抽签不是取最大 —— 第十二篇采样那一节讲过,用 argmax 会让输出陷入循环; temperature 第八篇也讲过(logits 除以 T 再 softmax)。这里不重复。

❗ 每轮都把整个序列重算一遍,却只用最后一个

bigram 里这非常浪费 —— 前面 T−1 个位置的预测算了也不用。 但这个写法是为注意力准备的:加上注意力之后,最后一个位置会综合前面所有字符, 这个函数一个字都不用改。(真实推理用 KV cache 解决重复计算。)

起点是 idx = torch.zeros((1, 1), dtype=torch.long),也就是 [[0]] —— 编号 0 是换行符, 相当于「新起一段」。换成 encode('ROMEO:\n') 模型就会接着罗密欧的台词往下写。

🔑 这就是 prompt 的本质:把你的话 encode 成 idx,然后跑 generate。


★ 初始 loss 比瞎猜还差一半

初始 loss 4.87 均匀瞎猜 ln 65 = 4.174

4.87 换算回去,模型给正确答案的平均概率是 e⁻⁴·⁸⁷ = 0.77%; 而闭着眼睛均匀猜是 1/65 = 1.54%。

正好是一半。 模型在初始化之后,比什么都不做还差一倍 —— 它在自信地猜错。

病因第十四篇讲过:初始权重太大,logits 的标准差被撑开,softmax 一压就把概率堆到错误的那几个上。 第十篇那个「初始 loss 17 而不是 3.3」是同一个病的更重版本。这里没修 —— 训练几百步自己就下来了。

💡 这把尺子每次都能用:均匀瞎猜的 loss 是 ln(词表大小)。 27 个字符是 3.2958(第十篇那把尺子),65 个字符是 4.174。初始 loss 比它高,说明初始化有问题。


2.49 这条基线

单个 batch 的 loss 噪声很大,所以取 200 个 batch 平均,train 和 val 分开报:

@torch.no_grad()
def estimate_loss():
...
model.eval()
# 抽 eval_iters 个 batch,各自算 loss 再平均
model.train()

这是第二十二篇 split_loss() 的升级版 —— 那里一次喂全部数据,这里抽 200 个 batch, 数据大时显存才装得下。model.eval() / model.train() 对 bigram 没用(没有 BN 也没有 Dropout), 但先写上,后面加了 Dropout 评估时必须关掉。

step 0: train 4.7305, val 4.7241
step 300: train 2.8110, val 2.8249
step 600: train 2.5434, val 2.5682
step 900: train 2.4932, val 2.5088
…
step 2700: train 2.4738, val 2.4911

三件事:

① 900 步之后就平了。 后 1800 步只降 0.02 —— 这是 bigram 的天花板, 它只看前一个字符,再练也就这样。

② train ≈ val,差 0.02。 4225 个参数面对 100 万字符,没有「背答案」的能力。 第十二篇那条判断原样适用:差距小说明「能加大」,绝对值 2.49 高说明「该加大」。

③ 这个 2.49 就是后面所有改进的对照组。 加了注意力之后约 2.4,整套 Transformer 搭完是 1.48。

生成出来的东西已经有了形状:大写基本只在行首、标点在词尾、单词长度合理、 有「角色名 + 冒号 + 换行」的剧本格式、元辅音交替。KIN 差一个 G 就是 KING。

但没有一个真词 —— 只看前一个字符,写到 hou 时它只看到 u,接不出 house。


优化器:第一次用现成的

optimizer = torch.optim.AdamW(m.parameters(), lr=1e-3)

第二十二篇手写的 p.data += -lr * grad 就是最简单的优化器(SGD), optimizer.step() 一句代劳。AdamW 比它多做三件事:

做什么
动量记住前几步的梯度方向,像推一个有重量的球,不会因一次噪声急转弯
自适应学习率每个参数有自己的步长,梯度一直很大的自动缩小步子
W = weight decay每步让参数往 0 缩一点,正则化

所以学习率的量级也不一样:SGD 常用 0.1,Adam 系常用 1e-3 ~ 3e-4 —— 它内部会自己缩放步长。

⚠️ optimizer.zero_grad() 不能漏 —— PyTorch 的梯度是累加的。 第六篇那个「清零 / 不清零」十种子对照实验量过这件事:不清零的版本跨了二十六个数量级。 上一篇写了那么多 +=,就是这个累加语义。


铺的路

现在后面
generate() 只看最后一个字符有了注意力,同一个函数就能综合全部上下文
嵌入表直接出 logits (65,65)拆成 (65, n_embd) + lm_head
一个片段含 T 条样本下三角 mask 就是为了保证这 T 条互不作弊
block_size = 8改成 256,那时才真正决定模型的记忆长度
val 2.49加注意力 → 约 2.4;整套搭完 → 1.48