代码解读 | CosyVoice 代码研读(一):CosyVoice2 LLM + DPO
白御青 Lv5

在 TTS 训练流程中,SFT (Speaker Fintuning) 无法直接优化人类对语音质量的主观偏好(如自然度、韵律、情感表达等)。DPO(Direct Preference Optimization) 提供了一种无需训练奖励模型的偏好对齐方法。只需要通过”偏好”数据(chosen vs rejected),即可直接优化策略模型,使得模型更倾向于于人类偏好或者抑制模型因为采样而出现的不稳定错误。

CosyVoice 系列在 LLM 从文本预测语音 token 的阶段引入了 DPO 训练(CosyVoice2 开始),本文从训练流程、模型前向运算、Loss 理论推导及代码实现等层面逐步解读。

核心文件功能:

文件 功能
cosyvoice/bin/train.py 训练入口,初始化策略(待更新)模型、参考模型和 DPO Loss
cosyvoice/llm/llm.py forward_dpo 方法,计算 chosen/rejected 的对数概率
cosyvoice/utils/train_utils.py batch_forward 函数,组装 DPO 训练逻辑
cosyvoice/utils/losses.py DPOLoss 类,实现 DPO/IPO/cDPO 损失函数

训练入口:模型与参考模型初始化

源码位置:cosyvoice/bin/train.py

monkey-patch 方法替换
1
2
if args.dpo is True:
configs[args.model].forward = configs[args.model].forward_dpo

通过 monkey-patch,DPO 训练时将模型的 forward 方法替换为 forward_dpo。这样训练循环中调用 model(batch, device) 时,无需修改训练代码即可切换为 DPO 前向逻辑。

参考模型初始化
1
2
3
4
5
ref_model = deepcopy(configs[args.model])
state_dict = torch.load(args.ref_model, map_location='cpu')
ref_model.load_state_dict(state_dict, strict=False)
dpo_loss = DPOLoss(beta=0.01, label_smoothing=0.0, ipo=False)
ref_model = wrap_cuda_model(args, ref_model)

DPO 需要一个冻结的模型 来作为参考模型(reference model)

  1. deepcopy 从当前模型配置中拷贝出一份结构相同的模型副本
  2. 加载 SFT 权重:从 --ref_model 指定的路径加载 SFT 阶段的 checkpoint 作为参考模型权重
  3. 实例化 DPO Loss:设置 beta=0.01,不启用 label smoothing 和 IPO(后文详细说明几种训练方式的细节)
损失函数加入训练流程
1
executor = Executor(gan=gan, ref_model=ref_model, dpo_loss=dpo_loss)

将参考模型和 DPO Loss 传入 Executor,在每个 training step 中通过 batch_forward 完成 DPO 计算。


模型前向传播:forward_dpo

源码位置:cosyvoice/llm/llm.pyforward_dpo 方法

forward_dpo 的核心思路是将 chosen 和 rejected 语音 token 拼成一个大 batch 做一次 LM 前向推理,再拆分输出分别计算对数概率。

输入数据提取
1
2
3
4
5
6
text_token = batch['text_token'].to(device)          # 文本 token
text_token_len = batch['text_token_len'].to(device)
speech_token = batch['speech_token'].to(device) # chosen 语音 token
speech_token_len = batch['speech_token_len'].to(device)
reject_speech_token = batch['reject_speech_token'].to(device) # rejected 语音 token
reject_speech_token_len = batch['reject_speech_token_len'].to(device)

每个样本包含同一条文本对应的两种语音输出:speech_token(高质量 / chosen)和 reject_speech_token(低质量 / rejected)。

4.2 Chosen + Rejected 凑新 batch
1
2
3
4
5
speech_token = unpad_sequence(speech_token, speech_token_len.cpu(), batch_first=True)
reject_speech_token = unpad_sequence(reject_speech_token, reject_speech_token_len.cpu(), batch_first=True)
speech_token_combined = speech_token + reject_speech_token # list 拼接,非张量加法
speech_token_combined = pad_sequence(speech_token_combined, batch_first=True, padding_value=0)
speech_token_combined_len = torch.concat([speech_token_len, reject_speech_token_len], dim=0)

假设原始 batch size 为 ,拼接后 batch size 变为 :前 条是 chosen,后 条是 rejected。文本 token 也相应地 repeat(2, ...),使每条 rejected 语音都和对应的 chosen 语音共享同一条文本输入。

LM 前向推理与输出拆分
1
2
3
4
lm_output, lm_output_mask = self.llm(lm_input, lm_input_len.to(device))
logits = self.llm_decoder(lm_output)
chosen_logits = logits[:text_token.shape[0]] # 前 B 条:chosen
rejected_logits = logits[text_token.shape[0]:] # 后 B 条:rejected

一次前向推理处理 条序列,然后按 batch 维度拆分回 chosen/rejected 两部分。SFT loss 和 accuracy 仅在 chosen 部分上计算:

1
2
loss = self.criterion_ce(chosen_logits, chosen_lm_target.to(device))
acc = th_accuracy(chosen_logits.view(-1, self.speech_token_size + 3), chosen_lm_target, ignore_label=IGNORE_ID)
对数概率计算
1
2
3
4
5
6
7
chosen_lm_mask = chosen_lm_target == IGNORE_ID
rejected_lm_mask = rejected_lm_target == IGNORE_ID
chosen_logps = torch.gather(
chosen_logits.log_softmax(dim=-1), dim=2,
index=chosen_lm_target.masked_fill(chosen_lm_mask, 0).unsqueeze(dim=-1)
).squeeze(dim=-1)
chosen_logps = (chosen_logps * chosen_lm_mask).sum(dim=-1) / chosen_lm_mask.sum(dim=-1)

逻辑拆解:

  1. 构造 maskIGNORE_ID 标记的位置是文本/特殊 token(不参与 Loss 计算)
  2. **masked_fill(..., 0)**:将 IGNORE_ID(-1)替换为 0,避免 gather 时索引越界
  3. **torch.gather**:从 log_softmax 输出中取出每个位置上目标 token 的对数概率
  4. mask 加权平均:对有效位置的 log 概率求平均,得到序列级别的

最终返回策略模型对 chosen/rejected 的平均对数概率,供后续 DPO Loss 使用:

1
return {'loss': loss, 'acc': acc, 'chosen_logps': chosen_logps, 'rejected_logps': rejected_logps}

DPO Loss 组装

源码位置:cosyvoice/utils/train_utils.pybatch_forward 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
with autocast:
info_dict['loss_dict'] = model(batch, device) # 策略模型,带梯度
if ref_model is not None and dpo_loss is not None:
chosen_logps = info_dict['loss_dict']["chosen_logps"]
rejected_logps = info_dict['loss_dict']["rejected_logps"]
sft_loss = info_dict['loss_dict']['loss']

with torch.no_grad():
ref_loss_dict = ref_model(batch, device) # 参考模型,无梯度
reference_chosen_logps = ref_loss_dict["chosen_logps"]
reference_rejected_logps = ref_loss_dict["rejected_logps"]

preference_loss, chosen_reward, reject_reward = dpo_loss(
chosen_logps, rejected_logps, reference_chosen_logps, reference_rejected_logps
)
dpo_acc = (chosen_reward > reject_reward).float().mean()
info_dict['loss_dict']["loss"] = preference_loss + sft_loss # 联合 loss

关键设计:

  • 策略模型chosen_logps / rejected_logps 带梯度,DPO Loss 的梯度通过它们回传
  • 参考模型torch.no_grad() 下运行,权重冻结,仅提供参考的 logps
  • 最终 loss = DPO preference loss + SFT loss,联合优化既保持原始参考模型的语言建模能力,又引导偏好对齐。这一点在 InstructGPT 论文中就已经提到,最近也有不少论文有提到这个设计。

DPO Loss 核心计算

源码位置:cosyvoice/utils/losses.pyDPOLoss

DPO 原理推导

DPO(Rafailov et al., 2023)的核心思想是将 RLHF 中的奖励建模 + PPO 两步优化,压缩为一步直接优化。DPO 的精髓在于采用了 Bradley-Terry 偏好模型,将最优策略的闭式解代入该模型,建模过程可以做极大的简化。

Bradley-Terry 模型 假设人类偏好概率为:

其中 是 sigmoid 函数, 是奖励函数。

RLHF 的目标是:

这里 KL 是显式的。上面这个带 KL 的目标函数,有解析最优解

可以推导出,在 KL 约束的 RL 目标下,最优策略和奖励之间存在闭式关系:

DPO 则是将这个关系代回 Bradley-Terry 模型( 在相减时消掉),得到 DPO 的损失函数:

对应代码:

1
2
3
4
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = reference_chosen_logps - reference_rejected_logps
logits = pi_logratios - ref_logratios
losses = -F.logsigmoid(self.beta * logits)

其中 logits 等价于:

直觉上:我们希望策略模型相比参考模型,在 chosen 上的概率提升幅度,大于在 rejected 上的,即

参数的作用:控制策略模型偏离参考模型的程度。 越大,惩罚越强,策略模型的更新越保守; 越小,模型越自由。CosyVoice 使用 ,属于较小的值,给予策略模型比较大的调整空间。

cDPO(Conservative DPO)

当偏好数据的标注存在噪声时(即 chosen 不一定真的优于 rejected),原始 DPO 可能过拟合到错误的偏好标签。cDPOMitchell, 2023)通过 label smoothing 来缓解这个问题:

其中 label_smoothing 参数。

对应代码:

1
2
3
4
losses = (
-F.logsigmoid(self.beta * logits) * (1 - self.label_smoothing)
- F.logsigmoid(-self.beta * logits) * self.label_smoothing
)

原理

  • 第一项 是标准 DPO,推动模型偏好 chosen
  • 第二项 是”反向 DPO”,推动模型偏好 rejected
  • 两项加权混合,相当于告诉模型”有 的概率标签是对的,有 的概率标签是反的”

时退化为原始 DPO,当 时 loss 为常数(完全不信任标签)。CosyVoice 中 label_smoothing=0.0,即使用原始 DPO。

个人感觉这种思路其实一定程度上加剧了 LLM 训练的不稳定性,一般情况如果能把 DPO 训练的数据可靠性提升上去时,不会有限考虑 cDPO / label smoothing 的这种思想。

IPO(Identity Preference Optimization)

IPOAzar et al., 2023)是另一种 DPO 变体,用 MSE loss 替代 sigmoid loss:

对应代码:

1
2
if self.ipo:
losses = (logits - 1 / (2 * self.beta)) ** 2

与 DPO 的区别

特性 DPO IPO
Loss 形式
梯度行为 logits 很大时梯度趋零(sigmoid 饱和) logits 偏离目标值时梯度始终存在
过拟合倾向 可能过拟合到极端偏好 目标值有限,天然正则化
适用场景 标签较干净 标签有噪声或希望更稳健的训练

IPO 的直觉是:不追求让 logits 无限大(即无限偏好 chosen),而是让 logits 稳定在 这个目标值,具体详见 IPO 原文。CosyVoice 中 ipo=False,未启用此模式。

隐式奖励(Implicit Reward)
1
2
chosen_rewards = self.beta * (policy_chosen_logps - reference_chosen_logps).detach()
rejected_rewards = self.beta * (policy_rejected_logps - reference_rejected_logps).detach()

这两个值来自 DPO 的理论推导:

它们不参与梯度计算(.detach()),常常作为训练的监控指标:

  • dpo_acc = (chosen_rewards > rejected_rewards).float().mean():chosen 奖励高于 rejected 的比例
  • 合理的训练现象:随着训练推进,chosen_rewards 应上升,rejected_rewards 应下降或保持稳定

训练监控指标

batch_forward 中,以下指标会被写入 TensorBoard:

指标 含义 理想趋势
loss preference_loss + sft_loss(总 loss) 下降
sft_loss chosen 上的交叉熵 loss 下降
dpo_loss DPO preference loss 下降
acc chosen 上的 token 预测准确率 上升
dpo_acc chosen reward > rejected reward 的比例 趋近 1.0
chosen_reward chosen 的平均隐式奖励 上升
reject_reward rejected 的平均隐式奖励 下降或不变

总结

CosyVoice 的 DPO 训练方案具有以下特点:

  1. SFT + DPO 联合优化:最终 loss 是 SFT CE loss 和 DPO preference loss 的直接相加,在偏好对齐的同时保持基本的语言建模能力
  2. 高效的批处理策略:将 chosen 和 rejected 拼成 的大 batch 做一次前向推理,避免两次独立推理的开销
  3. 灵活的 Loss 选择:代码中实现了 DPO / cDPO / IPO 三种变体,通过 label_smoothingipo 参数切换
  4. 完善的监控体系:通过隐式奖励、DPO accuracy 等指标实时监控训练状态

参考文献

  • [DPO] Rafailov et al., “Direct Preference Optimization: Your Language Model is Secretly a Reward Model”, 2023. arXiv:2305.18290
  • [IPO] Azar et al., “A General Theoretical Paradigm to Understand Learning from Human Feedback”, 2023. arXiv:2310.12036
  • [cDPO] Mitchell, “A Note on DPO with Noisy Preferences & Relationship to IPO”, 2023. Blog