spaCyでどこに行きたいか「場所」を抽出する【自然言語処理, Python】

205, 2021-03-15

目次

spaCyで「場所」を抽出する

私たちが使う日本語は「自然言語」と呼ばれます。
この自然言語をプログラム的に解析するのが「自然言語処理」です。

Pythonにはいくつか自然言語処理を行えるライブラリがあります。
spaCy(スパイシー)のその中の1つです。

今回はこのspaCyを使って日本語の文章から「場所」を抽出するスクリプトをPythonで書いてみたいと思います。
具体的には↓を見ていきます。

  • スクリプトの動作結果

  • スクリプトの設計

  • スクリプトのソースコード

  • ソースコードの解説

スクリプトの動作結果

今回のスクリプトは単体テストで動作確認をしています。
単体テストにはPythonの標準ライブラリであるunittestを使っています。
スクリプトのソースコードをsample.pyなどに保存し、python -m unittest sampleというコマンドを実行するとテストを実行することができます。
スクリプトを実行すると↓のような結果になります。

.
----------------------------------------------------------------------
Ran 1 test in 1.346s

OK

↑の結果はテストがすべて成功したことを表しています。
テストコードは↓です。

class Test(unittest.TestCase):
    def eq(self, a, b):
        an = Analyzer()
        c = an.analyze(a)
        self.assertEqual(c, b)

    def test_analyze(self):
        self.eq('北海道に行く', '北海道')
        self.eq('北海道へ行く', '北海道')
        self.eq('友人と北海道に行く', '北海道')
        self.eq('北海道に友人と一緒に行く', '北海道')
        self.eq('行こう北海道', '北海道')
        self.eq('行くぞ北海道', '北海道')
        self.eq('向こうへ行こう', '向こう')
        self.eq('向こう行こう', '向こう')
        self.eq('あっちに行こう', 'あっち')
        self.eq('あっち行こう', 'あっち')
        self.eq('そろそろ北海道へ行こう', '北海道')
        self.eq('これから北海道に行きたい', '北海道')
        self.eq('北海道に行くのか?', '北海道')
        self.eq('沖縄に向かう', '沖縄')
        self.eq('寒いので沖縄に行こうか', '沖縄')
        self.eq('沖縄へ', '沖縄')
        self.eq('沖縄', '沖縄')
        self.eq('こっち', 'こっち')
        self.eq('こっちへ', 'こっち')
        self.eq('こっちに', 'こっち')

今回はいわゆる期待通りに動作する正常系のテストしかやっていませんが、異常系が気になる方はテストを追加してみてください。
↑のテストケースを見ると、「これから北海道に行きたい」という文章から「北海道」という場所が抽出されているのがわかります。
そのほかにも「あっちに行こう」という文章から「あっち」という場所が抽出されています。

スクリプトの設計

今回作成するスクリプトの設計についてです。

スクリプトの動作検証は単体テスト(unittest)で行います。

スクリプトの本体はAnalyzerというクラスです。
このクラスのanalyze()メソッドが文章から場所を抽出するメソッドです。

analyze()メソッドは引数のsentenceを自然言語処理で解析します。
そしてそれをspaCyのオブジェクト(トークン列)に変換します。
あとはこのトークン列を走査して解析します。

トークンの原形(lemma_)を参照して原形が「行く」「向かう」などだったら解析を分岐します。
「行く」「向かう」などのトークンの依存構造上における子要素や親要素を参照して固有名詞や名詞を探します。
あとはその見つかった固有名詞などのトークンのテキストを取得して、analyze()の返り値として返します。

アルゴリズム的には↑のように「行く」「向かう」の単語をキーにして依存構造を検索するだけの処理になります。
「に」や「へ」なども考慮しておらず、非常にあいまいな検索になっています。
言い換えれば「行く」「向かう」などの周辺にある固有名詞を抽出してるだけの処理です。

アバウトだな

先ほどのテストケースを見る限りではなんとなくうまく行ってるように見えますが、もっと複雑な文章になった場合はおそらく失敗するかもしれません。
筆者の語彙力が足りずテストケースを網羅的に書けていないためです。
その辺はご了承ください。

スクリプトのソースコード

今回作成したスクリプトのソースコードは↓になります。
繰り返しになりますが、単体テストによる実行を想定したスクリプトなので、実行する場合は↓のソースコードをsample.pyなどに保存してpython -m unittest sampleを実行します。

"""
どこに行きたいのか「場所」を抽出する

License: MIT
Created at: 2021/01/31
"""
import spacy
import unittest


# GiNZAのモデルのロード
nlp = spacy.load('ja_ginza')


class Analyzer:
    """
    どこに行きたい・向かいたいのか抽出するアナライザー
    """
    def analyze(self, sentence):
        """
        sentenceを解析してどこに行きたいのか抽出する
        """
        doc = nlp(sentence)  # 文章を解析してdocに

        # 最初に「行く」「向かう」をキーにして解析する
        result = self.analyze_go_to(doc)
        if result:
            return result  # 解析に成功した

        # 解析に失敗したら適当に固有名詞, 名詞, 代名詞を探す
        found = self.find_from_children(doc[0], ('PROPN', 'NOUN', 'PRON'))
        if found:
            return found.text  # 見つかったのでそのトークンのtextを返す

        return None

    def analyze_go_to(self, doc):
        """
        原形の「行く」「向かう」をキーにして解析する
        """
        for tok in doc:
            if tok.lemma_ in ('行く', '向かう'):  # 原形が「行く」「向かう」だったら
                result = self.analyze_go(tok)  # そのトークンをキーに解析して
                if result:
                    return result  # 結果を返す

        return None

    def analyze_go(self, tok):
        """
        tokを解析する(「行く」「向かう」がキー)
        """
        # 最初に子要素から固有名詞を探す
        found = self.find_from_children(tok, ('PROPN', ))
        if found:
            return found.text  # 見つかったらそれを返す

        # 次に親要素から代名詞を探す
        found = self.find_from_head(tok, ('PROPN', ))
        if found:
            return found.text  # 見つかったら(ry

        # 子要素から名詞、代名詞を探す
        found = self.find_from_children(tok, ('NOUN', 'PRON', ))
        if found:
            return found.text  # 見つ(ry

        return None

    def find_from_children(self, tok, poses_):
        """
        tokの子要素から再帰的にposes_にマッチするトークンを探す
        """
        if tok.pos_ in poses_:
            return tok  # 見つかった

        for t in tok.children:  # 依存構造における子要素を参照する
            found = self.find_from_children(t, poses_)  # 再帰的に検索
            if found:
                return found  # 見つかった

        return None  # 見つからなかった

    def find_from_head(self, tok, poses_):
        """
        tokの親要素がposes_にマッチしたら親要素を返す
        """
        if tok.head.pos_ in poses_:
            return tok.head  # マッチした

        return None  # マッチしなかった


class Test(unittest.TestCase):
    def eq(self, a, b):
        an = Analyzer()
        c = an.analyze(a)
        self.assertEqual(c, b)

    def test_analyze(self):
        self.eq('北海道に行く', '北海道')
        self.eq('北海道へ行く', '北海道')
        self.eq('友人と北海道に行く', '北海道')
        self.eq('北海道に友人と一緒に行く', '北海道')
        self.eq('行こう北海道', '北海道')
        self.eq('行くぞ北海道', '北海道')
        self.eq('向こうへ行こう', '向こう')
        self.eq('向こう行こう', '向こう')
        self.eq('あっちに行こう', 'あっち')
        self.eq('あっち行こう', 'あっち')
        self.eq('そろそろ北海道へ行こう', '北海道')
        self.eq('これから北海道に行きたい', '北海道')
        self.eq('北海道に行くのか?', '北海道')
        self.eq('沖縄に向かう', '沖縄')
        self.eq('寒いので沖縄に行こうか', '沖縄')
        self.eq('沖縄へ', '沖縄')
        self.eq('沖縄', '沖縄')
        self.eq('こっち', 'こっち')
        self.eq('こっちへ', 'こっち')
        self.eq('こっちに', 'こっち')

ソースコードの解説

簡単ですがソースコードの解説になります。

必要モジュールのインポート

スクリプトの先頭で必要モジュールをインポートします。
今回は自然言語処理にspaCyを使うので、spacyをインポートします。
それから単体テスト用にunittestもインポートしておきます。

import spacy
import unittest

GiNZAモデルのロード

spacy.load()でGiNZAのモデルをロードします。
spaCyによる日本語の解析ではGiNZAというプロダクトの統計モデルを使用します。
spacy.load('ja_ginza')とやるとGiNZAの統計モデルをロードできます。

ja_ginzaを読み込んだ場合spacy.load()は返り値としてginza.Japaneseを返します。
この返り値には慣例としてnlpと命名するようになっています。このnlpに文章を渡すことで解析を行うことができます。

# GiNZAのモデルのロード
nlp = spacy.load('ja_ginza')

このGiNZAは日本のリクルートと国立国語研究所が共同開発した自然言語処理ライブラリです。
spaCyでは最近まで日本語の解析が構文解析以上のことができなかったのですが、このGiNZAの登場によってそれ以降の解析ができるようになったということです。

Analyzerクラスの作成

スクリプトの主要な処理はAnalyzerというクラスに実装します。
Analyzerは「解析者」とかそういう意味です。
__init__()メソッドは定義していません。属性はメソッドだけになっています。

class Analyzer:
    """
    どこに行きたい・向かいたいのか抽出するアナライザー
    """
    ...

メソッドについては後述します。

analyze()メソッドで解析を行う

analyze()メソッドは引数sentenceを解析して文章中から「どこに行きたい」「どこに向かいたい」のかの「場所」を抽出します。
引数sentencenlp()に渡してspaCyで解析します。
このnlp()の返り値はspacy.tokens.doc.Docです。

docはトークン列を抽象化したオブジェクトで、このdocを参照することによってspaCyが解析した結果を参照することができます。
spaCyはnlp()に渡された文章を形態素解析しトークン列にします。そしてトークン列を使って依存構造解析をします。その解析の結果はトークンの情報として保存されます。
つまりトークン列のトークンを参照すれば形態素解析や依存構造解析の結果を取得できるということになります。

形態素解析とは簡単に言うと文章を単語のリストに変換する解析です。
たとえば「猫が笑う」という文章であれば「猫 / が / 笑う」に分割します。分割の時にその単語の原形とか読み方とかもトークンに保存します。

依存構造解析は簡単に言うとどの単語がどの単語に係(かか)っているかの解析です。
これはツリー構造によって表現され、単語から単語へと矢印で結ばれます。この矢印をたどることで依存構造を走査できるということになります。
この依存構造はどの単語がどの単語に関係を持っているかわかるため、意味解析以上の自然言語処理で使われる情報です。

話はそれましたが、docを取得したらそのdocを解析していきます。
まずanalyze_go_to()というメソッドで「行く」「向かう」などの単語をキーにして解析をします。
その解析に成功したら結果をそのまま返します。

analyze_go_to()の解析に失敗したら今度はdocから適当なトークンを探します。
これはPROPN, NOUN, PRONなどのトークンです。
これらの記号はトークンのpospos_属性に保存されるラベルで、品詞の種類を表すものです。
たとえば↑のラベルの意味は↓のようになります。

  • PROPN ... 固有名詞

  • NOUN ... 名詞

  • PRON ... 代名詞

適当な検索で固有名詞などが見つかったら、そのトークンのテキストを返します。
すべての解析に失敗したらNoneを返して終わりです。

    def analyze(self, sentence):
        """
        sentenceを解析してどこに行きたいのか抽出する
        """
        doc = nlp(sentence)  # 文章を解析してdocに

        # 最初に「行く」「向かう」をキーにして解析する
        result = self.analyze_go_to(doc)
        if result:
            return result  # 解析に成功した

        # 解析に失敗したら適当に固有名詞, 名詞, 代名詞を探す
        found = self.find_from_children(doc[0], ('PROPN', 'NOUN', 'PRON'))
        if found:
            return found.text  # 見つかったのでそのトークンのtextを返す

        return None

analyze_go_to()で「行く」「向かう」を解析する

analyze_go_to()メソッドは引数docを解析します。

docはトークン列なのでfor文で回すことができます。
for tok in doc:のようにfor文で回すとトークンを1つずつ取り出すことができます。
このtokspacy.tokens.token.Tokenになります。

このトークンのlemma_属性を参照します。
lemma_属性はそのトークンの原形を表す文字列が保存されています。
原形とはつまり単語の原形のことで、「行った」の「行っ」の原形は「行く」になります。
ほかにも「向かった」の「向かっ」の原形は「向かう」になります。

トークンのlemma_が「行く」「向かう」のいずれかであれば、処理を分岐してanalyze_go()メソッドを呼び出します。

    def analyze_go_to(self, doc):
        """
        原形の「行く」「向かう」をキーにして解析する
        """
        for tok in doc:
            if tok.lemma_ in ('行く', '向かう'):  # 原形が「行く」「向かう」だったら
                result = self.analyze_go(tok)  # そのトークンをキーに解析して
                if result:
                    return result  # 結果を返す

        return None

analyze_go()で文脈からトークンを見つける

analyze_go()は引数tokの依存構造から指定の品詞のトークンを探すメソッドです。
最初にfind_from_children()メソッドを使ってトークンの子要素から固有名詞(PROPN)を探します。
見つかったらそれのテキストを返します。

見つからなければfind_from_head()メソッドでトークンの親要素から固有名詞(PROPN)を探します。
見つかったらそれのテキストを返します。

それも見つからなかったら今度はfind_from_children()でトークンの子要素から名詞(NOUN)と代名詞(PRON)を探します。
見つかったらそれのテキストを返します。

このanalyze_goでは固有名詞を優先した検索を行っています。
それから親要素の検索は固有名詞のみを行っています。名詞や代名詞を親要素から検索するようにすればさらに精度が上がるかもしれません(たぶん)。

    def analyze_go(self, tok):
        """
        tokを解析する(「行く」「向かう」がキー)
        """
        # 最初に子要素から固有名詞を探す
        found = self.find_from_children(tok, ('PROPN', ))
        if found:
            return found.text  # 見つかったらそれを返す

        # 次に親要素から代名詞を探す
        found = self.find_from_head(tok, ('PROPN', ))
        if found:
            return found.text  # 見つかったら(ry

        # 子要素から名詞、代名詞を探す
        found = self.find_from_children(tok, ('NOUN', 'PRON', ))
        if found:
            return found.text  # 見つ(ry

        return None

find_from_children()で子要素からトークンを探す

find_from_children()メソッドは引数tokの依存構造上における子要素を参照して、poses_にマッチする品詞のトークンを探し、見つかったらそのトークンを返すメソッドです。
このメソッドは子要素の参照に再帰呼び出しを使っています。

トークンのpos_は品詞を表す文字列で、引数poses_は品詞のラベルの文字列が入ったリスト(またはタプル)です。

トークンのchildren属性には依存構造上における子要素のトークン列が保存されています。
このchildrenを参照することで依存構造を辿ることができます。

    def find_from_children(self, tok, poses_):
        """
        tokの子要素から再帰的にposes_にマッチするトークンを探す
        """
        if tok.pos_ in poses_:
            return tok  # 見つかった

        for t in tok.children:  # 依存構造における子要素を参照する
            found = self.find_from_children(t, poses_)  # 再帰的に検索
            if found:
                return found  # 見つかった

        return None  # 見つからなかった

find_from_head()で親要素からトークンを探す

find_from_head()は引数tokの親要素(head)を参照し、poses_にマッチしていたらその親要素を返すメソッドです。
トークンのhead属性はspacy.tokens.token.Tokenで、依存構造上における親のトークンになります。
矢印の向きの反対側のトークンですね。

poses_は品詞のラベルの入ったリスト(またはタプル)です。

このメソッドは再帰的な呼び出しはしていません。
今回の実装では必要ありませんでした。

    def find_from_head(self, tok, poses_):
        """
        tokの親要素がposes_にマッチしたら親要素を返す
        """
        if tok.head.pos_ in poses_:
            return tok.head  # マッチした

        return None  # マッチしなかった

テストケースを書く

テストケースは↓のようになります。
このテスト内容が実行されます。

Pythonのunittestモジュールを使って単体テストを書くにはunittest.TestCaseを継承したクラスを作ります。
そしてそのクラスにtest_で始まるメソッドを定義します。このtest_で始まるメソッドがテストの実行で実行されるメソッドになります。
↓の例でいればtest_analyze()がそうで、これがテスト時に実行されます。

eq()メソッドはテストの便利ツールで、引数abを比較するメソッドです。
内部では引数aAnalyzeranalyze()で解析しています。
そしてその結果のcbと比較します。
assertEqual()TestCaseのメソッドで、第1引数と第2引数が違う場合はエラーを送出します。

test_analyze()ではこのeq()を使ってテストケースを書いています。

テストの量的にはまだ改善の余地があるテスト量です。
というか、自然言語処理のテストは自然が相手なので網羅的に書ききるのはちょっと難しいという印象です。

語彙が足りない

テストには語彙力が必要

class Test(unittest.TestCase):
    def eq(self, a, b):
        an = Analyzer()
        c = an.analyze(a)
        self.assertEqual(c, b)

    def test_analyze(self):
        self.eq('北海道に行く', '北海道')
        self.eq('北海道へ行く', '北海道')
        self.eq('友人と北海道に行く', '北海道')
        self.eq('北海道に友人と一緒に行く', '北海道')
        self.eq('行こう北海道', '北海道')
        self.eq('行くぞ北海道', '北海道')
        self.eq('向こうへ行こう', '向こう')
        self.eq('向こう行こう', '向こう')
        self.eq('あっちに行こう', 'あっち')
        self.eq('あっち行こう', 'あっち')
        self.eq('そろそろ北海道へ行こう', '北海道')
        self.eq('これから北海道に行きたい', '北海道')
        self.eq('北海道に行くのか?', '北海道')
        self.eq('沖縄に向かう', '沖縄')
        self.eq('寒いので沖縄に行こうか', '沖縄')
        self.eq('沖縄へ', '沖縄')
        self.eq('沖縄', '沖縄')
        self.eq('こっち', 'こっち')
        self.eq('こっちへ', 'こっち')
        self.eq('こっちに', 'こっち')

おわりに

今回はspaCyを使って日本語の文章から「場所」を抽出してみました。
ユーザーがどこかに行きたいときに使えば行きたい場所がわかるので、その場所に応じたテキストを返すなどの応用が考えられます。

どこに行きたい?

ラスベガス!

投稿者名です。64字以内で入力してください。

必要な場合はEメールアドレスを入力してください(全体に公開されます)。

投稿する内容です。