欢迎光临
我们一直在努力

手写 DPO:从零实现大模型直接偏好优化训练

手写系列第 N+1 弹:继手写 RLHF 之后,这次带来更简洁、更强大的 DPO(Direct Preference Optimization)。不用繁琐的奖励模型,不用复杂的 PPO 稳定性调优,一个简单的二元交叉熵损失就能让大模型学会对齐人类偏好。DeepSeek R1 的推理能力背后,DPO 正是关键技术之一。

一、从 RLHF 到 DPO:为什么我们需要更简洁的对齐方法?

1.1 RLHF 的困境

大模型对齐(Alignment)是让模型输出符合人类期望的关键步骤。RLHF(Reinforcement Learning from Human Feedback)自 InstructGPT 以来一直是主流范式,但它在工程落地中面临三个核心难题:

奖励模型难以训练。RLHF 需要单独训练一个 Reward Model,对模型输出进行打分。这个奖励模型本身就需要大量人工标注数据,且很容易出现 reward hacking(奖励欺骗)——模型学会了利用奖励函数的漏洞,而非真正理解人类偏好。

PPO 训练不稳定。PPO(Proximal Policy Optimization)是个出了名的"调参噩梦"。学习率、clip 范围、KL 散度系数、GAE 参数……任何一项没调好,训练直接崩溃。即使调好了,一个 batch 内可能还是会出现策略坍塌。

计算开销巨大。整个流程需要同时加载:参考模型(Reference Model)、策略模型(Policy Model)、奖励模型、价值模型(Critic)。四份模型参数,对显存的要求极不友好。

1.2 DPO 的核心洞察

2023 年,斯坦福大学的研究者在论文《Direct Preference Optimization: Your Language Model is Secretly a Reward Model》中提出了一个关键洞察:

语言模型自身的概率比,就已经隐含了奖励信息。

换句话说,我们根本不需要额外的奖励模型。给定偏好对 $(y_w, y_l)$(win vs lose),DPO 直接基于策略模型和参考模型的对数概率比来构造损失函数,将偏好学习转化为一个简单的二元分类问题:

$$L_{DPO}(\\pi_\\theta; \\pi_{ref}) = -\\mathbb{E}{(x,y_w,y_l) \\sim D} \\left[ \\log \\sigma \\left( \\beta \\log \\frac{\\pi\\theta(y_w|x)}{\\pi_{ref}(y_w|x)} – \\beta \\log \\frac{\\pi_\\theta(y_l|x)}{\\pi_{ref}(y_l|x)} \\right) \\right]$$

这个公式看起来有点复杂,但本质思想很简单:我们希望模型更倾向于生成偏好回答 $y_w$ 而非非偏好回答 $y_l$,同时用参考模型 $\\pi_{ref}$ 做正则化,防止模型偏离初始化太远。

这就是 DPO 的全部核心——一个损失函数,取代了 RLHF 整个复杂的三阶段流水线。

二、DPO 的数学原理详解

2.1 Bradley-Terry 偏好模型

在理解 DPO 之前,我们需要先了解它背后的偏好建模理论。Bradley-Terry 模型是经典的成对比较模型,它假设每个回答 $y$ 有一个隐式的"强度"分值 $r(x,y)$,用户偏好 $y_w$ 胜过 $y_l$ 的概率为:

$$p^*(y_w \\succ y_l | x) = \\frac{\\exp(r(x,y_w))}{\\exp(r(x,y_w)) + \\exp(r(x,y_l))} = \\sigma(r(x,y_w) – r(x,y_l))$$

其中 $\\sigma$ 是 sigmoid 函数。RLHF 的做法是训练一个神经网络 $r_\\phi(x,y)$ 来近似这个隐含奖励,然后用 PPO 基于该奖励优化策略。

2.2 DPO 的巧妙推导

DPO 的关键一步是:如果我们把策略模型 $\\pi_\\theta$ 看作一个隐式奖励函数,那么最优策略 $\\pi^*$ 和其对应的奖励函数之间存在一一对应的解析关系。

根据 RLHF 中带 KL 散度约束的最优策略解:

$$\\pi^*(y|x) = \\frac{1}{Z(x)} \\pi_{ref}(y|x) \\exp\\left(\\frac{1}{\\beta} r(x,y)\\right)$$

解出奖励函数:

$$r(x,y) = \\beta \\log \\frac{\\pi^*(y|x)}{\\pi_{ref}(y|x)} + \\beta \\log Z(x)$$

将 $r(x,y)$ 代入 Bradley-Terry 模型,配分函数 $Z(x)$ 被消去,得到:

$$p^(y_w \\succ y_l | x) = \\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)$$

于是我们有了一个由策略模型 $\\pi_\\theta$ 参数化的偏好概率。最大化对数似然就得到了 DPO 损失函数。

这个推导的精妙之处在于:它将奖励建模和策略优化合并为一步。DPO 不使用显式的奖励函数,而是通过策略模型的概率比隐式地编码了偏好信息。

2.3 DPO 损失函数的梯度分析

为了更好地理解 DPO 的训练动态,我们来看看损失函数的梯度形式:

$$\\nabla_\\theta L_{DPO} = -\\beta \\mathbb{E}{(x,y_w,y_l) \\sim D} \\Big[ \\sigma(\\hat{r}\\theta(x,y_l) – \\hat{r}\\theta(x,y_w)) \\Big( \\nabla\\theta \\log \\pi_\\theta(y_w|x) – \\nabla_\\theta \\log \\pi_\\theta(y_l|x) \\Big) \\Big]$$

其中 $\\hat{r}\\theta(x,y) = \\beta \\log \\frac{\\pi\\theta(y|x)}{\\pi_{ref}(y|x)}$ 是隐式奖励。

这个梯度形式告诉我们三件事:

  • 权重因子 $\\sigma(\\hat{r}\\theta(x,y_l) – \\hat{r}\\theta(x,y_w))$ 可以看作是"错误程度"的指示器。当模型错误地偏好 $y_l$ 时($\\hat{r}\\theta(x,y_l) > \\hat{r}\\theta(x,y_w)$),这个权重接近 1,梯度信号最强;当模型已经正确偏好 $y_w$ 时,权重接近 0,梯度自然衰减。

  • 梯度方向是同时提高 $\\log \\pi_\\theta(y_w|x)$ 和降低 $\\log \\pi_\\theta(y_l|x)$,即让好的回答概率上升,坏的回答概率下降。

  • 自适应性:DPO 不需要显式的 reward clipping 或 PPO 中的 clip 操作,隐式奖励的差异自然地控制了梯度的大小,实现了类似自适应的学习率效果。

  • 三、从零实现 DPO:完整代码解析

    现在进入实战环节。我们将用 PyTorch 从零实现一个完整的 DPO 训练框架,包括数据处理、损失计算、训练循环和评估。

    3.1 项目结构

    dpo-from-scratch/
    ├── data/ # 偏好数据集
    ├── model/ # 模型定义
    │ ├── base_model.py # 基础语言模型
    │ └── reference.py # 参考模型封装
    ├── trainer/
    │ ├── dpo_loss.py # DPO 核心损失函数
    │ └── dpo_trainer.py # DPO 训练器
    ├── utils/
    │ ├── data_utils.py # 数据处理工具
    │ └── eval_utils.py # 评估工具
    └── train_dpo.py # 训练入口

    3.2 基础语言模型

    首先,我们需要一个可训练的语言模型。为了降低硬件门槛,我们基于一个简化的 GPT-2 风格 Transformer 实现:

    # model/base_model.py
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    from transformers import AutoModelForCausalLM, AutoTokenizer
    from typing import Optional, Tuple, List

    class GPT2LikeModel(nn.Module):
    """简化的 GPT-2 风格语言模型,支持因果语言建模"""

    def __init__(self, vocab_size: int = 50257, hidden_size: int = 768,
    num_layers: int = 12, num_heads: int = 12, max_length: int = 1024):
    super().__init__()

    self.token_embedding = nn.Embedding(vocab_size, hidden_size)
    self.position_embedding = nn.Embedding(max_length, hidden_size)

    self.layers = nn.ModuleList([
    TransformerBlock(hidden_size, num_heads)
    for _ in range(num_layers)
    ])

    self.ln_final = nn.LayerNorm(hidden_size)
    self.lm_head = nn.Linear(hidden_size, vocab_size, bias=False)

    # 权重绑定:输入嵌入和输出头共享权重
    self.token_embedding.weight = self.lm_head.weight

    self.hidden_size = hidden_size
    self.max_length = max_length

    def forward(self, input_ids: torch.Tensor,
    attention_mask: Optional[torch.Tensor] = None,
    labels: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor, torch.Tensor]:
    """
    Args:
    input_ids: (batch_size, seq_len)
    attention_mask: (batch_size, seq_len) – 0 表示 padding
    labels: (batch_size, seq_len) – -100 表示忽略的 token
    Returns:
    logits: (batch_size, seq_len, vocab_size)
    loss: 标量(训练时返回)
    """
    batch_size, seq_len = input_ids.shape

    # 位置编码
    positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)

    # 词嵌入 + 位置嵌入
    x = self.token_embedding(input_ids) + self.position_embedding(positions)

    # 构建因果注意力掩码
    if attention_mask is not None:
    # 将 padding mask (0/1) 扩展为 2D 注意力掩码
    # 0 表示 padding,1 表示有效 token
    extended_mask = attention_mask[:, None, None, :] # (B, 1, 1, S)
    extended_mask = extended_mask.to(dtype=x.dtype)
    extended_mask = (1.0 – extended_mask) * torch.finfo(x.dtype).min
    else:
    extended_mask = None

    # 逐层传递
    for layer in self.layers:
    x = layer(x, attention_mask=extended_mask)

    x = self.ln_final(x)
    logits = self.lm_head(x) # (B, S, V)

    loss = None
    if labels is not None:
    shift_logits = logits[…, :-1, :].contiguous()
    shift_labels = labels[…, 1:].contiguous()
    loss = F.cross_entropy(
    shift_logits.view(-1, shift_logits.size(-1)),
    shift_labels.view(-1),
    ignore_index=-100
    )

    return logits, loss

    class TransformerBlock(nn.Module):
    """标准 Transformer 块:多头注意力 + FFN"""

    def __init__(self, hidden_size: int, num_heads: int):
    super().__init__()
    self.ln_1 = nn.LayerNorm(hidden_size)
    self.attn = CausalSelfAttention(hidden_size, num_heads)
    self.ln_2 = nn.LayerNorm(hidden_size)
    self.mlp = FeedForward(hidden_size)

    def forward(self, x: torch.Tensor, attention_mask: Optional[torch.Tensor] = None):
    x = x + self.attn(self.ln_1(x), attention_mask)
    x = x + self.mlp(self.ln_2(x))
    return x

    class CausalSelfAttention(nn.Module):
    """因果自注意力:确保当前位置只能看到之前的位置"""

    def __init__(self, hidden_size: int, num_heads: int):
    super().__init__()
    self.num_heads = num_heads
    self.head_dim = hidden_size // num_heads
    assert hidden_size % num_heads == 0, "hidden_size must be divisible by num_heads"

    self.c_attn = nn.Linear(hidden_size, 3 * hidden_size)
    self.c_proj = nn.Linear(hidden_size, hidden_size)

    # 下三角因果掩码,注册为 buffer
    self.register_buffer(
    "bias",
    torch.tril(torch.ones(1, 1, 1024, 1024)).view(1, 1, 1024, 1024)
    )

    def forward(self, x: torch.Tensor, attention_mask: Optional[torch.Tensor] = None):
    B, T, C = x.shape

    qkv = self.c_attn(x)
    q, k, v = qkv.chunk(3, dim=-1)

    # 多头拆分
    q = q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
    k = k.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
    v = v.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)

    # 计算注意力分数
    att = (q @ k.transpose(-2, -1)) * (1.0 / self.head_dim ** 0.5)
    att = att.masked_fill(self.bias[:, :, :T, :T] == 0, float('-inf'))

    # 叠加 padding mask
    if attention_mask is not None:
    att = att + attention_mask[:, :, :T, :]

    att = F.softmax(att, dim=-1)
    y = att @ v

    # 合并多头
    y = y.transpose(1, 2).contiguous().view(B, T, C)
    y = self.c_proj(y)

    return y

    class FeedForward(nn.Module):
    """前馈网络:GELU 激活 + 投影"""

    def __init__(self, hidden_size: int):
    super().__init__()
    self.c_fc = nn.Linear(hidden_size, 4 * hidden_size)
    self.c_proj = nn.Linear(4 * hidden_size, hidden_size)

    def forward(self, x: torch.Tensor):
    x = self.c_fc(x)
    x = F.gelu(x)
    x = self.c_proj(x)
    return x

    3.3 DPO 核心损失函数

    这是整个框架的核心——DPO 损失的一行即可重现的代:

    # trainer/dpo_loss.py
    import torch
    import torch.nn.functional as F
    from typing import Optional, Tuple

    def dpo_loss(
    policy_chosen_logps: torch.Tensor, # 策略模型在偏好回答上的对数概率
    policy_rejected_logps: torch.Tensor, # 策略模型在非偏好回答上的对数概率
    ref_chosen_logps: torch.Tensor, # 参考模型在偏好回答上的对数概率
    ref_rejected_logps: torch.Tensor, # 参考模型在非偏好回答上的对数概率
    beta: float = 0.1, # KL 散度系数
    label_smoothing: float = 0.0, # 标签平滑
    ipo: bool = False # 是否使用 IPO 变体
    ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    """
    计算 DPO 损失。

    Args:
    policy_chosen_logps: (batch_size,) 策略模型对 chosen 序列的对数概率
    policy_rejected_logps: (batch_size,) 策略模型对 rejected 序列的对数概率
    ref_chosen_logps: (batch_size,) 参考模型对 chosen 序列的对数概率
    ref_rejected_logps: (batch_size,) 参考模型对 rejected 序列的对数概率
    beta: 控制 KL 约束的强度,越大对参考模型的偏离惩罚越重
    label_smoothing: 标签平滑系数,防止过拟合
    ipo: 如果 True,使用 Identity Preference Optimization 变体

    Returns:
    loss: 标量损失
    chosen_rewards: 偏好回答的隐式奖励
    rejected_rewards: 非偏好回答的隐式奖励
    """
    # 计算对数概率比值
    # log π_θ(y_w|x) – log π_ref(y_w|x) = π_θ 相对于 π_ref 的 log 比率
    chosen_log_ratio = policy_chosen_logps – ref_chosen_logps
    rejected_log_ratio = policy_rejected_logps – ref_rejected_logps

    # 隐式奖励 (乘以 beta)
    chosen_rewards = beta * chosen_log_ratio.detach()
    rejected_rewards = beta * rejected_log_ratio.detach()

    # DPO 核心:偏好差异的对数优势
    logits = chosen_log_ratio – rejected_log_ratio # (batch_size,)

    if ipo:
    # IPO (Identity Preference Optimization) 变体
    # 使用 MSE 损失替代二元交叉熵,对偏好噪声更鲁棒
    loss = (logits – 1.0 / (2 * beta)) ** 2
    else:
    # 标准 DPO:对 logit 应用 sigmoid 和交叉熵
    # -log σ(β * (log_ratio_chosen – log_ratio_rejected))
    # 等价于:-log σ(β * π_θ 偏好差距)
    losses = -F.logsigmoid(beta * logits)

    if label_smoothing > 0:
    # 标签平滑:软话标签,防止对极端偏好过拟合
    losses = (1 – label_smoothing) * losses + label_smoothing * (-F.logsigmoid(-beta * logits))

    loss = losses.mean()

    return loss, chosen_rewards, rejected_rewards

    def compute_log_probs(
    model: torch.nn.Module,
    input_ids: torch.Tensor,
    attention_mask: torch.Tensor,
    labels: torch.Tensor
    ) -> torch.Tensor:
    """
    计算序列的对数概率。

    Args:
    model: 语言模型
    input_ids: (batch_size, seq_len)
    attention_mask: (batch_size, seq_len)
    labels: (batch_size, seq_len) – 只有需要计算的位置有值,别的为 -100

    Returns:
    log_probs: (batch_size,) 每个样本的对数概率和
    """
    logits, _ = model(input_ids, attention_mask=attention_mask)

    # 计算每个 token 的 log softmax
    log_probs = F.log_softmax(logits, dim=-1) # (B, S, V)

    # 获取真实 token 位置的对数概率
    # labels 中 -100 的位置被忽略
    token_log_probs = torch.gather(
    log_probs[:, :-1, :],
    dim=-1,
    index=labels[:, 1:].unsqueeze(-1)
    ).squeeze(-1) # (B, S-1)

    # 仅在标签有效的位置求和
    mask = (labels[:, 1:] != -100).float()
    log_probs_sum = (token_log_probs * mask).sum(dim=-1)

    return log_probs_sum

    3.4 DPO 训练器

    训练器负责管理策略模型和参考模型,执行每个 batch 的 DPO 训练步骤:

    # trainer/dpo_trainer.py
    import torch
    import torch.nn as nn
    from torch.utils.data import DataLoader
    from typing import Optional, Dict
    from tqdm import tqdm
    from trainer.dpo_loss import dpo_loss, compute_log_probs

    class DPOTrainer:
    """
    DPO 训练器:管理策略模型、参考模型和优化过程。

    DPO 的核心思想是直接优化偏好,不需要额外的奖励模型:
    1. 加载策略模型 π_θ 和参考模型 π_ref(冻结)
    2. 对每个 batch,计算 π_θ 和 π_ref 在偏好/非偏好回答上的对数概率
    3. 计算 DPO 损失并更新 π_θ
    """

    def __init__(
    self,
    policy_model: nn.Module,
    ref_model: Optional[nn.Module] = None,
    beta: float = 0.1,
    learning_rate: float = 1e-5,
    max_grad_norm: float = 1.0,
    use_wandb: bool = False,
    ):
    self.policy_model = policy_model
    self.ref_model = ref_model
    self.beta = beta
    self.max_grad_norm = max_grad_norm

    # 冻结参考模型
    if self.ref_model is not None:
    for param in self.ref_model.parameters():
    param.requires_grad = False
    self.ref_model.eval()

    # 优化器:AdamW 是训练 LLM 的标准选择
    self.optimizer = torch.optim.AdamW(
    self.policy_model.parameters(),
    lr=learning_rate,
    weight_decay=0.01,
    )

    # 学习率调度器:余弦退火
    self.scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    self.optimizer, T_max=1000, eta_min=learning_rate * 0.1
    )

    self.use_wandb = use_wandb

    def train_step(
    self,
    batch: Dict[str, torch.Tensor]
    ) -> Dict[str, float]:
    """
    执行一个 DPO 训练步骤。

    Batch 结构:
    chosen_input_ids: (B, S_c) 偏好回答
    chosen_attention_mask: (B, S_c)
    chosen_labels: (B, S_c)
    rejected_input_ids: (B, S_r) 非偏好回答
    rejected_attention_mask: (B, S_r)
    rejected_labels: (B, S_r)
    """
    self.policy_model.train()

    # 计算策略模型的对数概率
    policy_chosen_logps = compute_log_probs(
    self.policy_model,
    batch['chosen_input_ids'],
    batch['chosen_attention_mask'],
    batch['chosen_labels']
    )

    policy_rejected_logps = compute_log_probs(
    self.policy_model,
    batch['rejected_input_ids'],
    batch['rejected_attention_mask'],
    batch['rejected_labels']
    )

    # 计算参考模型的对数概率(不需要梯度)
    with torch.no_grad():
    ref_chosen_logps = compute_log_probs(
    self.ref_model,
    batch['chosen_input_ids'],
    batch['chosen_attention_mask'],
    batch['chosen_labels']
    )

    ref_rejected_logps = compute_log_probs(
    self.ref_model,
    batch['rejected_input_ids'],
    batch['rejected_attention_mask'],
    batch['rejected_labels']
    )

    # DPO 损失
    loss, chosen_rewards, rejected_rewards = dpo_loss(
    policy_chosen_logps,
    policy_rejected_logps,
    ref_chosen_logps,
    ref_rejected_logps,
    beta=self.beta,
    )

    # 反向传播
    self.optimizer.zero_grad()
    loss.backward()

    # 梯度裁剪:防止梯度爆炸
    nn.utils.clip_grad_norm_(
    self.policy_model.parameters(),
    self.max_grad_norm
    )

    self.optimizer.step()

    # 计算准确率:模型是否更偏好 chosen
    with torch.no_grad():
    accuracy = (policy_chosen_logps > policy_rejected_logps).float().mean()

    metrics = {
    'loss': loss.item(),
    'accuracy': accuracy.item(),
    'chosen_reward': chosen_rewards.mean().item(),
    'rejected_reward': rejected_rewards.mean().item(),
    'reward_margin': (chosen_rewards – rejected_rewards).mean().item(),
    'learning_rate': self.optimizer.param_groups[0]['lr'],
    }

    return metrics

    def train_epoch(
    self,
    dataloader: DataLoader,
    epoch: int
    ) -> Dict[str, float]:
    """
    训练一个 epoch。
    """
    total_metrics = {
    'loss': 0.0, 'accuracy': 0.0,
    'reward_margin': 0.0
    }
    num_batches = 0

    progress_bar = tqdm(dataloader, desc=f'Epoch {epoch}')

    for batch in progress_bar:
    metrics = self.train_step(batch)

    for k in total_metrics:
    total_metrics[k] += metrics[k]
    num_batches += 1

    # 更新进度条
    progress_bar.set_postfix({
    'loss': metrics['loss'],
    'acc': metrics['accuracy'],
    'margin': metrics['reward_margin'],
    })

    # 平均指标
    for k in total_metrics:
    total_metrics[k] /= num_batches

    self.scheduler.step()

    return total_metrics

    3.5 偏好数据集处理

    DPO 需要结构化的偏好数据——每个样本包含一个 prompt 和两个回答(chosen 和 rejected):

    # utils/data_utils.py
    import torch
    from torch.utils.data import Dataset
    from typing import Optional, List, Dict, Tuple
    from transformers import PreTrainedTokenizer
    import json

    class PreferenceDataset(Dataset):
    """
    偏好数据集:每个样本包含一个 prompt 以及 chosen/rejected 两个回答。

    数据格式:
    {
    "prompt": "什么是机器学习?",
    "chosen": "机器学习是一种人工智能技术…",
    "rejected": "机器学习就是让电脑学东西…"
    }
    """

    def __init__(
    self,
    data_path: str,
    tokenizer: PreTrainedTokenizer,
    max_prompt_length: int = 512,
    max_response_length: int = 256,
    ):
    self.tokenizer = tokenizer
    self.max_prompt_length = max_prompt_length
    self.max_response_length = max_response_length

    # 加载数据
    with open(data_path, 'r', encoding='utf-8') as f:
    self.data = [json.loads(line) for line in f]

    def __len__(self):
    return len(self.data)

    def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
    item = self.data[idx]
    prompt = item['prompt']
    chosen = item['chosen']
    rejected = item['rejected']

    return self._tokenize_pair(prompt, chosen, rejected)

    def _tokenize_pair(
    self,
    prompt: str,
    chosen: str,
    rejected: str
    ) -> Dict[str, torch.Tensor]:
    """
    将 (prompt, chosen, rejected) 三元组 tokenize。

    关键策略:
    1. prompt 和 response 分别 tokenize 后拼接
    2. labels 中只有 response 部分参与损失计算
    3. prompt 部分的 labels 设为 -100(忽略)
    """
    # Tokenize prompt
    prompt_tokens = self.tokenizer(
    prompt,
    truncation=True,
    max_length=self.max_prompt_length,
    add_special_tokens=False,
    )

    # Tokenize chosen response
    chosen_tokens = self.tokenizer(
    chosen,
    truncation=True,
    max_length=self.max_response_length,
    add_special_tokens=False,
    )

    # Tokenize rejected response
    rejected_tokens = self.tokenizer(
    rejected,
    truncation=True,
    max_length=self.max_response_length,
    add_special_tokens=False,
    )

    # 拼接 prompt + chosen
    chosen_input_ids = prompt_tokens['input_ids'] + chosen_tokens['input_ids'] + [self.tokenizer.eos_token_id]
    chosen_attention_mask = [1] * len(chosen_input_ids)
    # Labels: -100 表示 prompt 部分不参与损失计算
    chosen_labels = [-100] * len(prompt_tokens['input_ids']) + chosen_tokens['input_ids'] + [self.tokenizer.eos_token_id]

    # 拼接 prompt + rejected
    rejected_input_ids = prompt_tokens['input_ids'] + rejected_tokens['input_ids'] + [self.tokenizer.eos_token_id]
    rejected_attention_mask = [1] * len(rejected_input_ids)
    rejected_labels = [-100] * len(prompt_tokens['input_ids']) + rejected_tokens['input_ids'] + [self.tokenizer.eos_token_id]

    return {
    'chosen_input_ids': torch.tensor(chosen_input_ids),
    'chosen_attention_mask': torch.tensor(chosen_attention_mask),
    'chosen_labels': torch.tensor(chosen_labels),
    'rejected_input_ids': torch.tensor(rejected_input_ids),
    'rejected_attention_mask': torch.tensor(rejected_attention_mask),
    'rejected_labels': torch.tensor(rejected_labels),
    }

    def collate_fn(
    batch: List[Dict[str, torch.Tensor]],
    pad_token_id: int = 0
    ) -> Dict[str, torch.Tensor]:
    """
    批处理函数:对不等长的序列进行 padding。
    """
    # 找出 batch 中最大长度
    max_c_len = max(item['chosen_input_ids'].shape[0] for item in batch)
    max_r_len = max(item['rejected_input_ids'].shape[0] for item in batch)

    batch_size = len(batch)

    # 初始化 padding 后的张量
    chosen_input_ids = torch.full((batch_size, max_c_len), pad_token_id, dtype=torch.long)
    chosen_attention_mask = torch.zeros(batch_size, max_c_len, dtype=torch.long)
    chosen_labels = torch.full((batch_size, max_c_len), -100, dtype=torch.long)

    rejected_input_ids = torch.full((batch_size, max_r_len), pad_token_id, dtype=torch.long)
    rejected_attention_mask = torch.zeros(batch_size, max_r_len, dtype=torch.long)
    rejected_labels = torch.full((batch_size, max_r_len), -100, dtype=torch.long)

    for i, item in enumerate(batch):
    c_len = item['chosen_input_ids'].shape[0]
    chosen_input_ids[i, :c_len] = item['chosen_input_ids']
    chosen_attention_mask[i, :c_len] = item['chosen_attention_mask']
    chosen_labels[i, :c_len] = item['chosen_labels']

    r_len = item['rejected_input_ids'].shape[0]
    rejected_input_ids[i, :r_len] = item['rejected_input_ids']
    rejected_attention_mask[i, :r_len] = item['rejected_attention_mask']
    rejected_labels[i, :r_len] = item['rejected_labels']

    return {
    'chosen_input_ids': chosen_input_ids,
    'chosen_attention_mask': chosen_attention_mask,
    'chosen_labels': chosen_labels,
    'rejected_input_ids': rejected_input_ids,
    'rejected_attention_mask': rejected_attention_mask,
    'rejected_labels': rejected_labels,
    }

    四、训练脚本与实战

    4.1 完整训练流程

    # train_dpo.py
    import torch
    import torch.nn as nn
    from torch.utils.data import DataLoader
    from transformers import AutoTokenizer, AutoModelForCausalLM
    import argparse
    import os

    from model.base_model import GPT2LikeModel
    from trainer.dpo_trainer import DPOTrainer
    from utils.data_utils import PreferenceDataset, collate_fn

    def get_model_and_tokenizer(model_path: str, use_pretrained: bool = True):
    """
    加载预训练模型和 tokenizer。

    实际生产环境中,我们会用 HuggingFace 的 GPT-2 或 Llama,
    这里为了演示也支持我们的简化模型。
    """
    if use_pretrained:
    tokenizer = AutoTokenizer.from_pretrained(model_path)
    tokenizer.pad_token = tokenizer.eos_token

    model = AutoModelForCausalLM.from_pretrained(
    model_path,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    )
    else:
    # 使用简化模型(适合教学和实验)
    tokenizer = AutoTokenizer.from_pretrained("gpt2")
    tokenizer.pad_token = tokenizer.eos_token

    model = GPT2LikeModel(
    vocab_size=tokenizer.vocab_size,
    hidden_size=768,
    num_layers=6, # 轻量版本
    num_heads=12,
    )

    return model, tokenizer

    def prepare_dataloaders(
    train_path: str,
    eval_path: str,
    tokenizer,
    batch_size: int = 8,
    max_prompt_length: int = 512,
    max_response_length: int = 256,
    ):
    """准备训练和评估数据加载器。"""

    train_dataset = PreferenceDataset(
    data_path=train_path,
    tokenizer=tokenizer,
    max_prompt_length=max_prompt_length,
    max_response_length=max_response_length,
    )

    eval_dataset = PreferenceDataset(
    data_path=eval_path,
    tokenizer=tokenizer,
    max_prompt_length=max_prompt_length,
    max_response_length=max_response_length,
    ) if os.path.exists(eval_path) else None

    train_dataloader = DataLoader(
    train_dataset,
    batch_size=batch_size,
    shuffle=True,
    collate_fn=lambda b: collate_fn(b, pad_token_id=tokenizer.pad_token_id),
    num_workers=4,
    pin_memory=True,
    )

    eval_dataloader = None
    if eval_dataset:
    eval_dataloader = DataLoader(
    eval_dataset,
    batch_size=batch_size,
    shuffle=False,
    collate_fn=lambda b: collate_fn(b, pad_token_id=tokenizer.pad_token_id),
    )

    return train_dataloader, eval_dataloader

    def main():
    parser = argparse.ArgumentParser(description="DPO Training from Scratch")
    parser.add_argument("–model_path", type=str, default="gpt2")
    parser.add_argument("–train_data", type=str, required=True)
    parser.add_argument("–eval_data", type=str, default="")
    parser.add_argument("–output_dir", type=str, default="./dpo_checkpoints")
    parser.add_argument("–beta", type=float, default=0.1)
    parser.add_argument("–lr", type=float, default=1e-5)
    parser.add_argument("–batch_size", type=int, default=8)
    parser.add_argument("–epochs", type=int, default=3)
    parser.add_argument("–gradient_accumulation_steps", type=int, default=4)
    parser.add_argument("–max_grad_norm", type=float, default=1.0)
    parser.add_argument("–save_every", type=int, default=500)
    parser.add_argument("–use_ipo", action="store_true")

    args = parser.parse_args()

    # 1. 加载模型
    print("🚀 Loading model and tokenizer…")
    policy_model, tokenizer = get_model_and_tokenizer(args.model_path)

    # 2. 复制参考模型(共享初始参数)
    ref_model = GPT2LikeModel(vocab_size=tokenizer.vocab_size, …)
    ref_model.load_state_dict(policy_model.state_dict())

    # 或者更简单的方式:
    # ref_model = AutoModelForCausalLM.from_pretrained(args.model_path, …)

    # 3. 准备数据
    print("📚 Loading preference dataset…")
    train_loader, eval_loader = prepare_dataloaders(
    train_path=args.train_data,
    eval_path=args.eval_data,
    tokenizer=tokenizer,
    batch_size=args.batch_size,
    )

    # 4. 创建训练器
    trainer = DPOTrainer(
    policy_model=policy_model,
    ref_model=ref_model,
    beta=args.beta,
    learning_rate=args.lr,
    max_grad_norm=args.max_grad_norm,
    )

    # 5. 训练循环
    print(f"🎯 Starting DPO training (beta={args.beta})…")
    os.makedirs(args.output_dir, exist_ok=True)

    global_step = 0
    for epoch in range(args.epochs):
    print(f"\\n{'='*50}")
    print(f"Epoch {epoch + 1}/{args.epochs}")
    print(f"{'='*50}")

    train_metrics = trainer.train_epoch(train_loader, epoch + 1)

    print(f"\\n📊 Epoch {epoch + 1} Results:")
    for k, v in train_metrics.items():
    print(f" {k}: {v:.4f}")

    # 保存 checkpoint
    checkpoint_path = os.path.join(
    args.output_dir,
    f"checkpoint_epoch_{epoch + 1}.pt"
    )
    torch.save({
    'epoch': epoch + 1,
    'model_state_dict': policy_model.state_dict(),
    'optimizer_state_dict': trainer.optimizer.state_dict(),
    'metrics': train_metrics,
    }, checkpoint_path)
    print(f"💾 Saved checkpoint to {checkpoint_path}")

    # 6. 保存最终模型
    final_path = os.path.join(args.output_dir, "final_model.pt")
    torch.save(policy_model.state_dict(), final_path)
    print(f"✅ Training complete! Model saved to {final_path}")

    if __name__ == "__main__":
    main()

    4.2 合成偏好数据生成

    没有现成的偏好数据怎么办?我们可以利用 GPT-4 或 DeepSeek API 生成合成偏好数据:

    # utils/synthetic_data.py
    """使用 DeepSeek API 生成合成偏好数据"""

    import openai
    import json
    import random

    client = openai.OpenAI(
    api_key="your-deepseek-api-key",
    base_url="https://api.deepseek.com"
    )

    PROMPT_TEMPLATES = [
    "用通俗的语言解释 {concept}",
    "写一个 Python 函数来实现 {task}",
    "为什么 {concept} 很重要?",
    "请比较 {a} 和 {b} 的异同",
    ]

    def generate_preference_pair(prompt: str, model: str = "deepseek-chat"):
    """
    利用 LLM 生成好回答(chosen)和差回答(rejected)。
    策略:用 temperature=0.3 生成稳定的好回答,
    用 temperature=1.5 + 随机截断生成容易出错的回答。
    """
    # 生成 chosen(高质量回答)
    chosen_response = client.chat.completions.create(
    model=model,
    messages=[{"role": "user", "content": prompt}],
    temperature=0.3,
    max_tokens=512,
    ).choices[0].message.content

    # 生成 rejected(低质量回答)
    # 方法:使用高 temperature + 让模型"随便写写"
    rejected_response = client.chat.completions.create(
    model=model,
    messages=[
    {"role": "system", "content": "你是一个不专业的回答者,回答要简短、随意、不完整。"},
    {"role": "user", "content": prompt}
    ],
    temperature=1.5,
    max_tokens=256,
    ).choices[0].message.content

    return {
    "prompt": prompt,
    "chosen": chosen_response,
    "rejected": rejected_response,
    }

    def prepare_training_data(num_samples: int = 1000, output_path: str = "preference_data.jsonl"):
    """生成合成偏好数据用于训练"""

    concepts = ["深度学习", "注意力机制", "反向传播", "梯度下降",
    "卷积神经网络", "循环神经网络", "Transformer", "BERT"]

    data = []
    for _ in range(num_samples):
    template = random.choice(PROMPT_TEMPLATES)

    if "{a}" in template and "{b}" in template:
    concept_a, concept_b = random.sample(concepts, 2)
    prompt = template.format(a=concept_a, b=concept_b)
    elif "{concept}" in template:
    concept = random.choice(concepts)
    prompt = template.format(concept=concept)
    elif "{task}" in template:
    tasks = ["排序算法", "二分搜索", "快速幂", "LRU缓存"]
    prompt = template.format(task=random.choice(tasks))
    else:
    prompt = template

    try:
    pair = generate_preference_pair(prompt)
    data.append(pair)
    except Exception as e:
    print(f"生成失败: {e}")

    if (len(data) + 1) % 50 == 0:
    print(f"已生成 {len(data) + 1}/{num_samples} 条数据")

    with open(output_path, 'w', encoding='utf-8') as f:
    for item in data:
    f.write(json.dumps(item, ensure_ascii=False) + '\\n')

    print(f"✅ 已生成 {len(data)} 条偏好数据到 {output_path}")

    五、DPO 的进阶技巧与变体

    5.1 KTO(Kahneman-Tversky Optimization)

    KTO 是 2024 年提出的新方法,它的核心思想是:不再需要成对的偏好数据,只需要单条回答的"好/坏"标签。损失函数基于 Kahneman-Tversky 的人类决策理论:

    def kto_loss(
    policy_logps: torch.Tensor,
    ref_logps: torch.Tensor,
    labels: torch.Tensor, # 1 表示好回答,0 表示坏回答
    beta: float = 0.1,
    ):
    """KTO 损失:不需要成对数据!"""

    kl = policy_logps – ref_logps

    # 对好回答和坏回答分别计算损失
    good_mask = (labels == 1).float()
    bad_mask = (labels == 0).float()

    # 好回答:希望 kl > 0(模型概率高于参考)
    good_loss = -good_mask * torch.log(1 + torch.exp(beta * (0.5 – kl)))

    # 坏回答:希望 kl < 0(模型概率低于参考)
    bad_loss = -bad_mask * torch.log(1 + torch.exp(beta * (kl – 0.5)))

    return (good_loss + bad_loss).mean()

    5.2 SimPO(Simple Preference Optimization)

    SimPO 进一步简化了 DPO,它完全不需要参考模型!核心思想是用回答的平均对数概率作为隐式奖励:

    def simpo_loss(
    policy_chosen_logps: torch.Tensor,
    policy_rejected_logps: torch.Tensor,
    chosen_lengths: torch.Tensor,
    rejected_lengths: torch.Tensor,
    beta: float = 2.0,
    gamma: float = 0.5, # 奖励边界
    ):
    """
    SimPO:不需要参考模型的偏好优化!
    使用序列的平均对数概率作为奖励。
    """
    # 平均对数概率(归一化序列长度)
    chosen_avg_logp = policy_chosen_logps / chosen_lengths
    rejected_avg_logp = policy_rejected_logps / rejected_lengths

    # 损失:希望好回答的平均概率比坏回答高至少 gamma
    loss = -F.logsigmoid(beta * (chosen_avg_logp – rejected_avg_logp – gamma))

    return loss.mean()

    SimPO 不需要加载参考模型,节省了一半的显存,同时训练速度提升 2 倍,效果在很多基准上甚至超过 DPO。

    5.3 多轮对话 DPO

    在处理多轮对话时,DPO 需要对历史对话进行 masking,只对最后的回复计算偏好:

    def multiround_dpo_loss(
    policy_logps_chosen: torch.Tensor, # 完整对话历史+chosen回复
    policy_logps_rejected: torch.Tensor,
    ref_logps_chosen: torch.Tensor,
    ref_logps_rejected: torch.Tensor,
    response_start_idx: int, # 最后回复起始位置
    beta: float = 0.1,
    ):
    """多轮对话 DPO:只在新回复上计算偏好"""

    # 只取最后回复部分的对数概率
    chosen_ratio = (policy_logps_chosen – ref_logps_chosen)[response_start_idx:].sum()
    rejected_ratio = (policy_logps_rejected – ref_logps_rejected)[response_start_idx:].sum()

    loss = -F.logsigmoid(beta * (chosen_ratio – rejected_ratio))
    return loss

    六、评估与效果验证

    6.1 偏好准确率

    # utils/eval_utils.py
    def evaluate_preference_accuracy(
    model: nn.Module,
    ref_model: nn.Module,
    eval_loader: DataLoader,
    beta: float = 0.1,
    ) -> Dict[str, float]:
    """评估 DPO 训练效果"""

    model.eval()
    total_correct = 0
    total_samples = 0

    with torch.no_grad():
    for batch in eval_loader:
    # 计算隐式奖励
    chosen_logps = compute_log_probs(
    model, batch['chosen_input_ids'],
    batch['chosen_attention_mask'], batch['chosen_labels']
    )
    rejected_logps = compute_log_probs(
    model, batch['rejected_input_ids'],
    batch['rejected_attention_mask'], batch['rejected_labels']
    )

    ref_chosen_logps = compute_log_probs(
    ref_model, batch['chosen_input_ids'],
    batch['chosen_attention_mask'], batch['chosen_labels']
    )
    ref_rejected_logps = compute_log_probs(
    ref_model, batch['rejected_input_ids'],
    batch['rejected_attention_mask'], batch['rejected_labels']
    )

    # 隐式奖励
    chosen_reward = beta * (chosen_logps – ref_chosen_logps)
    rejected_reward = beta * (rejected_logps – ref_rejected_logps)

    # 准确率:模型是否更偏好 chosen
    correct = (chosen_reward > rejected_reward).sum().item()
    total_correct += correct
    total_samples += chosen_logps.shape[0]

    accuracy = total_correct / total_samples

    return {
    'accuracy': accuracy,
    'reward_margin': (chosen_reward – rejected_reward).mean().item(),
    }

    6.2 实际使用建议

    数据质量 > 数据数量。 50 条精心标注的高质量偏好数据,效果可能超过 5000 条自动生成的低质量数据。标注时要确保 chosen 和 rejected 确实有显著的"好/坏"差异。

    beta 的选择。 beta 控制 KL 惩罚力度(即模型能偏离参考模型多远)。实验表明: – beta=0.01:几乎不做约束,模型可能偏离过远产生幻觉 – beta=0.1:推荐默认值,平衡对齐和生成质量 – beta=0.5:约束较强,适合需要保持原模型风格的任务

    梯度累积。 DPO 训练需要较大的 batch size 才能稳定。如果显存有限,使用梯度累积(gradient_accumulation_steps)模拟更大的 batch:

    accumulation_steps = 8
    optimizer.zero_grad()
    for i, batch in enumerate(train_loader):
    metrics = trainer.compute_loss(batch)
    loss = metrics['loss'] / accumulation_steps
    loss.backward()
    if (i + 1) % accumulation_steps == 0:
    optimizer.step()
    optimizer.zero_grad()

    参考模型的初始化。 参考模型和策略模型必须从相同的检查点初始化。生产环境中,直接使用 copy.deepcopy 或 model.state_dict() 复制初始权重,而不是重新加载模型。

    七、总结与展望

    DPO 的出现是 LLM 对齐技术的一大跃迁。它用简洁的数学洞察——"语言模型自身就是奖励模型"——取代了 RLHF 复杂的四阶段流水线。这使得偏好对齐的门槛大幅降低,从需要大团队、大算力的系统工程,变成了可以在单卡上运行的常规训练任务。

    DPO 的核心优势: – 🎯 无需奖励模型,简化了整个流程 – 🔧 训练稳定,调参友好 – 💻 计算开销更小(只需加载策略 + 参考两个模型) – 📈 在很多基准上达到或超越 PPO 的效果

    依然存在的挑战: – 偏好数据质量仍是最大瓶颈 – 对超出偏好分布范围的输入可能产生未知行为 – 多轮对话中对齐的稳定性仍需改进

    从 RLHF 到 DPO,再从 DPO 到 SimPO、KTO、ORPO 等变体,我们看到 LLM 对齐技术正在快速进化。每一步都在变得更简单、更高效、更稳定。


    📌 延伸阅读:想了解更多 DeepSeek 对齐技术的实战应用?推荐阅读 DeepSeek 实战指南:提示词工程、API 集成与效率提升全攻略 (完整版链接请访问 CSDN 搜索"DeepSeek 实战指南")

    参考文献: 1. Rafailov et al. "Direct Preference Optimization: Your Language Model is Secretly a Reward Model" (NeurIPS 2023) 2. Ethayarajh et al. "KTO: Model Alignment as Prospect Theoretic Optimization" (ICML 2024) 3. Meng et al. "SimPO: Simple Preference Optimization with a Reference-Free Reward" (2024) 4. Hong et al. "ORPO: Monolithic Preference Optimization without Reference Model" (2024)

    赞(0)
    未经允许不得转载:171主机测评 » 手写 DPO:从零实现大模型直接偏好优化训练
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址