[Python] TF-IDFを使ったワードクラウドの作り方

# はじめに
Pythonを使って、文章からTF-IDFを計算しワードクラウドを作成する手順をまとめました。TF-IDFの計算、ワードクラウド作成の部分に焦点を当て、文章の前処理などの説明は省略しています。
ライブラリは`gensim`, `wordcloud`を使っています。

簡単に試せるようにコードをコピペすれば実行できるようにしてます(フォントパスの指定だけお願いします)。シードの設定を行っているので結果は同じになると思います。
# 実装
```python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from gensim.corpora import Dictionary
from gensim.models import TfidfModel

from wordcloud import WordCloud
```
### ダミー文章作成
今回のワードクラウド作成用のダミーの文章(=単語列)を作成します。
```python
# 文書数
N_docs = 10
words = ['朝日', 'いろは', '上野', '英語', '大阪', '為替', '切手', 'クラブ', '景色', '子供', 
'桜', '新聞', 'すずめ', '世界', 'そろばん', '煙草', 'ちどり', 'つるかめ', '手紙', '東京', 
'名古屋', '日本', '沼津', 'ねずみ', '野原', 'はがき', '飛行機', '富士山', '平和', '保険', 
'マッチ', '三笠', '無線', '明治', 'もみじ', '大和', '弓矢', '吉野', 
'ラジオ', 'りんご', 'るすい', 'れんげ', 'ローマ', 'わらび', 'ゐど', 'かぎ', '尾張']
```
文書数を10として、`words`の単語を利用して文章を作成します。(単語は適当に[通話表](https://ja.wikipedia.org/wiki/%E9%80%9A%E8%A9%B1%E8%A1%A8)から持ってきています)
```python
# 文章
documents = []
rng = np.random.default_rng(seed=0)
for N_words in rng.uniform(10,40,size=N_docs).astype(int):
    documents.append(list(rng.choice(words, size=N_words)))
```
各文書に20個以上40個未満の単語を`words`からランダムに割り当て(単語の重複あり)リストでまとめ、それをさらにリストとしてまとめた文章が`documents`です。中身はこんな感じ↓
```python
[['世界', 'ラジオ', '三笠', '朝日', '手紙', ... ], 
['平和', '大和', '手紙', '日本', '尾張', ... ], 
['明治', 'りんご', '野原', 'つるかめ', 'そろばん', ... ], 
['野原', 'ちどり', '三笠', '飛行機', '新聞', ... ], 
['大和', '手紙', '煙草', 'れんげ', 'すずめ', ... ], 
['尾張', '子供', 'ゐど', '大阪', '保険', ... ], ...]
```

### TF-IDF計算
```python
dictionary = Dictionary(documents)
print("文書数 :", dictionary.num_docs) # 文書数 : 10
print("単語数 :", len(dictionary.dfs)) # 単語数 : 47
```
`documents`を`Dictionary()`に与え辞書を作成します。`dictionary`は単語をIDに対応させ、何の単語が何回出現したのかを保持しています。
```python 
corpus = [*map(lambda x: dictionary.doc2bow(x), documents)] # 文章内での単語の出現位置と回数
tfidfModel = TfidfModel(corpus)
corpus_tfidf = tfidfModel[corpus] # 計算されたTF-IDF

TfIdf_value = [pd.DataFrame(corpus_tfidf[i], columns=["id","tfidf"]) for i in range(N_docs)]
for tfidf in TfIdf_value:
    tfidf["name"] = tfidf["id"].map(lambda x: dictionary[x])
```
作成した`dictionary`を使って、`documents`をBoW形式の`corpus`に変換。それを`TfidfModel()`のコンストラクタに与えて、`tfidfmodel`を生成します。この時点ではまだTF-IDFは計算されておらず、`corpus`を与えることで実際のTF-IDFの値が計算されます。
ただ、このままでは(ID, TF-IDF)の状態で返ってきて我々人間が見てもよくわからない(+あとでワードクラウドにするときに必要になる)ので、IDを単語に変換しています。最終的に一つ目の文書の単語とTF-IDFである`TfIdf_value[0]`は下のようになっています。
| |id|tfidf|name|
|:---:|:---:|:---:|:---:|
|0|0|0.5582752|いろは|
|1|1|0.1071361|すずめ|
|2|2|0.1071361|そろばん|
|3|3|0.1860917|はがき|
|：|：|：|：|

### ワードクラウド作成
```python
# フォントパス(画像ではIPAexゴシックを使用)
font_path = "~/ipaexg.ttf"
# 表示する最大単語数
max_words=50

fig, axs = plt.subplots(2,5, figsize=(12,5), tight_layout=True)
axs = axs.flatten()
for i in range(N_docs):
    x = dict(TfIdf_value[i][["name", "tfidf"]].values) # 文書の{単語:TF-IDF}の辞書
    im = WordCloud(
        font_path=font_path,
        width=600, height=600, prefer_horizontal=1,
        background_color='white', colormap='viridis', 
        max_words=max_words, random_state=0
    ).generate_from_frequencies(x)
    axs[i].imshow(im)
    axs[i].axis('off')
    axs[i].set_title("Doc {}".format(i+1))
```
`TfIdf_value`の各要素から`name`と`tfidf`を利用して{単語:TF-IDF}の辞書を作り、`WordCloud`に与えることでワードクラウドを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3069837/50b8f7af-877b-597a-1fc3-41791998aadd.png)
`WordCloud()`の引数としては、
- `width`, `height`：出力される`image`の横幅、縦幅
- `prefer_horizontal`：文字を配置するときに水平方向に配置する比率(1未満のときに垂直方向のフィッティングを行う)
- `background_color`：背景色
- `colormap`：単語の配色を決める[colormap](https://matplotlib.org/stable/users/explain/colors/colormaps.html)
- `max_words`：表示する最大単語数

参考：WordCloudの[公式ドキュメント](https://amueller.github.io/word_cloud/generated/wordcloud.WordCloud.html)

# 補足
### TF-IDF計算式のカスタム
今回のTF-IDFの計算では、パラメータ等を特にいじらずに`TfidfModel`のデフォルトの設定の下式で計算しています。
```math
TF\text{-}IDF_{t,d} = TF_{t,d} \times IDF_{t}
```
```math
TF_{t,d} = frequency_{t,d}
```
```math
IDF_{t} = log_2\frac{Ndocs}{docfreq_{t}}
```
$frequency_{t,d}$ : 文書d内での単語tの出現数
$Ndocs$ : 文書数
$docfreq_{t}$ : 単語tが出現する文書数


TFとIDFの計算式は`TfidfModel`のコンストラクタにユーザ定義の関数を与えることが可能で、`wlocal`引数がTF、`wglobal`引数がIDFの計算に対応します。`wlocal`で指定する関数の引数には、`[3, 1, 2, 1, 1, 2]`のような文書dに出現するすべての単語の出現数が`ndarray`として与えられることが想定されています。一方、`wglobal`で指定する関数は、文書dに出現する単語ごとに計算を行うので、引数で用いる値の違いに注意が必要です。
```python
from gensim.models.tfidfmodel import df2idf

def calcTf(p):
    return p / p.sum()
def calcIdf(docfreq, Ndocs):
    return df2idf(docfreq+1, Ndocs, add=1.0)

tfidfModel = TfidfModel(corpus, wlocal=calcTf, wglobal=calcIdf)
```
例えば、`TfidfModel`をインスタンス化するときに上のコードのように与えると、

```math
TF_{t,d} = \frac{frequency_{t,d}}{\sum_{t'\in d}frequency_{t',d}}
```
```math
IDF_{t} = log_2\frac{Ndocs}{docfreq_{t}+1}+1
```
として計算されます。

`df2idf`はデフォルトでは`tf2idf(docfreq, totaldocs, log_base=2.0, add=0.0)`で計算され、
```math
IDF_{t} = add+log_{log\_base}\frac{totaldocs}{docfreq}\\
=log_{2}\frac{totaldocs}{docfreq}
```
です(`TfidfModel`に対してデフォルトで`TfidfModel(wglobal=df2idf)`として与えられているので、本節の最初に紹介したIDFの計算式はここからきています)。`df2idf()`を利用してあげることでうまい具合にカスタムのIDF計算を行えます。

### 単語の色をそろえる
`WordCloud`は単語とTF-IDFを与えるだけで、自動的に値の高い方から順に単語を選択しワードクラウドを作成してくれます。このときの単語の色は(たぶん)値の高い方から順に割り当てられているので、単語→色の変換をこちら側で用意する必要があります。単語→カラーコードの変換を行う関数を定義します。
```python
import matplotlib.colors as mcolors

colormap = "viridis"

# 一度生成したカラーマップを一時保持するリスト
images = []
for i in range(N_docs):
    x = dict(TfIdf_value[i][["name", "tfidf"]].values)
    im = WordCloud(
        font_path=font_path,
        width=600, height=600, prefer_horizontal=1,
        background_color='white', include_numbers=True, 
        max_words=max_words, random_state=0
    ).generate_from_frequencies(x)
    images.append(im)

# ワードクラウドに出現した全単語
words_set = set()
for image in images:
    words_set |= set(map(lambda x: x[0][0], image.layout_))
words_list = sorted(words_set)

rng = np.random.default_rng(seed=0)
# 単語→整数の変換を行う辞書
word2num_dict = dict(zip(words_list, (rng.permutation(range(len(words_list))))))

cm = plt.get_cmap(colormap, len(word2num_dict))

# 単語→カラーコードの変換を行う関数
def custom_color_func(word=None, **kwargs):
    color = mcolors.rgb2hex(cm(word2num_dict.get(word)))
    return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))
```
一度ワードクラウドを作成し、各`image`の`layout_`からワードクラウドの情報にアクセスすることで出現する全単語を取得しています。得た単語に一意な整数を割り当て、単語→整数→色(カラーコード)という変換を行う関数を作成します。
```python
fig, axs = plt.subplots(2,5, figsize=(12,5), tight_layout=True)
axs = axs.flatten()
for ix,im in enumerate(images):
    im = im.recolor(color_func=custom_color_func)
    axs[ix].imshow(im)
    axs[ix].axis('off')
    axs[ix].set_title("Doc {}".format(ix+1))
```
作成したワードクラウドに対して、`recolor()`を呼び出し`color_func`引数にカスタム関数を与えることで、単語の配置はそのままに色が再び割り当てられます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3069837/c322c8f1-ee86-c5bc-a371-59a2b0dd5361.png)
先ほど作成したワードクラウドと比べて、配置は変わらずに色だけが変化し、同じ単語は同じ色になっているかと思います(Doc1・2・5の"手紙"、Doc1・7・10の"為替"あたりが見つけやすいかも)。

別の方法として、`WordCloud()`を呼び出す際に`colormap`引数の代わりに`color_func`引数にカスタム関数を与えることで同じような処理が可能です。しかし、なぜか一部で単語の配置やフォントサイズが変化してしまうので、`recolor()`を使う実装の方がいいんじゃないかと思っています(TF-IDFの値に基づく文字の大小関係は保っているみたいなので大きな問題はないと思いますが、フォントサイズが変わってしまうという挙動はよくわからない)。
参考までにコードと出力↓
```python
TfIdf_dicts = [dict(TfIdf_value[i].sort_values("tfidf", ascending=False
                                              )[:max_words][["name", "tfidf"]].values) for i in range(N_docs)]
words_set = set()
for dct in TfIdf_dicts:
    words_set |= set(dct.keys())
words_list = sorted(words_set)

rng = np.random.default_rng(seed=0)
word2num_dict = dict(zip(words_list, (rng.permutation(range(len(words_list))))))

cm = plt.get_cmap(colormap, len(word2num_dict))

def custom_color_func(word=None, **kwargs):
    color = mcolors.rgb2hex(cm(word2num_dict.get(word)))
    return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))

fig, axs = plt.subplots(2,5, figsize=(12,5), tight_layout=True)
axs = axs.flatten()
for i in range(N_docs):
    dct = TfIdf_dicts[i]
    im = WordCloud(
        font_path=font_path,
        width=600, height=600, prefer_horizontal=1,
        background_color='white', 
        max_words=max_words, random_state=0, 
        color_func=custom_color_func, # カスタム関数を与える
    ).generate_from_frequencies(dct)
    axs[i].imshow(im)
    axs[i].axis('off')
    axs[i].set_title("Doc {}".format(i+1))
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3069837/a3b0b114-55ae-60e7-4ff6-1ccdcaabbccf.png)

# 最後に
`gensim`と`wordcloud`を使ってTF-IDFに基づいたワードクラウドを作成する方法を紹介しました。

(実は最後の節の単語の色をそろえる方法を実装してみた+今までscikit-learnでTF-IDFやっていたのをgensimでやってみたのをまとめたくて、この記事を書きました。)

質問・アドバイス等あればお気軽にコメントください。

# 参考
- gensimのtfidfあれこれ (https://qiita.com/tatsuya-miyamoto/items/f1539d86ad4980624111)
- models.tfidfmodel – TF-IDF model (https://radimrehurek.com/gensim/models/tfidfmodel.html)
- wordcloud.WordCloud (https://amueller.github.io/word_cloud/generated/wordcloud.WordCloud.html)