Skip to content

第 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 当成毫无副作用的动作。

切分点为 s=⌊0.9n⌋,其中 n 是 Python 字符串长度。训练读取 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 要用这个版本?预训练嵌入矩阵的第 i 行已经对应 GPT-2 的第 i 个 token。如果拿字符编号代替 BPE 编号,即使所有编号都在 0 到 50256 之间、程序完全不报错,模型看到的仍是另一种语言编码。形状检查不能发现这种语义错位。

此脚本不写 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 位整数可表示 0 到 216−1=65535。若词表编号从 0 连续排列,则词表大小最多为 65536;若编号稀疏,应检查最大编号而不是只查词表条目数。GPT-2 最大编号 50256 在范围内。将更大词表强行转为 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。

本章来源 ​

保留各书来源与许可 · 示例运行条件以正文为准