神戸のデータ活用塾!KDL Data Blog

KDLが誇るデータ活用のプロフェッショナル達が書き連ねるブログです。

【やってみた】BERTにブログの特徴を教えてもらってみた

株式会社神戸デジタル・ラボ DataIntelligenceチームの高木です。

今回はBERTというAIモデルに当ブログ『神戸のデータ活用塾!KDL Data Blog』の特徴を教えてもらいたいと思います。

AIにブログの特徴を教えてもらう??

「AIにブログの特徴を教えてもらう」ことはどのように実現するのでしょうか? 自然言語処理分野(言葉に関するAIの分野)には、様々なタスクに向けたAIモデルが存在しますが、その中に、文中で虫食いになっている単語を予測するAIモデルが存在します。今回はそのモデルを利用して、AIに当ブログの特徴を予測してもらいたいと思います。具体的な手順は以下の通りです。

  1. 当ブログの特徴を示すワードをあらかじめ虫食いに加工する
  2. 加工した文章をAIに入力して、虫食い部分を予測する

人間の僕がこのブログの特徴を答えるとしたら、

データ活用」「AI」「データサイエンス」「Azure
Kaggle」「SIGNATE」「テキスト分析」「画像分析

といったところでしょうか。これに対して、AIはどういった特徴を予測してくれるのか、順を追ってみていきます。

BERTとは

今回は、AIモデルとしてBERT(Bidirectional Encoder Representations from Transformers)と呼ばれるものを利用します。BERTは2018年にGoogleから発表された自然言語処理タスクに対して用いられるモデルです。他のモデルと比較して、離れた位置にある単語の情報も適切にとってくることができるという特徴があります。これによって、文脈(文章の流れの中にある意味内容のつながり)を深く考慮できるようになりました。

BERTの学習は、事前学習ファインチューニングの二つの過程によって構成されます。

  • 事前学習:大量の文章を利用して、言語における汎用的なルールを学習する
  • ファインチューニング:適度な数の文章を利用して、特定のタスクに対応できるように微調整を行う

事前学習部分では大量の文章が必要ということで、実際にBERTを利用する際は公開されている事前学習済みモデルを利用することが一般的です。今回は、東北大学の研究室が公開している訓練済み日本語BERTモデルを事前学習済みモデルとして利用します。こちらは学習データとして全てのWikipediaの日本語ページを用いることにより、日本語の文章の特徴を掴んだモデルとなっています。

ファインチューニングは、事前学習済みモデルに対して行われます。今回は当ブログの記事ならではの特徴(当ブログ内で頻出する単語など)をBERTに学習して欲しいので、学習データは当ブログ上の記事とします。

ファインチューニング

BERTにおいてファインチューニングとは特定のタスクに対応することでしたが、今回の場合の「特定のタスク」とはなんでしょうか?今回のタスクは、ある単語を周りの単語から予測するというものになります。この予測を実現するために、モデルはランダムに選ばれたトークン(文を構成する要素)を[MASK]という特殊なトークンに置き換えて入力とし、[MASK]の位置の単語が何であるのかを予測するという学習を行います。このような学習を行うモデルのことをマスク付き言語モデルといいます。(ちなみに、事前学習モデルの際にもBERTは同様の学習を行います。)

具体例として、「今年の夏は暑いらしい。」という文を考えてみましょう。こちらをトークンに分割すると以下のようになります。

'[CLS]', '今年', 'の', '夏', 'は', '暑い', 'らしい', '。', '[SEP]'

トークンに分割することによって、文頭に[CLS]・文末に[SEP]が付け加わっています。こちらは文の始まりと終わりを示すものになります。このトークンを使うことで、文の連続の判定ができるのですが、詳しい解説はまたの機会にしましょう。興味がある方は、BERTについて調べてみてください。(BERTは事前学習でマスク付き言語モデルと文の連続性を判定するNext Sentence Predictionの二つのタスクが行われています)

上記の分割されたトークンに対して、ランダムに[MASK]という特殊トークンへの変更を行います。

'[CLS]', '今年', 'の', '[MASK]', 'は', '暑い', 'らしい', '。', '[SEP]'

例えば、「夏」の部分をマスクするとこんな感じですね。こちらを入力として、モデルは[MASK]の部分が夏ということを予測できるように学習を行います。

実装

今回のコードはGoogle Colaboratory上で実行されることを想定しています。

データを集めよう

ファインチューニングのために用いる当ブログの記事を、webスクレイピングの技術を用いて取得します。 今回は、RequestsBeatiful Soup 4の二つのライブラリを利用します。

  • Requests:HTMLやXMLファイルからデータを取得するライブラリ
  • Beatiful Soup 4:取得したデータの中から必要な情報のみを抽出するライブラリ

最初のコードではこの二つのライブラリを使用できるようにしましょう。

!pip install beautifulsoup4

import requests
from bs4 import BeautifulSoup

今回はデータとして当ブログの過去20記事を利用します。 以下のコードでは、記事の中の<p>タグで囲まれた部分の文章を全てとってきてなるべく関係のないような要素は削除しています。今回は、コードが簡潔になるようにデータの収集部分には簡単な処理(タグを利用してデータを取得し、明確に不必要な要素は除去する)しか適用していませんが、テキスト取得をもっと厳密に行なったり、各テキストに対してさまざまな前処理を施してみるのも面白いと思います。

# 当ブログの20記事分のURL
urls = [
    "https://kdl-di.hatenablog.com/entry/2022/06/10/090000",
    "https://kdl-di.hatenablog.com/entry/2022/05/27/090000",
    "https://kdl-di.hatenablog.com/entry/2022/05/13/100000",
    "https://kdl-di.hatenablog.com/entry/2022/04/28/090000",
    "https://kdl-di.hatenablog.com/entry/2022/04/15/090000",
    "https://kdl-di.hatenablog.com/entry/2022/04/01/090000",
    "https://kdl-di.hatenablog.com/entry/2022/03/18/090000",
    "https://kdl-di.hatenablog.com/entry/2022/03/04/090000",
    "https://kdl-di.hatenablog.com/entry/2022/02/18/090000",
    "https://kdl-di.hatenablog.com/entry/2022/02/04/090000",
    "https://kdl-di.hatenablog.com/entry/2022/01/21/090000",
    "https://kdl-di.hatenablog.com/entry/2022/01/07/090000",
    "https://kdl-di.hatenablog.com/entry/2021/12/24/090000",
    "https://kdl-di.hatenablog.com/entry/2021/12/10/090000",
    "https://kdl-di.hatenablog.com/entry/2021/11/26/090000",
    "https://kdl-di.hatenablog.com/entry/2021/11/12/090000",
    "https://kdl-di.hatenablog.com/entry/2021/10/29/090000",
    "https://kdl-di.hatenablog.com/entry/2021/10/15/090000",
    "https://kdl-di.hatenablog.com/entry/2021/10/01/090000",
    "https://kdl-di.hatenablog.com/entry/2021/09/17/134347"
]

# 変数all_textsの中にブログ記事の一行
all_texts = []
for url in urls:
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'html.parser')
    texts_p = [c.get_text() for c in soup.find_all('p')]
    all_texts.extend(texts_p[:-8]) # 各記事の最後付近の要素には記事と関係ない情報が見られるので削除

# 変数all_textsにはノーブレークスペース\xa0や空文字が見られたので削除
texts = [text for text in all_texts if (text != '\xa0') and (text != '')] 

必要なライブラリをとってこよう

今回は学習をPyTorch LightningというPyTorchのラッパーライブラリを利用して学習を行います。こちらのライブラリを利用することで、学習のコードの可視性が上がり、実験管理が行いやすくなります。こちらを準備しましょう!(ここでは、再現性を確保するための処理も同時に行なっています)

%env CUBLAS_WORKSPACE_CONFIG=:4096:8 #環境変数をここで定義する(Google Colab特有の処理)
!pip install pytorch-lightning

# ライブラリのimport
import torch
import pytorch_lightning as pl
from pytorch_lightning import Trainer

# 再現性を確保するための処理
torch.use_deterministic_algorithms(True)
pl.seed_everything(42, workers=True)

続いて、transformersというライブラリをインポートします。こちらは、米国のHugging Face社が公開しているもので、最新の自然言語処理モデルが多く公開されています。今回、利用する東北大学のbert-base-japanese-whole-word-maskingモデルもこちらのライブラリで簡単に扱えます。なお、こちらのモデルを扱う際にfugashiipadicという二つのライブラリが依存するのでこちらも同時にpipでインストールしてください。

  • fugashi:MeCabという形態素解析(文章を意味を持つ最小単位の「形態素」に分割する)エンジンのラッパーライブラリ
  • ipadic:MeCab用の辞書ライブラリ
!pip install transformers fugashi ipadic

from transformers import BertJapaneseTokenizer, BertForMaskedLM
from transformers import DataCollatorForLanguageModeling

事前学習済みモデルで予測してみよう

予測を行うためには、トークナイザーモデルが必要です。トークナイザーは、文章を意味を持つ最小単位に分割する形態素解析を実現するために用意されます。予測の全体の流れとしては、以下のようになります。

  1. トークナイザーを用いて文章を形態素に変換する
  2. モデルに形態素に変換した文章を入力し、出力から最も確率の高い単語を抽出する

まずは、今回利用する東北大学の研究室の公開しているトークナイザーとモデルを定義しましょう!

model_name = 'cl-tohoku/bert-base-japanese-whole-word-masking'

# トークナイザーとモデルを定義
tokenizer = BertJapaneseTokenizer.from_pretrained(model_name)
bert_model = BertForMaskedLM.from_pretrained(model_name)

bert_model = bert_model.cuda() # GPU上で使えるように変換

定義したモデルとトークナイザーを利用して、実際に今回の質問をBERTに予測させてみましょう。まず、入力のテキストを形態素に分割し、各形態素を対応するIDに変換することを行っていきます。

text = '『神戸のデータ活用塾!KDL Data Blog』では[MASK]についての記事を発信しています。是非、一度ご覧ください。'

# 形態素に分割
tokens = tokenizer.tokenize(text)

# 形態素を対応するIDに変換
input_ids = tokenizer.encode(text, return_tensors='pt')
input_ids = input_ids.cuda() # GPU上で使えるように変換

なお、入力テキストにおいて虫食いにする部分は[MASK]という特殊トークンを設定します。また、定義したトークナイザーによって、形態素をIDに変換した際に特殊トークン[MASK]のIDは4と設定されています。

IDに変換した入力を学習済みモデルに入力して、BERTに[MASK]部分を予測させてみましょう。

# モデルの出力を得る
with torch.no_grad():
  output = bert_model(input_ids=input_ids)
  scores = output.logits

# 最も確率の高い形態素を抽出
mask_position = input_ids[0].tolist().index(4)
id_best = scores[0, mask_position].argmax(-1).item()
token_best  = tokenizer.convert_ids_to_tokens(id_best)
token_best = token_best.replace('##', '')

# 実際にテキストを書き換える
text = text.replace('[MASK]', token_best)
print(text)

予測をする大まかな流れとしては、事前に出力として登録されている32000種類の形態素の中から最も確率が高いものを選択しています。変数mask_positionでは値が4である部分のindexをとってきていますが、この4という数字は[MASK]のIDを表していることに注意してください。

さて、気になる結果は??


『神戸のデータ活用塾!KDL Data Blog』では神戸についての記事を発信しています。是非、一度ご覧ください。

学習済みモデルをそのまま利用すると、当ブログは「神戸」についての情報を発信していると予想されます。確かに我々は神戸に本社をおく企業ですが、当ブログは神戸についての情報というよりデータ活用についての記事を多く掲載しています。そのことをしっかりBERTに学習させないといけませんね。そこで、先ほどお話ししたファインチューニングの出番です!

MASKをつけよう

今回のファインチューニングは、先述の通りランダムに選ばれたトークン([MASK]は要素毎にランダムに位置を決めることで、モデルがより正確な予測ができるようになります)を[MASK]トークンに置き換えモデルに入力し、[MASK]の位置の単語が何であるのかを予測します。そこで問題となるのが[MASK]をどのように設定するかです。transformersライブラリではDataCollatorForLanguageModelingを利用することで、指定の確率でランダムに[MASK]を設定できる実装ができるようになっています。DataCollatorForLanguageModelingへの入力はinput_idsattention_masklabelsの3種類です。

  • input_ids:入力テキストをトークンに分割してID化したもの
  • attention_mask:入力テキストのトークンの存在を示す(トークンが存在すれば1を示し、存在しなければ0を示す)
  • labels:モデルを学習する際に利用するラベル(DataCollatorForLanguageModelingへの入力にはinput_idsと同じものを用意することで、自動的に変換してくれる)

上記は「今年の夏は暑いらしい。」という例文を実際にDataCollatorForLanguageModelingに入力してみた結果です。先ほども述べた通り、今回のトークナイザーでは4が[MASK]を示すものになりますので、この場合は「今年」という単語が[MASK]に変換されていることが確認できます。

# input_ids・attention_maskをtokenizerから作成
encodings = tokenizer(texts, return_tensors='pt', padding=True, truncation=True, max_length=64)
input_ids = encodings['input_ids']
attention_mask = encodings['attention_mask']

# 各文に対してinput_ids・attention_mask・labelsのdictionaryを作成する
col_all = []
for i in range(len(attention_mask)):
    col = {}
    col['input_ids'] = input_ids[i].tolist()
    col['attention_mask'] = attention_mask[i].tolist()
    col['labels'] = input_ids[i].tolist()
    col_all.append(col)

# [MASK]を自動生成する(15%の確率で[MASK]を生成)
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm_probability=0.15)[f:id:kdl-di:20220620171231p:plain]
input_ids = data_collator(col_all)['input_ids']
attention_mask = data_collator(col_all)['attention_mask']
labels = data_collator(col_all)['labels']

ファインチューニングのために準備しよう

ここでは、学習のためにPyTorch LightningのDataset ModuleModel ModuleCallbacksを定義してTrainerを作成していきます。なお、コード上の細かいPyTorch Lightningに関する挙動は公式のチュートリアルをご覧ください。

Dataset Module

今回、訓練データを1000件・評価データを183件として学習を進めていきます。PyTorch Lightningを使うことで、簡潔にPyTorchのデータローダを定義することができるので便利ですね。

# Dataset定義
class Datasets(torch.utils.data.Dataset):
    def __init__(self, input_ids, attention_mask, labels):
        self.input_ids = input_ids
        self.attention_mask = attention_mask
        self.labels = labels

    def __len__(self):
        return len(self.input_ids)

    def __getitem__(self, idx):
        input = self.input_ids[idx]
        mask = self.attention_mask[idx]
        label = self.labels[idx]
        return input, mask, label

# DataModule定義
class DataModule(pl.LightningDataModule):
    def __init__(self, input_ids, attention_mask, labels, batch_size=32, num_workers=2):
        super().__init__()
        self.input_ids = input_ids
        self.attention_mask = attention_mask
        self.labels = labels
        self.save_hyperparameters() 
    # 訓練・検証時のデータセット作成
    def prepare_data(self):
        self.train_dataset = Datasets(self.input_ids[:1000], self.attention_mask[:1000], self.labels[:1000])
        self.val_dataset = Datasets(self.input_ids[1000:], self.attention_mask[1000:], self.labels[1000:])
    # 訓練時のデータローダ作成
    def train_dataloader(self):
        dataloader = torch.utils.data.DataLoader(
            self.train_dataset, batch_size=self.hparams.batch_size
        )
        return dataloader
    # 検証時のデータローダ作成
    def val_dataloader(self):
        dataloader = torch.utils.data.DataLoader(
            self.val_dataset, batch_size=self.hparams.batch_size
        )
        return dataloader
Model Module

ここでは、モデルに関わる定義を行なっています。今回、最適化関数はAdamを利用し、スケジューラ(モデルを学習する際に学習率を管理する)として最もシンプルなStepLRを利用しています。また、モデルの保存のために訓練・検証時の各ステップでlossを記録しています。

# ModelModule定義
class ModelModule(pl.LightningModule):
    def __init__(self):
        super().__init__()
        self.model_name = 'cl-tohoku/bert-base-japanese-whole-word-masking'
        self.model = BertForMaskedLM.from_pretrained(self.model_name)
    # 順伝播
    def forward(self, x):
        x = self.model(x)
        return x
    # 損失関数・スケジューラ定義
    def configure_optimizers(self):
        optimizer = torch.optim.Adam(self.parameters(), lr=1e-5)
        lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=1)
        return [optimizer], [lr_scheduler]
    # 訓練時
    def training_step(self, batch, batch_idx):
        input, mask, label = batch
        loss = self.model(input_ids=input, labels=label).loss
        self.log('train_loss', loss)
        return loss
    # 検証時
    def validation_step(self, batch, batch_idx):
        input, mask, label = batch
        loss = self.model(input_ids=input, labels=label).loss
        self.log('val_loss', loss)
        return loss
Callbacks

PyTorch Lightningでは、モデル学習時に既存の仕組み以外に追加で処理を差し込むためのCallbacksという挙動が用意されています。今回は、二つのCallbacksを定義します。

  • ModelCheckpoint:モデルの保存条件として、検証段階において最も損失(学習の際にモデルの出力結果と実際の正解に対して計算される指標)が小さい時を指定する
  • Early Stopping:検証時の損失が5回連続下回る場合は、指定したエポック回数に達していなくても学習を停止する
# モデルの重みを保存する条件を指定
save_point = pl.callbacks.ModelCheckpoint(
    monitor = 'val_loss', 
    mode = 'min',
    save_top_k = 1, # 保存するモデルの数
    save_weights_only=True,
    dirpath = '/content/model/' # モデルの保存先
)

# Early Stopping
early_stopping = pl.callbacks.EarlyStopping(
    monitor = 'val_loss',
    mode = 'min',
    patience = 5
)

# 学習の大枠を定義(今回は二つのCallbacksを利用)
trainer = pl.Trainer(
    max_epochs=30,
    gpus = -1 if torch.cuda.is_available() else None,
    callbacks = [save_point, early_stopping],
    deterministic=True
)
学習

ここで学習を行います。PyTorch Lightningを使うことで、学習部分を定義したコードを非常に簡潔に書けました。

data = DataModule(input_ids, attention_mask, labels, batch_size=32, num_workers=8)
model = ModelModule().to(device)

# 訓練・検証
trainer.fit(model, data)

Trainerのmax_epochsはモデルの学習が途中で止まることをなるべく防げるように、ある程度幅を持たせて30回に設定していますが、今回はEarly Stoppingを設定したことによりPyTorchLightningが自動的に10回で学習を終了してくれました。

BERTはなんと予測する?

さて、ファインチューニングの学習が終わったところでBERTは当ブログの特徴をなんというのか確認してみましょう!予測の部分のプログラムは事前学習済みのものと同じです。

model_predict = ModelModule()
model_predict = model_predict.load_from_checkpoint(save_point.best_model_path)
model_predict = model_predict.cuda()

text = '『神戸のデータ活用塾!KDL Data Blog』では[MASK]についての記事を発信しています。是非、一度ご覧ください。'
tokens = tokenizer.tokenize(text)

input_ids = tokenizer.encode(text, return_tensors='pt')
input_ids = input_ids.cuda()

with torch.no_grad():
  output = model_predict(input_ids=input_ids)
  scores = output.logits

mask_position = input_ids[0].tolist().index(4)
id_best = scores[0, mask_position].argmax(-1).item()
token_best  = tokenizer.convert_ids_to_tokens(id_best)
token_best = token_best.replace('##', '')

text = text.replace('[MASK]', token_best)
print(text)

さて、気になる実行結果は??

結果発表


『神戸のデータ活用塾!KDL Data Blog』ではデータについての記事を発信しています。是非、一度ご覧ください。

お、先ほどの「神戸」から「データ」に変わっていますね。確かに、我々はデータに関連する情報を当ブログで発信しているので、うまくファインチューニングできたと言ってよいでしょう。

まとめ

ここまでみてきたように、BERTはファインチューニングすることで学習データの特徴を反映したモデルを作成することができます。今回の例ではマスク付き言語モデルでファインチューニングを行いましたが、分類モデルなどにファインチューニングすることも可能です。BERTは、このようにファインチューニング次第で様々なタスクに応用可能なため、検索エンジン・自動翻訳・スマートスピーカーなど多くの分野で用いられています。皆様も、是非一度BERTで遊んでみてください!

高木裕仁

データインテリジェンスチーム所属
データサイエンティスト。自然言語処理を中心としながら、その他の非構造化データや構造化データに関しても偏りなく扱います。こちらのブログでは、自然言語処理に関するトピックやAzureを中心としたクラウドを利用したデータ活用に関してのトピックを中心に様々な記事を発信していきます。