微调样本的标签对齐:移位、填充、只算回答与 LoRA 初始化
对一个对话模型做监督微调,损失曲线一切正常。上线后发现它经常把用户的问题复述一遍再回答;另一次实验里,微调后的模型说话总是「跳一个词」。两次都不是数据内容的问题,而是标签没对齐。
监督微调(SFT)的样本看起来很简单:一段提示、一段回答,拼起来交给模型,算交叉熵。但「哪个位置的输出对应哪个目标词」「哪些位置算损失」这些细节完全由 labels 张量决定,而它错了不会报错,损失照样下降。
本文用一个由配置随机初始化的单层 GPT-2 结构(PyTorch 2.14.1 CPU、Transformers 5.20.2,词表 16,不下载任何权重)检查这几个细节,最后看 LoRA 的初始化为什么是「一个随机、一个为零」。实验见文末。
一、移位由模型内部完成
因果语言模型在位置 t 预测位置 t+1 的词。Transformers 的 *ForCausalLM 模型在计算损失时会自己做这次移位,所以 labels 应该和 input_ids 对齐,而不是提前错开。
labels=input_ids:损失等于「位置 t 的 logits 对位置 t+1 的词」:True
自己先把 labels 左移一位再传入:损失等于「位置 t 对位置 t+2」:True
训练 150 步后输入 [1, 2, 3]:labels 正确时预测 4;重复移位时预测 5自己再移一位,模型学会的就是「预测下下个词」。在一条递增序列上训练后,输入 [1, 2, 3],正确对齐的模型预测 4,重复移位的模型预测 5。开头那个「跳一个词」的模型就是这种错误。自己写训练循环、不用库算损失时,移位要自己做;用库算损失时,一定不要再做。不确定时,看一眼库的损失函数实现,或者像本文这样用手算的交叉熵对一下。
二、填充位置:attention_mask 管不到损失
批量训练时短样本要补齐。attention_mask 让真实词不去注意填充位置,但它不决定哪些位置算损失,那是 labels 的事。
填充位置的标签仍是填充词:计入损失的位置 10 个,其中目标是填充词的 3 个;attention_mask 只影响注意力,不影响哪些位置算损失
填充位置的标签设为 -100:计入损失的位置 7 个;损失从 2.726 变为 2.839把 labels = input_ids.clone() 直接用于补齐后的批次,填充位置也成了训练目标:模型在学「真实内容之后接一串填充词」。-100 是 CrossEntropyLoss 默认的 ignore_index,标签为 -100 的位置不计入损失。注意力掩码和损失掩码是两件事,见注意力掩码。
三、只算回答部分
对话微调的目标是学会「看到这样的提示,给出这样的回答」,提示本身不是要学的内容。
提示 7 个词、回答 2 个词:labels 全保留时计入损失 8 个位置,提示部分设 -100 后 2 个
训练 150 步后模型对提示部分的对数概率和:全保留 0.0,只算回答 -54.8(全保留时模型也在学着复述提示)提示通常比回答长得多。全保留时,大部分梯度来自提示:模型把提示也背了下来(对数概率和接近 0,表示每个词都被预测得几乎确定)。这就是开头那个「复述问题」的模型的来历。把提示部分的标签设为 -100 后,只有回答贡献梯度。
真实数据里,「回答从哪个词开始」由对话模板决定,系统提示、角色标记、多轮对话中每一轮的边界都要算对。最可靠的检查是把一条样本的 labels 中不为 -100 的部分解码出来看一眼,它应该恰好是所有助手回答,加上回答的结束标记(模型要学会何时停下)。
四、损失按词平均
同一批里 2 个词与 8 个词的目标:库按词平均 2.830,等于 (2×2.876+8×2.819)/10:True;按样本平均是 2.847默认的交叉熵对批次里所有计入的位置取平均,长样本的权重更大。这不一定是错,但它意味着:回答很长的样本主导训练;梯度累积、多卡训练时,如果每个小批次先平均再平均,结果和整批按词平均不同。要哪一种,应该是一个明确的选择。
五、LoRA:为什么一个随机、一个为零
LoRA 冻结原权重 W,只训练两个低秩矩阵:输出为 W x + (alpha / r) · B A x。
A 随机、B 为零:初始输出与原层一致:True;第 1 步 A 无梯度、B 有梯度,第 2 步 A 有梯度、B 有梯度,第 3 步 A 有梯度、B 有梯度
A、B 都为零:初始输出与原层一致:True;第 1 步 A 无梯度、B 无梯度,第 2 步 A 无梯度、B 无梯度,第 3 步 A 无梯度、B 无梯度
A、B 都随机:初始输出与原层一致:False;第 1 步 A 有梯度、B 有梯度,……- B 为零保证训练开始时模型和原模型完全一样,微调从原模型的能力出发。
- A 的梯度正比于 B,B 的梯度正比于 A x。两者都为零时,两个梯度都是零,永远训练不动,而且不会报错,只是损失一动不动。
- 两者都随机时,训练还没开始,模型就已经被改动了。
自己实现或修改 LoRA 层时,这三种情况都值得用一个两三步的小测试确认。
六、合成数据也要遵守同样的契约
用模型生成微调数据时,上面这些问题会以另一种形式出现:生成的样本要有固定的结构(提示、回答、结束标记),按模型输出的结构契约校验;要去重,同一个模板生成的近似重复样本会放大某种回答方式;评估要用独立的测试集,不能用同一批生成的数据,否则评估的是模型复述模板的能力。
七、检查清单
- 用库算损失时
labels与input_ids对齐,不自己移位。 - 填充位置的标签设为 -100;
attention_mask不影响损失。 - 只让助手回答和结束标记计入损失;解码一条样本的有效标签检查边界。
- 明确损失按词平均还是按样本平均,梯度累积时保持一致。
- LoRA 的 B 初始化为零、A 随机;用小测试确认初始输出不变、梯度不为零。
配套实验
- codesphere-labs/ai/sft-label-alignment:Python 3.14.8、PyTorch 2.14.1 CPU、Transformers 5.20.2(随机初始化的小模型):内部移位与重复移位、填充位置与 -100、只算回答、按词平均、LoRA 三种初始化(验证记录)
参考资料