強い言葉判定器の実装メモ【Python】

# 概要
[ビーフストロガノフはどのくらい強いのか](https://qiita.com/Hirata-Masato/items/775ecd86f26f305fd393)という記事が好きすぎて、自分でも強い言葉言葉判定器をつくってみたくなりました。（著者の方、素敵な記事をありがとうございます）
上記の元記事は非常に丁寧に書いていただいているので、基本的に大きく詰まることなく実装できたのですが、kerasと深層学習の初心者だったこともあって少し調べないとわからないこともあったので、やったことをメモしておきたいと思います。（以下、特別な注釈がない限り「元記事」という言葉は上記記事を指すものとします）

# 環境

```
% sw_vers
ProductName:	macOS
ProductVersion:	12.3.1
BuildVersion:	21E258
```

# 準備

## 仮想環境
仮想環境を作って立ち上げておきます。

```
% mkdir [dirname]
% cd [dirname]
% python -m venv .venv
% source .venv/bin/activate
(.venv) % python -V
python 3.8.6
```

以降の作業は特に断りのない限り仮想環境で行うものとします。

## インストール

必要なライブラリをインストールしておきます。
```
pip install --upgrade pip
pip install selenium
pip install chromedriver_binary==[chromeのバージョン]
pip install pandas
pip keras
pip install tensorflow
pip install mecab-python3
pip install jaconv
```

seleniumとchromedriver_binaryは単語収集のときに楽をするために使いましたが、全部手動でコピペする場合は不要です。
chromedriver_binaryはPCにインストールされているchromeのバージョンと合ったものをインストールする必要があります。
参考：[selenium向け ChromeDriverをpipでインストールする方法（パス通し不要、バージョン指定可能）](https://qiita.com/hanzawak/items/2ab4d2a333d6be6ac760)

# 単語の収集
一番大変でした。
元記事で上げていただいたWebページを一つずつ開いて、基本的には手動でコピペしていきました。
同じ構造のサイトが複数登場したり、あまりに手動コピペが面倒なページについては、pythonでスクレイピングしました。動的にhtmlを読み込むタイプのページもあったので、スクレイピングにはselniumを使いました。

「大学」や「駅」のランキングから収集した単語は、「大学」や「駅」が末尾についてしまうのでこれは削除したほうが精度が上がるかもしれません。今回は一旦削除せずに学習していますが、普通に考えたら「大学」や「駅」が強い言葉であるというバイアスが乗ってしまうので、除いたほうがいい気がします。

単語リストとしては、単語の表層形と読みをセットで取得する必要がありますが、それぞれコピペしていると大変なので、基本的に表層形のみをコピペしたあとで、MeCabで読みを推測し、目検で修正するようにしました。
固有名詞も多いのでMeCabの推定も割と失敗しますが、とはいえ、大半は正解しているので、読みをいちいち手動コピペするよりはマシだったと思います。初期辞書よりもmecab-ipadic-neologdを使ったほうがミスが少ないと思います。

また目検修正をするために一旦スプレッドシートに情報を集約しようと思ったので、pyperclipを使ってクリップボードに情報を貼り付けたりもしました。

参考にスクレイピングして、読みを推測して、スプレッドシートに貼り付け可能な形でクリップボードに貼り付けるコードを記載します。（スクレイピングは利用規約を守り、サーバーに負担をかけない範囲で、自己責任で実施してください）

```Python
# Webドライバーの起動
from selenium import webdriver
import chromedriver_binary

# webdriverのインスタンスを作成
driver = webdriver.Chrome()

# mecabのインスタンスを作成
import MeCab
mecab = MeCab.Tagger("-Oyomi")

# クリップボード操作用のライブラリ
import pyperclip 

```

```Python
url = "URL"
driver.get(url) # ブラウザが起動しているはずなので手動でurlを打ち込んでも良い。

selector = "webのhtmlから必要な情報を取得するためのセレクタを書く"
tags = driver.find_elements_by_css_selector(selector) # 

rows = []
for tag in tags:
  # textを取得
  text = tag.text
  """
  textに追加で加工が必要ならその処理を書く
  """
  rows.append(text)
rows = ["{}\t{}".format(v, mecab.parse(v).strip()) for v in rows] # 表層と読みをtab区切りで取得（スプレッドシートに貼り付けやすいように）
pyperclip.copy("\n".join(rows)) # クリップボードにコピー
```

# 単語リストの作成
収集した強い言葉、弱い言葉、ニュートラルな言葉を一つのファイルにまとめます。
またその際、表層形（surface）、読み（yomi）に加えて、強さ（strength）の列を設定します。
strengthは強い、弱い、ニュートラルのとき、それぞれ1, -1, 0とします。strengthの値については、元記事に言及がなかったのですが、多分、こういう感じの設定だったのではないかと想像しています。

```:words.csv
surface,yomi,strength
皇,スメラギ,1
柊,ヒイラギ,1
榊,サカキ,1
鳳,オオトリ,1
...
```

読みはこの時点で、カタカナ表記に統一しておきます。ひらがなが混じっている場合は、mecabやjaconvなどでカタカナに変換しておきます。
元記事はromaji表記も取得していますが、それはこのあとにやります。
ここでは、surface, yomi, strength列が存在していればOKです。

# 学習用にデータ整形

先程作成したwords.csvをpandas.DataFrameで読み込んで、kerasに入力するための列（romaji, surface_w, yomi_w）を作ります。

```Python
import pandas as pd
import jaconv
word_list = pd.read_csv("words.csv")
word_list["romaji"] = word_list["yomi"].map(lambda x: jaconv.hiragana2julius(jaconv.kata2hira(x))) # ローマ字、というか発音表記の取得
word_list["surface_w"] = word_list["surface"].map(lambda x: " ".join(list(x)))
word_list["yomi_w"] = word_list["yomi"].map(lambda x: " ".join(list(x)))
word_list.head()
```

romajiは元記事では"ittoryodan"のようなローマ字表記なのですが、今回は"i q t o: ry o: d a N"のようなjuliusの発音表記を使ってみました。
理由は「ん」や「っ」などが含まれているときは特に、juliusの発音表記のほうがローマ字表記よりも実際の発音を正確に反映していると思ったからです。
ただし、ローマ字表記では"ry"は"r"を含んだ子音として（おそらく）学習されるのに対し、発音表記では”r"と"ry"は全く別の子音として（おそらく）学習されてしまいます。したがって発音表記がローマ字表記の完全な上位互換というわけではないので、好み（やテストデータが用意できるのであればその結果を比較するなど）でよいかと思います。

surface_w, yomi_wはsurface, yomiを１文字ずつスペースで区切った文字列です（"一刀両断" -> "一 刀 両 断", "イットウリョウダン" -> "イ ッ ト ウ リ ョ ウ ダ ン"など）
後述のkerasのTokenizerはスペースを区切り文字として認識するようなので、そのようにしました。romajiについてはjaconvの出力の時点で、スペース区切りとなっているので、そのまま使います。

# トーカナイズ
トーカナイズは元記事のコードをほぼそのまま使わせていただきました。
列名だけ、分かち書きしたもの（surface_w, yomi_w）を与えるようにしてください。（わざわざ分かち書きした列を作らなくても引数指定とかで文字単位でtokenizeできる方法が用意されていそうな気もしますが、よくわからなかったので）


```Python
from keras.preprocessing.text import Tokenizer

# ベクトル化したい文章をリストで宣言します。
texts = word_list["surface_w"].tolist() + word_list["yomi_w"].tolist() + word_list["romaji"].tolist()

# Tokenizerをインスタンス化し、上で用意した文章を与えます。
tokenizer = Tokenizer()
tokenizer.fit_on_texts(texts)

WORD_MAXLEN = 32
YOMI_MAXLEN = 32
ROMAJI_MAXLEN = 64

from keras_preprocessing.sequence import pad_sequences
sequence_word = tokenizer.texts_to_sequences(word_list.surface_w)
sequence_yomi = tokenizer.texts_to_sequences(word_list.yomi_w)
sequence_romaji = tokenizer.texts_to_sequences(word_list.romaji)
```

# モデルの学習まで
以降、model.fitまでは元記事のコードをそのまま使わせていただきました。
元記事に記載の通り、学習は３分もあれば終わりました。

# モデル、tokenizerの保存
学習が完了したモデルを保存します。
model.saveで保存が可能ですが、再学習が不要の場合は、以下のようinclude_optimizer=Falseを設定し、load時にcompile=Falseとすると、ロードが早いです。

```Python
model.save("model.h5", include_optimizer=False)
```
参考: [Kerasのノウハウ覚え書き](https://qiita.com/ak11/items/67118e11b756b0ee83a5)

またtokenizerも予測に必要なので保存します。

```Python
# tokenizerの保存
import pickle
with open("model/tokenizer.pickle", 'wb') as handle:
  pickle.dump(tokenizer, handle, protocol=pickle.HIGHEST_PROTOCOL)

```


# モデル、tokenizerの読み込み
モデルを読み込みます。前述の通り、compile=Falseで読み込みます。
tokenizerもpickleで読み込みます。

```Python
model = keras.model.load_model("model.h5",compile=False)
tokenizer = pickle.load(open("model/tokenizer.pickle", 'rb'))
```

# 予測
元記事にあるようにsequence_word (表層）、sequence_yomi, sequence_romajiの３つを入力することで、強さの予測スコアを返すことができます。
日本語をsequence_...に変換するコードは以下のとおりです。

```
surface_w,  yomi_w, romaji = "一刀両断", "イットウリョウダン", "i q t o: ry o: d a N"
sequence_word = tokenizer.texts_to_sequences([surface_w])
sequence_yomi = tokenizer.texts_to_sequences([yomi_w])
sequence_romaji = tokenizer.texts_to_sequences([romaji])
sequence_word = pad_sequences(sequence_word, maxlen=WORD_MAXLEN, dtype='int32', padding='post', truncating='post', value=0)
sequence_yomi = pad_sequences(sequence_yomi, maxlen=YOMI_MAXLEN, dtype='int32', padding='post', truncating='post', value=0)
sequence_romaji = pad_sequences(sequence_romaji, maxlen=ROMAJI_MAXLEN, dtype='int32', padding='post', truncating='post', value=0)
```

予測値は以下のように２重のリストで返ってきます。

```Python
y_pred = model.predict([sequence_word, sequence_yomi, sequence_romaji])
print(y_pred)
```

```
[[ 1.0497698 ]]
```

使いやすいように日本語を入力したら、予測値を返す関数predictを定義します。

```Python
mecab = MeCab.Tagger('-Oyomi')

# 入力はsurface, yomi, romajiをkeyとするdict。surfaceまたはyomiの少なくともどちらか一方は必ず入力される。その他のkeyはなくても動く
def predict(ipt, model):
  surface, yomi, romaji = ipt.get("surface"), ipt.get('yomi'), ipt.get('romaji')
  assert surface or yomi # surfaceもyomiもないとき、エラー

  if not surface: surface = yomi # surfaceがなければyomiをsurfaceとする
  elif not yomi: yomi = surface # yomiがなければsurfaceをyomiとする
  yomi = mecab.parse(yomi).strip() # yomiをmecabでカタカナに変換する
  if not romaji:
    romaji = jaconv.hiragana2julius(jaconv.kata2hira(yomi)) # romajiがなければyomiをromajiに変換する

  surface_w = " ".join(list(surface))
  yomi_w = " ".join(list(yomi))

  sequence_word = tokenizer.texts_to_sequences([surface_w])
  sequence_yomi = tokenizer.texts_to_sequences([yomi_w])
  sequence_romaji = tokenizer.texts_to_sequences([romaji])
  sequence_word = pad_sequences(sequence_word, maxlen=WORD_MAXLEN, dtype='int32', padding='post', truncating='post', value=0)
  sequence_yomi = pad_sequences(sequence_yomi, maxlen=YOMI_MAXLEN, dtype='int32', padding='post', truncating='post', value=0)
  sequence_romaji = pad_sequences(sequence_romaji, maxlen=ROMAJI_MAXLEN, dtype='int32', padding='post', truncating='post', value=0)
  return model.predict([sequence_word, sequence_yomi, sequence_romaji])[0][0]
  ```

predict関数を使ってみます。

```Python
print(predict({"surface":"象"},loaded))
print(predict({"surface":"象","yomi":"エレファント"},loaded))
print(predict({"yomi":"エレファント"},loaded))
```

```
1/1 [==============================] - 0s 28ms/step
0.33264643
1/1 [==============================] - 0s 26ms/step
0.5922932
1/1 [==============================] - 0s 30ms/step
-0.40525636
```

いい感じです。

# おわりに
[ビーフストロガノフはどのくらい強いのか](https://qiita.com/Hirata-Masato/items/775ecd86f26f305fd393)という記事を参考に強い言葉の判定器を作ってみました。
本記事自体には特に技術的な進歩性はありませんが、私と同じ初心者で具体的な実装でつまる方がもしいたら、なにかの助けになると幸いです。
今後は、何かアプリケーションに落とし込んだり、データを変えて予測結果の違いを眺めてみたいなと思っています（そのためには評価のためのデータセットを作る必要もあり、時間がかかりそうですが）。