icon

新闻 资讯

News and information

语言模型驱动的软件工具思考:可解释与可溯源

发布时间:2024-11-02

  语言模型在软件开发的应用与挑战。


  语言模型正在变革软件开发流程的各个环节,包括代码的生成、编辑、测试、调试等。在开发和训练代码语言模型时,人们需要统一的收集清理数据、训练模型、更新调整等。因此,我们预期,针对模型训练的分析技术将成为新的一层架构来回答“模型是如何产生某个预测的”、“模型预测是如何逐渐训练得到的”、以及“我们应该怎么做去修改和增强某个预测”等问题。


  在今年 8 月份举办的 AICon 全球人工智能开发与应用大会上,上海交通大学计算机系副教授林云做了专题演讲分享“语言模型驱动的软件工具思考:可解释与可溯源”,深入探讨了如何分析模型、追溯训练样本,并构建数字孪生环境来测试代码编辑模型,最后展望了未来大模型对软件开发范式的影响。


  以下是演讲实录(经 InfoQ 进行不改变原意的编辑整理)


  非常荣幸能够在这里与大家分享我们团队的最新研究成果。我们一直在探索如何利用语言模型来生成代码,并深入理解这些模型背后的原理。目前,语言模型在软件工程领域的应用日益广泛,已经逐步介入到设计、编程、测试和调试等多个环节。我们的研究团队致力于将语言模型融入这些环节中。


  在语言模型出现之前,我们已经有了传统的代码编辑的技术,但语言模型的介入使得编辑过程变得更加智能化,我们称之为“生成式编辑”。它能够辅助我们完成整个代码栈的工作。接下来,我会介绍我们与字节跳动合作的一个项目,该项目旨在自动定位代码编辑的位置,并在特定行生成所需的编辑内容。


  在语言模型生成代码之前,我们也在解决测试用例生成的问题。按照传统方式,我们会将测试用例的生成视为一个约束求解问题,关注如何实现分支覆盖和路径覆盖。但语言模型的出现让我们开始思考,我们是否可以实现需求覆盖,即不仅仅覆盖 特定的分支,而是结合需求和分支,生成更符合项目特点的测试用例。


  此外,我们也在探索如何让语言模型自动调试代码。过去,开发者常常自嘲说,自己写的 bug 含泪也要修复完。但现在,也许我们要含着泪修复 AI 帮我们写的 bug.AI 时代的代码调试问题也许是一个新的挑战。因此,我们也希望有新的智能化技术能够帮助开发者发现并修复 bug.在这项工作中,我们的目标是将调试问题转化为在代码执行轨迹上找到第一个出错的步骤,然后让语言模型在这个轨迹上通过交互不断定位错误,并指导开发者了解错误是如何发生的。


  训练软件工程语言模型的“套路” 


  当我们深入研究语言模型在软件工程中的应用时,我们逐渐发现了一个反复出现的模式,或者称之为“套路”。在这个套路中,我们是这么做的。首先,我们需要收集和清洗来自 Git、JIRA、Jenkins 等软件工具的数据,将它们转换成训练数据集。这些数据集随后被用来训练代码模型,最终这些模型被集成到集成开发环境(IDE)中。


  无论是进行测试生成、调试、代码生成还是测试用例生成,我们通常会遵循这个方式。但随着时间的推移,我们意识到,尽管这个套路在业界得到了广泛应用,但在实际应用中却并不简单。例如,当我们训练出一个模型后,我们首先想知道的是,模型为什么会做出这样的预测。毕竟,模型本质上是将大量的数据集压缩编码到代码中,然后利用其泛化能力进行各种生成任务。


  那模型的预测是如何产生的?我们知道,模型并非一蹴而就,而是经过数小时甚至数天的训练,经过多次迭代才得到的。因此,我们想要了解模型预测的具体生成过程。最终,我们希望能够提出一些方案,自动矫正模型中不符合我们期望的行为。


  上述套路解决的是"AI for SE",即我们提出了 AI 解决方案来帮助程序员完成任务。但随着 AI 解决方案的增多,我们发现需要一个"SE for AI for SE"的基础框架,以支持和管理这些 AI 解决方案。



  案例研究: 交互式代码编辑 (CoEdPilot) 


  在具体介绍上述框架解决思路前,我想先跟大家介绍下我们与字节跳动合作的一个研究案例,这个案例恰恰符合我们之前讨论的“套路”。我们称这个过程为“编代码、编辑定位”。在现代代码仓库中,编写代码并不总像 Copilot 那样,给出一个注释后自动生成十几行代码。更多的时候,我们面临的是编辑任务:根据需求修改某一行代码,删除一行,或者更改一行中的几个字符串。这种编辑往往是跨文件的,一次编辑可能会影响到多个文件。


  在我们的案例中,我们首先关注的是编辑定位问题。当出现一个需求或者一个编辑请求时,我们希望能够迅速定位这个编辑在整个项目中如何传播。接下来,我们想要解决的是编辑生成问题。一旦我们知道某一行需要修改,我们就想进一步推荐出这一行具体应该改成什么样子。我们希望通过人机交互来实现这一点,利用人的反馈来进一步推荐下一轮的编辑定位和编辑生成。


  我们的工作目前集中在开发一个 Visual Studio Code 插件上,这个插件旨在帮助用户根据输入的需求自动定位代码修改的位置。用户一开始会输入需求,插件会生成一个定位提示,显示整个文件中可能需要修改的地方。在这个提示中,红色标记代表可能需要修改的地方,而绿色标记则表示可能需要添加内容的位置。


  当用户选择某个特定的位置后,插件会通过一个差异比较(DIFF)视图来展示这一行代码可能的修改方式。用户可以从多个选项中选择。一旦用户接受了某些建议或者拒绝了某些建议,这些反馈就会被收集起来,作为新一轮输入和迭代的数据。


  这个插件的核心思想在于,我们通过收集代码提交的信息来训练模型。每个提交通常包含多个代码修改,这些修改也被一并收集。通过训练,模型能够在整个项目中滑动窗口,识别出需要修改的地方,并推荐出具体的修改内容。



  代码编辑的基本设计思路 


  我们的基本设计思路是将代码编辑任务分解为几个小模型来实现,避免直接将整个代码库喂给一个大模型,这样做的原因主要是为了减轻模型的计算负担,包含两个核心部分:任务分解和矫正反馈。


  首先,任务分解的目标是将一个大模型拆分成几个小模型,这样可以减少模型的输入量。例如,输入 1 万行代码与输入 30 行代码的效果是有很大差异的。我们使用三到四个小模型来完成这个任务。


  其次,我们希望通过与用户的交互来实现矫正反馈。具体来说,我们首先使用一个小模型,通过滑动窗口来预测文件中可能需要修改的位置。核心思想是比较两段代码的语义相似度和依赖关系,以判断它们是否会产生协同变化。在得到这些信息后,我们使用另一个小模型,将问题转化为一个分类问题。给定一个滑动窗口,窗口中有多行代码,我们根据之前的编辑来预测每一行可能发生的编辑类型。这样,我们不需要处理一个很大的窗口,只需要对每一行进行分类即可。训练模式采用的是指令微调,即给定一个指令(如替换或保留),然后让模型预测每一行的编辑类型。得到编辑类型后,我们使用另一个基于 Transformer 的编码器 - 解码器模型来生成具体的内容。当我们确定某一行需要添加或替换时,就让这个 Transformer 生成相应的内容。这样,我们就大大减少了活动窗口的大小。


  最后,我们使用另一个模型来学习之前的编辑,将之前的编辑作为 Transformer 输入和反馈设计的一部分。通过这种方式,我们在定位的准确性和生成内容的准确性上都达到了一个可接受的程度。


  哪些训练数据影响了这次预测? 


  当我们构建并训练了代码模型后,我们希望它能够自动定位代码编辑的需求,并最终集成到 IDE 中。然而,我们发现在某些情况下,模型的表现并没有达到我们的预期。为了解决这个问题,我们首先需要进行训练归因分析,以了解为什么模型会做出特定的预测。


  我们想要回答的核心问题是:为什么模型认为某行代码需要修改,或者需要插入代码?为了解决这个问题,我们从三个角度进行思考:样本归因、表征归因和仿真验证。


  归因问题在机器学习领域是一个经典问题。我们想要了解的是,哪些训练数据真正影响了模型的预测。当我们面对一个严格的数学问题陈述时,我们可以这样表述问题:给定一个训练样本 Zi,如果我们对这个样本进行权重调整(增加或减少 ϵ),模型会发生什么变化?因为模型是在看到数据后才进行神经元调整的,所以我们想要了解哪些预测相关的神经元是由哪些数据调整的。


  在数学层面上,这个问题可以通过一个公式来描述。我们有一个测试集 _X_test 和一个训练集 _X_train.我们想要了解 _X_train 和 _X_test 之间的关系。如果我们发现 _X_train 和 _X_test 的值是一个大的正数,这意味着如果我们更多地训练 _X_train 这个样本,模型在预测 _X_test 这个样本时的表现会变得更好。相反,如果 _X_train 和 _X_test 的值是一个大的负数,比如说 -0.9,这意味着如果我们更多地训练 _X_train 这个样本,_X_test 这个测试样本的预测会变得更糟,说明这两个样本之间存在矛盾。如果 _X_train 和 _X_test 的影响因素是 0,那就意味着无论我们增加还是减少对 _X_train 的训练,对 _X_test 的预测都没有影响。



  要理解模型预测的影响关系,我们可以从理论上推导出三个决定性因素。首先,模型对测试样本 _X_test 的拟合程度会影响其预测。每个测试样本都有其损失函数和标签,模型在拟合这些样本时会朝某个方向移动,这个方向反映了参数空间的调整。


  其次,模型对训练样本 _X_train 的拟合方向也是一个重要因素。如果模型在拟合 _X_test 和 _X_train 时方向一致,那么它们之间会有正向影响;如果方向相反,则会产生负向影响;如果方向的夹角为零,则它们之间没有影响。


  最后,Hessian 矩阵及其逆矩阵代表了所有样本之间的交互效应。Hessian 矩阵是损失函数对所有参数的二阶导数的矩阵,其逆矩阵反映了样本间的相互作用。然而,计算 Hessian 矩阵的逆在实际中是非常困难的,尤其是当模型参数达到百万或千万级别时。为了解决这个问题,我们提出了一种改进的想法,即通过多次变异模型来模拟 Hessian 矩阵的效果。我们可以通过在参数空间上进行抽样来模拟 Hessian 矩阵,观察模型在多次变异后对训练样本和测试样本的影响。如果变异后的模型在训练样本和测试样本上都显示出对抗性或正相关 / 负相关的影响,那么我们就可以认为它们之间存在相互影响。


  通过这种技术,我们发现模型预测中的一些问题并不总是源于模型架构,而是可能源自训练数据集本身。例如,在开源数据集上运行模型时,我们可能会发现模型的某些错误预测实际上可以归因于训练数据的标注问题。例如,在服装分类任务中,开源数据集可能会将非常相似的服装款式标注为不同的类别,而人类观察者可能会认为这些款式是相近的。这种令人困惑的标注会影响模型预测的性能。为此我们设计了新的影响函数在很多开源数据集上找到了很多标注 bug, 并发表在了 NeurIPS’22 的会议论文《Debugging and Explaining Metric Learning Approaches: An Influence FunctionBased Perspective》上。



  将影响函数应用于代码编辑生成任务 


  我们将影响函数应用于代码编辑生成任务中,以评估每个预测背后的有益和有害训练样本。有益的训练样本是指那些通过增加训练量可以提升特定测试样本表现的样本,而有害样本则是指增加训练量会降低某些测试样本表现的样本。我们发现,对于任何一个测试样本,有害样本和有益样本的数量通常都非常少。


  通过这种方式,我们可以发现模型预测的具体影响。例如,当我们的模型预测需要将代码中的版本号从 0.01 更改为 0.02 时,使用影响函数进行归因分析,我们可以看到与数字变动相关的训练样本,这与模型的表征空间是相关的。


  在函数调用中添加参数时,模型应该定位到代码窗口中的某一行,并预测需要替换的行以添加类似的参数。对于这样的测试样本,模型的预测和归因分析将揭示出形状相似的代码标注,指出在语法上需要添加子节点。这种归因分析有助于我们理解哪些训练样本对预测有重大贡献,从而发现可能存在的标注问题。例如,我们可能会发现原本认为相似的代码样本实际上在语义上有很大差异,这表明我们的标注可能存在问题,或者标注的语义不够丰富。


  此外,在代码编辑中,commit message 的质量非常重要。相似的 commit 或者过长的 commit 可能会导致信息量减少,从而形成打架效应。这意味着,为了提高代码编辑的质量,我们需要确保 commit message 的书写质量非常高,避免使用过于冗长或含糊不清的描述。


  我们觉得未来可能会有好几个方向可以尝试,第一是通过影响函数,可以帮助我们去做数据分析,判断到底哪些是脏数据,或者说非预期的训练数据产生了坏的影响。第二个是当产生坏的影响之后,有可能我们需要对整个数据进行重标注,所以我们也在尝试在训练过程当中动态地去更新某一些标注,因为我们永远不能保证人标的东西就一定是对的,或者说预期的标注就是我们想要的。最后是想去观测,如果有些训练样本有非常高的互影响的话,就意味着整个训练数据集有可能是冗余的。


  我们大量地在收集数据集,但是数据集过大真的是件好事吗?对此我们其实也是存疑的,我们有没有可能利用一个小但质量非常高的数据集产出一样的效果?这对模型训练效率的影响其实是非常大的。


  表征归因 


  在讨论完样本归因之后,我们来谈谈表征归因。表征归因是深度学习的核心,因为深度学习本质上是表征学习。无论是处理图像、声音还是文本,深度学习的目标是将这些输入转换成向量,然后进行矩阵运算。


  以文本为例,深度学习模型需要将每个单词映射到向量空间中。在这个空间里,语义相近的词汇(如“男孩”和“女孩”)的表征应该彼此接近,而语义相距较远的词汇(如“猫”和“狗”)的表征则应该相距较远。在自然语言处理(NLP)中,我们希望模型能够通过单词的 embedding 来捕捉这种语义关系。


  如果我们能够训练模型,使其对每个样本或单词的表征具有这样的语义效果,那么模型就能逐渐发展出接近人类的预测能力,从而能够进行更自然的交流。然而,我们面临的一个主要挑战是,真实的表征空间可能是 512 维、1024 维或 768 维,而人类很难直观理解高维空间中的变化。模型训练初期,样本的表征通常是随机分布在高维空间中的。随着训练的进行,这些表征会逐渐变化,最终形成一种分布,反映出人类的理解能力。我们可以将模型训练过程视为样本表征在高维空间中的运动。一开始,这些表征是无序的,但最终会形成一个有结构的分布。我们希望能够在二维空间中帮助人们理解这些表征是如何变化的,例如,猫和狗的表征是否真的接近。这将能为提供巨大的信息量,帮助我们更好地理解和改进模型。


  

在过去的工作中,我们的目标是将模型的训练过程可视化。模型训练本质上是样本表征在高维空间中的变化过程,但由于这些维度通常是数百甚至数千维,这使得直观理解变得困难。因此,我们希望能够将这一过程投影到二维空间,使人们能够直观地看到,例如,两只猫的样本表征如何逐渐靠近,而猫和狗的样本表征如何逐渐远离。将训练过程转化为二维动画后,我们不仅可以观察到模型在表征空间中的运动,而且还可以与动画进行交互和分析。


  在模型训练过程中,我们通过可视化技术观察到了一个有趣的现象,即干净数据和噪音数据在表征空间中的运动轨迹存在显著差异。例如,在某个训练阶段,我们可以将橘黄色的点视为干净数据,而黑色的点代表噪音数据。


  如果我们观察到最后一个训练阶段,比如模型学习"apple"这个词汇时,会发现无论是干净数据还是噪音数据,模型最终都能达到很高的准确度。然而,它们在训练过程中的运动轨迹却大相径庭。干净数据在经过一两次训练迭代后,很快就能定位到它应该在的区域。相比之下,噪音数据则表现得像“钉子户”,在初始位置上停留很长时间,直到训练的后期,由于模型内部的某种“拉力”作用,它们才最终被拉回到适当的位置。


  这种现象不仅揭示了噪音数据在训练过程中的顽固性,也为我们提供了一种新的思路,即如何在训练过程中有效地去除噪音。通过观察数据在表征空间中的运动,我们可以识别出那些不易被模型正确学习的噪音样本,并采取相应措施。


  回到代码任务本身,我们注意到基于检索的生成(RAG)是一个非常热门的领域。在这种情况下,检索能力变得至关重要。在这个语义空间中,我们可以观察到代码表征的分布情况,同样也可以观察到代码描述的表征分布。这种映射允许我们在给定一个自然语言描述时,在整个语义空间中搜索与其最接近的代码表征。这样,与描述最相关的代码就可以被检索出来。


  基本上,这是一种在高维空间中进行代码检索的方法。通过这种方式,我们可以根据代码的自然语言描述快速找到相应的代码实现,从而提高代码检索的效率和准确性。这种方法利用了深度学习模型的能力,将文本描述和代码映射到同一个高维空间,使得相关代码的检索变得更加直接和有效。


  高层语义编辑距离 


  在深入研究模型训练过程中的表征时,我们有时会发现模型可能只是学习到了表面现象,而并没有真正理解人类所理解的概念。例如,当我们探讨高层语义编辑距离时,可以通过比较两个序列或字符串来观察这一点。


  我们可以将字符串进行匹配,就像在本科课程中学到的字符串匹配算法那样。这种方法也可以应用于代码,因为代码中的每个 token 也都有一个高维的语义表征向量。例如,return 这个词在代码中会有一个语义表示,我们可以计算两个 return 之间的语义相似度,从而判断它们在语义上是否大致相似。


  通过这种方式,我们可以对整篇代码进行理解。如果我们使用像 CodeBERT 这样的模型来训练代码,使用表征距离或高维空间的语义表征来对齐两篇代码。但是,在训练的初期,代码可以被正确对齐,但在训练的后期,模型可能会将 version download 这个词与 if 的表征关联得最近,而将 data 的表征与 return 的表征关联得更近。


  这种现象表明,尽管模型似乎学习到了预测代码和描述之间相似性的能力,但它的理解仍然与人类的理解存在较大差距。这提示我们在模型训练和评估时,需要更加关注模型是否真正理解了代码的语义,而不仅仅是表面形式上的相似性。



  通过深入分析表征,我们意识到在模型训练过程中需要加强代码和描述之间的对齐能力。目前,我们主要采用对比学习的方法来训练模型,但为了进一步提升模型的性能,我们计划在训练中加入更多的对齐机制。

  

  仿真验证(数字孪生)

  

  这部分我们想讨论的是一种称为仿真验证的技术,也就是数字孪生。在模型训练完成后,我们经常会遇到模型的评估指标,如准确率、召回率和 F1 分数等,看起来非常高的情况。这些数字并不总能代表模型在实际应用中能显著提升程序员的工作效率。有时候,即使模型的 BLEU 分数只差一点点,程序员可能仍需花费大量时间进行调整。另一方面,即使 BLEU 分数差异很大,也不一定意味着模型的预测结果不对。这是一个非常微妙的问题。为了解决这个问题,我们提出了数字孪生验证技术。

  

  在我们与字节跳动的合作中,我们进行了用户实验,让学生实际使用我们的工具进行编码。我们发现,即使在学术环境中,验证模型的预测是否真正有用是一项工作量非常庞大的工作。因此,我们希望通过代码提交,即编辑历史的一个结果,来恢复过去的开发过程。

  

  我们称这个项目为“Historian”,就像考古学家通过文物来还原历史一样,我们希望通过已知的代码提交来恢复程序员过去的代码编辑过程。在这个过程中,我们需要解决一些问题,例如两个编辑之间可能存在的偏序关系,确定哪个编辑先发生,哪个后发生。通过恢复整个代码编辑的开发过程,我们可以在这个过程中引入模型,并观察在什么情况下模型真正有助于提升生产力,或者是否实际上在拖累开发。我们需要评估模型的表现是否真的有助于提高效率,或者它是否与不使用模型时的表现相当。

  

  基本思路:从提交历史重现“当年的”开发过程

  

  在我们的工作中,我们建立了一个复杂的工作流程,旨在通过提交历史来重现程序员当年的开发过程。这个流程的出发点是确定在何种程度的 BLEU 分数下,模型应该采取下一步行动。我们的目标是利用历史记录来创建一个虚拟的程序员,这个虚拟的程序员能够基于单个提交(commit)恢复出多种可能的编辑过程。在这些编辑过程中,我们的模型将被引入。

  

  我们允许对这个虚拟程序员的行为进行配置,例如:在检查推荐时需要花费多少时间?如果推荐错误,他将被延误多长时间?如果推荐正确,他将花费多少时间进行审查?我们会根据不同情况来设定这些参数。


 

在这个过程中,我们会模拟实际的编辑场景。例如,如果我们输入一个描述并产生编辑,这个过程可能需要 77 秒,这包括了第一次编辑、加载语言模型的时间(因为模型不是凭空产生的),以及推荐编辑位置所需的时间。如果我们的推荐正确,我们将计算产生的延迟;如果错误,我们将计算延误的时间。我们还会模拟用户检测推荐所需的时间。通过这样的模拟,我们可以与正常的编辑过程进行比较,以确定模型是在帮助用户还是影响用户。


  通过这种方式,我们基本上可以观察到,当模型被应用于实际的开发过程时,所有的性能指标,如准确率和召回率,实际上都会出现一定程度的下降。这是因为在现实世界中,模型的表现受到多种因素的影响,包括与人类用户的交互。



  这个就是我们的 SE for (AI for SE)框架,旨在探索和改进人工智能在软件工程中的应用。在这个框架中,我们预见到未来业界将越来越多地采用这种模式。程序员的工作方式正在发生变化,他们不再只是调用和开发 API 或修改第三方库,而是可能会需要收集训练数据来微调模型,就像调整第三方库一样。模型本质上是一种特殊的第三方库,程序员在未来可能需要学习如何编写更有效的提示(prompt)来与这些模型交互。这可能会形成新的工作模式。随着这些新工作流程的出现,我们面临着如何进一步提升和赋权这些模式的问题。目前的模型是概率模型,每次输出可能并不稳定,同时还需要解决模型输出的幻觉问题。


  为了解决这些问题,我们尝试提出了一些方法。例如,样本归因可以帮助我们追溯并理解对特定预测产生贡献的训练样本。通过分析学习后的样本表征,我们可以在表征空间上进行更深入的交互式分析。


  我们还提出了一个仿真验证过程,也就是数字孪生的概念。通过创建一个虚拟的程序员来进行编辑操作,我们可以模拟实际的开发过程,并观察模型在其中的作用。我们希望这种虚拟仿真的方法能够帮助程序员或大型企业验证模型的实际效用。如果我们想在生产环境中引入一个新模型,我们需要说服生产团队这个模型确实能够带来产能增值。通过数字孪生技术,我们可以模拟模型在实际开发过程中的表现,从而预估它可能带来的效益。


  展望:AI 原生的软件工程实践 


  随着人工智能时代的到来,软件工程的实践将发生根本性变化。过去,编程主要是为了交付软件产品。但在 AI 时代,编程不仅仅是为了交付,它还具有数据标注的意义。我们编写的每一行代码、提交的每一个 commit、撰写的每一个需求,都可能被用来训练模型。这意味着代码编辑和整个编辑过程实际上在无形中完成了数据的标注工作。


  由于模型训练对数据质量有很高的要求,我们预见未来将出现一种 AI 原生的软件工程实践。我们将利用现有的数据来训练模型,然后评估这些模型是否符合我们的预期。有了新模型后,我们可以反向工作,利用模型预测的好坏来评估过去的编程实践是否合适。这个过程类似于梯度下降,从模型预测到生产过程或代码标注的反向优化。我们可以通过模型的性能和对数据质量的分析,反过来指导整个开发实践,告诉我们何时应该如何编写代码、如何记录代码历史,或者如何提出问题。


  以前,我们通常依据一些软性指标来推荐最佳实践,未来我们将有更硬性的理由来证明为何要这样编写代码。因为这样做可以使模型训练得更好。通过这种方式,我们可以不断调整实践,形成一个 AI 原生的软件工程范式,最终推动整个过程的自动化。


本文来源:36氪

文章转载于其他网络,如有侵权请联系我们及时删除!