Livedoorニュースコーパスで主成分分析 - 実践 -

#この記事について
Livedoorニュースコーパスを使って、テキストデータの主成分分析に挑戦します。
[前回](https://qiita.com/torahirod/items/2d8644ee6bb67de68039)、分析前の前準備として、テキストを形態素に分解し、表形式にまとめました。

この表を使って、主成分分析を実施していきます。
重みの単語はたくさんあっても大変なので、名詞一般、且つ、記事分類毎の頻出単語Top5のものに絞ることにします。

コードは以下からも参照できます。
https://github.com/torahirod/TextDataPCA

まず、各記事分類毎の頻出単語Top5を確認します。

#記事分類毎の頻出単語Top5の確認
```Python:
import pandas as pd
import numpy as np

# 前準備で1ファイルにまとめたテキストデータを読み込む
df = pd.read_csv('c:/temp/livedoor_corpus.csv')

# 品詞は名詞一般のみに絞り込み
df = df[df['品詞'].str.startswith('名詞,一般')].reset_index(drop=True)

# 記事分類毎の単語の出現頻度を集計
gdf = pd.crosstab([df['記事分類'],df['単語'],df['品詞']],
                  '件数',
                  aggfunc='count',
                  values=df['単語']
                 ).reset_index()

# 記事分類毎の単語の出現頻度の降順で順位付け
gdf['順位'] = gdf.groupby(['記事分類'])['件数'].rank('dense', ascending=False)
gdf.sort_values(['記事分類','順位'], inplace=True)

# 記事分類毎の頻出単語Top5だけに絞り込み
gdf = gdf[gdf['順位'] <= 5]

# 記事分類毎の頻出単語Top5の確認
for k in gdf['記事分類'].unique():
    display(gdf[gdf['記事分類']==k])
```
**・dokujo-tsushin**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/99d6e7e9-d0c0-01ea-23c1-4a0d6d8f0a9c.png)
独女通信と名の付くだけあって、やはり「女性」、「女」といった単語が上位に来ています。

**・it-life-hack**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/70bb0e10-3e87-304e-2e38-bd002511a795.png)
ITライフハックは、「人」、「アプリ」といった単語は確かに関連度が高そうな気がします。
「製品」は何でしょう？ガジェット的ものを指しているのかもしれません。

**・kaden-channel**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/e7c710f0-45bf-677c-6858-f8bf85809687.png)
家電チャンネルは、「話題」、「売れ筋」、「ビデオ」とこちらもそれっぽい単語が頻出しています。
「人」は何でしょう？家電チャンネルという記事分類で頻出しているのは少し不思議に思います。

**・livedoor-homme**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/cddf5bf6-36ec-4fe8-d022-1bc120745d65.png)
ライブドア-オムは、男性向けの記事の模様。
やはり「ゴルフ」は紳士の嗜みなのでしょうか。「年収」がTop5に入っているのも面白いですね。

**・movie-enter**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/aeea51f9-1cd7-042d-9aa6-df720c8aedc9.png)
映画・エンタメは、その名の通り、「映画」、「作品」といった単語が上位に来ています。

**・peachy**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/20eccb9f-f4ca-ca6b-36e9-05dd7b92768c.png)
こちらも女性向けの記事の模様。独女通信との切り分けが難しそうです。

**・smax**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/17b38278-881e-c4cb-c693-27bd2d89b2d7.png)
エスマックスは、スマホやモバイル関連の記事のようです。
若干、ITライフハックと内容被る部分がありそうです。

**・sports-watch**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/cc5686a1-1150-664e-987a-b9cd35bd9024.png)
スポーツウォッチは、「選手」、「サッカー」、「野球」はいかにもといった感じですが、
「T」が何なのか不明です。こちらは後でどのような文になっているのか確認します。

**・topic-news**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/d6ee88d1-9e23-1059-ad5e-d61c4d29446e.png)
トピックニュースは様々な記事を扱うイメージでしたが、「ネット」や「掲示板」、「声」といった単語から、ニュースに対する大衆の反応を書いた記事が多いのかもしれません。

# 気になる単語の周辺文の確認
先ほど、スポーツウォッチで関連なさそうな単語が上位に来ていたのが気になったので、確認してみます。

```python
# 気になった単語を設定
word = 'T'

df = pd.read_csv('c:/temp/livedoor_corpus.csv')

# 気になった単語の出現位置を取得
idxes = df[(df['単語'] == word)
          &(df['品詞'].str.startswith('名詞,一般'))].index.values.tolist()

# ウィンドウサイズ（気になった単語の前後何単語まで確認するかの設定）
ws = 20

# 気になる単語の周辺文を取得
l = []
for i, r in df.loc[idxes, :].iterrows():
    s = i - ws
    e = i + ws
    tmp = df.loc[s:e, :]
    tmp = tmp[tmp['ファイル名']==r['ファイル名']]
    lm = list(map(str, tmp['単語'].values.tolist()))
    ss = ''.join(lm)
    l.append([r['記事分類'],r['ファイル名'],r['単語'],ss])
rdf = pd.DataFrame(np.array(l))
rdf.columns = ['記事分類','ファイル名','単語','単語周辺文']

rdf.head(5)

```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/68644dd3-36e7-e9b3-30ce-f7d118bbd80f.png)
どうやら単語一文字の「T」は、URLの一部で時間を表す部分のようです。
URLを除外すれば、4～5位の単語は変化しそうなので、URLを除外した上で再度Top5を確認します。

# テキストデータの加工
前準備のコードを少し修正し、空白や改行、URLを除去した上で、ファイル毎の形態素を集計します。
URLの除去は単語周辺文の結果を眺め、ある程度URLの型が決まっているように見えたので、ざっくりとした基準で除外を試みます。
URL部分を完全に除去するような綺麗な処理にはなっていません。

```python
import pandas as pd
import numpy as np
import pathlib
import glob
import re

from janome.tokenizer import Tokenizer
tnz = Tokenizer()

pth = pathlib.Path('c:/temp/text')

l = []
for p in pth.glob('**/*.txt') :
    # 記事データ以外はスキップ
    if p.name in ['CHANGES.txt','README.txt','LICENSE.txt']:
        continue
    
    # 記事データを開き、janomeで形態素解析⇒1行1単語の形式でリストに保持
    with open(p,'r',encoding='utf-8-sig') as f :
        s = f.read()
        s = s.replace('　', '')
        s = s.replace(' ', '')
        s = s.replace('\n', '')
        s = re.sub(r'http://.*\+[0-9]{4}', '', s)
        # 空白、改行、URLを除去
        l.extend([[p.parent.name, p.name, t.surface, t.part_of_speech] for t in tnz.tokenize(s)])

# リストをデータフレームに変換
df = pd.DataFrame(np.array(l))

# 列名を付与
df.columns = ['記事分類','ファイル名','単語','品詞']

# データフレームをcsv出力
df.to_csv('c:/temp/livedoor_corpus.csv', index=False)
```

# スポーツウォッチの頻出単語Top5を再確認
**・sports-watch**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/13418655-c437-b87c-8314-10ff649e50a4.png)
加工前と比較して、4位～5位の単語が変わりました。
「チーム」はスポーツに関連ありそうです。

各記事分類毎の頻出単語Top5は確認できました。
これらを重みにして主成分分析を実施していきます。

# 主成分分析（2次元）
```python
# 上段のセルにて取得した記事分類毎の頻出単語Top5をリストとして保持
words = gdf['単語'].unique().tolist()

df = pd.read_csv('c:/temp/livedoor_corpus.csv')
df = df[df['品詞'].str.startswith('名詞,一般')].reset_index(drop=True)
df = df[df['単語'].isin(words)]

# ファイルと記事分類毎の頻出単語Top5のクロス集計表を取得
xdf = pd.crosstab([df['記事分類'],df['ファイル名']],df['単語']).reset_index()
# 後に因子負荷量のラベルとして出力するため、リストとして保持
cls = xdf.columns.values.tolist()[2:]

# 後のグラフ表示のため、記事分類毎に分類番号を付与
ul = xdf['記事分類'].unique()
def _fnc(x):
    return ul.tolist().index(x)
xdf['分類番号'] = xdf['記事分類'].apply(lambda x : _fnc(x))

# 主成分を求めるための前準備
data = xdf.values
labels = data[:,0]
d = data[:, 2:-1].astype(np.int64)
k = data[:, -1].astype(np.int64)

# データの標準化 ※ 標準偏差は不偏標準偏差で計算
X = (d - d.mean(axis=0)) / d.std(ddof=1,axis=0)

# 相関行列を求めます
XX = np.round(np.dot(X.T,X) / (len(X) - 1), 2)

# 相関行列の固有値、固有値ベクトルを求めます
w, V = np.linalg.eig(XX)

# 第1主成分を求める
z1 = np.dot(X,V[:,0])

# 第2主成分を求める
z2 = np.dot(X,V[:,1])

# グラフ用オブジェクトの生成
fig = plt.figure(figsize=(10, 6))
ax = fig.add_subplot(111)

# グリッド線を入れる
ax.grid()

# 描画するデータの境界
lim = [-10.0, 10.0]
ax.set_xlim(lim)
ax.set_ylim(lim)

# 左と下の軸線を真ん中に持っていく
ax.spines['bottom'].set_position(('axes', 0.5))
ax.spines['left'].set_position(('axes', 0.5))
# 右と上の軸線を消す
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)

# 軸の目盛の間隔を調整
ticks = np.arange(-10.0, 10.0, 1.0)
ax.set_xticks(ticks)
ax.set_yticks(ticks)

# 軸ラベルの追加、位置の調整
ax.set_xlabel('Z1', fontsize=16)
ax.set_ylabel('Z2', fontsize=16, rotation=0)
ax.xaxis.set_label_coords(1.02, 0.49)
ax.yaxis.set_label_coords(0.5, 1.02)

from matplotlib.colors import ListedColormap
colors = ['red','blue','gold','olive','green','dodgerblue','brown','black','grey']
cmap = ListedColormap(colors)
a = np.array(list(zip(z1,z2,k,labels)))
df = pd.DataFrame({'z1':pd.Series(z1, dtype='float'),
                   'z2':pd.Series(z2, dtype='float'),
                   'k':pd.Series(k, dtype='int'),
                   'labels':pd.Series(labels, dtype='str'),
                  })

# 記事分類毎に色を変えてプロット
for l in df['labels'].unique():
    d = df[df['labels']==l]
    ax.scatter(d['z1'],d['z2'],c=cmap(d['k']),label=l)
    ax.legend()

# 描画
plt.show()

```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/cdc78a0b-fc4e-59b9-5d32-b5bc6a2a48af.png)
結果を見てみると、記事分類によっては、近い位置で点がまとまっていますが、点の重なりが多く、記事分類毎の境界が判り辛いですね。

続いて、因子負荷量を確認してみます。

```Python
# 最大の固有値に対応する固有ベクトルを横軸、最大から2番目の固有値に対応する固有ベクトルを縦軸とした座標。
V_ = np.array([(V[:,0]),V[:,1]]).T
V_ = np.round(V_,2)

# グラフ描画用のデータ
z1 = V_[:,0]
z2 = V_[:,1]

# グラフ用オブジェクトの生成
fig = plt.figure(figsize=(10, 6))
ax = fig.add_subplot(111)

# グリッド線を入れる
ax.grid()

# 描画するデータの境界
lim = [-0.4, 0.4]
ax.set_xlim(lim)
ax.set_ylim(lim)

# 左と下の軸線を真ん中に持っていく
ax.spines['bottom'].set_position(('axes', 0.5))
ax.spines['left'].set_position(('axes', 0.5))
# 右と上の軸線を消す
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)

# 軸の目盛の間隔を調整
ticks = np.arange(-0.4, 0.4, 0.05)
ax.set_xticks(ticks)
ax.set_yticks(ticks)

# 軸ラベルの追加、位置の調整
ax.set_xlabel('Z1', fontsize=16)
ax.set_ylabel('Z2', fontsize=16, rotation=0)
ax.xaxis.set_label_coords(1.02, 0.49)
ax.yaxis.set_label_coords(0.5, 1.02)

# データのプロット
for (i,j,k) in zip(z1,z2,cls):
    ax.plot(i,j,'o')
    ax.annotate(k, xy=(i, j),fontsize=10)
    
# 描画
plt.show()

```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/d7d0ff0c-d789-d1eb-c987-aa6ef81a33a4.png)
先ほどの主成分分析の結果と見比べてみると、各記事分類のまとまりと、似たような位置にその記事分類に関連する単語が来ているように見えます。

# 主成分分析（3次元）
```python
# 上段のセルにて取得した記事分類毎の頻出単語Top5をリストとして保持
words = gdf['単語'].unique().tolist()

df = pd.read_csv('c:/temp/livedoor_corpus.csv')
df = df[df['品詞'].str.startswith('名詞,一般')].reset_index(drop=True)
df = df[df['単語'].isin(words)]

# ファイルと記事分類毎の頻出単語Top5のクロス集計表を取得
xdf = pd.crosstab([df['記事分類'],df['ファイル名']],df['単語']).reset_index()
# 後に因子負荷量のラベルとして出力するため、リストとして保持
cls = xdf.columns.values.tolist()[2:]

# 後のグラフ表示のため、記事分類毎に分類番号を付与
ul = xdf['記事分類'].unique()
def _fnc(x):
    return ul.tolist().index(x)
xdf['分類番号'] = xdf['記事分類'].apply(lambda x : _fnc(x))

# 主成分を求めるための前準備
data = xdf.values
labels = data[:,0]
d = data[:, 2:-1].astype(np.int64)
k = data[:, -1].astype(np.int64)

# データの標準化 ※ 標準偏差は不偏標準偏差で計算
X = (d - d.mean(axis=0)) / d.std(ddof=1,axis=0)

# 相関行列を求めます
XX = np.round(np.dot(X.T,X) / (len(X) - 1), 2)

# 相関行列の固有値、固有値ベクトルを求めます
w, V = np.linalg.eig(XX)

# 第1主成分を求める
z1 = np.dot(X,V[:,0])

# 第2主成分を求める
z2 = np.dot(X,V[:,1])

# 第3主成分を求める
z3 = np.dot(X,V[:,2])

# グラフ用オブジェクトの生成
fig = plt.figure(figsize=(10, 6))
ax = fig.add_subplot(111, projection='3d')

# グリッド線を入れる
ax.grid()

# 描画するデータの境界
lim = [-10.0, 10.0]
ax.set_xlim(lim)
ax.set_ylim(lim)

# 左と下の軸線を真ん中に持っていく
ax.spines['bottom'].set_position(('axes', 0.5))
ax.spines['left'].set_position(('axes', 0.5))
# 右と上の軸線を消す
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)

# 軸の目盛の間隔を調整
ticks = np.arange(-10.0, 10.0, 1.0)
ax.set_xticks(ticks)
ax.set_yticks(ticks)

# 軸ラベルの追加、位置の調整
ax.set_xlabel('Z1', fontsize=16)
ax.set_ylabel('Z2', fontsize=16, rotation=0)
ax.xaxis.set_label_coords(1.02, 0.49)
ax.yaxis.set_label_coords(0.5, 1.02)

from matplotlib.colors import ListedColormap
colors = ['red','blue','gold','olive','green','dodgerblue','brown','black','grey']
cmap = ListedColormap(colors)

a = np.array(list(zip(z1,z2,z3,k,labels)))
df = pd.DataFrame({'z1':pd.Series(z1, dtype='float'),
                   'z2':pd.Series(z2, dtype='float'),
                   'z3':pd.Series(z3, dtype='float'),
                   'k':pd.Series(k, dtype='int'),
                   'labels':pd.Series(labels, dtype='str'),
                  })

for l in df['labels'].unique():
    d = df[df['labels']==l]
    ax.scatter(d['z1'],d['z2'],d['z3'],c=cmap(d['k']),label=l)
    ax.legend()

# 描画
plt.show()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/64b3f40f-33f8-e10a-4709-08bc9b0e5af5.png)

3次元で見てみても、やはり各点が重なっていて、綺麗に境界線を引くのは難しそうです。
ただ、グラフをぐるぐる回せるので、回して見れるのがおもしろいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/c3b9340e-1f02-955f-0df7-2e293d34dfe7.png)
ひっくり返してみました。

# 因子負荷量（3次元）
```Python
# 最大の固有値に対応する固有ベクトルを横軸、最大から2番目の固有値に対応する固有ベクトルを縦軸とした座標。
V_ = np.array([(V[:,0]),V[:,1],V[:,2]]).T
V_ = np.round(V_,2)

# グラフ描画用のデータ
z1 = V_[:,0]
z2 = V_[:,1]
z3 = V_[:,2]

# グラフ用オブジェクトの生成
fig = plt.figure(figsize=(10, 6))
ax = fig.add_subplot(111, projection='3d')

# グリッド線を入れる
ax.grid()

# 描画するデータの境界
lim = [-0.4, 0.4]
ax.set_xlim(lim)
ax.set_ylim(lim)

# 左と下の軸線を真ん中に持っていく
ax.spines['bottom'].set_position(('axes', 0.5))
ax.spines['left'].set_position(('axes', 0.5))
# 右と上の軸線を消す
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)

# 軸の目盛の間隔を調整
ticks = np.arange(-0.4, 0.4, 0.05)
ax.set_xticks(ticks)
ax.set_yticks(ticks)

# 軸ラベルの追加、位置の調整
ax.set_xlabel('Z1', fontsize=16)
ax.set_ylabel('Z2', fontsize=16)
ax.set_zlabel('Z3', fontsize=16)

ax.xaxis.set_label_coords(1.02, 0.49)
ax.yaxis.set_label_coords(0.5, 1.02)

# データのプロット
for zdir, x, y, z in zip(cls, z1, z2, z3):
    ax.scatter(x, y, z)
    ax.text(x, y, z, zdir)

# 描画
plt.show()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490521/1d8f1d48-526f-961b-1795-0c59a9b04f77.png)

# ソースコード
https://github.com/torahirod/TextDataPCA

# 感想
記事分類毎の頻出単語Top5を使った主成分分析だけでも、ある程度のまとまりは視覚的に確認することができ、おもしろかったです。

また、以下を試して結果を見てみるのもおもしろそうです。

・Top10まで広げてみる。
・Top5のうち、複数の記事分類に重複して出現している単語は除外してみる。
・単純な出現頻度のTop5ではなく、TF-IDFなどの特徴量でのTop5を試してみる。

ここから目指すところとしては、主成分分析で得られた、2次元座標、3次元座標の情報を使って、k-meansと組み合わせて文書分類に挑戦したいです。