# PPO update loss block 源码解析 本文只解释 MiniMind PPO 训练中的这一段: ```text trainer/train_ppo.py:171-214 ``` 它完成一次 PPO mini-batch update 的核心计算: ```text 当前 actor logprob -> ratio -> clipfrac -> reference KL penalty -> clipped policy loss -> clipped value loss -> 合成最终 loss -> backward ``` ## 0. 变量约定 这一节先按源码变量读。数学符号只用来把 `[batch, response_len]` 里的某个元素写清楚。 ### 0.1 下标怎么读 ```text b = batch 里的第几条样本 t = response 里的第几个 token ``` 所以: ```text x[b, t] ``` 就是: ```text 第 b 条样本,第 t 个 response token 位置上的值。 ``` ### 0.2 action 和 state 第 `b` 条样本第 `t` 个 response token 记作: $$ a_{b,t} $$ 它对应源码里的: ```python completion_ids[b, t] ``` 生成这个 token 时,模型看到的上下文记作: $$ s_{b,t} $$ 它表示: ```text prompt + response 前 t-1 个 token ``` 也就是说,PPO 里的: ```text 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}) $$ 对应源码: ```python 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}) $$ 对应源码: ```python 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}) $$ 对应源码: ```python ref_resp_logp[b, t] ``` ### 0.4 其它源码变量 有效 response token mask: ```python resp_policy_mask[b, t] ``` 公式里写成: $$ m_{b,t} $$ GAE 算出的 advantage: ```python advantages[b, t] ``` 公式里写成: $$ A_{b,t} $$ 当前 critic value: ```python mb_resp_values[b, t] ``` 公式里写成: $$ V_{\theta,b,t} $$ rollout 阶段保存的 old critic value: ```python old_resp_values[b, t] ``` 公式里写成: $$ V_{\mathrm{old},b,t} $$ critic 的训练目标 return: ```python 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 源码: ```python 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}) $$ 含义: ```text rollout 阶段已经保存了 old actor 的 old_resp_logp。 现在 PPO update 阶段,要用当前 actor 对同一批 response token 重新算 logprob。 ``` 注意: ```text 这里没有重新生成 response。 它是在同一批 old rollout 上重新 forward,计算当前 actor 对这些 token 的概率。 ``` ## 2. log_ratio 和 ratio 源码: ```python 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}) } $$ 含义: ```text ratio 表示当前 actor 相对 rollout 时 old actor,对同一个 token 的概率改了多少。 ``` 判断方式: ```text ratio = 1: 当前 actor 和 old actor 对这个 token 概率一样。 ratio > 1: 当前 actor 更倾向这个 token。 ratio < 1: 当前 actor 更不倾向这个 token。 ``` ## 3. approx_kl:监控 actor 是否离 old actor 太远 源码: ```python 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) $$ 用途: ```text approx_kl 不直接作为 policy_loss 的主项。 它主要用于 early stop。 ``` 源码: ```python if approx_kl_val > args.early_stop_kl: stop_ppo = True ``` 含义: ```text 如果当前 actor 已经离 old actor 太远,就停止继续用这批 rollout 更新。 ``` ## 4. clipfrac:统计有多少 token 超出 PPO clip 范围 源码: ```python 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} $$ 含义: ```text clipfrac 不是 loss。 它是一个监控指标,表示有多少有效 response token 的 ratio 已经超过 PPO 裁剪范围。 ``` 例子: ```text clip_epsilon = 0.2 ratio 合理范围 = [0.8, 1.2] ratio = 1.05 -> 不计入 clipfrac ratio = 1.35 -> 计入 clipfrac ratio = 0.70 -> 计入 clipfrac ``` 如果 `clipfrac` 很大,说明: ```text 这批 update 里很多 token 的策略变化已经被 PPO clip 限制住了。 ``` ## 5. reference KL penalty:约束 actor 不要偏离 reference 源码: ```python 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) $$ 含义: ```text reference model 是 frozen SFT 模型。 这个项惩罚当前 actor 偏离 reference model。 ``` 注意它和 `approx_kl` 不同: ```text 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 源码: ```python 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} $$ 直观理解: ```text advantage > 0: 这个 token 比 critic 预期更好,希望提高它的概率。 但 ratio 不能无限变大,最多按 1 + epsilon 裁剪。 advantage < 0: 这个 token 比 critic 预期更差,希望降低它的概率。 但 ratio 不能无限变小,最多按 1 - epsilon 裁剪。 ``` 所以 `policy_loss` 同时做两件事: ```text 1. 按 advantage 更新 actor。 2. 用 reference KL 约束 actor 不要偏离 SFT reference 太远。 ``` ## 7. value_loss:critic 的 clipped value loss 源码: ```python 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] $$ 含义: ```text critic 要学习预测 returns。 但源码也限制 critic value 不要一次离 old value 太远。 ``` 这里的 `0.5` 是 MSE 常见写法,方便梯度形式更简洁。 ## 8. kl 和 kl_ref 只是日志变量 源码: ```python kl = approx_kl_val kl_ref = kl_ref_penalty.detach() ``` 含义: ```text kl: 记录当前 actor vs old actor 的 approx_kl。 kl_ref: 记录当前 actor vs reference model 的 KL penalty。 ``` `.detach()` 表示: ```text 这里只用于记录,不让日志变量继续保留计算图。 ``` ## 9. stop_ppo 时为什么 loss 乘 0 源码: ```python 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`? 源码注释已经说明: ```text 早停时必须保证 forward-backward 闭环,故只截断 loss 不中断 DDP 通信 ``` 含义: ```text 分布式训练里,各卡最好执行相同的 forward/backward 结构。 如果某张卡提前 break,而其它卡还在 backward,可能导致 DDP 等通信卡死。 ``` 乘 `0.0` 的效果: ```text 仍然执行 backward; 但梯度为 0; 不会真正更新参数; 同时保持 DDP 通信路径完整。 ``` ## 10. 这段代码的完整执行顺序 ```text 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 个点 1. `approx_kl` 不是 `kl_ref_penalty`。 ```text approx_kl: 当前 actor vs old actor kl_ref_penalty: 当前 actor vs reference model ``` 2. `clipfrac` 不是 loss。 ```text 它只是监控有多少 token 的 ratio 超过 [1-eps, 1+eps]。 ``` 3. `policy_loss` 更新 actor。 ```text 它使用 new/old logprob、advantage、reference KL。 ``` 4. `value_loss` 更新 critic。 ```text 它使用 current value、old value、returns。 ``` 5. `stop_ppo` 时仍然 backward。 ```text loss 乘 0 是为了保留 DDP forward-backward 闭环。 ```