Google Play データ活用

Google Playレビューのテキスト分析:ワードクラウド・共起ネットワーク・TF-IDF【Python】

はじめに

本記事では、Google Playのレビュー本文を対象にワードクラウド共起ネットワークTF-IDFの3手法で特徴を可視化します。
形態素解析・ストップワード・表記ゆれの処理を最適化し、ノイズの少ない結果を得る完全版です。

前提:レビュー取得はデータ取得編を参照。本記事はローカルCSV(例:reviews_genshin_paged.csv)を読み込みます。


0. 各手法の概要

  • ワードクラウド:頻出語を俯瞰。感覚的に全体傾向を把握するのに適します。
  • 共起ネットワーク:同時出現関係から論点構造を把握。どの語が一緒に語られるかを可視化。
  • TF-IDF:スコア別(★1〜★5)に相対的に特徴的な語を抽出。不満/称賛の要因分析に最適。

1. セットアップ

pip install pandas janome scikit-learn wordcloud networkx plotly
import re, math, itertools, unicodedata, os
from collections import Counter, defaultdict
import pandas as pd
from janome.tokenizer import Tokenizer
from wordcloud import WordCloud
from sklearn.feature_extraction.text import TfidfVectorizer
import networkx as nx
import plotly.graph_objects as go
import numpy as np

# データ読み込み
df = pd.read_csv("reviews_genshin_paged.csv")
df = df.dropna(subset=["content","score"]).copy()
df["score"] = df["score"].astype(int)

# =============================
# 表記ゆれ統一・形態素解析設定
# =============================
t = Tokenizer(wakati=False)

NORMALIZE_MAP = {
    "出来る":"できる", "出来":"できる", "ゲー":"ゲーム", "スマ":"スマホ",
    "おもろい":"面白い", "言う":"いう", "カク":"カクつく"
}
def normalize_surface(s: str) -> str:
    s = unicodedata.normalize("NFKC", s)
    s = NORMALIZE_MAP.get(s, s)
    s = re.sub(r"ー{2,}", "ー", s)
    return s

def tokenize_ja(text: str) -> list[str]:
    text = re.sub(r"http\S+|www\.\S+"," ", str(text))
    text = re.sub(r"[0-90-9]+"," ", text)
    text = re.sub(r"[^\wぁ-んァ-ン一-龥ー]"," ", text)
    toks = []
    for token in t.tokenize(text):
        base = token.base_form if token.base_form != "*" else token.surface
        base = normalize_surface(base)
        pos = token.part_of_speech.split(",")[0]
        if pos in {"名詞","形容詞","動詞"} and base and len(base) > 1:
            toks.append(base)
    return toks

df["tokens_raw"] = df["content"].map(tokenize_ja)

# =============================
# ストップワード設定
# =============================
base_stop = {
    "する","なる","いる","ある","できる","てる","やる","れる","出来る",
    "いう","思う","多い","すぎる"
}
domain_stop_generic = {
    "ゲーム","ゲー","スマ","スマホ","面白い","楽しい","最高","いい","良い",
    "悪い","最悪","普通","神"
}
domain_keepwords = {"ガチャ","課金","キャラ","ストーリー","バグ","音楽","UI","重い","容量"}

# 自動学習ストップワード
def learn_corpus_stopwords(tokens_series, df_threshold=0.3, keepwords=None):
    keepwords = keepwords or set()
    doc_n = len(tokens_series)
    df_counts = Counter()
    for toks in tokens_series:
        df_counts.update(set(toks))
    return {w for w,c in df_counts.items() if c/doc_n >= df_threshold and w not in keepwords}

auto_stop = learn_corpus_stopwords(df["tokens_raw"], 0.3, domain_keepwords)
STOPWORDS = base_stop | domain_stop_generic | auto_stop

# トークン正規化+ストップワード除去
def normalize_tokens(tokens):
    return [w for w in tokens if w not in STOPWORDS]

df["tokens"] = df["tokens_raw"].map(normalize_tokens)
dfs = {s: d.copy() for s,d in df.groupby("score")}

※ STOPWORDSには汎用語・ドメイン語・自動学習語を統合。
「表記ゆれ正規化」→「形態素解析」→「STOPWORDS除去」の順で処理しています。


注意:STOPWORDSの調整について

Google Playレビューはアプリやジャンルごとに語彙の傾向が異なるため、初回実行で完全にクリーンな結果が得られるとは限りません。
特定のタイトルでは「やる」「すぎる」「重い」「課金」などがノイズとして頻出することがあります。

その場合は、出力結果を確認しながら次のようにストップワードを追加していくと改善されます。

# 例:TF-IDF上位に毎回出てくる語を除外する
STOPWORDS |= {"やる","すぎる","重い","容量","課金"}

STOPWORDSを見直すたびに、ワードクラウドやTF-IDFの結果が安定し、より意味のある特徴語(内容語)が抽出されるようになります。
分析対象が変わる場合も、このストップワードリストを「育てる」イメージで運用するのがおすすめです。


2. ワードクラウド(スコア別)

os.makedirs("wc_images", exist_ok=True)
FONT_PATH = "C:/Windows/Fonts/meiryo.ttc"

def wordcloud_from_tokens(tokens, out_path, max_words=200):
    freq = Counter(tokens)
    wc = WordCloud(width=1200, height=800, background_color="white",
                   font_path=FONT_PATH, collocations=False, max_words=max_words)
    wc.generate_from_frequencies(freq)
    wc.to_file(out_path)

for s in [1,2,3,4,5]:
    toks = list(itertools.chain.from_iterable(dfs.get(s,pd.DataFrame()).get("tokens",[])))
    if not toks: continue
    out = f"wc_images/wordcloud_score_{s}.png"
    wordcloud_from_tokens(toks, out)
    print(f"score={s}: {out} を出力")

3. 共起ネットワーク(見やすさ重視)

def build_cooccurrence(tokens_list, window=8, min_edge=8, min_node_freq=10, top_k_nodes=80):
    pair_counts = defaultdict(int)
    node_counts = Counter()
    for tokens in tokens_list:
        node_counts.update(tokens)
        for i in range(len(tokens)):
            for j in range(i+1, min(i+window, len(tokens))):
                a,b = sorted([tokens[i], tokens[j]])
                if a!=b:
                    pair_counts[(a,b)] += 1
    valid_nodes = {w for w,c in node_counts.items() if c>=min_node_freq}
    edges = [(a,b,c) for (a,b),c in pair_counts.items() if c>=min_edge and a in valid_nodes and b in valid_nodes]
    top_nodes = set([w for w,_ in node_counts.most_common(top_k_nodes)])
    valid_nodes &= top_nodes
    edges = [(a,b,c) for (a,b,c) in edges if a in valid_nodes and b in valid_nodes]
    G = nx.Graph()
    for w in valid_nodes: G.add_node(w, size=node_counts[w])
    for a,b,c in edges: G.add_edge(a,b,weight=c)
    return G

def plot_network_plotly(G, title="共起ネットワーク", height=900):
    if len(G.nodes)==0: return None
    k = 1.2/(math.sqrt(len(G.nodes))+1e-6)
    pos = nx.spring_layout(G,k=k,seed=42,iterations=100)
    edge_x,edge_y=[],[]
    for u,v,_ in G.edges(data=True):
        x0,y0=pos[u]; x1,y1=pos[v]
        edge_x+=[x0,x1,None]; edge_y+=[y0,y1,None]
    edge_trace=go.Scatter(x=edge_x,y=edge_y,mode="lines",line=dict(width=0.6,color="#bbb"),hoverinfo="none")
    node_x,node_y,hover,sizes=[],[],[],[]
    for n,d in G.nodes(data=True):
        node_x.append(pos[n][0]); node_y.append(pos[n][1])
        hover.append(f"{n}(freq={d['size']})")
        sizes.append(6+2*math.log1p(d['size']))
    node_trace=go.Scatter(x=node_x,y=node_y,mode="markers",hovertext=hover,hoverinfo="text",
                          marker=dict(size=sizes,line=dict(width=0.6,color="#333"),color="#4c78a8"))
    fig=go.Figure([edge_trace,node_trace])
    fig.update_layout(title=title,template="plotly_white",showlegend=False,height=height,
                      xaxis=dict(visible=False),yaxis=dict(visible=False),
                      margin=dict(l=10,r=10,t=60,b=10))
    fig.show()
    return fig

os.makedirs("co_graphs",exist_ok=True)
for s in [1,2,3,4,5]:
    sub=dfs.get(s,pd.DataFrame())
    tokens_list=sub["tokens"].tolist() if "tokens" in sub else []
    if not tokens_list: continue
    G=build_cooccurrence(tokens_list,window=8,min_edge=8,min_node_freq=10,top_k_nodes=80)
    fig=plot_network_plotly(G,f"共起ネットワーク(score={s})")
    if fig: fig.write_html(f"co_graphs/co_network_score_{s}.html",include_plotlyjs="cdn")

4. TF-IDF(★1〜★5比較)

def tokenizer_for_vectorizer(text):
    toks = tokenize_ja(text)
    return [t for t in toks if t not in STOPWORDS]

vectorizer = TfidfVectorizer(
    tokenizer=tokenizer_for_vectorizer,
    token_pattern=None,
    max_features=20000,
    min_df=5
    # ngram_range=(1,2)  # 2-gram併用したい場合はアンコメント
)

topN = 20
tops = {}
for s in [1,2,3,4,5]:
    sub = df[df["score"]==s]["content"].fillna("")
    if len(sub)==0:
        tops[s]=[]; continue
    X = vectorizer.fit_transform(sub)
    vocab = np.array(vectorizer.get_feature_names_out())
    mean = X.mean(axis=0).A1
    idx = np.argsort(mean)[::-1][:topN]
    tops[s] = list(vocab[idx])

rows=[]
for i in range(topN):
    row={"Rank":i+1}
    for s in [1,2,3,4,5]:
        row[f"score{s}"]=tops[s][i] if i<len(tops[s]) else ""
    rows.append(row)
tfidf_table=pd.DataFrame(rows,columns=["Rank","score1","score2","score3","score4","score5"])
tfidf_table.to_csv("tfidf_top20_score1to5.csv",index=False,encoding="utf-8-sig")
print(tfidf_table.head(10))

※STOPWORDSを完全適用。必要に応じてmin_dfを調整、またはngram_range=(1,2)で複合語分析も可能です。


データ仕様と注意点

  • Google Playレビューはユーザー1人1件のみ保持(再投稿時は上書き)。
  • 本文分析は取得時点スナップショットでの傾向分析として扱う。
  • appVersion欠損レビューはバージョン別分析で除外推奨。
  • STOPWORDSは汎用+ドメイン+自動学習+手動追加の4層構造でメンテ。


まとめ

  • 表記ゆれを吸収して安定した形態素解析を実現。
  • STOPWORDSを段階的に調整することで、意味のないワードの頻出を防ぎ、精度を高められる。
  • 共起ネットワークは上位語間のみ表示し、視認性を確保。
  • TF-IDF表で★別の特徴語を比較し、評価の違いを直感的に把握。

※初回実行で完全な結果を得ようとせず、数回のチューニングを前提にSTOPWORDSを更新していくのが実務的です。

-Google Play, データ活用