DeepSeek R1 的完整训练流程核心在于,在其基础模型 DeepSeek V3 之上,运用了多种强化学习策略。
本文将从一个可本地运行的基础模型起步,并参照其技术报告,完全从零开始构建 DeepSeek R1,理论结合实践,逐步深入每个训练环节。通过可视化方式,由浅入深地解析 DeepSeek R1 的工作机制。
本文的代码可在github上获得,并且我将英文的注释翻译成了中文,项目文件结构:
train-deepseek-r1/
├── code.ipynb # Jupyter Notebook 代码实现
├── requirements.txt # 依赖库列表
└── r1_for_dummies.md # 面向非技术受众的 DeepSeek R1 解释
环境配置
首先,克隆代码仓库并执行以下命令安装必要的库:
git clone https://github.com/FareedKhan-dev/train-deepseek-r1.git
cd train-deepseek-r1
pip install -r requirements.txt
接下来,导入所需的 Python 库:
# 导入必要的库
import logging
import os
import sys
import re
import math
from dataclasses import dataclass, field
from typing import List, Optional
# 导入 PyTorch 与deep hub Hugging Face Transformers
import torch
import transformers
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
HfArgumentParser,
TrainingArguments,
set_seed,
TrainerCallback,
TrainerControl,
TrainerState,
)
from transformers.trainer_utils import get_last_checkpoint
# 导入数据集工具库
import datasets
from datasets import load_dataset
# 导入 TRL (Transformers Reinforcement Learning deep—hub) 库
from trl import (
AutoModelForCausalLMWithValueHead,
PPOConfig,
PPOTrainer,
GRPOTrainer,
GRPOConfig,
SFTTrainer
)
# 导入数学相关工具库
from latex2sympy2_extended import NormalizationConfig
from math_verify import LatexExtractionConfig, parse, verify
训练数据集
尽管 DeepSeek R1 的技术报告未明确指定强化学习预训练的初始数据集,但根据其目标,我们推断数据集应侧重于推理能力。
为尽可能贴近 DeepSeek R1 的复现,本文采用以下两个开源的 Hugging Face 推理数据集:
- NuminaMath-TIR:用于 R1 Zero 阶段的训练。
- Bespoke-Stratos-17k:用于 R1 阶段的训练。
NuminaMath-TIR 数据集由 DigitalLearningGmbH 发布,包含 7 万个数学问题,
messages
列详细记录了解题过程的思维链 (Chain-of-Thought, COT) 推理。
以下是该数据集的样本示例:
# 从 DigitalLearningGmbH 加载 "AI-MO/NuminaMath-TIR" 数据集
MATH_le = load_dataset("AI-MO/NuminaMath-TIR", "default")
# 访问训练集首个样本
MATH_le['train'][0]
#### 输出 ####
{
'problem': 'What is the degree of the polynomial 4 +5x^3 ... ',
'solution': 'This polynomial is not written in ...',
'messages': [{'from': 'user', 'value': 'The problem ...'}]
}
#### 输出 ####
Bespoke-Stratos 数据集由 bespokelabs 提供,包含 1.7 万个问题,专注于数学和代码相关的推理任务。
以下是 Bespoke-Stratos 数据集的样本示例:
# 从 bespokelabs 加载 "Bespoke-Stratos-17k" 数据集
bespoke_rl = load_dataset("bespokelabs/Bespoke-Stratos-17k", "default")
# 访问训练集首个样本
bespoke_rl['train'][0]
#### 输出 ####
{
'system': 'Your role as an assistant involves ... ',
'conversations': [{'from': 'user', 'value': 'Return your ... deep hub'}]
}
##### 输出 ####
数据集的选择并非局限于上述两个,您可以根据需求选用其他数据集,但需确保数据集侧重于推理能力,即包含问题及其详细的逐步解答。
DeepSeek R1 训练流程概览
在深入技术细节之前,先对 DeepSeek R1 的训练流程进行简要概述。DeepSeek R1 并非从零开始训练,而是基于 DeepSeek 团队已有的强大语言模型 DeepSeek-V3。为了进一步提升模型的推理能力,DeepSeek 团队采用了强化学习方法。
强化学习 (Reinforcement Learning, RL) 的核心思想是:当语言模型在推理任务中表现出色时,给予奖励;反之,则施以惩罚。
DeepSeek R1 的训练并非单一的训练过程,而是一个多阶段的复杂流程,可称之为训练管线。首先DeepSeek 团队进行了纯粹的 强化学习 尝试,旨在探索推理能力是否能够自发涌现,这一阶段产出了 DeepSeek-R1-Zero 模型,可视作一次探索性实验。对于 正式的 DeepSeek-R1 模型,训练流程被进一步细化和组织。训练管线包含多个阶段,包括预训练数据准备、强化学习训练、数据迭代和多轮强化学习等步骤,如同模型能力逐级提升的过程。
整个训练流程的核心目标是显著提升语言模型的问题分析和深入思考能力。
以上是对 DeepSeek R1 训练流程的高度概括,后续章节将深入剖析每个训练阶段的具体细节。
基础模型选型
DeepSeek 团队选用 DeepSeek-V3 作为 R1 Zero 和 R1 的基础模型。然而,DeepSeek-V3 模型规模庞大,模型体积高达 685 GB 💀,这对个人开发者而言显然难以企及。
为降低实验门槛,本文选用规模更小的基础模型 Qwen/Qwen2.5–0.5B-Instruct (模型体积 0.9 GB)。若你拥有更充裕的 GPU 内存,可考虑加载更大规模的模型,如 Qwen/Qwen2.5–7B-Instruct。
以下是所选基础模型 Qwen2.5–0.5B-Instruct 的部分配置信息:
MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
OUTPUT_DIR = "data/Qwen-GRPO-training" # 用于保存训练后模型
# 创建输出目录,如果目录不存在
os.makedirs(OUTPUT_DIR, exist_ok=True)
# 初始化 tokenizer,并指定聊天模板
tokenizer = AutoTokenizer.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
padding_side="right"
)
# 若 pad token 未设置deephub,则指定 pad token 为 eos token
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
print(f"Vocabulary size: {len(tokenizer)}")
print(f"Model max length: {tokenizer.model_max_length}")
print(f"Pad token: {tokenizer.pad_token}")
print(f"EOS token: {tokenizer.eos_token}")
#### 输出 ####
Vocabulary size: 151665
Model max length: 131072
Pad token: <|endoftext|>
EOS token: <|im_end|>
#### 输出 ####
上述代码展示了模型的基础信息。接下来,我们查看基础模型的参数量:
# 初始化基础模型
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
torch_dtype=torch.bfloat16
)
print(f"Model parameters: {model.num_parameters():,}")
#### 输出 ####
Model parameters: 494,032,768
#### 输出 ####
模型参数量约为 0.5B。为验证模型的基本推理能力,我们测试一个简单请求并打印模型的响应:
# 检查 CUDA 是否可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
# 将模型移至可用设备
model.to(device)
# 测试基础推理能力
def test_model_inference(user_input: str):
"""使用已加载的模型和 tokenizer 测试基础模型推理。"""
messages = [
{"role": "system", "content": "You are Qwen, a helpful assistant."},
{"role": "user", "content": user_input}
]
# 应用聊天模板
text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
# 分词并生成
inputs = tokenizer(text, return_tensors="pt").to(device)
outputs = model.generate(
**inputs,
max_new_tokens=100,
do_sample=True,
temperature=0.7
)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
return response
# 测试模型
test_input = "how are you?"
response = test_model_inference(test_input)
print(f"Test Input: {test_input}")
print(f"Model Response: {response}")
#### 输出 ####
"Test Input: how are you?
Model Response: As an AI language model I dont have feelings ..."
##### 输出 ####
结果表明,即使是小规模的 Qwen2.5–0.5B-Instruct 模型,其输出也具备一定的可靠性,可以作为 DeepSeek R1 仿制模型训练的基础模型。
强化学习 (RL) 框架中的策略模型 (R)
在选定基础模型之后,我们需要理解强化学习 (RL) 的基本框架如何应用于训练大型语言模型 (LLM)。
DeepSeek R1 的训练起点是 DeepSeek V3 基础模型,而本文实践则选用 Qwen2.5–0.5B-Instruct。此处的“起点”指的是,DeepSeek 团队首先利用强化学习构建了 R1 Zero 的初始版本,该版本在最终 R1 版本之前存在一些缺陷。
R1 Zero 的初始版本采用强化学习进行训练,其中 DeepSeek V3 或本文的 Qwen2.5–0.5B-Instruct 模型充当强化学习智能体 (Agent),执行决策动作。下图展示了其基本工作流程:
强化学习智能体 (Qwen2–0.5B) 接收到环境 (Environment) 输入的问题,并采取行动 (Action),即生成针对该问题的答案和推理过程。此处的“环境”即为推理任务本身。
执行行动后,环境将返回奖励 (Reward)。奖励信号是对智能体行动质量的反馈,告知基础模型 (Qwen2–0.5B) 其行动的优劣程度。正向奖励表示模型行动有效,可能得到了正确答案或进行了合理的推理。此反馈信号反向传递至基础模型,帮助模型学习并调整未来的行动策略,以期获得更高的累积奖励。
R1 Zero 的 GRPO 算法
上一节介绍了强化学习的基本流程。现在我们要深入了解 DeepSeek R1-Zero 模型所采用的具体强化学习算法:GRPO (Gradient Reward Policy Optimization)。
目前存在多种强化学习算法,但传统方法通常依赖于 “评论家” (Critic) 模型来辅助主决策模型(即“行动者” Actor,此处为 DeepSeek-V3/Qwen2-0.5B)。评论家模型通常与行动者模型具有相近的规模和复杂度,导致计算成本成倍增加。
DeepSeek 选择了 GRPO 算法训练 R1 Zero 模型。GRPO 的独特之处在于,它能够直接从一组行动结果中推导出基线 (Baseline),作为评估行动优劣的参考标准。因此GRPO 算法无需额外的评论家模型,显著降低了计算开销,提高了训练效率。
下图展示了 GRPO 算法在 R1 Zero 训练中的应用流程,随后将对流程图进行详细解读。
DeepSeek GRPO 算法与 Qwen2–0.5B 基础模型的集成运作方式如下:
首先,问题输入 (A) 被馈送至 Qwen 模型 (B)。Qwen 模型尝试通过 生成补全 (C) 过程,给出问题的答案。最终的 补全输出 (D) 包含在
<think>
标签中的推理步骤以及
<answer>
标签中的最终答案。
随后,问题输入 (A) 和 标准答案 (E) 一并输入 奖励函数 (F),奖励函数充当智能评分系统的角色。这些函数将 Qwen 模型的 补全输出 (D) 与标准答案进行比对,并从多个维度进行评估,包括:
- 准确性 (Accuracy):答案是否在数学上正确?
- 格式 (Format):是否规范使用了
<think>
和<answer>
标签? - 推理步骤 (Reasoning Steps):推理逻辑是否清晰可循?
- 余弦缩放 (Cosine Scaling):响应内容是否精炼简洁?
- 重复惩罚 (Repetition Penalty):是否存在不必要的重复内容?
上述评估过程产生 奖励分数 (G),并将其传递给 GRPO 训练器 (H)。训练器利用奖励分数,通过梯度反向传播来调整 Qwen 模型 (B) 的参数,优化模型生成答案的方式。此过程被称为 梯度奖励策略优化,因为它利用 梯度、奖励反馈 和 策略调整 来优化 Qwen 模型的响应,从而最大化模型性能。
最后,经过参数更新的 Qwen 模型 (B) 会再次接受新问题的测试,通过迭代循环不断优化自身。随着训练的持续进行,Qwen 模型的问题解决能力将得到持续提升。
Prompt 模板
本文沿用 DeepSeek R1 Zero 模型 GRPO 算法所采用的思考型 Prompt 模板,具体定义如下:
# 基于 GRPO 训练的 DeepSeek 系统 Prompt
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 deephub 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>"
)
此 系统 Prompt 旨在告知基础模型 (Qwen2–0.5B) 其角色定位为乐于助人的助手,需要在给出答案之前进行逐步推理。
**
<think>
和
<answer>
标签** 的作用是规范模型输出的结构,将内部推理过程与最终答案区分开,以便于后续的评估和奖励计算。
训练数据预处理
系统 Prompt 设置完成后,下一步需要依据 Prompt 模板转换训练数据。
首先,定义
make_conversation
函数,用于构建对话数据格式:
# 构建训练数据结构的函数
def make_conversation(example):
"""将数据集样本转换为对话格式。"""
return {
"prompt": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": example["problem"]},
],
}
该函数接收数据集中的
problem
列的值,并返回一个字典,其中包含系统 Prompt 和用户提出的问题,构成一个对话样本。接下来,定义
load_math_dataset
函数,用于加载数据集并进行预处理:
# 加载并准备数据集
def load_math_dataset():
"""加载并准备数学数据集。"""
dataset = load_dataset(
"AI-MO/NuminaMath-TIR",
name="default",
split=['train', 'test']
)
# 将数据集划分为训练集和测试集
dataset = {
'train': dataset[0],
'test': dataset[1]
}
# 应用对话格式
for split in dataset:
dataset[split] = dataset[split].map(make_conversation)
# 若存在 'messages' 列,则移除该列
if "messages" in dataset[split].column_names:
dataset[split] = dataset[split].remove_columns("messages")
return dataset
这样数据预处理函数已准备就绪。执行以下代码,将训练数据转换为所需格式,并打印训练集和测试集的大小:
# 加载训练数据集并打印训练集/测试集大小
dataset = load_math_dataset()
print(f"Train set size: {len(dataset['train'])}")
print(f"Test set size: {len(dataset['test'])}")
#### 输出 ####
Train set size: 72441
Test set size: 99
#### 输出 ####
数据集已划分为训练集和测试集。在进行后续训练步骤之前,需要验证数据集的格式是否符合要求(例如,检查是否存在用户/助手对话)。定义
validate_dataset
函数进行数据校验:
def validate_dataset(dataset):
"""对数据集执行基础验证检查。"""
# 定义数据集所需字段
required_fields = ["problem", "prompt"]
# 遍历数据集的 'train' 和 'test' 划分
for split in ['train', 'test']:
print(f"\nValidating {split} split:")
# 从数据集中获取列名
fields = dataset[split].column_names
# 检查是否缺少必要字段
missing = [field for field in required_fields if field not in fields]
if missing:
print(f"Warning: Missing fields: {missing}") # 若缺少字段,则发出警告
else:
print("✓ All required fields present") # 确认所有必要字段均存在
# 获取数据集划分的首个样本
sample = dataset[split][0]
# 提取包含对话消息列表的 'prompt' 字段
messages = sample['prompt']
# 验证 Prompt 格式:
# - 至少包含两条消息
# - 首条消息 Role 为 'system'
# - 次条消息 Role 为 'user'
if (len(messages) >= 2 and
messages[0]['role'] == 'system' and
messages[1]['role'] == 'user'):
print("✓ Prompt format is correct") # 确认 Prompt 格式正确
else:
print("Warning: Incorrect prompt format") # 若 Prompt 格式不正确,则发出警告
# 验证数据集
validate_dataset(dataset)
执行上述代码,得到如下输出:
Validating train split:
✓ All required fields present
✓ Prompt format is correct
Validating test split:
✓ All required fields present
✓ Prompt format is correct
输出结果显示,训练数据集已成功通过格式验证 🙌,表明数据集已成功转换为满足训练要求的格式。
奖励函数
如 GRPO 算法章节所述,模型答案的评估将通过五个不同的奖励函数进行:
- 准确性 (Accuracy):答案是否在数学上正确?
- 格式 (Format):是否规范使用了
<think>
和<answer>
标签? - 推理步骤 (Reasoning Steps):推理逻辑是否清晰可循?
- 余弦缩放 (Cosine Scaling):响应内容是否精炼简洁?
- 重复惩罚 (Repetition Penalty):是否存在不必要的重复内容?
接下来,我们将逐一实现这五个奖励函数。
准确性奖励函数
准确性奖励函数的设计思路相对直观,但代码实现略显复杂。该奖励函数的目的是验证模型给出的答案是否在数学上等价于标准答案。
若模型答案在数学上正确,则奖励值为 1.0;若答案错误,则奖励值为 0.0。对于无法解析的标准答案,为避免不公平惩罚,将给予 0.5 的中性奖励。
以下是准确性奖励函数的 Python 代码实现:
def accuracy_reward(completions, solution, **kwargs):
"""
奖励函数,用于检查模型的响应是否在数学上等价于标准答案。
使用 deep hub latex2sympy2 进行解析,使用 math_verify 进行验证。
"""
# 提取模型响应内容
contents = [completion[0]["content"] for completion in completions]
rewards = []
for content, sol in zip(contents, solution):
# 解析标准答案
gold_parsed = parse(sol, extraction_mode="first_match",
extraction_config=[LatexExtractionConfig()])
if gold_parsed: # 检查标准答案是否解析成功
# 使用宽松的归一化配置解析模型答案
answer_parsed = parse(
content,
extraction_config=[
LatexExtractionConfig(
normalization_config=NormalizationConfig(
nits=False,
malformed_operators=False,
basic_latex=True,
equations=True,
boxed="all",
units=True,
),
boxed_match_priority=0,
try_extract_without_anchor=False,
)
],
extraction_mode="first_match",
)
# 若答案正确,奖励 1.0,否则奖励 0.0
reward = float(verify(answer_parsed, gold_parsed))
else:
# 若标准答案解析失败,则给予中性奖励 0.5
reward = 0.5
print("Warning: Failed to parse gold solution:", sol) # 警告:无法解析标准答案
rewards.append(reward)
return rewards
该函数的核心逻辑在于验证模型响应与标准答案在数学上的 等价性。具体步骤如下:
- 使用
latex2sympy2
工具将标准答案转换为结构化的数学表达式。 - 若标准答案解析失败,则给予中性奖励 0.5。
- 提取模型输出,并进行归一化处理,以提高评估的鲁棒性。
- 利用
math_verify
工具,比对解析后的模型响应与解析后的标准答案是否在数学上一致。 - 若数学上一致,则奖励 1,否则奖励 0。
该奖励函数确保了准确性评估并非基于简单的文本相似度,而是基于 真实的数学正确性。
格式奖励函数
格式奖励函数旨在确保模型遵循指令,并按照预定义的结构化格式输出结果。此前,我们已要求模型将推理过程置于 **
<think>
标签内,并将最终答案置于
<answer>
** 标签内。格式奖励函数的功能正是检查模型是否严格遵守了这一格式约定。
若模型正确使用了
<think>
和
<answer>
标签,则给予奖励值 1;若模型未能遵循格式规范,则奖励值为 0。该奖励机制旨在引导模型关注并遵守预设的输出结构。
以下是格式奖励函数的代码实现:
# 实现格式奖励函数
def format_reward(completions, **kwargs):
"""
奖励函数,用于检查模型输出是否符合预定义的格式:
<think>...</think>deep hub <answer>...</answer>。
"""
# 定义目标格式的正则表达式模式
pattern = r"^<think>.*?</think>\s*<answer>.*?</answer>$"
# 从每个模型输出中提取内容
completion_contents = [completion[0]["content"] for completion in completions]
# 检查每个模型输出是否与目标模式匹配
matches = [re.match(pattern, content, re.DOTALL | re.MULTILINE)
for content in completion_contents]
# 若格式正确,奖励 1.0,否则奖励 0.0
return [1.0 if match else 0.0 for match in matches]
该函数的具体实现逻辑如下:
- 定义正则表达式 (regex) 模式。该模式精确描述了期望的输出格式:以
<think>
开头,<think>
和</think>
标签对之间可包含任意字符,随后是空白字符,然后以<answer>
开头,<answer>
和</answer>
标签对之间可包含任意字符,并以此结尾。 - 从每个模型的输出结果中提取文本内容。
- 使用
re.match
函数,逐一检查模型输出内容是否与定义的正则表达式模式完全匹配。re.DOTALL
标志使正则表达式中的.
能够匹配换行符,re.MULTILINE
标志使^
和$
能够匹配整个字符串的起始和结束位置,而非仅限于行首和行尾。 - 对于符合格式规范的模型输出,奖励值设为 1;反之,设为 0。该奖励机制对格式的正确性采取严格的二元评价标准。
推理步骤奖励函数
推理步骤奖励函数的设计更具策略性。其目标是鼓励模型展现其 “思考过程”,即奖励模型输出中包含的、类似于推理步骤的成分。
该函数通过识别模型输出中常见的逐步推理指示性词汇和结构,例如:
- “步骤 1”、“步骤 2” 等序号型步骤标识;
- “1.”、“2.” 等数字编号列表;
- “-” 或 “*” 等项目符号列表;
- “首先”、“其次”、“然后”、“最后” 等过渡性连接词。
模型输出中包含的上述指示性成分越多,获得的奖励越高。这种奖励机制类似于对模型“展示解题步骤”的行为进行加分。
以下是实现推理步骤奖励函数的代码:
def reasoning_steps_reward(completions, **kwargs):
r"""
奖励函数,用于鼓励模型进行清晰的逐步推理。
该函数会检测诸如 "Step 1:"、编号列表、项目符号以及过渡词等模式。
"""
# 用于匹配推理步骤指示符的正则表达式模式
pattern = r"(Step \d+:|^\d+\.|\n-|\n\*|First,|Second,|Next,|Finally,)"
# 提取模型输出内容
completion_contents = [completion[0]["content"] for completion in completions]
# 统计每个模型输出中推理步骤指示符的数量
matches = [len(re.findall(pattern, content, re.MULTILINE))
for content in completion_contents]
# 奖励值与推理步骤数量成正比,最高奖励值为 1.0
# 此处采用“魔法数字” 3,鼓励模型至少输出 3 个推理步骤以获得全额奖励
return [min(1.0, count / 3) for count in matches]
该函数定义了一个相对复杂的正则表达式模式,用于识别前述的推理步骤指示性成分。
函数使用
re.findall
方法查找每个模型输出内容中 所有 与该模式匹配的片段。
len(re.findall(…))
则返回匹配到的指示符 数量。
奖励值的计算公式为
min(1.0, count / 3)
。其含义如下:
- 若模型输出中包含 3 个或更多推理指示符(
count >= 3
),则奖励值为 1.0(满额奖励)。 - 若指示符数量少于 3 个(例如,
count = 1
或2
),则获得 部分 奖励(例如,1/3 或 2/3)。 - 若未检测到任何推理指示符(
count = 0
),则奖励值为 0.0。
公式中的除数
3
在此可视为一个经验参数(“魔法数字”)。其意义在于,期望模型输出约 3 个推理步骤,方可获得满额奖励。您可以根据实际需求调整此数值,以鼓励模型输出更多或更少的推理步骤。
余弦缩放奖励函数
余弦缩放奖励函数的设计思路更具技巧性。其核心目标是鼓励模型在给出正确答案时尽可能 简洁,并在答案错误时,对较长的错误答案给予 相对较轻的惩罚。
其背后的逻辑是:
- 对于正确答案:我们更倾向于奖励 简洁、直接的解答,而非冗长、散漫的答案。简明扼要且正确的答案通常更佳。
- 对于错误答案: 相较于尝试进行推理的较长错误答案,简短的错误答案可能更不可取。因此,我们希望对简短的错误答案施加 更重 的惩罚,而对较长的错误答案施加相对较轻的惩罚。
以下是实现余弦缩放奖励的代码:
# 实现余弦缩放奖励函数
def get_cosine_scaled_reward(
min_value_wrong: float = -0.5,
max_value_wrong: float = -0.1,
min_value_correct: float = 0.8,
max_value_correct: float = 1.0,
max_len: int = 1000,
):
"""
返回一个余弦缩放奖励函数。该函数基于模型输出的长度,对准确性奖励进行缩放调整。
较短的正确答案将获得更高的奖励,而较长的错误答案将受到较轻的惩罚。
"""
def cosine_scaled_reward(completions, solution, accuracy_rewards, **kwargs):
"""
余弦缩放奖励函数,根据模型输出长度调整准确性奖励。
"""
contents = [completion[0]["content"] for completion in completions]
rewards = []
for content, sol, acc_reward in zip(contents, solution, accuracy_rewards):
gen_len = len(content) # 模型生成答案的长度
progress = gen_len / max_len # 答案长度相对于最大长度的进度
cosine = math.cos(progress * math.pi) # 基于进度的余弦值
if acc_reward > 0.5: # 假设准确性奖励函数对正确答案给出约 1.0 的奖励
min_value = min_value_correct
max_value = max_value_correct
else: # 答案错误
min_value = max_value_wrong # 注意此处交换了 min_value 和 max_value
max_value = min_value_wrong
# 余弦缩放公式
reward = min_value + 0.5 * (max_value - min_value) * (1.0 + cosine)
rewards.append(float(reward))
return rewards
return cosine_scaled_reward
get_cosine_scaled_reward(...)
函数用于生成余弦缩放奖励函数,并允许用户自定义缩放参数,如
min_value_wrong/max_value_wrong
(错误答案的惩罚值范围) 和
min_value_correct/max_value_correct
(正确答案的奖励值范围)。
max_len
参数定义了进行缩放的最大长度阈值。
在
cosine_scaled_reward(...)
函数内部,奖励值将基于模型输出
completions
、标准答案
solution
以及准确性奖励
accuracy_rewards
进行计算。
函数首先计算模型生成答案的长度
gen_len
,并将其归一化为进度值
progress = gen_len / max_len
。随后,基于该进度值计算余弦值。余弦值起始于 1 (对应短答案),并随答案长度增加逐渐减小至 -1 (对应长答案)。
若
acc_reward > 0.5
,则采用正确答案的奖励值范围;否则,采用错误答案的奖励值范围,但需注意交换
min_value
和
max_value
的值,以实现对较长错误答案施加较轻惩罚的效果。
重复惩罚奖励函数
重复惩罚奖励函数旨在抑制模型生成重复性内容。我们期望模型能够生成新颖、多样的推理过程和答案,而非简单地重复使用相同的词语序列。
该奖励函数通过惩罚模型在输出文本中过度重复使用相同的 n-gram 序列来实现上述目标。在本文示例中,我们采用 n-gram 的大小为 3 (trigrams,即三元词组),您可以根据需要调整 n-gram 的大小。
若模型输出中存在大量重复内容,将受到负向奖励 (惩罚)。反之,若模型输出更具多样性,并能有效避免重复,则惩罚将相对较轻。
以下是实现重复惩罚奖励函数的代码:
def get_repetition_penalty_reward(ngram_size: int = 3, max_penalty: float = -0.1):
"""
返回一个重复惩罚奖励函数。该函数惩罚模型在生成文本中对 n-gram 的重复使用。
"""
if max_penalty > 0:
raise ValueError(f"max_penalty {max_penalty} should not be positive")
def zipngram(text: str, ngram_size: int):
"""辅助函数,用于从文本中生成 n-gram。"""
words = text.lower().split() # 转换为小写并按空格分割为单词列表
return zip(*[words[i:] for i in range(ngram_size)]) # 生成 n-gram
def repetition_penalty_reward(completions, **kwargs) -> float:
"""
重复惩罚奖励函数。
"""
contents = [completion[0]["content"] for completion in completions]
rewards = []
for completion in contents:
if completion == "": # 对于空输出,不施加惩罚
rewards.append(0.0)
continue
if len(completion.split()) < ngram_size: # 对于过短的输出,不施加惩罚
rewards.append(0.0)
continue
ngrams = set() # 使用集合存储唯一的 n-gram
total = 0
for ng in zipngram(completion, ngram_size): # 生成 n-gram
ngrams.add(ng) # 将 n-gram 添加到集合 (重复的 n-gram 会被自动忽略)
total += 1 # 统计 n-gram 的总数量
# 计算缩放因子:重复程度越高 -> 缩放因子越大
scaling = 1 - len(ngrams) / total
reward = scaling * max_penalty # 基于缩放因子施加惩罚
rewards.append(reward)
return rewards
return get_repetition_penalty_reward
get_repetition_penalty_reward(...)
函数用于创建重复惩罚奖励函数,并可通过参数
ngram_size
(默认值为 3,即三元词组) 和
max_penalty
(最大惩罚值,需为负数,如 -0.1) 进行配置。
辅助函数
zipngram(text, ngram_size)
的作用是生成 n-gram。其实现方式为:首先将输入文本转换为小写,并按空格分割为单词列表,然后利用
zip(*[words[i:] for i in range(ngram_size)])
方法高效提取 n-gram。
repetition_penalty_reward(...)
函数负责计算每个模型输出的惩罚值。对于空输出或长度过短的输出,奖励值为 0.0,即不施加惩罚。
惩罚值的大小由缩放因子
scaling = 1 - len(ngrams) / total
决定。其中,
total
表示 n-gram 的总数量,
len(ngrams)
表示唯一 n-gram 的数量。重复程度越高,
scaling
值越接近 1,惩罚力度也随之增大。
最终的奖励值为
scaling * max_penalty
。这意味着,模型输出的重复程度越低,惩罚值越小;重复程度越高,惩罚值 (负奖励) 越大。
至此,五个奖励函数已全部实现。接下来,我们将进入配置训练参数的环节。
R1 Zero 训练配置
本节将介绍如何配置训练参数,以便对前述定义的 奖励函数 的具体工作方式进行精细调整。首先,定义配置类
GRPOScriptArguments
:
# 为 GRPO 脚本参数定义 GRPOScriptArguments 类,用于配置奖励函数参数
@dataclass
class GRPOScriptArguments:
"""
GRPO 训练的脚本参数,特别是与奖励函数相关的参数。
"""
reward_funcs: list[str] = field(
default_factory=lambda: ["accuracy", "format"],
metadata={
"help": "奖励函数列表。可选值: 'accuracy', 'format', 'reasoning_steps', 'cosine', 'repetition_penalty'"
},
)
cosine_min_value_wrong: float = field(
default=-0.5,
metadata={"help": "余弦缩放奖励函数中,错误答案的最小奖励值"},
)
cosine_max_value_wrong: float = field(
default=-0.1,
metadata={"help": "余弦缩放奖励函数中,错误答案的最大奖励值"},
)
cosine_min_value_correct: float = field(
default=0.8,
metadata={"help": "余弦缩放奖励函数中,正确答案的最小奖励值"},
)
cosine_max_value_correct: float = field(
default=1.0,
metadata={"help": "余弦缩放奖励函数中,正确答案的最大奖励值"},
)
cosine_max_len: int = field(
default=1000,
metadata={"help": "余弦缩放奖励函数的最大长度阈值"},
)
repetition_n_grams: int = field(
default=3,
metadata={"help": "重复惩罚奖励函数中,n-gram 的大小"},
)
repetition_max_penalty: float = field(
default=-0.1,
metadata={"help": "重复惩罚奖励函数中,最大惩罚值 (负值)"},
)
@dataclass
装饰器简化了数据类的创建过程。
GRPOScriptArguments
类用于存储与奖励函数相关的配置参数。
reward_funcs
列表用于指定训练过程中启用的奖励函数,默认值为
["accuracy", "format"]
。用户可以根据需求添加其他奖励函数,如
"reasoning_steps"
,
"cosine"
,
"repetition_penalty"
。
其他配置项主要用于调整
cosine_scaled_reward
和
repetition_penalty_reward
这两个奖励函数的行为,用户可根据具体情况调整奖励机制。
接下来,我们使用
transformers
库提供的
TrainingArguments
类。
TrainingArguments
是一个核心配置类,几乎控制着训练过程的方方面面。
# 从 transformers 库定义 TrainingArguments
training_args = TrainingArguments(
output_dir=OUTPUT_DIR, # 检查点和日志输出目录
overwrite_output_dir=True,
num_train_epochs=1, # 训练的总 epoch 数
per_device_train_batch_size=8, # 每个设备的训练批次大小
per_device_eval_batch_size=16, # 评估批次大小
gradient_accumulation_steps=2, # 梯度累积步数,用于模拟更大的批次大小
learning_rate=5e-5, # AdamW 优化器的初始学习率
warmup_ratio=0.1, # 预热步数比例
weight_decay=0.01, # 权重衰减系数,应用于除 bias 和 LayerNorm 权重外的所有层
logging_steps=10, # 日志记录频率 (步数)
evaluation_strategy="steps", # 评估策略:每 `eval_steps` 步进行评估
eval_steps=50, # 评估频率 (步数)
save_strategy="steps", # 模型保存策略:每 `save_steps` 步保存模型
save_steps=50, # 模型保存频率 (步数)
save_total_limit=2, # 最大 checkpoint 保存数量,超出限制则删除旧 checkpoint
dataloader_num_workers=2, # 数据加载器 worker 数量
seed=42, # 随机种子,用于保证实验可复现
bf16=True, # 启用混合精度 BF16 训练
push_to_hub=False, # 是否将模型推送至 Hugging Face Hub
gradient_checkpointing=True, # 启用梯度检查点
report_to="none", # 不使用任何报告工具
)
最后,需要定义
ModelConfig
类。
ModelConfig
用于配置与 模型自身 相关的参数,例如,指定预训练模型名称、数据类型 (如 bfloat16)、是否信任远程代码等。
@dataclass
class ModelConfig:
"""
模型配置类。
"""
model_name_or_path: str = field(
default=MODEL_NAME, metadata={"help": "预训练模型路径或 Hugging Face Model Hub 模型标识符"}
)
model_revision: Optional[str] = field(
default="main", metadata={"help": "指定模型版本 (分支名, tag 名 或 commit id)"}
)
torch_dtype: Optional[str] = field(
default="bfloat16", metadata={"help": "覆盖默认 torch_dtype,以指定 dtype 加载模型"}
)
trust_remote_code: bool = field(
default=True, metadata={"help": "加载模型和 tokenizer 时,信任远程代码"}
)
attn_implementation: Optional[str] = field(
default="flash_attention_2", metadata={"help": "选择 Attention 实现方式, 可选 'flash_attention_2' 或 None"}
)
ModelConfig
类存储了关键的模型配置信息,包括
model_name_or_path
(默认为 Qwen 0.5B Instruct 模型)。
torch_dtype="bfloat16"
用于提升训练效率,
trust_remote_code=True
确保远程加载代码的安全性。此外,
attn_implementation="flash_attention_2"
选项用于启用 FlashAttention 2,在硬件支持的情况下,可潜在地加速训练过程。
接下来,实例化上述配置类,以便在后续代码中使用:
# 实例化配置对象
script_args = GRPOScriptArguments()
model_args = ModelConfig()
然后,我们需要获取奖励函数列表,以及在训练过程中使用的“回调函数” (callbacks)。
回调函数类似于助手,在训练过程的不同阶段执行特定任务,例如记录训练进度、保存模型等。目前,我们仅使用一个简单的日志记录回调函数。
将奖励函数集中管理:
# 实用函数,根据脚本参数获取奖励函数列表
def get_reward_functions(script_args):
"""
根据脚本参数,返回奖励函数列表。
"""
reward_funcs_list = []
reward_funcs_registry = {
"accuracy": accuracy_reward, # 假设 accuracy_reward 函数已在之前步骤定义
"format": format_reward, # 假设 format_reward 函数已在之前步骤定义
"reasoning_steps": reasoning_steps_reward, # 假设 reasoning_steps_reward 函数已定义
"cosine": get_cosine_scaled_reward( # 假设 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( # 假设 get_repetition_penalty_reward 函数已定义
ngram_size=script_args.repetition_n_grams,
max_penalty=script_args.repetition_max_penalty,
),
}
for func_name in script_args.reward_funcs:
if func_name not in reward_funcs_registry:
raise ValueError(f"Reward function '{func_name}' not found in registry.")
reward_funcs_list.append(reward_funcs_registry[func_name])
return reward_funcs_list
定义回调函数,用于跟踪训练损失及其他关键信息:
logger = logging.getLogger(__name__)
class LoggingCallback(TrainerCallback):
"""
一个简单的回调函数,用于在特定步骤记录训练信息。
"""
def on_step_end(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs):
if state.global_step % args.logging_steps == 0:
logger.info(f"Step {state.global_step}: Loss = {state.log_history[-1].get('loss', None)}, Learning Rate = {state.log_history[-1].get('learning_rate', None)}")
def get_callbacks(training_args, model_args, script_args):
"""
返回训练过程中使用的回调函数列表。
目前仅包含 LoggingCallback。您可以扩展此列表以添加更多回调函数。
"""
callbacks = [LoggingCallback()] # 实例化 LoggingCallback
return callbacks
最后,初始化奖励函数和回调函数:
# 获取奖励函数和回调函数
reward_functions = get_reward_functions(script_args)
callbacks = get_callbacks(training_args, model_args, script_args)
GRPO 训练循环
本节将启动 GRPO 训练的核心引擎。初始化
GRPOTrainer
,并将之前准备好的所有组件传入,包括模型、奖励函数、训练参数、数据集和回调函数。
初始化
GRPOTrainer
:
# 从 TrainingArguments 创建 GRPOConfig
grpo_config = GRPOConfig(
**training_args.to_dict(), # 将 TrainingArguments 转换为字典并解包
**{
# 此处移除了 model_init_kwargs
# 因为我们直接传递了实例化的 'model' 对象,GRPOTrainer 无需 model_init_kwargs
}
)
grpo_trainer = GRPOTrainer(
model=model, # 初始化的 Qwen 模型
reward_funcs=reward_functions, # 前述步骤定义的奖励函数列表
args=grpo_config, # GRPOConfig 对象 (由 TrainingArguments 创建)
train_dataset=dataset['train'], # 训练数据集
eval_dataset=dataset['test'], # 评估数据集
callbacks=callbacks # 回调函数列表
)
现在,可以启动 训练循环。只需调用
grpo_trainer
对象的
train()
方法即可开始训练:
# 启动 GRPO 训练循环
train_result = grpo_trainer.train()
执行上述代码后,您应能观察到训练过程开始运行。
...
INFO:dee phub__main__:Step 10: Loss = ..., Learning Rate = ...
INFO:deeph ub __main__:Step 20: Loss = ..., Learning Rate = ...
...
训练时长取决于硬件配置和设定的 epoch 数。由于本文示例中
num_train_epochs
仅设置为 1,且模型规模较小,因此训练过程相对迅速。
然而,在实际的 DeepSeek R1 Zero GRPO 训练中,通常需要进行更多 epoch 和步数的训练。
保存 Tiny R1 Zero LLM
训练完成后,即可保存训练得到的模型,用于后续的推理任务。
# 定义训练后模型保存路径 (与 OUTPUT_DIR 相同)
TRAINED_MODEL_PATH = "data/Qwen-GRPO-training"
# 保存 tokenizer
tokenizer.save_pretrained(TRAINED_MODEL_PATH)
# 保存训练后的模型
grpo_trainer.save_model(TRAINED_MODEL_PATH)
print(f"GRPO 训练后的模型已保存至 {TRAINED_MODEL_PATH}")
保存完成后,可以使用以下代码加载训练好的模型:
# 加载 tokenizer - 如有需要,请确保设置 trust_remote_code=True
tokenizer = AutoTokenizer.from_pretrained(
TRAINED_MODEL_PATH,
trust_remote_code=True, # 如果模型配置需要,则设置为 True
padding_side="right" # 确保 padding 方向一致
)
# 若 pad token 未正确保存或加载,则进行设置
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# 加载训练后的模型
trained_model = AutoModelForCausalLM.from_pretrained(
TRAINED_MODEL_PATH,
trust_remote_code=True, # 如果模型架构需要,则设置为 True
torch_dtype=torch.bfloat16 # 保持与训练时一致的数据类型
)
# 将加载的模型移至指定设备 (如有 GPU 可用,则移至 GPU)
trained_model.to(device) # 'device' 变量仍为之前定义的 CUDA 设备
进行推理测试:
# 使用训练后的模型进行推理测试
def test_trained_model_inference(user_input: str):
"""使用加载的训练后模型和 tokenizer 进行推理测试。"""
messages = [
{"role": "system", "content": SYSTEM_PROMPT}, # 复用之前的系统 Prompt
{"role": "user", "content": user_input}
]
# 使用 tokenizer 应用聊天模板
text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
# 对输入文本进行分词
inputs = tokenizer(text, return_tensors="pt").to(device)
# 使用 *trained_model* 生成模型输出
outputs = trained_model.generate(
**inputs,
max_new_tokens=200, # 相比之前,可以生成稍长的文本
do_sample=True,
temperature=0.7
)
# 将生成的 token 解码为文本
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
return response
R1 Zero 的主要问题
至此Qwen2–0.5B 基础模型(而非 DeepSeek R1 原始的 DeepSeek V3 基础模型)完成了 R1 Zero 模型的训练。
尽管本文的训练模型可能无法直接体现 R1 Zero 的问题,但 DeepSeek 团队的研究人员发现,R1 Zero 模型在推理测试中表现出色,在 AIME 2024 等任务上的得分甚至与 OpenAI-01–0912 等更先进的模型相近。
这验证了使用强化学习 (RL) 鼓励语言模型进行推理是一种有潜力的方向。
然而,DeepSeek 团队也注意到 DeepSeek-R1-Zero 存在一些关键问题,需要解决这些问题才能使其真正应用于实际场景和更广泛的研究领域。
DeepSeek 团队的研究人员指出,R1 Zero 的 Prompt 模板是 有意设计得简洁且侧重于结构, 避免 对 推理过程本身 施加任何 内容特定的约束。例如,Prompt 模板并未明确要求:
- “你 必须 使用逐步推理”(仅使用了 “推理过程” 这一宽泛表述,将推理方式的定义权交由模型自身)。
- “你 必须 使用反思性推理”。
- “你 必须 采用特定的问题解决策略”。
R1 Zero 模型的主要问题在于,**
<think>
标签内的推理过程可读性较差**,人类难以理解和分析模型的推理思路。
另一个问题是 语言混合。当用户以多语言提问时,模型有时会在同一回答中混用多种语言,导致输出结果不一致且混乱。
例如,当用户以西班牙语提问时,模型在
<think>
标签内的“思考”过程可能会出现 英语和西班牙语混杂 的情况,输出质量欠佳。这些问题,即推理过程的混乱和语言的混用,成为了 R1 Zero 模型进一步发展的阻碍。
正是上述两个主要问题,促使 DeepSeek 团队将初始的 R1 Zero 模型迭代升级为 R1 模型。
为 SFT 准备冷启动数据
为解决 R1 Zero 模型存在的问题,并使 DeepSeek R1 模型具备更完善的推理能力,DeepSeek 团队开展了 冷启动数据收集工作,并引入了监督微调 (Supervised Fine-Tuning, SFT) 技术。
可以将冷启动数据收集理解为,在进行高强度的强化学习训练之前,为模型奠定良好的推理基础。其核心目标是,让 DeepSeek-V3 Base 模型(或本文示例中的 Qwen2–0.5B 模型)学习何为高质量的推理,以及如何清晰地呈现推理过程。
基于长 CoT 的少样本 Prompting
基于长思维链 (CoT) 的少样本 Prompting 是一种有效的数据构建技术。该技术的核心思想是,向 DeepSeek-V3 Base 模型(或本文示例中的 Qwen2–0.5B 模型)展示少量问题示例,并为每个问题配备极其详尽的、逐步分解的解答,即长思维链 (Long Chain-of-Thought, Long CoT)。
该技术的目的是使模型通过学习示例,模仿这种详尽的推理风格,并逐步掌握高质量推理的模式。
以问题 “2 + 3 * 4 等于多少?” 为例,我们可以构建包含少量已解答问题的 Prompt 示例。以下 Python 代码展示了如何实现基于长 CoT 的少样本 Prompting:
# 加载模型和 Tokenizer
MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True, padding_side="right")
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, trust_remote_code=True, torch_dtype=torch.bfloat16).to("cuda" if torch.cuda.is_available() else "cpu")
# 生成长 CoT 响应
def generate_response(prompt_text):
messages = [
{"role": "system", "content": "dee phub You are a helpful assistant that provides step-by-step solutions."},
{"role": "user", "content": prompt_text}
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
outputs = model.generate(**inputs, max_new_tokens=200, do_sample=False) # 为示例保持确定性输出
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
return response.split("<|im_start|>assistant\n")[-1].strip() # 提取助手的响应
为提出的问题定义少样本 Prompt 示例:
# 示例问题及其解答 (使用 | special_token | 作为分隔符)
few_shot_prompt = """
Problem: dee ph ub What's the square root of 9 plus 5?
Solution: <|special_token|> First, find the square root of 9, which is 3. Then, add 5 to 3. 3 + 5 equals 8. <|special_token|> Summary: The answer is 8.
Problem: Train travels at 60 mph for 2 hours, how far?
Solution: <|special_token|> Use the formula: Distance = Speed times Time. Speed is 60 mph, Time is 2 hours. Distance = 60 * 2 = 120 miles. <|special_token|> Summary: Train travels 120 miles.
Problem: What is 2 + 3 * 4?
Solution:
"""
使用基础模型进行少样本生成:
# 使用少样本示例,为目标问题生成响应
target_problem_prompt = few_shot_prompt + "What is 2 + 3 * 4?"
model_response_few_shot = generate_response(target_problem_prompt)
print("Few-shot Prompt:")
print(target_problem_prompt)
print("\nModel Response (Few-shot CoT):")
print(model_response_few_shot)
模型输出结果如下,呈现出结构化的数据格式:
Few-shot Prompt:
Problem: What's the square root of 9 plus 5?
Solution: <|special_token|> First, find the square root of 9,
which is 3. Then, add 5 to 3. 3 + 5 equals 8.
<|special_token|> Summary: The answer is 8.
Problem: Train travels at 60 mph for 2 hours, how far?
Solution: <|special_token|> Use the formula: Distance = Speed times Time.
Speed is 60 mph, Time is 2 hours. Distance = 60 * 2 = 120 miles.
<|special_token|> Summary: Train travels 120 miles.
Problem: What is 2 + 3 * 4?
Solution:
Model Response (Few-shot CoT):
<|special_token|> To solve 2 + 3 * 4, we need to follow the order
of operations (PEMDAS/BODMAS). Multiplication should be performed
before addition.
Step 1: Multiply 3 by 4, which equals 12.
Step 2: Add 2 to the result from Step 1: 2 + 12 = 14.
<|special_token|> Summary: The answer is 14.
可以看到,在学习了少量示例后,模型开始采用
<|special_token|>
分隔符来组织答案,并提供逐步推理过程,最终给出总结和最终答案。
这便是少样本学习的强大之处,它能够引导模型学习并生成期望的输出格式。
直接 Prompting
直接 Prompting 是另一种数据构建方法。与少样本 Prompting 不同,直接 Prompting 侧重于直接指示模型,不仅要解决问题,还要明确地展示逐步推理过程,并对答案进行验证。
直接 Prompting 的目标是鼓励模型采取更审慎、更周全的问题解决策略。
以下代码展示了如何为问题 “2 + 3 * 4 等于多少?” 构建 Prompt,并显式要求模型进行推理和验证:
# 直接 prompting 示例
direct_prompt_text = """
Problem: d ee p hub Solve this, show reasoning step-by-step, and verify:
What is 2 + 3 * 4?
"""
model_response_direct = generate_response(direct_prompt_text)
print("Direct Prompt:")
print(direct_prompt_text)
print("\nModel Response (Direct Prompting):")
print(model_response_direct)
直接 Prompting 的输出结果清晰易懂,如下所示:
Direct Prompt:
Problem: Solve this, show reasoning step-by-step, and verify:
What is 2 + 3 * 4?
Model Response (Direct Prompting):
<|special_token|> Reasoning: To solve 2 + 3 * 4, I need to follow
the order of operations, which states that multiplication should
be done before addition.
Step 1: Multiply 3 by 4, which equals 12.
Step 2: Add 2 to the result from Step 1: 2 + 12 = 14.
Verification: d ee p hub To verify the answer, I can double-check the
order of operations and the calculations. Multiplication is
indeed performed before addition, and the calculations are correct.
<|special_token|> Summary: The answer is 14.
如输出所示,通过直接要求模型进行推理和验证,模型生成了更为全面的输出结果,其中包括 “Verification” (验证) 部分。
直接 Prompting 方法能够有效地引导模型生成用户期望的、包含详细推理过程的解答。
后处理优化
最后一种数据构建技术是 后处理优化。值得注意的是,DeepSeek 团队甚至使用了已训练完成的 R1 Zero 模型的输出结果进行后处理优化。
即使 R1 Zero 模型存在一些问题,但其已具备一定的推理能力。因此,DeepSeek 团队收集了 R1 Zero 模型的输出,并由人工标注员对这些输出进行精细化处理,使其更清晰、结构更规整,并纠正其中的错误。
例如,对于如下 R1 Zero 模型生成的、略显粗糙的输出:
<think> ummm... multiply 3 and 4... get 12... then add 2...</think>
<answer> 14 </answer>
人工标注员会将其优化为更清晰、格式更规范的输出:
<|special_token|> Reasoning: d ee p hub To solve this, we use order of operations, doing multiplication before addition.
Step 1: Multiply 3 by 4, which is 12.
Step 2: Add 2 to the result: 2 + 12 = 14.
<|special_token|> Summary: The answer is 14.
在代码层面,我们难以完美地模拟人工优化过程。但我们可以演示一种基本思路,即如何以编程方式对潜在的粗糙输出进行重新格式化和结构化。
以下代码以模拟的 “粗糙” 输出为例,展示如何对其进行优化:
# 模拟的 R1 Zero 模型粗糙输出
messy_output = "<think> ummm... multiply 3 and 4... get 12... then add 2...</think>\n<answer> 14 </answer>"
def refine_output(messy_text):
think_content = messy_text.split("<think>")[1].split("</think>")[0].strip()
answer_content = messy_text.split("<answer>")[1].split("</answer>")[0].strip()
refined_text = f"""<|special_token|> Reasoning: {think_content.replace('umm...', '').strip().capitalize()}.
<|special_token|> Summary: The answer is {answer_content}."""
return refined_text
refined_output_text = refine_output(messy_output)
print("Messy Output (Simulated R1 Zero):")
print(messy_output)
print("\nRefined Output:")
print(refined_output_text)
代码输出结果如下:
Messy Output (Simulated R1 Zero):
<think> ummm... multiply 3 and 4... get 12... then add 2...</think>
<answer> 14 </answer>
Refined Output:
<|special_token|> Reasoning: Multiply 3 and 4... get 12... then add 2.
<|special_token|> Summary: The answer is 14.
示例中的
refine_output
函数仅为演示基本优化思路。真实的人工优化过程远比代码示例复杂,涉及到对推理步骤更细致的理解和更正。
然而,代码示例已展现了后处理优化的核心思想:对模型的初始输出进行质量提升和结构优化,从而构建更高质量的训练数据。
在生成冷启动数据之后,下一个关键步骤是 监督微调 (SFT),我们将在下一节详细探讨 SFT 的训练过程。
基于冷启动数据的阶段 1 SFT 训练
为利用监督微调技术 (SFT) 构建 R1 模型,并生成高质量的冷启动数据,我们需要投入专业团队和大量的代码开发工作。幸运的是,我们已经拥有了与冷启动数据形式相近的数据集 (Bespoke-Stratos-17k)。
为了深入理解 SFT 的训练机制,我们需要了解 SFT 训练器在处理训练数据时,其内部执行了哪些操作?
SFT 属于监督学习范畴。这意味着,我们需要向模型提供成对的输入和 期望的 输出。
在本文的场景中,输入可以是问题 Prompt,期望的输出则是来自训练数据集的、包含良好推理过程的逐步解答。希望这一点能够清晰地阐释冷启动数据存在的必要性。
SFT 训练器接收分词后的训练数据,并以批次 (batch) 的形式输入模型。针对每个批次,训练器会执行一系列关键操作。下图展示了 SFT 训练的内部流程:
首先,模型接收输入,例如问题 Prompt。模型处理该输入,并逐 token 生成其对问题解答的最佳预测,这些预测的 token 即为 预测 token。
接下来,SFT 训练器需要评估模型预测的质量。评估过程依赖于 损失函数,通常采用交叉熵损失函数 (Cross-Entropy Loss)。损失函数从数学层面比较模型的预测 token 与训练数据中 正确 的 token,计算模型答案的 “误差”。
计算得到的 “误差” 并不会被直接丢弃,而是作为模型学习的关键信号。通过名为 反向传播 (backpropagation) 的过程,误差信号被用于计算 梯度 (gradients)。梯度类似于指南针,指示模型参数调整的方向,模型参数沿着梯度方向调整后,可有效降低预测误差。
最后,优化器 (optimizer),如 AdamW,利用计算得到的梯度,对模型的内部参数进行细微调整。这些调整旨在使模型在下一次预测时,输出结果更接近标准答案。
R1 阶段 1 SFT 训练配置
R1 Zero 模型在推理过程的清晰度和语言一致性方面存在不足。SFT 训练旨在解决这些问题。通过使用高质量、优化后的数据进行训练,SFT 能够引导模型:
- 学习清晰的推理风格:以易于理解和追溯的方式组织 “思考” 过程。
- 保持语言一致性:在单个回复中坚持使用单一语言,避免语言混用造成的困扰。
本文采用 Bespoke-Stratos-17k 数据集进行 SFT 训练。如前文所述,该数据集包含 1.7 万个数学和代码相关的问题,其数据格式与我们的训练需求高度契合。
回顾 Bespoke-Stratos-17k 数据集的样本示例:
# 从 bespokelabs 加载 "Bespoke-Stratos-17k" 数据集
bespoke_rl = load_dataset("bespokelabs/Bespoke-Stratos-17k", "default")
# 访问训练集首个样本
bespoke_rl['train'][0]
#### 输出 ####
{
'system': 'Your role as an assistant involves ... ',
'conversations': [{'from': 'user', 'value': 'Return your ...'}]
}
#### 输出 ####
该数据集包含系统 Prompt 和用户-助手对话,非常适合用于指导模型学习如何进行包含推理过程的对话。
本文再次使用
trl
库,该库极大简化了 SFT 训练的流程。
首先,配置 SFT 训练参数。配置方式与 GRPO 训练类似,但需针对 SFT 训练进行调整:
# 模型和输出配置 (与之前相同,或根据需要调整)
MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
OUTPUT_DIR = "data/Qwen-SFT-training" # SFT 模型的新输出目录
os.makedirs(OUTPUT_DIR, exist_ok=True)
# 训练参数 - 与 GRPO 类似,但需针对 SFT 进行调整
training_args = TrainingArguments(
output_dir=OUTPUT_DIR,
overwrite_output_dir=True,
num_train_epochs=1, # 根据需要调整 epoch 数
per_device_train_batch_size=8,
per_device_eval_batch_size=16,
gradient_accumulation_steps=2,
learning_rate=2e-5, # 为 SFT 调整学习率
warmup_ratio=0.1,
weight_decay=0.01,
logging_steps=10,
evaluation_strategy="no",
eval_steps=50,
save_strategy="steps",
save_steps=50,
save_total_limit=2,
dataloader_num_workers=2,
seed=42,
bf16=True,
push_to_hub=False,
gradient_checkpointing=True,
report_to="none",
packing=True, # 启用数据打包以提高训练效率
max_seq_length=4096 # 设置最大序列长度
)
# 模型配置 - 与之前相同
model_args = ModelConfig(
model_name_or_path=MODEL_NAME,
model_revision="main",
torch_dtype="bfloat16",
trust_remote_code=True,
attn_implementation="flash_attention_2"
)
TrainingArguments
和
ModelConfig
的配置与 GRPO 训练基本一致,但针对 SFT 训练进行了微调 (如略微调整了学习率)。值得注意的是,
packing=True
和
max_seq_length=4096
这两个参数对于提升长序列 SFT 训练的效率至关重要。
阶段 1 SFT 训练循环
加载数据集和 tokenizer:
# 加载 Bespoke-Stratos-17k 数据集
dataset_sft = load_dataset("HuggingFaceH4/Bespoke-Stratos-17k", split='train') # 仅使用训练集以简化流程
# 初始化 tokenizer - 与之前相同
tokenizer = AutoTokenizer.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
padding_side="right"
)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
初始化
SFTTrainer
并启动训练:
# 初始化 SFT 训练的基础模型 - 与之前相同
model_sft = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
torch_dtype=torch.bfloat16
)
# 初始化 SFT 训练器
sft_trainer = SFTTrainer(
model=model_sft, # 初始化的 Qwen 模型
train_dataset=dataset_sft, # Bespoke-Stratos-17k 数据集
tokenizer=tokenizer, # Tokenizer
args=training_args, # 训练参数
dataset_text_field="conversations", # 数据集中包含文本的字段 - SFT 训练的关键参数
packing=True, # 启用数据打包
max_seq_length=4096 # 最大序列长度
)
# 启动 SFT 训练循环
sft_train_result = sft_trainer.train()
执行上述代码后,SFT 训练过程将开始运行。训练日志输出与 GRPO 训练类似,将在每个日志记录步骤显示损失值 (loss) 和学习率 (learning rate)。
...
INFO:__main__:Step 10: Loss = ..., Learning Rate = ...
INFO:__main__:Step 20: Loss = ..., Learning Rate = ...
...
与 GRPO 训练类似,SFT 训练时长取决于硬件配置和设定的 epoch 数。由于本文示例仍采用小规模模型,且仅训练 1 个 epoch,因此训练过程应相对快速。
保存 Tiny R1 LLM
SFT 训练完成后,保存新微调的模型 (R1):
# 保存训练后的 SFT 模型
TRAINED_SFT_MODEL_PATH = "data/Qwen-SFT-training" # 与 OUTPUT_DIR 相同
# 保存 tokenizer
tokenizer.save_pretrained(TRAINED_SFT_MODEL_PATH)
# 保存训练后的模型
sft_trainer.save_model(TRAINED_SFT_MODEL_PATH)
print(f"SFT 训练后的模型已保存至 {TRAINED_SFT_MODEL_PATH}")
至此,SFT 训练环节完成。我们已利用基础模型,并向其展示了大量高质量推理示例,通过微调使其更擅长生成清晰、结构化的响应。
经过阶段 1 SFT 训练微调的模型,即为本文所称的 R1 模型。
SFT 之后的训练步骤,特别是强化学习阶段和拒绝采样策略,从零开始使用 Python 实现较为复杂。理解其背后的理论原理是把握整个训练流程的关键。
面向推理的强化学习
SFT 训练后的模型推理能力得到提升,但为进一步聚焦推理质量,并彻底解决语言混用问题,DeepSeek 团队在后续阶段再次采用了强化学习,并设计了更精细化的奖励系统。
新的奖励系统会检查模型输出的推理过程和答案是否与用户提问时使用的语言保持一致。例如,若用户使用英语提问,则模型 整个 回复(包括推理和答案)都应使用英语。这有效解决了语言混用问题。
在准确性奖励的基础上,DeepSeek 团队 引入了语言一致性奖励,以确保 SFT 模型在推理和回答时,所用语言与输入语言保持一致。R1 Zero 阶段使用的 GRPO 算法和训练循环被复用,但奖励信号得到改进,更精准地引导模型生成高质量推理结果和语言风格一致的输出。
拒绝采样
为获得更高质量的推理数据,DeepSeek 团队采用了 拒绝采样 (Rejection Sampling) 策略。拒绝采样可被视为一个过滤器,用于筛选并保留质量 最佳 的训练样本。
模型首先生成大量的推理示例,随后,对这些示例的正确性和推理质量进行评估 (评估过程通常结合生成式奖励模型和人工评估)。
只有质量 最佳 的、高质量推理示例才会被保留。DeepSeek 团队将这些高质量推理示例与非推理数据相结合,构建了精细化的数据集,并用于 阶段 2 SFT 训练,进一步提升模型的推理能力和通用能力。
阶段 2 SFT 训练
最终的强化学习阶段,目标是将模型训练为在 所有 场景下都乐于助人且安全的 AI 助手,而不仅限于解决推理问题。该阶段侧重于模型与人类价值观的对齐。
核心关注点:助人性和无害性奖励
在奖励机制设计上,不仅关注答案的准确性,还引入了以下奖励指标:
- 助人性 (Helpfulness):模型回复是否实用且信息量丰富?
- 无害性 (Harmlessness):模型回复是否安全、无偏见且符合伦理道德?
训练数据变得更加多样化,不仅包含推理任务,还融入了人类偏好数据 (用于指示哪个输出更佳——更助人,更无害)。
奖励系统在准确性、助人性和无害性 之间寻求平衡。通过迭代强化学习训练 (很可能仍采用 GRPO 算法),模型得到持续优化,最终不仅擅长推理,更成长为一个安全、乐于助人、可广泛应用的 AI 助手,即 DeepSeek R1 模型。
知识蒸馏
为使 DeepSeek R1 模型更易于部署和应用,DeepSeek 团队采用了 知识蒸馏 (Distillation) 技术,将其知识迁移到规模更小的模型中。
知识蒸馏技术将大型、高性能的 “教师” 模型 (DeepSeek R1) 的知识,转移到规模较小的 “学生” 模型。DeepSeek 团队构建了包含大量推理示例的数据集,并将 DeepSeek R1 模型的输出作为 目标答案。
随后,使用监督微调 (SFT) 技术训练小规模模型,使其模仿 DeepSeek R1 模型的输出。最终,得到规模更小、推理速度更快的模型,这些 “学生” 模型继承了 DeepSeek R1 模型推理能力的重要部分,使其更具实用价值,可应用于更广泛的场景。
总结
本文深入剖析了 DeepSeek R1 模型的构建过程,从基础模型选型到多阶段训练流程,再到关键技术如强化学习、拒绝采样和知识蒸馏的应用,进行了详尽的阐述。通过对 GRPO 算法、Prompt 模板、奖励函数以及 SFT 训练等核心环节的逐步解析,我们不仅了解了 DeepSeek R1 如何从零开始构建,更对其在推理能力、语言一致性以及安全助人等方面所做的努力有了更深刻的认识。希望本文能够帮助读者更好地理解 DeepSeek R1 的技术原理,并为相关研究和实践提供有益的参考。
https://avoid.overfit.cn/post/ac6d4be0a234412ea00032737365638c
作者:FareedKhan
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。