Jouez avec la modélisation statistique: quantifiez la force des équipes de la J-League avec Stan et Python

introduction

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.

La version principale de cette fois

Collecter des données

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
... ... ... ... ... ... ...

En pensant

Pensez au mécanisme par lequel les résultats de correspondance sont produits

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.

Ecrire le modèle en Stan

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])));
        }
    }
}
"""

explication facile

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.

bloc de données

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.

bloc de paramètres

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.

bloc modèle

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.

bloc de quantités générées

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.

Autre

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 ...

Calculer avec le modèle et les données

Définissez quelques fonctions utiles

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.

Exécuter

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)

résultat

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
... ... ... ... ... ... ... ... ... ... ...

Voir les résultats

Estimation de la force de l'équipe

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).

à la fin

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

Jouez avec la modélisation statistique: quantifiez la force des équipes de la J-League avec Stan et Python
Livre de canard implémenté en Python "Modélisation statistique Bayes avec Stan et R"
Fractal pour faire et jouer avec Python
"Introduction à l'analyse de données par modélisation statistique bayésienne à partir de R et Stan" implémenté en Python
Jouez avec 2016-Python
Notes de lecture (en Python et Stan) pour une introduction à la modélisation statistique pour l'analyse de données (Midorimoto)
[Python] Comment jouer avec les variables de classe avec décorateur et métaclasse
[Jouons avec Python] Traitement d'image en monochrome et points
Jouez avec les archives de Mastodon dans les réponses et les favoris de Python 2 Count
Jouez avec le mécanisme de mot de passe de GitHub Webhook et Python
Programmation avec Python et Tkinter
Chiffrement et déchiffrement avec Python
Python et matériel - Utilisation de RS232C avec Python -
[Python] Jouez avec le Webhook de Discord.
python avec pyenv et venv
Fonctionne avec Python et R
Introduction à la modélisation statistique bayésienne avec python ~ Essai de régression linéaire avec MCMC ~
Communiquez avec FX-5204PS avec Python et PyUSB
Briller la vie avec Python et OpenCV
Robot fonctionnant avec Arduino et python
Installez Python 2.7.9 et Python 3.4.x avec pip.
Réseau neuronal avec OpenCV 3 et Python 3
Modulation et démodulation AM avec python
Scraping avec Node, Ruby et Python
Grattage avec Python, Selenium et Chromedriver
Grattage avec Python et belle soupe
Jouons avec Excel avec Python [Débutant]
Encodage et décodage JSON avec python
Introduction à Hadoop et MapReduce avec Python
Lire et écrire NetCDF avec Python
Lire et écrire du CSV avec Python
Intégration multiple avec Python et Sympy
Coexistence de Python2 et 3 avec CircleCI (1.0)
Jeu Sugoroku et jeu d'addition avec Python
Modulation et démodulation FM avec Python