该实训基于 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)