こんにちは。パロアルトインサイト、データサイエンティストの辻です。前回の「最先端自然言語モデルBERTとは①」ではモデルの概要について説明しました。今回はデータの前処理も含めたBERTのチュートリアルをします。BERTは既にたくさんブログで紹介されていますが、簡素化されたAPIの使い方やニューラルネットワークそのものを構築したものが多く、データ作成手順にについてのブログが少ないと感じたため、BERT日本語Pretrainedモデルを使い、固有表現抽出モデルのデータの作り方とその学習プロセスを紹介します。これから紹介するステップを順に追うことで、BERTをよりよく理解することが目的です。
構築環境 (MacOS Catalina)
python = 3.6
pytorch-transformers == 1.2.0
Juman++ = 1.0.2
pyknp = 0.4.1
tensorflow = 1.15
ステップ1:BERTのリポジトリーをクローンする
GoogleのBERTレポジトリーをクローンします。
git clone https://github.com/google-research/bert
ステップ2:日本語版BERTモデルのダウンロード
BERT日本語版Pretrainedモデルサイトからpytorch transformer用ファイルをダウンロードしてください。このチュートリアルではBASE 通常版を使用します。ダウンロードしたファイルは、サイトの注意書きにあるよう「bert」ディレクトリー内に置いてください
ステップ3:Juman++対応
初期の状態では日本語形態素解析システムJuman++に対応していないため、以下の変更をtokenization.pyに加えることが必要です。[参考URL:BERT導入手順おさらいメモ] 1から3のステップでプログラムを始める準備が整いました。
1. tokenization.pyの末尾にclass JumanPPTokenizerの追加する
class JumanPPTokenizer(BasicTokenizer):
def __init__(self):
"""Constructs a BasicTokenizer.
"""
from pyknp import Juman
self.do_lower_case = False
self._jumanpp = Juman()
self.never_split = ("[UNK]", "[SEP]", "[PAD]", "[CLS]", "[MASK]")
def tokenize(self, text):
"""Tokenizes a piece of text."""
text = self.convert_to_unicode(text.replace(' ', ''))
text = self._clean_text(text)
juman_result = self._jumanpp.analysis(text)
split_tokens = []
for mrph in juman_result.mrph_list():
split_tokens.extend(self._run_split_on_punc(mrph.midasi))
output_tokens = whitespace_tokenize(" ".join(split_tokens))
return output_tokens
def convert_to_unicode(self, text):
"""Converts `text` to Unicode (if it's not already), assuming utf-8 input."""
if six.PY3:
if isinstance(text, str):
return text
elif isinstance(text, bytes):
return text.decode("utf-8", "ignore")
else:
raise ValueError("Unsupported string type: %s" % (type(text)))
elif six.PY2:
if isinstance(text, str):
return text.decode("utf-8", "ignore")
elif isinstance(text, unicode):
return text
else:
raise ValueError("Unsupported string type: %s" % (type(text)))
else:
raise ValueError("Not running on Python2 or Python 3?")
2. class FullTokenizerでBasicToknizerをJumannPPTokenizerに変更
class FullTokenizer(object):
"""Runs end-to-end tokenziation."""
def __init__(self, vocab_file, do_lower_case=True):
self.vocab = load_vocab(vocab_file)
self.inv_vocab = {v: k for k, v in self.vocab.items()}
# JUMAN++を用いた日本語対応の場合、BasicTokenizerを使わない。
# self.basic_tokenizer = BasicTokenizer(do_lower_case=do_lower_case)
self.jumanpp_tokenizer = JumanPPTokenizer()
self.wordpiece_tokenizer = WordpieceTokenizer(vocab=self.vocab)
def tokenize(self, text):
split_tokens = []
# JUMAN++を用いた日本語対応用。
#for token in self.basic_tokenizer.tokenize(text):
for token in self.jumanpp_tokenizer.tokenize(text):
for sub_token in self.wordpiece_tokenizer.tokenize(token):
split_tokens.append(sub_token)
return split_tokens
def convert_tokens_to_ids(self, tokens):
return convert_by_vocab(self.vocab, tokens)
def convert_ids_to_tokens(self, ids):
return convert_by_vocab(self.inv_vocab, ids)
ステップ4:BERTトークン化
ここでは、モデルをよりよく理解するためpythonのスクリプト状でモデルを動かしてみたいと思います。先ほどダウンロードしたフォルダーからtokenizerをロードします。
from bert.tokenization import FullTokenizer
bert_dir = os.path.join(os.getcwd(), "bert/Japanese_L-12_H-768_A-12_E-30_BPE_transformers")
tokenizer = FullTokenizer(os.path.join(bert_dir, "vocab.txt"),do_lower_case=False)
次に以下の5文をトークン化してみます。BERTトークンはJuman++に加え、「##」トークンが追加されることが特徴で、前の語彙との繋がりを表しします。固有表現抽出の場合これにより語彙数が変わってしまうので、後ほど調整を行います。
# Make sample text
sampletxts = ["こんにちはいい天気ですね。",
"明日は雨になるそうです。",
"昨日からお腹が痛いです。",
"今年の冬は暖冬だそうです。",
"体調が良くなってきました。"]
result = [tokenizer.tokenize(txt) for txt in sampletxts]
result
[['こん', '##にち', '##は', '、', 'いい', '天気', 'です', 'ね', '。'],
['明日', 'は', '雨', 'に', 'なる', 'そう', '##です', '。'],
['昨', '##日', 'から', 'お', '##腹', 'が', '痛', '##い', 'です', '。'],
['今年', 'の', '冬', 'は', '暖', '##冬', 'だ', 'そう', '##です', '。'],
['体調', 'が', '良く', 'なって', 'き', 'ました', '。']]
オリジナルjuman++のトークン化
[['こんにち', 'は', 'いい', '天気', 'です', 'ね', '。']
['明日', 'は', '雨', 'に', 'なる', 'そうです', '。']
['昨日', 'から', 'お腹', 'が', '痛い', 'です', '。']
['今年', 'の', '冬', 'は', '暖冬', 'だ', 'そうです', '。']
['体調', 'が', '良く', 'なって', 'き', 'ました', '。']]
学習データを文章ID,語彙以下のフォーマットに落とし込みます。日時、天気、健康の3種類のラベル付けを上の文章に行ったサンプルです。(最初の3文章。残りはURLからダウンロードしてください)タグを付ける初めの語彙はBで始まり、連続するタグはIでタグ付けをするのが特徴です。また関係のない語彙は「O」でタグ付けを行います。
Sentence_id | Word | Tag |
0 | こんにち | O |
0 | は | O |
0 | いい | O |
0 | 天気 | B-Weather |
0 | です | O |
0 | ね | O |
0 | 。 | O |
1 | 明日 | B-DayTime |
1 | は | O |
1 | 雨 | B-Weather |
1 | に | O |
1 | なる | O |
1 | そうです | O |
1 | 。 | O |
2 | 昨日 | B-DayTime |
2 | から | O |
2 | お腹 | B-Health |
2 | が | I-Health |
2 | 痛い | I-Health |
2 | です | O |
2 | 。 | O |
BERTのモデルをトレーニングするためには、データをBERTが読み込めるフォーマットに変換することが必要です。そのために以下のしょりを行います。
(1) 文章の始めと終わりに[CLS], [SEP]を追加をする。(文章分類、固有表現抽出の場合スタートと終わりを見分けるため)
(2) subword ##を使用したトークン。語彙が細かく分解されるためタグを調整する
# Make sentence generator
class SentenceGetter():
def __init__(self, data, tokenizer):
self.n_sent = 1
self.data = data
self.empty = False
agg_func = lambda s: [(w, t) for w, t in zip(s["Word"].values.tolist(), s["Tag"].values.tolist())]
self.grouped = self.data.groupby("Sentence_id").apply(agg_func)
self.sentences = [s for s in self.grouped]
self.tokenizer = tokenizer
def get_adjustedTags(self):
sentences, tags = [], []
for sent in getter.sentences:
newsent = ['[CLS]']
newtag = ['O']
for (word, tag) in sent:
subword = tokenizer.tokenize(word)
if len(subword) >= 1:
newtag = newtag + [tag]
tag = tag.replace('B-', 'I-') if 'B-' in tag else tag
newtag = newtag + ([tag] * (len(subword) - 1))
else:
newtag.append(tag)
newsent = newsent + subword
newsent.append('[SEP]')
newtag.append('O')
sentences.append(newsent)
tags.append(newtag)
return sentences, tags
dfNER = pd.read_csv(os.path.join(data_dir, "dftrain_labeled.csv"), encoding = 'utf-8', sep = ',')
getter = SentenceGetter(dfNER, tokenizer)
sentences, tags = getter.get_adjustedTags()
tags_vals = set([l for tag in tags for l in tag])
tag2idx = {t: i for i, t in enumerate(tags_vals)}
tdx2tag = {i: t for i, t in enumerate(tags_vals)}
この時点で文章とタグは以下の通りになります
sentences
[['[CLS]', 'こん', '##にち', 'は', 'いい', '天気', 'です', 'ね', '。', '[SEP]'],
['[CLS]', '明日', 'は', '雨', 'に', 'なる', 'そう', 'です', '。', '[SEP]'],
['[CLS]', '昨', '##日', 'から', 'お', '##腹', 'が', '痛', '##い', 'です', '。', '[SEP]'],
['[CLS]', '今年', 'の', '冬', 'は', '暖', '##冬', 'だ', 'そう', 'です', '。', '[SEP]'],
['[CLS]', '体調', 'が', '良く', 'なって', 'き', 'ました', '。', '[SEP]']]
tags
[['O', 'O', 'O', 'O', 'O', 'B-Weather', 'O', 'O', 'O', 'O'],
['O', 'B-DayTime', 'O', 'B-Weather', 'O', 'O', 'O', 'O', 'O', 'O'],
['O', 'B-DayTime', 'I-DayTime','O','B-Health','I-Health','I-Health','I-Health','I-Health','O','O','O'],
['O','B-DayTime','O','O','O','B-Weather','I-Weather','O','O','O','O','O'],
['O', 'B-Health', 'I-Health', 'I-Health', 'O', 'O', 'O', 'O', 'O']]
tag2idx
{'O' : 0, B-Health': 1, 'I-Weather': 2, 'B-Weather': 3, 'I-DayTime': 4, 'B-DayTime': 5, 'I-Health': 6}
さらにpaddingと呼ばれる入力値の長さをそろえる処理とデータを数値に変換する処理を行います
from keras.preprocessing.sequence import pad_sequences
MAX_LEN = 15
input_ids = pad_sequences([tokenizer.convert_tokens_to_ids(txt) for txt in sentences],
maxlen=MAX_LEN, dtype="long", truncating="post", padding="post")
tags_ids = pad_sequences([[tag2idx.get(l) for l in tag] for tag in tags],
maxlen = MAX_LEN, value = tag2idx["O"], padding = "post",
dtype = "long", truncating = "post")
入力値は以下のようにすべての語彙とタグを数値に変換します。これで入力IDs(X)とタグ(Y)のペアが揃いました。すべての語彙はvocab.txtにある語彙順の番号が割り当てられ、タグも定義した番号に振り分けられました。
input_ids
[[ 2 8185 21115 9 1635 9292 3338 2382 7 3 0 0 0 0 0]
[ 2 12072 9 2899 8 66 2208 3338 7 3 0 0 0 0 0]
[ 2 20753 2581 27 273 11209 11 5743 574 3338 7 3 0 0 0]
[ 2 19884 5 2141 9 11147 12029 234 2208 3338 7 3 0 0 0]
[ 2 9125 11 4161 78 1237 4561 7 3 0 0 0 0 0 0]]
tags_ids
[[0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 5, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 5, 4, 0, 1, 6, 6, 6, 6, 0, 0, 0, 0, 0, 0],
[0, 5, 0, 0, 0, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 1, 6, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]
以上のプロセスで一般的な学習の準備が揃ったのですが、BERTには「attension mask」と言う文章の語彙を隠しその言葉を予測する教師なし学習プロセスが含まれています。そのため、X、Yペアの他に「attention mask」を作成します。語彙の部分のみをマスクの対象とし1を割り当てます。これでトレーニングデータが完成です。
attention_masks = [[float(i > 0) for i in ii] for ii in input_ids]
attention_masks
[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]
今回は5サンプルのみのため、トレーニングデータをバリデーションデータと分けることはやめ、モデルチューニングのプロセスのみを説明します。学習データを作成した後は、pytorchの一般的なトレーニング方法で学習が可能です。
import torch
from torch.optim import Adam
from torch.utils.data import TensorDataset, DataLoader, RandomSampler
from pytorch_transformers import BertTokenizer, BertConfig, BertModel
from pytorch_transformers import BertForTokenClassification
# モデルの読み込み
config = BertConfig.from_json_file(os.path.join(bert_dir, "config.json"))
model = BertModel.from_pretrained(os.path.join(bert_dir, "pytorch_model.bin"), config = config)
# データをtensorに変換
train_inputs = torch.tensor(input_ids)
train_tags = torch.tensor(tags_ids)
train_masks = torch.tensor(attention_masks)
train_data = TensorDataset(train_inputs, train_masks, train_tags)
train_sampler = RandomSampler(train_data)
train_dataloader = DataLoader(train_data, sampler=train_sampler, batch_size = batch_size)
# 全ての重みを学習し直す場合は、パラメターの設定を行う。Opimizerの設定
FULL_FINETUNING = True
if FULL_FINETUNING:
param_optimizer = list(model.named_parameters())
no_decay = ["bias", "gamma", "beta"]
optimizer_grouped_parameters = [
{'params' : [p for n, p in param_optimizer if not any (nd in n for nd in no_decay)],
'weight_decay_rate' : 0.01},
{'params' : [p for n, p in param_optimizer if any(nd in n for nd in no_decay)],
'weight_decay_rate' : 0.0}
]
else:
param_optimizer = list(model.classifier.named_parameters())
optimizer_grouped_parameters = [{"params": [p for n, p in param_optimizer]}]
optimizer = Adam(optimizer_grouped_parameters, lr=3e-5)
# トレーニング
epochs = 5
max_grad_norm = 1.0
for _ in trange(epochs, desc="Epoch"):
# TRAIN loop
model.train()
train_loss = 0
nb_train_examples, nb_train_steps = 0, 0
for step, batch in enumerate(train_dataloader):
b_input_ids, b_input_mask, b_labels = batch
# forward pass
loss = model(b_input_ids, token_type_ids = None, attention_mask = b_input_mask, labels = b_labels)
# backward pass
loss[0].backward()
# track train loss
train_loss += loss[0].item()
nb_train_examples += b_input_ids.size(0)
nb_train_steps += 1
# gradient clipping
torch.nn.utils.clip_grad_norm_(parameters = model.parameters(), max_norm = max_grad_norm)
# update parameters
optimizer.step()
model.zero_grad()
print("Train loss: {}".format(train_loss / nb_train_steps))
トレーニング結果の出力。モデルの動作確認のため、精度の検証は割愛します。
Epoch: 100%|██████████| 5/5 [00:52<00:00, 10.57s/it]
Train loss: 1.490822696685791
Train loss: 0.6730928838253021
Train loss: 0.2201131671667099
Train loss: 0.07963154837489128
Train loss: 0.027780962735414506
最後にトレーニングをしたモデルから予測しを出力します。非常に少ないデータでトレーニングを行なったので、以下の2文章の予測を比べます。
1. 明日はお腹の痛みが引くといいですね
2. 明日までにお腹の痛みが引くといいですね
1の文はトレーニングデータに全く同じ文脈があるため、うまくタグ付けがされています。2の文は明日の後の部分がトレーニングデータにないため、うまくタグ付けができてません。一方でお腹の痛みの周辺の語彙は違うにもかかわらず、タグ付けがうまくできています。「明日」は文脈の先頭部分に当たり、他の語彙と比べ周りの文脈の情報が少ないことと、「お腹の痛み」は語彙数5の長さですが、「明日」は語彙数1のため文脈が読みづらいことなどが考えられます。実際のプロジェクトでは多くのデータで学習を行い、様々な文脈を読めるモデルを構築します。
# Test with a sentence
test_sents = ["明日までにお腹の痛みが引くといいですね。",
"明日はお腹の痛みが引くといいですね。"]
test_sents = [['[CLS]'] + tokenizer.tokenize(sent) + ['[SEP]'] for sent in test_sents]
sent_ids = pad_sequences([tokenizer.convert_tokens_to_ids(sent) for sent in test_sents],
maxlen = MAX_LEN, dtype = 'long', truncating='post', padding='post')
result = model((torch.tensor(sent_ids)))
values, indexes = result[0].max(2)
preds = []
for index in indexes.tolist():
preds.append([idx2tag[i] for i in index])
test_sents
[['[CLS]', '明日', 'まで', 'に', 'お', '##腹', 'の', '痛み', 'が', '引く', 'と', 'いい', 'です', 'ね', '。', '[SEP]'],
['[CLS]', '明日', 'は', 'お', '##腹', 'の', '痛み', 'が', '引く', 'と', 'いい', 'です', 'ね', '。', '[SEP]']]
preds
[['O', 'O', 'O', 'O', 'B-Health', 'I-Health', 'I-Health', 'I-Health', 'I-Health', 'I-Health', 'O', 'O', 'O', 'O', 'O'],
['O', 'B-DayTime', 'O', 'B-Health', 'I-Health', 'I-Health', 'I-Health', 'I-Health', 'O', 'O', 'O', 'O', 'O', 'O', 'O']]
今回のブログでは日本語BERTのデータの作り方を紹介しました。オープンソースのAPIなどを使用すれば前処理の必要もなく予測できるものもありますが、予測の結果がうまくいかない時などは、学習データが正しく変換されているかやパラメータが適切なのかなどの分析が必要となる場合もあります。ご興味のある方は一度試してください。
実際に導入をご検討の方は、AI導入/DX推進プロジェクトを成功させる3つアドバイスもぜひご参考ください。
BERT の学習時間を削減する研究論文「Early BERT」について、The Insight の記事「BERT(自然言語処理)の学習時間を削減する「モデル圧縮」とは」としても取り上げました。こちらもぜひご確認ください。
AIの活用提案から、ビジネスモデルの構築、AI開発と導入まで一貫した支援を日本企業へ提供する、石角友愛氏(CEO)が2017年に創業したシリコンバレー発のAI企業。
社名 :パロアルトインサイトLLC
設立 :2017年
所在 :米国カリフォルニア州 (シリコンバレー)
メンバー数:17名(2021年9月現在)
パロアルトインサイトHP:www.paloaltoinsight.com
お問い合わせ、ご質問などはこちらまで:info@paloaltoinsight.com
2010年にハーバードビジネススクールでMBAを取得したのち、シリコンバレーのグーグル本社で多数のAI関連プロジェクトをシニアストラテジストとしてリード。その後HRテック・流通系AIベンチャーを経てパロアルトインサイトをシリコンバレーで起業。東急ホテルズ&リゾーツのDXアドバイザーとして中長期DX戦略への助言を行うなど、多くの日本企業に対して最新のDX戦略提案からAI開発まで一貫したAI・DX支援を提供する。2024年より一般社団法人人工知能学会理事に就任。
AI人材育成のためのコンテンツ開発なども手掛け、順天堂大学大学院医学研究科データサイエンス学科客員教授(AI企業戦略)及び東京大学工学部アドバイザリー・ボードをはじめとして、京都府アート&テクノロジー・ヴィレッジ事業クリエイターを務めるなど幅広く活動している。
毎日新聞、日経xTREND、ITmediaなど大手メディアでの連載を持ち、 DXの重要性を伝える毎週配信ポッドキャスト「Level 5」のMCや、NHKラジオ第1「マイあさ!」内「マイ!Biz」コーナーにレギュラー出演中。「報道ステーション」「NHKクローズアップ現代+」などTV出演も多数。
著書に『AI時代を生き抜くということ ChatGPTとリスキリング』(日経BP)『いまこそ知りたいDX戦略』『いまこそ知りたいAIビジネス』(ディスカヴァー・トゥエンティワン)、『経験ゼロから始めるAI時代の新キャリアデザイン』(KADOKAWA)、『才能の見つけ方 天才の育て方』(文藝春秋)など多数。
実践型教育AIプログラム「AIと私」:https://www.aitowatashi.com/
お問い合わせ、ご質問などはこちらまで:info@paloaltoinsight.com
※石角友愛の著書一覧
毎週水曜日、アメリカの最新AI情報が満載の
ニュースレターを無料でお届け!
その他講演情報やAI導入事例紹介、
ニュースレター登録者対象の
無料オンラインセミナーのご案内などを送ります。