Google Playデータ取得 データ取得

Google Playレビューのテキスト分析入門|特徴語・共起・TF-IDFをPythonで見る

2025年10月11日

レビュー本文を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 $csv

True を確認してから進みます。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

掲載コードを保存: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に入れると残ります。この例は実レビューの分析結果ではありません。設定変更の効果は上位語だけでなく元本文も確認してください。

-Google Playデータ取得, データ取得