機械学習・自然言語処理の勉強メモ

学んだことのメモやまとめ

pytorch-transformersを触ってみる①

今更ながら、pytorch-transformersを触ってみます。
このライブラリはドキュメントが充実していて、とても親切です。
なので、今回はドキュメントに基づいて触ってみただけの備忘録です。
以下、有名どころのBERTで試してます。

詳しいことはここなどを参照してください。
huggingface.co

はじめに

以下で、入手できます。簡単です。

pip install pytorch-transformers

インストールしたら、以下でimportします。

import torch
from pytorch_transformers import BertTokenizer, BertModel

pytorch-transformersの基本は以下の3つのクラスで構成されます。

model classes モデル本体
configuration classes モデルのパラメータを設定するクラス(デフォルト設定の場合は不要)
tokenizer classes 入力文のtokenizeや辞書に関連するクラス

model classes

各モデル毎にfine tuningのための以下の7つのクラスが準備されています。
例えば、BERTの場合は以下のクラスがあります。

  • BertModel
  • BertForPreTraining
  • BertForMaskedLM
  • BertForNextSentencePrediction
  • BertForSequenceClassification
  • BertForMultipleChoice
  • BertForTokenClassification
  • BertForQuestionAnswering

理解を深めるため、クラスのコードを追っかけてみます。
modeling_bert.pyを見ると、全てBertPreTrainedModelの派生クラスということが分かります。
なので、基本的な振る舞いはほとんど同じです。
更に、BertPreTrainedModelはPreTrainedModelの派生クラスということが分かります。
PreTrainedModelが定義されているmodeling_utils.pyを見ると、
torch.nn.Modulesを継承しているということが分かります。
torch.nn.Modulesは、モデルを構築する時に継承するあれです。

では、BertForMaskedLMのアーキテクチャを見てみます。

from pytorch_transformers import BertForMaskedLM
model = BertForMaskedLM.from_pretrained('bert-base-uncased')
BertForMaskedLM(
  (bert): BertModel(
    (embeddings): BertEmbeddings(
      (word_embeddings): Embedding(30522, 768, padding_idx=0)
      (position_embeddings): Embedding(512, 768)
      (token_type_embeddings): Embedding(2, 768)
      (LayerNorm): BertLayerNorm()
      (dropout): Dropout(p=0.1)
    )
    (encoder): BertEncoder(
      (layer): ModuleList(
        (0): BertLayer(
        .
        .
        .
  (cls): BertOnlyMLMHead(
    (predictions): BertLMPredictionHead(
      (transform): BertPredictionHeadTransform(
        (dense): Linear(in_features=768, out_features=768, bias=True)
        (LayerNorm): BertLayerNorm()
      )
      (decoder): Linear(in_features=768, out_features=30522, bias=False)
    )
  )
)

最後のdecoderの部分に着目すると、

(decoder): Linear(in_features=768, out_features=30522, bias=False)

30522クラスへの線形変換を行っていることが分かります。
30522は単語の数なので、単語を出力するためのモデルとなっていることが分かります。

同様にBertForSequenceClassificationの出力層を見てみます。

from pytorch_transformers import BertForSequenceClassification
model = BertForSequenceClassification.from_pretrained('bert-base-uncased')
(classifier): Linear(in_features=768, out_features=2, bias=True)

2クラスの線形変換であり、分類問題ということが分かります。
このように、タスクに応じて適切なモデルが用意されています。

では、BertModelとは何なんでしょうか。出力層を見てみます。

model = BertModel.from_pretrained('bert-base-uncased')
  (pooler): BertPooler(
    (dense): Linear(in_features=768, out_features=768, bias=True)
    (activation): Tanh()
  )

良く分かりませんが、説明では

use BertModel to encode our inputs in hidden-states

と書いていますので、この出力を別のモデルに連結する場合に使用するのでしょうか。
(用途が良く分かっていません。。)

Pretrained models

上ではモデルをロードする際に以下のように書いていました。

model = BertModel.from_pretrained('bert-base-uncased')

bert-base-uncased とは何なんでしょうか。

これは学習済のパラメータをロードするための記述です。
例えば、bert-base-uncasedと指定した場合は、12-layer, 768-hidden, 12-headsで小文字英文での事前学習済のパラメータがロードされます。
*論文中のBERT(BASE)に相当します。

以下のように自身で学習したパラメータのロードも可能です。

model = BertModel.from_pretrained('./test/saved_model/')

from_pretrainedについても、少しだけ追っかけてみました。
先ほど、BertModelはPreTrainedModelを継承していると書きました。
このfrom_pretrainedもPreTrainedModelのclassmethodとして定義されています。
(modeling_utils.pyの375行目あたり)
ロードするモデルについては、以下のように記述されています。

# Load model
if pretrained_model_name_or_path in cls.pretrained_model_archive_map:
    archive_file = cls.pretrained_model_archive_map[pretrained_model_name_or_path]
elif os.path.isdir(pretrained_model_name_or_path):
    if from_tf:
        # Directly load from a TensorFlow checkpoint
        archive_file = os.path.join(pretrained_model_name_or_path, TF_WEIGHTS_NAME + ".index")
    else:
        archive_file = os.path.join(pretrained_model_name_or_path, WEIGHTS_NAME)

cls.pretrained_model_archive_mapは以下のような辞書ファイルです。

BERT_PRETRAINED_MODEL_ARCHIVE_MAP = {
    'bert-base-uncased': "https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-pytorch_model.bin", 
    # ...
}

なので、引数をbert-base-uncasedとした場合は、Amazon S3 のストレージ上のmodelファイルを見に行くのですね。
(ローカルダウンロード後はそちらを見に行きます。)
model_archive_mapに存在しない文字列の場合は、ローカルのmodelファイルを見に行きます。
その後、指定したmodelファイルを以下のようにロードします。

if state_dict is None and not from_tf:
    state_dict = torch.load(resolved_archive_file, map_location='cpu')

このstate_dictもオプショナルな引数のようで、指定して与えることも可能です。

configuration classes

BertConfigなどのConfigクラスは、PretrainedConfigクラスを継承しています。
PretrainedConfigクラスはclassmethodとして以下のような関数があります。

  • from_dict
  • from_json_file
  • from_pretrained

from_dict, from_json_fileなどはハイパーパラメータを与える形式の違いということが想定できます。
ややこしいのが、from_pretrainedです。Modelクラスにもfrom_pretrainedとの違いについて少し掘り下げてみます。
BertConfigも同じようにPretrainedConfigのclassmethodとして定義されています。
(modeling_utils.pyの109行目あたり)
ここも、BertModelとやっていることはほとんど同じです。

config = BertConfig.from_pretrained('bert-base-uncased') 

のように記述された場合、以下の辞書ファイルに従ってAmazon S3 のストレージ上のjsonファイルを見に行きます。

BERT_PRETRAINED_CONFIG_ARCHIVE_MAP = {
    'bert-base-uncased': "https://s3.amazonaws.com/models.huggingface.co/bert/bert-base-uncased-config.json",
    # ...

ちなみに、こっちはJSON形式なので、ブラウザから参照可能です。
(bert-base-uncasedは以下のようなJSONファイルです。)

{
  "attention_probs_dropout_prob": 0.1,
  "hidden_act": "gelu",
  "hidden_dropout_prob": 0.1,
  "hidden_size": 768,
  "initializer_range": 0.02,
  "intermediate_size": 3072,
  "max_position_embeddings": 512,
  "num_attention_heads": 12,
  "num_hidden_layers": 12,
  "type_vocab_size": 2,
  "vocab_size": 30522
}

少し、込み入ったので内容を整理すると、

model = BertModel.from_pretrained('bert-base-uncased')

のようにしてmodelクラスのfrom_pretrainedが呼ばれると、引数にconfigがない場合は
1. from_pretrainedの文字列でconfig.from_pretrainedを呼び出し
2. config.from_pretrainedの結果をconfigにセット
という手順でハイパーパラメータの値を得ます。
そのconfigの値で、モデルをインスタンス化します。
その後、読み込んでおいたmodelファイルを_load_from_state_dict()でセットします。

tokenizer classes

tokenizer classesにもfrom_pretrained()を通じてインスタンス化可能です。
tokenizer classのfrom_pretrainedも同様に

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

のように記述された場合、Amazon S3 のストレージ上のtxtファイルを見に行きます。

このtxtファイルはtokenが行単位でズラーっと記述されたものです。

.
skin
foreign
opening
governor
okay
.
引数について

tokenizerクラスには以下のような引数があります。

  • do_lower_case .. Whether to lower case the input. Only has an effect when do_wordpiece_only=False
  • do_basic_tokenize .. Whether to do basic tokenization before wordpiece.

※日本語の場合、do_basic_tokenize =False としおかないと文字単位に分割されてしまいます。

実験

最後に、いくつかのモデルを触ってみます。

bertForMaskedLM

ある文(文章)の一部をMASKして、それを予測する言語モデルです。
以下の文で試してみます。

I like to play football with my friends

ここで、footballをMASKして予測できるか試してみます。

まず、BERT用に先頭[CLS]、末尾に[SEP]を付けます。

text = "[CLS] I like to play football with my friends [SEP] ."

このtextをtokenizerに与えます。

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
tokenized_text = tokenizer.tokenize(text)
print(tokenized_text)
# ['[CLS]', 'i', 'like', 'to', 'play', 'football', 'with', 'my', 'friends', '[SEP]', '.']

次に、footballの箇所をMASKします。

masked_index = 5
tokenized_text[masked_index] = '[MASK]'
print(tokenized_text)
# ['[CLS]', 'i', 'like', 'to', 'play', '[MASK]', 'with', 'my', 'friends', '[SEP]', '.']

最後に単語を対応するidに変換したら入力系列の出来上がりです。

indexed_tokens = tokenizer.convert_tokens_to_ids(tokenized_text)
tokens_tensor = torch.tensor([indexed_tokens])

これをモデルに与えます。
このあたりは、通常のニューラルネットと同じ感じでいけます。

model = BertForMaskedLM.from_pretrained('bert-base-uncased')
model.eval()

tokens_tensor = tokens_tensor.to('cuda')
segments_tensors = segments_tensors.to('cuda')
model.to('cuda')

with torch.no_grad():
    outputs = model(tokens_tensor)
    predictions = outputs[0]
_, predicted_indexes = torch.topk(predictions[0, masked_index], k=5)
predicted_tokens = tokenizer.convert_ids_to_tokens(predicted_indexes.tolist())
print(predicted_tokens)
# ['golf', 'games', 'football', 'chess', 'tennis']

footballが3番目に来てます。良い感じです。
今回の文では、どれも正解と言えると思います。

BertForNextSentencePrediction

2つの文が連続しているかどうかを判定します。

まずは、ドキュメント通りに試してみます。

### Classify next sentence using ``bertForNextSentencePrediction``
# Going back to our initial input
text = "[CLS] Who was Jim Henson ? [SEP] Jim Henson was a puppeteer [SEP]"
tokenized_text = tokenizer.tokenize(text)
indexed_tokens = tokenizer.convert_tokens_to_ids(tokenized_text)
segments_ids = [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
tokens_tensor = torch.tensor([indexed_tokens])
segments_tensors = torch.tensor([segments_ids])

model = BertForNextSentencePrediction.from_pretrained('bert-base-uncased')
model.eval()

tokens_tensor = tokens_tensor.to('cuda')
segments_tensors = segments_tensors.to('cuda')
model.to('cuda')

# Predict the next sentence classification logits
with torch.no_grad():
    next_sent_classif_logits = model(tokens_tensor, segments_tensors)

print(torch.softmax(next_sent_classif_logits[0], dim=1))
# tensor([[1.0000e+00, 2.8665e-06]], device='cuda:0')

0が連続していると考えると、正解しています。

次に、連続していない文章を与えてみます。

text = "[CLS] Japan has many traditional areas [SEP] Mike goes to library to study [SEP]"

予測結果を見てみます。

torch.softmax(next_sent_classif_logits[0], dim=1)
# tensor([[1.3438e-05, 9.9999e-01]], device='cuda:0')

さっきとは逆に1の方の確率が高くなっています。これも正解です。

おわりに

pytorch-transformersの簡単なところを触ってみました。
次回は、日本語の学習済モデルを使って同様のことを試してみたいと思います。

*コードはの行数などは記事を書いた時点のものなので、今後変更されるかもしれません。

tensorboardXの基本的な使い方①

はじめに

今更ですが、tensorboardを触ってみました。

普段TensorFlowを使わないので、何となく避けてたのですが、
tensorboardXは「tensorboard for pytorch」ということで、
触ってみました。

結論から言うとめちゃくちゃ簡単で便利でした。
なので、備忘録として残そうと思います。

きっちりと理解したい人は公式のGithubやDocumentを見るのが良いと思います。
github.com

tensorboardx.readthedocs.io

ここでは、利用頻度が高いと思わるものについて限定してまとめます。

基本

基本的な使い方は以下の通りです。

from tensorboardX import SummaryWriter

writer = SummaryWriter()

# build network
# training

# data to tensorboard
writer.add_XXX

writer.close()

作った後は以下をシェルで起動します。

tensorboard --logdir runs

立ち上がった後はブラウザから確認可能です。

http://localhost:6006/

add_scalar

1次元(スカラー値)の連続データを描画します。

add_scalar(tag, scalar_value, global_step=None, walltime=None)

サンプルはこんな感じです。

from tensorboardX import SummaryWriter
writer = SummaryWriter()
x = range(100)
for i in x:
    writer.add_scalar('y=2x', i * 2, i)
writer.close()

f:id:kento1109:20190706173125p:plain:w300

損失値の描画に使うとこんな感じになります。

for n_iter in range(epochs):
    train_loss = train(...)
    test_loss = test(...)
    writer.add_scalar('data/scalar1', train_loss, n_iter)
    writer.add_scalar('data/scalar2', test_loss, n_iter)
writer.close()

f:id:kento1109:20190706173644p:plain

add_scalars

訓練データとテストデータの損失値を合わせて描画したいです。
そんな場合は「add_scalars」を使うと便利です。

add_scalars(main_tag, tag_scalar_dict, global_step=None, walltime=None)

サンプルはこんな感じです。

from tensorboardX import SummaryWriter
writer = SummaryWriter()
r = 5
for i in range(100):
    writer.add_scalars('run_14h', {'xsinx':i*np.sin(i/r),
                                    'xcosx':i*np.cos(i/r),
                                    'tanx': np.tan(i/r)}, i)
writer.close()

f:id:kento1109:20190706173937p:plain:w300

さて、訓練データとテストデータの損失値を合わせて描画します。

for n_iter in range(epochs):
    train_loss = train(...)
    test_loss = test(...)
    writer.add_scalars('data/scalars',{'train': train_loss,
                                       'test': test_loss
                                       }, n_iter)
writer.close()

f:id:kento1109:20190706174419p:plain

ちなみにタグは「/」でグループ化できるみたいです。
テストの精度を含めてグループ化してみます。

for n_iter in range(epochs):
    train_loss = train(...)
    test_loss, test_acc = test(...)
    writer.add_scalars('model1/loss',{'train': train_loss,
                                       'test': test_loss
                                       }, n_iter)
    writer.add_scalar('model1/acc', test_acc, n_iter)
writer.close()

f:id:kento1109:20190706175550p:plain

今回、紹介したのは add_scalar, add_scalars だけですが、
とりあえず、基本的な学習時の値を描画するのは十分かと思います。

本日、用いたコードは以下に置きました。
repo/tensorX.py at master · kento1109/repo · GitHub

次回は、Embeddingについてまとめたいと思います。

(論文)Cloze-driven Pretraining of Self-attention Networks

はじめに



2018年のNLPの主役は「BERT」で間違いないでしょう。
元の論文はGoogleから発表されており、Googleすごいってなりました。
黙っていないのがPytorchを開発した「Facebook」です。

ってことで、彼らの手法でNERのタスクにおいて僅かですがBERTを抜いてSOTAを達成しました。

今日はその論文を紹介したいと思います。
Cloze-driven Pretraining of Self-attention Networks

では、さっそく内容に入っていきます。

ELMoとGPTの流れを汲んだモデルみたいです。
(その点ではBERTと同じですね。)

cloze-style training

モデルの特徴は「bi-directional transformer」ということです。
「cloze-style」(穴埋め形式)の訓練手法という点が特徴的です。
前方(left-to-right)と後方(right-to-left)の文脈情報を与えて、中心の単語を予測します。
f:id:kento1109:20190508160307p:plain

BERTとの違い

気になるのはやはり「BERT」との違いです。
著者も意識していると思いますし、その違いを本文でも以下のように述べています。

我々のモデルは、系列中の各tokenを予測するbi-directional transformer型の言語モデルであり、
BERTもinput全体を利用することで、bi-directionalを実現している。
しかし、実現にはMasked LMやNext Sentence Predictionといった特殊なレジームを必要としている。
その一方で我々は周辺のtokenから各tokenを予測するモデルであり、1つの損失関数で事足りる。

要は学習時に特殊なことしなくてもよいよ、ってことですかね。

Block structure

各Blockの構造は「Transfomer」みたいなものです。
各Blockは2つのsub-blockから構成されます。
1つはH=16の「multi-head self-attention」です。

f:id:kento1109:20190427140223p:plain:h300
https://arxiv.org/pdf/1706.03762.pdf
※予測するtoken以降はマスクしておきます。

もう1つはfeed-forward networkです。

ここまでの入力を整理します。
まず、入力は文の各tokenを分散表現で表した行列です。それにposition embeddingsにより位置情報を足します。

f:id:kento1109:20190427121810p:plain:w300
https://towardsdatascience.com/how-to-code-the-transformer-in-pytorch-24db27c8f9ec
更にこの論文では、tokenの文字情報をCNNでエンコードした情報も加えています。

厳密性には欠けますが、入力情報は以下のような行列になるはずです。
f:id:kento1109:20190508170843p:plain:w300

これをforwardとbackwardでそれぞれ共通して利用します。
例えば、forwardでは「I, like」から「playing」単語を予測します。
backwardでは「soccer, ...」から「playing」単語を予測します。
ただ、文中のtokenを予測するモデルなのに、そのtoken以降の情報を含めてはいけません。
そこで、forwardとbackwardでそれぞれマスクをかけます。

Combination of representations

forwardとbackwardのtransformerモデルを組み合わせます。

  • base modelではsum
  • larger modelではconcat

しています。
forwardとbackwardを組み合わせることで、ターゲットtokenの周囲のtoken情報全体を利用することになります。
訓練は各tokenごとに行っているそうです。

Results

このモデルの上にタスク固有のモデルを繋げるそうです。
NERのタスクの場合、biLSTM-CRFを適用させているみたいです。
CoNLL 2003の結果です。
f:id:kento1109:20190509125437p:plain

最後に

最近はRNN系ではなく、transformerをベースとしたモデルが主流のようです。
ただ、NERのタスクで言うとRNN系でも十分に精度は高いと思います。
例えば、以下の論文でのDevセットでのF1値は94.74と今回のSOTAとも2ポイント程度しか違いません。
End-to-end Sequence Labeling via Bi-directional LSTM-CNNs-CRF
もちろん、この2ポイントの違い(RNN系とtransformer系のエラー分析)は気になりますが…

(論文)Pretrained language models

はじめに



以前にPretrained language modelsの基本について書きました。
kento1109.hatenablog.com

今回はドメイン領域(Biomedical)での固有表現認識(NER)について適用した論文をまとめます。

[1711.07908] Effective Use of Bidirectional Language Modeling for Transfer Learning in Biomedical Named Entity Recognition

どんな論文?



Biomedicalから重要なエンティティ(薬品名、病名、組織名など)を抽出するタスクはかなり昔から研究されており、今でも重要なタスクです。
このドメインで抽出したいエンティティはIREXが定義している一般的なエンティティ(人名、都市名など)とは大きく異なります。下記が医療文書に出現するテキスト例で、赤いマーカーが「disease entities」、黄色いマーカーが「anatomical entities」です。
f:id:kento1109:20181008134739p:plain

このようにBiomedicalのエンティティとなる単語はドメイン固有な場合は多く、一般的なコーパスWikipediaなど)ではほとんど出現しません。

そこで一般的なコーパスから訓練済モデルを構築するのではく、PubMedデータセットを用いて学習を行い精度向上を達成しました。
また、単語の分散表現をCharacter-Level CNNでも学習している点も面白いと思いました。

手法



NERモデル構造は下図の通りです。
f:id:kento1109:20181008142538p:plain
これらのレイヤーのうち、

  • character-CNN
  • word embedding
  • BiLSTM(LSTM_F + LSTM_B)

言語モデルで事前に学習します。

言語モデルの構造は下図の通りです。
f:id:kento1109:20181008143148p:plain

また、NERモデルのうち、

  • Decoder
  • CRF

はNER固有なので、ランダムな初期値を用います。
順番に各レイヤーを見ていきます。

character-level CNN

単語を文字単位で畳み込んでその特徴量を抽出します。
f:id:kento1109:20181008144447p:plain

単語レベルで捉えられない文字の特徴量で単語を表現できることが期待できます。

word embedding

単語レベルの特徴量を抽出します。
言語モデルでは一般的なレイヤーと言えます。

Bidirectional LSTM

character-level CNNとword embeddingの特徴量を結合したものをこの層の入力とします。

実験結果



実験には下記の4つのデータセットを利用しています。
f:id:kento1109:20181008150933p:plain:w500
言語モデルとNERの学習に用いたデータセットは同じみたいです。

また、言語モデルの訓練においても、embedding層の初期パラメータとしてskip-gramで学習しています。事前学習の事前学習みたいなものです。

結果は下図の通りです。概ねSOTAを達成しています。
f:id:kento1109:20181008151209p:plain:w800

事前学習の有無の結果は下図の通りです。事前学習の効果が見られます。
f:id:kento1109:20181008151446p:plain:w500

その他



ドメイン固有のタスクの場合、教師データの準備が容易ではありません。この論文では、同じデータセット言語モデルという教師なしモデルで事前学習しました。
一方、ドメインに依存しない教師ありデータセットの場合は比較的入手が容易です(日本語の場合はそうでもないですが・・)。そのデータセットを使って事前学習を行う手法もあるみたいです。

(論文)BERT

今日も少し前に流行ったモデルの復習。
今日はBERTに関してまとめる。

f:id:kento1109:20190428105706p:plain
https://wikiwiki.jp/animegameex/%E3%83%90%E3%83%BC%E3%83%88

これもまた既に素晴らしいまとめがあるのでそちらを参考にしながら復習した。
jalammar.github.io
towardsdatascience.com

BERTとは



「Bidirectional Encoder Representations from Transformers」の略。
BERT自体は事前学習モデルではあるが、これを利用することで様々なタスクのSOTAを達成している。

A new era of NLP」なんて言われるほど、色々なところで騒がれている。

さて、以下の内容としては

  • BERTのモデルについて
  • どうやって目的のタスクに適用するか

の2つを中心にまとめようと思う。

※きっちり内容を理解したい場合、上の2つの記事を読むことを勧める。

Transformer Encoder



BERTの「T」は、Transformersの略でもあり、モデル自体は前回まとめたTransformerモデルはベースとなっている。
Seq2Seqモデルではないので、Decoderの部分は不要であり、必要なのはEncoderの部分のみ。
論文中には、以下のハイパーパラメータから成るBERT(BASE)とBERT(LARGE)の2つの実験をしている。

BASE LARGE 元のTransformerモデル
Encoderの数 12 24 6
Headの数 12 16 8

元のTransformerと比べて、BASEでも十分にごついモデルであることが分かる。
Transformerでは、Encoderの入力は文の各単語を分散表現したものと文をPositional Encodingしたものを加えたものであった。
BERTの場合は何が異なるのか。

Inputs



特徴的なのは、単文と複文(文のペア)のどちらかを入力とすることが出来る点。

入力情報は以下の埋め込み情報の総和である。

  • token embeddings
  • segmentation embeddings
  • position embeddings

f:id:kento1109:20190428113316p:plain
https://arxiv.org/pdf/1810.04805.pdf

特徴的なのは、[CLS][SEP]というトークン。

全ての入力の先頭には、[CLS]というトークンを挿入する。
これは「classification embedding」と呼ばれ、後の分類タスクで利用される。
※分類タスク以外の場合、このトークンは無視される。

[SEP]トークンは、文の区切りを表すトークン。
先頭文を示す埋め込み表現(A embedding)、次の文を表す埋め込み表現(B embedding)を付与する。
※常に入力を単文とする場合は、A embeddingのみが用いられる。

position embeddingsは、文中の各トークンの位置情報を付与するための情報。
これは、Transformerモデルでも利用されていたPositional Encodingと同じ概念。
※実験では最大512系列までの位置情報を埋め込み表現で利用。

Pre-training Tasks



BERTは教師なし学習なので、Seq2Seqのような入力文と対になる出力は存在しない。
では、どのようにして学習を行うのか。

Feature-based Approaches

ELMOのような従来の言語モデルは、次の単語や前の単語をRNNにより学習することで、事前学習を行っていた。

f:id:kento1109:20190428124738p:plain
https://arxiv.org/pdf/1810.04805.pdf
ELMOは前向きの言語モデルと後ろ向きの言語モデルを組み合わせることで、表現力の高い言語モデルを獲得している。
※あくまで各言語モデルを組み合わせているだけで、双方向のモデルではない。

Fine-tuning Approaches

最近の転移学習のトレンド。言語モデルを事前学習し、そのモデルを本来のタスクに転移させる手法。
OpenAI GPTは、Transfomerモデルをベースにした言語モデル
言語モデルからFine-tuningで本来のタスクを解くことができる。

ELMOは前向きの言語モデルと後ろ向きの言語モデルを組み合わせている。
OpenAI GPTはあくまで前向きの言語モデルからのみ(未来の情報をマスクする)構成されている。
しかし、一般的に前向きモデルと後ろ向きモデルの連結より、双方向モデルの方が連結は密と言える。

できれば、双方向で学習したいが、言語モデルではそれが難しい。
双方向の場合、予測時に未来の情報を利用するためカンニングとなってしまう。

BERTではこの問題をうまく回避して双方向の言語モデルを実現している。

1.Masked LM

BERTでは入力情報の一部をマスクしたMasked LMを提案している。
このモデルは、次の単語や前の単語を予測するのではなく、maskされた単語のみを予測する。

実験では、系列の15%を[MASK]で置換した。
※ただし、常に[MASK]に置き換えるのではなく、以下の条件をつけた。

  • 80%は[MASK]のtokenのまま
  • 10%は[MASK]のtokenを全く別の単語に置換
  • 10%は[MASK]のtokenを元の単語に戻した

学習は従来の言語モデルと似たアプローチ。
Encoderの出力にClassificaiton Layerを追加し、各単語の確率値をsoftmaxにより計算する。
損失計算では、maskされたtokenの損失値のみを考慮して、残りのtokenは無視する。

f:id:kento1109:20190428133104p:plain
https://towardsdatascience.com/bert-explained-state-of-the-art-language-model-for-nlp-f8b21a9b6270

2.Next Sentence Prediction

BERTの入力は、複文(文のペア)の入力を許していた。
この目的としては、複文のタスク(QAタスクなど)にも利用可能なモデルを構築すること。
ただし、Masked LMだけでは、そのようなモデルは期待できない。
そこで、「Next Sentence Prediction」という学習を行う。
このタスクは非常に簡単である。
問題は「2つの文(A,B)を与えて、BはAの後に続きそうな文かどうか」を解くこと。
f:id:kento1109:20190428134957p:plain

Task specific-Models



ここまでがpre-trainedモデルとしてのBERTのモデルの説明。
では、次に気になるのが「どうやって目的のタスクに適用するか」である。

classification tasks

この場合は非常にシンプルで文の先頭に付与した[CLS]のEncode結果を入力として分類器を構築する。

f:id:kento1109:20190428140222p:plain
http://jalammar.github.io/illustrated-bert/
分類器との接続後、接続した層のパラメータとBERTのパラメータを最適化する。

Named Entity Recognition

NERの場合でも、各エンティティを分類する層をBERTの出力に追加するだけ。

最後に



全体を通して、「WordPiece」というキーワードが多く出現した。
入力は単語単位ではなく、このWordPiece単位でtoken化されているよう。
この辺りの概念がよく整理できていないので、次はこれをまとめたい。

(論文)Transformer

久しぶりにブログを更新する。

今日は「Attention Is All You Need」に関する復習。

もはや2年前の論文で、日本語でも丁寧な解説記事がたくさんある。

deeplearning.hatenablog.com

とっても今更感があるが、自分自身の理解の定着のためにまとめようと思う。
とりあえず、従来のRNN型のSeq2Seqモデルと何が違うのか、その辺を押さえたい。
後、理解を深めるために適宜コードも入れればと思う。
※コードに関しては以下サイトより引用させて頂いた。
towardsdatascience.com
nlp.seas.harvard.edu

Transformerモデル



論文中の図を引用する。
f:id:kento1109:20190427111935p:plain:w350

Decoderの入力となるOutputはターゲットは1tokenずつ右にシフトさせておく。
例えば、ターゲット文が「<BOS> 私 は サッカー が 好き <EOS>」の場合、
「<BOS> 私 は サッカー が 好き」としておく。
また、時刻tのtokenから時刻t+1のtokenを予測するためターゲットを左にシフトさせる。
(ターゲットは「私 は サッカー が 好き <EOS>」としておく。)

モデル自体は従来のSeq2Seqモデルと同じ構造。
新しく出現するキーワードは、

  • Positional Encoding
  • Multi-Head Attention
  • Position-wise Feed-Forward Networks

の3つ。

Positional Encoding



入力の位置情報の付与。
センテンスの文脈情報を理解するためには、各単語の順序が重要となる。
このモデルはRNNのように順序を考慮しないので、位置情報を明示的に付与する必要がある。
Positional Encodingは以下の式に基づいて行う。


PE_{(pos,2i)}=sin(pos/10000^{2i/d_{model}}) \\
PE_{(pos,2i+1)}=cos(pos/10000^{2i/d_{model}})

posはセンテンス中の出現位置 0,1,...,Tiは次元数(単語の分散表現の次元数)。

例えば、文の先頭位置に出現する単語のPositional Encodingを考える。
論文の単語の分散表現の次元数は512なので、以下のように先頭位置を表すベクトルは得られる。


PE_{(pos,2i)}=sin(0/10000^{2i/50})

このようして得られた行列をEmbedding後の文の行列に加算することで、各単語と位置(文脈)情報を考慮した情報が得られる。

f:id:kento1109:20190427121810p:plain:w300
https://towardsdatascience.com/how-to-code-the-transformer-in-pytorch-24db27c8f9ec

Multi-Head Attention



Multi-Head Attention層は以下のようなイメージ。
f:id:kento1109:20190427140223p:plain:w300

入力を複数のHeadで分割する。

入力は{\rm X} = Q = K = V \in \mathbb{R}^{T\times d_{model}}
各パラメータは W_i^Q\in \mathbb{R}^{d_{model}\times d_k}W_i^K\in \mathbb{R}^{d_{model}\times d_k}W_i^V \in \mathbb{R}^{d_{model}\times d_k}

実験ではd_{model}=512h=8なので,d_k=64としている。
入力を各重みでの線形変換したもの

{\rm Attention}(QW_i^Q , KW_i^K , VW_i^V)
をScaled dot-product attentionの入力としている。

Scaled dot-product attention



この論文でのattentionモデルであり、タイトルにもあるようにここがモデルの肝の部分。
f:id:kento1109:20190427140247p:plain:w200
入力のQKVは先ほど計算したもの。
attentionの計算式は以下の通り。
\begin{eqnarray}{\rm Attention}(Q , K , V)={\rm softmax}(\frac{QK^T}{\sqrt d_k})V\end{eqnarray}

まず、QK^Tの部分。
Q ,K \in\mathbb{R}^{T\times d_k}だったので、QK^T \in \mathbb{R}^{T\times T}となる。
これに\sqrt d_kでスケーリングを行った後、softmaxを適用する。
これにV \in\mathbb{R}^{T\times d_k}を掛け合わせるので、最終的には、
{\rm Attention}(Q , K , V)={\rm softmax}(\frac{QK^T}{\sqrt d_k})V\in \mathbb{R}^{T\times h_d}となる。

これを各head毎に行ったものをConcatして重みW^O \in\mathbb{R}^{hd_v \times d_{model}}で線形変換を行う。

{\rm MultiHead}(Q , K , V)={\rm Concat(head_1, ..., head_h)}W^O
{\rm MultiHead}(Q , K , V)\in\mathbb{R}^{T\times d_{model}}となり、単語数×単語の分散表現の次元数に戻される。

結局のところ、この式変形が何を行っていたかをもう少し深堀してみる。

Query, Key, Value

まず、QK^Tの部分。

f:id:kento1109:20190427151617p:plain

スケーリングを無視して考えると、オレンジのスカラー値はQの赤のベクトルとKの青のベクトルの内積からなることが分かる。
f:id:kento1109:20190427160203p:plain
以下、同様となる。
f:id:kento1109:20190427160342p:plain

要するに、各queryと各keyの類似度を計算していることに相当する。
この行列の0行目のベクトルはquery0と各keyの類似度を表現したベクトルを意味する。
これをsoftmaxで正規化しており、各行の総和が1となるような重みベクトル(attention_weight)を作り出す。

最後にこの行列とV内積を取る。
f:id:kento1109:20190427163122p:plain
各queryのattention_weightとvalue内積を取ったものをattentionの結果として返す。
f:id:kento1109:20190427164937p:plain
なので、最終的な単語ベクトル(オレンジ)は、valueの各ベクトルにattention_weightを掛け合わせた値となることが分かる。
f:id:kento1109:20190427165130p:plain

Encoderでは、query, key, valueは全て同じ入力から渡される。(Self-Attention)
一方、Decoderでは、1層目のMulti-Head Attentionは全て出力から渡されるが、
2層目のQueryは出力から、key, valueはEncoderから渡される。(Source-Target-Attention)
Decoderの2層目のMulti-Head Attentionでは、出力の各queryとEncoderの各keyの類似度を取り、attention_weightを計算する。
それに出力のvalueを掛け合わせることで、Encoderのattention_weightを考慮した計算が可能となる。

もう少し具体例を挙げて考える。
Source文が「I like playing soccer」、Target文が「私/は/サッカー/が/好き/です」とする。
EncoderではSource文でのAttentionを計算する。

次に、Decoderでは、Source文とTarget文でAttentionを計算する。
query, key, valueはそれぞれ以下の通り。
f:id:kento1109:20190509102126p:plain
※Source文からkey, valueが、Target文からQueryが作られる。
attention_weight(QK^T)は以下のように計算される。
f:id:kento1109:20190509102146p:plain

attention_weightの計算を少しだけやってみる。
例えば、attention_weight(0,0)の値は次のように計算できる。
f:id:kento1109:20190509102528p:plain:w300
以下、同様である。
f:id:kento1109:20190509102700p:plain:w300
先頭のattention_weightは、Target文の「私」とSource文の各単語の内積を計算した結果となる。

f:id:kento1109:20190509104005p:plain

このattention_weightとvalueの行列計算を行う。
f:id:kento1109:20190509120816p:plain
attention_weightがこのような値の場合、水色のスカラー値はvalueの「soccer」の値に強く影響される一方、その他の値は0.1でスケーリングされるため、ほとんど影響をうけなくなる。
学習がうまくいくと、以降のネットワーク計算において、最も確率の高い単語として「サッカー」が出力されるようになる。

Mask



ターゲット文が「<BOS> 私 は サッカー が 好き <EOS>」の場合、
「<BOS> 私 は サッカー が 好き」としていた。
これは、時刻tのtokenから時刻t+1のtokenを予測するためであり、
「<BOS> 私 は」の情報から次のtokenが「サッカー」であることを予測していた。
つまり「<BOS> 私 は」の時点で「サッカー」という未来の情報は知るはずがない。
学習時は文として与えられるが、推論時は未来の情報は分からない。
このため、未来の情報にMaskをすることでリークを防ぐ。

Position-wise Feed-Forward Networks

attentionが理解できれば、この層は難しくない。
{\rm FFN}(x)={\rm ReLU}(xW_1+b_1)W_2+b_2

residual connection

ResNetで用いられている構造。

f:id:kento1109:20190427174124p:plain
https://deepage.net/deep_learning/2016/11/30/resnet.html

前の層の入力を足し合わせる。
これは「Positional Encoding & Multi-Head Attention」と「Multi-Head Attention & Position-wise Feed-Forward Networks」で利用されている。
実装例は以下の通り。

class SublayerConnection(nn.Module):
    """
    A residual connection followed by a layer norm.
    Note for code simplicity the norm is first as opposed to last.
    """
    def __init__(self, size, dropout):
        super(SublayerConnection, self).__init__()
        self.norm = LayerNorm(size)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, sublayer):
        "Apply residual connection to any sublayer with the same size."
        return x + self.dropout(sublayer(self.norm(x)))

これをEncoder内部で呼ぶ。

class EncoderLayer(nn.Module):
    "Encoder is made up of self-attn and feed forward (defined below)"
    def __init__(self, size, self_attn, feed_forward, dropout):
        super(EncoderLayer, self).__init__()
        self.self_attn = self_attn
        self.feed_forward = feed_forward
        self.sublayer = clones(SublayerConnection(size, dropout), 2)
        self.size = size

    def forward(self, x, mask):
        "Follow Figure 1 (left) for connections."
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
        return self.sublayer[1](x, self.feed_forward)

Positional Encodingの出力がxとなる。

これをEncoder,Decoderで6層ずつ積んでいる。

こんな感じでTransformerモデルが出来る。
今更だが、attentionとか良い勉強になった。

gensimに依存しない単語の類似度計算

はじめに



前にgensimによる単語の類似度について書きました。
kento1109.hatenablog.com
この手の記事はググればいっぱい出てくるので、gensimでモデルを作って単語の類似度を計算するのは難しくないと思います。

ただ、LSTMなどで学習した後の単語の分散表現の類似度を測定したい場合に、そのためだけにわざわざgensimのモデルを構築するのは面倒ですし、無駄かなと思います。

ある単語と別の単語の類似度を測定するだけの場合、そのベクトル同士で測定すれば良いですが、gensimmost_similar関数のような「ある単語のベクトルに近いベクトルの単語をN個取ってくる」機能を実現する場合は少し実装が必要です。
1単語ずつループで回してコサイン類似度を計算する、なんてしていては計算速度が遅くなります。

そこで、scipyとnumpyのライブラリを活用した関数を考えました。

僕が考えたというより、stack overflowで書いている人の内容を整理しただけですが、、
stackoverflow.com

実装

scipyには、cdistという便利な関数があります。
scipy.spatial.distance.cdist — SciPy v1.2.1 Reference Guide

これは、入力のペアの各組み合わせの距離を計算してくれます。

早速見てみます。

import numpy as np
from scipy.spatial import distance

x = np.random.uniform(low=-1.0, high=1.0, size=[5, 5])
"""
array([[-0.63429221, -0.34524962,  0.33029203, -0.47351972, -0.53888627],
       [ 0.53165424, -0.70032725, -0.91572034, -0.71147304, -0.75343722],
       [ 0.41431408, -0.67125034,  0.59739731,  0.48297114,  0.78140535],
       [-0.62946089, -0.74813722,  0.65404081,  0.08913251,  0.33907134],
       [ 0.00564964, -0.79616936,  0.17679241,  0.89239204, -0.76633555]])
"""

distances = distance.cdist(x, x, "cosine")
"""
array([[0.        , 0.80326347, 1.33463697, 0.50618473, 0.79103524],
       [0.80326347, 0.        , 1.35661241, 1.36218033, 0.85455544],
       [1.33463697, 1.35661241, 0.        , 0.43272935, 0.75444092],
       [0.50618473, 1.36218033, 0.43272935, 0.        , 0.69980619],
       [0.79103524, 0.85455544, 0.75444092, 0.69980619, 0.        ]])
"""

この関数は「距離」を計算する関数であり、「類似度」を計算する場合は「1-距離」とする必要があります。

0番目のベクトルとそれぞれのベクトルとの類似度を計算する場合は次のように書きます。

distances = distance.cdist([x[0]], x, "cosine")[0]
similarity = 1 - distances
# array([ 1.        ,  0.19673653, -0.33463697,  0.49381527,  0.20896476])

類似度計算は後で行うとして、とりあえず距離のままでいきます。
最も近い距離を計算するためには、np.argmin()を使います。

min_distance = np.argmin(distances)
# 0

最も距離が近いのは自分自身なので、「0」が返ってきます。
これでは意味がないので、以下のように工夫します。

n = 3
target_index = distances.argsort()[1:n+1]
# array([3, 4, 1])

距離が近い1番目~N+1番目までのベクトルを取ってきます。
これにより、自身を除くN個のベクトルが取得可能となりました。

最後に取得したインデックスのベクトルとの類似度を計算します。

target_distance = distances[target_index]
target_similarity = 1 - target_distance
# [0.49381527 0.20896476 0.19673653]

最後にまとめて関数化します。

def most_similar(idx, X, n=10):
    distances = distance.cdist([X[idx]], X, "cosine")[0]
    target_index = distances.argsort()[1:n+1]
    target_distance = distances[target_index]
    target_similarity = 1 - target_distance
    print(target_similarity)

most_similar(idx=0, X=x, n=3)
# [0.49381527 0.20896476 0.19673653]

このままでは対応するインデックスなどは取れませんが、そこは簡単に修正できれば対応できると思います。