はじめに
Qiita x COTOHA APIプレゼント企画をきっかけにAPIを使って分析を行ってみることにしました。今回は自然言語処理の実務でよくありそうなタスクである口コミ分析を行います。
担当
@YuiKasuga 記事作成、スクレイピング、感情分析、ユーザ属性推定
@asmrt_ds 校正・校閲、WordCloud、キーワード抽出
COTOHA APIとは
https://api.ce-cotoha.com/
NTTコミュニケーションズが提供している自然言語処理や音声処理を実行できるAPI群です。
本記事では以下のAPIを使いました。
キーワード抽出
入力した文章の中に含まれる特徴的なキーワードを抽出します。感情分析
入力した文章がポジティブなのかネガティブなのかどちらでもないのか判定します。
また、文章をフレーズごとに分解し、それぞれについて感情表現ラベルが付与されます。ユーザ属性推定
入力した文章をもとに年代、性別、趣味、職業などの人物に関する属性を推定します。
API呼び出しの実装は以下の記事のソースコードを利用いたしました。
「メントスと囲碁の思い出」をCOTOHAさんに要約してもらった結果。COTOHA最速チュートリアル付き
関数定義
口コミ全件に対するAPIの出力を得る関数を定義しておきます。
Code
import MeCab
import neologdn
from tqdm import tqdm
# COTOHA APIのアカウント登録をする必要があります。
CLIENT_ID = "YourClientID"
CLIENT_SECRET = "YourClientSecret"
DEVELOPER_API_BASE_URL = "https://api.ce-cotoha.com/api/dev/"
ACCESS_TOKEN_PUBLISH_URL = "https://api.ce-cotoha.com/v1/oauth/accesstokens"
# CotohaApiクラスを使用します。
# https://qiita.com/youwht/items/16e67f4ada666e679875
cotoha_api = CotohaApi(
CLIENT_ID,
CLIENT_SECRET,
DEVELOPER_API_BASE_URL,
ACCESS_TOKEN_PUBLISH_URL
)
mecab = MeCab.Tagger('-O wakati')
def normalize(review):
review = neologdn.normalize(review)
# 文字数が3000文字以上あるとAPIが受け付けてくれないことがあるので短くします。
# 単語の途中で切れないように形態素解析器を途中に挟みます。
if len(review) > 3000:
review = mecab.parse(review).split()
count = 0
for idx, each in enumerate(review):
count += len(each)
if count > 3000:
break
review = ''.join(review[0:idx])
return review
def get_api_results(cotoha_api_method, reviews, n_repeats=3):
"""
cotoha_api_method : method
COTOHA APIの呼び出し関数を指定します。reviewsの1つ1つに対して実行されます。
例)get_api_results(cotoha_api.callKeywordApi, df.text, 5)
reviews : iterable
口コミを全件格納した配列です。
n_repeats : int
APIの呼び出し失敗時に何回やり直すか指定します。
"""
results = []
for review in tqdm(reviews):
review = normalize(review)
# Internal Server Errorになることが多いので何回かやり直す必要があります。
for i in range(n_repeats):
api_result = cotoha_api_method(review)
if api_result != "":
break
results.append(api_result)
return results
後に使用する集計用の関数とグラフ描画用の関数を定義します。
Code
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = 'Noto Sans CJK JP'
def value_counts(values, sort_index=True, bins=None, topk=None):
data = pd.Series(values, name='value').value_counts(bins=bins)
if sort_index:
data = data.sort_index()
data = data.reset_index().iloc[:topk]
data['index'] = data['index'].astype(str)
return data
def plot_figure(data, figsize=(8, 6)):
plt.figure(figsize=figsize)
ax = sns.barplot(x='value', y='index', data=data, palette='viridis')
ax.set_xlabel(None)
ax.set_ylabel(None)
ax.set_xticklabels(ax.get_xticks(), fontsize=12)
ax.set_yticklabels(ax.get_yticklabels(), fontsize=14)
plt.show()
データを準備する
食べログをスクレイピングして口コミを収集します。今回はラーメン二郎 三田本店の口コミのうち、2/24日時点でテキストが存在するもの860件を星の数(スコア)と共に収集しました。スクレイピングのソースコードは以下を参考にしました。
【Python】🍜ラーメンガチ勢によるガチ勢のための食べログスクレイピング🍜
食べログスクレイピング実装の詳細を以下に示します。
Code
import requests
import numpy as np
import pandas as pd
from bs4 import BeautifulSoup
from tqdm import tqdm
class StoreInfo:
def __init__(self, reviews, scores):
self.reviews = reviews
self.scores = scores
def get_dataframe(self):
df = pd.DataFrame()
df["score"] = self.scores
df["text"] = self.reviews
return df
class Tabelog:
def __init__(self, url, debug_mode=True):
self.debug_mode = debug_mode
r = requests.get(url)
if r.status_code != requests.codes.ok:
print(f'error:not found{url}')
self.parser = BeautifulSoup(r.content, 'html.parser')
def scrape_store_info(self):
review_url_list, review_cnt = self.get_review_url_list()
reviews = []
scores = []
for review_url in tqdm(review_url_list):
review_detail_url_list = self.get_review_detail_url_list(review_url)
for url in tqdm(review_detail_url_list):
text, score = self.get_review_text(url)
if text is not '':
reviews.append(text)
scores.append(score)
return StoreInfo(reviews, scores)
def get_review_url_list(self):
review_tag_id = self.parser.find('li', id="rdnavi-review")
review_tag = review_tag_id.a.get('href')
review_cnt = review_tag_id.find('span', class_='rstdtl-navi__total-count').em.string
if self.debug_mode:
max_page_num = 2
else:
max_page_num = int(np.ceil(int(review_cnt) / 20))
url_list = []
for page_num in range(1, max_page_num):
url = f'{review_tag}COND-0/smp1/?lc=0&rvw_part=all&PG={page_num}'
url_list.append(url)
return url_list, review_cnt
def get_review_detail_url_list(self, review_url):
r = requests.get(review_url)
if r.status_code != requests.codes.ok:
print(f'error:not found{review_url}')
return []
parser = BeautifulSoup(r.content, 'html.parser')
review_detail_tags = parser.find_all('div', class_='rvw-item')
url_list = []
for tag in review_detail_tags:
url = f"https://tabelog.com{tag.get('data-detail-url')}"
url_list.append(url)
return url_list
def get_review_text(self, review_detail_url):
r = requests.get(review_detail_url)
if r.status_code != requests.codes.ok:
print(f'error:not found{review_detail_url}')
return ''
parser = BeautifulSoup(r.content, 'html.parser')
review = parser.find_all('div', class_='rvw-item__rvw-comment')
score = parser.find_all('b', class_='c-rating__val c-rating__val--strong')
if len(review) == 0:
review = ''
score = 0
else:
review = review[0].p.text.strip()
score = score[0].text
return review, score
url = "https://tabelog.com/tokyo/A1314/A131402/13006051/"
tabelog = Tabelog(url, debug_mode=False)
store_info = tabelog.scrape_store_info()
df = store_info.get_dataframe()
df.head()
収集したテキストは以下のようになりました。
score | text |
---|---|
3.5 | 平日のランチ時12時ちょっと前に来店。既に行列は裏迄続いていたが、並ぶ事50分で店内に通され... |
3.6 | 2020年2月平日18:05往訪、15-20名の並び。18:35に券売機にて購入、18:45... |
3.5 | 11:00頃訪問田町駅から歩いて訪問慶應側から歩いて店の先まで見なくて並んだので後から気づい... |
5.0 | 早朝作業の後は三田二郎で朝ラー9時台ならば並んでいないかな?と期待したけど、建物の角まで並ん... |
3.7 | 昨日は、二郎インスパイア店に訪問したので、そろそろ二郎に行かないとと思っていました。2020... |
スコアのヒストグラムを描画してみると以下のようになりました。縦軸がユーザが付与したスコアの範囲を表し、横軸がその範囲のスコアを付与した人が何人いるかを示しています。
Code
# スコアなしのレビューのスコアは"-"と格納されているので除外しています。
values = df[df.score != "-"].score.astype(float)
bins = [1e-3] + np.linspace(0, 5, 11).tolist()[1:]
data = value_counts(values, bins=bins)
plot_figure(data, figsize=(6, 6))
グラフを見てみると3.0~3.5を付与する人と比べて4.0~4.5を付与する人が少なくなっています。理由を正確に知ることはできませんが、一定以上満足度が高かった場合は中間的なスコアをつけずに5にする人が多いのではないか、というのは考えられそうですね。
キーワードを抽出する
口コミ全体を使ってWord Cloudを作成しました。実装は以下のようになっています。
Code
from PIL import Image
from wordcloud import WordCloud
import matplotlib.pyplot as plt
stopwords = [
'また', 'そして', 'まあ', 'はい', 'です', 'さて', 'あれ', 'うん', 'しかし', 'ただ', 'でも',
'http', 'https', 'com', 'html', 'jp', 'entry', 'tabelog', 'fc2'
]
def create_word_cloud(texts, img_path=None, font_path=None, title=None, colormap='copper_r'):
if img_path:
mask = np.array(Image.open(img_path))
mask = mask[:,:,1]
else:
mask = None
wc = WordCloud(
font_path=font_path,
background_color="white",
max_words=2000,
mask=mask,
stopwords=stopwords,
collocations=False
).generate(" ".join(texts))
plt.figure(figsize=(20,10))
plt.axis("off")
plt.title(title, fontsize=20)
plt.imshow(wc.recolor(colormap=colormap, random_state=17), alpha=0.98)
plt.show()
# img_pathとfont_pathは任意のものを指定してください。
create_word_cloud(
df.text,
img_path='どんぶり.png',
font_path='ヒラギノ角ゴシック W8.ttc',
title='All Phrases',
colormap=None
)
ニンニクと野菜が印象的ですね。
さて、ここで店舗のページに何らかのカテゴリタグを自動で付与したくなったとしましょう。そんなときにキーワード抽出APIを活用できそうです。今回は口コミ1件につき最大5個のキーワードをAPIによって抽出し、頻度順に並べて可視化を行いました。頻出するキーワードはカテゴリのタグに適しているか確認します。
Code
keyword_extraction_results = get_api_results(cotoha_api.callKeywordApi, df.text)
keywords = []
for each in keyword_extraction_results:
if each == "":
continue
keywords.extend([x['form'] for x in each['result']])
data = value_counts(keywords, sort_index=False, topk=19)
plot_figure(data)
抽出されたキーワードを確認すると、「ラーメン、スープ、野菜、ニンニク、豚」など、二郎を表す基本的な単語が抽出されていることが分かります。使用する単語の種類を限定するなどの後処理を加えることで、ある程度自動的にカテゴリタグを付与できそうです。また、その店舗の特徴的な具材を抽出するのにも使えるかもしれません。
感情を分析する
感情分析APIの出力結果を以下に示しました。各出力項目について見ていきます。
sentiment_analysis_results = get_api_results(cotoha_api.callSentimentApi, df.text)
sentiment_analysis_results[0]
->
{'result': {'sentiment': 'Positive',
'score': 0.10250407500527128,
'emotional_phrase': [{'form': '好きかな', 'emotion': 'P'},
{'form': '冷たい', 'emotion': 'PN'},
{'form': 'けっこう辛かった', 'emotion': 'N'},
{'form': '良かったですね', 'emotion': 'P'},
{'form': 'ラッキー', 'emotion': 'P'},
{'form': '早く', 'emotion': 'P'},
{'form': '薄く', 'emotion': 'PN'},
{'form': 'イマイチでした', 'emotion': 'N'},
{'form': '少な目', 'emotion': 'PN'},
{'form': '適度な', 'emotion': 'PN'},
{'form': '殆ど記憶に残っていませんでした', 'emotion': 'N'},
{'form': '高い', 'emotion': 'PN'}]},
'status': 0,
'message': 'OK'}
sentiment
文章全体としてPositiveなのかNegativeなのかのラベルが付与されます。ラベルは['Positive', 'Negative', 'Neutral', 'Positive/Negative']
の4種類です。score
感情分析の信頼度が0~1で付与されています。本記事では使用しませんでした。emotional_phrase
フレーズとフレーズ単位の感情ラベルが格納されています。ラベルは['P', 'N', 'PN']
を含む15種類のラベルの中から付与されます。複数個付与されることもあります。本記事では15種類のラベルを以下のようにポジティブ表現、ネガティブ表現、ニュートラル表現として取り扱います。
positive = ['喜ぶ', '好ましい', '興奮', '安心', 'P']
neutral = ['驚く', '願望', 'PN']
negative = ['怒る', '悲しい', '不安', '恥ずかしい', '嫌', '切ない', 'N']
感情表現の頻出フレーズを可視化する
口コミに含まれる感情表現であるemotional_phrase
を感情ラベルごとに集計してWord Cloudを使って可視化しました。感情表現の集計コードを以下に示します。
Code
positive_phrases = []
negative_phrases = []
neutral_phrases = []
for result in sentiment_analysis_results:
for phrase in result['result']['emotional_phrase']:
# 複数の感情がカンマ区切りで設定されているケースがあるため、
# カンマ単位で文字列を分割します。
emotion_labels = phrase['emotion'].split(',')
count = 0
for label in emotion_labels:
# ラベルが複数付与されている場合はポジティブのラベル数と
# ネガティブのラベル数を比較して多いほうを採用します。
if label in positive:
count += 1
elif label in negative:
count -= 1
if count > 0:
positive_phrases.append(phrase['form'])
elif count < 0:
negative_phrases.append(phrase['form'])
else:
neutral_phrases.append(phrase['form'])
Word Clouds
Code
create_word_cloud(
positive_phrases,
img_path='どんぶり.png',
font_path='ヒラギノ角ゴシック W8.ttc',
title='Positive Phrase'
)
ポジティブ表現のWord Cloudではラーメンが美味しいときの表現がたくさん含まれています。見ているだけでポジティブになれますね。店舗の商品の強みを知るのにもよさそうです。
Code
create_word_cloud(
negative_phrases,
img_path='どんぶり.png',
font_path='ヒラギノ角ゴシック W8.ttc',
title='Negative Phrase'
)
ネガティブ表現のWord Cloudで特に興味深いのは「初心者・難しい・わからない」といった単語です。普通の店舗ではあまりみられない単語ではないでしょうか。二郎特有の注文の難しさが表れていますね。二郎の場合は改善する必要はないとは思いますが、一般の店舗の場合では、ネガティブ表現を可視化することでサービス品質改善につなげることができそうです。ちなみに、ホロホロと絡むがネガティブ表現に分類されてしまいました。泣くときのホロホロだったり不良に絡まれるときの絡むと認識されてしまった可能性があります。実際は豚がホロホロだったり、麺が絡むといった使われ方なのでここは機械学習泣かせなところですね。日本語の表現の難しさが浮き彫りになりました。
Code
create_word_cloud(
neutral_phrases,
img_path='どんぶり.png',
font_path='ヒラギノ角ゴシック W8.ttc',
title='Neutral Phrase'
)
ニュートラル表現では商品の質感であったり分量の感情表現が集まりました。こちらもポジティブ表現と同様に商品の強みを掴むのによさそうですね。
口コミだけから店舗の評価点を導き出せるか?
口コミのスコアを使わず、テキストのみから以下のルールで店舗の評価点を導いてみます。
-
sentiment
ラベルがPositive
の場合を★4、Negative
の場合を★2、Neutral
の場合を★3とします。 - 口コミに含まれる
emotional_phrase
について以下の要領でスコアリングを行いemotion_count
と定義しました。- ポジティブ表現であれば + 1
- ネガティブ表現であれば - 1
- ニュートラル表現であれば ± 0
- ラベルが複数付与されている場合はポジティブのラベル数とネガティブのラベル数を比較して多いほうを採用します。同数の場合はニュートラルとします。
-
sentiment
ラベルがPositive
のときemotion_count
が5より上であれば★5とします。 -
sentiment
ラベルがNegative
のときemotion_count
が-2より下であれば★1とします。
上記のルールをコードに起こすと以下のようになります。
Code
emotion_counts = []
for each in sentiment_analysis_results:
count = 0
for phrase in each['result']['emotional_phrase']:
emotion_labels = phrase['emotion'].split(',')
inner_count = 0
for label in emotion_labels:
if label in positive:
inner_count += 1
elif label in negative:
inner_count -= 1
if inner_count > 0:
count += 1
elif inner_count < 0:
count -= 1
emotion_counts.append(count)
scores = []
for i, each in enumerate(sentiment_analysis_results):
sentiment = each['result']['sentiment']
if sentiment == 'Positive':
score = 5 if emotion_counts[i] > 5 else 4
elif sentiment == 'Negative':
score = 1 if emotion_counts[i] < -2 else 2
elif sentiment in ['Neutral', 'Positive/Negative']:
score = 3
else:
raise ValueError(sentiment)
scores.append(score)
np.mean(scores)
-> 3.7465116279069766
計算結果は以下のようになりました。
method | score |
---|---|
ラーメン二郎 三田本店のページトップのスコア | 3.76 |
食べログの口コミの評価の平均 | 3.74 |
COTOHA API | 3.75 |
本記事では恣意的にルールを決めて実施しているため、汎用性はありませんが、COTOHA APIとルールをうまく設計すれば大まかな食べログの評価点をテキスト情報のみから算出することが可能であることが分かりました。また、口コミ単体のスコアと予測したスコアの相関係数を計算したところ0.19と低い値となり、口コミ単体でのスコアの予測は難しいことが分かりました。
感情ラベルと本来のスコアの比較
APIで付与されたsentiment
のラベルと口コミのスコアのヒストグラムを描画してみました。Positive/Negative
のラベルはNeutral
としてカウントしています。横軸は本来のスコアを表し、縦軸はスコアを付けた人の割合を表しています(最大値が1になるように正規化されています)
Code
df['sentiment'] = [x['result']['sentiment'] for x in sentiment_analysis_results]
df['sentiment'][df['sentiment'] == 'Positive/Negative'] = 'Neutral'
df['score'] = df['score'].replace('-', np.nan).astype(float)
palette = ['#FF9900', '#99CC33', '#66CCFF']
fig = sns.FacetGrid(df, hue='sentiment', height=4, aspect=2, palette=palette)
fig.map(sns.kdeplot, 'score', shade=True)
fig.set_xticklabels(np.arange(6), fontsize=16)
fig.set_yticklabels(fontsize=16)
fig.set_xlabels(fontsize=18)
fig.set_ylabels(fontsize=18)
fig.set(xlim=[0, 6])
fig.add_legend(fontsize=14)
グラフを見てみると、ネガティブと判定された口コミでもスコアが高いものが見受けられ、特にスコアが3~4の間でポジティブとネガティブの分布の重なりが大きくなっています。そのことから、低評価のレビュー判定が難しいことがわかります。ただし、ネガティブのグラフにおいて、スコア1付近の分布が他の分布と比べて山が高くなっていることから、極端なネガティブの感情をもったテキストであれば分類しやすいことが推察されます。
次に、以下の要領で本来のスコアを['Positive', 'Neutral', 'Negative']
ラベルに変換し、APIの結果と比較してみます。
- スコアが3.2より上のものを
Positive
とする - スコアが2.8より下のものは
Negative
とする - スコアがそれ以外のものを
Neutral
とする
ラベル変換用の閾値は任意で決められますが、食べログの評価において両極端な評価は付きづらいということが経験上およびヒストグラムから予想できるので、上記のような閾値を用いることにしました。
結果を以下のテーブルに示します。
Code
from sklearn.metrics import classification_report
df['true_label'] = 'Neutral'
df.true_label[df.score > 3.2] = 'Positive'
df.true_label[df.score < 2.8] = 'Negative'
y_true = df[~df.score.isnull()].true_label
y_pred = df[~df.score.isnull()].sentiment
print(classification_report(y_true, y_pred))
precision recall f1-score support
Negative 0.07 0.29 0.12 35
Neutral 0.22 0.13 0.16 103
Positive 0.86 0.79 0.82 671
accuracy 0.68 809
macro avg 0.38 0.40 0.37 809
weighted avg 0.75 0.68 0.71 809
ラベル別の精度(precision)をみてみると、Positiveの精度は高いもののそれ以外はあまりうまく判定できていないことが分かります。感情分類においてNeutralの判定が難しいのはタスク特有の問題なので仕方ないとして、Negativeの分類がほとんどできていないことが気になります。これはなぜなのでしょうか。二郎ラーメンの特徴として麺や野菜の量が非常に多く、苦しみながらも完食する人が一定数存在することが挙げられます。これは「食べた直後はしばらく行きたくなくなるが、翌日にはまた食べたくなる」というような、ネガティブ表現を使った高評価レビューが存在することを意味します。こういった機械学習で取り扱うことが難しい表現方法がNegativeの誤判定が多くなってしまうひとつの要因ではないかと考えられます。
ユーザーのプロフィールを推定する
ユーザ属性推定のAPIの出力結果を以下に示します。
user_attributes = get_api_results(cotoha_api.callUserAttributeApi, df.text)
user_attributes[0]
->
{'result': {'age': '40-49歳',
'earnings': '-1M',
'gender': '男性',
'hobby': ['INTERNET', 'SHOPPING'],
'location': '関東',
'occupation': '会社員'},
'status': 0,
'message': 'OK'}
ユーザ属性推定APIは文章から年齢・年収・性別・趣味・出身地・職業などを推定することができます。口コミ全件に対してユーザ属性推定を実施し、属性情報を集計しました。結果を以下に示します。(ユーザ属性の各項目はAPIで推定できずに結果が返ってこないことがあります。その場合は集計から除外しています。)
Code
attributes = []
for each in user_attributes:
gender = each['result'].get('gender')
age = each['result'].get('age')
if (gender and age):
attributes.append(f'{gender} {age}')
data = value_counts(attributes)
plot_figure(data)
まず、年齢と性別についてヒストグラムを描画しました。グラフより、女性より男性のほうが多いというのは料理の性質からしても納得感があります。また、40代男性が圧倒的に多い結果となっています。食べログユーザはプロフィールを公開している人はそれほど多くないため正解は分かりませんが、個人的にはもう少し若者が多いような気もします。食べログに口コミを書くユーザが40代に偏っている可能性はありそうです。この情報だけを見て何かを判断するのは難しいとは思いますが、ざっくりとした傾向を見られるのは面白いなぁと思います。
Code
attributes = []
for each in user_attributes:
location = each['result'].get('location')
if location is not None:
attributes.append(location)
data = value_counts(attributes, sort_index=False)
plot_figure(data)
続いて、出身地のヒストグラムを示しました。三田店なので関東出身が多いのは納得感がありますが、口コミを関西弁で書く人はまれだと思うので出身地というよりは現在地を推定してるように思えます。どちらにせよ口コミに含まれる地名を拾うことで精度よく推定しているなという印象を受けました。
Code
attributes = []
for each in user_attributes:
attribute = each['result'].get('hobby', [])
attributes.extend(attribute)
data = value_counts(attributes, sort_index=False)
plot_figure(data, figsize=(8, 10))
最後に、趣味のヒストグラムを描画しました。口コミのサイトなのでINTERNETが一番になるのは当然として、COOKINGやGOURMETが上位に来ているのは興味深いですね。グルメの口コミの文章としての特徴を捉えていると言えそうです。それ以外の趣味を当てるのは食べログの口コミだけでは厳しいと思われるのであくまで参考程度にはなってしまいますが、面白いですね。
おわりに
最後まで読んでいただきありがとうございました。本記事では以下の3つのAPIを使って分析を行いました。
- キーワード抽出
- 口コミのキーワードを集計し、店舗のカテゴリタグになりそうな単語を収集できました。
- 感情分析
- 口コミのテキストのみから店舗の評価点を算出しました。設計次第で他の店舗にも応用できそうです。
- APIによる感情分類の精度を確認しました。ポジティブなものはそれなりの精度が出ますが、ニュートラルとネガティブのものは低い精度でした。二郎特有の口コミの記述方法が関係してきそうです。
- ユーザ属性抽出
- ユーザのプロフィール情報を集計しました。なんとなくの傾向を掴むのにはよさそうです。
皆さんもCOTOHA APIを使って口コミ分析を試してみてください。
いいね、質問、コメント等お待ちしております。