ユーニックス総合研究所

  • home
  • archives
  • spacy-my

spaCyで文章から「私の~」を抽出する【自然言語処理, Python】

spaCyで「私の~」を抽出

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

Pythonには自然言語処理ライブラリであるspaCyがあり、今回はこれを使って日本語の文章から「私の~」や「僕の~」を抽出してみたいと思います。

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

  • spaCyとは?
  • プログラムの設計
  • プログラムの実行結果
  • プログラムのソースコード
  • ソースコードの解説

spaCyとは?

spaCyとはPython製の自然言語処理ライブラリです。
MITライセンスでオープンソースソフトウェアとして開発されています。
多数の自然言語の学習済みモデルを搭載していて、簡単に自然言語を統計モデルを使って解析することができます。

日本語の文章解析でspaCyと一緒によく使われるのがGiNZAです。
GiNZAはリクルートと国立国語研究所が共同開発した自然言語処理ライブラリです。
spaCyは日本語の文章の解析ではGiNZAのモデルを使用します。
つまりspaCyはGiNZAの提供する機能を使って一部の解析を行います。

プログラムの設計

今回作成するプログラムの設計についてです。

プログラムは標準入力から日本語の文章を読み取って、その入力を解析器に渡します。
そして解析器は文章の中から「私の~」や「僕の~」を抽出して文字列として返します。
解析器が返した文字列を画面に出力してまた処理先頭からループします。

入力文章の解析ではspaCyを使います。
spaCyでGiNZAのモデルをロードし、そのモデルを使って文章を解析します。
解析した結果からトークン列を読みだして、そのトークン列の中に名詞のトークンがあるか調べます。
名詞のトークンが見つかったら、その名詞に係っているトークン列を探し、そのトークン列に「私の~」が含まれているか調べ、それらのトークン列を保存します。
トークン列を保存したら、それを元に文字列を生成して、関数からreturnします。

基本的にはこれだけの設計です。
設計上、名詞に係っていない「私の」などは抽出できなくなってます。
気になる方は改造してみてください。

プログラムの実行結果

今回作成するプログラムを実行すると↓のような結果になります。

$ python sample.py  
日本語の文章を入力してください > 私の犬  
私の犬  
日本語の文章を入力してください > 私は猫  
None  
日本語の文章を入力してください > 林の中を歩く私の可愛い猫  
私の猫  
日本語の文章を入力してください >  

プログラムを実行すると最初に「日本語の文章を入力してください >」というプロンプトが表示されます。
このプロンプトに「私の犬」や「林の中を歩く私の可愛い猫」と入力すると、その文章から「私の~」を抽出します。
↑の例では、「私の犬」の入力から「私の犬」が抽出され、「林の中を歩く私の可愛い猫」から「私の猫」が抽出されているのがわかります。

プログラムのソースコード

今回作成したプログラムのソースコードです。
プログラムを実行するには↓のコードをsample.pyなどに保存します。
そしてpython sample.pyを実行してください。
テストを実行したい場合はpython -m unittest sampleで実行します。

"""  
日本語の文章から「私の~」や「僕の~」を抽出するスクリプト  

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


nlp = spacy.load('ja_ginza')  


def collect_children(toks, tok):  
    """  
    子供のトークン列を集める  
    """  
    for t in tok.children:  
        toks.append(t)  
        collect_children(toks, t)  


def find_watasi_no(toks):  
    """  
    「代名詞 + 'の'」の組み合わせを探し、そのトークン列を返す  
    """  
    for t in toks:  
        # 格表示の親が代名詞なら  
        if (t.dep_ == 'case' and t.text == 'の') and t.head.pos_ == 'PRON':   
            return [t.head, t]  
    return []  


def grep_watashi_no(doc):  
    """  
    docから「私の~」を抽出する  
    """  
    for tok in doc:  
        if tok.pos_ == 'NOUN':  
            childs = []  
            collect_children(childs, tok)  
            toks = find_watasi_no(childs)  
            if not len(toks):  
                continue  
            toks.append(tok)  
            return ''.join([t.text for t in toks])  

    return None  


def main():  
    while True:  
        try:  
            sentence = input('日本語の文章を入力してください > ')  
        except (KeyboardInterrupt, EOFError):  
            break  

        doc = nlp(sentence)  
        result = grep_watashi_no(doc)  
        print(result)  


if __name__ == '__main__':  
    main()  


class Test(unittest.TestCase):  
    def eq(self, a, b):  
        doc = nlp(a)  
        c = grep_watashi_no(doc)  
        self.assertEqual(c, b)  

    def test_grep(self):  
        # 期待通りのケース  
        self.eq('私の犬', '私の犬')  
        self.eq('私は犬', None)  
        self.eq('僕の猫', '僕の猫')  
        self.eq('僕のペルシャ猫', '僕の猫')  
        self.eq('僕の巨大猫', '僕の猫')  
        self.eq('拙者の鳥', '拙者の鳥')  
        self.eq('私の犬と猫', '私の犬')  
        self.eq('私のかわいい犬', '私の犬')  
        self.eq('林の中を歩いている私の犬', '私の犬')  
        self.eq('かわいらしい私の犬と一緒に歩く', '私の犬')  

        # うまく行かないケース  
        self.eq('相棒と私の頼りになる猫', '私の頼り')  

ソースコードの解説

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

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

最初に必要モジュールをインポートします。
今回は解析用にspacyを使うのでこれをインポートします。
それから単体テスト用にunittestもインポートしておきます。

import spacy  
import unittest  

モデルのロードとnlpの作成

グローバル変数nlpを作成します。
これはspacyでモデルをロードした結果が入ります。
モデルはspacy.load()ja_ginzaをロードします。
こうすることでspaCyでGiNZAのモデルを使えるようになります。
spacy.load()の返り値はja_ginzaをロードした場合、ginza.Japaneseクラスが返ってきます。

nlp = spacy.load('ja_ginza')  

main関数の作成

プログラムはmain関数から始まります。
内容的には標準入力をinput()で読み取って、その内容をnlp()に渡します。
nlp()に文章を渡すと、その文章が解析されdocというオブジェクトになります。
このdocspacy.tokens.doc.Docクラスのオブジェクトです。

docのメソッドや属性を使うことによって、解析した結果を参照することが出来るようになります。
このdocgrep_watashi_no()関数に渡して、「私の~」を抽出します。
grep_watashi_no()は抽出に成功すると文字列を返してくるので、その文字列をprint()で出力してループ内の一連の処理は完了です。

if文で__name__を調べてmain()を実行してます。
__name____main__の場合はコマンドラインからこのスクリプトが実行されていることを表します。
テストの実行と区別をつけるためこのif文を書いています。

def main():  
    while True:  
        try:  
            sentence = input('日本語の文章を入力してください > ')  
        except (KeyboardInterrupt, EOFError):  
            break  

        doc = nlp(sentence)  
        result = grep_watashi_no(doc)  
        print(result)  


if __name__ == '__main__':  
    main()  

grep_watashi_no()で「私の~」を抽出

grep_watashi_no()docから「私の~」という文章を抽出する関数です。
docfor文に渡すと、トークン列を取得できます。
そしてその中のトークンのpos_属性を調べます。
pos_が文字列のNOUNだった場合、そのトークンが名詞ということになります。

spaCyは文章を解析すると、このように文章をトークン列に分割します。
トークン列と言うのは単語のリストのことです。
たとえば「私の猫」という文章だったら、「私 / の / 猫」という単語のリストに分割されます。

名詞のトークンが見つかったらcollect_children()でそのトークンの子供のトークン列を集めます。
その集めたトークン列(childs)をfind_watashi_no()に渡して、「私の」や「僕の」の形のトークン列を抽出します。
そのトークン列が存在していたら、そのトークン列の末尾に名詞のトークンを追加します。
あとは''.join(...)でトークン列内のtext属性を参照し、1つの文字列に整形します。
トークンのtext属性はそのトークンが持つ文章上の表示の文字列です。

def grep_watashi_no(doc):  
    """  
    docから「私の~」を抽出する  
    """  
    for tok in doc:  
        if tok.pos_ == 'NOUN':  
            childs = []  
            collect_children(childs, tok)  
            toks = find_watasi_no(childs)  
            if not len(toks):  
                continue  
            toks.append(tok)  
            return ''.join([t.text for t in toks])  

    return None  

collect_children()で子トークンを集める

collect_children()はトークンのchildren属性を参照し、そのトークンを再帰的にtoksに保存します。
children属性はトークン列です。これはspaCyの依存関係構造における子要素のことです。

def collect_children(toks, tok):  
    """  
    子供のトークン列を集める  
    """  
    for t in tok.children:  
        toks.append(t)  
        collect_children(toks, t)  

find_watashi_no()で「私の」トークン列を探す

find_watasi_no()にトークン列を渡すと、関数はその中から「私の」の形のトークン列を返します。
見つからなかったら空のリストを返します。

「私の」にマッチする条件はトークンのdep_属性がcaseであること、かつtext属性が「の」であること、かつトークンの親(head)のpos_属性がPRON(代名詞)であることです。
dep_属性というのはspaCyで文章を解析したときに生成される、単語や句、節などの係り受けの関係性を表すラベルです。
このラベルがcaseであるとき、そのトークンは「格表示」のトークンになります。

トークンのpos_属性はそのトークンの品詞を表します。
pos_PRONの場合はそのトークンは名詞になります。

トークンの依存構造上における親はトークンの属性headで参照することができます。
↓のif文を日本語で表現すると「現在のトークンが『の』で、その親のトークンが代名詞だったらTrue」という内容になります。
つまり「私の」という文章は「の」の親のトークンが「私」なわけで、「私」の品詞は代名詞ってことになります。

def find_watasi_no(toks):  
    """  
    「代名詞 + 'の'」の組み合わせを探し、そのトークン列を返す  
    """  
    for t in toks:  
        # 格表示の親が代名詞なら  
        if (t.dep_ == 'case' and t.text == 'の') and t.head.pos_ == 'PRON':   
            return [t.head, t]  
    return []  

テストを書く

今回はPythonのunittestモジュールを使って簡単な単体テストを書きます。
↓がテスト内容です。

unittestを使って単体テストを書くには↓のようにunittest.TestCaseを継承したクラスを作ります。

eq()メソッドは引数abがイコールになるか判定します。
イコールにならなければテストに失敗します。
eq()の内部ではnlp()で引数aを解析して、その結果をgrep_watashi_no()に渡しています。
そしてgrep_watashi_no()の返り値cを引数bと比較します。
比較にはTestCaseのメソッドassertEqual()を使います。

python -m unittest sampleなどでテストを実行したときに実行されるメソッドはtest_grep()です。
test_という接頭辞をメソッド名につけるとそのメソッドがテストされます。

class Test(unittest.TestCase):  
    def eq(self, a, b):  
        doc = nlp(a)  
        c = grep_watashi_no(doc)  
        self.assertEqual(c, b)  

    def test_grep(self):  
        # 期待通りのケース  
        self.eq('私の犬', '私の犬')  
        self.eq('私は犬', None)  
        self.eq('僕の猫', '僕の猫')  
        self.eq('僕のペルシャ猫', '僕の猫')  
        self.eq('僕の巨大猫', '僕の猫')  
        self.eq('拙者の鳥', '拙者の鳥')  
        self.eq('私の犬と猫', '私の犬')  
        self.eq('私のかわいい犬', '私の犬')  
        self.eq('林の中を歩いている私の犬', '私の犬')  
        self.eq('かわいらしい私の犬と一緒に歩く', '私の犬')  

        # うまく行かないケース  
        self.eq('相棒と私の頼りになる猫', '私の頼り')  

↑のテスト内容を見ると、「期待通りのケース」ではうまくテストがいってますが、「うまく行かないケース」のテストはあんまり期待した結果になっていません。
↑の場合「相棒と私の頼りになる猫」は「私の猫」という出力を期待したいところですが、実際には出力は「私の頼り」になります。
これは「頼り」という単語が名詞扱いになるためです。
ネットの辞書でも「頼り」は名詞という扱いです。

🦝 < 「頼り」が名詞とは

🐭 < 意外やね

おわりに

今回はspaCyを使って日本語の文章から「私の~」を抽出してみました。
モノの所有関係を要約して表示できるプログラムになりましたが、今のところ使いどころは浮かびません。

🦝 < うーん、考え付いた人はコメントください

🐭 < モノの所有関係の要約ねぇ