Slackに飛んでくるブックマークをDoc2VecとPCAで可視化してみた

アドベントカレンダーの11日目の記事です

## これは
みんな（4人）がブックマークした記事タイトルの分散表現を獲得して可視化したもの

ブックマークするとIFTTTが拾ってslackに吐くようにしているのでそこから処理する

## 前提条件

- 環境
    - [Dockerfileへのリンク](https://github.com/sh05/training/blob/master/docker/Dockerfile)



## 参考文献／利用したもの

1. [【転職会議】クチコミをword2vecで自然言語処理して会社を分類してみる](https://qiita.com/naotaka1128/items/2c4551abfd40e43b0146)
2. [【word2vec】会社のクチコミを自然言語処理した結果を可視化してみる](https://qiita.com/naotaka1128/items/e617f63907fed035408a)
3. [pythonによる日本語前処理備忘録](https://datumstudio.jp/blog/python%E3%81%AB%E3%82%88%E3%82%8B%E6%97%A5%E6%9C%AC%E8%AA%9E%E5%89%8D%E5%87%A6%E7%90%86%E5%82%99%E5%BF%98%E9%8C%B2)
3. [slackのチャットログをお手軽バックアップ](https://blog.pyyoshi.com/2016/10/18/slacknotiyatutoroguwooshou-qing-batukuatupu/)
2. [models.doc2vec(gensim)](https://radimrehurek.com/gensim/models/doc2vec.html)
3. [slack-dump](https://github.com/joefitzgerald/slack-dump)
2. [slack api](https://api.slack.com/methods/channels.history)


## できるもの

### やる前の予想
- Rくん
    - ガジェットやセキュリティ系が多い
    - ブクマ数79
- Yさん
    - 4人の中で一番範囲が広い
    - 実はこのユーザだけみんなにシェアする目的で取捨選択したものを投稿している
    - ブクマ数864
- Mくん
    - Webと機械学習など
    - ブクマ数240
- S（自分）
    - Webと機械学習とガジェットに加えて「今年はサンマが不漁」みたいなのまで投げてしまう
    - ブクマ数896

### 結果
範囲と重複具合は直感的に予想に近い

<img width="800" alt="スクリーンショット 2019-12-11 19.16.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176375/5f5f8507-2200-eb89-9792-722268284bfc.png">


## 準備

### はてなブックマークをIFTTTにSlackへ投稿させる仕組み
#### 手順

詳細は割愛するが下図の流れで仕組み自体は出来上がる、なお4コマ目と5コマ目の間にRSS Feedを受け取るためのURL入力が必要で、今回ははてなブックマークなので`http://b.hatena.ne.jp/<username>/rss`となる

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176375/c6612769-d18b-69cd-aa4e-895ba5a82ff0.jpeg">

こんな感じ

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176375/265954fd-7ca0-ed1a-68e6-9fc3f0825627.png">

#### 理由
##### はてな内でユーザをお気に入りすればいい?
 
それでも悪くはない（むしろ両方してもいい）が、コミュニティ内だとこんな感じで気軽にコメントしあえる

<img width="200" alt="IMG_52CF406E5EED-1.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176375/98637cd6-dfb4-1904-05b7-a8c58cd7cc6f.jpeg">

##### Slackコマンドの/feedをつかえばいい?

投稿文をカスタマイズできるので今回のように遊びに使える、またSlackコマンドを使うとめちゃくちゃスペースを取るので困る

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176375/620cee12-1a4f-2913-400f-c34033cff2fd.png">

### Slackから投稿メッセージを取得

簡単にできそうなのはこの2種類

1. SlackのAPI
    - https://api.slack.com/methods/channels.history
2. Go製のツール（今回はこっち）
    - https://github.com/joefitzgerald/slack-dump
    - バイナリはこれ
        - https://github.com/PyYoshi/slack-dump/releases

どちらにせよトークンが必要なので[ここ](https://api.slack.com/custom-integrations/legacy-tokens)から取得

```sh
$ wget https://github.com/PyYoshi/slack-dump/releases/download/v1.1.3/slack-dump-v1.1.3-linux-386.tar.gz
$ tar -zxvf slack-dump-v1.1.3-linux-386.tar.gz
$ linux-386/slack-dump -t=<token> <channel>
```
DMとかもいっしょに取ってきて邪魔なので　別のところに移す

```python:python
import zipfile, os

os.mkdir('dumps')
with zipfile.ZipFile('./zipfile_name') as z:
    for n in z.namelist():
        if 'channel_name' in n:
            z.extract(n, './dumps')
```

ファイルを開いて中身を取得する、日付ごとになっているので全部を１つにする

```python:python
import json, glob

posts = []
files = glob.glob('./dumps/channel/<channel_name>/*.json'.format(dirname))
for file in files:
    with open(file) as f:
        posts += json.loads(f.read())

```
Messageを取り出して記事タイトルとユーザ名を紐づける（この辺はIFTTTでの設定による）


```python:python
user_post_dic = {
    'Y': [],
    'S': [],
    'M': [],
    'R': [],
}

for p in posts:
    if "username" not in p or p["username"] != "IFTTT":
        continue
    for a in p["attachments"]:
        # 雑回避
        try:
            user_post_dic[a["text"]].append(a["title"])
        except:
            pass
        
users = user_post_dic.keys()
print([[u, len(user_post_dic[u])] for u in users])
```

```:出力
[['Y', 864], ['S', 896], ['M', 240], ['R', 79]]
```

## 本編
### 前処理

#### クレンジングとわかち書き
投稿されるメッセージはこんな感じになっていてサイトのタイトルやURLは不要なので削除する

```
ブラウザのテキストエリアでNeovimを使う  <http://Developers.IO|Developers.IO>

フロントエンドエンジニアのためのセキュリティ対策 / #frontkansai 2019 - Speaker Deck

matplotlibで日本語

モダンJavaScript再入門 / Re-introduction to Modern JavaScript - Speaker Deck
```


`re`を使う時のお作法がよくわからなかったのでゴリ押し
加えて、MeCabでの分かち書きもおこなう、環境にはsudachipyなども入っているが、手に馴染んでいるものをつかう、速いし

```python:python
import MeCab, re
m = MeCab.Tagger("-Owakati")

_tag = re.compile(r'<.*?>')
_url = re.compile(r'(http|https)://([-\w]+\.)+[-\w]+(/[-\w./?%&=]*)?')
_title = re.compile(r'( - ).*$')
_par = re.compile(r'\(.*?\)')
_sla = re.compile(r'/.*$')
_qt = re.compile(r'"')
_sep = re.compile(r'\|.*$')
_twi = re.compile(r'(.*)on Twitter: ')
_lab = re.compile(r'(.*) ⇒ \(')
_last_par = re.compile(r'\)$')

def clean_text(text):
    text = text.translate(str.maketrans({chr(0xFF01 + i): chr(0x21 + i) for i in range(94)}))
    text = re.sub(_lab, '', text)
    text = re.sub(_tag, '', text)
    text = re.sub(_url, '', text)
    text = re.sub(_title, '', text)
    text = re.sub(_sla,  '', text)
    text = re.sub(_qt,  '', text)
    text = re.sub(_sep, '', text)
    text = re.sub(_twi, '', text)
    text = re.sub(_par, '', text)
    text = re.sub(_last_par, '', text)
    return text

p_all = []
m_all = []
for u in users:
    user_post_dic[u] = list(map(clean_text, p_dic[u]))
    m_all += [m.parse(p).split('\n')[0] for p in p_dic[u]]
    p_all += [u + '**' + p for p in user_post_dic[u]]
```

`p_all`で各要素の頭にユーザ名を付けたのは前処理によってテキストが消滅してしまい、listのindexがずれてしますため、苦し紛れで紐づけている
(ちなみにURLを記事タイトルとしてブクマしている場合など)

一応はきれいになった

```
ブラウザのテキストエリアでNeovimを使う 
 
フロントエンドエンジニアのためのセキュリティ対策
 
matplotlibで日本語

モダンJavaScript再入門 
```

### Doc2Vec
`m_all`が分散表現を獲得する時の材料となる文章本体
`p_all`は呼び方にすぎない

パラメータは熱心には検討していない

```python:python
from gensim import models

# 参考記事： http://qiita.com/okappy/items/32a7ba7eddf8203c9fa1
class LabeledListSentence(object):
    def __init__(self, words_list, labels):
        self.words_list = words_list
        self.labels = labels

    def __iter__(self):
        for i, words in enumerate(self.words_list):
            yield models.doc2vec.TaggedDocument(words, ['%s' % self.labels[i]])

sentences = LabeledListSentence(m_all, p_all)
model = models.Doc2Vec(
    alpha=0.025,
    min_count=5,
    vector_size=100,
    epoch=20,
    workers=4
)
# 持っている文から語彙を構築
model.build_vocab(sentences)
model.train(
    sentences,
    total_examples=len(m_all),
    epochs=model.epochs
)

# 順番が変わってしまうことがあるので再呼び出し
tags = model.docvecs.offset2doctag
```

### PCAと描画
PCAのライブラリを利用するのは初めてで、あんなに手順を踏んで学んだのに2行で使えてすごい

```python:python
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
import japanize_matplotlib

vecs = [model.docvecs[p] for p in tags]
draw_scatter_plot(vecs, ls)

# 紐付けを解く
tag_users = [p.split('**')[0] for p in tags]
tag_docs = [p.split('**')[1] for p in tags]

# ４色で同じ程度の色感を見つけるのは難しかった
cols = ["#0072c2", "#Fc6993", "#ffaa1c", "#8bd276" ]

# 無理に1行で書いた
clusters = [cols[0] if u == tag_users[0] else cols[1] if u == tag_users[1] else cols[2] if u == tag_users[2] else cols[3] for u in lab_users]

# 平面なので2次元
pca = PCA(n_components=2)
coords = pca.fit_transform(vecs)

fig, ax = plt.subplots(figsize=(16, 12))
x = [v[0] for v in coords]
y = [v[1] for v in coords]

# 凡例をつけるためにこのループをする
for i, u in enumerate(set(tag_users)):
    x_of_u = [v for i, v in enumerate(x) if tag_users[i] == u]
    y_of_u = [v for i, v in enumerate(y) if tag_users[i] == u]
    ax.scatter(
        x_of_u,
        y_of_u,
        label=u,
        c=cols[i],
        s=30,
        alpha=1,
        linewidth=0.2,
        edgecolors='#777777'
    )

plt.legend(
    loc='upper right',
    fontsize=20,
    prop={'size':18,}
)
plt.show()
```
## できたもの（再掲）

### やる前の予想
- Rくん
    - ガジェットやセキュリティ系が多い
    - ブクマ数79
- Yさん
    - 4人の中で一番範囲が広い
    - 実はこのユーザだけみんなにシェアする目的で取捨選択したものを投稿している
    - ブクマ数864
- Mくん
    - Webと機械学習など
    - ブクマ数240
- S（自分）
    - Webと機械学習とガジェットに加えて「今年はサンマが不漁」みたいなのまで投げてしまう
    - ブクマ数896

### 結果
範囲と重複具合は直感的に予想に近い

<img width="800" alt="スクリーンショット 2019-12-11 19.16.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176375/5f5f8507-2200-eb89-9792-722268284bfc.png">

## おわり
そもそもブックマークに重複が多いのできれいには別れなくて残念
もう少しデータが増えればユーザの推論とか回してレコメンドとかしたいですね

遅れてすみませんでした(12/11/21:00)