SIGNATE Student Cup 2022 入門編②【BERTによる学習・予測の詳細解説】


# 目次
[1.はじめに](#1-はじめに)
[2.モデルの概要](#2-モデルの概要)
[3.ファイル場所の指定](#3-ファイル場所の指定)
[4.ライブラリのインポート](#4-ライブラリのインポート)
[5.乱数とデバイスの指定](#5-乱数とデバイスの指定)
[6.基本設定](#6-基本設定)
[7.データセットの作成](#7-データセットの作成)
[8.モデルの定義](#8-モデルの定義)
[9.スコアの出力](#9-スコアの出力)
[10.学習結果の可視化](#10-学習結果の可視化)
[11.学習](#11-学習)
[12.学習の実行](#12-学習の実行)
[13.予測](#13-予測)
[14.最後に](#14-最後に)

# 1. はじめに
本記事は、[SIGNATE Student Cup 2022【予測部門】](https://signate.jp/competitions/724)に参加する方に向けて、一緒に取り組めればと思い作成しました！

今回からSIGNATEに挑戦したい！初めて自然言語処理を始めてみよう！という方に読んでもらいたいです！

【第一弾の記事】

https://qiita.com/yama09/items/f2f94b6bdf0ce8e83ddb

【第三弾の記事】

https://qiita.com/yama09/items/2eb188f62cdfb1d23fa0


第二弾は、BERTでの学習・予測です。自然言語処理といえば、BERTというイメージがあったので、紹介できれば思います！（自然言語は初めてのため、間違っている認識があるかもしれませんがご了承ください....）




:::note info
BERTコードの引用
yaneura-no-gomiさんのコードを解説していきます。私は、初学者でしたがとても可読性の高い、見やすいコードだったので理解しやすかったです！（使用許可も頂き、ありがとうございます。）
:::

:::note warn
警告
- コード作成者がSIGNATE Student Cup 2020に参加したときのものになっており、discussionなどで共有されていたものも含んでいる。
- コード作成者にとってBERT初実装だったため良くない実装が含まれている可能性や最新版に対応していない可能性がある。
:::

> [yaneura-no-gomiさんのBERTコード（本記事での使用許可を頂いております）](https://github.com/yaneura-no-gomi/SIGNATE_Student_Cup_2020/blob/master/src/02_BERT/run.py)

# 2. モデルの概要

**BERT**とは、**B**idirectional **E**ncoder **R**epresentations from **T**ransformersの略称のことで、直訳するとTransformerによる双方向のエンコード表現という意味です。

簡単に言うと、Transformerという学習モデルのエンコーダーを使用したモデルということです。詳細については、分からない部分が多いのでBERTの論文解説をしている記事で確認してみてください。
> [自然言語処理の王様「BERT」の論文を徹底解説](https://qiita.com/omiita/items/72998858efc19a368e50)
>[自然言語処理の必須知識 BERT を徹底解説！](https://deepsquare.jp/2020/09/bert/)

# 3. ファイル場所の指定
```
----- data    ----- train.csv , test.csv , submit_sample.csv
----- output  ----- 
----- models  ----- 
----- result  ----- 
----- figures ----- 
BERT.py（今回のコードをまとめたもの）
```
プログラム作成にあたって、上手く動かすためにBERT用のファイルを5個作成します。dataファイルの中にだけ、コンペのデータを入れ、残りのファイルは空のままにします。5個のファイルと同じ場所にBERT.pyを置いて実行すると上手く動作します。

# 4. ライブラリのインポート
```python
import collections
import os
import random

import matplotlib.pyplot as plt
import nlp
import numpy as np
import pandas as pd
import seaborn as sns
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from sklearn.metrics import classification_report, confusion_matrix, f1_score
from sklearn.model_selection import StratifiedKFold
from tqdm.notebook import tqdm
from transformers import AdamW, AutoModel, AutoTokenizer
```
最初に、今回使用するライブラリのインポートを行っていきます。ライブラリは、難しい処理を簡単なプログラムで書けるようにしてくれている便利なパッケージのようなものです。

もし、Import Errorというエラーメッセージが出た際には
```python
pip install [ライブラリ名]
```
というコマンドを入力することでPCにダウンロードされてないライブラリをインストールしてくれます。

# 5. 乱数とデバイスの指定
```python
# seeds
SEED = 42
def seed_everything(seed):
    random.seed(seed)
    os.environ['PYTHONHASHSEED'] = str(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)

seed_everything(SEED)

if torch.cuda.is_available():
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False
    current_device = torch.cuda.current_device()
    print("Device:", torch.cuda.get_device_name(current_device))
```
機械学習で予測するとき常に同じ結果が出てくるとは限りません。これは、学習の中で多くの計算を行う際に乱数を使用したりするためです。したがって、たまたま出た良い結果なのか、学習がうまくいって良い結果なのかを判別するために、乱数（シード）を指定する必要があります。

学習モデルの再現性を保つ場合にも、非常に有効であるため、最初に設定していきます。

また、今回は自然言語というメモリを多く使用する処理を行うので``GPU``を使用します。CPUで自然言語モデルを扱うこともできますが、計算時間がかかってしまうのでGPUの方がおすすめです。（Google Colabでも、無料でGPUを使うことができます）

上手くデバイスの指定が出来ると、デバイス名が出力されます。

# 6. 基本設定
```python
# config
data_dir = os.path.join(os.environ["HOME"], "Workspace/learning/signate/SIGNATE_Student_Cup_2020/data")
DEVICE = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
TRAIN_FILE = os.path.join(data_dir, "train.csv")
TEST_FILE = os.path.join(data_dir, "test.csv")
MODELS_DIR = "./models/"
MODEL_NAME = 'bert-base-uncased'
TRAIN_BATCH_SIZE = 32
VALID_BATCH_SIZE = 128
NUM_CLASSES = 4
EPOCHS = 5
NUM_SPLITS = 5
```
BERTでの予測にあたり、どんなパラメータで動作させるかを書いてあります。重要な部分のみ説明していきます。


|パラメータ名 |概要  |
|----|----|
|MODEL_NAME|使用する自然言語モデルを選択します。今回は、BERTのモデルでよく使用されている``bert-base-uncased``を使用します。[Hugging Faceのサイト](https://huggingface.co/models?pipeline_tag=text-classification&sort=downloads)から好きなモデルを選ぶこともできます。（モデルよって出力などが異なるので、プログラム修正が必要） |
|BATCH_SIZE|学習時に最適なパラメータを探索するときに、データを小さく分割して探索を行っていきます。（学習用・検証用とかのデータ分割ではないです）この時の、データサイズのことです。機械学習の慣例として、$2^n$ で決めます。 |
|EPOCHS|学習する回数のことです。1回で十分に学習できることは、ほとんどないので5回～10回など学習させていきます。|
|NUM_SPLITS|[K分割交差検証](https://di-acc2.com/analytics/ai/6498/#index_id2)の分割数を決めます。データ数により変わりますが、5個で分割するのが一般的です。|
>[Hugging Face / models ](https://huggingface.co/models?pipeline_tag=text-classification&sort=downloads)
>[【AI・機械学習】ホールドアウト検証とK分割交差検証(K-foldクロスバリデーション)｜モデル性能の評価](https://di-acc2.com/analytics/ai/6498/#index_id2)

# 7. データセットの作成
```python
# dataset
def make_folded_df(csv_file, num_splits=5):
    df = pd.read_csv(csv_file)
    df["jobflag"] = df["jobflag"] - 1
    df["kfold"] = np.nan
    df = df.rename(columns={'jobflag': 'labels'})
    label = df["labels"].tolist()

    skfold = StratifiedKFold(num_splits, shuffle=True, random_state=SEED)
    for fold, (_, valid_indexes) in enumerate(skfold.split(range(len(label)), label)):
        for i in valid_indexes:
            df.iat[i,3] = fold
    return df

def make_dataset(df, tokenizer, device):
    dataset = nlp.Dataset.from_pandas(df)
    dataset = dataset.map(
        lambda example: tokenizer(example["description"],
                                  padding="max_length",
                                  truncation=True,
                                  max_length=128))
    dataset.set_format(type='torch', 
                       columns=['input_ids', 'token_type_ids', 'attention_mask', 'labels'], 
                       device=device)
    return dataset
```
#### make_folded_df
ここでは、データを処理しやすいように目的変数を変形したり、Ｋ交差検証のためのデータ分割を行っています。ここで行っている交差検証は、分割後のデータの目的数に偏りが出ない分割を行っています。（Stratified K-Fold）
#### make_dataset
BERTが処理できるようなデータ形式に変更していきます。ここで、データ形式の変更に使用するのが``Tokenizer``です。簡単に説明すると、Tokenizerは文章を単語（トークン）ごとに区切って分解し、IDに変更するモジュールです。使用しているパラメータは以下のようになっています。
|パラメータ名 （Tokenizer）|概要  |
|----|----|
|padding|単語数を揃えるために、0などで埋めることです。30語の文章と20語の文章があった際に、文字数を揃えるため20語の方に0を10個つけるようなイメージです。（max_lengthとするのが一般的なようです）|
|truncation|切り捨てを指定します。（Trueとすると、最大の文字数で切り捨て）|
|max_length|処理する文章の最大の単語数を指定します。|


Tokenizerで処理された後に、データ形式を綺麗に揃えることを行います。綺麗に揃えることで、BERTだけでなくRobertaにも使えるような形式に変換されます。変換後にどうなるかを以下にまとめました。


|データ形式|概要  |
|----|----|
|input_ids|単語にIDを割り振ったもの。最初と最後に[CLS] [SEP]が付いてます。|
|token_type_ids|文章を判別するためのバイナリマスク。0,1のどちらかが割り当てられて、文の判別に使われます。|
|attention_mask|埋め込みを判別するバイナリーマスク（詳細は、BERTの解説論文）|
|labels|正解ラベル（教師データ）|

# 8. モデルの定義
``` Python
# model
class Classifier(nn.Module):
    def __init__(self, model_name, num_classes=4):
        super().__init__()

        self.bert = AutoModel.from_pretrained(model_name)
        self.dropout = nn.Dropout(0.1)
        self.linear = nn.Linear(768, num_classes)
        nn.init.normal_(self.linear.weight, std=0.02)
        nn.init.zeros_(self.linear.bias)

    def forward(self, input_ids, attention_mask, token_type_ids):
        output, _ = self.bert(
            input_ids = input_ids,
            attention_mask = attention_mask,
            token_type_ids = token_type_ids,
            return_dict=False) # Pythonの実行上必要なので加筆しました。
        output = output[:, 0, :]
        output = self.dropout(output)
        output = self.linear(output)
        return output
```

### __init__
初期化条件が記載されています。
|変数|概要  |
|----|----|
|bert|AutoModel（指定されたモデルの構造を選んでくれるライブラリ）を使って、BERTモデルを作成しています。|
|dropout|ドロップアウト層を定義しています。|
|linear|全結合層を定義しています。|
|linear.weight|重みの初期化|
|linear.bias|バイアスの初期化|

### forward
モデル構造が記載されています。ここを変更することで、BERTの構造を変化させることができそうです。今回だと、テキスト分類を行うために、ドロップアウト層と全結合層を定義しています。

# 9. スコアの出力
``` Python
# training function
def train_fn(dataloader, model, criterion, optimizer, scheduler, device, epoch):
    
    model.train()
    total_loss = 0
    total_corrects = 0
    all_labels = []
    all_preds = []

    progress = tqdm(dataloader, total=len(dataloader))

    for i, batch in enumerate(progress):
        progress.set_description(f"<Train> Epoch{epoch+1}")

        attention_mask, input_ids, labels, token_type_ids = batch.values()
        del batch

        optimizer.zero_grad()

        outputs = model(input_ids, attention_mask, token_type_ids)
        del input_ids, attention_mask, token_type_ids
        loss = criterion(outputs, labels)  # 損失を計算
        _, preds = torch.max(outputs, 1)  # ラベルを予測
        del outputs

        loss.backward()
        optimizer.step()
        scheduler.step()

        total_loss += loss.item()
        del loss
        total_corrects += torch.sum(preds == labels)

        all_labels += labels.tolist()
        all_preds += preds.tolist()
        del labels, preds

        progress.set_postfix(loss=total_loss/(i+1), f1=f1_score(all_labels, all_preds, average="macro"))

    train_loss = total_loss / len(dataloader)
    train_acc = total_corrects.double().cpu().detach().numpy() / len(dataloader.dataset)
    train_f1 = f1_score(all_labels, all_preds, average="macro")

    return train_loss, train_acc, train_f1


def eval_fn(dataloader, model, criterion, device, epoch):
    model.eval()
    total_loss = 0
    total_corrects = 0
    all_labels = []
    all_preds = []

    with torch.no_grad():
        progress = tqdm(dataloader, total=len(dataloader))
        
        for i, batch in enumerate(progress):
            progress.set_description(f"<Valid> Epoch{epoch+1}")

            attention_mask, input_ids, labels, token_type_ids = batch.values()
            del batch

            outputs = model(input_ids, attention_mask, token_type_ids)
            del input_ids, attention_mask, token_type_ids
            loss = criterion(outputs, labels)
            _, preds = torch.max(outputs, 1)
            del outputs

            total_loss += loss.item()
            del loss
            total_corrects += torch.sum(preds == labels)

            all_labels += labels.tolist()
            all_preds += preds.tolist()
            del labels, preds

            progress.set_postfix(loss=total_loss/(i+1), f1=f1_score(all_labels, all_preds, average="macro"))

    valid_loss = total_loss / len(dataloader)
    valid_acc = total_corrects.double().cpu().detach().numpy() / len(dataloader.dataset)

    valid_f1 = f1_score(all_labels, all_preds, average="macro")

    return valid_loss, valid_acc, valid_f1
```
この関数では、学習結果のスコアを算出しています。スコアを出力する際、３つ出力させています。この関数に変更を加えても精度に影響しないため、説明は省略します。

|スコア|概要  |
|----|----|
|Loss|モデルによる予測と正解との誤差の大きさを表した値のこと|
|Accuracy|正解率のこと|
|F1 Score|今回のコンペの評価指標|


# 10. 学習結果の可視化
``` Python
def plot_training(train_losses, train_accs, train_f1s,
                  valid_losses, valid_accs, valid_f1s,
                  epoch, fold):
    
    loss_df = pd.DataFrame({"Train":train_losses,
                            "Valid":valid_losses},
                        index=range(1, epoch+2))
    loss_ax = sns.lineplot(data=loss_df).get_figure()
    loss_ax.savefig(f"./figures/loss_plot_fold={fold}.png", dpi=300)
    loss_ax.clf()

    acc_df = pd.DataFrame({"Train":train_accs,
                           "Valid":valid_accs},
                          index=range(1, epoch+2))
    acc_ax = sns.lineplot(data=acc_df).get_figure()
    acc_ax.savefig(f"./figures/acc_plot_fold={fold}.png", dpi=300)
    acc_ax.clf()

    f1_df = pd.DataFrame({"Train":train_f1s,
                          "Valid":valid_f1s},
                         index=range(1, epoch+2))
    f1_ax = sns.lineplot(data=f1_df).get_figure()
    f1_ax.savefig(f"./figures/f1_plot_fold={fold}.png", dpi=300)
    f1_ax.clf()
``` 
学習の結果を算出しています。この結果は、前のセクションで扱った３つの評価指標をEpochごとの時間的な遷移で可視化しています。評価指標の変化を追うことで、最適なEpoch、学習率などを決める手がかりにもなるので見てみましょう。

# 11. 学習
``` Python
def trainer(fold, df):
    
    train_df = df[df.kfold != fold].reset_index(drop=True)
    valid_df = df[df.kfold == fold].reset_index(drop=True)

    tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

    train_dataset = make_dataset(train_df, tokenizer, DEVICE)
    valid_dataset = make_dataset(valid_df, tokenizer, DEVICE)

    train_dataloader = torch.utils.data.DataLoader(
        train_dataset, batch_size=TRAIN_BATCH_SIZE, shuffle=True
    )
    valid_dataloader = torch.utils.data.DataLoader(
        valid_dataset, batch_size=VALID_BATCH_SIZE, shuffle=False
    )

    model = Classifier(MODEL_NAME, num_classes=NUM_CLASSES)
    model = model.to(DEVICE)

    criterion = nn.CrossEntropyLoss()
    optimizer = AdamW(model.parameters(), lr=2e-5)
    scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=100000, gamma=1.0)
    # ダミーのスケジューラー

    train_losses = []
    train_accs = []
    train_f1s = []
    valid_losses = []
    valid_accs = []
    valid_f1s = []

    best_loss = np.inf
    best_acc = 0
    best_f1 = 0

    for epoch in range(EPOCHS):
        train_loss, train_acc, train_f1 = train_fn(train_dataloader, model, criterion, optimizer, scheduler, DEVICE, epoch)
        valid_loss, valid_acc, valid_f1 = eval_fn(valid_dataloader, model, criterion, DEVICE, epoch)
        print(f"Loss: {valid_loss}  Acc: {valid_acc}  f1: {valid_f1}  ", end="")

        train_losses.append(train_loss)
        train_accs.append(train_acc)
        train_f1s.append(train_f1)
        valid_losses.append(valid_loss)
        valid_accs.append(valid_acc)
        valid_f1s.append(valid_f1)

        plot_training(train_losses, train_accs, train_f1s,
                      valid_losses, valid_accs, valid_f1s,
                      epoch, fold)
        
        best_loss = valid_loss if valid_loss < best_loss else best_loss
        besl_acc = valid_acc if valid_acc > best_acc else best_acc
        if valid_f1 > best_f1:
            best_f1 = valid_f1
            print("model saving!", end="")
            torch.save(model.state_dict(), MODELS_DIR + f"best_{MODEL_NAME}_{fold}.pth")
        print("\n")

    return best_f1

```
今まで作成してきた関数を組み合わせて、学習に関するコードをまとめたものを作成します。

|ステップ|概要|
|-----|-----|
|1-Step　　|データの分割を実施|
|2-Step|データをトークン化|
|3-Step|トークンをデータセットに変形|
|4-Step|データセットをバッチサイズに分割|
|5-Step|モデルの定義|
|6-Step|損失関数の指定（学習の際には、損失を小さくするため損失関数により結果が変わる場合がある）|
|7-Step|optimizerの指定（損失を最適化するためのアルゴリズム）|
|8-Step|schedulerの指定（学習率を自動で変動させるアルゴリズム）|
|9-Step|学習の開始|
|10-Step|学習結果の可視化を保存|
|11-Step|学習モデルを保存|

損失関数,optimizer,schedulerについては、変化させれば学習結果が変わる可能性が高いです。よって、この部分を変化させると精度が向上するかもしれません。


>[【決定版】スーパーわかりやすい最適化アルゴリズム -損失関数からAdamとニュートン法-](https://qiita.com/omiita/items/1735c1d048fe5f611f80#7-adam)
>[【PyTorch】エポックに応じて自動で学習率を変えるtorch.optim.lr_scheduler](https://take-tech-engineer.com/pytorch-lr-scheduler/)


# 12. 学習の実行
``` Python
# training
df = make_folded_df(TRAIN_FILE, NUM_SPLITS)
f1_scores = []
for fold in range(NUM_SPLITS):
    print(f"fold {fold}", "="*80)
    f1 = trainer(fold, df)
    f1_scores.append(f1)
    print(f"<fold={fold}> best score: {f1}\n")

cv = sum(f1_scores) / len(f1_scores)
print(f"CV: {cv}")

lines = ""
for i, f1 in enumerate(f1_scores):
    line = f"fold={i}: {f1}\n"
    lines += line
lines += f"CV    : {cv}"
with open(f"./result/{MODEL_NAME}_result.txt", mode='w') as f:
    f.write(lines)
```
今まで作成してきた関数たちを実行するだけです。したがって、解説は省略します。

# 13. 予測
```Python
# inference
models = []
for fold in range(NUM_SPLITS):
    model = Classifier(MODEL_NAME)
    model.load_state_dict(torch.load(MODELS_DIR + f"best_{MODEL_NAME}_{fold}.pth"))
    model.to(DEVICE)
    model.eval()
    models.append(model)

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
test_df = pd.read_csv(TEST_FILE)
test_df["labels"] = -1
test_dataset = make_dataset(test_df, tokenizer, DEVICE)
test_dataloader = torch.utils.data.DataLoader(
    test_dataset, batch_size=VALID_BATCH_SIZE, shuffle=False)

with torch.no_grad():
    progress = tqdm(test_dataloader, total=len(test_dataloader))
    final_output = []

    for batch in progress:
        progress.set_description("<Test>")

        attention_mask, input_ids, labels, token_type_ids = batch.values()

        outputs = []
        for model in models:
            output = model(input_ids, attention_mask, token_type_ids)
            outputs.append(output)

        outputs = sum(outputs) / len(outputs)
        outputs = torch.softmax(outputs, dim=1).cpu().detach().tolist()
        outputs = np.argmax(outputs, axis=1)

        final_output.extend(outputs)

submit = pd.read_csv(os.path.join(data_dir, "submit_sample.csv"), names=["id", "labels"])
submit["labels"] = final_output
submit["labels"] = submit["labels"] + 1
try:
    submit.to_csv("./output/submission_cv{}.csv".format(str(cv).replace(".", "")[:10]), index=False, header=False)
except NameError:
    submit.to_csv("./output/submission.csv", index=False, header=False)
```
学習済みのモデルを読み込んで、今まで作成してきた関数たちを実行するだけです。したがって、解説は省略します。

# 14. 最後に
これでBERTによる学習・予測ができました。お疲れ様でした！

BERTを理解すれば、Robertaなど他のモデルを試すこともできると思います。残り約1か月ほどありますが、一緒に頑張っていきましょう！

本記事を気に入って頂けたら、「LGTMボタン、コンペのフォーラムでのいいね」をお願いします！