9
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

【PyTorch】BERTのfine-tuningを試してみた(SIGNATE Student Cup 2020)

はじめに

夏休み前に,Transformer, BERTを一から勉強し直したこともあって,実際に試してみたいなと思っていたら,ちょうど良さげなコンペ(SIGNATE Student Cup 2020)があって参加していました.
提出した結果を見てみると,BERTをfine-tuningしただけのモデルでも,最終順位50位に入れるくらいの結果が出ていたので,復習も兼ねて簡単にまとめられたらと思い,記事を書くことにしました.
最終順位は色々あって,かなり低くなってしまいました.その辺りのコンペの感想や他に試した手法などについては,はてなブログにSIGNATE Student Cup 2020参加レポートとして書きました.
SIGNATE Student Cup 2020 [予測部門] 参加レポート

Transformer, BERTは,論文と「つくりながら学ぶ!PyTorchによる発展ディープラーニング」を読んで勉強し直したこともあって,実装は「PyTorchでBERTなど各種DLモデルを作りながら学ぶ書籍を執筆しました」の記事中にあるGitHubリポジトリで公開しているものを使わせていただきました.

GitHubリポジトリのリンク↓
https://github.com/YutaroOgawa/pytorch_advanced

環境

Jupyter Notebook
GPUは使わず,今回はCPUを使用
(一応,GPUでも動くようにコード書いてますが,試してないのでGPUでも動く保証はないです)

ノートパソコン

  • MacBook Air (13-inch, Mid 2013)
  • macOS Catalina ver10.15.4
  • プロセッサ: 1.3 GHz デュアルコアIntel Core i5
  • メモリ: 4 GB 1600 MHz DDR3

python関係

python 3.7.7
torch 1.5.0
torchtext 0.5.0
scikit-learn 0.21.3
tqdm 4.48.2
numpy 1.17.2

ディレクトリ構造

GitHubにあるmake_folders_and_data_downloads.ipynbを参考にしました.

make_folders_and_data_downloads.ipynb

import os
import urllib.request
import zipfile
import tarfile
import glob
import io

ディレクトリ「data」,「vocab」,「weights」が存在しない場合は作成

data_dir = "./data/"
if not os.path.exists(data_dir):
    os.mkdir(data_dir)

vocab_dir = "./vocab/"
if not os.path.exists(vocab_dir):
    os.mkdir(vocab_dir)

weights_dir = "./weights/"
if not os.path.exists(weights_dir):
    os.mkdir(weights_dir)

単語集:ボキャブラリーをダウンロード

save_path="./vocab/bert-base-uncased-vocab.txt"
url = "https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-vocab.txt"
urllib.request.urlretrieve(url, save_path)

BERTの学習済みモデルのダウンロード&解凍

# ダウンロード
save_path = "./weights/bert-base-uncased.tar.gz"
url = "https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased.tar.gz"
urllib.request.urlretrieve(url, save_path)

# 解凍
archive_file = "./weights/bert-base-uncased.tar.gz"
tar = tarfile.open(archive_file, 'r:gz')
tar.extractall('./weights/')
tar.close()

データセットは,SIGNATE Student Cup 2020からダウンロードし,ディレクトリ「data」に置く.(train.csv, test.csv, submit_sample.csv)
train.csvは「id(int)」,「description(str)」,「jobflag(int)」をカラムに持つ.
test.csvは「id(int)」,「description(str)」をカラムに持つ.
submit_sample.csvは,1列目に評価用データの"id",2列目に"職種 (1 or 2 or 3 or 4)"がランダムに記入されているヘッダ無しのcsv.test.csvとidの順番が一致している.

https://github.com/YutaroOgawa/pytorch_advanced/tree/master/8_nlp_sentiment_bert/utils
から「utils」をダウンロードする.

以下のようなディレクトリ構造となる.(太字がディレクトリ)

signate
--bert.ipynb
--make_folders_and_data_downloads.ipynb
--data
--vocab
--weights
--utils

SIGNATE Student Cup 2020の概要

タスク説明
英語圏の求人情報に含まれるテキストデータ(職務内容に関する記述)をもとに,その職務内容が
 ①データサイエンティスト(DS)
 ②機械学習エンジニア(ML Engineer)
 ③ソフトウェアエンジニア(Software Engineer)
 ④コンサルタント(Consultant)
のどの職種に該当するものかを判別

英語圏の求人情報であるため,テキストデータは英語.
テキストを用いた4値分類.
学習データは2931あるが,テキストが短いものが多いと感じた.
また,学習データのラベルが不均衡.(1: 624, 2: 348, 3: 1376, 4: 583)
評価はF1Score(Macro).

bert.ipynb

8-4_bert_IMDb.ipynbを参考にしました.
とりあえず,使いそうなモジュールのimportとseedの設定

bert.ipynb
import random
import time
import numpy as np
import math
from tqdm import tqdm
import torch 
from torch import nn
import torch.optim as optim
import torchtext
from sklearn.metrics import f1_score
import pandas as pd
import re
import string
from utils.bert import BertTokenizer

seed = 42
torch.manual_seed(seed)
if torch.cuda.is_available():
    torch.cuda.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
np.random.seed(seed)
random.seed(seed)

データの読み込み&前処理

前処理のための関数

bert.ipynb
def preprocessing_text(text):
    '''前処理'''
    # 改行コードを消去
    text = re.sub('<br />', '', text)
    # カンマ、ピリオド以外の記号をスペースに置換
    for p in string.punctuation:
        if (p == ".") or (p == ","):
            continue
        else:
            text = text.replace(p, " ")
    # ピリオドなどの前後にはスペースを入れておく
    text = text.replace(".", " . ")
    text = text.replace(",", " , ")
    return text

# 単語分割用のTokenizerを用意
tokenizer_bert = BertTokenizer(
    vocab_file="./vocab/bert-base-uncased-vocab.txt", do_lower_case=True)

# 前処理と単語分割をまとめた関数を定義
def tokenizer_with_preprocessing(text, tokenizer=tokenizer_bert.tokenize):
    text = preprocessing_text(text)
    ret = tokenizer(text)  # tokenizer_bert
    return ret

データを読み込んだときに、読み込んだ内容に対して行う処理を定義

bert.ipynb
max_length = 256
TEXT = torchtext.data.Field(sequential=True, tokenize=tokenizer_with_preprocessing, use_vocab=True,
                            lower=True, include_lengths=True, batch_first=True, fix_length=max_length, init_token="[CLS]", eos_token="[SEP]", pad_token='[PAD]', unk_token='[UNK]')
LABEL = torchtext.data.Field(sequential=False, use_vocab=False)
ID = torchtext.data.Field(sequential=False, use_vocab=False)

「data」から各csvファイルを読み込み

bert.ipynb
train_val_ds, test_ds = torchtext.data.TabularDataset.splits(
    path='./data/', train='original_train.csv',
    test='original_test.csv', format='csv',
    skip_header=True,
    fields=[('Id', ID), ('Text', TEXT), ('Label', LABEL)])

train_ds, val_ds = train_val_ds.split(
    split_ratio=0.9, random_state=random.seed(seed))

辞書をBERT用のものに変更
(そのため訓練データからボキャブラリーは作成しない)

bert.ipynb
from utils.bert import BertTokenizer, load_vocab
vocab_bert, ids_to_tokens_bert = load_vocab(
    vocab_file="./vocab/bert-base-uncased-vocab.txt")

TEXT.build_vocab(train_ds, min_freq=1)
TEXT.vocab.stoi = vocab_bert

DataLoaderを作成

bert.ipynb
batch_size = 32  # BERTでは16、32あたりを使用するらしいです

train_dl = torchtext.data.Iterator(
    train_ds, batch_size=batch_size, train=True)

val_dl = torchtext.data.Iterator(
    val_ds, batch_size=batch_size, train=False, sort=False)

test_dl = torchtext.data.Iterator(
    test_ds, batch_size=batch_size, train=False, sort=False)

dataloaders_dict = {"train": train_dl, "val": val_dl}

事前学習済みのBERTモデルを読み込みパラメータをセット

bert.ipynb
from utils.bert import get_config, BertModel, set_learned_params
config = get_config(file_path="./weights/bert_config.json")
net_bert = BertModel(config)
net_bert = set_learned_params(
    net_bert, weights_path="./weights/pytorch_model.bin")

[CLS]の特徴量を使用して、多値分類できるように層と活性化関数を追加したモデルを作成
・活性化関数はgelu使ってみました
・768次元を一気に4次元に落とすのが気になったので,層を二層にしてみました

bert.ipynb
def gelu(x):
    '''Gaussian Error Linear Unitという活性化関数です。
    LeLUが0でカクっと不連続なので、そこを連続になるように滑らかにした形のLeLUです。
    '''
    return x * 0.5 * (1.0 + torch.erf(x / math.sqrt(2.0)))

class BertForClassify(nn.Module):

    def __init__(self, net_bert):
        super(BertForClassify, self).__init__()

        # BERTモジュール
        self.bert = net_bert  # BERTモデル

        # 入力はBERTの出力特徴量の次元、出力は1:Data scientist, 2:Machine learning engineer, 3:Software engineer, 4:Consultantの4つ
        self.cls1 = nn.Linear(in_features=768, out_features=64)
        self.cls2 = nn.Linear(in_features=64, out_features=4)

        # 重み初期化処理
        nn.init.normal_(self.cls1.weight, std=0.02)
        nn.init.normal_(self.cls1.bias, 0)
        nn.init.normal_(self.cls2.weight, std=0.02)
        nn.init.normal_(self.cls2.bias, 0)

        self.dropout = nn.Dropout(0.1)

        # 活性化関数gelu
        self.intermediate_act_fn = gelu

    def forward(self, input_ids, token_type_ids=None, attention_mask=None, output_all_encoded_layers=False, attention_show_flg=False):
        '''
        input_ids: [batch_size, sequence_length]の文章の単語IDの羅列
        token_type_ids: [batch_size, sequence_length]の、各単語が1文目なのか、2文目なのかを示すid
        attention_mask:Transformerのマスクと同じ働きのマスキングです
        output_all_encoded_layers:最終出力に12段のTransformerの全部をリストで返すか、最後だけかを指定
        attention_show_flg:Self-Attentionの重みを返すかのフラグ
        '''

        # BERTの基本モデル部分の順伝搬
        # 順伝搬させる
        if attention_show_flg == True:
            '''attention_showのときは、attention_probsもリターンする'''
            encoded_layers, pooled_output, attention_probs = self.bert(
                input_ids, token_type_ids, attention_mask, output_all_encoded_layers, attention_show_flg)
        elif attention_show_flg == False:
            encoded_layers, pooled_output = self.bert(
                input_ids, token_type_ids, attention_mask, output_all_encoded_layers, attention_show_flg)

        # 入力文章の1単語目[CLS]の特徴量を使用して、多値分類
        vec_0 = encoded_layers[:, 0, :]
        vec_0 = self.dropout(vec_0.view(-1, 768))  # sizeを[batch_size, hidden_sizeに変換
        hidden = self.cls1(vec_0)
        out = self.cls2(self.intermediate_act_fn(self.dropout(hidden)))

        # attention_showのときは、attention_probs(1番最後の)もリターンする
        if attention_show_flg == True:
            return out, attention_probs, hidden
        elif attention_show_flg == False:
            return out, hidden

モデル構築&訓練モードに設定

bert.ipynb
net = BertForClassify(net_bert)
net.train()
print('ネットワーク設定完了')

学習させるパラメータを決める
・CPU環境でのfine-tuningのため,BertLayerの最終層と追加した分類のための2層を学習させることにさせました.
・今回のコンペでは,もう少し深く学習させるべきだったかもしれませんが,これでもそれなりの結果が出ました.

bert.ipynb
# 1. まず全部を、勾配計算Falseにしてしまう
for name, param in net.named_parameters():
    param.requires_grad = False
# 2. 最後のBertLayerモジュールを勾配計算ありに変更
for name, param in net.bert.encoder.layer[-1].named_parameters():
    param.requires_grad = True
# 3. 識別器を勾配計算ありに変更
for name, param in net.cls1.named_parameters():
    param.requires_grad = True
for name, param in net.cls2.named_parameters():
    param.requires_grad = True

GPUが使えるかを確認
最適化手法の設定
損失関数の設定
・不均衡データであるため,損失関数にweightを与えた

bert.ipynb
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
optimizer = optim.Adam([
    {'params': net.bert.encoder.layer[-1].parameters(), 'lr': 5e-5},
    {'params': net.cls1.parameters(), 'lr': 5e-5},
    {'params': net.cls2.parameters(), 'lr': 5e-5}
], betas=(0.9, 0.999))
weights = np.array([0, 0, 0, 0])
for example in train_ds:
        weights[int(example.Label) - 1] += 1
weights = 1 / weights
weights /= np.sum(weights)
weights = torch.tensor(weights, dtype=torch.float32, device=device)
criterion = nn.CrossEntropyLoss(weight=weights)

モデルを学習させる関数を作成

bert.ipynb
def train_model(net, dataloaders_dict, criterion, optimizer, num_epochs):
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    print("使用デバイス:", device)
    print('-----start-------')
    net.to(device)
    torch.backends.cudnn.benchmark = True
    batch_size = dataloaders_dict["train"].batch_size
    for epoch in range(num_epochs):
        for phase in ['train', 'val']:
            if phase == 'train':
                net.train()
            else:
                net.eval()
            epoch_loss = 0.0
            epoch_corrects = 0
            iteration = 1     
            # macro_f1
            predlist=torch.zeros(0, dtype=torch.long, device='cpu')
            lbllist=torch.zeros(0, dtype=torch.long, device='cpu')
            t_epoch_start = time.time()
            t_iter_start = time.time()
            for batch in (dataloaders_dict[phase]):
                inputs = batch.Text[0].to(device)
                labels = batch.Label.to(device)
                labels -= 
                optimizer.zero_grad()
                with torch.set_grad_enabled(phase == 'train'):
                    outputs, hidden = net(inputs, token_type_ids=None, attention_mask=None,
                                  output_all_encoded_layers=False, attention_show_flg=False)
                    loss = criterion(outputs, labels)
                    _, preds = torch.max(outputs, 1)
                    if phase == 'train':
                        loss.backward()
                        optimizer.step()
                        if (iteration % 10 == 0):
                            t_iter_finish = time.time()
                            duration = t_iter_finish - t_iter_start
                            acc = (torch.sum(preds == labels.data)
                                   ).double()/batch_size
                            print('イテレーション {} || Loss: {:.4f} || 10iter: {:.4f} sec. || 本イテレーションの正解率:{}'.format(
                                iteration, loss.item(), duration, acc))
                            # print(preds[0:10])
                            t_iter_start = time.time()
                    iteration += 1
                    epoch_loss += loss.item() * batch_size
                    epoch_corrects += torch.sum(preds == labels.data)
                    # macro_f1
                    predlist=torch.cat([predlist,preds.detach().view(-1).cpu()])
                    lbllist=torch.cat([lbllist,labels.view(-1).cpu()])
            t_epoch_finish = time.time()
            epoch_loss = epoch_loss / len(dataloaders_dict[phase].dataset)
            epoch_acc = epoch_corrects.double() / len(dataloaders_dict[phase].dataset)
            print('Epoch {}/{} | {:^5} |  Loss: {:.4f} Acc: {:.4f}'.format(epoch+1, num_epochs,
                                                                           phase, epoch_loss, epoch_acc))
            # macro_f1
            print('Macro_f1 {:^5}'.format(f1_score(lbllist, predlist, average='macro')))
            t_epoch_start = time.time()
    return net

学習・検証を実行

bert.ipynb
num_epochs = 10
net_trained = train_model(net, dataloaders_dict,criterion, optimizer, num_epochs=num_epochs)

出力例


使用デバイス: cpu
-----start-------
イテレーション 10 || Loss: 1.4068 || 10iter: 471.4296 sec. || 本イテレーションの正解率:0.4375
イテレーション 20 || Loss: 1.4239 || 10iter: 427.7281 sec. || 本イテレーションの正解率:0.40625

イテレーション 80 || Loss: 1.3781 || 10iter: 388.2995 sec. || 本イテレーションの正解率:0.34375
イテレーション 90 || Loss: 1.3696 || 10iter: 377.9352 sec. || 本イテレーションの正解率:0.3125
Epoch 1/10 | train | Loss: 1.3952 Acc: 0.2955
Macro_f1 0.25144529676014216


学習したネットワークパラメータを保存
・実際には,関数train_modelに,エポックごとに保存できるようにして,良さげなものを後からロードし直して,予測に使っていました.

bert.ipynb
save_path = './weights/bert_fine_tuning.pth'
torch.save(net_trained.state_dict(), save_path)

テストデータの予測

bert.ipynb
pred_label_list = torch.zeros(0, dtype=torch.long, device='cpu')
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
net_trained.eval()
net_trained.to(device)
for batch in tqdm(test_dl):
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    inputs = batch.Text[0].to(device)
    with torch.set_grad_enabled(False):
        outputs, hidden = net_trained(inputs, token_type_ids=None, attention_mask=None,
                              output_all_encoded_layers=False, attention_show_flg=False)
        _, preds = torch.max(outputs, 1)
        pred_label_list = torch.cat([pred_label_list,preds.detach().view(-1).cpu()])

提出ファイルを作成

bert.ipynb
sub_df  = pd.read_csv('./data/submit_sample.csv', header=None)
sub_df[1] = np.array(pred_label_list) + 1
sub_df.to_csv('./data/submit.csv', header=False, index=False)

結果

一番最初に,10エポック学習させたものを提出したのですが,public score private scoreともに良好な結果が得られてました.

image.png

この結果だけでも,最終順位50位くらいなので,BERTすごいなと実感しました.

おわりに

自分自身,BERT使うのにハードルを感じていたのですが,とりあえず動かすだけであれば,半日くらいで実装できたので,これから使ってみたい人に,この記事が少しでも役に立ったらいいなと思っています.
詳しく学びたい方は,「つくりながら学ぶ!PyTorchによる発展ディープラーニング」がとても参考になったので,ぜひ購入して読んでみてください!

他にも隙間時間で試したいくつかの手法や,最終的に弱弱なスコアを提出することになった理由などは,はてなブログに書いてあります.
SIGNATE Student Cup 2020 [予測部門] 参加レポート

最後まで記事を読んでいただきありがとうございました.

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
9
Help us understand the problem. What are the problem?