主题
第 2 章 字符与 BPE:三条数据准备流水线
2.1 同一段文字,token 可以完全不同
模型处理的是整数编号,编号的意义由编码器规定。字符编码把一个字符变成一个编号;GPT-2 的 BPE 把字节序列按已有词表组合成 token。BPE token 不等于一个词,字符 token 也不等于一个 UTF-8 字节。若比较两个模型“看了多少 token”或“每 token 损失多少”,必须先确认它们使用相同编码。
固定仓库提供三条准备流水线。两条使用同一份 Shakespeare 文本,区别在编码方式;另一条按文档处理 OpenWebText。它们最后都写出 train.bin 和 val.bin,但文件名相同不意味着语义可互换。三条数据目录
| 流水线 | 切分单位与方式 | 编码及边界 | 额外元数据 |
|---|---|---|---|
shakespeare_char | 文本前 90% / 后 10% | 排序后的唯一字符集;无额外终止符 | meta.pkl 存词表、正反映射 |
shakespeare | 文本前 90% / 后 10% | GPT-2 encode_ordinary;无额外终止符 | 不写字符映射 |
openwebtext | 文档随机切分,seed 2357,验证比例 0.0005 | 每文档独立 BPE,尾部添加 EOT,再串接 | 中间数据包含 ids 和 len |
2.2 字符版:排序决定编号稳定性
chars = sorted(list(set(data))) 先去重再排序。排序很重要:集合的遍历顺序不应成为模型词表的隐含随机源。之后 stoi 把字符映到整数,itos 把整数映回字符;encode 是逐字符查表,decode 是逐编号查表并拼接。
用本书合成词表 "\n abc" 举例,换行是 0,空格是 1,a、b、c 分别是 2、3、4。encode("ab c") 得到 [2,3,1,4],反向还原必须得到原字符串。这里的整数没有大小语义:4 不代表比 2 更强、更晚或更接近另一个字符。嵌入层会为它们分别学习向量。
原脚本先从完整文本建立字符集,再切分训练和验证。这意味着验证集中的字符身份参与了词表定义,但验证序列没有作为训练目标。这种做法不等于完全严格的“仅从训练集拟合预处理”;如果研究要求严格隔离,应由训练集定义词表,并为未见字符设计未知符号策略。原脚本没有未知符号,查询缺失字符会直接报错。字符脚本
字符版原脚本读写 input.txt 时没有显式指定编码,而 BPE 版指定 UTF-8;在默认编码不同的系统上,这也是需要固定的输入条件。OpenWebText 的大型处理主体放在 if __name__ == "__main__" 下,适配其多进程运行方式,但编码器初始化仍在文件顶层,不能把 import 当成毫无副作用的动作。
切分点为 data[:s],验证读取 data[s:]。两边各自编码、各自写文件,因此训练窗口不会跨入验证集。不过戏剧的连续内容可能高度相关;这是一种固定的顺序留出实验,不是跨作者、跨作品的泛化评估。
脚本最后用 pickle 保存 vocab_size、stoi、itos。这三个字段分别供训练构造输出层、采样编码提示、采样解码生成编号。pickle 能执行反序列化逻辑,不能读取陌生人发来的未审查 meta.pkl。本书只加载自己刚写出的元数据。
2.3 Shakespeare BPE:复用 GPT-2 词表的必要性
BPE 版在原始字符串上完成 90/10 切分,再调用 enc.encode_ordinary。这使两边的 token 化各自独立;切分点附近的 token 边界不一定等于“先整体编码再切 token 数量”的结果。encode_ordinary 把看似特殊 token 的文字当普通内容处理,不自动把它识别成控制符。
为什么微调 GPT-2 要用这个版本?预训练嵌入矩阵的第
此脚本不写 meta.pkl,采样脚本因而默认回到 GPT-2 BPE。默认值建立在“确实使用 GPT-2 编码”这个前提上。自制另一套 BPE 后仅省掉元数据,并不会让采样程序自动推断你的编码器。BPE 脚本
两个 Shakespeare 下载器都采用“文件存在就不再下载”的策略,没有校验 HTTP 状态、哈希和内容类型。错误页面可能被保存为 input.txt,之后仍能被编码和训练。正式准备数据时应先验证响应、编码、许可证和内容摘要;本书不执行这条联网路径,而用自建小语料验证下游协议。
2.4 OpenWebText:文档结束符与连续存储
process(example) 有两个输出:ids 是正文的 token 列表加一个 EOT,len 是加完后的长度。EOT 是一个可预测的符号,不是一堵自动隔离注意力的墙。文档被串接后,窗口可能跨过 EOT;因果注意力仍能访问窗口内前一篇文档。若任务要求文档间绝不互看,需要额外的注意力边界逻辑,原仓库没有实现。
脚本用 Hugging Face 的 train_test_split 按文档打乱并分出验证集,再把 test 重命名为 val。.map(process, remove_columns=['text']) 把原始文本替换为编号及长度。num_proc 控制 token 化进程数量,num_proc_load_dataset 控制加载阶段并发;它们不是训练 GPU 数量,也不应凭“越大越快”盲目设置。OpenWebText 准备脚本
写盘先用 np.uint64 求总长度,避免几十亿 token 的计数溢出;再建立形状为总 token 数的一维 memmap。1024 个连续 shard 依次转为 NumPy 数据、拼接 ids、写入 [idx:idx+len(arr_batch)],随后更新 idx。这里的 idx 是已写 token 数,而不是字节偏移;每个 uint16 占两个字节。最后 flush() 将修改刷到文件。
固定 1024 个 shard 适合上游大数据路径,却不是任意小数据都能照用的常数。若把数据缩到几个文档,某些 shard 可能为空,np.concatenate 就可能没有数组可拼。缩小实验必须同时调整分片方式,不能只把下载数量改小。
2.5 uint16 是协议,也是一个硬边界
无符号 16 位整数可表示 uint16 可能发生溢出、回绕或转换错误,取决于转换入口及 NumPy 版本。本书明确测试了先建 int64 再 .astype(np.uint16):65536 变成了 0。这会把一种 token 静默改成另一种。
如果改成 uint32,准备脚本和所有读取器都必须一起修改,包括 train.py 与 bench.py。只改写入端会让读取器把一个 32 位编号拆成两个 16 位编号,损失仍可能下降,却是在训练损坏的数据。
上游三个 readme 报告的 token 数是历史语料结果:字符版训练 1,003,854、验证 111,540;BPE Shakespeare 训练 301,966、验证 36,059;OWT 训练约 90 亿、验证约 443 万。这些数字用于解释存储规模,不是本书重新下载所得。
2.6 练习与检查
补充练习 1。 为什么 decode(encode(s)) == s 通过,还不能证明训练和采样词表一致?
答案。 每套自洽映射都可能往返成功。还要比较训练时保存的映射与采样实际加载的映射,最好记录词表文件哈希;交换两个 token 的编号仍能保持各自的往返性质。
补充练习 2。 训练文件有 7000 个 uint16 token,理论原始文件长度是多少?改为 uint32 呢?
答案。 分别为 14000 和 28000 字节,不含任何头部。tofile 写的是原始数组,不携带 dtype、端序、词表或形状说明,因此应另外保存这些约定。完整本地数据生成代码见 examples/tiny_pipeline.py,边界反例见 examples/verify_mechanics.py。