第十三章:直指人心——DPO之革新

直指人心DPO,跳过奖励直接学。
在这里插入图片描述

【本章导读】

DPO(Direct Preference Optimization,直接偏好优化)是一种革命性的对齐方法。它跳过了奖励模型训练,直接从偏好数据学习,更简单、更稳定、更高效。


一、DPO的诞生

【RLHF的问题】

RLHF虽然有效,但存在明显问题:

问题 描述
复杂 需要训练奖励模型 + PPO优化
不稳定 PPO训练敏感,容易崩溃
成本高 需要多个模型同时运行
调参难 KL系数、学习率等难以调整

【DPO的洞察】

DPO的核心洞察:可以直接从偏好数据优化策略,无需显式训练奖励模型。


二、DPO的数学原理

【从奖励到策略】

在RLHF中,最优策略与奖励函数的关系:

π∗(y∣x)=1Z(x)πref(y∣x)exp⁡(1βr(x,y))\pi^*(y|x) = \frac{1}{Z(x)} \pi_{ref}(y|x) \exp\left(\frac{1}{\beta}r(x,y)\right)π(yx)=Z(x)1πref(yx)exp(β1r(x,y))

由此可以反推奖励函数:

r(x,y)=βlog⁡π(y∣x)πref(y∣x)+βlog⁡Z(x)r(x,y) = \beta \log\frac{\pi(y|x)}{\pi_{ref}(y|x)} + \beta \log Z(x)r(x,y)=βlogπref(yx)π(yx)+βlogZ(x)

【DPO损失函数】

将奖励代入Bradley-Terry模型,得到DPO损失:

LDPO=−E[log⁡σ(βlog⁡π(yw∣x)πref(yw∣x)−βlog⁡π(yl∣x)πref(yl∣x))]L_{DPO} = -\mathbb{E}\left[\log\sigma\left(\beta\log\frac{\pi(y_w|x)}{\pi_{ref}(y_w|x)} - \beta\log\frac{\pi(y_l|x)}{\pi_{ref}(y_l|x)}\right)\right]LDPO=E[logσ(βlogπref(ywx)π(ywx)βlogπref(ylx)π(ylx))]

其中:

  • ywy_wyw:chosen(更好的回答)
  • yly_lyl:rejected(更差的回答)
  • π\piπ:当前策略
  • πref\pi_{ref}πref:参考策略(SFT模型)

【直观理解】

DPO的目标:让模型提高chosen的概率,降低rejected的概率,同时不要偏离参考模型太远。

优化前:
  P(chosen) = 0.3
  P(rejected) = 0.5

优化后:
  P(chosen) = 0.6 ↑
  P(rejected) = 0.2 ↓

三、DPO vs RLHF

【对比】

方面 RLHF DPO
流程 SFT → RM → PPO SFT → DPO
模型数量 3-4个 2个
训练稳定性 不稳定 稳定
计算成本
调参难度 困难 简单
效果 相当或更好

【流程对比】

RLHF流程:
SFT模型 → 训练奖励模型 → PPO优化 → 对齐模型
           (需要额外训练)   (需要多个模型)

DPO流程:
SFT模型 → 直接偏好优化 → 对齐模型
           (一步到位)

四、DPO代码实现

【完整代码】

import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer

class DPOTrainer:
    def __init__(self, model, ref_model, tokenizer, beta=0.1, lr=1e-6):
        self.model = model
        self.ref_model = ref_model  # 参考模型(冻结)
        self.tokenizer = tokenizer
        self.beta = beta
        self.optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    
    def get_logprobs(self, model, input_ids, attention_mask):
        """计算序列的对数概率"""
        outputs = model(input_ids=input_ids, attention_mask=attention_mask)
        logits = outputs.logits[:, :-1, :]  # 去掉最后一个token
        labels = input_ids[:, 1:]  # 去掉第一个token
        
        log_probs = F.log_softmax(logits, dim=-1)
        token_logprobs = log_probs.gather(2, labels.unsqueeze(-1)).squeeze(-1)
        
        # 只计算回答部分的概率
        return token_logprobs.sum(dim=-1)
    
    def compute_dpo_loss(self, batch):
        """计算DPO损失"""
        # 编码
        chosen_ids = self.tokenizer(
            batch['prompt'] + batch['chosen'],
            return_tensors='pt', padding=True, truncation=True
        ).input_ids.to(self.model.device)
        
        rejected_ids = self.tokenizer(
            batch['prompt'] + batch['rejected'],
            return_tensors='pt', padding=True, truncation=True
        ).input_ids.to(self.model.device)
        
        # 计算当前策略的对数概率
        policy_chosen_logprobs = self.get_logprobs(self.model, chosen_ids, None)
        policy_rejected_logprobs = self.get_logprobs(self.model, rejected_ids, None)
        
        # 计算参考策略的对数概率(不计算梯度)
        with torch.no_grad():
            ref_chosen_logprobs = self.get_logprobs(self.ref_model, chosen_ids, None)
            ref_rejected_logprobs = self.get_logprobs(self.ref_model, rejected_ids, None)
        
        # 计算对数比率
        chosen_logratios = policy_chosen_logprobs - ref_chosen_logprobs
        rejected_logratios = policy_rejected_logprobs - ref_rejected_logprobs
        
        # DPO损失
        logits = self.beta * (chosen_logratios - rejected_logratios)
        loss = -F.logsigmoid(logits).mean()
        
        return loss
    
    def train_step(self, batch):
        """训练一步"""
        self.optimizer.zero_grad()
        loss = self.compute_dpo_loss(batch)
        loss.backward()
        self.optimizer.step()
        return loss.item()
    
    def train(self, dataset, epochs=1, batch_size=4):
        """完整训练"""
        from torch.utils.data import DataLoader
        
        dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
        
        for epoch in range(epochs):
            total_loss = 0
            for batch in dataloader:
                loss = self.train_step(batch)
                total_loss += loss
            
            avg_loss = total_loss / len(dataloader)
            print(f"Epoch {epoch+1}, Loss: {avg_loss:.4f}")

【使用示例】

from transformers import AutoModelForCausalLM, AutoTokenizer

# 加载模型
model = AutoModelForCausalLM.from_pretrained("sft_model")
ref_model = AutoModelForCausalLM.from_pretrained("sft_model")  # 参考模型
tokenizer = AutoTokenizer.from_pretrained("sft_model")

# 冻结参考模型
for param in ref_model.parameters():
    param.requires_grad = False

# 创建训练器
trainer = DPOTrainer(model, ref_model, tokenizer, beta=0.1)

# 训练
trainer.train(preference_dataset, epochs=1)

五、DPO的变体

【IPO(Identity Preference Optimization)】

解决DPO可能过拟合的问题:

LIPO=E[((β−1(rw−rl)−log⁡π(yw∣x)π(yl∣x))2]L_{IPO} = \mathbb{E}\left[\left((\beta^{-1}(r_w - r_l) - \log\frac{\pi(y_w|x)}{\pi(y_l|x)}\right)^2\right]LIPO=E[((β1(rwrl)logπ(ylx)π(ywx))2]

【KTO(Kahneman-Tversky Optimization)】

不需要成对偏好数据,只需要标注"好"或"坏":

LKTO=λy⋅(1−σ(β(log⁡π(y∣x)πref(y∣x)−z(x))))L_{KTO} = \lambda_y \cdot (1 - \sigma(\beta(\log\frac{\pi(y|x)}{\pi_{ref}(y|x)} - z(x))))LKTO=λy(1σ(β(logπref(yx)π(yx)z(x))))

【ORPO(Odds Ratio Preference Optimization)】

将SFT和偏好优化合并为一个目标:

LORPO=LSFT+λ⋅LORL_{ORPO} = L_{SFT} + \lambda \cdot L_{OR}LORPO=LSFT+λLOR


六、DPO的最佳实践

【超参数选择】

超参数 推荐值 说明
β\betaβ 0.1 - 0.5 KL惩罚强度
学习率 1e-6 ~ 1e-5 比SFT更小
Batch Size 64-256 越大越稳定
训练轮数 1-3轮 避免过拟合

【数据质量】

  • 确保偏好标注一致
  • chosen和rejected差异明显
  • 覆盖多种场景

【常见问题】

问题 原因 解决方案
模型变笨 β\betaβ太小 增大β\betaβ
学习太慢 β\betaβ太大 减小β\betaβ
过拟合 训练太久 早停、正则化

七、本章心法总结

【口诀】

DPO直指人心,跳过奖励模型。
简单稳定效率高,偏好数据直接学。

【要点回顾】

要点 说明
核心思想 直接从偏好数据优化策略
优势 简单、稳定、高效
损失函数 最大化chosen与rejected的对数概率差
超参数 β\betaβ控制KL惩罚强度
变体 IPO、KTO、ORPO等

【下一章预告】

下一章,我们将学习红队测试与安全防御,了解如何发现模型的安全漏洞并构建防御机制。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐