Ahogrammer

Deep Dive Into NLP, ML and Cloud

実践!固有表現認識 ~Flairを使って最先端の固有表現認識を体験しよう~

自然言語処理の分野で昔から研究され、実際に使われている技術として固有表現認識があります。固有表現認識は、テキスト中で固有表現が出現する位置を特定し、人名や地名などのラベルを付与するタスクです。情報抽出や質問応答、対話システムなどへの幅広い応用が可能なため、今でも盛んに研究され、使われている技術です。本記事では、日本語の固有表現認識をFlairと呼ばれるPythonパッケージを使って実現する方法について紹介します。

準備

本記事では Flair を使って固有表現認識のモデルを学習させます。Flairは最先端の自然言語処理のモデルを簡単に使い始められる形で提供してくれているパッケージです。その中で提供されている機能として、固有表現認識や品詞タグ付け、文書分類のモデルを学習するための機能があります。使い始めるために、以下のようにしてFlairをインストールしておく必要があります。

$ pip install flair

モデルを学習するためのデータも用意しておきましょう。本記事を読んでいる方はおそらく日本人なので、日本語の固有表現認識を行うことにします。以下のリポジトリからコーパスをダウンロードしてください。

github.com

wgetでダウンロードすると簡単なのでおすすめです。

$ wget https://raw.githubusercontent.com/Hironsan/IOB2Corpus/master/ja.wikipedia.conll

実装

今回は、2018年にAkbikらがContextual String Embeddings for Sequence Labelingという論文の中で提案したモデルを実装します。以下にモデルのアーキテクチャを示しました。このモデルでは、文字ベースの言語モデルを使って単語分散表現を構築し、それを使ってラベルを予測します。固有表現認識に携わる者であれば、まず知っているモデルであり、とても良い性能を出すことで知られています。

f:id:Hironsan:20190913151023p:plain
モデルのアーキテクチャ

ちなみに、以下の記事で今回実装するモデルのパワーアップ版を紹介しているので、本記事と合わせて読むと知識の幅が広がります。

hironsan.hatenablog.com

では実装していきましょう。まずは必要なクラスをインポートします。

from flair.datasets import ColumnCorpus
from flair.embeddings import StackedEmbeddings, FlairEmbeddings
from flair.data import Sentence
from flair.models import SequenceTagger
from flair.trainers import ModelTrainer

次に、ダウンロードしたコーパスを読み込みます。data_folderにはダウンロードしたコーパスが存在するディレクトリを指定してください。

columns = {0: 'text', 1: 'ner'}
data_folder = '.'
corpus = ColumnCorpus(data_folder, columns,
                      train_file='ja.wikipedia.conll')

コーパスの読み込みが終わったら、タグを用意します。今回は固有表現認識を行うので、tag_type='ner'を指定します。

tag_type = 'ner'
tag_dictionary = corpus.make_tag_dictionary(tag_type=tag_type)

タグの一覧を表示してみましょう。コーパス中に存在するタグに加えて、<unk><START><STOP>という3つのタグが用意されていることを確認できます。

>>> print(tag_dictionary.idx2item)
[b'<unk>', b'O', b'B-PERSON', b'I-PERSON', ..., b'<START>', b'<STOP>']

次に使用する分散表現を用意します。今回は論文中で使われている文字ベースの言語モデルから得られる分散表現を使いたいのでFlairEmbeddingsを指定します。事前学習済みモデルとしてja-forwardja-backwardを指定することで、日本語の事前学習済み言語モデルを使うことができます。

embedding_types = [
    FlairEmbeddings('ja-forward'),
    FlairEmbeddings('ja-backward'),
]
embeddings = StackedEmbeddings(embeddings=embedding_types)

分散表現を用意したら、学習させるモデルを定義します。ラベル間の依存関係を考慮するため、use_crf=TrueでCRFを使うようにします。

tagger = SequenceTagger(hidden_size=256,
                        embeddings=embeddings,
                        tag_dictionary=tag_dictionary,
                        tag_type=tag_type,
                        use_crf=True)

モデルを定義できたので、学習させていきます。今回は小さなコーパスを使っているので、CPUで計算しても数時間で学習は完了します。そんなに待てないという方はGPUやTPUを使いましょう。

trainer = ModelTrainer(tagger, corpus)
trainer.train('resources/taggers/example-ner',
              learning_rate=0.1,
              mini_batch_size=32,
              max_epochs=150)

学習が終わると以下のようにスコアを表示してくれます。今回はF1のマイクロ平均で0.7886という結果でした。以前に同じデータセットに対して文字情報なしのBiLSTM-CRFで実験した際には0.55程度だったので、それに比べると遥かに良い性能となりました。

0.8156    0.7633 0.7886
MICRO_AVG: acc 0.6509 - f1-score 0.7886
MACRO_AVG: acc 0.6436 - f1-score 0.7638666666666667
ARTIFACT   tp: 40 - fp: 19 - fn: 47 - tn: 40 - precision: 0.6780 - recall: 0.4598 - accuracy: 0.3774 - f1-score: 0.5480
DATE       tp: 123 - fp: 11 - fn: 15 - tn: 123 - precision: 0.9179 - recall: 0.8913 - accuracy: 0.8255 - f1-score: 0.9044
EVENT      tp: 21 - fp: 7 - fn: 11 - tn: 21 - precision: 0.7500 - recall: 0.6562 - accuracy: 0.5385 - f1-score: 0.7000
LOCATION   tp: 242 - fp: 45 - fn: 26 - tn: 242 - precision: 0.8432 - recall: 0.9030 - accuracy: 0.7732 - f1-score: 0.8721
NUMBER     tp: 76 - fp: 13 - fn: 13 - tn: 76 - precision: 0.8539 - recall: 0.8539 - accuracy: 0.7451 - f1-score: 0.8539
ORGANIZATION tp: 86 - fp: 41 - fn: 60 - tn: 86 - precision: 0.6772 - recall: 0.5890 - accuracy: 0.4599 - f1-score: 0.6300
OTHER      tp: 19 - fp: 7 - fn: 25 - tn: 19 - precision: 0.7308 - recall: 0.4318 - accuracy: 0.3725 - f1-score: 0.5429
PERCENT    tp: 13 - fp: 0 - fn: 0 - tn: 13 - precision: 1.0000 - recall: 1.0000 - accuracy: 1.0000 - f1-score: 1.0000
PERSON     tp: 70 - fp: 13 - fn: 17 - tn: 70 - precision: 0.8434 - recall: 0.8046 - accuracy: 0.7000 - f1-score: 0.8235

学習が完了したので、最も良いモデルを読み込みます。

model = SequenceTagger.load('resources/taggers/example-ner/final-model.pt')

読み込んだモデルを使って予測を行います。

sentence = Sentence('私 は 田中 と 東京 駅 へ 行っ た')
model.predict(sentence)

結果を表示すると以下のようになりました。正しく認識できていますね。

>>> print(sentence.to_tagged_string())
私 は 田中 <B-PERSON> と 東京 <B-LOCATION> 駅 <I-LOCATION> へ 行っ た

See also

Colaboratoryにノートブックを用意したので、試してみてください。

colab.research.google.com

参考文献

単語分散表現の履歴を使って固有表現認識の性能を向上させる

NAACL 2019より以下の論文。あのAkbikの手法がパワーアップして帰ってきた!

この論文では、固有表現認識を行う際に、今までの認識に使った単語の分散表現を記憶しておき、その情報を使って性能を向上させる手法を提案している。最近の固有表現認識では言語モデルによって得られる分散表現を利用するが、そこでの課題として、文脈が十分にないまれ語に対しては良い表現を得られないという問題がある。たとえば、以下の文の「Indra」は文脈が十分ではないまれ語なので、組織なのか人名なのかの解釈が難しい。

f:id:Hironsan:20190913071930p:plain
上手くいかない例

直感的には、文脈が十分にないまれ語について読者が正しく解釈するためには、その単語について知っておく必要がある。では、読者はどこで知るのかというと、そのまれ語が出現する文以前に、十分な文脈を伴って同じ語についての説明があるはずだというのが著者らの主張となっている。つまり、さきほどの「Indra」の例であれば、「Indra Wijia(Indonesia) beat Ong Ewe Hock」のような文があるはずだと言っている。このような文があれば「Indra」を人名だと解釈するのは難しくない。

そこで、提案するモデルでは、今までに出現した文脈を伴った単語分散表現を記録しておき、その情報を利用して固有表現認識を行う。図にすると以下のようになる。手法としては、まずは文字レベルの言語モデルから単語の分散表現を得る。次に、認識対象となる単語の分散表現をメモリ上から取得する。このメモリ上にはこれまでの認識に使った言語モデルから出力された単語分散表現が格納されている。その後、メモリから得られた分散表現をプーリングして、元の単語分散表現と連結する。そうすることで、現在の文脈を使いつつ、これまでに出現した文脈を利用できるようになる。

f:id:Hironsan:20190913071902p:plain
提案手法の概要

実験はCoNLL 2003とWNUT-17をコーパスとして使っている。WNUT-17はSNSのテキストから作成された固有表現認識用のコーパスで、対象とするテキストの性質から新語が多いという特徴がある。様々なモデルとの比較実験の結果を以下に示す。この結果を見ると、比較対象のモデルと比べて良い結果を示していることがわかる。

f:id:Hironsan:20190913072032p:plain
比較実験

また、履歴の分散表現の効果についても調査している。以下の表では、メモリ上から得られた分散表現だけを使って認識した場合と、現在の文脈から得られた分散表現を使って認識した場合の性能について検証している。この結果を見ると、現在の文脈から得られた分散表現を使うより、履歴を使ったほうが性能が一貫して良いことがわかる。

f:id:Hironsan:20190913072137p:plain
提案手法の効果の検証

感想としては、たしかに性能は向上しているのだけど、WNUT-17の結果を見ると、当初意図していたような文脈の不十分なまれ語に対する認識性能が向上したとは言えないと思う。それよりは、同じエンティティに対して過去の認識結果と一貫性のある認識をできた結果として性能が向上した可能性がある。

TensorFlow Datasetsを使ってテキストの分かち書きとID化をする

自然言語処理で欠かせない前処理としてテキストの分かち書きとID化があります。分かち書きはテキストを分割するプロセスであり、文字や単語、サブワードといった単位でテキストを分割します。これらの分割後の要素はトークンと呼ばれます。一方、ID化はトークンを数値に変換するプロセスです。数値に変換することで、機械学習アルゴリズムトークンを与えることができるようになります。本記事では、これらのプロセスをTensorFlow Datasetsの機能を使って実現する方法について紹介します。

準備

この記事ではTensorFlow Datasetsを使って分かち書きとID化を行います。TensorFlow DatasetsはIMDbやMNISTなどの機械学習でよく使われるデータセットを簡単に使い始められる形で提供してくれるパッケージです。その中の一部の機能として、テキストの分かち書きとID化をするための機能を提供しています。使い始めるために、以下のようにしてTensorFlow Datasetsをインストールしておく必要があります。

$ pip install tensorflow-datasets

また、後で日本語を扱うために、Janomeもインストールしておきます。

$ pip install janome

英語の場合

ではまずは、英語で書かれたテキストの分かち書きとID化をやってみましょう。はじめに、Tokenizerを使ってテキストをトークンに分割します。得られたトークンからボキャブラリを作成し、それをTokenTextEncoderに与えてエンコーダを作成します。

import tensorflow_datasets as tfds

example_text = 'She sells seashells by the seashore'
tokenizer = tfds.features.text.Tokenizer()
tokenized_text = tokenizer.tokenize(example_text)
vocabulary_list = list(set(tokenized_text))
encoder = tfds.features.text.TokenTextEncoder(vocabulary_list,
                                              lowercase=True)

エンコーダを作成したら、encodeメソッドを呼んで、トークンをID化します。また、ID化したトークンを文字列に戻すためにdecodeメソッドも呼びます。

encoded_example = encoder.encode(example_text)
decoded_example = encoder.decode(encoded_example)

変換結果を表示するためのコードを書きます。

>>> print(tokenized_text)
>>> print(encoded_example)
>>> print(decoded_example)
>>> print(encoder.vocab_size)

以下のような結果が得られます。分かち書きとID化ができていることを確認できます。

['She', 'sells', 'seashells', 'by', 'the', 'seashore']
[3, 4, 6, 2, 1, 5]
she sells seashells by the seashore
8

作成したエンコーダはsave_to_fileメソッドを呼んで保存します。これにより、ボキャブラリを保存することができます。

filename_prefix = 'encoder'
encoder.save_to_file(filename_prefix)
encoder = tfds.features.text.TokenTextEncoder.load_from_file(filename_prefix)
print(encoder.encode(example_text))
# [3, 4, 6, 2, 1, 5]

日本語の場合

日本語の場合もID化するところまでは変わりません。トークナイザーをJanomeに変えるだけです。

from janome.tokenizer import Tokenizer

example_text = '太郎は花子に花束を渡した。'
tokenizer = Tokenizer(wakati=True)
tokenized_text = tokenizer.tokenize(example_text)
vocabulary_list = list(set(tokenized_text))
encoder = tfds.features.text.TokenTextEncoder(vocabulary_list, tokenizer=tokenizer)
encoded_example = encoder.encode(example_text)

変換結果を表示するためのコードを書きます。

>>> print(tokenized_text)
>>> print(encoded_example)
>>> print(decoded_example)
>>> print(encoder.vocab_size)

結果は以下のようになります。

['太郎', 'は', '花子', 'に', '花束', 'を', '渡し', 'た', '。']
[3, 1, 4, 6, 7, 8, 9, 5, 2]
太郎 は 花子 に 花束 を 渡し た 。
11

しかし、エンコーダーを保存するときに問題が起きます。

>>> encoder.save_to_file(filename_prefix)
AttributeError: 'Tokenizer' object has no attribute 'save_to_file'

これはJanomeのTokenizerにsave_to_fileメソッドが無いことにより起きる例外です。エンコーダーsave_to_fileメソッドを呼ぶと、その内部でTokenizersave_to_fileメソッドを呼び出します。しかし、Janomeにはsave_to_fileメソッドはありません。その結果、メソッドが存在しないというエラーが出るわけです。

対策として、tfds.features.text.Tokenizerクラスを継承したトークナイザーを用意します。そこに何もしないsave_to_fileメソッドをもたせます。また、用意したトークナイザーを読み込めるようにするために、tfdds.features.text.TokenTextEncoderを継承したエンコーダーを作成します。

class JapaneseTokenizer(tfds.features.text.Tokenizer):

    def __init__(self, alphanum_only=True, reserved_tokens=None):
        super().__init__(alphanum_only, reserved_tokens)
        self._t = Tokenizer(wakati=True)

    def tokenize(self, s):
        return self._t.tokenize(s)

    def save_to_file(self, filename_prefix):
        pass

    @classmethod
    def load_from_file(cls, filename_prefix):
        return cls()


class ExtendedEncoder(tfds.features.text.TokenTextEncoder):
    @classmethod
    def load_from_file(cls, filename_prefix):
        filename = cls._filename(filename_prefix)
        vocab_lines, kwargs = cls._read_lines_from_file(filename)
        has_tokenizer = kwargs.pop("has_tokenizer", False)
        if has_tokenizer:
            kwargs["tokenizer"] = JapaneseTokenizer.load_from_file(filename)
        return cls(vocab_list=vocab_lines, **kwargs)

定義したクラスを使うことで、作成したボキャブラリが保存されるようになります。

tokenizer = JapaneseTokenizer()
tokenized_text = tokenizer.tokenize(example_text)
vocabulary_list = list(set(tokenized_text))
encoder = ExtendedEncoder(vocabulary_list, tokenizer=tokenizer)
encoded_example = encoder.encode(example_text)
encoder.save_to_file('encoder')
encoder = ExtendedEncoder.load_from_file('encoder')
print(encoded_example)
# [3, 1, 4, 6, 7, 8, 9, 5, 2]

おわり。まだ様子見が必要。

参考文献