はじめに
本記事では、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を更新していくのが実務的です。