Qwen2微调实训与精准踩坑

该实训基于 Qwen2-0.5b-Instruct 进行,用Lora方法进行单轮对话的有监督微调(SFT)。

所需的库为 torch、transformers、datasets、peft。

要微调一个开源大模型,那必须事先查清该模型微调的模板。如果不按照模板进行微调,那进行SFT的效果是很差的,且不论各类指标( rouge 或者 bleu ),即便是模型生成的自然语言可能都是不流畅的。

作者本来是想直接用官方提供的sft代码来进行微调的,但是电脑上无法使用deepspeed。不论是直接pip install还是git下来本地编译安装后都会报 LINK : fatal error LNK1181: 无法打开输入文件“aio.lib” 这样的错。所以还是另外写了一个程序。

1 数据预处理

数据预处理部分是微调一个模型最重要的部分,首先是数据本身是否需要清洗来符合训练的要求,再者是将其转化为成dataset时input_ids是否与attention_mask和labels相对应,并且要符合模型设置的Templates。

1.1 Qwen2微调的Templates

https://github.com/QwenLM/Qwen2
最简单的方法就是去官方的 github 上找相应的说明,官方的readme上即使没有给出,但是一般会在example的sft中给出。

作者在之前下载过,但是现在来看官方似乎删除了sft的部分。
在这里插入图片描述
还有一种方法就是去其他支持该模型的微调框架下去找找对数据的处理方式。作者这里尝试了官方给出的方法和Llama框架下的方法。

总结下来需要满足这样的提示格式:

<|im_start|>system\n 内容…<|im_end|>\n
<|im_start|>user\n 内容…<|im_end|>\n
<|im_start|>assistant\n 内容…<|im_end|><|endoftext|>

如果是多轮对话,那只需要在<|endoftext|>前反复第二行和第三行的内容就可以了。

这些特殊字符在模型文件 tokenizer_config.json 中都有说明(其实要求的模版也说明了):
在这里插入图片描述

1.2 制作数据集

为了便于处理,首先需要将原始数据转化成 json 文件,格式需要满足:

{"type": "chatml", "messages": [{"role": "system", "content": "内容..."}, {"role": "user", "content": "内容..."}, {"role": "assistant", "content": "内容..."}], "source": "self-made"}

每一条数据都需要制作成这样的形式,其中 “source” 项不是必须的。

接下来可以通过这段代码来处理数据(根据官方之前给出的SFT代码修改):

import json
import transformers
from typing import Dict, List, Optional
from torch.utils.data import Dataset
from transformers.trainer_pt_utils import LabelSmoother
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("model_0.5b_instruct/",trust_remote_code=True,padding_side="left")
TEMPLATE = "{% for message in messages %}{% if loop.first and messages[0]['role'] != 'system' %}{{ '<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n' }}{% endif %}{{'<|im_start|>' + message['role'] + '\n' + message['content']}}{% if loop.last %}{{ '<|im_end|>'}}{% else %}{{ '<|im_end|>\n' }}{% endif %}{% endfor %}"
IGNORE_TOKEN_ID = LabelSmoother.ignore_index

def preprocess(
    messages,
    tokenizer: transformers.PreTrainedTokenizer,
    max_len: int,
) -> Dict:
    """Preprocesses the data for supervised fine-tuning."""

    texts = []
    for i, msg in enumerate(messages):
        texts.append(
            tokenizer.apply_chat_template(
                msg,
                chat_template=TEMPLATE,
                tokenize=True,
                add_generation_prompt=False,
                padding="max_length",
                max_length=max_len,
                truncation=True,
            )
        )
    input_ids = torch.tensor(texts, dtype=torch.int)
    target_ids = input_ids.clone()
    target_ids[target_ids == tokenizer.pad_token_id] = IGNORE_TOKEN_ID
    attention_mask = input_ids.ne(tokenizer.pad_token_id)

    return dict(
        input_ids=input_ids, target_ids=target_ids, attention_mask=attention_mask
    )

class SupervisedDataset(Dataset):
    """Dataset for supervised fine-tuning."""

    def __init__(
        self, raw_data, tokenizer: transformers.PreTrainedTokenizer, max_len: int
    ):
        super(SupervisedDataset, self).__init__()

        messages = [example["messages"] for example in raw_data]
        data_dict = preprocess(messages, tokenizer, max_len)

        self.input_ids = data_dict["input_ids"]
        self.target_ids = data_dict["target_ids"]
        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.target_ids[i],
            attention_mask=self.attention_mask[i],
        )
    
    
train_data = []
with open("data.json", "r",encoding='utf-8') as f:
    for line in f:
        train_data.append(json.loads(line))
train_dataset = SupervisedDataset(train_data, tokenizer=tokenizer, max_len=512)

踩坑1: 在处理成json文件的过程中,我们通常会为了数据的美观而在json.dumps()函数的参数加上indent=4来使得json文件数据带有缩进便于观察,但是实操下来如果使用这段代码处理数据就会出现无法识别未知字符的错误,所以在转化成json的过程中不要使用这个参数。

最后处理出来的数据是这样的:

在这里插入图片描述
由于给模型输入的数据是有长度限制的,因此处理的时候需要用到 pad_token 来代替空白部分使得在训练的时候所有batch的数据对齐。

那么这里为什么和1.1中说的数据处理格式不一样呢?作者在微调的时候看到下方有一个warning,是说该模型的用的是左对齐的方式,也就是 pad_token 从开始填充,1.1的方式是右填充。不过对应0.5b的模型来说,尝试之后感觉对模型的输出影响并不是很大。

2 加载模型

这一块没什么好说的,从官网上下模型的时候不要把文件下漏了就行。

from transformers import AutoModelForCausalLM, TrainingArguments, Trainer, DataCollatorForSeq2Seq, AutoTokenizer
import torch

# Transformers加载模型权重
tokenizer = AutoTokenizer.from_pretrained("model_0.5b_instruct/",trust_remote_code=True,padding_side="left")
model = AutoModelForCausalLM.from_pretrained("model_0.5b_instruct/", device_map="auto", torch_dtype=torch.bfloat16)

3 Lora

这一块是对 Lora 参数的设置以及加载Peft model。

from peft import LoraConfig, TaskType, get_peft_model

config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    target_modules=[
        "q_proj",
        "k_proj",
        "v_proj",
        "o_proj",
        "gate_proj",
        "up_proj",
        "down_proj",
    ],
    inference_mode=False,  # 训练模式
    r=8,  # Lora 秩
    lora_alpha=8,  # Lora alaph,具体作用参见 Lora 原理
    lora_dropout=0.1,  # Dropout 比例
)

model = get_peft_model(model, config)

当然也可以打印看看模型可训练的参数是多少。

model.print_trainable_parameters()

4 训练

单纯的训练其实非常简单,只要显存足够,几行代码就可以解决。但是通常为了评判模型是否达到预期,我们需要在训练过程中添加 metrics ,依据 metrics 进行评判才比较科学。但是就是添加metrics的过程中遇到了许多问题。

不用 transformers官方提供的evaluate包中的metrics的话,也可以自己手写compute_metrics函数,然后在训练的时候调用。

增加eval的过程还需要这些库 nltk、jieba、rouge_chinese

增加后的代码是这样的

import jieba,nltk
from rouge_chinese import Rouge
from nltk.translate.bleu_score import sentence_bleu,SmoothingFunction
from transformers import EvalPrediction
import numpy as np
import functools

def compute_metrics(eval_preds: EvalPrediction, tokenizer):
    batched_pred_ids, batched_label_ids = eval_preds

    metrics_dct = {'rouge-1': [], 'rouge-2': [], 'rouge-l': [], 'bleu-4': []}
    for pred_ids, label_ids in zip(batched_pred_ids, batched_label_ids):
        pred_txt = tokenizer.decode(pred_ids).strip()
        #pred_txt = tokenizer.batch_decode(pred_ids,skip_special_tokens=True)
        #label_ids = np.where(label_ids != -100, label_ids, tokenizer.pad_token_id)
        label_txt = tokenizer.decode(label_ids).strip()
        #label_txt = tokenizer.batch_decode(label_ids,skip_special_tokens=True)
        pred_tokens = list(jieba.cut(pred_txt))
        label_tokens = list(jieba.cut(label_txt))
        rouge = Rouge()
        scores = rouge.get_scores(' '.join(pred_tokens), ' '.join(label_tokens))
        for k, v in scores[0].items():
            metrics_dct[k].append(round(v['f'] * 100, 4))
        metrics_dct['bleu-4'].append(
            sentence_bleu(
                [label_tokens],
                pred_tokens,
                smoothing_function=SmoothingFunction().method3,
            )
        )
    return {k: np.mean(v) for k, v in metrics_dct.items()}

args = TrainingArguments(
    output_dir="./test_Lora1",
    per_device_train_batch_size=1,
    per_device_eval_batch_size=1,
    logging_steps=50,
    num_train_epochs=3,
    save_steps=200,
    learning_rate=5e-5,
    save_strategy="steps",
    eval_steps=5,
    evaluation_strategy="epoch",
)


trainer = Trainer(
    model=model,
    args=args,
    train_dataset=train_dataset,
    eval_dataset=train_dataset,
    data_collator=DataCollatorForSeq2Seq(
            tokenizer=tokenizer,
            padding='longest',
            return_tensors='pt',
    ),
    tokenizer=None,  # LORA does not need tokenizer
    compute_metrics=functools.partial(compute_metrics, tokenizer=tokenizer),
)

trainer.train()

踩坑2: 事实是,如果不添加eval_steps和evaluation_strategy,训练过程中不会做eval。这个时候是没有任何问题的,也是完美运行出来最后的结果。但是一旦加上了并开始做eval的时候,就会报错 OutOfMemory 也就是爆显存了。这其实就很奇怪,训练的时候不爆显存,而做负载更低的验证的时候竟然会爆显存。这个问题困扰了作者许久之后,在 huggingface forums找到了答案。

验证时有数据集合并的过程,此时所有数据默认都放在gpu上了。因此需要在trainer里设置preprocess_logits_for_metrics。

在代码中加入这一段:

def preprocess_logits_for_metrics(logits, labels):
    """
    Original Trainer may have a memory leak. 
    This is a workaround to avoid storing too many tensors that are not needed.
    """
    pred_ids = torch.argmax(logits, dim=-1)
    return pred_ids, labels

并且在Trainer()中加入这一句preprocess_logits_for_metrics=preprocess_logits_for_metrics,

5 预测

最后经过周折,得到最后的模型,就可以用模型进行预测啦。

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("model_0.5b_instruct/",  trust_remote_code=True,padding_side="left")
model = AutoModelForCausalLM.from_pretrained("model_0.5b_instruct/", device_map="auto", torch_dtype="auto")
peft_model = PeftModel.from_pretrained(model = model, model_id="./test_Lora1/checkpoint-1000/")

def predict(messages, model, tokenizer):
    device = "cuda"
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    model_inputs = tokenizer([text], return_tensors="pt").to(device)
    generated_ids = model.generate(model_inputs.input_ids, max_new_tokens=512)
    generated_ids = [
        output_ids[len(input_ids) :]
        for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
    ]

    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    #print(response)

    return response

response = predict(messages, peft_model, tokenizer)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值