自然言語処理の深遠

Deep Dive Into Natural Language Processing

ディープラーニングで作る固有表現認識器

固有表現認識は自然言語処理の基礎技術であり、様々なタスクの要素技術として使われます。たとえば、情報抽出や対話システム、質問応答といった応用システムの中で固有表現認識は使われることがあります。また、関係認識やEntity Linkingといった基礎技術で使われることもあります。

従来の固有表現認識では、言語に特有な特徴や外部知識に依存した手法が使われていました。これらの手法では、特徴を人間が定義することで、高性能な認識を実現していました。ただ、言語依存の特徴を使うため、モデルを新しい言語に対して適用する際のコストが高くなる問題があります。

本記事では、ディープラーニングを使って言語的な特徴や外部知識に依存しない固有表現認識器を作成します。本文は以下の内容で構成されています。

  • 実装するモデルの説明
  • モデルの実装
  • モデルの学習

全体のコードは以下のGitHubリポジトリにあります。スターしていただけるとうれしいです。

github.com

では、実装していきましょう。

固有表現認識とは?

まず最初に固有表現認識について簡単に確認しておきましょう。

固有表現認識とは、テキストに出現する人名や地名などの固有名詞や、日付や時間などの数値表現を認識する技術です。具体例を見てみましょう。以下の文から固有表現を抽出すると人名として「太郎」と「花子」、日付として「5月18日」、時間として「朝9時」が抽出できます。

太郎は5月18日の朝9時に花子に会いに行った。

固有表現認識の一般的な方法として、分かち書きした文中の各単語に対してラベル付けを行う方法があります。以下は「太郎は5月18日の朝9時に・・・」という文を分かち書きした後にラベル付けを行った例です。

スクリーンショット 2016-01-28 14.35.17.png

B-XXX、I-XXX というラベルがこれらの文字列が固有表現であることを表現しています。B-XXX は固有表現文字列の始まり、I-XXX は固有表現文字列が続いていることを意味しています。XXX 部分には LOCATION、PERSON などの固有表現クラスが入ります。固有表現でない部分には O というラベルが付与されます。

今回実装するモデル

今回は固有表現を認識するためにディープラーニングを用いたモデルを構築します。具体的にはLampleらが提案したモデルを構築します。このモデルでは、単語とその単語を構成する文字を入力することで、固有表現の認識を行います。言語固有の特徴を定義する必要性もなく、ディープな固有表現認識のベースラインとしてよく使われているモデルです。

Lampleらのモデルは主に文字用BiLSTM、単語用BiLSTM、およびCRFを用いて構築されています。まず単語を構成する文字をBiLSTMに入力して、文字から単語表現を獲得します。それを単語分散表現と連結して、単語用BiLSTMに入力します。最後にCRFを入れることで、出力ラベル間の依存性を考慮しています。以下のようなイメージです。

black_painted.png

A Bidirectional LSTM and Conditional Random Fields Approach to Medical Named Entity Recognitionより

Bidirectionalにすると固有表現認識の性能が向上する理由について以下の文を例に考えてみましょう。この文で「安倍」を認識したいものとします。

11日に安倍首相はアメリカを...

この文に対して1つのLSTMに文の前から入力をすると、「11」「日」「に」といった文脈情報を使って「安倍」が人であるという認識をすることになります。

一方で、逆順の入力をしてみたらどうでしょうか?この場合、「アメリカ」「は」「首相」といった情報を使うことができます。特に「首相」という単語があればその前に来るのは人である可能性が高いはずです。このように逆順の入力をすると認識し易い場面が多いのでBi-LSTMを使うことで性能向上すると考えられるのです。

また、単語を構成する文字から単語の埋め込み表現を得ることで、未知語に強くなる効果があります。単語は日々増え続け、未知語が増え続けているのに対し、文字はほとんど増えないので、文字から単語表現を作ることで未知語対策をしているとも言えます。

データセットの作成

ここではモデルに入力するデータセットを作成します。ここで行うのは以下の2つです。

はじめに、学習に用いるデータセットを読み込み、その後、読み込んだデータセットを前処理していきましょう。

データセットの読み込み

まずは学習につかうデータセットをダウンロードします。以下からダウンロードしてください

ダウンロードしたデータセットの中身を見てみましょう。すると、以下のようにタブ区切りで単語とラベルが定義されていることがわかります。また、1文ごとに空行が挟まれていることもわかります。

EU   B-ORG
rejects O
German  B-MISC
call    O
to  O
boycott O
British B-MISC
lamb    O
.   O

Peter   B-PER
Blackburn   I-PER
...

このデータセットを読み込むコードは以下のように書くことができます。

def load_data_and_labels(filename):
    sents, labels = [], []
    words, tags = [], []
    with open(filename) as f:
        for line in f:
            line = line.rstrip()
            if line:
                word, tag = line.split('\t')
                words.append(word)
                tags.append(tag)
            else:
                sents.append(words)
                labels.append(tags)
                words, tags = [], []
                
    return sents, labels

train_file = 'train.txt'
valid_file = 'valid.txt'

x_train, y_train = load_data_and_labels(train_file)
x_valid, y_valid = load_data_and_labels(valid_file)
# x_train[0]
# ['EU', 'rejects', 'German', 'call', 'to', 'boycott', 'British', 'lamb', '.']

これで、データセットを読み込むことができました。次に、データセットを前処理していきましょう。

データセットの前処理

データセットの前処理では、読み込んだデータセットをモデルに入力できる形式に変換します。具体的には以下の処理を行います。

  • ボキャブラリの作成
  • 文字系列の作成
  • 単語と文字のID化
  • パディング

では、1つずつ行っていきましょう。

ボキャブラリの作成

まずはボキャブラリの作成を行います。今回は、単語と文字を入力として使うので、それぞれについてボキャブラリを作成します。また、ラベルについてもボキャブラリを作成しておきましょう。

最初にボキャブラリ用の辞書を定義します。辞書は単語とそのIDの対応から構成されます。このとき、あらかじめPADUNKにIDを割り当てていることに注意してください。PADはパディング用、UNKは未知語用のトークンです。

UNK = '<UNK>'
PAD = '<PAD>'

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

ボキャブラリ用の辞書が定義できたら、これらにトークンを追加していきます。

for sent in x_train:
    for w in sent:
        # create char dictionary.
        for c in w:
            if c in vocab_char:
                continue
            vocab_char[c] = len(vocab_char)

        # create word dictionary.
        if w in vocab_word:
            continue
        vocab_word[w] = len(vocab_word)

# create label dictionary.
for labels in y_train:
    for tag in labels:
        if tag in vocab_label:
            continue
        vocab_label[tag] = len(vocab_label)

これでボキャブラリの作成が完了しました。次に、モデルの入力として与える文字系列を作成します。

文字系列の作成

今回用いるモデルは、入力として単語系列と文字系列を必要とします。単語系列はすでに作成できているので、文字系列を作成します。文字系列を作成するために、単語系列を与えると文字系列を返す関数get_char_sequencesを定義します。

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

    return chars

x_train_chars = get_char_sequences(x_train)
x_valid_chars = get_char_sequences(x_valid)
# x_train_chars[0]
# [['E', 'U'],
#  ['r', 'e', 'j', 'e', 'c', 't', 's'],
#  ['G', 'e', 'r', 'm', 'a', 'n'],
#  ['c', 'a', 'l', 'l'],
#  ['t', 'o'],
#  ['b', 'o', 'y', 'c', 'o', 't', 't'],
#  ['B', 'r', 'i', 't', 'i', 's', 'h'],
#  ['l', 'a', 'm', 'b'],
#  ['.']]

これで、文字系列を得ることができました。次にこれらの系列をID化します。

単語と文字のID化

これまでに、モデルの入力として与える単語系列と文字系列を作成してきました。しかし、これらの要素は文字列であるため、そのままではモデルに与えることができません。そこで、作成したボキャブラリを使って、文字列を単語/文字のIDに変換します。変換のために変換用の関数を定義します。

まずは、単語系列をID化します。transform_wordに単語系列を与えるとID化して返してくれます。

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

x_train_words = transform_word(x_train)
x_valid_words = transform_word(x_valid)
# x_train_words[0]
# [2, 3, 4, 5, 6, 7, 8, 9, 10]

次に、文字系列のID化をします。単語系列と同様にtransform_charを定義しました。こちらに文字系列を与えるとID化して返してくれます。

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

x_train_chars = transform_char(x_train)
x_valid_chars = transform_char(x_valid)
# x_train_chars[0]
# [[2, 3],
#  [4, 5, 6, 5, 7, 8, 9],
#  [10, 5, 4, 11, 12, 13],
#  [7, 12, 14, 14],
#  [8, 15],
#  [16, 15, 17, 7, 15, 8, 8],
#  [18, 4, 19, 8, 19, 9, 20],
#  [14, 12, 11, 16],
#  [21]]

最後にラベルもID化しておきます。

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


y_train = transform_label(y_train)
# y_train[0]
# [1, 2, 3, 2, 2, 2, 3, 2, 2]

ここまでで、入出力に使う系列をすべてID化することができました。最後に系列のパディングを行います。

パディング

前処理の最後に、系列に対してパディングを行います。パディングを行うことで、系列長を揃えることができます。本来はミニバッチ単位でパディングするのですが、今回は簡単のためにデータセット全体に対してパディングをします。

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


def pad_char(sequences):
    maxlen_word = max(len(max(seq, key=len)) for seq in sequences)
    maxlen_seq = len(max(sequences, key=len))
    sequences = [list(seq) + [[] for i in range(max(maxlen_seq - len(seq), 0))] for seq in sequences]

    return np.array([pad_sequences(seq, padding='post', maxlen=maxlen_word) for seq in sequences])

x_train_words = pad_sequences(x_train_words, padding='post')
x_valid_words = pad_sequences(x_valid_words, padding='post')
x_train_chars = pad_char(x_train_chars)
x_valid_chars = pad_char(x_valid_chars)
y_train = pad_sequences(y_train, padding='post')
y_train = to_categorical(y_train, len(vocab_label))

以上で前処理は完了です。前処理の次はいよいよモデルの実装に取り掛かります。モデルを実装する前に、モデルで用いるハイパーパラメータについて定義しておきましょう。

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
num_tags = len(vocab_label)

モデルの実装

ではモデルを実装していきましょう。モデルの実装にはKerasを使います。一度にすべてを実装するとわかりにくいので、以下の部分に分けて少しずつ実装していくことにします。

  • 入力
  • 文字用BiLSTM
  • 単語用BiLSTM
  • 出力

モデルの入力

まずはモデルの入力を用意します。今回のモデルは入力として単語系列とその文字系列をとります。以下のようにして、2つの入力を定義します。

from keras.layers import Input

word_ids = Input(batch_shape=(None, None), dtype='int32')
char_ids = Input(batch_shape=(None, None, None), dtype='int32')

文字用BiLSTM

次に文字用の BiLSTM を定義して、単語を構成する文字をベースに単語ベクトルを構築します。ここではまず、入力した文字系列を埋め込み表現に変換します。次に、埋め込み表現を BiLSTM に入力して、その状態を取得します。

import keras.backend as K
from keras.layers import LSTM, Embedding, Bidirectional, TimeDistributed
from keras.layers.merge import Concatenate

char_embeddings = Embedding(input_dim=char_vocab_size,
                            output_dim=char_emb_size,
                            mask_zero=True
                            )(char_ids)
char_embeddings = TimeDistributed(Bidirectional(LSTM(char_lstm_units)))(char_embeddings)

単語用BiLSTM

では次に単語用BiLSTMを定義します。まずは、単語埋め込み表現を定義し、先ほど作成した文字ベースの単語埋め込み表現と連結して新たな単語埋め込み表現とします。

word_embeddings = Embedding(input_dim=word_vocab_size,
                            output_dim=word_emb_size,
                            mask_zero=True)(word_ids)
x = Concatenate(axis=-1)([word_embeddings, char_embeddings])

単語埋め込み表現を作成できたので、これを BiLSTM に入力します。

x = Bidirectional(LSTM(units=word_lstm_units, return_sequences=True))(x)

モデルの出力

最後にモデルの出力層の部分を定義していきます。今回は出力層としてCRFを使います。ChainCRFについてはkeras-contribから取ってきてください。

from keras.layers import Dense
from layers import ChainCRF

x = Dense(word_lstm_units, activation='tanh')(x)
x = Dense(num_tags)(x)
crf = ChainCRF()
pred = crf(x)

あとはModelクラスに入出力を与えてモデルを作成するだけです。コンパイルを忘れないようにしましょう。

from keras.models import Model

model = Model(inputs=[word_ids, char_ids], outputs=[pred])
model.compile(loss=crf.loss, optimizer='adam')

以上でモデルの実装は終わりました。次はモデルを学習させてみましょう。

モデルの学習

ここまでで、モデルを学習させる準備が整いました。実際に学習させてみましょう。学習はモデルのfitメソッドに学習データを与えるだけです。

>>> model.fit([x_train_words, x_train_chars], y_train)
Epoch 1/1
14041/14041 [==============================] - 970s 69ms/step - loss: 231.8478

今回の場合、1エポックに970秒かかりました。ミニバッチごとにパディングを行うと、無駄にパディングをしないで済むのでさらなる高速化につながります。

モデルの評価

最後にモデルを評価します。固有表現認識の評価には通常はf1が用いられます。f1で評価するために、系列ラベリングの評価用パッケージである seqeval を使用します。

まずは、テストデータに対して予測をします。

y_pred = model.predict([x_valid_words, x_valid_chars])
y_pred = np.argmax(y_pred, -1)
# y_pred[0]
# array([2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
#        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
#        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
#        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
#        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])

予測した結果はパディング済みの長さになっています。パディング部分は評価には必要ないので除去してしまいます。また、評価のためにID化されているラベルを元の文字列に戻します。そのための関数がinverse_transform_labelです。

def inverse_transform_label(y):
    seq = []
    inv_vocab_label = {v: k for k, v in vocab_label.items()}
    for labels in y:
        tags = [inv_vocab_label[label_id] for label_id in labels]
        seq.append(tags)
        
    return seq

lengths = [len(s) for s in y_valid]
y_pred = [y[:l] for y, l in zip(y_pred, lengths)]
y_pred = inverse_transform_label(y_pred)
# y_pred[0]
# ['O', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O']

これで評価の準備ができました。あとは、seqevalf1_scoreに正解と予測結果を与えるだけです。

from seqeval.metrics import f1_score, classification_report

f1_score(y_valid, y_pred)
# 0.7911886949293432

以上ですべての実装が完了しました。今回は性能を上げるための様々なテクニックを使用していないので、この程度の評価結果になりました。

おわりに

固有表現認識は自然言語処理の様々なタスクで使われる重要な技術です。本記事ではディープラーニングを使った固有表現認識器を実装しました。この記事が皆様のお役に立てば幸いです。

私のTwitterアカウントでも機械学習自然言語処理に関する情報をつぶやいています。

この分野にご興味のある方のフォローをお待ちしています。

参考文献