「Python 是什么?」这道题很简单,拿来拆 SFT 的 loss 刚刚好。示范回答是「Python 是一种编程语言。」我们可以看清楚一件事:模型给标准答案的概率变高,和它在推理时自己把整句话答对,分别是怎么测的。
先算第一个 token,再看哪一步最难
假设 tokenizer 把示范切成 [Python, 是, 一种, 编程, 语言, 。],再映射成 token ID。这里的切分只为演示,实际结果由 tokenizer 决定,空格也可能影响边界。模型读到问题和回答起始标记后,会给整个词表打分。假设第一个位置的概率分布如下。
| 候选 token | 概率 |
|---|---|
| Python | 0.70 |
| Java | 0.10 |
| C++ | 0.05 |
| 其他 token 合计 | 0.15 |
标准答案的第一个 token 是 Python。在普通的硬标签交叉熵里,它在目标分布中的权重是 1,其余候选 token 的权重为 0。因此这个位置的损失就是负对数似然 −ln(0.70) ≈ 0.357。给目标 token 的概率越接近 1,这一项就越接近 0。如果只给 0.01,损失约为 4.605,低概率的目标会在平均值里留下明显影响。PyTorch CrossEntropyLoss 文档
再给出一组虚构的后续概率。每行的概率都以真实前缀为条件。
| 目标 token | 模型给目标的概率 | 单 token 损失 |
|---|---|---|
| Python | 0.70 | 0.357 |
| 是 | 0.80 | 0.223 |
| 一种 | 0.50 | 0.693 |
| 编程 | 0.40 | 0.916 |
| 语言 | 0.90 | 0.105 |
| 。 | 0.80 | 0.223 |
只监督这六个位置时,样本的平均 token loss 约为 0.420。表里最难的是「编程」,它的损失比开头的 Python 大得多。看单 token 的数据,比只看曲线上一个均值更容易找出模型在哪些词或字段上犹豫。实际聊天模板若还监督结束标记,就要把那个位置一起纳入分子和分母。
教师强制给了模型怎样的前缀?
假设另一个 checkpoint 在第一个位置更偏向 Java。如果让它自由生成,后面就会看到「Java」作为历史,有可能顺势写出一段关于 Java 的介绍。SFT 计算第二个位置时,喂进去的前缀依然是问题加示范里的「Python」,所以它算的是给定这段前缀后预测「是」的概率。第一个位置的错误会体现在它自己的 loss 上,后面的计分仍使用参考答案的前缀。
这就是 teacher forcing(教师强制)。训练时已有完整示范,因果注意力让各位置读取各自能看到的前缀,可以在一次前向里并行拿到所有位置的 logits。推理则得把自己选出来的 token 接回上下文。两种前缀分布有差异,开头选错可能使后面的回答偏离原任务,也可能在后续上下文中被纠正。想知道模型能否从自己的错误里恢复,得实际让它生成一遍。
掩码和平均值各查一遍
在 response-only 的配置中,「Python 是什么?」仍然要输入模型,让它知道该回答什么。问题对应的目标位置可以用 loss mask 标记为忽略,只对回答区间计算交叉熵。在 PyTorch 风格的实现里,常用 -100 表示忽略标签。排查时可以把参与计分的 token 解码出来。如果只剩句尾标点,或者开头的 Python 被漏掉,即使 loss 下降,也只说明模型更擅长预测当前掩码覆盖的内容。前一篇:Shift 与 Mask 的对齐和代码
比较两个 checkpoint,我会在同一批验证题上分别记录三项:回答区间的 token loss、关键位置(比如实体名或工具名)的目标概率,以及自由生成时的任务通过率。比如模型更会写「是一种」「语言」,但容易把开头实体答错,前两项会告诉你均值改善来自哪里,最后一项能看出这种改善有没有帮助把整道题答对。