KNPを用いた自然言語処理の一例

この記事は [自然言語処理 #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/nlp2) の8日目の記事です。

この一年BERTやらXLNetやら、色々と面白いものがでてきていますね。
とはいえ泥臭い自然言語処理もなかなか楽しいものです。
今回はKNPを利用した構文解析についてお話できればと思います。

と思ってつらつら記事を書いていたんですが、
「これ公式のREADME読めば解決するでしょ」みたいな話ばっかりになってしまったので、全部消しました。

せっかくなので、juman(++)、knp、pyknpあたりは一応使える状態を前提として、
もう少し発展的な文法ベースの言語処理の一例として、係り受け構造の解釈事例をご紹介をしてみようと思います。
以下、CUI上でもパイプやら駆使すれば実現できるかもしれませんが、すべてpyknpを利用してお話します。

# 係り受け解析
はじめに、日本語における係り受け解析では以下のような仮定を前提として語られることが多く、今回もそれに倣います。

- 形態素間ではなく文節間の係り受けを考える
- 前の文節が後ろの文節に係る
- 各文節は必ず1つの文節に係る（木構造制約）
  - ただし最後の文節はどの文節にも係らない
- 係り受けは交差しない（非交差制約）

また、これとは別に格関係を決定するプロセスが存在します（格解析）が、KNPはこれも勝手にやってくれます。すごい！

実例を見てみましょう。

```python
from pyknp import KNP

knp = KNP(jumanpp=True)
knp_result = knp.parse('お婆さんは川へ洗濯に行きました。')
```

このknp_resultは `pyknp.BList` オブジェクトで、`pyknp.BList.bnst_list()` により `Bunsetsu` オブジェクトを取り出すことができます。

```python
bnst_list = knp_result.bnst_list()
print(type(bnst_list))
>> typing.List[pyknp.Bunsetsu]
```

`Bunsetsu` オブジェクトは更にメンバ変数として構文情報を複数保持しています

- `Bunsetsu.bnst_id`: 当該`Bunsetsu`を示すindex
- `Bunsetsu.parent_id`: 当該`Bunsetsu`が係る（主辞）`Bunsetsu`を示すindex
  - `Bunsetsu.parent`: 上記`parent_id`をindexにもつ`Bunsetsu`オブジェクト
- `Bunsetsu.children`: 当該`Bunsetsu`に係る（子）`Bunsetsu`オブジェクトのリスト
- `Bunsetsu.fstring`: feature文字列（格解析結果を保持している）

このあたりの情報をうまく使うことで、係り受け情報を用いた分析等を行うことができます。

# ユースケース
一例として、SNSの書き込みからどんな本がよく読まれているかを調べたい、というようなユースケースを考えてみます。
もちろん様々な手法が考えられますが、原始的には係り受け情報を用いたルールベースでの抽出が考えられそうです。
まずはそもそも本に言及するツイートであるかどうかを判別したくなります。
この場合、以下のような仮定をもとにルールを記述することができそうです。

- 「○○を読んだ」というように、動詞「読む」の過去形に「ヲ格」で係る名詞が書籍名？
- 日本語では述語が末尾の文節に来ることが多い

この場合、まずは文節を末尾から順に、動詞が出現するまでチェックしていくということができそうです。

```python
from typing import List

def contains_read(bnst_list: List[pyknp.Bunsetsu]) -> bool:
"""
用言が動詞「読む」の過去形であるかどうかを判定します。
"""
    for bnst in reversed(bnst_list):  # 末尾から取り出す
        if '<用言:動>' in bnst.fstring:
            if '<正規化代表表記:読む/よむ>' in bnst.fstring and '<時制:過去>' in bnst.fstring:
                return True
            break
    return False

knp_result = knp.parse('昨日は自然言語処理シリーズの「対話システム」を読んだ。')
print(contains_read(knp_result.bnst_list()))
>> True
```

非常に雑で申し訳ないですが、まずは第１段階のフィルタリングが済ませられそうです。

次は「何を」読んだのか、を取りたいと考えます。
一番かんたんな例だと、「読んだ」という用言に直接書籍名が係る場合が考えられます。
日本語における動詞の動作の対象を表す目的格は原則として「ヲ格」です。（一部、「二格」をとることができるものなどの例外はあります）
特に「読む」は基本的には動詞の動作の対象を表す目的格として「ヲ格」しか取らないため、今回はこの文節に「ヲ格」で係る文節を取り出せれば良さそうです。
言い換えると、以下の２条件を満たすような文節を探せば良さそうです。

- 「読んだ」の文節に直接係る子文節である
- その子文節が「ヲ格」である

先程作った `contains_read` メソッドを `extracts_book_bunsetsu`メソッドに書き換えて見ましょう

```python
from typing import Optional

def extracts_book_bunsetsu(bnst_list: List[pyknp.Bunsetsu]) -> Optional[pyknp.Bunsetsu]:  # Bunsetsu or Noneを返す
"""
用言が動詞「読む」の過去形であるかどうかを判定し、そうである場合にその文節に直接「ヲ格」でかかる文節を返します。
用言が条件を満たさない、もしくは直接「ヲ格」でかかる子文節が存在しない場合はNoneを返します。
"""
    for bnst in reversed(bnst_list):  # 末尾から取り出す
        if '<用言:動>' in bnst.fstring:
            if '<正規化代表表記:読む/よむ>' in bnst.fstring and '<時制:過去>' in bnst.fstring:
                for bnst_c in bnst.children:
                    if '<係:ヲ格>' in bnst_c.fstring:
                        return bnst_c
            break
    return None

knp_result = knp.parse('昨日は自然言語処理シリーズの「対話システム」を読んだ。')
book_bunsetsu = extract_book_bunsetsu(knp_result.bnst_list())
if book_bunsetsu is not None:
    print(book_bunsetsu.midasi)

>> '「対話システム」を'
```

ネストが深くなってひどいですね。説明のためなので許してください。
このように「読む」に直接係る子文節を `childen` で取り出した上でヲ格である文節を調べればよさそうですね。
判定時に「正規化代表表記」を見ているのは、原文が「読んだ」でも「よんだ」でもマッチできるようにするためです。

厳密には「対話システム」だけが取り出したいのですが、そこは以下のような追加のルールによって簡単に取り除くことができますので試してみて下さい。

- この文節は「ヲ格」であることが確定しているので、純粋に末尾の「を」という文字を取り除く
- より厳密に行うなら`Bunsetsu.mrph_list()` などで形態素ごとに詳しくチェックし、取り出す

などが考えられます。

また今回のように述語とそれに係る語（項といいます）の関係性を「述語項構造」といいます。
冗長な表現が多用される文章では、述語項構造に着目することで簡略化して扱うことができます。

# 考察
さて、とりあえずそれっぽいアルゴリズムができあがりました。とはいえもちろん万能ではありません。
うまくいかない例として、例えば以下のような文が考えられます

- 昨日は自然言語処理シリーズの「対話システム」を買って来て読んだ。
- 対話システムを読んだが、まるで他分野の論文を読んだかのようだった。

前者は「対話システム」が「読む」ではなく「買う」に係る例で、後者は読むが複数出現する例になります。
前者の場合、「読んだ」に直接係る文節は「昨日は」と「買って来て」のみとなるため抽出に失敗してしまいます。
後者の場合、より末尾に近い「読んだ」に係る「論文を」が取れてしまいますが、実際に読んだのは論文ではなく対話システムという本ですね。

前者については、「買って来て」が「読んだ」に係る連用節であることなどをもとに、再帰的にチェックしていくことで解決可能かもしれません。
また、後者はその行為が事実であるかどうかに関する書き手の判断を評価する「事実性解析」が必要になるかもしれません。
これもKNPがfeatureとして付与する情報（ex. `<モダリティ-認識-証拠性>`）を利用したりするとある程度解決可能ですが、ここでは割愛します。

いずれにせよ、単純な係り受けに関する情報だけでも一定のルールは記述可能で、
かつKNPの付与する豊富なfeatureを活用すると、様々なルールを記述できそうだとおわかりいただけたと思います。


# あとがき
近い将来、これらの課題もすべて汎用言語モデルによって解決されることになるかもしれませんが、
やはり文法にもとづくルールベースでの記述は（強力な言語モデルによってルール自体が不要となるまでの間）まだまだ現役であると考えられます。

特に、機械学習・深層学習の発展により誰でも簡単に固有表現抽出や文書分類などを試すことができるようになりましたが、
このようないわゆる古典的自然言語処理のアルゴリズムを適切に実装できる人が相対的に少なくなり、その分重要性は増しているのではないかとさえ思っています。

KNPは非常に豊富な情報を付与してくれるため、触っているだけでもとてもおもしろいツールです。
ぜひみなさんも使ってみてください。