0. 简介

随着chatgpt的爆火,最近也有很多大模型在不断地出现,比如说Bloom系列以及以LLAMA为基础的ziya和baichuan。这些模型相较于chatglm来说,更加具有发展前景,因为其是完全可商用,并可以不断迭代更新的。最近作者在跟着hiyouga大佬的LLaMA-Efficient-Tuning进行学习,相较于其他的项目来说,该项目是非常适合跟着学习并入门的。

1. 什么是SFT

SFT(Scalable Fine-Tuning)是一种用于自然语言处理的技术,它通过对预训练的语言模型进行微调,使其适应特定任务。在大模型SFT中,使用的是大型的预训练语言模型,例如LLAMA、GPT等,这些模型具有数十亿甚至数百亿个参数,可以处理大量的文本数据。

SFT的主要思想是在一个大型的预训练模型的基础上,针对特定的任务对模型进行微调。在微调过程中,模型会根据任务的特点调整模型的参数和结构,以提高模型在该任务上的表现。在微调过程中,可以使用不同的技术,例如数据增强、正则化、优化算法等。

SFT的优点是可以快速地针对不同的任务进行微调,而无需重新训练整个模型。此外,由于使用的是大型的预训练模型,可以利用海量的文本数据进行训练,从而获得更好的性能。不过,SFT也有一些缺点,例如需要大量的计算资源和时间进行微调,以及可能会出现过拟合等问题。

目前常用的SFT方法有P-Tuning v2LORAQLoRA、冻结(Freeze)、全参数(full-parameter)等方法。我们先来看一看在LLaMA-Efficient-Tuning中是如何写SFT的

2. 代码阅读–train_sft.py

下面是sft对应大模型的脚本,主要包括模型和数据的准备,数据集的划分,训练和评估等步骤。

首先,代码导入了一些必要的模块和函数。这包括一些用于数据处理、训练、加载预训练模型和绘制损失图的工具函数。(这部分和pt中一样)

    # Prepare pretrained model and dataset
    model_args, data_args, training_args, finetuning_args = prepare_args(stage="sft")# 用于准备各种参数,包括模型参数、数据参数、训练参数和微调参数。
    dataset = prepare_data(model_args, data_args)# 用于准备数据集
    model, tokenizer = load_pretrained(model_args, finetuning_args, training_args.do_train, stage="sft")# 用于加载sft微调的模型和分词器。
    dataset = preprocess_data(dataset, tokenizer, data_args, training_args, stage="sft")# 用于预处理数据,例如将文本转换为模型可以理解的格式。
    data_collator = DynamicDataCollatorWithPadding(tokenizer, data_args.ignore_pad_token_for_loss)# 动态地对数据进行填充,使得每个batch中的数据长度一致。

下面的代码是用于Seq2SeqTrainer的解码参数进行覆盖

   # Override the decoding parameters of Seq2SeqTrainer
    training_args.generation_max_length = training_args.generation_max_length if \
                training_args.generation_max_length is not None else data_args.max_target_length# 设置训练参数(training_args)中的生成最大长度
    training_args.generation_num_beams = data_args.eval_num_beams if \
                data_args.eval_num_beams is not None else training_args.generation_num_beams # 设置训练参数中的生成束搜索数(generation_num_beams)

然后,根据是否进行训练,对数据集进行划分。如果进行训练,且开发集的比例大于0,那么数据集会被划分为训练集和开发集;否则,全部数据用于训练。如果不进行训练,那么全部数据用于评估或预测。

    # Split the dataset
    if training_args.do_train:
        if data_args.dev_ratio > 1e-6:
            dataset = dataset.train_test_split(test_size=data_args.dev_ratio)
            trainer_kwargs = {"train_dataset": dataset["train"], "eval_dataset": dataset["test"]}
        else:
            trainer_kwargs = {"train_dataset": dataset}
    else: # do_eval or do_predict
        trainer_kwargs = {"eval_dataset": dataset}

接着,初始化Seq2SeqPeftTrainer对象,传入微调参数、模型、训练参数、分词器、数据处理器、回调函数和计算度量等参数(都是继承自Seq2SeqTrainer),以及前面划分的数据集。这个我们下一节将会仔细阅读里面的操作

    # Initialize our Trainer
    trainer = Seq2SeqPeftTrainer(
        finetuning_args=finetuning_args,
        model=model,
        args=training_args,
        tokenizer=tokenizer,
        data_collator=data_collator,
        callbacks=[LogCallback()],
        compute_metrics=ComputeMetrics(tokenizer) if training_args.predict_with_generate else None,
        **trainer_kwargs
    )

在进行训练后,代码会记录训练的结果,并保存模型和训练结果。如果模型在所有进程中的进程号为0(主进程),并且设定了绘制损失图,那么会绘制训练损失和评估损失的图。

    # Training
    if training_args.do_train:
        train_result = trainer.train()
        trainer.log_metrics("train", train_result.metrics)
        trainer.save_metrics("train", train_result.metrics)
        trainer.save_state()
        trainer.save_model()
        if trainer.is_world_process_zero() and model_args.plot_loss:
            plot_loss(training_args.output_dir, keys=["loss", "eval_loss"])

通过训练器对象(trainer)的evaluate方法,代码会在验证集上进行评估,并将结果保存在metrics变量中。

    # Evaluation
    if training_args.do_eval:# 如果训练参数(training_args)中有设置进行评估,则执行下列代码
        metrics = trainer.evaluate(metric_key_prefix="eval", **gen_kwargs)
        trainer.log_metrics("eval", metrics)
        trainer.save_metrics("eval", metrics)#评估结果保存在训练器对象(trainer)的指定文件中,方便后续的分析和比较

如果训练参数(training_args)中有设置进行预测,则执行下列代码

    # Predict
    if training_args.do_predict:
        predict_results = trainer.predict(dataset, metric_key_prefix="predict", **gen_kwargs)#过训练器对象(trainer)的predict方法,在测试集上进行预测,并将结果保存在predict_results变量中
        trainer.log_metrics("predict", predict_results.metrics)
        trainer.save_metrics("predict", predict_results.metrics)
        trainer.save_predictions(predict_results, tokenizer)#将预测结果保存在训练器对象(trainer)的指定文件中

3. 代码阅读–seq2seq.py

下面的代码主要定义了一个名为ComputeMetrics的类和一个名为Seq2SeqPeftTrainer的子类,用于计算文本生成任务的评价指标。

3.1 ComputeMetrics函数

ComputeMetrics类接受一个PreTrainedTokenizer类型的参数tokenizer,用于计算给定的模型预测结果的指标。下面我们来仔细看一下这部分的代码

@dataclass
class ComputeMetrics:
    r"""
    Wraps the tokenizer into metric functions, used in Seq2SeqPeftTrainer.

    Borrowed from: https://github.com/THUDM/ChatGLM-6B/blob/0c2806fea82683349194e21996dd6b3acc3c265b/ptuning/main.py#L307
    """

    tokenizer: PreTrainedTokenizer# 定义了一个tokenizer变量,它的类型是PreTrainedTokenizer,这是从transformers.tokenization_utils中拿出的

    def __call__(self, eval_preds: Sequence[Union[np.ndarray, Tuple[np.ndarray]]]) -> Dict[str, float]:
        r"""
        Uses the model predictions to compute metrics.
        """
        preds, labels = eval_preds#获取输入的eval_preds序列中的两个数组,分别赋值给preds和labels变量
        # 处理preds和labels数组中的IGNORE_INDEX值,将它们替换成tokenizer.pad_token_id
        if isinstance(preds, tuple):
            preds = preds[0]
        # 因为如果ignore_pad_token_for_loss为True时,我们不能解码IGNORE_INDEX值。
        preds = np.where(preds != IGNORE_INDEX, preds, self.tokenizer.pad_token_id)
        labels = np.where(labels != IGNORE_INDEX, labels, self.tokenizer.pad_token_id)

        # 定义了一个字典score_dict,它包含四个指标:rouge-1,rouge-2,rouge-l和bleu-4
        score_dict = {"rouge-1": [], "rouge-2": [], "rouge-l": [], "bleu-4": []}
        for pred, label in zip(preds, labels):# 遍历preds和labels中的每一个元素,分别赋值给pred和label变量
            pred = pred[(pred == self.tokenizer.bos_token_id).nonzero()[0][0]:] # 从pred数组中删除第一个出现bos_token_id的位置及其之前的元素
            hypothesis = list(jieba.cut(self.tokenizer.decode(pred, skip_special_tokens=True)))# pred和label解码成文本,然后用jieba库将它们分词
            reference = list(jieba.cut(self.tokenizer.decode(label, skip_special_tokens=True)))

            # 使用Rouge计算指标rouge-1,rouge-2和rouge-l的得分
            if len(" ".join(hypothesis).split()) == 0:
                result = {"rouge-1": {"f": 0.0}, "rouge-2": {"f": 0.0}, "rouge-l": {"f": 0.0}}
            else:
                rouge = Rouge()
                scores = rouge.get_scores(" ".join(hypothesis), " ".join(reference))
                result = scores[0]

            # 将result中的每个指标的f值乘以100取整,然后将结果添加到score_dict字典中
            for k, v in result.items():
                score_dict[k].append(round(v["f"] * 100, 4))

            bleu_score = sentence_bleu([list(label)], list(pred), smoothing_function=SmoothingFunction().method3)
            score_dict["bleu-4"].append(round(bleu_score * 100, 4))# 使用NLTK库的sentence_bleu函数计算指标bleu-4的得分 

        return {k: float(np.mean(v)) for k, v in score_dict.items()}

3.2 Seq2SeqPeftTrainer函数

这段代码定义了一个名为Seq2SeqPeftTrainer的类,它继承自PeftTrainer类。详细的内容其实已经在PeftTrainer里面全部囊括了,它只是拓展了一个函数用于计算生成式指标,如BLEU和ROUGE。并让它在Predict处调用

class Seq2SeqPeftTrainer(PeftTrainer):
    r"""
    predict_results: PredictionOutput类型,表示模型的预测结果。
    tokenizer: PreTrainedTokenizer类型,表示用于将预测结果转换为文本的分词器。
    """
    def save_predictions(
            self,
            predict_results: PredictionOutput,
            tokenizer: PreTrainedTokenizer
    ) -> None:
        r"""
        Saves model predictions to `output_dir`.

        A custom behavior that not contained in Seq2SeqTrainer.
        """
        if not self.is_world_process_zero():# 判断当前进程是否是主进程
            return

        preds = np.where(predict_results.predictions != IGNORE_INDEX, predict_results.predictions, self.tokenizer.pad_token_id)#预测结果中的IGNORE_INDEX值替换为分词器的pad_token_id值,得到预测结果preds和标签labels
        labels = np.where(predict_results.label_ids != IGNORE_INDEX, predict_results.label_ids, self.tokenizer.pad_token_id)#每个预测结果pred,从第一个出现分词器的bos_token_id的位置开始截取,得到去除查询部分的预测结果

        preds = [pred[(pred == self.tokenizer.bos_token_id).nonzero()[0][0]:] for pred in preds] # 预测结果和标签使用分词器的decode方法转换为文本形式,并去除特殊标记和空格
        preds = [tokenizer.decode(pred, skip_special_tokens=True).strip() for pred in preds]
        labels = [tokenizer.decode(label, skip_special_tokens=True).strip() for label in labels]

        output_prediction_file = os.path.join(self.args.output_dir, "generated_predictions.jsonl")#预测结果和标签按照指定的格式组织成字典,并以json格式写入到输出文件generated_predictions.jsonl中
        logger.info(f"Saving prediction results to {output_prediction_file}")
        with open(output_prediction_file, "w", encoding="utf-8") as writer:
            res: List[str] = []
            for pred, label in zip(preds, labels):
                res.append(json.dumps({"label": label, "predict": pred}, ensure_ascii=False))
            writer.write("\n".join(res))