この記事は ZOZO #3 Advent Calendar 2021 11日目の記事になります。
はじめに
文章中に存在する品詞の頻度数を見てみたいと思い、実現方法を調べたところGiNZAとSudachiPyの組み合わせが環境構築と実装の面でも簡単そうでした。
本記事では、品詞の頻度解析の実装方法と大量のテキストを処理する上で問題となっていた処理時間の高速化方法をあわせて紹介したいと思います。
最終的に出来ること
下記のようなワードがあったとき
ナイロンっぽいトップス
渡せるフラワー
ピスタチオカラー
ロング
オシャレなブーツ
腕時計
セール対象商品
...
これらのワードをGiNZAとSudachiPyを使ったプログラムで解析し、下記のように結果を返します。
※ サンプルで用意したこれらのワードは自作したものです。
形態素解析結果
keyword,token_pos
赤い靴下,赤い:ADJ|靴下:NOUN|
靴下+レッグウェア+レッグウェア,靴下:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|
ナイロンっぽいトップス,ナイロン:NOUN|っぽい:PART|トップス:NOUN|
渡せるフラワー,渡せる:PROPN|フラワー:NOUN|
...
品詞頻度結果
用語説明
GiNZAとは
GiNZAはPython自然言語ライブラリとして有名なSpaCyをラップし、日本語を扱えるようにした自然言語処理オープンソースライブラリです。
特徴は以下の通りです
- 高度な自然言語処理をワンステップで導入完了
- 高速・高精度な解析処理と依存構造解析レベルの国際化に対応
- 国立国語研究所との共同研究成果の学習モデルを提供
出典:https://www.recruit.co.jp/newsroom/2019/0402_18331.html
SudachiPyとは
GiNZAはトークン化(形態素解析)処理にSudachiPyを使います。
SudachiPyは、日本語形態素解析器のPython版です。
形態素解析とは
自然言語で書かれた文を言語上の最小単位である形態素に分割し、それぞれの品詞や変化などを割り出すことを表します。
SudachiPyを使うメリット
日本語形態素解析器は他にもkuromojiやMeCabなどありますが、導入に一手間かかったり、形態素解析器で使える日本語辞書の更新が長らくされていなかったりと、いくつか課題がありました。
一方で、SudachiPyは専用の辞書(SudachiDict)があり、株式会社ワークスアプリケーションズの徳島人工知能NLP研究所によって現在も継続して開発されているため将来性があります。
品詞頻度解析ツール
事前準備
Python3.9以上
Python3.9以上で動作確認しているので、pyenvなどでバージョンの切り替えを行いましょう。
Poetryインストール
本ツールはpoetry でパッケージを管理しているので、まだインストールしていない場合は別途インストールを行ってください。もうすでにある方はスキップ。
$ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
インストール
GitHubにコードがあるので、サクッと始めたい方はクローンしてください。
# クローン
$ 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
に解析したいワードを入力します。
ナイロンっぽいトップス
渡せるフラワー
ピスタチオカラー
ロング
オシャレなブーツ
腕時計
セール対象商品
...
実行
poetryを使ってコードを実行します。
$ 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
には形態素解析の結果が保存されます。
keyword,token_pos
赤い靴下,赤い:ADJ|靴下:NOUN|
靴下+レッグウェア+レッグウェア,靴下:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|+:SYM|レッグ:NOUN|ウェア:NOUN|
ナイロンっぽいトップス,ナイロン:NOUN|っぽい:PART|トップス:NOUN|
渡せるフラワー,渡せる:PROPN|フラワー:NOUN|
...
1列目は、元のワードが入っています。
2列目は、形態素と品詞のペアが|
区切りで複数入っています。
品詞頻度解析の結果
./result/main/result_YYYYMMDDhhmmss.png
には品詞頻度解析の結果が保存されます。
コードの説明
形態素解析&品詞分析ロジック
main.py
に記載されている形態素解析&品詞分析ロジックを説明します。
詳細は割愛しているので、気になる方はmain.py(GitHub)をご覧ください。
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インスタンスの作成には時間がかかるため、1回だけ呼ぶようにします。
# 日本語辞書の読み込み
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()
オブジェクトを作成しています。
# 品詞カウント用
pos_counter = collections.Counter()
通常下記のように辞書型の値をカウントする場合は必ず初期化が必要でした
dict['A'] = 0
for token in sent:
if token == 'A':
dict[token] += 1
Counterオブジェクトでは、初期化せずにカウント出来るのでスッキリかけます。
for token in sent:
dict[token] += 1
最後に、count_pos_of_token
関数では形態素解析結果を辞書に詰めて返します。
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)をご覧ください。
また、ファイルパスやタイムアウト時間などの設定は./src/config.py
に記載しています。
config.py(GitHub)
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
パッケージを使ってターミナルにプログレスバーを標準出力して、進捗状況を可視化できるようにしています。
#100%|██████████| 100/100 [01:40<00:00, 1.00s/it]
高速化
著者のPC環境はCore i7でそこそこですが、数十万、数百万ものワードを処理するとそれなりに時間がかかってしまいます。
そこで、処理が並列で実行できるように改造し、高速化を図りました。
並列化したコードはthread-main.py
です。詳細はthread-main.py(GitHub)をご覧ください。
並列実行出来るように改造したmain関数
concurrent.futures.ThreadPoolExecutor
を使ってワーカースレッドmax_workers
に指定した数だけスレッドを立て並列で実行できるように改造しました。
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
の要素を完了順に辿るイテレータを返します。完了したタスクが無い場合は、タスクがひとつ完了するまでブロックされます。
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
を指定しました。
try:
# 処理が終わらないとき用にタイムアウトを設定
result = future.result(timeout=config.THREAD_TIMEOUT)
count_pos_of_token
関数はnlp
やpos_counter
といったスレッド間で共有する変数を扱っているので、このままだとセグメンテーション違反になりかねません。
なので、threading.Lock()
でLock
クラスのインスタンスlock
を作成し、with lock:
を使って違反を避けています
# 日本語辞書の読み込み
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の実行
下記コマンドで実行できます。
$ 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のみなのでとてもお手軽です。