レビュー本文をJanomeで分かち書きし、特徴語・共起・評価別TF-IDFを保存します。単語から元レビューへ戻れることを重視し、辞書変更は元のtokens_rawから再計算します。
最短手順:ファイルを用意し、実行して出力を開く
レビューCSVからよく使われる語、同じレビューに現れる語の組、星評価ごとの特徴語を調べます。取得済みCSVの分析で、Google Playへの通信はありません。
掲載Python10ファイルのZIPをダウンロードし、「すべて展開」で任意の作業フォルダーへ展開します。ZIP内にサブフォルダーはありません。必要ファイルは gp_common.py、gp_text_analysis.py、plot_gp_saved.py(架空データならmake_gp_sample.py) です。使わないファイルは実行しません。以下の長いコードを手で保存する場合も、同じ名前・同じフォルダーへ置きます。
Python導入済みのWindows PowerShellで実行します。最初の入力欄へ、展開後の gp_common.py が見えるフォルダーの絶対パスを貼り付けてEnterを押してください。引用符は入力不要です。以降もその作業フォルダーから実行します。インストールには通信が必要ですが、その後の保存CSV分析とは別です。
$work = Read-Host "ZIPを展開したフォルダーの絶対パス"
Set-Location -LiteralPath $work
Get-ChildItem *.py
python -m pip install pandas Janome scikit-learn plotly wordcloud入力は次のどちらか一方です。自分のCSVなら 取得・保存記事で確認したCSV のパスを指定します(必要列はcontent・score・at)。練習なら次だけ実行し、最後に表示された outputs/gp_runs/fictional-sample-… をコピーします。
python make_gp_sample.py次の入力欄にはCSVファイルのパスを貼り付けます。架空データの場合は、今コピーしたフォルダー名の末尾へ /google_play_reviews.csv を付けてください。… は実際の表示ではなく省略記号で、そのまま入力しません。自分のCSVなら例として固定した実行名を使わず、実在するファイルを指定します。
$csv = Read-Host "分析するCSVファイルのパス"
Test-Path -LiteralPath $csvTrue を確認してから進みます。False ならフォルダーではなくCSVまで指定したか、作業フォルダーが正しいかを確認してください。別のPowerShellを開いた場合は作業フォルダーと $csv を設定し直します。
python gp_text_analysis.py "$csv"処理後、最後の行に今回の実行フォルダー(review-aggregates/text/sentimentから始まる名前)が表示されます。その行だけをコピーして、次の入力欄へ貼り付けます。これはCSVファイルではなくmetadata.jsonを含むフォルダーです。
$run = Read-Host "直前に表示された実行フォルダーのパス"
python plot_gp_saved.py "$run"さらに新しいsaved-plotsフォルダーが表示されます。元の集計CSVは先ほどのフォルダー、HTMLはこの新しいフォルダーです。HTMLをダブルクリックするとブラウザで開きます。0件・語彙なし等で図が省略された場合はmetadata.jsonのskippedを確認し、全図が揃うと決めつけないでください。
まず開くファイル:知りたいことから選ぶ
よく現れる語を探す
term_frequency.csv:term・occurrences・review_count。まずreview_countも一緒に確認。
特徴語を含む元レビューを読む
review_tokens.csvのtokens列で語を探し、同じ行のcontent_rawを読む。row_id/reviewIdで元の行へ戻れます。
共起・評価別の特徴語
cooccurrence.htmlとcooccurrence_edges.csv(review_cooccurrence_count)、tfidf.csv(score・term・mean_tfidf・document_count)。省略理由はtfidf_status.csv。
CSVは表計算ソフトでも開けますが、保存し直すと型やファイルの確認値(hash)が変わる場合があります。元ファイルは上書きせず閲覧してください。図がない場合は、同じ実行フォルダーのmetadata.jsonにある skipped や手法別状態を確認します。
コードの前に:結果から何が分かるか
頻度は「何回/何レビューに現れたか」、共起は「同じレビューに一緒に現れたか」、TF-IDFは「その評価群で特徴を探すための重み」です。共起は因果関係や語順を表しません。前回の架空例で「ゲーム」が5行すべてにあれば頻度は高くても区別の手掛かりになりにくく、自動除外の対象になります。KEEPWORDSで残した場合も、評価理由は元のcontent_rawで確認します。TF-IDFは星別に別計算なので星1と星5の値をそのまま大小比較しません。
掲載コードと詳しい条件(ZIPと同じ内容)
入力の基本は outputs/google_play_reviews.csv(取得・保存手順)。content・score・atが必要です。reviewId・バージョンは文字列、row_idは入力CSVの0始まり行番号として保持します。元本文・元日時・元評価と、分析用の値を分けます。日時にオフセットがある行はUTCで集計し、旧CSVの時刻帯不明な行は壁時計の年月を別集団として扱います。lang・countryから時刻帯を推定しません。
gp_common.pyと次のgp_text_analysis.pyを同じフォルダーに置きます。STOPWORDS・KEEPWORDS・AUTO_RATIOは実行前に変更します。変更後はファイル全体を再実行し、tokens_raw→除外語適用→星評価別の集合→全出力を更新します。
語と元レビューを対応付ける:gp_text_analysis.py
import argparse
import json
import re
import unicodedata
from collections import Counter
from itertools import combinations
from pathlib import Path
import pandas as pd
from janome.tokenizer import Tokenizer
from sklearn.feature_extraction.text import TfidfVectorizer
from gp_common import Run, load_reviews
# ここを変えてから、このファイル全体を再実行する。古いtokensを使い回さない。
STOPWORDS = {"する", "ある", "いる", "なる", "こと", "もの"}
KEEPWORDS = {"ガチャ", "課金"}
AUTO_MIN_REVIEWS, AUTO_RATIO = 5, 0.8
FONT_PATH = "C:/Windows/Fonts/meiryo.ttc"
NORMALIZATION = "NFKC; URL removal; nouns/adjectives/verbs; base form; not numeric"
def tokenize(text, tokenizer):
text = re.sub(r"https?://\S+", " ", unicodedata.normalize("NFKC", str(text)))
words = []
for token in tokenizer.tokenize(text):
word = token.base_form if token.base_form != "*" else token.surface
if token.part_of_speech.split(",")[0] in {"名詞", "形容詞", "動詞"} and not word.isnumeric() and word.strip():
words.append(word)
return words
def prepare(path, stopwords=STOPWORDS, keepwords=KEEPWORDS, auto_ratio=AUTO_RATIO):
df = load_reviews(path)
tokenizer = Tokenizer()
df["tokens_raw"] = [tokenize(t, tokenizer) for t in df["content_raw"]]
nonempty = int(df["content_raw"].str.strip().ne("").sum())
documents = Counter(w for tokens in df["tokens_raw"] for w in set(tokens))
auto = {w for w, n in documents.items() if nonempty >= AUTO_MIN_REVIEWS and n / nonempty >= auto_ratio} - keepwords
excluded = (set(stopwords) | auto) - keepwords
df["tokens"] = [[w for w in tokens if w not in excluded] for tokens in df["tokens_raw"]]
df["doc"] = df["tokens"].map(" ".join)
settings = dict(normalization=NORMALIZATION, stopwords=sorted(stopwords), keepwords=sorted(keepwords),
auto_min_reviews=AUTO_MIN_REVIEWS, auto_ratio=auto_ratio, auto_stopwords=sorted(auto),
auto_denominator=nonempty, excluded=sorted(excluded))
return df, settings
def text_analysis(path="outputs/google_play_reviews.csv", root="outputs/gp_runs", stopwords=STOPWORDS):
df, settings = prepare(path, stopwords=stopwords)
run = Run("text", path, settings, root)
audit = df.copy()
for name in ["tokens_raw", "tokens"]:
audit[name] = audit[name].map(lambda x: json.dumps(x, ensure_ascii=False))
run.csv("review_tokens.csv", audit)
occurrence = Counter(w for tokens in df["tokens"] for w in tokens)
documents = Counter(w for tokens in df["tokens"] for w in set(tokens))
terms = pd.DataFrame([(w, n, documents[w]) for w, n in occurrence.most_common()], columns=["term", "occurrences", "review_count"])
run.csv("term_frequency.csv", terms)
run.csv("cooccurrence_nodes.csv", terms)
edges = Counter(pair for tokens in df["tokens"] for pair in combinations(sorted(set(tokens)), 2))
run.csv("cooccurrence_edges.csv", pd.DataFrame([(a, b, n) for (a, b), n in edges.items()], columns=["source", "target", "review_cooccurrence_count"]))
if occurrence:
import math
import plotly.graph_objects as go
nodes = [w for w, _ in occurrence.most_common(20)]
positions = {w: (math.cos(2*math.pi*i/len(nodes)), math.sin(2*math.pi*i/len(nodes))) for i, w in enumerate(nodes)}
fig = go.Figure()
for (a, b), n in edges.items():
if a in positions and b in positions:
fig.add_trace(go.Scatter(x=[positions[a][0], positions[b][0]], y=[positions[a][1], positions[b][1]],
mode="lines", line=dict(width=min(5, n)), hovertemplate=f"{a} / {b}: {n}レビュー<extra></extra>", showlegend=False))
fig.add_trace(go.Scatter(x=[positions[w][0] for w in nodes], y=[positions[w][1] for w in nodes],
mode="markers+text", text=nodes, textposition="top center", hovertext=[f"{w}: 延べ{occurrence[w]}回 / {documents[w]}レビュー" for w in nodes], showlegend=False))
fig.update_layout(title="共起:上位20語(円形の配置に距離の意味はありません)", xaxis=dict(visible=False), yaxis=dict(visible=False))
run.figure("cooccurrence.html", fig)
else:
run.meta["skipped"]["cooccurrence.html"] = "語彙なし"
rows, statuses = [], []
for score in range(1, 6):
group = df.loc[df["score_valid"].eq(score) & df["doc"].ne(""), "doc"].tolist()
n = len(group); min_df = 1 if n < 5 else 2; max_df = 1.0 if n < 5 else 0.95
status = "SKIPPED_NO_TOKENS"
if n:
try:
v = TfidfVectorizer(tokenizer=str.split, token_pattern=None, lowercase=False, min_df=min_df, max_df=max_df)
matrix = v.fit_transform(group)
means = matrix.mean(axis=0).A1
for rank, i in enumerate(means.argsort()[::-1][:20], 1):
rows.append((score, v.get_feature_names_out()[i], rank, float(means[i]), n))
status = "OK_EXPLORATORY_SMALL" if n < 5 else "OK"
except ValueError as exc:
if not any(t in str(exc).lower() for t in ["empty vocabulary", "no terms remain", "max_df corresponds"]):
raise
status = "SKIPPED_EMPTY_VOCABULARY"
statuses.append((score, n, min_df, max_df, status))
run.csv("tfidf.csv", pd.DataFrame(rows, columns=["score", "term", "rank", "mean_tfidf", "document_count"]))
run.csv("tfidf_status.csv", pd.DataFrame(statuses, columns=["score", "document_count", "min_df", "max_df", "status"]))
if occurrence and Path(FONT_PATH).is_file():
try:
from wordcloud import WordCloud
WordCloud(font_path=FONT_PATH, width=900, height=500, background_color="white").generate_from_frequencies(occurrence).to_file(str(run.path / "wordcloud.png"))
run.record("wordcloud.png")
except ImportError:
run.meta["skipped"]["wordcloud.png"] = "wordcloud未導入。必要ならpip install wordcloud。CSVは保存済み。"
else:
run.meta["skipped"]["wordcloud.png"] = "語彙なし、または日本語フォント未検出。FONT_PATHを実在する日本語フォントへ設定。"
return run.finish(input_rows=len(df), empty_text=int(df["content_raw"].str.strip().eq("").sum()))
if __name__ == "__main__":
p = argparse.ArgumentParser(); p.add_argument("input", nargs="?", default="outputs/google_play_reviews.csv")
text_analysis(p.parse_args().input)出力の読み方と再表示
review_tokens.csvには元本文・row_id・reviewId・tokens_raw・tokensを保存します。term_frequency.csvのoccurrencesは延べ出現回数、review_countはその語を含むレビュー件数です。共起エッジの重みは同じレビュー内に両語があった件数で、出現順や因果関係ではありません。tfidf.csvの値は星評価ごとに別学習した平均TF-IDFであり、星1の0.2と星5の0.3を直接比較する尺度ではありません。
語彙が残る1〜4件はmin_df=1・max_df=1.0、5件以上はmin_df=2・max_df=0.95です。空語彙は省略理由をtfidf_status.csvに保存します。少数例をアプリ全体の強い傾向とは断定しません。日本語フォントがなければFONT_PATHを修正するか、ワードクラウドだけ省略します。他のCSVは保存します。
除外設定の変更前後を小さな例で確認
from collections import Counter
tokens_raw = [["ゲーム", "音楽"], ["ゲーム", "物語"], ["ゲーム", "操作"], ["ゲーム", "音楽"], ["ゲーム", "物語"]]
df_counts = Counter(w for row in tokens_raw for w in set(row))
auto = {w for w, n in df_counts.items() if n / len(tokens_raw) >= 0.8}
for keepwords in [set(), {"ゲーム"}]:
tokens = [[w for w in row if w not in (auto - keepwords)] for row in tokens_raw]
print("keepwords:", keepwords, "結果:", tokens)自動除外では5行すべてにある「ゲーム」が消えますが、KEEPWORDSに入れると残ります。この例は実レビューの分析結果ではありません。設定変更の効果は上位語だけでなく元本文も確認してください。