LoginSignup
65
61

More than 1 year has passed since last update.

60分でできるBERT(英語テキストの感情分析)

Last updated at Posted at 2022-03-13

はじめに

「現場で使える! Python自然言語処理入門」「最短コースでわかる PyTorch &深層学習プログラミング」の著者です。

現場で使える! Python自然言語処理入門」では、本の一番最後にBERTの簡単な解説をしています。ただ、この執筆したときには、BERTは本当にまだできたてで、ライブラリなどもほとんどなかったため、残念ながら実習を入れることができませんでした。
このあたりの最新状況を調べ直したところ、今ではいろいろとライブラリができあがっていることがわかりました。自分の備忘録を兼ねて、最新状況を反映した実習プログラムを作ってみたので、その結果を連携します。
本当はWord2Vecのサンプル※のように「15分でできる」としたかったのですが、バリバリのディープラーニングのプログラムで全然無理そうだったのであきらめて「60分でできる」にしました。

「15分でできる日本語Word2Vec」の記事は、 私がqiitaに執筆した記事の中で人気No1です。「15分」のキーワードが受けたのではないかと思っています。

なお、以下の解説では、PyTorchに関する基本的な知識はあることを前提にしています。この部分で不明の点がある方は、是非上で紹介した、「最短コースでわかる PyTorch &深層学習プログラミング」を通読して下さい。我田引水で恐縮ですが、この本を書くために付けたPyTorchの知識で、今回ご紹介するコードのほとんどはすぐに理解できました。

実習プログラムの前提

調べたいモデルが英語を対象としたものだったため、英語のテキスト分析を対象にしています。
ユースケースとしては、最も簡単に実装可能な二値分類モデル(感情分析)です。
実行環境は、だれでも簡単に構築できるように、Google Colabを前提としました。
また、PyTorch本を執筆したばかりということもあり、フレームワークは当然のように?PyTorchを選択しました。

学習データは。IMDB データセットを使いました。ただ、件数が多くて、学習に時間がかかりすぎるので、データを1/10に間引く加工をしています。
なお、サンプルプログラムの下敷きにしたコードは
https://bit.ly/3J8m2v1
に記載されていたものです。

コード解説

以下にサンプルコードの解説を行います。なお、コードの全量は、下記のURLにアップしてあります。

環境準備

transformersの導入

BERTを使う場合、tramsformersというライブラリを使うのが、標準的なようです。このライブラリはまだ、Google Colabには含まれていないので、ライブラリの導入から始めます。ついでに乱数の初期化もやっておきます。

!pip install transformers | tail -n 1

# 乱数初期化
import torch

SEED = 1111
torch.manual_seed(SEED)
torch.backends.cudnn.deterministic = True

BertTokenizerのインポート

transformersでは、BERTに対してモデルそのものだけでなく、tokenizerと呼ばれる、素のテキストを個々のトークンに分割するライブラリもセットで利用することができます。まずは、このtokenizerのインスタンスを生成します。その後ろのコードはtokenize関数と、convert_tokens_to_ids関数の動作確認です。

# tokenizer インスタンスの生成
# 対象モデルは'bert-base-uncased'
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

# tokenizer関数の動作確認
tokens = tokenizer.tokenize("What's going on?")
print(tokens)

# convert_tokens_to_ids関数の動作確認
indexes = tokenizer.convert_tokens_to_ids(tokens)
print(indexes)

こんな結果になるはずです。

スクリーンショット 2022-03-13 22.00.06.png

特殊トークン

BERTでは、単語に該当する「トークン」の中に「制御コード」と同じような意味合いを持つ特殊コードがあります。
下記のコードは、その特殊コードの実装を示しています。

# BERT固有の特殊トークン達
cls_token = tokenizer.cls_token
sep_token = tokenizer.sep_token
pad_token = tokenizer.pad_token
unk_token = tokenizer.unk_token
print(cls_token, sep_token, pad_token, unk_token)

# idによるトークン表記
cls_token_idx = tokenizer.cls_token_id
sep_token_idx = tokenizer.sep_token_id
pad_token_idx = tokenizer.pad_token_id
unk_token_idx = tokenizer.unk_token_id
print(cls_token_idx, sep_token_idx, pad_token_idx, unk_token_idx)
[CLS] [SEP] [PAD] [UNK]
101 102 0 100

学習データの定義

次に、torchtext.legacy.data ライブラリを使って、学習データを定義していきます。

入力テキストのトークン化関数

最初に素のテキストを引数として、トークン化する関数を定義します。この関数は、この後で、学習データを定義する際に利用することになります。

# 入力テキストのトークン化関数
def tokenize(sentence):
    tokens = tokenizer.tokenize(sentence) 
    # 252までで切る
    tokens = tokens[:254-2]
    return tokens

学習データのデータ構造定義

次に学習データのデータ構造を定義します。ちょっと戸惑ったのが、ネット上のチュートリアルでは、ライブラリ名がtorchtext.dataとなっているのですが、この名前を使おうとするとエラーになってしまう点です。結論として、最新の torchtextでは、torchtext.legacy.dataとする必要があることがわかりました。
この実装で重要なのが、tokenizepreprocessingの2つのパラメータです。
最初のパラメータには先ほど定義した関数を渡します。
後者のパラメータには、tokenizer.convert_tokens_to_idsを渡し、これにより、tokenizeした結果を更に数値データに置き換えることをします。

# 学習データのデータ構造定義

# torchtextのバージョンアップに伴い、legacyを付ける必要あり
from torchtext.legacy import data

# 入力データ
TEXT = data.Field(batch_first = True,
                  use_vocab = False,
                  # 上で定義したトークン化関数
                  tokenize = tokenize,
                  # 前処理として各トークンをIDに変換
                  preprocessing = tokenizer.convert_tokens_to_ids,
                  init_token = cls_token_idx,
                  eos_token = sep_token_idx,
                  pad_token = pad_token_idx)
 
# 正解ラベル
LABEL = data.LabelField()

データ読み込み

次にデータの読み込みをライブラリ呼び出しにより実行します。読み込むデータは、IMDBと呼ばれる、映画のレビューを含んだデータセットで、感情分析(positive/negative)モデルの学習に用いられます。

from torchtext.legacy import datasets
# IMDBは映画のレビューを含んだデータセットで、感情分析(positive/negative)モデルの学習に用いられる
# 読み込みに20分程度時間がかかります
train_data, valid_data = datasets.IMDB.splits(TEXT, LABEL)

データ間引き

このデータセットは、訓練データ25,000件、検証データ25,000件があるのですが、試したところ、このままで学習にまわすと時間がかかりすぎることがわかりました、そこで、1時間で学習を終わらせるため、データを1/10に間引くことにしました。その実装が下記になります。

# 全件のデータを使うと学習に時間がかかるので、1/10に間引いた
import random
train_data1, train_data2 = train_data.split(random_state=random.seed(SEED),split_ratio=0.1)
valid_data1, valid_data2 = valid_data.split(random_state=random.seed(SEED),split_ratio=0.1)

訓練用、検証用のイテレーターの定義

次に、訓練用、検証用データのイテレーターを定義しておきます。同時にGPUの準備もしておきます。

# ボキャブラリのビルド
LABEL.build_vocab(train_data1)

# 訓練時のバッチサイズ
BATCH_SIZE = 16

# GPU利用
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(device)

# 訓練用、検証用のイテレーターの定義
train_iterator, valid_iterator = data.BucketIterator.splits(
    (train_data1, valid_data1), 
    batch_size = BATCH_SIZE, 
    device = device)

訓練用データの確認

ここで、訓練用データがどのようなものなのか、確認してみましょう。個々のデータセットのtextという属性に学習データが含まれているので、その内容をprint関数で表示します。結果は、下記にように整数のインデックスの配列になっています。
個々の整数をconvert_ids_to_tokens関数にかけると、元のトークン(ほぼ単語と同じ)を見ることもできます。、

# 訓練データの中身を見てみる
idx0 = train_data[1].text
print(idx0)

# token表記に戻してみる
text0 = tokenizer.convert_ids_to_tokens(idx0)
print(text0)
[2045, 2024, 2261, 3152, 2008, 2681, 2033, 2007, 1996, 3110, 2008, 16973, 5207, 1005, 1055, 1005, 12311, 5163, 1005, 2143, 2106, 1012, 3322, 1045, 2018, 2657, 2069, 2431, 18627, 11433, 1010, 1998, 2787, 2000, 2156, 2009, 2005, 2870, 1012, 2144, 2059, 1010, 1045, 2031, 3734, 2119, 1996, 2678, 1998, 6050, 1010, 1998, 2031, 2000, 2360, 2008, 2044, 2195, 10523, 2015, 1010, 1045, 2572, 2145, 2200, 7622, 2007, 1996, 10318, 2839, 1997, 2023, 2143, 1012, 2009, 2003, 2036, 6919, 2000, 2156, 2242, 2827, 999, 1045, 9120, 2049, 2091, 2000, 3011, 3737, 1010, 2008, 2065, 2017, 3198, 2033, 2003, 1037, 10958, 15780, 1010, 2004, 2092, 2004, 1996, 6438, 1997, 26997, 9961, 2008, 3138, 2185, 2013, 2061, 2116, 3152, 1012, 2023, 2143, 16481, 2008, 2017, 2123, 1005, 1056, 9352, 5478, 11281, 12703, 1998, 1037, 1043, 10278, 25373, 2275, 2008, 16888, 2015, 2129, 2116, 8817, 1997, 6363, 2000, 2191, 1037, 2391, 1012, 1996, 3459, 2001, 1037, 6781, 1010, 2164, 1037, 3528, 1997, 2092, 2124, 1010, 1998, 2453, 1045, 5587, 1010, 2204, 2559, 2111, 2040, 2106, 2092, 2000, 7540, 2046, 1996, 2535, 1997, 2107, 4310, 3494, 1012, 2009, 2003, 5875, 2000, 3602, 1010, 2008, 2172, 1997, 1996, 6256, 4953, 2023, 2143, 2038, 2042, 2055, 2040, 2209, 2054, 1010, 1998, 2129, 2027, 2069, 2056, 2061, 2116, 3210, 1012, 2174, 1010, 2065, 2151, 6256, 2003, 2349, 1010, 2009, 2323, 26157, 2135, 3579, 2006, 1996, 2755, 2008, 1037, 2193, 1997, 3937, 3787, 1997, 1996, 2434, 2824, 2020, 12421, 1012, 1999, 4507, 1010, 2122, 20903, 2000, 2081, 2009, 1996, 25812, 2008]
['there', 'are', 'few', 'films', 'that', 'leave', 'me', 'with', 'the', 'feeling', 'that', 'gregor', 'jordan', "'", 's', "'", 'ned', 'kelly', "'", 'film', 'did', '.', 'initially', 'i', 'had', 'heard', 'only', 'half', 'hearted', 'recommendations', ',', 'and', 'decided', 'to', 'see', 'it', 'for', 'myself', '.', 'since', 'then', ',', 'i', 'have', 'acquired', 'both', 'the', 'video', 'and', 'soundtrack', ',', 'and', 'have', 'to', 'say', 'that', 'after', 'several', 'viewing', '##s', ',', 'i', 'am', 'still', 'very', 'impressed', 'with', 'the', 'underlying', 'character', 'of', 'this', 'film', '.', 'it', 'is', 'also', 'wonderful', 'to', 'see', 'something', 'australian', '!', 'i', 'appreciate', 'its', 'down', 'to', 'earth', 'quality', ',', 'that', 'if', 'you', 'ask', 'me', 'is', 'a', 'ra', '##rity', ',', 'as', 'well', 'as', 'the', 'absence', 'of', 'tack', '##iness', 'that', 'takes', 'away', 'from', 'so', 'many', 'films', '.', 'this', 'film', 'proves', 'that', 'you', 'don', "'", 't', 'necessarily', 'require', 'fancy', 'costumes', 'and', 'a', 'g', '##lam', '##orous', 'set', 'that', 'absorb', '##s', 'how', 'many', 'millions', 'of', 'dollars', 'to', 'make', 'a', 'point', '.', 'the', 'cast', 'was', 'a', 'bonus', ',', 'including', 'a', 'variety', 'of', 'well', 'known', ',', 'and', 'might', 'i', 'add', ',', 'good', 'looking', 'people', 'who', 'did', 'well', 'to', 'slip', 'into', 'the', 'role', 'of', 'such', 'unique', 'characters', '.', 'it', 'is', 'interesting', 'to', 'note', ',', 'that', 'much', 'of', 'the', 'criticism', 'regarding', 'this', 'film', 'has', 'been', 'about', 'who', 'played', 'what', ',', 'and', 'how', 'they', 'only', 'said', 'so', 'many', 'lines', '.', 'however', ',', 'if', 'any', 'criticism', 'is', 'due', ',', 'it', 'should', 'constructive', '##ly', 'focus', 'on', 'the', 'fact', 'that', 'a', 'number', 'of', 'basic', 'elements', 'of', 'the', 'original', 'events', 'were', 'excluded', '.', 'in', 'reality', ',', 'these', 'functioned', 'to', 'made', 'it', 'the', 'hallmark', 'that']

学習の準備

いよいよ学習の準備に入ります。

事前学習済みモデルのロード

最初のステップは、事前学習済みモデルのロードです。
ここで利用するbert-base-uncasedというモデルは、
12-層、768-隠れ次元、12-ヘッド、110M パラメータ lower-cased 英語テキスト上で訓練
という条件のものです。詳細は下記リンク先を参照して下さい。

実装は下記になります。

# 事前学習済みモデルのロード
from transformers import BertModel
bert = BertModel.from_pretrained('bert-base-uncased')

モデルの定義

次に、このモデルの後ろに線形関数を追加して、二値分類モデルを作ります。具体的な実装は以下のものです。

# モデルの定義
# 事前学習済みモデルの後段に線形関数を追加し、この出力で感情分析をする
import torch.nn as nn

class BERTSentiment(nn.Module):
    def __init__(self,
                 bert,
                 output_dim):
        
        super().__init__()
        
        self.bert = bert
        embedding_dim = bert.config.to_dict()['hidden_size']
        self.out = nn.Linear(embedding_dim, output_dim)
        
    def forward(self, text):
        #text = [batch size, sent len]

        #embedded = [batch size, emb dim]
        embedded = self.bert(text)[1]
        
        #output = [batch size, out dim]
        output = self.out(embedded)
        
        return output

モデルインスタンスの生成

上で定義したモデルクラスを用いて、モデルインスタンスを生成します。

# モデルインスタンスの生成
# 出力は感情分析なので2
OUTPUT_DIM = 2

model = BERTSentiment(bert,
                     OUTPUT_DIM).to(device)

モデルのパラメータ数の確認

BERTは、膨大な数のパラメータを持っているとよく言われています。具体的にいくつのパラメータがあるのか、確認してみましょう。実装は下記になります。

# モデルのパラメータ数の確認
def count_parameters(model):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)

print(f'The model has {count_parameters(model):,} trainable parameters')

結果は以下のとおり。1億個あることがわかりました。

The model has 109,483,778 trainable parameters

最適化関数、損失関数の定義など

次に最適化関数や損失関数の定義を行います。

import torch.optim as optim
from transformers import AdamW, get_constant_schedule_with_warmup

# 最適化関数の定義
optimizer = AdamW(model.parameters(),lr=2e-5,eps=1e-6)

# 損失関数の定義
criterion = nn.CrossEntropyLoss().to(device)

# スケジューラの定義
def get_scheduler(optimizer, warmup_steps):
    scheduler = get_constant_schedule_with_warmup(optimizer, num_warmup_steps=warmup_steps)
    return scheduler

# 精度計算
def categorical_accuracy(preds, y):
    max_preds = preds.argmax(dim = 1, keepdim = True)
    correct = (max_preds.squeeze(1)==y).float()
    return correct.sum() / len(y)

学習用関数trainの定義

次に学習で用いる関数trainの定義をします。

# ステータスバー表示用
from tqdm.notebook import tqdm

def train(model, iterator, optimizer, criterion, scheduler):
    
    epoch_loss = 0
    epoch_acc = 0
    model.train()
    
    for batch in tqdm(iterator):
        optimizer.zero_grad() # clear gradients first
        torch.cuda.empty_cache() # releases all unoccupied cached memory 
        text = batch.text
        label = batch.label
        predictions = model(text)
        loss = criterion(predictions, label)
        acc = categorical_accuracy(predictions, label)
        loss.backward()
        optimizer.step()
        scheduler.step()
        epoch_loss += loss.item()
        epoch_acc += acc.item()
        
    return epoch_loss / len(iterator), epoch_acc / len(iterator)

検証用関数evaluateの定義

もう一つ、精度検証用の関数、evaluateも定義します。

def evaluate(model, iterator, criterion):
    epoch_loss = 0
    epoch_acc = 0
    model.eval()
    
    with torch.no_grad():
        for batch in tqdm(iterator):
            text = batch.text
            label = batch.label
            predictions = model(text)
            loss = criterion(predictions, label)
            acc = categorical_accuracy(predictions, label)
            epoch_loss += loss.item()
            epoch_acc += acc.item()
        
    return epoch_loss / len(iterator), epoch_acc / len(iterator)

処理時間計算用関数 epoch_timeの定義

最後に、処理時間計算用関数 epoch_timeの定義をします。

import time

def epoch_time(start_time, end_time):
    elapsed_time = end_time - start_time
    elapsed_mins = int(elapsed_time / 60)
    elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
    return elapsed_mins, elapsed_secs

学習

これで、学習のための準備はすべて整いました。
最初に学習に向けた、いくつかの変数初期設定をします。

各種変数の初期化

# 各種変数の初期化
import math
N_EPOCHS = 3
#train_data_len = 25000
train_data_len = 2500

warmup_percent = 0.2
total_steps = math.ceil(N_EPOCHS*train_data_len*1./BATCH_SIZE)
warmup_steps = int(total_steps*warmup_percent)
scheduler = get_scheduler(optimizer, warmup_steps)

best_valid_loss = float('inf')

学習

学習時間は1epochあたり5分、計15分程度です(Google ColabでGPU利用の場合)

# 学習
# 学習時間は1epochあたり5分、計15分程度です(Google ColabでGPU利用の場合)
for epoch in range(N_EPOCHS):

    start_time = time.time()
    # 学習と評価
    train_loss, train_acc = train(model, train_iterator, optimizer, criterion, scheduler)
    # 検証データによる評価
    valid_loss, valid_acc = evaluate(model, valid_iterator, criterion)
    
    #  処理時間の計算
    end_time = time.time()
    epoch_mins, epoch_secs = epoch_time(start_time, end_time)
    
    # 検証データの損失が最もいい場合は、モデルを保存する
    if valid_loss < best_valid_loss:
        best_valid_loss = valid_loss
        torch.save(model.state_dict(), 'bert-nli.pt')
    
    print(f'Epoch: {epoch+1:02} | Epoch Time: {epoch_mins}m {epoch_secs}s')
    print(f'\tTrain Loss: {train_loss:.3f} | Train Acc: {train_acc*100:.2f}%')
    print(f'\t Val. Loss: {valid_loss:.3f} |  Val. Acc: {valid_acc*100:.2f}%')

結果は以下のようになるはずです。

スクリーンショット 2022-03-13 23.20.48.png

検証データに対して約90%の精度がえられました。

65
61
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
65
61