トピックモデルで言葉の「意味」を表現してみた

# TL;DR
- 人間の持つ言語知識構造をトピックモデルで表現した
- KLダイバージェンスや意味的エントロピーなどの尺度が人間の感覚と一致することを（主観的に）確認できた

# トピックモデル
トピックモデルとは，「トピックから単語が生起する確率」を推定する，確率モデルの一種です。
トピックの数を決める必要こそあれ，トピックと単語の関係（どの単語がどれくらいの確率でどのトピックから生起するか・トピック自体がどれくらいの確率で生起するか）は自動で学習してくれるため，教師データが不要という特徴があります。いわゆる教師なし学習のひとつです。

トピックモデルは例えば，下に示すような図で表現されます。なお，$P(c)$は潜在意味クラス（トピック）$c$が生起する確率，$P(w|c)$は意味クラス$c$が生起したあとに単語$w$が生起する確率です。意味クラス$c_1, c_2, c_3$は「政治クラス」だったり「野球クラス」だったりします。単語$w_1, w_2, w_3, w_4$は「衆議院」だったり「読売ジャイアンツ」だったりします。

![topic.png](https://qiita-image-store.s3.amazonaws.com/0/319673/75264e0e-42e6-7f31-fa6a-cb84b207f67c.png)


$w_1, w_2$がそれぞれ「読売ジャイアンツ」「衆議院」で，$c_1$を「野球クラス」と仮定したとき，トピックモデルの学習（潜在クラスの推定）が適切に進めば，野球クラス$c_1$から$w_1$が生起する確率$P(w_1|c_1)$はそれなりに高くなり，$w_2$が生起する確率$P(w_2|c_1)$は低くなります。このことは，「野球の話をしているときに『衆議院』という語が出にくい」ことを考えても妥当です。
ちなみに，意味クラスは「『野球クラス』です」とか「『政治クラス』です」とかいう結果が推定されるのではなく，（単語の）クラスへの帰属確率$P(c|w)$をチェックして「なんとなく野球関係の単語が多いなあ」という具合に人間が適当に決めます（後述）。

トピックモデルは，言語処理分野ではトピックに基づいた文書分類や著者推定に利用されますが，トピック・単語の関係を客層・商品の関係に置き換えることでレコメンデーションに使えたり，曲調・音符の関係に置き換えることで楽曲検索ができたりするなど，応用力の高いモデルだといえます。

# 活性拡散モデル（意味ネットワークモデル）
ところで，みなさんは，自分の知識や言葉の意味が頭の中でどのように「整理」されているか考えたことはありますか。

例えば，「オレンジ」や「バナナ」という語を見せられた後に「リ◯◯」と見せられると，多くの人が「リンゴ」を思い浮かべるでしょう。一方で，「音符」や「テンポ」という語を見せられた後に「リ◯◯」と見せられたら，「リズム」を思い浮かべるかも知れません。「リ◯◯」という実験刺激は同じなのに，事前に提示された刺激によって想起する語が異なるということは，少なくとも，言葉はランダムに脳内のレジスタへ格納されているのではなく，何らかの形で結びつき合っている（しかも意味の似た語は近くに固まっている）と考えられそうです。

人間の言語知識構造の表現として，Collins & Quillian (1969) によって提案された「意味ネットワークモデル」が挙げられます。意味ネットワークは一般的に，下図のように表現されます。

![semantic_network.png](https://qiita-image-store.s3.amazonaws.com/0/319673/10d8b22c-33e5-22e4-69ab-938c93427168.png)

人間の言語知識はこのような，概念（ノード）と概念間の上位下位関係（エッジ）を持つネットワーク構造になっていると一般的に考えられています。

しかし，意味ネットワークにはいくつか問題点があります。例えば，"Ostrich"（ダチョウ）は鳥類ですが「空を飛ぶ」「さえずる」といった鳥のイメージからは遠く，一方で"Canary"（カナリア）は典型的な鳥のイメージに近いことを踏まえると，"Canary"と"Bird"（鳥）の間には，"Ostrich"と"Bird"の間よりも強いエッジが張られるべきです。また，"Bird"と"Shark"（サメ）の間にはエッジが張られていませんが，"Bird"は"Shark"の上位概念ではないとは言い切れないため，本来はエッジが張られるべきです（そしてそのエッジは"Canary"よりも"Ostrich"よりも弱いエッジであるべきです）。
階層的ネットワークの特徴を引き継ぎ，上記の問題点の解消を目指したのが，Collins & Loftus (1975) の「活性拡散モデル」です。活性拡散モデルでは，ノード間のエッジの強度を意味的類似度に置き換えて考えます（下図では強度を太さで表現しています）。

![new_semantic.png](https://qiita-image-store.s3.amazonaws.com/0/319673/93aef90f-e9a8-d718-fc7a-03eea7eaf61b.png)

おやおや。この活性拡散モデル，トピックモデルにそっくりですネ（似せて作図したので当たり前ですが）。上位概念である"Bird"や"Fish"を潜在意味クラス$c$，下位概念である"Canary"や"Ostrich"などを単語$w$，ノード間のリンク強度を単語の生起確率$P(w|c)$と置き換えてあげると，人間の言語知識構造をトピックモデルで表現できそうです。

前置きが長くなりましたが，本稿では，トピックモデルの応用のひとつである，人間の持つ言語知識構造（もしくは単語の「意味」）の確率的表現についてまとめたいと思います。

# 動かしてみようトピックモデル
## 意味クラスの推定
とにもかくにも，トピックモデルを使って意味クラスを推定してみないことには何も始まりません。
トピックモデルは[gensim](https://radimrehurek.com/gensim/models/ldamodel.html)にもAPIが入っていますが，今回は[PLDA](http://ai.deepq.com/plda/)というソフトウェアを利用しました。MPIという規格に基づいた並列実行が可能ですが，コーパスが大きい場合，ディスクスペース（一時的ですが）とメモリをけっこう食うので注意してください。

PLDAの入力データ（コーパス）は，下に示すような1行1文書の出現頻度付きBag-of-Wordsです。Pythonで集計する場合は`Counter`モジュールを使うと楽です。なお，トピックモデルはあくまで語の背景にある意味クラスを推定するモデルですので，語の出現順を考慮しません。

```
a␣2␣is␣1␣character␣1
a␣2␣is␣1␣b␣1␣character␣1␣after␣1
...
w1␣w1_count␣w2␣w2_count␣w3␣w3_count
```

推定するトピック数（意味クラス数）は，用途にもよりますが，埋込表現の次元数と同じように200とか300とかがよく使われる気がします。その他のハイパーパラメータも何らかの指標に基づいて決められれば良いのですが，自身の経験からいうと，PerplexityやAIC/BICなどの評価指標が優れていても知識構造としては扱いづらい場合が往往にしてあり，最終的には，次節で述べるように人間が目視で結果を確認する必要があります。

推定が終わると，下に示すような，単語と各トピックにおける出現回数のマトリクスが出力されます。
このマトリクスから，$P(c|w)$，$P(w)$，$P(c)$を計算します。詳細は記事最下部のソースコードを参照してください。

```
w1<TAB>w1_count1␣w1_count2␣...␣w1_countN
w2<TAB>w2_count1␣w2_count2␣...␣w2_countN
...
```

今回は日本語版Wikipediaを，Wikipedia中のアンカーテキストやページ名の最長共通接頭辞マッチと形態素解析の組合せで分割したコーパスを利用しました。またトピック数は200としました。

## 意味クラスの確認
意味クラスの推定がうまくいっているかどうかは，各意味クラスの「雰囲気」を見れば分かります。クラスの「雰囲気」は，任意の意味クラス$c$に対する，語$w$の帰属確率$P(c|w)$を降順にソートすることで確認できます。
試しに，183番目のクラスを見てみましょう。主観ですが，これはどうやら洋楽クラスといえそうです。ビートルズ関連の語が上の方に来ていますが（「ゴット・トゥ・ゲット・ユー・イントゥ・マイ・ライフ」はビートルズの楽曲です），日本語のWikipediaではビートルズこそが洋楽の代表格ということかもしれません。

```Python
>>> for x in lda_model.belong_to_class(183):
...    print x[0], x[1]

ニール・マーレイ 1.0
us_200 1.0
プラチナ・ディスク 1.0
ディキシー・ドレッグス 1.0
ザ・ビートルズ・アンソロジー3 1.0
センチュリー・メディア・レコード 1.0
gary_brooker 1.0
ハンブル・パイ 1.0
ゴット・トゥ・ゲット・ユー・イントゥ・マイ・ライフ 1.0
ザ・ビートルズ・アンソロジー2 1.0
```

29番目のクラスはAKB48グループのクラスといえそうです。音楽は音楽でも，ジャンルによって意味クラスが分かれていることを確認できました（日本語版WikipediaではAKBグループが一大トピックを為していることも分かりました）。

```Python
>>> for x in lda_model.belong_to_class(29):
...     print x[0], x[1]

仲川遥香 0.99972415
akb48グループ 0.99971753
入山杏奈 0.9996667
宮崎美穂 0.99966305
横山由依 0.9996414
西野七瀬 0.99955356
北原里英 0.9993597
斉藤優里 0.9992829
松井珠理奈 0.99904644
渡辺美優紀 0.99731517
```

このように，それぞれの意味クラスを「政治クラス」「野球クラス」のように名付けられるのであれば，クラス推定はうまくいっているといえます。名前が付けられない場合や複数の意味が混ざってしまっている場合は，クラス数$|C|$を増減させて再推定した方が良いでしょう。
とはいえ，200とか300とかあるクラスを全て確認するのは難儀です。基本的には，一部の意味クラスを意味付けできれば十分だと思います。

# 表現してみよう言葉の意味
## KLダイバージェンス
KLダイバージェンス（KL情報量，KL擬距離）とは，確率分布の差を測る尺度で，下式で示されます。

```math
D(w_i||w_j)=\sum_{c \in C}P(c|w_i)\log\frac{P(c|w_i)}{P(c|w_j)}
```

くだけた説明をすると，「$w_i$から$w_j$のことをどれくらい説明できるか」を示しており，$w_i=w_j$のとき $0$ ，$w_i \neq w_j$であるほど $>0$ になります（ちなみに上式は負の値をとりません）。「カナリア」は典型的な「鳥」に近いため「鳥（という概念）」の説明をしやすいですが，「ダチョウ」や「ペンギン」では（クチバシこそあれ）「鳥（という概念）」の説明はしづらいですよね。

「説明しやすいほど値が0に近い」というのはイマイチ直感的でないので，指数化してあげます。

```math
e^{-D(w_i||w_j)}=exp\Bigl(-\sum_{c \in C}P(c|w_1)\ln\frac{P(c|w_1)}{P(c|w_2)}\Bigr)
```

$y=e^{-x}$ は下図にしめすように$0$以上の$x$に対して $(0, 1]$ を$y$としてとるため（$x$が0に近いほど$y$が1に近くなるため），上記の「説明のしやすさ」を「類似度」として扱いやすくなります。

![e.png](https://qiita-image-store.s3.amazonaws.com/0/319673/554565d1-3d8e-b6b8-a654-a0f048f04c1b.png)

また，先述したように，KLダイバージェンスに基づく類似度$e^{-D(w_i||w_j)}$は「$w_i$から$w_j$のことがどれくらい分かるか」の尺度であり，よく利用される単語埋込表現の類似度（コサイン距離）と違って「観点」が存在します。「$w_i$は$w_j$」「$w_i$といえば$w_j$」や「$w_i$は$w_j$の仲間」のように言い換えることもできます。「連想のしやすさ」と考えても良いかもしれません。

```Python
# 「国家はイスラエル」
>>> lda_model.kl_divergence(u'国家', u'イスラエル')
1.4006457622098468e-07

# 「イスラエルは国家」
>>> lda_model.kl_divergence(u'イスラエル', u'国家')
0.024704699382507033
```

「国家はイスラエル」よりも「イスラエルは国家」の方が類似度が高いです。実際に後者の方が意味的にも妥当といえるでしょう。
他の例も見てみます。

```Python
# 「携帯電話といえばNTTドコモ」
>>> lda_model.kl_divergence(u'携帯電話', u'nttドコモ')
0.0043745858933742559

# 「NTTドコモといえば携帯電話」
>>> lda_model.kl_divergence(u'nttドコモ', u'携帯電話')
0.27253755652317602
```

「NTTドコモといえば携帯電話」のイメージが強いようです。一方，「携帯電話といえばNTTドコモ」とはならないことも分かります。これは，NTTドコモ以外にも競合他社がいるから……ではありません。そうならない理由をエントロピーの観点から考えてみたいと思います。

# 言葉の持つエントロピー
## 意味の確率分布
単語$w$の意味確率分布$P(c|w)$は，単語の持つ意味的性質を表現します。例として，機能語である「の」の分布を見てみましょう。

![no.png](https://qiita-image-store.s3.amazonaws.com/0/319673/d5adf89c-8ed1-03fd-996c-e5b678680921.png)

$x$軸が意味クラス，$y$軸が意味クラスへの帰属確率です。どの意味クラスにも同じくらいの確率値で帰属しています。つまり，「の」は特定の意味クラスに属さない（特定の「意味」を持たない）といえそうです。
一方で，「NTTドコモ」の分布は下図のようになります（**$y$軸のスケールが違うことに注意してください**）。

![nttdocomo.png](https://qiita-image-store.s3.amazonaws.com/0/319673/7ce26957-06ed-7b17-d025-4b6f3bd6a089.png)

先ほどとは違い，特定の意味クラス（$c_{166}$や$c_{174}$）への帰属確率が突出して高く，分布に偏りがあります。確かに，「NTTドコモ」という語は一般名詞でもないですし語義も曖昧でないことを考えると，特定の「意味」を持つ語だといえそうです。

ちなみに，$c_{166}$への帰属確率が高い語を降順にソートしてみると，これはどうやらIT関係のクラスといえそうです。

```Python
>>> for x in lda_model.belong_to_class(166):
...     print x[0], x[1]

mysql 1.0
インプット_メソッド_エディタ 1.0
direct2d 1.0
機械語コード 1.0
データメンバ 1.0
advanced_systems_format 1.0
コードベース 1.0
jis_x_4081 1.0
immodule 1.0
unix_compress 1.0
```

ついでに，「携帯電話」の確率分布を見てみます。「NTTドコモ」の確率分布に似ていますが，y軸のスケールに注目してみると，分布が若干マイルドになっていることが分かります。

![keitaidenwa.png](https://qiita-image-store.s3.amazonaws.com/0/319673/1bef552e-269c-181e-3923-7f52b6a7de54.png)

このように語の持つ「意味」は確率分布によって可視化できますし，感覚的にも「意味」がシャープであるとかマイルドであるとかは理解できると思います。実は，「意味」のシャープさやマイルドさは，次節で紹介する意味的エントロピーという尺度で定量化できます。

## 意味的エントロピー
エントロピーは「乱雑さ」などと邦訳されますが，殊，情報理論においては「予測のしづらさ」と言ってもよいと思います。
例えば，「の」や「より」のように，$|C|$個ある意味クラスのうち，どの意味クラス$c$に属するか分からない場合はエントロピーが高い（どの意味クラスに帰属するか予測しづらい）です。逆に，「NTTドコモ」や「NTTドコモレッドハリケーンズ」のように特定の意味クラスに強く帰属する語の場合はエントロピーが低い（どの意味クラスに帰属するか予測しやすい）です。離散的な確率変数の場合，エントロピーは下式で示されます。

```math
H(w)=-\sum_{c \in C}P(c|w) \ln P(c|w)
```

エントロピー$H(w)$が$0$に近いほど，語$w$は具体的な意味を持ち（特定の意味クラスに属し），$\ln|C|$に近づくにつれ意味が抽象的になっていきます。
「意味が具体的になるほど値が$0$に近い」というのは，KLダイバージェンスと同様，直感的に理解しづらいので，$H(w)$に対してエントロピーの最大値$\ln|C|$との比をとってあげます。

```math
\begin{align}
e(w)&=1- \frac{H(w)}{\ln|C|} \\
&=1+\frac{\sum_{c \in C}P(c|w) \ln P(c|w)}{\ln|C|}
\end{align}
```

意味的エントロピー$e(w)$は$[0, 1]$の値をとり，$w$が持つ意味の「具象度」を表します。

```Python
# 意味的エントロピーの高い語（具体的な意味を持つ語）
>>> lda_model.entropy(u'nttドコモレッドハリケーンズ')
0.9725114230849676
>>> lda_model.entropy(u'nttドコモ')
0.7994362869966823

# 意味的エントロピーが中程度の語
>>> lda_model.entropy(u'携帯電話')
0.6169965655115256

# 意味的エントロピーの低い語（抽象的な意味を持つ語）
>>> lda_model.entropy(u'より')
0.180221307114
>>> lda_model.entropy(u'の')
0.14755658837512653
```

意味的エントロピーによれば，「携帯電話」は「NTTドコモ」と比べて抽象的な意味を持ちます。熱力学第二法則によれば，温度は，高いところから低いところへエントロピーを増大させるように移動するそうですから，「意味」も同様に，意味的エントロピーの高いところから低いところへ（つまり，具体的な意味から概念的な意味へ）流れていく傾向にあるといえそうです。したがって，「NTTドコモといえば携帯電話」とはなっても，「携帯電話といえばNTTドコモ」とはなりづらいのだと考えられます。
なお，「ココアはやっぱり◯◯」や「チョコレートは◯◯」のように，抽象的な意味から具体的な意味へと意味が流れる例はあり，意味的にも妥当だと考えられるのですが，これは言語知識とは別の原因（例えばCMソングによる刷込み）があるためです。日本文化を知らない外国人がこのように連想するかどうかを考えてみてください。

意味的エントロピーの流れを確認するために，例として，「NTTドコモ」を連想する語（「NTTドコモ」へのKLダイバージェンスが大きい語）を見てみます。

```Python
>>> for x in lda_model.connect_to(u'nttドコモ'):
...     print x[0], x[1]

ドコモ 0.86021525
ezチャンネル 0.8105121
docomo_style_series 0.80286616
ウィルコム 0.79250824
j-フォン 0.7746527
プッシュトーク 0.77447724
nttパーソナル 0.76808465
aquos_shot 0.7626154
パナソニック_モバイルコミュニケーションズ 0.762564
デジタルツーカー 0.76222014
```

どちらかといえばマニアックな語（意味的エントロピーの高い単語）が目立つ印象です。
逆に「NTTドコモ」から連想される語（「NTTドコモ」からのKLダイバージェンスが大きい語）はどうでしょうか。

```Python
>>> for x in lda_model.connect_from(u'nttドコモ'):
...     print x[0], x[1]

フレッツ 0.4039376
ソフトバンクモバイル 0.32197213
携帯電話 0.27253753
東北インテリジェント通信 0.26394805
kddi(au) 0.24360868
ドコモ 0.20538177
ウィルコム沖縄 0.18562686
日本移動通信 0.18548276
ケータイ 0.18473795
yahoo!_bb 0.1845392
```

先ほどと比べると，「携帯電話」「ケータイ」などの抽象的な意味を持つ語や，「ソフトバンクモバイル」や「KDDI (au)」といったポピュラーな固有名詞が多く，意味的エントロピー（具体的な意味から抽象的な意味へ）の流れを確認できたと思います。

# まとめ
本稿では，人間の持つ言語知識構造をトピックモデルで表現し，「意味」を確率的に表現してみました。言語処理界隈では，Word2Vecを始めとする単語埋込表現にお株を奪われたの感がありますが，結果を確率的に表現できたりトピックがはっきりしていたり，埋込表現にはない特徴がトピックモデルにはあります。
なお，本稿は下記文献を参考しています。

- 持橋大地, & 松本裕治. (2002). 意味の確率的表現. 情報処理学会研究報告自然言語処理 (NL), 2002(4 (2001-NL-147)), 77-84.
- M. Collins, Allan & Loftus, Elizabeth. (1975). A Spreading Activation Theory of Semantic Processing. Psychological Review. 82. 407-428.

# ソースコード
<details><summary>ソースコード</summary><div>

```Python
class LdaModel(object):
    '''
    # 語彙をvocabs集合で制限する
    lda_model = LdaModel.load(data_dir + 'lda_model.txt', vocabs)
    '''
    def __init__(self, c):
        self.pcw = None
        self.pw = None
        self.pc = None
        self.word2index = None
        self.index2word = None
        self.class_size = c

    def __getitem__(self, w):
        w = w.lower()
        if w in self.word2index:
            return self.pcw[self.word2index[w]]
        else:
            return np.zeros(200, dtype=np.float32)

    def __contains__(self, w):
        w = w.lower()
        return w in self.word2index

    @classmethod
    def load(cls, fname, vocabs):
        eps = np.finfo(float).eps  # avoid 0 division
        pcw = []
        pw = []
        pc = None
        index2word = []
        with codecs.open(fname, 'r', 'utf-8') as rf:
            for line in rf:
                line = line.strip()
                try:
                    word, counts = line.split('\t')
                    word = word.lower()
                    if word not in vocabs:
                        continue
                    counts = np.asarray([float(x) + eps for x in counts.split(' ')])
                    pcw.append(counts / np.sum(counts))
                    pw.append(np.sum(counts))
                    pc = counts if pc is None else pc + counts
                    index2word.append(word)
                except ValueError:
                    print(line)
        word2index = {x: i for i, x in enumerate(index2word)}
        pcw = np.asarray(pcw, dtype=np.float32)
        pw = np.asarray(pw, dtype=np.float32)
        class_size = pcw[0].shape[0]
        result = cls(class_size)
        result.pcw = pcw
        result.pw = pw / np.sum(pw)
        result.pc = pc / np.sum(pc)
        result.index2word = index2word
        result.word2index = word2index
        result.pwc = (pcw * (pw[:, None] * np.reciprocal(pc[None, :]))).astype(np.float32)
        return result

    def belong_to_class(self, idx, topn=30):
        args = self.pcw[:, idx].argsort()[::-1][:topn]
        return [(self.index2word[arg], self.pcw[arg, idx]) for arg in args]

    def kl_divergence(self, w1, w2):
        w1, w2 = w1.lower(), w2.lower()
        pcw1 = self.pcw[self.word2index[w1]]
        pcw2 = self.pcw[self.word2index[w2]]
        return np.exp(-1 * np.dot(pcw1, np.log(pcw1/pcw2)))

    def connect_from(self, w1, topn=30):
        w1 = w1.lower()
        pcw1 = self.pcw[self.word2index[w1]]
        sim = np.exp(-1 * np.dot(np.log(pcw1/self.pcw), pcw1))
        args = sim.argsort()[::-1][1:topn]
        return [(self.index2word[arg], sim[arg]) for arg in args]

    def connect_to(self, w1, topn=30):
        w1 = w1.lower()
        pcw1 = self.pcw[self.word2index[w1]]
        sim = np.exp(-1 * np.sum(np.log(self.pcw/pcw1) * self.pcw, axis=1))
        args = sim.argsort()[::-1][1:topn]
        return [(self.index2word[arg], sim[arg]) for arg in args]

    def entropy(self, w):
        w = w.lower()
        pcw1 = self.pcw[self.word2index[w]]
        return 1 + (np.dot(pcw1, np.log(pcw1)) / np.log(self.class_size))
```
</div></details>