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

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

(論文)BERTによる日本語構文解析

今日は以下の論文についてまとめます。

BERTによる日本語構文解析の精度向上(柴田ら 2019)

昨年の「言語処理学会」で発表されたものです。

日本語で分かりやすいのですが、整理もかねてまとめます。

BERT自体の解説はググれば分かりやすい説明があります。
また、以前にまとめましたので、詳細は割愛します。
kento1109.hatenablog.com

なにがスゴい?

BERTを使って既存の構文解析器の精度を大幅に上回った」という点です。
ここでは、既存の構文解析器として「KNP, Cabocha, J.depP」と比較しています。

従来Shift-Reduce 法やチャンキング段階適用により、係り受け関係を特定する手法が知られています。
SVMを用いた 統計的日本語係り受け解析」の手法では「係り元(A)」と「係り先(B)」の素性を利用して、
(A)が(B)に係るか」という二値分類としてSVMを使って問題を解いています。

これらの場合「文そのもの」を入力とするのではなく、文から候補の文節を取り出して、その分類問題としています。
この研究において、BERTではそのような中間状態を必要とせず、End-to-Endで構文解析を行っています。
「End-to-Endがスゴい」という訳ではないのですが、どのようなタスクで解いているのか気になりました。

どうやって実現した?

研究では「構文解析を head selection問題と捉える」とあります。
これは、各入力トークンに対して、主辞のトークンを推測するようなタスクです。
図を見るのが早いです。

f:id:kento1109:20200312105121p:plain
論文より引用

例えば、「スタッフ」というトークンであれば、「一同」が主辞(Head)のトークンになります。
こう考えると、トークン毎の何らかの出力を予測する「token classification」の1つと理解できます。

入力のトークン列(t_0, t_1, ..., t_N)で表される時、t_iの主辞がt_jである確率は以下の式で計算されます。


\begin{eqnarray}
P_{head}(t_j|t_i,S)=\frac{\exp (s(t_j,t_i))}{\sum_k \exp (s(t_k,t_i))}
\end{eqnarray}

t_iを全体のトークンについてsoftmax関数を取ったものです。
重要なのはスコアs(t_j,t_i)ですが、以下のように計算しています。


\begin{eqnarray}
s(t_j,t_i)=v_h^T {\rm tanh}(U_ht_j+W_ht_i)
\end{eqnarray}

t_i,t_jはTransfomerの最終層のembeddingです。
一般的には768次元のベクトルになるでしょうか。
(この次元数をdと書いておきます。)
t_iU_hの重みを掛けています。

パラメータについては論文では詳しい記載はありませんが、
head selectionに関する参考文献を見ると詳しく書いてあります。

その論文通りにスコア設計されているとすると、
 v_h \in \mathbb{R}^d, U_h=W_h \in \mathbb{R}^{d\times d}
のパラメータになります。

これを計算すると、スカラー値のスコアが計算できます。

実験結果は以下の通りです。

f:id:kento1109:20200312113343p:plain

※既存の構文解析器は「基本句単位の係り受けを自立語 head の単語単位に変換」して評価したそうです。

最後に

構文解析を「token classification」として解くことに違和感があったのですが、
それは「クラス数をどのように決めるのか」が自明ではないのではという疑問からでした。
しかし、BERTでは事前に入力の系列長を指定するので、長さが可変になることはありません。
なので、系列長を128とすれば、128クラスの分類問題となります。
そう考えると「token classification」として解くことに問題はないように思いました。

BERTなどを利用したEnd-to-Endモデルでは「関係抽出」にも応用されてたりもするので、
そこら辺もキャッチアップしていきたいです。

Negative Samplingの復習

はじめに

今更ですが、word2vecの高速化の計算手法である「Negative Sampling」について復習しました。
世は完全に「Transfomer」の趨勢ですが、勉強のために復習しました。

前に「階層的ソフトマックス」を説明している論文を読んでいて、これってどう実装すれば良いのかと思い、せっかくなので「Negative Sampling」も実装してみようと思ったのが、モチベーションです。

今回はnumpyを使用して、簡単な実装だけを行いました。
※簡単のため、正確ではない部分もあります。

後、word2vec、Negative Samplingの解説自体は既にたくさんの良記事があるので、詳しくは述べません。

全単語の出現確率の計算

ある単語が与えられた時、ターゲットとなる単語が出現する確率を求めます。

\begin{aligned}
p(y_{v}|y_{w})=\frac{\exp(y_{v},y_{w})}{\sum^V_{v=1}\exp(y_{v'},y_{w})}
\end{aligned}

y_{v}などは隠れ層の出力であり、y_{v}\in\mathbb{R}^{\rm h}{\rm h}は隠れ層の次元数)のベクトルとなります。
この辺についての詳細な背景は以下を読むことをお勧めします。

tkengo.github.io


では、簡単に行列を準備してみます。

import numpy as np

vocab_size = 100000
hidden_dim = 300
H = np.random.rand(vocab_size, hidden_dim)
H.shape  # (100000, 300)

今回、語彙数は100000、隠れ層の次元数は300としました。
行列Hは各単語の隠れ層の出力ベクトルからなる行列となります。

次に、ある単語が与えられた時、ターゲットとなる単語が出現する確率をソフトマックス関数を使って求めます。
先ほどの式を実装すると以下のようになります。

def softmax(x):
    e_x = np.exp(x - np.max(x))
    return e_x / e_x.sum()

source_idx = np.random.randint(vocab_size)
target_idx = np.random.randint(vocab_size)
p_target = softmax([np.dot(H[i], H[source_idx]) for i in range(vocab_size)])[target_idx]

最後の確率を求める計算では、語彙数分の内積計算を行っていることが分かります。
この繰り返し計算が非常にコストがかかるため、何とか高速化したいです。

Negative Sampling

全ての単語の内積計算をするのではなく、いくつかの負例を選択して損失計算を行います。
これは、NCE(Noise Contrastive Estimation)と言われる手法を発展させたものです。

Negative Samplingは、コーパス中の単語の頻度分布に基づいて行います。
では、コーパスの頻度分布を作ります。

n_corpus = 1000000
word_freq = np.random.randint(0, vocab_size, n_corpus)
# array([7847, 3041, 4726, ..., 9368, 6440, 6872])

word_dist = np.bincount(word_freq) / n_corpus
# array([1.3e-05, 1.0e-05, 5.0e-06, ..., 4.0e-06, 1.4e-05, 7.0e-06])

このコーパスは、先ほどの単語(各単語はインデックスに変換済)から構成されます。
※簡単のため、各単語の出現頻度は完全にランダムとしています。

各出現回数を総数で割ることで、単語の頻度分布が求められます。

この頻度分布に「0.75乗」をしたものを計算に利用します。
※「0.75乗」により、頻度分布の平滑化を行います。

word_dist = np.power(word_dist, 0.75) / np.sum(np.power(word_dist, 0.75))

この平滑化した頻度分布から、指定したサンプル数だけ負例を抽出します。

negative_samples = np.random.choice(range(vocab_size), size=5, p=word_dist, replace=False)
negative_samples  # array([8608, 4896, 5054, 3571,   37])

これにより、頻度分布を考慮したNegative Samplingが抽出できます。
これらを負例として損失関数に含めることで、負例の出現確率を低くするように学習が行われます。

最後に

一番大事なNegative Samplingですが、厳密にはNegative Samplingになっておりません。
実際は正例を除いてサンプリングする必要があるのですが、今回は省略しました。

とても簡単な実装でしたが、良い復習になりました。

Pytorch:CNNで文字表現の抽出

NLPにCNNを利用したモデルはすっかり当たり前になりました。

少し前に「CNNでテキスト分類」という記事を書きましたが、
その時はPytorchでCNNを扱うための基本的なコードだけに留まっていました。
kento1109.hatenablog.com

今回はNERなどで用いられる文字情報をCNNで表現する際のコーディングについて書こうと思います。

以下は単語を文字単位に分割してその分散表現を獲得する場合のイメージです。

f:id:kento1109:20190930103525p:plain
End-to-end Sequence Labeling via Bi-directional LSTM-CNNs-CRFより引用

今回はこれをPytorchで書いてみたいと思います。

Char Embedding

簡単のため、文字列は既にpadding済であることを前提とします。

vocab_size = 100
char_emb_size = 30

char_emb = nn.Embedding(num_embeddings=vocab_size, embedding_dim=char_emb_size)

として、Embeddingクラスを定義しておきます。

ここに、入力文を渡します。

max_sent = 20
max_char = 10

input = torch.randint(0, vocab_size, (max_sent, max_char))
char_embeded = char_emb(input)
char_embeded.size()
# torch.Size([20, 10, 30])

CNN

各単語は「文字列×分散表現の次元数」の行列で表現されています。
これにCNNに渡します。
今回は、window size=3で畳み込みます。

window_size = 3
cnn = nn.Conv2d(in_channels=1, 
                out_channels=char_emb_size, 
                kernel_size=(window_size,char_emb_size), 
                stride=window_size-1,
                padding=(window_size-1,0))

stride=window_size-1は、上の図の畳み込み操作のイメージ通りで、2文字列単位でフィルターをスライドさせます。
また、文字の前後にwindow_size-1の行列をpaddingしておきます。

char_embeded = char_embeded.unsqueeze(1)
feature_map = cnn(char_embeded)
feature_map.size()
torch.Size([20, 30, 6, 1])

最初にチャネル数の次元を追加します。

出力は(N, C_{out}, H_{out}, W_{out}) の次元となります。

畳み込み操作をイメージで表すと以下のようになります。
f:id:kento1109:20190930113643p:plain

max_pooling

さて、畳み込み操作により得た特徴マップをプーリング層に渡します。

m = nn.MaxPool1d(kernel_size=feature_map.size(2))

kernel_sizeは最大値を取る範囲を指定します。
今回は各チャネルの特徴マップ全てからmax_poolingを行うので、上記のように書きます。

最後に特徴マップをプーリング層に渡します。

char_feat = m(feature_map.squeeze()).squeeze()
char_feat.size()
# torch.Size([20, 30])

これで、「単語×次元数」の分散表現を得ることが出来ました。
NER等の場合、これを後続のLSTMなどに渡してあげるとOKです。

BERTなどが主流のなか、あえて少し前のベーシックなモデルについて復習しました。

pytorch-transformersを触ってみる⑤

はじめに

前回はBERTのfine tuningということで、NER(固有表現認識)を試してみました。
kento1109.hatenablog.com

今回はfine tuningではなく、BERTの事前学習について見ていきたいと思います。

pre-training from scratch

ただ、pytorch-transformersでの事前学習を調べると、早々に壁にぶつかりました。
ほとんどの内容がfine tuning向けなので、pre-trainingの内容があまり見つかりませんでした。
Issuesを見てみると、既に同様の質問をしている人がいました。
github.com

ここでpytorch-transformersの位置づけについて、開発者は以下のように述べています。

pytorch-pretraned-BERT was mostly designed to provide easy and fast access to pretrained models.
If you want to train a BERT model from scratch you will need a more robust code base for training and data-processing than the simple examples that are provided in this repo.

要は、事前学習済のモデルをfine tuningして利用することを目的としたライブラリなので、モデルのpre-training用として汎用化されたものではない、と言うことです。

ただ、全くpre-training用として使えないかというとそんなことはありません。
pytorch-transformersにはlm_finetuningなるスクリプトが用意されており、これは大規模なコーパスでpre-trainingしたモデルについて、ドメインに特化した小規模コーパスでpre-trainingしたモデルをfine tuningするためのスクリプトです。
pytorch-transformers/examples/lm_finetuning at master · huggingface/pytorch-transformers · GitHub

これを使うことでpre-trainingも可能になるのではと思います。
(実際に先ほどのIssuesでもそのようなことを示唆しているコメントもありました。)

ただし、pytorch-transformersでpre-trainingする必要はなく、Facebook researchやNVIDIAがBERTのpre-trainingに関するコードを公開しているので、そっちを利用するのもアリです。
GitHub - facebookresearch/XLM: PyTorch original implementation of Cross-lingual Language Model Pretraining.
DeepLearningExamples/TensorFlow/LanguageModeling/BERT at master · NVIDIA/DeepLearningExamples · GitHub

今回はpytorch-transformersのlm_finetuningを触ってみて、次回以降にその他のpre-trainingに関するコードも見れたらと思います。

lm_finetuning

データセットを作る場合、pregenerate_training_data.pyを動かすのがよさそうです。
ということで、pregenerate_training_data.pyを少し見てみます。

pregenerate_training_data.py

データセットのフォーマットとしては、ドキュメント単位にブランク行を入れるようです。
例えば、以下のようなドキュメントがあったとします。

リンゴは、バラ科リンゴ属の落葉高木樹。またはその果実のこと。植物学上はセイヨウリンゴと呼ぶ。春、白または薄紅の花が咲く。

バナナはバショウ科バショウ属のうち、果実を食用とする品種群の総称。また、その果実のこと。いくつかの原種から育種された多年性植物。種によっては熟すまでは毒を持つものもある。

その場合、データセットのテキストファイルは以下のようにします。

リンゴは、バラ科リンゴ属の落葉高木樹。
植物学上はセイヨウリンゴと呼ぶ。
春、白または薄紅の花が咲く。

バナナはバショウ科バショウ属のうち、果実を食用とする品種群の総称。
いくつかの原種から育種された多年性植物。
種によっては熟すまでは毒を持つものもある。

このスクリプトでは、1文書をdocリスト(各文はtokenize済)で保持し、それをまとめてdocsクラスで保持します。

Next Sentence Prediction

ドキュメントをdocsクラスに保持した後、create_training_file()でdocsを処理していきます。
実際の処理は、create_instances_from_document()でdoc毎に処理(インスタンスの生成)を行います。
create_instances_from_document()は、BERTの訓練データを作るうえで重要なので丁寧にみていきます。

BERTの訓練データでは、Next Sentence Predictionのために、文のペア(sentences A and B)を1インスタンスとします。
sentences Aペアの作成がNext Sentence Predictionの精度を高めるため、少し工夫されています。
ここでは、sentenceを選択するのではなく、sentenceの境界を無作為に選択します。
そして、その境界までのsentencesをtokens_aとして保持します。

例えば、docが4 sentencesから構成される場合、境界は1,2,3のいずれかになります。
1,2,3の境界から3が選択された場合、先頭から3 sentencesまでがtokens_aとして保持します。

token_bは50%の割合で実際の後続するsentences、50%の割合で関係の無いsentenceと入れ替えます。
(この割合は原著論文と同じです。)

Masked language modeling (LM)

次に、Masked LMのタスクのため、tokensの一部をMASKします。
これは、create_masked_lm_predictions()によって行われます。

簡単のため、以下の文でのMASK例を考えます。

my dog is hairy

最初にtokensのうち、MASKするtokenの数を決めます。(今回は「1」)
次に、tokensのindexをシャッフルして順番に取り出します。
今回は系列番号3(hairy)が先頭に出現したとします。
その系列を割合に基づいて以下のtokenに置き換えます。

  • 80% を [MASK] に置換(e.g., my dog is [MASK])
  • 10% を置換しない(e.g., my dog is hairy)
  • 10% を辞書内の任意のtokenに置換(e.g., my dog is apple

(この割合は原著論文と同じです。)

最後に置換されたtokensと、置換されたtokenのindex(4)とそのtoken(hairy)を返します。

create_instances_from_document()にMASK後のtokenを返した後、これらの情報をinstance辞書に格納します。
create_training_file()にdocのinstance辞書を返した後、json形式でファイルとして保存します。

pregenerate_training_data.pyの役割は以上です。

finetune_on_pregenerated.py

pre-trained model

モデルをインスタンス化します。

model = BertForPreTraining.from_pretrained(args.bert_model)

今回使用するモデルは、BertForPreTrainingクラスです。
名前がややこしいですが、BertPreTrainedModelを継承したクラスです。
このモデルにてMasked LM及びNext Sentence Predictionの学習を行います。

data loading

作成したjsonファイルを利用してpre-training(fine tuning)を行います。
これは、finetune_on_pregenerated.pyにより実行されます。

まず、先ほどの2つのjsonファイルのあるパスをPregeneratedDatasetクラスに渡してデータセットを作成します。
基本的な処理としては、epoch_{0}.json(maskされたtokensデータを含んだファイル)を1行(1インスタンス)毎に読み込み、InputFeaturesクラスを生成します。
InputFeaturesクラスは以下のクラス変数から成ります。

  • input_ids:tokensの系列(padding済)
  • input_mask:input_idsのpaddingを表す系列
  • segment_ids:input_idsのsentences A, Bを表す系列
  • lm_label_ids:input_idsのmaskされたtokenの元のtoken(正解ラベル)を並べた系列
  • is_next:sentences Bが元のsentences Aに続く文か、ランダムに挿入された文か(True / False)

lm_label_idsですが、convert_example_to_featuresで少し工夫されていたのを見逃していました。
lm_label_idsはinput_idsの系列を全て「-1」で初期化して、maskされた位置のみ元のtokenのラベルで置き換えます。

例えば、「my dog is hairy」の例で考えると以下のようになります。

input_ids = ['my', 'dog', 'is', '[MASK]']
lm_label_ids = [-1, -1, -1, 20] # {20: hairy}

※paddingやid変換などは考慮していないため、正確ではありません。

これを最後の行まで読み込んだデータをPregeneratedDatasetクラスで保持します。

PregeneratedDatasetをSamplerクラス→DataLoaderクラスと順番に渡していきます。

DataLoaderクラスまで渡したら、学習の準備が完了です。

後はモデルにInputFeaturesクラスをバッチ化したものを渡してforwardを行います。

outputs = model(input_ids, segment_ids, input_mask, lm_label_ids, is_next)

BertForPreTrainingのforwardを確認します。

outputs = self.bert(input_ids, position_ids=position_ids, token_type_ids=token_type_ids,
                    attention_mask=attention_mask, head_mask=head_mask)

BertForPreTrainingですが、ネットワークの中核となるのはBertModelです。
BertModelは、encoderの役割(隠れ層の値を出力)を果たします。
入力としては以下を受け取ります。

  • input_ids:InputFeaturesクラスのinput_ids
  • token_type_ids: InputFeaturesクラスのsegment_ids
  • attention_mask:InputFeaturesクラスのinput_mask
sequence_output, pooled_output = outputs[:2]
prediction_scores, seq_relationship_score = self.cls(sequence_output, pooled_output)  #  BertPreTrainingHeads

BertForPreTrainingでは、以下のBertModelの出力を利用します。
1. sequence_output:隠れ層の値
2. pooled_output :隠れ層のLinear→Tanhした値

これをBertPreTrainingHeadsクラスの入力として渡します。
更に、BertPreTrainingHeadsでは以下の2つの処理を行います。
1. sequence_outputをBertLMPredictionHeadに渡す
2. pooled_outputをLinear(n=2)

BertLMPredictionHeadを確認します。
これは、sequence_output(隠れ層の値)を受け取り、BertPredictionHeadTransformにて変換後、Linear(n=vocab)を行います。

さて、BertForPreTrainingのforwardに戻り、損失計算の部分を確認します。
正解となるラベル情報は以下を利用します。

  • masked_lm_labels:InputFeaturesクラスのlm_label_ids
  • next_sentence_label:InputFeaturesクラスのis_next
outputs = (prediction_scores, seq_relationship_score,) + outputs[2:]

loss_fct = CrossEntropyLoss(ignore_index=-1)
masked_lm_loss = loss_fct(prediction_scores.view(-1, self.config.vocab_size), masked_lm_labels.view(-1))
next_sentence_loss = loss_fct(seq_relationship_score.view(-1, 2), next_sentence_label.view(-1))
total_loss = masked_lm_loss + next_sentence_loss
outputs = (total_loss,) + outputs

prediction_scoresのshapeは「batch × tokens × vocab」です。
seq_relationship_scoreのshapeは「batch × 2」です。

masked_lm_labelsは、maskされたラベル以外が-1の系列なので、maskされた位置のみの損失が計算されます。
next_sentence_labelは0,1のラベル(1がランダム)です。

masked_lm_loss + next_sentence_lossを足し合わせた損失をtotal_loss として返します。

その後は、finetune_on_pregenerated.pyのmain()でloss.backward()して重みを調整していきます。

fine tuning For pre-training

さて、BERTのfine tuningについて確認しました。
これが出来ると、1からpre-trainingする理由があまり無いような気もします。
しかし、以下のような場合はやはり1からpre-trainingが必要と思います。

  • 大規模なpre-training modelがそもそも存在しない
  • pre-training modelは存在するが、自作tokenizerを利用したい

特にTokenClassificationの場合、tokenizerが望ましい分割をしてくれないとタスクが困難となります。
では、1からpre-trainingを行いたい場合はどうすれば良いでしょうか。

単純にpre-trained modelをロードしなければ良いのではと思いました。
最初に挙げたIssuesでも似たようなにコメントしている人もいました。

If you construct and initialize a new model instead of loading from pretrained, you can use the simple_lm_finetuning script to train on new data.

finetune_on_pregenerated.pyでは、237行目で以下のようにしてpre-trained modelを読み込んでいます。

model = BertForPreTraining.from_pretrained(args.bert_model)

これは、pre-trained modelが無い場合は利用できません。

その場合は、以下のようにしてBertForPreTrainingをインスタンス化すればOKです。

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

BertForPreTrainingのコンストラクタを見ると以下のように記述されています。

def __init__(self, config):
    super(BertForPreTraining, self).__init__(config)

    self.bert = BertModel(config)
    self.cls = BertPreTrainingHeads(config)

    self.apply(self.init_weights)
    self.tie_weights()

2行目のBertModel(config)により、configの値に基づき、BERTのモデルが構築されます。
4行目のself.apply(self.init_weights)が実行されると、BERTの重みが初期化されます。

当然、tokenizerを自作したい場合は、別途tokenizerの作成が必要となります。
それ以外の点では、大きな変更は要らないはずです。

おわりに

pytorch-transformersのライブラリを活用して、pre-trainedモデルを学習する方法について確認しました。
今後は、Facebook researchやNVIDIAのBERTの学習方法についても見ていければと思います。

pytorch-transformersを触ってみる④

はじめに

前回はfine tuningということで、GLUEタスクのSST-2に取り組みました。
kento1109.hatenablog.com

また、GLUEタスクのfine tuningを実行するためのスクリプトrun_glue.py )のコードを眺めました。

今回は、CoNLL(NERの共通タスク)のためのfine tuningにチャレンジします。
BERT-NERですが、以下の内容が参考となりました。
https://www.depends-on-the-definition.com/named-entity-recognition-with-bert/
BERT-NER/run_ner.py at experiment · kamalkraj/BERT-NER · GitHub
GitHub - kyzhouhzau/BERT-NER: Use Google's BERT for named entity recognition (CoNLL-2003 as the dataset).


ただし、pytorch-pretrained-bertを利用している点に留意する必要があります。

準備

データの入手及びロードを行います。

データの入手

今回は「CoNLL 2003 shared task (NER) data」を利用します。

データは以下から入手可能です。
NER/corpus/CoNLL-2003 at master · synalp/NER · GitHub

このデータセットは、以下のようなフォーマットとなっています。

-DOCSTART- -X- O O

CRICKET NNP I-NP O
- : O O
LEICESTERSHIRE NNP I-NP I-ORG
TAKE NNP I-NP O
OVER IN I-PP O
AT NNP I-NP O
TOP NNP I-NP O
AFTER NNP I-NP O
INNINGS NNP I-NP O
VICTORY NN I-NP O
. . O O

LONDON NNP I-NP I-LOC
1996-08-30 CD I-NP O

-DOCSTART-で文章が区切られており、各文は空行で区切られています。
NERタスクの場合、4列目をラベルとして抽出します。

データのロード

これらのデータをロードしておきます。
自前でloaderを作っても良いですが、ここではtorchtextのloaderを利用します。

torchtextを利用したCoNLLのデータセットのloaderについては以前まとめました。
Pytorch:テキストの前処理(torchtext)③ - 機械学習・自然言語処理の勉強メモ

ただし、CoNLL2003は-DOCSTART-が含まれていますが、これはNERでは不要です。
デフォルトのSequenceTaggingDatasetではこれを考慮できていないので少し工夫が必要です。

具体的には、SequenceTaggingDatasetのコードをコピーして少し手を加えた以下のloaderを用意します。

from torchtext import data, datasets
class SequenceTaggingDataset(data.Dataset):
    @staticmethod
    def sort_key(example):
        for attr in dir(example):
            if not callable(getattr(example, attr)) and \
                    not attr.startswith("__"):
                return len(getattr(example, attr))
        return 0

    def __init__(self, path, fields, encoding="utf-8", separator="\t", **kwargs):
        examples = []
        columns = []

        with open(path, encoding=encoding) as input_file:
            for line in input_file:
                line = line.strip()
                # add start
                if line.startswith('-DOCSTART-'):  
                    continue
                # add end
                if line == "":
                    if columns:
                        examples.append(data.Example.fromlist(columns, fields))
                    columns = []
                else:
                    for i, column in enumerate(line.split(separator)):
                        if len(columns) < i + 1:
                            columns.append([])
                        columns[i].append(column)

            if columns:
                examples.append(data.Example.fromlist(columns, fields))
        super(SequenceTaggingDataset, self).__init__(examples, fields,
                                                     **kwargs)

これで以下のようにして、CoNLLのデータセットが簡単に読み込めるようになります。

WORD = data.Field()
POS1 = data.Field()
POS2 = data.Field()
LABEL = data.Field()
train_ds, valid_ds, test_ds = SequenceTaggingDataset.splits(fields=[('word', WORD), 
                                                                    ('pos1', POS1), 
                                                                    ('pos2', POS2), 
                                                                    ('label', LABEL)],
                                                            path='CoNLL-2003' ,
                                                            separator=" ",
                                                            train="eng.train", 
                                                            validation="eng.testa", 
                                                            test="eng.testb"
                                                           )

また、後々使うのでラベルリストを取得しておくといいです。

LABEL.build_vocab(train_ds, valid_ds, test_ds)
label_list = list(LABEL.vocab.freqs)
# ['I-ORG', 'O', 'I-MISC', 'I-PER', 'I-LOC', 'B-LOC', 'B-MISC', 'B-ORG']

fine tuning

NERデータセットを用いて訓練するまでに必要な処理をまとめます。

pretrained modelの読み込み

以下のようにて各クラスをインスタンス化します。

from pytorch_transformers import BertConfig, BertTokenizer, BertForTokenClassification
model_name = 'bert-base-uncased'
config = BertConfig.from_pretrained(model_name, num_labels=len(label_list))
tokenizer = BertTokenizer.from_pretrained(model_name, do_lower_case=True)
model = BertForTokenClassification.from_pretrained(model_name, config=config)

※今回はNERなので、モデルはTokenClassificationとなります。

BERTの入力に変換

読み込んだデータセットは、BERTの入力形式に変換してあげる必要があります。
前回のGLUEタスクで利用したconverterを流用する形で実装していきたいと思います。
入力に関しては元のスクリプトもInputExampleクラスで定義している大した修正は不要です。

ただし、NERはTokenClassificationなので、出力ラベルについては、入力系列と同じ長さの系列を作るように修正が必要です。しかし、BERTの入力系列は少し特殊なので、その点を考慮しないといけません。
論文の図では、NERの入出力系列は以下のように表現されています。

f:id:kento1109:20190824072049p:plain
論文より引用

また、入力系列がWordPiece tokenizerにより分割されることについては、以下のように説明しています。

We feed each CoNLL-tokenized input word into our WordPiece tokenizer and use the hidden state corresponding to the first sub-token as input to the classifier.

Jim Hen ##son was a puppet ##eer
I-PER I-PER X O O O X

Where no prediction is made for X. Since the WordPiece tokenization boundaries are a known part of the input, this is done for both training and test.

要は、WordPiece tokenizerの先頭以外は予測は不要ということです。
後、先頭の[CLS]トークンについては、それに対応するCラベルを付与しています。
また、入力は1文単位であるため末尾の[SEP]トークンは不要となります。

この点を考慮した修正ができればOKです。
こちらのコードでは、それらを入力の作り方をしているので、これを持ってきてもOKです。

BERTの訓練・評価

入力データをBERTのTokenClassification用に整形できました。
では、train()を呼び出して訓練を行います。

global_step, tr_loss = train(args, dataset, model, tokenizer)

しばらく待てば学習が完了します。

完了後、結果を評価してみます。

precision : 0.9560
recall : 0.9596
f1-score : 0.9577

良い感じで学習出来ているのではないでしょうか。
(今回は正確な評価が目的ではないので、[CLS]Xのラベルが含まれた状態で評価しています
。)

正解例と予測結果を見比べてみます。

['[CLS]', 'soccer', '-', 'japan', 'get', 'lucky', 'win', ',', 'china', 'in', 'surprise', 'defeat', '.']
TRUE : ['[CLS]', 'O', 'O', 'I-LOC', 'O', 'O', 'O', 'O', 'I-PER', 'O', 'O', 'O', 'O']
PRED : ['[CLS]', 'O', 'O', 'I-LOC', 'O', 'O', 'O', 'O', 'I-LOC', 'O', 'O', 'O', 'O']

この場合、chinaがズレていますが、アノテーションが間違っているのではないかという気もします。

おわりに

今回はBERTのfine tuningとしてCoNLLタスクに取り組んでみました。
結果を見る限りでは、fine tuningだけで十分の結果が出せたと思います。
次回は、これを日本語に適用してみたいと思います。

pytorch-transformersを触ってみる③

はじめに

前回は日本語でのpytorch-transformersの扱い方についてまとめました。
kento1109.hatenablog.com

これまでpytorch-transformersの基本的なところを英語・日本語で動かしてみました。

今回はもう一歩進んでfine tuningについてまとめてみます。

ドキュメントについては、以下を参考とします。
Examples — pytorch-transformers 1.0.0 documentation

準備

今回はGLUEタスクのうち、SST-2(評判分析)のデータセットで動かしてみます。
データセットについては、こちらのスクリプトを実行して入手しておきます。

実行

ものすごく簡単です。run_bert_classifier.pyに適切な引数を与えて実行するだけです。
と思ったのですが、github上にこのようなファイル名のスクリプトは存在しません。

このissueについては、既に以下で解決済です。
github.com

run_bert_classifier.pyは前のpytorch-pretrained-bertの時のスクリプトのようで、
今は、run_glue.pyという名称に代わっているみたいです。
githubのREADMEにもGLUEのfine tuningはrun_glue.pyと書いていますね。。

export GLUE_DIR=/path/to/glue
export TASK_NAME=MRPC

python ./examples/run_glue.py \
    --model_type bert \
    --model_name_or_path bert-base-uncased \
    --task_name $TASK_NAME \
    --do_train \
    --do_eval \
    --do_lower_case \
    --data_dir $GLUE_DIR/$TASK_NAME \
    --max_seq_length 128 \
    --per_gpu_eval_batch_size=8   \
    --per_gpu_train_batch_size=8   \
    --learning_rate 2e-5 \
    --num_train_epochs 3.0 \
    --output_dir /tmp/$TASK_NAME/

GPU環境の場合、1時間もしないうちに学習が終わります。
最後に、以下のような評価結果が表示されればOKかと思います。

08/22/2019 10:59:29 - INFO - __main__ -   ***** Eval results  *****
08/22/2019 10:59:29 - INFO - __main__ -     acc = 0.9231651376146789

環境設定などでハマらなければとても簡単です。

run_glue.py

GLUEのタスクでは、用意されたスクリプトを実行するだけで結果が得られました。
ほとんど頭で何か考える必要はありませんでした。
しかし、GLUE以外のタスク(自身で用意したデータセットなど)に適用する場合、このスクリプトは使えません。
なので、run_glue.pyでどうやってfine tuningを行ったかをコードを少し見て勉強しようと思います。
具体的には、run_glue.py内でこれまでに見たpretrained modelをどのように扱っているのか学びます。
※distributed training(分散学習)に関する部分は今回は扱いません。

model Instantiate

スクリプト内では、辞書形式で引数に応じたクラスを定義しています。

MODEL_CLASSES = {
    'bert': (BertConfig, BertForSequenceClassification, BertTokenizer),
    'xlnet': (XLNetConfig, XLNetForSequenceClassification, XLNetTokenizer),
    'xlm': (XLMConfig, XLMForSequenceClassification, XLMTokenizer),
    'roberta': (RobertaConfig, RobertaForSequenceClassification, RobertaTokenizer),
}

引数model_typeにてbertと与えられたら、bertのクラスが呼ばれるようになっています。
valueに存在するクラスについては、以前に扱いました。

BertForSequenceClassificationの出力層の数は「2」なので、分類問題用のモデルとなっています。

これらのクラスを以下のようにして変数にセットします。

config_class, model_class, tokenizer_class = MODEL_CLASSES[args.model_type]

まず、config_classの設定です。

 config = config_class.from_pretrained(args.config_name if args.config_name else args.model_name_or_path, num_labels=num_labels, finetuning_task=args.task_name)

デフォルトのハイパーパラメータ以外に以下を設定します。

  • num_labels
  • finetuning_task

finetuning_taskはドキュメントによると、checkpointの名前で用いるみたいです。

次に、tokenizerを設定します。

tokenizer = tokenizer_class.from_pretrained(args.tokenizer_name if args.tokenizer_name else args.model_name_or_path, do_lower_case=args.do_lower_case)

対象はbert-base-uncasedなので、do_lower_case=Trueとします。

最後にclass methodのfrom_pretrained()により、モデルをインスタンス化します。

model = model_class.from_pretrained(args.model_name_or_path, from_tf=bool('.ckpt' in args.model_name_or_path), config=config)
dataset

次に、datasetについてみていきます。
BERTなどは入力のdatasetが特殊なので、工夫が必要となります。
ちなみに元々の入力データは以下のようになっています。

it 's a charming and often affecting journey . 	1
unflinchingly bleak and desperate 	0

これをどのように変換するか確認します。
訓練データのload & tokenize & id変換はload_and_cache_examples()で行います。

train_dataset = load_and_cache_examples(args, args.task_name, tokenizer, evaluate=False)

load_and_cache_examplesを読んでみます。

examples = processor.get_dev_examples(args.data_dir) if evaluate else processor.get_train_examples(args.data_dir)
features = convert_examples_to_features(examples, label_list, args.max_seq_length, tokenizer, output_mode,
                                        cls_token_at_end=bool(args.model_type in ['xlnet']),
                                        # xlnet has a cls token at the end
                                        cls_token=tokenizer.cls_token,
                                        cls_token_segment_id=2 if args.model_type in ['xlnet'] else 0,
                                        sep_token=tokenizer.sep_token,
                                        sep_token_extra=bool(args.model_type in ['roberta']),
                                        # roberta uses an extra separator b/w pairs of sentences, cf. github.com/pytorch/fairseq/commit/1684e166e3da03f5b600dbb7855cb98ddfcd0805
                                        pad_on_left=bool(args.model_type in ['xlnet']),  # pad on the left for xlnet
                                        pad_token=tokenizer.convert_tokens_to_ids([tokenizer.pad_token])[0],
                                        pad_token_segment_id=4 if args.model_type in ['xlnet'] else 0,
                                        )

1行目で入力データからexamplesを作成します。
2行目でexamplesを引数として、各入力の形式に変換します。
2つ目のlabel_listはラベルが取り得る値のリストです。

label_list = ["0", "1"]

各exampleの処理を確認します。(一部コードを省略しています。)

features = []
for (ex_index, example) in enumerate(examples):

    tokens_a = tokenizer.tokenize(example.text_a)

    # The convention in BERT is:
    # (a) For sequence pairs:
    #  tokens:   [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP]
    #  type_ids:   0   0  0    0    0     0       0   0   1  1  1  1   1   1
    # (b) For single sequences:
    #  tokens:   [CLS] the dog is hairy . [SEP]
    #  type_ids:   0   0   0   0  0     0   0
    
    tokens = tokens_a + [sep_token]  # sep_token = '[SEP]'
    segment_ids = [sequence_a_segment_id] * len(tokens)

    tokens = [cls_token] + tokens
    segment_ids = [cls_token_segment_id] + segment_ids

    input_ids = tokenizer.convert_tokens_to_ids(tokens)

    # The mask has 1 for real tokens and 0 for padding tokens.
    input_mask = [1 if mask_padding_with_zero else 0] * len(input_ids)
    input_ids = input_ids + ([pad_token] * padding_length)  # pad_token=0
    input_mask = input_mask + ([0 if mask_padding_with_zero else 1] * padding_length)
    segment_ids = segment_ids + ([pad_token_segment_id] * padding_length)

    label_id = label_map[example.label]

    features.append(
        InputFeatures(input_ids=input_ids,
                      input_mask=input_mask,
                      segment_ids=segment_ids,
                      label_id=label_id))

return features

class InputFeatures(object):
    """A single set of features of data."""

    def __init__(self, input_ids, input_mask, segment_ids, label_id):
        self.input_ids = input_ids
        self.input_mask = input_mask
        self.segment_ids = segment_ids
        self.label_id = label_id

やっていることは、大まかに言うと
1. tokenize
2. [SEP], [CLS]の挿入
3. id変換
4. padding
5. mask
です。(2.を除いて一般的なNLPタスクとほとんど同じです。)

Training

変換した訓練データと定義済のモデルでfine tuningを行います。

global_step, tr_loss = train(args, train_dataset, model, tokenizer)

train()内では、samplerとdataloaderを定義します。

train_sampler = RandomSampler(train_dataset) if args.local_rank == -1 else DistributedSampler(train_dataset)
train_dataloader = DataLoader(train_dataset, sampler=train_sampler, batch_size=args.train_batch_size)

samplerとdataloaderについては以下が分かりやすいです。
PytorchのDataloaderとSamplerの使い方 - Qiita

そっから、ごにょごにょした後、エポックを回します。
イテレーションの内容は、一般的な学習とほとんど同じです。(一部コードを省略しています。)

tr_loss, logging_loss = 0.0, 0.0
model.zero_grad()
train_iterator = trange(int(args.num_train_epochs), desc="Epoch", disable=args.local_rank not in [-1, 0])
set_seed(args)  # Added here for reproductibility (even between python 2 and 3)
for _ in train_iterator:
    epoch_iterator = tqdm(train_dataloader, desc="Iteration", disable=args.local_rank not in [-1, 0])
    for step, batch in enumerate(epoch_iterator):
        model.train()
        batch = tuple(t.to(args.device) for t in batch)
        inputs = {'input_ids': batch[0],
                  'attention_mask': batch[1],
                  'token_type_ids': batch[2] if args.model_type in ['bert', 'xlnet'] else None,
                  # XLM don't use segment_ids
                  'labels': batch[3]}
        outputs = model(**inputs)
        loss = outputs[0]  # model outputs are always tuple in pytorch-transformers (see doc)

        loss = loss.mean() # mean() to average on multi-gpu parallel training

        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)  # default=1.0
      
        tr_loss += loss.item()

modelはBertForSequenceClassificationクラスですので、outputsには「(loss), logits, (hidden_states), (attentions)」が格納されています。
モデルの出力に関しては、ここを確認しておくと良いと思います。
Migrating from pytorch-pretrained-bert — pytorch-transformers 1.0.0 documentation

Saving best-practices

fine tuning後のモデルなどを保存しておきます。

model_to_save = model.module if hasattr(model, 'module') else model  # Take care of distributed/parallel training
model_to_save.save_pretrained(args.output_dir)
tokenizer.save_pretrained(args.output_dir)
# torch.save(model_to_save.state_dict(), output_model_file)

# Good practice: save your training arguments together with the trained model
torch.save(args, os.path.join(args.output_dir, 'training_args.bin'))

save_pretrainedでは、torch.save()を呼んで保存しています。

Evaluation

基本的に訓練時と同じです。
ただ、Samplerのところはランダムではなくシーケンシャルです。

# Note that DistributedSampler samples randomly
eval_sampler = SequentialSampler(eval_dataset) if args.local_rank == -1 else DistributedSampler(eval_dataset)
eval_dataloader = DataLoader(eval_dataset, sampler=eval_sampler, batch_size=args.eval_batch_size)

あと、lossと一緒にlogitも取り出す必要があります。

eval_loss = 0.0
nb_eval_steps = 0
preds = None
for batch in tqdm(eval_dataloader, desc="Evaluating"):
    outputs = model(**inputs)
    eval_loss += tmp_eval_loss.mean().item()

    if preds is None:
        preds = logits.detach().cpu().numpy()
        out_label_ids = inputs['labels'].detach().cpu().numpy()
    else:
        preds = np.append(preds, logits.detach().cpu().numpy(), axis=0)
        out_label_ids = np.append(out_label_ids, inputs['labels'].detach().cpu().numpy(), axis=0)

    eval_loss = eval_loss / nb_eval_steps
    preds = np.argmax(preds, axis=1)

    result = compute_metrics(eval_task, preds, out_label_ids)
    results.update(result)

ここらへんが理解できれば、別のタスクでもfine tuningできると思いました。

おわりに

run_glue.pyを走らせて、fine tuningを触ってみました。
run_glue.pyは動かすだけで、GLUEのfine tuningが出来るので凄く簡単です。
しかし、別のタスクに取り組むためには、run_glue.pyに頼らないコーディングが必要です。
次回は、run_glue.pyに存在しないタスク(CoNLL)に挑戦したいと思います。

pytorch-transformersを触ってみる②

はじめに

前回はの入門ということで、QuikStartの内容を触ってみました。
kento1109.hatenablog.com

前回は英語でしたが、日本語のテキストを扱う場合はそのまま使うことは出来ません。
ということで、今回はpytorch-transformersで日本語のテキストを扱ってみようと思います。

Pretrained model

日本語でのPretrained modelとしては、京大の黒橋・河原研究室が公開しているものが有名です。
BERT日本語Pretrainedモデル - KUROHASHI-KAWAHARA LAB

このリソースを利用した既存のやってみたシリーズとしては以下などが参考となります。
Pytorchで日本語のbert学習済みモデルを動かすまで - Qiita
pytorchでBERTの日本語学習済みモデルを利用する - 文章埋め込み編 - Out-of-the-box

今回はこちらのリソースを活用して色々と触っていきます。

準備

ホームページより、Japanese_L-12_H-768_A-12_E-30_BPEをダウンロードします。
Pretrained modelは、Jumann++で形態素解析を行っているので、Jumann++をインストールしておきます。
また、Jumann++をpythonから利用するため、pyknp もインストールしておきます。

以下のようにpythonから形態素解析が出来れば準備OKです。

from pyknp import Juman
jumanpp = Juman()

text = "すもももももももものうち"
result = jumanpp.analysis(text)
print([mrph.midasi for mrph in result.mrph_list()])
# ['すもも', 'も', 'もも', 'も', 'もも', 'の', 'うち']

ただし、今回は触ってみることが目的なので、前処理の形態素解析は必ずしも必要でないです。

読み込み

前回は、以下のように文字列を指定してをロードしました。

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

今回は、ダウンロードしたpytorch_model.binを指定して読み込みます。
※学習は、BERT(BASE)と同じ設定 (12-layer, 768-hidden, 12-head)で行ったそうです。

model = BertModel.from_pretrained('bert-base-uncased')
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte

しかし、この場合UnicodeDecodeErrorが発生します。

上に挙げた記事はpytorch_pretrained_bertというライブラリを使っていたので、
少々勝手が異なるみたいです。

エラー箇所を見ると、

Constructs a `BertConfig` from a json file of parameters.

に関連する箇所が怪しかったので、以下のようにしてconfigclasssを引数に与えました。

config = BertConfig.from_json_file('Japanese_L-12_H-768_A-12_E-30_BPE/bert_config.json')
model = BertModel.from_pretrained('Japanese_L-12_H-768_A-12_E-30_BPE/pytorch_model.bin', config=config)

とりあえず、エラーは出ていないので問題なさそうです。

bertForMaskedLM

前回同様に、文の一部をMASKする問題を試してみます。

model = BertForMaskedLM.from_pretrained('Japanese_L-12_H-768_A-12_E-30_BPE/pytorch_model.bin', config=config)

この場合の出力は単語数となります。modelの最後の層を見てみると、

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

となっているのが分かります。

では、「僕は友達とサッカーをすることが好きだ」の文を形態素解析します。

from pyknp import Juman
jumanpp = Juman()
text = "僕は友達とサッカーをすることが好きだ"
result = jumanpp.analysis(text)
tokenized_text = [mrph.midasi for mrph in result.mrph_list()]
print(tokenized_text)
# ['僕', 'は', '友達', 'と', 'サッカー', 'を', 'する', 'こと', 'が', '好きだ']

となります。
続いて、BERT用の入力に整形し、「サッカー」をMASKしてみます。

tokenized_text.insert(0, '[CLS]')
tokenized_text.append('[SEP]')
masked_index = 5
tokenized_text[masked_index] = '[MASK]'
print(tokenized_text)
['[CLS]', '僕', 'は', '友達', 'と', '[MASK]', 'を', 'する', 'こと', 'が', '好きだ', '[SEP]']

日本語の辞書ファイルを読み込みます。

tokenizer = BertTokenizer("Japanese_L-12_H-768_A-12_E-30_BPE/vocab.txt",
                          do_lower_case=False, do_basic_tokenize=False)

一応、入力系列を確認してみます。

indexed_tokens = tokenizer.convert_tokens_to_ids(tokenized_text)
tokens_tensor = torch.tensor([indexed_tokens])
print(tokens_tensor)
#tensor([[    2,  5020,     9, 10729,    12,     4,    10,    22,    30,    11,    30808,     3]])

ここまで来れば、言語は関係ありません。

model.eval()

tokens_tensor = tokens_tensor.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)
# ['話', '仕事', 'キス', 'ゲーム', 'サッカー']

5番目にサッカーと予測できています。その他も、MASKとして妥当そうです。

[SEP]は必要??

ここで、思ったのですが、単文の場合は末尾に[SEP]は必要なのでしょうか。
試しに[SEP]無しで予測した結果が以下の通りです。

print(predicted_tokens)
['話', '仕事', 'キス', '会話', 'セックス']

サッカーは挙がってきませんが、概ね結果は同じです。
(サッカーの代わりにセックスと予測したのはなかなか面白いですね。。)

ここから、[SEP]が予測結果に影響を与えていることが分かります。
また、[SEP]は付けておいた方が無難かと思います。(他では結果が異なるかもしれませんが・・)

おわりに

今回は、pytorch-transformersで日本語のテキストを触ってみました。
次回は、fine tuningで目的のタスクに応用してみたいと思います。