import os
import sys
__package__ = "trainer"
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import argparse
import re
import gc
import warnings
import torch
import torch.distributed as dist
from transformers import AutoTokenizer
from contextlib import nullcontext
from torch import optim
from torch.nn.parallel import DistributedDataParallel
from torch.utils.data import DataLoader, DistributedSampler
from torch.optim.lr_scheduler import CosineAnnealingLR
from transformers import AutoModel
from model.model_minimind import MiniMindConfig, MiniMindForCausalLM
from dataset.lm_dataset import RLAIFDataset
from trainer.trainer_utils import *
warnings.filterwarnings('ignore')
def calculate_rewards(prompts, responses, reward_model, reward_tokenizer):
"""整合所有奖励函数计算总奖励"""
def reasoning_model_reward(rewards):
# --- 1. 格式奖励:鼓励模型学会“思考”格式 ---
# 使用正则表达式检查回答是否遵循 ...... 的标准格式。
# 这种格式化的输出有助于模型学习链式思考(Chain-of-Thought)。
pattern = r"^\n.*?\n\n\n.*?\n$"
pattern2 = r"^\n.*?\n\n\n\n.*?\n$"
matches_pattern = [re.match(pattern, response, re.S) for response in responses]
matches_pattern2 = [re.match(pattern2, response, re.S) for response in responses]
format_rewards = []
for match_pattern, match_pattern2 in zip(matches_pattern, matches_pattern2):
if match_pattern:
format_rewards.append(0.5) # 格式正确则给予奖励
elif match_pattern2:
format_rewards.append(0.5)
else:
format_rewards.append(0.0)
rewards += torch.tensor(format_rewards, device=args.device)
# --- 2. 标记奖励:提供更密集的引导信号 ---
# 检查, , , 四个标签是否都只出现一次。
# 这是一个“稀疏奖励”的补充,即使格式不完全对,只要包含了正确的标签,也能得到一些分数,
# 避免了模型在初期因无法生成完整正确格式而得不到任何正反馈的问题。
def mark_num(text):
reward = 0
if text.count("") == 1:
reward += 0.25
if text.count("") == 1:
reward += 0.25
if text.count("") == 1:
reward += 0.25
if text.count("") == 1:
reward += 0.25
return reward
mark_rewards = [mark_num(response) for response in responses]
rewards += torch.tensor(mark_rewards, device=args.device)
return rewards
# 初始化一个长度为 responses 的张量,用于保存奖励
rewards = torch.zeros(len(responses), device=args.device)
# --- 3. 如果是推理模式,则添加上述的格式和标记奖励 ---
if args.reasoning == 1:
rewards = reasoning_model_reward(rewards) # 训练推理模型时使用
# --- 4. 核心奖励:使用外部奖励模型打分 ---
# with torch.no_grad() 确保这部分计算不产生梯度
with torch.no_grad():
reward_model_scores = []
batch_size = len(prompts)
scale = 3.0
# 遍历每一个Prompt
for i in range(batch_size):
# 每一个Prompt对应num_generations个生成结果
for j in range(args.num_generations):
# 取该个Prompt的j个生成结果
response_idx = i * args.num_generations + j
response = responses[response_idx]
prompt = prompts[i]
# --- 准备奖励模型的输入 ---
# 解析prompt,将其转换为奖励模型能理解的对话格式列表。
pattern = r"<\|im_start\|>(system|user|assistant)\s+(.*?)<\|im_end\|>"
matches = re.findall(pattern, prompt, re.DOTALL)
messages = [{"role": role, "content": content.strip()} for role, content in matches]
# 将当前response作为assistant的最新回复,添加到对话历史中。
tmp_chat = messages + [{"role": "assistant", "content": response}]
# 调用奖励模型,获取对整个对话(包括当前response)的评分。
score = reward_model.get_score(reward_tokenizer, tmp_chat)
# 将分数裁剪到[-3.0, 3.0]区间,防止极端值影响训练稳定性。
score = max(min(score, scale), -scale)
# --- 对推理任务的特殊处理 ---
# 如果是推理模式...
if args.reasoning == 1:
#...尝试从response中提取标签内的最终答案。
answer_match = re.search(r'(.*?)', response, re.DOTALL)
# 如果找到了最终答案...
if answer_match:
#...提取答案内容。
answer_content = answer_match.group(1).strip()
#...构造一个新的对话历史,其中assistant的回复仅包含这个最终答案。
tmp_chat_answer = messages + [{"role": "assistant", "content": answer_content}]
#...单独对这个最终答案进行评分。
answer_score = reward_model.get_score(reward_tokenizer, tmp_chat_answer)
answer_score = max(min(answer_score, scale), -scale)
# 最终分数是“完整回答”和“核心答案”的加权平均,核心答案权重更高(0.6)。
score = score * 0.4 + answer_score * 0.6
# 将计算出的最终分数添加到列表中。
reward_model_scores.append(score)
# 4. 将所有分数转换为PyTorch张量,并累加到总奖励上。
reward_model_scores = torch.tensor(reward_model_scores, device=args.device)
rewards += reward_model_scores
# 5. 返回包含了所有维度奖励的最终总奖励张量。
return rewards
def grpo_train_epoch(epoch, wandb):
for step, batch in enumerate(train_loader):
# --- 准备输入 ---
prompts = batch['prompt'] # list[str], 一维List 长度 = Batch_size
prompt_inputs = tokenizer(prompts, return_tensors="pt", padding=True, return_token_type_ids=False,
padding_side="left", add_special_tokens=False).to(args.device) # input_ids: [B, P], attention_mask: [B, P]
# 如果超出长度则截断
if args.max_seq_len:
prompt_inputs["input_ids"] = prompt_inputs["input_ids"][:, -args.max_seq_len:]
prompt_inputs["attention_mask"] = prompt_inputs["attention_mask"][:, -args.max_seq_len:]
# --- 步骤1: 生成一组响应 (Rollout) ---
# 对于每个prompt,让当前策略模型生成 num_generations 个不同的回答。
# do_sample=True 和 temperature=0.8 保证了生成的多样性。
with torch.no_grad():
outputs = (model.module if ddp else model).generate(
**prompt_inputs, max_new_tokens=args.max_gen_len, do_sample=True, temperature=0.8,
num_return_sequences=args.num_generations, pad_token_id=tokenizer.pad_token_id) # [B*num_gen, P+R]
# 从生成结果中分离出回答部分 (completion) 的 token IDs
completion_ids = outputs[:, prompt_inputs["input_ids"].size(1):] # [B*num_gen, R]
# --- 步骤2: 计算对数概率 ---
# 定义一个辅助函数来计算给定序列中每个token的对数概率
def get_per_token_logps(mdl, input_ids, n_keep):
# input_ids [B*num_gen, P+R]
# 判断是否为推理模式,如果是,则 detaching input_ids,以防止反向传播
input_ids = input_ids.detach().clone() if input_ids.is_inference() else input_ids
# 计算模型的 logits,logits 的形状是 [B, P+R, vocab_size]
logits = mdl(input_ids, logits_to_keep=n_keep + 1).logits[:, :-1, :]
per_token_logps = []
# 遍历每一行 logits 和输入的 id,计算每个 token 的 log 概率
for logits_row, ids_row in zip(logits, input_ids[:, -n_keep:]): # [B*num_gen , R]
# 如果是推理阶段,detach input_ids 防止反向传播
ids_row = ids_row.detach().clone() if ids_row.is_inference() else ids_row
# 使用 torch.gather 从 logits 中选出 ids_row 对应的列,然后对 logits 做 softmax 转换得到 log 概率
per_token_logps.append(torch.gather(logits_row.log_softmax(dim=-1), 1, ids_row.unsqueeze(1)).squeeze(1))
return torch.stack(per_token_logps) # [B*num_gen, R]
# 计算“策略模型”对于这批生成的responses的log_probs。
per_token_logps = get_per_token_logps(model, outputs, completion_ids.size(1)) # [B*num_gen, R]
# 在不计算梯度的模式下,计算“参考模型”的log_probs。
with torch.no_grad():
ref_per_token_logps = get_per_token_logps(ref_model, outputs, completion_ids.size(1)) # [B*num_gen, R]
# --- 步骤3: 计算奖励和优势 (Reward & Advantage) ---
# 将response的token IDs解码回文本。
completions = tokenizer.batch_decode(completion_ids, skip_special_tokens=True) # [B*num_gen, R]
rewards = calculate_rewards(prompts, completions, reward_model, reward_tokenizer).to(args.device) # [B*num_gen]
#!!! GRPO算法的核心!!!
# 将扁平的rewards张量重塑为(batch_size, num_generations)的形状。
grouped_rewards = rewards.view(-1, args.num_generations) # [B, num_gen]
# 沿着num_generations维度计算每组的平均奖励。
mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations) # [B*num_gen]
std_r = grouped_rewards.std(dim=1).repeat_interleave(args.num_generations) # [B*num_gen]
# 计算优势值:(当前奖励 - 组内平均奖励) / 组内标准差。
# 这衡量了每个response比其“兄弟”们好多少(或差多少)。
advantages = torch.clamp((rewards - mean_r) / (std_r + 1e-4), -10, 10)
# 对所有优势值进行全局归一化,使训练更加稳定。
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8) # [B*num_gen]
# --- 步骤4: 计算损失 (Loss) ---
# 创建一个mask,确保只计算到句子结束符(EOS)为止的token的损失。
is_eos = completion_ids == tokenizer.eos_token_id # [B*num_gen, R]
eos_idx = torch.full((is_eos.size(0),), is_eos.size(1), dtype=torch.long, device=args.device)
eos_idx[is_eos.any(dim=1)] = is_eos.int().argmax(dim=1)[is_eos.any(dim=1)]
completion_mask = (torch.arange(is_eos.size(1), device=args.device).expand(is_eos.size(0), -1) <= eos_idx.unsqueeze(1)).int() # [B*num_gen, R]
kl_div = ref_per_token_logps - per_token_logps
per_token_kl = torch.exp(kl_div) - kl_div - 1 # [B*num_gen, R]
per_token_loss = -(torch.exp(per_token_logps - per_token_logps.detach()) * advantages.unsqueeze(1) - args.beta * per_token_kl) # [B*num_gen, R]
loss = ((per_token_loss * completion_mask).sum(dim=1) / completion_mask.sum(dim=1)).mean() / args.accumulation_steps # scalar
loss.backward()
if (step + 1) % args.accumulation_steps == 0:
if args.grad_clip > 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
optimizer.step()
scheduler.step()
optimizer.zero_grad()
if step % args.log_interval == 0 or step == iters:
policy_loss_val = loss.item()
avg_reward_val = rewards.mean().item()
avg_len_val = completion_mask.sum(dim=1).float().mean().item()
current_lr = optimizer.param_groups[0]['lr']
Logger(f'Epoch: {epoch+1}, Step: {step}/{iters}, '
f'Actor Loss: {policy_loss_val:.6f}, Reward: {avg_reward_val:.6f}, '
f'Avg Response Len: {avg_len_val:.2f}, LR: {current_lr:.2e}')
if wandb and is_main_process():
wandb.log({
"policy_loss": policy_loss_val,
"reward": avg_reward_val,
"avg_response_len": avg_len_val,
"advantages_mean": advantages.mean().item(),
"learning_rate": current_lr
})
if (step % args.save_interval == 0 or step == iters - 1) and is_main_process():
model.eval()
moe_suffix = '_moe' if lm_config.use_moe else ''
ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
state_dict = model.module.state_dict() if isinstance(model, DistributedDataParallel) else model.state_dict()
torch.save({k: v.half() for k, v in state_dict.items()}, ckp)
lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer,
epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scheduler=scheduler)
model.train()
del prompt_inputs, outputs, completion_ids, per_token_logps, ref_per_token_logps
del completions, rewards, grouped_rewards, mean_r, std_r, advantages, completion_mask
torch.cuda.empty_cache()
gc.collect()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="MiniMind GRPO (Group Relative Policy Optimization)")
parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
parser.add_argument('--save_weight', default='grpo', type=str, help="保存权重的前缀名")
parser.add_argument("--epochs", type=int, default=1, help="训练轮数")
parser.add_argument("--batch_size", type=int, default=2, help="batch size")
parser.add_argument("--learning_rate", type=float, default=8e-8, help="初始学习率")
parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
parser.add_argument("--num_workers", type=int, default=1, help="数据加载线程数")
parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
parser.add_argument("--log_interval", type=int, default=1, help="日志打印间隔")
parser.add_argument("--save_interval", type=int, default=10, help="模型保存间隔")
parser.add_argument('--hidden_size', default=512, type=int, help="隐藏层维度")
parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
parser.add_argument('--use_moe', default=False, type=bool, help="是否使用MoE")
parser.add_argument('--max_seq_len', default=66, type=int, help="Prompt最大长度")
parser.add_argument("--max_gen_len", type=int, default=1536, help="生成的最大长度")
parser.add_argument("--data_path", type=str, default="../dataset/rlaif-mini.jsonl", help="RLAIF数据路径")
parser.add_argument("--num_generations", type=int, default=8, help="每个prompt生成的样本数")
parser.add_argument("--beta", type=float, default=0.02, help="KL惩罚系数")
parser.add_argument("--reasoning", type=int, default=1, help='0:普通模型,1:推理模型')
parser.add_argument("--reward_model_path", type=str, default="../../internlm2-1_8b-reward", help="Reward模型路径")
parser.add_argument('--from_resume', default=0, type=int, help="是否自动检测&续训,0否1是")
parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
parser.add_argument("--wandb_project", type=str, default="MiniMind-GRPO", help="wandb项目名")
args = parser.parse_args()
# ========== 1. 初始化环境和随机种子 ==========
local_rank = init_distributed_mode()
if dist.is_initialized(): args.device = f"cuda:{local_rank}"
setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
# ========== 2. 配置目录、模型参数、检查ckp ==========
os.makedirs(args.save_dir, exist_ok=True)
lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers,
max_seq_len=args.max_seq_len + args.max_gen_len, use_moe=args.use_moe)
ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
# ========== 3. 设置混合精度 ==========
device_type = "cuda" if "cuda" in args.device else "cpu"
dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
# ========== 4. 配wandb ==========
wandb = None
if args.use_wandb and is_main_process():
import swanlab as wandb
wandb_id = ckp_data.get('wandb_id') if ckp_data else None
resume = 'must' if wandb_id else None
wandb_run_name = f"MiniMind-GRPO-Epoch-{args.epochs}-BS-{args.batch_size}-LR-{args.learning_rate}"
wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
# ========== 5. 初始化模型和数据 ==========
tokenizer = AutoTokenizer.from_pretrained('../model/')
moe_suffix = '_moe' if lm_config.use_moe else ''
base_weight = "reason" if args.reasoning == 1 else "full_sft"
ckp = f'{args.save_dir}/{base_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
state_dict = torch.load(ckp, map_location=args.device)
# Policy模型
model = MiniMindForCausalLM(lm_config)
model.load_state_dict(state_dict, strict=False)
model = model.to(args.device)
Logger(f'Policy模型总参数量:{sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f} M')
# Reference模型
ref_model = MiniMindForCausalLM(lm_config)
ref_model.load_state_dict(state_dict, strict=False)
ref_model.eval().requires_grad_(False)
ref_model = ref_model.to(args.device)
# Reward模型
reward_model = AutoModel.from_pretrained(
args.reward_model_path, device_map="cuda", torch_dtype=torch.float16, trust_remote_code=True
).to(args.device).eval().requires_grad_(False)
reward_tokenizer = AutoTokenizer.from_pretrained(args.reward_model_path, trust_remote_code=True)
# 数据和优化器
train_ds = RLAIFDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len)
train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
loader_for_count = DataLoader(train_ds, batch_size=args.batch_size, sampler=train_sampler)
iters = len(loader_for_count)
total_optimizer_steps = (iters // args.accumulation_steps) * args.epochs
scheduler = CosineAnnealingLR(optimizer, T_max=total_optimizer_steps, eta_min=args.learning_rate / 10)
# ========== 6. 从ckp恢复状态 ==========
start_epoch, start_step = 0, 0
if ckp_data:
model.load_state_dict(ckp_data['model'])
optimizer.load_state_dict(ckp_data['optimizer'])
scheduler.load_state_dict(ckp_data['scheduler'])
start_epoch = ckp_data['epoch']
start_step = ckp_data.get('step', 0)
# ========== 7. DDP包模型 ==========
if dist.is_initialized():
model._ddp_params_and_buffers_to_ignore = {"freqs_cos", "freqs_sin"}
model = DistributedDataParallel(model, device_ids=[local_rank])
# ========== 8. 开始训练 ==========
for epoch in range(start_epoch, args.epochs):
train_sampler and train_sampler.set_epoch(epoch)
if epoch == start_epoch and start_step > 0: # 第一个epoch且存在检查点
batch_sampler = SkipBatchSampler(train_sampler or range(len(train_ds)), args.batch_size, start_step + 1)
loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
grpo_train_epoch(epoch, loader, len(loader) + start_step + 1, ref_model, reward_model, reward_tokenizer, start_step, wandb)
else: # 默认从头开始
loader = DataLoader(train_ds, batch_size=args.batch_size, pin_memory=True,
drop_last=False, shuffle=(train_sampler is None),
num_workers=args.num_workers, sampler=train_sampler)
grpo_train_epoch(epoch, loader, len(loader), ref_model, reward_model, reward_tokenizer, 0, wandb)