这篇只看一件小事:nanoGPT 里 data/shakespeare_char/prepare.py 是怎样把 Tiny Shakespeare 变成字符级语言模型可以吃的 train.bin、val.bin 和 meta.pkl 的。
这段脚本短到像玩具,但它把语言模型最核心的地基摊开了:模型并不直接读“文字意义”,它读的是一串整数 id;训练目标也不是先理解莎士比亚,而是在给定前文时预测下一个离散符号。GPT-2 路线通常用 byte-level BPE token,nanoGPT 这个配置故意退回到字符:一个字符就是一个 token。
源码范围是 nanoGPT 的 data/shakespeare_char/prepare.py。对应训练配置是 config/train_shakespeare_char.py,它把 context length 设为 256 个字符。
1. 先把语言模型讲到最小
语言模型的基本问题可以先粗暴地写成一句话:
看前面的符号,猜下一个符号。
Bengio 等人在 2003 年的 neural probabilistic language model 里,把问题放在“词序列概率”和“分布式表示”上;Graves 2013 年用 RNN/LSTM 展示了逐点预测也能生成有长程结构的序列,其中离散文本就是典型例子。后来的 Transformer 把序列建模主干从 recurrence 推到 self-attention;GPT-2 又把 byte-level BPE tokenizer、大规模 WebText 和 Transformer 串成现代 LLM 的经典路线。
但这些大词汇都可以先放下。对字符级 Shakespeare 来说,最小版本是:
字符 -> 字符 id -> embedding -> Transformer -> 下一个字符 id
也就是说,tokenizer 在这里不学习复杂切分规则,只做一张小字典。
2. 最小数学地基:字符表、整数 id 和 next-token 样本
先用一个玩具文本:
ABBA
如果语料里只有两个字符,排序后得到:
chars = ["A", "B"]
stoi = {"A": 0, "B": 1}
itos = {0: "A", 1: "B"}
编码之后:
ABBA -> [0, 1, 1, 0]
训练时不会把整段文本当成一个标签,而是做错位一格的输入和目标:
x = [0, 1, 1]
y = [1, 1, 0]
这句话翻成人话就是:
看到 A,目标是 B
看到 AB,目标是 B
看到 ABB,目标是 A
nanoGPT 的 train.py 后面也是这个逻辑:从 train.bin 或 val.bin 里用 np.memmap 读一段 id,x 是当前位置开始的 block_size 个 id,y 是整体右移一位的下一个 id。字符级模型和 GPT-2 BPE 模型在这里共享同一个 next-token 训练形式,差别只是“token 到底是什么”。
3. prepare.py 的数据流
这段脚本的数据流很干净:
flowchart LR
A["input.txt<br/>Tiny Shakespeare"] --> B["收集全部唯一字符"]
B --> C["sorted(chars)"]
C --> D["stoi: char to id"]
C --> E["itos: id to char"]
A --> F["90% train<br/>10% val"]
F --> G["encode 成整数 id"]
G --> H["np.uint16 数组"]
H --> I["train.bin"]
H --> J["val.bin"]
D --> K["meta.pkl"]
E --> K
K --> L["训练时确定 vocab_size<br/>采样时 decode"]
关键步骤可以拆成五段。
第一段:如果本地没有 input.txt,脚本从 Karpathy 的 char-rnn 仓库下载 Tiny Shakespeare 文本。这个数据集常用于字符级语言模型 demo。
第二段:读完整文本,拿 set(data) 得到出现过的所有字符,再排序:
chars = sorted(list(set(data)))
vocab_size = len(chars)
这次输出是:
length of dataset in characters: 1,115,394
vocab size: 65
所以模型的输入词表不是 GPT-2 的 50,257 左右,而是 65 个字符。换句话说,embedding 表只需要 65 行,每一行对应一个字符 id。
第三段:建立双向映射:
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
stoi 用于训练前编码,itos 用于采样后把 id 拼回字符串。这个设计非常朴素,但它让整个 tokenizer 变得可见:没有 merge rule,没有 byte fallback,也没有特殊 token 魔法。
第四段:按原始文本顺序做 90/10 切分,然后分别编码:
train has 1,003,854 tokens
val has 111,540 tokens
注意这里的 token 就是字符 token。另一个 data/shakespeare/prepare.py 会用 GPT-2 BPE tokenizer 处理同一类 Shakespeare 数据,得到的 token 数少很多:train.bin 约 301,966 个 token,val.bin 约 36,059 个 token。BPE 一个 token 往往覆盖多个字符,所以序列更短。
第五段:保存二进制 id 和元信息:
train_ids = np.array(train_ids, dtype=np.uint16)
val_ids = np.array(val_ids, dtype=np.uint16)
uint16 能存 0 到 65,535。字符级 Shakespeare 只有 65 个 id,GPT-2 词表也能放进来;如果以后换成更大的词表,才需要考虑 uint32 或其他存储格式。meta.pkl 则保存 vocab_size、stoi、itos。训练脚本会用它设置模型的 vocab_size;采样脚本如果能找到它,就用同一套字符映射 decode,否则会退回假设 GPT-2 tokenizer。
4. 为什么要故意不用 BPE
从工程效率看,BPE 更像现代 LLM 的默认选择。Sennrich 等人的 subword units 论文讨论了用子词缓解 rare word 和 open vocabulary 问题;GPT-2 论文则使用 byte-level BPE,把开放文本压成更短、更稳定的 token 序列。它的好处很明显:
更短的序列 -> 更少的 attention 步数 -> 更适合大规模训练
字符级建模反过来。它有几个好处:
1. tokenizer 极其透明,每个 token 就是一个字符。
2. 没有 OOV,语料中出现过的字符都能表示。
3. 适合教学,因为能直接看见“文本 -> id -> 训练样本”的全过程。
代价也同样明显:
1. 序列更长,同样一段文本要预测更多步。
2. 模型要自己从字符中学出单词、标点、换行、说话人格式。
3. 对长程依赖更吃 context length 和训练预算。
这就是 nanoGPT 这个 demo 有意思的地方:它不是说字符级比 BPE 更适合大模型,而是把语言模型的机制压到最低复杂度。你先看懂字符级,再去看 byte-level BPE、embedding、attention、loss,脑子里就不容易把“tokenizer 的复杂性”和“语言模型目标”混成一团。
5. 和论文脉络怎么接上
这段脚本可以挂到几条论文线索上看。
第一条是神经语言模型。Bengio et al. 2003 的重点是:用神经网络同时学习符号表示和序列概率,缓解传统 n-gram 的稀疏问题。nanoGPT 这里的符号不是 word,而是 character,但“把离散符号映射到向量,再预测下一个符号”的味道是连着的。
第二条是字符级序列生成。Graves 2013 展示了 RNN 可以通过一步步预测生成复杂序列。Karpathy 的 char-rnn 后来把这件事做成了非常有传播力的教学项目,Tiny Shakespeare 也来自这条传统。nanoGPT 把 RNN 主干换成 causal Transformer,但数据准备仍然保留了字符级直觉。
第三条是 Transformer。Vaswani et al. 2017 证明 self-attention 可以替代 recurrence/convolution 成为强序列建模主干。Al-Rfou et al. 的 character-level deeper self-attention 又说明,字符级语言模型并不天然绑定 RNN;深层 self-attention 也可以直接做字符级预测。
第四条是 tokenizer 的工程演进。Sennrich et al. 2016 的 BPE 子词路线、GPT-2 的 byte-level BPE 路线,解决的是另一个现实问题:自然语言开放、词形变化多、字符级太长,工程上需要压缩序列并保留可泛化的文本片段。nanoGPT 这个脚本标题里那句“instead of encoding with GPT-2 BPE tokens”说的正是这个分岔点。
6. 一个容易忽略的小边界
这个 demo 是先从全量 input.txt 里收集字符表,再切 train/val。对 Tiny Shakespeare 来说,这样做很方便,也不会泄露具体的下一个字符标签;但如果要做非常严格的实验,可以先切训练集,再只用训练集建立 vocab,并为验证集中训练没见过的字符设计 <unk> 或 byte fallback。
另外,meta.pkl 不是可有可无。没有它,训练时可能拿不到正确的 65 字符 vocab_size,采样时也不知道怎样把 id 还原成字符。很多“模型能训练但采样乱码”的问题,第一眼就该检查数据目录里的 tokenizer 元信息和 checkpoint 是否匹配。
7. 总结
prepare.py 的核心并不是下载 Shakespeare,而是完成三件事:
1. 定义模型世界里有哪些离散符号:65 个字符。
2. 把文本变成整数序列:字符 -> id。
3. 保存训练和还原所需的公共约定:train.bin / val.bin / meta.pkl。
如果说 GPT-2 BPE tokenizer 是现代 LLM 的工程入口,那么字符级 Shakespeare 就是语言模型的显微镜。它去掉了复杂 tokenizer 的遮挡,让你直接看见最朴素的一件事:语言模型训练,本质上是在一串符号里,一步一步学习“下一个会是什么”。
参考资料
- Yoshua Bengio, Réjean Ducharme, Pascal Vincent, Christian Jauvin, A Neural Probabilistic Language Model, JMLR, 2003.
- Alex Graves, Generating Sequences With Recurrent Neural Networks, arXiv, 2013.
- Rico Sennrich, Barry Haddow, Alexandra Birch, Neural Machine Translation of Rare Words with Subword Units, ACL, 2016.
- Ashish Vaswani et al., Attention Is All You Need, NeurIPS, 2017.
- Rami Al-Rfou et al., Character-Level Language Modeling with Deeper Self-Attention, arXiv, 2018.
- Alec Radford et al., Language Models are Unsupervised Multitask Learners, OpenAI, 2019.
- Andrej Karpathy, char-rnn and nanoGPT.