12 KiB
PPO update loss block 源码解析
本文只解释 MiniMind PPO 训练中的这一段:
trainer/train_ppo.py:171-214
它完成一次 PPO mini-batch update 的核心计算:
当前 actor logprob
-> ratio
-> clipfrac
-> reference KL penalty
-> clipped policy loss
-> clipped value loss
-> 合成最终 loss
-> backward
0. 变量约定
这一节先按源码变量读。数学符号只用来把 [batch, response_len] 里的某个元素写清楚。
0.1 下标怎么读
b = batch 里的第几条样本
t = response 里的第几个 token
所以:
x[b, t]
就是:
第 b 条样本,第 t 个 response token 位置上的值。
0.2 action 和 state
第 b 条样本第 t 个 response token 记作:
a_{b,t}
它对应源码里的:
completion_ids[b, t]
生成这个 token 时,模型看到的上下文记作:
s_{b,t}
它表示:
prompt + response 前 t-1 个 token
也就是说,PPO 里的:
state = s_{b,t}
action = a_{b,t}
0.3 logprob 符号
本文用小写希腊字母 ell:
\ell
表示 logprob。
当前 actor 的 token logprob:
\ell^{\theta}_{b,t}
=
\log \pi_{\theta}(a_{b,t} \mid s_{b,t})
对应源码:
mb_resp_logp[b, t]
rollout 时 old actor 的 token logprob:
\ell^{\mathrm{old}}_{b,t}
=
\log \pi_{\mathrm{old}}(a_{b,t} \mid s_{b,t})
对应源码:
old_resp_logp[b, t]
frozen reference model 的 token logprob:
\ell^{\mathrm{ref}}_{b,t}
=
\log \pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
对应源码:
ref_resp_logp[b, t]
0.4 其它源码变量
有效 response token mask:
resp_policy_mask[b, t]
公式里写成:
m_{b,t}
GAE 算出的 advantage:
advantages[b, t]
公式里写成:
A_{b,t}
当前 critic value:
mb_resp_values[b, t]
公式里写成:
V_{\theta,b,t}
rollout 阶段保存的 old critic value:
old_resp_values[b, t]
公式里写成:
V_{\mathrm{old},b,t}
critic 的训练目标 return:
returns[b, t]
公式里写成:
G_{b,t}
0.5 masked mean
后面很多 loss 都只在有效 response token 上求平均。统一写成:
\operatorname{mean}_m(x)
=
\frac{
\sum_{b,t} m_{b,t} x_{b,t}
}{
\sum_{b,t} m_{b,t}
}
其中 m_{b,t} 对应源码里的 resp_policy_mask[inds] 或 resp_value_mask[inds]。
1. 当前 actor 重新计算 response token logprob
源码:
res = actor_unwrapped(input_ids=gen_out[inds], attention_mask=full_mask[inds])
mb_resp_logp = F.log_softmax(res.logits[:, :-1], dim=-1) \
.gather(2, labels[inds].unsqueeze(-1)) \
.squeeze(-1) \
.gather(1, logp_pos[inds])
公式:
\ell^{\theta}_{b,t}
=
\log \pi_{\theta}(a_{b,t} \mid s_{b,t})
含义:
rollout 阶段已经保存了 old actor 的 old_resp_logp。
现在 PPO update 阶段,要用当前 actor 对同一批 response token 重新算 logprob。
注意:
这里没有重新生成 response。
它是在同一批 old rollout 上重新 forward,计算当前 actor 对这些 token 的概率。
2. log_ratio 和 ratio
源码:
log_ratio = mb_resp_logp - old_resp_logp[inds]
ratio = torch.exp(log_ratio)
公式:
\log r_{b,t}
=
\ell^{\theta}_{b,t}
-
\ell^{\mathrm{old}}_{b,t}
r_{b,t}
=
\exp(\log r_{b,t})
=
\frac{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}{
\pi_{\mathrm{old}}(a_{b,t} \mid s_{b,t})
}
含义:
ratio 表示当前 actor 相对 rollout 时 old actor,对同一个 token 的概率改了多少。
判断方式:
ratio = 1:
当前 actor 和 old actor 对这个 token 概率一样。
ratio > 1:
当前 actor 更倾向这个 token。
ratio < 1:
当前 actor 更不倾向这个 token。
3. approx_kl:监控 actor 是否离 old actor 太远
源码:
approx_kl = (
0.5 * (log_ratio ** 2) * resp_policy_mask[inds]
).sum() / resp_policy_mask[inds].sum().clamp(min=1)
公式:
\operatorname{approx\_kl}
=
\operatorname{mean}_m
\left(
\frac{1}{2}
(\log r_{b,t})^2
\right)
展开:
\operatorname{approx\_kl}
=
\operatorname{mean}_m
\left(
\frac{1}{2}
\left[
\log \pi_{\theta}(a_{b,t} \mid s_{b,t})
-
\log \pi_{\mathrm{old}}(a_{b,t} \mid s_{b,t})
\right]^2
\right)
用途:
approx_kl 不直接作为 policy_loss 的主项。
它主要用于 early stop。
源码:
if approx_kl_val > args.early_stop_kl:
stop_ppo = True
含义:
如果当前 actor 已经离 old actor 太远,就停止继续用这批 rollout 更新。
4. clipfrac:统计有多少 token 超出 PPO clip 范围
源码:
clipfrac = (
(((ratio - 1.0).abs() > args.clip_epsilon).float()
* resp_policy_mask[inds]).sum()
/ resp_policy_mask[inds].sum().clamp(min=1)
)
公式:
\operatorname{clipfrac}
=
\operatorname{mean}_m
\left(
\mathbf{1}
\left[
\left|r_{b,t} - 1\right| > \epsilon
\right]
\right)
其中:
\epsilon = \texttt{args.clip\_epsilon}
含义:
clipfrac 不是 loss。
它是一个监控指标,表示有多少有效 response token 的 ratio 已经超过 PPO 裁剪范围。
例子:
clip_epsilon = 0.2
ratio 合理范围 = [0.8, 1.2]
ratio = 1.05 -> 不计入 clipfrac
ratio = 1.35 -> 计入 clipfrac
ratio = 0.70 -> 计入 clipfrac
如果 clipfrac 很大,说明:
这批 update 里很多 token 的策略变化已经被 PPO clip 限制住了。
5. reference KL penalty:约束 actor 不要偏离 reference
源码:
kl_ref_penalty = (
(torch.exp(ref_resp_logp[inds] - mb_resp_logp)
- (ref_resp_logp[inds] - mb_resp_logp)
- 1.0)
* resp_policy_mask[inds]
).sum() / resp_policy_mask[inds].sum().clamp(min=1)
先定义:
\Delta_{b,t}
=
\ell^{\mathrm{ref}}_{b,t}
-
\ell^{\theta}_{b,t}
=
\log
\frac{
\pi_{\mathrm{ref}}(a_{b,t} \mid s_{b,t})
}{
\pi_{\theta}(a_{b,t} \mid s_{b,t})
}
单个 token 的 penalty:
k_{b,t}
=
\exp(\Delta_{b,t})
-
\Delta_{b,t}
-
1
masked mean:
\operatorname{kl\_ref\_penalty}
=
\operatorname{mean}_m
\left(
\exp(\Delta_{b,t}) - \Delta_{b,t} - 1
\right)
含义:
reference model 是 frozen SFT 模型。
这个项惩罚当前 actor 偏离 reference model。
注意它和 approx_kl 不同:
approx_kl:
当前 actor vs old actor
用于 early stop
kl_ref_penalty:
当前 actor vs reference model
加进 policy_loss
6. policy_loss:PPO clipped surrogate + reference KL
源码:
policy_loss = (
(
torch.max(
-advantages[inds] * ratio,
-advantages[inds] * torch.clamp(
ratio,
1.0 - args.clip_epsilon,
1.0 + args.clip_epsilon,
)
)
* resp_policy_mask[inds]
).sum() / resp_policy_mask[inds].sum().clamp(min=1)
+ args.kl_coef * kl_ref_penalty
)
先定义裁剪后的 ratio:
\bar{r}_{b,t}
=
\operatorname{clip}
\left(
r_{b,t},
1-\epsilon,
1+\epsilon
\right)
PPO 原始最大化目标通常写成:
\mathcal{J}_{\mathrm{clip}}
=
\mathbb{E}
\left[
\min
\left(
r_{b,t} A_{b,t},
\bar{r}_{b,t} A_{b,t}
\right)
\right]
源码是在最小化 loss,所以写成:
\mathcal{L}_{\mathrm{policy\_clip}}
=
\operatorname{mean}_m
\left[
\max
\left(
-A_{b,t} r_{b,t},
-A_{b,t} \bar{r}_{b,t}
\right)
\right]
MiniMind 最终 policy loss:
\mathcal{L}_{\mathrm{policy}}
=
\mathcal{L}_{\mathrm{policy\_clip}}
+
\lambda_{\mathrm{KL}}
\operatorname{kl\_ref\_penalty}
其中:
\lambda_{\mathrm{KL}} = \texttt{args.kl\_coef}
直观理解:
advantage > 0:
这个 token 比 critic 预期更好,希望提高它的概率。
但 ratio 不能无限变大,最多按 1 + epsilon 裁剪。
advantage < 0:
这个 token 比 critic 预期更差,希望降低它的概率。
但 ratio 不能无限变小,最多按 1 - epsilon 裁剪。
所以 policy_loss 同时做两件事:
1. 按 advantage 更新 actor。
2. 用 reference KL 约束 actor 不要偏离 SFT reference 太远。
7. value_loss:critic 的 clipped value loss
源码:
value_loss = 0.5 * (
torch.max(
(mb_resp_values - returns[inds]) ** 2,
(
torch.clamp(
mb_resp_values,
old_resp_values[inds] - args.cliprange_value,
old_resp_values[inds] + args.cliprange_value,
)
- returns[inds]
) ** 2
)
* resp_value_mask[inds]
).sum() / resp_value_mask[inds].sum().clamp(min=1)
当前 critic value:
V_{\theta,b,t}
=
\texttt{mb\_resp\_values}_{b,t}
旧 critic value:
V_{\mathrm{old},b,t}
=
\texttt{old\_resp\_values}_{b,t}
return target:
G_{b,t}
=
\texttt{returns}_{b,t}
裁剪后的 value:
\bar{V}_{b,t}
=
\operatorname{clip}
\left(
V_{\theta,b,t},
V_{\mathrm{old},b,t} - c_v,
V_{\mathrm{old},b,t} + c_v
\right)
其中:
c_v = \texttt{args.cliprange\_value}
两个 value error:
e^{\mathrm{raw}}_{b,t}
=
\left(
V_{\theta,b,t} - G_{b,t}
\right)^2
e^{\mathrm{clip}}_{b,t}
=
\left(
\bar{V}_{b,t} - G_{b,t}
\right)^2
最终 value loss:
\mathcal{L}_{\mathrm{value}}
=
\frac{1}{2}
\operatorname{mean}_m
\left[
\max
\left(
e^{\mathrm{raw}}_{b,t},
e^{\mathrm{clip}}_{b,t}
\right)
\right]
含义:
critic 要学习预测 returns。
但源码也限制 critic value 不要一次离 old value 太远。
这里的 0.5 是 MSE 常见写法,方便梯度形式更简洁。
8. kl 和 kl_ref 只是日志变量
源码:
kl = approx_kl_val
kl_ref = kl_ref_penalty.detach()
含义:
kl:
记录当前 actor vs old actor 的 approx_kl。
kl_ref:
记录当前 actor vs reference model 的 KL penalty。
.detach() 表示:
这里只用于记录,不让日志变量继续保留计算图。
9. stop_ppo 时为什么 loss 乘 0
源码:
if stop_ppo:
loss = (policy_loss + args.vf_coef * value_loss + aux_loss) * 0.0
else:
loss = (
policy_loss
+ args.vf_coef * value_loss
+ aux_loss
) / args.accumulation_steps
loss.backward()
正常训练时:
\mathcal{L}
=
\frac{
\mathcal{L}_{\mathrm{policy}}
+
\lambda_v \mathcal{L}_{\mathrm{value}}
+
\mathcal{L}_{\mathrm{aux}}
}{
\texttt{accumulation\_steps}
}
其中:
\lambda_v = \texttt{args.vf\_coef}
early stop 时:
\mathcal{L}_{\mathrm{stop}}
=
0
\cdot
\left(
\mathcal{L}_{\mathrm{policy}}
+
\lambda_v \mathcal{L}_{\mathrm{value}}
+
\mathcal{L}_{\mathrm{aux}}
\right)
为什么不直接 continue 或 break?
源码注释已经说明:
早停时必须保证 forward-backward 闭环,故只截断 loss 不中断 DDP 通信
含义:
分布式训练里,各卡最好执行相同的 forward/backward 结构。
如果某张卡提前 break,而其它卡还在 backward,可能导致 DDP 等通信卡死。
乘 0.0 的效果:
仍然执行 backward;
但梯度为 0;
不会真正更新参数;
同时保持 DDP 通信路径完整。
10. 这段代码的完整执行顺序
1. critic forward:
得到当前 critic value: mb_resp_values
2. actor forward:
得到当前 actor logits
3. gather:
从 logits 中取 response token 的 mb_resp_logp
4. log_ratio:
当前 actor logprob - old actor logprob
5. approx_kl:
估计当前 actor 和 old actor 的距离,用于 early stop
6. ratio:
exp(log_ratio)
7. clipfrac:
统计多少 token 超出 PPO clip 范围
8. kl_ref_penalty:
惩罚 actor 偏离 reference model
9. policy_loss:
PPO clipped policy loss + reference KL
10. value_loss:
clipped critic value loss
11. final loss:
policy_loss + vf_coef * value_loss + aux_loss
12. backward:
反向传播 actor 和 critic
11. 最容易混的 5 个点
approx_kl不是kl_ref_penalty。
approx_kl: 当前 actor vs old actor
kl_ref_penalty: 当前 actor vs reference model
clipfrac不是 loss。
它只是监控有多少 token 的 ratio 超过 [1-eps, 1+eps]。
policy_loss更新 actor。
它使用 new/old logprob、advantage、reference KL。
value_loss更新 critic。
它使用 current value、old value、returns。
stop_ppo时仍然 backward。
loss 乘 0 是为了保留 DDP forward-backward 闭环。