卷积神经网络(CNN)在NLP中的应用

纵观机器学习或者深度学习算法,都没有某一个算法只适用于某一个领域,大多数在某个领域取得显著成果的算法,稍加修改后在别的领域依旧能够取得非常不错的结果。我们知道卷积神经网络(CNN)广泛用于计算机视觉中,大约从2014年开始ImageNet参赛队伍提交的模型基本都是基于CNN的。在卷积神经网络在图像领域取得巨大成果后,有研究人员开始在自然语言处理领域开始使用卷积神经网络,早期的研究是在句子分类任务上做的,基于CNN的模型取得了非常显著的效果,这也表明了CNN同样适用于NLP领域的一些问题【PS,附上一个彩蛋,这里有一个用Theano实现的CNN模型GRU-or-CNN(觉得不错记得加星哟),可以对两个有关系的句子进行建模】。同样的,我们知道NLP中最常见的深度学习模型是循环神经网络(RNN),这是一个对序列进行建模的模型,因而这一模型在语音处理领域也有广泛的应用,其实也有人尝试过在图像处理领域使用RNN模型,比如这篇论文《ReNet: A Recurrent Neural Network Based Alternative to Convolutional Networks》。由此可见,CNN、RNN这些深度学习算法并没有领域之分,同样传统的机器学习算法也一样,比如最常见的分类模型SVM,只要是分类任务基本都可以用SVM,无论是在图像还是自然语言领域。

下面我们就来看一些卷积神经网络在NLP中的应用以及所取得的显著效果:


  • 《A Convolutional Neural Network for Modelling Sentences》,这篇论文应该算是比较早期在NLP任务中运用卷积神经网络的了,这是ACL2014年的一篇论文。论文中提到CNN模型的一个优势就是不依赖于parse树,并且很容易适用于任何语言。这篇论文的一个创新点在于动态pooling技术,即在做max pooling的时候不是只取最大值,而是取k-max的值。这里插入一句,关于CNN中max pooling的一些介绍,请参考这篇博客《自然语言处理中CNN模型几种常见的Max Pooling操作》,这篇博客对常见几种max pooling方法都有很详细的介绍。对于这篇论文,还有一点需要注意的,就是论文中的模型是针对词向量的每一维进行卷积的,而后续的其他模型一般都是对整个词向量进行卷积的(即卷积窗口的长度为词向量维度),这一点也可以从如下模型示意图中看出,
    CNN_NLP1

  • 《Convolutional Neural Networks for Sentence Classification》,这是EMNLP2014的一篇论文。论文中基于CNN的模型和上一篇论文中的CNN不太一样,第一个是并没有采用动态pooling的技术,第二卷积窗口的长度为词向量的维度,这两点都可以从模型示意图中看出来,
    CNN_NLP2
    具体的操作为,假如卷积窗口宽度为m,那么取m个连续的词,将他们对应的词向量连接在一起得到一个[latex]m*d[/latex]维的向量[latex]\mathbf{x}_{i:i+m-1}[/latex]([latex]d[/latex]表示词向量维度)。然后向量[latex]\mathbf{x}_{i:i+m-1}[/latex]与卷积核w相乘(w也是一个向量),[latex]c_i=f(\mathbf{w}{\cdot}\mathbf{x}_{i:i+m-1}+b)[/latex],窗口滑动得到[latex]\mathbf{c}=[c_1,c_2,…,c_{n-m+1}][/latex],再对[latex]\mathbf{c}[/latex]做max pooling得到一个值,假设现在又K个卷积核,那么最后得到K维的向量。很显然,这里做pooling操作的目前就是处理不同长度的句子,使得无论句子长度为多少,卷积核宽度是多少,最终到得到定长的向量表示,同时max pooling也是capture最重要的特征信息。作者在7个数据集上与14个模型进行了比较,这7个数据集全是句子分类任务(包括电影reviews,question分类,以及MP3的reviews)。通过大量的实验证明了CNN模型适用于多种任务,而且效果非常显著,相比于传统方法不用进行繁琐的特征工程而且也不需要parse树。这篇论文还给出了一个非常好的结论,模型输入预先训练好的词向量比随机初始化词向量效果要好很多,目前大家使用deep learning都会输入预先训练好的词向量。

  • 《Document Modeling with Gated Recurrent Neural Network for Sentiment Classification》,这是EMNLP2015的一篇论文,论文主要是对文本进行建模,然后分析文本的情感极性。作者采用了两层神经网络来学习文本的表示,即先用CNN来学习句子的表示,在用一个双向的RNN来学习文本的表示,最后一个softmax层得到情感极性,模型结构如下图,
    CNN_NLP3
    模型中从词到句子表示的CNN模型采用的第二篇论文中的CNN模型,而且作者分别设置窗口宽度为1、2、3,这正好类似于uni-grams,bi-grams,和tri-grams。

  • 《Answer Sequence Learning with Neural Networks for Answer Selection in Community Question Answering》,这篇论文同样同时使用了CNN和RNN,不过是对不同的内容进行建模的。论文所正对的任务是answer selection,对于一个question,有很多answers作为候选,模型需要选出匹配question的answer,同时该论文所针对的answers还有一个特定是这些answers之间存在对话关系(即关于该question的一个讨论,比如一些论坛,同一个问题下有很多回答,而这些回答存在一个回复对话关系)。作者将这一任务看出是一个序列标注任务,即对所有answers打标签。具体算法步骤为,先用CNN学习question和一个answer之间的联合表示,然后将这个表示作为RNN的输入,学习answers之间的序列关系,RNN输出每个answers的标签(good,bad,和potential)。


说了这么多,大家对CNN模型也有了一个大致的认识,下面通过一些代码来更加直观的理解CNN,代码用theano实现。(代码来自CNNModel.py,略有改动)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class DocmentEncoder():
def init_params(self):
""" sent weights """
self.Filter1 = add_to_params(self.params, theano.shared(value=NormalInit(self.rng, self.rankdim, self.qdim_encoder), name='Filter1'))
self.Filter2 = add_to_params(self.params, theano.shared(value=NormalInit(self.rng, 2*self.rankdim, self.qdim_encoder), name='Filter2'))
self.Filter3 = add_to_params(self.params, theano.shared(value=NormalInit(self.rng, 3*self.rankdim, self.qdim_encoder), name='Filter3'))
self.b_1 = add_to_params(self.params, theano.shared(value=np.zeros((self.qdim_encoder,), dtype='float32'), name='cnn_b1'))
self.b_2 = add_to_params(self.params, theano.shared(value=np.zeros((self.qdim_encoder,), dtype='float32'), name='cnn_b2'))
self.b_3 = add_to_params(self.params, theano.shared(value=np.zeros((self.qdim_encoder,), dtype='float32'), name='cnn_b3'))
def ConvLayer1(self, q1):
output = T.dot(q1, self.Filter1) + self.b_1
return output
def ConvLayer2(self, q1, q2):
output = T.dot(T.concatenate([q1, q2], axis=1), self.Filter2) + self.b_2
return output
def ConvLayer3(self, q1, q2, q3):
output = T.dot(T.concatenate([q1, q2, q3], axis=1), self.Filter3) + self.b_3
return output
def Convolution(self, xe):
_res1, _ = theano.scan(self.ConvLayer1, sequences=[xe])
_res2, _ = theano.scan(self.ConvLayer2, sequences=[xe[:-1], xe[1:]])
_res3, _ = theano.scan(self.ConvLayer3, sequences=[xe[:-2],xe[1:-1],xe[2:]])
hidden1 = T.tanh(T.max(_res1,axis=0))
hidden2 = T.tanh(T.max(_res2,axis=0))
hidden3 = T.tanh(T.max(_res3,axis=0))
return T.mean(T.concatenate([hidden1, hidden2, hidden3], axis=0), axis=0)
#return (hidden1 + hidden2 + hidden3)/3.0
def __init__(self):
self.rankdim = dim
self.qdim_encoder = qdim_encoder
self.name = 'CNN'
self.params = []
self.rng = np.random.RandomState(23455)
self.init_params()

简单解读一下这段代码,24行的Convolution函数是这个类的核心函数,输入的xe变量需要是一个三维tensor,每一个元素都是一个单行矩阵,而且需要包含至少3个元素。ConvLayer1,ConvLayer2,和ConvLayer3本别对应窗口宽度为1,2,3的卷积操作。这里需要重点提一下33行的代码,T.mean(T.concatenate([hidden1, hidden2, hidden3], axis=0), axis=0),其实这个操作就对hidden1,hidden2,hidden3求平均,按理说直接采用被注释掉的34行的写法是最简单的,但是如果采用34行的写法,在求梯度时会报错(错误信息大意是在构建图的时候会引入环,“This substitution would insert a cycle in the graph”)。所以不得已采用了33行这种复杂的写法,具体为什么会出现这个原因我至今还没有弄清楚,但这至少告诉了我们一点,就是尽可能用theano的函数来实现一些计算,因为这些函数的具体求导形式或构建网络图theano肯定是定义好了的,所以不容易出错。顺带我们再分析一下输出的维度吧,首先xe是一个3维tensor,所以每次传给ConvLayer的是一个矩阵,同时ConvLayer返回的也是一个矩阵,因此_res1将是一个3维tensor,然后T.max操作会降一维,得到的hidden1是一个矩阵,最后的T.mean再降一维,所以返回的将是一个向量(只有一维)。

以上内容全是我个人观点和总结,如有错误欢迎指正和讨论。