はじめに
先日 StanとRでベイズ統計モデリング という本を読みまして、統計モデリングをやってみたい気持ちになりました。とても良い本ですのでオススメです。
StanはRからだけでなく、PythonライブラリのPyStanから使うことができます。今回は、2016年のサッカーJ1リーグの各チームの強さ(のような値)を試合結果から推定するモデルを作ってみることにします。
今回の主なVersion
- Python 3.5.1
- pystan==2.14.0.0
データを集める
Jリーグの試合結果は http://www.jleague.jp/stats/SFTD01.html などから調べることができます。
そこから何らかの方法で2016年の 1stステージの 各チームの対戦結果の集めた以下のデータがあるとします。
https://gist.github.com/mokemokechicken/a26eb7230e51fba7018476f7706e5b0b
away_score | away_team | date | home_score | home_team | score | visitors |
---|---|---|---|---|---|---|
1 | 名古屋 | 02/27(土) | 0 | 磐田 | 0-1 | 14333 |
1 | 川崎F | 02/27(土) | 0 | 広島 | 0-1 | 18120 |
1 | 福岡 | 02/27(土) | 2 | 鳥栖 | 2-1 | 19762 |
... | ... | ... | ... | ... | ... | ... |
思うこと
- チーム数は 18 である
- 1stステージは 各チーム同士が1回だけ対戦するので試合数は 153(
=18*17/2
) - サッカーではホーム&アウェイという程だし、対戦における地元と敵地の違いがありそう
試合結果が生まれるメカニズムを考える
試合結果(スコア)がどのように生じるかを以下のように仮定してみます。
- 各チームは 攻撃力 防御力 ホーム力 というのがあるとする。
- 攻撃力が高いほど得点が多く、防御力が高いほど失点が少ない
- ホームゲームにおいては、攻撃力、防御力ともにアドバンテージが付与される(=ホーム力)
とします。まあ、ここまではそんなにおかしな話ではなさそうです。
次に、統計の世界に入って考えてみます。
まず「得点」はポアソン分布に従うと適当に仮定します。
つまり、
あるチームのある試合の得点 ~ Poisson(そのチームの攻撃力 - 対戦チームの防御力)
という感じです。 (ちなみに ~
は「分布に従う」という意味です。)
ただし、ポアソン分布のパラメータは負の値を取れないので、exp
に便宜的に乗っけて、さらにホーム力を加味すると、
あるホームチームのある試合の得点 ~ Poisson(exp(
(ホーム力 + そのホームチームの攻撃力) - アウェイチームの防御力
))
になるとします。
また、対称的に
あるホームチームのある試合の失点 ~ Poisson(exp(
アウェイチームの攻撃力 - (ホーム力 + そのホームチームの防御力)
))
とします。
モデルをStanで記述する
こんな感じになりました。
model_code = """
data {
int N; // N Games
int K; // K Teams
int Th[N]; // Home Team ID
int Ta[N]; // Away Team ID
int Sh[N]; // Home Team score point
int Sa[N]; // Away Team score point
}
parameters {
real atk[K];
real def[K];
real home_power[K];
real<lower=0> sigma;
real<lower=0> hp_sigma;
}
model {
for (k in 1:K) {
atk[k] ~ normal(0, sigma);
def[k] ~ normal(0, sigma);
home_power[k] ~ normal(0, hp_sigma);
}
for (n in 1:N) {
Sh[n] ~ poisson(exp(
(home_power[Th[n]] + atk[Th[n]]) -
(def[Ta[n]])
));
Sa[n] ~ poisson(exp(
(atk[Ta[n]]) -
(def[Th[n]] + home_power[Th[n]])
));
}
}
generated quantities {
real games[K, K, 2];
for (th in 1:K) {
for (ta in 1:K) {
games[th, ta, 1] = poisson_rng(exp((home_power[th] + atk[th]) - (def[ta])));
games[th, ta, 2] = poisson_rng(exp((atk[ta]) - (def[th] + home_power[th])));
}
}
}
"""
簡単な説明
Stanについては、先に紹介した本がとても参考になりますが、ここでも簡単に説明をしてみます。
dataブロック
data {
int N; // N Games
int K; // K Teams
int Th[N]; // Home Team ID
int Ta[N]; // Away Team ID
int Sh[N]; // Home Team score point
int Sa[N]; // Away Team score point
}
このdata
ブロックは、外部から与えられるデータを宣言しています。
先程集めたデータをこの名前で後程注入することになります。
parametersブロック
parameters {
real atk[K];
real def[K];
real home_power[K];
real<lower=0> sigma;
real<lower=0> hp_sigma;
}
このparameters
ブロックは、推定したいパラメータを記述します。
今回は、攻撃力(atk)、防御力(def)、ホーム力(home_power)がメインです。
<lower=0>
というのは、パラメータがとり得る範囲を表します。
modelブロック
model {
for (k in 1:K) {
atk[k] ~ normal(0, sigma);
def[k] ~ normal(0, sigma);
home_power[k] ~ normal(0, hp_sigma);
}
for (n in 1:N) {
Sh[n] ~ poisson(exp(
(home_power[Th[n]] + atk[Th[n]]) -
(def[Ta[n]])
));
Sa[n] ~ poisson(exp(
(atk[Ta[n]]) -
(def[Th[n]] + home_power[Th[n]])
));
}
}
このmodel
ブロックは、入力データと推定したいパラメータの関係を確率分布を使って表現します。
先程の「メカニズム」もここで表現します。
また、各チームの攻撃力や防御力などは「平均0の分散sigma
の正規分布」に従うという仮定をおきます。
このsigma
も(ついでに)推定するパラメータになります。
こうしないと計算(MCMC)が収束しませんでした(全値がいろんな範囲値で収束してしまうので)。まあ「とりあえずこの辺の値で収まって欲しい」という希望を書いた感じでしょうか。
面白いのが sigma
を固定の1
と書くより、推定させたほうが計算が収束しやすかったことです(収束したsigma
は1よりだいぶ小さかった)。1とハードコードした方が範囲を限定した気持ちになりますが、そうでもない、というのが面白いです。
generated quantitiesブロック
generated quantities {
real games[K, K, 2];
for (th in 1:K) {
for (ta in 1:K) {
games[th, ta, 1] = poisson_rng(exp((home_power[th] + atk[th]) - (def[ta])));
games[th, ta, 2] = poisson_rng(exp((atk[ta]) - (def[th] + home_power[th])));
}
}
}
このgenerated quantities
ブロックはオマケみたいなものです。
収束した値を使って、別途違う計算をしてくれる機能です。
ここでは、各チームのパラメータを使って、各チームが対戦したときの試合のスコアをサンプリングしてもらっています。別途Python側で計算してもよいのですが、計算ロジックはModelと一致させないといけないので、ここで管理したほうがメンテナンスが楽そうです。
その他
他に transformed parameters
ブロックというのがあって、data
やparameters
の変数を使って、別変数を定義することができます。
今回の (home_power[th] + atk[th]) - (def[ta])
などの式もmodel
とgenerated quantities
に2回出てきているので、本来はまとめた方が良いのでしょうが、、、
モデルとデータで計算する
いくつか便利関数を定義しておく
from hashlib import sha256
import pickle
import time
import requests
import pandas as pd
from pystan import StanModel
def stan_cache(file=None, model_code=None, model_name=None, cache_dir="."):
"""Use just as you would `stan`"""
# http://pystan.readthedocs.io/en/latest/avoiding_recompilation.html#automatically-reusing-models
if file:
if file.startswith("http"):
model_code = requests.get(file).text
else:
with open(file) as f:
model_code = f.read()
code_hash = sha256(model_code.encode('utf8')).hexdigest()
if model_name is None:
cache_fn = '{}/cached-model-{}.pkl'.format(cache_dir, code_hash)
else:
cache_fn = '{}/cached-{}-{}.pkl'.format(cache_dir, model_name, code_hash)
try:
sm = pickle.load(open(cache_fn, 'rb'))
except:
start_time = time.time()
sm = StanModel(model_code=model_code)
print("compile time: %s sec" % (time.time() - start_time))
with open(cache_fn, 'wb') as f:
pickle.dump(sm, f)
return sm
def sampling_summary_to_df(fit4model):
"""
:param StanFit4model fit4model:
:return:
"""
s = fit4model.summary()
return pd.DataFrame(s["summary"], columns=s['summary_colnames'], index=s['summary_rownames'])
stan_cache
関数は、stanのモデルをキャッシュしたり、モデルのコードをHTTP経由でも取得するようにしたものです。便利です。
sampling_summary_to_df
関数は、modelをfitした結果を PandasのDataFrameにしてくれるものです。Jupyterなどで作業する時に見やすくなるメリットがあります。
実行する
import numpy as np
import pandas as pd
df = pd.read_csv("https://gist.githubusercontent.com/mokemokechicken/a26eb7230e51fba7018476f7706e5b0b/raw/90e1c26e172b92d5f52d53d0591a611e0258bd2b/2016-j1league-1st.csv")
team_list = list(sorted(set(df['away_team']) | set(df['home_team'])))
teams = dict(zip(range(1, len(team_list)+1), team_list))
for k, v in list(teams.items()):
teams[v] = k
model = stan_cache(model_code=model_code)
stan_input = {
"N": len(df),
"K": len(team_list),
"Th": df['home_team'].apply(lambda x: teams[x]),
"Ta": df['away_team'].apply(lambda x: teams[x]),
"Sh": df['home_score'],
"Sa": df['away_score'],
}
fitted = model.sampling(data=stan_input, seed=999)
sampling_summary_to_df(fitted).sort_values("Rhat", ascending=False)
結果
パラメータの推定結果のサマリーは例えば以下のようになります。
書籍では、「このRhat
が全ての推定パラメータで1.1未満になるとまあ計算が収束したとみなして良いだろう」ということですので、まあ一応収束したとみなすことにします。
ただ、n_eff
という「ちゃんと値がサンプリングできた回数」が100より小さいのはちょっと微妙ということ(特に区間推定するとき)なので、少し微妙なものもありますが、今回は良しとしておきます。
mean | se_mean | sd | 2.5% | 25% | 50% | 75% | 97.5% | n_eff | Rhat | |
---|---|---|---|---|---|---|---|---|---|---|
lp__ | -214.114891 | 1.542823 | 13.712915 | -237.604900 | -223.960556 | -215.747425 | -205.033248 | -183.817981 | 79.0 | 1.049519 |
hp_sigma | 0.094069 | 0.005835 | 0.063921 | 0.011408 | 0.044970 | 0.081836 | 0.132181 | 0.245524 | 120.0 | 1.031258 |
atk[12] | 0.048430 | 0.009507 | 0.158794 | -0.318161 | -0.052664 | 0.052359 | 0.155878 | 0.342186 | 279.0 | 1.018430 |
atk[16] | 0.225840 | 0.008570 | 0.158951 | -0.075769 | 0.115129 | 0.223274 | 0.330571 | 0.531899 | 344.0 | 1.013612 |
sigma | 0.220279 | 0.002456 | 0.056227 | 0.112035 | 0.182132 | 0.217550 | 0.255500 | 0.344382 | 524.0 | 1.011379 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
結果を眺める
チームの強さの推定値
チーム別にパラメータを回収します。
tk = {}
params = fitted.extract()
atks = params['atk']
defs = params['def']
home_power = params['home_power']
games = params['games']
for k, team in enumerate(team_list):
tk[team] = {
"atk": np.mean(atks[:, k]),
"def": np.mean(defs[:, k]),
"home_power": np.mean(home_power[:, k]),
}
tdf = pd.DataFrame(tk).T
とりあえず、「ホーム力」が高い順に並べてみます。
tdf.sort_values("home_power", ascending=False)
atk | def | home_power | |
---|---|---|---|
鹿島 | 0.225840 | 0.217699 | 0.068898 |
浦和 | 0.152638 | 0.063192 | 0.041830 |
神戸 | 0.099154 | -0.163383 | 0.030443 |
広島 | 0.293808 | -0.000985 | 0.029861 |
名古屋 | 0.130757 | -0.247969 | 0.015849 |
川崎F | 0.312593 | 0.087859 | 0.014728 |
新潟 | 0.010827 | -0.146252 | 0.011885 |
磐田 | 0.048430 | -0.105230 | 0.011843 |
仙台 | 0.037960 | -0.150151 | 0.005462 |
FC東京 | -0.072115 | 0.017485 | -0.005050 |
G大阪 | 0.087034 | -0.033666 | -0.005532 |
鳥栖 | -0.232595 | 0.103412 | -0.006186 |
柏 | 0.036172 | -0.053332 | -0.009568 |
大宮 | -0.040014 | 0.027842 | -0.020942 |
横浜FM | 0.059420 | -0.009322 | -0.021393 |
福岡 | -0.185292 | -0.130759 | -0.026643 |
甲府 | 0.002608 | -0.268061 | -0.037079 |
湘南 | -0.000052 | -0.184515 | -0.049440 |
となりました。
今回、強さの平均が0になっているはずなので、0より高いか低いかでJ1チームの平均よりどうか、が表現されていることになります。
ちなみに、2ndステージのデータでやるとまた全然違う結果になります(鹿島がだいぶ悪くなる)。
あくまで、1stステージのデータから計算したらこうなったというだけなので、あしからず。
考えてみれば当たり前なのですが、2016年1stステージの順位表に近い感じになります。
https://ja.wikipedia.org/wiki/2016%E5%B9%B4%E3%81%AEJ1%E3%83%AA%E3%83%BC%E3%82%B0#.E9.A0.86.E4.BD.8D.E8.A1.A8
特に「総得点、総失点」と「攻撃力、防御力」は概ね相関がありそうです(無いならないで困りますが)。
さいごに
この予測モデルで2ndステージのTotoを買ったらどうなるか、、、とシミュレーションしてみようと思ったのですが、なんかひどい結果になりそうなのでやめました(笑)。もう少し、短期間で対戦密度が高ければ良い予想もできそうです。
16節までのデータで17節を予想、くらいなら少しはマシなのかな??
こういうモデリングが簡単にできるようになったというのはすごいことだし、いろいろ楽しいですね。