【自然言語処理】Slackコミュニティで今週盛り上がった話題を可視化してみた

## 本記事について

この記事では、Slackコミュニティである一定期間（ここでは1週間）内にどんな話題で盛り上がったのかをWordcloudを用いて可視化する手法について紹介します。

ソースコードは[こちら](https://github.com/sota0121/slack-msg-analysis)にあります :octocat: 

あわせて読みたい：[【自然言語処理】Slackコミュニティにおける各メンバーの発言を可視化してみた](https://qiita.com/masso/items/dca470fbc172daeb7dfd)


## 目次

1. 使い方と出力例
2. Slackからメッセージを取得
3. 前処理：メッセージマートテーブル作成
4. 前処理：クリーニング
5. 前処理：形態素解析(Janome)
6. 前処理：正規化
7. 前処理：ストップワード除去
8. 前処理：重要語句抽出(tf-idf)
9. Wordcloudで可視化処理
10. おまけ

<font color=red>※前処理については、今後別記事にまとめたいと思います</font>

## 1. 使い方と出力例

### 1.1. 使い方

詳細は、[README](https://github.com/sota0121/slack-msg-analysis/blob/master/README.md)のGetting started を参照ください。
流れは、こんな感じです。

1. `docker-compose up -d` で仮想環境構築
2. `docker exec -it ds-py3 bash` でシェルに入る
3. `run_wordcloud_by_term.sh` を実行

### 1.2. 出力例

実際に出力した例です。それぞれ異なる期間の発言をWordcloudにしています。

![anim__.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181972/55f42e33-0889-6490-4df8-f36f287e6143.gif)


## 2. Slackからメッセージを取得

### 2.1. SlackAPIを使う

[Slack API公式](https://api.slack.com/)よりSlackAPIのトークンを取得しましょう

Slack APIの始め方は、ここには記載しません。

以降の処理を実施するには、以下のトークンを取得してください

- Channel API
- Users API

### 2.2. API経由でSlackの情報を取得するクラスを作成

ここでは、API経由でSlackの情報を取得する **SlackApp** クラスを作ります。
取得した情報は、加工せずにJSON形式で保存します。

```python:slack_app.py
# slackapiを使って所望の情報を取得するクラス(加工はしない)
import requests
import json
from tqdm import tqdm
import pandas as pd


class SlackApp:
    ch_list_url = 'https://slack.com/api/channels.list'
    ch_history_url = 'https://slack.com/api/channels.history'
    usr_list_url = 'https://slack.com/api/users.list'

    def __init__(self, ch_api_key, usr_api_key):
        # NEW members
        self.channels_info = []
        self.users_info = []
        self.messages_info = []
        # OLD members
        self.channelInfo = {}  # k: ch_name, v: ch_id
        self.messages_in_chs = {}
        self.userInfo = {}
        self.ch_api_token = str(ch_api_key)
        self.usr_api_token = str(usr_api_key)

    def load_save_channel_info(self, outdir: str):
        # slackAPI経由でchannel情報を取得してファイルに保存
        payload = {'token': self.ch_api_token}
        response = requests.get(SlackApp.ch_list_url, params=payload)
        if response.status_code == 200:
            json_data = response.json()
            if 'channels' in json_data.keys():
                self.channels_info = json_data['channels']
            with open(outdir + '/' + 'channel_info.json', 'w', encoding='utf-8') as f:
                json.dump(self.channels_info, f, indent=4, ensure_ascii=False)

    def load_save_user_info(self, outdir: str):
        # slackAPI経由でuser情報を取得してファイルに保存
        payload = {'token': self.usr_api_token}
        response = requests.get(SlackApp.usr_list_url, params=payload)
        if response.status_code == 200:
            json_data = response.json()
            if 'members' in json_data.keys():
                self.users_info = json_data['members']
            with open(outdir + '/' + 'user_info.json', 'w', encoding='utf-8') as f:
                json.dump(self.users_info, f, indent=4, ensure_ascii=False)

    def load_save_messages_info(self, outdir: str):
        # channel id list 作成
        channel_id_list = []
        for ch in self.channels_info:
            channel_id_list.append(ch['id'])
        # slackAPI経由でuser情報を取得してファイルに保存
        for ch_id in tqdm(channel_id_list, desc='[loading...]'):
            payload = {'token': self.ch_api_token, 'channel': ch_id}
            response = requests.get(SlackApp.ch_history_url, params=payload)
            if response.status_code == 200:
                json_data = response.json()
                msg_in_ch = {}
                msg_in_ch['channel_id'] = ch_id
                if 'messages' in json_data.keys():
                    msg_in_ch['messages'] = json_data['messages']
                else:
                    msg_in_ch['messages'] = ''
                self.messages_info.append(msg_in_ch)
        with open(outdir + '/' + 'messages_info.json', 'w', encoding='utf-8') as f:
            json.dump(self.messages_info, f, indent=4, ensure_ascii=False)        

```

### 2.3. Slackの情報を取得する

先ほどの **SlackApp** クラスを用いて情報を取得します。

取得する情報は以下の三つです

1. チャンネル一覧
2. メッセージ一覧（チャンネルごとに取得）
3. ユーザー一覧

```python:slack_msg_extraction.py
# slackapiを使って所望の情報を取得するクラス
import sys
import json
sys.path.append('../../src/d000_utils') # SlackAppスクリプトの格納パスを追加
import slack_app as sa


def main():
    # -------------------------------------
    # load api token
    # -------------------------------------
    credentials_root = '../../conf/local/'
    credential_fpath = credentials_root + 'credentials.json'
    print('load credential.json ...')
    with open(credential_fpath, 'r') as f:
        credentials = json.load(f)
    # -------------------------------------
    # start slack app
    # -------------------------------------    
    print('start slack app ...')
    app = sa.SlackApp(
        credentials['channel_api_key'],
        credentials['user_api_key']
        )
    outdir = '../../data/010_raw'
    # -------------------------------------
    # get channels info
    # -------------------------------------
    app.load_save_channel_info(outdir)
    # -------------------------------------
    # get user info
    # -------------------------------------
    app.load_save_user_info(outdir)
    # -------------------------------------
    # get msg info
    # -------------------------------------
    app.load_save_messages_info(outdir)


if __name__ == "__main__":
    main()
```

## 3. 前処理：メッセージマートテーブル作成

### 3.1. メッセージマートテーブルの設計

SlackAPIで取得した情報は、SlackAPIの仕様に従ったJSON形式で保存しました。
これを分析しやすい形に整形したテーブルデータにします。
テーブルを設計する場合は、Tidy Data になるように意識しましょう。

今回は、以下のような設計にしました。
最低限必要な情報＋αといったイメージで、下記のテーブルにしました。

#### message mart table

|No|Column Name|Type|Content|
|:-:|:-:|:-:|:--|
|0|index|int|AUTO INCREMENT|
|1|ch_id|str|チャンネルID|
|2|msg|str|メッセージ文字列|
|3|uid|str|発言者のユーザーID|
|4|timestamp|datetime|発言した時刻|

#### channels table (おまけ)

|No|Column Name|Type|Content|
|:-:|:-:|:-:|:--|
|0|index|int|AUTO INCREMENT|
|1|ch_id|str|チャンネルID|
|2|ch_name|str|チャンネル名（SlackのUIに表示される名）|
|3|ch_namenorm|str|正規化済みチャンネル名|
|4|ch_membernum|int|当該チャンネルの参加者数|

#### users table (おまけ)

|No|Column Name|Type|Content|
|:-:|:-:|:-:|:--|
|0|index|int|AUTO INCREMENT|
|1|uid|str|ユーザーID|
|2|uname|str|ユーザー名|


### 3.2. RAWデータ→メッセージマートテーブルに整形

実際のコードは以下の通りです。

- 【注】
    - `../../data/010_raw` : Slackから取得した情報JSON形式の保存場所
    - `user_info.json` : ユーザー情報（JSON）ファイル名
    - `messages_info.json` : 全チャンネルのメッセージ情報（JSON）ファイル名
    - `channel_info.json` : チャンネル情報（JSON）ファイル名

```python:make_msg_mart_table.py
import json
import pandas as pd


def make_user_table(usr_dict: dict) -> pd.DataFrame:
    uid_list = []
    uname_list = []
    for usr_ditem in usr_dict:
        if usr_ditem['deleted'] == True:
            continue
        uid_list.append(usr_ditem['id'])
        uname_list.append(usr_ditem['profile']['real_name_normalized'])
    user_table = pd.DataFrame({'uid': uid_list, 'uname': uname_list})
    return user_table


def make_msg_table(msg_dict: dict) -> pd.DataFrame:
    ch_id_list = []
    msg_list = []
    uid_list = []
    ts_list = []
    for msg_ditem in msg_dict:
        if 'channel_id' in msg_ditem.keys():
            ch_id = msg_ditem['channel_id']
        else:
            continue
        if 'messages' in msg_ditem.keys():
            msgs_in_ch = msg_ditem['messages']
        else:
            continue
        # get message in channel
        for i, msg in enumerate(msgs_in_ch):
            # if msg by bot, continue
            if 'user' not in msg:
                continue
            ch_id_list.append(ch_id)
            msg_list.append(msg['text'])
            uid_list.append(msg['user'])  # botの場合はこのキーがない
            ts_list.append(msg['ts'])
    df_msgs = pd.DataFrame({
        'ch_id': ch_id_list,
        'msg': msg_list,
        'uid': uid_list,
        'timestamp': ts_list
    })
    return df_msgs


def make_ch_table(ch_dict: dict) -> pd.DataFrame:
    chid_list = []
    chname_list = []
    chnormname_list = []
    chmembernum_list = []
    for ch_ditem in ch_dict:
        chid_list.append(ch_ditem['id'])
        chname_list.append(ch_ditem['name'])
        chnormname_list.append(ch_ditem['name_normalized'])
        chmembernum_list.append(ch_ditem['num_members'])
    ch_table = pd.DataFrame({
        'ch_id': chid_list,
        'ch_name': chname_list,
        'ch_namenorm': chnormname_list,
        'ch_membernum': chmembernum_list
    })
    return ch_table


def main():
    # 1. load user/message/channels
    input_root = '../../data/010_raw'
    user_info_fpath = input_root + '/' + 'user_info.json'
    with open(user_info_fpath, 'r', encoding='utf-8') as f:
        user_info_rawdict = json.load(f)
        print('load ... ', user_info_fpath)
    msg_info_fpath = input_root + '/' + 'messages_info.json'
    with open(msg_info_fpath, 'r', encoding='utf-8') as f:
        msgs_info_rawdict = json.load(f)
        print('load ... ', msg_info_fpath)
    ch_info_fpath = input_root + '/' + 'channel_info.json'
    with open(ch_info_fpath, 'r', encoding='utf-8') as f:
        ch_info_rawdict = json.load(f)
        print('load ... ', ch_info_fpath)
    # 2. make and save tables
    # user
    output_root = '../../data/020_intermediate'
    df_user_info = make_user_table(user_info_rawdict)
    user_tbl_fpath = output_root + '/' + 'users.csv'
    df_user_info.to_csv(user_tbl_fpath, index=False)
    print('save ... ', user_tbl_fpath)
    # msg
    df_msg_info = make_msg_table(msgs_info_rawdict)
    msg_tbl_fpath = output_root + '/' + 'messages.csv'
    df_msg_info.to_csv(msg_tbl_fpath, index=False)
    print('save ... ', msg_tbl_fpath)
    # channel
    df_ch_info = make_ch_table(ch_info_rawdict)
    ch_tbl_fpath = output_root + '/' + 'channels.csv'
    df_ch_info.to_csv(ch_tbl_fpath, index=False)
    print('save ... ', ch_tbl_fpath)


if __name__ == "__main__":
    main()
```

## 4. 前処理：クリーニング

### 4.1. クリーニング処理の内容

一般に、ノイズを除去する行為を指します
対象とするデータや目的によって、様々な処理を施す必要があります。
ここでは、下記の処理を実行しました。

1. URL文字列の削除
2. メンション文字列の削除
3. Unicode絵文字の削除
4. htmlの特殊文字削除（&gtとか）
5. コードブロックの削除
6. インラインコードブロックの削除
7. 「〇〇がチャンネルに参加しました」というメッセージ除去
8. その他、本コミュニティ特有のノイズ除去

### 4.2. クリーニング処理の実装

```python:cleaning.py
import re
import pandas as pd
import argparse
from pathlib import Path


def clean_msg(msg: str) -> str:
    # sub 'Return and Space'
    result = re.sub(r'\s', '', msg)
    # sub 'url link'
    result = re.sub(r'(<)http.+(>)', '', result)
    # sub 'mention'
    result = re.sub(r'(<)@.+\w(>)', '', result)
    # sub 'reaction'
    result = re.sub(r'(:).+\w(:)', '', result)
    # sub 'html key words'
    result = re.sub(r'(&).+?\w(;)', '', result)
    # sub 'multi lines code block'
    result = re.sub(r'(```).+(```)', '', result)
    # sub 'inline code block'
    result = re.sub(r'(`).+(`)', '', result)
    return result


def clean_msg_ser(msg_ser: pd.Series) -> pd.Series:
    cleaned_msg_list = []
    for i, msg in enumerate(msg_ser):
        cleaned_msg = clean_msg(str(msg))
        if 'チャンネルに参加しました' in cleaned_msg:
            continue
        cleaned_msg_list.append(cleaned_msg)
    cleaned_msg_ser = pd.Series(cleaned_msg_list)
    return cleaned_msg_ser


def get_ch_id_from_table(ch_name_parts: list, input_fpath: str) -> list:
    df_ch = pd.read_csv(input_fpath)
    ch_id = []
    for ch_name_part in ch_name_parts:
        for i, row in df_ch.iterrows():
            if ch_name_part in row.ch_name:
                ch_id.append(row.ch_id)
                break
    return ch_id


def main(input_fname: str):
    input_root = '../../data/020_intermediate'
    output_root = input_root
    # 1. load messages.csv (including noise)
    msgs_fpath = input_root + '/' + input_fname
    df_msgs = pd.read_csv(msgs_fpath)
    print('load :{0}'.format(msgs_fpath))
    # 2. Drop Not Target Records
    print('drop records (drop non-target channel\'s messages)')
    non_target_ch_name = ['general', '運営からのアナウンス']
    non_target_ch_ids = get_ch_id_from_table(non_target_ch_name, input_root + '/' + 'channels.csv')
    print('=== non target channels bellow ====')
    print(non_target_ch_ids)
    for non_target_ch_id in non_target_ch_ids:
        df_msgs = df_msgs.query('ch_id != @non_target_ch_id')
    # 3. clean message string list
    ser_msg = df_msgs.msg
    df_msgs.msg = clean_msg_ser(ser_msg)
    # 4. save it
    pin = Path(msgs_fpath)
    msgs_cleaned_fpath = output_root + '/' + pin.stem + '_cleaned.csv'
    df_msgs.to_csv(msgs_cleaned_fpath, index=False)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("input_fname", help="set input file name", type=str)
    args = parser.parse_args()
    input_fname = args.input_fname
    main(input_fname)
```

本当は、「コードブロック内がソースコードの場合のみ除去する」としたかったのですが、できませんでした。 :sweat: 

## 5. 前処理：形態素解析(Janome)

### 5.1. 形態素解析とは

一般に、文章中の「形態素」を見つけ出す処理です。
形態素解析の詳細は、他の記事に譲ります。

ここでは、「分かち書き」を実行するのが真の目的です。

### 5.2. 分かち書きとは

ざっくりと言えば、文章を「単語 単語 単語」という情報に変換する処理です。
例えば、

**例文：私は、野球が好きです。**
**例文の分かち書き：「私 は 野球 が 好き です」**

といった形になります。

実践的な話で言えば、今回のように文章のコンテクストを表すワードを取り扱いたいという場合は、 <font color="red">「名詞」を抜き出す</font> のが望ましいと思います。したがって、

**例文の分かち書き（名詞のみ）：「私 野球」**

とすると尚良いでしょう。

※「私」も消した方がいいのでは？と思ったそこのあなた。「ストップワード除去」の章をみてください

### 5.3. 形態素解析と分かち書きの実装

形態素解析を実装する場合は、

- 形態素解析ライブラリに何を使うか
- 辞書データとして何を使うか

という点を決めなくてはなりません。

今回は、以下のようにしました。

- 形態素解析ライブラリ : [Janome](https://mocobeta.github.io/janome/)
- 辞書データ : Janomeデフォルト（新語対応のNEologdだと尚良い）

また、抽出する品詞は、「[形態素解析ツールの品詞体系](http://www.unixuser.org/~euske/doc/postag/)」とにらめっこしつつ目的を達成する為に必要なものは何か？という観点で考えました。

公式マスコットのJanomeちゃんかわいいですね。（Janomeちゃんって名前なのかは知らないですけど）

```python:morphological_analysis.py
from janome.tokenizer import Tokenizer
from tqdm import tqdm
import pandas as pd
import argparse
from pathlib import Path
import sys


exc_part_of_speech = {
    "名詞": ["非自立", "代名詞", "数"]
}
inc_part_of_speech = {
    "名詞": ["サ変接続", "一般", "固有名詞"],
}


class MorphologicalAnalysis:

    def __init__(self):
        self.janome_tokenizer = Tokenizer()

    def tokenize_janome(self, line: str) -> list:
        # list of janome.tokenizer.Token
        tokens = self.janome_tokenizer.tokenize(line)
        return tokens

    def exists_pos_in_dict(self, pos0: str, pos1: str, pos_dict: dict) -> bool:
        # Retrurn where pos0, pos1 are in pos_dict or not.
        # ** pos = part of speech
        for type0 in pos_dict.keys():
            if pos0 == type0:
                for type1 in pos_dict[type0]:
                    if pos1 == type1:
                        return True
        return False

    def get_wakati_str(self, line: str, exclude_pos: dict,
                       include_pos: dict) -> str:
        '''
        exclude/include_pos is like this
        {"名詞": ["非自立", "代名詞", "数"], "形容詞": ["xxx", "yyy"]}
        '''
        tokens = self.janome_tokenizer.tokenize(line, stream=True)  # 省メモリの為generator
        extracted_words = []
        for token in tokens:
            part_of_speech0 = token.part_of_speech.split(',')[0]
            part_of_speech1 = token.part_of_speech.split(',')[1]
            # check for excluding words
            exists = self.exists_pos_in_dict(part_of_speech0, part_of_speech1,
                                             exclude_pos)
            if exists:
                continue
            # check for including words
            exists = self.exists_pos_in_dict(part_of_speech0, part_of_speech1,
                                             include_pos)
            if not exists:
                continue
            # append(表記揺れを吸収する為 表層形を取得)
            extracted_words.append(token.surface)
        # wakati string with extracted words
        wakati_str = ' '.join(extracted_words)
        return wakati_str


def make_wakati_for_lines(msg_ser: pd.Series) -> pd.Series:
    manalyzer = MorphologicalAnalysis()
    wakati_msg_list = []
    for msg in tqdm(msg_ser, desc='[mk wakati msgs]'):
        wakati_msg = manalyzer.get_wakati_str(str(msg), exc_part_of_speech,
                                              inc_part_of_speech)
        wakati_msg_list.append(wakati_msg)
    wakati_msg_ser = pd.Series(wakati_msg_list)
    return wakati_msg_ser


def main(input_fname: str):
    input_root = '../../data/020_intermediate'
    output_root = '../../data/030_processed'
    # 1. load messages_cleaned.csv
    msgs_cleaned_fpath = input_root + '/' + input_fname
    df_msgs = pd.read_csv(msgs_cleaned_fpath)
    # 2. make wakati string by record
    ser_msg = df_msgs.msg
    df_msgs['wakati_msg'] = make_wakati_for_lines(ser_msg)
    # 3. save it
    pin = Path(msgs_cleaned_fpath)
    msgs_wakati_fpath = output_root + '/' + pin.stem + '_wakati.csv'
    df_msgs.to_csv(msgs_wakati_fpath, index=False)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("input_fname", help="set input file name", type=str)
    args = parser.parse_args()
    input_fname = args.input_fname
    # input file must been cleaned
    if 'cleaned' not in input_fname:
        print('input file name is invalid.: {0}'.format(input_fname))
        print('input file name must include \'cleaned\'')
        sys.exit(1)
    main(input_fname)
```


## 6. 前処理：正規化

### 6.1. 正規化とは

自然言語処理の前処理における正規化とは、以下のような処理を指します。
「名寄せ」などと呼ばれることもあります。

1. 文字種の統一
    1. カナを全角に統一
    2. アルファベットは小文字に統一…など
2. 数字の置き換え
    1. 数字を全て0に置き換えるなど
    2. ※自然言語処理において数字が重要な場面は少ない
3. 辞書を用いた単語の統一
    1. 「Sony」と「ソニー」を同一と判断し、「Sony」という表記に統一など

正規化の世界は奥深いですので、ここではこの辺までにしておきます。

### 6.2. 正規化の実装

今回は、簡単の為以下の処理のみ実装しました。
めちゃくちゃ簡単です。

1. アルファベットを小文字に統一
2. 数字は全て0に置き換え

```python:normalization.py
import re
import pandas as pd
from tqdm import tqdm
import argparse
from pathlib import Path
import sys


def normarize_text(text: str):
    normalized_text = normalize_number(text)
    normalized_text = lower_text(normalized_text)
    return normalized_text


def normalize_number(text: str) -> str:
    """
    pattern = r'\d+'
    replacer = re.compile(pattern)
    result = replacer.sub('0', text)
    """
    # 連続した数字を0で置換
    replaced_text = re.sub(r'\d+', '0', text)
    return replaced_text


def lower_text(text: str) -> str:
    return text.lower()


def normalize_msgs(wktmsg_ser: pd.Series) -> pd.Series:
    normalized_msg_list = []
    for wktstr in tqdm(wktmsg_ser, desc='normalize words...'):
        normalized = normarize_text(str(wktstr))
        normalized_msg_list.append(normalized)
    normalized_msg_ser = pd.Series(normalized_msg_list)
    return normalized_msg_ser


def main(input_fname: str):
    input_root = '../../data/030_processed'
    output_root = input_root
    # 1. load wakati messages
    msgs_fpath = input_root + '/' + input_fname
    df_msgs = pd.read_csv(msgs_fpath)
    # 2. normalize wakati_msg (update)
    ser_msg = df_msgs.wakati_msg
    df_msgs.wakati_msg = normalize_msgs(ser_msg)
    # 3. save it
    pin = Path(msgs_fpath)
    msgs_ofpath = output_root + '/' + pin.stem + '_norm.csv'
    df_msgs.to_csv(msgs_ofpath, index=False)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("input_fname", help="set input file name", type=str)
    args = parser.parse_args()
    input_fname = args.input_fname
    # input file must been cleaned
    if 'wakati' not in input_fname:
        print('input file name is invalid.: {0}'.format(input_fname))
        print('input file name must include \'wakati\'')
        sys.exit(1)
    main(input_fname)
```


## 7. 前処理：ストップワード除去

### 7.1. ストップワード除去とは？

「ストップワード除去」とは、ストップワードを除去する処理です。
（そのまますぎる……）

では、「**ストップワード**」とは何でしょうか？

goo国語辞書によると

> 全文検索などで、あまりに一般的であるため、単独では検索から除外される単語。日本語の「は」「の」「です」、英語のa, the, ofなどを指す。ストップワーズ。

自然言語処理のタスクは、往々にして「文章のコンテクストを理解する」という目的を持っています。ストップワードは、この目的に不必要なものであるため、除去する必要があります。

### 7.2. ストップワードはどうやって決めるのか？

主要なものとして二つあります。今回は1を採用します。

1. 辞書を用いる方法（**←今回はこちらを採用**）
2. 出現頻度を用いる方法

辞書データは、[こちら](http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt)を使います。

1の方法を選択した理由はいくつかあります。

- 辞書を用いる場合 [既存の辞書](http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt) があるので導入が楽
- 出現頻度を用いる場合、「**一般的な会話** の中で出現頻度が高く、それゆえ除去すべきワード」のみを抜き出す必要があります。手元にあるSlackデータだけで出現頻度を出すと **「盛り上がった話題に関するワード」が不本意に除去される恐れがある** ので、別途データを用意して集計する必要があると考えました。……それはちょっと大変

### 7.3. ストップワード除去の実装

前節で紹介した辞書データに登録されている単語を除去します。
それに加えて、以下の文字も除去対象にしました。
いろいろチューニングしているうちに邪魔だなーと思った単語です。

- 「-」
- 「ー」
- 「w」
- 「m」
- 「笑」

```python:stopword_removal.py
import pandas as pd
import urllib.request
from pathlib import Path
from tqdm import tqdm
import argparse
from pathlib import Path
import sys


def maybe_download(path: str):
    stopword_def_page_url = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
    p = Path(path)
    if p.exists():
        print('File already exists.')
    else:
        print('downloading stop words definition ...')
        # Download the file from `url` and save it locally under `file_name`:
        urllib.request.urlretrieve(stopword_def_page_url, path)
    # stop word 追加分
    sw_added_list = [
        '-',
        'ー',
        'w',
        'W',
        'm',
        '笑'
    ]
    sw_added_str = '\n'.join(sw_added_list)
    with open(path, 'a') as f:
        print(sw_added_str, file=f)


def load_sw_definition(path: str) -> list:
    with open(path, 'r', encoding='utf-8') as f:
        lines = f.read()
        line_list = lines.split('\n')
        line_list = [x for x in line_list if x != '']
    return line_list


def remove_sw_from_text(wktstr: str, stopwords: list) -> str:
    words_list = wktstr.split(' ')
    words_list_swrm = [x for x in words_list if x not in stopwords]
    swremoved_str = ' '.join(words_list_swrm)
    return swremoved_str


def remove_sw_from_msgs(wktmsg_ser: pd.Series, stopwords: list) -> pd.Series:
    swremved_msg_list = []
    for wktstr in tqdm(wktmsg_ser, desc='remove stopwords...'):
        removed_str = remove_sw_from_text(str(wktstr), stopwords)
        swremved_msg_list.append(removed_str)
    swremved_msg_ser = pd.Series(swremved_msg_list)
    return swremved_msg_ser


def main(input_fname: str):
    input_root = '../../data/030_processed'
    output_root = input_root
    # 1. load stop words
    sw_def_fpath = 'stopwords.txt'
    maybe_download(sw_def_fpath)
    stopwords = load_sw_definition(sw_def_fpath)
    # 2. load messages
    msgs_fpath = input_root + '/' + input_fname
    df_msgs = pd.read_csv(msgs_fpath)
    # 3. remove stop words
    ser_msg = df_msgs.wakati_msg
    df_msgs.wakati_msg = remove_sw_from_msgs(ser_msg, stopwords)
    # 4. save it
    pin = Path(msgs_fpath)
    msgs_ofpath = output_root + '/' + pin.stem + '_rmsw.csv'
    df_msgs.to_csv(msgs_ofpath, index=False)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("input_fname", help="set input file name", type=str)
    args = parser.parse_args()
    input_fname = args.input_fname
    # input file must been cleaned
    if 'norm' not in input_fname:
        print('input file name is invalid.: {0}'.format(input_fname))
        print('input file name must include \'norm\'')
        sys.exit(1)
    main(input_fname)
```

## 8. 前処理：重要語句抽出(tf-idf)

### 8.1. tf-idfとは？

素晴らしい解説記事がたくさんあります。
私はこちらの記事を参考にしました。
[TF-IDF | Qiita](https://qiita.com/AwaJ/items/5937665d5a4152cc24cf)

ここでは、ざっくり説明します。

- tf (Term Frequency)
    - ある文書における単語の出現頻度
    - 大きければ、「その単語はその文書によく出てくる」
- idf (Inverse Document Frequency)
    - ある単語が出現する文書の(全文書に対する)割合 **の逆数**
    - 大きければ、「ほかの文書であまり出てこない」

tf-idfとは、tfとidfを掛け合わせた数です。
つまり、
**tf-idfが大きい**
　＝　**ある文書で良く出てくる** ＆ **他の文書ではあまり出てこない**
　＝　その文書の **コンテクストを理解する上で重要**


### 8.2. tf-idfによる単語のスコアリング処理実装

#### 8.2.1. 何を文書・全文書とするか？

今回の目的は、ある期間（1週間）の発言の特徴を見ることです。
ゆえに、**これまでの全投稿に対して、ある期間（1週間）の発言がどのような特徴を持っているか** が分かるようにすべきと考えました。

したがって、

- **全文書** ：これまでの全投稿
- **1文書**　：ある期間（1週間）分の投稿の塊

としてtf-idfを計算しました。

#### 8.2.2. 実装

簡単に処理の流れを書きます

1. メッセージマートテーブル（前処理済み）読み込み
2. メッセージの期間によるグルーピング
    1. 処理実行時を起点に7日単位で過去のデータを区切り、グルーピングします
2. 1グループのメッセージを1文書として、tf-idfを計算
3. tf-idfのスコアが閾値以上の単語を抽出する（辞書として出力）

```python:important_word_extraction.py
import pandas as pd
import json
from datetime import datetime, date, timedelta, timezone
from pathlib import Path
from sklearn.feature_extraction.text import TfidfVectorizer
JST = timezone(timedelta(hours=+9), 'JST')

# メッセージを指定した期間単位でグルーピング
def group_msgs_by_term(df_msgs: pd.DataFrame, term: str) -> dict:
    # set term
    term_days = 8
    if term == 'lm':
        term_days = 31
    print('group messages every {0} days'.format(term_days))
    # analyze timestamp
    now_in_sec = (datetime.now(JST) - datetime.fromtimestamp(0, JST)).total_seconds()
    interval_days = timedelta(days=term_days)
    interval_seconds = interval_days.total_seconds()
    oldest_timestamp = df_msgs.min().timestamp
    oldest_ts_in_sec = (datetime.fromtimestamp(oldest_timestamp, JST) - datetime.fromtimestamp(0, JST)).total_seconds()
    loop_num = (abs(now_in_sec - oldest_ts_in_sec) / interval_seconds) + 1
    # extract by term
    dict_msgs_by_term = {}
    df_tmp = df_msgs
    now_tmp = now_in_sec
    for i in range(int(loop_num)):
        # make current term string
        cur_term_s = 'term_ago_{0}'.format(str(i).zfill(3))
        print(cur_term_s)
        # current messages
        df_msgs_cur = df_tmp.query('@now_tmp - timestamp < @interval_seconds')
        df_msgs_other = df_tmp.query('@now_tmp - timestamp >= @interval_seconds')
        # messages does not exist. break.
        if df_msgs_cur.shape[0] == 0:
            break
        # add current messages to dict
        dict_msgs_by_term[cur_term_s] = ' '.join(df_msgs_cur.wakati_msg.dropna().values.tolist())
        # update temp value for next loop
        now_tmp = now_tmp - interval_seconds
        df_tmp = df_msgs_other
    return dict_msgs_by_term

# tf-idfスコアを参照しながら重要単語を抽出し辞書として返す
def extract_important_word_by_key(feature_names: list, bow_df: pd.DataFrame, uids: list) -> dict:
    # > 行ごとにみていき、重要単語を抽出する(tfidf上位X個の単語)
    dict_important_words_by_user = {}
    for uid, (i, scores) in zip(uids, bow_df.iterrows()):
        # 当該ユーザーの単語・tfidfスコアのテーブルを作る
        words_score_tbl = pd.DataFrame()
        words_score_tbl['scores'] = scores
        words_score_tbl['words'] = feature_names
        # tfidfスコアで降順ソートする
        words_score_tbl = words_score_tbl.sort_values('scores', ascending=False)
        words_score_tbl = words_score_tbl.reset_index()
        # extract : tf-idf score > 0.001
        important_words = words_score_tbl.query('scores > 0.001')
        # 当該ユーザの辞書作成 'uid0': {'w0': 0.9, 'w1': 0.87}
        d = {}
        for i, row in important_words.iterrows():
            d[row.words] = row.scores
        # 当該ユーザの辞書にワードが少なくとも一つ以上ある場合のみテーブルに追加
        if len(d.keys()) > 0:
            dict_important_words_by_user[uid] = d
    return dict_important_words_by_user

# 指定した期間単位で重要単語を抽出する
def extraction_by_term(input_root: str, output_root: str, term: str) -> dict:
    # ---------------------------------------------
    # 1. load messages (processed)
    # ---------------------------------------------
    print('load msgs (all of history and last term) ...')
    msg_fpath = input_root + '/' + 'messages_cleaned_wakati_norm_rmsw.csv'
    df_msgs_all = pd.read_csv(msg_fpath)
    # ---------------------------------------------
    # 2. group messages by term
    # ---------------------------------------------
    print('group messages by term and save it.')
    msgs_grouped_by_term = group_msgs_by_term(df_msgs_all, term)
    msg_grouped_fpath = input_root + '/' + 'messages_grouped_by_term.json'
    with open(msg_grouped_fpath, 'w', encoding='utf-8') as f:
        json.dump(msgs_grouped_by_term, f, ensure_ascii=False, indent=4)
    # ---------------------------------------------
    # 3. 全文書を対象にtf-idf計算
    # ---------------------------------------------
    print('tfidf vectorizing ...')
    # > 全文書にある単語がカラムで、文書数（=user）が行となる行列が作られる。各要素にはtf-idf値がある
    tfidf_vectorizer = TfidfVectorizer(token_pattern=u'(?u)\\b\\w+\\b')

    bow_vec = tfidf_vectorizer.fit_transform(msgs_grouped_by_term.values())
    bow_array = bow_vec.toarray()
    bow_df = pd.DataFrame(bow_array,
                        index=msgs_grouped_by_term.keys(),
                        columns=tfidf_vectorizer.get_feature_names())
    # ---------------------------------------------
    # 5. tf-idfに基づいて重要単語を抽出する
    # ---------------------------------------------
    print('extract important words ...')
    dict_word_score_by_term = extract_important_word_by_key(
        tfidf_vectorizer.get_feature_names(),
        bow_df, msgs_grouped_by_term.keys())
    return dict_word_score_by_term
```


## 9. Wordcloudで可視化処理

### 9.1. Wordcloudとは

スコアの大きなワードは大きく、スコアの小さなワードは小さく表示した画像です。スコアには「出現頻度」や「重要度」など様々な値を自由に設定できます。

公式リポジトリ：[amueller/word_cloud :octocat: ](https://github.com/amueller/word_cloud)

### 9.2. Wordcloudのフォントを用意

今回は、こちらを利用します。
[自家製 Rounded M+ とは](http://jikasei.me/font/rounded-mplus/about.html)

### 9.3. Wordcloudの実装

前章の「8. 前処理：重要語句抽出(tf-idf)」では、以下のようなJSONファイルを出力しました。

```json:important_word_tfidf_by_term.json
{
  "term_ago_000": {
    "データ": 0.890021,
    "ゲーム": 0.780122,
    "記事": 0.720025,
    :
  },
  "term_ago_001": {
    "翻訳": 0.680021,
    "データ": 0.620122,
    "deepl": 0.580025,
    :
  },
  :
}
```

これを読み込んでWordcloudの画像を作ります。
`WordCloud.generate_from_frequencies()` というメソッドを使います。

```python:wordcloud_from_score.py
from wordcloud import WordCloud
import matplotlib.pyplot as plt
import json
from pathlib import Path
from sklearn.feature_extraction.text import TfidfVectorizer
import pandas as pd
from tqdm import tqdm
import sys
import argparse


def main(input_fname: str):
    input_root = '../../data/031_features'
    output_root = './wordcloud_by_user' if 'by_user' in input_fname else './wordcloud_by_term'
    p = Path(output_root)
    if p.exists() is False:
        p.mkdir()
    # -------------------------------------
    # 1. load tf-idf score dictionary
    # -------------------------------------
    d_word_score_by_user = {}
    tfidf_fpath = input_root + '/' + input_fname
    with open(tfidf_fpath, 'r', encoding='utf-8') as f:
        d_word_score_by_user = json.load(f)
    # -------------------------------------
    # 2. gen word cloud from score
    # -------------------------------------
    fontpath = './rounded-l-mplus-1c-regular.ttf'
    for uname, d_word_score in tqdm(d_word_score_by_user.items(), desc='word cloud ...'):
        # img file name is user.png
        uname = str(uname).replace('/', '-')
        out_img_fpath = output_root + '/' + uname + '.png'
        # gen
        wc = WordCloud(
            background_color='white',
            font_path=fontpath,
            width=900, height=600,
            collocations=False
            )
        wc.generate_from_frequencies(d_word_score)
        wc.to_file(out_img_fpath)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("input_fname", help="set input file name", type=str)
    args = parser.parse_args()
    input_fname = args.input_fname
    main(input_fname)
```


## 10. おまけ

### 特に参考にした記事

- [自然言語処理における前処理の種類とその威力 | Qiita](https://qiita.com/Hironsan/items/2466fe0f344115aff177)
- [NLP Pipeline 101 With Basic Code Example — Feature Extraction](https://medium.com/voice-tech-podcast/nlp-pipeline-101-with-basic-code-example-feature-extraction-ea9894ed8daf)

その他の参考資料（大量）は、[こちら :octocat: ](https://github.com/sota0121/slack-msg-analysis/blob/master/references/REFERENCE_LINK.md)にまとめています。

### 宣伝

今回は、Data Learning Guild というSlackコミュニティのデータを利用させていただいています。
Data Learning Guild はデータ分析人材が集まるオンラインコミュニティです。気になる方は、こちらをチェックしてみてください。

[データラーニングギルド公式ホームページ](https://data-learning.com/guild)

[データラーニングギルド 2019 Advent Calendar](https://qiita.com/advent-calendar/2019/data-learning-guild)