微调 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在平台复制密钥
不同平台可参考官方文档 密钥使用方法
# linux 平台# 用您的 DashScope API-KEY 代替 YOUR_DASHSCOPE_API_KEYexport DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"批量生成 output:
import jsonimport 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.jsonwith open('stories.json', 'r', encoding='utf-8') as file: inputs = json.load(file)
# 读取results.jsonwith 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.jsonwith 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 modelscopefrom modelscope import snapshot_download
# 第一次下载时打开model_dir = snapshot_download('Qwen/Qwen2-1.5B-Instruct',cache_dir='./')处理数据集
依赖加载
import pandas as pdfrom datasets import Datasetfrom 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.5 和 Qwen2 采用的 Prompt Template 格式如下:
<|im_start|>systemYou 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_idprint(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部分的层,不同的模型对应的层的名字不同,可以传入数组,也可以字符串,也可以正则表达式。r:lora的秩,具体可以看Lora原理lora_alpha:Lora 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_sizegradient_accumulation_steps: 梯度累加,如果你的显存比较小,那可以把batch_size设置小一点,梯度累加增大一些。logging_steps:多少步,输出一次lognum_train_epochs:顾名思义epochgradient_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_modelfrom transformers import AutoModelForCausalLM, AutoTokenizerdevice = "cuda:0" # the device to load the model onto
import torchfrom 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)# 加载tokenizertokenizer = 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)
# 启动批处理存为jsonres_novel = baseline_model(stories,model)A100-40G 的生成速度约为 23s / 条,此代码会打印生成每一步的过程。