HOMEWORK 02

奖励模型(Bradley-Terry)

在 UltraFeedback 上训一个偏好奖励模型。两组对照:135M 完全学不动,360M 学到了但很弱。这是一个「失败得很有教育意义」的实验。

对应章节:第 5 章 · 奖励建模 日志:homework/hw2-rm/logs/run_bt_smol135m.log、run_bt_smol360m.log 状态:✓ 已跑通

0. 任务目标

奖励模型(Reward Model, RM)是 RLHF 里唯一一个「把人的判断压缩成一个可微函数」的环节。第 5 章把 Bradley-Terry 从概率模型一路推到那个著名的 -logsigmoid(r_chosen - r_rejected),本作业要做的就是把这个损失真的跑一遍,然后回答一个很朴素的问题:它到底学没学到东西?

之所以要单独问一遍,是因为 RM 训练有一个不太友好的性质:它的所有指标看起来都在动。loss 会降,margin 会涨,训练准确率会升。但这些当中只有一个和「模型是否真的会判好坏」直接挂钩,其余几个都可能在模型什么都没学会的情况下照样好看。这组实验就是用两个规模的基座,把这件事演示出来。

这组实验的验收标准
  • 主指标是留出集(validation)上的成对判别准确率,即 $r_{\text{chosen}} > r_{\text{rejected}}$ 的比例。它的随机基线是 0.5。低于或等于 0.5 就是没学到,没有商量余地。
  • 参考基线是 BT 损失的随机值 $\ln 2 \approx 0.693$。如果 loss 在 0.693 之上徘徊,说明模型给出的两个分数几乎无差别甚至方向相反。
  • margin 是辅助指标,绝不能单独看。本次实测会给出一个 margin 一路上涨、而准确率原地踏步的完整例子。
  • 规模是这里的关键变量:同一份代码、同一个数据集,135M 和 360M 的结果是定性不同的。

上游第 5 章末尾的 Suggested Experiments 第 1 条给的起手式是 --samples 2000 --epochs 1,默认基座 Qwen/Qwen3-0.6B-Base;本站因为显存只剩约 6.9 GiB,换成了 SmolLM2 的 135M 与 360M 两档,正好凑成一组规模对照。

1. Bradley-Terry 训练在做什么

完整推导在第 5 章,这里只留跑实验必需的那几行。

Bradley-Terry 模型假设每个回复 $y$ 在给定 prompt $x$ 下有一个标量「实力值」,人在两个回复之间做选择的概率由二者实力之差决定:

$$ P\big(y_{\text{c}} \succ y_{\text{r}} \mid x\big) \;=\; \sigma\!\big(r_\phi(x, y_{\text{c}}) - r_\phi(x, y_{\text{r}})\big) $$

其中 $\sigma$ 是 sigmoid,$y_{\text{c}}$ / $y_{\text{r}}$ 分别是被选中(chosen)与被拒绝(rejected)的回复。对数据集做最大似然,取负对数,就得到实现里那一行:

loss = -F.logsigmoid(r_chosen - r_rejected).mean()
三个必须记住的性质
  • 只有差值有意义。把所有 $r_\phi$ 同时加一个常数,损失完全不变——奖励函数只被约束到差一个平移常数。所以看单个回复的绝对分数是没有意义的:两个不同模型(甚至同一个模型的两个 checkpoint)给出的分数之间没有可比性,唯一有意义的比较是同一个模型对同一个 prompt 的多个回复之间的排序。
  • 随机基线是 $\ln 2$。模型什么都不会时 $r_{\text{c}} - r_{\text{r}} \approx 0$,$-\log\sigma(0) = \log 2 \approx 0.693$。训练 loss 长期在 0.693 附近或之上 = 没学到。
  • 准确率就是 margin 的符号统计。代码里 correct = (r_chosen > r_rejected).sum(),也就是「margin 为正的比例」。margin 的大小不进入这个统计——这是第 4 节要展开的关键。

模型结构:一个挂在最后一个非 padding token 上的线性头

上游 reward_models/base.py + train_preference_rm.py 的做法非常薄:拿一个普通的因果语言模型当骨干,在最后一层隐状态上加一个输出维度为 1 的 nn.Linear,然后只取序列最后一个有效位置的输出作为整条序列的奖励。

def get_reward(self, input_ids, attention_mask):
    hidden = self.get_hidden_states(input_ids, attention_mask)

    # 每条序列的最后一个非 padding 位置
    seq_lengths = attention_mask.sum(dim=1) - 1
    batch_indices = torch.arange(hidden.size(0), device=hidden.device)
    last_hidden = hidden[batch_indices, seq_lengths]

    return self.head(last_hidden).squeeze(-1)

两个细节值得留意:

  • 为什么取最后一个 token。因果注意力下,只有最后一个位置「看过」整条序列,所以它的隐状态是唯一能代表全序列的那个。这就是第 5 章说的序列级(sequence-level)监督——整条回复只拿一个标量分数。
  • attention_mask.sum(dim=1) - 1 依赖右侧 padding。如果 tokenizer 配成左侧 padding,这行代码会静默取错位置。这是 RM 实现里最常见的沉默 bug 之一。

骨干是全参数微调(不是只训头),这也是为什么显存开销和一次普通 SFT 是同一个量级,而不是「只训一个线性层」那么轻。

一次前向要跑两遍

forward 对 chosen 和 rejected 各调一次 get_reward,所以一个「pair」在计算上等于两条序列。配置里的 batch_size: 2 指的是 2 个 pair,实际过模型的是 4 条序列——估算显存和时间时容易记漏这一点。

上游默认超参(不是本次实测结果)

reward_models/config.py 里的默认值:数据集 argilla/ultrafeedback-binarized-preferences-cleaned,samples 5000,max_length 512,batch_size 2,grad_accum_steps 8,epochs 2,lr 5e-5,warmup_ratio 0.1,val_ratio 0.1,eval_interval 25,lr_scheduler linear_decay,seed 123。val_ratio 0.1 意味着留出集是从同一批样本里切出来的 10%,不是独立的评测集——这一点在解读准确率时要记住。

上游 README 对这个模块的标注是 Experimental:「奖励模型训练需要调超参、数据集和模型才能得到更干净的曲线」。下面的实测结果印证了这句话。

2. 运行命令

这个模块没有配套 YAML,全部走命令行覆盖。本次实测的通用形式是:

python -m reward_models.train_preference_rm \
    --model-id <M> --samples <N> --epochs <E> --no-wandb

两组对照具体展开就是:

# A 组:135M,2000 对,1 个 epoch
python -m reward_models.train_preference_rm \
    --model-id HuggingFaceTB/SmolLM2-135M --samples 2000 --epochs 1 --no-wandb

# B 组:360M,5000 对,2 个 epoch
python -m reward_models.train_preference_rm \
    --model-id HuggingFaceTB/SmolLM2-360M --samples 5000 --epochs 2 --no-wandb

其余参数保持上游默认(batch_size 2、max_length 512、grad_accum_steps 8、lr 5e-5、val_ratio 0.1、eval_interval 25)。数据集是 argilla/ultrafeedback-binarized-preferences-cleaned。

A 组和 B 组不是干净的单变量对照

两组之间同时改了三个变量:模型规模(135M→360M)、样本数(2000→5000)、epoch 数(1→2)。所以严格来说,「360M 比 135M 好」这个结论里混着数据量和训练长度的贡献。把它当作「一个明显失败的配置 vs 一个勉强能看的配置」的定性对比,而不是规模的干净消融。想做干净对照,见第 5 节 —— 那正是这组实验最该补的一个实验。

加上 --no-wandb 之后指标去哪了

--no-wandb 会让 log_metrics 空转,但这个模块和 SFT / DPO 不同:它把关键信息也打到了 stdout。每个优化步一行训练指标,每 eval_interval(默认 25)步一行验证指标,每个 epoch 结束再来一行汇总。格式大致是这样(下面第二行的数字取自本次 B 组的实测结果):

Epoch 0 step 25 | loss <训练 loss> | acc <训练准确率>
Epoch 1 | Val Loss: 0.6963 | Val Accuracy: 0.558 | Val Margin: 0.2348

所以这一组直接重定向控制台输出就够用了,不必套 run_with_metrics.py——本作业 logs/ 下的两份日志就是这么来的。(如果你想要 train/r_chosen_mean、train/r_rejected_mean 这些只进 W&B 的字段,再用包装器。)

训练结束后的 demo

脚本默认会在训练结束后跑一个 demo_scoring:对固定 prompt Explain quantum computing in simple terms. 给出一个「好回答」和一个「差回答」,分别打分,然后打印模型有没有把好的排在前面。加 --skip-demo 可以跳过。

demo 是一个样本量为 1 的测试

它有用,但只在结果为负时有用:如果一个 RM 连这么悬殊的一对都分不出来,那它肯定不行。反过来,判对了什么也不说明——单个样本上的成功完全可能是运气。真正的判据是留出集准确率。本页在结果表里保留了 demo 这一列,是因为 A 组恰好在这里翻车了,而这个翻车很直观。

3. 实测结果

两组对照

配置基座样本epoch最终 val 准确率最终 val margindemo 是否判对
ASmolLM2-135M200010.4850.031✗ 判错
BSmolLM2-360M500020.5680.235✓ 判对

日志:homework/hw2-rm/logs/run_bt_smol135m.log、run_bt_smol360m.log。

先把这张表读完再往下:A 组的 0.485 在 0.5 的下面。成对判别的随机基线就是 0.5,所以 A 组不是「学得慢」,是完全没学到——0.485 与 0.5 的差距完全在 200 条留出样本的采样噪声之内。B 组的 0.568 高于随机,但离第 5 章提到的合格 RM 的 0.65–0.75 还差得远。

B 组的 val margin 轨迹

step125150175200225250275300350400450500550
margin0.1260.1460.1630.1720.1880.1960.2040.2100.2230.2290.2350.2320.234

这条曲线单看非常漂亮:从 0.126 一路单调爬到 0.235,只在最后两个点上略微回落。如果 margin 是你唯一盯着的指标,你会认为这次训练很成功。

epoch 汇总

训练 loss训练准确率val lossval 准确率val margin
epoch 00.72760.5300.70280.5580.2064
epoch 10.65020.5960.69630.5580.2348

A 组的训练 loss 全程在 0.73–0.76——注意这是在 $\ln 2 \approx 0.693$ 这条随机基线之上。

同一份数据的两种「最终 val 准确率」

第一张表给 B 组的是 0.568,epoch 汇总表给的是 0.558。两个都对,只是评估时点不同:0.568 来自 eval_interval 触发的 step 550 那次评估,0.558 来自 epoch 结束时的那次评估。同一个模型、相隔十几步,两次评估就能给出不同的准确率——这个抖动量级本身就说明,这组实验里的小幅准确率差异不该被当真。

没有测的东西

本次实验没有独立的留出评测集:验证集是从同一批 UltraFeedback 样本里按 val_ratio 0.1 切出来的,A 组 200 对、B 组 500 对。也没有做多种子重复、没有记录峰值显存、没有在 RewardBench 之类的外部基准上评过。因此本页只给上面这三张表里的数字,不做任何外推。

4. 四个教学点

(1) 135M 完全学不动

A 组的 val 准确率 0.485 就是随机。三条独立证据互相印证:

  • 0.485 落在 0.5 的下方,200 对留出样本上这个偏差毫无意义;
  • A 组的训练 loss 全程 0.73–0.76,而 BT 损失的随机基线是 $\ln 2 \approx 0.693$——训练 loss 都在基线之上,连训练集都没拟合;
  • demo 里它甚至给差回答打了更高的分。

值得强调的是,SmolLM2-135M 能写出通顺的句子。它有完整的语法能力、常识片段和上下文建模。但「判断两个回答哪个更好」是一个完全不同的能力:它要求模型同时理解问题、评估两个回答各自的正确性与完整性、还要把这个比较压进一个标量。

奖励建模对基座规模的要求,比「能生成通顺句子」高得多

这解释了工业界一个看起来奢侈的做法:奖励模型通常不小于策略模型,很多配方里甚至更大。直觉上「打分应该比生成简单」,但实际恰恰相反——生成只要在每一步给出一个合理的下一个 token,打分要对整条序列做一次全局判断。第 5 章讨论 RM scaling 时的那些证据,在这里以一个极端小的规模被复现了:低于某个容量门槛,RM 训练根本不启动。

实践含义:如果你的 RM 训练曲线看起来像 A 组(训练 loss 贴着或高于 0.693,准确率在 0.5 附近),先怀疑基座太小或数据管线有 bug,不要去调学习率。调超参救不回一个容量不够的模型。

(2) 360M 学到了,但很弱

B 组的 0.568 确实高于随机,训练 loss 也从 0.7276 降到了 0.6502(跌破了 $\ln 2$),说明学习信号是真的。但 0.568 远低于第 5 章提到的合格 RM 的 0.65–0.75。

这个区间值得记住,因为它有一层反直觉的含义:即使是生产级的偏好奖励模型,成对判别准确率也就在 0.65–0.75 这个量级,而不是接近满分。原因不是模型笨,而是偏好数据本身就有大量「两个回答其实差不多」或者「标注者之间意见不一致」的样本——第 10 章那句「标注者之间的分歧不是噪声,是信号」在这里体现为一个准确率天花板。所以看到 0.568 时要同时意识到两件事:这个模型很弱,而且就算训得很好,它的上限也远没有一般分类任务那么高。

(3) 第二个 epoch 已经在过拟合

把 epoch 汇总表竖着读一遍:

指标epoch 0 → epoch 1怎么解读
训练 loss0.7276 → 0.6502明显下降。模型确实在拟合训练集
训练准确率0.530 → 0.596同上,训练集上的判别力在涨
val loss0.7028 → 0.6963几乎没动
val 准确率0.558 → 0.558原地不动

这是过拟合的教科书形态:训练指标和验证指标分道扬镳。第二个 epoch 里模型学到的东西,全部是这 4500 对训练样本的特有模式,没有一点泛化出去。

RM 通常只训 1 个 epoch

这不是本实验的特殊发现,而是 RLHF 里的常规做法:偏好奖励模型一般只过一遍数据。InstructGPT 那篇论文就明确提到多个 epoch 会过拟合。原因不难理解——RM 的监督信号极其稀疏(一整对回复只提供 1 bit:谁更好),模型有大量容量可以用来死记硬背具体样本对,而不是学习可迁移的判别标准。

本次 B 组用 2 个 epoch,正好把这件事演示了出来:第二个 epoch 的算力完全白花了。

(4) margin 会持续涨,但涨的不一定是判别力

这是四个教学点里最容易被忽略、也最值得带走的一个。把两条曲线并排放:

训练早期训练末期变化
val margin0.126(step 125)0.234(step 550)接近翻倍,且几乎单调
val 准确率全程只在 0.55–0.57 之间徘徊基本没动
为什么 margin 能涨而准确率不涨

回到定义:margin 是 $\E[r_{\text{c}} - r_{\text{r}}]$(一个连续量的均值),准确率是 $P(r_{\text{c}} > r_{\text{r}})$(一个符号统计)。模型完全可以在本来就判对的那些样本上把差距拉得更大,而对判错的样本一动不动——这样 margin 上升,符号分布却不变。

更糟的情况是:BT 损失 $-\log\sigma(\Delta)$ 对已经判对的样本仍有非零梯度($\sigma(\Delta) < 1$ 恒成立),所以「把简单样本的 margin 推得更大」是一条永远能降低 loss 的捷径。困难样本(两个回答确实接近的那些)的梯度贡献相对小得多。于是优化器很自然地走捷径。

margin 单独看会骗人,必须和准确率一起读。这条规则的适用范围远不止 RM:

  • HW4 的 DPO 也报 margins,同样不能单独看——那里的 accuracy 是配套指标。
  • HW1 是这个主题的另一个版本:loss 在动而能力没动(那里是 loss 不降但能力提升,这里是 margin 上升但能力不变——两个方向的脱钩都存在)。
  • 第 14 章的过优化整章都在讲这件事的放大版:奖励曲线一路向上,模型却越来越难用。
推论:RM 的绝对分数不要拿来做阈值

既然 margin 可以被单方面推大,那么「奖励分超过某个固定阈值就算好回答」这种用法从根上就不成立——分数尺度既有平移自由度(第 1 节),又会随训练漂移。第 5 章的说法是:奖励模型的绝对分数没有意义,只有相对排序有意义。这也是为什么拒绝采样用的是「在同一个 prompt 的 N 个候选里取最高分」,而不是「取所有分数高于某个阈值的候选」。

5. 自选消融

上游第 5 章的建议是「先扫 --samples、--lr、--model-id,再考虑动模型结构」。这个顺序有道理:结构改动会同时改变一堆东西,在你还不知道基线长什么样的时候动它,得不到任何可解释的结论。以下全部未在本次会话中运行,只说改什么、看什么。

(a) 先补一组干净的规模对照

这是最该做的第一件事。第 2 节说过,A / B 两组同时改了三个变量,所以「规模」这个结论其实是脏的。固定样本数和 epoch,只换 --model-id:

for M in HuggingFaceTB/SmolLM2-135M HuggingFaceTB/SmolLM2-360M; do
  python -m reward_models.train_preference_rm \
      --model-id "$M" --samples 2000 --epochs 1 --no-wandb
done

要回答的问题很具体:135M 的失败是容量问题,还是只是数据/训练量不够?如果 360M 在同样的 2000 对 / 1 epoch 下明显超过 0.5,那容量假说成立;如果它也趴在 0.5 附近,说明前面观察到的差异里数据量的贡献更大。

(b) 扫样本数

--samples 取 1000 / 2000 / 5000 / 10000,固定基座为 360M、固定 1 个 epoch。注意留出集是按比例切的(val_ratio 0.1),所以样本数一变,评测集也跟着变——这会让不同设置之间的准确率不严格可比。想做对,要么把 --val-ratio 设成 0 再配一个固定的外部评测集(见 (d)),要么至少在解读时记住这一点。

(c) 扫学习率

上游默认 lr 5e-5。往两边各试一个数量级(1e-5 / 5e-5 / 1e-4),观察:

症状可能的含义
训练 loss 始终在 $\ln 2$ 附近或之上学习率太小,或者容量不够(先排除后者,见 (a))
训练 loss 快速下跌但 val 准确率不动典型的过拟合,参考第 4 节 (3)
margin 涨得很快而准确率不涨正在走「把简单样本推得更开」的捷径,参考第 4 节 (4)
loss 出现尖峰或 NaN学习率太大。RM 用的是全参数微调,稳定性和 SFT 一个量级

顺带一提,本作业沿用的是 lr_scheduler: linear_decay(warmup + 线性衰减)。另一个可选值 warmup_only 保留了上游更早的行为,做学习率消融时不要同时改这一项,否则末段的有效学习率也变了。

(d) 加一个 50–200 条的固定留出评测集

这是上游第 5 章 Suggested Experiments 明确点名的一个「有用的贡献」,也是本组实验最大的短板——第 3 节列过:本次没有独立评测集,验证集是从同一批训练样本里切出来的。

要做的事很小:从 UltraFeedback(或另一个偏好数据集)里固定抽 50–200 对,永远不参与训练,写一个不需要跑完整训练就能调用的评测函数,报告成对排序准确率。核心逻辑其实上游已经写好了,就是 evaluate_preference_rm:

margin = r_chosen - r_rejected
total_correct += (margin > 0).sum().item()
total_margin  += margin.sum().item()

需要补的只是「固定的一份数据 + 一个能单独调用的入口」。它带来的三个好处:

  • 不同超参设置之间终于可比了——评测集不再随 --samples 变。
  • 能在调参过程中随时用,不必每次等训练跑完(这正是上游要求「小到能在调参时用」的原因)。
  • 能同时报准确率和 margin,把第 4 节 (4) 那个脱钩现象变成一个常规检查项,而不是事后才发现的意外。
再往前一步:按难度分层

如果这 200 条评测样本还带着标注置信度或分数差(UltraFeedback 有评分),可以按「两个回答分差大 / 分差小」分成两层分别报准确率。一个健康的 RM 应该在「差距明显」的那层接近满分,在「差距微小」的那层接近随机;如果两层的准确率差不多,说明模型学到的可能是长度、格式之类的表面特征,而不是内容质量——第 5 章讲 RM 评测批判时的核心担忧就是这个。

做完这组实验后回哪一节

  • 第 5 章 · 奖励建模:Bradley-Terry 的完整推导、ORM / PRM / 生成式奖励的分工、value head 的实现细节、以及 RewardBench 这类评测在测什么、测不到什么。
  • 第 14 章 · 过优化:本页第 4 节 (4) 的放大版——当这个不完美的 RM 被拿去做 RL 优化目标时会发生什么。
  • HW3 · 策略梯度:下一步。有了奖励函数之后怎么用它更新策略。
  • HW4 · DPO:另一条路线——完全跳过显式奖励模型,直接从偏好对上优化策略。跑完 HW2 再看 DPO,会更容易理解「隐式奖励」这个说法到底省掉了什么。