import os import sys __package__ = "trainer" sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) import argparse import time import math import warnings import torch import torch.nn.functional as F import torch.distributed as dist from contextlib import nullcontext from torch import optim from torch.nn.parallel import DistributedDataParallel from torch.utils.data import DataLoader, DistributedSampler from transformers import AutoTokenizer, AutoModelForCausalLM from model.model_minimind import MiniMindConfig, MiniMindForCausalLM from dataset.lm_dataset import DPODataset warnings.filterwarnings('ignore') def Logger(content): if not ddp or dist.get_rank() == 0: print(content) def get_lr(current_step, total_steps, lr): return lr / 10 + 0.5 * lr * (1 + math.cos(math.pi * current_step / total_steps)) def logits_to_probs(logits, labels): """将模型输出的logits转换为token级别的概率 Args: logits: 模型输出的logits,形状为(batch_size, seq_len, vocab_size) labels: 真实标签,形状为(batch_size, seq_len) Returns: probs: token级别的概率,形状为(batch_size, seq_len) """ # 对logits进行softmax得到概率分布 log_probs = F.log_softmax(logits, dim=2) # 收集每个位置上目标token的概率 probs = torch.gather(log_probs, dim=2, index=labels.unsqueeze(2)).squeeze(-1) return probs def dpo_loss(ref_probs, probs, mask, beta): """计算Direct Preference Optimization (DPO)损失 DPO通过最大化偏好数据中chosen和rejected样本的对数概率比来优化模型。 具体来说,它使用一个参考模型(通常是训练前的模型)来防止策略过度偏离。 Args: ref_probs: 参考模型的token概率,形状为(batch_size, seq_len) probs: 当前模型的token概率,形状为(batch_size, seq_len) mask: 有效token的掩码,形状为(batch_size, seq_len) beta: 温度系数,用于控制策略的保守程度 Returns: loss: 标量损失值 """ # 计算序列级别的对数概率,通过mask只考虑有效token seq_lengths = mask.sum(dim=1, keepdim=True) # (batch_size, 1) ref_probs = (ref_probs * mask).sum(dim=1) / seq_lengths.squeeze() probs = (probs * mask).sum(dim=1) / seq_lengths.squeeze() # 将batch分成chosen和rejected两部分 batch_size = ref_probs.shape[0] chosen_ref_probs = ref_probs[:batch_size // 2] # 参考模型对chosen样本的概率 reject_ref_probs = ref_probs[batch_size // 2:] # 参考模型对rejected样本的概率 chosen_probs = probs[:batch_size // 2] # 当前模型对chosen样本的概率 reject_probs = probs[batch_size // 2:] # 当前模型对rejected样本的概率 # 计算当前模型和参考模型的对数概率比 pi_logratios = chosen_probs - reject_probs # 当前模型的对数比 ref_logratios = chosen_ref_probs - reject_ref_probs # 参考模型的对数比 # 计算最终的logits并应用温度系数 logits = pi_logratios - ref_logratios # 计算损失:最大化chosen相对于rejected的概率 loss = -F.logsigmoid(beta * logits) return loss.mean() def train_epoch(epoch, wandb): """训练一个epoch的DPO优化 使用人类偏好数据进行训练,每个batch包含chosen和rejected两部分样本。 通过最大化chosen样本相对于rejected样本的概率来优化模型。 """ start_time = time.time() for step, batch in enumerate(train_loader): x_chosen = batch['x_chosen'].to(args.device) x_rejected = batch['x_rejected'].to(args.device) y_chosen = batch['y_chosen'].to(args.device) y_rejected = batch['y_rejected'].to(args.device) mask_chosen = batch['mask_chosen'].to(args.device) mask_rejected = batch['mask_rejected'].to(args.device) x = torch.cat([x_chosen, x_rejected], dim=0) y = torch.cat([y_chosen, y_rejected], dim=0) mask = torch.cat([mask_chosen, mask_rejected], dim=0) lr = get_lr(epoch * iter_per_epoch + step, args.epochs * iter_per_epoch, args.learning_rate) for param_group in optimizer.param_groups: param_group['lr'] = lr with ctx: with torch.no_grad(): ref_outputs = ref_model(x) ref_logits = ref_outputs.logits ref_probs = logits_to_probs(ref_logits, y) ref_probs = ref_probs * mask outputs = model(x) logits = outputs.logits probs = logits_to_probs(logits, y) probs = probs * mask loss = dpo_loss(ref_probs, probs, mask, beta=0.1) loss = loss / args.accumulation_steps scaler.scale(loss).backward() if (step + 1) % args.accumulation_steps == 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True) if step % args.log_interval == 0: spend_time = time.time() - start_time Logger( 'Epoch:[{}/{}]({}/{}) loss:{:.3f} lr:{:.12f} epoch_Time:{}min:'.format( epoch + 1, args.epochs, step, iter_per_epoch, loss.item() * args.accumulation_steps, optimizer.param_groups[-1]['lr'], spend_time / (step + 1) * iter_per_epoch // 60 - spend_time // 60)) if (wandb is not None) and (not ddp or dist.get_rank() == 0): wandb.log({"loss": loss * args.accumulation_steps, "lr": optimizer.param_groups[-1]['lr'], "epoch_Time": spend_time / (step + 1) * iter_per_epoch // 60 - spend_time // 60}) if (step + 1) % args.save_interval == 0 and (not ddp or dist.get_rank() == 0): model.eval() moe_path = '_moe' if lm_config.use_moe else '' ckp = f'{args.save_dir}/rlhf_{lm_config.hidden_size}{moe_path}.pth' if isinstance(model, torch.nn.parallel.DistributedDataParallel): state_dict = model.module.state_dict() else: state_dict = model.state_dict() state_dict = {k: v.half() for k, v in state_dict.items()} # 半精度保存 torch.save(state_dict, ckp) model.train() def init_model(lm_config): """初始化DPO训练所需的模型 包括: 1. 主模型:用于优化的模型 2. 参考模型:用于计算KL散度,防止策略过度偏离 3. 分词器:用于处理输入文本 """ # 加载分词器 tokenizer = AutoTokenizer.from_pretrained('../model/') # 初始化主模型 model = MiniMindForCausalLM(lm_config) moe_path = '_moe' if lm_config.use_moe else '' ckp = f'{args.save_dir}/full_sft_{lm_config.hidden_size}{moe_path}.pth' state_dict = torch.load(ckp, map_location=args.device) model.load_state_dict(state_dict, strict=False) # 初始化参考模型(固定参数的SFT模型) ref_model = MiniMindForCausalLM(lm_config) ref_model.load_state_dict(state_dict, strict=False) ref_model.eval() # 设置为评估模式 ref_model.requires_grad_(False) # 冻结所有参数 Logger(f'LLM总参数量:{sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f} 百万') # 将模型移动到指定设备 model = model.to(args.device) ref_model = ref_model.to(args.device) return model, ref_model, tokenizer def init_distributed_mode(): if not ddp: return global ddp_local_rank, DEVICE dist.init_process_group(backend="nccl") ddp_rank = int(os.environ["RANK"]) ddp_local_rank = int(os.environ["LOCAL_RANK"]) ddp_world_size = int(os.environ["WORLD_SIZE"]) DEVICE = f"cuda:{ddp_local_rank}" torch.cuda.set_device(DEVICE) if __name__ == "__main__": # 配置命令行参数 parser = argparse.ArgumentParser(description="MiniMind RLHF") # 基础训练参数 parser.add_argument("--out_dir", type=str, default="../out", help="输出目录") parser.add_argument("--epochs", type=int, default=2, help="训练轮数") parser.add_argument("--batch_size", type=int, default=4, help="批次大小") # DPO训练使用较小的学习率,防止策略过度偏离 parser.add_argument("--learning_rate", type=float, default=1e-8, help="学习率,建议<=1e-8,否则容易遗忘训坏") 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("--use_wandb", action="store_true", help="是否使用wandb记录训练过程") parser.add_argument("--wandb_project", type=str, default="MiniMind-RLHF-SFT", help="wandb项目名称") parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔") parser.add_argument("--save_interval", type=int, default=100, help="模型保存间隔") # 分布式训练参数 parser.add_argument("--num_workers", type=int, default=1, help="数据加载进程数") parser.add_argument("--ddp", action="store_true", help="是否使用分布式训练") parser.add_argument('--local_rank', 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("--warmup_iters", type=int, default=0, help="学习率预热迭代次数") # 模型参数 parser.add_argument('--hidden_size', default=512, type=int, help="隐藏层维度") parser.add_argument('--num_hidden_layers', default=8, type=int, help="Transformer层数") parser.add_argument('--max_seq_len', default=1024, type=int, help="最大序列长度") parser.add_argument('--use_moe', default=False, type=bool, help="是否使用MoE") # 数据参数 parser.add_argument("--data_path", type=str, default="../dataset/dpo.jsonl", help="训练数据路径,包含人类偏好对") parser.add_argument("--wandb_api_key", type=str, default=None, help="WandB API Key,用于无交互环境自动登录") args = parser.parse_args() lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=args.use_moe) args.save_dir = os.path.join(args.out_dir) os.makedirs(args.save_dir, exist_ok=True) os.makedirs(args.out_dir, exist_ok=True) tokens_per_iter = args.batch_size * args.max_seq_len device_type = "cuda" if "cuda" in args.device else "cpu" args.wandb_run_name = f"MiniMind-Full-DPO-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LearningRate-{args.learning_rate}" ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast() ddp = int(os.environ.get("RANK", -1)) != -1 # is this a ddp run? ddp_local_rank, DEVICE = 0, "cuda:0" base_seed = 1337 torch.manual_seed(base_seed) torch.cuda.manual_seed(base_seed) if ddp: init_distributed_mode() args.device = torch.device(DEVICE) rank = dist.get_rank() torch.manual_seed(base_seed + rank) # 同时设置 CUDA 的随机种子 torch.cuda.manual_seed(base_seed + rank) if args.use_wandb and (not ddp or ddp_local_rank == 0): import wandb # 优先使用命令行参数,其次环境变量 api_key = args.wandb_api_key or os.environ.get("WANDB_API_KEY", None) if api_key is not None: # 自动登录,适用于无交互环境 wandb.login(key=api_key) # 初始化 wandb 任务 wandb.init(project=args.wandb_project, name=args.wandb_run_name) else: wandb = None model, ref_model, tokenizer = init_model(lm_config) train_ds = DPODataset(args.data_path, tokenizer, max_length=args.max_seq_len) train_sampler = DistributedSampler(train_ds) if ddp else None train_loader = DataLoader( train_ds, batch_size=args.batch_size, pin_memory=True, drop_last=False, shuffle=False, num_workers=args.num_workers, sampler=train_sampler ) scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype in ['float16', 'bfloat16'])) optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate) if ddp: model._ddp_params_and_buffers_to_ignore = {"pos_cis"} model = DistributedDataParallel(model, device_ids=[ddp_local_rank]) iter_per_epoch = len(train_loader) for epoch in range(args.epochs): train_epoch(epoch, wandb)