BERT模型用于代码补全的实证研究

互联不一般哥 2024-04-08 09:56:42

An Empirical Study on the Usage of BERT Models for Code Completion

Matteo Ciniselli, Nathan Cooper, Luca Pascarella, Denys Poshyvanyk, Massimiliano Di Penta, Gabriele Bavota

SEART @ Software Institute, Universita della Svizzera italiana (USI), Switzerland

SEMERU @ Computer Science Department, William and Mary, USA

Department of Engineering, University of Sannio, Italy

引用

Ciniselli M, Cooper N, Pascarella L, et al. An empirical study on the usage of bert models for code completion[C]//2021 IEEE/ACM 18th International Conference on Mining Software Repositories (MSR). IEEE, 2021: 108-119.

论文:https://arxiv.org/abs/2103.07115

摘要

本文通过一项大规模的实证研究,探索目前较为先进的RoBERTa模型在支持代码补全方面的能力。我们训练并测试了几种最近提出的RoBERTa模型的变体,并从多个角度评估其预测能力,包括常用的深度学习模型评估指标、完美预测百分比以及生成的代码在语义上与开发人员编写的代码的等效性。结果表明BERT模型的完美预测范围可以达到7%,进行完整代码块补全时可达到58%。

1 引言

随着软件开发领域的不断变化,新兴的编程语言、框架和API不断出现,这使得即使对于经验丰富的开发人员来说,凭记忆编写代码也是一项相当具有挑战性的任务。因此,代码补全被认为是现代集成开发环境(IDEs)中的一个重要功能:它可以根据IDE中已经编写的代码,为开发人员提供关于下一个代码标记(即code token,例如一个方法调用)的建议,从而加快软件开发速度并防止错误。

现有文献记录了代码补全工具的重大进展,补全的范围从仅仅是给出下一个要写的标记的按字母排序的列表到考虑代码上下文的“智能”补全,以及考虑代码更改历史和从软件仓库挖掘的编码模式。深度学习(DL)模型目前已被应用于代码补全,建立了预测性能方面的新标准。随着相关领域研究的进行,代码补全技术的性能显著提高,但它们提供给开发人员的支持类型仍然较少,且只能预测单个代码标记,目前只有少数几项研究关注于预测多个连续的标记。

在本项工作中,我们进行了一项大规模的实证研究,探讨最先进的DL模型支持代码补全的能力。除了生成开发人员可能要编写的下一个标记外,我们还将DL模型应用于生成整个语句和代码块,例如if语句的主体。在文献中提出的许多DL模型中,我们决定采用最近由Liu等人提出的RoBERTa模型。RoBERTa是一种BERT模型,它使用一个预训练任务,在输入句子中随机单词被使用特殊的<MASK>标记作为掩码,模型负责预测被标记的词。RoBERTa的预训练任务公式尤其适用于代码补全:输入句子可以被视为代码语句,标记的词可以被视为代码标记。此外,与统计语言模型相比,BERT模型的优势在于考虑前后的词来进行预测。

RoBERTa预训练任务的一个局限性是必须使用n个<MASK>标记来代替n个代码标记,从而隐含地告诉模型必须生成多少代码标记来自动完成掩码的语句。这在实际使用情景中是不现实的,代码补全引擎必须预测要生成的掩码。基于这个原因,我们改进了RoBERTa的预训练目标,以便能够从一个单一的<MASK>标记中预测被标记的语句中需要生成哪些代码标记。

我们训练了RoBERTa模型的三种不同变体,分别专门用于:(1)标记级别的预测,即经典的代码补全,在其中模型被用于猜测开发人员开始编写的语句中的最后n个标记;(2)结构级别的预测,其中模型被用于预测特定的代码结构(例如if语句的条件);以及(3)代码块级别的预测,标记的代码跨越一个或多个完整语句组成的代码块(例如for循环的迭代块)。

我们从多个不同的角度分析生成的预测代码的质量,包括:(1)在评估深度学习生成模型时通常采用的指标(例如BLEU分数和Levenshtein距离);(2) 完美预测的百分比(即模型预测的代码与开发者编写的代码完全相同);以及(3) 非完美预测的“语义”等价性,即模型预测的代码与开发者原始编写的代码不同,但在提供的功能方面是等效的。我们还将RoBERTa模型与Hellendoorn和Devanbu提出的n-gram模型进行比较。 实现的结果显示,在经典的代码补全任务(即,标记级别),RoBERTa能够在39%至58%的情况下正确预测所有标记。这取决于我们使用的特定数据集和代码表示方式。当代码补全任务涉及更具挑战性的情景,如结构级预测时,性能下降了10%-15%。在最具挑战性的情况下,即我们标记整个代码块时,RoBERTa显示出其局限性,仅能在7%-9%的情况下正确重现被标记的代码块,特别是当单个代码块相当短时。与n-gram模型相比,RoBERTa的表现要好得多。然而,这种性能提升伴随着更高的训练成本。

2 技术介绍

我们的研究利用现成的RoBERTa模型,它是代码补全的较为合适的选择。基于BERT的模型,如RoBERTa,使用一种特殊的预训练,其中输入句子中的随机单词被特殊的<MASK>标记。这种预训练任务非常适合模拟代码补全任务,在这个任务中,输入是开发人员正在编写的不完整代码片段,而掩码代表了需要自动完成的代码片段。然而,这种预训练的一个限制是,当尝试预测多个标记时,比如整个被掩码代替的if条件语句,由于变换器的固定序列长度,需要知道要生成的掩码数量。为了克服这个问题,我们使用T5预训练模型的一个版本,使用单个标记来代替一系列标记。在图1中,四个标记(即X2到X5)被一个单独的<MASK>标记来代替。在我们的研究中,我们没有利用RoBERTa的预训练,因为微调任务(即预测我们标记的特定代码部分)与BERT模型中被认为是预训练的任务(即预测被打上的标记)非常相似。唯一的区别,就是我们使用单个<MASK>标记来替代多个标记。此外,由于这是一项探索性的实证研究,我们希望评估RoBERTa模型在简单和更具挑战性的代码补全场景和数据集中的性能,我们决定为每个代码补全任务训练不同的模型,避免了迁移学习引入的可能混淆因素。事实上,正如最近的研究所显示,对模型进行多任务微调会对模型的性能产生重大影响。

为了训练这些模型,我们使用了Python transformers库。除了训练RoBERTa模型外,我们还为每个模型训练了一个分词器(tokenizer)。我们使用HuggingFace的tokenizers Python库训练了一个Byte Pair Encoding (BPE) 模型。BPE使用字节作为词汇表,可以对每个文本进行分词,而无需使用深度学习应用于自然语言处理中常用的未知标记,有助于解决代码的词汇表外问题。

图1:对BERT模型进行的扩展

3 实验评估

3.1 实验设置

研究问题。在本文中,我们研究以下研究问题:

RQ1:BERT模型是否是学习代码自动补全的可行方法?

RQ1.1:掩码数量对预测质量的影响有多大?

RQ1.2:在多大程度上通过抽象源代码来减少词汇量有助于预测任务?

RQ1.3:在多大程度上,模型的性能受到用于训练和测试的数据集的特异性的影响?

RQ2:RoBERTa模型与最先进的n-gram模型相比如何?

构建数据集。为了创建Java数据集,我们使用了Husain等人提供的CodeSearchNet Java数据集。该数据集包含从开源、非fork的GitHub仓库中收集的超过150万个Java方法。对于我们的工作,在构建数据集时的标准是:(1) 排除少于三行的方法;(2) 使用CodeSearchNet的去重算法删除近似重复的方法;(3) 删除名称中包含“test”子字符串的方法,以尝试删除测试方法;同时也删除名称为“toString”的方法。为了构建Android数据集,我们采用了类似的过程。我们从GitHub克隆了AndroidTimeMachine数据集中提供的8,431个开源Android应用。然后,我们从每个项目的最新快照中提取方法列表,总共得到约220万个方法。然后,我们应用了为Java数据集定义的相同过滤启发式方法,最终得到654,224个方法。由于我们研究的目标之一是比较RoBERTa在更通用(Java)和更具体(Android)数据集上应用时的性能,我们将这654,224个方法来与Android数据集大小对齐。我们将在抽象过程中发生解析错误的方法从两个数据集中排除,这样,Java数据集中剩下了634,799个方法,Android数据集中剩下了532,096个方法。对于每个方法,都有原始版本和抽象版本可用。最后,我们创建了每个数据集的三个版本,并对原始代码和抽象代码进行以下的操作:

(1)标记掩码。对于每个具有多个标记的方法中的每行代码 l,我们将其最后 x 个标记进行掩码处理,其中 x 是一个介于 1 到 n - 1 之间的随机数,其中 n 是组成 l 的标记数量。标记掩码的目的是模拟典型的代码补全场景:开发人员开始编写一行代码,工具会推荐如何完成它。对于具有超过一个标记的 k 行的方法 m,我们生成 m 的 k 个版本,每个版本仅有一行的最后 x 个标记被掩码处理。我们设定最大标记数为 10(即,如果 x > 10,则 x = 10)。

(2)结构掩码。我们选择了一些代码结构,这些结构特别适合使用自动代码补全。给定一个方法 m,我们使用 srcML 工具包 来识别所有 m 中用于以下情况的标记:(1)定义 if 语句或 while/for 循环的完整条件(例如,在一个语句中有 for(int i=0; i<data.size(); i++),我们识别括号之间的所有标记作为定义 for 循环的标记);(2)定义方法调用中的参数(例如,在 copyFile(source, target) 中,识别标记 "source"、"," 和 "target");以及 (3) 定义 catch 语句中捕获的异常。对于方法 m,这将产生一个集合 S={T1, T2, . . . , Tn},其中 Ti 代表前述结构中的一组相关标记(例如,Ti 是用于定义 for 循环条件的标记集)。给定 m,我们生成 |S| 个版本,每个版本都掩码一个结构。同样,在这种情况下,我们将最大掩码标记数设定为 10。这意味着如果一个结构需要掩码超过 10 个标记,那么在我们的数据集中就不会对其进行掩码处理。

(3)代码块掩码。使用 srcML 工具包,识别每个方法 m 中的代码块,将代码块定义为两个花括号之间的代码段。然后,对于在方法 m 中识别出的 k 个代码块,我们创建 k 个版本,每个版本都会掩码一个特定的代码块。我们将被掩码的代码块的最大大小设置为两个完整语句。这意味着如果一个代码块由两个以上的语句组成,那么它将不会被掩码处理。这种方法旨在帮助模拟代码补全的情景,使开发人员能够更好地理解和完善代码结构。为了解决研究问题 RQ1.2,我们使用了 Tufano 等人开发的 src2abs 工具, 来生成每个数据集的抽象版本。这个抽象过程旨在减少源代码的词汇量,提供一种表达丰富但受词汇限制的表示形式。例如,方法中的所有变量名都被抽象为 VAR_X,其中 X 表示方法中的变量编号(例如,第一个变量被抽象为 VAR_1,第二个变量被抽象为 VAR_2,依此类推)。语言关键字和标点符号保持不变。

在训练、评估、创建测试集时,我们首先从12个数据集中筛选出特定的实例。在使用生成式深度学习模型时,输入方法长度的变化会影响模型的训练和性能。因此,我们分析了数据集中方法长度的分布,发现其中三分之二的方法由最多100个标记组成。因此,与Tufano等人的做法一样,我们排除了数据集中所有具有超过100个标记的方法。其次,RoBERTa模型无法有效处理掩码标记多于非掩码标记的情况,这些实例也被排除在外。最后,我们再次进行方法去重,只保留每组重复方法中的一个随机实例。虽然这在数据集创建的最初阶段已经完成,但在抽象后,两个方法即使其原始代码不同(例如,它们仅在一个变量名的值上有所不同,但在两种情况下都被抽象为VAR1)也可能相等。

在筛选步骤之后,我们将每个数据集分割为训练集(80%)、评估集(10%)和测试集(10%)。尽管数据集中的方法是随机排序的,但我们进行的分割并不是随机的,以避免引入偏见。例如,我们考虑块级掩码数据集的情况。给定一个包含k个块的方法m,在数据集中我们为m添加了k个版本,每个版本只掩码一个块。假设m包含两个块b1和b2,因此会导致m的两个版本:一个掩码b1(mb1),b2未掩码;另一个掩码b2(mb2),b1未掩码。采用随机分割,可能导致mb1被分配到训练集,mb2被分配到测试集。然而,在mb1中,b2未被掩码。因此,当模型需要猜测mb2中被掩码的标记时,它在训练集中已经知道了答案,从而提高了预测性能。因此,我们取每个数据集中前80%的方法,并将它们所有的掩码版本都分配到训练集。然后,我们以同样的方式处理评估集和测试集。

通过这种方式,我们获得了表1中描述的数据集。需要注意的是,考虑到使用标记级别和结构级别掩码的原始数据集的规模,我们决定将训练集的规模限制在75万个实例(评估集和测试集没有进行更改)。

表1:实验所使用的的数据集相关信息

数据的收集和分析。在获得了十二组训练、评估和测试集之后,我们通过超参数调优确定程序的最佳配置,对十二个 RoBERTa 模型进行了训练和测试。我们在一台装有 Nvidia RTX Titan GPU 的 Linux 服务器上使用 Weights & Biases 的 Python 库进行了超参数调优。调优后的超参数如表2所示。训练是跨服务器进行的,这些服务器分别配备了 Nvidia Tesla V100S、Nvidia RTX Titan 和 3 个 Nvidia GTX 1080Ti。训练时间依赖于数据集的大小和使用的服务器,但每个模型的训练时间在 28 到 114 小时之间。通过在对应的测试集上运行每个训练模型,我们计算以下指标:

(1) BLEU-n 分数:BLEU分数是用于评估自动翻译文本质量的指标。我们使用四种BLEU变体。BLEU-n 变体通过考虑生成文本中的 n 元组来计算 BLEU 分数,BLEU 分数的范围在 0% 到 100% 之间,100% 表示生成的代码与参考代码完全相同。

(2) Levenshtein 距离:可以定义为将预测代码转换为参考代码所需的最小标记编辑次数(插入、删除或替换)

(3) 完美预测百分比:我们使用 McNemar's 检验和 Odds Ratios(ORs)在抽象数据集和原始数据集之间进行统计比较。

我们还分析了在非完美预测情况下发生的情况。我们手动分析了一些非完美预测的样本,以评估它们是否与开发人员编写的原始代码在"语义上等价"。我们从我们拥有的每个测试集中选择了 100 个非完美预测样本,从中随机选取了 25 个,样本的Levenshtein距离分别属于四个不同的范围。对于每个Levenshtein距离区间,我们报告了在每个数据集中找到的语义上等价预测的百分比。我们使用 Wilcoxon 符号秩检验来统计比较抽象数据集和原始数据集的Levenshtein距离和BLEU分数,使用Wilcoxon符号秩检验来比较 Android 和 Java 的结果。

表2:RoBERTa模型的超参数调优

RQ1 定性定量分析代码补全效果

实验设计。我们从定量(如 BLEU 分数、Levenshtein 距离)和定性(如完美预测、错误预测的潜在用处)两个角度评估所生成的预测质量。对于RQ1.1,我们在数据集上训练和测试 RoBERTa 模型,其中掩码的代码标记包括从给定语句中的几个连续标记到组成代码块的多个缺失语句。对于RQ1.2,我们测试了文献中提出的方法对代码进行抽象化是否有助于模型学习。我们比较了应用抽象化和不应用抽象化时的预测性能,而在使用 BPE 时则不需要进行抽象化。对于RQ1.3,我们比较了两个不同数据集上的自动补全性能:第一个是更一般的数据集,由Java方法组成;第二个是更具特定性的数据集,由Android应用程序的方法组成。虽然编程语言相同,但第二个数据集大量使用Android API,很可能相似用途的功能会使用相同的API,我们期望这将在Android数据集中产生“规律性”,有助于模型学习。

结果。图2展示了RoBERTa模型在不同评估场景中实现的完美预测结果。每个子图显示了涉及特定掩码方法的结果,即(从左到右)标记掩码、结构掩码和代码块掩码。每个子图的顶部(即黑色背景)显示了在使用原始源代码或其抽象版本时,在Java和Android数据集上实现的完美预测的总体百分比。例如,在Java数据集上,对原始源代码使用标记掩码时,RoBERTa生成了38%的完美预测。图2中的左侧图表显示了当我们仅掩码最后一个标记(即一个掩码标记)、最后两个标记等时的完美预测百分比。在处理块掩码情景时,x轴上的刻度不同,因为这里我们掩码整个代码块,有时会有几十个掩码标记。每个数据点表示在x-5和x个标记之间的点被掩码,例如,对于第一个数据点,最多掩码了5个标记,对于第二个数据点,则在5到10之间,依此类推。在处理 x = 5时,抽象和原始源代码之间的完美预测差距约为20%,其 OR 分别为 1.35 和 1.69。

对于标记级掩码,红色线(Android)始终高于橙色线(Java),证实了在 Android 数据集上(更具特异性)表现更优越是一种普遍趋势。正如预期的那样,通过抽象化的数据集在 Java 和 Android 数据集上均导致显着更好的性能(McNemar 检验 p-value<0.001,OR 分别为 3.66 和 1.96)。这是由于抽象化带来的较小词汇量和简化预测任务。

对于结构级掩码,RoBERTa 模型证实了对 Android 数据集的显着更好的性能,尽管差距较小(原始数据集为 OR=1.19,抽象数据集为 OR=1.12)。同样,代码抽象化显著有助于预测(Java 为 OR=1.72,Android 为 OR=1.46)。就 BLEU 分数和Levenshtein 距离而言,与标记级掩码相比,实现的数值较差,验证了结构级别掩码所代表的更具挑战性的预测场景。

对于代码块级掩码,抽象数据集的性能较差(Java 为 OR=0.60,Android 为 0.71),而 Android 的结果仅略优于 Java(抽象数据集为 OR=1.18,原始数据集为1.08)。正如预期的那样,在这种情况下,BLEU 分数最低,开发人员可能需要平均修订约55%的预测标记,与感兴趣的数据集和所使用的代码表示形式无关。

图2:RoBERTa模型的预测结果

表3显示了四种考虑变体中的平均BLEU得分和平均归一化Levenshtein距离。同样,在这种情况下,结果根据掩码级别、数据集和代码表示进行分组。

表3:RoBERTa模型的 BLEU分数和Levenshtein距离

RQ2 与n-gram模型的性能比较

实验设计。对于所有数据集,我们将RoBERTa的性能与最先进的n-gram模型的性能进行比较。尽管n-gram模型旨在预测给定其前n个标记的单个标记,但我们试图设计一个公平的比较。因此,在我们掩码多个标记的情况下,我们以以下方式使用n-gram模型:我们运行它以独立预测每个掩码的标记。然后,我们将所有预测组合起来生成最终字符串(即,一组先前掩码的标记)。这些n-gram模型是在与RoBERTa相同的训练集上进行训练的,但没有掩码标记。我们比较这两种方法在测试集上生成的完美预测方面的表现。使用McNemar's测试和OR进行统计比较。

结果。表4为 RoBERTa 模型和 n-gram 模型在完全预测方面的比较。由于这两种模型使用不同的脚本对代码进行标记化,我们在测试集中排除了以下情况:两种方法在要预测的标记(即掩码的标记)在两种方法之间的标记化不同的情况。RoBERTa在所有实验数据集/代码表示中均取得了更好的表现,而McNemar测试始终表明显著差异,OR值介于1.90(对Android原始代码进行代码块级掩码)和18.87(对Android抽象代码进行结构级掩码)之间。在标记掩码方案中,n-gram模型的性能非常出色,尤其是在Java原始代码上,接近RoBERTa的性能水平。当掩码特定结构时,性能差距变得更大,特别是在处理抽象代码时存在实质性差距。

表4:RoBERTa与n-gram完美预测对比

对于测试集中的给定方法mt,我们克隆其存储库,并检查最新系统快照中mt的源代码是否与测试集中的代码完全相同。我们在每个原始测试集上收集了200个数据点,并比较了当提供这些额外信息时n-gram模型在这200个实例上的性能。表5为实现结果。正如预期的那样,n-gram模型的性能得到提高,这要归功于在测试项目中使用的信息。在这200个数据点上,RoBERTa的性能始终优于n-gram模型,只有在Java标记掩码的情况下例外。

表5:测试克隆仓库时n-gram模型的完美预测比例

转述:杨沛然

0 阅读:0

互联不一般哥

简介:感谢大家的关注