Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

人类反馈与偏好优化

同一个问题可以有几种都说得通的回答:

问题:只回答颜色。小林选了红色杯子。
A:红
B:红色。
C:蓝

A 和 B 都提到了正确颜色,但 A 更贴近“只回答颜色”的要求。C 则内容错误。让人写出一份完美答案,有时很费力;把两份回答摆在一起,请人比较哪份更好,往往更容易组织。

这一章研究怎样使用这样的比较。我们会先把偏好变成一个奖励预测,再用策略更新提高奖励;还会看到一种直接使用偏好对的方法 DPO。最后回到一个朴素问题:更讨评分器喜欢,是否真的等于更好?

1. 一条偏好记录里有什么

最基本的偏好样本包含三个部分:同一个提示 xx、较受偏好的回答 ywy_w、另一份回答 yly_l。下标 ww、ll 分别表示这次比较里的胜者与败者。

“这次比较”很重要。人可能在准确性、简洁性、完整性和语气之间做不同权衡。没有说明比较规则,标注分歧未必意味着谁粗心。

真实数据通常还需要保留候选生成方式、评分标准、标注来源与分歧情况。呈现顺序、回答长度和醒目的措辞都可能影响判断;同一来源的近重复提示也不该随意拆进训练与测试两边。

本章只用一个提示、三个候选和十个模拟偏好标签:八次选择 A,二次选择 B,C 没有参与这些比较。它刻意保留了分歧和未覆盖的候选,方便观察训练目标究竟约束了什么。

2. 奖励模型:把“更喜欢”变成分数差

奖励模型给提示与回答一个分数:

rϕ(x,y) r_\phi(x,y)

ϕ\phi 是奖励模型参数。分数本身可以是任意实数,通常不能直接解释成正确概率。

一种常用比较模型把分数差通过 Sigmoid 变成偏好概率:

P(yw≻yl∣x)=σ(rϕ(x,yw)−rϕ(x,yl)) P(y_w\succ y_l\mid x) =\sigma\bigl(r_\phi(x,y_w)-r_\phi(x,y_l)\bigr)

其中 σ(z)=1/(1+e−z)\sigma(z)=1/(1+e^{-z})。两者分数相等,比较概率为 0.5;胜者得分越高,预测偏好概率越大。

相应的训练损失为:

LRM=−log⁡σ(rw−rl) \mathcal L_{\mathrm{RM}} =-\log\sigma(r_w-r_l)

再对偏好样本平均。给两个分数同时加同一个常数,分数差不变,因此偏好记录并不决定奖励的绝对零点。

在八比二的模拟标签中,最佳比较概率接近 0.8,所需分数差为:

rA−rB=log⁡0.80.2=log⁡4≈1.386 r_A-r_B=\log\frac{0.8}{0.2} =\log4\approx1.386

这没有告诉我们 C 应该得多少分。没有比较过的回答区域,需要更多数据、合理的泛化或额外检查,不能靠这个式子自动补齐。

3. RLHF:用反馈学奖励,再用奖励调整策略

一种典型路线是:先做 SFT,让模型具备基本回答行为;再收集同一提示下的回答比较,训练奖励模型;最后让策略生成回答,用预测奖励指导更新。

指令跟随与人类反馈研究 展示了这类流程,通常称为 RLHF,即从人类反馈中进行强化学习。具体系统可以有不同的数据和训练安排,不能把某一套流程当成唯一实现。

如果只最大化奖励模型分数,策略可能迅速远离原来的语言行为。常见做法是保留一个冻结的参考策略 πref\pi_{\mathrm{ref}},给偏离它的行为加惩罚:

J(θ)=Ey∼πθ[rϕ(x,y)]−βDKL(πθ∥πref) \begin{aligned} J(\theta)={}&\mathbb E_{y\sim\pi_\theta}[r_\phi(x,y)]\\ &-\beta D_{\mathrm{KL}}(\pi_\theta\Vert\pi_{\mathrm{ref}}) \end{aligned}

为简洁省略了对提示的平均。β>0\beta>0 控制参考约束的强弱。若候选回答可以枚举,这里的 KL 是:

DKL(π∥πref)=∑yπ(y)log⁡π(y)πref(y) \begin{aligned} &D_{\mathrm{KL}}(\pi\Vert\pi_{\mathrm{ref}})\\ &\qquad=\sum_y\pi(y)\log\frac{\pi(y)}{\pi_{\mathrm{ref}}(y)} \end{aligned}

它衡量两个分布的差异。语言模型通常不能枚举所有完整回答,会使用相应的词元计算或采样估计。KL 约束可以限制漂移,但不能保证奖励正确,也不能保证所有能力都被保留。

4. PPO:同一批尝试,不要一次改得太猛

PPO 是一种常用的策略优化方法。它先用当前策略采样一批轨迹,把这份采样策略暂时记为 πold\pi_{\mathrm{old}},再对这批轨迹进行若干轮更新。

某个已采样动作的概率比是:

ρt(θ)=πθ(at∣st)πold(at∣st) \rho_t(\theta) =\frac{\pi_\theta(a_t\mid s_t)} {\pi_{\mathrm{old}}(a_t\mid s_t)}

如果 ρt=1.3\rho_t=1.3,表示这个动作在新策略下的概率是采样时的 1.3 倍。它不是概率本身,所以可以大于 1。

一种常见的 PPO 裁剪收益写成:

gt=min⁡(ρtA^t,clip⁡(ρt,1−ϵ,1+ϵ)A^t) \begin{aligned} g_t=\min\bigl(&\rho_t\hat A_t,\\ &\operatorname{clip}(\rho_t,1-\epsilon,1+\epsilon)\hat A_t\bigr) \end{aligned}

A^t\hat A_t 是优势估计,ϵ\epsilon 是裁剪宽度。优化时提高平均的 gtg_t,再配合价值学习、KL 项等相应设计。

若优势为正,把动作概率提高一些是好方向,但比例超过 1+ϵ1+\epsilon 后,裁剪项不再给额外收益。若优势为负,则需要注意不等号随符号改变;并不是把所有更新都机械地截到同一个区间。

图 1:ϵ=0.2\epsilon=0.2,优势分别为 1 和 −1-1。这里画的是单个样本的目标函数,不是对整张策略分布的硬约束。

裁剪不会保证每个动作最终概率比都处在区间内,也不能保证 KL 一定小于某个阈值。它是优化目标的一部分,仍需要检查实际更新幅度与训练过程。PPO 原始论文 给出了这种代理目标及其实验。

两个旧模型,别混在一起

名称在做什么什么时候更新
πold\pi_{\mathrm{old}}记录本批轨迹来自哪个策略,用于概率比每次重新采样时换成新的快照
πref\pi_{\mathrm{ref}}作为整个后训练过程的参考分布本例始终冻结

当前策略、旧采样策略和参考策略,可能一开始参数相同,之后职责却不同。把它们混成一个变量,会改变算法。

5. DPO:直接学习偏好对的相对变化

DPO 不先显式训练一个奖励模型再运行同样的在线奖励优化循环,而是把偏好对与参考策略直接放进损失。

先定义一个相对参考的回答分数:

zθ(x,y)=log⁡πθ(y∣x)−log⁡πref(y∣x) z_\theta(x,y) =\log\pi_\theta(y\mid x) -\log\pi_{\mathrm{ref}}(y\mid x)

然后对一对回答计算:

δθ=zθ(x,yw)−zθ(x,yl)LDPO=−log⁡σ(βδθ) \begin{aligned} \delta_\theta&=z_\theta(x,y_w)-z_\theta(x,y_l)\\ \mathcal L_{\mathrm{DPO}} &=-\log\sigma(\beta\delta_\theta) \end{aligned}

直观上,它希望胜者相对参考策略得到的提升,比败者更大。参考项不能在微调时跟着一起更新,否则比较的基准就变了。

对于真正的自回归语言模型,一份回答的对数概率是回答各词元条件对数概率之和,通常包含约定的终止符:

log⁡πθ(y∣x)=∑tlog⁡πθ(yt∣x,y<t) \log\pi_\theta(y\mid x) =\sum_t\log\pi_\theta(y_t\mid x,y_{<t})

提示部分提供条件,不作为这份回答的生成概率累加进去。直接把“序列总对数概率”换成“每词元平均”会改变目标,不能当作无影响的代码整理。

DPO 原始研究 从带 KL 约束的奖励优化与偏好概率模型之间的联系得到这个形式。它有自己的假设,包括偏好建模方式、参考分布及数据覆盖。更简单的训练流程不意味着偏好数据的偏差消失了。

β\beta 也不是学习率。它改变偏好损失中的相对分数尺度;固定偏好数据下,选择不同值会改变需要多大策略概率变化才能拟合这些偏好。

6. 动手:三个候选上的奖励、PPO 与 DPO

preference_optimization.py 把每份完整候选回答看作一个动作,直接训练三个分数。这样可以枚举概率、计算精确 KL,并检查优化结果。

它不是完整的语言模型 RLHF:没有大规模文本生成,没有逐词元价值网络,也没有人类标注平台。PPO 部分是单步环境,已知三个奖励后,用旧策略的精确期望奖励作基线;每轮抽 64 个动作,重复更新 4 次,共 100 轮。

运行:

uv run --python 3.12 --with-requirements requirements.txt python src/examples/preference_optimization.py
同一提示的三个候选:红 | 红色。 | 蓝
模拟偏好:简短正确答案赢 8 次,完整正确答案赢 2 次;错误答案未参与比较。
参考策略:0.200, 0.600, 0.200
DPO:0.444, 0.333, 0.222
单步 PPO:0.449, 0.330, 0.220
拟合奖励的解析最优:0.444, 0.333, 0.222
奖励差=1.386;拟合的简短偏好概率=0.800
ratio=0.7,1.0,1.3;正优势的裁剪收益:0.7, 1.0, 1.2
ratio=0.7,1.0,1.3;负优势的裁剪收益:-0.8, -1.0, -1.3
另一个假设:代理奖励高估错误答案。
beta=2.0:代理奖励=1.761;真实正确概率=0.577
beta=1.0:代理奖励=2.317;真实正确概率=0.319
beta=0.2:代理奖励=3.000;真实正确概率=0.000

前半段同时拟合奖励与 DPO,二者的参数独立,没有互相传递梯度。随后用拟合奖励做单步 PPO。

这里还可以求出奖励目标的解析最优分布:

π∗(y)∝πref(y)exp⁡(r(y)β) \pi^*(y)\propto \pi_{\mathrm{ref}}(y)\exp\left(\frac{r(y)}{\beta}\right)

正比号表示最后还要除以所有候选权重之和。我们取 β=1\beta=1。这个结果给数值优化提供了一个独立检查,而不是单凭“损失降了”就认定实现正确。

图 2:候选 C 从未参与偏好比较。相对提高 A 对 B 的偏好,并不自动保证 C 的概率下降。

看到 0.222,先别跳过它

DPO 后,“红”的概率从 0.2 提高到约 0.444,达到了这批偏好想表达的方向。但错误答案“蓝”的概率从 0.2 变成约 0.222,并没有下降。

这不是说 DPO 必然增加错误答案,而是说明:数据没有约束的区域,不会因为偏好损失下降就自动变好。 整张分布需要归一化,调整两个候选也会影响第三个的概率。实际语言模型的未覆盖区域远比这个例子大。

展开完整代码:奖励拟合、DPO、单步 PPO 与反例
"""第 29 章:三候选回答上的奖励拟合、DPO 与单步 PPO;不是完整 LLM RLHF。"""

import torch
from torch import nn
from torch.nn import functional as F

REFERENCE = torch.tensor([.2, .6, .2], dtype=torch.float64)
RESPONSES = ["红", "红色。", "蓝"]


def preference_pairs():
    # 同一个提示:两份正确答案比较,8 个模拟标注选简短,2 个选完整句。
    winners = torch.tensor([0]*8+[1]*2)
    losers = 1-winners
    return winners, losers


def dpo_loss(logits, reference, winners, losers, beta=1):
    log_probs, log_ref = logits.log_softmax(-1), reference.log()
    margin = beta*((log_probs[winners]-log_ref[winners])
                   -(log_probs[losers]-log_ref[losers]))
    return -F.logsigmoid(margin).mean()


def clipped_gain(ratio, advantage, epsilon=.2):
    return torch.minimum(ratio*advantage, ratio.clamp(1-epsilon, 1+epsilon)*advantage)


def fit_reward_and_dpo():
    winners, losers = preference_pairs()
    rewards = nn.Parameter(torch.zeros(3, dtype=torch.float64))
    logits = nn.Parameter(REFERENCE.log().clone())
    optimizer = torch.optim.SGD([rewards, logits], lr=.3)
    for _ in range(300):
        reward_loss = -F.logsigmoid(rewards[winners]-rewards[losers]).mean()
        loss = reward_loss+dpo_loss(logits, REFERENCE, winners, losers)
        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        optimizer.step()
    return rewards.detach(), logits.detach()


def ppo_bandit(rewards, rounds=100):
    logits = nn.Parameter(REFERENCE.log().clone())
    optimizer = torch.optim.SGD([logits], lr=.2)
    generator = torch.Generator().manual_seed(2026)
    for _ in range(rounds):
        # old 随每轮采样更新;REFERENCE 在整个实验中固定。
        old_probs = logits.detach().softmax(-1)
        actions = torch.multinomial(old_probs, 64, replacement=True, generator=generator)
        old_log_probs = old_probs[actions].log()
        advantages = (rewards[actions]-(old_probs*rewards).sum()).detach()
        for _ in range(4):
            log_probs = logits.log_softmax(-1)
            ratio = (log_probs[actions]-old_log_probs).exp()
            gain = clipped_gain(ratio, advantages).mean()
            # 三个候选全部可枚举,所以用精确 KL,不使用采样近似。
            kl = (log_probs.exp()*(log_probs-REFERENCE.log())).sum()
            loss = -gain+kl
            optimizer.zero_grad(set_to_none=True)
            loss.backward()
            optimizer.step()
    return logits.detach()


def overoptimization():
    # 独立假设:第三个错误答案被一个有缺陷的代理奖励高估。
    proxy = torch.tensor([1., .8, 3.], dtype=torch.float64)
    utility = torch.tensor([1., 1., 0.], dtype=torch.float64)
    rows = []
    for beta in [2., 1., .2]:
        probabilities = (REFERENCE.log()+proxy/beta).softmax(-1)
        rows.append((beta, float(probabilities @ proxy), float(probabilities @ utility)))
    return rows


def main():
    torch.set_num_threads(1)
    rewards, dpo = fit_reward_and_dpo()
    ppo = ppo_bandit(rewards)
    print("同一提示的三个候选:" + " | ".join(RESPONSES))
    print("模拟偏好:简短正确答案赢 8 次,完整正确答案赢 2 次;错误答案未参与比较。")
    for name, probabilities in [("参考策略", REFERENCE), ("DPO", dpo.softmax(-1)),
                                ("单步 PPO", ppo.softmax(-1)),
                                ("拟合奖励的解析最优", (REFERENCE.log()+rewards).softmax(-1))]:
        print(name + ":" + ", ".join(f"{value:.3f}" for value in probabilities))
    print(f"奖励差={rewards[0]-rewards[1]:.3f};"
          f"拟合的简短偏好概率={torch.sigmoid(rewards[0]-rewards[1]):.3f}")
    ratios = torch.tensor([.7, 1., 1.3], dtype=torch.float64)
    print("ratio=0.7,1.0,1.3;正优势的裁剪收益:" +
          ", ".join(f"{value:.1f}" for value in clipped_gain(ratios, torch.ones(3))))
    print("ratio=0.7,1.0,1.3;负优势的裁剪收益:" +
          ", ".join(f"{value:.1f}" for value in clipped_gain(ratios, -torch.ones(3))))
    print("另一个假设:代理奖励高估错误答案。")
    for beta, proxy, utility in overoptimization():
        print(f"beta={beta:.1f}:代理奖励={proxy:.3f};真实正确概率={utility:.3f}")


if __name__ == "__main__":
    main()

7. 奖励越高,可能离目标越远

输出的后半段是一个独立的假设反例。我们故意给代理奖励设置:

“红”得 1 分,“红色。”得 0.8 分,错误的“蓝”却得 3 分。

真实任务只关心颜色是否正确,前两个候选正确,第三个错误。随后直接计算不同 KL 系数下的最优策略。随着约束减弱,策略越来越喜欢那个被高估的错误回答:代理奖励接近 3,正确概率却接近 0。

图 3:这是人为指定奖励误差的演示,不是前半段拟合奖励的结果。用来说明代理目标与真实目标可以朝不同方向变化。

这类问题常被讨论为奖励过度优化:优化器在越来越有效地追逐一个并不完整的代理目标。Gao 等人的研究 对奖励模型过度优化进行了系统实验。

实际偏差可能来自偏好数据覆盖不足、奖励模型把篇幅或口气当作质量线索、标注标准含糊,以及策略逐渐生成奖励模型没见过的回答。增加训练轮数可以把这些偏差利用得更充分,而不是自动修复它们。

因此,后训练评估需要与训练奖励保持一定独立性:检查真实任务正确性、要求遵循情况、保留能力和具体失败样本;必要时补充新的比较数据。仅看训练奖励或偏好分类准确率,不足以证明最终助手更可靠。

8. 停下来试一试

  1. 给两份回答的奖励都加 100,比较概率会改变吗?
  2. 十次比较中八次偏好 A、两次偏好 B,为什么拟合概率应接近 0.8,而不是 1?
  3. PPO 的概率比为何可能大于 1?
  4. A^=−1\hat A=-1、ρ=0.7\rho=0.7、ϵ=0.2\epsilon=0.2 时,裁剪收益为什么是 −0.8-0.8?
  5. πold\pi_{\mathrm{old}} 与 πref\pi_{\mathrm{ref}} 分别何时变化?
  6. DPO 中直接平均回答词元对数概率,会不会得到同一个目标?
  7. C 没参与比较,为什么仍要检查它的概率?
  8. 为本例补充涉及 C 的偏好记录,再比较三种分布。你会把哪些结果作为任务改善的证据?
参考答案与提示
  1. 不会,分数差没有变化。
  2. 标签存在分歧,始终预测 A 胜出会给选择 B 的样本很大损失。
  3. 它是新旧概率之比,例如 0.39/0.3=1.30.39/0.3=1.3,不是一个独立概率。
  4. 两项分别为 −0.7-0.7 与 −0.8-0.8,取较小者;负优势改变了比较方向。
  5. old 随新一轮采样更新,随后在本批更新中固定;ref 在本章整个后训练中固定。
  6. 不会。长度归一化会改变不同回答的相对尺度,应视为目标修改。
  7. 我们最终使用整张回答分布,而不是只使用已经比较过的那两个选项。
  8. 预先规定正确候选优于 C,报告新增标签、偏好拟合与全部候选概率,并检查真实正确概率是否提高。只展示偏好训练损失不够。

9. 继续阅读

如果任务答案可以用程序检查,能否直接把检查结果变成奖励,少依赖一个预测偏好的模型?下一章从这个问题出发。