从 128 条到 7473 条:一次 SFT 实现与五组 GSM8K 实验复盘
实验日期:2026 年 10 月 20—21 日。本文基于实际训练日志和本地 W&B 结果整理。代码为整理时的当前快照;实验数值来自已完成运行,没有为写文章重新训练。本文为实验记录,不代表官方基准成绩。
这次练习围绕一个问题:使用相同的模型和训练步数,只改变监督微调的数据量,会发生什么?
五组实验的训练数据量分别为 128、256、512、1024 和 7473 条。最明显的现象是:小数据组很快达到较高准确率,但训练后期持续退化;全量数据组在更长训练过程中保持了效果,最终准确率为 74.91%。
1. 这次 SFT 在训练什么
SFT 使用成对的 prompt 和 response 作为监督数据。模型读取问题和回答的前缀,预测回答中的下一个 token。训练目标是提高示范回答的概率,评估阶段计算的 reward 不参与这段训练的梯度更新。
本实验的模型为 Qwen2.5-Math-1.5B,训练文件为 train_sft_reason_gsm8k_raw.jsonl。代码直接读取其中的 prompt 和 response。没有开启 --filter_correct,因此没有按 is_correct 字段筛选;这不等于已经核实训练数据中存在多少错误回答。
评估时,程序把 GSM8K 问题放入 r1_zero.prompt,让模型生成推理文本及答案。评分器检查输出格式,再比较提取出的最终答案。
2. 从代码理解训练流程
2.1 输入、标签和 response mask
tokenize_prompt_and_output 分别编码 prompt 和 response,再拼接并右侧补齐。假设 prompt token 为 [P1, P2],response token 为 [R1, R2],则对齐关系是:
拼接序列: P1 P2 R1 R2
input_ids: P1 P2 R1
labels: P2 R1 R2
response_mask: 0 1 1
每个位置预测下一个 token,因此输入取 [:-1],标签与回答掩码取 [1:]。损失只保留回答位置,prompt 和补齐位置不直接作为监督目标。prompt 仍是生成回答的上下文,回答损失可以通过模型的计算路径向前传播梯度。
2.2 取出目标 token 的 log probability
模型 logits 的形状为 [B, L, V]:batch、序列长度、词表大小。训练代码计算:
lse = torch.logsumexp(logits, dim=-1)
target_logits = torch.gather(logits, -1, labels.unsqueeze(-1)).squeeze(-1)
log_probs = target_logits - lse
这对应目标 token 的 log-softmax 值,结果形状为 [B, L]。SFT 对其取负,得到负对数似然损失。
2.3 当前 loss 的归一化方式
当前实现先对每条回答的有效 token 损失求和,再按样本数平均:
L = (1 / B) × Σ样本 Σ回答位置 [-log p(目标 token | 前缀)]
这里的 normalize_constant=1.0,所以没有再按回答 token 数平均。这意味着回答越长,在其他条件相同的情况下,累计损失贡献越大。不能把日志中的 loss 直接与其他项目的 token 平均交叉熵比较,也不能直接对它取指数当作通常定义的 token 困惑度。
2.4 梯度累积与参数更新
每个 micro-batch 为 2 条,累积 32 次后更新一次参数,有效样本量为 64 条次。每次反向传播前把 loss 除以 32,日志记录时再还原这一缩放。然后将梯度范数裁剪到 1.0,执行 AdamW 更新并清空梯度。
实际循环由 micro_batch_size × gradient_accumulation_steps 决定;当前代码中的 batch_size 参数没有用于自动计算或校验累积次数。单独修改 --batch_size 并不会改变实际有效 batch。
每次抽取 micro-batch 内的两条数据互不重复,但不同 micro-batch 之间可以抽到相同样本。
2.5 评估与权重同步
训练使用 PyTorch 模型,生成评估使用 vLLM。每次评估前,将当前策略权重同步到 vLLM 并清空 prefix cache,避免用旧权重或旧缓存评估新模型。代码通过 CPU 中转权重,以适配当时的运行环境。
评估使用 temperature=0.0,最多生成 1024 个 token,遇到 </answer> 停止并保留停止字符串。这里的 1024 是生成 token 上限,不是训练输入截断长度。
3. 实验设置
| 配置 | 五组共同设置 |
|---|---|
| 基础模型 | Qwen2.5-Math-1.5B |
| 训练步数 | 256 |
| 学习率 | 2e-5,代码未配置学习率调度器 |
| micro-batch / 梯度累积 | 2 / 32 |
| 每步样本条次 | 64 |
| 评估间隔 | 每 8 步,另包含 step 0 |
| 评估数据 | GSM8K test.jsonl,1319 题 |
| 生成方式 | 贪心,最多 1024 token |
| 正确样本过滤 | 未开启 |
| 设备 | 单卡训练与评估,cuda:0 |
| 随机种子参数 | 42;显式传入 vLLM |
128~1024 条由批量脚本运行;7473 条来自单独完成的全量实验。其主要配置一致。更早的另一份 7473 条未完成日志没有混入本次比较。
当前训练程序没有独立显式调用 random.seed、numpy.random.seed 或 torch.manual_seed;vLLM 初始化是否影响全局随机状态依赖库实现。因此,只看 --seed 42 不足以证明训练抽样的完全可复现性,也不能假定不同规模的子集严格嵌套。
4. 指标含义
| 指标 | 当前实现的定义 | 阅读方式 |
|---|---|---|
eval/accuracy | 平均最终 reward,等于格式合格且答案正确的题目比例 | 核心效果指标 |
eval/answer_score | 平均答案奖励 | 与 accuracy 完全相同 |
eval/format_score | 格式合格的题目比例 | 不代表数学答案正确 |
eval/avg_length | 生成文本 len(text) 的均值 | 单位是字符,不是 token |
eval/avg_length_correct | reward 大于 0.5 的回答平均字符数 | 当前即判对的回答 |
eval/avg_length_incorrect | 其他回答平均字符数 | 包含格式错误 |
train/loss | 回答 token NLL 求和后按样本平均 | 衡量训练拟合,受长度影响 |
train/response_entropy | 训练回答位置的平均词表分布熵 | 低表示确定,不表示正确 |
train/global_entropy | prompt 与回答非 padding 位置的平均熵 | 混合了两类位置 |
train_step | 优化器更新次数 | 不是 micro-batch 次数 |
eval/samples | 每轮前 100 题的生成表 | 用于错误分析,不是全评估集 |
评分器要求出现精确子串 </think> <answer> 和 </answer>。格式不合格时,答案奖励也为 0。它不是对任意格式都提取数字的纯答案准确率,也没有严格校验完整 XML 结构。
熵是在训练文本前缀下计算的,不是评估集自由生成过程的平均熵。代码使用自然对数;当前直接在模型 logits 的数值精度下计算,小熵数值不宜作过细解读。全局熵和回答熵也不能脱离各位置的权重直接相减得到 prompt 熵。
5. 五组结果总览
| 训练条数 | 最高准确率 | 最高点 step | 最终准确率 | 下降/百分点 | 平均抽取次数 |
|---|---|---|---|---|---|
| 128 | 72.10% | 8 | 49.81% | 22.29 | 128.00 |
| 256 | 71.42% | 8 | 55.50% | 15.92 | 64.00 |
| 512 | 73.46% | 32 | 56.03% | 17.44 | 32.00 |
| 1024 | 73.09% | 72、80 | 61.33% | 11.75 | 16.00 |
| 7473 | 74.91% | 256 | 74.91% | 0.00 | 2.19 |
五组初始准确率均为 20.85%。最高点只指每 8 步采样评估所观测到的最高值,不是对所有训练时刻的穷尽搜索。
按最终模型比较,7473 条组最好,较 1024 条组高 13.57 个百分点,相当于多答对 179 道题。按各组峰值比较,全量组仍最高,但比 512 条组只高约 1.44 个百分点;单次运行不足以证明这一小幅峰值差距可以稳定复现。
准确率随训练进度变化
| step | 128 | 256 | 512 | 1024 | 7473 |
|---|---|---|---|---|---|
| 0 | 20.85% | 20.85% | 20.85% | 20.85% | 20.85% |
| 8 | 72.10% | 71.42% | 70.96% | 70.89% | 70.96% |
| 16 | 70.66% | 70.81% | 70.58% | 70.05% | 69.37% |
| 32 | 66.19% | 70.58% | 73.46% | 70.96% | 71.34% |
| 64 | 55.12% | 64.22% | 70.20% | 72.25% | 72.40% |
| 80 | 58.38% | 62.09% | 68.84% | 73.09% | 73.01% |
| 128 | 53.98% | 59.29% | 62.09% | 70.89% | 73.84% |
| 192 | 52.16% | 55.65% | 56.48% | 66.79% | 74.68% |
| 256 | 49.81% | 55.50% | 56.03% | 61.33% | 74.91% |
完整 33 个评估点见 results/eval_history_all.csv,总计五组 165 条记录。
6. 逐组分析
128 条:早期见效,后期退化最明显
step 8 达到 72.10%,step 64 降至 55.12%,最终只有 49.81%。此时最终训练 loss 为 0.1373,回答熵为 0.0358。低训练损失与下降的测试准确率同时出现,是明显的过拟合信号。不过,仅凭这些指标不能把所有下降都严格归因于同一个机制。
256 条:高分维持时间延长,但仍训练过久
step 8 为 71.42%,step 40 仍有 70.51%,到 step 128 为 59.29%,最终为 55.50%。最终 loss 为五组最低的 0.0562,却没有获得最高测试准确率。这说明训练拟合程度与泛化表现必须分开评估。
512 条:额外训练先有收益,再出现退化
从 step 8 的 70.96% 上升到 step 32 的 73.46%,step 64 仍有 70.20%,但最终降至 56.03%。更多不同样本使额外更新在早期仍有价值,长期反复训练后优势则大幅丢失。
1024 条:高分窗口进一步延长
step 72、80 达到 73.09%,到 step 128 仍有 70.89%,最终为 61.33%。相较小数据组,它更耐训练,但 256 步相对于当前数据量仍表现出后期退化。
7473 条:高位波动,没有出现同样的持续下滑
step 8 达到 70.96%,step 48 为 73.01%,step 128 为 73.84%,最终为 74.91%。中间也有回落,例如 step 160 为 72.25%,随后恢复,因此不能描述成单调增长。
这组最后一步恰好是观测最高点,并不证明 256 步是最佳停止时间,也不能直接断言继续训练一定会更好。能够确认的是:在已经观察到的训练范围内,全量数据组保留了早期收益。
7. 为什么数据量会改变训练曲线
五组都处理了 256 × 64 = 16384 条次训练样本,但每条样本平均被抽到的次数差异很大:128 条组约 128 次,256 条组 64 次,512 条组 32 次,1024 条组 16 次,全量组约 2.19 次。
这不是严格逐条遍历的 epoch,而是随机抽样的期望次数;全量组也不保证每一条样本都出现过。
相同更新次数下,小数据组反复拟合同一小批回答,全量组则接触更丰富的数据。这与前四组后期退化、全量组较稳定的结果相符。不过,实验同时改变了数据覆盖范围和平均重复次数,不能把两者的作用完全拆开,也不是严格的等 token 或等 FLOPs 对比。
8. 用其他指标交叉检查
以下是 step 256 的结果。训练指标是最后一个更新步所统计的值,不是全程均值,也不是对整个训练集重新计算的损失。
| 最终指标 | 128 | 256 | 512 | 1024 | 7473 |
|---|---|---|---|---|---|
| 准确率 | 49.81% | 55.50% | 56.03% | 61.33% | 74.91% |
| 格式合格率 | 98.64% | 99.39% | 99.39% | 99.85% | 99.55% |
| 训练 loss | 0.1373 | 0.0562 | 0.5188 | 11.3750 | 43.4434 |
| 回答熵 | 0.0358 | 0.0250 | 0.0757 | 0.1955 | 0.5107 |
| 全局熵 | 1.0767 | 0.7944 | 0.8608 | 0.7825 | 1.2410 |
| 平均长度/字符 | 275.7 | 258.0 | 281.8 | 273.5 | 259.7 |
| 正确回答长度/字符 | 239.5 | 228.8 | 238.9 | 245.5 | 232.2 |
| 错误回答长度/字符 | 311.7 | 294.4 | 336.5 | 317.9 | 341.6 |
loss 更大,为什么效果更好
全量组最终 loss 为 43.4434,远高于小数据组,但测试准确率最高。它没有把训练回答拟合到小数据组那样的程度,却在未见题目上表现更好。loss 还受回答长度和当前抽样 batch 影响,不能直接按跨组 loss 大小评选最佳模型,更不能把“让 loss 变大”当成优化目标。
格式已经基本学会,剩下主要是答案错误
五组最终格式合格率都超过 98%。全量组的 1319 题中,1313 题格式合格,988 题答对,325 题格式合格但答案错误,另有 6 题格式不合格。此时继续单纯强化标签格式,不足以解决主要错误。
初始准确率也受格式门槛影响。保留的初始前 100 题样例中,55 题格式合格、16 题答对;这是局部样本统计,不能直接当作全量格式率。因此,从 20.85% 到约 71% 的初期跃升,不能全部解释成数学能力提高。
错误回答普遍更长,但不能据此推断因果
五组错误回答平均长度均高于正确回答。可能是难题同时导致长推理与错误,也可能存在重复或绕路。需要检查具体样例才能区分;直接限制回答长度未必提高准确率。
低回答熵不是高可靠性的保证
小数据组最终回答熵很低,但测试表现较差,符合对训练上下文高度确定而泛化不佳的现象。全量组熵较高,也不意味着“熵越高越好”;这里只能结合训练暴露次数、loss 和评估曲线共同解释。
9. 实验边界和下一步
- 当前评估文件实际是测试集。本文可以回顾其训练曲线,但下一轮应单独划分验证集选择停止点,再对测试集做最终评估。
- 当前只在训练结束保存模型,没有保存每次评估的最佳权重。日志中的早期峰值不能直接恢复成 checkpoint。
- 每个规模只有一次运行,没有多随机种子均值、误差区间或全量逐题配对检验。512 与 1024 的峰值差约 5 道题,不足以认定前者稳定更优。
- 后续显式设置训练随机种子,并保存抽样索引;如要研究纯粹的数据规模效应,可预先构造嵌套子集。
- 同时记录 token 平均 NLL、实际生成 token 数、截断比例,并按错误类型检查样例,提升指标解释性。
- 优先补齐最佳 checkpoint 保存和独立验证集,再决定是否扩展全量组训练时长。
本次实验表明,少量数据可以迅速改善模型在当前提示与评分规则下的表现,但反复训练同一小批数据容易使收益退化。全量 7473 条数据让高分维持到更晚阶段,并获得本次实验最高的最终准确率。选择 SFT 配置时,数据规模、训练时长和验证效果应放在一起看。
附件下载
附:代码和结果索引
评分器是带有上游署名和 Apache-2.0 声明的依赖代码,不应作为个人原创算法发布。本文中的实验结果来自运行产物,分析文字由本次整理辅助生成并供作者审阅。