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

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

PyTorch:Bi-LSTM+CRF①(学習)

はじめに



今回は、Bi-LSTM+CRFに関して整理する。
最近の深層学習を用いた系列ラベリングに関する論文は、この手法でSOTAを達成していることが多い。

尚、Bi-LSTM+CRFの基本的なことに関しては、以前のTheanoでの記事で述べた。
kento1109.hatenablog.com

Theanoでは、遷移素性の計算をscanを用いて実装していた。(正直、理解するのが難しかった・・)今回は、Pytorchの場合、そこをどう実装しているのかに着目して読んでいきたい。

以下のチュートリアルでBi-LSTM+CRFまで実装してくれているという親切さである。

Advanced: Making Dynamic Decisions and the Bi-LSTM CRF — PyTorch Tutorials 0.3.0.post4 documentation

では、さっそくみていく。
LSTMに関しては
前回まとめたので重複する部分は割愛する。

モデル構築・学習



以下の通り

class BiLSTM_CRF(nn.Module):

    def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim):
        super(BiLSTM_CRF, self).__init__()
        self.embedding_dim = embedding_dim
        self.hidden_dim = hidden_dim
        self.vocab_size = vocab_size
        self.tag_to_ix = tag_to_ix
        self.tagset_size = len(tag_to_ix)

        self.word_embeds = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2,
                            num_layers=1, bidirectional=True)

        # Maps the output of the LSTM into tag space.
        self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)

        # Matrix of transition parameters.  Entry i,j is the score of
        # transitioning *to* i *from* j.
        self.transitions = nn.Parameter(
            torch.randn(self.tagset_size, self.tagset_size))

        # These two statements enforce the constraint that we never transfer
        # to the start tag and we never transfer from the stop tag
        self.transitions.data[tag_to_ix[START_TAG], :] = -10000
        self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000

        self.hidden = self.init_hidden()

LSTM層の出力を線形変換した結果をself.hidden2tagに保存するまでは前回と同じ。
ただしラベルには、「開始・終了ラベル」を追加しておく。
self.hidden2tagは以下のような「単語系列×ラベル」の行列(観測素性)。

B I O <START> <STOP>
Mark 3.7 0.4 0.2 0.1 0.2
Watney 1.6 3.1 0.3 0.7 0.1
visited 0.2 0.5 1.7 0.4 0.8
Mars 3.6 0.4 1.1 0.2 0.5

次に以下のような遷移素性行列パラメータを作成する。

self.transitions = nn.Parameter(
        torch.randn(self.tagset_size, self.tagset_size))
B I O <START> <STOP>
B 0.1 1.3 0.8 -0.2 0.4
I 0.1 1.0 1.3 0.4 0.5
O 0.7 0.2 0.8 -0.1 1.1
<START> 0.5 0.1 0.9 1.1 -0.4
<STOP> -0.2 -0.3 -0.1 0.4 0.2

※列jから行iへの遷移

ランダムで初期化した後、以下のあり得ない遷移「-10000」の重みをセットする。

  • <START>への遷移
  • <STOP>からの遷移
self.transitions.data[tag_to_ix[START_TAG], :] = -10000
self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000
B I O <START> <STOP>
B 0.1 1.3 0.8 -0.2 -10000
I 0.1 1.0 1.3 0.4 -10000
O 0.7 0.2 0.8 -0.1 -10000
<START> -10000 -10000 -10000 -10000 -10000
<STOP> -0.2 -0.3 -0.1 0.4 -10000

損失値の計算



CRFの学習の目的は、系列x に対応するラベルyの確率最大化、すなわち

argmax_y P(y|x)
となるよう重みを調整する。
さて、確率P(y|x) であるがsoftmax関数を用いて以下のように定義される。

\begin{eqnarray}
P(y|x)=\frac{\exp({\rm Score}(x,y))}{\sum_{y'}\exp({\rm Score}(x,y'))}
\end{eqnarray}

  • \exp{\rm Score}(x,y) は、系列x に対応するラベルy のスコアに対数を取った値。
  • \sum_{y'}\exp({\rm Score}(x,y'))は、系列x に対応するラベルy' のスコアに対数を取った値の総和。

尚、{\rm Score}(x,y) は、素性関数\phi の対数を取った下式で定義される。


\begin{eqnarray}
{\rm Score}(x,y)=\sum_{i'}\log \phi_i(x,y)
\end{eqnarray}

計算を簡単にするため確率P(y|x) の対数を取る。


\begin{eqnarray}
\log P(y|x)={\rm Score}(x,y)-\log \sum_y'\exp({\rm Score}(x,y'))
\end{eqnarray}

上式が最大となるよう学習を行う。
つまり、\log \sum_y'\exp({\rm Score}(x,y'))-{\rm Score}(x,y) が最小となるよう学習を行う。
実際、neg_log_likelihoodの戻り値は、

forward_score - gold_score

と書かれており、これを損失値として扱っている。
このことを頭に入れてコードを読んでいく。

学習時は

neg_log_likelihood = model.neg_log_likelihood(sentence_in, targets)

でネットワークを実行して損失値を得る。

def neg_log_likelihood(self, sentence, tags):
    feats = self._get_lstm_features(sentence)
    forward_score = self._forward_alg(feats)
    gold_score = self._score_sentence(feats, tags)
    return forward_score - gold_score

get_lstm_featuresでは、観測素性行列を得る。
それを引数にして_forward_algを呼ぶ。
読んだ感じ_forward_algがCRFの学習におけるメインとなる処理の1つと思われる。

_forward_alg

\log \sum_y'\exp({\rm Score}(x,y'))の計算を行う。

# Iterate through the sentence
for feat in feats:
    alphas_t = []  # The forward variables at this timestep
    for next_tag in range(self.tagset_size):
        # broadcast the emission score: it is the same regardless of
        # the previous tag
        emit_score = feat[next_tag].view(
            1, -1).expand(1, self.tagset_size)

外側のループで系列から単語毎の素性ベクトルを取り出す。

B I O <START> <STOP>
Mark 3.7 0.4 0.2 0.1 0.2
Watney 1.6 3.1 0.3 0.7 0.1
visited 0.2 0.5 1.7 0.4 0.8
Mars 3.6 0.4 1.1 0.2 0.5
内側のループで単語のラベルごとの素性値を1つずつ取り出す。
B I O <START> <STOP>
Mark 3.7 0.4 0.2 0.1 0.2
Watney 1.6 3.1 0.3 0.7 0.1
visited 0.2 0.5 1.7 0.4 0.8
Mars 3.6 0.4 1.1 0.2 0.5
取り出した素性値をラベル数次元のベクトルにしてemit_scoreに格納する。

emit_score = feat[next_tag].view(
    1, -1).expand(1, self.tagset_size)

emit_score

Mark 3.7 3.7 3.7 3.7 3.7

次に遷移素性ベクトルを以下で得る。

trans_score = self.transitions[next_tag].view(1, -1)

B I O <START> <STOP>
B 0.1 1.3 0.8 -0.2 -10000
I 0.1 1.0 1.3 0.4 -10000
O 0.7 0.2 0.8 -0.1 -10000
<START> -10000 -10000 -10000 -10000 -10000
<STOP> -0.2 -0.3 -0.1 0.4 -10000
素性ベクトルを整理すると、

  • 観測素性ベクトル:Markのラベル「B」の素性ベクトル
  • 遷移素性ベクトル:先頭ラベル~Markのラベル「B」への遷移素性ベクトル

スコアは観測素性と遷移素性の加算であるが、ここで1つ問題が生じる。この計算の場合、任意の先頭ラベルからのラベル「B」への素性を計算しているが、実際には先頭ラベルは<START>以外あり得ない。この制約を課すために最初に下記のようなベクトルを作っていた。これをベクトルの足し算に加えることで、先頭ラベルが<START>以外のスコアを低くしている。

init_alphas = torch.Tensor(1, self.tagset_size).fill_(-10000.)
init_alphas[0][self.tag_to_ix[START_TAG]] = 0.
forward_var = autograd.Variable(init_alphas)

これのベクトルをlog_sum_expにより、指数計算した総和の対数を取ることで、スカラー値を得る。
系列の最後まで計算したら単語毎の素性ベクトルに指数計算した総和の対数を取ったスカラー値をneg_log_likelihoodに返す。
これが、\log \sum_y'\exp({\rm Score}(x,y')) に相当する。

_score_sentence

{\rm Score}(x,y) の計算を行う関数。
これは、先ほどの _forward_alg に比べると計算が簡単。
計算は系列の正解ラベルの値のみを単語ごとに加算していく。

上述したが、計算後の

forward_score - gold_score

を損失値として返す。
メインの訓練部分では、これを最小化するように最適化を行う。

長くなったので一旦、区切る。
次は、予測に関するコードを見ていく。