命名实体识别学习-用lstm+crf处理conll03数据集
[TOC]
一直想写的一篇文章,虽然好像也不是很忙,但是一直拖着没做。就是讲下面两篇文章介绍的数据集和算法做一个整合
命名实体识别学习-从基础算法开始(02)lstm+crf序列标注
一 整合时要解决的问题
要为数据和模型读入设计合理的数据结构:即vocab-vecterizer-dataset这个pipeline,几乎所有的nlp任务都要走这个pipeline的模式(看别人的源码发现,真正实现这些数据结构时代码五花八门,不过数据结构本来就是ADT的物理实现,只要把核心功能实现就好了。)
原有的算法是一句一句读入的,我实现的时候要用mini-batch, mini-batch已经被证明了其在深度学习应用的作用和功能。不过应用mini-batch要考虑输入句子长短不一的问题,使用pad和mask的技术,尽量避免模型看到pad的元素。本模型主要有三处用到,第一是lstm读入时,然后是crf的算句子得分,和loss计算的时候。在这三处要用mask的方法避免模型读到pad的元素。
原代码,即pytorch官网上放的教程为了使代码便于理解,使用了很多for循环,不利于cuda对代码的加速,尽量将能够变为矩阵运算的for循环变为矩阵的计算。
要解决的三个问题:数据结构,pad和mask,for循环改为矩阵计算
。还有一个使代码可以在GPU上运行。(不过我自己最近没法找到卡,所以代码都是凭感觉debug的,不过这次代码已经在学弟卡上跑过了)
二 mask和pad
变长序列的处理是深度学习框架的一大难题,各个框架都有比较成熟的解决问题,
lstm读入
其中pytorch为RNN的读入专门做了处理。所以对于lstm读入时处理就很简单,只需简单调用torch.nn.utils.rnn.pack_padded_sequence()和torch.nn.utils.rnn.pad_packed_sequence即可:
1 | def _get_lstm_features(self, embedded_vec, seq_len): |
注意:使用这两个自带函数有个问题,并不能恢复百分百恢复原来的输入,他恢复后的句长是输入最长句子的长度,也就是说如果你输入时最长句子也有一定长度的pad元素,那样是没办法恢复的。
涉及转移分矩阵的计算
第二处mask是在转移分的计算,因为self.transitions给pad元素留了位置,代码如下:
1 | self.transition = nn.Parameter( |
这其实不符合我们尽量避免模型看到pad元素的原则(我尝试不在transition里给pad留位置,但是由于 变长序列总会有pad元素,如果没有pad元素的位置,索引就会报错。)这里我使用折中处理,在涉及到转移分矩阵的运算并直接关联结果的都mask掉,(其实存在于矩阵里无所谓,只要最后计算不影响结果即可。
涉及到转移分的计算,主要是loss的计算,在官网文档里:
1 | def neg_log_likelihood(self, sentence, tags): |
其中,gold_score的计算和forward_score的计算都需要mask机制。
首先是得到mask:
1 | mask = (token_vec != self.token_vocab.lookup_token(self.pad)).to(self.device) # [b_s, max_seq_len] |
这个token_vec就是句子向量,mask是一个布尔值向量,其中不等于pad的位置为true,等于pad的位置为false。
1 | def _score_sentence(self, feats, tags): |
这个gold_score的代码相对简单:逻辑就是把真实tag对应的转移分和发射分相加,(其实这里的for循环可以去掉换成矩阵运算)因为feats中每个句子(即每个时间步)都参与一次计算,且可能有pad元素,对mask的处理:
1 | total_score = (score * mask.type(torch.float)).sum(dim=1) |
forward_score出的mask的处理,官网关于foward_score的计算比较长,就不放了,简述下逻辑,forward_score的计算本质上就是前向算法,前向算法就是DP。(在前面的博客里介绍的比较详细)在每个时间步里求前向变量,而我们用了mini-batch那么一个时间步计算的就不是一个token了。而是一批token,而由于一个mini-batch里句子是不等长的,可能一个句子还没结束,其他句子就已经运算的pad元素了,所以要用mask机制避免pad元素参与到运算中。
1 | for feat_index in range(1, feats.size(1)): |
这里是直接算出非pad元素的个数,因为我们的输入是按句长排列的,所以可以直接取前n_finish个进行计算。
三 将for循环改为矩阵运算
原官网代码为了代码可读性,使用的了几处for循环,是可以用矩阵运算代替的,同时代码里的前向算法和维特比解码算法是动态规划算法,可能是不适合改为矩阵运算(不过也不一定,可能有大神实现了)
哪些可以改为for循环?
这个问题我也没有查到,我的理解就是看能不能并行,能并行的话大部分都可以用矩阵运算(这是句废话),当然如何分析能不能并行,这个还有待后续查资料继续学习。
gold_score的计算
在计算neg_log_likelihood时需要计算gold_score,其代码如下:
1 | def _score_sentence(self, feats, tags): |
但看这段代码,因为score计算依赖上一个时间步的score,乍一看似乎不能无法更改(不能因为依赖上一步的结果就认为是个dp算法,关键看有没有最优子结构),不过稍加分析,其实这个是最容易改为矩阵计算的,证明如下:
1 | score0=transition[tags[1], tags[0]]+feat[tags[1]] |
从上面的分析,很容易将这段代码改为矩阵计算,同时加上mask机制。最终代码如下:
1 | def _score_sentence(self, feats, tags, mask): |
forward_score的计算
原官网的代码:
1 | def _forward_alg(self, feats): |
下面简单分析下,假设tag_size=2,值集合为:{0,1}
1 | emit_score=feat[0].expand(1, self.tagset_size) #经过扩展emit_score变为shape为[1,2]的矩阵,因为pytorch的广播机制,其实就是[feat[0], feat[0]] |
加上batch所处的维度,刚好是三维张量的计算,是一个比较习惯处理的维度,再加去掉for循环,加高维度没有必要。最终结果:
1 | def _forward_alg(self, feats, mask): |
维特比解码算法的修改思路和前向算法大致一致,修改后的代码如下:
1 | def _viterbi_decode(self, feats, mask, seq_len): |
整个工程代码地址:https://github.com/SStarLib/NERfromBasic/tree/master/Day03minibatch-lstm%2Bcrfs/conll03Ner
结果:
![image-20200719003342138](/Users/wei/Library/Application Support/typora-user-images/image-20200719003342138.png)
这个是加入了预训练embedding的结果,比不加稍微好一点。不过好的有限。甚至有的地方还要更差。
总结
整个工程代码花费了接近一天的时间,写代码的时候出现了各种奇奇怪怪的bug,有的是变量命名差个s,结果写的时候没有注意出错,好不容易调通了,结果发现loss一直不变化,我还以为是我的代码实现的有问题,结果把代码改的面目全非,甚至很多地方已经背离了我最初的思路了,几乎是把整个工程推翻重写,最后debug时,发现lstm生成的feats几乎不变化,才开始意识到模型根本没起作用,最终发现是因为没有对模型参数初始化的原因,(真的是太久不写pytorch的代码了,连模型参数初始化都先不起来)虽然最后整个工程项目成功了,但是花费了大量时间,真的,写出bug free的工程代码是一个人素质的体现!
关于加入了预训练embedding的结果不显著的问题,可能是glove,只有小写字母,而字母的大小写本身就是实体的一个重要特征,使用了glove反而可能丢失了这个重要特征,解决办法是加入char embedding ,下次可以在模型中加入,或者一步到位,使用Elmo的embedding,
尽量要使用 mini batch
for循环改为矩阵计算,提高并行性,提升运算速度很重要,一般常用的都是三维张量,包含一维batch,所以尽量改成三维张量的运算为好。
pad和mask机制的使用,有空可以专门写一篇文章,介绍这个,并总结一下。
pipeline的数据结构,有空了再介绍吧,不难,主要是要明确要实现的功能,并抽象出来,然后用代码实现即可。
一直想要系统性的学一下pytorch,可是到现在连本相关的书都没买过,(甚至30分钟的官网视频也没看),甚至不知道pytorch能做啥不能做啥。所以我的coding过程非常痛苦,使用google的频率非常高。pytorch的api,几乎要不停的查。磨刀不误砍柴工
,真的应该系统性的学习下这个框架,然后再想着复现论文。
比赛的时候一定不会用pytorch,相比keras的搭积木似的coding过程,pytorch的开发周期确实有点长了,甚至要一直照顾shape,所以我在我的代码里要一直备注shape的变化,和广播机制将要怎么发生。
要好好了解下pytorch的广播机制,这个是代码可读性的天敌,不好好了解,有时候无法看懂别人的代码。