LORA:LORA: LOW-RANK ADAPTATION OF LARGE LANGUAGE MODELS
原理:INTRINSIC DIMENSIONALITY EXPLAINS THE EFFECTIVENESS OF LANGUAGE MODEL FINE-TUNING
前人的肩膀:Adapter: Parameter-Efficient Transfer Learning for NLP
我们之前在解密Prompt系列3. 冻结LM微调Prompt介绍过一些soft-prompt,包括P-Tunning和Prompt-Tunning也属于低参数微调。这些方案是通过参数拼接的方案引入额外参数。这里介绍另一类方案,同样是冻结LLM的参数,通过参数相加的方案引入额外参数, 相较soft-prompt最明显的优势,就是不会占用输入token的长度。
LoRA的原理比较简单,原始全量微调其实就是在原始模型参数上通过微调加入增量W=W0+ΔW,那我们可以通过冻结原始参数W00,并且把增量部分通过低秩分解方式进一步降低参数量级ΔW=A?BTΔ,原始参数的维度是d?d, 则低秩分解后的参数量级是2?r?d2?,因为这里的r<<d,因此可以起到大幅降低微调参数量级的效果,如下图
核心代码如下
## 初始化低秩矩阵A和B self.lora_A.update(nn.ModuleDict({adapter_name: nn.Linear(self.in_features, r, bias=False)})) self.lora_B.update(nn.ModuleDict({adapter_name: nn.Linear(r, self.out_features, bias=False)})) self.scaling[adapter_name] = lora_alpha / r ? ## 向前计算 result = F.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias) result += ( ? ?self.lora_B[self.active_adapter]( ? ? ? ?self.lora_A[self.active_adapter](self.lora_dropout[self.active_adapter](x)) ? ) ? ?* self.scaling[self.active_adapter] )
论文测试了在多数场景下适当的LORA微调和全量微调的效果不相上下。一个可能原因是INTRINSIC DIMENSIONALITY论文中提出,虽然语言模型整体参数空间很大,但具体到每个任务其实有各自的隐表征空间(intrisic dimension),这个隐表征空间的维度并不高, 因此在微调过程中加入低秩分解并不一定会影响微调效果。使用LORA微调有以下几个细节
对哪些参数进行微调:基于Transformer结构,LORA只对每层的Self-Attention的部分进行微调,有Wq,Wk,Wv,WO四个映射层参数可以进行微调。消融实验显示只微调Wq效果略差,微调Wq,Wv的效果和微调Wq,Wk,Wv,WO的效果相似。需要注意不同模型参数名称不同,像chatglm对应的参数名称就是query_key_value
Rank的选取:Rank的取值作者对比了1-64,效果上Rank在4-8之间最好,再高并没有效果提升。不过论文的实验是面向下游单一监督任务的,因此在指令微调上根据指令分布的广度,Rank选择还是需要在8以上的取值进行测试。
alpha参数:alpha其实是个缩放参数,本质和learning rate相同,所以为了简化我默认让alpha=rank,只调整lr,这样可以简化超参
初始化:A和Linear层的权重相同Uniform初始化,B是zero初始化,这样最初的Lora权重为0。所以Lora参数是从头学起,并没有那么容易收敛。
Lora的优点很明显,低参数,适合小样本场景;可以拔插式的使用,快速针对不同下游任务训练不同的lora权重;完全没有推理延时,这个在后面代码中会提到推理时,可以预先把lora权重merge到原始权重上。
但Lora微调虽好,个人在尝试中感受到的局限性就是adapter类的微调方案可能更适合下游单一任务类型/生成风格。至于是否适合作为通用指令微调的解决方案,有个问题我也没有搞懂,就是通用的指令样本是否真的有统一的低秩空间表征?这个表征又是什么含义?因为指令微调阶段的样本其实是混合的多任务指令样本,这种情况下lora是否合适,感觉需要更全面的评估(当前出来的众多LLama们都缺少合理统一全面可比的Evaluation),当前就我们的尝试情况lora的效果并不及预期。
我用了featurize和揽睿星舟。云服务厂商的选择主要看是否有jupyter,存储够大,下载快,能连git,有高配torch环境。这两家在众多小厂里脱颖而出,4090的卡一个小时也就3块钱,来来来盆友辛苦把推广费结一下~
强调下环境配置,想跑通微调,搞定环境你就成功了80%!运气好1分钟,运气差1天都在原地打转
实例环境:TRX4090 + py38 + torch2.0 + CUDA12
python环境:主要坑在transforemrs和peft,几个相关issue包括:llama tokenizer special token有问题,peft adapter.bin微调不更新,Bug with fan_in_fan_out。我一个不差都踩中了。。。
# 以下配置可能会随时间变化,出了问题就去issue里面刨吧 # 要相信你不是唯一一个大冤种! accelerate appdirs loralib bitsandbytes black black[jupyter] datasets fire transformers>=4.28.0 git+https://github.com/huggingface/peft.git sentencepiece gradio wandb cpm-kernel
以下代码主要整合自alpaca-lora和chatglm-finetune。其实lora微调的代码本身并不复杂,相反是如何加速大模型训练,降低显存占用的一些技巧大家可能不太熟悉。模型初始化代码如下,get_peft_model会初始化PeftModel把原模型作为base模型,并在各个self-attention层加入lora层,同时改写模型forward的计算方式。
主要说下load_in_8bit和prepare_model_for_int8_training,这里涉及到2个时间换空间的大模型显存压缩技巧。
from peft import get_peft_model, LoraConfig, prepare_model_for_int8_training, set_peft_model_state_dict from transformers import AutoTokenizer, AutoModel ? model = AutoModel.from_pretrained("THUDM/chatglm-6b", load_in_8bit=True, torch_dtype=torch.float16, trust_remote_code=True, device_map="auto") tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm-6b", trust_remote_code=True) model = prepare_model_for_int8_training(model) ? lora_config = LoraConfig( ? ?task_type=TaskType.CAUSAL_LM, ? ?inference_mode=False, ? ?r=8, ? ?lora_alpha=8, ? ?lora_dropout=0.05, ) model = get_peft_model(model, lora_config) model.config.use_cache = False
模型显存占用分成两个部分,一部分是静态显存基本由模型参数量级决定,另一部分是动态显存在向前传播的过程中每个样本的每个神经元都会计算激活值并存储,用于向后传播时的梯度计算,这部分和batchsize以及参数量级相关。以下8bit量化优化的是静态显存,而梯度检查优化的是动态显存。
from_pretrained中的load_in_8bit参数是bitsandbytes库赋予的能力,会把加载模型转化成混合8bit的量化模型,注意这里的8bit模型量化只用于模型推理,通过量化optimizer state降低训练时显存的时8bit优化器是另一个功能不要搞混哟~
模型量化本质是对浮点参数进行压缩的同时,降低压缩带来的误差。 8-bit quantization是把原始FP32(4字节)压缩到Int8(1字节)也就是1/4的显存占用。如上加载后会发现除lora层外的多数层被转化成int类型如下
当然压缩方式肯定不是直接四舍五入,那样会带来巨大的精度压缩损失。常见的量化方案有absolute-maximum和zero-point,它们的差异只是rescale的方式不同,这里简单说下absmax,如下
先寻找tensor矩阵的绝对值的最大值,并计算最大值到127的缩放因子,然后使用该缩放因子对整个tensor进行缩放后,再round到整数。这样就把浮点数映射到了INT8,逆向回到float的原理相同。
当然以上的缩放方案依旧存在精度损失,以及当矩阵中存在outlier时,这个精度损失会被放大,例如当tensor中绝大部分取值在1以下,有几个值在100+,则缩放后,所有1以下的tensor信息都会被round抹去。因此LLM.int8()的实现对outlier做了进一步的优化,把outlier和非outlier的矩阵分开计算,再把结果进行合并来降低outlier对精度的影响。
prepare_model_for_int8_training是对在Lora微调中使用LLM.int8()进行了适配用来提高训练的稳定性,主要包括
layer norm层保留FP32精度
输出层保留FP32精度保证解码时随机sample的差异性
prepare_model_for_int8_training函数还做了一件事就是设置gradient_checkpointing=True,这是另一个时间换空间的技巧。
gradient checkpoint的实现是在向前传播的过程中使用torch.no_grad()不去存储中间激活值,降低动态显存的占用。而只是保存输入和激活函数,当进行反向传播的时候,会重新获取输入和激活函数计算激活值用于梯度计算。因此向前传播会计算两遍,所以需要更多的训练时间。
use_cache设置为False,是因为和gradient checkpoint存在冲突。因为use_cache是对解码速度的优化,在解码器解码时,存储每一步输出的hidden-state用于下一步的输入,而因为开启了gradient checkpoint,中间激活值不会存储,因此use_cahe=False。其实#21737已经加入了参数检查,这里设置只是为了不输出warning。