Skip to content
JieWong.com
🌐links

微调 Qwen2-7B-Instruct

写于 2024 年 7 月

赛事背景

赛题名称:

优酷 x 天池 「酷文」小说创作大模型挑战赛

赛题背景:

自 ChatGPT 发布以来,AIGC 在各个垂直领域展现出惊人的潜力与可能性,尤其是在 AI + 文学创作方面,引发了广泛的关注与讨论。机器辅助写作不再是科幻小说中的情节,而是正在突破想象力的边界,为文字工作带来全新的可能性。

然而,AI 写作的发展仍然处于初级阶段,面临着诸多挑战。如何让 AI 具备更强的语言理解能力、情感表达能力以及逻辑推理能力,使其创作出逻辑严密且富有创意的内容,是当前众多开发者共同研究的课题。

在此背景下,优酷联合阿里云天池举办「酷文」小说创作大模型挑战赛,旨在激发开发者的兴趣,共同研究如何拓展 AI 模型的创作能力,推动 AIGC 在文学创作中的应用与发展。

赛题任务和主题

基于给定测试集的文本创作任务,选手需要在初赛参考训练数据集的基础上补充数据集,任选 35b 或以下的开源模型进行模型训练,提升模型创作能力,完成 800 字左右的文本创作任务。

我的解决方案

本文中的方案为 Lora 微调 Qwen2-7B-Instruct

生成微调语料

生成 input

在通义千问官网、讯飞星火官网提问

仿照以下内容,生成另一个stories列表
stories = [
"现代励志故事,一个失业青年如何克服生活困境,终于实现自我突破,成为行业翘楚的心路历程。",
"一个现代女性穿越到古代某朝代后发生的传奇故事。",
"现代背景,一名神探警察遇到了一桩棘手的连环失踪案并将其侦破的故事。",
"古代背景,皇家侍卫和公主历经层层考验,突破身份桎梏的爱情故事。",
"现代玄幻背景,在一所驯服神兽的魔法学校中,围绕着三个学生小伙伴发生的奇幻冒险故事。",
"古代侦探系列,一位才华横溢的年轻学士,在解决一连串神秘案件中揭露皇室阴谋的故事。",
"二十一世纪初,一个小镇上发生的一系列神秘事件,让一群青少年开始探索超自然现象,并发现了小镇隐藏的古老秘密的故事。",
"现代都市背景,一个名不见经传的漫画家,通过与自己创作的虚拟角色“交流”,解决一系列诡秘案件的故事。",
"古代异界背景,一位天赋异禀的少年,在师傅的指导下学习古老的灵术,最终踏上寻找失落的神器,拯救家园的冒险旅程的故事。",
"繁华都市背景,一个单亲妈妈如何在抚养孩子和维持生计之间找到平衡,同时保持对自己梦想的追求的故事。",
"现代悬疑系列,一位心理学家利用自己的专业知识,帮助警方侦破一系列复杂的心理游戏案件。",
"现代心理惊悚背景,一名精神科医生被卷入一连串的脑控实验阴谋,如何在精神与现实的边缘徘徊求生的故事。",
"虚构古代背景,一位年轻的书生因缘巧合获得一本神秘典籍,开启了他成为一代宗师的修道之旅。",
"古代神话背景,一位勇者如何经过重重试炼,最终获取神器,拯救世界于水深火热之中的传奇故事。",
"虚拟现实背景,一群玩家在一款极度真实的VR游戏中探索未知世界并揭露游戏背后隐藏的秘密的故事。",
"穿越时空背景,一群来自不同时代的人意外聚集在一个神秘的地方,他们如何互相协作,解开时空之谜的故事。",
"科幻背景,一个机器人意识觉醒后,它如何在追求自我身份的同时,挑战人类社会关于存在和自由的根本问题。",
"20世纪60年代的欧洲,一个侦探在解决一起跨国艺术品盗窃案中,逐渐揭露出一个关于失落宝藏的大阴谋。",
"现代都市背景,一位因交通事故失去双腿的舞者,通过先进的义肢技术重新站起来,重新找回舞台与自我的故事。",
"古代背景,一个普通医女奋斗成为朝廷高官,最终影响整个王朝政治格局变化的故事。"
]

生成类似的故事列表 199 条,写入到 stories.json

[
"b",
"c",
"d"
]

生成 output

使用 qwen-max 生成,在 阿里云百炼大模型服务平台开通服务

安装 dashscope 库,用作密钥管理

pip install dashscope

在平台复制密钥 b2d986f2f3cf41c5272b9e1b37fc4bb8.png 不同平台可参考官方文档 密钥使用方法

# linux 平台
# 用您的 DashScope API-KEY 代替 YOUR_DASHSCOPE_API_KEY
export DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"

批量生成 output:

import json
import dashscope
def get_response(messages):
response = dashscope.Generation.call(model="qwen-max",
messages=messages,
result_format='message')
print("Response:", response) # 打印响应以确认其内容
return response
# 确认读取stories.json文件
try:
with open('stories.json', 'r', encoding='utf-8') as file:
stories = json.load(file)
print(f"Loaded {len(stories)} stories.") # 打印故事数量确认文件被正确读取
except Exception as e:
print("Error reading stories.json:", e)
# 尝试写入结果到results200.json文件
try:
with open('results.json', 'w', encoding='utf-8') as file:
for story in stories:
messages = [{'role': 'system', 'content': '你是个畅销小说作家,创作能力出众:1.措辞华丽流畅;2.内容层层叠进、藏有悬疑;3.不要有分章节;4.字数不多于800'}]
messages.append({'role': 'user', 'content': story})
response = get_response(messages)
if response: # 确认响应不为空
assistant_output = response.output.choices[0]['message']['content']
# 每次循环时即时写入
print(story)
file.write(json.dumps(assistant_output, ensure_ascii=False) + '\n')
file.flush() # 确保数据被写入磁盘
else:
print("No response for story:", story) # 如果没有响应,打印消息
except Exception as e:
print("Error writing to results200.json:", e)

现在有 stories.json 和 results.json 两个文件

# stories.json 格式
[
"b",
"c",
"d"
]
# results.json文件格式
"e"
"f"
"g"

我们要将这两个文件合并为:

# 文件 C 格式
[
{
"instruction": "a",
"input": "b",
"output": "e"
},
{
"instruction": "a",
"input": "c",
"output": "f"
},
{
"instruction": "a",
"input": "d",
"output": "g"
}
]

合并代码:

import json
# 读取stories.json
with open('stories.json', 'r', encoding='utf-8') as file:
inputs = json.load(file)
# 读取results.json
with open('results.json', 'r', encoding='utf-8') as file:
outputs = file.read().splitlines()
# 构建输出数据
instructions = []
for input_value, output_value in zip(inputs, outputs):
instructions.append({
"instruction": "你是个畅销小说作家,创作能力出众:1.措辞华丽流畅;2.内容层层叠进、藏有悬疑;3.不要有分章节;4.字数不多于800",
"input": input_value,
"output": output_value
})
# 写入文件C.json
with open('C.json', 'w', encoding='utf-8') as file:
json.dump(instructions, file, indent=4, ensure_ascii=False)
print("文件C.json已创建")

微调模型

环境准备

python -m pip install --upgrade pip
# 更换 pypi 源加速库的安装
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# 安装微调所需要的库
pip install -U huggingface_hub modelscope "transformers>=4.37.0" streamlit==1.24.0 sentencepiece==0.1.99 accelerate==0.27.2 transformers_stream_generator==0.0.4 datasets==2.18.0 peft==0.10.0

下载模型

这里给出下载示范,我的模型为平台导入 '/tmp/pretrainmodel/Qwen2-7B-Instruct'

#下载模型
pip install modelscope
from modelscope import snapshot_download
# 第一次下载时打开
model_dir = snapshot_download('Qwen/Qwen2-1.5B-Instruct',cache_dir='./')

处理数据集

依赖加载

import pandas as pd
from datasets import Dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, DataCollatorForSeq2Seq, TrainingArguments, Trainer, GenerationConfig
# 将JSON文件转换为CSV文件
df = pd.read_json('./C.json')
ds = Dataset.from_pandas(df)
# 构建好后变成可以批量输入的数据格式
print(ds[:3]) # 查看一条数据
print(len(ds)) # 总共199条微调指令数据

加载 tokenizer

model_path = '/tmp/pretrainmodel/Qwen2-7B-Instruct'
tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False, trust_remote_code=True)
tokenizer

指令集构造

LLM 的微调一般指指令微调过程。这里我们主要强调数据格式化部分,这部分在数据准备部分已经实现,如果是用前面产生的数据集训练,那么这部分可以跳过。这里我们只是提供一个方法,来帮助有其他需要的参赛者。

所谓指令微调,是说我们使用的微调数据形如:

{
"instruction":"回答以下用户问题,仅输出答案。",
"input":"1+1等于几?",
"output":"2"
}

其中,instruction 是用户指令,告知模型其需要完成的任务;input 是用户输入,是完成用户指令所必须的输入内容;output 是模型应该给出的输出。

即我们的核心训练目标是让模型具有理解并遵循用户指令的能力。因此,在指令集构建时,我们应针对我们的目标任务,针对性构建任务指令集。

数据格式化

Lora 训练的数据是需要经过格式化、编码之后再输入给模型进行训练的,如果是熟悉 Pytorch 模型训练流程的同学会知道,我们一般需要将输入文本编码为 input_ids,将输出文本编码为 labels,编码之后的结果都是多维的向量。我们首先定义一个预处理函数,这个函数用于对每一个样本,编码其输入、输出文本并返回一个编码后的字典:

Prompt Template

Qwen1.5Qwen2 采用的 Prompt Template 格式如下:

<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
你是谁?<|im_end|>
<|im_start|>assistant
我是一个有用的助手。<|im_end|>
def process_func(example):
MAX_LENGTH = 2048 # Llama分词器会将一个中文字切分为多个token,因此需要放开一些最大长度,保证数据的完整性
input_ids, attention_mask, labels = [], [], []
instruction = tokenizer(f"<|im_start|>system\n你是个畅销小说作家,创作能力出众:1.措辞华丽流畅;2.内容层层叠进、藏有悬疑;3.不要有分章节;4.字数不多于800。<|im_end|>\n<|im_start|>user\n{example['instruction'] + example['input']}<|im_end|>\n<|im_start|>assistant\n", add_special_tokens=False) # add_special_tokens 不在开头加 special_tokens
response = tokenizer(f"{example['output']}", add_special_tokens=False)
input_ids = instruction["input_ids"] + response["input_ids"] + [tokenizer.pad_token_id]
attention_mask = instruction["attention_mask"] + response["attention_mask"] + [1] # 因为eos token咱们也是要关注的所以 补充为1
labels = [-100] * len(instruction["input_ids"]) + response["input_ids"] + [tokenizer.pad_token_id]
if len(input_ids) > MAX_LENGTH: # 做一个截断
input_ids = input_ids[:MAX_LENGTH]
attention_mask = attention_mask[:MAX_LENGTH]
labels = labels[:MAX_LENGTH]
return {
"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": labels
}
tokenized_id = ds.map(process_func, remove_columns=ds.column_names)
tokenized_id
print(tokenizer.decode(tokenized_id[0]['input_ids']))
print(tokenizer.decode(list(filter(lambda x: x != -100, tokenized_id[1]["labels"]))))

创建模型

加载半精度模型

import torch
model = AutoModelForCausalLM.from_pretrained(model_path, device_map="auto",torch_dtype=torch.bfloat16)
model
model.enable_input_require_grads() # 开启梯度检查点时,要执行该方法
model.dtype

配置训练参数

定义 LoraConfig

LoraConfig 这个类中可以设置很多参数,但主要的参数没多少

  • task_type:模型类型
  • target_modules:需要训练的模型层的名字,主要就是 attention 部分的层,不同的模型对应的层的名字不同,可以传入数组,也可以字符串,也可以正则表达式。
  • rlora 的秩,具体可以看 Lora 原理
  • lora_alphaLora alaph,具体作用参见 Lora 原理

Lora 的缩放是啥嘞?当然不是 r(秩),这个缩放就是 lora_alpha/r, 在这个 LoraConfig 中缩放就是 4 倍。

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"],# 需要添加的lora的目标模块
inference_mode=False, # 训练模式
r=8, # Lora 秩
lora_alpha=32, # Lora alaph,具体作用参见 Lora 原理
lora_dropout=0.1# Dropout 比例
)
config

创建 PeftModel

使用 get_peft_model () 函数创建一个 PeftModel。

它需要一个基本模型(您可以从 Transformers 库加载)和 LoraConfig,其中包含如何配置模型以使用 LoRA 进行训练的参数。

model = get_peft_model(model, config)
config
model.print_trainable_parameters()

自定义 TrainingArguments 参数

TrainingArguments 这个类的源码也介绍了每个参数的具体作用

  • output_dir:模型的输出路径
  • per_device_train_batch_size:顾名思义 batch_size
  • gradient_accumulation_steps: 梯度累加,如果你的显存比较小,那可以把 batch_size 设置小一点,梯度累加增大一些。
  • logging_steps:多少步,输出一次 log
  • num_train_epochs:顾名思义 epoch
  • gradient_checkpointing:梯度检查,这个一旦开启,模型就必须执行
lora_path = "./output0702/Qwen2-7B-Instruct_novel_all"
args = TrainingArguments(
output_dir=lora_path,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
logging_steps=10,
num_train_epochs=10,
save_steps=100,
learning_rate=1e-4,
save_on_each_node=True,
gradient_checkpointing=True
)

模型训练

trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized_id,
data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
)
trainer.train()
lora_path = "./output0702/Qwen2-7B-Instruct_novel_all"
trainer.save_model(lora_path + "/final")

训练完之后并没有合并权重。

此时建议重启一下内核,清空显存,再进行下一步

模型推理

import json
# 微调模型配置
from peft import LoraConfig, TaskType, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer
device = "cuda:0" # the device to load the model onto
import torch
from peft import PeftModel
model_path = '/tmp/pretrainmodel/Qwen2-7B-Instruct'
lora_path = "./output0702/Qwen2-7B-Instruct_novel_all/final"
max_new_tokens = 2048
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_alpha=32,
lora_dropout=0.1
)
# 加载tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_path)
# 加载模型
model = AutoModelForCausalLM.from_pretrained(model_path, device_map="auto",torch_dtype=torch.bfloat16)
# 加载lora权重
model = PeftModel.from_pretrained(model, model_id=lora_path, config=config)
stories = [
"现代励志故事,一个失业青年如何克服生活困境,终于实现自我突破,成为行业翘楚的心路历程。",
"一个现代女性穿越到古代某朝代后发生的传奇故事。",
"现代背景,一名神探警察遇到了一桩棘手的连环失踪案并将其侦破的故事。",
"古代背景,皇家侍卫和公主历经层层考验,突破身份桎梏的爱情故事。",
"现代玄幻背景,在一所驯服神兽的魔法学校中,围绕着三个学生小伙伴发生的奇幻冒险故事。",
"古代侦探系列,一位才华横溢的年轻学士,在解决一连串神秘案件中揭露皇室阴谋的故事。",
"二十一世纪初,一个小镇上发生的一系列神秘事件,让一群青少年开始探索超自然现象,并发现了小镇隐藏的古老秘密的故事。",
"现代都市背景,一个名不见经传的漫画家,通过与自己创作的虚拟角色“交流”,解决一系列诡秘案件的故事。",
"古代异界背景,一位天赋异禀的少年,在师傅的指导下学习古老的灵术,最终踏上寻找失落的神器,拯救家园的冒险旅程的故事。",
"繁华都市背景,一个单亲妈妈如何在抚养孩子和维持生计之间找到平衡,同时保持对自己梦想的追求的故事。",
]
import os
# 写入到sub.json中
def write_to_file(data_to_write):
file_path = 'sub0702.json'
# 检查文件是否存在,如果不存在则创建
if not os.path.exists(file_path):
with open(file_path, 'w', encoding='utf-8') as file:
json.dump([], file, ensure_ascii=False, indent=4)
with open(file_path, 'r+', encoding='utf-8') as file:
try:
data = json.load(file)
except json.JSONDecodeError:
data = []
data.append(data_to_write)
file.seek(0)
json.dump(data, file, ensure_ascii=False, indent=4)
file.truncate()
# 批处理函数
def baseline_model(tasks, model):
for task in tasks:
messages = [
{"role": "system", "content": "你是个畅销小说作家,创作能力出众:1.措辞华丽流畅;2.内容层层叠进、藏有悬疑;3.不要有分章节;4.字数不多于842"},
{"role": "user", "content": task}
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
model_inputs = tokenizer([text], return_tensors="pt").to('cuda:0')
num_gen = 50
for n in range(num_gen):
generated_ids = model.generate(input_ids=model_inputs.input_ids,max_new_tokens=max_new_tokens)
# generated_ids = model.generate(
# model_inputs.input_ids,
# max_new_tokens=max_new_tokens
# )
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]
data_to_write = {
"instruction": "你是个畅销小说作家,创作能力出众:1.措辞华丽流畅;2.内容层层叠进、藏有悬疑;3.不要有分章节;4.字数不多于842",
"input": task,
"output": response,
}
print("写入",n)
# 写入到sub.json中
write_to_file(data_to_write)
# 启动批处理存为json
res_novel = baseline_model(stories,model)

A100-40G 的生成速度约为 23s / 条,此代码会打印生成每一步的过程。

© JieWong.com All Rights Reserved.
皖ICP备20013599号-3
皖公网安备34010402704133号