HOMEWORK 01

指令微调(SFT)

在 No Robots 上微调一个 360M 的 base 模型,亲眼看着它从「无限重复问题」变成「回答问题然后停下来」。这一路上 loss 几乎没有下降。

对应章节:第 4 章 · 指令微调 配置:homework/hw1-sft/sft_smol360m.yaml 日志:homework/hw1-sft/logs/run_sft_360m.log 状态:✓ 已跑通

0. 任务目标

第 4 章反复强调的一件事是:SFT 的损失函数和预训练完全一样,变的只有数据格式(chat template)和 loss 在哪些 token 上算(prompt masking)。既然目标函数没变,那 SFT 到底改变了什么?

答案是行为,而不是「困惑度」。base 模型对巴黎是法国首都这件事早就知道,它缺的是「我现在扮演的是一个助手,问题问完了该给答案而不是接着出题,答完了该停下来而不是继续写」这套约定。这个转变是离散的、行为层面的,而交叉熵是连续的、token 层面的平均值。两者不必同步——这正是本次实验要你亲眼确认的事。

这组实验的验收标准
  • 要看的是在环采样(in-loop generation)面板:训练脚本每 50 个优化步会拿同一组固定 prompt 生成一次。step 0 时的输出和几百步后的输出放在一起,差异是肉眼可见的。
  • 不要看 loss 判断成败。本次实测中 loss 全程在 2.0–2.4 之间震荡,末步比首步还高——但模型确实学会了回答并停止。
  • 验证「相变」的具体标志:(a) 不再复述 prompt;(b) 给出与问题相关的内容;(c) 会停——不再一路写到 max_new_tokens 耗尽。

这一题的上游版本(第 4 章末尾的 Suggested Experiments 第 1 条)用的是 allenai/OLMo-2-0425-1B 跑全量 No Robots。本站因为显存只剩约 6.9 GiB,换成了 HuggingFaceTB/SmolLM2-360M,并相应改小了序列长度、batch 与数据量——改了什么、为什么改,是下一节的内容。

1. 配置讲解

完整配置在 homework/hw1-sft/sft_smol360m.yaml:

# Model
model_name: HuggingFaceTB/SmolLM2-360M
chat_template_source: HuggingFaceTB/SmolLM2-360M-Instruct  # base 分词器没有 chat_template

# Dataset (CC BY-NC 4.0; educational use only)
dataset_name: HuggingFaceH4/no_robots
dataset_split: train
max_samples: 4000
max_length: 1024

# Training
lr: 2.0e-5
num_epochs: 2
batch_size: 2
gradient_accumulation_steps: 8   # 有效 batch = 16
warmup_ratio: 0.1
weight_decay: 0.0
max_grad_norm: 1.0
seed: 42

# Hardware
bf16: true
gradient_checkpointing: true
model_device_id: 0

# In-loop generation logging
sample_every: 50
sample_max_tokens: 128
sample_max_input_tokens: 512
sample_temperature: 0.7
sample_top_p: 0.9
sample_do_sample: true

# Logging
wandb_project: null
wandb_run_name: hw1_sft_smollm2_360m_no_robots

相对上游默认改了什么

项上游 sft_olmo2_1b.yaml本作业为什么
model_name allenai/OLMo-2-0425-1B HuggingFaceTB/SmolLM2-360M 1B 全参数 + AdamW(权重 + 梯度 + 两份动量)在约 6.9 GiB 可用显存里会 OOM。360M 留出了足够余量
max_length 2048 1024 激活显存随序列长度线性增长。No Robots 的绝大多数样本远短于 1024,截断损失有限
batch_size 4 2 同上,直接压激活峰值
gradient_accumulation_steps 8(有效 32) 8(有效 16) micro-batch 减半后有效 batch 也随之减半。有效 batch 16 远小于第 4 章提到的生产级约 256,训练噪声因此更大——这也是后面 loss 曲线抖成那样的原因之一
max_samples null(全量约 9.5K) 4000 控制单次实验时长。4000 条 × 2 epoch 已足以看到相变
num_epochs 3 2 同上
lr 5.0e-6 2.0e-5 小模型需要更大的学习率。这一条不是拍脑袋——HW4 在同一个 360M 上做了 lr 对照:5e-6 时 DPO 的 loss 全程贴着 $\ln 2$ 不动,换成 2e-5 才出现学习信号

几个没改、但值得知道为什么在那里的项

chat_template_source。base 模型的 tokenizer 没有 chat_template 字段——它从来没被教过对话格式。上游代码的做法是从一个「同族的 Instruct 版本」把模板搬过来:读取 SmolLM2-360M-Instruct 的 tokenizer,把它的 chat_template 赋给 base 的 tokenizer。这一步如果搞错(比如借了另一个模型族的模板),训练时的格式和你之后推理时用的格式就对不上,模型会学得很好但推理时表现莫名其妙——第 4 章说这是最高频的线上事故来源。

gradient_checkpointing: true 与 bf16: true。前者用重算换显存(前向不保存中间激活,反向时重新算一遍),代价是训练变慢;后者把权重和计算放在 bf16 上。在 6.9 GiB 这个预算下这两项都不是可选项。

sample_every: 50。这是整个配置里最重要的一行。每 50 个优化步,脚本会用一组固定 prompt(What is the capital of France?、Explain quantum computing in simple terms.、Write a haiku about programming.、How does photosynthesis work?)生成一次并打印。本次实验的全部结论都来自这个面板,不是来自 loss。

采样用的是 do_sample=True,不是贪心

sample_do_sample: true 加 temperature 0.7 / top_p 0.9,意味着每次采样面板都带随机性。好处是能看到模型输出分布的真实样貌(贪心解码会掩盖掉「模型其实很不确定」这件事);坏处是相邻两次采样的差异里有一部分只是运气。判断相变要看趋势,不要抓住某一次特别好或特别差的输出下结论。

loss mask 是在哪一步发生的

这是 SFT 与预训练唯一的实质差别,值得看一眼真实代码。上游 instruction_tuning/utils.py 的 _encode_row:

IGNORE_INDEX = -100

prompt_ids = tokenizer.apply_chat_template(
    messages[:-1], tokenize=True, add_generation_prompt=True, return_dict=False
)
full_ids = tokenizer.apply_chat_template(
    messages, tokenize=True, add_generation_prompt=False, return_dict=False
)
labels = [IGNORE_INDEX] * len(prompt_ids) + list(full_ids[len(prompt_ids):])

手法很朴素:把同一段对话渲染两次——一次只到「该 assistant 说话了」为止(add_generation_prompt=True),一次是完整的;前者的长度就是要被屏蔽的前缀长度。前缀位置的 label 全部置为 -100,F.cross_entropy(..., ignore_index=-100) 会跳过它们。

这个写法的好处是不需要任何字符串匹配或模板解析,因此对模板细节免疫。坏处是每条样本要 tokenize 两遍,以及它只保留了最后一个 assistant 轮次的监督——多轮对话里前面几轮的 assistant 回复也被当成上下文屏蔽掉了。对 No Robots 这种以单轮为主的数据集影响很小,但如果你换成多轮数据集,这是第一个要检查的地方。

2. 运行命令

本次实测使用的命令:

python -m instruction_tuning.train --config homework/hw1-sft/sft_smol360m.yaml

执行前提是 instruction_tuning 这个包能被 import 到,也就是上游的 code/ 目录(本仓库在 _src/code/)在 PYTHONPATH 上;配置文件路径按你机器上的实际位置写。如果你按上游文档的方式工作,等价写法是:

cd _src/code/
uv run python -m instruction_tuning.train \
    --config ../../homework/hw1-sft/sft_smol360m.yaml

配置里已经写了 wandb_project: null,所以不需要 W&B 账号,脚本会以 disabled 模式初始化。但这样一来逐步指标就只剩控制台里的那点信息了——想留一份完整的 loss / grad_norm / lr 曲线,走总览页介绍过的包装器:

METRICS_JSONL=homework/hw1-sft/logs/metrics_sft_360m.jsonl \
  python homework/tools/run_with_metrics.py instruction_tuning.train \
  --config homework/hw1-sft/sft_smol360m.yaml

它把 wandb.log 换成「先写 JSONL、再原样转发」的版本,行为与直接跑训练脚本完全一致,只是多落一份逐步指标。本作业目录下的 logs/metrics_sft_360m.jsonl 就是这么来的。

跑起来会看到什么

脚本先打印一个配置面板(模型、参数量、设备、数据集、有效 batch、总步数与 warmup 步数),本次运行报出总计 496 个优化步。然后进入训练循环,控制台交替出现两类东西:

  • 进度条,描述栏实时显示当前优化步的平均 loss;
  • 每 50 步一次的 Samples @ step N 面板,四个固定 prompt 各占一个框,输出里的特殊 token(<|im_start|>、<|im_end|> 等)会被高亮。
生成面板打印的是完整序列,包含输入

generate_samples 里解码的是 out[0] 全长且 skip_special_tokens=False,所以面板里看到的是「system + user + assistant」整段,而不只是新生成的部分。第一次看容易误以为模型在复读——要分清哪一段是模板给的、哪一段是模型写的。判断依据是 <|im_start|>assistant 这一行:它之后的内容才是模型的输出。

这次运行的完整控制台记录保存在 homework/hw1-sft/logs/run_sft_360m.log,下一节引用的所有输出都出自它。

3. 实测结果

运行环境与规模:基座 HuggingFaceTB/SmolLM2-360M(base),chat template 取自 SmolLM2-360M-Instruct;数据 HuggingFaceH4/no_robots 4000 条,2 epoch,max_length 1024;batch 2 × grad_accum 8(有效 16),lr 2e-5,bf16 + gradient checkpointing;总计 496 个优化步。

loss 曲线

step15099148197246295344393442491
loss2.10352.05861.98492.31932.21972.26272.42292.10942.25592.23542.3398

末步 loss 2.2383。loss 全程在 2.0–2.4 之间震荡,没有单调下降——step 99 的 1.9849 是全程最低值,而它出现在训练进行到五分之一的时候。最后一步(2.2383)比第一步(2.1035)还高。

如果这是你第一次跑 SFT,看到这条曲线的正常反应是「训练失败了,学习率是不是设错了」。不是。下面是同一次运行里的生成面板。

生成质量:这才是真信号

step 0(还没有做任何更新的 base 模型),prompt What is the capital of France?:

What is the capital of France?<0xEF><0xBF><0xBD>ctory
<0xEF><0xBF><0xBD>ctoryuser
What is the capital of France?<0xEF><0xBF><0xBD>ctory
<0xEF><0xBF><0xBD>ctoryuser
(如此重复直到 max_tokens 用尽,永不停止)

同样是 step 0,prompt Explain quantum computing in simple terms.:

Explain quantum computing in simple terms.
urbandictionary
urbandictionary
urbandictionary
(重复刷屏直到耗尽预算)

step 450,同一个 What is the capital of France?:

Paris ikipedia
# The city of Paris is the capital of France.
# It is the largest city in the country and the fourth-largest in Europe.

结论:答对了、会停了,但仍带 ikipedia 这类残留噪声和多余的 markdown # 前缀。360M + 4000 条数据能完成 base→assistant 的形态转变,但离干净助手还有距离。

把这两组输出摆在一起看

观察维度step 0(base)step 450
是否复述 prompt是,把问题原样吐回来并反复循环否,直接给答案
内容是否相关否,ctory / urbandictionary 这类碎片是,Paris,且后续描述基本切题
是否伪造角色标记是,自己写出了 user 开始下一轮否
是否会停否,一路写到 token 预算耗尽是
残留问题—ikipedia 噪声、多余的 # 前缀、事实细节未必可靠
这张表里的每一行,都不体现在 loss 上

「会不会复述 prompt」「会不会伪造角色标记」「会不会停」——这三件事全是离散的行为开关。它们翻转所对应的,是极少数关键位置上的概率重排(比如「在回答结束的那个位置,<|im_end|> 的概率是否超过其它 token」)。一条 1024 长度序列的平均交叉熵里,这几个位置的贡献被稀释到了几乎看不见。

未运行 / 未测量的部分

本次运行没有做任何留出评测(held-out eval),也没有记录峰值显存占用,因此本页不给出「验证集 loss」「显存占用 X GiB」之类的数字。上面出现的所有数字仅限于:11 个采样点的训练 loss、末步 loss、总步数,以及两组原样引用的生成输出。

4. 核心教学点:loss 曲线不是进度指标,在环采样才是

这是本次实验唯一真正重要的产出,值得完整论证一遍。

现象

loss 从 2.10 走到 2.24,几乎没动、甚至略升;而同期生成质量发生了质变:从「无限重复 prompt + 伪造角色标记 + 永不停止」变成「答对了、会停了」。两条信号完全脱钩。

原因一:批间方差淹没了训练进展

先看这一步的 loss 是怎么算出来的。compute_loss 做的是标准的因果语言建模交叉熵,在所有未被屏蔽的 token 上求平均:

$$ \mathcal{L}(\theta) = -\frac{1}{|\mathcal{M}|}\sum_{t \in \mathcal{M}} \log \pi_\theta\!\left(y_t \mid y_{<t}, x\right) $$

其中 $\mathcal{M}$ 是这一个 batch 里所有 label 不等于 -100 的位置的集合(也就是 assistant 回复的 token)。注意这是逐 token 平均,不是逐样本平均——一条长回复对 loss 的贡献权重远大于一条短回复。

而 No Robots 是一个刻意做得很杂的人写数据集:里面既有「Chat」这样一两句话的闲聊,也有「Generation」类的长篇写作、「Summarize」「Rewrite」「Extract」这类给定长文本再加工的任务。不同类目的样本,长度和难度差着一个数量级。本作业的有效 batch 只有 16 条(batch 2 × grad_accum 8),意味着每个优化步的 loss 是从 16 条随机抽到的样本上算出来的。

结果就是:这一步碰巧抽到几条创意写作(本来就难预测、交叉熵天然高),loss 就往上跳;下一步抽到几条短问答,loss 就掉下来。这个批间方差的量级,和 2 个 epoch 内模型真实进步带来的那点交叉熵下降,是同一个数量级——甚至更大。于是曲线看起来就是噪声。

怎么把这个方差压下去(如果你确实想看到平滑曲线)
  • 加大有效 batch。第 4 章提到的生产级设置是约 256 条 prompt,是本作业的 16 倍——方差按 $1/\sqrt{N}$ 缩小,曲线自然平滑得多。这也是为什么论文里的 SFT loss 曲线总是很好看,而你自己跑的总是毛刺。
  • 用固定的留出集算 eval loss,而不是看训练 loss。每次都在同一批样本上评估,样本组成这一项方差就消失了,剩下的才是模型的变化。本次运行没有做这件事——这是这组实验最值得补的一个改进。
  • 按类目分开看。No Robots 自带类目标签,分类目统计 loss 能立刻看出「变化到底来自模型还是来自采样」。

原因二:行为转变本来就不体现在平均交叉熵上

这一条比方差更根本。下面是一个示意计算(不是实测数据):假设一条 assistant 回复有 200 个 token,模型要学会的「答完就停」这件事,落到概率上是在第 200 个位置让 <|im_end|> 的概率超过其它候选。这一个位置的对数概率哪怕从 $-6$ 改善到 $-0.1$,对 200 个 token 的平均值的影响也只有大约 $5.9/200 \approx 0.03$——完全被前面提到的批间方差盖住。

但从行为上看,这个位置的翻转是决定性的:它是「模型可用」与「模型不可用」的分界线。同理,「不再复述 prompt」「不再自己写出 user 开始下一轮」也都是少数几个关键位置上的概率重排。

一句话概括这个不匹配

交叉熵度量的是整条序列上的平均预测精度;而我们关心的是少数关键位置上的 argmax 是否正确。前者是一个被大量无关 token 稀释过的连续量,后者是离散的、稀疏的、且只在采样时才显形。用前者当后者的代理,分辨率不够。

推论:SFT 阶段该怎么判断「练好了没有」

信号能告诉你什么不能告诉你什么
训练 loss数值是否爆炸(NaN、突然飙到 10 以上)——这是故障探测器模型好没好。它在正常范围内的起伏几乎不含信息
grad_norm是否触发了 max_grad_norm 裁剪、是否有异常尖峰同上
在环采样格式对不对、会不会停、有没有复述、内容相不相关——SFT 阶段真正要的东西定量比较两个 checkpoint 谁更好(样本太少、还带采样随机性)
留出集评测能定量比较 checkpoint本次未运行,见下一节的消融建议

这条经验不止适用于 SFT。HW2 里会看到一个对称的版本:奖励模型的 margin 一路上涨,但判别准确率原地踏步——同样是「一个连续指标在动,而你真正关心的离散能力没动」。附录 C 把这类问题归成了一类症状:先确认你在看的指标和你想要的能力之间到底有没有因果关系。

常见误区:拿 loss 高低横向比模型

更糟的用法是拿两次运行的 loss 数值直接比大小。SFT 的 loss 绝对值取决于分词器、序列长度、mask 策略、数据类目配比——换任何一项,数值就不可比了。不同 tokenizer 之间的交叉熵尤其没有可比性(同一段文本切成的 token 数不同,逐 token 平均的分母就不同)。想比较,只能在完全相同的数据与 mask 下比,或者干脆去比下游行为。

5. 自选消融

下面四个方向都是一次只改一个变量的对照实验。以下全部未在本次会话中运行,因此不给任何预期数字——只说改什么、看什么、以及为什么这个变量值得单独扫。

(a) 扫学习率

上游第 4 章的建议是复制配置,试 1e-6、5e-6、5e-5,其余不动。本作业已经把默认值定在 2e-5,所以更有信息量的一组是 5e-6 / 2e-5 / 5e-5 / 1e-4。

要回答的问题怎么看
最早在哪个 step 出现「会停」这个行为翻采样面板,找第一次输出以 <|im_end|> 正常结束的那一页。这是本实验唯一靠谱的「速度」度量
学习率太小会怎样采样面板长时间维持 base 的样子:复述、循环、不停
学习率太大会怎样模型很快学会格式,但内容退化成模板形状的套话(第 4 章说的 "template-shaped slop");更极端时 loss 出现尖峰或 NaN

这一组正是第 4 章「SFT 学习率比预训练低 1–2 个数量级」那句话的实操版。注意那句话的参照系是十亿参数级模型:360M 上的合适值明显更高,本配置从 5e-6 提到 2e-5 的依据来自 HW4 在同一基座上的实测对照。

(b) 改 epoch 数

把 num_epochs 设成 1 / 2 / 4,其余不动。要看的是「格式学会了」和「内容开始退化」之间的窗口有多宽:SFT 的过拟合不像分类任务那样表现为验证集指标掉头,而是表现为输出变得模板化、开始重复训练集里的固定句式、多样性下降。采样面板的四个 prompt 如果开始给出结构高度雷同的回答,就是信号。

这里也是补一个留出集的好时机:No Robots 自带 test 划分(脚本默认只用 train)。加一个在固定 500 条上算 eval loss 的钩子,就能把「训练 loss 的噪声」和「模型的真实变化」分开——这是第 3 节那条抖动曲线最直接的解药。

(c) 改数据量

max_samples 取 500 / 1000 / 4000 / null(全量约 9.5K),其余不动,尤其要固定 epoch 数还是固定总步数——这两种控制方式回答的是不同的问题:

  • 固定 epoch 数:数据越多,总步数越多,比较的是「更多数据 + 更多更新」的合力。
  • 固定总步数(把 num_epochs 相应调小):比较的是数据多样性本身的价值,因为梯度更新次数一样多。

后一种设置才是第 4 章「数据质量 > 数据数量」「LIMA 用 1000 条就能做 chat 对齐」那场讨论的正确对照方式。在 360M 这个尺度上,500 条能不能完成 base→assistant 的相变,是一个很值得亲自测一次的问题。

(d) 关掉 loss masking

这是最有教育意义的一个消融,因为它对应第 4 章点名的「最常见的 bug」。改法是在 instruction_tuning/utils.py 的 _encode_row 里把 mask 那一行换掉:

# 原始:只在最后一个 assistant 轮次上算 loss
labels = [IGNORE_INDEX] * len(prompt_ids) + list(full_ids[len(prompt_ids):])

# 消融:整条序列都算 loss(等价于「在对话格式上继续做预训练」)
labels = list(full_ids)

改完之后要看两件事:

  1. loss 的绝对值会变,而且方向不一定直观——prompt 部分往往比 assistant 回复更好预测(模板 token 是确定的),所以平均 loss 可能反而降低。这恰好再次证明第 4 节那句话:loss 变低不等于模型变好。两种设置下的 loss 数值根本不可比,因为分母(参与平均的 token 集合)都不一样。
  2. 采样面板里模型学到了什么。理论上的失效模式是:模型把一部分容量花在「学会生成用户会问什么问题」上,因此更容易在回答后自己伪造下一轮 <|im_start|>user 继续写下去——也就是 step 0 那种行为的一个变体。这个方向的预测本次未验证,值得你自己跑一遍确认。
为什么这个 bug 特别难被发现

它不报错、loss 曲线看起来完全正常(甚至更好看)、模型也确实在学东西。唯一能暴露它的就是看生成,或者做一个专门检查「模型会不会自己续写用户轮」的小评测。这也是本页反复强调「在环采样是主指标」的另一个理由:它是少数几种能同时抓到行为退化和数据管线 bug 的手段。

做完这组实验后回哪一节

  • 第 4 章 · 指令微调:chat template 的渲染细节、loss masking 的多轮处理、数据质量与数量之争、超参的实际取值范围。本页所有「为什么这么配」的完整论证都在那里。
  • 附录 C · 实践问题:显存逐项核算、超参该先动哪个、「loss 不降」这类症状的排查顺序。
  • HW2 · 奖励模型:下一步。SFT 模型是后训练流水线的地基,而奖励模型是它上面的第一层——你会在那里再次遇到「指标在动、能力没动」这个主题。