比赛心得与复盘
第一阶段
自从取了"like cxk"这个队名后,发现很多人和我有相似的想法,取名"不是cxk"、"唱跳rap篮球",还有一些"ikun"之类的,我们看起来就像是彼此的小号,哈哈哈哈。
这个比赛组织方提供了两个基准模型,在第一阶段的性能分别是:
- BIDAF F1=63.9%
- Bert F1=78.6
在第一阶段,我直接采用了最简单的bert模型,使用的是huggingface设计的框架。当时这个框架的名字还是"pytorch_pretrained_bert",现在已经改名了。新版本进一步封装设计了整个流程,而当时的run_squad.py版本则是根据tf版本修改的,整个流程就是训练一次然后预测一次。所以我就略微改写了整个流程,在训练中验证模型的效果,保存性能最好的模型。当然理论上来说,这和原来版本保存最后三个模型相差不太多。
相对于squad2.0,这个比赛增加了YES/NO类型的问题。最初的版本,我先不考虑YES/NO类型,所以就在预处理阶段把这些问题去除,然后运行squad2.0版本的代码。结果出乎意料,f1值居然只有6点几。然后我就想,这个数据集这么难吗,连bert都回答不好?进一步分析数据后,发现很多文章长度都超过了512(bert的max_seq限制),于是我猜测可能是预处理出了问题。检查了一下代码,发现没有问题。之后我就放弃了这个比赛。
过了几天,闲着没事,我就把训练脚本中的bert模型改成huggingface里自动从亚马逊云下载的作者转换好的模型,结果突然正常了。大概结果如下(在step=500的情况下):
- OrderedDict([('civil', {'em': 46.6, 'f1': 61.9, 'qas': 500}), ('criminal', {'em': 39.2, 'f1': 62.7, 'qas': 500}),
('overall', {'em': 42.9, 'f1': 62.3, 'qas': 1000})])
这个结果怎么还是那么低呢,还没到官方给的BIDAF的水平。同期,我发现在github上有人把百度的ERNIE转成了pytorch版本ERNIE,于是我就用这个跑模型,结果出乎意料,更低了:
- OrderedDict([('civil', {'em': 40.6, 'f1': 58.2, 'qas': 500}), ('criminal', {'em': 36.8, 'f1': 59.3, 'qas': 500}),
('overall', {'em': 38.7, 'f1': 58.8, 'qas': 1000})])
接着我就简单调了调参数,然后按照上面的思路改写了训练流程,将数据预处理、模型、结果后处理和训练流程分为几个文件,修改了max_answer_length,设置了完整的steps或epochs,然后F1值大概提升到67.9。之后就再也提升不上去了,于是又玩了几天。 闲暇时,我重新回顾了比赛并分析了数据分布。YES/NO类型的问题约占总问题的12%,这意味着相比之前的模型,我有约12%的结果F1值为0。
因此,需要设计一个能处理YES/NO类型问题的模型,通常做法是在BERT输出上添加一个分类器(参考Hotpot数据集的基准模型)。但我懒得修改模型和输入输出,所以查看了YES/NO类型问题,突然发现它们都有明显特征:只要包含"是否"或"是xxx的吗?"这样的线索,大多都是YES/NO问题。统计显示,在99个包含这两个关键线索的问题中,86个是YES/NO/UNK问题。进一步分析发现,YES和NO的比例约为6.5:4.5。
这意味着,无需模型,只要将这类问题都回答YES,性能就能提升8到9个百分点。简单尝试后,果然提升了8到9个点。此时F1值约为75左右,第一阶段排名瞬间提高,突然有了获奖希望(其实并没有)。
接下来,我分析了这些是非问题。我不能简单地都预测成YES,是否有方法能将一些答案预测成NO?突然想到,虽然训练时没考虑是非问题,但预测时对是非问题仍会预测一个span。观察预测的span后,发现其中包含一些问题倾向的特征,比如预测span中含有反面(负面)词,则答案很可能是NO。于是我分析了负面词,性能略有提升,主要体现在部分是非问题中NO类型问题回答正确。
value = 'predict span'
# value.find('未') >= 0 or value.find('没有') >= 0 or value.find('不是') >= 0
if value.find('无责任') >= 0 or value.find('不归还') >= 0 \
or value.find('不予认可') >= 0 or value.find('拒不') >= 0 \
or value.find('无效') >= 0 or value.find('不是') >= 0 \
or value.find('未尽') >= 0 or value.find('未经') >= 0 \
or value.find('无异议') >= 0 or value.find('未办理') >= 0\
or value.find('均未') >= 0:
preds.append({'id': key, 'answer': "NO"})
然后人生就遇到了瓶颈,无法进步,调整参数无效,规则也找不到门路。开始分析错误样例,沉迷于理清投保人、保险人、被保险人等之间的关系,分析了很多保险类的问题,一度觉得自己可以去卖保险了。也发现了数据集本身存在一些问题,主要有:
- 标注不正确:答案错误
- 标注不一致:比如同样的问题,有些标准答案是"300元",有些则是"300",而文中是"300元"。这种情况似乎普遍存在,在之前的xxx评测中,追一科技对超过1万个样本进行了重新标注。
- 标注质量不佳:这是一个比较致命的问题。例如,需要预测一个命名实体,比如"张3",而文中多次出现"张3",标注者给出的答案往往是"张3"第一次出现的位置。这会导致一个问题:我们的模型本质上是一个匹配模型,假设文章中出现"被告人李4和张3","被保险人为张3","张3"出现了两次,而对于问题"被保险人是谁?",标准答案标注的位置却是第一个被告人张3。这样模型很容易将"被保险人-被告人"映射成一组关系,导致在下一个样本预测时,错误地预测成"李4"而不是"张3"。可以看出,模型主要是从span的上下文来判断当前span是否为正确答案。这种标注会导致模型学习到错误的上下文环境,从而学习到错误的匹配方式,最终导致预测错误。因此,标注者在正确的上下文场景下给出答案标注位置是至关重要的。
对于标注不一致的问题,由于无法看到开发集和测试集,自己重新标注不一定更符合原有分布(主要是懒得做)。对于标注质量不佳的问题,就想等下一阶段的数据出来再重新标注(其实并没有)。
然后突然想起,没有对unk类型的问题进行处理。unk问题大约占7%?分析后发现,基本没有明显的特征可以像YES/NO问题那样处理。于是遵循run_squad.py里的处理方式,设置一个阈值,score(start_null)*score(end_null)>null_thresh,这里的start_null和end_null按照原来的处理都是序列的下标为0的位置。可以参考"Read + Verify"这篇论文背景章节的介绍。在预测时传入阈值参数(null_score_diff_threshold=5),线上性能也有显著提升。至此,整套模型的性能接近官方BERT基准。
接着,又闲着没事就对错误样例进行分析,领悟了很多人生道理。在人类语言面前,模型就是人工"智障"。比如,一个问题加入了时间或场景限定,模型就立即迷失方向。对于多目标跟踪,如"犯罪分工情况",模型只能识别出"xxx望风",而答案是"xxx望风xxx抢劫"。由于刑事类文书的问题很多是描述性的,不像民事类那样找"实体"(是谁?是多少?),所以模型难以控制自己预测的答案是长还是短,是多还是少。模型无法解决的问题还有很多。尝试寻找规律,却发现还是太年轻。
又玩了几天,想着大概可以开始做集成了。而集成怎么做呢?最简单的方法就是对每个模型预测的最佳span的分数进行排序。这就是最原始版本的集成模型。可以参考"squad_2.0 ensemble",集成还是有一定效果的。第一阶段用了4个模型集成,成功超过了BERT基准(讯飞)。突然觉得好像要夺冠了?(想太多)
然后又玩了两周,第二阶段开始前,发现其他人已经刷到85分了!(后悔玩了两周)
第二阶段
第二阶段开始后,数据增加到了4万条,我们在这些数据上(按9:1比例分割)训练了之前的模型。提交后发现线上F1值只有75左右,落后其他参赛者很多。因此推测,其他人可能已经修改了原始的BERT模型,以适应未知词和是/否类型的问题。于是我们设计了几个方案:
- 模型+分类:本质上是一个简单的多任务学习,包括训练样本预处理和答案后处理
- K折交叉验证和集成
- 数据增强:从SQuAD排行榜可以看到,许多模型对数据进行了增强,比如使用回译方法。利用翻译软件将中文翻译成英文,再翻译回中文,从而得到新的训练数据。这个思路大概是QANet应用的方法,SQuAD排行榜上缩写为MMT之类的模型大多使用了这种技术。
- 预训练新的BERT模型(或XLNet模型):这个方案需要大量语料,但由于只有两张1080Ti显卡可用,所以放弃了。后来清华团队发布了一个经过大量法律文书增量预训练的BERT模型,我们尝试了一下,效果反而不如原来的好。可能的原因是它将刑事和民事两类数据分别预训练了模型,而我们的训练样本是两类数据融合在一起的。我只使用了其中一个预训练模型,但在两种问题上的表现都同时下降了。当然,我的推理也不一定是正确的。
关于第一个方案:我们不得不修改原有的模型,主要是在预处理和模型输入阶段。需要添加"是"、"否"和"未知"的标签。对于这三个类别,我们使用BERT在[CLS]位置的输出作为一个小型网络(全连接层)的输入,预测出3个logits,与原有的span logits拼接,进行softmax操作,然后计算损失。本质上这是一个非常基础的多任务学习,损失函数为loss=loss_span+loss_yes+loss_no+loss_unk。其实应该加入超参数设置,变成加权求和。当然,还需要在预测阶段添加"是"、"否"和"未知"的预测。经过这样的改造,我们完成了一个端到端的模型(无需额外处理是/否类型问题)。这个端到端模型在是/否类型的预测上,竟然能达到("是"95%以上,"否"40%以上)的效果,说明模型能够很好地捕捉到问题中"是否"这类关键特征。
同时,我们也更深入地检查了run_squad.py中预处理和答案后处理的代码。在原有的run_squad.py中,处理长文本时使用doc_stride间隔,将文章切成一段段作为新样本处理。在预测阶段,这可能导致一个词在多个片段中出现,而预测只需要这个词的一个概率。那么我们应该选择哪个片段中的这个词作为最终概率呢?这就是"_check_is_max_context"函数解决的问题。它的核心思想是,如果一个词在一段文本中的位置越靠中间,那么这个词包含的上下文信息就越多,因此在这个上下文场景下预测的这个词的得分就应该更可信。这说明了一个词的上下文语境的重要性,也解释了为什么语言模型比单纯的词向量效果好那么多。虽然原作者的这种做法无法证明,但感觉很有道理,难以反驳。而XLNet的作者对答案预测的后处理则采用了另一种思路,同样令人信服。
上述的端到端单模型在线上达到了78+的性能。这是在两张显卡,batch_size=12(每张卡batch_size=6)的情况下得到的结果。
接着,我们尝试了几种不同的预训练BERT模型,性能大致如下: bert-wwm > google > [ERNIE](>THU,因此之后我们一直使用bert-wwm这个预训练模型。 上述模型存在一个问题,yes、no和unk竟然共享了同一个输入和网络参数。YES/NO类型的问题也存在一些unk情况,但YES/NO实际上能根据上下文推理出正确答案,即在文章中能找到一些evidence span作为支持,这与前面提到的利用预测span中的某些特征来预测NO的现象是一致的。而unk呢?通常无法在文章中找到有利的支持,因此导致无法回答这个问题。所以我们似乎至少应该使用两个小型网络分别处理yes/no问题和unk问题。基于此,我仍然使用[CLS]位置的输出作为输入来预测unk,而对于yes/no,则使用self attention+sum pooling的方式将整个序列的输出压缩成一个向量作为输入,用来预测yes/no。这种模型在线上能够达到79+的效果。
后来我查看了一些answer verified模块的论文,但都不太满意。有一天,我发现SOUGO在SMRCTOOKIT中发布了他们在coqa上使用的bert+answer verified模型,于是我参考他们的tf版本,改写了一个pytorch版本。由于这个数据集没有rational,所以略有不同。多次运行这个模型后,线上效果能够达到80左右。
考虑到K折交叉验证,我估计这个比赛最多支持3G的压缩包,而pytorch模型大约400M,所以做了一个8折的交叉验证。通过这样划分,训练了8个base模型,这些模型在线上基本维持在80左右(由于提交次数有限,没有一一验证)。
在集成方面,第一阶段的集成方法大概能达到80.8。原来的方法存在什么问题呢?因为有些问题是YES/NO或者是unk的,这种情况似乎用投票的方式来解决会更好。所以我修改了集成方法,假设一个样本有8个答案,如果yes、no或unk超过半数,则直接用投票的方式,而不是选择得分最高的作为答案。
同时,我与组里做阅读理解的专家讨论了几个集成方案:
- 选择得分最高的方式
- 对yes/no、unk和span分别处理,使用得分最高+投票的方式
- 完全使用投票的方式,即如果一个span被多个模型预测,则选它作为答案
- 所有模型在序列上的概率求平均,然后作为单个模型的输出进行预测(我认为这个方案会导致预测边界模糊,专家说不一定,但我们没有得出结论。是否能给出证明,证明这种集成方式不会导致边界模糊?)
实际上,第三个和第四个方案在本地效果差不多,但在线上第三个方案优于第四个方案。可能是因为第三个方案对yes/no/unk类问题更友好。最终这个集成方案在线上达到了F1值为81.7的效果。
接下来就是抽奖时间了,让模型运行,玩了两天,然后选择不同的模型组合,最终效果差不多达到了81.77。然而此时我遇到了瓶颈,其他人刷到了82、83,而我却再也提升不了了。
于是又到了"人工"智能时刻,我开始分析错误的案例,阅读了大量法律文书,学到了很多人生道理。我学习了很多法律知识和保险知识,然而我的模型却理解不了我的用心良苦。
最后几天,我只好做一些答案后处理的工作。虽然方法很低级,但确实提高了线上性能,F1值达到了81.815,这就是第二阶段的最终成绩。最后,主办方剔除了一些小号后,我的排名定格在了第4位。
第三阶段
突然就成为第三名了...可能是因为"like cxk"这个队名的魔力吧。
总结
- 友情提示,由于代码未经整理,不太建议查看。
- 比赛最重要的是先了解你的数据,然后想办法解决问题,无论是使用听起来很厉害的方法还是"人工"智能的方法。
- 整个复盘中最有用的几个要点
- 多任务学习
- 集成方法
- 错误样例分析:包括标注问题等
- run_squad.py预处理和后处理的依据
- 缺少的是什么?
- 这是一个法律文本任务,没有根据法律文本的特点设计模型。法律文本有什么特点呢?进一步说,是否应该根据民事和刑事案件区分训练模型呢?
- 对于人工标注质量不佳的数据,我们如何应对这些噪声呢?特别是标注问题的第三点,是否能设计一个模型来解决这个问题?还是只能人工重新标注?
- 外部知识,无论是推理能力还是法律常识,模型都很难使用,这是现有阅读理解模型都难以解决的问题,而BERT是一个大型匹配网络。
- 模糊性和精确性匹配的权衡,我记得遇到过一个问题,仅仅因为问题中"遇到"被改写成了"运到",回答的答案就完全偏离了,这有点类似SQuAD对抗样本和ACL2019论文讨论过的话题。
- 为什么加入条件限制的问题回答得都不好?
- ...
总之,人生就是这么奇妙
最后,感谢我的队友caldreaming提供了答案验证模块!
计划
[x] 后期预计会整理一下代码,提供一个可以直接运行的版本。 更新 向各位道歉,当年忙于发表论文和找工作,最终没能提供一个完整的代码。现在的Hugging Face库已经非常完善,使用现在的库能很快实现类似的思路。如今大模型盛行,很多技巧已经失效了~ 欢迎大家用大模型来做机器阅读理解~