【Python】GiNZA+SudachiPyを使ってテキスト内の品詞出現頻度を解析してみた

この記事は　[ZOZO #3 Advent Calendar 2021](https://qiita.com/advent-calendar/2021/zozo) 11日目の記事になります。

# はじめに
文章中に存在する品詞の頻度数を見てみたいと思い、実現方法を調べたところGiNZAとSudachiPyの組み合わせが環境構築と実装の面でも簡単そうでした。

本記事では、**品詞の頻度解析の実装方法**と大量のテキストを処理する上で問題となっていた**処理時間の高速化方法**をあわせて紹介したいと思います。

# 最終的に出来ること
下記のようなワードがあったとき

```csv:input.csv
ナイロンっぽいトップス
渡せるフラワー
ピスタチオカラー
ロング
オシャレなブーツ
腕時計
セール対象商品
...
```

これらのワードをGiNZAとSudachiPyを使ったプログラムで解析し、下記のように結果を返します。
※ サンプルで用意したこれらのワードは自作したものです。

#### 形態素解析結果

```csv
keyword,token_pos
赤い靴下,赤い:ADJ|靴下:NOUN|
靴下+レッグウェア+レッグウェア,靴下:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|
ナイロンっぽいトップス,ナイロン:NOUN|っぽい:PART|トップス:NOUN|
渡せるフラワー,渡せる:PROPN|フラワー:NOUN|
...
```

#### 品詞頻度結果
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130670/c279a8e5-180f-3d5b-dea9-cb9b7a8d707c.png" width=60%>

# 用語説明
## GiNZAとは
[GiNZA](https://megagonlabs.github.io/ginza/)はPython自然言語ライブラリとして有名な[SpaCy](https://spacy.io/)をラップし、日本語を扱えるようにした自然言語処理オープンソースライブラリです。

特徴は以下の通りです

- 高度な自然言語処理をワンステップで導入完了
- 高速・高精度な解析処理と依存構造解析レベルの国際化に対応
- 国立国語研究所との共同研究成果の学習モデルを提供

出典：https://www.recruit.co.jp/newsroom/2019/0402_18331.html

## SudachiPyとは
GiNZAはトークン化（形態素解析）処理に[SudachiPy](https://github.com/WorksApplications/SudachiPy)を使います。
SudachiPyは、日本語形態素解析器のPython版です。

### 形態素解析とは
自然言語で書かれた文を言語上の最小単位である形態素に分割し、それぞれの品詞や変化などを割り出すことを表します。

### SudachiPyを使うメリット
日本語形態素解析器は他にも[kuromoji](https://www.atilika.com/ja/kuromoji/)や[MeCab](http://taku910.github.io/mecab/)などありますが、導入に一手間かかったり、形態素解析器で使える日本語辞書の更新が長らくされていなかったりと、いくつか課題がありました。

一方で、SudachiPyは専用の辞書（[SudachiDict](https://github.com/WorksApplications/SudachiDict)）があり、株式会社ワークスアプリケーションズの徳島人工知能NLP研究所によって現在も継続して開発されているため将来性があります。

# 品詞頻度解析ツール
## 事前準備
### Python3.9以上
Python3.9以上で動作確認しているので、pyenvなどでバージョンの切り替えを行いましょう。

### Poetryインストール
本ツールはpoetry でパッケージを管理しているので、まだインストールしていない場合は別途インストールを行ってください。もうすでにある方はスキップ。

```bash
$ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
```

### インストール
GitHubにコードがあるので、サクッと始めたい方はクローンしてください。

```bash
# クローン
$ git clone https://github.com/sattosan/analyze-pos-using-ginza.git
$ cd analyze-pos-using-ginza

# パッケージのインストール
$ poetry install
```

`./pyproject.toml`に書かれた下記依存パッケージがインストールされます。

- python = ">=3.9,<3.11"
- ginza = "^5.0.3" # GiNZA動作のために必要
- ja-ginza = "^5.0.0" # GiNZA動作のために必要
- emoji = "^1.6.1" # 絵文字除去のために必要
- mojimoji = "^0.0.12" # 全角半角を変換のために必要
- asyncio = "^3.4.3" # 使わないが一応入れている。
- tqdm = "^4.62.3" # 実行中の進行状況を表示するために必要
- neologdn = "^0.5.1" # 表記ゆれをなくすために必要
- matplotlib = "^3.5.0" # 棒グラフ描画に必要
- japanize-matplotlib = "^1.1.3" # 棒グラフの日本語ラベルが文字化けしないように導入

### 解析したいワードの入力
`./input.csv`に解析したいワードを入力します。

```csv:input.csv
ナイロンっぽいトップス
渡せるフラワー
ピスタチオカラー
ロング
オシャレなブーツ
腕時計
セール対象商品
...
```

## 実行
poetryを使ってコードを実行します。

```bash
$ poetry run python src/main.py
========ファイル読み込み開始========
100%|█████████████████████████████████████████████| 30/30 [00:00<00:00, 35128.17it/s]
========ファイル読み込み完了========
========形態素解析開始========
100%|█████████████████████████████████████████████| 26/26 [00:00<00:00, 99.57it/s]
{'形容詞': 4, '名詞': 40, '記号': 4, '接辞': 1, '固有名詞': 1, '助動詞': 2, '接置詞': 3, '数詞': 1, '代名詞': 1, '動詞': 1}
========形態素解析完了========
実行時間: 0.24016714096069336s
```

### 結果
実行結果は`./result/main`に保存されます。

#### 形態素解析結果
`./result/main/result_YYYYMMDDhhmmss.csv`には形態素解析の結果が保存されます。

```csv:result_YYYYMMDDhhmmss.csv
keyword,token_pos
赤い靴下,赤い:ADJ|靴下:NOUN|
靴下+レッグウェア+レッグウェア,靴下:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|
ナイロンっぽいトップス,ナイロン:NOUN|っぽい:PART|トップス:NOUN|
渡せるフラワー,渡せる:PROPN|フラワー:NOUN|
...
```

1列目は、元のワードが入っています。
2列目は、形態素と品詞のペアが`|`区切りで複数入っています。

#### 品詞頻度解析の結果
`./result/main/result_YYYYMMDDhhmmss.png`には品詞頻度解析の結果が保存されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130670/c279a8e5-180f-3d5b-dea9-cb9b7a8d707c.png" width=60%>

## コードの説明

### 形態素解析＆品詞分析ロジック
`main.py`に記載されている形態素解析＆品詞分析ロジックを説明します。
詳細は割愛しているので、気になる方は[main.py(GitHub)](https://github.com/sattosan/analyze-pos-using-ginza/blob/master/src/main.py)をご覧ください。

```python:./src/main.py
import collections
import time

import spacy
from tqdm import tqdm

import config
import utils


# 日本語辞書の読み込み
nlp = spacy.load('ja_ginza')
# 品詞カウント用
pos_counter = collections.Counter()


# 品詞の出現回数をカウント
def count_pos_of_token(keyword):
    try:
        token_pos_pair = ''
        # 形態素解析
        nlp_keyword = nlp(keyword)
        for sent in nlp_keyword.sents:
            for token in sent:
                token_pos_pair += f'{token.text}:{token.pos_}|'
                pos_counter[token.pos_] += 1
        return {'keyword': keyword, 'token_pos': token_pos_pair}
    except Exception:
        return {'keyword': keyword, 'token_pos': 'None'}
```

`count_pos_of_token`関数にキーワードを渡し、GiNZAのインスタンス`nlp`を使って形態素解析を行っています。

また、nlpインスタンスの作成には時間がかかるため、１回だけ呼ぶようにします。

```python
# 日本語辞書の読み込み
nlp = spacy.load('ja_ginza')
```

ここで、`nlp_keyword.sents`は「文（`sent`）」のジェネレータで、さらに`sent`は「形態素（{`token`）」のジェレネータです。

`token`は以下のようなプロパティがあります。

```
・token.i : トークン番号
・token.text : テキスト
・token.lemma_ : レンマ
・token.tag_ : 日本語の品詞タグ
・token.pos_ : Universal Dependenciesの品詞タグ
```

今回は、形態素と品詞のペアを知りたいので、`token.text`と`token.pos_`を使います。

次に品詞頻度を調べるために、Pythonの標準で用意されている`collections.Counter()`オブジェクトを作成しています。

```python
# 品詞カウント用
pos_counter = collections.Counter()
```

通常下記のように辞書型の値をカウントする場合は必ず初期化が必要でした

```python
dict['A'] = 0
for token in sent:
    if token == 'A':
        dict[token] += 1
```

Counterオブジェクトでは、初期化せずにカウント出来るのでスッキリかけます。

```python
for token in sent:
    dict[token] += 1
```

最後に、`count_pos_of_token`関数では形態素解析結果を辞書に詰めて返します。

```python
return {'keyword': keyword, 'token_pos': token_pos_pair}
```

### main関数
main関数では、解析対象のファイル(`input.csv`)を読み込み、先程紹介した`count_pos_of_token`関数で処理した結果を受け取って、ファイル(`./result/main/result_*.csv`)に書き込んだり`pos_counter`に格納された品詞頻度結果をもとに棒グラフ(`./result/main/result_*.png`)を作成しています。

ファイル読み書きや棒グラフに関するロジックは`./utils.py`にまとめてあるので詳細が気になる方は[utils.py(GitHub)](https://github.com/sattosan/analyze-pos-using-ginza/blob/master/src/utils.py)をご覧ください。

また、ファイルパスやタイムアウト時間などの設定は`./src/config.py`に記載しています。
[config.py(GitHub)](https://github.com/sattosan/analyze-pos-using-ginza/blob/master/src/config.py)

```python:./src/main.py
def main():
    # ファイル読み込み
    keywords = utils.get_file_contents(config.INPUT_FILEPATH, limit=None)

    print('========形態素解析開始========')
    result_csv = []
    for keyword in tqdm(keywords, total=len(keywords)):
        result_csv.append(count_pos_of_token(keyword))
    print('========形態素解析完了========')

    # ファイル書き込み
    utils.write_contents(config.NORMAL_OUTPUT_DIRPATH, result_csv)

    jp_pos_counter = utils.to_jp_keys_of_pos_counter(pos_counter)
    print(jp_pos_counter)
    # 品詞数を棒グラフ化した画像を生成
    utils.generate_graph(config.NORMAL_OUTPUT_DIRPATH, jp_pos_counter)

if __name__ == '__main__':
    start = time.time()
    main()
    end = time.time()
    print(f"実行時間: {end - start}s")
```

今回サンプルとして30個のワードを用意しましたが、より多くのワードを形態素解析する場合、実行時間が長くなる可能性があります。そのためfor文では`tqdm`パッケージを使ってターミナルにプログレスバーを標準出力して、進捗状況を可視化できるようにしています。

```bash
#100%|██████████| 100/100 [01:40<00:00,  1.00s/it]
```

## 高速化
著者のPC環境はCore i7でそこそこですが、数十万、数百万ものワードを処理するとそれなりに時間がかかってしまいます。

そこで、処理が並列で実行できるように改造し、高速化を図りました。

並列化したコードは`thread-main.py`です。詳細は[thread-main.py(GitHub)](https://github.com/sattosan/analyze-pos-using-ginza/blob/master/src/thread-main.py)をご覧ください。

### 並列実行出来るように改造したmain関数
`concurrent.futures.ThreadPoolExecutor`を使ってワーカースレッド`max_workers`に指定した数だけスレッドを立て並列で実行できるように改造しました。

```python
def main():
    # ファイル読み込み
    keywords = utils.get_file_contents(config.INPUT_FILEPATH, limit=None)

    # 複数スレッドを使って並列実行
    print('========形態素解析開始========')
    result_csv = []
    tasks = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=config.MAX_WORKERS) as executor:
        for keyword in keywords:
            tasks.append(executor.submit(count_pos_of_token, keyword))
        # 処理が終わったタスクは随時プログレスバーに反映される
        for future in tqdm(concurrent.futures.as_completed(tasks), total=len(tasks)):
            try:
                # 処理が終わらないとき用にタイムアウトを設定
                result = future.result(timeout=config.THREAD_TIMEOUT)
                # 結果をつめる
                result_csv.append(result)
            except concurrent.futures.TimeoutError:
                print("this took too long...")
    print(pos_counter)
    print('========形態素解析完了========')

    # ファイル書き込み
    utils.write_contents(config.THREAD_OUTPUT_DIRPATH, result_csv)

    jp_pos_counter = utils.to_jp_keys_of_pos_counter(pos_counter)
    print(jp_pos_counter)
    # 品詞数を棒グラフ化した画像を生成
    utils.generate_graph(config.THREAD_OUTPUT_DIRPATH, jp_pos_counter)
```

`executor.submit`にタスクを詰め込み、各タスク(`future`)の完了を待ち、結果を取得します。
`concurrent.futures.as_completed`では与えられた`tasks`の要素を完了順に辿るイテレータを返します。完了したタスクが無い場合は、タスクがひとつ完了するまでブロックされます。

```python
for keyword in keywords:
    tasks.append(executor.submit(count_pos_of_token, keyword))
# 処理が終わったタスクは随時プログレスバーに反映される
for future in tqdm(concurrent.futures.as_completed(tasks), total=len(tasks)):
    ...
```

いつまでもタスクが完了しない場合、ずっとブロックされてしまうので`future.result`の引数に`timeout`を指定しました。

```python
try:
   # 処理が終わらないとき用にタイムアウトを設定
   result = future.result(timeout=config.THREAD_TIMEOUT)
```

`count_pos_of_token`関数は`nlp`や`pos_counter`といったスレッド間で共有する変数を扱っているので、このままだと**セグメンテーション違反**になりかねません。

なので、`threading.Lock()`で`Lock`クラスのインスタンス`lock`を作成し、`with lock:`を使って違反を避けています

```python
# 日本語辞書の読み込み
nlp = spacy.load('ja_ginza')
# スレッドで変数を共有するためのロックを使う
lock = threading.Lock() # 追加
# 品詞カウント用
pos_counter = collections.Counter()


# 品詞の出現回数をカウント
def count_pos_of_token(keyword):
    try:
        # スレッド間で共通の変数を使うのでエラーにならないようにロックする
        with lock:
            # 形態素解析
            nlp_keyword = nlp(keyword)
            token_pos_pair = ""
            for sent in nlp_keyword.sents:
                for token in sent:
                    token_pos_pair += f"{token.text}:{token.pos_}|"
                    pos_counter[token.pos_] += 1
            return {'keyword': keyword, 'token_pos': token_pos_pair}
    except Exception:
        return {'keyword': keyword, 'token_pos': 'None'}
```

## 並列化したmain.pyの実行
下記コマンドで実行できます。

```bash
$ poetry run python src/thread-main.py
========ファイル読み込み開始========
100%|█████████████████████████████████████████████| 30/30 [00:00<00:00, 35128.17it/s]
========ファイル読み込み完了========
========形態素解析開始========
100%|█████████████████████████████████████████████| 26/26 [00:00<00:00, 99.57it/s]
{'形容詞': 4, '名詞': 40, '記号': 4, '接辞': 1, '固有名詞': 1, '助動詞': 2, '接置詞': 3, '数詞': 1, '代名詞': 1, '動詞': 1}
========形態素解析完了========
実行時間: 0.2710714340209961s
```

※ 分析するワード数が少ないと、並列化によるオーバーヘッドによって通常時より動作が遅くなる可能性があります

### 結果
実行結果は./result/threadに保存されます。
csvやpngはmainと同じものになるはずです。

# おわりに
GiNZAとSudachiPyを使って形態素解析を行い、matplotlibで品詞の頻度を可視化してみました。

並列化を想定すると少々複雑な処理が必要になりますが、そうでない場合、たかだか数行で形態素解析ができ、環境構築もpipのみなのでとてもお手軽です。