minimind/course/notes/23_ppo_notes.md

365 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Lesson 23 笔记:PPO 中 Response 采样与梯度问题
## 目录
- [核心问题:为什么 y 不能直接求导](#核心问题为什么-y-不能直接求导)
- [普通 CE Loss 的梯度怎么传](#普通-ce-loss-的梯度怎么传)
- [PPO 的问题:y 是"采样"出来的](#ppo-的问题y-是采样出来的)
- [DPO 的解决方案](#dpo-的解决方案)
- [PPO 的解决方案:Reinforce 估计](#ppo-的解决方案reinforce-估计)
- [一句话总结](#一句话总结)
---
## 核心问题:为什么 y 不能直接求导
> response y 是模型采样出来的离散 token 序列,不能像普通 CE loss 那样直接对 token 选择过程求导。
---
## 普通 CE Loss 的梯度怎么传
```python
# 普通 SFT
logits = model(input_ids) # 模型输出每个 token 的 logits
loss = F.cross_entropy(logits, labels) # 直接拿 ground truth labels 算 loss
loss.backward() # 梯度直接流回 logits → embedding
```
**关键:** labels 是**固定的 ground truth**,不是从模型里采样出来的。
| 步骤 | 说明 |
|------|------|
| 1 | logits = model(input_ids) |
| 2 | loss = cross_entropy(logits, y_fixed) |
| 3 | backward → 梯度流回 logits |
---
## PPO 的问题:y 是"采样"出来的
```python
# PPO 里
y_w = torch.multinomial(probs, num_samples) # 从模型分布中采样
```
| 问题 | 说明 |
|------|------|
| 采样是随机的 | `torch.multinomial` 每次结果可能不同 |
| 采样不可导 | autograd 无法跟踪随机采样过程 |
**梯度流断在这里:**
```
logits → softmax → sample → y (随机!)
↑
这个随机采样过程没有梯度
```
---
## DPO 的解决方案
DPO **绕过了这个问题**,不采样,而是直接用 model 输出的 log_probs:
```python
# DPO 直接用模型输出的概率分布
policy_log_probs = model(x_chosen).log_probs # 可导
ref_log_probs = ref_model(x_chosen).log_probs # 可导
# 计算 ratio 时,不需要对采样求导
log_ratio = policy_log_probs - ref_log_probs
```
---
## PPO 的解决方案:Reinforce 估计
PPO 需要对采样过程求导,用的是 **Reinforce 梯度估计**:
```
∇θ E_{y~πθ}[r(y)] = E_{y~πθ}[r(y) * ∇θ log πθ(y)]
```
| 步骤 | 说明 |
|------|------|
| 1 | 从 π_θ(y|x) 采样 y |
| 2 | 计算 reward r(y) |
| 3 | 用 ∇ log πθ(y) 把 reward 的信号传回去 |
**关键技巧:** 用 log probability 的梯度来"引导"随机采样的方向。
---
## PPO + KL 约束公式:k = r - log r - 1
### 公式拆解
```python
k = π_ref(a|s) / π_θ(a|s) - log(π_ref(a|s) / π_θ(a|s)) - 1
```
设 `r = π_ref / π_θ`,则:
```
k(r) = r - log r - 1
```
### 核心性质
| 性质 | 说明 |
|------|------|
| **最小值在 r = 1** | k(1) = 1 - 0 - 1 = 0,policy = reference 时无惩罚 |
| **永远 ≥ 0** | 经典不等式 r - log r - 1 ≥ 0 |
| **偏离越大,惩罚越大** | r >> 1 或 r << 1 时,k 增大 |
### 为什么这样设计
> 限制 policy 不要离 reference 太远,但又不能太死
| 公式项 | 含义 |
|--------|------|
| `r = π_ref / π_θ` | policy 相对于 reference 的概率比值 |
| `r - log r - 1` | KL divergence 的单 token surrogate |
**作用:**
- 保证非负
- 在 r=1 处最小
- 对大偏离进行更强惩罚
- 实现稳定的策略更新约束
---
## Thinking 机制:open_thinking 的作用
### RLAIFDataset 中的 thinking 控制
```python
def create_chat_prompt(self, conversations):
use_thinking = random.random() < self.thinking_ratio
return self.tokenizer.apply_chat_template(
conversations[:-1],
tokenize=False,
open_thinking=use_thinking,
add_generation_prompt=True
)
```
| 模式 | 效果 |
|------|------|
| `open_thinking=True` | 模型学会"先想再答",输出包含 `<think>` 推理过程 |
| `open_thinking=False` | 模型学会"直接回答",不输出推理过程 |
### chat template 中的实现
```jinja
{%- if open_thinking is defined and open_thinking is true %}
{{- '<think>\n' }} {# 只打开,不关闭,让模型自己生成 </think> #}
{%- else %}
{{- '<think>\n\n</think>\n\n' }} {# 完整空 thinking 块 #}
{%- endif %}
```
| open_thinking | 模板输出 |
|----------------|----------|
| True | `<|im_start|>assistant\n<think>`(打开但没关闭) |
| False | `<|im_start|>assistant\n<think>\n\n</think>\n\n`(完整空块) |
### 之前阶段有没有训练过 thinking
| 阶段 | 有 thinking 训练数据吗 |
|------|----------------------|
| Pretrain | 没有 |
| SFT | 有字段但**实际数据没有**(`reasoning_content` 是空的) |
| RLAIFDataset | **有** — 通过 `open_thinking` 参数控制 |
### SFT 中也有 thinking 相关机制
SFTDataset 的数据 features 定义了 `reasoning_content` 字段,chat template 对 assistant 消息会做:
```jinja
<|im_start|>assistant
<think>
{reasoning_content}
</think>
{content}<|im_end|>
```
另外 `post_processing_chat()` 会随机移除空 thinking 块:
```python
if '<think>\n\n</think>\n\n' in prompt_content and random.random() > empty_think_ratio:
prompt_content = prompt_content.replace('<think>\n\n</think>\n\n', '')
```
### 总结:thinking 训练流程
| 阶段 | 作用 |
|------|------|
| **SFT** | 让模型见过 `<think>`...`</think>` 格式,也见过空 thinking 或被移除的情况 |
| **RLAIF/PPO** | 用 `thinking_ratio` 随机决定 rollout 时"打开 thinking"还是"空 thinking 直答" |
| **推理** | 用户用 `open_thinking` 开关控制模型偏向"显式思考"还是"直接回答" |
---
## PPO 中四个模型的角色
| 模型 | 来源 | 训练方式 |
|------|------|----------|
| **actor_model** | 从 full_sft 来 | PPO 训练它 |
| **ref_model** | 从 full_sft 来 | 冻结,当参考模型 |
| **critic_model** | 从 full_sft backbone 来,加 value_head | PPO 训练它 |
| **reward_model** | 外部奖励模型 | 只负责给 response 打分 |
---
## SGLang 详解
### 一句话定性
> HTTP API 的作用:把"模型生成能力"变成一个"可调用的远程服务接口"。本质不是为了"网络",而是为了:解耦 + 调度 + 并发 + 工程化部署。
---
### model.generate vs SGLang
| 方式 | model.generate | SGLang |
|------|---------------|--------|
| 形式 | 函数调用 | HTTP / RPC |
| 执行 | 当前进程 | 独立推理服务 |
| batch | 固定 batch | Continuous batching |
| cache | 本地函数里 | server 统一管理 |
---
### SGLang 额外做了什么
**没有发生的事:**
- ❌ 改变模型结构
- ❌ 改变 forward 计算
- ❌ 替换 model.generate 的算法本质
**发生的事:**
- 把"generate 循环"从 Python 进程搬到一个高性能推理系统里执行
---
### Continuous Batching(关键)
| 方式 | 行为 |
|------|------|
| HF generate | 等所有请求到齐,固定 batch 一起生成 |
| SGLang | 动态拼 batch,完成就退出,新请求随时加入 |
```
t=0: prompt A, B
t=1: prompt C 加进来
t=2: prompt D 加进来
→ GPU 利用率更高
```
---
### HTTP API 的核心作用
| 作用 | 说明 |
|------|------|
| **解耦** | 训练和推理完全分开,可独立升级/扩展 |
| **服务化** | 变成标准接口,不关心内部怎么生成 |
| **并发调度入口** | 统一入口供 scheduler 做 continuous batching |
| **分布式扩展** | 多训练 worker 共用一个推理服务 |
---
### RLHF 场景为什么特别需要
| 需求 | 为什么需要 HTTP API |
|------|---------------------|
| rollout 量极大 | 一个 step = 100~1000 prompts,必须高吞吐入口 |
| 训练/推理资源分离 | Trainer 用一批 GPU,Rollout 用另一批,必须跨进程通信 |
| 动态调度 | prompt 随时进来,generation 长度不一致,必须统一调度 |
---
### RLHF pipeline 中的位置
```
Prompt dataset
↓
HTTP API(SGLang)
↓
Generated trajectories
↓
Reward model
↓
PPO / GRPO update
```
---
### 一句话总结(考试级)
> SGLang 并没有替代 model.generate,而是把"生成过程(forward + sampling + KV cache loop)"从本地函数调用,升级为可并发调度的推理服务;模型参数 θ 完全不变,只是执行环境和调度方式变了。
---
## approx_kl 详解
### 公式
```python
approx_kl = (
0.5 * (log_ratio ** 2) * resp_policy_mask[inds]
).sum() / resp_policy_mask[inds].sum().clamp(min=1)
```
展开:
```
approx_kl = Σ_{b,t} m_{b,t} * 0.5 * (log π_θ(a_{b,t}|s_{b,t}) - log π_old(a_{b,t}|s_{b,t}))²
/ Σ_{b,t} m_{b,t}
```
其中 `m_{b,t} = resp_policy_mask[b,t]`
### resp_policy_mask 的作用
| 排除的内容 | 说明 |
|-----------|------|
| prompt tokens | 只统计 response |
| padding | 只统计有效 token |
| EOS 之后 | 只统计有效 response 区域 |
### 为什么叫 approx_kl
PPO 需要监控:当前 actor `π_θ` 离 rollout 时的 old actor `π_old` 有多远。
KL 本来是衡量两个分布距离的量。当新旧策略很接近时,可以用二阶近似:
```
KL(π_old || π_θ) ≈ 0.5 * (log π_θ - log π_old)²
```
所以源码用 `0.5 * (log_ratio ** 2)` 来近似新旧策略的 KL。
### 直观判断
| log_ratio | 含义 | approx_kl |
|-----------|------|-----------|
| 接近 0 | new actor 和 old actor 很接近 | 小 |
| 绝对值变大 | new actor 和 old actor 差得更远 | 大 |
---
## 一句话总结
| 概念 | 说明 |
|------|------|
| **普通 CE** | y 是 ground truth 常数 → 梯度直接流回 |
| **DPO** | y 是分布比较,不需要采样 → 直接用 log_probs |
| **PPO** | y 必须采样 → 用 Reinforce 估计器把 reward 信号传回去 |
| **KL 约束** | k = r - log r - 1,保证 policy 不偏离 reference 太远 |
| **approx_kl** | 0.5 * (log_ratio)²,新旧策略 KL 散度的近似 |
| **Thinking** | 通过 `open_thinking` 控制模型是否显式输出推理过程 |
| **SGLang** | 生成服务化,支持并发调度和连续 batching |