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
ここでは、利用頻度が高いと思わるものについて限定してまとめます。
基本
基本的な使い方は以下の通りです。
from tensorboardX import SummaryWriter writer = SummaryWriter() # build network # training # data to tensorboard writer.add_XXX writer.close()
作った後は以下をシェルで起動します。
tensorboard --logdir runs
立ち上がった後はブラウザから確認可能です。
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()
損失値の描画に使うとこんな感じになります。
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()
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()
さて、訓練データとテストデータの損失値を合わせて描画します。
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()
ちなみにタグは「/」でグループ化できるみたいです。
テストの精度を含めてグループ化してみます。
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()
今回、紹介したのは 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)の文脈情報を与えて、中心の単語を予測します。
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」です。※予測するtoken以降はマスクしておきます。
もう1つはfeed-forward networkです。
ここまでの入力を整理します。
まず、入力は文の各tokenを分散表現で表した行列です。それにposition embeddingsにより位置情報を足します。更にこの論文では、tokenの文字情報をCNNでエンコードした情報も加えています。
厳密性には欠けますが、入力情報は以下のような行列になるはずです。
これを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の結果です。
最後に
最近は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)について適用した論文をまとめます。
どんな論文?
Biomedicalから重要なエンティティ(薬品名、病名、組織名など)を抽出するタスクはかなり昔から研究されており、今でも重要なタスクです。
このドメインで抽出したいエンティティはIREXが定義している一般的なエンティティ(人名、都市名など)とは大きく異なります。下記が医療文書に出現するテキスト例で、赤いマーカーが「disease entities」、黄色いマーカーが「anatomical entities」です。
このようにBiomedicalのエンティティとなる単語はドメイン固有な場合は多く、一般的なコーパス(Wikipediaなど)ではほとんど出現しません。
そこで一般的なコーパスから訓練済モデルを構築するのではく、PubMedデータセットを用いて学習を行い精度向上を達成しました。
また、単語の分散表現をCharacter-Level CNNでも学習している点も面白いと思いました。
手法
NERモデル構造は下図の通りです。
これらのレイヤーのうち、
- character-CNN
- word embedding
- BiLSTM(LSTM_F + LSTM_B)
を言語モデルで事前に学習します。
言語モデルの構造は下図の通りです。
また、NERモデルのうち、
- Decoder
- CRF
はNER固有なので、ランダムな初期値を用います。
順番に各レイヤーを見ていきます。
character-level CNN
単語を文字単位で畳み込んでその特徴量を抽出します。
単語レベルで捉えられない文字の特徴量で単語を表現できることが期待できます。
word embedding
単語レベルの特徴量を抽出します。
言語モデルでは一般的なレイヤーと言えます。
Bidirectional LSTM
character-level CNNとword embeddingの特徴量を結合したものをこの層の入力とします。
(論文)BERT
今日も少し前に流行ったモデルの復習。
今日はBERTに関してまとめる。
これもまた既に素晴らしいまとめがあるのでそちらを参考にしながら復習した。
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
特徴的なのは、[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により学習することで、事前学習を行っていた。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は無視する。
2.Next Sentence Prediction
BERTの入力は、複文(文のペア)の入力を許していた。
この目的としては、複文のタスク(QAタスクなど)にも利用可能なモデルを構築すること。
ただし、Masked LMだけでは、そのようなモデルは期待できない。
そこで、「Next Sentence Prediction」という学習を行う。
このタスクは非常に簡単である。
問題は「2つの文(A,B)を与えて、BはAの後に続きそうな文かどうか」を解くこと。
Task specific-Models
ここまでがpre-trainedモデルとしてのBERTのモデルの説明。
では、次に気になるのが「どうやって目的のタスクに適用するか」である。
classification tasks
この場合は非常にシンプルで文の先頭に付与した[CLS]
のEncode結果を入力として分類器を構築する。分類器との接続後、接続した層のパラメータとBERTのパラメータを最適化する。
Named Entity Recognition
NERの場合でも、各エンティティを分類する層をBERTの出力に追加するだけ。
最後に
全体を通して、「WordPiece」というキーワードが多く出現した。
入力は単語単位ではなく、このWordPiece単位でtoken化されているよう。
この辺りの概念がよく整理できていないので、次はこれをまとめたい。
(論文)Transformer
久しぶりにブログを更新する。
今日は「Attention Is All You Need」に関する復習。
もはや2年前の論文で、日本語でも丁寧な解説記事がたくさんある。
とっても今更感があるが、自分自身の理解の定着のためにまとめようと思う。
とりあえず、従来のRNN型のSeq2Seqモデルと何が違うのか、その辺を押さえたい。
後、理解を深めるために適宜コードも入れればと思う。
※コードに関しては以下サイトより引用させて頂いた。
towardsdatascience.com
nlp.seas.harvard.edu
Transformerモデル
論文中の図を引用する。
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は以下の式に基づいて行う。
pos
はセンテンス中の出現位置 ,i
は次元数(単語の分散表現の次元数)。
例えば、文の先頭位置に出現する単語のPositional Encodingを考える。
論文の単語の分散表現の次元数は512なので、以下のように先頭位置を表すベクトルは得られる。
このようして得られた行列をEmbedding後の文の行列に加算することで、各単語と位置(文脈)情報を考慮した情報が得られる。
Multi-Head Attention
Multi-Head Attention層は以下のようなイメージ。
入力を複数のHeadで分割する。
入力は
各パラメータは,,
実験では,なので,としている。
入力を各重みでの線形変換したもの
をScaled dot-product attentionの入力としている。
Scaled dot-product attention
この論文でのattentionモデルであり、タイトルにもあるようにここがモデルの肝の部分。
入力の,,は先ほど計算したもの。
attentionの計算式は以下の通り。
まず、の部分。
だったので、となる。
これにでスケーリングを行った後、softmaxを適用する。
これにを掛け合わせるので、最終的には、
となる。
これを各head毎に行ったものをConcatして重みで線形変換を行う。
となり、単語数×単語の分散表現の次元数に戻される。
結局のところ、この式変形が何を行っていたかをもう少し深堀してみる。
Query, Key, Value
まず、の部分。
スケーリングを無視して考えると、オレンジのスカラー値はの赤のベクトルとの青のベクトルの内積からなることが分かる。
以下、同様となる。
要するに、各queryと各keyの類似度を計算していることに相当する。
この行列の0行目のベクトルはquery0と各keyの類似度を表現したベクトルを意味する。
これをsoftmaxで正規化しており、各行の総和が1となるような重みベクトル(attention_weight)を作り出す。
最後にこの行列との内積を取る。
各queryのattention_weightとvalueの内積を取ったものをattentionの結果として返す。
なので、最終的な単語ベクトル(オレンジ)は、valueの各ベクトルにattention_weightを掛け合わせた値となることが分かる。
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はそれぞれ以下の通り。
※Source文からkey, valueが、Target文からQueryが作られる。
attention_weight()は以下のように計算される。
attention_weightの計算を少しだけやってみる。
例えば、attention_weight(0,0)の値は次のように計算できる。
以下、同様である。
先頭のattention_weightは、Target文の「私」とSource文の各単語の内積を計算した結果となる。
このattention_weightとvalueの行列計算を行う。
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が理解できれば、この層は難しくない。
residual connection
ResNetで用いられている構造。
前の層の入力を足し合わせる。
これは「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
のモデルを構築するのは面倒ですし、無駄かなと思います。
ある単語と別の単語の類似度を測定するだけの場合、そのベクトル同士で測定すれば良いですが、gensim
のmost_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]
このままでは対応するインデックスなどは取れませんが、そこは簡単に修正できれば対応できると思います。