# 第 25 课:Reward、Logprob 与 KL Penalty 这一课不是再讲一遍 PPO 或 GRPO,而是把第 21-24 课反复出现的三个核心量统一整理清楚:`reward`、`logprob`、`KL penalty`。看懂这一节,后面读 `train_agent.py` 时就不会把 reward model、old logprob、reference model、approx KL 混在一起。 ## 目录 - [0. 本节主线](#l25-mainline) - [1. 本节要懂的 7 个原理](#l25-principles) - [2. 完整原理:三个量分别管什么](#l25-complete-principle) - [3. 源码阅读顺序图](#l25-reading-order) - [4. MiniMind 源码走读](#l25-source-walkthrough) - [5. 本节必须会写 / 暂时不要求](#l25-must-write) - [6. 手写模块复盘](#l25-handwrite) - [7. 实验验证](#l25-experiment) - [8. 阶段组装](#l25-stage-assembly) - [9. 本节检查](#l25-check) - [10. 下一课](#l25-next) ## 0. 本节主线 MiniMind 的 RL 训练里,这条链路反复出现: ```text rollout policy 生成 response -> 保存 old_logp,也就是采样当时每个 response token 的 logprob -> reward model / 规则函数给整条 response 一个 sequence reward -> PPO 把 sequence reward 放到最后一个有效 token,再用 critic + GAE 得到 token advantage -> GRPO 把同 prompt 多条 response 的 reward 做 group mean/std,得到 sequence advantage -> 训练时用 current policy 重新计算 policy_logp -> 用 frozen reference model 计算 ref_logp -> ratio = exp(policy_logp - old_logp) -> reference KL penalty = exp(ref_logp - policy_logp) - (ref_logp - policy_logp) - 1 -> mask 掉 padding / EOS 后无效 token -> policy loss 反向更新 policy ``` 一句话: ```text reward 告诉模型哪条回答好; logprob 连接 policy 参数并形成 ratio; KL penalty 约束 policy 不要离 reference 太远。 ``` 这三个量的角色不能混: ```text reward: 来自外部评价或规则,通常不反传梯度。 policy_logp: 来自当前正在训练的 policy,是策略梯度的主要路径。 old_logp: 来自 rollout 时的旧 policy,用来算 ratio。 ref_logp: 来自冻结 reference model,用来做 KL 约束或 DPO 偏好基准。 ``` ## 1. 本节要懂的 7 个原理 | 原理 | 要理解什么 | 源码位置 | |---|---|---| | reward 是 sequence 级信号 | MiniMind 先对整条 response 打分,不是每个 token 单独打分 | `trainer/train_ppo.py:52-76`, `trainer/train_grpo.py:37-68` | | reward model wrapper 只负责打分 | `LMForRewardModel.get_score` 把对话历史和回答交给 reward model,并裁剪分数范围 | `trainer/trainer_utils.py:160-177` | | old logprob 来自 rollout | 采样 response 时同时保存每个 response token 的 logprob | `trainer/rollout_engine.py:23-36`, `trainer/rollout_engine.py:71-92` | | current/ref logprob 在 update 时重算 | 训练时对同一批 `output_ids` 重算 policy/ref logprob | `trainer/train_ppo.py:117-139`, `trainer/train_grpo.py:97-105` | | reward 到 advantage 的算法不同 | PPO 用 critic + GAE;GRPO 用 group reward 标准化 | `trainer/train_ppo.py:140-155`, `trainer/train_grpo.py:121-124` | | KL 有不同用途 | `approx_kl` 监控 policy-old 距离;reference KL penalty 约束 policy-ref 距离 | `trainer/train_ppo.py:186-205`, `trainer/train_grpo.py:132-143` | | DPO 的 reference 不是 token KL | DPO 用 chosen/rejected 的 reference logratio 作为偏好基准 | `trainer/train_dpo.py:34-49` | 学完本节,你应该能独立说清楚这些 tensor: ```text old_logps: rollout policy 采样时的 response token logprob policy_logps: 当前 policy 对同一 response token 的 logprob ref_logps: frozen reference 对同一 response token 的 logprob rewards: 每条 response 或 trajectory 的外部分数 advantages: reward 经过 PPO/GRPO 规则转换后的训练权重 ratio: exp(policy_logps - old_logps) ref_kl_penalty: exp(ref_logps - policy_logps) - (ref_logps - policy_logps) - 1 mask: 哪些 response token 参与 loss ``` ## 2. 完整原理:三个量分别管什么 ### 2.1 Reward:回答好不好 MiniMind 的 PPO/GRPO 都先生成完整 response,再计算一个标量 reward: $$ R_i \in \mathbb{R} $$ 这个 reward 可以来自: ```text 规则项: 长度是否合理、thinking 格式是否合理、重复惩罚等。 reward model: 用外部模型给 prompt/response 打分。 ``` 它不是直接对 policy 参数求导的东西。更准确地说: ```text reward 是训练信号来源; policy_logp 才是把这个信号传回模型参数的路径。 ``` PPO 会把 sequence reward 放到最后一个有效 response token 上: $$ r_{i,t} = \begin{cases} R_i, & t = T_i \\ 0, & \text{otherwise} \end{cases} $$ 然后用 critic 的 value estimate 做 GAE,得到 token-level advantage: $$ A_{i,t} $$ GRPO 不训练 critic,而是同一个 prompt 采样多条 response: $$ R_{i,1}, R_{i,2}, \dots, R_{i,G} $$ 再做组内标准化: $$ A_{i,j} = \frac{ R_{i,j} - \mu_i }{ \sigma_i + \epsilon } $$ 所以: ```text PPO: sequence reward -> last token reward -> GAE -> token advantage GRPO: group sequence rewards -> group normalization -> sequence advantage ``` ### 2.2 Logprob:policy 更新的入口 对一个已经生成出来的 token: $$ a_t $$ current policy 的 logprob 是: $$ \ell_t^\theta = \log \pi_\theta(a_t \mid s_t) $$ rollout old policy 的 logprob 是: $$ \ell_t^{old} = \log \pi_{old}(a_t \mid s_t) $$ reference model 的 logprob 是: $$ \ell_t^{ref} = \log \pi_{ref}(a_t \mid s_t) $$ MiniMind 里这些 logprob 都是通过同一个基本操作拿到的: ```text logits -> log_softmax -> gather 目标 token id ``` 即: $$ \log p(a_t) = \operatorname{log\_softmax}(\operatorname{logits}_t)_{a_t} $$ 注意 logprob 的对齐关系: ```text logits[:, k] 预测 input_ids[:, k + 1] ``` 所以 response 第 `j` 个 token 的 logprob,要从: ```text prompt_len - 1 + j ``` 这个 logits 位置取。 ### 2.3 Ratio:current policy 相对 old policy 变了多少 PPO/GRPO 的 ratio 是: $$ r_t = \frac{ \pi_\theta(a_t \mid s_t) }{ \pi_{old}(a_t \mid s_t) } = \exp \left( \ell_t^\theta - \ell_t^{old} \right) $$ 它回答的问题是: ```text 当前 policy 对当时采样出来的这个 token,比 old policy 更想生成还是更不想生成? ``` 如果: ```text ratio > 1 ``` 说明 current policy 比 old policy 更偏向这个 token。 如果: ```text ratio < 1 ``` 说明 current policy 比 old policy 更不偏向这个 token。 ratio 进入 PPO/GRPO/CISPO 的 policy loss;它不是 reward,也不是 KL。 ### 2.4 Reference KL penalty:别离 SFT reference 太远 MiniMind 的 PPO/GRPO 使用同一个 token-level reference KL 近似: $$ \Delta_t = \ell_t^{ref} - \ell_t^\theta $$ $$ \operatorname{KLPenalty}_t = \exp(\Delta_t) - \Delta_t - 1 $$ 源码里对应: ```python kl_div = ref_logp - policy_logp per_token_kl = torch.exp(kl_div) - kl_div - 1 ``` 它的作用是: ```text policy 可以因为 reward 往某些方向移动; 但不要离 frozen reference 太远。 ``` ### 2.5 Approx KL:PPO 早停用,不是 reference KL PPO 里还有一个: $$ \operatorname{approx\_kl} = \frac{1}{2} \left( \ell_t^\theta - \ell_t^{old} \right)^2 $$ MiniMind 用它判断当前 policy 相对 rollout old policy 是否更新过猛: ```text 如果 approx_kl 太大,就停止本轮 PPO 多 epoch 更新。 ``` 所以要区分: | 名称 | 比较对象 | 主要用途 | |---|---|---| | `ratio` | current policy vs old policy | policy loss 的概率比 | | `approx_kl` | current policy vs old policy | PPO update early stop | | `reference KL penalty` | current policy vs reference model | 防止偏离 SFT reference | | DPO `ref_logratio` | reference chosen vs rejected | 偏好基准,不是 token KL penalty | ## 3. 源码阅读顺序图 建议按这个顺序读: ```text 1. trainer/trainer_utils.py:160-177 先看 reward model wrapper 返回什么。 2. trainer/train_ppo.py:52-76 看 PPO 的规则 reward + reward model score 如何相加。 3. trainer/rollout_engine.py:23-36 看 rollout 阶段如何保存 old per-token logprob。 4. trainer/train_ppo.py:117-139 看 PPO 如何定位 response token,并计算 ref logprob。 5. trainer/train_ppo.py:140-155 看 PPO 如何把 sequence reward 变成 token advantage。 6. trainer/train_ppo.py:186-205 看 ratio、approx KL、reference KL 和 policy loss。 7. trainer/train_grpo.py:121-143 对比 GRPO 如何用 group advantage 和同一套 KL/ratio。 8. trainer/train_dpo.py:34-49 最后用 DPO 对照:reference logprob 也能服务偏好 logratio,而不是只服务 KL penalty。 ``` ## 4. MiniMind 源码走读 ### 4.1 reward model wrapper:外部模型只负责打分 #### 源码证据:`LMForRewardModel` 文件:`trainer/trainer_utils.py:160-177` 看它是为了理解:MiniMind 把 reward model 封装成 `get_score(messages, response)`,训练脚本只拿到一个标量分数。 代码摘录: ```python class LMForRewardModel: def __init__(self, model_path, device="cuda", dtype=torch.float16): self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) self.model = AutoModel.from_pretrained(model_path, torch_dtype=dtype, trust_remote_code=True) self.model = self.model.to(device).eval() @torch.no_grad() def get_score(self, messages, response): ... score = self.model.get_score(self.tokenizer, eval_messages) return max(min(score, 3.0), -3.0) ``` 这段代码说明: - reward model 是 `eval()`,不会被 PPO/GRPO 更新。 - `get_score` 返回一个 Python 标量分数。 - 分数被裁剪到 `[-3.0, 3.0]`,避免极端 reward。 - 这个分数后面会和规则 reward 相加。 ### 4.2 PPO/GRPO 的 reward:规则项 + reward model score #### 源码证据 A:PPO reward 文件:`trainer/train_ppo.py:52-76` 看它是为了理解:PPO 对每条 response 计算一个 sequence reward。 代码摘录: ```python def calculate_rewards(prompts, responses, reward_model): rewards = torch.zeros(len(responses), device=args.device) ... rewards[i] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5 if '' in response: rewards[i] += 1.0 if 20 <= len(thinking_content.strip()) <= 300 else -0.5 rewards[i] += 0.25 if response.count('') == 1 else -0.25 rewards[i] -= rep_penalty(answer) score = reward_model.get_score(messages, answer) reward_model_scores.append(score) ... rewards += reward_model_scores ``` 这段代码说明: - reward 不是纯 reward model 分数。 - MiniMind 还加了长度、thinking 格式、重复惩罚等规则项。 - 返回的 `rewards` shape 是 `[B]`。 #### 源码证据 B:GRPO reward 文件:`trainer/train_grpo.py:37-68` 看它是为了理解:GRPO 的 reward shape 是 `[B * num_generations]`,因为每个 prompt 会生成多条 response。 代码摘录: ```python for i in range(batch_size): for j in range(args.num_generations): response_idx = i * args.num_generations + j response = responses[response_idx] prompt = prompts[i] ... rewards[response_idx] -= rep_penalty(answer) score = reward_model.get_score(messages, answer) reward_model_scores.append(score) ... rewards += reward_model_scores ``` 这段代码说明: - flat 排列顺序是 `prompt_i` 的第 `j` 条生成。 - 这个顺序决定后面可以用 `rewards.view(-1, num_generations)`。 - reward 仍然是 sequence 级,不是 token 级。 ### 4.3 old logprob:rollout 阶段保存采样策略概率 #### 源码证据 A:通用 token logprob helper 文件:`trainer/rollout_engine.py:23-36` 看它是为了理解:old logprob 本质也是 `log_softmax + gather`。 代码摘录: ```python def compute_per_token_logps(model, input_ids, n_keep, attention_mask=None): logits = unwrapped(input_ids, attention_mask=attention_mask, logits_to_keep=n_keep + 1).logits[:, :-1, :] per_token_logps = [] for logits_row, ids_row in zip(logits, input_ids[:, -n_keep:]): per_token_logps.append( torch.gather(logits_row.log_softmax(dim=-1), 1, ids_row.unsqueeze(1)).squeeze(1) ) return torch.stack(per_token_logps) ``` 这段代码说明: - `n_keep` 是 response token 数量。 - `input_ids[:, -n_keep:]` 是要取 logprob 的 completion tokens。 - 返回 shape 是 `[B, R]`。 - 这个 helper 用在 rollout 阶段,所以结果就是 `old_logp`。 #### 源码证据 B:Torch rollout 保存 old logprob 文件:`trainer/rollout_engine.py:71-92` 看它是为了理解:生成 response 后立刻保存 `per_token_logps`。 代码摘录: ```python output_ids = model.generate(...) prompt_len = prompt_ids.size(1) completion_ids = output_ids[:, prompt_len:] full_mask = (output_ids != self.tokenizer.pad_token_id).long() per_token_logps = compute_per_token_logps( self.policy_model, output_ids, completion_ids.size(1), attention_mask=full_mask ) return RolloutResult(output_ids, completion_ids, per_token_logps, ...) ``` 这段代码说明: - `output_ids` 是 prompt + response。 - `completion_ids` 是 response 部分。 - `per_token_logps` 是 rollout policy 对 response tokens 的 logprob。 - PPO/GRPO 里后续会把它命名为 `old_resp_logp` 或 `old_per_token_logps`。 #### 源码证据 C:SGLang rollout 也返回 logprob 文件:`trainer/rollout_engine.py:115-173` 看它是为了理解:即使用服务端 rollout,也要拿回同样语义的 `per_token_logps` 和 `completion_mask`。 代码摘录: ```python payload = { "input_ids": all_input_ids, "sampling_params": {...}, "return_logprob": True, } ... raw_logprobs = meta.get("output_token_logprobs", []) ... return RolloutResult( output_ids=..., completion_ids=..., per_token_logps=pad_to_tensor(all_logprobs, max_comp_len, pad_val=0.0), completion_mask=torch.tensor([[1] * len(ids) + [0] * ... for ids in all_completion_ids], device=device), ) ``` 这段代码说明: - SGLang 返回的 logprob 也被整理成 `[B * num_generations, R]`。 - SGLang 的 `completion_mask` 能标出 padding。 - Torch rollout 的 mask 可能全 1,SGLang rollout 的 mask 更接近真实变长 completion。 ### 4.4 current/ref logprob:训练时对同一批 token 重算 #### 源码证据 A:PPO response 位置和 ref logprob 文件:`trainer/train_ppo.py:117-139` 看它是为了理解:response token 的 logprob 要从 `logits[:, :-1]` 的对应位置取。 代码摘录: ```python labels = gen_out[:, 1:].clone() resp_idx = torch.arange(resp_labels.size(1), device=gen_out.device).unsqueeze(0) logp_pos = prompt_lens.unsqueeze(1) - 1 + resp_idx ... ref_resp_logp = F.log_softmax( ref_model(input_ids=gen_out, attention_mask=full_mask).logits[:, :-1], dim=-1 ).gather(2, labels.unsqueeze(-1)).squeeze(-1).gather(1, logp_pos) ``` 这段代码说明: - `labels = gen_out[:, 1:]` 表示每个位置预测下一个 token。 - `logp_pos = prompt_len - 1 + response_idx` 是 response token 对应的 logits 位置。 - `ref_resp_logp` shape 是 `[B, R]`。 - reference model 是 frozen,只提供约束基准。 #### 源码证据 B:GRPO current/ref logprob 文件:`trainer/train_grpo.py:97-105` 看它是为了理解:GRPO 的 policy/ref logprob 和 PPO 是同一类 gather 操作。 代码摘录: ```python res = model_unwrapped(outputs, attention_mask=full_mask) per_token_logps = F.log_softmax(res.logits[:, :-1, :], dim=-1).gather( 2, outputs[:, 1:].unsqueeze(-1) ).squeeze(-1).gather(1, logp_pos) with torch.no_grad(): ref_per_token_logps = F.log_softmax( ref_model(outputs, attention_mask=full_mask).logits[:, :-1, :], dim=-1 ).gather(2, outputs[:, 1:].unsqueeze(-1)).squeeze(-1).gather(1, logp_pos) ``` 这段代码说明: - `per_token_logps` 是 current policy,参与反传。 - `ref_per_token_logps` 在 `torch.no_grad()` 下计算,不参与训练。 - 两者都只取 response token 位置。 ### 4.5 reward 怎么变成 advantage #### 源码证据 A:PPO 把 sequence reward 放到最后有效 token 文件:`trainer/train_ppo.py:140-155` 看它是为了理解:PPO 的 reward 是 sequence 级,但 advantage 是 token 级。 代码摘录: ```python token_rewards = torch.zeros_like(old_resp_logp) last_idx = resp_lengths - 1 token_rewards[torch.arange(B, device=args.device)[valid_resp], last_idx[valid_resp]] += rewards[valid_resp] for t in reversed(range(gen_len)): nv = old_resp_values[:, t + 1] if t < gen_len - 1 else 0.0 delta = token_rewards[:, t] + args.gamma * nv - old_resp_values[:, t] lastgaelam = delta + args.gamma * args.lam * lastgaelam advs_rev.append(lastgaelam) advantages = torch.stack(advs_rev[::-1], dim=1) returns = advantages + old_resp_values ``` 这段代码说明: - 外部 reward 只加在最后一个有效 token。 - Critic 的 `old_resp_values` 参与 GAE。 - 最后得到的 `advantages` shape 是 `[B, R]`。 - padding / EOS 后无效位置后面还会用 mask 去掉。 #### 源码证据 B:GRPO 用组内 reward 标准化 文件:`trainer/train_grpo.py:121-124` 看它是为了理解:GRPO 不需要 critic,直接把 group reward 变成 sequence advantage。 代码摘录: ```python grouped_rewards = rewards.view(-1, args.num_generations) mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations) std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations) advantages = (rewards - mean_r) / (std_r + 1e-4) ``` 这段代码说明: - `grouped_rewards` shape 是 `[B, num_generations]`。 - 每条 response 的 advantage 是同组相对分数。 - `advantages` shape 是 `[B * num_generations]`,后面通过 `unsqueeze(1)` 广播到 token 维。 ### 4.6 KL 的两个名字:approx KL 和 reference KL penalty #### 源码证据 A:PPO update 中的 ratio 与 KL 文件:`trainer/train_ppo.py:186-205` 看它是为了理解:PPO 同时计算 policy-old 的 approx KL 和 policy-reference 的 KL penalty。 代码摘录: ```python log_ratio = mb_resp_logp - old_resp_logp[inds] approx_kl = (0.5 * (log_ratio ** 2) * resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1) ... ratio = torch.exp(log_ratio) 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) policy_loss = ( masked_ppo_loss + args.kl_coef * kl_ref_penalty ) ``` 这段代码说明: - `log_ratio` 比较 current policy 和 rollout old policy。 - `approx_kl` 用于 early stop,不是加到 loss 的 reference KL。 - `kl_ref_penalty` 比较 current policy 和 frozen reference。 - policy loss 里加的是 `kl_coef * kl_ref_penalty`。 #### 源码证据 B:GRPO/CISPO 中的 reference KL 文件:`trainer/train_grpo.py:132-143` 看它是为了理解:GRPO/CISPO 没有 PPO 的 critic/value loss,但仍然使用 reference KL penalty。 代码摘录: ```python kl_div = ref_per_token_logps - per_token_logps per_token_kl = torch.exp(kl_div) - kl_div - 1 ratio = torch.exp(per_token_logps - old_per_token_logps) if args.loss_type == "cispo": clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach() per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps - args.beta * per_token_kl) else: clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon) per_token_loss1 = ratio * advantages.unsqueeze(1) per_token_loss2 = clipped_ratio * advantages.unsqueeze(1) per_token_loss = -(torch.min(per_token_loss1, per_token_loss2) - args.beta * per_token_kl) ``` 这段代码说明: - `per_token_kl` 是 token-level reference KL penalty。 - GRPO 和 CISPO 共享 `ratio`、`advantages`、`per_token_kl`。 - 区别只在 policy loss 形式。 ### 4.7 DPO 对照:reference logprob 不是只能做 KL #### 源码证据:DPO logratio 文件:`trainer/train_dpo.py:34-49` 看它是为了理解:DPO 也有 policy/ref logprob,但它不是 token-level KL penalty,而是 chosen/rejected 偏好差。 代码摘录: ```python ref_log_probs = (ref_log_probs * mask).sum(dim=1) policy_log_probs = (policy_log_probs * mask).sum(dim=1) ... pi_logratios = chosen_policy_log_probs - reject_policy_log_probs ref_logratios = chosen_ref_log_probs - reject_ref_log_probs logits = pi_logratios - ref_logratios loss = -F.logsigmoid(beta * logits) ``` 这段代码说明: - DPO 先把 token logprob 沿 sequence 求和。 - `ref_logratios` 是 reference 对 chosen/rejected 的偏好基准。 - DPO 的 reference 用法是“偏好相对提升”,不是 `exp(ref-policy)-...` 这种 KL penalty。 ## 5. 本节必须会写 / 暂时不要求 ### 5.1 必须会写 这节课不新增一个大的训练模块,但你必须能独立写出这些小公式: ```python log_probs = F.log_softmax(logits, dim=-1) token_logps = log_probs.gather(2, labels.unsqueeze(-1)).squeeze(-1) response_logps = token_logps.gather(1, logp_pos) ``` ```python ratio = torch.exp(policy_logps - old_logps) ``` ```python delta = ref_logps - policy_logps ref_kl_penalty = torch.exp(delta) - delta - 1.0 ``` ```python approx_kl = 0.5 * (policy_logps - old_logps) ** 2 ``` ```python masked_mean = (x * mask).sum() / mask.sum().clamp(min=1) ``` 以及 GRPO 的 group advantage: ```python grouped_rewards = rewards.view(-1, num_generations) mean_r = grouped_rewards.mean(dim=1).repeat_interleave(num_generations) std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(num_generations) advantages = (rewards - mean_r) / (std_r + 1e-4) ``` ### 5.2 暂时不要求 本节暂时不要求: ```text 真正加载 reward model 权重; 启动 SGLang server; 写完整 PPO/GRPO 训练脚本; 调 reward shaping 超参; 证明 KL 近似公式的理论来源。 ``` 现在只要求你能读懂源码中每个张量的身份。 ## 6. 手写模块复盘 本节对应已有手写文件: ```text course/impl/core/ppo.py course/impl/core/grpo.py ``` 你应该回头确认: ```text course/impl/core/ppo.py: response_logp_positions reference_kl_penalty ppo_policy_loss course/impl/core/grpo.py: group_relative_advantages token_kl_penalty grpo_policy_loss cispo_policy_loss ``` 这些函数共同覆盖了第 25 课的核心: ```text logprob 位置对齐; ratio; mask; reference KL penalty; reward -> advantage。 ``` 验收命令: ```bash cd /home/sun/minimind python course/impl/tests/test_ppo.py python course/impl/tests/test_grpo.py ``` 如果 `test_grpo.py` 的 policy loss 失败,优先检查 reduction: ```python policy_loss = ( (per_token_loss * completion_mask).sum(dim=1) / completion_mask.sum(dim=1).clamp(min=1) ).mean() ``` 不要写成全 batch token 直接平均,二者在 response 长度不一致时不同。 ## 7. 实验验证 本节新增实验: ```text course/labs/trace_reward_logprob_kl.py ``` 它不加载真实模型,只用固定 logits/token/reward 张量复现 MiniMind 的关键公式。 运行: ```bash cd /home/sun/minimind python course/labs/trace_reward_logprob_kl.py ``` 它会打印: ```text [Token logprobs] old_logps / policy_logps / ref_logps 的 shape [Reward to advantage] PPO sequence reward 如何落到最后有效 token GRPO group rewards 如何变成 group-relative advantages [KL and ratio] ratio = exp(policy-old) approx_kl = 0.5 * (policy-old)^2 reference_kl_penalty = exp(ref-policy) - (ref-policy) - 1 [DPO contrast] DPO 的 policy/ref logratio 和 reference KL penalty 的区别 ``` 判断实验正确的重点不是某个固定数值,而是这些关系: ```text old_logps.shape == policy_logps.shape == ref_logps.shape == response_mask.shape ratio.shape == policy_logps.shape reference_kl_penalty.shape == policy_logps.shape PPO rewards.shape == [B] GRPO rewards.shape == [B * num_generations] ``` ## 8. 阶段组装 到这里,PPO/GRPO 阶段的核心张量已经完整: ```text rollout_engine -> output_ids / completion_ids / old_logps / completion_mask -> reward model / rules -> PPO: token_rewards + critic values -> GAE advantage -> GRPO: group rewards -> group advantage -> current policy logps -> reference logps -> ratio / KL penalty -> policy loss ``` 后面组装 `course/impl/train_grpo_impl.py` 时,不需要复刻 MiniMind 的所有工程外围。最小教学版可以只保留: ```text synthetic prompts 或 tiny RLAIF prompt -> rollout 生成 response -> 简化 reward 函数 -> group_relative_advantages -> token_kl_penalty -> grpo_policy_loss / cispo_policy_loss -> optimizer step ``` 本节的约束是: ```text 所有 loss 函数必须明确: 哪个 logprob 来自 old policy; 哪个 logprob 来自 current policy; 哪个 logprob 来自 reference model; 哪个 mask 决定有效 response token; reward 是如何进入 advantage 的。 ``` ## 9. 本节检查 1. `old_logp` 和 `policy_logp` 的区别是什么? 2. 为什么 PPO/GRPO 不能只保存 response 文本,还要保存 old per-token logprob? 3. `ref_logp - policy_logp` 在 PPO/GRPO 中用来算什么? 4. `approx_kl = 0.5 * log_ratio^2` 比较的是哪两个 policy? 5. PPO 为什么把 sequence reward 加到最后一个有效 response token? 6. GRPO 为什么要求同一个 prompt 的多条 response 在 flat tensor 中连续排列? 7. DPO 的 `ref_logratios` 和 PPO/GRPO 的 reference KL penalty 有什么区别? ## 10. 下一课 第 26 课进入 Tool Use 与 Agentic RL。 下一课要解决: ```text train_agent.py 如何组织 tool schema、tool_call、tool_response; 一次多轮 trajectory 如何 rollout; 环境反馈 reward 和普通 reward model score 有什么区别; Agentic RL 为什么是延迟 reward; MiniMind 如何把 tool calling 接回 chat template。 ```