kaggle tweet コンペの闇と光 (コンペ概要と上位解法)
概要
先日 to be twitter masters
というチーム名で Tweet Sentiment Extraction コンペ (以下 tweet コンペ) に参加したので、まとめに記事を書いておきます。チームメンバーは筆者と @fuz_qwa @Kenmatsu4 @tkm2261 @yiemon773 の 5 人で、結果は 5 位となり見事金メダルを獲得できました。
本記事では上位解法の紹介と、これを理解するために必要なコンペ概要の紹介を主眼とします。また、チームでやったことはもう少し軽めの記事として後日まとめようと思っています。
いつも文字数多すぎてごちゃごちゃするので、できるだけ完結に書きたい...。情報足りないところは回答できるので、気軽に質問ください~~
🙏🙏🙏🙏🥇🥇🥇🥇🥇🥇🥇🥇🥇🥇🥇🙏🙏🙏🙏 pic.twitter.com/3gcKwocALI
— ぐちお (@ihcgT_Ykchi) June 17, 2020
コンペ概要
データ概要
tweet コンペでは下記のような train 27,481 行、public test 3,435 行のデータが与えられます。
このコンペでは text と、その text が {positive, negative, neutral}
の内どの感情に当てはまるかを表す sentiment から、text 中のどの部分を根拠に sentiment を選んだかを表す selected_text を予測します。例えば下図のように positive な text として I'd love that. And, don't think of it as easy. Think of it as enthusiastic.
が与えられた時、最も良い予測は selected_text である Think of it as enthusiastic.
ということになります。
ちなみに、このコンペのデータは selected_text が存在しない既存のデータセットに対して kaggle がアノテーションしたもの *1 で、この既存のデータと train として与えられたデータの text が若干違います (つまり kaggle によって若干加工されて渡されている)。この元データには private test data にあたるものも入っているのですが、なんと普通にアクセス可能です *2。元データには selected_text は存在しないが text は存在するため、private test data の分析やこれを使った pseudo labeling も可能でした。
また、実はさらにこの元データは本当の元データ、つまり元の tweet からも若干加工されたものになっています。元の tweet をいくつかみてみると、大半が顔文字のはいったものになっており、少なくとも多くのケースでこの顔文字が除去されているようです。
評価指標
評価指標は Jaccard score です。要はどれだけ過不足なく正解の selected_text を予想できたかという値になります。
baseline 的戦略
upvate が非常に多かった Chris の notebookの戦略を例に上げます。
preprocess
空白が続く等のノイズを除去するため、normalized_text = " "+" ".join(text.split())
の処理をします。先頭に空白を付与しているのは、多くの kernel で使われた RoBERTa が word の先頭に空白があるか否かを区別するためです。
また、sentiment を文字として text に追加します。NLP 系のモデリングでメタデータを扱う際にはよく使う方法ですね。
加えて、char level で selected text に入るか否かを判別し、tokenizer で区切られた token 内の char が 1 つでも selected_text に入っていたらその token を選ぶようにラベル付けします。これは tokenizer の token 分割の仕方とアノテーションの区切りにズレが有るためで、このやり方の他にもいくつもの工夫が可能です。
model
RoBERTa を使います。何故か他のコンペとは異なり、(少なくとも huggingface の transformers を使った場合は) BERT より RoBERTa の方がうまくワークするコンペでした。個人的には Tokenizer の違い (RoBERTa は ByteLevelBPETokenizer) かなぁと思っていますが、ちゃんと検証したわけではないです。
また、RoBERTa に付与する head には若干気をつける必要があり、系列方向の構造を保てる形にしないと経験上スコアが大きく下がります。つまり、例えば入力が 128 tokens で RoBERTa の最終層が (768 * 128)
だった場合、これを (768)
に圧縮すると良くなく、(128)
に圧縮するほうが良いです。この kernel では kernel size 1 の 1d CNN で (768 * 128) -> (128)
と加工しています。今回のコンペでは系列方向のどこが selected_text に当たるかを予測するコンペなので、この構造を保つことが重要だったのだと思います。
objective
selected_text の開始 index と終了 index をそれぞれ softmax 関数をかけて予測する形を取ります。この他にも segmentation として解く方法や NER (Named Entity Recognition) として解く方法が議論されていました *3 が、少なくとも自分の経験上は結局開始・終了 index を予測する形が最もスコアが良く出ました。selected_text が必ず 1 つしかなく、また連続した領域だったため、この構造を自然に表現できる開始・終了 index の予測という形がハマったのだと解釈しています。
ちなみに、このやり方だと開始 index が終了 index よりあとになってしまうというありえない状況がモデルの予測結果となる可能性もありますが、これは postprocess で対応します。この Chris の notebook ではこの場合『text 全体を予測結果とする』としていましたが、例えば『自信度の低い方を 0 埋めして再判定する』等、色々と工夫が可能なポイントです。
ラベルノイズ
このコンペでは主に 2 つの観点に起因して正解ラベルにノイズがあり、これをどうハンドルするかが (虚無感ありますが) 勝つために最も重要なポイントです。
1 つ目が HTML codes に起因するアノテーションズレです。要は、kaggle が外部サービスにアノテーションを依頼した際、selected_text の index という形式でラベルを取得していたようなのですが、アノテーション時と kaggle が selected_text を作成するときで HTML codes を生のコードで扱うか記号として扱うかに整合性がなく、この差分だけ index がズレてしまったというものです。この現象は discussion で議論されており、某運営によりコンペ開催後に対応されました (leader board も update された)。
2 つ目のノイズが (恐らく) 空白数に起因するアノテーションズレです。これは magic として discussion でも暗に議論されており、運営による対応もなかったため今回上位に入るか否かはこれを適切に扱えたか否かに大きく依存します。例えば下図のようなものがこれに当たります。この例は negative なもので onna
が selected_text になっていますが、text を見る限り明らかに正解は miss
です。これは実は text に大量の空白が含まれているためで、恐らく運営はアノテーション依頼時に複数の空白を一つに置き換えて依頼し、なぜか selected_text を抽出するときには元の空白数のまま text に対してアノテーションされた index を当てはめたと考えれば辻褄が合います。この前提に立ち、モデルの学習時には空白数分ずらした正解ラベルを使い、推論後に今度は逆方向に空白数分ずらすことで、精度良く学習した後アノテーションズレのある test データに fit させに行くことができます。
ちなみに、我々のチームでは空白数分ずらすことはもちろんしたのですがそれだけではデータがきれいになりきらず、恐らく他にもなにかに起因して更にアノテーションズレがあった (単に元のアノテーションがおかしかった可能性もあるが) のではないかと予想しています。
上位解法
(虚無感ありますが) 全ての上位解法で最もスコアに寄与しているのはラベルノイズをどの様に加味するかという部分です。この扱い方は大別すると 2 種類あり、1 つ目が pre-post processing として人手で対応するやり方、2 つ目が char level の tokenizer を使ってモデルにラベルノイズごと扱わせるやり方です。これを踏まえた上で、上位解法の目に止まった部分を紹介していきます。(※ 図を各 solution から引用しています)
1st
いくつかの通常の token base のモデルによる予測を行った後、下図のようにこの予測結果を使って char level の stacking を行うというアプローチを取っています。
char NN としては CNN, Bi-LSTM, そして WaveNet を使っていて、最終的に private で 1st となったモデル構成は下図です。
また、個々のモデルも色々と工夫が行われており、目に止まったものは下記です。
- Custom loss (Jaccard-based Soft Labels)
- 開始・終了 index を下図のようにしてなまらしたもの。2 乗の項は分布の smoothing のために入れられている (n 乗 (to inf) までこれを繰り返していくと jaccard = 1 の部分のみ 1 に近づいていくのでほんとに?という気がしているのでまだ理解できていない...)。
分布の例を図示したのが下図らしい。
確か類似アイデア自体は discussion で議論されていた (どの discussion 記事か忘れてしまった...) けど、自分達はうまくいかなかったので活用できててすごいと思った
- MSD (多分 Multi-Sample Dropout)
- Sequence Bucketing for faster training
- Quest の解法であった
leakless
な pseudo-labeling- ちなみに我々は pseudo labeling は普通に試しても全く上手く行かなかったです...
- Bertweet
2nd
このチームは pre-post process をマニュアルで設計してアノテーションズレに対応しています。
- pre-postprocess
- Quest の 1st の解法にある、学習可能な weight をつかって RoBERTa から hidden layer を抽出
- Multi-Sample Dropout
- Sentiment Sampler。sentiment 毎に精度がかなり違うため batch 間の sentiment のバランスを調整
- SWA
- Reranking-model training
- 一度開始・終了 index を予測した後、予測結果の top-n から最良のものを選ぶモデルを再度作る
- top-n を予測し、それぞれについて selected_text との jaccard を計算
- の予測結果を元に 1. で計算した jaccard を回帰で求めるモデルを作成
- のスコア * 0.5 + 2. の jaccard を指標に、最良の組み合わせを選ぶ
- モデルが 50 以上できるため、推論高速化のために Sequence Bucketing を使用
- 一度開始・終了 index を予測した後、予測結果の top-n から最良のものを選ぶモデルを再度作る
3rd
- char level での予測をする Dieter のモデルと token level での予測をする Khoi のモデルが有り、空白が服う数個ある場合は Dieter のモデルを、そうでないときはアンサンブルを行うという方策。
- Khoi のモデルは 学習は通常の開始・終了 index の予測のフレームワークで行い、推論時に開始・終了 index をそれぞれ top-3 つずつ (3 * 3 = 9 pairs) 使う。その後、start prob * end prob で最も高い確率のペアを選択。
- Dieter のモデルは BERT 属モデルの Head に char level RNN を適用し、アノテーションズレを含めて e2e で学習。
4th
- pre-postprocessing
- model に 4 つの head を用意し、マルチタスク学習
- 1st と似ているが、開始・終了 index のラベルを 0.9、その両隣を 0.05 として KL-divergence loss で学習。文の先頭の場合は 0.9, 0.06, 0.04 とし、逆もまた然り。
- segmentation
- sentiment によって意味付けされた semantic segmentation
- sentiment を当てる (リークしてる...?よくわかっていない)
- SWA
- self-distillation に期待していたが、実装ミスかうまく行かなかったらしい
- 1~3 番目の head の出力 + 4 つの head を持つモデルの出力した top-3 の候補それぞれが正解か否かを当てる external scorer の出力の系 4 つを weighted sum して予測を行う。
5th
これは我々なので詳しくは後日書きますが、概要は以下です。
- pre-postprocessing
- 開始・終了 index と segmentation のマルチタスクを行い、segmentation 側の loss 比率を比較的大きく (ex. 0.5 : 0.5 : 2 等) 設定
- segmentation 用の出力を CuMax 関数で算出
- segmentation 用の出力をゲートとして開始・終了 index 算出用の logits にかけるモデルも作成
- batch size をかなり大きめに設定 (ex. batch accum 込で 32 * 16, SentimentSampler 的な効果...?)
11th
- Psi はこの solo gold で GM に...!
merges.txt
を修正することで、RobertaToeknizer が明示的に.
や!
を分けて扱えるようにした (ref)- これは自分も軽く試したけどやり方わからなかったので復習しておきたい...
- 開始 index を予測した後、これに依存させる形で終了 index を予測させる
- これも自分は試したけどワークしなかった...
neutral
は 1 epoch 目のみ使い、その後はpositive, negative
のみで学習。neutral
用の学習は別途行う。- 最初から
neutral
を除去するのは試したけどうまく行かなかった... こうすればよかったのか...
- 最初から
12th
- 開始・終了 index のペアを適切に選択するため、
torch.einsum
を使ってペアとしてモデリングしてしまう。
13th
- cutout data augmentation のアナロジーとして、入力の一部を
[mask]
に置き換える操作を行った。
まとめ
なにはともあれ金メダルをとれてよかった...うれしい...。個人的には magic 系のコンペは虚無感あるしわかるまでは辛いですが、得るものがまったくないかと言うとそうでもなくて、magic を見つけたり利用する分析力は実務でもかなり役に立つものだと思っています。それ以外の部分でもかなり学びのある solution があったので、これらを吸収して次は solo gold 取れるように頑張りたいと思います。
質問・ご指摘等あれば気軽に投げて頂ければと思いま す。あと、6/20 に tkm san youtube live に出るのでもしよかったらそちらも見て頂ければと...!