minimind/trainer/train_dpo.py

301 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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="训练数据路径,包含人类偏好对")
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
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)