文章目录
- 一. 常见微调分类
- 1.1 全量微调(FFT:Full Fine-tuning)
- 1.2 参数高效微调(PEFT:Parameter-Efficient Fine-Tuning)
- 1.3 指令微调(IFT:Instructional Fine-tuning)
- 1.3.1 Hard prompt
- 1.3.2 Soft prompt
- 二. 常见微调方法
- 2.1 Prefix-tuning
- 2.2 Prompt-tuning
- 2.3 P-tuning v1
- 2.4 P-tunning v2
- 2.4 Lora
一. 常见微调分类
1.1 全量微调(FFT:Full Fine-tuning)
全量微调是对预训练模型的所有参数进行微调,即预训练模型的所有层和参数均被会更新和优化,从而适应目标任务的需求。需要注意,与预训练一样,全量微调需要足够的内存和计算来存储和处理训练过程中的所有梯度、优化器和其它需要更新的部分。全量微调一般可以获得更好的模型性能。
这种微调方法通常适用于任务和预训练模型之间存在较大差异的情况,或者任务需要模型具有高度灵活性和自适应能力的情况。
1.2 参数高效微调(PEFT:Parameter-Efficient Fine-Tuning)
为了降低微调时的资源成本,及提升微调效率。研究人员提出了PEFT方法。旨在通过最小化微调参数的数量和计算复杂度,来提高预训练模型在新任务上的性能。常见PEFT方法可分为以下三类:
-
Freeze method : 冻结模型的一些layers,只更新非冻结layers的模型参数,从而降低微调参数的数量与计算复杂度。
-
Additive method : 向预训练模型添加新的层或在某些层上拼接。然后微调时只更拼接网络结构的参数。
-
Reparametrization-based method : 重新参数化方法,些方法的思想是使用低秩表征来最小化可训练参数的数量。
注: PEFT的Freeze method 可理解为与全量微调对应的部分微调,但Additive method与Reparametrization-based method是否可归为部分微调,这个主要取决于考虑的模型主体是PLM还是微调后的整体模型,以PLM为基准,这两个方法就不算是部分微调。为了不把文章写的复杂,这里没有列出部分微调这个分类。
1.3 指令微调(IFT:Instructional Fine-tuning)
FFT与PEFT是在从参数更新的层面上来考虑模型的微调,还是有一种提高模型在各种任务上表现的策略是指令微调。这涉及到使用示例来训练机器学习模型,展示模型应该如何响应查询。用于微调大型语言模型的数据集必须符合你的指令目的。使用指令微调时,其模型参数的更新可以是FFT或PEFT。与IFT相关的概念有CoT,in-context learning等, 这篇blog主要把重点放到参数重新上,所以这些概念不做展开。
例如,如果你想提高模型的摘要能力,你应该构建一个包含摘要指令和相应摘的数据集。在翻译任务中,应包含“请将下面的文本翻译成英文”等指令。这些提示有助于让模型以新的专业方式“思考”,并服务于特定任务。
上面例子中“请将下面的文本翻译成英文”及类似的“请对下文做出摘要”等提示,我们统称Hard prompt, 与之对应的是Soft prompt。
1.3.1 Hard prompt
是一个人类可理解的文本字符串,可由任何语言文字组成,直接拼接在模型原始输入之前。从模型角度来看,hard prompt 并没有对PLM做任何的修改,也没没有增加新的参数,所以使用hard prompt微调时,本质是更新PLM本身的参数。因此受PLM参数化的限制 。
举个例子,在情感分类任务中,我们要判断“《功夫》这个电影是周星弛主演的,无厘头式的幽默,我很喜欢。”这句话的情感极性,我们怎么让一个语言模型来判断这个极性呢?我们可以如下图这样在输入后面接入一个引导的句子“这个电影太[MASK]了”,这个引导的句子里明显缺少了一个形容词,我们让模型把这个容易词自动补全,我们就可以根据这个形容词来判断极性了。
从这里我们可以看出,如果说finetune是为了使用预训练模型适配下游任务,那prompt就是为了使下游任务适配预训练模型。这种下游任务适配预训练模型的做法可大幅减少预训练模型在下游任务的应用成本,甚至做到不对预训练模型做修改就能使用。
1.3.2 Soft prompt
通常是在向量空间优化出来的prompt,直接面向模型,人类无法直接理解。且,soft prompt 通常会在PLM模型外部添加相关的网络结构做为prompt的参数,因此,soft prompt 的微调不会更新PLM模型的参数。
举个例子,如prefix tuning 中,在autoregressive 任务中,在PML所有layer之前拼接一个prefix,在微调时,只更新prefix的参数。
二. 常见微调方法
2.1 Prefix-tuning
论文地址:https://arxiv.org/abs/2101.00190
prefix-tuning的中文表达是前缀微调,在实现时,prefix-tuning是在PLM前部拼接一小段可学习的向量(virtual tokens)作为Prefix(这里的“前部”是指PLM网络结构每一层的前部,拼接后,PLM网络的层数是不变的), 且在微调模型的过程中只优化拼接的Prefix,而不需要优化整个模型的参数(训练的时候只更新Prefix部分的参数,而PLM中的其他部分参数不更新)。对于不同的任务和模型结构需要不同的Prefix:
- 在autoregressive LM 前添加prefix: z = [ P R E F I X ; x ; y ] z=[PREFIX;x;y] z=[PREFIX;x;y]
- 在encoder和decoder前添加prefix: z = [ P R E F I X ; x ; P R E F I X ′ ; y ] z=[PREFIX;x;{PREFIX}^{'};y] z=[PREFIX;x;PREFIX′;y]
可以参考下图来理解。
在原始论文的4.2 节中有表述,直接更新Prefix的参数会导致训练不稳定和性能下降的情况,所以在Prefix前增加了一层MLP, 此MLP与Prefix同row dimension相同,columns dimension 不同。训练之后,MLP直接舍弃,只保留Prefix的参数即可。
在原始论文的 7.2 节中,对prefix的拼接进行了消融实验,实验证明,PLM的所有层均拼接prefix能取得更好的结果。因此,我们提到prefix-tuning算法,默认是所有层都增加prefix。
看到这里,如果没有基础的话,可能还不清楚prefix到底是怎么回事儿,这里我画了一幅图展开一下。
从上图中,我们可以看出,当一个PreTrainModel在使用Prefix算法微调时,其实是在PreTrainModel之外增加了一个PrefixEncoder模块,这个模块用来生成Prefix, 而这个Prefix本质就是一个学习后的Embedding。PrefixEncoder会生成多个Prefix (past_key_values_1, past_key_values_2, …), 至于具体生成多少,取决于PreTrainModel的层数,这个层数指的是decoder的层数(以gpt类模型为例),如上图,这个层数为24。Prefix在传入decoder后,在attention函数中被拼接到当前hidden_states之前。
PrefixEnocder 源码(出自huggingface PEFT库)
class PrefixEncoder(torch.nn.Module):def __init__(self, config):super().__init__()self.prefix_projection = config.prefix_projectiontoken_dim = config.token_dimnum_layers = config.num_layersencoder_hidden_size = config.encoder_hidden_sizenum_virtual_tokens = config.num_virtual_tokensif self.prefix_projection and not config.inference_mode:# Use a two-layer MLP to encode the prefixself.embedding = torch.nn.Embedding(num_virtual_tokens, token_dim)self.transform = torch.nn.Sequential(torch.nn.Linear(token_dim, encoder_hidden_size),torch.nn.Tanh(),torch.nn.Linear(encoder_hidden_size, num_layers * 2 * token_dim),)else:self.embedding = torch.nn.Embedding(num_virtual_tokens, num_layers * 2 * token_dim)def forward(self, prefix: torch.Tensor):if self.prefix_projection:prefix_tokens = self.embedding(prefix)past_key_values = self.transform(prefix_tokens)else:past_key_values = self.embedding(prefix)return past_key_values
注: 上文的prefix其实是属于soft prompt 也称为 continuous prompt,与之对应的是 hard prompt 亦称为 discrete prompt。下面对hard prompt 和 soft prompt 分别做简单介绍。
2.2 Prompt-tuning
论文地址: https://arxiv.org/abs/2104.08691
该方法可以看作是Prefix Tuning的简化版本,它给每个任务定义了自己的Prompt,然后拼接到数据上作为输入,具体的理解可参考下图。
从上图我们可以看出,prompt tuning的encoder相比于prefix 的 encoder少了一个两层的MLP结构,只有一个embedding方法。拼接的方式是直接与原始PreTrainModel的Embedding进行拼接,然后进入到PreTrainModel的decoder stack中。没有其它操作。相比于prefix的每个encoder都进行拼接,需要训练的参数规则减少了10倍以上(现在的模型encoder动则几十层)。
下面是hugging face peft库中的源码, 核心代码就几行:
class PromptEmbedding(torch.nn.Module):def __init__(self, config, word_embeddings):super().__init__()total_virtual_tokens = config.num_virtual_tokens * config.num_transformer_submodulesself.embedding = torch.nn.Embedding(total_virtual_tokens, config.token_dim)if config.prompt_tuning_init == PromptTuningInit.TEXT and not config.inference_mode:# 此处为初始化self.enbedding的参数,省略...def forward(self, indices):# Just get embeddingsprompt_embeddings = self.embedding(indices)return prompt_embeddings
-
多任务同时训练
论文中指出,Prompt tuning 不必像fine tuning那样,对每个下游任务都使用全量模型做一次微调,有几个下游任务就保存几份模型参数,推理时也要使用特定的模型副本对相应的任务做推理。在Prompt tuning中,所有的任务均可以一起进行微调,Prompt tuning 只会为每个任务保存一个prompt。推理时,一个batch中可以有多个下游任务的数据。如下图所示。
-
性能参数规模逼近全量微调
原始论文的Introduction段落阐述了随着模型参数规模的增加,Prompt Tuning 的效果会逼近全量微调。具体表现见下图。
-
prompt 长度的影响
论文的3.2节,阐述了prompt长度的影响,prompt的长度在20左右时的表现已经不错(超过20之后,继续增加Prompt token长度,对模型的性能提升不明显),同样的,这个gap也会随着模型参数规模的提升而减小(即对于超大规模模型而言,即使 Prompt token 长度很短,对性能也不会有太大的影响)。具体表现见下图。
-
Prompt 参数初始化
在论文的3.2节中探讨了 Prompt token 的初始化方法和长度对于模型性能的影响。通过消融实验结果发现,与随机初始化和使用样本词汇表初始化相比,Prompt Tuning采用“class label”初始化模型的效果更好。不过随着模型参数规模的提升,这种gap最终会消失。具体表现见下图
-
Prompt Ensembing
论文的第6节,提出了 Prompt Ensembling,也就是在一个Batch中同时训练同一个任务的不同 prompt(即采用多种不同方式询问同一个问题),这样相当于训练了不同模型,比模型集成的成本小很多。模型规模很大时,有很大的优势。
原始论文中主要是在T5上做了相关实验,且最主要的性能如果要接近FFT,参数规模足够大是前提,包括embedding参数的初始化,也与参数规模有关,因些使用此方法时,要注意所使用的模型的参数规模。甜品级及以下的模型使用就不要使用这个方法了。
2.3 P-tuning v1
论文地址:https://arxiv.org/abs/2103.10385 v2
该方法的核心思想是使用可微的virtual token替换discrete tokens,且仅加入到输入层,并使用prompt encoder(BiLSTM+MLP)对virtual token进行编码学习。下图是论文中的框架示意图。
-
P-Tuning 算法原理 (出自论文2.2节)
给定 M M M为一个预训练模型,hidden size为 h h h, 词表大小为 [ V ] [V] [V], { x i , y i } \{x_i,y_i\} {xi,yi} 为已经标注好的一个NLU数据集,其中 x 0 : n = { x 0 , x 1 , . . . , x n } x_{0:n} = \{x_0,x_1,...,x_n\} x0:n={x0,x1,...,xn}为一个由离散token组成的输入, y ∈ Y y \in Y y∈Y 为标签集合。我们的目标是估计 f M ( x ) = p ^ ( y ∣ x ) f_{M(x)}=\hat{p}(y|x) fM(x)=p^(y∣x)的分类条件概率,其中 M M M的参数经过微调或冻结。
Prompt最初是以离散token的形式提出的(Schick and Schütze,2020)。设 [ D i ] [D_i] [Di]为离散porompt的token。每个prompt都可以描述为一个模板 T = { [ D 0 : i ] , x , [ D ( i + 1 ) : j ] , y , [ D ( j + 1 ) : k ] } T =\{[D_{0:i}],x,[D_{(i+1):j}],y,[D_{(j+1):k}]\} T={[D0:i],x,[D(i+1):j],y,[D(j+1):k]},它可以将token数据(包括输入 x x x 和标签 y y y)组织成一个文本token序列,这样就可以将任务重新表述为 对输入文本的空白地方进行填空。例如,对于预测一个国家首都的任务(LAMA-TREx P36),prompt可以是“[INPUT] 的首都是 [LABEL]”。对于一段标注数据“(英国,伦敦)”,重新表述的文本将是“英国的首都是 [MASK]。”,其中“[MASK]”应该预测为给定的标签“伦敦”。离散prompt和离散数据一起映射到输入嵌入中:
{ e ( D 0 ) . . . e ( D i ) , e ( x 0 ) , . . . , e ( x n ) , . . . , e ( D k ) } \{e(D_0)...e(D_i), e(x_0), ..., e(x_n), ..., e(D_k)\} {e(D0)...e(Di),e(x0),...,e(xn),...,e(Dk)}
根据预训练的嵌入层, e ∈ R ∣ V ∣ × d e \in R|V |×d e∈R∣V∣×d 。
这种离散提示往往极不稳定,可能不是反向传播的最佳选择。因此,我们提出了 P-Tuning,它使用连续prompt嵌入来改进和稳定prompt。令 [ P i ] [P_i] [Pi] 为第 i i i 个连续提示嵌入。P-Tuning 的提示模板如下:
T = { [ P 0 : i ] , x , [ P ( i + 1 ) : j ] , y , [ P ( j + 1 ) : k ] } T = \{[P_{0:i}],x,[P_{(i+1):j}],y,[P_{(j+1):k}]\} T={[P0:i],x,[P(i+1):j],y,[P(j+1):k]}
P-Tuning 利用额外的嵌入函数 f : [ P i ] → h i f : [P_i] → h_i f:[Pi]→hi将模板映射到
{ h 0 , . . . , h i , e ( x ) , h i + 1 , . . . , h j , e ( y ) , h j + 1 , . . . , h k } \{h_0,...,h_i,e(x),h_{i+1},...,h_j,e(y),h_{j+1},...,h_k\} {h0,...,hi,e(x),hi+1,...,hj,e(y),hj+1,...,hk}最后,我们更新embeddings { P i } i = 1 k \{P_i\}^k_{i=1} {Pi}i=1k 以优化任务损失函数(在p-tunning中,embedding 会被更新)。值得注意的是,我们还可以将离散prompt与连续的prompt连接起来( 如下图中input embedding 即包含 e ( x ) e(x) e(x)也包含 h i h_i hi ),这种方法效果更好,并在我们的整个实验中都采用了这种方法。P-Tuning 适用于冻结和微调语言模型。
关于上面几个公式可以参考下图来理解:
-
Prompt Encoder(出自论文2.3节)
在上述框架中,我们使用映射函数 f f f 将可训练嵌入 { P i } \{P_i\} {Pi} 映射到模型输入 { h i } \{h_i\} {hi}。直觉是,与使用独立的可学习嵌入相比,使用映射函数可以更方便地对不同提示嵌入之间的依赖关系进行建模。在我们的实现中,我们使用轻量级神经网络来制定函数 f f f。具体来说,我们尝试使用长短期记忆 (LSTM) 网络、多层感知器 (MLP) 和第 3 节中的恒等映射函数。这一段落关于Prompt Encoder的说明,我感觉略显笼统,通过这段话我并没有看出PromptEncoder到底是怎么实现的,于是乎我又翻阅了GPT understands, too 的v1版本,在v1版本里对PromptEncoder有较详细的描述,且我又在清华的官网查了p-tuning的实现代码,在当前时间点(2024/06/20),清华的官方实现的代码与v1版本中的描述是相同的。所以下面按v1版本中的描述补充一下理解。
Prompt Encoder 由一个使用ReLU做为激活函数的双层MLP及BiLSTM组成,对于任意时刻的 h i h_i hi有:
h i = M L P ( [ h i → : h i ← ] = M L P ( [ L S T M ( h 0 : i ) : L S T M ( h i : m ) ] ) \begin{aligned} h_i &= MLP([ \mathop{h_i} \limits ^{\rightarrow}: \mathop{h_i} \limits ^{\leftarrow}] \\ &= MLP([LSTM(h_{0:i}):LSTM(h_{i:m})]) \end{aligned} hi=MLP([hi→:hi←]=MLP([LSTM(h0:i):LSTM(hi:m)])下面贴上清华官网的P-tuning PromptEncoder代码:
import torch
import torch.nn as nnclass PromptEncoder(torch.nn.Module):def __init__(self, template, hidden_size, tokenizer, device, args):super().__init__()self.device = deviceself.spell_length = sum(template)self.hidden_size = hidden_sizeself.tokenizer = tokenizerself.args = args# ent embeddingself.cloze_length = templateself.cloze_mask = [[1] * self.cloze_length[0] # first cloze+ [1] * self.cloze_length[1] # second cloze+ [1] * self.cloze_length[2] # third cloze]self.cloze_mask = torch.LongTensor(self.cloze_mask).bool().to(self.device)self.seq_indices = torch.LongTensor(list(range(len(self.cloze_mask[0])))).to(self.device)# embeddingself.embedding = torch.nn.Embedding(len(self.cloze_mask[0]), self.hidden_size).to(self.device)# LSTMself.lstm_head = torch.nn.LSTM(input_size=self.hidden_size,hidden_size=self.hidden_size // 2,num_layers=2,dropout=self.args.lstm_dropout,bidirectional=True,batch_first=True)self.mlp_head = nn.Sequential(nn.Linear(self.hidden_size, self.hidden_size),nn.ReLU(),nn.Linear(self.hidden_size, self.hidden_size))print("init prompt encoder...")def forward(self):input_embeds = self.embedding(self.seq_indices).unsqueeze(0)output_embeds = self.mlp_head(self.lstm_head(input_embeds)[0]).squeeze()return output_embeds
- 结论
相同参数规模下,如果进行全参数微调,Bert在NLU任务上的效果,超过GPT很多;但是在P-Tuning下,GPT可以取得超越Bert的效果。如下图。
2.4 P-tunning v2
原始论文:P-Tuning v2: Prompt Tuning Can Be Comparable to Fine-tuning Universally Across Scales and Tasks
论文地址:https://arxiv.org/abs/2110.07602
之前的Prompt Tuning和P-Tuning v1 等方法存在两个主要的问题:
第一, 缺乏模型参数规模和任务通用性。
- 缺乏规模通用性:Prompt Tuning论文中表明当模型规模超过100亿个参数时,提示优化可以与全量微调相媲美。但是对于那些较小的模型(从100M到1B),提示优化和全量微调的表现有很大差异,这大大限制了提示优化的适用性。
- 缺乏任务普遍性:尽管Prompt Tuning和P-tuning在一些 NLU 基准测试中表现出优势,但提示调优对序列标记任务(即序列标注)的有效性尚未得到验证。
第二,缺少深度提示优化,在Prompt Tuning和P-tuning中,连续提示只被插入transformer第一层的输入embedding序列中,在接下来的transformer层中,插入连续提示的位置的embedding是由之前的transformer层计算出来的,这可能导致两个可能的优化挑战。
- 由于序列长度的限制,可调参数的数量是有限的。
- 输入embedding对模型预测只有相对间接的影响。
这些问题在P-tuning v2得到了改进。
P-Tuning v2主要是基于P-tuning和prefix-tuning技术,引入Deep Prompt Encoding和Multi-task Learning等策略进行优化的。
如上图(b)所示,不同层的提示被作为前缀token添加。P-Tuning v2的这种做法带来了两个显著优点:
- 更多的可调任务特定参数:P-Tuning v2拥有更多的可调任务特定参数(从0.01%到0.1%-3%),这不仅增加了每个任务的处理能力,而且在参数效率上更优。
- 深层提示对模型预测的直接影响:添加到更深层的提示对模型预测有更直接的影响。
为了达到最佳性能,P-Tuning v2在优化和实现方面有一些有用的细节:(原始论文3.3节)
重参数化(Reparameterization):先前的研究通常使用多层感知机(MLP)这样的重参数化编码器来转换可训练向量,但对于NLU任务来说MLP的有效性取决于任务和数据集。对于某些数据集(如RTE和CoNLL04),MLP带来了明显的改进;但对于其他数据集(例如,BoolQ和CoNLL12),MLP对结果的影响微小甚至负面。
提示词长度(Prompt Length): 提示词长度在P-Tuning v2中扮演着关键角色。研究发现,通常简单的分类任务更倾向于较短的Prompt(少于20个);而复杂的序列标注任务则更适合较长的Prompt(大约100个)。不同的NLU任务通常在不同的提示词长度下达到最佳性能。
多任务学习(Multi-task Learning): 多任务学习通过共享连续提示,在针对个别任务进行微调之前,联合优化多个任务。多任务学习对P-Tuning v2来说不是必选项,但可以通过提供更好的初始化来进一步提升性能(Gu等人,2021)。
分类头(Classification Head): 使用语言建模头(language modeling head)来预测类别化标签(Verbalizers)一直是P-Tuning的核心操作,但在完整数据设置下这不是必需的,且与序列标注不兼容。P-Tuning v2改为在BERT中的token之上应用一个随机初始化的分类头(见上图b)。
2.4 Lora
todo ..