关注微信公众号:NLP分享汇。【喜欢的扫波关注,每天都在更新自己之前的积累】
文章链接:https://mp.weixin.qq.com/s/aKB6j42bC1MnWCFIEyjwQQ
【前言】
本文旨在介绍如何利用MRC(阅读理解)方式做事件抽取任务,并在文章第二部分详细介绍模型训练步骤以及训练过程中出现的BUG问题及解决方案。
本文是基于百度2020语言与智能技术竞赛:事件抽取任务。关于事件抽取原理、该任务的数据介绍以及获取方式、评价标准,可见文章:基于百度2020语言与智能技术竞赛:事件抽取任务
竞赛官网:http://lic2020.cipsc.org.cn/
GitHub:https://github.com/qiufengyuyi/event_extraction
理解难度:★★★★★
【本文内容较多,但介绍非常详细,耐心看完必有收获啦。代码或数据可私信作者获取 】
一、如何使用MRC的方式来做这个事件抽取任务?
整个工作主要分为三个步骤:
-
步骤一:事件类型抽取
使用MRC做事件抽取任务,很难同时把事件类型抽取和论元抽取都处理好,因此我选择了pipeline的模式,先单独将事件类型抽取任务转化为一个多标签分类任务,使用模型得到每个文本包含的事件类型,然后将结果输出到论元抽取中。
-
步骤二:事件论元抽取
使用MRC的方式进行论元抽取,包括MRC问题的构建、模型的构建以及span的预测。
-
步骤三:优化事件论元抽取模块
针对步骤2中出现的unanswerable问题进行处理。
步骤一:事件类型抽取
这块内容其实做了简化,你并不需要去抽取事件类型的触发词,只需要知道包含哪些事件类型就可以了。(当然,有些同学会用到事件触发词来帮助做论元抽取,那么还是要做触发词抽取的)因此,我可以将这个子任务转化为一个多标签分类任务。当前一共有65个事件类型,因此可以转化为65个多标签分类任务。
借鉴了NL2SQL中预测SQL语句中包含哪些column的方法,将65个事件分别用一个unused的标签包围,然后拼接到原文本后面,具体如下:
[CLS]泰安今早发生2.9级地震!靠近这个国家森林公园[SEP][unused1]地震[unused2][unused1]死亡[unused2]....
然后就是用BERT类的模型对整合后的文本进行encode,同时将所有[unused1]标签位置的tensor提取出来拼接成一个[batch_size*65*hidden_size]的矩阵,最后连接一个分类层,并同样使用sigmoid_cross_entropy_with_logits 的损失函数进行训练。模型结构图可查看图1。
【要注意事件文本拼接顺序和我们预测的事件index要一致。】
该做法与传统做法还是有所区别的:
-
传统做法:将文本用预训练模型(bert-style)encode之后,使用其pooled-output连接一个分类层,然后使用sigmoid_cross_entropy_with_logits 的损失函数进行训练。最后得到每个事件类型被该文本包含的一个概率。最后根据一个概率阈值,来判断该文本包含哪些事件。
-
上述做法有一个问题:仅仅使用了原始文本的信息,但是忽略了事件类型文本的标签信息。因为在这个任务上,我们的标签是有一定的语义信息的。例如“胜负”和“晋级”两个事件都可能指向某个队伍在某个比赛中胜利,因此两个事件同时存在的可能性会比较高。如果将事件本身的语义信息也整合到模型中,会提升模型对标签的理解程度。
步骤二:事件论元抽取
使用MRC方式来做事件论元抽取任务。在此模块中,我们会接入已经预测出来的事件类型(训练时直接用ground-truth类型),根据不同事件类型中包含的论元类型来构建MRC样本。
【插曲】
-
将MRC应用到序列标注任务中,主要思想就是构建阅读理解问题,然后将该问题与原始文本passage进行一定的处理,然后用当前比较主流的MRC模型来做span抽取类型的阅读理解任务。因此,query问题的质量关系到整个任务的完成情况。这块就需要一定的人工规则了。
[1] MRC问题构建
将事件类型与论元类型进行整合后,能够得到217条不同的label形态。我们可以将这个217条不同的label形态视作我们要做序列标注的217种不同的标签,之后就是针对每种标签构建一个适用于该标签的问题。在对这217种标签进行分析后,我将这些标签大致分为四类:
-
通用性的标签
比如所有事件类型中的时间、人数、人物对象等论元(事件角色)都是具有一定通用性的,这种标签即使跟不同的事件类型进行整合后,表达的含义基本相同。因此这类论元对应的query基本都不用变化,只需要在query之前增加事件类型字符串以示区分:
获奖-时间:找到获奖事件发生的时间,包含年、月、日、天、周、时、分、秒等
求婚-时间:找到求婚事件发生的时间,包含年、月、日、天、周、时、分、秒等
-
事件强相关的标签
这类标签通常与具体的事件类型有一定的关联,例如晋级-晋级方,罚款-执法机构等。这类标签的query可能需要提到事件类型的某些属性:
罚款-执法机构:拥有相对独立的法律地位和组织结构的行政机构
-
无法生成query的标签
这类标签是由于实在无法给予其较为合适的问题,因此只是单纯保留其原始的论元类型描述,与事件类型整合,例如涨停-涨停股票等:
涨停-涨停股票:涨停-涨停股票
-
最后一种标签是我在最后对问题生成做了一步优化后得到的一些特殊例子
在对我的baseline进行了初步分析后,我发现我的模型对于数字类的问题回答得较差,例如回答死亡人数时,将年龄答案预测为了该答案。这种错误可以理解为数字类的回答通常都比较短小,且由数字和某个计量单位组成,因此模型很容易将其混淆。而我原始的问题生成时,对于大部分的数字问题都使用了原始的论元描述,需要针对数字问题专门设计问题,例如袭击-死亡人数:
袭击-死亡人数:袭击导致了多少人死亡?通常以人数为计量单位。
设计完问题模板后,就可以对每个文本进行MRC样本的生成,代码如下:
for event_type in event_type_list:
complete_slot_str = event_type + "-" + role_type
slot_id = self.labels_map.get(complete_slot_str)
query_str = self.query_map.get(slot_id)
event_type_str = event_type.split("-")[-1]
if query_str.__contains__("?"):
query_str_final = query_str
if query_str == role_type:
query_str_final = "找到{}事件中的{}".format(event_type_str, role_type)
elif role_type == "时间":
query_str_final = "找到{}{}".format(event_type_str, query_str)
else:
query_str_final = "找到{}事件中的{},包括{}".format(event_type_str, role_type, query_str)
return query_str_final
MRC方式做论元抽取可以间接得增加数据量。假设一个文本包含n个不同事件类型,每个事件类型平均包含m个可提取内容的论元,那么一个文本可以扩充n*m倍,相当于做了数据增强。
[2] MRC模型构建及span的预测
使用bert类模型做span抽取类型的MRC任务通常能够得到很可观的效果。传统的一些sota方法通常需要分别对query和passage进行encode,然后设计各种attention方法(q-p and p-qattention等等)将query和passage之间的语义关系充分捕捉,并在passage中获取最终的答案位置。而Bert类方法通常将query和passage拼接在一起,然后统一使用bert类模型进行encode,最后,将整个sequence的tensor输出,预测sequence上每个位置上的结果,根据预测的标签类别可以分为两种做法:
-
方法一:每个位置视作一个多分类任务,即使用传统序列标注任务中的BIO(或者其他标注方式)方式,将待抽取span的起始字符标位B,剩余部分标为I,其余非span部分标为O。
-
方法二:每个位置视作两个二分类任务,分别是预测该位置是否为span起始位置以及该位置是否为span结束位置。
上述两个方法对于该任务来说基本上效果差不多。由于有些事件类型的论元抽取时,一个论元类型可能会有两个不同的论元内容,例如:离婚这个事件类型中,离婚双方这个论元就必须包含两个不同的人名对象。转化为MRC任务后,相当于是一个multi-answers问题。此时对于第一种标记方式就不需要修改任何东西,直接当做一个多分类序列标注任务就可以;然而对于第二种任务,就需要在predict的时候注意多个start位置、多个end位置以及start和end的对应映射处理。同时也需要注意start位置和end位置相同的情况也是存在的。(详见代码中的event_predict.py)。
步骤三:优化事件论元抽取模块
Retro-Reader提升unanswerable问题效果
1、对前述的模型预测得到的结果进行了error analysis后发现模型存在着一些问题,即在之前构建MRC任务数据时,除了构建了文本中包含的论元类型query样本外,还人为添加了一些不被包含的论元类型的negative sample。比如地震类型的事件,其包含的论元类型包括:
{"role": "时间"}, {"role": "死亡人数"}, {"role": "震级"}, {"role": "震源深度"}, {"role": "震中"}, {"role": "受伤人数”}
2、但不是所有文本都会包含所有论元,比如测试集的第一个例子:7岁男孩地震遇难,父亲等来噩耗失声痛哭,网友:希望不再有伤亡。该例子中,关于地震的论元似乎一个都不存在。在训练集中构建这样的例子时,会添加这样的no answer query添加到训练集中,其label均为0(表示非span)。
3、然而,如此构建样本会导致一些问题。当negative的样本构建过多时,在测试集上的recall分数会非常低,相反precision分数会相对较高;而当把所有negative样本都删除时,测试集上的recall分数会比较高,但是precision分数会急剧下降。当然,两者的f1分数提升都非常有限。对于上述测试集中的例子来说,模型会提取诸如 死亡人数:7岁,震级:7岁等结果。
4、显然,单纯通过增减negative sample数量是无法解决上述问题的。因此,经过相当长一段时间的调研和尝试,终于找到一个易实现且效果较好的方法:Retro-Reader。这是上海交大一位大佬的文章,其在Squad 2.0 leaderboard上的排名还是很高的。其中,该方法在unanswerable问题上的表现是非常好的。具体的论文解读这里不做解释,可以参考博文:https://zhuanlan.zhihu.com/p/106024973。
二、模型训练步骤
GitHub:https://github.com/qiufengyuyi/event_extraction
关于GPU环境配置,如果服务器大环境配置程序跑不通,可自行安装anaconda环境,下面是我配置环境时版本控制情况,仅供参考:
bert4keras 0.8.4
Keras 2.3.1
scipy 1.0.0
tensorflow-gpu 1.13.1
tensorflow-probability 0.6.0 【如果这个出现错误,则需要看下你的版本是否过高】
termcolor 1.1.0
torch 1.4.0
tornado 5.1.1
1、生成k-fold训练数据(根据不同阶段,生成两个阶段的k-fold训练数据):python gen_kfold_data.py
【注意】
-
在运行预处理数据前,原GitHub仓库缺少一份文件,我这边是自己根据代码生成了一份,具体文件样式看vocab_all_event_type_label_map.txt。
-
运行成功后会在./event_extraction/data文件夹下面:生成6个index_type_fold_data_0/1/2/3/4/5文件、生成6个verify_neg_fold_data_0/1/2/3/4/5文件。
2、事件类型抽取:bash run_event_classification.sh
输出分数【代码中用AUC作为评价指标】:
Saving dict for global step 28036: 0 = 0.5000005, 1 = 0.9444444, 10 = 0.92857146, 11 = 1.0, 12 = 0.99874055, 13 = 1.0, 14 = 1.0, 15 = 1.0, 16 = 1.0, 17 = 1.0, 18 = 1.0, 19 = 0.99715096, 2 = 0.8333334, 20 = 1.0, 21 = 1.0, 22 = 0.99874055, 23 = 1.0, 24 = 1.0, 25 = 1.0, 26 = 0.99874055, 27 = 1.0, 28 = 1.0, 29 = 1.0, 3 = 1.0, 30 = 1.0, 31 = 0.9987437, 32 = 1.0, 33 = 0.9987437, 34 = 0.9583334, 35 = 1.0, 36 = 0.9748712, 37 = 1.0, 38 = 0.99873096, 39 = 0.875, 4 = 1.0, 40 = 1.0, 41 = 0.8888889, 42 = 0.7500001, 43 = 1.0, 44 = 1.0, 45 = 0.7500001, 46 = 1.0, 47 = 0.78444207, 48 = 1.0, 49 = 1.0, 5 = 1.0, 50 = 1.0, 51 = 1.0, 52 = 1.0, 53 = 0.99872774, 54 = 0.8333334, 55 = 1.0, 56 = 0.75000006, 57 = 0.9948718, 58 = 1.0, 59 = 1.0, 6 = 1.0, 60 = 1.0, 61 = 0.99864495, 62 = 0.9907162, 63 = 1.0, 64 = 1.0, 7 = 1.0, 8 = 0.9583334, 9 = 1.0, eval_loss = 0.09869139, global_step = 28036, loss = 0.098691374
3、baseline事件论元抽取:bash run_event_role.sh
输出分数:
Saving dict for global step 64326:f1_end_micro=0.91180056,f1_start_micro=0.90675104,loss=1.1188397
【注意】这部分代码bug较多,下面是复现的bug以及解决方式。(其他小问题不做复现)
[BUG-1] not such file or directory : neg_fold_data_{}...
解决方案:
这部分运行前需要将.../event_extraction/train_helper.py中第117-125行的 neg_fold_data_{} 改成 verify_neg_fold_data_{}(行号可能会不一致,我这边修改了代码)
[BUG-2] TypeError: Tensor objects are only iterable when eager execution is enabled. To iterate over this tensor use tf.map_fn.
解决方案:
作者试了两种label标注,一种是多分类形式,一种是两个类型label,分别预测起始位置,这个问题应该是模型用的是预测起始位置,但是实际上加载的label是多分类,建议看一下代码,换成加载预测起始位置的数据。
^_^ train_helper.py——run_event_role_mrc(args)修改处如下:
(1)118行train_labels替换成2个labels:train_labels_start & train_labels_end(dev_labels也一样替换掉)
(2)173行train_input_fn中的 event_input_bert_mrc_mul_fn 替换成 event_input_bert_mrc_fn 。里面的train_labels也要替换掉。(dev_input_fn同样处理方式)
[BUG-3] NameError: name 'event_input_bert_mrc_fn' is not defined
解决方案:
在train_helper.py中导入包:from data_processing.event_prepare_data import event_input_bert_mrc_fn
[BUG-4] TypeError: create_optimizer() missing 1 required positional argument: ‘use_tpu'
解决方案:
正确:将./models/bert_mrc.py中第101行train_op的最后一个参数设置为False。train_op = optimization.create_optimizer(loss,args.lr, params["decay_steps"],args.clip_norm,False)
错误:将optimization.py——create_optimizer(…)中的参数use_tpu去掉。会出现TypeError:create_optimizer() takes 4 positional arguments but 5 were given。
4、RetroReader-EAV问题是否可回答模块:bash run_retro_eav.sh
输出分数:
Saving dict for global step 53606: eval_loss = 0.25366816, f1_score_micro = 0.9475, global_step = 53606, loss = 0.25366816
5、RetroReader-精读模块:bash run_retro_rolemrc.sh
输出分数:
Saving dict for global step 85768: eval_loss = 0.44854522, f1_end_macro = 0.9141987, f1_has_answer_macro = 0.935, f1_start_macro = 0.9093747, global_step = 85768, loss = 0.44854522
三、总结
这些方法只在当前的测试集1上产生了上述效果,由于测试集2的数据量较大,模型在上面的泛化性能还未可知。不过,验证目的在一定程度上已经达到,可以暂时得到以下结论:
-
使用MRC方式做事件抽取任务是可行的,且容易实现的。它可以应用在实际的工业场景中,需要做的繁琐工作在于设计问题描述模板,这个工作可以迭代进行。通过这种方式,可以进行一定的数据增强,这对于一些缺少标注的公司也是一个不错的选择。
-
MRC领域目前相对于其他领域来说进展较为迅速,目前在Squad 2.0 leaderboard上前排模型已经超过人类水平。因此有许多模型架构可以借鉴。
-
不可否认,应用MRC方式做其他领域的NLP任务会遇到各种各样的问题,例如不可回答的问题,以及问题模板的构建。另外,对于原始任务转化成MRC方式的设计流程也是一个难题,例如对于上述事件抽取任务,还可以将事件触发词抽取任务转化为MRC问题,问题模式可以为“XX事件的触发词是什么?”,并将该问题与论元抽取任务一起整合成一个阅读理解问答库,不过具体实施起来会遇到不少问题,有兴趣的同学可以尝试一下。
附件:一些标签映射文件简介
1、vocab_all_slot_label_noBI_map.txt
比赛中,给出了样本中囊括的所有事件类型65种,每种事件类型都有不同数量的论元类型。将事件类型与论元类型组合成一种label类型后,能够得到217种类型,比如出售/收购 与 出售方进行整合,得到了出售/收购-出售方这样一个label形态。
样例:
财经/交易-降价-降价物 0
财经/交易-降价-时间 21
财经/交易-降价-降价幅度 36
财经/交易-降价-降价方 204
类别-事件类型-事件角色1/事件角色2/...
2、vocab_all_event_type_label_map.txt(原github并未提供该文件,需要自己生成一份[看代码得需求样例])
样例:
财经/交易-出售/收购 0
财经/交易-跌停 1
财经/交易-加息 2
财经/交易-降价 3
财经/交易-降息 4
财经/交易-融资 5
财经/交易-上市 6
财经/交易-涨价 7
财经/交易-涨停 8