这篇是 pokemon qwen 微调系列(三):SFT 评估复盘 的后续。
上一篇的结论是:SFT v1 退化以后,不能急着做 DPO;必须先修 SFT 数据。现在 SFT v2 已经在同一套 30 题评测上超过 base,才进入这一步:尝试用 DPO 做偏好优化。
但这次 DPO v1 的结果也很重要:技术链路跑通了,指标没有变强。
这不是坏结果。它说明 DPO 不是“接在 SFT 后面的万能增强按钮”,而是一个很依赖偏好数据强度、训练信号和评测覆盖的精细对齐步骤。
主要依据:DPO 原论文提出用一个分类式目标直接优化偏好对,避免显式 reward model 和 PPO 训练;TRL 文档中
DPOTrainer要求每条样本包含prompt / chosen / rejected,并通过 policy 与 reference 的 log probability 差异计算偏好损失。[Rafailov et al., 2023, arXiv:2305.18290] [Hugging Face, 2026-05-06, TRL DPOTrainer]
TL;DR
| 问题 | 结论 | 证据 |
|---|---|---|
| DPO 是什么 | 不显式训练 reward model 的偏好优化方法 | 直接用 chosen / rejected 对训练 policy |
| 为什么接在 SFT v2 后面 | DPO 适合做偏好对齐,不适合补基础知识 | SFT v1 退化时应先修数据 |
| DPO v1 数据多大 | 146 对 synthetic / RLAIF preference pairs | SFT_DPO/data/pokemon_dpo_v1.jsonl |
| DPO v1 是否跑通 | 跑通 | smoke 5/5 steps,full training 19/19 steps |
| DPO v1 是否超过 SFT v2 | 没有 | Accuracy 同为 63.3%,hallucination 同为 36.7% |
| 主要问题 | 训练信号几乎没动 | loss≈0.6931,reward margin 为 0.0 |
| 下一步 | 先做 overfit sanity test,再扩 DPO v2 | 20-40 对高置信偏好,3-5 epochs |
1) DPO 要解决什么问题
SFT 的训练信号很简单:给模型一个 prompt 和标准 answer,让模型最大化标准 answer 的 likelihood。
形式上可以写成:
这里:
- 是 prompt;
- 是训练集中给定的 assistant answer;
- 是正在训练的语言模型。
SFT 的问题也很直接:它只告诉模型“模仿这个答案”,但不告诉模型“两个答案里哪个更好”。
例如同一个问题:
Prompt:
What is the Electric type weak to?
Good answer:
Electric-type Pokemon are weak to Ground-type moves.
Bad answer:
This type is weak to Flying, Water, and Grass.SFT 只能学习某个单独答案的分布;DPO 学的是更强的比较信号:
在同一个 prompt 下,chosen 应该比 rejected 更可能被模型生成。也就是说,DPO 的数据不是普通监督样本,而是偏好对:
| 字段 | 含义 |
|---|---|
prompt | 用户问题 |
chosen | 更好的回答 |
rejected | 更差的回答 |
这也是 SFT_DPO/data/pokemon_dpo_v1.jsonl 的基本结构。
2) 从 RLHF 到 DPO:为什么它更简单
传统 RLHF 常见流程可以拆成三步:
- 先做 SFT,得到一个可用初始模型;
- 收集偏好数据,训练 reward model;
- 用 PPO 之类的强化学习方法优化 policy,同时用 KL 约束防止模型偏离原模型太远。
这个流程有效,但工程复杂度很高:
| 阶段 | 需要训练什么 | 主要风险 |
|---|---|---|
| SFT | policy | 数据质量差会直接学坏 |
| Reward Model | 单独的奖励模型 | reward hacking、偏好泛化不稳 |
| PPO | policy | 训练不稳定、超参数敏感、成本高 |
DPO 的核心想法是:不单独训练 reward model,也不显式跑 PPO,而是把 RLHF 目标重写成一个可以直接优化的分类式损失。
直观理解:
如果 chosen 比 rejected 更好,
那么 policy 相对 reference 应该更偏向 chosen。这里的 reference 通常就是 DPO 开始前的 SFT 模型。它的作用不是继续训练,而是作为“不要偏离太远”的锚点。
3) DPO 的数学形式
每条 DPO 样本可以记作:
其中:
- 是 prompt;
- 是 preferred / chosen answer;
- 是 dispreferred / rejected answer。
DPO 比较的不是单独的 log probability,而是 policy 相对 reference 的偏好差。
先定义 chosen 的相对优势:
再定义 rejected 的相对优势:
DPO 希望 chosen 的相对优势大于 rejected:
最终 loss 可以写成:
这里最关键的是四个量:
| 项 | 含义 |
|---|---|
| 当前 policy 对 chosen 的概率 | |
| 当前 policy 对 rejected 的概率 | |
| reference 对 chosen 的概率 | |
| reference 对 rejected 的概率 |
如果 policy 相比 reference 更偏向 chosen,括号里的值会变大,loss 会下降。
如果 policy 对 chosen 和 rejected 没有拉开差距,sigmoid 输入接近 0,loss 就接近:
这正好对应这次 DPO v1 的训练现象:loss 长期停在 0.6931 附近,reward margin 几乎为 0.0。
4) beta 是什么
DPO 里的 beta 控制偏好信号强度,也可以理解为 policy 偏离 reference 的力度旋钮。
beta 情况 | 直观效果 | 风险 |
|---|---|---|
| 太小 | 更新很弱,模型几乎不动 | 指标无变化 |
| 适中 | chosen / rejected 被稳定拉开 | 理想状态 |
| 太大 | 偏好信号过强,容易过拟合偏好对 | 事实回归或风格变窄 |
这次配置里:
beta: 0.1
learning_rate: 5.0e-7
num_train_epochs: 1beta=0.1 本身是常见保守起点,真正更可疑的是整体训练强度太弱:学习率低、数据少、epoch 少,总步数只有 19。
5) Pokemon DPO v1 的数据怎么构造
这次 DPO 数据集不是人工标注数据,而是 synthetic / RLAIF 风格的学习数据。
核心文件:
SFT_DPO/data/pokemon_dpo_v1.jsonl
SFT_DPO/DPO_DATASET_CARD.md
SFT_DPO/src/build_dpo_dataset.py数据规模:
| 指标 | 值 |
|---|---|
| preference pairs | 146 |
chosen 含 $effect_chance | 0 |
rejected 含 $effect_chance | 5 |
类别分布:
| Category | Count |
|---|---|
factoid | 18 |
semi_structured | 33 |
reasoning | 79 |
long_form | 16 |
来源分布:
| Source | Count | 含义 |
|---|---|---|
canonical_type_chart | 54 | 基于类型克制表构造 |
eval_sft_rejected | 29 | SFT 评测失败输出作为 rejected |
eval_winner_pair | 24 | base / SFT 一方胜出的 pair |
canonical_pokemon_profile | 20 | 基于 Pokemon 类型和弱点构造 |
eval_base_rejected | 15 | base 失败输出作为 rejected |
| synthetic variants | 4 | placeholder、style、ability confusion |
一个真实样本长这样:
{
"prompt": "What is the Electric type weak to?",
"chosen": "Electric-type Pokemon are weak to Ground-type moves.",
"rejected": "flying, water, grass",
"category": "factoid",
"source": "eval_sft_rejected"
}这个 pair 很适合 DPO,因为 chosen 和 rejected 的差异不是风格差异,而是明确的事实正确性差异。
再看一个不那么强的 pair:
{
"prompt": "Explain what Levitate does in Pokemon battles.",
"chosen": "In Pokémon battles, the Levitate ability allows a Pokémon to become immune to Ground-type moves and Terrain types...",
"rejected": "Nullifies effects of being grounded.",
"category": "factoid",
"source": "eval_winner_pair"
}这个 pair 方向大体正确,但 chosen 本身也不完美:它把 Terrain 相关表述混进来了,可能让偏好信号变脏。
这就是 synthetic preference data 的主要风险:不是每个 pair 都像“Ground vs Flying/Water/Grass”那么干净。
6) 训练实现:policy adapter 和 reference adapter
训练入口在:
SFT_DPO/src/train_dpo.py
SFT_DPO/src/modal_dpo.py
SFT_DPO/configs/dpo_qwen25_7b_lora_v1.yaml核心配置:
| 项 | 值 |
|---|---|
| Base model | Qwen/Qwen2.5-7B-Instruct |
| SFT adapter | /outputs/pokemon-qwen25-7b-instruct-qlora-v2/adapter |
| DPO dataset | SFT_DPO/data/pokemon_dpo_v1.jsonl |
| Output | /outputs/pokemon-qwen25-7b-instruct-dpo-v1 |
| max seq length | 1024 |
| learning rate | 5e-7 |
| epochs | 1 |
| batch | 1 |
| grad acc | 8 |
| beta | 0.1 |
| LoRA | r=16, alpha=32, dropout=0.05 |
| quantization | 4bit NF4 + double quant + bf16 |
| GPU | A100 |
这次实现里有一个关键点:policy 和 reference 都从同一个 SFT v2 adapter 初始化,但只有 policy 继续训练。
代码逻辑可以简化成:
model = PeftModel.from_pretrained(
model,
sft_adapter_path,
is_trainable=True,
adapter_name="policy",
)
model.load_adapter(
sft_adapter_path,
adapter_name="reference",
)然后在 DPOConfig 里指定:
model_adapter_name="policy"
ref_adapter_name="reference"这个设计对应 DPO 的数学形式:
| 角色 | 工程对象 | 是否训练 |
|---|---|---|
policy adapter | 是 | |
reference adapter | 否 |
这样可以避免额外加载一整份 reference model,比较适合 LoRA / QLoRA 场景。
7) Modal 链路
Modal 入口:
SFT_DPO/src/modal_dpo.py关键点有四个:
- 使用
python_version="3.11"; - pin 住
trl==0.25.0; - 限制
transformers>=4.57.0,<5.0.0; - 训练结束后调用
outputs_volume.commit(),确保 adapter 持久化。
依赖片段:
.uv_pip_install(
"torch",
"transformers>=4.57.0,<5.0.0",
"datasets>=2.20.0",
"peft>=0.12.0",
"trl==0.25.0",
"mergekit",
"accelerate>=0.33.0",
"bitsandbytes>=0.43.0",
"pyyaml>=6.0",
"pydantic>=2.6.0",
)训练完成后,实际可用于评估的 policy adapter 路径是:
/outputs/pokemon-qwen25-7b-instruct-dpo-v1/adapter/policy不是父目录:
/outputs/pokemon-qwen25-7b-instruct-dpo-v1/adapter这个路径差异很容易踩坑,因为 PEFT 多 adapter 保存时会把 adapter name 变成子目录。
8) DPO v1 的评估结果
DPO v1 完整跑通:
- smoke training:
5/5steps; - full training:
19/19steps; - output persisted 到
pokemon-qlora-outputs; - 评估使用同一套 30 题 Pokemon eval set;
- scoring output:
SFT/outputs/eval_scored_dpo_v1.json。
总体指标:
| Model | Accuracy | Hallucination rate |
|---|---|---|
| Base | 50.0% | 50.0% |
| SFT v2 | 63.3% | 36.7% |
| DPO v1 | 63.3% | 36.7% |
也就是说:
DPO v1 没有比 SFT v2 更好。这不是“DPO 算法失败”,而是“这次 DPO 实验没有产生可见收益”。
9) 为什么 DPO v1 没有变强
训练日志里最关键的信号是:
loss ≈ 0.6931
rewards/chosen = 0.0
rewards/rejected = 0.0
rewards/margins = 0.0
rewards/accuracies = 0.0这说明 policy 没有把 chosen 和 rejected 拉开。
从 DPO loss 看,0.6931 不是随机数字。它接近:
也就是模型面对 chosen / rejected 的偏好分类时,几乎还在二选一随机状态。
9.1 训练设置太保守
这是最可能的直接原因。
当前 DPO v1:
| 项 | 值 |
|---|---|
| dataset size | 146 pairs |
| learning rate | 5e-7 |
| epochs | 1 |
| optimizer steps | 19 |
| batch | 1 |
| grad acc | 8 |
对于一个 7B 模型上的 LoRA adapter 来说,这个更新强度非常弱。
保守设置有合理动机:不想破坏 SFT v2 已经修好的事实能力。但结果说明它可能保守过头,连高置信偏好也没有明显学进去。
9.2 偏好数据有用,但不够强
DPO 对 pair 质量非常敏感。
强 pair 应该长这样:
| Prompt | Chosen | Rejected |
|---|---|---|
| Electric weak to? | Ground | Flying / Water / Grass |
| Fire vs Rock? | Not very effective | Super-effective |
| Thunderbolt effect? | Electric damage + may paralyze | $effect_chance% |
这类 pair 的优点是:chosen 和 rejected 的差异明确、可验证、非风格化。
弱 pair 则可能只是:
- chosen 更长,rejected 更短;
- chosen 更像解释,rejected 也不完全错;
- rejected 只是缺字段,不是事实错误;
- chosen 自身带有不够干净的额外信息。
如果弱 pair 比例过高,DPO 就很难形成稳定、方向一致的更新。
9.3 评测集太小,可能看不到局部收益
这次仍然用 30 题固定评测集。
它适合做工程闭环,但不适合证明 DPO 的全部收益或失败。
可能出现这种情况:
DPO 改善了某些偏好 pair 附近的输出,
但这 30 题没有覆盖到;
或者改善幅度太小,关键词 scorer 看不出来。所以不能只根据 aggregate metric 否定 DPO。更准确的结论是:
在当前 146 对 synthetic preference pairs、当前超参数、当前 30 题评测下,
DPO v1 没有产生可见收益。10) DPO 不应该承担什么任务
这次实验再次确认一个原则:
SFT 负责把基础行为和领域知识学对;
DPO 负责在“都能回答”的基础上偏向更好的回答。DPO 不适合用来做大规模知识注入。
如果模型不知道:
- Electric 弱 Ground;
- Ground 免疫 Electric;
- Gyarados 是 Water/Flying;
- Levitate 是 ability 而不是 move;
那应该回到 SFT 数据、RAG 数据或基础知识源,而不是指望 DPO 靠少量 rejected answer 把知识补齐。
11) 下一步:先做 overfit sanity test
现在不应该直接扩到几千条 DPO 数据。更好的下一步是先做一个 overfit sanity test。
目标:
验证当前 DPO 实现和超参数是否能让 adapter 真的移动。建议设置:
| 项 | 建议 |
|---|---|
| pair 数 | 20-40 |
| pair 类型 | 高置信事实纠错 |
| learning rate | 1e-6 到 5e-6 |
| epochs | 3-5 |
| generation | deterministic |
选择 pair 时优先满足:
- chosen 明确正确;
- rejected 明确错误;
- 差异不是单纯语气或长度;
- rejected 包含事实错误、placeholder、方向混淆或关键字段缺失;
- prompt 与 eval failure 有直接关系。
预期结果分三种:
| 结果 | 解释 | 下一步 |
|---|---|---|
loss 仍停在 0.6931,margin 仍为 0 | 实现、adapter trainability 或超参数有问题 | 查 TRL / PEFT adapter 交互 |
| margin 能拉开,但 30 题不变 | 训练能动,但评测覆盖或数据覆盖不足 | 扩评测、扩高质量 pair |
| margin 能拉开,目标题输出改善 | DPO 链路有效 | 构建 DPO v2 |
12) DPO v2 应该怎么做
DPO v2 不应该追求“大而杂”,应该追求“小而硬”。
建议目标:
300-800 对高置信 preference pairs数据分层:
| Layer | 用途 |
|---|---|
| factual correction | 修事实错误,例如类型、技能、进化 |
| rule reasoning | 修类型克制、免疫、双属性倍率 |
| semi-structured | 修字段缺失、profile 顺序、comparison |
| style control | 修 flavor text 泄漏、啰嗦、答非所问 |
同时每条数据最好保留:
category
source
error_bucket
confidence这样评估时就能回答更细的问题:
- DPO 是否真的改善 type chart reasoning;
- 是否只改善了 style;
- 是否牺牲 factoid accuracy;
- 是否把回答变短但漏了字段;
- 是否降低 hallucination。
13) 当前关键路径
SFT_DPO/data/pokemon_dpo_v1.jsonl
SFT_DPO/DPO_DATASET_CARD.md
SFT_DPO/DPO_V1_ANALYSIS.md
SFT_DPO/configs/dpo_qwen25_7b_lora_v1.yaml
SFT_DPO/src/build_dpo_dataset.py
SFT_DPO/src/train_dpo.py
SFT_DPO/src/modal_dpo.py
SFT/outputs/eval_scored_dpo_v1.json14) 一句话总结
DPO v1 的价值不是“让 Pokemon Qwen 立刻变强”,而是证明了从 SFT v2 adapter、偏好数据、TRL DPOTrainer、Modal A100 训练到固定评估的链路已经打通;真正的问题转向了更核心的部分:偏好数据是否足够硬,训练信号是否足够强,评测是否足够敏感。
下一步不该盲目扩大训练,而应该先用 20-40 条高置信 pair 做 overfit sanity test。只有当 loss、reward margin 和目标输出都能动起来,DPO v2 才值得扩大到 300-800 条高质量偏好数据。