BERTを動かしてKaggle過去コンペ（Quora Question Pairs）にSubmit

自然言語処理界隈で、BERTが話題ですね。
私は理論的なことに明るくないので、その辺の説明は他の人に任せて
まずはとにかく動かしてみようと思います。

ぶっちゃけ、GithubのREADME読めば動かし方は分かるんですが、
英語めんどいって人とかはまぁ↓読んでみてください。

# やること

実力を確かめたいので、[BERT論文](https://arxiv.org/abs/1810.04805)中で解いているタスクであるGLUEタスク群のうち、
QQP（Quora Question Pairs）というお題で、実際にスコアを出してみたいと思います。

このQQPというタスク、実は1年くらい前に[Kaggleのコンペ](https://www.kaggle.com/c/quora-question-pairs)にもなっていました。
（BERT論文の対象タスクである[GLUE benchmark](https://gluebenchmark.com/)のタスクと、どっちが先かはよく知らない）

確かに、データの中身を見るとKaggleで使われているtrainと同じようです。
GLUEタスクの方では、trainからvalidateを固定で分割している、くらい。
testセットも、多分使っているデータは同じだけど、GLUEの方が量が少なくて、Kaggleの1/10くらい。

Kaggleは過去のコンペでもSubmitしてスコアを出せる（当然、順位には影響しません）ので
BERTで試したモデルがKaggleでどのくらいスコアが出るのかも見ることができます。
これもやってみます。

# 準備

1. 12GB以上のメモリを搭載したGPU環境、もしくはTPUが使える環境を用意  
    * メモリ12GB未満のGPU（GTX1080とか）だと、少なくともデフォルト設定（論文の設定）ではOOMで落ちます。私は1080Tiで動かしました。
    * 最近は[ColaboratoryでTPUが無料で使えるらしい](https://qiita.com/koshian2/items/25a6341c035e8a260a01)ので、素直にそれを利用した方が良いと思います。
    * OOMについては[issue](https://github.com/google-research/bert#out-of-memory-issues)としても取り上げられています。今対応を検討中だそうですが、まだ実装できてないとのこと。
1. Python環境を用意して、tensorflow 1.11.0をインストール

1. BERTのソースコードを[GoogleのGithubリポジトリ](https://github.com/google-research/bert)からclone

1. 同リポジトリのREADMEに記載されている、BERTの[事前学習モデル](https://github.com/google-research/bert#pre-trained-models)をダウンロード
    *  英語モデルに関しては、以下の4つがありますが、とりあえず動かしたいだけなら `BERT-Base Uncased` を使えば良いと思います。
        * BERT-Base, Uncased: 12-layer, 768-hidden, 12-heads, 110M parameters
        * BERT-Large, Uncased: 24-layer, 1024-hidden, 16-heads, 340M parameters
        * BERT-Base, Cased: 12-layer, 768-hidden, 12-heads , 110M parameters
        * BERT-Large, Cased: 24-layer, 1024-hidden, 16-heads, 340M parameters
    * `Uncased` ってのは、前処理として英単語をすべて小文字化（lowercased）しているモデルです。
    * `Multilingual` ってモデルもあって、日本語も対応しているのですが、Unicode正規化の影響で濁点が除去されたりしているようです。  
       [この辺を試している方もいる](https://qiita.com/knok/items/9e3b4505d6b8f813943d)ので、BERTの日本語の扱いに関してはそちらを参照ください。

# ソースコードについて

`run_*.py` となっているスクリプトが、実行用のスクリプトです。
QQPタスクは「2つの質問文が意味的に等価かどうかを判定」する分類問題なので、 `run_classifier.py` を使います。

使うスクリプトはこれだけ。
他にも事前学習モデルを独自で作成したい場合に `run_pretraining.py` を使うと思うのですが、
Googleの事前学習モデルを使う場合には不要です。
事前学習モデルを日本語で作っている方もいるので、それは[こちら](https://techlife.cookpad.com/entry/2018/12/04/093000)を参照。
（参照ばかりでリンク集みたいになってきた...） 

## タスクに合わせてスクリプトを修正

QQPをBERTで動かす前には、ちょっと `run_classifier.py` をいじる必要があります。
[READMEの記載](https://github.com/google-research/bert#sentence-and-sentence-pair-classification-tasks)から、`run_classifier.py` の実行方法はすぐに想像がつくのですが

```shell
export BERT_BASE_DIR=/path/to/bert/uncased_L-12_H-768_A-12
export GLUE_DIR=/path/to/glue

python run_classifier.py \
  --task_name=MRPC \
  --do_train=true \
  --do_eval=true \
  --data_dir=$GLUE_DIR/MRPC \
  --vocab_file=$BERT_BASE_DIR/vocab.txt \
  --bert_config_file=$BERT_BASE_DIR/bert_config.json \
  --init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt \
  --max_seq_length=128 \
  --train_batch_size=32 \
  --learning_rate=2e-5 \
  --num_train_epochs=3.0 \
  --output_dir=/tmp/mrpc_output/
```

の、`--task_name`にタスク名を指定すれば、なんか動いてくれそうな予感がしますよね。

でも、ソースの中身を見てみると、この `--task_name` にQQPという選択肢はありません。

この `--task_name` はどう使われているかというと

https://github.com/google-research/bert/blob/master/run_classifier.py#L810-L813  

```python:run_classifier.py
  task_name = FLAGS.task_name.lower()

  if task_name not in processors:
    raise ValueError("Task not found: %s" % (task_name))
```

で読み込まれていて、じゃあ `processors` はというと

https://github.com/google-research/bert/blob/master/run_classifier.py#L786-L791

```python:run_classifier.py
    processors = {
        "cola": ColaProcessor,
        "mnli": MnliProcessor,
        "mrpc": MrpcProcessor,
        "xnli": XnliProcessor,
    } 
```

と定義されています。このdictに `"qqp": QqpProcessor` という要素を足せば良い、ということです。

もちろん `QqpProcessor` 自体も定義してあげないといけないのですが、
ほとんど[MrpcProcessor](https://github.com/google-research/bert/blob/master/run_classifier.py#L296-L333)をパクって、以下のようにすればOKです。
（余計な`if`とか`try`が入っていますが、これはkaggleデータを読み込む時に出るエラー対処などです。悪しからず...）

```python:run_classifier.py
class QqpProcessor(DataProcessor):
  """Processor for the QQP data set (GLUE version)."""

  def get_train_examples(self, data_dir):
    """See base class."""
    return self._create_examples(
        self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")

  def get_dev_examples(self, data_dir):
    """See base class."""
    return self._create_examples(
        self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")

  def get_test_examples(self, data_dir):
    """See base class."""
    return self._create_examples(
        self._read_tsv(os.path.join(data_dir, "test.tsv")), "test")

  def get_labels(self):
    """See base class."""
    return ["0", "1"]

  def _create_examples(self, lines, set_type):
    """Creates examples for the training and dev sets."""
    examples = []
    for (i, line) in enumerate(lines):
      if i == 0:
        continue
      guid = "%s-%s" % (set_type, i)
      try:
        if set_type == "test":
          text_a = tokenization.convert_to_unicode(line[1])
          text_b = tokenization.convert_to_unicode(line[2])
          label = "0"
        else:
          text_a = tokenization.convert_to_unicode(line[3])
          text_b = tokenization.convert_to_unicode(line[4])
          label = tokenization.convert_to_unicode(line[5])
      except IndexError as e:
        sys.stderr.write('Index Error: {} {}\n'.format(i, line))
        if set_type == 'test':
          text_a = tokenization.convert_to_unicode('Why does qqp include invalid data?')
          text_b = tokenization.convert_to_unicode('Why does qqp include invalid data?')
          label = '0'
      else:
        examples.append(
            InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
    return examples
```

これだけ修正してあげれば、とりあえず事前学習モデルでBERTを動かせます。

# GLUEにSubmit

## まずはfine tuning

論文中のGLUEデータは、別リポジトリにある[download_glue_data.py](https://gist.github.com/W4ngatang/60c2bdb54d156a41194446737ce03e2e)を使って落とせます。
これを実行すると、実行ディレクトリ配下の `glue_data/QQP` ディレクトリにデータが格納されます。

```
glue_data/QQP/
├── dev.tsv
├── original
│   └── quora_duplicate_questions.tsv
├── test.tsv
└── train.tsv
```

あとは以下のようにしてBERTのfine tuningを走らせます。

```shell
export BERT_BASE_DIR=/path/to/bert/uncased_L-12_H-768_A-12
export GLUE_DIR=/path/to/glue_data

python run_classifier.py \
  --task_name=QQP \
  --do_train=true \
  --do_eval=true \
  --data_dir=$GLUE_DIR/QQP \
  --vocab_file=$BERT_BASE_DIR/vocab.txt \
  --bert_config_file=$BERT_BASE_DIR/bert_config.json \
  --init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt \
  --max_seq_length=128 \
  --train_batch_size=32 \
  --learning_rate=2e-5 \
  --num_train_epochs=3.0 \
  --output_dir=/tmp/qqp_output/
```

GTX1080Tiで、大体3時間くらいでした。

## 次にtestデータのpredict

testデータのpredictは以下のように実行できます。

```shell
export BERT_BASE_DIR=/path/to/bert/uncased_L-12_H-768_A-12
export GLUE_DIR=/path/to/glue
export TRAINED_CLASSIFIER=/tmp/qqp_output/  # fine tuning時に--output-dirで指定したディレクトリでOK。中にモデルができている。

python run_classifier.py \
  --task_name=QQP \
  --do_predict=true \
  --data_dir=$GLUE_DIR/QQP \
  --vocab_file=$BERT_BASE_DIR/vocab.txt \
  --bert_config_file=$BERT_BASE_DIR/bert_config.json \
  --init_checkpoint=$TRAINED_CLASSIFIER \
  --max_seq_length=128 \
  --output_dir=/tmp/qqp_output/
```

実行が終われば、ここで`--output_dir`に指定したディレクトリに `test_results.tsv` ができるはずです。

## GLUE benchmarkにSubmit

`test_results.tsv` を[GLUE benchmarkのサイト](https://gluebenchmark.com/)でSubmitしてみます。
Googleアカウントでログインして https://gluebenchmark.com/submit からSubmitできます。

が、GLUEっていうのは11個のタスクの集合体です。
他タスクの結果も格納したzipファイルをアップロードする必要があります。
せっかくなら、それらもBERTで作成したかったのですが、時間がなかったのでQQP.tsvだけ作って
あとはサンプルSubmitファイル（[ここ](https://goo.gl/aYbxjR)からダウンロードできる）をそのまま使ってアップロードしました。

結果は以下。（QQP以外は、サンプルSubmitのスコアになっています）
<img width="1270" alt="スクリーンショット 2018-12-20 22.59.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/47605/f3ae6721-3cff-fcbd-0293-6982ddfe3567.png">

Googleの結果では、71.2/89.2（F1/Accuracy)なので、近い値が出ています。（バグなのかGLUE benchmarkのサイトでの表示はF1とAccuracyが逆になってますが...）
無事実行できていた、ということだと思います。


# KaggleにSubmit

無事にBERTモデルができたということで、KaggleにもSubmitしてみます。
KaggleとGLUEとでは、testデータのフォーマットが異なるので、

1. BERTスクリプトで動くように整形
1. BERTスクリプトでpredict
1. できた `test_result.tsv` を再度Kaggleのフォーマットに整形

をしなければなりませんが割愛。
あと、Kaggleのtestデータ量はGLUEの10倍くらいあるので、predictにめっちゃ時間かかります。

QQPのKaggle Leaderboardは[こんな感じ](https://www.kaggle.com/c/quora-question-pairs/leaderboard)になっています。

<img width="975" alt="スクリーンショット 2018-12-20 23.12.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/47605/b9e7d4d2-0628-0a3b-bf42-0b50836dd639.png">

スコアはLoglossです。0.11〜で、ひしめき合っていますね。

では今回の結果はどうだったか...

<img width="805" alt="スクリーンショット 2018-12-20 23.16.03.png" src="https://qiita-image-store.s3.amazonaws.com/0/47605/79566b12-7e51-e07e-d466-9ea4e0a8193e.png">

**0.38154** ... !!!
全然ダメやん。
※BERTがダメというわけではありません。私のタスクのあて方がダメなのです。（後述）

## Kaggleでダメだった理由

なんでダメなんだろ。ダメにしても、もう少し良い結果なのかな、と思っていた。
一応、スクリプトの処理など見直してみたけど特に引っかかるものはなし。

そこで、今度はKaggleのkernelから適当なものを引っ張ってきてモデル作って
逆にそれをGLUEに投稿してみた。

使ったkernelは[[ LB 0.18+ ] LSTM with GloVe and magic features](https://www.kaggle.com/lystdo/lb-0-18-lstm-with-glove-and-magic-features)というもの。（まるコピーです。ありがとうございました。）
このモデルではLogloss 0.18くらいだったようです。
共起している単語の頻度とったり、[GloVe](https://nlp.stanford.edu/projects/glove/)というEmbeddingを使ったり、らしい。

すると、こちらの結果は...

<img width="1267" alt="スクリーンショット 2018-12-20 23.25.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/47605/4ed03a09-d974-4f4b-58ca-cff3667c3806.png">

なるほど、Google本家のも含めてBERTより良いスコア出てますね。
QQP「だけ」なら、Kagglerの方が強かったわけです。

## GLUEって

...さて、唐突かつ今更なのですが、GLUEって
The **G**eneral **L**anguage **U**nderstanding **E**valuation
なんですね。

あぁ...`General`って...そういうこと？

あらかじめ知っておけ、というか気づけ、という話なのですが
GLUEって11タスクを **汎用的に** 解くことができるモデルを目指すもの、なんですね。きっと。

[GLUEのpaperがある](https://www.nyu.edu/projects/bowman/glue.pdf)のですが、Abstractに思いっきり書いていました。

>  it must be general: it must be able to process language in a way that is not exclusively 
tailored to any one specific task or dataset.

つまり特定のタスクに特化したモデルを目指すものじゃないよ、と。
BERTは、それら様々なタスクで、より汎用的な言語モデルを獲得できたことで話題になったのでした。

だからKaggleで世界の猛者データサイエンティストたちが、コンペタスクのみに特化したモデルや前処理、試行錯誤しまくったものに
理論も分かってないタダのおっさんがちょっとGithubのスクリプトそのまま叩いたくらいで勝てるわけないだろ、と。
（いや、勝てるとは思ってなかったですよ、一応...）
Kagglerナメんなよ、と。
BERTもうちょい調べてから来いよ、と。

# BERT試して感じたこと

最後はちょっと自分の無知ゆえ、打ちのめされた感じになってしまいましたが、とりあえず簡単に動かすことはできます、BERT。
今回はGLUEでやってみましたが、もちろんオリジナルのタスクに適用することもできると思います。

fine tuning自体はGPUでも数時間で出来るものもあるので、例えば自身で保有しているコーパスで事前学習モデル作成しておいて
タスクごとに短い時間、かつ少量のドメイン特化コーパスで良いモデルが得られるのであれば、それだけでもありがたいです。

また、有効な前処理とかも全然調べられていないので、その辺もまだまだ余地があると思います。
今回私がやったようにそのまま適用しただけでは、Kaggleで負けてしまいましたが
きっとアプローチを変えれば、もっと良いモデルができるのでしょう。
業務でNLPの分類を扱っているものもいくつかあるので、そちらでも試してみる価値はありそうです。

# 蛇足1

これを書いているときに気づいたのですが、GLUEの１位が、BERTを抜いてMicrosoftのモデルに変わったようです。
https://gluebenchmark.com/leaderboard

<img width="1718" alt="スクリーンショット 2018-12-20 23.47.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/47605/1df9e253-08b2-1f67-dc00-eeff2bb6aafc.png">

日進月歩ですねぇ（他人事）

# 蛇足2

これを書いているときに気づいたのですが、[Kaggle Advent Calendar 2018](https://qiita.com/advent-calendar/2018/kaggle)の明日のネタが
@namakemono さんの「BERT出たので，過去問(Quora)で性能を調べてみます」のようで、まる被りしてしまいました。。
わざとじゃないです、本当。でも、なんかすみません。

私の内容は間違っていたり不足があったりするかもなので、明日拝見して勉強させていただきます！