【第4章】言語処理100本ノックでPythonに入門

この記事は拙著[100本ノックでPythonに入門](https://qiita.com/hi-asano/items/8e303425052781d95f09)の続編です。[100本ノック第4章](https://nlp100.github.io/ja/ch04.html)の解説をやってきます。

まずは形態素解析器MeCabをインストールしてneko.txtをダウンロードし、形態素解析して中身を確認してみましょう。


```shell
$ mecab < neko.txt > neko.txt.mecab
```

青空文庫の「吾輩は猫である」です。

MeCabのデフォルト辞書の体系は学校文法に似ていますが、形容動詞は名詞+助動詞、サ変動詞は名詞+動詞になるなど、学校文法における単語より少し細かい分割になります。出力フォーマットはおそらく次のようになっています。

```
表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音形
```

また、文は`EOS`で区切られています。ちなみに、多くの形態素解析器は全角文字を前提としているので、Webテキストなどを解析する際は半角文字を置換しておくのが良いでしょう。

# itertools
せっかくなので、[itertools](https://docs.python.org/ja/3/library/itertools.html)モジュールを知っておきましょう。便利なイテレータを作るメソッドが揃っています。

## islice()
2章でも登場しましたね。`islice(iterable, start, stop, step)`でイテレータのスライスができます。`step`省略時は1、さらに`start`を省略すると`start=0`になります。`stop=None`とすると終端までという意味になります。とても便利なので是非使いましょう。

## groupby()
Unixコマンドの`uniq`的なことができます。


```python
from itertools import groupby
a = [1,1,1,0,0,1]
for k, g in groupby(a):
    print(k, list(g))
```

    1 [1, 1, 1]
    0 [0, 0]
    1 [1]


このように第1引数に`iterable`を渡すと、その要素の値と、そのグループを返すイテレータのペアを作ってくれます。`sort`と同様に、第2引数に`key`を指定することもできます。よく使うのは`operator.itemgetter`ですね（2章で[ソート HOW TO](https://docs.python.org/ja/3/howto/sorting.html)を読んだ人は知っていることでしょう）。また、ブール値を返すラムダ式を使ったりもします。。


```python
from operator import itemgetter
a = [(3, 0), (4, 0), (2, 1)]
for k, g in groupby(a, key=itemgetter(1)):
    print(k, list(g))
```

    0 [(3, 0), (4, 0)]
    1 [(2, 1)]


## chain.from_iterable()
2次配列を1次元に`flatten`できます。


```python
from itertools import chain
a = [[1,2], [3,4], [5,6]
print(list(chain.from_iterable(a))
```

    [1, 2, 3, 4, 5, 6]


## zip_longest()
組み込み関数の`zip()`は短いイテラブルに合わせますが、長いイテラブルに合わせてほしいときはこれを使います。普通に使うと`None`で穴埋めされますが、第2引数` fillvalue`で穴埋めに使う値を指定できます。

## product()
直積を計算します。他にも`permutations()`や`combinations()`は自分で実装すると辛いので一応知っておいて損はないと思います。

これらは`itertools`のごく一部なので、興味のある人は[ドキュメント](https://docs.python.org/ja/3/library/itertools.html)を読みましょう。

# 30. 形態素解析結果の読み込み
> 形態素解析結果（neko.txt.mecab）を読み込むプログラムを実装せよ．ただし，各形態素は表層形（surface），基本形（base），品詞（pos），品詞細分類1（pos1）をキーとするマッピング型に格納し，1文を形態素（マッピング型）のリストとして表現せよ．第4章の残りの問題では，ここで作ったプログラムを活用せよ．

オブジェクト指向な言語をやった人であればクラスを使いたいころですが、それは次の章までお預けです。また2章同様Pandasを使うこともできますが、問題文の意図から離れそうなのでやめておきます。

以下、解答例です。


```q30.py
import argparse
from itertools import groupby, islice
from pprint import pprint
import sys


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('num', type=int)
    args = parser.parse_args()
    for sent_lis in islice(read_mecab(sys.stdin), args.num-1, args.num):
        pprint(sent_lis)

            
def read_mecab(fi):
    for is_eos, sentence in groupby(fi, lambda line: line.startswith('EOS')):
        if not is_eos:
            yield list(map(line2dic, sentence))


def line2dic(line):
    surface, info = line.rstrip().split('\t')
    col = info.split(',')
    dic = {'surface': surface,
          'pos': col[0],
          'pos1': col[1],
          'base': col[6]}
    return dic


if __name__ == '__main__':
    main()

```


```shell
$ python q30.py 2 < neko.txt.mecab
```

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


`main()`では出力を絞ってます。`pprint.pprint()`は`print()`の代わりに使うと、改行とかを整えて出力（プリティープリント）してくれます。

この手の形式は`groupby()`の`key`に、行が`EOS`かどうかを返す関数を渡せばスマートに書けます。`yield`は2章でやりましたね。

問題なのは`list(map())`です。`map(func, iterable)`は関数`func`を`iterable`の各要素に適用し、イテレータを返す、という動作をします。結果は`[line2sic(x) for x in sentence]`と同じなのですが、どうもPythonは自作関数を`for`文中で呼び出すのが遅いようなのでこのような記法を採用してみました（[参考](https://qiita.com/hi-asano/items/aa2976466739f280b887)）。

# 31. 動詞
> 動詞の表層形をすべて抽出せよ．

以下、解答例です。


```q31.py
from itertools import islice
import sys

from q30 import read_mecab


def main():
    for sent_lis in islice(read_mecab(sys.stdin), 5):
        for word in filter(lambda x: x['pos'] == '動詞', sent_lis):
            print(word['surface'])

                
if __name__ == '__main__':
    main()

```


```shell
$ python q31.py < neko.txt.mecab
```

    生れ
    つか
    し
    泣い
    し
    いる


先頭N文をとってくるようにしてます。`argpase`も面倒臭がって省略しています。

他に特に言うことが無かったので強引に`filter()`を使ってみました。これは`(x for x in iterable if condition(x))`と等価で、条件にマッチした要素だけを返します。正直普通に`if`文使えば十分なので出番は少ないですね（というかこの場合`filter()`使うと遅い気がします）。


# 32. 動詞の原形
> 動詞の原形をすべて抽出せよ．

ほぼ31と同じなので省略。

# 33.「AのB」
> 2つの名詞が「の」で連結されている名詞句を抽出せよ．

ごり押しでできてしまうので何も言うことはありません。以下、解答例です。


```q33.py
from itertools import islice
import sys


from q30 import read_mecab


def main():
    for sent_lis in islice(read_mecab(sys.stdin), 20):
        for i in range(len(sent_lis) - 2):
            if (sent_lis[i+1]['base'] == 'の' and sent_lis[i]['pos'] == '名詞'
                and sent_lis[i+2]['pos'] == '名詞'):
                print(''.join(x['surface'] for x in sent_lis[i: i+3]))

                
if __name__ == '__main__':
    main()

```


```shell
$ python q33.py < neko.txt.mecab
```

    彼の掌
    掌の上
    書生の顔
    はずの顔
    顔の真中
    穴の中
    書生の掌
    掌の裏


Pythonで行継続は`\`と前言いましたが、括弧の内側でも自由に改行できます。それを言いたいがためだけに無駄にif文の条件式を`()`で囲っています。

# 34. 名詞の連接
> 名詞の連接（連続して出現する名詞）を最長一致で抽出せよ．

品詞で`groupby()`すれば良いです。以下、解答例です。


```q34.py
import sys
from itertools import groupby, islice

from q30 import read_mecab


def main():
    for sent_lis in islice(read_mecab(sys.stdin), 20):
        for key, group in groupby(sent_lis, lambda word: word['pos']):
            if key == '名詞':
                words = [word['surface'] for word in group]
                if len(words) > 1:
                    print(''.join(words))

                
if __name__ == '__main__':
    main()

```

```shell
$ python q34.py < neko.txt.mecab
```

    人間中
    一番獰悪
    時妙
    一毛
    その後猫
    一度
    ぷうぷうと煙


# 35. 単語の出現頻度
> 文章中に出現する単語とその出現頻度を求め，出現頻度の高い順に並べよ．

`collections.Counter`を使うだけです。以下、解答例です。


```q35.py
import sys
from collections import Counter
from pprint import pprint

from q30 import read_mecab


def get_freq():
    word_freq = Counter(word['surface'] for sent_lis in read_mecab(sys.stdin) 
                            for word in sent_lis)
    return word_freq.most_common(10)


if __name__ == '__main__':
    pprint(get_freq())

```



```shell
$ python q35.py < neko.txt.mecab
```

    [('の', 9194),
     ('。', 7486),
     ('て', 6868),
     ('、', 6772),
     ('は', 6420),
     ('に', 6243),
     ('を', 6071),
     ('と', 5508),
     ('が', 5337),
     ('た', 3988)]


# matplotlib
さて、次の問題でグラフを書くのでいよいよこいつの出番です。`pip install`しておきましょう。「Pythonに入門」という記事で外部モジュールの解説はあまりしたくないですし、こいつを真面目に解説すると本が1冊書けてしまいます。まずこの[Qiita記事](https://qiita.com/skotaro/items/08dc0b8c5704c94eafb9)を読んでmatplotlibの階層構造を把握しましょう。とてもゴチャゴチャしてますよね。その辺を適当に設定してくれるのが`Pyplot`です。今回は細かい見た目の調整はしないのでこちらの方法でやります。簡単な例を紹介します。



```python
import matplotlib.pyplot as plt
# グラフの種類とデータを指定
plt.plot([1, 2, 3, 4], [1, 4, 9, 16])
# なんやかんや設定する
plt.title('example')
plt.ylabel('some numbers')
# 描画
plt.show()
```


![output_28_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531660/f7364d3c-ebc1-f339-db41-7cd08df3b252.png)

インポート文の意味は「`matplotlib`モジュールのサブモジュール`pyplot`を`plt`という名前でインポート」という意味になります。

まずはグラフの種類を決めます。折れ線グラフであれば`plt.plot()`, 横棒グラフであれば`plt.barh()`, 縦棒グラフであれば`plt.bar()`といった具合です（イテラブルはpyplot内部で`numpy`配列に勝手に変換されるので、`numpy`配列を渡す方が本当は良いかもしれません）。

次になんやかんやの見た目を設定します。`plt.yticks()`はy座標とそこに添える文字を設定できます。`plt.xlim()`を使うとx軸の最大・最小値を設定できます。`plt.yscale("log")`とするとy軸が対数スケールになります。

最後に描画です。私はJupyterを使ってるので`plt.show()`としています。スクリプト実行している人は`plt.savefig(filename)`を使ってファイル出力する方が良いと思います。

正直この方法はMATLABライクでありPythonicでは無いですが、簡単ではありますね。

## matplotlibで日本語を表示させるには
デフォルトのフォントが日本語非対応なので、豆腐化してしまいます。そのためグラフに日本語を表示させるには日本語対応のフォントを設定すれば良いのですが、お使いの環境によっては日本語フォントが見当たらなかったり、そもそも入ってないのでインストールが必要だったりして、大変です。[japanize-matplotlib](https://github.com/uehara1414/japanize-matplotlib)で楽をさせていただきます。


# 36. 頻度上位10語
> 出現頻度が高い10語とその出現頻度をグラフ（例えば棒グラフなど）で表示せよ．


```python
from collections import Counter

from q30 import read_mecab
import matplotlib.pyplot as plt
import japanize_matplotlib

word_freq = Counter(word['base'] for sent_lis in read_mecab(open('neko.txt.mecab')) 
                            for word in sent_lis)
word, count  = zip(*word_freq.most_common(10))
len_word = range(len(word))

plt.barh(len_word, count, align='center')
plt.yticks(len_word, word)
plt.xlabel('frequency')
plt.ylabel('word')
plt.title('36. 頻度上位10語')
plt.show()
```


![output_31_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531660/79a73987-c08f-1967-1ee6-e6cd29711604.png)


`zip()`の中の`*`は何だ？という話ですが、ここでやりたいのは転置です。すなわち、`[[a,b],[c,d]]`のようなデータを`[[a,c],[b,d]]`のように変形することです。そこでその転置が一番簡単にできるのが`zip(*seq)`という書き方です。これは`zip(seq[0], seq[1], ...)`と等価です（[引数リストのアンパック](https://docs.python.org/ja/3/tutorial/controlflow.html#unpacking-argument-lists)）。`zip([a,b], [c,d])`は`[(a,c),(b,d)]`ですよね？さらにアンパック代入を使えば一気に別々の変数に代入できます。

（これでようやく第1章のngram関数の別解の説明が終了しました）

単語頻度で上位を占める語は機能語（助詞、句読点）であることをおさえておきましょう。


# 37. 「猫」と共起頻度の高い上位10語
> 「猫」とよく共起する（共起頻度が高い）10語とその出現頻度をグラフ（例えば棒グラフなど）で表示せよ．



```python
word_freq = Counter()
for sent_lis in read_mecab(open('neko.txt.mecab')):
    for word in sent_lis:
        if word['surface'] == '猫':
            word_freq.update(x['base'] for x in sent_lis if x['surface'] != '猫')
            break
            
words, count  = zip(*word_freq.most_common(10))
len_word = range(len(words))

plt.barh(len_word, count, align='center')
plt.yticks(len_word, words)
plt.xlabel('frequency')
plt.ylabel('word')
plt.title('37. 「猫」と共起頻度の高い上位10語')
plt.show()
```

![output_34_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531660/47dd0892-404d-921c-72b1-1944e48cc8b8.png)



`Counter.update(iterable)`で`Counter`オブジェクトは更新できます。しかし自立語に絞らないと全く面白くない結果になりますね。



# 38. ヒストグラム
> 単語の出現頻度のヒストグラム（横軸に出現頻度，縦軸に出現頻度をとる単語の種類数を棒グラフで表したもの）を描け．


```python
word_freq = Counter(word['base'] for sent_lis in read_mecab(open('neko.txt.mecab')) 
                        for word in sent_lis)
data = Counter(count for count in word_freq.values())

x, y = data.keys(), data.values()

plt.bar(x, y)
plt.title("38. ヒストグラム")
plt.xlabel("frequency")
plt.ylabel("number of the words")
plt.xlim(1, 30)
plt.show()
```


![output_34_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531660/87ecddb2-21b5-1495-0276-1a66eac7b5b3.png)


`dict.keys()`で全てのkeyを、`dict.values()`で全てのvalueを取得できます。

単語の種類数で見ると、多くの語は低頻度であることがわかります。なんとなく反比例しているようにも見えます。深層学習ではこうした事情で低頻度語処理も重要になってきます。

# 39. Zipfの法則
> 単語の出現頻度順位を横軸，その出現頻度を縦軸として，両対数グラフをプロットせよ．


```python
word_freq = Counter(word['base'] for sent_lis in read_mecab(open('neko.txt.mecab')) 
                        for word in sent_lis)
_, count = zip(*word_freq.most_common())
plt.plot(range(1, len(count)+1), count)
plt.yscale("log")
plt.xscale("log")
plt.title("39. Zipfの法則")
plt.xlabel("log(rank)")
plt.ylabel("log(frequency)")
plt.show()
```


![output_37_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/531660/7c09781c-224e-4bac-3368-2187cf2400d1.png)



両対数グラフの傾きが大体-1になってますね。これは`freq ∝ rank^(-1)`という意味になります。38番の結果と関連していると思われます。Zipfの法則の詳細はググりましょう。

# まとめ
- `itertools`
- `map()`, `filter()`
- `matplotlib`
- 転置
- `dict.keys()`, `dict.values()`

# 次は5章
いよいよクラスを使います。Pythonの入門としては次で最終回ですかね。

(5/16) 書きました → https://qiita.com/hi-asano/items/5e18e3a5a711a752ad99