DAPO 源码解析笔记
算法来源:字节跳动 2025 年论文《DAPO: Decoupled Clip and Dynamic Sampling Policy Optimization》
实验配置:Qwen2.5-1.5B-Instruct + 单卡 48G GPU + GSM8K 中文数据集 + 300 步训练(约 60 分钟)
什么是 DAPO?
DAPO 是对 DeepSeek 使用的 GRPO 算法 的改进版,专门针对 CoT(Chain-of-Thought)长文本推理训练 优化。
核心思想:用 Group 内多个回答的对比,代替 PPO 中的 Critic 网络估计优势,省掉了单独的价值函数,同时在 GRPO 基础上做了三处关键改进:
| 改进点 | GRPO | DAPO |
|---|---|---|
| ① Clip 范围 | 对称 [1-ε, 1+ε] | 解耦 [1-0.2, 1+0.28],上限更大 |
| ② 无效样本处理 | 无 | Dynamic Sampling,优势全零则跳过 |
| ③ Loss 粒度 | 序列级(token 被平均) | Token 级(直接 sum/总 token 数) |
整体训练流水线
训练是 off-policy 模式:先采样一批经验,然后用这批经验复用训练 num_iterations 次。
数据集:GSM8KDataset
class GSM8KDataset(Dataset):
def __getitem__(self, index):
return {
"prompt": sample["question_zh-cn"], # 中文数学题
"answer": sample["answer_only"] # 纯数字答案,如 "72"
}
- 数据集:GSM8K 中文版(约 8500 道小学数学题)
- 每个样本只有题目和答案,格式简单
核心数据结构:Samples(一个 Group)
@dataclass
class Samples:
prompt_response_ids # [num_gen, seq_len] 完整 prompt+response 的 token id
response_ids # [num_gen, resp_len] 仅 response 部分
attention_mask # [num_gen, seq_len] 非 pad 位置为 1
action_mask # [num_gen, resp_len] 非 eos/pad 的 response token 为 1
num_actions # response 最大长度
response_length # [num_gen] 每个回答的实际有效 token 数量
Group 的概念:对同一道题生成 num_generations=4 个不同回答,构成一个 Group,用于组内对比计算优势。
超参数:DapoArguments
| 参数 | 值 | 说明 |
|---|---|---|
num_generations | 4 | Group 大小,每道题生成 4 个回答 |
clip_eps_high | 0.28 | Clip-Higher 上限(比标准 PPO 的 0.2 更大) |
clip_eps_low | 0.2 | Clip-Higher 下限 |
beta | 0.0 | KL 惩罚系数,为 0 不用参考模型(省显存) |
gradient_accumulation_steps | 2 | 梯度累积,模拟更大 batch |
num_iterations | 1 | 每批经验复用训练次数 |
max_prompt_length | 256 | 输入最大长度 |
max_generate_length | 128 | 输出最大长度 |
lr | 1e-6 | 学习率 |
generate_samples:批量采样
def generate_samples(self, inputs):
关键设计点:
Left Padding
tokenizer.padding_side = "left"
生成时 batch 内 prompt 长度不同,左 pad 确保所有序列右端对齐,使 response 部分在每行同一偏移处开始。
批量生成 4 个回答
inputs_enc = self.tokenizer([input_text] * self.args.num_generations, ...)
prompt_response_ids = self.model.generate(**inputs_enc, temperature=0.9, top_p=1, top_k=50)
temperature=0.9 引入随机性,使 4 个回答各不相同,构成有效的组内对比。
action_mask 的构建
action_mask = (
response_ids.ne(self.tokenizer.eos_token_id)
& response_ids.ne(self.tokenizer.pad_token_id)
).to(dtype=torch.long)
过滤掉 eos 和 pad,只保留 "真正生成的内容",后续 loss 计算只在这些位置计算。
generate_experiences:计算经验数据
def generate_experiences(self, inputs):
流程图:
奖励计算:
# rewards_per_func: [num_funcs, num_generations]
rewards = rewards_per_func * torch.tensor(reward_weights).unsqueeze(1)
rewards = rewards.sum(dim=0) # → [num_generations]
组内归一化(优势估计):
advantages = (rewards - rewards.mean()) / (rewards.std() + 1e-8)
用组内均值和标准差归一化,避免奖励尺度对梯度大小的影响,这是 GRPO/DAPO 的核心思想。
【DAPO 创新 ②】Dynamic Sampling:
nonzero_num = advantages.count_nonzero().item()
if nonzero_num == 0:
print("组内优势为0, 跳过")
continue
组内 4 个回答的奖励完全一样(全对或全错)→ 优势全为 0 → 梯度贡献为零 → 直接跳过,避免浪费算力。
注意:源码只过滤了
nonzero_num == 0,论文中还过滤了nonzero_num == len(advantages)