Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

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

本記事について

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

ソースコードはこちらにあります :octocat:

あわせて読みたい:【自然言語処理】Slackコミュニティにおける各メンバーの発言を可視化してみた

目次

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

※前処理については、今後別記事にまとめたいと思います

1. 使い方と出力例

1.1. 使い方

詳細は、READMEの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

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

2.1. SlackAPIを使う

Slack API公式よりSlackAPIのトークンを取得しましょう

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

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

  • Channel API
  • Users API

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

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

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. ユーザー一覧
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)ファイル名
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. クリーニング処理の実装

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. 分かち書きとは

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

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

といった形になります。

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

例文の分かち書き(名詞のみ):「私 野球」

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

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

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

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

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

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

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

  • 形態素解析ライブラリ : Janome
  • 辞書データ : Janomeデフォルト(新語対応のNEologdだと尚良い)

また、抽出する品詞は、「形態素解析ツールの品詞体系」とにらめっこしつつ目的を達成する為に必要なものは何か?という観点で考えました。

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

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に置き換え
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. 出現頻度を用いる方法

辞書データは、こちらを使います。

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

  • 辞書を用いる場合 既存の辞書 があるので導入が楽
  • 出現頻度を用いる場合、「一般的な会話 の中で出現頻度が高く、それゆえ除去すべきワード」のみを抜き出す必要があります。手元にあるSlackデータだけで出現頻度を出すと 「盛り上がった話題に関するワード」が不本意に除去される恐れがある ので、別途データを用意して集計する必要があると考えました。……それはちょっと大変

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

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

  • 「-」
  • 「ー」
  • 「w」
  • 「m」
  • 「笑」
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

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

  • 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日単位で過去のデータを区切り、グルーピングします
  3. 1グループのメッセージを1文書として、tf-idfを計算
  4. tf-idfのスコアが閾値以上の単語を抽出する(辞書として出力)
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:

9.2. Wordcloudのフォントを用意

今回は、こちらを利用します。
自家製 Rounded M+ とは

9.3. Wordcloudの実装

前章の「8. 前処理:重要語句抽出(tf-idf)」では、以下のような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() というメソッドを使います。

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. おまけ

特に参考にした記事

その他の参考資料(大量)は、こちら :octocat: にまとめています。

宣伝

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

データラーニングギルド公式ホームページ

データラーニングギルド 2019 Advent Calendar

masso
物理出身のエンジニア+研究者(画像処理/機械学習/PointCloud/データ分析/統計/C++/Python/Ruby) 最近はGCPを主軸にデータエンジニアリングも
data-learning-guild
データ分析人材のキャリア構築を支援するためのオンラインコミュニティです
https://data-learning.com/guild
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away