L'autre jour, j'ai lu le livre Bayes Statistical Modeling with Stan and R, qui m'a donné envie d'essayer la modélisation statistique. Je le recommande car c'est un très bon livre.
Stan peut être utilisé non seulement depuis R, mais aussi depuis la bibliothèque Python PyStan. Cette fois, je vais créer un modèle qui estime la force (comme la valeur) de chaque équipe dans la Ligue de football J1 2016 à partir des résultats du match.
Vous pouvez consulter les résultats des matchs de la J-League sur http://www.jleague.jp/stats/SFTD01.html. Supposons que vous ayez les données suivantes collectées à partir des résultats de match de chaque équipe dans la 1ère étape de 2016 d'une manière ou d'une autre. https://gist.github.com/mokemokechicken/a26eb7230e51fba7018476f7706e5b0b
away_score | away_team | date | home_score | home_team | score | visitors |
---|---|---|---|---|---|---|
1 | Nagoya | 02/27(sol) | 0 | Iwata | 0-1 | 14333 |
1 | Kawasaki F | 02/27(sol) | 0 | Hiroshima | 0-1 | 18120 |
1 | Fukuoka | 02/27(sol) | 2 | Torisu | 2-1 | 19762 |
... | ... | ... | ... | ... | ... | ... |
= 18 * 17 / 2
)Supposons comment le résultat du match (score) est généré comme suit.
ça ira. Eh bien, cela ne semble pas cette histoire étrange pour l'instant.
Ensuite, entrons dans le monde des statistiques. Premièrement, on suppose à juste titre que le «score» suit la distribution de Poisson. En d'autres termes
Score d'une partie d'une équipe ~ Poisson (puissance d'attaque de cette équipe-puissance de défense de l'équipe adverse)
C'est comme ça. (Au fait, ~
signifie "suivre la distribution".)
Cependant, les paramètres de la distribution de Poisson ne peuvent pas prendre de valeurs négatives, donc si vous prenez ʻexp` pour plus de commodité et ajoutez de l'énergie domestique,
Marquer un match pour une équipe à domicile~ Poisson(exp(
(Puissance domestique+Puissance d'attaque de l'équipe à domicile) -Défense de l'équipe à l'extérieur
))
Supposer que Également symétriquement
A concédé un match dans une équipe à domicile~ Poisson(exp(
Puissance d'attaque de l'équipe à l'extérieur- (Puissance domestique+La défense de l'équipe locale)
))
ça ira.
Il s'est avéré être quelque chose comme ça.
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])));
}
}
}
"""
Le livre que j'ai présenté plus tôt est très utile pour Stan, mais je vais l'expliquer brièvement ici aussi.
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
}
Ce bloc data
déclare des données externes.
Les données collectées précédemment seront injectées plus tard avec ce nom.
parameters {
real atk[K];
real def[K];
real home_power[K];
real<lower=0> sigma;
real<lower=0> hp_sigma;
}
Ce bloc paramètres
décrit les paramètres que vous souhaitez estimer.
Cette fois, l'objectif principal est la puissance d'attaque (atk), la puissance de défense (def) et la puissance domestique (home_power).
«<lower = 0>» représente la plage que le paramètre peut prendre.
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]])
));
}
}
Ce bloc modèle
exprime la relation entre les données d'entrée et les paramètres que vous souhaitez estimer à l'aide d'une distribution de probabilité.
Le "mécanisme" évoqué précédemment est également exprimé ici.
De plus, on suppose que la puissance offensive et la puissance défensive de chaque équipe suivent la "distribution normale de la distribution" sigma "avec une moyenne de 0".
Ce «sigma» est aussi (accessoirement) un paramètre estimé.
Sinon, le calcul (MCMC) ne convergerait pas (car toutes les valeurs convergeraient dans diverses valeurs de plage). Eh bien, il semble que j'ai écrit un espoir que "je veux que vous vous mettiez dans la valeur ici pour le moment".
Ce qui est intéressant, c'est qu'il était plus facile pour le calcul de converger (le «sigma» convergé était beaucoup plus petit que 1) plutôt que d'écrire le «sigma» comme un «1» fixe. Le codage en dur 1 me donne l'impression que la portée est limitée, mais il est intéressant de noter que ce n'est pas le cas.
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])));
}
}
}
Ce bloc «quantités générées» est comme un bonus. C'est une fonction qui effectue un calcul différent en utilisant la valeur convergée. Ici, les paramètres de chaque équipe sont utilisés pour échantillonner le score du match lorsque chaque équipe s'est affrontée. Vous pouvez le calculer séparément côté Python, mais comme la logique de calcul doit correspondre au modèle, il semble plus facile de le maintenir si vous le gérez ici.
Il existe un autre bloc paramètres transformés
, et vous pouvez utiliser les variables data
et parameters
pour définir différentes variables.
Des expressions telles que (home_power [th] + atk [th]) - (def [ta])
cette fois apparaissent également deux fois dans model
et les quantités générées
, il est donc préférable de les assembler. Je me demande, mais ...
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'])
La fonction stan_cache
met en cache le modèle stan et récupère également le code du modèle via HTTP. Pratique.
La fonction sampling_summary_to_df
transforme le résultat de l'ajustement du modèle en un Pandas DataFrame. Il a l'avantage d'être plus facile à voir lorsque vous travaillez avec 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)
Un résumé des résultats de l'estimation des paramètres est par exemple le suivant.
Dans le livre, "Si this Rhat
est inférieur à 1,1 pour tous les paramètres estimés, on peut considérer que le calcul a convergé", donc je le considérerai comme convergé pour le moment.
Cependant, il est un peu subtil que le "nombre de fois où la valeur peut être échantillonnée correctement" appelé n_eff
soit inférieur à 100 (surtout lors de l'estimation de l'intervalle), donc il y a quelques subtilités, mais cette fois je le laisserai comme bon.
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 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
Collectez les paramètres par équipe.
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
Pour le moment, je vais les organiser par ordre décroissant de "puissance domestique".
tdf.sort_values("home_power", ascending=False)
atk | def | home_power | |
---|---|---|---|
Kashima | 0.225840 | 0.217699 | 0.068898 |
Urawa | 0.152638 | 0.063192 | 0.041830 |
Kobe | 0.099154 | -0.163383 | 0.030443 |
Hiroshima | 0.293808 | -0.000985 | 0.029861 |
Nagoya | 0.130757 | -0.247969 | 0.015849 |
Kawasaki F | 0.312593 | 0.087859 | 0.014728 |
Niigata | 0.010827 | -0.146252 | 0.011885 |
Iwata | 0.048430 | -0.105230 | 0.011843 |
Sendai | 0.037960 | -0.150151 | 0.005462 |
FC Tokyo | -0.072115 | 0.017485 | -0.005050 |
G Osaka | 0.087034 | -0.033666 | -0.005532 |
Torisu | -0.232595 | 0.103412 | -0.006186 |
chêne | 0.036172 | -0.053332 | -0.009568 |
Omiya | -0.040014 | 0.027842 | -0.020942 |
Yokohama FM | 0.059420 | -0.009322 | -0.021393 |
Fukuoka | -0.185292 | -0.130759 | -0.026643 |
Kofu | 0.002608 | -0.268061 | -0.037079 |
Shonan | -0.000052 | -0.184515 | -0.049440 |
est devenu. Cette fois, la force moyenne doit être de 0, donc si elle est supérieure ou inférieure à 0, c'est plus que la moyenne de l'équipe J1.
En passant, si vous le faites avec les données de la 2ème étape, vous obtiendrez un résultat complètement différent (Kashima sera bien pire). Jusqu'au dernier, c'est comme ça quand on le calcule à partir des données de la 1ère étape, donc je ne suis pas sûr.
Si vous y réfléchissez, c'est naturel, mais c'est proche du classement de la 1ère étape 2016. 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 En particulier, il semble y avoir une corrélation générale entre «le total des points et le total des buts» et «la puissance d'attaque et la puissance de défense» (bien que ce serait un problème s'il n'y en avait pas).
J'ai essayé de simuler ce qui se passerait si j'achetais le Toto 2ème étage avec ce modèle de prédiction, mais j'ai arrêté car cela semble être un résultat terrible (rires). Il semble que nous pouvons faire de bonnes prédictions si la densité de concurrence est élevée dans un court laps de temps. Prédire 17 versets avec des données jusqu'à 16 versets, est-ce un peu mieux ??
C'est incroyable que ce genre de modélisation soit devenu facile et très amusant.
Recommended Posts