BiLSTMとkerasを使った固有表現抽出

# はじめに
今回は、ディープラーニングの一種であるBiLSTM(双方向LSTM)と、それを活用するライブラリkerasを用いて固有表現抽出タスクを行います。
固有表現抽出とは、文章中からある種類を表す文字列を見つけるタスクです。
例えばレシピ文章から食材などの表現を抽出したり[(例)](https://techlife.cookpad.com/entry/2020/11/06/110000)、ニュース文章から会社名を見つけその会社の株式のトレンドを機械的に把握したりできます。

この記事では固有表現タスクとしてホテル名や旅館名を含ませた自動生成の文章からそれらを見つけ出すモデルを作ることにします。

「来週　パーク　ハイアット東京予約」→

|      |            |          |            |            |      |      | 
| ---- | ---------- | -------- | ---------- | ---------- | ---- | ---- | 
| 来週 | (スペース) | パーク   | (スペース) | ハイアット | 東京 | 予約 | 
|      |            | ホテル名 |       | ホテル名   |  ホテル名    |      | 

イメージは上記のような形で、分かち書きされた文章からホテル名にあたる部分を取り出すことになります。
ホテル名の一覧で単純に当てるだけだと表記ゆれの問題があり、あらゆる表記ゆれを考慮した辞書は大変重くなるので実用的には難しいです。

# 環境
ライブラリのバージョンが違うと全然動かなかったりするので注意です！

- tensorflow: 2.3.0
- tensorflow_addons: 0.13.0
- numpy: 1.19.5
- transformers: 4.8.2

# 実装
## データの用意
分かち書きされた文と正解ラベルデータを用意します。今回は地名やスポット名とホテル名と日付表現をランダムに並び替えて大量に生成します。つなげる際には全角スペースor半角スペースor何も無しをランダムにします。

```python
import pandas as pd
df = pd.read_pickle('tag_test_data.pkl')
x = list(df.input_word_wakati)
y = list(df.tag_wakati)
print(''.join(x[10]))
#=> '十がつ十二にち\u3000横浜ベイシェラトン ホテル＆タワーズ かさおか古代の丘スポーツ公園'
print(x[10], y[10])
#=> ['十', 'がつ', '十二', 'にち', '\u3000', '横浜', 'ベイシェラトン', ' ', 'ホテル', '＆', 'タワーズ', ' ', 'かさおか', '古代', 'の', '丘', 'スポーツ', '公園'] ['O', 'O', 'O', 'O', 'O', 'I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'O', 'I-AREA', 'I-AREA', 'I-AREA', 'I-AREA', 'I-AREA', 'I-AREA']
```
Dataframeは入力データにあたるinput_word_wakatiとtag_wakatiの二つのカラムを持ち、それぞれ同じ長さの配列が格納されています。
固有表現のラベルではトークンの始まりはB-, 途中はI-をprefixとし、続いて種別を繋げます。今回は一つの文章中にホテル名は一つだけと限定するので、B-とI-の区別は必要ありません。一般的には複数存在し別のホテル名が連続して出現する可能性があるので区別する必要があります。

データ数は約20,000件です。

一般的なデータセットを使う場合は、下記のサイトにアクセスしてみると良いでしょう。

- [Wikipediaを用いた日本語の固有表現抽出データセットの公開](https://tech.stockmark.co.jp/blog/202012_ner_dataset/)
- [Language-Independent Named Entity Recognition](https://www.clips.uantwerpen.be/conll2003/ner/)


## 入力を数字化する
単語そのものはBiLSTMの入力として使えないので、単語と数字の対応を作ります。さらに、単語だけでなく文字単体も入力に与えることでより性能の向上が期待できるので、文字も数字に対応させます。[(参考)](https://hironsan.hatenablog.com/entry/deep-named-entity-recognition_1)

```python
UNK = '<UNK>'
PAD = '<PAD>'

vocab_word = {PAD: 0, UNK: 1}
vocab_char = {PAD: 0, UNK: 1}
vocab_label = {PAD: 0}

for sent in x:
    for w in sent:
        for c in w:
            if c in vocab_char:
                continue
            vocab_char[c] = len(vocab_char)

        if w in vocab_word:
            continue
        vocab_word[w] = len(vocab_word)

for labels in y:
    for tag in labels:
        if tag in vocab_label:
            continue
        vocab_label[tag] = len(vocab_label)

print(len(vocab_char), len(vocab_word), len(vocab_label))
# => 2917 31299 4
```

文字の種類数は2,917、単語数は31,229です。vocab_wordの中身の一部分は下のようになっています。

'小布施': 503,
'ドッグラン': 504,
'広場': 505,
'プレミア': 506,
'門司': 507,
'港': 508,
'小田急': 509,
'サザン': 510,
'タワー': 511,

x(入力)から単語系列、文字系列の二つを作ります。また、ラベルも同様に数字化します。

```python
def get_char_sequences(x):
    chars = []
    for sent in x:
        chars.append([list(w) for w in sent])

    return chars

def transform_char(x):
    seq = []
    for sent in x:
        char_seq = []
        for w in sent:
            char_ids = [vocab_char.get(c, vocab_char[UNK]) for c in w]
            char_seq.append(char_ids)
        seq.append(char_seq)
    
    return seq

def transform_word(x):
    seq = []
    for sent in x:
        word_ids = [vocab_word.get(w, vocab_word[UNK]) for w in sent]
        seq.append(word_ids)

    return seq

def transform_label(y):
    seq = []
    for labels in y:
        tag_ids = [vocab_label[tag] for tag in labels]
        seq.append(tag_ids)
        
    return seq

x_chars = get_char_sequences(x)
x_chars = transform_char(x_chars)
x_words = transform_word(x)
y = transform_label(y)
```

## データパディング
BiLSTMの入力として使うためにはもう一つやらなければならないことがあります。各配列の長さをそろえるパディング処理です。

```python
import numpy as np
from keras.preprocessing.sequence import pad_sequences
from keras.utils.np_utils import to_categorical

MAX_WORD_PADDING = 64 # １文当たりの最大単語数
MAX_CHAR_PADDING = 15 # １単語当たりの最大文字数

def pad_char(sequences):
    sequences = [list(seq) + [[] for i in range(max(MAX_WORD_PADDING - len(seq), 0))] for seq in sequences]
    tmp = [pad_sequences(seq, maxlen=MAX_CHAR_PADDING, padding='post', truncating='post').tolist() for seq in sequences]
    return np.array(tmp)

x_words = pad_sequences(x_words, maxlen=MAX_WORD_PADDING, padding='post', truncating='post')
x_chars = pad_char(x_chars)

y = pad_sequences(y, maxlen=MAX_WORD_PADDING, padding='post', truncating='post')
y = to_categorical(y, len(vocab_label))
```

pad_sequencesのtruncatingはMAX_WORD_PADDING以上のトークンが入力されたときに前を切り捨てるか後ろを切り捨てるかを指定します。今回のケースではどちらでも精度は変わらなそうですが、ニュース文章を解析する際などは後ろの方を切り捨てる（truncating='post'）の方が重要部分が切り捨てられにくいので良いと思います。デフォルトでは[preになっています](https://keras.io/ja/preprocessing/sequence/)。


## モデルの実装
さて、入力データの前処理ができたのでいよいよモデルを作っていきます。めちゃ長いです。

実装は[Advanced Natural Language Processing with TensorFlow 2](https://books.google.co.jp/books?id=QAIaEAAAQBAJ&pg=PA94&lpg=PA94)のコードに文字系列のinputを加えたものです。

```python
char_vocab_size = len(vocab_char)
char_emb_size = 50
char_lstm_units = 25
word_vocab_size = len(vocab_word)
word_emb_size = 100
word_lstm_units = 100
label_size = len(vocab_label)
BATCH_SIZE = 90

blc_model = NerModel(char_vocab_size,
                 char_emb_size,
                 char_lstm_units,
                 word_vocab_size, 
                 word_emb_size,
                 word_lstm_units,
                 label_size, dynamic=True)
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
```

NerModelの中身を作っていきます。

```python
class NerModel(tf.keras.Model):
    def __init__(self,
                 char_vocab_size,
                 char_emb_size,
                 char_lstm_units,
                 word_vocab_size, 
                 word_emb_size,
                 lstm_units,
                 label_size,
                 name='BilstmCrfModel',
                 **kwargs):
        super(NerModel, self).__init__(name=name, **kwargs)
        
        self.char_embedding = Embedding(input_dim=char_vocab_size, output_dim=char_emb_size, mask_zero=True, name='char_embedding')
        self.char_biLSTM = TimeDistributed(Bidirectional(LSTM(char_lstm_units, name='char_bilstm')))
        
        self.word_embedding = Embedding(input_dim=word_vocab_size, output_dim=word_emb_size, mask_zero=True, name='word_embedding')
        
        self.concatenate = Concatenate(axis=-1)
        
        self.biLSTM = Bidirectional(LSTM(units=lstm_units, return_sequences=True, name='bilstm'))
        
        self.dense = TimeDistributed(tf.keras.layers.Dense(label_size), name='dense')

        self.crf = CRFLayer(label_size, name='crf')
        
    def call(self, arrs, labels=None, training=None):
        seq_lengths = tf.math.reduce_sum(tf.cast(
            tf.math.not_equal(arrs[0], 0), dtype=tf.int32
        ), axis=-1)
        
        if training is None:
            training = K.learning_phase()
        
        char_embeddings = self.char_embedding(arrs[1])
        char_embeddings = self.char_biLSTM(char_embeddings)
        word_embeddings = self.word_embedding(arrs[0])
        
        conc = self.concatenate([word_embeddings, char_embeddings])
        bilstm = self.biLSTM(conc)
        logits = self.dense(bilstm)
        outputs = self.crf(logits, seq_lengths, training)
        
        return outputs
```

基本はword_embeddingを作った後、biLSTM、dense層で正解ラベルとつなげます。次はCRFLayerクラスを作っていきます。CRFクラスは次で作ります。CRFクラスの説明は[多分わかりやすいBi-LSTM-CRF入門](https://qiita.com/wakafar/items/c9a05713f579f436d36d)が詳しいです。ざっくりとラベル同士の関係性を学習する層だと理解しています。

ちなみに、
tensorflow 2.5.0、tensorflow_addon 0.13.0
だと

```
TypeError: Keras symbolic inputs/outputs do not implement `__len__`. ...
```
が出てきてtfa.layers.CRFが使えませんでした（泣）

また、CRF層はkeras addonからtensorflow addonに移行されています。
https://github.com/tensorflow/addons/pull/1999

```python
class CRFLayer(Layer):
    def __init__(self, label_size, mask_id=0, 
                 trans_params=None, name='crf', **kwargs):
        super(CRFLayer, self).__init__(name=name, **kwargs)
        self.label_size = label_size
        self.mask_id = mask_id
        self.transition_params = None
        
        if trans_params is None:
            self.transition_params = tf.Variable(
                tf.random.uniform(shape=(label_size, label_size)), trainable=False
            )
        else:
            self.transition_params = trans_params
            
    def call(self, inputs, seq_lengths, training=None):
        if training is None:
            training = K.learning_phase()
            
        if training:
            return inputs
        
        # viterbiあり
        # viterbi decode logic to return proper results
        _, max_seq_len, _ = inputs.shape
        seqlens = seq_lengths
        paths = []
        for logit, text_len in zip(inputs, seqlens):
            viterbi_path, _ = tfa.text.viterbi_decode(
                logit[:text_len], self.transition_params)
            paths.append(self.pad_viterbi(viterbi_path, max_seq_len))
            
        return tf.convert_to_tensor(paths)
        
        # viterbiなし
        #return inputs
        
    def pad_viterbi(self, viterbi, max_seq_len):
        if len(viterbi) < max_seq_len:
            viterbi = viterbi + [self.mask_id] * (max_seq_len - len(viterbi))
        return viterbi
    
    def loss(self, y_true, y_pred):
        y_pred = tf.convert_to_tensor(y_pred)
        y_true = tf.cast(self.get_proper_labels(y_true), y_pred.dtype)
        
        seq_lengths = self.get_seq_lengths(y_true)
        log_likelihoods, self.transition_params = tfa.text.crf_log_likelihood(
            y_pred, y_true, seq_lengths
        )
        
        self.transition_params = tf.Variable(self.transition_params, trainable=False)
        loss = -tf.reduce_mean(log_likelihoods)
        return loss
    
    def get_proper_labels(self, y_true):
        shape = y_true.shape
        if len(shape) > 2:
            return tf.argmax(y_true, -1, output_type=tf.int32)
        return y_true
    
    def get_seq_lengths(self, matrix):
        # matrix is of shape (batch_size, max_seq_len):
        mask = tf.not_equal(matrix, self.mask_id)
        seq_lengths = tf.math.reduce_sum(tf.cast(mask,dtype=tf.int32), axis=-1)
        return seq_lengths
```

データセットをtrain, testに分けます。

```python
# データ分ける
from sklearn.model_selection import train_test_split
x_train_words, x_test_words, x_train_chars, x_test_chars, y_train, y_test = train_test_split(
    x_words, x_chars, y, test_size=0.1, random_state=100
)

train_dataset = tf.data.Dataset.from_tensor_slices((x_train_words, x_train_chars, y_train))
train_dataset = train_dataset.batch(BATCH_SIZE, drop_remainder=True)

test_dataset = tf.data.Dataset.from_tensor_slices((x_test_words, x_test_chars, y_test))
test_dataset = test_dataset.batch(BATCH_SIZE, drop_remainder=True)
```

データを入れて実行します。

```python
loss_metric = tf.keras.metrics.Mean()
epochs=5
for epoch in range(epochs):
    print(f'Start of epoch {epoch}')
    
    for step, (text_batch, char_batch, labels_batch) in enumerate(train_dataset):
        labels_max = tf.argmax(labels_batch, -1, output_type=tf.int32)
        with tf.GradientTape() as tape:
            logits = blc_model([text_batch, char_batch], training=True)
            loss = blc_model.crf.loss(labels_max, logits)
            grads = tape.gradient(loss, blc_model.trainable_weights)
            optimizer.apply_gradients(zip(grads, blc_model.trainable_weights))
            
            loss_metric(loss)
        if step % 50 == 0:
            print(f'step {step}, mean loss = {loss_metric.result()}')

#=> Start of epoch 0
#=> step 0, mean loss = 2.5987370014190674
#=> step 50, mean loss = 2.135017156600952
#=> step 100, mean loss = 1.34137761592865
#=> step 150, mean loss = 1.0355430841445923
#=> step 200, mean loss = 0.8880751132965088
#=> Start of epoch 1
#=> step 0, mean loss = 0.8794817924499512
...
```

学習は15分くらいで終わりました。


# モデル評価

続いて推論に移ります。

```python
y_pred = blc_model.predict([x_test_words, x_test_chars])
y_test = np.argmax(y_test, -1)
```

正解データが数字だと人間が分かりにくいので逆の対応も用意します。

```python
inv_vocab_label = {v: k for k, v in vocab_label.items()}
inv_vocab_word = {v: k for k, v in vocab_word.items()}

y_pred_decode = [[inv_vocab_label[pp] for pp in p] for p in y_pred]
y_test_decode = [[inv_vocab_label[pp] for pp in p] for p in y_test]
x_test_words_decode = [[inv_vocab_word[pp] for pp in p] for p in x_test_words]
```

例を確認してみます。

```python
test_index = 41
print([v for v in x_test_words_decode[test_index] if v != '<PAD>'])
print(''.join([v for v in x_test_words_decode[test_index] if v != '<PAD>']))
#=> ['三', '／', 'ニジュウナナ', '\u3000', '南林間', '六丁目', '児童', '遊園', '瀬底', '倶楽部']
#=> 三／ニジュウナナ　南林間六丁目児童遊園瀬底倶楽部
```

どの部分がホテル名だか分かるでしょうか？正解は「瀬底倶楽部」です。

```python
print([v for v in y_pred_decode[test_index] if v != '<PAD>'])
print([v for v in y_test_decode[test_index] if v != '<PAD>'])
#=> ['O', 'O', 'O', 'O', 'I-AREA', 'I-AREA', 'I-AREA', 'I-AREA', 'I-HOTEL', 'I-HOTEL']
#=> ['O', 'O', 'O', 'O', 'I-AREA', 'I-AREA', 'I-AREA', 'I-AREA', 'I-HOTEL', 'I-HOTEL']
```

正解と予測が一致していましたね。テストデータには当該ホテル名は存在しないので「なんとなくこれがホテル名かな？」と文字や単語の関係だけから学習したことになっています。

間違えた例もあります。

```python
#=> ['R&B', 'ホテル', '蒲田', '東口', '善光寺', '\u3000', '1', 'がつ', '２８', 'にち']
#=> R&Bホテル蒲田東口善光寺　1がつ２８にち

#=> 予測：['I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'O', 'O', 'O', 'O', 'O']
#=> 実際：['I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'I-HOTEL', 'I-AREA', 'O', 'O', 'O', 'O', 'O']
```
予測は「R&Bホテル蒲田東口善光寺」がホテル名だと思っていますが、実際は「R&Bホテル蒲田東口」でした。漢字が続いているからなんでしょうかね？理由は分かりません。中を覗いて解明してみたいですが一旦横に置いておきます。

続いて、トークンの一致率を計算してみます。

```python
def precision(true, pred):
    total = 0
    correct = 0
    assert len(true) == len(pred) 
    for i, t in enumerate(true):
        assert len(t) == len(pred[i])
        for j, tt in enumerate(t):
            if tt != 0:
                total += 1
                if tt == pred[i][j]:
                    correct += 1
    return correct / total
precision(y_pred, y_test)
#=> 0.9318181818181818
```

93%でした。まずまずの結果と言えるのではないでしょうか。

# 最後に
入力に単語系列と文字系列を使うことで良い精度を出すことができました。今回使ったデータは量が少ないので、ユーザーが実際に入力した表記ゆれのあるデータを使ってさらに学習させる事で精度向上が期待できそうです。