主题
第 06 章 Tokenizer 和标签决定模型实际学到什么

图 06:助手回答及其 EOS 需要监督,mask 随目标右移。(本书自绘)
模型并不会直接看到一段“用户提问”和“助手回答”。它看到的是一串 token ID,以及哪些位置需要计算损失的约定。分词、聊天模板、填充和标签对齐只要有一处不一致,训练就可能顺利运行,却学到另一件事。
本章跟着一条样本走:先把文本变成 token,再整理成可重复读取的数据,最后确定每个输入位置要预测什么、哪些目标应当参与损失。读到结尾时,可以拿一条真实样本逐项核对,而不只是确认张量形状没有报错。
06.1 切分粒度是在词表、长度与覆盖率之间取舍
把整词作为一个 token,常见词只需一个 ID,序列可以较短;代价是词表容易变大,没见过的词也难处理。按字符切分时,词表通常较小,新组合也容易表示,但同一段文字需要更多位置。子词试图在这两者之间取舍:常见片段合并,罕见词拆开。[源教程第五章 §5.2.1—§5.2.3。]
这种切分来自算法和语料,不等于模型理解了词源。即使 BPE 恰好切出一个像词根的片段,也不能据此说它已经学会形态学。
BPE 的基本训练过程是统计相邻符号对,把最常见的一对合并成新符号,然后继续统计、合并。假设语料是 低 温、低 温、低 压,其中 低 温 出现两次,低 压 只有一次,因此可以先合并“低温”。
训练结束后,编码新文本要使用已经学到的合并规则,而非在每条新输入上重新统计。合并顺序必须保存,频率并列时的选择规则也要固定,否则相同语料未必得到相同词表。
其他分词方法的选择标准并不完全相同。WordPiece 通常从词表构造和子词概率的角度选择片段,编码常用最长匹配;Unigram 则从较大的候选词表出发,为切分路径赋概率,再迭代删减候选。不能因为最后都得到子词,就把它们写成同一个算法。
SentencePiece 又处在另一个层次:它是支持不同分词模型的实现工具,并非与 BPE、Unigram 并列的一条独立数学目标。选工具时,要一起看分词模型、文本规范化器(normalizer)、预切分器(pre-tokenizer)和解码器(decoder)。只保存 token 列表,还不足以重建整条转换过程。[源教程 §5.2.3 的三种算法。]
06.2 一个 tokenizer 文件里不止词表
上游 train_tokenizer.py 的 train_tokenizer 把这些环节接在一起:构造 BPE 模型,先用 NFKC 规范化文本,再做 ByteLevel 预切分,交给 BpeTrainer 训练,并配置 ByteLevel 解码。[源代码 train_tokenizer。]
第一步就可能改变原文。NFKC 会折叠某些兼容字符,例如全角拉丁字母。因此,检查“编码后再解码是否等于原字符串”之前,要先明确是否允许这种变化;若任务接受规范化,应与约定的规范化结果比较。必须保留原始字形的任务,不能直接照搬这一配置。
ByteLevel 把 UTF-8 字节映射为可表示的符号,再由 BPE 合并。完整的初始字节表有助于覆盖任意 UTF-8 文本;特殊 token 仍有独立的处理规则。训练器设置 min_frequency=2,函数默认词表大小为 8192,主函数实际请求 6144。读配置时要追到调用处,不能把默认值当成本次训练值。
数据量也不能只看进度条。这里的 length=os.path.getsize(data_path) 使用文件字节数作为迭代器长度提示,并不是文本条数,主要会使进度估计失真。真正有多少样本,还得从读取结果统计。
负责读取的 read_texts_from_jsonl 逐行解析 JSON 并提取 text。坏 JSON 和缺字段会打印错误并跳过,但它没有检查 text 一定是字符串。使用这类管线时,还应统计有效记录与跳过记录的数量,否则数据减少了,训练日志里未必容易察觉。
训练后,create_tokenizer_config 写入聊天模板与特殊 token 映射;eval_tokenizer 尝试加载结果,检查属性、模板、截断后的编码解码及特殊 token。main 的数据路径仍是占位符。本章原有验证没有执行 tokenizer 训练或 AutoTokenizer 加载,当时的 CPU 环境未安装相应依赖;下面的资产核对属于静态检查,不能写成真实编码测试。
保存结果时,三个文件应当作为同一版本管理:
| 文件 | 主要保存什么 |
|---|---|
tokenizer.json | 词表、merge 顺序、规范化和预切分等机器状态 |
tokenizer_config.json | BOS、EOS、PAD、聊天模板与类名 |
special_tokens_map.json | 特殊 token 的语义角色 |
配置中的巨大 model_max_length 是框架表示“未设具体限制”的占位值,不是模型能接受的真实长度。实际输入仍须满足模型的位置和上下文配置。
这里五个特殊 token 的 ID 分别是:<unk> 为 0,<s> 为 1,</s> 为 2,<|im_start|> 为 3,<|im_end|> 为 4。配置却让 EOS 和 PAD 共用 <|im_end|>。于是,同一个 ID 4 既可能表示真实消息结束,也可能只是补齐长度。
如果仅凭“ID 等于 PAD”就忽略损失,真实结束标记也会被删掉。要区分这两种用途,应依据实际填充位置建立 mask。第 07 章会继续检查模型中的 ignore_index:数据端区分对了,损失函数也必须遵守同一约定。
06.3 原始数据转换必须可重复,也必须保留对话角色
tokenizer 准备好后,还要确定它读到什么数据。上游预训练 JSONL 的每行是一个含 text 的对象。deal_dataset.split_text 默认每 512 个 Python 字符切一块;这里数的是字符,并非 token,所以分词后仍可能超过长度,也可能在句子或代码中间截断。[源教程 §5.3.1 及 deal_dataset.py。]
聊天数据则经过 convert_message:它添加 system 消息,把 from=human 转成 role=user,把 from=assistant 转成 role=assistant。其他角色会被忽略。换一份数据时,必须先检查 schema,也就是字段和角色名称的约定;角色名字不同,可能意味着整段消息没有进入输出。
转换结果还应经得起重复运行。源程序以 append 模式打开输出,每运行一次就继续追加;同时用 readlines 一次读取整个输入文件,大文件会带来明显的内存开销。若只是反复执行原命令,既可能得到重复样本,也难以知道哪次运行产生了哪些内容。
更可复现的做法是逐行处理,写入新的临时文件,记录输入输出哈希以及有效、无效记录数,成功后再替换目标。这样,同一输入重复转换不会不断扩张输出。去重仍可能有用,但不应代替转换本身的幂等性。
获取数据的脚本也需要单独阅读。download_dataset.sh 用 ModelScope 下载并解压 seq-monkey,用 Hugging Face CLI 下载 BelleGroup;其中配置了镜像和本地路径占位符。第一条下载命令的路径变量没有完整引号保护,带空格的路径可能被拆成多个参数。
Windows 文件虽然以 .sh 结尾,里面实际分别列出 PowerShell 和 CMD 语法,应在对应 shell 选择合适的一段,不能整份交给 Bash。下载前还要核对数据许可、下载量、磁盘空间和网络来源。本章只解释这些入口,没有执行下载或安装命令。
06.4 按字节索引 JSONL,按 token 右移标签
完成转换后,PretrainDataset 需要能按样本定位文件。PretrainDataset.__init__ 以二进制方式记录每行起始字节偏移;__len__ 返回行数,__getitem__ 则重新打开文件,seek 到相应偏移,读取一行并按 UTF-8 解码。中文字符可能占多个字节,因此不能把字符数直接当作文件字节位置。
这种索引依赖文件保持不变。建立偏移后再修改文件,原来的位置就可能失效;空行和坏 JSON 也不会因为建立了索引就自动被清理。
读出 text 后,源实现添加 BOS 字符串,编码、截断并在右侧补齐,再构造 X=ids[:-1]、Y=ids[1:]。输入位置要预测的是原序列中的下一个 token。因此 max_length=512 最多形成 511 个预测位置,loss mask 也要跟着目标取 mask[1:]。[源教程 §5.3.3 PretrainDataset。]
可以把一条样本完整列出来:完整 ID 为 [BOS,甲,乙,EOS,PAD],输入是 [BOS,甲,乙,EOS],目标是 [甲,乙,EOS,PAD],目标损失 mask 为 [1,1,1,0]。前三个目标应当监督,最后一个只是 padding。若只看输入 X 是否有效,最后的 EOS 也会被视为有效输入,便会把它预测 PAD 的位置多算一次。
EOS 的作用也要与真实数据配方一致。希望模型学会结束,就应保留真实结束标记的监督;但上游预训练 Dataset 只显式添加 BOS,没有自动补 EOS。若 tokenizer 也不补 EOS,这条路径就没有提供终止监督,不能在说明中擅自声称它已经训练了 EOS。
06.5 SFT mask 必须跟着消息边界走
预训练例子监督连续文本中的有效目标。SFT 通常还要区分谁在说话:用户和 system 消息提供上下文,希望拟合的则是助手回答及其结束标记。
SFTDataset 沿用同样的字节索引方式,把每行的消息对象列表交给聊天模板,生成 system/user/assistant 文本,再编码、截断、填充、生成 mask,最后对齐输入和目标。角色标记与换行也是 token 序列的一部分,修改模板就会改变模型实际看到的训练分布。[源教程 §5.3.3 SFTDataset。]
源 generate_loss_mask 从 token 序列中搜索 <|im_start|>assistant\n,找到后继续寻找第一个 EOS,把助手正文至 EOS 的区间标为 1。注释写的是“找到第一个 4”,实际代码用的是 tokenizer.eos_token_id,应以执行代码为准。
这种事后扫描有两个边界。用户正文如果包含同样的角色标记,扫描可能把它误认成消息结构;如果截断移除了助手 EOS,扫描找不到结束位置,整段回答可能没有被标记。更可靠的管线是在渲染消息时保留角色跨度,或直接构造 assistant mask,让结构来自消息数据,而非从不受信任的正文猜测。
mask 确定后,还要明确损失怎样平均。对逐位置损失
本书的 core_checks.py --section labels 使用整数 fixture 构造三项助手监督,检查被忽略的目标位置对应的 logits 梯度严格为零。这是在验证标签与损失的约定,不是运行上游 tokenizer。也不要把“目标被忽略”理解成那段输入对训练毫无作用:它仍可能作为上下文影响后面的助手预测。
交互实验:标签右移与损失掩码
数据实验室 · 标签与损失
下一格是目标,这一格该不该计分?
保留提示、回答和补齐的角色,逐格对齐输入与目标,再算真正参与平均的损失。EOS 与 PAD 即使同 ID,也不该一起删掉。
01
输入 · 先分清角色
勾选:都用 4。取消:EOS 用 4,PAD 用 0。词表始终保留 8 个类别。
0–19。在 X/Y 对齐后,从左侧保留相同数量的位置。
教学词表:问=2、答=3、好=5、吗=6、是=7;空白只用于分隔。这里按字查表,不运行 BPE 或聊天模板。BOS、EOS 与 PAD 由页面添加。
02
过程 · 角色跟着目标走
步骤 1 / 4
先保留角色和真实位置。EOS 与 PAD 都用 ID 4,但只有 EOS 是真实结束标记;这一步还没有移位。
token 和数字保持完整。窄屏可在各区域左右滑动,或聚焦后用方向键查看。
- 原位置 0BOSID 1开始源 mask 0
- 原位置 1问ID 2提示源 mask 0
- 原位置 2答ID 3回答源 mask 1
- 原位置 3EOSID 4真实结束源 mask 1
- 原位置 4PADID 4右补齐源 mask 0
上方展示截断前的原序列。源 mask 先按角色和监督范围建立;目标 mask 取它的下一格,并跟 X/Y 一起截断。错误对照会改用 ID 判断补齐。
X = [1, 2, 3, 4]
Y = [2, 3, 4, 4]
labels = [-100, 3, 4, -100]
loss mask = [0, 1, 1, 0]
输入有效位置 = [1, 1, 1, 1]
| 位置 j | 输入 X[j] | 目标 Y[j] | 目标角色 | labels[j] | mask | 目标概率 p | 原始 −ln p | 计入损失 | 原因 |
|---|---|---|---|---|---|---|---|---|---|
| 0 | BOS · 1 | 问 · 2 | 提示 | -100 | 0 | 0.513519 | 0.666468 | 0.000000 | 提示不计分 |
| 1 | 问 · 2 | 答 · 3 | 回答 | 3 | 1 | 0.279708 | 1.274009 | 1.274009 | 计入目标 |
| 2 | 答 · 3 | EOS · 4 | 真实结束 | 4 | 1 | 0.049930 | 2.997130 | 2.997130 | 监督真实 EOS |
| 3 | EOS · 4 | PAD · 4 | 右补齐 | -100 | 0 | 0.125000 | 2.079442 | 0.000000 | 补齐不计分 |
看最后一个真实 token 的边界:X 仍可能是 EOS,但它要预测的 Y 已经是 PAD。判断这一项要不要计分,必须看目标位置。
03
指标 · 只除以有效目标数
有效目标数2分母,不含 −100 位置
有效损失和4.271139每个有效目标的 −ln p 相加
平均 token 损失2.135569损失和 ÷ 有效目标数
L = 4.271139 / 2 = 2.135569
| 忽略规则 | 有效目标数 | 真实 EOS 数 | 损失和 | 均值 |
|---|---|---|---|---|
| 按真实补齐位置 | 2 | 1 | 4.271139 | 2.135569 |
| 按 PAD ID 全部忽略 | 1 | 0 | 1.274009 | 1.274009 |
按 PAD ID 忽略会漏掉 1 个真实 EOS。即使平均损失更小,也只是少评分了一个目标,不能据此说模型更会回答。
04
解释 · 不计分,仍能作为上下文
loss mask 控制哪些输出位置计入损失;attention mask 控制能读取哪些输入。提示标签设成 −100,不会从输入中删去提示,回答仍可依赖它。上面的“输入有效位置”只标出真实输入与补齐,因果注意力还会限制读取未来位置。
−100 只表示“这个标签不计分”,不是词表 ID,不能送进 embedding。这里的 X 始终是非负的词表 ID。
这些损失是怎么算出来的?
页面使用 8 类固定 logits。四行按预测位置 j 循环使用,先做 softmax,再取真实目标的概率并计算 −ln p;没有训练模型,也没有根据文本生成预测。这组数只用来核对移位、掩码和分母,不表示语言能力。
下面是未经 softmax 的分数,不是概率;未做类别加权或标签平滑。PyTorch 的 CrossEntropyLoss 接受 logits,本例的手算与默认类别索引目标的计算口径对应。
| j mod 4 | 0 · PAD(独立 ID) | 1 · BOS | 2 · 问 | 3 · 答 | 4 · EOS | 5 · 好 | 6 · 吗 | 7 · 是 |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
| 2 | 0 | 0 | 0 | 0 | -1 | 0 | 0 | 0 |
| 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
和框架接口怎样对应?
本实验显式构造 X=ids[:-1]、Y=ids[1:],用于展示原理。Hugging Face GPT2LMHeadModel 的 labels 会在模型内部移位,调用时要遵守它的接口约定。本页不调用模型,也不把这里的已移位数组当作它的调用示例。
截断在对齐后同时裁剪 X、Y 和 mask。真实角色由构造时的元数据保留,不从 token 文本中猜。真实数据还需要正确的聊天模板、分词与批处理;本实验没有覆盖完整训练流程。
不操作也能读懂:原序列 [BOS, 问, 答, EOS, PAD] 的 ID 是 [1, 2, 3, 4, 4]。只训练回答时,移位后的 labels 为 [−100, 3, 4, −100]。提示“问”作为输入保留;回答“答”和真实 EOS 共两个目标参与平均。
依据:PyTorch 2.6.0:CrossEntropyLoss;Transformers 4.57.1:GPT-2 labels 接口。核对于 2026-10-14。
06.6 练习与答案
补充练习。 一批两条回答分别含 1 和 3 个有效 token,总负对数似然分别为 2 和 12。按 token 平均损失是多少?按样本分别求均值后再平均是多少?EOS 与 PAD 同 ID 时,怎样保留 EOS 的监督?
答案。 token 平均为 labels==pad_id 改 -100 会删掉真实 EOS。
06.7 来源与运行范围
本章改编 Datawhale Happy-LLM 第五章 §5.2—§5.3.3 和对应 tokenizer、数据处理脚本,CC BY-NC-SA 4.0;新增协议检查及边界分析。固定源码目录。本书实际训练例采用本地字符词表,不能替代 BPE 质量测试。原理参考:BPE 子词、SentencePiece。
06.8 固定 tokenizer 资产的静态核对
原有静态核对记录显示,仓库 JSON 中的词表有 6144 项,merge 规则有 5883 条;normalizer 为 NFKC,pre-tokenizer 与 decoder 为 ByteLevel。资产未配置 post_processor,也没有默认的 truncation 或 padding。五个 added_tokens 的 ID 与 §06.2 一致,均标记为 special,不参与普通规范化。
这些结果说明文件保存了哪些配置,但不能证明任意中文字符串会得到什么编码。核对 JSON 无需加载 tokenizer 库,实际编码仍需另外测试。保存训练代码和资产时,还应一起保留 ByteLevel 的具体设置;只凭类名重建,不能假定所有默认选项都相同。