本記事について
この記事では、Slackコミュニティである一定期間(ここでは1週間)内にどんな話題で盛り上がったのかをWordcloudを用いて可視化する手法について紹介します。
ソースコードはこちらにあります
あわせて読みたい:【自然言語処理】Slackコミュニティにおける各メンバーの発言を可視化してみた
目次
- 使い方と出力例
- Slackからメッセージを取得
- 前処理:メッセージマートテーブル作成
- 前処理:クリーニング
- 前処理:形態素解析(Janome)
- 前処理:正規化
- 前処理:ストップワード除去
- 前処理:重要語句抽出(tf-idf)
- Wordcloudで可視化処理
- おまけ
※前処理については、今後別記事にまとめたいと思います
1. 使い方と出力例
1.1. 使い方
詳細は、READMEのGetting started を参照ください。
流れは、こんな感じです。
-
docker-compose up -d
で仮想環境構築 -
docker exec -it ds-py3 bash
でシェルに入る -
run_wordcloud_by_term.sh
を実行
1.2. 出力例
実際に出力した例です。それぞれ異なる期間の発言をWordcloudにしています。
2. Slackからメッセージを取得
2.1. SlackAPIを使う
Slack API公式よりSlackAPIのトークンを取得しましょう
Slack APIの始め方は、ここには記載しません。
以降の処理を実施するには、以下のトークンを取得してください
- Channel API
- Users API
2.2. API経由でSlackの情報を取得するクラスを作成
ここでは、API経由でSlackの情報を取得する SlackApp クラスを作ります。
取得した情報は、加工せずにJSON形式で保存します。
# 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 クラスを用いて情報を取得します。
取得する情報は以下の三つです
- チャンネル一覧
- メッセージ一覧(チャンネルごとに取得)
- ユーザー一覧
# 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)ファイル名
-
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. クリーニング処理の内容
一般に、ノイズを除去する行為を指します
対象とするデータや目的によって、様々な処理を施す必要があります。
ここでは、下記の処理を実行しました。
- URL文字列の削除
- メンション文字列の削除
- Unicode絵文字の削除
- htmlの特殊文字削除(>とか)
- コードブロックの削除
- インラインコードブロックの削除
- 「〇〇がチャンネルに参加しました」というメッセージ除去
- その他、本コミュニティ特有のノイズ除去
4.2. クリーニング処理の実装
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)
本当は、「コードブロック内がソースコードの場合のみ除去する」としたかったのですが、できませんでした。
5. 前処理:形態素解析(Janome)
5.1. 形態素解析とは
一般に、文章中の「形態素」を見つけ出す処理です。
形態素解析の詳細は、他の記事に譲ります。
ここでは、「分かち書き」を実行するのが真の目的です。
5.2. 分かち書きとは
ざっくりと言えば、文章を「単語 単語 単語」という情報に変換する処理です。
例えば、
例文:私は、野球が好きです。
例文の分かち書き:「私 は 野球 が 好き です」
といった形になります。
実践的な話で言えば、今回のように文章のコンテクストを表すワードを取り扱いたいという場合は、 「名詞」を抜き出す のが望ましいと思います。したがって、
例文の分かち書き(名詞のみ):「私 野球」
とすると尚良いでしょう。
※「私」も消した方がいいのでは?と思ったそこのあなた。「ストップワード除去」の章をみてください
5.3. 形態素解析と分かち書きの実装
形態素解析を実装する場合は、
- 形態素解析ライブラリに何を使うか
- 辞書データとして何を使うか
という点を決めなくてはなりません。
今回は、以下のようにしました。
- 形態素解析ライブラリ : Janome
- 辞書データ : Janomeデフォルト(新語対応のNEologdだと尚良い)
また、抽出する品詞は、「形態素解析ツールの品詞体系」とにらめっこしつつ目的を達成する為に必要なものは何か?という観点で考えました。
公式マスコットのJanomeちゃんかわいいですね。(Janomeちゃんって名前なのかは知らないですけど)
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. 正規化とは
自然言語処理の前処理における正規化とは、以下のような処理を指します。
「名寄せ」などと呼ばれることもあります。
- 文字種の統一
- カナを全角に統一
- アルファベットは小文字に統一…など
- 数字の置き換え
- 数字を全て0に置き換えるなど
- ※自然言語処理において数字が重要な場面は少ない
- 辞書を用いた単語の統一
- 「Sony」と「ソニー」を同一と判断し、「Sony」という表記に統一など
正規化の世界は奥深いですので、ここではこの辺までにしておきます。
6.2. 正規化の実装
今回は、簡単の為以下の処理のみ実装しました。
めちゃくちゃ簡単です。
- アルファベットを小文字に統一
- 数字は全て0に置き換え
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の方法を選択した理由はいくつかあります。
- 辞書を用いる場合 既存の辞書 があるので導入が楽
- 出現頻度を用いる場合、「一般的な会話 の中で出現頻度が高く、それゆえ除去すべきワード」のみを抜き出す必要があります。手元にあるSlackデータだけで出現頻度を出すと 「盛り上がった話題に関するワード」が不本意に除去される恐れがある ので、別途データを用意して集計する必要があると考えました。……それはちょっと大変
7.3. ストップワード除去の実装
前節で紹介した辞書データに登録されている単語を除去します。
それに加えて、以下の文字も除去対象にしました。
いろいろチューニングしているうちに邪魔だなーと思った単語です。
- 「-」
- 「ー」
- 「w」
- 「m」
- 「笑」
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. 実装
簡単に処理の流れを書きます
- メッセージマートテーブル(前処理済み)読み込み
- メッセージの期間によるグルーピング
- 処理実行時を起点に7日単位で過去のデータを区切り、グルーピングします
- 1グループのメッセージを1文書として、tf-idfを計算
- tf-idfのスコアが閾値以上の単語を抽出する(辞書として出力)
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
9.2. Wordcloudのフォントを用意
今回は、こちらを利用します。
自家製 Rounded M+ とは
9.3. Wordcloudの実装
前章の「8. 前処理:重要語句抽出(tf-idf)」では、以下のような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()
というメソッドを使います。
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. おまけ
特に参考にした記事
その他の参考資料(大量)は、こちら にまとめています。
宣伝
今回は、Data Learning Guild というSlackコミュニティのデータを利用させていただいています。
Data Learning Guild はデータ分析人材が集まるオンラインコミュニティです。気になる方は、こちらをチェックしてみてください。