言語処理100本ノック-45:動詞の格パターンの抽出

[言語処理100本ノック 2015](http://www.cl.ecei.tohoku.ac.jp/nlp100/)[「第5章: 係り受け解析」](http://www.cl.ecei.tohoku.ac.jp/nlp100/#ch5)の[45本目「動詞の格パターンの抽出」](http://www.cl.ecei.tohoku.ac.jp/nlp100/#sec45)記録です。
`if`の条件分岐も増え、少しずつ複雑になっています。アルゴリズム考えるのがやや面倒です。

# 参考リンク

|リンク|備考|
|:--|:--|
|[045.動詞の格パターンの抽出.ipynb](https://github.com/YoheiFukuhara/nlp100/blob/master/05.%E4%BF%82%E3%82%8A%E5%8F%97%E3%81%91%E8%A7%A3%E6%9E%90/045.%E5%8B%95%E8%A9%9E%E3%81%AE%E6%A0%BC%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3%E3%81%AE%E6%8A%BD%E5%87%BA.ipynb)|回答プログラムのGitHubリンク|
|[素人の言語処理100本ノック:45](https://qiita.com/segavvy/items/2f8387a5973a9966a12d)|多くのソース部分のコピペ元|
|[CaboCha公式](https://taku910.github.io/cabocha/)|最初に見ておくCaboChaのページ|

# 環境
CRF++とCaboChaはインストールしたのが昔すぎてインストール方法忘れました。全然更新されていないパッケージなので、環境再構築もしていません。CaboChaをWindowsで使おうと思い、挫折した記憶だけはあります。確か64bitのWindowsで使えなかった気がします(記憶が曖昧だし私の技術力の問題も多分にあるかも)。

|種類|バージョン|内容|
|:--|:--|:--|
|OS|Ubuntu18.04.01 LTS|仮想で動かしています|
|pyenv|1.2.16|複数Python環境を使うことがあるのでpyenv使っています|
|Python|3.8.1|pyenv上でpython3.8.1を使っています<br>パッケージはvenvを使って管理しています|
|Mecab|0.996-5|apt-getでインストール|
|CRF++|0.58|昔すぎてインストール方法忘れました(多分`make install`)|
|CaboCha|0.69|昔すぎてインストール方法忘れました(多分`make install`)|

# [第5章: 係り受け解析](http://www.cl.ecei.tohoku.ac.jp/nlp100/#ch5)
## 学習内容
> 『吾輩は猫である』に係り受け解析器CaboChaを適用し，係り受け木の操作と統語的な分析を体験します．
>> クラス, 係り受け解析, [CaboCha](https://code.google.com/p/cabocha/), 文節, 係り受け, 格, 機能動詞構文, 係り受けパス, [Graphviz](http://www.graphviz.org/)

## ノック内容
> 夏目漱石の小説『吾輩は猫である』の文章（[neko.txt](http://www.cl.ecei.tohoku.ac.jp/nlp100/data/neko.txt)）をCaboChaを使って係り受け解析し，その結果をneko.txt.cabochaというファイルに保存せよ．このファイルを用いて，以下の問に対応するプログラムを実装せよ．

### [45. 動詞の格パターンの抽出](http://www.cl.ecei.tohoku.ac.jp/nlp100/#sec45)
>今回用いている文章をコーパスと見なし，日本語の述語が取りうる格を調査したい．動詞を述語，動詞に係っている文節の助詞を格と考え，述語と格をタブ区切り形式で出力せよ．ただし，出力は以下の仕様を満たすようにせよ．
>
> - 動詞を含む文節において，最左の動詞の基本形を述語とする
> - 述語に係る助詞を格とする
> - 述語に係る助詞（文節）が複数あるときは，すべての助詞をスペース区切りで辞書順に並べる
>
>「吾輩はここで始めて人間というものを見た」という例文（neko.txt.cabochaの8文目）を考える．この文は「始める」と「見る」の２つの動詞を含み，「始める」に係る文節は「ここで」，「見る」に係る文節は「吾輩は」と「ものを」と解析された場合は，次のような出力になるはずである．
>
>```
>始める  で
>見る    は を
>```
>
>このプログラムの出力をファイルに保存し，以下の事項をUNIXコマンドを用いて確認せよ．
>
> - コーパス中で頻出する述語と格パターンの組み合わせ
> - 「する」「見る」「与える」という動詞の格パターン（コーパス中で出現頻度の高い順に並べよ）

### 課題補足(「格」について)
プログラムを完成させる目的では特に意識をしませんが、日本語の「格」というのは奥が深そうです。興味が出たら[Wikipedit「格」](https://ja.wikipedia.org/wiki/%E6%A0%BC)を見てみましょう。私はチラ見程度です。
昔、オーストラリアでランゲージ・エクスチェンジをしていたときに「は」と「が」の何が違うのかを聞かれたことを思い出しました。

# 回答
## 回答プログラム [045.動詞の格パターンの抽出.ipynb](https://github.com/YoheiFukuhara/nlp100/blob/master/05.%E4%BF%82%E3%82%8A%E5%8F%97%E3%81%91%E8%A7%A3%E6%9E%90/045.%E5%8B%95%E8%A9%9E%E3%81%AE%E6%A0%BC%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3%E3%81%AE%E6%8A%BD%E5%87%BA.ipynb)

```python
import re

# 区切り文字
separator = re.compile('\t|,')

# 係り受け
dependancy = re.compile(r'''(?:\*\s\d+\s) # キャプチャ対象外
                            (-?\d+)       # 数字(係り先)
                          ''', re.VERBOSE)

    def __init__(self, line):
        
        #タブとカンマで分割
        cols = separator.split(line)
        
        self.surface = cols[0] # 表層形(surface)
        self.base = cols[7]    # 基本形(base)
        self.pos = cols[1]     # 品詞(pos)
        self.pos1 = cols[2]    # 品詞細分類1(pos1)

class Chunk:
    def __init__(self, morphs, dst):
        self.morphs = morphs
        self.srcs = []   # 係り元文節インデックス番号のリスト
        self.dst  = dst  # 係り先文節インデックス番号
        
        self.verb = ''
        self.joshi = ''
        
        for morph in morphs:            
            if morph.pos != '記号':
                self.joshi = ''  # 記号を除いた最終行の助詞を取得するため、記号以外の場合はブランク
            if morph.pos == '動詞':
                self.verb = morph.base
            if morph.pos == '助詞':
                self.joshi = morph.base

# 係り元を代入し、Chunkリストを文のリストを追加
def append_sentence(chunks, sentences):
    
    # 係り元を代入
    for i, chunk in enumerate(chunks):
        if chunk.dst != -1:
            chunks[chunk.dst].srcs.append(i)
    sentences.append(chunks)
    return sentences, []

morphs = []
chunks = []
sentences = []

with open('./neko.txt.cabocha') as f:
    
    for line in f:
        dependancies = dependancy.match(line)
        
        # EOSまたは係り受け解析結果でない場合
        if not (line == 'EOS\n' or dependancies):
            morphs.append(Morph(line))
            
        # EOSまたは係り受け解析結果で、形態素解析結果がある場合
        elif len(morphs) > 0:
            chunks.append(Chunk(morphs, dst))
            morphs = []
       
        # 係り受け結果の場合
        if dependancies:
            dst = int(dependancies.group(1))
        
        # EOSで係り受け結果がある場合
        if line == 'EOS\n' and len(chunks) > 0:
            sentences, chunks = append_sentence(chunks, sentences)

with open('./045.result_python.txt', 'w') as out_file:
    for sentence in sentences:
        for chunk in sentence:
            if chunk.verb != '' and len(chunk.srcs) > 0:
                
                # 係り元助詞のリストを作成
                sources = [sentence[source].joshi for source in chunk.srcs if sentence[source].joshi != '']
            
                if len(sources) > 0:
                    sources.sort()
                    out_file.write(('{}\t{}\n'.format(chunk.verb, ' '.join(sources))))
```

以下はUNIXコマンド部分です。[`grep`コマンド](https://www.atmarkit.co.jp/ait/articles/1604/07/news018.html)を初めて使いましたが便利なのですね。

```bash:UNIXコマンド部
# ソート、重複除去とカウント、降順ソート
sort 045.result_python.txt | uniq --count | sort --numeric-sort --reverse > "045.result_1_すべて.txt"

# 「(行頭)する(空白)」を抽出、ソート、重複除去とカウント、降順ソート
grep "^する\s" 045.result_python.txt | sort | uniq --count | sort --numeric-sort --reverse > "045.result_2_する.txt"

# 「(行頭)見る(空白)」を抽出、ソート、重複除去とカウント、降順ソート
grep "^見る\s" 045.result_python.txt | sort | uniq --count | sort --numeric-sort --reverse > "045.result_3_見る.txt"

# 「(行頭)与える(空白)」を抽出、ソート、重複除去とカウント、降順ソート
grep "^与える\s" 045.result_python.txt | sort | uniq --count | sort --numeric-sort --reverse > "045.result_4_与える.txt"
```


## 回答解説
### Chunkクラス
Chunkクラスで動詞と助詞の原型を格納しています。1文節に複数の動詞があった場合は、後勝ちにしています。格となる助詞は文節の最後に出てくるはずなのですが、記号を考慮した条件分岐を入れています。

```python:
class Chunk:
    def __init__(self, morphs, dst):
        self.morphs = morphs
        self.srcs = []   # 係り元文節インデックス番号のリスト
        self.dst  = dst  # 係り先文節インデックス番号
        
        self.verb = ''
        self.joshi = ''
        
        for morph in morphs:            
            if morph.pos != '記号':
                self.joshi = ''  # 記号を除いた最終行の助詞を取得するため、記号以外の場合はブランク
            if morph.pos == '動詞':
                self.verb = morph.base
            if morph.pos == '助詞':
                self.joshi = morph.base
```

### 出力部分
係り元の助詞はリスト内包表記でリスト化して、「辞書順に並べる」を満たすためにソートしています。そして、最後に`join`関数を使ってスペース区切りで出力しています。ネストが深くて、書いていて気持ち悪いです。

```python:
with open('./045.result_python.txt', 'w') as out_file:
    for sentence in sentences:
        for chunk in sentence:
            if chunk.verb != '' and len(chunk.srcs) > 0:

                # 係り元助詞のリストを作成
                sources = [sentence[source].joshi for source in chunk.srcs if sentence[source].joshi != '']

                if len(sources) > 0:
                    sources.sort()
                    out_file.write(('{}\t{}\n'.format(chunk.verb, ' '.join(sources))))
```

### 出力結果(実行結果)
プログラム実行すると以下の結果が出力されます。多いので10行だけここに表示します。

#### Pythonの出力結果

```bash:045.result_python.txt(冒頭10行)
生れる	で
つく	が と
泣く	で
いる	て は
始める	で
見る	は を
聞く	で
捕える	を
煮る	て
食う	て
```
#### UNIXコマンドの出力結果
多いので10行だけここに表示します。

```bash:045.result_1_すべて.txt(冒頭10行)
   3176 ある	が
   1997 つく	が と
    800 云う	は
    721 ある	が と に
    464 られる	に
    330 られる	て と
    309 思う	と
    305 見る	の
    301 かく	たり を
    262 ある	まで
```

```bash:045.result_2_する.txt(冒頭10行)
   1099 する	が
    651 する	が と
    221 する	で に は
    109 する	でも に
     86 する	まで
     59 する	と は は は
     41 する	たり は へ
     27 する	たり と は を
     24 する	て まで
     18 する	として
```

```bash:045.result_3_見る.txt(冒頭10行)
    305 見る	の
     99 見る	は を
     31 見る	て て は
     24 見る	から て
     19 見る	から
     11 見る	から て て
      7 見る	が ので
      5 見る	て て て は
      2 見る	ながら に を
      2 見る	で ばかり も
```

「与える」は出現頻度少なかく、これで全部です。

```bash:045.result_4_与える.txt
      7 与える	に を
      4 与える	で に を
      3 与える	て と は を
      1 与える	けれども は を
      1 与える	か として
      1 与える	が て と に は を
```