Kto wygra finał mistrzostw świata w piłce nożnej 2018?

piłka nożna, przewidywanie wyniku, python, sztuczna inteligencja, data science

W ostatnich tygodniach cały świat ogarnęło piłkarskie święto. Myślę, że wszyscy zastanawiamy się, kto przez kolejne cztery lata będzie nosić tytuł mistrza. Piłkarska gorączka dotarła również na mojego bloga i wyjątkowo odkładam dziś na bok tematy związane z szeroko rozumianym biznesem i postaram się przewidzieć wynik finałowego meczu. 🙂


  1. Wstęp
  2. Cel projektu
  3. Założenia dotyczące projektu
  4. Opis zbioru danych
  5. Wczytanie danych
  6. Przygotowanie zbiorów wejściowych
  7. Przygotowanie danych do modelowania
  8. Modelowanie
  9. Predykcja zwycięzcy MŚ
  10. Podsumowanie

Wstęp

W niedzielę 15 lipca 2018 Chorwacja zmierzy się z Francją w meczu o mistrzostwo. Choć ani ja, ani żaden z moich znajomych nie jest specjalistą od budowania tego typu modeli predykcyjnych, to wspólnie zastanawialiśmy się, jak mógłby wyglądać model charakteryzujący się wysoką skutecznością. Pomysłów było wiele. Doszliśmy do wniosku, że idealne rozwiązanie powinno zawierać kompleksowe dane o obu zespołach, składzie i dyspozycji piłkarzy w danym dniu oraz ostatnich meczach. Warto również umieścić wśród zmiennych inne charakterystyki zespołów, np. to jak drużyna sprawuje się w meczach o wysoką stawkę. Nie bez znaczenia jest też miejsce, czas i warunki panujące na boisku. Być może zastosowanie miałyby tu również metody estymacji poszczególnych zmiennych charakteryzujących zespoły, m.in. siła ataku, poziom gry obronnej.

Samo przygotowanie danych zajęłoby masę czasu, a do finału pozostało zaledwie kilkadziesiąt godzin. Uproszczam zatem problem i spróbuję zbudować model o relatywnie dobrej jakości z wykorzystaniem ograniczonego zbioru danych.

Cel projektu

Główny cel projektu jest dziś wyjątkowo klarowny i prosty: predykcja zwycięzcy finału MŚ.

Założenia dotyczące projektu

  • Algorytm: nie stawiam żadnych ograniczeń co do wyboru algorytmu. Liczy się skuteczność i przetestuję kilka najbardziej wysublimowanych rozwiązań.
  • Zmienne objaśniające: skupię się na zmiennych pochodzących z dwóch źródeł (historyczne wyniki meczów i ranking FIFA).
  • Miara jakości modelu: Accuracy.

Opis zbioru danych

W projekcie wykorzystam dwa zbiory danych:

Wczytanie danych

Wczytuję wszystkie niezbędne biblioteki. Lista jest dosyć krótka. Nie narzucam sobie żadnych ograniczeń co do algorytmów, dlatego odpalę dwie „armaty”: XGBoost GradientBoostingClassifier z biblioteki sklearn 🙂

import pandas as pd
import numpy as np
import xgboost
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split, cross_val_score, RandomizedSearchCV
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, roc_curve, auc
from scipy.stats import randint
Wczytanie zbioru z wynikami spotkań
matches = pd.read_csv('data//long/matches.csv')

Pogląd danych.

date home_team away_team home_score away_score tournament city country neutral
0 1872-11-30 Scotland England 0 0 Friendly Glasgow Scotland False
1 1873-03-08 England Scotland 4 2 Friendly London England False
2 1874-03-07 Scotland England 2 1 Friendly Glasgow Scotland False
3 1875-03-06 England Scotland 2 2 Friendly London England False
4 1876-03-04 Scotland England 3 0 Friendly Glasgow Scotland False

Sprawdzam typy zmiennych.

matches.dtypes
[out]:
date          object
home_team     object
away_team     object
home_score     int64
away_score     int64
tournament    object
city          object
country       object
neutral         bool
dtype: object

Wszystko wygląda w porządku z wyjątkiem zmiennej „date”. Zmieniaj jej typ, tak by była widoczna w tabeli jako data.

matches['date'] = pd.to_datetime(matches['date'], format = '%Y-%m-%d')

Mając na uwadze wymagania algorytmów, zmieniam wartości, jakie przyjmuje zmienna „neutral”.

matches['neutral'].replace([True, False], [1, 0], inplace = True)

Zmieniam również nazwy zmiennych. W finale żadna z drużyn nie jest gospodarzem. W zbiorze znajdują się jeszcze mecze, które odbywały się na nieneutralnym terenie, ale w kolejnych krokach usunę je. Zabieg ten ma na celu uczenie modelu jedynie na meczach, które odbywały się w warunkach możliwie podobnych do spotkania finałowego.

matches.columns = ['date', 'team_1', 'team_2', 'score_1', 'score_2', 'tournament', 'city', 'country', 'neutral']
Wczytanie rankingu FIFA

Ranking FIFA i wszystkie widoczne w nim zmienne będą głównymi predyktorami wyników spotkań. W zbiorze znajdują się kolejne publikacje rankingów, od 1993 roku. Połączę je ze spotkaniami w taki sposób, by do każdego meczu przypisane były aktualne pozycje obu zespołów i punkty przyznane przez FIFA w ostatnim rankingu opublikowanym przed meczem.

fifa_ranking = pd.read_csv('data//long/fifa_ranking.csv')

Pogląd danych.

fifa_ranking.sort_values('rank_date').head()
rank country_full country_abrv total_points previous_points rank_change cur_year_avg cur_year_avg_weighted last_year_avg last_year_avg_weighted two_year_ago_avg two_year_ago_weighted three_year_ago_avg three_year_ago_weighted confederation rank_date
0 1 Germany GER 0.0 57 0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 UEFA 1993-08-08
1 2 Italy ITA 0.0 57 0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 UEFA 1993-08-08
2 3 Switzerland SUI 0.0 50 9 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 UEFA 1993-08-08
3 4 Sweden SWE 0.0 55 0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 UEFA 1993-08-08
4 5 Argentina ARG 0.0 51 5 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 CONMEBOL 1993-08-08

Pierwsza publikacja rankingu (widoczna w zbiorze) odbyła się w 1993 roku, zatem w kolejnych krokach będę musiał usunąć spotkania, które odbyły się przed 8 sierpnia 1993.

Sprawdzam typy zmiennych.

fifa_ranking.dtypes
[out]:
rank                         int64
country_full                object
country_abrv                object
total_points               float64
previous_points              int64
rank_change                  int64
cur_year_avg               float64
cur_year_avg_weighted      float64
last_year_avg              float64
last_year_avg_weighted     float64
two_year_ago_avg           float64
two_year_ago_weighted      float64
three_year_ago_avg         float64
three_year_ago_weighted    float64
confederation               object
rank_date                   object
dtype: object

Podobnie jak w przypadku poprzedniego zbioru, tutaj również data została źle wczytana. Naprawiam ten błąd i zmieniam dodatkowo nazwy zmiennych.

fifa_ranking['rank_date'] = pd.to_datetime(fifa_ranking['rank_date'], format = '%Y-%m-%d')
fifa_ranking.columns = ['rank', 'country_full', 'country_abrv', 'total_points', 'previous_points', 'rank_change', 'cur_year_avg', 'cur_year_avg_weighted', 'last_year_avg', 'last_year_avg_weighted', 'two_year_ago_avg', 'two_year_ago_weighted', 'three_year_ago_avg', 'three_year_ago_weighted', 'confederation', 'date']

Przygotowanie zbiorów wejściowych

W tym punkcie skupię się na przygotowaniu i połączeniu obu zbiorów. Dodam nowe i usunę kilka zbędnych zmiennych.

Zbudowanie zmiennej celu

W teorii mecz może się zakończyć trzema wynikami: wygrana, przegrana, remis. W oparciu o liczbę strzelonych goli przez oba zespoły buduję więc zmienną celu.

matches = matches.assign(y = 0)
matches.loc[matches.score_1 > matches.score_2, 'y'] = 1
matches.loc[matches.score_1 < matches.score_2, 'y'] = 2
Usunięcie meczów zakończonych remisem

Skupiam się na wyznaczeniu zwycięzcy, ponieważ w finale nie ma mowy o końcowym remisie. Usuwam wszystkie remisy ze zbioru.

print(matches.shape)
matches = matches[matches.y != 0]
print(matches.shape)
[out]:
(39668, 10)
(30517, 10)

Z początkowych niemal 40 tysięcy spotkań zostało „jedynie” 30.5 tysiąca.

Usunięcie meczów, które odbyły się na nieneutralnym terenie

Finał odbędzie się na neutralnym terenie i żadna z reprezentacji nie będzie gospodarzem. Usuwam zatem wszystkie mecze, na które mogło mieć miejsce rozgrywania spotkania.

matches = matches[matches.neutral == 1]
print(matches.shape)
[out]: (7590, 10)

Pozostało 7590 spotkań w zbiorze. Tak duży ubytek był do przewidzenia. W piłce nożnej znaczną większość stanowią mecze eliminacyjne (do poszczególnych turniejów) i mecze towarzyskie. Imprezy takie jak mistrzostwa świta, czy też mistrzostwa Europy są wisienką na torcie i stanowią rzadkość, jeśli spojrzymy na cały zbiór.

Usuwam również niepotrzebną zmienną, która informowała nas o tym, czy mecz odbył się na neutralnym terenie.

matches.drop('neutral', axis = 1, inplace = True)
Usunięcie starych spotkań
fifa_ranking.date.describe()
[out]:
count                   57793
unique                    286
top       2016-12-22 00:00:00
freq                      211
first     1993-08-08 00:00:00
last      2018-06-07 00:00:00
Name: date, dtype: object

Zgodnie z powyższym, pierwszy ranking został opublikowany 8 sierpnia 1993. Usuwam więc wszystkie mecze, które odbyły się przed tym dniem. Muszę przyznać, że przez chwilę miałem obawę, czy nie dopuszczę tu do przecieku danych. Pozwalam przecież by predyktorami były zmienne z rankingu, który został opublikowany tego samego dnia, którego odbyło się spotkanie. Zakładam jednak, że przy budowaniu rankingu np. z 8 sierpnia 1993 roku nie były brane pod uwagę meczów, które odbyły się tego dnia. Dla przykładu ranking opublikowany przez FIFA 8 czerwca 2018 raczej nie uwzględnia spotkań, które zakończyły się 8 czerwca 2018 o godzinie 22:00 (a przynajmniej mam taką nadzieję) 😉

matches = matches[matches.date >= '1993-08-08']
print(matches.shape)
[out]: (4550, 9)

Po odfiltrowaniu pozostało 4550 meczów.

Dodanie identyfikatora (indeksu) pozycji w rankingu FIFA.

Do połączenia obu zbiorów mogę podejść na kilka sposobów. Jednym z nich jest łączenie bezpośrednio po nazwie państwa i dacie (rok i miesiąc) rozgrywania meczu/opublikowania rankingu. Nie jest to jednak najlepszym pomysłem, ponieważ:

  • Ranking FIFA nie jest publikowany co miesiąc. Po uwzględnieniu daty powstanie dużo pustych wartości.
  • Dojdzie do wycieku danych. Ranking opublikowany kilka dni po rozegraniu meczu (zwłaszcza przy meczach o wysoką stawkę) może sugerować wynik spotkania.

Muszę zatem napisać funkcję, która do zbioru z meczami przypisze indeks ze zbioru zawierającego rankingi FIFA. Nie chcę w zbiorze przypisywać bezpośrednio pozycji drużyny w rankingu, ponieważ zbiór „fifa_ranking” zawiera znacznie więcej informacji, które mnie interesują.

def find_current_ranking_position(team_1, team_2, date):
    _fifa_ranking = fifa_ranking[(fifa_ranking.date <= date)]
    
    _fifa_ranking_team_1 = _fifa_ranking[(_fifa_ranking.country_full == team_1)]
    _fifa_ranking_team_2 = _fifa_ranking[(_fifa_ranking.country_full == team_2)]
    
    if(_fifa_ranking_team_1.empty):
        _team_1_id = None
    else:
        _team_1_id = _fifa_ranking_team_1.sort_values('date', ascending = False).iloc[0:1].index[0]
    
    if(_fifa_ranking_team_2.empty):
        _team_2_id = None
    else:
        _team_2_id = _fifa_ranking_team_2.sort_values('date', ascending = False).iloc[0:1].index[0]
        
    return pd.Series([_team_1_id, _team_2_id])

Po drodze powstał niewielki problem: w rankingu FIFA Irlandia widnieje pod nazwą „Republic of Ireland”. Zmieniam więc nazwę, by zachować spójność w obu zbiorach.

matches.team_1.replace(['Ireland'], ['Republic of Ireland'], inplace = True)

Uruchamiam teraz funkcję, która przypisze dla każdego zespołu id z rankingu FIFA.

matches[['t1_id', 't2_id']] = matches.apply(lambda row: find_current_ranking_position(row['team_1'], row['team_2'], row['date']), axis = 1)
print(matches.head())
date team_1 team_2 score_1 score_2 tournament city country y t1_id t2_id
17890 1993-09-22 Mexico Cameroon 1 0 Friendly Los Angeles USA 1 13.0 23.0
17893 1993-09-22 San Marino Netherlands 0 7 FIFA World Cup qualification Bologna Italy 2 118.0 15.0
17907 1993-10-06 Mexico South Africa 4 0 Friendly Los Angeles USA 1 182.0 261.0
17927 1993-10-15 Korea DPR Iraq 3 2 FIFA World Cup qualification Doha Qatar 1 230.0 225.0
17929 1993-10-16 Iran Korea Republic 0 3 FIFA World Cup qualification Doha Qatar 2 202.0

W zbiorze pojawiły się dwa identyfikatory, z pomocą których połączę oba zbiory.

Połączenie obu zbiorów
matches = matches.merge(fifa_ranking, how = 'inner', left_on = 't1_id', right_index = True, suffixes = ['', '_1'])
matches = matches.merge(fifa_ranking, how = 'inner', left_on = 't2_id', right_index = True, suffixes = ['', '_2'])

Niestety sufiksy nie zostały nadane automatycznie w obu przypadkach. By zachować spójność w nazewnictwie zmiennych, ręcznie zmieniam nazwy.

matches.columns = ['date', 'team_1', 'team_2', 'score_1', 'score_2', 'tournament', 'city',
       'country', 'y', 't1_id', 't2_id', 'rank_1', 'country_full_1',
       'country_abrv_1', 'total_points_1', 'previous_points_1', 'rank_change_1',
       'cur_year_avg_1', 'cur_year_avg_weighted_1', 'last_year_avg_1',
       'last_year_avg_weighted_1', 'two_year_ago_avg_1', 'two_year_ago_weighted_1',
       'three_year_ago_avg_1', 'three_year_ago_weighted_1', 'confederation_1',
       'date_1', 'rank_2', 'country_full_2', 'country_abrv_2',
       'total_points_2', 'previous_points_2', 'rank_change_2',
       'cur_year_avg_2', 'cur_year_avg_weighted_2', 'last_year_avg_2',
       'last_year_avg_weighted_2', 'two_year_ago_avg_2',
       'two_year_ago_weighted_2', 'three_year_ago_avg_2',
       'three_year_ago_weighted_2', 'confederation_2', 'date_2']
Usunięcie zbędnych zmiennych

Usuwam zbędne zmienne, z których już nie będę korzystać.

matches.drop(['team_1', 'team_2', 'score_1', 'score_2', 'city', 'country', 't1_id', 't2_id', 'country_full_1', 'country_abrv_1', 'date_1', 'country_full_2', 'country_abrv_2', 'date_2'], axis = 1, inplace = True)

Ponownie sprawdzam rozmiar zbioru ABT. Przy operacji „inner join” część spotkań mogła zostać usunięta.

print(matches.shape)
[out]: (3352, 29)

Finalnie w zbiorze pozostały 3352 obserwacje.

Dodanie nowych zmiennych

Dodaję nowe zmienne. Ważną zmienną będzie „time_delta”, która określa, jak dawno odbył się mecz. Użyję jej jako wagi do modelu (parametr „sample_weight” w algorytmach biblioteki sklearn).

matches['time_delta'] = (matches['date'] - pd.to_datetime('2018-07-15', format = '%Y-%m-%d'))/np.timedelta64(1, 'M')
matches['year'] = matches.date.dt.year
matches['month'] = matches.date.dt.month
matches['day_of_week'] = matches.date.dt.dayofweek
matches['day_of_year'] = matches.date.dt.dayofyear
matches['is_year_end'] = matches.date.dt.is_year_end
matches['is_year_start'] = matches.date.dt.is_year_start
matches['quarter'] = matches.date.dt.quarter
matches['is_year_end'].replace([False, True], [0, 1], inplace = True)
matches['is_year_start'].replace([False, True], [0, 1], inplace = True)

Dopiero teraz mogę usunąć zbędną zmienną: „date”.

matches.drop(['date'], axis = 1, inplace = True)
Zmiana kodowania zmiennych

W zbiorze nadal są zawarte zmienne kategoryczne. Zmieniam ich kodowanie z pomocą LabelEncodera.

label_encoder = LabelEncoder()
label_encoder.fit(matches.tournament)

# turnieje
matches.tournament = label_encoder.transform(matches.tournament)

# konfederacje
label_encoder.fit(matches.confederation_1)
matches.confederation_1 = label_encoder.transform(matches.confederation_1)
matches.confederation_2 = label_encoder.transform(matches.confederation_2)

Przygotowanie danych do modelowania

Usunięcie braków danych
print(matches.isnull().any().any())
[out]: False

Brak braków 🙂

Usunięcie odstających wartości

Użyję XGBoost i GradientBoostingClassifier, które nie są wrażliwe na obserwacje odstające. Pomijam więc ten punkt.

Podział zbioru

Sprawdzam rozkład zmiennej celu w całym zbiorze.

print(matches.y.value_counts(normalize = True))
[out]:
1    0.555191
2    0.444809
Name: y, dtype: float64

Wykonam szereg operacji mających na celu podział zbioru (szczegóły zawarłem w komentarzu). Uwzględniam stratyfikację. Rozkład zmiennej celu jest dosyć równy, więc bez przeszkód mogę użyć Accuracy jako głównej miary jakości modelu.

# oddzielam zmienną celu od zmiennych objaśniających
y = matches.y.copy()
x = matches.drop('y', axis = 1)

# zamieniam wartości jakie przyjmuje zmienna celu
y.replace([1,2], [0, 1], inplace = True)

# dzielę zbiór na dwie części
x_tr, x_te, y_tr, y_te = train_test_split(x, y, test_size = 0.2, stratify = y, random_state = 20180715)

# oddzielam wagi, których użyję do uczenia modelu
weights_tr = x_tr.time_delta
weights_te = x_te.time_delta
print(y_tr.value_counts(normalize = True))
print(y_te.value_counts(normalize = True))
[out]:
0    0.555017
1    0.444983
Name: y, dtype: float64
0    0.555887
1    0.444113
Name: y, dtype: float64

Jak widać, po podziale proporcje w zbiorze zostały zachowane. Jeśli chodzi o wspomniane wcześniej wagi, to z góry zakładam, że mecze, które odbyły się przed dwudziestoma laty, mają mniejsze znaczenie dla jakości predykcji, niż mecze z np. roku ubiegłego. Czemu? Wszystko się zmienia i reguły decyzyjne, które były aktualne kiedyś, nie muszą być aktualne dziś. Poprzez wagi wskażę modelowi, które obserwacje są bardziej istotne.

Niestety zmienna „time_delta” przyjmuje jedynie ujemne wartości. Zgodnie z dokumentacją sklearn taka sytuacja nie jest pożądana. Wykonuję zatem jeszcze jedną transformację wag.

weights_tr = (weights_tr.min()*-1) - weights_tr
weights_te = (weights_te.min()*-1) - weights_te
Modelowanie

Podejście do modelowania obrane przeze mnie w ramach tego projektu jest dosyć proste. Dysponuję zbiorem podzielonym na dwie części:

  1. Część ucząca – wykonam na niej walidację krzyżową i posłuży mi ona do poszukiwania parametrów modelu.
  2. Część testująca – posłuży do walidacji jakości modelu na próbie „z zewnątrz”.

Zbudowałem dodatkowo trzeci zbiór złożony z jednej obserwacji (zmienne opisujące niedzielny mecz Chorwacja – Francja), który posłuży mi jedynie do wykonania predykcji. Jakość predykcji będę mógł sprawdzić dopiero w niedzielę, 15 lipca 2018 ok. godziny 19:00 🙂

Model 1

Opis modelu:

  • GradientBoostingClassifier.
  • Brak doboru parametrów.
gbc_1 = GradientBoostingClassifier()
cv = cross_val_score(gbc_1, x_tr, y_tr, cv = 10, scoring = 'accuracy', n_jobs=-1)
print('Średnie Accuracy: ' + str(cv.mean().round(3)))
print('Stabilność: ' + str((cv.std()*100/cv.mean()).round(3)) + '%')
[out]:
Średnie Accuracy: 0.697
Stabilność: 4.875%
gbc_1.fit(x_tr, y_tr)
print("Accuracy modelu na zbiorze testowym bez uwzględnienia wag: {}.".format(gbc_1.score(x_te, y_te).round(4)))
[out]: Accuracy modelu na zbiorze testowym bez uwzględnienia wag: 0.7139.
gbc_1.fit(x_tr, y_tr, sample_weight=weights_tr)
print("Accuracy modelu na zbiorze testowym z uwzględnieniem wag: {}.".format(gbc_1.score(x_te, y_te, sample_weight=weights_te).round(4)))
[out]: Accuracy modelu na zbiorze testowym z uwzględnieniem wag: 0.716.

Wynik 0.716 na zbiorze testowym. Jest nieźle, mając na uwadze niewielką liczbę obserwacji i zmienne objaśniające pochodzące z tylko jednego źródła 🙂

Model 2

Opis modelu:

  • XGBoost.
  • Brak doboru parametrów.
xgb_1 = xgboost.XGBClassifier()
cv = cross_val_score(xgb_1, x_tr, y_tr, cv = 10, scoring = 'accuracy', n_jobs=-1)
print('Średnie Accuracy: ' + str(cv.mean().round(3)))
print('Stabilność: ' + str((cv.std()*100/cv.mean()).round(3)) + '%')
[out]:
Średnie Accuracy: 0.693
Stabilność: 4.941%
xgb_1.fit(x_tr, y_tr)
print("Accuracy modelu na zbiorze testowym bez uwzględnienia wag: {}.".format(xgb_1.score(x_te, y_te).round(4)))
[out]: Accuracy modelu na zbiorze testowym bez uwzględnienia wag: 0.7139.
gbc_1.fit(x_tr, y_tr, sample_weight=weights_tr)
print("Accuracy modelu na zbiorze testowym z uwzględnieniem wag: {}.".format(xgb_1.score(x_te, y_te, sample_weight=weights_te).round(4)))
[out]: Accuracy modelu na zbiorze testowym z uwzględnieniem wag: 0.7087.

Wynik zbliżony, choć nieco gorszy niż w modelu pierwszym. Spróbuję powalczyć o choćby niewielką poprawę wyniku poprzez zmianę parametrów obu modeli.

Model 3

Opis modelu:

Definiuję zakres parametrów, który będę przeszukiwać.

params_rs = {'loss' : ('deviance', 'exponential'),
             'n_estimators' : randint(50,200),
             'max_depth' : randint(5, 50),
             'criterion' : ('friedman_mse', 'mse', 'mae'),
             'min_samples_split' : randint(2, 50),
             'min_samples_leaf' : randint(1, 50),
             'max_features' : ('auto', 'sqrt', 'log2'),
             'max_leaf_nodes' :  randint(1, 50)}
rs = RandomizedSearchCV(gbc_1, param_distributions=params_rs, n_iter = 20, n_jobs = -1, cv=5)
rs.fit(x_tr, y_tr)
print(rs.best_params_)

I już po kilkunastu minutach… 🙂

[out]:
{'criterion': 'friedman_mse',
 'loss': 'deviance',
 'max_depth': 27,
 'max_features': 'log2',
 'max_leaf_nodes': 11,
 'min_samples_leaf': 36,
 'min_samples_split': 7,
 'n_estimators': 72}

Zapisuję parametry do zmiennej i buduję oparty o nie model.

gbc_params = {'criterion': 'friedman_mse',
 'loss': 'deviance',
 'max_depth': 27,
 'max_features': 'log2',
 'max_leaf_nodes': 11,
 'min_samples_leaf': 36,
 'min_samples_split': 7,
 'n_estimators': 72}

# nowy model
gbc_2 = GradientBoostingClassifier(**gbc_params)

# walidacja krzyżowa
cv = cross_val_score(gbc_2, x_tr, y_tr, cv = 10, scoring = 'accuracy')
print('Średnie Accuracy: ' + str(cv.mean().round(3)))
print('Stabilność: ' + str((cv.std()*100/cv.mean()).round(3)) + '%')
[out]:
Średnie Accuracy: 0.702
Stabilność: 5.303%

Już podczas walidacji krzyżowej widać poprawę średniego Accuracy. Niestety nieco spadła stabilności modelu. Sprawdzę jeszcze, jak wygląda wynik na zbiorze testowym.

gbc_2.fit(x_tr, y_tr)
print("Accuracy modelu na zbiorze testowym bez uwzględnienia wag: {}.".format(gbc_2.score(x_te, y_te).round(4)))
Accuracy modelu na zbiorze testowym bez uwzględnienia wag: 0.7049.
gbc_2.fit(x_tr, y_tr, sample_weight=weights_tr)
print("Accuracy modelu na zbiorze testowym z uwzględnieniem wag: {}.".format(gbc_2.score(x_te, y_te, sample_weight=weights_te).round(4)))
Accuracy modelu na zbiorze testowym z uwzględnieniem wag: 0.7121.

W porównaniu do modelu 1 poprawiłem średni wynik w walidacji krzyżowej. Niestety nie udało się poprawić wyniku na zbiorze testowym.

Model 4

Opis modelu:

Podobnie jak w przypadku modelu 3, definiuję zakres parametrów, który będę przeszukiwać.

params_rs = {'max_depth':randint(15, 50),
             'n_estimators':randint(100,300),
             'booster' : ('gbtree', 'gblinear', 'dart'),
             'min_child_weight' : randint(1, 30),
             'max_delta_step' : randint(1, 20)}
rs = RandomizedSearchCV(xgb_1, param_distributions=params_rs, n_iter = 20, n_jobs = -1, cv=5)
rs.fit(x_tr, y_tr)

Zapisuję parametry do zmiennej i buduję oparty o nie model.

xgb_params = {'booster': 'gblinear',
'max_delta_step': 9,
'max_depth': 46,
'min_child_weight': 27,
'n_estimators': 106}

# nowy model
xgb_2 = xgboost.XGBClassifier(**xgb_params)

# walidacja krzyżowa
cv = cross_val_score(xgb_2, x_tr, y_tr, cv = 10, scoring = 'accuracy')
print('Średnie Accuracy: ' + str(cv.mean().round(3)))
print('Stabilność: ' + str((cv.std()*100/cv.mean()).round(3)) + '%')
[out]:
Średnie Accuracy: 0.707
Stabilność: 4.92%

Już podczas walidacji krzyżowej widać poprawę, zarówno średniego Accuracy, jak i stabilności modelu. Sprawdzę jeszcze, jak wygląda wynik na zbiorze testowym.

gbc_2.fit(x_tr, y_tr)
print("Accuracy modelu na zbiorze testowym bez uwzględnienia wag: {}.".format(gbc_2.score(x_te, y_te).round(4)))
Accuracy modelu na zbiorze testowym bez uwzględnienia wag: 0.7049.
gbc_1.fit(x_tr, y_tr, sample_weight=weights_tr)
print("Accuracy modelu na zbiorze testowym z uwzględnieniem wag: {}.".format(gbc_1.score(x_te, y_te, sample_weight=weights_te).round(4)))
Accuracy modelu na zbiorze testowym z uwzględnieniem wag: 0.7084.

Efekt podobny jak w przypadku GradientBoostingClassifier. Test krzyżowy na plus, zbiór testowy na minus. Nie mniej jest to wciąż niewielka różnica i mając na uwadze odchylenie standardowe wyników z testu krzyżowego, uznaję wynik za zadowalający.

Predykcja zwycięzcy MŚ

Przygotowuję dane wejściowe do modelu:

final_match = [21, 20, 945.18, 975,-2, 397.75, 397.75, 672.78, 336.39, 335.96, 100.79, 551.26, 110.25, 5, 7, 1198.13, 1166, 0, 520.12, 520.12, 856.75, 428.38, 393.65, 118.09, 657.68, 131.54, 5, 0, 2018, 7, 6, 196, 0, 0, 3]
final_match = pd.DataFrame(final_match, index = x_tr.columns)
print(final_match)
|   | tournament | rank_1 | total_points_1 | previous_points_1 | rank_change_1 | cur_year_avg_1 | cur_year_avg_weighted_1 | last_year_avg_1 | last_year_avg_weighted_1 | two_year_ago_avg_1 | two_year_ago_weighted_1 | three_year_ago_avg_1 | three_year_ago_weighted_1 | confederation_1 | rank_2 | total_points_2 | previous_points_2 | rank_change_2 | cur_year_avg_2 | cur_year_avg_weighted_2 | last_year_avg_2 | last_year_avg_weighted_2 | two_year_ago_avg_2 | two_year_ago_weighted_2 | three_year_ago_avg_2 | three_year_ago_weighted_2 | confederation_2 | time_delta |  year  | month | day_of_week | day_of_year | is_year_end | is_year_start | quarter |
|:-:|:----------:|:------:|:--------------:|:-----------------:|:-------------:|:--------------:|:-----------------------:|:---------------:|:------------------------:|:------------------:|:-----------------------:|:--------------------:|:-------------------------:|:---------------:|:------:|:--------------:|:-----------------:|:-------------:|:--------------:|:-----------------------:|:---------------:|:------------------------:|:------------------:|:-----------------------:|:--------------------:|:-------------------------:|:---------------:|:----------:|:------:|:-----:|:-----------:|:-----------:|:-----------:|:-------------:|:-------:|
| 0 |    21.0    |  20.0  |     945.18     |       975.0       |      -2.0     |     397.75     |          397.75         |      672.78     |          336.39          |       335.96       |          100.79         |        551.26        |           110.25          |       5.0       |   7.0  |     1198.13    |       1166.0      |      0.0      |     520.12     |          520.12         |      856.75     |          428.38          |       393.65       |          118.09         |        657.68        |           131.54          |       5.0       |     0.0    | 2018.0 |  7.0  |     6.0     |    196.0    |     0.0     |      0.0      |   3.0   |

Wszystkie zmienne z sufiksem „_1”, to zmienne opisujące zespół Chorwacki. Zmienne z przyrostkiem „_2” to zmienne objasniające Francji.

GradientBoostingClassifier dawał dziś nieco lepsze wyniki, dlatego to na modelu 3 wykonuję prognozę zwycięzcy finału.

gbc_2.predict_proba(final_match.transpose())
[out]: [0.21760278, 0.78239722]

Według zbudowanego modelu Francja (78%) pokona Chorwację (22%) w finale mistrzostw świata w piłce nożnej 🙂

Podsumowanie

Dziękuję Ci za dobrnięcie do końca. Mam nadzieję, że przypadł Ci do gustu nieco luźniejszy charakter tego projektu. Jeśli masz jakiekolwiek uwagi, pytania, lub spostrzeżenia, to proszę, podziel się nimi w komentarzu, lub skontaktuj się ze mną.

Poniżej znajduje się lista plików użytych, wspomnianych, bądź też utworzonych w projekcie:

photos: unsplash.com (Tom Grimbert), pixabay.com (RonnyK)

 

Podobał Ci się ten artykuł?

Jeśli tak, to zarejestruj się, by otrzymywać informacje o nowych wpisach. Dodatkowo w prezencie wyślę Ci bezpłatny poradnik 🙂

6 Komentarze

  1. Strasznie przypada mi do gustu dokładność opisywanych operacji.

    Zapiszę link do wpisu w notatniku, gdyż przyda mi się tu wrócić w celu przypomnienia sobie jak efektywnie analizować i łączyć dwa zbiory.

  2. Hej,

    Czemu przy zmianie nazw kolumn przypisujesz całą listę do df.columns zamiast rename(columns={1:2})? Drugie rozwiązanie zdaje się być szybsze i nie jest wrażliwe na kolejność/liczbę kolumn w danych źródłowych.

    Dodatkowo żeby sprawdzić czy inner join zadziałał dobrze można w pandasie skorzystać z argumentu validate przekazywanego do metody merge. Tam możesz okreslić np validate=’1:1′ i wiesz że wszystko się zjoinowało albo dostajesz wyjątek 🙂

    Ciekawy artykuł, czekam na więcej.
    Pozdrawiam

    • Hej Mateusz,

      Dzięki za ciekawy i merytoryczny komentarz 🙂

      1. „Czemu przy zmianie nazw kolumn przypisujesz całą listę do df.columns zamiast rename(columns={1:2})? Drugie rozwiązanie zdaje się być szybsze i nie jest wrażliwe na kolejność/liczbę kolumn w danych źródłowych.” – Nie mam na to wytłumaczenia. Przyzwyczajenie bierze górę, choć zdaję sobie sprawę z zagrożeń i alternatyw 😉 Rozwiązanie, które wymieniłeś jest zdecydowanie bezpieczniejsze.

      2. „Dodatkowo żeby sprawdzić czy inner join zadziałał dobrze można w pandasie skorzystać z argumentu validate przekazywanego do metody merge. Tam możesz okreslić np validate=’1:1′ i wiesz że wszystko się zjoinowało albo dostajesz wyjątek” – Dzięki za podpowiedź, sprawdzę to przy następnej okazji 🙂

  3. Hej Mateusz,

    dzięki, bardzo fajnie się czytało! Bardzo mi się podoba jak zbudowałeś zmienną wskaźnikową przy użyciu .assign() i .loc[]. Wydaje mi się to bardzo zgrabne. Ja zazwyczaj robiłem to jakoś inaczej i mam poczucie, że potrzebowałem do tego więcej linijek kodu. Wielkie dzięki za inspirację! 🙂

    Pozdrowienia!

Dodaj komentarz

Twój adres email nie zostanie opublikowany.


*