PythonでのMeCabを速くするtips

ちゃお... [Python Advent Calendar 2015](http://www.adventar.org/calendars/846) 18日目の記事です...

Pythonといったらデータサイエンスに強いし、データサイエンスといったら形態素解析が必要になることがあるし、形態素解析といったらMeCabだし――ということで、今回はPythonでのMeCabの処理を少しでも速くする豆知識を共有したいと思います！

## parseToNodeを捨てよ parseを使おう
MeCabの解析結果を得るには`parse`と`parseToNode`の2つのメソッドがあります。

わたしはもっぱら`parseToNode`使ってたのですが、なんか遅いなーって思って、本当に遅いのか確かめるために処理時間測ってみました。現実的な設定でやった方が実用的だと思ったので、今回は夢野久作のドグラマグラから名詞を抽出することにします。

### コード
https://gist.github.com/ikegami-yukino/68a741ef854de68871cc#file-parse_vs_parsetonode-ipynb

```py3
import MeCab

tagger = MeCab.Tagger('-d /usr/local/lib/mecab/dic/ipadic')


def preprocessing(sentence):
    return sentence.rstrip()


def extract_noun_by_parse(path):
    with open(path) as fd:
        nouns = []
        for sentence in map(preprocessing, fd):
            for chunk in tagger.parse(sentence).splitlines()[:-1]:
                (surface, feature) = chunk.split('\t')
                if feature.startswith('名詞'):
                    nouns.append(surface)
    return nouns


def extract_noun_by_parsetonode(path):
    with open(path) as fd:
        nouns = []
        for sentence in map(preprocessing, fd):
            node = tagger.parseToNode(sentence)
            while node:
                if node.feature.startswith('名詞'):
                    nouns.append(node.surface)
                node = node.next
    return nouns
```

### 結果

- Py2: Python 2.7.10
- Py3: Python 3.5.0
- ASIS: [MeCabリポジトリ](https://github.com/taku910/mecab/)そのまま
- NEW SWIG: SWIG 3.0.7でMeCabリポジトリのラッパーをつくりなおした場合
- mecab-python3: PyPIにある [mecab-python3](https://pypi.python.org/pypi/mecab-python3)

|  | parse | parseToNode |
| --- | --- | --- |
|Py2, ASIS | 531 ms | 642 ms |
|Py2, NEW SWIG | 604 ms | 630 ms |
|Py2, mecab-python3 | 547 ms | 652 ms |
|Py3, ASIS | 673 ms | **1610 ms** |
|Py3, NEW SWIG | 684 ms | **1640 ms** |
|Py3, mecab-python3 | 654 ms | **1610 ms** |

Python 3の`parseToNode`だけ明らかに遅い......！？

### 原因
詳細なプロファイルを取ってみると、

```
         1362455 function calls in 2.177 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    1.162    1.162    2.174    2.174 <ipython-input-5-e12642d808e1>:1(extract_noun_by_parsetonode)
   313688    0.372    0.000    0.372    0.000 MeCab.py:35(_swig_setattr_nondynamic)
     3416    0.251    0.000    0.262    0.000 {built-in method _MeCab.Tagger_parseToNode}
   313688    0.131    0.000    0.629    0.000 MeCab.py:126(<lambda>)
   313688    0.126    0.000    0.498    0.000 MeCab.py:48(_swig_setattr)
以下省略...
```

`parseToNode`は形態素ごとに`Node`インスタンスの生成時に`Node`の全ての要素をあらかじめ取得するので、そのときのオーバーヘッドがだいぶ大きいようです。

### 結論
Python 3でMeCab使うときは`parseToNode`じゃなくて`parse`を使いましょう (バッドノウハウだけど......)

## joblibで並列化

さらに速くしたいとなったら並列化が頭をよぎりますよね。でも並列化っていうととっつきにくいイメージがあります。途中で失敗したときにうまくプロセスが死んでくれなかったり、途中でやめたいって思っても`KeyBoardInterrupt`が効かなかったり、データの分割数どれくらいがわからなかったり、今どれくらい処理してるのか経過わかんなかったり。。。

そこで、[scikit-learnも採用](https://github.com/scikit-learn/scikit-learn/tree/master/sklearn/externals/joblib)している[joblib](http://pythonhosted.org/joblib/)というものを使います。joblibはいろいろできるんですけど、ここでは並列処理の[`joblib.Parralel`](http://pythonhosted.org/joblib/parallel.html)に着目します。これはざっくりいうとPython標準の`multiprocessing`や`threading`を使いやすくしたものです。たとえば、各プロセスにどれくらいの粒度でデータを分割して渡すかを自動で調整したり、`KeyBoardInterrupt`でちゃんと終わってくれたり、途中経過を標準出力とかに流したりできます。かゆいところに手が届く！これなら並列化こわくない！💪😤

### 比較

並列化するとどれくらい速くなるか比較するために、またドグラマグラから名詞抽出しました。ボリューム大きめのテキストでやった方がわかりやすいので今回は長さを10倍してます。

コード: https://gist.github.com/ikegami-yukino/68a741ef854de68871cc#file-asis_vs_joblib-ipynb

```py3

def extract_noun(sentence):
    nouns = []
    sentence = preprocessing(sentence)
    for chunk in tagger.parse(sentence).splitlines()[:-1]:
        (surface, feature) = chunk.split('\t')
        if feature.startswith('名詞'):
            nouns.append(surface)
    return nouns


# 並列なし
%timeit nouns = [extract_noun(sentence) for sentence in doc.splitlines()]


# 並列あり
from joblib import Parallel, delayed

%timeit nouns = Parallel(n_jobs=-1, pre_dispatch='all')(delayed(extract_noun)(sentence) for sentence in doc.splitlines())
```

### 結果
```
並列なし
1 loops, best of 3: 6.78 s per loop

並列あり
1 loops, best of 3: 3.72 s per loop
```

1.8倍くらい速くなりました！

ついでに並列化なしの`parseToNode`で同じデータを処理させると
```
1 loops, best of 3: 1min 18s per loop
```

並列ありの`parse`と比べて20倍遅いです😱

## 総括
Python3でMeCabを使うときは`parseToNode`を使うとオーバーヘッドが大きいので`parse`を使った方が速く処理できます。さらに並列化するともっと処理時間が短くなります。ワーストケース (Python 3で並列なしで`parseToNode`で名詞抽出する場合) 78秒かかる処理が、今回紹介したやり方では3.72秒となり、およそ20倍の差がつきました。小規模のテキストを扱い場合なら誤差の内かもしれませんが、ちょっとした規模の量を処理するときなんかに恩恵を受けると思います＾＾