Qiitaベクトル v0.1: SCDVによるQiita記事のベクトル化

# はじめに

本記事では、SCDV(Sparse Composite Document Vectors)により、Qiita記事のベクトルを作成した。
以降、Qiita記事のベクトルを**Qiitaベクトル**と呼ぶ。

### 本記事の概要
- [[作成](#Qiitaベクトルの作成)] Qiita記事を収集し、SCDVによるQiitaベクトルを作成
- [[実験](#実験)] Qiitaベクトルを用いて、Qiitaでのタグ、投稿者、組織間の類似度を観察
- [[今後の課題](#今後やってみたいこと)] やってみると、微妙なところが気になったので**v0.1**にした。試したいことが色々でてきた。

# SCDVとは

SCDVは、EMNLP2017で発表された文書ベクトル作成手法[[元論文]](#参考)
文書ベクトルは、以下の特徴を持つ。

- Word-topics vectorにより高い表現力を実現
  - Word-topics vectorは、単語の潜在的な複数のトピックを考慮する。具体的には、単語ベクトルへソフトクラスタリング(GMM)を適用し、各クラスタへの帰属度および文書のIDF値を考慮して、クラスタの数だけ単語ベクトルを連結する
- スパース化により計算効率の向上およびノイズ除去を実現(?)

元論文では英語文書の分類タスクで高い精度を確認している。
また、以下の記事では日本語文書において検証されている。

- [[1]](#参考): 日本語ニュース記事(livedoorニュースコーパス)で検証
- [[2]](#参考), [[3]](#参考): それぞれのドメインで検証しており「いい感じ」らしい?

以下は手法の概要図である。こちらは[著者ホームページ](https://dheeraj7596.github.io/SDV/)より抜粋。

- Precomputation of word-topics vector
![](https://cloud.githubusercontent.com/assets/24454690/21263003/60c4a452-c3ba-11e6-83af-98f1a369d091.png)
- Building sparse document vectors using word-topics vectors
![](https://cloud.githubusercontent.com/assets/24454690/21744514/2953d954-d53d-11e6-9b0a-d796ca242c66.png)

# Qiitaベクトルの作成

### 参考にしたコード

- Qiita記事のスクレイピングは[[4]](#参考)を参考にしました。
- SCDVの実装は[[1]](#参考)を参考にしました。

### Qiita記事取得
- 2011/9 - 2019/2の間の358574本の記事を取得した
- 本文はrendered_textからpタグ内のtextのみ取得。コード等は無視。

### SCDVにより記事ベクトルを作成

今回は、Qiita記事のテキストをfastTextにより学習し、得られた単語ベクトルを用いてSCDVにより文書ベクトルを作成した。

#### テキスト前処理
- MeCabによる形態素解析
   - 使用した品詞: 名詞、動詞、形容詞のみ
   - 使用した辞書: IPA辞書+頻出Qiitaタグのユーザ辞書を作成。頻出Qiitaタグは、10以上の記事に付与されているものとした。今回の実験では、6049個。

#### fastTextの学習
fastTextの学習には[gensim](https://radimrehurek.com/gensim/)を使用した。

```python:train_fastText
from gensim.models import FastText

# hyper parameters

num_features = 300 # fastText embedding dim
min_word_count = 10 # Minimum word count
context = 10 # Context window size

# learn fastText

w2v_model = FastText(size=num_features, sg=1, workers=-1, window=context, min_count=min_word_count)
w2v_model.build_vocab( sentences )
w2v_model.train(sentences, total_examples=w2v_model.corpus_count, epochs=w2v_model.epochs)
```

#### SCDVによる文書ベクトル作成

```python:train_SCDV
from sklearn.mixture import GaussianMixture

# hyper parameters

num_clusters = 30 # cluster num of GMM clustering
sparse_percentage = 0.01

# train scdv

gmm =  GaussianMixture(n_components=num_clusters, covariance_type="tied", init_params='kmeans', max_iter=50)
scdv_model = SCDV(w2v_model=w2v_model, sc_model=gmm, sparse_percentage = sparse_percentage )
scdv_model.precompute_word_topic_vector(sentences)
X = scdv_model.train(sentences)
```

```python:SCDV_class
class SCDV(object):
    
    """
    w2v_model: gensim_w2v_model
    softclustering_model: sklearn_gmm (gmm only?)
    sparse_percentage: the threshold percentage for making it sparse
    """
    def __init__(self, w2v_model, sc_model, sparse_percentage):
        
        # values
        self.w2v_model = w2v_model
        self.num_clusters = sc_model.n_components
        self.w2v_vector_size = w2v_model.vector_size
        self.min_no = .0
        self.max_no = .0
        self.sparse_percentage = sparse_percentage
        
        # apply soft clustering to embedding vectors
        self.w2v_vectors = w2v_model.wv.vectors
        idx, idx_proba = self._soft_clustering( sc_model, self.w2v_vectors)
        self.word_centroid_map = dict( zip( w2v_model.wv.index2word, idx ) ) # Create a Word / Index dictionary, mapping each vocabulary word to a cluster number
        self.word_centroid_prob_map = dict(zip( w2v_model.wv.index2word, idx_proba )) # Create a Word / Probability of cluster assignment dictionary, mapping each vocabulary word to list of probabilities of cluster assignments.       
               
        return
    
    def precompute_word_topic_vector( self, sentences ):
               
        # compute idf values
        self.featurenames, self.word_idf_dict = self._compute_idf_values( sentences ) 
        
        # compute word topic vectors
        self.wv = self._get_probability_word_vectors()
        return
    
    def train( self, sentences ):

        # values
        doc_num = len(sentences)
               
        # get document vector
        X = np.zeros( (doc_num, self.num_clusters*self.w2v_vector_size), dtype="float32")
        for idx, tokens in enumerate( sentences ):
            X[idx] = self._create_cluster_vector_and_gwbowv( tokens, train=True)

        # get the threshold value for making it sparse. 
        thres = (abs( self.max_no / float( doc_num ) ) + abs( self.min_no / float( doc_num ) )) / 2
        self.sparse_thres = thres * self.sparse_percentage
        
        # Make values of matrices which are less than threshold to zero.
        temp = abs(X) < self.sparse_thres
        X[temp] = 0

        return X

    def infer_vector( self, sentences ):
        
        # values
        doc_num = len(sentences)
        
        # get document vector
        X = np.zeros( (doc_num, self.num_clusters*self.w2v_vector_size), dtype="float32")
        for idx, tokens in enumerate( sentences ):
            X[idx] = self._create_cluster_vector_and_gwbowv( tokens, train=False)
       
        # Make values of matrices which are less than threshold to zero.
        temp = abs(X) < self.sparse_thres
        X[temp] = 0        

        return X

    
    def _create_cluster_vector_and_gwbowv( self, wordlist, train=False):
        
        bag_of_centroids = np.zeros( self.num_clusters * self.w2v_vector_size, dtype="float32" )
        for word in wordlist:
            if not word in self.word_centroid_map: continue  
            bag_of_centroids += self.wv[word]

        norm = np.sqrt(np.einsum('...i,...i', bag_of_centroids, bag_of_centroids))
        if norm != 0: bag_of_centroids /= norm

        # to make feature vector sparse, make note of minimum and maximum values.
        if train:
            self.min_no += min(bag_of_centroids)
            self.max_no += max(bag_of_centroids)

        return bag_of_centroids

    def _get_probability_word_vectors( self ):
               
        prob_wordvecs = {}
        for word in self.word_centroid_map:
            prob_wordvecs[word] = np.zeros( self.num_clusters * self.w2v_vector_size, dtype="float32" )
            for c_idx in range(0, self.num_clusters):
                if not word in self.word_idf_dict: continue
                prob_wordvecs[word][c_idx*self.w2v_vector_size:(c_idx+1)*self.w2v_vector_size] = self.w2v_model.wv[word] * self.word_centroid_prob_map[word][c_idx] * self.word_idf_dict[word]
        
        return prob_wordvecs

    @staticmethod
    def _soft_clustering( sc_model, word_vectors):
        
        sc_model.fit(word_vectors)
        idx = sc_model.predict(word_vectors)
        idx_proba = sc_model.predict_proba(word_vectors)

        return (idx, idx_proba)

    @staticmethod
    def _compute_idf_values( sentences ):
        
        tfv = TfidfVectorizer(tokenizer=lambda x: x, preprocessor=lambda x: x, dtype=np.float32)
        tfidfmatrix_traindata = tfv.fit_transform(sentences)
        featurenames = tfv.get_feature_names()
        idf = tfv._tfidf.idf_
        idf_dict = { pair[0]:pair[1] for pair in zip(featurenames, idf) }
        
        return featurenames, idf_dict
```


# 実験

以下では、ベクトルの近傍探索により類似記事、タグ、投稿者、企業の検索をしてみました。
記事は、SCDVにより得られたQiitaベクトルをそのまま使いますが、それ以外はそれぞれ以下のようにベクトルを作成しました。

- `タグ`　タグが付与された記事のQiitaベクトルの平均ベクトル
- `投稿者`　投稿者が投稿した記事のQiitaベクトルの平均ベクトル
- `組織`　組織(Qiita Organization)が投稿した記事のQiitaベクトルの平均ベクトル
  - 投稿者が組織への所属を登録している場合、以下のようにQiita記事に登録されており、これを使いました。
  - <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264359/7f4d03c6-c56b-7c4f-184e-cb8556760a28.png" width=70%>

なお、近傍探索はSpotifyの作ったライブラリ[Annoy](https://github.com/spotify/annoy)を使いました。
以下のコードのように9近傍のベクトルを探索しました。

```python
target = id2idx['9f8fee924fdc3f7ef411'] # MeCabの辞書への語彙追加方法【Windows 10, Ubuntu 18.04】

print( 'Query  - title: %s' % (titles[target]) )

k = 10
near_list, dists = t_scdv.get_nns_by_item(target, k, include_distances=True) # 
for i in range( 1, k, 1 ):
    doc_idx = near_list[i]
    print( 'Rank:%d (dist:%.3f) - title: %s' % (i, dists[i], titles[doc_idx] ) )
```



## 記事

実験結果は以下の通りです。`Query`が入力のベクトルです。
表示しているのがタイトルだけですが、上位部分はある程度類似していそうに見えます。

```
Query  - title: MeCabの辞書への語彙追加方法【Windows 10, Ubuntu 18.04】

Rank:1 (dist:0.940) - title: MeCabで文字コードの違う辞書を使う
Rank:2 (dist:1.004) - title: mecabのユーザー辞書をphp-mecabから認識させる
Rank:3 (dist:1.047) - title: kagomeでの形態素解析にユーザ辞書を使う雑なサンプル
Rank:4 (dist:1.074) - title: Anacondaの仮想環境を切り替えるSimple is Best 方法
Rank:5 (dist:1.118) - title: Azure App Service へASP.NET Web アプリをコマンドプロンプトからデプロイする
Rank:6 (dist:1.122) - title: mecabと文字コード
Rank:7 (dist:1.123) - title: mecab-ipadic-neologd対応のRESTful MeCabサーバ用Dockerfile
Rank:8 (dist:1.124) - title: pipenv仮想環境内のJupyter Notebookを一瞬で起動する方法
Rank:9 (dist:1.124) - title: pythonでつくるTodoアプリ（CLI）
```

```
Query  - title: 自然言語処理における前処理の種類とその威力

Rank:1 (dist:0.868) - title: 自然言語（前）処理
Rank:2 (dist:0.895) - title: なぜ自然言語処理にとって単語の分散表現は重要なのか？
Rank:3 (dist:1.029) - title: 汎用言語表現モデルBERTの内部動作を解明してみる
Rank:4 (dist:1.057) - title: Vimでキャメルケースのスペルチェックをするプラグインを作った 〜 CCSpellCheck.vim 〜
Rank:5 (dist:1.100) - title: word2vecで佐久間まゆの類似単語を調べる
Rank:6 (dist:1.103) - title: Atom や Sublime Text で日本語の単語移動を可能にする（Vim化プラグインでも有効）
Rank:7 (dist:1.106) - title: Gearpumpの最小アプリケーションの構成は？
Rank:8 (dist:1.118) - title: 大規模データ処理基盤に入門する
Rank:9 (dist:1.131) - title: ES2017 async/await で sleep 処理を書く
```

```
Query  - title: AtCoder に登録したら次にやること ～ これだけ解けば十分闘える！過去問精選 10 問 ～

Rank:1 (dist:0.700) - title: AtCoder に登録したら解くべき精選過去問 10 問を Hexagony で解いてみた（問題解説編）
Rank:2 (dist:0.730) - title: AtCoder に登録したら解くべき精選過去問 10 問を別解で解いてみた
Rank:3 (dist:0.737) - title: 「期待値の線型性」についての解説と、それを用いる問題のまとめ
Rank:4 (dist:0.739) - title: しゃくとり法 (尺取り法) の解説と、それを用いる問題のまとめ
Rank:5 (dist:0.743) - title: Python3で競技プログラミングする時に知っておきたいtips（入力編）
Rank:6 (dist:0.747) - title: AtCoder に登録したら解くべき精選過去問 10 問を Whitespace で解いてみた
Rank:7 (dist:0.747) - title: ABC112　D問題
Rank:8 (dist:0.748) - title: Project Eulerをシェル芸で解いてみる(Problem 27) #シェル芸
Rank:9 (dist:0.758) - title: 動的計画法超入門！ Educational DP Contest の A ～ E 問題の解説と類題集
```

## タグ

良い感じに見えます。
ただ、タグに関しては記事の類似性というより、同じ記事に付与されたタグの共起っぽい類似性ですね。

```
Query  - user: Python

Rank:1 (dist:0.121) - title: Python3
Rank:2 (dist:0.302) - title: python2.7
Rank:3 (dist:0.352) - title: 初心者
Rank:4 (dist:0.361) - title: #python
Rank:5 (dist:0.368) - title: 備忘録
Rank:6 (dist:0.373) - title: tips
Rank:7 (dist:0.389) - title: JavaScript
Rank:8 (dist:0.396) - title: TensorFlow
Rank:9 (dist:0.400) - title: Linux
```

```
Query  - user: Ruby

Rank:1 (dist:0.256) - title: RubyOnRails
Rank:2 (dist:0.257) - title: Rails
Rank:3 (dist:0.315) - title: Rails4
Rank:4 (dist:0.343) - title: Rails5
Rank:5 (dist:0.390) - title: Gem
Rank:6 (dist:0.413) - title: 備忘録
Rank:7 (dist:0.421) - title: 初心者
Rank:8 (dist:0.425) - title: tips
Rank:9 (dist:0.433) - title: Python
```

```
Query  - user: 機械学習

Rank:1 (dist:0.156) - title: MachineLearning
Rank:2 (dist:0.261) - title: DeepLearning
Rank:3 (dist:0.272) - title: 深層学習
Rank:4 (dist:0.320) - title: ディープラーニング
Rank:5 (dist:0.375) - title: scikit-learn
Rank:6 (dist:0.416) - title: 人工知能
Rank:7 (dist:0.422) - title: Keras
Rank:8 (dist:0.424) - title: Chainer
Rank:9 (dist:0.433) - title: TensorFlow
```

```
Query  - user: 自然言語処理

Rank:1 (dist:0.285) - title: NLP
Rank:2 (dist:0.513) - title: Python
Rank:3 (dist:0.527) - title: word2vec
Rank:4 (dist:0.535) - title: 形態素解析
Rank:5 (dist:0.537) - title: DeepLearning
Rank:6 (dist:0.538) - title: 機械学習
Rank:7 (dist:0.538) - title: gensim
Rank:8 (dist:0.540) - title: Python3
Rank:9 (dist:0.562) - title: 初心者
```

## 投稿者

自分を入力にして、検索してみました。

```
Query  - user: myaun

Rank:1 (dist:0.869) - name: methane
Rank:2 (dist:0.878) - name: FukuharaYohei
Rank:3 (dist:0.879) - name: daisukelab
Rank:4 (dist:0.881) - name: fantm21
Rank:5 (dist:0.884) - name: yukiB
Rank:6 (dist:0.892) - name: hiroyuki827
Rank:7 (dist:0.895) - name: wapa5pow
Rank:8 (dist:0.895) - name: gold-kou
Rank:9 (dist:0.895) - name: momotaro98
```

自分と類似する上位3名のプロフィールは、こんな感じです。
微妙ですが、主にPythonで、機械学習系が多めな感じなので割と納得感はあります。

|投稿者|プロフィール|
|---|---|
|Query(自分)|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264359/eea9afa1-19bc-cf10-d4fb-123f23ea2b31.png">|
|Rank 1|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264359/0c7a7358-2c17-b198-cab9-0a1393a22dfb.png">|
|Rank 2|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264359/e61dbc59-ca78-ea71-0f8c-01568226bb7e.png">|
|Rank 3|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264359/0a5e713f-9494-894f-0cc3-f9a1c2d68f02.png">|

## 組織 (Qiita Organization)

あまりピンとこない感じでした。
この結果の理由としては、この辺かと思ってます。

- 所属ってそんなにちゃんと使われてない気がする (タグはちゃんとしてそう)
- 対象を絞り込んでないので、変なのがかなり混じっていた
- `組織の類似性`を`組織に所属している人が投稿した記事の類似性`で測れる?

```
Query  - name: 東京大学

Rank:1 (dist:0.827) - name: The University of Tokyo
Rank:2 (dist:0.830) - name: KLab Inc.
Rank:3 (dist:0.830) - name: Kyoto University
Rank:4 (dist:0.836) - name: Cookpad Inc.
Rank:5 (dist:0.838) - name: フリーランス
Rank:6 (dist:0.839) - name: 株式会社ニジボックス
Rank:7 (dist:0.839) - name: エムスリー株式会社
Rank:8 (dist:0.842) - name: 東京大学工学部
Rank:9 (dist:0.842) - name: なし
```

```
Query  - name: IBM

Rank:1 (dist:0.625) - name: フリーランス
Rank:2 (dist:0.640) - name: DeNA Co., Ltd.
Rank:3 (dist:0.643) - name: Tech Fun株式会社
Rank:4 (dist:0.646) - name: 株式会社オープンストリーム
Rank:5 (dist:0.647) - name: 株式会社ISAO
Rank:6 (dist:0.651) - name: Freelance
Rank:7 (dist:0.659) - name: CyberAgent, Inc.
Rank:8 (dist:0.663) - name: SAKURA Internet Inc.
Rank:9 (dist:0.667) - name: Enechange
```

```
Query  - name: Mercari

Rank:1 (dist:0.428) - name: フリーランス
Rank:2 (dist:0.455) - name: Cookpad Inc.
Rank:3 (dist:0.465) - name: AbemaTV, Inc.
Rank:4 (dist:0.469) - name: DeNA Co., Ltd.
Rank:5 (dist:0.480) - name: Freelance
Rank:6 (dist:0.482) - name: mixi
Rank:7 (dist:0.486) - name: 株式会社キュリオシティソフトウェア
Rank:8 (dist:0.490) - name: CyberAgent, Inc.
Rank:9 (dist:0.493) - name: freee K.K.
```

```
Query  - name: DeNA Co., Ltd.

Rank:1 (dist:0.299) - name: フリーランス
Rank:2 (dist:0.364) - name: Freelance
Rank:3 (dist:0.391) - name: CyberAgent, Inc.
Rank:4 (dist:0.401) - name: Wantedly, Inc.
Rank:5 (dist:0.401) - name: freee K.K.
Rank:6 (dist:0.402) - name: 株式会社ISAO
Rank:7 (dist:0.406) - name: freelance
Rank:8 (dist:0.423) - name: 株式会社ゆめみ
Rank:9 (dist:0.440) - name: LIFULL Co., Ltd.
```

# 終わりに

### なぜやったか
- ちょっと前、TwitterのTLや色んな技術記事で`SCDV`を観測して、サブリミナル的に刷り込まれて試したくなった
- 今春からQiitaが転職支援サービス`Qiita Jobs`が始まったらしいので、投稿記事を用いて企業に合う求職中のQiitaユーザを予測するのかな(適当)と思ったので、その真似事をしてみたかった

### 今後やってみたいこと 
- **タグを考慮した学習**: [[5]](#参考)のように記事のタグも一緒に学習したい。そのままテキストとして食べさせせずに、より高次な情報として良い感じに使いたい。
- **コードを考慮した学習**: 埋め込まれているコードの部分も使えたらQiitaっぽくて良い。せめて言語の種類ぐらいは埋め込みたい、技術記事の分析とかで先行事例あれば知りたい。
- **メタデータを考慮した学習**: 他の統計情報とかメタデータもモデルに組み込みたい(記事のいいね数、ストック数やユーザのいいね、フォロー関係など)。影響が強くなりすぎて文書の内容を汚染しないように注意。
- **定量評価**: Qiitaベクトルを何らかの応用に適用して評価する。タグ予測をしようとたけどうまくいかなかった(良い感じに問題設定できなかった)そのうちやりたい。

# 参考

**SCDV関連**
[元論文] Dheeraj Mekala, Vivek Gupta, Bhargavi Paranjape, Harish Karnick, "SCDV : Sparse Composite Document Vectors using soft clustering over distributional representations", EMNLP2017, https://arxiv.org/abs/1612.06778
[1] [文書ベクトルをお手軽に高い精度で作れるSCDVって実際どうなのか日本語コーパスで実験した(EMNLP2017)](https://qiita.com/fufufukakaka/items/a7316273908a7c400868)
[2] [医療用語に注目した文書の類似度計算(SCDV+XGBoost)](https://www.m3tech.blog/entry/similarity-with-scdv)
[3] [Anonymize Large-scale Sparse User Features at LINE Corp](https://speakerdeck.com/line_developers/anonymize-large-scale-sparse-user-features-at-line-corp)

**Qiita記事スクレイピング関連**
[4] [Qiitaの記事データをQiita API, Scrapyで収集](https://qiita.com/nkmk/items/fbac13cd05b80334eb2b)

**その他**
[5] [サブカルのためのword2vec](https://engineer.dena.jp/2018/01/animew2v.html)