本文对可完成赛事“逻辑推理赛道:复杂推理能力评估”初赛的Baseline2部分关键代码进行详细解读,介绍Baseline2涉及的关键技术和初步上分思路。
Baseline2代码由Datawhale AI夏令营提供,核心内容是将大语言模型部署至本地,并在此基础上使用Lora技术微调大语言模型。另外,为了加快本地推理的速度,本次任务代码使用vllm技术加速大语言模型推理(Task1和Task2方法的核心是直接调用外部大语言模型的API进行推理)。
🔴注意:
1、在进行代码解读时,为了简化代码、不影响代码理解,本文对baseline2代码顺序进行了一定调整。
2、运行Baseline2代码有部分注意点,分别是:尽量使用cuda12.1的设备(一)、根据选择的微调数据集修改答案提取函数(五)。
3、使用ana.json作为微调数据集,效果由于使用an.json。关于这两个文件的区别详见后文。
4、本文另外尝试在不使用vLLM的情况下进行推理,加速前单个问题的推理速度约为5分钟/题,加速后单个问题的推理速度约为0.35分钟/题,加速效果显著。
5、本文另外发现了vLLM服务启动后GPU占用较高的问题,vLLM服务启动后整体推理速度也仍有待提高(目前推理所有问题在V100-32G上需要7小时。改进方式可参考使用vLLM加速大语言模型推理)。
LoRA(Low-Rank Adaptation)微调是一种高效的模型微调技术,特别适用于大型预训练语言模型的适应性调整。LoRA的核心思想是通过引入低秩矩阵来调整模型的权重,从而在不显著增加模型参数数量的情况下,实现对模型的微调。
LoRA优势:
可以针对不同的下游任务构建小型 LoRA 模块,从而在共享预训练模型参数基础上有效地切换下游任务。
LoRA 使用自适应优化器(Adaptive Optimizer),不需要计算梯度或维护大多数参数的优化器状态,训练更有效、硬件门槛更低。
LoRA 使用简单的线性设计,在部署时将可训练矩阵与冻结权重合并,不存在推理延迟。
LoRA 与其他方法正交,可以组合。
实际使用方法可参考本文“Baseline2部分代码解读”中的第二、三章节。
vLLM(Virtual Large Language Model)是一个由伯克利大学LMSYS组织开源的大规模语言模型高速推理框架,设计目标是在实时应用场景中大幅提升语言模型服务的吞吐量和内存使用效率。vLLM的特点包括易于使用、与Hugging Face等流行工具无缝集成以及高效的性能。
实际使用方法可参考本文“Baseline2部分代码解读”中的第四章节。
Datawhale教程中对于多路投票/多路召回的解释:
所谓的“多路召回策略”就是指采用不同的策略、特征或者简单模型,分别召回一部分候选集,然后再把这些候选集混合在一起后供后续排序模型使用的策略。
在实践中,“不同的策略、特征或者简单模型”就是指不同次调用微调后的大语言模型;“召回一部分候选集”就是指分别得到不同的回答/答案选项(可能不同,也可能相同),“把这些候选集混合在一起后供后续排序模型使用”就是指统计这些回答中每个出现过的回答的次数,按照出现次数进行排序,最终决定出现次数最多的回答即为答案。
实际使用方法可参考本文“Baseline2部分代码解读”中的第五章节。
Baseline2的代码有一部分与Baseline1的代码相似,在此文章只记录Baseline2中新出现的技术与代码内容。
Datawhale教程建议使用的环境配置如下:
unbuntu22.04 cuda12.1.0 py3.10 torch2.1.2
我认为cuda的版本比较关键,在自己配置环境的过程中,一开始先选择了一台pytorch版本也是2.1.2但cuda版本是11.8的GPU进行配置,最后到了启动vllm的环节时会无法启动(报错信息可见本章的补充部分)。
其余依赖的python第三方包:
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
数据文件用于微调大语言模型,使之推理能力增强。本次任务使用赛事官方提供的训练集文件round1_train_data.jsonl作为微调数据。Datawhale教程对round1_train_data.jsonl文件中的数据进行了两种处理,分别得到an.json
和ana.json
文件。本次任务将使用an.json
或ana.json
文件作为微调数据集。
处理一:将每个问题中的子问题和答案构建成问答对,得到的结果示例:
{ "instruction": "你是一个逻辑推理专家,擅长解决逻辑推理问题。以下是一个逻辑推理的题目,形式为单项选择题。所有的问题都是(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)))\n", "input": "", "output": "B" },
整个文件处理完成后,数据存储在an.json
文件中。
处理二:通过qwen2-72b模型在train数据集上跑得正确结果并分析,将每个问题中的子问题、分析及答案构建成问答对,得到的结果示例:
{ "instruction": "你是一个逻辑推理专家,擅长解决逻辑推理问题。以下是一个逻辑推理的题目,形式为单项选择题。所有的问题都是(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)))\n", "input": "", "output": "根据题目中的规则,我们依次插入数字 [5, 9, 2, 10, 11, 3] 来构建二叉搜索树。\n\n首先插入数字 5,树为:\n```\ntree(5, nil, nil)\n```\n\n然后插入数字 9,由于 9 > 5,因此插入到右子树:\n```\ntree(5, nil, tree(9, nil, nil))\n```\n\n接下来插入数字 2,由于 2 < 5,因此插入到左子树:\n```\ntree(5, tree(2, nil, nil), tree(9, nil, nil))\n```\n\n再插入数字 10,由于 10 > 5 并且 10 > 9,所以插入到 9 的右子树:\n```\ntree(5, tree(2, nil, nil), tree(9, nil, tree(10, nil, nil)))\n```\n\n继续插入数字 11,由于 11 > 5 并且 11 > 9 和 11 > 10,所以插入到 10 的右子树:\n```\ntree(5, tree(2, nil, nil), tree(9, nil, tree(10, nil, tree(11, nil, nil))))\n```\n\n最后插入数字 3,由于 3 < 5 但是 3 > 2,所以插入到 2 的右子树:\n```\ntree(5, tree(2, nil, tree(3, nil, nil)), tree(9, nil, tree(10, nil, tree(11, nil, nil))))\n```\n\n比较这个最终结果与给定的选项,可以看到选项 B 正确地描述了结果树的结构。\n\n答案是:B" },
整个文件处理完成后,数据存储在ana.json
文件中。
数据文件由Datawhale处理后提供,可在链接Datawhale2024年第二届世界科学智能大赛逻辑推理赛道:复杂推理能力评估baseline2中查看(链接中除了数据文件还有相关的代码,本文分析的代码源自此链接中代码)。
可以使用如下命令将数据文件下载到服务器中:
git clone https://www.modelscope.cn/datasets/Datawhale/DW2024_Complex_reasoning_ability_assessment_qwen2-7b-lora.git
这里多说一句,微调数据集的质量对微调后模型推理的性能有较大的影响。根据我自己的实验结果,使用an.json中数据进行微调,在比赛中的分数低于使用ana.json中数据进行微调。
下载魔搭社区的Qwen2-7B-Instruct模型文件。将模型下载到服务器后,我们可以直接使用模型文件推理(不考虑推理速度问题的话),不必再调用外部的大语言模型API。
使用如下命令进行下载:
import torch from modelscope import snapshot_download, AutoModel, AutoTokenizer import os model_dir = snapshot_download('qwen/Qwen2-7B-Instruct', cache_dir='./', revision='master')
在安装完需要使用的python第三方包、微调完成后,在启动vllm时出现如下多则报错信息,最终未能解决,此处仅将尝试解决报错的过程进行记录。
报错信息1
ImportError: libcudart.so.12: cannot open shared object file: No such file or directory
vllm的github仓库有人就这一问题进行提问:ImportError: libcudart.so.12,得到的回复是:
这个回复的意思是,要么使用cuda 12.1的环境,要么安装适合cuda 11.8的vllm。
在xformers的github仓库中,安装cuda 11.8对应的vllm使用的指令如下,前提是需要已有pytorch 2.3.1,这与教程推荐的pytorch版本又有出入(但是我忘记当时自己是不是没看到这个前提、仍然在pytorch=2.1.2的情况下执行了这条命令,安装成功了,但是后续仍然有新的报错)。
# cuda 11.8 version pip3 install -U xformers --index-url https://download.pytorch.org/whl/cu118
报错信息2
xFormers can't load C++/CUDA extensions.
针对这则报错信息,Stackflow上的一位网友建议检查xFormers与pytorch的版本是否对应。可在此网站上查看xFormers和pytorch的版本对应信息:https://anaconda.org/xformers/xformers/files。在这个网页上,看到与pytorch2.1.2、cuda11.8对应的xformers包是linux-64/xformers-0.0.23.post1-py310_cu11.8.0_pyt2.1.2.tar.bz2,使用wget命令将这个包下载到服务器后安装:
wget https://anaconda.org/xformers/xformers/0.0.23.post1/download/linux-64/xformers-0.0.23.post1-py310_cu11.8.0_pyt2.1.2.tar.bz2 pip install xformers-0.0.23.post1-py310_cu11.8.0_pyt2.1.2.tar.bz2
参考:BUG:C++/CUDA extensions. xFormers was built for: PyTorch 2.1.2+cu121 with CUDA 1201 (you have 2.1.0+
报错信息3
RuntimeError: CUDA error: no kernel image is available for execution on the device
文章《解决CUDA error: no kernel image is available for execution on the device》的作者认为此报错由GPU算力与pytorch依赖的CUDA的算力不匹配造成,建议重新安装pytorch,故使用如下命令重新安装pytorch:
pip install --force-reinstall torch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 --index-url https://download.pytorch.org/whl/cu118
然而这并没有帮助我解决这一报错。由于时间已晚,不再深入debug,使用新的cuda版本为2.1.2的GPU服务器配置环境。
from datasets import Dataset import pandas as pd from transformers import AutoTokenizer, AutoModelForCausalLM, DataCollatorForSeq2Seq, TrainingArguments, Trainer, GenerationConfig import torch from peft import LoraConfig, TaskType, get_peft_model df = pd.read_json('an.json') # 这里先使用an.json文件 ds = Dataset.from_pandas(df) # 将JSON文件转换为CSV文件
pretrained_model_path = './qwen/Qwen2-7B-Instruct' # 具体路径需按实际情况修改 # 加载Tokenizer tokenizer = AutoTokenizer.from_pretrained(pretrained_model_path, use_fast=False, trust_remote_code=True) # 加载预训练模型 model = AutoModelForCausalLM.from_pretrained(pretrained_model_path, device_map="auto",torch_dtype=torch.bfloat16)
使用Lora微调大语言模型时,训练数据需要经过格式化、编码(即tokenize操作),然后再输入模型进行微调训练。这个过程一般包括:
input_ids
;labels
。编码之后的结果都是多维的向量。
定义预处理函数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=False表示不在开头加special_tokens # 对输出部分进行分词 response = tokenizer(f"{example['output']}", add_special_tokens=False) input_ids = instruction["input_ids"] + response["input_ids"] + [tokenizer.pad_token_id] # input_ids是指token在词典中对应的序号 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] # 对于instruction部分的 token,用-100填充(通常用于忽略计算损失)response部分的token则直接用其token IDs,并在末尾添加一个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 }
调用process_func
函数处理数据。
tokenized_id = ds.map(process_func, remove_columns=ds.column_names)
调用process_func
函数处理数据后,得到的数据集结构如下:
Dataset({ features: ['input_ids', 'attention_mask', 'labels'], num_rows: 1204 })
进行decode
数据检查,选择数据集中第一个问答对,检查其输入文本的解码情况;选择数据集中第二个问答对,检查其输出文本的解码情况。
tokenizer.decode(tokenized_id[0]['input_ids']) # 检查输入文本的解码结果 tokenizer.decode(list(filter(lambda x: x != -100, tokenized_id[1]["labels"]))) # 检查输出文本的解码结果
输入文本的解码结果如下:
'<|im_start|>system\n你是一个逻辑推理专家,擅长解决逻辑推理问题。<|im_end|>\n<|im_start|>user\n你是一个逻辑推理专家,擅长解决逻辑推理问题。以下是一个逻辑推理的题目,形式为单项选择题。所有的问题都是(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)))\n<|im_end|>\n<|im_start|>assistant\n根据题目中的规则,我们依次插入数字 [5, 9, 2, 10, 11, 3] 来构建二叉搜索树。\n\n首先插入数字 5,树为:\n```\ntree(5, nil, nil)\n```\n\n然后插入数字 9,由于 9 > 5,因此插入到右子树:\n```\ntree(5, nil, tree(9, nil, nil))\n```\n\n接下来插入数字 2,由于 2 < 5,因此插入到左子树:\n```\ntree(5, tree(2, nil, nil), tree(9, nil, nil))\n```\n\n再插入数字 10,由于 10 > 5 并且 10 > 9,所以插入到 9 的右子树:\n```\ntree(5, tree(2, nil, nil), tree(9, nil, tree(10, nil, nil)))\n```\n\n继续插入数字 11,由于 11 > 5 并且 11 > 9 和 11 > 10,所以插入到 10 的右子树:\n```\ntree(5, tree(2, nil, nil), tree(9, nil, tree(10, nil, tree(11, nil, nil))))\n```\n\n最后插入数字 3,由于 3 < 5 但是 3 > 2,所以插入到 2 的右子树:\n```\ntree(5, tree(2, nil, tree(3, nil, nil)), tree(9, nil, tree(10, nil, tree(11, nil, nil))))\n```\n\n比较这个最终结果与给定的选项,可以看到选项 B 正确地描述了结果树的结构。\n\n答案是:B<|endoftext|>'
输出文本的解码结果如下:
'根据题目描述的函数逻辑,我们来逐步分析输入列表 [3,7,5,9] 的处理过程:\n\n1. 列表非空,开始处理第一个元素3。\n2. 3是奇数,根据规则应被删除。\n3. 处理下一个元素7,同样为奇数,删除。\n4. 继续处理5,同样是奇数,删除。\n5. 最后处理9,依然是奇数,删除。\n\n整个过程中,由于输入列表中的所有元素都是奇数,根据规则它们都将被删除,不会有任何元素保留在结果列表中。\n\n因此,对于输入列表 [3,7,5,9],函数返回的结果是空列表。\n\n答案是:D. []<|endoftext|>'
config = LoraConfig( task_type=TaskType.CAUSAL_LM, # 模型类型 target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], # 需要训练的模型层的名字,主要是attention部分的层,不同的模型对应的层的名字不同,可以传入数组,也可以字符串,也可以正则表达式 inference_mode=False, # 训练模式 r=8, # Lora 秩,具体可以看Lora原理 lora_alpha=32, # Lora alaph,具体作用参见 Lora 原理 # Lora的缩放是lora_alpha/r, 在这个LoraConfig中缩放就是4倍 lora_dropout=0.1# Dropout 比例 )
PEFT(Parameter-Efficient Fine-Tuning,参数高效微调),是一个用于在不微调所有模型参数的情况下,高效地将预训练语言模型(PLM)适应到各种下游应用的库。
每个PEFT方法由一个PeftConfig类来定义,该类存储了用于构建PeftModel的所有重要参数。由于本文使用LoRA微调,因此实际创建的是LoraConfig类。随后,使用get_peft_model()
函数创建PeftModel
,需要基本模型(可从Transformers
库加载)和 LoraConfig
(其中包含如何配置模型以使用 LoRA 进行训练的参数)。
model = get_peft_model(model, config)
打印可训练参数的规模:
model.print_trainable_parameters() # trainable params: 20,185,088 || all params: 7,635,801,600 || trainable%: 0.26434798934534914
args = TrainingArguments( output_dir="./output/Qwen2_instruct_lora", # 微调完成后,微调模型的输出路径(仅包括微调层的部分,并不是整个模型。如果需要得到微调后的整个模型,需要进行模型合并,下文会继续提到) per_device_train_batch_size=1, # batch_size gradient_accumulation_steps=4, # 梯度累加,如果显存比较小,可以把batch_size设置小一点,梯度累加增大一些 logging_steps=10, # 多少步,输出一次log num_train_epochs=1, # epoch save_steps=100, # 为了快速演示,这里设置10,建议你设置成100 learning_rate=1e-4, save_on_each_node=True, gradient_checkpointing=True # 梯度检查,这个一旦开启,模型就必须执行 )
开启梯度检查点时,要执行此方法:
model.enable_input_require_grads()
关于梯度检查点(gradient_checkpoint),这是一种用于节省训练时显存的同时不拖慢训练的技术。更多介绍可参考文章:大模型高效训练基础知识:梯度检查点(Gradient Checkpointing)。
trainer = Trainer( model=model, args=args, train_dataset=tokenized_id, data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True), )
torch.backends.cuda.enable_mem_efficient_sdp(False) trainer.train()
多说一句:在魔搭平台的notebook(A10,8核cpu)运行训练过程耗时约10min,在我自己配置的V100-32GB(6核cpu)服务器上运行训练过程则耗时约60min,差异较大,暂时未弄清差异来源。
from transformers import AutoModelForCausalLM, AutoTokenizer import torch from peft import PeftModel mode_path = './qwen/Qwen2-7B-Instruct' # 预训练大语言模型的路径 lora_path = './output/Qwen2_instruct_lora/checkpoint-100' # 微调完成后,微调模型的输出路径 # 加载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 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} # 使用generate函数生成推理结果 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))
输出:
……(一些日志信息) 2024-07-28 00:15:34.902958: W tensorflow/compiler/tf2tensorrt/utils/py_utils.cc:38] TF-TRT Warning: Could not find TensorRT Special tokens have been added in the vocabulary, make sure the associated word embeddings are fine-tuned or trained. Loading checkpoint shards: 100%|██████████| 4/4 [00:40<00:00, 10.25s/it] B
将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)
预训练大语言模型的tokenizer.json
文件也需要一并放入合并后模型的路径(下方命令在终端执行,路径按实际情况修改):
cp ./qwen/Qwen2-7B-Instruct/tokenizer.json ./merged_model_an/
由于在环境配置时已经安装vllm第三方包,直接在终端输入如下命令即可启动vllm服务。执行后通过vllm的类openai接口便可成功将微调后的模型部署到本地8000端口。
VLLM_USE_MODELSCOPE=False python -m vllm.entrypoints.openai.api_server --model ./merged_model --served-model-name Qwen2-7B-Instruct-lora --max-model-len=4096 --gpu_memory_utilization=0.6
VLLM_USE_MODELSCOPE=False
表示不使用modelscope上面的模型,使用本地的模型。添加这个参数的设置可以避免vllm错误识别模型路径或名称、远程下载模型。
--model ./merged_model
表示需要调用./merged_model
中的模型,路径需根据实际情况修改
--served-model-name Qwen2-7B-Instruct-lora
表示启动的模型名称为Qwen2-7B-Instruct-lora
--gpu_memory_utilization=0.6
(可选,设置这个参数是为了避免占用过多GPU内存,默认情况下是0.9)
vllm服务启动成功的输出:
发现启动vLLM服务后GPU占用较高,以V100-32GB显卡为例,GPU内存使用达到17799MiB / 32768MiB(GPU利用率则为0%)。开始推理后,GPU内存使用达到17971MiB / 32768MiB(这是在设置了gpu_memory_utilization=0.6
的情况下,如果不设置这个参数,会几乎拉满),GPU利用率则为92%左右。
讨论这个问题的文章有:【实践】VLLM显存暴增 | 多卡推理 | 批量推理
推理速度的瓶颈应该还是在于显卡显存的大小,而非GPU内存使用。
正式进行推理这部分的代码与Baseline1中的代码相似,这里只记录几个不同的地方。
由于在启动vllm服务时,定义了served-model-name
是Qwen2-7B-Instruct-lora,所以在代码中,MODEL_NAME的值也需要是Qwen2-7B-Instruct-lora,即:
MODEL_NAME = 'Qwen2-7B-Instruct-lora'
改写Baseline1中的call_qwen_api
代码,改为调用本地类openai的qwen微调模型接口。
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
1、设计投票函数
定义函数most_frequent_chat
,通过三次结果推理,将选择答案最多的结果作为最终结果返回。
def most_frequent_char(char1, char2, char3): # 这里设置三个char作为参数是因为后面会三次调用LLM,每个char对应一个LLM调用的回答 # 创建一个字典来存储每个字符的出现次数 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
假设输入此函数的参数分别为A、A、B,初始化字符频率字典 frequency
为 {A: 0, A: 0, B: 0}
。但是由于字典的键必须是唯一的,所以最终字典将是 {A: 0, B: 0}
。
更新频率字典,增加每个字符的出现次数:
frequency[A] += 1
,频率字典变为 {A: 1, B: 0}
。frequency[A] += 1
,频率字典变为 {A: 2, B: 0}
。frequency[B] += 1
,频率字典变为 {A: 2, B: 1}
。使用 max(frequency, key=frequency.get)
找到出现次数最多的字符。此时 A
的频率是 2
,B
的频率是 1
,所以 A
是出现次数最多的字符。
2、多次调用大语言模型,生成多个结果以供投票
改写process_datas
函数,三次调用LLM,统计出现的答案的出现次数,最终返回投票数最多的结果。
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
值得一提的是,答案提取函数extract
的写法根据微调训练使用的数据集不同也有较小的改动,具体改动内容见注释:
def extract(input_text): ans_pattern = re.compile(r"(.)", re.S) # 使用an.json文件作为微调训练集时使用 # ans_pattern = re.compile(r"答案是:(.)", re.S) # 使用ana.json文件作为微调训练集时使用 problems = ans_pattern.findall(input_text) if(problems == []): return 'A' return problems[0]
这个改动是相当重要的,否则推理完成后,可能会发现提取的答案不是合格的字符,浪费推理资源。
在不使用/无法使用vllm进行加速推理时,由于微调后的大语言模型就存储在本地服务器中,因此直接使用模型进行推理是可行的,这里展示一段直接使用模型进行推理的代码(与3.1节中的代码其实很相似):
from transformers import AutoModelForCausalLM, AutoTokenizer from datetime import datetime device = "cuda" # the device to load the model onto model_path = './merged_model' # 按实际情况修改 model = AutoModelForCausalLM.from_pretrained(model_path, device_map="auto") tokenizer = AutoTokenizer.from_pretrained(model_path) prompt = '''你是一个逻辑推理专家,擅长解决逻辑推理问题。以下是一个逻辑推理的题目,形式为单项选择题。所有的问题都是(close-world assumption)闭世界假设,即未观测事实都为假。请逐步分析问题并在最后一行输出答案,最后一行的格式为"答案是:A"。题目如下:\n\n### 题目:\n假设您需要构建一个二叉搜索树,……\n\n请基于以上规则,回答以下选择题:\n\n### 问题:\n选择题 1:\n给定一个空的二叉搜索树,插入下列数字: [5, 9, 2, 10, 11, 3],下面哪个选项正确描述了结果树的结构?\nA. ……\nB. ……\nC. ……\nD. ……''' messages = [{"role": "user", "content": prompt}] for i in range(5): # 这个循环只是为了模拟回答多个问题的情形 currentDateAndTime = datetime.now() print("The current date and time is", currentDateAndTime) # 方便观察回答一个问题耗时 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, do_sample=True) # 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] print(response) ''' 具体地组织不同问题的prompt、提取答案选项、保存答案至文件等环节的代码省略 '''
多说一句,在我的环境和配置下,不使用vllm加速推理,处理测试集中500个问题(由于每个问题背景下有多个子问题,因此实际是需要处理1204个问题),每个问题需要处理5 minutes;使用vllm加速推理的情况下,处理全部问题需要约7 hours,即每个问题需要处理约0.35 minute,加速效果还是挺明显的(但是整体推理速度还是比较慢,需要7小时)。
在Task4文档里有此部分建议,提出了若干种类型的prompt。
使用相同数据集或开源数据集进行数据集扩充
用GPT 4或者其他能力更强的大语言模型生成数据
目前微调是使用参数高效微调peft框架进行微调。peft框架文档:https://huggingface.co/docs/peft/conceptual_guides/adapter
可以尝试微调和RAG一起弄
FFT全量微调
LoRA-Pro对齐全量微调
DoRA
LoRA +
具体地组织不同问题的prompt、提取答案选项、保存答案至文件等环节的代码省略
‘’’
多说一句,在我的环境和配置下,不使用vllm加速推理,处理测试集中500个问题(由于每个问题背景下有多个子问题,因此实际是需要处理1204个问题),每个问题需要处理5 minutes;使用vllm加速推理的情况下,处理全部问题需要约7 hours,即每个问题需要处理约0.35 minute,加速效果还是挺明显的(但是整体推理速度还是比较慢,需要7小时)。 ## 💦初步上分思路 ### 1、prompt优化 在Task4文档里有此部分建议,提出了若干种类型的prompt。 - 训练时和推理时用的prompt建议用一样的 - 题目有数学计算、逻辑推理、判断等主要类型。可以考虑将数据集进行分类,对不同类型的题目设计不同的prompt(有没有相关的案例?) ### 2、agent设计 ### 3、数据集扩充 - 使用相同数据集或开源数据集进行数据集扩充 - 用GPT 4或者其他能力更强的大语言模型生成数据 ### 4、其他微调方法 目前微调是使用参数高效微调peft框架进行微调。peft框架文档:https://huggingface.co/docs/peft/conceptual_guides/adapter - 可以尝试微调和RAG一起弄 - FFT全量微调 - LoRA-Pro对齐全量微调 - DoRA - LoRA +