UTH-BERTをPyTorchで実装

# はじめに
[東京大学大学院の医療AI開発講座](https://ai-health.m.u-tokyo.ac.jp/home/research/uth-bert)が開発したUTH-BERTモデルの実装方法について記事を書いていこうと思いました。
この事前学習モデルの特徴として、医療テキスト１億２千万文を学習し、医療分野に特化しているという点があげられる。

今回はこの事前学習モデルを、PyTorchを活用してファインチューニングする方法を示していこうと思います。
参考にした記事などはtensorflowを用いていますが、私は慣れているPyTorchを使います。

※本記事ではライブラリなどのバージョンを合わせなくても動くと思いますが、パソコンのスペックによってはメモリが不足する可能性があります。また、mecabやneologd、CUDAなどのインストールは済んでいるものとします。

まだまだPython、BERTの勉強中なので拙いコードだとは思いますが大目に見てもらえると幸いです。また、間違い点や疑問点などがありましたらコメントしてもらえると嬉しいです。

# 参考文献

https://qiita.com/Kunikata/items/d9fda2351a273a7412f6

https://github.com/stockmarkteam/bert-book/blob/master/Chapter7.ipynb

# 環境
・Windows10
・VScode
・Python3.9.13
・cuda 11.6
・torch 1.12.0+cu116
・tensorboard 2.9.1
・transformers 4.20.1

# 用いるデータ
看護分野のデータ　
データ数：3300個　ラベル数：11個
訓練データ：テストデータを9：1に分割

__データの例__
ラベル付与の形式はワンホットベクトル化している
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2729175/445f0ec1-817f-a83f-b0bb-57478bbff42c.png)
__※データの前処理は既に行っているものとする__

# UTH-BERTのダウンロード
以下のサイトからUTH_BERT_BASE_512_MC_BPE_WWM_V25000_352K.zip  (pytorch)をダウンロード
※(full)ではなく(pytorch)を選択

https://ai-health.m.u-tokyo.ac.jp/home/research/uth-bert

# 実装コード
## ライブラリのインポート
```python
from operator import index
import random
import tensorboard
import torch
from torch.utils.data import DataLoader
from transformers import BertJapaneseTokenizer, BertModel
import pytorch_lightning as pl
import pandas as pd
from sklearn.metrics import accuracy_score
import math
```

## 事前準備１
ここでは、事前学習モデルの指定・ラベルの数の指定・データの前処理を行っています。

```python
# 日本語の事前学習モデル
MODEL_NAME = 'UTH-BERTの展開先のパス'

#ラベルの数
label_NUM = 11

#ファインチューニングするためのデータの前処理
train_data = pd.read_excel('訓練データのパス')
test_data_1 = pd.read_excel('テストデータのパス')
#ラベルをlist化
train_data_labels = train_data.drop('本文',axis=1).values.tolist()

dataset=[]
index = 0
for sentences in train_data['本文']:
    sentence = sentences
    train_data_label = train_data_labels[index]
    sample = {'text':sentence , 'labels':train_data_label}
    dataset.append(sample)
    index+=1
```
## 事前準備２
ここでは、用いるデータを形態素解析し、BERTの系列長を指定する。
```python
# トークナイザのロード
tokenizer = BertJapaneseTokenizer.from_pretrained(MODEL_NAME,mecab_kwargs={"mecab_option": "C:\\mecab-ipadic-neologd"})

def tokenize_text(s):
    result = []
    for text in s:
        result.append(['[CLS]'] + tokenizer.tokenize(text) + ['[SEP]'])

    return result

train_text_tokenized_train = tokenize_text(train_data['本文'])
test_text_tokenized_1 = tokenize_text(test_data_1['本文'])

maxlen = 0

for tokens in train_text_tokenized_train:
    maxlen = max(maxlen, len(tokens))

for tokens in test_text_tokenized_1:
    maxlen = max(maxlen, len(tokens))
print(maxlen)

# 各データの形式を整える
max_length = maxlen

```
## 入力データの符号化、GPUに転載
```python
dataset_for_loader = []
#文章の符号化
for sample in dataset:
    text = sample['text']
    labels = sample['labels']
    encoding = tokenizer(
        text,
        max_length=max_length,
        padding='max_length',
        truncation=True
    )
    #データをGPUに載せる
    encoding['labels'] = labels
    encoding = { k: torch.tensor(v) for k, v in encoding.items() }
    dataset_for_loader.append(encoding)
```
## データローダの作成
先ほどのデータセットからデータローダを作成する。
ここでのバッチサイズは８となっていますが、GPUのスペックによってはバッチサイズを大きくすることが可能です。
```python
# データセットの分割
random.shuffle(dataset_for_loader) 
n = len(dataset_for_loader)
n_train = int(0.8*n)
n_val = int(0.1*n)
dataset_train = dataset_for_loader[:n_train] # 学習データ
dataset_val = dataset_for_loader[n_train:n_train+n_val] # 検証データ
dataset_test = dataset_for_loader[n_train+n_val:] # テストデータ

#　データセットからデータローダを作成
dataloader_train = DataLoader(
    dataset_train, batch_size=8, shuffle=True
) 
dataloader_val = DataLoader(dataset_val, batch_size=64)
dataloader_test = DataLoader(dataset_test, batch_size=64)
```

## マルチラベル分類を行うためのクラス作成
この記事では、与えられたラベルの中から近い分野のラベルを選択するというタスクだが、応用が利くマルチラベル分類するモデルを用いた。
```python 
class BertForSequenceClassificationMultiLabel(torch.nn.Module):
    
    def __init__(self, model_name, num_labels):
        super().__init__()
        # BertModelのロード
        self.bert = BertModel.from_pretrained(model_name) 
        # 線形変換を初期化しておく
        self.linear = torch.nn.Linear(
            self.bert.config.hidden_size, num_labels
        ) 

    def forward(
        self, 
        input_ids=None, 
        attention_mask=None, 
        token_type_ids=None, 
        labels=None
    ):
        # データを入力しBERTの最終層の出力を得る。
        bert_output = self.bert(
            input_ids=input_ids,
            attention_mask=attention_mask,
            token_type_ids=token_type_ids)
        last_hidden_state = bert_output.last_hidden_state
        
        # [PAD]以外のトークンで隠れ状態の平均をとる
        averaged_hidden_state = \
            (last_hidden_state*attention_mask.unsqueeze(-1)).sum(1) \
            / attention_mask.sum(1, keepdim=True)
        
        # 線形変換
        scores = self.linear(averaged_hidden_state) 
        
        # 出力の形式を整える。
        output = {'logits': scores}

        # labelsが入力に含まれていたら、損失を計算し出力する。
        if labels is not None: 
            loss = torch.nn.BCEWithLogitsLoss()(scores, labels.float())
            output['loss'] = loss
            
        # 属性でアクセスできるようにする。
        output = type('bert_output', (object,), output) 

        return output
```
## PyTorch-Lightningを用いたファインチューニングと性能評価
ここでは、フレームワークであるPyTorch-Lightningを用いてファインチューニングを行います。このクラスは学習のパラメータを設定するだけで学習とテストを実行できるため、効率よく実験できます。
このクラスのポイントとしては
・train_step　・validation_step　・test_step
の3つの関数があげられます。

・train_step：ミニバッチから損失を算出する関数であり、得られた損失値からパラメータを自動で更新するというタスクを行う。
・validation_step , test_step：検証データ、テストデータそれぞれのモデルの評価指標を計算する。

```python
class BertForSequenceClassificationMultiLabel_pl(pl.LightningModule):

    def __init__(self, model_name, num_labels, lr):
        super().__init__()
        self.save_hyperparameters() 
        self.bert_scml = BertForSequenceClassificationMultiLabel(
            model_name, num_labels=num_labels
        ) 

    def training_step(self, batch, batch_idx):
        output = self.bert_scml(**batch)
        loss = output.loss
        self.log('train_loss', loss)
        return loss
        
    def validation_step(self, batch, batch_idx):
        output = self.bert_scml(**batch)
        val_loss = output.loss
        self.log('val_loss', val_loss)

    def test_step(self, batch, batch_idx):
        labels = batch.pop('labels')
        output = self.bert_scml(**batch)
        scores = output.logits
        lp = ( scores > 0 ).int()
        num_correct = ( lp == labels ).all(-1).sum().item()
        accuracy = num_correct/scores.size(0)
        self.log('accuracy', accuracy)

    def configure_optimizers(self):
        return torch.optim.Adam(self.parameters(), lr=self.hparams.lr)
```

## ファインチューニングの設定と評価
ここではファインチューニングの設定を行います。

ModelCheckPointでは、検証データの損失についての条件を指定する。
・monitor='val_loss'：損失値の監視
・mode='min'：損失値が小さいモデルを保存
・save_top_k=1：損失値が最も小さいモデルを保存
・save_weights_only=True：モデルの重みを保存
この方法によって、オーバーフィッティングしたモデルの保存を防ぐことができます。

trainerというクラスで条件を指定して学習を行います。
・gpus=1 ：使用するGPUの数を指定
・max_epochs=15：最大学習回数の指定
・callbacks = [checkpoint]：checkpointが指定した最適なモデルの重みを指定

また、今回の学習率は1e-5とする。
```python
#学習時にもでるの重みを保存する条件を指定
checkpoint = pl.callbacks.model_checkpoint.ModelCheckpoint(
    monitor='val_loss',
    mode='min',
    save_top_k=1,
    save_weights_only=True,
    dirpath='model/',
)

#学習方法の指定
trainer = pl.Trainer(
    gpus=1, 
    max_epochs=10,
    callbacks = [checkpoint]
)

#PyTorch-Lightningモデルのロード
model = BertForSequenceClassificationMultiLabel_pl(
    MODEL_NAME, 
    num_labels=label_NUM, 
    lr=1e-5
)

#ファインチューニングを行う
trainer.fit(model, dataloader_train, dataloader_val)
test = trainer.test(dataloaders=dataloader_test)
print(f'Accuracy: {test[0]["accuracy"]:.10f}')


#結果
#Accuracy: 0.6816326380
```
68％程度の精度が得られることがわかりました。
この作成したモデルを用いてテストデータの推論を行っていきます。

# 作成したモデルで推論を行う
作成したtestingというクラスでは以下の処理を行います。
・ニューラルネットワークから出力された分類スコアをSoftMax関数を用いて予測確率に変換します。
・算出された予測確率から最も高い確率を示した選択肢にラベルを付与する。



```python
# モデルのロード
best_model_path = checkpoint.best_model_path
print('ベストモデルのファイル：　', checkpoint.best_model_path)
print('ベストモデルの検証データに対する損失：　',checkpoint.best_model_score)
model = BertForSequenceClassificationMultiLabel_pl.load_from_checkpoint(best_model_path)
bert_scml = model.bert_scml.cuda()

def testing(tql,tll):
    # データの符号化
    encoding = tokenizer(
    tql, 
    padding='longest',
    return_tensors='pt'
    )
    encoding = { k: v.cuda() for k, v in encoding.items() }

    # BERTへデータを入力し分類スコアを得る。
    with torch.no_grad():
        output = bert_scml(**encoding)
    scores = output.logits

    kakuritu_01=[] #予測するラベルをlistに
    subete_kakuritu_list = [] #softmax関数の計算結果をlistに

    for add in range(len(tql)):
        score_list = scores[add].tolist() #scoresの各行のlistを新しいlistに
        goukei = 0.0
        empty = []
        for i in range(label_NUM):
            goukei += math.exp(score_list[i]) #softmaxの割る部分
        for j in range(label_NUM):
            softmax_pro = math.exp(score_list[j]) / goukei
            empty.append(softmax_pro) 
        subete_kakuritu_list.append(empty) #softmax関数による確率を格納
        or_01=[] #入れるためだけのlist
        address = empty.index(max(empty))
        for xx in range(label_NUM):
            if address==xx:
                or_01.extend([1])
            else:
                or_01.extend([0])
        kakuritu_01.append(or_01)

    index = 0
    acc_doc = 0.0
    for index in range(len(tql)):
        if tll[index] == kakuritu_01[index]:
            acc_doc+=1  
        index+=1
    print(acc_doc/index)

testing(test_question_text1,test_label_list1)

#結果（数回行った結果）
0.7538461538461538
0.704225352112676
0.7846153846153846
0.7605633802816901

# 具体的な結果　入力文は省略
入力：文章１
正解：[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
予測：[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
[0.003692075600576589, 0.005075001983941033, 0.006175272652851684, 0.004746125839516769, 0.005948370198471993, 0.0054634802484065945, 0.004152061412052129, 0.9497300424361814, 0.004444495163441781, 0.006533501930476617, 0.004039572534083578]
--
入力：文章２
正解：[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
予測：[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[0.007886097620075345, 0.012118767660172903, 0.8855975904566018, 0.008482725065849883, 0.010580542340643245, 0.011639895645773607, 0.007961752386671619, 0.015836727270653595, 0.008781598676007644, 0.01104917428795838, 0.020065128589591866]
--
入力：文章３
正解：[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
予測：[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
[0.004030688021784514, 0.004883853766550455, 0.005282295394929196, 0.004906844090083413, 0.006503694870306638, 0.005747536938732837, 0.005379088491994795, 0.9467170460557841, 0.004630990831147263, 0.007427328718851091, 0.004490632819835757]
入力：文章４
正解：[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
予測：[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
[0.008234258405405556, 0.012475723482564842, 0.005631610505923268, 0.011071886716023202, 0.7632582839659615, 0.14749187652544776, 0.009196501974619666, 0.008114431469086875, 0.006858455497152965, 0.019745717212098675, 0.007921254245715492]
--
入力：文章５
正解：[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
予測：[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
[0.005042950919602332, 0.005242961659304102, 0.0048507379654663205, 0.9454098068972069, 0.006029198802225182, 0.006442782681739268, 0.00381830011622627, 0.004606453034984488, 0.004796862857384116, 0.0056165803013647625, 0.008143364764496062]
```
正答率は70%後半の値であったが、各入力文の予測確率を見ると9割近い確率であることから明確に分類できていることがわかります。しかし、不正解の予測ラベルの予測確率も高い値を示すことがあるため、間違っているラベルの特徴的な単語が含まれているなどの原因が考えられます。
解決策としては、各ラベルの単語の頻出度を見て、正しい分類の妨げとなるような単語を文中から削除を行う。または、各ラベルの特有の単語を明確にするという案を考えています。今回はUTH-BERTの実装方法の紹介なのでここまでとします。


# さいごに
UTH-BERTを用いた医療文書の分類を行う方法について紹介しました。今回用いたデータは医療用語が多く含まれているデータを使ったため、分野指定していない文章を学習した事前学習モデルよりは高い性能が出たと思います。
今後は高い汎用性を誇る東北大学が開発したモデルとUTH-BERTモデルの比較を行い、本当に分野特化したモデルの方が良い性能が出るかどうかを実験したいと考えております。
結果が出次第、記事を書いていこうと思います。