层1/T4: trainer.py 掩码 SFT 损失 + 最小 HF Trainer 子类(docs/02 §2.4)

- sft_loss 纯张量函数:batch-min prompt_length、移位切片、labels 重掩码;
  空 batch/无 prompt 行显式报错(参考实现静默归零,差异已标注)
- SFTTrainer 只重写 compute_loss 与 log;不向模型传 labels(防内部损失
  绕过重掩码);日志并入每步有效 token 数供 sanity 监控
- tests/test_trainer.py: 7 个单测,含手算对拍、移位对齐、窗口外无关性、
  漏切重掩码

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 05:02:41 -04:00
parent 5ea58ddf59
commit f5bb852fde
2 changed files with 268 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
"""层 1 / T4:掩码 SFT 损失单测(docs/02 §2.4 的切片几何与重掩码)。
只测纯张量函数 sft_loss / compute_prompt_lengthSFTTrainer 类是 HF 接线,
由远程 sanity run 验证。张量全部手工构造,期望值可手算。
"""
import pytest
import torch
import torch.nn.functional as F
from ars_opd.data import IGNORE_INDEX
from ars_opd.trainer import compute_prompt_length, sft_loss
V = 7 # 玩具词表大小
def onehot_logits(target_ids, scale=10.0):
"""构造在 target_ids 处放尖峰的 logits。(T,) -> (T, V)"""
t = torch.tensor(target_ids)
return F.one_hot(t, V).float() * scale
def batch_of_one(input_ids, labels, attention=None):
"""单行 batch 的三件套,logits 另配。"""
ids = torch.tensor([input_ids])
lab = torch.tensor([labels])
att = torch.ones_like(ids) if attention is None else torch.tensor([attention])
return ids, lab, att
def test_prompt_length_取batch最小且不数padding():
# pad p p c c c p p p c c c
labels = torch.tensor(
[
[IGNORE_INDEX] * 3 + [5, 6, 5],
[IGNORE_INDEX] * 3 + [6, 5, 6],
]
)
attention = torch.tensor([[0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]])
# 行 0 有效长 5、completion 3 → prompt 2;行 1 是 6-3=3batch 最小 = 2
assert compute_prompt_length(attention, labels) == 2
def test_损失与手算交叉熵一致():
ids, lab, att = batch_of_one([3, 4, 5, 6], [IGNORE_INDEX, IGNORE_INDEX, 5, 6])
torch.manual_seed(0)
logits = torch.randn(1, 4, V)
loss, num = sft_loss(logits, ids, lab, att)
# pl=2:位置 1、2 的 logit 分别预测位置 2、3 的 token(5 和 6
expected = F.cross_entropy(logits[0, 1:3], torch.tensor([5, 6]))
assert torch.allclose(loss, expected)
assert num == 2
def test_移位对齐_预测下一个token而非当前():
ids, lab, att = batch_of_one([3, 4, 5, 6], [IGNORE_INDEX, IGNORE_INDEX, 5, 6])
# 位置 t 的尖峰指向位置 t+1 的 token(正确的"预测下一个")→ loss ≈ 0
next_logits = onehot_logits([4, 5, 6, 0]).unsqueeze(0)
loss_next, _ = sft_loss(next_logits, ids, lab, att)
# 位置 t 的尖峰指向位置 t 自己的 token(错误的"复读当前")→ loss 大
self_logits = onehot_logits([3, 4, 5, 6]).unsqueeze(0)
loss_self, _ = sft_loss(self_logits, ids, lab, att)
assert loss_next.item() < 0.01
assert loss_self.item() > 5.0
def test_窗口外的logits不影响损失():
ids, lab, att = batch_of_one([3, 4, 5, 6], [IGNORE_INDEX, IGNORE_INDEX, 5, 6])
torch.manual_seed(0)
logits = torch.randn(1, 4, V)
loss_base, _ = sft_loss(logits, ids, lab, att)
# pl=2 → 用到的窗口是位置 [1, 3);位置 0(prompt 内部)和 3(末位)不参与
perturbed = logits.clone()
perturbed[0, 0] += 100.0
perturbed[0, 3] -= 100.0
loss_pert, _ = sft_loss(perturbed, ids, lab, att)
assert torch.allclose(loss_base, loss_pert)
def test_batch_min切片漏进的prompt_token被重掩码():
# 行 0pad1 + prompt2 + comp3;行 1prompt3 + comp3 → pl = min(2,3) = 2
ids = torch.tensor([[0, 3, 4, 5, 6, 5], [3, 4, 3, 6, 5, 6]])
lab = torch.tensor(
[
[IGNORE_INDEX] * 3 + [5, 6, 5],
[IGNORE_INDEX] * 3 + [6, 5, 6],
]
)
att = torch.tensor([[0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]])
torch.manual_seed(1)
logits = torch.randn(2, 6, V)
loss_base, num = sft_loss(logits, ids, lab, att)
assert num == 6 # 两行各 3 个 completion token,漏进切片的 prompt 位不计数
# 位置 1 的 logit 预测位置 2——两行的位置 2 都在切片内但都是 -100(行 0 是
# prompt 尾、行 1 是漏进来的 prompt token)。改它不该动 loss
perturbed = logits.clone()
perturbed[:, 1] += 100.0
loss_pert, _ = sft_loss(perturbed, ids, lab, att)
assert torch.allclose(loss_base, loss_pert)
def test_全掩码batch显式报错():
ids, lab, att = batch_of_one([3, 4, 5, 6], [IGNORE_INDEX] * 4)
with pytest.raises(ValueError, match="有效 completion"):
sft_loss(torch.randn(1, 4, V), ids, lab, att)
def test_无prompt行显式报错():
ids, lab, att = batch_of_one([3, 4], [3, 4]) # labels 全有效 → prompt 长 0
with pytest.raises(ValueError, match="prompt_length"):
sft_loss(torch.randn(1, 2, V), ids, lab, att)