西武ライオンズの順位とGoogle検索ボリュームの関係は?-状態空間モデルを用いた時系列分析-

プロ野球チーム「西武ライオンズ」の順位とファンの関心度の関係をデータで検証しました。順位が低いと、ファンの関心度も下がるのでしょうか?2022〜2024年のGoogle検索ボリュームをもとに、状態空間モデルを用いて分析しました。
1 はじめに
プロ野球ファンであれば、応援する球団の成績と自分の熱量の関係に気づいたことがあるのではないでしょうか。チームが優勝争いをしている時は連日の試合結果をチェックする一方で、下位に沈んでいる時は少し距離を置いてしまう…そんな経験は珍しくないでしょう。
西武ライオンズファンである筆者も、ここ2年間はやや控えめに成績をチェックする日々でした。。。
そこで本記事では「西武ライオンズの順位」と「ファンの関心度」の関係性について、Googleでの検索データをもとに分析していきます。ファンの関心度を示す指標としてGoogleトレンドの検索ボリュームを用いて、チームの順位変動がどのように検索数に影響するかを探ります。
分析対象は2022年2月から2025年1月までの約3年間です。この期間は西武ライオンズが上位争いをした年もあれば、残念ながら下位に沈んだ年もあるため、順位と検索数の関係を見るのに適したデータが得られています。
それでは、西武ライオンズの成績とファンの関心度の関係について、データをもとに検証していきましょう。
2 分析の概要
明らかにしたいこと
本ブログ記事の目的は、「西武ライオンズ」の順位と同チームへの関心度がどのように関係しているかを明らかにすることです。因果関係を厳密に特定することは目的としていませんが、順位の変動と検索ボリュームの変動の関係を明示化し、ファンの関心がどのように変動するかを理解するための手がかりを得ることを目指します。
方法
データの概要
今回は、「西武ライオンズ」の検索ボリュームとその順位との関係を探るため、Googleトレンドから取得した週ごとの検索データを目的変数として使用しました。
説明変数には、ライオンズの順位やシーズンイベント(開幕、オールスター、ポストシーズンなど)を表すダミー変数を組み込み、検索ボリュームに影響を与える可能性のある要素を考慮しています。
詳細なデータの内容については後述します。
状態空間モデルとは
状態空間モデルは、観測された時系列データを「状態方程式」と「観測方程式」を用いて分解することで、未観測の「状態」を推定することができる統計分析のフレームワークです。
状態方程式や観測方程式の形を変更して人間の直感を柔軟にモデルに組み込める点や、非定常な時系列データもそのまま扱える点などに強みがあります。
状態空間モデルについては以下の記事や、同著者が出版されている書籍『時系列分析と状態空間モデルの基礎』が非常に参考になります。
今回のケースでは、観測方程式でモデル化されたデータ(「西武ライオンズ」のGoogle検索ボリューム)は、状態方程式にもとづく「状態」と外生変数(順位やプロ野球のイベントなど)から推定されます。この手法を用いることで、目的変数を自己相関、外生変数の効果、ランダムなノイズに分解して分析することができます。
3 データ
今回扱うデータの範囲と概要は以下の通りです。
- 時系列データの集計単位:週ごと
- 集計期間:2022-02-01 ~ 2025-01-31
- データのサイズ:156時点
目的変数
説明変数
なお、順位とオンシーズンダミーはそのままモデルに組み込むのではなく、少し工夫を加えます。詳細は後述します。
データの探索
主要な変数の推移
下図は、「西武ライオンズ」検索ボリュームの推移です。
シーズン中かオフシーズンかによって、検索ボリュームに大きな差があることが読み取れます。
また、ライオンズの順位の推移(下図)と見比べると、高い順位だった2022年シーズンと比べて、2023年や2024年は検索ボリュームが小さい傾向にあることが読み取れます。
特に2024年シーズンは順位が極めて低く、検索ボリュームも小さいです(涙)
目的変数の自己相関
検索ボリュームの自己相関係数も確認しておきます。
下のコレログラムをみると、検索ボリュームに明らかな自己相関があることがわかります。そのため、通常の最小二乗法(OLS)を用いた線形回帰モデルではなく、自己相関を考慮した時系列解析手法を用いる必要がありそうです。
4 状態空間モデルの構築
今回は、外生変数付きのローカルレベルモデルを用います。
状態方程式
$$ 状態_{t} = 状態_{t-1} + w_{t-1}, \ w_{t} \sim \mathrm{N} (0, \sigma_{w}^{2}) $$
観測方程式
$$ \begin{align*} 検索ボリューム_{t} &= 状態_{t} \\ &+ \beta_{順位} \times 順位_{t} \cdot オンシーズンダミー_{t} \\ &+ \beta_{オフシーズン} \times (1 – オンシーズンダミー_{t}) \\ &+ \beta_{キャンプ} \times キャンプダミー_{t} \\ &+ \beta_{開幕} \times 開幕ダミー_{t} \\ &+ \cdots \\ &+ v_{t}, \ v_{t} \sim \mathrm{N} (0, \sigma_{v}^{2}) \end{align*} $$
「順位」はシーズン中のみ1.0~6.0の値を取り、オフシーズンでは欠損値となります。説明変数が欠損値を含んだままでは状態空間モデルで扱えないので、オンシーズンダミーとの交差項としてモデルに組み込みます。
これにより、「順位×オンシーズンダミー」はシーズン中は1.0~6.0の値を取り、オフシーズン中は0を取る変数になります。
ただし、このままではオフシーズン中の値0が、「1位よりも良い順位」として扱われてしまいます。
そこで「1 – オンシーズンダミー」の項を追加することで、これに対処しています。この項はオフシーズンに1を取るダミー変数であり、その係数は「オフシーズンであることの効果」を表しています。
シーズン中の場合の観測方程式:
$$ \begin{align*} 検索ボリューム_{t} &= 状態_{t} \\ &+ \beta_{順位} \times 順位_{t} \\ &+ \beta_{キャンプ} \times キャンプダミー_{t} \\ &+ \cdots \\ \end{align*} $$
オフシーズンの場合の観測方程式:
$$ \begin{align*} 検索ボリューム_{t} &= 状態_{t} \\ &+ \beta_{オフシーズン} \times (1 – オンシーズンダミー_{t}) \\ &+ \beta_{キャンプ} \times キャンプダミー_{t} \\ &+ \cdots \\ \end{align*} $$
状態とパラメータの推定
上記のローカルレベルモデルの状態とパラメータを、カルマンフィルタと最尤法を用いて推定します。
Pythonのstatsmodels
ライブラリを用いて実装しました。
推定部分のコード(抜粋)は以下の通りです。なお、分析全体のコードは末尾の付録に記載しています。
### モデルの変数を定義 ###
# 目的変数:「西武ライオンズ」の検索ボリューム
endog = df["search_volume"]
# 説明変数
list_exog = [
"stnd_onseason", # 順位 × オンシーズンダミー
"dummy_offseason", # 1 - オンシーズンダミー
"dummy_camp", # キャンプダミー
"dummy_opening", # 開幕ダミー
"dummy_allstar", # オールスターダミー
"dummy_ps", # ポストシーズンダミー
"dummy_draft", # ドラフトダミー
"dummy_fest", # ファン感謝イベントダミー
"dummy_samurai", # サムライジャパン
"avg_attendance", # パリーグの平均観客数
]
exog = df[list_exog]
### ローカルレベルモデルの構築 ###
from statsmodels.tsa.statespace.structural import UnobservedComponents
# モデルのインスタンス化
local_level = UnobservedComponents(
endog,
exog=exog,
level="local level"
)
# パラメタの最尤推定
result_ll = local_level.fit(
method='bfgs',
maxiter=10000,
start_params=local_level.fit(method='nm', maxiter=10000).params, # 2段階でパラメタ推定
disp=False
)
# 結果の表示
result_ll.summary()
# 推定結果のプロット
result_ll.plot_components()
plt.show()
5 分析の結果
まず、モデルが予測した検索ボリューム(下図の青線)と実際の検索ボリューム(下図の黒線)の当てはまりを確認します。
期間全体を通じて、概ね予測値の95%信頼区間は実際の観測値を含んでおり、当てはまりは悪くなさそうです。
なお、予測値(青線)と実測値(黒線)の間に1時点分ラグがあるように見えるのは、予測値が前の時点の実測値をもとに計算されているからです。
次に、パラメータの推定値を確認します。
順位と検索ボリュームの関係
「順位×オンシーズンダミー」は検索ボリュームに負の影響を与えていることが示唆されます。
この変数が1単位増加する(順位が1つ下落する)と、検索ボリュームは平均的に約3.26単位減少する傾向にあると解釈できます。したがって、1位である場合と比べて、6位であることによって平均的に (-3.26×5=) 16.31だけ検索ボリュームが小さいことになります。
順位が下がることによってファンからの関心を失い、検索数が減少する可能性が示唆され、直感と整合的な結果が得られたと言えます。
ただし、p値は0.072であるため、有意水準5%の帰無仮説 \(\beta_{順位}=0\) を棄却できないことには注意が必要です。
他の説明変数と検索ボリュームの関係
「1 – オンシーズンダミー」の係数(すなわち、オフシーズンであることの効果)は約-29.81であり、5%水準で統計的に有意に0と異なるという結果が得られました。シーズン中と比べてオフシーズンの検索ボリュームは約29.81単位ほど少ないと解釈され、プロ野球がもつ季節性(シーズン中とオフシーズンという大きな隔たり)を捉えることができていそうです。
順位の寄与度を可視化
「西武ライオンズ」検索ボリュームに対する順位の寄与度を可視化してみます。
下図の黒い実線は検索ボリュームの観測値を表し、青い破線は「順位」を除く全要素(状態・他の説明変数)によって計算される検索ボリュームの予測値を表しています。
オレンジ色の領域はライオンズの順位の寄与を表しており、下位の期間が長かった2023、2024年シーズンは特にネガティブな効果が大きかったことが読み取れます。
6 分析の問題点
今回実施した分析の問題点や限界について述べます。
データサイズの限界
今回の目的変数は、Googleトレンドから取得した週次の検索ボリューム(156週分)でした。
本来であれば日次の検索ボリュームを取得したかったところですが、Googleトレンドの仕様上、複数年にわたる検索ボリュームのデータは週次でしか取得できなかったため、今回は週次のデータを用いました。
データサイズが日次の場合と比べ7分の1になっていることで、少ないデータでパラメータを推定せざるを得ず、結果として係数の推定値の標準誤差が大きくなってしまった可能性があります。
順位の効果の非均一性
今回のモデルでは、順位の効果は均一であることを仮定しました。
1位→2位と5位→6位で、検索ボリュームの変動が等しいことを仮定していますが、現実にはこの仮定は正しくない可能性もあります。
係数の解釈に関する注意点
今回のモデルで推定された係数の推定値は、目的変数への因果効果であるとは限らない点に注意が必要です。
より厳密に「順位→検索ボリューム」への因果効果を求めたい場合には、状態空間モデルとは別のアプローチを用いたほうがよいかもしれません。
7 まとめ
本記事では、「西武ライオンズ」の順位とGoogleでの検索ボリュームの関係を状態空間モデルを用いて分析しました。分析の結果、筆者の直感通り、チームの順位が下がると検索ボリュームも減少する傾向が確認できました。具体的には、1位から6位に順位が下がると、検索ボリュームは16単位ほど減少するという結果が得られました。
ただし、今回の分析にはいくつかの限界もあります。順位の効果を均一と仮定していることや、データの単位を週次としたことなどは、分析結果を解釈する際に考慮すべき点です。また、検索ボリュームと順位の関係は相関関係であり、厳密な因果関係の証明ではないことにも留意が必要です。
本記事が、プロ野球に関するデータ分析を実施したい方や、Pythonで状態空間モデルを用いて時系列解析を実施したい方の参考になれば幸いです。
そして何より、西武ライオンズには順位を上げて、ファンの関心を高めてほしいと願っています。
8 参考
状態空間モデルに関する参考資料
Logics of Blues 「状態空間モデル」
馬場真哉 『時系列分析と状態空間モデルの基礎』
Pythonによる実装の参考資料
statsmodels
公式ドキュメント
9 付録:分析用コード
必要なデータがcsvファイルとして得られた後のコードを以下に記します。
(実行環境:Python 3.11.9)
### 必要なライブラリのインポート ###
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import matplotlib.patches as mpatches
from matplotlib.collections import LineCollection
from matplotlib.dates import date2num
import japanize_matplotlib
import statsmodels.api as sm
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.stats.outliers_influence import variance_inflation_factor
from statsmodels.tsa.statespace.structural import UnobservedComponents
### データの準備 ###
# CSVファイルの読み込み
file_path = "任意のファイルパス"
df = pd.read_csv(file_path, parse_dates=["week"])
# 順位とシーズンダミーの交差項を作成
df["stnd_onseason"] = df["standings"].fillna(0)
# オフシーズンダミーの作成
df["dummy_offseason"] = 1 - df["dummy_onseason"]
### 「西武ライオンズ」検索ボリュームの推移 ###
plt.figure(figsize=(12, 6))
# 日付データを数値に変換
dates = date2num(df['week'])
# セグメント作成
points = np.array([dates, df['search_volume']]).T.reshape(-1, 1, 2)
segments = np.concatenate([points[:-1], points[1:]], axis=1)
# シーズン中とオフシーズンに基づいて色を設定
colors = [df['dummy_onseason'].iloc[i] for i in range(len(df)-1)]
# カラーマップと正規化の設定
norm = mcolors.Normalize(0, 1)
cmap = mcolors.LinearSegmentedColormap.from_list('Season', ['#1F77B4', '#FF7F0E'])
# LineCollectionの作成
lc = LineCollection(segments, cmap=cmap, norm=norm, linewidth=2)
lc.set_array(np.array(colors))
# プロットに追加
plt.gca().add_collection(lc)
# ポイントのプロット
plt.scatter(df['week'], df['search_volume'], c=df['dummy_onseason'], cmap=cmap, norm=norm, s=30)
# 最大値と最小値のポイントをマーク
max_val = df["search_volume"].max()
max_idx = df["search_volume"].idxmax()
max_date = df["week"].iloc[max_idx]
min_val = df["search_volume"].min()
min_idx = df["search_volume"].idxmin()
min_date = df["week"].iloc[min_idx]
# 最大値と最小値をプロット
plt.plot(max_date, max_val, 'ro', markersize=8, label='最大値')
plt.plot(min_date, min_val, 'go', markersize=8, label='最小値')
# 注釈を追加
plt.annotate(f'最大値: {max_val}', xy=(max_date, max_val), xytext=(60, 7.5), textcoords='offset points', ha='center', bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="gray", alpha=0.8))
plt.annotate(f'最小値: {min_val}', xy=(min_date, min_val), xytext=(-50, -15), textcoords='offset points', ha='center', bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="gray", alpha=0.8))
# 凡例を作成
onseason_patch = mpatches.Patch(color='#FF7F0E', label='シーズン中')
offseason_patch = mpatches.Patch(color='#1F77B4', label='オフシーズン')
# プロットの美化
plt.grid(True, linestyle='--', alpha=0.5)
plt.title('「西武ライオンズ」の検索ボリュームの推移', fontsize=16)
plt.xlabel('週')
plt.ylabel('検索ボリューム(相対値)')
plt.xlim(df['week'].min(), df['week'].max())
plt.ylim(df['search_volume'].min() - 10, df['search_volume'].max() + 10)
plt.xticks(rotation=45)
plt.legend(handles=[onseason_patch, offseason_patch,
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='r', markersize=3, label='最大値'),
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='g', markersize=3, label='最小値')],
loc="best", fontsize="small")
plt.tight_layout()
plt.show()
### 順位の推移 ###
plt.figure(figsize=(12, 6))
# 順位の推移をプロット
plt.plot(df["week"], df["standings"], color='#1F77B4', linewidth=2, marker='o', markersize=5, alpha=1, label='順位')
# y軸を反転
plt.gca().invert_yaxis()
# プロットの美化
plt.grid(True, linestyle='--', alpha=1)
plt.title('西武ライオンズの順位の推移', fontsize=16)
plt.xlabel('週')
plt.ylabel('順位')
plt.xticks(rotation=45)
plt.legend(loc="best", fontsize="small", markerscale=0.8)
plt.tight_layout()
plt.show()
### 自己相関関数のプロット ###
plt.figure(figsize=(12, 6))
plot_acf(df['search_volume'], lags=53, alpha=0.05)
plt.title('「西武ライオンズ」検索ボリュームの自己相関係数', fontsize=12)
plt.tight_layout()
plt.show()
### 状態空間モデルの変数を定義 ###
# 目的変数:西武ライオンズの検索ボリューム
endog = df["search_volume"]
# 説明変数のリスト
list_exog = [
"stnd_onseason", # 順位 × オンシーズンダミー
"dummy_offseason", # オフシーズンダミー
"dummy_camp", # キャンプダミー
"dummy_opening", # 開幕ダミー
"dummy_allstar", # オールスターダミー
"dummy_ps", # ポストシーズンダミー
"dummy_draft", # ドラフトダミー
"dummy_fest", # ファン感謝イベントダミー
"dummy_samurai", # サムライジャパン
"avg_attendance", # パリーグの平均観客数
]
exog = df[list_exog]
### ローカルレベルモデルの構築 ###
local_level = UnobservedComponents(endog, exog=exog, level="local level")
# パラメータの最尤推定
result_ll = local_level.fit(
method='bfgs',
maxiter=10000,
start_params=local_level.fit(method='nm', maxiter=10000).params, # 2段階でパラメータ推定
disp=False
)
# 結果の表示
print(result_ll.summary())
# 推定結果のプロット
result_ll.plot_components()
plt.show()
### 順位の寄与度を可視化 ###
# 状態の推定値を抽出
states_df = pd.DataFrame(result_ll.smoothed_state.T, columns=result_ll.model.state_names, index=endog.index)
mu_t = states_df["level"]
# 外生変数の寄与を計算
exog_contribs = pd.DataFrame(index=endog.index)
for col in exog.columns:
param_name = "beta." + col
exog_contribs[col] = exog[col] * result_ll.params[param_name]
# 順位の寄与のみ抽出
season_contrib = pd.DataFrame(index=endog.index)
if "stnd_onseason" in exog_contribs.columns:
season_contrib["stnd_onseason"] = exog_contribs["stnd_onseason"]
# 順位以外の寄与を合計
other_vars = [col for col in exog_contribs.columns if col != "stnd_onseason"]
other_contribs_sum = exog_contribs[other_vars].sum(axis=1) if other_vars else pd.Series(0, index=endog.index)
# ベースライン + 順位以外の寄与
baseline = mu_t + other_contribs_sum
# 検索ボリュームの予測値
predicted = baseline + season_contrib["stnd_onseason"] if "stnd_onseason" in exog_contribs.columns else baseline
# グラフ作成
fig, ax = plt.subplots(figsize=(20, 10))
time = df["week"] if "week" in df.columns else df.index
# ベースラインをプロット
ax.plot(time, baseline, 'b--', lw=3.5, label='順位を除く全て要素による予測値', zorder=3, alpha=0.8)
# シーズン効果を積み上げエリアとして表示
if "stnd_onseason" in season_contrib.columns:
ax.fill_between(time, baseline, baseline + season_contrib["stnd_onseason"], label='順位効果', alpha=0.7, color='#FF7F0E')
# 観測値を実線で表示
ax.plot(time, endog, 'k-', lw=3.5, label='「西武ライオンズ」検索ボリューム(観測値)', zorder=2, alpha=0.8)
# 凡例とパラメータ値の表示
handles, labels = ax.get_legend_handles_labels()
param_labels = labels.copy()
if "stnd_onseason" in exog.columns:
season_index = labels.index('順位効果')
param_name = "beta.stnd_onseason"
param_value = result_ll.params[param_name]
param_labels[season_index] = f'順位の寄与 (β={param_value:.2f})'
# 凡例とグラフの美化
ax.legend(handles, param_labels, loc='upper left', bbox_to_anchor=(1.01, 1), fontsize=16)
ax.set_title("順位の寄与度", fontsize=20)
ax.set_xlabel("週", fontsize=14)
ax.set_ylabel("検索ボリューム(相対値)", fontsize=14)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()