返回关卡地图
预判 0 / 2 闯关题:0 / 2 XP 0 / 200

Level 09 | 零基础导学关卡

SFT Training Loop

每个 Mission 先猜一猜(押个假设解锁讲解)→ 看图解原理 → 做巩固题 → 回 notebook 写代码。带着问题学,比直接读记得牢。

09_SFT_Training_Loop.ipynb SFTLossOptimizer
先猜一猜
图解原理
巩固 + 作业
loss = CrossEntropy(logits[:, :-1], labels[:, 1:])
Mission 1 TODO 1 / TODO 2

Loss Masking:为什么只批改“答案”,不批改“题目”

🎯 先猜一猜(下注解锁)

SFT 样本是 [Prompt] + [Response]。构造 labels 时,我们偏偏把 Prompt 那一段全设成 -100。

先判断:把 prompt 段的 label 设成 -100,最主要是为了?

先押一个假设,下面的讲解就会解锁。猜错完全没关系——带着疑问读,记得最牢。

先补的知识

  • 回忆预训练 vs SFT:预训练时每个 token 都要预测、都算 loss(读一整本书);SFT 给的是 [Prompt(问题)] + [Response(答案)],我们只想让模型学会“答”,不想让它学“背题干”。
  • 做法叫 Loss Masking:把 labels 里属于 prompt 和 padding 的位置全设成 -100。
  • -100 是 PyTorch CrossEntropyLoss 的默认 ignore_index:凡是 label=-100 的位置,既不算损失、也不回传梯度,等于“这题不批改”。
  • input_ids 和 labels 是两条等长的序列:input_ids 喂给模型看,labels 告诉损失函数“每个位置的正确答案是什么、要不要算分”。

图解原理

把一条 SFT 样本想成一张试卷:prompt 是印好的题干,response 是学生要写的作答。模型读整张卷子(input_ids 完整保留),但老师只批改作答区(labels 里只有 response 是真值,题干和空白都涂成 -100 表示不批改)。这一关就是构造这张“只在答案区留分”的 labels,再处理超长截断和不足填充。

0. 先看清两条序列的关系

input_ids = prompt_ids + response_ids,整条都保留给模型看。labels 长度一样,但只有 response 段是“真答案”,prompt 段和后面 padding 段都填 -100。

位置含义input_ids(模型看的)labels(算分用的)
prompt 题干[10, 20, 30][-100, -100, -100]
response 答案[40, 50, 60, 70][40, 50, 60, 70]
padding 补位[0][-100]

这正是 notebook 测试期望的:labels = [-100,-100,-100, 40,50,60,70, -100](max_len=8)。

为什么题干要涂掉?想想“背题”和“答题”的区别
1. 涂掉 prompt = 不让模型把“背题干”当成任务

如果 prompt 也算 loss,模型会花容量去学“人类爱怎么提问”,而不是“怎么答得好”。把它设成 -100,梯度就只从答案区回传,模型的全部注意力都用在“给定这个问题,下一个词该答什么”。

算 loss 的位置只有 response 段其余-100 全部跳过
想一想:padding 位为什么也必须设成 -100,而不是随便填个 pad_id?

padding 只是为了把一个 batch 里长短不一的样本对齐成矩形,它不是真实内容。如果 labels 里 padding 位留着 pad_id,模型就会被要求去“预测 padding”,白白浪费容量、还污染 loss。

所以 input_ids 的 padding 填 pad_id(占位),但 labels 的 padding 必须填 -100(不批改)。两条序列在 padding 段的填法故意不同——这是最容易写错的地方。

TODO 2:超长要截、不足要补
2. 截断与填充:把每条样本对齐到 max_len

拼完可能比 max_len 长(要从末尾截掉)或短(要在末尾补齐)。两条序列同步操作,只是填充值不同:input_ids 补 pad_id,labels 补 -100。

超长input_ids[:max_len]
labels[:max_len]
不足
input_ids+[pad_id]*n
labels+[-100]*n
行业视角:SFT 数据构造是“脏活”,但决定成败

面试里“你怎么构造 input_ids 和 labels、为什么 mask 掉 prompt”几乎必问,因为它直接暴露你是不是真做过训练。现实中多轮对话、system prompt、工具调用的 mask 规则更复杂(比如只算最后一轮助手回复),但内核就是这关:用 -100 精确控制“哪些 token 要学、哪些只读”

一个常见线上事故就是 mask 写错——模型把用户的问法也学了进去,微调完开始“复读”用户,而不是回答。

语法热身:拼接 → 造 labels → 截断/填充

input_ids = prompt_ids + response_ids
labels = [-100] * len(prompt_ids) + response_ids   # 题干涂掉,答案保留
if len(input_ids) > max_len:                        # 超长:末尾截断
    input_ids, labels = input_ids[:max_len], labels[:max_len]
else:                                               # 不足:末尾填充
    pad = max_len - len(input_ids)
    input_ids += [pad_id] * pad                     # 占位
    labels    += [-100]   * pad                     # 不批改

巩固一下

动手算:prompt=[10,20,30]、response=[40,50,60,70]、max_len=8、pad_id=0,labels 应该是?

回到 notebook 的作业

这里不直接写答案。你已经拿到足够输入,最后用 notebook 的 TODO 做举一反三。

Mission 2 TODO 3 / TODO 4

Shift 对齐:位置 t 的输出,去预测位置 t+1 的词

🎯 先猜一猜(下注解锁)

模型一次输出 logits[batch, seq, vocab],labels 是 [batch, seq]。算 loss 前,代码先把 logits 砍掉最后一个位置、labels 砍掉第一个位置。

先判断:为什么要这样错位切一刀?

先押一个假设,下面的讲解就会解锁。猜错完全没关系——带着疑问读,记得最牢。

先补的知识

  • 自回归模型的本质:看着前面的词,预测下一个词。所以位置 t 的输出 logits,目标是“第 t+1 个 token”。
  • 模型一次性输出所有位置的 logits:[batch, seq_len, vocab]。labels 是 [batch, seq_len]。它们没有天然对齐——需要错位一格。
  • 错位做法:logits 砍掉最后一个位置(它没有“下一个词”可预测),labels 砍掉第一个位置(它没有“上一个词”来预测它)。
  • CrossEntropyLoss 要求 logits 形状 [N, vocab]、labels 形状 [N],所以要先 flatten;ignore_index=-100 让上一关涂掉的位置自动不算分。

图解原理

把序列想成一排格子,模型站在每个格子上猜“下一格是什么字”。第 0 格的猜测要和第 1 格的真值比、第 1 格的猜测和第 2 格比……最后一格没有下一格,它的猜测作废。于是 logits 去尾、labels 去头,两者就一一对齐了。对齐后拉平成一长条,交给交叉熵。

0. 先看清“错位”从哪来

模型在位置 t 看到的是 token[0..t],它输出的 logits[t] 是在预测 token[t+1]。所以 logits[t] 的正确答案是 labels[t+1]——天生差一格。

logits t=0 t=1 t=2 t=3✂ labels ✂t=0 t=1 t=2 t=3 斜线:谁预测谁

去掉两头灰色虚线格,剩下的就一一对上了:logits[0→2] 对 labels[1→3]。

TODO 3:用切片实现错位
1. logits 去尾、labels 去头
shift_logits = logits[..., :-1, :]shift_labels = labels[..., 1:]

notebook 里 logits 是 [2,5,10],logits[..., :-1, :] 把 seq 从 5 砍到 4 → [2,4,10];labels 同理去掉第一个 → [2,4]。数量对齐了。

想一想:为什么是 logits 去尾、labels 去头,而不是反过来?

因为“预测方向”是往后看。最后一个位置的 logits 想预测的是“序列外的下一个词”,我们没有它的真值,只能丢掉——所以砍 logits 的尾。

第一个 token 是模型的输入起点,没有“前文”来预测它,它不该作为被预测的目标——所以砍 labels 的头。反过来切会把对应关系彻底错乱,loss 变成噪声。

TODO 4:展平 + 交叉熵
2. 拉平成一长条,喂给 CrossEntropyLoss

CrossEntropyLoss 要的是 [N, vocab] 和 [N]。把 [batch, seq, vocab] 拉成 [batch*seq, vocab]、labels 拉成 [batch*seq]。ignore_index=-100 会自动跳过上一关涂成 -100 的所有位置(prompt+padding)。

shift_logits.view(-1, vocab)shift_labels.view(-1)CrossEntropyLoss(ignore_index=-100)
行业视角:这就是所有 LLM 训练的心脏

不管是预训练、SFT 还是指令微调,最终的监督信号都归结到这一步:shift 一格、拉平、交叉熵。HuggingFace 的 modeling_llama.py 里 loss 段几乎和你写的一模一样。理解它,你就能读懂任何开源模型的训练代码。

上一关的 -100 mask + 这一关的 shift 对齐,合起来就是“只在答案区、只算下一个词预测”——SFT 的全部秘密。

语法热身:shift 对齐 + 展平 + 交叉熵

shift_logits = logits[..., :-1, :].contiguous()   # 去掉最后一个位置
shift_labels = labels[..., 1:].contiguous()        # 去掉第一个位置
loss_fct = nn.CrossEntropyLoss(ignore_index=-100)  # -100 自动跳过
loss = loss_fct(
    shift_logits.view(-1, shift_logits.size(-1)),   # [N, vocab]
    shift_labels.view(-1),                          # [N]
)

巩固一下

动手算:logits 的 shape 是 [2,5,10],shift_logits = logits[..., :-1, :] 之后 shape 是?

回到 notebook 的作业

这里不直接写答案。你已经拿到足够输入,最后用 notebook 的 TODO 做举一反三。

完成 2 道闯关题后,本关即算完成;作业 checklist 用来辅助你回 notebook 练习。
进度只保存在当前浏览器 localStorage,分享 HTML 不会带走你的记录。
上一关:L08 下一关:L10