1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

はじめに

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

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

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

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

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

環境

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

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

実装

データの用意

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

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件です。

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

入力を数字化する

単語そのものはBiLSTMの入力として使えないので、単語と数字の対応を作ります。さらに、単語だけでなく文字単体も入力に与えることでより性能の向上が期待できるので、文字も数字に対応させます。(参考)

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(入力)から単語系列、文字系列の二つを作ります。また、ラベルも同様に数字化します。

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の入力として使うためにはもう一つやらなければならないことがあります。各配列の長さをそろえるパディング処理です。

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

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

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になっています

モデルの実装

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

実装はAdvanced Natural Language Processing with TensorFlow 2のコードに文字系列のinputを加えたものです。

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の中身を作っていきます。

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入門が詳しいです。ざっくりとラベル同士の関係性を学習する層だと理解しています。

ちなみに、
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

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に分けます。

# データ分ける
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)

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

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分くらいで終わりました。

モデル評価

続いて推論に移ります。

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

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

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]

例を確認してみます。

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', '南林間', '六丁目', '児童', '遊園', '瀬底', '倶楽部']
#=> 三/ニジュウナナ 南林間六丁目児童遊園瀬底倶楽部

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

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']

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

間違えた例もあります。

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

#=> 予測:['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ホテル蒲田東口」でした。漢字が続いているからなんでしょうかね?理由は分かりません。中を覗いて解明してみたいですが一旦横に置いておきます。

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

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%でした。まずまずの結果と言えるのではないでしょうか。

最後に

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?