3.1 损失与掩码:只对回答算 loss


3.1 损失与掩码:只对回答算 loss

本节摘要:SFT 训练循环与预训练几乎逐字相同——下一词预测、交叉熵、Adam——唯一本质的区别藏在 labels 里:prompt 部分的 loss 被置为忽略。本节用"考试只批答案不批题目"的直觉讲清 prompt masking 为什么必须做(不做的话模型分心学"怎么生成一个好问题",甚至学会自己编用户),然后给出按消息边界构造掩码的完整代码:模板分两次渲染(一次加回答、一次不加),两次的长度差即回答段的区间,labels 在此区间之外全部置 -100。这个"分段渲染求差"的技巧也是 nanochat 官方做法(发布帖口径:只在回答 token 上计算 loss)的通用化表达。

学习目标

阅读完本节,你应当能够:

  1. 解释不 mask prompt 会发生什么、为什么 mask 了更好。
  2. 用"两次渲染求差"法写出任意对话的掩码构造代码。
  3. 说出 -100 的来历(PyTorch 交叉熵的忽略标记)。

一、直觉:考试只批答案

一条样本序列化后长这样(示意,special tokens 详见第 4 章):

<|user|>中国最长的河流是哪一条?<|assistant|>中国最长的河流是长江……<|endoftext|> └──────────── 条件段(prompt)──────────┘└──────── 学习段(回答)──────┘

两个段在 loss 里的地位应当不同:

参与 loss? 理由
条件段(user/system + special tokens 边界) 它是"题目"——给模型看的条件,不是要学的话
学习段(assistant 回答 + 终止符) 它是"答案"——模型要学的东西,包括何时停

不 mask 的两个具体恶果:

  1. 目标被稀释:模型拿一半梯度学"怎么生成一个像样的用户提问"——这是用户模拟器的目标,不是助手的。
  2. 行为漂移:见多了"问题之后紧跟另一个问题"的序列统计,模型续写时更倾向"替用户再问一句"——0.1 节里 base 模型的老毛病被数据亲自教回来了。

💡 special tokens 的边界标记(如 <|assistant|> 之后的第一个 token)习惯上划入学习段:模型必须学会"见到 assistant 标记就开始产出回答",这个转移本身是行为的一部分。终止符更是一定要算——"何时停"是 SFT 要教的第一课

二、实现:两次渲染求差

思路:把同一条消息列表渲染两次——一次不含最后那条 assistant 回答(得到条件段长度 L),一次完整渲染(得到总长 T)。学习段就是 [L, T),labels 的该区间保留、其余置 -100。

# build_sft_example.py —— 掩码构造:两次渲染求差(写法示意,以官方仓库 README 为准) import torch from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained("base/tokenizer") def build_example(messages: list[dict]) -> dict: """messages 须以 assistant 消息收尾(2.1 契约)。""" # 渲染一:完整对话(含最后一条 assistant 回答) full_ids = tok.apply_chat_template(messages, tokenize=True) # 渲染二:去掉最后一条 assistant 回答,得到条件段 prompt_ids = tok.apply_chat_template(messages[:-1], tokenize=True, add_generation_prompt=True) L, T = len(prompt_ids), len(full_ids) assert 0 < L < T, "模板渲染异常:检查 chat template 是否对称" input_ids = torch.tensor(full_ids) labels = torch.full((T,), -100, dtype=torch.long) # 默认全部忽略 labels[L:T] = input_ids[L:T] # 只放开回答段(含终止符) return {"input_ids": input_ids, "labels": labels} ex = build_example([ {"role": "user", "content": "中国最长的河流是哪一条?"}, {"role": "assistant", "content": "中国最长的河流是长江,全长约 6300 公里。"}, ]) print(tok.convert_ids_to_tokens(ex["input_ids"].tolist())) print(ex["labels"].tolist()) # -100 ... -100 之后跟着回答段的 id —— 掩码即生效

三个细节:

  1. add_generation_prompt=True 让第二次渲染以 assistant 起始标记收尾——这正好是推理时发给模型的形态(4.3 节会回头照应这一点:训练时怎么 mask,推理时就怎么拼提示)。
  2. -100 的来历:PyTorch 的 F.cross_entropy(..., ignore_index=-100) 默认忽略该下标的目标——它就是"这里不算分"的行业暗号,HuggingFace 全家桶沿用。
  3. 多轮对话里每条 assistant 回答都要放开:把 messages 按轮拆开逐段渲染求差即可;更工程化的做法是一次渲染、按 special token 位置切区间(第 4 章讲透 token 边界后回头优化,本书 124M 实验用逐段渲染足矣)。

三、验收:loss 数字对不对

掩码接对的最快验收——拿基座模型对一小批样本算一次 loss(不训练,只前向):

# sanity_check_mask.py —— 掩码验收:基座前向 loss 应落在 1~2 附近(写法示意) import torch from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("base/model", dtype=torch.bfloat16) batch = [build_example(m) for m in load_sample("data_sft/train.jsonl", n=32)] input_ids = torch.nn.utils.rnn.pad_sequence( [b["input_ids"] for b in batch], batch_first=True) labels = torch.nn.utils.rnn.pad_sequence( [b["labels"] for b in batch], batch_first=True, padding_value=-100) out = model(input_ids=input_ids, labels=labels) print(f"masked SFT loss = {out.loss.item():.3f}")

预期数字:1~2 附近(社区实测 nanochat SFT 起步约 1.3,同一量级)。两个反向信号:若在 7~10(均匀瞎猜量级),多半是 labels 错位(mask 段划反了或 tokenizer 加了计划外的 token);若低得离谱(<0.1),查是不是把输入本身当目标(teacher forcing 接错了)。这一步 5 分钟,能省第 3.3 节里半天的"loss 不降"排查。

四、与 nanochat 官方做法的对照

官方发布帖(Discussion #1)对 SFT 的描述:取一批对话数据,只在回答 token 上计算损失,对话之间用填充对齐到 2048 token 边界(对齐的动机:让每个训练序列等长、批处理高效,且填充部分不计 loss)。本书的"两次渲染求差"与"按 2048 对齐"是同一原则的两种包装:学习信号只出现在 assistant 段。填充与打包(packing)的取舍属于第 3.3 节实战脚本的工程选择,不改变本节的掩码原理。

本节要点回顾

  1. prompt masking = 考试只批答案:不 mask 会稀释目标、教回"替用户提问"的老毛病。
  2. 实现用两次渲染求差:labels[:L] = -100,回答段含终止符全部放开。
  3. -100 是 PyTorch 交叉熵的忽略标记;基座前向 loss 应在 1~2 量级(SFT 起点约 1.3 的由来,3.2 展开)。

目标信号定义好了,力度呢?3.2 节把 lr、epoch、batch 三个旋钮与 loss 曲线的读法一次配齐。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U