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

Google Playレビューのトピック抽出入門|LDAとBERTopicをPythonで試す

2025年10月11日

LDAとBERTopicの文書割当を、同じ入力CSV・同じ前処理・同じ実行記録に結び付けます。保存表を再読込して比較できるようにし、片方を省略した際に過去の成功ファイルを混ぜません。

最短手順:ファイルを用意し、実行して出力を開く

レビューCSVを似た話題のまとまりへ整理し、どのレビューがどのトピックへ入ったかを保存します。まずはLDAだけを試し、必要ならBERTopicを含む別実行を選びます。

掲載Python10ファイルのZIPをダウンロードし、「すべて展開」で任意の作業フォルダーへ展開します。ZIP内にサブフォルダーはありません。必要ファイルは gp_common.py、gp_text_analysis.py、gp_topics.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

入力は次のどちらか一方です。自分の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_topics.py "$csv"

上はLDAだけです。両手法の比較が目的なら、上の代わりに次の2行を実行します。モデルの初回ダウンロード・推論が必要で時間とメモリを使います。

python -m pip install bertopic sentence-transformers umap-learn hdbscan
python gp_topics.py "$csv" --bertopic

最後に表示されたtopicsフォルダーのmetadata.jsonを開き、statusesを先に確認します。架空6行は有効評価・本文・語を満たす分析対象が4行なのでLDAも省略され、BERTopicも10行未満で省略されます。これは省略確認用で、全分析成功の見本ではありません。件数条件を満たす自分のCSVでも話題の品質や成功を保証する条件ではありません。

まず開くファイル:知りたいことから選ぶ

実行できたか、どの行が対象か
metadata.jsonのstatuses・selected_rowsとselection.csvのselection。OK/SKIPPED/FAILEDを先に確認。

トピックに入ったレビューを読む
lda_assignments.csv/bertopic_assignments.csv:topic_id・row_id・content_raw。手法が完了した場合だけ生成。

代表語と月次の広がり
lda_terms.csv/bertopic_terms.csv:term・rank・weight。月次表は*_monthly.csv、生成された図は*_monthly.html。

CSVは表計算ソフトでも開けますが、保存し直すと型やファイルの確認値(hash)が変わる場合があります。元ファイルは上書きせず閲覧してください。図がない場合は、同じ実行フォルダーのmetadata.jsonにある skipped や手法別状態を確認します。

コードの前に:結果から何が分かるか

LDAは単語の出現パターンから話題を作り、このコードでは各レビューを最大の重みのトピックへ割り当てます。BERTopicは文を数値表現(embedding)に変え、近い文をまとめます。まず外部モデル不要のLDAで保存結果の読み方を確認し、文の近さも試したい場合だけBERTopicを選びます。topic_idは仮の番号、上位語は解釈の手掛かりです。元レビューを複数読み、異なる内容が混ざっていないか確かめてから仮の名前を考えます。BERTopicの−1はまとまりに入らなかった外れ値で、悪いレビューや不要データという意味ではありません。

掲載コードと詳しい条件(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、次のgp_topics.pyを同じフォルダーに保存します。日付欠損は文書割当から除外しません。本文が空、星評価が無効、前処理後に語が残らない行はselection.csvに理由を残します。これは従来の分析対象条件を維持したものです。

話題の割当と上位語を保存する:gp_topics.py

掲載コードを保存:gp_topics.py

import argparse
import json
import numpy as np
import pandas as pd
import plotly.express as px
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
from gp_common import Run
from gp_text_analysis import prepare

MODEL_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

def topics(path="outputs/google_play_reviews.csv", root="outputs/gp_runs", use_bertopic=False, embeddings=None):
    df, settings = prepare(path)
    reasons = []
    for _, r in df.iterrows():
        reasons.append("empty_text" if not r.content_raw.strip() else "invalid_score" if pd.isna(r.score_valid) else "empty_tokens" if not r.doc else "included")
    df["selection"] = reasons
    selected = df[df["selection"].eq("included")].copy()
    docs = selected["doc"].tolist()
    settings.update(lda={"min_reviews": 5, "max_topics": 10, "random_state": 42, "max_iter": 20, "min_df": 1, "max_df": 1.0},
                    bertopic={"enabled": use_bertopic, "min_reviews": 10, "embedding_model": MODEL_ID if embeddings is None else "provided_embeddings_not_model_inference",
                              "min_df": 1, "max_df": 1.0, "ngram_range": [1, 2], "umap_neighbors": min(15, max(1, len(docs)-1)),
                              "umap_components": 5, "umap_init": "random", "umap_metric": "cosine", "random_state": 42,
                              "hdbscan_min_cluster_size": 5, "calculate_probabilities": False})
    run = Run("topics", path, settings, root)
    audit = df.copy()
    for col in ["tokens", "tokens_raw"]:
        audit[col] = audit[col].map(lambda v: json.dumps(v, ensure_ascii=False))
    run.csv("selection.csv", audit)
    statuses = {}

    def save_assignment(method, assignments, terms):
        if len(assignments) != len(selected):
            raise ValueError("結果行数と分析対象が不一致。切り詰めず停止します。")
        assignment = pd.DataFrame({"row_id": selected["row_id"].tolist(), "topic_id": assignments})
        output = selected.drop(columns=["tokens", "tokens_raw"]).merge(assignment, on="row_id", validate="one_to_one")
        output["run_id"] = run.meta["run_id"]
        output["input_sha256"] = run.meta["input_sha256"]
        run.csv(method + "_assignments.csv", output)
        run.csv(method + "_terms.csv", pd.DataFrame(terms, columns=["topic_id", "rank", "term", "weight"]))
        monthly = output[output["month"].ne("") & output["topic_id"].ne(-1)].groupby(["date_basis", "month", "topic_id"]).size().reset_index(name="count")
        monthly["denominator"] = monthly.groupby(["date_basis", "month"])["count"].transform("sum")
        monthly["ratio"] = monthly["count"] / monthly["denominator"]
        run.csv(method + "_monthly.csv", monthly)
        if not monthly.empty:
            plot = monthly.copy(); plot["topic_id"] = plot["topic_id"].astype(str)
            run.figure(method + "_monthly.html", px.bar(plot, x="month", y="count", color="topic_id", facet_row="date_basis", title=method + ":有効日付・外れ値以外の割当件数"))
        else:
            run.meta["skipped"][method + "_monthly.html"] = "有効日付を持つ非外れ値0件"

    if len(docs) < 5 or len(set(docs)) < 2:
        statuses["lda"] = "SKIPPED_FEW_OR_IDENTICAL_DOCUMENTS"
    else:
        try:
            v = CountVectorizer(tokenizer=str.split, token_pattern=None, lowercase=False, min_df=1, max_df=1.0)
            x = v.fit_transform(docs)
            if x.shape[1] < 2:
                statuses["lda"] = "SKIPPED_FEW_TERMS"
            else:
                model = LatentDirichletAllocation(n_components=min(10, *x.shape), learning_method="batch", random_state=42, max_iter=20)
                dist = model.fit_transform(x); names = v.get_feature_names_out()
                terms = [(k, rank, names[i], float(weights[i])) for k, weights in enumerate(model.components_) for rank, i in enumerate(weights.argsort()[::-1][:10], 1)]
                save_assignment("lda", dist.argmax(axis=1), terms)
                statuses["lda"] = "OK"
        except ValueError as exc:
            statuses["lda"] = "FAILED: " + str(exc)
    if not use_bertopic:
        statuses["bertopic"] = "SKIPPED_NOT_REQUESTED"
    elif len(docs) < 10 or len(set(docs)) < 2:
        statuses["bertopic"] = "SKIPPED_FEW_OR_IDENTICAL_DOCUMENTS"
    else:
        try:
            from bertopic import BERTopic
            from umap import UMAP
            from hdbscan import HDBSCAN
            embedding_model = None
            if embeddings is None:
                from sentence_transformers import SentenceTransformer
                embedding_model = SentenceTransformer(MODEL_ID)
            elif len(embeddings) != len(docs):
                raise ValueError("embeddingsは分析対象と同じ行数・順序が必要です。")
            model = BERTopic(embedding_model=embedding_model, language="multilingual",
                vectorizer_model=CountVectorizer(tokenizer=str.split, token_pattern=None, lowercase=False, min_df=1, max_df=1.0, ngram_range=(1, 2)),
                umap_model=UMAP(n_neighbors=min(15, len(docs)-1), n_components=5, metric="cosine", init="random", random_state=42),
                hdbscan_model=HDBSCAN(min_cluster_size=5, prediction_data=True), calculate_probabilities=False)
            labels, _ = model.fit_transform(docs, embeddings=embeddings)
            terms = [(int(k), rank, term, float(weight)) for k in sorted(set(labels)) for rank, (term, weight) in enumerate(model.get_topic(k)[:10], 1)]
            save_assignment("bertopic", labels, terms)
            count = len(set(labels) - {-1})
            statuses["bertopic"] = "ALL_OUTLIERS" if count == 0 else "OK"
            jobs = []
            if count:
                jobs.append(("bertopic_barchart.html", lambda: model.visualize_barchart(top_n_topics=min(12, count))))
            if count >= 2:
                jobs.append(("bertopic_hierarchy.html", model.visualize_hierarchy))
            if count >= 4:
                jobs.append(("bertopic_distance.html", model.visualize_topics))
            for name, make in jobs:
                try:
                    run.figure(name, make())
                except (ValueError, TypeError, IndexError) as exc:
                    run.meta["skipped"][name] = type(exc).__name__ + ": " + str(exc)
            if count < 2:
                run.meta["skipped"]["bertopic_hierarchy.html"] = "非外れ値トピック2未満"
            if count < 4:
                run.meta["skipped"]["bertopic_distance.html"] = "非外れ値トピック4未満"
        except Exception as exc:
            statuses["bertopic"] = "FAILED: " + type(exc).__name__ + ": " + str(exc)
    return run.finish(statuses=statuses, input_rows=len(df), selected_rows=len(selected),
                      selected_row_ids=selected["row_id"].tolist(), models_saved=False)

if __name__ == "__main__":
    p = argparse.ArgumentParser(); p.add_argument("input", nargs="?", default="outputs/google_play_reviews.csv"); p.add_argument("--bertopic", action="store_true")
    a = p.parse_args(); topics(a.input, use_bertopic=a.bertopic)

省略・失敗とモデルの扱い

LDAは5文書未満・語彙2語未満・同一文書のみ、BERTopicは10文書未満・同一文書のみを省略します。これは教材の運用基準であり信頼性の保証ではありません。BERTopicのCountVectorizerはトピックごとに連結した文書に適用されるため、min_df=1・max_df=1.0を維持します。全件外れ値でも割当を残し、非外れ値の月次・図は省略します。階層図は2トピック以上、距離図は4以上を条件にし、図の入力条件を満たさない場合は理由を保存します。

metadataには前処理前の入力hash、対象row_id、設定・版・モデル識別子・手法別状態・今回の出力hashを残します。FAILEDは「話題なし」ではなく環境や計算の失敗です。通常のBERTopicは多言語モデルを必要に応じてダウンロードして推論します。本記事の改訂検証は実LDAと合成embeddingによるBERTopic本体までで、多言語モデルの推論確認とは別です。

トピックIDは別学習間で共通の意味を持ちません。CSVだけの比較にはモデル不要ですが、モデル固有の距離図等を再生成するには同じ学習済みモデルが必要です。この例はモデル全体を保存せず、その実行時に生成できた図と表を保存します。BERTopicの保存仕様も確認してください。

保存結果のhashを確認して比較する。過去CSVへ今からhashを付けても、当時と同じ入力だった証明にはなりません。

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