初始 loss 比瞎猜还差一半:从莎士比亚到 2.49 这条基线
上一篇把 backward() 拆到了底。这一篇换数据集重开 —— 从这里到阶段一结束,目标只有一个:
从零写出一个 GPT。
第六集(WaveNet)跳过。 它把上一篇那个 MLP 改成层级结构、loss 再降一截, 但用的还是已经拆完的那套零件 —— 按性价比取舍,直接进第七集。 所以来源清单里 Ep.5 之后直接是 Ep.7,那个空档是有意的。
数据换了,切法也得换
names.txt(第 7—22 篇) | input.txt(本篇起) | |
|---|---|---|
| 大小 | 228 KB | 1.1 MB(1,115,394 个字符) |
| 结构 | 32033 行,每行一个名字 | 一整块连续文本 |
| 样本边界 | 明确 | 没有 —— 从哪切到哪都行 |
| 词表 | 27 | 65(大小写 + 标点 + 空格 + 换行) |
词表按 ASCII 排序:0 是换行,1 是空格,然后标点、大写、小写。
整部莎士比亚只出现过数字 3 —— 字符级词表完全由数据决定,数据里没有的字符遇到就 KeyError。
n = int(0.9 * len(data))train_data = data[:n] # 前 90%:1,003,854val_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… | 20 | 65~256 | 序列太长 |
| 词级 | The · cats · are · unhappy | 4 | 几万~几十万 | OOV —— 词表外的词只能变 <UNK> |
| 子词级(真实 LLM) | The · cats · are · un · happy | 5 | 3 万~15 万 | 无 —— 兜底拆到字节 |
子词级按频率决定:常见词整个是一个 token,罕见词拆成几块,再罕见的一路拆到字节 —— 所以永远不会有 OOV。
❗ 为什么序列长度这么要紧
注意力的计算量是 O(T²)。 同一段文本:
| token 数 | 注意力的量级 | |
|---|---|---|
| 字符级 | 1000 | 1,000,000 |
| 子词级 | 250 | 62,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) 两个维度
| 维度 | 含义 | 来自 | 训练时 |
|---|---|---|---|
| B | batch,并行几个序列 | batch_size | 32 |
| T | time,每个序列多长 | block_size | 8 |
每一步 32 × 8 = 256 个预测。 像一套卷子 32 页、每页 8 道填空,每页题目来自书的不同章节, 一次批改完整套。
⚠️ 但这 256 个不完全是独立样本 —— 同一序列的 8 个位置来自原文同一处。 bigram 里没区别(只看当前字符),有了注意力就有区别了:第 3 个位置不能偷看第 5 个位置的答案。 这就是后面那个下三角 mask 存在的全部理由。
查表就是一层全连接
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)| 字母 | 含义 | 值 |
|---|---|---|
| B | batch,几个序列 | 32 |
| T | time,每个序列几个位置 | 8 |
| C | channel,每个位置几个数 | 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 |
| 1 | H | H 后面是什么 | 知道,是 e |
| 2 | e | e 后面是什么 | 不知道 ← 要的就是这个 |
前 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.1744.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.7241step 300: train 2.8110, val 2.8249step 600: train 2.5434, val 2.5682step 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 |