## 6.2 模型有监督微调 在上一节,我们介绍了如何使用 transformers 框架快速、高效地进行模型预训练。在本部分,我们将基于上部分内容,介绍如何使用 transformers 框架对预训练好的模型进行有监督微调。 ### 6.2.1 Pretrain VS SFT 首先需要回顾一下,对 LLM 进行预训练和进行有监督微调的核心差异在于什么。在第四章中提到过,目前成型的 LLM 一般通过 Pretrain-SFT-RLHF 三个阶段来训练,在 Pretrain 阶段,会对海量无监督文本进行自监督建模,来学习文本语义规则和文本中的世界知识;在 SFT 阶段,一般通过对 Pretrain 好的模型进行指令微调,即训练模型根据用户指令完成对应任务,从而使模型能够遵循用户指令,根据用户指令进行规划、行动和输出。因此,Pretrain 和 SFT 均使用 CLM 建模,其核心差异在于,Pretrain 使用海量无监督文本进行训练,模型直接对文本执行“预测下一个 token”的任务;而 SFT 使用构建成对的指令对数据,模型根据输入的指令,建模后续的输出。反映到具体的训练实现上,Pretrain 会对全部 text 进行 loss 计算,要求模型对整个文本实现建模预测;而 SFT 仅对输出进行 loss 计算,不计算指令部分的 loss。 因此,相较于上一节完成的 Pretrain 代码,SFT 部分仅需要修改数据处理环节,实现对指令对数据转化为训练样本的构建,其余部分和 Pretrain 是完全一致的实现逻辑。本部分代码脚本为 ./code/finetune.py。 ### 6.2.2 微调数据处理 同样与第五章类似,我们此处使用贝壳开源的 BelleGroup 数据集进行 SFT。 在 SFT 过程中,我们会定义一个 Chat Template,这个 Template 即表示了如何将对话数据转化为一个模型可以建模拟合的文本序列。当我们使用做过 SFT 的模型进行下游任务微调时,一般需要查看该模型的 Chat Template 并进行适配,即是为了不损伤其在 SFT 中学到的指令遵循能力。由于我们此处使用 Pretrain 模型进行 SFT,可以自定义一个 Chat Template。由于我们使用了 Qwen-2.5-1.5B 模型结构进行 Pretrain,此处我们沿承使用 Qwen-2.5 的 Chat Template。如果读者没有足够的资源进行上一部分模型的 Pretrain 的话,此处也可以使用官方的 Qwen-2.5-1.5B 模型作为 SFT 的基座模型。 我们首先定义几个特殊 token,特殊 token 在模型进行拟合中有特殊的作用,包括文本序列开始(BOS)、文本序列结束(EOS)、换行符等。定义特殊 token,有助于避免模型在拟合过程中的语义混淆: ```python # 不同的 tokenizer 需要特别定义 # BOS im_start = tokenizer("<|im_start|>").input_ids # EOS im_end = tokenizer("<|im_end|>").input_ids # PAD IGNORE_TOKEN_ID = tokenizer.pad_token_id # 换行符 nl_tokens = tokenizer('\n').input_ids # 角色标识符 _system = tokenizer('system').input_ids + nl_tokens _user = tokenizer('human').input_ids + nl_tokens _assistant = tokenizer('assistant').input_ids + nl_tokens ``` Qwen 系列的 Chat Template 一般有三个对话角色:System、User 和 Assistant。System 是系统提示词,负责激活模型的能力,默认为“You are a helpful assistant.”,一般不会在 SFT 过程中更改使用。User 即为用户给出的提示词,此处由于数据集中的对话角色为 “human”,我们将 “user” 修改为了“human”。Assistant 即为 LLM 给出的回复,也就是模型在 SFT 过程中需要拟合的文本。 接着,由于该数据集是一个多轮对话数据集,我们需要对多轮对话进行拼接处理,将多轮对话拼接到一个文本序列中: ```python # 拼接多轮对话 input_ids, targets = [], [] # 多个样本 for i in tqdm(range(len(sources))): # source 为一个多轮对话样本 source = sources[i] # 从 user 开始 if source[0]["from"] != "human": source = source[1:] # 分别是输入和输出 input_id, target = [], [] # system: 【BOS】system\nYou are a helpful assistant.【EOS】\n system = im_start + _system + tokenizer(system_message).input_ids + im_end + nl_tokens input_id += system # system 不需要拟合 target += im_start + [IGNORE_TOKEN_ID] * (len(system)-3) + im_end + nl_tokens assert len(input_id) == len(target) # 依次拼接 for j, sentence in enumerate(source): # sentence 为一轮对话 role = roles[sentence["from"]] # user:<|im_start|>human\ninstruction【EOS】\n # assistant:<|im_start|>assistant\nresponse【EOS】\n _input_id = tokenizer(role).input_ids + nl_tokens + \ tokenizer(sentence["value"]).input_ids + im_end + nl_tokens input_id += _input_id if role == '<|im_start|>human': # user 不需要拟合 _target = im_start + [IGNORE_TOKEN_ID] * (len(_input_id)-3) + im_end + nl_tokens elif role == '<|im_start|>assistant': # assistant 需要拟合 _target = im_start + [IGNORE_TOKEN_ID] * len(tokenizer(role).input_ids) + \ _input_id[len(tokenizer(role).input_ids)+1:-2] + im_end + nl_tokens else: print(role) raise NotImplementedError target += _target assert len(input_id) == len(target) # 最后进行 PAD input_id += [tokenizer.pad_token_id] * (max_len - len(input_id)) target += [IGNORE_TOKEN_ID] * (max_len - len(target)) input_ids.append(input_id[:max_len]) targets.append(target[:max_len]) ``` 上述代码沿承了 Qwen 的 Chat Template 逻辑,读者也可以根据自己的偏好进行修改,其核心点在于 User 的文本不需要拟合,因此 targets 中 User 对应的文本内容是使用的 IGNORE_TOKEN_ID 进行遮蔽,而 Assistant 对应的文本内容则是文本原文,是需要计算 loss 的。目前主流 LLM IGNORE_TOKEN_ID 一般设置为 -100。 完成拼接后,将 tokenize 后的数值序列转化为 tensor,再拼接成 Dataset 所需的字典返回即可: ```python input_ids = torch.tensor(input_ids) targets = torch.tensor(targets) return dict( input_ids=input_ids, labels=targets, attention_mask=input_ids.ne(tokenizer.pad_token_id), ) ``` 完成上述处理逻辑后,需要自定义一个 Dataset 类,在该类中调用该逻辑进行数据的处理: ```python class SupervisedDataset(Dataset): def __init__(self, raw_data, tokenizer, max_len: int): super(SupervisedDataset, self).__init__() # 加载并预处理数据 sources = [example["conversations"] for example in raw_data] # preprocess 即上文定义的数据预处理逻辑 data_dict = preprocess(sources, tokenizer, max_len) self.input_ids = data_dict["input_ids"] self.labels = data_dict["labels"] self.attention_mask = data_dict["attention_mask"] def __len__(self): return len(self.input_ids) def __getitem__(self, i) -> Dict[str, torch.Tensor]: return dict( input_ids=self.input_ids[i], labels=self.labels[i], attention_mask=self.attention_mask[i], ) ``` 该类继承自 torch 的 Dataset 类,可以直接在 Trainer 中使用。完成数据处理后,基于上一节脚本,修改数据处理逻辑即可,后续模型训练等几乎完全一致,此处附上主函数逻辑: ```python # 加载脚本参数 parser = HfArgumentParser((ModelArguments, DataTrainingArguments, TrainingArguments)) model_args, data_args, training_args = parser.parse_args_into_dataclasses() # 初始化 WandB wandb.init(project="sft", name="qwen-1.5b") # 设置日志 logging.basicConfig( format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", datefmt="%m/%d/%Y %H:%M:%S", handlers=[logging.StreamHandler(sys.stdout)], ) # 将日志级别设置为 INFO transformers.utils.logging.set_verbosity_info() log_level = training_args.get_process_log_level() logger.setLevel(log_level) datasets.utils.logging.set_verbosity(log_level) transformers.utils.logging.set_verbosity(log_level) transformers.utils.logging.enable_default_handler() transformers.utils.logging.enable_explicit_format() # 训练整体情况记录 logger.warning( f"Process rank: {training_args.local_rank}, device: {training_args.device}, n_gpu: {training_args.n_gpu}" + f"distributed training: {bool(training_args.local_rank != -1)}, 16-bits training: {training_args.fp16}" ) logger.info(f"Training/evaluation parameters {training_args}") # 检查 checkpoint last_checkpoint = None if os.path.isdir(training_args.output_dir): last_checkpoint = get_last_checkpoint(training_args.output_dir) if last_checkpoint is None and len(os.listdir(training_args.output_dir)) > 0: raise ValueError( f"输出路径 ({training_args.output_dir}) 非空 " ) elif last_checkpoint is not None and training_args.resume_from_checkpoint is None: logger.info( f"从 {last_checkpoint}恢复训练" ) # 设置随机数种子. set_seed(training_args.seed) # 初始化模型 logger.warning("加载预训练模型") logger.info(f"模型参数地址:{model_args.model_name_or_path}") model = AutoModelForCausalLM.from_pretrained(model_args.model_name_or_path,trust_remote_code=True) n_params = sum({p.data_ptr(): p.numel() for p in model.parameters()}.values()) logger.info(f"继承一个预训练模型 - Total size={n_params/2**20:.2f}M params") # 初始化 Tokenizer tokenizer = AutoTokenizer.from_pretrained(model_args.model_name_or_path) logger.info("完成 tokenzier 加载") # 加载微调数据 with open(data_args.train_files) as f: lst = [json.loads(line) for line in f.readlines()[:10000]] logger.info("完成训练集加载") logger.info(f"训练集地址:{data_args.train_files}") logger.info(f'训练样本总数:{len(lst)}') # logger.info(f"训练集采样:{ds["train"][0]}") train_dataset = SupervisedDataset(lst, tokenizer=tokenizer, max_len=2048) logger.info("初始化 Trainer") trainer = Trainer( model=model, args=training_args, train_dataset= IterableWrapper(train_dataset), tokenizer=tokenizer ) # 从 checkpoint 加载 checkpoint = None if training_args.resume_from_checkpoint is not None: checkpoint = training_args.resume_from_checkpoint elif last_checkpoint is not None: checkpoint = last_checkpoint logger.info("开始训练") train_result = trainer.train(resume_from_checkpoint=checkpoint) trainer.save_model() ``` 启动方式也同样在 sh 脚本中使用 deepspeed 启动即可,此处不再赘述,源码见 ./code/finetune.sh。