ワードウルフのお題（単語ペア）を word2vec で自動生成する

# はじめに
「ワードウルフ」という人狼系のゲームがあります。

> ワードウルフとは、みんなで“あるお題”について話し合う中、「みんなとは異なるお題」を与えられた少数派の人（ワードウルフ）を探し出すゲームです。村人に紛れた人狼を見つけ出す「人狼ゲーム」に似ているので、ワード人狼と呼ばれることもあります。
> 
> 出典：[【全てわかる】『ワードウルフ』少数派探しゲームを徹底紹介 | ぼくとボドゲ](https://boku-boardgame.net/wordwolf)

スマホアプリなどで手軽に遊べて盛り上がるゲームなんですが、長く楽しむためには、良いお題（村人に与えるお題と人狼に与えるお題のペア＝近いけど異なる単語ペア）がたくさん必要です。というわけで、ワードウルフのお題を大量に自動生成してみます。

# やったこと
近いけど異なる単語ペアを自動生成できればよいのですが、やり方は色々ありそうです。今回は、単語をベクトル化（分散表現化）[^vec] して単語間の類似度を定量的に算出できるようにするアプローチでやってみました。「単語のベクトル化（分散表現化）」手法としては、word2vec[^word2vec] を使いました。word2vec では、同じような意味や使われ方をする単語は同じような文脈の中に登場するという考え方に基づいて、単語をベクトル化します。

というわけで、方針は以下のとおりです。

1. [wikipedia の日本語記事を word2vec で学習して単語の分散表現を獲得](#wikipedia-の日本語記事を-word2vec-で学習して単語の分散表現を獲得)
1. [分散表現間の距離（単語の類似度）に基づいて単語ペアを自動生成](#分散表現間の距離単語の類似度に基づいて単語ペアを自動生成)

以降では、Python での実装方法を説明していきます。

[^vec]: 「[分散表現(単語埋め込み) - 岩波データサイエンス](https://sites.google.com/site/iwanamidatascience/vol2/word-embedding)」
[^word2vec]: 「[Word2vec - Wikipedia](https://ja.wikipedia.org/wiki/Word2vec)」


## wikipedia の日本語記事を word2vec で学習して単語の分散表現を獲得
単語の分散表現を学習するためのデータセットとしては、wikipedia の日本語記事を使いました。[TensorFlow Datasets](https://www.tensorflow.org/datasets) の [wiki40b](https://www.tensorflow.org/datasets/catalog/wiki40b) が前処理済みでとても便利です[^wiki40b]。

[^wiki40b]: 「[Wikipediaの前処理はもうやめて「Wiki-40B」を使う - Ahogrammer](https://hironsan.hatenablog.com/entry/how-to-use-wiki40b)」「[wiki-40b の使い方｜npaka｜note](https://note.com/npaka/n/n8b1b87b45162)」


### wikipedia の日本語記事（wiki40b/ja）を読み込み
wiki40b/ja には、wikipedia の日本語記事 80万ページほどが収録されています。今回は、train（収録全体のうち 90%）、validation（収録全体のうち 5%）、test（収録全体のうち 5%）の全てを使います。

```python
# wiki40b/ja を読み込む
import tensorflow_datasets as tfds
dataset = tfds.load('wiki40b/ja', split='train+validation+test')
```

### 読み込んだ記事を単語単位に分かち書き
形態素解析器の [MeCab](https://taku910.github.io/mecab/)（辞書は、比較的新しい固有表現にも対応している [mecab-ipadic-NEologd](https://github.com/neologd/mecab-ipadic-neologd)を選択）を使って分かち書きするための関数を、以下のように定義しておきます。

```python
# テキストを分かち書きする関数を定義
import MeCab
mecab = MeCab.Tagger('-d <辞書が格納された場所のパス>')

def wakati(text):
    # 形態素解析
    mecab.parse('')
    node = mecab.parseToNode(text)

    arr_all = []
    arr_nav = []
    arr_n = []

    while node:
        pos0 = node.feature.split(',')[0] # 品詞
        pos1 = node.feature.split(',')[1] # 品詞細分類1
        pos2 = node.feature.split(',')[2] # 品詞細分類2
        pos3 = node.feature.split(',')[3] # 品詞細分類3
        word = node.feature.split(',')[6] # 原形
        if word == '*':
            word = node.surface # 表層形

        if len(word) > 0:
            # 全部出力
            arr_all.append(word)

            # 名詞・形容詞・動詞のみ出力
            if (
                (
                    pos0 == '名詞' and (
                        pos1 == 'サ変接続' or
                        pos1 == 'ナイ形容詞語幹' or
                        pos1 == '形容動詞語幹' or
                        pos1 == '一般' or
                        pos1 == '固有名詞'
                    )
                ) or
                (pos0 == '形容詞' and pos1 == '自立') or
                (pos0 == '動詞' and pos1 == '自立')
            ):
                arr_nav.append(word)

            # 名詞のみ出力
            if (
                pos0 == '名詞' and (
                    pos1 == '一般' or (
                        pos1 == '固有名詞' and
                        (
                            pos2 == '一般' or
                            pos2 == '地域' or
                            pos2 == '組織' or
                            (
                                pos2 == '人名' and
                                pos3 == '一般'
                            )
                        )
                    )
                )
            ):
                arr_n.append(word)

        node = node.next

    # スペース区切りの文字列にして出力
    return (
        ' '.join(arr_all) if len(arr_all) > 0 else '',
        ' '.join(arr_nav) if len(arr_nav) > 0 else '',
        ' '.join(arr_n) if len(arr_n) > 0 else ''
    )
```
この関数は、「全品詞」「名詞・形容詞・動詞のみ」「名詞のみ」の3通りの分かち書き結果を出力します。たとえば、この関数で『ワードウルフのお題を自動で作ってみます』を分かち書きすると、以下のようになります。

```
('ワードウルフ の お題 を 自動 で 作る て みる ます',
 'ワードウルフ お題 自動 作る',
 'ワードウルフ お題 自動')
```

word2vec で学習しやすいよう、単語は原型に変換して出力しています。また、この関数の3つの出力のうち、学習では2つ目の「名詞・形容詞・動詞のみ」を、単語ペアを作るときには3つ目の「名詞のみ」を使います。


### 学習用のデータセットと単語ペア作成用のデータセットを準備
後で使い回せるよう、記事を分かち書きした結果を、1ページ1行のテキストファイルに保存しておきます。

```python
# データセットの準備
file_all = 'data/wiki40b_ja_all.txt'
file_nav = 'data/wiki40b_ja_nav.txt'
file_n   = 'data/wiki40b_ja_n.txt'

print('start converting wiki40b/ja')
with (
    open(file_all, 'w') as fw_all,
    open(file_nav, 'w') as fw_nav,
    open(file_n  , 'w') as fw_n,
):
    count = 0

    # wiki40b のデータをイテレート（ページ単位）
    for item in dataset.as_numpy_iterator():
        arr_all = []
        arr_nav = []
        arr_n   = []

        # ページをパース
        for text in item['text'].decode('utf-8').split('\n'):
            if (
                len(text) > 0 and
                text != '_START_ARTICLE_' and
                text != '_START_SECTION_' and
                text != '_START_PARAGRAPH_'
            ):
                text = text.replace('_NEWLINE_', ' ')
                words_all, words_nav, words_n = wakati(text)
                arr_all.append(words_all)
                arr_nav.append(words_nav)
                arr_n.append(words_n)

        # 出力
        fw_all.write(' '.join(arr_all) + '\n')
        fw_nav.write(' '.join(arr_nav) + '\n')
        fw_n.write(' '.join(arr_n) + '\n')

        # 進捗表示
        count += 1
        if count % 10000 == 0:
            print('*', end='')
        if count % 100000 == 0:
            print(' {:,} paragraphs finished'.format(count))

print('\nfinished!')
```


### word2vec で学習して単語の分散表現を獲得
自然言語処理でよく使われる Python ライブラリ [gensim](https://radimrehurek.com/gensim/) を使って、word2vec していきます[^gensim]。

[^gensim]: 「[models.word2vec – Word2vec embeddings — gensim](https://radimrehurek.com/gensim/models/word2vec.html)」「[Python で「老人と海」を word2vec する - #m0t0k1ch1st0ry](https://m0t0k1ch1st0ry.com/blog/2016/08/28/word2vec/)」「[【Python】Word2Vecの使い方 - Qiita](https://qiita.com/kenta1984/items/93b64768494f971edf86)」

```python
# gensim で word2vec の学習
from datetime import datetime as dt
from gensim.models import word2vec
from gensim.models.callbacks import CallbackAny2Vec

# エポック毎にロス（学習データでの損失）を表示するコールバッククラスを定義
class MyLossCalculator(CallbackAny2Vec):
    def __init__(self, file_model, file_log, dt_start, every):
        self.file_model = file_model
        self.file_log = file_log
        self.dt_start = dt_start
        self.every = every
        self.epoch = 1

    # エポック開始時の処理
    def on_epoch_begin(self, model):
        pass

    # エポック終了時の処理
    def on_epoch_end(self, model):
        # ロスを取得
        loss = model.get_latest_training_loss()
        print('Epock #{} end : loss = {:.2f}'.format(self.epoch, loss))

        # 学習済みモデルを保存
        if self.epoch % self.every == 0:
            model.save(self.file_model.format(self.epoch))

        # ロスをログに記録
        with open(self.file_log, mode='a') as f:
            f.write('{},{:.2f},{:.1f}\n'.format(
                self.epoch, loss, (dt.now() - self.dt_start).total_seconds()
            ))

        # 次のエポックの準備
        self.epoch += 1
        model.running_training_loss = 0.0

# 入出力データのパス設定
file_dataset = 'data/wiki40b_ja_nav.txt'
file_model   = 'data/model-size200_win5_min10_ns20.epoch{:04d}.model'
file_log     = 'data/model-size200_win5_min10_ns20.log'

# 時間計測準備
dt_start = dt.now()
print('start training')

# ログファイル準備
with open(file_log, mode='w') as f:
    f.write('epoch,loss,seconds\n')

# コールバック準備（10エポックごとに学習済みモデルを出力）
mycallback = MyLossCalculator(file_model, file_log, dt_start, 10)

# 学習実行
model = word2vec.Word2Vec(
    corpus_file=file_dataset,
    vector_size=200,   # ベクトルの次元数
    window=5,          # 窓サイズ（出現判定のための単語間距離の最大値）
    min_count=10,      # 最小出現頻度（これより少ない単語は無視）
    sg=1,              # skip-gram モデルを使用
    hs=0,              # 全結合層に negative sampling を使用
    negative=20,       # negative sampling する要素数
    ns_exponent=0.75,  # negative sampling のための頻度分布平滑化係数
    workers=3,         # 並列計算のワーカースレッド数
    epochs=1000,       # 総学習エポック数
    compute_loss=True,
    callbacks=[mycallback]
)

print('finished! processing time = {:,.2f} seconds'.format(
    (dt.now() - dt_start).total_seconds()
))
```

wikipedia の全記事を扱うため、学習にはかなりの時間がかかります。Xeon が載ったハイスペックな環境で実行しても、1000エポック回すのには1週間程度かかりました。下のグラフは100エポックあたりまでの学習ロスの変化ですが、これ以降は殆ど学習が進まなかったので、今回は100エポック時点の学習済みモデルを使うことにしました。
![los.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336603/ff2dc86f-9406-4070-fb04-995dd3b0a914.png)


## 分散表現間の距離（単語の類似度）に基づいて単語ペアを自動生成
前述したとおり、今回は、wikipedia の記事に登場する名詞（のうち、一般名詞、および、固有名詞の一部）の中から単語ペアを作ります。

### 単語ペア作成用の語彙辞書を作成
[準備しておいた単語ペア作成用のデータセット](#学習用のデータセットと単語ペア作成用のデータセットを準備)から、gensim の機能を使って語彙辞書（記事に出現する単語の集合）を作ります。

```python
# 単語ペアを作るための語彙辞書を生成
from gensim.corpora import Dictionary

# 語彙辞書
dct = Dictionary()

# 単語ペア作成用の wikipedia データセット（名詞のみ）を読み込み
count = 0
with open('data/wiki40b_ja_n.txt') as f:
    for line in f:
        # データセットから wikipedia 記事を取得して単語辞書に登録
        dct.add_documents([line.split()], prune_at=4000000)

        # 進捗表示
        count += 1
        if count % 10000 == 0:
            print('*', end='')
        if count % 100000 == 0:
            print(' {:,} lines finished'.format(count))

print('\nfinished!')

# 語彙を枝刈り
print('num original tokens = {}'.format(len(dct.token2id.keys())))
dct.filter_extremes(
    no_below=10,   # 最小出現頻度（これより少ない単語はドロップ）
    no_above=0.1,  # 最大出現ページ割合（これより大きな単語はドロップ）
    keep_n=1000000
)
print('num filtered tokens = {}'.format(len(dct.token2id.keys())))

# 語彙をリスト化
tokens = list(dct.token2id.keys())
```

実行結果はこんな感じ。

```
********** 100,000 lines finished
********** 200,000 lines finished
********** 300,000 lines finished
********** 400,000 lines finished
********** 500,000 lines finished
********** 600,000 lines finished
********** 700,000 lines finished
********** 800,000 lines finished
**
finished!
num original tokens = 2443401
num filtered tokens = 331836
```

wikipedia 記事に登場する名詞（のうち、一般名詞、および、固有名詞の一部）総数約 244 万語のうち、約 33 万語が抽出されました。この語彙辞書から単語ペアを作っていきます。


### 語彙辞書から類似する（分散表現間の距離が近い）単語のペアを自動選定
[作成した語彙辞書](#単語ペア作成用の語彙辞書を作成)から単語をランダムに選定し、選定した単語に類似した単語を word2vec の学習済みモデルで抽出することで、単語ペアを機械的に作ることができます。

一方で、語彙辞書には形態素解析で名詞（のうち、一般名詞、および、固有名詞の一部）と判定された単語のみが登録されていますが、この中には、「2021年」や「12時」などの時間表現や、「120円」「10kg」「41人」などの数値表現など、ワードウルフのお題としてふさわしくない名詞が多数含まれています。
そこでまず、これらの固有表現を自動でチェックするための関数を用意しておきます。固有表現抽出には [GiNZA](https://megagonlabs.github.io/ginza/)[^ginza]を使います。

[^ginza]: つい数日前（2021年8月26日）に、Transformer モデルを取り入れて大幅に性能が上がったとされるGiNZA v5 がリリースされていますが、今回は旧バージョンの GiNZA を利用しています。

```python
# テキストの固有表現をチェックする関数を定義
import spacy
nlp = spacy.load('ja_ginza')

# 時間表現や数値表現に該当する固有表現ラベルの集合を設定
# http://liat-aip.sakura.ne.jp/ene/ene8/definition_jp/html/enedetail.html
drop_entity = set([
    'Time', 'Date',
    'Period_Time', 'Period_Day', 'Period_Week', 'Period_Month', 'Period_Year',
    'Numex_Other', 'Money', 'Point', 'Percent', 'Multiplication', 'Frequency',
    'Age', 'School_Age', 'Ordinal_Number', 'Rank', 'Latitude_Longitude',
    'Measurement', 'Measurement_Other',
    'Physical_Extent', 'Space', 'Volume', 'Weight', 'Speed', 'Intensity',
    'Temperature', 'Calorie', 'Seismic_Intensity', 'Seismic_Magnitude',
    'N_Person', 'N_Organization', 'N_Location', 'N_Location_Other',
    'N_Country', 'N_Facility', 'N_Product', 'N_Event', 'N_Natural_Object',
    'N_Natural_Object_Other', 'N_Animal', 'N_Flora'
])

# text が時間表現や数値表現なら True、それ以外は False
def is_time_or_numex(text):
    # テキストを形態素解析しつつ固有表現を抽出
    doc = nlp(text)

    # 時間表現や数値表現が含まれるかどうか判定
    for ent in doc.ents:
        if ent.label_ in drop_entity:
            return True
    return False
```

また、選んだ単語ペアがあまりにも似すぎているとワードウルフのお題としてふさわしくありません。せめて表記くらいは似すぎないようにしたいので、お互いに同一の文字をなるべく含まないようなチェックをするための関数を用意しておきます。
今回は、文字列間の『表記上の』類似度を定量的に測る指標として、[編集距離（レーベンシュタイン距離）](https://ja.wikipedia.org/wiki/%E3%83%AC%E3%83%BC%E3%83%99%E3%83%B3%E3%82%B7%E3%83%A5%E3%82%BF%E3%82%A4%E3%83%B3%E8%B7%9D%E9%9B%A2)を採用しました。編集距離の算出には [python-Levenshtein](https://pypi.org/project/python-Levenshtein/)、カタカナとかなの表記統一には [jaconv](https://pypi.org/project/jaconv/) をそれぞれ使いました。

```python
# テキスト間の表記上の類似度を判定する関数を定義
import Levenshtein
import jaconv

# 似ているなら True、似ていないなら False
def is_similar(str1, str2):
    # カタカナはひらがなに統一
    str1h = jaconv.kata2hira(str1)
    str2h = jaconv.kata2hira(str2)

    # 編集距離を算出
    dist = Levenshtein.distance(str1h, str2h)

    # スコア化（長い方の文字列長で編集距離を正規化）
    score = dist / max(len(str1h), len(str2h))

    # スコアが0.5未満か、お互いにお互いの文字列が含まれている場合「似ている」
    if score < 0.5 or str1h in str2h or str2h in str1h:
        return True
    else:
        return False
```

というわけで、ようやく、語彙辞書から類似（分散表現間の距離が近い）単語のペアを自動選定する関数が作れます。こんな感じにしてみました。

```python
# 類似する単語ペアを自動生成する関数を定義
import random
from gensim.models import word2vec

# word2vec の学習済みモデルを読み込み
model = word2vec.Word2Vec.load('data/model-size200_win5_min10_ns20.epoch0100.model')

# 単語ペアを自動生成する
def get_pair(model, tokens, max_try=100):
    # 最大100回試行
    cnt = 0
    while cnt < 100:
        # 語彙辞書から単語(token1)をランダムに選定
        token1 = None
        while True:
            token1 = random.choice(tokens)
            if not is_time_or_numex(token1): # 時間表現や数値表現でないか
                break

        # token1 に類似した単語（上位100個）を word2vec で取得
        results = model.wv.most_similar(positive=token1, topn=100)
        for result in results:
            token2 = result[0]
            if (
                token2 in tokens and             # 語彙辞書に含まれているか
                not is_time_or_numex(token2) and # 時間表現や数値表現でないか
                not is_similar(token1, token2)   # token1 と表記が似ていないか
            ):
                # 条件に合致するペアが見つかったら出力
                return (token1, token2)

        # 試行回数をインクリメント
        cnt += 1

    # 条件に合致するペアが見つからなかったら None を出力
    return None
```


### 自動生成された単語ペアの例
`get_pair()` の出力例をいくつか載せておきます。

```
('ゆうもあ大賞', 'アカデミー名誉賞')
('手元資金', '投資額')
('薄田兼相', '後藤基次')
('ビードル', 'マクリントック')
('魔獣', '幻獣')
('経済産業事務次官', '国土交通事務次官')
('豊川市内', '岡崎市内')
('東京都社会人サッカーリーグ', 'J3リーグ')
('アナルコサンディカリスム', '無政府主義')
('ピーター・トッシュ', 'ボブ・マーリー')
```

これ、、、確かに「近いけど異なる単語ペア」ではあるんですが、ワードウルフのお題としてはあまりにもマニアック過ぎますね。


# おわりに
というわけで、自然言語処理技術を活用して「近いけど異なる単語ペア」を大量に自動生成できるようになりました。が、ワードウルフのお題とするには、もう少しの（いや、かなりの）工夫が必要そうです。