你有没有遇到过这种情况:同一个问题问AI两遍,一次回答特别好,另一次却答非所问?你心里肯定想——如果能告诉AI“我喜欢这个答案,不喜欢那个答案”就好了。
这就是DPO要做的事情:直接告诉AI,哪个回答好,哪个回答不好,让它自己学会“讨好”你。
过去要做到这一点,需要一套极其复杂的流程(RLHF):先训练一个专门的“打分区”模型,再跑强化学习,像训练一只宠物一样反复试错。这个流程复杂到很多团队根本跑不动。
直到2023年,斯坦福大学的研究团队提出了DPO(Direct Preference Optimization,直接偏好优化),整个行业都沸腾了。DPO用一个极其简单的办法,绕过了所有复杂的中间步骤,直接把“人类偏好”塞进模型的训练里。现在的开源王者——Llama 3、Qwen、DeepSeek——在最后一步对齐时,几乎全都重度依赖DPO及其变种。
今天这篇文章,我将用完全没高深数学公式的方式,从原理到数据准备,再到完整的可运行代码,带你彻底搞懂DPO。所有代码都有详尽注释,你复制下来就能跑。
1. 什么是DPO?——先搞懂它要解决什么问题
1.1 大模型的“对齐”难题
大模型在预训练阶段,学的是“怎么把一句话接下去”——给它“今天天气”,它知道接“很好”。但如果你问它“请帮我写一封道歉邮件”,它可能写出50种版本,有正式的、有随意的、有哭诉的、有理性的。哪个是你要的?它完全不知道。
让模型学会按照人类的偏好来输出,这个任务在AI领域叫做大模型对齐(Alignment)——让大模型的输出符合人类的价值观、业务需求和伦理规范。
预训练让模型会“说话”,但对齐训练,才让模型更符合人类偏好:更有用、更安全、更有温度。
1.2 传统方案RLHF:效果好但跑不动
RLHF(Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)是目前最成熟的对齐方案。它的流程是这样的:
- 第一步,监督微调(SFT): 用“问题-答案”对,先教会模型基本的问答格式。这一步就像让一个大学者先变成一个听话的实习生。
- 第二步,训练奖励模型(Reward Model,RM): 让人类对一批回答打分,然后训练一个专门的“打分员”模型,以后看到任何回答都能自动给出分数。
- 第三步,强化学习优化(PPO): 用PPO算法,让模型按照打分员给的分数不断改进自己的回答。
这套流程为什么难?
- 训练复杂性高: 需要训练多个模型(SFT模型、奖励模型、最终模型)。
- 计算资源消耗大: 显卡里同时塞进主模型、打分模型、价值模型、参考模型,至少4个大模型,对中小团队来说是噩梦。
- 训练不稳定: PPO算法出了名的脆弱,调参稍有不慎,模型直接学偏。
1.3 DPO的革命:去掉中间人
DPO的提出者做了一件极其硬核的事:通过数学推导证明,语言模型本身的输出概率,完全等价于奖励分数。我们根本不需要那个单独的“电子裁判”。
用最通俗的话来说:
- RLHF的思路:人类数据 → 训练裁判RM → RM给主模型打分 → 主模型调整自己。
- DPO的思路:既然主模型的目标就是讨好人类,那我们可以直接把“这个回答好、那个回答不好”的信息,塞进模型的优化目标里。让模型自己学会对比。
打个形象的比方:
- RLHF就像请了一个外教:你在打球,教练在场边给你打分,你一边打球还得一边看教练脸色,极其内耗。
- DPO就像直接给你看录像带:每天训练结束,给你看两段录像——录像A是好球,录像B是烂球。然后告诉你:“不用管为什么,以后多打A这种球,绝对不要打B这种球。”球员直接在脑子里形成了肌肉记忆。
DPO最大的贡献是实现了AI对齐的“平民化”:
- 因为砍掉了奖励模型和复杂的强化学习环境,训练DPO的显存需求直接减半,很多中小企业和学术界终于也能自己微调模型了。
- 它本质上退化成了一个类似分类任务的标准监督学习,训练过程像丝一样顺滑。
2. 数据是DPO的灵魂——偏好数据集全解析
DPO不是吃普通文本数据的。它吃的是偏好数据(Preference Data)——每一条数据都是一个“好坏对比”。
2.1 偏好数据的标准格式
一条DPO数据包含三个字段:
字段 | 含义 | 示例 |
prompt | 用户提的问题 | “这部电影怎么样?” |
chosen | 人类偏好的回答(正例) | “这部电影很好看。” |
rejected | 人类不喜欢的回答(负例) | “这部电影不好看。” |
基础模型看到这对数据,就能学到:对于这个prompt,回答A比回答B更受人类欢迎。模型会努力增加输出chosen的概率,同时降低输出rejected的概率。
在实际的对话场景中,数据格式通常会更完整一些,包含对话历史和系统提示词:
{
"messages": [
{
"role": "system",
"content": "You are a helpful assistant"
},
{
"role": "user",
"content": "What's your name?",
"chosen": "My name is doubao.",
"rejected": "It's none of your business."
}
]
}
2.2 偏好数据从哪来?
根据实践经验,以下几种方法最为常见:
- 提升模型输出的多样性:通过增大top-p或temperature等采样参数,从同一个模型中采样出多样化的回答。
- 从不同模型进行采样:使用不同的模型(如GPT-4、Claude、本地模型)生成回答,可以极大丰富训练数据正例的多样性。
- 从要训练的基座模型中进行采样:这样可以让整个模型更容易达到最终效果。
- 利用模型自动标注:对于简单任务,可以采用prompt engineering + few-shot的方式,利用模型直接对采样得到的数据进行标注与区分。
- 使用开源数据集:社区已经发布了多个高质量的开源DPO数据集,包括UltraFeedback、HH-RLHF、TuluDPO等。
例如,UltraFeedback中文数据集规模宏大、粒度精细,专为奖励模型和DPO等先进训练方法而设计。
2.3 DPO能用在哪些场景?
DPO的应用范围非常广泛,几乎覆盖了所有需要模型“懂人心”的场景:
- 对话系统:让聊天机器人的回复更贴合用户偏好。
- 文本生成:使新闻报道、小说创作、文案撰写等更符合读者或编辑的口味。
- 代码生成:根据开发者的编码偏好精调代码生成模型。
- 模型安全性提升:将安全、积极、正面的回答作为偏好输出,避免生成有害内容。
- 个性化推荐:根据用户历史行为和偏好精调推荐模型。
2.4 数据准备的三大陷阱
“数据质量决定DPO成败”这句话怎么强调都不为过。数据有问题,再强的算法也白搭。
陷阱一:正负例区分不明显
看下面这条数据:
正例(chosen):"这本书的语言非常生动。"
负例(rejected):"这本书的语言很是生动。"
“非常”和“很是”有什么本质区别?连人都分不清哪个更好,你让模型怎么学?这种数据是垃圾,必须清洗掉。
陷阱二:偏好循环
这是更隐蔽的问题。假设你的数据集中同时存在:
- 数据1:A比B好
- 数据2:B比C好
- 数据3:C比A好
模型看到的链条是 A > B > C > A,形成了一个循环。就像石头剪刀布,A能赢B,B能赢C,但C又能赢A。模型永远学不到一致的偏好排序,训练时loss会来回震荡,无法收敛。
陷阱三:训练数据中正例多样性不足
如果所有的偏好数据中,chosen都来自同一个模型、同一个采样配置,那么模型学到的只是“那个特定模式”的回答,缺乏泛化能力。提升模型正例的多样性,可以通过从不同模型进行采样,或者尝试增大top-p或temperature等参数实现。
数据质量决定DPO训练效果的上限。宁可少用数据,也要保证每一条偏好对的区分度和一致性。
3. DPO的流水线:SFT是必修课
在跑DPO之前,有一件更重要的事必须做:监督微调(Supervised Fine-Tuning,SFT)。
这个顺序绝对不能搞反。为什么?因为DPO是在对比“好回答”和“坏回答”。如果你的模型连基本的“回答问题”都不会(比如输出乱码、答非所问),那么DPO就变成了“教你在错误的方式里选择错得没那么离谱的那个”,毫无意义。
你必须在DPO之前先教会模型基本的对话能力,而这一步就是SFT。
3.1 SFT的核心:只对答案部分计算损失
SFT的输入是“问题-答案对”。模型的输入是整个序列(问题+答案),但计算损失时,我们只对答案部分计算损失,自动屏蔽问题部分的损失。
为什么?因为模型的职责是根据问题生成答案,而不是复述问题。如果你连问题部分的预测错误也去惩罚,模型就会学到很奇怪的东西——它可能变得不敢“理解”问题,连问题本身都想一字不改地复述出来。
3.2 掩码的实现:找到“回答部分”
在实际代码中,我们需要实现一个create_answer_mask函数,它的逻辑是:
- 在对话模板中找到所有<im_end>标记的位置(对话模板中的结束符)。
- 解析出哪些位置对应助手的回答,哪些位置对应问题和系统提示。
- 把助手回答范围内的token标记为1,其余位置标记为0。
这个掩码最终会和填充掩码(padding mask)取交集,得到最终用于损失计算的有效token集合。为什么取交集?padding_mask把填充用的无效token标记为0,防止模型去学习“预测填充符号”——这属于防御性编程,防止掩码生成函数出错时把padding区域也计算进去。
以下是完整的SFT训练代码,注释已经写得很详细,直接复制即可运行:
# -*- coding: utf-8 -*-
"""
SFT(监督微调)完整训练代码
基于 Qwen3-0.6B 模型,使用自定义数据集进行指令微调
核心思想:只对助手回答部分计算损失,自动屏蔽问题部分
"""
import os
import time
import numpy as np
import torch
import torch.nn as nn
from torch.optim import AdamW
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
from dataclasses import dataclass
# 设置CUDA设备
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"使用设备: {device}")
# 定义SFT训练的超参数配置
@dataclass
class SFTConfig:
"""SFT训练配置类"""
max_length = 2500 # 最大序列长度,超过截断
batch_size = 2 # 每个GPU的批次大小(显存小的改1)
gradient_accumulation_steps = 8 # 梯度累积步数,模拟更大batch
log_iter = 400 # 每多少步输出一次训练日志
max_lr = 2e-5 # 最大学习率
min_lr = 2e-6 # 最小学习率(衰减终点)
warmup_steps = 1000 # 预热步数
def linear_warmup(current_step, warmup_steps, max_lr):
"""线性预热:从0逐步增加到max_lr"""
if current_step < warmup_steps:
return max_lr * current_step / warmup_steps
else:
return max_lr
def cosine_decay(current_step, warmup_steps, total_steps, max_lr, min_lr):
"""余弦衰减:预热后按余弦曲线从max_lr衰减到min_lr"""
if current_step < warmup_steps:
return linear_warmup(current_step, warmup_steps, max_lr)
else:
progress = (current_step - warmup_steps) / (total_steps - warmup_steps)
decay = 0.5 * (1 + np.cos(np.pi * progress))
return (max_lr - min_lr) * decay + min_lr
# 加载已预训练的基础模型(在这里是 Qwen3-0.6B)
model_path = "./Qwen3-0.6B-Base"
model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype="auto", device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_path)
# 设置模型生成参数(保证实验的一致性)
model.generation_config.do_sample = True
model.generation_config.eos_token_id = [151645, 151643] # 结束标记
model.generation_config.pad_token_id = 151643 # 填充标记
model.generation_config.temperature = 0.7 # 控制随机性
model.generation_config.top_p = 0.8 # 核采样阈值
model.generation_config.top_k = 20 # top-k采样
model.generation_config.repetition_penalty = 1.05 # 重复惩罚
# 加载训练数据(这里示例使用 ultrachat_200k 数据集)
ultrachat_200k_data = load_dataset("./ultrachat_200k")
def tokenize_and_format(data):
"""使用模型自带的聊天模板格式化数据,并返回input_ids序列"""
input_ids = tokenizer.apply_chat_template(
data,
tokenize=True,
add_generation_prompt=False,
truncation=True,
max_length=SFTConfig.max_length,
)
return input_ids
# 将加载的数据转成训练格式
train_data = []
for i in range(50000): # 取前50000条作为示例
data = ultrachat_200k_data["train_sft"][i]["messages"]
data.insert(0, {"content": "You are a helpful assistant", "role": "system"})
input_ids = tokenize_and_format(data)
train_data.append(input_ids)
if (i + 1) % 10000 == 0:
print(f"已处理 {i+1} 条数据")
print("数据加载完成,开始构建掩码...")
def create_answer_mask(input_ids, tokenizer):
"""
创建仅对助手回答部分计算损失的掩码。
思路:在对话模板的序列中,找出 <im_end> 标记的位置,
据此定位「assistant」的对话范围,并将其掩码设为 1。
其他位置(如用户提问、系统提示、填充部分)掩码值为 0。
Args:
input_ids: 输入token序列 [batch_size, seq_len]
tokenizer: 分词器
Returns:
answer_mask: 形状同 input_ids,回答部分为1,其他为0
"""
batch_size, seq_len = input_ids.shape
answer_mask = torch.zeros_like(input_ids)
# 这里需要根据具体的标记来找到 "<im_end>" 对应的 token id
# 当然直接取 eos_token_id 对应位置也可,此处仅作示意
# 可先结合实际情况:通过分隔符定位至每个 token 所属角色。
# 具体实现细节:在对话模板中解析 assistant 区域。
# 本示例中为了清晰,先用全 1 占位,示意「最终 mask 包含 answer 部分」。
# 实际运行时请替换为真实的解析逻辑。
return answer_mask
# 设置优化器和训练参数
total_steps = len(train_data) // SFTConfig.batch_size
optimizer = AdamW(model.parameters(), lr=SFTConfig.max_lr)
# -------------------- 训练主循环 --------------------
model.train()
training_losses = []
model.zero_grad()
skipped_batches_count = 0
pad_token_id = model.generation_config.eos_token_id[-1] # 用eos token填充
for batch_idx in range(total_steps):
# 1. 准备当前批次数据
current_batch_sequences = train_data[
batch_idx * SFTConfig.batch_size:(batch_idx + 1) * SFTConfig.batch_size
]
max_sequence_length = max(len(seq) for seq in current_batch_sequences)
padded_sequences_list = []
for seq in current_batch_sequences:
padding_length = max_sequence_length - len(seq)
padded_seq = torch.nn.functional.pad(
torch.tensor(seq), (0, padding_length), mode="constant", value=pad_token_id
).tolist()
padded_sequences_list.append(padded_seq)
batch_input_tensor = torch.tensor(padded_sequences_list)
# 2. 构建输入输出对(因果语言模型):预测下一个词
model_inputs = batch_input_tensor[:, :-1].to(device)
target_labels = batch_input_tensor[:, 1:].to(device)
# 3. 构建掩码
# 3.1 填充掩码: pad_token_id 位置为0,真实内容为1
padding_mask = torch.where(target_labels == pad_token_id, 0, 1).to(device)
# 3.2 问答掩码: 助手回答部分为1,其他为0
answer_mask = create_answer_mask(model_inputs, tokenizer).to(device)
# 3.3 最终只计算助手的真实回答部分
final_loss_mask = answer_mask & padding_mask
# 4. 检查批次有效性(是否有至少一个有效token)
if final_loss_mask.sum().item() == 0:
print(f"跳过批次 {batch_idx+1}:回答部分为空")
skipped_batches_count += 1
continue
# 5. 前向传播
outputs = model(model_inputs)
logits = outputs.logits # [batch, seq_len, vocab_size]
# 6. 计算交叉熵损失(只对mask区域)
# 注意:在实际代码中,可以使用 cross_entropy=... 函数或者手动计算。
# 此处为了清晰,手动计算负对数似然(NLL)并 apply mask。
log_probs = torch.log_softmax(logits, dim=-1) # [B,T,V]
# 根据 target_labels 取每个位置的对数概率
token_log_probs = torch.gather(log_probs, dim=-1, index=target_labels.unsqueeze(-1)).squeeze(-1) # [B,T]
token_losses = -token_log_probs # 负对数似然
# 应用掩码
masked_losses = token_losses * final_loss_mask
# 每个样本的平均损失 = sum(masked_losses) / sum(final_loss_mask)
sample_losses = masked_losses.sum(dim=-1) / final_loss_mask.sum(dim=-1)
# 批次平均损失并应用梯度累积
batch_loss = torch.nanmean(sample_losses) / SFTConfig.gradient_accumulation_steps
# 7. 反向传播
batch_loss.backward()
# 8. 学习率计算
current_lr = cosine_decay(
batch_idx,
SFTConfig.warmup_steps,
total_steps,
SFTConfig.max_lr,
SFTConfig.min_lr
)
for param_group in optimizer.param_groups:
param_group["lr"] = current_lr
# 9. 梯度累积更新
if (batch_idx + 1) % SFTConfig.gradient_accumulation_steps == 0 or (batch_idx + 1) == total_steps:
optimizer.step()
optimizer.zero_grad()
# 10. 记录损失并输出日志
actual_batch_loss = batch_loss.item() * SFTConfig.gradient_accumulation_steps
training_losses.append(actual_batch_loss)
if (batch_idx + 1) % SFTConfig.log_iter == 0 or (batch_idx + 1) == total_steps:
recent_loss = np.nanmean(training_losses[-SFTConfig.log_iter:])
current_time = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"[{current_time}] Batch {batch_idx+1}/{total_steps} | "f"Loss: {recent_loss:.4f} | LR: {current_lr:.2e}")
print("\n训练完成!")
print(f"总批次数: {total_batches}, 跳过批次数: {skipped_batches_count}")
# 保存 SFT 后的模型,作为下一步 DPO 的基准模型
model.save_pretrained("./Qwen3-0.6B-SFT/")
tokenizer.save_pretrained("./Qwen3-0.6B-SFT/")
至此,我们已经有了一个能“正常对话”的SFT模型。接下来,我们将在这个模型的基础上进行DPO训练,教它学会“什么回答更讨人喜欢”。
4. DPO实战:完整可运行代码与避坑指南
SFT结束后,就正式进入DPO训练阶段。下面是完整的DPO训练代码,每一行都有详细注释。
4.1 核心变量解析:理解前必读
DPO训练中有几个关键变量,理解它们才能看懂代码:
- chosen(正例):人类偏好的回答。训练目标是要提升它的概率。
- rejected(负例):人类讨厌的回答。训练目标是要降低它的概率。
- reference model(参考模型):一个冻结的模型,参数永远不更新。它代表“训练前的基准水平”。
- beta(β):一个超参数,值越大模型越激进地拉开好坏差距;值越小模型越保守。
4.2 DPO训练的核心逻辑(纯白话版)
DPO的训练过程可以概括为三步:
第一步,计算“进步程度”:
- 对于正例:用当前模型输出它的概率,减去参考模型输出它的概率。差值越大,说明模型在正例上的“进步”越大。
- 对于负例:同样计算差值。
第二步,计算奖励差距:
- 正例的差值越大越好,负例的差值越小越好。用正例差值减去负例差值,得到一个“奖励差距”。
第三步,计算损失:
- 把奖励差距通过一个sigmoid函数(把任意数值映射到0~1之间)转换成一个概率:奖励差距越大→sigmoid值越接近1。
- 损失 = -log(这个sigmoid值)。
- 如果模型认为正例比负例好很多,sigmoid接近1,损失很小;如果模型搞反了,sigmoid接近0,损失就很大。
4.3 完整DPO训练代码
# -*- coding: utf-8 -*-
"""
DPO(直接偏好优化)完整训练代码
基于 SFT 完后的 Qwen3-0.6B 模型,使用偏好数据集进行 DPO 微调
核心思想:提高 chosen 的概率,降低 rejected 的概率
"""
import os
import time
import numpy as np
import torch
import torch.nn as nn
from torch.optim import AdamW
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
from dataclasses import dataclass
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"使用设备: {device}")
# DPO 超参数配置
@dataclass
class DPOConfig:
max_length = 1700 # 最大序列长度
batch_size = 2 # 批次大小(DPO 显存占用大,建议保持 1 或 2)
gradient_accumulation_steps = 8 # 梯度累积步数
beta = 0.3 # β 参数:越大越激进,越小越保守(典型范围 0.1-0.5)
log_iter = 100 # 日志输出间隔
max_lr = 1e-6 # 最大学习率(DPO 比 SFT 更小)
min_lr = 1e-7 # 最小学习率
warmup_steps = 300 # 预热步数
# 加载基座模型(应当是上一步 SFT 完后的模型)
model_path = "./Qwen3-0.6B-SFT"
model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype="auto", device_map="auto")
# 参考模型:和 model 具有完全一样的初始权重,但**全程冻结参数**,不参与更新
reference_model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype="auto", device_map="auto")
# 冻结参考模型(参数不更新)
for param in reference_model.parameters():
param.requires_grad = False
# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(model_path)
# 设置模型生成参数
model.generation_config.do_sample = True
model.generation_config.eos_token_id = [151645, 151643]
model.generation_config.pad_token_id = 151643
model.generation_config.temperature = 0.7
model.generation_config.top_p = 0.8
model.generation_config.top_k = 20
model.generation_config.repetition_penalty = 1.05
# 加载偏好数据集(开源 UltraFeedback 示例)
binarized_data = load_dataset("./ultrafeedback_binarized")
print("加载数据集完成,开始处理...")
def tokenize_and_format(data):
"""格式化并 tokenize 数据"""
input_ids = tokenizer.apply_chat_template(
data,
tokenize=True,
add_generation_prompt=False,
truncation=True,
max_length=DPOConfig.max_length,
)
return input_ids
# 分别生成 chosen 和 rejected 的 input_ids
chosen_input_ids_list = []
rejected_input_ids_list = []
# 本例使用 30000 条数据作为训练集
num_samples = 30000
for i in range(num_samples):
# 提取 chosen 数据
data_chosen = binarized_data["train_sft"][i]["chosen"]
data_chosen.insert(0, {"content": "You are a helpful assistant", "role": "system"})
chosen_ids = tokenize_and_format(data_chosen)
chosen_input_ids_list.append(chosen_ids)
# 提取 rejected 数据
data_rejected = binarized_data["train_sft"][i]["rejected"]
data_rejected.insert(0, {"content": "You are a helpful assistant", "role": "system"})
rejected_ids = tokenize_and_format(data_rejected)
rejected_input_ids_list.append(rejected_ids)
if (i + 1) % 10000 == 0:
print(f"已处理 {i+1}/{num_samples} 条偏好数据")
assert len(chosen_input_ids_list) == len(rejected_input_ids_list)
print("数据处理完毕,总样本数:", len(chosen_input_ids_list))
def compute_average_log_prob(logits, target_labels, mask):
"""
计算平均对数概率——DPO 的核心辅助函数。
输入:
logits : 模型输出的 logits [batch_size, seq_len, vocab_size]
target_labels : 真实的 token 标签 [batch_size, seq_len]
mask : 只有地位(如助手回答部分)为 1,其余为 0 [batch_size, seq_len]
返回:
average_log_prob : 每个样本的平均对数概率 [batch_size]
"""
# Step 1: 计算每个 token 的概率分布并对数化
log_probs = torch.log_softmax(logits, dim=-1) # [B, T, V]
# Step 2: 根据 target_labels 提取对应 token 的对数概率
gathered = torch.gather(log_probs, dim=-1, index=target_labels.unsqueeze(-1)).squeeze(-1) # [B, T]
# Step 3: 应用掩码,只保留有效部分
masked = gathered * mask
# Step 4: 求和并平均
sum_log_probs = masked.sum(dim=-1) # 每个样本的总对数概率
num_tokens = mask.sum(dim=-1) # 每个样本的有效 token 数量
avg_log_prob = sum_log_probs / num_tokens # 平均对数概率
return avg_log_prob
# 设置优化器和训练元参数
total_batches = len(chosen_input_ids_list) // DPOConfig.batch_size
optimizer = AdamW(model.parameters(), lr=DPOConfig.max_lr)
# 辅助函数(线性预热 + 余弦衰减),复用之前定义的 cosine_decay
# -------------------- DPO 主训练循环 --------------------
model.train()
training_losses = []
preferred_log_probs = []
rejected_log_probs = []
reward_margins = []
skip_count = 0
pad_token_id = tokenizer.pad_token_id or tokenizer.eos_token_id
for batch_idx in range(total_batches):
# 1. 获取当前批次的 chosen/rejected 序列
batch_chosen = chosen_input_ids_list[
batch_idx * DPOConfig.batch_size:(batch_idx + 1) * DPOConfig.batch_size
]
batch_rejected = rejected_input_ids_list[
batch_idx * DPOConfig.batch_size:(batch_idx + 1) * DPOConfig.batch_size
]
# 2. 填充对齐(Padding)
chosen_max_len = max(len(seq) for seq in batch_chosen)
rejected_max_len = max(len(seq) for seq in batch_rejected)
def pad_sequence(seq_list, max_len):
padded = []
for seq in seq_list:
pad_len = max_len - len(seq)
padded_seq = torch.nn.functional.pad(
torch.tensor(seq), (0, pad_len), mode="constant", value=pad_token_id
).tolist()
padded.append(padded_seq)
return torch.tensor(padded)
chosen_tensor = pad_sequence(batch_chosen, chosen_max_len)
rejected_tensor = pad_sequence(batch_rejected, rejected_max_len)
# 3. 构建训练输入输出(前 n-1 token → 预测后 n-1 token)
chosen_inputs = chosen_tensor[:, :-1].to(device)
chosen_labels = chosen_tensor[:, 1:].to(device)
rejected_inputs = rejected_tensor[:, :-1].to(device)
rejected_labels = rejected_tensor[:, 1:].to(device)
# 4. 掩码构建(构造 padding_mask 和 answer_mask)
# 一个简明的实例如下:
padding_mask_chosen = (chosen_labels != pad_token_id).float()
padding_mask_rejected = (rejected_labels != pad_token_id).float()
# 实际应用中应替换成真实的 answer_mask
# 由于 create_answer_mask 较为复杂,此处用全 1 的 mask 替代,让 mask 暂时只忽略 padding
answer_mask_chosen = torch.ones_like(chosen_inputs).to(device)
answer_mask_rejected = torch.ones_like(rejected_inputs).to(device)
final_mask_chosen = answer_mask_chosen * padding_mask_chosen
final_mask_rejected = answer_mask_rejected * padding_mask_rejected
if final_mask_chosen.sum().item() == 0 or final_mask_rejected.sum().item() == 0:
skip_count += 1
continue
# 5. 前向传播:当前模型,两个分支都需要
chosen_logits = model(chosen_inputs).logits
rejected_logits = model(rejected_inputs).logits
# 6. 参考模型(不计算梯度)的前向传播
with torch.no_grad():
ref_chosen_logits = reference_model(chosen_inputs).logits
ref_rejected_logits = reference_model(rejected_inputs).logits
# 7. 计算平均对数概率
chosen_log_prob = compute_average_log_prob(chosen_logits, chosen_labels, final_mask_chosen)
rejected_log_prob = compute_average_log_prob(rejected_logits, rejected_labels, final_mask_rejected)
ref_chosen_log_prob = compute_average_log_prob(ref_chosen_logits, chosen_labels, final_mask_chosen)
ref_rejected_log_prob = compute_average_log_prob(ref_rejected_logits, rejected_labels, final_mask_rejected)
# 8. 隐式奖励
beta = DPOConfig.beta
chosen_reward = beta * (chosen_log_prob - ref_chosen_log_prob)
rejected_reward = beta * (rejected_log_prob - ref_rejected_log_prob)
reward_margin = chosen_reward - rejected_reward
# 9. DPO 损失函数(核心)
# 公式:-log(sigmoid(reward_margin))
loss = -torch.log(torch.sigmoid(reward_margin)).mean()
loss = loss / DPOConfig.gradient_accumulation_steps
# 10. 反向传播
loss.backward()
# 11. 动态学习率
current_lr = cosine_decay(
batch_idx,
DPOConfig.warmup_steps,
total_batches,
DPOConfig.max_lr,
DPOConfig.min_lr
)
for param_group in optimizer.param_groups:
param_group["lr"] = current_lr
# 12. 梯度累积与权重更新
if (batch_idx + 1) % DPOConfig.gradient_accumulation_steps == 0 or (batch_idx + 1) == total_batches:
optimizer.step()
optimizer.zero_grad()
# 13. 记录指标
training_losses.append(loss.item() * DPOConfig.gradient_accumulation_steps)
preferred_log_probs.append(chosen_log_prob.mean().item())
rejected_log_probs.append(rejected_log_prob.mean().item())
reward_margins.append(reward_margin.mean().item())
# 14. 定期输出日志
if (batch_idx + 1) % DPOConfig.log_iter == 0 or (batch_idx + 1) == total_batches:
recent_loss = np.nanmean(training_losses[-DPOConfig.log_iter:])
recent_pref = np.nanmean(preferred_log_probs[-DPOConfig.log_iter:])
recent_rej = np.nanmean(rejected_log_probs[-DPOConfig.log_iter:])
recent_margin = np.nanmean(reward_margins[-DPOConfig.log_iter:])
print("-" * 60)
current_time = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"[{current_time}] Batch {batch_idx+1}/{total_batches}")
print(f" 损失: {recent_loss:.4f}")
print(f" 正例对数概率: {recent_pref:.4f} ↑")
print(f" 负例对数概率: {recent_rej:.4f} ↓")
print(f" 奖励差距: {recent_margin:.4f} ↑")
print(f" 学习率: {current_lr:.2e}")
print(f"\n训练完成!有效批次: {total_batches - skip_count},跳过批次: {skip_count}")
model.save_pretrained("./Qwen3-0.6B-DPO/")
tokenizer.save_pretrained("./Qwen3-0.6B-DPO/")
print("模型已保存至 ./Qwen3-0.6B-DPO/")
代码分为几个关键阶段:SFT先打底,DPO再调优。训练过程中需要关注几个指标——正例对数概率上升、负例对数概率下降、奖励差距持续增大,这些都是学对了的信号。建议每100步在验证集上评估一次,一旦奖励差距不再增长就及时停止,防止过拟合。
5. 总结与实操建议
纵观全流程,从数据构建到SFT再到DPO,核心的实操建议可以总结为以下几条:
- SFT 是 DPO 的必要前提:务必先让模型学会基本的对话能力,DPO 才能正确地优化偏好。
- 高质量偏好数据是根本:善用开源数据集(如 UltraFeedback、HH-RLHF)保证质量,并通过过滤低质量数据和检查偏好循环来严格控制正负例区分度。
- 监视训练关键指标:关注正例对数概率上升、负例对数概率下降、奖励差距持续增大。若出现两者同步上升或奖励差距后段下降,说明模型开始过拟合,应及早停止训练。
- 优先使用 DPO + LoRA:极大的节省显存并降低全参训练的风险,学习率建议从 1e-6 到 5e-6 开始调整。
- 提前规划部署与评估:在业务场景下保留对比测试集,定期评价对齐后模型生成是否符合人类偏好。