PyTorchのBERTで日本語ニュース分類

# はじめに
[Hugging Face](https://huggingface.co)が[Transformers](https://huggingface.co/docs/transformers/index)というライブラリを公開しており、その中にはBERTの実装、さらには日本語対応した事前学習モデルが用意されています。

このライブラリを使えば、BERTを使って簡単にさまざまなfine-tuningができるようなので、試してみました。

以下では、BERTを使ったカテゴリ分類の手順について説明をしていきます。

# BERTによる日本語ニュース分類

今回は、日本語のニュース（ライブドアニュース）をカテゴリ分類する簡単なfine-tuningを行います。

BERTを使った学習は、2段階になっています。
1段階目は事前学習で、ここで汎用的な言語モデルの作成が行われます。
2段階目はfine-tuningで、事前学習で作成されたモデルに対して、課題に応じた学習を行います。

今回は、事前学習済みモデルを使って、fine-tuningを行います。

## 環境
macOS 12.5
Python 3.10.3

## ライブラリのインストール
まず、今回のプログラムを実行するのに必要なライブラリをインストールします。
```sh
pip install scikit-learn
pip install transformers
pip install fugashi
pip install ipadic
pip install datasets
```

transformersが今回使用するメインのライブラリ、fugashi、ipadicは日本語のトークナイザーで必要となります。

## データの準備
ダウンロードしたlivedoorニュースを加工し、タグ（カテゴリ）とセットにしてCSVとして保存するプログラム`dataset.py`を作成します。

### データセットの準備
RONDHUIT社の[ダウンロードページ](https://www.rondhuit.com/download.html#ldcc)から、ldcc-20140209.tar.gzをダウンロードし、任意のディレクトリに解凍します。
本稿では、作成するプログラムと同じディレクトリに解凍し、ディレクトリ名を`livedoor_news`としています。

プログラムを実行する前に、あらかじめdata、model、results、logsディレクトリを作成しておいてください（classification.py、dataset.py、train.pyは）。
```
.
├── data
├── livedoor_news
│   ├── dokujo-tsushin
│   ├── it-life-hack
│   ├── kaden-channel
│   ├── livedoor-homme
│   ├── movie-enter
│   ├── peachy
│   ├── smax
│   ├── sports-watch
│   ├── topic-news
│   ├── CHANGES.txt
│   └── README.txt
├── model
├── results
├── logs
├── classification.py
├── dataset.py
└── train.py
```

下記のプログラムでは、ニュースの各ファイルの本文を抽出し、改行、全角スペース、タブを除去して、1行の文に変換したのちに、カテゴリの番号（0-9）とのセットにします。
```python:dataset.py
import glob
import os

raw_data_path = "./livedoor_news"  # ライブドアニュースを格納したディレクトリ

dir_files = os.listdir(path=raw_data_path)
dirs = [f for f in dir_files if os.path.isdir(os.path.join(raw_data_path, f))]
text_label_data = []  # 文章とラベル（カテゴリ）のセット

for i in range(len(dirs)):
    dir = dirs[i]
    files = glob.glob(os.path.join(raw_data_path, dir, "*.txt"))

    for file in files:
        if os.path.basename(file) == "LICENSE.txt": # 各ディレクトリにあるLICENSE.txtを除外する
            continue

        with open(file, "r") as f:
            text = f.readlines()[3:]
            text = "".join(text)
            text = text.translate(str.maketrans({"\n":"", "\t":"", "\r":"", "\u3000":""})) 
            text_label_data.append([text, i])
```

### 学習用、テスト用データの作成、保存

先ほど作成した、本文、ラベル（カテゴリ）のセットを、学習用、評価用のデータに分割し、CSVファイル（news_train.csv、news_test.csv）として保存します。


```python:dataset.py
import csv
from sklearn.model_selection import train_test_split

news_train, news_test =  train_test_split(text_label_data, shuffle=True)  # データを学習用とテスト用に分割
data_path = "./data"

with open(os.path.join(data_path, "news_train.csv"), "w") as f:
    writer = csv.writer(f)
    writer.writerows(news_train) # 

with open(os.path.join(data_path, "news_test.csv"), "w") as f:
    writer = csv.writer(f)
    writer.writerows(news_test)
```

## 学習
先ほど作成したデータを入力とし、BERTを使ってニュースのカテゴリ分類を学習（fine-tuning）させるプログラム`train.py`を作成します。

### モデル、トークナイザーの読み込み

transformersに含まれている文章を分類するためのモデル`BertForSequenceClassification`、日本語を形態素解析するためのトークナイザー`BertJapaneseTokenizer`を読み込みます。

`cl-tohoku/bert-base-japanese-whole-word-masking`は事前学習済みの日本語BERTモデルです。
このモデルは、東北大学の乾研究室によって作成されたもので、[こちらのページ](https://www.nlp.ecei.tohoku.ac.jp/news-release/3284/)で公開されています。

`BertForSequenceClassification`、`BertJapaneseTokenizer`を読み込んだ際に、自動的にダウンロードされるため、あらかじめダウンロードをする必要はありません。

```python:train.py
from transformers import BertForSequenceClassification, BertJapaneseTokenizer

# モデル
model = BertForSequenceClassification.from_pretrained("cl-tohoku/bert-base-japanese-whole-word-masking", num_labels=9)
# model.cuda() # cudaを使う場合は、この行を有効にする
# トークナイザー
tokenizer = BertJapaneseTokenizer.from_pretrained("cl-tohoku/bert-base-japanese-whole-word-masking")
```

### 学習用、テスト用データの読み込み
学習の入力データとして、先ほど保存したデータを読み込みます。

```python:train.py
import os
from datasets import load_dataset

# トークナイズ用関数
def tokenize(batch):
    return tokenizer(batch["text"], padding=True, truncation=True, max_length=128)
    
data_path = "./data"

# 学習用データ
train_data = load_dataset("csv", data_files=os.path.join(data_path, "news_train.csv"), column_names=["text", "label"], split="train")
train_data = train_data.map(tokenize, batched=True, batch_size=len(train_data))
train_data.set_format("torch", columns=["input_ids", "label"])

# テスト用データ
test_data = load_dataset("csv", data_files=os.path.join(data_path, "news_test.csv"), column_names=["text", "label"], split="train")
test_data = test_data.map(tokenize, batched=True, batch_size=len(test_data))
test_data.set_format("torch", columns=["input_ids", "label"]) 
```

### Trainerの初期化
Trainerに、学習対象のモデル、学習用パラメーター、評価用関数、学習用データ、評価用データを設定して初期化します。

```python:train.py
# 評価用関数
from sklearn.metrics import accuracy_score

def compute_metrics(result):
    labels = result.label_ids
    preds = result.predictions.argmax(-1)
    acc = accuracy_score(labels, preds)
    return {
        "accuracy": acc,
    }


# Trainerの設定
from transformers import Trainer, TrainingArguments

# 学習用パラメーター
training_args = TrainingArguments(
    output_dir = "./results",
    num_train_epochs = 2,
    per_device_train_batch_size = 8,
    per_device_eval_batch_size = 32,
    warmup_steps = 500,  # 学習係数が0からこのステップ数で上昇
    weight_decay = 0.01,  # 重みの減衰率
    # evaluate_during_training = True,  # ここの記述はバージョンによっては必要ありません
    logging_dir = "./logs",
)

# Trainerの初期化
trainer = Trainer(
    model = model, # 学習対象のモデル
    args = training_args, # 学習用パラメーター
    compute_metrics = compute_metrics, # 評価用関数
    train_dataset = train_data, # 学習用データ
    eval_dataset = test_data, # テスト用データ
)
```

### モデルの学習
Trainerを使って、モデルの学習、評価を行います。

```python:train.py
trainer.train() # 学習
trainer.evaluate() # 評価
```
cudaが使えない手元のマシンで実行したため、3時間弱かかってしまいました。
ちなみに、Google Colaboratoryでcudaを使って実行した場合は、5分弱で終わりました。


### 学習済みモデルの保存
後で使うために、学習済みモデル、トークナイザーを保存します。

```python:train.py
model_dir = "./model"
trainer.save_model(model_dir)
tokenizer.save_pretrained(model_dir)
```

## 分類の実行
では、学習したモデルを使って、ニュースが正しく分類できるかどうかを確認します。
今回はsports-watchディレクトリにあるsports-watch-4764756.txtを入力として分類を行います。


```python:classification.py
import os
import torch
from transformers import BertForSequenceClassification, BertJapaneseTokenizer

# 学習済みモデルの読み込み
model_dir = "./model"
loaded_model = BertForSequenceClassification.from_pretrained(model_dir)
# loaded_model.cuda() # cudaを使う場合は、この行を有効にする
loaded_tokenizer = BertJapaneseTokenizer.from_pretrained(model_dir)

# 分類するデータの読み込み
file = "./livedoor_news/sports-watch/sports-watch-4764756.txt"  # sports-watchの適当なニュース

with open(file, "r") as f:
    sample_text = f.readlines()[3:]
    sample_text = "".join(sample_text)
    sample_text = sample_text.translate(str.maketrans({"\n":"", "\t":"", "\r":"", "\u3000":""})) 

max_length = 512
words = loaded_tokenizer.tokenize(sample_text)
word_ids = loaded_tokenizer.convert_tokens_to_ids(words)  # 単語をインデックスに変換
word_tensor = torch.tensor([word_ids[:max_length]])  # Tensorに変換

# 予測の実行
# word_tensor.cuda()  # cudaを使う場合は、この行を有効にする
y = loaded_model(word_tensor)  # 結果の予測
pred = y[0].argmax(-1)  # 最大値のインデックス（ディレクトリの番号）

# 結果の標準
path = "./livedoor_news"
dir_files = os.listdir(path=path)
dirs = [f for f in dir_files if os.path.isdir(os.path.join(path, f))]  # ディレクトリ一覧
print("結果は", dirs[pred])
```

実行すると
```
result: sports-watch
```
と表示され、正しく分類ができることを確認できました。

# まとめ
以前は、GPUマシンで数日間かけてBERTに日本語の文章を食わせて、事前学習を行なっていましたが、便利なライブラリの出現により、BERTを使って簡単にさまざまな学習（fine tuning）を試すことができるようになりました。

また、実装が必要なのは、データの作成、読み込みが大部分で、学習自体は数行で行えるため非常に簡単です。

今後は、このライブラリを使って様々なタスクを試していこうと思います。

今回作成したプログラムはGitHubで公開しています。
https://github.com/age884/bert_japanese_news_classification