scattertextで文章の単語出現頻度を可視化


# scattertextとは
単語出現頻度の散布図を生成できるライブラリです。
各ポイントに重ならないようなラベル付けや、カテゴリと単語の関連度を示す色付けを自動で行ってくれるため、簡単にきれいな散布図を作成することできます。また、作成後は検索窓に単語を検索したりといった動的な操作も可能です。
この記事ではそんなscattertextの実装例と、散布図がどのように作成されるかについて解説します。

# 実装例
Google Colaboratoryにて、livedoorニュースコーパスの「Sports Watch」と「MOVIE ENTER」の出現単語をscattertextで可視化していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/989042/71838182-4603-eb13-e2ca-0b68d587b577.png)


## 流れ
- 事前準備
- 散布図を作成するデータセットをPandas DataFrameに格納
- spacy、scattertextを使用したDataFrame → Corpusへの変換
- htmlの作成、可視化

## 事前準備
```python
%%capture
!pip install scattertext
!pip install ginza
!pip install neologdn # データセット作成時に使用

# GiNZAを使用するためのおまじない
import pkg_resources, imp
imp.reload(pkg_resources)
```
## 散布図を作成するデータセットをPandas DataFrameに格納
下記のようなtext列（文章）とcategory列から成るDataFrameを準備します。
※ここは本筋ではないので解説は割愛します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/989042/acc42046-4612-a7b3-db47-f90d52b5039a.png)


<details><summary>作成用コード</summary><div>

```python
import glob
import os
import pathlib
import tarfile
import neologdn
import pandas as pd

# Livedoorニュースのファイルをダウンロード
! wget "https://www.rondhuit.com/download/ldcc-20140209.tar.gz"

# rawデータ解凍
tar = tarfile.open("./ldcc-20140209.tar.gz", "r:gz")
tar.extractall(".")
tar.close()

# Dataframeの作成
df = pd.DataFrame(columns=["text", "category"])

for file_path in pathlib.Path("./text").glob("**/*.txt"):
    f_path = pathlib.Path(file_path)
    file_name = f_path.name
    category_name = f_path.parent.name

    if category_name in ['movie-enter', 'sports-watch']:

        # 特殊ファイルはスキップ
        if file_name in ["CHANGES.txt", "README.txt", "LICENSE.txt"]:
            continue

        with open(file_path, "r") as f:
            text_all = f.read()
            text_lines = text_all.split("\n")
            url, time, title, *text = text_lines

            # text前処理
            text = "".join(text)
            text = text.strip()
            text = neologdn.normalize(text)
            text = text.replace(' ', '')
            text = text.replace('　', '')

            df.loc[file_name] = [text,category_name]
        
# インデックスに使用していたファイル名を削除
df.reset_index(inplace=True,drop=True)
```
</div></details>


## spacy、scattertextを使用したDataFrame → Corpusへの変換
DataFrameができたら、`CorpusFromPandas`を使用して、DataFrameをCorpusに変換していきます。

- 引数`nlp`で指定したspacyモデルでDataFrameの`text_col`列の各文章をscapyのDoc型に変換
- `feats_from_spacy_doc`で指定した方法で`category_col`毎に単語の出現頻度をカウント

というのが挙動イメージです。
今回は品詞の絞り込みを行い、unigramで集計するクラスをデフォルトの`st.FeatsFromSpacyDoc`を継承して作成しました。

```python
import scattertext as st
import spacy
from collections import Counter
from itertools import chain
from IPython.display import HTML

# 品詞を絞りこみつつ、unigramの出現回数を集計
class UnigramSelectedPos(st.FeatsFromSpacyDoc):
    """
    品詞の絞り込みを行い、unigramをカウント
    デフォルトの絞り込み品詞は[固有名詞、名詞、動詞、形容詞、副詞]
    """    
    def __init__(self,use_pos=['PROPN', 'NOUN', 'VERB', 'ADJ', 'ADV']):
        super().__init__()
        self._use_pos = use_pos

    def get_feats(self, doc):
        return Counter([c.lemma_ for c in doc if c.pos_ in self._use_pos])

# Corpusの作成
corpus = (st.CorpusFromPandas(df, 
                              category_col='category', 
                              text_col='text',
                              nlp = spacy.load("ja_ginza"),
                              feats_from_spacy_doc=UnigramSelectedPos()
                              )
          .build())
```
品詞指定＋unigram＋bigramにしたい場合はこちらのクラスを使用してください。

<details><summary>品詞指定＋bigramのクラス</summary><div>

```python
class BigramSelectedPos(st.FeatsFromSpacyDoc):
    """
    品詞の絞り込みを行い、unigramとbigramをカウント
    デフォルトの絞り込み品詞は[固有名詞、名詞、動詞、形容詞、副詞]
    """    
    def __init__(self, use_pos=['PROPN', 'NOUN', 'VERB', 'ADJ', 'ADV'], use_lemmas=True):
        super().__init__()
        self._use_pos = use_pos
        self._use_lemmas = use_lemmas

    def _get_bigram_feats(self, unigrams):
        """
        list型で返すように書き換え。
        """        
        if len(unigrams) > 1:
            return list(map(' '.join, zip(unigrams[:-1], unigrams[1:])))
        else:
            return list()

    def _get_unigram_feats_use(self, sent):
        """
        品詞の絞り込みを行う形に書き換え。
        """        
        unigrams = []
        for tok in sent:
            if tok.pos_ in self._use_pos:
                if tok.ent_type_ in self._entity_types_to_censor:
                    unigrams.append('_' + tok.ent_type_)
                elif tok.tag_ in self._tag_types_to_censor:
                    unigrams.append(tok.tag_)
                elif self._use_lemmas and tok.lemma_.strip():
                    unigrams.append(self._post_process_term(
                        tok.lemma_.strip().lower()))
                elif tok.lower_.strip():
                    unigrams.append(
                        self._post_process_term(tok.lower_.strip()))
        return unigrams


    def get_feats(self, doc):
        """
        品詞の絞り込みにより、実際は存在しないbigramがカウントされることを防ぐため書き換え。
        例えば、「今日は晴れ」という文は品詞の絞り込みにより「は」が削除されるが、書き換え
        を行わないと「今日 晴れ」という実際は存在しないbigramが集計されてしまう。
        """        
        ngram_counter = Counter()
        for sent in doc.sents:
            unigrams_all = self._get_unigram_feats(sent)
            unigrams_use = self._get_unigram_feats_use(sent)

            bigrams_all = list(self._get_bigram_feats(unigrams_all))
            bigrams_use = list(self._get_bigram_feats(unigrams_use))

            for element in bigrams_use:
                if element not in bigrams_all:
                    bigrams_use.remove(element)

            ngram_counter += Counter(chain(unigrams_use, bigrams_use))
        return ngram_counter
```
</div></details>


## htmlの作成、可視化
Corpusができれば、あとは`produce_scattertext_explorer`でhtmlを作成し、表示すれば完成です。各引数の内容についてはコメントを参照ください。

```python
# htmlの作成
html = st.produce_scattertext_explorer(
          corpus,
          category='sports-watch', # y軸カテゴリ
          not_categories = ['movie-enter'], # x軸カテゴリ（複数選択可）
          category_name='Sports Watch', # y軸ラベル
          not_category_name='MOVIE ENTER', # x軸ラベル
          asian_mode=True, # 日本語モード
          minimum_term_frequency=50, # 指定より出現回数の多い単語のみをプロット
          max_terms=4000 # プロットする最大数
          )

# 散布図の表示
HTML(html)
```
(再掲）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/989042/71838182-4603-eb13-e2ca-0b68d587b577.png)

# プロットされた各ポイントの位置/色の決定方法
ここからは、散布図がどのように作成されるかについて見ていきます。
上記コードで作成された散布図について、各ポイントの位置/色は共にカテゴリ毎の出現回数の順位が決定要因となります（出現回数ではなく、出現回数の**順位**）。それぞれ具体的に見ていきます。

## ポイントの位置
ポイントの位置は**カテゴリ毎の順位**に対応します。イメージは下記の通りで、あくまで順位に対応するので、仮に下記の例でＡの「りんご」の出現回数が500回だったとしても、順位は変わらず1位であるため、プロットされる位置は同じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/989042/1ee45dbd-8620-72af-b565-f38d9af0de4e.png)

## ポイントの色
ポイントの色付けは、**カテゴリ間の順位差で算出したスコア**によって行われます。スコアは下図の要領で算出され、絶対値が大きいほど青/赤が濃くなります。順位差が大きい→一方のカテゴリにのみ使用されている→そのカテゴリの特徴を表す単語、という考え方のようです。なお、マウスを点に置いたときに表示されるツールチップに表示される```score```にもこのスコアが記載されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/989042/d3ebf726-d20a-aad2-bf51-7608e2f7edf1.png)
コードで確認したい方はこちらを参考にしてください。

```python
from scattertext.termscoring.RankDifference import RankDifference
freq_df = corpus.get_term_freq_df()
freq_df["score"] = RankDifference().get_scores(freq_df["sports-watch freq"], freq_df["movie-enter freq"])
freq_df.sort_values("score",ascending=False)
```
※「Scaled F-Scoreの値による色付けが行われている」旨の記載があるサイトもあったのですが、ソースコードを見ると当該部分がコメントアウトされており、現在は上記の通りscoreの算出、色付けが行われているものと思われます。間違っている場合はご指摘頂けますと幸いです。

# 感想
簡単に散布図が作成できるので、分析を始める際の一手目として非常に有用だと思いました。また、本記事で紹介できませんでしたが、```Corpus```には様々な指標を簡単に計算できるメソッドが用意されている様子なので、こちらも今後活用していきたいなと思いました。

# 参考
[https://github.com/JasonKessler/scattertext](https://github.com/JasonKessler/scattertext)
[はじめての自然言語処理 第6回 OSS によるテキストマイニング](https://www.ogis-ri.co.jp/otc/hiroba/technical/similar-document-search/part6.html#7)

# 修正
（2021/4/6)
GiNZAを使用する形に変更しました。事前準備部分と、モデルロード部分```spacy.load('ja_ginza')```に変更を加えています。
参考：[何もない所から一瞬で、自然言語処理と係り受け解析をライブコーディングする手品を、LTでやってみた話](https://qiita.com/youwht/items/b047225a6fc356fd56ee)