Skip to content

微调样本的标签对齐:移位、填充、只算回答与 LoRA 初始化 ​

对一个对话模型做监督微调,损失曲线一切正常。上线后发现它经常把用户的问题复述一遍再回答;另一次实验里,微调后的模型说话总是「跳一个词」。两次都不是数据内容的问题,而是标签没对齐。

监督微调(SFT)的样本看起来很简单:一段提示、一段回答,拼起来交给模型,算交叉熵。但「哪个位置的输出对应哪个目标词」「哪些位置算损失」这些细节完全由 labels 张量决定,而它错了不会报错,损失照样下降。

本文用一个由配置随机初始化的单层 GPT-2 结构(PyTorch 2.14.1 CPU、Transformers 5.20.2,词表 16,不下载任何权重)检查这几个细节,最后看 LoRA 的初始化为什么是「一个随机、一个为零」。实验见文末。

input_idsp1p2p3p4a1a2eospadpadpadpadlabels-100-100-100-100a1a2eos-100-100-100-100斜箭头:位置 t 的输出对齐 t+1 的标签。移位在模型内部完成,labels 与 input_ids 对齐,不要提前错开自己再移一位学成「下下个词」:预测 5提示部分不设 -100模型学会复述提示填充位置不设 -100学「接一串填充词」LoRA:B = 0、A 随机 → 初始输出不变,梯度不为零;A、B 都为零 → 梯度一直为零,损失不动
图 1 · 实测(PyTorch 2.14.1 CPU、Transformers 5.20.2):labels 与 input_ids 对齐,由模型内部移一位;自己再移一位,训练后输入 [1, 2, 3] 预测 5 而不是 4。提示与填充位置设为 -100 后,计入损失的位置从 8 个降为 2 个;attention_mask 不改变哪些位置计入损失。

一、移位由模型内部完成 ​

因果语言模型在位置 t 预测位置 t+1 的词。Transformers 的 *ForCausalLM 模型在计算损失时会自己做这次移位,所以 labels 应该和 input_ids 对齐,而不是提前错开。

text
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 的事。

text
填充位置的标签仍是填充词:计入损失的位置 10 个,其中目标是填充词的 3 个;attention_mask 只影响注意力,不影响哪些位置算损失
填充位置的标签设为 -100:计入损失的位置 7 个;损失从 2.726 变为 2.839

把 labels = input_ids.clone() 直接用于补齐后的批次,填充位置也成了训练目标:模型在学「真实内容之后接一串填充词」。-100 是 CrossEntropyLoss 默认的 ignore_index,标签为 -100 的位置不计入损失。注意力掩码和损失掩码是两件事,见注意力掩码。

三、只算回答部分 ​

对话微调的目标是学会「看到这样的提示,给出这样的回答」,提示本身不是要学的内容。

text
提示 7 个词、回答 2 个词:labels 全保留时计入损失 8 个位置,提示部分设 -100 后 2 个
训练 150 步后模型对提示部分的对数概率和:全保留 0.0,只算回答 -54.8(全保留时模型也在学着复述提示)

提示通常比回答长得多。全保留时,大部分梯度来自提示:模型把提示也背了下来(对数概率和接近 0,表示每个词都被预测得几乎确定)。这就是开头那个「复述问题」的模型的来历。把提示部分的标签设为 -100 后,只有回答贡献梯度。

真实数据里,「回答从哪个词开始」由对话模板决定,系统提示、角色标记、多轮对话中每一轮的边界都要算对。最可靠的检查是把一条样本的 labels 中不为 -100 的部分解码出来看一眼,它应该恰好是所有助手回答,加上回答的结束标记(模型要学会何时停下)。

四、损失按词平均 ​

text
同一批里 2 个词与 8 个词的目标:库按词平均 2.830,等于 (2×2.876+8×2.819)/10:True;按样本平均是 2.847

默认的交叉熵对批次里所有计入的位置取平均,长样本的权重更大。这不一定是错,但它意味着:回答很长的样本主导训练;梯度累积、多卡训练时,如果每个小批次先平均再平均,结果和整批按词平均不同。要哪一种,应该是一个明确的选择。

五、LoRA:为什么一个随机、一个为零 ​

LoRA 冻结原权重 W,只训练两个低秩矩阵:输出为 W x + (alpha / r) · B A x。

text
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 三种初始化(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权