参考资料
DPO 是目前工业界最主流的离线偏好优化方法,把 RLHF 的四模型在线 RL 循环压缩成一个二分类 loss,工程上几乎零额外开销。SimPO 再往前一步,连 reference model 也砍掉,训练速度提升 20%、峰值显存降低 10%[3]。
效果上,DPO 在 TL;DR 摘要任务上达到 61% GPT-4 胜率,超过 PPO 的 57%[2]。SimPO 在 AlpacaEval 2 和 Arena-Hard 上进一步把胜率分别拉高 6.4% 和 7.5%[3]。
这篇文章从 RLHF 目标出发,推导 DPO 和 SimPO 的公式,解释”为什么能消掉 reward model”,并用代码走一遍 SimPO 的完整计算流程。
回顾:RLHF 目标与 PPO 实现
RLHF 的总体目标
RLHF 的目标是训练一个 policy,让它既能拿到高的 reward,又不要偏离 reference 太远:
其中:
- :reward model 对回答 的评分
- :KL 惩罚系数
- :reference model(通常是 SFT 模型)
这是 RLHF 的”问题定义”:我们想要达成的目标是这样的。
PPO 的具体实现
PPO 是用来实现上述目标的一种方法。PPO 的完整 objective 由 三项 组合成一个总 loss:
三项各自的作用:
| 组件 | 公式 | 作用 |
|---|---|---|
| Policy loss | 更新策略,使高 advantage 动作的概率提升。前面带负号是因为要用梯度下降最小化 loss | |
| Value loss | 训练 critic 准确预测 value,让 advantage 估计更准 | |
| Entropy bonus | 鼓励探索,防止策略过早收敛到局部最优 |
其中 是重要性采样比率,、 是权重系数。
关键点:PPO 只有 一个总 loss, backward 时同时产生 policy、value、entropy 三个梯度,一次更新 policy network 和 critic network(如果它们共享 backbone,梯度会累加到共享层)。
PPO 完整训练流程:
- Rollout:用当前策略采样一批轨迹
- 计算 advantage(用 GAE)
- 多次更新:同一批数据重复算总 loss,反复更新参数
- 重复
PPO 存在的问题
PPO 的问题:需要 4 个模型(Policy, Reference, RM, Critic)、在线采样、训练不稳定。
DPO 的思路:既然 RLHF 的目标已经很明确,我们能不能从 RLHF 目标出发,通过数学推导直接得到一个新的 loss?这样就不需要 PPO 那套复杂的在线 RL 机制了。
关键洞察:如果我们知道最优 policy,就可以反推出 reward 的表达式,从而消掉 reward model。
DPO 推导:四步闭式消元
下面的推导过程不需要记,真正需要记住的是:
- 核心思想:RLHF 目标可以推导成 policy log ratio 的二分类 loss
- 最终公式:DPO loss 的样子
- 直觉理解:DPO 是怎么工作的
DPO 的核心发现:在 Bradley-Terry 偏好模型假设下,可以从 KL 约束的 RL 目标中解析解出最优 policy,然后反推出 reward 的表达式,最终消掉 reward model。
Step 1:写出 KL 约束优化的拉格朗日形式
对于固定 prompt ,优化目标是:
展开 KL 散度:
Step 2:求解闭式最优策略
这是一个带约束的变分优化问题。用拉格朗日乘子法,约束是 (概率归一化)。
构造拉格朗日函数:
对 求变分导数并令其为零:
整理得:
指数化:
其中 是归一化常数(partition function),确保 。
直观理解:最优策略在 reference 的基础上,按 exponentiated reward 重新加权。reward 越高的回答,概率提升越多。
Step 3:反解 reward
从 Step 2 的结果反解 :
关键洞察:最优 reward 可以用最优 policy 和 reference policy 的 log ratio 表示!
这意味着:如果我们知道最优 policy,就不需要显式的 reward model。
Step 4:代入 Bradley-Terry 偏好模型得到 DPO Loss
人类偏好数据通常是成对比较:对于同一个 prompt ,比较两个回答 (win)和 (lose)。
Bradley-Terry 模型假设偏好概率为:
其中 是 sigmoid 函数。
把 Step 3 的 reward 表达式代入:
注意 被消掉了!这是 DPO 能work的关键——不需要知道归一化常数。
最终得到 DPO loss:
直觉理解:
- :policy 比 reference 更”喜欢”这个回答的程度
- 把 reward 差值映射成”chosen 优于 rejected”的概率, 则是让模型最大化这个概率
- 当 chosen 和 rejected 的 reward 差值变大时, 趋近 1,loss 趋近 0,说明模型已经学会了区分好坏
- Loss 想要增大 chosen 和 rejected 的这个差值,即:增大 chosen 的 log ratio,减小 rejected 的 log ratio
- 结果就是让 policy 更大概率生成 chosen,更小概率生成 rejected
- 不需要 reward model:reward 被隐式地表示为 policy 和 reference 的 log ratio
- 不需要在线采样:用离线偏好数据 直接训练
- 不需要 PPO:简单的二分类 loss
DPO 的隐式 reward 与梯度
定义 DPO 的 隐式 reward:
其中 叫做 margin(差距/裕量),表示 chosen 和 rejected 的 reward 差值。
为什么叫 margin:就像 SVM 里的间隔一样,margin 越大,模型越确信 chosen 比 rejected 好。当 margin > 0 时,模型判断正确;margin 越大,loss 越小,梯度越小(已经学好了)。
梯度效果:
- 增大 的 log probability
- 减小 的 log probability
- 权重取决于当前的 margin (通过 )
数值示例:从模型输出到 Loss
是模型对序列 的条件概率。对于自回归模型,它等于每个 token 条件概率的连乘:
取 log 后变成求和,这就是 的来源。
具体计算流程
假设 ,prompt 是”请解释量子计算”,模型前向传播后:
| 步骤 | 操作 | Chosen (y_w) | Rejected (y_l) |
|---|---|---|---|
| 1 | 模型输出 logits | shape [32, 151936],每个位置是 vocab 上的分数 | shape [28, 151936] |
| 2 | Log softmax 得 log prob | 每个 token 的 log prob 在 [-10, 0] 之间 | 类似 |
| 3 | 关键 token 示例 | ”量子”位置的 log prob = -0.95 ”比特”位置的 log prob = -0.72 | ”量子”位置的 log prob = -1.50 ”原理”位置的 log prob = -0.85 |
| 4 | 求和得 | -12.8 (32个token) | -18.5 (28个token) |
| 5 | 平均得 | -0.40 | -0.66 |
| 6 | Reference 模型同样计算 | = -0.44 | = -0.64 |
| 7 | Log ratio | +0.04 | -0.02 |
为什么选择”量子”这个 token 很重要
模型对 chosen 中的”量子”预测更自信(-0.95 vs -1.50),因为 chosen 的回答”量子计算利用量子比特…”更符合训练数据的分布。这体现在:
- 更小的负数 = 更高的概率 = 模型更”确信”
- 所有 token 的 log prob 加起来,chosen 总和更高(-12.8 > -18.5)
从 log ratio 到 loss
| 计算项 | Chosen | Rejected | 说明 |
|---|---|---|---|
| Log ratio | +0.04 | -0.02 | policy - reference |
| 隐式 reward ( ratio) | +0.004 | -0.002 | 乘以 0.1 |
| Margin | 0.006 | 差距很小,模型还没学好 | |
| Sigmoid | 0.5015 | 接近 0.5,不太确定 | |
| Loss | 0.689 | 还有优化空间 |
直观感受这些数字
- Log prob ≈ -1.0 对应概率 ≈ 37%(模型不太确定)
- Log prob ≈ -0.1 对应概率 ≈ 90%(模型很确定)
- Log prob 是负数,所以”总和更高”意味着”绝对值更小”
- Margin = 0.006 很小,说明 policy 对 chosen/rejected 的区分还不明显,需要继续训练
本轮训练前后对比(模型已经过若干轮训练,不是初始 SFT 状态):
| 本轮训练前 Chosen | 本轮训练前 Rejected | 本轮训练后 Chosen | 本轮训练后 Rejected | |
|---|---|---|---|---|
| -0.40 | -0.66 | -0.35 | -0.70 | |
| -0.44 | -0.64 | -0.44 | -0.64(frozen) | |
| +0.04 | -0.02 | +0.09 | -0.06 | |
| 隐式 reward | +0.004 | -0.002 | +0.009 | -0.006 |
本轮训练的效果: 从 -0.40 提升到 -0.35(概率增大), 从 -0.66 降低到 -0.70(概率减小),margin 从 0.006 变成 0.015,loss 从 0.689 降低到 0.556。
初始状态(Step 0):如果是刚初始化的 SFT 模型(),则 chosen 和 rejected 的 log ratio 都是 0,margin = 0,loss = -log σ(0) = 0.693。表格展示的是训练过程中某一轮的改进。
DPO 训练过程拆解
DPO 的计算流程比 PPO 简单很多,不需要在线采样、不需要 critic、不需要 rollout。假设我们有一对偏好数据,用具体数值演示整个计算循环。
输入示例:
- Prompt:
"请解释量子计算" - Chosen (y_w):
"量子计算利用量子比特进行计算,可以同时处理多个状态,计算能力远超经典计算机。"(32 个 token) - Rejected (y_l):
"量子计算就是用量子做的计算,非常复杂,涉及很多物理原理。"(28 个 token)
流程概览:
- Tokenize & 前向传播(Step 1-3):生成 token,提取 log probs
- Reference 前向传播(Step 4):计算 reference 的 log probs
- 计算隐式 reward(Step 5):计算 log ratio
- DPO Loss 计算(Step 6):计算最终 loss
Step 1: Tokenize —— 分词与对齐
# Tokenize prompt, chosen, rejected
prompt_tokens = tokenizer("请解释量子计算")["input_ids"] # [8]
chosen_tokens = tokenizer("请解释量子计算" + chosen)["input_ids"] # [40] = 8 + 32
rejected_tokens = tokenizer("请解释量子计算" + rejected)["input_ids"] # [36] = 8 + 28
prompt_len = len(prompt_tokens) # 8
chosen_len = len(chosen_tokens) # 40
rejected_len = len(rejected_tokens) # 36
# Batch tensor for forward pass (with padding)
input_ids = tokenizer(
[chosen, rejected],
padding=True,
return_tensors="pt"
)["input_ids"] # [2, 40],rejected 会被 padding 到 40
Shape 解释:
prompt_len=8:prompt 有 8 个 tokenchosen_len=40:chosen 完整序列是 prompt(8) + chosen_response(32)rejected_len=36:rejected 完整序列是 prompt(8) + rejected_response(28)input_ids=[2, 40]:batch 维度 2(chosen 和 rejected),序列长度 40(padding 后)
关键位置的 token:
| 序列 | 位置 | token | 中文 | 说明 |
|---|---|---|---|---|
| chosen | 0 | 3891 | 请 | prompt 开头 |
| chosen | 8 | 9123 | 量子 | chosen_response 开头 |
| chosen | 39 | 151643 | 序列结束符 | |
| rejected | 8 | 9123 | 量子 | rejected_response 开头 |
| rejected | 35 | 151643 | 序列结束符 |
Step 2: Policy 前向传播与 Log Probs 提取
# Policy 前向传播
outputs = policy(input_ids)
logits = outputs.logits[:, :-1, :] # [2, 39, 151936],预测下一个 token
# Log softmax
log_probs = F.log_softmax(logits, dim=-1) # [2, 39, 151936]
# 提取实际 token 对应的 log prob
labels = input_ids[:, 1:] # [2, 39],目标 token
policy_logp = log_probs.gather(-1, labels.unsqueeze(-1)).squeeze(-1) # [2, 39]
# 创建 completion mask(只计算 response 部分,不计算 prompt)
completion_mask = torch.zeros_like(policy_logp, dtype=torch.bool)
completion_mask[:, prompt_len-1:] = True # [2, 39]
# Masked log probs(只保留 response 部分)
policy_logp_masked = policy_logp * completion_mask # [2, 39]
Shape 解释:
- 第 0 维 batch=2:chosen 和 rejected
- 第 1 维 seq_len=39:因为 logits[:, :-1, :] 是预测下一个 token,所以比 input_ids 少 1
- 第 2 维 vocab=151936:每个 token 在词表上的概率分布
关键位置的数值(chosen 响应部分):
| 位置 | token ID | 中文 | 对应概率 | policy_logp | 含义 |
|---|---|---|---|---|---|
| 7 | 15234 | 计算 | 0.22 | -1.50 | 模型有 22% 置信度预测”计算”是下一个 token |
| 8 | 9123 | 量子 | 0.39 | -0.95 | 模型有 39% 置信度预测”量子”是下一个 token |
| 15 | 4532 | 比特 | 0.49 | -0.72 | 模型有 49% 置信度预测”比特”是下一个 token |
| 20 | 9876 | 经典 | 0.56 | -0.58 | 模型有 56% 置信度预测”经典”是下一个 token |
| 38 | 151643 | 0.26 | -1.35 | 模型有 26% 置信度预测结束 |
logp 的意义:对数概率。logp 越接近 0,模型越”确信”这个 token 是正确的。指数后得到概率:exp(-0.72) ≈ 0.49。
Step 3: 计算 Chosen 和 Rejected 的 Response Log Prob
# Sum over sequence dimension(只计算 response 部分)
policy_sum_logp = policy_logp_masked.sum(dim=-1) # [2]
# Count completion tokens
completion_lengths = completion_mask.sum(dim=-1) # [2]
# chosen_lengths = 32, rejected_lengths = 28
# Split chosen and rejected
chosen_sum_logp = policy_sum_logp[0] # scalar, 比如 -12.8
rejected_sum_logp = policy_sum_logp[1] # scalar, 比如 -18.5
chosen_len = completion_lengths[0] # 32
rejected_len = completion_lengths[1] # 28
# Average log prob(可选,DPO 可以用 sum 或 average)
chosen_avg_logp = chosen_sum_logp / chosen_len # -0.40
rejected_avg_logp = rejected_sum_logp / rejected_len # -0.66
关键位置的数值:
| Sum Log Prob | 长度 | Avg Log Prob | 含义 | |
|---|---|---|---|---|
| Chosen (y_w) | -12.8 | 32 | -0.40 | chosen 平均每个 token 的 log prob |
| Rejected (y_l) | -18.5 | 28 | -0.66 | rejected 平均每个 token 的 log prob |
直观理解:
- Chosen 的 avg_logp = -0.40,比 rejected 的 -0.66 高,说明模型对 chosen 更”确信”
- 这与”人类偏好 chosen”一致,模型应该学会给 chosen 更高的概率
Step 4: Reference 前向传播
# Reference model(frozen,不训练)
with torch.no_grad():
ref_outputs = reference(input_ids)
ref_logits = ref_outputs.logits[:, :-1, :] # [2, 39, 151936]
ref_log_probs = F.log_softmax(ref_logits, dim=-1) # [2, 39, 151936]
ref_logp = ref_log_probs.gather(-1, labels.unsqueeze(-1)).squeeze(-1) # [2, 39]
ref_logp_masked = ref_logp * completion_mask # [2, 39]
# Sum over sequence dimension
ref_sum_logp = ref_logp_masked.sum(dim=-1) # [2]
# Split chosen and rejected
chosen_ref_sum_logp = ref_sum_logp[0] # scalar, 比如 -14.2
rejected_ref_sum_logp = ref_sum_logp[1] # scalar, 比如 -17.8
chosen_ref_avg_logp = chosen_ref_sum_logp / chosen_len # -0.44
rejected_ref_avg_logp = rejected_ref_sum_logp / rejected_len # -0.64
关键位置的数值(参考模型的结果):
| Ref Sum Log Prob | Ref Avg Log Prob | 含义 | |
|---|---|---|---|
| Chosen (y_w) | -14.2 | -0.44 | reference 对 chosen 的平均 log prob |
| Rejected (y_l) | -17.8 | -0.64 | reference 对 rejected 的平均 log prob |
为什么 reference 的 log prob 比当前 policy 低:
- Reference 是 SFT 模型,未经过 DPO 微调
- Policy 已经经过一些 DPO 训练,学会给 chosen 更高概率
- 所以 policy 的 log prob > reference 的 log prob(对 chosen)
Step 5: 计算 DPO 隐式 Reward —— Log Ratio
回顾 DPO 的隐式 reward 公式:
这里的 就是 policy 和 reference 的 log prob 差值。
beta = 0.1 # KL 约束系数
# Log ratio(DPO 的隐式 reward)
# 注意:DPO 可以用 sum 或 average,这里用 average
chosen_log_ratio = chosen_avg_logp - chosen_ref_avg_logp # -0.40 - (-0.44) = 0.04
rejected_log_ratio = rejected_avg_logp - rejected_ref_avg_logp # -0.66 - (-0.64) = -0.02
# 隐式 reward(乘以 beta)
chosen_reward = beta * chosen_log_ratio # 0.1 * 0.04 = 0.004
rejected_reward = beta * rejected_log_ratio # 0.1 * (-0.02) = -0.002
关键位置的数值(β=0.1):
| Policy Avg Logp | Ref Avg Logp | Log Ratio (policy - ref) | 隐式 Reward (β × ratio) | 含义 | |
|---|---|---|---|---|---|
| Chosen (y_w) | -0.40 | -0.44 | +0.04 | +0.004 | policy 比 ref 更”喜欢”chosen |
| Rejected (y_l) | -0.66 | -0.64 | -0.02 | -0.002 | policy 比 ref 更”不喜欢”rejected |
Log Ratio 的意义:
- log_ratio > 0:
policy_logp > ref_logp,policy 给这个回答更高的概率,比 reference “激进” - log_ratio < 0:policy 给这个回答更低的概率,比 reference “保守”
- 隐式 reward 可以理解为”policy 偏离 reference 的程度”
Margin 计算:
- Margin = chosen_reward - rejected_reward = 0.004 - (-0.002) = 0.006
- Margin > 0:chosen 比 rejected 好,符合人类偏好
- Loss 应该把这个 margin 拉大(通过训练)
Step 6: DPO Loss 计算 —— 二分类 Loss
回顾 DPO loss 公式:
# Margin(隐式 reward 的差值)
margin = beta * (chosen_log_ratio - rejected_log_ratio) # 0.006
# DPO loss(负的 log sigmoid)
loss = -F.logsigmoid(margin) # scalar
# Backward
loss.backward()
数值计算:
- Margin = 0.006
- (chosen 优于 rejected 的概率)
- Loss =
梯度更新方向:
- 当 margin > 0 时,loss 想要增大 margin(让 更接近 1)
- 这会增大 chosen 的 log ratio(增大 chosen_reward)
- 减小 rejected 的 log ratio(减小 rejected_reward)
训练前后对比(假设训练一轮后):
| 训练前 Chosen | 训练前 Rejected | 训练后 Chosen | 训练后 Rejected | 改变 | |
|---|---|---|---|---|---|
| Policy Avg Logp | -0.40 | -0.66 | -0.35 | -0.70 | chosen 提升,rejected 降低 |
| Log Ratio | +0.04 | -0.02 | +0.09 | -0.06 | margin 变大 |
| 隐式 Reward | +0.004 | -0.002 | +0.009 | -0.006 | margin 变大 |
为什么训练后 margin 变大:
- Chosen 的 log ratio 从 0.04 提升到 0.09(policy 更”喜欢”chosen)
- Rejected 的 log ratio 从 -0.02 降低到 -0.06(policy 更”不喜欢”rejected)
- Margin 从 0.006 变成 0.015,loss 减小
SimPO:砍掉 Reference Model
DPO 虽然已经省掉了 reward model 和在线 RL,但仍然需要 reference model。每个 batch 要多做一遍前向传播,显存和速度都有开销。
SimPO 发现 DPO 有两个问题[3]:
- Reward 与生成指标不一致:DPO 优化的是 (log ratio),但实际生成时只关心 自己的概率
- Reference model 开销:每个 sample 多做一遍前向
SimPO 的修改
修改 1:隐式 reward 改为 length-normalized average log prob
和 DPO 的对比:
- DPO: —— 依赖 reference
- SimPO: —— 只依赖 policy 自身
除以长度 是为了归一化,防止长回答天然概率低的问题。
修改 2:加入 target margin
是超参数(通常取 0.5-1.0),表示期望 chosen 和 rejected 之间的最小 margin。
DPO vs SimPO 对比
| 特性 | DPO | SimPO |
|---|---|---|
| 隐式 reward | $\frac{\beta}{ | |
| 需要 reference | ✅ 需要 | ❌ 不需要 |
| 长度归一化 | 隐式(通过 ratio) | 显式(除以 |y|) |
| Target margin | 无 | 有() |
| 显存开销 | 2× policy | 1× policy |
| 训练速度 | 慢(2x forward) | 快(1x forward) |
SimPO 在 AlpacaEval 2 上达到 72.4% 长度控制胜率,Arena-Hard 上 59.1%[3]。工程上,训练时间少 20%,峰值显存少 10%[3]。
偏好数据构造
SFT 数据:(prompt, answer)。DPO/SimPO 需要:(prompt, chosen, rejected)。
数据来源
人工标注(InstructGPT[1]):
- 多个模型对同一 prompt 生成候选
- 人工标注员比较,选出更好和更差的
- 成本高昂,但质量高
自动化构造(SimPO[3]):
- 用当前 policy 生成多个回答
- 用 reward model 或 rule-based scorer 打分
- 选最高分作为 chosen,最低分作为 rejected
- 成本低,可大规模扩展
从线上 rollout 回流:
- 在线 RL 系统产生的轨迹
- 用最终 reward 筛选高低分样本
- 回流到离线偏好数据集
什么样的样本适合做 SimPO
| 适合 | 不适合 |
|---|---|
| 有明确好坏之分的(正确 vs 错误) | 都好或都差(难以区分) |
| 格式规范可比较的 | 格式混乱无法解析 |
| 同一 prompt 有多个候选 | 只有一个回答 |
如果 和 差距不明显,模型学到的信号弱,训练效率低。
SimPO 训练过程拆解
SimPO 不需要 reference model,所以比 DPO 更简单。继续用上面的例子,演示 SimPO 的计算流程。
输入示例(与 DPO 相同):
- Prompt:
"请解释量子计算" - Chosen (y_w):
"量子计算利用量子比特进行计算,可以同时处理多个状态,计算能力远超经典计算机。"(32 个 token) - Rejected (y_l):
"量子计算就是用量子做的计算,非常复杂,涉及很多物理原理。"(28 个 token)
流程概览:
- Tokenize & 前向传播(Step 1-3):生成 token,提取 log probs
- Length-normalized Avg Log Prob(Step 4):计算长度归一化的平均 log prob
- SimPO Loss 计算(Step 5):计算最终 loss
Step 1: Tokenize —— 分词与对齐
# Tokenize prompt, chosen, rejected
prompt = "请解释量子计算"
chosen = "量子计算利用量子比特进行计算,可以同时处理多个状态,计算能力远超经典计算机。"
rejected = "量子计算就是用量子做的计算,非常复杂,涉及很多物理原理。"
prompt_tokens = tokenizer(prompt)["input_ids"] # [8]
chosen_tokens = tokenizer(prompt + chosen)["input_ids"] # [40] = 8 + 32
rejected_tokens = tokenizer(prompt + rejected)["input_ids"] # [36] = 8 + 28
prompt_len = len(prompt_tokens) # 8
chosen_len = len(chosen_tokens) # 40
rejected_len = len(rejected_tokens) # 36
# Batch tensor for forward pass (with padding)
input_ids = tokenizer(
[chosen, rejected],
padding=True,
return_tensors="pt"
)["input_ids"] # [2, 40],rejected 会被 padding 到 40
Shape 解释:
prompt_len=8:prompt 有 8 个 tokenchosen_len=40:chosen 完整序列是 prompt(8) + chosen_response(32)rejected_len=36:rejected 完整序列是 prompt(8) + rejected_response(28)input_ids=[2, 40]:batch 维度 2(chosen 和 rejected),序列长度 40(padding 后)
Step 2: Policy 前向传播与 Log Probs 提取
# Policy 前向传播(SimPO 只需要 policy,不需要 reference)
outputs = policy(input_ids)
logits = outputs.logits[:, :-1, :] # [2, 39, 151936],预测下一个 token
# Log softmax
log_probs = F.log_softmax(logits, dim=-1) # [2, 39, 151936]
# 提取实际 token 对应的 log prob
labels = input_ids[:, 1:] # [2, 39],目标 token
token_log_probs = log_probs.gather(-1, labels.unsqueeze(-1)).squeeze(-1) # [2, 39]
# 创建 completion mask(只计算 response 部分,不计算 prompt)
completion_mask = torch.zeros_like(token_log_probs, dtype=torch.bool)
completion_mask[:, prompt_len-1:] = True # [2, 39]
# Masked log probs(只保留 response 部分)
masked_log_probs = token_log_probs * completion_mask # [2, 39]
Shape 解释:
- 第 0 维 batch=2:chosen 和 rejected
- 第 1 维 seq_len=39:logits[:, :-1, :] 比 input_ids 少 1
- 第 2 维 vocab=151936:词表大小
关键位置的数值(chosen 响应部分):
| 位置 | token ID | 中文 | 对应概率 | token_log_prob | 含义 |
|---|---|---|---|---|---|
| 7 | 15234 | 计算 | 0.33 | -1.10 | 模型有 33% 置信度预测”计算”是下一个 token |
| 8 | 9123 | 量子 | 0.52 | -0.65 | 模型有 52% 置信度预测”量子”是下一个 token |
| 15 | 4532 | 比特 | 0.66 | -0.42 | 模型有 66% 置信度预测”比特”是下一个 token |
| 20 | 9876 | 经典 | 0.76 | -0.28 | 模型有 76% 置信度预测”经典”是下一个 token |
| 38 | 151643 | 0.39 | -0.95 | 模型有 39% 置信度预测结束 |
与 DPO 对比:这里的数值与 DPO 不同,因为 SimPO 用的是 policy 自己的概率,不依赖 reference。
Step 3: 计算 Response Token 级别的 Log Prob
# Sum over sequence dimension(只计算 response 部分)
sum_log_probs = masked_log_probs.sum(dim=-1) # [2]
# Count completion tokens(注意 padding 的部分)
# 由于 rejected 被 padding 到 40,需要计算真实的 response 长度
chosen_response_len = chosen_len - prompt_len # 32
rejected_response_len = rejected_len - prompt_len # 28
# Split chosen and rejected
chosen_sum_logp = sum_log_probs[0] # scalar, 比如 -10.5
rejected_sum_logp = sum_log_probs[1] # scalar, 比如 -13.2
关键位置的数值:
| Sum Log Prob | Response 长度 | 含义 | |
|---|---|---|---|
| Chosen (y_w) | -10.5 | 32 | chosen 所有 response token 的 log prob 总和 |
| Rejected (y_l) | -13.2 | 28 | rejected 所有 response token 的 log prob 总和 |
为什么 chosen 的 sum 更高(-10.5 > -13.2):
- Chosen 的 log prob 总和更高,说明模型对 chosen 更”确信”
- 这与”人类偏好 chosen”一致
Step 4: Length-normalized Average Log Prob —— SimPO 的核心
SimPO 的关键创新:强制使用长度归一化的平均 log prob 作为隐式 reward。
DPO vs SimPO 的归一化对比:DPO 既可以用 log prob 的总和(sum),也可以用平均(average),因为 DPO 的 reward 是
log ratio,长度因素在相减时部分抵消了。SimPO 直接对 policy 的 log prob 做平均,显式地消除长度影响,这是 SimPO 能去掉 reference model 的关键设计之一。
beta = 2.0 # SimPO 的 beta 通常比 DPO 大(DPO 是 0.1,SimPO 是 2.0)
# Length-normalized average log prob
chosen_avg_logp = chosen_sum_logp / chosen_response_len # -10.5 / 32 = -0.328
rejected_avg_logp = rejected_sum_logp / rejected_response_len # -13.2 / 28 = -0.471
# SimPO 隐式 reward(长度归一化后的 avg log prob,乘以 beta)
chosen_reward_simpo = beta * chosen_avg_logp # 2.0 * (-0.328) = -0.656
rejected_reward_simpo = beta * rejected_avg_logp # 2.0 * (-0.471) = -0.942
关键位置的数值(β=2.0):
| Sum Log Prob | Response 长度 | Avg Log Prob | SimPO Reward (β × avg) | 含义 | |
|---|---|---|---|---|---|
| Chosen (y_w) | -10.5 | 32 | -0.328 | -0.656 | chosen 的 SimPO reward |
| Rejected (y_l) | -13.2 | 28 | -0.471 | -0.942 | rejected 的 SimPO reward |
Avg Log Prob 的意义:
- Avg log prob = Sum Log Prob / Response 长度
- 这相当于”平均每个 token 的确信度”
- 长归一化防止长回答天然概率低的问题
为什么 SimPO 的 beta 比 DPO 大:
- DPO 的 reward 是 log ratio,通常是 [-1, 1] 范围
- SimPO 的 reward 是 avg log prob,通常是 [-2, 0] 范围(因为 log prob 是负数)
- 为了让 margin 有合理的尺度,SimPO 的 beta 需要更大(通常是 2.0)
Step 5: SimPO Loss 计算
回顾 SimPO loss 公式:
其中 是 target margin,表示期望 chosen 和 rejected 之间的最小差距。
SimPO 的 margin 定义:reward 差值减去 target margin,即 。当实际差距超过 target margin 时,margin > 0,loss 小;反之 loss 大,模型需要继续学习拉大差距。
gamma = 0.5 # target margin
# Margin(SimPO reward 的差值,减去 target margin)
margin = beta * (chosen_avg_logp - rejected_avg_logp) - gamma
# = 2.0 * (-0.328 - (-0.471)) - 0.5
# = 2.0 * 0.143 - 0.5
# = 0.286 - 0.5
# = -0.214
# SimPO loss(负的 log sigmoid)
loss = -F.logsigmoid(margin) # scalar
# Backward
loss.backward()
数值计算:
- Chosen avg log prob = -0.328
- Rejected avg log prob = -0.471
- Margin (before gamma) = 2.0 × (-0.328 + 0.471) = 2.0 × 0.143 = 0.286
- Margin (after gamma) = 0.286 - 0.5 = -0.214
- Loss =
为什么 margin 是负数:
- Margin = 0.286 < gamma = 0.5
- 说明 chosen 和 rejected 的差距还不够大,模型需要进一步优化
- Loss = 0.805 比较大,需要训练
训练效果(训练前后对比):
| 训练前 Chosen | 训练前 Rejected | 训练后 Chosen | 训练后 Rejected | |
|---|---|---|---|---|
| Avg Log Prob | -0.328 | -0.471 | -0.250 | -0.550 |
| SimPO Reward | -0.656 | -0.942 | -0.500 | -1.100 |
| Margin (before γ) | 0.143 | — | 0.300 | — |
| Margin (after γ) | -0.214 | — | +0.100 | — |
| Loss | 0.805 | — | 0.645 | — |
- Chosen avg log prob 从 -0.328 提升到 -0.250(概率增大)
- Rejected avg log prob 从 -0.471 降低到 -0.550(概率减小)
- Margin 转正,loss 从 0.805 降到 0.645
- Chosen avg log prob 从 -0.328 提升到 -0.250(更接近 0,说明模型更”确信”)
- Rejected avg log prob 从 -0.471 降低到 -0.550(更远离 0,说明模型更”不确信”)
- 差距从 0.143 变成 0.300,margin 从 -0.214 变成 +0.100
SimPO 与 DPO 的关键区别
| 特性 | DPO | SimPO |
|---|---|---|
| Reward 公式 | $\frac{\beta}{ | |
| 需要 reference | ✅ 需要 | ❌ 不需要 |
| Beta 典型值 | 0.1 | 2.0 |
| Reward 范围 | [-0.1, 0.1] | [-2, 0] |
| Target margin | 无 | 有() |
为什么 SimPO 能不用 reference:
- DPO 用 log ratio(policy vs reference)作为 reward
- SimPO 直接用 policy 自己的 avg log prob 作为 reward
- 长归一化确保不同长度的回答可比
为什么 SimPO 的 beta 更大:
- DPO 的 log ratio 范围小(因为除以 reference)
- SimPO 的 avg log prob 范围大(直接是 log prob)
- 为了让 margin 有合理的尺度,beta 需要更大
SimPO 代码实现
数据准备
# 假设有一对偏好数据
prompt = "请解释量子计算"
chosen = "量子计算利用量子比特进行计算,可以同时处理多个状态,计算能力远超经典计算机。"
rejected = "量子计算就是用量子做的计算,非常复杂,涉及很多物理原理。"
# Tokenize(拼接 prompt + response)
texts = [prompt + chosen, prompt + rejected] # [2]
inputs = tokenizer(texts, return_tensors="pt", padding=True)
# input_ids: [2, 40](chosen 是 40,rejected 被 padding 到 40)
# attention_mask: [2, 40]
# 计算 prompt 和 response 的长度
prompt_tokens = tokenizer(prompt)["input_ids"]
prompt_len = len(prompt_tokens) # 8
chosen_response_len = len(chosen_tokens) - prompt_len # 32
rejected_response_len = len(rejected_tokens) - prompt_len # 28examples/simpo_data.py
形状说明:
texts:[2]字符串列表,包含 chosen 和 rejected 完整文本input_ids:[2, 40],batch=2,序列长度=40(padding 后)attention_mask:[2, 40],标记哪些位置是有效 token
前向传播与 log prob 提取
# Policy 前向传播(SimPO 只需要 policy,不需要 reference)
outputs = policy(**inputs)
logits = outputs.logits[:, :-1, :] # [2, 39, V],预测下一个 token
# Log softmax to get log probs
log_probs = F.log_softmax(logits, dim=-1) # [2, 39, V]
# 提取实际 token 对应的 log prob
labels = inputs.input_ids[:, 1:] # [2, 39],目标 token
token_log_probs = log_probs.gather(
dim=-1,
index=labels.unsqueeze(-1)
).squeeze(-1) # [2, 39]
# 创建 completion mask(只计算 response 部分)
completion_mask = torch.zeros_like(token_log_probs, dtype=torch.bool)
completion_mask[:, prompt_len-1:] = True # [2, 39],从位置 7 开始
# Masked log probs(prompt 部分被 mask 为 0)
masked_log_probs = token_log_probs * completion_mask # [2, 39]examples/simpo_forward.py
形状说明:
logits:[2, 39, 151936],预测下一个 token 的分布log_probs:[2, 39, 151936],log softmax 后的概率分布token_log_probs:[2, 39],每个位置实际 token 的 log probcompletion_mask:[2, 39],布尔 mask,True 表示是 response 部分masked_log_probs:[2, 39],masked 后的 log prob,prompt 部分为 0
Length-normalized average log prob
# Sum over sequence dimension(只计算 response 部分)
sum_log_probs = masked_log_probs.sum(dim=-1) # [2]
# 比如:chosen_sum = -10.5, rejected_sum = -13.2
# Count completion tokens(真实长度,不含 padding)
response_lengths = torch.tensor([
chosen_response_len, # 32
rejected_response_len, # 28
]) # [2]
# Average log prob(length normalized)
avg_log_probs = sum_log_probs / response_lengths # [2]
# 比如:chosen_avg = -0.328, rejected_avg = -0.471
# Split chosen and rejected
chosen_avg = avg_log_probs[0] # scalar
rejected_avg = avg_log_probs[1] # scalarexamples/simpo_avg_logp.py
形状说明:
sum_log_probs:[2],response 部分所有 token 的 log prob 求和response_lengths:[2],response 部分真实长度(不含 padding)avg_log_probs:[2],长度归一化后的平均 log prob
SimPO Loss
beta = 2.0 # SimPO 的 beta 通常比 DPO 大
gamma = 0.5 # target margin
# Margin with target(隐式 reward 的差值,减去 target margin)
margin = beta * (chosen_avg - rejected_avg) - gamma # scalar
# = 2.0 * (-0.328 - (-0.471)) - 0.5
# = 2.0 * 0.143 - 0.5
# = 0.286 - 0.5
# = -0.214
# SimPO loss
loss = -F.logsigmoid(margin) # scalar
# = -F.logsigmoid(-0.214)
# ≈ -log(0.447)
# ≈ 0.805
# Backward
loss.backward()examples/simpo_loss.py
形状说明:
margin: scalar,chosen 和 rejected 的 reward 差值(减去 target margin)loss: scalar,最终的训练损失
常见 bug:
- 忘记 mask prompt:如果不 mask,
avg_log_probs会包含 prompt 部分,导致计算错误 - padding 长度计算错误:用
completion_mask.sum()会包含 padding 部分,应该用真实长度 - beta 设置错误:SimPO 的 beta 通常比 DPO 大(2.0 vs 0.1),设置太小会导致训练不稳定
- gamma 设置错误:target margin 太大会导致训练困难,太小会让 margin 偏移中心
DPO 代码实现对比
DPO 的代码实现与 SimPO 类似,但需要额外计算 reference 的 log prob。
# Policy 前向传播
policy_outputs = policy(**inputs)
policy_logits = policy_outputs.logits[:, :-1, :]
policy_log_probs = F.log_softmax(policy_logits, dim=-1)
policy_logp = policy_log_probs.gather(-1, labels.unsqueeze(-1)).squeeze(-1)
policy_logp_masked = policy_logp * completion_mask
policy_sum_logp = policy_logp_masked.sum(dim=-1)
policy_avg_logp = policy_sum_logp / response_lengths
# Reference 前向传播(frozen)
with torch.no_grad():
ref_outputs = reference(**inputs)
ref_logits = ref_outputs.logits[:, :-1, :]
ref_log_probs = F.log_softmax(ref_logits, dim=-1)
ref_logp = ref_log_probs.gather(-1, labels.unsqueeze(-1)).squeeze(-1)
ref_logp_masked = ref_logp * completion_mask
ref_sum_logp = ref_logp_masked.sum(dim=-1)
ref_avg_logp = ref_sum_logp / response_lengths
# DPO Loss(需要 reference)
beta = 0.1 # DPO 的 beta 比较小
margin = beta * (policy_avg_logp[0] - ref_avg_logp[0]) - \
beta * (policy_avg_logp[1] - ref_avg_logp[1])
# = 0.1 * (chosen_log_ratio - rejected_log_ratio)
loss = -F.logsigmoid(margin)
loss.backward()examples/dpo_loss.py
DPO vs SimPO 代码对比:
| 特性 | DPO | SimPO |
|---|---|---|
| 前向传播次数 | 2×(policy + reference) | 1×(只有 policy) |
| Beta | 0.1 | 2.0 |
| Reward 计算 | ||
| 显存开销 | 高(需要加载 reference) | 低(只需 policy) |
小结:离线分支的演化
| 方法 | 需要模型 | 砍掉的组件 | 用什么替代 |
|---|---|---|---|
| PPO | Policy, Reference, RM, Critic | — | 在线 RL |
| DPO | Policy, Reference | RM, Critic, PPO | Policy log ratio 作为隐式 reward |
| SimPO | Policy | Reference, RM, Critic, PPO | 长度归一化的 avg log prob |
演化逻辑:
- PPO → DPO:发现不需要在线 RL,直接用离线偏好数据优化 policy
- DPO → SimPO:发现不需要 reference,直接用 policy 自己的概率
代价:
- PPO 复杂但通用,适合各种 reward 类型
- DPO/SimPO 简单但依赖偏好数据质量
DPO 和 SimPO 的对比总结
RLHF 目标
PPO 完整 Objective
其中:
- :entropy bonus
DPO Loss
直觉:增大 chosen 的 log ratio,减小 rejected 的 log ratio
SimPO Loss
直觉:不需要 reference,直接用 policy 自己的长度归一化 avg log prob
DPO vs SimPO 快速对比
| 特性 | DPO | SimPO |
|---|---|---|
| 隐式 reward | ||
| 需要 reference | ✅ | ❌ |
| Beta | 0.1 | 2.0 |
| Target margin | 无 | 有() |
下一篇回到在线分支:GRPO 用组采样替代 critic,DAPO 用四项修正把 GRPO 跑稳。