LoRA指令微调(百川模型)
指令微调 (Instruction Tuning) 是指使用自然语言形式的数据对预训练后的大语言模型进行参数微调,这一术语由谷歌研究员在2022年的一篇ICLR论文中正式提出。在另外一些参考文献中,指令微调也被称为有监督微调 (Supervised Fine-Tuning) 或多任务提示训练 (Multi-task Prompted Training)。
指令微调过程首先收集或构建指令化的数据,然后通过有监督的方式对大语言模型的参数进行微调。
一、指令微调作用
指令微调方法主要分为全量精调和低资源精调两大类别。其中,低资源精调对模型局部进行微调或者冻结一部分参数进行微调,LoRA方法效果最佳。指令微调能够带来以下好处:
(1)有助于增强模型整体能力。指令微调旨在使用人工构建的指令数据对于大语言模型进一步训练,从而增强或解锁大语言模型的能力。相关研究表明,不同规模的语言模型 (参数量从77M到540B) 都可以从指令微调中受益,提升的性能随着参数规模的增加而提升。此外,经过指令微调的小模型甚至可以比没有经过微调的大模型表现得更出色,进一步凸显了指令微调的有效性。除了参数规模外,指令微调在不同的模型架构上都能取得相对稳定的增益,这说明指令微调是一种非常通用的模型能力增强方法。
(2)有助于增强指令遵循能力。指令微调旨在指导模型学会理解自然语言指令,并据此完成相应的任务。通过指令微调,大模型能够获得较好的指令遵循与任务求解能力,无需下游任务的训练样本或者示例就可以解决训练中未见过的任务。指令微调还可以缓解预训练阶段大模型会出现的一些常见问题,例如生成重复内容或者仅仅补全输入而不解决相关任务。此外,使用英文指令微调数据训练的大模型还可以将相应的能力泛化到其他语言的相关任务上。
(3)有助于领域专业化适配。通用的大语言模型能够在传统自然语言处理任务 (如生成和推理) 以及日常生活任务 (如头脑风暴) 上取得较好的效果,然而它们在特定领域中 (如医学、法学、金融等) 的表现与领域专用模型的效果仍有一定差距。在实际应用中,可以针对大语言模型进行面向特定领域的指令微调,从而使之能够适配下游的任务。以医学领域为例,研究人员提出使用医学数据集对FLAN-PaLM进行微调,得到了医学知识助手模型Med-PaLM,其性能水平可与专业临床医生相媲美;国内研究学者也开源了基于LLaMA指令微调后的医学模型,例如“本草”。在电子商务领域,研究人员也针对大模型进行微调,从而使之适配于推荐系统中的多种任务,取得了出色的效果提升。与此同时,研究人员还在法律、金融等领域探索了指令微调大模型的适配性。这些工作表明,指令微调为大模型提供了一种通用的领域适配方法,拓宽了它们在实际场景中的应用范围。
二、LoRA原理
大语言模型中包含大量的线性变换层,其中参数矩阵的维度通常很高。研究人员发现模型在针对特定任务进行适配时,参数矩阵往往是过参数化 (Over-parametrized) 的,其存在一个较低的内在秩。为了解决这一问题,LoRA提出在预训练模型的参数矩阵上添加低秩分解矩阵来近似每层的参数更新,从而减少适配下游任务所需要训练的参数。
给定一个参数矩阵𝑊,其更新过程可以一般性地表达为以下形式,𝑊0是原始参数矩阵, ∆𝑊是更新的梯度矩阵。
LoRA的基本思想是冻结原始矩阵 𝑊0,维度 (H,H),通过低秩分解矩阵𝐴,维度 (H,R) 、矩阵𝐵,维度 (H,R) 来近似参数更新矩阵 ∆𝑊=𝐴𝐵^𝑇 ,其中R远远小于H,是减小后的秩。在微调期间,原始的矩阵参数𝑊0不会被更新,低秩分解矩阵 𝐴 和 𝐵 则是可训练参数用于适配下游任务。在前向传播过程中,原始计算中间状态 ℎ 的计算修改如下:
在训练完成后,进一步将原始参数矩阵 𝑊0 和训练得到的权重 𝐴 和 𝐵 进行合并,得到更新后的参数矩阵。因此,LoRA微调得到的模型在解码过程中不会增加额外的开销。
尽管LoRA能够有效地节省显存,但对于参数规模达到上百亿级别的模型而言,其微调所需的成本仍然相当高昂。QLoRA将原始的参数矩阵量化为4比特,而低秩参数部分仍使用16比特进行训练,在保存微调效果的同时进一步节省了显存开销。对于给定参数量为P的模型,QLoRA微调所需要的显存由LoRA微调所需要的2P,进一步下降为0.5P。通过QLoRA技术,可以在一张A6000 (48GB) 的GPU上微调65B的模型,接近16比特模型微调的性能。
三、环境配置
本篇博客参考的项目源码:https://github.com/wp931120/baichuan_sft_lora
首先翻墙,在外网下载baichuan-7B模型,指令微调数据,分别作为baichuan-7B,dataset文件夹:
https://huggingface.co/baichuan-inc/baichuan-7B
https://huggingface.co/datasets/BelleGroup/train_0.5M_CN
配置环境:
pip install -r requirements.txt
pip install datasets
pip install tensorboard -i https://pypi.tuna.tsinghua.edu.cn/simple
运行:python sft_lora.py出现报错:
CUDA SETUP: CUDA detection failed! Possible reasons:
1 UDA driver not installed
2. CUDA not installed
3. You have multiple conflicting CUDA libraries
4. Required library not pre-compiled for this bitsandbytes release!
最后发现问题出在requirements.txt中"bitsandbytes==0.39.0"版本导致
后面直接改成:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple bitsandbytes==0.40.0,此时训练需要98h。
四、指令微调数据集
采用公开的指令微调数据集"Belle_open_source_0.5M",对baichuan-7B进行指令微调。
“Belle_open_source_0.5M"包含519255条指令样本,每个样本实际上就是一个自然语言形式的问答语句,包括"instruction”,“input”,"output"三部分。
(1)instruction(指令):是对模型的具体指令或任务描述,告诉模型需要完成什么样的任务,例如:用黎明、天空和广阔这三个词组成一个句子解释一下相对论的基本原理。
(2)input(输入,可选):一些任务可能需要额外的输入信息,该部分为模型提供除指令之外的相关输入内容,比如在:计算这些物品的总费用中,输入部分可以是:汽车3000,衣服100,书20,这样的具体物品价格信息。但不是所有的指令都需要输入部分,如果指令本身已经足够明确,不需要额外信息,那么这部分可以为空。
(3)output(输出):这是模型根据指令和输入所生成的回答或结果。例如对于上述两个例子,对应的输出分别是:随着黎明的到来,天空逐渐明亮,一片广阔的视野展现在眼前;汽车、衣服和书的总费用为3000+100+20=3120。
举个例子,其中某个指令微调样本示例如下:
{“instruction”: “写一个制作披萨的步骤指南。\n”, “input”: “”, “output”: “1. 准备面团:将面粉、盐、酵母和水混合搅拌,揉成面团,放在温暖通风处发酵。\n2. 切配佐料:准备用于披萨的酱料、奶酪和各种佐料,如蔬菜、香肠、火腿等。\n3. 擀面皮:将发酵好的面团擀成薄饼状,越薄越好。\n4. 加入酱料:把酱料均匀地涂在面皮上。\n5. 撒上奶酪和佐料:撒上足够的奶酪和各种佐料。\n6. 烘烤:将披萨放进预热好的烤箱,烘烤10-15分钟,或者直到表面变为金黄色。\n7. 切片享用:取出披萨,切成适当的大小,稍微冷却一下,即可食用。”}
五、百川模型
Baichuan-7B是由百川智能开发的一个开源的大规模预训练模型。基于Transformer架构,在大于1.2万亿tokens上训练的70亿参数模型,支持中英双语,上下文窗口长度为4096。在标准的中文和英文权威benchmark(C-EVAL/MMLU)上均取得同尺寸最好的效果。
为什么选择Baichuan呢?
(1)在同尺寸模型中,Baichuan-7B达到了目前SOTA的水平,可参考MMLU指标;
(2)Baichuan-7B使用自有的中英文双语语料进行训练,在中文上进行优化,达到SOTA水平;
(3)不同于LLaMA完全禁止商业使用,Baichuan-7B使用更宽松的开源协议,允许用于商业目的;
Baichuan基于标准的Transformer结构,采用了和LLaMA一样的模型设计:
(1)Position Embedding:采用Rotary Embedding,是被大多数模型采用的位置编码方案,具有很好的外推性;
(2)Feedforward Layer:采用SwiGLU,Feedforward变化为(8/3)倍的隐含层大小,即11008;
(3)Layer Normalization:基于RMSNorm的Pre-Normalization;
Baichuan同时开源出了和本模型配套的训练代码,允许进行高效的Finetune用于下游任务,具体参见Baichuan-7B。
六、源码阅读
第一步:加载分词器,加载模型(含预训练权重),预处理量化模型以进行训练。
tokenizer = AutoTokenizer.from_pretrained("./baichuan-7B", trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained("./baichuan-7B",
trust_remote_code=True,
quantization_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type='nf4'
),
device_map=device_map)
model = prepare_model_for_kbit_training(model)
第二步:将模型所有的线性层都装配上LoRA,除了"lm_head"层。
因为LM-Head层(即语言模型头)是大型语言模型中的关键部分,它负责生成最终的输出。由于LM-Head层的梯度尺度分布与其他层存在显著差异,且其稳定性对模型训练至关重要,因此对其进行微调时需要特别谨慎。在某些情况下,对LM-Head层进行微调可能会导致模型性能的下降或训练不稳定。因此,为了避免这种风险,通常选择不在LM-Head层上装配LoRA。
modules = find_all_linear_names(model)
# print(modules): ['down_proj', 'up_proj', 'o_proj', 'gate_proj', 'W_pack']
config = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05, bias="none",
target_modules=modules, task_type="CAUSAL_LM",)
model = get_peft_model(model, config)
第三步:对指令数据的每个样本,进行一些格式化的整合合并。
首先,将"Belle_open_source_0.5M"数据集划分为训练集和验证集,从训练集中随机取出一个样本。
data_point = {'instruction': '编辑这个句子并将“房子”改为“公寓”。\n这是我新买的房子,我非常喜欢。\n', 'input': '', 'output': '这是我新买的公寓,我非常喜欢。'}
然后,取出"instruction"部分和"input"部分,在其前面加入"Human: “,在其后面加入”\n\nAssistant: ",然后拼接在一起,并在其前面加上起始token(bos_token)。
instruction = data_point['instruction']
input_text = data_point["input"]
# print(instruction): '编辑这个句子并将“房子”改为“公寓”。\n这是我新买的房子,我非常喜欢。\n'
# print(input_text): ''
input_text = "Human: " + instruction + input_text + "\n\nAssistant: "
# print(input_text): Human: 编辑这个句子并将“房子”改为“公寓”。\n这是我新买的房子,我非常喜欢。\n\n\nAssistant:
# bos表示,beginning of sentence token,eos表示,end of sentence token.
# print(tokenizer.bos_token): <s>
input_text = tokenizer.bos_token + input_text if tokenizer.bos_token != None else input_text
# print(input_text): <s>Human: 编辑这个句子并将“房子”改为“公寓”。\n这是我新买的房子,我非常喜欢。\n\n\nAssistant:
最后,取出"output"部分,在其后面加上终止token(eos_token),两者拼接形成最终指令。
# print(tokenizer.eos_token): </s>
target_text = data_point["output"] + tokenizer.eos_token
# print(target_text): 这是我新买的公寓,我非常喜欢。</s>
full_prompt = input_text + target_text
# print(full_prompt): <s>Human: 编辑这个句子并将“房子”改为“公寓”。\n这是我新买的房子,我非常喜欢。\n\n\nAssistant:这是我新买的公寓,我非常喜欢。</s>
第四步:借助训练好的分词器tokenizer,将full_prompt进行分词化(序列过长时会进行截断)。
分词化后会输出三部分内容,包括"inputs_ids",“attention_mask”,“labels”。
“input_ids”:是输入文本的token ids,即词汇表中的索引。它们是模型输入的实际内容。模型通常需要将文本转换为数字形式来处理,input_ids就是将每个词(或子词)映射到一个整数,这个整数表示该词在预训练模型的词汇表中的位置。
“attention_mask”:是一个与input_ids等长的向量,指示模型在哪些位置需要关注(即哪些位置是有效的)以及哪些位置应忽略(即哪些位置是填充的)。在许多NLP模型中,文本可能需要进行填充(padding),特别是在处理不同长度的文本时。attention_mask用于指示模型应该计算注意力的哪些位置,而哪些位置是填充,应该被忽略。一般来说,1表示该位置是有效的,模型应该关注这个位置;0表示该位置是填充,模型应该忽略这个位置。
“labels”:模型输出的目标数据,通常用于监督学习任务中的目标标签。对于语言模型任务,labels可能和input_ids一样,但也可能有所不同(例如,在某些位置使用特殊标记如-100来掩盖标签)。例如,这里labels和input_ids完全一样,在后面损失函数的计算过程中,会通过右移一位来实现自回归的有监督训练。
tokenized_full_prompt = tokenize(full_prompt)
# print(tokenized_full_prompt)
# {'input_ids': [1, 5132, 31143, 23192, 1737, 22667, 25334, 31162, 8955, 31164, 29710, 31162, 18538, 31164, 73, 5, 3908, 31182, 31226, 21068, 8955, 72, 31182, 2583, 2676, 73, 5, 5, 5, 7905, 18056, 31143, 31106, 3908, 31182, 31226, 21068, 18538, 72, 31182, 2583, 2676, 73, 2],
# 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
# 'labels': [1, 5132, 31143, 23192, 1737, 22667, 25334, 31162, 8955, 31164, 29710, 31162, 18538, 31164, 73, 5, 3908, 31182, 31226, 21068, 8955, 72, 31182, 2583, 2676, 73, 5, 5, 5, 7905, 18056, 31143, 31106, 3908, 31182, 31226, 21068, 18538, 72, 31182, 2583, 2676, 73, 2]}
第五步:将序列长度填充至8的倍数,即处理"inputs_ids",“attention_mask”,“labels”。
在进行长度填充时,填充的id为0(即表示"unk"),填充的attention_mask为0(0表示不进行自注意力操作),填充的labels为-100(-100表示在损失函数中忽略计算)。
data_collator=transformers.DataCollatorForSeq2Seq(tokenizer,
pad_to_multiple_of=8, # 序列长度填充至8的倍数,填充部分attention_mask=0
return_tensors="pt",
padding=True)
# print(input_ids) # 序列长度填充至8的倍数, 填充的id为0, 即表示 '<unk>'
# tensor([[1, 5132, 31143, 23192, 1737, 22667, 25334, 31162, 8955, 31164, 29710, 31162, 18538, 31164, 73, 5, 3908, 31182, 31226, 21068, 8955, 72, 31182, 2583, 2676, 73, 5, 5, 5, 7905, 18056, 31143, 31106, 3908, 31182, 31226, 21068, 18538, 72, 31182, 2583, 2676, 73, 2, 0, 0, 0, 0]], device='cuda:0')
# print(attention_mask) # 填充部分attention_mask=0
# tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0]], device='cuda:0')
# labels用于计算掩码语言建模损失的标签
# print(labels) # 填充部分attention_mask=-100, -100的位置在计算损失函数的时候会被忽略
# tensor([[1, 5132, 31143, 23192, 1737, 22667, 25334, 31162, 8955, 31164, 29710, 31162, 18538, 31164, 73, 5, 3908, 31182, 31226, 21068, 8955, 72, 31182, 2583, 2676, 73, 5, 5, 5, 7905, 18056, 31143, 31106, 3908, 31182, 31226, 21068, 18538, 72, 31182, 2583, 2676, 73, 2, -100, -100, -100, -100]], device='cuda:0')
第六步:取出"input_ids",维度为(1,48),“attention_mask”,维度为(1,48),基于Transformer架构进行前向运算。
首先通过Embedding层将"input_ids"映射成(1,48,4096)维度的向量,然后加上旋转位置编码,经过一系列的Transformer Decoder,得到hidden_states,维度(1,48,4096),最终经过线性层lm_head,得到输出logits,维度(1,48,64000),这里64000表示词表大小。
值得注意的是,在自注意力运算的过程中,"_prepare_decoder_attention_mask"函数首先生成mask1矩阵,维度(1,1,48,48),其中对角线及下三角区域数值均为0,其余位置数值均为负无穷。然后生成mask2矩阵,维度(1,1,48,48),其中在attention_mask=0(表示序列padding)位置的值为负无穷, 其余位置的值为0。最后将mask1和mask2合并,负无穷值的位置表示无效token,在自注意力运算过程中不与其他位置token产生关联。
第七步:进行损失函数计算。
取出"labels",维度(1,48),与模型前向过程输出的logits,维度(1,48,64000),进行损失函数计算,梯度回传,更新模型参数。
需要注意:在CrossEntropyLoss()函数中,ignore_index参数值默认为-100,即不参与损失计算。
# 靠错位相移, 取logit的前n-1个token, 与label的后n-1个token, 即只预测序列下一个位置的token
# Shift so that tokens < n predict n
shift_logits = logits[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()
# print(shift_logits.shape): torch.Size([1, 47, 64000])
# print(shift_labels.shape): torch.Size([1, 47])
# Flatten the tokens
loss_fct = CrossEntropyLoss()
shift_logits = shift_logits.view(-1, self.config.vocab_size)
shift_labels = shift_labels.view(-1)
# print(shift_logits.shape): torch.Size([47, 64000])
# print(shift_labels.shape): torch.Size([47])
# Enable model parallelism
shift_labels = shift_labels.to(shift_logits.device)
loss = loss_fct(shift_logits, shift_labels)
# print(loss): tensor(3.2836, device='cuda:0', grad_fn=<NllLossBackward0>)
总结
所谓指令微调,无非是采用指令问答数据集,通过一些格式化的整合合并(加入Human-Assistant引导词,eos,bos等),在Transformer架构上进行自回归的有监督训练,利用当前位置及之前的token,预测序列下一个token。整体网络架构非常标准,并无太多晦涩之处。
唯一比较繁琐的是,理解对指令文本(字符串形式)的一系列操作,如何转化成数值向量输入模型,包括:分词,过长截断,padding填充,以及attention mask对指定位置的忽略,损失函数计算等。