AGI|DeepSeek R1训练原理拆解:如何用GRPO革新传统训练

目录

一、概述

二、GRPO原理

基本原理

源码解析

三、关键流程

环境配置

Step 1 模型蒸馏

Step 2 GRPO强化学习

Step 3 完整的R1训练流程

四、总结


一、概述

近日,DeepSeek R1受到了广泛的关注,其突破性表现源于其创新的训练策略,尤其是在强化学习(RL)和架构优化上的革新。该模型通过群组相对策略优化(GRPO)算法替代传统的近端策略优化(PPO),显著降低了训练复杂度。GRPO无需独立的价值模型,采用基于组的相对优势估计,不仅减少了内存和计算开销,还增强了数学、代码推理能力。

在DeepSeek R1推出后不久,许多人纷纷开始讨论其原理,复现其训练流程,试图验证其方法的有效性并探索潜在的改进空间。比较有代表性的trl、open-r1和simpleRL-reason等开源库,让我们跟随他们的脚步,一起探索DeepSeek R1的训练原理及流程。

二、GRPO原理

基本原理

群组相对策略优化(Group Relative Policy Optimization, GRPO)方法是Deepseek R1训练的核心方法,其源自DeepSeekMath这篇文章,GRPO方法在PPO方法的基础上,它舍弃了critic(value) model,而是使用群组评价作为价值基线,因此显著减少了训练的资源占用。

在了解GRPO之前,我们先看常规的PPO算法,参与训练的有4个模型。分别是Policy model、Reference model、Reward model和Value model。

  • Policy model: 被训练的模型,根据问题q生成答案o,可以更新梯度。
  • Reference model: 参考模型,一般与Policy model同源,但是参数是冻结的,不可更新梯度,输入问题q,输出答案的概率分布,它用于约束Policy model的更新幅度,确保梯度更新后的Policy model输出的概率分布与Reference model相差不要太大。
  • Reward model: 奖励模型,负责给整个结果(问题+答案)打分。一般需要使用偏好数据集,单独训练一个reward模型。
  • Value model:价值模型,也叫critic model,可以更新梯度,用于预测当前token到结束时的期望回报。

PPO的核心思想是通过优化策略来最大化期望回报,通过最大化以下目标函数实现。

四个模型的输出为目标函数提供必要的变量,结合上图看,A是优势(advantage),用于衡量当前状态下,进行某一动作相对其他动作的优越性,它是基于奖励r和价值v并使用GAE算法计算:

其中奖励r来自于Reward model,并考虑Policy model和Reference model的KL散度来计算。公式是:

价值v是由Value model预测而来,从图中可以看到,Value model是一个可训练的模型,随着训练不断更新自己的价值预测。

由此可知,在计算优势A的过程中,有4个模型参与,特别是Value model,其大小需要与Policy模型相当,也需要更新梯度,这会带来大量的内存和计算开销。

那有没有办法能够绕过Value model来计算优势A呢?GRPO给出了一种答案。

GRPO摒弃了价值函数模型的训练,而是通过组内奖励分数来估计基准线(advantage),大幅降低了训练资源的需求。如图所示。GRPO首先根据问题q,生成一组回答(o1,...oG),然后问题+回答输入Reference模型,并与Policy模型计算KL散度,约束Policy模型的更新幅度,此外,问题+回答输入Reward模型中输出一组奖励分数(r1,...rG),与PPO显著不同的是,GRPO使用这一组奖励分数来估算A,具体的公式是:

这种方法显著简化了A的计算,不需要value模型,只需要一组r即可。

最后的目标函数是:

从目标函数还可以看出,GRPO中Policy模型和Reference模型的KL散度没有参与r的计算,而是放到了外层。

源码解析

以上是GRPO的粗略原理,比较晦涩难懂,让我们看看代码,可能对整个流程会更清晰一些。

以trl库的GRPO实现为例。trl提供了GRPOTrainer类,实现了关键的训练流程。

首先看GRPOTrainer的__init__方法

# 代码不完整,只展示主要功能
class GRPOTrainer(Trainer):
    def __init__(
        self,
        model: Union[str, PreTrainedModel], # 模型:用于初始化policy模型和reference模型
        reward_funcs: Union[RewardFunc, list[RewardFunc]], # 奖励函数,可以是函数或者模型
        args: GRPOConfig = None, # GRPO训练相关的配置
        train_dataset: Optional[Union[Dataset, IterableDataset]] = None, # 训练集
        eval_dataset: Optional[Union[Dataset, IterableDataset, dict[str, Union[Dataset, IterableDataset]]]] = None, # 验证机
        processing_class: Optional[PreTrainedTokenizerBase] = None, # tokenizer类
        reward_processing_classes: Optional[Union[PreTrainedTokenizerBase, list[PreTrainedTokenizerBase]]] = None, # 与奖励函数对应的处理类
        callbacks: Optional[list[TrainerCallback]] = None, # callbacks方法
        optimizers: tuple[Optional[torch.optim.Optimizer], Optional[torch.optim.lr_scheduler.LambdaLR]] = (None, None), # 优化器方法
        peft_config: Optional["PeftConfig"] = None, # PEFT(参数高效微调)配置,如果启用PEFT,传入该配置
    ):
    # 1.初始化policy模型
    # 2.初始化reference模型
    # 3.初始化tokenizer
    # 4.初始化reward模型/函数及相关配置
    # 5.初始化训练配置

完成GRPOTrainer初始化后,即可调用train方法训练,train实现了_inner_training_loop方法。

# 代码不完整,只展示主要功能
def _inner_training_loop():
    for epoch in range(epochs_trained, num_train_epochs): # epoch遍历
        # 根据梯度累计部署换算更新频率
        total_updates = steps_in_epoch // args.gradient_accumulation_steps + 1 
        for _ in range(total_updates):
            update_step += 1
            num_batches = args.gradient_accumulation_steps if update_step != (total_updates - 1) else remainder
            batch_samples, num_items_in_batch = self.get_batch_samples(epoch_iterator, num_batches)
            # 获取batch数据,送入training_step训练
            for i, inputs in enumerate(batch_samples):
                tr_loss_step = self.training_step(model, inputs, num_items_in_batch)

training_step提供了两个关键方法,

# 代码不完整,只展示主要功能
def training_step(
    self, model: nn.Module, inputs: Dict[str, Union[torch.Tensor, Any]], num_items_in_batch=None
) -> torch.Tensor:
    model.train()
    # 准备数据
    inputs = self._prepare_inputs(inputs)
    # 计算loss
    with self.compute_loss_context_manager():
        loss = self.compute_loss(model, inputs, num_items_in_batch=num_items_in_batch)

_prepare_inputs用于准备数据,涉及到Policy模型、Reference模型、Reward模型/函数的推理和优势A的计算等。

优势计算,对应advantages变量

# 代码不完整,只展示主要功能
def _prepare_inputs(self, inputs: dict[str, Union[torch.Tensor, Any]]) -> dict[str, Union[torch.Tensor, Any]]:
    device = self.accelerator.device
    # 获取问题
    prompts = [x["prompt"] for x in inputs]
    # 应用问题模板
    prompts_text = [maybe_apply_chat_template(example, self.processing_class)["prompt"] for example in inputs]
    # tokenizer
    prompt_inputs = self.processing_class(
        prompts_text, return_tensors="pt", padding=True, padding_side="left", add_special_tokens=False
    )
    # 预处理数据,这里涉及的主要是格式转换和加载到设备
    prompt_inputs = super()._prepare_inputs(prompt_inputs)
    prompt_ids, prompt_mask = prompt_inputs["input_ids"], prompt_inputs["attention_mask"]

    if self.max_prompt_length is not None:
        prompt_ids = prompt_ids[:, -self.max_prompt_length :]
        prompt_mask = prompt_mask[:, -self.max_prompt_length :]

    # 这一部分是调用policy模型,生成补全(答案)
    if self.args.use_vllm:
        # First, have main process load weights if needed
        if self.state.global_step != self._last_loaded_step:
            self._move_model_to_vllm()
            self._last_loaded_step = self.state.global_step

        # Generate completions using vLLM: gather all prompts and use them in a single call in the main process
        all_prompts_text = gather_object(prompts_text)
        if self.accelerator.is_main_process:
            outputs = self.llm.generate(all_prompts_text, sampling_params=self.sampling_params, use_tqdm=False)
            completion_ids = [out.token_ids for completions in outputs for out in completions.outputs]
        else:
            completion_ids = [None] * len(all_prompts_text)
        # Broadcast the completions from the main process to all processes, ensuring each process receives its
        # corresponding slice.
        completion_ids = broadcast_object_list(completion_ids, from_process=0)
        process_slice = slice(
            self.accelerator.process_index * len(prompts),
            (self.accelerator.process_index + 1) * len(prompts),
        )
        completion_ids = completion_ids[process_slice]

        # Pad the completions, and concatenate them with the prompts
        completion_ids = [torch.tensor(ids, device=device) for ids in completion_ids]
        completion_ids = pad(completion_ids, padding_value=self.processing_class.pad_token_id)
        prompt_completion_ids = torch.cat([prompt_ids, completion_ids], dim=1)
    else:
        # Regular generation path
        with unwrap_model_for_generation(self.model, self.accelerator) as unwrapped_model:
            prompt_completion_ids = unwrapped_model.generate(
                prompt_ids, attention_mask=prompt_mask, generation_config=self.generation_config
            )

        # Compute prompt length and extract completion ids
        prompt_length = prompt_ids.size(1)
        prompt_ids = prompt_completion_ids[:, :prompt_length]
        completion_ids = prompt_completion_ids[:, prompt_length:]

    # Mask everything after the first EOS token
    is_eos = completion_ids == self.processing_class.eos_token_id
    eos_idx = torch.full((is_eos.size(0),), is_eos.size(1), dtype=torch.long, device=device)
    eos_idx[is_eos.any(dim=1)] = is_eos.int().argmax(dim=1)[is_eos.any(dim=1)]
    sequence_indices = torch.arange(is_eos.size(1), device=device).expand(is_eos.size(0), -1)
    completion_mask = (sequence_indices <= eos_idx.unsqueeze(1)).int()

    # Concatenate prompt_mask with completion_mask for logit computation
    attention_mask = torch.cat([prompt_mask, completion_mask], dim=1)  # (B*G, P+C)

    logits_to_keep = completion_ids.size(1)  # we only need to compute the logits for the completion tokens
    # reference模型生成logprobs,为后续新旧策略的KL散度计算做准备
    with torch.inference_mode():
        if self.ref_model is not None:
            ref_per_token_logps = self._get_per_token_logps(
                self.ref_model, prompt_completion_ids, attention_mask, logits_to_keep
            )
        else:
            with self.accelerator.unwrap_model(self.model).disable_adapter():
                ref_per_token_logps = self._get_per_token_logps(
                    self.model, prompt_completion_ids, attention_mask, logits_to_keep
                )

    # 解码completion_ids为文字,为后续计算奖励做准备
    completions_text = self.processing_class.batch_decode(completion_ids, skip_special_tokens=True)
    if is_conversational(inputs[0]):
        completions = []
        for prompt, completion in zip(prompts, completions_text):
            bootstrap = prompt.pop()["content"] if prompt[-1]["role"] == "assistant" else ""
            completions.append([{"role": "assistant", "content": bootstrap + completion}])
    else:
        completions = completions_text
    # 计算奖励
    rewards_per_func = torch.zeros(len(prompts), len(self.reward_funcs), device=device)
    for i, (reward_func, reward_processing_class) in enumerate(
        zip(self.reward_funcs, self.reward_processing_classes)
    ):
        if isinstance(reward_func, nn.Module):  # Module instead of PretrainedModel for compat with compiled models
            if is_conversational(inputs[0]): # 如果是reward模型,评分模型
                messages = [{"messages": p + c} for p, c in zip(prompts, completions)]
                texts = [apply_chat_template(x, reward_processing_class)["text"] for x in messages]
            else:
                texts = [p + c for p, c in zip(prompts, completions)]
            reward_inputs = reward_processing_class(
                texts, return_tensors="pt", padding=True, padding_side="right", add_special_tokens=False
            )
            reward_inputs = super()._prepare_inputs(reward_inputs)
            with torch.inference_mode():
                rewards_per_func[:, i] = reward_func(**reward_inputs).logits[:, 0]  # Shape (B*G,)
        else:
            # Repeat all input columns (but "prompt" and "completion") to match the number of generations
            keys = [key for key in inputs[0] if key not in ["prompt", "completion"]]
            reward_kwargs = {key: [example[key] for example in inputs] for key in keys}
            output_reward_func = reward_func(prompts=prompts, completions=completions, **reward_kwargs)
            rewards_per_func[:, i] = torch.tensor(output_reward_func, dtype=torch.float32, device=device)

    # Gather the reward per function: this part is crucial, because the rewards are normalized per group and the
    # completions may be distributed across processes
    rewards_per_func = gather(rewards_per_func)

    # 对多种奖励加权,获得最终奖励
    rewards = (rewards_per_func * self.reward_weights.to(device).unsqueeze(0)).sum(dim=1)

    # Compute grouped-wise rewards
    mean_grouped_rewards = rewards.view(-1, self.num_generations).mean(dim=1)
    std_grouped_rewards = rewards.view(-1, self.num_generations).std(dim=1)

    # 计算优势
    mean_grouped_rewards = mean_grouped_rewards.repeat_interleave(self.num_generations, dim=0)
    std_grouped_rewards = std_grouped_rewards.repeat_interleave(self.num_generations, dim=0)
    advantages = (rewards - mean_grouped_rewards) / (std_grouped_rewards + 1e-4)

    # Slice to keep only the local part of the data
    process_slice = slice(
        self.accelerator.process_index * len(prompts),
        (self.accelerator.process_index + 1) * len(prompts),
    )
    advantages = advantages[process_slice]
    # 返回计算loss必须的数据
    return {
        "prompt_ids": prompt_ids, # 输入文本(问题)的token id
        "prompt_mask": prompt_mask, # 输入文本(问题)的mask
        "completion_ids": completion_ids, # 输出文本(答案)的token id
        "completion_mask": completion_mask, # 输出文本(答案)的mask
        "ref_per_token_logps": ref_per_token_logps, # 参考模型输出的每个token的对数概率
        "advantages": advantages, # 优势值
    }

compute_loss使用预处理的数据进行loss计算。涉及的公式有:

计算新旧策略的KL散度,对应per_token_kl

计算loss

# 代码不完整,只展示主要功能
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
    if return_outputs:
        raise ValueError("The GRPOTrainer does not support returning outputs")

    prompt_ids, prompt_mask = inputs["prompt_ids"], inputs["prompt_mask"]
    completion_ids, completion_mask = inputs["completion_ids"], inputs["completion_mask"]
    input_ids = torch.cat([prompt_ids, completion_ids], dim=1)
    attention_mask = torch.cat([prompt_mask, completion_mask], dim=1)
    # 计算当前completion(答案)的对数概率
    logits_to_keep = completion_ids.size(1)
    per_token_logps = self._get_per_token_logps(model, input_ids, attention_mask, logits_to_keep)

    # 计算当前与参考的KL散度
    ref_per_token_logps = inputs["ref_per_token_logps"]
    per_token_kl = torch.exp(ref_per_token_logps - per_token_logps) - (ref_per_token_logps - per_token_logps) - 1

    # x - x.detach() allows for preserving gradients from x
    advantages = inputs["advantages"]
    # 这里只对采样数据训练了一轮,所以新旧策略是一个,
    # advantages是没有梯度的,
    # torch.exp(per_token_logps - per_token_logps.detach())始终为1
    # 这个写法是为了让per_token_loss可以被更新
    per_token_loss = torch.exp(per_token_logps - per_token_logps.detach()) * advantages.unsqueeze(1)
    per_token_loss = -(per_token_loss - self.beta * per_token_kl)
    loss = ((per_token_loss * completion_mask).sum(dim=1) / completion_mask.sum(dim=1)).mean()
    return loss

三、关键流程

下面我们基于open-r1库,动手实践Deepseek R1的关键训练流程。

open-r1是在trl库基础上构建的,专用于复现Deepseek R1的开源库,它实现了Deepseek R1的GRPO训练流程,奖励函数,数据生成等关键功能。

如图所示,open-r1将Deepseek R1论文中的训练步骤分为三部分。

Step 1是蒸馏Distill的过程。这一部分是在Deepseek-R1训练完成后,基于该模型生成一些训练数据,对Qwen、Llama等小型模型做简单的SFT。输入是各类输出是各种版本的distill模型。

Step 2是一个强化学习RL过程,使用了GRPO。输入是base model(论文中是Deepseek-v3-Base)、推理数据中的问题,输出是R1-zero模型。

Step 3是完整的R1训练过程,包括SFT和GRPO两个阶段,首先,对base model做SFT,训练数据来自于R1-zero,以及其他方式收集,然后对SFT后的模型,再做一个完整的GRPO,等到最终的R1模型。

环境配置

首先配置训练环境,因为open-r1依赖于cuda,需要安装cuda 12.4 Toolkit,为了方便起见,我们直接只用docker hub的cuda 12.4镜像。详细流程如下

docker pull nvidia/cuda:12.4.1-cudnn-devel-ubuntu22.04
# 拉取open-r1源码
git clone https://github.com/huggingface/open-r1.git
cd open-r1
# 启动容器
docker run -it -v $PWD:/openrlhf --shm-size 16g cuda:12.4.1镜像tag bash
# 使用uv安装虚拟环境
apt update
apt install curl
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv openr1 --python 3.11 && source openr1/bin/activate && uv pip install --upgrade pip --link-mode=copy
# 安装vllm加速推理
uv pip install vllm==0.7.2 --link-mode=copy
# 编译open-r1库
GIT_LFS_SKIP_SMUDGE=1 uv pip install -e ".[dev]" --link-mode=copy
# 编译中有可能报错https://github.com/huggingface/open-r1/issues/282
# 只需在上述命令后加--no-build-isolation即可
huggingface-cli login # 登录huggingface,方便把训练的模型上传到huggingface平台
wandb login # 登录wandb,方便监控训练流程
# 安装git-lfs方便上传大文件
git-lfs --version
apt-get install git-lfs

Step 1 模型蒸馏

这一部分与Deepseek R1的训练本身无关,是根据训练后的R1模型,进一步蒸馏得到小参数模型。

open-r1提供了蒸馏脚本,src/open_r1/sft.py

这里使用Qwen/Qwen2.5-0.5B-Instruct作为基座模型,使用HuggingFaceH4/Bespoke-Stratos-17k推理数据集,回答中<|begin_of_thought|>和<|end_of_thought|>包裹模型的思考,使用<|begin_of_solution|>和<|end_of_solution|>包裹最终答案。

[{"from": "user","value": "question"},{"from": "assistant","value": "<|begin_of_thought|> think <|end_of_thought|>\n\n<|begin_of_solution|> solution <|end_of_solution|>"}]

启动脚本

ACCELERATE_LOG_LEVEL=info accelerate launch --config_file recipes/accelerate_configs/zero3.yaml \
    src/open_r1/sft.py \
    --config recipes/Qwen2.5-0.5B-Instruct/sft/config_demo.yaml

启动训练后,我们可以在终端打印中点击wandb链接,查看训练指标。

Step 2 GRPO强化学习

GRPO的入口在src/open_r1/grpo.py

使用Qwen/Qwen2.5-0.5B-Instruct模型和AI-MO/NuminaMath-TIR数据集

注意,代码中使用了R1原文的提示词作为系统提示词。

SYSTEM_PROMPT = (
    "A conversation between User and Assistant. The user asks a question, and the Assistant solves it. The assistant "
    "first thinks about the reasoning process in the mind and then provides the user with the answer. The reasoning "
    "process and answer are enclosed within <think> </think> and <answer> </answer> tags, respectively, i.e., "
    "<think> reasoning process here </think><answer> answer here </answer>"
)

只使用了数据集中的problem问题,答案是GRPO过程中模型给出的。

def make_conversation(example):
    return {
        "prompt": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": example["problem"]},
        ],
    }

值得注意的是,R1在GRPO时没有使用神经网络,而是采用了基于规则的奖励,准确性奖励和格式奖励等函数

# Get reward functions
REWARD_FUNCS_REGISTRY = {
    "accuracy": accuracy_reward,
    "format": format_reward,
    "reasoning_steps": reasoning_steps_reward,
    "cosine": get_cosine_scaled_reward(
        min_value_wrong=script_args.cosine_min_value_wrong,
        max_value_wrong=script_args.cosine_max_value_wrong,
        min_value_correct=script_args.cosine_min_value_correct,
        max_value_correct=script_args.cosine_max_value_correct,
        max_len=script_args.cosine_max_len,
    ),
    "repetition_penalty": get_repetition_penalty_reward(
        ngram_size=script_args.repetition_n_grams,
        max_penalty=script_args.repetition_max_penalty,
    ),
    "length": len_reward,
}
reward_funcs = [REWARD_FUNCS_REGISTRY[func] for func in script_args.reward_funcs]

启动脚本

ACCELERATE_LOG_LEVEL=info accelerate launch --config_file recipes/accelerate_configs/zero2.yaml \
    --num_processes=7 src/open_r1/grpo.py \
    --config recipes/DeepSeek-R1-Distill-Qwen-1.5B/grpo/config_demo.yaml

同样,在wandb可以看到训练流程

Step 3 完整的R1训练流程

最后,我们再通过一张图,了解一下完整的R1训练流程。

四、总结

本次分享,讨论了DeepSeek-R1训练原理及流程。其中,GRPO方法证明了纯RL方案的潜力,但也存在混合语言和可读性差问题。在此基础上,R1通过2轮SFT和2轮RL,提升了的推理能力,并且对齐了模型的安全性和实用性,推理能力达到与openai-o1-1217相当的水平,但在通用能力方面,比如工具调用(function call),多轮对话,格式化输出仍然不尽人意。

open-r1为DeepSeek-R1的复现提供了方便可靠的工具,本文介绍了SFT和GRPO的关键代码思路和训练流程,但是完整的R1训练流程和细节仍然值得进一步探讨,希望这篇文章能让你对DeepSeek-R1的训练有一个初步了解,让我们赶紧动手,一起探索大模型的顿悟时刻(aha moment)吧。

参考资料:

  1. https://arxiv.org/abs/2402.03300
  2. https://arxiv.org/abs/2501.12948
  3. https://github.com/huggingface/open-r1

作者:张传辉|高级AI工程师

版权声明:本文由神州数码云基地团队整理撰写,若转载请注明出处。

公众号搜索神州数码云基地,回复【AI】进入AI社群讨论。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值