题目: Learning to Represent Programs with Graphs
作者: Miltiadis Allamanis, Marc Brockschmidt, Mahmoud Khademi
单位: Microsoft Research, Simon Fraser University
出版: ICLR 2018
解决的问题
以往将深度学习与代码表达结合的工作更多只是抓住了代码浅层的文本结构信息。这样的模型错失了抓住代码丰富语义的机会。在这篇文章中我们通过增加两种信息在一定程度上弥补了这一损失:数据流和类型层级。我们将程序编码成图,图的边代表语法关系(前/后token)以及语义关系(上次在这里使用的变量,参数的形参叫做stream,等)。直接将这些语义作为结构化的机器学习模型输入能够减少对训练数据量的要求。
我们通过两个任务来说明方法的有效性,一是变量命名任务——给出一些源码,正确的变量名能够以一些子token的形式被推断出来。二是变量误用预测——网络用来推断哪个变量应该在程序的某个位置使用。
方法
门控图神经网络(Li et al. 2015)
门控图神经网络中的图
是由一系列结点
,结点特征
,以及一个有向边集合
组成,其中K是边的类型数量。我们将每个结点
的特征表示为一个实值向量
,也就是对结点字符串标签的一个编码。
我们又将每个结点
与一个状态向量
对应,用
来初始化。状态向量和特征向量维度相同。为在图中传播信息,类型为k的消息可以通过每个结点v传播给它的邻居,消息是利用状态向量计算的,即
。这里的
可以是任意函数。我们选择了一个线性的函数。通过在同一时间计算所有边的消息,所有的状态都同时被更新。特别地,结点v的新状态是通过聚集所有进入的消息来计算的。
其中g是一个聚合函数。给出了聚合后的消息和当前状态向量,下一时刻的状态 ,GRU是门控循环单元。上面提到的过程会重复固定的时间步,然后我们将最后一步得到的状态向量当作结点的向量表示。
程序图
我们用程序图来表示token之间的语法和语义关系。程序图的骨架是程序的抽象语法树,由语法结点和语法token组成,分别是程序中的非终端部分和终端部分。我们将语法结点标注为程序语法中非终端部分的名字,而语法token标记为它们所表示的字符串。我们利用Child边来连接结点。由于这样并没有对一个语法结点的孩子排序,我们又添加了一个NextToken边来连接每个语法token和它的兄弟。下图是一个例子:
为抓住控制流和数据流,我们为连接不同的与变量相关的语法token的应用和更新添加了额外的边。对于一个token v,令
为此变量上次可能被使用的语法token集合。这一集合可能包含多个结点(例如在条件语句后变量在两个分支都有使用),甚至对于循环来说会包含它后面的token。类似地,用
表示变量上次写入的语法token。利用这些,我们添加了LastRead边和LastWrite边。并且,无论何时我们观察到赋值语句 v = expr,我们将v用ComputedFrom边连接到所有expr中出现过的变量token。下面是一个例子:
这张图是下面代码的数据流边,其中红色点线代表LastUse边,绿色线代表LastWrite边,红色虚线代表ComputedFrom边:
(x1, y2) = Foo();
while (x3 > 0)
x4 = x5 + y6
我们继续扩展这个图,通过LastLexicalUse边来连接对所有相同变量的使用。例如在if(...) {... v...} else {... v...}
中,我们把两个v连接起来。我们也把return token用ReturnsTo边连接到方法的声明上。受Rice等的启发,我们用FormalArgName将方法调用里的实参连接到它们所对应的形参,也就是说我们看到一个方法调用Foo(bar)
和方法声明Foo(InputStream stream)
时,我们将token bar
连接到token stream
。最后,我们将所有与一个变量有关的token用边GuardedBy和GuardedByNegation连接为闭合的保护表达式。例如,对于if(x > y) {...x...} else {...y...}
,我们从x到与x > y
相关的AST结点添加一条GuardedBy边,从y到与x > y
相关的AST结点添加一条GuardedByNegation边。
最后,对于所有类型的边我们引入它们的后向边(相当于转置了图的邻接矩阵),把边的数量和种类都加倍。后向边有助于在GGNN中快速传播信息。
利用变量类型信息
我们假设有一种静态类型的语言,并且源码可以被编译,因此所有变量都有已知的类型 ,为利用这一信息,我们为已知类型定义一个可学习的映射函数 ,对所有未知的类型增加一个“UNKTYPE”。对于那些面向对象的语言, 我们也会利用它们丰富的层级结构。对于面向对象语言,我们将一个变量的类型 映射到它的父类,也就是说 ,然后我们计算这种表示的表达:对于变量v,我们从 集合中选择最大的 作为它的表达。这是一种普通的最大池化思想。在训练期间,我们随机选择一个 的非空子集。这样的做法类似于dropout,使我们能够对所有的类型都学到好的表示。
初始化结点表示
我们将token的文本表示与它的类型相结合来计算结点的初始状态。具体地,我们利用驼峰表示和帕斯卡表示法将结点名字切分成subtoken,然后将每个subtoken的表示取平均来得到结点名字的表示。最后,我们把之前学习到的类型表示 与结点名字的表示连接起来,然后通过一个线性层来得到图中每个结点的最初表示。
针对VARNAMING的程序图
给出一个程序和一个变量v,我们像上面那样构建一个程序图,并且将所有与v相关的变量名替换成一个 token。为预测这个变量名,我们利用初始结点标签来运行GGNN 8 个时间步,计算出所有 token表示的平均值作为变量的表示。这个表示就被用作一个单层GRU的初始状态,以subtoken的形式预测目标名字。我们用极大似然来训练这种graph2seq结构。
针对VARMISUSE的程序图
为VARMISUSE建模我们需要修改这个图结构。首先,为了计算槽t的上下文表示 ,我们在t的位置插入一个新的结点 ,当作这里有一个“洞”,然后将它与的剩下部分用除LastUse, LastWrite, LastLexicalUse和GuardedBy边之外的所有边相连。然后,为了计算目标槽的每个候选变量的表示 ,我们为所有