写在前面
本次博客主要是2024年第三期的第三次任务,涉及了大模型微调等相关内容。经过先前的两次任务,我们已经学会了如何去调用大模型,但是对于如何提升大模型,我们可能只能靠更加精准的提问,这种靠更加精准的提问来获得更好的效果的方式我们称为提示词工程(prompt engineering) 关于提示词工程,这篇博客讲的我觉得比较好:很全面的提示工程指南(包含大量示例!)
这次任务的大模型微调采用LoRA微调。LoRA(Low-Rank Adaptation)微调是一种高效的模型微调技术,特别适用于大型预训练语言模型的适应性调整。LoRA的核心思想是通过引入低秩矩阵来调整模型的权重,从而在不显著增加模型参数数量的情况下,实现对模型的微调。
整体思路
本次的代码个人认为比较难的地方就是配置这个token这个地方,很多非专业的同学到这块可能会觉得头疼,感觉没太看明白,咋就返回一堆数,这些数都干啥的?不急,我们先宏观的看一下总体上这个代码都干了些啥,了解哪些地方我们没有学过,我们再去攻克这些地方
整体上,这个代码分为两个部分,一个是训练,另一个是使用。训练部分我觉得是知识点最多的地方。首先,我们要知道,大模型在训练的时候最”喜欢“看到token这样的数据:
return {
"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": labels
}
这个就是process_func函数,我们先知道一下它的功能,是把这个训练集的数据处理成我们的大模型所谓的”喜欢看到“的样子,然后用lora训练,之后这个结果再进baseline里面跑。lora这个是训练用的。这个训练过程,先把lora参数,就是调好给那个config对象,然后给那个trainer,如果还不理解,就是你导儿把资料(我们这个原来的模型)和课题(这个config)给你(就是那个trainer)了,你最后生产出一篇论文(就是我们要的调好的模型)。后半部分就是这个vllm这部分,要不是想要特别专业的话我觉得它的原理看看就好,然后这个llm就是”民主集中制“,谁投票多谁说了算,那个函数就是那种取大的函数,我们再调用大模型的时候调用三次,然后取得票最多的。
整体思路就是这样。我们接下来看一下具体的实现过程
必要的基础
这次我觉得思维跨度比较大的可能就是那个token了 ,我们先来介绍一下,大模型究竟喜欢看点啥这次我们将这个token不讲特别细,就是能用到啥咱们就讲点啥。
在自然语言处理(NLP)和机器学习领域,"token"(通常翻译为“标记”或“词元”)是一个非常重要的概念。在NLP中,token是文本处理的基本单位。它可以是单词、短语、数字或其他任何形式的文本片段。
说白了就是把一句话分块呗,欸,有个专门给分块的东西,叫做分词器(Tokenization) 拆分一句话可不是随便瞎猜的,尤其是汉语,很容易就变成”断句大师“(doge)
这个返回值它给了三个 input_ids attention_mask label 一个是输入样本的id,另一个是attention_mask 欸训练个模型要模型注意点啥?为了让这些分开的词数据长度相同,我们会在前面补上一些,这时候大模型可就需要注意了,有些词会因为是补上来的,这个数据有”噪音“,我们要注意一下,并且在训练的时候清除或保证这些数据不会干扰我们正常的训练
后面那个labels,也可以暂时理解为除噪。我们在后面会详细解释这段东西
总而言之,看过这一段文字应该明白三件事情:一是我们需要分词,二是我们需要用到专门的分词器,这个是我们的大模型最喜欢看到的,三是这个数据处理的最终结果应该有三个值:input_ids attention_masks labels(我觉得labels这块最难理解)
代码精读
!pip install modelscope==1.9.5
!pip install "transformers>=4.39.0"
!pip install streamlit==1.24.0
!pip install sentencepiece==0.1.99
!pip install transformers_stream_generator==0.0.4
!pip install datasets==2.18.0
!pip install peft==0.10.0
!pip install openai==1.17.1
!pip install tqdm==4.64.1
!pip install transformers==4.39.3
!python -m pip install setuptools==69.5.1
!pip install vllm==0.4.0.post1
!pip install nest-asyncio
!pip install accelerate
!pip install tf-keras
该下载的库,都给pip下载好,这些库可能不止在本次夏令营,以后可能都非常有用。
import torch
from modelscope import snapshot_download, AutoModel, AutoTokenizer
import os
model_dir = snapshot_download('qwen/Qwen2-7B-Instruct', cache_dir='./', revision='master')
这个代码,就是下一个原始的模型,就是modelscope上有,这个执行完会出现一个相应的文件夹qwen,如图:
接下来导入这些库:
from datasets import Dataset
import pandas as pd
from transformers import AutoTokenizer, AutoModelForCausalLM, DataCollatorForSeq2Seq, TrainingArguments, Trainer, GenerationConfig
经过了这些前置工作,我们终于进入制作这个训练集的环节
# 将JSON文件转换为CSV文件
df = pd.read_json('an.json')
ds = Dataset.from_pandas(df)
#加载tokenizer
tokenizer = AutoTokenizer.from_pretrained('./qwen/Qwen2-7B-Instruct', use_fast=False, trust_remote_code=True)
这个由我给出的注释也可以看出,这两句话把我们给的json文件转成易读的(对于人家ai来说)csv文件(不明白csv文件长啥样的话我用ai给举了个例子):
姓名,年龄,职业
张三,30,软件工程师
李四,25,设计师
王五,35,项目经理
赵六,28,数据分析师
(如果说加上线的话可能也挺像excel的)
然后我们加载这个tokenizer,也就是分词器,就是后面那一行。
现在,我们定义一个数据格式化函数,这个比较专业一些,本人搞硬件出身的,这个东西我也没理解太透彻,我就尽我所能的讲一下,如果有更好的解释方法的话欢迎评论区留言~
数据格式化的意思就是把我们给的这个文件给做成我们前面所说的 input_ids 等组成的一系列值,,为了更好的理解他是怎么调用的,我先解释一下调用它的那部分长什么样的:
tokenized_id = ds.map(process_func, remove_columns=ds.column_names)
这里又出现了map函数,这个是什么呢?
map是python的一个方法,接受可迭代对象(如列表、元组等,就是python里基本上由几个元素包起来的数据类型都是可迭代对象,因为可遍历,更详细的大家可以google一下~)对这些对象内部的每一个元素都执行此方法,这个方法就是我们的第一个函数,也就是我们的process_func,第二个参数是一个设置,我们可以看到我们要remove的对象是column_names,我们不去看原来的数据集被转化成什么样的csv文件,我们就看我刚才举那个例子,题头上写着“姓名 年龄 职业” 这些显然对训练数据来说都是干扰项,我们要remove掉。
所以,这个函数的参数就是我们csv文件的·每一个元素,那么具体的处理,就是这个比较难懂的process_func函数了:
def process_func(example):
MAX_LENGTH = 1800 # Llama分词器会将一个中文字切分为多个token,因此需要放开一些最大长度,保证数据的完整性
input_ids, attention_mask, labels = [], [], []
instruction = tokenizer(f"<|im_start|>system\n你是一个逻辑推理专家,擅长解决逻辑推理问题。<|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
}
这个MAX_LENGTH就是在后面做个截断,根据注释所说,放开一些最大长度可能会导致汉字卡到一般,影响训练,汉字卡到一半是什么概念呢,如果有用过keil5等一些老的国外软件的话可能会体会更深一些,就是撤回一个汉字需要backspace两下,并且backspace一下会产生一个问号,这个也是类似的道理,汉字的编码还是比较复杂的。
在这之后我们创建了这三个目录,就是我们需要返回的“三大件”
后面就是这个instruction和response了,说白了就是Q&A,我们训练的话需要告诉他什么,要是不理解的话可以看一下这个an.jsonl文件的结构,我在这里取一段:
{
"instruction": "你是一个逻辑推理专家,擅长解决逻辑推理问题。以下是一个逻辑推理的题目,形式为单项选择题。所有的问题都是(close-world assumption)闭世界假设,即未观测事实都为假。请逐步分析问题并在最后一行输出答案,最后一行的格式为\"答案是:A\"。题目如下:\n\n### 题目:\n有一些规则和关系描述了不同的人、他们的状态以及事件的发生条件。以下是已知的信息:\n\n1. 如果某人是幸运的,那么他有工作。\n2. 如果某人通过了考试,并且有工作,那么他是快乐的。\n3. 如果某人学习了,他就通过考试。\n4. 如果某人是幸运的,他就通过考试。\n5. John 是幸运的。\n6. Bob 学习了。\n\n根据这些信息,回答以下选择题:\n\n### 问题:\n选择题 1:\n谁是快乐的?\nA. John\nB. Bob\nC. John 和 Bob\nD. 没有人\n",
"input": "",
"output": "A"
},
我们这里可以给出一个思路,就是process_func的instruction对应我们json文件的instruction和input,原来process_func函数对应着我们这里的output。input是额外输入,逻辑推理题目通常不需要外部输入,而是依赖于题目中给出的前提条件来解决问题,所以这个input是空的,但是作为一个标准我认为加上还是有好处的,万一它需要额外输入呢。
然后有人f-string字符串看不懂了,会的同学可以跳过,不会的同学我给讲一讲tokenizer里面写了个什么东西:
首先我们给出他的结构:
<|im_start|>system
表示系统部分的开始,可能是用来描述系统的行为或角色。<|im_end|>
表示系统部分的结束。<|im_start|>user
表示用户输入部分的开始。{example['instruction'] + example['input']}
是实际的用户输入,其中example
是一个包含指令和输入的字典。<|im_end|>
表示用户输入部分的结束。<|im_start|>assistant
表示助手回答部分的开始。- 结尾处是助手的回答,但是它确实,为啥缺失?我的问题是让ai回答user的问题,这块自然是缺失的。
response这部分的结构也类似,这里就不再赘述。
example就是对应的每一个元素。对应上面我给出的原来json文件的结构,我们可以得知。
好了,我们看下面的内容.
通过后面调用instruction时是instruction["input_ids"] 和 instruction["attention_mask"],所以我们可以猜测出,这个tokenizer返回一个字典,这个字典不管有没有其他的东西,但是它有两个key值:input_ids 和 attention_mask 后面的input_ids 和这个attention_mask就是将instruction和response给合并,怎么理解呢,就是科目一背题库的时候(训练模型),我需要的是既有题目又有答案的题,这个题目就是instruction,答案就是response,我在背题库(训练模型)时,必须是题和答案在一起,否则我们背不了(训练不知道答案,怎么训练呢)后面这一项tokenizer.pad_token_id,我们在前面提到过,为了长度相同,我们添加到了一些token,这些token都被表示为这个,为了防止我们这个input_ids将两者合起来之后数据长度不相等,我们就在后面加上这个。后面的attention_mask加1,我理解,就是说“都注意一下”那为什么不直接设为1呢,ai告诉我是为了上下文连贯性:
好吧,先这么理解吧。
然后就是这个labels,模型不需要预测这个user问的问题,于是我们给他置为-100,在内部这个值的意思是忽略这部分的预测。我们要预测的是response这部分,这部分我们在后面也是加上个tokenize.pad_token_id,跟之前的用法一样,保证都一样长(为啥都一样长,不一样长作为机器要识别的话都成“断句大师”了),后面这个截断也是同理,都一样长。
最后返回一个字典
后面这个tokenizer_id接收到的就是我返回的·字典组成列表重新形成的dataset对象。至于后面的操作:
tokenizer.decode(tokenized_id[0]['input_ids'])
tokenizer.decode(list(filter(lambda x: x != -100, tokenized_id[1]["labels"])))
就是解码(decode是解码,把我们的数据集翻译成人话),然后看一眼对不对
训练集做好了,是不是得有模型啊,就是加载我们下载完的这个模型
import torch
model = AutoModelForCausalLM.from_pretrained('./qwen/Qwen2-7B-Instruct', device_map="auto",torch_dtype=torch.bfloat16)
model
接下来是这个Task的核心——Lora
其实也没多难,就是设置个参数:
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=32, # Lora alaph,具体作用参见 Lora 原理
lora_dropout=0.1# Dropout 比例
)
config
后面这仨数可以自己调,具体原理的话可以参考一下底层的相关论文,这里有一篇博客,讲的是一些常用参数的调法(原来代码里有挺多不太常用的,就放那就好应该)https://blog.csdn.net/shebao3333/article/details/134523779
接下来就是这个
model = get_peft_model(model, config)
这个就是合并,把config和model都给你了,这个是打包一下
args = TrainingArguments(
output_dir="./output/Qwen2_instruct_lora",
per_device_train_batch_size=1,
gradient_accumulation_steps=4,
logging_steps=10,
num_train_epochs=1,
save_steps=100,
learning_rate=1e-4,
save_on_each_node=True,
gradient_checkpointing=True
)
设置训练的参数。
-
output_dir
:模型的输出路径 -
per_device_train_batch_size
:顾名思义batch_size
-
gradient_accumulation_steps
: 梯度累加,如果你的显存比较小,那可以把batch_size
设置小一点,梯度累加增大一些。 -
logging_steps
:多少步,输出一次log
-
num_train_epochs
:顾名思义epoch
-
gradient_checkpointing
:梯度检查,这个一旦开启,模型就必须执行
调参这个东西,懂的都懂,调之前可以算一卦(doge)
trainer = Trainer(
model=model,
args=args,
train_dataset=tokenized_id,
data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
)
然后就是训练了,我们可以看到,这个train_dataset就是我们刚刚费了挺大功夫整的训练集
torch.backends.cuda.enable_mem_efficient_sdp(False)
这个不用关注,默认就是禁用的
trainer.train()
训练!
微调模型测试
先看代码
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
from peft import PeftModel
mode_path = './qwen/Qwen2-7B-Instruct/'
lora_path = './output/Qwen2_instruct_lora_an/checkpoint-100' # 这里改称你的 lora 输出对应 checkpoint 地址
# 加载tokenizer
tokenizer = AutoTokenizer.from_pretrained(mode_path, trust_remote_code=True)
# 加载模型
model = AutoModelForCausalLM.from_pretrained(mode_path, device_map="auto",torch_dtype=torch.float16, trust_remote_code=True).eval()
# 加载lora权重
model = PeftModel.from_pretrained(model, model_id=lora_path)
prompt = '''你是一个逻辑推理专家,擅长解决逻辑推理问题。以下是一个逻辑推理的题目,形式为单项选择题。所有的问题都是(close-world assumption)闭世界假设,即未观测事实都为假。请逐步分析问题并在最后一行输出答案,最后一行的格式为"答案是:A"。题目如下:\n\n### 题目:\n假设您需要构建一个二叉搜索树,其中每个节点或者是一个空的节点(称为"空节点"),或者是一个包含一个整数值和两个子树的节点(称为"数值节点")。以下是构建这棵树的规则:\n\n1. 树中不存在重复的元素。\n2. 对于每个数值节点,其左子树的所有值都小于该节点的值,其右子树的所有值都大于该节点的值。\n3. 插入一个新值到一个"空节点"时,该"空节点"会被一个包含新值的新的数值节点取代。\n4. 插入一个已存在的数值将不会改变树。\n\n请基于以上规则,回答以下选择题:\n\n### 问题:\n选择题 1:\n给定一个空的二叉搜索树,插入下列数字: [5, 9, 2, 10, 11, 3],下面哪个选项正确描述了结果树的结构?\nA. tree(5, tree(2, tree(3, nil, nil), nil), tree(9, tree(10, nil, nil), tree(11, nil, nil)))\nB. tree(5, tree(2, nil, tree(3, nil, nil)), tree(9, nil, tree(10, nil, tree(11, nil, nil))))\nC. tree(5, tree(3, tree(2, nil, nil), nil), tree(9, nil, tree(10, tree(11, nil, nil), nil)))\nD. tree(5, nil, tree(2, nil, tree(3, nil, nil)), tree(9, tree(11, nil, nil), tree(10, nil, nil)))'''
inputs = tokenizer.apply_chat_template([{"role": "user", "content": "你是一个逻辑推理专家,擅长解决逻辑推理问题。"},{"role": "user", "content": prompt}],
add_generation_prompt=True,
tokenize=True,
return_tensors="pt",
return_dict=True
).to('cuda')
gen_kwargs = {"max_length": 2500, "do_sample": True, "top_k": 1}
with torch.no_grad():
outputs = model.generate(**inputs, **gen_kwargs)
outputs = outputs[:, inputs['input_ids'].shape[1]:]
print(tokenizer.decode(outputs[0], skip_special_tokens=True))
这个整体思路就是,我加载这个训练完的模型和lora,问他问题,他回复答案,你看看对不对,就这个意思中间有些参数就照着设置就好,深入讲的话就太底层了
(*是解包操作,**是解包字典)
照着这个思路的话这个代码应该很容易看懂,(看不懂的话可以问)
模型合并存储
# 模型合并存储
new_model_directory = "./merged_model_an"
merged_model = model.merge_and_unload()
# 将权重保存为safetensors格式的权重, 且每个权重文件最大不超过2GB(2048MB)
merged_model.save_pretrained(new_model_directory, max_shard_size="2048MB", safe_serialization=True)
!cp ./qwen/Qwen2-7B-Instruct/tokenizer.json ./merged_model_an/
这个就是创建一个文件夹,然后合并并且保存到这个新的目录里。后面这个cp是复制把这个文件复制粘贴到新建的目录中
关于vllm
vLLM(Virtual Large Language Model)是一个由伯克利大学LMSYS组织开源的大规模语言模型高速推理框架。它的设计目标是在实时应用场景中大幅提升语言模型服务的吞吐量和内存使用效率。vLLM的特点包括易于使用、与Hugging Face等流行工具无缝集成以及高效的性能。
这个使用的话就是在后台开着就行
def call_qwen_api(MODEL_NAME, query):
# 这里采用dashscope的api调用模型推理,通过http传输的json封装返回结果
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="sk-xxx", # 随便填写,只是为了通过接口参数校验
)
completion = client.chat.completions.create(
model=MODEL_NAME,
messages=[
# {'role':'system','content':'你是一个解决推理任务的专家,你需要分析出问题中的每个实体以及响应关系。然后根据问题一步步推理出结果。并且给出正确的结论。'},
{"role": "user", "content": query}
]
)
return completion.choices[0].message.content
baseline里的这大哥就是说把模型改成本地的,剩下的和Task1的一样
多路llm投票
简单来说,就是多次调用取次数最多的
def most_frequent_char(char1, char2, char3):
# 创建一个字典来存储每个字符的出现次数
frequency = {char1: 0, char2: 0, char3: 0}
# 增加每个字符的出现次数
frequency[char1] += 1
frequency[char2] += 1
frequency[char3] += 1
# 找到出现次数最多的字符
most_frequent = max(frequency, key=frequency.get)
return most_frequent
def process_datas(datas,MODEL_NAME):
results = []
# 送入多线程任务
for data in tqdm(datas, desc="Submitting tasks", total=len(datas)):
problem = data['problem']
for id,question in enumerate(data['questions']):
prompt = get_prompt(problem,
question['question'],
question['options'],
)
# 统一使用llm 三次调用
res,res1,res2 = api_retry(MODEL_NAME, prompt),api_retry(MODEL_NAME, prompt),api_retry(MODEL_NAME, prompt)
# 统一做结果抽取
extract_response,extract_response1,extract_response2 = extract(res),extract(res1),extract(res2)
# 通过投票函数获取最终结果并返回
ans = most_frequent_char(extract_response,extract_response1,extract_response2)
data['questions'][id]['answer'] = ans
results.append(data)
return results
这个我相信结合Task1的讲解和我对整体思路的讲解,这一块也应该可以看明白。
由于博主有点事,大家看到这篇文章的时候可能Task3已经截至打卡了,但是弄明白这个我觉得还是对大家以后的学习有一定的帮助,谢谢大家
祝大家学习顺利!
标签:Task3,nil,tree,ids,datawhale,这个,input,model,LoRA From: https://blog.csdn.net/weixin_54542639/article/details/140840744