Google Play データ活用

Google Playレビューのトピック抽出入門|LDAとBERTopicで話題を可視化する方法

2025年10月11日

この記事では、Google Playレビュー本文から「どのような話題が語られているか」を抽出するために、LDABERTopic を使ったトピック抽出の方法を解説します。

レビュー件数や平均スコア、感情スコアを見るだけでは、ユーザーが具体的に何について評価しているのかまでは分かりません。トピック抽出を使うと、「ガチャ」「バグ」「容量」「ストーリー」「操作性」など、レビュー内で語られている話題のまとまりを把握しやすくなります。

本記事では、取得済みのGoogle PlayレビューCSVを使い、日本語レビューの前処理、LDAによる軽量なトピック抽出、BERTopicによる文脈ベースのトピック抽出、月次推移の可視化までをPythonで実装します。

この記事でできること

  • Google Playレビュー本文から話題のまとまりを抽出する
  • LDAで軽量にトピック構造を確認する
  • BERTopicで文脈に近いレビューをクラスタリングする
  • トピックごとの上位語を確認する
  • レビューごとに主トピックを割り当てる
  • 月次でトピック比率の変化を可視化する
  • トピック抽出結果を解釈するときの注意点を理解する

想定読者

  • Google Playレビューで語られている話題を整理したい方
  • 低評価レビューや高評価レビューの論点を分類したい方
  • LDAやBERTopicを実データで試したい方
  • レビュー分析記事の「魅力」「不満点」「改善点」の材料を作りたい方
  • テキスト分析・感情分析の次に、より構造的な分析を行いたい方

事前準備

この記事では、Google Playレビューを取得済みで、CSVファイルとして保存されている前提で進めます。レビュー取得がまだの場合は、先に以下の記事を確認してください。

Google Playのデータ取得方法まとめ|google-play-scraperでアプリ情報・レビューを取得する

また、日本語レビューの前処理やストップワード調整については、以下の記事でも詳しく扱っています。

Google Playレビューのテキスト分析入門|ワードクラウド・共起ネットワーク・TF-IDFをPythonで可視化

今回使う主な列は以下です。

列名内容
contentレビュー本文
score星評価
atレビュー投稿日
reviewCreatedVersionレビュー投稿時のアプリバージョン

本記事では、例として reviews_genshin_paged.csv というCSVファイルを読み込みます。ファイル名は手元のデータに合わせて変更してください。

LDAとBERTopicの違い

LDAとBERTopicは、どちらもレビュー本文から話題のまとまりを抽出する手法ですが、考え方が異なります。

手法特徴向いている用途
LDA単語の出現パターンからトピックを推定する軽量な手法まず話題の当たりをつけたい場合、処理を軽くしたい場合
BERTopic文章の意味ベクトルを使って近いレビューをクラスタリングする手法文脈や言い換えも含めて話題をまとめたい場合

実務的には、まずLDAで軽く全体像を確認し、その後BERTopicでより細かい文脈のまとまりを見る流れが扱いやすいです。

使用ライブラリ

LDAには scikit-learn、日本語形態素解析には Janome、可視化には Plotly を使います。

pip install -U pandas janome scikit-learn plotly

BERTopicを使う場合は、以下もインストールします。BERTopic関連のライブラリは重めなので、まずLDAだけ試してから追加するのもおすすめです。

pip install -U bertopic[visualization] umap-learn hdbscan sentence-transformers

CSVを読み込む

まず、取得済みのレビューCSVを読み込みます。レビュー本文とスコアがない行は除外します。

import pandas as pd

csv_path = "reviews_genshin_paged.csv"

df = pd.read_csv(csv_path)

df = df.dropna(subset=["content", "score"]).copy()
df["score"] = df["score"].astype(int)

print(df[["content", "score"]].head())
print("rows:", len(df))

日本語レビューを前処理する

トピック抽出では、前処理の品質が結果に大きく影響します。ここでは、表記ゆれ補正、形態素解析、ストップワード除去を行い、レビュー本文をスペース区切りの単語列に変換します。

import re
import unicodedata
from collections import Counter

from janome.tokenizer import Tokenizer

tokenizer = Tokenizer(wakati=False)

NORMALIZE_MAP = {
    "出来る": "できる",
    "出来": "できる",
    "ゲー": "ゲーム",
    "スマ": "スマホ",
    "おもろい": "面白い",
    "言う": "いう",
    "カク": "カクつく",
}


def normalize_surface(text: str) -> str:
    text = unicodedata.normalize("NFKC", str(text))
    text = NORMALIZE_MAP.get(text, text)
    text = re.sub(r"ー{2,}", "ー", text)
    return text


def tokenize_ja(text: str) -> list[str]:
    text = str(text)
    text = re.sub(r"http\S+|www\.\S+", " ", text)
    text = re.sub(r"[0-90-9]+", " ", text)
    text = re.sub(r"[^\wぁ-んァ-ン一-龥ー]", " ", text)

    tokens = []

    for token in tokenizer.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:
            tokens.append(base)

    return tokens


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

print(df[["content", "tokens_raw"]].head())

ストップワードを設定する

トピック抽出では、どのレビューにも出る一般的な単語が混ざると、トピックの解釈が難しくなります。そこで、分析に使いにくい単語をストップワードとして除外します。

base_stopwords = {
    "する", "なる", "いる", "ある", "できる", "てる", "やる",
    "れる", "いう", "思う", "多い", "すぎる",
}

domain_stopwords = {
    "ゲーム", "ゲー", "スマホ", "面白い", "楽しい",
    "最高", "いい", "良い", "悪い", "最悪", "普通", "神",
}

keepwords = {
    "ガチャ", "課金", "キャラ", "ストーリー",
    "バグ", "音楽", "UI", "重い", "容量",
}


def learn_corpus_stopwords(tokens_series, df_threshold: float = 0.3, keepwords=None) -> set[str]:
    keepwords = keepwords or set()
    doc_count = len(tokens_series)
    df_counts = Counter()

    for tokens in tokens_series:
        df_counts.update(set(tokens))

    return {
        word
        for word, count in df_counts.items()
        if count / doc_count >= df_threshold and word not in keepwords
    }


auto_stopwords = learn_corpus_stopwords(
    df["tokens_raw"],
    df_threshold=0.3,
    keepwords=keepwords,
)

STOPWORDS = base_stopwords | domain_stopwords | auto_stopwords


def remove_stopwords(tokens: list[str]) -> list[str]:
    return [word for word in tokens if word not in STOPWORDS]


df["tokens"] = df["tokens_raw"].map(remove_stopwords)
df["doc"] = df["tokens"].map(lambda tokens: " ".join(tokens))

docs = df["doc"].tolist()

print("auto_stopwords:", sorted(auto_stopwords)[:50])
print(df[["tokens", "doc"]].head())

ストップワードは、初回実行で完成させるものではありません。トピック上位語を見ながら、解釈に使いにくい語を少しずつ追加していくのが実用的です。

LDAでトピックを抽出する

まずはLDAを使って、レビュー本文を複数のトピックに分解します。LDAでは、各レビューが複数トピックの混合として表現されます。

ここでは、トピック数を10に設定して実行します。実際には、5〜12程度で複数試し、上位語を見て解釈しやすい値を選ぶのがおすすめです。

import numpy as np

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation

def tokenizer_for_vectorizer(text: str) -> list[str]:
    return [
        word
        for word in text.split()
        if word and word not in STOPWORDS
    ]


vectorizer = CountVectorizer(
    tokenizer=tokenizer_for_vectorizer,
    token_pattern=None,
    min_df=5,
    max_df=0.9,
    ngram_range=(1, 1),
)

X = vectorizer.fit_transform(docs)
vocab = np.array(vectorizer.get_feature_names_out())

n_topics = 10

lda = LatentDirichletAllocation(
    n_components=n_topics,
    learning_method="batch",
    random_state=42,
    max_iter=20,
)

doc_topic_dist = lda.fit_transform(X)
topic_word_matrix = lda.components_

print("X shape:", X.shape)
print("doc_topic_dist shape:", doc_topic_dist.shape)

LDAのトピック上位語を確認する

各トピックについて、重みの大きい単語を確認します。トピック名は自動で決まるわけではないため、上位語と代表レビューを見ながら人間が解釈します。

top_n = 10
topic_rows = []

for topic_id in range(n_topics):
    top_idx = topic_word_matrix[topic_id].argsort()[::-1][:top_n]
    top_words = vocab[top_idx]

    topic_rows.append({
        "topic_id": topic_id,
        "top_words": ", ".join(top_words),
    })

topic_df = pd.DataFrame(topic_rows)

topic_df.to_csv(
    "lda_topics_k10.csv",
    index=False,
    encoding="utf-8-sig",
)

print(topic_df)

たとえば、上位語に「容量」「ダウンロード」「重い」が多いトピックであれば、容量や端末負荷に関する話題と解釈できます。「ガチャ」「課金」「キャラ」が多い場合は、ガチャ・課金・育成バランスに関するトピックと考えられます。

レビューごとの主トピックを保存する

各レビューについて、最も確率が高いトピックを主トピックとして割り当てます。

doc_topic = pd.DataFrame({
    "topic_id": doc_topic_dist.argmax(axis=1),
    "topic_prob": doc_topic_dist.max(axis=1),
})

df_lda = pd.concat(
    [
        df[["content", "score"]].reset_index(drop=True),
        doc_topic,
    ],
    axis=1,
)

df_lda.to_csv(
    "lda_document_topics.csv",
    index=False,
    encoding="utf-8-sig",
)

print(df_lda.head())

topic_prob は、そのレビューが主トピックにどの程度寄っているかを表す値です。値が低いレビューは、複数トピックが混ざっている可能性があります。

LDAのトピック比率を可視化する

次に、主トピックの割合をドーナツグラフで可視化します。どの話題がレビュー全体で多いかを確認できます。

import plotly.express as px

topic_ratio = (
    doc_topic["topic_id"]
    .value_counts(normalize=True)
    .sort_index()
    .rename("ratio")
    .reset_index()
)

topic_ratio.columns = ["topic_id", "ratio"]

topic_ratio_sorted = topic_ratio.sort_values(
    "ratio",
    ascending=False,
).copy()

topic_ratio_sorted["topic_id"] = topic_ratio_sorted["topic_id"].astype(str)

fig_ratio = px.pie(
    topic_ratio_sorted,
    names="topic_id",
    values="ratio",
    title="LDA:トピック比率",
    hole=0.6,
)

fig_ratio.update_traces(
    sort=False,
    textposition="inside",
    texttemplate="%{percent:.1%}",
)

fig_ratio.update_layout(
    template="plotly_white",
    height=700,
    legend_traceorder="normal",
)

fig_ratio.show()

fig_ratio.write_html(
    "lda_topic_ratio.html",
    include_plotlyjs="cdn",
    full_html=True,
)

トピック比率を見ることで、レビュー全体の中でどの話題が多いかを把握しやすくなります。ただし、トピックIDだけでは意味が分からないため、上位語や代表レビューを確認しながらトピック名を付ける必要があります。

LDAトピックの解釈例

トピック抽出では、上位語を見て人間が名前を付けます。以下は、ゲームレビューでよく見られる解釈例です。

上位語の例解釈例
ストーリー、キャラ、グラフィック、世界世界観・演出・キャラクター評価
ガチャ、課金、キャラ、武器、育成ガチャ・課金・育成バランス
容量、重い、ダウンロード、端末容量・端末負荷・パフォーマンス
操作、戦闘、ボタン、移動操作性・戦闘UI
ログイン、画面、バグ、アカウントログイン障害・不具合

トピック名は機械が自動で正しく付けてくれるわけではありません。上位語だけでなく、実際の代表レビューも確認しながら命名することが重要です。

BERTopicでトピックを抽出する

BERTopicは、文章を埋め込みベクトルに変換し、意味的に近いレビューをクラスタリングしてトピックを作る手法です。LDAよりも計算は重いですが、言い換えや文脈を反映しやすいという特徴があります。

以下は、多言語対応の軽量モデルを使う例です。初回実行時はモデルのダウンロードが発生するため、処理に時間がかかる場合があります。

from bertopic import BERTopic
from sentence_transformers import SentenceTransformer
from sklearn.feature_extraction.text import CountVectorizer

embedding_model = SentenceTransformer(
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)

vectorizer_model = CountVectorizer(
    tokenizer=str.split,
    token_pattern=None,
    min_df=5,
    max_df=0.9,
    ngram_range=(1, 2),
)

topic_model = BERTopic(
    embedding_model=embedding_model,
    language="multilingual",
    vectorizer_model=vectorizer_model,
    calculate_probabilities=True,
    verbose=True,
    nr_topics=None,
)

topics, probs = topic_model.fit_transform(docs)

BERTopicでは、-1 が外れ値トピックとして扱われます。-1 が多い場合は、レビューがうまくクラスタリングされていない可能性があるため、前処理、サンプル数、パラメータを見直します。

BERTopicのトピック一覧を確認する

学習後は、get_topic_info() でトピック一覧を確認できます。

topic_info = topic_model.get_topic_info()

topic_info.to_csv(
    "bertopic_topics.csv",
    index=False,
    encoding="utf-8-sig",
)

print(topic_info.head(20))

各トピックの上位語を確認したい場合は、以下のようにします。

for topic_id in topic_info["Topic"].head(10):
    if topic_id == -1:
        continue

    print("topic:", topic_id)
    print(topic_model.get_topic(topic_id)[:10])

BERTopicの文書割り当てを保存する

レビューごとのトピック割り当てもCSVとして保存しておくと、後から代表レビューを確認しやすくなります。

document_info = topic_model.get_document_info(docs)

df_bertopic = pd.concat(
    [
        df[["content", "score"]].reset_index(drop=True),
        document_info.reset_index(drop=True),
    ],
    axis=1,
)

df_bertopic.to_csv(
    "bertopic_document_topics.csv",
    index=False,
    encoding="utf-8-sig",
)

print(df_bertopic.head())

BERTopicの可視化をHTML保存する

BERTopicには、トピック間の距離、階層構造、上位語の棒グラフなどを可視化する機能があります。

fig_topics = topic_model.visualize_topics()
fig_topics.write_html(
    "bertopic_visualize_topics.html",
    include_plotlyjs="cdn",
    full_html=True,
)

fig_hierarchy = topic_model.visualize_hierarchy()
fig_hierarchy.write_html(
    "bertopic_hierarchy.html",
    include_plotlyjs="cdn",
    full_html=True,
)

fig_barchart = topic_model.visualize_barchart(top_n_topics=12)
fig_barchart.write_html(
    "bertopic_barchart.html",
    include_plotlyjs="cdn",
    full_html=True,
)

保存したHTMLファイルを開くと、トピック同士の距離感や階層構造、各トピックの上位語をインタラクティブに確認できます。

BERTopicの解釈例

BERTopicでは、LDAよりも固有名詞、端末環境、入力デバイス、イベント名などが分かれやすい場合があります。

上位語の例解釈例
キャラ、イベント、育成、武器キャラ育成・イベント進行
Android、コントローラー、対応端末・入力デバイス対応
PC、重い、動作、スペックPC環境・パフォーマンス
ダウンロード、容量、時間初回ダウンロード・データ容量
ログイン、アカウント、画面ログイン・アカウント関連トラブル

ただし、BERTopicも自動的に正しい意味を理解しているわけではありません。上位語と代表レビューを読み、人間がトピック名を付ける工程が必要です。

月次でトピック比率を可視化する

抽出したトピックを投稿日と組み合わせると、どの話題がいつ増えたかを確認できます。アップデート後に不具合トピックが増えたか、イベント期間にキャラ関連トピックが増えたか、といった見方ができます。

まず、レビュー投稿日から月を作成します。

df["at"] = pd.to_datetime(df["at"], errors="coerce")
df = df.dropna(subset=["at"]).copy()
df["month"] = df["at"].dt.to_period("M").astype(str)

LDAの主トピックを月次で集計します。

df_lda["month"] = df["month"].values

lda_month = (
    df_lda.groupby(["month", "topic_id"])
          .size()
          .reset_index(name="count")
)

lda_month["ratio"] = (
    lda_month.groupby("month")["count"]
             .transform(lambda values: values / values.sum())
)

fig_lda_month = px.area(
    lda_month,
    x="month",
    y="ratio",
    color="topic_id",
    title="LDA:トピック比率の月次推移",
    groupnorm="fraction",
)

fig_lda_month.update_layout(
    template="plotly_white",
    height=700,
)

fig_lda_month.show()

fig_lda_month.write_html(
    "lda_topics_by_month.html",
    include_plotlyjs="cdn",
    full_html=True,
)

BERTopicでも同様に、トピック比率を月次で集計できます。ここでは外れ値トピック -1 を除外しています。

df_bertopic["month"] = df["month"].values

bertopic_month = (
    df_bertopic[df_bertopic["Topic"] != -1]
    .groupby(["month", "Topic"])
    .size()
    .reset_index(name="count")
)

bertopic_month["ratio"] = (
    bertopic_month.groupby("month")["count"]
                  .transform(lambda values: values / values.sum())
)

fig_bertopic_month = px.area(
    bertopic_month,
    x="month",
    y="ratio",
    color="Topic",
    title="BERTopic:トピック比率の月次推移",
    groupnorm="fraction",
)

fig_bertopic_month.update_layout(
    template="plotly_white",
    height=700,
)

fig_bertopic_month.show()

fig_bertopic_month.write_html(
    "bertopic_topics_by_month.html",
    include_plotlyjs="cdn",
    full_html=True,
)

月次推移を見ると、特定の話題がいつ増えたかを把握しやすくなります。ただし、レビュー件数が少ない月は比率が大きくブレるため、件数も合わせて確認してください。

トピック抽出結果を見るときの注意点

トピック抽出は便利ですが、結果をそのまま正解として扱うのは危険です。以下の点に注意してください。

  • 教師なし学習のため、唯一の正解トピックがあるわけではありません。
  • LDAはトピック数を事前に決める必要があります。
  • BERTopicは外れ値トピック -1 が多くなる場合があります。
  • ストップワードや前処理の設定で結果が大きく変わります。
  • ランダム性により、実行ごとに結果が揺れる場合があります。
  • 上位語だけでなく、代表レビュー本文を確認して解釈する必要があります。
  • レビュー件数が少ない月のトピック比率はブレやすいです。

トピック抽出は、レビュー本文を読むための補助として使うのが基本です。上位語、代表レビュー、星評価、時期、バージョン情報を組み合わせて、話題の意味を解釈します。

トピック抽出の活用例

Google Playレビューのトピック抽出は、以下のような用途に活用できます。

  • 高評価レビューで多い魅力要素を整理する
  • 低評価レビューで多い不満点を分類する
  • アップデート後に増えた話題を確認する
  • バグ、容量、課金、操作性などの論点を分けて見る
  • レビュー分析記事の「改善点」「考察」の材料にする

たとえば、低評価レビューで「容量・重い・ダウンロード」トピックが多い場合、端末負荷や初回ダウンロードに関する不満が多い可能性があります。高評価レビューで「キャラ・ストーリー・音楽」トピックが多い場合、世界観や演出面が魅力として語られている可能性があります。

次に読む記事

まとめ

この記事では、Google Playレビュー本文を対象に、LDAとBERTopicでトピック抽出を行う方法を紹介しました。

LDAは軽量で試しやすく、レビュー全体にどのような話題があるかを俯瞰するのに向いています。BERTopicは文章の意味的な近さを使うため、端末環境、固有名詞、イベント、操作性などの具体的な文脈を分けやすい場合があります。

ただし、トピック抽出は自動で正解を出すものではありません。上位語と代表レビューを確認しながら、人間がトピック名を付け、星評価・時期・レビュー件数・感情スコアと組み合わせて解釈することが重要です。

-Google Play, データ活用