迈向预训练代码模型的高效微调:一项实验研究及其延伸

互联不一般哥 2024-07-24 03:16:46

Towards Efficient Fine-Tuning of Pre-trained Code Models: An Experimental Study and Beyond

Ensheng Shi*, Yanlin Wang*t, Hongyu Zhang, Lun Du, Shi Han, Dongmei Zhang, Hongbin Sunt

引用

Shi E, Wang Y, Zhang H, et al. Towards efficient fine-tuning of pre-trained code models: An experimental study and beyond[C]//Proceedings of the 32nd ACM SIGSOFT International Symposium on Software Testing and Analysis. 2023: 39-51.

论文:https://dl.acm.org/doi/abs/10.1145/3597926.3598036

摘要

近期,微调预训练代码模型如CodeBERT在软件测试与分析任务中取得显著成效。然而,这一过程计算成本高昂。本文通过研究发现,代码的词汇、句法和结构特性在不同层级中编码,语义特性则遍布整个模型。微调主要保留了这些特性,尤其是底层和中间层的变化较小,而顶层两层变化最大。据此,我们提出了Telly方法,通过冻结底层来高效微调模型,实验显示该方法减少了训练参数和时间成本,同时保持了性能。

1 引言

近期,采用微调的预训练范式在软件测试和分析任务中取得了显著进步,如漏洞检测、补丁生成、自动程序修复、代码审查、代码生成和克隆检测等。这些方法首先在大数据上预训练大型Transformer模型以学习通用代码表示,然后针对特定任务进行微调。尽管有效,但微调预训练参数的计算成本和能源消耗巨大。本研究首先探索了预训练代码模型各层编码的代码特性以及微调过程中的变化,随后提出了一种基于层冻结的高效微调方法Telly-K。主要发现和贡献如下:

我们发现代码的词汇特性主要编码在底层,句法特性在中层,结构特性在高层,而语义特性遍布整个模型。微调过程保留了大多数代码特性,尤其是底层和中间层变化较小,仅顶层两层变化显著。基于此,我们提出Telly-K方法,通过冻结微调过程中变化不大的底层来减少训练参数和时间。实验结果显示,在几乎不影响模型性能的情况下,训练时间和参数大幅减少,且在某些情况下模型性能有所提升。

本文的主要贡献:

提出了与代码的词汇、句法、语义和结构特性相关的四个探测任务,探索了这些特性如何在各层表示中编码。首次进行了广泛的实验研究,分析了预训练代码模型在微调过程中各层表示及其编码的代码特性的变化。提出了通过层冻结高效微调预训练代码模型的方法,并在五个不同的下游任务上验证了其有效性。

2 预训练代码模型的实验研究

2.1 研究问题

虽然微调预训练代码模型有效且普遍,但其计算成本高昂。在本研究中,我们首先进行实验研究,探究各层预训练表示中编码了哪些代码特性,以及这些表示在微调过程中的变化。以下是实验研究的详细研究问题介绍。

RQ1:各层预训练表示中编码了哪些代码特性?受编译过程[3]和静态分析技术[38]的启发,我们首先提出了与源代码的词汇、句法、语义和结构特性相关的四个探测任务,详细介绍见第3.2.1节。然后,在第3.2.2节中,我们通过这些探测任务研究各层预训练表示编码的代码特性,并分析每层表示对这些特性的理解贡献。此外,在第3.5节中,我们对微调后的模型进行相同的探测实验,直观理解微调过程中预训练模型捕获的代码特性的变化。

RQ2:微调过程中各层表示发生了什么变化?在RQ1中,我们借助探测任务大致理解微调时预训练代码模型的变化。我们进一步进行了广泛的表示相似性分析(RSA),以研究在下游任务上微调时,预训练表示逐层的变化。RSA在第2.3节介绍,是一种与任务无关的技术,不需要探测任务的先验知识。如何在预训练和微调模型上应用RSA的描述见第3.3节。为确保实验发现的普遍性,我们在包括代码搜索、克隆检测、代码摘要、代码生成和行级代码补全在内的五个不同下游任务上进行了实验。

2.2 探测预训练代码模型

我们介绍了以下四个与代码相关的探测任务和探测流程。

2.2.1 四个探测任务

我们在图1中展示了四个探测任务,它们分别与代码的词汇、句法、语义和结构特性相关。下面逐一详细介绍。

词汇探测。词汇探测旨在衡量上下文表示对源代码词汇特性的编码效果。众所周知,源代码编译时,第一步是词法分析,它将源代码字符串分词并确定每个代码标记的类型(如标识符、关键字等)。不同类型在后续的程序分析和编译中扮演着不同的语义或句法角色。因此,理解预训练代码模型是否通过上下文表示捕获了源代码的词汇信息至关重要。我们首先将预训练代码模型的上下文表示作为固定特征,输入到线性分类器中,然后训练它预测每个代码标记的类型。如图1(d)所示,每个标记属于五种类型之一:标识符、关键字、运算符、数字和字符串。详细的类型定义和词汇探测描述可在在线附录[51]中找到。

句法探测。句法分析通常在程序编译过程中的词法分析之后进行,其中解析器将词法器生成的标记序列作为输入,生成解析树或抽象语法树(AST)。句法探测旨在探究上下文表示对源代码句法特性的感知能力。基本思路是识别代码和匿名AST(如图1(b)所示的AST-Only)是否配对。我们首先解析源代码以获取相应的AST,包括非终端和终端节点。非终端节点代表句法信息,而终端节点由源代码中句法元素的类型和值组成。我们训练一个线性分类器来确定给定的AST-Only是否由给定的代码片段解析而来。详细的句法探测描述可在在线附录[51]中找到。

语义探测。为了了解预训练代码模型对代码语义的理解程度,我们进行语义探测(如图1(e)),它检验识别具有相同语义但不同实现代码片段的能力。我们使用POJ-104[39]数据集,包含104个问题和每个问题的500个C/C++实现,作为评估数据集。我们训练一个线性映射器,将预训练表示作为输入,将语义相似的代码片段映射到相似的嵌入中。这样,具有相同语义的实现可以通过它们的向量距离轻松召回。详细的语义探测描述可在在线附录[51]中找到。

结构探测。除了词汇、句法和语义特性之外,结构特性对于代码分析也同样重要。循环复杂度[36],它表示程序的复杂性,并可以参考控制流图(CFG),可以作为代码的结构特性。从数学上讲,循环复杂度M可以通过源代码的CFG来计算:

其中E和N分别是图中边和节点的数量。P是连通分量的数量。其值通常是1,因为CFG是一个连通图。如图1(c)所示,CFG有7个节点和7条边,因此代码片段的循环复杂度为7-7+2=2。我们使用循环复杂度预测作为结构探测任务,以探究上下文表示对源代码结构特性的理解程度。结构探测的详细描述可以在在线附录[51]中找到。

2.2.2 探测流程

遵循先前的研究[37,52],为了探究预训练模型中各层表示编码了哪些代码特性,我们训练了一个分类器,该分类器将这些层级的表示作为输入,以预测与代码特性相关的探测任务。同时,我们学习所有层上下文表示的线性组合,以研究每层表示对这些代码特性的理解贡献程度。从数学上讲,对于预训练的层级表示H0, H1, ..., HL,我们通过以下方式将它们组合起来:

其中,层级权重al与探测分类器一起联合学习。一方面,我们比较组合表示F与随机初始化表示的性能,以研究预训练的上下文表示对源代码特性的编码效果。我们还将预训练表示与微调后的表示进行比较,以研究微调过程中代码特性的变化。另一方面,为了探究每层表示对编码代码特性的贡献程度,以及探索预训练与微调模型之间的差异,我们展示了预训练和微调模型在每个探测任务中的层级权重al。

2.3 表示相似性分析

遵循先前的研究[37],我们随机抽取N个代码片段,并获取预训练和微调模型的各层表示。然后,对于每个层,我们通过计算这一层的表示向量之间任意两个代码片段的余弦相似度,得到该层的距离矩阵Al(在第2.3节引入,大小为N×N)。表示向量是通过平均该层的上下文表示获得的。从数学上讲,我们将第k个代码片段的预训练或微调代码模型的第l层上下文表示表示为Hk l。距离矩阵Al的计算方式为:

接下来,对于第l层,我们计算预训练模型和微调模型获得的两个距离矩阵之间的皮尔逊相关系数pl。特别是,我们在包括代码搜索、克隆检测、代码摘要、代码生成和行级代码补全在内的五个不同下游任务上进行了实验。这些任务的概览在表1中。

2.4 实验设置

在本研究中,我们分析了最先进的预训练代码模型UniXcoder [17] 和 GraphCodeBERT [18]。两者都是12层的Transformer模型,维度为768,总参数约120 MB。UniXcoder是一个统一的预训练代码模型,可以通过特殊指示标记作为编码器、解码器或编码器-解码器架构使用。GraphCodeBERT考虑了数据流信息,并使用大量双模态数据(代码函数与自然语言注释配对)和单模态代码数据进行预训练。我们选择UniXcoder和GraphCodeBERT进行实验,因为它们在许多代码智能任务上都取得了令人鼓舞的结果。

对于探测实验,我们通过CodeSeachNet和POJ-104 [39]构建了评估数据集,如表1所示。词汇、句法和结构探测使用Python的CodeSeachNet数据集,语义探测使用POJ-104。遵循UniXcoder/GraphCodeBERT在代码搜索上的微调实验设置,我们在CodeSeachNet数据集上用Python进行微调,并用四个探测任务探测预训练和微调后的模型。探测时,代码片段的最大长度设为512。最大周期和批量大小分别设为30和32。我们采用Adam优化器,学习率为1e-4,并在验证集上执行早停。我们随机种子为0、1、2各运行实验3次,并在论文中展示平均值。

对于表示相似性分析,遵循先前的研究[37],N设为5,000。遵循UniXcoder/GraphCodeBERT的微调实验设置,我们在表1所示的五个下游任务上进行了微调。

2.5 实验发现

在本节中,我们将呈现并分析上述两个研究问题的结果。由于篇幅限制,我们仅呈现基于UniXcoder的Telly-K的结果,并将基于GraphCodeBERT的Telly-K的结果放在在线附录[51]中。对UniXcoder的结论和发现通常适用于GraphCodeBERT。

2.5.1 RQ1:各层预训练表示中编码了哪些代码特性?

我们使用与词汇、句法、语义和结构特性相关的四个探测任务,以探索各层预训练表示中编码的代码特性以及每层表示对这些特性的理解贡献。同时,我们还比较了相同设置下预训练和微调的层表示。探测任务的性能结果展示在表2中,各层的贡献在图2中呈现。在表2中,词汇、句法和结构探测的结果通过准确率来衡量,语义探测的结果通过平均平均精度(MAP)[44]来衡量。关于准确率和MAP度量的更多详细描述,请参阅在线附录[51]。从表2中,我们可以发现(1)预训练和微调的表示比随机表示更好地理解代码特性;(2)微调后,词汇、句法和语义代码特性仍然被很好地捕捉,而捕捉结构特性的能力显著下降。第一个发现是可以预期的,因为预训练的代码模型可以利用更大的数据集和模型大小,将基本代码知识编码到它们的表示中。微调后,一些代码特性仍然被保留。这可能与下游任务的特点有关,因为代码搜索主要依赖于代码的词汇、句法和语义信息,而不是结构信息。这也可能是由于梯度消失[8]的原因,因为低层编码的代码特性变化很小,而高层编码的代码特性变化明显。实际上,梯度消失对预训练代码模型的优化影响不大,因为模型所采用的基本架构使用了残差连接[20,56],这可以有效地避免梯度消失问题。

图2展示了预训练和微调模型的层贡献(λl在公式4中)。从图2中,一方面,我们可以观察到,对于预训练或微调的模型,源代码的词汇、句法和结构特性主要在低层、中层和高层被捕捉,而语义特性几乎贯穿整个模型。我们对贡献差异进行了显著性测试1,以检验贡献差异的显著性。实验结果显示,对于词汇探测,第1层、第2层和第4层的贡献显著高于其他层。对于句法探测,第4层到第7层的贡献显著高于其他层。对于语义探测,不同层的贡献没有显著差异。对于结构探测,预训练和微调模型的最后层的贡献显著高于其他层。另一方面,我们发现微调过程保留了大多数代码特性。具体来说,低层和中间层捕捉的基本代码特性在微调过程中仍然被保留。只有结构探测任务的性能明显变化。

总结。对于预训练的各层表示,源代码的词汇、句法和结构特性分别主要由低层、中层和高层捕捉,而语义特性几乎贯穿整个模型。同时,低层和中间层捕捉的基本代码特性在微调过程中仍然被保留。

2.5.2 RQ2:微调过程中各层表示发生了什么变化?

我们还进行了广泛的表示相似性分析(RSA)实验,以研究预训练模型在为五个不同下游任务进行微调时各层表示的变化,而没有借助探测任务。结果如图3所示。从展示的结果中,我们可以看到底层9层的关联系数(图3中的相似度分数)都大于0.8。这意味着底层9层的表示在预训练和微调模型之间对于五个下游任务是相似的。除了代码补全之外,顶层表示是不相似的(p ≤ 0.5),这是因为预训练模型的UniXcoder的顶层被用来预测遮蔽的标记,这与代码补全的实验设置相似。此外,我们还发现底层7层的表示(p ≥ 0.9)之间密切相关,底层5层的表示(p ≥ 0.95)之间强烈相似。

总结。对于五个下游任务,预训练和微调模型的底层9层表示是相似的。只有在微调过程中,顶层两层的表示发生了显著变化。

3 预训练代码模型的有效微调

3.1 研究问题

3.1.1 RQ3:是否存在更高效的微调替代方案?

基于实验研究的结果,我们探索了更高效地微调预训练代码模型的替代方案。我们的主要动机是冻结在下游任务微调过程中变化不大的层的预训练参数。我们提出了Telly-K,它代表通过层冻结实现预训练代码模型的有效微调。Telly-K意味着冻结底层K层的预训练参数,不同的K值代表我们方法的变体。第0层是嵌入层,我们研究的预训练代码模型的最大层数为12。如果K设为12,那么Telly-12将冻结所有参数,模型将退化为原始的预训练模型。因此,为了实验的全面性,我们从0到11变化K值,并在五个下游任务上对这12个模型变体进行了广泛的实验。接下来,我们将介绍实验设置、结果和分析。

3.2 实验设置

实验涵盖了五个不同的下游任务:代码搜索、代码检测、代码摘要、代码生成和行级代码补全。这些任务的概览已在表1中给出。代码搜索在CodeSeachNet数据集上进行评估,使用Python和Ruby,并使用MRR和top-k召回作为性能指标。克隆检测在BigCloneBench数据集上进行,使用精度、召回率和F1分数作为评估指标。代码摘要在CodeSearchNet数据集上进行微调,使用BLEU、Meteor、Rouge-L和Cider作为评估指标。代码生成在CONCODE数据集上进行评估,使用BLEU和EM作为性能指标。行级代码补全在CodeXGLUE的GitHub Java Corpus数据集上进行,使用EM和Levenshtein编辑相似性作为评估指标。所有任务都遵循先前的研究设置,使用Adam优化器,最大周期为30,并在验证集上执行早停。实验重复3次,随机种子为0,1,2,并在论文中展示平均值。所有实验都在配备Tesla A100 GPU的机器上进行。详细的实验设置可以在在线附录中找到。

3.3 实验结果

文章进行了Telly-K实验,该实验在微调预训练代码模型用于五个下游任务时冻结其底层K层。我们首先呈现并分析了包括训练参数、训练时间成本和性能任务在内的实验结果,然后总结了下游任务的结果。由于篇幅限制,我们只展示了基于UniXcoder的Telly-K的结果,并将基于GraphCodeBERT的Telly-K结果放在在线附录[51]中。对UniXcoder的结论和发现也通常适用于GraphCodeBERT。

3.3.1 代码搜索

我们在代码搜索任务上研究了12种Telly-K变体的性能。结果展示在表3中。我们只展示了Python数据集的结果,Ruby数据集(与Python类似)的结果放在在线附录[51]中。

在表3中,我们展示了基础模型和12种模型变体训练的参数数量、训练时间和性能。基础模型是微调所有预训练参数,而不同的变体会冻结部分参数。我们报告了每个周期的训练时间和模型收敛所需的时间。不同变体模型与基础模型相比的参数减少比例在括号中显示。从表3的结果中,我们可以发现:

对于所有变体模型,与基础模型相比,训练时间成本(特别是收敛时间成本)和训练参数都显著减少,而性能在四个指标上变化不大。特别是,Telly-11(冻结底层11层)的收敛时间成本和训练参数分别减少了88%和94%,而性能仅下降了约3%。对于Telly-K(0 ≤ K ≤ 8),它们减少了32%到77%的训练参数,相应地节省了18%到81%的训练时间,所有指标的性能都有所提升,从0%到3%。当冻结底层9层时,训练参数减少了83%,相应的训练时间节省了84%,性能略有变化。例如,与基础模型相比,Telly-9的MRR和R@10值分别下降不到1%,R@1增加了约1%,而R@5保持不变。对于Telly-K(K ≥ 10),四个指标的性能一致下降。然而,即使冻结底层11层,性能也不会显著下降,而训练参数和相应的训练时间大幅减少。

总结。在代码搜索任务中,Telly-K的性能随着K值的增加而变化:当0 ≤ K ≤ 8时,性能有所提升;当K = 9时,性能略有变化;而当K ≥ 10时,性能略有下降,与基础模型相比。

3.3.2 克隆检测

我们在克隆检测任务上对不同的Telly-K变体进行了实验,结果展示在表4的左半部分。由于预训练的代码模型采用两层MLP作为分类器来确定两个代码片段是否为克隆,总参数约为1.271亿。性能通过精度(P)、召回率(R)和F1分数(F1)来评估,它们都在[0, 1]的范围内。从表4中,我们可以发现:

对于所有变体模型,与基础模型相比,训练成本和参数都有显著减少,而性能没有显著变化。特别是,当冻结底层11层时,收敛时间成本和训练参数分别减少了88%和94%,而性能仅变化了1-3%。对于Telly-K(0 ≤ K ≤ 8),训练参数减少了32%到77%,相应地节省了大约23%到51%的训练时间成本。此外,F1分数的性能总体上保持稳定,而精确度和召回率略有变化。当K ≥ 9时,不同变体的性能在精确度和F1分数上持续下降,而在召回率上有所增加。然而,即使对于Telly-11,所有评估指标的得分都较高(大于0.9),而训练参数和相应的训练时间成本大幅减少。

总结。在克隆检测任务中,Telly-K的性能对于0 ≤ K ≤ 8的变体是稳定的,而对于K ≥ 9的变体与基础模型相比则略有变化。

3.3.3 代码摘要

我们在代码摘要任务上进行了不同Telly-K变体的实验,结果展示在表4的右半部分。由于空间限制,Python上的Rouge-L和Cider的结果以及Ruby上的所有实验结果都放在了在线附录[51]中。报告的指标包括BLEU和METEOR,它们的范围是[0, 100]。从表4中,我们可以发现:

对于所有变体模型,与基础模型相比,训练时间成本和参数都有显著减少,而所有指标的性能变化不大。对于Telly-K(0 ≤ K ≤ 5),它们减少了32%到61%的训练参数,相应地节省了大约5%到57%的训练时间,同时保持了稳定的性能。特别是,所有评估指标的性能变化都小于1%。对于Telly-K(6 ≤ K ≤ 9),训练参数减少了66%到83%,相应地节省了60%到80%的训练时间,性能总体上略有提升。当Telly-K(K ≥ 10)时,四个指标的性能略有下降。然而,即使冻结底层11层,性能也没有显著下降,同时训练参数和相应的训练时间成本大幅减少。

3.3.4 代码生成

我们在代码生成任务上进行了不同Telly-K变体的实验,结果展示在表5的左半部分。评估指标包括BLEU和EM,它们的范围是[0, 100]。从表5中,我们可以发现:

对于所有变体模型,与基础模型相比,训练时间成本和参数都有显著减少,而除了Telly-K(K ≥ 8)之外,所有指标的性能变化不大。对于Telly-K(0 ≤ K ≤ 5),他们减少了32%到61%的训练参数,相应地节省了大约10%到55%的训练时间,性能略有提升。具体来说,BLEU分数提高了0%到2%,EM分数提高了3%到10%。对于Telly-K(6 ≤ K ≤ 9),训练参数减少了66%到83%,相应地节省了57%到65%的训练时间。这些变体的BLEU性能显著下降,但在EM下通常有所提升。这是因为BLEU结合了生成代码片段和真实样本之间的n-gram精度(n=1,2,3,4),而EM是1如果它们完全相同,0否则。然而,对于整个数据集,只有大约18%的代码片段可以与真实样本完全相同地生成。其余82%的样本也会影响BLEU分数的最终结果。因此,BLEU和EM的性能变化表现不同。当Telly-K(K ≥ 10)时,训练参数和相关时间成本大幅减少,变体的性能也在两个指标上显著下降。

摘要。在代码生成任务上,Telly-K的性能随着K值的增加而变化:对于Telly-K(0 ≤ K ≤ 5),性能略有提升;对于6 ≤ K ≤ 9,性能明显变化;而当K ≥ 10时,性能显著下降,与基础模型相比。

3.3.5 行级代码补全

我们在行级代码补全任务上进行了所有Telly-K变体的实验,结果展示在表5的右半部分。评估指标包括Edit Sim和EM,它们的范围是[0, 100]。从表5中,我们可以发现:

对于所有变体模型,与基础模型相比,训练时间成本和参数都有大幅减少,而除了Telly-11之外,性能没有显著变化。对于Telly-K(0 ≤ K ≤ 7),他们减少了32%到72%的训练参数,相应地节省了大约13%到75%的训练时间。此外,Telly-K(0 ≤ K ≤ 3)的性能略有提升,而Telly-K(4 ≤ K ≤ 7)的性能略有下降。对于Telly-K(K ≥ 8),训练参数和相关时间成本大幅减少。变体的性能在两个指标上普遍下降。特别是,当K = 11时,性能显著下降。因此,最后层的预训练参数需要进行微调,以学习预测下一行代码。

摘要。在代码补全任务上,Telly-K的性能对于(0 ≤ K ≤ 10)略有提升,而当K = 11时显著下降,与基础模型相比。

3.3.6 所有下游任务的发现

在分析了每个任务的实验结果后,我们总结如下:

对于所有Telly-K,与基础模型相比,训练时间成本和训练参数都有显著减少,性能变化不大。当0 ≤ K ≤ 5时,Telly-K将训练参数减少30%到65%,节省约10%到70%的训练时间,性能普遍增加1%到4%。当6 ≤ K ≤ 9时,Telly-K将训练参数减少65%到80%,节省约50%到80%的训练时间,性能略有变化。当K ≥ 10时,训练参数和时间成本大幅减少,性能略有下降,但变化不大。性能提升可能是由于参数减少减轻了过拟合。

摘要。对于Telly-K在各种下游任务上,与基础模型相比,训练参数和时间成本都显著减少。通常情况下,Telly-K的性能在0 ≤ K ≤ 5时比基础模型提高1%到4%,在6 ≤ K ≤ 9时略有变化,而当K ≥ 10时则明显下降。

转述:伊高磊

0 阅读:0

互联不一般哥

简介:感谢大家的关注