AI大模型应用(2)ChatGLM3部署及其在alpaca_zh数据集上的低精度微调
- 我们之前已经了解了HuggingFace中peft库的几种高效微调方法。
参数高效微调PEFT(一)快速入门BitFit、Prompt Tuning、Prefix Tuning
参数高效微调PEFT(二)快速入门P-Tuning、P-Tuning V2
参数高效微调PEFT(三)快速入门LoRA、AdaLoRA
参数高效微调PEFT(四)快速入门(IA)3_ia3微调
- 之前我们都是以单精度FP32加载模型,因此在训练过程中,模型本身占用的显存大小并没有改变。
- 今天我们了解下ChatGLM3模型,以及低精度微调。
- https://github.com/THUDM/ChatGLM3
1 ChatGLM3 API快速入门
ChatGLM3 是智谱AI和清华大学 KEG 实验室联合发布的对话预训练模型。ChatGLM3-6B 是 ChatGLM3 系列中的开源模型,在保留了前两代模型对话流畅、部署门槛低等众多优秀特性的基础上,ChatGLM3-6B 引入了如下特性:
- 更强大的基础模型: ChatGLM3-6B 的基础模型 ChatGLM3-6B-Base 采用了更多样的训练数据、更充分的训练步数和更合理的训练策略。
- 更完整的功能支持: ChatGLM3-6B 采用了全新设计的 Prompt 格式 ,除正常的多轮对话外。同时原生支持工具调用(Function Call)、代码执行(Code Interpreter)和 Agent 任务等复杂场景。
- 更全面的开源序列: 除了对话模型 ChatGLM3-6B 外,还开源了基础模型 ChatGLM3-6B-Base 、长文本对话模型 ChatGLM3-6B-32K 和进一步强化了对于长文本理解能力的 ChatGLM3-6B-128K。
1.1 本地GPU部署
首先需要下载本仓库:
git clone https://github.com/THUDM/ChatGLM3
cd ChatGLM3
然后使用 pip 安装依赖:
pip install -r requirements.txt
- 为了保证
torch
的版本正确,请严格按照 官方文档 的说明安装。 - 默认情况下,模型以 FP16 精度加载,需要大概 13GB 显存。
- 如果本地没有GPU,可以租借云GPU:AutoDL平台租借GPU详解
- 官方提供了很多demo示例,例如:模型微调、网页版对话 Demo、命令行对话 Demo、LangChain Demo、OpenAI API / Zhipu API Demo等,可以参考官方文档进行使用。
1.2 API快速调用
1.3.1 HuggingFace风格API调用
# 注意需要安装transformers库
# 可以参考:https://blog.csdn.net/qq_44665283/article/details/133823613
from transformers import AutoTokenizer, AutoModelForCausalLM# 本地模型路径
# 可以利用魔搭社区下载:https://blog.csdn.net/qq_44665283/article/details/139244306
model_path = r'/root/autodl-tmp/models/ZhipuAI/chatglm3-6b'tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)# 1、非量化(默认是FP16精度加载,大概需要13GB显存)
model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True, device='cuda')# 2、量化为int8
# model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True).quantize(8).cuda()# 3、量化为int4
# model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True).quantize(4).cuda()for name, param in model.named_parameters():print(name, param.dtype)model.eval()
response, history = model.chat(tokenizer, "你好", history=[])print(f'response = {response}')
print(f'history = {history}')
1.3.2 OpenAI风格的API调用
作者团队已经推出了 OpenAI / ZhipuAI 格式的 开源模型 API 部署代码,可以作为任意基于 ChatGPT 的应用的后端。
目前,可以通过运行仓库中的 api_server.py 进行部署
root@autodl-container-c4c240bdcc-422ab4ba:~/autodl-tmp/llm/ChatGLM3# cd openai_api_demo# 1、设置从本地路径加载
# 因此需要修改api_server.py中的模型路径
# 还需要设置EMBEDDING_PATH,我这里使用的是text2vec-large-chinese模型
root@autodl-container-c4c240bdcc-422ab4ba:~/autodl-tmp/llm/ChatGLM3/openai_api_demo# vim api_server.py# MODEL_PATH = os.environ.get('MODEL_PATH', 'THUDM/chatglm3-6b')
# TOKENIZER_PATH = os.environ.get("TOKENIZER_PATH", MODEL_PATH)
# set Embedding Model path
# EMBEDDING_PATH = os.environ.get('EMBEDDING_PATH', 'BAAI/bge-m3')# set LLM path
MODEL_PATH = r'/root/autodl-tmp/models/ZhipuAI/chatglm3-6b'
TOKENIZER_PATH = r'/root/autodl-tmp/models/ZhipuAI/chatglm3-6b'
EMBEDDING_PATH = r'/root/autodl-tmp/models/ZhipuAI/text2vec-large-chinese'# 2、启动API服务,默认端口8000
root@autodl-container-c4c240bdcc-422ab4ba:~/autodl-tmp/llm/ChatGLM3/openai_api_demo# python api_server.py
- 启动服务后,我们就可以使用OpenAI风格的API进行调用了:
from openai import OpenAIbase_url = "http://127.0.0.1:8000/v1/"
client = OpenAI(api_key="EMPTY", base_url=base_url)def simple_chat(use_stream=True):messages = [{"role": "system","content": "You are ChatGLM3, a large language model trained by Zhipu.AI. Follow the user's instructions carefully. "},{"role": "user","content": "你是谁"}]response = client.chat.completions.create(model="chatglm3-6b", # 模型名称messages=messages, # 会话历史stream=use_stream, # 指定是否使用流式传输模式,如果设置为True,则返回一个生成器对象,可以逐个获取生成的文本片段;如果设置为False,则一次性返回完整的生成结果。max_tokens=256, # 最多生成字数temperature=0.8, # 温度presence_penalty=1.1, # 控制生成回答时对已出现词汇的惩罚强度,较高的值会减少重复词汇的出现top_p=0.8) # 采样概率print(response)if response:if use_stream:for chunk in response:print(chunk.choices[0].delta.content)else:content = response.choices[0].message.contentprint(content)else:print("Error:", response.status_code)
if __name__ == "__main__":simple_chat(use_stream=False)# simple_chat(use_stream=True)
输出:
ChatCompletion(id='', choices=[Choice(finish_reason='stop', index=0, logprobs=None, message=ChatCompletionMessage(content='我是一个名为 ChatGLM3 的大型语言模型,由 Zhipu.AI 训练。我的目的是通过回答用户的问题来帮助他们解决问题。', role='assistant', function_call=None, tool_calls=None, name=None))], created=1721811856, model='chatglm3-6b', object='chat.completion', service_tier=None, system_fingerprint=None, usage=CompletionUsage(completion_tokens=34, prompt_tokens=44, total_tokens=78)
)
- 启动API服务后,我们也可以对文本进行embedding了
from openai import OpenAIbase_url = "http://127.0.0.1:8000/v1/"
client = OpenAI(api_key="EMPTY", base_url=base_url)def embedding():response = client.embeddings.create(# model="bge-large-zh-1.5",model="text2vec-large-chinese",input=["你好,给我讲一个故事,大概100字"],)embeddings = response.data[0].embeddingprint("嵌入完成,维度:", len(embeddings))return embeddingsif __name__ == "__main__":# 嵌入完成,维度: 1024embedding()
- 当然,我们也可以使用function_call了
- 更详细的使用案例,可以参考官方
tools_using_demo
文件夹下内容
from openai import OpenAIbase_url = "http://127.0.0.1:8000/v1/"
client = OpenAI(api_key="EMPTY", base_url=base_url)tools = [{"type": "function","function": {"name": "get_current_weather","description": "Get the current weather in a given location","parameters": {"type": "object","properties": {"location": {"type": "string","description": "The city and state, e.g. San Francisco, CA",},"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},},"required": ["location"],},},}]messages = [{"role": "user", "content": "What's the weather like in BeiJing?"}]
response = client.chat.completions.create(model="chatglm3-6b",messages=messages,tools=tools,tool_choice="auto",)print(response)
content = response.choices[0].message.contentprint(f'content:\n{content}')
"""
content:
get_current_weather```python
tool_call(location='Beijing', unit='celsius')
```
"""
输出:
ChatCompletion(id='', choices=[Choice(finish_reason='function_call', index=0, logprobs=None, message=ChatCompletionMessage(content="get_current_weather\n ```python\ntool_call(location='Beijing', unit='celsius')\n```", role='assistant'# 模型识别到了function_call, function_call=FunctionCall(arguments='{"location": "Beijing", "unit": "celsius"}', name='get_current_weather'), tool_calls=None, name=None))], created=1721811783, model='chatglm3-6b', object='chat.completion', service_tier=None, system_fingerprint=None, usage=CompletionUsage(completion_tokens=28, prompt_tokens=222, total_tokens=250))
2 ChatGLM3的低精度微调
具体微调可以参考官方源码finetune_demo
,我们今天利用transformer库来了解下低精度微调的过程。
-
我们知道,模型训练时候的显存占用主要如下:
- 模型权重:4Bytes*模型参数量
- 优化器状态(AdamW优化器):8Bytes*模型参数量
- 梯度:4Bytes*模型参数量
- 当然也受批次大小,序列长度等多个因素的影响。
-
我们以ChatGLM3-6B为例,如果以FP32(4Bytes)进行加载,大概需要24GB的显存。
- FP32也叫做 float32,两种叫法是完全一样的,全称是Single-precision floating-point(单精度浮点数)
- 如下图所示,FP32是用32位二进制来表示的浮点数:
- Sign(符号位): 1位,0表示整数,1表示负数
- Exponent(指数位):8位,表示整数部分,偏置值是127
- Fraction(尾数位):23位,表示小数部分,隐含了首位的1,实际的尾数精度为24位
- 而利用transformers库加载时,默认是FP16(2Bytes)精度加载,大概需要13GB显存。我们发现,在参数量不变的情况下,降低模型中每个参数占用的字节数,能够降低模型的显存占用。
- 常见的低精度数据类型有:float16(半精度)、bfloat16、int8、fp4、nf4等
2.1 半精度训练
2.1.1 FP16简介
-
FP16也叫做 float16,两种叫法是完全一样的,全称是Half-precision floating-point(半精度浮点数)
-
如下图所示,是用16位二进制来表示的浮点数:
-
Sign(符号位): 1位,0表示整数;1表示负数。
-
Exponent(指数位):5位,简单地来说就是表示整数部分。
- 范围为00001(1)到11110(30),正常来说整数范围就是 2 1 − 2 30 2^{1}-2^{30} 21−230,注:当指数位都为00000和11111时,表示的是特殊情况。
- 但为了指数位能够表示负数,引入了一个偏置值,偏置值是一个固定的数,它被加到实际的指数上,在二进制16位浮点数中,偏置值是15。
- 这个偏置值确保了指数位可以表示从-14到+15的范围,即 2 − 14 − 2 15 2^{-14}-2^{15} 2−14−215。
-
Fraction(尾数位):10位,简单地来说就是表示小数部分。
-
存储的尾数位数为10位,但其隐含了首位的1,因此实际的尾数精度为11位。
-
这里的隐含位简单通俗来说,假设尾数部分为1001000000,为默认在其前面加一个1,最后变成1.1001000000然后换成10进制就是:
1.1001000000 = 1 + 576(1001000000转换为10进制)/1024 = 1.5625
-
-
- 因此,FP16所表示10进制数的计算公式为:
( − 1 ) s i g n × 2 e x p o n e n t − 15 × ( 1 + f r a c t i o n ( 转为 10 进制 ) 1024 ) (-1)^{sign}×2^{exponent-15}×(1+\frac{fraction(转为10进制)}{1024}) (−1)sign×2exponent−15×(1+1024fraction(转为10进制))
我们可以计算下FP16表示的范围:
我们用pytorch框架进行验证:
>>> import torch>>> torch.finfo(torch.float16)
finfo(resolution=0.001 # 在十进制上的分辨率,表示两个不同值之间的最小间隔。, min=-65504 # 最小值, max=65504 # 最大值, eps=0.000976562 # 表示在给定数据类型下,比1大的最小浮点数, smallest_normal=6.10352e-05 # 最小正规数,大于零的最小浮点数, tiny=6.10352e-05 # 最小非零数,大于零的最小浮点数, dtype=float16
)# 这里重点解释下resolution,这个是我们以十进制来说的两个数之间的最小间隔
# float16下,resolution为0.001,而3.141和3.1415间隔只有0.0005,所以在float16下结果是一样的# 把10进制数转化为 torch.float16
>>> num = 3.141
>>> num_fp16 = torch.tensor(num).half()
>>> print(num_fp16)
tensor(3.1406, dtype=torch.float16)>>> num = 3.1415
>>> num_fp16 = torch.tensor(num).half()
>>> print(num_fp16)
tensor(3.1406, dtype=torch.float16)
# 可以看到3.141和3.1415间隔只有0.0005,所以在float16下结果是一样的# 我们看下float32的信息,可以看到表示范围远远大于float16
>>> torch.finfo(torch.float32)
finfo(resolution=1e-06 # 在十进制上的分辨率,表示两个不同值之间的最小间隔。, min=-3.40282e+38 # 最小值, max=3.40282e+38 # 最大值, eps=1.19209e-07, smallest_normal=1.17549e-38, tiny=1.17549e-38 , dtype=float32
)
-
如下图所示,使用半精度进行计算会导致的两个问题的处理:舍入误差(
Rounding Error
)和溢出错误(Grad Overflow / Underflow
)。- 舍入误差:舍入误差指的是当梯度过小时,该次梯度更新可能会失败。
>>> num1 = 2**(-3) >>> num2 = 2**(-14)>>> num_fp16_1 = torch.tensor(num1).half() >>> num_fp16_2 = torch.tensor(num2).half()>>> print(num_fp16_1) 0.125 >>> print(num_fp16_2) 6.103515625e-05 >>> print(num_fp16_1 + num_fp16_2) tensor(0.1250, dtype=torch.float16)
-
溢出错误:
-
float16的有效的动态范围约为 ( 5.96 × 1 0 − 8 至 6.55 × 1 0 4 5.96×10^{-8}至6.55×10^4 5.96×10−8至6.55×104)
最大正值上面已经计算过: 0 _ 11110 _ 1111111111 = ( − 1 ) 0 × 2 30 − 15 × ( 1 + 1023 1024 ) = 65504 计算最小正值时,指数位全为 0 ,也就是所谓的非规格数,此时指数位置固定为 ( 1 − 偏置项 ) 另外小数位的隐藏也不再是 1 ,而是 0 ,因此最小正值为: 0 _ 00000 _ 0000000001 = ( − 1 ) 0 × 2 1 − 15 × ( 0 + 1 1024 ) = 2 − 24 = 5.96 × 1 0 − 8 最大正值上面已经计算过:\\ 0\_11110\_1111111111=(-1)^0×2^{30-15}×(1+\frac{1023}{1024})=65504\\ 计算最小正值时,指数位全为0,也就是所谓的非规格数,此时指数位置固定为(1-偏置项)\\ 另外小数位的隐藏也不再是1,而是0,因此最小正值为:\\ 0\_00000\_0000000001=(-1)^0×2^{1-15}×(0+\frac{1}{1024})=2^{-24}=5.96×10^{-8} 最大正值上面已经计算过:0_11110_1111111111=(−1)0×230−15×(1+10241023)=65504计算最小正值时,指数位全为0,也就是所谓的非规格数,此时指数位置固定为(1−偏置项)另外小数位的隐藏也不再是1,而是0,因此最小正值为:0_00000_0000000001=(−1)0×21−15×(0+10241)=2−24=5.96×10−8 -
比单精度的float32( 1.4 × 1 0 − 45 至 3.4 × 1 0 38 1.4×10^{-45}至3.4×10^{38} 1.4×10−45至3.4×1038)要狭窄很多,注意:这里不是从最小值到最大值, 而是说的正数的部分;
计算最小正值时,指数位全为 0 ,也就是所谓的非规格数,此时指数位置固定为 ( 1 − 偏置项 ) 另外小数位的隐藏也不再是 1 ,而是 0 ,因此最小正值为: ( − 1 ) 0 × 2 1 − 127 × ( 0 + 1 2 23 ) = 2 − 149 = 1.4 × 1 0 − 45 计算最小正值时,指数位全为0,也就是所谓的非规格数,此时指数位置固定为(1-偏置项)\\ 另外小数位的隐藏也不再是1,而是0,因此最小正值为:\\ (-1)^0×2^{1-127}×(0+\frac{1}{2^{23}})=2^{-149}=1.4×10^{-45} 计算最小正值时,指数位全为0,也就是所谓的非规格数,此时指数位置固定为(1−偏置项)另外小数位的隐藏也不再是1,而是0,因此最小正值为:(−1)0×21−127×(0+2231)=2−149=1.4×10−45 -
精度下降(小数点后16相比较小数点后8位要精确的多)会导致得到的值大于或者小于fp16的有效动态范围,也就是上溢出或者下溢出。
-
因此,使用
FP16
会损失掉梯度更新小于 2 − 24 2^{-24} 2−24的值,有研究表明大约占网络所有梯度更新的5%。因此,现在大型模型在训练时候,一般会使用混合精度训练(Mixed Precision
),而且更常用是BF16的数据类型。
-
-
BF16也叫做bfloat16(这是最常叫法),由Google Brain开发的。如下图所示,其指数位数与FP32相同,都是8位,因此表示的数据范围更广,但是精度比FP16要差。
-
BF16的指数域位数(8位)和float32一样多,能表示的大小范围类似,只是精度降低了(也就是相邻数之间的间隔略微变大,大多数情况下对神经网络的表现影响不显著),而float16的指数域位数只有5,可以表达的大数上限降低,接近0的小数下限升高,比BF16更容易发生上溢和下溢等数值问题,因此大模型的训练和推理更常用BF16。
B F 16 最小正值为: ( − 1 ) 0 × 2 1 − 127 × ( 0 + 1 2 7 ) = 2 − 133 = 9.2 × 1 0 − 41 B F 16 最大正值范围和 F P 32 类似,可以看到 B F 16 有效动态范围比 F P 16 大很多 BF16最小正值为:\\ (-1)^0×2^{1-127}×(0+\frac{1}{2^{7}})=2^{-133}=9.2×10^{-41}\\ BF16最大正值范围和FP32类似,可以看到BF16有效动态范围比FP16大很多 BF16最小正值为:(−1)0×21−127×(0+271)=2−133=9.2×10−41BF16最大正值范围和FP32类似,可以看到BF16有效动态范围比FP16大很多
>>> import torch
>>> torch.finfo(torch.bfloat16)
# 结果
finfo(resolution=0.01 # 在十进制上的分辨率,表示两个不同值之间的最小间隔。, min=-3.38953e+38 # 最小值, max=3.38953e+38 # 最大值, eps=0.0078125, smallest_normal=1.17549e-38, tiny=1.17549e-38, dtype=bfloat16
)
-
这里要注意一下,并不是所有的硬件都支持bfloat16,可以用下面代码验证是否支持bfloat16
import transformerstransformers.utils.import_utils.is_torch_bf16_gpu_available() # 结果为True就是支持
2.1.2 使用半精度微调ChatGLM3-6B模型
开启半精度训练很简单:
# 推荐做法如下
# 在模型加载时,指定torch_dtype为torch.half或者torch.float16
# 如果,你的机器支持torch.bfloat16,推荐使用
model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True, torch_dtype=torch.bfloat16, device_map="cuda:0")
我们使用transformers库进行低精度微调:
-
数据集:https://huggingface.co/datasets/shibing624/alpaca-zh
-
微调模型:https://modelscope.cn/models/ZhipuAI/chatglm3-6b
-
代码和之前PEFT微调代码相似,最主要的区别有两点:
- 我们这次使用半精度进行微调,加载的模型是ChatGLM3-6B
- 数据预处理逻辑和之前不同(ChatGLM模型新设计的prompt模板)
1、加载数据集
from datasets import Dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, DataCollatorForSeq2Seq, TrainingArguments, Trainer# 1、加载数据集
ds = Dataset.load_from_disk("./data/alpaca_data_zh/")
ds
输出:
Dataset({features: ['output', 'input', 'instruction'],num_rows: 26858
})
# 查看一些数据
ds[:3]
输出:
{'output': ['以下是保持健康的三个提示:\n\n1. 保持身体活动。每天做适当的身体运动,如散步、跑步或游泳,能促进心血管健康,增强肌肉力量,并有助于减少体重。\n\n2. 均衡饮食。每天食用新鲜的蔬菜、水果、全谷物和脂肪含量低的蛋白质食物,避免高糖、高脂肪和加工食品,以保持健康的饮食习惯。\n\n3. 睡眠充足。睡眠对人体健康至关重要,成年人每天应保证 7-8 小时的睡眠。良好的睡眠有助于减轻压力,促进身体恢复,并提高注意力和记忆力。','4/16等于1/4是因为我们可以约分分子分母都除以他们的最大公约数4,得到(4÷4)/ (16÷4)=1/4。分数的约分是用分子和分母除以相同的非零整数,来表示分数的一个相同的值,这因为分数实际上表示了分子除以分母,所以即使两个数同时除以同一个非零整数,分数的值也不会改变。所以4/16 和1/4是两种不同的书写形式,但它们的值相等。','朱利叶斯·凯撒,又称尤利乌斯·恺撒(Julius Caesar)是古罗马的政治家、军事家和作家。他于公元前44年3月15日被刺杀。 \n\n根据历史记载,当时罗马元老院里一些参议员联合起来策划了对恺撒的刺杀行动,因为他们担心恺撒的统治将给罗马共和制带来威胁。在公元前44年3月15日(又称“3月的艾达之日”),恺撒去参加元老院会议时,被一群参议员包围并被攻击致死。据记载,他身中23刀,其中一刀最终致命。'],'input': ['', '输入:4/16', ''],'instruction': ['保持健康的三个提示。', '解释为什么以下分数等同于1/4', '朱利叶斯·凯撒是如何死亡的?']}
# 2、加载tokenizer
model_path = r'/root/autodl-tmp/models/ZhipuAI/chatglm3-6b'tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
2、获取ChatGLM3模型需要的数据格式
ChatGLM3模型需要的数据格式呢?
- 我们看下
model.chat(tokenizer, "你好", history=[])
这行代码中的chat方法:
# modeling_chatglm.py@torch.inference_mode()def chat(self, tokenizer, query: str, history: List[Tuple[str, str]] = None, role: str = "user",max_length: int = 8192, num_beams=1, do_sample=True, top_p=0.8, temperature=0.8, logits_processor=None,**kwargs):if history is None:history = []if logits_processor is None:logits_processor = LogitsProcessorList()logits_processor.append(InvalidScoreLogitsProcessor())gen_kwargs = {"max_length": max_length, "num_beams": num_beams, "do_sample": do_sample, "top_p": top_p,"temperature": temperature, "logits_processor": logits_processor, **kwargs}# 1、调用tokenizer.build_chat_input方法对user的输入query进行处理inputs = tokenizer.build_chat_input(query, history=history, role=role)inputs = inputs.to(self.device)# 注意:这里是调用 tokenizer.get_command("<|user|>")获得token_ideos_token_id = [tokenizer.eos_token_id, tokenizer.get_command("<|user|>"),tokenizer.get_command("<|observation|>")]# 2、generate方法outputs = self.generate(**inputs, **gen_kwargs, eos_token_id=eos_token_id)# 3、解码outputs = outputs.tolist()[0][len(inputs["input_ids"][0]):-1]response = tokenizer.decode(outputs)history.append({"role": role, "content": query})# 后处理阶段response, history = self.process_response(response, history)return response, history
我们调用用build_chat_input方法:
>>> from transformers import AutoTokenizer, AutoModelForCausalLM>>> model_path = r'/root/autodl-tmp/models/ZhipuAI/chatglm3-6b'>>> tokenizer.build_chat_input("考试的技巧有哪些?", history=[], role="user")
{'input_ids': tensor([[64790, 64792, 64795, 30910, 13, 30910, 32227, 54530, 33741, 34953, 31514, 64796]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]), 'position_ids': tensor([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]])
}# 我们对input_ids进行解码
>>> tokenizer.decode([64790, 64792, 64795, 30910, 13, 30910, 32227, 54530, 33741, 34953,31514, 64796])
'[gMASK]sop<|user|> \n 考试的技巧有哪些?<|assistant|>'# 需要注意的是,由于chatglm3支持工具调用等功能,因此我们还需关注下后处理
# modeling_chatglm.py 后处理def process_response(self, output, history):content = ""history = deepcopy(history)for response in output.split("<|assistant|>"):# 注意:又利用"\n"进行了split# 前面为metadata,工具调用时使用,比如为:get_current_weather# 我们这里虽然没有metadata,但是需要手动拼接"\n",否则会把conent当作metadatametadata, content = response.split("\n", maxsplit=1)if not metadata.strip():# 没有metadata,只返回contentcontent = content.strip()history.append({"role": "assistant", "metadata": metadata, "content": content})content = content.replace("[[训练时间]]", "2023年")else:history.append({"role": "assistant", "metadata": metadata, "content": content})if history[0]["role"] == "system" and "tools" in history[0]:content = "\n".join(content.split("\n")[1:-1])def tool_call(**kwargs):return kwargsparameters = eval(content)content = {"name": metadata.strip(), "parameters": parameters}else:content = {"name": metadata.strip(), "content": content}return content, history
# 因此,我们得到最终的数据格式为:
# '[gMASK]sop<|user|> \n Prompt<|assistant|> \n Response eos_token'
-
根据以上步骤,我们得到了ChatGLM3最终的数据格式,不过这样有些麻烦
-
目前,一些框架支持多种模型的微调,那么不同的模型肯定数据预处理是不一样的,这些框架是如何处理的呢?
-
这里介绍一个比较火的训练框架
LLaMA-Factory
:https://github.com/hiyouga/LLaMA-Factory -
通过LLaMA-Factory的WebUI,小白也可以快速的训练出自己需要的模型(不仅支持SFT,还支持PPO、DPO等方法)。
-
LLaMA-Factory中有一个
template.py
,提供了大量模型的数据预处理,我们找到chatglm3的模板如下。
# LLaMA-Factory\src\llamafactory\data\template.py# 这里我们关注format_prefix + format_user + format_assistant # format_prefix=[gMASK]sop # format_user=<|user|>\n{{content}}<|assistant|> # format_assistant=\n{{content}}# 因此,我们可以得到模板为: # [gMASK]sop<|user|> \n Prompt<|assistant|> \n Response eos_token _register_template(name="chatglm3",format_user=StringFormatter(slots=[{"token": "<|user|>"}, "\n", "{{content}}", {"token": "<|assistant|>"}]),format_assistant=StringFormatter(slots=["\n", "{{content}}"]),format_system=StringFormatter(slots=[{"token": "<|system|>"}, "\n", "{{content}}"]),format_function=FunctionFormatter(slots=[], tool_format="glm4"),format_observation=StringFormatter(slots=[{"token": "<|observation|>"}, "\n", "{{content}}", {"token": "<|assistant|>"}]),format_tools=ToolFormatter(tool_format="glm4"),format_prefix=EmptyFormatter(slots=[{"token": "[gMASK]"}, {"token": "sop"}]),stop_words=["<|user|>", "<|observation|>"],efficient_eos=True, )# 假如我们现在需要微调qwen模型,我们可以找到qwen的模板 # 这里我们关注format_system + format_user # format_system=<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n # format_user=<|im_start|>user\n{{content}}<|im_end|>\n<|im_start|>assistant\n # 最终的模板为: """ <|im_start|>system You are a helpful assistant.<|im_end|> <|im_start|>user Prompt<|im_end|> <|im_start|>assistant Response eos_token """ _register_template(name="qwen",format_user=StringFormatter(slots=["<|im_start|>user\n{{content}}<|im_end|>\n<|im_start|>assistant\n"]),format_system=StringFormatter(slots=["<|im_start|>system\n{{content}}<|im_end|>\n"]),format_observation=StringFormatter(slots=["<|im_start|>tool\n{{content}}<|im_end|>\n<|im_start|>assistant\n"]),format_separator=EmptyFormatter(slots=["\n"]),default_system="You are a helpful assistant.",stop_words=["<|im_end|>"],replace_eos=True, )
-
3、数据预处理
def process_func(example):MAX_LENGTH = 384input_ids, attention_mask, labels = [], [], []instruction = "\n".join([example["instruction"], example["input"]]).strip() # queryinstruction = tokenizer.build_chat_input(instruction, history=[], role="user") # [gMASK]sop<|user|> \n query<|assistant|>response = tokenizer("\n" + example["output"], add_special_tokens=False) # \n response, 缺少eos tokeninput_ids = instruction["input_ids"][0].numpy().tolist() + response["input_ids"] + [tokenizer.eos_token_id]attention_mask = instruction["attention_mask"][0].numpy().tolist() + response["attention_mask"] + [1]# 这里instruction部分设置为-100,不计算Losslabels = [-100] * len(instruction["input_ids"][0].numpy().tolist()) + response["input_ids"] + [tokenizer.eos_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_ds = ds.map(process_func, remove_columns=ds.column_names)
tokenized_ds
输出:
4、加载模型进行训练
import torch# 1、单精度进行加载(此时加载模型需要约24GB显存,因此官方默认是半精度加载)
# model = AutoModelForCausalLM.from_pretrained(model_path
# , trust_remote_code=True
# , torch_dtype=torch.float32
# , device_map="cuda:0")# 2、半精度进行加载(此时加载模型需要13GB显存)
model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True, torch_dtype=torch.bfloat16, device_map="cuda:0")
# 利用Lora进行微调
from peft import LoraConfig, TaskType, get_peft_model, PeftModelconfig = LoraConfig(task_type=TaskType.CAUSAL_LM, target_modules=["query_key_value"])
model = get_peft_model(model, config)# 配置训练参数
args = TrainingArguments(output_dir="./chatbot-chatglm3",per_device_train_batch_size=2,gradient_accumulation_steps=16,logging_steps=10,num_train_epochs=1,learning_rate=1e-4,remove_unused_columns=False,save_strategy="epoch"
)trainer = Trainer(model=model,args=args,train_dataset=tokenized_ds.select(range(6000)),data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
)trainer.train()# 模型预测
model.eval()
print(model.chat(tokenizer, "数学考试怎么考高分?", history=[])[0])
# 训练过程中,显存消耗如下
(llm) root@autodl-container-c4c240bdcc-422ab4ba:~# nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.78 Driver Version: 550.78 CUDA Version: 12.4 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 NVIDIA GeForce RTX 3090 On | 00000000:0E:00.0 Off | N/A |
| 49% 58C P2 310W / 350W | 15518MiB / 24576MiB | 95% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------++-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
+-----------------------------------------------------------------------------------------+
2.2 8bit量化模型微调
2.2.1 为什么需要进行模型的量化
把Float类型 (FP32,FP16) 的模型参数和激活值,用整数 (Int8,Int4)来代替,同时尽可能减少量化后模型推理的误差。
模型量化带来的好处:
- 减少模型的存储空间和显存的占用
- 减少显存和TensorCore之间的数据传输量,从而加快模型推理时间。
- 显卡对整数运算速度快于浮点型数据,从而加快模型推理时间。
2.2.2 如何进行量化和反量化
下图就是非对称量化和反量化的过程:
但是上面的量化存在下面的问题:
-
不能处理离群值问题
假设hidden state中有一个向量A: A=[-0.10, -0.23, 0.08, -0.38, -0.28, -0.29, -2.11, 0.34, -0.53, -67.0]。 向量A有一个emergent feature -67.0。如果我们去掉emergent feature -67.0对向量A做量化和反量化, 处理后的结果是:[-0.10, -0.23, 0.08, -0.38, -0.28, -0.28, -2.11, 0.33, -0.53]。 出现的误差只有-0.29 -> -0.28。但是如果我们在保留emergent feature -67.0的情况下对该向量做量化和反量化, 处理后的结果是:[ -0.00, -0.00, 0.00, -0.53, -0.53, -0.53, -2.11, 0.53, -0.53, -67.00]。大部分信息在处理后都丢失了。
-
8位精度表示的动态范围有限,因此量化具有多个大值得向量会产生严重的误差
-
误差在累计过程中会导致模型最终性能的大幅度下降
对此,可以采用混合精度分解量化(LLM.int8()
):
-
将包含了Emergent Features的几个维度从矩阵中分离出来,对其做高精度的矩阵乘法(按列提取离群值 ,即大于某个阈值的值),其余部分进行量化;
-
对 FP16 离群值矩阵和INT8 非离群值矩阵分别作矩阵乘法。
-
反量化非离群值的矩阵乘结果与离群值矩阵乘结果相加,获得最终的 FP16 结果。
2.2.3 8bit量化模型微调
# 3、量化为int8
# 通过transformers库和bitsandbytes库,如下所示很容易进行8bit的量化
model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True, torch_dtype=torch.half, device_map="cuda:0", load_in_8bit=True )
- 此时显存消耗情况如下,可以看到显存降低了不少
# 训练过程中,显存消耗如下,可以看到显存降低了不少
(llm) root@autodl-container-c4c240bdcc-422ab4ba:~# nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.78 Driver Version: 550.78 CUDA Version: 12.4 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 NVIDIA GeForce RTX 3090 On | 00000000:0E:00.0 Off | N/A |
| 38% 53C P2 271W / 350W | 11308MiB / 24576MiB | 71% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------++-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
+-----------------------------------------------------------------------------------------+
2.3 4bit量化模型微调(QLora)
QLoRA的解决方案主要包括三个部分:
(1)NF4 Quantization(4-bit量化):经NF4量化的权重信息损失较小,从而保证模型整体精度的最小损失。
(2)Double Quantization(双重量化):对初次完成量化的常量进行二次量化,进一步缩减模型存储体积。
(3)Paged Optimizers(分页优化器):利用NVIDIA的统一内存管理功能,该技术可以在CPU和GPU之间自动进行页对页的传输,使得即便在GPU偶发地内存溢出(OOM)时仍能够继续进行训练。
2.3.1 NF4量化
-
int8量化是一种常见的线性量化过程,其计算公式是线性的: q = ( w / s c a l e ) + z e r o _ p o i n t q = (w / scale) + zero\_point q=(w/scale)+zero_point。
-
但这存在一个问题:若数据分布不均匀,量化后的值有可能“粘连”堆叠在一起
。 -
例如, [ 0.001 , 0.0015 , 0.0016 , 0.002 , 55.0 ] [0.001, 0.0015, 0.0016, 0.002, 55.0] [0.001,0.0015,0.0016,0.002,55.0]在经量化处理后,变为: [ − 128 , − 128 , − 128 , − 128 , 127 ] [-128,-128,-128,-128,127] [−128,−128,−128,−128,127]。
-
这四个原本不同的权重经量化后全数转化为相同的数值,导致模型出现较大误差。
-
-
一般的模型参数通常呈正态分布,而非均匀分布。
- 若依照线性方式进行量化,极可能导致多个不同的值被量化到相同的数值上。
- 如参数符合标准正态分布,(0,1)区间内的值差异性将远大于(10,11),造成相同值概率的不均衡。
-
nf4量化则采取一种非对称量化方式,它基于分位数来执行量化映射。
在标准正态分布里,由于靠近中心0点的取值较多,非对称量化能为这些取值提供更多的“格子”,以维持数据的精细度
。- 以4bit为例,表示范围为16个值,将权重从小到大排序,找到十五个分位数,将其切分为十六块,权重数值落在第几块,量化的表示就是多少,范围[0-15];
- 此外,由于涉及到反量化,还需要给这16个值一个浮点数的映射,这个映射可以取分位区间两侧分位点的均值,用于反量化,这部分称之为量化值;
- 具体操作时,我们只需要把待量化的数跟量化值进行比较,取最相近的值即可作为该数值的量化值,对应的表示可以通过分位数进行确定,存储时同时存储4bit的表示与对应量化值,反量化后进行计算
-
大多数权重整体呈现正态分布,那么可以将其统一缩放至[-1,1],根据标准正态分布得到16个量化值,并将量化值也缩放至[-1,1],此时,便可利用前面提到的方法,将权重进行量化
-
为了减少异常值的问题,采用分块量化,块大小为64,即64个值为一组进行量化
2.3.2 双重量化
-
在QLoRA框架中,采用64个参数构成一个block进行量化,即block_size=64,每个块计算出一个对称量化中用到的Scale值。
-
如果以32位浮点数存储Scale,那么每个block将会额外存储一个32位数字,这意味着每个参数实际上需要额外的 32 / 64 = 0.5 b i t 32/64=0.5bit 32/64=0.5bit存储空间。因此,每个参数实际占用的存储空间变成了 4 + 0.5 = 4.5 b i t s 4+0.5=4.5bits 4+0.5=4.5bits。
为了优化这一存储需求,研究人员提出了Double Quant策略,即对Scale本身再进行一次量化;不过这里使用的是线性量化方法,量化后的格式为FP8,其中block_size=256。
-
Double Quant 后,每个参数做量化只需要额外的 8/64 + 32 / (64*256) = 0.127 bits 显存。
-
Double Quant策略通过对量化系数Scale再次进行量化,有效地降低了每个参数所需的额外存储开销。在这一策略的应用之下,每个参数的总量化开销降至大约0.127 bits的额外显存,极大程度上节约了资源。
2.3.3 4bit量化模型微调
# 4、量化为int4
model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True, torch_dtype=torch.half, device_map="cuda:0", load_in_4bit=True , bnb_4bit_compute_dtype=torch.half , bnb_4bit_quant_type="nf4" , bnb_4bit_use_double_quant=True
)
"""
参数解释:
load_in_4bit:替换Linear层为FP4/NF4层,启用4位量化
bnb_4bit_compute_dtype:设置计算类型,它可能与输入时的类型不同。例如,输入可能是fp32,但计算可以设置为bf16以获得速度提升。
bnb_4bit_use_double_quant:是否开启double_quant(双重量化)
bnb_4bit_quant_type:有两个参数可以选fp4和nf4,默认是fp4
"""
- 训练过程中,显存消耗如下,现在显存降到了一块2080Ti就能对其进行微调了
# 训练过程中,显存消耗如下,现在显存降到了一块2080Ti就能对其进行微调了
(llm) root@autodl-container-c4c240bdcc-422ab4ba:~# nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.78 Driver Version: 550.78 CUDA Version: 12.4 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 NVIDIA GeForce RTX 3090 On | 00000000:0E:00.0 Off | N/A |
| 48% 58C P2 305W / 350W | 7260MiB / 24576MiB | 100% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------++-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
+-----------------------------------------------------------------------------------------+