NLP(九十三)使用HuggingFace-TRL微调Qwen1.5-7B模型(SFT)

本文将会介绍如何使用HuggingFace开源的trl模块来对阿里的通义千问模型Qwen1.5-7B进行微调(SFT),并分享笔者在SFT过程中遇到的坑。

笔者之前的文章NLP(六十三)使用Baichuan-7b模型微调人物关系分类任务NLP(九十二)大模型时代下的微博新闻标题生成中分别介绍了如何使用大模型训练工具fireflyLLaMA-Factory来完成大模型微调(SFT阶段)。

本文将会利用更加基础的HuggingFace开源的trl模块来实现大模型微调(SFT),这次我们自己来实现SFT!

trl模块是一个全栈模块,它为我们提供了一系列工具来通过强化学习训练 Transformer语言模型,从Supervised Fine-tuning (SFT)、Reward Modeling (RM) 到Proximal Policy Optimization (PPO) 都能很好地支持。 该模块已经与HuggingFace的transformers模块进行了高度集成。

Qwen1.5-7B是阿里在今天2月份发布的通义千问大模型的新版本,参数量为70亿,性能更好更强大。本文将会介绍如何使用trl模块对该模型进行微调(SFT)。我们以文本分类任务为例,数据集采用Sougou Mini分类数据集,共5个类别。

本文将介绍两种形式的SFT:

  • 指令微调(Instruction Tuning)
  • 对话微调(Chat Tuning)

指令微调

所谓指令微调,指的是数据以Instruction(指令)-Input(输入)-Output(输出)的形式进行组织,其中Input(输入)可以为空,格式如下:

1
2
3
4
5
6
7
8
9
### Instruction:
...

### Input:
...

### Output:
...

我们将文本分类任务的训练数据集加工成上述形式:

1
2
3
4
5
6
7
8
9
[
{
"text": "### 指令:\n给定以下类别标签:['体育', '军事', '教育', '健康', '汽车'],请问下面的输入应当属于哪个类别?\n\n### 输入:\n届数比赛时间比赛地点参加国家和地区冠军亚军决赛成绩第一届1956-1957英国11美国丹麦6:1第二届1959-1960费城(美国)14美国英国5:2第三届1962-1963威尔明顿(美国)11美国英国4:3第四届1965-1966惠灵顿(新西兰)17日本美国5:2第五届1968-1969东京(日本)19日本印尼6:1第六届1971-1972东京(日本)17日本印尼6:1第七届1974-1975雅加达(印尼)14印尼日本5:2第八届1977-1978奥克兰(新西兰)16日本印尼5:2第九届1980-1981东京(日本)15日本印尼4:3第十届1983-1984吉隆坡(马来西亚)23中国印尼5:0第十一届1986雅加达(印尼)34中国印尼3:2第十二届1988吉隆坡(马来西亚)31中国韩国5:0第十三届1990东京(日本)42中国韩国3:2第十四届1992吉隆坡(马来西亚)44中国韩国3:2第十五届1994雅加达(印尼)44印尼中国3:2第十六届1996香港(中国)50印尼中国4:1第十七\n\n### 输出:\n体育\n"
},
{
"text": "### 指令:\n给定以下类别标签:['体育', '军事', '教育', '健康', '汽车'],请问下面的输入应当属于哪个类别?\n\n### 输入:\n商品属性材质软橡胶带加浮雕工艺+合金彩色队徽吊牌规格162mm数量这一系列产品不限量发行图案软橡胶手机带上有浮雕的队名,配有全彩色合金队徽吊牌用途手机吊饰配件彩色精美纸卡包装.所属球队火箭队所属人物无特殊标志NBA商品介绍将NBA球队的队徽,结合时下最流行的手机吊饰用品,是球迷不可错过的时尚选择.吊饰使用彩色队徽吊牌及软像胶带.并在橡胶带上用球队的主要颜色用浮雕效果做出球队名称,产品都同时搭配彩色NBA标志吊牌,是同时兼具时尚和实用功能的NBA商品商品种类NBA标志手机吊饰及7支球队队徽手机吊饰共8款,(首批推出休士顿火箭队,洛杉矶湖人队,迈阿密热火队,圣安东尼奥马刺队,明尼苏达森林狼队,费城76人队,以及底特律活塞队),其他球队未来将陆续推出.\n\n### 输出:\n体育\n"
}
...
]

注意:加工后的数据集只有text字段。

使用trl模块对模型进行微调,PEFT方法采用Lora,训练脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
from datasets import load_dataset
import torch
from peft import LoraConfig
from trl import SFTTrainer
from transformers import TrainingArguments
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig

# Hugging Face model id
model_id = "/data-ai/usr/lmj/models/Qwen1.5-7B"

# BitsAndBytesConfig int-4 config
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16
)

# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto",
torch_dtype=torch.bfloat16,
quantization_config=bnb_config
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.padding_side = 'right'
tokenizer.pad_token = tokenizer.eos_token
tokenizer.pad_token_id = tokenizer.eos_token_id


# Load Dataset
train_dataset = load_dataset("json", data_files="./data/sougou/train.json")['train']
test_dataset = load_dataset("json", data_files="./data/sougou/test.json")['train']
print(train_dataset[0])
print(f"train size: {len(train_dataset)}, test size: {len(test_dataset)}")

# LoRA config based on QLoRA paper & Sebastian Raschka experiment
peft_config = LoraConfig(
lora_alpha=16,
lora_dropout=0.05,
r=64,
bias="none",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj","gate_proj"],
task_type="CAUSAL_LM",
)


# Using TrainingArguments
args = TrainingArguments(
output_dir="output/sougou", # directory to save and repository id
num_train_epochs=3, # number of training epochs
per_device_train_batch_size=8, # batch size per device during training
per_device_eval_batch_size=8, # batch size per device during training
gradient_accumulation_steps=2, # number of steps before performing a backward/update pass
gradient_checkpointing=True, # use gradient checkpointing to save memory
optim="paged_adamw_8bit", # optimizer
save_strategy="epoch", # save by epoch
evaluation_strategy="epoch", # evaluate by rpoch
logging_strategy="steps", # log by step
logging_steps=20, # log every 20 steps
bf16=True, # use bfloat16 precision
learning_rate=2e-4, # learning rate, based on QLoRA paper
max_grad_norm=0.3, # max gradient norm based on QLoRA paper
warmup_ratio=0.1, # warmup ratio
lr_scheduler_type="linear", # use constant learning rate scheduler
push_to_hub=False, # push model to hub
report_to="tensorboard", # report metrics to tensorboard
)

max_seq_length = 512

trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_dataset,
eval_dataset=test_dataset,
peft_config=peft_config,
dataset_text_field="text",
max_seq_length=max_seq_length,
tokenizer=tokenizer,
packing=False
)

# start training, the model will be automatically saved to the hub and the output directory
trainer.train()

# save model
trainer.save_model()

训练完后,会在对应的otutput目录下生成adaper模型文件,我们加载该模型,并在测试集上进行评估,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from transformers import AutoModelForCausalLM, AutoTokenizer

def format_instruction(text):
string = f"""### 指令:
给定以下类别标签:['体育', '军事', '教育', '健康', '汽车'],请问下面的输入应当属于哪个类别?

### 输入:
{text}

### 输出:
"""
return string

peft_model_id = "/data-ai/usr/lmj/code/qwen15_sft/output/sougou/checkpoint-750"
model = AutoModelForCausalLM.from_pretrained(peft_model_id, device_map="cuda")
tokenizer = AutoTokenizer.from_pretrained("/data-ai/usr/lmj/models/Qwen1.5-7B")

def predict(text):
input_text = format_instruction(text=text)
encoding = tokenizer(input_text, return_tensors="pt").to("cuda")
outputs = model.generate(**encoding, max_new_tokens=10, temperature=0.1, do_sample=True, pad_token_id=tokenizer.eos_token_id)
generated_ids = outputs[:, encoding.input_ids.shape[1]:]
generated_texts = tokenizer.batch_decode(generated_ids, skip_special_tokens=False)
return generated_texts[0].split('\n')[0]

import pandas as pd

df = pd.read_csv("./data/sougou/test.csv")

true_labels, pred_labels = [], []
for i, row in df.iterrows():
text, label = row["content"], row["label"]
predict_label = predict(text=text[:450])
true_labels.append(label)
pred_labels.append(predict_label)
print(i, label, repr(predict_label))

from sklearn.metrics import classification_report
print(classification_report(y_true=true_labels, y_pred=pred_labels, digits=4))

结果如下:

1
2
3
4
5
6
7
8
9
10
11
              precision    recall  f1-score   support

体育 0.9898 0.9798 0.9848 99
健康 0.9340 1.0000 0.9659 99
军事 1.0000 1.0000 1.0000 99
教育 1.0000 0.9293 0.9634 99
汽车 0.9800 0.9899 0.9849 99

accuracy 0.9798 495
macro avg 0.9808 0.9798 0.9798 495
weighted avg 0.9808 0.9798 0.9798 495

细心的读者可能注意到,我们在使用训练好的大模型进行生成预测的时候,对生成结果进行了后处理generated_texts[0].split('\n')[0],即只取换行符前面的部分,这是因为大模型生成了10个新的token。

此时,我们还不能完全控制大模型的生成行为,但它总体上遵循了我们的数据指令,只是我们无法知道什么是生成预测的结标志束,只好以换行符为标志。

对话微调

为了改善上述训练后模型的行为,我们使用对话微调,即使用对话模板来加工训练数据。

在transformers中的tokenizer中引入了apply_chat_template方法,我们来看个简单的例子:

1
2
3
4
5
6
7
8
9
10
from transformers import AutoTokenizer
model_id = "./models/Qwen1.5-7B"
tokenizer = AutoTokenizer.from_pretrained(model_id)

print("默认模板: ")
print(tokenizer.default_chat_template)
chat = [{"role": "user", "content": "你好吗?"},
{"role": "user", "content": "我很好。"}]
print("对话格式: ")
print(tokenizer.apply_chat_template(chat, tokenize=False))

输出如下:

1
2
3
4
5
6
7
8
9
10
11
{% for message in messages %}{{'<|im_start|>' + message['role'] + '
' + message['content'] + '<|im_end|>' + '
'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant
' }}{% endif %}
对话格式:
<|im_start|>system
You are a helpful assistant<|im_end|>
<|im_start|>user
你好吗?<|im_end|>
<|im_start|>user
我很好。<|im_end|>

可以看到Qwen1.5-7B模型的默认对话模板为GPT3.5模型的ChatML格式(无BOS/EOS这两个token)。

我们将数据加工成对话形式,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[
{
"messages": [
{
"role": "user",
"content": "届数比赛时间比赛地点参加国家和地区冠军亚军决赛成绩第一届1956-1957英国11美国丹麦6:1第二届1959-1960费城(美国)14美国英国5:2第三届1962-1963威尔明顿(美国)11美国英国4:3第四届1965-1966惠灵顿(新西兰)17日本美国5:2第五届1968-1969东京(日本)19日本印尼6:1第六届1971-1972东京(日本)17日本印尼6:1第七届1974-1975雅加达(印尼)14印尼日本5:2第八届1977-1978奥克兰(新西兰)16日本印尼5:2第九届1980-1981东京(日本)15日本印尼4:3第十届1983-1984吉隆坡(马来西亚)23中国印尼5:0第十一届1986雅加达(印尼)34中国印尼3:2第十二届1988吉隆坡(马来西亚)31中国韩国5:0第十三届1990东京(日本)42中国韩国3:2第十四届1992吉隆坡(马来西亚)44中国韩国3:2第十五届1994雅加达(印尼)44印尼中国3:2第十六届1996香港(中国)50印尼中国4:1第十七"
},
{
"role": "assistant",
"content": "体育"
}
]
},
...
]

训练脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
from datasets import load_dataset
import torch
from peft import LoraConfig
from trl import SFTTrainer
from transformers import TrainingArguments
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig

# Hugging Face model id
model_id = "/data-ai/usr/lmj/models/Qwen1.5-7B"

# BitsAndBytesConfig int-4 config
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16
)

# Load model and tokenizer
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto",
torch_dtype=torch.bfloat16,
quantization_config=bnb_config
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.padding_side = 'right'
tokenizer.pad_token = tokenizer.eos_token
tokenizer.pad_token_id = tokenizer.eos_token_id

def formatting_prompts_func(samples):
output_texts = []
for i in range(len(samples['messages'])):
text = tokenizer.apply_chat_template(samples['messages'][i], tokenize=False)
output_texts.append(text)
return output_texts

train_dataset = load_dataset("json", data_files="./data/sougou/train_chat.json")['train']
test_dataset = load_dataset("json", data_files="./data/sougou/test_chat.json")['train']
print(train_dataset[0])
print(f"train size: {len(train_dataset)}, test size: {len(test_dataset)}")

# LoRA config based on QLoRA paper & Sebastian Raschka experiment
peft_config = LoraConfig(
lora_alpha=16,
lora_dropout=0.05,
r=64,
bias="none",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj","gate_proj"],
task_type="CAUSAL_LM",
)

args = TrainingArguments(
output_dir="output/sougou",
num_train_epochs=3,
per_device_train_batch_size=8,
per_device_eval_batch_size=8,
gradient_accumulation_steps=2,
gradient_checkpointing=True,
optim="paged_adamw_8bit",
save_strategy="epoch",
evaluation_strategy="epoch",
logging_strategy="steps",
logging_steps=20,
bf16=True,
learning_rate=2e-4,
max_grad_norm=0.3,
warmup_ratio=0.1,
lr_scheduler_type="constant",
push_to_hub=False,
report_to="tensorboard",
)

max_seq_length = 512

trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_dataset,
eval_dataset=test_dataset,
peft_config=peft_config,
max_seq_length=max_seq_length,
tokenizer=tokenizer,
packing=False,
formatting_func=formatting_prompts_func
)

# start training, the model will be automatically saved to the hub and the output directory
trainer.train()

# save model
trainer.save_model()

该训练脚本的区别在于我们将数据加工成了对话形式,使用tokenizer.apply_chat_template方法。

对训练好的模型进行预测(注意generate函数的参数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer

peft_model_id = "/data-ai/usr/lmj/code/qwen15_sft/output/sougou/checkpoint-750"
model = AutoModelForCausalLM.from_pretrained(peft_model_id, device_map="cuda")
tokenizer = AutoTokenizer.from_pretrained("/data-ai/usr/lmj/models/Qwen1.5-7B")

def predict(text):
eos_token_id = tokenizer("<|im_end|>",add_special_tokens=False)["input_ids"][0]
encoding = tokenizer(text, return_tensors="pt").to("cuda")
outputs = model.generate(**encoding, max_new_tokens=10, temperature=0.1, do_sample=True, eos_token_id=eos_token_id, pad_token_id=tokenizer.eos_token_id)
generated_ids = outputs[:, encoding.input_ids.shape[1]:]
generated_texts = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)
return generated_texts[0]


dataset = load_dataset("json", data_files="./data/sougou/test_chat.json")['train']

true_labels, pred_labels = [], []
for i in range(len(dataset)):
message = dataset[i]["messages"][:1]
true_label = dataset[i]["messages"][1]["content"]
true_labels.append(true_label)
text = tokenizer.apply_chat_template(message, tokenize=False, add_generation_prompt=True)
pred_label = predict(text=text)
pred_labels.append(pred_label)
print(i, true_label, pred_label)

from sklearn.metrics import classification_report
print(classification_report(y_true=true_labels, y_pred=pred_labels, digits=4))

此时我们设置了预测生成时的eos_token_id,这样我们就能控制大模型生成的行为了,即生成的文本只有文本分类任务的类别,这就达到了我们的目标。

总结

本文是笔者对于想要使用更基础的模块进行SFT的一次尝试,也是笔者一直想要努力的方向:掌握ChatGPT3.5模型的整体训练流程。

SFT看上去简单,但实际自己调试起来,还是有不少坑的。

笔者后续将会将SFT阶段进行整理,形成开源项目,方便大家使用,不过其实fireflyLLaMA-Factory也已经非常好用啦~

推荐阅读

参考文献

  1. TRL - Transformer Reinforcement Learning: https://huggingface.co/docs/trl/index#trl---transformer-reinforcement-learning
  2. Supervised Fine-tuning Trainer: https://huggingface.co/docs/trl/sft_trainer
  3. Templates for Chat Models: https://huggingface.co/docs/transformers/chat_templating
  4. How to fine-tune Google Gemma with ChatML and Hugging Face TRL: https://www.philschmid.de/fine-tune-google-gemma
  5. Google Gemma 2B 微调实战(IT科技新闻标题生成): https://ganymedenil.com/2024/03/24/Google-Gemma-2B-fine-tuning-practice-IT-technology-news-headline-generation.html

欢迎关注我的知识星球“自然语言处理奇幻之旅”,笔者正在努力构建自己的技术社区。


NLP(九十三)使用HuggingFace-TRL微调Qwen1.5-7B模型(SFT)
https://percent4.github.io/NLP(九十三)使用HuggingFace-TRL微调Qwen1-5-7B模型(SFT)/
作者
Jclian91
发布于
2024年5月3日
许可协议