BERTを用いたエントリーシートの合否分類AIの作成

# はじめに
現在就活中でESを書きことが多いので、書いたESが通過しやすいかどうかを判別するモデルを作成してみました。
あくまで遊び半分で作ったので、似たようなモデルを作りたい人や結果だけでも気になる人はぜひ見てください。
# 環境
|OS|使用言語|Editor|
|:---:|:---:|:--:|
|Windows10|Python3.10.5|VScode|
### ライブラリ
・transformers 4.19.2
・fugashi 1.1.2
・ipadic 1.0.0
・torch 1.11.0+cu113
・pytorch-lightning 1.6.4
・mecab of 0.996
# 学習データ
データ数は計70個（∵落選したESのサンプル数が少ないため）
合格したESの数：不合格のESの数 = 1:1
|本文|ESのジャンル|合格|不合格|
|:---:|:---:|:--:|:--:|
|私は学生時代に、...|ガクチカ|1|0|
|私の就活の軸は、...|就活の軸|0|1|
|貴社のSEとして...|志望動機|0|1|
|私の強みは粘り...|自己PR|1|0|
# コード
## ライブラリの準備
日本語の事前学習モデルは東北大学が開発したモデルを使用しました。
汎用性が高いのが特徴です。
https://github.com/cl-tohoku/bert-japanese
```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 transformers import ElectraForPreTraining, ElectraTokenizerFast
from sklearn.metrics import accuracy_score
import math
# 日本語の事前学習モデル
MODEL_NAME = 'cl-tohoku/bert-base-japanese-whole-word-masking'
```
## マルチラベル分類を行うためのクラス
今回は2値分類なのでBertForSequenceClassificationでもよかったが、マルチラベル分類のほうが応用がききやすいと思い、このクラスを使用しました。
```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
```
## データの前処理
Tokenizerに入力するためのデータを加工
```python
#ファインチューニングするためのデータの前処理
df1 = pd.read_excel('学習データ')
finetuning_labels_lists = df1.drop(['本文','ESのジャンル'],axis=1).values.tolist()
dataset=[]
index = 0
for sentences in df1['本文']:
    sentence = sentences
    finetuning_labels_list = finetuning_labels_lists[index]
    sample = {'text':sentence , 'labels':finetuning_labels_list}
    dataset.append(sample)
    index+=1
```
## データローダの作成
加工したデータをTokenizerに入力し、データセットを作成。
今回用いる学習データは長文なため、BERTの最大値である512トークンに設定した。
作成したデータセットを　__学習データ：検証データ：テストデータ = 6:2:2__　に分割する。
分割する割合は8:1:1、7:1.5:1.5のようにいろいろあり、明確な決まりはない。
分割したデータからデータローダを作成。
学習データでのバッチサイズについては性能によりますが、大体の人は8になると思います。
```python
# トークナイザのロード
tokenizer = BertJapaneseTokenizer.from_pretrained(MODEL_NAME)
# 各データの形式を整える
max_length = 512
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
    )
    encoding['labels'] = labels
    encoding = { k: torch.tensor(v) for k, v in encoding.items() }
    dataset_for_loader.append(encoding)
# データセットの分割
random.shuffle(dataset_for_loader) 
n = len(dataset_for_loader)
n_train = int(0.6*n)
n_val = int(0.2*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)
```
## PyTorch-Lightningを用いたファインチューニングとテスト
PyTorch-LightningとはPyTorchで書く必要な処理がメソッド化されたことで、コーディングを簡単にできるフレーワークです。
PyTorch Lightningを用いるこで、開発スピードを高めることが出来ます。
ここでの処理は、データによらず共通しているのでこのクラスを用います。
```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
        labels_predicted = ( scores > 0 ).int()
        num_correct = ( labels_predicted == 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が指定した最適なモデルの重みを指定
```python
checkpoint = pl.callbacks.ModelCheckpoint(
    monitor='val_loss',
    mode='min',
    save_top_k=1,
    save_weights_only=True,
    dirpath='model/',
)
trainer = pl.Trainer(
    gpus=1, 
    max_epochs=15,
    callbacks = [checkpoint]
)
```
### 設定した条件下でファインチューニングと評価
東北大学が開発した事前学習モデル、ラベルの数を2、学習率を1e-5と設定
学習時の学習データと検証データの損失値を時間ごとに見れるtensorbordを活用すると、パラメータの調節がしやすくなります。
```python
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.7857142686843872
accuracy            0.5714285969734192
accuracy            0.7142857313156128
```
## 作成したモデルで実際に分類してみた
```python
text_list=['長いので省略']
text_label = [[0,1],[1,0],[1,0],[1,0]]
# モデルのロード
best_model_path = checkpoint.best_model_path
model = BertForSequenceClassificationMultiLabel_pl.load_from_checkpoint(best_model_path)
bert_scml = model.bert_scml.cuda()
# データの符号化
encoding = tokenizer(
    text_list, 
    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
```
出力された分類スコア「scores」をSoftMax関数を用いてそれぞれのカテゴリーの予測確率を出力するが、コードでは省略しています。
```
入力：私が学生時代に一番頑張ったことは、哲学を勉強したことです。私は大学の授業で西洋哲学を履修して 
います。授業の課題でアリストテレスの輪読が出ましたが、私には難しい課題で苦戦しました。しかし何度も 
読み進めるうちに理解が深まり、無事にレポートを提出できました。
正解：[0, 1]
予測：[0, 1]
[0.118556603609516, 0.881443396390484]
--
入力：私が学生時代に一番頑張ったことは、授業の履修者同士で結束を強め、全員をA評価にしたことです。私
は受講生の8割が単位を取れないと言われる西洋哲学の授業を履修しました。そこで脱落しそうな生徒が多いこ
とに気づき、授業後に集まって勉強会を主催しました。勉強会ではメンバーそれぞれが強みを発揮し、最後に 
は全員がA評価をもらえました。
正解：[1, 0]
予測：[0, 1]
[0.2777838870634747, 0.7222161129365253]
--
入力：私が御社を志望した理由は、御社のインターンシップで営業支援ソフトウェアの開発に携わり、大きな 
やりがいを感じたからです。御社は常に画期的な営業支援ソフトウェアを開発しており、ユーザーの視点に立 
ったきめ細かな設計をされています。インターンシップ中、社員の方々が納得のいくまで何度でも試行錯誤す 
る姿に感動し、自分もぜひその一員になりたいと強く思いました。その後独学でプログラミング言語を学び、 
スキルを用いてスマートフォンのアプリ制作をしたことがあります。インターネットで公開したところ、多く 
の人に喜んでもらえ、コンテンツを開発する楽しさも知ることができました。ITには大きな可能性があり、現 
在はITのベースを支えるインフラエンジニアにも興味があります。御社に入社後はソフトウェア技術者として 
しっかりスキルを磨き、自己研鑽しながら今までにない画期的な商品アイディアを提案できる開発者として御 
社に貢献したいです。
正解：[1, 0]
予測：[1, 0]
[0.7321102605998581, 0.267889739400142]
--
入力：私が貴社を志望する理由は、貴社の商品を通して出会いから別れまでの全ての人の繋がりに寄り添いた 
いと考えているからです。私は、お酒には人の情緒を動かす力があると感じています。その力をより多くの人 
に届けて今までなかった新しい幸せや喜びを感じて欲しいと考えます。その中で貴社の原料から販売までの一 
人一人の想いを大切にしている点、そして想いを受け止めて挑戦し続ける社員を応援する体制や風土がある点 
に魅力を感じました。私の「常に成長したいという熱意」が最大限発揮できると考え貴社を強く志望します。 
正解：[1, 0]
予測：[1, 0]
[0.8532395615155416, 0.14676043848445844]
```
__正答率75%となった__
# 考察
数値的には、良い結果に見えるがデータ数が少なすぎるので何とも言えないのが現状。
学習データ数が多ければ、文中の単語の頻出度を見て特徴がみられると思う。
# さいごに
なんとなくで作ったモデルだが、一応参考になりそうなのでESを書いたら入力してみようと思います。
とにかく、不合格のESのサンプル集めが大変だった。。。
これからもデータを集めていこうと思います。
リクルート系の会社とかならいいAI作れそう。