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

Google針對這一數據集提供了一個開源的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