Skip to content

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

图 06:助手回答及其 EOS 需要监督,mask 随目标右移。
可切换原始尺寸,滚动查看细节

查看大图

尺寸读取中

适合窗口:完整显示图片,不放大超过原始尺寸。 按 Esc 关闭。

图 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.jsonBOS、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 确定后,还要明确损失怎样平均。对逐位置损失 ℓi 与有效标签 mask mi∈{0,1},希望每个有效 token 权重相同,就使用 L=∑imiℓi/∑imi。分母为零时,应拒绝或跳过样本并记录原因,不能让 NaN 混入训练。如果把 padding 对应的零损失也放进分母,不同长度的样本就会受到不同程度的稀释。

本书的 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 和数字保持完整。窄屏可在各区域左右滑动,或聚焦后用方向键查看。

  1. 原位置 0BOSID 1开始源 mask 0
  2. 原位置 1问ID 2提示源 mask 0
  3. 原位置 2答ID 3回答源 mask 1
  4. 原位置 3EOSID 4真实结束源 mask 1
  5. 原位置 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]

固定 logits → softmax → 目标概率 → −ln p。自然对数,数值显示 6 位小数。
位置 j输入 X[j]目标 Y[j]目标角色labels[j]mask目标概率 p原始 −ln p计入损失原因
0BOS · 1问 · 2提示-10000.5135190.6664680.000000提示不计分
1问 · 2答 · 3回答310.2797081.2740091.274009计入目标
2答 · 3EOS · 4真实结束410.0499302.9971302.997130监督真实 EOS
3EOS · 4PAD · 4右补齐-10000.1250002.0794420.000000补齐不计分

看最后一个真实 token 的边界:X 仍可能是 EOS,但它要预测的 Y 已经是 PAD。判断这一项要不要计分,必须看目标位置。

03

指标 · 只除以有效目标数

有效目标数2分母,不含 −100 位置
有效损失和4.271139每个有效目标的 −ln p 相加
平均 token 损失2.135569损失和 ÷ 有效目标数

L = 4.271139 / 2 = 2.135569

两种规则使用同一组目标和 logits
忽略规则有效目标数真实 EOS 数损失和均值
按真实补齐位置214.2711392.135569
按 PAD ID 全部忽略101.2740091.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,本例的手算与默认类别索引目标的计算口径对应。

类别轴始终有 8 项。共用 ID 时,PAD 也使用类别 4。
j mod 40 · PAD(独立 ID)1 · BOS2 · 问3 · 答4 · EOS5 · 好6 · 吗7 · 是
000200000
100010000
20000-1000
300000000
和框架接口怎样对应?

本实验显式构造 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 平均为 (2+12)/(1+3)=3.5;样本平均为 (2/1+12/3)/2=3,二者优化的加权目标不同。应在填充前记录真实长度或角色跨度,填充位置的 label 改为 -100,真实 EOS 的 label 仍是它的 token ID。仅凭 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 的具体设置;只凭类名重建,不能假定所有默认选项都相同。

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