ギークなエンジニアを目指す男

機械学習系の知識を蓄えようとするブログ

TF-IDFで見る評価の高いラーメン屋の口コミ傾向(自然言語処理, TF-IDF, Mecab, wordcloud, 形態素解析、分かち書き)

年末〜学習している日本語口コミデータの解析について一区切り(?)ついたので、まとめてみようと思います。

はじめに

本記事は下記の関連記事です。

www.takapy.work

某グルメサイトの新宿にあるラーメン屋の口コミを解析し、評価の高い店舗と評価の低い店舗では、口コミにどのような傾向があるのかを分析(?)していきます。

本記事が今後のラーメン業界の発展に貢献し、より美味しいラーメン屋が誕生すれば、ラーメンエンジニア冥利に尽きます(謎)

データ読み込み & EDA

データの読み込み

まずは、前回取得したデータを読み込みます。

www.takapy.work
shinjuku_ramen_df = pd.read_csv('shinjuku_ramen_df.csv', index_col=0)
shinjuku_ramen_df.head()

f:id:taxa_program:20190113170551p:plain
 

これを、店舗のDFと口コミのDFに分割しておきます。

# 店舗情報
store_df = shinjuku_ramen_df[['store_id', 'store_name', 'score', 'ward', 'review_cnt']]
# 重複データを削除する
store_df = store_df.drop_duplicates(['store_id', 'store_name', 'score', 'ward', 'review_cnt'])
# 確認
store_df.head()

f:id:taxa_program:20190113170746p:plain:w500

# 口コミ情報
review_df = shinjuku_ramen_df[['store_id', 'review']]

EDA

基礎統計情報を表示させてみます。

# 基礎統計情報を確認してみる
store_df.describe()

f:id:taxa_program:20190113171055p:plain:w300

review_df.describe()

f:id:taxa_program:20190113171206p:plain:w150

上記より、店舗数は160店舗、レビューは8688件あることが分かります。また、評価点数は3.64〜3.00の範囲であることが分かります。

今回解析するのに評価の点数が3.5以上の口コミと、3.3以下の口コミを対象として、両者の口コミにどのような傾向があるか検証していきます。

f:id:taxa_program:20190113171543p:plain:w400

前処理(欠損値、形態素解析&分かち書き、ストップワード除去)

下記4つの前処理を実施しました。

  • 欠損値を削除
  • 数字を0に置換
  • Mecabで形態素解析&分かち書き
  • ストップワードの除外

欠損値削除

# 欠損値を確認する関数
def missing_values_table(df): 
    mis_val = df.isnull().sum()
    mis_val_percent = 100 * df.isnull().sum()/len(df)
    mis_val_table = pd.concat([mis_val, mis_val_percent], axis=1)
    mis_val_table_ren_columns = mis_val_table.rename(
    columns = {0 : 'Missing Values', 1 : '% of Total Values'})
    return mis_val_table_ren_columns 

missing_values_table(review_df)

f:id:taxa_program:20190113172321p:plain:w300

129件の欠損値が存在していました。今回はこれらは削除します。

# 欠損値を削除する
review_df = review_df.dropna()

数字の扱い

数字は解析する際にあまり意味がないと考え、今回は全て0に置換しました。

# あまり関係のないと思われる数字を全て0に置き換える関数
import re
def replace_number_to_zero(text):
    changed_text = re.sub(r'[0-9]+', "0", text) #半角
    changed_text = re.sub(r'[0-9]+', "0", changed_text) #全角
    return changed_text

# 数字を0に置換
review_df['review_number_to_zero'] = review_df['review'].map(replace_number_to_zero)
review_df.head()

f:id:taxa_program:20190113172543p:plain
reviewが置換前、review_number_to_zeroが置換後

Mecabで形態素解析&分かち書き

Mecabで分かち書きしてみます。

Mecabのインストール方法や簡単な使い方については下記記事で紹介しています。

www.takapy.work

今回は素性として、['助詞', '助動詞', '接続詞', '動詞', '記号']を除外しました。

# 新語を含むmecab-ipadic-neologdで形態素解析する
tagger = MeCab.Tagger ('-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd')

# 分かち書きした結果を返す関数
def leaving_space_between_words_column(text):
    splitted = ' '.join([x.split('\t')[0] for x in tagger.parse(text).splitlines()[:-1] if x.split('\t')[1].split(',')[0] not in ['助詞', '助動詞', '接続詞', '動詞', '記号']])
    return splitted

# 分かち書きしたカラムをdfに追加する
review_df['lsbw'] = review_df['review_number_to_zero'].map(leaving_space_between_words_column)
review_df.head()

f:id:taxa_program:20190113185740p:plain
lsbwカラムに分かち書きした結果を設定

ストップワード

ストップワードというのは自然言語処理する際に一般的で役に立たない等の理由で処理対象外とする単語のことです。たとえば、助詞や助動詞などの機能語(「は」「の」「です」「ます」など)が挙げられます。これらの単語は出現頻度が高い割に役に立たず、計算量や性能に悪影響を及ぼすため除去します。上記の素性除外によって、ある程度は除外できていますが、それに加えて次の処理を実施します。

今回は、日本語のストップワードでよく使用されているSlothLibのストップワードと、自分で作成したストップワードファイル(more_stopword.txt)を使用できるようにしました。

# ストップワードの設定を行う関数を定義。今回はローカルのtxtファイルから設定できるようにした。
def set_stopwords():
    """
    Get stopwords from input document.
    """
    # Defined by SlpothLib
    slothlib_path = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
    slothlib_file = urllib.request.urlopen(slothlib_path)
    slothlib_stopwords = [line.decode("utf-8").strip() for line in slothlib_file]
    slothlib_stopwords = [ss for ss in slothlib_stopwords if not ss==u'']
    
    stopwords_list = []
    
    # add stop_word from text file
    f = open('more_stopword.txt')
    txt_file = f.readlines()
    f.close()
    more_stopwords = [line.strip() for line in txt_file]
    more_stopwords = [ss for ss in more_stopwords if not ss==u'']
    stopwords_list += more_stopwords

    # Merge and drop duplication
    stopwords_list += slothlib_stopwords
    stopwords_list = set(stopwords_list)

    return stopwords_list

f:id:taxa_program:20190113173410p:plain

関数も定義しておきます。

# リストを文字列に変換する関数
def join_list_str(list):
    return ' '.join(list)

# ストップワード除外関数
def exclude_stopword(text):
    changed_text = [token for token in text.lower().split(" ") if token != "" if token not in stopwords]
    # 上記のままだとリスト形式になってしまうため、空白区切の文字列に変換
    changed_text = join_list_str(changed_text)
    return changed_text

ここまでの前処理を通して下記口コミが

初訪問です。新宿TOHOシネマの裏、新宿駅から徒歩7.8分の場所にあるこちらのお店。平日13時半ごろに到着し、外待ち3名でしたがすぐに入れました。カウンターがあるのですが、なにやら個室もあるそうで、6人テーブルと4人テーブルが2つずつあるそうです。カウンター席に座りました。メニューがたくさんあり、レビューの評価もまばらでしたので、歩いてる間に評価が良さそうな人が頼んでるメニューをリサーチしました。本日頼んだのは、裏竹虎つけ麺 辛口 930円トッピングを一品選べるので、チャーシューに決定。居酒屋のように使う人も多いみたいで、昼から隣の人もビール呑んでました。とにかく入って早々なんか店内の臭いに違和感があって、そこでマイナスでした。最初に出て来たのはラーメンスナックで、トマトサラダ味とかかな?ポリポリ食べながらメインを待ちます。10分弱で運ばれてきました。麺は縮れた太麺は卵麺、表面がツルツルしています。ややかためでしっかり歯ごたえがあります。つけ汁をつけてびっくり。背脂チャッチャ系の脂が浮きつつ、豆板醤の味と人工的な甘みと酸味。ニンニク臭が強めのため、昼からはなかなか厳しい。ブレスケアを買うことをすぐに決断しました。確かに辛みはありますが、カバーするようになのか甘さと酸味が強くて調味料で味をまとめた感じが強かったです。具はチャーシューとメンマがつけ汁に入っているのと、麺の上に半たまご、トッピングのチャーシュー。トッピングとわざわざ頼むから、チャーシューメンのように枚数がくるのかと思ったら1枚で、特に美味しくもなく残念でした。初めてなのに「裏」を頼んだのが失敗だったか。次は1番最初のあご出汁醤油ですかね。ごちそうさまでした。

最終的に下記のようになりました。もう少し前処理頑張れよ

初 訪問 裏 徒歩 場所 平日 半 待ち カウンター なにやら 個室 テーブル テーブル カウンター レビュー 評価 まばら 歩いてる 評価 良 リサーチ 本日 裏 竹虎 辛口 トッピング チャーシュー 決定 居酒屋 多い 昼 隣 ビール とにかく 早々 店内 臭い 違和感 マイナス 最初 スナック トマト サラダ 味 かな ポリポリ メイン 弱 麺 太 麺 卵 麺 表面 ツルツル 歯ごたえ 汁 びっくり 背脂 チャッチャ 脂 豆板醤 味 人工 甘み 酸味 ニンニク臭 昼 厳しい ブレスケア 決断 辛み カバー なのか 甘 酸味 強く 調味料 味 強かっ チャーシュー メンマ 汁 麺 半 たまご トッピング チャーシュー トッピング わざわざ チャーシュー メン 枚数 特に 美味しく 残念 裏 失敗 番 最初 あご 出汁醤油

wordcloudで可視化

次にwordcloudというライブラリを使用して、単語の可視化を行ってみます。標準のまま使用すると日本語は豆腐(文字化け)になってしまうので、日本語フォントのパスを設定しています。

# wordcloudを描画する関数
def plot_wordcloud(text, mask=None, max_words=200, max_font_size=100, figure_size=(24.0,16.0), 
                   title = None, title_size=40):
    
    # 日本語に対応させるためにフォントのパスを指定
    f_path = '/Users/nozawa/Library/Fonts/ipaexm.ttf'
    
    # wordcloudの生成
    wordcloud = WordCloud(background_color='black',
                    stopwords = stopwords,
                    font_path= f_path, #日本語対応
                    max_words = max_words,
                    max_font_size = max_font_size, 
                    random_state = 42,
                    width=800, 
                    height=400,
                    mask = mask)
    wordcloud.generate(str(text))
    
    plt.figure(figsize=figure_size)
    plt.imshow(wordcloud)
    plt.title(title, fontdict={'size': title_size, 
                               'color': 'black', 
                               'verticalalignment': 'bottom'})
    plt.axis('off')
    plt.tight_layout()

# 評価3.5以上の口コミを表示
ramen_3p5over_df = store_df[store_df['score'] >= 3.5].merge(review_df, left_on = 'store_id', right_on = 'store_id')
plot_wordcloud(ramen_3p5over_df['lsbw_ex_sw'], title="Word Cloud of 新宿ラーメン 評価3.5以上")

# 評価3.3以下の口コミを表示
ramen_3p3under_df = store_df[store_df['score'] <= 3.3].merge(review_df, left_on = 'store_id', right_on = 'store_id')
plot_wordcloud(ramen_3p3under_df['lsbw_ex_sw'], title="Word Cloud of 新宿ラーメン 評価3.3以下")

f:id:taxa_program:20190114100558p:plain
評価3.5以上の単語をwordcloudで可視化

f:id:taxa_program:20190114100644p:plain
評価3.3以下の単語をwordcloudで可視化

棒グラフで頻出単語を可視化(N-gram=1, N-gram=2, N-gram=3)

ここでいうN-gramとは自然言語(テキスト)を連続するN個の文字、もしくはN個の単語単位で単語を切り出す手法のことです。
N=1のときユニグラム(unigram), N=2のときバイグラム(bigram), N=3のときトライグラム(trigram)と呼ばれます。

# テキストを空白で分割し、リストに変換する関数
def generate_ngrams(text, n_gram=1):
    token = [token for token in text.lower().split(" ") if token != "" if token not in stopwords] # ストップワード有り
    #token = [token for token in text.lower().split(" ") if token != ""] # ストップワード無し
    ngrams = zip(*[token[i:] for i in range(n_gram)])
    return [" ".join(ngram) for ngram in ngrams]

# 棒グラフ生成関数
def horizontal_bar_chart(df, color):
    trace = go.Bar(
        y=df["word"].values[::-1],
        x=df["wordcount"].values[::-1],
        showlegend=False,
        orientation = 'h',
        marker=dict(
            color=color,
        ),
    )
    return trace

N-gram = 1

N-gram = 1で描画してみます。

# -- 3.5以上の口コミ描画設定
freq_dict = defaultdict(int)
# 単語毎に出現回数をカウント
for sent in ramen_3p5over_df['lsbw']:
    for word in generate_ngrams(sent):
        freq_dict[word] += 1
        
# 1番目のカラムでソートしたデータフレームを生成
fd_sorted_3p5over = pd.DataFrame(sorted(freq_dict.items(), key=lambda x: x[1])[::-1])
# カラム名を設定 
fd_sorted_3p5over.columns = ["word", "wordcount"]
# グラフの設定
ramen_3p5over_chart = horizontal_bar_chart(fd_sorted_3p5over.head(50), 'blue')

# -- 3.3以下の口コミ描画設定
freq_dict = defaultdict(int)

# 単語毎に出現回数をカウント
for sent in ramen_3p3under_df['lsbw']:
    for word in generate_ngrams(sent):
        freq_dict[word] += 1
        
# 1番目のカラムでソートしたデータフレームを生成
fd_sorted_3p3under = pd.DataFrame(sorted(freq_dict.items(), key=lambda x: x[1])[::-1])
# カラム名を設定 
fd_sorted_3p3under.columns = ["word", "wordcount"]
# グラフの設定
ramen_3p3under_chart = horizontal_bar_chart(fd_sorted_3p3under.head(50), 'green')

# -- プロット
fig = tools.make_subplots(rows=1, cols=2, vertical_spacing=0.04,
                          subplot_titles=["3.5以上の口コミ", 
                                          "3.3以下の口コミ"])
fig.append_trace(ramen_3p5over_chart, 1, 1)
fig.append_trace(ramen_3p3under_chart, 1, 2)
fig['layout'].update(height=1200, width=1000, paper_bgcolor='rgb(233,233,233)', title="Word Count Plots N-gram = 1")
py.iplot(fig, filename='word-plots')

f:id:taxa_program:20190114101652p:plain
N-gram = 1 の場合の頻出ワード

パッと見ると、3.3以下の口コミには餃子、油そば、チャーハンなどの単語が現れていることがわかります。

正確に確認するために、3.5以上にしか存在しないワード、3.3以下にしか存在しないワードを抽出してみます。

# 3.5以上にしか存在しないワード
difference_data = fd_sorted_3p5over.head(50)[~fd_sorted_3p5over['word'].head(50).isin(fd_sorted_3p3under['word'].head(50))]
print(difference_data.word)

# 3.3以下にしか存在しないワード
difference_data = fd_sorted_3p3under.head(50)[~fd_sorted_3p3under['word'].head(50).isin(fd_sorted_3p5over['word'].head(50))]
print(difference_data.word)

f:id:taxa_program:20190114114528p:plain:w250f:id:taxa_program:20190114114608p:plain:w250
左:3.5以上にしか存在しないワード / 右:3.3以下にしか存在しないワード

上記から読み取るに、評価の高いラーメン屋は

  • 煮干し、鶏、魚介を何かしら感じる
  • 塩ラーメンがある?
  • 豚(チャーシュー)について言及される
  • 大盛りである
  • 餃子がない
  • セットメニューがない
  • こってりしているものは少ない
  • 替え玉がない
  • チャーハンがない
  • 油そばがない?
  • ライスがない?

などの傾向があるように見えます。

N-gram = 2

N-gram = 2でも同じように確認してみます。

f:id:taxa_program:20190114114956p:plain
N-gram = 2 の場合の頻出ワード

3.5以上にしか存在しないワード、3.3以下にしか存在しないワードを抽出してみます。

# 3.5以上にしか存在しないワード
difference_data = fd_sorted_3p5over.head(50)[~fd_sorted_3p5over['word'].head(50).isin(fd_sorted_3p3under['word'].head(50))]
print(difference_data.word)

# 3.3以下にしか存在しないワード
difference_data = fd_sorted_3p3under.head(50)[~fd_sorted_3p3under['word'].head(50).isin(fd_sorted_3p5over['word'].head(50))]
print(difference_data.word)

f:id:taxa_program:20190114115433p:plain:w250f:id:taxa_program:20190114115524p:plain:w250
左:3.5以上にしか存在しないワード / 右:3.3以下にしか存在しないワード

ここで分かることは、評価の高い店舗ではスープの味を多くの人が口コミとして投稿しています。その中でも大盛り無料などのワードが目立ちます。

一方で評価の低い店舗ではスープの味などにはあまり言及されていなく、替え玉無料や普通、味が濃い薄いという口コミが多いことがわかります。

N-gram = 3

n-gram = 3のグラフも表示してみます。

f:id:taxa_program:20190114121519p:plain
N-gram = 3 の場合の頻出ワード

ここにはURLなどのノイズが混ざってきてしまいました。だから前処理が大事だとあれほど言ったのに

TF-IDFで評価3.5以上と評価3.3以下の口コミにおける重要単語を比較してみる

最後に、TF-IDFで評価3.5以上と評価3.3以下の重要単語をみていこうと思います。

TF-IDFとは

Term Frequency - Inverse Document Frequencyの略で自然言語をベクトルで表現する方法のひとつであり、ある文書を特徴づける重要な単語を抽出したいときに有効な手法です。

詳しくは下記記事で簡単にまとめましたので、ご参照ください。

www.takapy.work

TF-IDF値の計算

まずは口コミの文字列を結合して、新しいデータフレームを生成します。

# 評価3.5以上の口コミテキストの結合
sum_text_3p5over = ''
for text in ramen_3p5over_df['lsbw_ex_sw']:
    sum_text_3p5over += text

# 評価3.3以下の口コミテキストの結合
sum_text_3p3under = ''
for text in ramen_3p3under_df['lsbw_ex_sw']:
    sum_text_3p3under += text

# 3.5以上と3.3以下の口コミを合体させたデータフレームの生成
merge_df = pd.DataFrame({'score': ['3.5以上', '3.3以下'],
                    'sum_review': [sum_text_3p5over, sum_text_3p3under]})

# 確認
merge_df

下記のようなデータフレームが生成されます。

- score sum_review
0 3.5以上 初 訪問 裏 徒歩 場所 平日 半 到着.........
1 3.3以下 西武新宿駅 麺 匠 竹虎 本店 ザ歌舞伎.........

これを用いて計算していきます。

# TF-IDFの計算
tfidf_vectorizer = TfidfVectorizer(
    min_df = 0.03,
    ngram_range=(1, 2) # n_gramのレンジを1と2で計算
)

# 文章内の全単語のTfidf値を取得
tfidf_matrix = tfidf_vectorizer.fit_transform(merge_df['sum_review'])

# index 順の単語リスト
terms = tfidf_vectorizer.get_feature_names()

# 単語毎のtfidf値配列を取得:TF-IDF 行列 (numpy の ndarray 形式で取得される)
# 1つ目の文書に対する、各単語のベクトル値
# 2つ目の文書に対する、各単語のベクトル値
# ・・・
# が取得できる(文書の数 * 全単語数)の配列になる。(toarray()で密行列に変換)
tfidfs = tfidf_matrix.toarray()

ここで取得できるTF-IDF行列の形状は

# 形状
tfidfs.shape

# -> (2, 405228)

となります。これは今回対象としてmerge_dfが2行の行列であり、それぞれの文書(3.5以上、3.3以下)に対して各単語がベクトルとして格納されていることを表しています。(単語数が405228あることも示しています)

特徴単語の抽出

上記で取得した行列から、特徴となる単語TOP50を取得してみます。

# TF-IDF の結果からi 番目のドキュメントの特徴的な上位 n 語を取り出す関数
# terms = 単語リスト
# tfidfs = TF-IDF行列
def extract_feature_words(terms, tfidfs, i, n):
    tfidf_array = tfidfs[i]
    top_n_idx = tfidf_array.argsort()[-n:][::-1]
    words = [terms[idx] for idx in top_n_idx]
    return words

# 新しい列を追加
merge_df['tfidf'] = ''

# 結果の出力
for i in range(0, len(merge_df['sum_review'])):
    print ('------------------------------------------')
    print (merge_df['score'][i])
    feature_words = extract_feature_words(terms, tfidfs, i, 50)
    print ('feature_words:')
    print(feature_words)
    merge_df.at[i, 'tfidf'] = feature_words

上記を実行すると、下記のようにそれぞれを特徴づける単語TOP50が出力されます。

------------------------------------------
3.5以上
feature_words:
['スープ', 'チャーシュー', '美味しい', '店内', 'カウンター', '注文', '醤油', '訪問', '味噌', '煮干し', '濃厚', 'メンマ', '食券', '普通', '好き', 'トッピング', '店員', '券売機', '出汁', 'あっさり', '煮干', '購入', '風味', '豚骨', '魚介', '多い', '味噌ラーメン', 'ネギ', '大盛り', '野菜', '香り', '印象', '海老', '旨味', '味わい', '提供', '美味い', '中太', 'オーダー', '食感', '見た目', '美味しかっ', '美味しく', '中華そば', '高い', '店舗', '一杯', '大盛', 'ワンタン', '雰囲気']
------------------------------------------
3.3以下
feature_words:
['スープ', '注文', 'チャーシュー', '店内', 'カウンター', '美味しい', '普通', '餃子', '醤油', '好き', 'セット', '訪問', '店員', '豚骨', 'トッピング', '替え玉', '油そば', 'あっさり', '野菜', 'こってり', 'チャーハン', '店舗', '食券', 'メンマ', 'ネギ', 'オーダー', '濃厚', '多い', '無料', '味噌', '券売機', 'ランチ', 'ご飯', '味噌ラーメン', 'サービス', '提供', '印象', 'ライス', '女性', '雰囲気', 'とんこつ', '大盛り', 'ニンニク', '美味しかっ', '美味しく', 'たっぷり', '定食', '博多', '卓上', 'お客さん']

ここから、それぞれにしか存在しないワードを確認してみます。

l1 = merge_df.at[0, 'tfidf']
l2 = merge_df.at[1, 'tfidf']

# 3.5以上にしか存在しない単語
result = set(l1) - set(l2)
print(result)

# 3.3以下にしか存在しない単語
result = set(l2) - set(l1)
print(result)

f:id:taxa_program:20190114124053p:plain:w100f:id:taxa_program:20190114124117p:plain:w100
左:3.5以上にしか存在しない重要単語 / 右:3.3以下にしか存在しない重要単語

結論

これからラーメン屋を出店する方は、

  • 出汁(スープ)にこだわる。特に「海老、煮干、魚介」の出汁がオススメ。
  • 中太麺にする。
  • 麺は少し固めの方が良い。
  • 大盛り無料 or ボリュームのある一杯にする。
  • 香り、風味、食感といった直接的な味以外も意識する。
  • ラーメンだけを提供する。(餃子やチャーハンなどのサイドメニューに手を出さない)
  • サービスの質よりも、ラーメンの味が重要。
  • ラーメンにワンタンを乗せる(?)

という部分を是非考慮していただけると、人々に受け入れられやすいラーメン屋になる可能性が高くなると思われます。本当かこれ。

実際にやってみて、店舗毎に取得する口コミ数に上限は設けたものの、やはり特定の店舗では投稿されている口コミの文字数が多く(これは口コミ数に上限を設けても意味がなく、その分総量は多くなってしまう)、少し偏ってしまったことは否めない結果となりました。

次にやりたいこと

ある文書を入力としてそれと近しい口コミが投稿されているラーメン屋を出力するなどできたら良いかなと思っています。

とりあえず一区切りついたので、Kaggleもやっていこうと思います。