HuggingFace(BERT)を使ってお手軽に日本語ニュースを分類する

# 0.はじめに
今回は自然言語処理のAI分類に関してBERTを活用する方法を書いていきます。
**BERT関連の記事を見ると割と難解でウワッナニコレ・・となる人も少なくないかな？と個人的に感じてる**のでいつものようになるべく平易な用語とコメントで記事書いていきます。

★本記事を読めば、自作データセットを同じように作って分類もできるようになるので最後まで読んでいただけると幸いです。
★本記事ではBERTのアルゴリズム詳細等は記載してません。まずは実装してから理解すればいいと思ってます。

* 動作環境
 * OS : Windows10 pro(64bit) + メモリ6GBの貧弱GPU
 * Python : 3.8.3// Miniconda 4.9.1
 * transformers : 4.2.2
 * torch : 1.7.1
 * jupyter notebook

# 1. 事前準備

今回はニュースの分類なので当然サンプルデータが必要になる。

## 1-1.livedoor ニュースコーパスをダウンロードし解凍
今回のサンプルは`livedoor ニュースコーパス`を使用します。
まずは以下URLより`ldcc-20140209.tar.gz`をダウンロードし、解凍までしてください。
※解凍するとtextというフォルダになるので、それをプログラムと同じ階層に配置する

https://www.rondhuit.com/download.html#ldcc

:::note warn
拡張子が.gzですが普通に解凍できます(少し時間かかります)
コマンドだと「tar zxvf ldcc-20140209.tar.gz」で解凍できます
:::

## 1-2.livedoor ニュースコーパスをデータフレーム化する

解凍した中身を見ると「独女通信、家電チャンネル」等の9つの出版社毎にフォルダが分かれており、その中に大量のテキストファイルが配置されている。

このままでは使えないので、これをまとめて1個のデータ(csv)に変換させる必要がある。
`9つだとちょっと多いので今回は例として3媒体を抜粋し、タイトルからどの出版社に属するか？を予測するデータセットを以下のプログラムで作成`する。


```Python:
import os
import pandas as pd

def get_title_list(path):
    """記事タイトル取得関数"""
    title_list = []
    filenames = os.listdir(path) #ファイル名称一覧取得
    for filename in filenames:
        # 1記事ずつファイルの読み込み
        with open(path+filename, encoding="utf_8_sig") as f:
            title = f.readlines()[2].strip() #各記事テキストの改行2番目に記事タイトルが記載してある
            title_list.append(title)
    return title_list
       
# データセットの生成(タイトルとラベル付与)
"""
今回は例として与えられた記事タイトルから
どのニュース媒体記事なのか？(独女通信、ITライフハック、MOVIE ENTERの3種類)
を分類する為のデータセットを作成する
"""
df = pd.DataFrame(columns=['label', 'sentence']) #空データフレーム

#独女通信(ラベル0)
title_list = get_title_list('./text/dokujo-tsushin/')
for title in title_list:
    df = df.append({'label':0 , 'sentence':title}, ignore_index=True) #ignore_indexで合体後のindexを連番に

#ITライフハック(ラベル1)
title_list = get_title_list('./text/it-life-hack/')
for title in title_list:
    df = df.append({'label':1 , 'sentence':title}, ignore_index=True)

#MOVIE ENTER(ラベル2)
title_list = get_title_list('./text/movie-enter/')
for title in title_list:
    df = df.append({'label':2 , 'sentence':title}, ignore_index=True)

# 全データの順番をシャッフル(+index振り直し)
df = df.sample(frac=1 ,random_state=0).reset_index(drop=True)

#一応csvとしても保存しておく
df.to_csv('livedoor_sentence.csv', sep=',', index=False, encoding='utf_8_sig')
```

## 1-3.中身を確認する
作成したデータフレームを一応確認する。
**※sentence(記事のタイトル)をBERTに与えて、何のlabelか(どの出版社？)を分類するデータ**
**※これと同じような形でデータフレームを作れば以後使いまわしで自作データでも分類可能**

```Python:
df.head(2)#例として2行表示
```

|  |label|sentence|
|---|---|---|
|0  |1  |楽しくリズム感覚が身につく「おやこでリズムえほんプラス」【iPhoneでチャンスを掴め】  |
|1  |2  |懐かしのゲームブックをネットで再現 「鷹の団のガッツになってドルドレイを攻略せよ！」  |

<br>

一応ラベルの分布状況も確認しておく

```Python:
import collections
print(collections.Counter(df['label']))
```
```:実行結果
Counter({1: 871, 2: 871, 0: 871})
```

# 2.HuggingFaceを使用して分類を行う

**・HuggingFace Transformers とは？**
アメリカHugging Face社が提供している、自然言語処理に特化したディープラーニングのフレームワーク。言語の分類、情報抽出、質問応答、要約、翻訳、テキスト生成等の言語処理タスクを実行するための事前学習モデルが提供されている。
また、`PyTorch`と`TensorFlow 2.0`の両方に対応している。※本記事はPytorch


まずはインストールですが。`pip install transformers[ja]`で導入できます。


## 2-1.tokenizer(日本語用)の設定
まずは`tokenizer(文書を最小単位のトークンに分けて入力データへと変換する処理)`に関して。

今回は「`cl-tohoku/bert-base-japanese-v2`」という事前学習モデルを使う。
これはMeCabが使用されていて、デフォルトの辞書は「UniDic」が使用されている。
これを使ってどんな感じに文章がが最小単位にトークナイズされるかも確認してみる

```Python:
from transformers import BertJapaneseTokenizer

"""
・BertJapaneseTokenizerは日本語用のBERTトークナイザ
・from_pretrainedで指定されたPytorchモデルの事前学習を実行する
・"cl-tohoku/bert-base-japanese-v2"は東北大学の日本語事前学習用BERTモデル ※下記補足参照
・do_subword_tokenizeは、サブワードのトークン化をするかどうか
・mecab_kwargsでMeCabユーザー辞書やNeoLogd等の指定も可能
　※"mecab_dic": Noneでデフォルト辞書(UniDic)をOFFにする必要あり
"""
tokenizer = BertJapaneseTokenizer.from_pretrained(
    "cl-tohoku/bert-base-japanese-v2", 
    #do_subword_tokenize=False,
    #mecab_kwargs={"mecab_dic": None, "mecab_option": "-d 'C:\mecab-unidic-neologd'"
)

#適当なキーワードでトークナイズしてみる
text = "楽しくリズム感覚が身につく"

#tokenizer.encodeでテキストをトークンIDに,return_tensors='pt'でPytorch型のテンソル型に変換
ids = tokenizer.encode(text, return_tensors='pt')[0]
wakati = tokenizer.convert_ids_to_tokens(ids) #どのようにトークナイズされたか分かち書きで確認
print(ids)
print(wakati)
```

```:実行結果
tensor([    2, 32589, 17651, 16947,   862,  5128,   893, 12953,     3])
['[CLS]', '楽しく', 'リズム', '感覚', 'が', '身', 'に', 'つく', '[SEP]']
```

※ここでやってることは下図　「テキスト(文字列)⇒トークンID(数字)に変換⇒Tensor型に変換」

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373060/b1d3a8c3-27c4-26a9-f2ea-afae5764bc8c.png" width=80%>

※補足：[東北大学日本語事前学習用BERTモデル一覧のリンク](https://huggingface.co/cl-tohoku)

:::note info
東北大のモデルは9つ(※9/21現在)ある。よく出てくる(？)3つの違い。
・bert-base-japanese-whole-word-maskingは2019年9月の日本語版Wikiで学習(IPAdic)
・bert-base-japanese-v2は2020年8月の日本語版Wikiで学習。BERTのBaseモデルを使用(UniDic)
・bert-large-japaneseは↑の-v2と基本同じだがBERTのLargeモデルを使用(UniDic)
:::

※用語補足
[CLS] : 文頭に埋め込むスペシャルトークン
[SEP] : 文章の区切りに使用されるスペシャルトークン
BERT Baseモデル ：12層、ハイパーパラメーター数1.1億
BERT Largeモデル：24層、ハイパーパラメーター数3.4億 (※PC性能あればこっち使った方がいい)
IPAdic：IPA辞書(形態素解析の辞書)
UniDic：国立国語研究所が開発している辞書 ※今はIPAよりこれが推奨されてるらしい
whole-word-masking ：マスキングをランダムでなくサブワードを加味している(**性能がいいのでWikiが古いがよく使われてると思われる**)

## 2-2.分類器の設定
トークナイザが終わったので、BERTの分類器を設定する

```Python:
"""
・BertForSequenceClassificationはBEETの最終層をクラス分類に変えたもの
・事前学習はトークナイザと同じものを指定する
・num_labelsで分類数を指定する
"""
import torch
from transformers import BertForSequenceClassification

model = BertForSequenceClassification.from_pretrained(
    "cl-tohoku/bert-base-japanese-v2",
    num_labels = 3
)

#学習モードに設定
model.train()

#使用デバイス設定(CPU,GPU)
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
model.to(device) #modelをGPUに転送

#オプティマイザの設定
from transformers import AdamW
optimizer = AdamW(model.parameters(), lr=1e-5)
```

## 2-3.乱数固定＋データセット作成
まずは乱数seedを固定する

```Python:
#乱数のseedを全固定する
import random

def seed_everything(seed):
    random.seed(seed)
    os.environ['PYTHONHASHSEED'] = str(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True

seed_everything(0)
```
後はいつものPytorchデータセットのお決まりの流れ。

```Python:
from torch.utils.data import TensorDataset, random_split, DataLoader, SequentialSampler, RandomSampler
from sklearn.model_selection import train_test_split

"""
・encodingで分類対象の文をトークナイザ
・input_idsはトークンID
・attention_mask はパディング用に埋め込み文字化どうかの判断用
・train_labels は分類ラベル
"""
encoding = tokenizer(df['sentence'].tolist(), return_tensors='pt', padding=True, truncation=True)
input_ids = encoding['input_ids']
attention_mask = encoding['attention_mask']
train_labels = torch.tensor(df['label'].tolist())

#データセット作成
dataset = TensorDataset(input_ids, attention_mask, train_labels)

#学習とテストの割合　※ここでは9割学習
train_size = int(0.9*len(dataset))
val_size= len(dataset) - train_size
train_dataset, val_dataset= random_split(dataset, [train_size, val_size])

print("学習データ数: {}" .format(train_size))
print("テストデータ数: {}" .format(val_size))
```
```:実行結果
学習データ数:　2351
テストデータ数:　262
```

次に後で結果見るためにテストデータの元sentenceをデータフレームで保持しておく
※学習データはデータローダーにてシャッフルしてしまうので、ここではテストデータのみ保存する

```Python:
import pandas as pd
import numpy as np

for i in range(val_dataset.__len__()):
    #別の方法あるかもだが、文字置換でスペシャルトークン(CLSとか)を消している
    sentence = tokenizer.decode(val_dataset.__getitem__(i)[0].detach().numpy().tolist()).replace('[CLS]', '').replace('[PAD]', '').replace('[SEP]', '').replace(' ', '')
    df_val_sentence_kari = pd.DataFrame(sentence.split(), columns={"sentence"})
        
    if i==0:
        df_val_sentence = df_val_sentence_kari.copy()
    else:
        df_val_sentence = pd.concat([df_val_sentence_kari, df_val_sentence], axis=0)

df_val_sentence.head(2) #お試しで2行表示
```

|  |sentence|
|---|---|
|0  |もうプロポーズを待たない女たち |
|0  | 写真魂のバトンリレー!GRデジタルをバトンに若き写真家たちの駅伝写真展がスタート|

## 2-4.データローダー作成
これもいつものPytorchのお決まりのやつ。

```Python:
batch_size = 8

train_dataloader = DataLoader(
    train_dataset,
    sampler = RandomSampler(train_dataset),
    batch_size = batch_size
)

validation_dataloader = DataLoader(
    val_dataset,
#     sampler = RandomSampler(val_dataset), #先ほど元sentenceを保存した為シャッフルしない
    batch_size = 1 #テストなので1にした
)
```

## 2-5.学習
ids,mask,labelの3つを入力している以外はいつものPytorchの学習ループパターン

```Python:
def train(model):
    """学習ループ用関数"""
    model.train()
    train_loss = 0
    
    for batch in train_dataloader:
        b_input_ids = batch[0].to(device) #トークンID
        b_input_mask = batch[1].to(device) #埋め込み文字判断
        b_labels = batch[2].to(device) #正解ラベル
        optimizer.zero_grad()
        
        outputs = model(b_input_ids, token_type_ids=None, attention_mask=b_input_mask, labels=b_labels)
        loss = outputs.loss
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        train_loss += loss.item()    
    return train_loss

max_epoch = 10 #Epoch数は適当に
train_loss_ = []

for epoch in range(max_epoch):
    train_ = train(model)
    train_loss_.append(train_)
    
    print(train_)
```

```:実行結果
108.53635125933215
29.553316734847613
・
・
```

## 2-6.テスト
作成したモデルを使ってテストする

```Python:
model.eval() #検証モード
preds_list = []
b_labels_list = []

for batch in validation_dataloader:
    b_input_ids = batch[0].to(device)
    b_input_mask = batch[1].to(device)
    b_labels = batch[2].to(device) #ラベルはモデルへ入力しないが正解確認用
    
    with torch.no_grad():
        preds = model(b_input_ids, token_type_ids=None, attention_mask=b_input_mask)
        preds_list.append(preds) #予測ラベル
        b_labels_list.append(b_labels) #正解ラベル

#データフレームで結果を可視化する
for i in range(len(preds_list)):
    #preds_list[i][0]で一番確率が高いものを予測ラベルと判断(よくある分類のパターンと同じ)
    df_pred = pd.DataFrame(np.argmax(preds_list[i][0].cpu().numpy(), axis=1), columns={"pred_label"})
    df_label = pd.DataFrame(b_labels_list[i].cpu().numpy(), columns={"true_label"})
    df_result_kari = pd.concat([df_pred, df_label], axis=1)
    
    if i==0:
        df_result = df_result_kari.copy()
    else:
        df_result = pd.concat([df_result_kari, df_result], axis=0)

df_result = pd.concat([df_val_sentence, df_result], axis=1) #元タイトル(2-3で準備)と合体

df_result.head(2) #お試しで2行表示
```

|  |sentence|pred_label|true_label|
|---|---|---|---|
|0  |もうプロポーズを待たない女たち|0|0|
|0  |写真魂のバトンリレー!GRデジタルをバトンに若き写真家たちの駅伝写真展がスタート|1|1|


こんな感じで予測ラベルと正解ラベルの比較ができる

# 3.さいごに

ちょっと長くなったのでこの記事はここで終わろうと思います。
HuggingFaceを使うことで簡単に最新の事前学習モデルを活用した言語処理が可能なので、分類以外にも色々活用できると思うので色々触ってもらうきっかけになればいいかなぁなんて考えてます。
それでは今回はここまで。

**追記　:　本記事のコメント欄に@reluさんがプログラム部分をGoogleColabとしてまとめてくださってます。そちらもご参照下さい**


# 4.追記
本記事を「Trainer」で書き直したものも投稿しましたのでご参考までに。

https://qiita.com/ku_a_i/items/c7792be568615f451b98

## コーディング参考リンク

・https://huggingface.co/transformers/custom_datasets.html
・https://huggingface.co/transformers/model_doc/bert_japanese.html
・https://qiita.com/karaage0703/items/30485c2ba1c396760982
・https://note.com/npaka/n/n6df2be2a91c5