Qiitaに直近投稿された記事のタグから共起ネットワーク図を作る

# 背景

**直近15日間にQiitaで投稿された記事のタグの共起表現をもとに、タグ名のネットワーク図を作ります。**

どういうこと？という方は↓の図を見てください。そっちの方が早いね。

![概念図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/6c69ff89-f76e-a635-aa40-c2e92883cd41.jpeg)

:::note
より拡大すると下図のようになります。
・丸いのがノードで、Qiitaに投稿されたタグの名前を元に作っています。
・ノードの大きさは出現回数を基準にしています。（多いとノードも大きくなる）
・ノード間の距離は共起回数を基準にしています。（同時に使用されやすいタグほど近くに配置される）
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/895fb3c0-9256-85a0-82e8-5a630bfde84a.png)

こういう図を作ることで、

- **最近自分がよく使ってるから、"React"に近い概念や技術を調べてみよう！**
- **言語とかライブラリとか多くて把握しきれない、とりあえず何関連の技術なのか？くらいは知っておきたい！**
- **自分が知らないだけでこのライブラリ以外と投稿数多いな、チェックしておこう！**

…みたいな使い方ができるんじゃないか？と思って作ってみました。

ちなみに、後述する問題があったため、今回タグデータを収集する記事は条件を付けて絞り込んでいます。（「JavaScript」「HTML」「フロントエンド」の3種のタグのいずれかを含む記事）
たぶん図を見る限りはうまくできてそう:sweat_smile:。
だけど、何か間違いとかあればぜひコメントください。

# 成果物のイメージ

使っている技術や構成要素はこんな感じ。
※GoogleColaboratoryでデータ分析するときはPythonを使用

- Qiita
- GoogleAppsScript
- GoogleSpreadsheets
- GoogleColaboratory

![図解.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/04fba44f-9009-2b1c-ca01-046ca48e3b1b.jpeg)

今回はGASでのデータ収集部分などは省いて、GoogleColaboratoryでどういったコードを書いたか、工夫をしたか、といったことを書いていきます。
特に、収集したタグデータのDataFrameをInputとして、ネットワーク図出力で必要な形式のOutputに変換する部分のコードのみを載せます。
※SpreadSheetからデータを読み込む部分や、データ配列からネットワーク図を出力する部分は、参考サイト（後述）をかなりそのまま使っているところもあるのと、単純に記事がめちゃ長くなってしまうので省きます。

# コード

import文とか。

```py
!pip install numpy
!pip install scikit-learn

from sklearn.preprocessing import MinMaxScaler
import pandas as pd
from collections import Counter
import numpy as np
```

関数の定義とか。

```py
def scale_min_max(source):
  """
  数値のリストを渡して、0～1の範囲まで落とし込む。(min/maxスケーリング)
  """
  scaler = MinMaxScaler()
  scaled_data = scaler.fit_transform(source)
  # scale後の値は0になることがありうる。小さな定数を追加してZeroDivisionErrorを回避する。
  adjuster = 0.01
  return [i + adjuster for i in scaled_data]

def get_count_pair(target_df):
  """
  単語や単語のセットのdfを渡し、単語や単語のセットの出現数をカウントし、
  2種類のリストに分割して返す。
  """
  # countからweightの形にするため、min/maxスケーリングで0～1の範囲まで落とし込む
  word_count = Counter(target_df)
  items = list(word_count.items())
  words, counts = zip(*items)
  counts = np.array(counts).reshape(-1, 1)
  scaled_data = scale_min_max(counts)
  return words, scaled_data

def get_all_words_count(df, all_tag_columns):
  """
  全単語と、出現回数のタプルを返す。
  """
  all_words = np.array([df[tag].tolist() for tag in all_tag_columns if bool(tag)]).flatten()
  words, scaled_data = get_count_pair(all_words)
  return words, scaled_data


def get_word_size_pair(all_words, all_words_scaled_data):
  """
  全シートのdfから、ノードの大きさなどに反映するための単語と出現数のdfを得る。
  """
  converted_word_count = [(all_words[i], {"weight": all_words_scaled_data[i][0]}) for i in range(len(all_words))]
  return converted_word_count

def get_words_and_distant_pair(df, all_words, all_words_scaled_data):

  # 同じ記事に対して付与されているタグ同士をセットにして、共起情報から0-1までの類似度を算出する。
  # 具体的には、("タグA", "タグB", "0.03636...")のように2タグと2タグ間の類似度のセットを作る。
  pair_list = []

  for row in df.itertuples(index=False, name=None):
    null_deleted = filter(lambda x: bool(x), row)
    pairs = list(combinations(null_deleted, 2))
    pair_list.extend(pairs)

  word_pairs, words_distance_scaled_data = get_count_pair(pair_list)

  # そのままだと、「複数記事で同じセットで登場するが、総数が少ないためにネットワークの辺境に出力されるタグ」があるため調整する。
  # 単語自体の出現頻度の逆数（出現頻度が高いほど低く、頻度が低いほど高くなる値）をとり、重みにかける。
  # より正確には、2単語あるため各単語の出現回数の平均の逆数とする。
  reversed_words_frequency = [[get_reversed_words_frequency(all_words_scaled_data, all_words, target_words)] for target_words in word_pairs]
  scaled_reversed_words_frequency = scale_min_max(reversed_words_frequency)
  return [word + tuple(scaled_data + reversed_frequency) for word, scaled_data, reversed_frequency in zip(word_pairs, words_distance_scaled_data, scaled_reversed_words_frequency)]

def get_word_count(all_counts, all_words, word):
  """
  指定した単語の出現回数を返す。
  """
  return np.sum(all_counts[all_words.index(word)], axis=0)

def get_reversed_words_frequency(all_counts, all_words, target_words):
  """
  2単語それぞれの出現回数の平均値をとり、その逆数を返す。
  """
  count_average = np.average([get_word_count(all_counts, all_words, word) for word in target_words])
  reversed_frequency = 1 / count_average
  return reversed_frequency
```

渡すデータ例とか関数の呼び出しとか。
```py
# dfは以下のような構造になってます。(Qiitaはタグの数最大5つ)
#            tag1        tag2          tag3        tag4       tag5
# 1    JavaScript     Node.js    TypeScript    unittest       Jest
# 2    JavaScript    three.js      CesiumJS    Globe.GL     Gio.js
# 3    JavaScript         AWS          tips  TypeScript      Polly
# 19   JavaScript     mariadb  GoogleMapAPI     geojson   geometry
# 20   JavaScript  Salesforce          Apex        SOQL        lwc
# ...(略)

# all_tag_columnsは["tag1", "tag2", "tag3", "tag4", "tag5"]の列名になっています。

all_words, all_words_scaled_data = get_all_words_count(df, all_tag_columns)
word_size_pair = get_word_size_pair(all_words, all_words_scaled_data)
words_and_distant_pair = get_words_and_distant_pair(df, all_words, all_words_scaled_data)

# この時点のデータは以下の通り。
# word_size_pair ('単語', {'weight': ノードの大きさ})
# [
#   ('JavaScript', {'weight': 1.01}),
#   ('Python', {'weight': 0.15035087719298246}),
#   ('HTML', {'weight': 0.22052631578947368}),
#   ...(略)
# ]
# words_and_distant_pair ('タグ1', 'タグ2', ノード同士の距離)
# [
#   ('JavaScript', 'Node.js', 0.488555561538897),
#   ('JavaScript', 'TypeScript', 0.6208905867326013),
#   ('JavaScript', 'unittest', 0.0242976094547408)
#   ...(略)
# ]
```

まあ処理はコードを見てください、という感じですが、
　１：ノードの大きさ
　２：ノード間の距離
がどんな感じで算出されているかざっくり書いておきます。

１は、数が大きいほどノードの直径も大きくなります。単純に、収集した記事でのそのタグの出現回数を使っています。
２は、2つの単語のペアと、そのペアがセットで何回出たかという指標を使っています。同時に使われることが多いほど数は大きくなり、ノード同士の距離も近づきます。
※ただし、単純に同時に使われた回数だけを指標にすると後述する問題（２：出現頻度の低い単語同士が近くに配置されない）があったので、トータルの出現回数の少ない単語同士の場合は下駄をはかせるように調整しています。

# 詰まったところとか工夫したところとか

### １：分野を絞らないと、共起表現の解釈ができない

**う、うわあああああああ** :slight_smile::upside_down::slight_smile::upside_down::slight_smile::upside_down:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/d0a2a05f-08c3-001e-51b9-879b679c985a.png)

ごちゃつきすぎ。ですね。
これは、実装のかなり初期に、分野をフロントエンドに絞らず出力したネットワーク図です。後述の2つの問題もまだ解消されていないので、本当にカオス。
もちろん、出力するタグ数が多いとか、生成されるファイルサイズが巨大になるとか、色々問題はあるんですが、実は **分野を絞らずにデータ収集＆作図した場合、データの性質上の根本的な問題があったんです。**

例えば、以下のようなタグを設定された記事があるとします。

- Python / システム開発 / 初心者向け
- Javascript / システム開発 / 初心者向け

この時、
「Python」と「システム開発」の距離
「Javascript」と「システム開発」の距離
は、同じ値になります。つまり、先ほどのデータだと、↓の2例のようなパターンが起こりえます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/e8db44c5-195f-98b2-3c6f-4de977c6f20f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/61befdaa-b948-fabf-d2b0-a04f610efa63.png)

共通のタグを持つ場合、全然違う分野でも近しい位置に配置されやすくなります。
「初心者向け」とか「ポエム」とか、特定の技術分野に依らずつけられるタグがある以上、この問題は起こりえます。
タグ数が少ないならまだしも、先ほどのタグの量でこれが起こると、もともとやりたかった「近しい概念、共起しやすいタグ同士をグルーピングする」ということができなくなります。
そのため、今回は **フロントエンドで出やすい3種のタグをベースに、それを含む記事と共起するタグのみ収集する** ようにしました。

### ２：出現頻度の低い単語同士が近くに配置されない

当たり前なんですが、「Node.js」とか「TypeScript」とかは出現回数がとても多いです。
一方で、記事の主題だけど技術の名前ではない「ニコニコ生放送」とか「ニコ生ゲーム」とかは出現回数が少ないです。
この状態で、共起回数だけを基準にノードの距離を計算しようとすると、下図の左のような結果になってしまいました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/cdd2b964-38ec-cdc5-544e-6462eaef61b1.png)

本当は「ニコニコ生放送」「ニコ生ゲーム」などをまとめて右のような感じで、近しい場所に共起しやすいタグのノードを配置したいんですが、 **そもそもの出現回数が少ないので、中心にあるタグ（Node.jsやTypescript）以外は、ノードの近しさを表す指標が相対的に全部小さな値となってしまいます。** つまり、外縁部のノードは、近さについてはどんぐりの背比べ状態で、「どこが特に近いノードなのか？」がわからないため、それぞれがまとまりなく配置されている、という状態です。
そのため今回は、 **「共起表現のペアに含まれる2単語の出現回数の平均値の逆数を算出し、共起回数と足し合わせて距離とする」** という対応で距離の指標に下駄をはかせる方法をとっています。
……書いてはみたものの、文章だとよくわかりませんね。:rolling_eyes:

例えば、↓のようなデータを考えてみます。
※（）内はスケーリング後。0は小さな定数（今回は0.001）として計算しています。

**単語自体の出現回数**
| タグ名 | 出現回数 |
| --- | --- |
| Node.js | 90 |
| レスポンシブ | 30 |
| ニコ生ゲーム | 5 |
| ニコニコ生放送 | 10 |

**2単語同士の共起回数**
| タグ1 | タグ2 | 共起回数 | 
| --- | --- | --- |
| Node.js | レスポンシブ | 20(1) |
| Node.js | ニコニコ生放送 | 8(0.294) |
| ニコ生ゲーム | ニコニコ生放送 | 3(0.001) |

上記のサンプルデータをもとに、単語自体の出現回数、逆数…などを計算し、最終的に

> 共起表現のペアに含まれる2単語の出現回数の平均値の逆数を算出し、共起回数と足し合わせて距離とする

の距離を算出してみます。

| タグ1 | タグ2 | 共起回数 | 2単語の出現回数平均 | 出現回数平均の逆数 | 距離 |
| --- | --- | --- | --- | --- | --- |
| Node.js | レスポンシブ | 20(1) | 60 | 0.016...(0.001) | 1.001 |
| Node.js | ニコニコ生放送 | 8(0.294) | 50 | 0.02(0.034) | 0.328 |
| ニコ生ゲーム | ニコニコ生放送 | 3(0.001) | 7.5 | 0.133...(1) | 1.001 |

3つのタグの組み合わせについて、主観を交えてみてみましょう。

１：Node.js & レスポンシブ
　共起回数は一番多く、２よりは関係の強い組み合わせ。
２：Node.js & ニコニコ生放送
　共起回数は二番目に多いが、出現回数の総数が多いだけで、１や３ほど強い関係ではない。
３：ニコ生ゲーム & ニコニコ生放送
　共起回数は一番少ないが、出現回数の総数が少ないだけで、２よりも関係の強い組み合わせ。

なので、ノード間の距離を表す値は、 ↓のような関係になるのが望ましそうですね。

```
１ ≒ ３ > ２
```

共起回数のスケーリング後の数を「ノード間の距離を表す値」として使う場合、１ > ２ > ３　のよう関係になってしまいます。そこで、単語自体の出現回数平均の逆数と合わせて、バランスを調整した「距離」列のような数値を使って図示化しています。

これにより、②のように「出現総数は低いが共起しやすい」タグ同士はめちゃめちゃ太い線で、かつ近しい位置に出力できました。（ネットワーク図としては汚くなりますが、やりたいことはできたのでまあいいかな…:ok_hand:）

### ３：スケーリングで0が出てしまい扱いに困った

今回、MinMaxスケーリング（0~1の間に落とし込む）を使っており結果が0になることがあります。
途中でスケーリング後の値を使った除算をするため、ZeroDivisionErrorが発生しました。

こういった場合は「1e-10(1 × 10の10乗)のように、極小の定数値を追加することで0ではない値にする」といった手法をとることもあるらしいんですが、今回はそれだとうまくいかなかったため、色々調整して0.01を足しています。

具体的には、↓みたいな問題がありました。
①ノードの距離や線の太さ計算への影響。（ノード間の線がほぼ見えない）
②ノードのサイズ計算への影響。（ノードを表す丸がほぼ見えない）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551920/2604d98b-6234-b65e-271a-a6639cb72d36.png)
※端っこ、つまり出現総数が少ないタグの場合に起こる問題です。

### 感想とか

実際にデータ分析とか機械学習関連の技術を使って実用的なツール作るのは初めてだったので、いい経験になりました。
あと、結構やりたいことによっては、最初に考えた算段だとか、理論だとかをそのまま使ってもうまくいかないことがあるので、算段を立てて、理論を知った上で、さらに細かい調整が必要なんだな～、というのが実感できて良かったです。:v:

# 参考サイト等

[Google Colaboratoryでスプレッドシートを簡単に読み込む方法](https://qiita.com/ryo18/items/73d49d40632da624c587)
[Pythonで共起ネットワークを作る方法をご紹介します](https://note.com/noa813/n/ne506f884e467)