テキストデータの仲良し単語をグルーピング！ 〜 word2vec & fasttext → UMAP･t-SNE ～ 

**2022/10/01** 国語研長単位がVer2.9→3.2にVer.upし、コード記述変更しないとインストールできなくなったのでpip変更
**2022/09/01**　[**fasttext**](https://pypi.org/project/fasttext/)も適用できるようにしました。ドロップダウンメニューでword2vecかfasttextを選ぶ（Google colabのフォーム機能）だけなので、お試しになるならどうぞ。（私が適用したデータでは大差なしという感じでした）
***


![9BD4C754-DD0A-4F2C-8B3B-62C794F35336.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/3709bbc6-fe3f-2448-8309-afbbe7ae6838.jpeg)
## はじめに

以前、[**word2vec**](https://pypi.org/project/word2vec/)  に 公開された学習済データを適用しました。

>**『 ’松本人志’+’浜田雅功’ を与えたら ’ダウンタウン’,’お笑い’, ’ガキ使’ が返ってきたぁ・・・』**

https://qiita.com/hima2b4/items/feed7ac03914d96a8473

と、よろこんでいたのが昨年末。

ただよろこんでいただけで、悲しいかな それ以降の実務適用には まったく至らず・・・。
「テキスト分析」において、[**word2vec**](https://pypi.org/project/word2vec/) をどう活用するか、イメージできていなかったからです。

[**前回記事**](https://qiita.com/hima2b4/items/ca1a4fa440acdedeb3dd) に取り上げた **気になる単語の周辺語や関連語を探る** ために [**word2vec**](https://pypi.org/project/word2vec/) を適用したのは、本当にたまたま・・・　やってみたというだけだったのですが、やってみたことで、すこし [**word2vec**](https://pypi.org/project/word2vec/) が身近になった感があります。

できれば、 **気になる単語の周辺語や関連語を探る以外の使い道も探りたい。**
目的は [**word2vec**](https://pypi.org/project/word2vec/) を使うことではなく「テキスト分析」すること・・・　一体、どんな活用があるでしょうか？


## テキスト分析とは何か？
各論に入る前に、そもそも「テキスト分析」とは何か？を考えたいと思います。

**テキスト分析のゴールは、分析を通じてテキストの訴えをまとめること**です。

大枠のプロセスとしては
>まず
>　**①対象テキストの質のバリエーションを把握**し、
>つぎに
>　**②質の中身を把握**し、
>最後に
>　**③まとめる**

という流れと思います。

**「テキスト分析」といえば、王道は「KJ法」** です。

https://qiita.com/hima2b4/items/78889fb0fa718f5b2aaa

先のプロセスを KJ法にあてはめると、

>**① 探検ネット → ② 狭義KJ → ③ 文章化**

となります。

　

”テキスト分析の王道がKJ法ならば、KJ法を適用すればよいではないか？” 

ということになりますが、ビッグデータを扱う場合、まず最初のステップである①が大変です。

よいまとめを得るためには、バリエーションを漏らさないことが大切ですが、ビッグデータであるほど、簡単ではありません。

現在 私は、Pythonで「テキスト」を計量化する「テキスト分析」に取り組んでいますが、この計量化による「テキスト分析」だけでは、まとめた！というレベルに達することはできません。
**テキストの計量化は、あくまで「テキスト分析」を進めるための手掛かり**であり、効率的に進めるための手段となりますが、**バリエーションの把握には長けている**と思います。

よって、計量「テキスト分析」でバリエーションを効率的につかみ、中身の把握やまとめにうまくつなげるということが、現実的なアプローチと思います。

この立場・場面で、[**word2vec**](https://pypi.org/project/word2vec/) はどう活用できるでしょうか？
私は、活用のひとつに「単語のグルーピング」があると思います。


## word2vecでの「単語のグルーピング」
[**word2vec**](https://pypi.org/project/word2vec/) は**単語をベクトル化**しますので、**一緒に出現しやすい仲良し単語をいくつかのクラスタ（グループ）にわけることができます。**
これは、バリエーション把握の助けのひとつになるように思います。

グループ分けという点では、過去にTFIDF、PCA等を試行していますが、しっくりした結果を得ることができませんでしたので、今回は**t-SNE**と**UMAP**にチャレンジし、かつこれを [**plotly**](https://pypi.org/project/plotly/) で描くことで、クラスタ（グループ）ごとの状況を確認しやすくしたいと思います。


## 実行したこと
>- Google Colabで実行しています。
>- テキストデータは、Excel表形式にまとめられたアンケート等を想定し、csvデータとしています。適用データは [**前回記事**](https://qiita.com/hima2b4/items/7125b15ad76b2f5c38e3) 同様、tweetデータ（csv）です。（※「再エネ」でtweet検索したつぶやきを [**ついすぽ**](https://chrome.google.com/webstore/detail/%E3%81%A4%E3%81%84%E3%81%99%E3%81%BD-tweet-export/imjojcgefiinokaclmifakpphkfeiddg?hl=ja) でcsvにエクスポートしたデータです。）

https://chrome.google.com/webstore/detail/ついすぽ-tweet-export/imjojcgefiinokaclmifakpphkfeiddg?hl=ja

主な実行内容は、以下の通りです。
>- 読込んだcsvデータをデータフレームに格納し、[**GiNZA**](https://pypi.org/project/ginza/)で行単位でテキスト処理した後、分かち書きの結果をデータフレームのカラムに反映しています。
>- 分かち書きした結果を[**word2vec**](https://pypi.org/project/word2vec/) でベクトル化、**t-SNE**、**UMAP**でクラスタリングし、**Plotly**で可視化（2次元グラフ）しています。


## GoogleColabで実行するためのライブラリのインストール
以下のライブラリや辞書をインストールしました。

```Python:sudachipy, GINZAのインストール
!pip install sudachipy sudachidict_core
!pip install "https://github.com/megagonlabs/ginza/releases/download/latest/ginza-latest.tar.gz"
```
```Python:「国語研長単位」モデルのインストール
#!pip install ja_gsdluw deplacy -f https://github.com/megagonlabs/UD_Japanese-GSD/releases/tag/r2.9-NE
!pip install https://github.com/megagonlabs/UD_Japanese-GSD/releases/download/r2.9-NE/ja_gsdluw-3.2.0-py3-none-any.whl
```
```Python:GoogleColabへのGINZAインストール時のランライム再起動を不要にするおまじない
import pkg_resources, imp
imp.reload(pkg_resources)
```
このおまじないがないと、[**GiNZA**](https://pypi.org/project/ginza/)インストール後にランタイムの再起動が求められますが、このおまじないの実行により、インストールするだけ（＝ランタイムの再起動は不要）になります。

```Python:日本語フォントインストール
!apt-get -y install fonts-ipafont-gothic
```
```Python:UMAPインストール
!pip install umap-learn
```

### データ読込み
まず、データの読込みです。
データの読込みにおいては、GoogleColabのフォーム機能を利用し、データセットを選択できるようにしています。
選択肢は2つ。tweetサンプルデータ（tweet_data_sampleを選択）と、任意のcsvファイル（Uploadを選択）です。
※tweet_data_sampleは、Githubにアップしたこの記事の適用データです。

```Python:データセット選択
#@title Select_Dataset { run: "auto" }
#@markdown  **<font color= "Crimson">注意</font>：かならず 実行する前に 設定してください。**</font>

dataset = 'tweet_data_sample' #@param ['tweet_data_sample','Upload']
```

以下を実行すると、datasetの選択に沿ったデータが読み込まれ、読み込んだデータをデータフレームに反映し、表示します。

```Python:データ読込み
#@title データ読込み
import pandas as pd
from google.colab import files

# データの読み込み
if dataset =='Upload':
  uploaded = files.upload()#Upload
  target = list(uploaded.keys())[0]
  df = pd.read_csv(target,encoding='utf-8')

elif dataset == 'tweet_data_sample':
  file_url ='https://raw.githubusercontent.com/hima2b4/Natural-language-processing/main/TwExport_20220806_152219.csv'
  df = pd.read_csv(file_url,encoding='utf-8')

display(df)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/8ccfe902-a38c-c48d-6aea-e2e4001c8e57.png)

次に、テキスト処理するカラムを指定します。
これもGoogleColabのフォーム機能を利用し、カラム名を入力することで指定できるようにしています。
今回のデータの場合、「テキスト」カラムが対象となりますので、テキストと入力しています。
```Python:意見カラム名の入力
#@title 意見カラム名の入力 { run: "auto" }

column_name = 'テキスト' #@param {type:"raw"}
```


### テキスト処理
つぎは、テキスト処理です。
テキストから不要語を取りのぞく前処理を行った後、テキスト処理を実行して単語に分解、指定した品詞の単語を取り出し、stop_wordsに指定した単語があれば除外します。
その後、行毎に取り出した単語をデータフレームに格納しています。
品詞とstop_wordsの指定は、GoogleColabのフォーム機能を利用しています。
フォーム機能を利用するとコード操作が不要となりますので、とても便利です。

```Python:品詞とストップワード指定
#@title 使用する品詞とストップワードの指定
#@markdown  ※include_pos：使用する品詞，stopwords：表示させない単語 ← それぞれ任意に追加と削除が可能

include_pos = ('NOUN', 'PROPN', 'VERB', 'ADJ')#@param {type:"raw"}
stop_words = ('する', 'ある', 'ない', 'いう', 'もの', 'こと', 'よう', 'なる', 'ほう', 'いる', 'くる', 'お', 'つ', 'とき','ところ', '為', '他', '物', '時', '中', '方', '目', '回', '事', '点', 'ため') #@param {type:"raw"}
```

```Python:テキストの前処置と分かち書き
#@title テキスト処理実行
import spacy
import pandas as pd
import re

# 意見に空行がある場合は削除
df[column_name] = df[column_name].replace('\n+', '\n', regex=True)

# テキストデータの前処理
def text_preprocessing(text):
   # 改行コード、タブ、スペース削除
   text = ''.join(text.split())
   # URLの削除
   text = re.sub(r'https?://[\w/:%#\$&\?\(\)~\.=\+\-]+', '', text)
   # メンション除去 
   text = re.sub(r'@([A-Za-z0-9_]+)', '', text) 
   # 記号の削除
   text = re.sub(r'[!"#$%&\'\\\\()*+,-./:;<=>?@[\\]^_`{|}~「」〔〕“”〈〉『』【】＆＊・（）＄＃＠。、？！｀＋￥]', '', text)

   return text

df[column_name] = df[column_name].map(text_preprocessing)

#モデルをja_gsdluw（国語研長単位）に
nlp = spacy.load("ja_gsdluw")

# 行毎に出現する単語をリストに追加
words_list=[]  #行毎の単語リスト
for doc in nlp.pipe(df[column_name]):
    sep_word=[token.lemma_ for token in doc if token.pos_ in include_pos and token.lemma_ not in stop_words]
    words_list.append(sep_word)
    #print(word_list)

#行毎の意見 → 単語に分解し、カラムに格納
df['separate_words']= [s for s in words_list]

# 参考:対象カラムに形態素解析実行、すべての形態素結果をseparateカラムに格納
#df['separate'] = [list(nlp(s)) for s in df[column_name]

```

## word2vec or fasttext → 周辺語・関連語の検索
**embedding_model** のドロップダウンにて、word2vec か fasttext のいずれかを選択し、実行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/432fd05f-3662-6dfd-b767-93f691212e5a.png)
```Python:
#@title Keyword 前後に現れる **周辺語**、２つのKeywordに紐づく **関連語** を表示

#@markdown  **<font color= "Crimson">ガイド</font>：Number_of_serch_words_shown で 結果表示数を設定し、Keyword_A,B にキーワードを入力して実行してください。※入力するキーワードは、WordCloudや共起ネットワークに表示される単語表記としてください。**</font> 

#@markdown  **<font color= "Crimson">※入力するキーワードは、WordCloud や 共起ネットワーク に表示される単語の表記としてください。**</font>

embedding_model = 'fasttext' #@param ['word2vec','fasttext']
Number_of_serch_words_shown = 7 #@param {type:"slider", min:5, max:15, step:1}
Keyword_A = '再エネ賦課金やめる' #@param {type:"raw"}
Keyword_B = '政府' #@param {type:"raw"}

from gensim.models import word2vec
from gensim.models import FastText
import warnings
warnings.filterwarnings('ignore')

# size : 中間層のニューロン数・数値に応じて配列の大きさが変わる。数値が多いほど精度が良くなりやすいが、処理が重くなる。
# min_count : この値以下の出現回数の単語を無視
# window : 対象単語を中心とした前後の単語数
# iter : epochs数
# sg : skip-gramを使うかどうか 0:CBOW 1:skip-gram

import os
import hashlib

os.environ["PYTHONHASHSEED"] = "0"

def hashfxn(x):
    return int(hashlib.md5(str(x).encode()).hexdigest(), 16)

#分散表現
if embedding_model == 'word2vec':
  model = word2vec.Word2Vec(df['separate_words'],
                          size=100,#alpha=0.025,
                          min_count=3,workers=1,
                          window=5,seed=42,
                          iter=20,batch_words=1000,hashfxn=hashfxn,
                          sg = 1)    # sg=1:skip-gram使用
else:
  model = FastText(df['separate_words'],
                          size=100,#alpha=0.025,
                          min_count=3,workers=1,
                          window=5,seed=42,
                          iter=20,batch_words=1000,hashfxn=hashfxn,
                          sg = 1)    # sg=1:skip-gram使用

#ベクトル化したテキストの各語彙確認
#model.wv.index2word

print('\n')
print('● [', Keyword_A,'] の前後に現れる 周辺語')
print('\n')
try:
  for item, value in model.wv.most_similar(positive=[Keyword_A], topn=Number_of_serch_words_shown):
    print('\t✓',item, '\t',str("{:.2f}".format(value)))
    
except:
  pass

print('\n')
print('---------------------------------------------------------------------------------------------------------------------------------------------------------------')

#Keyword_B
print('\n')
print('●  [', Keyword_B,'] の前後に現れる 周辺語')
print('\n')
try:
  for item, value in model.wv.most_similar(positive=[Keyword_B], topn=Number_of_serch_words_shown):
    print('\t✓',item, '\t',str("{:.2f}".format(value)))

except:
  pass

print('\n')
print('---------------------------------------------------------------------------------------------------------------------------------------------------------------')


#Keyword_A+B
if embedding_model == 'word2vec':
  model_ = word2vec.Word2Vec(df['separate_words'],
                          size=100,#alpha=0.025,
                          min_count=3,workers=1,
                          window=5,seed=42,
                          iter=20,batch_words=1000,hashfxn=hashfxn,
                          sg = 0)    # sg=0:CBOW使用

else:
  model_ = FastText(df['separate_words'],
                          size=100,#alpha=0.025,
                          min_count=3,workers=1,
                          window=5,seed=42,
                          iter=20,batch_words=1000,hashfxn=hashfxn,
                          sg = 0)   # sg=0:CBOW使用

print('\n')
print('●  [', Keyword_A ,'] と [', Keyword_B ,'] に紐づく関連語')
print('\n')
try:
  for item, value in model_.wv.most_similar(positive=[Keyword_A,Keyword_B], topn=Number_of_serch_words_shown):
    print('\t✓',item, '\t',str("{:.2f}".format(value)))

except:
  pass

print('\n')
```

## 単語の可視化 → グルーピングしてみる
※ 先のセルで選択した **embedding_model** に応じ、グラフのタイトルは変わります。

### ① UMAP

```Python:UMAP
#@title UMAP
#@markdown  **<font color= "Crimson">手順1</font>：n_neighbors によってクラスタリングの状況が変化します。例えば 2, 5, 10, 30, 50, 100 など振ってみて、クラスタの違いがわかりやすくなる値を探ってください。**

#@markdown  **<font color= "Crimson">手順2</font>：max_words_ratio でグラフの単語表示率を変更できます。単語表示数が多い＝内容把握がむつかしい場合は表示率を調整してください。**

n_neighbors = 2 #@param {type:"slider", min:2, max:100, step:1}
UMAP_max_words_ratio = 1 #@param {type:"slider", min:0.1, max:1, step:0.05}
UMAP_text_font_size = 10 #@param {type:"slider", min:8, max:24, step:1}

import umap.umap_ as umap
from scipy.sparse.csgraph import connected_components
import plotly.graph_objs as go
import plotly.express as px
import os

os.environ['OMP_NUM_THREADS'] = '1'
os.environ['PYTHONHASHSEED'] = '0'

UMAP_max_words = int(len(model.wv.vectors)*UMAP_max_words_ratio)

np.set_printoptions(suppress=True)
values1 = umap.UMAP(n_components=2,n_neighbors=n_neighbors).fit_transform(model.wv.vectors)

if embedding_model == 'word2vec':
  fig_title ='<b>UMAP on word2vec embeddings'
else:
  fig_title ='<b>UMAP on fasttext embeddings'

#可視化
fig1 = go.Figure()
for value, word in zip(values1, model.wv.index2word[0:UMAP_max_words]):
  fig1.add_trace(
      go.Scatter(
          x = pd.Series(value[0]),
          y = pd.Series(value[1]),
          mode = 'markers+text',
          text = word,
          textposition="top center"
          )
)
fig1.update_layout(title=dict(text = fig_title,
                             font=dict(size=18,
                                       color='grey'),
                             xref='paper', # container or paper
                             x=0.5,
                             y=0.9,
                             xanchor='center'
                            ),
                  showlegend=False,
                  font = dict(size = 10),
                  width=900,
                  height=700
                   )
fig1.show()

```

**n_neighbors** の値を2, 5, 10, 30, 50, 100 と変えてみて、クラスタリングの状況を確認しました。
**n_neighbors** =2 あたりで、以下のように仲良し単語が複数のクラスタにわかれてくれました。
ただ、クラスタ数はもうすこし少なくてもいいかなという感じです。
![9BD4C754-DD0A-4F2C-8B3B-62C794F35336.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/3709bbc6-fe3f-2448-8309-afbbe7ae6838.jpeg)

### ② t-SNE
次に、**t-SNE**を実行しました。

```Python:t-SNE
#@title t-SNE
#@markdown  **<font color= "Crimson">手順1</font>：perplenxity によってクラスタリングの状況が変化します。例えば 2, 5, 10, 30, 50, 100 など振ってみて、クラスタの違いがわかりやすくなる値を探ってください。**

#@markdown  **<font color= "Crimson">手順2</font>：max_words_ratio でグラフの単語表示率が変更できます。単語表示数が多い＝内容把握がむつかしい場合は表示率を調整してください。**

perplexity = 2 #@param {type:"slider", min:2, max:100, step:1}
tSNE_max_words_ratio = 0.5 #@param {type:"slider", min:0.1, max:1, step:0.05}
tSNE_text_font_size = 10 #@param {type:"slider", min:8, max:24, step:1}

from sklearn.manifold import TSNE

tSNE_max_words = int(len(model.wv.vectors)*tSNE_max_words_ratio)

#実行
tsne = TSNE(n_components=2, perplexity=perplexity,random_state=0)
np.set_printoptions(suppress=True)
values2 = tsne.fit_transform(model.wv.vectors)

if embedding_model == 'word2vec':
  fig_title ='<b>UMAP on word2vec embeddings'
else:
  fig_title ='<b>UMAP on fasttext embeddings'

#可視化
fig2 = go.Figure()
for value, word in zip(values2, model.wv.index2word[0:tSNE_max_words]):
  fig2.add_trace(
      go.Scatter(
          x = pd.Series(value[0]),
          y = pd.Series(value[1]),
          mode = 'markers+text',
          text = word,
          textposition="top center"
          )
)
fig2.update_layout(title=dict(text= fig_title,
                             font=dict(size=18,
                                       color='grey'),
                             xref='paper', # container or paper
                             x=0.5,
                             y=0.9,
                             xanchor='center'
                            ),
                  showlegend=False,
                  font = dict(size = 10),
                  width=900,
                  height=700
                   )
fig2.show()
```
**perplexity** の値を2, 5, 10, 30, 50, 100 と変えてみて、クラスタリングの状況を確認しました。
**perplexity** =2 あたりで、以下のように仲良し単語が複数のクラスタにわかれてくれました。
**UMAP**の方がクラスタリングははっきりしていますが、**t-SNE**の結果の方がクラスタ数が少なくなりましたので、こちらで色々見てみることにしました。
![681D23EC-52B8-4D61-8F9D-0B382757A74A.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/2a703287-894c-b2c2-0921-489726b6a0b6.jpeg)

単語を眺めることである程度の内容が想定できるグループもあれば、今ひとつ内容が掴みにくいグループもありました。
単語が多いグループほど掴みにくく感じました。これは出現回数が少ない単語が多い場合、ポイントが掴みにくくなるということもあるでしょう。

[**word2vec**](https://pypi.org/project/word2vec/)は、

>**model.wv.index2word**

で単語が呼び出せます。

>**print(model.wv.index2word)** 

とすると単語が確認できますが、これがなんと頻出単語順に並んでいますので、表示させる単語の比率を変えることにしました。
比率を下げると、出現回数が多い単語に絞られますので、クラスタリング（グループ）内の単語の暑苦しさは和らぎます。

以下は、**t-SNE**で単語表示比率を 0.7 → 0.5 → 0.3 と変えてみた結果です。

**単語表示比率70％(0.7)**
![newplot (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/51a0fb3e-48c7-d78f-1a93-698514049d23.png)
**単語表示比率50％(0.5)**
![newplot (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/da29ac94-efe9-55d4-3667-a957df0c3415.png)
**単語表示比率30％(0.3)**
![newplot (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1275001/b7ad6240-7fa7-ed28-099a-a6107d4e6f83.png)
クラスタ毎の単語を見ただけでテキストの訴えが理解できるというものではありませんが、バリエーション理解の助けにはなったかなという感じです。


テキストのバリエーションを把握するため、いくつかの単語クラスタリング（グループ分け）を試行してみました。

これは、適用するデータによって異なる可能性はありますが、以下の点でもっともよいと感じたのは、共起ネットワークによるネットワーククラスタリングでした。
- 適度なクラスタ数
- クラスタ内の単語を見た時の解釈容易性


## 最後に
以前にPCAを実行したコードがありましたので、このコードをモディファイすることで**t-SNE**と**UMAP**は案外簡単に実行できました。

**t-SNE**も**UMAP**も、そこそこのクラスタリングができました。
できればクラスタリング数がある程度可変できれば・・・　単語だけでクラスタリング毎の質のバリエーションをもう少しつかみやすくなれば・・・というのが、実行しての率直な感想です。

テキスト分析は、あたらめて深いなぁ。まだまだ修行が足りませんね。