从 Siri 到谷歌翻译,深度神经网络大步推动了机器对自然语言的理解。
迄今为止,大多数模型把语言看作是字词的平面序列(flat sequence),使用时间递归神经网络(recurrent neural network)来处理。但语言学家认为,这并不是看待语言的最佳方式,应把其理解为由短语组成的的分层树状结构( hierarchical tree of phrases)。由于对该类结构的支持,大量深度学习研究投入到结构递归神经网络(recursive neural network)之中。在业内,这些模型有非常难以执行、运行起来效率低下的名声。
但对于今年 Facebook 开源的新深度学习框架 PyTorch ,业内人士人认为它的一大贡献是:搭建结构递归神经网络以及其它复杂自然语言处理模型,变得更简便。
结构递归神经网络,是展示 PyTorch 灵活性的一个不错的例子。但同时,PyTorch 是一个对于各类深度学习任务有完备功能的框架,虽然特别适于计算机视觉问题。它诞生于 Facebook AI 研究院 FAIR 的研究人员之手,PyTorch 把 Torch7 中高效、灵活的 GPU 加速后端算法库,和直觉性的 Python 前端结合到一起,并具有快速创建原型机的能力、高度可读性的代码、以及对于各类深度学习模型的支持。
SPINNing Up
本文将带领大家在 PyTorch 上,实现一个有 recurrent tracker 和 TreeLSTM 节点的结构递归神经网络,即 SPINN。对于相当多的主流深度学习框架,这是一个很难搭建的自然语言处理模型。这里我描述的实现部分做了 batch,所以能够利用 GPU 加速的性能,比不用 batch 的版本要快得多。
SPINN 模型的全称是“Stack-augmented Parser-Interpreter Neural Network”,它作为一种解决了自然语言推理任务的方案,随 Bowman et al. (2016) 论文面世,当时使用的是斯坦福的 SNLI 数据集。
这里的任务,是把成对的语句分类为三组类别:假设第一个句子是对某幅用户无法看到的图像的准确注解,第二个句子同样是对该图像的注解,那么第二句话到底是 (a) 绝对准确 (b) 可能准确还是 (c) 绝对不准确的?举个例子,假设第一句话是 “two dogs are running through a field”(两条狗穿过一片农田)。那么,让这组语句“绝对正确”的句子可以是““there are animals outdoors”(户外有动物);让它们“可能准确”的,可以是“some puppies are running to catch a stick”(一群幼犬跑着去接一个木棒);让它们“绝对不准确”的可以是 “the pets are sitting on a couch”(宠物们坐在沙发上)。
导致 SPINN 诞生的研究,为实现其目标要在决定句子之间的关系之前,把每句话编码为固定长度的矢量表达(还有其他方式,比如注意力模型)。
数据集包含机器生成的语法树( syntactic parse trees),后者把每句话里的词组合为短语和子句,每一个都有独立涵义,并且有两个词或 sub-phrases 组成。许多语言学家认为,人类理解语言,是通过把涵义以层级(hierarchical)方式组合起来,就像这样的树状结构。因此,创建一个以同样方式运作的神经网络或许是必要的。下面的例子是一个数据集里的句子,它的语法树以括号结构表示:
( ( The church ) ( ( has ( cracks ( in ( the ceiling ) ) ) ) . ) )
用支持语法树结构的神经网络对这个句子编码,方法之一是创建一个神经网络层 Reduce,把词组(以 GloVe 这样的 word embedding 来表示)或短语组合起来,然后将这一层循环应用,把上一个 Reduce 操作的结果,作为句子的编码:
X = Reduce(“the”, “ceiling”) Y = Reduce(“in”, X) ... etc.
但如果,我想让神经网络以更“人性化”的方式运作呢?能从左到右阅读,保持语境,同时使用语法树把短语组合起来?或者,如果我想要训练一个神经网络,让它在看到这句子时,基于读到的词语创建它自己的语法树?这是一个同样的语法树,只是写出来的方式稍稍有区别:
The church ) has cracks in the ceiling ) ) ) ) . ) )
第三种方法,仍然是一回事:
WORDS: The church has cracks in the ceiling . PARSES: S S R S S S S S R R R R S R R
我所做的,仅仅是去除括号,用“S” 代表“shift”来标记词语, 并用“R”代表“reduce”替代右括号。现在,信息可作为操作堆栈(stack) 和类似堆栈的 buffer 的一系列指令从左读到右,与上文描述的循环方式有同样的结果:
将文本导入 buffer。
从 buffer 的首词“The” pop 出去,把 push 入栈,这时“The”应该在“church”前面。
Pop 最上面的两个堆栈值,应用 Reduce,把结果 push 入栈。
从 buffer pop 出“has”,push 到入栈,随后 “cracks”,再“in”,再“the”,再“ceiling”。
重复四次:pop 最前面的两个堆栈值,应用 Reduce,push 结果。
从缓存 pop 出“.”再 push 入栈。
重复两次:pop 最前面的两个堆栈值,应用 Reduce,push 结果。
Pop 剩下的堆栈值,作为句子编码返回。
我还想维持语境,照顾到其他信息——句子中系统已读取的部分,并在句子的之后部分上进行 Reduce 操作。所以,我将把两个参数(two-argument)的 Reduce 函数,用三个参数的函数来替代,后者导入左子树、右子树短语以及当前语境状态。该状态由第二个神经网络层生成——一个名为 Tracker 的循环单位。给定现有句子语境状态,Tracker 在堆栈操作的每一步生成一个新状态(读取每个词语和右括号之后),buffer 最顶端的 entry b 和堆栈中最顶端的两个 entries s1、s2:
context[t+1] = Tracker(context[t], b, s1, s2)
可以想象一下用你最喜欢的编程语言写这些东西:对于需要处理的每个句子,它会从 buffer 中加载下一个词,运行 Tracker,检查是否要 push 入栈或者进行 Reduce,操作后不断重复,直到整个句子处理完毕。当应用在单个句子上面,这个过程由大且复杂的深度神经网络运行,网络上的两个可训练层一遍遍按照 stack manipulation 规定的方式执行。
但如果你对 TensorFlow、Theano 等传统深度学习框架很熟悉,你就知道执行这类动态过程有多么费劲。这值得我们花点时间多想想,为什么它们处理这种任务力不从心,以及 PyTorch 是否能提供不一样的东西。
图理论
本质上,深度神经网路只是有海量参数的复杂函数。深度学习的目标也仅仅是通过计算 partial derivatives(梯度)、衡量损失来优化这些参数。如果该函数以计算图结构来表示,反着运行能去除计算梯度的不必要工作。所有现代深度学习框架都是基于这一反向传播概念,作为结果,每个框架都需要找到一种方式来表示计算图。
大多数的主流深度学习框架,比如 TensorFlow、Theano、Keras 以及 Torch7 的 nngraph 算法库,它们的计算图都是事先创建好的静态物体。该图由看起来像是数学表达的代码来定义,但它的变量其实是还没有赋予任何数值的占位符(placeholder)。由占位符标量组成的图,编译为一个函数,然后重复在训练数据 batch 上运行,生成输出和梯度。
这种静态计算图在 CNN 上的效果很好,后者的结构一般是固定的。但对于许多应用,开发者需要让神经网络图的机构能随数据修改。在自然语言处理中,研究人员通常希望把时间递归神经网络展开,最好输入有多少词,就有多少时间步(timestep)。上文提到的 SPINN 模型的 stack manipulation,非常倚赖控制流,比如“for”和“if”statement,来定义某个特定句子的计算图结构。在更复杂的例子里,你也许想要搭建结构取决于子网络输出的模型。
有的想法可以被硬塞进静态图系统,但不是全部 ,而且几乎总是以更糟的透明度、看不懂的代码作为代价。框架需要给计算图添加代表了编程基本指令(loops and conditionals)的特殊节点,用户需要学习、使用这些节点,而不是程序语言中的 “for”和“if” statement。这是因为任何程序员使用的控制流 statement 均只能使用一次,在创建图时写死(hard coding)一条计算通道。
比如说,词语(从初始状态 h0 开始)中的矢量上,运行一个时间递归神经网络(rnn_unit)需要 tf.while_loop,一个特殊的控制流节点。在 TensorFlow 运行时获取词语长度需要一个额外特殊节点,这是由于代码运行的时候它只是一个占位符。
# TensorFlow # (this code runs once, during model initialization) # “words” is not a real list (it’s a placeholder variable) so # I can’t use “len” cond = lambda i, h: i < tf.shape(words)[0] cell = lambda i, h: rnn_unit(words[i], h) i = 0 _, h = tf.while_loop(cond, cell, (i, h0))
一个在根本上与之区别的方式,是动态计算图,这在几十年前的学界就已展开研究,又被称为“define-by-run”。哈佛大学研发出来的 Kayak 、autograd,以及研究导向的框架 Chainer and DyNet 都基于动态计算图。在这样的框架中,计算图在运行时才被创建出来或重新创建。进行前馈通道运算的代码,也为反向传播创建所需的数据结构。该方式生成更直观的代码,因为控制流使用标准的“for”和“if”来写。修补漏洞也变得更简单,因为运行时的断点、堆栈踪迹(stack trace)让你直接找到写的代码,而不是执行引擎里的编译函数。同样变量长度的时间递归神经网络,可用简单的 Python “for”循环在动态框架里实现。
# PyTorch (also works in Chainer) # (this code runs on every forward pass of the model) # “words” is a Python list with actual values in it h = h0 for word in words: h = rnn_unit(word, h)
PyTorch 是第一个在性能、灵活性上媲美静态图框架的 “define-by-run”深度学习框架。这使它适合于开发几乎所有模型,从标准的卷积网络到最离谱的强化学习想法。下篇中,我们将一起看看 SPINN 的代码实现。