diff --git a/docs/chapter6/6.2 模型有监督微调.md b/docs/chapter6/6.2 模型有监督微调.md new file mode 100644 index 0000000..eabcb2c --- /dev/null +++ b/docs/chapter6/6.2 模型有监督微调.md @@ -0,0 +1,217 @@ +## 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。 \ No newline at end of file