这是关系抽取的一篇经典文章Classifying Relations by Ranking with Convolutional Neural Networks。使用的是SemEval2010 Task 8 数据集,共有9种二元关系,每种关系对实体的前后排列敏感,加上other分类一共19个小分类。
论文的主要是在Zeng 2014的CNN基础上做的改进,最大的变化是损失函数,不再使用softmax+cross-entropy的方式,而是margin based的ranking-loss。还有一个对于other分类的特殊处理。
先看网络结构,不考虑loss的改变,实际上的网络结构没有什么变化,同样是一层CNN加上一层全连接:
全连接层之后没有使用常用的softmax+cross entropy,而是自定义了一个基于margin的ranking loss:
在这里有两个分数, s θ ( x ) y + s_{\theta}(x)_{y^{+}} sθ(x)y+指的是句子x正确分类对应的score,而 s θ ( x ) c − s_{\theta}(x)_{c^{-}} sθ(x)c−指的是从全连接层得到的分数向量中除去 s θ ( x ) y + s_{\theta}(x)_{y^{+}} sθ(x)y+之外最大的分量,也就是分数最大的错误得分。这样做在训练的过程中会使得 s θ ( x ) y + s_{\theta}(x)_{y^{+}} sθ(x)y+不断变大而 s θ ( x ) c − s_{\theta}(x)_{c^{-}} sθ(x)c−不断变小。其中 m + m^{+} m+与 m − m^{-} m−表示正确与错误的margin。
Other类别的特殊处理:因为Other类别比较杂,因此对其特殊处理。在计算loss的时候如果样本的label是Other,那么就将其Loss的第一项归零。而计算最终结果时只有当Other外类别的score都小于0时才归为Other类,否则选择其它score中最大一项对应的分类。
明确我们需要的输入为每个句子的word embedding以及每个词与目标的两个实体之间的positional embedding。
举个例子:
每一个数据有三行,第一行是文本,第二行是关系label,第三行是注释。word embedding就是将每个词都转换成对应的词向量,这里可以用开源的glove等。而positional embedding是指每个词距离两个entity之间的距离映射而成的embedding。比如上图第二句中的wrapped,距离前一个实体child距离为3,距离后一个实体距离为-5,而positional embedding是用这两个数字映射得到的embedding。假如每个词的词向量维度是 d w d_{w} dw,每个距离对应的向量维度是 d p d_{p} dp,那么整合之后每个词语对应的embedding维度为 d w + 2 ∗ d p d_{w}+2*d_{p} dw+2∗dp。其中positional embedding的初始化为随机初始化。
文本为英文,使用spacy库中的分词工具来处理英文分词(主要是标点和缩写等等)。然后计算出每个词到两个实体的距离,分别存为CSV格式的训练集验证集与测试集。具体的代码在github的data_util.py文件中。
这样每个CSV中文件的格式如下:
有三列,代表label,原始句子,句子中每个词对应的位置向量。
之后需要把每个输入变成sent_len * ( d w + 2 ∗ d p d_{w}+2*d_{p} dw+2∗dp)维度的矩阵,sent_len表示的是句子的长度。这里由于每个句子的长度不同,因此统一句子的长度为90个词。不足的部分用’ < p a d > <pad> <pad>'替代,长的部分舍去。因此词到实体的距离的取值就在[-89,89]之间。
其中位置的表示已经用数字表示出来了,只需要在模型中加一个embedding层进行lookup即可。而句子需要分词后建立词表,然后将每个词对应成词典中的序号。这里使用了torchtext库直接完成,这部分代码如下:
def tokenizer(text): # create a tokenizer function
# 返回 a list of
return [tok.text for tok in nlp.tokenizer(text)]
def emb_tokenizer(l):
r = [y for x in eval(l) for y in x]
return r
TEXT = data.Field(sequential=True, tokenize=tokenizer,fix_length=args.sent_len)
LABEL = data.Field(sequential=False, unk_token=None)
POS_EMB = data.Field(sequential=True,unk_token=0,tokenize=emb_tokenizer,use_vocab=False,pad_token=0,fix_length=2*args.sent_len)
print('loading data...')
train,valid,test = data.TabularDataset.splits(path='../data/SemEval2010_task8_all_data',
train='SemEval2010_task8_training/TRAIN_FILE_SUB.CSV',
validation='SemEval2010_task8_training/VALID_FILE.CSV',
test='SemEval2010_task8_testing_keys/TEST_FILE_FULL.CSV',
format='csv',
skip_header=True,csv_reader_params={'delimiter':'\t'},
fields=[('relation',LABEL),('sentence',TEXT),('pos_embed',POS_EMB)])
TEXT.build_vocab(train,vectors='glove.6B.300d')
LABEL.build_vocab(train)
train_iter, val_iter, test_iter = data.Iterator.splits((train,valid,test),
batch_sizes=(args.batch_size,len(valid),len(test)),
device=args.device,
sort_key=lambda x: len(x.sentence),
# sort_within_batch=False,
repeat=False)
torchtext库的使用可以参考官方文档以及这个博客还有知乎文章。总的来说这个库能够完成分词、去停用词、建立词表映射、设定预训练的词向量等诸多操作,而且最后能够以iterator的形式给出处理完的结果,很是方便。
其实也没得说的,具体还是要看代码。
可以先实现成正常的cnn+softmax+cross entropy的形式,然后在这个基础上进行修改。主要的修改是加入了positional embedding,需要与原本的word embedding合并以后再进行卷积的操作。正常的cnn实现可以参考前一篇文章pytorch 实现textCNN。也可以直接看我的github。
实际上改进的部分在于Loss的计算,因此在cnn结构的部分基本没有变化,主要的变化是Loss的设计,这里遇到了不少坑,还是直接看代码吧。
还有一个是对于Other类别的特殊处理。在Loss的计算里已经加上了,不过在判断结果的时候也会有一个特殊处理。在train.py的getResult函数里。
除此之外还有个要注意的点就是虽然实际上有19个(9*2+1)分类,但实际上在计算F值的时候不计算Other类,同时将剩下的18类当做实体前后不敏感的9类计算。如下图所示,cnn模型计算score时当做18类,而计算accuracy和F值时候当做9类。
才疏学浅,还以为将Loss表示出来pytorch就能完成自动求导。我毕竟还是too young。可以参考知乎问题pytorch如何自定义loss?首先就是继承自nn.Module,然后是必须让计算图能够连起来,不然没法计算梯度。
写完loss之后可以自行测试一下,看看计算的梯度是否与理论相符:
比如这个基于margin的ranking loss,与每个样本score向量中的一个或两个分量有关系(与最大的score对应的label是否是other有关),可以观察对应的梯度是否相符。
就是不计算other以及把18类归为9类计算的事情。不好好看论文的结果。
再看一眼loss的定义:
随着训练过程的持续,正确分类的score不断变大,错误分类的score不断变小。经过exponential操作以后就会导致溢出。
报的错误是cuda runtime error (59) : device-side assert triggered at /pytorch/aten/src/THC/generic/THCTensorCopy.c:70类似的,而且根本定位不了。
搜来搜去都说大致是因为可能是数组下标越界,然而找了一两天才发现并不是,而是因为溢出。
我的解决方法是修改它的loss:
原来的loss为: L = l o g ( 1 + e x p ( γ ( m + − s θ ( x ) y + ) ) + l o g ( 1 + e x p ( γ ( m − − s θ ( x ) c − ) ) L=log(1+exp(\gamma(m^{+}-s_{\theta}(x)_{y^{+}}))+log(1+exp(\gamma(m^{-}-s_{\theta}(x)_{c^{-}})) L=log(1+exp(γ(m+−sθ(x)y+))+log(1+exp(γ(m−−sθ(x)c−)),我修改后的Loss为
L = l o g ( 1 + e x p ( γ ( m + − s θ ( x ) y + ) ) + l o g ( 1 + e x p ( γ ( − 100 + s θ ( x ) y + ) ) + l o g ( 1 + e x p ( γ ( m − − s θ ( x ) c − ) ) + l o g ( 1 + e x p ( γ ( − 100 − s θ ( x ) c − ) ) L=log(1+exp(\gamma(m^{+}-s_{\theta}(x)_{y^{+}}))+log(1+exp(\gamma(-100+s_{\theta}(x)_{y^{+}}))+log(1+exp(\gamma(m^{-}-s_{\theta}(x)_{c^{-}}))+log(1+exp(\gamma(-100-s_{\theta}(x)_{c^{-}})) L=log(1+exp(γ(m+−sθ(x)y+))+log(1+exp(γ(−100+sθ(x)y+))+log(1+exp(γ(m−−sθ(x)c−))+log(1+exp(γ(−100−sθ(x)c−))
这个Loss大致能够保证正确label的score不超过100,错误label的score不超过-100。不过我比较想知道不修改loss的话应该怎么避免这个溢出的问题。
拼了老命调参最后的结果F值只有77左右,距离文中的84还相去甚远,感觉可能不全是调参的问题,暂时先这样吧。
训练集上非other类别的准确率已经接近100%了,所以应该不是模型表现力不够的问题,那就是过拟合了。不过L2系数加了一点之后就开始学不到东西了。dropout已经加到0.7,再加结果也会变得更差。在embedding层后面以及卷积层后面都加了dropout了。
分析一下可能导致效果不好的原因:
最后感谢github某老哥的tensorflow版本的实现。