Files
happy-llm/docs/chapter6/6.2 模型有监督微调.md
2025-04-25 16:33:27 +08:00

10 KiB
Raw Blame History

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有助于避免模型在拟合过程中的语义混淆


# 不同的 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 过程中需要拟合的文本。

接着,由于该数据集是一个多轮对话数据集,我们需要对多轮对话进行拼接处理,将多轮对话拼接到一个文本序列中:

# 拼接多轮对话
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 所需的字典返回即可:

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 类,在该类中调用该逻辑进行数据的处理:

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 中使用。完成数据处理后,基于上一节脚本,修改数据处理逻辑即可,后续模型训练等几乎完全一致,此处附上主函数逻辑:

# 加载脚本参数
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。