Kaggle TensorFlow 2.0 Question Answering 16名复盘

比赛封面

这是 Kaggle 春节前结束的一个阅读理解的比赛,我和管老师曹老师最终获得 16/1233 的成绩。成绩来自于管老师的提交,我自己的最好成绩大概排在 23 名的样子。

数据集

这次比赛的数据集来自于 Google,名为 Natural Questions,简称 NQ。数据集早在 19 年初就已经公布,在官网上还有排行榜。

这个数据集和 SQuAD 挺像的,关于 SQuAD 的介绍大家可以在这篇文章中找到。NQ 的训练集包含 30 多万个样例,每个样例包含一篇来源于维基百科的文章和一个问题。每篇文章可以被分为多个“候选长答案”,所谓候选长答案,可能是一个段落、一张表格、一个列表等等。候选长答案有可能有包含关系,但大部分的标注出来的长答案(95%)都是顶层候选长答案。在所有样例中,有大约一半样例的问题可以用候选长答案来回答。对于有的问题,还可以用更加简短的文章区间来回答,这种区间称为短答案。大约有三分之一的样例可以用短答案来回答。短答案并不一定是一个连续区间,有可能是多个离散的区间。

从上面的描述可以看出这个数据集比 SQuAD 复杂不少,大家可以到这个页面看一些官方提供的可视化样例。由于复杂,NQ 的难度也比 SQuAD 大不少,我认为主要体现在两点:

  1. 文章来源于维基百科原文,并不是像 SQuAD 一样已经筛选好了段落。这就导致 NQ 的文章通常很长也很乱。长比较好理解,乱主要体现在数据中会有大量表格、列表以及用来标示这些的 HTML 保留字。这些内容不能舍弃,因为问题的答案有可能出现在这些部件中。
  2. 答案的种类更加多样。SQuAD1.x 只需要预测答案区间,2.0 增加了不可回答问题。NQ 更进一步,多了找长答案这个任务,短答案除了普通的区间还增加了两种特殊的形式:Yes 或 No

Kaggle 这次的比赛形式是 Kernel 赛,允许参赛者线下训练,但必须在线上完成测试集的推理,推理时间限制为 2 小时。

Baseline

谷歌针对这一数据集提供了一个开源的Baseline模型,并且有一篇简短的论文介绍这个模型。这里需要介绍一下它,因为它真的是一个很强的基线模型。

模型叫做 BERTjoint,joint 的含义是用单个模型完成长答案和短答案的寻找。这个模型的基础是 BERT,输入进 BERT 的数据由问题和文章正文用[sep]拼接而成。因为文章正文很长,所以使用了滑窗的方式截取正文,默认的步长是 128 个 token。由于问题长度几乎都很小,所以如果模型的输入长度是 512 个 token 的话,大概每个 token 会出现在四个滑窗样本(chunk)里。

具体的数据集构造过程大概如下:

  1. 取出所有的顶层候选长答案,在其头部增加特殊 token,表明该候选长答案的类型(段落、表格、列表)和位置,即是全文的第几个该种类型候选长答案;
  2. 将处理过的候选长答案拼接起来,进行滑窗处理。将每个窗口的正文与问题拼接,组成一个输入样本;
  3. 样本的标签包含两部分:类型和起止位置。若样本中包含短答案,则该样本的答案类型为”short”,若没有短答案而有长答案则标注为”long”,其他情况均为”unknown”,short 类型有可能分化为”yes”和”no”两种特殊类型。有短答案时,起止位置是第一个短答案的起止位置,当只有长答案没有短答案的时候,起止位置标为整个长答案的头和尾,当不包含答案时,起止位置都标为 0,即指向[CLS];
  4. 如果该样本的类型为”unknown”,则有 98% 的概率将这个样本丢弃,98% 这个比例是为了使最终制作出来的训练集里面不含答案的样本大概占 50%

训练的方法比较简单,对于找开头和结尾这个标准阅读理解任务,直接在 BERT 后面加了一个全连接,然后对概率在句子维度上做 softmax;对于答案类别分类则是在[CLS]的输出后面跟一个全连接。论文中指出,训练 1 轮的效果是最好的,我们在训练时也使用了这个设定。

baseline使用的模式和这张图非常像

推理过程如下:

  1. 对于每个长度为 512 的文本窗口,得到 512 个位置作为开始和结束位置的概率,以及该样本属于 5 个答案类型的概率;
  2. 分别找出概率最高的 k 个开始位置和结束位置;将这些开始和结束位置两两组合,留下符合要求的区间。这里的要求有两个,一是开始位置必须小于等于结束位置(区间必须合法),二是区间长度小于某个设定的值(是一个可调节的超参数);
  3. 一篇文章对应的所有符合要求的区间按概率进行排序,找到最好的那个区间作为短答案,并记录区间概率,找到包含这个区间的候选长答案作为长答案,用短答案区间概率作为长答案概率,并记录答案类型概率;
  4. 通过阈值进行筛选,保留大于阈值的长答案和短答案。如果被保留长答案的 YES 或 NO 答案类型概率大于 0.5,则将短答案改为 YES 或 NO。

可以看出,由于输入输出明确,模型其实是整个 pipeline 里最简单的部分。而对于长文本、长短答案、特殊答案类型的处理大多是有数据预处理和后处理来完成的。其实这也说明一个道理,模型能力永远不是一个算法工程师最重要的能力,它们已经被封装得越来越好,使用门槛越来越低。

我的做法

当时看了 Baseline 之后我感觉这个解法有几个比较明显的不足:

  1. 答案只有在长度小于一个 chunk 长度时才会被认可,这样会导致长度较长的长答案 label 丢失;
  2. 只取了第一个短答案,也会丢失信息;
  3. 将所有候选长答案拼接之后再滑窗增大了寻找长答案的难度。

针对这几个方面,我决定走一条和 baseline 不同的道路,主要操作如下:

  1. 我不再拼接候选长答案,二是直接对每个候选长答案进行滑窗。也就是说,我的每个样本里不会含有来自多个候选长答案的文本,这样我就可以用[CLS]来做针对某条具体长答案的分类任务。
  2. 候选长答案里不包含任何标题,而我发现有的表格或列表如果不看标题根本不知道它们的主题。所以我单独写了一段处理代码来引入标题。
  3. 在标签中加入所有的短答案开头结尾,将找开头结尾的 loss 改为 BCE loss。

由于我不会用 TensorFlow,所以我花了很长时间把相关代码写成 pytorch;整个调模型的过程也并不顺利,一方面是训练所需时间特别长,另一方面是因为我很晚才建立起比较稳健的线下验证体系。我的 LB 成绩一直比曹老师的 baseline 模型低很多,这让我有点慌了阵脚。赛后回看,曹老师的模型很严重地过拟合了 LB,这也是比较诡异的一件事情。当时我们就发现了这种可能性,所以我也很乐观地相信我们前面有队伍肯定过拟合了,并在结束前把队名改成了“Shake up 过新年”。虽然没有在 B 榜上升到金牌区,但确实上升了 10 名。

最终我最好的成绩来自一个 BERT Large WWM SQuAD 和 Roberta SQuAD 的融合,线下 F1 是 0.66,线上 LB 0.638,private score 0.670。两个单模的线下 CV 都是 0.65 左右,融合带给我大概 0.01 的 boost。

这次比赛里我踩的一个大坑是我前期为了加快训练,一直使用的是基于序列长度的 Bucket Sampler。这个 trick 使我的训练速度提高了一倍,但模型的性能却大打折扣。一开始我并不知道有这个情况,直到最后几天我为了使用多卡训练的 distributedSampler 而无法使用这个技巧之后成绩突然猛增,我才意识到这个问题。后来回想,由于我是对单个长答案进行直接滑窗,不同训练样本的非 padding 长度差异比较大,这么训练很可能带来 bias。但这个技巧在提升推理速度方面是很有成效的,后面有机会的话给大家介绍这个技巧。

这次比赛的另一大遗憾是队伍里的模型没有很好地融合起来。这是因为我们一开始没有考虑到这一题的特殊性带来的融合难度。首先我们都没有做检索用的快速模型,这导致我们推理的时间都比较长,即使融合也只能塞 2-3 个模型。第二是由于大家的预处理不太一样,输入 token 层面就产生了差异,需要提前将预测结果映射回 word 空间再进行融合,但我们一开始没有想到这点,到后面已经来不及了。

做的比较好的地方也有一些,例如在比赛最后几天 Huggingface 放出了他们 Rust 实现的新版 Tokenizer,比原来快了好几倍,我在第一时间使用了这个库;预处理的代码经过精心考虑,做了很多缓存,大大加快了速度等等。

前排方案

总体来看大家都没有超出 baseline 的模式,但还是有很多值得借鉴的地方。曹老师在讨论区开了一个帖子,收集了所有的金牌方案

1st

很开心地看到 Guanshuo 老师使用了和我一样的数据生成模式,但他做了很重要的一步就是hard negative sampling。他先用一个模型得到了训练集所有候选长答案的预测概率,然后根据这个概率来对负样本进行采样。每个模型训练了 3-4 个 epochs。相比于我们直接随机采样负样本(baseline 只保留了 2% 的负样本)这种方式更能保留有训练价值的数据。这个技巧应该会在后面的比赛中被越来越多地使用。

大佬的另一个牛逼之处就是由于做了上面的操作,他可以在推理的时候很自然地也引入这个机制,他使用一个 bert base 来事先筛选出可能性较高的候选长答案,然后再用大模型求短答案。因此他在最后融合了惊人的 5 个模型!而且推理时间只有 1 小时。这个方式我们也想到了,但是在线下实验的时候可能由于代码写错了没有成功。

在训练方面,开始和结束位置的权重只有在训练样本为正样本时才更新。

2nd

老师的方法很朴素,几乎跟 baseline 一样。他说他的关键点也是采样,他调高了负样本的保留概率。

3rd

融合了 3 个模型,3 个模型在训练时的主要差别是使用了不同的滑窗步长生成的数据以及略有差异的训练目标。

4rd

一方面提高了负样本比例,另一方面使用了知识蒸馏。知识蒸馏之后的学生模型在验证集上相比教师模型有 0.01 的 boost。

后记

可能是由于运算时间的限制或者评测数据集不一样,我发现 Kaggle 竞赛冠军的成绩也没有达到官网 Leaderboard 最顶尖的水平。但很神奇的是这个 task 几乎没人发 paper,所以无从知晓排行榜上大佬们做了什么操作。如果大家对阅读理解有兴趣,可以尝试一下这个任务。不像 SQuAD 里机器已经大幅超越人类,在这里目前机器的水平离人类还有较大差距,巨大的空间等着大家探索。

底图.png