BERTを用いて、日本語文章の多値分類を行う

この記事では、Google ColaboratoryのGPUを使用してBERTを動かし、日本語文章の多値分類を行います。

前回の記事の続きです。重なる部分が多いと思います。
[Google Colaboratory上でBERTを使い、日本語文章の二値分類を行う](https://qiita.com/Yuu94/items/e43fafe472ba36c838d8)

学習済みモデルは、GoogleDriveに保存し、GoogleColaboratoryにGoogleDriveをマウントして使用します。

**＊注意**
GoogleDriveに学習済みモデルとデータセットを保存するため、1.6GB以上の空きが必要です。
一度だけ試す場合は、GoogleDriveとGoogleColaboratory、を連携せず、GoogleColaboratoryのGPUインスタンス上にモデルやデータセットを保存する方が良いかもしれません。

# BERTを使用した日本語文章の多値分類
ここでは、BERTとBERT日本語学習済みモデル、livedoor newsコーパスを使用して、GoogleColaboratory上で学習済みモデルのfine-tuningと文章の多値分類を行います。
分類は記事のタイトルを使用して行います。

fine-tuning、分類はBERTの公式が公開している`run_classifier.py`のコードを一部変更して行います。

基本的な流れは前回作成した記事と同じですので、詳しい方法を知りたい方は下記の記事をご確認ください。
[Google Colaboratory上でBERTを使い、日本語文章の二値分類を行う](https://qiita.com/Yuu94/items/e43fafe472ba36c838d8)

それでは、早速実行していきましょう。

# 用意するもの
- Googleアカウント

# 多値分類の実施
今回は、[livedoor newsコーパス](https://www.rondhuit.com/download.html)の中から以下のジャンルを分類します。

- dokujo-tsushin
- it-life-hack
- kaden-channel
- livedoor-homme
- movie-enter
- peachy
- smax
- sports-watch
- topic-news


分類に使用する情報は、記事のタイトルです。
(データセットをBERT向けのフォーマットに変換の際に使用するプログラムを変更することで、本文を用いたfine-tuning、予測が可能になります。)

内容を保存したColaboratory notebookを私のGitHubに公開していますので、ソースコードの方が理解しやすい方はそちらをご覧ください。

[Yuu94/bert-ja-maruchi-classification](https://github.com/Yuu94/bert-ja-maruchi-classification)

本記事は以下のような流れをとります。

1. 形態素解析器のインストール
1. GoogleDriveをマウント
1. データセット/BERT日本語学習済みモデルをGoogleDriveへ保存
1. データセットをBERT向けのフォーマットに変換
1. BERTのリポジトリをClone
1. プログラムの改変
1. trainデータを使用した、学習済みモデルのfine-tuning
1. テストデータの予測
1. 予測結果の検証

日本語学習済みモデルは 京都大学 黒橋・河原研究所が公開している`BERT日本語Pretrainedモデル`を、形態素解析器は`JUMAN`を使用します。

最終的には、GoogleDrive上が以下のようなディレクトリ構成になります。
上手く読み込めない等の問題があれば、これを確認しながら作業してみてください。

```text
マイドライブ/
　└ bert/
      └ livedoor_news
          ├ Japanese_L-12_H-768_A-12_E-30_BPE/ # 日本語学習済みモデル
          ├ tmp/ # 出力ファイル
              ├ livedoor_news_output_predic/
              └ livedoor_news_output_fine/
          ├ bert/
              ├tokenization.py
              └ run_classifier_livedoor.py
          ├ train.tsv
          ├ test.tsv
          ├ rand-all.tsv
          ├ dev.tsv
          ├ all.tsv
          └ livedoor_news_multi.ipynb
```

# 形態素解析器のインストール
## JUMANのインストール
今回の学習済みモデルは、 京都大学 黒橋・河原研究所が公開している`BERT日本語Pretrainedモデル`を使用します。
上記のモデルは、同研究室が公開している日本語形態素解析器システム`JUMAN`を使用してモデルを学習させているため、同じ形態素解析器を使用します。
異なる形態素解析器を使用すると、学習済みモデルを作成した際の単語の最小単位と、新しくfine-tuningまたは入力データの単語の最小単位が異なり、新たに入力した単語が未知の単語扱いになるため、単語をベクトル化することができません。

学習済みモデルを使用する際、どの形態素解析器を使用すれば良いのかは、モデルを公開しているページに記述されていることが多いので、そこを確認する必要があります。
京都大学 黒橋・河原研究所が公開している`BERT日本語Pretrainedモデル`のページにも、どの形態素解析器を使用すれば良いのか記述がされているので、リンクを貼ります。
[BERT日本語Pretrainedモデル - KUROHASHI-KAWAHARA LAB - 詳細](http://nlp.ist.i.kyoto-u.ac.jp/index.php?BERT日本語Pretrainedモデル#r6199008)

それでは、Colaboratory notebookを使用して、GPUマシンにJUMANをインストールします。

下記のコマンドをセルに貼り付け、実行していください。

```
!wget https://github.com/ku-nlp/jumanpp/releases/download/v2.0.0-rc2/jumanpp-2.0.0-rc2.tar.xz && \
tar xJvf jumanpp-2.0.0-rc2.tar.xz && \
rm jumanpp-2.0.0-rc2.tar.xz && \
cd jumanpp-2.0.0-rc2/ && \
mkdir bld && \
cd bld && \
cmake .. \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX=/usr/local && \
make && \
sudo make install
```

以下のコマンドで、JUMANが問題なくインストールされているか確認します。

```
!jumanpp -v
```
`Juman++ Version: 2.0.0-rc2`と表示されれば、インストール完了です。

## TensorFlowのバージョン変更
本チュートリアルはTensorFlow 1系で動作するプログラムであるため、以下のコマンドを実行してTensorFlowのバージョンを1系に変更します。
デフォルトではTensorFlowは2系です。

```
%tensorflow_version 1.x
```

## pyknpのインストール
JUMANをPythonで使用するため、`pyknp`をpipインストールします。

pipはサーバーにインストール済みなので、以下のコマンドをセルに貼り付け、`pyknp`をインストールします。

```
!pip install pyknp
```

# GoogleDriveをマウント
学習済みモデルやデータセットを保存するGoogleDriveをマウントします。

以下のコードをセルに貼り付け、実行します。

```
from google.colab import drive 


drive.mount('/content/drive')
```

次に作業ディレクトリの作成と移動を行います。
今回は、GoogleDrive直下に`bert`ディレクトリを作成し、その中に`livedoor_news`ディレクトリを作成してその中で作業を行います。

そのため、以下のコマンドを使用して、ディレクトリを作成します。
(*別々のセルでコマンドを実行してください。)

```
!mkdir -p /content/drive/'My Drive'/bert/livedoor_news
```

```
cd /content/drive/'My Drive'/bert/livedoor_news
```

# データセット/BERT日本語学習済みモデルをGoogleDriveへ保存
## livedoor newsコーパスのダウンロード
下記のコマンドを使用して、データセットをダウンロードします。

```
import urllib.request

livedoor_news_url = "https://www.rondhuit.com/download/ldcc-20140209.tar.gz"
urllib.request.urlretrieve(livedoor_news_url, "ldcc-20140209.tar.gz")
```

## BERT日本語学習済みモデル
**＊注意**
学習済みモデルの容量1.6GBあるので、GoogleDriveの容量、ダウンロード時間にはご注意ください。
私の場合、ダウンロードは1時間以上かかった記憶がありますので、ダウンロードコマンドを実行したら、放置した方が良いと思います。

下記のコマンドをセルに貼り付け、実行し、モデルをダウンロードします。

```
kyoto_u_bert_url = "http://nlp.ist.i.kyoto-u.ac.jp/nl-resource/JapaneseBertPretrainedModel/Japanese_L-12_H-768_A-12_E-30_BPE.zip"
urllib.request.urlretrieve(kyoto_u_bert_url, "Japanese_L-12_H-768_A-12_E-30_BPE.zip")
```

ダウンロードが完了し、GoogleDriveの`bert/livedoor_news`に`Japanese_L-12_H-768_A-12_E-30_BPE.zip`が保存されていることを確認したら、下記のコマンドをColaboratory notebookで実行し、ZIPを解凍します。

```
!unzip Japanese_L-12_H-768_A-12_E-30_BPE.zip
```

問題がなければ、GoogleDrive上に`bert/livedoor_news/Japanese_L-12_H-768_A-12_E-30_BPE`ディレクトリが作成されます。

# データセットをBERT向けのフォーマットに変換
ダウンロードしたlivedoor newsコーパスを、BERTで扱いやすいようなフォーマットに変換します。
下記の記事を参考に作成しました。
[BERT多言語モデルで日本語文章の二値分類を試す](https://qiita.com/knok/items/9e3b4505d6b8f813943d)

次のプログラムをセルに貼り付け、実行します。
(実行後、GoogleDriveにマウントされるまで少し時間に差があります。)

```
import tarfile
import csv
import re


target_genre = [
                "dokujo-tsushin",
                "it-life-hack",
                "kaden-channel",
                "livedoor-homme",
                "movie-enter",
                "peachy",
                "smax",
                "sports-watch",
                "topic-news"
                ]

fname_list = [[] for i in range(len(target_genre))]

tsv_fname = "all.tsv"

brackets_tail = re.compile('【[^】]*】$')
brackets_head = re.compile('^【[^】]*】')

def remove_brackets(inp):
    output = re.sub(brackets_head, '',re.sub(brackets_tail, '', inp))

    return output

def read_title(f):
    next(f)
    next(f)
    title = next(f)
    title = remove_brackets(title.decode('utf-8'))

    return title[:-1]

with tarfile.open("ldcc-20140209.tar.gz") as tf:
    for ti in tf:
        if "LICENSE.txt" in ti.name:
            continue
        elif "CHANGES.txt" in ti.name:
            continue
        elif "README.txt" in ti.name:
            continue
        else:
            for i, t in enumerate(target_genre):
                if target_genre[i] in ti.name and ti.name.endswith(".txt"):
                    fname_list[i].append(ti.name)
                    continue

    with open(tsv_fname, "w") as wf:
        writer = csv.writer(wf, delimiter='\t')
        for i, fcategory in enumerate(fname_list):
            for name in fcategory:
                f = tf.extractfile(name)
                title = read_title(f)
                row = [target_genre[i], i, '', title]
                writer.writerow(row)
```

`bert/livedoor_news/`に`all.tsv`の作成が確認できたら、次のコードを実行して、学習用/テスト用のtsvファイルに分割します。

```
import random

random.seed(100)
with open("all.tsv", 'r') as f, open("rand-all.tsv", "w") as wf:
    lines = f.readlines()
    random.shuffle(lines)
    for line in lines:
        wf.write(line)

random.seed(101)

train_fname, dev_fname, test_fname = ["train.tsv", "dev.tsv", "test.tsv"]

with open("rand-all.tsv") as f, open(train_fname, "w") as tf, open(dev_fname, "w") as df, open(test_fname, "w") as ef:
    ef.write("class\tsentence\n")
    for line in f:
        v = random.randint(0, 9)
        if v == 8:
            df.write(line)
        elif v == 9:
            ef.write(line)
        else:
            tf.write(line)
```

# BERTのリポジトリをClone
BERTの公式リポジトリを以下のコマンドをセルにコピー/実行してクローンします。

```
!git clone https://github.com/google-research/bert.git
```

# プログラムの改変
ここでは、BERTのチュートリアルにある文章の等価性を評価するタスク(MRPC)のプログラムを改変し、文章の多値分類を行います。
そのため、Colaboratory notebookからGoogleDriveにCloneした`run_classifier.py`と`tokenization.py`をローカルで編集します。
今回は、元のプログラムを残したいので、`run_classifier.py`をローカルでコピーし、`run_classifier_livedoor.py`と名前を変更して中身を改変することにします。
`tokenization.py`に関しては、名前が変わるとプログラムを呼び出す部分も変更する必要があり(少々面倒なので)このままの名前で編集します。

下記の記事を参考に、改変を行いました。
[自然言語処理で注目のBERT ~取り敢えず動かしてみる編~](https://qiita.com/neonsk/items/27424d6122e00fe632b0)

## run_classifier_livedoor.pyの変更

変更する場所は以下の2点です。

1. LivedoorProcessorクラスの追加
1. 引数からLivedoorProcessorを呼び出せるようprocessorsに設定

### LivedoorProcessorクラスの追加
既に作成されている`ColaProcessor`クラスの下に、新しいクラス`LivedoorProcessor`を作成します。

```python:run_classifier_livedoor.py
class LivedoorProcessor(DataProcessor):
  """Processor for the MRPC 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", "2", "3", "4", "5", "6", "7", "8"]

  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)
      text_a = tokenization.convert_to_unicode(line[3])
      if set_type == "test":
        label = "0"
      else:
        label = tokenization.convert_to_unicode(line[1])
      examples.append(
          InputExample(guid=guid, text_a=text_a, text_b=None, label=label))
    return examples
```

プログラムのベースは`MrpcProcessor`クラスですが、2つの文章(text_a, text_b)を比較する部分を一つの文章(text_a)に変更しています。
また、今回はたち分類ですので、`def get_labels`の部分で9つのラベルを使用する様に変更しています。

### processorsの設定
次に、`main関数`の中にある`processors`に先ほど追加したクラスの情報を追記します。

```python:run_classifier_livedoor.py
  processors = {
      "cola": ColaProcessor,
      "mnli": MnliProcessor,
      "mrpc": MrpcProcessor,
      "xnli": XnliProcessor,
      "livedoor": LivedoorProcessor, # 追記
  }
```

## tokenization.pyの変更
今回は、形態素解析器にJUMANを使用している為、`tokenization.py`にJUMANを使用して形態素解析を行うのプログラムを記述します。
`FullTokenizer`クラスの`__init__`と`tokenize`部位分を変更します。

```python:tokenization.py
class FullTokenizer(object):
  """Runs end-to-end tokenziation."""

  def __init__(self, vocab_file, do_lower_case=True):
    self.vocab = load_vocab(vocab_file)
    self.inv_vocab = {v: k for k, v in self.vocab.items()}
    # Jumanを使用する様に変更
    # self.basic_tokenizer = BasicTokenizer(do_lower_case=do_lower_case)
    self.jumanpp_tokenizer = JumanPPTokenizer()
    self.wordpiece_tokenizer = WordpieceTokenizer(vocab=self.vocab)

  def tokenize(self, text):
    split_tokens = []
    # Jumanを使用する様に変更
    # for token in self.basic_tokenizer.tokenize(text):
    for token in self.jumanpp_tokenizer.tokenize(text):
      for sub_token in self.wordpiece_tokenizer.tokenize(token):
        split_tokens.append(sub_token)

    return split_tokens
```
これで、形態素解析を行う際にJUMANが使用されます。

# trainデータを使用した、学習済みモデルのfine-tuning
改変したプログラムを使用して、fine-tuningを行います。

以下のコマンドを実行してfine-tuningを行います。

```
!python bert/run_classifier_livedoor.py \
--task_name=livedoor \
--do_train=true \
--do_eval=true \
--data_dir=./ \
--vocab_file=./Japanese_L-12_H-768_A-12_E-30_BPE/vocab.txt \
--bert_config_file=./Japanese_L-12_H-768_A-12_E-30_BPE/bert_config.json \
--init_checkpoint=./Japanese_L-12_H-768_A-12_E-30_BPE/bert_model.ckpt \
--max_seq_length=128 \
--train_batch_size=32 \
--learning_rate=2e-5 \
--num_train_epochs=3.0 \
--output_dir=./tmp/livedoor_news_output_fine \
--do_lower_case False
```

問題なければ、以下の様に結果が出力されます。

```
INFO:tensorflow:***** Eval results *****
I0113 04:51:41.729829 140011245029248 run_classifier_livedoor.py:963] ***** Eval results *****
INFO:tensorflow:  eval_accuracy = 0.875
I0113 04:51:41.730009 140011245029248 run_classifier_livedoor.py:965]   eval_accuracy = 0.875
INFO:tensorflow:  eval_loss = 0.40959355
I0113 04:51:41.734117 140011245029248 run_classifier_livedoor.py:965]   eval_loss = 0.40959355
INFO:tensorflow:  global_step = 131
I0113 04:51:41.734362 140011245029248 run_classifier_livedoor.py:965]   global_step = 131
INFO:tensorflow:  loss = 0.40959355
I0113 04:51:41.734567 140011245029248 run_classifier_livedoor.py:965]   loss = 0.40959355
```

# テストデータの予測
fine-tuningを行なったモデルを使用して、test.tsvを多値分類します。

以下のコマンドを使用して、テストデータの予測を行います。

```
!python bert/run_classifier_livedoor.py \
  --task_name=livedoor \
  --do_predict=true \
  --data_dir=./ \
  --vocab_file=./Japanese_L-12_H-768_A-12_E-30_BPE/vocab.txt \
  --bert_config_file=./Japanese_L-12_H-768_A-12_E-30_BPE/bert_config.json \
  --init_checkpoint=./tmp/livedoor_news_output_fine \
  --max_seq_length=128 \
  --output_dir=tmp/livedoor_news_output_predic/
```
問題がなければ、GoogleDriveの`tmp/livedoor_news_output_predic/`に`test_results.tsv`が出力されます。
(出力結果がマウントされるのにし少し時間がかかります。)

# 予測結果の検証
text.tsvには、既に正解のカラムがありますので、BERTを使用して予測した値と正解の値を比較し、正解率を求めます。

下記のコードをセルにコピーして実行します。

```
import csv
import numpy as np


with open("./test.tsv") as f, open("tmp/livedoor_news_output_predic/test_results.tsv") as rf:
  test = csv.reader(f, delimiter = '\t')
  test_result = csv.reader(rf, delimiter = '\t')

  # 正解データの抽出
  next(test)
  test_list = [int(row[1]) for row in test ]

  # 予測結果を抽出
  result_list = []
  for result in test_result:
    max_index = np.argmax(result)
    result_list.append(max_index)

  # 分類した予測結果(カテゴリNo)を出力
  with open('tmp/livedoor_news_output_predic/test_results.csv', 'w') as of:
    writer = csv.writer(of)
    for row in result_list:
      writer.writerow([row])

  test_count = len(test_list)
  result_correct_answer_list = [result for test, result in zip(test_list, result_list) if test == result]
  result_correct_answer_count = len(result_correct_answer_list)
  print("正解率: ", result_correct_answer_count / test_count)
```
＊ 今回は、分類結果を`tmp/livedoor_news_output_predic/test_results.csv`に出力するプログラムを追加しました。多値分類ができていることを確認してみてください。

特に何もせず高い正解率が出ていると思います。

# 最後に
BERTで日本語の処理を行う手順をさらっとですが、ここで紹介しました。
BERTのチュートリアルから日本語の二値分類、そして多値分類と一通り紹介することができたので、次はBERTを理解したり何かに活用できるようにしたいと思います。

最後までお付き合いありがとうございました。

# 参考
- [BERT多言語モデルで日本語文章の二値分類を試す](https://qiita.com/knok/items/9e3b4505d6b8f813943d)
- [Beginner’s Guide to BERT for Multi-classification Task](https://towardsdatascience.com/beginners-guide-to-bert-for-multi-classification-task-92f5445c2d7c)