直接偏好优化(DPO)
四行 PyTorch 就能写完的损失函数,为什么还是有一半人第一次跑不出信号——一次有完整实测记录的 DPO 实验,外加一个上游代码里的静默 bug。
0. 本组作业要回答什么
第 8 章用四步代数证明了一件漂亮的事:带 KL 正则的 RLHF 目标有闭式最优解,把这个解反代进 Bradley-Terry 模型,配分函数 $Z(x)$ 恰好被消掉,剩下一个可以直接对策略参数求导的 logsigmoid。整章的结论是——你不需要奖励模型,也不需要 RL 循环。
推导是干净的,但跑起来完全是另一回事。这一组作业要回答的是三个非常具体的工程问题:
- 怎么确认自己真的在跑 DPO,而不是在跑一堆无意义的数?DPO 的损失依赖 $\pi_\theta$ 与 $\pi_{\text{ref}}$ 的对数比。如果参考模型接错了——比如它压根不是同一个模型、甚至不是同一个分词器——损失照样会下降,accuracy 照样会打印出一个漂亮的百分数,没有任何一处会报错。本组作业真的踩到了这个坑(第 2 节),并给出一个五秒钟就能做完的自检(第 3 节)。
- DPO 跑不出信号时,第一个该动的旋钮是什么?答案不是模型规模、不是数据量、不是 $\beta$,而是学习率。第 4 节给出本机实测的两组对照:除 lr 外全部相同,一组几乎原地不动,一组 margin 涨了将近一个数量级。
- 教科书里的现象,一定会在你的实验里出现吗?第 8 章第 5 节讲了「概率位移」——DPO 常常把 chosen 和 rejected 的概率一起往下压。本组实验没有复现这个现象。第 5 节如实记录,并说明为什么不能据此推翻文献结论。
HuggingFaceTB/SmolLM2-360M-Instruct(policy 与 reference 同源),数据
argilla/ultrafeedback-binarized-preferences-cleaned 取 4000 条,1 个 epoch,
$\beta=0.1$,batch 2 × grad_accum 8,max_length 512,共 250 个优化步。
两份配置文件除学习率外逐字相同:dpo_smol360m.yaml(lr 5e-6)与
dpo_smol360m_lr2e5.yaml(lr 2e-5)。
硬件:RTX 5080,但由于另一个进程常驻占用,实际只有约 6.9 GiB 可用显存—— 这也是为什么基座是 360M 而不是上游默认的 OLMo-2-1B:DPO 要同时驻留 policy 和 reference 两份权重。
这一组是全书最容易上手的实验:完全离线,不需要奖励模型服务,不需要 rollout 循环,一张消费级显卡就能在一小时量级内跑完两组对照。正因为它便宜,才值得把它跑到「每个数字都能解释」的程度——后面那些昂贵的在线 RL 实验,出问题时你没有这么多次重跑的机会。
1. 训练脚本在算什么:把第 8 章的推导对回代码
先把要观察的东西和第 8 章的公式一一对上,否则后面看指标只是在看一堆浮点数。
DPO 的损失是
$$ \mathcal{L}_{\text{DPO}}(\theta) = -\,\E_{(x,y_c,y_r)\sim\mathcal{D}}\left[\log\sigma\Big(\beta\log\frac{\pi_\theta(y_c\mid x)}{\pi_{\text{ref}}(y_c\mid x)} - \beta\log\frac{\pi_\theta(y_r\mid x)}{\pi_{\text{ref}}(y_r\mid x)}\Big)\right] $$括号里那两项,就是第 8 章说的隐式奖励(implicit reward):
$$ \hat r_\theta(x,y) \;=\; \beta \log \frac{\pi_\theta(y\mid x)}{\pi_{\text{ref}}(y\mid x)} $$训练脚本每个优化步打出来的五个数,全部是这个式子的直接函数:
| 指标名 | 数学含义 | 怎么读 |
|---|---|---|
chosen_rewards | $\hat r_\theta(x, y_c)$ 在 batch 上的均值 | 正数 = 策略比参考模型更愿意生成被偏好的回复 |
rejected_rewards | $\hat r_\theta(x, y_r)$ 的均值 | 负数 = 策略在压低被拒绝的回复 |
margins | 上面两个之差 | DPO 真正在最大化的量,应当单调上行 |
accuracy | $\Pr[\hat r_\theta(x,y_c) > \hat r_\theta(x,y_r)]$ 的经验估计 | 唯一跨算法可比的指标;DPO / IPO / SimPO 的 loss 尺度完全不同,accuracy 可以直接比 |
loss | $-\log\sigma(\text{margin})$ 的均值 | 随机基线是 $\log 2 \approx 0.6931$;贴着这个值就是没学到东西 |
最后一行值得单独强调,因为它是本组作业最省时间的一条经验。$\sigma(0)=0.5$,所以当隐式奖励对 chosen 和 rejected 一视同仁时,损失恰好是 $-\log 0.5 = \log 2 \approx 0.6931$。DPO 的 loss 有一个天然的、有意义的参照点,这和 SFT 的交叉熵完全不同(SFT 的 loss 数值取决于词表大小和数据难度,没有绝对基线)。所以看 DPO 的 loss 时,你要问的不是「它有没有下降」,而是「它离 0.6931 有多远」。
margins 想成一根弹簧的伸长量,$\beta$ 是弹簧的刚度。$\beta$ 越大,同样的策略变化会被换算成越大的隐式奖励差,损失也就越早饱和到 0($\sigma$ 进入平坦区,梯度趋近于 0)。这就是为什么 $\beta$ 和学习率会互相纠缠:它们在一定范围内是同一个旋钮的两种拧法。本组作业固定 $\beta=0.1$(第 8 章讲的常用取值),只扫学习率,就是为了不让两个耦合的量同时变化。
为什么这一组不需要奖励模型
对照 HW2:那一组要真的训一个 Bradley-Terry 奖励模型出来,然后(在完整 RLHF 流程里)还要用它去打分。这一组一个奖励模型都不训——第 8 章的推导告诉我们,策略自己就是奖励模型,$\hat r_\theta$ 是从 $\pi_\theta$ 和 $\pi_{\text{ref}}$ 里读出来的,不是学出来的。
代价是:这个「奖励模型」只在 $\pi_{\text{ref}}$ 附近有意义。$\pi_\theta$ 一旦跑远,$\log$ 比值的绝对尺度就不再可信——这也是第 8 章第 9 节说「DAA 和在线 RL 的真正分水岭是数据是不是 on-policy」的根源。本组用的 UltraFeedback 是纯离线数据集,它的回复来自一堆和 SmolLM2 毫无关系的模型,off-policy 程度是未知且很可能很高的。看指标时请把这一点记在心里。
2. 上游代码 bug:参考模型会静默停在默认值
这是本组作业最值钱的一节。它不是「配置写错了」这种低级失误,而是一个在任何情况下都不会报错、日志里只有一行证据、算出来的所有指标都是垃圾但看起来完全正常的 bug。学会怎么发现它,比学会 DPO 的推导更能救你的项目。
--model_name X 换基座模型,参考模型不会跟着换。
它会静默停留在 Config 的默认值 allenai/OLMo-2-0425-1B-SFT——
即使你换的模型跟它连分词器都不一样。而且 train.py 根本没有 --ref_model_name 这个命令行参数,你无法从 CLI 修正它。
根因:__post_init__ 跑得太早
direct_alignment/config.py 里,参考模型的默认逻辑写在 dataclass 的 __post_init__ 中:
def __post_init__(self):
if self.ref_model_name is None:
self.ref_model_name = self.model_name
意图很清楚:「没指定参考模型,就用策略模型本身」——这正是 DPO 的标准做法(从 SFT checkpoint 出发,$\pi_\theta$ 与 $\pi_{\text{ref}}$ 同源)。
问题出在 train.py::main_cli 的覆盖顺序上,它是先构造 Config(),再用 setattr 逐项覆盖:
cfg = Config() # __post_init__ 在这里就跑完了,
# ref_model_name 被定死为默认的 OLMo-2-1B-SFT
for key, value in vars(args).items():
if value is not None and key != "config":
setattr(cfg, key, value) # 只改了 model_name,__post_init__ 不会重跑
Python 的 dataclass 只在 __init__ 结束时调用一次 __post_init__。之后无论你怎么 setattr,那段「同步默认值」的逻辑都不会再执行。于是:
Config()构造时,model_name是默认的allenai/OLMo-2-0425-1B-SFT,ref_model_name是None→__post_init__把它设成allenai/OLMo-2-0425-1B-SFT。- 然后
setattr(cfg, "model_name", "HuggingFaceTB/SmolLM2-360M-Instruct")。 - 结果:
model_name是 SmolLM2-360M,ref_model_name是 OLMo-2-1B。两个模型,两套分词器,两个词表。
唯一的证据:日志里相邻的两行
本组实测复现了这个现象。命令行传 --model_name 那次运行的日志是:
Loading policy model: HuggingFaceTB/SmolLM2-360M-Instruct
Loading reference model: allenai/OLMo-2-0425-1B-SFT ← 没跟着改
这两行相隔不到一秒,中间还夹着权重加载的进度条。如果你不是抱着「我要确认参考模型是谁」的心态去看日志,几乎不可能注意到。而修正之后的运行,同样位置是这样的:
Loading policy model: HuggingFaceTB/SmolLM2-360M-Instruct
Loading reference model: HuggingFaceTB/SmolLM2-360M-Instruct
为什么它不会报错,但结果全是垃圾
DPO 需要计算 $\log \pi_\theta(y\mid x) - \log \pi_{\text{ref}}(y\mid x)$。两个模型的分词器不同,同一段文本会被切成长度不同、内容不同的 token 序列;即使实现上强行用 policy 的 token id 去喂 reference 模型,那些 id 在 reference 的词表里指向的也是完全无关的 token。算出来的 log-prob 是一个有确定数值、但没有任何语义的量。
而 PyTorch 不会抱怨:shape 对得上(都是 [batch, seq_len]),dtype 对得上,减法照做,logsigmoid 照算,梯度照回传。那次错配运行最终打印出的 Final accuracy: 62.50% 是彻头彻尾的垃圾数据——而 62.5% 恰恰是一个「看起来在学习」的漂亮数字,比随机的 50% 高出一截,正好落在你会相信它的区间里。
规避办法
换模型时一律走 YAML,并且把 ref_model_name 显式写死:
# homework/hw4-dpo/dpo_smol360m.yaml
# Model —— 两行必须一致
model_name: HuggingFaceTB/SmolLM2-360M-Instruct
ref_model_name: HuggingFaceTB/SmolLM2-360M-Instruct
为什么 YAML 这条路径是安全的?因为 load_config 走的是 Config(**raw)——所有字段在构造时就位,__post_init__ 拿到的已经是正确的 model_name。也就是说,即使你在 YAML 里省掉 ref_model_name,默认同步逻辑也会正确生效。但本组的两份配置仍然把它显式写出来,理由是:显式写出来的东西会出现在日志和 diff 里,隐式推导出来的不会。这个 bug 本身就是「隐式默认值」的代价。
顺带一提,这个坑值得提给上游。最小修复是把 main_cli 的覆盖顺序改成「先收集所有覆盖项,再一次性构造 Config(**merged)」,或者在 setattr 循环之后手动补一次同步。
3. 第 0 步自检:所有指标必须恰好为 0
上一节的 bug 提出了一个更一般的问题:有没有一个便宜的、必然成立的检查,能在训练开始的那一刻就告诉你「参考模型接对了」?对 DPO 来说有,而且它只需要看日志的第一行指标。
训练刚开始时,策略模型还没被更新过,所以
$$ \pi_\theta \;=\; \pi_{\text{ref}} \quad\Longrightarrow\quad \hat r_\theta(x,y) = \beta\log\frac{\pi_\theta(y\mid x)}{\pi_{\text{ref}}(y\mid x)} = \beta\log 1 = 0 $$注意这个 0 是逐样本、逐 token 精确成立的,不是「平均意义上接近 0」。分子分母是同一个模型在同一段输入上的同一次前向,两个 log-prob 逐项相等,相减恒为 0。于是第一个优化步打出来的指标必然是:
| 指标 | 理论值 | 为什么 |
|---|---|---|
chosen_rewards | 0.0 | $\beta\log 1$ |
rejected_rewards | 0.0 | 同上 |
margins | 0.0 | $0 - 0$ |
accuracy | 0.0 | 判据是严格大于,而两边恰好相等,所以一条都不算命中 |
loss | $\approx \log 2$ | $-\log\sigma(0)$ |
本组实测:第 1 步的 chosen_rewards / rejected_rewards / margins / accuracy 全是 0.0。这就是参考模型接对了的证明。
train() 模式、参考模型的权重被优化器意外更新、log-prob 的 mask 对不齐、prompt 与 completion 的边界算错、dropout 没关(两次前向随机性不同,差值不会恰好为 0)。
上一节那次参考模型错配的运行,这个自检直接就通不过——两个不同的模型算出的 log-prob 不可能逐项相等。也就是说,如果当初先看了这一行,那次运行的三十多分钟本可以在第一秒就被省下来。
accuracy 为什么是 0 而不是 0.5
这一点第一次看会觉得别扭,值得说清楚。直觉上「两边完全一样」应该对应 50% 的准确率(相当于抛硬币)。但代码里的判据是严格不等式 chosen_reward > rejected_reward,在恰好相等时返回 False。所以初始 accuracy 是 0.0,不是 0.5。
这带来一个读图上的注意事项:DPO 的 accuracy 曲线不是从 0.5 起步的,而是从 0 起步、然后迅速爬到 0.5 附近再继续走。如果你按「50% 是随机基线」的心态去看前几十步,会误以为模型在往反方向学。本组两组实验的首 25 步平均 accuracy 分别是 0.287 和 0.245,都低于 0.5——这不是学坏了,主要是初始那个 0 还没被后面的步数稀释掉,同时早期的隐式奖励差本身也很小、符号随机。
把自检做成流程的一部分
建议每次启动 DPO 训练后,做完下面三件事再去干别的:
- 核对两行加载日志:policy 和 reference 的模型 id 是不是你要的那两个。
- 核对第 1 步指标:四个奖励类指标是不是恰好 0.0,loss 是不是在 $\log 2$ 附近。
- 核对配置回显:脚本会打印 Model / Loss / Beta / Dataset / Samples / Batch size / Learning rate / Epochs,以及学习率调度的步数与 warmup 步数。这些是你后面解释曲线形状时要用的——本组的有效 batch 是 2 × 8 = 16,总共 250 个优化步,
warmup_ratio为 0.1,于是「首段指标普遍疲软」有一部分原因就是学习率还在爬坡。
三步加起来不到一分钟,但它挡住的是「训练三小时后才发现数据是假的」这一类损失。
4. 学习率对照:照抄别人的 lr 是 DAA 实验里最常见的失败原因
本组跑了两次训练,两份 YAML 除学习率外逐字相同:同一个基座、同一批 4000 条 UltraFeedback、同一个 $\beta$、同一个 batch 配置、同一个种子、同样的 250 个优化步。唯一的差别是 learning_rate 从 5.0e-6 变成 2.0e-5(4 倍)。
实测数据
下面是首 25 步与末 25 步的指标均值(取段均值而不是单点,是为了避免被 batch 噪声带偏;本组 batch 只有 16 条,单步指标抖得很厉害):
| lr=5e-6 首25步 | lr=5e-6 末25步 | lr=2e-5 首25步 | lr=2e-5 末25步 | |
|---|---|---|---|---|
| loss | 0.6968 | 0.6835 | 0.7003 | 0.6222 |
| accuracy | 0.287 | 0.330 | 0.245 | 0.537 |
| margin | −0.00016 | +0.02832 | −0.00809 | +0.20306 |
| chosen_rewards | −0.00581 | +0.03286 | −0.00607 | +0.20895 |
| rejected_rewards | −0.00566 | +0.00455 | +0.00203 | +0.00583 |
补充一个整体范围:lr=5e-6 那组的 loss 全程在 0.6353–0.7539 之间,均值贴着 $\ln 2 = 0.6931$。
怎么读这张表
第一列和第三列几乎一样,第二列和第四列差了一个数量级。这就是全部结论。
两组的起点是同一个地方(都在 0 附近微微抖动,符号随机——这正是第 3 节说的「初始隐式奖励为 0,早期只有噪声」)。跑完 250 步之后:
- lr=5e-6:margin 从 −0.00016 挪到 +0.02832。方向是对的,但幅度小到几乎可以被当作噪声。loss 从 0.6968 降到 0.6835——如果你只盯着 loss,会觉得「在下降啊,挺好」,但它离随机基线 0.6931 还很近,而全程 loss 在 0.6353–0.7539 之间摆动,这个摆幅远大于首末两段之间的差值。这条曲线的信息量约等于零。
- lr=2e-5:margin 到 +0.20306,和 lr=5e-6 组末段的 +0.02832 完全不在一个量级;accuracy 越过 0.5 达到 0.537;loss 降到 0.6222,明显跌破了 $\ln 2$。三个指标同时、一致地指向同一个方向,这才叫学到了东西。
configs/dpo.yaml 给 OLMo-2-1B 用的是 5e-6;把同样的值原样搬到 360M 上,模型几乎不动。提到 2e-5 之后,同一个 360M 模型立刻显出信号。
换句话说:照抄别人的学习率,是 DAA 实验里最常见的失败原因。而它的失败方式特别阴险——不崩溃、不报错、loss 甚至在缓慢下降,只是什么都没学到。
为什么会这样:DPO 的学习率不是「越小越稳」
第 8 章讲过一段历史:2023 年秋天 Zephyr-$\beta$ 和 Tülu 2 把 DPO 跑通的关键发现,是学习率要低到反常识的程度(相对 SFT 而言,常见量级是 5e-7 到 5e-6)。这条经验被广泛传播,于是很多人形成了一个错误推论:「DPO 的学习率越小越安全」。
本组的实验说明这个推论是错的。正确的表述是:DPO 的合适学习率区间比 SFT 低一到两个数量级,但它仍然是一个「区间」,有下界。低于下界的后果不是「训得慢一点」,而是在你能负担的步数内根本走不出噪声——250 步 × 有效 batch 16 = 4000 条样本,如果每一步的更新量都小到被梯度噪声淹没,跑再多步也只是随机游走。
而这个区间的位置依赖模型规模。粗略的直觉是:参数越少,同样的学习率带来的函数空间移动越小(也越不容易被少数几个方向主导),所以小模型通常需要更大的学习率。上游默认值是给 1B 模型调的,360M 比它小了三倍不止,需要更大的 lr 是完全合理的。
margins 是否明显脱离 0、accuracy 是否越过 0.5、loss 是否明显跌破 $\ln 2$。三者中有两个不满足,就说明这个 lr 不在可用区间里,别急着去调 $\beta$、换损失函数或加数据。
反过来,学习率过大的信号也很好认:margin 涨得非常快然后 accuracy 掉头、生成的样本开始复读或坍缩。先找到「有信号」的下界,再往上试到「开始不稳」的上界,取中间。
一个方法论上的注记
这张表之所以有说服力,是因为它是受控对照:两组除了一个变量之外完全相同,连随机种子都一样。如果你在换 lr 的同时还换了数据量或 $\beta$,得到的差异就无法归因。做实验时的纪律是:一次只动一个旋钮,并且把「没动的那些」也写进记录里——后者同样重要,因为半年后回看时,你需要知道当时的 $\beta$ 是多少才能复现。
5. 关于概率位移:本次实验没有复现
第 8 章第 5 节讲了 DPO 最著名的一个「隐藏的坑」——概率位移(likelihood displacement):训练过程中 margin 在稳定上升,但把 chosen 和 rejected 拆开看会发现,两者的绝对概率是一起往下掉的,只是 rejected 掉得更快。也就是说,模型并没有变得更愿意生成好回复,它只是变得更不愿意生成坏回复——同时把概率质量泄漏到了第三方的、你没在数据里见过的输出上。这也是 DPO 训练常见的样本坍缩、复读、答非所问的一个来源。
这个现象是 DAA 文献里被反复讨论的,也是第 8 章反复叮嘱「必须把 chosen_rewards 和 rejected_rewards 分开看,只看 margin 永远发现不了」的原因。
本组的观察
本组的两次运行都没有出现这个现象。以信号明确的 lr=2e-5 组为例:
| 指标 | 首 25 步 | 末 25 步 | 方向 |
|---|---|---|---|
chosen_rewards | −0.00607 | +0.20895 | 大幅上升 |
rejected_rewards | +0.00203 | +0.00583 | 基本不动 |
把它翻译回概率语言:$\hat r_\theta(x,y) = \beta\log\frac{\pi_\theta(y\mid x)}{\pi_{\text{ref}}(y\mid x)}$,所以 chosen_rewards 为正就意味着 $\pi_\theta(y_c\mid x) > \pi_{\text{ref}}(y_c\mid x)$——被偏好回复的概率是升上去的,不是掉下来的。而 rejected 那一侧几乎原地不动,既没被明显压低,也没上升。
换句话说,本次的 margin 增长几乎全部由 chosen 一侧贡献。这和教科书里那张「两条线一起向下、间距慢慢拉开」的经典示意图,形状完全不同。
所以正确的结论是:「在本组的这个具体设置下没有观察到概率位移」,而不是「概率位移不存在」或「文献说错了」。要真正检验这件事,至少需要多个种子、多个模型规模、以及在训练更长时间之后再看。
那这一节的教学价值在哪?
恰恰在于「预期落空」本身。它同时说明了三件事:
- 指标必须自己看,不能靠背结论。如果本组直接照抄「DPO 会把两边一起压低」写进报告,那就是编造——数据明明不是这样。做实验的纪律是:先记录看到的,再解释看到的,而不是先有结论,再从数据里找支持。
- 「拆开看 chosen 和 rejected」这个方法论是无条件正确的,即使结论是「没事发生」。本组之所以能确定地说「没有位移」,正是因为一直分开记录着这两个数。如果只记 margin,这一节根本写不出来——margin 从 −0.00809 涨到 +0.20306,两种截然不同的机制会给出完全一样的 margin 曲线。
- 小规模实验的结论边界要写清楚。一个诚实的实验报告,负结果和正结果一样有价值,但必须同时给出它的适用范围。这不是学术客套,是防止半年后的自己(或同事)拿着这条结论去指导一个 8B 模型的训练。
如果两条绝对曲线一起往下走,那就是标准的位移;如果 chosen 那条是平的或往上的,就像本组这样,那 margin 的增长就是「正当」的。这是一个改动量很小、但能显著提升实验说服力的练习。
6. 怎么跑,以及怎么把指标留下来
基本命令
两份配置各跑一次,基本形式是:
python -m direct_alignment.train --config homework/hw4-dpo/dpo_smol360m.yaml
python -m direct_alignment.train --config homework/hw4-dpo/dpo_smol360m_lr2e5.yaml
前提是 direct_alignment 包可导入:先在 _src/code/ 下 uv sync,然后要么在该目录下运行(配置用相对该目录能解析到的路径),要么从仓库根目录运行并设置 PYTHONPATH=_src/code。上游 README 推荐的写法是 uv run python -m direct_alignment.train ...。
code/pyproject.toml 给 x86_64 Linux 钉的是 cu126 轮子,uv sync 装出来的 torch 2.13.0+cu126 的 get_arch_list() 里不含 sm_120。RTX 5080 是 Blackwell(compute capability 12.0),运行时会报
CUDA error: no kernel image is available for execution on the device。
特别阴险的是
torch.cuda.is_available() 返回 True、device_count() 也正常,只有真正启动 kernel 时才失败。定位方法是比对 torch.cuda.get_device_capability(0) 与 torch.cuda.get_arch_list()。修复:
uv pip install --index-url https://download.pytorch.org/whl/cu128 \
--reinstall-package torch torch
注意 --reinstall-package 不能省——不带它时 uv 会认为「torch 已满足需求」而跳过(输出 Checked 1 package)。装完是 torch 2.11.0+cu128,get_arch_list() 含 sm_120。
问题:逐步指标只进 wandb
上游 train.py 的每一个优化步都会调 wandb.log(step_metrics, step=global_step),而控制台只在训练结束时打印最终的 loss 和 accuracy。这带来一个两难:
- 开 wandb → 要联网、要账号、要项目,而且离线复盘时数据不在本地仓库里。
- 关 wandb(
WANDB_MODE=disabled或wandb_project: null)→ 逐步指标直接消失,你只剩下两个终值。
而本组第 4 节那张对照表——首 25 步 / 末 25 步的五个指标均值——没有逐步曲线是根本做不出来的。
解法:homework/tools/run_with_metrics.py
这个包装器在不改动上游任何一行代码的前提下,把 wandb.log 换成一个「先落盘、再转发」的版本,于是关掉 wandb 也能拿到完整的逐步指标 JSONL。用法:
METRICS_JSONL=homework/hw4-dpo/logs/metrics_dpo_360m_lr2e5.jsonl \
WANDB_MODE=disabled \
python homework/tools/run_with_metrics.py direct_alignment.train \
--config homework/hw4-dpo/dpo_smol360m_lr2e5.yaml
它等价于 python -m direct_alignment.train --config ...,只是多了一份落盘的指标——本组 logs/ 目录下那两份 metrics_dpo_360m*.jsonl 就是这么来的。核心只有三件事:
def logging_shim(data=None, step=None, **kwargs):
if isinstance(data, dict):
record = {}
for key, value in data.items():
if isinstance(value, bool):
record[key] = value
elif isinstance(value, (int, float)):
record[key] = float(value)
if step is not None:
record["_step"] = step
if record:
sink.write(json.dumps(record) + "\n")
return current_log(data, step=step, **kwargs) # 原样转发
然后用 runpy.run_module(module, run_name="__main__", alter_sys=True) 启动目标模块——所以对被包装的脚本来说,它就是被 python -m 正常启动的,argparse、__name__ == "__main__" 全都照常工作。
wandb.init,而不能只在导入后打一次。因为 wandb.init() 会把模块级的 wandb.log 重新绑定到本次 run 上,此前打的补丁会被直接覆盖掉——训练照跑,JSONL 却是空的,而且没有任何报错。
所以包装器的写法是:先替换
wandb.init 本身,等它返回之后再把 log 的补丁装上去。
original_init = wandb.init
def init_shim(*args, **kwargs):
run = original_init(*args, **kwargs)
install_log_shim() # init 之后再打补丁
return run
wandb.init = init_shim
install_log_shim() # 兼容压根不调用 init 的脚本
install_log_shim 里还有一个 _is_metrics_shim 标记用于幂等,避免重复包装导致每条指标被写多次。
JSONL 长什么样
每行是一个 wandb.log 调用的扁平快照,字段就是上游 step_metrics 的键:chosen_rewards、rejected_rewards、margins、accuracy、loss、grad_norm、learning_rate、epoch、hours_elapsed,外加包装器补上的 _step。
拿到这份文件之后,第 4 节那张表就是几行 pandas 或几行 jq 的事。建议把「跑训练」和「算统计」彻底分开:训练只负责把原始逐步记录落盘,所有的聚合、切段、画图都在事后做。这样你换一种聚合方式(比如改成滑动窗口而不是首尾各 25 步)时不需要重跑三十分钟的训练。
同一个包装器对本仓库的其它几组作业同样适用——HW3、HW5、HW6 的上游脚本用的是同一套 wandb 日志约定。
7. 自选消融
下面四组都建立在同一个前提上:先把学习率调到有信号的区间(第 4 节),否则任何消融都只是在比较两堆噪声。本组的经验是,在 SmolLM2-360M-Instruct + 4000 条 UltraFeedback 这个设置下,lr=2e-5 是有信号的。
消融一:换损失函数
把数据和模型完全固定,只改 --loss:
python -m direct_alignment.train --config homework/hw4-dpo/dpo_smol360m_lr2e5.yaml --loss ipo
python -m direct_alignment.train --config homework/hw4-dpo/dpo_smol360m_lr2e5.yaml --loss simpo
python -m direct_alignment.train --config homework/hw4-dpo/dpo_smol360m_lr2e5.yaml --loss orpo
--loss 是安全的——因为 --config 这条路径已经用正确的 model_name 触发过 __post_init__ 了,ref_model_name 此时已经是对的,后面的 setattr 只动 loss,不会破坏这个不变量。
一般规律:只有那些「会被
__post_init__ 用来推导其它字段」的参数,才不能用命令行覆盖。在这份代码里就是 model_name 一个。
读结果时的三个注意点:
- 不要跨算法比 loss 的绝对值。IPO 的目标是把 margin 推到 $\frac{1}{2\beta}$($\beta=0.1$ 时是 5.0)而不是推到无穷,它的 loss 是平方误差形式,和 DPO 的
logsigmoid完全不在一个尺度上。跨算法唯一可比的是accuracy,其次是 margin 的走势形状。 - SimPO 和 ORPO 需要单独调 lr。上游配置给它们的学习率明显更低(SimPO 8e-7、ORPO 1e-6,对比 DPO 的 5e-6),所以直接套用本组的 2e-5 大概率会炸。这两个损失在上游被明确标记为「仍然噪声较大、需要进一步验证」(见
direct_alignment/ORPO_SIMPO.md),把它们调出信号本身就是一个有价值的练习。 - SimPO / ORPO 不需要参考模型,显存占用会明显低于 DPO。如果你的卡装不下两份权重,这是一条出路——但代价是失去了第 3 节那个「第 0 步全零」的自检(没有 $\pi_{\text{ref}}$ 就没有这个恒等式)。
消融二:扫 $\beta$
固定 lr,改 beta(比如 0.05 / 0.1 / 0.3 / 1.0)。$\beta$ 在 DPO 里同时扮演两个角色:
- KL 正则强度——$\beta$ 越大,$\pi_\theta$ 被拉得离 $\pi_{\text{ref}}$ 越近。
- 隐式奖励的尺度——$\hat r = \beta\log(\cdot)$,$\beta$ 越大,同样的策略变化被换算成越大的奖励差,
logsigmoid越早饱和、梯度越早消失。
所以 $\beta$ 的效果不是单调的:太小 → 正则太弱、容易学坏;太大 → 梯度早早饱和、看起来「很稳」但学不动。观察目标是 margin 的饱和位置,而不是 margin 的绝对值(它本身就正比于 $\beta$,不同 $\beta$ 之间直接比大小没有意义)。记录时请把 margins 除以 $\beta$ 之后再比较。
消融三:扫 max_length
本组用的是 512。改成 256 / 1024 观察两件事:
- 显存与速度:注意力是二次的,长度翻倍时峰值显存的增长会比线性快。这一组在 6.9 GiB 的预算下很容易撞到 OOM,是个很好的显存直觉练习。
- 截断带来的偏置:配置里的
truncation_mode: keep_end意味着超长样本从左边截、保住回复尾部。UltraFeedback 里 chosen 回复往往更长,所以max_length偏小会不成比例地砍掉 chosen 的内容——这是一个可能污染结论的系统性偏置。如果 512 → 1024 让 margin 明显变化,说明你之前测到的一部分「偏好信号」其实是「长度截断信号」。
消融四:改数据,而不是改损失
把损失固定成 DPO,只改 max_samples(1000 / 4000 / 全量)或整个偏好数据集。第 8 章反复强调的一条经验是:数据通常压倒各种 DPO 类目标之间的小差别。如果你观察到「换数据量带来的差异 > 在 DPO/IPO/ORPO 之间切换带来的差异」,那你就亲手复现了这条经验——而这几乎肯定会发生。
做这一组时特别注意把「步数」和「数据量」解耦:max_samples 翻倍会让优化步数也翻倍,于是你其实同时改了两个变量。想干净地归因,就固定总步数、只改数据来源。
本组小结
| 问题 | 本组的答案 |
|---|---|
| 参考模型接对了吗? | 看第 1 个优化步:chosen_rewards / rejected_rewards / margins / accuracy 必须恰好为 0。不为 0 就别往下跑。 |
| DPO 跑不动,先动哪个旋钮? | 学习率。本组实测:同样的 360M 模型、同样的数据,lr 从 5e-6 提到 2e-5,末段 margin 从 +0.02832 变成 +0.20306。 |
| 只看 loss 够吗? | 不够。lr=5e-6 那组的 loss 全程在 0.6353–0.7539,均值贴着 $\ln 2$——「看起来在训练」,其实几乎没学到东西。 |
| margin 上升就是好事吗? | 必须拆开看 chosen 和 rejected。本组的 margin 增长几乎全部来自 chosen 上升(+0.20895),rejected 基本不动。 |
| 指标怎么落盘? | 上游逐步指标只进 wandb。用 homework/tools/run_with_metrics.py 包一层,WANDB_MODE=disabled 也能拿到完整 JSONL。 |
| 换基座模型要注意什么? | 一律走 YAML 并显式写死 ref_model_name。命令行 --model_name 不会带动参考模型(第 2 节)。 |
交付物清单
- 两份配置文件(或你自己扫出来的更多份),以及每一份对应的完整日志。
- 每次运行的逐步指标 JSONL(
METRICS_JSONL=...)。 - 一张对照表:至少包含 loss / accuracy / margins / chosen_rewards / rejected_rewards 的首段与末段均值。五个指标缺一不可——少了 chosen/rejected 的拆分,这张表就回答不了「margin 是怎么涨上去的」。
- 第 1 步的原始指标行,用来证明参考模型接对了。
- 一段文字说明:你观察到的现象里,哪些和第 8 章讲的一致,哪些不一致,以及你认为不一致的原因是什么。