LLM中PPO、DPO以及GRPO

AI 时代程序员必备技能

Codex、Claude Code、Cursor、Hermes Agent、OpenClaw等工程化实战专栏 ,讲透 AI 如何接管脏活累活

     

目录

一、强化学习的基础概念

1、基础概念和流程

2、重要关键词

策略优化(policy-optimization)

价值优化(value-optimization)

on-line

off-line

on-policy

off-policy

3、NLP中的强化学习

二、RLHF中的PPO

1、RLHF算法流程和关键模型

2、Loss函数

RLHF中的目标函数

Actor模型的目标函数

Critic Loss

代码实现

reward_score

actor loss

critic loss

3、训练流程

三、低成本的DPO

1、关键模型

2、Loss函数

四、R1类思考模型的GRPO

1、关键模型和算法流程

2、Loss函数的理解以及代码阅读

3、训练流程

4、PPO和GRPO的对比

1、模型架构差异

2、优势计算差异

3、损失函数差异

4、采样效率差异

5、训练稳定性差异

6、适用场景建议


         2025年Deepseek推出了Deepseek-R1模型,一下子把LLM和强化学习推到一个新的热度。作为nlp的从业人员,经历过Bert、GPT2、ChatGPT以及GPT-4O和R1等模型,见证了模型从不会思考到具有一定的推理和思考能力的变迁。记得2022年的时候,也是采用PPO强化学习的算法用于对话策略学习,实现了一个任务型问答机器人。时至今日LLM领域,强化学习算法一步一步的进化升级,从RLHF中的PPO到后来的DPO,又到今年的GRPO以及更新更好的一些算法,模型训练效率越来越高、模型效果也越来越好,强化学习在LLM中的地位也越来越高。之前也有计划写一点强化学的相关内容,由于一些这样那样的原因(我懒+理解不到位)这个规划就一直搁浅了,接着Deepseek-r1模型大火,看了好多遍PPO、DPO以及GRPO相关的文章和代码后,我觉得还是要写一篇强化学习相关的内容,记录一下这个知识,以防忘记。本文就LLM中的PPO、DPO以及GRPO算法进行解析,增强自己对它们的理解,由于水平有限,难免出现一些错误,如果有错轻喷,评论区指出就好。

一、强化学习的基础概念

        为了更好的理解强化学习以及后续的各种算法,先从强化学习中的整体流程和基础概念出发。下图是引至《深度强化学习基础研究和应用》一书第2章节,形象的描述了强化学习的组成和场景示例

     

1、基础概念和流程

        强化学习中重要的组成部分:智能体、环境、动作、状态和奖励

        智能体:强化学习的主体、与环境进行交互,负责动作或者执行策略

        环境:强化学习的基本元素,与智能体交互的对象,可以理解为规律规则或者规则的组合

        动作空间:智能体能够采取的行为的集合。比方说上图围棋游戏中的在某个点下子。

        状态空间:当前环境信息的所有集合

        奖励:智能体采取动作后,环境给出的反馈,有正有负、有大有小

        强化学习的目标:让智能体学习到一种模式或者策略使得它与环境长期或者指定的时间段内交互得到的奖励累积最大化

2、重要关键词

        强化学习中有很多稀奇古怪、容易混淆的概念,下面就把我认为比较重要的关键词列举出来,并做简单的说明解释和分析。

        怎么来实现强化学习的目标呢?有两种路线,一种是策略优化,直接优化策略;一种是价值优化,通过价值的大小来间接的选择最优动作

策略优化(policy-optimization)

        直接优化策略(神经网络参数化建模),策略给出动作状态的条件概率。学习的过程中,把从初始状态开始累积的奖励期望最大化作为优化目标,对参数就偏导,就得到梯度。特点是——高方差、低偏差。方差是指策略优化过程中,建模的神经网络更新的方向和幅度在不同的训练数据上的波动情况;偏差是指学习的策略和期望的策略的差异程度。方差高是由于和环境交互轨迹(动作选择或者状态转移等)具有随机性——训练数据差异较大,因此神经网络更新的方向和幅度必然不稳定,波动比较大;由于是直接对神经网络建模的策略进行学习,只要采样够多,学习充分,最后得到的策略和我们期望的策略就是差异就是比较小的。

价值优化(value-optimization)

        先学习价值函数——状态或者状态-动作对的长期期望收益,然后策略就是根据价值函数来间接选择最优动作。所以优化还是优化价值函数,没有策略函数也就是没有显式的对策略进行建模(用一个神经网络参数化建模策略)。特点是——低方差、高偏差。这里的方差是指价值函数的价值估计(具体的价值大小)在不同的训练数据上的波动情况;偏差是指估计价值和真实价值或者间接的策略和真实的策略之间的差异。方差低是由于它的价值估计算法采用了即时奖励和下一状态的估计,主要的波动就在即时奖励上,被局部化,而即时奖励的波动不大,所以整体方差就比较低;偏差主要是由于价值的估计本身就带有系统误差——估计的就不准确以及再间接的指导策略选择动作,进一步的加大误差,所以偏差就更大了。

学习方式可以分为在线以及离线这两种方式

on-line

        在线学习主要是指智能体和环境交互过程中实时生成训练数据,并利用这些数据来更新策略。比如我们本文中关注的算法PPO和GPRO等,学习过程中,都是先和环境交互,输入prompt得到response和reward组成训练数据,然后进行即时训练。可以简单的理解训练过程中模型有没有生成数据,生成了就是在线学习。

off-line

        离线学习顾名思义就是离线,不是实时和环境交互获取训练数据,而是利用预先收集好的数据进行训练和学习。DPO算法就是离线学习的算法,模型的prompt和response都是提前收集好了,没有在训练过程中实时的生成。

        从数据生成使用的策略也就是行为策略和学习优化的目标策略是否相同的角度,强化学习可以分为on-policy(同策算法)和off-police(异策算法)

on-policy

        行为策略和目标策略是同一个策略,那么它就是on-policy的算法。行为策略怎理解呢?就是强化学习过程中数据的生成采用的是什么策略。PPO算法中,数据生成使用的是Actor这个模型,同时优化的目标策略也是Actor这个模型,所以简单来讲它就是on-policy算法。严格的来讲我赞同PPO是on-policy和off-policy的算法,PPO的优化目标策略实现的时候,会使用收集到的数据更新多个epoch,在第一个epoch可以认为行为策略和目标策略是同一个策略,这个时候就是on-policy;后面的几个epoch就是off-policy了,模型参数已经更新过了,它不是原来生成数据的策略了。有更多不同的理解可以参考问题——在强化学习中,为什么TRPO和PPO算法属于On-Policy的算法?

off-policy

        行为策略和目标策略不是同一个策略,那么它就是off-policy算法。具体是算法有Q-Learning,下面看一个Q-Learning算法示例图(强化学习中的奇怪概念(一)——On-policy与off-policy   小错 高赞文章)

        图中可以明确的看到收集数据的行为策略(蓝色框框)是1-\varepsilon贪心策略,而目标策略(红色框框 arg max)是完整的贪心策略,它们两个不是同一个策略,因此Q-Learning就是off-policy的算法。

3、NLP中的强化学习

     上文给出了一个具体是强化学习的应用场景示例,那就是围棋游戏。它的智能体、环境、状态、动作分别如下定义

                智能体:下围棋的策略或者电脑

                环境:围棋的规律或者规则,智能体与其交互

                状态:是指当前棋盘,黑白子的坐标信息以及没有棋子的坐标信息

                动作:智能体围棋落子的坐标信息

        在NLP中,这些概念或者名字以及流程和NLP又是怎么对应的呢?在NLP中使用强化学习的目的就是让模型生成符合我们人类喜好、毒害性少、帮助性好这样最优的文本。gpt类模型采用自回归解码方式,给定输入,预测下一个token;然后把历史输入和生成的token作为新的输入,预测新的一个token。按照上述步骤一直进行下去,直到满足结束条件。如图:

      prompt输入模型后,就会生成下一个token(图中的蓝色框框)。输入的prompt为上文,在强化学习中,可以视为状态S_{t} ,生成的token,视为动作a_{t} ;那么此时可以得到及时奖励R_{t}(根据当前上下文,当前生成这个token的好坏的评估)以及总收益V_{t}(当前时刻的即时奖励以及未来时刻的总收益),并且智能体、环境、状态以及动作等概念可以按照如下理解:

        智能体:想要训练的大语言模型

        环境:评估模型生成的文本的规则或者规律

        状态:当前的上文

        动作:词表对应的空间,模型要生成的一个token

当然还有一些疑问就是NLP中使用强化学习,R_{t}V_{t}到底是怎么计算的?在后面的章节中,我们会给出解释。

二、RLHF中的PPO

       2022年11月OpenAI发布了ChatGPT,引发了全民对 LLM 的应用、探索、学习和创新的热潮;同时强化学习在 LLM 中持续深入,它通过反馈优化模型,生成更贴合人类喜好的文本。其中,PPO算法首次在文本大模型中成功应用,为 LLM 优化开辟新路径。下文我们就一起看看RLHF以及它的PPO是什么样的。

1、RLHF算法流程和关键模型

       RLHF在LLM火起来可以追溯到OpenAI的instructGPT——Training language models to follow instructions with human feedback,该论文详细的介绍了RLHF的训练流程、涉及到的具体模型数量以及使用的强化学习的算法。方法示例图如下:

          第一步:收集示范数据对GPT3进行微调。——SFT

          第二步:收集对比数据训练一个奖励模型。——reward model

          第三步:使用PPO强化学习算法结合奖励模型进行学习——rl PPO

       用一个模型流程图来表示上述过程

          第一阶段SFT只有一个模型更新;第二阶段的Reward Model的训练也只有一个模型更新;最后的PPO算法阶段需要涉及到4个模型,分别是:

        Actor Model:就是我们训练的目标模型,训练完成后这个模型就可以用来部署进行推理了。参数需要更新,虚线框的模型。

        Reference Model:其实就算第一阶段微调好的预训练模型,它的作用就是给Actor Model训练更新的时候一个约束,让它不要太奔放,跑得太歪了。这个模型是冻结的,不参与参数更新。

        Critic Model:一般的RL方案中,这个模型初始化就是第二阶段训练好的Rewrad模型,它的作用是评价给定prompt+已生成的token的价值,也就是上面提到的V_{t}(注意它包含了当前时刻的奖励以及未来所有时刻的奖励),并且这是一个预估的值,对未来的预估,可靠性有待商榷。参数也需要更新,虚线框的模型。

        Reward Model:奖励模型,顾名思义,就是给出当前生成的token的奖励R_{t}——它是即时奖励,理论上是准确的,模型冻结,参数不更新。另外注意这里其实有个细节的地方,奖励模型训练的时候对比的是整个response1和response2的好坏。推理的时候把prompt+response通过Linear_head有[L,D]的logits转化为[L],这里的每个分数都可以使用吗?很明显只有最后一个token的分数是准确的,我们人类标注数据也是看的prompt+完整的response,而不是prompt+部分response做排序,这个也排序不了,因为不知道未来的token是什么样的。——简而言之就是reward模型的分数只有最后一个token是准确的,其他位置给出的分数都是不太准确的。业界主流的实现方案也是只使用最后一个token的Reward Model分数作为奖励,其他的位置则认为是0。当然也有一些工作直接尝试使用Reward Model给出的每个位置的分数或者认为每个token都是一样的重要,因此把一个平均分数分配给所有的token。后面代码阅读的时候可以看看主流的方案是怎么处理的

2、Loss函数

     reward model loss

       这个loss函数公式字面上的意思就是针对一个prompt-X, 标注了K(4<=K<=9)个response,它们之间有排序关系,构建为K*(K-1)/2个样本对,形如(X,Yw,Yl),使得X+Yw和X+Yl输入到模型后得出的分值只差,经过sigmoid函数之后取对数;对所有的样本求期望(也就是求一个平均值),加上负号,loss函数要求这个值越小越好。含义就是模型能力具备区分不同答案的好坏,区分得越开越好,不同的答案之间的奖励差值越大越好。具体的伪代码如下:

for epoch in range(epochs):
        total_loss = 0
        for batch in dataloader:
            x, y_w, y_l = batch

            r_theta_yw = model(x+y_w)
            r_theta_yl = model(x+y_l)
            diff = r_theta_yw - r_theta_yl
            log_prob = torch.log(sigmoid(diff))
            loss = -torch.mean(log_prob)  

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

        同样可以看看LLM-Factory这个比较流行的模型训练库,RM模型训练的loss是怎么计算的,代码如下:

@override
    def compute_loss(
        self, model: "PreTrainedModel", inputs: dict[str, "torch.Tensor"], return_outputs: bool = False, **kwargs
    ) -> Union["torch.Tensor", tuple["torch.Tensor", list["torch.Tensor"]]]:
        r"""Compute pairwise loss. The first n examples are chosen and the last n examples are rejected.

        Subclass and override to inject custom behavior.

        Note that the first element will be removed from the output tuple.
        See: https://github.com/huggingface/transformers/blob/v4.40.0/src/transformers/trainer.py#L3842
        """
        _, _, values = model(**inputs, output_hidden_states=True, return_dict=True, use_cache=False)
        batch_size = inputs["input_ids"].size(0) // 2
        chosen_masks, rejected_masks = torch.split(inputs["attention_mask"], batch_size, dim=0)
        chosen_rewards, rejected_rewards = torch.split(values, batch_size, dim=0)
        chosen_scores = chosen_rewards.gather(dim=-1, index=(chosen_masks.sum(dim=-1, keepdim=True) - 1))
        rejected_scores = rejected_rewards.gather(dim=-1, index=(rejected_masks.sum(dim=-1, keepdim=True) - 1))
        chosen_scores, rejected_scores = chosen_scores.squeeze(), rejected_scores.squeeze()

        loss = -torch.nn.functional.logsigmoid(chosen_scores.float() - rejected_scores.float()).mean()
        if return_outputs:
            return loss, (loss, chosen_scores, rejected_scores)
        else:
            return loss

        很明显的可以看到这个代码和上图的损失函数公式是对齐的,没有比较难理解的实现。

        注意这里的样本对的组织和处理方式有两种策略

        1、把同一个prompt的构建的样本对放到一个batch中——这个是严格的遵循论文的目标函数和损失函数。优势是batch内能够学习到数据的排序关系,感觉数据的多样性有点少,因为batch内的数据同质性验证、相关性过高

        2、是把不同的prompt构建的样本对放到同一个batch中——这样每一次的更新就和论文的公式不太一样,这里的优势就是数据在一个batch内,多样性好,这样的训练方式会使得模型最后的泛化性能比较好。

        业界训练RM模型使用的方案都是采用第二种样本组织和处理的方式,为的就是增强模型的泛化性能。

RLHF中的目标函数

        上文也提到过RLHF中PPO的训练涉及到4个模型,其中有两个模型是非冻结可以学习的,就是actor model和critic model,那么上述目标函数就是一定会把loss应用或者说关联到这个两个模型上面。简单理解一下这个目标函数的意义:针对一个输入x,改变学习模型的参数,然它的输出y使得:RM模型的(x,y)的奖励值与新模型和老模型的概率比(这个比值一定是大于1的)的对数的差值最大化。——所以就要取r(x,y)更大的同时,新模型不能偏离原始的SFT模型太远。如何来限制或者调整y的生成呢?PPO强化学习算法框架中,其优化目标是使得累积折扣回报期望最大化,采用的是GAE来指导限制Actor模型的学习。

Actor模型的目标函数

r_{t}(\theta ) 表示新老模型的概率比,这里需要用到actor和referenc模型的logtis——概率比*相对优势越大越好。目标函数梯度如下:

这里的优势含义就是评估选择的动作相对其他平均动作到底有多好或者多坏

        这里的优势函数的公式中也做了类似未来折扣计算(当前时刻的真实值+未来所有时刻的估计)——那么对动作的好坏评估就更加准确了;而计算需要用到奖励模型的输出、critic模型的输出。到这里所有的模型都参与到整个策略的更新了。总结:

        1、为了让积累的折扣回报期望越大,采用GAE来指导actor模型的学习,让它产生好的动作的概率更大,坏的动作概率更小——学习的模型是actor模型;奖励模型、参考模型、评价模型都是间接的参与到模型学习优化中。

        2、为了让优化计算时候的基线更准确率,critic 模型需要进行学习优化,对未来的价值预测更加准确;辅助actor的学习,让actor模型学习的更准确。

对应于具体的PPO算法中目标函数如下

DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models

        公式1中具体说的是求期望(对所有的问题q,和对应生成o),后面的公式韩式——对o中的每个token新旧模型相对gae优势求平均,整体就是 对token的新旧模型相对gae平均优势求期望。注意到这个是求token级别的。

        另外一个注意点是RM模型最后一个token是有奖励的也就是非EOS的token 奖励R为0,所有根据下面的公式是可以计算每一个时刻的(每个token)的优势。

        最后一个token是有奖励的也就是非EOS的token 奖励R为0——这个是一个强假设或者说工程上的妥协,现在的方案中这样假设中间token奖励为0,同时结合critic 模型来预测每个token的价值一起计算优势,模型的效果是可以的。但是理论上这样的假设是不合理的,一个序列中,每个字都应该有不同的价值,而不是简简单单的只给最后一个字给于奖励其他的为0。从工程上来说最后一个token是有奖励的也就是非EOS的token 奖励R为0是最容易实现的,成本最低,如果要给其他的token给出一个合适的奖励,目前是无法做到的,或者说很难做到。

Critic Loss

它就是一个mse均方误差函数,v target 就是基于优势和critic old模型计算出来的。

# GAE 计算 Advantage
δ_t = r_t + γ * V_old(s_{t+1}) - V_old(s_t)  # 注意:用的都是 V_old
A_t = δ_t + γλδ_{t+1} + ...

# Returns
R_t = A_t + V_old(s_t)  # 这里用的也是 V_old

# Critic 的 MSE 损失
value_loss = (V_new(s_t) - R_t)²

        loss的作用就是让critic model去拟合它的真实评估能力,让它评估的越来越准确。

代码实现

        下文来看看具体的各自的代码实现

reward_score

        这个是由reward model 最后的head生成的一个[B,S]矩阵经过一些列的处理得到的。看看具体的逻辑(verl源码)

        verl/experimental/reward_loop/reward_loop.py中的compute_rm_score函数实现入口调用

def compute_rm_score(self, data: DataProto) -> DataProto:
        if self.reward_model_manager is not None:
            self.reward_model_manager.wake_up()

        chunks = data.chunk(len(self.reward_loop_workers))
        outputs = ray.get(
            [
                worker.compute_score_batch.remote(chunk)
                for worker, chunk in zip(self.reward_loop_workers, chunks, strict=True)
            ]
        )
        outputs_flat = [item for sublist in outputs for item in sublist]

        # compute rm score
        scores = [item["reward_score"] for item in outputs_flat]
        rm_scores = self.reward_manager_cls.assemble_rm_scores(data, scores)
        batch = TensorDict({"rm_scores": rm_scores}, batch_size=len(data))

        在reward_loop_workers.compute_score_batch中完成score的计算,最后在assemble_rm_scores中完成组装

 async def compute_score_batch(self, data: DataProto) -> list[dict]:
        tasks = []
        for i in range(len(data)):
            tasks.append(asyncio.create_task(self.compute_score(data[i : i + 1])))
        outputs = await asyncio.gather(*tasks)
        return outputs

async def compute_score(self, data: DataProto) -> dict:
    if self.config.reward.custom_reward_function.path is not None:
            # directly use user-customized reward function
            return await self.reward_manager.run_single(data)
     else:
         if self.config.reward.reward_model.enable:
                # we assume the rm is disrm
                # genrm must set custom_reward_function
             return await self.compute_score_disrm(data[-1:])
        else:
             return await self.reward_manager.run_single(data)

 async def compute_score_disrm(self, data: DataProto) -> dict:
        disrm_prompt = await self._preprocess_reward_inputs(data)
        engine_name = self.config.reward.reward_model.rollout.name
        model_name = self.config.reward.reward_model.model_path
        if engine_name == "vllm":
            payloads = {
                "model": model_name,
                "input": disrm_prompt,
                "use_activation": False,
            }
            output = await self._post_request(payloads, "classify")
            rm_score = output["data"][-1]["probs"][-1]
        elif engine_name == "sglang":
            payloads = {
                "model": model_name,
                "input": disrm_prompt,
            }
            output = await self._post_request(payloads, "v1/embeddings")
            rm_score = output["data"][-1]["embedding"][-1]
        elif engine_name == "trtllm":
            # TODO: remove this once TRT-LLM switches to TorchSampler
            raise ValueError("TensorRT-LLM backend does not support reward models currently.")

            payloads = {
                "model": model_name,
                "prompt": disrm_prompt,
                "return_context_logits": True,
            }
            output = await self._post_request(payloads, "v1/completions")
            rm_score = output["choices"][0]["context_logits"]
            assert isinstance(rm_score, list) and len(rm_score) > 0, (
                "TensorRT-LLM OpenAI server response for reward score is not in the expected format."
            )

            rm_score = float(rm_score[0][0])
            logger.debug(f"rm score: {rm_score}")
        else:
            raise NotImplementedError(f"RewardLoopManager does not support {engine_name}")

        return {"reward_score": rm_score}

        compute_score_batch中异步的进行计算,compute_score中根据配置选择是否使用reward model 还是reward function,compute_score_disrm函数中完成各种框架vllm tensorLLM等score分值的计算,注意到[-1],只取最后一个token位置的分数。

        verl/experimental/reward_loop/reward_manager/base.py中的assemble_rm_scores拼装后得到token level score:

def assemble_rm_scores(cls, data: DataProto, scores: list[float]) -> torch.Tensor:
        """Assemble per-sample reward scores into the ``rm_scores`` tensor for a batch.

        Args:
            data: The concatenated batch passed to :meth:`run_single`.
                ``data.batch["prompts"]``, ``data.batch["responses"]`` and
                ``data.batch["attention_mask"]`` are expected to be present
                for the default implementation.
            scores: List of scalar reward scores, one per sample in ``data``.

        Returns:
            torch.Tensor: The ``rm_scores`` tensor with leading dimension equal to
            ``len(data)``.
        """
        prompt_length = data.batch["prompts"].size(1)
        valid_response_length = data.batch["attention_mask"][:, prompt_length:].sum(dim=1)
        rm_scores = torch.zeros_like(data.batch["responses"], dtype=torch.float32)
        rm_scores[torch.arange(rm_scores.size(0), device=rm_scores.device), valid_response_length - 1] = (
            rm_scores.new_tensor(scores)
        )
        return rm_scores
actor loss
def compute_policy_loss(
    old_log_probs: torch.Tensor,
    log_probs: torch.Tensor,
    advantages: torch.Tensor,
    response_mask: torch.Tensor,
    clip_ratio_low: float,
    clip_ratio_high: float,
    clip_ratio_dual: float,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
    """Compute the policy loss.

    Args:
        old_log_prob: `(torch.Tensor)`
            shape: (bs, response_length)
        log_prob: `(torch.Tensor)`
            shape: (bs, response_length)
        advantages: `(torch.Tensor)`
            shape: (bs, response_length)
        response_mask: `(torch.Tensor)`
            shape: (bs, response_length)
        clip_ratio_low: (float)
            The lower clip range used in PPO. See https://arxiv.org/abs/1707.06347
        clip_ratio_high: (float)
            The higher clip range used in DAPO. See https://arxiv.org/pdf/2503.14476
        clip_ratio_dual: (float)
            The dual clip range used in Dual-clip PPO. See 

    Returns:
        pg_loss: `a scalar torch.Tensor`
            policy gradient loss computed via PPO
        pg_clipfrac_higher: (float)
            a float number indicating the fraction of policy gradient loss being clipped to a higher value
        pg_clipfrac_lower: (float)
            a float number indicating the fraction of policy gradient loss being clipped to a lower value
        ppo_kl: (float)
            a float number indicating the mean KL divergence between the old policy and the new policy

    """
    negative_approx_kl = log_probs - old_log_probs
    # clamp the ratio before exp to avoid nan
    # see: https://github.com/pytorch/pytorch/issues/10729
    ratio = torch.exp(negative_approx_kl)
    clipped_ratio = torch.exp(
        torch.clamp(negative_approx_kl, np.log(1.0 - clip_ratio_low), np.log(1.0 + clip_ratio_high))
    )

    pg_loss = -advantages * ratio
    pg_loss2 = -advantages * clipped_ratio
    pg_loss3 = -advantages * clip_ratio_dual

    clipped_pg_loss_higher = torch.max(pg_loss, pg_loss2)  # clip if pg_loss < pg_loss2
    pg_clipfrac_higher = (pg_loss < pg_loss2).float()
    clipped_pg_loss_lower = torch.min(clipped_pg_loss_higher, pg_loss3)  # clip if pg_loss > pg_loss3 and adv < 0
    final_pg_loss = torch.where(advantages < 0, clipped_pg_loss_lower, clipped_pg_loss_higher)
    pg_clipfrac_lower = (clipped_pg_loss_higher > pg_loss3).float() * (advantages < 0).float()

    final_pg_loss = VF.masked_mean(final_pg_loss, response_mask)
    pg_clipfrac_higher = VF.masked_mean(pg_clipfrac_higher, response_mask)
    pg_clipfrac_lower = VF.masked_mean(pg_clipfrac_lower, response_mask)
    ppo_kl = VF.masked_mean(-negative_approx_kl, response_mask)

和Actor模型的目标函数印证,这里的实现多了一些处理,pg_loss2就已经实现了标准的PPO的actor loss;后续的处理就是对截断的上下界以及小于0的处理,防止优势小于0的时候梯度出现问题。

critic loss
def compute_value_loss(
    vpreds: torch.Tensor,
    returns: torch.Tensor,
    values: torch.Tensor,
    action_mask: torch.Tensor,
    cliprange_value: float,
) -> Tuple[torch.Tensor, float]:
    """Compute the value loss.



    Args:
        vpreds (`torch.FloatTensor`):
            Predicted values of the value head, shape (`batch_size`, `response_length`)
        returns: (`torch.FloatTensor`):
            Ground truth returns, shape (`batch_size`, `response_length`)
        values (`torch.FloatTensor`):
            Old values of value head, shape (`batch_size`, `response_length`)
        action_mask: `(torch.Tensor)`
            shape: (bs, response_length)
        cliprange_value: (float)
            The clip range for value net used in PPO

    Returns:
        vf_loss: a scalar (`torch.FloatTensor`):
            value function loss
        vf_clipfrac: a float
            The ratio of vf being clipped

    """
    vpredclipped = torch.clamp(vpreds, values - cliprange_value, values + cliprange_value)
    vf_loss1 = torch.square(vpreds - returns)
    vf_loss2 = torch.square(vpredclipped - returns)
    vf_loss = 0.5 * VF.masked_mean(torch.max(vf_loss1, vf_loss2), action_mask)  # clip if vf_loss1 < vf_loss2
    vf_clipfrac = VF.masked_mean((vf_loss1 < vf_loss2).float(), action_mask)
    return vf_loss, vf_clipfrac

        它是一个L2也就是MSE均方误差损失函数,是critic模型当前的value(状态价值)对return(也就是Q值当前的动作价值)的拟合,上述实现是verl中的源码,添加了一些clip的策略,让模型训练更稳定效果更好。

3、训练流程

        上述训练流程图,是阅读了verl中ppo训练流程得到的过程示意图。图中还有一个细节没有画出来。就是log_probs的生成中是有old_log_probs和ref_log_probs,其中ref_log_probs就是使用参考模型来生成计算的,作用就是用来限制actor模型更新离参考模型太远了,更新步子不要太大。注意到训练的数据是没有label的,只需要有prompt,没有预先收集好的response,让模型自己去探索生成,使用rewardmodel 的奖励来指导模型参数的更新。

三、低成本的DPO

        PPO作为一个强化学习的算法, LLM 推理和思考能力的提升,具有开创性意义,但是它具有明显的不足。1、训练超参及其敏感,时不时就训练异常,需要反复调整训练超参;2、最明显的就是资源消耗很大,训练一个目标LLM,需要使用Actor、Critic、reference以及reward等4个模型,显存消耗很大。为了找到一条低消耗能让模型具有一定的推理和思考的能力,2023年研究者们对PPO进行了简化,提出了DPO来进行LLM的RLHF的训练,不严格的来说,这个算法其实就是一个带有强化学习和对比学习思想的SFT。

1、关键模型

       DPO训练中使用到的模型就比之前的PPO要少很多了,只需要一个训练的Acotr模型和一个reference模型。架构如下图:
 

        其中reference模型是要冻结的,参数不可更新,而actor模型是要冻结的。注意到,模型的训练数据必须是同一个prompt+chosen_response和prompt+rejected_response,目标就是希望模型经过训练后能够尽可能的选择输出chosen_response(符合人类喜好的或者偏向的)

2、Loss函数

        上述论文的截图中给出了标准DPO损失函数的公式,函数就是最小化新老模型在正负样本概率比值的对数与新老模型在负样本概率比值的对数的差的对数的相反数——进一步理解,由于对数函数单调递增,它就是最大化新模型在正样本概率比值的对数与与新老模型在负样本概率比值的对数的差——拉大正样本和负样本之间的概率的对数差值。这个就是DPO的目标,让模型朝着输出正样本的方向去学习。从实际上来说,上述的loss函数的目标是不会总是达成的。上面的loss函数做转化后就是下图中的公式(使用对数函数变形和加减法的结合):

        显示dpo的目标可以等价于让新模型的正负样本的概率比值 比 老模型的正负样本比值要大,就能降低loss。但是这个loss降低,可能是新模型 正负样本的绝对概率都变小或者变大(只要相对比值比老模型大——老模式是0.6/0.3,新模型是0.3/0.1,这样log3-log2 大于0 降低loss),这种情况下,模型的输出是什么呢?很有可能就是模型原始分布中的其他response。实际训练过程中也可以观察到模型loss降低,但是模型不怎么输出正样本,有很多时候生成其他的样本;更有的时候loss升高了,效果不变坏。也是这个新模型正负样本绝对概率变化导致的。

接下来看看trl框架中dpo_trainer中的loss实现:

def dpo_loss(
        self,
        chosen_logps: torch.FloatTensor,
        rejected_logps: torch.FloatTensor,
        ref_chosen_logps: torch.FloatTensor,
        ref_rejected_logps: torch.FloatTensor,
    ) -> tuple[torch.FloatTensor, torch.FloatTensor, torch.FloatTensor]:
        """
        Compute the DPO loss for a batch of policy and reference model log probabilities.

        Args:
            chosen_logps (`torch.FloatTensor`):
                Log probabilities of the model for the chosen responses. Shape: `(batch_size,)`.
            rejected_logps (`torch.FloatTensor`):
                Log probabilities of the model for the rejected responses. Shape: `(batch_size,)`.
            ref_chosen_logps (`torch.FloatTensor`):
                Log probabilities of the reference model for the chosen responses. Shape: `(batch_size,)`.
            ref_rejected_logps (`torch.FloatTensor`):
                Log probabilities of the reference model for the rejected responses. Shape: `(batch_size,)`.

        Returns:
            A tuple of three tensors: `(losses, chosen_rewards, rejected_rewards)`.
            The losses tensor contains the DPO loss for each example in the batch.
            The `chosen_rewards` and `rejected_rewards` tensors contain the rewards for the chosen and rejected
            responses, respectively.
        """
        device = self.accelerator.device

        # Get the log ratios for the chosen and rejected responses
        chosen_logratios = chosen_logps.to(device) - (not self.reference_free) * ref_chosen_logps.to(device)
        rejected_logratios = rejected_logps.to(device) - (not self.reference_free) * ref_rejected_logps.to(device)

        if self.f_divergence_type == FDivergenceType.ALPHA_DIVERGENCE.value:
            # The alpha-divergence formula: (1 - u^-alpha) / alpha
            # The divergence difference between the chosen and rejected sample is:
            #     (1 - u[w]^-alpha) / alpha - (1 - u[l]^-alpha) / alpha
            #        = (u[l]^-alpha - u[w]^-alpha) / alpha
            # where u[w] and u[l] are the policy/reference probability ratios
            # for the chosen and rejected samples, respectively.
            alpha_coef = FDivergenceConstants.ALPHA_DIVERGENCE_COEF_DEFAULT
            if self.f_divergence_params and FDivergenceConstants.ALPHA_DIVERGENCE_COEF_KEY in self.f_divergence_params:
                alpha_coef = float(self.f_divergence_params[FDivergenceConstants.ALPHA_DIVERGENCE_COEF_KEY])
            logits = (cap_exp(rejected_logratios * -alpha_coef) - cap_exp(chosen_logratios * -alpha_coef)) / alpha_coef
        else:
            logratios = chosen_logps - rejected_logps
            if self.reference_free:
                ref_logratios = torch.tensor([0], dtype=logratios.dtype, device=logratios.device)
            else:
                ref_logratios = ref_chosen_logps - ref_rejected_logps

            logratios = logratios.to(self.accelerator.device)
            ref_logratios = ref_logratios.to(self.accelerator.device)
            logits = logratios - ref_logratios

            if self.f_divergence_type == FDivergenceType.JS_DIVERGENCE.value:
                # The js-divergence formula: log(2 * u / (1 + u))
                # The divergence difference between the chosen and rejected sample is:
                #     log(2 * u[w] / (1 + u[w])) - log(2 * u[l] / (1 + u[l]))
                #       = log(u[w]) - log(u[l]) - (log(1 + u[w]) - log(1 + u[l]))
                # where u[w] and u[l] are the policy/reference probability ratios
                # for the chosen and rejected samples, respectively.
                logits -= F.softplus(chosen_logratios) - F.softplus(rejected_logratios)

        # The beta is a temperature parameter for the DPO loss, typically something in the range of 0.1 to 0.5.
        # We ignore the reference model as beta -> 0. The label_smoothing parameter encodes our uncertainty about the
        # labels and calculates a conservative DPO loss.
        if self.loss_type == "sigmoid":
            losses = (
                -F.logsigmoid(self.beta * logits) * (1 - self.label_smoothing)
                - F.logsigmoid(-self.beta * logits) * self.label_smoothing
            )

        elif self.loss_type == "robust":
            losses = (
                -F.logsigmoid(self.beta * logits) * (1 - self.label_smoothing)
                + F.logsigmoid(-self.beta * logits) * self.label_smoothing
            ) / (1 - 2 * self.label_smoothing)

        elif self.loss_type == "exo_pair":
            # eqn (16) of the EXO paper: https://huggingface.co/papers/2402.00856
            import math

            if self.label_smoothing == 0:
                self.label_smoothing = 1e-3
            losses = (self.beta * logits).sigmoid() * (
                F.logsigmoid(self.beta * logits) - math.log(1 - self.label_smoothing)
            ) + (-self.beta * logits).sigmoid() * (F.logsigmoid(-self.beta * logits) - math.log(self.label_smoothing))

        elif self.loss_type == "hinge":
            losses = torch.relu(1 - self.beta * logits)

        elif self.loss_type == "ipo":
            # eqn (17) of the paper where beta is the regularization parameter for the IPO loss, denoted by tau in the paper.
            losses = (logits - 1 / (2 * self.beta)) ** 2

        elif self.loss_type == "bco_pair":
            chosen_logratios = chosen_logps - ref_chosen_logps
            rejected_logratios = rejected_logps - ref_rejected_logps
            chosen_rewards = self.beta * chosen_logratios
            rejected_rewards = self.beta * rejected_logratios
            rewards = torch.cat((chosen_rewards, rejected_rewards), 0).mean().detach()
            self.running.update(rewards)
            delta = self.running.mean
            losses = -F.logsigmoid((self.beta * chosen_logratios) - delta) - F.logsigmoid(
                -(self.beta * rejected_logratios - delta)
            )

        elif self.loss_type == "sppo_hard":
            # In the paper (https://huggingface.co/papers/2405.00675), SPPO employs a soft probability approach,
            # estimated using the PairRM score. The probability calculation is conducted outside of the trainer class.
            # The version described here is the hard probability version, where P in Equation (4.7) of Algorithm 1 is
            # set to 1 for the winner and 0 for the loser.
            a = chosen_logps - ref_chosen_logps
            b = rejected_logps - ref_rejected_logps
            losses = (a - 0.5 / self.beta) ** 2 + (b + 0.5 / self.beta) ** 2

        elif self.loss_type == "nca_pair":
            chosen_rewards = (chosen_logps - ref_chosen_logps) * self.beta
            rejected_rewards = (rejected_logps - ref_rejected_logps) * self.beta
            losses = (
                -F.logsigmoid(chosen_rewards)
                - 0.5 * F.logsigmoid(-chosen_rewards)
                - 0.5 * F.logsigmoid(-rejected_rewards)
            )

        elif self.loss_type == "aot_pair":
            chosen_logratios = chosen_logps - ref_chosen_logps
            rejected_logratios = rejected_logps - ref_rejected_logps
            chosen_logratios_sorted, _ = torch.sort(chosen_logratios, dim=0)
            rejected_logratios_sorted, _ = torch.sort(rejected_logratios, dim=0)
            delta = chosen_logratios_sorted - rejected_logratios_sorted
            losses = (
                -F.logsigmoid(self.beta * delta) * (1 - self.label_smoothing)
                - F.logsigmoid(-self.beta * delta) * self.label_smoothing
            )

        elif self.loss_type == "aot":
            logratios = chosen_logps - rejected_logps
            ref_logratios = ref_chosen_logps - ref_rejected_logps
            logratios_sorted, _ = torch.sort(logratios, dim=0)
            ref_logratios_sorted, _ = torch.sort(ref_logratios, dim=0)
            delta = logratios_sorted - ref_logratios_sorted
            losses = (
                -F.logsigmoid(self.beta * delta) * (1 - self.label_smoothing)
                - F.logsigmoid(-self.beta * delta) * self.label_smoothing
            )

        elif self.loss_type == "apo_zero":
            # Eqn (7) of the APO paper (https://huggingface.co/papers/2408.06266)
            # Use this loss when you believe the chosen outputs are better than your model's default output
            losses_chosen = 1 - F.sigmoid(self.beta * chosen_logratios)  # Increase chosen likelihood
            losses_rejected = F.sigmoid(self.beta * rejected_logratios)  # Decrease rejected likelihood
            losses = losses_chosen + losses_rejected

        elif self.loss_type == "apo_down":
            # Eqn (8) of the APO paper (https://huggingface.co/papers/2408.06266)
            # Use this loss when you believe the chosen outputs are worse than your model's default output.
            # Decrease chosen likelihood and decrease rejected likelihood more
            losses_chosen = F.sigmoid(self.beta * chosen_logratios)
            losses_rejected = 1 - F.sigmoid(self.beta * (chosen_logratios - rejected_logratios))
            losses = losses_chosen + losses_rejected

        elif self.loss_type == "discopop":
            # Eqn (5) of the DiscoPOP paper (https://huggingface.co/papers/2406.08414)
            # This loss was discovered with LLM discovery
            logratios = chosen_logps - rejected_logps
            ref_logratios = ref_chosen_logps - ref_rejected_logps
            logits = logratios - ref_logratios
            logits = logits * self.beta
            # Modulate the mixing coefficient based on the log ratio magnitudes
            log_ratio_modulation = torch.sigmoid(logits / self.args.discopop_tau)
            logistic_component = -F.logsigmoid(logits)
            exp_component = torch.exp(-logits)
            # Blend between logistic and exponential component based on log ratio modulation
            losses = logistic_component * (1 - log_ratio_modulation) + exp_component * log_ratio_modulation

        上述代码是完整的实现,里面包含了很多种对新老模型对数概率差值的处理方案,我们只看最标准的sigmoid方案,把核心的代码切出来看,如下:

logratios = chosen_logps - rejected_logps
ref_logratios = ref_chosen_logps - ref_rejected_logps
logits = logratios - ref_logratios
losses = (
                -F.logsigmoid(self.beta * logits) * (1 - self.label_smoothing)
                - F.logsigmoid(-self.beta * logits) * self.label_smoothing
            )

        这个论文的公式就是完全一一对应的,只不过加了smooth处理,这个正则化的处理,就是防止模型对样本对的概率差值太绝对了,让模型有更强的容错。

四、R1类思考模型的GRPO

        DPO算法简化了PPO训练成本,它比较擅长让模型不学习到人类不希望模型生成的知识,但是模型的思考和推理能力并没有提升。2025年Deepseek 在其Deepseek v3基座模型的基础上,使用强化学习的方式进行RL训练,发布了带有思考过程的Deepseek R1模型,因为它的思考和推理效果惊艳、有质的提升,同时开源训练方案以及训练方法确实相对以前的方法简单有效,迅速引发广泛关注。一时间,DeepSeek R1 热度飙升,在全球 AI 领域掀起热潮,成为现象级产品。其使用的强化学习算法GRPO以及训练方案都值得学习。

1、关键模型和算法流程

     

        grpo整体算法的模型架构和ppo大同小异,整体而言,少了critic model 和reward模型,reward model使用了reward-function 来替换的,GAE的计算替换为组内优势归一化

2、Loss函数的理解以及代码阅读

论文中的loss函数

  DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models

另外deepseek的论文中DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning

        这个损失函数和之前PPO的损失函数大同小异,都是token级别的,核心的不同点有2个

        a、grpo的损失中是有分组的概念的,同一个prompt生成的多个response都是同一个组的,因此损失函数的公式有有G以及O有下标i——表示这个token是那个分组的(注意也有t,这个t表示的是一个response中的具体位置的token)

        b、优势Ait的含义也是不同的,这个是组内的相对优势(相对组内其他序列而言,比组内序列的平均要好多少——而PPO则是想到critic模型当前状态的生成要好多少),同样有下标i和t——这里也是值得注意的,这个Ai虽然有下标t,但是并不是代表没给token的优势不同,它们是一样的,通过上图公式3可以看出,A是通过分组的序列计算的,因此同一个序列的所有token优势是相同的,之所以有个下标t,感觉是为了loss函数的公式形式上统一,更好区分和PPO的不同。
组内优势归一化实现如下:

def compute_grpo_outcome_advantage(
    token_level_rewards: torch.Tensor, response_mask: torch.Tensor, index: torch.Tensor, eps: float = 1e-6
) -> Tuple[torch.Tensor, torch.Tensor]:
    """
    Compute advantage for GRPO, operating only on Outcome reward
    (with only one scalar reward for each response).

    Args:
        token_level_rewards: `(torch.Tensor)`
            shape: (bs, response_length)
        response_mask: `(torch.Tensor)`
            shape: (bs, response_length)

    Returns:
        advantages: `(torch.Tensor)`
            shape: (bs, response_length)
        returns: `(torch.Tensor)`
            shape: (bs, response_length)

    """
    scores = token_level_rewards.sum(dim=-1)
    id2score = defaultdict(list)
    id2mean, id2std = {}, {}

    bsz = scores.shape[0]
    for i in range(bsz):
        id2score[index[i]].append(scores[i])

    for idx in id2score:
        assert len(id2score[idx]) > 1, "GRPO needs rollout.n > 1."
        id2mean[idx] = torch.mean(torch.tensor(id2score[idx]))
        id2std[idx] = torch.std(torch.tensor(id2score[idx]))

    for i in range(bsz):
        scores[i] = (scores[i] - id2mean[index[i]]) / (id2std[index[i]] + eps)

    returns = scores.unsqueeze(-1) * response_mask
    return returns, returns

        其中index是用来记录batch中相同prompt不同response的分组。然后就是计算均值和方差了,最后做归一化。其他的地方不用怎么解读了,就是需要注意A的计算方式,公式3中给出明确的含义和计算方式,其中的r就是我们的reward_function给出来的。

3、训练流程

4、PPO和GRPO的对比

        上图中形象的给出来PPo和GRPO的差异,具体的一些差异如下

1、模型架构差异

        PPO 需要维护 4 个模型:Actor(策略网络)、Critic(价值网络)、Reference(参考模型)和 Reward Model(奖励模型)。其中 Critic 和 Reward Model 都需要单独训练,显存占用高。

        GRPO 只需 2 个模型:Actor 和 Reference。奖励信号可由规则函数直接计算,无需训练额外的 Reward Model,且完全摒弃了 Critic 网络,显存占用降低约 50%。

2、优势计算差异

        PPO 采用广义优势估计(GAE),依赖 Critic 网络预测的基线值 V(s) 来计算每个 token 的优势。这种方法需要训练价值网络,且 GAE 引入 γ、λ 两个超参数。

        GRPO 采用组内优势归一化,对同一 prompt 生成的 G 个响应,计算组内均值和标准差,然后归一化得到优势值。整个过程无需训练,且同一响应内的所有 token 共享该优势值。

3、损失函数差异

        PPO 的损失函数由三部分组成:策略损失(Clip Loss)+ 价值损失(MSE)+ 熵正则。其中价值损失需要拟合状态价值,增加了训练负担。

        GRPO 的损失函数更简洁:策略损失(Clip Loss)+ 显式 KL 惩罚 + 熵正则。无需价值损失,KL 散度以显式惩罚项形式加入,对策略更新的约束更可控。

4、采样效率差异

        PPO 对每个 prompt 只生成 1 个响应,每次更新仅依赖单样本,数据利用率较低。

        GRPO 对每个 prompt 生成 G 个响应(通常 4-8 个),通过组内比较实现"自己与自己比",数据利用效率更高。

5、训练稳定性差异

        PPO 的优势估计强烈依赖 Critic 网络的精度,Critic 训练不稳会直接影响策略更新。

        GRPO 无 Critic,优势完全基于组内统计,避免了价值网络误差传播问题。同时 KL 散度以显式惩罚约束更新幅度,训练更稳定

6、适用场景建议

        PPO 适合:传统 RLHF 偏好对齐、需要 token 级细粒度优势的场景。

        GRPO 适合:资源受限环境、生成性任务(创作、对话)、快速实验迭代。

        GRPO 不适合:确定性任务(信息提取、分类)、奖励信号稀疏的场景。

图解大模型RLHF系列之:人人都能看懂的PPO原理与源码解读

Direct Preference Optimization: Your Language Model is Secretly a Reward Model

简单例子说明 DPO 为什么可能表现不好

DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models

DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning

verl 源码

AI 时代程序员必备技能

Codex、Claude Code、Cursor、Hermes Agent、OpenClaw等工程化实战专栏 ,讲透 AI 如何接管脏活累活

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值