Juman++をMultiThreadで使う

# 目的

形態素解析を使いたいという状況において、MultiThreadにしたいという場面はいくつかあります。

- WEBアプリ・スマホアプリとしてリアルタイムに形態素解析を使いたい
- ビッグデータに対して形態素解析を用いる時に、処理を高速化したい

Pythonにおいて一般的なJumanのライブラリといえばpyKNPだと思います。
ただ、pyKNPの通常の方法ではMultiThreadができません。
そのため、PythonのMultiThread状況下でもJumanを動かす方法を防備録代わりにメモします。


# 環境

- Ubuntu 18.04.6 LTS
- Python 3.6.7
- pyKNP 0.4.6
- Juman++ 2.0.0-rc3（一部1.02を使用）
- Ruby 2.5.1p57


#  MultiThread化する主な方法

PythonからJumanを実行する方法はいくつかあります。

1. pyknpの通常モード
1. pythonとは別にJumanサーバー(ruby)を立ち上げて、pyknpにサーバーモードの設定をする
1. コマンドラインからサーバーを立ち上げて、rubyを経由せずにpythonから呼び出す

1の方法ではMultiThreadができません。
最終的にMultiThread化に成功したのは３の方法です。
２の方法でも公式のファイルを修正すればできそうな気はしますが、処理時間も３のほうが早いので３でいいと思います。
以下の内容は３の方法の説明です。
一応「おまけ」に１や２の方法も記載しておきますので興味のある人は見てみて下さい。


# コマンドラインからサーバーを立ち上げて、Pythonから呼び出す（方法３）

こちらを参考に進めていきます。

https://symfoware.blog.fc2.com/blog-entry-1993.html


まずはコマンドラインからJumanのサーバーを起動します。

```
$ juman -S -F
```
TCP通信で接続します。
jumanの場合、portはデフォルトで32000のはずなので確認してみます。
```
$ lsof -i | grep juman
juman (省略) TCP *:32000 (LISTEN)
```

ちなみにバージョンは7.01です。

```python
import socket
import sys
import time
from pyknp import MList

name = 'localhost'
port = 32000

def get_response(text):
    # jumanサーバーへ接続
    juman_s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    juman_s.connect((name, port))
    res = juman_s.recv(1024)
    juman_s.sendall('RUN\n'.encode('utf-8'))
    res = juman_s.recv(1024).decode('utf-8')
    if not 'OK' in res:
        return None

    context = text+'\n'
    juman_s.sendall(context.encode('utf-8'))
    juman_result = juman_s.recv(4096)
    return MList(juman_result.decode('utf-8'))

# 時間を計測します
start_time = time.time()
N = 100
# N = 1000
# N = 10000
for i in range(N):
    juman_result = get_response('ラーメンと自動車、食べ物はどちらでしょうか？')
print(time.time() - start_time)
```


|  繰り返し回数 |  100回  |  1000回  |  10000回  |
| ---- | ---- | --- | --- |
|  方法３ | 0.179 | 1.876 | 19.02 |

おおよそ、1万回の呼び出しで20sほどです。
後ほど他の方法でかかる時間と比較しますが、ざっくり数倍の速さにはなっている気がします。
ただ、速くなってもMultiThreadで使えなくては意味ないので、試してみます。

```python:multithread_juman.py
# データを与えておいて、指定した数のthreadがデータを１つずつ取り出して処理を行う
import threading
import time
import socket
import sys
import time
from pyknp import MList

class MultiThread():
    
    def __init__(self, lst, ip_address = 'localhost', port = 32000):
        self.lst = lst
        self.threads = []
        self.first_length = len(self.lst)
        self.ip_address = ip_address
        self.port = port

    # １つのThreadが実行する関数
    def run(self):
        while len(self.lst) is not 0:
            target = self.lst.pop()
            self.process(target)

    # テスト用のため、解析するだけで特に何もしない
    def process(self, target):
        self.get_response(target)
    
    # 複数のThreadを立ち上げる関数
    # num_processはPCのスペックに合わせて適宜調整        
    def start(self, num_process = 4):
        for i in range(1, num_process):
            t = threading.Thread(target=self.run)
            t.start()
            self.threads.append(t)
            
    # 全てのThreadの処理が終了するまで待つ
    def wait(self):
        start_time = time.time() 
        for t in self.threads:
            t.join()
        end_time = time.time() 
        print('処理時間', end_time - start_time)
        
    def get_response(self, text):
        # jumanサーバーへ接続
        juman_s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        juman_s.connect((self.ip_address, self.port))
        res = juman_s.recv(1024)
        juman_s.sendall('RUN\n'.encode('utf-8'))
        res = juman_s.recv(1024).decode('utf-8')
        if not 'OK' in res:
            return None

        context = text+'\n'
        juman_s.sendall(context.encode('utf-8'))
        juman_result = juman_s.recv(4096)
        return MList(juman_result.decode('utf-8'))
        
target_words = ['カレーには肉と野菜が含まれていることが一般的です'] * 10000
t =  MultiThread(target_words)
t.start(num_process = 8)
t.wait()

結果
処理時間 11.043
```


エラーになることなく結果がでてきました。
MultiThreadにすることで並列処理ができたので、MList辺りの処理が高速化できたのか、微妙に処理時間が短くなりましたね。
今はprocessの所で特に何も難しい処理をしていませんが、ここが肥大化すればするほどMultiThreadにすることによる恩恵が生まれてくると思います。
高速化もそうですが、これでWEBサイトのような複数人のユーザが同時にアクセスする環境でもJumanが使えるかと思います。


# おまけ

JumanをMultiThreadで使うという趣旨からはそれますが、公式で紹介されているサーバースクリプトの起動の仕方と、その方法でかかる処理時間をメモとして残します。

## 公式で紹介されているJumanのサーバー化の方法

Jumanのインストールの仕方はいくつかありますが、[Github](https://github.com/ku-nlp/jumanpp)の方にはJumanのサーバーを立ち上げるための「server.rb」がありません。
（バージョン2.0にはサーバー化する方法がない可能性？ 未確認）

そのため、古いバージョンである1.02を入手します。
公式の資料としては[こちら](http://lotus.kuee.kyoto-u.ac.jp/nl-resource/jumanpp/jumanpp-manual-1.0.pdf)が参考になります。

```
$ wget http://lotus.kuee.kyoto-u.ac.jp/nl-resource/jumanpp/jumanpp-1.0.tar.xz
$ tar Jxfv jumanpp-1.02.tar.xz
$ cd jumanpp-1.02/
$ ls script/
client.rb  corpus2train.rb  fullmrp2basep.rb  jmn2base.py  postprocess.py  server.rb
```

「server.rb」がちゃんとあります。
では早速起動してみます。

```
$ ruby script/server.rb
```
次に、クライアント側からアクセスしてみます。
pyknpにはserverモードにする時の引数があるので、それを使います。

```python:client_juman.py
from pyknp import Juman
# jumanの引数にserverのアドレスとポートを指定。
# 「server.rb」のデフォルトポートは12000
juman = Juman(server='localhost', port=12000)

# 解析して、カテゴリ情報を出してみる
juman_result = juman.analysis('ラーメンと自動車、食べ物はどちらでしょうか？')
for mrph in juman_result.mrph_list():
    if mrph.imis != 'NIL':
        imis_dict = {ele.split(':')[0]:ele.split(':')[1] for ele in mrph.imis.split(' ') if ':' in ele}
        if 'カテゴリ' in imis_dict:
            print(mrph.genkei, 'is categorized as', imis_dict['カテゴリ'])

```

```
$ python client_juman.py

~/.pyenv/versions/3.6.7/lib/python3.6/site-packages/pyknp/juman/process.py in __init__(self, hostname, port, option)
     17             raise
     18         if option is not None:
---> 19             self.sock.send(option)
     20         data = ""
     21         while "OK" not in data:

TypeError: a bytes-like object is required, not 'str'
```
pyknpでエラーが出るみたいです。
ライブラリの問題っぽいので、まずは修正すべきファイルの場所を探します。

```
$ pip show pyknp

Name: pyknp
Version: 0.4.6
Summary: Python module for JUMAN/KNP.
Home-page: https://github.com/ku-nlp/pyknp
Author: Kurohashi-Kawahara Lab, Kyoto Univ.
Author-email: contact@nlp.ist.i.kyoto-u.ac.jp
License: See COPYING
Location: /home/xxxx/.pyenv/versions/3.6.7/lib/python3.6/site-packages
Requires: six
Required-by: 
```

Locationに記されている場所を開きます。
中身を見ていると、Socket周りが原因な感じがする。

## 修正前のコード
```python:pyknp/juman/process.py
class Socket(object):

    def __init__(self, hostname, port, option=None):
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.connect((hostname, port))
        except:
            raise
        if option is not None:
            self.sock.send(option)
        data = ""
        while "OK" not in data:
            data = self.sock.recv(1024)

    def __del__(self):
        if self.sock:
            self.sock.close()

    def query(self, sentence, pattern):
        assert(isinstance(sentence, six.text_type))
        sentence = sentence.strip() + '\n'  # ensure sentence ends with '\n'
        self.sock.sendall(sentence.encode('utf-8'))
        data = self.sock.recv(1024)
        recv = data
        while not re.search(pattern, recv):
            data = self.sock.recv(1024)
            recv = "%s%s" % (recv, data)
        return recv.strip().decode('utf-8')
```
## 修正後のコード
```python:pyknp/juman/process.py
class Socket(object):

    def __init__(self, hostname, port, option=None):
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.connect((hostname, port))
        except:
            raise
        if option is not None:
            self.sock.send(option.encode('utf-8'))
        data = ""
        while "EOS" not in data:
            data = self.sock.recv(1024).decode('utf-8')

    def __del__(self):
        if self.sock:
            self.sock.close()

    def query(self, sentence, pattern):
        assert(isinstance(sentence, six.text_type))
        sentence = sentence.strip() + '\n'  # ensure sentence ends with '\n'
        self.sock.sendall(sentence.encode('utf-8'))
        data = self.sock.recv(1024).decode('utf-8')
        recv = data
        while "EOS" not in recv:
            data = self.sock.recv(1024).decode('utf-8')
            recv = "%s%s" % (recv, data)
        return recv.strip()
```
結構書き換えたので、細かく説明はしません。
ごりごり自己解釈で書き換えてしまったところがあるのでpull request発行する勇気はないですが、これで一応動きはします。
(実行は自己責任でお願いします)

```
$ python client_juman.py

ラーメン is categorized as 人工物-食べ物
自動車 is categorized as 人工物-乗り物
食べ物 is categorized as 人工物-食べ物
```

動いてそうな気はする。
以下はサーバー化することで得られるであろう恩恵をテストします。

## 公式のサーバー化の方法はMultiThreadでも動くか
pyknpを普通に使うとシングルスレッドでしか動きません。pyknpで使っているsignalがエラーを出すためです。
ただ中身を見てみると、serverモードではsignalを呼び出していないようですので、serverモードならmultithreadでも動くかも知れないという淡い期待を胸に取り組んでみます。
```python
# データを与えておいて、指定した数のthreadがデータを１つずつ取り出して処理を行う
import threading
import time

class MultiThread():
    
    def __init__(self, lst, server='localhost', port=12000):
        self.lst = lst
        self.threads = []
        self.first_length = len(self.lst)
        # serverを指定しているため、もしかしたらMultiThreadでも動くかもしれない
        self.juman = Juman(server=server, port=port)

    def run(self):
        while len(self.lst) is not 0:
            print("(%i/%i)" % (len(self.lst),self.first_length))
            target = self.lst.pop()
            self.process(target)
            
    def process(self, target):
        # テスト用のため、解析するだけで特に何もしない
        self.juman.analysis(target)
            
    def start(self, num_process = 4):
        for i in range(1, num_process):
            t = threading.Thread(target=self.run)
            t.start()
            self.threads.append(t)
            
    def wait(self):
        start_time = time.time() 
        for t in self.threads:
            t.join()
        end_time = time.time() 
        print('処理時間', end_time - start_time)
```
まずは通常モードで呼び出します。
```python
target_words = ['カレーには肉と野菜が含まれていることが一般的です'] * 10
t =  MultiThread(target_words, server=None, port=None)
t.start(num_process = 8)
t.wait()

結果
ValueError: signal only works in main thread
```
やはりダメですね。クライアント側でエラー吐いているみたいです。
ではサーバーモードでやってみます。

```python
target_words = ['カレーには肉と野菜が含まれていることが一般的です'] * 10
t =  MultiThread(target_words, server='localhost', port=12000)
t.start(num_process = 8)
t.wait()

結果
(10/10)
(9/10)
(8/10)
(7/10)
(6/10)
(5/10)
(4/10)
処理がいつまでも終わらない.....
```

エラーはなくなりましたが、動作が止まっています。
クライアント側は問題なさそうなので、サーバー側がマルチスレッドに対応していないのが原因だと思います。

ひとまず、jumanのscript/server.rbはシングルスレッドのみだと解釈できたので、MultiThreadに対応させるためには、新しくサーバーサイドを書く必要がありそうです。


## 処理時間は短くなったか
通常モードだと呼び出しの度にオーバーヘッドが発生していたものが、サーバーモードだとなくなるはずなので、どれくらい早くなるか見てみます。
Multi-Threadは失敗したので、普通にSingle-Threadで比較します。

```python
from pyknp import Juman

text = 'カレーには肉と野菜が含まれていることが一般的です'
start_time = time.time() 
# 通常モード
juman = Juman(server=None, port=None)
# サーバーモード
# juman = Juman(server='localhost', port=12000)
for i in range(100):
    juman.analysis(text)
print('経過時間', time.time() - start_time)
```

|  繰り返し回数 |  100回  |  1000回  |  10000回  |
| ---- | ---- | --- | --- |
|  方法１（通常モード） | 0.848 | 8.546 | 86.40 |
|  方法２（サーバーモード）  |  0.911  | 9.251 | 93.74 |

Jumanのバージョンの影響か、サーバーのほうが通常よりも時間かかっています。

冒頭の「コマンドラインからサーバーを立ち上げて、Pythonから呼び出す方法」（方法３）も含めてと比較するとこんな感じです。

|  繰り返し回数 | jumann++ バージョン |  100回  |  1000回  |  10000回  |
| ---- | ---- | --- | --- | --- |
|  方法１（通常モード） | 2.0.0-rc3 | 0.848 | 8.546 | 86.40 |
|  方法２（サーバーモード）  | 1.02 |  0.911  | 9.251 | 93.74 |
|  方法３（MultiThread）  | 2.0.0-rc3 |  0.188  | 0.910 | 9.622 |

処理時間の計測の方法が色々異なるので信頼性の高いデータではないですが、3の方法はJumanを高速化できる可能性のある方法の１つとして見ていただけたらと思います。

# 感想

間違い等ありましたらご指摘よろしくお願いします。