言語処理100本ノック第4章で学ぶ形態素解析

## はじめに

社内のメンバーを中心にした勉強会で[言語処理100本ノック](http://www.cl.ecei.tohoku.ac.jp/nlp100)を解いているのですが、その解答コードや、解く過程で便利だなと思った小技のまとめです。自分で調べたり検証したりした内容が多いですが、他の勉強会メンバーが共有してくれた情報も入っています。

第3章まではPythonプログラミング全般で使える内容でしたが、この章は形態素解析ということで、いよいよ言語処理らしくなってきました。

## シリーズ

- [言語処理100本ノック第2章で学ぶUNIXコマンド](https://qiita.com/Yusuke196/items/35668859ea70bc9539f5)
- [言語処理100本ノック第3章で学ぶ正規表現](https://qiita.com/Yusuke196/items/47203be382f60d653da7)
- [言語処理100本ノック第4章で学ぶ形態素解析](https://qiita.com/Yusuke196/items/b41e70b3a60cbac25294)（この記事）

## 環境

- macOS
- Python 3.8.1
- JupyterLab

## コード

### 前処理

ipynb（またはPython）ファイルと同じディレクトリに`neko.txt`を置いてから

```bash
!mecab neko.txt -o neko.txt.mecab
```

とすると、形態素解析の結果が`neko.txt.mecab`に記録されます。

### 30. 形態素解析結果の読み込み

```py
import re
import itertools

def parse_text(flat=True):
    with open('neko.txt.mecab') as file:
        morphs = []
        sentence = []

        for line in file:
            if re.search(r'EOS', line):
                continue

            surface, rest = line.split('\t')
            arr = rest.split(',')
            sentence.append({
                'surface': surface,
                'base': arr[6],
                'pos': arr[0],
                'pos1': arr[1],
            })
                
            if surface == '　': # 空白は形態素と見なさない
                sentence.pop(-1)
            if surface in ['　', '。']: # 空白と句点を文の終わりと見なす
                morphs.append(sentence)
                sentence = []
        
        if flat:
            return list(itertools.chain.from_iterable(morphs))
        else:
            return morphs

parse_text(flat=False)[:10]
```

```:結果
[[{'surface': '吾輩', 'base': '吾輩', 'pos': '名詞', 'pos1': '代名詞'},
  {'surface': 'は', 'base': 'は', 'pos': '助詞', 'pos1': '係助詞'},
  {'surface': '猫', 'base': '猫', 'pos': '名詞', 'pos1': '一般'},
  {'surface': 'で', 'base': 'だ', 'pos': '助動詞', 'pos1': '*'},
  {'surface': 'ある', 'base': 'ある', 'pos': '助動詞', 'pos1': '*'},
  {'surface': '。', 'base': '。', 'pos': '記号', 'pos1': '句点'}],
 [{'surface': '名前', 'base': '名前', 'pos': '名詞', 'pos1': '一般'},
```

この問題では「1文を形態素（マッピング型）のリストとして表現」することが求められていますが、31以降の問題でこの関数を使う際は返り値が平坦化されていた（ネストされていない）方が扱いやすいので、`flat`という仮引数を用意することにしました。この`flat`のデフォルト値は`True`としましたが、この問題では平坦化されていないリストが欲しいので`flat=False`という引数を渡しています。

なお、最後の行で`[:10]`としているのは確認のために表示する要素の数が10個くらいだとちょうどよかったからで、必然性は特にありません。

### 31. 動詞

```py
import numpy as np

def extract_surface():
    verbs = list(filter(lambda morph: morph['pos'] == '動詞', parse_text()))
    return [verb['surface'] for verb in verbs]

print(extract_surface()[:100])
```

```:結果
['生れ', 'つか', 'し', '泣い', 'し', 'いる', '始め', '見', '聞く', '捕え', '煮', '食う', '思わ', '載せ', 'られ', '持ち上げ', 'られ', 'し', 'あっ', '落ちつい', '見', '見', '思っ', '残っ', 'いる', 'さ', 'れ', 'し', '逢っ', '出会わ', 'し', 'のみ', 'なら', 'し', 'いる', '吹く', 'せ', '弱っ', '飲む', '知っ', '坐っ', 'おっ', 'する', 'し', '始め', '動く', '動く', '分ら', '廻る', 'なる', '助から', '思っ', 'いる', 'さり', 'し', '出', 'し', 'いる', '考え出そ', '分ら', '付い', '見る', 'い', 'おっ', '見え', '隠し', 'しまっ', '違っ', '明い', 'い', 'られ', '這い出し', '見る', '棄て', 'られ', '這い出す', 'ある', '坐っ', 'し', '考え', '見', '出', 'し', '泣い', '来', 'くれる', '考え付い', 'やっ', '見', '来', '渡っ', 'かかる', '減っ', '来', '泣き', '出', 'ある', 'ある', 'し', 'そろ']
```

返り値は`list(map(lambda morph: morph['surface'], verbs))`としても作れますが、上のように内包表記を使ったほうが簡潔なコードになります。

また、これは問題文をどう解釈するかによりますが、返り値となるリストで要素の重複を許したくない場合は、関数の最後の行を

```py
return set([verb['surface'] for verb in verbs])
```

とする、などの方法があります。

### 32. 動詞の原形

```py
def extract_base():
    verbs = list(filter(lambda morph: morph['pos'] == '動詞', parse_text()))
    return [verb['base'] for verb in verbs]

print(extract_base()[:100])
```

```:結果
['生れる', 'つく', 'する', '泣く', 'する', 'いる', '始める', '見る', '聞く', '捕える', '煮る', '食う', '思う', '載せる', 'られる', '持ち上げる', 'られる', 'する', 'ある', '落ちつく', '見る', '見る', '思う', '残る', 'いる', 'する', 'れる', 'する', '逢う', '出会う', 'する', 'のむ', 'なる', 'する', 'いる', '吹く', 'する', '弱る', '飲む', '知る', '坐る', 'おる', 'する', 'する', '始める', '動く', '動く', '分る', '廻る', 'なる', '助かる', '思う', 'いる', 'さる', 'する', '出る', 'する', 'いる', '考え出す', '分る', '付く', '見る', 'いる', 'おる', '見える', '隠す', 'しまう', '違う', '明く', 'いる', 'られる', '這い出す', '見る', '棄てる', 'られる', '這い出す', 'ある', '坐る', 'する', '考える', '見る', '出る', 'する', '泣く', '来る', 'くれる', '考え付く', 'やる', '見る', '来る', '渡る', 'かかる', '減る', '来る', '泣く', '出る', 'ある', 'ある', 'する', 'そる']
```

31とほぼ同じ。

### 33. サ変名詞

```py
def extract_sahens():
    return list(filter(lambda morph: morph['pos1'] == 'サ変接続', parse_text()))

extract_sahens()[:20]
```

```:結果
[{'surface': '見当', 'base': '見当', 'pos': '名詞', 'pos1': 'サ変接続'},
 {'surface': '記憶', 'base': '記憶', 'pos': '名詞', 'pos1': 'サ変接続'},
 {'surface': '話', 'base': '話', 'pos': '名詞', 'pos1': 'サ変接続'},
 {'surface': '装飾', 'base': '装飾', 'pos': '名詞', 'pos1': 'サ変接続'},
 {'surface': '突起', 'base': '突起', 'pos': '名詞', 'pos1': 'サ変接続'},
 ...
```

31とほぼ同じ。

### 34. 「AのB」

```py
def extract_noun_phrases():
    morphs = parse_text()
    phrases = []

    for i, morph in enumerate(morphs):
        if morph['surface'] == 'の' and morphs[i - 1]['pos'] == '名詞' \
                and morphs[i + 1]['pos'] == '名詞':
            phrases.append(
                morphs[i - 1]['surface'] + 'の' + morphs[i + 1]['surface'])

    return phrases
    
print(extract_noun_phrases()[:100])
```

```:結果
['彼の掌', '掌の上', '書生の顔', 'はずの顔', '顔の真中', '穴の中', '書生の掌', '掌の裏', '何の事', '肝心の母親', '藁の上', '笹原の中', '池の前', '池の上', '垣根の穴', '隣家の三', '時の通路', '家の内', '彼の書生', '以外の人間', '前の書生', 'おさんの隙', 'おさんの三', '胸の痞', '家の主人', '主人の方', '鼻の下', '吾輩の顔', '自分の住', '吾輩の主人', '家のもの', 'うちのもの', '彼の書斎', '本の上', '皮膚の色', '本の上', '彼の毎夜', '以外のもの', '主人の傍', '彼の膝', '膝の上', '経験の上', '飯櫃の上', '炬燵の上', 'ここのうち', '供の寝床', '彼等の中間', '供の人', '例の神経', '性の主人', '次の部屋', '自分の勝手', '吾輩の方', '台所の板の間', '吾輩の尊敬', '向の白', '玉のよう', 'そこの家', '家の書生', '裏の池', '親子の愛', 'もっともの議論', '刺の頭', '鰡の臍', '彼等のため', '軍人の家', '代言の主人', '教師の家', '猫の時節', '吾輩の家', '家の主人', 'だらけの英文', '胃弱の癖', '後架の中', '平の宗', '月の月給', '当分の間', '下のよう', '今更のよう', '主人の述懐', '彼の友', '金縁の眼鏡', '主人の顔', '内の想像', '訳のもの', '利の大家', '金縁の裏', '吾輩の後ろ', '彼の友', '吾輩の輪廓', '顔のあたり', '上乗の出来', '顔の造作', '他の猫', '不器量の吾輩', '吾輩の主人', '斯産の猫', '斑入りの皮膚', '主人の彩色', '身内の筋肉']
```

`enumerate()`を使うと、`morph`が`morphs`の中で何番目かを`i`という情報を変数として扱えるのですっきりしたコードになります。

### 35. 名詞の連接

```py
def extract_continuous_nouns():
    morphs = parse_text()
    continuous_nouns = []

    for i, morph in enumerate(morphs):
        if morph['pos'] == '名詞' and morphs[i + 1]['pos'] == '名詞':
            continuous_noun = morph['surface'] + morphs[i + 1]['surface']
            
            j = 1
            while morphs[i + 1 + j]['pos'] == '名詞':
                continuous_noun += morphs[i + 1 + j]['surface']
                j += 1

            continuous_nouns.append(continuous_noun)
            
    return continuous_nouns

print(extract_continuous_nouns()[:100])
```

```:結果
['人間中', '時妙', 'その後猫', 'ぷうぷうと煙', '邸内', '三毛', '書生以外', '四五遍', '五遍', 'この間おさん', '三馬', '御台所', 'まま奥', '住家', '終日書斎', '勉強家', '勉強家', '勤勉家', '二三ページ', '三ページ', '主人以外', '限り吾輩', '朝主人', '二人', '最後大変', '——猫', '神経胃弱性', '胃弱性', '物指', '尻ぺたをひどく', '言語同断', '家内総がかり', '総がかり', '筋向', '白君', '度毎', '白君', '先日玉', '四疋', '三日目', '日目', '四疋', '白君', '我等猫族', '等猫族', '猫族', '家族的生活', '的生活', '三毛君', '毛君', '所有権', '我々同族間', '同族間', '目刺', '彼等人間', '我等', '吾人', '白君', '三毛君', '毛君', '間違いだらけ', '後架先生', '宗盛', '宗盛', '月給日', '水彩絵具', '毎日毎日書斎', '毎日書斎', '人の', '自ら筆', '眼鏡越', '以太利', '大家アンドレア・デル・サルト', '露華', '寒鴉', 'これ幅', '活画', '翌日吾輩', '辛棒', '今吾輩', '今吾輩', '波斯産', '上不思議', '盲猫', '心中ひそか', 'いくらアンドレア・デル・サルト', 'あと大', '壊わし', '馬鹿野郎', '馬鹿野郎', '辛棒', '馬鹿野郎呼わり', '野郎呼わり', '呼わり', '平生吾輩', '馬鹿野郎', 'みんな増長', '先どこ', '数倍', '十坪']
```

### 36. 単語の出現頻度

```py
import pandas as pd

def sort_by_freq():
    words = [morph['base'] for morph in parse_text()]
    
    return pd.Series(words).value_counts()

sort_by_freq()[:20]
```

```:結果
の     9194
。     7486
て     6868
、     6772
は     6420
...
```

別解としては、以下のように標準ライブラリの`collections`をインポートしておいて、

```py
from collections import Counter
```

`Counter()`オブジェクトの`most_common()`メソッドを（`pandas`の`value_counts()`の代わりに）使う方法もあります。

```py
return Counter(words).most_common()
```

```:結果
[('の', 9194),
 ('。', 7486),
 ('て', 6868),
 ('、', 6772),
 ('は', 6420),
 ...
```

この二つの解法の違いとしては`value_counts()`が`Series`を返すのに対して`most_common()`はタプルの配列を返すので、どちらを使ったらその後の処理がしやすくなるかで使い分けるといいのではないでしょうか。

### 37. 頻度上位10語

```py
import japanize_matplotlib

def draw_bar_graph():
    sort_by_freq()[:10].plot.bar()

draw_bar_graph()
```

![bar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229718/a1948037-75aa-5427-f41f-f2761a5d6e80.png)

日本語を表示させるのに一手間かかりますが、[こちら](https://yolo.love/matplotlib/japanese/)の記事で紹介されている`japanize_matplotlib`というライブラリを使うと簡単でした。

また関数の中身について、 自分は短く書きたかったので`Series`オブジェクトに対して`.plot.bar()`としましたが、

```py
import matplotlib.pyplot as plt
```

として`matplotlib`をインポートしたうえで、以下のように書いても動作します。

```py
morph_freqs = sort_by_freq()[:10]
plt.bar(morph_freqs.index, morph_freqs)
```

### 38. ヒストグラム

```py
import matplotlib.pyplot as plt

def draw_hist():
    plt.xlabel('出現頻度')
    plt.ylabel('単語の種類数')
    plt.hist(sort_by_freq(), bins=200)
    
draw_hist()
```

![hist.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229718/9edcf147-bb39-c03d-cd50-4bbd327c8fc9.png)

全体を表示するならこのようなコードになりますが、ちょっと見づらいので以下のように表示する範囲を限定するほうが現実的かもしれません。

```py
def draw_hist_2():
    plt.xlabel('出現頻度')
    plt.ylabel('単語の種類数')
    plt.title('出現頻度20以下')
    plt.hist(sort_by_freq(), bins=20, range=(1, 20))

draw_hist_2()
```

![hist2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229718/18ca5e4f-0d51-bed7-d688-06870cdc7b29.png)

### 39. Zipfの法則

```py
def draw_log_graph():
    plt.xscale('log')
    plt.yscale('log')
    plt.xlabel('出現頻度順位')
    plt.ylabel('出現頻度')
    plt.scatter(range(1, len(sort_by_freq()) + 1), sort_by_freq(), s=10)
    
draw_log_graph()
```

![zipf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229718/4ae009bf-6388-eb12-e3c5-9bcb8d2bf72d.png)

`scatter`メソッドを呼び出すとき、`s`オプションで点のサイズを指定できます。デフォルトは20ですが、それだと少し大きかったので`10`としました。

## まとめ

MeCabはオープンソースのソフトウェアですが、それでもこれだけいろいろなことができるということが分かって面白かったです。

一方で、解析の精度はこれだけだと完璧ではないと感じる点もありました（たとえば35で出力結果として返ってくる「ぷうぷうと」は名詞ではなく副詞なのでは、など）。このあたりの問題を解決するには、いろいろな辞書を試したり自分でカスタマイズしたり、といった工夫が必要なのかなと思います。

この章については以上になりますが、もし間違いなどあったらコメントいただけると助かります。