ユーニックス総合研究所

  • home
  • archives
  • spacy-what

spaCyで「~って何?」に回答するスクリプトを作る【自然言語処理, Python】

spaCyで「~って何?」に回答する

我々が話す言葉は「自然言語」と呼ばれます。
この自然言語をプログラム的に解析することを「自然言語処理」と言います。

spaCy(スパイシー)というPythonの自然言語処理ライブラリを使うと簡単に日本語を処理することができます。
今回はこのspaCyを使って「~って何?」という質問に回答するスクリプトをルールベースで作ってみたいと思います。

具体的には↓を見ていきます。

  • spaCyとは?
  • スクリプトの実行結果
  • スクリプトの設計
  • スクリプトのソースコード
  • ソースコードの解説

spaCyとは?

spaCy(スパイシー)とはPythonとCythonで作られたPythonの自然言語処理ライブラリです。
オープンソースで開発されていて、MITライセンスで利用することができます。
さまざまな言語の統計モデルをあらかじめ利用することができます。

最近になってGiNZA(ギンザ)という日本製の自然言語処理ライブラリが登場し、これのモデルがspaCyに組み込まれました。
GiNZAは日本語の形態素解析、構文解析を行うことができるライブラリで、spaCyもこのGiNZAを利用することで日本語の処理が強化されました。

spaCy, GiNZAともこれからの日本語の自然言語処理で活躍しそうな雰囲気のあるライブラリです。
要チェックしておきましょう。

🦝 < GiNZAに類似するライブラリは……

🐭 < もちろんSHiBUYA

スクリプトの実行結果

今回のスクリプトの動作検証は単体テストで行っています。
単体テストにはPythonの標準ライブラリであるunittestを使います。
今回のスクリプトのソースコードをsample.pyなどのファイルに保存し、↓のようにコマンドを実行します。

$ python -m unittest sample  

すると↓のような出力が出ます。

.  
----------------------------------------------------------------------  
Ran 1 test in 0.806s  

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('本日は晴天なり', None)  
        self.eq('何ですか', None)  

        self.eq('肉って何ですか?', '肉は食べ物です')  
        self.eq('ねぇ、肉ってなに?', '肉は食べ物です')  
        self.eq('鹿って何だよ?', '鹿は動物です')  
        self.eq('あのー、鹿って何ですか?', '鹿は動物です')  

↑のテストケースを見ると「肉って何ですか?」という入力に対して「肉は食べ物です」という出力が得られているのがわかります。
また「あのー、鹿って何ですか?」という入力に対しては「鹿は動物です」と出力しています。

今回のスクリプトはこのように「~って何?」とか「~ってなんですか?」という問いかけに対して答えを生成するスクリプトです。

スクリプトの設計

繰り返しになりますが、スクリプトの動作検証は単体テストで行います。
これはPythonの標準ライブラリであるunittestを使います。

スクリプトのメインの処理を行うのはAnalyzerというクラスです。
このクラスにanalyze()というメソッドを定義し、このメソッドに入力となる文字列を渡したら、それを解析して回答を生成して返すという具合です。

analyze()は内部でspaCyを利用します。
spaCyで入力となる文字列(日本語の文章、質問文)を解析し、これをspaCyのオブジェクトであるトークン列に変換します。

文章をspaCyのトークン列に変換したら、このトークン列をfor文で走査します。
そして「何」を原形に持つトークンを見つけたら、そのトークンを入り口にして解析を分岐します。

トークン列から「何」のトークンに係っているトークン(名詞)を探して、そのトークンを見つけたらそのトークンのテキストから意味データベースを参照します。
意味データベースには単語の意味が保存されています。このデータベースを参照することで単語の意味を解析器に理解させます。
これはいわゆるルールベースと呼ばれる自然言語処理のアプローチです。ルール(辞書)を参照して、そのルールに当てはまるように処理を行います。

トークンの意味が判明したら、その意味にもとづいて結果となるテキストを生成します。
あとはそのテキストを返せば終わりです。

基本的な設計は↑のようになります。

🦝 < シンプル・イズ・ベスト

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

今回作成するスクリプトは↓になります。

"""  
「~って何?」に回答するスクリプト  

License: MIT  
Created at: 2021/02/02  
"""  
import spacy  
import unittest  


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


class Analyzer:  
    """  
    「~って何?」に回答するアナライザー  
    """  
    def __init__(self):  
        # 意味データベース  
        self.imi_db = {  
            '肉': {  # 肉 is 食べ物  
                'is': '食べ物',  # is-a の関係で表現されるテキストが保存される  
            },  
            '鹿': {  # 鹿 is 動物  
                'is': '動物',  
            }  
        }  

    def analyze(self, text):  
        """  
        引数textを解析して「~って何?」の質問に答える  
        """  
        doc = nlp(text)  
        return self.analyze_nani(doc)  

    def analyze_nani(self, doc):  
        # トークン列を走査する  
        for tok in doc:  
            if tok.lemma_ == '何':  # 原形で「何」が見つかった  
                target = self.find_target(tok)  # 「何」に係っているトークンを探す  
                if not target:  
                    continue  # 見つからなかった  

                imi = self.grep_imi(target.text)  # トークンの意味を求める  
                if not imi:  
                    continue  # 見つからなかった  

                # 意味が見つかったのでテキストを整形して返す  
                return f'{target.text}は{imi["is"]}です'  

        return None  

    def find_target(self, tok):  
        """  
        「何」に係っているトークンを探す  
        """  
        for child in tok.children:  
            if child.pos_ == 'NOUN':  # 名詞が見つかったので  
                return child  # それを返す  
        return None  # 見つからなかった  

    def grep_imi(self, text):  
        """  
        引数textの意味を求める  
        """  
        if text in self.imi_db.keys():  
            return self.imi_db[text]  
        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('本日は晴天なり', None)  
        self.eq('何ですか', None)  

        self.eq('肉って何ですか?', '肉は食べ物です')  
        self.eq('ねぇ、肉ってなに?', '肉は食べ物です')  
        self.eq('鹿って何だよ?', '鹿は動物です')  
        self.eq('あのー、鹿って何ですか?', '鹿は動物です')  

ソースコードの解説

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

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

スクリプトの冒頭で必要となるモジュールをインポートしておきます。
spaCyを使うにはspacyモジュールをインポートします。
それから単体テスト用にunittestをインポートします。

import spacy  
import unittest  

GiNZAのモデルをロードする

spaCyでは利用前に学習済みの統計モデルをロードして使います。
今回は日本語の解析のためにGiNZAのモデルを利用します。
↓のようにspacy.load()ja_ginzaを指定するとGiNZAのモデルをロードできます。
ja_ginzaをロードした場合、spacy.load()ginza.Japaneseを返します。
このspacy.load()の返り値には慣例的にnlpと命名するようになっています。
このnlpに文字列を渡すことで解析を実行することが可能です。

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

Analyzerクラスを定義する

今回のメインの処理はAnalyzerクラスに実装します。
Analyzerには__init__()メソッドを定義します。
このメソッド内で意味データベースを初期化します。

意味データベースは仰々しい名前ですが、他にこの名前を使ってる所があるのかはしりません。
意味データベースは内容的にはただのPythonの辞書です。
↓のように単語をキーにしてその意味を辞書で定義します。
↓の例で言えば、「肉」という単語の意味は「is」が「食べ物」になります。
このisというのは、「~は~です」というフォーマットで利用されるテキストになります。
「肉」の場合はisが「食べ物」なのでこれから生成される文章は「肉は食べ物です」になります。

意味データベースに登録されていない単語には非対応になります.
この意味データベースが充実すれば生成できる回答も充実しますが、手作業でこれを整えるのはかなり大変かと予想できます。

class Analyzer:  
    """  
    「~って何?」に回答するアナライザー  
    """  
    def __init__(self):  
        # 意味データベース  
        self.imi_db = {  
            '肉': {  # 肉 is 食べ物  
                'is': '食べ物',  # is-a の関係で表現されるテキストが保存される  
            },  
            '鹿': {  # 鹿 is 動物  
                'is': '動物',  
            }  
        }  

analyze()で入力文章を解析する

analyze()メソッドは引数textの文章を解析して、回答となるテキストを返り値で返します。
nlp()にテキストを渡すと、spaCyによる解析が実行され、その結果をdocとして返します。
このdocspacy.tokens.doc.Docになります。
このdocはトークン列を抽象化したオブジェクトです。そのためfor文などで回すことができます。

docを生成したらanalyze_nani()メソッドに処理を任せて終わりです。

    def analyze(self, text):  
        """  
        引数textを解析して「~って何?」の質問に答える  
        """  
        doc = nlp(text)  
        return self.analyze_nani(doc)  

analyze_nani()でトークン列を解析する

analyze_nani()メソッドは引数docを解析して回答となるテキストを返すメソッドです。
docfor文で回せるのでfor tok in doc:とやって回します。
このときのtokspacy.tokens.token.Tokenになります。

spaCyは日本語の文章を解析すると、トークン列に変換します。
これは形態素解析と構文解析の結果のトークン列です。

形態素解析と言うのは簡単に言うと文章を単語のリストに変換する解析です。
たとえば「犬が笑う」という文章を形態素解析すると「犬 / が / 笑う」という単語列になります。
このとき、単語(トークン)にその単語の持つ情報を保存します。
情報とは単語の読み方や単語の原形などのことです。

この形態素解析が終わると構文解析がはじまります。
構文解析は単語と単語の関係を依存構造で構築する解析です。
この解析によってどの単語がどの単語に係(かか)っているかと言うのがプログラム的に解析できるようになります。
たとえば「猫が鳴く」という文章を解析すると↓のような依存構造になります。

猫   NOUN ═╗<╗ iobj  
が   ADP  <╝ ║ case  
鳴く VERB ═══╝ ROOT  

「鳴く」という単語から矢印が伸びて「猫」に係っているのがわかります。
どの単語がどの単語にかかっているかがわかれば、単語から関係性のある単語を辿ることが可能になります。
これは意味解析以上の自然言語処理で必要となる情報です。

analyze_nani()はトークン列を走査し、トークンのlemma_属性を参照します。
このlemma_属性は単語の「原形」を表す属性です。
原形とはたとえば「鳴いた」の「鳴い」の原形は「鳴く」です。それから「立った」の「立っ」の原形は「立つ」です。

lemma_が「何」だったらそのトークンをキーにして処理を分岐します。
そのトークンに係っているトークンをfind_target()メソッドで検索します。
係っているトークン(ターゲット)が見つかったら、そのターゲットのtext属性を参照します。
トークンのtext属性は元の文章のそのままの表記の文字列が保存されています。
そのtextgrep_imi()メソッドに渡して意味データベースからトークンの意味を求めます。

意味が見つかったらその情報とトークンの情報を元に回答となる文章を生成してreturnします。

    def analyze_nani(self, doc):  
        # トークン列を走査する  
        for tok in doc:  
            if tok.lemma_ == '何':  # 原形で「何」が見つかった  
                target = self.find_target(tok)  # 「何」に係っているトークンを探す  
                if not target:  
                    continue  # 見つからなかった  

                imi = self.grep_imi(target.text)  # トークンの意味を求める  
                if not imi:  
                    continue  # 見つからなかった  

                # 意味が見つかったのでテキストを整形して返す  
                return f'{target.text}は{imi["is"]}です'  

        return None  

find_target()で係っているトークンを探す

find_target()メソッドは引数tokに係っているトークン(名詞)を探します。
トークンのchildren属性にはそのトークンに係っているトークン列が保存されています。
つまりspacy.tokens.token.Tokenのリスト(正確にはジェネレーター)です。

このchildren属性をfor文で回し、そのトークンのpos_属性を調べます。
pos_属性はトークンの品詞を表す文字列が保存されています。
このpos_属性がNOUNだった場合はそのトークンは名詞になります。

名詞のトークンが見つかったらそのトークンをreturnします。
見つからなかったらNoneを返します。

    def find_target(self, tok):  
        """  
        「何」に係っているトークンを探す  
        """  
        for child in tok.children:  
            if child.pos_ == 'NOUN':  # 名詞が見つかったので  
                return child  # それを返す  
        return None  # 見つからなかった  

grep_imi()で意味を求める

grep_imi()は引数textをキーにしてその文字列の意味を求めるメソッドです。
内容的にはtextをキーにしてimi_dbを参照します。
imi_db内にデータがあればそれを返し、無ければNoneを返します。

    def grep_imi(self, text):  
        """  
        引数textの意味を求める  
        """  
        if text in self.imi_db.keys():  
            return self.imi_db[text]  
        return None  # 意味が見つからなかった  

テストケースを書く

テストケースを書きます。
unittestを使ったテストケースを書くにはunittest.TestCaseを継承したクラスを作ります。
そしてtest_で始まるメソッドを定義します。このメソッドがテスト時に実行されます。

eq()メソッドは引数abを比較するメソッドです。
内部ではAnalyzerを使って引数aを解析し、その結果のcb比較しています。
assertEqual()は第1引数と第2引数が違う場合エラーを出します。
エラーが出るとテストに失敗します。

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('本日は晴天なり', None)  
        self.eq('何ですか', None)  

        self.eq('肉って何ですか?', '肉は食べ物です')  
        self.eq('ねぇ、肉ってなに?', '肉は食べ物です')  
        self.eq('鹿って何だよ?', '鹿は動物です')  
        self.eq('あのー、鹿って何ですか?', '鹿は動物です')  

おわりに

今回はspaCyを使って「~って何?」という問いかけに答えるスクリプトを作ってみました。
人工知能などでユーザーの問いかけに答えるAIというのは需要が高いジャンルかと思いますが、今回はそれを目指してやってみました。
何かの参考になれば幸いです。

🦝 < 自然言語処理 is 何?

🐭 < ぼっちを救う科学