# 第 10 课:Causal LM forward 与 loss shift
这一课只解决一个问题:`MiniMindForCausalLM.forward` 如何把 `hidden_states` 变成 `logits`,再把 `logits` 和 `labels` shift 后算出 next-token loss。
## 目录
- [0. 本节主线](#l10-mainline)
- [1. 本节要懂的 5 个原理](#l10-principles)
- [2. 变量流转](#l10-flow)
- [3. 原理一:`lm_head` 把 hidden 映射成 logits](#l10-lm-head-logits)
- [4. 原理二:next-token loss 必须 shift](#l10-loss-shift)
- [5. 原理三:`-100` 决定哪些位置不参与 loss](#l10-ignore-index)
- [6. 原理四:Pretrain 和 SFT 共享同一个 forward loss](#l10-shared-loss)
- [7. 原理五:训练目标差异来自 labels](#l10-labels-target)
- [8. 实验验证](#l10-experiment)
- [9. 本节检查](#l10-check)
- [10. 下一课](#l10-next)
## 0. 本节主线
Causal LM loss 的本质是:
```text
input_ids
-> MiniMindModel
-> hidden_states [batch, seq, hidden_size]
-> lm_head
-> logits [batch, seq, vocab_size]
-> logits[..., :-1, :]
-> labels[..., 1:]
-> cross_entropy(ignore_index=-100)
-> loss
```
最关键的一句话:
```text
logits 在位置 i 的输出,用来预测 labels 在位置 i+1 的 token。
```
所以模型不是预测“当前位置 token”,而是用当前位置之前的信息预测“下一个 token”。
## 1. 本节要懂的 5 个原理
| 原理 | 要理解什么 | 源码证据 |
|---|---|---|
| `lm_head` 把 hidden 映射成 logits | 主干输出 hidden_states,外层 CausalLM 头输出词表分数 | `model/model_minimind.py:245-249` |
| next-token loss 必须 shift | `logits[..., :-1, :]` 对齐 `labels[..., 1:]` | `model/model_minimind.py:249-252` |
| `-100` 决定哪些位置不参与 loss | cross entropy 用 `ignore_index=-100` 忽略被 mask 的 label | `model/model_minimind.py:250-252` |
| Pretrain 和 SFT 共享同一个 forward loss | 训练脚本都只是传入 `(input_ids, labels)` | `trainer/train_pretrain.py:35-40`, `trainer/train_full_sft.py:35-40` |
| 训练目标差异来自 labels | Pretrain 基本全段监督,SFT 只监督 assistant 区域 | `dataset/lm_dataset.py:47-55`, `dataset/lm_dataset.py:88-119` |
学完本节,你应该能解释:为什么 SFT 仍然是 causal LM next-token loss,以及为什么 user token 的 label 要设成 `-100`。
## 2. 变量流转
先看 shape:
```text
input_ids:
[batch, seq]
hidden_states:
[batch, seq, hidden_size]
logits:
[batch, seq, vocab_size]
labels:
[batch, seq]
```
再看 shift:
```text
logits[..., :-1, :]
[batch, seq - 1, vocab_size]
位置 0 到 seq-2 的预测分数
labels[..., 1:]
[batch, seq - 1]
位置 1 到 seq-1 的目标 token
```
对齐关系是:
```text
logits[:, 0, :] -> labels[:, 1]
logits[:, 1, :] -> labels[:, 2]
logits[:, 2, :] -> labels[:, 3]
...
```
所以:
```text
labels[:, 0] 不参与 loss
logits[:, -1, :] 不参与 loss
```
## 3. 原理一:`lm_head` 把 hidden 映射成 logits
### 原理讲解
第 9 课讲到,`MiniMindModel` 的输出还是 hidden states:
```text
[batch, seq, hidden_size]
```
但训练语言模型时,我们需要在每个位置预测下一个 token。预测 token 就要输出词表大小的分数:
```text
[batch, seq, vocab_size]
```
这一步由 `lm_head` 完成:
```text
hidden_size -> vocab_size
```
每个位置都会得到一组长度为 `vocab_size` 的 logits。logits 还不是概率,它只是未归一化分数;cross entropy 内部会处理 softmax。
### 源码证据 A:forward 先得到 hidden,再调用 lm_head
文件:`model/model_minimind.py:245-249`
看它是为了理解:logits 是从哪里来的。
源码:
```python
def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, logits_to_keep=0, labels=None, **kwargs):
hidden_states, past_key_values, aux_loss = self.model(input_ids, attention_mask, past_key_values, use_cache, **kwargs)
slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
logits = self.lm_head(hidden_states[:, slice_indices, :])
loss = None
```
这段代码说明:
- `self.model(...)` 返回的是 `hidden_states`。
- `self.lm_head(...)` 把 hidden 映射成词表 logits。
- logits 的最后一维是 `vocab_size`。
- `loss` 初始为 `None`,只有传入 labels 才计算训练损失。
### 理解到这一步就够
你应该能说清楚:
```text
MiniMindModel 负责产生 hidden 表示;
MiniMindForCausalLM 负责把 hidden 变成 logits,并在有 labels 时计算 loss。
```
暂时不用看:
- `logits_to_keep` 的推理节省计算用途。
- softmax 的数值稳定实现。
## 4. 原理二:next-token loss 必须 shift
### 原理讲解
Causal LM 的训练目标是:
```text
看到前面的 token,预测下一个 token。
```
如果输入是:
```text
input_ids = [A, B, C, D]
```
那么监督关系应该是:
```text
看到 A -> 预测 B
看到 A B -> 预测 C
看到 A B C -> 预测 D
```
这就是为什么要 shift:
```text
logits positions: 0, 1, 2
labels positions: 1, 2, 3
```
模型输出的 `logits[:, i, :]` 不是用来预测 `input_ids[:, i]`,而是用来预测下一位 `labels[:, i+1]`。
### 源码证据 A:shift logits 和 labels
文件:`model/model_minimind.py:249-252`
看它是为了理解:next-token 对齐关系在源码里是哪一行实现的。
源码:
```python
loss = None
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
```
这段代码说明:
- `logits[..., :-1, :]` 去掉最后一个位置的预测。
- `labels[..., 1:]` 去掉第一个位置的目标。
- `x` 和 `y` 的 seq 维都变成 `seq-1`。
- 这样 `x[:, i, :]` 就对齐 `y[:, i]`,也就是原始的 `labels[:, i+1]`。
### 理解到这一步就够
你应该能回答:
```text
为什么 labels[0] 不参与 loss?
因为没有前一个位置的 logits 去预测它。
为什么 logits 最后一个位置不参与 loss?
因为序列里没有下一个 label 给它预测。
```
暂时不用看:
- teacher forcing 的训练范式。
- 推理时如何一步步 autoregressive generate。
## 5. 原理三:`-100` 决定哪些位置不参与 loss
### 原理讲解
`labels` 不只是目标 token id,也是一张 loss mask。
规则是:
```text
label 是正常 token id -> 这个位置参与 loss
label 是 -100 -> 这个位置被忽略
```
PyTorch 的 cross entropy 支持 `ignore_index`。MiniMind 设置:
```text
ignore_index=-100
```
所以只要 Dataset 把某些位置的 label 写成 `-100`,这些位置就不会对 loss 和梯度产生贡献。
注意 shift 后才判断是否忽略:
```text
真正参与 loss 的是 labels[..., 1:] 里的非 -100 位置。
```
### 源码证据 A:cross entropy 忽略 `-100`
文件:`model/model_minimind.py:250-252`
看它是为了理解:`-100` 为什么不是普通 token id,而是 loss mask。
源码:
```python
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
```
这段代码说明:
- `y.view(-1)` 里有正常 token id,也可能有 `-100`。
- `ignore_index=-100` 让 `-100` 位置不参与 loss。
- Dataset 决定哪些位置是 `-100`,模型 forward 只负责执行这个规则。
### 理解到这一步就够
你应该能说清楚:
```text
-100 不是 tokenizer 词表里的特殊 token;
它是 PyTorch loss 的忽略标记。
```
暂时不用看:
- reduction='mean' 时忽略位置如何影响平均值。
- label smoothing。
## 6. 原理四:Pretrain 和 SFT 共享同一个 forward loss
### 原理讲解
从训练循环看,Pretrain 和 SFT 都是同一种调用:
```python
model(input_ids, labels=labels)
```
训练脚本没有写两套 loss。模型 forward 也没有判断“现在是 pretrain 还是 SFT”。
这说明一个关键事实:
```text
阶段差异不在 loss 函数;
阶段差异在 labels 是怎么构造的。
```
Pretrain 传进来的 labels 和 SFT 传进来的 labels 不一样,所以同一个 causal LM loss 会产生不同训练目标。
### 源码证据 A:Pretrain 训练循环
文件:`trainer/train_pretrain.py:35-40`
看它是为了理解:pretrain 训练时如何调用模型 loss。
源码:
```python
with autocast_ctx:
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss
loss = loss / args.accumulation_steps
scaler.scale(loss).backward()
```
这段代码说明:
- pretrain 阶段直接把 `input_ids` 和 `labels` 交给模型。
- `res.loss` 来自 `MiniMindForCausalLM.forward`。
- 训练循环本身没有手写 next-token loss。
### 源码证据 B:SFT 训练循环
文件:`trainer/train_full_sft.py:35-40`
看它是为了理解:SFT 阶段是否用了另一套 loss。
源码:
```python
with autocast_ctx:
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss
loss = loss / args.accumulation_steps
scaler.scale(loss).backward()
```
这段代码说明:
- SFT 调用方式和 pretrain 一样。
- 模型 forward 不知道当前阶段名称。
- 训练目标差异要去 Dataset 的 labels 里找。
### 理解到这一步就够
你应该能回答:
```text
为什么 SFT 仍然可以使用 causal LM next-token loss?
因为 SFT 只是把对话 prompt 的 assistant 区域设成监督目标,其余位置设成 -100。
```
暂时不用看:
- SFT 超参数选择。
- SFT 是否会注入新知识。
## 7. 原理五:训练目标差异来自 labels
### 原理讲解
Pretrain 和 SFT 的 `input_ids` 都是 token 序列,但 labels 的含义不同。
Pretrain:
```text
labels = input_ids.clone()
padding labels = -100
```
这表示普通文本几乎每个 token 都参与 next-token prediction。
SFT:
```text
labels 默认全是 -100
只把 assistant 回答区改成 token id
```
这表示模型只在 assistant 回答区域被监督。user/system/tool prompt 只是条件上下文,不作为要模仿的答案。
关键点:
```text
SFT 不是让模型学习“复读 user 问题”;
SFT 是让模型在 user/system 上下文之后,学习 assistant 应该怎么回答。
```
### 源码证据 A:Pretrain labels
文件:`dataset/lm_dataset.py:47-55`
看它是为了理解:pretrain 为什么几乎全段参与 loss。
源码:
```python
def __getitem__(self, index):
sample = self.samples[index]
tokens = self.tokenizer(str(sample['text']), add_special_tokens=False, max_length=self.max_length - 2, truncation=True).input_ids
tokens = [self.tokenizer.bos_token_id] + tokens + [self.tokenizer.eos_token_id]
input_ids = tokens + [self.tokenizer.pad_token_id] * (self.max_length - len(tokens))
input_ids = torch.tensor(input_ids, dtype=torch.long)
labels = input_ids.clone()
labels[input_ids == self.tokenizer.pad_token_id] = -100
return input_ids, labels
```
这段代码说明:
- `labels = input_ids.clone()` 是 pretrain 的核心。
- padding 位置改成 `-100`,不参与 loss。
- 非 padding 文本 token 基本都作为 next-token 监督目标。
### 源码证据 B:SFT labels
文件:`dataset/lm_dataset.py:88-119`
看它是为了理解:SFT 如何只监督 assistant 区域。
源码:
```python
def generate_labels(self, input_ids):
labels = [-100] * len(input_ids)
i = 0
while i < len(input_ids):
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
end = start
while end < len(input_ids):
if input_ids[end:end + len(self.eos_id)] == self.eos_id:
break
end += 1
for j in range(start, min(end + len(self.eos_id), self.max_length)):
labels[j] = input_ids[j]
i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
else:
i += 1
return labels
...
labels = self.generate_labels(input_ids)
return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long)
```
这段代码说明:
- SFT labels 初始全部是 `-100`。
- 代码找到 assistant 回答区域后,才把对应 label 改成 token id。
- user/system/padding 默认保持 `-100`,所以不参与 loss。
### 理解到这一步就够
你应该能说清楚:
```text
同一个 forward loss;
不同 Dataset 构造出来的 labels;
最终得到不同训练目标。
```
暂时不用看:
- tool call 样本的完整标签细节。
- 多轮对话中多个 assistant span 的边界情况。
## 8. 实验验证
### 实验 A:手动复现 SFT 的 shifted loss
这个实验验证:
```text
model.forward 的 loss
等于
logits[..., :-1, :] 与 labels[..., 1:] 手动 cross_entropy 的 loss
```
运行:
```bash
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_loss_shift.py --mode sft --show_rows 70
```
记录:
```text
logits.shape =
shift_logits.shape =
shift_labels.shape =
model_forward_loss =
manual_shift_loss =
abs_diff =
active_loss_positions =
ignored_loss_positions =
```
你应该看到:
```text
abs_diff 接近 0
shift_logits.shape = [1, seq-1, vocab_size]
shift_labels.shape = [1, seq-1]
SFT 里大量位置 participates=False
```
这说明模型源码里的 loss 就是 shifted next-token cross entropy。
### 实验 B:对比 Pretrain 的参与位置
运行:
```bash
PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_loss_shift.py --mode pretrain --show_rows 50
```
记录:
```text
active_loss_positions =
ignored_loss_positions =
哪些位置是 padding 导致的 ignored
```
你应该看到:
- pretrain 的非 padding 位置基本都参与 loss。
- SFT 的 user/system/prompt 大量不参与 loss。
- 两者 `manual_shift_loss` 都和 `model_forward_loss` 一致。
## 9. 本节检查
如果你真懂了本节,应该能不看答案说清楚:
1. 为什么 `logits[..., :-1, :]` 要对齐 `labels[..., 1:]`。
2. `logits[:, i, :]` 预测的是哪个位置的 token。
3. 为什么 `labels[:, 0]` 不参与 loss。
4. `ignore_index=-100` 在 MiniMind 里起什么作用。
5. 为什么 SFT 不需要单独写一个 SFT loss。
6. Pretrain 和 SFT 的训练目标差异到底来自哪里。
## 10. 下一课
第 11 课进入 `Attention 原理与源码`:我们会看 `q_proj / k_proj / v_proj` 如何把 `[batch, seq, hidden_size]` 拆成多头 Q/K/V,并开始解释 causal mask 与注意力权重。